From 7af7219b01afb6a5a144f544a6cd56a67141a08a Mon Sep 17 00:00:00 2001 From: Matt Doran Date: Fri, 24 Jan 2025 05:50:56 +1100 Subject: [PATCH 0001/3148] Update Hydrawise maximum watering duration to meet the app limits (#136050) Co-authored-by: Robert Resch --- homeassistant/components/hydrawise/binary_sensor.py | 2 +- homeassistant/components/hydrawise/services.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/hydrawise/binary_sensor.py b/homeassistant/components/hydrawise/binary_sensor.py index 34c31d3ad16..83e8a8325f9 100644 --- a/homeassistant/components/hydrawise/binary_sensor.py +++ b/homeassistant/components/hydrawise/binary_sensor.py @@ -68,7 +68,7 @@ ZONE_BINARY_SENSORS: tuple[HydrawiseBinarySensorEntityDescription, ...] = ( ) SCHEMA_START_WATERING: VolDictType = { - vol.Optional("duration"): vol.All(vol.Coerce(int), vol.Range(min=0, max=90)), + vol.Optional("duration"): vol.All(vol.Coerce(int), vol.Range(min=0, max=1440)), } SCHEMA_SUSPEND: VolDictType = { vol.Required("until"): cv.datetime, diff --git a/homeassistant/components/hydrawise/services.yaml b/homeassistant/components/hydrawise/services.yaml index 64c04901816..bf90a8e23b3 100644 --- a/homeassistant/components/hydrawise/services.yaml +++ b/homeassistant/components/hydrawise/services.yaml @@ -10,7 +10,7 @@ start_watering: selector: number: min: 0 - max: 90 + max: 1440 unit_of_measurement: min mode: box suspend: From 8440a271528c39d9d240db765903208822961fc2 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 20 Jan 2025 23:59:12 +0100 Subject: [PATCH 0002/3148] Bump holidays to 0.65 (#136122) --- homeassistant/components/holiday/manifest.json | 2 +- homeassistant/components/workday/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/holiday/manifest.json b/homeassistant/components/holiday/manifest.json index 09943faf0a2..edf3ebe7f04 100644 --- a/homeassistant/components/holiday/manifest.json +++ b/homeassistant/components/holiday/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/holiday", "iot_class": "local_polling", - "requirements": ["holidays==0.64", "babel==2.15.0"] + "requirements": ["holidays==0.65", "babel==2.15.0"] } diff --git a/homeassistant/components/workday/manifest.json b/homeassistant/components/workday/manifest.json index bb5e6333b8b..4b9d072f747 100644 --- a/homeassistant/components/workday/manifest.json +++ b/homeassistant/components/workday/manifest.json @@ -7,5 +7,5 @@ "iot_class": "local_polling", "loggers": ["holidays"], "quality_scale": "internal", - "requirements": ["holidays==0.64"] + "requirements": ["holidays==0.65"] } diff --git a/requirements_all.txt b/requirements_all.txt index 28269469a78..c4ff74efd76 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1131,7 +1131,7 @@ hole==0.8.0 # homeassistant.components.holiday # homeassistant.components.workday -holidays==0.64 +holidays==0.65 # homeassistant.components.frontend home-assistant-frontend==20250109.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 265390966db..fddaad5f9ca 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -960,7 +960,7 @@ hole==0.8.0 # homeassistant.components.holiday # homeassistant.components.workday -holidays==0.64 +holidays==0.65 # homeassistant.components.frontend home-assistant-frontend==20250109.0 From 0512fc5e0c10ef3d52c6d35642930b05b33df3ba Mon Sep 17 00:00:00 2001 From: Makrit Date: Fri, 24 Jan 2025 07:49:33 +0000 Subject: [PATCH 0003/3148] Handle width and height placeholders in the thumbnail URL (#136227) --- homeassistant/components/twitch/coordinator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/twitch/coordinator.py b/homeassistant/components/twitch/coordinator.py index c61e80bd2b8..010a9e90ccc 100644 --- a/homeassistant/components/twitch/coordinator.py +++ b/homeassistant/components/twitch/coordinator.py @@ -122,7 +122,7 @@ class TwitchCoordinator(DataUpdateCoordinator[dict[str, TwitchUpdate]]): stream.game_name if stream else None, stream.title if stream else None, stream.started_at if stream else None, - stream.thumbnail_url if stream else None, + stream.thumbnail_url.format(width="", height="") if stream else None, channel.profile_image_url, bool(sub), sub.is_gift if sub else None, From e7a4f5fd2773c3cec0dbdb61325a60ed58131cc2 Mon Sep 17 00:00:00 2001 From: Claudio Ruggeri - CR-Tech <41435902+crug80@users.noreply.github.com> Date: Thu, 23 Jan 2025 22:12:02 +0100 Subject: [PATCH 0004/3148] Fix slave id equal to 0 (#136263) Co-authored-by: J. Nick Koston --- homeassistant/components/modbus/entity.py | 5 ++- homeassistant/components/modbus/modbus.py | 4 +- tests/components/modbus/test_init.py | 53 +++++++++++++++++++++++ 3 files changed, 60 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/modbus/entity.py b/homeassistant/components/modbus/entity.py index 90833516e59..d252528f6d4 100644 --- a/homeassistant/components/modbus/entity.py +++ b/homeassistant/components/modbus/entity.py @@ -79,7 +79,10 @@ class BasePlatform(Entity): """Initialize the Modbus binary sensor.""" self._hub = hub - self._slave = entry.get(CONF_SLAVE) or entry.get(CONF_DEVICE_ADDRESS, 0) + if (conf_slave := entry.get(CONF_SLAVE)) is not None: + self._slave = conf_slave + else: + self._slave = entry.get(CONF_DEVICE_ADDRESS, 1) self._address = int(entry[CONF_ADDRESS]) self._input_type = entry[CONF_INPUT_TYPE] self._value: str | None = None diff --git a/homeassistant/components/modbus/modbus.py b/homeassistant/components/modbus/modbus.py index 8c8a879ead6..fce831e9cd4 100644 --- a/homeassistant/components/modbus/modbus.py +++ b/homeassistant/components/modbus/modbus.py @@ -368,7 +368,9 @@ class ModbusHub: self, slave: int | None, address: int, value: int | list[int], use_call: str ) -> ModbusPDU | None: """Call sync. pymodbus.""" - kwargs = {"slave": slave} if slave else {} + kwargs: dict[str, Any] = ( + {ATTR_SLAVE: slave} if slave is not None else {ATTR_SLAVE: 1} + ) entry = self._pb_request[use_call] try: result: ModbusPDU = await entry.func(address, value, **kwargs) diff --git a/tests/components/modbus/test_init.py b/tests/components/modbus/test_init.py index 5dd3f6e9033..d37f55ede94 100644 --- a/tests/components/modbus/test_init.py +++ b/tests/components/modbus/test_init.py @@ -1265,3 +1265,56 @@ async def test_no_entities(hass: HomeAssistant) -> None: ] } assert await async_setup_component(hass, DOMAIN, config) is False + + +@pytest.mark.parametrize( + ("do_config", "expected_slave_value"), + [ + ( + { + CONF_SENSORS: [ + { + CONF_NAME: "dummy", + CONF_ADDRESS: 1234, + }, + ], + }, + 1, + ), + ( + { + CONF_SENSORS: [ + { + CONF_NAME: "dummy", + CONF_ADDRESS: 1234, + CONF_SLAVE: 0, + }, + ], + }, + 0, + ), + ( + { + CONF_SENSORS: [ + { + CONF_NAME: "dummy", + CONF_ADDRESS: 1234, + CONF_DEVICE_ADDRESS: 6, + }, + ], + }, + 6, + ), + ], +) +async def test_check_default_slave( + hass: HomeAssistant, + mock_modbus, + do_config, + mock_do_cycle, + expected_slave_value: int, +) -> None: + """Test default slave.""" + assert mock_modbus.read_holding_registers.mock_calls + first_call = mock_modbus.read_holding_registers.mock_calls[0] + assert first_call.kwargs["slave"] == expected_slave_value From 0caa1ed8257ac68428e8ab39e3f942e3c8f68afb Mon Sep 17 00:00:00 2001 From: Simon Lamon <32477463+silamon@users.noreply.github.com> Date: Thu, 23 Jan 2025 20:36:59 +0100 Subject: [PATCH 0005/3148] Handle LinkPlay devices with no mac (#136272) Co-authored-by: J. Nick Koston --- homeassistant/components/linkplay/entity.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/linkplay/entity.py b/homeassistant/components/linkplay/entity.py index 00e2f39b233..74e067f5eb3 100644 --- a/homeassistant/components/linkplay/entity.py +++ b/homeassistant/components/linkplay/entity.py @@ -44,9 +44,15 @@ class LinkPlayBaseEntity(Entity): if model != MANUFACTURER_GENERIC: model_id = bridge.device.properties["project"] + connections: set[tuple[str, str]] = set() + if "MAC" in bridge.device.properties: + connections.add( + (dr.CONNECTION_NETWORK_MAC, bridge.device.properties["MAC"]) + ) + self._attr_device_info = dr.DeviceInfo( configuration_url=bridge.endpoint, - connections={(dr.CONNECTION_NETWORK_MAC, bridge.device.properties["MAC"])}, + connections=connections, hw_version=bridge.device.properties["hardware"], identifiers={(DOMAIN, bridge.device.uuid)}, manufacturer=manufacturer, From 2e4a19b058d56904e236eeb08a0a7d0a52505f8c Mon Sep 17 00:00:00 2001 From: Yuxin Wang Date: Wed, 22 Jan 2025 23:10:52 -0500 Subject: [PATCH 0006/3148] Fallback to None for literal "Blank" serial number for APCUPSD integration (#136297) * Fallback to None for Blank serial number * Fix comments --- homeassistant/components/apcupsd/coordinator.py | 5 ++++- tests/components/apcupsd/test_config_flow.py | 2 ++ tests/components/apcupsd/test_init.py | 8 ++++++-- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/apcupsd/coordinator.py b/homeassistant/components/apcupsd/coordinator.py index 768e9605967..1ae12d8c4b0 100644 --- a/homeassistant/components/apcupsd/coordinator.py +++ b/homeassistant/components/apcupsd/coordinator.py @@ -44,7 +44,10 @@ class APCUPSdData(dict[str, str]): @property def serial_no(self) -> str | None: """Return the unique serial number of the UPS, if available.""" - return self.get("SERIALNO") + sn = self.get("SERIALNO") + # We had user reports that some UPS models simply return "Blank" as serial number, in + # which case we fall back to `None` to indicate that it is actually not available. + return None if sn == "Blank" else sn class APCUPSdCoordinator(DataUpdateCoordinator[APCUPSdData]): diff --git a/tests/components/apcupsd/test_config_flow.py b/tests/components/apcupsd/test_config_flow.py index 88594260579..0b8386dbb5a 100644 --- a/tests/components/apcupsd/test_config_flow.py +++ b/tests/components/apcupsd/test_config_flow.py @@ -125,6 +125,8 @@ async def test_flow_works(hass: HomeAssistant) -> None: ({"UPSNAME": "Friendly Name"}, "Friendly Name"), ({"MODEL": "MODEL X"}, "MODEL X"), ({"SERIALNO": "ZZZZ"}, "ZZZZ"), + # Some models report "Blank" as serial number, which we should treat it as not reported. + ({"SERIALNO": "Blank"}, "APC UPS"), ({}, "APC UPS"), ], ) diff --git a/tests/components/apcupsd/test_init.py b/tests/components/apcupsd/test_init.py index 723ec164eae..6bb94ca2948 100644 --- a/tests/components/apcupsd/test_init.py +++ b/tests/components/apcupsd/test_init.py @@ -31,6 +31,8 @@ from tests.common import MockConfigEntry, async_fire_time_changed # Does not contain either "SERIALNO" field. # We should _not_ create devices for the entities and their IDs will not have prefixes. MOCK_MINIMAL_STATUS, + # Some models report "Blank" as SERIALNO, but we should treat it as not reported. + MOCK_MINIMAL_STATUS | {"SERIALNO": "Blank"}, ], ) async def test_async_setup_entry(hass: HomeAssistant, status: OrderedDict) -> None: @@ -41,7 +43,7 @@ async def test_async_setup_entry(hass: HomeAssistant, status: OrderedDict) -> No await async_init_integration(hass, status=status) prefix = "" - if "SERIALNO" in status: + if "SERIALNO" in status and status["SERIALNO"] != "Blank": prefix = slugify(status.get("UPSNAME", "APC UPS")) + "_" # Verify successful setup by querying the status sensor. @@ -56,6 +58,8 @@ async def test_async_setup_entry(hass: HomeAssistant, status: OrderedDict) -> No [ # We should not create device entries if SERIALNO is not reported. MOCK_MINIMAL_STATUS, + # Some models report "Blank" as SERIALNO, but we should treat it as not reported. + MOCK_MINIMAL_STATUS | {"SERIALNO": "Blank"}, # We should set the device name to be the friendly UPSNAME field if available. MOCK_MINIMAL_STATUS | {"SERIALNO": "XXXX", "UPSNAME": "MyUPS"}, # Otherwise, we should fall back to default device name --- "APC UPS". @@ -71,7 +75,7 @@ async def test_device_entry( await async_init_integration(hass, status=status) # Verify device info is properly set up. - if "SERIALNO" not in status: + if "SERIALNO" not in status or status["SERIALNO"] == "Blank": assert len(device_registry.devices) == 0 return From 1f8129f4b83e670004b6c49632dbc214828b992a Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 23 Jan 2025 14:02:30 +0100 Subject: [PATCH 0007/3148] Update peblar to v0.4.0 (#136329) * Update peblar to v0.4.0 * Update snapshots --- homeassistant/components/peblar/const.py | 2 +- homeassistant/components/peblar/manifest.json | 2 +- homeassistant/components/peblar/strings.json | 1 + requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/peblar/snapshots/test_diagnostics.ambr | 5 ----- tests/components/peblar/snapshots/test_sensor.ambr | 4 ++-- 7 files changed, 7 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/peblar/const.py b/homeassistant/components/peblar/const.py index d7d7c2fa5b5..58fcc9b85da 100644 --- a/homeassistant/components/peblar/const.py +++ b/homeassistant/components/peblar/const.py @@ -23,7 +23,7 @@ PEBLAR_CHARGE_LIMITER_TO_HOME_ASSISTANT = { ChargeLimiter.INSTALLATION_LIMIT: "installation_limit", ChargeLimiter.LOCAL_MODBUS_API: "local_modbus_api", ChargeLimiter.LOCAL_REST_API: "local_rest_api", - ChargeLimiter.LOCAL_SCHEDULED: "local_scheduled", + ChargeLimiter.LOCAL_SCHEDULED_CHARGING: "local_scheduled_charging", ChargeLimiter.OCPP_SMART_CHARGING: "ocpp_smart_charging", ChargeLimiter.OVERCURRENT_PROTECTION: "overcurrent_protection", ChargeLimiter.PHASE_IMBALANCE: "phase_imbalance", diff --git a/homeassistant/components/peblar/manifest.json b/homeassistant/components/peblar/manifest.json index 859682d3f1d..e2ae96de988 100644 --- a/homeassistant/components/peblar/manifest.json +++ b/homeassistant/components/peblar/manifest.json @@ -7,6 +7,6 @@ "integration_type": "device", "iot_class": "local_polling", "quality_scale": "platinum", - "requirements": ["peblar==0.3.3"], + "requirements": ["peblar==0.4.0"], "zeroconf": [{ "type": "_http._tcp.local.", "name": "pblr-*" }] } diff --git a/homeassistant/components/peblar/strings.json b/homeassistant/components/peblar/strings.json index fffa2b08d85..a33667fa533 100644 --- a/homeassistant/components/peblar/strings.json +++ b/homeassistant/components/peblar/strings.json @@ -96,6 +96,7 @@ "installation_limit": "Installation limit", "local_modbus_api": "Modbus API", "local_rest_api": "REST API", + "local_scheduled_charging": "Scheduled charging", "ocpp_smart_charging": "OCPP smart charging", "overcurrent_protection": "Overcurrent protection", "phase_imbalance": "Phase imbalance", diff --git a/requirements_all.txt b/requirements_all.txt index c4ff74efd76..7e420323548 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1603,7 +1603,7 @@ panasonic-viera==0.4.2 pdunehd==1.3.2 # homeassistant.components.peblar -peblar==0.3.3 +peblar==0.4.0 # homeassistant.components.peco peco==0.0.30 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fddaad5f9ca..661e9188b3f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1330,7 +1330,7 @@ panasonic-viera==0.4.2 pdunehd==1.3.2 # homeassistant.components.peblar -peblar==0.3.3 +peblar==0.4.0 # homeassistant.components.peco peco==0.0.30 diff --git a/tests/components/peblar/snapshots/test_diagnostics.ambr b/tests/components/peblar/snapshots/test_diagnostics.ambr index e33a2f557de..fbcdcfbaff5 100644 --- a/tests/components/peblar/snapshots/test_diagnostics.ambr +++ b/tests/components/peblar/snapshots/test_diagnostics.ambr @@ -51,10 +51,8 @@ 'Hostname': 'PBLR-0000645', 'HwFixedCableRating': 20, 'HwFwCompat': 'wlac-2', - 'HwHas4pRelay': False, 'HwHasBop': True, 'HwHasBuzzer': True, - 'HwHasDualSocket': False, 'HwHasEichrechtLaserMarking': False, 'HwHasEthernet': True, 'HwHasLed': True, @@ -64,13 +62,11 @@ 'HwHasPlc': False, 'HwHasRfid': True, 'HwHasRs485': True, - 'HwHasShutter': False, 'HwHasSocket': False, 'HwHasTpm': False, 'HwHasWlan': True, 'HwMaxCurrent': 16, 'HwOneOrThreePhase': 3, - 'HwUKCompliant': False, 'MainboardPn': '6004-2300-7600', 'MainboardSn': '23-38-A4E-2MC', 'MeterCalIGainA': 267369, @@ -86,7 +82,6 @@ 'MeterCalVGainB': 246074, 'MeterCalVGainC': 230191, 'MeterFwIdent': 'b9cbcd', - 'NorFlash': 'True', 'ProductModelName': 'WLAC1-H11R0WE0ICR00', 'ProductPn': '6004-2300-8002', 'ProductSn': '23-45-A4O-MOF', diff --git a/tests/components/peblar/snapshots/test_sensor.ambr b/tests/components/peblar/snapshots/test_sensor.ambr index da17a4661ee..bb1a3eb34d6 100644 --- a/tests/components/peblar/snapshots/test_sensor.ambr +++ b/tests/components/peblar/snapshots/test_sensor.ambr @@ -302,7 +302,7 @@ 'installation_limit', 'local_modbus_api', 'local_rest_api', - 'local_scheduled', + 'local_scheduled_charging', 'ocpp_smart_charging', 'overcurrent_protection', 'phase_imbalance', @@ -354,7 +354,7 @@ 'installation_limit', 'local_modbus_api', 'local_rest_api', - 'local_scheduled', + 'local_scheduled_charging', 'ocpp_smart_charging', 'overcurrent_protection', 'phase_imbalance', From 4cf1b1a707e1c47851926de42cd3f0c5dd9c380c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=85ke=20Strandberg?= Date: Thu, 23 Jan 2025 18:04:00 +0100 Subject: [PATCH 0008/3148] Avoid keyerror on incomplete api data in myuplink (#136333) * Avoid keyerror * Inject erroneous value in device point fixture * Update diagnostics snapshot --- homeassistant/components/myuplink/sensor.py | 4 ++-- .../myuplink/fixtures/device_points_nibe_f730.json | 2 +- tests/components/myuplink/snapshots/test_diagnostics.ambr | 4 ++-- tests/components/myuplink/snapshots/test_sensor.ambr | 8 ++++---- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/myuplink/sensor.py b/homeassistant/components/myuplink/sensor.py index ef827fc1fb1..fa50e8a7001 100644 --- a/homeassistant/components/myuplink/sensor.py +++ b/homeassistant/components/myuplink/sensor.py @@ -325,10 +325,10 @@ class MyUplinkEnumSensor(MyUplinkDevicePointSensor): } @property - def native_value(self) -> str: + def native_value(self) -> str | None: """Sensor state value for enum sensor.""" device_point = self.coordinator.data.points[self.device_id][self.point_id] - return self.options_map[str(int(device_point.value))] # type: ignore[no-any-return] + return self.options_map.get(str(int(device_point.value))) class MyUplinkEnumRawSensor(MyUplinkDevicePointSensor): diff --git a/tests/components/myuplink/fixtures/device_points_nibe_f730.json b/tests/components/myuplink/fixtures/device_points_nibe_f730.json index 0a61ab05f21..795a89e7e13 100644 --- a/tests/components/myuplink/fixtures/device_points_nibe_f730.json +++ b/tests/components/myuplink/fixtures/device_points_nibe_f730.json @@ -822,7 +822,7 @@ "parameterUnit": "", "writable": false, "timestamp": "2024-02-08T19:13:05+00:00", - "value": 30, + "value": 31, "strVal": "Heating", "smartHomeCategories": [], "minValue": null, diff --git a/tests/components/myuplink/snapshots/test_diagnostics.ambr b/tests/components/myuplink/snapshots/test_diagnostics.ambr index 6fe6becff11..521823e282d 100644 --- a/tests/components/myuplink/snapshots/test_diagnostics.ambr +++ b/tests/components/myuplink/snapshots/test_diagnostics.ambr @@ -883,7 +883,7 @@ "parameterUnit": "", "writable": false, "timestamp": "2024-02-08T19:13:05+00:00", - "value": 30, + "value": 31, "strVal": "Heating", "smartHomeCategories": [], "minValue": null, @@ -2045,7 +2045,7 @@ "parameterUnit": "", "writable": false, "timestamp": "2024-02-08T19:13:05+00:00", - "value": 30, + "value": 31, "strVal": "Heating", "smartHomeCategories": [], "minValue": null, diff --git a/tests/components/myuplink/snapshots/test_sensor.ambr b/tests/components/myuplink/snapshots/test_sensor.ambr index a5469dc9a77..34acbbb8785 100644 --- a/tests/components/myuplink/snapshots/test_sensor.ambr +++ b/tests/components/myuplink/snapshots/test_sensor.ambr @@ -3396,7 +3396,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'Heating', + 'state': 'unknown', }) # --- # name: test_sensor_states[sensor.gotham_city_priority_2-entry] @@ -3462,7 +3462,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'Heating', + 'state': 'unknown', }) # --- # name: test_sensor_states[sensor.gotham_city_priority_raw-entry] @@ -3508,7 +3508,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '30', + 'state': '31', }) # --- # name: test_sensor_states[sensor.gotham_city_priority_raw_2-entry] @@ -3554,7 +3554,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '30', + 'state': '31', }) # --- # name: test_sensor_states[sensor.gotham_city_r_start_diff_additional_heat-entry] From 4b13c20e7418ddaf2ad1b00777479e8814dde493 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Thu, 23 Jan 2025 15:49:18 +0100 Subject: [PATCH 0009/3148] Update frontend to 20250109.1 (#136339) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 3d9f12bd3d3..3a736429516 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -21,5 +21,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20250109.0"] + "requirements": ["home-assistant-frontend==20250109.1"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 3d9ecdece06..ec4da61054c 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -35,7 +35,7 @@ habluetooth==3.7.0 hass-nabucasa==0.87.0 hassil==2.1.0 home-assistant-bluetooth==1.13.0 -home-assistant-frontend==20250109.0 +home-assistant-frontend==20250109.1 home-assistant-intents==2025.1.1 httpx==0.27.2 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 7e420323548..8567768d7ae 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1134,7 +1134,7 @@ hole==0.8.0 holidays==0.65 # homeassistant.components.frontend -home-assistant-frontend==20250109.0 +home-assistant-frontend==20250109.1 # homeassistant.components.conversation home-assistant-intents==2025.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 661e9188b3f..805e2fb1cf2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -963,7 +963,7 @@ hole==0.8.0 holidays==0.65 # homeassistant.components.frontend -home-assistant-frontend==20250109.0 +home-assistant-frontend==20250109.1 # homeassistant.components.conversation home-assistant-intents==2025.1.1 From 7590a868b92edf0e8937e2386866a76b128b0a7f Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Thu, 23 Jan 2025 17:18:00 +0100 Subject: [PATCH 0010/3148] Update frontend to 20250109.2 (#136348) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 3a736429516..2724569d1ed 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -21,5 +21,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20250109.1"] + "requirements": ["home-assistant-frontend==20250109.2"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index ec4da61054c..061ff2a0ef7 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -35,7 +35,7 @@ habluetooth==3.7.0 hass-nabucasa==0.87.0 hassil==2.1.0 home-assistant-bluetooth==1.13.0 -home-assistant-frontend==20250109.1 +home-assistant-frontend==20250109.2 home-assistant-intents==2025.1.1 httpx==0.27.2 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 8567768d7ae..0cd084daffd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1134,7 +1134,7 @@ hole==0.8.0 holidays==0.65 # homeassistant.components.frontend -home-assistant-frontend==20250109.1 +home-assistant-frontend==20250109.2 # homeassistant.components.conversation home-assistant-intents==2025.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 805e2fb1cf2..28e8711a1de 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -963,7 +963,7 @@ hole==0.8.0 holidays==0.65 # homeassistant.components.frontend -home-assistant-frontend==20250109.1 +home-assistant-frontend==20250109.2 # homeassistant.components.conversation home-assistant-intents==2025.1.1 From acbbb1978888a884df1e123e2962794e3291e63f Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 23 Jan 2025 17:17:07 +0100 Subject: [PATCH 0011/3148] Bump aiowithings to 3.1.5 (#136350) --- homeassistant/components/withings/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/withings/manifest.json b/homeassistant/components/withings/manifest.json index ad9b9a6fe71..4c78e077d21 100644 --- a/homeassistant/components/withings/manifest.json +++ b/homeassistant/components/withings/manifest.json @@ -13,5 +13,5 @@ "documentation": "https://www.home-assistant.io/integrations/withings", "iot_class": "cloud_push", "loggers": ["aiowithings"], - "requirements": ["aiowithings==3.1.4"] + "requirements": ["aiowithings==3.1.5"] } diff --git a/requirements_all.txt b/requirements_all.txt index 0cd084daffd..b136d731e1e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -416,7 +416,7 @@ aiowatttime==0.1.1 aiowebostv==0.4.2 # homeassistant.components.withings -aiowithings==3.1.4 +aiowithings==3.1.5 # homeassistant.components.yandex_transport aioymaps==1.2.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 28e8711a1de..20f06aa5caf 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -398,7 +398,7 @@ aiowatttime==0.1.1 aiowebostv==0.4.2 # homeassistant.components.withings -aiowithings==3.1.4 +aiowithings==3.1.5 # homeassistant.components.yandex_transport aioymaps==1.2.5 From b9443fa204bf6a1d47a7eeceb5ed6c25caa82598 Mon Sep 17 00:00:00 2001 From: Klaas Schoute Date: Thu, 23 Jan 2025 20:52:54 +0100 Subject: [PATCH 0012/3148] Bump powerfox to v1.2.1 (#136366) --- homeassistant/components/powerfox/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/powerfox/manifest.json b/homeassistant/components/powerfox/manifest.json index bb72d73b5a8..3938eb01a1b 100644 --- a/homeassistant/components/powerfox/manifest.json +++ b/homeassistant/components/powerfox/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/powerfox", "iot_class": "cloud_polling", "quality_scale": "silver", - "requirements": ["powerfox==1.2.0"], + "requirements": ["powerfox==1.2.1"], "zeroconf": [ { "type": "_http._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index b136d731e1e..9fd6a3af6a9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1650,7 +1650,7 @@ pmsensor==0.4 poolsense==0.0.8 # homeassistant.components.powerfox -powerfox==1.2.0 +powerfox==1.2.1 # homeassistant.components.reddit praw==7.5.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 20f06aa5caf..5d63c7a5c61 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1360,7 +1360,7 @@ plumlightpad==0.0.11 poolsense==0.0.8 # homeassistant.components.powerfox -powerfox==1.2.0 +powerfox==1.2.1 # homeassistant.components.reddit praw==7.5.0 From 223b437cb96a6f627ec7383f96b59cc61c9ac560 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 24 Jan 2025 08:02:10 +0000 Subject: [PATCH 0013/3148] Bump version to 2025.1.4 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index f5046b510f8..101cd2e3173 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -25,7 +25,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2025 MINOR_VERSION: Final = 1 -PATCH_VERSION: Final = "3" +PATCH_VERSION: Final = "4" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 12, 0) diff --git a/pyproject.toml b/pyproject.toml index e24dbcd58e5..fad27cfd7f5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.1.3" +version = "2025.1.4" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 34e8595d19d35f2b3c14d9819c41180cd769a5af Mon Sep 17 00:00:00 2001 From: Keith <22891515+keithle888@users.noreply.github.com> Date: Sat, 25 Jan 2025 21:38:27 +0100 Subject: [PATCH 0014/3148] Updated igloohome-api dependency to 0.1.0 (#136516) - Updated igloohome-api to 0.1.0 --- homeassistant/components/igloohome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/igloohome/manifest.json b/homeassistant/components/igloohome/manifest.json index 28e287db2ab..35c58479d75 100644 --- a/homeassistant/components/igloohome/manifest.json +++ b/homeassistant/components/igloohome/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/igloohome", "iot_class": "cloud_polling", "quality_scale": "bronze", - "requirements": ["igloohome-api==0.0.6"] + "requirements": ["igloohome-api==0.1.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 2a92d18f57d..f9a58779c8e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1199,7 +1199,7 @@ ifaddr==0.2.0 iglo==1.2.7 # homeassistant.components.igloohome -igloohome-api==0.0.6 +igloohome-api==0.1.0 # homeassistant.components.ihc ihcsdk==2.8.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index dde87698b6b..127d08c22d6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1016,7 +1016,7 @@ idasen-ha==2.6.3 ifaddr==0.2.0 # homeassistant.components.igloohome -igloohome-api==0.0.6 +igloohome-api==0.1.0 # homeassistant.components.imgw_pib imgw_pib==1.0.9 From 5e6f6249384a168251008c55db55fe9653a46e29 Mon Sep 17 00:00:00 2001 From: Christopher Fenner <9592452+CFenner@users.noreply.github.com> Date: Sat, 25 Jan 2025 21:42:49 +0100 Subject: [PATCH 0015/3148] Add heat pump heating rod sensors in ViCare integration (#136467) * add heating rod sensors * add labels * update snapshot --- homeassistant/components/vicare/sensor.py | 15 +++ homeassistant/components/vicare/strings.json | 6 ++ .../vicare/snapshots/test_sensor.ambr | 99 +++++++++++++++++++ 3 files changed, 120 insertions(+) diff --git a/homeassistant/components/vicare/sensor.py b/homeassistant/components/vicare/sensor.py index 44c3f3cfc0f..14624be2b6d 100644 --- a/homeassistant/components/vicare/sensor.py +++ b/homeassistant/components/vicare/sensor.py @@ -847,6 +847,21 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( value_getter=lambda api: api.getSupplyPressure(), unit_getter=lambda api: api.getSupplyPressureUnit(), ), + ViCareSensorEntityDescription( + key="heating_rod_starts", + translation_key="heating_rod_starts", + value_getter=lambda api: api.getHeatingRodStarts(), + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + ViCareSensorEntityDescription( + key="heating_rod_hours", + translation_key="heating_rod_hours", + native_unit_of_measurement=UnitOfTime.HOURS, + value_getter=lambda api: api.getHeatingRodHours(), + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.TOTAL_INCREASING, + ), ) CIRCUIT_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( diff --git a/homeassistant/components/vicare/strings.json b/homeassistant/components/vicare/strings.json index f49a73f1659..5ab92880ba0 100644 --- a/homeassistant/components/vicare/strings.json +++ b/homeassistant/components/vicare/strings.json @@ -458,6 +458,12 @@ }, "supply_pressure": { "name": "Supply pressure" + }, + "heating_rod_starts": { + "name": "Heating rod starts" + }, + "heating_rod_hours": { + "name": "Heating rod hours" } }, "water_heater": { diff --git a/tests/components/vicare/snapshots/test_sensor.ambr b/tests/components/vicare/snapshots/test_sensor.ambr index aaf75e6753a..17c9ee99320 100644 --- a/tests/components/vicare/snapshots/test_sensor.ambr +++ b/tests/components/vicare/snapshots/test_sensor.ambr @@ -1858,6 +1858,105 @@ 'state': '16.4', }) # --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_heating_rod_hours-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': , + 'entity_id': 'sensor.model0_heating_rod_hours', + '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': 'Heating rod hours', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'heating_rod_hours', + 'unique_id': 'gateway0_deviceSerialVitocal250A-heating_rod_hours', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_heating_rod_hours-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'model0 Heating rod hours', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.model0_heating_rod_hours', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_heating_rod_starts-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': , + 'entity_id': 'sensor.model0_heating_rod_starts', + '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': 'Heating rod starts', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'heating_rod_starts', + 'unique_id': 'gateway0_deviceSerialVitocal250A-heating_rod_starts', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_heating_rod_starts-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'model0 Heating rod starts', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.model0_heating_rod_starts', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- # name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_outside_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From cf8409dcd2d7f070f91cecfc8b4ee265091d4860 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 25 Jan 2025 22:31:30 +0100 Subject: [PATCH 0016/3148] Add backup agent to Synology DSM (#135227) * pre-alpha state * small type * use ChunkAsyncStreamIterator from aiohttp_client helper * create parent folders during upload if none exists * check file station permissionsduring setup * ensure backup-agents are reloaded * adjust config flow * fix check for availability of file station * fix possible unbound * add config flow tests * fix existing tests * add backup tests * backup listeners are not async * some more tests * migrate existing config entries * fix migration * notify backup listeners only when needed during setup * add backup settings to options flow * switch back to the listener approach from the dev docs example * add negative tests * fix tests * use HassKey * fix tests * Revert "use HassKey" This reverts commit 71c5a4d6fa9c04b4907ff5f8df6ef7bd1737aa85. * use hass loop call_soon instead of non-eager-start tasks * use HassKey for backup-agent-listeners * delete empty backup-agent-listener list from hass.data * don't handle single file download errors * Apply suggestions from code review Co-authored-by: J. Nick Koston * add more tests * we don't have entities related to file_station api * add more backup tests * test unload backup agent * revert sorting of properties * additional use hass config location for default backup path --------- Co-authored-by: J. Nick Koston --- .../components/synology_dsm/__init__.py | 24 +- .../components/synology_dsm/backup.py | 223 ++++++ .../components/synology_dsm/common.py | 42 +- .../components/synology_dsm/config_flow.py | 104 ++- .../components/synology_dsm/const.py | 9 + .../components/synology_dsm/strings.json | 11 + tests/components/synology_dsm/conftest.py | 2 +- .../snapshots/test_config_flow.ambr | 14 + tests/components/synology_dsm/test_backup.py | 709 ++++++++++++++++++ .../synology_dsm/test_config_flow.py | 152 +++- tests/components/synology_dsm/test_init.py | 44 +- .../synology_dsm/test_media_source.py | 1 + 12 files changed, 1297 insertions(+), 38 deletions(-) create mode 100644 homeassistant/components/synology_dsm/backup.py create mode 100644 tests/components/synology_dsm/test_backup.py diff --git a/homeassistant/components/synology_dsm/__init__.py b/homeassistant/components/synology_dsm/__init__.py index 3619619782e..0b8b8731f8f 100644 --- a/homeassistant/components/synology_dsm/__init__.py +++ b/homeassistant/components/synology_dsm/__init__.py @@ -11,12 +11,15 @@ from synology_dsm.exceptions import SynologyDSMNotLoggedInException from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_MAC, CONF_VERIFY_SSL -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr from .common import SynoApi, raise_config_entry_auth_error from .const import ( + CONF_BACKUP_PATH, + CONF_BACKUP_SHARE, + DATA_BACKUP_AGENT_LISTENERS, DEFAULT_VERIFY_SSL, DOMAIN, EXCEPTION_DETAILS, @@ -60,6 +63,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.config_entries.async_update_entry( entry, data={**entry.data, CONF_VERIFY_SSL: DEFAULT_VERIFY_SSL} ) + if CONF_BACKUP_SHARE not in entry.options: + hass.config_entries.async_update_entry( + entry, + options={**entry.options, CONF_BACKUP_SHARE: None, CONF_BACKUP_PATH: None}, + ) # Continue setup api = SynoApi(hass, entry) @@ -118,6 +126,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) entry.async_on_unload(entry.add_update_listener(_async_update_listener)) + if entry.options[CONF_BACKUP_SHARE]: + _async_notify_backup_listeners_soon(hass) + return True @@ -127,9 +138,20 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry_data: SynologyDSMData = hass.data[DOMAIN][entry.unique_id] await entry_data.api.async_unload() hass.data[DOMAIN].pop(entry.unique_id) + _async_notify_backup_listeners_soon(hass) return unload_ok +def _async_notify_backup_listeners(hass: HomeAssistant) -> None: + for listener in hass.data.get(DATA_BACKUP_AGENT_LISTENERS, []): + listener() + + +@callback +def _async_notify_backup_listeners_soon(hass: HomeAssistant) -> None: + hass.loop.call_soon(_async_notify_backup_listeners, hass) + + async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: """Handle options update.""" await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/synology_dsm/backup.py b/homeassistant/components/synology_dsm/backup.py new file mode 100644 index 00000000000..eed6af758ba --- /dev/null +++ b/homeassistant/components/synology_dsm/backup.py @@ -0,0 +1,223 @@ +"""Support for Synology DSM backup agents.""" + +from __future__ import annotations + +from collections.abc import AsyncIterator, Callable, Coroutine +import logging +from typing import TYPE_CHECKING, Any + +from aiohttp import StreamReader +from synology_dsm.api.file_station import SynoFileStation +from synology_dsm.exceptions import SynologyDSMAPIErrorException + +from homeassistant.components.backup import AgentBackup, BackupAgent, BackupAgentError +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.aiohttp_client import ChunkAsyncStreamIterator +from homeassistant.helpers.json import json_dumps +from homeassistant.util.json import JsonObjectType, json_loads_object + +from .const import ( + CONF_BACKUP_PATH, + CONF_BACKUP_SHARE, + DATA_BACKUP_AGENT_LISTENERS, + DOMAIN, +) +from .models import SynologyDSMData + +LOGGER = logging.getLogger(__name__) + + +async def async_get_backup_agents( + hass: HomeAssistant, +) -> list[BackupAgent]: + """Return a list of backup agents.""" + if not ( + entries := hass.config_entries.async_loaded_entries(DOMAIN) + ) or not hass.data.get(DOMAIN): + LOGGER.debug("No proper config entry found") + return [] + syno_datas: dict[str, SynologyDSMData] = hass.data[DOMAIN] + return [ + SynologyDSMBackupAgent(hass, entry) + for entry in entries + if entry.unique_id is not None + and (syno_data := syno_datas.get(entry.unique_id)) + and syno_data.api.file_station + and entry.options.get(CONF_BACKUP_PATH) + ] + + +@callback +def async_register_backup_agents_listener( + hass: HomeAssistant, + *, + listener: Callable[[], None], + **kwargs: Any, +) -> Callable[[], None]: + """Register a listener to be called when agents are added or removed. + + :return: A function to unregister the listener. + """ + hass.data.setdefault(DATA_BACKUP_AGENT_LISTENERS, []).append(listener) + + @callback + def remove_listener() -> None: + """Remove the listener.""" + hass.data[DATA_BACKUP_AGENT_LISTENERS].remove(listener) + if not hass.data[DATA_BACKUP_AGENT_LISTENERS]: + del hass.data[DATA_BACKUP_AGENT_LISTENERS] + + return remove_listener + + +class SynologyDSMBackupAgent(BackupAgent): + """Synology DSM backup agent.""" + + domain = DOMAIN + + def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + """Initialize the Synology DSM backup agent.""" + super().__init__() + LOGGER.debug("Initializing Synology DSM backup agent for %s", entry.unique_id) + self.name = entry.title + self.path = ( + f"{entry.options[CONF_BACKUP_SHARE]}/{entry.options[CONF_BACKUP_PATH]}" + ) + syno_data: SynologyDSMData = hass.data[DOMAIN][entry.unique_id] + self.api = syno_data.api + + @property + def _file_station(self) -> SynoFileStation: + if TYPE_CHECKING: + # we ensure that file_station exist already in async_get_backup_agents + assert self.api.file_station + return self.api.file_station + + async def async_download_backup( + self, + backup_id: str, + **kwargs: Any, + ) -> AsyncIterator[bytes]: + """Download a backup file. + + :param backup_id: The ID of the backup that was returned in async_list_backups. + :return: An async iterator that yields bytes. + """ + try: + resp = await self._file_station.download_file( + path=self.path, + filename=f"{backup_id}.tar", + ) + except SynologyDSMAPIErrorException as err: + raise BackupAgentError("Failed to download backup") from err + + if TYPE_CHECKING: + assert isinstance(resp, StreamReader) + + return ChunkAsyncStreamIterator(resp) + + async def async_upload_backup( + self, + *, + open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]], + backup: AgentBackup, + **kwargs: Any, + ) -> None: + """Upload a backup. + + :param open_stream: A function returning an async iterator that yields bytes. + :param backup: Metadata about the backup that should be uploaded. + """ + + # upload backup.tar file first + try: + await self._file_station.upload_file( + path=self.path, + filename=f"{backup.backup_id}.tar", + source=await open_stream(), + create_parents=True, + ) + except SynologyDSMAPIErrorException as err: + raise BackupAgentError("Failed to upload backup") from err + + # upload backup_meta.json file when backup.tar was successful uploaded + try: + await self._file_station.upload_file( + path=self.path, + filename=f"{backup.backup_id}_meta.json", + source=json_dumps(backup.as_dict()).encode(), + ) + except SynologyDSMAPIErrorException as err: + raise BackupAgentError("Failed to upload backup") from err + + async def async_delete_backup( + self, + backup_id: str, + **kwargs: Any, + ) -> None: + """Delete a backup file. + + :param backup_id: The ID of the backup that was returned in async_list_backups. + """ + try: + await self._file_station.delete_file( + path=self.path, filename=f"{backup_id}.tar" + ) + await self._file_station.delete_file( + path=self.path, filename=f"{backup_id}_meta.json" + ) + except SynologyDSMAPIErrorException as err: + raise BackupAgentError("Failed to delete the backup") from err + + async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]: + """List backups.""" + return list((await self._async_list_backups(**kwargs)).values()) + + async def _async_list_backups(self, **kwargs: Any) -> dict[str, AgentBackup]: + """List backups.""" + + async def _download_meta_data(filename: str) -> JsonObjectType: + try: + resp = await self._file_station.download_file( + path=self.path, filename=filename + ) + except SynologyDSMAPIErrorException as err: + raise BackupAgentError("Failed to download meta data") from err + + if TYPE_CHECKING: + assert isinstance(resp, StreamReader) + + try: + return json_loads_object(await resp.read()) + except Exception as err: + raise BackupAgentError("Failed to read meta data") from err + + try: + files = await self._file_station.get_files(path=self.path) + except SynologyDSMAPIErrorException as err: + raise BackupAgentError("Failed to list backups") from err + + if TYPE_CHECKING: + assert files + + backups: dict[str, AgentBackup] = {} + for file in files: + if file.name.endswith("_meta.json"): + try: + meta_data = await _download_meta_data(file.name) + except BackupAgentError as err: + LOGGER.error("Failed to download meta data: %s", err) + continue + agent_backup = AgentBackup.from_dict(meta_data) + backups[agent_backup.backup_id] = agent_backup + return backups + + async def async_get_backup( + self, + backup_id: str, + **kwargs: Any, + ) -> AgentBackup | None: + """Return a backup.""" + backups = await self._async_list_backups() + return backups.get(backup_id) diff --git a/homeassistant/components/synology_dsm/common.py b/homeassistant/components/synology_dsm/common.py index 9a6284eff2b..dfc372e6bde 100644 --- a/homeassistant/components/synology_dsm/common.py +++ b/homeassistant/components/synology_dsm/common.py @@ -14,6 +14,7 @@ from synology_dsm.api.core.upgrade import SynoCoreUpgrade from synology_dsm.api.core.utilization import SynoCoreUtilization from synology_dsm.api.dsm.information import SynoDSMInformation from synology_dsm.api.dsm.network import SynoDSMNetwork +from synology_dsm.api.file_station import SynoFileStation from synology_dsm.api.photos import SynoPhotos from synology_dsm.api.storage.storage import SynoStorage from synology_dsm.api.surveillance_station import SynoSurveillanceStation @@ -62,11 +63,12 @@ class SynoApi: self.config_url = f"http://{entry.data[CONF_HOST]}:{entry.data[CONF_PORT]}" # DSM APIs + self.file_station: SynoFileStation | None = None self.information: SynoDSMInformation | None = None self.network: SynoDSMNetwork | None = None + self.photos: SynoPhotos | None = None self.security: SynoCoreSecurity | None = None self.storage: SynoStorage | None = None - self.photos: SynoPhotos | None = None self.surveillance_station: SynoSurveillanceStation | None = None self.system: SynoCoreSystem | None = None self.upgrade: SynoCoreUpgrade | None = None @@ -74,10 +76,11 @@ class SynoApi: # Should we fetch them self._fetching_entities: dict[str, set[str]] = {} + self._with_file_station = True self._with_information = True + self._with_photos = True self._with_security = True self._with_storage = True - self._with_photos = True self._with_surveillance_station = True self._with_system = True self._with_upgrade = True @@ -157,6 +160,26 @@ class SynoApi: self.dsm.reset(SynoCoreUpgrade.API_KEY) LOGGER.debug("Disabled fetching upgrade data during setup: %s", ex) + # check if file station is used and permitted + self._with_file_station = bool(self.dsm.apis.get(SynoFileStation.LIST_API_KEY)) + if self._with_file_station: + shares: list | None = None + with suppress(*SYNOLOGY_CONNECTION_EXCEPTIONS): + shares = await self.dsm.file.get_shared_folders(only_writable=True) + if not shares: + self._with_file_station = False + self.dsm.reset(SynoFileStation.API_KEY) + LOGGER.debug( + "File Station found, but disabled due to missing user" + " permissions or no writable shared folders available" + ) + + LOGGER.debug( + "State of File Station during setup of '%s': %s", + self._entry.unique_id, + self._with_file_station, + ) + await self._fetch_device_configuration() try: @@ -225,6 +248,15 @@ class SynoApi: self.dsm.reset(self.security) self.security = None + if not self._with_file_station: + LOGGER.debug( + "Disable file station api from being updated or '%s'", + self._entry.unique_id, + ) + if self.file_station: + self.dsm.reset(self.file_station) + self.file_station = None + if not self._with_photos: LOGGER.debug( "Disable photos api from being updated or '%s'", self._entry.unique_id @@ -272,6 +304,12 @@ class SynoApi: self.network = self.dsm.network await self.network.update() + if self._with_file_station: + LOGGER.debug( + "Enable file station api updates for '%s'", self._entry.unique_id + ) + self.file_station = self.dsm.file + if self._with_security: LOGGER.debug("Enable security api updates for '%s'", self._entry.unique_id) self.security = self.dsm.security diff --git a/homeassistant/components/synology_dsm/config_flow.py b/homeassistant/components/synology_dsm/config_flow.py index 03e2eaf8e7b..30f5078f19d 100644 --- a/homeassistant/components/synology_dsm/config_flow.py +++ b/homeassistant/components/synology_dsm/config_flow.py @@ -3,12 +3,14 @@ from __future__ import annotations from collections.abc import Mapping +from contextlib import suppress from ipaddress import ip_address as ip import logging -from typing import Any, cast +from typing import TYPE_CHECKING, Any, cast from urllib.parse import urlparse from synology_dsm import SynologyDSM +from synology_dsm.api.file_station.models import SynoFileSharedFolder from synology_dsm.exceptions import ( SynologyDSMException, SynologyDSMLogin2SAFailedException, @@ -40,6 +42,12 @@ from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.selector import ( + SelectOptionDict, + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, +) from homeassistant.helpers.service_info.ssdp import ( ATTR_UPNP_FRIENDLY_NAME, ATTR_UPNP_SERIAL, @@ -47,12 +55,16 @@ from homeassistant.helpers.service_info.ssdp import ( ) from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from homeassistant.helpers.typing import DiscoveryInfoType, VolDictType +from homeassistant.util import slugify from homeassistant.util.network import is_ip_address as is_ip from .const import ( + CONF_BACKUP_PATH, + CONF_BACKUP_SHARE, CONF_DEVICE_TOKEN, CONF_SNAPSHOT_QUALITY, CONF_VOLUMES, + DEFAULT_BACKUP_PATH, DEFAULT_PORT, DEFAULT_PORT_SSL, DEFAULT_SCAN_INTERVAL, @@ -61,7 +73,9 @@ from .const import ( DEFAULT_USE_SSL, DEFAULT_VERIFY_SSL, DOMAIN, + SYNOLOGY_CONNECTION_EXCEPTIONS, ) +from .models import SynologyDSMData _LOGGER = logging.getLogger(__name__) @@ -131,6 +145,7 @@ class SynologyDSMFlowHandler(ConfigFlow, domain=DOMAIN): self.discovered_conf: dict[str, Any] = {} self.reauth_conf: Mapping[str, Any] = {} self.reauth_reason: str | None = None + self.shares: list[SynoFileSharedFolder] | None = None def _show_form( self, @@ -173,6 +188,8 @@ class SynologyDSMFlowHandler(ConfigFlow, domain=DOMAIN): verify_ssl = user_input.get(CONF_VERIFY_SSL, DEFAULT_VERIFY_SSL) otp_code = user_input.get(CONF_OTP_CODE) friendly_name = user_input.get(CONF_NAME) + backup_path = user_input.get(CONF_BACKUP_PATH) + backup_share = user_input.get(CONF_BACKUP_SHARE) if not port: if use_ssl is True: @@ -209,6 +226,12 @@ class SynologyDSMFlowHandler(ConfigFlow, domain=DOMAIN): if errors: return self._show_form(step_id, user_input, errors) + with suppress(*SYNOLOGY_CONNECTION_EXCEPTIONS): + self.shares = await api.file.get_shared_folders(only_writable=True) + + if self.shares and not backup_path: + return await self.async_step_backup_share(user_input) + # unique_id should be serial for services purpose existing_entry = await self.async_set_unique_id(serial, raise_on_progress=False) @@ -221,6 +244,10 @@ class SynologyDSMFlowHandler(ConfigFlow, domain=DOMAIN): CONF_PASSWORD: password, CONF_MAC: api.network.macs, } + config_options = { + CONF_BACKUP_PATH: backup_path, + CONF_BACKUP_SHARE: backup_share, + } if otp_code: config_data[CONF_DEVICE_TOKEN] = api.device_token if user_input.get(CONF_DISKS): @@ -233,10 +260,12 @@ class SynologyDSMFlowHandler(ConfigFlow, domain=DOMAIN): "reauth_successful" if self.reauth_conf else "reconfigure_successful" ) return self.async_update_reload_and_abort( - existing_entry, data=config_data, reason=reason + existing_entry, data=config_data, options=config_options, reason=reason ) - return self.async_create_entry(title=friendly_name or host, data=config_data) + return self.async_create_entry( + title=friendly_name or host, data=config_data, options=config_options + ) async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -368,6 +397,43 @@ class SynologyDSMFlowHandler(ConfigFlow, domain=DOMAIN): return await self.async_step_user(user_input) + async def async_step_backup_share( + self, user_input: dict[str, Any], errors: dict[str, str] | None = None + ) -> ConfigFlowResult: + """Select backup location.""" + if TYPE_CHECKING: + assert self.shares is not None + + if not self.saved_user_input: + self.saved_user_input = user_input + + if CONF_BACKUP_PATH not in user_input and CONF_BACKUP_SHARE not in user_input: + return self.async_show_form( + step_id="backup_share", + data_schema=vol.Schema( + { + vol.Required(CONF_BACKUP_SHARE): SelectSelector( + SelectSelectorConfig( + options=[ + SelectOptionDict(value=s.path, label=s.name) + for s in self.shares + ], + mode=SelectSelectorMode.DROPDOWN, + ), + ), + vol.Required( + CONF_BACKUP_PATH, + default=f"{DEFAULT_BACKUP_PATH}_{slugify(self.hass.config.location_name)}", + ): str, + } + ), + ) + + user_input = {**self.saved_user_input, **user_input} + self.saved_user_input = {} + + return await self.async_step_user(user_input) + def _async_get_existing_entry(self, discovered_mac: str) -> ConfigEntry | None: """See if we already have a configured NAS with this MAC address.""" for entry in self._async_current_entries(): @@ -388,6 +454,8 @@ class SynologyDSMOptionsFlowHandler(OptionsFlow): if user_input is not None: return self.async_create_entry(title="", data=user_input) + syno_data: SynologyDSMData = self.hass.data[DOMAIN][self.config_entry.unique_id] + data_schema = vol.Schema( { vol.Required( @@ -404,6 +472,36 @@ class SynologyDSMOptionsFlowHandler(OptionsFlow): ): vol.All(vol.Coerce(int), vol.Range(min=0, max=2)), } ) + + shares: list[SynoFileSharedFolder] | None = None + if syno_data.api.file_station: + with suppress(*SYNOLOGY_CONNECTION_EXCEPTIONS): + shares = await syno_data.api.file_station.get_shared_folders( + only_writable=True + ) + + if shares: + data_schema = data_schema.extend( + { + vol.Required( + CONF_BACKUP_SHARE, + default=self.config_entry.options[CONF_BACKUP_SHARE], + ): SelectSelector( + SelectSelectorConfig( + options=[ + SelectOptionDict(value=s.path, label=s.name) + for s in shares + ], + mode=SelectSelectorMode.DROPDOWN, + ), + ), + vol.Required( + CONF_BACKUP_PATH, + default=self.config_entry.options[CONF_BACKUP_PATH], + ): str, + } + ) + return self.async_show_form(step_id="init", data_schema=data_schema) diff --git a/homeassistant/components/synology_dsm/const.py b/homeassistant/components/synology_dsm/const.py index e6367458578..dbee85b99d6 100644 --- a/homeassistant/components/synology_dsm/const.py +++ b/homeassistant/components/synology_dsm/const.py @@ -2,6 +2,8 @@ from __future__ import annotations +from collections.abc import Callable + from aiohttp import ClientTimeout from synology_dsm.api.surveillance_station.const import SNAPSHOT_PROFILE_BALANCED from synology_dsm.exceptions import ( @@ -15,8 +17,12 @@ from synology_dsm.exceptions import ( ) from homeassistant.const import Platform +from homeassistant.util.hass_dict import HassKey DOMAIN = "synology_dsm" +DATA_BACKUP_AGENT_LISTENERS: HassKey[list[Callable[[], None]]] = HassKey( + f"{DOMAIN}_backup_agent_listeners" +) ATTRIBUTION = "Data provided by Synology" PLATFORMS = [ Platform.BINARY_SENSOR, @@ -34,6 +40,8 @@ CONF_SERIAL = "serial" CONF_VOLUMES = "volumes" CONF_DEVICE_TOKEN = "device_token" CONF_SNAPSHOT_QUALITY = "snap_profile_type" +CONF_BACKUP_SHARE = "backup_share" +CONF_BACKUP_PATH = "backup_path" DEFAULT_USE_SSL = True DEFAULT_VERIFY_SSL = False @@ -43,6 +51,7 @@ DEFAULT_PORT_SSL = 5001 DEFAULT_SCAN_INTERVAL = 15 # min DEFAULT_TIMEOUT = ClientTimeout(total=60, connect=15) DEFAULT_SNAPSHOT_QUALITY = SNAPSHOT_PROFILE_BALANCED +DEFAULT_BACKUP_PATH = "ha_backup" ENTITY_UNIT_LOAD = "load" diff --git a/homeassistant/components/synology_dsm/strings.json b/homeassistant/components/synology_dsm/strings.json index 0f8ea594732..3d64f908256 100644 --- a/homeassistant/components/synology_dsm/strings.json +++ b/homeassistant/components/synology_dsm/strings.json @@ -21,6 +21,17 @@ "otp_code": "Code" } }, + "backup_share": { + "title": "Synology DSM: Backup location", + "data": { + "backup_share": "Shared folder", + "backup_path": "Path" + }, + "data_description": { + "backup_share": "Select the shared folder, where the automatic Home-Assistant backup should be stored.", + "backup_path": "Define the path on the selected shared folder (will automatically be created, if not exist)." + } + }, "link": { "description": "Do you want to set up {name} ({host})?", "data": { diff --git a/tests/components/synology_dsm/conftest.py b/tests/components/synology_dsm/conftest.py index 0e8f79ffd40..331c879332d 100644 --- a/tests/components/synology_dsm/conftest.py +++ b/tests/components/synology_dsm/conftest.py @@ -34,5 +34,5 @@ def fixture_dsm(): dsm.network.update = AsyncMock(return_value=True) dsm.surveillance_station.update = AsyncMock(return_value=True) dsm.upgrade.update = AsyncMock(return_value=True) - + dsm.file = AsyncMock(get_shared_folders=AsyncMock(return_value=None)) return dsm diff --git a/tests/components/synology_dsm/snapshots/test_config_flow.ambr b/tests/components/synology_dsm/snapshots/test_config_flow.ambr index 807ec764e52..384f6b885d7 100644 --- a/tests/components/synology_dsm/snapshots/test_config_flow.ambr +++ b/tests/components/synology_dsm/snapshots/test_config_flow.ambr @@ -84,3 +84,17 @@ 'verify_ssl': False, }) # --- +# name: test_user_with_filestation + dict({ + 'host': 'nas.meontheinternet.com', + 'mac': list([ + '00-11-32-XX-XX-59', + '00-11-32-XX-XX-5A', + ]), + 'password': 'password', + 'port': 1234, + 'ssl': True, + 'username': 'Home_Assistant', + 'verify_ssl': False, + }) +# --- diff --git a/tests/components/synology_dsm/test_backup.py b/tests/components/synology_dsm/test_backup.py new file mode 100644 index 00000000000..0cd119cf015 --- /dev/null +++ b/tests/components/synology_dsm/test_backup.py @@ -0,0 +1,709 @@ +"""Tests for the Synology DSM backup agent.""" + +from io import StringIO +from typing import Any +from unittest.mock import AsyncMock, MagicMock, Mock, patch + +import pytest +from synology_dsm.api.file_station.models import SynoFileFile, SynoFileSharedFolder +from synology_dsm.exceptions import SynologyDSMAPIErrorException + +from homeassistant.components.backup import ( + DOMAIN as BACKUP_DOMAIN, + AddonInfo, + AgentBackup, + Folder, +) +from homeassistant.components.synology_dsm.const import ( + CONF_BACKUP_PATH, + CONF_BACKUP_SHARE, + DOMAIN, +) +from homeassistant.const import ( + CONF_HOST, + CONF_MAC, + CONF_PASSWORD, + CONF_PORT, + CONF_SSL, + CONF_USERNAME, +) +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component +from homeassistant.util.aiohttp import MockStreamReader + +from .consts import HOST, MACS, PASSWORD, PORT, SERIAL, USE_SSL, USERNAME + +from tests.common import MockConfigEntry +from tests.typing import ClientSessionGenerator, WebSocketGenerator + + +class MockStreamReaderChunked(MockStreamReader): + """Mock a stream reader with simulated chunked data.""" + + async def readchunk(self) -> tuple[bytes, bool]: + """Read bytes.""" + return (self._content.read(), False) + + +async def _mock_download_file(path: str, filename: str) -> MockStreamReader: + if filename == "abcd12ef_meta.json": + return MockStreamReader( + b'{"addons":[],"backup_id":"abcd12ef","date":"2025-01-09T20:14:35.457323+01:00",' + b'"database_included":true,"extra_metadata":{"instance_id":"36b3b7e984da43fc89f7bafb2645fa36",' + b'"with_automatic_settings":true},"folders":[],"homeassistant_included":true,' + b'"homeassistant_version":"2025.2.0.dev0","name":"Automatic backup 2025.2.0.dev0","protected":true,"size":13916160}' + ) + if filename == "abcd12ef.tar": + return MockStreamReaderChunked(b"backup data") + raise MockStreamReaderChunked(b"") + + +async def _mock_download_file_meta_ok_tar_missing( + path: str, filename: str +) -> MockStreamReader: + if filename == "abcd12ef_meta.json": + return MockStreamReader( + b'{"addons":[],"backup_id":"abcd12ef","date":"2025-01-09T20:14:35.457323+01:00",' + b'"database_included":true,"extra_metadata":{"instance_id":"36b3b7e984da43fc89f7bafb2645fa36",' + b'"with_automatic_settings":true},"folders":[],"homeassistant_included":true,' + b'"homeassistant_version":"2025.2.0.dev0","name":"Automatic backup 2025.2.0.dev0","protected":true,"size":13916160}' + ) + if filename == "abcd12ef.tar": + raise SynologyDSMAPIErrorException("api", "404", "not found") + raise MockStreamReaderChunked(b"") + + +async def _mock_download_file_meta_defect(path: str, filename: str) -> MockStreamReader: + if filename == "abcd12ef_meta.json": + return MockStreamReader(b"im not a json") + if filename == "abcd12ef.tar": + return MockStreamReaderChunked(b"backup data") + raise MockStreamReaderChunked(b"") + + +@pytest.fixture +def mock_dsm_with_filestation(): + """Mock a successful service with filestation support.""" + + with patch("homeassistant.components.synology_dsm.common.SynologyDSM") as dsm: + dsm.login = AsyncMock(return_value=True) + dsm.update = AsyncMock(return_value=True) + + dsm.surveillance_station.update = AsyncMock(return_value=True) + dsm.upgrade.update = AsyncMock(return_value=True) + dsm.utilisation = Mock(cpu_user_load=1, update=AsyncMock(return_value=True)) + dsm.network = Mock(update=AsyncMock(return_value=True), macs=MACS) + dsm.storage = Mock( + disks_ids=["sda", "sdb", "sdc"], + volumes_ids=["volume_1"], + update=AsyncMock(return_value=True), + ) + dsm.information = Mock(serial=SERIAL) + dsm.file = AsyncMock( + get_shared_folders=AsyncMock( + return_value=[ + SynoFileSharedFolder( + additional=None, + is_dir=True, + name="HA Backup", + path="/ha_backup", + ) + ] + ), + get_files=AsyncMock( + return_value=[ + SynoFileFile( + additional=None, + is_dir=False, + name="abcd12ef_meta.json", + path="/ha_backup/my_backup_path/abcd12ef_meta.json", + ), + SynoFileFile( + additional=None, + is_dir=False, + name="abcd12ef.tar", + path="/ha_backup/my_backup_path/abcd12ef.tar", + ), + ] + ), + download_file=_mock_download_file, + upload_file=AsyncMock(return_value=True), + delete_file=AsyncMock(return_value=True), + ) + dsm.logout = AsyncMock(return_value=True) + yield dsm + + +@pytest.fixture +def mock_dsm_without_filestation(): + """Mock a successful service with filestation support.""" + + with patch("homeassistant.components.synology_dsm.common.SynologyDSM") as dsm: + dsm.login = AsyncMock(return_value=True) + dsm.update = AsyncMock(return_value=True) + + dsm.surveillance_station.update = AsyncMock(return_value=True) + dsm.upgrade.update = AsyncMock(return_value=True) + dsm.utilisation = Mock(cpu_user_load=1, update=AsyncMock(return_value=True)) + dsm.network = Mock(update=AsyncMock(return_value=True), macs=MACS) + dsm.storage = Mock( + disks_ids=["sda", "sdb", "sdc"], + volumes_ids=["volume_1"], + update=AsyncMock(return_value=True), + ) + dsm.information = Mock(serial=SERIAL) + dsm.file = None + + yield dsm + + +@pytest.fixture +async def setup_dsm_with_filestation( + hass: HomeAssistant, + mock_dsm_with_filestation: MagicMock, +): + """Mock setup of synology dsm config entry.""" + with ( + patch( + "homeassistant.components.synology_dsm.common.SynologyDSM", + return_value=mock_dsm_with_filestation, + ), + patch("homeassistant.components.synology_dsm.PLATFORMS", return_value=[]), + ): + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: HOST, + CONF_PORT: PORT, + CONF_SSL: USE_SSL, + CONF_USERNAME: USERNAME, + CONF_PASSWORD: PASSWORD, + CONF_MAC: MACS[0], + }, + options={ + CONF_BACKUP_PATH: "my_backup_path", + CONF_BACKUP_SHARE: "/ha_backup", + }, + unique_id="mocked_syno_dsm_entry", + ) + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}}) + await hass.async_block_till_done() + + yield mock_dsm_with_filestation + + +async def test_agents_info( + hass: HomeAssistant, + setup_dsm_with_filestation: MagicMock, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test backup agent info.""" + client = await hass_ws_client(hass) + + await client.send_json_auto_id({"type": "backup/agents/info"}) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == { + "agents": [ + {"agent_id": "synology_dsm.Mock Title"}, + {"agent_id": "backup.local"}, + ], + } + + +async def test_agents_not_loaded( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test backup agent with no loaded config entry.""" + with patch("homeassistant.components.backup.is_hassio", return_value=False): + assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}}) + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + await hass.async_block_till_done() + client = await hass_ws_client(hass) + + await client.send_json_auto_id({"type": "backup/agents/info"}) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == { + "agents": [ + {"agent_id": "backup.local"}, + ], + } + + +async def test_agents_on_unload( + hass: HomeAssistant, + setup_dsm_with_filestation: MagicMock, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test backup agent on un-loading config entry.""" + # config entry is loaded + client = await hass_ws_client(hass) + + await client.send_json_auto_id({"type": "backup/agents/info"}) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == { + "agents": [ + {"agent_id": "synology_dsm.Mock Title"}, + {"agent_id": "backup.local"}, + ], + } + + # unload config entry + entries = hass.config_entries.async_loaded_entries(DOMAIN) + await hass.config_entries.async_unload(entries[0].entry_id) + await hass.async_block_till_done(wait_background_tasks=True) + + client = await hass_ws_client(hass) + + await client.send_json_auto_id({"type": "backup/agents/info"}) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == { + "agents": [ + {"agent_id": "backup.local"}, + ], + } + + +async def test_agents_list_backups( + hass: HomeAssistant, + setup_dsm_with_filestation: MagicMock, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test agent list backups.""" + client = await hass_ws_client(hass) + + await client.send_json_auto_id({"type": "backup/info"}) + response = await client.receive_json() + + assert response["success"] + assert response["result"]["agent_errors"] == {} + assert response["result"]["backups"] == [ + { + "addons": [], + "backup_id": "abcd12ef", + "date": "2025-01-09T20:14:35.457323+01:00", + "database_included": True, + "folders": [], + "homeassistant_included": True, + "homeassistant_version": "2025.2.0.dev0", + "name": "Automatic backup 2025.2.0.dev0", + "protected": True, + "size": 13916160, + "agent_ids": ["synology_dsm.Mock Title"], + "failed_agent_ids": [], + "with_automatic_settings": None, + } + ] + + +async def test_agents_list_backups_error( + hass: HomeAssistant, + setup_dsm_with_filestation: MagicMock, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test agent error while list backups.""" + client = await hass_ws_client(hass) + + setup_dsm_with_filestation.file.get_files.side_effect = ( + SynologyDSMAPIErrorException("api", "500", "error") + ) + + await client.send_json_auto_id({"type": "backup/info"}) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == { + "agent_errors": {"synology_dsm.Mock Title": "Failed to list backups"}, + "backups": [], + "last_attempted_automatic_backup": None, + "last_completed_automatic_backup": None, + "next_automatic_backup": None, + "next_automatic_backup_additional": False, + } + + +async def test_agents_list_backups_disabled_filestation( + hass: HomeAssistant, + mock_dsm_without_filestation: MagicMock, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test agent error while list backups when file station is disabled.""" + client = await hass_ws_client(hass) + + await client.send_json_auto_id({"type": "backup/info"}) + response = await client.receive_json() + + assert not response["success"] + + +@pytest.mark.parametrize( + ("backup_id", "expected_result"), + [ + ( + "abcd12ef", + { + "addons": [], + "backup_id": "abcd12ef", + "date": "2025-01-09T20:14:35.457323+01:00", + "database_included": True, + "folders": [], + "homeassistant_included": True, + "homeassistant_version": "2025.2.0.dev0", + "name": "Automatic backup 2025.2.0.dev0", + "protected": True, + "size": 13916160, + "agent_ids": ["synology_dsm.Mock Title"], + "failed_agent_ids": [], + "with_automatic_settings": None, + }, + ), + ( + "12345", + None, + ), + ], + ids=["found", "not_found"], +) +async def test_agents_get_backup( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + setup_dsm_with_filestation: MagicMock, + backup_id: str, + expected_result: dict[str, Any] | None, +) -> None: + """Test agent get backup.""" + client = await hass_ws_client(hass) + await client.send_json_auto_id({"type": "backup/details", "backup_id": backup_id}) + response = await client.receive_json() + + assert response["success"] + assert response["result"]["agent_errors"] == {} + assert response["result"]["backup"] == expected_result + + +async def test_agents_get_backup_not_existing( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + setup_dsm_with_filestation: MagicMock, +) -> None: + """Test agent get not existing backup.""" + client = await hass_ws_client(hass) + backup_id = "ef34ab12" + + setup_dsm_with_filestation.file.download_file = AsyncMock( + side_effect=SynologyDSMAPIErrorException("api", "404", "not found") + ) + + await client.send_json_auto_id({"type": "backup/details", "backup_id": backup_id}) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == {"agent_errors": {}, "backup": None} + + +async def test_agents_get_backup_error( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + setup_dsm_with_filestation: MagicMock, +) -> None: + """Test agent error while get backup.""" + client = await hass_ws_client(hass) + backup_id = "ef34ab12" + + setup_dsm_with_filestation.file.get_files.side_effect = ( + SynologyDSMAPIErrorException("api", "500", "error") + ) + + await client.send_json_auto_id({"type": "backup/details", "backup_id": backup_id}) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == { + "agent_errors": {"synology_dsm.Mock Title": "Failed to list backups"}, + "backup": None, + } + + +async def test_agents_get_backup_defect_meta( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + setup_dsm_with_filestation: MagicMock, +) -> None: + """Test agent error while get backup.""" + client = await hass_ws_client(hass) + backup_id = "ef34ab12" + + setup_dsm_with_filestation.file.download_file = _mock_download_file_meta_defect + + await client.send_json_auto_id({"type": "backup/details", "backup_id": backup_id}) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == {"agent_errors": {}, "backup": None} + + +async def test_agents_download( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + setup_dsm_with_filestation: MagicMock, +) -> None: + """Test agent download backup.""" + client = await hass_client() + backup_id = "abcd12ef" + + resp = await client.get( + f"/api/backup/download/{backup_id}?agent_id=synology_dsm.Mock Title" + ) + assert resp.status == 200 + assert await resp.content.read() == b"backup data" + + +async def test_agents_download_not_existing( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + setup_dsm_with_filestation: MagicMock, +) -> None: + """Test agent download not existing backup.""" + client = await hass_client() + backup_id = "abcd12ef" + + setup_dsm_with_filestation.file.download_file = ( + _mock_download_file_meta_ok_tar_missing + ) + + resp = await client.get( + f"/api/backup/download/{backup_id}?agent_id=synology_dsm.Mock Title" + ) + assert resp.reason == "Internal Server Error" + assert resp.status == 500 + + +async def test_agents_upload( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + caplog: pytest.LogCaptureFixture, + setup_dsm_with_filestation: MagicMock, +) -> None: + """Test agent upload backup.""" + client = await hass_client() + backup_id = "test-backup" + test_backup = AgentBackup( + addons=[AddonInfo(name="Test", slug="test", version="1.0.0")], + backup_id=backup_id, + database_included=True, + date="1970-01-01T00:00:00.000Z", + extra_metadata={}, + folders=[Folder.MEDIA, Folder.SHARE], + homeassistant_included=True, + homeassistant_version="2024.12.0", + name="Test", + protected=True, + size=0, + ) + + with ( + patch( + "homeassistant.components.backup.manager.BackupManager.async_get_backup", + ) as fetch_backup, + patch( + "homeassistant.components.backup.manager.read_backup", + return_value=test_backup, + ), + patch("pathlib.Path.open") as mocked_open, + ): + mocked_open.return_value.read = Mock(side_effect=[b"test", b""]) + fetch_backup.return_value = test_backup + resp = await client.post( + "/api/backup/upload?agent_id=synology_dsm.Mock Title", + data={"file": StringIO("test")}, + ) + + assert resp.status == 201 + assert f"Uploading backup {backup_id}" in caplog.text + mock: AsyncMock = setup_dsm_with_filestation.file.upload_file + assert len(mock.mock_calls) == 2 + assert mock.call_args_list[0].kwargs["filename"] == "test-backup.tar" + assert mock.call_args_list[0].kwargs["path"] == "/ha_backup/my_backup_path" + assert mock.call_args_list[1].kwargs["filename"] == "test-backup_meta.json" + assert mock.call_args_list[1].kwargs["path"] == "/ha_backup/my_backup_path" + + +async def test_agents_upload_error( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + caplog: pytest.LogCaptureFixture, + setup_dsm_with_filestation: MagicMock, +) -> None: + """Test agent error while uploading backup.""" + client = await hass_client() + backup_id = "test-backup" + test_backup = AgentBackup( + addons=[AddonInfo(name="Test", slug="test", version="1.0.0")], + backup_id=backup_id, + database_included=True, + date="1970-01-01T00:00:00.000Z", + extra_metadata={}, + folders=[Folder.MEDIA, Folder.SHARE], + homeassistant_included=True, + homeassistant_version="2024.12.0", + name="Test", + protected=True, + size=0, + ) + + # fail to upload the tar file + with ( + patch( + "homeassistant.components.backup.manager.BackupManager.async_get_backup", + ) as fetch_backup, + patch( + "homeassistant.components.backup.manager.read_backup", + return_value=test_backup, + ), + patch("pathlib.Path.open") as mocked_open, + ): + mocked_open.return_value.read = Mock(side_effect=[b"test", b""]) + fetch_backup.return_value = test_backup + setup_dsm_with_filestation.file.upload_file.side_effect = ( + SynologyDSMAPIErrorException("api", "500", "error") + ) + resp = await client.post( + "/api/backup/upload?agent_id=synology_dsm.Mock Title", + data={"file": StringIO("test")}, + ) + + assert resp.status == 201 + assert f"Uploading backup {backup_id}" in caplog.text + assert "Failed to upload backup" in caplog.text + mock: AsyncMock = setup_dsm_with_filestation.file.upload_file + assert len(mock.mock_calls) == 1 + assert mock.call_args_list[0].kwargs["filename"] == "test-backup.tar" + assert mock.call_args_list[0].kwargs["path"] == "/ha_backup/my_backup_path" + + # fail to upload the meta json file + with ( + patch( + "homeassistant.components.backup.manager.BackupManager.async_get_backup", + ) as fetch_backup, + patch( + "homeassistant.components.backup.manager.read_backup", + return_value=test_backup, + ), + patch("pathlib.Path.open") as mocked_open, + ): + mocked_open.return_value.read = Mock(side_effect=[b"test", b""]) + fetch_backup.return_value = test_backup + setup_dsm_with_filestation.file.upload_file.side_effect = [ + True, + SynologyDSMAPIErrorException("api", "500", "error"), + ] + + resp = await client.post( + "/api/backup/upload?agent_id=synology_dsm.Mock Title", + data={"file": StringIO("test")}, + ) + + assert resp.status == 201 + assert f"Uploading backup {backup_id}" in caplog.text + assert "Failed to upload backup" in caplog.text + mock: AsyncMock = setup_dsm_with_filestation.file.upload_file + assert len(mock.mock_calls) == 3 + assert mock.call_args_list[1].kwargs["filename"] == "test-backup.tar" + assert mock.call_args_list[1].kwargs["path"] == "/ha_backup/my_backup_path" + assert mock.call_args_list[2].kwargs["filename"] == "test-backup_meta.json" + assert mock.call_args_list[2].kwargs["path"] == "/ha_backup/my_backup_path" + + +async def test_agents_delete( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + setup_dsm_with_filestation: MagicMock, +) -> None: + """Test agent delete backup.""" + client = await hass_ws_client(hass) + backup_id = "abcd12ef" + + await client.send_json_auto_id( + { + "type": "backup/delete", + "backup_id": backup_id, + } + ) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == {"agent_errors": {}} + mock: AsyncMock = setup_dsm_with_filestation.file.delete_file + assert len(mock.mock_calls) == 2 + assert mock.call_args_list[0].kwargs["filename"] == "abcd12ef.tar" + assert mock.call_args_list[0].kwargs["path"] == "/ha_backup/my_backup_path" + assert mock.call_args_list[1].kwargs["filename"] == "abcd12ef_meta.json" + assert mock.call_args_list[1].kwargs["path"] == "/ha_backup/my_backup_path" + + +async def test_agents_delete_not_existing( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + setup_dsm_with_filestation: MagicMock, +) -> None: + """Test delete not existing backup.""" + client = await hass_ws_client(hass) + backup_id = "ef34ab12" + + setup_dsm_with_filestation.file.delete_file = AsyncMock( + side_effect=SynologyDSMAPIErrorException("api", "404", "not found") + ) + + await client.send_json_auto_id( + { + "type": "backup/delete", + "backup_id": backup_id, + } + ) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == { + "agent_errors": {"synology_dsm.Mock Title": "Failed to delete the backup"} + } + + +async def test_agents_delete_error( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + setup_dsm_with_filestation: MagicMock, +) -> None: + """Test error while delete backup.""" + client = await hass_ws_client(hass) + + # error while delete + backup_id = "abcd12ef" + setup_dsm_with_filestation.file.delete_file.side_effect = ( + SynologyDSMAPIErrorException("api", "404", "not found") + ) + await client.send_json_auto_id( + { + "type": "backup/delete", + "backup_id": backup_id, + } + ) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == { + "agent_errors": {"synology_dsm.Mock Title": "Failed to delete the backup"} + } + mock: AsyncMock = setup_dsm_with_filestation.file.delete_file + assert len(mock.mock_calls) == 1 + assert mock.call_args_list[0].kwargs["filename"] == "abcd12ef.tar" + assert mock.call_args_list[0].kwargs["path"] == "/ha_backup/my_backup_path" diff --git a/tests/components/synology_dsm/test_config_flow.py b/tests/components/synology_dsm/test_config_flow.py index 3ef47292a9b..b63ce6c2e18 100644 --- a/tests/components/synology_dsm/test_config_flow.py +++ b/tests/components/synology_dsm/test_config_flow.py @@ -4,6 +4,7 @@ from ipaddress import ip_address from unittest.mock import AsyncMock, MagicMock, Mock, patch import pytest +from synology_dsm.api.file_station.models import SynoFileSharedFolder from synology_dsm.exceptions import ( SynologyDSMException, SynologyDSMLogin2SAFailedException, @@ -15,9 +16,9 @@ from syrupy import SnapshotAssertion from homeassistant.components.synology_dsm.config_flow import CONF_OTP_CODE from homeassistant.components.synology_dsm.const import ( + CONF_BACKUP_PATH, + CONF_BACKUP_SHARE, CONF_SNAPSHOT_QUALITY, - DEFAULT_SCAN_INTERVAL, - DEFAULT_SNAPSHOT_QUALITY, DOMAIN, ) from homeassistant.config_entries import SOURCE_SSDP, SOURCE_USER, SOURCE_ZEROCONF @@ -73,7 +74,7 @@ def mock_controller_service(): update=AsyncMock(return_value=True), ) dsm.information = Mock(serial=SERIAL) - + dsm.file = AsyncMock(get_shared_folders=AsyncMock(return_value=None)) yield dsm @@ -96,6 +97,7 @@ def mock_controller_service_2sa(): update=AsyncMock(return_value=True), ) dsm.information = Mock(serial=SERIAL) + dsm.file = AsyncMock(get_shared_folders=AsyncMock(return_value=None)) yield dsm @@ -116,6 +118,39 @@ def mock_controller_service_vdsm(): update=AsyncMock(return_value=True), ) dsm.information = Mock(serial=SERIAL) + dsm.file = AsyncMock(get_shared_folders=AsyncMock(return_value=None)) + yield dsm + + +@pytest.fixture(name="service_with_filestation") +def mock_controller_service_with_filestation(): + """Mock a successful service with filestation support.""" + with patch("homeassistant.components.synology_dsm.config_flow.SynologyDSM") as dsm: + dsm.login = AsyncMock(return_value=True) + dsm.update = AsyncMock(return_value=True) + + dsm.surveillance_station.update = AsyncMock(return_value=True) + dsm.upgrade.update = AsyncMock(return_value=True) + dsm.utilisation = Mock(cpu_user_load=1, update=AsyncMock(return_value=True)) + dsm.network = Mock(update=AsyncMock(return_value=True), macs=MACS) + dsm.storage = Mock( + disks_ids=["sda", "sdb", "sdc"], + volumes_ids=["volume_1"], + update=AsyncMock(return_value=True), + ) + dsm.information = Mock(serial=SERIAL) + dsm.file = AsyncMock( + get_shared_folders=AsyncMock( + return_value=[ + SynoFileSharedFolder( + additional=None, + is_dir=True, + name="HA Backup", + path="/ha_backup", + ) + ] + ) + ) yield dsm @@ -137,7 +172,7 @@ def mock_controller_service_failed(): update=AsyncMock(return_value=True), ) dsm.information = Mock(serial=None) - + dsm.file = AsyncMock(get_shared_folders=AsyncMock(return_value=None)) yield dsm @@ -283,6 +318,55 @@ async def test_user_vdsm( assert result["data"] == snapshot +@pytest.mark.usefixtures("mock_setup_entry") +async def test_user_with_filestation( + hass: HomeAssistant, + service_with_filestation: MagicMock, + snapshot: SnapshotAssertion, +) -> None: + """Test user config.""" + with patch( + "homeassistant.components.synology_dsm.config_flow.SynologyDSM", + return_value=service_with_filestation, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=None + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + with patch( + "homeassistant.components.synology_dsm.config_flow.SynologyDSM", + return_value=service_with_filestation, + ): + # test with all provided + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={ + CONF_HOST: HOST, + CONF_PORT: PORT, + CONF_SSL: USE_SSL, + CONF_VERIFY_SSL: VERIFY_SSL, + CONF_USERNAME: USERNAME, + CONF_PASSWORD: PASSWORD, + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "backup_share" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_BACKUP_SHARE: "/ha_backup", CONF_BACKUP_PATH: "automatic_ha_backups"}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["result"].unique_id == SERIAL + assert result["title"] == HOST + assert result["data"] == snapshot + + @pytest.mark.usefixtures("mock_setup_entry") async def test_reauth(hass: HomeAssistant, service: MagicMock) -> None: """Test reauthentication.""" @@ -560,46 +644,54 @@ async def test_existing_ssdp(hass: HomeAssistant, service: MagicMock) -> None: assert result["reason"] == "already_configured" -@pytest.mark.usefixtures("mock_setup_entry") -async def test_options_flow(hass: HomeAssistant, service: MagicMock) -> None: +async def test_options_flow( + hass: HomeAssistant, service_with_filestation: MagicMock +) -> None: """Test config flow options.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - data={ - CONF_HOST: HOST, - CONF_USERNAME: USERNAME, - CONF_PASSWORD: PASSWORD, - CONF_MAC: MACS, - }, - unique_id=SERIAL, - ) - config_entry.add_to_hass(hass) + with ( + patch( + "homeassistant.components.synology_dsm.common.SynologyDSM", + return_value=service_with_filestation, + ), + patch("homeassistant.components.synology_dsm.PLATFORMS", return_value=[]), + ): + config_entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: HOST, + CONF_PORT: PORT, + CONF_SSL: USE_SSL, + CONF_USERNAME: USERNAME, + CONF_PASSWORD: PASSWORD, + CONF_MAC: MACS[0], + }, + unique_id=SERIAL, + ) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() - assert config_entry.options == {} + assert config_entry.options == {CONF_BACKUP_SHARE: None, CONF_BACKUP_PATH: None} result = await hass.config_entries.options.async_init(config_entry.entry_id) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "init" - # Scan interval - # Default - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={}, - ) - assert result["type"] is FlowResultType.CREATE_ENTRY - assert config_entry.options[CONF_SCAN_INTERVAL] == DEFAULT_SCAN_INTERVAL - assert config_entry.options[CONF_SNAPSHOT_QUALITY] == DEFAULT_SNAPSHOT_QUALITY - - # Manual result = await hass.config_entries.options.async_init(config_entry.entry_id) result = await hass.config_entries.options.async_configure( result["flow_id"], - user_input={CONF_SCAN_INTERVAL: 2, CONF_SNAPSHOT_QUALITY: 0}, + user_input={ + CONF_SCAN_INTERVAL: 2, + CONF_SNAPSHOT_QUALITY: 0, + CONF_BACKUP_PATH: "my_nackup_path", + CONF_BACKUP_SHARE: "/ha_backup", + }, ) assert result["type"] is FlowResultType.CREATE_ENTRY assert config_entry.options[CONF_SCAN_INTERVAL] == 2 assert config_entry.options[CONF_SNAPSHOT_QUALITY] == 0 + assert config_entry.options[CONF_BACKUP_PATH] == "my_nackup_path" + assert config_entry.options[CONF_BACKUP_SHARE] == "/ha_backup" @pytest.mark.usefixtures("mock_setup_entry") diff --git a/tests/components/synology_dsm/test_init.py b/tests/components/synology_dsm/test_init.py index 13d568e6137..7eaafc98437 100644 --- a/tests/components/synology_dsm/test_init.py +++ b/tests/components/synology_dsm/test_init.py @@ -4,7 +4,13 @@ from unittest.mock import MagicMock, patch from synology_dsm.exceptions import SynologyDSMLoginInvalidException -from homeassistant.components.synology_dsm.const import DOMAIN, SERVICES +from homeassistant.components.synology_dsm.const import ( + CONF_BACKUP_PATH, + CONF_BACKUP_SHARE, + DEFAULT_VERIFY_SSL, + DOMAIN, + SERVICES, +) from homeassistant.const import ( CONF_HOST, CONF_MAC, @@ -12,6 +18,7 @@ from homeassistant.const import ( CONF_PORT, CONF_SSL, CONF_USERNAME, + CONF_VERIFY_SSL, ) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -78,3 +85,38 @@ async def test_reauth_triggered(hass: HomeAssistant) -> None: assert not await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() mock_async_step_reauth.assert_called_once() + + +async def test_config_entry_migrations( + hass: HomeAssistant, mock_dsm: MagicMock +) -> None: + """Test if reauthentication flow is triggered.""" + with ( + patch( + "homeassistant.components.synology_dsm.common.SynologyDSM", + return_value=mock_dsm, + ), + patch("homeassistant.components.synology_dsm.PLATFORMS", return_value=[]), + ): + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: HOST, + CONF_PORT: PORT, + CONF_SSL: USE_SSL, + CONF_USERNAME: USERNAME, + CONF_PASSWORD: PASSWORD, + CONF_MAC: MACS[0], + }, + ) + entry.add_to_hass(hass) + + assert CONF_VERIFY_SSL not in entry.data + assert CONF_BACKUP_SHARE not in entry.options + assert CONF_BACKUP_PATH not in entry.options + + assert await hass.config_entries.async_setup(entry.entry_id) + + assert entry.data[CONF_VERIFY_SSL] == DEFAULT_VERIFY_SSL + assert entry.options[CONF_BACKUP_SHARE] is None + assert entry.options[CONF_BACKUP_PATH] is None diff --git a/tests/components/synology_dsm/test_media_source.py b/tests/components/synology_dsm/test_media_source.py index 0c7ab6bc1cc..baa91822ca0 100644 --- a/tests/components/synology_dsm/test_media_source.py +++ b/tests/components/synology_dsm/test_media_source.py @@ -62,6 +62,7 @@ def dsm_with_photos() -> MagicMock: dsm.photos.get_item_thumbnail_url = AsyncMock( return_value="http://my.thumbnail.url" ) + dsm.file = AsyncMock(get_shared_folders=AsyncMock(return_value=None)) return dsm From cffb0a03d2033c1db06c7764504f697f4af03f14 Mon Sep 17 00:00:00 2001 From: Robin Wohlers-Reichel Date: Sun, 26 Jan 2025 01:18:20 +0100 Subject: [PATCH 0017/3148] Add Darsstar as codeowner for solax integration (#136528) * Add Darsstar as codeowner for solax integration * Update manifest.json --- CODEOWNERS | 4 ++-- homeassistant/components/solax/manifest.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 489b848c772..f16b890d407 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1408,8 +1408,8 @@ build.json @home-assistant/supervisor /homeassistant/components/solaredge_local/ @drobtravels @scheric /homeassistant/components/solarlog/ @Ernst79 @dontinelli /tests/components/solarlog/ @Ernst79 @dontinelli -/homeassistant/components/solax/ @squishykid -/tests/components/solax/ @squishykid +/homeassistant/components/solax/ @squishykid @Darsstar +/tests/components/solax/ @squishykid @Darsstar /homeassistant/components/soma/ @ratsept @sebfortier2288 /tests/components/soma/ @ratsept @sebfortier2288 /homeassistant/components/sonarr/ @ctalkington diff --git a/homeassistant/components/solax/manifest.json b/homeassistant/components/solax/manifest.json index 925f11e4c65..5509901ae02 100644 --- a/homeassistant/components/solax/manifest.json +++ b/homeassistant/components/solax/manifest.json @@ -1,7 +1,7 @@ { "domain": "solax", "name": "SolaX Power", - "codeowners": ["@squishykid"], + "codeowners": ["@squishykid", "@Darsstar"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/solax", "iot_class": "local_polling", From 733e1feba3ef929c33d236b9ff7c3e465410b618 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 26 Jan 2025 01:20:05 +0100 Subject: [PATCH 0018/3148] Fix wrong plural on tado.add_meter_reading action (#136524) As this action can only take a single argument the plural introduced in the descriptions is misleading. This also makes the friendly name of the action consistent with its key name. --- homeassistant/components/tado/strings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/tado/strings.json b/homeassistant/components/tado/strings.json index 8124570f9c9..735fe34bcf4 100644 --- a/homeassistant/components/tado/strings.json +++ b/homeassistant/components/tado/strings.json @@ -135,12 +135,12 @@ } }, "add_meter_reading": { - "name": "Add meter readings", - "description": "Add meter readings to Tado Energy IQ.", + "name": "Add meter reading", + "description": "Adds a meter reading to Tado Energy IQ.", "fields": { "config_entry": { "name": "Config Entry", - "description": "Config entry to add meter readings to." + "description": "Config entry to add meter reading to." }, "reading": { "name": "Reading", From 1a57992e78bb051e4c54685f23810e72508fd975 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Sun, 26 Jan 2025 01:20:41 +0100 Subject: [PATCH 0019/3148] Add restore backup tests (#136538) * Test restore backup with busy manager * Test restore backup with agent error * Test restore backup with file error --- tests/components/backup/test_manager.py | 262 ++++++++++++++++++++++++ 1 file changed, 262 insertions(+) diff --git a/tests/components/backup/test_manager.py b/tests/components/backup/test_manager.py index c961230e9e6..48e6db4ae9a 100644 --- a/tests/components/backup/test_manager.py +++ b/tests/components/backup/test_manager.py @@ -2483,3 +2483,265 @@ async def test_restore_backup_wrong_parameters( mocked_write_text.assert_not_called() mocked_service_call.assert_not_called() + + +@pytest.mark.usefixtures("mock_backup_generation") +async def test_restore_backup_when_busy( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test restore backup with busy manager.""" + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + ws_client = await hass_ws_client(hass) + + await ws_client.send_json_auto_id( + {"type": "backup/generate", "agent_ids": [LOCAL_AGENT_ID]} + ) + result = await ws_client.receive_json() + + assert result["success"] is True + + await ws_client.send_json_auto_id( + { + "type": "backup/restore", + "backup_id": TEST_BACKUP_ABC123.backup_id, + "agent_id": LOCAL_AGENT_ID, + } + ) + result = await ws_client.receive_json() + + assert result["success"] is False + assert result["error"]["code"] == "home_assistant_error" + assert result["error"]["message"] == "Backup manager busy: create_backup" + + +@pytest.mark.usefixtures("mock_backup_generation") +@pytest.mark.parametrize( + ("exception", "error_code", "error_message"), + [ + (BackupAgentError("Boom!"), "home_assistant_error", "Boom!"), + (Exception("Boom!"), "unknown_error", "Unknown error"), + ], +) +async def test_restore_backup_agent_error( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + exception: Exception, + error_code: str, + error_message: str, +) -> None: + """Test restore backup with agent error.""" + remote_agent = BackupAgentTest("remote", backups=[TEST_BACKUP_ABC123]) + await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + await setup_backup_platform( + hass, + domain="test", + platform=Mock( + async_get_backup_agents=AsyncMock(return_value=[remote_agent]), + spec_set=BackupAgentPlatformProtocol, + ), + ) + ws_client = await hass_ws_client(hass) + + await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) + + result = await ws_client.receive_json() + assert result["event"] == {"manager_state": BackupManagerState.IDLE} + + result = await ws_client.receive_json() + assert result["success"] is True + + with ( + patch("pathlib.Path.open"), + patch("pathlib.Path.write_text") as mocked_write_text, + patch("homeassistant.core.ServiceRegistry.async_call") as mocked_service_call, + patch.object( + remote_agent, "async_download_backup", side_effect=exception + ) as download_mock, + ): + await ws_client.send_json_auto_id( + { + "type": "backup/restore", + "backup_id": TEST_BACKUP_ABC123.backup_id, + "agent_id": remote_agent.agent_id, + } + ) + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.RESTORE_BACKUP, + "stage": None, + "state": RestoreBackupState.IN_PROGRESS, + } + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.RESTORE_BACKUP, + "stage": None, + "state": RestoreBackupState.FAILED, + } + + result = await ws_client.receive_json() + assert result["event"] == {"manager_state": BackupManagerState.IDLE} + + result = await ws_client.receive_json() + assert not result["success"] + assert result["error"]["code"] == error_code + assert result["error"]["message"] == error_message + + assert download_mock.call_count == 1 + assert mocked_write_text.call_count == 0 + assert mocked_service_call.call_count == 0 + + +@pytest.mark.usefixtures("mock_backup_generation") +@pytest.mark.parametrize( + ( + "open_call_count", + "open_exception", + "write_call_count", + "write_exception", + "close_call_count", + "close_exception", + "write_text_call_count", + "write_text_exception", + "validate_password_call_count", + ), + [ + ( + 1, + OSError("Boom!"), + 0, + None, + 0, + None, + 0, + None, + 0, + ), + ( + 1, + None, + 1, + OSError("Boom!"), + 1, + None, + 0, + None, + 0, + ), + ( + 1, + None, + 1, + None, + 1, + OSError("Boom!"), + 0, + None, + 0, + ), + ( + 1, + None, + 1, + None, + 1, + None, + 1, + OSError("Boom!"), + 1, + ), + ], +) +async def test_restore_backup_file_error( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + open_call_count: int, + open_exception: list[Exception | None], + write_call_count: int, + write_exception: Exception | None, + close_call_count: int, + close_exception: list[Exception | None], + write_text_call_count: int, + write_text_exception: Exception | None, + validate_password_call_count: int, +) -> None: + """Test restore backup with file error.""" + remote_agent = BackupAgentTest("remote", backups=[TEST_BACKUP_ABC123]) + await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + await setup_backup_platform( + hass, + domain="test", + platform=Mock( + async_get_backup_agents=AsyncMock(return_value=[remote_agent]), + spec_set=BackupAgentPlatformProtocol, + ), + ) + ws_client = await hass_ws_client(hass) + + await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) + + result = await ws_client.receive_json() + assert result["event"] == {"manager_state": BackupManagerState.IDLE} + + result = await ws_client.receive_json() + assert result["success"] is True + + open_mock = mock_open() + open_mock.side_effect = open_exception + open_mock.return_value.write.side_effect = write_exception + open_mock.return_value.close.side_effect = close_exception + + with ( + patch("pathlib.Path.open", open_mock), + patch( + "pathlib.Path.write_text", side_effect=write_text_exception + ) as mocked_write_text, + patch("homeassistant.core.ServiceRegistry.async_call") as mocked_service_call, + patch( + "homeassistant.components.backup.manager.validate_password" + ) as validate_password_mock, + patch.object(remote_agent, "async_download_backup") as download_mock, + ): + download_mock.return_value.__aiter__.return_value = iter((b"backup data",)) + await ws_client.send_json_auto_id( + { + "type": "backup/restore", + "backup_id": TEST_BACKUP_ABC123.backup_id, + "agent_id": remote_agent.agent_id, + } + ) + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.RESTORE_BACKUP, + "stage": None, + "state": RestoreBackupState.IN_PROGRESS, + } + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.RESTORE_BACKUP, + "stage": None, + "state": RestoreBackupState.FAILED, + } + + result = await ws_client.receive_json() + assert result["event"] == {"manager_state": BackupManagerState.IDLE} + + result = await ws_client.receive_json() + assert not result["success"] + assert result["error"]["code"] == "unknown_error" + assert result["error"]["message"] == "Unknown error" + + assert download_mock.call_count == 1 + assert validate_password_mock.call_count == validate_password_call_count + assert open_mock.call_count == open_call_count + assert open_mock.return_value.write.call_count == write_call_count + assert open_mock.return_value.close.call_count == close_call_count + assert mocked_write_text.call_count == write_text_call_count + assert mocked_service_call.call_count == 0 From ee07f1f290a4ac9b4531c751cc04537be727c47b Mon Sep 17 00:00:00 2001 From: Dan Raper Date: Sun, 26 Jan 2025 01:05:20 +0000 Subject: [PATCH 0020/3148] Bump ohmepy version to 1.2.6 (#136547) --- homeassistant/components/ohme/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ohme/manifest.json b/homeassistant/components/ohme/manifest.json index 67c41550491..bb3716c3e74 100644 --- a/homeassistant/components/ohme/manifest.json +++ b/homeassistant/components/ohme/manifest.json @@ -7,5 +7,5 @@ "integration_type": "device", "iot_class": "cloud_polling", "quality_scale": "silver", - "requirements": ["ohme==1.2.5"] + "requirements": ["ohme==1.2.6"] } diff --git a/requirements_all.txt b/requirements_all.txt index f9a58779c8e..d006effb24f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1540,7 +1540,7 @@ odp-amsterdam==6.0.2 oemthermostat==1.1.1 # homeassistant.components.ohme -ohme==1.2.5 +ohme==1.2.6 # homeassistant.components.ollama ollama==0.4.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 127d08c22d6..6e720c4ee55 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1288,7 +1288,7 @@ objgraph==3.5.0 odp-amsterdam==6.0.2 # homeassistant.components.ohme -ohme==1.2.5 +ohme==1.2.6 # homeassistant.components.ollama ollama==0.4.7 From f8013655be6fcff339702619a149a2553e9a15fe Mon Sep 17 00:00:00 2001 From: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> Date: Sun, 26 Jan 2025 04:20:37 -0600 Subject: [PATCH 0021/3148] Move action implementation out of HEOS Coordinator (#136539) * Move play_source * Update property docstring * Correct import location --- homeassistant/components/heos/coordinator.py | 28 +++++-------------- homeassistant/components/heos/media_player.py | 16 ++++++++++- 2 files changed, 22 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/heos/coordinator.py b/homeassistant/components/heos/coordinator.py index 8ed8449685a..9fc3bb2460f 100644 --- a/homeassistant/components/heos/coordinator.py +++ b/homeassistant/components/heos/coordinator.py @@ -15,7 +15,6 @@ from pyheos import ( HeosError, HeosNowPlayingMedia, HeosOptions, - HeosPlayer, MediaItem, MediaType, PlayerUpdateResult, @@ -25,12 +24,12 @@ from pyheos import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HassJob, HomeAssistant, callback -from homeassistant.exceptions import ConfigEntryNotReady, ServiceValidationError +from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.event import async_call_later from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from . import DOMAIN +from .const import DOMAIN _LOGGER = logging.getLogger(__name__) @@ -62,6 +61,11 @@ class HeosCoordinator(DataUpdateCoordinator[None]): self._inputs: list[MediaItem] = [] super().__init__(hass, _LOGGER, config_entry=config_entry, name=DOMAIN) + @property + def inputs(self) -> list[MediaItem]: + """Get input sources across all devices.""" + return self._inputs + async def async_setup(self) -> None: """Set up the coordinator; connect to the host; and retrieve initial data.""" # Add before connect as it may occur during initial connection @@ -265,21 +269,3 @@ class HeosCoordinator(DataUpdateCoordinator[None]): ): return favorite.name return None - - async def async_play_source(self, source: str, player: HeosPlayer) -> None: - """Determine type of source and play it.""" - # Favorite - if (index := self.async_get_favorite_index(source)) is not None: - await player.play_preset_station(index) - return - # Input source - for input_source in self._inputs: - if input_source.name == source: - await player.play_media(input_source) - return - - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="unknown_source", - translation_placeholders={"source": source}, - ) diff --git a/homeassistant/components/heos/media_player.py b/homeassistant/components/heos/media_player.py index d405b235f76..547f932c21f 100644 --- a/homeassistant/components/heos/media_player.py +++ b/homeassistant/components/heos/media_player.py @@ -306,7 +306,21 @@ class HeosMediaPlayer(CoordinatorEntity[HeosCoordinator], MediaPlayerEntity): @catch_action_error("select source") async def async_select_source(self, source: str) -> None: """Select input source.""" - await self.coordinator.async_play_source(source, self._player) + # Favorite + if (index := self.coordinator.async_get_favorite_index(source)) is not None: + await self._player.play_preset_station(index) + return + # Input source + for input_source in self.coordinator.inputs: + if input_source.name == source: + await self._player.play_media(input_source) + return + + raise ServiceValidationError( + translation_domain=HEOS_DOMAIN, + translation_key="unknown_source", + translation_placeholders={"source": source}, + ) @catch_action_error("set repeat") async def async_set_repeat(self, repeat: RepeatMode) -> None: From 3adbf751549850645a9d5dad98faba164da87a54 Mon Sep 17 00:00:00 2001 From: tronikos Date: Sun, 26 Jan 2025 03:06:05 -0800 Subject: [PATCH 0022/3148] Bump opower to 0.8.8 (#136555) --- homeassistant/components/opower/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/opower/manifest.json b/homeassistant/components/opower/manifest.json index bd68cc84d13..7227f7171ac 100644 --- a/homeassistant/components/opower/manifest.json +++ b/homeassistant/components/opower/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/opower", "iot_class": "cloud_polling", "loggers": ["opower"], - "requirements": ["opower==0.8.7"] + "requirements": ["opower==0.8.8"] } diff --git a/requirements_all.txt b/requirements_all.txt index d006effb24f..e4231d4562a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1585,7 +1585,7 @@ openwrt-luci-rpc==1.1.17 openwrt-ubus-rpc==0.0.2 # homeassistant.components.opower -opower==0.8.7 +opower==0.8.8 # homeassistant.components.oralb oralb-ble==0.17.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6e720c4ee55..bb8cc060fbc 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1321,7 +1321,7 @@ openhomedevice==2.2.0 openwebifpy==4.3.1 # homeassistant.components.opower -opower==0.8.7 +opower==0.8.8 # homeassistant.components.oralb oralb-ble==0.17.6 From 93a231fb19b9e34097691d03da01406efd4ad943 Mon Sep 17 00:00:00 2001 From: Markus Adrario Date: Sun, 26 Jan 2025 12:49:28 +0000 Subject: [PATCH 0023/3148] Homee cover-test (#136563) initial cover-test --- tests/components/homee/__init__.py | 12 ++ tests/components/homee/conftest.py | 21 ++-- tests/components/homee/fixtures/cover1.json | 101 ++++++++++++++++ tests/components/homee/fixtures/cover2.json | 101 ++++++++++++++++ tests/components/homee/fixtures/cover3.json | 101 ++++++++++++++++ tests/components/homee/fixtures/cover4.json | 101 ++++++++++++++++ tests/components/homee/test_cover.py | 124 ++++++++++++++++++++ 7 files changed, 551 insertions(+), 10 deletions(-) create mode 100644 tests/components/homee/fixtures/cover1.json create mode 100644 tests/components/homee/fixtures/cover2.json create mode 100644 tests/components/homee/fixtures/cover3.json create mode 100644 tests/components/homee/fixtures/cover4.json create mode 100644 tests/components/homee/test_cover.py diff --git a/tests/components/homee/__init__.py b/tests/components/homee/__init__.py index 03095aca7df..95fc6099269 100644 --- a/tests/components/homee/__init__.py +++ b/tests/components/homee/__init__.py @@ -1 +1,13 @@ """Tests for the homee component.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Set up the component.""" + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/homee/conftest.py b/tests/components/homee/conftest.py index a777f6b59a9..fb94ba0bbcc 100644 --- a/tests/components/homee/conftest.py +++ b/tests/components/homee/conftest.py @@ -17,6 +17,15 @@ TESTUSER = "testuser" TESTPASS = "testpass" +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Mock setting up a config entry.""" + with patch( + "homeassistant.components.homee.async_setup_entry", return_value=True + ) as mock_setup: + yield mock_setup + + @pytest.fixture def mock_config_entry() -> MockConfigEntry: """Return the default mocked config entry.""" @@ -32,15 +41,6 @@ def mock_config_entry() -> MockConfigEntry: ) -@pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock]: - """Mock setting up a config entry.""" - with patch( - "homeassistant.components.homee.async_setup_entry", return_value=True - ) as mock_setup: - yield mock_setup - - @pytest.fixture def mock_homee() -> Generator[AsyncMock]: """Return a mock Homee instance.""" @@ -50,7 +50,7 @@ def mock_homee() -> Generator[AsyncMock]: ) as mocked_homee, patch( "homeassistant.components.homee.Homee", - autospec=True, + new=mocked_homee, ), ): homee = mocked_homee.return_value @@ -62,6 +62,7 @@ def mock_homee() -> Generator[AsyncMock]: homee.settings.uid = HOMEE_ID homee.settings.homee_name = HOMEE_NAME homee.reconnect_interval = 10 + homee.connected = True homee.get_access_token.return_value = "test_token" diff --git a/tests/components/homee/fixtures/cover1.json b/tests/components/homee/fixtures/cover1.json new file mode 100644 index 00000000000..8fedfb19d4f --- /dev/null +++ b/tests/components/homee/fixtures/cover1.json @@ -0,0 +1,101 @@ +{ + "id": 3, + "name": "Test%20Cover", + "profile": 2002, + "image": "default", + "favorite": 0, + "order": 4, + "protocol": 23, + "routing": 0, + "state": 1, + "state_changed": 1687175681, + "added": 1672086680, + "history": 1, + "cube_type": 14, + "note": "TestCoverDevice", + "services": 7, + "phonetic_name": "", + "owner": 2, + "security": 0, + "attributes": [ + { + "id": 1, + "node_id": 3, + "instance": 0, + "minimum": 0, + "maximum": 4, + "current_value": 1.0, + "target_value": 1.0, + "last_value": 4.0, + "unit": "n%2Fa", + "step_value": 1.0, + "editable": 1, + "type": 135, + "state": 1, + "last_changed": 1687175680, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "can_observe": [300], + "observes": [75], + "automations": ["toggle"] + } + }, + { + "id": 2, + "node_id": 3, + "instance": 0, + "minimum": 0, + "maximum": 100, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 0.0, + "unit": "%25", + "step_value": 0.5, + "editable": 1, + "type": 15, + "state": 1, + "last_changed": 1687175680, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["step"], + "history": { + "day": 35, + "week": 5, + "month": 1 + } + } + }, + { + "id": 3, + "node_id": 3, + "instance": 0, + "minimum": -45, + "maximum": 90, + "current_value": -45.0, + "target_value": 0.0, + "last_value": -45.0, + "unit": "%C2%B0", + "step_value": 1.0, + "editable": 1, + "type": 113, + "state": 1, + "last_changed": 1678284920, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["step"] + } + } + ] +} diff --git a/tests/components/homee/fixtures/cover2.json b/tests/components/homee/fixtures/cover2.json new file mode 100644 index 00000000000..b53c3d49b62 --- /dev/null +++ b/tests/components/homee/fixtures/cover2.json @@ -0,0 +1,101 @@ +{ + "id": 1, + "name": "Test%20Cover", + "profile": 2002, + "image": "default", + "favorite": 0, + "order": 4, + "protocol": 23, + "routing": 0, + "state": 1, + "state_changed": 1687175681, + "added": 1672086680, + "history": 1, + "cube_type": 14, + "note": "TestCoverDevice", + "services": 7, + "phonetic_name": "", + "owner": 2, + "security": 0, + "attributes": [ + { + "id": 1, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 4, + "current_value": 1.0, + "target_value": 1.0, + "last_value": 0.0, + "unit": "n%2Fa", + "step_value": 1.0, + "editable": 1, + "type": 135, + "state": 1, + "last_changed": 1687175680, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "can_observe": [300], + "observes": [75], + "automations": ["toggle"] + } + }, + { + "id": 2, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 100, + "current_value": 100.0, + "target_value": 0.0, + "last_value": 0.0, + "unit": "%25", + "step_value": 0.5, + "editable": 1, + "type": 15, + "state": 1, + "last_changed": 1687175680, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["step"], + "history": { + "day": 35, + "week": 5, + "month": 1 + } + } + }, + { + "id": 3, + "node_id": 1, + "instance": 0, + "minimum": -45, + "maximum": 90, + "current_value": 90.0, + "target_value": 0.0, + "last_value": -45.0, + "unit": "%C2%B0", + "step_value": 1.0, + "editable": 1, + "type": 113, + "state": 1, + "last_changed": 1678284920, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["step"] + } + } + ] +} diff --git a/tests/components/homee/fixtures/cover3.json b/tests/components/homee/fixtures/cover3.json new file mode 100644 index 00000000000..0d3d5ea57e2 --- /dev/null +++ b/tests/components/homee/fixtures/cover3.json @@ -0,0 +1,101 @@ +{ + "id": 3, + "name": "Test%20Cover", + "profile": 2002, + "image": "default", + "favorite": 0, + "order": 4, + "protocol": 23, + "routing": 0, + "state": 1, + "state_changed": 1687175681, + "added": 1672086680, + "history": 1, + "cube_type": 14, + "note": "TestCoverDevice", + "services": 7, + "phonetic_name": "", + "owner": 2, + "security": 0, + "attributes": [ + { + "id": 1, + "node_id": 3, + "instance": 0, + "minimum": 0, + "maximum": 4, + "current_value": 3.0, + "target_value": 0.0, + "last_value": 1.0, + "unit": "n%2Fa", + "step_value": 1.0, + "editable": 1, + "type": 135, + "state": 1, + "last_changed": 1687175680, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "can_observe": [300], + "observes": [75], + "automations": ["toggle"] + } + }, + { + "id": 2, + "node_id": 3, + "instance": 0, + "minimum": 0, + "maximum": 100, + "current_value": 75.0, + "target_value": 0.0, + "last_value": 100.0, + "unit": "%25", + "step_value": 0.5, + "editable": 1, + "type": 15, + "state": 1, + "last_changed": 1687175680, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["step"], + "history": { + "day": 35, + "week": 5, + "month": 1 + } + } + }, + { + "id": 3, + "node_id": 3, + "instance": 0, + "minimum": -45, + "maximum": 90, + "current_value": 56.0, + "target_value": 56.0, + "last_value": 0.0, + "unit": "%C2%B0", + "step_value": 1.0, + "editable": 1, + "type": 113, + "state": 1, + "last_changed": 1678284920, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["step"] + } + } + ] +} diff --git a/tests/components/homee/fixtures/cover4.json b/tests/components/homee/fixtures/cover4.json new file mode 100644 index 00000000000..a3de555794a --- /dev/null +++ b/tests/components/homee/fixtures/cover4.json @@ -0,0 +1,101 @@ +{ + "id": 3, + "name": "Test%20Cover", + "profile": 2002, + "image": "default", + "favorite": 0, + "order": 4, + "protocol": 23, + "routing": 0, + "state": 1, + "state_changed": 1687175681, + "added": 1672086680, + "history": 1, + "cube_type": 14, + "note": "TestCoverDevice", + "services": 7, + "phonetic_name": "", + "owner": 2, + "security": 0, + "attributes": [ + { + "id": 1, + "node_id": 3, + "instance": 0, + "minimum": 0, + "maximum": 4, + "current_value": 4.0, + "target_value": 1.0, + "last_value": 0.0, + "unit": "n%2Fa", + "step_value": 1.0, + "editable": 1, + "type": 135, + "state": 1, + "last_changed": 1687175680, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "can_observe": [300], + "observes": [75], + "automations": ["toggle"] + } + }, + { + "id": 2, + "node_id": 3, + "instance": 0, + "minimum": 0, + "maximum": 100, + "current_value": 25.0, + "target_value": 100.0, + "last_value": 0.0, + "unit": "%25", + "step_value": 0.5, + "editable": 1, + "type": 15, + "state": 1, + "last_changed": 1687175680, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["step"], + "history": { + "day": 35, + "week": 5, + "month": 1 + } + } + }, + { + "id": 3, + "node_id": 3, + "instance": 0, + "minimum": -45, + "maximum": 90, + "current_value": -11.0, + "target_value": 0.0, + "last_value": -45.0, + "unit": "%C2%B0", + "step_value": 1.0, + "editable": 1, + "type": 113, + "state": 1, + "last_changed": 1678284920, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["step"] + } + } + ] +} diff --git a/tests/components/homee/test_cover.py b/tests/components/homee/test_cover.py new file mode 100644 index 00000000000..a7feaa10b66 --- /dev/null +++ b/tests/components/homee/test_cover.py @@ -0,0 +1,124 @@ +"""Test homee covers.""" + +from unittest.mock import AsyncMock, MagicMock + +from pyHomee import HomeeNode + +from homeassistant.components.cover import DOMAIN as COVER_DOMAIN, CoverState +from homeassistant.components.homee.const import DOMAIN +from homeassistant.const import ATTR_ENTITY_ID, SERVICE_CLOSE_COVER, SERVICE_OPEN_COVER +from homeassistant.core import HomeAssistant + +from . import setup_integration + +from tests.common import MockConfigEntry, load_json_object_fixture + + +async def test_cover_open( + hass: HomeAssistant, mock_homee: AsyncMock, mock_config_entry: MockConfigEntry +) -> None: + """Test an open cover.""" + # Cover open, tilt open. + cover_json = load_json_object_fixture("cover1.json", DOMAIN) + cover_node = HomeeNode(cover_json) + mock_homee.nodes = [cover_node] + + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("cover.test_cover").state == CoverState.OPEN + + attributes = hass.states.get("cover.test_cover").attributes + assert attributes.get("supported_features") == 143 + assert attributes.get("current_position") == 100 + assert attributes.get("current_tilt_position") == 100 + + +async def test_cover_closed( + hass: HomeAssistant, mock_homee: MagicMock, mock_config_entry: MockConfigEntry +) -> None: + """Test a closed cover.""" + # Cover closed, tilt closed. + cover_json = load_json_object_fixture("cover2.json", DOMAIN) + cover_node = HomeeNode(cover_json) + mock_homee.nodes = [cover_node] + + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("cover.test_cover").state == CoverState.CLOSED + attributes = hass.states.get("cover.test_cover").attributes + assert attributes.get("current_position") == 0 + assert attributes.get("current_tilt_position") == 0 + + +async def test_cover_opening( + hass: HomeAssistant, mock_homee: MagicMock, mock_config_entry: MockConfigEntry +) -> None: + """Test an opening cover.""" + # opening, 75% homee / 25% HA + cover_json = load_json_object_fixture("cover3.json", DOMAIN) + cover_node = HomeeNode(cover_json) + mock_homee.nodes = [cover_node] + + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("cover.test_cover").state == CoverState.OPENING + attributes = hass.states.get("cover.test_cover").attributes + assert attributes.get("current_position") == 25 + assert attributes.get("current_tilt_position") == 25 + + +async def test_cover_closing( + hass: HomeAssistant, mock_homee: MagicMock, mock_config_entry: MockConfigEntry +) -> None: + """Test a closing cover.""" + # closing, 25% homee / 75% HA + cover_json = load_json_object_fixture("cover4.json", DOMAIN) + cover_node = HomeeNode(cover_json) + mock_homee.nodes = [cover_node] + + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("cover.test_cover").state == CoverState.CLOSING + attributes = hass.states.get("cover.test_cover").attributes + assert attributes.get("current_position") == 75 + assert attributes.get("current_tilt_position") == 74 + + +async def test_open_cover( + hass: HomeAssistant, mock_homee: MagicMock, mock_config_entry: MockConfigEntry +) -> None: + """Test opening the cover.""" + # Cover closed, tilt closed. + cover_json = load_json_object_fixture("cover2.json", DOMAIN) + cover_node = HomeeNode(cover_json) + mock_homee.nodes = [cover_node] + + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: "cover.test_cover"}, + blocking=True, + ) + mock_homee.set_value.assert_called_once_with(cover_node.id, 1, 0) + + +async def test_close_cover( + hass: HomeAssistant, mock_homee: MagicMock, mock_config_entry: MockConfigEntry +) -> None: + """Test opening the cover.""" + # Cover open, tilt open. + cover_json = load_json_object_fixture("cover1.json", DOMAIN) + cover_node = HomeeNode(cover_json) + mock_homee.nodes = [cover_node] + + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: "cover.test_cover"}, + blocking=True, + ) + mock_homee.set_value.assert_called_once_with(cover_node.id, 1, 1) From 7044771876a12d3d0f4a23b67664a3805f59ba2a Mon Sep 17 00:00:00 2001 From: Dan Raper Date: Sun, 26 Jan 2025 12:52:01 +0000 Subject: [PATCH 0024/3148] Add select platform to Ohme (#136536) * Add select platform * Formatting * Add parallel updates to select * Remove comments --- homeassistant/components/ohme/const.py | 1 + homeassistant/components/ohme/icons.json | 5 ++ homeassistant/components/ohme/select.py | 70 ++++++++++++++++++ homeassistant/components/ohme/strings.json | 10 +++ .../ohme/snapshots/test_select.ambr | 58 +++++++++++++++ tests/components/ohme/test_select.py | 72 +++++++++++++++++++ 6 files changed, 216 insertions(+) create mode 100644 homeassistant/components/ohme/select.py create mode 100644 tests/components/ohme/snapshots/test_select.ambr create mode 100644 tests/components/ohme/test_select.py diff --git a/homeassistant/components/ohme/const.py b/homeassistant/components/ohme/const.py index 308664ba0ad..d97f6e3cfd7 100644 --- a/homeassistant/components/ohme/const.py +++ b/homeassistant/components/ohme/const.py @@ -6,6 +6,7 @@ DOMAIN = "ohme" PLATFORMS = [ Platform.BUTTON, Platform.NUMBER, + Platform.SELECT, Platform.SENSOR, Platform.SWITCH, Platform.TIME, diff --git a/homeassistant/components/ohme/icons.json b/homeassistant/components/ohme/icons.json index a6b04004833..7a27156b2fe 100644 --- a/homeassistant/components/ohme/icons.json +++ b/homeassistant/components/ohme/icons.json @@ -10,6 +10,11 @@ "default": "mdi:battery-heart" } }, + "select": { + "charge_mode": { + "default": "mdi:play-box" + } + }, "sensor": { "status": { "default": "mdi:car", diff --git a/homeassistant/components/ohme/select.py b/homeassistant/components/ohme/select.py new file mode 100644 index 00000000000..a357e98f0a6 --- /dev/null +++ b/homeassistant/components/ohme/select.py @@ -0,0 +1,70 @@ +"""Platform for Ohme selects.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from typing import Any, Final + +from ohme import ApiException, ChargerMode, OhmeApiClient + +from homeassistant.components.select import SelectEntity, SelectEntityDescription +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import OhmeConfigEntry +from .const import DOMAIN +from .entity import OhmeEntity, OhmeEntityDescription + +PARALLEL_UPDATES = 1 + + +@dataclass(frozen=True, kw_only=True) +class OhmeSelectDescription(OhmeEntityDescription, SelectEntityDescription): + """Class to describe an Ohme select entity.""" + + select_fn: Callable[[OhmeApiClient, Any], Awaitable[None]] + current_option_fn: Callable[[OhmeApiClient], str | None] + + +SELECT_DESCRIPTION: Final[OhmeSelectDescription] = OhmeSelectDescription( + key="charge_mode", + translation_key="charge_mode", + select_fn=lambda client, mode: client.async_set_mode(mode), + options=[e.value for e in ChargerMode], + current_option_fn=lambda client: client.mode.value if client.mode else None, + available_fn=lambda client: client.mode is not None, +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: OhmeConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Ohme selects.""" + coordinator = config_entry.runtime_data.charge_session_coordinator + + async_add_entities([OhmeSelect(coordinator, SELECT_DESCRIPTION)]) + + +class OhmeSelect(OhmeEntity, SelectEntity): + """Ohme select entity.""" + + entity_description: OhmeSelectDescription + + async def async_select_option(self, option: str) -> None: + """Handle the selection of an option.""" + try: + await self.entity_description.select_fn(self.coordinator.client, option) + except ApiException as e: + raise HomeAssistantError( + translation_key="api_failed", translation_domain=DOMAIN + ) from e + await self.coordinator.async_request_refresh() + + @property + def current_option(self) -> str | None: + """Return the current selected option.""" + return self.entity_description.current_option_fn(self.coordinator.client) diff --git a/homeassistant/components/ohme/strings.json b/homeassistant/components/ohme/strings.json index 84f62ba65ab..eb5bbffda52 100644 --- a/homeassistant/components/ohme/strings.json +++ b/homeassistant/components/ohme/strings.json @@ -55,6 +55,16 @@ "name": "Target percentage" } }, + "select": { + "charge_mode": { + "name": "Charge mode", + "state": { + "smart_charge": "Smart charge", + "max_charge": "Max charge", + "paused": "Paused" + } + } + }, "sensor": { "status": { "name": "Status", diff --git a/tests/components/ohme/snapshots/test_select.ambr b/tests/components/ohme/snapshots/test_select.ambr new file mode 100644 index 00000000000..04770397098 --- /dev/null +++ b/tests/components/ohme/snapshots/test_select.ambr @@ -0,0 +1,58 @@ +# serializer version: 1 +# name: test_selects[select.ohme_home_pro_charge_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'smart_charge', + 'max_charge', + 'paused', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.ohme_home_pro_charge_mode', + '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': 'Charge mode', + 'platform': 'ohme', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charge_mode', + 'unique_id': 'chargerid_charge_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_selects[select.ohme_home_pro_charge_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Ohme Home Pro Charge mode', + 'options': list([ + 'smart_charge', + 'max_charge', + 'paused', + ]), + }), + 'context': , + 'entity_id': 'select.ohme_home_pro_charge_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/ohme/test_select.py b/tests/components/ohme/test_select.py new file mode 100644 index 00000000000..5aeebc1f477 --- /dev/null +++ b/tests/components/ohme/test_select.py @@ -0,0 +1,72 @@ +"""Tests for selects.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +from ohme import ChargerMode +from syrupy import SnapshotAssertion + +from homeassistant.const import STATE_UNAVAILABLE, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +async def test_selects( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, + mock_client: MagicMock, +) -> None: + """Test the Ohme selects.""" + with patch("homeassistant.components.ohme.PLATFORMS", [Platform.SELECT]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_select_option( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_client: MagicMock, +) -> None: + """Test selecting an option in the Ohme select entity.""" + mock_client.mode = ChargerMode.SMART_CHARGE + mock_client.async_set_mode = AsyncMock() + + await setup_integration(hass, mock_config_entry) + + state = hass.states.get("select.ohme_home_pro_charge_mode") + assert state is not None + assert state.state == "smart_charge" + + await hass.services.async_call( + "select", + "select_option", + { + "entity_id": "select.ohme_home_pro_charge_mode", + "option": "max_charge", + }, + blocking=True, + ) + + mock_client.async_set_mode.assert_called_once_with("max_charge") + assert state.state == "smart_charge" + + +async def test_select_unavailable( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_client: MagicMock, +) -> None: + """Test that the select entity shows as unavailable when no mode is set.""" + mock_client.mode = None + + await setup_integration(hass, mock_config_entry) + + state = hass.states.get("select.ohme_home_pro_charge_mode") + assert state is not None + assert state.state == STATE_UNAVAILABLE From a9f14ce174d39698b78c7076fad1f8338b26b7a8 Mon Sep 17 00:00:00 2001 From: Markus Adrario Date: Sun, 26 Jan 2025 13:48:35 +0000 Subject: [PATCH 0025/3148] Bump pyHomee to 1.2.5 (#136567) --- homeassistant/components/homee/__init__.py | 4 ++-- homeassistant/components/homee/entity.py | 8 ++++---- homeassistant/components/homee/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/homee/__init__.py b/homeassistant/components/homee/__init__.py index 1ec09e09694..9837d6094ff 100644 --- a/homeassistant/components/homee/__init__.py +++ b/homeassistant/components/homee/__init__.py @@ -51,14 +51,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: HomeeConfigEntry) -> boo entry.runtime_data = homee entry.async_on_unload(homee.disconnect) - async def _connection_update_callback(connected: bool) -> None: + def _connection_update_callback(connected: bool) -> None: """Call when the device is notified of changes.""" if connected: _LOGGER.warning("Reconnected to Homee at %s", entry.data[CONF_HOST]) else: _LOGGER.warning("Disconnected from Homee at %s", entry.data[CONF_HOST]) - await homee.add_connection_listener(_connection_update_callback) + homee.add_connection_listener(_connection_update_callback) # create device register entry device_registry = dr.async_get(hass) diff --git a/homeassistant/components/homee/entity.py b/homeassistant/components/homee/entity.py index a6cd54354bf..50b67e582bb 100644 --- a/homeassistant/components/homee/entity.py +++ b/homeassistant/components/homee/entity.py @@ -38,7 +38,7 @@ class HomeeEntity(Entity): self._attribute.add_on_changed_listener(self._on_node_updated) ) self.async_on_remove( - await self._entry.runtime_data.add_connection_listener( + self._entry.runtime_data.add_connection_listener( self._on_connection_changed ) ) @@ -56,7 +56,7 @@ class HomeeEntity(Entity): def _on_node_updated(self, attribute: HomeeAttribute) -> None: self.schedule_update_ha_state() - async def _on_connection_changed(self, connected: bool) -> None: + def _on_connection_changed(self, connected: bool) -> None: self._host_connected = connected self.schedule_update_ha_state() @@ -93,7 +93,7 @@ class HomeeNodeEntity(Entity): """Add the homee binary sensor device to home assistant.""" self.async_on_remove(self._node.add_on_changed_listener(self._on_node_updated)) self.async_on_remove( - await self._entry.runtime_data.add_connection_listener( + self._entry.runtime_data.add_connection_listener( self._on_connection_changed ) ) @@ -142,6 +142,6 @@ class HomeeNodeEntity(Entity): def _on_node_updated(self, node: HomeeNode) -> None: self.schedule_update_ha_state() - async def _on_connection_changed(self, connected: bool) -> None: + def _on_connection_changed(self, connected: bool) -> None: self._host_connected = connected self.schedule_update_ha_state() diff --git a/homeassistant/components/homee/manifest.json b/homeassistant/components/homee/manifest.json index 6d03547efc9..d85ba25b6e7 100644 --- a/homeassistant/components/homee/manifest.json +++ b/homeassistant/components/homee/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "loggers": ["homee"], "quality_scale": "bronze", - "requirements": ["pyHomee==1.2.3"] + "requirements": ["pyHomee==1.2.5"] } diff --git a/requirements_all.txt b/requirements_all.txt index e4231d4562a..b0ecc10914b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1763,7 +1763,7 @@ pyEmby==1.10 pyHik==0.3.2 # homeassistant.components.homee -pyHomee==1.2.3 +pyHomee==1.2.5 # homeassistant.components.rfxtrx pyRFXtrx==0.31.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bb8cc060fbc..9333914685a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1452,7 +1452,7 @@ pyDuotecno==2024.10.1 pyElectra==1.2.4 # homeassistant.components.homee -pyHomee==1.2.3 +pyHomee==1.2.5 # homeassistant.components.rfxtrx pyRFXtrx==0.31.1 From c9218b91c1950cebc502457ff5b396fa43a2ce1c Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 26 Jan 2025 16:33:43 +0100 Subject: [PATCH 0026/3148] Make casing of "server" and action descriptions consistent (#136561) --- .../components/music_assistant/strings.json | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/music_assistant/strings.json b/homeassistant/components/music_assistant/strings.json index af366c94310..32b72088518 100644 --- a/homeassistant/components/music_assistant/strings.json +++ b/homeassistant/components/music_assistant/strings.json @@ -7,15 +7,15 @@ } }, "manual": { - "title": "Manually add Music Assistant Server", - "description": "Enter the URL to your already running Music Assistant Server. If you do not have the Music Assistant Server running, you should install it first.", + "title": "Manually add Music Assistant server", + "description": "Enter the URL to your already running Music Assistant server. If you do not have the Music Assistant server running, you should install it first.", "data": { "url": "URL of the Music Assistant server" } }, "discovery_confirm": { - "description": "Do you want to add the Music Assistant Server `{url}` to Home Assistant?", - "title": "Discovered Music Assistant Server" + "description": "Do you want to add the Music Assistant server `{url}` to Home Assistant?", + "title": "Discovered Music Assistant server" } }, "error": { @@ -34,13 +34,13 @@ "issues": { "invalid_server_version": { "title": "The Music Assistant server is not the correct version", - "description": "Check if there are updates available for the Music Assistant Server and/or integration." + "description": "Check if there are updates available for the Music Assistant server and/or integration." } }, "services": { "play_media": { "name": "Play media", - "description": "Play media on a Music Assistant player with more fine-grained control options.", + "description": "Plays media on a Music Assistant player with more fine-grained control options.", "fields": { "media_id": { "name": "Media ID(s)", @@ -70,7 +70,7 @@ }, "play_announcement": { "name": "Play announcement", - "description": "Play announcement on a Music Assistant player with more fine-grained control options.", + "description": "Plays an announcement on a Music Assistant player with more fine-grained control options.", "fields": { "url": { "name": "URL", @@ -88,7 +88,7 @@ }, "transfer_queue": { "name": "Transfer queue", - "description": "Transfer the player's queue to another player.", + "description": "Transfers a player's queue to another player.", "fields": { "source_player": { "name": "Source media player", @@ -102,11 +102,11 @@ }, "get_queue": { "name": "Get playerQueue details (advanced)", - "description": "Get the details of the currently active queue of a Music Assistant player." + "description": "Retrieves the details of the currently active queue of a Music Assistant player." }, "search": { "name": "Search Music Assistant", - "description": "Perform a global search on the Music Assistant library and all providers.", + "description": "Performs a global search on the Music Assistant library and all providers.", "fields": { "config_entry_id": { "name": "Music Assistant instance", From b467bb2813e754a24431ff9b09fc7562e50b5945 Mon Sep 17 00:00:00 2001 From: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> Date: Sun, 26 Jan 2025 09:41:04 -0600 Subject: [PATCH 0027/3148] Use typed ConfigEntry throughout HEOS (#136569) --- homeassistant/components/heos/__init__.py | 5 +---- homeassistant/components/heos/config_flow.py | 5 ++--- homeassistant/components/heos/coordinator.py | 4 +++- homeassistant/components/heos/media_player.py | 3 +-- homeassistant/components/heos/services.py | 7 ++++++- 5 files changed, 13 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/heos/__init__.py b/homeassistant/components/heos/__init__.py index 10fd2bfcff3..b119ea83064 100644 --- a/homeassistant/components/heos/__init__.py +++ b/homeassistant/components/heos/__init__.py @@ -4,7 +4,6 @@ from __future__ import annotations from datetime import timedelta -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr @@ -13,7 +12,7 @@ from homeassistant.helpers.typing import ConfigType from . import services from .const import DOMAIN -from .coordinator import HeosCoordinator +from .coordinator import HeosConfigEntry, HeosCoordinator PLATFORMS = [Platform.MEDIA_PLAYER] @@ -21,8 +20,6 @@ MIN_UPDATE_SOURCES = timedelta(seconds=1) CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) -type HeosConfigEntry = ConfigEntry[HeosCoordinator] - async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the HEOS component.""" diff --git a/homeassistant/components/heos/config_flow.py b/homeassistant/components/heos/config_flow.py index 18b8f1f7918..db2abee559c 100644 --- a/homeassistant/components/heos/config_flow.py +++ b/homeassistant/components/heos/config_flow.py @@ -9,7 +9,6 @@ from pyheos import CommandAuthenticationError, Heos, HeosError, HeosOptions import voluptuous as vol from homeassistant.config_entries import ( - ConfigEntry, ConfigEntryState, ConfigFlow, ConfigFlowResult, @@ -23,8 +22,8 @@ from homeassistant.helpers.service_info.ssdp import ( SsdpServiceInfo, ) -from . import HeosConfigEntry from .const import DOMAIN +from .coordinator import HeosConfigEntry _LOGGER = logging.getLogger(__name__) @@ -107,7 +106,7 @@ class HeosFlowHandler(ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow: + def async_get_options_flow(config_entry: HeosConfigEntry) -> OptionsFlow: """Create the options flow.""" return HeosOptionsFlowHandler() diff --git a/homeassistant/components/heos/coordinator.py b/homeassistant/components/heos/coordinator.py index 9fc3bb2460f..1cd75049f16 100644 --- a/homeassistant/components/heos/coordinator.py +++ b/homeassistant/components/heos/coordinator.py @@ -33,11 +33,13 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) +type HeosConfigEntry = ConfigEntry[HeosCoordinator] + class HeosCoordinator(DataUpdateCoordinator[None]): """Define the HEOS integration coordinator.""" - def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: + def __init__(self, hass: HomeAssistant, config_entry: HeosConfigEntry) -> None: """Set up the coordinator and set in config_entry.""" self.host: str = config_entry.data[CONF_HOST] credentials: Credentials | None = None diff --git a/homeassistant/components/heos/media_player.py b/homeassistant/components/heos/media_player.py index 547f932c21f..bee03018f7c 100644 --- a/homeassistant/components/heos/media_player.py +++ b/homeassistant/components/heos/media_player.py @@ -38,9 +38,8 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util.dt import utcnow -from . import HeosConfigEntry from .const import DOMAIN as HEOS_DOMAIN -from .coordinator import HeosCoordinator +from .coordinator import HeosConfigEntry, HeosCoordinator PARALLEL_UPDATES = 0 diff --git a/homeassistant/components/heos/services.py b/homeassistant/components/heos/services.py index 5a0105f830e..c447befbb30 100644 --- a/homeassistant/components/heos/services.py +++ b/homeassistant/components/heos/services.py @@ -1,6 +1,7 @@ """Services for the HEOS integration.""" import logging +from typing import cast from pyheos import CommandAuthenticationError, Heos, HeosError import voluptuous as vol @@ -17,6 +18,7 @@ from .const import ( SERVICE_SIGN_IN, SERVICE_SIGN_OUT, ) +from .coordinator import HeosConfigEntry _LOGGER = logging.getLogger(__name__) @@ -59,7 +61,10 @@ def _get_controller(hass: HomeAssistant) -> Heos: translation_key="sign_in_out_deprecated", ) - entry = hass.config_entries.async_entry_for_domain_unique_id(DOMAIN, DOMAIN) + entry = cast( + HeosConfigEntry, + hass.config_entries.async_entry_for_domain_unique_id(DOMAIN, DOMAIN), + ) if not entry or not entry.state == ConfigEntryState.LOADED: raise HomeAssistantError( translation_domain=DOMAIN, translation_key="integration_not_loaded" From a2bc260dc15b285972bef28bec589ae628c9d2ef Mon Sep 17 00:00:00 2001 From: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> Date: Sun, 26 Jan 2025 09:51:29 -0600 Subject: [PATCH 0028/3148] Bump HEOS quality scale to silver (#136533) bump heos quality scale --- homeassistant/components/heos/manifest.json | 1 + script/hassfest/quality_scale.py | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/heos/manifest.json b/homeassistant/components/heos/manifest.json index e3d2632e340..ebeb851f37a 100644 --- a/homeassistant/components/heos/manifest.json +++ b/homeassistant/components/heos/manifest.json @@ -7,6 +7,7 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["pyheos"], + "quality_scale": "silver", "requirements": ["pyheos==1.0.0"], "single_config_entry": true, "ssdp": [ diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index 3732101913c..706a482523a 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -1544,7 +1544,6 @@ INTEGRATIONS_WITHOUT_SCALE = [ "haveibeenpwned", "hddtemp", "hdmi_cec", - "heos", "heatmiser", "here_travel_time", "hikvision", From 6a877ec77da9fd9f58b3a920534b97d9f26208cf Mon Sep 17 00:00:00 2001 From: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> Date: Sun, 26 Jan 2025 09:53:10 -0600 Subject: [PATCH 0029/3148] Don't cast type in HEOS services (#136583) --- homeassistant/components/heos/services.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/heos/services.py b/homeassistant/components/heos/services.py index c447befbb30..f4d5961cc47 100644 --- a/homeassistant/components/heos/services.py +++ b/homeassistant/components/heos/services.py @@ -1,7 +1,6 @@ """Services for the HEOS integration.""" import logging -from typing import cast from pyheos import CommandAuthenticationError, Heos, HeosError import voluptuous as vol @@ -61,10 +60,10 @@ def _get_controller(hass: HomeAssistant) -> Heos: translation_key="sign_in_out_deprecated", ) - entry = cast( - HeosConfigEntry, - hass.config_entries.async_entry_for_domain_unique_id(DOMAIN, DOMAIN), + entry: HeosConfigEntry | None = ( + hass.config_entries.async_entry_for_domain_unique_id(DOMAIN, DOMAIN) ) + if not entry or not entry.state == ConfigEntryState.LOADED: raise HomeAssistantError( translation_domain=DOMAIN, translation_key="integration_not_loaded" From b27ee261bbf02379bfcd2b165311008e64ccf386 Mon Sep 17 00:00:00 2001 From: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> Date: Sun, 26 Jan 2025 10:25:30 -0600 Subject: [PATCH 0030/3148] Fix HEOS play media type playlist (#136585) --- homeassistant/components/heos/media_player.py | 7 +++---- tests/components/heos/test_media_player.py | 2 +- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/heos/media_player.py b/homeassistant/components/heos/media_player.py index bee03018f7c..0c401f01470 100644 --- a/homeassistant/components/heos/media_player.py +++ b/homeassistant/components/heos/media_player.py @@ -279,13 +279,12 @@ class HeosMediaPlayer(CoordinatorEntity[HeosCoordinator], MediaPlayerEntity): return if media_type == MediaType.PLAYLIST: - playlists = await self._player.heos.get_playlists() + playlists = await self.coordinator.heos.get_playlists() playlist = next((p for p in playlists if p.name == media_id), None) if not playlist: raise ValueError(f"Invalid playlist '{media_id}'") - add_queue_option = HA_HEOS_ENQUEUE_MAP.get(kwargs.get(ATTR_MEDIA_ENQUEUE)) - - await self._player.add_to_queue(playlist, add_queue_option) + add_queue_option = HA_HEOS_ENQUEUE_MAP[kwargs.get(ATTR_MEDIA_ENQUEUE)] + await self._player.play_media(playlist, add_queue_option) return if media_type == "favorite": diff --git a/tests/components/heos/test_media_player.py b/tests/components/heos/test_media_player.py index 2d9f69d764d..8fc63bbc7ad 100644 --- a/tests/components/heos/test_media_player.py +++ b/tests/components/heos/test_media_player.py @@ -1054,7 +1054,7 @@ async def test_play_media_playlist( service_data, blocking=True, ) - player.add_to_queue.assert_called_once_with(playlist, criteria) + player.play_media.assert_called_once_with(playlist, criteria) async def test_play_media_playlist_error( From 363ecde41b75dd7968a59ecc88da1ce649bab2d8 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 26 Jan 2025 17:32:09 +0100 Subject: [PATCH 0031/3148] Fix spelling of "Home Assistant" and "IDs" in xiaomi_aqara (#136578) --- homeassistant/components/xiaomi_aqara/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/xiaomi_aqara/strings.json b/homeassistant/components/xiaomi_aqara/strings.json index 75b4ab1ecda..6221b9b9d65 100644 --- a/homeassistant/components/xiaomi_aqara/strings.json +++ b/homeassistant/components/xiaomi_aqara/strings.json @@ -26,7 +26,7 @@ } }, "error": { - "discovery_error": "Failed to discover a Xiaomi Aqara Gateway, try using the IP of the device running HomeAssistant as interface", + "discovery_error": "Failed to discover a Xiaomi Aqara Gateway, try using the IP of the device running Home Assistant as interface", "invalid_interface": "Invalid network interface", "invalid_key": "Invalid Gateway key", "invalid_host": "Invalid hostname or IP address, see https://www.home-assistant.io/integrations/xiaomi_aqara/#connection-problem", @@ -59,7 +59,7 @@ }, "ringtone_id": { "name": "Ringtone ID", - "description": "One of the allowed ringtone ids." + "description": "One of the allowed ringtone IDs." }, "ringtone_vol": { "name": "Ringtone volume", From 909af0db82f48e08e591687538a6a0dbbb296ead Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 26 Jan 2025 17:33:33 +0100 Subject: [PATCH 0032/3148] Fix sentence-casing in action names, spelling of "IDs" (#136576) --- homeassistant/components/ecobee/strings.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/ecobee/strings.json b/homeassistant/components/ecobee/strings.json index 7713a8fb4b9..2b44c45edef 100644 --- a/homeassistant/components/ecobee/strings.json +++ b/homeassistant/components/ecobee/strings.json @@ -138,7 +138,7 @@ } }, "set_dst_mode": { - "name": "Set Daylight savings time mode", + "name": "Set daylight savings time mode", "description": "Enables/disables automatic daylight savings time.", "fields": { "dst_enabled": { @@ -172,8 +172,8 @@ } }, "set_sensors_used_in_climate": { - "name": "Set Sensors Used in Climate", - "description": "Sets the participating sensors for a climate.", + "name": "Set sensors used in climate", + "description": "Sets the participating sensors for a climate program.", "fields": { "entity_id": { "name": "Entity", @@ -198,7 +198,7 @@ "message": "Invalid sensor for thermostat, available options are: {options}" }, "sensor_lookup_failed": { - "message": "There was an error getting the sensor ids from sensor names. Try reloading the ecobee integration." + "message": "There was an error getting the sensor IDs from sensor names. Try reloading the ecobee integration." } }, "issues": { From feb65c7e9f7569aa75dd365cab72bee03bbd223c Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Sun, 26 Jan 2025 17:42:10 +0100 Subject: [PATCH 0033/3148] Fix optional argument in deconz test type definition (#136411) --- tests/components/deconz/conftest.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/tests/components/deconz/conftest.py b/tests/components/deconz/conftest.py index fd3003b96ef..4a74a673ef8 100644 --- a/tests/components/deconz/conftest.py +++ b/tests/components/deconz/conftest.py @@ -19,9 +19,14 @@ from tests.common import MockConfigEntry from tests.components.light.conftest import mock_light_profiles # noqa: F401 from tests.test_util.aiohttp import AiohttpClientMocker -type ConfigEntryFactoryType = Callable[ - [MockConfigEntry], Coroutine[Any, Any, MockConfigEntry] -] + +class ConfigEntryFactoryType(Protocol): + """Fixture factory that can set up deCONZ config entry.""" + + async def __call__(self, entry: MockConfigEntry = ..., /) -> MockConfigEntry: + """Set up a deCONZ config entry.""" + + type WebsocketDataType = Callable[[dict[str, Any]], Coroutine[Any, Any, None]] type WebsocketStateType = Callable[[str], Coroutine[Any, Any, None]] @@ -203,10 +208,10 @@ async def fixture_config_entry_factory( config_entry: MockConfigEntry, mock_requests: Callable[[str], None], ) -> ConfigEntryFactoryType: - """Fixture factory that can set up UniFi network integration.""" + """Fixture factory that can set up deCONZ integration.""" async def __mock_setup_config_entry( - entry: MockConfigEntry = config_entry, + entry: MockConfigEntry = config_entry, / ) -> MockConfigEntry: entry.add_to_hass(hass) mock_requests(entry.data[CONF_HOST]) From 647a7ae8e0c46ac350f545c51d39ee662f4a6f81 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 26 Jan 2025 17:46:26 +0100 Subject: [PATCH 0034/3148] Bump yt-dlp to 2025.01.26 (#136581) --- homeassistant/components/media_extractor/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/media_extractor/manifest.json b/homeassistant/components/media_extractor/manifest.json index becca8e6da8..f0f8ee03ad0 100644 --- a/homeassistant/components/media_extractor/manifest.json +++ b/homeassistant/components/media_extractor/manifest.json @@ -8,6 +8,6 @@ "iot_class": "calculated", "loggers": ["yt_dlp"], "quality_scale": "internal", - "requirements": ["yt-dlp[default]==2025.01.15"], + "requirements": ["yt-dlp[default]==2025.01.26"], "single_config_entry": true } diff --git a/requirements_all.txt b/requirements_all.txt index b0ecc10914b..c687944081f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3106,7 +3106,7 @@ youless-api==2.2.0 youtubeaio==1.1.5 # homeassistant.components.media_extractor -yt-dlp[default]==2025.01.15 +yt-dlp[default]==2025.01.26 # homeassistant.components.zabbix zabbix-utils==2.0.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9333914685a..69b6912ec56 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2501,7 +2501,7 @@ youless-api==2.2.0 youtubeaio==1.1.5 # homeassistant.components.media_extractor -yt-dlp[default]==2025.01.15 +yt-dlp[default]==2025.01.26 # homeassistant.components.zamg zamg==0.3.6 From db2fed2034f6c35dc05e80ba3b3f9f8e58ebfa8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joris=20Pelgr=C3=B6m?= Date: Sun, 26 Jan 2025 18:42:44 +0100 Subject: [PATCH 0035/3148] Fix LetPot reauthentication flow tests setting up config entry (#136589) Fix LetPot reauth tests setting up config entry --- tests/components/letpot/test_config_flow.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/components/letpot/test_config_flow.py b/tests/components/letpot/test_config_flow.py index 0ec1bd95d91..425298dc231 100644 --- a/tests/components/letpot/test_config_flow.py +++ b/tests/components/letpot/test_config_flow.py @@ -149,7 +149,7 @@ async def test_flow_duplicate( async def test_reauth_flow( - hass: HomeAssistant, mock_config_entry: MockConfigEntry + hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_config_entry: MockConfigEntry ) -> None: """Test reauth flow with success.""" mock_config_entry.add_to_hass(hass) @@ -196,6 +196,7 @@ async def test_reauth_flow( ) async def test_reauth_exceptions( hass: HomeAssistant, + mock_setup_entry: AsyncMock, mock_config_entry: MockConfigEntry, exception: Exception, error: str, @@ -249,7 +250,7 @@ async def test_reauth_exceptions( async def test_reauth_different_user_id_new( - hass: HomeAssistant, mock_config_entry: MockConfigEntry + hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_config_entry: MockConfigEntry ) -> None: """Test reauth flow with different, new user ID updating the existing entry.""" mock_config_entry.add_to_hass(hass) @@ -288,7 +289,7 @@ async def test_reauth_different_user_id_new( async def test_reauth_different_user_id_existing( - hass: HomeAssistant, mock_config_entry: MockConfigEntry + hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_config_entry: MockConfigEntry ) -> None: """Test reauth flow with different, existing user ID aborting.""" mock_config_entry.add_to_hass(hass) From 40127a5ca4fb8b1b15c2b274df77d0251f7d83d8 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Sun, 26 Jan 2025 20:03:13 +0100 Subject: [PATCH 0036/3148] Add Reolink privacy switch entity (#136521) --- homeassistant/components/reolink/icons.json | 6 +++++ homeassistant/components/reolink/strings.json | 3 +++ homeassistant/components/reolink/switch.py | 26 +++++++++++++++++++ 3 files changed, 35 insertions(+) diff --git a/homeassistant/components/reolink/icons.json b/homeassistant/components/reolink/icons.json index a9c231bf68f..26198a11594 100644 --- a/homeassistant/components/reolink/icons.json +++ b/homeassistant/components/reolink/icons.json @@ -371,6 +371,12 @@ }, "led": { "default": "mdi:lightning-bolt-circle" + }, + "privacy_mode": { + "default": "mdi:eye", + "state": { + "on": "mdi:eye-off" + } } } }, diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index 412362fc447..1cadc16f818 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -808,6 +808,9 @@ }, "led": { "name": "LED" + }, + "privacy_mode": { + "name": "Privacy mode" } } } diff --git a/homeassistant/components/reolink/switch.py b/homeassistant/components/reolink/switch.py index 85c35b5c987..cecb0b0000f 100644 --- a/homeassistant/components/reolink/switch.py +++ b/homeassistant/components/reolink/switch.py @@ -208,6 +208,17 @@ SWITCH_ENTITIES = ( ), ) +AVAILABILITY_SWITCH_ENTITIES = ( + ReolinkSwitchEntityDescription( + key="privacy_mode", + translation_key="privacy_mode", + entity_category=EntityCategory.CONFIG, + supported=lambda api, ch: api.supported(ch, "privacy_mode"), + value=lambda api, ch: api.baichuan.privacy_mode(ch), + method=lambda api, ch, value: api.baichuan.set_privacy_mode(ch, value), + ), +) + NVR_SWITCH_ENTITIES = ( ReolinkNVRSwitchEntityDescription( key="email", @@ -344,6 +355,12 @@ async def async_setup_entry( for entity_description in CHIME_SWITCH_ENTITIES for chime in reolink_data.host.api.chime_list ) + entities.extend( + ReolinkAvailabilitySwitchEntity(reolink_data, channel, entity_description) + for entity_description in AVAILABILITY_SWITCH_ENTITIES + for channel in reolink_data.host.api.channels + if entity_description.supported(reolink_data.host.api, channel) + ) # Can be removed in HA 2025.4.0 depricated_dict = {} @@ -409,6 +426,15 @@ class ReolinkSwitchEntity(ReolinkChannelCoordinatorEntity, SwitchEntity): self.async_write_ha_state() +class ReolinkAvailabilitySwitchEntity(ReolinkSwitchEntity): + """Switch entity class for Reolink IP cameras which will be available even if API is unavailable.""" + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return self._host.api.camera_online(self._channel) + + class ReolinkNVRSwitchEntity(ReolinkHostCoordinatorEntity, SwitchEntity): """Switch entity class for Reolink NVR features.""" From 7133eec18588c4313e998c37785f9883cafc271d Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Sun, 26 Jan 2025 20:43:31 +0000 Subject: [PATCH 0037/3148] Bump python-kasa to 0.10.0 (#136586) Bump python-kasa to 0.10.0 Release notes: https://github.com/python-kasa/python-kasa/releases/tag/0.10.0 --- homeassistant/components/tplink/manifest.json | 2 +- homeassistant/components/tplink/siren.py | 3 +-- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/tplink/const.py | 3 --- tests/components/tplink/test_config_flow.py | 4 ---- tests/components/tplink/test_init.py | 15 ++++++++++----- 7 files changed, 14 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/tplink/manifest.json b/homeassistant/components/tplink/manifest.json index a975e675ceb..f55dfda1664 100644 --- a/homeassistant/components/tplink/manifest.json +++ b/homeassistant/components/tplink/manifest.json @@ -300,5 +300,5 @@ "documentation": "https://www.home-assistant.io/integrations/tplink", "iot_class": "local_polling", "loggers": ["kasa"], - "requirements": ["python-kasa[speedups]==0.9.1"] + "requirements": ["python-kasa[speedups]==0.10.0"] } diff --git a/homeassistant/components/tplink/siren.py b/homeassistant/components/tplink/siren.py index 5931a508d6c..d1ce03c1469 100644 --- a/homeassistant/components/tplink/siren.py +++ b/homeassistant/components/tplink/siren.py @@ -7,7 +7,6 @@ from dataclasses import dataclass from typing import Any from kasa import Device, Module -from kasa.smart.modules.alarm import Alarm from homeassistant.components.siren import ( DOMAIN as SIREN_DOMAIN, @@ -101,7 +100,7 @@ class TPLinkSirenEntity(CoordinatedTPLinkModuleEntity, SirenEntity): ) -> None: """Initialize the siren entity.""" super().__init__(device, coordinator, description, parent=parent) - self._alarm_module: Alarm = device.modules[Module.Alarm] + self._alarm_module = device.modules[Module.Alarm] @async_refresh_after async def async_turn_on(self, **kwargs: Any) -> None: diff --git a/requirements_all.txt b/requirements_all.txt index c687944081f..c1148cc3b6b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2396,7 +2396,7 @@ python-join-api==0.0.9 python-juicenet==1.1.0 # homeassistant.components.tplink -python-kasa[speedups]==0.9.1 +python-kasa[speedups]==0.10.0 # homeassistant.components.linkplay python-linkplay==0.1.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 69b6912ec56..3c946d89857 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1935,7 +1935,7 @@ python-izone==1.2.9 python-juicenet==1.1.0 # homeassistant.components.tplink -python-kasa[speedups]==0.9.1 +python-kasa[speedups]==0.10.0 # homeassistant.components.linkplay python-linkplay==0.1.3 diff --git a/tests/components/tplink/const.py b/tests/components/tplink/const.py index 57829a7aa34..54aab1e2f3c 100644 --- a/tests/components/tplink/const.py +++ b/tests/components/tplink/const.py @@ -55,7 +55,6 @@ DEVICE_CONFIG_KLAP = DeviceConfig( IP_ADDRESS, credentials=CREDENTIALS, connection_type=CONN_PARAMS_KLAP, - uses_http=True, ) CONN_PARAMS_AES = DeviceConnectionParameters( DeviceFamily.SmartTapoPlug, DeviceEncryptionType.Aes @@ -84,7 +83,6 @@ DEVICE_CONFIG_AES = DeviceConfig( IP_ADDRESS2, credentials=CREDENTIALS, connection_type=CONN_PARAMS_AES, - uses_http=True, aes_keys=AES_KEYS, ) CONN_PARAMS_AES_CAMERA = DeviceConnectionParameters( @@ -94,7 +92,6 @@ DEVICE_CONFIG_AES_CAMERA = DeviceConfig( IP_ADDRESS3, credentials=CREDENTIALS, connection_type=CONN_PARAMS_AES_CAMERA, - uses_http=True, ) DEVICE_CONFIG_DICT_KLAP = { diff --git a/tests/components/tplink/test_config_flow.py b/tests/components/tplink/test_config_flow.py index b093847869e..35fd4f418de 100644 --- a/tests/components/tplink/test_config_flow.py +++ b/tests/components/tplink/test_config_flow.py @@ -1169,7 +1169,6 @@ async def test_manual_port_override( host, credentials=None, port_override=port, - uses_http=True, connection_type=CONN_PARAMS_KLAP, ) mock_device = _mocked_device( @@ -1491,7 +1490,6 @@ async def test_integration_discovery_with_ip_change( # Check that init set the new host correctly before calling connect assert config.host == IP_ADDRESS config.host = IP_ADDRESS2 - config.uses_http = False # Not passed in to new config class config.http_client = "Foo" mock_connect["connect"].assert_awaited_once_with(config=config) @@ -1578,7 +1576,6 @@ async def test_integration_discovery_with_connection_change( assert mock_config_entry.state is ConfigEntryState.LOADED config.host = IP_ADDRESS2 - config.uses_http = False # Not passed in to new config class config.http_client = "Foo" config.aes_keys = AES_KEYS mock_connect["connect"].assert_awaited_once_with(config=config) @@ -1847,7 +1844,6 @@ async def test_reauth_update_with_encryption_change( connection_type=Device.ConnectionParameters( Device.Family.SmartTapoPlug, Device.EncryptionType.Klap ), - uses_http=True, ) mock_device = _mocked_device( alias="my_device", diff --git a/tests/components/tplink/test_init.py b/tests/components/tplink/test_init.py index ffcadc79faf..972ca73c45c 100644 --- a/tests/components/tplink/test_init.py +++ b/tests/components/tplink/test_init.py @@ -246,7 +246,6 @@ async def test_config_entry_with_stored_credentials( await hass.async_block_till_done() assert mock_config_entry.state is ConfigEntryState.LOADED config = DeviceConfig.from_dict(DEVICE_CONFIG_KLAP.to_dict()) - config.uses_http = False config.http_client = "Foo" assert config.credentials != stored_credentials config.credentials = stored_credentials @@ -762,7 +761,6 @@ async def test_credentials_hash_auth_error( expected_config = DeviceConfig.from_dict( {**DEVICE_CONFIG_DICT_KLAP, "credentials_hash": "theHash"} ) - expected_config.uses_http = False expected_config.http_client = "Foo" connect_mock.assert_called_with(config=expected_config) assert entry.state is ConfigEntryState.SETUP_ERROR @@ -794,13 +792,20 @@ async def test_migrate_remove_device_config( As async_setup_entry will succeed the hash on the parent is updated from the device. """ + old_device_config = { + k: v for k, v in device_config.to_dict().items() if k != "credentials" + } + device_config_dict = { + **old_device_config, + "uses_http": device_config.connection_type.encryption_type + is not Device.EncryptionType.Xor, + } + OLD_CREATE_ENTRY_DATA = { CONF_HOST: expected_entry_data[CONF_HOST], CONF_ALIAS: ALIAS, CONF_MODEL: MODEL, - CONF_DEVICE_CONFIG: { - k: v for k, v in device_config.to_dict().items() if k != "credentials" - }, + CONF_DEVICE_CONFIG: device_config_dict, } entry = MockConfigEntry( From 3e0f6562c7ee5c7a89828d6c517867ca22d5c8f8 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sun, 26 Jan 2025 21:57:32 +0100 Subject: [PATCH 0038/3148] Cleanup stale devices on incomfort integration startup (#136566) --- .../components/incomfort/__init__.py | 44 +++++++++++++- tests/components/incomfort/test_init.py | 58 ++++++++++++++++++- 2 files changed, 97 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/incomfort/__init__.py b/homeassistant/components/incomfort/__init__.py index 249a0ae9085..4d05a57bcfa 100644 --- a/homeassistant/components/incomfort/__init__.py +++ b/homeassistant/components/incomfort/__init__.py @@ -7,12 +7,12 @@ from incomfortclient import InvalidGateway, InvalidHeaterList from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers import device_registry as dr from .const import DOMAIN -from .coordinator import InComfortDataCoordinator, async_connect_gateway +from .coordinator import InComfortData, InComfortDataCoordinator, async_connect_gateway from .errors import InComfortTimeout, InComfortUnknownError, NoHeaters, NotFound PLATFORMS = ( @@ -27,6 +27,43 @@ INTEGRATION_TITLE = "Intergas InComfort/Intouch Lan2RF gateway" type InComfortConfigEntry = ConfigEntry[InComfortDataCoordinator] +@callback +def async_cleanup_stale_devices( + hass: HomeAssistant, + entry: InComfortConfigEntry, + data: InComfortData, + gateway_device: dr.DeviceEntry, +) -> None: + """Cleanup stale heater devices and climates.""" + heater_serial_numbers = {heater.serial_no for heater in data.heaters} + device_registry = dr.async_get(hass) + device_entries = device_registry.devices.get_devices_for_config_entry_id( + entry.entry_id + ) + stale_heater_serial_numbers: list[str] = [ + device_entry.serial_number + for device_entry in device_entries + if device_entry.id != gateway_device.id + and device_entry.serial_number is not None + and device_entry.serial_number not in heater_serial_numbers + ] + if not stale_heater_serial_numbers: + return + cleanup_devices: list[str] = [] + # Find stale heater and climate devices + for serial_number in stale_heater_serial_numbers: + cleanup_list = [f"{serial_number}_{index}" for index in range(1, 4)] + cleanup_list.append(serial_number) + cleanup_identifiers = [{(DOMAIN, cleanup_id)} for cleanup_id in cleanup_list] + cleanup_devices.extend( + device_entry.id + for device_entry in device_entries + if device_entry.identifiers in cleanup_identifiers + ) + for device_id in cleanup_devices: + device_registry.async_remove_device(device_id) + + async def async_setup_entry(hass: HomeAssistant, entry: InComfortConfigEntry) -> bool: """Set up a config entry.""" try: @@ -46,7 +83,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: InComfortConfigEntry) -> # Register discovered gateway device device_registry = dr.async_get(hass) - device_registry.async_get_or_create( + gateway_device = device_registry.async_get_or_create( config_entry_id=entry.entry_id, identifiers={(DOMAIN, entry.entry_id)}, connections={(dr.CONNECTION_NETWORK_MAC, entry.unique_id)} @@ -55,6 +92,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: InComfortConfigEntry) -> manufacturer="Intergas", name="RFGateway", ) + async_cleanup_stale_devices(hass, entry, data, gateway_device) coordinator = InComfortDataCoordinator(hass, data, entry.entry_id) entry.runtime_data = coordinator await coordinator.async_config_entry_first_refresh() diff --git a/tests/components/incomfort/test_init.py b/tests/components/incomfort/test_init.py index a9b3a8e4e3a..92ce0afa448 100644 --- a/tests/components/incomfort/test_init.py +++ b/tests/components/incomfort/test_init.py @@ -1,6 +1,7 @@ """Tests for Intergas InComfort integration.""" from datetime import timedelta +from typing import Any from unittest.mock import AsyncMock, MagicMock, patch from aiohttp import ClientResponseError, RequestInfo @@ -8,13 +9,17 @@ from freezegun.api import FrozenDateTimeFactory from incomfortclient import InvalidGateway, InvalidHeaterList import pytest +from homeassistant.components.incomfort import DOMAIN from homeassistant.components.incomfort.coordinator import UPDATE_INTERVAL from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.device_registry import DeviceRegistry -from tests.common import async_fire_time_changed +from .conftest import MOCK_HEATER_STATUS + +from tests.common import MockConfigEntry, async_fire_time_changed @pytest.mark.usefixtures("entity_registry_enabled_by_default") @@ -22,13 +27,62 @@ async def test_setup_platforms( hass: HomeAssistant, mock_incomfort: MagicMock, entity_registry: er.EntityRegistry, - mock_config_entry: ConfigEntry, + mock_config_entry: MockConfigEntry, ) -> None: """Test the incomfort integration is set up correctly.""" await hass.config_entries.async_setup(mock_config_entry.entry_id) assert mock_config_entry.state is ConfigEntryState.LOADED +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.parametrize( + "mock_heater_status", [MOCK_HEATER_STATUS | {"serial_no": "c01d00c0ffee"}] +) +async def test_stale_devices_cleanup( + hass: HomeAssistant, + device_registry: DeviceRegistry, + mock_incomfort: MagicMock, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + mock_heater_status: dict[str, Any], +) -> None: + """Test the incomfort integration is cleaning up stale devices.""" + # Setup an old heater with serial_no c01d00c0ffee + await hass.config_entries.async_setup(mock_config_entry.entry_id) + assert mock_config_entry.state is ConfigEntryState.LOADED + await hass.config_entries.async_unload(mock_config_entry.entry_id) + old_entries = device_registry.devices.get_devices_for_config_entry_id( + mock_config_entry.entry_id + ) + assert len(old_entries) == 3 + old_heater = device_registry.async_get_device({(DOMAIN, "c01d00c0ffee")}) + assert old_heater is not None + assert old_heater.serial_number == "c01d00c0ffee" + old_climate = device_registry.async_get_device({(DOMAIN, "c01d00c0ffee_1")}) + assert old_heater is not None + old_climate = device_registry.async_get_device({(DOMAIN, "c01d00c0ffee_1")}) + assert old_climate is not None + + mock_heater_status["serial_no"] = "c0ffeec0ffee" + await hass.config_entries.async_setup(mock_config_entry.entry_id) + assert mock_config_entry.state is ConfigEntryState.LOADED + + new_entries = device_registry.devices.get_devices_for_config_entry_id( + mock_config_entry.entry_id + ) + assert len(new_entries) == 3 + new_heater = device_registry.async_get_device({(DOMAIN, "c0ffeec0ffee")}) + assert new_heater is not None + assert new_heater.serial_number == "c0ffeec0ffee" + new_climate = device_registry.async_get_device({(DOMAIN, "c0ffeec0ffee_1")}) + assert new_climate is not None + + old_heater = device_registry.async_get_device({(DOMAIN, "c01d00c0ffee")}) + assert old_heater is None + old_climate = device_registry.async_get_device({(DOMAIN, "c01d00c0ffee_1")}) + assert old_climate is None + + @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_coordinator_updates( hass: HomeAssistant, From 17e12e6671670a74367eb2a67436615df5a28430 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Sun, 26 Jan 2025 22:44:15 +0100 Subject: [PATCH 0039/3148] Prevent errors when Reolink privacy mode is turned on (#136506) --- homeassistant/components/reolink/__init__.py | 31 ++++- homeassistant/components/reolink/entity.py | 18 ++- homeassistant/components/reolink/host.py | 29 +++-- tests/components/reolink/conftest.py | 1 + tests/components/reolink/test_init.py | 114 ++++++++++++++++++- 5 files changed, 179 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/reolink/__init__.py b/homeassistant/components/reolink/__init__.py index 747e68e8a00..576ab3c64f8 100644 --- a/homeassistant/components/reolink/__init__.py +++ b/homeassistant/components/reolink/__init__.py @@ -5,9 +5,14 @@ from __future__ import annotations import asyncio from datetime import timedelta import logging +from typing import Any from reolink_aio.api import RETRY_ATTEMPTS -from reolink_aio.exceptions import CredentialsInvalidError, ReolinkError +from reolink_aio.exceptions import ( + CredentialsInvalidError, + LoginPrivacyModeError, + ReolinkError, +) from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_PORT, EVENT_HOMEASSISTANT_STOP, Platform @@ -19,6 +24,7 @@ from homeassistant.helpers import ( entity_registry as er, ) from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.event import async_call_later from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -115,6 +121,8 @@ async def async_setup_entry( await host.stop() raise ConfigEntryAuthFailed(err) from err raise UpdateFailed(str(err)) from err + except LoginPrivacyModeError: + pass # HTTP API is shutdown when privacy mode is active except ReolinkError as err: host.credential_errors = 0 raise UpdateFailed(str(err)) from err @@ -192,6 +200,23 @@ async def async_setup_entry( hass.http.register_view(PlaybackProxyView(hass)) + async def refresh(*args: Any) -> None: + """Request refresh of coordinator.""" + await device_coordinator.async_request_refresh() + host.cancel_refresh_privacy_mode = None + + def async_privacy_mode_change() -> None: + """Request update when privacy mode is turned off.""" + if host.privacy_mode and not host.api.baichuan.privacy_mode(): + # The privacy mode just turned off, give the API 2 seconds to start + if host.cancel_refresh_privacy_mode is None: + host.cancel_refresh_privacy_mode = async_call_later(hass, 2, refresh) + host.privacy_mode = host.api.baichuan.privacy_mode() + + host.api.baichuan.register_callback( + "privacy_mode_change", async_privacy_mode_change, 623 + ) + await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) config_entry.async_on_unload( @@ -216,6 +241,10 @@ async def async_unload_entry( await host.stop() + host.api.baichuan.unregister_callback("privacy_mode_change") + if host.cancel_refresh_privacy_mode is not None: + host.cancel_refresh_privacy_mode() + return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) diff --git a/homeassistant/components/reolink/entity.py b/homeassistant/components/reolink/entity.py index dc2366e8f56..63c95c25025 100644 --- a/homeassistant/components/reolink/entity.py +++ b/homeassistant/components/reolink/entity.py @@ -69,7 +69,9 @@ class ReolinkHostCoordinatorEntity(CoordinatorEntity[DataUpdateCoordinator[None] super().__init__(coordinator) self._host = reolink_data.host - self._attr_unique_id = f"{self._host.unique_id}_{self.entity_description.key}" + self._attr_unique_id: str = ( + f"{self._host.unique_id}_{self.entity_description.key}" + ) http_s = "https" if self._host.api.use_https else "http" self._conf_url = f"{http_s}://{self._host.api.host}:{self._host.api.port}" @@ -90,7 +92,11 @@ class ReolinkHostCoordinatorEntity(CoordinatorEntity[DataUpdateCoordinator[None] @property def available(self) -> bool: """Return True if entity is available.""" - return self._host.api.session_active and super().available + return ( + self._host.api.session_active + and not self._host.api.baichuan.privacy_mode() + and super().available + ) @callback def _push_callback(self) -> None: @@ -110,8 +116,10 @@ class ReolinkHostCoordinatorEntity(CoordinatorEntity[DataUpdateCoordinator[None] cmd_id = self.entity_description.cmd_id if cmd_key is not None: self._host.async_register_update_cmd(cmd_key) - if cmd_id is not None and self._attr_unique_id is not None: + if cmd_id is not None: self.register_callback(self._attr_unique_id, cmd_id) + # Privacy mode + self.register_callback(f"{self._attr_unique_id}_623", 623) async def async_will_remove_from_hass(self) -> None: """Entity removed.""" @@ -119,8 +127,10 @@ class ReolinkHostCoordinatorEntity(CoordinatorEntity[DataUpdateCoordinator[None] cmd_id = self.entity_description.cmd_id if cmd_key is not None: self._host.async_unregister_update_cmd(cmd_key) - if cmd_id is not None and self._attr_unique_id is not None: + if cmd_id is not None: self._host.api.baichuan.unregister_callback(self._attr_unique_id) + # Privacy mode + self._host.api.baichuan.unregister_callback(f"{self._attr_unique_id}_623") await super().async_will_remove_from_hass() diff --git a/homeassistant/components/reolink/host.py b/homeassistant/components/reolink/host.py index 97d888c0323..e9b86f1e297 100644 --- a/homeassistant/components/reolink/host.py +++ b/homeassistant/components/reolink/host.py @@ -95,6 +95,7 @@ class ReolinkHost: self.firmware_ch_list: list[int | None] = [] self.starting: bool = True + self.privacy_mode: bool | None = None self.credential_errors: int = 0 self.webhook_id: str | None = None @@ -112,7 +113,9 @@ class ReolinkHost: self._poll_job = HassJob(self._async_poll_all_motion, cancel_on_shutdown=True) self._fast_poll_error: bool = False self._long_poll_task: asyncio.Task | None = None + self._lost_subscription_start: bool = False self._lost_subscription: bool = False + self.cancel_refresh_privacy_mode: CALLBACK_TYPE | None = None @callback def async_register_update_cmd(self, cmd: str, channel: int | None = None) -> None: @@ -232,6 +235,8 @@ class ReolinkHost: self._hass, FIRST_TCP_PUSH_TIMEOUT, self._async_check_tcp_push ) + self.privacy_mode = self._api.baichuan.privacy_mode() + ch_list: list[int | None] = [None] if self._api.is_nvr: ch_list.extend(self._api.channels) @@ -299,7 +304,7 @@ class ReolinkHost: ) # start long polling if ONVIF push failed immediately - if not self._onvif_push_supported: + if not self._onvif_push_supported and not self._api.baichuan.privacy_mode(): _LOGGER.debug( "Camera model %s does not support ONVIF push, using ONVIF long polling instead", self._api.model, @@ -416,6 +421,11 @@ class ReolinkHost: wake = True self.last_wake = time() + if self._api.baichuan.privacy_mode(): + await self._api.baichuan.get_privacy_mode() + if self._api.baichuan.privacy_mode(): + return # API is shutdown, no need to check states + await self._api.get_states(cmd_list=self.update_cmd, wake=wake) async def disconnect(self) -> None: @@ -459,8 +469,8 @@ class ReolinkHost: if initial: raise # make sure the long_poll_task is always created to try again later - if not self._lost_subscription: - self._lost_subscription = True + if not self._lost_subscription_start: + self._lost_subscription_start = True _LOGGER.error( "Reolink %s event long polling subscription lost: %s", self._api.nvr_name, @@ -468,15 +478,15 @@ class ReolinkHost: ) except ReolinkError as err: # make sure the long_poll_task is always created to try again later - if not self._lost_subscription: - self._lost_subscription = True + if not self._lost_subscription_start: + self._lost_subscription_start = True _LOGGER.error( "Reolink %s event long polling subscription lost: %s", self._api.nvr_name, err, ) else: - self._lost_subscription = False + self._lost_subscription_start = False self._long_poll_task = asyncio.create_task(self._async_long_polling()) async def _async_stop_long_polling(self) -> None: @@ -543,6 +553,9 @@ class ReolinkHost: self.unregister_webhook() await self._api.unsubscribe() + if self._api.baichuan.privacy_mode(): + return # API is shutdown, no need to subscribe + try: if self._onvif_push_supported and not self._api.baichuan.events_active: await self._renew(SubType.push) @@ -666,7 +679,9 @@ class ReolinkHost: try: channels = await self._api.pull_point_request() except ReolinkError as ex: - if not self._long_poll_error: + if not self._long_poll_error and self._api.subscribed( + SubType.long_poll + ): _LOGGER.error("Error while requesting ONVIF pull point: %s", ex) await self._api.unsubscribe(sub_type=SubType.long_poll) self._long_poll_error = True diff --git a/tests/components/reolink/conftest.py b/tests/components/reolink/conftest.py index 81865d98801..f8012f91351 100644 --- a/tests/components/reolink/conftest.py +++ b/tests/components/reolink/conftest.py @@ -126,6 +126,7 @@ def reolink_connect_class() -> Generator[MagicMock]: host_mock.baichuan = create_autospec(Baichuan) # Disable tcp push by default for tests host_mock.baichuan.events_active = False + host_mock.baichuan.privacy_mode.return_value = False host_mock.baichuan.subscribe_events.side_effect = ReolinkError("Test error") yield host_mock_class diff --git a/tests/components/reolink/test_init.py b/tests/components/reolink/test_init.py index f851e13c91d..7895923dd12 100644 --- a/tests/components/reolink/test_init.py +++ b/tests/components/reolink/test_init.py @@ -1,13 +1,18 @@ """Test the Reolink init.""" import asyncio +from collections.abc import Callable from typing import Any from unittest.mock import AsyncMock, MagicMock, Mock, patch from freezegun.api import FrozenDateTimeFactory import pytest from reolink_aio.api import Chime -from reolink_aio.exceptions import CredentialsInvalidError, ReolinkError +from reolink_aio.exceptions import ( + CredentialsInvalidError, + LoginPrivacyModeError, + ReolinkError, +) from homeassistant.components.reolink import ( DEVICE_UPDATE_INTERVAL, @@ -16,7 +21,13 @@ from homeassistant.components.reolink import ( ) from homeassistant.components.reolink.const import DOMAIN from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import CONF_PORT, STATE_OFF, STATE_UNAVAILABLE, Platform +from homeassistant.const import ( + CONF_PORT, + STATE_OFF, + STATE_ON, + STATE_UNAVAILABLE, + Platform, +) from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant from homeassistant.core_config import async_process_ha_core_config from homeassistant.helpers import ( @@ -749,3 +760,102 @@ async def test_port_changed( await hass.async_block_till_done() assert config_entry.data[CONF_PORT] == 4567 + + +async def test_privacy_mode_on( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + reolink_connect: MagicMock, + config_entry: MockConfigEntry, +) -> None: + """Test successful setup even when privacy mode is turned on.""" + reolink_connect.baichuan.privacy_mode.return_value = True + reolink_connect.get_states = AsyncMock( + side_effect=LoginPrivacyModeError("Test error") + ) + + with patch("homeassistant.components.reolink.PLATFORMS", [Platform.SWITCH]): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state == ConfigEntryState.LOADED + + reolink_connect.baichuan.privacy_mode.return_value = False + + +async def test_LoginPrivacyModeError( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + reolink_connect: MagicMock, + config_entry: MockConfigEntry, +) -> None: + """Test normal update when get_states returns a LoginPrivacyModeError.""" + reolink_connect.baichuan.privacy_mode.return_value = False + reolink_connect.get_states = AsyncMock( + side_effect=LoginPrivacyModeError("Test error") + ) + + with patch("homeassistant.components.reolink.PLATFORMS", [Platform.SWITCH]): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + reolink_connect.baichuan.check_subscribe_events.reset_mock() + assert reolink_connect.baichuan.check_subscribe_events.call_count == 0 + + freezer.tick(DEVICE_UPDATE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert reolink_connect.baichuan.check_subscribe_events.call_count >= 1 + + +async def test_privacy_mode_change_callback( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + config_entry: MockConfigEntry, + reolink_connect: MagicMock, +) -> None: + """Test privacy mode changed callback.""" + + class callback_mock_class: + callback_func = None + + def register_callback( + self, callback_id: str, callback: Callable[[], None], *args, **key_args + ) -> None: + if callback_id == "privacy_mode_change": + self.callback_func = callback + + callback_mock = callback_mock_class() + + reolink_connect.model = TEST_HOST_MODEL + reolink_connect.baichuan.events_active = True + reolink_connect.baichuan.subscribe_events.reset_mock(side_effect=True) + reolink_connect.baichuan.register_callback = callback_mock.register_callback + reolink_connect.baichuan.privacy_mode.return_value = True + reolink_connect.audio_record.return_value = True + reolink_connect.get_states = AsyncMock() + + with patch("homeassistant.components.reolink.PLATFORMS", [Platform.SWITCH]): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.LOADED + + entity_id = f"{Platform.SWITCH}.{TEST_NVR_NAME}_record_audio" + assert hass.states.get(entity_id).state == STATE_UNAVAILABLE + + # simulate a TCP push callback signaling a privacy mode change + reolink_connect.baichuan.privacy_mode.return_value = False + assert callback_mock.callback_func is not None + callback_mock.callback_func() + + # check that a coordinator update was scheduled. + reolink_connect.get_states.reset_mock() + assert reolink_connect.get_states.call_count == 0 + + freezer.tick(5) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert reolink_connect.get_states.call_count >= 1 + assert hass.states.get(entity_id).state == STATE_ON From 3582d9b4dab65ea0c7cac3f91fb11001863bd5b1 Mon Sep 17 00:00:00 2001 From: Pete Sage <76050312+PeteRager@users.noreply.github.com> Date: Sun, 26 Jan 2025 18:34:16 -0500 Subject: [PATCH 0040/3148] Bump SoCo to 0.30.8 - Sonos (#136601) update soco to 0.30.8 --- 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 76a7d0bfa91..bfdf0da9dbb 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.6", "sonos-websocket==0.1.3"], + "requirements": ["soco==0.30.8", "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 c1148cc3b6b..24a550e05de 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2735,7 +2735,7 @@ smhi-pkg==1.0.19 snapcast==2.3.6 # homeassistant.components.sonos -soco==0.30.6 +soco==0.30.8 # homeassistant.components.solaredge_local solaredge-local==0.2.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3c946d89857..fa1dd4bed7f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2202,7 +2202,7 @@ smhi-pkg==1.0.19 snapcast==2.3.6 # homeassistant.components.sonos -soco==0.30.6 +soco==0.30.8 # homeassistant.components.solarlog solarlog_cli==0.4.0 From 642a06b0f087867a42462b592d8c92b347795bed Mon Sep 17 00:00:00 2001 From: Arie Catsman <120491684+catsmanac@users.noreply.github.com> Date: Mon, 27 Jan 2025 00:38:45 +0100 Subject: [PATCH 0041/3148] Optimize enphase_envoy test integration setup. (#136572) --- tests/components/enphase_envoy/__init__.py | 12 +++++++++--- tests/components/enphase_envoy/test_init.py | 20 +------------------- 2 files changed, 10 insertions(+), 22 deletions(-) diff --git a/tests/components/enphase_envoy/__init__.py b/tests/components/enphase_envoy/__init__.py index f69ab8e44f2..f5381eda2a7 100644 --- a/tests/components/enphase_envoy/__init__.py +++ b/tests/components/enphase_envoy/__init__.py @@ -1,13 +1,19 @@ """Tests for the Enphase Envoy integration.""" +from homeassistant.config_entries import ConfigEntryState 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.""" +async def setup_integration( + hass: HomeAssistant, + config_entry: MockConfigEntry, + expected_state: ConfigEntryState = ConfigEntryState.LOADED, +) -> None: + """Fixture for setting up the component and testing expected state.""" config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) + assert config_entry.state is expected_state diff --git a/tests/components/enphase_envoy/test_init.py b/tests/components/enphase_envoy/test_init.py index 620bd654aca..93a150cfc5c 100644 --- a/tests/components/enphase_envoy/test_init.py +++ b/tests/components/enphase_envoy/test_init.py @@ -52,8 +52,6 @@ async def test_with_pre_v7_firmware( ) await setup_integration(hass, config_entry) - assert config_entry.state is ConfigEntryState.LOADED - assert (entity_state := hass.states.get("sensor.inverter_1")) assert entity_state.state == "1" @@ -84,8 +82,6 @@ async def test_token_in_config_file( ) mock_envoy.auth = EnvoyTokenAuth("127.0.0.1", token=token, envoy_serial="1234") await setup_integration(hass, entry) - await hass.async_block_till_done(wait_background_tasks=True) - assert entry.state is ConfigEntryState.LOADED assert (entity_state := hass.states.get("sensor.inverter_1")) assert entity_state.state == "1" @@ -129,8 +125,6 @@ async def test_expired_token_in_config( cloud_password="test_password", ) await setup_integration(hass, entry) - await hass.async_block_till_done(wait_background_tasks=True) - assert entry.state is ConfigEntryState.LOADED assert (entity_state := hass.states.get("sensor.inverter_1")) assert entity_state.state == "1" @@ -230,9 +224,6 @@ async def test_coordinator_token_refresh_error( ): await setup_integration(hass, entry) - await hass.async_block_till_done(wait_background_tasks=True) - assert entry.state is ConfigEntryState.LOADED - assert (entity_state := hass.states.get("sensor.inverter_1")) assert entity_state.state == "1" @@ -255,7 +246,6 @@ async def test_config_no_unique_id( }, ) await setup_integration(hass, entry) - assert entry.state is ConfigEntryState.LOADED assert entry.unique_id == mock_envoy.serial_number @@ -276,8 +266,7 @@ async def test_config_different_unique_id( CONF_PASSWORD: "test-password", }, ) - await setup_integration(hass, entry) - assert entry.state is ConfigEntryState.SETUP_RETRY + await setup_integration(hass, entry, expected_state=ConfigEntryState.SETUP_RETRY) @pytest.mark.parametrize( @@ -298,7 +287,6 @@ async def test_remove_config_entry_device( """Test removing enphase_envoy config entry device.""" assert await async_setup_component(hass, "config", {}) await setup_integration(hass, config_entry) - assert config_entry.state is ConfigEntryState.LOADED # use client to send remove_device command hass_client = await hass_ws_client(hass) @@ -349,8 +337,6 @@ async def test_option_change_reload( ) -> None: """Test options change will reload entity.""" await setup_integration(hass, config_entry) - await hass.async_block_till_done(wait_background_tasks=True) - assert config_entry.state is ConfigEntryState.LOADED # By default neither option is available assert config_entry.options == {} @@ -403,8 +389,6 @@ async def test_coordinator_firmware_refresh( ) -> None: """Test coordinator scheduled firmware check.""" await setup_integration(hass, config_entry) - await hass.async_block_till_done(wait_background_tasks=True) - assert config_entry.state is ConfigEntryState.LOADED # Move time to next firmware check moment # SCAN_INTERVAL is patched to 1 day to disable it's firmware detection @@ -447,8 +431,6 @@ async def test_coordinator_firmware_refresh_with_envoy_error( ) -> None: """Test coordinator scheduled firmware check.""" await setup_integration(hass, config_entry) - await hass.async_block_till_done(wait_background_tasks=True) - assert config_entry.state is ConfigEntryState.LOADED caplog.set_level(logging.DEBUG) logging.getLogger("homeassistant.components.enphase_envoy.coordinator").setLevel( From 107184b55f6cab95a3ba2045638a9884f00afdc3 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 27 Jan 2025 00:41:05 +0100 Subject: [PATCH 0042/3148] Update mypy-dev to 1.16.0a1 (#136544) * Update mypy-dev to 1.16.0a1 * Fix * Use type ignore until fixed upstream --- homeassistant/components/flux_led/config_flow.py | 2 +- requirements_test.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/flux_led/config_flow.py b/homeassistant/components/flux_led/config_flow.py index 035be5b115c..69e40d59f7f 100644 --- a/homeassistant/components/flux_led/config_flow.py +++ b/homeassistant/components/flux_led/config_flow.py @@ -299,7 +299,7 @@ class FluxLedConfigFlow(ConfigFlow, domain=DOMAIN): # AKA `HF-LPB100-ZJ200` return device bulb = async_wifi_bulb_for_host(host, discovery=device) - bulb.discovery = discovery + bulb.discovery = discovery # type: ignore[assignment] try: await bulb.async_setup(lambda: None) finally: diff --git a/requirements_test.txt b/requirements_test.txt index 68945852298..cf0a1e5473f 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -12,7 +12,7 @@ coverage==7.6.8 freezegun==1.5.1 license-expression==30.4.0 mock-open==1.4.0 -mypy-dev==1.15.0a2 +mypy-dev==1.16.0a1 pre-commit==4.0.0 pydantic==2.10.6 pylint==3.3.3 From dfbb48552c3ca802c3df20525d2c1b416aa9162d Mon Sep 17 00:00:00 2001 From: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> Date: Sun, 26 Jan 2025 20:49:55 -0600 Subject: [PATCH 0043/3148] Bump pyheos to v1.0.1 (#136604) --- homeassistant/components/heos/coordinator.py | 8 +++---- homeassistant/components/heos/manifest.json | 2 +- homeassistant/components/heos/media_player.py | 22 +++++++++++-------- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 19 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/heos/coordinator.py b/homeassistant/components/heos/coordinator.py index 1cd75049f16..ee0aeb3f165 100644 --- a/homeassistant/components/heos/coordinator.py +++ b/homeassistant/components/heos/coordinator.py @@ -5,7 +5,7 @@ The coordinator is responsible for refreshing data in response to system-wide ev entities to update. Entities subscribe to entity-specific updates within the entity class itself. """ -from collections.abc import Callable +from collections.abc import Callable, Sequence from datetime import datetime, timedelta import logging @@ -60,11 +60,11 @@ class HeosCoordinator(DataUpdateCoordinator[None]): self._update_sources_pending: bool = False self._source_list: list[str] = [] self._favorites: dict[int, MediaItem] = {} - self._inputs: list[MediaItem] = [] + self._inputs: Sequence[MediaItem] = [] super().__init__(hass, _LOGGER, config_entry=config_entry, name=DOMAIN) @property - def inputs(self) -> list[MediaItem]: + def inputs(self) -> Sequence[MediaItem]: """Get input sources across all devices.""" return self._inputs @@ -133,8 +133,6 @@ class HeosCoordinator(DataUpdateCoordinator[None]): assert data is not None if data.updated_player_ids: self._async_update_player_ids(data.updated_player_ids) - elif event == const.EVENT_GROUPS_CHANGED: - await self._async_update_players() elif ( event in (const.EVENT_SOURCES_CHANGED, const.EVENT_USER_CHANGED) and not self._update_sources_pending diff --git a/homeassistant/components/heos/manifest.json b/homeassistant/components/heos/manifest.json index ebeb851f37a..22dbbf4da28 100644 --- a/homeassistant/components/heos/manifest.json +++ b/homeassistant/components/heos/manifest.json @@ -8,7 +8,7 @@ "iot_class": "local_push", "loggers": ["pyheos"], "quality_scale": "silver", - "requirements": ["pyheos==1.0.0"], + "requirements": ["pyheos==1.0.1"], "single_config_entry": true, "ssdp": [ { diff --git a/homeassistant/components/heos/media_player.py b/homeassistant/components/heos/media_player.py index 0c401f01470..2f0945635c5 100644 --- a/homeassistant/components/heos/media_player.py +++ b/homeassistant/components/heos/media_player.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Awaitable, Callable, Coroutine +from datetime import datetime from functools import reduce, wraps from operator import ior from typing import Any @@ -56,6 +57,7 @@ BASE_SUPPORTED_FEATURES = ( ) PLAY_STATE_TO_STATE = { + None: MediaPlayerState.IDLE, PlayState.PLAY: MediaPlayerState.PLAYING, PlayState.STOP: MediaPlayerState.IDLE, PlayState.PAUSE: MediaPlayerState.PAUSED, @@ -399,38 +401,40 @@ class HeosMediaPlayer(CoordinatorEntity[HeosCoordinator], MediaPlayerEntity): return self._player.is_muted @property - def media_album_name(self) -> str: + def media_album_name(self) -> str | None: """Album name of current playing media, music track only.""" return self._player.now_playing_media.album @property - def media_artist(self) -> str: + def media_artist(self) -> str | None: """Artist of current playing media, music track only.""" return self._player.now_playing_media.artist @property - def media_content_id(self) -> str: + def media_content_id(self) -> str | None: """Content ID of current playing media.""" return self._player.now_playing_media.media_id @property - def media_duration(self): + def media_duration(self) -> int | None: """Duration of current playing media in seconds.""" duration = self._player.now_playing_media.duration if isinstance(duration, int): - return duration / 1000 + return int(duration / 1000) return None @property - def media_position(self): + def media_position(self) -> int | None: """Position of current playing media in seconds.""" # Some media doesn't have duration but reports position, return None if not self._player.now_playing_media.duration: return None - return self._player.now_playing_media.current_position / 1000 + if isinstance(self._player.now_playing_media.current_position, int): + return int(self._player.now_playing_media.current_position / 1000) + return None @property - def media_position_updated_at(self): + def media_position_updated_at(self) -> datetime | None: """When was the position of the current playing media valid.""" # Some media doesn't have duration but reports position, return None if not self._player.now_playing_media.duration: @@ -445,7 +449,7 @@ class HeosMediaPlayer(CoordinatorEntity[HeosCoordinator], MediaPlayerEntity): return image_url if image_url else None @property - def media_title(self) -> str: + def media_title(self) -> str | None: """Title of current playing media.""" return self._player.now_playing_media.song diff --git a/requirements_all.txt b/requirements_all.txt index 24a550e05de..d0b18b7cab5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1980,7 +1980,7 @@ pygti==0.9.4 pyhaversion==22.8.0 # homeassistant.components.heos -pyheos==1.0.0 +pyheos==1.0.1 # homeassistant.components.hive pyhive-integration==1.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fa1dd4bed7f..7ce62fbe43e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1609,7 +1609,7 @@ pygti==0.9.4 pyhaversion==22.8.0 # homeassistant.components.heos -pyheos==1.0.0 +pyheos==1.0.1 # homeassistant.components.hive pyhive-integration==1.0.1 From 69938545df420b309684ad029bc8ddb53d884b57 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sun, 26 Jan 2025 19:16:19 -0800 Subject: [PATCH 0044/3148] Push more of the LLM conversation agent loop into ChatSession (#136602) * Push more of the LLM conversation agent loop into ChatSession * Revert unnecessary changes * Revert changes to agent id filtering --- .../components/conversation/agent_manager.py | 17 ++- .../components/conversation/session.py | 37 ++++++- .../openai_conversation/conversation.py | 62 ++++------- tests/components/conversation/test_session.py | 102 +++++++++++++++++- tests/components/conversation/test_trace.py | 41 ++++++- 5 files changed, 202 insertions(+), 57 deletions(-) diff --git a/homeassistant/components/conversation/agent_manager.py b/homeassistant/components/conversation/agent_manager.py index 97dc5e1292e..ce3a0cf028d 100644 --- a/homeassistant/components/conversation/agent_manager.py +++ b/homeassistant/components/conversation/agent_manager.py @@ -9,7 +9,8 @@ from typing import Any import voluptuous as vol from homeassistant.core import Context, HomeAssistant, async_get_hass, callback -from homeassistant.helpers import config_validation as cv, singleton +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import config_validation as cv, intent, singleton from .const import ( DATA_COMPONENT, @@ -109,7 +110,19 @@ async def async_converse( dataclasses.asdict(conversation_input), ) ) - result = await method(conversation_input) + try: + result = await method(conversation_input) + except HomeAssistantError as err: + intent_response = intent.IntentResponse(language=language) + intent_response.async_set_error( + intent.IntentResponseErrorCode.UNKNOWN, + str(err), + ) + result = ConversationResult( + response=intent_response, + conversation_id=conversation_id, + ) + trace.set_result(**result.as_dict()) return result diff --git a/homeassistant/components/conversation/session.py b/homeassistant/components/conversation/session.py index 48040e8ac9c..2235459954f 100644 --- a/homeassistant/components/conversation/session.py +++ b/homeassistant/components/conversation/session.py @@ -9,6 +9,8 @@ from datetime import datetime, timedelta import logging from typing import Literal +import voluptuous as vol + from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import ( CALLBACK_TYPE, @@ -23,7 +25,9 @@ from homeassistant.helpers import intent, llm, template from homeassistant.helpers.event import async_call_later from homeassistant.util import dt as dt_util, ulid as ulid_util from homeassistant.util.hass_dict import HassKey +from homeassistant.util.json import JsonObjectType +from . import trace from .const import DOMAIN from .models import ConversationInput, ConversationResult @@ -120,7 +124,7 @@ async def async_get_chat_session( if history: history = replace(history, messages=history.messages.copy()) else: - history = ChatSession(hass, conversation_id) + history = ChatSession(hass, conversation_id, user_input.agent_id) message: ChatMessage = ChatMessage( role="user", @@ -190,6 +194,7 @@ class ChatSession[_NativeT]: hass: HomeAssistant conversation_id: str + agent_id: str | None user_name: str | None = None messages: list[ChatMessage[_NativeT]] = field( default_factory=lambda: [ChatMessage(role="system", agent_id=None, content="")] @@ -209,7 +214,9 @@ class ChatSession[_NativeT]: self.messages.append(message) @callback - def async_get_messages(self, agent_id: str | None) -> list[ChatMessage[_NativeT]]: + def async_get_messages( + self, agent_id: str | None = None + ) -> list[ChatMessage[_NativeT]]: """Get messages for a specific agent ID. This will filter out any native message tied to other agent IDs. @@ -326,3 +333,29 @@ class ChatSession[_NativeT]: agent_id=user_input.agent_id, content=prompt, ) + + LOGGER.debug("Prompt: %s", self.messages) + LOGGER.debug("Tools: %s", self.llm_api.tools if self.llm_api else None) + + trace.async_conversation_trace_append( + trace.ConversationTraceEventType.AGENT_DETAIL, + { + "messages": self.messages, + "tools": self.llm_api.tools if self.llm_api else None, + }, + ) + + async def async_call_tool(self, tool_input: llm.ToolInput) -> JsonObjectType: + """Invoke LLM tool for the configured LLM API.""" + if not self.llm_api: + raise ValueError("No LLM API configured") + LOGGER.debug("Tool call: %s(%s)", tool_input.tool_name, tool_input.tool_args) + + try: + tool_response = await self.llm_api.async_call_tool(tool_input) + except (HomeAssistantError, vol.Invalid) as e: + tool_response = {"error": type(e).__name__} + if str(e): + tool_response["error_text"] = str(e) + LOGGER.debug("Tool response: %s", tool_response) + return tool_response diff --git a/homeassistant/components/openai_conversation/conversation.py b/homeassistant/components/openai_conversation/conversation.py index c89574bf3bd..1464f4224d7 100644 --- a/homeassistant/components/openai_conversation/conversation.py +++ b/homeassistant/components/openai_conversation/conversation.py @@ -16,11 +16,9 @@ from openai.types.chat import ( ) from openai.types.chat.chat_completion_message_tool_call_param import Function from openai.types.shared_params import FunctionDefinition -import voluptuous as vol from voluptuous_openapi import convert from homeassistant.components import assist_pipeline, conversation -from homeassistant.components.conversation import trace from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_LLM_HASS_API, MATCH_ALL from homeassistant.core import HomeAssistant @@ -94,6 +92,19 @@ def _message_convert(message: ChatCompletionMessage) -> ChatCompletionMessagePar return param +def _chat_message_convert( + message: conversation.ChatMessage[ChatCompletionMessageParam], + agent_id: str | None, +) -> ChatCompletionMessageParam: + """Convert any native chat message for this agent to the native format.""" + if message.native is not None and message.agent_id == agent_id: + return message.native + return cast( + ChatCompletionMessageParam, + {"role": message.role, "content": message.content}, + ) + + class OpenAIConversationEntity( conversation.ConversationEntity, conversation.AbstractConversationAgent ): @@ -173,27 +184,10 @@ class OpenAIConversationEntity( for tool in session.llm_api.tools ] - messages: list[ChatCompletionMessageParam] = [] - for message in session.async_get_messages(user_input.agent_id): - if message.native is not None and message.agent_id == user_input.agent_id: - messages.append(message.native) - else: - messages.append( - cast( - ChatCompletionMessageParam, - {"role": message.role, "content": message.content}, - ) - ) - - LOGGER.debug("Prompt: %s", messages) - LOGGER.debug("Tools: %s", tools) - trace.async_conversation_trace_append( - trace.ConversationTraceEventType.AGENT_DETAIL, - { - "messages": session.messages, - "tools": session.llm_api.tools if session.llm_api else None, - }, - ) + messages = [ + _chat_message_convert(message, user_input.agent_id) + for message in session.async_get_messages() + ] client = self.entry.runtime_data @@ -211,14 +205,7 @@ class OpenAIConversationEntity( ) except openai.OpenAIError as err: LOGGER.error("Error talking to OpenAI: %s", err) - intent_response = intent.IntentResponse(language=user_input.language) - intent_response.async_set_error( - intent.IntentResponseErrorCode.UNKNOWN, - "Sorry, I had a problem talking to OpenAI", - ) - return conversation.ConversationResult( - response=intent_response, conversation_id=session.conversation_id - ) + raise HomeAssistantError("Error talking to OpenAI") from err LOGGER.debug("Response %s", result) response = result.choices[0].message @@ -241,18 +228,7 @@ class OpenAIConversationEntity( tool_name=tool_call.function.name, tool_args=json.loads(tool_call.function.arguments), ) - LOGGER.debug( - "Tool call: %s(%s)", tool_input.tool_name, tool_input.tool_args - ) - - try: - tool_response = await session.llm_api.async_call_tool(tool_input) - except (HomeAssistantError, vol.Invalid) as e: - tool_response = {"error": type(e).__name__} - if str(e): - tool_response["error_text"] = str(e) - - LOGGER.debug("Tool response: %s", tool_response) + tool_response = await session.async_call_tool(tool_input) messages.append( ChatCompletionToolMessageParam( role="tool", diff --git a/tests/components/conversation/test_session.py b/tests/components/conversation/test_session.py index feb6ca2a9e8..bca19b3b06a 100644 --- a/tests/components/conversation/test_session.py +++ b/tests/components/conversation/test_session.py @@ -2,13 +2,15 @@ from collections.abc import Generator from datetime import timedelta -from unittest.mock import Mock, patch +from unittest.mock import AsyncMock, Mock, patch import pytest from syrupy.assertion import SnapshotAssertion +import voluptuous as vol from homeassistant.components.conversation import ConversationInput, session from homeassistant.core import Context, HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import llm from homeassistant.util import dt as dt_util @@ -182,7 +184,7 @@ async def test_message_filtering( ) assert messages[1] == session.ChatMessage( role="user", - agent_id=mock_conversation_input.agent_id, + agent_id="mock-agent-id", content=mock_conversation_input.text, ) # Cannot add a second user message in a row @@ -203,7 +205,7 @@ async def test_message_filtering( native="assistant-reply-native", ) ) - # Different agent, will be filtered out. + # Different agent, native messages will be filtered out. chat_session.async_add_message( session.ChatMessage( role="native", agent_id="another-mock-agent-id", content="", native=1 @@ -214,11 +216,20 @@ async def test_message_filtering( role="native", agent_id="mock-agent-id", content="", native=1 ) ) + # A non-native message from another agent is not filtered out. + chat_session.async_add_message( + session.ChatMessage( + role="assistant", + agent_id="another-mock-agent-id", + content="Hi!", + native=1, + ) + ) - assert len(chat_session.messages) == 5 + assert len(chat_session.messages) == 6 messages = chat_session.async_get_messages(agent_id="mock-agent-id") - assert len(messages) == 4 + assert len(messages) == 5 assert messages[2] == session.ChatMessage( role="assistant", @@ -229,6 +240,9 @@ async def test_message_filtering( assert messages[3] == session.ChatMessage( role="native", agent_id="mock-agent-id", content="", native=1 ) + assert messages[4] == session.ChatMessage( + role="assistant", agent_id="another-mock-agent-id", content="Hi!", native=1 + ) async def test_llm_api( @@ -413,3 +427,81 @@ async def test_extra_systen_prompt( assert chat_session.extra_system_prompt == extra_system_prompt2 assert chat_session.messages[0].content.endswith(extra_system_prompt2) + + +async def test_tool_call( + hass: HomeAssistant, + mock_conversation_input: ConversationInput, +) -> None: + """Test using the session tool calling API.""" + + mock_tool = AsyncMock() + mock_tool.name = "test_tool" + mock_tool.description = "Test function" + mock_tool.parameters = vol.Schema( + {vol.Optional("param1", description="Test parameters"): str} + ) + mock_tool.async_call.return_value = "Test response" + + with patch( + "homeassistant.components.conversation.session.llm.AssistAPI._async_get_tools", + return_value=[], + ) as mock_get_tools: + mock_get_tools.return_value = [mock_tool] + + async with session.async_get_chat_session( + hass, mock_conversation_input + ) as chat_session: + await chat_session.async_update_llm_data( + conversing_domain="test", + user_input=mock_conversation_input, + user_llm_hass_api="assist", + user_llm_prompt=None, + ) + result = await chat_session.async_call_tool( + llm.ToolInput( + tool_name="test_tool", + tool_args={"param1": "Test Param"}, + ) + ) + + assert result == "Test response" + + +async def test_tool_call_exception( + hass: HomeAssistant, + mock_conversation_input: ConversationInput, +) -> None: + """Test using the session tool calling API.""" + + mock_tool = AsyncMock() + mock_tool.name = "test_tool" + mock_tool.description = "Test function" + mock_tool.parameters = vol.Schema( + {vol.Optional("param1", description="Test parameters"): str} + ) + mock_tool.async_call.side_effect = HomeAssistantError("Test error") + + with patch( + "homeassistant.components.conversation.session.llm.AssistAPI._async_get_tools", + return_value=[], + ) as mock_get_tools: + mock_get_tools.return_value = [mock_tool] + + async with session.async_get_chat_session( + hass, mock_conversation_input + ) as chat_session: + await chat_session.async_update_llm_data( + conversing_domain="test", + user_input=mock_conversation_input, + user_llm_hass_api="assist", + user_llm_prompt=None, + ) + result = await chat_session.async_call_tool( + llm.ToolInput( + tool_name="test_tool", + tool_args={"param1": "Test Param"}, + ) + ) + + assert result == {"error": "HomeAssistantError", "error_text": "Test error"} diff --git a/tests/components/conversation/test_trace.py b/tests/components/conversation/test_trace.py index 7c00b9a80b2..a975c9b7983 100644 --- a/tests/components/conversation/test_trace.py +++ b/tests/components/conversation/test_trace.py @@ -61,18 +61,18 @@ async def test_converation_trace( } -async def test_converation_trace_error( +async def test_converation_trace_uncaught_error( hass: HomeAssistant, init_components: None, sl_setup: None, ) -> None: - """Test tracing a conversation.""" + """Test tracing a conversation that raises an uncaught error.""" with ( patch( "homeassistant.components.conversation.default_agent.DefaultAgent.async_process", - side_effect=HomeAssistantError("Failed to talk to agent"), + side_effect=ValueError("Unexpected error"), ), - pytest.raises(HomeAssistantError), + pytest.raises(ValueError), ): await conversation.async_converse( hass, "add apples to my shopping list", None, Context() @@ -87,4 +87,35 @@ async def test_converation_trace_error( assert ( trace_event.get("event_type") == trace.ConversationTraceEventType.ASYNC_PROCESS ) - assert last_trace.get("error") == "Failed to talk to agent" + assert last_trace.get("error") == "Unexpected error" + assert not last_trace.get("result") + + +async def test_converation_trace_homeassistant_error( + hass: HomeAssistant, + init_components: None, + sl_setup: None, +) -> None: + """Test tracing a conversation with a HomeAssistant error.""" + with ( + patch( + "homeassistant.components.conversation.default_agent.DefaultAgent.async_process", + side_effect=HomeAssistantError("Failed to talk to agent"), + ), + ): + await conversation.async_converse( + hass, "add apples to my shopping list", None, Context() + ) + + traces = trace.async_get_traces() + assert traces + last_trace = traces[-1].as_dict() + assert last_trace.get("events") + assert len(last_trace.get("events")) == 1 + trace_event = last_trace["events"][0] + assert ( + trace_event.get("event_type") == trace.ConversationTraceEventType.ASYNC_PROCESS + ) + result = last_trace.get("result") + assert result + assert result["response"]["speech"]["plain"]["speech"] == "Failed to talk to agent" From 245ee2498e615ffa9e3a1cb4fe2bb224b8bc5a0b Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 27 Jan 2025 08:25:22 +0100 Subject: [PATCH 0045/3148] Update hassio to use the backup integration to make backups before update (#136235) Co-authored-by: Martin Hjelmare --- homeassistant/components/backup/__init__.py | 30 +- homeassistant/components/backup/config.py | 13 +- homeassistant/components/backup/manager.py | 15 + homeassistant/components/hassio/backup.py | 67 +++ homeassistant/components/hassio/update.py | 35 +- .../components/hassio/update_helper.py | 59 +++ .../components/hassio/websocket_api.py | 48 ++ tests/components/conftest.py | 6 +- tests/components/hassio/test_update.py | 318 +++++++++++- tests/components/hassio/test_websocket_api.py | 452 +++++++++++++++++- 10 files changed, 979 insertions(+), 64 deletions(-) create mode 100644 homeassistant/components/hassio/update_helper.py diff --git a/homeassistant/components/backup/__init__.py b/homeassistant/components/backup/__init__.py index 8d25a0c25cb..10294f6ff12 100644 --- a/homeassistant/components/backup/__init__.py +++ b/homeassistant/components/backup/__init__.py @@ -1,6 +1,7 @@ """The Backup integration.""" -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv from homeassistant.helpers.hassio import is_hassio from homeassistant.helpers.typing import ConfigType @@ -19,6 +20,7 @@ from .const import DATA_MANAGER, DOMAIN from .http import async_register_http_views from .manager import ( BackupManager, + BackupManagerError, BackupPlatformProtocol, BackupReaderWriter, BackupReaderWriterError, @@ -39,6 +41,7 @@ __all__ = [ "BackupAgent", "BackupAgentError", "BackupAgentPlatformProtocol", + "BackupManagerError", "BackupPlatformProtocol", "BackupReaderWriter", "BackupReaderWriterError", @@ -90,18 +93,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_handle_create_automatic_service(call: ServiceCall) -> None: """Service handler for creating automatic backups.""" - config_data = backup_manager.config.data - await backup_manager.async_create_backup( - agent_ids=config_data.create_backup.agent_ids, - include_addons=config_data.create_backup.include_addons, - include_all_addons=config_data.create_backup.include_all_addons, - include_database=config_data.create_backup.include_database, - include_folders=config_data.create_backup.include_folders, - include_homeassistant=True, # always include HA - name=config_data.create_backup.name, - password=config_data.create_backup.password, - with_automatic_settings=True, - ) + await backup_manager.async_create_automatic_backup() if not with_hassio: hass.services.async_register(DOMAIN, "create", async_handle_create_service) @@ -112,3 +104,15 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async_register_http_views(hass) return True + + +@callback +def async_get_manager(hass: HomeAssistant) -> BackupManager: + """Get the backup manager instance. + + Raises HomeAssistantError if the backup integration is not available. + """ + if DATA_MANAGER not in hass.data: + raise HomeAssistantError("Backup integration is not available") + + return hass.data[DATA_MANAGER] diff --git a/homeassistant/components/backup/config.py b/homeassistant/components/backup/config.py index 8edd6cf0f2b..1d1b8046360 100644 --- a/homeassistant/components/backup/config.py +++ b/homeassistant/components/backup/config.py @@ -390,22 +390,11 @@ class BackupSchedule: async def _create_backup(now: datetime) -> None: """Create backup.""" manager.remove_next_backup_event = None - config_data = manager.config.data self._schedule_next(cron_pattern, manager) # create the backup try: - await manager.async_create_backup( - agent_ids=config_data.create_backup.agent_ids, - include_addons=config_data.create_backup.include_addons, - include_all_addons=config_data.create_backup.include_all_addons, - include_database=config_data.create_backup.include_database, - include_folders=config_data.create_backup.include_folders, - include_homeassistant=True, # always include HA - name=config_data.create_backup.name, - password=config_data.create_backup.password, - with_automatic_settings=True, - ) + await manager.async_create_automatic_backup() except BackupManagerError as err: LOGGER.error("Error creating backup: %s", err) except Exception: # noqa: BLE001 diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index 32979194980..8c8cd805565 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -698,6 +698,21 @@ class BackupManager: await self._backup_finish_task return new_backup + async def async_create_automatic_backup(self) -> NewBackup: + """Create a backup with automatic backup settings.""" + config_data = self.config.data + return await self.async_create_backup( + agent_ids=config_data.create_backup.agent_ids, + include_addons=config_data.create_backup.include_addons, + include_all_addons=config_data.create_backup.include_all_addons, + include_database=config_data.create_backup.include_database, + include_folders=config_data.create_backup.include_folders, + include_homeassistant=True, # always include HA + name=config_data.create_backup.name, + password=config_data.create_backup.password, + with_automatic_settings=True, + ) + async def async_initiate_backup( self, *, diff --git a/homeassistant/components/hassio/backup.py b/homeassistant/components/hassio/backup.py index 2ebd3f6aab4..d49fafb886f 100644 --- a/homeassistant/components/hassio/backup.py +++ b/homeassistant/components/hassio/backup.py @@ -8,6 +8,7 @@ import logging from pathlib import Path from typing import Any, cast +from aiohasupervisor import SupervisorClient from aiohasupervisor.exceptions import ( SupervisorBadRequestError, SupervisorError, @@ -23,6 +24,7 @@ from homeassistant.components.backup import ( AddonInfo, AgentBackup, BackupAgent, + BackupManagerError, BackupReaderWriter, BackupReaderWriterError, CreateBackupEvent, @@ -31,7 +33,9 @@ from homeassistant.components.backup import ( NewBackup, RestoreBackupEvent, WrittenBackup, + async_get_manager as async_get_backup_manager, ) +from homeassistant.const import __version__ as HAVERSION from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -477,3 +481,66 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): self._hass, EVENT_SUPERVISOR_EVENT, handle_signal ) return unsub + + +async def _default_agent(client: SupervisorClient) -> str: + """Return the default agent for creating a backup.""" + mounts = await client.mounts.info() + default_mount = mounts.default_backup_mount + return f"hassio.{default_mount if default_mount is not None else 'local'}" + + +async def backup_addon_before_update( + hass: HomeAssistant, + addon: str, + addon_name: str | None, + installed_version: str | None, +) -> None: + """Prepare for updating an add-on.""" + backup_manager = hass.data[DATA_MANAGER] + client = get_supervisor_client(hass) + + # Use the password from automatic settings if available + if backup_manager.config.data.create_backup.agent_ids: + password = backup_manager.config.data.create_backup.password + else: + password = None + + try: + await backup_manager.async_create_backup( + agent_ids=[await _default_agent(client)], + include_addons=[addon], + include_all_addons=False, + include_database=False, + include_folders=None, + include_homeassistant=False, + name=f"{addon_name or addon} {installed_version or ''}", + password=password, + ) + except BackupManagerError as err: + raise HomeAssistantError(f"Error creating backup: {err}") from err + + +async def backup_core_before_update(hass: HomeAssistant) -> None: + """Prepare for updating core.""" + backup_manager = async_get_backup_manager(hass) + client = get_supervisor_client(hass) + + try: + if backup_manager.config.data.create_backup.agent_ids: + # Create a backup with automatic settings + await backup_manager.async_create_automatic_backup() + else: + # Create a manual backup + await backup_manager.async_create_backup( + agent_ids=[await _default_agent(client)], + include_addons=None, + include_all_addons=False, + include_database=True, + include_folders=None, + include_homeassistant=True, + name=f"Home Assistant Core {HAVERSION}", + password=None, + ) + except BackupManagerError as err: + raise HomeAssistantError(f"Error creating backup: {err}") from err diff --git a/homeassistant/components/hassio/update.py b/homeassistant/components/hassio/update.py index fbb3e191f81..17b0a5bc9ca 100644 --- a/homeassistant/components/hassio/update.py +++ b/homeassistant/components/hassio/update.py @@ -4,12 +4,8 @@ from __future__ import annotations from typing import Any -from aiohasupervisor import SupervisorError -from aiohasupervisor.models import ( - HomeAssistantUpdateOptions, - OSUpdate, - StoreAddonUpdate, -) +from aiohasupervisor import SupervisorClient, SupervisorError +from aiohasupervisor.models import OSUpdate from awesomeversion import AwesomeVersion, AwesomeVersionStrategy from homeassistant.components.update import ( @@ -40,6 +36,7 @@ from .entity import ( HassioOSEntity, HassioSupervisorEntity, ) +from .update_helper import update_addon, update_core ENTITY_DESCRIPTION = UpdateEntityDescription( name="Update", @@ -163,13 +160,9 @@ class SupervisorAddonUpdateEntity(HassioAddonEntity, UpdateEntity): **kwargs: Any, ) -> None: """Install an update.""" - try: - await self.coordinator.supervisor_client.store.update_addon( - self._addon_slug, StoreAddonUpdate(backup=backup) - ) - except SupervisorError as err: - raise HomeAssistantError(f"Error updating {self.title}: {err}") from err - + await update_addon( + self.hass, self._addon_slug, backup, self.title, self.installed_version + ) await self.coordinator.force_info_update_supervisor() @@ -303,11 +296,11 @@ class SupervisorCoreUpdateEntity(HassioCoreEntity, UpdateEntity): self, version: str | None, backup: bool, **kwargs: Any ) -> None: """Install an update.""" - try: - await self.coordinator.supervisor_client.homeassistant.update( - HomeAssistantUpdateOptions(version=version, backup=backup) - ) - except SupervisorError as err: - raise HomeAssistantError( - f"Error updating Home Assistant Core: {err}" - ) from err + await update_core(self.hass, version, backup) + + +async def _default_agent(client: SupervisorClient) -> str: + """Return the default agent for creating a backup.""" + mounts = await client.mounts.info() + default_mount = mounts.default_backup_mount + return f"hassio.{default_mount if default_mount is not None else 'local'}" diff --git a/homeassistant/components/hassio/update_helper.py b/homeassistant/components/hassio/update_helper.py new file mode 100644 index 00000000000..d801f6b5771 --- /dev/null +++ b/homeassistant/components/hassio/update_helper.py @@ -0,0 +1,59 @@ +"""Update helpers for Supervisor.""" + +from __future__ import annotations + +from aiohasupervisor import SupervisorError +from aiohasupervisor.models import HomeAssistantUpdateOptions, StoreAddonUpdate + +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError + +from .handler import get_supervisor_client + + +async def update_addon( + hass: HomeAssistant, + addon: str, + backup: bool, + addon_name: str | None, + installed_version: str | None, +) -> None: + """Update an addon. + + Optionally make a backup before updating. + """ + client = get_supervisor_client(hass) + + if backup: + # pylint: disable-next=import-outside-toplevel + from .backup import backup_addon_before_update + + await backup_addon_before_update(hass, addon, addon_name, installed_version) + + try: + await client.store.update_addon(addon, StoreAddonUpdate(backup=False)) + except SupervisorError as err: + raise HomeAssistantError( + f"Error updating {addon_name or addon}: {err}" + ) from err + + +async def update_core(hass: HomeAssistant, version: str | None, backup: bool) -> None: + """Update core. + + Optionally make a backup before updating. + """ + client = get_supervisor_client(hass) + + if backup: + # pylint: disable-next=import-outside-toplevel + from .backup import backup_core_before_update + + await backup_core_before_update(hass) + + try: + await client.homeassistant.update( + HomeAssistantUpdateOptions(version=version, backup=False) + ) + except SupervisorError as err: + raise HomeAssistantError(f"Error updating Home Assistant Core: {err}") from err diff --git a/homeassistant/components/hassio/websocket_api.py b/homeassistant/components/hassio/websocket_api.py index f9d1b40575b..23fdc721168 100644 --- a/homeassistant/components/hassio/websocket_api.py +++ b/homeassistant/components/hassio/websocket_api.py @@ -9,6 +9,7 @@ import voluptuous as vol from homeassistant.components import websocket_api from homeassistant.components.websocket_api import ActiveConnection +from homeassistant.const import ATTR_NAME from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import Unauthorized import homeassistant.helpers.config_validation as cv @@ -23,7 +24,9 @@ from .const import ( ATTR_ENDPOINT, ATTR_METHOD, ATTR_SESSION_DATA_USER_ID, + ATTR_SLUG, ATTR_TIMEOUT, + ATTR_VERSION, ATTR_WS_EVENT, DATA_COMPONENT, EVENT_SUPERVISOR_EVENT, @@ -33,6 +36,8 @@ from .const import ( WS_TYPE_EVENT, WS_TYPE_SUBSCRIBE, ) +from .coordinator import get_supervisor_info +from .update_helper import update_addon, update_core SCHEMA_WEBSOCKET_EVENT = vol.Schema( {vol.Required(ATTR_WS_EVENT): cv.string}, @@ -58,6 +63,8 @@ def async_load_websocket_api(hass: HomeAssistant) -> None: websocket_api.async_register_command(hass, websocket_supervisor_event) websocket_api.async_register_command(hass, websocket_supervisor_api) websocket_api.async_register_command(hass, websocket_subscribe) + websocket_api.async_register_command(hass, websocket_update_addon) + websocket_api.async_register_command(hass, websocket_update_core) @callback @@ -137,3 +144,44 @@ async def websocket_supervisor_api( ) else: connection.send_result(msg[WS_ID], result.get(ATTR_DATA, {})) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(WS_TYPE): "hassio/update/addon", + vol.Required("addon"): str, + vol.Required("backup"): bool, + } +) +@websocket_api.async_response +async def websocket_update_addon( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Websocket handler to update an addon.""" + addon_name: str | None = None + addon_version: str | None = None + addons: list = (get_supervisor_info(hass) or {}).get("addons", []) + for addon in addons: + if addon[ATTR_SLUG] == msg["addon"]: + addon_name = addon[ATTR_NAME] + addon_version = addon[ATTR_VERSION] + break + await update_addon(hass, msg["addon"], msg["backup"], addon_name, addon_version) + connection.send_result(msg[WS_ID]) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(WS_TYPE): "hassio/update/core", + vol.Required("backup"): bool, + } +) +@websocket_api.async_response +async def websocket_update_core( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Websocket handler to update an addon.""" + await update_core(hass, None, msg["backup"]) + connection.send_result(msg[WS_ID]) diff --git a/tests/components/conftest.py b/tests/components/conftest.py index 9e1ce8d7f43..0cd33e28d35 100644 --- a/tests/components/conftest.py +++ b/tests/components/conftest.py @@ -528,7 +528,7 @@ def resolution_suggestions_for_issue_fixture(supervisor_client: AsyncMock) -> As @pytest.fixture(name="supervisor_client") def supervisor_client() -> Generator[AsyncMock]: """Mock the supervisor client.""" - mounts_info_mock = AsyncMock(spec_set=["mounts"]) + mounts_info_mock = AsyncMock(spec_set=["default_backup_mount", "mounts"]) mounts_info_mock.mounts = [] supervisor_client = AsyncMock() supervisor_client.addons = AsyncMock() @@ -572,6 +572,10 @@ def supervisor_client() -> Generator[AsyncMock]: "homeassistant.components.hassio.repairs.get_supervisor_client", return_value=supervisor_client, ), + patch( + "homeassistant.components.hassio.update_helper.get_supervisor_client", + return_value=supervisor_client, + ), ): yield supervisor_client diff --git a/tests/components/hassio/test_update.py b/tests/components/hassio/test_update.py index c1775d6e0b4..88d7076824f 100644 --- a/tests/components/hassio/test_update.py +++ b/tests/components/hassio/test_update.py @@ -2,14 +2,17 @@ from datetime import timedelta import os +from typing import Any from unittest.mock import AsyncMock, patch from aiohasupervisor import SupervisorBadRequestError, SupervisorError -from aiohasupervisor.models import StoreAddonUpdate +from aiohasupervisor.models import HomeAssistantUpdateOptions, StoreAddonUpdate import pytest +from homeassistant.components.backup import BackupManagerError from homeassistant.components.hassio import DOMAIN from homeassistant.components.hassio.const import REQUEST_REFRESH_DELAY +from homeassistant.const import __version__ as HAVERSION from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.setup import async_setup_component @@ -216,12 +219,119 @@ async def test_update_addon(hass: HomeAssistant, update_addon: AsyncMock) -> Non assert result await hass.async_block_till_done() - await hass.services.async_call( - "update", - "install", - {"entity_id": "update.test_update"}, - blocking=True, - ) + with patch( + "homeassistant.components.backup.manager.BackupManager.async_create_backup", + ) as mock_create_backup: + await hass.services.async_call( + "update", + "install", + {"entity_id": "update.test_update"}, + blocking=True, + ) + mock_create_backup.assert_not_called() + update_addon.assert_called_once_with("test", StoreAddonUpdate(backup=False)) + + +@pytest.mark.parametrize( + ("commands", "default_mount", "expected_kwargs"), + [ + ( + [], + None, + { + "agent_ids": ["hassio.local"], + "include_addons": ["test"], + "include_all_addons": False, + "include_database": False, + "include_folders": None, + "include_homeassistant": False, + "name": "test 2.0.0", + "password": None, + }, + ), + ( + [], + "my_nas", + { + "agent_ids": ["hassio.my_nas"], + "include_addons": ["test"], + "include_all_addons": False, + "include_database": False, + "include_folders": None, + "include_homeassistant": False, + "name": "test 2.0.0", + "password": None, + }, + ), + ( + [ + { + "type": "backup/config/update", + "create_backup": { + "agent_ids": ["test-agent"], + "include_addons": ["my-addon"], + "include_all_addons": True, + "include_database": False, + "include_folders": ["share"], + "name": "cool_backup", + "password": "hunter2", + }, + }, + ], + None, + { + "agent_ids": ["hassio.local"], + "include_addons": ["test"], + "include_all_addons": False, + "include_database": False, + "include_folders": None, + "include_homeassistant": False, + "name": "test 2.0.0", + "password": "hunter2", + }, + ), + ], +) +async def test_update_addon_with_backup( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + supervisor_client: AsyncMock, + update_addon: AsyncMock, + commands: list[dict[str, Any]], + default_mount: str | None, + expected_kwargs: dict[str, Any], +) -> None: + """Test updating addon update entity.""" + config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) + config_entry.add_to_hass(hass) + + with patch.dict(os.environ, MOCK_ENVIRON): + result = await async_setup_component( + hass, + "hassio", + {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + ) + assert result + assert await async_setup_component(hass, "backup", {}) + await hass.async_block_till_done() + + client = await hass_ws_client(hass) + for command in commands: + await client.send_json_auto_id(command) + result = await client.receive_json() + assert result["success"] + + supervisor_client.mounts.info.return_value.default_backup_mount = default_mount + with patch( + "homeassistant.components.backup.manager.BackupManager.async_create_backup", + ) as mock_create_backup: + await hass.services.async_call( + "update", + "install", + {"entity_id": "update.test_update", "backup": True}, + blocking=True, + ) + mock_create_backup.assert_called_once_with(**expected_kwargs) update_addon.assert_called_once_with("test", StoreAddonUpdate(backup=False)) @@ -264,13 +374,125 @@ async def test_update_core(hass: HomeAssistant, supervisor_client: AsyncMock) -> await hass.async_block_till_done() supervisor_client.homeassistant.update.return_value = None - await hass.services.async_call( - "update", - "install", - {"entity_id": "update.home_assistant_core_update"}, - blocking=True, + with patch( + "homeassistant.components.backup.manager.BackupManager.async_create_backup", + ) as mock_create_backup: + await hass.services.async_call( + "update", + "install", + {"entity_id": "update.home_assistant_core_update"}, + blocking=True, + ) + mock_create_backup.assert_not_called() + supervisor_client.homeassistant.update.assert_called_once_with( + HomeAssistantUpdateOptions(version=None, backup=False) + ) + + +@pytest.mark.parametrize( + ("commands", "default_mount", "expected_kwargs"), + [ + ( + [], + None, + { + "agent_ids": ["hassio.local"], + "include_addons": None, + "include_all_addons": False, + "include_database": True, + "include_folders": None, + "include_homeassistant": True, + "name": f"Home Assistant Core {HAVERSION}", + "password": None, + }, + ), + ( + [], + "my_nas", + { + "agent_ids": ["hassio.my_nas"], + "include_addons": None, + "include_all_addons": False, + "include_database": True, + "include_folders": None, + "include_homeassistant": True, + "name": f"Home Assistant Core {HAVERSION}", + "password": None, + }, + ), + ( + [ + { + "type": "backup/config/update", + "create_backup": { + "agent_ids": ["test-agent"], + "include_addons": ["my-addon"], + "include_all_addons": True, + "include_database": False, + "include_folders": ["share"], + "name": "cool_backup", + "password": "hunter2", + }, + }, + ], + None, + { + "agent_ids": ["test-agent"], + "include_addons": ["my-addon"], + "include_all_addons": True, + "include_database": False, + "include_folders": ["share"], + "include_homeassistant": True, + "name": "cool_backup", + "password": "hunter2", + "with_automatic_settings": True, + }, + ), + ], +) +async def test_update_core_with_backup( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + supervisor_client: AsyncMock, + commands: list[dict[str, Any]], + default_mount: str | None, + expected_kwargs: dict[str, Any], +) -> None: + """Test updating core update entity.""" + config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) + config_entry.add_to_hass(hass) + + with patch.dict(os.environ, MOCK_ENVIRON): + result = await async_setup_component( + hass, + "hassio", + {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + ) + assert result + assert await async_setup_component(hass, "backup", {}) + await hass.async_block_till_done() + + client = await hass_ws_client(hass) + for command in commands: + await client.send_json_auto_id(command) + result = await client.receive_json() + assert result["success"] + + supervisor_client.homeassistant.update.return_value = None + supervisor_client.mounts.info.return_value.default_backup_mount = default_mount + with patch( + "homeassistant.components.backup.manager.BackupManager.async_create_backup", + ) as mock_create_backup: + await hass.services.async_call( + "update", + "install", + {"entity_id": "update.home_assistant_core_update", "backup": True}, + blocking=True, + ) + mock_create_backup.assert_called_once_with(**expected_kwargs) + supervisor_client.homeassistant.update.assert_called_once_with( + HomeAssistantUpdateOptions(version=None, backup=False) ) - supervisor_client.homeassistant.update.assert_called_once() async def test_update_supervisor( @@ -325,6 +547,41 @@ async def test_update_addon_with_error( ) +async def test_update_addon_with_backup_and_error( + hass: HomeAssistant, + supervisor_client: AsyncMock, +) -> None: + """Test updating addon update entity with error.""" + config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) + config_entry.add_to_hass(hass) + + with patch.dict(os.environ, MOCK_ENVIRON): + result = await async_setup_component( + hass, + "hassio", + {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + ) + assert result + assert await async_setup_component(hass, "backup", {}) + await hass.async_block_till_done() + + supervisor_client.homeassistant.update.return_value = None + supervisor_client.mounts.info.return_value.default_backup_mount = None + with ( + patch( + "homeassistant.components.backup.manager.BackupManager.async_create_backup", + side_effect=BackupManagerError, + ), + pytest.raises(HomeAssistantError, match=r"^Error creating backup:"), + ): + assert not await hass.services.async_call( + "update", + "install", + {"entity_id": "update.test_update", "backup": True}, + blocking=True, + ) + + async def test_update_os_with_error( hass: HomeAssistant, supervisor_client: AsyncMock ) -> None: @@ -406,6 +663,41 @@ async def test_update_core_with_error( ) +async def test_update_core_with_backup_and_error( + hass: HomeAssistant, + supervisor_client: AsyncMock, +) -> None: + """Test updating core update entity with error.""" + config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) + config_entry.add_to_hass(hass) + + with patch.dict(os.environ, MOCK_ENVIRON): + result = await async_setup_component( + hass, + "hassio", + {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + ) + assert result + assert await async_setup_component(hass, "backup", {}) + await hass.async_block_till_done() + + supervisor_client.homeassistant.update.return_value = None + supervisor_client.mounts.info.return_value.default_backup_mount = None + with ( + patch( + "homeassistant.components.backup.manager.BackupManager.async_create_backup", + side_effect=BackupManagerError, + ), + pytest.raises(HomeAssistantError, match=r"^Error creating backup:"), + ): + assert not await hass.services.async_call( + "update", + "install", + {"entity_id": "update.home_assistant_core_update", "backup": True}, + blocking=True, + ) + + async def test_release_notes_between_versions( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, diff --git a/tests/components/hassio/test_websocket_api.py b/tests/components/hassio/test_websocket_api.py index 21e6b03678b..1fefe54ad75 100644 --- a/tests/components/hassio/test_websocket_api.py +++ b/tests/components/hassio/test_websocket_api.py @@ -1,9 +1,15 @@ """Test websocket API.""" -from unittest.mock import AsyncMock +import os +from typing import Any +from unittest.mock import AsyncMock, patch +from aiohasupervisor import SupervisorError +from aiohasupervisor.models import HomeAssistantUpdateOptions, StoreAddonUpdate import pytest +from homeassistant.components.backup import BackupManagerError +from homeassistant.components.hassio import DOMAIN from homeassistant.components.hassio.const import ( ATTR_DATA, ATTR_ENDPOINT, @@ -15,14 +21,17 @@ from homeassistant.components.hassio.const import ( WS_TYPE_API, WS_TYPE_SUBSCRIBE, ) +from homeassistant.const import __version__ as HAVERSION from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.setup import async_setup_component -from tests.common import MockUser, async_mock_signal +from tests.common import MockConfigEntry, MockUser, async_mock_signal from tests.test_util.aiohttp import AiohttpClientMocker from tests.typing import WebSocketGenerator +MOCK_ENVIRON = {"SUPERVISOR": "127.0.0.1", "SUPERVISOR_TOKEN": "abcdefgh"} + @pytest.fixture(autouse=True) def mock_all( @@ -56,7 +65,7 @@ def mock_all( ) aioclient_mock.get( "http://127.0.0.1/core/info", - json={"result": "ok", "data": {"version_latest": "1.0.0"}}, + json={"result": "ok", "data": {"version_latest": "1.0.0", "version": "1.0.0"}}, ) aioclient_mock.get( "http://127.0.0.1/os/info", @@ -64,11 +73,42 @@ def mock_all( ) aioclient_mock.get( "http://127.0.0.1/supervisor/info", - json={"result": "ok", "data": {"version_latest": "1.0.0"}}, + json={ + "result": "ok", + "data": { + "version": "1.0.0", + "version_latest": "1.0.0", + "auto_update": True, + "addons": [ + { + "name": "test", + "state": "started", + "slug": "test", + "installed": True, + "update_available": True, + "icon": False, + "version": "2.0.0", + "version_latest": "2.0.1", + "repository": "core", + "url": "https://github.com/home-assistant/addons/test", + }, + ], + }, + }, ) aioclient_mock.get( "http://127.0.0.1/ingress/panels", json={"result": "ok", "data": {"panels": {}}} ) + aioclient_mock.get( + "http://127.0.0.1/network/info", + json={ + "result": "ok", + "data": { + "host_internet": True, + "supervisor_internet": True, + }, + }, + ) @pytest.mark.usefixtures("hassio_env") @@ -279,3 +319,407 @@ async def test_websocket_non_admin_user( msg = await websocket_client.receive_json() assert msg["error"]["message"] == "Unauthorized" + + +async def test_update_addon( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + update_addon: AsyncMock, +) -> None: + """Test updating addon.""" + client = await hass_ws_client(hass) + config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) + config_entry.add_to_hass(hass) + + with patch.dict(os.environ, MOCK_ENVIRON): + result = await async_setup_component( + hass, + "hassio", + {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + ) + assert result + await hass.async_block_till_done() + + with patch( + "homeassistant.components.backup.manager.BackupManager.async_create_backup", + ) as mock_create_backup: + await client.send_json_auto_id( + {"type": "hassio/update/addon", "addon": "test", "backup": False} + ) + result = await client.receive_json() + assert result["success"] + mock_create_backup.assert_not_called() + update_addon.assert_called_once_with("test", StoreAddonUpdate(backup=False)) + + +@pytest.mark.parametrize( + ("commands", "default_mount", "expected_kwargs"), + [ + ( + [], + None, + { + "agent_ids": ["hassio.local"], + "include_addons": ["test"], + "include_all_addons": False, + "include_database": False, + "include_folders": None, + "include_homeassistant": False, + "name": "test 2.0.0", + "password": None, + }, + ), + ( + [], + "my_nas", + { + "agent_ids": ["hassio.my_nas"], + "include_addons": ["test"], + "include_all_addons": False, + "include_database": False, + "include_folders": None, + "include_homeassistant": False, + "name": "test 2.0.0", + "password": None, + }, + ), + ( + [ + { + "type": "backup/config/update", + "create_backup": { + "agent_ids": ["test-agent"], + "include_addons": ["my-addon"], + "include_all_addons": True, + "include_database": False, + "include_folders": ["share"], + "name": "cool_backup", + "password": "hunter2", + }, + }, + ], + None, + { + "agent_ids": ["hassio.local"], + "include_addons": ["test"], + "include_all_addons": False, + "include_database": False, + "include_folders": None, + "include_homeassistant": False, + "name": "test 2.0.0", + "password": "hunter2", + }, + ), + ], +) +async def test_update_addon_with_backup( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + supervisor_client: AsyncMock, + update_addon: AsyncMock, + commands: list[dict[str, Any]], + default_mount: str | None, + expected_kwargs: dict[str, Any], +) -> None: + """Test updating addon with backup.""" + config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) + config_entry.add_to_hass(hass) + + with patch.dict(os.environ, MOCK_ENVIRON): + result = await async_setup_component( + hass, + "hassio", + {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + ) + assert result + assert await async_setup_component(hass, "backup", {}) + await hass.async_block_till_done() + + client = await hass_ws_client(hass) + for command in commands: + await client.send_json_auto_id(command) + result = await client.receive_json() + assert result["success"] + + supervisor_client.mounts.info.return_value.default_backup_mount = default_mount + with patch( + "homeassistant.components.backup.manager.BackupManager.async_create_backup", + ) as mock_create_backup: + await client.send_json_auto_id( + {"type": "hassio/update/addon", "addon": "test", "backup": True} + ) + result = await client.receive_json() + assert result["success"] + mock_create_backup.assert_called_once_with(**expected_kwargs) + update_addon.assert_called_once_with("test", StoreAddonUpdate(backup=False)) + + +async def test_update_core( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + supervisor_client: AsyncMock, +) -> None: + """Test updating core.""" + client = await hass_ws_client(hass) + config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) + config_entry.add_to_hass(hass) + + with patch.dict(os.environ, MOCK_ENVIRON): + result = await async_setup_component( + hass, + "hassio", + {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + ) + assert result + await hass.async_block_till_done() + + supervisor_client.homeassistant.update.return_value = None + with patch( + "homeassistant.components.backup.manager.BackupManager.async_create_backup", + ) as mock_create_backup: + await client.send_json_auto_id({"type": "hassio/update/core", "backup": False}) + result = await client.receive_json() + assert result["success"] + mock_create_backup.assert_not_called() + supervisor_client.homeassistant.update.assert_called_once_with( + HomeAssistantUpdateOptions(version=None, backup=False) + ) + + +@pytest.mark.parametrize( + ("commands", "default_mount", "expected_kwargs"), + [ + ( + [], + None, + { + "agent_ids": ["hassio.local"], + "include_addons": None, + "include_all_addons": False, + "include_database": True, + "include_folders": None, + "include_homeassistant": True, + "name": f"Home Assistant Core {HAVERSION}", + "password": None, + }, + ), + ( + [], + "my_nas", + { + "agent_ids": ["hassio.my_nas"], + "include_addons": None, + "include_all_addons": False, + "include_database": True, + "include_folders": None, + "include_homeassistant": True, + "name": f"Home Assistant Core {HAVERSION}", + "password": None, + }, + ), + ( + [ + { + "type": "backup/config/update", + "create_backup": { + "agent_ids": ["test-agent"], + "include_addons": ["my-addon"], + "include_all_addons": True, + "include_database": False, + "include_folders": ["share"], + "name": "cool_backup", + "password": "hunter2", + }, + }, + ], + None, + { + "agent_ids": ["test-agent"], + "include_addons": ["my-addon"], + "include_all_addons": True, + "include_database": False, + "include_folders": ["share"], + "include_homeassistant": True, + "name": "cool_backup", + "password": "hunter2", + "with_automatic_settings": True, + }, + ), + ], +) +async def test_update_core_with_backup( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + supervisor_client: AsyncMock, + commands: list[dict[str, Any]], + default_mount: str | None, + expected_kwargs: dict[str, Any], +) -> None: + """Test updating core with backup.""" + config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) + config_entry.add_to_hass(hass) + + with patch.dict(os.environ, MOCK_ENVIRON): + result = await async_setup_component( + hass, + "hassio", + {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + ) + assert result + assert await async_setup_component(hass, "backup", {}) + await hass.async_block_till_done() + + client = await hass_ws_client(hass) + for command in commands: + await client.send_json_auto_id(command) + result = await client.receive_json() + assert result["success"] + + supervisor_client.homeassistant.update.return_value = None + supervisor_client.mounts.info.return_value.default_backup_mount = default_mount + with patch( + "homeassistant.components.backup.manager.BackupManager.async_create_backup", + ) as mock_create_backup: + await client.send_json_auto_id({"type": "hassio/update/core", "backup": True}) + result = await client.receive_json() + assert result["success"] + mock_create_backup.assert_called_once_with(**expected_kwargs) + supervisor_client.homeassistant.update.assert_called_once_with( + HomeAssistantUpdateOptions(version=None, backup=False) + ) + + +async def test_update_addon_with_error( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + update_addon: AsyncMock, +) -> None: + """Test updating addon with error.""" + client = await hass_ws_client(hass) + config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) + config_entry.add_to_hass(hass) + + with patch.dict(os.environ, MOCK_ENVIRON): + assert await async_setup_component( + hass, + "hassio", + {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + ) + await hass.async_block_till_done() + + update_addon.side_effect = SupervisorError + await client.send_json_auto_id( + {"type": "hassio/update/addon", "addon": "test", "backup": False} + ) + result = await client.receive_json() + assert not result["success"] + assert result["error"] == { + "code": "home_assistant_error", + "message": "Error updating test: ", + } + + +async def test_update_addon_with_backup_and_error( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + supervisor_client: AsyncMock, +) -> None: + """Test updating addon with backup and error.""" + client = await hass_ws_client(hass) + config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) + config_entry.add_to_hass(hass) + + with patch.dict(os.environ, MOCK_ENVIRON): + result = await async_setup_component( + hass, + "hassio", + {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + ) + assert result + assert await async_setup_component(hass, "backup", {}) + await hass.async_block_till_done() + + supervisor_client.homeassistant.update.return_value = None + supervisor_client.mounts.info.return_value.default_backup_mount = None + with ( + patch( + "homeassistant.components.backup.manager.BackupManager.async_create_backup", + side_effect=BackupManagerError, + ), + ): + await client.send_json_auto_id( + {"type": "hassio/update/addon", "addon": "test", "backup": True} + ) + result = await client.receive_json() + assert not result["success"] + assert result["error"] == { + "code": "home_assistant_error", + "message": "Error creating backup: ", + } + + +async def test_update_core_with_error( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + supervisor_client: AsyncMock, +) -> None: + """Test updating core with error.""" + client = await hass_ws_client(hass) + config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) + config_entry.add_to_hass(hass) + + with patch.dict(os.environ, MOCK_ENVIRON): + assert await async_setup_component( + hass, + "hassio", + {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + ) + await hass.async_block_till_done() + + supervisor_client.homeassistant.update.side_effect = SupervisorError + await client.send_json_auto_id({"type": "hassio/update/core", "backup": False}) + result = await client.receive_json() + assert not result["success"] + assert result["error"] == { + "code": "home_assistant_error", + "message": "Error updating Home Assistant Core: ", + } + + +async def test_update_core_with_backup_and_error( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + supervisor_client: AsyncMock, +) -> None: + """Test updating core with backup and error.""" + client = await hass_ws_client(hass) + config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) + config_entry.add_to_hass(hass) + + with patch.dict(os.environ, MOCK_ENVIRON): + result = await async_setup_component( + hass, + "hassio", + {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + ) + assert result + assert await async_setup_component(hass, "backup", {}) + await hass.async_block_till_done() + + supervisor_client.homeassistant.update.return_value = None + supervisor_client.mounts.info.return_value.default_backup_mount = None + with ( + patch( + "homeassistant.components.backup.manager.BackupManager.async_create_backup", + side_effect=BackupManagerError, + ), + ): + await client.send_json_auto_id( + {"type": "hassio/update/addon", "addon": "test", "backup": True} + ) + result = await client.receive_json() + assert not result["success"] + assert result["error"] == { + "code": "home_assistant_error", + "message": "Error creating backup: ", + } From 33a23ad1c6ae42bdb815fd3fd5af1d0cadcbb1d2 Mon Sep 17 00:00:00 2001 From: Cyrill Raccaud Date: Mon, 27 Jan 2025 08:43:30 +0100 Subject: [PATCH 0046/3148] Add diagnostic sensors for the active subscription of Cookidoo (#136485) * add diagnostics for the active subcription * fix mapping between api and ha states for subscription * multiline lambda --- homeassistant/components/cookidoo/__init__.py | 2 +- homeassistant/components/cookidoo/const.py | 6 + .../components/cookidoo/coordinator.py | 7 +- homeassistant/components/cookidoo/icons.json | 13 ++ homeassistant/components/cookidoo/sensor.py | 111 ++++++++++++++++++ .../components/cookidoo/strings.json | 13 ++ tests/components/cookidoo/conftest.py | 5 + .../cookidoo/fixtures/subscriptions.json | 12 ++ .../cookidoo/snapshots/test_sensor.ambr | 106 +++++++++++++++++ tests/components/cookidoo/test_sensor.py | 44 +++++++ 10 files changed, 317 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/cookidoo/sensor.py create mode 100644 tests/components/cookidoo/fixtures/subscriptions.json create mode 100644 tests/components/cookidoo/snapshots/test_sensor.ambr create mode 100644 tests/components/cookidoo/test_sensor.py diff --git a/homeassistant/components/cookidoo/__init__.py b/homeassistant/components/cookidoo/__init__.py index 67095422e65..bff4c8123d6 100644 --- a/homeassistant/components/cookidoo/__init__.py +++ b/homeassistant/components/cookidoo/__init__.py @@ -14,7 +14,7 @@ from .const import DOMAIN from .coordinator import CookidooConfigEntry, CookidooDataUpdateCoordinator from .helpers import cookidoo_from_config_entry -PLATFORMS: list[Platform] = [Platform.BUTTON, Platform.TODO] +PLATFORMS: list[Platform] = [Platform.BUTTON, Platform.SENSOR, Platform.TODO] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/cookidoo/const.py b/homeassistant/components/cookidoo/const.py index 37c584404a0..0381e18725d 100644 --- a/homeassistant/components/cookidoo/const.py +++ b/homeassistant/components/cookidoo/const.py @@ -1,3 +1,9 @@ """Constants for the Cookidoo integration.""" DOMAIN = "cookidoo" + +SUBSCRIPTION_MAP = { + "NONE": "free", + "TRIAL": "trial", + "REGULAR": "premium", +} diff --git a/homeassistant/components/cookidoo/coordinator.py b/homeassistant/components/cookidoo/coordinator.py index ad86d1fb9f1..f99f58c2dd6 100644 --- a/homeassistant/components/cookidoo/coordinator.py +++ b/homeassistant/components/cookidoo/coordinator.py @@ -13,6 +13,7 @@ from cookidoo_api import ( CookidooException, CookidooIngredientItem, CookidooRequestException, + CookidooSubscription, ) from homeassistant.config_entries import ConfigEntry @@ -34,6 +35,7 @@ class CookidooData: ingredient_items: list[CookidooIngredientItem] additional_items: list[CookidooAdditionalItem] + subscription: CookidooSubscription | None class CookidooDataUpdateCoordinator(DataUpdateCoordinator[CookidooData]): @@ -75,6 +77,7 @@ class CookidooDataUpdateCoordinator(DataUpdateCoordinator[CookidooData]): try: ingredient_items = await self.cookidoo.get_ingredient_items() additional_items = await self.cookidoo.get_additional_items() + subscription = await self.cookidoo.get_active_subscription() except CookidooAuthException: try: await self.cookidoo.refresh_token() @@ -97,5 +100,7 @@ class CookidooDataUpdateCoordinator(DataUpdateCoordinator[CookidooData]): ) from e return CookidooData( - ingredient_items=ingredient_items, additional_items=additional_items + ingredient_items=ingredient_items, + additional_items=additional_items, + subscription=subscription, ) diff --git a/homeassistant/components/cookidoo/icons.json b/homeassistant/components/cookidoo/icons.json index 0e411a70fc2..cf4d9dc2858 100644 --- a/homeassistant/components/cookidoo/icons.json +++ b/homeassistant/components/cookidoo/icons.json @@ -1,5 +1,18 @@ { "entity": { + "sensor": { + "subscription": { + "default": "mdi:account", + "state": { + "free": "mdi:account", + "trial": "mdi:account-question", + "regular": "mdi:account-star" + } + }, + "expiration": { + "default": "mdi:account-reactivate" + } + }, "button": { "todo_clear": { "default": "mdi:cart-off" diff --git a/homeassistant/components/cookidoo/sensor.py b/homeassistant/components/cookidoo/sensor.py new file mode 100644 index 00000000000..7fbacea18bc --- /dev/null +++ b/homeassistant/components/cookidoo/sensor.py @@ -0,0 +1,111 @@ +"""Sensor platform for the Cookidoo integration.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from datetime import datetime +from enum import StrEnum + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, +) +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType +from homeassistant.util import dt as dt_util + +from .const import SUBSCRIPTION_MAP +from .coordinator import ( + CookidooConfigEntry, + CookidooData, + CookidooDataUpdateCoordinator, +) +from .entity import CookidooBaseEntity + +PARALLEL_UPDATES = 0 + + +@dataclass(kw_only=True, frozen=True) +class CookidooSensorEntityDescription(SensorEntityDescription): + """Cookidoo Sensor Description.""" + + value_fn: Callable[[CookidooData], StateType | datetime] + + +class CookidooSensor(StrEnum): + """Cookidoo sensors.""" + + SUBSCRIPTION = "subscription" + EXPIRES = "expires" + + +SENSOR_DESCRIPTIONS: tuple[CookidooSensorEntityDescription, ...] = ( + CookidooSensorEntityDescription( + key=CookidooSensor.SUBSCRIPTION, + translation_key=CookidooSensor.SUBSCRIPTION, + value_fn=( + lambda data: SUBSCRIPTION_MAP[data.subscription.type] + if data.subscription + else SUBSCRIPTION_MAP["NONE"] + ), + entity_category=EntityCategory.DIAGNOSTIC, + options=list(SUBSCRIPTION_MAP.values()), + device_class=SensorDeviceClass.ENUM, + ), + CookidooSensorEntityDescription( + key=CookidooSensor.EXPIRES, + translation_key=CookidooSensor.EXPIRES, + value_fn=( + lambda data: dt_util.parse_datetime(data.subscription.expires) + if data.subscription + else None + ), + entity_category=EntityCategory.DIAGNOSTIC, + device_class=SensorDeviceClass.TIMESTAMP, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: CookidooConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the sensor platform.""" + coordinator = config_entry.runtime_data + + async_add_entities( + CookidooSensorEntity( + coordinator, + description, + ) + for description in SENSOR_DESCRIPTIONS + ) + + +class CookidooSensorEntity(CookidooBaseEntity, SensorEntity): + """A sensor entity.""" + + entity_description: CookidooSensorEntityDescription + + def __init__( + self, + coordinator: CookidooDataUpdateCoordinator, + entity_description: CookidooSensorEntityDescription, + ) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + self.entity_description = entity_description + self._attr_unique_id = ( + f"{coordinator.config_entry.unique_id}_{self.entity_description.key}" + ) + + @property + def native_value(self) -> StateType | datetime: + """Return the state of the sensor.""" + + return self.entity_description.value_fn(self.coordinator.data) diff --git a/homeassistant/components/cookidoo/strings.json b/homeassistant/components/cookidoo/strings.json index 8a2a288d11b..ae384fb6635 100644 --- a/homeassistant/components/cookidoo/strings.json +++ b/homeassistant/components/cookidoo/strings.json @@ -49,6 +49,19 @@ } }, "entity": { + "sensor": { + "subscription": { + "name": "Subscription", + "state": { + "free": "Free", + "trial": "Trial", + "premium": "Premium" + } + }, + "expires": { + "name": "Subscription expiration date" + } + }, "button": { "todo_clear": { "name": "Clear shopping list and additional purchases" diff --git a/tests/components/cookidoo/conftest.py b/tests/components/cookidoo/conftest.py index a14bc285379..096b2abf958 100644 --- a/tests/components/cookidoo/conftest.py +++ b/tests/components/cookidoo/conftest.py @@ -8,6 +8,7 @@ from cookidoo_api import ( CookidooAdditionalItem, CookidooAuthResponse, CookidooIngredientItem, + CookidooSubscription, ) import pytest @@ -54,6 +55,10 @@ def mock_cookidoo_client() -> Generator[AsyncMock]: "data" ] ] + client.get_active_subscription.return_value = CookidooSubscription( + **load_json_object_fixture("subscriptions.json", DOMAIN)["data"] + ) + client.login.return_value = CookidooAuthResponse( **load_json_object_fixture("login.json", DOMAIN) ) diff --git a/tests/components/cookidoo/fixtures/subscriptions.json b/tests/components/cookidoo/fixtures/subscriptions.json new file mode 100644 index 00000000000..12b74b3af08 --- /dev/null +++ b/tests/components/cookidoo/fixtures/subscriptions.json @@ -0,0 +1,12 @@ +{ + "data": { + "active": true, + "start_date": "2024-12-16T00:00:00Z", + "expires": "2025-12-16T23:59:00Z", + "type": "REGULAR", + "extended_type": "REGULAR", + "subscription_level": "FULL", + "subscription_source": "COMMERCE", + "status": "ACTIVE" + } +} diff --git a/tests/components/cookidoo/snapshots/test_sensor.ambr b/tests/components/cookidoo/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..568b0baf688 --- /dev/null +++ b/tests/components/cookidoo/snapshots/test_sensor.ambr @@ -0,0 +1,106 @@ +# serializer version: 1 +# name: test_setup[sensor.cookidoo_subscription-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'free', + 'trial', + 'premium', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.cookidoo_subscription', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Subscription', + 'platform': 'cookidoo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'sub_uuid_subscription', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[sensor.cookidoo_subscription-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Cookidoo Subscription', + 'options': list([ + 'free', + 'trial', + 'premium', + ]), + }), + 'context': , + 'entity_id': 'sensor.cookidoo_subscription', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'premium', + }) +# --- +# name: test_setup[sensor.cookidoo_subscription_expiration_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': , + 'entity_id': 'sensor.cookidoo_subscription_expiration_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': 'Subscription expiration date', + 'platform': 'cookidoo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'sub_uuid_expires', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[sensor.cookidoo_subscription_expiration_date-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Cookidoo Subscription expiration date', + }), + 'context': , + 'entity_id': 'sensor.cookidoo_subscription_expiration_date', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2025-12-16T23:59:00+00:00', + }) +# --- diff --git a/tests/components/cookidoo/test_sensor.py b/tests/components/cookidoo/test_sensor.py new file mode 100644 index 00000000000..d2ef88f2857 --- /dev/null +++ b/tests/components/cookidoo/test_sensor.py @@ -0,0 +1,44 @@ +"""Test for sensor platform of the Cookidoo integration.""" + +from collections.abc import Generator +from unittest.mock import patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.fixture(autouse=True) +def sensor_only() -> Generator[None]: + """Enable only the sensor platform.""" + with patch( + "homeassistant.components.cookidoo.PLATFORMS", + [Platform.SENSOR], + ): + yield + + +@pytest.mark.usefixtures("mock_cookidoo_client") +async def test_setup( + hass: HomeAssistant, + cookidoo_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Snapshot test states of sensor platform.""" + + cookidoo_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(cookidoo_config_entry.entry_id) + await hass.async_block_till_done() + + assert cookidoo_config_entry.state is ConfigEntryState.LOADED + + await snapshot_platform( + hass, entity_registry, snapshot, cookidoo_config_entry.entry_id + ) From 385a0786759a634e2f318f2b9080233ea28e9d34 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Mon, 27 Jan 2025 01:04:27 -0800 Subject: [PATCH 0047/3148] Bump nest to python-nest-sdm to 7.1.0 (#136611) --- 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 e14474dc309..f7e78b2d538 100644 --- a/homeassistant/components/nest/manifest.json +++ b/homeassistant/components/nest/manifest.json @@ -19,5 +19,5 @@ "documentation": "https://www.home-assistant.io/integrations/nest", "iot_class": "cloud_push", "loggers": ["google_nest_sdm"], - "requirements": ["google-nest-sdm==7.0.0"] + "requirements": ["google-nest-sdm==7.1.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index d0b18b7cab5..96f53acf13d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1030,7 +1030,7 @@ google-cloud-texttospeech==2.17.2 google-generativeai==0.8.2 # homeassistant.components.nest -google-nest-sdm==7.0.0 +google-nest-sdm==7.1.0 # homeassistant.components.google_photos google-photos-library-api==0.12.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7ce62fbe43e..5ecdd37fe58 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -880,7 +880,7 @@ google-cloud-texttospeech==2.17.2 google-generativeai==0.8.2 # homeassistant.components.nest -google-nest-sdm==7.0.0 +google-nest-sdm==7.1.0 # homeassistant.components.google_photos google-photos-library-api==0.12.1 From ffdb686363d61ac82cd2daf68ea81b18f1e763e8 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 27 Jan 2025 10:15:53 +0100 Subject: [PATCH 0048/3148] Use runtime_data in crownstone (#136406) * Use runtime_data in crownstone * Move some logic into __init__ * Remove underscore in async_update_listener --- .../components/crownstone/__init__.py | 39 +++++++++++++----- .../components/crownstone/config_flow.py | 12 +++--- .../components/crownstone/entry_manager.py | 40 +++++-------------- homeassistant/components/crownstone/light.py | 12 ++---- .../components/crownstone/test_config_flow.py | 15 +++---- 5 files changed, 55 insertions(+), 63 deletions(-) diff --git a/homeassistant/components/crownstone/__init__.py b/homeassistant/components/crownstone/__init__.py index e1443eb9516..8f5739f9172 100644 --- a/homeassistant/components/crownstone/__init__.py +++ b/homeassistant/components/crownstone/__init__.py @@ -2,25 +2,42 @@ from __future__ import annotations -from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant -from .const import DOMAIN -from .entry_manager import CrownstoneEntryManager +from .const import PLATFORMS +from .entry_manager import CrownstoneConfigEntry, CrownstoneEntryManager -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: CrownstoneConfigEntry) -> bool: """Initiate setup for a Crownstone config entry.""" manager = CrownstoneEntryManager(hass, entry) - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = manager + if not await manager.async_setup(): + return False - return await manager.async_setup() + entry.runtime_data = manager + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + # HA specific listeners + entry.async_on_unload(entry.add_update_listener(async_update_listener)) + entry.async_on_unload( + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, manager.on_shutdown) + ) + + return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: CrownstoneConfigEntry) -> bool: """Unload a config entry.""" - unload_ok: bool = await hass.data[DOMAIN][entry.entry_id].async_unload() - if len(hass.data[DOMAIN]) == 0: - hass.data.pop(DOMAIN) - return unload_ok + entry.runtime_data.async_unload() + + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +async def async_update_listener( + hass: HomeAssistant, entry: CrownstoneConfigEntry +) -> None: + """Handle options update.""" + await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/crownstone/config_flow.py b/homeassistant/components/crownstone/config_flow.py index 2a96098421a..5f5af4f51a4 100644 --- a/homeassistant/components/crownstone/config_flow.py +++ b/homeassistant/components/crownstone/config_flow.py @@ -16,7 +16,6 @@ import voluptuous as vol from homeassistant.components import usb from homeassistant.config_entries import ( - ConfigEntry, ConfigEntryBaseFlow, ConfigFlow, ConfigFlowResult, @@ -37,6 +36,7 @@ from .const import ( MANUAL_PATH, REFRESH_LIST, ) +from .entry_manager import CrownstoneConfigEntry from .helpers import list_ports_as_str CONFIG_FLOW = "config_flow" @@ -140,7 +140,7 @@ class CrownstoneConfigFlowHandler(BaseCrownstoneFlowHandler, ConfigFlow, domain= @staticmethod @callback def async_get_options_flow( - config_entry: ConfigEntry, + config_entry: CrownstoneConfigEntry, ) -> CrownstoneOptionsFlowHandler: """Return the Crownstone options.""" return CrownstoneOptionsFlowHandler(config_entry) @@ -210,7 +210,9 @@ class CrownstoneConfigFlowHandler(BaseCrownstoneFlowHandler, ConfigFlow, domain= class CrownstoneOptionsFlowHandler(BaseCrownstoneFlowHandler, OptionsFlow): """Handle Crownstone options.""" - def __init__(self, config_entry: ConfigEntry) -> None: + config_entry: CrownstoneConfigEntry + + def __init__(self, config_entry: CrownstoneConfigEntry) -> None: """Initialize Crownstone options.""" super().__init__(OPTIONS_FLOW, self.async_create_new_entry) self.options = config_entry.options.copy() @@ -219,9 +221,7 @@ class CrownstoneOptionsFlowHandler(BaseCrownstoneFlowHandler, OptionsFlow): self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Manage Crownstone options.""" - self.cloud: CrownstoneCloud = self.hass.data[DOMAIN][ - self.config_entry.entry_id - ].cloud + self.cloud = self.config_entry.runtime_data.cloud spheres = {sphere.name: sphere.cloud_id for sphere in self.cloud.cloud_data} usb_path = self.config_entry.options.get(CONF_USB_PATH) diff --git a/homeassistant/components/crownstone/entry_manager.py b/homeassistant/components/crownstone/entry_manager.py index efee05a19c8..e414e3c7055 100644 --- a/homeassistant/components/crownstone/entry_manager.py +++ b/homeassistant/components/crownstone/entry_manager.py @@ -16,7 +16,7 @@ from crownstone_uart.Exceptions import UartException from homeassistant.components import persistent_notification from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, EVENT_HOMEASSISTANT_STOP +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import aiohttp_client @@ -26,7 +26,6 @@ from .const import ( CONF_USB_PATH, CONF_USB_SPHERE, DOMAIN, - PLATFORMS, PROJECT_NAME, SSE_LISTENERS, UART_LISTENERS, @@ -36,6 +35,8 @@ from .listeners import setup_sse_listeners, setup_uart_listeners _LOGGER = logging.getLogger(__name__) +type CrownstoneConfigEntry = ConfigEntry[CrownstoneEntryManager] + class CrownstoneEntryManager: """Manage a Crownstone config entry.""" @@ -44,7 +45,9 @@ class CrownstoneEntryManager: cloud: CrownstoneCloud sse: CrownstoneSSEAsync - def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: + def __init__( + self, hass: HomeAssistant, config_entry: CrownstoneConfigEntry + ) -> None: """Initialize the hub.""" self.hass = hass self.config_entry = config_entry @@ -100,18 +103,6 @@ class CrownstoneEntryManager: # Makes HA aware of the Crownstone environment HA is placed in, a user can have multiple self.usb_sphere_id = self.config_entry.options[CONF_USB_SPHERE] - await self.hass.config_entries.async_forward_entry_setups( - self.config_entry, PLATFORMS - ) - - # HA specific listeners - self.config_entry.async_on_unload( - self.config_entry.add_update_listener(_async_update_listener) - ) - self.config_entry.async_on_unload( - self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.on_shutdown) - ) - return True async def async_process_events(self, sse_client: CrownstoneSSEAsync) -> None: @@ -161,11 +152,12 @@ class CrownstoneEntryManager: setup_uart_listeners(self) - async def async_unload(self) -> bool: + @callback + def async_unload(self) -> None: """Unload the current config entry.""" # Authentication failed if self.cloud.cloud_data is None: - return True + return self.sse.close_client() for sse_unsub in self.listeners[SSE_LISTENERS]: @@ -176,23 +168,9 @@ class CrownstoneEntryManager: for subscription_id in self.listeners[UART_LISTENERS]: UartEventBus.unsubscribe(subscription_id) - unload_ok = await self.hass.config_entries.async_unload_platforms( - self.config_entry, PLATFORMS - ) - - if unload_ok: - self.hass.data[DOMAIN].pop(self.config_entry.entry_id) - - return unload_ok - @callback def on_shutdown(self, _: Event) -> None: """Close all IO connections.""" self.sse.close_client() if self.uart: self.uart.stop() - - -async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Handle options update.""" - await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/crownstone/light.py b/homeassistant/components/crownstone/light.py index 16faa3a36d2..70b7631fe6b 100644 --- a/homeassistant/components/crownstone/light.py +++ b/homeassistant/components/crownstone/light.py @@ -3,7 +3,7 @@ from __future__ import annotations from functools import partial -from typing import TYPE_CHECKING, Any +from typing import Any from crownstone_cloud.cloud_models.crownstones import Crownstone from crownstone_cloud.const import DIMMING_ABILITY @@ -11,7 +11,6 @@ from crownstone_cloud.exceptions import CrownstoneAbilityError from crownstone_uart import CrownstoneUart from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -20,24 +19,21 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( CROWNSTONE_INCLUDE_TYPES, CROWNSTONE_SUFFIX, - DOMAIN, SIG_CROWNSTONE_STATE_UPDATE, SIG_UART_STATE_CHANGE, ) from .entity import CrownstoneEntity +from .entry_manager import CrownstoneConfigEntry from .helpers import map_from_to -if TYPE_CHECKING: - from .entry_manager import CrownstoneEntryManager - async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: CrownstoneConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up crownstones from a config entry.""" - manager: CrownstoneEntryManager = hass.data[DOMAIN][config_entry.entry_id] + manager = config_entry.runtime_data entities: list[CrownstoneLightEntity] = [] diff --git a/tests/components/crownstone/test_config_flow.py b/tests/components/crownstone/test_config_flow.py index a38a04cb2ad..c3bb17cb6d6 100644 --- a/tests/components/crownstone/test_config_flow.py +++ b/tests/components/crownstone/test_config_flow.py @@ -163,7 +163,7 @@ async def start_config_flow(hass: HomeAssistant, mocked_cloud: MagicMock): async def start_options_flow( - hass: HomeAssistant, entry_id: str, mocked_manager: MagicMock + hass: HomeAssistant, entry: MockConfigEntry, mocked_manager: MagicMock ): """Patch CrownstoneEntryManager and start the flow.""" # set up integration @@ -171,9 +171,10 @@ async def start_options_flow( "homeassistant.components.crownstone.CrownstoneEntryManager", return_value=mocked_manager, ): - await hass.config_entries.async_setup(entry_id) + await hass.config_entries.async_setup(entry.entry_id) - return await hass.config_entries.options.async_init(entry_id) + entry.runtime_data = mocked_manager + return await hass.config_entries.options.async_init(entry.entry_id) async def test_no_user_input( @@ -413,7 +414,7 @@ async def test_options_flow_setup_usb( result = await start_options_flow( hass, - entry.entry_id, + entry, get_mocked_crownstone_entry_manager( get_mocked_crownstone_cloud(create_mocked_spheres(2)) ), @@ -490,7 +491,7 @@ async def test_options_flow_remove_usb(hass: HomeAssistant) -> None: result = await start_options_flow( hass, - entry.entry_id, + entry, get_mocked_crownstone_entry_manager( get_mocked_crownstone_cloud(create_mocked_spheres(2)) ), @@ -543,7 +544,7 @@ async def test_options_flow_manual_usb_path( result = await start_options_flow( hass, - entry.entry_id, + entry, get_mocked_crownstone_entry_manager( get_mocked_crownstone_cloud(create_mocked_spheres(1)) ), @@ -602,7 +603,7 @@ async def test_options_flow_change_usb_sphere(hass: HomeAssistant) -> None: result = await start_options_flow( hass, - entry.entry_id, + entry, get_mocked_crownstone_entry_manager( get_mocked_crownstone_cloud(create_mocked_spheres(3)) ), From 1e0165c5f708bff5d9072d65ce51994f8c523ad1 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 27 Jan 2025 10:16:13 +0100 Subject: [PATCH 0049/3148] Add lovelace compatiblity code (#136617) * Add lovelace compatiblity code * Docstring * Add tests --- homeassistant/components/lovelace/__init__.py | 27 ++++++++++++ tests/components/lovelace/test_init.py | 41 +++++++++++++++++++ 2 files changed, 68 insertions(+) diff --git a/homeassistant/components/lovelace/__init__.py b/homeassistant/components/lovelace/__init__.py index 9b1c86edb36..51d2ed3eab7 100644 --- a/homeassistant/components/lovelace/__init__.py +++ b/homeassistant/components/lovelace/__init__.py @@ -15,6 +15,7 @@ from homeassistant.const import CONF_FILENAME, CONF_MODE, CONF_RESOURCES from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import collection, config_validation as cv +from homeassistant.helpers.frame import report_usage from homeassistant.helpers.service import async_register_admin_service from homeassistant.helpers.typing import ConfigType from homeassistant.loader import async_get_integration @@ -99,6 +100,32 @@ class LovelaceData: resources: resources.ResourceYAMLCollection | resources.ResourceStorageCollection yaml_dashboards: dict[str | None, ConfigType] + def __getitem__(self, name: str) -> Any: + """Enable method for compatibility reason. + + Following migration from an untyped dict to a dataclass in + https://github.com/home-assistant/core/pull/136313 + """ + report_usage( + f"accessed lovelace_data['{name}'] instead of lovelace_data.{name}", + breaks_in_ha_version="2026.2", + ) + return getattr(self, name) + + def get(self, name: str, default: Any = None) -> Any: + """Enable method for compatibility reason. + + Following migration from an untyped dict to a dataclass in + https://github.com/home-assistant/core/pull/136313 + """ + report_usage( + f"accessed lovelace_data.get('{name}') instead of lovelace_data.{name}", + breaks_in_ha_version="2026.2", + ) + if hasattr(self, name): + return getattr(self, name) + return default + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Lovelace commands.""" diff --git a/tests/components/lovelace/test_init.py b/tests/components/lovelace/test_init.py index f56ff4371e6..6f11c22466e 100644 --- a/tests/components/lovelace/test_init.py +++ b/tests/components/lovelace/test_init.py @@ -38,3 +38,44 @@ async def test_create_dashboards_when_onboarded( response = await client.receive_json() assert response["success"] assert response["result"] == [] + + +async def test_hass_data_compatibility( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test compatibility for external access. + + See: + https://github.com/hacs/integration/blob/4a820e8b1b066bc54a1c9c61102038af6c030603 + /custom_components/hacs/repositories/plugin.py#L173 + """ + expected = ( + "Detected that integration 'lovelace' accessed lovelace_data.get('resources')" + " instead of lovelace_data.resources at" + ) + + assert await async_setup_component(hass, "lovelace", {}) + + assert (lovelace_data := hass.data.get("lovelace")) is not None + assert expected not in caplog.text + + # Direct access to resources is fine + assert lovelace_data.resources is not None + assert ( + "Detected that integration 'lovelace' accessed lovelace_data" not in caplog.text + ) + + # Dict compatibility logs warning + assert lovelace_data["resources"] is not None + assert ( + "Detected that integration 'lovelace' accessed lovelace_data['resources']" + in caplog.text + ) + + # Dict get compatibility logs warning + assert lovelace_data.get("resources") is not None + assert ( + "Detected that integration 'lovelace' accessed lovelace_data.get('resources')" + in caplog.text + ) From acb9d687064034d679f99f4c18c8039cfa122132 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 27 Jan 2025 10:16:33 +0100 Subject: [PATCH 0050/3148] Use runtime_data in dynalite (#136448) * Use runtime_data in dynalite * Delay listener --- homeassistant/components/dynalite/__init__.py | 23 ++++++++----------- homeassistant/components/dynalite/bridge.py | 3 +++ homeassistant/components/dynalite/cover.py | 5 ++-- homeassistant/components/dynalite/entity.py | 7 +++--- homeassistant/components/dynalite/light.py | 4 ++-- homeassistant/components/dynalite/services.py | 6 ++--- homeassistant/components/dynalite/switch.py | 4 ++-- 7 files changed, 24 insertions(+), 28 deletions(-) diff --git a/homeassistant/components/dynalite/__init__.py b/homeassistant/components/dynalite/__init__.py index a1a6a38c8ab..3411882b725 100644 --- a/homeassistant/components/dynalite/__init__.py +++ b/homeassistant/components/dynalite/__init__.py @@ -16,11 +16,11 @@ from .services import setup_services CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) +type DynaliteConfigEntry = ConfigEntry[DynaliteBridge] + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Dynalite platform.""" - hass.data[DOMAIN] = {} - setup_services(hass) await async_register_dynalite_frontend(hass) @@ -28,35 +28,30 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -async def async_entry_changed(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def async_entry_changed(hass: HomeAssistant, entry: DynaliteConfigEntry) -> None: """Reload entry since the data has changed.""" LOGGER.debug("Reconfiguring entry %s", entry.data) - bridge = hass.data[DOMAIN][entry.entry_id] + bridge = entry.runtime_data bridge.reload_config(entry.data) LOGGER.debug("Reconfiguring entry finished %s", entry.data) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: DynaliteConfigEntry) -> bool: """Set up a bridge from a config entry.""" LOGGER.debug("Setting up entry %s", entry.data) bridge = DynaliteBridge(hass, convert_config(entry.data)) - # need to do it before the listener - hass.data[DOMAIN][entry.entry_id] = bridge - entry.async_on_unload(entry.add_update_listener(async_entry_changed)) if not await bridge.async_setup(): LOGGER.error("Could not set up bridge for entry %s", entry.data) - hass.data[DOMAIN][entry.entry_id] = None raise ConfigEntryNotReady + entry.runtime_data = bridge + entry.async_on_unload(entry.add_update_listener(async_entry_changed)) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: DynaliteConfigEntry) -> bool: """Unload a config entry.""" LOGGER.debug("Unloading entry %s", entry.data) - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/dynalite/bridge.py b/homeassistant/components/dynalite/bridge.py index 6f090371eee..0e491281619 100644 --- a/homeassistant/components/dynalite/bridge.py +++ b/homeassistant/components/dynalite/bridge.py @@ -16,6 +16,7 @@ from dynalite_devices_lib.dynalite_devices import ( DynaliteNotification, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_send @@ -23,6 +24,8 @@ from homeassistant.helpers.dispatcher import async_dispatcher_send from .const import ATTR_AREA, ATTR_HOST, ATTR_PACKET, ATTR_PRESET, LOGGER, PLATFORMS from .convert_config import convert_config +type DynaliteConfigEntry = ConfigEntry[DynaliteBridge] + class DynaliteBridge: """Manages a single Dynalite bridge.""" diff --git a/homeassistant/components/dynalite/cover.py b/homeassistant/components/dynalite/cover.py index d7f366d919c..17adf1947ec 100644 --- a/homeassistant/components/dynalite/cover.py +++ b/homeassistant/components/dynalite/cover.py @@ -7,18 +7,17 @@ from homeassistant.components.cover import ( CoverDeviceClass, CoverEntity, ) -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 .bridge import DynaliteBridge +from .bridge import DynaliteBridge, DynaliteConfigEntry from .entity import DynaliteBase, async_setup_entry_base async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: DynaliteConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Record the async_add_entities function to add them later when received from Dynalite.""" diff --git a/homeassistant/components/dynalite/entity.py b/homeassistant/components/dynalite/entity.py index 62667dc19c3..7957e9c8515 100644 --- a/homeassistant/components/dynalite/entity.py +++ b/homeassistant/components/dynalite/entity.py @@ -6,27 +6,26 @@ from abc import ABC, abstractmethod from collections.abc import Callable from typing import Any -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity -from .bridge import DynaliteBridge +from .bridge import DynaliteBridge, DynaliteConfigEntry from .const import DOMAIN, LOGGER def async_setup_entry_base( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: DynaliteConfigEntry, async_add_entities: AddEntitiesCallback, platform: str, entity_from_device: Callable, ) -> None: """Record the async_add_entities function to add them later when received from Dynalite.""" LOGGER.debug("Setting up %s entry = %s", platform, config_entry.data) - bridge = hass.data[DOMAIN][config_entry.entry_id] + bridge = config_entry.runtime_data @callback def async_add_entities_platform(devices): diff --git a/homeassistant/components/dynalite/light.py b/homeassistant/components/dynalite/light.py index e0dd8b147aa..ea2bc2bc96f 100644 --- a/homeassistant/components/dynalite/light.py +++ b/homeassistant/components/dynalite/light.py @@ -3,16 +3,16 @@ from typing import Any from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from .bridge import DynaliteConfigEntry from .entity import DynaliteBase, async_setup_entry_base async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: DynaliteConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Record the async_add_entities function to add them later when received from Dynalite.""" diff --git a/homeassistant/components/dynalite/services.py b/homeassistant/components/dynalite/services.py index 14160cced9d..d0d57a582b4 100644 --- a/homeassistant/components/dynalite/services.py +++ b/homeassistant/components/dynalite/services.py @@ -23,9 +23,9 @@ from .const import ( def _get_bridges(service_call: ServiceCall) -> list[DynaliteBridge]: host = service_call.data.get(ATTR_HOST, "") bridges = [ - bridge - for bridge in service_call.hass.data[DOMAIN].values() - if not host or bridge.host == host + entry.runtime_data + for entry in service_call.hass.config_entries.async_loaded_entries(DOMAIN) + if not host or entry.runtime_data.host == host ] LOGGER.debug("Selected bridges for service call: %s", bridges) return bridges diff --git a/homeassistant/components/dynalite/switch.py b/homeassistant/components/dynalite/switch.py index d24a098056a..dd6aad8670c 100644 --- a/homeassistant/components/dynalite/switch.py +++ b/homeassistant/components/dynalite/switch.py @@ -3,17 +3,17 @@ from typing import Any from homeassistant.components.switch import SwitchEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_ON from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from .bridge import DynaliteConfigEntry from .entity import DynaliteBase, async_setup_entry_base async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: DynaliteConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Record the async_add_entities function to add them later when received from Dynalite.""" From 439a393816daef146262dd1ae37342d5eb10b1b2 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 27 Jan 2025 10:16:57 +0100 Subject: [PATCH 0051/3148] Use runtime_data in deconz (#136412) * Use runtime_data in deconz * Adjust master logic * Simplify * Move DeconzConfigEntry definition * More TYPE_CHECKING * Apply suggestions from code review --- homeassistant/components/deconz/__init__.py | 47 ++++++++++++------- .../components/deconz/alarm_control_panel.py | 6 +-- .../components/deconz/binary_sensor.py | 6 +-- homeassistant/components/deconz/button.py | 6 +-- homeassistant/components/deconz/climate.py | 6 +-- homeassistant/components/deconz/cover.py | 6 +-- .../components/deconz/device_trigger.py | 9 ++-- .../components/deconz/diagnostics.py | 7 ++- homeassistant/components/deconz/fan.py | 6 +-- homeassistant/components/deconz/hub/api.py | 7 ++- homeassistant/components/deconz/hub/config.py | 10 ++-- homeassistant/components/deconz/hub/hub.py | 19 ++------ homeassistant/components/deconz/light.py | 6 +-- homeassistant/components/deconz/lock.py | 7 ++- homeassistant/components/deconz/number.py | 6 +-- homeassistant/components/deconz/scene.py | 7 ++- homeassistant/components/deconz/select.py | 7 ++- homeassistant/components/deconz/sensor.py | 6 +-- homeassistant/components/deconz/services.py | 10 +++- homeassistant/components/deconz/siren.py | 7 ++- homeassistant/components/deconz/switch.py | 7 ++- homeassistant/components/deconz/util.py | 10 +++- 22 files changed, 113 insertions(+), 95 deletions(-) diff --git a/homeassistant/components/deconz/__init__.py b/homeassistant/components/deconz/__init__.py index 42c81e69740..7de091c1292 100644 --- a/homeassistant/components/deconz/__init__.py +++ b/homeassistant/components/deconz/__init__.py @@ -18,6 +18,8 @@ from .util import get_master_hub CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) +type DeconzConfigEntry = ConfigEntry[DeconzHub] + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up services.""" @@ -25,14 +27,14 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_setup_entry( + hass: HomeAssistant, config_entry: DeconzConfigEntry +) -> bool: """Set up a deCONZ bridge for a config entry. Load config, group, light and sensor data for server information. Start websocket for push notification of state changes from deCONZ. """ - hass.data.setdefault(DOMAIN, {}) - if not config_entry.options: await async_update_master_hub(hass, config_entry) @@ -43,7 +45,8 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b except AuthenticationRequired as err: raise ConfigEntryAuthFailed from err - hub = hass.data[DOMAIN][config_entry.entry_id] = DeconzHub(hass, config_entry, api) + hub = DeconzHub(hass, config_entry, api) + config_entry.runtime_data = hub await hub.async_update_device_registry() config_entry.async_on_unload( @@ -62,32 +65,44 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b return True -async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, config_entry: DeconzConfigEntry +) -> bool: """Unload deCONZ config entry.""" - hub: DeconzHub = hass.data[DOMAIN].pop(config_entry.entry_id) + hub = config_entry.runtime_data async_unload_events(hub) - if hass.data[DOMAIN] and hub.master: - await async_update_master_hub(hass, config_entry) - new_master_hub = next(iter(hass.data[DOMAIN].values())) - await async_update_master_hub(hass, new_master_hub.config_entry) + other_loaded_entries: list[DeconzConfigEntry] = [ + e + for e in hass.config_entries.async_loaded_entries(DOMAIN) + # exclude the config entry being unloaded + if e.entry_id != config_entry.entry_id + ] + if other_loaded_entries and hub.master: + await async_update_master_hub(hass, config_entry, master=False) + new_master_hub = next(iter(other_loaded_entries)).runtime_data + await async_update_master_hub(hass, new_master_hub.config_entry, master=True) return await hub.async_reset() async def async_update_master_hub( - hass: HomeAssistant, config_entry: ConfigEntry + hass: HomeAssistant, + config_entry: DeconzConfigEntry, + *, + master: bool | None = None, ) -> None: """Update master hub boolean. Called by setup_entry and unload_entry. Makes sure there is always one master available. """ - try: - master_hub = get_master_hub(hass) - master = master_hub.config_entry == config_entry - except ValueError: - master = True + if master is None: + try: + master_hub = get_master_hub(hass) + master = master_hub.config_entry == config_entry + except ValueError: + master = True options = {**config_entry.options, CONF_MASTER_GATEWAY: master} diff --git a/homeassistant/components/deconz/alarm_control_panel.py b/homeassistant/components/deconz/alarm_control_panel.py index 678e441a7a9..94f4cd1ddd6 100644 --- a/homeassistant/components/deconz/alarm_control_panel.py +++ b/homeassistant/components/deconz/alarm_control_panel.py @@ -16,10 +16,10 @@ from homeassistant.components.alarm_control_panel import ( AlarmControlPanelState, CodeFormat, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback +from . import DeconzConfigEntry from .entity import DeconzDevice from .hub import DeconzHub @@ -47,11 +47,11 @@ def get_alarm_system_id_for_unique_id(hub: DeconzHub, unique_id: str) -> str | N async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: DeconzConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the deCONZ alarm control panel devices.""" - hub = DeconzHub.get_hub(hass, config_entry) + hub = config_entry.runtime_data hub.entities[ALARM_CONTROl_PANEL_DOMAIN] = set() @callback diff --git a/homeassistant/components/deconz/binary_sensor.py b/homeassistant/components/deconz/binary_sensor.py index a5496d3bc10..e3b0fc2f2c0 100644 --- a/homeassistant/components/deconz/binary_sensor.py +++ b/homeassistant/components/deconz/binary_sensor.py @@ -23,11 +23,11 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback +from . import DeconzConfigEntry from .const import ATTR_DARK, ATTR_ON from .entity import DeconzDevice from .hub import DeconzHub @@ -160,11 +160,11 @@ ENTITY_DESCRIPTIONS: tuple[DeconzBinarySensorDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: DeconzConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the deCONZ binary sensor.""" - hub = DeconzHub.get_hub(hass, config_entry) + hub = config_entry.runtime_data hub.entities[BINARY_SENSOR_DOMAIN] = set() @callback diff --git a/homeassistant/components/deconz/button.py b/homeassistant/components/deconz/button.py index ecf28b5e22c..9fea1d02ab8 100644 --- a/homeassistant/components/deconz/button.py +++ b/homeassistant/components/deconz/button.py @@ -14,11 +14,11 @@ from homeassistant.components.button import ( ButtonEntity, ButtonEntityDescription, ) -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 . import DeconzConfigEntry from .entity import DeconzDevice, DeconzSceneMixin from .hub import DeconzHub @@ -46,11 +46,11 @@ ENTITY_DESCRIPTIONS = { async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: DeconzConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the deCONZ button entity.""" - hub = DeconzHub.get_hub(hass, config_entry) + hub = config_entry.runtime_data hub.entities[BUTTON_DOMAIN] = set() @callback diff --git a/homeassistant/components/deconz/climate.py b/homeassistant/components/deconz/climate.py index 690f943379d..aa274e6c0c1 100644 --- a/homeassistant/components/deconz/climate.py +++ b/homeassistant/components/deconz/climate.py @@ -28,11 +28,11 @@ from homeassistant.components.climate import ( HVACAction, HVACMode, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback +from . import DeconzConfigEntry from .const import ATTR_LOCKED, ATTR_OFFSET, ATTR_VALVE from .entity import DeconzDevice from .hub import DeconzHub @@ -76,11 +76,11 @@ DECONZ_TO_PRESET_MODE = {value: key for key, value in PRESET_MODE_TO_DECONZ.item async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: DeconzConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the deCONZ climate devices.""" - hub = DeconzHub.get_hub(hass, config_entry) + hub = config_entry.runtime_data hub.entities[CLIMATE_DOMAIN] = set() @callback diff --git a/homeassistant/components/deconz/cover.py b/homeassistant/components/deconz/cover.py index 030c4b12709..6dee00248ff 100644 --- a/homeassistant/components/deconz/cover.py +++ b/homeassistant/components/deconz/cover.py @@ -17,10 +17,10 @@ from homeassistant.components.cover import ( CoverEntity, CoverEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback +from . import DeconzConfigEntry from .entity import DeconzDevice from .hub import DeconzHub @@ -33,11 +33,11 @@ DECONZ_TYPE_TO_DEVICE_CLASS = { async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: DeconzConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up covers for deCONZ component.""" - hub = DeconzHub.get_hub(hass, config_entry) + hub = config_entry.runtime_data hub.entities[COVER_DOMAIN] = set() @callback diff --git a/homeassistant/components/deconz/device_trigger.py b/homeassistant/components/deconz/device_trigger.py index 2aeeece3ac5..158ac391b9b 100644 --- a/homeassistant/components/deconz/device_trigger.py +++ b/homeassistant/components/deconz/device_trigger.py @@ -22,7 +22,7 @@ from homeassistant.helpers import device_registry as dr from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType -from . import DOMAIN +from . import DOMAIN, DeconzConfigEntry from .deconz_event import ( CONF_DECONZ_EVENT, CONF_GESTURE, @@ -31,7 +31,6 @@ from .deconz_event import ( DeconzPresenceEvent, DeconzRelativeRotaryEvent, ) -from .hub import DeconzHub CONF_SUBTYPE = "subtype" @@ -684,9 +683,9 @@ def _get_deconz_event_from_device( device: dr.DeviceEntry, ) -> DeconzAlarmEvent | DeconzEvent | DeconzPresenceEvent | DeconzRelativeRotaryEvent: """Resolve deconz event from device.""" - hubs: dict[str, DeconzHub] = hass.data.get(DOMAIN, {}) - for hub in hubs.values(): - for deconz_event in hub.events: + entry: DeconzConfigEntry + for entry in hass.config_entries.async_loaded_entries(DOMAIN): + for deconz_event in entry.runtime_data.events: if device.id == deconz_event.device_id: return deconz_event diff --git a/homeassistant/components/deconz/diagnostics.py b/homeassistant/components/deconz/diagnostics.py index fcd5dec120f..284b538d1dd 100644 --- a/homeassistant/components/deconz/diagnostics.py +++ b/homeassistant/components/deconz/diagnostics.py @@ -5,21 +5,20 @@ from __future__ import annotations from typing import Any from homeassistant.components.diagnostics import async_redact_data -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_UNIQUE_ID from homeassistant.core import HomeAssistant -from .hub import DeconzHub +from . import DeconzConfigEntry REDACT_CONFIG = {CONF_API_KEY, CONF_UNIQUE_ID} REDACT_DECONZ_CONFIG = {"bridgeid", "mac", "panid"} async def async_get_config_entry_diagnostics( - hass: HomeAssistant, config_entry: ConfigEntry + hass: HomeAssistant, config_entry: DeconzConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - hub = DeconzHub.get_hub(hass, config_entry) + hub = config_entry.runtime_data diag: dict[str, Any] = {} diag["config"] = async_redact_data(config_entry.as_dict(), REDACT_CONFIG) diff --git a/homeassistant/components/deconz/fan.py b/homeassistant/components/deconz/fan.py index 26e4d3328b8..aec078f771f 100644 --- a/homeassistant/components/deconz/fan.py +++ b/homeassistant/components/deconz/fan.py @@ -12,7 +12,6 @@ from homeassistant.components.fan import ( FanEntity, FanEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.percentage import ( @@ -20,6 +19,7 @@ from homeassistant.util.percentage import ( percentage_to_ordered_list_item, ) +from . import DeconzConfigEntry from .entity import DeconzDevice from .hub import DeconzHub @@ -33,11 +33,11 @@ ORDERED_NAMED_FAN_SPEEDS: list[LightFanSpeed] = [ async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: DeconzConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up fans for deCONZ component.""" - hub = DeconzHub.get_hub(hass, config_entry) + hub = config_entry.runtime_data hub.entities[FAN_DOMAIN] = set() @callback diff --git a/homeassistant/components/deconz/hub/api.py b/homeassistant/components/deconz/hub/api.py index 916c34672d8..c00a2178eb0 100644 --- a/homeassistant/components/deconz/hub/api.py +++ b/homeassistant/components/deconz/hub/api.py @@ -3,10 +3,10 @@ from __future__ import annotations import asyncio +from typing import TYPE_CHECKING from pydeconz import DeconzSession, errors -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import aiohttp_client @@ -14,9 +14,12 @@ from ..const import LOGGER from ..errors import AuthenticationRequired, CannotConnect from .config import DeconzConfig +if TYPE_CHECKING: + from .. import DeconzConfigEntry + async def get_deconz_api( - hass: HomeAssistant, config_entry: ConfigEntry + hass: HomeAssistant, config_entry: DeconzConfigEntry ) -> DeconzSession: """Create a gateway object and verify configuration.""" session = aiohttp_client.async_get_clientsession(hass) diff --git a/homeassistant/components/deconz/hub/config.py b/homeassistant/components/deconz/hub/config.py index 06d2dc10542..5acbe816833 100644 --- a/homeassistant/components/deconz/hub/config.py +++ b/homeassistant/components/deconz/hub/config.py @@ -3,9 +3,8 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Self +from typing import TYPE_CHECKING, Self -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_PORT from ..const import ( @@ -17,12 +16,15 @@ from ..const import ( DEFAULT_ALLOW_NEW_DEVICES, ) +if TYPE_CHECKING: + from .. import DeconzConfigEntry + @dataclass class DeconzConfig: """Represent a deCONZ config entry.""" - entry: ConfigEntry + entry: DeconzConfigEntry host: str port: int @@ -33,7 +35,7 @@ class DeconzConfig: allow_new_devices: bool @classmethod - def from_config_entry(cls, config_entry: ConfigEntry) -> Self: + def from_config_entry(cls, config_entry: DeconzConfigEntry) -> Self: """Create object from config entry.""" config = config_entry.data options = config_entry.options diff --git a/homeassistant/components/deconz/hub/hub.py b/homeassistant/components/deconz/hub/hub.py index ff958bbda50..3020d624f97 100644 --- a/homeassistant/components/deconz/hub/hub.py +++ b/homeassistant/components/deconz/hub/hub.py @@ -11,7 +11,7 @@ from pydeconz.interfaces.api_handlers import APIHandler, GroupedAPIHandler from pydeconz.interfaces.groups import GroupHandler from pydeconz.models.event import EventType -from homeassistant.config_entries import SOURCE_HASSIO, ConfigEntry +from homeassistant.config_entries import SOURCE_HASSIO from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC @@ -26,6 +26,7 @@ from ..const import ( from .config import DeconzConfig if TYPE_CHECKING: + from .. import DeconzConfigEntry from ..deconz_event import ( DeconzAlarmEvent, DeconzEvent, @@ -67,7 +68,7 @@ class DeconzHub: """Manages a single deCONZ gateway.""" def __init__( - self, hass: HomeAssistant, config_entry: ConfigEntry, api: DeconzSession + self, hass: HomeAssistant, config_entry: DeconzConfigEntry, api: DeconzSession ) -> None: """Initialize the system.""" self.hass = hass @@ -94,12 +95,6 @@ class DeconzHub: self.deconz_groups: set[tuple[Callable[[EventType, str], None], str]] = set() self.ignored_devices: set[tuple[Callable[[EventType, str], None], str]] = set() - @callback - @staticmethod - def get_hub(hass: HomeAssistant, config_entry: ConfigEntry) -> DeconzHub: - """Return hub with a matching config entry ID.""" - return cast(DeconzHub, hass.data[DECONZ_DOMAIN][config_entry.entry_id]) - @property def bridgeid(self) -> str: """Return the unique identifier of the gateway.""" @@ -208,7 +203,7 @@ class DeconzHub: @staticmethod async def async_config_entry_updated( - hass: HomeAssistant, config_entry: ConfigEntry + hass: HomeAssistant, config_entry: DeconzConfigEntry ) -> None: """Handle signals of config entry being updated. @@ -217,11 +212,7 @@ class DeconzHub: Causes for this is either discovery updating host address or config entry options changing. """ - if config_entry.entry_id not in hass.data[DECONZ_DOMAIN]: - # A race condition can occur if multiple config entries are - # unloaded in parallel - return - hub = DeconzHub.get_hub(hass, config_entry) + hub = config_entry.runtime_data previous_config = hub.config hub.config = DeconzConfig.from_config_entry(config_entry) if previous_config.host != hub.config.host: diff --git a/homeassistant/components/deconz/light.py b/homeassistant/components/deconz/light.py index d82c05f14eb..72ba7035c8e 100644 --- a/homeassistant/components/deconz/light.py +++ b/homeassistant/components/deconz/light.py @@ -28,7 +28,6 @@ from homeassistant.components.light import ( LightEntity, LightEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -38,6 +37,7 @@ from homeassistant.util.color import ( color_temperature_mired_to_kelvin, ) +from . import DeconzConfigEntry from .const import DOMAIN as DECONZ_DOMAIN, POWER_PLUGS from .entity import DeconzDevice from .hub import DeconzHub @@ -141,11 +141,11 @@ def update_color_state( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: DeconzConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the deCONZ lights and groups from a config entry.""" - hub = DeconzHub.get_hub(hass, config_entry) + hub = config_entry.runtime_data hub.entities[LIGHT_DOMAIN] = set() @callback diff --git a/homeassistant/components/deconz/lock.py b/homeassistant/components/deconz/lock.py index 50375e99778..e5e2faf1d57 100644 --- a/homeassistant/components/deconz/lock.py +++ b/homeassistant/components/deconz/lock.py @@ -9,21 +9,20 @@ from pydeconz.models.light.lock import Lock from pydeconz.models.sensor.door_lock import DoorLock from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN, LockEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback +from . import DeconzConfigEntry from .entity import DeconzDevice -from .hub import DeconzHub async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: DeconzConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up locks for deCONZ component.""" - hub = DeconzHub.get_hub(hass, config_entry) + hub = config_entry.runtime_data hub.entities[LOCK_DOMAIN] = set() @callback diff --git a/homeassistant/components/deconz/number.py b/homeassistant/components/deconz/number.py index 53461960573..9de86c1c79b 100644 --- a/homeassistant/components/deconz/number.py +++ b/homeassistant/components/deconz/number.py @@ -17,11 +17,11 @@ from homeassistant.components.number import ( NumberEntity, NumberEntityDescription, ) -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 . import DeconzConfigEntry from .entity import DeconzDevice from .hub import DeconzHub @@ -69,11 +69,11 @@ ENTITY_DESCRIPTIONS: tuple[DeconzNumberDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: DeconzConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the deCONZ number entity.""" - hub = DeconzHub.get_hub(hass, config_entry) + hub = config_entry.runtime_data hub.entities[NUMBER_DOMAIN] = set() @callback diff --git a/homeassistant/components/deconz/scene.py b/homeassistant/components/deconz/scene.py index 70b9f3f21b5..3f29b12b05f 100644 --- a/homeassistant/components/deconz/scene.py +++ b/homeassistant/components/deconz/scene.py @@ -7,21 +7,20 @@ from typing import Any from pydeconz.models.event import EventType from homeassistant.components.scene import DOMAIN as SCENE_DOMAIN, Scene -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback +from . import DeconzConfigEntry from .entity import DeconzSceneMixin -from .hub import DeconzHub async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: DeconzConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up scenes for deCONZ integration.""" - hub = DeconzHub.get_hub(hass, config_entry) + hub = config_entry.runtime_data hub.entities[SCENE_DOMAIN] = set() @callback diff --git a/homeassistant/components/deconz/select.py b/homeassistant/components/deconz/select.py index cbd96a4faf9..a3109a278fc 100644 --- a/homeassistant/components/deconz/select.py +++ b/homeassistant/components/deconz/select.py @@ -12,13 +12,12 @@ from pydeconz.models.sensor.presence import ( ) from homeassistant.components.select import DOMAIN as SELECT_DOMAIN, SelectEntity -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 . import DeconzConfigEntry from .entity import DeconzDevice -from .hub import DeconzHub SENSITIVITY_TO_DECONZ = { "High": PresenceConfigSensitivity.HIGH.value, @@ -30,11 +29,11 @@ DECONZ_TO_SENSITIVITY = {value: key for key, value in SENSITIVITY_TO_DECONZ.item async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: DeconzConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the deCONZ button entity.""" - hub = DeconzHub.get_hub(hass, config_entry) + hub = config_entry.runtime_data hub.entities[SELECT_DOMAIN] = set() @callback diff --git a/homeassistant/components/deconz/sensor.py b/homeassistant/components/deconz/sensor.py index 241ba015c67..576d356bca9 100644 --- a/homeassistant/components/deconz/sensor.py +++ b/homeassistant/components/deconz/sensor.py @@ -34,7 +34,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_TEMPERATURE, ATTR_VOLTAGE, @@ -55,6 +54,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType import homeassistant.util.dt as dt_util +from . import DeconzConfigEntry from .const import ATTR_DARK, ATTR_ON from .entity import DeconzDevice from .hub import DeconzHub @@ -331,11 +331,11 @@ ENTITY_DESCRIPTIONS: tuple[DeconzSensorDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: DeconzConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the deCONZ sensors.""" - hub = DeconzHub.get_hub(hass, config_entry) + hub = config_entry.runtime_data hub.entities[SENSOR_DOMAIN] = set() known_device_entities: dict[str, set[str]] = { diff --git a/homeassistant/components/deconz/services.py b/homeassistant/components/deconz/services.py index 6127fe44308..1f032f3866a 100644 --- a/homeassistant/components/deconz/services.py +++ b/homeassistant/components/deconz/services.py @@ -1,5 +1,7 @@ """deCONZ services.""" +from typing import TYPE_CHECKING + from pydeconz.utils import normalize_bridge_id import voluptuous as vol @@ -16,6 +18,10 @@ from .const import CONF_BRIDGE_ID, DOMAIN, LOGGER from .hub import DeconzHub from .util import get_master_hub +if TYPE_CHECKING: + from . import DeconzConfigEntry + + DECONZ_SERVICES = "deconz_services" SERVICE_FIELD = "field" @@ -65,7 +71,9 @@ def async_setup_services(hass: HomeAssistant) -> None: found_hub = False bridge_id = normalize_bridge_id(service_data[CONF_BRIDGE_ID]) - for possible_hub in hass.data[DOMAIN].values(): + entry: DeconzConfigEntry + for entry in hass.config_entries.async_loaded_entries(DOMAIN): + possible_hub = entry.runtime_data if possible_hub.bridgeid == bridge_id: hub = possible_hub found_hub = True diff --git a/homeassistant/components/deconz/siren.py b/homeassistant/components/deconz/siren.py index 982a0bd1b9e..28b606e30ba 100644 --- a/homeassistant/components/deconz/siren.py +++ b/homeassistant/components/deconz/siren.py @@ -13,21 +13,20 @@ from homeassistant.components.siren import ( SirenEntity, SirenEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback +from . import DeconzConfigEntry from .entity import DeconzDevice -from .hub import DeconzHub async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: DeconzConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up sirens for deCONZ component.""" - hub = DeconzHub.get_hub(hass, config_entry) + hub = config_entry.runtime_data hub.entities[SIREN_DOMAIN] = set() @callback diff --git a/homeassistant/components/deconz/switch.py b/homeassistant/components/deconz/switch.py index c79cd7b28db..cd28871e35b 100644 --- a/homeassistant/components/deconz/switch.py +++ b/homeassistant/components/deconz/switch.py @@ -8,25 +8,24 @@ from pydeconz.models.event import EventType from pydeconz.models.light.light import Light from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN, SwitchEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback +from . import DeconzConfigEntry from .const import POWER_PLUGS from .entity import DeconzDevice -from .hub import DeconzHub async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: DeconzConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up switches for deCONZ component. Switches are based on the same device class as lights in deCONZ. """ - hub = DeconzHub.get_hub(hass, config_entry) + hub = config_entry.runtime_data hub.entities[SWITCH_DOMAIN] = set() @callback diff --git a/homeassistant/components/deconz/util.py b/homeassistant/components/deconz/util.py index bcf338b2d6d..c4dc9df08ce 100644 --- a/homeassistant/components/deconz/util.py +++ b/homeassistant/components/deconz/util.py @@ -2,11 +2,16 @@ from __future__ import annotations +from typing import TYPE_CHECKING + from homeassistant.core import HomeAssistant, callback from .const import DOMAIN from .hub import DeconzHub +if TYPE_CHECKING: + from . import DeconzConfigEntry + def serial_from_unique_id(unique_id: str | None) -> str | None: """Get a device serial number from a unique ID, if possible.""" @@ -18,8 +23,9 @@ def serial_from_unique_id(unique_id: str | None) -> str | None: @callback def get_master_hub(hass: HomeAssistant) -> DeconzHub: """Return the gateway which is marked as master.""" + entry: DeconzConfigEntry hub: DeconzHub - for hub in hass.data[DOMAIN].values(): - if hub.master: + for entry in hass.config_entries.async_loaded_entries(DOMAIN): + if (hub := entry.runtime_data).master: return hub raise ValueError From f1dfae6937c7d4f499dd8f731d2ef0baba389e3c Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Mon, 27 Jan 2025 10:52:48 +0100 Subject: [PATCH 0052/3148] Ask for permission to disable Reolink privacy mode during config flow (#136511) --- .../components/reolink/config_flow.py | 27 +++++++++ homeassistant/components/reolink/strings.json | 4 ++ tests/components/reolink/test_config_flow.py | 56 ++++++++++++++++++- 3 files changed, 86 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/reolink/config_flow.py b/homeassistant/components/reolink/config_flow.py index 48be2fc8ca7..e15a43e360b 100644 --- a/homeassistant/components/reolink/config_flow.py +++ b/homeassistant/components/reolink/config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio from collections.abc import Mapping import logging from typing import Any @@ -11,6 +12,7 @@ from reolink_aio.exceptions import ( ApiError, CredentialsInvalidError, LoginFirmwareError, + LoginPrivacyModeError, ReolinkError, ) import voluptuous as vol @@ -49,6 +51,7 @@ _LOGGER = logging.getLogger(__name__) DEFAULT_PROTOCOL = "rtsp" DEFAULT_OPTIONS = {CONF_PROTOCOL: DEFAULT_PROTOCOL} +API_STARTUP_TIME = 5 class ReolinkOptionsFlowHandler(OptionsFlow): @@ -101,6 +104,8 @@ class ReolinkFlowHandler(ConfigFlow, domain=DOMAIN): self._host: str | None = None self._username: str = "admin" self._password: str | None = None + self._user_input: dict[str, Any] | None = None + self._disable_privacy: bool = False @staticmethod @callback @@ -198,6 +203,21 @@ class ReolinkFlowHandler(ConfigFlow, domain=DOMAIN): self._host = discovery_info.ip return await self.async_step_user() + async def async_step_privacy( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Ask permission to disable privacy mode.""" + if user_input is not None: + self._disable_privacy = True + return await self.async_step_user(self._user_input) + + assert self._user_input is not None + placeholders = {"host": self._user_input[CONF_HOST]} + return self.async_show_form( + step_id="privacy", + description_placeholders=placeholders, + ) + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -219,6 +239,10 @@ class ReolinkFlowHandler(ConfigFlow, domain=DOMAIN): host = ReolinkHost(self.hass, user_input, DEFAULT_OPTIONS) try: + if self._disable_privacy: + await host.api.baichuan.set_privacy_mode(enable=False) + # give the camera some time to startup the HTTP API server + await asyncio.sleep(API_STARTUP_TIME) await host.async_init() except UserNotAdmin: errors[CONF_USERNAME] = "not_admin" @@ -227,6 +251,9 @@ class ReolinkFlowHandler(ConfigFlow, domain=DOMAIN): except PasswordIncompatible: errors[CONF_PASSWORD] = "password_incompatible" placeholders["special_chars"] = ALLOWED_SPECIAL_CHARS + except LoginPrivacyModeError: + self._user_input = user_input + return await self.async_step_privacy() except CredentialsInvalidError: errors[CONF_PASSWORD] = "invalid_auth" except LoginFirmwareError: diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index 1cadc16f818..b72e7bbd00d 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -18,6 +18,10 @@ "username": "Username to login to the Reolink device itself. Not the Reolink cloud account.", "password": "Password to login to the Reolink device itself. Not the Reolink cloud account." } + }, + "privacy": { + "title": "Permission to disable Reolink privacy mode", + "description": "Privacy mode is enabled on Reolink device {host}. By pressing SUBMIT, the privacy mode will be disabled to retrieve the necessary information from the Reolink device. You can abort the setup by pressing X and repeat the setup at a time in which privacy mode can be disabled. After this configuration, you are free to enable the privacy mode again using the privacy mode switch entity. During normal startup the privacy mode will not be disabled. Note however that all entities will be marked unavailable as long as the privacy mode is active." } }, "error": { diff --git a/tests/components/reolink/test_config_flow.py b/tests/components/reolink/test_config_flow.py index 5950fc49966..4d474588f38 100644 --- a/tests/components/reolink/test_config_flow.py +++ b/tests/components/reolink/test_config_flow.py @@ -2,7 +2,7 @@ import json from typing import Any -from unittest.mock import ANY, AsyncMock, MagicMock, call +from unittest.mock import ANY, AsyncMock, MagicMock, call, patch from aiohttp import ClientSession from freezegun.api import FrozenDateTimeFactory @@ -11,6 +11,7 @@ from reolink_aio.exceptions import ( ApiError, CredentialsInvalidError, LoginFirmwareError, + LoginPrivacyModeError, ReolinkError, ) @@ -88,6 +89,59 @@ async def test_config_flow_manual_success( assert result["result"].unique_id == TEST_MAC +async def test_config_flow_privacy_success( + hass: HomeAssistant, reolink_connect: MagicMock, mock_setup_entry: MagicMock +) -> None: + """Successful flow when privacy mode is turned on.""" + reolink_connect.baichuan.privacy_mode.return_value = True + reolink_connect.get_host_data.side_effect = LoginPrivacyModeError("Test error") + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + CONF_HOST: TEST_HOST, + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "privacy" + assert result["errors"] is None + + assert reolink_connect.baichuan.set_privacy_mode.call_count == 0 + reolink_connect.get_host_data.reset_mock(side_effect=True) + + with patch("homeassistant.components.reolink.config_flow.API_STARTUP_TIME", new=0): + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert reolink_connect.baichuan.set_privacy_mode.call_count == 1 + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == TEST_NVR_NAME + assert result["data"] == { + CONF_HOST: TEST_HOST, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + CONF_PORT: TEST_PORT, + CONF_USE_HTTPS: TEST_USE_HTTPS, + } + assert result["options"] == { + CONF_PROTOCOL: DEFAULT_PROTOCOL, + } + assert result["result"].unique_id == TEST_MAC + + reolink_connect.baichuan.privacy_mode.return_value = False + + async def test_config_flow_errors( hass: HomeAssistant, reolink_connect: MagicMock, mock_setup_entry: MagicMock ) -> None: From 6015c936b03a4f583510b12d3956cd320e0f7465 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Mon, 27 Jan 2025 11:35:33 +0100 Subject: [PATCH 0053/3148] Add a Matter temperature sensor based on `Thermostat` device `LocalTemperature` attribute (#133888) --- homeassistant/components/matter/climate.py | 1 + homeassistant/components/matter/sensor.py | 15 ++ .../matter/snapshots/test_sensor.ambr | 153 ++++++++++++++++++ tests/components/matter/test_sensor.py | 12 ++ 4 files changed, 181 insertions(+) diff --git a/homeassistant/components/matter/climate.py b/homeassistant/components/matter/climate.py index be6f024695d..8f6cd92d31f 100644 --- a/homeassistant/components/matter/climate.py +++ b/homeassistant/components/matter/climate.py @@ -445,5 +445,6 @@ DISCOVERY_SCHEMAS = [ clusters.OnOff.Attributes.OnOff, ), device_type=(device_types.Thermostat, device_types.RoomAirConditioner), + allow_multi=True, # also used for sensor entity ), ] diff --git a/homeassistant/components/matter/sensor.py b/homeassistant/components/matter/sensor.py index d8fe56278df..77b51d2dfbb 100644 --- a/homeassistant/components/matter/sensor.py +++ b/homeassistant/components/matter/sensor.py @@ -8,6 +8,7 @@ from typing import TYPE_CHECKING, cast from chip.clusters import Objects as clusters from chip.clusters.Types import Nullable, NullValue +from matter_server.client.models import device_types from matter_server.common.custom_clusters import ( EveCluster, NeoCluster, @@ -677,4 +678,18 @@ DISCOVERY_SCHEMAS = [ clusters.OperationalState.Attributes.OperationalStateList, ), ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="ThermostatLocalTemperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + measurement_to_ha=lambda x: x / 100, + state_class=SensorStateClass.MEASUREMENT, + ), + entity_class=MatterSensor, + required_attributes=(clusters.Thermostat.Attributes.LocalTemperature,), + device_type=(device_types.Thermostat,), + allow_multi=True, # also used for climate entity + ), ] diff --git a/tests/components/matter/snapshots/test_sensor.ambr b/tests/components/matter/snapshots/test_sensor.ambr index 205cba68d7c..5e22b9a1476 100644 --- a/tests/components/matter/snapshots/test_sensor.ambr +++ b/tests/components/matter/snapshots/test_sensor.ambr @@ -622,6 +622,57 @@ 'state': '20.0', }) # --- +# name: test_sensors[air_purifier][sensor.air_purifier_temperature_2-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.air_purifier_temperature_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': 'Temperature', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-000000000000008F-MatterNodeDevice-5-ThermostatLocalTemperature-513-0', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[air_purifier][sensor.air_purifier_temperature_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Air Purifier Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.air_purifier_temperature_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '20.0', + }) +# --- # name: test_sensors[air_purifier][sensor.air_purifier_vocs-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1745,6 +1796,57 @@ 'state': '100', }) # --- +# name: test_sensors[eve_thermo][sensor.eve_thermo_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.eve_thermo_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': 'Temperature', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-0000000000000021-MatterNodeDevice-1-ThermostatLocalTemperature-513-0', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[eve_thermo][sensor.eve_thermo_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Eve Thermo Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.eve_thermo_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '21.0', + }) +# --- # name: test_sensors[eve_thermo][sensor.eve_thermo_valve_position-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -3201,3 +3303,54 @@ 'state': '21.0', }) # --- +# name: test_sensors[thermostat][sensor.longan_link_hvac_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.longan_link_hvac_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': 'Temperature', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-0000000000000004-MatterNodeDevice-1-ThermostatLocalTemperature-513-0', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[thermostat][sensor.longan_link_hvac_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Longan link HVAC Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.longan_link_hvac_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '28.3', + }) +# --- diff --git a/tests/components/matter/test_sensor.py b/tests/components/matter/test_sensor.py index 630809a957d..bd3e146264a 100644 --- a/tests/components/matter/test_sensor.py +++ b/tests/components/matter/test_sensor.py @@ -219,6 +219,18 @@ async def test_eve_thermo_sensor( assert state assert state.state == "0" + # LocalTemperature + state = hass.states.get("sensor.eve_thermo_temperature") + assert state + assert state.state == "21.0" + + set_node_attribute(matter_node, 1, 513, 0, 1800) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get("sensor.eve_thermo_temperature") + assert state + assert state.state == "18.0" + @pytest.mark.parametrize("node_fixture", ["pressure_sensor"]) async def test_pressure_sensor( From 111906f54ec1e3d09b52bdc514298e5fd5752b56 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 27 Jan 2025 12:41:25 +0100 Subject: [PATCH 0054/3148] Add missing exclude_integrations in lovelace compatibility code (#136618) Add missing exclude_integrations in lovelace --- homeassistant/components/lovelace/__init__.py | 2 ++ tests/components/lovelace/test_init.py | 29 ++++++++----------- 2 files changed, 14 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/lovelace/__init__.py b/homeassistant/components/lovelace/__init__.py index 51d2ed3eab7..4d8472da9a2 100644 --- a/homeassistant/components/lovelace/__init__.py +++ b/homeassistant/components/lovelace/__init__.py @@ -109,6 +109,7 @@ class LovelaceData: report_usage( f"accessed lovelace_data['{name}'] instead of lovelace_data.{name}", breaks_in_ha_version="2026.2", + exclude_integrations={DOMAIN}, ) return getattr(self, name) @@ -121,6 +122,7 @@ class LovelaceData: report_usage( f"accessed lovelace_data.get('{name}') instead of lovelace_data.{name}", breaks_in_ha_version="2026.2", + exclude_integrations={DOMAIN}, ) if hasattr(self, name): return getattr(self, name) diff --git a/tests/components/lovelace/test_init.py b/tests/components/lovelace/test_init.py index 6f11c22466e..f35f7369f93 100644 --- a/tests/components/lovelace/test_init.py +++ b/tests/components/lovelace/test_init.py @@ -7,6 +7,7 @@ from unittest.mock import MagicMock, patch import pytest from homeassistant.core import HomeAssistant +from homeassistant.helpers import frame from homeassistant.setup import async_setup_component from tests.typing import WebSocketGenerator @@ -40,6 +41,8 @@ async def test_create_dashboards_when_onboarded( assert response["result"] == [] +@pytest.mark.parametrize("integration_frame_path", ["custom_components/my_integration"]) +@pytest.mark.usefixtures("mock_integration_frame") async def test_hass_data_compatibility( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, @@ -50,32 +53,24 @@ async def test_hass_data_compatibility( https://github.com/hacs/integration/blob/4a820e8b1b066bc54a1c9c61102038af6c030603 /custom_components/hacs/repositories/plugin.py#L173 """ - expected = ( - "Detected that integration 'lovelace' accessed lovelace_data.get('resources')" - " instead of lovelace_data.resources at" + expected_prefix = ( + "Detected that custom integration 'my_integration' accessed lovelace_data" ) assert await async_setup_component(hass, "lovelace", {}) assert (lovelace_data := hass.data.get("lovelace")) is not None - assert expected not in caplog.text # Direct access to resources is fine assert lovelace_data.resources is not None - assert ( - "Detected that integration 'lovelace' accessed lovelace_data" not in caplog.text - ) + assert expected_prefix not in caplog.text # Dict compatibility logs warning - assert lovelace_data["resources"] is not None - assert ( - "Detected that integration 'lovelace' accessed lovelace_data['resources']" - in caplog.text - ) + with patch.object(frame, "_REPORTED_INTEGRATIONS", set()): + assert lovelace_data["resources"] is not None + assert f"{expected_prefix}['resources']" in caplog.text # Dict get compatibility logs warning - assert lovelace_data.get("resources") is not None - assert ( - "Detected that integration 'lovelace' accessed lovelace_data.get('resources')" - in caplog.text - ) + with patch.object(frame, "_REPORTED_INTEGRATIONS", set()): + assert lovelace_data.get("resources") is not None + assert f"{expected_prefix}.get('resources')" in caplog.text From 4e29ac8e1b0f19597e938b0ca07c2400bfec99ff Mon Sep 17 00:00:00 2001 From: David Rapan Date: Mon, 27 Jan 2025 12:44:59 +0100 Subject: [PATCH 0055/3148] Starlink's energy consumption & usage cumulation fix (#135889) * refactor: history_stats result indexing * fix: Energy consumption & Usage cumulation * fix: typo * fix: mypy error: Call to untyped function * refactor: Use generic tuple instead of typing's Tuple * fix: tuple * fix: just syntax test * fix: AttributeError: 'NoneType' object has no attribute 'usage' * refactor: Return type * refactor: Merge into single method * refactor: Complex unpack test --- homeassistant/components/starlink/coordinator.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/starlink/coordinator.py b/homeassistant/components/starlink/coordinator.py index 89d03a4fadc..6fcfd8e0bfe 100644 --- a/homeassistant/components/starlink/coordinator.py +++ b/homeassistant/components/starlink/coordinator.py @@ -52,6 +52,7 @@ class StarlinkUpdateCoordinator(DataUpdateCoordinator[StarlinkData]): def __init__(self, hass: HomeAssistant, name: str, url: str) -> None: """Initialize an UpdateCoordinator for a group of sensors.""" self.channel_context = ChannelContext(target=url) + self.history_stats_start = None self.timezone = ZoneInfo(hass.config.time_zone) super().__init__( hass, @@ -67,7 +68,18 @@ class StarlinkUpdateCoordinator(DataUpdateCoordinator[StarlinkData]): location = location_data(context) sleep = get_sleep_config(context) status, obstruction, alert = status_data(context) - usage, consumption = history_stats(parse_samples=-1, context=context)[-2:] + index, _, _, _, _, usage, consumption, *_ = history_stats( + parse_samples=-1, start=self.history_stats_start, context=context + ) + self.history_stats_start = index["end_counter"] + if self.data: + if index["samples"] > 0: + usage["download_usage"] += self.data.usage["download_usage"] + usage["upload_usage"] += self.data.usage["upload_usage"] + consumption["total_energy"] += self.data.consumption["total_energy"] + else: + usage = self.data.usage + consumption = self.data.consumption return StarlinkData( location, sleep, status, obstruction, alert, usage, consumption ) From 6c9ff41b0b843ec6ed65fe0859b790fdc3e8f8c0 Mon Sep 17 00:00:00 2001 From: Avi Miller Date: Mon, 27 Jan 2025 23:06:01 +1100 Subject: [PATCH 0056/3148] Add product IDs for new LIFX Ceiling lights (#136619) --- homeassistant/components/lifx/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/lifx/const.py b/homeassistant/components/lifx/const.py index 667afe1125d..58c3550b812 100644 --- a/homeassistant/components/lifx/const.py +++ b/homeassistant/components/lifx/const.py @@ -61,7 +61,7 @@ INFRARED_BRIGHTNESS_VALUES_MAP = { } DATA_LIFX_MANAGER = "lifx_manager" -LIFX_CEILING_PRODUCT_IDS = {176, 177} +LIFX_CEILING_PRODUCT_IDS = {176, 177, 201, 202} _LOGGER = logging.getLogger(__package__) From 55278ebfc8a9f0667c0eea42971755026ac38ec3 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 27 Jan 2025 13:31:47 +0100 Subject: [PATCH 0057/3148] Use runtime_data in ecobee (#136632) --- homeassistant/components/ecobee/__init__.py | 21 +++++++++---------- .../components/ecobee/binary_sensor.py | 6 +++--- homeassistant/components/ecobee/climate.py | 7 +++---- homeassistant/components/ecobee/humidifier.py | 6 +++--- homeassistant/components/ecobee/notify.py | 8 +++---- homeassistant/components/ecobee/number.py | 8 +++---- homeassistant/components/ecobee/sensor.py | 6 +++--- homeassistant/components/ecobee/switch.py | 9 ++++---- homeassistant/components/ecobee/weather.py | 6 +++--- 9 files changed, 35 insertions(+), 42 deletions(-) diff --git a/homeassistant/components/ecobee/__init__.py b/homeassistant/components/ecobee/__init__.py index 54af6c0f801..ae5ee96a6a4 100644 --- a/homeassistant/components/ecobee/__init__.py +++ b/homeassistant/components/ecobee/__init__.py @@ -27,6 +27,8 @@ CONFIG_SCHEMA = vol.Schema( {DOMAIN: vol.Schema({vol.Optional(CONF_API_KEY): cv.string})}, extra=vol.ALLOW_EXTRA ) +type EcobeeConfigEntry = ConfigEntry[EcobeeData] + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Ecobee uses config flow for configuration. @@ -52,23 +54,23 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: EcobeeConfigEntry) -> bool: """Set up ecobee via a config entry.""" api_key = entry.data[CONF_API_KEY] refresh_token = entry.data[CONF_REFRESH_TOKEN] - data = EcobeeData(hass, entry, api_key=api_key, refresh_token=refresh_token) + runtime_data = EcobeeData(hass, entry, api_key=api_key, refresh_token=refresh_token) - if not await data.refresh(): + if not await runtime_data.refresh(): return False - await data.update() + await runtime_data.update() - if data.ecobee.thermostats is None: + if runtime_data.ecobee.thermostats is None: _LOGGER.error("No ecobee devices found to set up") return False - hass.data[DOMAIN] = data + entry.runtime_data = runtime_data await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -117,9 +119,6 @@ class EcobeeData: return False -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: EcobeeConfigEntry) -> bool: """Unload the config entry and platforms.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - hass.data.pop(DOMAIN) - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/ecobee/binary_sensor.py b/homeassistant/components/ecobee/binary_sensor.py index 2a021442a63..9c9f2192f43 100644 --- a/homeassistant/components/ecobee/binary_sensor.py +++ b/homeassistant/components/ecobee/binary_sensor.py @@ -6,21 +6,21 @@ from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback +from . import EcobeeConfigEntry from .const import DOMAIN, ECOBEE_MODEL_TO_NAME, MANUFACTURER async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: EcobeeConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up ecobee binary (occupancy) sensors.""" - data = hass.data[DOMAIN] + data = config_entry.runtime_data dev = [] for index in range(len(data.ecobee.thermostats)): for sensor in data.ecobee.get_remote_sensors(index): diff --git a/homeassistant/components/ecobee/climate.py b/homeassistant/components/ecobee/climate.py index bfb2635481c..4e32990a661 100644 --- a/homeassistant/components/ecobee/climate.py +++ b/homeassistant/components/ecobee/climate.py @@ -21,7 +21,6 @@ from homeassistant.components.climate import ( HVACAction, HVACMode, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_TEMPERATURE, @@ -39,7 +38,7 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.unit_conversion import TemperatureConverter -from . import EcobeeData +from . import EcobeeConfigEntry, EcobeeData from .const import ( _LOGGER, ATTR_ACTIVE_SENSORS, @@ -201,12 +200,12 @@ SUPPORT_FLAGS = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: EcobeeConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the ecobee thermostat.""" - data = hass.data[DOMAIN] + data = config_entry.runtime_data entities = [] for index in range(len(data.ecobee.thermostats)): diff --git a/homeassistant/components/ecobee/humidifier.py b/homeassistant/components/ecobee/humidifier.py index d9616383ab6..982cbdd07f2 100644 --- a/homeassistant/components/ecobee/humidifier.py +++ b/homeassistant/components/ecobee/humidifier.py @@ -12,11 +12,11 @@ from homeassistant.components.humidifier import ( HumidifierEntity, HumidifierEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback +from . import EcobeeConfigEntry from .const import DOMAIN, ECOBEE_MODEL_TO_NAME, MANUFACTURER SCAN_INTERVAL = timedelta(minutes=3) @@ -27,11 +27,11 @@ MODE_OFF = "off" async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: EcobeeConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the ecobee thermostat humidifier entity.""" - data = hass.data[DOMAIN] + data = config_entry.runtime_data entities = [] for index in range(len(data.ecobee.thermostats)): thermostat = data.ecobee.get_thermostat(index) diff --git a/homeassistant/components/ecobee/notify.py b/homeassistant/components/ecobee/notify.py index 70860003b2a..7c70d7ae4ac 100644 --- a/homeassistant/components/ecobee/notify.py +++ b/homeassistant/components/ecobee/notify.py @@ -3,22 +3,20 @@ from __future__ import annotations from homeassistant.components.notify import NotifyEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import EcobeeData -from .const import DOMAIN +from . import EcobeeConfigEntry, EcobeeData from .entity import EcobeeBaseEntity async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: EcobeeConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the ecobee thermostat.""" - data: EcobeeData = hass.data[DOMAIN] + data = config_entry.runtime_data async_add_entities( EcobeeNotifyEntity(data, index) for index in range(len(data.ecobee.thermostats)) ) diff --git a/homeassistant/components/ecobee/number.py b/homeassistant/components/ecobee/number.py index ed3744bf11e..f047ea8f896 100644 --- a/homeassistant/components/ecobee/number.py +++ b/homeassistant/components/ecobee/number.py @@ -12,13 +12,11 @@ from homeassistant.components.number import ( NumberEntityDescription, NumberMode, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfTemperature, UnitOfTime from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import EcobeeData -from .const import DOMAIN +from . import EcobeeConfigEntry, EcobeeData from .entity import EcobeeBaseEntity _LOGGER = logging.getLogger(__name__) @@ -54,11 +52,11 @@ VENTILATOR_NUMBERS = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: EcobeeConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the ecobee thermostat number entity.""" - data: EcobeeData = hass.data[DOMAIN] + data = config_entry.runtime_data assert data is not None diff --git a/homeassistant/components/ecobee/sensor.py b/homeassistant/components/ecobee/sensor.py index fe0442fb885..1b50fc21edf 100644 --- a/homeassistant/components/ecobee/sensor.py +++ b/homeassistant/components/ecobee/sensor.py @@ -12,7 +12,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONCENTRATION_PARTS_PER_MILLION, @@ -23,6 +22,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback +from . import EcobeeConfigEntry from .const import DOMAIN, ECOBEE_MODEL_TO_NAME, MANUFACTURER @@ -73,11 +73,11 @@ SENSOR_TYPES: tuple[EcobeeSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: EcobeeConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up ecobee sensors.""" - data = hass.data[DOMAIN] + data = config_entry.runtime_data entities = [ EcobeeSensor(data, sensor["name"], index, description) for index in range(len(data.ecobee.thermostats)) diff --git a/homeassistant/components/ecobee/switch.py b/homeassistant/components/ecobee/switch.py index 89ee433c072..c92082b7b58 100644 --- a/homeassistant/components/ecobee/switch.py +++ b/homeassistant/components/ecobee/switch.py @@ -8,14 +8,13 @@ from typing import Any from homeassistant.components.climate import HVACMode from homeassistant.components.switch import SwitchEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import dt as dt_util -from . import EcobeeData +from . import EcobeeConfigEntry, EcobeeData from .climate import HASS_TO_ECOBEE_HVAC -from .const import DOMAIN, ECOBEE_AUX_HEAT_ONLY +from .const import ECOBEE_AUX_HEAT_ONLY from .entity import EcobeeBaseEntity _LOGGER = logging.getLogger(__name__) @@ -25,11 +24,11 @@ DATE_FORMAT = "%Y-%m-%d %H:%M:%S" async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: EcobeeConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the ecobee thermostat switch entity.""" - data: EcobeeData = hass.data[DOMAIN] + data = config_entry.runtime_data entities: list[SwitchEntity] = [ EcobeeVentilator20MinSwitch( diff --git a/homeassistant/components/ecobee/weather.py b/homeassistant/components/ecobee/weather.py index b6378504c65..39b2d30ddd8 100644 --- a/homeassistant/components/ecobee/weather.py +++ b/homeassistant/components/ecobee/weather.py @@ -17,7 +17,6 @@ from homeassistant.components.weather import ( WeatherEntity, WeatherEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( UnitOfLength, UnitOfPressure, @@ -29,6 +28,7 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import dt as dt_util +from . import EcobeeConfigEntry from .const import ( DOMAIN, ECOBEE_MODEL_TO_NAME, @@ -39,11 +39,11 @@ from .const import ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: EcobeeConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the ecobee weather platform.""" - data = hass.data[DOMAIN] + data = config_entry.runtime_data dev = [] for index in range(len(data.ecobee.thermostats)): thermostat = data.ecobee.get_thermostat(index) From f87d952816a66e2fe153daca343877a918520166 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 27 Jan 2025 14:09:04 +0100 Subject: [PATCH 0058/3148] Bump codecov/codecov-action from 5.3.0 to 5.3.1 (#136614) --- .github/workflows/ci.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 6527a09e15f..dad662a9202 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1273,7 +1273,7 @@ jobs: pattern: coverage-* - name: Upload coverage to Codecov if: needs.info.outputs.test_full_suite == 'true' - uses: codecov/codecov-action@v5.3.0 + uses: codecov/codecov-action@v5.3.1 with: fail_ci_if_error: true flags: full-suite @@ -1411,7 +1411,7 @@ jobs: pattern: coverage-* - name: Upload coverage to Codecov if: needs.info.outputs.test_full_suite == 'false' - uses: codecov/codecov-action@v5.3.0 + uses: codecov/codecov-action@v5.3.1 with: fail_ci_if_error: true token: ${{ secrets.CODECOV_TOKEN }} From ba070b34c8f844e75e4d0cc941ff18173812c6cd Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 27 Jan 2025 14:27:41 +0100 Subject: [PATCH 0059/3148] Bump docker/build-push-action from 6.12.0 to 6.13.0 (#136612) --- .github/workflows/builder.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index 5b1cf48df68..39dc08444d3 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -509,7 +509,7 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Build Docker image - uses: docker/build-push-action@67a2d409c0a876cbe6b11854e3e25193efe4e62d # v6.12.0 + uses: docker/build-push-action@ca877d9245402d1537745e0e356eab47c3520991 # v6.13.0 with: context: . # So action will not pull the repository again file: ./script/hassfest/docker/Dockerfile @@ -522,7 +522,7 @@ jobs: - name: Push Docker image if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true' id: push - uses: docker/build-push-action@67a2d409c0a876cbe6b11854e3e25193efe4e62d # v6.12.0 + uses: docker/build-push-action@ca877d9245402d1537745e0e356eab47c3520991 # v6.13.0 with: context: . # So action will not pull the repository again file: ./script/hassfest/docker/Dockerfile From 2878ba601b3c73a7f76f722802c9352d475efff2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 27 Jan 2025 14:28:04 +0100 Subject: [PATCH 0060/3148] Bump github/codeql-action from 3.28.4 to 3.28.5 (#136613) --- .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 ee7fad4bb4e..9dbd39b4bc5 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -24,11 +24,11 @@ jobs: uses: actions/checkout@v4.2.2 - name: Initialize CodeQL - uses: github/codeql-action/init@v3.28.4 + uses: github/codeql-action/init@v3.28.5 with: languages: python - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3.28.4 + uses: github/codeql-action/analyze@v3.28.5 with: category: "/language:python" From 7dc2b92452970da84b66c38261642cc72bc9aeb8 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 27 Jan 2025 15:05:20 +0100 Subject: [PATCH 0061/3148] Use typed coordinator and runtime_data in eafm (#136629) * Move coordinator and use runtime_data in eafm * Add type hints --- homeassistant/components/eafm/__init__.py | 52 ++---------------- homeassistant/components/eafm/coordinator.py | 57 ++++++++++++++++++++ homeassistant/components/eafm/sensor.py | 13 ++--- tests/components/eafm/conftest.py | 2 +- 4 files changed, 68 insertions(+), 56 deletions(-) create mode 100644 homeassistant/components/eafm/coordinator.py diff --git a/homeassistant/components/eafm/__init__.py b/homeassistant/components/eafm/__init__.py index dc618a983f3..e2af2bae9f5 100644 --- a/homeassistant/components/eafm/__init__.py +++ b/homeassistant/components/eafm/__init__.py @@ -1,64 +1,22 @@ """UK Environment Agency Flood Monitoring Integration.""" -import asyncio -from datetime import timedelta -import logging -from typing import Any - -from aioeafm import get_station - -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from .const import DOMAIN +from .coordinator import EafmConfigEntry, EafmCoordinator PLATFORMS = [Platform.SENSOR] -_LOGGER = logging.getLogger(__name__) - -def get_measures(station_data): - """Force measure key to always be a list.""" - if "measures" not in station_data: - return [] - if isinstance(station_data["measures"], dict): - return [station_data["measures"]] - return station_data["measures"] - - -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: EafmConfigEntry) -> bool: """Set up flood monitoring sensors for this config entry.""" - station_key = entry.data["station"] - session = async_get_clientsession(hass=hass) - - async def _async_update_data() -> dict[str, dict[str, Any]]: - # DataUpdateCoordinator will handle aiohttp ClientErrors and timeouts - async with asyncio.timeout(30): - data = await get_station(session, station_key) - - measures = get_measures(data) - # Turn data.measures into a dict rather than a list so easier for entities to - # find themselves. - data["measures"] = {measure["@id"]: measure for measure in measures} - return data - - coordinator = DataUpdateCoordinator[dict[str, dict[str, Any]]]( - hass, - _LOGGER, - config_entry=entry, - name="sensor", - update_method=_async_update_data, - update_interval=timedelta(seconds=15 * 60), - ) + coordinator = EafmCoordinator(hass, entry=entry) await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: EafmConfigEntry) -> bool: """Unload flood monitoring sensors.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/eafm/coordinator.py b/homeassistant/components/eafm/coordinator.py new file mode 100644 index 00000000000..375368210a5 --- /dev/null +++ b/homeassistant/components/eafm/coordinator.py @@ -0,0 +1,57 @@ +"""UK Environment Agency Flood Monitoring Integration.""" + +import asyncio +from datetime import timedelta +import logging +from typing import Any + +from aioeafm import get_station + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +PLATFORMS = [Platform.SENSOR] + +_LOGGER = logging.getLogger(__name__) + +type EafmConfigEntry = ConfigEntry[EafmCoordinator] + + +def _get_measures(station_data: dict[str, Any]) -> list[dict[str, Any]]: + """Force measure key to always be a list.""" + if "measures" not in station_data: + return [] + if isinstance(station_data["measures"], dict): + return [station_data["measures"]] + return station_data["measures"] + + +class EafmCoordinator(DataUpdateCoordinator[dict[str, dict[str, Any]]]): + """Class to manage fetching UK Flood Monitoring data.""" + + def __init__(self, hass: HomeAssistant, entry: EafmConfigEntry) -> None: + """Initialize.""" + self._station_key = entry.data["station"] + self._session = async_get_clientsession(hass=hass) + super().__init__( + hass, + _LOGGER, + config_entry=entry, + name="sensor", + update_interval=timedelta(seconds=15 * 60), + ) + + async def _async_update_data(self) -> dict[str, dict[str, Any]]: + """Fetch the latest data from the source.""" + # DataUpdateCoordinator will handle aiohttp ClientErrors and timeouts + async with asyncio.timeout(30): + data = await get_station(self._session, self._station_key) + + measures = _get_measures(data) + # Turn data.measures into a dict rather than a list so easier for entities to + # find themselves. + data["measures"] = {measure["@id"]: measure for measure in measures} + return data diff --git a/homeassistant/components/eafm/sensor.py b/homeassistant/components/eafm/sensor.py index 297f4d6d2c8..d9b18cbc663 100644 --- a/homeassistant/components/eafm/sensor.py +++ b/homeassistant/components/eafm/sensor.py @@ -3,17 +3,14 @@ from typing import Any from homeassistant.components.sensor import SensorEntity, SensorStateClass -from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfLength from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, -) +from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN +from .coordinator import EafmConfigEntry, EafmCoordinator UNIT_MAPPING = { "http://qudt.org/1.1/vocab/unit#Meter": UnitOfLength.METERS, @@ -22,11 +19,11 @@ UNIT_MAPPING = { async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: EafmConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up UK Flood Monitoring Sensors.""" - coordinator: DataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data created_entities: set[str] = set() @callback @@ -70,7 +67,7 @@ class Measurement(CoordinatorEntity, SensorEntity): _attr_has_entity_name = True _attr_name = None - def __init__(self, coordinator, key): + def __init__(self, coordinator: EafmCoordinator, key: str) -> None: """Initialise the gauge with a data instance and station.""" super().__init__(coordinator) self.key = key diff --git a/tests/components/eafm/conftest.py b/tests/components/eafm/conftest.py index 3b060563a30..5dbdc98ad29 100644 --- a/tests/components/eafm/conftest.py +++ b/tests/components/eafm/conftest.py @@ -15,5 +15,5 @@ def mock_get_stations(): @pytest.fixture def mock_get_station(): """Mock aioeafm.get_station.""" - with patch("homeassistant.components.eafm.get_station") as patched: + with patch("homeassistant.components.eafm.coordinator.get_station") as patched: yield patched From e1607344f041b5529bf891a3e97933c633b149ef Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 27 Jan 2025 15:05:42 +0100 Subject: [PATCH 0062/3148] Cleanup unnecessary type hint in assist_satellite (#136626) --- homeassistant/components/assist_satellite/websocket_api.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/homeassistant/components/assist_satellite/websocket_api.py b/homeassistant/components/assist_satellite/websocket_api.py index c81648c6ee3..6cd7af2bbdb 100644 --- a/homeassistant/components/assist_satellite/websocket_api.py +++ b/homeassistant/components/assist_satellite/websocket_api.py @@ -10,7 +10,6 @@ from homeassistant.components import websocket_api from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.entity_component import EntityComponent from homeassistant.util import uuid as uuid_util from .connection_test import CONNECTION_TEST_URL_BASE @@ -20,7 +19,6 @@ from .const import ( DOMAIN, AssistSatelliteEntityFeature, ) -from .entity import AssistSatelliteEntity CONNECTION_TEST_TIMEOUT = 30 @@ -167,7 +165,7 @@ async def websocket_test_connection( Send an announcement to the device with a special media id. """ - component: EntityComponent[AssistSatelliteEntity] = hass.data[DOMAIN] + component = hass.data[DATA_COMPONENT] satellite = component.get_entity(msg["entity_id"]) if satellite is None: connection.send_error( From 037a0f25a4a3f44e68d30628be79099c34bcfd33 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 27 Jan 2025 15:06:03 +0100 Subject: [PATCH 0063/3148] Cleanup hass.data[DOMAIN] in application_credentials (#136625) --- homeassistant/components/application_credentials/__init__.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/homeassistant/components/application_credentials/__init__.py b/homeassistant/components/application_credentials/__init__.py index 58146818624..0ee936aeef2 100644 --- a/homeassistant/components/application_credentials/__init__.py +++ b/homeassistant/components/application_credentials/__init__.py @@ -143,8 +143,6 @@ class ApplicationCredentialsStorageCollection(collection.DictStorageCollection): async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Application Credentials.""" - hass.data[DOMAIN] = {} - id_manager = collection.IDManager() storage_collection = ApplicationCredentialsStorageCollection( Store(hass, STORAGE_VERSION, STORAGE_KEY), From 84561b744632e4ca2449bc07632663af52c7684f Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Mon, 27 Jan 2025 14:56:04 +0000 Subject: [PATCH 0064/3148] Use typed ConfigEntry in ring coordinator (#136457) * Use typed ConfigEntry in ring coordinator * Make config_entry a positional argument for coordinator --- homeassistant/components/ring/__init__.py | 34 +++++++------------- homeassistant/components/ring/coordinator.py | 30 +++++++++++++---- 2 files changed, 36 insertions(+), 28 deletions(-) diff --git a/homeassistant/components/ring/__init__.py b/homeassistant/components/ring/__init__.py index edc084fb57b..8e36f3e85e7 100644 --- a/homeassistant/components/ring/__init__.py +++ b/homeassistant/components/ring/__init__.py @@ -2,39 +2,29 @@ from __future__ import annotations -from dataclasses import dataclass import logging from typing import Any, cast import uuid -from ring_doorbell import Auth, Ring, RingDevices +from ring_doorbell import Auth, Ring from homeassistant.components.camera import DOMAIN as CAMERA_DOMAIN -from homeassistant.config_entries import ConfigEntry from homeassistant.const import APPLICATION_NAME, CONF_DEVICE_ID, CONF_TOKEN from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import CONF_LISTEN_CREDENTIALS, DOMAIN, PLATFORMS -from .coordinator import RingDataCoordinator, RingListenCoordinator +from .coordinator import ( + RingConfigEntry, + RingData, + RingDataCoordinator, + RingListenCoordinator, +) _LOGGER = logging.getLogger(__name__) -@dataclass -class RingData: - """Class to support type hinting of ring data collection.""" - - api: Ring - devices: RingDevices - devices_coordinator: RingDataCoordinator - listen_coordinator: RingListenCoordinator - - -type RingConfigEntry = ConfigEntry[RingData] - - def get_auth_user_agent() -> str: """Return user-agent for Auth instantiation. @@ -71,10 +61,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: RingConfigEntry) -> bool ) ring = Ring(auth) - devices_coordinator = RingDataCoordinator(hass, ring) + devices_coordinator = RingDataCoordinator(hass, entry, ring) listen_credentials = entry.data.get(CONF_LISTEN_CREDENTIALS) listen_coordinator = RingListenCoordinator( - hass, ring, listen_credentials, listen_credentials_updater + hass, entry, ring, listen_credentials, listen_credentials_updater ) await devices_coordinator.async_config_entry_first_refresh() @@ -91,19 +81,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: RingConfigEntry) -> bool return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: RingConfigEntry) -> bool: """Unload Ring entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) async def async_remove_config_entry_device( - hass: HomeAssistant, config_entry: ConfigEntry, device_entry: dr.DeviceEntry + hass: HomeAssistant, entry: RingConfigEntry, device_entry: dr.DeviceEntry ) -> bool: """Remove a config entry from a device.""" return True -async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_migrate_entry(hass: HomeAssistant, entry: RingConfigEntry) -> bool: """Migrate old config entry.""" entry_version = entry.version entry_minor_version = entry.minor_version diff --git a/homeassistant/components/ring/coordinator.py b/homeassistant/components/ring/coordinator.py index b143fd3dda0..f35a6e10b9f 100644 --- a/homeassistant/components/ring/coordinator.py +++ b/homeassistant/components/ring/coordinator.py @@ -1,9 +1,12 @@ """Data coordinators for the ring integration.""" +from __future__ import annotations + from asyncio import TaskGroup from collections.abc import Callable, Coroutine +from dataclasses import dataclass import logging -from typing import TYPE_CHECKING, Any +from typing import Any from ring_doorbell import ( AuthenticationError, @@ -15,7 +18,7 @@ from ring_doorbell import ( ) from ring_doorbell.listen import RingEventListener -from homeassistant import config_entries +from homeassistant.config_entries import ConfigEntry from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import ( @@ -29,6 +32,19 @@ from .const import SCAN_INTERVAL _LOGGER = logging.getLogger(__name__) +@dataclass +class RingData: + """Class to support type hinting of ring data collection.""" + + api: Ring + devices: RingDevices + devices_coordinator: RingDataCoordinator + listen_coordinator: RingListenCoordinator + + +type RingConfigEntry = ConfigEntry[RingData] + + async def _call_api[*_Ts, _R]( hass: HomeAssistant, target: Callable[[*_Ts], Coroutine[Any, Any, _R]], @@ -52,9 +68,12 @@ async def _call_api[*_Ts, _R]( class RingDataCoordinator(DataUpdateCoordinator[RingDevices]): """Base class for device coordinators.""" + config_entry: RingConfigEntry + def __init__( self, hass: HomeAssistant, + config_entry: RingConfigEntry, ring_api: Ring, ) -> None: """Initialize my coordinator.""" @@ -63,6 +82,7 @@ class RingDataCoordinator(DataUpdateCoordinator[RingDevices]): name="devices", logger=_LOGGER, update_interval=SCAN_INTERVAL, + config_entry=config_entry, ) self.ring_api: Ring = ring_api self.first_call: bool = True @@ -107,11 +127,12 @@ class RingDataCoordinator(DataUpdateCoordinator[RingDevices]): class RingListenCoordinator(BaseDataUpdateCoordinatorProtocol): """Global notifications coordinator.""" - config_entry: config_entries.ConfigEntry + config_entry: RingConfigEntry def __init__( self, hass: HomeAssistant, + config_entry: RingConfigEntry, ring_api: Ring, listen_credentials: dict[str, Any] | None, listen_credentials_updater: Callable[[dict[str, Any]], None], @@ -126,9 +147,6 @@ class RingListenCoordinator(BaseDataUpdateCoordinatorProtocol): self._listeners: dict[CALLBACK_TYPE, tuple[CALLBACK_TYPE, object | None]] = {} self._listen_callback_id: int | None = None - config_entry = config_entries.current_entry.get() - if TYPE_CHECKING: - assert config_entry self.config_entry = config_entry self.start_timeout = 10 self.config_entry.async_on_unload(self.async_shutdown) From 679b7f403283ec7c374bbfa1a58d5117d5a19721 Mon Sep 17 00:00:00 2001 From: Arie Catsman <120491684+catsmanac@users.noreply.github.com> Date: Mon, 27 Jan 2025 16:40:39 +0100 Subject: [PATCH 0065/3148] Fix test logic flaw in enphase_envoy test_select (#136570) * Fix test logic flaw in enphase_envoy test_select * Replace test loops by test parameters * Implement review feedback to Improve use of parametrize parameters --- tests/components/enphase_envoy/test_select.py | 179 ++++++++---------- 1 file changed, 74 insertions(+), 105 deletions(-) diff --git a/tests/components/enphase_envoy/test_select.py b/tests/components/enphase_envoy/test_select.py index 9b3a63d1e23..e13492c7f54 100644 --- a/tests/components/enphase_envoy/test_select.py +++ b/tests/components/enphase_envoy/test_select.py @@ -7,15 +7,12 @@ from syrupy.assertion import SnapshotAssertion from homeassistant.components.enphase_envoy.const import Platform from homeassistant.components.enphase_envoy.select import ( - ACTION_OPTIONS, - MODE_OPTIONS, RELAY_ACTION_MAP, RELAY_MODE_MAP, REVERSE_RELAY_ACTION_MAP, REVERSE_RELAY_MODE_MAP, REVERSE_STORAGE_MODE_MAP, STORAGE_MODE_MAP, - STORAGE_MODE_OPTIONS, ) from homeassistant.components.select import ATTR_OPTION, DOMAIN as SELECT_DOMAIN from homeassistant.const import ATTR_ENTITY_ID, SERVICE_SELECT_OPTION @@ -28,9 +25,9 @@ from tests.common import MockConfigEntry, snapshot_platform @pytest.mark.parametrize( - ("mock_envoy"), + "mock_envoy", ["envoy_metered_batt_relay", "envoy_eu_batt"], - indirect=["mock_envoy"], + indirect=True, ) @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_select( @@ -47,14 +44,14 @@ async def test_select( @pytest.mark.parametrize( - ("mock_envoy"), + "mock_envoy", [ "envoy", "envoy_1p_metered", "envoy_nobatt_metered_3p", "envoy_tot_cons_metered", ], - indirect=["mock_envoy"], + indirect=True, ) async def test_no_select( hass: HomeAssistant, @@ -68,13 +65,31 @@ async def test_no_select( assert not er.async_entries_for_config_entry(entity_registry, config_entry.entry_id) +@pytest.mark.parametrize("mock_envoy", ["envoy_metered_batt_relay"], indirect=True) @pytest.mark.parametrize( - ("mock_envoy"), ["envoy_metered_batt_relay"], indirect=["mock_envoy"] + ("relay", "target", "expected_state", "call_parameter"), + [ + ("NC1", "generator_action", "shed", "generator_action"), + ("NC1", "microgrid_action", "shed", "micro_grid_action"), + ("NC1", "grid_action", "shed", "grid_action"), + ("NC2", "generator_action", "shed", "generator_action"), + ("NC2", "microgrid_action", "shed", "micro_grid_action"), + ("NC2", "grid_action", "apply", "grid_action"), + ("NC3", "generator_action", "apply", "generator_action"), + ("NC3", "microgrid_action", "apply", "micro_grid_action"), + ("NC3", "grid_action", "shed", "grid_action"), + ], ) +@pytest.mark.parametrize("action", ["powered", "not_powered", "schedule", "none"]) async def test_select_relay_actions( hass: HomeAssistant, mock_envoy: AsyncMock, config_entry: MockConfigEntry, + target: str, + expected_state: str, + call_parameter: str, + relay: str, + action: str, ) -> None: """Test select platform entities dry contact relay actions.""" with patch("homeassistant.components.enphase_envoy.PLATFORMS", [Platform.SELECT]): @@ -82,54 +97,37 @@ async def test_select_relay_actions( entity_base = f"{Platform.SELECT}." - for contact_id, dry_contact in mock_envoy.data.dry_contact_settings.items(): - name = dry_contact.load_name.lower().replace(" ", "_") - for target in ( - ("generator_action", dry_contact.generator_action, "generator_action"), - ("microgrid_action", dry_contact.micro_grid_action, "micro_grid_action"), - ("grid_action", dry_contact.grid_action, "grid_action"), - ): - test_entity = f"{entity_base}{name}_{target[0]}" - assert (entity_state := hass.states.get(test_entity)) - assert RELAY_ACTION_MAP[target[1]] == (current_state := entity_state.state) - # set all relay modes except current mode - for action in [action for action in ACTION_OPTIONS if not current_state]: - await hass.services.async_call( - SELECT_DOMAIN, - SERVICE_SELECT_OPTION, - { - ATTR_ENTITY_ID: test_entity, - ATTR_OPTION: action, - }, - blocking=True, - ) - mock_envoy.update_dry_contact.assert_called_once_with( - {"id": contact_id, target[2]: REVERSE_RELAY_ACTION_MAP[action]} - ) - mock_envoy.update_dry_contact.reset_mock() - # and finally back to original - await hass.services.async_call( - SELECT_DOMAIN, - SERVICE_SELECT_OPTION, - { - ATTR_ENTITY_ID: test_entity, - ATTR_OPTION: current_state, - }, - blocking=True, - ) - mock_envoy.update_dry_contact.assert_called_once_with( - {"id": contact_id, target[2]: REVERSE_RELAY_ACTION_MAP[current_state]} - ) - mock_envoy.update_dry_contact.reset_mock() + assert (dry_contact := mock_envoy.data.dry_contact_settings[relay]) + assert (name := dry_contact.load_name.lower().replace(" ", "_")) + + test_entity = f"{entity_base}{name}_{target}" + + assert (entity_state := hass.states.get(test_entity)) + assert entity_state.state == RELAY_ACTION_MAP[expected_state] + + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: test_entity, + ATTR_OPTION: action, + }, + blocking=True, + ) + mock_envoy.update_dry_contact.assert_called_once_with( + {"id": relay, call_parameter: REVERSE_RELAY_ACTION_MAP[action]} + ) -@pytest.mark.parametrize( - ("mock_envoy"), ["envoy_metered_batt_relay"], indirect=["mock_envoy"] -) +@pytest.mark.parametrize("mock_envoy", ["envoy_metered_batt_relay"], indirect=True) +@pytest.mark.parametrize("relay_mode", ["battery", "standard"]) +@pytest.mark.parametrize("relay", ["NC1", "NC2", "NC3"]) async def test_select_relay_modes( hass: HomeAssistant, mock_envoy: AsyncMock, config_entry: MockConfigEntry, + relay_mode: str, + relay: str, ) -> None: """Test select platform dry contact relay mode changes.""" with patch("homeassistant.components.enphase_envoy.PLATFORMS", [Platform.SELECT]): @@ -137,40 +135,26 @@ async def test_select_relay_modes( entity_base = f"{Platform.SELECT}." - for contact_id, dry_contact in mock_envoy.data.dry_contact_settings.items(): - name = dry_contact.load_name.lower().replace(" ", "_") - test_entity = f"{entity_base}{name}_mode" - assert (entity_state := hass.states.get(test_entity)) - assert RELAY_MODE_MAP[dry_contact.mode] == (current_state := entity_state.state) - for mode in [mode for mode in MODE_OPTIONS if not current_state]: - await hass.services.async_call( - SELECT_DOMAIN, - SERVICE_SELECT_OPTION, - { - ATTR_ENTITY_ID: test_entity, - ATTR_OPTION: mode, - }, - blocking=True, - ) - mock_envoy.update_dry_contact.assert_called_once_with( - {"id": contact_id, "mode": REVERSE_RELAY_MODE_MAP[mode]} - ) - mock_envoy.update_dry_contact.reset_mock() + assert (dry_contact := mock_envoy.data.dry_contact_settings[relay]) + assert (name := dry_contact.load_name.lower().replace(" ", "_")) - # and finally current mode again - await hass.services.async_call( - SELECT_DOMAIN, - SERVICE_SELECT_OPTION, - { - ATTR_ENTITY_ID: test_entity, - ATTR_OPTION: current_state, - }, - blocking=True, - ) - mock_envoy.update_dry_contact.assert_called_once_with( - {"id": contact_id, "mode": REVERSE_RELAY_MODE_MAP[current_state]} - ) - mock_envoy.update_dry_contact.reset_mock() + test_entity = f"{entity_base}{name}_mode" + + assert (entity_state := hass.states.get(test_entity)) + assert entity_state.state == RELAY_MODE_MAP[dry_contact.mode] + + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: test_entity, + ATTR_OPTION: relay_mode, + }, + blocking=True, + ) + mock_envoy.update_dry_contact.assert_called_once_with( + {"id": relay, "mode": REVERSE_RELAY_MODE_MAP[relay_mode]} + ) @pytest.mark.parametrize( @@ -181,11 +165,13 @@ async def test_select_relay_modes( ], indirect=["mock_envoy"], ) +@pytest.mark.parametrize(("mode"), ["backup", "self_consumption", "savings"]) async def test_select_storage_modes( hass: HomeAssistant, mock_envoy: AsyncMock, config_entry: MockConfigEntry, use_serial: str, + mode: str, ) -> None: """Test select platform entities storage mode changes.""" with patch("homeassistant.components.enphase_envoy.PLATFORMS", [Platform.SELECT]): @@ -194,38 +180,21 @@ async def test_select_storage_modes( test_entity = f"{Platform.SELECT}.{use_serial}_storage_mode" assert (entity_state := hass.states.get(test_entity)) - assert STORAGE_MODE_MAP[mock_envoy.data.tariff.storage_settings.mode] == ( - current_state := entity_state.state + assert ( + entity_state.state + == STORAGE_MODE_MAP[mock_envoy.data.tariff.storage_settings.mode] ) - for mode in [mode for mode in STORAGE_MODE_OPTIONS if not current_state]: - await hass.services.async_call( - SELECT_DOMAIN, - SERVICE_SELECT_OPTION, - { - ATTR_ENTITY_ID: test_entity, - ATTR_OPTION: mode, - }, - blocking=True, - ) - mock_envoy.set_storage_mode.assert_called_once_with( - REVERSE_STORAGE_MODE_MAP[mode] - ) - mock_envoy.set_storage_mode.reset_mock() - - # and finally with original mode await hass.services.async_call( SELECT_DOMAIN, SERVICE_SELECT_OPTION, { ATTR_ENTITY_ID: test_entity, - ATTR_OPTION: current_state, + ATTR_OPTION: mode, }, blocking=True, ) - mock_envoy.set_storage_mode.assert_called_once_with( - REVERSE_STORAGE_MODE_MAP[current_state] - ) + mock_envoy.set_storage_mode.assert_called_once_with(REVERSE_STORAGE_MODE_MAP[mode]) @pytest.mark.parametrize( From b9c3548b5a5a0a517adfde1646f0492bcb46852c Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Mon, 27 Jan 2025 16:42:22 +0100 Subject: [PATCH 0066/3148] Change discovery schema for Matter Identify button to ignore type of None (#136621) --- homeassistant/components/matter/button.py | 4 +- .../matter/snapshots/test_button.ambr | 1274 +---------------- 2 files changed, 28 insertions(+), 1250 deletions(-) diff --git a/homeassistant/components/matter/button.py b/homeassistant/components/matter/button.py index 153124a4f7e..2c5e641e640 100644 --- a/homeassistant/components/matter/button.py +++ b/homeassistant/components/matter/button.py @@ -67,8 +67,8 @@ DISCOVERY_SCHEMAS = [ command=lambda: clusters.Identify.Commands.Identify(identifyTime=15), ), entity_class=MatterCommandButton, - required_attributes=(clusters.Identify.Attributes.AcceptedCommandList,), - value_contains=clusters.Identify.Commands.Identify.command_id, + required_attributes=(clusters.Identify.Attributes.IdentifyType,), + value_is_not=clusters.Identify.Enums.IdentifyTypeEnum.kNone, allow_multi=True, ), MatterDiscoverySchema( diff --git a/tests/components/matter/snapshots/test_button.ambr b/tests/components/matter/snapshots/test_button.ambr index bcba0da808e..7973f1a5147 100644 --- a/tests/components/matter/snapshots/test_button.ambr +++ b/tests/components/matter/snapshots/test_button.ambr @@ -1,239 +1,4 @@ # serializer version: 1 -# name: test_buttons[air_purifier][button.air_purifier_identify_1-entry] - 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.air_purifier_identify_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': 'Identify (1)', - 'platform': 'matter', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '00000000000004D2-000000000000008F-MatterNodeDevice-1-IdentifyButton-3-65529', - 'unit_of_measurement': None, - }) -# --- -# name: test_buttons[air_purifier][button.air_purifier_identify_1-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'identify', - 'friendly_name': 'Air Purifier Identify (1)', - }), - 'context': , - 'entity_id': 'button.air_purifier_identify_1', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_buttons[air_purifier][button.air_purifier_identify_2-entry] - 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.air_purifier_identify_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': 'Identify (2)', - 'platform': 'matter', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '00000000000004D2-000000000000008F-MatterNodeDevice-2-IdentifyButton-3-65529', - 'unit_of_measurement': None, - }) -# --- -# name: test_buttons[air_purifier][button.air_purifier_identify_2-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'identify', - 'friendly_name': 'Air Purifier Identify (2)', - }), - 'context': , - 'entity_id': 'button.air_purifier_identify_2', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_buttons[air_purifier][button.air_purifier_identify_3-entry] - 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.air_purifier_identify_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': 'Identify (3)', - 'platform': 'matter', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '00000000000004D2-000000000000008F-MatterNodeDevice-3-IdentifyButton-3-65529', - 'unit_of_measurement': None, - }) -# --- -# name: test_buttons[air_purifier][button.air_purifier_identify_3-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'identify', - 'friendly_name': 'Air Purifier Identify (3)', - }), - 'context': , - 'entity_id': 'button.air_purifier_identify_3', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_buttons[air_purifier][button.air_purifier_identify_4-entry] - 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.air_purifier_identify_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': 'Identify (4)', - 'platform': 'matter', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '00000000000004D2-000000000000008F-MatterNodeDevice-4-IdentifyButton-3-65529', - 'unit_of_measurement': None, - }) -# --- -# name: test_buttons[air_purifier][button.air_purifier_identify_4-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'identify', - 'friendly_name': 'Air Purifier Identify (4)', - }), - 'context': , - 'entity_id': 'button.air_purifier_identify_4', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_buttons[air_purifier][button.air_purifier_identify_5-entry] - 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.air_purifier_identify_5', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Identify (5)', - 'platform': 'matter', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '00000000000004D2-000000000000008F-MatterNodeDevice-5-IdentifyButton-3-65529', - 'unit_of_measurement': None, - }) -# --- -# name: test_buttons[air_purifier][button.air_purifier_identify_5-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'identify', - 'friendly_name': 'Air Purifier Identify (5)', - }), - 'context': , - 'entity_id': 'button.air_purifier_identify_5', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- # name: test_buttons[air_purifier][button.air_purifier_reset_filter_condition-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -326,53 +91,6 @@ 'state': 'unknown', }) # --- -# name: test_buttons[air_quality_sensor][button.lightfi_aq1_air_quality_sensor_identify-entry] - 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.lightfi_aq1_air_quality_sensor_identify', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Identify', - 'platform': 'matter', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-IdentifyButton-3-65529', - 'unit_of_measurement': None, - }) -# --- -# name: test_buttons[air_quality_sensor][button.lightfi_aq1_air_quality_sensor_identify-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'identify', - 'friendly_name': 'lightfi-aq1-air-quality-sensor Identify', - }), - 'context': , - 'entity_id': 'button.lightfi_aq1_air_quality_sensor_identify', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- # name: test_buttons[color_temperature_light][button.mock_color_temperature_light_identify-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -402,7 +120,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-IdentifyButton-3-65529', + 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-IdentifyButton-3-1', 'unit_of_measurement': None, }) # --- @@ -449,7 +167,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '00000000000004D2-0000000000000024-MatterNodeDevice-1-IdentifyButton-3-65529', + 'unique_id': '00000000000004D2-0000000000000024-MatterNodeDevice-1-IdentifyButton-3-1', 'unit_of_measurement': None, }) # --- @@ -467,100 +185,6 @@ 'state': 'unknown', }) # --- -# name: test_buttons[door_lock][button.mock_door_lock_identify-entry] - 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_door_lock_identify', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Identify', - 'platform': 'matter', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-IdentifyButton-3-65529', - 'unit_of_measurement': None, - }) -# --- -# name: test_buttons[door_lock][button.mock_door_lock_identify-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'identify', - 'friendly_name': 'Mock Door Lock Identify', - }), - 'context': , - 'entity_id': 'button.mock_door_lock_identify', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_buttons[door_lock_with_unbolt][button.mock_door_lock_identify-entry] - 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_door_lock_identify', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Identify', - 'platform': 'matter', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-IdentifyButton-3-65529', - 'unit_of_measurement': None, - }) -# --- -# name: test_buttons[door_lock_with_unbolt][button.mock_door_lock_identify-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'identify', - 'friendly_name': 'Mock Door Lock Identify', - }), - 'context': , - 'entity_id': 'button.mock_door_lock_identify', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- # name: test_buttons[eve_contact_sensor][button.eve_door_identify-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -590,7 +214,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-IdentifyButton-3-65529', + 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-IdentifyButton-3-1', 'unit_of_measurement': None, }) # --- @@ -637,7 +261,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '00000000000004D2-0000000000000053-MatterNodeDevice-1-IdentifyButton-3-65529', + 'unique_id': '00000000000004D2-0000000000000053-MatterNodeDevice-1-IdentifyButton-3-1', 'unit_of_measurement': None, }) # --- @@ -684,7 +308,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '00000000000004D2-00000000000000B7-MatterNodeDevice-1-IdentifyButton-3-65529', + 'unique_id': '00000000000004D2-00000000000000B7-MatterNodeDevice-1-IdentifyButton-3-1', 'unit_of_measurement': None, }) # --- @@ -731,7 +355,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '00000000000004D2-0000000000000021-MatterNodeDevice-1-IdentifyButton-3-65529', + 'unique_id': '00000000000004D2-0000000000000021-MatterNodeDevice-1-IdentifyButton-3-1', 'unit_of_measurement': None, }) # --- @@ -778,7 +402,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '00000000000004D2-000000000000001D-MatterNodeDevice-1-IdentifyButton-3-65529', + 'unique_id': '00000000000004D2-000000000000001D-MatterNodeDevice-1-IdentifyButton-3-1', 'unit_of_measurement': None, }) # --- @@ -825,7 +449,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '00000000000004D2-000000000000001D-MatterNodeDevice-2-IdentifyButton-3-65529', + 'unique_id': '00000000000004D2-000000000000001D-MatterNodeDevice-2-IdentifyButton-3-1', 'unit_of_measurement': None, }) # --- @@ -872,7 +496,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-IdentifyButton-3-65529', + 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-IdentifyButton-3-1', 'unit_of_measurement': None, }) # --- @@ -919,7 +543,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '00000000000004D2-000000000000001D-MatterNodeDevice-1-IdentifyButton-3-65529', + 'unique_id': '00000000000004D2-000000000000001D-MatterNodeDevice-1-IdentifyButton-3-1', 'unit_of_measurement': None, }) # --- @@ -937,335 +561,6 @@ 'state': 'unknown', }) # --- -# name: test_buttons[flow_sensor][button.mock_flow_sensor_identify-entry] - 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_flow_sensor_identify', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Identify', - 'platform': 'matter', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-IdentifyButton-3-65529', - 'unit_of_measurement': None, - }) -# --- -# name: test_buttons[flow_sensor][button.mock_flow_sensor_identify-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'identify', - 'friendly_name': 'Mock Flow Sensor Identify', - }), - 'context': , - 'entity_id': 'button.mock_flow_sensor_identify', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_buttons[generic_switch][button.mock_generic_switch_identify-entry] - 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_generic_switch_identify', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Identify', - 'platform': 'matter', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-IdentifyButton-3-65529', - 'unit_of_measurement': None, - }) -# --- -# name: test_buttons[generic_switch][button.mock_generic_switch_identify-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'identify', - 'friendly_name': 'Mock Generic Switch Identify', - }), - 'context': , - 'entity_id': 'button.mock_generic_switch_identify', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_buttons[generic_switch_multi][button.mock_generic_switch_fancy_button-entry] - 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_generic_switch_fancy_button', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Fancy Button', - 'platform': 'matter', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-2-IdentifyButton-3-65529', - 'unit_of_measurement': None, - }) -# --- -# name: test_buttons[generic_switch_multi][button.mock_generic_switch_fancy_button-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'identify', - 'friendly_name': 'Mock Generic Switch Fancy Button', - }), - 'context': , - 'entity_id': 'button.mock_generic_switch_fancy_button', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_buttons[generic_switch_multi][button.mock_generic_switch_identify_1-entry] - 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_generic_switch_identify_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': 'Identify (1)', - 'platform': 'matter', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-IdentifyButton-3-65529', - 'unit_of_measurement': None, - }) -# --- -# name: test_buttons[generic_switch_multi][button.mock_generic_switch_identify_1-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'identify', - 'friendly_name': 'Mock Generic Switch Identify (1)', - }), - 'context': , - 'entity_id': 'button.mock_generic_switch_identify_1', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_buttons[humidity_sensor][button.mock_humidity_sensor_identify-entry] - 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_humidity_sensor_identify', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Identify', - 'platform': 'matter', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-IdentifyButton-3-65529', - 'unit_of_measurement': None, - }) -# --- -# name: test_buttons[humidity_sensor][button.mock_humidity_sensor_identify-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'identify', - 'friendly_name': 'Mock Humidity Sensor Identify', - }), - 'context': , - 'entity_id': 'button.mock_humidity_sensor_identify', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_buttons[light_sensor][button.mock_light_sensor_identify-entry] - 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_light_sensor_identify', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Identify', - 'platform': 'matter', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-IdentifyButton-3-65529', - 'unit_of_measurement': None, - }) -# --- -# name: test_buttons[light_sensor][button.mock_light_sensor_identify-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'identify', - 'friendly_name': 'Mock Light Sensor Identify', - }), - 'context': , - 'entity_id': 'button.mock_light_sensor_identify', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_buttons[microwave_oven][button.microwave_oven_identify-entry] - 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.microwave_oven_identify', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Identify', - 'platform': 'matter', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '00000000000004D2-000000000000009D-MatterNodeDevice-1-IdentifyButton-3-65529', - 'unit_of_measurement': None, - }) -# --- -# name: test_buttons[microwave_oven][button.microwave_oven_identify-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'identify', - 'friendly_name': 'Microwave Oven Identify', - }), - 'context': , - 'entity_id': 'button.microwave_oven_identify', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- # name: test_buttons[microwave_oven][button.microwave_oven_pause-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1479,7 +774,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '00000000000004D2-00000000000000C5-MatterNodeDevice-5-IdentifyButton-3-65529', + 'unique_id': '00000000000004D2-00000000000000C5-MatterNodeDevice-5-IdentifyButton-3-1', 'unit_of_measurement': None, }) # --- @@ -1526,7 +821,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '00000000000004D2-00000000000000C5-MatterNodeDevice-4-IdentifyButton-3-65529', + 'unique_id': '00000000000004D2-00000000000000C5-MatterNodeDevice-4-IdentifyButton-3-1', 'unit_of_measurement': None, }) # --- @@ -1573,7 +868,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '00000000000004D2-00000000000000C5-MatterNodeDevice-1-IdentifyButton-3-65529', + 'unique_id': '00000000000004D2-00000000000000C5-MatterNodeDevice-1-IdentifyButton-3-1', 'unit_of_measurement': None, }) # --- @@ -1620,7 +915,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '00000000000004D2-00000000000000C5-MatterNodeDevice-2-IdentifyButton-3-65529', + 'unique_id': '00000000000004D2-00000000000000C5-MatterNodeDevice-2-IdentifyButton-3-1', 'unit_of_measurement': None, }) # --- @@ -1667,7 +962,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '00000000000004D2-00000000000000C5-MatterNodeDevice-6-IdentifyButton-3-65529', + 'unique_id': '00000000000004D2-00000000000000C5-MatterNodeDevice-6-IdentifyButton-3-1', 'unit_of_measurement': None, }) # --- @@ -1714,7 +1009,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '00000000000004D2-00000000000000C5-MatterNodeDevice-3-IdentifyButton-3-65529', + 'unique_id': '00000000000004D2-00000000000000C5-MatterNodeDevice-3-IdentifyButton-3-1', 'unit_of_measurement': None, }) # --- @@ -1761,7 +1056,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-IdentifyButton-3-65529', + 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-IdentifyButton-3-1', 'unit_of_measurement': None, }) # --- @@ -1808,7 +1103,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-IdentifyButton-3-65529', + 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-IdentifyButton-3-1', 'unit_of_measurement': None, }) # --- @@ -1826,147 +1121,6 @@ 'state': 'unknown', }) # --- -# name: test_buttons[onoff_light][button.mock_onoff_light_identify-entry] - 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_onoff_light_identify', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Identify', - 'platform': 'matter', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-IdentifyButton-3-65529', - 'unit_of_measurement': None, - }) -# --- -# name: test_buttons[onoff_light][button.mock_onoff_light_identify-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'identify', - 'friendly_name': 'Mock OnOff Light Identify', - }), - 'context': , - 'entity_id': 'button.mock_onoff_light_identify', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_buttons[onoff_light_alt_name][button.mock_onoff_light_identify-entry] - 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_onoff_light_identify', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Identify', - 'platform': 'matter', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-IdentifyButton-3-65529', - 'unit_of_measurement': None, - }) -# --- -# name: test_buttons[onoff_light_alt_name][button.mock_onoff_light_identify-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'identify', - 'friendly_name': 'Mock OnOff Light Identify', - }), - 'context': , - 'entity_id': 'button.mock_onoff_light_identify', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_buttons[onoff_light_no_name][button.mock_light_identify-entry] - 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_light_identify', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Identify', - 'platform': 'matter', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-IdentifyButton-3-65529', - 'unit_of_measurement': None, - }) -# --- -# name: test_buttons[onoff_light_no_name][button.mock_light_identify-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'identify', - 'friendly_name': 'Mock Light Identify', - }), - 'context': , - 'entity_id': 'button.mock_light_identify', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- # name: test_buttons[onoff_light_with_levelcontrol_present][button.d215s_identify-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1996,7 +1150,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '00000000000004D2-0000000000000008-MatterNodeDevice-1-IdentifyButton-3-65529', + 'unique_id': '00000000000004D2-0000000000000008-MatterNodeDevice-1-IdentifyButton-3-1', 'unit_of_measurement': None, }) # --- @@ -2014,147 +1168,6 @@ 'state': 'unknown', }) # --- -# name: test_buttons[pressure_sensor][button.mock_pressure_sensor_identify-entry] - 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_pressure_sensor_identify', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Identify', - 'platform': 'matter', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-IdentifyButton-3-65529', - 'unit_of_measurement': None, - }) -# --- -# name: test_buttons[pressure_sensor][button.mock_pressure_sensor_identify-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'identify', - 'friendly_name': 'Mock Pressure Sensor Identify', - }), - 'context': , - 'entity_id': 'button.mock_pressure_sensor_identify', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_buttons[room_airconditioner][button.room_airconditioner_identify_1-entry] - 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.room_airconditioner_identify_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': 'Identify (1)', - 'platform': 'matter', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '00000000000004D2-0000000000000024-MatterNodeDevice-1-IdentifyButton-3-65529', - 'unit_of_measurement': None, - }) -# --- -# name: test_buttons[room_airconditioner][button.room_airconditioner_identify_1-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'identify', - 'friendly_name': 'Room AirConditioner Identify (1)', - }), - 'context': , - 'entity_id': 'button.room_airconditioner_identify_1', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_buttons[room_airconditioner][button.room_airconditioner_identify_2-entry] - 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.room_airconditioner_identify_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': 'Identify (2)', - 'platform': 'matter', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '00000000000004D2-0000000000000024-MatterNodeDevice-2-IdentifyButton-3-65529', - 'unit_of_measurement': None, - }) -# --- -# name: test_buttons[room_airconditioner][button.room_airconditioner_identify_2-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'identify', - 'friendly_name': 'Room AirConditioner Identify (2)', - }), - 'context': , - 'entity_id': 'button.room_airconditioner_identify_2', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- # name: test_buttons[silabs_dishwasher][button.dishwasher_identify-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2184,7 +1197,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '00000000000004D2-0000000000000036-MatterNodeDevice-1-IdentifyButton-3-65529', + 'unique_id': '00000000000004D2-0000000000000036-MatterNodeDevice-1-IdentifyButton-3-1', 'unit_of_measurement': None, }) # --- @@ -2369,7 +1382,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '00000000000004D2-000000000000001D-MatterNodeDevice-1-IdentifyButton-3-65529', + 'unique_id': '00000000000004D2-000000000000001D-MatterNodeDevice-1-IdentifyButton-3-1', 'unit_of_measurement': None, }) # --- @@ -2600,7 +1613,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-IdentifyButton-3-65529', + 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-IdentifyButton-3-1', 'unit_of_measurement': None, }) # --- @@ -2647,7 +1660,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-IdentifyButton-3-65529', + 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-IdentifyButton-3-1', 'unit_of_measurement': None, }) # --- @@ -2694,7 +1707,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-IdentifyButton-3-65529', + 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-0-IdentifyButton-3-1', 'unit_of_measurement': None, }) # --- @@ -2741,7 +1754,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '00000000000004D2-0000000000000004-MatterNodeDevice-1-IdentifyButton-3-65529', + 'unique_id': '00000000000004D2-0000000000000004-MatterNodeDevice-1-IdentifyButton-3-1', 'unit_of_measurement': None, }) # --- @@ -2759,147 +1772,6 @@ 'state': 'unknown', }) # --- -# name: test_buttons[valve][button.valve_identify-entry] - 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.valve_identify', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Identify', - 'platform': 'matter', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '00000000000004D2-000000000000004B-MatterNodeDevice-1-IdentifyButton-3-65529', - 'unit_of_measurement': None, - }) -# --- -# name: test_buttons[valve][button.valve_identify-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'identify', - 'friendly_name': 'Valve Identify', - }), - 'context': , - 'entity_id': 'button.valve_identify', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_buttons[window_covering_full][button.mock_full_window_covering_identify-entry] - 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_full_window_covering_identify', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Identify', - 'platform': 'matter', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '00000000000004D2-0000000000000032-MatterNodeDevice-1-IdentifyButton-3-65529', - 'unit_of_measurement': None, - }) -# --- -# name: test_buttons[window_covering_full][button.mock_full_window_covering_identify-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'identify', - 'friendly_name': 'Mock Full Window Covering Identify', - }), - 'context': , - 'entity_id': 'button.mock_full_window_covering_identify', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_buttons[window_covering_lift][button.mock_lift_window_covering_identify-entry] - 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_lift_window_covering_identify', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Identify', - 'platform': 'matter', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '00000000000004D2-0000000000000032-MatterNodeDevice-1-IdentifyButton-3-65529', - 'unit_of_measurement': None, - }) -# --- -# name: test_buttons[window_covering_lift][button.mock_lift_window_covering_identify-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'identify', - 'friendly_name': 'Mock Lift Window Covering Identify', - }), - 'context': , - 'entity_id': 'button.mock_lift_window_covering_identify', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- # name: test_buttons[window_covering_pa_lift][button.longan_link_wncv_da01_identify-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2929,7 +1801,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-IdentifyButton-3-65529', + 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-IdentifyButton-3-1', 'unit_of_measurement': None, }) # --- @@ -2947,97 +1819,3 @@ 'state': 'unknown', }) # --- -# name: test_buttons[window_covering_pa_tilt][button.mock_pa_tilt_window_covering_identify-entry] - 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_pa_tilt_window_covering_identify', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Identify', - 'platform': 'matter', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '00000000000004D2-0000000000000032-MatterNodeDevice-1-IdentifyButton-3-65529', - 'unit_of_measurement': None, - }) -# --- -# name: test_buttons[window_covering_pa_tilt][button.mock_pa_tilt_window_covering_identify-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'identify', - 'friendly_name': 'Mock PA Tilt Window Covering Identify', - }), - 'context': , - 'entity_id': 'button.mock_pa_tilt_window_covering_identify', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_buttons[window_covering_tilt][button.mock_tilt_window_covering_identify-entry] - 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_tilt_window_covering_identify', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Identify', - 'platform': 'matter', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '00000000000004D2-0000000000000032-MatterNodeDevice-1-IdentifyButton-3-65529', - 'unit_of_measurement': None, - }) -# --- -# name: test_buttons[window_covering_tilt][button.mock_tilt_window_covering_identify-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'identify', - 'friendly_name': 'Mock Tilt Window Covering Identify', - }), - 'context': , - 'entity_id': 'button.mock_tilt_window_covering_identify', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- From fc75d939eb424bd593148d8132a343ddf78ab7e8 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 27 Jan 2025 16:43:02 +0100 Subject: [PATCH 0067/3148] Fix spelling of "Hub" and sentence-casing of "options" (#136573) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix spelling of "Hub" and sentence-casing of "options" * Change "the change channel command" to "a …" Co-authored-by: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> --------- Co-authored-by: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> --- homeassistant/components/harmony/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/harmony/strings.json b/homeassistant/components/harmony/strings.json index e13573a9ea3..577eb308d78 100644 --- a/homeassistant/components/harmony/strings.json +++ b/homeassistant/components/harmony/strings.json @@ -28,7 +28,7 @@ "options": { "step": { "init": { - "description": "Adjust Harmony Hub Options", + "description": "Adjust Harmony Hub options", "data": { "activity": "The default activity to execute when none is specified.", "delay_secs": "The delay between sending commands." @@ -53,7 +53,7 @@ }, "change_channel": { "name": "Change channel", - "description": "Sends change channel command to the Harmony HUB.", + "description": "Sends a change channel command to the Harmony Hub.", "fields": { "channel": { "name": "Channel", From a2830e7ebb574671b3552bffe18adc333f17591b Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Mon, 27 Jan 2025 15:46:32 +0000 Subject: [PATCH 0068/3148] Add config flow data descriptions to ring integration (#136464) * Add config flow data descriptions to ring integration * Change Ring cloud to Ring account * Revert config_flow change --- homeassistant/components/ring/strings.json | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/homeassistant/components/ring/strings.json b/homeassistant/components/ring/strings.json index 8170ec8e161..1f146bcf358 100644 --- a/homeassistant/components/ring/strings.json +++ b/homeassistant/components/ring/strings.json @@ -6,12 +6,19 @@ "data": { "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "username": "Your Ring account username.", + "password": "Your Ring account password." } }, "2fa": { "title": "Two-factor authentication", "data": { "2fa": "Two-factor code" + }, + "data_description": { + "2fa": "Account verification code via the method selected in your ring account settings." } }, "reauth_confirm": { @@ -19,6 +26,9 @@ "description": "The Ring integration needs to re-authenticate your account {username}", "data": { "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "password": "[%key:component::ring::config::step::user::data_description::password%]" } }, "reconfigure": { @@ -26,6 +36,9 @@ "description": "Will create a new Authorized Device for {username} at ring.com", "data": { "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "password": "[%key:component::ring::config::step::user::data_description::password%]" } } }, From 7c87bb2ffb69cbf8a40492f124068528d50e63b1 Mon Sep 17 00:00:00 2001 From: Splint77 Date: Mon, 27 Jan 2025 16:53:26 +0100 Subject: [PATCH 0069/3148] Twinkly RGBW color fixed (#136593) --- homeassistant/components/twinkly/light.py | 2 +- tests/components/twinkly/test_light.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/twinkly/light.py b/homeassistant/components/twinkly/light.py index de55aa5f217..31e95d70fc0 100644 --- a/homeassistant/components/twinkly/light.py +++ b/homeassistant/components/twinkly/light.py @@ -99,9 +99,9 @@ class TwinklyLight(TwinklyEntity, LightEntity): ): await self.client.interview() if LightEntityFeature.EFFECT & self.supported_features: - # Static color only supports rgb await self.client.set_static_colour( ( + kwargs[ATTR_RGBW_COLOR][3], kwargs[ATTR_RGBW_COLOR][0], kwargs[ATTR_RGBW_COLOR][1], kwargs[ATTR_RGBW_COLOR][2], diff --git a/tests/components/twinkly/test_light.py b/tests/components/twinkly/test_light.py index acf30764bab..f8289cb95e3 100644 --- a/tests/components/twinkly/test_light.py +++ b/tests/components/twinkly/test_light.py @@ -140,7 +140,7 @@ async def test_turn_on_with_color_rgbw( ) mock_twinkly_client.interview.assert_called_once_with() - mock_twinkly_client.set_static_colour.assert_called_once_with((128, 64, 32)) + mock_twinkly_client.set_static_colour.assert_called_once_with((0, 128, 64, 32)) mock_twinkly_client.set_mode.assert_called_once_with("color") assert mock_twinkly_client.default_mode == "color" From 5faf2fd66ccc2e6de90012e92f3ad790e3683134 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 27 Jan 2025 17:15:05 +0100 Subject: [PATCH 0070/3148] Replace "bosch_shc" with friendly name of integration (#136410) --- homeassistant/components/bosch_shc/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/bosch_shc/strings.json b/homeassistant/components/bosch_shc/strings.json index 88eb817bbd9..7aa3b0ace32 100644 --- a/homeassistant/components/bosch_shc/strings.json +++ b/homeassistant/components/bosch_shc/strings.json @@ -24,7 +24,7 @@ }, "reauth_confirm": { "title": "[%key:common::config_flow::title::reauth%]", - "description": "The bosch_shc integration needs to re-authenticate your account", + "description": "The Bosch SHC integration needs to re-authenticate your account", "data": { "host": "[%key:common::config_flow::data::host%]" } @@ -34,7 +34,7 @@ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "pairing_failed": "Pairing failed; please check the Bosch Smart Home Controller is in pairing mode (LED flashing) as well as your password is correct.", - "session_error": "Session error: API return Non-OK result.", + "session_error": "Session error: API returned Non-OK result.", "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { From d2138fe45bd514f5eed5acc5f92894ff4013e5a4 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 27 Jan 2025 17:28:45 +0100 Subject: [PATCH 0071/3148] Bump securetar to 2025.1.4 (#136639) --- homeassistant/components/backup/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/backup/manifest.json b/homeassistant/components/backup/manifest.json index ffaed260c88..6cbfb834c7f 100644 --- a/homeassistant/components/backup/manifest.json +++ b/homeassistant/components/backup/manifest.json @@ -8,5 +8,5 @@ "integration_type": "system", "iot_class": "calculated", "quality_scale": "internal", - "requirements": ["cronsim==2.6", "securetar==2025.1.3"] + "requirements": ["cronsim==2.6", "securetar==2025.1.4"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index cb29214390b..2959e8bf322 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -60,7 +60,7 @@ PyTurboJPEG==1.7.5 pyudev==0.24.1 PyYAML==6.0.2 requests==2.32.3 -securetar==2025.1.3 +securetar==2025.1.4 SQLAlchemy==2.0.36 standard-aifc==3.13.0;python_version>='3.13' standard-telnetlib==3.13.0;python_version>='3.13' diff --git a/pyproject.toml b/pyproject.toml index 56f2533840a..0e67a78954b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -66,7 +66,7 @@ dependencies = [ "python-slugify==8.0.4", "PyYAML==6.0.2", "requests==2.32.3", - "securetar==2025.1.3", + "securetar==2025.1.4", "SQLAlchemy==2.0.36", "standard-aifc==3.13.0;python_version>='3.13'", "standard-telnetlib==3.13.0;python_version>='3.13'", diff --git a/requirements.txt b/requirements.txt index f1eb8dac825..2ffb530393e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -38,7 +38,7 @@ psutil-home-assistant==0.0.1 python-slugify==8.0.4 PyYAML==6.0.2 requests==2.32.3 -securetar==2025.1.3 +securetar==2025.1.4 SQLAlchemy==2.0.36 standard-aifc==3.13.0;python_version>='3.13' standard-telnetlib==3.13.0;python_version>='3.13' diff --git a/requirements_all.txt b/requirements_all.txt index 96f53acf13d..1a86e1b0560 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2665,7 +2665,7 @@ screenlogicpy==0.10.0 scsgate==0.1.0 # homeassistant.components.backup -securetar==2025.1.3 +securetar==2025.1.4 # homeassistant.components.sendgrid sendgrid==6.8.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5ecdd37fe58..9dcae1ea28a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2147,7 +2147,7 @@ sanix==1.0.6 screenlogicpy==0.10.0 # homeassistant.components.backup -securetar==2025.1.3 +securetar==2025.1.4 # homeassistant.components.emulated_kasa # homeassistant.components.sense From 6bbb857d0fb71287d22132e6b5f9dda2822e7068 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 27 Jan 2025 17:59:21 +0100 Subject: [PATCH 0072/3148] Fix spelling of "Pi-hole" and "API" in user-facing strings (#136645) --- homeassistant/components/pi_hole/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/pi_hole/strings.json b/homeassistant/components/pi_hole/strings.json index 9e1d5948a09..504be7a62dd 100644 --- a/homeassistant/components/pi_hole/strings.json +++ b/homeassistant/components/pi_hole/strings.json @@ -17,8 +17,8 @@ } }, "reauth_confirm": { - "title": "Reauthenticate PI-Hole", - "description": "Please enter a new api key for PI-Hole at {host}/{location}", + "title": "Reauthenticate Pi-hole", + "description": "Please enter a new API key for Pi-hole at {host}/{location}", "data": { "api_key": "[%key:common::config_flow::data::api_key%]" } From ea92523af4c349a5a51501b7063bbf5f9340fb49 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Mon, 27 Jan 2025 18:06:03 +0100 Subject: [PATCH 0073/3148] Bump aioshelly to 12.3.2 (#136486) * Bump aioshelly * Add timeout parameter for call_rpc * Increase timeout for BLU TRV * Log timeout * Update test * Use const in test * Coverage --- homeassistant/components/shelly/climate.py | 2 + homeassistant/components/shelly/const.py | 3 ++ homeassistant/components/shelly/entity.py | 9 ++++- homeassistant/components/shelly/manifest.json | 2 +- homeassistant/components/shelly/number.py | 13 ++++++- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/shelly/test_climate.py | 3 +- tests/components/shelly/test_number.py | 38 ++++++++++++++++++- 9 files changed, 66 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/shelly/climate.py b/homeassistant/components/shelly/climate.py index f8e157a6a5d..f1491acdd81 100644 --- a/homeassistant/components/shelly/climate.py +++ b/homeassistant/components/shelly/climate.py @@ -36,6 +36,7 @@ from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM from .const import ( BLU_TRV_TEMPERATURE_SETTINGS, + BLU_TRV_TIMEOUT, DOMAIN, LOGGER, NOT_CALIBRATED_ISSUE_ID, @@ -604,4 +605,5 @@ class RpcBluTrvClimate(ShellyRpcEntity, ClimateEntity): "method": "Trv.SetTarget", "params": {"id": 0, "target_C": target_temp}, }, + timeout=BLU_TRV_TIMEOUT, ) diff --git a/homeassistant/components/shelly/const.py b/homeassistant/components/shelly/const.py index f81ba5ca7f7..e78a6f1a59d 100644 --- a/homeassistant/components/shelly/const.py +++ b/homeassistant/components/shelly/const.py @@ -265,3 +265,6 @@ VIRTUAL_NUMBER_MODE_MAP = { API_WS_URL = "/api/shelly/ws" COMPONENT_ID_PATTERN = re.compile(r"[a-z\d]+:\d+") + +# value confirmed by Shelly team +BLU_TRV_TIMEOUT = 60 diff --git a/homeassistant/components/shelly/entity.py b/homeassistant/components/shelly/entity.py index 8c9044aeaff..001727c74b3 100644 --- a/homeassistant/components/shelly/entity.py +++ b/homeassistant/components/shelly/entity.py @@ -390,15 +390,20 @@ class ShellyRpcEntity(CoordinatorEntity[ShellyRpcCoordinator]): """Handle device update.""" self.async_write_ha_state() - async def call_rpc(self, method: str, params: Any) -> Any: + async def call_rpc( + self, method: str, params: Any, timeout: float | None = None + ) -> Any: """Call RPC method.""" LOGGER.debug( - "Call RPC for entity %s, method: %s, params: %s", + "Call RPC for entity %s, method: %s, params: %s, timeout: %s", self.name, method, params, + timeout, ) try: + if timeout: + return await self.coordinator.device.call_rpc(method, params, timeout) return await self.coordinator.device.call_rpc(method, params) except DeviceConnectionError as err: self.coordinator.last_update_success = False diff --git a/homeassistant/components/shelly/manifest.json b/homeassistant/components/shelly/manifest.json index cf5c59da5e3..e0d8c03ffc4 100644 --- a/homeassistant/components/shelly/manifest.json +++ b/homeassistant/components/shelly/manifest.json @@ -8,7 +8,7 @@ "integration_type": "device", "iot_class": "local_push", "loggers": ["aioshelly"], - "requirements": ["aioshelly==12.3.1"], + "requirements": ["aioshelly==12.3.2"], "zeroconf": [ { "type": "_http._tcp.local.", diff --git a/homeassistant/components/shelly/number.py b/homeassistant/components/shelly/number.py index fb61c885423..7140c79fbb6 100644 --- a/homeassistant/components/shelly/number.py +++ b/homeassistant/components/shelly/number.py @@ -25,7 +25,7 @@ from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, DeviceIn from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_registry import RegistryEntry -from .const import CONF_SLEEP_PERIOD, LOGGER, VIRTUAL_NUMBER_MODE_MAP +from .const import BLU_TRV_TIMEOUT, CONF_SLEEP_PERIOD, LOGGER, VIRTUAL_NUMBER_MODE_MAP from .coordinator import ShellyBlockCoordinator, ShellyConfigEntry, ShellyRpcCoordinator from .entity import ( BlockEntityDescription, @@ -127,6 +127,17 @@ class RpcBluTrvNumber(RpcNumber): connections={(CONNECTION_BLUETOOTH, ble_addr)} ) + async def async_set_native_value(self, value: float) -> None: + """Change the value.""" + if TYPE_CHECKING: + assert isinstance(self._id, int) + + await self.call_rpc( + self.entity_description.method, + self.entity_description.method_params_fn(self._id, value), + timeout=BLU_TRV_TIMEOUT, + ) + NUMBERS: dict[tuple[str, str], BlockNumberDescription] = { ("device", "valvePos"): BlockNumberDescription( diff --git a/requirements_all.txt b/requirements_all.txt index 1a86e1b0560..11973ad3c2f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -368,7 +368,7 @@ aioruuvigateway==0.1.0 aiosenz==1.0.0 # homeassistant.components.shelly -aioshelly==12.3.1 +aioshelly==12.3.2 # homeassistant.components.skybell aioskybell==22.7.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9dcae1ea28a..7a3fd7857cb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -350,7 +350,7 @@ aioruuvigateway==0.1.0 aiosenz==1.0.0 # homeassistant.components.shelly -aioshelly==12.3.1 +aioshelly==12.3.2 # homeassistant.components.skybell aioskybell==22.7.0 diff --git a/tests/components/shelly/test_climate.py b/tests/components/shelly/test_climate.py index 352bdcb0a7d..5ad298c15a1 100644 --- a/tests/components/shelly/test_climate.py +++ b/tests/components/shelly/test_climate.py @@ -26,7 +26,7 @@ from homeassistant.components.climate import ( HVACAction, HVACMode, ) -from homeassistant.components.shelly.const import DOMAIN +from homeassistant.components.shelly.const import BLU_TRV_TIMEOUT, DOMAIN from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.const import ( @@ -804,6 +804,7 @@ async def test_blu_trv_climate_set_temperature( "method": "Trv.SetTarget", "params": {"id": 0, "target_C": 28.0}, }, + BLU_TRV_TIMEOUT, ) assert get_entity_attribute(hass, entity_id, ATTR_TEMPERATURE) == 28 diff --git a/tests/components/shelly/test_number.py b/tests/components/shelly/test_number.py index 2a64ab839ea..15ed098093b 100644 --- a/tests/components/shelly/test_number.py +++ b/tests/components/shelly/test_number.py @@ -18,7 +18,7 @@ from homeassistant.components.number import ( SERVICE_SET_VALUE, NumberMode, ) -from homeassistant.components.shelly.const import DOMAIN +from homeassistant.components.shelly.const import BLU_TRV_TIMEOUT, DOMAIN from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.const import ATTR_ENTITY_ID, ATTR_UNIT_OF_MEASUREMENT, STATE_UNKNOWN from homeassistant.core import HomeAssistant, State @@ -415,3 +415,39 @@ async def test_blu_trv_number_entity( entry = entity_registry.async_get(entity_id) assert entry == snapshot(name=f"{entity_id}-entry") + + +async def test_blu_trv_set_value( + hass: HomeAssistant, + mock_blu_trv: Mock, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test the set value action for BLU TRV number entity.""" + await init_integration(hass, 3, model=MODEL_BLU_GATEWAY_GEN3) + + entity_id = f"{NUMBER_DOMAIN}.trv_name_external_temperature" + + assert hass.states.get(entity_id).state == "15.2" + + monkeypatch.setitem(mock_blu_trv.status["blutrv:200"], "current_C", 22.2) + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: f"{NUMBER_DOMAIN}.trv_name_external_temperature", + ATTR_VALUE: 22.2, + }, + blocking=True, + ) + mock_blu_trv.mock_update() + mock_blu_trv.call_rpc.assert_called_once_with( + "BluTRV.Call", + { + "id": 200, + "method": "Trv.SetExternalTemperature", + "params": {"id": 0, "t_C": 22.2}, + }, + BLU_TRV_TIMEOUT, + ) + + assert hass.states.get(entity_id).state == "22.2" From 39845650841a561c8cab91c83be1ca5a05778e32 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Mon, 27 Jan 2025 11:42:00 -0600 Subject: [PATCH 0074/3148] Bump voip-utils to 0.3.0 (#136648) --- homeassistant/components/voip/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/voip/conftest.py | 1 + 4 files changed, 4 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/voip/manifest.json b/homeassistant/components/voip/manifest.json index ed7f11f8fbc..e96039a6b45 100644 --- a/homeassistant/components/voip/manifest.json +++ b/homeassistant/components/voip/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/voip", "iot_class": "local_push", "quality_scale": "internal", - "requirements": ["voip-utils==0.2.2"] + "requirements": ["voip-utils==0.3.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 11973ad3c2f..80890e8b612 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2984,7 +2984,7 @@ venstarcolortouch==0.19 vilfo-api-client==0.5.0 # homeassistant.components.voip -voip-utils==0.2.2 +voip-utils==0.3.0 # homeassistant.components.volkszaehler volkszaehler==0.4.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7a3fd7857cb..a3bc80b736b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2400,7 +2400,7 @@ venstarcolortouch==0.19 vilfo-api-client==0.5.0 # homeassistant.components.voip -voip-utils==0.2.2 +voip-utils==0.3.0 # homeassistant.components.volvooncall volvooncall==0.10.3 diff --git a/tests/components/voip/conftest.py b/tests/components/voip/conftest.py index 99707297230..d47db58d585 100644 --- a/tests/components/voip/conftest.py +++ b/tests/components/voip/conftest.py @@ -57,6 +57,7 @@ def call_info() -> CallInfo: """Fake call info.""" return CallInfo( caller_endpoint=get_sip_endpoint("192.168.1.210", 5060), + local_endpoint=get_sip_endpoint("192.168.1.10", 5060), caller_rtp_port=5004, server_ip="192.168.1.10", headers={ From 557b9d88b5cdef26ccff63a70affc8b4e5d205ec Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Mon, 27 Jan 2025 19:36:16 +0100 Subject: [PATCH 0075/3148] Catch and convert MatterError when sending device commands (#136635) --- homeassistant/components/matter/button.py | 6 +- homeassistant/components/matter/climate.py | 38 +++++-------- homeassistant/components/matter/cover.py | 8 --- homeassistant/components/matter/entity.py | 66 ++++++++++++++++++++-- homeassistant/components/matter/fan.py | 65 ++++++++------------- homeassistant/components/matter/light.py | 8 --- homeassistant/components/matter/lock.py | 25 +++----- homeassistant/components/matter/number.py | 9 +-- homeassistant/components/matter/select.py | 19 ++----- homeassistant/components/matter/switch.py | 21 ++----- homeassistant/components/matter/vacuum.py | 23 ++------ homeassistant/components/matter/valve.py | 11 ---- tests/components/matter/test_number.py | 24 ++++++++ tests/components/matter/test_switch.py | 23 ++++++++ 14 files changed, 170 insertions(+), 176 deletions(-) diff --git a/homeassistant/components/matter/button.py b/homeassistant/components/matter/button.py index 2c5e641e640..634406d18eb 100644 --- a/homeassistant/components/matter/button.py +++ b/homeassistant/components/matter/button.py @@ -49,11 +49,7 @@ class MatterCommandButton(MatterEntity, ButtonEntity): """Handle the button press leveraging a Matter command.""" if TYPE_CHECKING: assert self.entity_description.command is not None - await self.matter_client.send_device_command( - node_id=self._endpoint.node.node_id, - endpoint_id=self._endpoint.endpoint_id, - command=self.entity_description.command(), - ) + await self.send_device_command(self.entity_description.command()) # Discovery schema(s) to map Matter Attributes to HA entities diff --git a/homeassistant/components/matter/climate.py b/homeassistant/components/matter/climate.py index 8f6cd92d31f..25419c34e42 100644 --- a/homeassistant/components/matter/climate.py +++ b/homeassistant/components/matter/climate.py @@ -212,57 +212,45 @@ class MatterClimate(MatterEntity, ClimateEntity): matter_attribute = ( clusters.Thermostat.Attributes.OccupiedHeatingSetpoint ) - await self.matter_client.write_attribute( - node_id=self._endpoint.node.node_id, - attribute_path=create_attribute_path_from_attribute( - self._endpoint.endpoint_id, - matter_attribute, - ), + await self.write_attribute( value=int(target_temperature * TEMPERATURE_SCALING_FACTOR), + matter_attribute=matter_attribute, ) return if target_temperature_low is not None: # multi setpoint control - low setpoint (heat) if self.target_temperature_low != target_temperature_low: - await self.matter_client.write_attribute( - node_id=self._endpoint.node.node_id, - attribute_path=create_attribute_path_from_attribute( - self._endpoint.endpoint_id, - clusters.Thermostat.Attributes.OccupiedHeatingSetpoint, - ), + await self.write_attribute( value=int(target_temperature_low * TEMPERATURE_SCALING_FACTOR), + matter_attribute=clusters.Thermostat.Attributes.OccupiedHeatingSetpoint, ) if target_temperature_high is not None: # multi setpoint control - high setpoint (cool) if self.target_temperature_high != target_temperature_high: - await self.matter_client.write_attribute( - node_id=self._endpoint.node.node_id, - attribute_path=create_attribute_path_from_attribute( - self._endpoint.endpoint_id, - clusters.Thermostat.Attributes.OccupiedCoolingSetpoint, - ), + await self.write_attribute( value=int(target_temperature_high * TEMPERATURE_SCALING_FACTOR), + matter_attribute=clusters.Thermostat.Attributes.OccupiedCoolingSetpoint, ) async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set new target hvac mode.""" - system_mode_path = create_attribute_path_from_attribute( - endpoint_id=self._endpoint.endpoint_id, - attribute=clusters.Thermostat.Attributes.SystemMode, - ) + system_mode_value = HVAC_SYSTEM_MODE_MAP.get(hvac_mode) if system_mode_value is None: raise ValueError(f"Unsupported hvac mode {hvac_mode} in Matter") - await self.matter_client.write_attribute( - node_id=self._endpoint.node.node_id, - attribute_path=system_mode_path, + await self.write_attribute( value=system_mode_value, + matter_attribute=clusters.Thermostat.Attributes.SystemMode, ) # we need to optimistically update the attribute's value here # to prevent a race condition when adjusting the mode and temperature # in the same call + system_mode_path = create_attribute_path_from_attribute( + endpoint_id=self._endpoint.endpoint_id, + attribute=clusters.Thermostat.Attributes.SystemMode, + ) self._endpoint.set_attribute_value(system_mode_path, system_mode_value) self._update_from_device() diff --git a/homeassistant/components/matter/cover.py b/homeassistant/components/matter/cover.py index ba9c3afbdee..5b109d52189 100644 --- a/homeassistant/components/matter/cover.py +++ b/homeassistant/components/matter/cover.py @@ -102,14 +102,6 @@ class MatterCover(MatterEntity, CoverEntity): clusters.WindowCovering.Commands.GoToTiltPercentage((100 - position) * 100) ) - async def send_device_command(self, command: Any) -> None: - """Send device command.""" - await self.matter_client.send_device_command( - node_id=self._endpoint.node.node_id, - endpoint_id=self._endpoint.endpoint_id, - command=command, - ) - @callback def _update_from_device(self) -> None: """Update from device.""" diff --git a/homeassistant/components/matter/entity.py b/homeassistant/components/matter/entity.py index 61c62d8b564..a6d0dbb08d8 100644 --- a/homeassistant/components/matter/entity.py +++ b/homeassistant/components/matter/entity.py @@ -2,18 +2,24 @@ from __future__ import annotations -from collections.abc import Callable +from collections.abc import Callable, Coroutine from dataclasses import dataclass +import functools import logging -from typing import TYPE_CHECKING, Any, cast +from typing import TYPE_CHECKING, Any, Concatenate, cast from chip.clusters import Objects as clusters -from chip.clusters.Objects import ClusterAttributeDescriptor, NullValue -from matter_server.common.helpers.util import create_attribute_path +from chip.clusters.Objects import ClusterAttributeDescriptor, ClusterCommand, NullValue +from matter_server.common.errors import MatterError +from matter_server.common.helpers.util import ( + create_attribute_path, + create_attribute_path_from_attribute, +) from matter_server.common.models import EventType, ServerInfoMessage from propcache.api import cached_property from homeassistant.core import callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity, EntityDescription import homeassistant.helpers.entity_registry as er @@ -31,6 +37,23 @@ if TYPE_CHECKING: LOGGER = logging.getLogger(__name__) +def catch_matter_error[_R, **P]( + func: Callable[Concatenate[MatterEntity, P], Coroutine[Any, Any, _R]], +) -> Callable[Concatenate[MatterEntity, P], Coroutine[Any, Any, _R]]: + """Catch Matter errors and convert to Home Assistant error.""" + + @functools.wraps(func) + async def wrapper(self: MatterEntity, *args: P.args, **kwargs: P.kwargs) -> _R: + """Catch Matter errors and convert to Home Assistant error.""" + try: + return await func(self, *args, **kwargs) + except MatterError as err: + error_msg = str(err) or err.__class__.__name__ + raise HomeAssistantError(error_msg) from err + + return wrapper + + @dataclass(frozen=True) class MatterEntityDescription(EntityDescription): """Describe the Matter entity.""" @@ -218,3 +241,38 @@ class MatterEntity(Entity): return create_attribute_path( self._endpoint.endpoint_id, attribute.cluster_id, attribute.attribute_id ) + + @catch_matter_error + async def send_device_command( + self, + command: ClusterCommand, + **kwargs: Any, + ) -> None: + """Send device command on the primary attribute's endpoint.""" + await self.matter_client.send_device_command( + node_id=self._endpoint.node.node_id, + endpoint_id=self._endpoint.endpoint_id, + command=command, + **kwargs, + ) + + @catch_matter_error + async def write_attribute( + self, + value: Any, + matter_attribute: type[ClusterAttributeDescriptor] | None = None, + ) -> Any: + """Write an attribute(value) on the primary endpoint. + + If matter_attribute is not provided, the primary attribute of the entity is used. + """ + if matter_attribute is None: + matter_attribute = self._entity_info.primary_attribute + return await self.matter_client.write_attribute( + node_id=self._endpoint.node.node_id, + attribute_path=create_attribute_path_from_attribute( + self._endpoint.endpoint_id, + matter_attribute, + ), + value=value, + ) diff --git a/homeassistant/components/matter/fan.py b/homeassistant/components/matter/fan.py index 593693dbbf9..8b8ebee619d 100644 --- a/homeassistant/components/matter/fan.py +++ b/homeassistant/components/matter/fan.py @@ -5,7 +5,6 @@ from __future__ import annotations from typing import TYPE_CHECKING, Any from chip.clusters import Objects as clusters -from matter_server.common.helpers.util import create_attribute_path_from_attribute from homeassistant.components.fan import ( DIRECTION_FORWARD, @@ -97,24 +96,16 @@ class MatterFan(MatterEntity, FanEntity): # clear the wind setting if its currently set if self._attr_preset_mode in [PRESET_NATURAL_WIND, PRESET_SLEEP_WIND]: await self._set_wind_mode(None) - await self.matter_client.write_attribute( - node_id=self._endpoint.node.node_id, - attribute_path=create_attribute_path_from_attribute( - self._endpoint.endpoint_id, - clusters.FanControl.Attributes.FanMode, - ), + await self.write_attribute( value=clusters.FanControl.Enums.FanModeEnum.kOff, + matter_attribute=clusters.FanControl.Attributes.FanMode, ) async def async_set_percentage(self, percentage: int) -> None: """Set the speed of the fan, as a percentage.""" - await self.matter_client.write_attribute( - node_id=self._endpoint.node.node_id, - attribute_path=create_attribute_path_from_attribute( - self._endpoint.endpoint_id, - clusters.FanControl.Attributes.PercentSetting, - ), + await self.write_attribute( value=percentage, + matter_attribute=clusters.FanControl.Attributes.PercentSetting, ) async def async_set_preset_mode(self, preset_mode: str) -> None: @@ -128,41 +119,33 @@ class MatterFan(MatterEntity, FanEntity): if self._attr_preset_mode in [PRESET_NATURAL_WIND, PRESET_SLEEP_WIND]: await self._set_wind_mode(None) - await self.matter_client.write_attribute( - node_id=self._endpoint.node.node_id, - attribute_path=create_attribute_path_from_attribute( - self._endpoint.endpoint_id, - clusters.FanControl.Attributes.FanMode, - ), + await self.write_attribute( value=FAN_MODE_MAP[preset_mode], + matter_attribute=clusters.FanControl.Attributes.FanMode, ) async def async_oscillate(self, oscillating: bool) -> None: """Oscillate the fan.""" - await self.matter_client.write_attribute( - node_id=self._endpoint.node.node_id, - attribute_path=create_attribute_path_from_attribute( - self._endpoint.endpoint_id, - clusters.FanControl.Attributes.RockSetting, + await self.write_attribute( + value=( + self.get_matter_attribute_value( + clusters.FanControl.Attributes.RockSupport + ) + if oscillating + else 0 ), - value=self.get_matter_attribute_value( - clusters.FanControl.Attributes.RockSupport - ) - if oscillating - else 0, + matter_attribute=clusters.FanControl.Attributes.RockSetting, ) async def async_set_direction(self, direction: str) -> None: """Set the direction of the fan.""" - await self.matter_client.write_attribute( - node_id=self._endpoint.node.node_id, - attribute_path=create_attribute_path_from_attribute( - self._endpoint.endpoint_id, - clusters.FanControl.Attributes.AirflowDirection, + await self.write_attribute( + value=( + clusters.FanControl.Enums.AirflowDirectionEnum.kReverse + if direction == DIRECTION_REVERSE + else clusters.FanControl.Enums.AirflowDirectionEnum.kForward ), - value=clusters.FanControl.Enums.AirflowDirectionEnum.kReverse - if direction == DIRECTION_REVERSE - else clusters.FanControl.Enums.AirflowDirectionEnum.kForward, + matter_attribute=clusters.FanControl.Attributes.AirflowDirection, ) async def _set_wind_mode(self, wind_mode: str | None) -> None: @@ -173,13 +156,9 @@ class MatterFan(MatterEntity, FanEntity): wind_setting = WindBitmap.kSleepWind else: wind_setting = 0 - await self.matter_client.write_attribute( - node_id=self._endpoint.node.node_id, - attribute_path=create_attribute_path_from_attribute( - self._endpoint.endpoint_id, - clusters.FanControl.Attributes.WindSetting, - ), + await self.write_attribute( value=wind_setting, + matter_attribute=clusters.FanControl.Attributes.WindSetting, ) @callback diff --git a/homeassistant/components/matter/light.py b/homeassistant/components/matter/light.py index 5a2768d1d50..5c20554f065 100644 --- a/homeassistant/components/matter/light.py +++ b/homeassistant/components/matter/light.py @@ -282,14 +282,6 @@ class MatterLight(MatterEntity, LightEntity): return ha_color_mode - async def send_device_command(self, command: Any) -> None: - """Send device command.""" - await self.matter_client.send_device_command( - node_id=self._endpoint.node.node_id, - endpoint_id=self._endpoint.endpoint_id, - command=command, - ) - async def async_turn_on(self, **kwargs: Any) -> None: """Turn light on.""" diff --git a/homeassistant/components/matter/lock.py b/homeassistant/components/matter/lock.py index d69d0fd3dab..8524b39d584 100644 --- a/homeassistant/components/matter/lock.py +++ b/homeassistant/components/matter/lock.py @@ -62,19 +62,6 @@ class MatterLock(MatterEntity, LockEntity): return None - async def send_device_command( - self, - command: clusters.ClusterCommand, - timed_request_timeout_ms: int = 1000, - ) -> None: - """Send a command to the device.""" - await self.matter_client.send_device_command( - node_id=self._endpoint.node.node_id, - endpoint_id=self._endpoint.endpoint_id, - command=command, - timed_request_timeout_ms=timed_request_timeout_ms, - ) - async def async_lock(self, **kwargs: Any) -> None: """Lock the lock with pin if needed.""" if not self._attr_is_locked: @@ -89,7 +76,8 @@ class MatterLock(MatterEntity, LockEntity): code: str | None = kwargs.get(ATTR_CODE) code_bytes = code.encode() if code else None await self.send_device_command( - command=clusters.DoorLock.Commands.LockDoor(code_bytes) + command=clusters.DoorLock.Commands.LockDoor(code_bytes), + timed_request_timeout_ms=1000, ) async def async_unlock(self, **kwargs: Any) -> None: @@ -110,11 +98,13 @@ class MatterLock(MatterEntity, LockEntity): # the unlock command should unbolt only on the unlock command # and unlatch on the HA 'open' command. await self.send_device_command( - command=clusters.DoorLock.Commands.UnboltDoor(code_bytes) + command=clusters.DoorLock.Commands.UnboltDoor(code_bytes), + timed_request_timeout_ms=1000, ) else: await self.send_device_command( - command=clusters.DoorLock.Commands.UnlockDoor(code_bytes) + command=clusters.DoorLock.Commands.UnlockDoor(code_bytes), + timed_request_timeout_ms=1000, ) async def async_open(self, **kwargs: Any) -> None: @@ -130,7 +120,8 @@ class MatterLock(MatterEntity, LockEntity): code: str | None = kwargs.get(ATTR_CODE) code_bytes = code.encode() if code else None await self.send_device_command( - command=clusters.DoorLock.Commands.UnlockDoor(code_bytes) + command=clusters.DoorLock.Commands.UnlockDoor(code_bytes), + timed_request_timeout_ms=1000, ) @callback diff --git a/homeassistant/components/matter/number.py b/homeassistant/components/matter/number.py index 22929c60b89..4518e83e9d0 100644 --- a/homeassistant/components/matter/number.py +++ b/homeassistant/components/matter/number.py @@ -6,7 +6,6 @@ from dataclasses import dataclass from chip.clusters import Objects as clusters from matter_server.common import custom_clusters -from matter_server.common.helpers.util import create_attribute_path_from_attribute from homeassistant.components.number import ( NumberDeviceClass, @@ -52,16 +51,10 @@ class MatterNumber(MatterEntity, NumberEntity): async def async_set_native_value(self, value: float) -> None: """Update the current value.""" - matter_attribute = self._entity_info.primary_attribute sendvalue = int(value) if value_convert := self.entity_description.ha_to_native_value: sendvalue = value_convert(value) - await self.matter_client.write_attribute( - node_id=self._endpoint.node.node_id, - attribute_path=create_attribute_path_from_attribute( - self._endpoint.endpoint_id, - matter_attribute, - ), + await self.write_attribute( value=sendvalue, ) diff --git a/homeassistant/components/matter/select.py b/homeassistant/components/matter/select.py index 317c8515d4b..1018bed6af0 100644 --- a/homeassistant/components/matter/select.py +++ b/homeassistant/components/matter/select.py @@ -9,7 +9,6 @@ from typing import TYPE_CHECKING, cast from chip.clusters import Objects as clusters from chip.clusters.ClusterObjects import ClusterAttributeDescriptor, ClusterCommand from chip.clusters.Types import Nullable -from matter_server.common.helpers.util import create_attribute_path_from_attribute from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.config_entries import ConfigEntry @@ -70,11 +69,7 @@ class MatterAttributeSelectEntity(MatterEntity, SelectEntity): value_convert = self.entity_description.ha_to_native_value if TYPE_CHECKING: assert value_convert is not None - await self.matter_client.write_attribute( - node_id=self._endpoint.node.node_id, - attribute_path=create_attribute_path_from_attribute( - self._endpoint.endpoint_id, self._entity_info.primary_attribute - ), + await self.write_attribute( value=value_convert(option), ) @@ -101,10 +96,8 @@ class MatterModeSelectEntity(MatterAttributeSelectEntity): for mode in cluster.supportedModes: if mode.label != option: continue - await self.matter_client.send_device_command( - node_id=self._endpoint.node.node_id, - endpoint_id=self._endpoint.endpoint_id, - command=cluster.Commands.ChangeToMode(newMode=mode.mode), + await self.send_device_command( + cluster.Commands.ChangeToMode(newMode=mode.mode), ) break @@ -132,10 +125,8 @@ class MatterListSelectEntity(MatterEntity, SelectEntity): async def async_select_option(self, option: str) -> None: """Change the selected option.""" option_id = self._attr_options.index(option) - await self.matter_client.send_device_command( - node_id=self._endpoint.node.node_id, - endpoint_id=self._endpoint.endpoint_id, - command=self.entity_description.command(option_id), + await self.send_device_command( + self.entity_description.command(option_id), ) @callback diff --git a/homeassistant/components/matter/switch.py b/homeassistant/components/matter/switch.py index 2a1e6d59a06..890ca662295 100644 --- a/homeassistant/components/matter/switch.py +++ b/homeassistant/components/matter/switch.py @@ -7,7 +7,6 @@ from typing import Any from chip.clusters import Objects as clusters from matter_server.client.models import device_types -from matter_server.common.helpers.util import create_attribute_path_from_attribute from homeassistant.components.switch import ( SwitchDeviceClass, @@ -41,18 +40,14 @@ class MatterSwitch(MatterEntity, SwitchEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Turn switch on.""" - await self.matter_client.send_device_command( - node_id=self._endpoint.node.node_id, - endpoint_id=self._endpoint.endpoint_id, - command=clusters.OnOff.Commands.On(), + await self.send_device_command( + clusters.OnOff.Commands.On(), ) async def async_turn_off(self, **kwargs: Any) -> None: """Turn switch off.""" - await self.matter_client.send_device_command( - node_id=self._endpoint.node.node_id, - endpoint_id=self._endpoint.endpoint_id, - command=clusters.OnOff.Commands.Off(), + await self.send_device_command( + clusters.OnOff.Commands.Off(), ) @callback @@ -77,15 +72,9 @@ class MatterNumericSwitch(MatterSwitch): async def _async_set_native_value(self, value: bool) -> None: """Update the current value.""" - matter_attribute = self._entity_info.primary_attribute if value_convert := self.entity_description.ha_to_native_value: send_value = value_convert(value) - await self.matter_client.write_attribute( - node_id=self._endpoint.node.node_id, - attribute_path=create_attribute_path_from_attribute( - self._endpoint.endpoint_id, - matter_attribute, - ), + await self.write_attribute( value=send_value, ) diff --git a/homeassistant/components/matter/vacuum.py b/homeassistant/components/matter/vacuum.py index e98e1ad0bbd..511b32d3182 100644 --- a/homeassistant/components/matter/vacuum.py +++ b/homeassistant/components/matter/vacuum.py @@ -69,15 +69,15 @@ class MatterVacuum(MatterEntity, StateVacuumEntity): async def async_stop(self, **kwargs: Any) -> None: """Stop the vacuum cleaner.""" - await self._send_device_command(clusters.OperationalState.Commands.Stop()) + await self.send_device_command(clusters.OperationalState.Commands.Stop()) async def async_return_to_base(self, **kwargs: Any) -> None: """Set the vacuum cleaner to return to the dock.""" - await self._send_device_command(clusters.RvcOperationalState.Commands.GoHome()) + await self.send_device_command(clusters.RvcOperationalState.Commands.GoHome()) async def async_locate(self, **kwargs: Any) -> None: """Locate the vacuum cleaner.""" - await self._send_device_command(clusters.Identify.Commands.Identify()) + await self.send_device_command(clusters.Identify.Commands.Identify()) async def async_start(self) -> None: """Start or resume the cleaning task.""" @@ -87,26 +87,15 @@ class MatterVacuum(MatterEntity, StateVacuumEntity): clusters.RvcOperationalState.Commands.Resume.command_id in self._last_accepted_commands ): - await self._send_device_command( + await self.send_device_command( clusters.RvcOperationalState.Commands.Resume() ) else: - await self._send_device_command(clusters.OperationalState.Commands.Start()) + await self.send_device_command(clusters.OperationalState.Commands.Start()) async def async_pause(self) -> None: """Pause the cleaning task.""" - await self._send_device_command(clusters.OperationalState.Commands.Pause()) - - async def _send_device_command( - self, - command: clusters.ClusterCommand, - ) -> None: - """Send a command to the device.""" - await self.matter_client.send_device_command( - node_id=self._endpoint.node.node_id, - endpoint_id=self._endpoint.endpoint_id, - command=command, - ) + await self.send_device_command(clusters.OperationalState.Commands.Pause()) @callback def _update_from_device(self) -> None: diff --git a/homeassistant/components/matter/valve.py b/homeassistant/components/matter/valve.py index ccb4e89da17..29946621853 100644 --- a/homeassistant/components/matter/valve.py +++ b/homeassistant/components/matter/valve.py @@ -42,17 +42,6 @@ class MatterValve(MatterEntity, ValveEntity): entity_description: ValveEntityDescription _platform_translation_key = "valve" - async def send_device_command( - self, - command: clusters.ClusterCommand, - ) -> None: - """Send a command to the device.""" - await self.matter_client.send_device_command( - node_id=self._endpoint.node.node_id, - endpoint_id=self._endpoint.endpoint_id, - command=command, - ) - async def async_open_valve(self) -> None: """Open the valve.""" await self.send_device_command(ValveConfigurationAndControl.Commands.Open()) diff --git a/tests/components/matter/test_number.py b/tests/components/matter/test_number.py index 86e1fbbf419..2a4eea1c324 100644 --- a/tests/components/matter/test_number.py +++ b/tests/components/matter/test_number.py @@ -4,12 +4,14 @@ from unittest.mock import MagicMock, call from matter_server.client.models.node import MatterNode from matter_server.common import custom_clusters +from matter_server.common.errors import MatterError from matter_server.common.helpers.util import create_attribute_path_from_attribute import pytest from syrupy import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from .common import ( @@ -97,3 +99,25 @@ async def test_eve_weather_sensor_altitude( ), value=500, ) + + +@pytest.mark.parametrize("node_fixture", ["dimmable_light"]) +async def test_matter_exception_on_write_attribute( + hass: HomeAssistant, + matter_client: MagicMock, + matter_node: MatterNode, +) -> None: + """Test if a MatterError gets converted to HomeAssistantError by using a dimmable_light fixture.""" + state = hass.states.get("number.mock_dimmable_light_on_level") + assert state + matter_client.write_attribute.side_effect = MatterError("Boom") + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + "number", + "set_value", + { + "entity_id": "number.mock_dimmable_light_on_level", + "value": 500, + }, + blocking=True, + ) diff --git a/tests/components/matter/test_switch.py b/tests/components/matter/test_switch.py index 11451c715c3..e82848fcc3a 100644 --- a/tests/components/matter/test_switch.py +++ b/tests/components/matter/test_switch.py @@ -4,12 +4,14 @@ from unittest.mock import MagicMock, call from chip.clusters import Objects as clusters from matter_server.client.models.node import MatterNode +from matter_server.common.errors import MatterError from matter_server.common.helpers.util import create_attribute_path_from_attribute import pytest from syrupy import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from .common import ( @@ -165,3 +167,24 @@ async def test_numeric_switch( ), value=0, ) + + +@pytest.mark.parametrize("node_fixture", ["on_off_plugin_unit"]) +async def test_matter_exception_on_command( + hass: HomeAssistant, + matter_client: MagicMock, + matter_node: MatterNode, +) -> None: + """Test if a MatterError gets converted to HomeAssistantError by using a switch fixture.""" + state = hass.states.get("switch.mock_onoffpluginunit") + assert state + matter_client.send_device_command.side_effect = MatterError("Boom") + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + "switch", + "turn_on", + { + "entity_id": "switch.mock_onoffpluginunit", + }, + blocking=True, + ) From 7497beefed331a37c32864342078ad4333759cdd Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Mon, 27 Jan 2025 13:06:21 -0600 Subject: [PATCH 0076/3148] Add single target constraint to async_match_targets (#136643) Add single target constraint --- homeassistant/helpers/intent.py | 53 +++++++++++++++++++++++++++++++-- tests/helpers/test_intent.py | 35 ++++++++++++++++++++-- 2 files changed, 84 insertions(+), 4 deletions(-) diff --git a/homeassistant/helpers/intent.py b/homeassistant/helpers/intent.py index 649819a5f06..c93545ed414 100644 --- a/homeassistant/helpers/intent.py +++ b/homeassistant/helpers/intent.py @@ -215,6 +215,9 @@ class MatchFailedReason(Enum): DUPLICATE_NAME = auto() """Two or more entities matched the same name constraint and could not be disambiguated.""" + MULTIPLE_TARGETS = auto() + """Two or more entities matched when a single target is required.""" + def is_no_entities_reason(self) -> bool: """Return True if the match failed because no entities matched.""" return self not in ( @@ -255,6 +258,9 @@ class MatchTargetsConstraints: allow_duplicate_names: bool = False """True if entities with duplicate names are allowed in result.""" + single_target: bool = False + """True if result must contain a single target.""" + @property def has_constraints(self) -> bool: """Returns True if at least one constraint is set (ignores assistant).""" @@ -266,6 +272,7 @@ class MatchTargetsConstraints: or self.device_classes or self.features or self.states + or self.single_target ) @@ -291,7 +298,7 @@ class MatchTargetsResult: """Reason for failed match when is_match = False.""" states: list[State] = field(default_factory=list) - """List of matched entity states when is_match = True.""" + """List of matched entity states.""" no_match_name: str | None = None """Name of invalid area/floor or duplicate name when match fails for those reasons.""" @@ -357,7 +364,6 @@ class MatchTargetsCandidate: is_exposed: bool entity: entity_registry.RegistryEntry | None = None area: area_registry.AreaEntry | None = None - floor: floor_registry.FloorEntry | None = None device: device_registry.DeviceEntry | None = None matched_name: str | None = None @@ -549,6 +555,7 @@ def async_match_targets( # noqa: C901 or constraints.device_classes or constraints.area_name or constraints.floor_name + or constraints.single_target ): if constraints.assistant: # Check exposure @@ -719,6 +726,48 @@ def async_match_targets( # noqa: C901 candidates = final_candidates + if constraints.single_target and len(candidates) > 1: + # Find best match using preferences + if not (preferences.area_id or preferences.floor_id): + # No preferences + return MatchTargetsResult( + False, + MatchFailedReason.MULTIPLE_TARGETS, + states=[c.state for c in candidates], + ) + + if not areas_added: + ar = area_registry.async_get(hass) + dr = device_registry.async_get(hass) + _add_areas(ar, dr, candidates) + areas_added = True + + filtered_candidates: list[MatchTargetsCandidate] = candidates + if preferences.area_id: + # Filter by area + filtered_candidates = [ + c for c in candidates if c.area and (c.area.id == preferences.area_id) + ] + + if (len(filtered_candidates) > 1) and preferences.floor_id: + # Filter by floor + filtered_candidates = [ + c + for c in candidates + if c.area and (c.area.floor_id == preferences.floor_id) + ] + + if len(filtered_candidates) != 1: + # Filtering could not restrict to a single target + return MatchTargetsResult( + False, + MatchFailedReason.MULTIPLE_TARGETS, + states=[c.state for c in candidates], + ) + + # Filtering succeeded + candidates = filtered_candidates + return MatchTargetsResult( True, None, diff --git a/tests/helpers/test_intent.py b/tests/helpers/test_intent.py index ae8c2ed65d0..bf0df305c35 100644 --- a/tests/helpers/test_intent.py +++ b/tests/helpers/test_intent.py @@ -234,7 +234,7 @@ async def test_async_match_targets( # Floor 2 floor_2 = floor_registry.async_create("second floor", aliases={"upstairs"}) - area_bedroom_2 = area_registry.async_get_or_create("bedroom") + area_bedroom_2 = area_registry.async_get_or_create("second floor bedroom") area_bedroom_2 = area_registry.async_update( area_bedroom_2.id, floor_id=floor_2.floor_id ) @@ -269,7 +269,7 @@ async def test_async_match_targets( # Floor 3 floor_3 = floor_registry.async_create("third floor", aliases={"upstairs"}) - area_bedroom_3 = area_registry.async_get_or_create("bedroom") + area_bedroom_3 = area_registry.async_get_or_create("third floor bedroom") area_bedroom_3 = area_registry.async_update( area_bedroom_3.id, floor_id=floor_3.floor_id ) @@ -510,6 +510,37 @@ async def test_async_match_targets( bathroom_light_3.entity_id, } + # Check single target constraint + result = intent.async_match_targets( + hass, + intent.MatchTargetsConstraints(domains={"light"}, single_target=True), + states=states, + ) + assert not result.is_match + assert result.no_match_reason == intent.MatchFailedReason.MULTIPLE_TARGETS + + # Only one light on the ground floor + result = intent.async_match_targets( + hass, + intent.MatchTargetsConstraints(domains={"light"}, single_target=True), + preferences=intent.MatchTargetsPreferences(floor_id=floor_1.floor_id), + states=states, + ) + assert result.is_match + assert len(result.states) == 1 + assert result.states[0].entity_id == bathroom_light_1.entity_id + + # Only one switch in bedroom + result = intent.async_match_targets( + hass, + intent.MatchTargetsConstraints(domains={"switch"}, single_target=True), + preferences=intent.MatchTargetsPreferences(area_id=area_bedroom_2.id), + states=states, + ) + assert result.is_match + assert len(result.states) == 1 + assert result.states[0].entity_id == bedroom_switch_2.entity_id + async def test_match_device_area( hass: HomeAssistant, From 85540cea3fb6584373863a4e73ac7b56fab85d12 Mon Sep 17 00:00:00 2001 From: Denis Shulyaka Date: Mon, 27 Jan 2025 22:21:27 +0300 Subject: [PATCH 0077/3148] Add LLM ActionTool (#136591) Add ActionTool --- homeassistant/helpers/llm.py | 173 ++++++++++++++++++++--------------- tests/helpers/test_llm.py | 8 +- 2 files changed, 103 insertions(+), 78 deletions(-) diff --git a/homeassistant/helpers/llm.py b/homeassistant/helpers/llm.py index ea376923f9d..cc397c5d428 100644 --- a/homeassistant/helpers/llm.py +++ b/homeassistant/helpers/llm.py @@ -49,9 +49,9 @@ from . import ( ) from .singleton import singleton -SCRIPT_PARAMETERS_CACHE: HassKey[dict[str, tuple[str | None, vol.Schema]]] = HassKey( - "llm_script_parameters_cache" -) +ACTION_PARAMETERS_CACHE: HassKey[ + dict[str, dict[str, tuple[str | None, vol.Schema]]] +] = HassKey("llm_action_parameters_cache") LLM_API_ASSIST = "assist" @@ -624,104 +624,105 @@ def _selector_serializer(schema: Any) -> Any: # noqa: C901 return {"type": "string"} -def _get_cached_script_parameters( - hass: HomeAssistant, entity_id: str +def _get_cached_action_parameters( + hass: HomeAssistant, domain: str, action: str ) -> tuple[str | None, vol.Schema]: - """Get script description and schema.""" - entity_registry = er.async_get(hass) - + """Get action description and schema.""" description = None parameters = vol.Schema({}) - entity_entry = entity_registry.async_get(entity_id) - if entity_entry and entity_entry.unique_id: - parameters_cache = hass.data.get(SCRIPT_PARAMETERS_CACHE) - if parameters_cache is None: - parameters_cache = hass.data[SCRIPT_PARAMETERS_CACHE] = {} + parameters_cache = hass.data.get(ACTION_PARAMETERS_CACHE) - @callback - def clear_cache(event: Event) -> None: - """Clear script parameter cache on script reload or delete.""" - if ( - event.data[ATTR_DOMAIN] == SCRIPT_DOMAIN - and event.data[ATTR_SERVICE] in parameters_cache - ): - parameters_cache.pop(event.data[ATTR_SERVICE]) + if parameters_cache is None: + parameters_cache = hass.data[ACTION_PARAMETERS_CACHE] = {} - cancel = hass.bus.async_listen(EVENT_SERVICE_REMOVED, clear_cache) + @callback + def clear_cache(event: Event) -> None: + """Clear action parameter cache on action removal.""" + if ( + event.data[ATTR_DOMAIN] in parameters_cache + and event.data[ATTR_SERVICE] + in parameters_cache[event.data[ATTR_DOMAIN]] + ): + parameters_cache[event.data[ATTR_DOMAIN]].pop(event.data[ATTR_SERVICE]) - @callback - def on_homeassistant_close(event: Event) -> None: - """Cleanup.""" - cancel() + cancel = hass.bus.async_listen(EVENT_SERVICE_REMOVED, clear_cache) - hass.bus.async_listen_once( - EVENT_HOMEASSISTANT_CLOSE, on_homeassistant_close - ) + @callback + def on_homeassistant_close(event: Event) -> None: + """Cleanup.""" + cancel() - if entity_entry.unique_id in parameters_cache: - return parameters_cache[entity_entry.unique_id] + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_CLOSE, on_homeassistant_close) - if service_desc := service.async_get_cached_service_description( - hass, SCRIPT_DOMAIN, entity_entry.unique_id - ): - description = service_desc.get("description") - schema: dict[vol.Marker, Any] = {} - fields = service_desc.get("fields", {}) + if domain in parameters_cache and action in parameters_cache[domain]: + return parameters_cache[domain][action] - for field, config in fields.items(): - field_description = config.get("description") - if not field_description: - field_description = config.get("name") - key: vol.Marker - if config.get("required"): - key = vol.Required(field, description=field_description) - else: - key = vol.Optional(field, description=field_description) - if "selector" in config: - schema[key] = selector.selector(config["selector"]) - else: - schema[key] = cv.string + if action_desc := service.async_get_cached_service_description( + hass, domain, action + ): + description = action_desc.get("description") + schema: dict[vol.Marker, Any] = {} + fields = action_desc.get("fields", {}) - parameters = vol.Schema(schema) + for field, config in fields.items(): + field_description = config.get("description") + if not field_description: + field_description = config.get("name") + key: vol.Marker + if config.get("required"): + key = vol.Required(field, description=field_description) + else: + key = vol.Optional(field, description=field_description) + if "selector" in config: + schema[key] = selector.selector(config["selector"]) + else: + schema[key] = cv.string - aliases: list[str] = [] - if entity_entry.name: - aliases.append(entity_entry.name) - if entity_entry.aliases: - aliases.extend(entity_entry.aliases) - if aliases: - if description: - description = description + ". Aliases: " + str(list(aliases)) - else: - description = "Aliases: " + str(list(aliases)) + parameters = vol.Schema(schema) - parameters_cache[entity_entry.unique_id] = (description, parameters) + if domain == SCRIPT_DOMAIN: + entity_registry = er.async_get(hass) + if ( + entity_id := entity_registry.async_get_entity_id(domain, domain, action) + ) and (entity_entry := entity_registry.async_get(entity_id)): + aliases: list[str] = [] + if entity_entry.name: + aliases.append(entity_entry.name) + if entity_entry.aliases: + aliases.extend(entity_entry.aliases) + if aliases: + if description: + description = description + ". Aliases: " + str(list(aliases)) + else: + description = "Aliases: " + str(list(aliases)) + + parameters_cache.setdefault(domain, {})[action] = (description, parameters) return description, parameters -class ScriptTool(Tool): - """LLM Tool representing a Script.""" +class ActionTool(Tool): + """LLM Tool representing an action.""" def __init__( self, hass: HomeAssistant, - script_entity_id: str, + domain: str, + action: str, ) -> None: """Init the class.""" - self._object_id = self.name = split_entity_id(script_entity_id)[1] - if self.name[0].isdigit(): - self.name = "_" + self.name - - self.description, self.parameters = _get_cached_script_parameters( - hass, script_entity_id + self._domain = domain + self._action = action + self.name = f"{domain}.{action}" + self.description, self.parameters = _get_cached_action_parameters( + hass, domain, action ) async def async_call( self, hass: HomeAssistant, tool_input: ToolInput, llm_context: LLMContext ) -> JsonObjectType: - """Run the script.""" + """Call the action.""" for field, validator in self.parameters.schema.items(): if field not in tool_input.tool_args: @@ -753,8 +754,8 @@ class ScriptTool(Tool): tool_input.tool_args[field] = floor result = await hass.services.async_call( - SCRIPT_DOMAIN, - self._object_id, + self._domain, + self._action, tool_input.tool_args, context=llm_context.context, blocking=True, @@ -764,6 +765,30 @@ class ScriptTool(Tool): return {"success": True, "result": result} +class ScriptTool(ActionTool): + """LLM Tool representing a Script.""" + + def __init__( + self, + hass: HomeAssistant, + script_entity_id: str, + ) -> None: + """Init the class.""" + script_name = split_entity_id(script_entity_id)[1] + + action = script_name + entity_registry = er.async_get(hass) + entity_entry = entity_registry.async_get(script_entity_id) + if entity_entry and entity_entry.unique_id: + action = entity_entry.unique_id + + super().__init__(hass, SCRIPT_DOMAIN, action) + + self.name = script_name + if self.name[0].isdigit(): + self.name = "_" + self.name + + class CalendarGetEventsTool(Tool): """LLM Tool allowing querying a calendar.""" diff --git a/tests/helpers/test_llm.py b/tests/helpers/test_llm.py index 57e151ba8eb..e288026b67b 100644 --- a/tests/helpers/test_llm.py +++ b/tests/helpers/test_llm.py @@ -745,7 +745,7 @@ async def test_script_tool( area = area_registry.async_create("Living room") floor = floor_registry.async_create("2") - assert llm.SCRIPT_PARAMETERS_CACHE not in hass.data + assert llm.ACTION_PARAMETERS_CACHE not in hass.data api = await llm.async_get_api(hass, "assist", llm_context) @@ -769,7 +769,7 @@ async def test_script_tool( } assert tool.parameters.schema == schema - assert hass.data[llm.SCRIPT_PARAMETERS_CACHE] == { + assert hass.data[llm.ACTION_PARAMETERS_CACHE]["script"] == { "test_script": ( "This is a test script. Aliases: ['script name', 'script alias']", vol.Schema(schema), @@ -866,7 +866,7 @@ async def test_script_tool( ): await hass.services.async_call("script", "reload", blocking=True) - assert hass.data[llm.SCRIPT_PARAMETERS_CACHE] == {} + assert hass.data[llm.ACTION_PARAMETERS_CACHE]["script"] == {} api = await llm.async_get_api(hass, "assist", llm_context) @@ -882,7 +882,7 @@ async def test_script_tool( schema = {vol.Required("beer", description="Number of beers"): cv.string} assert tool.parameters.schema == schema - assert hass.data[llm.SCRIPT_PARAMETERS_CACHE] == { + assert hass.data[llm.ACTION_PARAMETERS_CACHE]["script"] == { "test_script": ( "This is a new test script. Aliases: ['script name', 'script alias']", vol.Schema(schema), From 58b4556a1dcb5bd88d259610b77764707d4aa4c6 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Mon, 27 Jan 2025 11:38:52 -0800 Subject: [PATCH 0078/3148] Add the Model Context Protocol integration (#135058) * Add the Model Context Protocol integration * Improvements to mcp integration * Move the API prompt constant * Update config flow error handling * Update test descriptions * Update tests/components/mcp/test_config_flow.py Co-authored-by: Joost Lekkerkerker * Update tests/components/mcp/test_config_flow.py Co-authored-by: Joost Lekkerkerker * Address PR feedback * Update homeassistant/components/mcp/coordinator.py Co-authored-by: Paulus Schoutsen * Move tool parsing to the coordinator * Update session handling not to use a context manager --------- Co-authored-by: Joost Lekkerkerker Co-authored-by: Paulus Schoutsen --- .strict-typing | 1 + CODEOWNERS | 2 + homeassistant/components/mcp/__init__.py | 69 ++++++ homeassistant/components/mcp/config_flow.py | 111 +++++++++ homeassistant/components/mcp/const.py | 3 + homeassistant/components/mcp/coordinator.py | 171 +++++++++++++ homeassistant/components/mcp/manifest.json | 10 + .../components/mcp/quality_scale.yaml | 88 +++++++ homeassistant/components/mcp/strings.json | 25 ++ homeassistant/components/mcp/types.py | 7 + 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/mcp/__init__.py | 1 + tests/components/mcp/conftest.py | 45 ++++ tests/components/mcp/test_config_flow.py | 234 ++++++++++++++++++ tests/components/mcp/test_init.py | 225 +++++++++++++++++ 19 files changed, 1011 insertions(+) create mode 100644 homeassistant/components/mcp/__init__.py create mode 100644 homeassistant/components/mcp/config_flow.py create mode 100644 homeassistant/components/mcp/const.py create mode 100644 homeassistant/components/mcp/coordinator.py create mode 100644 homeassistant/components/mcp/manifest.json create mode 100644 homeassistant/components/mcp/quality_scale.yaml create mode 100644 homeassistant/components/mcp/strings.json create mode 100644 homeassistant/components/mcp/types.py create mode 100644 tests/components/mcp/__init__.py create mode 100644 tests/components/mcp/conftest.py create mode 100644 tests/components/mcp/test_config_flow.py create mode 100644 tests/components/mcp/test_init.py diff --git a/.strict-typing b/.strict-typing index 1c0456a745d..62da6c5ca92 100644 --- a/.strict-typing +++ b/.strict-typing @@ -316,6 +316,7 @@ homeassistant.components.manual.* homeassistant.components.mastodon.* homeassistant.components.matrix.* homeassistant.components.matter.* +homeassistant.components.mcp.* homeassistant.components.mcp_server.* homeassistant.components.mealie.* homeassistant.components.media_extractor.* diff --git a/CODEOWNERS b/CODEOWNERS index f16b890d407..faded2af138 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -891,6 +891,8 @@ build.json @home-assistant/supervisor /tests/components/matrix/ @PaarthShah /homeassistant/components/matter/ @home-assistant/matter /tests/components/matter/ @home-assistant/matter +/homeassistant/components/mcp/ @allenporter +/tests/components/mcp/ @allenporter /homeassistant/components/mcp_server/ @allenporter /tests/components/mcp_server/ @allenporter /homeassistant/components/mealie/ @joostlek @andrew-codechimp diff --git a/homeassistant/components/mcp/__init__.py b/homeassistant/components/mcp/__init__.py new file mode 100644 index 00000000000..4a2b4da990d --- /dev/null +++ b/homeassistant/components/mcp/__init__.py @@ -0,0 +1,69 @@ +"""The Model Context Protocol integration.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import llm + +from .const import DOMAIN +from .coordinator import ModelContextProtocolCoordinator +from .types import ModelContextProtocolConfigEntry + +__all__ = [ + "DOMAIN", + "async_setup_entry", + "async_unload_entry", +] + +API_PROMPT = "The following tools are available from a remote server named {name}." + + +async def async_setup_entry( + hass: HomeAssistant, entry: ModelContextProtocolConfigEntry +) -> bool: + """Set up Model Context Protocol from a config entry.""" + coordinator = ModelContextProtocolCoordinator(hass, entry) + await coordinator.async_config_entry_first_refresh() + + unsub = llm.async_register_api( + hass, + ModelContextProtocolAPI( + hass=hass, + id=f"{DOMAIN}-{entry.entry_id}", + name=entry.title, + coordinator=coordinator, + ), + ) + entry.async_on_unload(unsub) + + entry.runtime_data = coordinator + entry.async_on_unload(coordinator.close) + + return True + + +async def async_unload_entry( + hass: HomeAssistant, entry: ModelContextProtocolConfigEntry +) -> bool: + """Unload a config entry.""" + return True + + +@dataclass(kw_only=True) +class ModelContextProtocolAPI(llm.API): + """Define an object to hold the Model Context Protocol API.""" + + coordinator: ModelContextProtocolCoordinator + + async def async_get_api_instance( + self, llm_context: llm.LLMContext + ) -> llm.APIInstance: + """Return the instance of the API.""" + return llm.APIInstance( + self, + API_PROMPT.format(name=self.name), + llm_context, + tools=self.coordinator.data, + ) diff --git a/homeassistant/components/mcp/config_flow.py b/homeassistant/components/mcp/config_flow.py new file mode 100644 index 00000000000..92e0052c665 --- /dev/null +++ b/homeassistant/components/mcp/config_flow.py @@ -0,0 +1,111 @@ +"""Config flow for the Model Context Protocol integration.""" + +from __future__ import annotations + +import logging +from typing import Any + +import httpx +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_URL +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import config_validation as cv + +from .const import DOMAIN +from .coordinator import mcp_client + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_URL): str, + } +) + + +async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]: + """Validate the user input and connect to the MCP server.""" + url = data[CONF_URL] + try: + cv.url(url) # Cannot be added to schema directly + except vol.Invalid as error: + raise InvalidUrl from error + try: + async with mcp_client(url) as session: + response = await session.initialize() + except httpx.TimeoutException as error: + _LOGGER.info("Timeout connecting to MCP server: %s", error) + raise TimeoutConnectError from error + except httpx.HTTPStatusError as error: + _LOGGER.info("Cannot connect to MCP server: %s", error) + if error.response.status_code == 401: + raise InvalidAuth from error + raise CannotConnect from error + except httpx.HTTPError as error: + _LOGGER.info("Cannot connect to MCP server: %s", error) + raise CannotConnect from error + + if not response.capabilities.tools: + raise MissingCapabilities( + f"MCP Server {url} does not support 'Tools' capability" + ) + + return {"title": response.serverInfo.name} + + +class ModelContextProtocolConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Model Context Protocol.""" + + 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 is not None: + try: + info = await validate_input(self.hass, user_input) + except InvalidUrl: + errors[CONF_URL] = "invalid_url" + except TimeoutConnectError: + errors["base"] = "timeout_connect" + except CannotConnect: + errors["base"] = "cannot_connect" + except InvalidAuth: + return self.async_abort(reason="invalid_auth") + except MissingCapabilities: + return self.async_abort(reason="missing_capabilities") + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + self._async_abort_entries_match({CONF_URL: user_input[CONF_URL]}) + return self.async_create_entry(title=info["title"], data=user_input) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) + + +class InvalidUrl(HomeAssistantError): + """Error to indicate the URL format is invalid.""" + + +class CannotConnect(HomeAssistantError): + """Error to indicate we cannot connect.""" + + +class TimeoutConnectError(HomeAssistantError): + """Error to indicate we cannot connect.""" + + +class InvalidAuth(HomeAssistantError): + """Error to indicate there is invalid auth.""" + + +class MissingCapabilities(HomeAssistantError): + """Error to indicate that the MCP server is missing required capabilities.""" diff --git a/homeassistant/components/mcp/const.py b/homeassistant/components/mcp/const.py new file mode 100644 index 00000000000..675b2d7031c --- /dev/null +++ b/homeassistant/components/mcp/const.py @@ -0,0 +1,3 @@ +"""Constants for the Model Context Protocol integration.""" + +DOMAIN = "mcp" diff --git a/homeassistant/components/mcp/coordinator.py b/homeassistant/components/mcp/coordinator.py new file mode 100644 index 00000000000..a5c5ee55dbf --- /dev/null +++ b/homeassistant/components/mcp/coordinator.py @@ -0,0 +1,171 @@ +"""Types for the Model Context Protocol integration.""" + +import asyncio +from collections.abc import AsyncGenerator +from contextlib import asynccontextmanager +import datetime +import logging + +import httpx +from mcp.client.session import ClientSession +from mcp.client.sse import sse_client +import voluptuous as vol +from voluptuous_openapi import convert_to_voluptuous + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_URL +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import llm +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.util.json import JsonObjectType + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +UPDATE_INTERVAL = datetime.timedelta(minutes=30) +TIMEOUT = 10 + + +@asynccontextmanager +async def mcp_client(url: str) -> AsyncGenerator[ClientSession]: + """Create a server-sent event MCP client. + + This is an asynccontext manager that exists to wrap other async context managers + so that the coordinator has a single object to manage. + """ + try: + async with sse_client(url=url) as streams, ClientSession(*streams) as session: + await session.initialize() + yield session + except ExceptionGroup as err: + raise err.exceptions[0] from err + + +class ModelContextProtocolTool(llm.Tool): + """A Tool exposed over the Model Context Protocol.""" + + def __init__( + self, + name: str, + description: str | None, + parameters: vol.Schema, + session: ClientSession, + ) -> None: + """Initialize the tool.""" + self.name = name + self.description = description + self.parameters = parameters + self.session = session + + async def async_call( + self, + hass: HomeAssistant, + tool_input: llm.ToolInput, + llm_context: llm.LLMContext, + ) -> JsonObjectType: + """Call the tool.""" + try: + result = await self.session.call_tool( + tool_input.tool_name, tool_input.tool_args + ) + except httpx.HTTPStatusError as error: + raise HomeAssistantError(f"Error when calling tool: {error}") from error + return result.model_dump(exclude_unset=True, exclude_none=True) + + +class ModelContextProtocolCoordinator(DataUpdateCoordinator[list[llm.Tool]]): + """Define an object to hold MCP data.""" + + config_entry: ConfigEntry + _session: ClientSession | None = None + _setup_error: Exception | None = None + + def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: + """Initialize ModelContextProtocolCoordinator.""" + super().__init__( + hass, + logger=_LOGGER, + name=DOMAIN, + config_entry=config_entry, + update_interval=UPDATE_INTERVAL, + ) + self._stop = asyncio.Event() + + async def _async_setup(self) -> None: + """Set up the client connection.""" + connected = asyncio.Event() + stop = asyncio.Event() + self.config_entry.async_create_background_task( + self.hass, self._connect(connected, stop), "mcp-client" + ) + try: + async with asyncio.timeout(TIMEOUT): + await connected.wait() + self._stop = stop + finally: + if self._setup_error is not None: + raise self._setup_error + + async def _connect(self, connected: asyncio.Event, stop: asyncio.Event) -> None: + """Create a server-sent event MCP client.""" + url = self.config_entry.data[CONF_URL] + try: + async with ( + sse_client(url=url) as streams, + ClientSession(*streams) as session, + ): + await session.initialize() + self._session = session + connected.set() + await stop.wait() + except httpx.HTTPStatusError as err: + self._setup_error = err + _LOGGER.debug("Error connecting to MCP server: %s", err) + raise UpdateFailed(f"Error connecting to MCP server: {err}") from err + except ExceptionGroup as err: + self._setup_error = err.exceptions[0] + _LOGGER.debug("Error connecting to MCP server: %s", err) + raise UpdateFailed( + "Error connecting to MCP server: {err.exceptions[0]}" + ) from err.exceptions[0] + finally: + self._session = None + + async def close(self) -> None: + """Close the client connection.""" + if self._stop is not None: + self._stop.set() + + async def _async_update_data(self) -> list[llm.Tool]: + """Fetch data from API endpoint. + + This is the place to pre-process the data to lookup tables + so entities can quickly look up their data. + """ + if self._session is None: + raise UpdateFailed("No session available") + try: + result = await self._session.list_tools() + except httpx.HTTPError as err: + raise UpdateFailed(f"Error communicating with API: {err}") from err + + _LOGGER.debug("Received tools: %s", result.tools) + tools: list[llm.Tool] = [] + for tool in result.tools: + try: + parameters = convert_to_voluptuous(tool.inputSchema) + except Exception as err: + raise UpdateFailed( + f"Error converting schema {err}: {tool.inputSchema}" + ) from err + tools.append( + ModelContextProtocolTool( + tool.name, + tool.description, + parameters, + self._session, + ) + ) + return tools diff --git a/homeassistant/components/mcp/manifest.json b/homeassistant/components/mcp/manifest.json new file mode 100644 index 00000000000..ee4baf04802 --- /dev/null +++ b/homeassistant/components/mcp/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "mcp", + "name": "Model Context Protocol", + "codeowners": ["@allenporter"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/mcp", + "iot_class": "local_polling", + "quality_scale": "silver", + "requirements": ["mcp==1.1.2"] +} diff --git a/homeassistant/components/mcp/quality_scale.yaml b/homeassistant/components/mcp/quality_scale.yaml new file mode 100644 index 00000000000..76afdf5860d --- /dev/null +++ b/homeassistant/components/mcp/quality_scale.yaml @@ -0,0 +1,88 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: Integration does not have actions. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: Integration does not have actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: Integration does not have entities. + entity-unique-id: + status: exempt + comment: Integration does not have entities. + has-entity-name: + status: exempt + comment: Integration does not have entities. + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: Integration does not have actions. + config-entry-unloading: done + docs-configuration-parameters: done + docs-installation-parameters: done + entity-unavailable: + status: exempt + comment: Integration does not have entities. + integration-owner: done + log-when-unavailable: done + parallel-updates: + status: exempt + comment: Integration does not have platforms. + reauthentication-flow: + status: exempt + comment: Integration does not support authentication. + test-coverage: done + + # Gold + devices: + status: exempt + comment: Integration does not have devices. + diagnostics: todo + discovery-update-info: todo + discovery: todo + docs-data-update: done + docs-examples: done + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done + dynamic-devices: todo + entity-category: + status: exempt + comment: Integration does not have entities. + entity-device-class: + status: exempt + comment: Integration does not have entities. + entity-disabled-by-default: + status: exempt + comment: Integration does not have entities. + entity-translations: + status: exempt + comment: Integration does not have entities. + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + repair-issues: todo + stale-devices: todo + + # Platinum + async-dependency: done + inject-websession: todo + strict-typing: done diff --git a/homeassistant/components/mcp/strings.json b/homeassistant/components/mcp/strings.json new file mode 100644 index 00000000000..97a75fc6f85 --- /dev/null +++ b/homeassistant/components/mcp/strings.json @@ -0,0 +1,25 @@ +{ + "config": { + "step": { + "user": { + "data": { + "url": "[%key:common::config_flow::data::url%]" + }, + "data_description": { + "url": "The remote MCP server URL for the SSE endpoint, for example http://example/sse" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]", + "timeout_connect": "[%key:common::config_flow::error::timeout_connect%]", + "invalid_url": "Must be a valid MCP server URL e.g. https://example.com/sse" + }, + "abort": { + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "missing_capabilities": "The MCP server does not support a required capability (Tools)", + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + } +} diff --git a/homeassistant/components/mcp/types.py b/homeassistant/components/mcp/types.py new file mode 100644 index 00000000000..961c9ab3d18 --- /dev/null +++ b/homeassistant/components/mcp/types.py @@ -0,0 +1,7 @@ +"""Types for the Model Context Protocol integration.""" + +from homeassistant.config_entries import ConfigEntry + +from .coordinator import ModelContextProtocolCoordinator + +type ModelContextProtocolConfigEntry = ConfigEntry[ModelContextProtocolCoordinator] diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index b393e5c8851..7dea4598790 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -358,6 +358,7 @@ FLOWS = { "mailgun", "mastodon", "matter", + "mcp", "mcp_server", "mealie", "meater", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 9a7167f5367..6d2e784c583 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -3607,6 +3607,12 @@ "config_flow": true, "iot_class": "local_push" }, + "mcp": { + "name": "Model Context Protocol", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_polling" + }, "mcp_server": { "name": "Model Context Protocol Server", "integration_type": "service", diff --git a/mypy.ini b/mypy.ini index 7f7b66e238f..188f1f7bbd7 100644 --- a/mypy.ini +++ b/mypy.ini @@ -2916,6 +2916,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.mcp.*] +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.mcp_server.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 80890e8b612..87580b45ca9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1364,6 +1364,7 @@ maxcube-api==0.4.3 # homeassistant.components.mythicbeastsdns mbddns==0.1.2 +# homeassistant.components.mcp # homeassistant.components.mcp_server mcp==1.1.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a3bc80b736b..2894749732e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1142,6 +1142,7 @@ maxcube-api==0.4.3 # homeassistant.components.mythicbeastsdns mbddns==0.1.2 +# homeassistant.components.mcp # homeassistant.components.mcp_server mcp==1.1.2 diff --git a/tests/components/mcp/__init__.py b/tests/components/mcp/__init__.py new file mode 100644 index 00000000000..e8e8635ab36 --- /dev/null +++ b/tests/components/mcp/__init__.py @@ -0,0 +1 @@ +"""Tests for the Model Context Protocol integration.""" diff --git a/tests/components/mcp/conftest.py b/tests/components/mcp/conftest.py new file mode 100644 index 00000000000..d86603a12ed --- /dev/null +++ b/tests/components/mcp/conftest.py @@ -0,0 +1,45 @@ +"""Common fixtures for the Model Context Protocol tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.components.mcp.const import DOMAIN +from homeassistant.const import CONF_URL +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + +TEST_API_NAME = "Memory Server" + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.mcp.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_mcp_client() -> Generator[AsyncMock]: + """Fixture to mock the MCP client.""" + with ( + patch("homeassistant.components.mcp.coordinator.sse_client"), + patch("homeassistant.components.mcp.coordinator.ClientSession") as mock_session, + ): + yield mock_session.return_value.__aenter__ + + +@pytest.fixture(name="config_entry") +def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: + """Fixture to load the integration.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_URL: "http://1.1.1.1/sse"}, + title=TEST_API_NAME, + ) + config_entry.add_to_hass(hass) + return config_entry diff --git a/tests/components/mcp/test_config_flow.py b/tests/components/mcp/test_config_flow.py new file mode 100644 index 00000000000..29733e653a6 --- /dev/null +++ b/tests/components/mcp/test_config_flow.py @@ -0,0 +1,234 @@ +"""Test the Model Context Protocol config flow.""" + +from typing import Any +from unittest.mock import AsyncMock, Mock + +import httpx +import pytest + +from homeassistant import config_entries +from homeassistant.components.mcp.const import DOMAIN +from homeassistant.const import CONF_URL +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from .conftest import TEST_API_NAME + +from tests.common import MockConfigEntry + + +async def test_form( + hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_mcp_client: Mock +) -> None: + """Test the complete configuration flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + response = Mock() + response.serverInfo.name = TEST_API_NAME + mock_mcp_client.return_value.initialize.return_value = response + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_URL: "http://1.1.1.1/sse", + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == TEST_API_NAME + assert result["data"] == { + CONF_URL: "http://1.1.1.1/sse", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("side_effect", "expected_error"), + [ + (httpx.TimeoutException("Some timeout"), "timeout_connect"), + ( + httpx.HTTPStatusError("", request=None, response=httpx.Response(500)), + "cannot_connect", + ), + (httpx.HTTPError("Some HTTP error"), "cannot_connect"), + (Exception, "unknown"), + ], +) +async def test_form_mcp_client_error( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_mcp_client: Mock, + side_effect: Exception, + expected_error: str, +) -> None: + """Test we handle different client library errors.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + mock_mcp_client.side_effect = side_effect + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_URL: "http://1.1.1.1/sse", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": expected_error} + + # Reset the error and make sure the config flow can resume successfully. + mock_mcp_client.side_effect = None + response = Mock() + response.serverInfo.name = TEST_API_NAME + mock_mcp_client.return_value.initialize.return_value = response + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_URL: "http://1.1.1.1/sse", + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == TEST_API_NAME + assert result["data"] == { + CONF_URL: "http://1.1.1.1/sse", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("side_effect", "expected_error"), + [ + ( + httpx.HTTPStatusError("", request=None, response=httpx.Response(401)), + "invalid_auth", + ), + ], +) +async def test_form_mcp_client_error_abort( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_mcp_client: Mock, + side_effect: Exception, + expected_error: str, +) -> None: + """Test we handle different client library errors that end with an abort.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + mock_mcp_client.side_effect = side_effect + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_URL: "http://1.1.1.1/sse", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == expected_error + + +@pytest.mark.parametrize( + "user_input", + [ + ({CONF_URL: "not a url"}), + ({CONF_URL: "rtsp://1.1.1.1"}), + ], +) +async def test_input_form_validation_error( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_mcp_client: Mock, + user_input: dict[str, Any], +) -> None: + """Test we handle invalid auth.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {CONF_URL: "invalid_url"} + + # Reset the error and make sure the config flow can resume successfully. + response = Mock() + response.serverInfo.name = TEST_API_NAME + mock_mcp_client.return_value.initialize.return_value = response + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_URL: "http://1.1.1.1/sse", + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == TEST_API_NAME + assert result["data"] == { + CONF_URL: "http://1.1.1.1/sse", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_unique_url( + hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_mcp_client: Mock +) -> None: + """Test that the same url cannot be configured twice.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_URL: "http://1.1.1.1/sse"}, + title=TEST_API_NAME, + ) + config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + response = Mock() + response.serverInfo.name = TEST_API_NAME + mock_mcp_client.return_value.initialize.return_value = response + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_URL: "http://1.1.1.1/sse", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_server_missing_capbilities( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_mcp_client: Mock, +) -> None: + """Test we handle different client library errors.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + response = Mock() + response.serverInfo.name = TEST_API_NAME + response.capabilities.tools = None + mock_mcp_client.return_value.initialize.return_value = response + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_URL: "http://1.1.1.1/sse", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "missing_capabilities" diff --git a/tests/components/mcp/test_init.py b/tests/components/mcp/test_init.py new file mode 100644 index 00000000000..460df2c5785 --- /dev/null +++ b/tests/components/mcp/test_init.py @@ -0,0 +1,225 @@ +"""Tests for the Model Context Protocol component.""" + +import re +from unittest.mock import Mock, patch + +import httpx +from mcp.types import CallToolResult, ListToolsResult, TextContent, Tool +import pytest +import voluptuous as vol + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import Context, HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import llm + +from .conftest import TEST_API_NAME + +from tests.common import MockConfigEntry + +SEARCH_MEMORY_TOOL = Tool( + name="search_memory", + description="Search memory for relevant context based on a query.", + inputSchema={ + "type": "object", + "required": ["query"], + "properties": { + "query": { + "type": "string", + "description": "A free text query to search context for.", + } + }, + }, +) +SAVE_MEMORY_TOOL = Tool( + name="save_memory", + description="Save a memory context.", + inputSchema={ + "type": "object", + "required": ["context"], + "properties": { + "context": { + "type": "object", + "description": "The context to save.", + "properties": { + "fact": { + "type": "string", + "description": "The key for the context.", + }, + }, + }, + }, + }, +) + + +def create_llm_context() -> llm.LLMContext: + """Create a test LLM context.""" + return llm.LLMContext( + platform="test_platform", + context=Context(), + user_prompt="test_text", + language="*", + assistant="conversation", + device_id=None, + ) + + +async def test_init( + hass: HomeAssistant, config_entry: MockConfigEntry, mock_mcp_client: Mock +) -> None: + """Test the integration is initialized and can be unloaded cleanly.""" + await hass.config_entries.async_setup(config_entry.entry_id) + assert config_entry.state is ConfigEntryState.LOADED + + await hass.config_entries.async_unload(config_entry.entry_id) + assert config_entry.state is ConfigEntryState.NOT_LOADED + + +async def test_mcp_server_failure( + hass: HomeAssistant, config_entry: MockConfigEntry, mock_mcp_client: Mock +) -> None: + """Test the integration fails to setup if the server fails initialization.""" + mock_mcp_client.side_effect = httpx.HTTPStatusError( + "", request=None, response=httpx.Response(500) + ) + + with patch("homeassistant.components.mcp.coordinator.TIMEOUT", 1): + await hass.config_entries.async_setup(config_entry.entry_id) + assert config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_list_tools_failure( + hass: HomeAssistant, config_entry: MockConfigEntry, mock_mcp_client: Mock +) -> None: + """Test the integration fails to load if the first data fetch returns an error.""" + mock_mcp_client.return_value.list_tools.side_effect = httpx.HTTPStatusError( + "", request=None, response=httpx.Response(500) + ) + + await hass.config_entries.async_setup(config_entry.entry_id) + assert config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_llm_get_api_tools( + hass: HomeAssistant, config_entry: MockConfigEntry, mock_mcp_client: Mock +) -> None: + """Test MCP tools are returned as LLM API tools.""" + mock_mcp_client.return_value.list_tools.return_value = ListToolsResult( + tools=[SEARCH_MEMORY_TOOL, SAVE_MEMORY_TOOL], + ) + + await hass.config_entries.async_setup(config_entry.entry_id) + assert config_entry.state is ConfigEntryState.LOADED + + apis = llm.async_get_apis(hass) + api = next(iter([api for api in apis if api.name == TEST_API_NAME])) + assert api + + api_instance = await api.async_get_api_instance(create_llm_context()) + assert len(api_instance.tools) == 2 + tool = api_instance.tools[0] + assert tool.name == "search_memory" + assert tool.description == "Search memory for relevant context based on a query." + with pytest.raises( + vol.Invalid, match=re.escape("required key not provided @ data['query']") + ): + tool.parameters({}) + assert tool.parameters({"query": "frogs"}) == {"query": "frogs"} + + tool = api_instance.tools[1] + assert tool.name == "save_memory" + assert tool.description == "Save a memory context." + with pytest.raises( + vol.Invalid, match=re.escape("required key not provided @ data['context']") + ): + tool.parameters({}) + assert tool.parameters({"context": {"fact": "User was born in February"}}) == { + "context": {"fact": "User was born in February"} + } + + +async def test_call_tool( + hass: HomeAssistant, config_entry: MockConfigEntry, mock_mcp_client: Mock +) -> None: + """Test calling an MCP Tool through the LLM API.""" + mock_mcp_client.return_value.list_tools.return_value = ListToolsResult( + tools=[SEARCH_MEMORY_TOOL] + ) + + await hass.config_entries.async_setup(config_entry.entry_id) + assert config_entry.state is ConfigEntryState.LOADED + + apis = llm.async_get_apis(hass) + api = next(iter([api for api in apis if api.name == TEST_API_NAME])) + assert api + + api_instance = await api.async_get_api_instance(create_llm_context()) + assert len(api_instance.tools) == 1 + tool = api_instance.tools[0] + assert tool.name == "search_memory" + + mock_mcp_client.return_value.call_tool.return_value = CallToolResult( + content=[TextContent(type="text", text="User was born in February")] + ) + result = await tool.async_call( + hass, + llm.ToolInput( + tool_name="search_memory", tool_args={"query": "User's birth month"} + ), + create_llm_context(), + ) + assert result == { + "content": [{"text": "User was born in February", "type": "text"}] + } + + +async def test_call_tool_fails( + hass: HomeAssistant, config_entry: MockConfigEntry, mock_mcp_client: Mock +) -> None: + """Test handling an MCP Tool call failure.""" + mock_mcp_client.return_value.list_tools.return_value = ListToolsResult( + tools=[SEARCH_MEMORY_TOOL] + ) + + await hass.config_entries.async_setup(config_entry.entry_id) + assert config_entry.state is ConfigEntryState.LOADED + + apis = llm.async_get_apis(hass) + api = next(iter([api for api in apis if api.name == TEST_API_NAME])) + assert api + + api_instance = await api.async_get_api_instance(create_llm_context()) + assert len(api_instance.tools) == 1 + tool = api_instance.tools[0] + assert tool.name == "search_memory" + + mock_mcp_client.return_value.call_tool.side_effect = httpx.HTTPStatusError( + "Server error", request=None, response=httpx.Response(500) + ) + with pytest.raises( + HomeAssistantError, match="Error when calling tool: Server error" + ): + await tool.async_call( + hass, + llm.ToolInput( + tool_name="search_memory", tool_args={"query": "User's birth month"} + ), + create_llm_context(), + ) + + +async def test_convert_tool_schema_fails( + hass: HomeAssistant, config_entry: MockConfigEntry, mock_mcp_client: Mock +) -> None: + """Test a failure converting an MCP tool schema to a Home Assistant schema.""" + mock_mcp_client.return_value.list_tools.return_value = ListToolsResult( + tools=[SEARCH_MEMORY_TOOL] + ) + + with patch( + "homeassistant.components.mcp.coordinator.convert_to_voluptuous", + side_effect=ValueError, + ): + await hass.config_entries.async_setup(config_entry.entry_id) + assert config_entry.state is ConfigEntryState.SETUP_RETRY From b633a0424a6a594c907da0cfb9ebb6510cdab1cf Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Mon, 27 Jan 2025 14:18:31 -0600 Subject: [PATCH 0079/3148] Add HassClimateSetTemperature (#136484) * Add HassClimateSetTemperature * Use single target constraint --- homeassistant/components/climate/__init__.py | 1 + homeassistant/components/climate/const.py | 1 + homeassistant/components/climate/intent.py | 94 ++++++- tests/components/climate/test_intent.py | 252 ++++++++++++++++++- 4 files changed, 345 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/climate/__init__.py b/homeassistant/components/climate/__init__.py index af64b06ebe6..3ea0f887e76 100644 --- a/homeassistant/components/climate/__init__.py +++ b/homeassistant/components/climate/__init__.py @@ -69,6 +69,7 @@ from .const import ( # noqa: F401 FAN_TOP, HVAC_MODES, INTENT_GET_TEMPERATURE, + INTENT_SET_TEMPERATURE, PRESET_ACTIVITY, PRESET_AWAY, PRESET_BOOST, diff --git a/homeassistant/components/climate/const.py b/homeassistant/components/climate/const.py index 111401a2251..d347ccbbb29 100644 --- a/homeassistant/components/climate/const.py +++ b/homeassistant/components/climate/const.py @@ -127,6 +127,7 @@ DEFAULT_MAX_HUMIDITY = 99 DOMAIN = "climate" INTENT_GET_TEMPERATURE = "HassClimateGetTemperature" +INTENT_SET_TEMPERATURE = "HassClimateSetTemperature" SERVICE_SET_AUX_HEAT = "set_aux_heat" SERVICE_SET_FAN_MODE = "set_fan_mode" diff --git a/homeassistant/components/climate/intent.py b/homeassistant/components/climate/intent.py index 9a8dfdda4ec..9837a326188 100644 --- a/homeassistant/components/climate/intent.py +++ b/homeassistant/components/climate/intent.py @@ -4,15 +4,24 @@ from __future__ import annotations import voluptuous as vol +from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant -from homeassistant.helpers import intent +from homeassistant.helpers import config_validation as cv, intent -from . import DOMAIN, INTENT_GET_TEMPERATURE +from . import ( + ATTR_TEMPERATURE, + DOMAIN, + INTENT_GET_TEMPERATURE, + INTENT_SET_TEMPERATURE, + SERVICE_SET_TEMPERATURE, + ClimateEntityFeature, +) async def async_setup_intents(hass: HomeAssistant) -> None: """Set up the climate intents.""" intent.async_register(hass, GetTemperatureIntent()) + intent.async_register(hass, SetTemperatureIntent()) class GetTemperatureIntent(intent.IntentHandler): @@ -52,3 +61,84 @@ class GetTemperatureIntent(intent.IntentHandler): response.response_type = intent.IntentResponseType.QUERY_ANSWER response.async_set_states(matched_states=match_result.states) return response + + +class SetTemperatureIntent(intent.IntentHandler): + """Handle SetTemperature intents.""" + + intent_type = INTENT_SET_TEMPERATURE + description = "Sets the target temperature of a climate device or entity" + slot_schema = { + vol.Required("temperature"): vol.Coerce(float), + vol.Optional("area"): intent.non_empty_string, + vol.Optional("name"): intent.non_empty_string, + vol.Optional("floor"): intent.non_empty_string, + vol.Optional("preferred_area_id"): cv.string, + vol.Optional("preferred_floor_id"): cv.string, + } + platforms = {DOMAIN} + + async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse: + """Handle the intent.""" + hass = intent_obj.hass + slots = self.async_validate_slots(intent_obj.slots) + + temperature: float = slots["temperature"]["value"] + + name: str | None = None + if "name" in slots: + name = slots["name"]["value"] + + area_name: str | None = None + if "area" in slots: + area_name = slots["area"]["value"] + + floor_name: str | None = None + if "floor" in slots: + floor_name = slots["floor"]["value"] + + match_constraints = intent.MatchTargetsConstraints( + name=name, + area_name=area_name, + floor_name=floor_name, + domains=[DOMAIN], + assistant=intent_obj.assistant, + features=ClimateEntityFeature.TARGET_TEMPERATURE, + single_target=True, + ) + match_preferences = intent.MatchTargetsPreferences( + area_id=slots.get("preferred_area_id", {}).get("value"), + floor_id=slots.get("preferred_floor_id", {}).get("value"), + ) + match_result = intent.async_match_targets( + hass, match_constraints, match_preferences + ) + if not match_result.is_match: + raise intent.MatchFailedError( + result=match_result, constraints=match_constraints + ) + + assert match_result.states + climate_state = match_result.states[0] + + await hass.services.async_call( + DOMAIN, + SERVICE_SET_TEMPERATURE, + service_data={ATTR_TEMPERATURE: temperature}, + target={ATTR_ENTITY_ID: climate_state.entity_id}, + blocking=True, + ) + + response = intent_obj.create_response() + response.response_type = intent.IntentResponseType.ACTION_DONE + response.async_set_results( + success_results=[ + intent.IntentResponseTarget( + type=intent.IntentResponseTargetType.ENTITY, + name=climate_state.name, + id=climate_state.entity_id, + ) + ] + ) + response.async_set_states(matched_states=[climate_state]) + return response diff --git a/tests/components/climate/test_intent.py b/tests/components/climate/test_intent.py index d17f3a1747d..00ab2f8d278 100644 --- a/tests/components/climate/test_intent.py +++ b/tests/components/climate/test_intent.py @@ -1,13 +1,16 @@ """Test climate intents.""" from collections.abc import Generator +from typing import Any import pytest from homeassistant.components import conversation from homeassistant.components.climate import ( + ATTR_TEMPERATURE, DOMAIN, ClimateEntity, + ClimateEntityFeature, HVACMode, intent as climate_intent, ) @@ -15,7 +18,12 @@ from homeassistant.components.homeassistant.exposed_entities import async_expose from homeassistant.config_entries import ConfigEntry, ConfigFlow from homeassistant.const import Platform, UnitOfTemperature from homeassistant.core import HomeAssistant -from homeassistant.helpers import area_registry as ar, entity_registry as er, intent +from homeassistant.helpers import ( + area_registry as ar, + entity_registry as er, + floor_registry as fr, + intent, +) from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.setup import async_setup_component @@ -107,6 +115,20 @@ class MockClimateEntity(ClimateEntity): _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_hvac_mode = HVACMode.OFF _attr_hvac_modes = [HVACMode.OFF, HVACMode.HEAT] + _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set the thermostat temperature.""" + value = kwargs[ATTR_TEMPERATURE] + self._attr_target_temperature = value + + +class MockClimateEntityNoSetTemperature(ClimateEntity): + """Mock Climate device to use in tests.""" + + _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_hvac_mode = HVACMode.OFF + _attr_hvac_modes = [HVACMode.OFF, HVACMode.HEAT] async def test_get_temperature( @@ -436,3 +458,231 @@ async def test_not_exposed( assistant=conversation.DOMAIN, ) assert err.value.result.no_match_reason == intent.MatchFailedReason.ASSISTANT + + +async def test_set_temperature( + hass: HomeAssistant, + area_registry: ar.AreaRegistry, + entity_registry: er.EntityRegistry, + floor_registry: fr.FloorRegistry, +) -> None: + """Test HassClimateSetTemperature intent.""" + assert await async_setup_component(hass, "homeassistant", {}) + await climate_intent.async_setup_intents(hass) + + climate_1 = MockClimateEntity() + climate_1._attr_name = "Climate 1" + climate_1._attr_unique_id = "1234" + climate_1._attr_current_temperature = 10.0 + climate_1._attr_target_temperature = 10.0 + entity_registry.async_get_or_create( + DOMAIN, "test", "1234", suggested_object_id="climate_1" + ) + + climate_2 = MockClimateEntity() + climate_2._attr_name = "Climate 2" + climate_2._attr_unique_id = "5678" + climate_2._attr_current_temperature = 22.0 + climate_2._attr_target_temperature = 22.0 + entity_registry.async_get_or_create( + DOMAIN, "test", "5678", suggested_object_id="climate_2" + ) + + await create_mock_platform(hass, [climate_1, climate_2]) + + # Add climate entities to different areas: + # climate_1 => living room + # climate_2 => bedroom + # nothing in office + living_room_area = area_registry.async_create(name="Living Room") + bedroom_area = area_registry.async_create(name="Bedroom") + office_area = area_registry.async_create(name="Office") + + entity_registry.async_update_entity( + climate_1.entity_id, area_id=living_room_area.id + ) + entity_registry.async_update_entity(climate_2.entity_id, area_id=bedroom_area.id) + + # Put areas on different floors: + # first floor => living room and office + # upstairs => bedroom + floor_registry = fr.async_get(hass) + first_floor = floor_registry.async_create("First floor") + living_room_area = area_registry.async_update( + living_room_area.id, floor_id=first_floor.floor_id + ) + office_area = area_registry.async_update( + office_area.id, floor_id=first_floor.floor_id + ) + + second_floor = floor_registry.async_create("Second floor") + bedroom_area = area_registry.async_update( + bedroom_area.id, floor_id=second_floor.floor_id + ) + + # Cannot target multiple climate devices + with pytest.raises(intent.MatchFailedError) as err: + await intent.async_handle( + hass, + "test", + climate_intent.INTENT_SET_TEMPERATURE, + {"temperature": {"value": 20}}, + assistant=conversation.DOMAIN, + ) + assert err.value.result.no_match_reason == intent.MatchFailedReason.MULTIPLE_TARGETS + + # Select by area explicitly (climate_2) + response = await intent.async_handle( + hass, + "test", + climate_intent.INTENT_SET_TEMPERATURE, + {"area": {"value": bedroom_area.name}, "temperature": {"value": 20.1}}, + assistant=conversation.DOMAIN, + ) + assert response.response_type == intent.IntentResponseType.ACTION_DONE + assert len(response.matched_states) == 1 + assert response.matched_states[0].entity_id == climate_2.entity_id + state = hass.states.get(climate_2.entity_id) + assert state.attributes[ATTR_TEMPERATURE] == 20.1 + + # Select by area implicitly (climate_2) + response = await intent.async_handle( + hass, + "test", + climate_intent.INTENT_SET_TEMPERATURE, + { + "preferred_area_id": {"value": bedroom_area.id}, + "temperature": {"value": 20.2}, + }, + assistant=conversation.DOMAIN, + ) + assert response.response_type == intent.IntentResponseType.ACTION_DONE + assert response.matched_states + assert response.matched_states[0].entity_id == climate_2.entity_id + state = hass.states.get(climate_2.entity_id) + assert state.attributes[ATTR_TEMPERATURE] == 20.2 + + # Select by floor explicitly (climate_2) + response = await intent.async_handle( + hass, + "test", + climate_intent.INTENT_SET_TEMPERATURE, + {"floor": {"value": second_floor.name}, "temperature": {"value": 20.3}}, + assistant=conversation.DOMAIN, + ) + assert response.response_type == intent.IntentResponseType.ACTION_DONE + assert response.matched_states + assert response.matched_states[0].entity_id == climate_2.entity_id + state = hass.states.get(climate_2.entity_id) + assert state.attributes[ATTR_TEMPERATURE] == 20.3 + + # Select by floor implicitly (climate_2) + response = await intent.async_handle( + hass, + "test", + climate_intent.INTENT_SET_TEMPERATURE, + { + "preferred_floor_id": {"value": second_floor.floor_id}, + "temperature": {"value": 20.4}, + }, + assistant=conversation.DOMAIN, + ) + assert response.response_type == intent.IntentResponseType.ACTION_DONE + assert response.matched_states + assert response.matched_states[0].entity_id == climate_2.entity_id + state = hass.states.get(climate_2.entity_id) + assert state.attributes[ATTR_TEMPERATURE] == 20.4 + + # Select by name (climate_2) + response = await intent.async_handle( + hass, + "test", + climate_intent.INTENT_SET_TEMPERATURE, + {"name": {"value": "Climate 2"}, "temperature": {"value": 20.5}}, + assistant=conversation.DOMAIN, + ) + assert response.response_type == intent.IntentResponseType.ACTION_DONE + assert len(response.matched_states) == 1 + assert response.matched_states[0].entity_id == climate_2.entity_id + state = hass.states.get(climate_2.entity_id) + assert state.attributes[ATTR_TEMPERATURE] == 20.5 + + # Check area with no climate entities (explicit) + with pytest.raises(intent.MatchFailedError) as error: + response = await intent.async_handle( + hass, + "test", + climate_intent.INTENT_SET_TEMPERATURE, + {"area": {"value": office_area.name}, "temperature": {"value": 20.6}}, + assistant=conversation.DOMAIN, + ) + + # Exception should contain details of what we tried to match + assert isinstance(error.value, intent.MatchFailedError) + assert error.value.result.no_match_reason == intent.MatchFailedReason.AREA + constraints = error.value.constraints + assert constraints.name is None + assert constraints.area_name == office_area.name + assert constraints.domains and (set(constraints.domains) == {DOMAIN}) + assert constraints.device_classes is None + + # Implicit area with no climate entities will fail with multiple targets + with pytest.raises(intent.MatchFailedError) as err: + await intent.async_handle( + hass, + "test", + climate_intent.INTENT_SET_TEMPERATURE, + { + "preferred_area_id": {"value": office_area.id}, + "temperature": {"value": 20.7}, + }, + assistant=conversation.DOMAIN, + ) + assert err.value.result.no_match_reason == intent.MatchFailedReason.MULTIPLE_TARGETS + + +async def test_set_temperature_no_entities( + hass: HomeAssistant, +) -> None: + """Test HassClimateSetTemperature intent with no climate entities.""" + assert await async_setup_component(hass, "homeassistant", {}) + await climate_intent.async_setup_intents(hass) + + await create_mock_platform(hass, []) + + with pytest.raises(intent.MatchFailedError) as err: + await intent.async_handle( + hass, + "test", + climate_intent.INTENT_SET_TEMPERATURE, + {"temperature": {"value": 20}}, + assistant=conversation.DOMAIN, + ) + assert err.value.result.no_match_reason == intent.MatchFailedReason.DOMAIN + + +async def test_set_temperature_not_supported(hass: HomeAssistant) -> None: + """Test HassClimateSetTemperature intent when climate entity doesn't support required feature.""" + assert await async_setup_component(hass, "homeassistant", {}) + await climate_intent.async_setup_intents(hass) + + climate_1 = MockClimateEntityNoSetTemperature() + climate_1._attr_name = "Climate 1" + climate_1._attr_unique_id = "1234" + climate_1._attr_current_temperature = 10.0 + climate_1._attr_target_temperature = 10.0 + + await create_mock_platform(hass, [climate_1]) + + with pytest.raises(intent.MatchFailedError) as error: + await intent.async_handle( + hass, + "test", + climate_intent.INTENT_SET_TEMPERATURE, + {"temperature": {"value": 20.0}}, + assistant=conversation.DOMAIN, + ) + + # Exception should contain details of what we tried to match + assert isinstance(error.value, intent.MatchFailedError) + assert error.value.result.no_match_reason == intent.MatchFailedReason.FEATURE From b79221e66610e9336aabd9f42834e0f5fa3688ba Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 27 Jan 2025 10:25:20 -1000 Subject: [PATCH 0080/3148] Make static modbus entity values classvar defaults (#136488) --- homeassistant/components/modbus/entity.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/modbus/entity.py b/homeassistant/components/modbus/entity.py index 35b7c02aa05..4684c2f2b8a 100644 --- a/homeassistant/components/modbus/entity.py +++ b/homeassistant/components/modbus/entity.py @@ -74,6 +74,11 @@ _LOGGER = logging.getLogger(__name__) class BasePlatform(Entity): """Base for readonly platforms.""" + _value: str | None = None + _attr_should_poll = False + _attr_available = True + _attr_unit_of_measurement = None + def __init__( self, hass: HomeAssistant, hub: ModbusHub, entry: dict[str, Any] ) -> None: @@ -86,17 +91,13 @@ class BasePlatform(Entity): self._slave = entry.get(CONF_DEVICE_ADDRESS, 1) self._address = int(entry[CONF_ADDRESS]) self._input_type = entry[CONF_INPUT_TYPE] - self._value: str | None = None self._scan_interval = int(entry[CONF_SCAN_INTERVAL]) self._cancel_timer: Callable[[], None] | None = None self._cancel_call: Callable[[], None] | None = None self._attr_unique_id = entry.get(CONF_UNIQUE_ID) self._attr_name = entry[CONF_NAME] - self._attr_should_poll = False self._attr_device_class = entry.get(CONF_DEVICE_CLASS) - self._attr_available = True - self._attr_unit_of_measurement = None def get_optional_numeric_config(config_name: str) -> int | float | None: if (val := entry.get(config_name)) is None: From c12fa34e33800b67466be75771cee54e3df50dc5 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Mon, 27 Jan 2025 20:27:29 +0000 Subject: [PATCH 0081/3148] Add support for tplink siren turn on parameters (#136642) Add support for tplink siren parameters - Allow passing tone, volume, and duration for siren's play action. --------- Co-authored-by: Teemu Rytilahti --- homeassistant/components/tplink/siren.py | 51 +++++++++- homeassistant/components/tplink/strings.json | 3 + tests/components/tplink/__init__.py | 35 +++++-- .../tplink/snapshots/test_siren.ambr | 15 ++- tests/components/tplink/test_siren.py | 92 +++++++++++++++++++ 5 files changed, 182 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/tplink/siren.py b/homeassistant/components/tplink/siren.py index d1ce03c1469..027fa2dd58f 100644 --- a/homeassistant/components/tplink/siren.py +++ b/homeassistant/components/tplink/siren.py @@ -4,20 +4,27 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass -from typing import Any +import math +from typing import TYPE_CHECKING, Any, cast from kasa import Device, Module from homeassistant.components.siren import ( + ATTR_DURATION, + ATTR_TONE, + ATTR_VOLUME_LEVEL, DOMAIN as SIREN_DOMAIN, SirenEntity, SirenEntityDescription, SirenEntityFeature, + SirenTurnOnServiceParameters, ) from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import TPLinkConfigEntry, legacy_device_id +from .const import DOMAIN from .coordinator import TPLinkDataUpdateCoordinator from .entity import ( CoordinatedTPLinkModuleEntity, @@ -86,7 +93,13 @@ class TPLinkSirenEntity(CoordinatedTPLinkModuleEntity, SirenEntity): """Representation of a tplink siren entity.""" _attr_name = None - _attr_supported_features = SirenEntityFeature.TURN_OFF | SirenEntityFeature.TURN_ON + _attr_supported_features = ( + SirenEntityFeature.TURN_OFF + | SirenEntityFeature.TURN_ON + | SirenEntityFeature.TONES + | SirenEntityFeature.DURATION + | SirenEntityFeature.VOLUME_SET + ) entity_description: TPLinkSirenEntityDescription @@ -102,10 +115,38 @@ class TPLinkSirenEntity(CoordinatedTPLinkModuleEntity, SirenEntity): super().__init__(device, coordinator, description, parent=parent) self._alarm_module = device.modules[Module.Alarm] + alarm_vol_feat = self._alarm_module.get_feature("alarm_volume") + alarm_duration_feat = self._alarm_module.get_feature("alarm_duration") + if TYPE_CHECKING: + assert alarm_vol_feat + assert alarm_duration_feat + self._alarm_volume_max = alarm_vol_feat.maximum_value + self._alarm_duration_max = alarm_duration_feat.maximum_value + @async_refresh_after async def async_turn_on(self, **kwargs: Any) -> None: """Turn the siren on.""" - await self._alarm_module.play() + turn_on_params = cast(SirenTurnOnServiceParameters, kwargs) + if (volume := kwargs.get(ATTR_VOLUME_LEVEL)) is not None: + # service parameter is a % so we round up to the nearest int + volume = math.ceil(volume * self._alarm_volume_max) + + if (duration := kwargs.get(ATTR_DURATION)) is not None: + if duration < 1 or duration > self._alarm_duration_max: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_alarm_duration", + translation_placeholders={ + "duration": str(duration), + "duration_max": str(self._alarm_duration_max), + }, + ) + + await self._alarm_module.play( + duration=turn_on_params.get(ATTR_DURATION), + volume=volume, + sound=kwargs.get(ATTR_TONE), + ) @async_refresh_after async def async_turn_off(self, **kwargs: Any) -> None: @@ -116,4 +157,8 @@ class TPLinkSirenEntity(CoordinatedTPLinkModuleEntity, SirenEntity): def _async_update_attrs(self) -> bool: """Update the entity's attributes.""" self._attr_is_on = self._alarm_module.active + # alarm_sounds returns list[str], so we need to widen the type + self._attr_available_tones = cast( + list[str | int], self._alarm_module.alarm_sounds + ) return True diff --git a/homeassistant/components/tplink/strings.json b/homeassistant/components/tplink/strings.json index 9c32dd5bbf4..fa284a3cc83 100644 --- a/homeassistant/components/tplink/strings.json +++ b/homeassistant/components/tplink/strings.json @@ -367,6 +367,9 @@ }, "unsupported_mode": { "message": "Tried to set unsupported mode: {mode}" + }, + "invalid_alarm_duration": { + "message": "Invalid duration {duration} available: 1-{duration_max}s" } }, "issues": { diff --git a/tests/components/tplink/__init__.py b/tests/components/tplink/__init__.py index a056555f4c0..008d25a3dcb 100644 --- a/tests/components/tplink/__init__.py +++ b/tests/components/tplink/__init__.py @@ -178,12 +178,6 @@ def _mocked_device( device_config.host = ip_address device.host = ip_address - if modules: - device.modules = { - module_name: MODULE_TO_MOCK_GEN[module_name](device) - for module_name in modules - } - device_features = {} if features: device_features = { @@ -201,6 +195,13 @@ def _mocked_device( ) device.features = device_features + # Add modules after features so modules can add required features + if modules: + device.modules = { + module_name: MODULE_TO_MOCK_GEN[module_name](device) + for module_name in modules + } + for mod in device.modules.values(): mod.get_feature.side_effect = device_features.get mod.has_feature.side_effect = lambda id: id in device_features @@ -251,7 +252,10 @@ def _mocked_feature( feature.id = id feature.name = name or id.upper() feature.set_value = AsyncMock() - if not (fixture := FEATURES_FIXTURE.get(id)): + if fixture := FEATURES_FIXTURE.get(id): + # copy the fixture so tests do not interfere with each other + fixture = dict(fixture) + else: assert require_fixture is False, ( f"No fixture defined for feature {id} and require_fixture is True" ) @@ -259,7 +263,8 @@ def _mocked_feature( f"Value must be provided if feature {id} not defined in features.json" ) fixture = {"value": value, "category": "Primary", "type": "Sensor"} - elif value is not UNDEFINED: + + if value is not UNDEFINED: fixture["value"] = value feature.value = fixture["value"] @@ -352,9 +357,23 @@ def _mocked_fan_module(effect) -> Fan: def _mocked_alarm_module(device): alarm = MagicMock(auto_spec=Alarm, name="Mocked alarm") alarm.active = False + alarm.alarm_sounds = "Foo", "Bar" alarm.play = AsyncMock() alarm.stop = AsyncMock() + device.features["alarm_volume"] = _mocked_feature( + "alarm_volume", + minimum_value=0, + maximum_value=3, + value=None, + ) + device.features["alarm_duration"] = _mocked_feature( + "alarm_duration", + minimum_value=0, + maximum_value=300, + value=None, + ) + return alarm diff --git a/tests/components/tplink/snapshots/test_siren.ambr b/tests/components/tplink/snapshots/test_siren.ambr index b144288bd1c..7141ccfa084 100644 --- a/tests/components/tplink/snapshots/test_siren.ambr +++ b/tests/components/tplink/snapshots/test_siren.ambr @@ -40,7 +40,12 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'available_tones': tuple( + 'Foo', + 'Bar', + ), + }), 'config_entry_id': , 'device_class': None, 'device_id': , @@ -62,7 +67,7 @@ 'original_name': None, 'platform': 'tplink', 'previous_unique_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': None, 'unique_id': '123456789ABCDEFGH', 'unit_of_measurement': None, @@ -71,8 +76,12 @@ # name: test_states[siren.hub-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'available_tones': tuple( + 'Foo', + 'Bar', + ), 'friendly_name': 'hub', - 'supported_features': , + 'supported_features': , }), 'context': , 'entity_id': 'siren.hub', diff --git a/tests/components/tplink/test_siren.py b/tests/components/tplink/test_siren.py index 8c3328558b0..1d820bca1d1 100644 --- a/tests/components/tplink/test_siren.py +++ b/tests/components/tplink/test_siren.py @@ -7,12 +7,16 @@ import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.siren import ( + ATTR_DURATION, + ATTR_TONE, + ATTR_VOLUME_LEVEL, DOMAIN as SIREN_DOMAIN, SERVICE_TURN_OFF, SERVICE_TURN_ON, ) from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import device_registry as dr, entity_registry as er from . import _mocked_device, setup_platform_for_device, snapshot_platform @@ -74,3 +78,91 @@ async def test_turn_on_and_off( ) alarm_module.play.assert_called() + + +@pytest.mark.parametrize( + ("max_volume", "volume_level", "expected_volume"), + [ + pytest.param(3, 0.1, 1, id="smart-10%"), + pytest.param(3, 0.3, 1, id="smart-30%"), + pytest.param(3, 0.99, 3, id="smart-99%"), + pytest.param(3, 1, 3, id="smart-100%"), + pytest.param(10, 0.1, 1, id="smartcam-10%"), + pytest.param(10, 0.3, 3, id="smartcam-30%"), + pytest.param(10, 0.99, 10, id="smartcam-99%"), + pytest.param(10, 1, 10, id="smartcam-100%"), + ], +) +async def test_turn_on_with_volume( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mocked_hub: Device, + max_volume: int, + volume_level: float, + expected_volume: int, +) -> None: + """Test that turn_on volume parameters work as expected.""" + + alarm_module = mocked_hub.modules[Module.Alarm] + alarm_volume_feat = alarm_module.get_feature("alarm_volume") + assert alarm_volume_feat + alarm_volume_feat.maximum_value = max_volume + + await setup_platform_for_device(hass, mock_config_entry, Platform.SIREN, mocked_hub) + + await hass.services.async_call( + SIREN_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: [ENTITY_ID], ATTR_VOLUME_LEVEL: volume_level}, + blocking=True, + ) + + alarm_module.play.assert_called_with( + volume=expected_volume, duration=None, sound=None + ) + + +async def test_turn_on_with_duration_and_sound( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mocked_hub: Device, +) -> None: + """Test that turn_on tone and duration parameters work as expected.""" + + alarm_module = mocked_hub.modules[Module.Alarm] + + await setup_platform_for_device(hass, mock_config_entry, Platform.SIREN, mocked_hub) + + await hass.services.async_call( + SIREN_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: [ENTITY_ID], ATTR_DURATION: 5, ATTR_TONE: "Foo"}, + blocking=True, + ) + + alarm_module.play.assert_called_with(volume=None, duration=5, sound="Foo") + + +@pytest.mark.parametrize(("duration"), [0, 301]) +async def test_turn_on_with_invalid_duration( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mocked_hub: Device, + duration: int, +) -> None: + """Test that turn_on with invalid_duration raises an error.""" + + await setup_platform_for_device(hass, mock_config_entry, Platform.SIREN, mocked_hub) + + msg = f"Invalid duration {duration} available: 1-300s" + + with pytest.raises(ServiceValidationError, match=msg): + await hass.services.async_call( + SIREN_DOMAIN, + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: [ENTITY_ID], + ATTR_DURATION: duration, + }, + blocking=True, + ) From 7cf20c95c209ccb08750d5c27d98876a9f463df7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 27 Jan 2025 10:38:18 -1000 Subject: [PATCH 0082/3148] Log the error when the WebSocket receives a error message (#136492) * Log the error when the WebSocket receives a non-text message related issue #126754 Right now we only log that it was a non-Text message and silently swallow the exception * coverage --- .../components/websocket_api/http.py | 16 +++- tests/components/websocket_api/test_auth.py | 77 ++++++++++++++++++- 2 files changed, 88 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/websocket_api/http.py b/homeassistant/components/websocket_api/http.py index 8bfa9480ff4..ebca497193b 100644 --- a/homeassistant/components/websocket_api/http.py +++ b/homeassistant/components/websocket_api/http.py @@ -387,7 +387,14 @@ class WebSocketHandler: raise Disconnect("Received close message during auth phase") if msg.type is not WSMsgType.TEXT: - raise Disconnect("Received non-Text message during auth phase") + if msg.type is WSMsgType.ERROR: + # msg.data is the exception + raise Disconnect( + f"Received error message during auth phase: {msg.data}" + ) + raise Disconnect( + f"Received non-Text message of type {msg.type} during auth phase" + ) try: auth_msg_data = json_loads(msg.data) @@ -477,7 +484,12 @@ class WebSocketHandler: continue if msg_type is not WSMsgType.TEXT: - raise Disconnect("Received non-Text message.") + if msg_type is WSMsgType.ERROR: + # msg.data is the exception + raise Disconnect( + f"Received error message during command phase: {msg.data}" + ) + raise Disconnect(f"Received non-Text message of type {msg_type}.") try: command_msg_data = json_loads(msg_data) diff --git a/tests/components/websocket_api/test_auth.py b/tests/components/websocket_api/test_auth.py index d55d2f97017..49ee593fed7 100644 --- a/tests/components/websocket_api/test_auth.py +++ b/tests/components/websocket_api/test_auth.py @@ -3,7 +3,7 @@ from unittest.mock import patch import aiohttp -from aiohttp import WSMsgType +from aiohttp import WSMsgType, web import pytest from homeassistant.auth.providers.homeassistant import HassAuthProvider @@ -258,7 +258,7 @@ async def test_auth_sending_binary_disconnects( await ws.send_bytes(b"[INVALID]") auth_msg = await ws.receive() - assert auth_msg.type == WSMsgType.close + assert auth_msg.type is WSMsgType.CLOSE async def test_auth_close_disconnects( @@ -277,7 +277,40 @@ async def test_auth_close_disconnects( await ws.close() auth_msg = await ws.receive() - assert auth_msg.type == WSMsgType.CLOSED + assert auth_msg.type is WSMsgType.CLOSED + + +async def test_auth_error_disconnects( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test error during auth.""" + assert await async_setup_component(hass, "websocket_api", {}) + await hass.async_block_till_done() + + client = await hass_client_no_auth() + ws_response = web.WebSocketResponse() + + with patch( + "homeassistant.components.websocket_api.http.web.WebSocketResponse", + return_value=ws_response, + ): + async with client.ws_connect(URL) as ws: + auth_msg = await ws.receive_json() + assert auth_msg["type"] == TYPE_AUTH_REQUIRED + + ws_response._reader.feed_data( + aiohttp.WSMessage( + type=WSMsgType.ERROR, data=Exception("explode"), extra=None + ), + 0, + ) + + auth_msg = await ws.receive() + assert auth_msg.type is WSMsgType.CLOSE + + assert "Received error message during auth phase: explode" in caplog.text async def test_auth_sending_unknown_type_disconnects( @@ -296,3 +329,41 @@ async def test_auth_sending_unknown_type_disconnects( await ws._writer.send_frame(b"1" * 130, 0x30) auth_msg = await ws.receive() assert auth_msg.type == WSMsgType.close + + +async def test_error_right_after_auth_disconnects( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + hass_access_token: str, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test error right after auth.""" + assert await async_setup_component(hass, "websocket_api", {}) + await hass.async_block_till_done() + + client = await hass_client_no_auth() + ws_response = web.WebSocketResponse() + + with patch( + "homeassistant.components.websocket_api.http.web.WebSocketResponse", + return_value=ws_response, + ): + async with client.ws_connect(URL) as ws: + auth_msg = await ws.receive_json() + assert auth_msg["type"] == TYPE_AUTH_REQUIRED + + await ws.send_json({"type": TYPE_AUTH, "access_token": hass_access_token}) + auth_msg = await ws.receive_json() + assert auth_msg["type"] == TYPE_AUTH_OK + + ws_response._reader.feed_data( + aiohttp.WSMessage( + type=WSMsgType.ERROR, data=Exception("explode"), extra=None + ), + 0, + ) + + close_error_msg = await ws.receive() + assert close_error_msg.type is WSMsgType.CLOSE + + assert "Received error message during command phase: explode" in caplog.text From 50b0abbd7b075eefe27af3e8e239acae7b70ea50 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Mon, 27 Jan 2025 22:24:14 +0100 Subject: [PATCH 0083/3148] Bump pyfritzhome to 0.6.14 (#136661) bump pyfritzhome to 0.6.14 --- homeassistant/components/fritzbox/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/fritzbox/manifest.json b/homeassistant/components/fritzbox/manifest.json index 1a127597b81..2fbb75443b2 100644 --- a/homeassistant/components/fritzbox/manifest.json +++ b/homeassistant/components/fritzbox/manifest.json @@ -7,7 +7,7 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["pyfritzhome"], - "requirements": ["pyfritzhome==0.6.12"], + "requirements": ["pyfritzhome==0.6.14"], "ssdp": [ { "st": "urn:schemas-upnp-org:device:fritzbox:1" diff --git a/requirements_all.txt b/requirements_all.txt index 87580b45ca9..f9f597b7f80 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1963,7 +1963,7 @@ pyforked-daapd==0.1.14 pyfreedompro==1.1.0 # homeassistant.components.fritzbox -pyfritzhome==0.6.12 +pyfritzhome==0.6.14 # homeassistant.components.ifttt pyfttt==0.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2894749732e..96793fc02a1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1598,7 +1598,7 @@ pyforked-daapd==0.1.14 pyfreedompro==1.1.0 # homeassistant.components.fritzbox -pyfritzhome==0.6.12 +pyfritzhome==0.6.14 # homeassistant.components.ifttt pyfttt==0.3 From 0b17d1168300e5f9a7462a2f1df2c0bf4d09139a Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 27 Jan 2025 22:28:55 +0100 Subject: [PATCH 0084/3148] Update flux-led to 1.1.3 (#136666) --- homeassistant/components/flux_led/config_flow.py | 2 +- homeassistant/components/flux_led/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/flux_led/config_flow.py b/homeassistant/components/flux_led/config_flow.py index 69e40d59f7f..035be5b115c 100644 --- a/homeassistant/components/flux_led/config_flow.py +++ b/homeassistant/components/flux_led/config_flow.py @@ -299,7 +299,7 @@ class FluxLedConfigFlow(ConfigFlow, domain=DOMAIN): # AKA `HF-LPB100-ZJ200` return device bulb = async_wifi_bulb_for_host(host, discovery=device) - bulb.discovery = discovery # type: ignore[assignment] + bulb.discovery = discovery try: await bulb.async_setup(lambda: None) finally: diff --git a/homeassistant/components/flux_led/manifest.json b/homeassistant/components/flux_led/manifest.json index 962098a0bf8..fcb16c9742b 100644 --- a/homeassistant/components/flux_led/manifest.json +++ b/homeassistant/components/flux_led/manifest.json @@ -53,5 +53,5 @@ "documentation": "https://www.home-assistant.io/integrations/flux_led", "iot_class": "local_push", "loggers": ["flux_led"], - "requirements": ["flux-led==1.1.0"] + "requirements": ["flux-led==1.1.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index f9f597b7f80..e7e1b767fe4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -930,7 +930,7 @@ flexit_bacnet==2.2.1 flipr-api==1.6.1 # homeassistant.components.flux_led -flux-led==1.1.0 +flux-led==1.1.3 # homeassistant.components.homekit # homeassistant.components.recorder diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 96793fc02a1..2d7a55f1a60 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -789,7 +789,7 @@ flexit_bacnet==2.2.1 flipr-api==1.6.1 # homeassistant.components.flux_led -flux-led==1.1.0 +flux-led==1.1.3 # homeassistant.components.homekit # homeassistant.components.recorder From e0ea5bfc518e57e0e830be765d595310282cf7a4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 27 Jan 2025 11:49:49 -1000 Subject: [PATCH 0085/3148] Add Bluetooth WebSocket API to subscribe to connection allocations (#136215) --- homeassistant/components/bluetooth/util.py | 23 ++- .../components/bluetooth/websocket_api.py | 45 ++++- tests/components/bluetooth/__init__.py | 13 +- tests/components/bluetooth/conftest.py | 30 ++- tests/components/bluetooth/test_manager.py | 102 +++++++--- .../bluetooth/test_websocket_api.py | 174 +++++++++++++++++- 6 files changed, 345 insertions(+), 42 deletions(-) diff --git a/homeassistant/components/bluetooth/util.py b/homeassistant/components/bluetooth/util.py index 8c7ad13294a..ca2e0180c00 100644 --- a/homeassistant/components/bluetooth/util.py +++ b/homeassistant/components/bluetooth/util.py @@ -11,13 +11,23 @@ from bluetooth_adapters import ( adapter_unique_name, ) from bluetooth_data_tools import monotonic_time_coarse +from habluetooth import get_manager -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from .models import BluetoothServiceInfoBleak from .storage import BluetoothStorage +class InvalidConfigEntryID(HomeAssistantError): + """Invalid config entry id.""" + + +class InvalidSource(HomeAssistantError): + """Invalid source.""" + + @callback def async_load_history_from_system( adapters: BluetoothAdapters, storage: BluetoothStorage @@ -85,3 +95,14 @@ def adapter_title(adapter: str, details: AdapterDetails) -> str: model = details.get(ADAPTER_PRODUCT, "Unknown") manufacturer = details[ADAPTER_MANUFACTURER] or "Unknown" return f"{manufacturer} {model} ({unique_name})" + + +def config_entry_id_to_source(hass: HomeAssistant, config_entry_id: str) -> str: + """Convert a config entry id to a source.""" + if not (entry := hass.config_entries.async_get_entry(config_entry_id)): + raise InvalidConfigEntryID(f"Config entry {config_entry_id} not found") + source = entry.unique_id + assert source is not None + if not get_manager().async_scanner_by_source(source): + raise InvalidSource(f"Source {source} not found") + return source diff --git a/homeassistant/components/bluetooth/websocket_api.py b/homeassistant/components/bluetooth/websocket_api.py index 45445a7a00f..2829617d09e 100644 --- a/homeassistant/components/bluetooth/websocket_api.py +++ b/homeassistant/components/bluetooth/websocket_api.py @@ -7,7 +7,7 @@ from functools import lru_cache, partial import time from typing import Any -from habluetooth import BluetoothScanningMode +from habluetooth import BluetoothScanningMode, HaBluetoothSlotAllocations from home_assistant_bluetooth import BluetoothServiceInfoBleak import voluptuous as vol @@ -18,12 +18,14 @@ from homeassistant.helpers.json import json_bytes from .api import _get_manager, async_register_callback from .match import BluetoothCallbackMatcher from .models import BluetoothChange +from .util import InvalidConfigEntryID, InvalidSource, config_entry_id_to_source @callback def async_setup(hass: HomeAssistant) -> None: """Set up the bluetooth websocket API.""" websocket_api.async_register_command(hass, ws_subscribe_advertisements) + websocket_api.async_register_command(hass, ws_subscribe_connection_allocations) @lru_cache(maxsize=1024) @@ -135,6 +137,7 @@ class _AdvertisementSubscription: self._async_added((service_info,)) +@websocket_api.require_admin @websocket_api.websocket_command( { vol.Required("type"): "bluetooth/subscribe_advertisements", @@ -148,3 +151,43 @@ async def ws_subscribe_advertisements( _AdvertisementSubscription( hass, connection, msg["id"], BluetoothCallbackMatcher(connectable=False) ).async_start() + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required("type"): "bluetooth/subscribe_connection_allocations", + vol.Optional("config_entry_id"): str, + } +) +@websocket_api.async_response +async def ws_subscribe_connection_allocations( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] +) -> None: + """Handle subscribe advertisements websocket command.""" + ws_msg_id = msg["id"] + source: str | None = None + if config_entry_id := msg.get("config_entry_id"): + try: + source = config_entry_id_to_source(hass, config_entry_id) + except InvalidConfigEntryID as err: + connection.send_error(ws_msg_id, "invalid_config_entry_id", str(err)) + return + except InvalidSource as err: + connection.send_error(ws_msg_id, "invalid_source", str(err)) + return + + def _async_allocations_changed(allocations: HaBluetoothSlotAllocations) -> None: + connection.send_message( + json_bytes(websocket_api.event_message(ws_msg_id, [allocations])) + ) + + manager = _get_manager(hass) + connection.subscriptions[ws_msg_id] = manager.async_register_allocation_callback( + _async_allocations_changed, source + ) + connection.send_message(json_bytes(websocket_api.result_message(ws_msg_id))) + if current_allocations := manager.async_current_allocations(source): + connection.send_message( + json_bytes(websocket_api.event_message(ws_msg_id, current_allocations)) + ) diff --git a/tests/components/bluetooth/__init__.py b/tests/components/bluetooth/__init__.py index c672de7424b..31d301e2dac 100644 --- a/tests/components/bluetooth/__init__.py +++ b/tests/components/bluetooth/__init__.py @@ -10,7 +10,7 @@ from unittest.mock import MagicMock, patch from bleak import BleakClient from bleak.backends.scanner import AdvertisementData, BLEDevice from bluetooth_adapters import DEFAULT_ADDRESS -from habluetooth import BaseHaScanner, BluetoothManager, get_manager +from habluetooth import BaseHaScanner, get_manager from homeassistant.components.bluetooth import ( DOMAIN, @@ -21,6 +21,7 @@ from homeassistant.components.bluetooth import ( BluetoothServiceInfoBleak, async_get_advertisement_callback, ) +from homeassistant.components.bluetooth.manager import HomeAssistantBluetoothManager from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -57,6 +58,11 @@ BLE_DEVICE_DEFAULTS = { } +HCI0_SOURCE_ADDRESS = "AA:BB:CC:DD:EE:00" +HCI1_SOURCE_ADDRESS = "AA:BB:CC:DD:EE:11" +NON_CONNECTABLE_REMOTE_SOURCE_ADDRESS = "AA:BB:CC:DD:EE:FF" + + @contextmanager def patch_bluetooth_time(mock_time: float) -> None: """Patch the bluetooth time.""" @@ -101,9 +107,10 @@ def generate_ble_device( return BLEDevice(**new) -def _get_manager() -> BluetoothManager: +def _get_manager() -> HomeAssistantBluetoothManager: """Return the bluetooth manager.""" - return get_manager() + manager: HomeAssistantBluetoothManager = get_manager() + return manager def inject_advertisement( diff --git a/tests/components/bluetooth/conftest.py b/tests/components/bluetooth/conftest.py index 6fa0b375e81..e07b580acb2 100644 --- a/tests/components/bluetooth/conftest.py +++ b/tests/components/bluetooth/conftest.py @@ -5,13 +5,19 @@ from unittest.mock import patch from bleak_retry_connector import bleak_manager from dbus_fast.aio import message_bus +from habluetooth import BaseHaRemoteScanner import habluetooth.util as habluetooth_utils import pytest from homeassistant.components import bluetooth from homeassistant.core import HomeAssistant -from . import FakeScanner +from . import ( + HCI0_SOURCE_ADDRESS, + HCI1_SOURCE_ADDRESS, + NON_CONNECTABLE_REMOTE_SOURCE_ADDRESS, + FakeScanner, +) @pytest.fixture(name="disable_bluez_manager_socket", autouse=True, scope="package") @@ -314,8 +320,9 @@ def disable_new_discovery_flows_fixture(): @pytest.fixture def register_hci0_scanner(hass: HomeAssistant) -> Generator[None]: """Register an hci0 scanner.""" - hci0_scanner = FakeScanner("hci0", "hci0") - cancel = bluetooth.async_register_scanner(hass, hci0_scanner) + hci0_scanner = FakeScanner(HCI0_SOURCE_ADDRESS, "hci0") + hci0_scanner.connectable = True + cancel = bluetooth.async_register_scanner(hass, hci0_scanner, connection_slots=5) yield cancel() bluetooth.async_remove_scanner(hass, hci0_scanner.source) @@ -324,8 +331,21 @@ def register_hci0_scanner(hass: HomeAssistant) -> Generator[None]: @pytest.fixture def register_hci1_scanner(hass: HomeAssistant) -> Generator[None]: """Register an hci1 scanner.""" - hci1_scanner = FakeScanner("hci1", "hci1") - cancel = bluetooth.async_register_scanner(hass, hci1_scanner) + hci1_scanner = FakeScanner(HCI1_SOURCE_ADDRESS, "hci1") + hci1_scanner.connectable = True + cancel = bluetooth.async_register_scanner(hass, hci1_scanner, connection_slots=5) yield cancel() bluetooth.async_remove_scanner(hass, hci1_scanner.source) + + +@pytest.fixture +def register_non_connectable_scanner(hass: HomeAssistant) -> Generator[None]: + """Register an non connectable remote scanner.""" + remote_scanner = BaseHaRemoteScanner( + NON_CONNECTABLE_REMOTE_SOURCE_ADDRESS, "non connectable", None, False + ) + cancel = bluetooth.async_register_scanner(hass, remote_scanner) + yield + cancel() + bluetooth.async_remove_scanner(hass, remote_scanner.source) diff --git a/tests/components/bluetooth/test_manager.py b/tests/components/bluetooth/test_manager.py index 77071368dd0..c7fc80ba068 100644 --- a/tests/components/bluetooth/test_manager.py +++ b/tests/components/bluetooth/test_manager.py @@ -45,6 +45,8 @@ from homeassistant.util.dt import utcnow from homeassistant.util.json import json_loads from . import ( + HCI0_SOURCE_ADDRESS, + HCI1_SOURCE_ADDRESS, FakeScanner, MockBleakClient, _get_manager, @@ -82,7 +84,7 @@ async def test_advertisements_do_not_switch_adapters_for_no_reason( local_name="wohand_signal_100", service_uuids=[] ) inject_advertisement_with_source( - hass, switchbot_device_signal_100, switchbot_adv_signal_100, "hci0" + hass, switchbot_device_signal_100, switchbot_adv_signal_100, HCI0_SOURCE_ADDRESS ) assert ( @@ -97,7 +99,7 @@ async def test_advertisements_do_not_switch_adapters_for_no_reason( local_name="wohand_signal_99", service_uuids=[] ) inject_advertisement_with_source( - hass, switchbot_device_signal_99, switchbot_adv_signal_99, "hci0" + hass, switchbot_device_signal_99, switchbot_adv_signal_99, HCI0_SOURCE_ADDRESS ) assert ( @@ -112,7 +114,7 @@ async def test_advertisements_do_not_switch_adapters_for_no_reason( local_name="wohand_good_signal", service_uuids=[] ) inject_advertisement_with_source( - hass, switchbot_device_signal_98, switchbot_adv_signal_98, "hci1" + hass, switchbot_device_signal_98, switchbot_adv_signal_98, HCI1_SOURCE_ADDRESS ) # should not switch to hci1 @@ -137,7 +139,10 @@ async def test_switching_adapters_based_on_rssi( local_name="wohand_poor_signal", service_uuids=[], rssi=-100 ) inject_advertisement_with_source( - hass, switchbot_device_poor_signal, switchbot_adv_poor_signal, "hci0" + hass, + switchbot_device_poor_signal, + switchbot_adv_poor_signal, + HCI0_SOURCE_ADDRESS, ) assert ( @@ -150,7 +155,10 @@ async def test_switching_adapters_based_on_rssi( local_name="wohand_good_signal", service_uuids=[], rssi=-60 ) inject_advertisement_with_source( - hass, switchbot_device_good_signal, switchbot_adv_good_signal, "hci1" + hass, + switchbot_device_good_signal, + switchbot_adv_good_signal, + HCI1_SOURCE_ADDRESS, ) assert ( @@ -159,7 +167,10 @@ async def test_switching_adapters_based_on_rssi( ) inject_advertisement_with_source( - hass, switchbot_device_good_signal, switchbot_adv_poor_signal, "hci0" + hass, + switchbot_device_good_signal, + switchbot_adv_poor_signal, + HCI0_SOURCE_ADDRESS, ) assert ( bluetooth.async_ble_device_from_address(hass, address) @@ -175,7 +186,10 @@ async def test_switching_adapters_based_on_rssi( ) inject_advertisement_with_source( - hass, switchbot_device_similar_signal, switchbot_adv_similar_signal, "hci0" + hass, + switchbot_device_similar_signal, + switchbot_adv_similar_signal, + HCI0_SOURCE_ADDRESS, ) assert ( bluetooth.async_ble_device_from_address(hass, address) @@ -198,7 +212,7 @@ async def test_switching_adapters_based_on_zero_rssi( local_name="wohand_no_rssi", service_uuids=[], rssi=0 ) inject_advertisement_with_source( - hass, switchbot_device_no_rssi, switchbot_adv_no_rssi, "hci0" + hass, switchbot_device_no_rssi, switchbot_adv_no_rssi, HCI0_SOURCE_ADDRESS ) assert ( @@ -211,7 +225,10 @@ async def test_switching_adapters_based_on_zero_rssi( local_name="wohand_good_signal", service_uuids=[], rssi=-60 ) inject_advertisement_with_source( - hass, switchbot_device_good_signal, switchbot_adv_good_signal, "hci1" + hass, + switchbot_device_good_signal, + switchbot_adv_good_signal, + HCI1_SOURCE_ADDRESS, ) assert ( @@ -220,7 +237,7 @@ async def test_switching_adapters_based_on_zero_rssi( ) inject_advertisement_with_source( - hass, switchbot_device_good_signal, switchbot_adv_no_rssi, "hci0" + hass, switchbot_device_good_signal, switchbot_adv_no_rssi, HCI0_SOURCE_ADDRESS ) assert ( bluetooth.async_ble_device_from_address(hass, address) @@ -236,7 +253,10 @@ async def test_switching_adapters_based_on_zero_rssi( ) inject_advertisement_with_source( - hass, switchbot_device_similar_signal, switchbot_adv_similar_signal, "hci0" + hass, + switchbot_device_similar_signal, + switchbot_adv_similar_signal, + HCI0_SOURCE_ADDRESS, ) assert ( bluetooth.async_ble_device_from_address(hass, address) @@ -266,7 +286,7 @@ async def test_switching_adapters_based_on_stale( switchbot_device_poor_signal_hci0, switchbot_adv_poor_signal_hci0, start_time_monotonic, - "hci0", + HCI0_SOURCE_ADDRESS, ) assert ( @@ -285,7 +305,7 @@ async def test_switching_adapters_based_on_stale( switchbot_device_poor_signal_hci1, switchbot_adv_poor_signal_hci1, start_time_monotonic, - "hci1", + HCI1_SOURCE_ADDRESS, ) # Should not switch adapters until the advertisement is stale @@ -333,7 +353,7 @@ async def test_switching_adapters_based_on_stale_with_discovered_interval( switchbot_device_poor_signal_hci0, switchbot_adv_poor_signal_hci0, start_time_monotonic, - "hci0", + HCI0_SOURCE_ADDRESS, ) assert ( @@ -354,7 +374,7 @@ async def test_switching_adapters_based_on_stale_with_discovered_interval( switchbot_device_poor_signal_hci1, switchbot_adv_poor_signal_hci1, start_time_monotonic, - "hci1", + HCI1_SOURCE_ADDRESS, ) # Should not switch adapters until the advertisement is stale @@ -368,7 +388,7 @@ async def test_switching_adapters_based_on_stale_with_discovered_interval( switchbot_device_poor_signal_hci1, switchbot_adv_poor_signal_hci1, start_time_monotonic + 10 + 1, - "hci1", + HCI1_SOURCE_ADDRESS, ) # Should not switch yet since we are not within the @@ -383,7 +403,7 @@ async def test_switching_adapters_based_on_stale_with_discovered_interval( switchbot_device_poor_signal_hci1, switchbot_adv_poor_signal_hci1, start_time_monotonic + 10 + TRACKER_BUFFERING_WOBBLE_SECONDS + 1, - "hci1", + HCI1_SOURCE_ADDRESS, ) # Should switch to hci1 since the previous advertisement is stale # even though the signal is poor because the device is now @@ -404,7 +424,9 @@ async def test_restore_history_from_dbus( ble_device = generate_ble_device(address, "name") history = { address: AdvertisementHistory( - ble_device, generate_advertisement_data(local_name="name"), "hci0" + ble_device, + generate_advertisement_data(local_name="name"), + HCI0_SOURCE_ADDRESS, ) } @@ -440,7 +462,9 @@ async def test_restore_history_from_dbus_and_remote_adapters( ble_device = generate_ble_device(address, "name") history = { address: AdvertisementHistory( - ble_device, generate_advertisement_data(local_name="name"), "hci0" + ble_device, + generate_advertisement_data(local_name="name"), + HCI0_SOURCE_ADDRESS, ) } @@ -480,7 +504,9 @@ async def test_restore_history_from_dbus_and_corrupted_remote_adapters( ble_device = generate_ble_device(address, "name") history = { address: AdvertisementHistory( - ble_device, generate_advertisement_data(local_name="name"), "hci0" + ble_device, + generate_advertisement_data(local_name="name"), + HCI0_SOURCE_ADDRESS, ) } @@ -511,7 +537,12 @@ async def test_switching_adapters_based_on_rssi_connectable_to_non_connectable( local_name="wohand_poor_signal", service_uuids=[], rssi=-100 ) inject_advertisement_with_time_and_source_connectable( - hass, switchbot_device_poor_signal, switchbot_adv_poor_signal, now, "hci0", True + hass, + switchbot_device_poor_signal, + switchbot_adv_poor_signal, + now, + HCI0_SOURCE_ADDRESS, + True, ) assert ( @@ -607,7 +638,7 @@ async def test_connectable_advertisement_can_be_retrieved_with_best_path_is_non_ switchbot_device_good_signal, switchbot_adv_good_signal, now, - "hci1", + HCI1_SOURCE_ADDRESS, False, ) @@ -622,7 +653,12 @@ async def test_connectable_advertisement_can_be_retrieved_with_best_path_is_non_ local_name="wohand_poor_signal", service_uuids=[], rssi=-100 ) inject_advertisement_with_time_and_source_connectable( - hass, switchbot_device_poor_signal, switchbot_adv_poor_signal, now, "hci0", True + hass, + switchbot_device_poor_signal, + switchbot_adv_poor_signal, + now, + HCI0_SOURCE_ADDRESS, + True, ) assert ( @@ -662,7 +698,10 @@ async def test_switching_adapters_when_one_goes_away( local_name="wohand_poor_signal", service_uuids=[], rssi=-100 ) inject_advertisement_with_source( - hass, switchbot_device_poor_signal, switchbot_adv_poor_signal, "hci0" + hass, + switchbot_device_poor_signal, + switchbot_adv_poor_signal, + HCI0_SOURCE_ADDRESS, ) # We want to prefer the good signal when we have options @@ -674,7 +713,10 @@ async def test_switching_adapters_when_one_goes_away( cancel_hci2() inject_advertisement_with_source( - hass, switchbot_device_poor_signal, switchbot_adv_poor_signal, "hci0" + hass, + switchbot_device_poor_signal, + switchbot_adv_poor_signal, + HCI0_SOURCE_ADDRESS, ) # Now that hci2 is gone, we should prefer the poor signal @@ -713,7 +755,10 @@ async def test_switching_adapters_when_one_stop_scanning( local_name="wohand_poor_signal", service_uuids=[], rssi=-100 ) inject_advertisement_with_source( - hass, switchbot_device_poor_signal, switchbot_adv_poor_signal, "hci0" + hass, + switchbot_device_poor_signal, + switchbot_adv_poor_signal, + HCI0_SOURCE_ADDRESS, ) # We want to prefer the good signal when we have options @@ -725,7 +770,10 @@ async def test_switching_adapters_when_one_stop_scanning( hci2_scanner.scanning = False inject_advertisement_with_source( - hass, switchbot_device_poor_signal, switchbot_adv_poor_signal, "hci0" + hass, + switchbot_device_poor_signal, + switchbot_adv_poor_signal, + HCI0_SOURCE_ADDRESS, ) # Now that hci2 has stopped scanning, we should prefer the poor signal diff --git a/tests/components/bluetooth/test_websocket_api.py b/tests/components/bluetooth/test_websocket_api.py index c9670f2f895..d9289fe8380 100644 --- a/tests/components/bluetooth/test_websocket_api.py +++ b/tests/components/bluetooth/test_websocket_api.py @@ -5,19 +5,25 @@ from datetime import timedelta import time from unittest.mock import ANY, patch +from bleak_retry_connector import Allocations from freezegun import freeze_time import pytest +from homeassistant.components.bluetooth import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.util.dt import utcnow from . import ( + HCI0_SOURCE_ADDRESS, + HCI1_SOURCE_ADDRESS, + NON_CONNECTABLE_REMOTE_SOURCE_ADDRESS, + _get_manager, generate_advertisement_data, generate_ble_device, inject_advertisement_with_source, ) -from tests.common import async_fire_time_changed +from tests.common import MockConfigEntry, async_fire_time_changed from tests.typing import WebSocketGenerator @@ -38,7 +44,7 @@ async def test_subscribe_advertisements( local_name="wohand_signal_100", service_uuids=[] ) inject_advertisement_with_source( - hass, switchbot_device_signal_100, switchbot_adv_signal_100, "hci0" + hass, switchbot_device_signal_100, switchbot_adv_signal_100, HCI0_SOURCE_ADDRESS ) client = await hass_ws_client() @@ -64,7 +70,7 @@ async def test_subscribe_advertisements( "rssi": -127, "service_data": {}, "service_uuids": [], - "source": "hci0", + "source": HCI0_SOURCE_ADDRESS, "time": ANY, "tx_power": -127, } @@ -79,7 +85,7 @@ async def test_subscribe_advertisements( rssi=-80, ) inject_advertisement_with_source( - hass, switchbot_device_signal_100, switchbot_adv_signal_100, "hci1" + hass, switchbot_device_signal_100, switchbot_adv_signal_100, HCI1_SOURCE_ADDRESS ) async with asyncio.timeout(1): response = await client.receive_json() @@ -93,7 +99,7 @@ async def test_subscribe_advertisements( "rssi": -80, "service_data": {}, "service_uuids": [], - "source": "hci1", + "source": HCI1_SOURCE_ADDRESS, "time": ANY, "tx_power": -127, } @@ -114,3 +120,161 @@ async def test_subscribe_advertisements( async with asyncio.timeout(1): response = await client.receive_json() assert response["event"] == {"remove": [{"address": "44:44:33:11:23:12"}]} + + +@pytest.mark.usefixtures("enable_bluetooth") +async def test_subscribe_subscribe_connection_allocations( + hass: HomeAssistant, + register_hci0_scanner: None, + register_hci1_scanner: None, + register_non_connectable_scanner: None, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test bluetooth subscribe_connection_allocations.""" + address = "44:44:33:11:23:12" + + switchbot_device_signal_100 = generate_ble_device( + address, "wohand_signal_100", rssi=-100 + ) + switchbot_adv_signal_100 = generate_advertisement_data( + local_name="wohand_signal_100", service_uuids=[] + ) + inject_advertisement_with_source( + hass, switchbot_device_signal_100, switchbot_adv_signal_100, HCI0_SOURCE_ADDRESS + ) + + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "bluetooth/subscribe_connection_allocations", + } + ) + async with asyncio.timeout(1): + response = await client.receive_json() + assert response["success"] + + async with asyncio.timeout(1): + response = await client.receive_json() + + assert response["event"] == [ + { + "allocated": [], + "free": 0, + "slots": 0, + "source": NON_CONNECTABLE_REMOTE_SOURCE_ADDRESS, + } + ] + + manager = _get_manager() + manager.async_on_allocation_changed( + Allocations( + adapter="hci1", # Will be translated to source + slots=5, + free=4, + allocated=["AA:BB:CC:DD:EE:EE"], + ) + ) + async with asyncio.timeout(1): + response = await client.receive_json() + assert response["event"] == [ + { + "allocated": ["AA:BB:CC:DD:EE:EE"], + "free": 4, + "slots": 5, + "source": "AA:BB:CC:DD:EE:11", + } + ] + manager.async_on_allocation_changed( + Allocations( + adapter="hci1", # Will be translated to source + slots=5, + free=5, + allocated=[], + ) + ) + async with asyncio.timeout(1): + response = await client.receive_json() + assert response["event"] == [ + {"allocated": [], "free": 5, "slots": 5, "source": HCI1_SOURCE_ADDRESS} + ] + + +@pytest.mark.usefixtures("enable_bluetooth") +async def test_subscribe_subscribe_connection_allocations_specific_scanner( + hass: HomeAssistant, + register_non_connectable_scanner: None, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test bluetooth subscribe_connection_allocations for a specific source address.""" + entry = MockConfigEntry( + domain=DOMAIN, unique_id=NON_CONNECTABLE_REMOTE_SOURCE_ADDRESS + ) + entry.add_to_hass(hass) + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "bluetooth/subscribe_connection_allocations", + "config_entry_id": entry.entry_id, + } + ) + async with asyncio.timeout(1): + response = await client.receive_json() + assert response["success"] + + async with asyncio.timeout(1): + response = await client.receive_json() + + assert response["event"] == [ + { + "allocated": [], + "free": 0, + "slots": 0, + "source": NON_CONNECTABLE_REMOTE_SOURCE_ADDRESS, + } + ] + + +@pytest.mark.usefixtures("enable_bluetooth") +async def test_subscribe_subscribe_connection_allocations_invalid_config_entry_id( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test bluetooth subscribe_connection_allocations for an invalid config entry id.""" + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "bluetooth/subscribe_connection_allocations", + "config_entry_id": "non_existent", + } + ) + async with asyncio.timeout(1): + response = await client.receive_json() + assert not response["success"] + assert response["error"]["code"] == "invalid_config_entry_id" + assert response["error"]["message"] == "Config entry non_existent not found" + + +@pytest.mark.usefixtures("enable_bluetooth") +async def test_subscribe_subscribe_connection_allocations_invalid_scanner( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test bluetooth subscribe_connection_allocations for an invalid source address.""" + entry = MockConfigEntry(domain=DOMAIN, unique_id="invalid") + entry.add_to_hass(hass) + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "bluetooth/subscribe_connection_allocations", + "config_entry_id": entry.entry_id, + } + ) + async with asyncio.timeout(1): + response = await client.receive_json() + assert not response["success"] + assert response["error"]["code"] == "invalid_source" + assert response["error"]["message"] == "Source invalid not found" From 5a53ed9e5b739cd627120cb48f7ed3c027b74944 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Mon, 27 Jan 2025 23:51:40 +0000 Subject: [PATCH 0086/3148] Merge Whirlpool tests into a parameterized test (#136490) * Use fixtures in config flow tests for Whirlpool * Keep old tests; new one will go to separate PR * Merge Whirlpool tests into a parameterized test * Address review comments * Remove uneeded block wait calls --- .../components/whirlpool/test_config_flow.py | 155 ++++++++---------- 1 file changed, 67 insertions(+), 88 deletions(-) diff --git a/tests/components/whirlpool/test_config_flow.py b/tests/components/whirlpool/test_config_flow.py index 94a34c96e2c..e451fda82ad 100644 --- a/tests/components/whirlpool/test_config_flow.py +++ b/tests/components/whirlpool/test_config_flow.py @@ -20,9 +20,22 @@ CONFIG_INPUT = { } +@pytest.fixture(name="mock_whirlpool_setup_entry") +def fixture_mock_whirlpool_setup_entry(): + """Set up async_setup_entry fixture.""" + with patch( + "homeassistant.components.whirlpool.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + @pytest.mark.usefixtures("mock_auth_api", "mock_appliances_manager_api") async def test_form( - hass: HomeAssistant, region, brand, mock_backend_selector_api: MagicMock + hass: HomeAssistant, + region, + brand, + mock_backend_selector_api: MagicMock, + mock_whirlpool_setup_entry: MagicMock, ) -> None: """Test we get the form.""" result = await hass.config_entries.flow.async_init( @@ -32,14 +45,10 @@ async def test_form( assert result["type"] is FlowResultType.FORM assert result["step_id"] == config_entries.SOURCE_USER - with patch( - "homeassistant.components.whirlpool.async_setup_entry", return_value=True - ) as mock_setup_entry: - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - CONFIG_INPUT | {"region": region[0], "brand": brand[0]}, - ) - await hass.async_block_till_done() + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + CONFIG_INPUT | {"region": region[0], "brand": brand[0]}, + ) assert result2["type"] is FlowResultType.CREATE_ENTRY assert result2["title"] == "test-username" @@ -49,7 +58,7 @@ async def test_form( "region": region[0], "brand": brand[0], } - assert len(mock_setup_entry.mock_calls) == 1 + assert len(mock_whirlpool_setup_entry.mock_calls) == 1 mock_backend_selector_api.assert_called_once_with(brand[1], region[1]) @@ -70,19 +79,31 @@ async def test_form_invalid_auth( assert result2["errors"] == {"base": "invalid_auth"} -async def test_form_cannot_connect( +@pytest.mark.usefixtures("mock_appliances_manager_api") +@pytest.mark.parametrize( + ("exception", "expected_error"), + [ + (aiohttp.ClientConnectionError, "cannot_connect"), + (TimeoutError, "cannot_connect"), + (Exception, "unknown"), + ], +) +async def test_form_auth_error( hass: HomeAssistant, + exception: Exception, + expected_error: str, region, brand, mock_auth_api: MagicMock, + mock_whirlpool_setup_entry: MagicMock, ) -> None: """Test we handle cannot connect error.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - mock_auth_api.return_value.do_auth.side_effect = aiohttp.ClientConnectionError - result2 = await hass.config_entries.flow.async_configure( + mock_auth_api.return_value.do_auth.side_effect = exception + result = await hass.config_entries.flow.async_configure( result["flow_id"], CONFIG_INPUT | { @@ -90,56 +111,25 @@ async def test_form_cannot_connect( "brand": brand[0], }, ) - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "cannot_connect"} + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": expected_error} - -async def test_form_auth_timeout( - hass: HomeAssistant, - region, - brand, - mock_auth_api: MagicMock, -) -> None: - """Test we handle auth timeout error.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - mock_auth_api.return_value.do_auth.side_effect = TimeoutError - result2 = await hass.config_entries.flow.async_configure( + # Test that it succeeds after the error is cleared + mock_auth_api.return_value.do_auth.side_effect = None + result = await hass.config_entries.flow.async_configure( result["flow_id"], - CONFIG_INPUT - | { - "region": region[0], - "brand": brand[0], - }, - ) - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "cannot_connect"} - - -async def test_form_generic_auth_exception( - hass: HomeAssistant, - region, - brand, - mock_auth_api: MagicMock, -) -> None: - """Test we handle cannot connect error.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + CONFIG_INPUT | {"region": region[0], "brand": brand[0]}, ) - mock_auth_api.return_value.do_auth.side_effect = Exception - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - CONFIG_INPUT - | { - "region": region[0], - "brand": brand[0], - }, - ) - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "unknown"} + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "test-username" + assert result["data"] == { + "username": "test-username", + "password": "test-password", + "region": region[0], + "brand": brand[0], + } + assert len(mock_whirlpool_setup_entry.mock_calls) == 1 @pytest.mark.usefixtures("mock_auth_api", "mock_appliances_manager_api") @@ -167,7 +157,6 @@ async def test_form_already_configured(hass: HomeAssistant, region, brand) -> No "brand": brand[0], }, ) - await hass.async_block_till_done() assert result2["type"] is FlowResultType.ABORT assert result2["reason"] == "already_configured" @@ -191,13 +180,14 @@ async def test_no_appliances_flow( result["flow_id"], CONFIG_INPUT | {"region": region[0], "brand": brand[0]}, ) - await hass.async_block_till_done() assert result2["type"] is FlowResultType.FORM assert result2["errors"] == {"base": "no_appliances"} -@pytest.mark.usefixtures("mock_auth_api", "mock_appliances_manager_api") +@pytest.mark.usefixtures( + "mock_auth_api", "mock_appliances_manager_api", "mock_whirlpool_setup_entry" +) async def test_reauth_flow(hass: HomeAssistant, region, brand) -> None: """Test a successful reauth flow.""" mock_entry = MockConfigEntry( @@ -213,14 +203,10 @@ async def test_reauth_flow(hass: HomeAssistant, region, brand) -> None: assert result["type"] is FlowResultType.FORM assert result["errors"] == {} - with patch( - "homeassistant.components.whirlpool.async_setup_entry", return_value=True - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_PASSWORD: "new-password", CONF_BRAND: brand[0]}, - ) - await hass.async_block_till_done() + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_PASSWORD: "new-password", CONF_BRAND: brand[0]}, + ) assert result2["type"] is FlowResultType.ABORT assert result2["reason"] == "reauth_successful" @@ -232,7 +218,7 @@ async def test_reauth_flow(hass: HomeAssistant, region, brand) -> None: } -@pytest.mark.usefixtures("mock_appliances_manager_api") +@pytest.mark.usefixtures("mock_appliances_manager_api", "mock_whirlpool_setup_entry") async def test_reauth_flow_auth_error( hass: HomeAssistant, region, brand, mock_auth_api: MagicMock ) -> None: @@ -251,20 +237,16 @@ async def test_reauth_flow_auth_error( assert result["errors"] == {} mock_auth_api.return_value.is_access_token_valid.return_value = False - with patch( - "homeassistant.components.whirlpool.async_setup_entry", return_value=True - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_PASSWORD: "new-password", CONF_BRAND: brand[0]}, - ) - await hass.async_block_till_done() + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_PASSWORD: "new-password", CONF_BRAND: brand[0]}, + ) assert result2["type"] is FlowResultType.FORM assert result2["errors"] == {"base": "invalid_auth"} -@pytest.mark.usefixtures("mock_appliances_manager_api") +@pytest.mark.usefixtures("mock_appliances_manager_api", "mock_whirlpool_setup_entry") async def test_reauth_flow_connnection_error( hass: HomeAssistant, region, brand, mock_auth_api: MagicMock ) -> None: @@ -284,13 +266,10 @@ async def test_reauth_flow_connnection_error( assert result["errors"] == {} mock_auth_api.return_value.do_auth.side_effect = ClientConnectionError - with patch( - "homeassistant.components.whirlpool.async_setup_entry", return_value=True - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_PASSWORD: "new-password", CONF_BRAND: brand[0]}, - ) - await hass.async_block_till_done() + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_PASSWORD: "new-password", CONF_BRAND: brand[0]}, + ) + assert result2["type"] is FlowResultType.FORM assert result2["errors"] == {"base": "cannot_connect"} From 517d258fb416379b44cdf2330e8e3335ee54f26c Mon Sep 17 00:00:00 2001 From: IceBotYT <34712694+IceBotYT@users.noreply.github.com> Date: Mon, 27 Jan 2025 21:59:40 -0500 Subject: [PATCH 0087/3148] Increase LaCrosse View polling interval to 60 seconds (#136680) --- homeassistant/components/lacrosse_view/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/lacrosse_view/const.py b/homeassistant/components/lacrosse_view/const.py index 900463cff6e..8750d1867e6 100644 --- a/homeassistant/components/lacrosse_view/const.py +++ b/homeassistant/components/lacrosse_view/const.py @@ -1,4 +1,4 @@ """Constants for the LaCrosse View integration.""" DOMAIN = "lacrosse_view" -SCAN_INTERVAL = 30 +SCAN_INTERVAL = 60 From 48a91540e1897051a11182c250cd6b8ff8619527 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 27 Jan 2025 19:04:46 -1000 Subject: [PATCH 0088/3148] Bump aioesphomeapi to 29.0.0 and bleak-esphome to 2.2.0 (#136684) --- homeassistant/components/eq3btsmart/manifest.json | 2 +- homeassistant/components/esphome/manifest.json | 4 ++-- requirements_all.txt | 4 ++-- requirements_test_all.txt | 4 ++-- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/eq3btsmart/manifest.json b/homeassistant/components/eq3btsmart/manifest.json index 43f524516a8..bab62723c82 100644 --- a/homeassistant/components/eq3btsmart/manifest.json +++ b/homeassistant/components/eq3btsmart/manifest.json @@ -22,5 +22,5 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["eq3btsmart"], - "requirements": ["eq3btsmart==1.4.1", "bleak-esphome==2.1.1"] + "requirements": ["eq3btsmart==1.4.1", "bleak-esphome==2.2.0"] } diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 4682be1c5c7..ecc7afb3661 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -16,9 +16,9 @@ "loggers": ["aioesphomeapi", "noiseprotocol", "bleak_esphome"], "mqtt": ["esphome/discover/#"], "requirements": [ - "aioesphomeapi==28.0.1", + "aioesphomeapi==29.0.0", "esphome-dashboard-api==1.2.3", - "bleak-esphome==2.1.1" + "bleak-esphome==2.2.0" ], "zeroconf": ["_esphomelib._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index e7e1b767fe4..8e5081db52d 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==28.0.1 +aioesphomeapi==29.0.0 # homeassistant.components.flo aioflo==2021.11.0 @@ -591,7 +591,7 @@ bizkaibus==0.1.1 # homeassistant.components.eq3btsmart # homeassistant.components.esphome -bleak-esphome==2.1.1 +bleak-esphome==2.2.0 # homeassistant.components.bluetooth bleak-retry-connector==3.8.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2d7a55f1a60..4a28323c95e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -231,7 +231,7 @@ aioelectricitymaps==0.4.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==28.0.1 +aioesphomeapi==29.0.0 # homeassistant.components.flo aioflo==2021.11.0 @@ -522,7 +522,7 @@ bimmer-connected[china]==0.17.2 # homeassistant.components.eq3btsmart # homeassistant.components.esphome -bleak-esphome==2.1.1 +bleak-esphome==2.2.0 # homeassistant.components.bluetooth bleak-retry-connector==3.8.0 From 5690516852a4134a5445d5b2d888d0d1cca284da Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 28 Jan 2025 00:12:42 -0500 Subject: [PATCH 0089/3148] ChatSession: Split native content out of message class (#136668) Split native content out of message class --- .../components/assist_pipeline/pipeline.py | 3 +- .../components/conversation/__init__.py | 11 +++- .../components/conversation/default_agent.py | 5 +- .../components/conversation/session.py | 36 +++++++------ .../openai_conversation/conversation.py | 26 +++++----- tests/components/conversation/test_session.py | 51 +++++++------------ 6 files changed, 59 insertions(+), 73 deletions(-) diff --git a/homeassistant/components/assist_pipeline/pipeline.py b/homeassistant/components/assist_pipeline/pipeline.py index 9353bbe0007..9fdcc2bf690 100644 --- a/homeassistant/components/assist_pipeline/pipeline.py +++ b/homeassistant/components/assist_pipeline/pipeline.py @@ -1101,11 +1101,10 @@ class PipelineRun: "speech", "" ) chat_session.async_add_message( - conversation.ChatMessage( + conversation.Content( role="assistant", agent_id=agent_id, content=speech, - native=intent_response, ) ) conversation_result = conversation.ConversationResult( diff --git a/homeassistant/components/conversation/__init__.py b/homeassistant/components/conversation/__init__.py index 9c1db128f15..b110d53540c 100644 --- a/homeassistant/components/conversation/__init__.py +++ b/homeassistant/components/conversation/__init__.py @@ -48,21 +48,28 @@ from .default_agent import DefaultAgent, async_setup_default_agent from .entity import ConversationEntity from .http import async_setup as async_setup_conversation_http from .models import AbstractConversationAgent, ConversationInput, ConversationResult -from .session import ChatMessage, ChatSession, ConverseError, async_get_chat_session +from .session import ( + ChatSession, + Content, + ConverseError, + NativeContent, + async_get_chat_session, +) from .trace import ConversationTraceEventType, async_conversation_trace_append __all__ = [ "DOMAIN", "HOME_ASSISTANT_AGENT", "OLD_HOME_ASSISTANT_AGENT", - "ChatMessage", "ChatSession", + "Content", "ConversationEntity", "ConversationEntityFeature", "ConversationInput", "ConversationResult", "ConversationTraceEventType", "ConverseError", + "NativeContent", "async_conversation_trace_append", "async_converse", "async_get_agent_info", diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py index bb815698941..be0387555dc 100644 --- a/homeassistant/components/conversation/default_agent.py +++ b/homeassistant/components/conversation/default_agent.py @@ -62,7 +62,7 @@ from .const import ( ) from .entity import ConversationEntity from .models import ConversationInput, ConversationResult -from .session import ChatMessage, async_get_chat_session +from .session import Content, async_get_chat_session from .trace import ConversationTraceEventType, async_conversation_trace_append _LOGGER = logging.getLogger(__name__) @@ -374,11 +374,10 @@ class DefaultAgent(ConversationEntity): speech: str = response.speech.get("plain", {}).get("speech", "") chat_session.async_add_message( - ChatMessage( + Content( role="assistant", agent_id=user_input.agent_id, content=speech, - native=response, ) ) diff --git a/homeassistant/components/conversation/session.py b/homeassistant/components/conversation/session.py index 2235459954f..43f4cbf427c 100644 --- a/homeassistant/components/conversation/session.py +++ b/homeassistant/components/conversation/session.py @@ -126,7 +126,7 @@ async def async_get_chat_session( else: history = ChatSession(hass, conversation_id, user_input.agent_id) - message: ChatMessage = ChatMessage( + message: Content = Content( role="user", agent_id=user_input.agent_id, content=user_input.text, @@ -169,23 +169,21 @@ class ConverseError(HomeAssistantError): @dataclass -class ChatMessage[_NativeT]: - """Base class for chat messages. +class Content: + """Base class for chat messages.""" - When role is native, the content is to be ignored and message - is only meant for storing the native object. - """ - - role: Literal["system", "assistant", "user", "native"] + role: Literal["system", "assistant", "user"] agent_id: str | None content: str - native: _NativeT | None = field(default=None) - # Validate in post-init that if role is native, there is no content and a native object exists - def __post_init__(self) -> None: - """Validate native message.""" - if self.role == "native" and self.native is None: - raise ValueError("Native message must have a native object") + +@dataclass(frozen=True) +class NativeContent[_NativeT]: + """Native content.""" + + role: str = field(init=False, default="native") + agent_id: str + content: _NativeT @dataclass @@ -196,15 +194,15 @@ class ChatSession[_NativeT]: conversation_id: str agent_id: str | None user_name: str | None = None - messages: list[ChatMessage[_NativeT]] = field( - default_factory=lambda: [ChatMessage(role="system", agent_id=None, content="")] + messages: list[Content | NativeContent[_NativeT]] = field( + default_factory=lambda: [Content(role="system", agent_id=None, content="")] ) extra_system_prompt: str | None = None llm_api: llm.APIInstance | None = None last_updated: datetime = field(default_factory=dt_util.utcnow) @callback - def async_add_message(self, message: ChatMessage[_NativeT]) -> None: + def async_add_message(self, message: Content | NativeContent[_NativeT]) -> None: """Process intent.""" if message.role == "system": raise ValueError("Cannot add system messages to history") @@ -216,7 +214,7 @@ class ChatSession[_NativeT]: @callback def async_get_messages( self, agent_id: str | None = None - ) -> list[ChatMessage[_NativeT]]: + ) -> list[Content | NativeContent[_NativeT]]: """Get messages for a specific agent ID. This will filter out any native message tied to other agent IDs. @@ -328,7 +326,7 @@ class ChatSession[_NativeT]: self.llm_api = llm_api self.user_name = user_name self.extra_system_prompt = extra_system_prompt - self.messages[0] = ChatMessage( + self.messages[0] = Content( role="system", agent_id=user_input.agent_id, content=prompt, diff --git a/homeassistant/components/openai_conversation/conversation.py b/homeassistant/components/openai_conversation/conversation.py index 1464f4224d7..2f35bea97e2 100644 --- a/homeassistant/components/openai_conversation/conversation.py +++ b/homeassistant/components/openai_conversation/conversation.py @@ -93,12 +93,13 @@ def _message_convert(message: ChatCompletionMessage) -> ChatCompletionMessagePar def _chat_message_convert( - message: conversation.ChatMessage[ChatCompletionMessageParam], - agent_id: str | None, + message: conversation.Content + | conversation.NativeContent[ChatCompletionMessageParam], ) -> ChatCompletionMessageParam: """Convert any native chat message for this agent to the native format.""" - if message.native is not None and message.agent_id == agent_id: - return message.native + if message.role == "native": + # mypy doesn't understand that checking role ensures content type + return message.content # type: ignore[return-value] return cast( ChatCompletionMessageParam, {"role": message.role, "content": message.content}, @@ -157,14 +158,15 @@ class OpenAIConversationEntity( async with conversation.async_get_chat_session( self.hass, user_input ) as session: - return await self._async_call_api(user_input, session) + return await self._async_handle_message(user_input, session) - async def _async_call_api( + async def _async_handle_message( self, user_input: conversation.ConversationInput, session: conversation.ChatSession[ChatCompletionMessageParam], ) -> conversation.ConversationResult: """Call the API.""" + assert user_input.agent_id options = self.entry.options try: @@ -185,8 +187,7 @@ class OpenAIConversationEntity( ] messages = [ - _chat_message_convert(message, user_input.agent_id) - for message in session.async_get_messages() + _chat_message_convert(message) for message in session.async_get_messages() ] client = self.entry.runtime_data @@ -212,11 +213,10 @@ class OpenAIConversationEntity( messages.append(_message_convert(response)) session.async_add_message( - conversation.ChatMessage( + conversation.Content( role=response.role, agent_id=user_input.agent_id, content=response.content or "", - native=messages[-1], ), ) @@ -237,11 +237,9 @@ class OpenAIConversationEntity( ) ) session.async_add_message( - conversation.ChatMessage( - role="native", + conversation.NativeContent( agent_id=user_input.agent_id, - content="", - native=messages[-1], + content=messages[-1], ) ) diff --git a/tests/components/conversation/test_session.py b/tests/components/conversation/test_session.py index bca19b3b06a..60c7f2957b8 100644 --- a/tests/components/conversation/test_session.py +++ b/tests/components/conversation/test_session.py @@ -82,7 +82,7 @@ async def test_cleanup( assert chat_session.conversation_id != conversation_id conversation_id = chat_session.conversation_id chat_session.async_add_message( - session.ChatMessage( + session.Content( role="assistant", agent_id="mock-agent-id", content="Hey!", @@ -127,12 +127,6 @@ async def test_cleanup( assert len(chat_session.messages) == 2 -def test_chat_message() -> None: - """Test chat message.""" - with pytest.raises(ValueError): - session.ChatMessage(role="native", agent_id=None, content="", native=None) - - async def test_add_message( hass: HomeAssistant, mock_conversation_input: ConversationInput ) -> None: @@ -144,7 +138,7 @@ async def test_add_message( with pytest.raises(ValueError): chat_session.async_add_message( - session.ChatMessage(role="system", agent_id=None, content="") + session.Content(role="system", agent_id=None, content="") ) # No 2 user messages in a row @@ -152,19 +146,19 @@ async def test_add_message( with pytest.raises(ValueError): chat_session.async_add_message( - session.ChatMessage(role="user", agent_id=None, content="") + session.Content(role="user", agent_id=None, content="") ) # No 2 assistant messages in a row chat_session.async_add_message( - session.ChatMessage(role="assistant", agent_id=None, content="") + session.Content(role="assistant", agent_id=None, content="") ) assert len(chat_session.messages) == 3 assert chat_session.messages[-1].role == "assistant" with pytest.raises(ValueError): chat_session.async_add_message( - session.ChatMessage(role="assistant", agent_id=None, content="") + session.Content(role="assistant", agent_id=None, content="") ) @@ -177,12 +171,12 @@ async def test_message_filtering( ) as chat_session: messages = chat_session.async_get_messages(agent_id=None) assert len(messages) == 2 - assert messages[0] == session.ChatMessage( + assert messages[0] == session.Content( role="system", agent_id=None, content="", ) - assert messages[1] == session.ChatMessage( + assert messages[1] == session.Content( role="user", agent_id="mock-agent-id", content=mock_conversation_input.text, @@ -190,7 +184,7 @@ async def test_message_filtering( # Cannot add a second user message in a row with pytest.raises(ValueError): chat_session.async_add_message( - session.ChatMessage( + session.Content( role="user", agent_id="mock-agent-id", content="Hey!", @@ -198,31 +192,25 @@ async def test_message_filtering( ) chat_session.async_add_message( - session.ChatMessage( + session.Content( role="assistant", agent_id="mock-agent-id", content="Hey!", - native="assistant-reply-native", ) ) # Different agent, native messages will be filtered out. chat_session.async_add_message( - session.ChatMessage( - role="native", agent_id="another-mock-agent-id", content="", native=1 - ) + session.NativeContent(agent_id="another-mock-agent-id", content=1) ) chat_session.async_add_message( - session.ChatMessage( - role="native", agent_id="mock-agent-id", content="", native=1 - ) + session.NativeContent(agent_id="mock-agent-id", content=1) ) # A non-native message from another agent is not filtered out. chat_session.async_add_message( - session.ChatMessage( + session.Content( role="assistant", agent_id="another-mock-agent-id", content="Hi!", - native=1, ) ) @@ -231,17 +219,14 @@ async def test_message_filtering( messages = chat_session.async_get_messages(agent_id="mock-agent-id") assert len(messages) == 5 - assert messages[2] == session.ChatMessage( + assert messages[2] == session.Content( role="assistant", agent_id="mock-agent-id", content="Hey!", - native="assistant-reply-native", ) - assert messages[3] == session.ChatMessage( - role="native", agent_id="mock-agent-id", content="", native=1 - ) - assert messages[4] == session.ChatMessage( - role="assistant", agent_id="another-mock-agent-id", content="Hi!", native=1 + assert messages[3] == session.NativeContent(agent_id="mock-agent-id", content=1) + assert messages[4] == session.Content( + role="assistant", agent_id="another-mock-agent-id", content="Hi!" ) @@ -361,7 +346,7 @@ async def test_extra_systen_prompt( user_llm_prompt=None, ) chat_session.async_add_message( - session.ChatMessage( + session.Content( role="assistant", agent_id="mock-agent-id", content="Hey!", @@ -401,7 +386,7 @@ async def test_extra_systen_prompt( user_llm_prompt=None, ) chat_session.async_add_message( - session.ChatMessage( + session.Content( role="assistant", agent_id="mock-agent-id", content="Hey!", From 0cd7aff6ea33cec626d4e01dfce033e1ff945b3e Mon Sep 17 00:00:00 2001 From: Artem Sorokin Date: Tue, 28 Jan 2025 10:37:39 +0300 Subject: [PATCH 0090/3148] Add power/energy sensor for Matter draft electrical measurement cluster (#132920) --- homeassistant/components/matter/sensor.py | 84 ++++++ tests/components/matter/conftest.py | 1 + .../fixtures/nodes/yandex_smart_socket.json | 278 ++++++++++++++++++ .../matter/snapshots/test_button.ambr | 47 +++ .../matter/snapshots/test_select.ambr | 59 ++++ .../matter/snapshots/test_sensor.ambr | 162 ++++++++++ .../matter/snapshots/test_switch.ambr | 47 +++ tests/components/matter/test_sensor.py | 28 ++ 8 files changed, 706 insertions(+) create mode 100644 tests/components/matter/fixtures/nodes/yandex_smart_socket.json diff --git a/homeassistant/components/matter/sensor.py b/homeassistant/components/matter/sensor.py index 77b51d2dfbb..39e11a683f5 100644 --- a/homeassistant/components/matter/sensor.py +++ b/homeassistant/components/matter/sensor.py @@ -10,6 +10,7 @@ from chip.clusters import Objects as clusters from chip.clusters.Types import Nullable, NullValue from matter_server.client.models import device_types from matter_server.common.custom_clusters import ( + DraftElectricalMeasurementCluster, EveCluster, NeoCluster, ThirdRealityMeteringCluster, @@ -105,6 +106,35 @@ class MatterSensor(MatterEntity, SensorEntity): self._attr_native_value = value +class MatterDraftElectricalMeasurementSensor(MatterEntity, SensorEntity): + """Representation of a Matter sensor for Matter 1.0 draft ElectricalMeasurement cluster.""" + + entity_description: MatterSensorEntityDescription + + @callback + def _update_from_device(self) -> None: + """Update from device.""" + raw_value: Nullable | float | None + divisor: Nullable | float | None + multiplier: Nullable | float | None + + raw_value, divisor, multiplier = ( + self.get_matter_attribute_value(self._entity_info.attributes_to_watch[0]), + self.get_matter_attribute_value(self._entity_info.attributes_to_watch[1]), + self.get_matter_attribute_value(self._entity_info.attributes_to_watch[2]), + ) + + for value in (divisor, multiplier): + if value in (None, NullValue, 0): + self._attr_native_value = None + return + + if raw_value in (None, NullValue): + self._attr_native_value = None + else: + self._attr_native_value = round(raw_value / divisor * multiplier, 2) + + class MatterOperationalStateSensor(MatterSensor): """Representation of a sensor for Matter Operational State.""" @@ -641,6 +671,60 @@ DISCOVERY_SCHEMAS = [ clusters.ElectricalEnergyMeasurement.Attributes.CumulativeEnergyImported, ), ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="ElectricalMeasurementActivePower", + device_class=SensorDeviceClass.POWER, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfPower.WATT, + suggested_display_precision=2, + state_class=SensorStateClass.MEASUREMENT, + ), + entity_class=MatterDraftElectricalMeasurementSensor, + required_attributes=( + DraftElectricalMeasurementCluster.Attributes.ActivePower, + DraftElectricalMeasurementCluster.Attributes.AcPowerDivisor, + DraftElectricalMeasurementCluster.Attributes.AcPowerMultiplier, + ), + absent_clusters=(clusters.ElectricalPowerMeasurement,), + ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="ElectricalMeasurementRmsVoltage", + device_class=SensorDeviceClass.VOLTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + suggested_display_precision=0, + state_class=SensorStateClass.MEASUREMENT, + ), + entity_class=MatterDraftElectricalMeasurementSensor, + required_attributes=( + DraftElectricalMeasurementCluster.Attributes.RmsVoltage, + DraftElectricalMeasurementCluster.Attributes.AcVoltageDivisor, + DraftElectricalMeasurementCluster.Attributes.AcVoltageMultiplier, + ), + absent_clusters=(clusters.ElectricalPowerMeasurement,), + ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="ElectricalMeasurementRmsCurrent", + device_class=SensorDeviceClass.CURRENT, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + suggested_display_precision=2, + state_class=SensorStateClass.MEASUREMENT, + ), + entity_class=MatterDraftElectricalMeasurementSensor, + required_attributes=( + DraftElectricalMeasurementCluster.Attributes.RmsCurrent, + DraftElectricalMeasurementCluster.Attributes.AcCurrentDivisor, + DraftElectricalMeasurementCluster.Attributes.AcCurrentMultiplier, + ), + absent_clusters=(clusters.ElectricalPowerMeasurement,), + ), MatterDiscoverySchema( platform=Platform.SENSOR, entity_description=MatterSensorEntityDescription( diff --git a/tests/components/matter/conftest.py b/tests/components/matter/conftest.py index 4e078f86939..d7429f6087d 100644 --- a/tests/components/matter/conftest.py +++ b/tests/components/matter/conftest.py @@ -116,6 +116,7 @@ async def integration_fixture( "window_covering_pa_lift", "window_covering_pa_tilt", "window_covering_tilt", + "yandex_smart_socket", ] ) async def matter_devices( diff --git a/tests/components/matter/fixtures/nodes/yandex_smart_socket.json b/tests/components/matter/fixtures/nodes/yandex_smart_socket.json new file mode 100644 index 00000000000..26cdf38414f --- /dev/null +++ b/tests/components/matter/fixtures/nodes/yandex_smart_socket.json @@ -0,0 +1,278 @@ +{ + "node_id": 4, + "date_commissioned": "2024-12-05T10:54:31.635203", + "last_interview": "2024-12-05T12:16:52.038776", + "interview_version": 6, + "available": true, + "is_bridge": false, + "attributes": { + "0/29/0": [ + { + "0": 22, + "1": 1 + } + ], + "0/29/1": [29, 31, 40, 42, 48, 49, 51, 52, 54, 60, 62, 63], + "0/29/2": [41], + "0/29/3": [1], + "0/29/65532": 0, + "0/29/65533": 2, + "0/29/65528": [], + "0/29/65529": [], + "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/31/0": [ + { + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 3 + } + ], + "0/31/1": [], + "0/31/2": 4, + "0/31/3": 3, + "0/31/4": 4, + "0/31/65532": 0, + "0/31/65533": 1, + "0/31/65528": [], + "0/31/65529": [], + "0/31/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "0/40/0": 17, + "0/40/1": "Yandex", + "0/40/2": 5130, + "0/40/3": "YNDX-00540", + "0/40/4": 540, + "0/40/5": "", + "0/40/6": "XX", + "0/40/7": 0, + "0/40/8": "v0.4", + "0/40/9": 18, + "0/40/10": "8.0.r13402545-18", + "0/40/15": "HP000RM000V4RW", + "0/40/17": true, + "0/40/18": "E4480D32A5480B29", + "0/40/19": { + "0": 3, + "1": 3 + }, + "0/40/65532": 0, + "0/40/65533": 1, + "0/40/65528": [], + "0/40/65529": [], + "0/40/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 15, 17, 18, 19, 65528, 65529, 65531, + 65532, 65533 + ], + "0/42/0": [], + "0/42/1": true, + "0/42/2": 1, + "0/42/3": null, + "0/42/65532": 0, + "0/42/65533": 1, + "0/42/65528": [], + "0/42/65529": [0], + "0/42/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/48/0": 0, + "0/48/1": { + "0": 60, + "1": 900 + }, + "0/48/2": 0, + "0/48/3": 0, + "0/48/4": true, + "0/48/65532": 0, + "0/48/65533": 1, + "0/48/65528": [1, 3, 5], + "0/48/65529": [0, 2, 4], + "0/48/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "0/49/0": 1, + "0/49/1": [ + { + "0": "**REDACTED**", + "1": true + } + ], + "0/49/2": 10, + "0/49/3": 30, + "0/49/4": true, + "0/49/5": 0, + "0/49/6": "**REDACTED**", + "0/49/7": null, + "0/49/65532": 1, + "0/49/65533": 1, + "0/49/65528": [1, 5, 7], + "0/49/65529": [0, 2, 4, 6, 8], + "0/49/65531": [0, 1, 2, 3, 4, 5, 6, 7, 65528, 65529, 65531, 65532, 65533], + "0/51/0": [ + { + "0": "WIFI_STA_DEF", + "1": true, + "2": null, + "3": null, + "4": "PAtP8Nse", + "5": ["wKgAEw=="], + "6": ["/oAAAAAAAAA+C0///vDbHg==", "/YrmoeskHZU+C0///vDbHg=="], + "7": 1 + } + ], + "0/51/1": 4, + "0/51/2": 124, + "0/51/3": 0, + "0/51/4": 1, + "0/51/5": [], + "0/51/6": [], + "0/51/7": [], + "0/51/8": false, + "0/51/65532": 0, + "0/51/65533": 1, + "0/51/65528": [], + "0/51/65529": [0], + "0/51/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 65528, 65529, 65531, 65532, 65533 + ], + "0/52/1": 79260, + "0/52/2": 171268, + "0/52/65532": 0, + "0/52/65533": 1, + "0/52/65528": [], + "0/52/65529": [], + "0/52/65531": [1, 2, 65528, 65529, 65531, 65532, 65533], + "0/54/0": "eJoYDvok", + "0/54/1": 4, + "0/54/2": 3, + "0/54/3": 11, + "0/54/4": -53, + "0/54/65532": 0, + "0/54/65533": 1, + "0/54/65528": [], + "0/54/65529": [], + "0/54/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "0/60/0": 0, + "0/60/1": null, + "0/60/2": null, + "0/60/65532": 0, + "0/60/65533": 1, + "0/60/65528": [], + "0/60/65529": [0, 1, 2], + "0/60/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], + "0/62/0": [ + { + "1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRBBgkBwEkCAEwCUEEvOt9COzrjgf+b8q6FcKeKfbtqJybToVtEF0jiidbqg8FPmTIPTm1kU9hEiE6sd2N/GWSQHRoMi3YNl19h1PM3zcKNQEoARgkAgE2AwQCBAEYMAQUSu0+nQ/nOzrUNECyeBAqGPVu33YwBRS2PEiS/N109emRL3DTMaiWoWrEShgwC0DoGPCGt0HeGYnTS4TS2R7vbNhiFuuIrUQuxY5phP/UXBZosBDQTsnRTbMof18OkeO68MEcLXdIXjBJvBDaP/TsGA==", + "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEz8nO5tz0gMFM5TW4YjYXGxkhr/UKHZg1rCa21StYqGd0wGaP7a5eMR+2BY20D1b11R7i6teWKnAaW+WqY0vQTjcKNQEpARgkAmAwBBS2PEiS/N109emRL3DTMaiWoWrESjAFFG//dFS5V0Y6/QdSQcC+z7idKKeJGDALQGFBsf7Ecq44e7NN8dCZIoJMUG16rmwD4ZtHtD4JPTxYabEreeblNF2ZDSgbo+A8sfz7Ci37WjznxbEj96vR8MgY", + "254": 3 + } + ], + "0/62/1": [ + { + "1": "BLB0QnDldRPfV2xt6Nd/34ja8uaWwvsLYZsF3yCdIwyB/krYZ0u1uBS0FTo7E3iqvN0cDZ7fbhw0OUsKTVZ9Y10=", + "2": 65521, + "3": 1, + "4": 4, + "5": "", + "254": 3 + } + ], + "0/62/2": 5, + "0/62/3": 4, + "0/62/4": [ + "FTABAQAkAgE3AyYU3a/iASYVn7wdXBgmBKaW1SwkBQA3BiYU3a/iASYVn7wdXBgkBwEkCAEwCUEE/OyhHiUZDgJ7iUVCKouxsZgI0DGBcK8E+vbDIHD5gfeFPNuT5sXN8aHlsEl7fZhfjbdEbIFudeJKIr5uf7+PLTcKNQEpARgkAmAwBBQtr6wAOFJ7UJLwYUKvomZh5wPaszAFFC2vrAA4UntQkvBhQq+iZmHnA9qzGDALQM5/1ziQdNcMURJqGH+j9wt7w/wPyeq8zf+u3FGgmmfhBSouJw4f+TIJLk7m/eQD0p2Q5rSDEuuwI2VBTxxeuWgY", + "FTABAQAkAgE3AycUe5hjm9Wdt4YmFewk8wUYJgTGN00tJAUANwYnFHuYY5vVnbeGJhXsJPMFGCQHASQIATAJQQR56PnGPW5p1dXhHDSVnjoah8C2+JYHzPAm5tvYgup9gf7DukH2TxxLdDEaBdD4hgQj/R8hrMYSmj8XmHQ8HhdZNwo1ASkBGCQCYDAEFN8wYcjYskj9OSQoEXkOn0QmWDrkMAUU3zBhyNiySP05JCgReQ6fRCZYOuQYMAtA+j7ir4H1KYIxAe49jhZr/Gg7pDUKtIcYyUVJD0g9egIYHShM1y1j3BsOQTBX6mnLPp4FS4AtNsUgaM+XPKSFSxg=", + "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQUARgkBwEkCAEwCUEEsHRCcOV1E99XbG3o13/fiNry5pbC+wthmwXfIJ0jDIH+SthnS7W4FLQVOjsTeKq83RwNnt9uHDQ5SwpNVn1jXTcKNQEpARgkAmAwBBRv/3RUuVdGOv0HUkHAvs+4nSiniTAFFG//dFS5V0Y6/QdSQcC+z7idKKeJGDALQKrvVhoinxo07C2nI/zakt4xUZKgab6DVI4mBXYoPQXaZM8jmEqWboPnLBUGbr9UAnqEc9yARHwlC77eXN1BCdUY", + "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQUARgkBwEkCAEwCUEEMmvMdDf/h+u7fawdjIe6gXEeWuszCShR8ulsHMLnJYTMHVrkztOcj4cHw6haH/q909aVmL3xLlbEC2lZtmZClDcKNQEpARgkAmAwBBRoZjEcSXeh6IFBtW0A2OilJBdeYjAFFGhmMRxJd6HogUG1bQDY6KUkF15iGDALQJm5+/SkVrR4iBpGVqZZGOH+DpS+cQYqceN1+JSnDFwxJe+khYxFifMohSQ5NLlTiJQTZWYpqMKMZHT36pWWADUY" + ], + "0/62/5": 3, + "0/62/65532": 0, + "0/62/65533": 1, + "0/62/65528": [1, 3, 5, 8], + "0/62/65529": [0, 2, 4, 6, 7, 9, 10, 11], + "0/62/65531": [0, 1, 2, 3, 4, 5, 65528, 65529, 65531, 65532, 65533], + "0/63/0": [], + "0/63/1": [], + "0/63/2": 4, + "0/63/3": 3, + "0/63/65532": 0, + "0/63/65533": 2, + "0/63/65528": [2, 5], + "0/63/65529": [0, 1, 3, 4], + "0/63/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "1/3/0": 0, + "1/3/1": 2, + "1/3/65532": 0, + "1/3/65533": 4, + "1/3/65528": [], + "1/3/65529": [0, 64], + "1/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "1/4/0": 128, + "1/4/65532": 1, + "1/4/65533": 4, + "1/4/65528": [0, 1, 2, 3], + "1/4/65529": [0, 1, 2, 3, 4, 5], + "1/4/65531": [0, 65528, 65529, 65531, 65532, 65533], + "1/6/0": true, + "1/6/16384": true, + "1/6/16385": 0, + "1/6/16386": 0, + "1/6/16387": 0, + "1/6/65532": 1, + "1/6/65533": 4, + "1/6/65528": [], + "1/6/65529": [0, 1, 2, 64, 65, 66], + "1/6/65531": [ + 0, 16384, 16385, 16386, 16387, 65528, 65529, 65531, 65532, 65533 + ], + "1/29/0": [ + { + "0": 266, + "1": 2 + } + ], + "1/29/1": [3, 4, 6, 29, 2820, 336264194, 336264195], + "1/29/2": [], + "1/29/3": [], + "1/29/65532": 0, + "1/29/65533": 2, + "1/29/65528": [], + "1/29/65529": [], + "1/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "1/2820/0": 9, + "1/2820/1285": 2170, + "1/2820/1288": 592, + "1/2820/1291": 560, + "1/2820/1536": 1, + "1/2820/1537": 10, + "1/2820/1538": 1, + "1/2820/1539": 1000, + "1/2820/1540": 1, + "1/2820/1541": 8, + "1/2820/2049": 2530, + "1/2820/2050": 16300, + "1/2820/65532": 0, + "1/2820/65533": 3, + "1/2820/65528": [], + "1/2820/65529": [], + "1/2820/65531": [ + 0, 1285, 1288, 1291, 1536, 1537, 1538, 1539, 1540, 1541, 2049, 2050, + 65528, 65529, 65531, 65532, 65533 + ], + "1/336264194/336199680": 44, + "1/336264194/336199681": 0, + "1/336264194/336199682": 0, + "1/336264194/336199698": 70, + "1/336264194/65532": 0, + "1/336264194/65533": 1, + "1/336264194/65528": [], + "1/336264194/65529": [], + "1/336264194/65531": [ + 65528, 65529, 65531, 336199680, 336199681, 336199682, 336199698, 65532, + 65533 + ], + "1/336264195/336199680": 0, + "1/336264195/65532": 0, + "1/336264195/65533": 1, + "1/336264195/65528": [], + "1/336264195/65529": [], + "1/336264195/65531": [65528, 65529, 65531, 336199680, 65532, 65533] + }, + "attribute_subscriptions": [] +} diff --git a/tests/components/matter/snapshots/test_button.ambr b/tests/components/matter/snapshots/test_button.ambr index 7973f1a5147..dbbc984ab2f 100644 --- a/tests/components/matter/snapshots/test_button.ambr +++ b/tests/components/matter/snapshots/test_button.ambr @@ -1819,3 +1819,50 @@ 'state': 'unknown', }) # --- +# name: test_buttons[yandex_smart_socket][button.yndx_00540_identify-entry] + 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.yndx_00540_identify', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Identify', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-0000000000000004-MatterNodeDevice-1-IdentifyButton-3-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[yandex_smart_socket][button.yndx_00540_identify-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'identify', + 'friendly_name': 'YNDX-00540 Identify', + }), + 'context': , + 'entity_id': 'button.yndx_00540_identify', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/matter/snapshots/test_select.ambr b/tests/components/matter/snapshots/test_select.ambr index 19a90503086..9a2639ba7e1 100644 --- a/tests/components/matter/snapshots/test_select.ambr +++ b/tests/components/matter/snapshots/test_select.ambr @@ -1852,3 +1852,62 @@ 'state': 'Quick', }) # --- +# name: test_selects[yandex_smart_socket][select.yndx_00540_power_on_behavior_on_startup-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'on', + 'off', + 'toggle', + 'previous', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.yndx_00540_power_on_behavior_on_startup', + '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': 'Power-on behavior on startup', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'startup_on_off', + 'unique_id': '00000000000004D2-0000000000000004-MatterNodeDevice-1-MatterStartUpOnOff-6-16387', + 'unit_of_measurement': None, + }) +# --- +# name: test_selects[yandex_smart_socket][select.yndx_00540_power_on_behavior_on_startup-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'YNDX-00540 Power-on behavior on startup', + 'options': list([ + 'on', + 'off', + 'toggle', + 'previous', + ]), + }), + 'context': , + 'entity_id': 'select.yndx_00540_power_on_behavior_on_startup', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/matter/snapshots/test_sensor.ambr b/tests/components/matter/snapshots/test_sensor.ambr index 5e22b9a1476..0215abf47c6 100644 --- a/tests/components/matter/snapshots/test_sensor.ambr +++ b/tests/components/matter/snapshots/test_sensor.ambr @@ -3354,3 +3354,165 @@ 'state': '28.3', }) # --- +# name: test_sensors[yandex_smart_socket][sensor.yndx_00540_current-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': , + 'entity_id': 'sensor.yndx_00540_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-0000000000000004-MatterNodeDevice-1-ElectricalMeasurementRmsCurrent-2820-1288', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[yandex_smart_socket][sensor.yndx_00540_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'YNDX-00540 Current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.yndx_00540_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.59', + }) +# --- +# name: test_sensors[yandex_smart_socket][sensor.yndx_00540_power-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': , + 'entity_id': 'sensor.yndx_00540_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-0000000000000004-MatterNodeDevice-1-ElectricalMeasurementActivePower-2820-1291', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[yandex_smart_socket][sensor.yndx_00540_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'YNDX-00540 Power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.yndx_00540_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '70.0', + }) +# --- +# name: test_sensors[yandex_smart_socket][sensor.yndx_00540_voltage-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': , + 'entity_id': 'sensor.yndx_00540_voltage', + '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': None, + 'original_name': 'Voltage', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-0000000000000004-MatterNodeDevice-1-ElectricalMeasurementRmsVoltage-2820-1285', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[yandex_smart_socket][sensor.yndx_00540_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'YNDX-00540 Voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.yndx_00540_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '217.0', + }) +# --- diff --git a/tests/components/matter/snapshots/test_switch.ambr b/tests/components/matter/snapshots/test_switch.ambr index 612e81580a5..8277ee28838 100644 --- a/tests/components/matter/snapshots/test_switch.ambr +++ b/tests/components/matter/snapshots/test_switch.ambr @@ -421,3 +421,50 @@ 'state': 'on', }) # --- +# name: test_switches[yandex_smart_socket][switch.yndx_00540-entry] + 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.yndx_00540', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-0000000000000004-MatterNodeDevice-1-MatterPlug-6-0', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[yandex_smart_socket][switch.yndx_00540-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'YNDX-00540', + }), + 'context': , + 'entity_id': 'switch.yndx_00540', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/matter/test_sensor.py b/tests/components/matter/test_sensor.py index bd3e146264a..8a5fbf48a49 100644 --- a/tests/components/matter/test_sensor.py +++ b/tests/components/matter/test_sensor.py @@ -351,3 +351,31 @@ async def test_operational_state_sensor( state = hass.states.get("sensor.dishwasher_operational_state") assert state assert state.state == "extra_state" + + +@pytest.mark.parametrize("node_fixture", ["yandex_smart_socket"]) +async def test_draft_electrical_measurement_sensor( + hass: HomeAssistant, + matter_client: MagicMock, + matter_node: MatterNode, +) -> None: + """Test Draft Electrical Measurement cluster sensors, using Yandex Smart Socket fixture.""" + state = hass.states.get("sensor.yndx_00540_power") + assert state + assert state.state == "70.0" + + # AcPowerDivisor + set_node_attribute(matter_node, 1, 2820, 1541, 0) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get("sensor.yndx_00540_power") + assert state + assert state.state == "unknown" + + # ActivePower + set_node_attribute(matter_node, 1, 2820, 1291, None) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get("sensor.yndx_00540_power") + assert state + assert state.state == "unknown" From b43379be7d1d96a7d6d5d506b34e364cba6c1737 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 28 Jan 2025 08:48:34 +0100 Subject: [PATCH 0091/3148] Standardize `helpers.xxx_registry` imports (#136688) Standardize registry imports --- homeassistant/components/acmeda/__init__.py | 2 +- homeassistant/components/ambient_station/__init__.py | 3 +-- homeassistant/components/analytics/analytics.py | 2 +- homeassistant/components/aprilaire/coordinator.py | 2 +- homeassistant/components/assist_pipeline/logbook.py | 2 +- homeassistant/components/bang_olufsen/__init__.py | 2 +- homeassistant/components/bang_olufsen/diagnostics.py | 2 +- homeassistant/components/cloud/assist_pipeline.py | 2 +- homeassistant/components/deconz/logbook.py | 2 +- homeassistant/components/esphome/entity.py | 3 +-- homeassistant/components/esphome/manager.py | 3 +-- homeassistant/components/fully_kiosk/services.py | 2 +- homeassistant/components/fyta/coordinator.py | 2 +- homeassistant/components/group/notify.py | 2 +- homeassistant/components/hue/v2/group.py | 2 +- .../components/hunterdouglas_powerview/__init__.py | 2 +- homeassistant/components/hunterdouglas_powerview/entity.py | 2 +- homeassistant/components/isy994/__init__.py | 7 +++++-- homeassistant/components/isy994/util.py | 2 +- homeassistant/components/lamarzocco/coordinator.py | 2 +- homeassistant/components/matter/entity.py | 2 +- homeassistant/components/minecraft_server/__init__.py | 3 +-- homeassistant/components/nasweb/switch.py | 2 +- homeassistant/components/octoprint/__init__.py | 2 +- homeassistant/components/pvpc_hourly_pricing/__init__.py | 2 +- homeassistant/components/ring/config_flow.py | 2 +- homeassistant/components/roomba/entity.py | 2 +- homeassistant/components/sabnzbd/__init__.py | 3 +-- homeassistant/components/schlage/coordinator.py | 2 +- homeassistant/components/solarlog/coordinator.py | 2 +- homeassistant/components/sonos/entity.py | 2 +- homeassistant/components/switchbee/__init__.py | 3 +-- homeassistant/components/tedee/coordinator.py | 2 +- homeassistant/components/tradfri/__init__.py | 2 +- homeassistant/components/unifi/device_tracker.py | 2 +- homeassistant/components/unifi/switch.py | 2 +- homeassistant/components/unifiprotect/entity.py | 2 +- homeassistant/components/withings/binary_sensor.py | 2 +- homeassistant/components/withings/calendar.py | 2 +- homeassistant/components/youtube/__init__.py | 2 +- homeassistant/components/zha/logbook.py | 2 +- homeassistant/components/zwave_js/api.py | 3 +-- homeassistant/components/zwave_js/logbook.py | 2 +- tests/components/bsblan/test_climate.py | 2 +- tests/components/bsblan/test_sensor.py | 2 +- tests/components/bsblan/test_water_heater.py | 2 +- tests/components/cloud/test_repairs.py | 2 +- tests/components/dhcp/test_init.py | 2 +- tests/components/esphome/test_assist_satellite.py | 7 +++++-- tests/components/esphome/test_media_player.py | 2 +- tests/components/fan/test_init.py | 2 +- tests/components/flexit_bacnet/test_climate.py | 2 +- tests/components/group/test_sensor.py | 3 +-- tests/components/hassio/test_repairs.py | 2 +- tests/components/home_connect/test_binary_sensor.py | 2 +- tests/components/home_connect/test_switch.py | 2 +- tests/components/html5/test_config_flow.py | 2 +- tests/components/html5/test_init.py | 2 +- .../components/hunterdouglas_powerview/test_config_flow.py | 2 +- tests/components/lcn/test_binary_sensor.py | 3 +-- tests/components/lcn/test_services.py | 2 +- tests/components/lock/test_init.py | 2 +- tests/components/madvr/test_binary_sensor.py | 2 +- tests/components/madvr/test_remote.py | 2 +- tests/components/madvr/test_sensor.py | 2 +- tests/components/matter/test_lock.py | 2 +- tests/components/melissa/test_climate.py | 2 +- tests/components/min_max/test_sensor.py | 2 +- tests/components/netatmo/common.py | 2 +- tests/components/netatmo/test_button.py | 2 +- tests/components/netatmo/test_camera.py | 2 +- tests/components/netatmo/test_climate.py | 2 +- tests/components/netatmo/test_cover.py | 2 +- tests/components/netatmo/test_fan.py | 2 +- tests/components/netatmo/test_light.py | 2 +- tests/components/netatmo/test_select.py | 2 +- tests/components/netatmo/test_switch.py | 2 +- tests/components/plugwise/test_sensor.py | 2 +- tests/components/plugwise/test_switch.py | 2 +- tests/components/proximity/test_init.py | 3 +-- tests/components/schlage/test_init.py | 2 +- tests/components/sun/test_sensor.py | 2 +- tests/components/tod/test_binary_sensor.py | 2 +- tests/components/velbus/test_services.py | 2 +- tests/components/zwave_js/test_repairs.py | 3 +-- tests/components/zwave_js/test_select.py | 2 +- 86 files changed, 94 insertions(+), 99 deletions(-) diff --git a/homeassistant/components/acmeda/__init__.py b/homeassistant/components/acmeda/__init__.py index 62a62795a05..ec7abe258cf 100644 --- a/homeassistant/components/acmeda/__init__.py +++ b/homeassistant/components/acmeda/__init__.py @@ -3,7 +3,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -import homeassistant.helpers.entity_registry as er +from homeassistant.helpers import entity_registry as er from .hub import PulseHub diff --git a/homeassistant/components/ambient_station/__init__.py b/homeassistant/components/ambient_station/__init__.py index 469ad7e6e06..374c313a144 100644 --- a/homeassistant/components/ambient_station/__init__.py +++ b/homeassistant/components/ambient_station/__init__.py @@ -17,9 +17,8 @@ from homeassistant.const import ( ) from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady -import homeassistant.helpers.device_registry as dr +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_send -import homeassistant.helpers.entity_registry as er from .const import ( ATTR_LAST_DATA, diff --git a/homeassistant/components/analytics/analytics.py b/homeassistant/components/analytics/analytics.py index b63475c80a4..9260642a58f 100644 --- a/homeassistant/components/analytics/analytics.py +++ b/homeassistant/components/analytics/analytics.py @@ -27,8 +27,8 @@ from homeassistant.config_entries import SOURCE_IGNORE from homeassistant.const import ATTR_DOMAIN, __version__ as HA_VERSION from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.entity_registry as er from homeassistant.helpers.hassio import is_hassio from homeassistant.helpers.storage import Store from homeassistant.helpers.system_info import async_get_system_info diff --git a/homeassistant/components/aprilaire/coordinator.py b/homeassistant/components/aprilaire/coordinator.py index 6b132cfcc95..a5126eda95e 100644 --- a/homeassistant/components/aprilaire/coordinator.py +++ b/homeassistant/components/aprilaire/coordinator.py @@ -11,7 +11,7 @@ from pyaprilaire.const import MODELS, Attribute, FunctionalDomain from homeassistant.config_entries import ConfigEntry from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback -import homeassistant.helpers.device_registry as dr +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import BaseDataUpdateCoordinatorProtocol diff --git a/homeassistant/components/assist_pipeline/logbook.py b/homeassistant/components/assist_pipeline/logbook.py index 50c5176bb22..b7ab24d2f2f 100644 --- a/homeassistant/components/assist_pipeline/logbook.py +++ b/homeassistant/components/assist_pipeline/logbook.py @@ -7,7 +7,7 @@ from collections.abc import Callable from homeassistant.components.logbook import LOGBOOK_ENTRY_MESSAGE, LOGBOOK_ENTRY_NAME from homeassistant.const import ATTR_DEVICE_ID from homeassistant.core import Event, HomeAssistant, callback -import homeassistant.helpers.device_registry as dr +from homeassistant.helpers import device_registry as dr from .const import DOMAIN, EVENT_RECORDING diff --git a/homeassistant/components/bang_olufsen/__init__.py b/homeassistant/components/bang_olufsen/__init__.py index b80e625e8d4..eab2bb3d4e5 100644 --- a/homeassistant/components/bang_olufsen/__init__.py +++ b/homeassistant/components/bang_olufsen/__init__.py @@ -17,7 +17,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_MODEL, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -import homeassistant.helpers.device_registry as dr +from homeassistant.helpers import device_registry as dr from homeassistant.util.ssl import get_default_context from .const import DOMAIN diff --git a/homeassistant/components/bang_olufsen/diagnostics.py b/homeassistant/components/bang_olufsen/diagnostics.py index cab7eae5e25..bf7b06e694a 100644 --- a/homeassistant/components/bang_olufsen/diagnostics.py +++ b/homeassistant/components/bang_olufsen/diagnostics.py @@ -6,7 +6,7 @@ from typing import TYPE_CHECKING, Any from homeassistant.components.media_player import DOMAIN as MEDIA_PLAYER_DOMAIN from homeassistant.core import HomeAssistant -import homeassistant.helpers.entity_registry as er +from homeassistant.helpers import entity_registry as er from . import BangOlufsenConfigEntry from .const import DOMAIN diff --git a/homeassistant/components/cloud/assist_pipeline.py b/homeassistant/components/cloud/assist_pipeline.py index c97e5bdc0a2..0e3736d9da8 100644 --- a/homeassistant/components/cloud/assist_pipeline.py +++ b/homeassistant/components/cloud/assist_pipeline.py @@ -14,7 +14,7 @@ from homeassistant.components.stt import DOMAIN as STT_DOMAIN from homeassistant.components.tts import DOMAIN as TTS_DOMAIN from homeassistant.const import Platform from homeassistant.core import HomeAssistant -import homeassistant.helpers.entity_registry as er +from homeassistant.helpers import entity_registry as er from .const import ( DATA_PLATFORMS_SETUP, diff --git a/homeassistant/components/deconz/logbook.py b/homeassistant/components/deconz/logbook.py index 3ef14eca657..28dfb603d8b 100644 --- a/homeassistant/components/deconz/logbook.py +++ b/homeassistant/components/deconz/logbook.py @@ -7,7 +7,7 @@ from collections.abc import Callable from homeassistant.components.logbook import LOGBOOK_ENTRY_MESSAGE, LOGBOOK_ENTRY_NAME from homeassistant.const import ATTR_DEVICE_ID, CONF_EVENT, CONF_ID from homeassistant.core import Event, HomeAssistant, callback -import homeassistant.helpers.device_registry as dr +from homeassistant.helpers import device_registry as dr from .const import CONF_GESTURE, DOMAIN as DECONZ_DOMAIN from .deconz_event import CONF_DECONZ_ALARM_EVENT, CONF_DECONZ_EVENT diff --git a/homeassistant/components/esphome/entity.py b/homeassistant/components/esphome/entity.py index 455a3f8d105..ae9e0d2491d 100644 --- a/homeassistant/components/esphome/entity.py +++ b/homeassistant/components/esphome/entity.py @@ -19,9 +19,8 @@ import voluptuous as vol from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import entity_platform +from homeassistant.helpers import device_registry as dr, entity_platform import homeassistant.helpers.config_validation as cv -import homeassistant.helpers.device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import AddEntitiesCallback diff --git a/homeassistant/components/esphome/manager.py b/homeassistant/components/esphome/manager.py index b382622281e..494df51721a 100644 --- a/homeassistant/components/esphome/manager.py +++ b/homeassistant/components/esphome/manager.py @@ -41,9 +41,8 @@ from homeassistant.core import ( callback, ) from homeassistant.exceptions import TemplateError -from homeassistant.helpers import template +from homeassistant.helpers import device_registry as dr, template import homeassistant.helpers.config_validation as cv -import homeassistant.helpers.device_registry as dr from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.event import async_track_state_change_event from homeassistant.helpers.issue_registry import ( diff --git a/homeassistant/components/fully_kiosk/services.py b/homeassistant/components/fully_kiosk/services.py index 089ae1d4246..bff78aa627a 100644 --- a/homeassistant/components/fully_kiosk/services.py +++ b/homeassistant/components/fully_kiosk/services.py @@ -8,8 +8,8 @@ from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import ATTR_DEVICE_ID from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr import homeassistant.helpers.config_validation as cv -import homeassistant.helpers.device_registry as dr from .const import ( ATTR_APPLICATION, diff --git a/homeassistant/components/fyta/coordinator.py b/homeassistant/components/fyta/coordinator.py index 553960bdcc6..a0c42d449d5 100644 --- a/homeassistant/components/fyta/coordinator.py +++ b/homeassistant/components/fyta/coordinator.py @@ -19,7 +19,7 @@ from fyta_cli.fyta_models import Plant from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -import homeassistant.helpers.device_registry as dr +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import CONF_EXPIRATION, DOMAIN diff --git a/homeassistant/components/group/notify.py b/homeassistant/components/group/notify.py index fdef327cb73..5bba2a677d5 100644 --- a/homeassistant/components/group/notify.py +++ b/homeassistant/components/group/notify.py @@ -28,9 +28,9 @@ from homeassistant.const import ( STATE_UNAVAILABLE, ) from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import entity_registry as er import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.helpers.entity_registry as er from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .entity import GroupEntity diff --git a/homeassistant/components/hue/v2/group.py b/homeassistant/components/hue/v2/group.py index c7f966ce9f2..17cd20b55aa 100644 --- a/homeassistant/components/hue/v2/group.py +++ b/homeassistant/components/hue/v2/group.py @@ -24,9 +24,9 @@ from homeassistant.components.light import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.helpers.entity_registry as er from homeassistant.util import color as color_util from ..bridge import HueBridge diff --git a/homeassistant/components/hunterdouglas_powerview/__init__.py b/homeassistant/components/hunterdouglas_powerview/__init__.py index d9358db2753..b4bbc37b1e8 100644 --- a/homeassistant/components/hunterdouglas_powerview/__init__.py +++ b/homeassistant/components/hunterdouglas_powerview/__init__.py @@ -11,7 +11,7 @@ from aiopvapi.shades import Shades from homeassistant.const import CONF_API_VERSION, CONF_HOST, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -import homeassistant.helpers.entity_registry as er +from homeassistant.helpers import entity_registry as er from .const import DOMAIN, HUB_EXCEPTIONS from .coordinator import PowerviewShadeUpdateCoordinator diff --git a/homeassistant/components/hunterdouglas_powerview/entity.py b/homeassistant/components/hunterdouglas_powerview/entity.py index ba572ecefce..f2a841a7d0e 100644 --- a/homeassistant/components/hunterdouglas_powerview/entity.py +++ b/homeassistant/components/hunterdouglas_powerview/entity.py @@ -4,7 +4,7 @@ import logging from aiopvapi.resources.shade import BaseShade, ShadePosition -import homeassistant.helpers.device_registry as dr +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity diff --git a/homeassistant/components/isy994/__init__.py b/homeassistant/components/isy994/__init__.py index d2862054971..738c7e2d5ad 100644 --- a/homeassistant/components/isy994/__init__.py +++ b/homeassistant/components/isy994/__init__.py @@ -21,8 +21,11 @@ from homeassistant.const import ( ) from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers import aiohttp_client, config_validation as cv -import homeassistant.helpers.device_registry as dr +from homeassistant.helpers import ( + aiohttp_client, + config_validation as cv, + device_registry as dr, +) from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from .const import ( diff --git a/homeassistant/components/isy994/util.py b/homeassistant/components/isy994/util.py index ed1a5abca8b..ca5c5ea46a9 100644 --- a/homeassistant/components/isy994/util.py +++ b/homeassistant/components/isy994/util.py @@ -3,7 +3,7 @@ from __future__ import annotations from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.entity_registry as er +from homeassistant.helpers import entity_registry as er from .const import _LOGGER, DOMAIN diff --git a/homeassistant/components/lamarzocco/coordinator.py b/homeassistant/components/lamarzocco/coordinator.py index 2385039f53d..dddca6565e4 100644 --- a/homeassistant/components/lamarzocco/coordinator.py +++ b/homeassistant/components/lamarzocco/coordinator.py @@ -17,7 +17,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed -import homeassistant.helpers.device_registry as dr +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN diff --git a/homeassistant/components/matter/entity.py b/homeassistant/components/matter/entity.py index a6d0dbb08d8..96696193466 100644 --- a/homeassistant/components/matter/entity.py +++ b/homeassistant/components/matter/entity.py @@ -20,9 +20,9 @@ from propcache.api import cached_property from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity, EntityDescription -import homeassistant.helpers.entity_registry as er from homeassistant.helpers.typing import UndefinedType from .const import DOMAIN, FEATUREMAP_ATTRIBUTE_ID, ID_TYPE_DEVICE_ID diff --git a/homeassistant/components/minecraft_server/__init__.py b/homeassistant/components/minecraft_server/__init__.py index f937c304471..f1392ea488a 100644 --- a/homeassistant/components/minecraft_server/__init__.py +++ b/homeassistant/components/minecraft_server/__init__.py @@ -20,8 +20,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady -import homeassistant.helpers.device_registry as dr -import homeassistant.helpers.entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er from .api import MinecraftServer, MinecraftServerAddressError, MinecraftServerType from .const import DOMAIN, KEY_LATENCY, KEY_MOTD diff --git a/homeassistant/components/nasweb/switch.py b/homeassistant/components/nasweb/switch.py index 00e5a21da18..c5a9e085b83 100644 --- a/homeassistant/components/nasweb/switch.py +++ b/homeassistant/components/nasweb/switch.py @@ -10,9 +10,9 @@ from webio_api import Output as NASwebOutput from homeassistant.components.switch import DOMAIN as DOMAIN_SWITCH, SwitchEntity from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.helpers.entity_registry as er from homeassistant.helpers.typing import DiscoveryInfoType from homeassistant.helpers.update_coordinator import ( BaseCoordinatorEntity, diff --git a/homeassistant/components/octoprint/__init__.py b/homeassistant/components/octoprint/__init__.py index 7a9f3990435..2b081eae45a 100644 --- a/homeassistant/components/octoprint/__init__.py +++ b/homeassistant/components/octoprint/__init__.py @@ -28,8 +28,8 @@ from homeassistant.const import ( ) from homeassistant.core import Event, HomeAssistant, ServiceCall, callback from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import device_registry as dr import homeassistant.helpers.config_validation as cv -import homeassistant.helpers.device_registry as dr from homeassistant.helpers.typing import ConfigType from homeassistant.util import slugify as util_slugify from homeassistant.util.ssl import get_default_context, get_default_no_verify_context diff --git a/homeassistant/components/pvpc_hourly_pricing/__init__.py b/homeassistant/components/pvpc_hourly_pricing/__init__.py index 6327164e3c8..4d120e9fae7 100644 --- a/homeassistant/components/pvpc_hourly_pricing/__init__.py +++ b/homeassistant/components/pvpc_hourly_pricing/__init__.py @@ -3,7 +3,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_TOKEN, Platform from homeassistant.core import HomeAssistant -import homeassistant.helpers.entity_registry as er +from homeassistant.helpers import entity_registry as er from .const import ATTR_POWER, ATTR_POWER_P3, DOMAIN from .coordinator import ElecPricesDataUpdateCoordinator diff --git a/homeassistant/components/ring/config_flow.py b/homeassistant/components/ring/config_flow.py index a23fd8f73de..7d5654947d8 100644 --- a/homeassistant/components/ring/config_flow.py +++ b/homeassistant/components/ring/config_flow.py @@ -23,8 +23,8 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.device_registry as dr from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from . import get_auth_user_agent diff --git a/homeassistant/components/roomba/entity.py b/homeassistant/components/roomba/entity.py index d55a260e53a..69e8d5b5414 100644 --- a/homeassistant/components/roomba/entity.py +++ b/homeassistant/components/roomba/entity.py @@ -3,7 +3,7 @@ from __future__ import annotations from homeassistant.const import ATTR_CONNECTIONS -import homeassistant.helpers.device_registry as dr +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity import homeassistant.util.dt as dt_util diff --git a/homeassistant/components/sabnzbd/__init__.py b/homeassistant/components/sabnzbd/__init__.py index fee459340f3..1f68781a3a2 100644 --- a/homeassistant/components/sabnzbd/__init__.py +++ b/homeassistant/components/sabnzbd/__init__.py @@ -11,8 +11,7 @@ import voluptuous as vol from homeassistant.const import Platform from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError -from homeassistant.helpers import config_validation as cv -import homeassistant.helpers.issue_registry as ir +from homeassistant.helpers import config_validation as cv, issue_registry as ir from homeassistant.helpers.typing import ConfigType from .const import ( diff --git a/homeassistant/components/schlage/coordinator.py b/homeassistant/components/schlage/coordinator.py index b319b21be0c..936ef9ee91e 100644 --- a/homeassistant/components/schlage/coordinator.py +++ b/homeassistant/components/schlage/coordinator.py @@ -13,7 +13,7 @@ from pyschlage.log import LockLog from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed -import homeassistant.helpers.device_registry as dr +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN, LOGGER, UPDATE_INTERVAL diff --git a/homeassistant/components/solarlog/coordinator.py b/homeassistant/components/solarlog/coordinator.py index 11f268db32a..bf2bc849111 100644 --- a/homeassistant/components/solarlog/coordinator.py +++ b/homeassistant/components/solarlog/coordinator.py @@ -19,8 +19,8 @@ from solarlog_cli.solarlog_models import SolarlogData from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.device_registry as dr from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import slugify diff --git a/homeassistant/components/sonos/entity.py b/homeassistant/components/sonos/entity.py index 98dc8b8b752..a9a76b3b4d0 100644 --- a/homeassistant/components/sonos/entity.py +++ b/homeassistant/components/sonos/entity.py @@ -8,7 +8,7 @@ import logging from soco.core import SoCo -import homeassistant.helpers.device_registry as dr +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity diff --git a/homeassistant/components/switchbee/__init__.py b/homeassistant/components/switchbee/__init__.py index b1a71665222..a2a3ecf0df9 100644 --- a/homeassistant/components/switchbee/__init__.py +++ b/homeassistant/components/switchbee/__init__.py @@ -13,9 +13,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.device_registry as dr -import homeassistant.helpers.entity_registry as er from .const import DOMAIN from .coordinator import SwitchBeeCoordinator diff --git a/homeassistant/components/tedee/coordinator.py b/homeassistant/components/tedee/coordinator.py index f9ebb29dd04..fec59d1c596 100644 --- a/homeassistant/components/tedee/coordinator.py +++ b/homeassistant/components/tedee/coordinator.py @@ -22,8 +22,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.device_registry as dr from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import CONF_LOCAL_ACCESS_TOKEN, DOMAIN diff --git a/homeassistant/components/tradfri/__init__.py b/homeassistant/components/tradfri/__init__.py index 0060310e6c2..92ed2ea8b82 100644 --- a/homeassistant/components/tradfri/__init__.py +++ b/homeassistant/components/tradfri/__init__.py @@ -14,7 +14,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady -import homeassistant.helpers.device_registry as dr +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, diff --git a/homeassistant/components/unifi/device_tracker.py b/homeassistant/components/unifi/device_tracker.py index 2ac47e67913..eebffc63277 100644 --- a/homeassistant/components/unifi/device_tracker.py +++ b/homeassistant/components/unifi/device_tracker.py @@ -24,9 +24,9 @@ from homeassistant.components.device_tracker import ( ScannerEntityDescription, ) from homeassistant.core import Event as core_Event, HomeAssistant, callback +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.helpers.entity_registry as er import homeassistant.util.dt as dt_util from . import UnifiConfigEntry diff --git a/homeassistant/components/unifi/switch.py b/homeassistant/components/unifi/switch.py index 7741e57c82c..91e4a0222f6 100644 --- a/homeassistant/components/unifi/switch.py +++ b/homeassistant/components/unifi/switch.py @@ -44,9 +44,9 @@ from homeassistant.components.switch import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.helpers.entity_registry as er from . import UnifiConfigEntry from .const import ATTR_MANUFACTURER, DOMAIN as UNIFI_DOMAIN diff --git a/homeassistant/components/unifiprotect/entity.py b/homeassistant/components/unifiprotect/entity.py index 335bc1e933d..90804559297 100644 --- a/homeassistant/components/unifiprotect/entity.py +++ b/homeassistant/components/unifiprotect/entity.py @@ -22,7 +22,7 @@ from uiprotect.data import ( ) from homeassistant.core import callback -import homeassistant.helpers.device_registry as dr +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity, EntityDescription diff --git a/homeassistant/components/withings/binary_sensor.py b/homeassistant/components/withings/binary_sensor.py index 691026ccb9a..856aeeffc5c 100644 --- a/homeassistant/components/withings/binary_sensor.py +++ b/homeassistant/components/withings/binary_sensor.py @@ -10,8 +10,8 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.helpers.entity_registry as er from . import WithingsConfigEntry from .const import DOMAIN diff --git a/homeassistant/components/withings/calendar.py b/homeassistant/components/withings/calendar.py index acab0fa5c40..ac867fbfdca 100644 --- a/homeassistant/components/withings/calendar.py +++ b/homeassistant/components/withings/calendar.py @@ -10,8 +10,8 @@ from aiowithings import WithingsClient, WorkoutCategory from homeassistant.components.calendar import CalendarEntity, CalendarEvent from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.helpers.entity_registry as er from . import DOMAIN, WithingsConfigEntry from .coordinator import WithingsWorkoutDataUpdateCoordinator diff --git a/homeassistant/components/youtube/__init__.py b/homeassistant/components/youtube/__init__.py index 8460a105fcb..aee4b83508c 100644 --- a/homeassistant/components/youtube/__init__.py +++ b/homeassistant/components/youtube/__init__.py @@ -8,11 +8,11 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.config_entry_oauth2_flow import ( OAuth2Session, async_get_config_entry_implementation, ) -import homeassistant.helpers.device_registry as dr from .api import AsyncConfigEntryAuth from .const import AUTH, COORDINATOR, DOMAIN diff --git a/homeassistant/components/zha/logbook.py b/homeassistant/components/zha/logbook.py index 3de81e1255d..05539a063d2 100644 --- a/homeassistant/components/zha/logbook.py +++ b/homeassistant/components/zha/logbook.py @@ -10,7 +10,7 @@ from zha.application.const import ZHA_EVENT from homeassistant.components.logbook import LOGBOOK_ENTRY_MESSAGE, LOGBOOK_ENTRY_NAME from homeassistant.const import ATTR_COMMAND, ATTR_DEVICE_ID from homeassistant.core import Event, HomeAssistant, callback -import homeassistant.helpers.device_registry as dr +from homeassistant.helpers import device_registry as dr from .const import DOMAIN as ZHA_DOMAIN from .helpers import async_get_zha_device_proxy diff --git a/homeassistant/components/zwave_js/api.py b/homeassistant/components/zwave_js/api.py index 1a1cd6ae9c1..37ce9a51c91 100644 --- a/homeassistant/components/zwave_js/api.py +++ b/homeassistant/components/zwave_js/api.py @@ -70,9 +70,8 @@ from homeassistant.components.websocket_api import ( ) from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.device_registry as dr from homeassistant.helpers.dispatcher import async_dispatcher_connect from .config_validation import BITMASK_SCHEMA diff --git a/homeassistant/components/zwave_js/logbook.py b/homeassistant/components/zwave_js/logbook.py index 315793b9726..120084788e1 100644 --- a/homeassistant/components/zwave_js/logbook.py +++ b/homeassistant/components/zwave_js/logbook.py @@ -9,7 +9,7 @@ from zwave_js_server.const import CommandClass from homeassistant.components.logbook import LOGBOOK_ENTRY_MESSAGE, LOGBOOK_ENTRY_NAME from homeassistant.const import ATTR_DEVICE_ID from homeassistant.core import Event, HomeAssistant, callback -import homeassistant.helpers.device_registry as dr +from homeassistant.helpers import device_registry as dr from .const import ( ATTR_COMMAND_CLASS, diff --git a/tests/components/bsblan/test_climate.py b/tests/components/bsblan/test_climate.py index 7ee12c5fa1a..41d566fc375 100644 --- a/tests/components/bsblan/test_climate.py +++ b/tests/components/bsblan/test_climate.py @@ -22,7 +22,7 @@ from homeassistant.components.climate import ( from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -import homeassistant.helpers.entity_registry as er +from homeassistant.helpers import entity_registry as er from . import setup_with_selected_platforms diff --git a/tests/components/bsblan/test_sensor.py b/tests/components/bsblan/test_sensor.py index c95671a1a6b..ba2af40f319 100644 --- a/tests/components/bsblan/test_sensor.py +++ b/tests/components/bsblan/test_sensor.py @@ -7,7 +7,7 @@ from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant -import homeassistant.helpers.entity_registry as er +from homeassistant.helpers import entity_registry as er from . import setup_with_selected_platforms diff --git a/tests/components/bsblan/test_water_heater.py b/tests/components/bsblan/test_water_heater.py index ed920774aa5..173498b14ff 100644 --- a/tests/components/bsblan/test_water_heater.py +++ b/tests/components/bsblan/test_water_heater.py @@ -20,7 +20,7 @@ from homeassistant.components.water_heater import ( from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -import homeassistant.helpers.entity_registry as er +from homeassistant.helpers import entity_registry as er from . import setup_with_selected_platforms diff --git a/tests/components/cloud/test_repairs.py b/tests/components/cloud/test_repairs.py index d165a129dbe..d131d211e2f 100644 --- a/tests/components/cloud/test_repairs.py +++ b/tests/components/cloud/test_repairs.py @@ -12,7 +12,7 @@ from homeassistant.components.cloud.repairs import ( ) from homeassistant.components.repairs import DOMAIN as REPAIRS_DOMAIN from homeassistant.core import HomeAssistant -import homeassistant.helpers.issue_registry as ir +from homeassistant.helpers import issue_registry as ir from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util diff --git a/tests/components/dhcp/test_init.py b/tests/components/dhcp/test_init.py index 9f3435f0cd9..76f15eb3e51 100644 --- a/tests/components/dhcp/test_init.py +++ b/tests/components/dhcp/test_init.py @@ -31,7 +31,7 @@ from homeassistant.const import ( STATE_NOT_HOME, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.device_registry as dr +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.discovery_flow import DiscoveryKey from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo diff --git a/tests/components/esphome/test_assist_satellite.py b/tests/components/esphome/test_assist_satellite.py index 5ca333df1e2..30535236970 100644 --- a/tests/components/esphome/test_assist_satellite.py +++ b/tests/components/esphome/test_assist_satellite.py @@ -48,8 +48,11 @@ from homeassistant.components.select import ( ) from homeassistant.const import STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er, intent as intent_helper -import homeassistant.helpers.device_registry as dr +from homeassistant.helpers import ( + device_registry as dr, + entity_registry as er, + intent as intent_helper, +) from homeassistant.helpers.entity_component import EntityComponent from .conftest import MockESPHomeDevice diff --git a/tests/components/esphome/test_media_player.py b/tests/components/esphome/test_media_player.py index 42b7e72a06e..a425b730771 100644 --- a/tests/components/esphome/test_media_player.py +++ b/tests/components/esphome/test_media_player.py @@ -38,7 +38,7 @@ from homeassistant.components.media_player import ( ) from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant -import homeassistant.helpers.device_registry as dr +from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component from .conftest import MockESPHomeDevice diff --git a/tests/components/fan/test_init.py b/tests/components/fan/test_init.py index 90061ec60a1..0ab7686a68b 100644 --- a/tests/components/fan/test_init.py +++ b/tests/components/fan/test_init.py @@ -12,7 +12,7 @@ from homeassistant.components.fan import ( NotValidPresetModeError, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.entity_registry as er +from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component from .common import MockFan diff --git a/tests/components/flexit_bacnet/test_climate.py b/tests/components/flexit_bacnet/test_climate.py index 7b0546f60ea..79ee84bdc14 100644 --- a/tests/components/flexit_bacnet/test_climate.py +++ b/tests/components/flexit_bacnet/test_climate.py @@ -6,7 +6,7 @@ from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant -import homeassistant.helpers.entity_registry as er +from homeassistant.helpers import entity_registry as er from . import setup_with_selected_platforms diff --git a/tests/components/group/test_sensor.py b/tests/components/group/test_sensor.py index de406cb251c..187991141e7 100644 --- a/tests/components/group/test_sensor.py +++ b/tests/components/group/test_sensor.py @@ -35,8 +35,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import issue_registry as ir -import homeassistant.helpers.entity_registry as er +from homeassistant.helpers import entity_registry as er, issue_registry as ir from homeassistant.setup import async_setup_component from tests.common import get_fixture_path diff --git a/tests/components/hassio/test_repairs.py b/tests/components/hassio/test_repairs.py index f8cac4e1a97..4c4f0e24dcc 100644 --- a/tests/components/hassio/test_repairs.py +++ b/tests/components/hassio/test_repairs.py @@ -17,7 +17,7 @@ from aiohasupervisor.models import ( import pytest from homeassistant.core import HomeAssistant -import homeassistant.helpers.issue_registry as ir +from homeassistant.helpers import issue_registry as ir from homeassistant.setup import async_setup_component from .test_init import MOCK_ENVIRON diff --git a/tests/components/home_connect/test_binary_sensor.py b/tests/components/home_connect/test_binary_sensor.py index b564b003af6..8e108cc2b0a 100644 --- a/tests/components/home_connect/test_binary_sensor.py +++ b/tests/components/home_connect/test_binary_sensor.py @@ -22,8 +22,8 @@ from homeassistant.components.script import scripts_with_entity from homeassistant.config_entries import ConfigEntryState from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.entity_component import async_update_entity -import homeassistant.helpers.issue_registry as ir from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry, load_json_object_fixture diff --git a/tests/components/home_connect/test_switch.py b/tests/components/home_connect/test_switch.py index 9d54feeaa54..80bfcf9db96 100644 --- a/tests/components/home_connect/test_switch.py +++ b/tests/components/home_connect/test_switch.py @@ -33,7 +33,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -import homeassistant.helpers.issue_registry as ir +from homeassistant.helpers import issue_registry as ir from homeassistant.setup import async_setup_component from .conftest import get_all_appliances diff --git a/tests/components/html5/test_config_flow.py b/tests/components/html5/test_config_flow.py index ca0b3da0389..3cde435771e 100644 --- a/tests/components/html5/test_config_flow.py +++ b/tests/components/html5/test_config_flow.py @@ -17,7 +17,7 @@ from homeassistant.components.html5.issues import ( ) from homeassistant.const import CONF_NAME from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant -import homeassistant.helpers.issue_registry as ir +from homeassistant.helpers import issue_registry as ir MOCK_CONF = { ATTR_VAPID_EMAIL: "test@example.com", diff --git a/tests/components/html5/test_init.py b/tests/components/html5/test_init.py index 290cb381296..840890f18d1 100644 --- a/tests/components/html5/test_init.py +++ b/tests/components/html5/test_init.py @@ -1,7 +1,7 @@ """Test the HTML5 setup.""" from homeassistant.core import HomeAssistant -import homeassistant.helpers.issue_registry as ir +from homeassistant.helpers import issue_registry as ir from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry diff --git a/tests/components/hunterdouglas_powerview/test_config_flow.py b/tests/components/hunterdouglas_powerview/test_config_flow.py index cf159c23bae..5a48e08e5db 100644 --- a/tests/components/hunterdouglas_powerview/test_config_flow.py +++ b/tests/components/hunterdouglas_powerview/test_config_flow.py @@ -9,7 +9,7 @@ from homeassistant.components.hunterdouglas_powerview.const import DOMAIN from homeassistant.const import CONF_API_VERSION, CONF_HOST, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -import homeassistant.helpers.entity_registry as er +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo diff --git a/tests/components/lcn/test_binary_sensor.py b/tests/components/lcn/test_binary_sensor.py index 2f64f421b93..7d636f546c4 100644 --- a/tests/components/lcn/test_binary_sensor.py +++ b/tests/components/lcn/test_binary_sensor.py @@ -15,8 +15,7 @@ from homeassistant.components.lcn.helpers import get_device_connection from homeassistant.components.script import scripts_with_entity from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant, ServiceCall -from homeassistant.helpers import entity_registry as er -import homeassistant.helpers.issue_registry as ir +from homeassistant.helpers import entity_registry as er, issue_registry as ir from homeassistant.setup import async_setup_component from .conftest import MockConfigEntry, init_integration diff --git a/tests/components/lcn/test_services.py b/tests/components/lcn/test_services.py index cd97e3484e3..c9eda40fdba 100644 --- a/tests/components/lcn/test_services.py +++ b/tests/components/lcn/test_services.py @@ -31,7 +31,7 @@ from homeassistant.const import ( CONF_UNIT_OF_MEASUREMENT, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.issue_registry as ir +from homeassistant.helpers import issue_registry as ir from homeassistant.setup import async_setup_component from .conftest import ( diff --git a/tests/components/lock/test_init.py b/tests/components/lock/test_init.py index 68af8c7d482..510034a2172 100644 --- a/tests/components/lock/test_init.py +++ b/tests/components/lock/test_init.py @@ -21,7 +21,7 @@ from homeassistant.components.lock import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError -import homeassistant.helpers.entity_registry as er +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.typing import UNDEFINED, UndefinedType from .conftest import MockLock diff --git a/tests/components/madvr/test_binary_sensor.py b/tests/components/madvr/test_binary_sensor.py index 469a3225ca0..9ddbc7b3afe 100644 --- a/tests/components/madvr/test_binary_sensor.py +++ b/tests/components/madvr/test_binary_sensor.py @@ -9,7 +9,7 @@ from syrupy import SnapshotAssertion from homeassistant.const import STATE_OFF, STATE_ON, Platform from homeassistant.core import HomeAssistant -import homeassistant.helpers.entity_registry as er +from homeassistant.helpers import entity_registry as er from . import setup_integration from .conftest import get_update_callback diff --git a/tests/components/madvr/test_remote.py b/tests/components/madvr/test_remote.py index 6fc507534d6..1ddbacdb6e9 100644 --- a/tests/components/madvr/test_remote.py +++ b/tests/components/madvr/test_remote.py @@ -20,7 +20,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.entity_registry as er +from homeassistant.helpers import entity_registry as er from . import setup_integration from .const import ( diff --git a/tests/components/madvr/test_sensor.py b/tests/components/madvr/test_sensor.py index ddc01fc737a..dd1722913f2 100644 --- a/tests/components/madvr/test_sensor.py +++ b/tests/components/madvr/test_sensor.py @@ -10,7 +10,7 @@ from syrupy import SnapshotAssertion from homeassistant.components.madvr.sensor import get_temperature from homeassistant.const import STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant -import homeassistant.helpers.entity_registry as er +from homeassistant.helpers import entity_registry as er from . import setup_integration from .conftest import get_update_callback diff --git a/tests/components/matter/test_lock.py b/tests/components/matter/test_lock.py index 7bcfd381d6c..bb03b296fc6 100644 --- a/tests/components/matter/test_lock.py +++ b/tests/components/matter/test_lock.py @@ -11,7 +11,7 @@ from homeassistant.components.lock import LockEntityFeature, LockState from homeassistant.const import ATTR_CODE, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError -import homeassistant.helpers.entity_registry as er +from homeassistant.helpers import entity_registry as er from .common import ( set_node_attribute, diff --git a/tests/components/melissa/test_climate.py b/tests/components/melissa/test_climate.py index ceb14faf8fb..b305d629a91 100644 --- a/tests/components/melissa/test_climate.py +++ b/tests/components/melissa/test_climate.py @@ -10,7 +10,7 @@ from homeassistant.components.climate import ( ) from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE from homeassistant.core import HomeAssistant -import homeassistant.helpers.entity_registry as er +from homeassistant.helpers import entity_registry as er from . import setup_integration diff --git a/tests/components/min_max/test_sensor.py b/tests/components/min_max/test_sensor.py index c875697bf2f..a7a70043d94 100644 --- a/tests/components/min_max/test_sensor.py +++ b/tests/components/min_max/test_sensor.py @@ -17,7 +17,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.entity_registry as er +from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component from tests.common import get_fixture_path diff --git a/tests/components/netatmo/common.py b/tests/components/netatmo/common.py index 730cb0cb117..9110f8c724f 100644 --- a/tests/components/netatmo/common.py +++ b/tests/components/netatmo/common.py @@ -11,7 +11,7 @@ from syrupy import SnapshotAssertion from homeassistant.components.webhook import async_handle_webhook from homeassistant.const import Platform from homeassistant.core import HomeAssistant -import homeassistant.helpers.entity_registry as er +from homeassistant.helpers import entity_registry as er from homeassistant.util.aiohttp import MockRequest from tests.common import MockConfigEntry, load_fixture diff --git a/tests/components/netatmo/test_button.py b/tests/components/netatmo/test_button.py index 681e42af051..bffecf7d83a 100644 --- a/tests/components/netatmo/test_button.py +++ b/tests/components/netatmo/test_button.py @@ -8,7 +8,7 @@ from syrupy import SnapshotAssertion from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant -import homeassistant.helpers.entity_registry as er +from homeassistant.helpers import entity_registry as er from .common import selected_platforms, snapshot_platform_entities diff --git a/tests/components/netatmo/test_camera.py b/tests/components/netatmo/test_camera.py index 43904ed8f71..32f20544043 100644 --- a/tests/components/netatmo/test_camera.py +++ b/tests/components/netatmo/test_camera.py @@ -19,7 +19,7 @@ from homeassistant.components.netatmo.const import ( from homeassistant.const import CONF_WEBHOOK_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -import homeassistant.helpers.entity_registry as er +from homeassistant.helpers import entity_registry as er from homeassistant.util import dt as dt_util from .common import ( diff --git a/tests/components/netatmo/test_climate.py b/tests/components/netatmo/test_climate.py index dc0312f7acd..18c811fd76b 100644 --- a/tests/components/netatmo/test_climate.py +++ b/tests/components/netatmo/test_climate.py @@ -41,7 +41,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError -import homeassistant.helpers.entity_registry as er +from homeassistant.helpers import entity_registry as er from homeassistant.util import dt as dt_util from .common import selected_platforms, simulate_webhook, snapshot_platform_entities diff --git a/tests/components/netatmo/test_cover.py b/tests/components/netatmo/test_cover.py index 509c1de736e..9368a564afb 100644 --- a/tests/components/netatmo/test_cover.py +++ b/tests/components/netatmo/test_cover.py @@ -14,7 +14,7 @@ from homeassistant.components.cover import ( ) from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant -import homeassistant.helpers.entity_registry as er +from homeassistant.helpers import entity_registry as er from .common import selected_platforms, snapshot_platform_entities diff --git a/tests/components/netatmo/test_fan.py b/tests/components/netatmo/test_fan.py index 989ea1ac364..3dbc8b3a6f5 100644 --- a/tests/components/netatmo/test_fan.py +++ b/tests/components/netatmo/test_fan.py @@ -11,7 +11,7 @@ from homeassistant.components.fan import ( ) from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant -import homeassistant.helpers.entity_registry as er +from homeassistant.helpers import entity_registry as er from .common import selected_platforms, snapshot_platform_entities diff --git a/tests/components/netatmo/test_light.py b/tests/components/netatmo/test_light.py index c90d67e7630..0932395b8ec 100644 --- a/tests/components/netatmo/test_light.py +++ b/tests/components/netatmo/test_light.py @@ -12,7 +12,7 @@ from homeassistant.components.light import ( from homeassistant.components.netatmo import DOMAIN from homeassistant.const import ATTR_ENTITY_ID, CONF_WEBHOOK_ID, Platform from homeassistant.core import HomeAssistant -import homeassistant.helpers.entity_registry as er +from homeassistant.helpers import entity_registry as er from .common import ( FAKE_WEBHOOK_ACTIVATION, diff --git a/tests/components/netatmo/test_select.py b/tests/components/netatmo/test_select.py index 274113405f6..458115f8f5c 100644 --- a/tests/components/netatmo/test_select.py +++ b/tests/components/netatmo/test_select.py @@ -17,7 +17,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.entity_registry as er +from homeassistant.helpers import entity_registry as er from .common import selected_platforms, simulate_webhook, snapshot_platform_entities diff --git a/tests/components/netatmo/test_switch.py b/tests/components/netatmo/test_switch.py index dd82fad3d08..837f6201b1e 100644 --- a/tests/components/netatmo/test_switch.py +++ b/tests/components/netatmo/test_switch.py @@ -11,7 +11,7 @@ from homeassistant.components.switch import ( ) from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant -import homeassistant.helpers.entity_registry as er +from homeassistant.helpers import entity_registry as er from .common import selected_platforms, snapshot_platform_entities diff --git a/tests/components/plugwise/test_sensor.py b/tests/components/plugwise/test_sensor.py index b3243d6b127..11aa68bded7 100644 --- a/tests/components/plugwise/test_sensor.py +++ b/tests/components/plugwise/test_sensor.py @@ -7,8 +7,8 @@ import pytest from homeassistant.components.plugwise.const import DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_component import async_update_entity -import homeassistant.helpers.entity_registry as er from tests.common import MockConfigEntry diff --git a/tests/components/plugwise/test_switch.py b/tests/components/plugwise/test_switch.py index fa8a8a434e7..003c47ed1f4 100644 --- a/tests/components/plugwise/test_switch.py +++ b/tests/components/plugwise/test_switch.py @@ -17,7 +17,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -import homeassistant.helpers.entity_registry as er +from homeassistant.helpers import entity_registry as er from tests.common import MockConfigEntry diff --git a/tests/components/proximity/test_init.py b/tests/components/proximity/test_init.py index eeb181e0670..22a546e6abe 100644 --- a/tests/components/proximity/test_init.py +++ b/tests/components/proximity/test_init.py @@ -15,8 +15,7 @@ from homeassistant.const import ( STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er -import homeassistant.helpers.issue_registry as ir +from homeassistant.helpers import entity_registry as er, issue_registry as ir from homeassistant.util import slugify from tests.common import MockConfigEntry diff --git a/tests/components/schlage/test_init.py b/tests/components/schlage/test_init.py index 57a139e582e..97da66c7e93 100644 --- a/tests/components/schlage/test_init.py +++ b/tests/components/schlage/test_init.py @@ -12,7 +12,7 @@ from syrupy.assertion import SnapshotAssertion from homeassistant.components.schlage.const import DOMAIN, UPDATE_INTERVAL from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant -import homeassistant.helpers.device_registry as dr +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceRegistry from . import MockSchlageConfigEntry diff --git a/tests/components/sun/test_sensor.py b/tests/components/sun/test_sensor.py index cb97ae565c7..495a97b88fe 100644 --- a/tests/components/sun/test_sensor.py +++ b/tests/components/sun/test_sensor.py @@ -10,7 +10,7 @@ import pytest from homeassistant.components import sun from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -import homeassistant.helpers.entity_registry as er +from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util diff --git a/tests/components/tod/test_binary_sensor.py b/tests/components/tod/test_binary_sensor.py index b4b6b13d8e3..47e64353004 100644 --- a/tests/components/tod/test_binary_sensor.py +++ b/tests/components/tod/test_binary_sensor.py @@ -7,7 +7,7 @@ import pytest from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant -import homeassistant.helpers.entity_registry as er +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.sun import get_astral_event_date, get_astral_event_next from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util diff --git a/tests/components/velbus/test_services.py b/tests/components/velbus/test_services.py index 2bcbac7b80d..94ba91e6dc3 100644 --- a/tests/components/velbus/test_services.py +++ b/tests/components/velbus/test_services.py @@ -18,7 +18,7 @@ from homeassistant.components.velbus.const import ( from homeassistant.const import CONF_ADDRESS from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError -import homeassistant.helpers.issue_registry as ir +from homeassistant.helpers import issue_registry as ir from . import init_integration diff --git a/tests/components/zwave_js/test_repairs.py b/tests/components/zwave_js/test_repairs.py index d237a6e410a..a46320168eb 100644 --- a/tests/components/zwave_js/test_repairs.py +++ b/tests/components/zwave_js/test_repairs.py @@ -10,8 +10,7 @@ from zwave_js_server.model.node import Node from homeassistant.components.zwave_js import DOMAIN from homeassistant.components.zwave_js.helpers import get_device_id from homeassistant.core import HomeAssistant -import homeassistant.helpers.device_registry as dr -import homeassistant.helpers.issue_registry as ir +from homeassistant.helpers import device_registry as dr, issue_registry as ir from tests.components.repairs import ( async_process_repairs_platforms, diff --git a/tests/components/zwave_js/test_select.py b/tests/components/zwave_js/test_select.py index ddfd205b017..d26cccbc7d5 100644 --- a/tests/components/zwave_js/test_select.py +++ b/tests/components/zwave_js/test_select.py @@ -10,7 +10,7 @@ from homeassistant.components.zwave_js.helpers import ZwaveValueMatcher from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_UNKNOWN, EntityCategory from homeassistant.core import HomeAssistant -import homeassistant.helpers.entity_registry as er +from homeassistant.helpers import entity_registry as er from .common import replace_value_of_zwave_value From 1ad2598c6f549d2c4c38bc7887b7d6ff3c3777cc Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 28 Jan 2025 08:48:59 +0100 Subject: [PATCH 0092/3148] Use runtime_data in ecoforest (#136689) --- homeassistant/components/ecoforest/__init__.py | 17 ++++++----------- .../components/ecoforest/coordinator.py | 8 +++++++- homeassistant/components/ecoforest/number.py | 8 +++----- homeassistant/components/ecoforest/sensor.py | 10 +++++----- homeassistant/components/ecoforest/switch.py | 8 +++----- tests/components/ecoforest/conftest.py | 2 +- 6 files changed, 25 insertions(+), 28 deletions(-) diff --git a/homeassistant/components/ecoforest/__init__.py b/homeassistant/components/ecoforest/__init__.py index 4d5aaa40576..e5350beba8e 100644 --- a/homeassistant/components/ecoforest/__init__.py +++ b/homeassistant/components/ecoforest/__init__.py @@ -11,20 +11,18 @@ from pyecoforest.exceptions import ( EcoforestConnectionError, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from .const import DOMAIN -from .coordinator import EcoforestCoordinator +from .coordinator import EcoforestConfigEntry, EcoforestCoordinator PLATFORMS: list[Platform] = [Platform.NUMBER, Platform.SENSOR, Platform.SWITCH] _LOGGER = logging.getLogger(__name__) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: EcoforestConfigEntry) -> bool: """Set up Ecoforest from a config entry.""" host = entry.data[CONF_HOST] @@ -41,20 +39,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: _LOGGER.error("Error communicating with device %s", host) raise ConfigEntryNotReady from err - coordinator = EcoforestCoordinator(hass, api) + coordinator = EcoforestCoordinator(hass, entry, api) await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: EcoforestConfigEntry) -> 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 + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/ecoforest/coordinator.py b/homeassistant/components/ecoforest/coordinator.py index 3b04325bd50..603fde38388 100644 --- a/homeassistant/components/ecoforest/coordinator.py +++ b/homeassistant/components/ecoforest/coordinator.py @@ -6,6 +6,7 @@ from pyecoforest.api import EcoforestApi from pyecoforest.exceptions import EcoforestError from pyecoforest.models.device import Device +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -13,16 +14,21 @@ from .const import POLLING_INTERVAL _LOGGER = logging.getLogger(__name__) +type EcoforestConfigEntry = ConfigEntry[EcoforestCoordinator] + class EcoforestCoordinator(DataUpdateCoordinator[Device]): """DataUpdateCoordinator to gather data from ecoforest device.""" - def __init__(self, hass: HomeAssistant, api: EcoforestApi) -> None: + def __init__( + self, hass: HomeAssistant, entry: EcoforestConfigEntry, api: EcoforestApi + ) -> None: """Initialize DataUpdateCoordinator.""" super().__init__( hass, _LOGGER, + config_entry=entry, name="ecoforest", update_interval=POLLING_INTERVAL, ) diff --git a/homeassistant/components/ecoforest/number.py b/homeassistant/components/ecoforest/number.py index db3275c1fcc..878c150343e 100644 --- a/homeassistant/components/ecoforest/number.py +++ b/homeassistant/components/ecoforest/number.py @@ -8,12 +8,10 @@ from dataclasses import dataclass from pyecoforest.models.device import Device from homeassistant.components.number import NumberEntity, NumberEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN -from .coordinator import EcoforestCoordinator +from .coordinator import EcoforestConfigEntry from .entity import EcoforestEntity @@ -38,11 +36,11 @@ NUMBER_ENTITIES = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: EcoforestConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up Ecoforest number platform.""" - coordinator: EcoforestCoordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data entities = [ EcoforestNumberEntity(coordinator, description) diff --git a/homeassistant/components/ecoforest/sensor.py b/homeassistant/components/ecoforest/sensor.py index 997b02436cc..0babb476ab6 100644 --- a/homeassistant/components/ecoforest/sensor.py +++ b/homeassistant/components/ecoforest/sensor.py @@ -13,7 +13,6 @@ from homeassistant.components.sensor import ( SensorEntity, SensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( PERCENTAGE, UnitOfPressure, @@ -24,8 +23,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType -from .const import DOMAIN -from .coordinator import EcoforestCoordinator +from .coordinator import EcoforestConfigEntry from .entity import EcoforestEntity _LOGGER = logging.getLogger(__name__) @@ -143,10 +141,12 @@ SENSOR_TYPES: tuple[EcoforestSensorEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: EcoforestConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Ecoforest sensor platform.""" - coordinator: EcoforestCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data entities = [ EcoforestSensor(coordinator, description) for description in SENSOR_TYPES diff --git a/homeassistant/components/ecoforest/switch.py b/homeassistant/components/ecoforest/switch.py index d643217bebc..de52248e751 100644 --- a/homeassistant/components/ecoforest/switch.py +++ b/homeassistant/components/ecoforest/switch.py @@ -10,12 +10,10 @@ from pyecoforest.api import EcoforestApi from pyecoforest.models.device import Device from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN -from .coordinator import EcoforestCoordinator +from .coordinator import EcoforestConfigEntry from .entity import EcoforestEntity @@ -39,11 +37,11 @@ SWITCH_TYPES: tuple[EcoforestSwitchEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: EcoforestConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up Ecoforest switch platform.""" - coordinator: EcoforestCoordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data entities = [ EcoforestSwitchEntity(coordinator, description) for description in SWITCH_TYPES diff --git a/tests/components/ecoforest/conftest.py b/tests/components/ecoforest/conftest.py index 85bfff08bdf..8678cfd4d05 100644 --- a/tests/components/ecoforest/conftest.py +++ b/tests/components/ecoforest/conftest.py @@ -6,7 +6,7 @@ from unittest.mock import AsyncMock, Mock, patch from pyecoforest.models.device import Alarm, Device, OperationMode, State import pytest -from homeassistant.components.ecoforest import DOMAIN +from homeassistant.components.ecoforest.const import DOMAIN from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant From b1fec51e2f8f3699a3183e7e68cbec235c918c4b Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Tue, 28 Jan 2025 01:54:36 -0800 Subject: [PATCH 0093/3148] Update roborock tests to patch client before test setup (#136587) --- tests/components/roborock/conftest.py | 19 +++++++++- tests/components/roborock/test_switch.py | 46 +++++++++++------------- tests/components/roborock/test_time.py | 32 ++++++++--------- 3 files changed, 53 insertions(+), 44 deletions(-) diff --git a/tests/components/roborock/conftest.py b/tests/components/roborock/conftest.py index d65bf7c61d7..4df5f479b7c 100644 --- a/tests/components/roborock/conftest.py +++ b/tests/components/roborock/conftest.py @@ -2,7 +2,8 @@ from collections.abc import Generator from copy import deepcopy -from unittest.mock import patch +from typing import Any +from unittest.mock import Mock, patch import pytest from roborock import RoborockCategory, RoomMapping @@ -139,6 +140,22 @@ def bypass_api_fixture() -> None: yield +@pytest.fixture(name="send_message_side_effect") +def send_message_side_effect_fixture() -> Any: + """Fixture to return a side effect for the send_message method.""" + return None + + +@pytest.fixture(name="mock_send_message") +def mock_send_message_fixture(send_message_side_effect: Any) -> Mock: + """Fixture to mock the send_message method.""" + with patch( + "homeassistant.components.roborock.coordinator.RoborockLocalClientV1._send_command", + side_effect=send_message_side_effect, + ) as mock_send_message: + yield mock_send_message + + @pytest.fixture def bypass_api_fixture_v1_only(bypass_api_fixture) -> None: """Bypass api for tests that require only having v1 devices.""" diff --git a/tests/components/roborock/test_switch.py b/tests/components/roborock/test_switch.py index 2476bfe497c..e2df9a3498f 100644 --- a/tests/components/roborock/test_switch.py +++ b/tests/components/roborock/test_switch.py @@ -1,6 +1,6 @@ """Test Roborock Switch platform.""" -from unittest.mock import patch +from unittest.mock import Mock import pytest import roborock @@ -29,6 +29,7 @@ def platforms() -> list[Platform]: ) async def test_update_success( hass: HomeAssistant, + mock_send_message: Mock, bypass_api_fixture, setup_entry: MockConfigEntry, entity_id: str, @@ -36,27 +37,22 @@ async def test_update_success( """Test turning switch entities on and off.""" # Ensure that the entity exist, as these test can pass even if there is no entity. assert hass.states.get(entity_id) is not None - with patch( - "homeassistant.components.roborock.coordinator.RoborockLocalClientV1._send_command" - ) as mock_send_message: - await hass.services.async_call( - "switch", - SERVICE_TURN_ON, - service_data=None, - blocking=True, - target={"entity_id": entity_id}, - ) + await hass.services.async_call( + "switch", + SERVICE_TURN_ON, + service_data=None, + blocking=True, + target={"entity_id": entity_id}, + ) assert mock_send_message.assert_called_once - with patch( - "homeassistant.components.roborock.coordinator.RoborockLocalClientV1.send_message" - ) as mock_send_message: - await hass.services.async_call( - "switch", - SERVICE_TURN_OFF, - service_data=None, - blocking=True, - target={"entity_id": entity_id}, - ) + mock_send_message.reset_mock() + await hass.services.async_call( + "switch", + SERVICE_TURN_OFF, + service_data=None, + blocking=True, + target={"entity_id": entity_id}, + ) assert mock_send_message.assert_called_once @@ -67,8 +63,12 @@ async def test_update_success( ("switch.roborock_s7_maxv_status_indicator_light", SERVICE_TURN_OFF), ], ) +@pytest.mark.parametrize( + "send_message_side_effect", [roborock.exceptions.RoborockTimeout] +) async def test_update_failed( hass: HomeAssistant, + mock_send_message: Mock, bypass_api_fixture, setup_entry: MockConfigEntry, entity_id: str, @@ -78,10 +78,6 @@ async def test_update_failed( # Ensure that the entity exist, as these test can pass even if there is no entity. assert hass.states.get(entity_id) is not None with ( - patch( - "homeassistant.components.roborock.coordinator.RoborockLocalClientV1._send_command", - side_effect=roborock.exceptions.RoborockTimeout, - ) as mock_send_message, pytest.raises(HomeAssistantError, match="Failed to update Roborock options"), ): await hass.services.async_call( diff --git a/tests/components/roborock/test_time.py b/tests/components/roborock/test_time.py index eb48e8e537f..9c0a53893ed 100644 --- a/tests/components/roborock/test_time.py +++ b/tests/components/roborock/test_time.py @@ -1,7 +1,7 @@ """Test Roborock Time platform.""" from datetime import time -from unittest.mock import patch +from unittest.mock import Mock import pytest import roborock @@ -29,6 +29,7 @@ def platforms() -> list[Platform]: ) async def test_update_success( hass: HomeAssistant, + mock_send_message: Mock, bypass_api_fixture, setup_entry: MockConfigEntry, entity_id: str, @@ -36,16 +37,13 @@ async def test_update_success( """Test turning switch entities on and off.""" # Ensure that the entity exist, as these test can pass even if there is no entity. assert hass.states.get(entity_id) is not None - with patch( - "homeassistant.components.roborock.coordinator.RoborockLocalClientV1._send_command" - ) as mock_send_message: - await hass.services.async_call( - "time", - SERVICE_SET_VALUE, - service_data={"time": time(hour=1, minute=1)}, - blocking=True, - target={"entity_id": entity_id}, - ) + await hass.services.async_call( + "time", + SERVICE_SET_VALUE, + service_data={"time": time(hour=1, minute=1)}, + blocking=True, + target={"entity_id": entity_id}, + ) assert mock_send_message.assert_called_once @@ -55,8 +53,12 @@ async def test_update_success( ("time.roborock_s7_maxv_do_not_disturb_begin"), ], ) +@pytest.mark.parametrize( + "send_message_side_effect", [roborock.exceptions.RoborockTimeout] +) async def test_update_failure( hass: HomeAssistant, + mock_send_message: Mock, bypass_api_fixture, setup_entry: MockConfigEntry, entity_id: str, @@ -64,13 +66,7 @@ async def test_update_failure( """Test turning switch entities on and off.""" # Ensure that the entity exist, as these test can pass even if there is no entity. assert hass.states.get(entity_id) is not None - with ( - patch( - "homeassistant.components.roborock.coordinator.RoborockLocalClientV1._send_command", - side_effect=roborock.exceptions.RoborockTimeout, - ) as mock_send_message, - pytest.raises(HomeAssistantError, match="Failed to update Roborock options"), - ): + with pytest.raises(HomeAssistantError, match="Failed to update Roborock options"): await hass.services.async_call( "time", SERVICE_SET_VALUE, From 5d55dcf3922d39b2c22a460cd59ce6e886d8a7a3 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 28 Jan 2025 10:58:10 +0100 Subject: [PATCH 0094/3148] Use runtime_data in electrasmart (#136696) --- .../components/electrasmart/__init__.py | 32 ++++++++++--------- .../components/electrasmart/climate.py | 8 +++-- 2 files changed, 22 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/electrasmart/__init__.py b/homeassistant/components/electrasmart/__init__.py index b8e5eb1bdd8..27cebc9aee9 100644 --- a/homeassistant/components/electrasmart/__init__.py +++ b/homeassistant/components/electrasmart/__init__.py @@ -2,8 +2,6 @@ from __future__ import annotations -from typing import cast - from electrasmart.api import ElectraAPI, ElectraApiError from homeassistant.config_entries import ConfigEntry @@ -12,36 +10,40 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import CONF_IMEI, DOMAIN +from .const import CONF_IMEI PLATFORMS: list[Platform] = [Platform.CLIMATE] +type ElectraSmartConfigEntry = ConfigEntry[ElectraAPI] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + +async def async_setup_entry( + hass: HomeAssistant, entry: ElectraSmartConfigEntry +) -> bool: """Set up Electra Smart Air Conditioner from a config entry.""" - hass.data.setdefault(DOMAIN, {}) - entry.async_on_unload(entry.add_update_listener(update_listener)) - hass.data[DOMAIN][entry.entry_id] = ElectraAPI( + api = ElectraAPI( async_get_clientsession(hass), entry.data[CONF_IMEI], entry.data[CONF_TOKEN] ) - try: - await cast(ElectraAPI, hass.data[DOMAIN][entry.entry_id]).fetch_devices() + await api.fetch_devices() except ElectraApiError as exp: raise ConfigEntryNotReady(f"Error communicating with API: {exp}") from exp + entry.async_on_unload(entry.add_update_listener(update_listener)) + entry.runtime_data = api await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, entry: ElectraSmartConfigEntry +) -> 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 + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) -async def update_listener(hass: HomeAssistant, config_entry: ConfigEntry) -> None: +async def update_listener( + hass: HomeAssistant, config_entry: ElectraSmartConfigEntry +) -> None: """Update listener.""" await hass.config_entries.async_reload(config_entry.entry_id) diff --git a/homeassistant/components/electrasmart/climate.py b/homeassistant/components/electrasmart/climate.py index 04e4742554b..84def436dfb 100644 --- a/homeassistant/components/electrasmart/climate.py +++ b/homeassistant/components/electrasmart/climate.py @@ -24,13 +24,13 @@ from homeassistant.components.climate import ( ClimateEntityFeature, HVACMode, ) -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.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback +from . import ElectraSmartConfigEntry from .const import ( API_DELAY, CONSECUTIVE_FAILURE_THRESHOLD, @@ -89,10 +89,12 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ElectraSmartConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Add Electra AC devices.""" - api: ElectraAPI = hass.data[DOMAIN][entry.entry_id] + api = entry.runtime_data _LOGGER.debug("Discovered %i Electra devices", len(api.devices)) async_add_entities( From b1a4ba7b7cdee75d2da9367fd61ad1fd2fa21cec Mon Sep 17 00:00:00 2001 From: Nathan Spencer Date: Tue, 28 Jan 2025 03:21:46 -0700 Subject: [PATCH 0095/3148] Update config flow tests for litterrobot (#136658) Co-authored-by: Joostlek --- .../components/litterrobot/quality_scale.yaml | 7 +- .../litterrobot/test_config_flow.py | 102 ++++++------------ 2 files changed, 32 insertions(+), 77 deletions(-) diff --git a/homeassistant/components/litterrobot/quality_scale.yaml b/homeassistant/components/litterrobot/quality_scale.yaml index 3eae5d3e668..d5f943943bc 100644 --- a/homeassistant/components/litterrobot/quality_scale.yaml +++ b/homeassistant/components/litterrobot/quality_scale.yaml @@ -23,12 +23,7 @@ rules: comment: | hub.py should be renamed to coordinator.py and updated accordingly Also should not need to return bool (never used) - config-flow-test-coverage: - status: todo - comment: | - Fix stale title and docstring - Make sure every test ends in either ABORT or CREATE_ENTRY - so we also test that the flow is able to recover + config-flow-test-coverage: done config-flow: done dependency-transparency: done docs-actions: diff --git a/tests/components/litterrobot/test_config_flow.py b/tests/components/litterrobot/test_config_flow.py index 2eadafb0d0c..caaf832b780 100644 --- a/tests/components/litterrobot/test_config_flow.py +++ b/tests/components/litterrobot/test_config_flow.py @@ -4,6 +4,7 @@ from unittest.mock import patch from pylitterbot import Account from pylitterbot.exceptions import LitterRobotException, LitterRobotLoginException +import pytest from homeassistant import config_entries from homeassistant.const import CONF_PASSWORD @@ -15,9 +16,8 @@ from .common import CONF_USERNAME, CONFIG, DOMAIN from tests.common import MockConfigEntry -async def test_form(hass: HomeAssistant, mock_account) -> None: - """Test we get the form.""" - +async def test_full_flow(hass: HomeAssistant, mock_account) -> None: + """Test full flow.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -34,19 +34,18 @@ async def test_form(hass: HomeAssistant, mock_account) -> None: return_value=True, ) as mock_setup_entry, ): - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], CONFIG[DOMAIN] ) - await hass.async_block_till_done() - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == CONFIG[DOMAIN][CONF_USERNAME] - assert result2["data"] == CONFIG[DOMAIN] + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == CONFIG[DOMAIN][CONF_USERNAME] + assert result["data"] == CONFIG[DOMAIN] assert len(mock_setup_entry.mock_calls) == 1 async def test_already_configured(hass: HomeAssistant) -> None: - """Test we handle already configured.""" + """Test already configured case.""" MockConfigEntry( domain=DOMAIN, data=CONFIG[DOMAIN], @@ -62,71 +61,32 @@ async def test_already_configured(hass: HomeAssistant) -> None: assert result["reason"] == "already_configured" -async def test_form_invalid_auth(hass: HomeAssistant) -> None: - """Test we handle invalid auth.""" +@pytest.mark.parametrize( + ("side_effect", "connect_errors"), + [ + (Exception, {"base": "unknown"}), + (LitterRobotLoginException, {"base": "invalid_auth"}), + (LitterRobotException, {"base": "cannot_connect"}), + ], +) +async def test_create_entry( + hass: HomeAssistant, mock_account, side_effect, connect_errors +) -> None: + """Test creating an entry.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) with patch( "homeassistant.components.litterrobot.config_flow.Account.connect", - side_effect=LitterRobotLoginException, + side_effect=side_effect, ): - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], CONFIG[DOMAIN] ) - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "invalid_auth"} - - -async def test_form_cannot_connect(hass: HomeAssistant) -> None: - """Test we handle cannot connect error.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - with patch( - "homeassistant.components.litterrobot.config_flow.Account.connect", - side_effect=LitterRobotException, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], CONFIG[DOMAIN] - ) - - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "cannot_connect"} - - -async def test_form_unknown_error(hass: HomeAssistant) -> None: - """Test we handle unknown error.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - with patch( - "homeassistant.components.litterrobot.config_flow.Account.connect", - side_effect=Exception, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], CONFIG[DOMAIN] - ) - - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "unknown"} - - -async def test_step_reauth(hass: HomeAssistant, mock_account: Account) -> None: - """Test the reauth flow.""" - entry = MockConfigEntry( - domain=DOMAIN, - data=CONFIG[DOMAIN], - ) - entry.add_to_hass(hass) - - result = await entry.start_reauth_flow(hass) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "reauth_confirm" + assert result["errors"] == connect_errors with ( patch( @@ -136,19 +96,19 @@ async def test_step_reauth(hass: HomeAssistant, mock_account: Account) -> None: patch( "homeassistant.components.litterrobot.async_setup_entry", return_value=True, - ) as mock_setup_entry, + ), ): result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={CONF_PASSWORD: CONFIG[DOMAIN][CONF_PASSWORD]}, + result["flow_id"], CONFIG[DOMAIN] ) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "reauth_successful" - assert len(mock_setup_entry.mock_calls) == 1 + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == CONFIG[DOMAIN][CONF_USERNAME] + assert result["data"] == CONFIG[DOMAIN] -async def test_step_reauth_failed(hass: HomeAssistant, mock_account: Account) -> None: - """Test the reauth flow fails and recovers.""" +async def test_reauth(hass: HomeAssistant, mock_account: Account) -> None: + """Test reauth flow (with fail and recover).""" entry = MockConfigEntry( domain=DOMAIN, data=CONFIG[DOMAIN], From ff73545a8690d4b2c563445c65811624f79f45de Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 28 Jan 2025 11:25:27 +0100 Subject: [PATCH 0096/3148] Use runtime_data in econet (#136691) --- homeassistant/components/econet/__init__.py | 24 +++++++++---------- .../components/econet/binary_sensor.py | 9 +++---- homeassistant/components/econet/climate.py | 10 ++++---- homeassistant/components/econet/const.py | 2 -- homeassistant/components/econet/sensor.py | 9 +++---- homeassistant/components/econet/switch.py | 7 +++--- .../components/econet/water_heater.py | 9 +++---- tests/components/econet/test_config_flow.py | 2 +- 8 files changed, 37 insertions(+), 35 deletions(-) diff --git a/homeassistant/components/econet/__init__.py b/homeassistant/components/econet/__init__.py index 4fd920a5ecc..40bece93599 100644 --- a/homeassistant/components/econet/__init__.py +++ b/homeassistant/components/econet/__init__.py @@ -6,7 +6,7 @@ import logging from aiohttp.client_exceptions import ClientError from pyeconet import EcoNetApiInterface -from pyeconet.equipment import EquipmentType +from pyeconet.equipment import Equipment, EquipmentType from pyeconet.errors import ( GenericHTTPError, InvalidCredentialsError, @@ -21,7 +21,7 @@ from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.dispatcher import dispatcher_send from homeassistant.helpers.event import async_track_time_interval -from .const import API_CLIENT, DOMAIN, EQUIPMENT, PUSH_UPDATE +from .const import PUSH_UPDATE _LOGGER = logging.getLogger(__name__) @@ -36,7 +36,12 @@ PLATFORMS = [ INTERVAL = timedelta(minutes=60) -async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +type EconetConfigEntry = ConfigEntry[dict[EquipmentType, list[Equipment]]] + + +async def async_setup_entry( + hass: HomeAssistant, config_entry: EconetConfigEntry +) -> bool: """Set up EcoNet as config entry.""" email = config_entry.data[CONF_EMAIL] @@ -57,9 +62,8 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b ) except (ClientError, GenericHTTPError, InvalidResponseFormat) as err: raise ConfigEntryNotReady from err - hass.data.setdefault(DOMAIN, {API_CLIENT: {}, EQUIPMENT: {}}) - hass.data[DOMAIN][API_CLIENT][config_entry.entry_id] = api - hass.data[DOMAIN][EQUIPMENT][config_entry.entry_id] = equipment + + config_entry.runtime_data = equipment await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) @@ -89,10 +93,6 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: EconetConfigEntry) -> bool: """Unload a EcoNet config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - hass.data[DOMAIN][API_CLIENT].pop(entry.entry_id) - hass.data[DOMAIN][EQUIPMENT].pop(entry.entry_id) - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/econet/binary_sensor.py b/homeassistant/components/econet/binary_sensor.py index 0f5cb6f92af..d66a8536bd0 100644 --- a/homeassistant/components/econet/binary_sensor.py +++ b/homeassistant/components/econet/binary_sensor.py @@ -9,11 +9,10 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN, EQUIPMENT +from . import EconetConfigEntry from .entity import EcoNetEntity BINARY_SENSOR_TYPES: tuple[BinarySensorEntityDescription, ...] = ( @@ -41,10 +40,12 @@ BINARY_SENSOR_TYPES: tuple[BinarySensorEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: EconetConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up EcoNet binary sensor based on a config entry.""" - equipment = hass.data[DOMAIN][EQUIPMENT][entry.entry_id] + equipment = entry.runtime_data all_equipment = equipment[EquipmentType.WATER_HEATER].copy() all_equipment.extend(equipment[EquipmentType.THERMOSTAT].copy()) diff --git a/homeassistant/components/econet/climate.py b/homeassistant/components/econet/climate.py index cdf82f6817f..1ebb7e483d4 100644 --- a/homeassistant/components/econet/climate.py +++ b/homeassistant/components/econet/climate.py @@ -16,13 +16,13 @@ from homeassistant.components.climate import ( ClimateEntityFeature, HVACMode, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from .const import DOMAIN, EQUIPMENT +from . import EconetConfigEntry +from .const import DOMAIN from .entity import EcoNetEntity ECONET_STATE_TO_HA = { @@ -51,10 +51,12 @@ SUPPORT_FLAGS_THERMOSTAT = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: EconetConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up EcoNet thermostat based on a config entry.""" - equipment = hass.data[DOMAIN][EQUIPMENT][entry.entry_id] + equipment = entry.runtime_data async_add_entities( [ EcoNetThermostat(thermostat) diff --git a/homeassistant/components/econet/const.py b/homeassistant/components/econet/const.py index ee8d4fc8a46..78384f7683d 100644 --- a/homeassistant/components/econet/const.py +++ b/homeassistant/components/econet/const.py @@ -1,7 +1,5 @@ """Constants for Econet integration.""" DOMAIN = "econet" -API_CLIENT = "api_client" -EQUIPMENT = "equipment" PUSH_UPDATE = "econet.push_update" diff --git a/homeassistant/components/econet/sensor.py b/homeassistant/components/econet/sensor.py index 19bac8c9e1f..510906d699c 100644 --- a/homeassistant/components/econet/sensor.py +++ b/homeassistant/components/econet/sensor.py @@ -10,7 +10,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( PERCENTAGE, SIGNAL_STRENGTH_DECIBELS, @@ -21,7 +20,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN, EQUIPMENT +from . import EconetConfigEntry from .entity import EcoNetEntity SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( @@ -82,11 +81,13 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: EconetConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up EcoNet sensor based on a config entry.""" - data = hass.data[DOMAIN][EQUIPMENT][entry.entry_id] + data = entry.runtime_data equipment = data[EquipmentType.WATER_HEATER].copy() equipment.extend(data[EquipmentType.THERMOSTAT].copy()) diff --git a/homeassistant/components/econet/switch.py b/homeassistant/components/econet/switch.py index e36f6c834b1..283256f25e3 100644 --- a/homeassistant/components/econet/switch.py +++ b/homeassistant/components/econet/switch.py @@ -9,11 +9,10 @@ from pyeconet.equipment import EquipmentType from pyeconet.equipment.thermostat import ThermostatOperationMode from homeassistant.components.switch import SwitchEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN, EQUIPMENT +from . import EconetConfigEntry from .entity import EcoNetEntity _LOGGER = logging.getLogger(__name__) @@ -21,11 +20,11 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: EconetConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the ecobee thermostat switch entity.""" - equipment = hass.data[DOMAIN][EQUIPMENT][entry.entry_id] + equipment = entry.runtime_data async_add_entities( EcoNetSwitchAuxHeatOnly(thermostat) for thermostat in equipment[EquipmentType.THERMOSTAT] diff --git a/homeassistant/components/econet/water_heater.py b/homeassistant/components/econet/water_heater.py index efe4196993c..fc3fe5e4bdf 100644 --- a/homeassistant/components/econet/water_heater.py +++ b/homeassistant/components/econet/water_heater.py @@ -17,12 +17,11 @@ from homeassistant.components.water_heater import ( WaterHeaterEntity, WaterHeaterEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, STATE_OFF, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN, EQUIPMENT +from . import EconetConfigEntry from .entity import EcoNetEntity SCAN_INTERVAL = timedelta(hours=1) @@ -47,10 +46,12 @@ SUPPORT_FLAGS_HEATER = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: EconetConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up EcoNet water heater based on a config entry.""" - equipment = hass.data[DOMAIN][EQUIPMENT][entry.entry_id] + equipment = entry.runtime_data async_add_entities( [ EcoNetWaterHeater(water_heater) diff --git a/tests/components/econet/test_config_flow.py b/tests/components/econet/test_config_flow.py index 2ef10c1bd41..2fc4356d1d8 100644 --- a/tests/components/econet/test_config_flow.py +++ b/tests/components/econet/test_config_flow.py @@ -5,7 +5,7 @@ from unittest.mock import patch from pyeconet.api import EcoNetApiInterface from pyeconet.errors import InvalidCredentialsError, PyeconetError -from homeassistant.components.econet import DOMAIN +from homeassistant.components.econet.const import DOMAIN from homeassistant.config_entries import SOURCE_USER from homeassistant.const import CONF_EMAIL, CONF_PASSWORD from homeassistant.core import HomeAssistant From 6ad4dfc0709179707826d5cc67e1e763b21a4c91 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 28 Jan 2025 11:28:21 +0100 Subject: [PATCH 0097/3148] Bump actions/setup-python from 5.3.0 to 5.4.0 (#136685) --- .github/workflows/builder.yml | 6 +++--- .github/workflows/ci.yaml | 32 +++++++++++++++--------------- .github/workflows/translations.yml | 2 +- .github/workflows/wheels.yml | 2 +- 4 files changed, 21 insertions(+), 21 deletions(-) diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index 39dc08444d3..aa4bfc60c11 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -32,7 +32,7 @@ jobs: fetch-depth: 0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v5.3.0 + uses: actions/setup-python@v5.4.0 with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -116,7 +116,7 @@ jobs: - name: Set up Python ${{ env.DEFAULT_PYTHON }} if: needs.init.outputs.channel == 'dev' - uses: actions/setup-python@v5.3.0 + uses: actions/setup-python@v5.4.0 with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -454,7 +454,7 @@ jobs: uses: actions/checkout@v4.2.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v5.3.0 + uses: actions/setup-python@v5.4.0 with: python-version: ${{ env.DEFAULT_PYTHON }} diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index dad662a9202..a58648212e3 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -234,7 +234,7 @@ jobs: uses: actions/checkout@v4.2.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v5.3.0 + uses: actions/setup-python@v5.4.0 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -279,7 +279,7 @@ jobs: - name: Check out code from GitHub uses: actions/checkout@v4.2.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v5.3.0 + uses: actions/setup-python@v5.4.0 id: python with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -319,7 +319,7 @@ jobs: - name: Check out code from GitHub uses: actions/checkout@v4.2.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v5.3.0 + uses: actions/setup-python@v5.4.0 id: python with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -359,7 +359,7 @@ jobs: - name: Check out code from GitHub uses: actions/checkout@v4.2.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v5.3.0 + uses: actions/setup-python@v5.4.0 id: python with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -469,7 +469,7 @@ jobs: uses: actions/checkout@v4.2.2 - name: Set up Python ${{ matrix.python-version }} id: python - uses: actions/setup-python@v5.3.0 + uses: actions/setup-python@v5.4.0 with: python-version: ${{ matrix.python-version }} check-latest: true @@ -572,7 +572,7 @@ jobs: uses: actions/checkout@v4.2.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v5.3.0 + uses: actions/setup-python@v5.4.0 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -605,7 +605,7 @@ jobs: uses: actions/checkout@v4.2.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v5.3.0 + uses: actions/setup-python@v5.4.0 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -643,7 +643,7 @@ jobs: uses: actions/checkout@v4.2.2 - name: Set up Python ${{ matrix.python-version }} id: python - uses: actions/setup-python@v5.3.0 + uses: actions/setup-python@v5.4.0 with: python-version: ${{ matrix.python-version }} check-latest: true @@ -686,7 +686,7 @@ jobs: uses: actions/checkout@v4.2.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v5.3.0 + uses: actions/setup-python@v5.4.0 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -733,7 +733,7 @@ jobs: uses: actions/checkout@v4.2.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v5.3.0 + uses: actions/setup-python@v5.4.0 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -778,7 +778,7 @@ jobs: uses: actions/checkout@v4.2.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v5.3.0 + uses: actions/setup-python@v5.4.0 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -859,7 +859,7 @@ jobs: uses: actions/checkout@v4.2.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v5.3.0 + uses: actions/setup-python@v5.4.0 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -923,7 +923,7 @@ jobs: uses: actions/checkout@v4.2.2 - name: Set up Python ${{ matrix.python-version }} id: python - uses: actions/setup-python@v5.3.0 + uses: actions/setup-python@v5.4.0 with: python-version: ${{ matrix.python-version }} check-latest: true @@ -1044,7 +1044,7 @@ jobs: uses: actions/checkout@v4.2.2 - name: Set up Python ${{ matrix.python-version }} id: python - uses: actions/setup-python@v5.3.0 + uses: actions/setup-python@v5.4.0 with: python-version: ${{ matrix.python-version }} check-latest: true @@ -1173,7 +1173,7 @@ jobs: uses: actions/checkout@v4.2.2 - name: Set up Python ${{ matrix.python-version }} id: python - uses: actions/setup-python@v5.3.0 + uses: actions/setup-python@v5.4.0 with: python-version: ${{ matrix.python-version }} check-latest: true @@ -1319,7 +1319,7 @@ jobs: uses: actions/checkout@v4.2.2 - name: Set up Python ${{ matrix.python-version }} id: python - uses: actions/setup-python@v5.3.0 + uses: actions/setup-python@v5.4.0 with: python-version: ${{ matrix.python-version }} check-latest: true diff --git a/.github/workflows/translations.yml b/.github/workflows/translations.yml index fa3c2305190..619d83aef51 100644 --- a/.github/workflows/translations.yml +++ b/.github/workflows/translations.yml @@ -22,7 +22,7 @@ jobs: uses: actions/checkout@v4.2.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v5.3.0 + uses: actions/setup-python@v5.4.0 with: python-version: ${{ env.DEFAULT_PYTHON }} diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 00f0c507414..e8dafe88833 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -36,7 +36,7 @@ jobs: - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v5.3.0 + uses: actions/setup-python@v5.4.0 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true From edac4b83d9deed68d1e86899cbddd0d3781294cd Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 28 Jan 2025 11:29:26 +0100 Subject: [PATCH 0098/3148] Use runtime_data in ezviz (#136702) --- homeassistant/components/ezviz/__init__.py | 25 +++++++------------ .../components/ezviz/alarm_control_panel.py | 13 +++++----- .../components/ezviz/binary_sensor.py | 12 ++++----- homeassistant/components/ezviz/button.py | 12 ++++----- homeassistant/components/ezviz/camera.py | 17 +++++-------- homeassistant/components/ezviz/config_flow.py | 12 ++++----- homeassistant/components/ezviz/const.py | 3 --- homeassistant/components/ezviz/coordinator.py | 18 +++++++++++-- homeassistant/components/ezviz/image.py | 14 +++++------ homeassistant/components/ezviz/light.py | 12 ++++----- homeassistant/components/ezviz/number.py | 12 ++++----- homeassistant/components/ezviz/select.py | 12 ++++----- homeassistant/components/ezviz/sensor.py | 12 ++++----- homeassistant/components/ezviz/siren.py | 12 ++++----- homeassistant/components/ezviz/switch.py | 12 ++++----- homeassistant/components/ezviz/update.py | 12 ++++----- 16 files changed, 94 insertions(+), 116 deletions(-) diff --git a/homeassistant/components/ezviz/__init__.py b/homeassistant/components/ezviz/__init__.py index 6885304e0de..43a71458fb2 100644 --- a/homeassistant/components/ezviz/__init__.py +++ b/homeassistant/components/ezviz/__init__.py @@ -11,7 +11,6 @@ from pyezviz.exceptions import ( PyEzvizError, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_TIMEOUT, CONF_TYPE, CONF_URL, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady @@ -22,12 +21,11 @@ from .const import ( CONF_FFMPEG_ARGUMENTS, CONF_RFSESSION_ID, CONF_SESSION_ID, - DATA_COORDINATOR, DEFAULT_FFMPEG_ARGUMENTS, DEFAULT_TIMEOUT, DOMAIN, ) -from .coordinator import EzvizDataUpdateCoordinator +from .coordinator import EzvizConfigEntry, EzvizDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -50,9 +48,8 @@ PLATFORMS_BY_TYPE: dict[str, list] = { } -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: EzvizConfigEntry) -> bool: """Set up EZVIZ from a config entry.""" - hass.data.setdefault(DOMAIN, {}) sensor_type: str = entry.data[CONF_TYPE] ezviz_client = None @@ -90,20 +87,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: raise ConfigEntryNotReady from error coordinator = EzvizDataUpdateCoordinator( - hass, api=ezviz_client, api_timeout=entry.options[CONF_TIMEOUT] + hass, entry, api=ezviz_client, api_timeout=entry.options[CONF_TIMEOUT] ) await coordinator.async_config_entry_first_refresh() - hass.data[DOMAIN][entry.entry_id] = {DATA_COORDINATOR: coordinator} + entry.runtime_data = coordinator entry.async_on_unload(entry.add_update_listener(_async_update_listener)) # Check EZVIZ cloud account entity is present, reload cloud account entities for camera entity change to take effect. # Cameras are accessed via local RTSP stream with unique credentials per camera. # Separate camera entities allow for credential changes per camera. - if sensor_type == ATTR_TYPE_CAMERA and hass.data[DOMAIN]: - for item in hass.config_entries.async_entries(domain=DOMAIN): + if sensor_type == ATTR_TYPE_CAMERA: + for item in hass.config_entries.async_loaded_entries(domain=DOMAIN): if item.data.get(CONF_TYPE) == ATTR_TYPE_CLOUD: _LOGGER.debug("Reload Ezviz main account with camera entry") await hass.config_entries.async_reload(item.entry_id) @@ -116,19 +113,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: EzvizConfigEntry) -> bool: """Unload a config entry.""" sensor_type = entry.data[CONF_TYPE] - unload_ok = await hass.config_entries.async_unload_platforms( + return await hass.config_entries.async_unload_platforms( entry, PLATFORMS_BY_TYPE[sensor_type] ) - if sensor_type == ATTR_TYPE_CLOUD and unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok -async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def _async_update_listener(hass: HomeAssistant, entry: EzvizConfigEntry) -> None: """Handle options update.""" await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/ezviz/alarm_control_panel.py b/homeassistant/components/ezviz/alarm_control_panel.py index f30a7852b4e..66a76df2cdc 100644 --- a/homeassistant/components/ezviz/alarm_control_panel.py +++ b/homeassistant/components/ezviz/alarm_control_panel.py @@ -15,14 +15,13 @@ from homeassistant.components.alarm_control_panel import ( AlarmControlPanelEntityFeature, AlarmControlPanelState, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DATA_COORDINATOR, DOMAIN, MANUFACTURER -from .coordinator import EzvizDataUpdateCoordinator +from .const import DOMAIN, MANUFACTURER +from .coordinator import EzvizConfigEntry, EzvizDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -49,12 +48,12 @@ ALARM_TYPE = EzvizAlarmControlPanelEntityDescription( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: EzvizConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up Ezviz alarm control panel.""" - coordinator: EzvizDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ - DATA_COORDINATOR - ] + coordinator = entry.runtime_data device_info = DeviceInfo( identifiers={(DOMAIN, entry.unique_id)}, # type: ignore[arg-type] diff --git a/homeassistant/components/ezviz/binary_sensor.py b/homeassistant/components/ezviz/binary_sensor.py index c13375cb487..6f0d87c8218 100644 --- a/homeassistant/components/ezviz/binary_sensor.py +++ b/homeassistant/components/ezviz/binary_sensor.py @@ -7,12 +7,10 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DATA_COORDINATOR, DOMAIN -from .coordinator import EzvizDataUpdateCoordinator +from .coordinator import EzvizConfigEntry, EzvizDataUpdateCoordinator from .entity import EzvizEntity PARALLEL_UPDATES = 1 @@ -34,12 +32,12 @@ BINARY_SENSOR_TYPES: dict[str, BinarySensorEntityDescription] = { async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: EzvizConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up EZVIZ sensors based on a config entry.""" - coordinator: EzvizDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ - DATA_COORDINATOR - ] + coordinator = entry.runtime_data async_add_entities( [ diff --git a/homeassistant/components/ezviz/button.py b/homeassistant/components/ezviz/button.py index 3c89677da09..b99674b0693 100644 --- a/homeassistant/components/ezviz/button.py +++ b/homeassistant/components/ezviz/button.py @@ -11,13 +11,11 @@ from pyezviz.constants import SupportExt from pyezviz.exceptions import HTTPError, PyEzvizError from homeassistant.components.button import ButtonEntity, ButtonEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DATA_COORDINATOR, DOMAIN -from .coordinator import EzvizDataUpdateCoordinator +from .coordinator import EzvizConfigEntry, EzvizDataUpdateCoordinator from .entity import EzvizEntity PARALLEL_UPDATES = 1 @@ -68,12 +66,12 @@ BUTTON_ENTITIES = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: EzvizConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up EZVIZ button based on a config entry.""" - coordinator: EzvizDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ - DATA_COORDINATOR - ] + coordinator = entry.runtime_data # Add button entities if supportExt value indicates PTZ capbility. # Could be missing or "0" for unsupported. diff --git a/homeassistant/components/ezviz/camera.py b/homeassistant/components/ezviz/camera.py index 3c4a5f70ff4..d96fc949c86 100644 --- a/homeassistant/components/ezviz/camera.py +++ b/homeassistant/components/ezviz/camera.py @@ -10,11 +10,7 @@ from homeassistant.components import ffmpeg from homeassistant.components.camera import Camera, CameraEntityFeature from homeassistant.components.ffmpeg import get_ffmpeg_manager from homeassistant.components.stream import CONF_USE_WALLCLOCK_AS_TIMESTAMPS -from homeassistant.config_entries import ( - SOURCE_IGNORE, - SOURCE_INTEGRATION_DISCOVERY, - ConfigEntry, -) +from homeassistant.config_entries import SOURCE_IGNORE, SOURCE_INTEGRATION_DISCOVERY from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.helpers import discovery_flow @@ -26,26 +22,25 @@ from homeassistant.helpers.entity_platform import ( from .const import ( ATTR_SERIAL, CONF_FFMPEG_ARGUMENTS, - DATA_COORDINATOR, DEFAULT_CAMERA_USERNAME, DEFAULT_FFMPEG_ARGUMENTS, DOMAIN, SERVICE_WAKE_DEVICE, ) -from .coordinator import EzvizDataUpdateCoordinator +from .coordinator import EzvizConfigEntry, EzvizDataUpdateCoordinator from .entity import EzvizEntity _LOGGER = logging.getLogger(__name__) async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: EzvizConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up EZVIZ cameras based on a config entry.""" - coordinator: EzvizDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ - DATA_COORDINATOR - ] + coordinator = entry.runtime_data camera_entities = [] diff --git a/homeassistant/components/ezviz/config_flow.py b/homeassistant/components/ezviz/config_flow.py index a7551737c10..845656c1d1d 100644 --- a/homeassistant/components/ezviz/config_flow.py +++ b/homeassistant/components/ezviz/config_flow.py @@ -17,12 +17,7 @@ from pyezviz.exceptions import ( from pyezviz.test_cam_rtsp import TestRTSPAuth import voluptuous as vol -from homeassistant.config_entries import ( - ConfigEntry, - ConfigFlow, - ConfigFlowResult, - OptionsFlow, -) +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow from homeassistant.const import ( CONF_CUSTOMIZE, CONF_IP_ADDRESS, @@ -48,6 +43,7 @@ from .const import ( EU_URL, RUSSIA_URL, ) +from .coordinator import EzvizConfigEntry _LOGGER = logging.getLogger(__name__) DEFAULT_OPTIONS = { @@ -148,7 +144,9 @@ class EzvizConfigFlow(ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry: ConfigEntry) -> EzvizOptionsFlowHandler: + def async_get_options_flow( + config_entry: EzvizConfigEntry, + ) -> EzvizOptionsFlowHandler: """Get the options flow for this handler.""" return EzvizOptionsFlowHandler() diff --git a/homeassistant/components/ezviz/const.py b/homeassistant/components/ezviz/const.py index 651110dd5d7..e6de538335c 100644 --- a/homeassistant/components/ezviz/const.py +++ b/homeassistant/components/ezviz/const.py @@ -33,6 +33,3 @@ RUSSIA_URL = "apirus.ezvizru.com" DEFAULT_CAMERA_USERNAME = "admin" DEFAULT_TIMEOUT = 25 DEFAULT_FFMPEG_ARGUMENTS = "" - -# Data -DATA_COORDINATOR = "coordinator" diff --git a/homeassistant/components/ezviz/coordinator.py b/homeassistant/components/ezviz/coordinator.py index c983371f4f8..0830784a501 100644 --- a/homeassistant/components/ezviz/coordinator.py +++ b/homeassistant/components/ezviz/coordinator.py @@ -13,6 +13,7 @@ from pyezviz.exceptions import ( PyEzvizError, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -21,19 +22,32 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) +type EzvizConfigEntry = ConfigEntry[EzvizDataUpdateCoordinator] + class EzvizDataUpdateCoordinator(DataUpdateCoordinator): """Class to manage fetching EZVIZ data.""" def __init__( - self, hass: HomeAssistant, *, api: EzvizClient, api_timeout: int + self, + hass: HomeAssistant, + entry: EzvizConfigEntry, + *, + api: EzvizClient, + api_timeout: int, ) -> None: """Initialize global EZVIZ data updater.""" self.ezviz_client = api self._api_timeout = api_timeout update_interval = timedelta(seconds=30) - super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=update_interval) + super().__init__( + hass, + _LOGGER, + config_entry=entry, + name=DOMAIN, + update_interval=update_interval, + ) async def _async_update_data(self) -> dict: """Fetch data from EZVIZ.""" diff --git a/homeassistant/components/ezviz/image.py b/homeassistant/components/ezviz/image.py index 73c09244222..d4c7a267b1e 100644 --- a/homeassistant/components/ezviz/image.py +++ b/homeassistant/components/ezviz/image.py @@ -8,14 +8,14 @@ from pyezviz.exceptions import PyEzvizError from pyezviz.utils import decrypt_image from homeassistant.components.image import Image, ImageEntity, ImageEntityDescription -from homeassistant.config_entries import SOURCE_IGNORE, ConfigEntry +from homeassistant.config_entries import SOURCE_IGNORE from homeassistant.const import CONF_PASSWORD from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import dt as dt_util -from .const import DATA_COORDINATOR, DOMAIN -from .coordinator import EzvizDataUpdateCoordinator +from .const import DOMAIN +from .coordinator import EzvizConfigEntry, EzvizDataUpdateCoordinator from .entity import EzvizEntity _LOGGER = logging.getLogger(__name__) @@ -27,13 +27,13 @@ IMAGE_TYPE = ImageEntityDescription( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: EzvizConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up EZVIZ image entities based on a config entry.""" - coordinator: EzvizDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ - DATA_COORDINATOR - ] + coordinator = entry.runtime_data async_add_entities( EzvizLastMotion(hass, coordinator, camera) for camera in coordinator.data diff --git a/homeassistant/components/ezviz/light.py b/homeassistant/components/ezviz/light.py index c35b53b47b7..145c8b1ca20 100644 --- a/homeassistant/components/ezviz/light.py +++ b/homeassistant/components/ezviz/light.py @@ -8,7 +8,6 @@ from pyezviz.constants import DeviceCatagories, DeviceSwitchType, SupportExt from pyezviz.exceptions import HTTPError, PyEzvizError from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -17,8 +16,7 @@ from homeassistant.util.percentage import ( ranged_value_to_percentage, ) -from .const import DATA_COORDINATOR, DOMAIN -from .coordinator import EzvizDataUpdateCoordinator +from .coordinator import EzvizConfigEntry, EzvizDataUpdateCoordinator from .entity import EzvizEntity PARALLEL_UPDATES = 1 @@ -26,12 +24,12 @@ BRIGHTNESS_RANGE = (1, 255) async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: EzvizConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up EZVIZ lights based on a config entry.""" - coordinator: EzvizDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ - DATA_COORDINATOR - ] + coordinator = entry.runtime_data async_add_entities( EzvizLight(coordinator, camera) diff --git a/homeassistant/components/ezviz/number.py b/homeassistant/components/ezviz/number.py index 08fbd3afb34..9e8a20f36dd 100644 --- a/homeassistant/components/ezviz/number.py +++ b/homeassistant/components/ezviz/number.py @@ -16,14 +16,12 @@ from pyezviz.exceptions import ( ) from homeassistant.components.number import NumberEntity, NumberEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DATA_COORDINATOR, DOMAIN -from .coordinator import EzvizDataUpdateCoordinator +from .coordinator import EzvizConfigEntry, EzvizDataUpdateCoordinator from .entity import EzvizBaseEntity SCAN_INTERVAL = timedelta(seconds=3600) @@ -51,12 +49,12 @@ NUMBER_TYPE = EzvizNumberEntityDescription( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: EzvizConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up EZVIZ sensors based on a config entry.""" - coordinator: EzvizDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ - DATA_COORDINATOR - ] + coordinator = entry.runtime_data async_add_entities( EzvizNumber(coordinator, camera, value, entry.entry_id) diff --git a/homeassistant/components/ezviz/select.py b/homeassistant/components/ezviz/select.py index d6dc3dc8550..8e037fe6c33 100644 --- a/homeassistant/components/ezviz/select.py +++ b/homeassistant/components/ezviz/select.py @@ -8,14 +8,12 @@ from pyezviz.constants import DeviceSwitchType, SoundMode from pyezviz.exceptions import HTTPError, PyEzvizError from homeassistant.components.select import SelectEntity, SelectEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DATA_COORDINATOR, DOMAIN -from .coordinator import EzvizDataUpdateCoordinator +from .coordinator import EzvizConfigEntry, EzvizDataUpdateCoordinator from .entity import EzvizEntity PARALLEL_UPDATES = 1 @@ -38,12 +36,12 @@ SELECT_TYPE = EzvizSelectEntityDescription( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: EzvizConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up EZVIZ select entities based on a config entry.""" - coordinator: EzvizDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ - DATA_COORDINATOR - ] + coordinator = entry.runtime_data async_add_entities( EzvizSelect(coordinator, camera) diff --git a/homeassistant/components/ezviz/sensor.py b/homeassistant/components/ezviz/sensor.py index e0750b985fc..f3d50836bc7 100644 --- a/homeassistant/components/ezviz/sensor.py +++ b/homeassistant/components/ezviz/sensor.py @@ -7,13 +7,11 @@ from homeassistant.components.sensor import ( SensorEntity, SensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DATA_COORDINATOR, DOMAIN -from .coordinator import EzvizDataUpdateCoordinator +from .coordinator import EzvizConfigEntry, EzvizDataUpdateCoordinator from .entity import EzvizEntity PARALLEL_UPDATES = 1 @@ -72,12 +70,12 @@ SENSOR_TYPES: dict[str, SensorEntityDescription] = { async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: EzvizConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up EZVIZ sensors based on a config entry.""" - coordinator: EzvizDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ - DATA_COORDINATOR - ] + coordinator = entry.runtime_data async_add_entities( [ diff --git a/homeassistant/components/ezviz/siren.py b/homeassistant/components/ezviz/siren.py index 8bacceff29f..a52e499eee2 100644 --- a/homeassistant/components/ezviz/siren.py +++ b/homeassistant/components/ezviz/siren.py @@ -13,7 +13,6 @@ from homeassistant.components.siren import ( SirenEntityDescription, SirenEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_ON from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError @@ -21,8 +20,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback import homeassistant.helpers.event as evt from homeassistant.helpers.restore_state import RestoreEntity -from .const import DATA_COORDINATOR, DOMAIN -from .coordinator import EzvizDataUpdateCoordinator +from .coordinator import EzvizConfigEntry, EzvizDataUpdateCoordinator from .entity import EzvizBaseEntity PARALLEL_UPDATES = 1 @@ -35,12 +33,12 @@ SIREN_ENTITY_TYPE = SirenEntityDescription( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: EzvizConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up EZVIZ sensors based on a config entry.""" - coordinator: EzvizDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ - DATA_COORDINATOR - ] + coordinator = entry.runtime_data async_add_entities( EzvizSirenEntity(coordinator, camera, SIREN_ENTITY_TYPE) diff --git a/homeassistant/components/ezviz/switch.py b/homeassistant/components/ezviz/switch.py index 65fb7b9f36b..1a347c931a6 100644 --- a/homeassistant/components/ezviz/switch.py +++ b/homeassistant/components/ezviz/switch.py @@ -13,13 +13,11 @@ from homeassistant.components.switch import ( SwitchEntity, SwitchEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DATA_COORDINATOR, DOMAIN -from .coordinator import EzvizDataUpdateCoordinator +from .coordinator import EzvizConfigEntry, EzvizDataUpdateCoordinator from .entity import EzvizEntity @@ -107,12 +105,12 @@ SWITCH_TYPES: dict[int, EzvizSwitchEntityDescription] = { async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: EzvizConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up EZVIZ switch based on a config entry.""" - coordinator: EzvizDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ - DATA_COORDINATOR - ] + coordinator = entry.runtime_data async_add_entities( EzvizSwitch(coordinator, camera, switch_number) diff --git a/homeassistant/components/ezviz/update.py b/homeassistant/components/ezviz/update.py index 25a506a0052..3027e048688 100644 --- a/homeassistant/components/ezviz/update.py +++ b/homeassistant/components/ezviz/update.py @@ -12,13 +12,11 @@ from homeassistant.components.update import ( UpdateEntityDescription, UpdateEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DATA_COORDINATOR, DOMAIN -from .coordinator import EzvizDataUpdateCoordinator +from .coordinator import EzvizConfigEntry, EzvizDataUpdateCoordinator from .entity import EzvizEntity PARALLEL_UPDATES = 1 @@ -30,12 +28,12 @@ UPDATE_ENTITY_TYPES = UpdateEntityDescription( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: EzvizConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up EZVIZ sensors based on a config entry.""" - coordinator: EzvizDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ - DATA_COORDINATOR - ] + coordinator = entry.runtime_data async_add_entities( EzvizUpdateEntity(coordinator, camera, sensor, UPDATE_ENTITY_TYPES) From 164078ac69e94aa9d9b07c5e8e61781981e20fac Mon Sep 17 00:00:00 2001 From: Gerben Jongerius Date: Tue, 28 Jan 2025 11:29:29 +0100 Subject: [PATCH 0099/3148] Add translations for youless sensors (#136349) --- homeassistant/components/youless/entity.py | 2 +- homeassistant/components/youless/sensor.py | 93 +- homeassistant/components/youless/strings.json | 54 + .../youless/snapshots/test_sensor.ambr | 1270 ++++++++--------- 4 files changed, 719 insertions(+), 700 deletions(-) diff --git a/homeassistant/components/youless/entity.py b/homeassistant/components/youless/entity.py index 9931768c267..4500fe71a96 100644 --- a/homeassistant/components/youless/entity.py +++ b/homeassistant/components/youless/entity.py @@ -20,6 +20,6 @@ class YouLessEntity(CoordinatorEntity[YouLessCoordinator]): identifiers={(DOMAIN, device_group)}, manufacturer="YouLess", model=self.device.model, - name=device_name, + translation_key=device_name, sw_version=self.device.firmware_version, ) diff --git a/homeassistant/components/youless/sensor.py b/homeassistant/components/youless/sensor.py index 413f1ad6958..3afb215ed5f 100644 --- a/homeassistant/components/youless/sensor.py +++ b/homeassistant/components/youless/sensor.py @@ -36,7 +36,6 @@ class YouLessSensorEntityDescription(SensorEntityDescription): """Describes a YouLess sensor entity.""" device_group: str - device_group_name: str value_func: Callable[[YoulessAPI], float | None] @@ -44,9 +43,7 @@ SENSOR_TYPES: tuple[YouLessSensorEntityDescription, ...] = ( YouLessSensorEntityDescription( key="water", device_group="water", - device_group_name="Water meter", - name="Water usage", - icon="mdi:water", + translation_key="total_water", device_class=SensorDeviceClass.WATER, state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, @@ -57,9 +54,7 @@ SENSOR_TYPES: tuple[YouLessSensorEntityDescription, ...] = ( YouLessSensorEntityDescription( key="gas", device_group="gas", - device_group_name="Gas meter", - name="Gas usage", - icon="mdi:fire", + translation_key="total_gas_m3", device_class=SensorDeviceClass.GAS, state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, @@ -68,9 +63,7 @@ SENSOR_TYPES: tuple[YouLessSensorEntityDescription, ...] = ( YouLessSensorEntityDescription( key="usage", device_group="power", - device_group_name="Power usage", - name="Power Usage", - icon="mdi:meter-electric", + translation_key="active_power_w", device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPower.WATT, @@ -83,9 +76,8 @@ SENSOR_TYPES: tuple[YouLessSensorEntityDescription, ...] = ( YouLessSensorEntityDescription( key="power_low", device_group="power", - device_group_name="Power usage", - name="Energy low", - icon="mdi:transmission-tower-export", + translation_key="total_energy_import_tariff_kwh", + translation_placeholders={"tariff": "1"}, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, @@ -96,9 +88,8 @@ SENSOR_TYPES: tuple[YouLessSensorEntityDescription, ...] = ( YouLessSensorEntityDescription( key="power_high", device_group="power", - device_group_name="Power usage", - name="Energy high", - icon="mdi:transmission-tower-export", + translation_key="total_energy_import_tariff_kwh", + translation_placeholders={"tariff": "2"}, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, @@ -109,9 +100,7 @@ SENSOR_TYPES: tuple[YouLessSensorEntityDescription, ...] = ( YouLessSensorEntityDescription( key="power_total", device_group="power", - device_group_name="Power usage", - name="Energy total", - icon="mdi:transmission-tower-export", + translation_key="total_energy_import_kwh", device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, @@ -124,9 +113,8 @@ SENSOR_TYPES: tuple[YouLessSensorEntityDescription, ...] = ( YouLessSensorEntityDescription( key="phase_1_power", device_group="power", - device_group_name="Power usage", - name="Phase 1 power", - icon=None, + translation_key="active_power_phase_w", + translation_placeholders={"phase": "1"}, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPower.WATT, @@ -135,9 +123,8 @@ SENSOR_TYPES: tuple[YouLessSensorEntityDescription, ...] = ( YouLessSensorEntityDescription( key="phase_1_voltage", device_group="power", - device_group_name="Power usage", - name="Phase 1 voltage", - icon=None, + translation_key="active_voltage_phase_v", + translation_placeholders={"phase": "1"}, device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfElectricPotential.VOLT, @@ -148,9 +135,8 @@ SENSOR_TYPES: tuple[YouLessSensorEntityDescription, ...] = ( YouLessSensorEntityDescription( key="phase_1_current", device_group="power", - device_group_name="Power usage", - name="Phase 1 current", - icon=None, + translation_key="active_current_phase_a", + translation_placeholders={"phase": "1"}, device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, @@ -161,9 +147,8 @@ SENSOR_TYPES: tuple[YouLessSensorEntityDescription, ...] = ( YouLessSensorEntityDescription( key="phase_2_power", device_group="power", - device_group_name="Power usage", - name="Phase 2 power", - icon=None, + translation_key="active_power_phase_w", + translation_placeholders={"phase": "2"}, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPower.WATT, @@ -172,9 +157,8 @@ SENSOR_TYPES: tuple[YouLessSensorEntityDescription, ...] = ( YouLessSensorEntityDescription( key="phase_2_voltage", device_group="power", - device_group_name="Power usage", - name="Phase 2 voltage", - icon=None, + translation_key="active_voltage_phase_v", + translation_placeholders={"phase": "2"}, device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfElectricPotential.VOLT, @@ -185,9 +169,8 @@ SENSOR_TYPES: tuple[YouLessSensorEntityDescription, ...] = ( YouLessSensorEntityDescription( key="phase_2_current", device_group="power", - device_group_name="Power usage", - name="Phase 2 current", - icon=None, + translation_key="active_current_phase_a", + translation_placeholders={"phase": "2"}, device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, @@ -198,9 +181,8 @@ SENSOR_TYPES: tuple[YouLessSensorEntityDescription, ...] = ( YouLessSensorEntityDescription( key="phase_3_power", device_group="power", - device_group_name="Power usage", - name="Phase 3 power", - icon=None, + translation_key="active_power_phase_w", + translation_placeholders={"phase": "3"}, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPower.WATT, @@ -209,9 +191,8 @@ SENSOR_TYPES: tuple[YouLessSensorEntityDescription, ...] = ( YouLessSensorEntityDescription( key="phase_3_voltage", device_group="power", - device_group_name="Power usage", - name="Phase 3 voltage", - icon=None, + translation_key="active_voltage_phase_v", + translation_placeholders={"phase": "3"}, device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfElectricPotential.VOLT, @@ -222,9 +203,8 @@ SENSOR_TYPES: tuple[YouLessSensorEntityDescription, ...] = ( YouLessSensorEntityDescription( key="phase_3_current", device_group="power", - device_group_name="Power usage", - name="Phase 3 current", - icon=None, + translation_key="active_current_phase_a", + translation_placeholders={"phase": "3"}, device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, @@ -235,9 +215,8 @@ SENSOR_TYPES: tuple[YouLessSensorEntityDescription, ...] = ( YouLessSensorEntityDescription( key="delivery_low", device_group="delivery", - device_group_name="Energy delivery", - name="Energy delivery low", - icon="mdi:transmission-tower-import", + translation_key="total_energy_export_tariff_kwh", + translation_placeholders={"tariff": "1"}, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, @@ -250,9 +229,8 @@ SENSOR_TYPES: tuple[YouLessSensorEntityDescription, ...] = ( YouLessSensorEntityDescription( key="delivery_high", device_group="delivery", - device_group_name="Energy delivery", - name="Energy delivery high", - icon="mdi:transmission-tower-import", + translation_key="total_energy_export_tariff_kwh", + translation_placeholders={"tariff": "2"}, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, @@ -265,9 +243,7 @@ SENSOR_TYPES: tuple[YouLessSensorEntityDescription, ...] = ( YouLessSensorEntityDescription( key="extra_total", device_group="extra", - device_group_name="Extra meter", - name="Extra total", - icon="mdi:meter-electric", + translation_key="total_s0_kwh", device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, @@ -280,9 +256,7 @@ SENSOR_TYPES: tuple[YouLessSensorEntityDescription, ...] = ( YouLessSensorEntityDescription( key="extra_usage", device_group="extra", - device_group_name="Extra meter", - name="Extra usage", - icon="mdi:lightning-bolt", + translation_key="active_s0_w", device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPower.WATT, @@ -316,6 +290,7 @@ class YouLessSensor(YouLessEntity, SensorEntity): """Representation of a Sensor.""" entity_description: YouLessSensorEntityDescription + _attr_has_entity_name = True def __init__( self, @@ -327,7 +302,7 @@ class YouLessSensor(YouLessEntity, SensorEntity): super().__init__( coordinator, f"{device}_{description.device_group}", - description.device_group_name, + description.device_group, ) self._attr_unique_id = f"{DOMAIN}_{device}_{description.key}" self.entity_description = description diff --git a/homeassistant/components/youless/strings.json b/homeassistant/components/youless/strings.json index e0eddd7d137..8a3f6cb5d8b 100644 --- a/homeassistant/components/youless/strings.json +++ b/homeassistant/components/youless/strings.json @@ -14,5 +14,59 @@ "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" } + }, + "device": { + "water": { + "name": "Water meter" + }, + "gas": { + "name": "Gas meter" + }, + "power": { + "name": "Power meter" + }, + "delivery": { + "name": "Energy delivery meter" + }, + "extra": { + "name": "S0 meter" + } + }, + "entity": { + "sensor": { + "total_water": { + "name": "Total water usage" + }, + "total_gas_m3": { + "name": "Total gas usage" + }, + "active_power_w": { + "name": "Current power usage" + }, + "active_power_phase_w": { + "name": "Power phase {phase}" + }, + "active_voltage_phase_v": { + "name": "Voltage phase {phase}" + }, + "active_current_phase_a": { + "name": "Current phase {phase}" + }, + "total_energy_import_tariff_kwh": { + "name": "Energy import tariff {tariff}" + }, + "total_energy_import_kwh": { + "name": "Total energy import" + }, + "total_energy_export_tariff_kwh": { + "name": "Energy export tariff {tariff}" + }, + "total_s0_kwh": { + "name": "Total energy" + }, + "active_s0_w": { + "name": "Current usage" + } + } } } diff --git a/tests/components/youless/snapshots/test_sensor.ambr b/tests/components/youless/snapshots/test_sensor.ambr index 3424a264f48..0647d854d2a 100644 --- a/tests/components/youless/snapshots/test_sensor.ambr +++ b/tests/components/youless/snapshots/test_sensor.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_sensors[sensor.energy_delivery_high-entry] +# name: test_sensors[sensor.energy_delivery_meter_energy_export_tariff_1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -13,8 +13,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.energy_delivery_high', - 'has_entity_name': False, + 'entity_id': 'sensor.energy_delivery_meter_energy_export_tariff_1', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -24,86 +24,33 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:transmission-tower-import', - 'original_name': 'Energy delivery high', + 'original_icon': None, + 'original_name': 'Energy export tariff 1', 'platform': 'youless', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'youless_localhost_delivery_high', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[sensor.energy_delivery_high-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'energy', - 'friendly_name': 'Energy delivery high', - 'icon': 'mdi:transmission-tower-import', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.energy_delivery_high', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '0.0', - }) -# --- -# name: test_sensors[sensor.energy_delivery_low-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.energy_delivery_low', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': 'mdi:transmission-tower-import', - 'original_name': 'Energy delivery low', - 'platform': 'youless', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'total_energy_export_tariff_kwh', 'unique_id': 'youless_localhost_delivery_low', 'unit_of_measurement': , }) # --- -# name: test_sensors[sensor.energy_delivery_low-state] +# name: test_sensors[sensor.energy_delivery_meter_energy_export_tariff_1-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', - 'friendly_name': 'Energy delivery low', - 'icon': 'mdi:transmission-tower-import', + 'friendly_name': 'Energy delivery meter Energy export tariff 1', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.energy_delivery_low', + 'entity_id': 'sensor.energy_delivery_meter_energy_export_tariff_1', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0.029', }) # --- -# name: test_sensors[sensor.energy_high-entry] +# name: test_sensors[sensor.energy_delivery_meter_energy_export_tariff_2-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -117,8 +64,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.energy_high', - 'has_entity_name': False, + 'entity_id': 'sensor.energy_delivery_meter_energy_export_tariff_2', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -128,242 +75,33 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:transmission-tower-export', - 'original_name': 'Energy high', + 'original_icon': None, + 'original_name': 'Energy export tariff 2', 'platform': 'youless', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'youless_localhost_power_high', + 'translation_key': 'total_energy_export_tariff_kwh', + 'unique_id': 'youless_localhost_delivery_high', 'unit_of_measurement': , }) # --- -# name: test_sensors[sensor.energy_high-state] +# name: test_sensors[sensor.energy_delivery_meter_energy_export_tariff_2-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', - 'friendly_name': 'Energy high', - 'icon': 'mdi:transmission-tower-export', + 'friendly_name': 'Energy delivery meter Energy export tariff 2', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.energy_high', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '4490.631', - }) -# --- -# name: test_sensors[sensor.energy_low-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.energy_low', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': 'mdi:transmission-tower-export', - 'original_name': 'Energy low', - 'platform': 'youless', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'youless_localhost_power_low', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[sensor.energy_low-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'energy', - 'friendly_name': 'Energy low', - 'icon': 'mdi:transmission-tower-export', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.energy_low', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '4703.562', - }) -# --- -# name: test_sensors[sensor.energy_total-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.energy_total', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': 'mdi:transmission-tower-export', - 'original_name': 'Energy total', - 'platform': 'youless', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'youless_localhost_power_total', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[sensor.energy_total-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'energy', - 'friendly_name': 'Energy total', - 'icon': 'mdi:transmission-tower-export', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.energy_total', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '9194.164', - }) -# --- -# name: test_sensors[sensor.extra_total-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.extra_total', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': 'mdi:meter-electric', - 'original_name': 'Extra total', - 'platform': 'youless', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'youless_localhost_extra_total', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[sensor.extra_total-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'energy', - 'friendly_name': 'Extra total', - 'icon': 'mdi:meter-electric', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.extra_total', + 'entity_id': 'sensor.energy_delivery_meter_energy_export_tariff_2', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0.0', }) # --- -# name: test_sensors[sensor.extra_usage-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.extra_usage', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': 'mdi:lightning-bolt', - 'original_name': 'Extra usage', - 'platform': 'youless', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'youless_localhost_extra_usage', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[sensor.extra_usage-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'power', - 'friendly_name': 'Extra usage', - 'icon': 'mdi:lightning-bolt', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.extra_usage', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '0', - }) -# --- -# name: test_sensors[sensor.gas_usage-entry] +# name: test_sensors[sensor.gas_meter_total_gas_usage-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -377,8 +115,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.gas_usage', - 'has_entity_name': False, + 'entity_id': 'sensor.gas_meter_total_gas_usage', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -388,34 +126,33 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:fire', - 'original_name': 'Gas usage', + 'original_icon': None, + 'original_name': 'Total gas usage', 'platform': 'youless', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'total_gas_m3', 'unique_id': 'youless_localhost_gas', 'unit_of_measurement': , }) # --- -# name: test_sensors[sensor.gas_usage-state] +# name: test_sensors[sensor.gas_meter_total_gas_usage-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'gas', - 'friendly_name': 'Gas usage', - 'icon': 'mdi:fire', + 'friendly_name': 'Gas meter Total gas usage', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.gas_usage', + 'entity_id': 'sensor.gas_meter_total_gas_usage', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '1624.264', }) # --- -# name: test_sensors[sensor.phase_1_current-entry] +# name: test_sensors[sensor.power_meter_current_phase_1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -429,8 +166,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.phase_1_current', - 'has_entity_name': False, + 'entity_id': 'sensor.power_meter_current_phase_1', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -441,32 +178,32 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Phase 1 current', + 'original_name': 'Current phase 1', 'platform': 'youless', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'active_current_phase_a', 'unique_id': 'youless_localhost_phase_1_current', 'unit_of_measurement': , }) # --- -# name: test_sensors[sensor.phase_1_current-state] +# name: test_sensors[sensor.power_meter_current_phase_1-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'current', - 'friendly_name': 'Phase 1 current', + 'friendly_name': 'Power meter Current phase 1', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.phase_1_current', + 'entity_id': 'sensor.power_meter_current_phase_1', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'unknown', }) # --- -# name: test_sensors[sensor.phase_1_power-entry] +# name: test_sensors[sensor.power_meter_current_phase_2-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -480,110 +217,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.phase_1_power', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Phase 1 power', - 'platform': 'youless', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'youless_localhost_phase_1_power', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[sensor.phase_1_power-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'power', - 'friendly_name': 'Phase 1 power', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.phase_1_power', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_sensors[sensor.phase_1_voltage-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.phase_1_voltage', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Phase 1 voltage', - 'platform': 'youless', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'youless_localhost_phase_1_voltage', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[sensor.phase_1_voltage-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'voltage', - 'friendly_name': 'Phase 1 voltage', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.phase_1_voltage', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_sensors[sensor.phase_2_current-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.phase_2_current', - 'has_entity_name': False, + 'entity_id': 'sensor.power_meter_current_phase_2', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -594,32 +229,32 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Phase 2 current', + 'original_name': 'Current phase 2', 'platform': 'youless', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'active_current_phase_a', 'unique_id': 'youless_localhost_phase_2_current', 'unit_of_measurement': , }) # --- -# name: test_sensors[sensor.phase_2_current-state] +# name: test_sensors[sensor.power_meter_current_phase_2-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'current', - 'friendly_name': 'Phase 2 current', + 'friendly_name': 'Power meter Current phase 2', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.phase_2_current', + 'entity_id': 'sensor.power_meter_current_phase_2', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'unknown', }) # --- -# name: test_sensors[sensor.phase_2_power-entry] +# name: test_sensors[sensor.power_meter_current_phase_3-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -633,110 +268,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.phase_2_power', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Phase 2 power', - 'platform': 'youless', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'youless_localhost_phase_2_power', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[sensor.phase_2_power-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'power', - 'friendly_name': 'Phase 2 power', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.phase_2_power', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_sensors[sensor.phase_2_voltage-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.phase_2_voltage', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Phase 2 voltage', - 'platform': 'youless', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'youless_localhost_phase_2_voltage', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[sensor.phase_2_voltage-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'voltage', - 'friendly_name': 'Phase 2 voltage', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.phase_2_voltage', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_sensors[sensor.phase_3_current-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.phase_3_current', - 'has_entity_name': False, + 'entity_id': 'sensor.power_meter_current_phase_3', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -747,32 +280,32 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Phase 3 current', + 'original_name': 'Current phase 3', 'platform': 'youless', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'active_current_phase_a', 'unique_id': 'youless_localhost_phase_3_current', 'unit_of_measurement': , }) # --- -# name: test_sensors[sensor.phase_3_current-state] +# name: test_sensors[sensor.power_meter_current_phase_3-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'current', - 'friendly_name': 'Phase 3 current', + 'friendly_name': 'Power meter Current phase 3', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.phase_3_current', + 'entity_id': 'sensor.power_meter_current_phase_3', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'unknown', }) # --- -# name: test_sensors[sensor.phase_3_power-entry] +# name: test_sensors[sensor.power_meter_current_power_usage-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -786,8 +319,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.phase_3_power', - 'has_entity_name': False, + 'entity_id': 'sensor.power_meter_current_power_usage', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -798,135 +331,32 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Phase 3 power', + 'original_name': 'Current power usage', 'platform': 'youless', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'youless_localhost_phase_3_power', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[sensor.phase_3_power-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'power', - 'friendly_name': 'Phase 3 power', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.phase_3_power', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_sensors[sensor.phase_3_voltage-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.phase_3_voltage', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Phase 3 voltage', - 'platform': 'youless', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'youless_localhost_phase_3_voltage', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[sensor.phase_3_voltage-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'voltage', - 'friendly_name': 'Phase 3 voltage', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.phase_3_voltage', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_sensors[sensor.power_usage-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.power_usage', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': 'mdi:meter-electric', - 'original_name': 'Power Usage', - 'platform': 'youless', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'active_power_w', 'unique_id': 'youless_localhost_usage', 'unit_of_measurement': , }) # --- -# name: test_sensors[sensor.power_usage-state] +# name: test_sensors[sensor.power_meter_current_power_usage-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power', - 'friendly_name': 'Power Usage', - 'icon': 'mdi:meter-electric', + 'friendly_name': 'Power meter Current power usage', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.power_usage', + 'entity_id': 'sensor.power_meter_current_power_usage', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '2382', }) # --- -# name: test_sensors[sensor.water_usage-entry] +# name: test_sensors[sensor.power_meter_energy_import_tariff_1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -940,8 +370,569 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.water_usage', - 'has_entity_name': False, + 'entity_id': 'sensor.power_meter_energy_import_tariff_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': 'Energy import tariff 1', + 'platform': 'youless', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_import_tariff_kwh', + 'unique_id': 'youless_localhost_power_low', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.power_meter_energy_import_tariff_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Power meter Energy import tariff 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.power_meter_energy_import_tariff_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4703.562', + }) +# --- +# name: test_sensors[sensor.power_meter_energy_import_tariff_2-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.power_meter_energy_import_tariff_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': 'Energy import tariff 2', + 'platform': 'youless', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_import_tariff_kwh', + 'unique_id': 'youless_localhost_power_high', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.power_meter_energy_import_tariff_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Power meter Energy import tariff 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.power_meter_energy_import_tariff_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4490.631', + }) +# --- +# name: test_sensors[sensor.power_meter_power_phase_1-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.power_meter_power_phase_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': 'Power phase 1', + 'platform': 'youless', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_power_phase_w', + 'unique_id': 'youless_localhost_phase_1_power', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.power_meter_power_phase_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Power meter Power phase 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.power_meter_power_phase_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[sensor.power_meter_power_phase_2-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.power_meter_power_phase_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': 'Power phase 2', + 'platform': 'youless', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_power_phase_w', + 'unique_id': 'youless_localhost_phase_2_power', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.power_meter_power_phase_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Power meter Power phase 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.power_meter_power_phase_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[sensor.power_meter_power_phase_3-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.power_meter_power_phase_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': 'Power phase 3', + 'platform': 'youless', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_power_phase_w', + 'unique_id': 'youless_localhost_phase_3_power', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.power_meter_power_phase_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Power meter Power phase 3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.power_meter_power_phase_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[sensor.power_meter_total_energy_import-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.power_meter_total_energy_import', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total energy import', + 'platform': 'youless', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_import_kwh', + 'unique_id': 'youless_localhost_power_total', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.power_meter_total_energy_import-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Power meter Total energy import', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.power_meter_total_energy_import', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '9194.164', + }) +# --- +# name: test_sensors[sensor.power_meter_voltage_phase_1-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.power_meter_voltage_phase_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': 'Voltage phase 1', + 'platform': 'youless', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_voltage_phase_v', + 'unique_id': 'youless_localhost_phase_1_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.power_meter_voltage_phase_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Power meter Voltage phase 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.power_meter_voltage_phase_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[sensor.power_meter_voltage_phase_2-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.power_meter_voltage_phase_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': 'Voltage phase 2', + 'platform': 'youless', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_voltage_phase_v', + 'unique_id': 'youless_localhost_phase_2_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.power_meter_voltage_phase_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Power meter Voltage phase 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.power_meter_voltage_phase_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[sensor.power_meter_voltage_phase_3-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.power_meter_voltage_phase_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': 'Voltage phase 3', + 'platform': 'youless', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_voltage_phase_v', + 'unique_id': 'youless_localhost_phase_3_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.power_meter_voltage_phase_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Power meter Voltage phase 3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.power_meter_voltage_phase_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[sensor.s0_meter_current_usage-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.s0_meter_current_usage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current usage', + 'platform': 'youless', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_s0_w', + 'unique_id': 'youless_localhost_extra_usage', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.s0_meter_current_usage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'S0 meter Current usage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.s0_meter_current_usage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensors[sensor.s0_meter_total_energy-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.s0_meter_total_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total energy', + 'platform': 'youless', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_s0_kwh', + 'unique_id': 'youless_localhost_extra_total', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.s0_meter_total_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'S0 meter Total energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.s0_meter_total_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[sensor.water_meter_total_water_usage-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.water_meter_total_water_usage', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -951,27 +942,26 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:water', - 'original_name': 'Water usage', + 'original_icon': None, + 'original_name': 'Total water usage', 'platform': 'youless', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'total_water', 'unique_id': 'youless_localhost_water', 'unit_of_measurement': , }) # --- -# name: test_sensors[sensor.water_usage-state] +# name: test_sensors[sensor.water_meter_total_water_usage-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'water', - 'friendly_name': 'Water usage', - 'icon': 'mdi:water', + 'friendly_name': 'Water meter Total water usage', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.water_usage', + 'entity_id': 'sensor.water_meter_total_water_usage', 'last_changed': , 'last_reported': , 'last_updated': , From c7c234c5dd6276675d744b276b4e432235165790 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 28 Jan 2025 11:34:57 +0100 Subject: [PATCH 0100/3148] Use runtime_data in electric_kiwi (#136699) --- .../components/electric_kiwi/__init__.py | 28 ++++++++--------- .../components/electric_kiwi/const.py | 3 -- .../components/electric_kiwi/coordinator.py | 31 +++++++++++++++++-- .../components/electric_kiwi/select.py | 13 ++++---- .../components/electric_kiwi/sensor.py | 16 +++++----- 5 files changed, 56 insertions(+), 35 deletions(-) diff --git a/homeassistant/components/electric_kiwi/__init__.py b/homeassistant/components/electric_kiwi/__init__.py index 8c9a0b3950e..de8d87553a3 100644 --- a/homeassistant/components/electric_kiwi/__init__.py +++ b/homeassistant/components/electric_kiwi/__init__.py @@ -6,23 +6,25 @@ import aiohttp from electrickiwi_api import ElectricKiwiApi from electrickiwi_api.exceptions import ApiException -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import aiohttp_client, config_entry_oauth2_flow from . import api -from .const import ACCOUNT_COORDINATOR, DOMAIN, HOP_COORDINATOR from .coordinator import ( ElectricKiwiAccountDataCoordinator, + ElectricKiwiConfigEntry, ElectricKiwiHOPDataCoordinator, + ElectricKiwiRuntimeData, ) PLATFORMS: list[Platform] = [Platform.SELECT, Platform.SENSOR] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry( + hass: HomeAssistant, entry: ElectricKiwiConfigEntry +) -> bool: """Set up Electric Kiwi from a config entry.""" implementation = ( await config_entry_oauth2_flow.async_get_config_entry_implementation( @@ -44,8 +46,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ek_api = ElectricKiwiApi( api.AsyncConfigEntryAuth(aiohttp_client.async_get_clientsession(hass), session) ) - hop_coordinator = ElectricKiwiHOPDataCoordinator(hass, ek_api) - account_coordinator = ElectricKiwiAccountDataCoordinator(hass, ek_api) + hop_coordinator = ElectricKiwiHOPDataCoordinator(hass, entry, ek_api) + account_coordinator = ElectricKiwiAccountDataCoordinator(hass, entry, ek_api) try: await ek_api.set_active_session() @@ -54,19 +56,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except ApiException as err: raise ConfigEntryNotReady from err - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = { - HOP_COORDINATOR: hop_coordinator, - ACCOUNT_COORDINATOR: account_coordinator, - } + entry.runtime_data = ElectricKiwiRuntimeData( + hop=hop_coordinator, account=account_coordinator + ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, entry: ElectricKiwiConfigEntry +) -> 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 + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/electric_kiwi/const.py b/homeassistant/components/electric_kiwi/const.py index 0b455b045cf..907b6247172 100644 --- a/homeassistant/components/electric_kiwi/const.py +++ b/homeassistant/components/electric_kiwi/const.py @@ -9,6 +9,3 @@ OAUTH2_TOKEN = "https://welcome.electrickiwi.co.nz/oauth/token" API_BASE_URL = "https://api.electrickiwi.co.nz" SCOPE_VALUES = "read_connection_detail read_billing_frequency read_account_running_balance read_consumption_summary read_consumption_averages read_hop_intervals_config read_hop_connection save_hop_connection read_session" - -HOP_COORDINATOR = "hop_coordinator" -ACCOUNT_COORDINATOR = "account_coordinator" diff --git a/homeassistant/components/electric_kiwi/coordinator.py b/homeassistant/components/electric_kiwi/coordinator.py index a10be5eafdd..2065da5d668 100644 --- a/homeassistant/components/electric_kiwi/coordinator.py +++ b/homeassistant/components/electric_kiwi/coordinator.py @@ -1,7 +1,10 @@ """Electric Kiwi coordinators.""" +from __future__ import annotations + import asyncio from collections import OrderedDict +from dataclasses import dataclass from datetime import timedelta import logging @@ -9,6 +12,7 @@ from electrickiwi_api import ElectricKiwiApi from electrickiwi_api.exceptions import ApiException, AuthException from electrickiwi_api.model import AccountBalance, Hop, HopIntervals +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -19,14 +23,31 @@ ACCOUNT_SCAN_INTERVAL = timedelta(hours=6) HOP_SCAN_INTERVAL = timedelta(minutes=20) +@dataclass +class ElectricKiwiRuntimeData: + """ElectricKiwi runtime data.""" + + hop: ElectricKiwiHOPDataCoordinator + account: ElectricKiwiAccountDataCoordinator + + +type ElectricKiwiConfigEntry = ConfigEntry[ElectricKiwiRuntimeData] + + class ElectricKiwiAccountDataCoordinator(DataUpdateCoordinator[AccountBalance]): """ElectricKiwi Account Data object.""" - def __init__(self, hass: HomeAssistant, ek_api: ElectricKiwiApi) -> None: + def __init__( + self, + hass: HomeAssistant, + entry: ElectricKiwiConfigEntry, + ek_api: ElectricKiwiApi, + ) -> None: """Initialize ElectricKiwiAccountDataCoordinator.""" super().__init__( hass, _LOGGER, + config_entry=entry, name="Electric Kiwi Account Data", update_interval=ACCOUNT_SCAN_INTERVAL, ) @@ -48,11 +69,17 @@ class ElectricKiwiAccountDataCoordinator(DataUpdateCoordinator[AccountBalance]): class ElectricKiwiHOPDataCoordinator(DataUpdateCoordinator[Hop]): """ElectricKiwi HOP Data object.""" - def __init__(self, hass: HomeAssistant, ek_api: ElectricKiwiApi) -> None: + def __init__( + self, + hass: HomeAssistant, + entry: ElectricKiwiConfigEntry, + ek_api: ElectricKiwiApi, + ) -> None: """Initialize ElectricKiwiAccountDataCoordinator.""" super().__init__( hass, _LOGGER, + config_entry=entry, # Name of the data. For logging purposes. name="Electric Kiwi HOP Data", # Polling interval. Will only be polled if there are subscribers. diff --git a/homeassistant/components/electric_kiwi/select.py b/homeassistant/components/electric_kiwi/select.py index a3f073b8ca2..fa111381612 100644 --- a/homeassistant/components/electric_kiwi/select.py +++ b/homeassistant/components/electric_kiwi/select.py @@ -5,14 +5,13 @@ from __future__ import annotations import logging from homeassistant.components.select import SelectEntity, SelectEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import ATTRIBUTION, DOMAIN, HOP_COORDINATOR -from .coordinator import ElectricKiwiHOPDataCoordinator +from .const import ATTRIBUTION +from .coordinator import ElectricKiwiConfigEntry, ElectricKiwiHOPDataCoordinator _LOGGER = logging.getLogger(__name__) ATTR_EK_HOP_SELECT = "hop_select" @@ -25,12 +24,12 @@ HOP_SELECT = SelectEntityDescription( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ElectricKiwiConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Electric Kiwi select setup.""" - hop_coordinator: ElectricKiwiHOPDataCoordinator = hass.data[DOMAIN][entry.entry_id][ - HOP_COORDINATOR - ] + hop_coordinator = entry.runtime_data.hop _LOGGER.debug("Setting up select entity") async_add_entities([ElectricKiwiSelectHOPEntity(hop_coordinator, HOP_SELECT)]) diff --git a/homeassistant/components/electric_kiwi/sensor.py b/homeassistant/components/electric_kiwi/sensor.py index 7672466106b..e070f9495c1 100644 --- a/homeassistant/components/electric_kiwi/sensor.py +++ b/homeassistant/components/electric_kiwi/sensor.py @@ -14,16 +14,16 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CURRENCY_DOLLAR, PERCENTAGE from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import dt as dt_util -from .const import ACCOUNT_COORDINATOR, ATTRIBUTION, DOMAIN, HOP_COORDINATOR +from .const import ATTRIBUTION from .coordinator import ( ElectricKiwiAccountDataCoordinator, + ElectricKiwiConfigEntry, ElectricKiwiHOPDataCoordinator, ) @@ -122,12 +122,12 @@ HOP_SENSOR_TYPES: tuple[ElectricKiwiHOPSensorEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ElectricKiwiConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Electric Kiwi Sensors Setup.""" - account_coordinator: ElectricKiwiAccountDataCoordinator = hass.data[DOMAIN][ - entry.entry_id - ][ACCOUNT_COORDINATOR] + account_coordinator = entry.runtime_data.account entities: list[SensorEntity] = [ ElectricKiwiAccountEntity( @@ -137,9 +137,7 @@ async def async_setup_entry( for description in ACCOUNT_SENSOR_TYPES ] - hop_coordinator: ElectricKiwiHOPDataCoordinator = hass.data[DOMAIN][entry.entry_id][ - HOP_COORDINATOR - ] + hop_coordinator = entry.runtime_data.hop entities.extend( [ ElectricKiwiHOPEntity(hop_coordinator, description) From d9d6308b7817c7881c453b5d1d61d254d79a2b97 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 28 Jan 2025 11:43:59 +0100 Subject: [PATCH 0101/3148] Cleanup use of hass.data in edl21 (#136694) --- homeassistant/components/edl21/sensor.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/edl21/sensor.py b/homeassistant/components/edl21/sensor.py index 4474893d9b6..62d06a8a535 100644 --- a/homeassistant/components/edl21/sensor.py +++ b/homeassistant/components/edl21/sensor.py @@ -292,8 +292,8 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the EDL21 sensor.""" - hass.data[DOMAIN] = EDL21(hass, config_entry.data, async_add_entities) - await hass.data[DOMAIN].connect() + api = EDL21(hass, config_entry.data, async_add_entities) + await api.connect() class EDL21: From f1305cd5a3b3c15842017d2b19a60277c5d7e6d0 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 28 Jan 2025 11:45:24 +0100 Subject: [PATCH 0102/3148] Improve type hints in econet (#136693) Co-authored-by: Marc Mueller <30130371+cdce8p@users.noreply.github.com> --- .../components/econet/binary_sensor.py | 4 +-- homeassistant/components/econet/climate.py | 28 +++++++++++-------- homeassistant/components/econet/entity.py | 12 ++++---- homeassistant/components/econet/switch.py | 6 ++-- .../components/econet/water_heater.py | 14 +++++----- 5 files changed, 35 insertions(+), 29 deletions(-) diff --git a/homeassistant/components/econet/binary_sensor.py b/homeassistant/components/econet/binary_sensor.py index d66a8536bd0..13ef8c4713b 100644 --- a/homeassistant/components/econet/binary_sensor.py +++ b/homeassistant/components/econet/binary_sensor.py @@ -2,7 +2,7 @@ from __future__ import annotations -from pyeconet.equipment import EquipmentType +from pyeconet.equipment import Equipment, EquipmentType from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, @@ -63,7 +63,7 @@ class EcoNetBinarySensor(EcoNetEntity, BinarySensorEntity): """Define a Econet binary sensor.""" def __init__( - self, econet_device, description: BinarySensorEntityDescription + self, econet_device: Equipment, description: BinarySensorEntityDescription ) -> None: """Initialize.""" super().__init__(econet_device) diff --git a/homeassistant/components/econet/climate.py b/homeassistant/components/econet/climate.py index 1ebb7e483d4..d46dbd8750a 100644 --- a/homeassistant/components/econet/climate.py +++ b/homeassistant/components/econet/climate.py @@ -3,7 +3,11 @@ from typing import Any from pyeconet.equipment import EquipmentType -from pyeconet.equipment.thermostat import ThermostatFanMode, ThermostatOperationMode +from pyeconet.equipment.thermostat import ( + Thermostat, + ThermostatFanMode, + ThermostatOperationMode, +) from homeassistant.components.climate import ( ATTR_TARGET_TEMP_HIGH, @@ -65,13 +69,13 @@ async def async_setup_entry( ) -class EcoNetThermostat(EcoNetEntity, ClimateEntity): +class EcoNetThermostat(EcoNetEntity[Thermostat], ClimateEntity): """Define an Econet thermostat.""" _attr_should_poll = True _attr_temperature_unit = UnitOfTemperature.FAHRENHEIT - def __init__(self, thermostat): + def __init__(self, thermostat: Thermostat) -> None: """Initialize.""" super().__init__(thermostat) self._attr_hvac_modes = [] @@ -92,24 +96,24 @@ class EcoNetThermostat(EcoNetEntity, ClimateEntity): ) @property - def current_temperature(self): + def current_temperature(self) -> int: """Return the current temperature.""" return self._econet.set_point @property - def current_humidity(self): + def current_humidity(self) -> int: """Return the current humidity.""" return self._econet.humidity @property - def target_humidity(self): + def target_humidity(self) -> int | None: """Return the humidity we try to reach.""" if self._econet.supports_humidifier: return self._econet.dehumidifier_set_point return None @property - def target_temperature(self): + def target_temperature(self) -> int | None: """Return the temperature we try to reach.""" if self.hvac_mode == HVACMode.COOL: return self._econet.cool_set_point @@ -118,14 +122,14 @@ class EcoNetThermostat(EcoNetEntity, ClimateEntity): return None @property - def target_temperature_low(self): + def target_temperature_low(self) -> int | None: """Return the lower bound temperature we try to reach.""" if self.hvac_mode == HVACMode.HEAT_COOL: return self._econet.heat_set_point return None @property - def target_temperature_high(self): + def target_temperature_high(self) -> int | None: """Return the higher bound temperature we try to reach.""" if self.hvac_mode == HVACMode.HEAT_COOL: return self._econet.cool_set_point @@ -142,7 +146,7 @@ class EcoNetThermostat(EcoNetEntity, ClimateEntity): self._econet.set_set_point(None, target_temp_high, target_temp_low) @property - def is_aux_heat(self): + def is_aux_heat(self) -> bool: """Return true if aux heater.""" return self._econet.mode == ThermostatOperationMode.EMERGENCY_HEAT @@ -171,7 +175,7 @@ class EcoNetThermostat(EcoNetEntity, ClimateEntity): self._econet.set_dehumidifier_set_point(humidity) @property - def fan_mode(self): + def fan_mode(self) -> str: """Return the current fan mode.""" econet_fan_mode = self._econet.fan_mode @@ -185,7 +189,7 @@ class EcoNetThermostat(EcoNetEntity, ClimateEntity): return _current_fan_mode @property - def fan_modes(self): + def fan_modes(self) -> list[str]: """Return the fan modes.""" return [ ECONET_FAN_STATE_TO_HA[mode] diff --git a/homeassistant/components/econet/entity.py b/homeassistant/components/econet/entity.py index 44488f0b133..2ec8af83dd0 100644 --- a/homeassistant/components/econet/entity.py +++ b/homeassistant/components/econet/entity.py @@ -1,5 +1,7 @@ """Support for EcoNet products.""" +from pyeconet.equipment import Equipment + from homeassistant.core import callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -8,18 +10,18 @@ from homeassistant.helpers.entity import Entity from .const import DOMAIN, PUSH_UPDATE -class EcoNetEntity(Entity): +class EcoNetEntity[_EquipmentT: Equipment = Equipment](Entity): """Define a base EcoNet entity.""" _attr_should_poll = False - def __init__(self, econet): + def __init__(self, econet: _EquipmentT) -> None: """Initialize.""" self._econet = econet self._attr_name = econet.device_name self._attr_unique_id = f"{econet.device_id}_{econet.device_name}" - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Subscribe to device events.""" await super().async_added_to_hass() self.async_on_remove( @@ -27,12 +29,12 @@ class EcoNetEntity(Entity): ) @callback - def on_update_received(self): + def on_update_received(self) -> None: """Update was pushed from the ecoent API.""" self.async_write_ha_state() @property - def available(self): + def available(self) -> bool: """Return if the device is online or not.""" return self._econet.connected diff --git a/homeassistant/components/econet/switch.py b/homeassistant/components/econet/switch.py index 283256f25e3..9fcd38c860e 100644 --- a/homeassistant/components/econet/switch.py +++ b/homeassistant/components/econet/switch.py @@ -6,7 +6,7 @@ import logging from typing import Any from pyeconet.equipment import EquipmentType -from pyeconet.equipment.thermostat import ThermostatOperationMode +from pyeconet.equipment.thermostat import Thermostat, ThermostatOperationMode from homeassistant.components.switch import SwitchEntity from homeassistant.core import HomeAssistant @@ -31,10 +31,10 @@ async def async_setup_entry( ) -class EcoNetSwitchAuxHeatOnly(EcoNetEntity, SwitchEntity): +class EcoNetSwitchAuxHeatOnly(EcoNetEntity[Thermostat], SwitchEntity): """Representation of a aux_heat_only EcoNet switch.""" - def __init__(self, thermostat) -> None: + def __init__(self, thermostat: Thermostat) -> None: """Initialize EcoNet ventilator platform.""" super().__init__(thermostat) self._attr_name = f"{thermostat.device_name} emergency heat" diff --git a/homeassistant/components/econet/water_heater.py b/homeassistant/components/econet/water_heater.py index fc3fe5e4bdf..cfbff70b580 100644 --- a/homeassistant/components/econet/water_heater.py +++ b/homeassistant/components/econet/water_heater.py @@ -5,7 +5,7 @@ import logging from typing import Any from pyeconet.equipment import EquipmentType -from pyeconet.equipment.water_heater import WaterHeaterOperationMode +from pyeconet.equipment.water_heater import WaterHeater, WaterHeaterOperationMode from homeassistant.components.water_heater import ( STATE_ECO, @@ -61,24 +61,24 @@ async def async_setup_entry( ) -class EcoNetWaterHeater(EcoNetEntity, WaterHeaterEntity): +class EcoNetWaterHeater(EcoNetEntity[WaterHeater], WaterHeaterEntity): """Define an Econet water heater.""" _attr_should_poll = True # Override False default from EcoNetEntity _attr_temperature_unit = UnitOfTemperature.FAHRENHEIT - def __init__(self, water_heater): + def __init__(self, water_heater: WaterHeater) -> None: """Initialize.""" super().__init__(water_heater) self.water_heater = water_heater @property - def is_away_mode_on(self): + def is_away_mode_on(self) -> bool: """Return true if away mode is on.""" return self._econet.away @property - def current_operation(self): + def current_operation(self) -> str: """Return current operation.""" econet_mode = self.water_heater.mode _current_op = STATE_OFF @@ -88,7 +88,7 @@ class EcoNetWaterHeater(EcoNetEntity, WaterHeaterEntity): return _current_op @property - def operation_list(self): + def operation_list(self) -> list[str]: """List of available operation modes.""" econet_modes = self.water_heater.modes op_list = [] @@ -131,7 +131,7 @@ class EcoNetWaterHeater(EcoNetEntity, WaterHeaterEntity): _LOGGER.error("Invalid operation mode: %s", operation_mode) @property - def target_temperature(self): + def target_temperature(self) -> int: """Return the temperature we try to reach.""" return self.water_heater.set_point From 7fc5a2294d94bd12f4561c56efa593459ad7b154 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 28 Jan 2025 11:50:11 +0100 Subject: [PATCH 0103/3148] Use runtime_data in evil_genius_labs (#136704) --- .../components/evil_genius_labs/__init__.py | 18 ++++++------------ .../components/evil_genius_labs/coordinator.py | 15 ++++++++++++--- .../components/evil_genius_labs/diagnostics.py | 8 +++----- .../components/evil_genius_labs/light.py | 9 +++------ 4 files changed, 24 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/evil_genius_labs/__init__.py b/homeassistant/components/evil_genius_labs/__init__.py index d5bc3a564a2..7fb7430a044 100644 --- a/homeassistant/components/evil_genius_labs/__init__.py +++ b/homeassistant/components/evil_genius_labs/__init__.py @@ -4,38 +4,32 @@ from __future__ import annotations import pyevilgenius -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import aiohttp_client -from .const import DOMAIN -from .coordinator import EvilGeniusUpdateCoordinator +from .coordinator import EvilGeniusConfigEntry, EvilGeniusUpdateCoordinator PLATFORMS = [Platform.LIGHT] UPDATE_INTERVAL = 10 -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: EvilGeniusConfigEntry) -> bool: """Set up Evil Genius Labs from a config entry.""" coordinator = EvilGeniusUpdateCoordinator( hass, - entry.title, + entry, pyevilgenius.EvilGeniusDevice( entry.data["host"], aiohttp_client.async_get_clientsession(hass) ), ) await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: EvilGeniusConfigEntry) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/evil_genius_labs/coordinator.py b/homeassistant/components/evil_genius_labs/coordinator.py index 9f0f0df02af..202dcaf6ba7 100644 --- a/homeassistant/components/evil_genius_labs/coordinator.py +++ b/homeassistant/components/evil_genius_labs/coordinator.py @@ -10,11 +10,16 @@ from typing import cast from aiohttp import ContentTypeError import pyevilgenius +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator UPDATE_INTERVAL = 10 +_LOGGER = logging.getLogger(__name__) + +type EvilGeniusConfigEntry = ConfigEntry[EvilGeniusUpdateCoordinator] + class EvilGeniusUpdateCoordinator(DataUpdateCoordinator[dict]): """Update coordinator for Evil Genius data.""" @@ -24,14 +29,18 @@ class EvilGeniusUpdateCoordinator(DataUpdateCoordinator[dict]): product: dict | None def __init__( - self, hass: HomeAssistant, name: str, client: pyevilgenius.EvilGeniusDevice + self, + hass: HomeAssistant, + entry: EvilGeniusConfigEntry, + client: pyevilgenius.EvilGeniusDevice, ) -> None: """Initialize the data update coordinator.""" self.client = client super().__init__( hass, - logging.getLogger(__name__), - name=name, + _LOGGER, + config_entry=entry, + name=entry.title, update_interval=timedelta(seconds=UPDATE_INTERVAL), ) diff --git a/homeassistant/components/evil_genius_labs/diagnostics.py b/homeassistant/components/evil_genius_labs/diagnostics.py index c9c79acc1bb..371e0c85b35 100644 --- a/homeassistant/components/evil_genius_labs/diagnostics.py +++ b/homeassistant/components/evil_genius_labs/diagnostics.py @@ -5,20 +5,18 @@ from __future__ import annotations from typing import Any from homeassistant.components.diagnostics import async_redact_data -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from .const import DOMAIN -from .coordinator import EvilGeniusUpdateCoordinator +from .coordinator import EvilGeniusConfigEntry TO_REDACT = {"wiFiSsidDefault", "wiFiSSID"} async def async_get_config_entry_diagnostics( - hass: HomeAssistant, config_entry: ConfigEntry + hass: HomeAssistant, config_entry: EvilGeniusConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - coordinator: EvilGeniusUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data return { "info": async_redact_data(coordinator.info, TO_REDACT), diff --git a/homeassistant/components/evil_genius_labs/light.py b/homeassistant/components/evil_genius_labs/light.py index 3556672dcce..a6d1d9531b5 100644 --- a/homeassistant/components/evil_genius_labs/light.py +++ b/homeassistant/components/evil_genius_labs/light.py @@ -7,12 +7,10 @@ from typing import Any, cast from homeassistant.components import light from homeassistant.components.light import ColorMode, LightEntity, LightEntityFeature -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN -from .coordinator import EvilGeniusUpdateCoordinator +from .coordinator import EvilGeniusConfigEntry, EvilGeniusUpdateCoordinator from .entity import EvilGeniusEntity from .util import update_when_done @@ -22,12 +20,11 @@ FIB_NO_EFFECT = "Solid Color" async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: EvilGeniusConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Evil Genius light platform.""" - coordinator: EvilGeniusUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] - async_add_entities([EvilGeniusLight(coordinator)]) + async_add_entities([EvilGeniusLight(config_entry.runtime_data)]) class EvilGeniusLight(EvilGeniusEntity, LightEntity): From 8b738c919c7c43322b68477fcd5fd2cbfc4b6979 Mon Sep 17 00:00:00 2001 From: Christopher Fenner <9592452+CFenner@users.noreply.github.com> Date: Tue, 28 Jan 2025 11:51:07 +0100 Subject: [PATCH 0104/3148] Correct labels in EnOcean config flow (#136338) --- homeassistant/components/enocean/__init__.py | 2 +- .../components/enocean/config_flow.py | 27 ++++++++++++++----- homeassistant/components/enocean/const.py | 2 +- homeassistant/components/enocean/dongle.py | 6 ++--- homeassistant/components/enocean/strings.json | 22 ++++++++++++--- tests/components/enocean/test_config_flow.py | 6 ++--- 6 files changed, 47 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/enocean/__init__.py b/homeassistant/components/enocean/__init__.py index 6dcec5ec218..9f53c79cc5b 100644 --- a/homeassistant/components/enocean/__init__.py +++ b/homeassistant/components/enocean/__init__.py @@ -47,7 +47,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: - """Unload ENOcean config entry.""" + """Unload EnOcean config entry.""" enocean_dongle = hass.data[DATA_ENOCEAN][ENOCEAN_DONGLE] enocean_dongle.unload() diff --git a/homeassistant/components/enocean/config_flow.py b/homeassistant/components/enocean/config_flow.py index 2452d27b168..fd25b0c6ce1 100644 --- a/homeassistant/components/enocean/config_flow.py +++ b/homeassistant/components/enocean/config_flow.py @@ -1,4 +1,4 @@ -"""Config flows for the ENOcean integration.""" +"""Config flows for the EnOcean integration.""" from typing import Any @@ -6,6 +6,11 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_DEVICE +from homeassistant.helpers.selector import ( + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, +) from . import dongle from .const import DOMAIN, ERROR_INVALID_DONGLE_PATH, LOGGER @@ -15,7 +20,7 @@ class EnOceanFlowHandler(ConfigFlow, domain=DOMAIN): """Handle the enOcean config flows.""" VERSION = 1 - MANUAL_PATH_VALUE = "Custom path" + MANUAL_PATH_VALUE = "manual" def __init__(self) -> None: """Initialize the EnOcean config flow.""" @@ -52,14 +57,24 @@ class EnOceanFlowHandler(ConfigFlow, domain=DOMAIN): return self.create_enocean_entry(user_input) errors = {CONF_DEVICE: ERROR_INVALID_DONGLE_PATH} - bridges = await self.hass.async_add_executor_job(dongle.detect) - if len(bridges) == 0: + devices = await self.hass.async_add_executor_job(dongle.detect) + if len(devices) == 0: return await self.async_step_manual(user_input) + devices.append(self.MANUAL_PATH_VALUE) - bridges.append(self.MANUAL_PATH_VALUE) return self.async_show_form( step_id="detect", - data_schema=vol.Schema({vol.Required(CONF_DEVICE): vol.In(bridges)}), + data_schema=vol.Schema( + { + vol.Required(CONF_DEVICE): SelectSelector( + SelectSelectorConfig( + options=devices, + translation_key="devices", + mode=SelectSelectorMode.LIST, + ) + ) + } + ), errors=errors, ) diff --git a/homeassistant/components/enocean/const.py b/homeassistant/components/enocean/const.py index 3624493b42e..0f3271655d8 100644 --- a/homeassistant/components/enocean/const.py +++ b/homeassistant/components/enocean/const.py @@ -1,4 +1,4 @@ -"""Constants for the ENOcean integration.""" +"""Constants for the EnOcean integration.""" import logging diff --git a/homeassistant/components/enocean/dongle.py b/homeassistant/components/enocean/dongle.py index 2d9a3f8787e..43214b12064 100644 --- a/homeassistant/components/enocean/dongle.py +++ b/homeassistant/components/enocean/dongle.py @@ -18,7 +18,7 @@ _LOGGER = logging.getLogger(__name__) class EnOceanDongle: """Representation of an EnOcean dongle. - The dongle is responsible for receiving the ENOcean frames, + The dongle is responsible for receiving the EnOcean frames, creating devices if needed, and dispatching messages to platforms. """ @@ -53,7 +53,7 @@ class EnOceanDongle: def callback(self, packet): """Handle EnOcean device's callback. - This is the callback function called by python-enocan whenever there + This is the callback function called by python-enocean whenever there is an incoming packet. """ @@ -63,7 +63,7 @@ class EnOceanDongle: def detect(): - """Return a list of candidate paths for USB ENOcean dongles. + """Return a list of candidate paths for USB EnOcean dongles. This method is currently a bit simplistic, it may need to be improved to support more configurations and OS. diff --git a/homeassistant/components/enocean/strings.json b/homeassistant/components/enocean/strings.json index 1a6f08cbf37..9baf4386eda 100644 --- a/homeassistant/components/enocean/strings.json +++ b/homeassistant/components/enocean/strings.json @@ -1,16 +1,23 @@ { "config": { + "flow_title": "{name}", "step": { "detect": { - "title": "Select the path to your EnOcean dongle", + "description": "Select your EnOcean USB dongle.", "data": { - "path": "USB dongle path" + "device": "USB dongle" + }, + "data_description": { + "device": "Path to your EnOcean USB dongle." } }, "manual": { - "title": "Enter the path to your EnOcean dongle", + "description": "Enter the path to your EnOcean USB dongle.", "data": { - "path": "[%key:component::enocean::config::step::detect::data::path%]" + "device": "[%key:component::enocean::config::step::detect::data::device%]" + }, + "data_description": { + "device": "[%key:component::enocean::config::step::detect::data_description::device%]" } } }, @@ -20,5 +27,12 @@ "abort": { "invalid_dongle_path": "Invalid dongle path" } + }, + "selector": { + "devices": { + "options": { + "manual": "Custom path" + } + } } } diff --git a/tests/components/enocean/test_config_flow.py b/tests/components/enocean/test_config_flow.py index 96c0843906f..fb5b1de19d8 100644 --- a/tests/components/enocean/test_config_flow.py +++ b/tests/components/enocean/test_config_flow.py @@ -32,7 +32,7 @@ async def test_user_flow_cannot_create_multiple_instances(hass: HomeAssistant) - async def test_user_flow_with_detected_dongle(hass: HomeAssistant) -> None: - """Test the user flow with a detected ENOcean dongle.""" + """Test the user flow with a detected EnOcean dongle.""" FAKE_DONGLE_PATH = "/fake/dongle" with patch(DONGLE_DETECT_METHOD, Mock(return_value=[FAKE_DONGLE_PATH])): @@ -42,13 +42,13 @@ async def test_user_flow_with_detected_dongle(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.FORM assert result["step_id"] == "detect" - devices = result["data_schema"].schema.get("device").container + devices = result["data_schema"].schema.get(CONF_DEVICE).config.get("options") assert FAKE_DONGLE_PATH in devices assert EnOceanFlowHandler.MANUAL_PATH_VALUE in devices async def test_user_flow_with_no_detected_dongle(hass: HomeAssistant) -> None: - """Test the user flow with a detected ENOcean dongle.""" + """Test the user flow with a detected EnOcean dongle.""" with patch(DONGLE_DETECT_METHOD, Mock(return_value=[])): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} From cd9abacdb22d3b95267a644f36a21fcb3e9e40e0 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 28 Jan 2025 11:52:10 +0100 Subject: [PATCH 0105/3148] Use runtime_data in eufylife_ble (#136705) --- .../components/eufylife_ble/__init__.py | 19 +++++-------------- .../components/eufylife_ble/models.py | 4 ++++ .../components/eufylife_ble/sensor.py | 8 +++----- 3 files changed, 12 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/eufylife_ble/__init__.py b/homeassistant/components/eufylife_ble/__init__.py index f66cf7df30d..8a58c50c8e4 100644 --- a/homeassistant/components/eufylife_ble/__init__.py +++ b/homeassistant/components/eufylife_ble/__init__.py @@ -6,17 +6,15 @@ from eufylife_ble_client import EufyLifeBLEDevice from homeassistant.components import bluetooth from homeassistant.components.bluetooth.match import ADDRESS, BluetoothCallbackMatcher -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_MODEL, EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.core import Event, HomeAssistant, callback -from .const import DOMAIN -from .models import EufyLifeData +from .models import EufyLifeConfigEntry, EufyLifeData PLATFORMS: list[Platform] = [Platform.SENSOR] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: EufyLifeConfigEntry) -> bool: """Set up EufyLife device from a config entry.""" address = entry.unique_id assert address is not None @@ -45,11 +43,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) ) - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = EufyLifeData( - address, - model, - client, - ) + entry.runtime_data = EufyLifeData(address, model, client) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -63,9 +57,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: EufyLifeConfigEntry) -> 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 + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/eufylife_ble/models.py b/homeassistant/components/eufylife_ble/models.py index eb937fc4f3d..26154a74fac 100644 --- a/homeassistant/components/eufylife_ble/models.py +++ b/homeassistant/components/eufylife_ble/models.py @@ -6,6 +6,10 @@ from dataclasses import dataclass from eufylife_ble_client import EufyLifeBLEDevice +from homeassistant.config_entries import ConfigEntry + +type EufyLifeConfigEntry = ConfigEntry[EufyLifeData] + @dataclass class EufyLifeData: diff --git a/homeassistant/components/eufylife_ble/sensor.py b/homeassistant/components/eufylife_ble/sensor.py index 5e3ae64aabf..d9cef45ce4d 100644 --- a/homeassistant/components/eufylife_ble/sensor.py +++ b/homeassistant/components/eufylife_ble/sensor.py @@ -6,7 +6,6 @@ from typing import Any from eufylife_ble_client import MODEL_TO_NAME -from homeassistant import config_entries from homeassistant.components.bluetooth import async_address_present from homeassistant.components.sensor import ( RestoreSensor, @@ -20,19 +19,18 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM -from .const import DOMAIN -from .models import EufyLifeData +from .models import EufyLifeConfigEntry, EufyLifeData IGNORED_STATES = {STATE_UNAVAILABLE, STATE_UNKNOWN} async def async_setup_entry( hass: HomeAssistant, - entry: config_entries.ConfigEntry, + entry: EufyLifeConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the EufyLife sensors.""" - data: EufyLifeData = hass.data[DOMAIN][entry.entry_id] + data = entry.runtime_data entities = [ EufyLifeWeightSensorEntity(data), From 3ac062453f09f6b87344077d5886867cccdeaee0 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Tue, 28 Jan 2025 02:53:57 -0800 Subject: [PATCH 0106/3148] Update nest config flow to create pub/sub topics (#136609) Co-authored-by: Joost Lekkerkerker --- homeassistant/components/nest/config_flow.py | 137 +++++---- homeassistant/components/nest/strings.json | 31 ++- tests/components/nest/conftest.py | 19 +- tests/components/nest/test_config_flow.py | 278 ++++++++++++++----- 4 files changed, 333 insertions(+), 132 deletions(-) diff --git a/homeassistant/components/nest/config_flow.py b/homeassistant/components/nest/config_flow.py index 274e4c288b4..0b249db7a4b 100644 --- a/homeassistant/components/nest/config_flow.py +++ b/homeassistant/components/nest/config_flow.py @@ -15,6 +15,7 @@ import logging from typing import TYPE_CHECKING, Any from google_nest_sdm.admin_client import ( + DEFAULT_TOPIC_IAM_POLICY, AdminClient, EligibleSubscriptions, EligibleTopics, @@ -25,6 +26,11 @@ import voluptuous as vol from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult from homeassistant.helpers import config_entry_oauth2_flow +from homeassistant.helpers.selector import ( + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, +) from homeassistant.util import get_random_string from . import api @@ -41,8 +47,9 @@ from .const import ( ) DATA_FLOW_IMPL = "nest_flow_implementation" +TOPIC_FORMAT = "projects/{cloud_project_id}/topics/home-assistant-{rnd}" SUBSCRIPTION_FORMAT = "projects/{cloud_project_id}/subscriptions/home-assistant-{rnd}" -SUBSCRIPTION_RAND_LENGTH = 10 +RAND_LENGTH = 10 MORE_INFO_URL = "https://www.home-assistant.io/integrations/nest/#configuration" @@ -59,6 +66,7 @@ DEVICE_ACCESS_CONSOLE_URL = "https://console.nest.google.com/device-access/" DEVICE_ACCESS_CONSOLE_EDIT_URL = ( "https://console.nest.google.com/device-access/project/{project_id}/information" ) +CREATE_NEW_TOPIC_KEY = "create_new_topic" CREATE_NEW_SUBSCRIPTION_KEY = "create_new_subscription" _LOGGER = logging.getLogger(__name__) @@ -66,10 +74,16 @@ _LOGGER = logging.getLogger(__name__) def _generate_subscription_id(cloud_project_id: str) -> str: """Create a new subscription id.""" - rnd = get_random_string(SUBSCRIPTION_RAND_LENGTH) + rnd = get_random_string(RAND_LENGTH) return SUBSCRIPTION_FORMAT.format(cloud_project_id=cloud_project_id, rnd=rnd) +def _generate_topic_id(cloud_project_id: str) -> str: + """Create a new topic id.""" + rnd = get_random_string(RAND_LENGTH) + return TOPIC_FORMAT.format(cloud_project_id=cloud_project_id, rnd=rnd) + + def generate_config_title(structures: Iterable[Structure]) -> str | None: """Pick a user friendly config title based on the Google Home name(s).""" names: list[str] = [ @@ -130,7 +144,7 @@ class NestFlowHandler( if self.source == SOURCE_REAUTH: _LOGGER.debug("Skipping Pub/Sub configuration") return await self._async_finish() - return await self.async_step_pubsub() + return await self.async_step_pubsub_topic() async def async_step_reauth( self, entry_data: Mapping[str, Any] @@ -192,7 +206,9 @@ class NestFlowHandler( ) -> ConfigFlowResult: """Handle cloud project in user input.""" if user_input is not None: - self._data.update(user_input) + self._data[CONF_CLOUD_PROJECT_ID] = user_input[ + CONF_CLOUD_PROJECT_ID + ].strip() return await self.async_step_device_project() return self.async_show_form( step_id="cloud_project", @@ -213,7 +229,7 @@ class NestFlowHandler( """Collect device access project from user input.""" errors = {} if user_input is not None: - project_id = user_input[CONF_PROJECT_ID] + project_id = user_input[CONF_PROJECT_ID].strip() if project_id == self._data[CONF_CLOUD_PROJECT_ID]: _LOGGER.error( "Device Access Project ID and Cloud Project ID must not be the" @@ -240,72 +256,83 @@ class NestFlowHandler( errors=errors, ) - async def async_step_pubsub( + async def async_step_pubsub_topic( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Configure and the pre-requisites to configure Pub/Sub topics and subscriptions.""" - data = { - **self._data, - **(user_input if user_input is not None else {}), - } - cloud_project_id = data.get(CONF_CLOUD_PROJECT_ID, "").strip() - device_access_project_id = data[CONF_PROJECT_ID] - - errors: dict[str, str] = {} - if cloud_project_id: + """Configure and create Pub/Sub topic.""" + cloud_project_id = self._data[CONF_CLOUD_PROJECT_ID] + if self._admin_client is None: access_token = self._data["token"]["access_token"] self._admin_client = api.new_pubsub_admin_client( - self.hass, access_token=access_token, cloud_project_id=cloud_project_id + self.hass, + access_token=access_token, + cloud_project_id=cloud_project_id, ) - try: - eligible_topics = await self._admin_client.list_eligible_topics( - device_access_project_id=device_access_project_id - ) - except ApiException as err: - _LOGGER.error("Error listing eligible Pub/Sub topics: %s", err) - errors["base"] = "pubsub_api_error" - else: - if not eligible_topics.topic_names: - errors["base"] = "no_pubsub_topics" + errors = {} + if user_input is not None: + topic_name = user_input[CONF_TOPIC_NAME] + if topic_name == CREATE_NEW_TOPIC_KEY: + topic_name = _generate_topic_id(cloud_project_id) + _LOGGER.debug("Creating topic %s", topic_name) + try: + await self._admin_client.create_topic(topic_name) + await self._admin_client.set_topic_iam_policy( + topic_name, DEFAULT_TOPIC_IAM_POLICY + ) + except ApiException as err: + _LOGGER.error("Error creating Pub/Sub topic: %s", err) + errors["base"] = "pubsub_api_error" if not errors: - self._data[CONF_CLOUD_PROJECT_ID] = cloud_project_id - self._eligible_topics = eligible_topics - return await self.async_step_pubsub_topic() + self._data[CONF_TOPIC_NAME] = topic_name + return await self.async_step_pubsub_topic_confirm() + device_access_project_id = self._data[CONF_PROJECT_ID] + try: + eligible_topics = await self._admin_client.list_eligible_topics( + device_access_project_id=device_access_project_id + ) + except ApiException as err: + _LOGGER.error("Error listing eligible Pub/Sub topics: %s", err) + return self.async_abort(reason="pubsub_api_error") + topics = [ + *eligible_topics.topic_names, # Untranslated topic paths + CREATE_NEW_TOPIC_KEY, + ] return self.async_show_form( - step_id="pubsub", + step_id="pubsub_topic", data_schema=vol.Schema( { - vol.Required(CONF_CLOUD_PROJECT_ID, default=cloud_project_id): str, + vol.Required( + CONF_TOPIC_NAME, default=next(iter(topics)) + ): SelectSelector( + SelectSelectorConfig( + translation_key="topic_name", + mode=SelectSelectorMode.LIST, + options=topics, + ) + ) } ), description_placeholders={ - "url": CLOUD_CONSOLE_URL, "device_access_console_url": DEVICE_ACCESS_CONSOLE_URL, "more_info_url": MORE_INFO_URL, }, errors=errors, ) - async def async_step_pubsub_topic( - self, user_input: dict[str, Any] | None = None + async def async_step_pubsub_topic_confirm( + self, user_input: dict | None = None ) -> ConfigFlowResult: - """Configure and create Pub/Sub topic.""" - if TYPE_CHECKING: - assert self._eligible_topics + """Have the user confirm the Pub/Sub topic is set correctly in Device Access Console.""" if user_input is not None: - self._data.update(user_input) return await self.async_step_pubsub_subscription() - topics = list(self._eligible_topics.topic_names) return self.async_show_form( - step_id="pubsub_topic", - data_schema=vol.Schema( - { - vol.Optional(CONF_TOPIC_NAME, default=topics[0]): vol.In(topics), - } - ), + step_id="pubsub_topic_confirm", description_placeholders={ - "device_access_console_url": DEVICE_ACCESS_CONSOLE_URL, + "device_access_console_url": DEVICE_ACCESS_CONSOLE_EDIT_URL.format( + project_id=self._data[CONF_PROJECT_ID] + ), + "topic_name": self._data[CONF_TOPIC_NAME], "more_info_url": MORE_INFO_URL, }, ) @@ -362,7 +389,7 @@ class NestFlowHandler( ) return await self._async_finish() - subscriptions = {} + subscriptions = [] try: eligible_subscriptions = ( await self._admin_client.list_eligible_subscriptions( @@ -375,10 +402,8 @@ class NestFlowHandler( ) errors["base"] = "pubsub_api_error" else: - subscriptions.update( - {name: name for name in eligible_subscriptions.subscription_names} - ) - subscriptions[CREATE_NEW_SUBSCRIPTION_KEY] = "Create New" + subscriptions.extend(eligible_subscriptions.subscription_names) + subscriptions.append(CREATE_NEW_SUBSCRIPTION_KEY) return self.async_show_form( step_id="pubsub_subscription", data_schema=vol.Schema( @@ -386,7 +411,13 @@ class NestFlowHandler( vol.Optional( CONF_SUBSCRIPTION_NAME, default=next(iter(subscriptions)), - ): vol.In(subscriptions), + ): SelectSelector( + SelectSelectorConfig( + translation_key="subscription_name", + mode=SelectSelectorMode.LIST, + options=subscriptions, + ) + ) } ), description_placeholders={ diff --git a/homeassistant/components/nest/strings.json b/homeassistant/components/nest/strings.json index a31a2856544..23da524ab7e 100644 --- a/homeassistant/components/nest/strings.json +++ b/homeassistant/components/nest/strings.json @@ -17,7 +17,7 @@ }, "device_project": { "title": "Nest: Create a Device Access Project", - "description": "Create a Nest Device Access project which **requires paying Google a US $5 fee** to set up.\n1. Go to the [Device Access Console]({device_access_console_url}), and through the payment flow.\n1. Select on **Create project**\n1. Give your Device Access project a name and select **Next**.\n1. Enter your OAuth Client ID\n1. Enable events by clicking **Enable** and **Create project**.\n\nEnter your Device Access Project ID below ([more info]({more_info_url})).", + "description": "Create a Nest Device Access project which **requires paying Google a US $5 fee** to set up.\n1. Go to the [Device Access Console]({device_access_console_url}), and through the payment flow.\n1. Select on **Create project**\n1. Give your Device Access project a name and select **Next**.\n1. Enter your OAuth Client ID\n1. Skip enabling events for now and select **Create project**.\n\nEnter your Device Access Project ID below ([more info]({more_info_url})).", "data": { "project_id": "Device Access Project ID" } @@ -25,20 +25,18 @@ "pick_implementation": { "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" }, - "pubsub": { - "title": "Configure Google Cloud Pub/Sub", - "description": "Home Assistant uses Cloud Pub/Sub receive realtime Nest device updates. Nest servers publish updates to a Pub/Sub topic and Home Assistant receives the updates through a Pub/Sub subscription.\n\n1. Visit the [Device Access Console]({device_access_console_url}) and ensure a Pub/Sub topic is configured.\n2. Visit the [Cloud Console]({url}) to find your Google Cloud Project ID and confirm it is correct below.\n3. The next step will attempt to auto-discover Pub/Sub topics and subscriptions.\n\nSee the integration documentation for [more info]({more_info_url}).", - "data": { - "cloud_project_id": "[%key:component::nest::config::step::cloud_project::data::cloud_project_id%]" - } - }, "pubsub_topic": { "title": "Configure Cloud Pub/Sub topic", - "description": "Nest devices publish updates on a Cloud Pub/Sub topic. Select the Pub/Sub topic below that is the same as the [Device Access Console]({device_access_console_url}). See the integration documentation for [more info]({more_info_url}).", + "description": "Nest devices publish updates on a Cloud Pub/Sub topic. You can select an existing topic if one exists, or choose to create a new topic and the next step will create it for you with the necessary permissions. See the integration documentation for [more info]({more_info_url}).", "data": { "topic_name": "Pub/Sub topic Name" } }, + "pubsub_topic_confirm": { + "title": "Enable events", + "description": "The Nest Device Access Console needs to be configured to publish device events to your Pub/Sub topic.\n\n1. Visit the [Device Access Console]({device_access_console_url}).\n2. Open the project.\n3. Enable *Events* and set the Pub/Sub topic name to `{topic_name}`\n4. Click *Add & Validate* to verify the topic is configured correctly.\n\nSee the integration documentation for [more info]({more_info_url}).", + "submit": "Confirm" + }, "pubsub_subscription": { "title": "Configure Cloud Pub/Sub subscription", "description": "Home Assistant receives realtime Nest device updates with a Cloud Pub/Sub subscription for topic `{topic}`.\n\nSelect an existing subscription below if one already exists, or the next step will create a new one for you. See the integration documentation for [more info]({more_info_url}).", @@ -70,7 +68,8 @@ "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", - "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]" + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]", + "pubsub_api_error": "[%key:component::nest::config::error::pubsub_api_error%]" }, "create_entry": { "default": "[%key:common::config_flow::create_entry::authenticated%]" @@ -109,5 +108,17 @@ } } } + }, + "selector": { + "topic_name": { + "options": { + "create_new_topic": "Create new topic" + } + }, + "subscription_name": { + "options": { + "create_new_subscription": "Create new subscription" + } + } } } diff --git a/tests/components/nest/conftest.py b/tests/components/nest/conftest.py index b5e3cd2b91c..92d90a18a7e 100644 --- a/tests/components/nest/conftest.py +++ b/tests/components/nest/conftest.py @@ -74,20 +74,25 @@ class FakeAuth: self.json = None self.headers = None self.captured_requests = [] + self._project_id = project_id + self._aioclient_mock = aioclient_mock + self.register_mock_requests() + def register_mock_requests(self) -> None: + """Register the mocks.""" # API makes a call to request structures to initiate pubsub feed, but the # integration does not use this. - aioclient_mock.get( - f"{API_URL}/enterprises/{project_id}/structures", + self._aioclient_mock.get( + f"{API_URL}/enterprises/{self._project_id}/structures", side_effect=self.request_structures, ) - aioclient_mock.get( - f"{API_URL}/enterprises/{project_id}/devices", + self._aioclient_mock.get( + f"{API_URL}/enterprises/{self._project_id}/devices", side_effect=self.request_devices, ) - aioclient_mock.post(DEVICE_URL_MATCH, side_effect=self.request) - aioclient_mock.get(TEST_IMAGE_URL, side_effect=self.request) - aioclient_mock.get(TEST_CLIP_URL, side_effect=self.request) + self._aioclient_mock.post(DEVICE_URL_MATCH, side_effect=self.request) + self._aioclient_mock.get(TEST_IMAGE_URL, side_effect=self.request) + self._aioclient_mock.get(TEST_CLIP_URL, side_effect=self.request) async def request_structures( self, method: str, url: str, data: dict[str, Any] diff --git a/tests/components/nest/test_config_flow.py b/tests/components/nest/test_config_flow.py index f08eeb82a1d..0e6ec290841 100644 --- a/tests/components/nest/test_config_flow.py +++ b/tests/components/nest/test_config_flow.py @@ -34,7 +34,7 @@ from tests.typing import ClientSessionGenerator WEB_REDIRECT_URL = "https://example.com/auth/external/callback" APP_REDIRECT_URL = "urn:ietf:wg:oauth:2.0:oob" -RAND_SUBSCRIBER_SUFFIX = "ABCDEF" +RAND_SUFFIX = "ABCDEF" FAKE_DHCP_DATA = DhcpServiceInfo( ip="127.0.0.2", macaddress="001122334455", hostname="fake_hostname" @@ -52,7 +52,7 @@ def mock_rand_topic_name_fixture() -> None: """Set the topic name random string to a constant.""" with patch( "homeassistant.components.nest.config_flow.get_random_string", - return_value=RAND_SUBSCRIBER_SUFFIX, + return_value=RAND_SUFFIX, ): yield @@ -173,6 +173,7 @@ class OAuthFixture: selected_topic: str, selected_subscription: str = "create_new_subscription", user_input: dict | None = None, + existing_errors: dict | None = None, ) -> ConfigEntry: """Fixture to walk through the Pub/Sub topic and subscription steps. @@ -193,6 +194,12 @@ class OAuthFixture: }, ) assert result.get("type") is FlowResultType.FORM + assert result.get("step_id") == "pubsub_topic_confirm" + assert not result.get("errors") + + # ACK the topic selection. User is instructed to do some manual + result = await self.async_configure(result, {}) + assert result.get("type") is FlowResultType.FORM assert result.get("step_id") == "pubsub_subscription" assert not result.get("errors") @@ -267,6 +274,12 @@ def mock_cloud_project_id() -> str: return CLOUD_PROJECT_ID +@pytest.fixture(name="create_topic_status") +def mock_create_topic_status() -> str: + """Fixture to configure the return code when creating the topic.""" + return HTTPStatus.OK + + @pytest.fixture(name="create_subscription_status") def mock_create_subscription_status() -> str: """Fixture to configure the return code when creating the subscription.""" @@ -285,6 +298,64 @@ def mock_list_subscriptions_status() -> str: return HTTPStatus.OK +def setup_mock_list_subscriptions_responses( + aioclient_mock: AiohttpClientMocker, + cloud_project_id: str, + subscriptions: list[tuple[str, str]], + list_subscriptions_status: HTTPStatus = HTTPStatus.OK, +) -> None: + """Configure the mock responses for listing Pub/Sub subscriptions.""" + aioclient_mock.get( + f"https://pubsub.googleapis.com/v1/projects/{cloud_project_id}/subscriptions", + json={ + "subscriptions": [ + { + "name": subscription_name, + "topic": topic, + "pushConfig": {}, + "ackDeadlineSeconds": 10, + "messageRetentionDuration": "604800s", + "expirationPolicy": {"ttl": "2678400s"}, + "state": "ACTIVE", + } + for (subscription_name, topic) in subscriptions or () + ] + }, + status=list_subscriptions_status, + ) + + +def setup_mock_create_topic_responses( + aioclient_mock: AiohttpClientMocker, + cloud_project_id: str, + create_topic_status: HTTPStatus = HTTPStatus.OK, +) -> None: + """Configure the mock responses for creating a Pub/Sub topic.""" + aioclient_mock.put( + f"https://pubsub.googleapis.com/v1/projects/{cloud_project_id}/topics/home-assistant-{RAND_SUFFIX}", + json={}, + status=create_topic_status, + ) + aioclient_mock.post( + f"https://pubsub.googleapis.com/v1/projects/{cloud_project_id}/topics/home-assistant-{RAND_SUFFIX}:setIamPolicy", + json={}, + status=create_topic_status, + ) + + +def setup_mock_create_subscription_responses( + aioclient_mock: AiohttpClientMocker, + cloud_project_id: str, + create_subscription_status: HTTPStatus = HTTPStatus.OK, +) -> None: + """Configure the mock responses for creating a Pub/Sub subscription.""" + aioclient_mock.put( + f"https://pubsub.googleapis.com/v1/projects/{cloud_project_id}/subscriptions/home-assistant-{RAND_SUFFIX}", + json={}, + status=create_subscription_status, + ) + + @pytest.fixture(autouse=True) def mock_pubsub_api_responses( aioclient_mock: AiohttpClientMocker, @@ -293,6 +364,7 @@ def mock_pubsub_api_responses( subscriptions: list[tuple[str, str]], device_access_project_id: str, cloud_project_id: str, + create_topic_status: HTTPStatus, create_subscription_status: HTTPStatus, list_topics_status: HTTPStatus, list_subscriptions_status: HTTPStatus, @@ -320,28 +392,14 @@ def mock_pubsub_api_responses( ) # We check for a topic created by the SDM Device Access Console (but note we don't have permission to read it) # or the user has created one themselves in the Google Cloud Project. - aioclient_mock.get( - f"https://pubsub.googleapis.com/v1/projects/{cloud_project_id}/subscriptions", - json={ - "subscriptions": [ - { - "name": subscription_name, - "topic": topic, - "pushConfig": {}, - "ackDeadlineSeconds": 10, - "messageRetentionDuration": "604800s", - "expirationPolicy": {"ttl": "2678400s"}, - "state": "ACTIVE", - } - for (subscription_name, topic) in subscriptions or () - ] - }, - status=list_subscriptions_status, + setup_mock_list_subscriptions_responses( + aioclient_mock, cloud_project_id, subscriptions, list_subscriptions_status ) - aioclient_mock.put( - f"https://pubsub.googleapis.com/v1/projects/{cloud_project_id}/subscriptions/home-assistant-{RAND_SUBSCRIBER_SUFFIX}", - json={}, - status=create_subscription_status, + setup_mock_create_topic_responses( + aioclient_mock, cloud_project_id, create_topic_status + ) + setup_mock_create_subscription_responses( + aioclient_mock, cloud_project_id, create_subscription_status ) @@ -371,7 +429,7 @@ async def test_app_credentials( "auth_implementation": "imported-cred", "cloud_project_id": CLOUD_PROJECT_ID, "project_id": PROJECT_ID, - "subscription_name": f"projects/{CLOUD_PROJECT_ID}/subscriptions/home-assistant-{RAND_SUBSCRIBER_SUFFIX}", + "subscription_name": f"projects/{CLOUD_PROJECT_ID}/subscriptions/home-assistant-{RAND_SUFFIX}", "topic_name": f"projects/sdm-prod/topics/enterprise-{PROJECT_ID}", "token": { "refresh_token": "mock-refresh-token", @@ -520,6 +578,11 @@ async def test_config_flow_pubsub_configuration_error( }, ) assert result.get("type") is FlowResultType.FORM + assert result.get("step_id") == "pubsub_topic_confirm" + assert not result.get("errors") + + result = await oauth.async_configure(result, {}) + assert result.get("type") is FlowResultType.FORM assert result.get("step_id") == "pubsub_subscription" assert result.get("data_schema")({}) == { "subscription_name": "create_new_subscription", @@ -565,6 +628,11 @@ async def test_config_flow_pubsub_subscriber_error( }, ) assert result.get("type") is FlowResultType.FORM + assert result.get("step_id") == "pubsub_topic_confirm" + assert not result.get("errors") + + result = await oauth.async_configure(result, {}) + assert result.get("type") is FlowResultType.FORM assert result.get("step_id") == "pubsub_subscription" assert result.get("data_schema")({}) == { "subscription_name": "create_new_subscription", @@ -691,37 +759,6 @@ async def test_reauth_multiple_config_entries( assert entry.data.get("extra_data") -@pytest.mark.parametrize(("sdm_managed_topic"), [(True)]) -async def test_pubsub_subscription_strip_whitespace( - hass: HomeAssistant, - oauth: OAuthFixture, -) -> None: - """Check that project id has whitespace stripped on entry.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - await oauth.async_app_creds_flow( - result, cloud_project_id=" " + CLOUD_PROJECT_ID + " " - ) - oauth.async_mock_refresh() - result = await oauth.async_configure(result, {"code": "1234"}) - entry = await oauth.async_complete_pubsub_flow( - result, selected_topic="projects/sdm-prod/topics/enterprise-some-project-id" - ) - assert entry.title == "Import from configuration.yaml" - assert "token" in entry.data - entry.data["token"].pop("expires_at") - assert entry.unique_id == PROJECT_ID - assert entry.data["token"] == { - "refresh_token": "mock-refresh-token", - "access_token": "mock-access-token", - "type": "Bearer", - "expires_in": 60, - } - assert "subscription_name" in entry.data - assert entry.data["cloud_project_id"] == CLOUD_PROJECT_ID - - @pytest.mark.parametrize( ("sdm_managed_topic", "create_subscription_status"), [(True, HTTPStatus.UNAUTHORIZED)], @@ -751,6 +788,11 @@ async def test_pubsub_subscription_auth_failure( }, ) assert result.get("type") is FlowResultType.FORM + assert result.get("step_id") == "pubsub_topic_confirm" + assert not result.get("errors") + + result = await oauth.async_configure(result, {}) + assert result.get("type") is FlowResultType.FORM assert result.get("step_id") == "pubsub_subscription" assert result.get("data_schema")({}) == { "subscription_name": "create_new_subscription", @@ -833,7 +875,7 @@ async def test_config_entry_title_from_home( assert entry.data.get("cloud_project_id") == CLOUD_PROJECT_ID assert ( entry.data.get("subscription_name") - == f"projects/{CLOUD_PROJECT_ID}/subscriptions/home-assistant-{RAND_SUBSCRIBER_SUFFIX}" + == f"projects/{CLOUD_PROJECT_ID}/subscriptions/home-assistant-{RAND_SUFFIX}" ) assert ( entry.data.get("topic_name") @@ -905,7 +947,7 @@ async def test_title_failure_fallback( assert entry.data.get("cloud_project_id") == CLOUD_PROJECT_ID assert ( entry.data.get("subscription_name") - == f"projects/{CLOUD_PROJECT_ID}/subscriptions/home-assistant-{RAND_SUBSCRIBER_SUFFIX}" + == f"projects/{CLOUD_PROJECT_ID}/subscriptions/home-assistant-{RAND_SUFFIX}" ) assert ( entry.data.get("topic_name") @@ -997,7 +1039,7 @@ async def test_dhcp_discovery_with_creds( "auth_implementation": "imported-cred", "cloud_project_id": CLOUD_PROJECT_ID, "project_id": PROJECT_ID, - "subscription_name": f"projects/{CLOUD_PROJECT_ID}/subscriptions/home-assistant-{RAND_SUBSCRIBER_SUFFIX}", + "subscription_name": f"projects/{CLOUD_PROJECT_ID}/subscriptions/home-assistant-{RAND_SUFFIX}", "topic_name": f"projects/sdm-prod/topics/enterprise-{PROJECT_ID}", "token": { "refresh_token": "mock-refresh-token", @@ -1092,7 +1134,7 @@ async def test_no_eligible_topics( hass: HomeAssistant, oauth: OAuthFixture, ) -> None: - """Test the case where there are no eligible pub/sub topics.""" + """Test the case where there are no eligible pub/sub topics and the topic is created.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -1101,8 +1143,36 @@ async def test_no_eligible_topics( result = await oauth.async_configure(result, None) assert result.get("type") is FlowResultType.FORM - assert result.get("step_id") == "pubsub" - assert result.get("errors") == {"base": "no_pubsub_topics"} + assert result.get("step_id") == "pubsub_topic" + assert not result.get("errors") + # Option shown to create a new topic + assert result.get("data_schema")({}) == { + "topic_name": "create_new_topic", + } + + entry = await oauth.async_complete_pubsub_flow( + result, + selected_topic="create_new_topic", + selected_subscription="create_new_subscription", + ) + + data = dict(entry.data) + assert "token" in data + data["token"].pop("expires_in") + data["token"].pop("expires_at") + assert data == { + "sdm": {}, + "auth_implementation": "imported-cred", + "cloud_project_id": CLOUD_PROJECT_ID, + "project_id": PROJECT_ID, + "subscription_name": f"projects/{CLOUD_PROJECT_ID}/subscriptions/home-assistant-{RAND_SUFFIX}", + "topic_name": f"projects/{CLOUD_PROJECT_ID}/topics/home-assistant-{RAND_SUFFIX}", + "token": { + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + }, + } @pytest.mark.parametrize( @@ -1122,11 +1192,90 @@ async def test_list_topics_failure( await oauth.async_app_creds_flow(result) oauth.async_mock_refresh() + result = await oauth.async_configure(result, None) + assert result.get("type") is FlowResultType.ABORT + assert result.get("reason") == "pubsub_api_error" + + +@pytest.mark.parametrize( + ("create_topic_status"), + [(HTTPStatus.INTERNAL_SERVER_ERROR)], +) +async def test_create_topic_failed( + hass: HomeAssistant, + oauth: OAuthFixture, + aioclient_mock: AiohttpClientMocker, + cloud_project_id: str, + subscriptions: list[tuple[str, str]], + auth: FakeAuth, +) -> None: + """Test the case where there are no eligible pub/sub topics and the topic is created.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + await oauth.async_app_creds_flow(result) + oauth.async_mock_refresh() + result = await oauth.async_configure(result, None) assert result.get("type") is FlowResultType.FORM - assert result.get("step_id") == "pubsub" + assert result.get("step_id") == "pubsub_topic" + assert not result.get("errors") + # Option shown to create a new topic + assert result.get("data_schema")({}) == { + "topic_name": "create_new_topic", + } + + result = await oauth.async_configure(result, {"topic_name": "create_new_topic"}) + assert result.get("type") is FlowResultType.FORM + assert result.get("step_id") == "pubsub_topic" assert result.get("errors") == {"base": "pubsub_api_error"} + # Re-register mock requests needed for the rest of the test. The topic + # request will now succeed. + aioclient_mock.clear_requests() + setup_mock_create_topic_responses(aioclient_mock, cloud_project_id) + # Fix up other mock responses cleared above + auth.register_mock_requests() + setup_mock_list_subscriptions_responses( + aioclient_mock, + cloud_project_id, + subscriptions, + ) + setup_mock_create_subscription_responses(aioclient_mock, cloud_project_id) + + result = await oauth.async_configure(result, {"topic_name": "create_new_topic"}) + assert result.get("type") is FlowResultType.FORM + assert result.get("step_id") == "pubsub_topic_confirm" + assert not result.get("errors") + + result = await oauth.async_configure(result, {}) + assert result.get("type") is FlowResultType.FORM + assert result.get("step_id") == "pubsub_subscription" + assert not result.get("errors") + + # Create a subscription for the topic and end the flow + entry = await oauth.async_finish_setup( + result, + {"subscription_name": "create_new_subscription"}, + ) + data = dict(entry.data) + assert "token" in data + data["token"].pop("expires_in") + data["token"].pop("expires_at") + assert data == { + "sdm": {}, + "auth_implementation": "imported-cred", + "cloud_project_id": CLOUD_PROJECT_ID, + "project_id": PROJECT_ID, + "subscription_name": f"projects/{CLOUD_PROJECT_ID}/subscriptions/home-assistant-{RAND_SUFFIX}", + "topic_name": f"projects/{CLOUD_PROJECT_ID}/topics/home-assistant-{RAND_SUFFIX}", + "token": { + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + }, + } + @pytest.mark.parametrize( ("sdm_managed_topic", "list_subscriptions_status"), @@ -1158,5 +1307,10 @@ async def test_list_subscriptions_failure( }, ) assert result.get("type") is FlowResultType.FORM + assert result.get("step_id") == "pubsub_topic_confirm" + assert not result.get("errors") + + result = await oauth.async_configure(result, {}) + assert result.get("type") is FlowResultType.FORM assert result.get("step_id") == "pubsub_subscription" assert result.get("errors") == {"base": "pubsub_api_error"} From f14f7936eb71f9baa45608a2d84bbc311cf288f4 Mon Sep 17 00:00:00 2001 From: Christopher Fenner <9592452+CFenner@users.noreply.github.com> Date: Tue, 28 Jan 2025 11:55:40 +0100 Subject: [PATCH 0107/3148] Support integrated ventilation on heating devices in ViCare integration (#130356) --- homeassistant/components/vicare/fan.py | 26 ++++++++++++-------- homeassistant/components/vicare/strings.json | 10 +++++--- 2 files changed, 22 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/vicare/fan.py b/homeassistant/components/vicare/fan.py index fc18bdbd8da..190a893157c 100644 --- a/homeassistant/components/vicare/fan.py +++ b/homeassistant/components/vicare/fan.py @@ -13,9 +13,6 @@ from PyViCare.PyViCareUtils import ( PyViCareNotSupportedFeatureError, PyViCareRateLimitError, ) -from PyViCare.PyViCareVentilationDevice import ( - VentilationDevice as PyViCareVentilationDevice, -) from requests.exceptions import ConnectionError as RequestConnectionError from homeassistant.components.fan import FanEntity, FanEntityFeature @@ -50,6 +47,8 @@ class VentilationMode(enum.StrEnum): PERMANENT = "permanent" # on, speed controlled by program (levelOne-levelFour) VENTILATION = "ventilation" # activated by schedule + STANDBY = "standby" # activated by schedule + STANDARD = "standard" # activated by schedule SENSOR_DRIVEN = "sensor_driven" # activated by schedule, override by sensor SENSOR_OVERRIDE = "sensor_override" # activated by sensor @@ -77,6 +76,8 @@ class VentilationMode(enum.StrEnum): HA_TO_VICARE_MODE_VENTILATION = { VentilationMode.PERMANENT: "permanent", VentilationMode.VENTILATION: "ventilation", + VentilationMode.STANDBY: "standby", + VentilationMode.STANDARD: "standard", VentilationMode.SENSOR_DRIVEN: "sensorDriven", VentilationMode.SENSOR_OVERRIDE: "sensorOverride", } @@ -96,7 +97,7 @@ def _build_entities( return [ ViCareFan(get_device_serial(device.api), device.config, device.api) for device in device_list - if isinstance(device.api, PyViCareVentilationDevice) + if device.api.isVentilationDevice() ] @@ -118,7 +119,6 @@ class ViCareFan(ViCareEntity, FanEntity): """Representation of the ViCare ventilation device.""" _attr_speed_count = len(ORDERED_NAMED_FAN_SPEEDS) - _attr_supported_features = FanEntityFeature.SET_SPEED _attr_translation_key = "ventilation" def __init__( @@ -131,8 +131,8 @@ class ViCareFan(ViCareEntity, FanEntity): super().__init__( self._attr_translation_key, device_serial, device_config, device ) - # init presets - supported_modes = list[str](self._api.getAvailableModes()) + # init preset_mode + supported_modes = list[str](self._api.getVentilationModes()) self._attr_preset_modes = [ mode for mode in VentilationMode @@ -140,6 +140,12 @@ class ViCareFan(ViCareEntity, FanEntity): ] if len(self._attr_preset_modes) > 0: self._attr_supported_features |= FanEntityFeature.PRESET_MODE + # init set_speed + supported_levels: list[str] | None = None + with suppress(PyViCareNotSupportedFeatureError): + supported_levels = self._api.getVentilationLevels() + if supported_levels is not None and len(supported_levels) > 0: + self._attr_supported_features |= FanEntityFeature.SET_SPEED def update(self) -> None: """Update state of fan.""" @@ -147,7 +153,7 @@ class ViCareFan(ViCareEntity, FanEntity): try: with suppress(PyViCareNotSupportedFeatureError): self._attr_preset_mode = VentilationMode.from_vicare_mode( - self._api.getActiveMode() + self._api.getActiveVentilationMode() ) with suppress(PyViCareNotSupportedFeatureError): level = filter_state(self._api.getVentilationLevel()) @@ -203,10 +209,10 @@ class ViCareFan(ViCareEntity, FanEntity): level = percentage_to_ordered_list_item(ORDERED_NAMED_FAN_SPEEDS, percentage) _LOGGER.debug("changing ventilation level to %s", level) - self._api.setPermanentLevel(level) + self._api.setVentilationLevel(level) def set_preset_mode(self, preset_mode: str) -> None: """Set new preset mode.""" target_mode = VentilationMode.to_vicare_mode(preset_mode) _LOGGER.debug("changing ventilation mode to %s", target_mode) - self._api.setActiveMode(target_mode) + self._api.activateVentilationMode(target_mode) diff --git a/homeassistant/components/vicare/strings.json b/homeassistant/components/vicare/strings.json index 5ab92880ba0..4eee81f3d05 100644 --- a/homeassistant/components/vicare/strings.json +++ b/homeassistant/components/vicare/strings.json @@ -81,10 +81,12 @@ "state_attributes": { "preset_mode": { "state": { - "permanent": "permanent", - "ventilation": "schedule", - "sensor_driven": "sensor", - "sensor_override": "schedule with sensor-override" + "standby": "[%key:common::state::standby%]", + "permanent": "Permanent", + "ventilation": "Schedule", + "sensor_driven": "Sensor-driven", + "sensor_override": "Schedule with sensor-override", + "standard": "Minimal" } } } From 933aec1027cfcdb7b0b7fd9a8445c2a42973a994 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 28 Jan 2025 11:57:12 +0100 Subject: [PATCH 0108/3148] Use runtime_data in epson (#136706) --- homeassistant/components/epson/__init__.py | 22 +++++++++---------- .../components/epson/media_player.py | 6 ++--- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/epson/__init__.py b/homeassistant/components/epson/__init__.py index 715b55824b4..27dbaa93734 100644 --- a/homeassistant/components/epson/__init__.py +++ b/homeassistant/components/epson/__init__.py @@ -13,13 +13,15 @@ from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import CONF_CONNECTION_TYPE, DOMAIN, HTTP +from .const import CONF_CONNECTION_TYPE, HTTP from .exceptions import CannotConnect, PoweredOff PLATFORMS = [Platform.MEDIA_PLAYER] _LOGGER = logging.getLogger(__name__) +type EpsonConfigEntry = ConfigEntry[Projector] + async def validate_projector( hass: HomeAssistant, @@ -45,7 +47,7 @@ async def validate_projector( return epson_proj -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: EpsonConfigEntry) -> bool: """Set up epson from a config entry.""" projector = await validate_projector( hass=hass, @@ -54,23 +56,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: check_power=False, check_powered_on=False, ) - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = projector + entry.runtime_data = projector await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + entry.async_on_unload(projector.close) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: EpsonConfigEntry) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - projector = hass.data[DOMAIN].pop(entry.entry_id) - projector.close() - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) -async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_migrate_entry( + hass: HomeAssistant, config_entry: EpsonConfigEntry +) -> bool: """Migrate old entry.""" _LOGGER.debug( "Migrating configuration from version %s.%s", diff --git a/homeassistant/components/epson/media_player.py b/homeassistant/components/epson/media_player.py index a901e9df216..e0eac4a1cfb 100644 --- a/homeassistant/components/epson/media_player.py +++ b/homeassistant/components/epson/media_player.py @@ -45,6 +45,7 @@ from homeassistant.helpers import ( from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback +from . import EpsonConfigEntry from .const import ATTR_CMODE, DOMAIN, SERVICE_SELECT_CMODE _LOGGER = logging.getLogger(__name__) @@ -52,13 +53,12 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: EpsonConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Epson projector from a config entry.""" - projector: Projector = hass.data[DOMAIN][config_entry.entry_id] projector_entity = EpsonProjectorMediaPlayer( - projector=projector, + projector=config_entry.runtime_data, unique_id=config_entry.unique_id or config_entry.entry_id, entry=config_entry, ) From 91ff31a3beaad07ca4a8b06ea9e734f23eec0a8e Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 28 Jan 2025 12:01:19 +0100 Subject: [PATCH 0109/3148] Use runtime_data in epion (#136708) --- homeassistant/components/epion/__init__.py | 17 ++++++----------- homeassistant/components/epion/coordinator.py | 8 +++++++- homeassistant/components/epion/sensor.py | 7 +++---- 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/epion/__init__.py b/homeassistant/components/epion/__init__.py index fec975c5098..c04c77f760d 100644 --- a/homeassistant/components/epion/__init__.py +++ b/homeassistant/components/epion/__init__.py @@ -4,30 +4,25 @@ from __future__ import annotations from epion import Epion -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, Platform from homeassistant.core import HomeAssistant -from .const import DOMAIN -from .coordinator import EpionCoordinator +from .coordinator import EpionConfigEntry, EpionCoordinator PLATFORMS = [Platform.SENSOR] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: EpionConfigEntry) -> bool: """Set up the Epion coordinator from a config entry.""" api = Epion(entry.data[CONF_API_KEY]) - coordinator = EpionCoordinator(hass, api) + coordinator = EpionCoordinator(hass, entry, api) await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: EpionConfigEntry) -> bool: """Unload Epion 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 + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/epion/coordinator.py b/homeassistant/components/epion/coordinator.py index 3eb7efb5dc7..9eb31331097 100644 --- a/homeassistant/components/epion/coordinator.py +++ b/homeassistant/components/epion/coordinator.py @@ -5,6 +5,7 @@ from typing import Any from epion import Epion, EpionAuthenticationError, EpionConnectionError +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -13,15 +14,20 @@ from .const import REFRESH_INTERVAL _LOGGER = logging.getLogger(__name__) +type EpionConfigEntry = ConfigEntry[EpionCoordinator] + class EpionCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Epion data update coordinator.""" - def __init__(self, hass: HomeAssistant, epion_api: Epion) -> None: + def __init__( + self, hass: HomeAssistant, entry: EpionConfigEntry, epion_api: Epion + ) -> None: """Initialize the Epion coordinator.""" super().__init__( hass, _LOGGER, + config_entry=entry, name="Epion", update_interval=REFRESH_INTERVAL, ) diff --git a/homeassistant/components/epion/sensor.py b/homeassistant/components/epion/sensor.py index 4717c095bfe..78027813ffa 100644 --- a/homeassistant/components/epion/sensor.py +++ b/homeassistant/components/epion/sensor.py @@ -10,7 +10,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONCENTRATION_PARTS_PER_MILLION, PERCENTAGE, @@ -23,7 +22,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN -from .coordinator import EpionCoordinator +from .coordinator import EpionConfigEntry, EpionCoordinator SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( @@ -59,11 +58,11 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: EpionConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Add an Epion entry.""" - coordinator: EpionCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data entities = [ EpionSensor(coordinator, epion_device_id, description) From 8300fd2de86e1020075db4e730206240c3fa2bbd Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Tue, 28 Jan 2025 12:06:03 +0100 Subject: [PATCH 0110/3148] Introduce `unique_id` to BackupAgent (#136651) * add unique_id to BackupAgent * adjust tests --- homeassistant/components/backup/agent.py | 3 +- homeassistant/components/backup/backup.py | 1 + homeassistant/components/backup/websocket.py | 5 ++- homeassistant/components/cloud/backup.py | 3 +- homeassistant/components/hassio/backup.py | 2 +- .../components/kitchen_sink/backup.py | 2 +- .../components/synology_dsm/backup.py | 5 ++- tests/components/backup/common.py | 1 + .../backup/snapshots/test_backup.ambr | 5 +++ .../backup/snapshots/test_websocket.ambr | 4 +- tests/components/backup/test_manager.py | 8 ++-- tests/components/cloud/test_backup.py | 5 ++- tests/components/hassio/test_backup.py | 23 ++++++---- tests/components/kitchen_sink/test_backup.py | 14 +++++-- tests/components/synology_dsm/test_backup.py | 42 +++++++++++-------- 15 files changed, 81 insertions(+), 42 deletions(-) diff --git a/homeassistant/components/backup/agent.py b/homeassistant/components/backup/agent.py index cb03327e941..fe9eb9ea699 100644 --- a/homeassistant/components/backup/agent.py +++ b/homeassistant/components/backup/agent.py @@ -30,11 +30,12 @@ class BackupAgent(abc.ABC): domain: str name: str + unique_id: str @cached_property def agent_id(self) -> str: """Return the agent_id.""" - return f"{self.domain}.{self.name}" + return f"{self.domain}.{self.unique_id}" @abc.abstractmethod async def async_download_backup( diff --git a/homeassistant/components/backup/backup.py b/homeassistant/components/backup/backup.py index ef4924161c2..3f60bd0b88e 100644 --- a/homeassistant/components/backup/backup.py +++ b/homeassistant/components/backup/backup.py @@ -32,6 +32,7 @@ class CoreLocalBackupAgent(LocalBackupAgent): domain = DOMAIN name = "local" + unique_id = "local" def __init__(self, hass: HomeAssistant) -> None: """Initialize the backup agent.""" diff --git a/homeassistant/components/backup/websocket.py b/homeassistant/components/backup/websocket.py index 70fc568c05c..74f56102670 100644 --- a/homeassistant/components/backup/websocket.py +++ b/homeassistant/components/backup/websocket.py @@ -306,7 +306,10 @@ async def backup_agents_info( connection.send_result( msg["id"], { - "agents": [{"agent_id": agent_id} for agent_id in manager.backup_agents], + "agents": [ + {"agent_id": agent.agent_id, "name": agent.name} + for agent in manager.backup_agents.values() + ], }, ) diff --git a/homeassistant/components/cloud/backup.py b/homeassistant/components/cloud/backup.py index 153d0741770..d42e846259c 100644 --- a/homeassistant/components/cloud/backup.py +++ b/homeassistant/components/cloud/backup.py @@ -82,8 +82,7 @@ def async_register_backup_agents_listener( class CloudBackupAgent(BackupAgent): """Cloud backup agent.""" - domain = DOMAIN - name = DOMAIN + domain = name = unique_id = DOMAIN def __init__(self, hass: HomeAssistant, cloud: Cloud[CloudClient]) -> None: """Initialize the cloud backup sync agent.""" diff --git a/homeassistant/components/hassio/backup.py b/homeassistant/components/hassio/backup.py index d49fafb886f..98ad2ad20e3 100644 --- a/homeassistant/components/hassio/backup.py +++ b/homeassistant/components/hassio/backup.py @@ -133,7 +133,7 @@ class SupervisorBackupAgent(BackupAgent): self._hass = hass self._backup_dir = Path("/backups") self._client = get_supervisor_client(hass) - self.name = name + self.name = self.unique_id = name self.location = location async def async_download_backup( diff --git a/homeassistant/components/kitchen_sink/backup.py b/homeassistant/components/kitchen_sink/backup.py index c4a045aeefc..44ac0456105 100644 --- a/homeassistant/components/kitchen_sink/backup.py +++ b/homeassistant/components/kitchen_sink/backup.py @@ -51,7 +51,7 @@ class KitchenSinkBackupAgent(BackupAgent): def __init__(self, name: str) -> None: """Initialize the kitchen sink backup sync agent.""" super().__init__() - self.name = name + self.name = self.unique_id = name self._uploads = [ AgentBackup( addons=[AddonInfo(name="Test", slug="test", version="1.0.0")], diff --git a/homeassistant/components/synology_dsm/backup.py b/homeassistant/components/synology_dsm/backup.py index eed6af758ba..62a1b97b717 100644 --- a/homeassistant/components/synology_dsm/backup.py +++ b/homeassistant/components/synology_dsm/backup.py @@ -39,7 +39,7 @@ async def async_get_backup_agents( return [] syno_datas: dict[str, SynologyDSMData] = hass.data[DOMAIN] return [ - SynologyDSMBackupAgent(hass, entry) + SynologyDSMBackupAgent(hass, entry, entry.unique_id) for entry in entries if entry.unique_id is not None and (syno_data := syno_datas.get(entry.unique_id)) @@ -76,11 +76,12 @@ class SynologyDSMBackupAgent(BackupAgent): domain = DOMAIN - def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + def __init__(self, hass: HomeAssistant, entry: ConfigEntry, unique_id: str) -> None: """Initialize the Synology DSM backup agent.""" super().__init__() LOGGER.debug("Initializing Synology DSM backup agent for %s", entry.unique_id) self.name = entry.title + self.unique_id = unique_id self.path = ( f"{entry.options[CONF_BACKUP_SHARE]}/{entry.options[CONF_BACKUP_PATH]}" ) diff --git a/tests/components/backup/common.py b/tests/components/backup/common.py index 4f456cc6d72..97236ee995d 100644 --- a/tests/components/backup/common.py +++ b/tests/components/backup/common.py @@ -64,6 +64,7 @@ class BackupAgentTest(BackupAgent): def __init__(self, name: str, backups: list[AgentBackup] | None = None) -> None: """Initialize the backup agent.""" self.name = name + self.unique_id = name if backups is None: backups = [ AgentBackup( diff --git a/tests/components/backup/snapshots/test_backup.ambr b/tests/components/backup/snapshots/test_backup.ambr index f91473e3b70..1a6774e7a95 100644 --- a/tests/components/backup/snapshots/test_backup.ambr +++ b/tests/components/backup/snapshots/test_backup.ambr @@ -39,6 +39,7 @@ 'agents': list([ dict({ 'agent_id': 'backup.local', + 'name': 'local', }), ]), }), @@ -97,6 +98,7 @@ 'agents': list([ dict({ 'agent_id': 'backup.local', + 'name': 'local', }), ]), }), @@ -128,6 +130,7 @@ 'agents': list([ dict({ 'agent_id': 'backup.local', + 'name': 'local', }), ]), }), @@ -159,6 +162,7 @@ 'agents': list([ dict({ 'agent_id': 'backup.local', + 'name': 'local', }), ]), }), @@ -190,6 +194,7 @@ 'agents': list([ dict({ 'agent_id': 'backup.local', + 'name': 'local', }), ]), }), diff --git a/tests/components/backup/snapshots/test_websocket.ambr b/tests/components/backup/snapshots/test_websocket.ambr index 43b4c1260dd..2a6bc14fb74 100644 --- a/tests/components/backup/snapshots/test_websocket.ambr +++ b/tests/components/backup/snapshots/test_websocket.ambr @@ -17,9 +17,11 @@ 'agents': list([ dict({ 'agent_id': 'backup.local', + 'name': 'local', }), dict({ - 'agent_id': 'domain.test', + 'agent_id': 'test.test', + 'name': 'test', }), ]), }), diff --git a/tests/components/backup/test_manager.py b/tests/components/backup/test_manager.py index 48e6db4ae9a..8a99f90d234 100644 --- a/tests/components/backup/test_manager.py +++ b/tests/components/backup/test_manager.py @@ -1203,8 +1203,8 @@ async def test_loading_platform_with_listener( await ws_client.send_json_auto_id({"type": "backup/agents/info"}) resp = await ws_client.receive_json() assert resp["result"]["agents"] == [ - {"agent_id": "backup.local"}, - {"agent_id": "test.remote1"}, + {"agent_id": "backup.local", "name": "local"}, + {"agent_id": "test.remote1", "name": "remote1"}, ] assert len(manager.local_backup_agents) == num_local_agents @@ -1220,8 +1220,8 @@ async def test_loading_platform_with_listener( await ws_client.send_json_auto_id({"type": "backup/agents/info"}) resp = await ws_client.receive_json() assert resp["result"]["agents"] == [ - {"agent_id": "backup.local"}, - {"agent_id": "test.remote2"}, + {"agent_id": "backup.local", "name": "local"}, + {"agent_id": "test.remote2", "name": "remote2"}, ] assert len(manager.local_backup_agents) == num_local_agents diff --git a/tests/components/cloud/test_backup.py b/tests/components/cloud/test_backup.py index db742525a48..373bd164c0c 100644 --- a/tests/components/cloud/test_backup.py +++ b/tests/components/cloud/test_backup.py @@ -146,7 +146,10 @@ async def test_agents_info( assert response["success"] assert response["result"] == { - "agents": [{"agent_id": "backup.local"}, {"agent_id": "cloud.cloud"}], + "agents": [ + {"agent_id": "backup.local", "name": "local"}, + {"agent_id": "cloud.cloud", "name": "cloud"}, + ], } diff --git a/tests/components/hassio/test_backup.py b/tests/components/hassio/test_backup.py index 40ab253b7e6..9483b513718 100644 --- a/tests/components/hassio/test_backup.py +++ b/tests/components/hassio/test_backup.py @@ -34,6 +34,7 @@ from homeassistant.components.backup import ( BackupAgentPlatformProtocol, Folder, ) +from homeassistant.components.hassio import DOMAIN from homeassistant.components.hassio.backup import LOCATION_CLOUD_BACKUP from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -252,11 +253,11 @@ async def setup_integration( class BackupAgentTest(BackupAgent): """Test backup agent.""" - domain = "test" - - def __init__(self, name: str) -> None: + def __init__(self, name: str, domain: str = "test") -> None: """Initialize the backup agent.""" + self.domain = domain self.name = name + self.unique_id = name async def async_download_backup( self, backup_id: str, **kwargs: Any @@ -304,7 +305,10 @@ async def _setup_backup_platform( @pytest.mark.parametrize( ("mounts", "expected_agents"), [ - (MountsInfo(default_backup_mount=None, mounts=[]), ["hassio.local"]), + ( + MountsInfo(default_backup_mount=None, mounts=[]), + [BackupAgentTest("local", DOMAIN)], + ), ( MountsInfo( default_backup_mount=None, @@ -321,7 +325,7 @@ async def _setup_backup_platform( ) ], ), - ["hassio.local", "hassio.test"], + [BackupAgentTest("local", DOMAIN), BackupAgentTest("test", DOMAIN)], ), ( MountsInfo( @@ -339,7 +343,7 @@ async def _setup_backup_platform( ) ], ), - ["hassio.local"], + [BackupAgentTest("local", DOMAIN)], ), ], ) @@ -348,7 +352,7 @@ async def test_agent_info( hass_ws_client: WebSocketGenerator, supervisor_client: AsyncMock, mounts: MountsInfo, - expected_agents: list[str], + expected_agents: list[BackupAgent], ) -> None: """Test backup agent info.""" client = await hass_ws_client(hass) @@ -361,7 +365,10 @@ async def test_agent_info( assert response["success"] assert response["result"] == { - "agents": [{"agent_id": agent_id} for agent_id in expected_agents], + "agents": [ + {"agent_id": agent.agent_id, "name": agent.name} + for agent in expected_agents + ], } diff --git a/tests/components/kitchen_sink/test_backup.py b/tests/components/kitchen_sink/test_backup.py index 9e46845e1cb..827bde39d7d 100644 --- a/tests/components/kitchen_sink/test_backup.py +++ b/tests/components/kitchen_sink/test_backup.py @@ -55,7 +55,10 @@ async def test_agents_info( assert response["success"] assert response["result"] == { - "agents": [{"agent_id": "backup.local"}, {"agent_id": "kitchen_sink.syncer"}], + "agents": [ + {"agent_id": "backup.local", "name": "local"}, + {"agent_id": "kitchen_sink.syncer", "name": "syncer"}, + ], } config_entry = hass.config_entries.async_entries(DOMAIN)[0] @@ -66,7 +69,9 @@ async def test_agents_info( response = await client.receive_json() assert response["success"] - assert response["result"] == {"agents": [{"agent_id": "backup.local"}]} + assert response["result"] == { + "agents": [{"agent_id": "backup.local", "name": "local"}] + } await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -76,7 +81,10 @@ async def test_agents_info( assert response["success"] assert response["result"] == { - "agents": [{"agent_id": "backup.local"}, {"agent_id": "kitchen_sink.syncer"}], + "agents": [ + {"agent_id": "backup.local", "name": "local"}, + {"agent_id": "kitchen_sink.syncer", "name": "syncer"}, + ], } diff --git a/tests/components/synology_dsm/test_backup.py b/tests/components/synology_dsm/test_backup.py index 0cd119cf015..436e3666176 100644 --- a/tests/components/synology_dsm/test_backup.py +++ b/tests/components/synology_dsm/test_backup.py @@ -208,8 +208,8 @@ async def test_agents_info( assert response["success"] assert response["result"] == { "agents": [ - {"agent_id": "synology_dsm.Mock Title"}, - {"agent_id": "backup.local"}, + {"agent_id": "synology_dsm.mocked_syno_dsm_entry", "name": "Mock Title"}, + {"agent_id": "backup.local", "name": "local"}, ], } @@ -231,7 +231,7 @@ async def test_agents_not_loaded( assert response["success"] assert response["result"] == { "agents": [ - {"agent_id": "backup.local"}, + {"agent_id": "backup.local", "name": "local"}, ], } @@ -251,8 +251,8 @@ async def test_agents_on_unload( assert response["success"] assert response["result"] == { "agents": [ - {"agent_id": "synology_dsm.Mock Title"}, - {"agent_id": "backup.local"}, + {"agent_id": "synology_dsm.mocked_syno_dsm_entry", "name": "Mock Title"}, + {"agent_id": "backup.local", "name": "local"}, ], } @@ -269,7 +269,7 @@ async def test_agents_on_unload( assert response["success"] assert response["result"] == { "agents": [ - {"agent_id": "backup.local"}, + {"agent_id": "backup.local", "name": "local"}, ], } @@ -299,7 +299,7 @@ async def test_agents_list_backups( "name": "Automatic backup 2025.2.0.dev0", "protected": True, "size": 13916160, - "agent_ids": ["synology_dsm.Mock Title"], + "agent_ids": ["synology_dsm.mocked_syno_dsm_entry"], "failed_agent_ids": [], "with_automatic_settings": None, } @@ -323,7 +323,9 @@ async def test_agents_list_backups_error( assert response["success"] assert response["result"] == { - "agent_errors": {"synology_dsm.Mock Title": "Failed to list backups"}, + "agent_errors": { + "synology_dsm.mocked_syno_dsm_entry": "Failed to list backups" + }, "backups": [], "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, @@ -362,7 +364,7 @@ async def test_agents_list_backups_disabled_filestation( "name": "Automatic backup 2025.2.0.dev0", "protected": True, "size": 13916160, - "agent_ids": ["synology_dsm.Mock Title"], + "agent_ids": ["synology_dsm.mocked_syno_dsm_entry"], "failed_agent_ids": [], "with_automatic_settings": None, }, @@ -429,7 +431,9 @@ async def test_agents_get_backup_error( assert response["success"] assert response["result"] == { - "agent_errors": {"synology_dsm.Mock Title": "Failed to list backups"}, + "agent_errors": { + "synology_dsm.mocked_syno_dsm_entry": "Failed to list backups" + }, "backup": None, } @@ -462,7 +466,7 @@ async def test_agents_download( backup_id = "abcd12ef" resp = await client.get( - f"/api/backup/download/{backup_id}?agent_id=synology_dsm.Mock Title" + f"/api/backup/download/{backup_id}?agent_id=synology_dsm.mocked_syno_dsm_entry" ) assert resp.status == 200 assert await resp.content.read() == b"backup data" @@ -482,7 +486,7 @@ async def test_agents_download_not_existing( ) resp = await client.get( - f"/api/backup/download/{backup_id}?agent_id=synology_dsm.Mock Title" + f"/api/backup/download/{backup_id}?agent_id=synology_dsm.mocked_syno_dsm_entry" ) assert resp.reason == "Internal Server Error" assert resp.status == 500 @@ -524,7 +528,7 @@ async def test_agents_upload( mocked_open.return_value.read = Mock(side_effect=[b"test", b""]) fetch_backup.return_value = test_backup resp = await client.post( - "/api/backup/upload?agent_id=synology_dsm.Mock Title", + "/api/backup/upload?agent_id=synology_dsm.mocked_syno_dsm_entry", data={"file": StringIO("test")}, ) @@ -578,7 +582,7 @@ async def test_agents_upload_error( SynologyDSMAPIErrorException("api", "500", "error") ) resp = await client.post( - "/api/backup/upload?agent_id=synology_dsm.Mock Title", + "/api/backup/upload?agent_id=synology_dsm.mocked_syno_dsm_entry", data={"file": StringIO("test")}, ) @@ -609,7 +613,7 @@ async def test_agents_upload_error( ] resp = await client.post( - "/api/backup/upload?agent_id=synology_dsm.Mock Title", + "/api/backup/upload?agent_id=synology_dsm.mocked_syno_dsm_entry", data={"file": StringIO("test")}, ) @@ -674,7 +678,9 @@ async def test_agents_delete_not_existing( assert response["success"] assert response["result"] == { - "agent_errors": {"synology_dsm.Mock Title": "Failed to delete the backup"} + "agent_errors": { + "synology_dsm.mocked_syno_dsm_entry": "Failed to delete the backup" + } } @@ -701,7 +707,9 @@ async def test_agents_delete_error( assert response["success"] assert response["result"] == { - "agent_errors": {"synology_dsm.Mock Title": "Failed to delete the backup"} + "agent_errors": { + "synology_dsm.mocked_syno_dsm_entry": "Failed to delete the backup" + } } mock: AsyncMock = setup_dsm_with_filestation.file.delete_file assert len(mock.mock_calls) == 1 From 1f35451863863142b50aa94055b82519564c0b1e Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 28 Jan 2025 12:24:03 +0100 Subject: [PATCH 0111/3148] Use runtime_data in epic_games_store (#136709) --- .../components/epic_games_store/__init__.py | 15 +++++---------- .../components/epic_games_store/calendar.py | 7 +++---- .../components/epic_games_store/coordinator.py | 5 ++++- 3 files changed, 12 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/epic_games_store/__init__.py b/homeassistant/components/epic_games_store/__init__.py index af25eb98137..d9fb3bee529 100644 --- a/homeassistant/components/epic_games_store/__init__.py +++ b/homeassistant/components/epic_games_store/__init__.py @@ -2,34 +2,29 @@ from __future__ import annotations -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from .const import DOMAIN -from .coordinator import EGSCalendarUpdateCoordinator +from .coordinator import EGSCalendarUpdateCoordinator, EGSConfigEntry PLATFORMS: list[Platform] = [ Platform.CALENDAR, ] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: EGSConfigEntry) -> bool: """Set up Epic Games Store from a config entry.""" coordinator = EGSCalendarUpdateCoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: EGSConfigEntry) -> 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 + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/epic_games_store/calendar.py b/homeassistant/components/epic_games_store/calendar.py index 2ebb381341e..5df1d6b756d 100644 --- a/homeassistant/components/epic_games_store/calendar.py +++ b/homeassistant/components/epic_games_store/calendar.py @@ -7,25 +7,24 @@ from datetime import datetime from typing import Any from homeassistant.components.calendar import CalendarEntity, CalendarEvent -from homeassistant.config_entries import ConfigEntry 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, CalendarType -from .coordinator import EGSCalendarUpdateCoordinator +from .coordinator import EGSCalendarUpdateCoordinator, EGSConfigEntry DateRange = namedtuple("DateRange", ["start", "end"]) # noqa: PYI024 async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: EGSConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the local calendar platform.""" - coordinator: EGSCalendarUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data entities = [ EGSCalendar(coordinator, entry.entry_id, CalendarType.FREE), diff --git a/homeassistant/components/epic_games_store/coordinator.py b/homeassistant/components/epic_games_store/coordinator.py index d9c48f5da02..0653a3da9b3 100644 --- a/homeassistant/components/epic_games_store/coordinator.py +++ b/homeassistant/components/epic_games_store/coordinator.py @@ -20,13 +20,15 @@ SCAN_INTERVAL = timedelta(days=1) _LOGGER = logging.getLogger(__name__) +type EGSConfigEntry = ConfigEntry[EGSCalendarUpdateCoordinator] + class EGSCalendarUpdateCoordinator( DataUpdateCoordinator[dict[str, list[dict[str, Any]]]] ): """Class to manage fetching data from the Epic Game Store.""" - def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + def __init__(self, hass: HomeAssistant, entry: EGSConfigEntry) -> None: """Initialize.""" self._api = EpicGamesStoreAPI( entry.data[CONF_LANGUAGE], @@ -37,6 +39,7 @@ class EGSCalendarUpdateCoordinator( super().__init__( hass, _LOGGER, + config_entry=entry, name=DOMAIN, update_interval=SCAN_INTERVAL, ) From 82ee47ef77e8a773b30f5e10dd6eac8865844a3e Mon Sep 17 00:00:00 2001 From: "Teemu R." Date: Tue, 28 Jan 2025 12:44:46 +0100 Subject: [PATCH 0112/3148] Initial implementation for tplink tapo vacuums (#131965) Co-authored-by: Steven B. <51370195+sdb9696@users.noreply.github.com> Co-authored-by: Joost Lekkerkerker --- homeassistant/components/tplink/const.py | 1 + homeassistant/components/tplink/entity.py | 1 + homeassistant/components/tplink/vacuum.py | 158 ++++++++++++++++++ tests/components/tplink/__init__.py | 75 ++++++++- .../tplink/snapshots/test_vacuum.ambr | 96 +++++++++++ tests/components/tplink/test_vacuum.py | 125 ++++++++++++++ 6 files changed, 453 insertions(+), 3 deletions(-) create mode 100644 homeassistant/components/tplink/vacuum.py create mode 100644 tests/components/tplink/snapshots/test_vacuum.ambr create mode 100644 tests/components/tplink/test_vacuum.py diff --git a/homeassistant/components/tplink/const.py b/homeassistant/components/tplink/const.py index 61c1bf1cb9b..ad17aadeb5b 100644 --- a/homeassistant/components/tplink/const.py +++ b/homeassistant/components/tplink/const.py @@ -41,6 +41,7 @@ PLATFORMS: Final = [ Platform.SENSOR, Platform.SIREN, Platform.SWITCH, + Platform.VACUUM, ] UNIT_MAPPING = { diff --git a/homeassistant/components/tplink/entity.py b/homeassistant/components/tplink/entity.py index edef8bd83a0..6c21ab63285 100644 --- a/homeassistant/components/tplink/entity.py +++ b/homeassistant/components/tplink/entity.py @@ -59,6 +59,7 @@ DEVICETYPES_WITH_SPECIALIZED_PLATFORMS = { DeviceType.Dimmer, DeviceType.Fan, DeviceType.Thermostat, + DeviceType.Vacuum, } # Primary features to always include even when the device type has its own platform diff --git a/homeassistant/components/tplink/vacuum.py b/homeassistant/components/tplink/vacuum.py new file mode 100644 index 00000000000..666584f4980 --- /dev/null +++ b/homeassistant/components/tplink/vacuum.py @@ -0,0 +1,158 @@ +"""Support for TPLink vacuum.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, cast + +from kasa import Device, Feature, Module +from kasa.smart.modules.clean import Clean, Status + +from homeassistant.components.vacuum import ( + DOMAIN as VACUUM_DOMAIN, + StateVacuumEntity, + StateVacuumEntityDescription, + VacuumActivity, + VacuumEntityFeature, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import TPLinkConfigEntry +from .coordinator import TPLinkDataUpdateCoordinator +from .entity import ( + CoordinatedTPLinkModuleEntity, + TPLinkModuleEntityDescription, + async_refresh_after, +) + +# Coordinator is used to centralize the data updates +# For actions the integration handles locking of concurrent device request +PARALLEL_UPDATES = 0 + +# Upstream state to VacuumActivity +STATUS_TO_ACTIVITY = { + Status.Idle: VacuumActivity.IDLE, + Status.Cleaning: VacuumActivity.CLEANING, + Status.GoingHome: VacuumActivity.RETURNING, + Status.Charging: VacuumActivity.DOCKED, + Status.Charged: VacuumActivity.DOCKED, + Status.Undocked: VacuumActivity.IDLE, + Status.Paused: VacuumActivity.PAUSED, + Status.Error: VacuumActivity.ERROR, +} + + +@dataclass(frozen=True, kw_only=True) +class TPLinkVacuumEntityDescription( + StateVacuumEntityDescription, TPLinkModuleEntityDescription +): + """Base class for vacuum entity description.""" + + +VACUUM_DESCRIPTIONS: tuple[TPLinkVacuumEntityDescription, ...] = ( + TPLinkVacuumEntityDescription( + key="vacuum", exists_fn=lambda dev, _: Module.Clean in dev.modules + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: TPLinkConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up vacuum entities.""" + data = config_entry.runtime_data + parent_coordinator = data.parent_coordinator + device = parent_coordinator.device + + known_child_device_ids: set[str] = set() + first_check = True + + def _check_device() -> None: + entities = CoordinatedTPLinkModuleEntity.entities_for_device_and_its_children( + hass=hass, + device=device, + coordinator=parent_coordinator, + entity_class=TPLinkVacuumEntity, + descriptions=VACUUM_DESCRIPTIONS, + platform_domain=VACUUM_DOMAIN, + known_child_device_ids=known_child_device_ids, + first_check=first_check, + ) + async_add_entities(entities) + + _check_device() + first_check = False + config_entry.async_on_unload(parent_coordinator.async_add_listener(_check_device)) + + +class TPLinkVacuumEntity(CoordinatedTPLinkModuleEntity, StateVacuumEntity): + """Representation of a tplink vacuum.""" + + _attr_supported_features = ( + VacuumEntityFeature.STATE + | VacuumEntityFeature.BATTERY + | VacuumEntityFeature.START + | VacuumEntityFeature.PAUSE + | VacuumEntityFeature.RETURN_HOME + | VacuumEntityFeature.FAN_SPEED + ) + + entity_description: TPLinkVacuumEntityDescription + + def __init__( + self, + device: Device, + coordinator: TPLinkDataUpdateCoordinator, + description: TPLinkVacuumEntityDescription, + *, + parent: Device, + ) -> None: + """Initialize the vacuum entity.""" + super().__init__(device, coordinator, description, parent=parent) + self._vacuum_module: Clean = device.modules[Module.Clean] + if speaker := device.modules.get(Module.Speaker): + self._speaker_module = speaker + self._attr_supported_features |= VacuumEntityFeature.LOCATE + + # Needs to be initialized empty, as vacuumentity's capability_attributes accesses it + self._attr_fan_speed_list: list[str] = [] + + @async_refresh_after + async def async_start(self) -> None: + """Start cleaning.""" + await self._vacuum_module.start() + + @async_refresh_after + async def async_pause(self) -> None: + """Pause cleaning.""" + await self._vacuum_module.pause() + + @async_refresh_after + async def async_return_to_base(self, **kwargs: Any) -> None: + """Return home.""" + await self._vacuum_module.return_home() + + @async_refresh_after + async def async_set_fan_speed(self, fan_speed: str, **kwargs: Any) -> None: + """Set fan speed.""" + await self._vacuum_module.set_fan_speed_preset(fan_speed) + + async def async_locate(self, **kwargs: Any) -> None: + """Locate the device.""" + await self._speaker_module.locate() + + @property + def battery_level(self) -> int | None: + """Return battery level.""" + return self._vacuum_module.battery + + def _async_update_attrs(self) -> bool: + """Update the entity's attributes.""" + self._attr_activity = STATUS_TO_ACTIVITY.get(self._vacuum_module.status) + fanspeeds = cast(Feature, self._vacuum_module.get_feature("fan_speed_preset")) + self._attr_fan_speed_list = cast(list[str], fanspeeds.choices) + self._attr_fan_speed = self._vacuum_module.fan_speed_preset + return True diff --git a/tests/components/tplink/__init__.py b/tests/components/tplink/__init__.py index 008d25a3dcb..664fb96fe71 100644 --- a/tests/components/tplink/__init__.py +++ b/tests/components/tplink/__init__.py @@ -16,7 +16,9 @@ from kasa import ( ThermostatState, ) from kasa.interfaces import Fan, Light, LightEffect, LightState, Thermostat +from kasa.smart.modules import Speaker from kasa.smart.modules.alarm import Alarm +from kasa.smart.modules.clean import Clean, ErrorCode, Status from kasa.smartcam.modules.camera import LOCAL_STREAMING_PORT, Camera from syrupy import SnapshotAssertion @@ -195,16 +197,33 @@ def _mocked_device( ) device.features = device_features - # Add modules after features so modules can add required features + # Add modules after features so modules can add any required features if modules: device.modules = { module_name: MODULE_TO_MOCK_GEN[module_name](device) for module_name in modules } + # module features are accessed from a module via get_feature which is + # keyed on the module attribute name. Usually this is the same as the + # feature.id but not always so accept overrides. + module_features = { + mod_key if (mod_key := v.expected_module_key) else k: v + for k, v in device_features.items() + } for mod in device.modules.values(): - mod.get_feature.side_effect = device_features.get - mod.has_feature.side_effect = lambda id: id in device_features + # Some tests remove the feature from device_features to test missing + # features, so check the key is still present there. + mod.get_feature.side_effect = ( + lambda mod_id: mod_feat + if (mod_feat := module_features.get(mod_id)) + and mod_feat.id in device_features + else None + ) + mod.has_feature.side_effect = ( + lambda mod_id: (mod_feat := module_features.get(mod_id)) + and mod_feat.id in device_features + ) device.parent = None device.children = [] @@ -243,6 +262,7 @@ def _mocked_feature( unit=None, minimum_value=None, maximum_value=None, + expected_module_key=None, ) -> Feature: """Get a mocked feature. @@ -284,6 +304,16 @@ def _mocked_feature( # select feature.choices = choices or fixture.get("choices") + # module features are accessed from a module via get_feature which is + # keyed on the module attribute name. Usually this is the same as the + # feature.id but not always. module_key indicates the key of the feature + # in the module. + feature.expected_module_key = ( + mod_key + if (mod_key := fixture.get("expected_module_key", expected_module_key)) + else None + ) + return feature @@ -400,6 +430,43 @@ def _mocked_thermostat_module(device): return therm +def _mocked_clean_module(device): + clean = MagicMock(auto_spec=Clean, name="Mocked clean") + + # methods + clean.start = AsyncMock() + clean.pause = AsyncMock() + clean.resume = AsyncMock() + clean.return_home = AsyncMock() + clean.set_fan_speed_preset = AsyncMock() + + # properties + clean.fan_speed_preset = "Max" + clean.error = ErrorCode.Ok + clean.battery = 100 + clean.status = Status.Charged + + # Need to manually create the fan speed preset feature, + # as we are going to read its choices through it + device.features["vacuum_fan_speed"] = _mocked_feature( + "vacuum_fan_speed", + type_=Feature.Type.Choice, + category=Feature.Category.Config, + choices=["Quiet", "Max"], + value="Max", + expected_module_key="fan_speed_preset", + ) + + return clean + + +def _mocked_speaker_module(device): + speaker = MagicMock(auto_spec=Speaker, name="Mocked speaker") + speaker.locate = AsyncMock() + + return speaker + + def _mocked_strip_children(features=None, alias=None) -> list[Device]: plug0 = _mocked_device( alias="Plug0" if alias is None else alias, @@ -469,6 +536,8 @@ MODULE_TO_MOCK_GEN = { Module.Alarm: _mocked_alarm_module, Module.Camera: _mocked_camera_module, Module.Thermostat: _mocked_thermostat_module, + Module.Clean: _mocked_clean_module, + Module.Speaker: _mocked_speaker_module, } diff --git a/tests/components/tplink/snapshots/test_vacuum.ambr b/tests/components/tplink/snapshots/test_vacuum.ambr new file mode 100644 index 00000000000..a28a7d80ab4 --- /dev/null +++ b/tests/components/tplink/snapshots/test_vacuum.ambr @@ -0,0 +1,96 @@ +# serializer version: 1 +# name: test_states[my_vacuum-entry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + 'aa:bb:cc:dd:ee:ff', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '1.0.0', + 'id': , + 'identifiers': set({ + tuple( + 'tplink', + '123456789ABCDEFGH', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'TP-Link', + 'model': 'HS100', + 'model_id': None, + 'name': 'my_vacuum', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '1.0.0', + 'via_device_id': None, + }) +# --- +# name: test_states[vacuum.my_vacuum-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'fan_speed_list': list([ + 'Quiet', + 'Max', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'vacuum', + 'entity_category': None, + 'entity_id': 'vacuum.my_vacuum', + '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': None, + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '123456789ABCDEFGH-vacuum', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[vacuum.my_vacuum-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'battery_icon': 'mdi:battery-charging-100', + 'battery_level': 100, + 'fan_speed': 'Max', + 'fan_speed_list': list([ + 'Quiet', + 'Max', + ]), + 'friendly_name': 'my_vacuum', + 'supported_features': , + }), + 'context': , + 'entity_id': 'vacuum.my_vacuum', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'docked', + }) +# --- diff --git a/tests/components/tplink/test_vacuum.py b/tests/components/tplink/test_vacuum.py new file mode 100644 index 00000000000..aac7c4f7fc8 --- /dev/null +++ b/tests/components/tplink/test_vacuum.py @@ -0,0 +1,125 @@ +"""Tests for vacuum platform.""" + +from __future__ import annotations + +from kasa import Device, Module +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.vacuum import ( + ATTR_BATTERY_LEVEL, + ATTR_FAN_SPEED, + DOMAIN as VACUUM_DOMAIN, + SERVICE_LOCATE, + SERVICE_PAUSE, + SERVICE_RETURN_TO_BASE, + SERVICE_SET_FAN_SPEED, + SERVICE_START, + VacuumActivity, +) +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from . import DEVICE_ID, _mocked_device, setup_platform_for_device, snapshot_platform + +from tests.common import MockConfigEntry + +ENTITY_ID = "vacuum.my_vacuum" + + +@pytest.fixture +async def mocked_vacuum(hass: HomeAssistant) -> Device: + """Return mocked tplink vacuum.""" + + return _mocked_device(modules=[Module.Clean, Module.Speaker], alias="my_vacuum") + + +async def test_vacuum( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + mocked_vacuum: Device, +) -> None: + """Test initialization.""" + await setup_platform_for_device( + hass, mock_config_entry, Platform.VACUUM, mocked_vacuum + ) + + device_entries = dr.async_entries_for_config_entry( + device_registry, mock_config_entry.entry_id + ) + assert device_entries + + entity = entity_registry.async_get(ENTITY_ID) + assert entity + assert entity.unique_id == f"{DEVICE_ID}-vacuum" + + state = hass.states.get(ENTITY_ID) + assert state.state == VacuumActivity.DOCKED + + assert state.attributes[ATTR_FAN_SPEED] == "Max" + assert state.attributes[ATTR_BATTERY_LEVEL] == 100 + + +async def test_states( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, + mocked_vacuum: Device, +) -> None: + """Test vacuum states.""" + await setup_platform_for_device( + hass, mock_config_entry, Platform.VACUUM, mocked_vacuum + ) + await snapshot_platform( + hass, entity_registry, device_registry, snapshot, mock_config_entry.entry_id + ) + + +@pytest.mark.parametrize( + ("service_call", "module_name", "method", "params"), + [ + (SERVICE_START, Module.Clean, "start", {}), + (SERVICE_PAUSE, Module.Clean, "pause", {}), + (SERVICE_RETURN_TO_BASE, Module.Clean, "return_home", {}), + ( + SERVICE_SET_FAN_SPEED, + Module.Clean, + "set_fan_speed_preset", + {ATTR_FAN_SPEED: "Quiet"}, + ), + (SERVICE_LOCATE, Module.Speaker, "locate", {}), + ], +) +async def test_vacuum_module( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mocked_vacuum: Device, + service_call: str, + module_name: str, + method: str, + params: dict, +) -> None: + """Test that all vacuum commands work correctly.""" + vacuum = mocked_vacuum + module = vacuum.modules[module_name] + + await setup_platform_for_device(hass, mock_config_entry, Platform.VACUUM, vacuum) + + mock_method = getattr(module, method) + + service_data = {ATTR_ENTITY_ID: ENTITY_ID} + service_data |= params + + await hass.services.async_call( + VACUUM_DOMAIN, service_call, service_data, blocking=True + ) + + # Is this required when using blocking=True? + await hass.async_block_till_done(wait_background_tasks=True) + + mock_method.assert_called() From 7db6f44f2d4d055f07f0209f97a685c9385ae0ea Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 28 Jan 2025 13:15:41 +0100 Subject: [PATCH 0113/3148] Bump github/codeql-action from 3.28.5 to 3.28.6 (#136686) 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 9dbd39b4bc5..d7f46b176cd 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -24,11 +24,11 @@ jobs: uses: actions/checkout@v4.2.2 - name: Initialize CodeQL - uses: github/codeql-action/init@v3.28.5 + uses: github/codeql-action/init@v3.28.6 with: languages: python - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3.28.5 + uses: github/codeql-action/analyze@v3.28.6 with: category: "/language:python" From 7f3e56eb582a21b4fb881f37caf62e70a771ec5c Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Tue, 28 Jan 2025 12:17:35 +0000 Subject: [PATCH 0114/3148] Update tplink coordinators to update hub-attached children (#135586) --- homeassistant/components/tplink/coordinator.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/tplink/coordinator.py b/homeassistant/components/tplink/coordinator.py index 186840e8faf..d1b4694779d 100644 --- a/homeassistant/components/tplink/coordinator.py +++ b/homeassistant/components/tplink/coordinator.py @@ -49,6 +49,12 @@ class TPLinkDataUpdateCoordinator(DataUpdateCoordinator[None]): ) -> None: """Initialize DataUpdateCoordinator to gather data for specific SmartPlug.""" self.device = device + + # The iot HS300 allows a limited number of concurrent requests and + # fetching the emeter information requires separate ones, so child + # coordinators are created below in get_child_coordinator. + self._update_children = not isinstance(device, IotStrip) + super().__init__( hass, _LOGGER, @@ -68,7 +74,7 @@ class TPLinkDataUpdateCoordinator(DataUpdateCoordinator[None]): async def _async_update_data(self) -> None: """Fetch all device and sensor data from api.""" try: - await self.device.update(update_children=False) + await self.device.update(update_children=self._update_children) except AuthenticationError as ex: raise ConfigEntryAuthFailed( translation_domain=DOMAIN, From fa4b93da2b25dde5914a65823ab785a9007e63c5 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Tue, 28 Jan 2025 13:24:44 +0100 Subject: [PATCH 0115/3148] Bump bring-api to 1.0.0 (#136657) --- homeassistant/components/bring/config_flow.py | 3 +- homeassistant/components/bring/coordinator.py | 16 +- homeassistant/components/bring/diagnostics.py | 7 +- homeassistant/components/bring/entity.py | 6 +- homeassistant/components/bring/manifest.json | 2 +- homeassistant/components/bring/sensor.py | 4 +- homeassistant/components/bring/todo.py | 48 ++--- homeassistant/components/bring/util.py | 24 ++- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/bring/conftest.py | 26 ++- tests/components/bring/fixtures/items.json | 78 ++++---- .../bring/fixtures/items_invitation.json | 78 ++++---- .../bring/fixtures/items_shared.json | 78 ++++---- tests/components/bring/fixtures/login.json | 12 ++ .../bring/snapshots/test_diagnostics.ambr | 168 ++++++++++-------- tests/components/bring/test_sensor.py | 8 +- tests/components/bring/test_util.py | 20 +-- 18 files changed, 311 insertions(+), 271 deletions(-) create mode 100644 tests/components/bring/fixtures/login.json diff --git a/homeassistant/components/bring/config_flow.py b/homeassistant/components/bring/config_flow.py index b8ee9d1e6ae..bfb5a2cd50f 100644 --- a/homeassistant/components/bring/config_flow.py +++ b/homeassistant/components/bring/config_flow.py @@ -63,7 +63,8 @@ class BringConfigFlow(ConfigFlow, domain=DOMAIN): ): self._abort_if_unique_id_configured() return self.async_create_entry( - title=self.info.get("name") or user_input[CONF_EMAIL], data=user_input + title=self.info.name or user_input[CONF_EMAIL], + data=user_input, ) return self.async_show_form( diff --git a/homeassistant/components/bring/coordinator.py b/homeassistant/components/bring/coordinator.py index d02237e84eb..0511d285afc 100644 --- a/homeassistant/components/bring/coordinator.py +++ b/homeassistant/components/bring/coordinator.py @@ -2,6 +2,7 @@ from __future__ import annotations +from dataclasses import dataclass from datetime import timedelta import logging @@ -12,6 +13,7 @@ from bring_api import ( BringRequestException, ) from bring_api.types import BringItemsResponse, BringList, BringUserSettingsResponse +from mashumaro.mixins.orjson import DataClassORJSONMixin from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_EMAIL @@ -24,9 +26,13 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) -class BringData(BringList, BringItemsResponse): +@dataclass(frozen=True) +class BringData(DataClassORJSONMixin): """Coordinator data class.""" + lst: BringList + content: BringItemsResponse + class BringDataUpdateCoordinator(DataUpdateCoordinator[dict[str, BringData]]): """A Bring Data Update Coordinator.""" @@ -67,11 +73,11 @@ class BringDataUpdateCoordinator(DataUpdateCoordinator[dict[str, BringData]]): return self.data list_dict: dict[str, BringData] = {} - for lst in lists_response["lists"]: - if (ctx := set(self.async_contexts())) and lst["listUuid"] not in ctx: + for lst in lists_response.lists: + if (ctx := set(self.async_contexts())) and lst.listUuid not in ctx: continue try: - items = await self.bring.get_list(lst["listUuid"]) + items = await self.bring.get_list(lst.listUuid) except BringRequestException as e: raise UpdateFailed( "Unable to connect and retrieve data from bring" @@ -79,7 +85,7 @@ class BringDataUpdateCoordinator(DataUpdateCoordinator[dict[str, BringData]]): except BringParseException as e: raise UpdateFailed("Unable to parse response from bring") from e else: - list_dict[lst["listUuid"]] = BringData(**lst, **items) + list_dict[lst.listUuid] = BringData(lst, items) return list_dict diff --git a/homeassistant/components/bring/diagnostics.py b/homeassistant/components/bring/diagnostics.py index f4193a9993c..1dec8f3a5ed 100644 --- a/homeassistant/components/bring/diagnostics.py +++ b/homeassistant/components/bring/diagnostics.py @@ -2,15 +2,16 @@ from __future__ import annotations +from typing import Any + from homeassistant.core import HomeAssistant from . import BringConfigEntry -from .coordinator import BringData async def async_get_config_entry_diagnostics( hass: HomeAssistant, config_entry: BringConfigEntry -) -> dict[str, BringData]: +) -> dict[str, Any]: """Return diagnostics for a config entry.""" - return config_entry.runtime_data.data + return {k: v.to_dict() for k, v in config_entry.runtime_data.data.items()} diff --git a/homeassistant/components/bring/entity.py b/homeassistant/components/bring/entity.py index a1e0cb2edc0..74076d66df9 100644 --- a/homeassistant/components/bring/entity.py +++ b/homeassistant/components/bring/entity.py @@ -20,13 +20,13 @@ class BringBaseEntity(CoordinatorEntity[BringDataUpdateCoordinator]): bring_list: BringData, ) -> None: """Initialize the entity.""" - super().__init__(coordinator, bring_list["listUuid"]) + super().__init__(coordinator, bring_list.lst.listUuid) - self._list_uuid = bring_list["listUuid"] + self._list_uuid = bring_list.lst.listUuid self.device_info = DeviceInfo( entry_type=DeviceEntryType.SERVICE, - name=bring_list["name"], + name=bring_list.lst.name, identifiers={ (DOMAIN, f"{coordinator.config_entry.unique_id}_{self._list_uuid}") }, diff --git a/homeassistant/components/bring/manifest.json b/homeassistant/components/bring/manifest.json index 71fe733ccf5..ecd3e911078 100644 --- a/homeassistant/components/bring/manifest.json +++ b/homeassistant/components/bring/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["bring_api"], - "requirements": ["bring-api==0.9.1"] + "requirements": ["bring-api==1.0.0"] } diff --git a/homeassistant/components/bring/sensor.py b/homeassistant/components/bring/sensor.py index bd33ce9bf88..02bd0e50788 100644 --- a/homeassistant/components/bring/sensor.py +++ b/homeassistant/components/bring/sensor.py @@ -65,7 +65,7 @@ SENSOR_DESCRIPTIONS: tuple[BringSensorEntityDescription, ...] = ( translation_key=BringSensor.LIST_LANGUAGE, value_fn=( lambda lst, settings: x.lower() - if (x := list_language(lst["listUuid"], settings)) + if (x := list_language(lst.lst.listUuid, settings)) else None ), entity_category=EntityCategory.DIAGNOSTIC, @@ -75,7 +75,7 @@ SENSOR_DESCRIPTIONS: tuple[BringSensorEntityDescription, ...] = ( BringSensorEntityDescription( key=BringSensor.LIST_ACCESS, translation_key=BringSensor.LIST_ACCESS, - value_fn=lambda lst, _: lst["status"].lower(), + value_fn=lambda lst, _: lst.content.status.value.lower(), entity_category=EntityCategory.DIAGNOSTIC, options=["registered", "shared", "invitation"], device_class=SensorDeviceClass.ENUM, diff --git a/homeassistant/components/bring/todo.py b/homeassistant/components/bring/todo.py index 75657e2fd64..7ab60084314 100644 --- a/homeassistant/components/bring/todo.py +++ b/homeassistant/components/bring/todo.py @@ -2,6 +2,7 @@ from __future__ import annotations +from itertools import chain from typing import TYPE_CHECKING import uuid @@ -59,7 +60,7 @@ async def async_setup_entry( SERVICE_PUSH_NOTIFICATION, { vol.Required(ATTR_NOTIFICATION_TYPE): vol.All( - vol.Upper, cv.enum(BringNotificationType) + vol.Upper, vol.Coerce(BringNotificationType) ), vol.Optional(ATTR_ITEM_NAME): cv.string, }, @@ -92,21 +93,21 @@ class BringTodoListEntity(BringBaseEntity, TodoListEntity): return [ *( TodoItem( - uid=item["uuid"], - summary=item["itemId"], - description=item["specification"] or "", + uid=item.uuid, + summary=item.itemId, + description=item.specification, status=TodoItemStatus.NEEDS_ACTION, ) - for item in self.bring_list["purchase"] + for item in self.bring_list.content.items.purchase ), *( TodoItem( - uid=item["uuid"], - summary=item["itemId"], - description=item["specification"] or "", + uid=item.uuid, + summary=item.itemId, + description=item.specification, status=TodoItemStatus.COMPLETED, ) - for item in self.bring_list["recently"] + for item in self.bring_list.content.items.recently ), ] @@ -119,7 +120,7 @@ class BringTodoListEntity(BringBaseEntity, TodoListEntity): """Add an item to the To-do list.""" try: await self.coordinator.bring.save_item( - self.bring_list["listUuid"], + self._list_uuid, item.summary or "", item.description or "", str(uuid.uuid4()), @@ -154,26 +155,25 @@ class BringTodoListEntity(BringBaseEntity, TodoListEntity): bring_list = self.bring_list - bring_purchase_item = next( - (i for i in bring_list["purchase"] if i["uuid"] == item.uid), + current_item = next( + ( + i + for i in chain( + bring_list.content.items.purchase, bring_list.content.items.recently + ) + if i.uuid == item.uid + ), None, ) - bring_recently_item = next( - (i for i in bring_list["recently"] if i["uuid"] == item.uid), - None, - ) - - current_item = bring_purchase_item or bring_recently_item - if TYPE_CHECKING: assert item.uid assert current_item - if item.summary == current_item["itemId"]: + if item.summary == current_item.itemId: try: await self.coordinator.bring.batch_update_list( - bring_list["listUuid"], + self._list_uuid, BringItem( itemId=item.summary or "", spec=item.description or "", @@ -192,10 +192,10 @@ class BringTodoListEntity(BringBaseEntity, TodoListEntity): else: try: await self.coordinator.bring.batch_update_list( - bring_list["listUuid"], + self._list_uuid, [ BringItem( - itemId=current_item["itemId"], + itemId=current_item.itemId, spec=item.description or "", uuid=item.uid, operation=BringItemOperation.REMOVE, @@ -225,7 +225,7 @@ class BringTodoListEntity(BringBaseEntity, TodoListEntity): try: await self.coordinator.bring.batch_update_list( - self.bring_list["listUuid"], + self._list_uuid, [ BringItem( itemId=uid, diff --git a/homeassistant/components/bring/util.py b/homeassistant/components/bring/util.py index b706156a3d3..9a075f7bb89 100644 --- a/homeassistant/components/bring/util.py +++ b/homeassistant/components/bring/util.py @@ -14,27 +14,25 @@ def list_language( """Get the lists language setting.""" try: list_settings = next( - filter( - lambda x: x["listUuid"] == list_uuid, - user_settings["userlistsettings"], - ) + filter(lambda x: x.listUuid == list_uuid, user_settings.userlistsettings) ) - return next( - filter( - lambda x: x["key"] == "listArticleLanguage", - list_settings["usersettings"], + return ( + next( + filter( + lambda x: x.key == "listArticleLanguage", list_settings.usersettings + ) ) - )["value"] + ).value - except (StopIteration, KeyError): + except StopIteration: return None def sum_attributes(bring_list: BringData, attribute: str) -> int: """Count items with given attribute set.""" return sum( - item["attributes"][0]["content"][attribute] - for item in bring_list["purchase"] - if len(item.get("attributes", [])) + getattr(item.attributes[0].content, attribute) + for item in bring_list.content.items.purchase + if item.attributes ) diff --git a/requirements_all.txt b/requirements_all.txt index 8e5081db52d..f154531e83b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -644,7 +644,7 @@ boto3==1.34.131 botocore==1.34.131 # homeassistant.components.bring -bring-api==0.9.1 +bring-api==1.0.0 # homeassistant.components.broadlink broadlink==0.19.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4a28323c95e..9be1cd9dab5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -564,7 +564,7 @@ boschshcpy==0.2.91 botocore==1.34.131 # homeassistant.components.bring -bring-api==0.9.1 +bring-api==1.0.0 # homeassistant.components.broadlink broadlink==0.19.0 diff --git a/tests/components/bring/conftest.py b/tests/components/bring/conftest.py index 7d1b787ff0b..2b2e9257097 100644 --- a/tests/components/bring/conftest.py +++ b/tests/components/bring/conftest.py @@ -1,17 +1,21 @@ """Common fixtures for the Bring! tests.""" from collections.abc import Generator -from typing import cast from unittest.mock import AsyncMock, patch import uuid -from bring_api.types import BringAuthResponse +from bring_api.types import ( + BringAuthResponse, + BringItemsResponse, + BringListResponse, + BringUserSettingsResponse, +) import pytest from homeassistant.components.bring.const import DOMAIN from homeassistant.const import CONF_EMAIL, CONF_PASSWORD -from tests.common import MockConfigEntry, load_json_object_fixture +from tests.common import MockConfigEntry, load_fixture EMAIL = "test-email" PASSWORD = "test-password" @@ -44,11 +48,17 @@ def mock_bring_client() -> Generator[AsyncMock]: client = mock_client.return_value client.uuid = UUID client.mail = EMAIL - client.login.return_value = cast(BringAuthResponse, {"name": "Bring"}) - client.load_lists.return_value = load_json_object_fixture("lists.json", DOMAIN) - client.get_list.return_value = load_json_object_fixture("items.json", DOMAIN) - client.get_all_user_settings.return_value = load_json_object_fixture( - "usersettings.json", DOMAIN + client.login.return_value = BringAuthResponse.from_json( + load_fixture("login.json", DOMAIN) + ) + client.load_lists.return_value = BringListResponse.from_json( + load_fixture("lists.json", DOMAIN) + ) + client.get_list.return_value = BringItemsResponse.from_json( + load_fixture("items.json", DOMAIN) + ) + client.get_all_user_settings.return_value = BringUserSettingsResponse.from_json( + load_fixture("usersettings.json", DOMAIN) ) yield client diff --git a/tests/components/bring/fixtures/items.json b/tests/components/bring/fixtures/items.json index e0b9006167b..eecdbaac8c7 100644 --- a/tests/components/bring/fixtures/items.json +++ b/tests/components/bring/fixtures/items.json @@ -1,44 +1,46 @@ { "uuid": "77a151f8-77c4-47a3-8295-c750a0e69d4f", "status": "REGISTERED", - "purchase": [ - { - "uuid": "b5d0790b-5f32-4d5c-91da-e29066f167de", - "itemId": "Paprika", - "specification": "Rot", - "attributes": [ - { - "type": "PURCHASE_CONDITIONS", - "content": { - "urgent": true, - "convenient": true, - "discounted": true + "items": { + "purchase": [ + { + "uuid": "b5d0790b-5f32-4d5c-91da-e29066f167de", + "itemId": "Paprika", + "specification": "Rot", + "attributes": [ + { + "type": "PURCHASE_CONDITIONS", + "content": { + "urgent": true, + "convenient": true, + "discounted": true + } } - } - ] - }, - { - "uuid": "72d370ab-d8ca-4e41-b956-91df94795b4e", - "itemId": "Pouletbrüstli", - "specification": "Bio", - "attributes": [ - { - "type": "PURCHASE_CONDITIONS", - "content": { - "urgent": true, - "convenient": true, - "discounted": true + ] + }, + { + "uuid": "72d370ab-d8ca-4e41-b956-91df94795b4e", + "itemId": "Pouletbrüstli", + "specification": "Bio", + "attributes": [ + { + "type": "PURCHASE_CONDITIONS", + "content": { + "urgent": true, + "convenient": true, + "discounted": true + } } - } - ] - } - ], - "recently": [ - { - "uuid": "fc8db30a-647e-4e6c-9d71-3b85d6a2d954", - "itemId": "Ananas", - "specification": "", - "attributes": [] - } - ] + ] + } + ], + "recently": [ + { + "uuid": "fc8db30a-647e-4e6c-9d71-3b85d6a2d954", + "itemId": "Ananas", + "specification": "", + "attributes": [] + } + ] + } } diff --git a/tests/components/bring/fixtures/items_invitation.json b/tests/components/bring/fixtures/items_invitation.json index 82ef623e439..be3671c359a 100644 --- a/tests/components/bring/fixtures/items_invitation.json +++ b/tests/components/bring/fixtures/items_invitation.json @@ -1,44 +1,46 @@ { "uuid": "77a151f8-77c4-47a3-8295-c750a0e69d4f", "status": "INVITATION", - "purchase": [ - { - "uuid": "b5d0790b-5f32-4d5c-91da-e29066f167de", - "itemId": "Paprika", - "specification": "Rot", - "attributes": [ - { - "type": "PURCHASE_CONDITIONS", - "content": { - "urgent": true, - "convenient": true, - "discounted": true + "items": { + "purchase": [ + { + "uuid": "b5d0790b-5f32-4d5c-91da-e29066f167de", + "itemId": "Paprika", + "specification": "Rot", + "attributes": [ + { + "type": "PURCHASE_CONDITIONS", + "content": { + "urgent": true, + "convenient": true, + "discounted": true + } } - } - ] - }, - { - "uuid": "72d370ab-d8ca-4e41-b956-91df94795b4e", - "itemId": "Pouletbrüstli", - "specification": "Bio", - "attributes": [ - { - "type": "PURCHASE_CONDITIONS", - "content": { - "urgent": true, - "convenient": true, - "discounted": true + ] + }, + { + "uuid": "72d370ab-d8ca-4e41-b956-91df94795b4e", + "itemId": "Pouletbrüstli", + "specification": "Bio", + "attributes": [ + { + "type": "PURCHASE_CONDITIONS", + "content": { + "urgent": true, + "convenient": true, + "discounted": true + } } - } - ] - } - ], - "recently": [ - { - "uuid": "fc8db30a-647e-4e6c-9d71-3b85d6a2d954", - "itemId": "Ananas", - "specification": "", - "attributes": [] - } - ] + ] + } + ], + "recently": [ + { + "uuid": "fc8db30a-647e-4e6c-9d71-3b85d6a2d954", + "itemId": "Ananas", + "specification": "", + "attributes": [] + } + ] + } } diff --git a/tests/components/bring/fixtures/items_shared.json b/tests/components/bring/fixtures/items_shared.json index 9ac999729d3..5e381d27ca8 100644 --- a/tests/components/bring/fixtures/items_shared.json +++ b/tests/components/bring/fixtures/items_shared.json @@ -1,44 +1,46 @@ { "uuid": "77a151f8-77c4-47a3-8295-c750a0e69d4f", "status": "SHARED", - "purchase": [ - { - "uuid": "b5d0790b-5f32-4d5c-91da-e29066f167de", - "itemId": "Paprika", - "specification": "Rot", - "attributes": [ - { - "type": "PURCHASE_CONDITIONS", - "content": { - "urgent": true, - "convenient": true, - "discounted": true + "items": { + "purchase": [ + { + "uuid": "b5d0790b-5f32-4d5c-91da-e29066f167de", + "itemId": "Paprika", + "specification": "Rot", + "attributes": [ + { + "type": "PURCHASE_CONDITIONS", + "content": { + "urgent": true, + "convenient": true, + "discounted": true + } } - } - ] - }, - { - "uuid": "72d370ab-d8ca-4e41-b956-91df94795b4e", - "itemId": "Pouletbrüstli", - "specification": "Bio", - "attributes": [ - { - "type": "PURCHASE_CONDITIONS", - "content": { - "urgent": true, - "convenient": true, - "discounted": true + ] + }, + { + "uuid": "72d370ab-d8ca-4e41-b956-91df94795b4e", + "itemId": "Pouletbrüstli", + "specification": "Bio", + "attributes": [ + { + "type": "PURCHASE_CONDITIONS", + "content": { + "urgent": true, + "convenient": true, + "discounted": true + } } - } - ] - } - ], - "recently": [ - { - "uuid": "fc8db30a-647e-4e6c-9d71-3b85d6a2d954", - "itemId": "Ananas", - "specification": "", - "attributes": [] - } - ] + ] + } + ], + "recently": [ + { + "uuid": "fc8db30a-647e-4e6c-9d71-3b85d6a2d954", + "itemId": "Ananas", + "specification": "", + "attributes": [] + } + ] + } } diff --git a/tests/components/bring/fixtures/login.json b/tests/components/bring/fixtures/login.json new file mode 100644 index 00000000000..62616471734 --- /dev/null +++ b/tests/components/bring/fixtures/login.json @@ -0,0 +1,12 @@ +{ + "uuid": "4d717571-174a-4bc1-ab24-929c7227ca43", + "publicUuid": "9a21fdfc-63a4-441a-afc1-ef3030605a9d", + "email": "test-email", + "name": "Bring", + "photoPath": "", + "bringListUUID": "e542eef6-dba7-4c31-a52c-29e6ab9d83a5", + "access_token": "ACCESS_TOKEN", + "refresh_token": "REFRESH_TOKEN", + "token_type": "Bearer", + "expires_in": 604799 +} diff --git a/tests/components/bring/snapshots/test_diagnostics.ambr b/tests/components/bring/snapshots/test_diagnostics.ambr index 6d830a12133..5955ded832a 100644 --- a/tests/components/bring/snapshots/test_diagnostics.ambr +++ b/tests/components/bring/snapshots/test_diagnostics.ambr @@ -2,100 +2,112 @@ # name: test_diagnostics dict({ 'b4776778-7f6c-496e-951b-92a35d3db0dd': dict({ - 'listUuid': 'b4776778-7f6c-496e-951b-92a35d3db0dd', - 'name': 'Baumarkt', - 'purchase': list([ - dict({ - 'attributes': list([ + 'content': dict({ + 'items': dict({ + 'purchase': list([ dict({ - 'content': dict({ - 'convenient': True, - 'discounted': True, - 'urgent': True, - }), - 'type': 'PURCHASE_CONDITIONS', + 'attributes': list([ + dict({ + 'content': dict({ + 'convenient': True, + 'discounted': True, + 'urgent': True, + }), + 'type': 'PURCHASE_CONDITIONS', + }), + ]), + 'itemId': 'Paprika', + 'specification': 'Rot', + 'uuid': 'b5d0790b-5f32-4d5c-91da-e29066f167de', + }), + dict({ + 'attributes': list([ + dict({ + 'content': dict({ + 'convenient': True, + 'discounted': True, + 'urgent': True, + }), + 'type': 'PURCHASE_CONDITIONS', + }), + ]), + 'itemId': 'Pouletbrüstli', + 'specification': 'Bio', + 'uuid': '72d370ab-d8ca-4e41-b956-91df94795b4e', }), ]), - 'itemId': 'Paprika', - 'specification': 'Rot', - 'uuid': 'b5d0790b-5f32-4d5c-91da-e29066f167de', - }), - dict({ - 'attributes': list([ + 'recently': list([ dict({ - 'content': dict({ - 'convenient': True, - 'discounted': True, - 'urgent': True, - }), - 'type': 'PURCHASE_CONDITIONS', + 'attributes': list([ + ]), + 'itemId': 'Ananas', + 'specification': '', + 'uuid': 'fc8db30a-647e-4e6c-9d71-3b85d6a2d954', }), ]), - 'itemId': 'Pouletbrüstli', - 'specification': 'Bio', - 'uuid': '72d370ab-d8ca-4e41-b956-91df94795b4e', }), - ]), - 'recently': list([ - dict({ - 'attributes': list([ - ]), - 'itemId': 'Ananas', - 'specification': '', - 'uuid': 'fc8db30a-647e-4e6c-9d71-3b85d6a2d954', - }), - ]), - 'status': 'REGISTERED', - 'theme': 'ch.publisheria.bring.theme.home', - 'uuid': '77a151f8-77c4-47a3-8295-c750a0e69d4f', + 'status': 'REGISTERED', + 'uuid': '77a151f8-77c4-47a3-8295-c750a0e69d4f', + }), + 'lst': dict({ + 'listUuid': 'b4776778-7f6c-496e-951b-92a35d3db0dd', + 'name': 'Baumarkt', + 'theme': 'ch.publisheria.bring.theme.home', + }), }), 'e542eef6-dba7-4c31-a52c-29e6ab9d83a5': dict({ - 'listUuid': 'e542eef6-dba7-4c31-a52c-29e6ab9d83a5', - 'name': 'Einkauf', - 'purchase': list([ - dict({ - 'attributes': list([ + 'content': dict({ + 'items': dict({ + 'purchase': list([ dict({ - 'content': dict({ - 'convenient': True, - 'discounted': True, - 'urgent': True, - }), - 'type': 'PURCHASE_CONDITIONS', + 'attributes': list([ + dict({ + 'content': dict({ + 'convenient': True, + 'discounted': True, + 'urgent': True, + }), + 'type': 'PURCHASE_CONDITIONS', + }), + ]), + 'itemId': 'Paprika', + 'specification': 'Rot', + 'uuid': 'b5d0790b-5f32-4d5c-91da-e29066f167de', + }), + dict({ + 'attributes': list([ + dict({ + 'content': dict({ + 'convenient': True, + 'discounted': True, + 'urgent': True, + }), + 'type': 'PURCHASE_CONDITIONS', + }), + ]), + 'itemId': 'Pouletbrüstli', + 'specification': 'Bio', + 'uuid': '72d370ab-d8ca-4e41-b956-91df94795b4e', }), ]), - 'itemId': 'Paprika', - 'specification': 'Rot', - 'uuid': 'b5d0790b-5f32-4d5c-91da-e29066f167de', - }), - dict({ - 'attributes': list([ + 'recently': list([ dict({ - 'content': dict({ - 'convenient': True, - 'discounted': True, - 'urgent': True, - }), - 'type': 'PURCHASE_CONDITIONS', + 'attributes': list([ + ]), + 'itemId': 'Ananas', + 'specification': '', + 'uuid': 'fc8db30a-647e-4e6c-9d71-3b85d6a2d954', }), ]), - 'itemId': 'Pouletbrüstli', - 'specification': 'Bio', - 'uuid': '72d370ab-d8ca-4e41-b956-91df94795b4e', }), - ]), - 'recently': list([ - dict({ - 'attributes': list([ - ]), - 'itemId': 'Ananas', - 'specification': '', - 'uuid': 'fc8db30a-647e-4e6c-9d71-3b85d6a2d954', - }), - ]), - 'status': 'REGISTERED', - 'theme': 'ch.publisheria.bring.theme.home', - 'uuid': '77a151f8-77c4-47a3-8295-c750a0e69d4f', + 'status': 'REGISTERED', + 'uuid': '77a151f8-77c4-47a3-8295-c750a0e69d4f', + }), + 'lst': dict({ + 'listUuid': 'e542eef6-dba7-4c31-a52c-29e6ab9d83a5', + 'name': 'Einkauf', + 'theme': 'ch.publisheria.bring.theme.home', + }), }), }) # --- diff --git a/tests/components/bring/test_sensor.py b/tests/components/bring/test_sensor.py index 974818ccedf..442fea5a247 100644 --- a/tests/components/bring/test_sensor.py +++ b/tests/components/bring/test_sensor.py @@ -3,6 +3,7 @@ from collections.abc import Generator from unittest.mock import AsyncMock, patch +from bring_api import BringItemsResponse import pytest from syrupy.assertion import SnapshotAssertion @@ -12,7 +13,7 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from tests.common import MockConfigEntry, load_json_object_fixture, snapshot_platform +from tests.common import MockConfigEntry, load_fixture, snapshot_platform @pytest.fixture(autouse=True) @@ -62,10 +63,9 @@ async def test_list_access_states( ) -> None: """Snapshot test states of list access sensor.""" - mock_bring_client.get_list.return_value = load_json_object_fixture( - f"{fixture}.json", DOMAIN + mock_bring_client.get_list.return_value = BringItemsResponse.from_json( + load_fixture(f"{fixture}.json", DOMAIN) ) - bring_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(bring_config_entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/bring/test_util.py b/tests/components/bring/test_util.py index 88379530362..3060f31c134 100644 --- a/tests/components/bring/test_util.py +++ b/tests/components/bring/test_util.py @@ -1,15 +1,13 @@ """Test for utility functions of the Bring! integration.""" -from typing import cast - -from bring_api import BringUserSettingsResponse +from bring_api import BringItemsResponse, BringListResponse, BringUserSettingsResponse import pytest from homeassistant.components.bring.const import DOMAIN from homeassistant.components.bring.coordinator import BringData from homeassistant.components.bring.util import list_language, sum_attributes -from tests.common import load_json_object_fixture +from tests.common import load_fixture @pytest.mark.parametrize( @@ -17,7 +15,7 @@ from tests.common import load_json_object_fixture [ ("e542eef6-dba7-4c31-a52c-29e6ab9d83a5", "de-DE"), ("b4776778-7f6c-496e-951b-92a35d3db0dd", "en-US"), - ("00000000-0000-0000-0000-00000000", None), + ("00000000-0000-0000-0000-000000000000", None), ], ) def test_list_language(list_uuid: str, expected: str | None) -> None: @@ -25,10 +23,7 @@ def test_list_language(list_uuid: str, expected: str | None) -> None: result = list_language( list_uuid, - cast( - BringUserSettingsResponse, - load_json_object_fixture("usersettings.json", DOMAIN), - ), + BringUserSettingsResponse.from_json(load_fixture("usersettings.json", DOMAIN)), ) assert result == expected @@ -44,12 +39,11 @@ def test_list_language(list_uuid: str, expected: str | None) -> None: ) def test_sum_attributes(attribute: str, expected: int) -> None: """Test function sum_attributes.""" + items = BringItemsResponse.from_json(load_fixture("items.json", DOMAIN)) + lst = BringListResponse.from_json(load_fixture("lists.json", DOMAIN)) result = sum_attributes( - cast( - BringData, - load_json_object_fixture("items.json", DOMAIN), - ), + BringData(lst.lists[0], items), attribute, ) From 1b78bbaaabebb57a7f7f34ac287b0943f97a6e70 Mon Sep 17 00:00:00 2001 From: IceBotYT <34712694+IceBotYT@users.noreply.github.com> Date: Tue, 28 Jan 2025 07:25:54 -0500 Subject: [PATCH 0116/3148] Bump nice-go to 1.0.1 (#136649) --- homeassistant/components/nice_go/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/nice_go/manifest.json b/homeassistant/components/nice_go/manifest.json index 1af23ec4d9b..8f43ed8a3e8 100644 --- a/homeassistant/components/nice_go/manifest.json +++ b/homeassistant/components/nice_go/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "cloud_push", "loggers": ["nice_go"], - "requirements": ["nice-go==1.0.0"] + "requirements": ["nice-go==1.0.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index f154531e83b..97d3a5402a3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1489,7 +1489,7 @@ nhc==0.3.9 nibe==2.14.0 # homeassistant.components.nice_go -nice-go==1.0.0 +nice-go==1.0.1 # homeassistant.components.nilu niluclient==0.1.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9be1cd9dab5..3e91e456224 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1252,7 +1252,7 @@ nhc==0.3.9 nibe==2.14.0 # homeassistant.components.nice_go -nice-go==1.0.0 +nice-go==1.0.1 # homeassistant.components.nfandroidtv notifications-android-tv==0.1.5 From be7a7c94f6cb11227df01a3ebf5cadd62a5453fe Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Tue, 28 Jan 2025 13:43:31 +0100 Subject: [PATCH 0117/3148] Remove unused function in hassio/update (#136701) --- homeassistant/components/hassio/update.py | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/homeassistant/components/hassio/update.py b/homeassistant/components/hassio/update.py index 17b0a5bc9ca..8e0585892f5 100644 --- a/homeassistant/components/hassio/update.py +++ b/homeassistant/components/hassio/update.py @@ -4,7 +4,7 @@ from __future__ import annotations from typing import Any -from aiohasupervisor import SupervisorClient, SupervisorError +from aiohasupervisor import SupervisorError from aiohasupervisor.models import OSUpdate from awesomeversion import AwesomeVersion, AwesomeVersionStrategy @@ -297,10 +297,3 @@ class SupervisorCoreUpdateEntity(HassioCoreEntity, UpdateEntity): ) -> None: """Install an update.""" await update_core(self.hass, version, backup) - - -async def _default_agent(client: SupervisorClient) -> str: - """Return the default agent for creating a backup.""" - mounts = await client.mounts.info() - default_mount = mounts.default_backup_mount - return f"hassio.{default_mount if default_mount is not None else 'local'}" From b1abf50a31b82220676860314c0af6053f3f8afe Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 28 Jan 2025 13:48:28 +0100 Subject: [PATCH 0118/3148] Tag backups created when updating addon with supervisor.addon_update (#136690) --- homeassistant/components/backup/manager.py | 9 +- homeassistant/components/hassio/backup.py | 1 + tests/components/backup/test_manager.py | 126 +++++++++++++++++- tests/components/hassio/test_update.py | 3 + tests/components/hassio/test_websocket_api.py | 3 + 5 files changed, 139 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index 8c8cd805565..f4ea27ca5f1 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -672,6 +672,7 @@ class BackupManager: self, *, agent_ids: list[str], + extra_metadata: dict[str, bool | str] | None = None, include_addons: list[str] | None, include_all_addons: bool, include_database: bool, @@ -684,6 +685,7 @@ class BackupManager: """Create a backup.""" new_backup = await self.async_initiate_backup( agent_ids=agent_ids, + extra_metadata=extra_metadata, include_addons=include_addons, include_all_addons=include_all_addons, include_database=include_database, @@ -717,6 +719,7 @@ class BackupManager: self, *, agent_ids: list[str], + extra_metadata: dict[str, bool | str] | None = None, include_addons: list[str] | None, include_all_addons: bool, include_database: bool, @@ -741,6 +744,7 @@ class BackupManager: try: return await self._async_create_backup( agent_ids=agent_ids, + extra_metadata=extra_metadata, include_addons=include_addons, include_all_addons=include_all_addons, include_database=include_database, @@ -764,6 +768,7 @@ class BackupManager: self, *, agent_ids: list[str], + extra_metadata: dict[str, bool | str] | None, include_addons: list[str] | None, include_all_addons: bool, include_database: bool, @@ -790,6 +795,7 @@ class BackupManager: name or f"{'Automatic' if with_automatic_settings else 'Custom'} backup {HAVERSION}" ) + extra_metadata = extra_metadata or {} try: ( @@ -798,7 +804,8 @@ class BackupManager: ) = await self._reader_writer.async_create_backup( agent_ids=agent_ids, backup_name=backup_name, - extra_metadata={ + extra_metadata=extra_metadata + | { "instance_id": await instance_id.async_get(self.hass), "with_automatic_settings": with_automatic_settings, }, diff --git a/homeassistant/components/hassio/backup.py b/homeassistant/components/hassio/backup.py index 98ad2ad20e3..4a9bfaded15 100644 --- a/homeassistant/components/hassio/backup.py +++ b/homeassistant/components/hassio/backup.py @@ -509,6 +509,7 @@ async def backup_addon_before_update( try: await backup_manager.async_create_backup( agent_ids=[await _default_agent(client)], + extra_metadata={"supervisor.addon_update": addon}, include_addons=[addon], include_all_addons=False, include_database=False, diff --git a/tests/components/backup/test_manager.py b/tests/components/backup/test_manager.py index 8a99f90d234..c6eeff79d45 100644 --- a/tests/components/backup/test_manager.py +++ b/tests/components/backup/test_manager.py @@ -90,13 +90,13 @@ def generate_backup_id_fixture() -> Generator[MagicMock]: @pytest.mark.usefixtures("mock_backup_generation") -async def test_async_create_backup( +async def test_create_backup_service( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, mocked_json_bytes: Mock, mocked_tarfile: Mock, ) -> None: - """Test create backup.""" + """Test create backup service.""" assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() @@ -137,6 +137,128 @@ async def test_async_create_backup( ) +@pytest.mark.usefixtures("mock_backup_generation") +@pytest.mark.parametrize( + ("manager_kwargs", "expected_writer_kwargs"), + [ + ( + { + "agent_ids": ["backup.local"], + "include_addons": None, + "include_all_addons": False, + "include_database": True, + "include_folders": None, + "include_homeassistant": True, + "name": None, + "password": None, + }, + { + "agent_ids": ["backup.local"], + "backup_name": "Custom backup 2025.1.0", + "extra_metadata": { + "instance_id": ANY, + "with_automatic_settings": False, + }, + "include_addons": None, + "include_all_addons": False, + "include_database": True, + "include_folders": None, + "include_homeassistant": True, + "on_progress": ANY, + "password": None, + }, + ), + ( + { + "agent_ids": ["backup.local"], + "include_addons": None, + "include_all_addons": False, + "include_database": True, + "include_folders": None, + "include_homeassistant": True, + "name": None, + "password": None, + "with_automatic_settings": True, + }, + { + "agent_ids": ["backup.local"], + "backup_name": "Automatic backup 2025.1.0", + "extra_metadata": { + "instance_id": ANY, + "with_automatic_settings": True, + }, + "include_addons": None, + "include_all_addons": False, + "include_database": True, + "include_folders": None, + "include_homeassistant": True, + "on_progress": ANY, + "password": None, + }, + ), + ( + { + "agent_ids": ["backup.local"], + "extra_metadata": {"custom": "data"}, + "include_addons": None, + "include_all_addons": False, + "include_database": True, + "include_folders": None, + "include_homeassistant": True, + "name": None, + "password": None, + }, + { + "agent_ids": ["backup.local"], + "backup_name": "Custom backup 2025.1.0", + "extra_metadata": { + "custom": "data", + "instance_id": ANY, + "with_automatic_settings": False, + }, + "include_addons": None, + "include_all_addons": False, + "include_database": True, + "include_folders": None, + "include_homeassistant": True, + "on_progress": ANY, + "password": None, + }, + ), + ], +) +async def test_async_create_backup( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + mocked_json_bytes: Mock, + mocked_tarfile: Mock, + manager_kwargs: dict[str, Any], + expected_writer_kwargs: dict[str, Any], +) -> None: + """Test create backup.""" + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + manager = hass.data[DATA_MANAGER] + + new_backup = NewBackup(backup_job_id="time-123") + backup_task = AsyncMock( + return_value=WrittenBackup( + backup=TEST_BACKUP_ABC123, + open_stream=AsyncMock(), + release_stream=AsyncMock(), + ), + )() # call it so that it can be awaited + + with patch( + "homeassistant.components.backup.manager.CoreBackupReaderWriter.async_create_backup", + return_value=(new_backup, backup_task), + ) as create_backup: + await manager.async_create_backup(**manager_kwargs) + + assert create_backup.called + assert create_backup.call_args == call(**expected_writer_kwargs) + + @pytest.mark.usefixtures("mock_backup_generation") async def test_create_backup_when_busy( hass: HomeAssistant, diff --git a/tests/components/hassio/test_update.py b/tests/components/hassio/test_update.py index 88d7076824f..732b2655107 100644 --- a/tests/components/hassio/test_update.py +++ b/tests/components/hassio/test_update.py @@ -240,6 +240,7 @@ async def test_update_addon(hass: HomeAssistant, update_addon: AsyncMock) -> Non None, { "agent_ids": ["hassio.local"], + "extra_metadata": {"supervisor.addon_update": "test"}, "include_addons": ["test"], "include_all_addons": False, "include_database": False, @@ -254,6 +255,7 @@ async def test_update_addon(hass: HomeAssistant, update_addon: AsyncMock) -> Non "my_nas", { "agent_ids": ["hassio.my_nas"], + "extra_metadata": {"supervisor.addon_update": "test"}, "include_addons": ["test"], "include_all_addons": False, "include_database": False, @@ -281,6 +283,7 @@ async def test_update_addon(hass: HomeAssistant, update_addon: AsyncMock) -> Non None, { "agent_ids": ["hassio.local"], + "extra_metadata": {"supervisor.addon_update": "test"}, "include_addons": ["test"], "include_all_addons": False, "include_database": False, diff --git a/tests/components/hassio/test_websocket_api.py b/tests/components/hassio/test_websocket_api.py index 1fefe54ad75..ab8dc1475e2 100644 --- a/tests/components/hassio/test_websocket_api.py +++ b/tests/components/hassio/test_websocket_api.py @@ -360,6 +360,7 @@ async def test_update_addon( None, { "agent_ids": ["hassio.local"], + "extra_metadata": {"supervisor.addon_update": "test"}, "include_addons": ["test"], "include_all_addons": False, "include_database": False, @@ -374,6 +375,7 @@ async def test_update_addon( "my_nas", { "agent_ids": ["hassio.my_nas"], + "extra_metadata": {"supervisor.addon_update": "test"}, "include_addons": ["test"], "include_all_addons": False, "include_database": False, @@ -401,6 +403,7 @@ async def test_update_addon( None, { "agent_ids": ["hassio.local"], + "extra_metadata": {"supervisor.addon_update": "test"}, "include_addons": ["test"], "include_all_addons": False, "include_database": False, From e120a7b59c786dc2f58c9d1a76f0523f8d252cc0 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 28 Jan 2025 13:48:42 +0100 Subject: [PATCH 0119/3148] Fix deadlock in WS command backup/can_decrypt_on_download (#136707) --- homeassistant/components/backup/manager.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index f4ea27ca5f1..99740428863 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -1055,7 +1055,9 @@ class BackupManager: backup_stream = await agent.async_download_backup(backup_id) reader = cast(IO[bytes], AsyncIteratorReader(self.hass, backup_stream)) try: - validate_password_stream(reader, password) + await self.hass.async_add_executor_job( + validate_password_stream, reader, password + ) except backup_util.IncorrectPassword as err: raise IncorrectPasswordError from err except backup_util.UnsupportedSecureTarVersion as err: From 5a52c775235bf8e656e9439518b9f3246e55fb7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=85ke=20Strandberg?= Date: Tue, 28 Jan 2025 13:48:58 +0100 Subject: [PATCH 0120/3148] Add test for myuplink DeviceInfo (#136360) --- .../myuplink/fixtures/device-alfred.json | 40 ++++++++ .../myuplink/fixtures/device-batman.json | 40 ++++++++ .../myuplink/fixtures/device-robin.json | 40 ++++++++ .../myuplink/fixtures/systems-multi.json | 61 ++++++++++++ .../myuplink/snapshots/test_init.ambr | 97 +++++++++++++++++++ tests/components/myuplink/test_init.py | 45 +++++++++ 6 files changed, 323 insertions(+) create mode 100644 tests/components/myuplink/fixtures/device-alfred.json create mode 100644 tests/components/myuplink/fixtures/device-batman.json create mode 100644 tests/components/myuplink/fixtures/device-robin.json create mode 100644 tests/components/myuplink/fixtures/systems-multi.json create mode 100644 tests/components/myuplink/snapshots/test_init.ambr diff --git a/tests/components/myuplink/fixtures/device-alfred.json b/tests/components/myuplink/fixtures/device-alfred.json new file mode 100644 index 00000000000..ca6f91459f6 --- /dev/null +++ b/tests/components/myuplink/fixtures/device-alfred.json @@ -0,0 +1,40 @@ +{ + "id": "alfred-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff", + "connectionState": "Connected", + "firmware": { + "currentFwVersion": "9682R7A", + "desiredFwVersion": "9682R7A" + }, + "product": { + "serialNumber": "10001", + "name": "Tehowatti Air" + }, + "availableFeatures": { + "settings": true, + "reboot": true, + "forcesync": true, + "forceUpdate": false, + "requestUpdate": false, + "resetAlarm": true, + "triggerEvent": true, + "getMenu": false, + "getMenuChain": false, + "getGuideQuestion": false, + "sendHaystack": true, + "setSmartMode": false, + "setAidMode": true, + "getZones": false, + "processIntent": false, + "boostHotWater": true, + "boostVentilation": true, + "getScheduleConfig": false, + "getScheduleModes": false, + "getScheduleWeekly": false, + "getScheduleVacation": false, + "setScheduleModes": false, + "setScheduleWeekly": false, + "setScheduleOverride": false, + "setScheduleVacation": false, + "setVentilationMode": false + } +} diff --git a/tests/components/myuplink/fixtures/device-batman.json b/tests/components/myuplink/fixtures/device-batman.json new file mode 100644 index 00000000000..f7c079be5dd --- /dev/null +++ b/tests/components/myuplink/fixtures/device-batman.json @@ -0,0 +1,40 @@ +{ + "id": "batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff", + "connectionState": "Connected", + "firmware": { + "currentFwVersion": "9682R7B", + "desiredFwVersion": "9682R7B" + }, + "product": { + "serialNumber": "10002", + "name": "F730 CU 3x400V" + }, + "availableFeatures": { + "settings": true, + "reboot": true, + "forcesync": true, + "forceUpdate": false, + "requestUpdate": false, + "resetAlarm": true, + "triggerEvent": true, + "getMenu": false, + "getMenuChain": false, + "getGuideQuestion": false, + "sendHaystack": true, + "setSmartMode": false, + "setAidMode": true, + "getZones": false, + "processIntent": false, + "boostHotWater": true, + "boostVentilation": true, + "getScheduleConfig": false, + "getScheduleModes": false, + "getScheduleWeekly": false, + "getScheduleVacation": false, + "setScheduleModes": false, + "setScheduleWeekly": false, + "setScheduleOverride": false, + "setScheduleVacation": false, + "setVentilationMode": false + } +} diff --git a/tests/components/myuplink/fixtures/device-robin.json b/tests/components/myuplink/fixtures/device-robin.json new file mode 100644 index 00000000000..3155d6e3f70 --- /dev/null +++ b/tests/components/myuplink/fixtures/device-robin.json @@ -0,0 +1,40 @@ +{ + "id": "robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff", + "connectionState": "Connected", + "firmware": { + "currentFwVersion": "9682R7C", + "desiredFwVersion": "9682R7C" + }, + "product": { + "serialNumber": "10003", + "name": "SMO 20" + }, + "availableFeatures": { + "settings": true, + "reboot": true, + "forcesync": true, + "forceUpdate": false, + "requestUpdate": false, + "resetAlarm": true, + "triggerEvent": true, + "getMenu": false, + "getMenuChain": false, + "getGuideQuestion": false, + "sendHaystack": true, + "setSmartMode": false, + "setAidMode": true, + "getZones": false, + "processIntent": false, + "boostHotWater": true, + "boostVentilation": true, + "getScheduleConfig": false, + "getScheduleModes": false, + "getScheduleWeekly": false, + "getScheduleVacation": false, + "setScheduleModes": false, + "setScheduleWeekly": false, + "setScheduleOverride": false, + "setScheduleVacation": false, + "setVentilationMode": false + } +} diff --git a/tests/components/myuplink/fixtures/systems-multi.json b/tests/components/myuplink/fixtures/systems-multi.json new file mode 100644 index 00000000000..a587900d23c --- /dev/null +++ b/tests/components/myuplink/fixtures/systems-multi.json @@ -0,0 +1,61 @@ +{ + "page": 1, + "itemsPerPage": 10, + "numItems": 3, + "systems": [ + { + "systemId": "123456-7890-1234", + "name": "Gotham City", + "securityLevel": "admin", + "hasAlarm": false, + "country": "Sweden", + "devices": [ + { + "id": "alfred-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff", + "connectionState": "Connected", + "currentFwVersion": "9682R7A", + "product": { + "serialNumber": "10001", + "name": "Tehowatti Air" + } + } + ] + }, + { + "systemId": "123456-7890-1234", + "name": "Batcave", + "securityLevel": "admin", + "hasAlarm": false, + "country": "Sweden", + "devices": [ + { + "id": "batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff", + "connectionState": "Connected", + "currentFwVersion": "9682R7B", + "product": { + "serialNumber": "10002", + "name": "F730 CU 3x400V" + } + } + ] + }, + { + "systemId": "123456-7890-1234", + "name": "Duckburg", + "securityLevel": "admin", + "hasAlarm": false, + "country": "Sweden", + "devices": [ + { + "id": "robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff", + "connectionState": "Connected", + "currentFwVersion": "9682R7C", + "product": { + "serialNumber": "10003", + "name": "SM0 20" + } + } + ] + } + ] +} diff --git a/tests/components/myuplink/snapshots/test_init.ambr b/tests/components/myuplink/snapshots/test_init.ambr new file mode 100644 index 00000000000..42ed9c20669 --- /dev/null +++ b/tests/components/myuplink/snapshots/test_init.ambr @@ -0,0 +1,97 @@ +# serializer version: 1 +# name: test_device_info[alfred-multi] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'myuplink', + 'alfred-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Jäspi', + 'model': 'Tehowatti Air', + 'model_id': None, + 'name': 'Gotham City', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '10001', + 'suggested_area': None, + 'sw_version': '9682R7A', + 'via_device_id': None, + }) +# --- +# name: test_device_info[batman-multi] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'myuplink', + 'batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Nibe', + 'model': 'F730', + 'model_id': None, + 'name': 'Batcave', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '10002', + 'suggested_area': None, + 'sw_version': '9682R7B', + 'via_device_id': None, + }) +# --- +# name: test_device_info[robin-multi] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'myuplink', + 'robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Nibe', + 'model': 'SMO 20', + 'model_id': None, + 'name': 'Duckburg', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '10003', + 'suggested_area': None, + 'sw_version': '9682R7C', + 'via_device_id': None, + }) +# --- diff --git a/tests/components/myuplink/test_init.py b/tests/components/myuplink/test_init.py index fda0d3526f9..320bf202024 100644 --- a/tests/components/myuplink/test_init.py +++ b/tests/components/myuplink/test_init.py @@ -6,6 +6,7 @@ from unittest.mock import MagicMock from aiohttp import ClientConnectionError import pytest +from syrupy import SnapshotAssertion from homeassistant.components.myuplink.const import DOMAIN, OAUTH2_TOKEN from homeassistant.config_entries import ConfigEntryState @@ -214,3 +215,47 @@ async def test_device_remove_devices( old_device_entry.id, mock_config_entry.entry_id ) assert response["success"] + + +@pytest.mark.parametrize( + "load_systems_file", + [load_fixture("systems-multi.json", DOMAIN)], + ids=[ + "multi", + ], +) +@pytest.mark.parametrize( + ("load_device_file", "device_id"), + [ + ( + load_fixture("device-alfred.json", DOMAIN), + "alfred-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff", + ), + ( + load_fixture("device-batman.json", DOMAIN), + "batman-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff", + ), + ( + load_fixture("device-robin.json", DOMAIN), + "robin-r-1234-20240201-123456-aa-bb-cc-dd-ee-ff", + ), + ], + ids=[ + "alfred", + "batman", + "robin", + ], +) +async def test_device_info( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_myuplink_client: MagicMock, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, + device_id: str, +) -> None: + """Test device registry integration.""" + await setup_integration(hass, mock_config_entry) + device_entry = device_registry.async_get_device(identifiers={(DOMAIN, device_id)}) + assert device_entry is not None + assert device_entry == snapshot From 6278d36981285ffe932463f606ff7ca9d7987432 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 28 Jan 2025 13:49:49 +0100 Subject: [PATCH 0121/3148] Use HassKey in diagnostics (#136627) --- homeassistant/components/diagnostics/__init__.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/diagnostics/__init__.py b/homeassistant/components/diagnostics/__init__.py index b23b7cef2bd..7bc43f2c3f5 100644 --- a/homeassistant/components/diagnostics/__init__.py +++ b/homeassistant/components/diagnostics/__init__.py @@ -33,6 +33,7 @@ from homeassistant.loader import ( async_get_integration, ) from homeassistant.setup import async_get_domain_setup_times +from homeassistant.util.hass_dict import HassKey from homeassistant.util.json import format_unserializable_data from .const import DOMAIN, REDACTED, DiagnosticsSubType, DiagnosticsType @@ -44,6 +45,7 @@ _LOGGER = logging.getLogger(__name__) CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) +_DIAGNOSTICS_DATA: HassKey[DiagnosticsData] = HassKey(DOMAIN) @dataclass(slots=True) @@ -72,7 +74,7 @@ class DiagnosticsData: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Diagnostics from a config entry.""" - hass.data[DOMAIN] = DiagnosticsData() + hass.data[_DIAGNOSTICS_DATA] = DiagnosticsData() await integration_platform.async_process_integration_platforms( hass, DOMAIN, _register_diagnostics_platform @@ -104,7 +106,7 @@ def _register_diagnostics_platform( hass: HomeAssistant, integration_domain: str, platform: DiagnosticsProtocol ) -> None: """Register a diagnostics platform.""" - diagnostics_data: DiagnosticsData = hass.data[DOMAIN] + diagnostics_data = hass.data[_DIAGNOSTICS_DATA] diagnostics_data.platforms[integration_domain] = DiagnosticsPlatformData( getattr(platform, "async_get_config_entry_diagnostics", None), getattr(platform, "async_get_device_diagnostics", None), @@ -118,7 +120,7 @@ def handle_info( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict ) -> None: """List all possible diagnostic handlers.""" - diagnostics_data: DiagnosticsData = hass.data[DOMAIN] + diagnostics_data = hass.data[_DIAGNOSTICS_DATA] result = [ { "domain": domain, @@ -145,7 +147,7 @@ def handle_get( ) -> None: """List all diagnostic handlers for a domain.""" domain = msg["domain"] - diagnostics_data: DiagnosticsData = hass.data[DOMAIN] + diagnostics_data = hass.data[_DIAGNOSTICS_DATA] if (info := diagnostics_data.platforms.get(domain)) is None: connection.send_error( @@ -267,7 +269,7 @@ class DownloadDiagnosticsView(http.HomeAssistantView): if (config_entry := hass.config_entries.async_get_entry(d_id)) is None: return web.Response(status=HTTPStatus.NOT_FOUND) - diagnostics_data: DiagnosticsData = hass.data[DOMAIN] + diagnostics_data = hass.data[_DIAGNOSTICS_DATA] if (info := diagnostics_data.platforms.get(config_entry.domain)) is None: return web.Response(status=HTTPStatus.NOT_FOUND) From c2da844f76ddc696bd313357ee9aff697b3fb752 Mon Sep 17 00:00:00 2001 From: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> Date: Tue, 28 Jan 2025 07:02:15 -0600 Subject: [PATCH 0122/3148] Add HEOS diagnostics (#136663) --- homeassistant/components/heos/coordinator.py | 5 + homeassistant/components/heos/diagnostics.py | 90 +++++ .../components/heos/quality_scale.yaml | 2 +- tests/components/heos/conftest.py | 57 ++- .../heos/snapshots/test_diagnostics.ambr | 371 ++++++++++++++++++ .../heos/snapshots/test_media_player.ambr | 6 +- tests/components/heos/test_diagnostics.py | 98 +++++ 7 files changed, 610 insertions(+), 19 deletions(-) create mode 100644 homeassistant/components/heos/diagnostics.py create mode 100644 tests/components/heos/snapshots/test_diagnostics.ambr create mode 100644 tests/components/heos/test_diagnostics.py diff --git a/homeassistant/components/heos/coordinator.py b/homeassistant/components/heos/coordinator.py index ee0aeb3f165..dd0e0a19d0b 100644 --- a/homeassistant/components/heos/coordinator.py +++ b/homeassistant/components/heos/coordinator.py @@ -68,6 +68,11 @@ class HeosCoordinator(DataUpdateCoordinator[None]): """Get input sources across all devices.""" return self._inputs + @property + def favorites(self) -> dict[int, MediaItem]: + """Get favorite stations.""" + return self._favorites + async def async_setup(self) -> None: """Set up the coordinator; connect to the host; and retrieve initial data.""" # Add before connect as it may occur during initial connection diff --git a/homeassistant/components/heos/diagnostics.py b/homeassistant/components/heos/diagnostics.py new file mode 100644 index 00000000000..bf33fc9bc15 --- /dev/null +++ b/homeassistant/components/heos/diagnostics.py @@ -0,0 +1,90 @@ +"""Define the HEOS integration diagnostics module.""" + +from collections.abc import Mapping, Sequence +import dataclasses +from typing import Any + +from pyheos import HeosError + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.device_registry import DeviceEntry + +from .const import ATTR_PASSWORD, ATTR_USERNAME, DOMAIN +from .coordinator import HeosConfigEntry + +TO_REDACT = [ + ATTR_PASSWORD, + ATTR_USERNAME, + "signed_in_username", + "serial", + "serial_number", +] + + +def _as_dict( + data: Any, redact: bool = False +) -> Mapping[str, Any] | Sequence[Any] | Any: + """Convert dataclasses to dicts within various data structures.""" + if dataclasses.is_dataclass(data): + data_dict = dataclasses.asdict(data) # type: ignore[arg-type] + return data_dict if not redact else async_redact_data(data_dict, TO_REDACT) + if not isinstance(data, (Mapping, Sequence)): + return data + if isinstance(data, Sequence): + return [_as_dict(val) for val in data] + return {k: _as_dict(v) for k, v in data.items()} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, config_entry: HeosConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + coordinator = config_entry.runtime_data + diagnostics = { + "config_entry": async_redact_data(config_entry.as_dict(), TO_REDACT), + "heos": { + "connection_state": coordinator.heos.connection_state, + "current_credentials": _as_dict( + coordinator.heos.current_credentials, redact=True + ), + }, + "groups": _as_dict(coordinator.heos.groups), + "source_list": coordinator.async_get_source_list(), + "inputs": _as_dict(coordinator.inputs), + "favorites": _as_dict(coordinator.favorites), + } + # Try getting system information + try: + system_info = await coordinator.heos.get_system_info() + except HeosError as err: + diagnostics["system"] = {"error": str(err)} + else: + diagnostics["system"] = _as_dict(system_info, redact=True) + return diagnostics + + +async def async_get_device_diagnostics( + hass: HomeAssistant, config_entry: HeosConfigEntry, device: DeviceEntry +) -> dict[str, Any]: + """Return diagnostics for a device.""" + entity_registry = er.async_get(hass) + entities = entity_registry.entities.get_entries_for_device_id(device.id, True) + player_id = next( + int(value) for domain, value in device.identifiers if domain == DOMAIN + ) + player = config_entry.runtime_data.heos.players.get(player_id) + return { + "device": async_redact_data(device.dict_repr, TO_REDACT), + "entities": [ + { + "entity": entity.as_partial_dict, + "state": state.as_dict() + if (state := hass.states.get(entity.entity_id)) + else None, + } + for entity in entities + ], + "player": _as_dict(player, redact=True), + } diff --git a/homeassistant/components/heos/quality_scale.yaml b/homeassistant/components/heos/quality_scale.yaml index d48bcc492cd..cc110c627f0 100644 --- a/homeassistant/components/heos/quality_scale.yaml +++ b/homeassistant/components/heos/quality_scale.yaml @@ -37,7 +37,7 @@ rules: comment: 99% test coverage # Gold devices: done - diagnostics: todo + diagnostics: done discovery-update-info: status: todo comment: Explore if this is possible. diff --git a/tests/components/heos/conftest.py b/tests/components/heos/conftest.py index 122467c6b02..5312b8295ed 100644 --- a/tests/components/heos/conftest.py +++ b/tests/components/heos/conftest.py @@ -6,11 +6,13 @@ from collections.abc import AsyncIterator from unittest.mock import AsyncMock, Mock, patch from pyheos import ( - CONTROLS_ALL, Heos, HeosGroup, + HeosHost, + HeosNowPlayingMedia, HeosOptions, HeosPlayer, + HeosSystem, LineOutLevelType, MediaItem, MediaType, @@ -98,6 +100,33 @@ async def controller_fixture( yield mock_heos +@pytest.fixture(name="system") +def system_info_fixture() -> dict[str, str]: + """Create a system info fixture.""" + return HeosSystem( + "user@user.com", + "127.0.0.1", + hosts=[ + HeosHost( + "Test Player", + "HEOS Drive HS2", + "123456", + "1.0.0", + "127.0.0.1", + NetworkType.WIRED, + ), + HeosHost( + "Test Player 2", + "Speaker", + "123456", + "1.0.0", + "127.0.0.2", + NetworkType.WIFI, + ), + ], + ) + + @pytest.fixture(name="players") def players_fixture(quick_selects: dict[int, str]) -> dict[int, HeosPlayer]: """Create two mock HeosPlayers.""" @@ -121,20 +150,18 @@ def players_fixture(quick_selects: dict[int, str]) -> dict[int, HeosPlayer]: volume=25, heos=None, ) - player.now_playing_media = Mock() - player.now_playing_media.supported_controls = CONTROLS_ALL - player.now_playing_media.album_id = 1 - player.now_playing_media.queue_id = 1 - player.now_playing_media.source_id = 1 - player.now_playing_media.station = "Station Name" - player.now_playing_media.type = "Station" - player.now_playing_media.album = "Album" - player.now_playing_media.artist = "Artist" - player.now_playing_media.media_id = "1" - player.now_playing_media.duration = None - player.now_playing_media.current_position = None - player.now_playing_media.image_url = "http://" - player.now_playing_media.song = "Song" + player.now_playing_media = HeosNowPlayingMedia( + type=MediaType.STATION, + song="Song", + station="Station Name", + album="Album", + artist="Artist", + image_url="http://", + album_id="1", + media_id="1", + queue_id=1, + source_id=10, + ) player.add_to_queue = AsyncMock() player.clear_queue = AsyncMock() player.get_quick_selects = AsyncMock(return_value=quick_selects) diff --git a/tests/components/heos/snapshots/test_diagnostics.ambr b/tests/components/heos/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..6de0a645f17 --- /dev/null +++ b/tests/components/heos/snapshots/test_diagnostics.ambr @@ -0,0 +1,371 @@ +# serializer version: 1 +# name: test_config_entry_diagnostics + dict({ + 'config_entry': dict({ + 'data': dict({ + 'host': '127.0.0.1', + }), + 'disabled_by': None, + 'discovery_keys': dict({ + }), + 'domain': 'heos', + 'minor_version': 1, + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'title': 'HEOS System (via 127.0.0.1)', + 'unique_id': 'heos', + 'version': 1, + }), + 'favorites': dict({ + '1': dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'browsable': False, + 'container_id': None, + 'image_url': '', + 'media_id': '123456789', + 'name': "Today's Hits Radio", + 'playable': True, + 'source_id': 1, + 'type': 'station', + }), + '2': dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'browsable': False, + 'container_id': None, + 'image_url': '', + 'media_id': 's1234', + 'name': 'Classical MPR (Classical Music)', + 'playable': True, + 'source_id': 3, + 'type': 'station', + }), + }), + 'groups': dict({ + '999': dict({ + 'group_id': 999, + 'is_muted': False, + 'lead_player_id': 1, + 'member_player_ids': list([ + 2, + ]), + 'name': 'Group', + 'volume': 0, + }), + }), + 'heos': dict({ + 'connection_state': 'disconnected', + 'current_credentials': None, + }), + 'inputs': list([ + dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'browsable': False, + 'container_id': None, + 'image_url': '', + 'media_id': 'inputs/aux_in_1', + 'name': 'HEOS Drive - Line In 1', + 'playable': True, + 'source_id': 1027, + 'type': 'station', + }), + dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'browsable': False, + 'container_id': None, + 'image_url': '', + 'media_id': 'inputs/aux_in_1', + 'name': 'Speaker - Line In 1', + 'playable': True, + 'source_id': 1027, + 'type': 'station', + }), + ]), + 'source_list': list([ + "Today's Hits Radio", + 'Classical MPR (Classical Music)', + 'HEOS Drive - Line In 1', + 'Speaker - Line In 1', + ]), + 'system': dict({ + 'connected_to_preferred_host': False, + 'host': '127.0.0.1', + 'hosts': list([ + dict({ + 'ip_address': '127.0.0.1', + 'model': 'HEOS Drive HS2', + 'name': 'Test Player', + 'network': 'wired', + 'serial': '**REDACTED**', + 'version': '1.0.0', + }), + dict({ + 'ip_address': '127.0.0.2', + 'model': 'Speaker', + 'name': 'Test Player 2', + 'network': 'wifi', + 'serial': '**REDACTED**', + 'version': '1.0.0', + }), + ]), + 'is_signed_in': True, + 'preferred_hosts': list([ + dict({ + 'ip_address': '127.0.0.1', + 'model': 'HEOS Drive HS2', + 'name': 'Test Player', + 'network': 'wired', + 'serial': '**REDACTED**', + 'version': '1.0.0', + }), + ]), + 'signed_in_username': '**REDACTED**', + }), + }) +# --- +# name: test_config_entry_diagnostics_error_getting_system + dict({ + 'config_entry': dict({ + 'data': dict({ + 'host': '127.0.0.1', + }), + 'disabled_by': None, + 'discovery_keys': dict({ + }), + 'domain': 'heos', + 'minor_version': 1, + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'title': 'HEOS System (via 127.0.0.1)', + 'unique_id': 'heos', + 'version': 1, + }), + 'favorites': dict({ + '1': dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'browsable': False, + 'container_id': None, + 'image_url': '', + 'media_id': '123456789', + 'name': "Today's Hits Radio", + 'playable': True, + 'source_id': 1, + 'type': 'station', + }), + '2': dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'browsable': False, + 'container_id': None, + 'image_url': '', + 'media_id': 's1234', + 'name': 'Classical MPR (Classical Music)', + 'playable': True, + 'source_id': 3, + 'type': 'station', + }), + }), + 'groups': dict({ + '999': dict({ + 'group_id': 999, + 'is_muted': False, + 'lead_player_id': 1, + 'member_player_ids': list([ + 2, + ]), + 'name': 'Group', + 'volume': 0, + }), + }), + 'heos': dict({ + 'connection_state': 'disconnected', + 'current_credentials': None, + }), + 'inputs': list([ + dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'browsable': False, + 'container_id': None, + 'image_url': '', + 'media_id': 'inputs/aux_in_1', + 'name': 'HEOS Drive - Line In 1', + 'playable': True, + 'source_id': 1027, + 'type': 'station', + }), + dict({ + 'album': None, + 'album_id': None, + 'artist': None, + 'browsable': False, + 'container_id': None, + 'image_url': '', + 'media_id': 'inputs/aux_in_1', + 'name': 'Speaker - Line In 1', + 'playable': True, + 'source_id': 1027, + 'type': 'station', + }), + ]), + 'source_list': list([ + "Today's Hits Radio", + 'Classical MPR (Classical Music)', + 'HEOS Drive - Line In 1', + 'Speaker - Line In 1', + ]), + 'system': dict({ + 'error': 'Not connected to device', + }), + }) +# --- +# name: test_device_diagnostics + dict({ + 'device': dict({ + 'area_id': None, + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'identifiers': list([ + list([ + 'heos', + '1', + ]), + ]), + 'labels': list([ + ]), + 'manufacturer': 'HEOS', + 'model': 'Drive HS2', + 'model_id': None, + 'name': 'Test Player', + 'name_by_user': None, + 'serial_number': '**REDACTED**', + 'sw_version': '1.0.0', + 'via_device_id': None, + }), + 'entities': list([ + dict({ + 'entity': dict({ + 'area_id': None, + 'categories': dict({ + }), + 'disabled_by': None, + 'entity_category': None, + 'entity_id': 'media_player.test_player', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_name': None, + 'platform': 'heos', + 'translation_key': None, + 'unique_id': '1', + }), + 'state': dict({ + 'attributes': dict({ + 'entity_picture': 'http://', + 'friendly_name': 'Test Player', + 'group_members': list([ + 'media_player.test_player', + 'media_player.test_player_2', + ]), + 'is_volume_muted': False, + 'media_album_id': '1', + 'media_album_name': 'Album', + 'media_artist': 'Artist', + 'media_content_id': '1', + 'media_content_type': 'music', + 'media_queue_id': 1, + 'media_source_id': 10, + 'media_station': 'Station Name', + 'media_title': 'Song', + 'media_type': 'station', + 'repeat': 'off', + 'shuffle': False, + 'source_list': list([ + "Today's Hits Radio", + 'Classical MPR (Classical Music)', + 'HEOS Drive - Line In 1', + 'Speaker - Line In 1', + ]), + 'supported_features': 3079741, + 'volume_level': 0.25, + }), + 'context': dict({ + 'parent_id': None, + 'user_id': None, + }), + 'entity_id': 'media_player.test_player', + 'state': 'idle', + }), + }), + ]), + 'player': dict({ + 'available': True, + 'control': 0, + 'group_id': 999, + 'ip_address': '127.0.0.1', + 'is_muted': False, + 'line_out': 1, + 'model': 'HEOS Drive HS2', + 'name': 'Test Player', + 'network': 'wired', + 'now_playing_media': dict({ + 'album': 'Album', + 'album_id': '1', + 'artist': 'Artist', + 'current_position': None, + 'current_position_updated': None, + 'duration': None, + 'image_url': 'http://', + 'media_id': '1', + 'options': list([ + ]), + 'queue_id': 1, + 'song': 'Song', + 'source_id': 10, + 'station': 'Station Name', + 'supported_controls': list([ + 'play', + 'pause', + 'stop', + 'play_next', + 'play_previous', + ]), + 'type': 'station', + }), + 'playback_error': None, + 'player_id': 1, + 'repeat': 'off', + 'serial': '**REDACTED**', + 'shuffle': False, + 'state': 'stop', + 'version': '1.0.0', + 'volume': 25, + }), + }) +# --- diff --git a/tests/components/heos/snapshots/test_media_player.ambr b/tests/components/heos/snapshots/test_media_player.ambr index 7bfdac232cb..88d27f2073a 100644 --- a/tests/components/heos/snapshots/test_media_player.ambr +++ b/tests/components/heos/snapshots/test_media_player.ambr @@ -9,16 +9,16 @@ 'media_player.test_player_2', ]), 'is_volume_muted': False, - 'media_album_id': 1, + 'media_album_id': '1', 'media_album_name': 'Album', 'media_artist': 'Artist', 'media_content_id': '1', 'media_content_type': , 'media_queue_id': 1, - 'media_source_id': 1, + 'media_source_id': 10, 'media_station': 'Station Name', 'media_title': 'Song', - 'media_type': 'Station', + 'media_type': , 'repeat': , 'shuffle': False, 'source_list': list([ diff --git a/tests/components/heos/test_diagnostics.py b/tests/components/heos/test_diagnostics.py new file mode 100644 index 00000000000..d6fb8e1a8fe --- /dev/null +++ b/tests/components/heos/test_diagnostics.py @@ -0,0 +1,98 @@ +"""Tests for the HEOS diagnostics module.""" + +from unittest import mock + +from pyheos import Heos, HeosSystem +import pytest +from syrupy import SnapshotAssertion +from syrupy.filters import props + +from homeassistant.components.heos.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from tests.common import MockConfigEntry +from tests.components.diagnostics import ( + get_diagnostics_for_config_entry, + get_diagnostics_for_device, +) +from tests.typing import ClientSessionGenerator + + +async def test_config_entry_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + config_entry: MockConfigEntry, + controller: Heos, + system: HeosSystem, + snapshot: SnapshotAssertion, +) -> None: + """Test generating diagnostics for a config entry.""" + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + + with mock.patch.object( + controller, controller.get_system_info.__name__, return_value=system + ): + diagnostics = await get_diagnostics_for_config_entry( + hass, hass_client, config_entry + ) + + assert diagnostics == snapshot( + exclude=props("created_at", "modified_at", "entry_id") + ) + + +@pytest.mark.usefixtures("controller") +async def test_config_entry_diagnostics_error_getting_system( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test generating diagnostics with error during getting system info.""" + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + + # Not patching get_system_info to raise error 'Not connected to device' + + diagnostics = await get_diagnostics_for_config_entry( + hass, hass_client, config_entry + ) + + assert diagnostics == snapshot( + exclude=props("created_at", "modified_at", "entry_id") + ) + + +@pytest.mark.usefixtures("controller") +async def test_device_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test generating diagnostics for a config entry.""" + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + device_registry = dr.async_get(hass) + device = device_registry.async_get_device({(DOMAIN, "1")}) + + diagnostics = await get_diagnostics_for_device( + hass, hass_client, config_entry, device + ) + assert diagnostics == snapshot( + exclude=props( + "created_at", + "modified_at", + "config_entries", + "id", + "primary_config_entry", + "config_entry_id", + "device_id", + "entity_picture_local", + "last_changed", + "last_reported", + "last_updated", + ) + ) From 3dbcdf933ead73dea37825aa705f022e7f0afcd2 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 28 Jan 2025 14:04:09 +0100 Subject: [PATCH 0123/3148] Cleanup ecobee YAML configuration import (#136633) --- homeassistant/components/ecobee/__init__.py | 42 +------ .../components/ecobee/config_flow.py | 69 +---------- homeassistant/components/ecobee/const.py | 2 - tests/components/ecobee/test_config_flow.py | 110 +----------------- 4 files changed, 8 insertions(+), 215 deletions(-) diff --git a/homeassistant/components/ecobee/__init__.py b/homeassistant/components/ecobee/__init__.py index ae5ee96a6a4..c34211e9ff0 100644 --- a/homeassistant/components/ecobee/__init__.py +++ b/homeassistant/components/ecobee/__init__.py @@ -3,57 +3,19 @@ from datetime import timedelta from pyecobee import ECOBEE_API_KEY, ECOBEE_REFRESH_TOKEN, Ecobee, ExpiredTokenError -import voluptuous as vol -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.typing import ConfigType from homeassistant.util import Throttle -from .const import ( - _LOGGER, - CONF_REFRESH_TOKEN, - DATA_ECOBEE_CONFIG, - DATA_HASS_CONFIG, - DOMAIN, - PLATFORMS, -) +from .const import _LOGGER, CONF_REFRESH_TOKEN, PLATFORMS MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=180) -CONFIG_SCHEMA = vol.Schema( - {DOMAIN: vol.Schema({vol.Optional(CONF_API_KEY): cv.string})}, extra=vol.ALLOW_EXTRA -) - type EcobeeConfigEntry = ConfigEntry[EcobeeData] -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Ecobee uses config flow for configuration. - - But, an "ecobee:" entry in configuration.yaml will trigger an import flow - if a config entry doesn't already exist. If ecobee.conf exists, the import - flow will attempt to import it and create a config entry, to assist users - migrating from the old ecobee integration. Otherwise, the user will have to - continue setting up the integration via the config flow. - """ - - hass.data[DATA_ECOBEE_CONFIG] = config.get(DOMAIN, {}) - hass.data[DATA_HASS_CONFIG] = config - - if not hass.config_entries.async_entries(DOMAIN) and hass.data[DATA_ECOBEE_CONFIG]: - # No config entry exists and configuration.yaml config exists, trigger the import flow. - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT} - ) - ) - - return True - - async def async_setup_entry(hass: HomeAssistant, entry: EcobeeConfigEntry) -> bool: """Set up ecobee via a config entry.""" api_key = entry.data[CONF_API_KEY] diff --git a/homeassistant/components/ecobee/config_flow.py b/homeassistant/components/ecobee/config_flow.py index 687d9173a66..ac834e92ca8 100644 --- a/homeassistant/components/ecobee/config_flow.py +++ b/homeassistant/components/ecobee/config_flow.py @@ -2,20 +2,15 @@ from typing import Any -from pyecobee import ( - ECOBEE_API_KEY, - ECOBEE_CONFIG_FILENAME, - ECOBEE_REFRESH_TOKEN, - Ecobee, -) +from pyecobee import ECOBEE_API_KEY, Ecobee import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_API_KEY -from homeassistant.exceptions import HomeAssistantError -from homeassistant.util.json import load_json_object -from .const import _LOGGER, CONF_REFRESH_TOKEN, DATA_ECOBEE_CONFIG, DOMAIN +from .const import CONF_REFRESH_TOKEN, DOMAIN + +_USER_SCHEMA = vol.Schema({vol.Required(CONF_API_KEY): str}) class EcobeeFlowHandler(ConfigFlow, domain=DOMAIN): @@ -30,11 +25,6 @@ class EcobeeFlowHandler(ConfigFlow, domain=DOMAIN): ) -> ConfigFlowResult: """Handle a flow initiated by the user.""" errors = {} - stored_api_key = ( - self.hass.data[DATA_ECOBEE_CONFIG].get(CONF_API_KEY) - if DATA_ECOBEE_CONFIG in self.hass.data - else "" - ) if user_input is not None: # Use the user-supplied API key to attempt to obtain a PIN from ecobee. @@ -47,9 +37,7 @@ class EcobeeFlowHandler(ConfigFlow, domain=DOMAIN): return self.async_show_form( step_id="user", - data_schema=vol.Schema( - {vol.Required(CONF_API_KEY, default=stored_api_key): str} - ), + data_schema=_USER_SCHEMA, errors=errors, ) @@ -75,50 +63,3 @@ class EcobeeFlowHandler(ConfigFlow, domain=DOMAIN): errors=errors, description_placeholders={"pin": self._ecobee.pin}, ) - - async def async_step_import(self, import_data: None) -> ConfigFlowResult: - """Import ecobee config from configuration.yaml. - - Triggered by async_setup only if a config entry doesn't already exist. - If ecobee.conf exists, we will attempt to validate the credentials - and create an entry if valid. Otherwise, we will delegate to the user - step so that the user can continue the config flow. - """ - try: - legacy_config = await self.hass.async_add_executor_job( - load_json_object, self.hass.config.path(ECOBEE_CONFIG_FILENAME) - ) - config = { - ECOBEE_API_KEY: legacy_config[ECOBEE_API_KEY], - ECOBEE_REFRESH_TOKEN: legacy_config[ECOBEE_REFRESH_TOKEN], - } - except (HomeAssistantError, KeyError): - _LOGGER.debug( - "No valid ecobee.conf configuration found for import, delegating to" - " user step" - ) - return await self.async_step_user( - user_input={ - CONF_API_KEY: self.hass.data[DATA_ECOBEE_CONFIG].get(CONF_API_KEY) - } - ) - - ecobee = Ecobee(config=config) - if await self.hass.async_add_executor_job(ecobee.refresh_tokens): - # Credentials found and validated; create the entry. - _LOGGER.debug( - "Valid ecobee configuration found for import, creating configuration" - " entry" - ) - return self.async_create_entry( - title=DOMAIN, - data={ - CONF_API_KEY: ecobee.api_key, - CONF_REFRESH_TOKEN: ecobee.refresh_token, - }, - ) - return await self.async_step_user( - user_input={ - CONF_API_KEY: self.hass.data[DATA_ECOBEE_CONFIG].get(CONF_API_KEY) - } - ) diff --git a/homeassistant/components/ecobee/const.py b/homeassistant/components/ecobee/const.py index d0e9ba8e8e9..115c91eceeb 100644 --- a/homeassistant/components/ecobee/const.py +++ b/homeassistant/components/ecobee/const.py @@ -20,8 +20,6 @@ from homeassistant.const import Platform _LOGGER = logging.getLogger(__package__) DOMAIN = "ecobee" -DATA_ECOBEE_CONFIG = "ecobee_config" -DATA_HASS_CONFIG = "ecobee_hass_config" ATTR_CONFIG_ENTRY_ID = "entry_id" ATTR_AVAILABLE_SENSORS = "available_sensors" ATTR_ACTIVE_SENSORS = "active_sensors" diff --git a/tests/components/ecobee/test_config_flow.py b/tests/components/ecobee/test_config_flow.py index 5c919ffab5c..9edb1d42331 100644 --- a/tests/components/ecobee/test_config_flow.py +++ b/tests/components/ecobee/test_config_flow.py @@ -2,15 +2,8 @@ from unittest.mock import patch -from pyecobee import ECOBEE_API_KEY, ECOBEE_REFRESH_TOKEN -import pytest - from homeassistant.components.ecobee import config_flow -from homeassistant.components.ecobee.const import ( - CONF_REFRESH_TOKEN, - DATA_ECOBEE_CONFIG, - DOMAIN, -) +from homeassistant.components.ecobee.const import CONF_REFRESH_TOKEN, DOMAIN from homeassistant.config_entries import SOURCE_USER from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant @@ -35,7 +28,6 @@ async def test_user_step_without_user_input(hass: HomeAssistant) -> None: """Test expected result if user step is called.""" flow = config_flow.EcobeeFlowHandler() flow.hass = hass - flow.hass.data[DATA_ECOBEE_CONFIG] = {} result = await flow.async_step_user() assert result["type"] is FlowResultType.FORM @@ -46,7 +38,6 @@ async def test_pin_request_succeeds(hass: HomeAssistant) -> None: """Test expected result if pin request succeeds.""" flow = config_flow.EcobeeFlowHandler() flow.hass = hass - flow.hass.data[DATA_ECOBEE_CONFIG] = {} with patch("homeassistant.components.ecobee.config_flow.Ecobee") as mock_ecobee: mock_ecobee = mock_ecobee.return_value @@ -64,7 +55,6 @@ async def test_pin_request_fails(hass: HomeAssistant) -> None: """Test expected result if pin request fails.""" flow = config_flow.EcobeeFlowHandler() flow.hass = hass - flow.hass.data[DATA_ECOBEE_CONFIG] = {} with patch("homeassistant.components.ecobee.config_flow.Ecobee") as mock_ecobee: mock_ecobee = mock_ecobee.return_value @@ -81,7 +71,6 @@ async def test_token_request_succeeds(hass: HomeAssistant) -> None: """Test expected result if token request succeeds.""" flow = config_flow.EcobeeFlowHandler() flow.hass = hass - flow.hass.data[DATA_ECOBEE_CONFIG] = {} with patch("homeassistant.components.ecobee.config_flow.Ecobee") as mock_ecobee: mock_ecobee = mock_ecobee.return_value @@ -105,7 +94,6 @@ async def test_token_request_fails(hass: HomeAssistant) -> None: """Test expected result if token request fails.""" flow = config_flow.EcobeeFlowHandler() flow.hass = hass - flow.hass.data[DATA_ECOBEE_CONFIG] = {} with patch("homeassistant.components.ecobee.config_flow.Ecobee") as mock_ecobee: mock_ecobee = mock_ecobee.return_value @@ -120,99 +108,3 @@ async def test_token_request_fails(hass: HomeAssistant) -> None: assert result["step_id"] == "authorize" assert result["errors"]["base"] == "token_request_failed" assert result["description_placeholders"] == {"pin": "test-pin"} - - -@pytest.mark.skip(reason="Flaky/slow") -async def test_import_flow_triggered_but_no_ecobee_conf(hass: HomeAssistant) -> None: - """Test expected result if import flow triggers but ecobee.conf doesn't exist.""" - flow = config_flow.EcobeeFlowHandler() - flow.hass = hass - flow.hass.data[DATA_ECOBEE_CONFIG] = {} - - result = await flow.async_step_import(import_data=None) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - - -async def test_import_flow_triggered_with_ecobee_conf_and_valid_data_and_valid_tokens( - hass: HomeAssistant, -) -> None: - """Test expected result if import flow triggers and ecobee.conf exists with valid tokens.""" - flow = config_flow.EcobeeFlowHandler() - flow.hass = hass - - MOCK_ECOBEE_CONF = {ECOBEE_API_KEY: None, ECOBEE_REFRESH_TOKEN: None} - - with ( - patch( - "homeassistant.components.ecobee.config_flow.load_json_object", - return_value=MOCK_ECOBEE_CONF, - ), - patch("homeassistant.components.ecobee.config_flow.Ecobee") as mock_ecobee, - ): - mock_ecobee = mock_ecobee.return_value - mock_ecobee.refresh_tokens.return_value = True - mock_ecobee.api_key = "test-api-key" - mock_ecobee.refresh_token = "test-token" - - result = await flow.async_step_import(import_data=None) - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == DOMAIN - assert result["data"] == { - CONF_API_KEY: "test-api-key", - CONF_REFRESH_TOKEN: "test-token", - } - - -async def test_import_flow_triggered_with_ecobee_conf_and_invalid_data( - hass: HomeAssistant, -) -> None: - """Test expected result if import flow triggers and ecobee.conf exists with invalid data.""" - flow = config_flow.EcobeeFlowHandler() - flow.hass = hass - flow.hass.data[DATA_ECOBEE_CONFIG] = {CONF_API_KEY: "test-api-key"} - - MOCK_ECOBEE_CONF = {} - - with ( - patch( - "homeassistant.components.ecobee.config_flow.load_json_object", - return_value=MOCK_ECOBEE_CONF, - ), - patch.object(flow, "async_step_user") as mock_async_step_user, - ): - await flow.async_step_import(import_data=None) - - mock_async_step_user.assert_called_once_with( - user_input={CONF_API_KEY: "test-api-key"} - ) - - -async def test_import_flow_triggered_with_ecobee_conf_and_valid_data_and_stale_tokens( - hass: HomeAssistant, -) -> None: - """Test expected result if import flow triggers and ecobee.conf exists with stale tokens.""" - flow = config_flow.EcobeeFlowHandler() - flow.hass = hass - flow.hass.data[DATA_ECOBEE_CONFIG] = {CONF_API_KEY: "test-api-key"} - - MOCK_ECOBEE_CONF = {ECOBEE_API_KEY: None, ECOBEE_REFRESH_TOKEN: None} - - with ( - patch( - "homeassistant.components.ecobee.config_flow.load_json_object", - return_value=MOCK_ECOBEE_CONF, - ), - patch("homeassistant.components.ecobee.config_flow.Ecobee") as mock_ecobee, - patch.object(flow, "async_step_user") as mock_async_step_user, - ): - mock_ecobee = mock_ecobee.return_value - mock_ecobee.refresh_tokens.return_value = False - - await flow.async_step_import(import_data=None) - - mock_async_step_user.assert_called_once_with( - user_input={CONF_API_KEY: "test-api-key"} - ) From 5053b203a562dc5fd95fde18b84117920a2629ac Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 28 Jan 2025 14:06:59 +0100 Subject: [PATCH 0124/3148] Fix spelling of "Ring" and sentence-casing of "integration" (#136652) --- homeassistant/components/ring/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/ring/strings.json b/homeassistant/components/ring/strings.json index 1f146bcf358..8320a3ec47f 100644 --- a/homeassistant/components/ring/strings.json +++ b/homeassistant/components/ring/strings.json @@ -18,7 +18,7 @@ "2fa": "Two-factor code" }, "data_description": { - "2fa": "Account verification code via the method selected in your ring account settings." + "2fa": "Account verification code via the method selected in your Ring account settings." } }, "reauth_confirm": { @@ -32,7 +32,7 @@ } }, "reconfigure": { - "title": "Reconfigure Ring Integration", + "title": "Reconfigure Ring integration", "description": "Will create a new Authorized Device for {username} at ring.com", "data": { "password": "[%key:common::config_flow::data::password%]" From 79de8114d3731f4edfba10f09c5c1e7ef5dc7827 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 28 Jan 2025 14:07:49 +0100 Subject: [PATCH 0125/3148] Fix spelling errors in user-facing strings of OctoPrint integration (#136644) --- homeassistant/components/octoprint/strings.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/octoprint/strings.json b/homeassistant/components/octoprint/strings.json index 5687ab36033..7f08d04e3da 100644 --- a/homeassistant/components/octoprint/strings.json +++ b/homeassistant/components/octoprint/strings.json @@ -1,11 +1,11 @@ { "config": { - "flow_title": "OctoPrint Printer: {host}", + "flow_title": "OctoPrint printer: {host}", "step": { "user": { "data": { "host": "[%key:common::config_flow::data::host%]", - "path": "Application Path", + "path": "Application path", "port": "[%key:common::config_flow::data::port%]", "ssl": "[%key:common::config_flow::data::ssl%]", "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]", @@ -29,7 +29,7 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "unknown": "[%key:common::config_flow::error::unknown%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "auth_failed": "Failed to retrieve application api key", + "auth_failed": "Failed to retrieve API key", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" }, "progress": { @@ -44,7 +44,7 @@ "services": { "printer_connect": { "name": "Connect to a printer", - "description": "Instructs the octoprint server to connect to a printer.", + "description": "Instructs the OctoPrint server to connect to a printer.", "fields": { "device_id": { "name": "Server", From c4f8de8fd97eb6d31e5c78668eff352891e345a9 Mon Sep 17 00:00:00 2001 From: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> Date: Tue, 28 Jan 2025 07:08:40 -0600 Subject: [PATCH 0126/3148] Raise exceptions in HEOS custom actions (#136546) --- homeassistant/components/heos/services.py | 20 ++++++--- homeassistant/components/heos/strings.json | 9 ++++ tests/components/heos/test_services.py | 49 +++++++++------------- 3 files changed, 43 insertions(+), 35 deletions(-) diff --git a/homeassistant/components/heos/services.py b/homeassistant/components/heos/services.py index f4d5961cc47..4dc3b247707 100644 --- a/homeassistant/components/heos/services.py +++ b/homeassistant/components/heos/services.py @@ -7,7 +7,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant, ServiceCall -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import config_validation as cv, issue_registry as ir from .const import ( @@ -46,7 +46,6 @@ def register(hass: HomeAssistant): def _get_controller(hass: HomeAssistant) -> Heos: """Get the HEOS controller instance.""" - _LOGGER.warning( "Actions 'heos.sign_in' and 'heos.sign_out' are deprecated and will be removed in the 2025.8.0 release" ) @@ -79,16 +78,25 @@ async def _sign_in_handler(service: ServiceCall) -> None: try: await controller.sign_in(username, password) except CommandAuthenticationError as err: - _LOGGER.error("Sign in failed: %s", err) + raise ServiceValidationError( + translation_domain=DOMAIN, translation_key="sign_in_auth_error" + ) from err except HeosError as err: - _LOGGER.error("Unable to sign in: %s", err) + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="sign_in_error", + translation_placeholders={"error": str(err)}, + ) from err async def _sign_out_handler(service: ServiceCall) -> None: """Sign out of the HEOS account.""" - controller = _get_controller(service.hass) try: await controller.sign_out() except HeosError as err: - _LOGGER.error("Unable to sign out: %s", err) + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="sign_out_error", + translation_placeholders={"error": str(err)}, + ) from err diff --git a/homeassistant/components/heos/strings.json b/homeassistant/components/heos/strings.json index 907804d10e1..4092d4360db 100644 --- a/homeassistant/components/heos/strings.json +++ b/homeassistant/components/heos/strings.json @@ -100,6 +100,15 @@ "integration_not_loaded": { "message": "The HEOS integration is not loaded" }, + "sign_in_auth_error": { + "message": "Failed to sign in: Invalid username and/or password" + }, + "sign_in_error": { + "message": "Unable to sign in: {error}" + }, + "sign_out_error": { + "message": "Unable to sign out: {error}" + }, "not_heos_media_player": { "message": "Entity {entity_id} is not a HEOS media player entity" }, diff --git a/tests/components/heos/test_services.py b/tests/components/heos/test_services.py index 8ca365497c6..8eda26d2b3d 100644 --- a/tests/components/heos/test_services.py +++ b/tests/components/heos/test_services.py @@ -11,7 +11,7 @@ from homeassistant.components.heos.const import ( SERVICE_SIGN_OUT, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from tests.common import MockConfigEntry @@ -34,10 +34,7 @@ async def test_sign_in( async def test_sign_in_failed( - hass: HomeAssistant, - config_entry: MockConfigEntry, - controller: Heos, - caplog: pytest.LogCaptureFixture, + hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos ) -> None: """Test sign-in service logs error when not connected.""" config_entry.add_to_hass(hass) @@ -47,22 +44,19 @@ async def test_sign_in_failed( "", "Invalid credentials", 6 ) - await hass.services.async_call( - DOMAIN, - SERVICE_SIGN_IN, - {ATTR_USERNAME: "test@test.com", ATTR_PASSWORD: "password"}, - blocking=True, - ) + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + DOMAIN, + SERVICE_SIGN_IN, + {ATTR_USERNAME: "test@test.com", ATTR_PASSWORD: "password"}, + blocking=True, + ) controller.sign_in.assert_called_once_with("test@test.com", "password") - assert "Sign in failed: Invalid credentials (6)" in caplog.text async def test_sign_in_unknown_error( - hass: HomeAssistant, - config_entry: MockConfigEntry, - controller: Heos, - caplog: pytest.LogCaptureFixture, + hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos ) -> None: """Test sign-in service logs error for failure.""" config_entry.add_to_hass(hass) @@ -70,15 +64,15 @@ async def test_sign_in_unknown_error( controller.sign_in.side_effect = HeosError() - await hass.services.async_call( - DOMAIN, - SERVICE_SIGN_IN, - {ATTR_USERNAME: "test@test.com", ATTR_PASSWORD: "password"}, - blocking=True, - ) + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + DOMAIN, + SERVICE_SIGN_IN, + {ATTR_USERNAME: "test@test.com", ATTR_PASSWORD: "password"}, + blocking=True, + ) controller.sign_in.assert_called_once_with("test@test.com", "password") - assert "Unable to sign in" in caplog.text async def test_sign_in_not_loaded_raises( @@ -123,17 +117,14 @@ async def test_sign_out_not_loaded_raises( async def test_sign_out_unknown_error( - hass: HomeAssistant, - config_entry: MockConfigEntry, - controller: Heos, - caplog: pytest.LogCaptureFixture, + hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos ) -> None: """Test the sign-out service.""" config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) controller.sign_out.side_effect = HeosError() - await hass.services.async_call(DOMAIN, SERVICE_SIGN_OUT, {}, blocking=True) + with pytest.raises(HomeAssistantError): + await hass.services.async_call(DOMAIN, SERVICE_SIGN_OUT, {}, blocking=True) assert controller.sign_out.call_count == 1 - assert "Unable to sign out" in caplog.text From 2c3cd6e1198dd2ae377deea120626d1c1ac6cf9e Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Tue, 28 Jan 2025 14:09:22 +0100 Subject: [PATCH 0127/3148] Fix total coffees sensor for lamarzocco (#135283) --- homeassistant/components/lamarzocco/sensor.py | 4 ++-- tests/components/lamarzocco/snapshots/test_sensor.ambr | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/lamarzocco/sensor.py b/homeassistant/components/lamarzocco/sensor.py index 2acca879d52..406e8e40e92 100644 --- a/homeassistant/components/lamarzocco/sensor.py +++ b/homeassistant/components/lamarzocco/sensor.py @@ -3,7 +3,7 @@ from collections.abc import Callable from dataclasses import dataclass -from pylamarzocco.const import BoilerType, MachineModel, PhysicalKey +from pylamarzocco.const import BoilerType, MachineModel from pylamarzocco.devices.machine import LaMarzoccoMachine from homeassistant.components.sensor import ( @@ -81,7 +81,7 @@ STATISTIC_ENTITIES: tuple[LaMarzoccoSensorEntityDescription, ...] = ( translation_key="drink_stats_coffee", native_unit_of_measurement="drinks", state_class=SensorStateClass.TOTAL_INCREASING, - value_fn=lambda device: device.statistics.drink_stats.get(PhysicalKey.A, 0), + value_fn=lambda device: device.statistics.total_coffee, available_fn=lambda device: len(device.statistics.drink_stats) > 0, entity_category=EntityCategory.DIAGNOSTIC, ), diff --git a/tests/components/lamarzocco/snapshots/test_sensor.ambr b/tests/components/lamarzocco/snapshots/test_sensor.ambr index 723f9738e1c..9e2eae482d2 100644 --- a/tests/components/lamarzocco/snapshots/test_sensor.ambr +++ b/tests/components/lamarzocco/snapshots/test_sensor.ambr @@ -256,7 +256,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '1047', + 'state': '2387', }) # --- # name: test_sensors[sensor.gs012345_total_flushes_made-entry] From 9897e4d3e491bd9d27b3c16a57f8b6643a745733 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 28 Jan 2025 14:10:09 +0100 Subject: [PATCH 0128/3148] Use runtime_data in drop_connect (#136442) --- .../components/drop_connect/__init__.py | 23 ++++++++----------- .../components/drop_connect/binary_sensor.py | 9 ++++---- .../components/drop_connect/coordinator.py | 11 ++++++--- .../components/drop_connect/select.py | 10 ++++---- .../components/drop_connect/sensor.py | 9 ++++---- .../components/drop_connect/switch.py | 9 ++++---- 6 files changed, 34 insertions(+), 37 deletions(-) diff --git a/homeassistant/components/drop_connect/__init__.py b/homeassistant/components/drop_connect/__init__.py index bc700456398..52b8f5a7d6e 100644 --- a/homeassistant/components/drop_connect/__init__.py +++ b/homeassistant/components/drop_connect/__init__.py @@ -7,12 +7,11 @@ from typing import TYPE_CHECKING from homeassistant.components import mqtt from homeassistant.components.mqtt import ReceiveMessage -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback -from .const import CONF_DATA_TOPIC, CONF_DEVICE_TYPE, DOMAIN -from .coordinator import DROPDeviceDataUpdateCoordinator +from .const import CONF_DATA_TOPIC, CONF_DEVICE_TYPE +from .coordinator import DROPConfigEntry, DROPDeviceDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -24,7 +23,7 @@ PLATFORMS: list[Platform] = [ ] -async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, config_entry: DROPConfigEntry) -> bool: """Set up DROP from a config entry.""" # Make sure MQTT integration is enabled and the client is available. @@ -34,9 +33,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b if TYPE_CHECKING: assert config_entry.unique_id is not None - drop_data_coordinator = DROPDeviceDataUpdateCoordinator( - hass, config_entry.unique_id - ) + drop_data_coordinator = DROPDeviceDataUpdateCoordinator(hass, config_entry) @callback def mqtt_callback(msg: ReceiveMessage) -> None: @@ -58,15 +55,13 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b config_entry.data[CONF_DATA_TOPIC], ) - hass.data.setdefault(DOMAIN, {})[config_entry.entry_id] = drop_data_coordinator + config_entry.runtime_data = drop_data_coordinator await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, config_entry: DROPConfigEntry +) -> bool: """Unload a config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms( - config_entry, PLATFORMS - ): - hass.data[DOMAIN].pop(config_entry.entry_id) - return unload_ok + return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) diff --git a/homeassistant/components/drop_connect/binary_sensor.py b/homeassistant/components/drop_connect/binary_sensor.py index 093c5bcbb8e..bc8cf900610 100644 --- a/homeassistant/components/drop_connect/binary_sensor.py +++ b/homeassistant/components/drop_connect/binary_sensor.py @@ -11,7 +11,6 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -25,9 +24,8 @@ from .const import ( DEV_RO_FILTER, DEV_SALT_SENSOR, DEV_SOFTENER, - DOMAIN, ) -from .coordinator import DROPDeviceDataUpdateCoordinator +from .coordinator import DROPConfigEntry, DROPDeviceDataUpdateCoordinator from .entity import DROPEntity _LOGGER = logging.getLogger(__name__) @@ -106,7 +104,7 @@ DEVICE_BINARY_SENSORS: dict[str, list[str]] = { async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: DROPConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the DROP binary sensors from config entry.""" @@ -116,9 +114,10 @@ async def async_setup_entry( config_entry.entry_id, ) + coordinator = config_entry.runtime_data if config_entry.data[CONF_DEVICE_TYPE] in DEVICE_BINARY_SENSORS: async_add_entities( - DROPBinarySensor(hass.data[DOMAIN][config_entry.entry_id], sensor) + DROPBinarySensor(coordinator, sensor) for sensor in BINARY_SENSORS if sensor.key in DEVICE_BINARY_SENSORS[config_entry.data[CONF_DEVICE_TYPE]] ) diff --git a/homeassistant/components/drop_connect/coordinator.py b/homeassistant/components/drop_connect/coordinator.py index 0861e091153..d37127d89ed 100644 --- a/homeassistant/components/drop_connect/coordinator.py +++ b/homeassistant/components/drop_connect/coordinator.py @@ -16,14 +16,19 @@ from .const import CONF_COMMAND_TOPIC, DOMAIN _LOGGER = logging.getLogger(__name__) +type DROPConfigEntry = ConfigEntry[DROPDeviceDataUpdateCoordinator] + + class DROPDeviceDataUpdateCoordinator(DataUpdateCoordinator[None]): """DROP device object.""" - config_entry: ConfigEntry + config_entry: DROPConfigEntry - def __init__(self, hass: HomeAssistant, unique_id: str) -> None: + def __init__(self, hass: HomeAssistant, entry: DROPConfigEntry) -> None: """Initialize the device.""" - super().__init__(hass, _LOGGER, name=f"{DOMAIN}-{unique_id}") + super().__init__( + hass, _LOGGER, config_entry=entry, name=f"{DOMAIN}-{entry.unique_id}" + ) self.drop_api = DropAPI() async def set_water(self, value: int) -> None: diff --git a/homeassistant/components/drop_connect/select.py b/homeassistant/components/drop_connect/select.py index ad06576c9f3..9e4c74b67e6 100644 --- a/homeassistant/components/drop_connect/select.py +++ b/homeassistant/components/drop_connect/select.py @@ -8,12 +8,11 @@ import logging from typing import Any from homeassistant.components.select import SelectEntity, SelectEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import CONF_DEVICE_TYPE, DEV_HUB, DOMAIN -from .coordinator import DROPDeviceDataUpdateCoordinator +from .const import CONF_DEVICE_TYPE, DEV_HUB +from .coordinator import DROPConfigEntry, DROPDeviceDataUpdateCoordinator from .entity import DROPEntity _LOGGER = logging.getLogger(__name__) @@ -50,7 +49,7 @@ DEVICE_SELECTS: dict[str, list[str]] = { async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: DROPConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the DROP selects from config entry.""" @@ -60,9 +59,10 @@ async def async_setup_entry( config_entry.entry_id, ) + coordinator = config_entry.runtime_data if config_entry.data[CONF_DEVICE_TYPE] in DEVICE_SELECTS: async_add_entities( - DROPSelect(hass.data[DOMAIN][config_entry.entry_id], select) + DROPSelect(coordinator, select) for select in SELECTS if select.key in DEVICE_SELECTS[config_entry.data[CONF_DEVICE_TYPE]] ) diff --git a/homeassistant/components/drop_connect/sensor.py b/homeassistant/components/drop_connect/sensor.py index ad123ee13c7..5ec47ed9eb1 100644 --- a/homeassistant/components/drop_connect/sensor.py +++ b/homeassistant/components/drop_connect/sensor.py @@ -12,7 +12,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONCENTRATION_PARTS_PER_MILLION, PERCENTAGE, @@ -35,9 +34,8 @@ from .const import ( DEV_PUMP_CONTROLLER, DEV_RO_FILTER, DEV_SOFTENER, - DOMAIN, ) -from .coordinator import DROPDeviceDataUpdateCoordinator +from .coordinator import DROPConfigEntry, DROPDeviceDataUpdateCoordinator from .entity import DROPEntity _LOGGER = logging.getLogger(__name__) @@ -243,7 +241,7 @@ DEVICE_SENSORS: dict[str, list[str]] = { async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: DROPConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the DROP sensors from config entry.""" @@ -253,9 +251,10 @@ async def async_setup_entry( config_entry.entry_id, ) + coordinator = config_entry.runtime_data if config_entry.data[CONF_DEVICE_TYPE] in DEVICE_SENSORS: async_add_entities( - DROPSensor(hass.data[DOMAIN][config_entry.entry_id], sensor) + DROPSensor(coordinator, sensor) for sensor in SENSORS if sensor.key in DEVICE_SENSORS[config_entry.data[CONF_DEVICE_TYPE]] ) diff --git a/homeassistant/components/drop_connect/switch.py b/homeassistant/components/drop_connect/switch.py index 98841d7ca24..404059d3196 100644 --- a/homeassistant/components/drop_connect/switch.py +++ b/homeassistant/components/drop_connect/switch.py @@ -8,7 +8,6 @@ import logging from typing import Any from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -18,9 +17,8 @@ from .const import ( DEV_HUB, DEV_PROTECTION_VALVE, DEV_SOFTENER, - DOMAIN, ) -from .coordinator import DROPDeviceDataUpdateCoordinator +from .coordinator import DROPConfigEntry, DROPDeviceDataUpdateCoordinator from .entity import DROPEntity _LOGGER = logging.getLogger(__name__) @@ -66,7 +64,7 @@ DEVICE_SWITCHES: dict[str, list[str]] = { async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: DROPConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the DROP switches from config entry.""" @@ -76,9 +74,10 @@ async def async_setup_entry( config_entry.entry_id, ) + coordinator = config_entry.runtime_data if config_entry.data[CONF_DEVICE_TYPE] in DEVICE_SWITCHES: async_add_entities( - DROPSwitch(hass.data[DOMAIN][config_entry.entry_id], switch) + DROPSwitch(coordinator, switch) for switch in SWITCHES if switch.key in DEVICE_SWITCHES[config_entry.data[CONF_DEVICE_TYPE]] ) From abb58ec785fdc79b8aca7350ea681e84a021d139 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 28 Jan 2025 14:44:09 +0100 Subject: [PATCH 0129/3148] Include error reason in backup events (#136697) * Include error reason in backup events * Update hassio backup tests * Sort code * Remove catching BackupError in async_receive_backup --- homeassistant/components/backup/agent.py | 8 +- homeassistant/components/backup/manager.py | 104 ++++++++++++++---- homeassistant/components/backup/models.py | 10 +- .../backup/snapshots/test_websocket.ambr | 13 +++ tests/components/backup/test_manager.py | 77 ++++++++++++- tests/components/backup/test_websocket.py | 2 +- tests/components/hassio/test_backup.py | 41 +++++-- 7 files changed, 219 insertions(+), 36 deletions(-) diff --git a/homeassistant/components/backup/agent.py b/homeassistant/components/backup/agent.py index fe9eb9ea699..33656b6edcc 100644 --- a/homeassistant/components/backup/agent.py +++ b/homeassistant/components/backup/agent.py @@ -10,18 +10,20 @@ from typing import Any, Protocol from propcache.api import cached_property from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import HomeAssistantError -from .models import AgentBackup +from .models import AgentBackup, BackupError -class BackupAgentError(HomeAssistantError): +class BackupAgentError(BackupError): """Base class for backup agent errors.""" + error_code = "backup_agent_error" + class BackupAgentUnreachableError(BackupAgentError): """Raised when the agent can't reach its API.""" + error_code = "backup_agent_unreachable" _message = "The backup agent is unreachable." diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index 99740428863..4a871cdf73e 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -22,7 +22,6 @@ from securetar import SecureTarFile, atomic_contents_add from homeassistant.backup_restore import RESTORE_BACKUP_FILE, password_to_key from homeassistant.const import __version__ as HAVERSION from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import ( instance_id, integration_platform, @@ -47,7 +46,7 @@ from .const import ( EXCLUDE_FROM_BACKUP, LOGGER, ) -from .models import AgentBackup, BackupManagerError, Folder +from .models import AgentBackup, BackupError, BackupManagerError, Folder from .store import BackupStore from .util import ( AsyncIteratorReader, @@ -171,6 +170,7 @@ class CreateBackupEvent(ManagerStateEvent): """Backup in progress.""" manager_state: BackupManagerState = BackupManagerState.CREATE_BACKUP + reason: str | None stage: CreateBackupStage | None state: CreateBackupState @@ -180,6 +180,7 @@ class ReceiveBackupEvent(ManagerStateEvent): """Backup receive.""" manager_state: BackupManagerState = BackupManagerState.RECEIVE_BACKUP + reason: str | None stage: ReceiveBackupStage | None state: ReceiveBackupState @@ -189,6 +190,7 @@ class RestoreBackupEvent(ManagerStateEvent): """Backup restore.""" manager_state: BackupManagerState = BackupManagerState.RESTORE_BACKUP + reason: str | None stage: RestoreBackupStage | None state: RestoreBackupState @@ -250,19 +252,23 @@ class BackupReaderWriter(abc.ABC): """Restore a backup.""" -class BackupReaderWriterError(HomeAssistantError): +class BackupReaderWriterError(BackupError): """Backup reader/writer error.""" + error_code = "backup_reader_writer_error" + class IncorrectPasswordError(BackupReaderWriterError): """Raised when the password is incorrect.""" + error_code = "password_incorrect" _message = "The password provided is incorrect." class DecryptOnDowloadNotSupported(BackupManagerError): """Raised when on-the-fly decryption is not supported.""" + error_code = "decrypt_on_download_not_supported" _message = "On-the-fly decryption is not supported for this backup." @@ -619,18 +625,30 @@ class BackupManager: if self.state is not BackupManagerState.IDLE: raise BackupManagerError(f"Backup manager busy: {self.state}") self.async_on_backup_event( - ReceiveBackupEvent(stage=None, state=ReceiveBackupState.IN_PROGRESS) + ReceiveBackupEvent( + reason=None, + stage=None, + state=ReceiveBackupState.IN_PROGRESS, + ) ) try: await self._async_receive_backup(agent_ids=agent_ids, contents=contents) except Exception: self.async_on_backup_event( - ReceiveBackupEvent(stage=None, state=ReceiveBackupState.FAILED) + ReceiveBackupEvent( + reason="unknown_error", + stage=None, + state=ReceiveBackupState.FAILED, + ) ) raise else: self.async_on_backup_event( - ReceiveBackupEvent(stage=None, state=ReceiveBackupState.COMPLETED) + ReceiveBackupEvent( + reason=None, + stage=None, + state=ReceiveBackupState.COMPLETED, + ) ) finally: self.async_on_backup_event(IdleEvent()) @@ -645,6 +663,7 @@ class BackupManager: contents.chunk_size = BUF_SIZE self.async_on_backup_event( ReceiveBackupEvent( + reason=None, stage=ReceiveBackupStage.RECEIVE_FILE, state=ReceiveBackupState.IN_PROGRESS, ) @@ -656,6 +675,7 @@ class BackupManager: ) self.async_on_backup_event( ReceiveBackupEvent( + reason=None, stage=ReceiveBackupStage.UPLOAD_TO_AGENTS, state=ReceiveBackupState.IN_PROGRESS, ) @@ -739,7 +759,11 @@ class BackupManager: self.store.save() self.async_on_backup_event( - CreateBackupEvent(stage=None, state=CreateBackupState.IN_PROGRESS) + CreateBackupEvent( + reason=None, + stage=None, + state=CreateBackupState.IN_PROGRESS, + ) ) try: return await self._async_create_backup( @@ -755,9 +779,14 @@ class BackupManager: raise_task_error=raise_task_error, with_automatic_settings=with_automatic_settings, ) - except Exception: + except Exception as err: + reason = err.error_code if isinstance(err, BackupError) else "unknown_error" self.async_on_backup_event( - CreateBackupEvent(stage=None, state=CreateBackupState.FAILED) + CreateBackupEvent( + reason=reason, + stage=None, + state=CreateBackupState.FAILED, + ) ) self.async_on_backup_event(IdleEvent()) if with_automatic_settings: @@ -861,6 +890,7 @@ class BackupManager: ) self.async_on_backup_event( CreateBackupEvent( + reason=None, stage=CreateBackupStage.UPLOAD_TO_AGENTS, state=CreateBackupState.IN_PROGRESS, ) @@ -891,14 +921,22 @@ class BackupManager: finally: self._backup_task = None self._backup_finish_task = None - self.async_on_backup_event( - CreateBackupEvent( - stage=None, - state=CreateBackupState.COMPLETED - if backup_success - else CreateBackupState.FAILED, + if backup_success: + self.async_on_backup_event( + CreateBackupEvent( + reason=None, + stage=None, + state=CreateBackupState.COMPLETED, + ) + ) + else: + self.async_on_backup_event( + CreateBackupEvent( + reason="upload_failed", + stage=None, + state=CreateBackupState.FAILED, + ) ) - ) self.async_on_backup_event(IdleEvent()) async def async_restore_backup( @@ -917,7 +955,11 @@ class BackupManager: raise BackupManagerError(f"Backup manager busy: {self.state}") self.async_on_backup_event( - RestoreBackupEvent(stage=None, state=RestoreBackupState.IN_PROGRESS) + RestoreBackupEvent( + reason=None, + stage=None, + state=RestoreBackupState.IN_PROGRESS, + ) ) try: await self._async_restore_backup( @@ -930,11 +972,28 @@ class BackupManager: restore_homeassistant=restore_homeassistant, ) self.async_on_backup_event( - RestoreBackupEvent(stage=None, state=RestoreBackupState.COMPLETED) + RestoreBackupEvent( + reason=None, + stage=None, + state=RestoreBackupState.COMPLETED, + ) ) + except BackupError as err: + self.async_on_backup_event( + RestoreBackupEvent( + reason=err.error_code, + stage=None, + state=RestoreBackupState.FAILED, + ) + ) + raise except Exception: self.async_on_backup_event( - RestoreBackupEvent(stage=None, state=RestoreBackupState.FAILED) + RestoreBackupEvent( + reason="unknown_error", + stage=None, + state=RestoreBackupState.FAILED, + ) ) raise finally: @@ -1210,6 +1269,7 @@ class CoreBackupReaderWriter(BackupReaderWriter): on_progress( CreateBackupEvent( + reason=None, stage=CreateBackupStage.HOME_ASSISTANT, state=CreateBackupState.IN_PROGRESS, ) @@ -1469,7 +1529,11 @@ class CoreBackupReaderWriter(BackupReaderWriter): await self._hass.async_add_executor_job(_write_restore_file) on_progress( - RestoreBackupEvent(stage=None, state=RestoreBackupState.CORE_RESTART) + RestoreBackupEvent( + reason=None, + stage=None, + state=RestoreBackupState.CORE_RESTART, + ) ) await self._hass.services.async_call("homeassistant", "restart", blocking=True) diff --git a/homeassistant/components/backup/models.py b/homeassistant/components/backup/models.py index 81c00d699c6..f2a83f50c17 100644 --- a/homeassistant/components/backup/models.py +++ b/homeassistant/components/backup/models.py @@ -71,5 +71,13 @@ class AgentBackup: ) -class BackupManagerError(HomeAssistantError): +class BackupError(HomeAssistantError): + """Base class for backup errors.""" + + error_code = "unknown" + + +class BackupManagerError(BackupError): """Backup manager error.""" + + error_code = "backup_manager_error" diff --git a/tests/components/backup/snapshots/test_websocket.ambr b/tests/components/backup/snapshots/test_websocket.ambr index 2a6bc14fb74..634404b09cd 100644 --- a/tests/components/backup/snapshots/test_websocket.ambr +++ b/tests/components/backup/snapshots/test_websocket.ambr @@ -3383,6 +3383,7 @@ dict({ 'event': dict({ 'manager_state': 'create_backup', + 'reason': None, 'stage': None, 'state': 'in_progress', }), @@ -3404,6 +3405,7 @@ dict({ 'event': dict({ 'manager_state': 'create_backup', + 'reason': None, 'stage': 'home_assistant', 'state': 'in_progress', }), @@ -3415,6 +3417,7 @@ dict({ 'event': dict({ 'manager_state': 'create_backup', + 'reason': None, 'stage': 'upload_to_agents', 'state': 'in_progress', }), @@ -3426,6 +3429,7 @@ dict({ 'event': dict({ 'manager_state': 'create_backup', + 'reason': None, 'stage': None, 'state': 'completed', }), @@ -3454,6 +3458,7 @@ dict({ 'event': dict({ 'manager_state': 'create_backup', + 'reason': None, 'stage': None, 'state': 'in_progress', }), @@ -3475,6 +3480,7 @@ dict({ 'event': dict({ 'manager_state': 'create_backup', + 'reason': None, 'stage': 'home_assistant', 'state': 'in_progress', }), @@ -3486,6 +3492,7 @@ dict({ 'event': dict({ 'manager_state': 'create_backup', + 'reason': None, 'stage': 'upload_to_agents', 'state': 'in_progress', }), @@ -3497,6 +3504,7 @@ dict({ 'event': dict({ 'manager_state': 'create_backup', + 'reason': None, 'stage': None, 'state': 'completed', }), @@ -3525,6 +3533,7 @@ dict({ 'event': dict({ 'manager_state': 'create_backup', + 'reason': None, 'stage': None, 'state': 'in_progress', }), @@ -3546,6 +3555,7 @@ dict({ 'event': dict({ 'manager_state': 'create_backup', + 'reason': None, 'stage': 'home_assistant', 'state': 'in_progress', }), @@ -3557,6 +3567,7 @@ dict({ 'event': dict({ 'manager_state': 'create_backup', + 'reason': None, 'stage': 'upload_to_agents', 'state': 'in_progress', }), @@ -3568,6 +3579,7 @@ dict({ 'event': dict({ 'manager_state': 'create_backup', + 'reason': None, 'stage': None, 'state': 'completed', }), @@ -3912,6 +3924,7 @@ dict({ 'event': dict({ 'manager_state': 'create_backup', + 'reason': None, 'stage': None, 'state': 'in_progress', }), diff --git a/tests/components/backup/test_manager.py b/tests/components/backup/test_manager.py index c6eeff79d45..f2c2e5c5b05 100644 --- a/tests/components/backup/test_manager.py +++ b/tests/components/backup/test_manager.py @@ -419,6 +419,7 @@ async def test_initiate_backup( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.CREATE_BACKUP, + "reason": None, "stage": None, "state": CreateBackupState.IN_PROGRESS, } @@ -433,6 +434,7 @@ async def test_initiate_backup( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.CREATE_BACKUP, + "reason": None, "stage": CreateBackupStage.HOME_ASSISTANT, "state": CreateBackupState.IN_PROGRESS, } @@ -440,6 +442,7 @@ async def test_initiate_backup( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.CREATE_BACKUP, + "reason": None, "stage": CreateBackupStage.UPLOAD_TO_AGENTS, "state": CreateBackupState.IN_PROGRESS, } @@ -447,6 +450,7 @@ async def test_initiate_backup( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.CREATE_BACKUP, + "reason": None, "stage": None, "state": CreateBackupState.COMPLETED, } @@ -670,6 +674,7 @@ async def test_initiate_backup_with_agent_error( assert result["event"] == { "manager_state": BackupManagerState.CREATE_BACKUP, "stage": None, + "reason": None, "state": CreateBackupState.IN_PROGRESS, } result = await ws_client.receive_json() @@ -683,6 +688,7 @@ async def test_initiate_backup_with_agent_error( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.CREATE_BACKUP, + "reason": None, "stage": CreateBackupStage.HOME_ASSISTANT, "state": CreateBackupState.IN_PROGRESS, } @@ -690,6 +696,7 @@ async def test_initiate_backup_with_agent_error( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.CREATE_BACKUP, + "reason": None, "stage": CreateBackupStage.UPLOAD_TO_AGENTS, "state": CreateBackupState.IN_PROGRESS, } @@ -697,6 +704,7 @@ async def test_initiate_backup_with_agent_error( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.CREATE_BACKUP, + "reason": "upload_failed", "stage": None, "state": CreateBackupState.FAILED, } @@ -1025,6 +1033,7 @@ async def test_initiate_backup_non_agent_upload_error( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.CREATE_BACKUP, + "reason": None, "stage": None, "state": CreateBackupState.IN_PROGRESS, } @@ -1039,6 +1048,7 @@ async def test_initiate_backup_non_agent_upload_error( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.CREATE_BACKUP, + "reason": None, "stage": CreateBackupStage.HOME_ASSISTANT, "state": CreateBackupState.IN_PROGRESS, } @@ -1046,6 +1056,7 @@ async def test_initiate_backup_non_agent_upload_error( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.CREATE_BACKUP, + "reason": None, "stage": CreateBackupStage.UPLOAD_TO_AGENTS, "state": CreateBackupState.IN_PROGRESS, } @@ -1053,6 +1064,7 @@ async def test_initiate_backup_non_agent_upload_error( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.CREATE_BACKUP, + "reason": "upload_failed", "stage": None, "state": CreateBackupState.FAILED, } @@ -1131,6 +1143,7 @@ async def test_initiate_backup_with_task_error( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.CREATE_BACKUP, + "reason": None, "stage": None, "state": CreateBackupState.IN_PROGRESS, } @@ -1138,6 +1151,7 @@ async def test_initiate_backup_with_task_error( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.CREATE_BACKUP, + "reason": "upload_failed", "stage": None, "state": CreateBackupState.FAILED, } @@ -1245,6 +1259,7 @@ async def test_initiate_backup_file_error( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.CREATE_BACKUP, + "reason": None, "stage": None, "state": CreateBackupState.IN_PROGRESS, } @@ -1259,6 +1274,7 @@ async def test_initiate_backup_file_error( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.CREATE_BACKUP, + "reason": None, "stage": CreateBackupStage.HOME_ASSISTANT, "state": CreateBackupState.IN_PROGRESS, } @@ -1266,6 +1282,7 @@ async def test_initiate_backup_file_error( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.CREATE_BACKUP, + "reason": None, "stage": CreateBackupStage.UPLOAD_TO_AGENTS, "state": CreateBackupState.IN_PROGRESS, } @@ -1273,6 +1290,7 @@ async def test_initiate_backup_file_error( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.CREATE_BACKUP, + "reason": "upload_failed", "stage": None, "state": CreateBackupState.FAILED, } @@ -1559,6 +1577,7 @@ async def test_receive_backup_busy_manager( result = await ws_client.receive_json() assert result["event"] == { "manager_state": "create_backup", + "reason": None, "stage": None, "state": "in_progress", } @@ -1752,6 +1771,7 @@ async def test_receive_backup_agent_error( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.RECEIVE_BACKUP, + "reason": None, "stage": None, "state": ReceiveBackupState.IN_PROGRESS, } @@ -1759,6 +1779,7 @@ async def test_receive_backup_agent_error( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.RECEIVE_BACKUP, + "reason": None, "stage": ReceiveBackupStage.RECEIVE_FILE, "state": ReceiveBackupState.IN_PROGRESS, } @@ -1766,6 +1787,7 @@ async def test_receive_backup_agent_error( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.RECEIVE_BACKUP, + "reason": None, "stage": ReceiveBackupStage.UPLOAD_TO_AGENTS, "state": ReceiveBackupState.IN_PROGRESS, } @@ -1773,6 +1795,7 @@ async def test_receive_backup_agent_error( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.RECEIVE_BACKUP, + "reason": None, "stage": None, "state": ReceiveBackupState.COMPLETED, } @@ -1885,6 +1908,7 @@ async def test_receive_backup_non_agent_upload_error( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.RECEIVE_BACKUP, + "reason": None, "stage": None, "state": ReceiveBackupState.IN_PROGRESS, } @@ -1892,6 +1916,7 @@ async def test_receive_backup_non_agent_upload_error( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.RECEIVE_BACKUP, + "reason": None, "stage": ReceiveBackupStage.RECEIVE_FILE, "state": ReceiveBackupState.IN_PROGRESS, } @@ -1899,6 +1924,7 @@ async def test_receive_backup_non_agent_upload_error( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.RECEIVE_BACKUP, + "reason": None, "stage": ReceiveBackupStage.UPLOAD_TO_AGENTS, "state": ReceiveBackupState.IN_PROGRESS, } @@ -2007,6 +2033,7 @@ async def test_receive_backup_file_write_error( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.RECEIVE_BACKUP, + "reason": None, "stage": None, "state": ReceiveBackupState.IN_PROGRESS, } @@ -2014,6 +2041,7 @@ async def test_receive_backup_file_write_error( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.RECEIVE_BACKUP, + "reason": None, "stage": ReceiveBackupStage.RECEIVE_FILE, "state": ReceiveBackupState.IN_PROGRESS, } @@ -2021,6 +2049,7 @@ async def test_receive_backup_file_write_error( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.RECEIVE_BACKUP, + "reason": "unknown_error", "stage": None, "state": ReceiveBackupState.FAILED, } @@ -2114,6 +2143,7 @@ async def test_receive_backup_read_tar_error( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.RECEIVE_BACKUP, + "reason": None, "stage": None, "state": ReceiveBackupState.IN_PROGRESS, } @@ -2121,6 +2151,7 @@ async def test_receive_backup_read_tar_error( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.RECEIVE_BACKUP, + "reason": None, "stage": ReceiveBackupStage.RECEIVE_FILE, "state": ReceiveBackupState.IN_PROGRESS, } @@ -2128,6 +2159,7 @@ async def test_receive_backup_read_tar_error( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.RECEIVE_BACKUP, + "reason": "unknown_error", "stage": None, "state": ReceiveBackupState.FAILED, } @@ -2151,6 +2183,7 @@ async def test_receive_backup_read_tar_error( "unlink_call_count", "unlink_exception", "final_state", + "final_state_reason", "response_status", ), [ @@ -2164,6 +2197,7 @@ async def test_receive_backup_read_tar_error( 1, None, ReceiveBackupState.COMPLETED, + None, 201, ), ( @@ -2176,6 +2210,7 @@ async def test_receive_backup_read_tar_error( 1, None, ReceiveBackupState.COMPLETED, + None, 201, ), ( @@ -2188,6 +2223,7 @@ async def test_receive_backup_read_tar_error( 1, None, ReceiveBackupState.COMPLETED, + None, 201, ), ( @@ -2200,6 +2236,7 @@ async def test_receive_backup_read_tar_error( 1, OSError("Boom!"), ReceiveBackupState.FAILED, + "unknown_error", 500, ), ], @@ -2218,6 +2255,7 @@ async def test_receive_backup_file_read_error( unlink_call_count: int, unlink_exception: Exception | None, final_state: ReceiveBackupState, + final_state_reason: str | None, response_status: int, ) -> None: """Test file read error during backup receive.""" @@ -2288,6 +2326,7 @@ async def test_receive_backup_file_read_error( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.RECEIVE_BACKUP, + "reason": None, "stage": None, "state": ReceiveBackupState.IN_PROGRESS, } @@ -2295,6 +2334,7 @@ async def test_receive_backup_file_read_error( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.RECEIVE_BACKUP, + "reason": None, "stage": ReceiveBackupStage.RECEIVE_FILE, "state": ReceiveBackupState.IN_PROGRESS, } @@ -2302,6 +2342,7 @@ async def test_receive_backup_file_read_error( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.RECEIVE_BACKUP, + "reason": None, "stage": ReceiveBackupStage.UPLOAD_TO_AGENTS, "state": ReceiveBackupState.IN_PROGRESS, } @@ -2309,6 +2350,7 @@ async def test_receive_backup_file_read_error( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.RECEIVE_BACKUP, + "reason": final_state_reason, "stage": None, "state": final_state, } @@ -2394,6 +2436,7 @@ async def test_restore_backup( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.RESTORE_BACKUP, + "reason": None, "stage": None, "state": RestoreBackupState.IN_PROGRESS, } @@ -2401,6 +2444,7 @@ async def test_restore_backup( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.RESTORE_BACKUP, + "reason": None, "stage": None, "state": RestoreBackupState.CORE_RESTART, } @@ -2410,6 +2454,7 @@ async def test_restore_backup( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.RESTORE_BACKUP, + "reason": None, "stage": None, "state": RestoreBackupState.COMPLETED, } @@ -2497,6 +2542,7 @@ async def test_restore_backup_wrong_password( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.RESTORE_BACKUP, + "reason": None, "stage": None, "state": RestoreBackupState.IN_PROGRESS, } @@ -2504,6 +2550,7 @@ async def test_restore_backup_wrong_password( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.RESTORE_BACKUP, + "reason": "password_incorrect", "stage": None, "state": RestoreBackupState.FAILED, } @@ -2523,23 +2570,27 @@ async def test_restore_backup_wrong_password( @pytest.mark.usefixtures("path_glob") @pytest.mark.parametrize( - ("parameters", "expected_error"), + ("parameters", "expected_error", "expected_reason"), [ ( {"backup_id": TEST_BACKUP_DEF456.backup_id}, f"Backup def456 not found in agent {LOCAL_AGENT_ID}", + "backup_manager_error", ), ( {"restore_addons": ["blah"]}, "Addons and folders are not supported in core restore", + "backup_reader_writer_error", ), ( {"restore_folders": [Folder.ADDONS]}, "Addons and folders are not supported in core restore", + "backup_reader_writer_error", ), ( {"restore_database": False, "restore_homeassistant": False}, "Home Assistant or database must be included in restore", + "backup_reader_writer_error", ), ], ) @@ -2548,6 +2599,7 @@ async def test_restore_backup_wrong_parameters( hass_ws_client: WebSocketGenerator, parameters: dict[str, Any], expected_error: str, + expected_reason: str, ) -> None: """Test restore backup wrong parameters.""" await async_setup_component(hass, DOMAIN, {}) @@ -2584,6 +2636,7 @@ async def test_restore_backup_wrong_parameters( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.RESTORE_BACKUP, + "reason": None, "stage": None, "state": RestoreBackupState.IN_PROGRESS, } @@ -2591,6 +2644,7 @@ async def test_restore_backup_wrong_parameters( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.RESTORE_BACKUP, + "reason": expected_reason, "stage": None, "state": RestoreBackupState.FAILED, } @@ -2640,10 +2694,20 @@ async def test_restore_backup_when_busy( @pytest.mark.usefixtures("mock_backup_generation") @pytest.mark.parametrize( - ("exception", "error_code", "error_message"), + ("exception", "error_code", "error_message", "expected_reason"), [ - (BackupAgentError("Boom!"), "home_assistant_error", "Boom!"), - (Exception("Boom!"), "unknown_error", "Unknown error"), + ( + BackupAgentError("Boom!"), + "home_assistant_error", + "Boom!", + "backup_agent_error", + ), + ( + Exception("Boom!"), + "unknown_error", + "Unknown error", + "unknown_error", + ), ], ) async def test_restore_backup_agent_error( @@ -2652,6 +2716,7 @@ async def test_restore_backup_agent_error( exception: Exception, error_code: str, error_message: str, + expected_reason: str, ) -> None: """Test restore backup with agent error.""" remote_agent = BackupAgentTest("remote", backups=[TEST_BACKUP_ABC123]) @@ -2694,6 +2759,7 @@ async def test_restore_backup_agent_error( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.RESTORE_BACKUP, + "reason": None, "stage": None, "state": RestoreBackupState.IN_PROGRESS, } @@ -2701,6 +2767,7 @@ async def test_restore_backup_agent_error( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.RESTORE_BACKUP, + "reason": expected_reason, "stage": None, "state": RestoreBackupState.FAILED, } @@ -2841,6 +2908,7 @@ async def test_restore_backup_file_error( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.RESTORE_BACKUP, + "reason": None, "stage": None, "state": RestoreBackupState.IN_PROGRESS, } @@ -2848,6 +2916,7 @@ async def test_restore_backup_file_error( result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.RESTORE_BACKUP, + "reason": "unknown_error", "stage": None, "state": RestoreBackupState.FAILED, } diff --git a/tests/components/backup/test_websocket.py b/tests/components/backup/test_websocket.py index 52c04474162..0fd0ba308b3 100644 --- a/tests/components/backup/test_websocket.py +++ b/tests/components/backup/test_websocket.py @@ -2816,7 +2816,7 @@ async def test_subscribe_event( assert await client.receive_json() == snapshot manager.async_on_backup_event( - CreateBackupEvent(stage=None, state=CreateBackupState.IN_PROGRESS) + CreateBackupEvent(stage=None, state=CreateBackupState.IN_PROGRESS, reason=None) ) assert await client.receive_json() == snapshot diff --git a/tests/components/hassio/test_backup.py b/tests/components/hassio/test_backup.py index 9483b513718..8cf8d11af04 100644 --- a/tests/components/hassio/test_backup.py +++ b/tests/components/hassio/test_backup.py @@ -753,6 +753,7 @@ async def test_reader_writer_create( response = await client.receive_json() assert response["event"] == { "manager_state": "create_backup", + "reason": None, "stage": None, "state": "in_progress", } @@ -780,6 +781,7 @@ async def test_reader_writer_create( response = await client.receive_json() assert response["event"] == { "manager_state": "create_backup", + "reason": None, "stage": "upload_to_agents", "state": "in_progress", } @@ -787,6 +789,7 @@ async def test_reader_writer_create( response = await client.receive_json() assert response["event"] == { "manager_state": "create_backup", + "reason": None, "stage": None, "state": "completed", } @@ -800,14 +803,20 @@ async def test_reader_writer_create( @pytest.mark.usefixtures("hassio_client", "setup_integration") @pytest.mark.parametrize( - ("side_effect", "error_code", "error_message"), + ("side_effect", "error_code", "error_message", "expected_reason"), [ ( SupervisorError("Boom!"), "home_assistant_error", "Error creating backup: Boom!", + "backup_manager_error", + ), + ( + Exception("Boom!"), + "unknown_error", + "Unknown error", + "unknown_error", ), - (Exception("Boom!"), "unknown_error", "Unknown error"), ], ) async def test_reader_writer_create_partial_backup_error( @@ -817,6 +826,7 @@ async def test_reader_writer_create_partial_backup_error( side_effect: Exception, error_code: str, error_message: str, + expected_reason: str, ) -> None: """Test client partial backup error when generating a backup.""" client = await hass_ws_client(hass) @@ -834,6 +844,7 @@ async def test_reader_writer_create_partial_backup_error( response = await client.receive_json() assert response["event"] == { "manager_state": "create_backup", + "reason": None, "stage": None, "state": "in_progress", } @@ -841,6 +852,7 @@ async def test_reader_writer_create_partial_backup_error( response = await client.receive_json() assert response["event"] == { "manager_state": "create_backup", + "reason": expected_reason, "stage": None, "state": "failed", } @@ -878,6 +890,7 @@ async def test_reader_writer_create_missing_reference_error( response = await client.receive_json() assert response["event"] == { "manager_state": "create_backup", + "reason": None, "stage": None, "state": "in_progress", } @@ -903,6 +916,7 @@ async def test_reader_writer_create_missing_reference_error( response = await client.receive_json() assert response["event"] == { "manager_state": "create_backup", + "reason": "upload_failed", "stage": None, "state": "failed", } @@ -961,6 +975,7 @@ async def test_reader_writer_create_download_remove_error( response = await client.receive_json() assert response["event"] == { "manager_state": "create_backup", + "reason": None, "stage": None, "state": "in_progress", } @@ -986,6 +1001,7 @@ async def test_reader_writer_create_download_remove_error( response = await client.receive_json() assert response["event"] == { "manager_state": "create_backup", + "reason": None, "stage": "upload_to_agents", "state": "in_progress", } @@ -993,6 +1009,7 @@ async def test_reader_writer_create_download_remove_error( response = await client.receive_json() assert response["event"] == { "manager_state": "create_backup", + "reason": "upload_failed", "stage": None, "state": "failed", } @@ -1042,6 +1059,7 @@ async def test_reader_writer_create_info_error( response = await client.receive_json() assert response["event"] == { "manager_state": "create_backup", + "reason": None, "stage": None, "state": "in_progress", } @@ -1067,6 +1085,7 @@ async def test_reader_writer_create_info_error( response = await client.receive_json() assert response["event"] == { "manager_state": "create_backup", + "reason": "upload_failed", "stage": None, "state": "failed", } @@ -1114,6 +1133,7 @@ async def test_reader_writer_create_remote_backup( response = await client.receive_json() assert response["event"] == { "manager_state": "create_backup", + "reason": None, "stage": None, "state": "in_progress", } @@ -1141,6 +1161,7 @@ async def test_reader_writer_create_remote_backup( response = await client.receive_json() assert response["event"] == { "manager_state": "create_backup", + "reason": None, "stage": "upload_to_agents", "state": "in_progress", } @@ -1148,6 +1169,7 @@ async def test_reader_writer_create_remote_backup( response = await client.receive_json() assert response["event"] == { "manager_state": "create_backup", + "reason": None, "stage": None, "state": "completed", } @@ -1204,6 +1226,7 @@ async def test_reader_writer_create_wrong_parameters( response = await client.receive_json() assert response["event"] == { "manager_state": "create_backup", + "reason": None, "stage": None, "state": "in_progress", } @@ -1211,6 +1234,7 @@ async def test_reader_writer_create_wrong_parameters( response = await client.receive_json() assert response["event"] == { "manager_state": "create_backup", + "reason": "unknown_error", "stage": None, "state": "failed", } @@ -1316,6 +1340,7 @@ async def test_reader_writer_restore( response = await client.receive_json() assert response["event"] == { "manager_state": "restore_backup", + "reason": None, "stage": None, "state": "in_progress", } @@ -1347,6 +1372,7 @@ async def test_reader_writer_restore( response = await client.receive_json() assert response["event"] == { "manager_state": "restore_backup", + "reason": None, "stage": None, "state": "completed", } @@ -1360,15 +1386,13 @@ async def test_reader_writer_restore( @pytest.mark.parametrize( - ("supervisor_error_string", "expected_error_code"), + ("supervisor_error_string", "expected_error_code", "expected_reason"), [ - ( - "Invalid password for backup", - "password_incorrect", - ), + ("Invalid password for backup", "password_incorrect", "password_incorrect"), ( "Backup was made on supervisor version 2025.12.0, can't restore on 2024.12.0. Must update supervisor first.", "home_assistant_error", + "unknown_error", ), ], ) @@ -1379,6 +1403,7 @@ async def test_reader_writer_restore_error( supervisor_client: AsyncMock, supervisor_error_string: str, expected_error_code: str, + expected_reason: str, ) -> None: """Test restoring a backup.""" client = await hass_ws_client(hass) @@ -1400,6 +1425,7 @@ async def test_reader_writer_restore_error( response = await client.receive_json() assert response["event"] == { "manager_state": "restore_backup", + "reason": None, "stage": None, "state": "in_progress", } @@ -1419,6 +1445,7 @@ async def test_reader_writer_restore_error( response = await client.receive_json() assert response["event"] == { "manager_state": "restore_backup", + "reason": expected_reason, "stage": None, "state": "failed", } From 139061afa349937055894848a84511b671b4fbdb Mon Sep 17 00:00:00 2001 From: Dan Raper Date: Tue, 28 Jan 2025 14:14:43 +0000 Subject: [PATCH 0130/3148] Bump ohmepy to 1.2.8 (#136719) --- homeassistant/components/ohme/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ohme/manifest.json b/homeassistant/components/ohme/manifest.json index bb3716c3e74..602c53ced7b 100644 --- a/homeassistant/components/ohme/manifest.json +++ b/homeassistant/components/ohme/manifest.json @@ -7,5 +7,5 @@ "integration_type": "device", "iot_class": "cloud_polling", "quality_scale": "silver", - "requirements": ["ohme==1.2.6"] + "requirements": ["ohme==1.2.8"] } diff --git a/requirements_all.txt b/requirements_all.txt index 97d3a5402a3..baec606c57c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1541,7 +1541,7 @@ odp-amsterdam==6.0.2 oemthermostat==1.1.1 # homeassistant.components.ohme -ohme==1.2.6 +ohme==1.2.8 # homeassistant.components.ollama ollama==0.4.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3e91e456224..ad8c67ba1fb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1289,7 +1289,7 @@ objgraph==3.5.0 odp-amsterdam==6.0.2 # homeassistant.components.ohme -ohme==1.2.6 +ohme==1.2.8 # homeassistant.components.ollama ollama==0.4.7 From 658d3cf06e107e3003cf63943af5537b8833355c Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Tue, 28 Jan 2025 15:16:58 +0100 Subject: [PATCH 0131/3148] Add support for KNX UI to create BinarySensor entities (#136703) --- homeassistant/components/knx/binary_sensor.py | 130 +++++++++++++----- homeassistant/components/knx/const.py | 8 +- homeassistant/components/knx/schema.py | 10 +- homeassistant/components/knx/storage/const.py | 1 + .../knx/storage/entity_store_schema.py | 32 ++++- tests/components/knx/test_binary_sensor.py | 57 +++++++- 6 files changed, 191 insertions(+), 47 deletions(-) diff --git a/homeassistant/components/knx/binary_sensor.py b/homeassistant/components/knx/binary_sensor.py index 96438df96d7..c629860351c 100644 --- a/homeassistant/components/knx/binary_sensor.py +++ b/homeassistant/components/knx/binary_sensor.py @@ -18,14 +18,28 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import ( + AddEntitiesCallback, + async_get_current_platform, +) from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType from . import KNXModule -from .const import ATTR_COUNTER, ATTR_SOURCE, KNX_MODULE_KEY -from .entity import KnxYamlEntity -from .schema import BinarySensorSchema +from .const import ( + ATTR_COUNTER, + ATTR_SOURCE, + CONF_CONTEXT_TIMEOUT, + CONF_IGNORE_INTERNAL_STATE, + CONF_INVERT, + CONF_RESET_AFTER, + CONF_STATE_ADDRESS, + CONF_SYNC_STATE, + DOMAIN, + KNX_MODULE_KEY, +) +from .entity import KnxUiEntity, KnxUiEntityPlatformController, KnxYamlEntity +from .storage.const import CONF_ENTITY, CONF_GA_PASSIVE, CONF_GA_SENSOR, CONF_GA_STATE async def async_setup_entry( @@ -35,40 +49,38 @@ async def async_setup_entry( ) -> None: """Set up the KNX binary sensor platform.""" knx_module = hass.data[KNX_MODULE_KEY] - config: list[ConfigType] = knx_module.config_yaml[Platform.BINARY_SENSOR] - - async_add_entities( - KNXBinarySensor(knx_module, entity_config) for entity_config in config + platform = async_get_current_platform() + knx_module.config_store.add_platform( + platform=Platform.BINARY_SENSOR, + controller=KnxUiEntityPlatformController( + knx_module=knx_module, + entity_platform=platform, + entity_class=KnxUiBinarySensor, + ), ) + entities: list[KnxYamlEntity | KnxUiEntity] = [] + if yaml_platform_config := knx_module.config_yaml.get(Platform.BINARY_SENSOR): + entities.extend( + KnxYamlBinarySensor(knx_module, entity_config) + for entity_config in yaml_platform_config + ) + if ui_config := knx_module.config_store.data["entities"].get( + Platform.BINARY_SENSOR + ): + entities.extend( + KnxUiBinarySensor(knx_module, unique_id, config) + for unique_id, config in ui_config.items() + ) + if entities: + async_add_entities(entities) -class KNXBinarySensor(KnxYamlEntity, BinarySensorEntity, RestoreEntity): + +class _KnxBinarySensor(BinarySensorEntity, RestoreEntity): """Representation of a KNX binary sensor.""" _device: XknxBinarySensor - def __init__(self, knx_module: KNXModule, config: ConfigType) -> None: - """Initialize of KNX binary sensor.""" - super().__init__( - knx_module=knx_module, - device=XknxBinarySensor( - xknx=knx_module.xknx, - name=config[CONF_NAME], - group_address_state=config[BinarySensorSchema.CONF_STATE_ADDRESS], - invert=config[BinarySensorSchema.CONF_INVERT], - sync_state=config[BinarySensorSchema.CONF_SYNC_STATE], - ignore_internal_state=config[ - BinarySensorSchema.CONF_IGNORE_INTERNAL_STATE - ], - context_timeout=config.get(BinarySensorSchema.CONF_CONTEXT_TIMEOUT), - reset_after=config.get(BinarySensorSchema.CONF_RESET_AFTER), - ), - ) - self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY) - self._attr_device_class = config.get(CONF_DEVICE_CLASS) - self._attr_force_update = self._device.ignore_internal_state - self._attr_unique_id = str(self._device.remote_value.group_address_state) - async def async_added_to_hass(self) -> None: """Restore last state.""" await super().async_added_to_hass() @@ -92,3 +104,59 @@ class KNXBinarySensor(KnxYamlEntity, BinarySensorEntity, RestoreEntity): if self._device.last_telegram is not None: attr[ATTR_SOURCE] = str(self._device.last_telegram.source_address) return attr + + +class KnxYamlBinarySensor(_KnxBinarySensor, KnxYamlEntity): + """Representation of a KNX binary sensor configured from YAML.""" + + _device: XknxBinarySensor + + def __init__(self, knx_module: KNXModule, config: ConfigType) -> None: + """Initialize of KNX binary sensor.""" + super().__init__( + knx_module=knx_module, + device=XknxBinarySensor( + xknx=knx_module.xknx, + name=config[CONF_NAME], + group_address_state=config[CONF_STATE_ADDRESS], + invert=config[CONF_INVERT], + sync_state=config[CONF_SYNC_STATE], + ignore_internal_state=config[CONF_IGNORE_INTERNAL_STATE], + context_timeout=config.get(CONF_CONTEXT_TIMEOUT), + reset_after=config.get(CONF_RESET_AFTER), + ), + ) + self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY) + self._attr_device_class = config.get(CONF_DEVICE_CLASS) + self._attr_force_update = self._device.ignore_internal_state + self._attr_unique_id = str(self._device.remote_value.group_address_state) + + +class KnxUiBinarySensor(_KnxBinarySensor, KnxUiEntity): + """Representation of a KNX binary sensor configured from UI.""" + + _device: XknxBinarySensor + + def __init__( + self, knx_module: KNXModule, unique_id: str, config: dict[str, Any] + ) -> None: + """Initialize KNX binary sensor.""" + super().__init__( + knx_module=knx_module, + unique_id=unique_id, + entity_config=config[CONF_ENTITY], + ) + self._device = XknxBinarySensor( + xknx=knx_module.xknx, + name=config[CONF_ENTITY][CONF_NAME], + group_address_state=[ + config[DOMAIN][CONF_GA_SENSOR][CONF_GA_STATE], + *config[DOMAIN][CONF_GA_SENSOR][CONF_GA_PASSIVE], + ], + sync_state=config[DOMAIN][CONF_SYNC_STATE], + invert=config[DOMAIN].get(CONF_INVERT, False), + ignore_internal_state=config[DOMAIN].get(CONF_IGNORE_INTERNAL_STATE, False), + context_timeout=config[DOMAIN].get(CONF_CONTEXT_TIMEOUT), + reset_after=config[DOMAIN].get(CONF_RESET_AFTER), + ) + self._attr_force_update = self._device.ignore_internal_state diff --git a/homeassistant/components/knx/const.py b/homeassistant/components/knx/const.py index 3ef35479c4e..b403018dae3 100644 --- a/homeassistant/components/knx/const.py +++ b/homeassistant/components/knx/const.py @@ -67,6 +67,8 @@ CONF_KNX_SECURE_USER_PASSWORD: Final = "user_password" CONF_KNX_SECURE_DEVICE_AUTHENTICATION: Final = "device_authentication" +CONF_CONTEXT_TIMEOUT: Final = "context_timeout" +CONF_IGNORE_INTERNAL_STATE: Final = "ignore_internal_state" CONF_PAYLOAD_LENGTH: Final = "payload_length" CONF_RESET_AFTER: Final = "reset_after" CONF_RESPOND_TO_READ: Final = "respond_to_read" @@ -156,7 +158,11 @@ SUPPORTED_PLATFORMS_YAML: Final = { Platform.WEATHER, } -SUPPORTED_PLATFORMS_UI: Final = {Platform.SWITCH, Platform.LIGHT} +SUPPORTED_PLATFORMS_UI: Final = { + Platform.BINARY_SENSOR, + Platform.LIGHT, + Platform.SWITCH, +} # Map KNX controller modes to HA modes. This list might not be complete. CONTROLLER_MODES: Final = { diff --git a/homeassistant/components/knx/schema.py b/homeassistant/components/knx/schema.py index 9311046e410..5c83da58c3a 100644 --- a/homeassistant/components/knx/schema.py +++ b/homeassistant/components/knx/schema.py @@ -45,6 +45,8 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import ENTITY_CATEGORIES_SCHEMA from .const import ( + CONF_CONTEXT_TIMEOUT, + CONF_IGNORE_INTERNAL_STATE, CONF_INVERT, CONF_KNX_EXPOSE, CONF_PAYLOAD_LENGTH, @@ -211,14 +213,6 @@ class BinarySensorSchema(KNXPlatformSchema): """Voluptuous schema for KNX binary sensors.""" PLATFORM = Platform.BINARY_SENSOR - - CONF_STATE_ADDRESS = CONF_STATE_ADDRESS - CONF_SYNC_STATE = CONF_SYNC_STATE - CONF_INVERT = CONF_INVERT - CONF_IGNORE_INTERNAL_STATE = "ignore_internal_state" - CONF_CONTEXT_TIMEOUT = "context_timeout" - CONF_RESET_AFTER = CONF_RESET_AFTER - DEFAULT_NAME = "KNX Binary Sensor" ENTITY_SCHEMA = vol.All( diff --git a/homeassistant/components/knx/storage/const.py b/homeassistant/components/knx/storage/const.py index 42b76a5a0fd..cf3f2bb9f95 100644 --- a/homeassistant/components/knx/storage/const.py +++ b/homeassistant/components/knx/storage/const.py @@ -10,6 +10,7 @@ CONF_GA_STATE: Final = "state" CONF_GA_PASSIVE: Final = "passive" CONF_DPT: Final = "dpt" +CONF_GA_SENSOR: Final = "ga_sensor" CONF_GA_SWITCH: Final = "ga_switch" CONF_GA_COLOR_TEMP: Final = "ga_color_temp" CONF_COLOR_TEMP_MIN: Final = "color_temp_min" diff --git a/homeassistant/components/knx/storage/entity_store_schema.py b/homeassistant/components/knx/storage/entity_store_schema.py index 84854d2ec85..d99ffa86f52 100644 --- a/homeassistant/components/knx/storage/entity_store_schema.py +++ b/homeassistant/components/knx/storage/entity_store_schema.py @@ -11,12 +11,15 @@ from homeassistant.const import ( CONF_PLATFORM, Platform, ) -from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import config_validation as cv, selector from homeassistant.helpers.entity import ENTITY_CATEGORIES_SCHEMA from homeassistant.helpers.typing import VolDictType, VolSchemaType from ..const import ( + CONF_CONTEXT_TIMEOUT, + CONF_IGNORE_INTERNAL_STATE, CONF_INVERT, + CONF_RESET_AFTER, CONF_RESPOND_TO_READ, CONF_SYNC_STATE, DOMAIN, @@ -42,6 +45,7 @@ from .const import ( CONF_GA_RED_BRIGHTNESS, CONF_GA_RED_SWITCH, CONF_GA_SATURATION, + CONF_GA_SENSOR, CONF_GA_STATE, CONF_GA_SWITCH, CONF_GA_WHITE_BRIGHTNESS, @@ -94,6 +98,29 @@ def optional_ga_schema(key: str, ga_selector: GASelector) -> VolDictType: } +BINARY_SENSOR_SCHEMA = vol.Schema( + { + vol.Required(CONF_ENTITY): BASE_ENTITY_SCHEMA, + vol.Required(DOMAIN): { + vol.Required(CONF_GA_SENSOR): GASelector(write=False, state_required=True), + vol.Required(CONF_RESPOND_TO_READ, default=False): bool, + vol.Required(CONF_SYNC_STATE, default=True): sync_state_validator, + vol.Optional(CONF_INVERT): selector.BooleanSelector(), + vol.Optional(CONF_IGNORE_INTERNAL_STATE): selector.BooleanSelector(), + vol.Optional(CONF_CONTEXT_TIMEOUT): selector.NumberSelector( + selector.NumberSelectorConfig( + min=0, max=10, step=0.1, unit_of_measurement="s" + ) + ), + vol.Optional(CONF_RESET_AFTER): selector.NumberSelector( + selector.NumberSelectorConfig( + min=0, max=10, step=0.1, unit_of_measurement="s" + ) + ), + }, + } +) + SWITCH_SCHEMA = vol.Schema( { vol.Required(CONF_ENTITY): BASE_ENTITY_SCHEMA, @@ -213,6 +240,9 @@ ENTITY_STORE_DATA_SCHEMA: VolSchemaType = vol.All( cv.key_value_schemas( CONF_PLATFORM, { + Platform.BINARY_SENSOR: vol.Schema( + {vol.Required(CONF_DATA): BINARY_SENSOR_SCHEMA}, extra=vol.ALLOW_EXTRA + ), Platform.SWITCH: vol.Schema( {vol.Required(CONF_DATA): SWITCH_SCHEMA}, extra=vol.ALLOW_EXTRA ), diff --git a/tests/components/knx/test_binary_sensor.py b/tests/components/knx/test_binary_sensor.py index dbb8d2ee832..4b58801a8a0 100644 --- a/tests/components/knx/test_binary_sensor.py +++ b/tests/components/knx/test_binary_sensor.py @@ -1,10 +1,19 @@ """Test KNX binary sensor.""" from datetime import timedelta +from typing import Any from freezegun.api import FrozenDateTimeFactory +import pytest -from homeassistant.components.knx.const import CONF_STATE_ADDRESS, CONF_SYNC_STATE +from homeassistant.components.knx.const import ( + CONF_CONTEXT_TIMEOUT, + CONF_IGNORE_INTERNAL_STATE, + CONF_INVERT, + CONF_RESET_AFTER, + CONF_STATE_ADDRESS, + CONF_SYNC_STATE, +) from homeassistant.components.knx.schema import BinarySensorSchema from homeassistant.const import ( CONF_ENTITY_CATEGORY, @@ -12,10 +21,12 @@ from homeassistant.const import ( STATE_OFF, STATE_ON, EntityCategory, + Platform, ) from homeassistant.core import HomeAssistant, State from homeassistant.helpers import entity_registry as er +from . import KnxEntityGenerator from .conftest import KNXTestKit from tests.common import ( @@ -60,7 +71,7 @@ async def test_binary_sensor(hass: HomeAssistant, knx: KNXTestKit) -> None: { CONF_NAME: "test_invert", CONF_STATE_ADDRESS: "2/2/2", - BinarySensorSchema.CONF_INVERT: True, + CONF_INVERT: True, }, ] } @@ -113,7 +124,7 @@ async def test_binary_sensor_ignore_internal_state( { CONF_NAME: "test_ignore", CONF_STATE_ADDRESS: "2/2/2", - BinarySensorSchema.CONF_IGNORE_INTERNAL_STATE: True, + CONF_IGNORE_INTERNAL_STATE: True, CONF_SYNC_STATE: False, }, ] @@ -156,7 +167,7 @@ async def test_binary_sensor_counter( { CONF_NAME: "test", CONF_STATE_ADDRESS: "2/2/2", - BinarySensorSchema.CONF_CONTEXT_TIMEOUT: context_timeout, + CONF_CONTEXT_TIMEOUT: context_timeout, CONF_SYNC_STATE: False, }, ] @@ -220,7 +231,7 @@ async def test_binary_sensor_reset( { CONF_NAME: "test", CONF_STATE_ADDRESS: "2/2/2", - BinarySensorSchema.CONF_RESET_AFTER: 1, + CONF_RESET_AFTER: 1, CONF_SYNC_STATE: False, }, ] @@ -279,7 +290,7 @@ async def test_binary_sensor_restore_invert(hass: HomeAssistant, knx) -> None: { CONF_NAME: "test", CONF_STATE_ADDRESS: _ADDRESS, - BinarySensorSchema.CONF_INVERT: True, + CONF_INVERT: True, CONF_SYNC_STATE: False, }, ] @@ -295,3 +306,37 @@ async def test_binary_sensor_restore_invert(hass: HomeAssistant, knx) -> None: await knx.receive_write(_ADDRESS, True) state = hass.states.get("binary_sensor.test") assert state.state is STATE_OFF + + +@pytest.mark.parametrize( + ("knx_data"), + [ + { + "ga_sensor": {"state": "2/2/2"}, + "sync_state": True, + }, + { + "ga_sensor": {"state": "2/2/2"}, + "sync_state": True, + "invert": True, + }, + ], +) +async def test_binary_sensor_ui_create( + hass: HomeAssistant, + knx: KNXTestKit, + create_ui_entity: KnxEntityGenerator, + knx_data: dict[str, Any], +) -> None: + """Test creating a binary sensor.""" + await knx.setup_integration({}) + await create_ui_entity( + platform=Platform.BINARY_SENSOR, + entity_data={"name": "test"}, + knx_data=knx_data, + ) + # created entity sends read-request to KNX bus + await knx.assert_read("2/2/2") + await knx.receive_response("2/2/2", not knx_data.get("invert")) + state = hass.states.get("binary_sensor.test") + assert state.state is STATE_ON From 58f7dd5dcc92437f16b57fd57d26030cf4e1fdd5 Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Tue, 28 Jan 2025 16:18:37 +0200 Subject: [PATCH 0132/3148] Fix LG webOS TV external arc volume set action (#136717) --- homeassistant/components/webostv/media_player.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/webostv/media_player.py b/homeassistant/components/webostv/media_player.py index 4b39841e29d..796dede88b6 100644 --- a/homeassistant/components/webostv/media_player.py +++ b/homeassistant/components/webostv/media_player.py @@ -228,7 +228,7 @@ class LgWebOSMediaPlayerEntity(RestoreEntity, MediaPlayerEntity): if self.state != MediaPlayerState.OFF or not self._supported_features: supported = SUPPORT_WEBOSTV - if self._client.sound_output in ("external_arc", "external_speaker"): + if self._client.sound_output == "external_speaker": supported = supported | SUPPORT_WEBOSTV_VOLUME elif self._client.sound_output != "lineout": supported = ( From 259f57b3aa3bc31492255563577fb046544887a6 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 28 Jan 2025 15:19:54 +0100 Subject: [PATCH 0133/3148] Use runtime_data in devialet (#136432) --- homeassistant/components/devialet/__init__.py | 19 ++++++++--------- .../components/devialet/coordinator.py | 10 ++++++++- .../components/devialet/diagnostics.py | 11 +++------- .../components/devialet/media_player.py | 21 ++++++++----------- tests/components/devialet/test_init.py | 5 ----- .../components/devialet/test_media_player.py | 9 -------- 6 files changed, 30 insertions(+), 45 deletions(-) diff --git a/homeassistant/components/devialet/__init__.py b/homeassistant/components/devialet/__init__.py index 2eccdb2a4b6..be641ad58a5 100644 --- a/homeassistant/components/devialet/__init__.py +++ b/homeassistant/components/devialet/__init__.py @@ -4,29 +4,28 @@ from __future__ import annotations from devialet import DevialetApi -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import DOMAIN +from .coordinator import DevialetConfigEntry, DevialetCoordinator PLATFORMS = [Platform.MEDIA_PLAYER] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: DevialetConfigEntry) -> bool: """Set up Devialet from a config entry.""" session = async_get_clientsession(hass) - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = DevialetApi( - entry.data[CONF_HOST], session - ) + client = DevialetApi(entry.data[CONF_HOST], session) + coordinator = DevialetCoordinator(hass, entry, client) + await coordinator.async_config_entry_first_refresh() + + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: DevialetConfigEntry) -> bool: """Unload Devialet config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - del hass.data[DOMAIN][entry.entry_id] - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/devialet/coordinator.py b/homeassistant/components/devialet/coordinator.py index 9cfeb797373..7b022b921f8 100644 --- a/homeassistant/components/devialet/coordinator.py +++ b/homeassistant/components/devialet/coordinator.py @@ -5,6 +5,7 @@ import logging from devialet import DevialetApi +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -14,15 +15,22 @@ _LOGGER = logging.getLogger(__name__) SCAN_INTERVAL = timedelta(seconds=5) +type DevialetConfigEntry = ConfigEntry[DevialetCoordinator] + class DevialetCoordinator(DataUpdateCoordinator[None]): """Devialet update coordinator.""" - def __init__(self, hass: HomeAssistant, client: DevialetApi) -> None: + config_entry: DevialetConfigEntry + + def __init__( + self, hass: HomeAssistant, entry: DevialetConfigEntry, client: DevialetApi + ) -> None: """Initialize the coordinator.""" super().__init__( hass, _LOGGER, + config_entry=entry, name=DOMAIN, update_interval=SCAN_INTERVAL, ) diff --git a/homeassistant/components/devialet/diagnostics.py b/homeassistant/components/devialet/diagnostics.py index ae887dd1c8c..75d6e7aa222 100644 --- a/homeassistant/components/devialet/diagnostics.py +++ b/homeassistant/components/devialet/diagnostics.py @@ -4,18 +4,13 @@ from __future__ import annotations from typing import Any -from devialet import DevialetApi - -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from .const import DOMAIN +from .coordinator import DevialetConfigEntry async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: DevialetConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - client: DevialetApi = hass.data[DOMAIN][entry.entry_id] - - return await client.async_get_diagnostics() + return await entry.runtime_data.client.async_get_diagnostics() diff --git a/homeassistant/components/devialet/media_player.py b/homeassistant/components/devialet/media_player.py index 8789516650a..04ec58723cf 100644 --- a/homeassistant/components/devialet/media_player.py +++ b/homeassistant/components/devialet/media_player.py @@ -9,7 +9,6 @@ from homeassistant.components.media_player import ( MediaPlayerEntityFeature, MediaPlayerState, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo @@ -17,7 +16,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN, MANUFACTURER, SOUND_MODES -from .coordinator import DevialetCoordinator +from .coordinator import DevialetConfigEntry, DevialetCoordinator SUPPORT_DEVIALET = ( MediaPlayerEntityFeature.VOLUME_SET @@ -37,14 +36,12 @@ DEVIALET_TO_HA_FEATURE_MAP = { async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: DevialetConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Devialet entry.""" - client = hass.data[DOMAIN][entry.entry_id] - coordinator = DevialetCoordinator(hass, client) - await coordinator.async_config_entry_first_refresh() - - async_add_entities([DevialetMediaPlayerEntity(coordinator, entry)]) + async_add_entities([DevialetMediaPlayerEntity(entry.runtime_data)]) class DevialetMediaPlayerEntity( @@ -55,18 +52,18 @@ class DevialetMediaPlayerEntity( _attr_has_entity_name = True _attr_name = None - def __init__(self, coordinator: DevialetCoordinator, entry: ConfigEntry) -> None: + def __init__(self, coordinator: DevialetCoordinator) -> None: """Initialize the Devialet device.""" - self.coordinator = coordinator super().__init__(coordinator) + entry = coordinator.config_entry self._attr_unique_id = str(entry.unique_id) self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, self._attr_unique_id)}, manufacturer=MANUFACTURER, - model=self.coordinator.client.model, + model=coordinator.client.model, name=entry.data[CONF_NAME], - sw_version=self.coordinator.client.version, + sw_version=coordinator.client.version, ) @callback diff --git a/tests/components/devialet/test_init.py b/tests/components/devialet/test_init.py index a87e8ac05c3..6808ee0983e 100644 --- a/tests/components/devialet/test_init.py +++ b/tests/components/devialet/test_init.py @@ -1,6 +1,5 @@ """Test the Devialet init.""" -from homeassistant.components.devialet.const import DOMAIN from homeassistant.components.media_player import DOMAIN as MP_DOMAIN, MediaPlayerState from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant @@ -16,7 +15,6 @@ async def test_load_unload_config_entry( """Test the Devialet configuration entry loading and unloading.""" entry = await setup_integration(hass, aioclient_mock) - assert entry.entry_id in hass.data[DOMAIN] assert entry.state is ConfigEntryState.LOADED assert entry.unique_id is not None @@ -26,7 +24,6 @@ async def test_load_unload_config_entry( await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() - assert entry.entry_id not in hass.data[DOMAIN] assert entry.state is ConfigEntryState.NOT_LOADED @@ -36,7 +33,6 @@ async def test_load_unload_config_entry_when_device_unavailable( """Test the Devialet configuration entry loading and unloading when the device is unavailable.""" entry = await setup_integration(hass, aioclient_mock, state="unavailable") - assert entry.entry_id in hass.data[DOMAIN] assert entry.state is ConfigEntryState.LOADED assert entry.unique_id is not None @@ -46,5 +42,4 @@ async def test_load_unload_config_entry_when_device_unavailable( await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() - assert entry.entry_id not in hass.data[DOMAIN] assert entry.state is ConfigEntryState.NOT_LOADED diff --git a/tests/components/devialet/test_media_player.py b/tests/components/devialet/test_media_player.py index 6ca3d23218f..fd593a10a98 100644 --- a/tests/components/devialet/test_media_player.py +++ b/tests/components/devialet/test_media_player.py @@ -6,7 +6,6 @@ from devialet import DevialetApi from devialet.const import UrlSuffix from yarl import URL -from homeassistant.components.devialet.const import DOMAIN from homeassistant.components.devialet.media_player import SUPPORT_DEVIALET from homeassistant.components.homeassistant import SERVICE_UPDATE_ENTITY from homeassistant.components.media_player import ( @@ -108,7 +107,6 @@ async def test_media_player_playing( await async_setup_component(hass, "homeassistant", {}) entry = await setup_integration(hass, aioclient_mock) - assert entry.entry_id in hass.data[DOMAIN] assert entry.state is ConfigEntryState.LOADED await hass.services.async_call( @@ -227,7 +225,6 @@ async def test_media_player_playing( await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() - assert entry.entry_id not in hass.data[DOMAIN] assert entry.state is ConfigEntryState.NOT_LOADED @@ -237,7 +234,6 @@ async def test_media_player_offline( """Test the Devialet configuration entry loading and unloading.""" entry = await setup_integration(hass, aioclient_mock, state=STATE_UNAVAILABLE) - assert entry.entry_id in hass.data[DOMAIN] assert entry.state is ConfigEntryState.LOADED state = hass.states.get(f"{MP_DOMAIN}.{NAME.lower()}") @@ -247,7 +243,6 @@ async def test_media_player_offline( await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() - assert entry.entry_id not in hass.data[DOMAIN] assert entry.state is ConfigEntryState.NOT_LOADED @@ -257,14 +252,12 @@ async def test_media_player_without_serial( """Test the Devialet configuration entry loading and unloading.""" entry = await setup_integration(hass, aioclient_mock, serial=None) - assert entry.entry_id in hass.data[DOMAIN] assert entry.state is ConfigEntryState.LOADED assert entry.unique_id is None await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() - assert entry.entry_id not in hass.data[DOMAIN] assert entry.state is ConfigEntryState.NOT_LOADED @@ -276,7 +269,6 @@ async def test_media_player_services( hass, aioclient_mock, state=MediaPlayerState.PLAYING ) - assert entry.entry_id in hass.data[DOMAIN] assert entry.state is ConfigEntryState.LOADED target = {ATTR_ENTITY_ID: hass.states.get(f"{MP_DOMAIN}.{NAME}").entity_id} @@ -309,5 +301,4 @@ async def test_media_player_services( await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() - assert entry.entry_id not in hass.data[DOMAIN] assert entry.state is ConfigEntryState.NOT_LOADED From 22e72953e543ec3d5727f07d98c1668fc6d3d6a2 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Tue, 28 Jan 2025 15:24:15 +0100 Subject: [PATCH 0134/3148] Adjust Matter discovery logic to disallow the primary value(s) to be None (#136712) --- homeassistant/components/matter/discovery.py | 9 ++++++++- homeassistant/components/matter/models.py | 3 +++ homeassistant/components/matter/number.py | 8 ++++++++ homeassistant/components/matter/select.py | 2 ++ homeassistant/components/matter/update.py | 1 + homeassistant/components/matter/vacuum.py | 1 + 6 files changed, 23 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/matter/discovery.py b/homeassistant/components/matter/discovery.py index de03d250836..7ca64482763 100644 --- a/homeassistant/components/matter/discovery.py +++ b/homeassistant/components/matter/discovery.py @@ -4,7 +4,7 @@ from __future__ import annotations from collections.abc import Generator -from chip.clusters.Objects import ClusterAttributeDescriptor +from chip.clusters.ClusterObjects import ClusterAttributeDescriptor, NullValue from matter_server.client.models.node import MatterEndpoint from homeassistant.const import Platform @@ -121,6 +121,13 @@ def async_discover_entities( ): continue + # check if value exists but is none/null + if not schema.allow_none_value and any( + endpoint.get_attribute_value(None, val_schema) in (None, NullValue) + for val_schema in schema.required_attributes + ): + continue + # check for required value in (primary) attribute primary_attribute = schema.required_attributes[0] primary_value = endpoint.get_attribute_value(None, primary_attribute) diff --git a/homeassistant/components/matter/models.py b/homeassistant/components/matter/models.py index f1fd7ca9fa3..ea80d0eb903 100644 --- a/homeassistant/components/matter/models.py +++ b/homeassistant/components/matter/models.py @@ -128,3 +128,6 @@ class MatterDiscoverySchema: # [optional] bool to specify if this primary value may be discovered # by multiple platforms allow_multi: bool = False + + # [optional] the primary attribute value may not be null/None + allow_none_value: bool = False diff --git a/homeassistant/components/matter/number.py b/homeassistant/components/matter/number.py index 4518e83e9d0..93b6b8f75c9 100644 --- a/homeassistant/components/matter/number.py +++ b/homeassistant/components/matter/number.py @@ -86,6 +86,8 @@ DISCOVERY_SCHEMAS = [ ), entity_class=MatterNumber, required_attributes=(clusters.LevelControl.Attributes.OnLevel,), + # allow None value to account for 'default' value + allow_none_value=True, ), MatterDiscoverySchema( platform=Platform.NUMBER, @@ -103,6 +105,8 @@ DISCOVERY_SCHEMAS = [ ), entity_class=MatterNumber, required_attributes=(clusters.LevelControl.Attributes.OnTransitionTime,), + # allow None value to account for 'default' value + allow_none_value=True, ), MatterDiscoverySchema( platform=Platform.NUMBER, @@ -120,6 +124,8 @@ DISCOVERY_SCHEMAS = [ ), entity_class=MatterNumber, required_attributes=(clusters.LevelControl.Attributes.OffTransitionTime,), + # allow None value to account for 'default' value + allow_none_value=True, ), MatterDiscoverySchema( platform=Platform.NUMBER, @@ -137,6 +143,8 @@ DISCOVERY_SCHEMAS = [ ), entity_class=MatterNumber, required_attributes=(clusters.LevelControl.Attributes.OnOffTransitionTime,), + # allow None value to account for 'default' value + allow_none_value=True, ), MatterDiscoverySchema( platform=Platform.NUMBER, diff --git a/homeassistant/components/matter/select.py b/homeassistant/components/matter/select.py index 1018bed6af0..b10f4e0e484 100644 --- a/homeassistant/components/matter/select.py +++ b/homeassistant/components/matter/select.py @@ -267,6 +267,8 @@ DISCOVERY_SCHEMAS = [ ), entity_class=MatterAttributeSelectEntity, required_attributes=(clusters.OnOff.Attributes.StartUpOnOff,), + # allow None value for previous state + allow_none_value=True, ), MatterDiscoverySchema( platform=Platform.SELECT, diff --git a/homeassistant/components/matter/update.py b/homeassistant/components/matter/update.py index f31dd7b3aa3..5ee9b2e5fa0 100644 --- a/homeassistant/components/matter/update.py +++ b/homeassistant/components/matter/update.py @@ -261,5 +261,6 @@ DISCOVERY_SCHEMAS = [ clusters.OtaSoftwareUpdateRequestor.Attributes.UpdateState, clusters.OtaSoftwareUpdateRequestor.Attributes.UpdateStateProgress, ), + allow_none_value=True, ), ] diff --git a/homeassistant/components/matter/vacuum.py b/homeassistant/components/matter/vacuum.py index 511b32d3182..de4a885d8fb 100644 --- a/homeassistant/components/matter/vacuum.py +++ b/homeassistant/components/matter/vacuum.py @@ -208,5 +208,6 @@ DISCOVERY_SCHEMAS = [ clusters.PowerSource.Attributes.BatPercentRemaining, ), device_type=(device_types.RoboticVacuumCleaner,), + allow_none_value=True, ), ] From a05ac6255c5d3bc775fa2ffad58f6aacb4becd87 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 28 Jan 2025 15:54:06 +0100 Subject: [PATCH 0135/3148] Standardize util imports (#136723) --- homeassistant/components/alexa/capabilities.py | 3 +-- homeassistant/components/avea/light.py | 2 +- homeassistant/components/blinksticklight/light.py | 4 ++-- homeassistant/components/eufy/light.py | 2 +- homeassistant/components/everlights/light.py | 4 ++-- homeassistant/components/hive/light.py | 2 +- homeassistant/components/home_connect/light.py | 2 +- homeassistant/components/homekit_controller/light.py | 2 +- homeassistant/components/hyperion/light.py | 2 +- homeassistant/components/iglo/light.py | 4 ++-- homeassistant/components/knx/light.py | 2 +- homeassistant/components/lifx/util.py | 2 +- homeassistant/components/light/__init__.py | 2 +- homeassistant/components/light/intent.py | 2 +- homeassistant/components/lw12wifi/light.py | 4 ++-- homeassistant/components/mqtt/light/schema_basic.py | 4 ++-- homeassistant/components/mqtt/light/schema_json.py | 4 ++-- homeassistant/components/mqtt/light/schema_template.py | 4 ++-- homeassistant/components/osramlightify/light.py | 4 ++-- homeassistant/components/plum_lightpad/light.py | 2 +- homeassistant/components/tikteck/light.py | 4 ++-- homeassistant/components/trace/models.py | 3 +-- homeassistant/components/tradfri/light.py | 2 +- homeassistant/components/vera/light.py | 2 +- homeassistant/components/wemo/light.py | 2 +- homeassistant/components/xiaomi_aqara/light.py | 2 +- homeassistant/components/yeelight/light.py | 5 ++--- homeassistant/components/yeelightsunflower/light.py | 4 ++-- homeassistant/components/zengge/light.py | 4 ++-- homeassistant/components/zerproc/light.py | 2 +- homeassistant/components/zwave_js/light.py | 2 +- homeassistant/helpers/check_config.py | 2 +- homeassistant/helpers/device_registry.py | 2 +- homeassistant/scripts/check_config.py | 3 +-- tests/components/color_extractor/test_service.py | 2 +- tests/components/light/test_init.py | 2 +- 36 files changed, 48 insertions(+), 52 deletions(-) diff --git a/homeassistant/components/alexa/capabilities.py b/homeassistant/components/alexa/capabilities.py index c5b4ad15904..e70055c20b1 100644 --- a/homeassistant/components/alexa/capabilities.py +++ b/homeassistant/components/alexa/capabilities.py @@ -50,8 +50,7 @@ from homeassistant.const import ( UnitOfVolume, ) from homeassistant.core import HomeAssistant, State -import homeassistant.util.color as color_util -import homeassistant.util.dt as dt_util +from homeassistant.util import color as color_util, dt as dt_util from .const import ( API_TEMP_UNITS, diff --git a/homeassistant/components/avea/light.py b/homeassistant/components/avea/light.py index 48471b41633..ec39a6f371c 100644 --- a/homeassistant/components/avea/light.py +++ b/homeassistant/components/avea/light.py @@ -16,7 +16,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -import homeassistant.util.color as color_util +from homeassistant.util import color as color_util def setup_platform( diff --git a/homeassistant/components/blinksticklight/light.py b/homeassistant/components/blinksticklight/light.py index 19ac5f80242..01e5c90aadf 100644 --- a/homeassistant/components/blinksticklight/light.py +++ b/homeassistant/components/blinksticklight/light.py @@ -17,10 +17,10 @@ from homeassistant.components.light import ( ) from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -import homeassistant.util.color as color_util +from homeassistant.util import color as color_util CONF_SERIAL = "serial" diff --git a/homeassistant/components/eufy/light.py b/homeassistant/components/eufy/light.py index 95ad8a15d1c..dcce52612ee 100644 --- a/homeassistant/components/eufy/light.py +++ b/homeassistant/components/eufy/light.py @@ -16,7 +16,7 @@ from homeassistant.components.light import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -import homeassistant.util.color as color_util +from homeassistant.util import color as color_util EUFYHOME_MAX_KELVIN = 6500 EUFYHOME_MIN_KELVIN = 2700 diff --git a/homeassistant/components/everlights/light.py b/homeassistant/components/everlights/light.py index 2ba47978353..ae159d77240 100644 --- a/homeassistant/components/everlights/light.py +++ b/homeassistant/components/everlights/light.py @@ -21,11 +21,11 @@ from homeassistant.components.light import ( from homeassistant.const import CONF_HOSTS from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -import homeassistant.util.color as color_util +from homeassistant.util import color as color_util _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/hive/light.py b/homeassistant/components/hive/light.py index 8d09c902f36..e941087c6fb 100644 --- a/homeassistant/components/hive/light.py +++ b/homeassistant/components/hive/light.py @@ -15,7 +15,7 @@ from homeassistant.components.light import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.util.color as color_util +from homeassistant.util import color as color_util from . import refresh_system from .const import ATTR_MODE, DOMAIN diff --git a/homeassistant/components/home_connect/light.py b/homeassistant/components/home_connect/light.py index e33017cd51f..3e81bcbddad 100644 --- a/homeassistant/components/home_connect/light.py +++ b/homeassistant/components/home_connect/light.py @@ -18,7 +18,7 @@ from homeassistant.components.light import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.util.color as color_util +from homeassistant.util import color as color_util from . import HomeConnectConfigEntry, get_dict_from_home_connect_error from .api import HomeConnectDevice diff --git a/homeassistant/components/homekit_controller/light.py b/homeassistant/components/homekit_controller/light.py index b306c440d7b..04c75731731 100644 --- a/homeassistant/components/homekit_controller/light.py +++ b/homeassistant/components/homekit_controller/light.py @@ -21,7 +21,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.util.color as color_util +from homeassistant.util import color as color_util from . import KNOWN_DEVICES from .connection import HKDevice diff --git a/homeassistant/components/hyperion/light.py b/homeassistant/components/hyperion/light.py index 5fa129ce7ad..40d093430a5 100644 --- a/homeassistant/components/hyperion/light.py +++ b/homeassistant/components/hyperion/light.py @@ -26,7 +26,7 @@ from homeassistant.helpers.dispatcher import ( async_dispatcher_send, ) from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.util.color as color_util +from homeassistant.util import color as color_util from . import ( get_hyperion_device_id, diff --git a/homeassistant/components/iglo/light.py b/homeassistant/components/iglo/light.py index 0d20761c6e5..d356ad05541 100644 --- a/homeassistant/components/iglo/light.py +++ b/homeassistant/components/iglo/light.py @@ -20,10 +20,10 @@ from homeassistant.components.light import ( ) from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -import homeassistant.util.color as color_util +from homeassistant.util import color as color_util DEFAULT_NAME = "iGlo Light" DEFAULT_PORT = 8080 diff --git a/homeassistant/components/knx/light.py b/homeassistant/components/knx/light.py index 6115f8be128..33edc19fb1c 100644 --- a/homeassistant/components/knx/light.py +++ b/homeassistant/components/knx/light.py @@ -26,7 +26,7 @@ from homeassistant.helpers.entity_platform import ( async_get_current_platform, ) from homeassistant.helpers.typing import ConfigType -import homeassistant.util.color as color_util +from homeassistant.util import color as color_util from . import KNXModule from .const import CONF_SYNC_STATE, DOMAIN, KNX_ADDRESS, KNX_MODULE_KEY, ColorTempModes diff --git a/homeassistant/components/lifx/util.py b/homeassistant/components/lifx/util.py index ffffe7a4856..3d37f1c3bc5 100644 --- a/homeassistant/components/lifx/util.py +++ b/homeassistant/components/lifx/util.py @@ -24,7 +24,7 @@ from homeassistant.components.light import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr -import homeassistant.util.color as color_util +from homeassistant.util import color as color_util from .const import ( _ATTR_COLOR_TEMP, diff --git a/homeassistant/components/light/__init__.py b/homeassistant/components/light/__init__.py index 65a89b7d688..d87dcf41161 100644 --- a/homeassistant/components/light/__init__.py +++ b/homeassistant/components/light/__init__.py @@ -35,7 +35,7 @@ from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.frame import ReportBehavior, report_usage from homeassistant.helpers.typing import ConfigType, VolDictType from homeassistant.loader import bind_hass -import homeassistant.util.color as color_util +from homeassistant.util import color as color_util from .const import ( # noqa: F401 COLOR_MODES_BRIGHTNESS, diff --git a/homeassistant/components/light/intent.py b/homeassistant/components/light/intent.py index e496255029a..83f2ee58b5e 100644 --- a/homeassistant/components/light/intent.py +++ b/homeassistant/components/light/intent.py @@ -9,7 +9,7 @@ import voluptuous as vol from homeassistant.const import SERVICE_TURN_ON from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, intent -import homeassistant.util.color as color_util +from homeassistant.util import color as color_util from . import ATTR_BRIGHTNESS_PCT, ATTR_COLOR_TEMP_KELVIN, ATTR_RGB_COLOR from .const import DOMAIN diff --git a/homeassistant/components/lw12wifi/light.py b/homeassistant/components/lw12wifi/light.py index 60741c861dd..7071cc9f416 100644 --- a/homeassistant/components/lw12wifi/light.py +++ b/homeassistant/components/lw12wifi/light.py @@ -20,10 +20,10 @@ from homeassistant.components.light import ( ) from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -import homeassistant.util.color as color_util +from homeassistant.util import color as color_util _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/mqtt/light/schema_basic.py b/homeassistant/components/mqtt/light/schema_basic.py index eaaa80af223..a2f424b247d 100644 --- a/homeassistant/components/mqtt/light/schema_basic.py +++ b/homeassistant/components/mqtt/light/schema_basic.py @@ -42,11 +42,11 @@ from homeassistant.const import ( STATE_ON, ) from homeassistant.core import callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.service_info.mqtt import ReceivePayloadType from homeassistant.helpers.typing import ConfigType, VolSchemaType -import homeassistant.util.color as color_util +from homeassistant.util import color as color_util from .. import subscription from ..config import MQTT_RW_SCHEMA diff --git a/homeassistant/components/mqtt/light/schema_json.py b/homeassistant/components/mqtt/light/schema_json.py index 2d152ca12c8..43b0cbf77b3 100644 --- a/homeassistant/components/mqtt/light/schema_json.py +++ b/homeassistant/components/mqtt/light/schema_json.py @@ -49,12 +49,12 @@ from homeassistant.const import ( STATE_ON, ) from homeassistant.core import async_get_hass, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.json import json_dumps from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType, VolSchemaType -import homeassistant.util.color as color_util +from homeassistant.util import color as color_util from homeassistant.util.json import json_loads_object from homeassistant.util.yaml import dump as yaml_dump diff --git a/homeassistant/components/mqtt/light/schema_template.py b/homeassistant/components/mqtt/light/schema_template.py index 69bc801ff1e..901cee6f14c 100644 --- a/homeassistant/components/mqtt/light/schema_template.py +++ b/homeassistant/components/mqtt/light/schema_template.py @@ -31,11 +31,11 @@ from homeassistant.const import ( STATE_ON, ) from homeassistant.core import callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.service_info.mqtt import ReceivePayloadType from homeassistant.helpers.typing import ConfigType, TemplateVarsType, VolSchemaType -import homeassistant.util.color as color_util +from homeassistant.util import color as color_util from .. import subscription from ..config import MQTT_RW_SCHEMA diff --git a/homeassistant/components/osramlightify/light.py b/homeassistant/components/osramlightify/light.py index 6ddd392af7b..25380810862 100644 --- a/homeassistant/components/osramlightify/light.py +++ b/homeassistant/components/osramlightify/light.py @@ -24,10 +24,10 @@ from homeassistant.components.light import ( ) from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -import homeassistant.util.color as color_util +from homeassistant.util import color as color_util _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/plum_lightpad/light.py b/homeassistant/components/plum_lightpad/light.py index a385565b837..08a3d0ab0b9 100644 --- a/homeassistant/components/plum_lightpad/light.py +++ b/homeassistant/components/plum_lightpad/light.py @@ -17,7 +17,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.util.color as color_util +from homeassistant.util import color as color_util from .const import DOMAIN diff --git a/homeassistant/components/tikteck/light.py b/homeassistant/components/tikteck/light.py index 26ffc0e7b6d..a3961cbb569 100644 --- a/homeassistant/components/tikteck/light.py +++ b/homeassistant/components/tikteck/light.py @@ -17,10 +17,10 @@ from homeassistant.components.light import ( ) from homeassistant.const import CONF_DEVICES, CONF_NAME, CONF_PASSWORD from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -import homeassistant.util.color as color_util +from homeassistant.util import color as color_util _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/trace/models.py b/homeassistant/components/trace/models.py index e8ef417ca5f..3c503efdd28 100644 --- a/homeassistant/components/trace/models.py +++ b/homeassistant/components/trace/models.py @@ -15,9 +15,8 @@ from homeassistant.helpers.trace import ( trace_id_set, trace_set_child_id, ) -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util, uuid as uuid_util from homeassistant.util.limited_size_dict import LimitedSizeDict -import homeassistant.util.uuid as uuid_util type TraceData = dict[str, LimitedSizeDict[str, BaseTrace]] diff --git a/homeassistant/components/tradfri/light.py b/homeassistant/components/tradfri/light.py index a71691e6e90..e464d1a8142 100644 --- a/homeassistant/components/tradfri/light.py +++ b/homeassistant/components/tradfri/light.py @@ -20,7 +20,7 @@ from homeassistant.components.light import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.util.color as color_util +from homeassistant.util import color as color_util from .const import CONF_GATEWAY_ID, COORDINATOR, COORDINATOR_LIST, DOMAIN, KEY_API from .coordinator import TradfriDeviceDataUpdateCoordinator diff --git a/homeassistant/components/vera/light.py b/homeassistant/components/vera/light.py index e512676de9a..9b8ae42f620 100644 --- a/homeassistant/components/vera/light.py +++ b/homeassistant/components/vera/light.py @@ -17,7 +17,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.util.color as color_util +from homeassistant.util import color as color_util from .common import ControllerData, get_controller_data from .entity import VeraEntity diff --git a/homeassistant/components/wemo/light.py b/homeassistant/components/wemo/light.py index 6068cd3ff0b..619e0952457 100644 --- a/homeassistant/components/wemo/light.py +++ b/homeassistant/components/wemo/light.py @@ -21,7 +21,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import CONNECTION_ZIGBEE, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.util.color as color_util +from homeassistant.util import color as color_util from . import async_wemo_dispatcher_connect from .const import DOMAIN as WEMO_DOMAIN diff --git a/homeassistant/components/xiaomi_aqara/light.py b/homeassistant/components/xiaomi_aqara/light.py index c8057f1df4a..11ce7a0107b 100644 --- a/homeassistant/components/xiaomi_aqara/light.py +++ b/homeassistant/components/xiaomi_aqara/light.py @@ -14,7 +14,7 @@ from homeassistant.components.light import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.util.color as color_util +from homeassistant.util import color as color_util from .const import DOMAIN, GATEWAYS_KEY from .entity import XiaomiDevice diff --git a/homeassistant/components/yeelight/light.py b/homeassistant/components/yeelight/light.py index 8cc3f2600e5..92ee3976f7f 100644 --- a/homeassistant/components/yeelight/light.py +++ b/homeassistant/components/yeelight/light.py @@ -32,13 +32,12 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_ENTITY_ID, ATTR_MODE, CONF_NAME from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import entity_platform -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_call_later from homeassistant.helpers.typing import VolDictType -import homeassistant.util.color as color_util +from homeassistant.util import color as color_util from . import YEELIGHT_FLOW_TRANSITION_SCHEMA from .const import ( diff --git a/homeassistant/components/yeelightsunflower/light.py b/homeassistant/components/yeelightsunflower/light.py index 0d8247fc865..4cacd1def22 100644 --- a/homeassistant/components/yeelightsunflower/light.py +++ b/homeassistant/components/yeelightsunflower/light.py @@ -17,10 +17,10 @@ from homeassistant.components.light import ( ) from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -import homeassistant.util.color as color_util +from homeassistant.util import color as color_util _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/zengge/light.py b/homeassistant/components/zengge/light.py index 69b7c63476a..2ab46820b56 100644 --- a/homeassistant/components/zengge/light.py +++ b/homeassistant/components/zengge/light.py @@ -18,10 +18,10 @@ from homeassistant.components.light import ( ) from homeassistant.const import CONF_DEVICES, CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -import homeassistant.util.color as color_util +from homeassistant.util import color as color_util _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/zerproc/light.py b/homeassistant/components/zerproc/light.py index ed6ed03ad27..36a964a46ab 100644 --- a/homeassistant/components/zerproc/light.py +++ b/homeassistant/components/zerproc/light.py @@ -20,7 +20,7 @@ from homeassistant.core import Event, HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_time_interval -import homeassistant.util.color as color_util +from homeassistant.util import color as color_util from .const import DATA_ADDRESSES, DATA_DISCOVERY_SUBSCRIPTION, DOMAIN diff --git a/homeassistant/components/zwave_js/light.py b/homeassistant/components/zwave_js/light.py index 639d2fbcd7a..0a2ca95a2b0 100644 --- a/homeassistant/components/zwave_js/light.py +++ b/homeassistant/components/zwave_js/light.py @@ -42,7 +42,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.util.color as color_util +from homeassistant.util import color as color_util from .const import DATA_CLIENT, DOMAIN from .discovery import ZwaveDiscoveryInfo diff --git a/homeassistant/helpers/check_config.py b/homeassistant/helpers/check_config.py index a8e8fa4160d..0841585e1a1 100644 --- a/homeassistant/helpers/check_config.py +++ b/homeassistant/helpers/check_config.py @@ -29,7 +29,7 @@ from homeassistant.requirements import ( async_clear_install_history, async_get_integration_with_requirements, ) -import homeassistant.util.yaml.loader as yaml_loader +from homeassistant.util.yaml import loader as yaml_loader from . import config_validation as cv from .typing import ConfigType diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py index 685509cb29d..975b4a2aec9 100644 --- a/homeassistant/helpers/device_registry.py +++ b/homeassistant/helpers/device_registry.py @@ -24,11 +24,11 @@ from homeassistant.core import ( ) from homeassistant.exceptions import HomeAssistantError from homeassistant.loader import async_suggest_report_issue +from homeassistant.util import uuid as uuid_util from homeassistant.util.dt import utc_from_timestamp, utcnow from homeassistant.util.event_type import EventType from homeassistant.util.hass_dict import HassKey from homeassistant.util.json import format_unserializable_data -import homeassistant.util.uuid as uuid_util from . import storage, translation from .debounce import Debouncer diff --git a/homeassistant/scripts/check_config.py b/homeassistant/scripts/check_config.py index 568e8c84a30..a24568e9a6f 100644 --- a/homeassistant/scripts/check_config.py +++ b/homeassistant/scripts/check_config.py @@ -23,8 +23,7 @@ from homeassistant.helpers import ( issue_registry as ir, ) from homeassistant.helpers.check_config import async_check_ha_config_file -from homeassistant.util.yaml import Secrets -import homeassistant.util.yaml.loader as yaml_loader +from homeassistant.util.yaml import Secrets, loader as yaml_loader # mypy: allow-untyped-calls, allow-untyped-defs diff --git a/tests/components/color_extractor/test_service.py b/tests/components/color_extractor/test_service.py index 23ba5e7808c..3f920b7dee2 100644 --- a/tests/components/color_extractor/test_service.py +++ b/tests/components/color_extractor/test_service.py @@ -25,7 +25,7 @@ from homeassistant.components.light import ( from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -import homeassistant.util.color as color_util +from homeassistant.util import color as color_util from tests.common import load_fixture from tests.test_util.aiohttp import AiohttpClientMocker diff --git a/tests/components/light/test_init.py b/tests/components/light/test_init.py index 6d0337f37a5..5bc17ea3e24 100644 --- a/tests/components/light/test_init.py +++ b/tests/components/light/test_init.py @@ -23,7 +23,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, Unauthorized from homeassistant.helpers import frame from homeassistant.setup import async_setup_component -import homeassistant.util.color as color_util +from homeassistant.util import color as color_util from .common import MockLight From 3d7e3590d422a3edbbdd3d3ca37e50f8a85696a0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 28 Jan 2025 04:57:11 -1000 Subject: [PATCH 0136/3148] Migrate usb to use aiousbwatcher (#136676) * Migrate usb to use aiousbwatcher aiousbwatcher uses inotify on /dev/bus/usb to look for devices added and being removed which works on a lot more systems * bump asyncinotify * bump aiousbwatcher to 1.1.1 * tweaks * tweaks * tweaks * fixes * debugging * Update homeassistant/components/usb/__init__.py * Update homeassistant/components/usb/__init__.py --------- Co-authored-by: Paulus Schoutsen --- .../components/keyboard_remote/manifest.json | 2 +- homeassistant/components/usb/__init__.py | 124 ++++----- homeassistant/components/usb/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 8 +- requirements_test_all.txt | 6 +- tests/components/usb/test_init.py | 241 ++++++++---------- 7 files changed, 175 insertions(+), 210 deletions(-) diff --git a/homeassistant/components/keyboard_remote/manifest.json b/homeassistant/components/keyboard_remote/manifest.json index b405f36bb23..f543ae72972 100644 --- a/homeassistant/components/keyboard_remote/manifest.json +++ b/homeassistant/components/keyboard_remote/manifest.json @@ -7,5 +7,5 @@ "iot_class": "local_push", "loggers": ["aionotify", "evdev"], "quality_scale": "legacy", - "requirements": ["evdev==1.6.1", "asyncinotify==4.0.2"] + "requirements": ["evdev==1.6.1", "asyncinotify==4.2.0"] } diff --git a/homeassistant/components/usb/__init__.py b/homeassistant/components/usb/__init__.py index ec65143b984..d68742522a0 100644 --- a/homeassistant/components/usb/__init__.py +++ b/homeassistant/components/usb/__init__.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio from collections.abc import Callable, Coroutine, Sequence import dataclasses from datetime import datetime, timedelta @@ -10,8 +11,9 @@ from functools import partial import logging import os import sys -from typing import TYPE_CHECKING, Any, overload +from typing import Any, overload +from aiousbwatcher import AIOUSBWatcher, InotifyNotAvailableError from serial.tools.list_ports import comports from serial.tools.list_ports_common import ListPortInfo import voluptuous as vol @@ -26,7 +28,7 @@ from homeassistant.core import ( HomeAssistant, callback as hass_callback, ) -from homeassistant.helpers import config_validation as cv, discovery_flow, system_info +from homeassistant.helpers import config_validation as cv, discovery_flow from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.deprecation import ( DeprecatedConstant, @@ -43,15 +45,13 @@ from .const import DOMAIN from .models import USBDevice from .utils import usb_device_from_port -if TYPE_CHECKING: - from pyudev import Device, MonitorObserver - _LOGGER = logging.getLogger(__name__) PORT_EVENT_CALLBACK_TYPE = Callable[[set[USBDevice], set[USBDevice]], None] POLLING_MONITOR_SCAN_PERIOD = timedelta(seconds=5) REQUEST_SCAN_COOLDOWN = 10 # 10 second cooldown +ADD_REMOVE_SCAN_COOLDOWN = 5 # 5 second cooldown to give devices a chance to register __all__ = [ "USBCallbackMatcher", @@ -255,15 +255,17 @@ class USBDiscovery: self.seen: set[tuple[str, ...]] = set() self.observer_active = False self._request_debouncer: Debouncer[Coroutine[Any, Any, None]] | None = None + self._add_remove_debouncer: Debouncer[Coroutine[Any, Any, None]] | None = None self._request_callbacks: list[CALLBACK_TYPE] = [] self.initial_scan_done = False self._initial_scan_callbacks: list[CALLBACK_TYPE] = [] self._port_event_callbacks: set[PORT_EVENT_CALLBACK_TYPE] = set() self._last_processed_devices: set[USBDevice] = set() + self._scan_lock = asyncio.Lock() async def async_setup(self) -> None: """Set up USB Discovery.""" - if await self._async_supports_monitoring(): + if self._async_supports_monitoring(): await self._async_start_monitor() self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, self.async_start) @@ -279,16 +281,19 @@ class USBDiscovery: if self._request_debouncer: self._request_debouncer.async_shutdown() - async def _async_supports_monitoring(self) -> bool: - info = await system_info.async_get_system_info(self.hass) - return not info.get("docker") + @hass_callback + def _async_supports_monitoring(self) -> bool: + return sys.platform == "linux" async def _async_start_monitor(self) -> None: """Start monitoring hardware.""" - if not await self._async_start_monitor_udev(): + try: + await self._async_start_aiousbwatcher() + except InotifyNotAvailableError as ex: _LOGGER.info( - "Falling back to periodic filesystem polling for development, libudev " - "is not present" + "Falling back to periodic filesystem polling for development, aiousbwatcher " + "is not available on this system: %s", + ex, ) self._async_start_monitor_polling() @@ -309,70 +314,27 @@ class USBDiscovery: self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _stop_polling) - async def _async_start_monitor_udev(self) -> bool: - """Start monitoring hardware with pyudev. Returns True if successful.""" - if not sys.platform.startswith("linux"): - return False + async def _async_start_aiousbwatcher(self) -> None: + """Start monitoring hardware with aiousbwatcher. - if not ( - observer := await self.hass.async_add_executor_job( - self._get_monitor_observer - ) - ): - return False - - def _stop_observer(event: Event) -> None: - observer.stop() - - self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _stop_observer) - self.observer_active = True - return True - - def _get_monitor_observer(self) -> MonitorObserver | None: - """Get the monitor observer. - - This runs in the executor because the import - does blocking I/O. + Returns True if successful. """ - from pyudev import ( # pylint: disable=import-outside-toplevel - Context, - Monitor, - MonitorObserver, - ) - try: - context = Context() - except (ImportError, OSError): - return None + @hass_callback + def _usb_change_callback() -> None: + self._async_delayed_add_remove_scan() - monitor = Monitor.from_netlink(context) - try: - monitor.filter_by(subsystem="tty") - except ValueError as ex: # this fails on WSL - _LOGGER.debug( - "Unable to setup pyudev filtering; This is expected on WSL: %s", ex - ) - return None + watcher = AIOUSBWatcher() + watcher.async_register_callback(_usb_change_callback) + cancel = watcher.async_start() - observer = MonitorObserver( - monitor, callback=self._device_event, name="usb-observer" - ) + @hass_callback + def _async_stop_watcher(event: Event) -> None: + cancel() - observer.start() - return observer + self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_stop_watcher) - def _device_event(self, device: Device) -> None: - """Call when the observer receives a USB device event.""" - if device.action not in ("add", "remove"): - return - - _LOGGER.info( - "Received a udev device event %r for %s, triggering scan", - device.action, - device.device_node, - ) - - self.hass.create_task(self._async_scan()) + self.observer_active = True @hass_callback def async_register_scan_request_callback( @@ -466,11 +428,13 @@ class USBDiscovery: async def _async_process_ports(self, ports: Sequence[ListPortInfo]) -> None: """Process each discovered port.""" + _LOGGER.debug("Processing ports: %r", ports) usb_devices = { usb_device_from_port(port) for port in ports if port.vid is not None or port.pid is not None } + _LOGGER.debug("USB devices: %r", usb_devices) # CP2102N chips create *two* serial ports on macOS: `/dev/cu.usbserial-` and # `/dev/cu.SLAB_USBtoUART*`. The former does not work and we should ignore them. @@ -509,11 +473,27 @@ class USBDiscovery: for usb_device in usb_devices: await self._async_process_discovered_usb_device(usb_device) + @hass_callback + def _async_delayed_add_remove_scan(self) -> None: + """Request a serial scan after a debouncer delay.""" + if not self._add_remove_debouncer: + self._add_remove_debouncer = Debouncer( + self.hass, + _LOGGER, + cooldown=ADD_REMOVE_SCAN_COOLDOWN, + immediate=False, + function=self._async_scan, + background=True, + ) + self._add_remove_debouncer.async_schedule_call() + async def _async_scan_serial(self) -> None: """Scan serial ports.""" - await self._async_process_ports( - await self.hass.async_add_executor_job(comports) - ) + _LOGGER.debug("Executing comports scan") + async with self._scan_lock: + await self._async_process_ports( + await self.hass.async_add_executor_job(comports) + ) if self.initial_scan_done: return diff --git a/homeassistant/components/usb/manifest.json b/homeassistant/components/usb/manifest.json index 19269801c11..7035e2ab2cb 100644 --- a/homeassistant/components/usb/manifest.json +++ b/homeassistant/components/usb/manifest.json @@ -7,5 +7,5 @@ "integration_type": "system", "iot_class": "local_push", "quality_scale": "internal", - "requirements": ["pyudev==0.24.1", "pyserial==3.5"] + "requirements": ["aiousbwatcher==1.1.1", "pyserial==3.5"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 2959e8bf322..e29c0f25d7c 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -8,6 +8,7 @@ aiohttp-asyncmdnsresolver==0.0.1 aiohttp-fast-zlib==0.2.0 aiohttp==3.11.11 aiohttp_cors==0.7.0 +aiousbwatcher==1.1.1 aiozoneinfo==0.2.1 astral==2.2 async-interrupt==1.2.0 @@ -57,7 +58,6 @@ pyserial==3.5 pyspeex-noise==1.0.2 python-slugify==8.0.4 PyTurboJPEG==1.7.5 -pyudev==0.24.1 PyYAML==6.0.2 requests==2.32.3 securetar==2025.1.4 diff --git a/requirements_all.txt b/requirements_all.txt index baec606c57c..e9436475775 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -403,6 +403,9 @@ aiotractive==0.6.0 # homeassistant.components.unifi aiounifi==81 +# homeassistant.components.usb +aiousbwatcher==1.1.1 + # homeassistant.components.vlc_telnet aiovlc==0.5.1 @@ -511,7 +514,7 @@ async-upnp-client==0.43.0 asyncarve==0.1.1 # homeassistant.components.keyboard_remote -asyncinotify==4.0.2 +asyncinotify==4.2.0 # homeassistant.components.supla asyncpysupla==0.0.5 @@ -2491,9 +2494,6 @@ pytrafikverket==1.1.1 # homeassistant.components.v2c pytrydan==0.8.0 -# homeassistant.components.usb -pyudev==0.24.1 - # homeassistant.components.uptimerobot pyuptimerobot==22.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ad8c67ba1fb..c1752dc7e45 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -385,6 +385,9 @@ aiotractive==0.6.0 # homeassistant.components.unifi aiounifi==81 +# homeassistant.components.usb +aiousbwatcher==1.1.1 + # homeassistant.components.vlc_telnet aiovlc==0.5.1 @@ -2015,9 +2018,6 @@ pytrafikverket==1.1.1 # homeassistant.components.v2c pytrydan==0.8.0 -# homeassistant.components.usb -pyudev==0.24.1 - # homeassistant.components.uptimerobot pyuptimerobot==22.2.0 diff --git a/tests/components/usb/test_init.py b/tests/components/usb/test_init.py index 8f8ed672374..9730dba53d7 100644 --- a/tests/components/usb/test_init.py +++ b/tests/components/usb/test_init.py @@ -7,6 +7,7 @@ import os from typing import Any from unittest.mock import MagicMock, Mock, call, patch, sentinel +from aiousbwatcher import InotifyNotAvailableError import pytest from homeassistant.components import usb @@ -15,58 +16,29 @@ from homeassistant.const import EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT from homeassistant.core import HomeAssistant from homeassistant.helpers.service_info.usb import UsbServiceInfo from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util from . import conbee_device, slae_sh_device -from tests.common import import_and_test_deprecated_constant +from tests.common import async_fire_time_changed, import_and_test_deprecated_constant from tests.typing import WebSocketGenerator -@pytest.fixture(name="operating_system") -def mock_operating_system(): - """Mock running Home Assistant Operating system.""" +@pytest.fixture(name="aiousbwatcher_no_inotify") +def aiousbwatcher_no_inotify(): + """Patch AIOUSBWatcher to not use inotify.""" with patch( - "homeassistant.components.usb.system_info.async_get_system_info", - return_value={ - "hassio": True, - "docker": True, - }, + "homeassistant.components.usb.AIOUSBWatcher.async_start", + side_effect=InotifyNotAvailableError, ): yield -@pytest.fixture(name="docker") -def mock_docker(): - """Mock running Home Assistant in docker container.""" - with patch( - "homeassistant.components.usb.system_info.async_get_system_info", - return_value={ - "hassio": False, - "docker": True, - }, - ): - yield - - -@pytest.fixture(name="venv") -def mock_venv(): - """Mock running Home Assistant in a venv container.""" - with patch( - "homeassistant.components.usb.system_info.async_get_system_info", - return_value={ - "hassio": False, - "docker": False, - "virtualenv": True, - }, - ): - yield - - -async def test_observer_discovery( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, venv +async def test_aiousbwatcher_discovery( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: - """Test that observer can discover a device without raising an exception.""" - new_usb = [{"domain": "test1", "vid": "3039"}] + """Test that aiousbwatcher can discover a device without raising an exception.""" + new_usb = [{"domain": "test1", "vid": "3039"}, {"domain": "test2", "vid": "0FA0"}] mock_comports = [ MagicMock( @@ -78,26 +50,23 @@ async def test_observer_discovery( description=slae_sh_device.description, ) ] - mock_observer = None - async def _mock_monitor_observer_callback(callback): - await hass.async_add_executor_job( - callback, MagicMock(action="add", device_path="/dev/new") - ) + aiousbwatcher_callback = None - def _create_mock_monitor_observer(monitor, callback, name): - nonlocal mock_observer - hass.create_task(_mock_monitor_observer_callback(callback)) - mock_observer = MagicMock() - return mock_observer + def async_register_callback(callback): + nonlocal aiousbwatcher_callback + aiousbwatcher_callback = callback + + MockAIOUSBWatcher = MagicMock() + MockAIOUSBWatcher.async_register_callback = async_register_callback with ( patch("sys.platform", "linux"), - patch("pyudev.Context"), - patch("pyudev.MonitorObserver", new=_create_mock_monitor_observer), - patch("pyudev.Monitor.filter_by"), patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), patch("homeassistant.components.usb.comports", return_value=mock_comports), + patch( + "homeassistant.components.usb.AIOUSBWatcher", return_value=MockAIOUSBWatcher + ), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, ): assert await async_setup_component(hass, "usb", {"usb": {}}) @@ -105,18 +74,42 @@ async def test_observer_discovery( hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() - assert len(mock_config_flow.mock_calls) == 1 - assert mock_config_flow.mock_calls[0][1][0] == "test1" + assert aiousbwatcher_callback is not None - hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) - await hass.async_block_till_done() + assert len(mock_config_flow.mock_calls) == 1 + assert mock_config_flow.mock_calls[0][1][0] == "test1" + await hass.async_block_till_done() + assert len(mock_config_flow.mock_calls) == 1 - # pylint:disable-next=unnecessary-dunder-call - assert mock_observer.mock_calls == [call.start(), call.__bool__(), call.stop()] + mock_comports.append( + MagicMock( + device=slae_sh_device.device, + vid=4000, + pid=4000, + serial_number=slae_sh_device.serial_number, + manufacturer=slae_sh_device.manufacturer, + description=slae_sh_device.description, + ) + ) + + aiousbwatcher_callback() + await hass.async_block_till_done() + + async_fire_time_changed( + hass, dt_util.utcnow() + timedelta(seconds=usb.ADD_REMOVE_SCAN_COOLDOWN) + ) + await hass.async_block_till_done(wait_background_tasks=True) + + assert len(mock_config_flow.mock_calls) == 2 + assert mock_config_flow.mock_calls[1][1][0] == "test2" + + hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) + await hass.async_block_till_done() +@pytest.mark.usefixtures("aiousbwatcher_no_inotify") async def test_polling_discovery( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, venv + hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: """Test that polling can discover a device without raising an exception.""" new_usb = [{"domain": "test1", "vid": "3039"}] @@ -143,10 +136,6 @@ async def test_polling_discovery( with ( patch("sys.platform", "linux"), - patch( - "homeassistant.components.usb.USBDiscovery._get_monitor_observer", - return_value=None, - ), patch( "homeassistant.components.usb.POLLING_MONITOR_SCAN_PERIOD", timedelta(seconds=0.01), @@ -174,19 +163,9 @@ async def test_polling_discovery( await hass.async_block_till_done() -async def test_removal_by_observer_before_started( - hass: HomeAssistant, operating_system -) -> None: - """Test a device is removed by the observer before started.""" - - async def _mock_monitor_observer_callback(callback): - await hass.async_add_executor_job( - callback, MagicMock(action="remove", device_path="/dev/new") - ) - - def _create_mock_monitor_observer(monitor, callback, name): - hass.async_create_task(_mock_monitor_observer_callback(callback)) - +@pytest.mark.usefixtures("aiousbwatcher_no_inotify") +async def test_removal_by_aiousbwatcher_before_started(hass: HomeAssistant) -> None: + """Test a device is removed by the aiousbwatcher before started.""" new_usb = [{"domain": "test1", "vid": "3039", "pid": "3039"}] mock_comports = [ @@ -203,7 +182,6 @@ async def test_removal_by_observer_before_started( with ( patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), patch("homeassistant.components.usb.comports", return_value=mock_comports), - patch("pyudev.MonitorObserver", new=_create_mock_monitor_observer), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, ): assert await async_setup_component(hass, "usb", {"usb": {}}) @@ -219,6 +197,7 @@ async def test_removal_by_observer_before_started( await hass.async_block_till_done() +@pytest.mark.usefixtures("aiousbwatcher_no_inotify") async def test_discovered_by_websocket_scan( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -237,7 +216,6 @@ async def test_discovered_by_websocket_scan( ] with ( - patch("pyudev.Context", side_effect=ImportError), patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), patch("homeassistant.components.usb.comports", return_value=mock_comports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, @@ -256,6 +234,7 @@ async def test_discovered_by_websocket_scan( assert mock_config_flow.mock_calls[0][1][0] == "test1" +@pytest.mark.usefixtures("aiousbwatcher_no_inotify") async def test_discovered_by_websocket_scan_limited_by_description_matcher( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -276,7 +255,6 @@ async def test_discovered_by_websocket_scan_limited_by_description_matcher( ] with ( - patch("pyudev.Context", side_effect=ImportError), patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), patch("homeassistant.components.usb.comports", return_value=mock_comports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, @@ -295,6 +273,7 @@ async def test_discovered_by_websocket_scan_limited_by_description_matcher( assert mock_config_flow.mock_calls[0][1][0] == "test1" +@pytest.mark.usefixtures("aiousbwatcher_no_inotify") async def test_most_targeted_matcher_wins( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -316,7 +295,6 @@ async def test_most_targeted_matcher_wins( ] with ( - patch("pyudev.Context", side_effect=ImportError), patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), patch("homeassistant.components.usb.comports", return_value=mock_comports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, @@ -335,6 +313,7 @@ async def test_most_targeted_matcher_wins( assert mock_config_flow.mock_calls[0][1][0] == "more" +@pytest.mark.usefixtures("aiousbwatcher_no_inotify") async def test_discovered_by_websocket_scan_rejected_by_description_matcher( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -355,7 +334,6 @@ async def test_discovered_by_websocket_scan_rejected_by_description_matcher( ] with ( - patch("pyudev.Context", side_effect=ImportError), patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), patch("homeassistant.components.usb.comports", return_value=mock_comports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, @@ -373,6 +351,7 @@ async def test_discovered_by_websocket_scan_rejected_by_description_matcher( assert len(mock_config_flow.mock_calls) == 0 +@pytest.mark.usefixtures("aiousbwatcher_no_inotify") async def test_discovered_by_websocket_scan_limited_by_serial_number_matcher( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -398,7 +377,6 @@ async def test_discovered_by_websocket_scan_limited_by_serial_number_matcher( ] with ( - patch("pyudev.Context", side_effect=ImportError), patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), patch("homeassistant.components.usb.comports", return_value=mock_comports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, @@ -417,6 +395,7 @@ async def test_discovered_by_websocket_scan_limited_by_serial_number_matcher( assert mock_config_flow.mock_calls[0][1][0] == "test1" +@pytest.mark.usefixtures("aiousbwatcher_no_inotify") async def test_discovered_by_websocket_scan_rejected_by_serial_number_matcher( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -437,7 +416,6 @@ async def test_discovered_by_websocket_scan_rejected_by_serial_number_matcher( ] with ( - patch("pyudev.Context", side_effect=ImportError), patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), patch("homeassistant.components.usb.comports", return_value=mock_comports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, @@ -455,6 +433,7 @@ async def test_discovered_by_websocket_scan_rejected_by_serial_number_matcher( assert len(mock_config_flow.mock_calls) == 0 +@pytest.mark.usefixtures("aiousbwatcher_no_inotify") async def test_discovered_by_websocket_scan_limited_by_manufacturer_matcher( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -480,7 +459,6 @@ async def test_discovered_by_websocket_scan_limited_by_manufacturer_matcher( ] with ( - patch("pyudev.Context", side_effect=ImportError), patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), patch("homeassistant.components.usb.comports", return_value=mock_comports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, @@ -499,6 +477,7 @@ async def test_discovered_by_websocket_scan_limited_by_manufacturer_matcher( assert mock_config_flow.mock_calls[0][1][0] == "test1" +@pytest.mark.usefixtures("aiousbwatcher_no_inotify") async def test_discovered_by_websocket_scan_rejected_by_manufacturer_matcher( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -524,7 +503,6 @@ async def test_discovered_by_websocket_scan_rejected_by_manufacturer_matcher( ] with ( - patch("pyudev.Context", side_effect=ImportError), patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), patch("homeassistant.components.usb.comports", return_value=mock_comports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, @@ -542,6 +520,7 @@ async def test_discovered_by_websocket_scan_rejected_by_manufacturer_matcher( assert len(mock_config_flow.mock_calls) == 0 +@pytest.mark.usefixtures("aiousbwatcher_no_inotify") async def test_discovered_by_websocket_rejected_with_empty_serial_number_only( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -562,7 +541,6 @@ async def test_discovered_by_websocket_rejected_with_empty_serial_number_only( ] with ( - patch("pyudev.Context", side_effect=ImportError), patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), patch("homeassistant.components.usb.comports", return_value=mock_comports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, @@ -580,6 +558,7 @@ async def test_discovered_by_websocket_rejected_with_empty_serial_number_only( assert len(mock_config_flow.mock_calls) == 0 +@pytest.mark.usefixtures("aiousbwatcher_no_inotify") async def test_discovered_by_websocket_scan_match_vid_only( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -598,7 +577,6 @@ async def test_discovered_by_websocket_scan_match_vid_only( ] with ( - patch("pyudev.Context", side_effect=ImportError), patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), patch("homeassistant.components.usb.comports", return_value=mock_comports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, @@ -617,6 +595,7 @@ async def test_discovered_by_websocket_scan_match_vid_only( assert mock_config_flow.mock_calls[0][1][0] == "test1" +@pytest.mark.usefixtures("aiousbwatcher_no_inotify") async def test_discovered_by_websocket_scan_match_vid_wrong_pid( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -635,7 +614,6 @@ async def test_discovered_by_websocket_scan_match_vid_wrong_pid( ] with ( - patch("pyudev.Context", side_effect=ImportError), patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), patch("homeassistant.components.usb.comports", return_value=mock_comports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, @@ -653,6 +631,7 @@ async def test_discovered_by_websocket_scan_match_vid_wrong_pid( assert len(mock_config_flow.mock_calls) == 0 +@pytest.mark.usefixtures("aiousbwatcher_no_inotify") async def test_discovered_by_websocket_no_vid_pid( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -671,7 +650,6 @@ async def test_discovered_by_websocket_no_vid_pid( ] with ( - patch("pyudev.Context", side_effect=ImportError), patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), patch("homeassistant.components.usb.comports", return_value=mock_comports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, @@ -689,9 +667,9 @@ async def test_discovered_by_websocket_no_vid_pid( assert len(mock_config_flow.mock_calls) == 0 -@pytest.mark.parametrize("exception_type", [ImportError, OSError]) +@pytest.mark.usefixtures("aiousbwatcher_no_inotify") async def test_non_matching_discovered_by_scanner_after_started( - hass: HomeAssistant, exception_type, hass_ws_client: WebSocketGenerator + hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: """Test a websocket scan that does not match.""" new_usb = [{"domain": "test1", "vid": "4444", "pid": "4444"}] @@ -708,7 +686,6 @@ async def test_non_matching_discovered_by_scanner_after_started( ] with ( - patch("pyudev.Context", side_effect=exception_type), patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), patch("homeassistant.components.usb.comports", return_value=mock_comports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, @@ -726,10 +703,10 @@ async def test_non_matching_discovered_by_scanner_after_started( assert len(mock_config_flow.mock_calls) == 0 -async def test_observer_on_wsl_fallback_without_throwing_exception( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, venv +async def test_aiousbwatcher_on_wsl_fallback_without_throwing_exception( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: - """Test that observer on WSL failure results in fallback to scanning without raising an exception.""" + """Test that aiousbwatcher on WSL failure results in fallback to scanning without raising an exception.""" new_usb = [{"domain": "test1", "vid": "3039"}] mock_comports = [ @@ -744,8 +721,6 @@ async def test_observer_on_wsl_fallback_without_throwing_exception( ] with ( - patch("pyudev.Context"), - patch("pyudev.Monitor.filter_by", side_effect=ValueError), patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), patch("homeassistant.components.usb.comports", return_value=mock_comports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, @@ -764,20 +739,8 @@ async def test_observer_on_wsl_fallback_without_throwing_exception( assert mock_config_flow.mock_calls[0][1][0] == "test1" -async def test_not_discovered_by_observer_before_started_on_docker( - hass: HomeAssistant, docker -) -> None: - """Test a device is not discovered since observer is not running on bare docker.""" - - async def _mock_monitor_observer_callback(callback): - await hass.async_add_executor_job( - callback, MagicMock(action="add", device_path="/dev/new") - ) - - def _create_mock_monitor_observer(monitor, callback, name): - hass.async_create_task(_mock_monitor_observer_callback(callback)) - return MagicMock() - +async def test_discovered_by_aiousbwatcher_before_started(hass: HomeAssistant) -> None: + """Test a device is discovered since aiousbwatcher is now running.""" new_usb = [{"domain": "test1", "vid": "3039", "pid": "3039"}] mock_comports = [ @@ -790,23 +753,45 @@ async def test_not_discovered_by_observer_before_started_on_docker( description=slae_sh_device.description, ) ] + initial_mock_comports = [] + aiousbwatcher_callback = None + + def async_register_callback(callback): + nonlocal aiousbwatcher_callback + aiousbwatcher_callback = callback + + MockAIOUSBWatcher = MagicMock() + MockAIOUSBWatcher.async_register_callback = async_register_callback with ( + patch("sys.platform", "linux"), patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), - patch("homeassistant.components.usb.comports", return_value=mock_comports), - patch("pyudev.MonitorObserver", new=_create_mock_monitor_observer), + patch( + "homeassistant.components.usb.comports", return_value=initial_mock_comports + ), + patch( + "homeassistant.components.usb.AIOUSBWatcher", return_value=MockAIOUSBWatcher + ), + patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, ): assert await async_setup_component(hass, "usb", {"usb": {}}) await hass.async_block_till_done() - with ( - patch("homeassistant.components.usb.comports", return_value=[]), - patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, - ): hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() - assert len(mock_config_flow.mock_calls) == 0 + assert len(mock_config_flow.mock_calls) == 0 + + initial_mock_comports.extend(mock_comports) + aiousbwatcher_callback() + await hass.async_block_till_done() + + async_fire_time_changed( + hass, dt_util.utcnow() + timedelta(seconds=usb.ADD_REMOVE_SCAN_COOLDOWN) + ) + await hass.async_block_till_done(wait_background_tasks=True) + + assert len(mock_config_flow.mock_calls) == 1 def test_get_serial_by_id_no_dir() -> None: @@ -889,6 +874,7 @@ def test_human_readable_device_name() -> None: assert "8A2A" in name +@pytest.mark.usefixtures("aiousbwatcher_no_inotify") async def test_async_is_plugged_in( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -912,7 +898,6 @@ async def test_async_is_plugged_in( } with ( - patch("pyudev.Context", side_effect=ImportError), patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), patch("homeassistant.components.usb.comports", return_value=[]), patch.object(hass.config_entries.flow, "async_init"), @@ -935,6 +920,7 @@ async def test_async_is_plugged_in( assert usb.async_is_plugged_in(hass, matcher) +@pytest.mark.usefixtures("aiousbwatcher_no_inotify") @pytest.mark.parametrize( "matcher", [ @@ -953,7 +939,6 @@ async def test_async_is_plugged_in_case_enforcement( new_usb = [{"domain": "test1", "vid": "ABCD"}] with ( - patch("pyudev.Context", side_effect=ImportError), patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), patch("homeassistant.components.usb.comports", return_value=[]), patch.object(hass.config_entries.flow, "async_init"), @@ -967,6 +952,7 @@ async def test_async_is_plugged_in_case_enforcement( usb.async_is_plugged_in(hass, matcher) +@pytest.mark.usefixtures("aiousbwatcher_no_inotify") async def test_web_socket_triggers_discovery_request_callbacks( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -974,7 +960,6 @@ async def test_web_socket_triggers_discovery_request_callbacks( mock_callback = Mock() with ( - patch("pyudev.Context", side_effect=ImportError), patch("homeassistant.components.usb.async_get_usb", return_value=[]), patch("homeassistant.components.usb.comports", return_value=[]), patch.object(hass.config_entries.flow, "async_init"), @@ -1002,6 +987,7 @@ async def test_web_socket_triggers_discovery_request_callbacks( assert len(mock_callback.mock_calls) == 1 +@pytest.mark.usefixtures("aiousbwatcher_no_inotify") async def test_initial_scan_callback( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -1010,7 +996,6 @@ async def test_initial_scan_callback( mock_callback_2 = Mock() with ( - patch("pyudev.Context", side_effect=ImportError), patch("homeassistant.components.usb.async_get_usb", return_value=[]), patch("homeassistant.components.usb.comports", return_value=[]), patch.object(hass.config_entries.flow, "async_init"), @@ -1038,6 +1023,7 @@ async def test_initial_scan_callback( cancel_2() +@pytest.mark.usefixtures("aiousbwatcher_no_inotify") async def test_cancel_initial_scan_callback( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -1045,7 +1031,6 @@ async def test_cancel_initial_scan_callback( mock_callback = Mock() with ( - patch("pyudev.Context", side_effect=ImportError), patch("homeassistant.components.usb.async_get_usb", return_value=[]), patch("homeassistant.components.usb.comports", return_value=[]), patch.object(hass.config_entries.flow, "async_init"), @@ -1064,6 +1049,7 @@ async def test_cancel_initial_scan_callback( assert len(mock_callback.mock_calls) == 0 +@pytest.mark.usefixtures("aiousbwatcher_no_inotify") async def test_resolve_serial_by_id( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -1082,7 +1068,6 @@ async def test_resolve_serial_by_id( ] with ( - patch("pyudev.Context", side_effect=ImportError), patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), patch("homeassistant.components.usb.comports", return_value=mock_comports), patch( @@ -1106,6 +1091,7 @@ async def test_resolve_serial_by_id( assert mock_config_flow.mock_calls[0][2]["data"].device == "/dev/serial/by-id/bla" +@pytest.mark.usefixtures("aiousbwatcher_no_inotify") @pytest.mark.parametrize( "ports", [ @@ -1190,7 +1176,6 @@ async def test_cp2102n_ordering_on_macos( with ( patch("sys.platform", "darwin"), - patch("pyudev.Context", side_effect=ImportError), patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), patch("homeassistant.components.usb.comports", return_value=ports), patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, @@ -1239,6 +1224,7 @@ def test_deprecated_constants( ) +@pytest.mark.usefixtures("aiousbwatcher_no_inotify") @patch("homeassistant.components.usb.REQUEST_SCAN_COOLDOWN", 0) async def test_register_port_event_callback( hass: HomeAssistant, hass_ws_client: WebSocketGenerator @@ -1273,7 +1259,6 @@ async def test_register_port_event_callback( # Start off with no ports with ( - patch("pyudev.Context", side_effect=ImportError), patch("homeassistant.components.usb.comports", return_value=[]), ): assert await async_setup_component(hass, "usb", {"usb": {}}) @@ -1335,6 +1320,7 @@ async def test_register_port_event_callback( assert mock_callback2.mock_calls == [] +@pytest.mark.usefixtures("aiousbwatcher_no_inotify") @patch("homeassistant.components.usb.REQUEST_SCAN_COOLDOWN", 0) async def test_register_port_event_callback_failure( hass: HomeAssistant, @@ -1371,7 +1357,6 @@ async def test_register_port_event_callback_failure( # Start off with no ports with ( - patch("pyudev.Context", side_effect=ImportError), patch("homeassistant.components.usb.comports", return_value=[]), ): assert await async_setup_component(hass, "usb", {"usb": {}}) From 56955823878a373510d8e36fc9382f756bfe7e81 Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Tue, 28 Jan 2025 15:57:46 +0100 Subject: [PATCH 0137/3148] Add OneDrive as backup provider (#135121) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Abílio Costa --- .strict-typing | 1 + CODEOWNERS | 2 + homeassistant/brands/microsoft.json | 1 + homeassistant/components/onedrive/__init__.py | 167 ++++++++ homeassistant/components/onedrive/api.py | 53 +++ .../onedrive/application_credentials.py | 14 + homeassistant/components/onedrive/backup.py | 290 ++++++++++++++ .../components/onedrive/config_flow.py | 112 ++++++ homeassistant/components/onedrive/const.py | 24 ++ .../components/onedrive/manifest.json | 13 + .../components/onedrive/quality_scale.yaml | 139 +++++++ .../components/onedrive/strings.json | 53 +++ .../generated/application_credentials.py | 1 + homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + mypy.ini | 10 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/onedrive/__init__.py | 14 + tests/components/onedrive/conftest.py | 178 +++++++++ tests/components/onedrive/const.py | 19 + tests/components/onedrive/test_backup.py | 363 ++++++++++++++++++ tests/components/onedrive/test_config_flow.py | 197 ++++++++++ tests/components/onedrive/test_init.py | 112 ++++++ 24 files changed, 1776 insertions(+) create mode 100644 homeassistant/components/onedrive/__init__.py create mode 100644 homeassistant/components/onedrive/api.py create mode 100644 homeassistant/components/onedrive/application_credentials.py create mode 100644 homeassistant/components/onedrive/backup.py create mode 100644 homeassistant/components/onedrive/config_flow.py create mode 100644 homeassistant/components/onedrive/const.py create mode 100644 homeassistant/components/onedrive/manifest.json create mode 100644 homeassistant/components/onedrive/quality_scale.yaml create mode 100644 homeassistant/components/onedrive/strings.json create mode 100644 tests/components/onedrive/__init__.py create mode 100644 tests/components/onedrive/conftest.py create mode 100644 tests/components/onedrive/const.py create mode 100644 tests/components/onedrive/test_backup.py create mode 100644 tests/components/onedrive/test_config_flow.py create mode 100644 tests/components/onedrive/test_init.py diff --git a/.strict-typing b/.strict-typing index 62da6c5ca92..811e5d54c81 100644 --- a/.strict-typing +++ b/.strict-typing @@ -359,6 +359,7 @@ homeassistant.components.number.* homeassistant.components.nut.* homeassistant.components.onboarding.* homeassistant.components.oncue.* +homeassistant.components.onedrive.* homeassistant.components.onewire.* homeassistant.components.onkyo.* homeassistant.components.open_meteo.* diff --git a/CODEOWNERS b/CODEOWNERS index faded2af138..68a33f34f9a 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1071,6 +1071,8 @@ build.json @home-assistant/supervisor /tests/components/oncue/ @bdraco @peterager /homeassistant/components/ondilo_ico/ @JeromeHXP /tests/components/ondilo_ico/ @JeromeHXP +/homeassistant/components/onedrive/ @zweckj +/tests/components/onedrive/ @zweckj /homeassistant/components/onewire/ @garbled1 @epenet /tests/components/onewire/ @garbled1 @epenet /homeassistant/components/onkyo/ @arturpragacz @eclair4151 diff --git a/homeassistant/brands/microsoft.json b/homeassistant/brands/microsoft.json index 4d9eb5f95f3..0e00c4a7bc3 100644 --- a/homeassistant/brands/microsoft.json +++ b/homeassistant/brands/microsoft.json @@ -11,6 +11,7 @@ "microsoft_face", "microsoft", "msteams", + "onedrive", "xbox" ] } diff --git a/homeassistant/components/onedrive/__init__.py b/homeassistant/components/onedrive/__init__.py new file mode 100644 index 00000000000..7419ca6e20c --- /dev/null +++ b/homeassistant/components/onedrive/__init__.py @@ -0,0 +1,167 @@ +"""The OneDrive integration.""" + +from __future__ import annotations + +from dataclasses import dataclass +import logging + +from kiota_abstractions.api_error import APIError +from kiota_abstractions.authentication import BaseBearerTokenAuthenticationProvider +from msgraph import GraphRequestAdapter, GraphServiceClient +from msgraph.generated.drives.item.items.items_request_builder import ( + ItemsRequestBuilder, +) +from msgraph.generated.models.drive_item import DriveItem +from msgraph.generated.models.folder import Folder + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers.config_entry_oauth2_flow import ( + OAuth2Session, + async_get_config_entry_implementation, +) +from homeassistant.helpers.httpx_client import create_async_httpx_client +from homeassistant.helpers.instance_id import async_get as async_get_instance_id + +from .api import OneDriveConfigEntryAccessTokenProvider +from .const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN, OAUTH_SCOPES + + +@dataclass +class OneDriveRuntimeData: + """Runtime data for the OneDrive integration.""" + + items: ItemsRequestBuilder + backup_folder_id: str + + +type OneDriveConfigEntry = ConfigEntry[OneDriveRuntimeData] + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistant, entry: OneDriveConfigEntry) -> bool: + """Set up OneDrive from a config entry.""" + implementation = await async_get_config_entry_implementation(hass, entry) + + session = OAuth2Session(hass, entry, implementation) + + auth_provider = BaseBearerTokenAuthenticationProvider( + access_token_provider=OneDriveConfigEntryAccessTokenProvider(session) + ) + adapter = GraphRequestAdapter( + auth_provider=auth_provider, + client=create_async_httpx_client(hass, follow_redirects=True), + ) + + graph_client = GraphServiceClient( + request_adapter=adapter, + scopes=OAUTH_SCOPES, + ) + assert entry.unique_id + drive_item = graph_client.drives.by_drive_id(entry.unique_id) + + # get approot, will be created automatically if it does not exist + try: + approot = await drive_item.special.by_drive_item_id("approot").get() + except APIError as err: + if err.response_status_code == 403: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, translation_key="authentication_failed" + ) from err + _LOGGER.debug("Failed to get approot", exc_info=True) + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="failed_to_get_folder", + translation_placeholders={"folder": "approot"}, + ) from err + + if approot is None or not approot.id: + _LOGGER.debug("Failed to get approot, was None") + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="failed_to_get_folder", + translation_placeholders={"folder": "approot"}, + ) + + instance_id = await async_get_instance_id(hass) + backup_folder_id = await _async_create_folder_if_not_exists( + items=drive_item.items, + base_folder_id=approot.id, + folder=f"backups_{instance_id[:8]}", + ) + + entry.runtime_data = OneDriveRuntimeData( + items=drive_item.items, + backup_folder_id=backup_folder_id, + ) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: OneDriveConfigEntry) -> bool: + """Unload a OneDrive config entry.""" + _async_notify_backup_listeners_soon(hass) + return True + + +def _async_notify_backup_listeners(hass: HomeAssistant) -> None: + for listener in hass.data.get(DATA_BACKUP_AGENT_LISTENERS, []): + listener() + + +@callback +def _async_notify_backup_listeners_soon(hass: HomeAssistant) -> None: + hass.loop.call_soon(_async_notify_backup_listeners, hass) + + +async def _async_create_folder_if_not_exists( + items: ItemsRequestBuilder, + base_folder_id: str, + folder: str, +) -> str: + """Check if a folder exists and create it if it does not exist.""" + folder_item: DriveItem | None = None + + try: + folder_item = await items.by_drive_item_id(f"{base_folder_id}:/{folder}:").get() + except APIError as err: + if err.response_status_code != 404: + _LOGGER.debug("Failed to get folder %s", folder, exc_info=True) + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="failed_to_get_folder", + translation_placeholders={"folder": folder}, + ) from err + # is 404 not found, create folder + _LOGGER.debug("Creating folder %s", folder) + request_body = DriveItem( + name=folder, + folder=Folder(), + additional_data={ + "@microsoft_graph_conflict_behavior": "fail", + }, + ) + try: + folder_item = await items.by_drive_item_id(base_folder_id).children.post( + request_body + ) + except APIError as create_err: + _LOGGER.debug("Failed to create folder %s", folder, exc_info=True) + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="failed_to_create_folder", + translation_placeholders={"folder": folder}, + ) from create_err + _LOGGER.debug("Created folder %s", folder) + else: + _LOGGER.debug("Found folder %s", folder) + if folder_item is None or not folder_item.id: + _LOGGER.debug("Failed to get folder %s, was None", folder) + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="failed_to_get_folder", + translation_placeholders={"folder": folder}, + ) + return folder_item.id diff --git a/homeassistant/components/onedrive/api.py b/homeassistant/components/onedrive/api.py new file mode 100644 index 00000000000..934a4f74ec9 --- /dev/null +++ b/homeassistant/components/onedrive/api.py @@ -0,0 +1,53 @@ +"""API for OneDrive bound to Home Assistant OAuth.""" + +from typing import Any, cast + +from kiota_abstractions.authentication import AccessTokenProvider, AllowedHostsValidator + +from homeassistant.const import CONF_ACCESS_TOKEN +from homeassistant.helpers import config_entry_oauth2_flow + + +class OneDriveAccessTokenProvider(AccessTokenProvider): + """Provide OneDrive authentication tied to an OAuth2 based config entry.""" + + def __init__(self) -> None: + """Initialize OneDrive auth.""" + super().__init__() + # currently allowing all hosts + self._allowed_hosts_validator = AllowedHostsValidator(allowed_hosts=[]) + + def get_allowed_hosts_validator(self) -> AllowedHostsValidator: + """Retrieve the allowed hosts validator.""" + return self._allowed_hosts_validator + + +class OneDriveConfigFlowAccessTokenProvider(OneDriveAccessTokenProvider): + """Provide OneDrive authentication tied to an OAuth2 based config entry.""" + + def __init__(self, token: str) -> None: + """Initialize OneDrive auth.""" + super().__init__() + self._token = token + + async def get_authorization_token( # pylint: disable=dangerous-default-value + self, uri: str, additional_authentication_context: dict[str, Any] = {} + ) -> str: + """Return a valid authorization token.""" + return self._token + + +class OneDriveConfigEntryAccessTokenProvider(OneDriveAccessTokenProvider): + """Provide OneDrive authentication tied to an OAuth2 based config entry.""" + + def __init__(self, oauth_session: config_entry_oauth2_flow.OAuth2Session) -> None: + """Initialize OneDrive auth.""" + super().__init__() + self._oauth_session = oauth_session + + async def get_authorization_token( # pylint: disable=dangerous-default-value + self, uri: str, additional_authentication_context: dict[str, Any] = {} + ) -> str: + """Return a valid authorization token.""" + await self._oauth_session.async_ensure_token_valid() + return cast(str, self._oauth_session.token[CONF_ACCESS_TOKEN]) diff --git a/homeassistant/components/onedrive/application_credentials.py b/homeassistant/components/onedrive/application_credentials.py new file mode 100644 index 00000000000..b38aa9313d0 --- /dev/null +++ b/homeassistant/components/onedrive/application_credentials.py @@ -0,0 +1,14 @@ +"""Application credentials platform for the OneDrive integration.""" + +from homeassistant.components.application_credentials import AuthorizationServer +from homeassistant.core import HomeAssistant + +from .const import OAUTH2_AUTHORIZE, OAUTH2_TOKEN + + +async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer: + """Return authorization server.""" + return AuthorizationServer( + authorize_url=OAUTH2_AUTHORIZE, + token_url=OAUTH2_TOKEN, + ) diff --git a/homeassistant/components/onedrive/backup.py b/homeassistant/components/onedrive/backup.py new file mode 100644 index 00000000000..a5a5c019797 --- /dev/null +++ b/homeassistant/components/onedrive/backup.py @@ -0,0 +1,290 @@ +"""Support for OneDrive backup.""" + +from __future__ import annotations + +from collections.abc import AsyncIterator, Callable, Coroutine +from functools import wraps +import html +import json +import logging +from typing import Any, Concatenate, cast + +from httpx import Response +from kiota_abstractions.api_error import APIError +from kiota_abstractions.authentication import AnonymousAuthenticationProvider +from kiota_abstractions.headers_collection import HeadersCollection +from kiota_abstractions.method import Method +from kiota_abstractions.native_response_handler import NativeResponseHandler +from kiota_abstractions.request_information import RequestInformation +from kiota_http.middleware.options import ResponseHandlerOption +from msgraph import GraphRequestAdapter +from msgraph.generated.drives.item.items.item.content.content_request_builder import ( + ContentRequestBuilder, +) +from msgraph.generated.drives.item.items.item.create_upload_session.create_upload_session_post_request_body import ( + CreateUploadSessionPostRequestBody, +) +from msgraph.generated.drives.item.items.item.drive_item_item_request_builder import ( + DriveItemItemRequestBuilder, +) +from msgraph.generated.models.drive_item import DriveItem +from msgraph.generated.models.drive_item_uploadable_properties import ( + DriveItemUploadableProperties, +) +from msgraph_core.models import LargeFileUploadSession + +from homeassistant.components.backup import AgentBackup, BackupAgent, BackupAgentError +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.httpx_client import get_async_client + +from . import OneDriveConfigEntry +from .const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN + +_LOGGER = logging.getLogger(__name__) +UPLOAD_CHUNK_SIZE = 16 * 320 * 1024 # 5.2MB + + +async def async_get_backup_agents( + hass: HomeAssistant, +) -> list[BackupAgent]: + """Return a list of backup agents.""" + entries: list[OneDriveConfigEntry] = hass.config_entries.async_loaded_entries( + DOMAIN + ) + return [OneDriveBackupAgent(hass, entry) for entry in entries] + + +@callback +def async_register_backup_agents_listener( + hass: HomeAssistant, + *, + listener: Callable[[], None], + **kwargs: Any, +) -> Callable[[], None]: + """Register a listener to be called when agents are added or removed.""" + hass.data.setdefault(DATA_BACKUP_AGENT_LISTENERS, []).append(listener) + + @callback + def remove_listener() -> None: + """Remove the listener.""" + hass.data[DATA_BACKUP_AGENT_LISTENERS].remove(listener) + if not hass.data[DATA_BACKUP_AGENT_LISTENERS]: + del hass.data[DATA_BACKUP_AGENT_LISTENERS] + + return remove_listener + + +def handle_backup_errors[_R, **P]( + func: Callable[Concatenate[OneDriveBackupAgent, P], Coroutine[Any, Any, _R]], +) -> Callable[Concatenate[OneDriveBackupAgent, P], Coroutine[Any, Any, _R]]: + """Handle backup errors with a specific translation key.""" + + @wraps(func) + async def wrapper( + self: OneDriveBackupAgent, *args: P.args, **kwargs: P.kwargs + ) -> _R: + try: + return await func(self, *args, **kwargs) + except APIError as err: + if err.response_status_code == 403: + self._entry.async_start_reauth(self._hass) + _LOGGER.error( + "Error during backup in %s: Status %s, message %s", + func.__name__, + err.response_status_code, + err.message, + ) + _LOGGER.debug("Full error: %s", err, exc_info=True) + raise BackupAgentError("Backup operation failed") from err + except TimeoutError as err: + _LOGGER.error( + "Error during backup in %s: Timeout", + func.__name__, + ) + raise BackupAgentError("Backup operation timed out") from err + + return wrapper + + +class OneDriveBackupAgent(BackupAgent): + """OneDrive backup agent.""" + + domain = DOMAIN + + def __init__(self, hass: HomeAssistant, entry: OneDriveConfigEntry) -> None: + """Initialize the OneDrive backup agent.""" + super().__init__() + self._hass = hass + self._entry = entry + self._items = entry.runtime_data.items + self._folder_id = entry.runtime_data.backup_folder_id + self.name = entry.title + assert entry.unique_id + self.unique_id = entry.unique_id + + @handle_backup_errors + async def async_download_backup( + self, backup_id: str, **kwargs: Any + ) -> AsyncIterator[bytes]: + """Download a backup file.""" + # this forces the query to return a raw httpx response, but breaks typing + request_config = ( + ContentRequestBuilder.ContentRequestBuilderGetRequestConfiguration( + options=[ResponseHandlerOption(NativeResponseHandler())], + ) + ) + response = cast( + Response, + await self._get_backup_file_item(backup_id).content.get( + request_configuration=request_config + ), + ) + + return response.aiter_bytes(chunk_size=1024) + + @handle_backup_errors + async def async_upload_backup( + self, + *, + open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]], + backup: AgentBackup, + **kwargs: Any, + ) -> None: + """Upload a backup.""" + + # upload file in chunks to support large files + upload_session_request_body = CreateUploadSessionPostRequestBody( + item=DriveItemUploadableProperties( + additional_data={ + "@microsoft.graph.conflictBehavior": "fail", + }, + ) + ) + upload_session = await self._get_backup_file_item( + backup.backup_id + ).create_upload_session.post(upload_session_request_body) + + if upload_session is None or upload_session.upload_url is None: + raise BackupAgentError( + translation_domain=DOMAIN, translation_key="backup_no_upload_session" + ) + + await self._upload_file( + upload_session.upload_url, await open_stream(), backup.size + ) + + # store metadata in description + backup_dict = backup.as_dict() + backup_dict["metadata_version"] = 1 # version of the backup metadata + description = json.dumps(backup_dict) + _LOGGER.debug("Creating metadata: %s", description) + + await self._get_backup_file_item(backup.backup_id).patch( + DriveItem(description=description) + ) + + @handle_backup_errors + async def async_delete_backup( + self, + backup_id: str, + **kwargs: Any, + ) -> None: + """Delete a backup file.""" + await self._get_backup_file_item(backup_id).delete() + + @handle_backup_errors + async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]: + """List backups.""" + backups: list[AgentBackup] = [] + items = await self._items.by_drive_item_id(f"{self._folder_id}").children.get() + if items and (values := items.value): + for item in values: + if (description := item.description) is None: + continue + if "homeassistant_version" in description: + backups.append(self._backup_from_description(description)) + return backups + + @handle_backup_errors + async def async_get_backup( + self, backup_id: str, **kwargs: Any + ) -> AgentBackup | None: + """Return a backup.""" + try: + drive_item = await self._get_backup_file_item(backup_id).get() + except APIError as err: + if err.response_status_code == 404: + return None + raise + if ( + drive_item is not None + and (description := drive_item.description) is not None + ): + return self._backup_from_description(description) + return None + + def _backup_from_description(self, description: str) -> AgentBackup: + """Create a backup object from a description.""" + description = html.unescape( + description + ) # OneDrive encodes the description on save automatically + return AgentBackup.from_dict(json.loads(description)) + + def _get_backup_file_item(self, backup_id: str) -> DriveItemItemRequestBuilder: + return self._items.by_drive_item_id(f"{self._folder_id}:/{backup_id}.tar:") + + async def _upload_file( + self, upload_url: str, stream: AsyncIterator[bytes], total_size: int + ) -> None: + """Use custom large file upload; SDK does not support stream.""" + + adapter = GraphRequestAdapter( + auth_provider=AnonymousAuthenticationProvider(), + client=get_async_client(self._hass), + ) + + async def async_upload( + start: int, end: int, chunk_data: bytes + ) -> LargeFileUploadSession: + info = RequestInformation() + info.url = upload_url + info.http_method = Method.PUT + info.headers = HeadersCollection() + info.headers.try_add("Content-Range", f"bytes {start}-{end}/{total_size}") + info.headers.try_add("Content-Length", str(len(chunk_data))) + info.headers.try_add("Content-Type", "application/octet-stream") + _LOGGER.debug(info.headers.get_all()) + info.set_stream_content(chunk_data) + result = await adapter.send_async(info, LargeFileUploadSession, {}) + _LOGGER.debug("Next expected range: %s", result.next_expected_ranges) + return result + + start = 0 + buffer: list[bytes] = [] + buffer_size = 0 + + async for chunk in stream: + buffer.append(chunk) + buffer_size += len(chunk) + if buffer_size >= UPLOAD_CHUNK_SIZE: + chunk_data = b"".join(buffer) + uploaded_chunks = 0 + while ( + buffer_size > UPLOAD_CHUNK_SIZE + ): # Loop in case the buffer is >= UPLOAD_CHUNK_SIZE * 2 + slice_start = uploaded_chunks * UPLOAD_CHUNK_SIZE + await async_upload( + start, + start + UPLOAD_CHUNK_SIZE - 1, + chunk_data[slice_start : slice_start + UPLOAD_CHUNK_SIZE], + ) + start += UPLOAD_CHUNK_SIZE + uploaded_chunks += 1 + buffer_size -= UPLOAD_CHUNK_SIZE + buffer = [chunk_data[UPLOAD_CHUNK_SIZE * uploaded_chunks :]] + + # upload the remaining bytes + if buffer: + _LOGGER.debug("Last chunk") + chunk_data = b"".join(buffer) + await async_upload(start, start + len(chunk_data) - 1, chunk_data) diff --git a/homeassistant/components/onedrive/config_flow.py b/homeassistant/components/onedrive/config_flow.py new file mode 100644 index 00000000000..83f6dd6e2ee --- /dev/null +++ b/homeassistant/components/onedrive/config_flow.py @@ -0,0 +1,112 @@ +"""Config flow for OneDrive.""" + +from collections.abc import Mapping +import logging +from typing import Any, cast + +from kiota_abstractions.api_error import APIError +from kiota_abstractions.authentication import BaseBearerTokenAuthenticationProvider +from kiota_abstractions.method import Method +from kiota_abstractions.request_information import RequestInformation +from msgraph import GraphRequestAdapter, GraphServiceClient + +from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN +from homeassistant.helpers.config_entry_oauth2_flow import AbstractOAuth2FlowHandler +from homeassistant.helpers.httpx_client import get_async_client + +from .api import OneDriveConfigFlowAccessTokenProvider +from .const import DOMAIN, OAUTH_SCOPES + + +class OneDriveConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN): + """Config flow to handle OneDrive OAuth2 authentication.""" + + DOMAIN = DOMAIN + + @property + def logger(self) -> logging.Logger: + """Return logger.""" + return logging.getLogger(__name__) + + @property + def extra_authorize_data(self) -> dict[str, Any]: + """Extra data that needs to be appended to the authorize url.""" + return {"scope": " ".join(OAUTH_SCOPES)} + + async def async_oauth_create_entry( + self, + data: dict[str, Any], + ) -> ConfigFlowResult: + """Handle the initial step.""" + auth_provider = BaseBearerTokenAuthenticationProvider( + access_token_provider=OneDriveConfigFlowAccessTokenProvider( + cast(str, data[CONF_TOKEN][CONF_ACCESS_TOKEN]) + ) + ) + adapter = GraphRequestAdapter( + auth_provider=auth_provider, + client=get_async_client(self.hass), + ) + + graph_client = GraphServiceClient( + request_adapter=adapter, + scopes=OAUTH_SCOPES, + ) + + # need to get adapter from client, as client changes it + request_adapter = cast(GraphRequestAdapter, graph_client.request_adapter) + + request_info = RequestInformation( + method=Method.GET, + url_template="{+baseurl}/me/drive/special/approot", + path_parameters={}, + ) + parent_span = request_adapter.start_tracing_span(request_info, "get_approot") + + # get the OneDrive id + # use low level methods, to avoid files.read permissions + # which would be required by drives.me.get() + try: + response = await request_adapter.get_http_response_message( + request_info=request_info, parent_span=parent_span + ) + except APIError: + self.logger.exception("Failed to connect to OneDrive") + return self.async_abort(reason="connection_error") + except Exception: + self.logger.exception("Unknown error") + return self.async_abort(reason="unknown") + + drive = response.json() + + await self.async_set_unique_id(drive["parentReference"]["driveId"]) + + if self.source == SOURCE_REAUTH: + reauth_entry = self._get_reauth_entry() + self._abort_if_unique_id_mismatch( + reason="wrong_drive", + ) + return self.async_update_reload_and_abort( + entry=reauth_entry, + data=data, + ) + + self._abort_if_unique_id_configured() + + title = f"{drive['shared']['owner']['user']['displayName']}'s OneDrive" + return self.async_create_entry(title=title, data=data) + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Perform reauth upon an API authentication error.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm reauth dialog.""" + if user_input is None: + return self.async_show_form(step_id="reauth_confirm") + return await self.async_step_user() diff --git a/homeassistant/components/onedrive/const.py b/homeassistant/components/onedrive/const.py new file mode 100644 index 00000000000..f9d49b141e5 --- /dev/null +++ b/homeassistant/components/onedrive/const.py @@ -0,0 +1,24 @@ +"""Constants for the OneDrive integration.""" + +from collections.abc import Callable +from typing import Final + +from homeassistant.util.hass_dict import HassKey + +DOMAIN: Final = "onedrive" + +# replace "consumers" with "common", when adding SharePoint or OneDrive for Business support +OAUTH2_AUTHORIZE: Final = ( + "https://login.microsoftonline.com/consumers/oauth2/v2.0/authorize" +) +OAUTH2_TOKEN: Final = "https://login.microsoftonline.com/consumers/oauth2/v2.0/token" + +OAUTH_SCOPES: Final = [ + "Files.ReadWrite.AppFolder", + "offline_access", + "openid", +] + +DATA_BACKUP_AGENT_LISTENERS: HassKey[list[Callable[[], None]]] = HassKey( + f"{DOMAIN}.backup_agent_listeners" +) diff --git a/homeassistant/components/onedrive/manifest.json b/homeassistant/components/onedrive/manifest.json new file mode 100644 index 00000000000..056e31864a4 --- /dev/null +++ b/homeassistant/components/onedrive/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "onedrive", + "name": "OneDrive", + "codeowners": ["@zweckj"], + "config_flow": true, + "dependencies": ["application_credentials"], + "documentation": "https://www.home-assistant.io/integrations/onedrive", + "integration_type": "service", + "iot_class": "cloud_polling", + "loggers": ["msgraph", "msgraph-core", "kiota"], + "quality_scale": "bronze", + "requirements": ["msgraph-sdk==1.16.0"] +} diff --git a/homeassistant/components/onedrive/quality_scale.yaml b/homeassistant/components/onedrive/quality_scale.yaml new file mode 100644 index 00000000000..f0d58d89c9a --- /dev/null +++ b/homeassistant/components/onedrive/quality_scale.yaml @@ -0,0 +1,139 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: Integration does not register custom actions. + appropriate-polling: + status: exempt + comment: | + This integration does not poll. + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: | + This integration does not have any custom actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: | + Entities of this integration does not explicitly subscribe to events. + entity-unique-id: + status: exempt + comment: | + This integration does not have entities. + has-entity-name: + status: exempt + comment: | + This integration does not have entities. + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: done + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: | + No Options flow. + docs-installation-parameters: done + entity-unavailable: + status: exempt + comment: | + This integration does not have entities. + integration-owner: done + log-when-unavailable: + status: exempt + comment: | + This integration does not have entities. + parallel-updates: + status: exempt + comment: | + This integration does not have platforms. + reauthentication-flow: done + test-coverage: todo + + # Gold + devices: + status: exempt + comment: | + This integration connects to a single service. + diagnostics: + status: exempt + comment: | + There is no data to diagnose. + discovery-update-info: + status: exempt + comment: | + This integration is a cloud service and does not support discovery. + discovery: + status: exempt + comment: | + This integration is a cloud service and does not support discovery. + docs-data-update: + status: exempt + comment: | + This integration does not poll or push. + docs-examples: + status: exempt + comment: | + This integration only serves backup. + docs-known-limitations: done + docs-supported-devices: + status: exempt + comment: | + This integration is a cloud service. + docs-supported-functions: + status: exempt + comment: | + This integration does not have entities. + docs-troubleshooting: + status: exempt + comment: | + No issues known to troubleshoot. + docs-use-cases: done + dynamic-devices: + status: exempt + comment: | + This integration connects to a single service. + entity-category: + status: exempt + comment: | + This integration does not have entities. + entity-device-class: + status: exempt + comment: | + This integration does not have entities. + entity-disabled-by-default: + status: exempt + comment: | + This integration does not have entities. + entity-translations: + status: exempt + comment: | + This integration does not have entities. + exception-translations: done + icon-translations: + status: exempt + comment: | + This integration does not have entities. + reconfiguration-flow: + status: exempt + comment: | + Nothing to reconfigure. + repair-issues: done + stale-devices: + status: exempt + comment: | + This integration connects to a single service. + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/onedrive/strings.json b/homeassistant/components/onedrive/strings.json new file mode 100644 index 00000000000..9cbdb2bdeae --- /dev/null +++ b/homeassistant/components/onedrive/strings.json @@ -0,0 +1,53 @@ +{ + "config": { + "step": { + "pick_implementation": { + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + }, + "reauth_confirm": { + "title": "[%key:common::config_flow::title::reauth%]", + "description": "The OneDrive integration needs to re-authenticate your account" + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]", + "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", + "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", + "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", + "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", + "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", + "user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]", + "connection_error": "Failed to connect to OneDrive.", + "wrong_drive": "New account does not contain previously configured OneDrive.", + "unknown": "[%key:common::config_flow::error::unknown%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "failed_to_create_folder": "Failed to create backup folder" + }, + "create_entry": { + "default": "[%key:common::config_flow::create_entry::authenticated%]" + } + }, + "exceptions": { + "backup_not_found": { + "message": "Backup not found" + }, + "backup_no_content": { + "message": "Backup has no content" + }, + "backup_no_upload_session": { + "message": "Failed to start backup upload" + }, + "authentication_failed": { + "message": "Authentication failed" + }, + "failed_to_get_folder": { + "message": "Failed to get {folder} folder" + }, + "failed_to_create_folder": { + "message": "Failed to create {folder} folder" + } + } +} diff --git a/homeassistant/generated/application_credentials.py b/homeassistant/generated/application_credentials.py index 6b3028826dc..ef55798b3a0 100644 --- a/homeassistant/generated/application_credentials.py +++ b/homeassistant/generated/application_credentials.py @@ -24,6 +24,7 @@ APPLICATION_CREDENTIALS = [ "neato", "nest", "netatmo", + "onedrive", "point", "senz", "spotify", diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 7dea4598790..12dda0f56be 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -434,6 +434,7 @@ FLOWS = { "omnilogic", "oncue", "ondilo_ico", + "onedrive", "onewire", "onkyo", "onvif", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 6d2e784c583..53a485a1340 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -3802,6 +3802,12 @@ "iot_class": "cloud_push", "name": "Microsoft Teams" }, + "onedrive": { + "integration_type": "service", + "config_flow": true, + "iot_class": "cloud_polling", + "name": "OneDrive" + }, "xbox": { "integration_type": "hub", "config_flow": true, diff --git a/mypy.ini b/mypy.ini index 188f1f7bbd7..db1ec0a04e4 100644 --- a/mypy.ini +++ b/mypy.ini @@ -3346,6 +3346,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.onedrive.*] +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.onewire.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index e9436475775..128586eb01e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1434,6 +1434,9 @@ motioneye-client==0.3.14 # homeassistant.components.bang_olufsen mozart-api==4.1.1.116.4 +# homeassistant.components.onedrive +msgraph-sdk==1.16.0 + # homeassistant.components.mullvad mullvad-api==1.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c1752dc7e45..117886a0bc8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1206,6 +1206,9 @@ motioneye-client==0.3.14 # homeassistant.components.bang_olufsen mozart-api==4.1.1.116.4 +# homeassistant.components.onedrive +msgraph-sdk==1.16.0 + # homeassistant.components.mullvad mullvad-api==1.0.0 diff --git a/tests/components/onedrive/__init__.py b/tests/components/onedrive/__init__.py new file mode 100644 index 00000000000..0bafe37775b --- /dev/null +++ b/tests/components/onedrive/__init__.py @@ -0,0 +1,14 @@ +"""Tests for the OneDrive integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Set up the OneDrive integration for testing.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/onedrive/conftest.py b/tests/components/onedrive/conftest.py new file mode 100644 index 00000000000..0cca8e9df0b --- /dev/null +++ b/tests/components/onedrive/conftest.py @@ -0,0 +1,178 @@ +"""Fixtures for OneDrive tests.""" + +from collections.abc import AsyncIterator, Generator +from html import escape +from json import dumps +import time +from unittest.mock import AsyncMock, MagicMock, patch + +from httpx import Response +from msgraph.generated.models.drive_item import DriveItem +from msgraph.generated.models.drive_item_collection_response import ( + DriveItemCollectionResponse, +) +from msgraph.generated.models.upload_session import UploadSession +from msgraph_core.models import LargeFileUploadSession +import pytest + +from homeassistant.components.application_credentials import ( + ClientCredential, + async_import_client_credential, +) +from homeassistant.components.onedrive.const import DOMAIN, OAUTH_SCOPES +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from .const import BACKUP_METADATA, CLIENT_ID, CLIENT_SECRET + +from tests.common import MockConfigEntry + + +@pytest.fixture(name="scopes") +def mock_scopes() -> list[str]: + """Fixture to set the scopes present in the OAuth token.""" + return OAUTH_SCOPES + + +@pytest.fixture(autouse=True) +async def setup_credentials(hass: HomeAssistant) -> None: + """Fixture to setup credentials.""" + assert await async_setup_component(hass, "application_credentials", {}) + await async_import_client_credential( + hass, + DOMAIN, + ClientCredential(CLIENT_ID, CLIENT_SECRET), + ) + + +@pytest.fixture(name="expires_at") +def mock_expires_at() -> int: + """Fixture to set the oauth token expiration time.""" + return time.time() + 3600 + + +@pytest.fixture +def mock_config_entry(expires_at: int, scopes: list[str]) -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + title="John Doe's OneDrive", + domain=DOMAIN, + data={ + "auth_implementation": DOMAIN, + "token": { + "access_token": "mock-access-token", + "refresh_token": "mock-refresh-token", + "expires_at": expires_at, + "scope": " ".join(scopes), + }, + }, + unique_id="mock_drive_id", + ) + + +@pytest.fixture +def mock_adapter() -> Generator[MagicMock]: + """Return a mocked GraphAdapter.""" + with ( + patch( + "homeassistant.components.onedrive.config_flow.GraphRequestAdapter", + autospec=True, + ) as mock_adapter, + patch( + "homeassistant.components.onedrive.backup.GraphRequestAdapter", + new=mock_adapter, + ), + ): + adapter = mock_adapter.return_value + adapter.get_http_response_message.return_value = Response( + status_code=200, + json={ + "parentReference": {"driveId": "mock_drive_id"}, + "shared": {"owner": {"user": {"displayName": "John Doe"}}}, + }, + ) + yield adapter + adapter.send_async.return_value = LargeFileUploadSession( + next_expected_ranges=["2-"] + ) + + +@pytest.fixture(autouse=True) +def mock_graph_client(mock_adapter: MagicMock) -> Generator[MagicMock]: + """Return a mocked GraphServiceClient.""" + with ( + patch( + "homeassistant.components.onedrive.config_flow.GraphServiceClient", + autospec=True, + ) as graph_client, + patch( + "homeassistant.components.onedrive.GraphServiceClient", + new=graph_client, + ), + ): + client = graph_client.return_value + + client.request_adapter = mock_adapter + + drives = client.drives.by_drive_id.return_value + drives.special.by_drive_item_id.return_value.get = AsyncMock( + return_value=DriveItem(id="approot") + ) + + drive_items = drives.items.by_drive_item_id.return_value + drive_items.get = AsyncMock(return_value=DriveItem(id="folder_id")) + drive_items.children.post = AsyncMock(return_value=DriveItem(id="folder_id")) + drive_items.children.get = AsyncMock( + return_value=DriveItemCollectionResponse( + value=[ + DriveItem(description=escape(dumps(BACKUP_METADATA))), + DriveItem(), + ] + ) + ) + drive_items.delete = AsyncMock(return_value=None) + drive_items.create_upload_session.post = AsyncMock( + return_value=UploadSession(upload_url="https://test.tld") + ) + drive_items.patch = AsyncMock(return_value=None) + + async def generate_bytes() -> AsyncIterator[bytes]: + """Asynchronous generator that yields bytes.""" + yield b"backup data" + + drive_items.content.get = AsyncMock( + return_value=Response(status_code=200, content=generate_bytes()) + ) + + yield client + + +@pytest.fixture +def mock_drive_items(mock_graph_client: MagicMock) -> MagicMock: + """Return a mocked DriveItems.""" + return mock_graph_client.drives.by_drive_id.return_value.items.by_drive_item_id.return_value + + +@pytest.fixture +def mock_get_special_folder(mock_graph_client: MagicMock) -> MagicMock: + """Mock the get special folder method.""" + return mock_graph_client.drives.by_drive_id.return_value.special.by_drive_item_id.return_value.get + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.onedrive.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture(autouse=True) +def mock_instance_id() -> Generator[AsyncMock]: + """Mock the instance ID.""" + with patch( + "homeassistant.components.onedrive.async_get_instance_id", + return_value="9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0", + ): + yield diff --git a/tests/components/onedrive/const.py b/tests/components/onedrive/const.py new file mode 100644 index 00000000000..c187feef30a --- /dev/null +++ b/tests/components/onedrive/const.py @@ -0,0 +1,19 @@ +"""Consts for OneDrive tests.""" + +CLIENT_ID = "1234" +CLIENT_SECRET = "5678" + + +BACKUP_METADATA = { + "addons": [], + "backup_id": "23e64aec", + "date": "2024-11-22T11:48:48.727189+01:00", + "database_included": True, + "extra_metadata": {}, + "folders": [], + "homeassistant_included": True, + "homeassistant_version": "2024.12.0.dev0", + "name": "Core 2024.12.0.dev0", + "protected": False, + "size": 34519040, +} diff --git a/tests/components/onedrive/test_backup.py b/tests/components/onedrive/test_backup.py new file mode 100644 index 00000000000..a3cfbe95a46 --- /dev/null +++ b/tests/components/onedrive/test_backup.py @@ -0,0 +1,363 @@ +"""Test the backups for OneDrive.""" + +from __future__ import annotations + +from collections.abc import AsyncGenerator +from html import escape +from io import StringIO +from json import dumps +from unittest.mock import Mock, patch + +from kiota_abstractions.api_error import APIError +from msgraph.generated.models.drive_item import DriveItem +import pytest + +from homeassistant.components.backup import DOMAIN as BACKUP_DOMAIN, AgentBackup +from homeassistant.components.onedrive.const import DOMAIN +from homeassistant.config_entries import SOURCE_REAUTH +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from . import setup_integration +from .const import BACKUP_METADATA + +from tests.common import AsyncMock, MockConfigEntry +from tests.typing import ClientSessionGenerator, MagicMock, WebSocketGenerator + + +@pytest.fixture(autouse=True) +async def setup_backup_integration( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> AsyncGenerator[None]: + """Set up onedrive integration.""" + with ( + patch("homeassistant.components.backup.is_hassio", return_value=False), + patch("homeassistant.components.backup.store.STORE_DELAY_SAVE", 0), + ): + assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}}) + await setup_integration(hass, mock_config_entry) + + await hass.async_block_till_done() + yield + + +async def test_agents_info( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_config_entry: MockConfigEntry, +) -> None: + """Test backup agent info.""" + client = await hass_ws_client(hass) + + await client.send_json_auto_id({"type": "backup/agents/info"}) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == { + "agents": [ + {"agent_id": "backup.local", "name": "local"}, + { + "agent_id": f"{DOMAIN}.{mock_config_entry.unique_id}", + "name": mock_config_entry.title, + }, + ], + } + + +async def test_agents_list_backups( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_config_entry: MockConfigEntry, +) -> None: + """Test agent list backups.""" + + client = await hass_ws_client(hass) + await client.send_json_auto_id({"type": "backup/info"}) + response = await client.receive_json() + + assert response["success"] + assert response["result"]["agent_errors"] == {} + assert response["result"]["backups"] == [ + { + "addons": [], + "backup_id": "23e64aec", + "date": "2024-11-22T11:48:48.727189+01:00", + "database_included": True, + "folders": [], + "homeassistant_included": True, + "homeassistant_version": "2024.12.0.dev0", + "name": "Core 2024.12.0.dev0", + "protected": False, + "size": 34519040, + "agent_ids": [f"{DOMAIN}.{mock_config_entry.unique_id}"], + "failed_agent_ids": [], + "with_automatic_settings": None, + } + ] + + +async def test_agents_get_backup( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_drive_items: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test agent get backup.""" + + mock_drive_items.get = AsyncMock( + return_value=DriveItem(description=escape(dumps(BACKUP_METADATA))) + ) + backup_id = BACKUP_METADATA["backup_id"] + client = await hass_ws_client(hass) + await client.send_json_auto_id({"type": "backup/details", "backup_id": backup_id}) + response = await client.receive_json() + + assert response["success"] + assert response["result"]["agent_errors"] == {} + assert response["result"]["backup"] == { + "addons": [], + "backup_id": "23e64aec", + "date": "2024-11-22T11:48:48.727189+01:00", + "database_included": True, + "folders": [], + "homeassistant_included": True, + "homeassistant_version": "2024.12.0.dev0", + "name": "Core 2024.12.0.dev0", + "protected": False, + "size": 34519040, + "agent_ids": [f"{DOMAIN}.{mock_config_entry.unique_id}"], + "failed_agent_ids": [], + "with_automatic_settings": None, + } + + +async def test_agents_delete( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_drive_items: MagicMock, +) -> None: + """Test agent delete backup.""" + client = await hass_ws_client(hass) + + await client.send_json_auto_id( + { + "type": "backup/delete", + "backup_id": BACKUP_METADATA["backup_id"], + } + ) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == {"agent_errors": {}} + mock_drive_items.delete.assert_called_once() + + +async def test_agents_upload( + hass_client: ClientSessionGenerator, + caplog: pytest.LogCaptureFixture, + mock_drive_items: MagicMock, + mock_config_entry: MockConfigEntry, + mock_adapter: MagicMock, +) -> None: + """Test agent upload backup.""" + client = await hass_client() + test_backup = AgentBackup.from_dict(BACKUP_METADATA) + + with ( + patch( + "homeassistant.components.backup.manager.BackupManager.async_get_backup", + ) as fetch_backup, + patch( + "homeassistant.components.backup.manager.read_backup", + return_value=test_backup, + ), + patch("pathlib.Path.open") as mocked_open, + patch("homeassistant.components.onedrive.backup.UPLOAD_CHUNK_SIZE", 3), + ): + mocked_open.return_value.read = Mock(side_effect=[b"test", b""]) + fetch_backup.return_value = test_backup + resp = await client.post( + f"/api/backup/upload?agent_id={DOMAIN}.{mock_config_entry.unique_id}", + data={"file": StringIO("test")}, + ) + + assert resp.status == 201 + assert f"Uploading backup {test_backup.backup_id}" in caplog.text + mock_drive_items.create_upload_session.post.assert_called_once() + mock_drive_items.patch.assert_called_once() + assert mock_adapter.send_async.call_count == 2 + assert mock_adapter.method_calls[0].args[0].content == b"tes" + assert mock_adapter.method_calls[0].args[0].headers.get("Content-Range") == { + "bytes 0-2/34519040" + } + assert mock_adapter.method_calls[1].args[0].content == b"t" + assert mock_adapter.method_calls[1].args[0].headers.get("Content-Range") == { + "bytes 3-3/34519040" + } + + +async def test_broken_upload_session( + hass_client: ClientSessionGenerator, + caplog: pytest.LogCaptureFixture, + mock_drive_items: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test broken upload session.""" + client = await hass_client() + test_backup = AgentBackup.from_dict(BACKUP_METADATA) + + mock_drive_items.create_upload_session.post = AsyncMock(return_value=None) + + with ( + patch( + "homeassistant.components.backup.manager.BackupManager.async_get_backup", + ) as fetch_backup, + patch( + "homeassistant.components.backup.manager.read_backup", + return_value=test_backup, + ), + patch("pathlib.Path.open") as mocked_open, + ): + mocked_open.return_value.read = Mock(side_effect=[b"test", b""]) + fetch_backup.return_value = test_backup + resp = await client.post( + f"/api/backup/upload?agent_id={DOMAIN}.{mock_config_entry.unique_id}", + data={"file": StringIO("test")}, + ) + + assert resp.status == 201 + assert "Failed to start backup upload" in caplog.text + + +async def test_agents_download( + hass_client: ClientSessionGenerator, + mock_drive_items: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test agent download backup.""" + mock_drive_items.get = AsyncMock( + return_value=DriveItem(description=escape(dumps(BACKUP_METADATA))) + ) + client = await hass_client() + backup_id = BACKUP_METADATA["backup_id"] + + resp = await client.get( + f"/api/backup/download/{backup_id}?agent_id={DOMAIN}.{mock_config_entry.unique_id}" + ) + assert resp.status == 200 + assert await resp.content.read() == b"backup data" + mock_drive_items.content.get.assert_called_once() + + +@pytest.mark.parametrize( + ("side_effect", "error"), + [ + ( + APIError(response_status_code=404, message="File not found."), + "Backup operation failed", + ), + (TimeoutError(), "Backup operation timed out"), + ], +) +async def test_delete_error( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_drive_items: MagicMock, + mock_config_entry: MockConfigEntry, + side_effect: Exception, + error: str, +) -> None: + """Test error during delete.""" + mock_drive_items.delete = AsyncMock(side_effect=side_effect) + + client = await hass_ws_client(hass) + + await client.send_json_auto_id( + { + "type": "backup/delete", + "backup_id": BACKUP_METADATA["backup_id"], + } + ) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == { + "agent_errors": {f"{DOMAIN}.{mock_config_entry.unique_id}": error} + } + + +@pytest.mark.parametrize( + "problem", + [ + AsyncMock(return_value=None), + AsyncMock(side_effect=APIError(response_status_code=404)), + ], +) +async def test_agents_backup_not_found( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_drive_items: MagicMock, + problem: AsyncMock, +) -> None: + """Test backup not found.""" + + mock_drive_items.get = problem + backup_id = BACKUP_METADATA["backup_id"] + client = await hass_ws_client(hass) + await client.send_json_auto_id({"type": "backup/details", "backup_id": backup_id}) + response = await client.receive_json() + + assert response["success"] + assert response["result"]["backup"] is None + + +async def test_agents_backup_error( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_drive_items: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test backup not found.""" + + mock_drive_items.get = AsyncMock(side_effect=APIError(response_status_code=500)) + backup_id = BACKUP_METADATA["backup_id"] + client = await hass_ws_client(hass) + await client.send_json_auto_id({"type": "backup/details", "backup_id": backup_id}) + response = await client.receive_json() + + assert response["success"] + assert response["result"]["agent_errors"] == { + f"{DOMAIN}.{mock_config_entry.unique_id}": "Backup operation failed" + } + + +async def test_reauth_on_403( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_drive_items: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test we re-authenticate on 403.""" + + mock_drive_items.get = AsyncMock(side_effect=APIError(response_status_code=403)) + backup_id = BACKUP_METADATA["backup_id"] + client = await hass_ws_client(hass) + await client.send_json_auto_id({"type": "backup/details", "backup_id": backup_id}) + response = await client.receive_json() + + assert response["success"] + assert response["result"]["agent_errors"] == { + f"{DOMAIN}.{mock_config_entry.unique_id}": "Backup operation failed" + } + + await hass.async_block_till_done() + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + + flow = flows[0] + assert flow["step_id"] == "reauth_confirm" + assert flow["handler"] == DOMAIN + assert "context" in flow + assert flow["context"]["source"] == SOURCE_REAUTH + assert flow["context"]["entry_id"] == mock_config_entry.entry_id diff --git a/tests/components/onedrive/test_config_flow.py b/tests/components/onedrive/test_config_flow.py new file mode 100644 index 00000000000..8be6aadfd0f --- /dev/null +++ b/tests/components/onedrive/test_config_flow.py @@ -0,0 +1,197 @@ +"""Test the OneDrive config flow.""" + +from http import HTTPStatus +from unittest.mock import AsyncMock, MagicMock + +from httpx import Response +from kiota_abstractions.api_error import APIError +import pytest + +from homeassistant import config_entries +from homeassistant.components.onedrive.const import ( + DOMAIN, + OAUTH2_AUTHORIZE, + OAUTH2_TOKEN, +) +from homeassistant.config_entries import ConfigFlowResult +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import config_entry_oauth2_flow + +from . import setup_integration +from .const import CLIENT_ID + +from tests.common import MockConfigEntry +from tests.test_util.aiohttp import AiohttpClientMocker +from tests.typing import ClientSessionGenerator + + +async def _do_get_token( + hass: HomeAssistant, + result: ConfigFlowResult, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, +) -> None: + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + scope = "Files.ReadWrite.AppFolder+offline_access+openid" + + assert result["url"] == ( + f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}&scope={scope}" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == HTTPStatus.OK + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + }, + ) + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_full_flow( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_setup_entry: AsyncMock, +) -> None: + """Check full flow.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + await _do_get_token(hass, result, hass_client_no_auth, aioclient_mock) + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + assert result["title"] == "John Doe's OneDrive" + assert result["result"].unique_id == "mock_drive_id" + assert result["data"][CONF_TOKEN][CONF_ACCESS_TOKEN] == "mock-access-token" + assert result["data"][CONF_TOKEN]["refresh_token"] == "mock-refresh-token" + + +@pytest.mark.usefixtures("current_request_with_host") +@pytest.mark.parametrize( + ("exception", "error"), + [ + (Exception, "unknown"), + (APIError, "connection_error"), + ], +) +async def test_flow_errors( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_adapter: MagicMock, + exception: Exception, + error: str, +) -> None: + """Test errors during flow.""" + + mock_adapter.get_http_response_message.side_effect = exception + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + await _do_get_token(hass, result, hass_client_no_auth, aioclient_mock) + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == error + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_already_configured( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test already configured account.""" + await setup_integration(hass, mock_config_entry) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + await _do_get_token(hass, result, hass_client_no_auth, aioclient_mock) + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_reauth_flow( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that the reauth flow works.""" + + await setup_integration(hass, mock_config_entry) + + result = await mock_config_entry.start_reauth_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + await _do_get_token(hass, result, hass_client_no_auth, aioclient_mock) + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_reauth_flow_id_changed( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, + mock_adapter: MagicMock, +) -> None: + """Test that the reauth flow fails on a different drive id.""" + mock_adapter.get_http_response_message.return_value = Response( + status_code=200, + json={ + "parentReference": {"driveId": "other_drive_id"}, + }, + ) + + await setup_integration(hass, mock_config_entry) + + result = await mock_config_entry.start_reauth_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + await _do_get_token(hass, result, hass_client_no_auth, aioclient_mock) + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "wrong_drive" diff --git a/tests/components/onedrive/test_init.py b/tests/components/onedrive/test_init.py new file mode 100644 index 00000000000..bc5c22c3ce6 --- /dev/null +++ b/tests/components/onedrive/test_init.py @@ -0,0 +1,112 @@ +"""Test the OneDrive setup.""" + +from unittest.mock import MagicMock + +from kiota_abstractions.api_error import APIError +import pytest + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from . import setup_integration + +from tests.common import MockConfigEntry + + +async def test_load_unload_config_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test loading and unloading the integration.""" + 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 + + +@pytest.mark.parametrize( + ("side_effect", "state"), + [ + (APIError(response_status_code=403), ConfigEntryState.SETUP_ERROR), + (APIError(response_status_code=500), ConfigEntryState.SETUP_RETRY), + ], +) +async def test_approot_errors( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_get_special_folder: MagicMock, + side_effect: Exception, + state: ConfigEntryState, +) -> None: + """Test errors during approot retrieval.""" + mock_get_special_folder.side_effect = side_effect + await setup_integration(hass, mock_config_entry) + assert mock_config_entry.state is state + + +async def test_faulty_approot( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_get_special_folder: MagicMock, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test faulty approot retrieval.""" + mock_get_special_folder.return_value = None + await setup_integration(hass, mock_config_entry) + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + assert "Failed to get approot folder" in caplog.text + + +async def test_faulty_integration_folder( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_drive_items: MagicMock, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test faulty approot retrieval.""" + mock_drive_items.get.return_value = None + await setup_integration(hass, mock_config_entry) + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + assert "Failed to get backups_9f86d081 folder" in caplog.text + + +async def test_500_error_during_backup_folder_get( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_drive_items: MagicMock, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test error during backup folder creation.""" + mock_drive_items.get.side_effect = APIError(response_status_code=500) + await setup_integration(hass, mock_config_entry) + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + assert "Failed to get backups_9f86d081 folder" in caplog.text + + +async def test_error_during_backup_folder_creation( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_drive_items: MagicMock, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test error during backup folder creation.""" + mock_drive_items.get.side_effect = APIError(response_status_code=404) + mock_drive_items.children.post.side_effect = APIError() + await setup_integration(hass, mock_config_entry) + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + assert "Failed to create backups_9f86d081 folder" in caplog.text + + +async def test_successful_backup_folder_creation( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_drive_items: MagicMock, +) -> None: + """Test successful backup folder creation.""" + mock_drive_items.get.side_effect = APIError(response_status_code=404) + await setup_integration(hass, mock_config_entry) + assert mock_config_entry.state is ConfigEntryState.LOADED From 01f63cfefd9dd882e8a4754f758767bfc7b1a42c Mon Sep 17 00:00:00 2001 From: Christopher Fenner <9592452+CFenner@users.noreply.github.com> Date: Tue, 28 Jan 2025 16:34:08 +0100 Subject: [PATCH 0138/3148] Add SPF sensor for heat pumps in ViCare integration (#136233) Co-authored-by: Dave T <17680170+davet2001@users.noreply.github.com> --- homeassistant/components/vicare/sensor.py | 21 +++ homeassistant/components/vicare/strings.json | 9 ++ .../vicare/snapshots/test_sensor.ambr | 147 ++++++++++++++++++ 3 files changed, 177 insertions(+) diff --git a/homeassistant/components/vicare/sensor.py b/homeassistant/components/vicare/sensor.py index 14624be2b6d..091deeba2a9 100644 --- a/homeassistant/components/vicare/sensor.py +++ b/homeassistant/components/vicare/sensor.py @@ -862,6 +862,27 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.TOTAL_INCREASING, ), + ViCareSensorEntityDescription( + key="spf_total", + translation_key="spf_total", + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + value_getter=lambda api: api.getSeasonalPerformanceFactorTotal(), + ), + ViCareSensorEntityDescription( + key="spf_dhw", + translation_key="spf_dhw", + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + value_getter=lambda api: api.getSeasonalPerformanceFactorDHW(), + ), + ViCareSensorEntityDescription( + key="spf_heating", + translation_key="spf_heating", + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + value_getter=lambda api: api.getSeasonalPerformanceFactorHeating(), + ), ) CIRCUIT_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( diff --git a/homeassistant/components/vicare/strings.json b/homeassistant/components/vicare/strings.json index 4eee81f3d05..a8636f651f3 100644 --- a/homeassistant/components/vicare/strings.json +++ b/homeassistant/components/vicare/strings.json @@ -466,6 +466,15 @@ }, "heating_rod_hours": { "name": "Heating rod hours" + }, + "spf_total": { + "name": "Seasonal performance factor" + }, + "spf_dhw": { + "name": "Seasonal performance factor - domestic hot water" + }, + "spf_heating": { + "name": "Seasonal performance factor - heating" } }, "water_heater": { diff --git a/tests/components/vicare/snapshots/test_sensor.ambr b/tests/components/vicare/snapshots/test_sensor.ambr index 17c9ee99320..ace22391797 100644 --- a/tests/components/vicare/snapshots/test_sensor.ambr +++ b/tests/components/vicare/snapshots/test_sensor.ambr @@ -2110,6 +2110,153 @@ 'state': '35.3', }) # --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_seasonal_performance_factor-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': , + 'entity_id': 'sensor.model0_seasonal_performance_factor', + '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': 'Seasonal performance factor', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'spf_total', + 'unique_id': 'gateway0_deviceSerialVitocal250A-spf_total', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_seasonal_performance_factor-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'model0 Seasonal performance factor', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.model0_seasonal_performance_factor', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3.9', + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_seasonal_performance_factor_domestic_hot_water-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': , + 'entity_id': 'sensor.model0_seasonal_performance_factor_domestic_hot_water', + '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': 'Seasonal performance factor - domestic hot water', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'spf_dhw', + 'unique_id': 'gateway0_deviceSerialVitocal250A-spf_dhw', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_seasonal_performance_factor_domestic_hot_water-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'model0 Seasonal performance factor - domestic hot water', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.model0_seasonal_performance_factor_domestic_hot_water', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4.1', + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_seasonal_performance_factor_heating-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': , + 'entity_id': 'sensor.model0_seasonal_performance_factor_heating', + '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': 'Seasonal performance factor - heating', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'spf_heating', + 'unique_id': 'gateway0_deviceSerialVitocal250A-spf_heating', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_seasonal_performance_factor_heating-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'model0 Seasonal performance factor - heating', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.model0_seasonal_performance_factor_heating', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3.2', + }) +# --- # name: test_all_entities[type:heatpump-vicare/Vitocal250A.json][sensor.model0_secondary_circuit_supply_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 3f013ab620440d99e7d689c731810b85abe08676 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Tue, 28 Jan 2025 16:39:41 +0100 Subject: [PATCH 0139/3148] Add sensor for Matter OperationalState cluster / CurrentPhase attribute (#129757) --- homeassistant/components/matter/icons.json | 3 + homeassistant/components/matter/sensor.py | 44 ++++++++++++++ homeassistant/components/matter/strings.json | 3 + .../fixtures/nodes/silabs_laundrywasher.json | 4 +- .../matter/snapshots/test_sensor.ambr | 58 +++++++++++++++++++ tests/components/matter/test_sensor.py | 22 ++++++- 6 files changed, 131 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/matter/icons.json b/homeassistant/components/matter/icons.json index bd8665eb18b..4f3e532d877 100644 --- a/homeassistant/components/matter/icons.json +++ b/homeassistant/components/matter/icons.json @@ -45,6 +45,9 @@ "contamination_state": { "default": "mdi:air-filter" }, + "current_phase": { + "default": "mdi:state-machine" + }, "air_quality": { "default": "mdi:air-filter" }, diff --git a/homeassistant/components/matter/sensor.py b/homeassistant/components/matter/sensor.py index 39e11a683f5..40b25d14c46 100644 --- a/homeassistant/components/matter/sensor.py +++ b/homeassistant/components/matter/sensor.py @@ -7,6 +7,7 @@ from datetime import datetime from typing import TYPE_CHECKING, cast from chip.clusters import Objects as clusters +from chip.clusters.ClusterObjects import ClusterAttributeDescriptor from chip.clusters.Types import Nullable, NullValue from matter_server.client.models import device_types from matter_server.common.custom_clusters import ( @@ -89,6 +90,14 @@ class MatterSensorEntityDescription(SensorEntityDescription, MatterEntityDescrip """Describe Matter sensor entities.""" +@dataclass(frozen=True, kw_only=True) +class MatterListSensorEntityDescription(MatterSensorEntityDescription): + """Describe Matter sensor entities from MatterListSensor.""" + + # list attribute: the attribute descriptor to get the list of values (= list of strings) + list_attribute: type[ClusterAttributeDescriptor] + + class MatterSensor(MatterEntity, SensorEntity): """Representation of a Matter sensor.""" @@ -171,6 +180,28 @@ class MatterOperationalStateSensor(MatterSensor): ) +class MatterListSensor(MatterSensor): + """Representation of a sensor entity from Matter list from Cluster attribute(s).""" + + entity_description: MatterListSensorEntityDescription + _attr_device_class = SensorDeviceClass.ENUM + + @callback + def _update_from_device(self) -> None: + """Update from device.""" + self._attr_options = list_values = cast( + list[str], + self.get_matter_attribute_value(self.entity_description.list_attribute), + ) + current_value: int = self.get_matter_attribute_value( + self._entity_info.primary_attribute + ) + try: + self._attr_native_value = list_values[current_value] + except IndexError: + self._attr_native_value = None + + # Discovery schema(s) to map Matter Attributes to HA entities DISCOVERY_SCHEMAS = [ MatterDiscoverySchema( @@ -762,6 +793,19 @@ DISCOVERY_SCHEMAS = [ clusters.OperationalState.Attributes.OperationalStateList, ), ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterListSensorEntityDescription( + key="OperationalStateCurrentPhase", + translation_key="current_phase", + list_attribute=clusters.OperationalState.Attributes.PhaseList, + ), + entity_class=MatterListSensor, + required_attributes=( + clusters.OperationalState.Attributes.CurrentPhase, + clusters.OperationalState.Attributes.PhaseList, + ), + ), MatterDiscoverySchema( platform=Platform.SENSOR, entity_description=MatterSensorEntityDescription( diff --git a/homeassistant/components/matter/strings.json b/homeassistant/components/matter/strings.json index 4054adba530..8bac67a4ca7 100644 --- a/homeassistant/components/matter/strings.json +++ b/homeassistant/components/matter/strings.json @@ -257,6 +257,9 @@ }, "battery_replacement_description": { "name": "Battery type" + }, + "current_phase": { + "name": "Current phase" } }, "switch": { diff --git a/tests/components/matter/fixtures/nodes/silabs_laundrywasher.json b/tests/components/matter/fixtures/nodes/silabs_laundrywasher.json index 4d26dfb03aa..a91584d7212 100644 --- a/tests/components/matter/fixtures/nodes/silabs_laundrywasher.json +++ b/tests/components/matter/fixtures/nodes/silabs_laundrywasher.json @@ -673,8 +673,8 @@ "1/86/65528": [], "1/86/65529": [0], "1/86/65531": [4, 5, 65528, 65529, 65531, 65532, 65533], - "1/96/0": null, - "1/96/1": null, + "1/96/0": ["pre-soak", "rinse", "spin"], + "1/96/1": 0, "1/96/3": [ { "0": 0 diff --git a/tests/components/matter/snapshots/test_sensor.ambr b/tests/components/matter/snapshots/test_sensor.ambr index 0215abf47c6..d9bc0bdf1fc 100644 --- a/tests/components/matter/snapshots/test_sensor.ambr +++ b/tests/components/matter/snapshots/test_sensor.ambr @@ -2870,6 +2870,64 @@ 'state': '0.0', }) # --- +# name: test_sensors[silabs_laundrywasher][sensor.laundrywasher_current_phase-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'pre-soak', + 'rinse', + 'spin', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.laundrywasher_current_phase', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current phase', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'current_phase', + 'unique_id': '00000000000004D2-000000000000001D-MatterNodeDevice-1-OperationalStateCurrentPhase-96-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[silabs_laundrywasher][sensor.laundrywasher_current_phase-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'LaundryWasher Current phase', + 'options': list([ + 'pre-soak', + 'rinse', + 'spin', + ]), + }), + 'context': , + 'entity_id': 'sensor.laundrywasher_current_phase', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'pre-soak', + }) +# --- # name: test_sensors[silabs_laundrywasher][sensor.laundrywasher_energy-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/matter/test_sensor.py b/tests/components/matter/test_sensor.py index 8a5fbf48a49..251aab73e3b 100644 --- a/tests/components/matter/test_sensor.py +++ b/tests/components/matter/test_sensor.py @@ -332,7 +332,7 @@ async def test_operational_state_sensor( matter_client: MagicMock, matter_node: MatterNode, ) -> None: - """Test dishwasher sensor.""" + """Test Operational State sensor, using a dishwasher fixture.""" # OperationalState Cluster / OperationalState attribute (1/96/4) state = hass.states.get("sensor.dishwasher_operational_state") assert state @@ -379,3 +379,23 @@ async def test_draft_electrical_measurement_sensor( state = hass.states.get("sensor.yndx_00540_power") assert state assert state.state == "unknown" + + +@pytest.mark.parametrize("node_fixture", ["silabs_laundrywasher"]) +async def test_list_sensor( + hass: HomeAssistant, + matter_client: MagicMock, + matter_node: MatterNode, +) -> None: + """Test Matter List sensor.""" + # OperationalState Cluster / CurrentPhase attribute (1/96/1) + state = hass.states.get("sensor.laundrywasher_current_phase") + assert state + assert state.state == "pre-soak" + + set_node_attribute(matter_node, 1, 96, 1, 1) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get("sensor.laundrywasher_current_phase") + assert state + assert state.state == "rinse" From b16c3a55a57e7a9da7f996a512b7417f9ceca176 Mon Sep 17 00:00:00 2001 From: RJPoelstra <36924801+RJPoelstra@users.noreply.github.com> Date: Tue, 28 Jan 2025 16:45:19 +0100 Subject: [PATCH 0140/3148] Add authentication support to MotionMount integration (#126487) --- .../components/motionmount/__init__.py | 20 +- .../components/motionmount/config_flow.py | 154 ++++- .../components/motionmount/entity.py | 28 +- .../components/motionmount/select.py | 32 + .../components/motionmount/strings.json | 31 +- tests/components/motionmount/__init__.py | 4 +- .../motionmount/test_config_flow.py | 566 ++++++++++++++---- 7 files changed, 674 insertions(+), 161 deletions(-) diff --git a/homeassistant/components/motionmount/__init__.py b/homeassistant/components/motionmount/__init__.py index 28963d83d89..9b27ce9bc6c 100644 --- a/homeassistant/components/motionmount/__init__.py +++ b/homeassistant/components/motionmount/__init__.py @@ -7,9 +7,9 @@ import socket import motionmount from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, CONF_PORT, Platform +from homeassistant.const import CONF_HOST, CONF_PIN, CONF_PORT, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.device_registry import format_mac from .const import DOMAIN, EMPTY_MAC @@ -48,6 +48,22 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: f"Unexpected device found at {host}; expected {entry.unique_id}, found {found_mac}" ) + # Check we're properly authenticated or be able to become so + if not mm.is_authenticated: + if CONF_PIN not in entry.data: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="no_pin_provided", + ) + + pin = entry.data[CONF_PIN] + await mm.authenticate(pin) + if not mm.is_authenticated: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="incorrect_pin", + ) + # Store an API object for your platforms to access hass.data.setdefault(DOMAIN, {})[entry.entry_id] = mm diff --git a/homeassistant/components/motionmount/config_flow.py b/homeassistant/components/motionmount/config_flow.py index 50a1e334f1d..283f1f01d6e 100644 --- a/homeassistant/components/motionmount/config_flow.py +++ b/homeassistant/components/motionmount/config_flow.py @@ -1,5 +1,7 @@ """Config flow for Vogel's MotionMount.""" +import asyncio +from collections.abc import Mapping import logging import socket from typing import Any @@ -9,10 +11,11 @@ import voluptuous as vol from homeassistant.config_entries import ( DEFAULT_DISCOVERY_UNIQUE_ID, + SOURCE_REAUTH, ConfigFlow, ConfigFlowResult, ) -from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT, CONF_UUID +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PIN, CONF_PORT, CONF_UUID from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo @@ -34,7 +37,9 @@ class MotionMountFlowHandler(ConfigFlow, domain=DOMAIN): def __init__(self) -> None: """Set up the instance.""" - self.discovery_info: dict[str, Any] = {} + self.connection_data: dict[str, Any] = {} + self.backoff_task: asyncio.Task | None = None + self.backoff_time: int = 0 async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -43,23 +48,16 @@ class MotionMountFlowHandler(ConfigFlow, domain=DOMAIN): if user_input is None: return self._show_setup_form() + self.connection_data.update(user_input) info = {} try: - info = await self._validate_input(user_input) + info = await self._validate_input_connect(self.connection_data) except (ConnectionError, socket.gaierror): return self.async_abort(reason="cannot_connect") except TimeoutError: return self.async_abort(reason="time_out") except motionmount.NotConnectedError: return self.async_abort(reason="not_connected") - except motionmount.MotionMountResponseError: - # This is most likely due to missing support for the mac address property - # Abort if the handler has config entries already - if self._async_current_entries(): - return self.async_abort(reason="already_configured") - - # Otherwise we try to continue with the generic uid - info[CONF_UUID] = DEFAULT_DISCOVERY_UNIQUE_ID # If the device mac is valid we use it, otherwise we use the default id if info.get(CONF_UUID, EMPTY_MAC) != EMPTY_MAC: @@ -67,17 +65,22 @@ class MotionMountFlowHandler(ConfigFlow, domain=DOMAIN): else: unique_id = DEFAULT_DISCOVERY_UNIQUE_ID - name = info.get(CONF_NAME, user_input[CONF_HOST]) + name = info.get(CONF_NAME, self.connection_data[CONF_HOST]) + self.connection_data[CONF_NAME] = name await self.async_set_unique_id(unique_id) self._abort_if_unique_id_configured( updates={ - CONF_HOST: user_input[CONF_HOST], - CONF_PORT: user_input[CONF_PORT], + CONF_HOST: self.connection_data[CONF_HOST], + CONF_PORT: self.connection_data[CONF_PORT], } ) - return self.async_create_entry(title=name, data=user_input) + if not info[CONF_PIN]: + # We need a pin to authenticate + return await self.async_step_auth() + # No pin is needed + return self._create_or_update_entry() async def async_step_zeroconf( self, discovery_info: ZeroconfServiceInfo @@ -91,7 +94,7 @@ class MotionMountFlowHandler(ConfigFlow, domain=DOMAIN): name = discovery_info.name.removesuffix(f".{zctype}") unique_id = discovery_info.properties.get("mac") - self.discovery_info.update( + self.connection_data.update( { CONF_HOST: host, CONF_PORT: port, @@ -114,16 +117,13 @@ class MotionMountFlowHandler(ConfigFlow, domain=DOMAIN): self.context.update({"title_placeholders": {"name": name}}) try: - info = await self._validate_input(self.discovery_info) + info = await self._validate_input_connect(self.connection_data) except (ConnectionError, socket.gaierror): return self.async_abort(reason="cannot_connect") except TimeoutError: return self.async_abort(reason="time_out") except motionmount.NotConnectedError: return self.async_abort(reason="not_connected") - except motionmount.MotionMountResponseError: - info = {} - # We continue as we want to be able to connect with older FW that does not support MAC address # If the device supplied as with a valid MAC we use that if info.get(CONF_UUID, EMPTY_MAC) != EMPTY_MAC: @@ -137,6 +137,10 @@ class MotionMountFlowHandler(ConfigFlow, domain=DOMAIN): else: await self._async_handle_discovery_without_unique_id() + if not info[CONF_PIN]: + # We need a pin to authenticate + return await self.async_step_auth() + # No pin is needed return await self.async_step_zeroconf_confirm() async def async_step_zeroconf_confirm( @@ -146,16 +150,82 @@ class MotionMountFlowHandler(ConfigFlow, domain=DOMAIN): if user_input is None: return self.async_show_form( step_id="zeroconf_confirm", - description_placeholders={CONF_NAME: self.discovery_info[CONF_NAME]}, + description_placeholders={CONF_NAME: self.connection_data[CONF_NAME]}, errors={}, ) - return self.async_create_entry( - title=self.discovery_info[CONF_NAME], - data=self.discovery_info, + return self._create_or_update_entry() + + async def async_step_reauth( + self, user_input: Mapping[str, Any] + ) -> ConfigFlowResult: + """Handle re-authentication.""" + reauth_entry = self._get_reauth_entry() + self.connection_data.update(reauth_entry.data) + return await self.async_step_auth() + + async def async_step_auth( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle authentication form.""" + errors = {} + + if user_input is not None: + self.connection_data[CONF_PIN] = user_input[CONF_PIN] + + # Validate pin code + valid_or_wait_time = await self._validate_input_pin(self.connection_data) + if valid_or_wait_time is True: + return self._create_or_update_entry() + + if type(valid_or_wait_time) is int: + self.backoff_time = valid_or_wait_time + self.backoff_task = self.hass.async_create_task( + self._backoff(valid_or_wait_time) + ) + return await self.async_step_backoff() + + errors[CONF_PIN] = CONF_PIN + + return self.async_show_form( + step_id="auth", + data_schema=vol.Schema( + { + vol.Required(CONF_PIN): vol.All(int, vol.Range(min=1, max=9999)), + } + ), + errors=errors, ) - async def _validate_input(self, data: dict) -> dict[str, Any]: + async def async_step_backoff( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle backoff progress.""" + if not self.backoff_task or self.backoff_task.done(): + self.backoff_task = None + return self.async_show_progress_done(next_step_id="auth") + + return self.async_show_progress( + step_id="backoff", + description_placeholders={ + "timeout": str(self.backoff_time), + }, + progress_action="progress_action", + progress_task=self.backoff_task, + ) + + def _create_or_update_entry(self) -> ConfigFlowResult: + if self.source == SOURCE_REAUTH: + reauth_entry = self._get_reauth_entry() + return self.async_update_reload_and_abort( + reauth_entry, data_updates=self.connection_data + ) + return self.async_create_entry( + title=self.connection_data[CONF_NAME], + data=self.connection_data, + ) + + async def _validate_input_connect(self, data: dict) -> dict[str, Any]: """Validate the user input allows us to connect.""" mm = motionmount.MotionMount(data[CONF_HOST], data[CONF_PORT]) @@ -164,7 +234,33 @@ class MotionMountFlowHandler(ConfigFlow, domain=DOMAIN): finally: await mm.disconnect() - return {CONF_UUID: format_mac(mm.mac.hex()), CONF_NAME: mm.name} + return { + CONF_UUID: format_mac(mm.mac.hex()), + CONF_NAME: mm.name, + CONF_PIN: mm.is_authenticated, + } + + async def _validate_input_pin(self, data: dict) -> bool | int: + """Validate the user input allows us to authenticate.""" + + mm = motionmount.MotionMount(data[CONF_HOST], data[CONF_PORT]) + try: + await mm.connect() + + can_authenticate = mm.can_authenticate + if can_authenticate is True: + await mm.authenticate(data[CONF_PIN]) + else: + # The backoff is running, return the remaining time + return can_authenticate + finally: + await mm.disconnect() + + can_authenticate = mm.can_authenticate + if can_authenticate is True: + return mm.is_authenticated + + return can_authenticate def _show_setup_form( self, errors: dict[str, str] | None = None @@ -180,3 +276,9 @@ class MotionMountFlowHandler(ConfigFlow, domain=DOMAIN): ), errors=errors or {}, ) + + async def _backoff(self, time: int) -> None: + while time > 0: + time -= 1 + self.backoff_time = time + await asyncio.sleep(1) diff --git a/homeassistant/components/motionmount/entity.py b/homeassistant/components/motionmount/entity.py index ba81c9d10bd..57a5f638d54 100644 --- a/homeassistant/components/motionmount/entity.py +++ b/homeassistant/components/motionmount/entity.py @@ -1,13 +1,12 @@ """Support for MotionMount sensors.""" import logging -import socket from typing import TYPE_CHECKING import motionmount from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_CONNECTIONS, ATTR_IDENTIFIERS +from homeassistant.const import ATTR_CONNECTIONS, ATTR_IDENTIFIERS, CONF_PIN from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo, format_mac from homeassistant.helpers.entity import Entity @@ -26,6 +25,11 @@ class MotionMountEntity(Entity): def __init__(self, mm: motionmount.MotionMount, config_entry: ConfigEntry) -> None: """Initialize general MotionMount entity.""" self.mm = mm + self.config_entry = config_entry + + # We store the pin, as we might need it during reconnect + self.pin = config_entry.data[CONF_PIN] + mac = format_mac(mm.mac.hex()) # Create a base unique id @@ -74,23 +78,3 @@ class MotionMountEntity(Entity): self.mm.remove_listener(self.async_write_ha_state) self.mm.remove_listener(self.update_name) await super().async_will_remove_from_hass() - - async def _ensure_connected(self) -> bool: - """Make sure there is a connection with the MotionMount. - - Returns false if the connection failed to be ensured. - """ - - if self.mm.is_connected: - return True - try: - await self.mm.connect() - except (ConnectionError, TimeoutError, socket.gaierror): - # We're not interested in exceptions here. In case of a failed connection - # the try/except from the caller will report it. - # The purpose of `_ensure_connected()` is only to make sure we try to - # reconnect, where failures should not be logged each time - return False - else: - _LOGGER.warning("Successfully reconnected to MotionMount") - return True diff --git a/homeassistant/components/motionmount/select.py b/homeassistant/components/motionmount/select.py index 9b43d901a21..23fcf576af0 100644 --- a/homeassistant/components/motionmount/select.py +++ b/homeassistant/components/motionmount/select.py @@ -51,6 +51,38 @@ class MotionMountPresets(MotionMountEntity, SelectEntity): self._attr_options = options + async def _ensure_connected(self) -> bool: + """Make sure there is a connection with the MotionMount. + + Returns false if the connection failed to be ensured. + """ + if self.mm.is_connected: + return True + try: + await self.mm.connect() + except (ConnectionError, TimeoutError, socket.gaierror): + # We're not interested in exceptions here. In case of a failed connection + # the try/except from the caller will report it. + # The purpose of `_ensure_connected()` is only to make sure we try to + # reconnect, where failures should not be logged each time + return False + + # Check we're properly authenticated or be able to become so + if not self.mm.is_authenticated: + if self.pin is None: + await self.mm.disconnect() + self.config_entry.async_start_reauth(self.hass) + return False + await self.mm.authenticate(self.pin) + if not self.mm.is_authenticated: + self.pin = None + await self.mm.disconnect() + self.config_entry.async_start_reauth(self.hass) + return False + + _LOGGER.debug("Successfully reconnected to MotionMount") + return True + async def async_update(self) -> None: """Get latest state from MotionMount.""" if not await self._ensure_connected(): diff --git a/homeassistant/components/motionmount/strings.json b/homeassistant/components/motionmount/strings.json index bd28156607c..098a7a592f3 100644 --- a/homeassistant/components/motionmount/strings.json +++ b/homeassistant/components/motionmount/strings.json @@ -1,4 +1,7 @@ { + "common": { + "incorrect_pin": "Pin is not correct" + }, "config": { "flow_title": "{name}", "step": { @@ -13,15 +16,33 @@ "zeroconf_confirm": { "description": "Do you want to set up {name}?", "title": "Discovered MotionMount" + }, + "auth": { + "title": "Authenticate to your MotionMount", + "description": "Your MotionMount requires a pin to operate.", + "data": { + "pin": "[%key:common::config_flow::data::pin%]" + } + }, + "backoff": { + "title": "Authenticate to your MotionMount", + "description": "Too many incorrect pin attempts." } }, + "error": { + "pin": "[%key:component::motionmount::common::incorrect_pin%]" + }, + "progress": { + "progress_action": "Too many incorrect pin attempts. Please wait {timeout} s..." + }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "time_out": "Failed to connect due to a time out.", + "time_out": "[%key:common::config_flow::error::timeout_connect%]", "not_connected": "Failed to connect.", - "invalid_response": "Failed to connect due to an invalid response from the MotionMount." + "invalid_response": "Failed to connect due to an invalid response from the MotionMount.", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } }, "entity": { @@ -60,6 +81,12 @@ "exceptions": { "failed_communication": { "message": "Failed to communicate with MotionMount" + }, + "no_pin_provided": { + "message": "No pin provided" + }, + "incorrect_pin": { + "message": "[%key:component::motionmount::common::incorrect_pin%]" } } } diff --git a/tests/components/motionmount/__init__.py b/tests/components/motionmount/__init__.py index ed7dae26663..3b97c8aa7fe 100644 --- a/tests/components/motionmount/__init__.py +++ b/tests/components/motionmount/__init__.py @@ -2,7 +2,7 @@ from ipaddress import ip_address -from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.const import CONF_HOST, CONF_PIN, CONF_PORT from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo HOST = "192.168.1.31" @@ -21,6 +21,8 @@ MOCK_USER_INPUT = { CONF_PORT: PORT, } +MOCK_PIN_INPUT = {CONF_PIN: 1234} + MOCK_ZEROCONF_TVM_SERVICE_INFO_V1 = ZeroconfServiceInfo( type=TVM_ZEROCONF_SERVICE_TYPE, name=f"{ZEROCONF_NAME}.{TVM_ZEROCONF_SERVICE_TYPE}", diff --git a/tests/components/motionmount/test_config_flow.py b/tests/components/motionmount/test_config_flow.py index 4de23de63c9..1fa2715595d 100644 --- a/tests/components/motionmount/test_config_flow.py +++ b/tests/components/motionmount/test_config_flow.py @@ -1,20 +1,23 @@ """Tests for the Vogel's MotionMount config flow.""" import dataclasses +from datetime import timedelta import socket from unittest.mock import MagicMock, PropertyMock +from freezegun.api import FrozenDateTimeFactory import motionmount import pytest from homeassistant.components.motionmount.const import DOMAIN -from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF -from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT +from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER, SOURCE_ZEROCONF +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PIN, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from . import ( HOST, + MOCK_PIN_INPUT, MOCK_USER_INPUT, MOCK_ZEROCONF_TVM_SERVICE_INFO_V1, MOCK_ZEROCONF_TVM_SERVICE_INFO_V2, @@ -24,23 +27,12 @@ from . import ( ZEROCONF_NAME, ) -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed MAC = bytes.fromhex("c4dd57f8a55f") pytestmark = pytest.mark.usefixtures("mock_setup_entry") -async def test_show_user_form(hass: HomeAssistant) -> None: - """Test that the user set up form is served.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - ) - - assert result["step_id"] == "user" - assert result["type"] is FlowResultType.FORM - - async def test_user_connection_error( hass: HomeAssistant, mock_motionmount_config_flow: MagicMock, @@ -117,33 +109,6 @@ async def test_user_not_connected_error( assert result["reason"] == "not_connected" -async def test_user_response_error_single_device_old_ce_old_new_pro( - hass: HomeAssistant, - mock_motionmount_config_flow: MagicMock, -) -> None: - """Test that the flow creates an entry when there is a response error.""" - mock_motionmount_config_flow.connect.side_effect = ( - motionmount.MotionMountResponseError(motionmount.MotionMountResponse.NotFound) - ) - - user_input = MOCK_USER_INPUT.copy() - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data=user_input, - ) - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == HOST - - assert result["data"] - assert result["data"][CONF_HOST] == HOST - assert result["data"][CONF_PORT] == PORT - - assert result["result"] - - async def test_user_response_error_single_device_new_ce_old_pro( hass: HomeAssistant, mock_motionmount_config_flow: MagicMock, @@ -199,30 +164,6 @@ async def test_user_response_error_single_device_new_ce_new_pro( assert result["result"].unique_id == ZEROCONF_MAC -async def test_user_response_error_multi_device_old_ce_old_new_pro( - hass: HomeAssistant, - mock_config_entry: MockConfigEntry, - mock_motionmount_config_flow: MagicMock, -) -> None: - """Test that the flow is aborted when there are multiple devices.""" - mock_config_entry.add_to_hass(hass) - - mock_motionmount_config_flow.connect.side_effect = ( - motionmount.MotionMountResponseError(motionmount.MotionMountResponse.NotFound) - ) - - user_input = MOCK_USER_INPUT.copy() - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data=user_input, - ) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" - - async def test_user_response_error_multi_device_new_ce_new_pro( hass: HomeAssistant, mock_config_entry: MockConfigEntry, @@ -246,6 +187,53 @@ async def test_user_response_error_multi_device_new_ce_new_pro( assert result["reason"] == "already_configured" +async def test_user_response_authentication_needed( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_motionmount_config_flow: MagicMock, +) -> None: + """Test that authentication is requested when needed.""" + type(mock_motionmount_config_flow).name = PropertyMock(return_value=ZEROCONF_NAME) + type(mock_motionmount_config_flow).mac = PropertyMock(return_value=MAC) + type(mock_motionmount_config_flow).is_authenticated = PropertyMock( + return_value=False + ) + + user_input = MOCK_USER_INPUT.copy() + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data=user_input, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "auth" + + # Now simulate the user entered the correct pin to finalize the test + type(mock_motionmount_config_flow).is_authenticated = PropertyMock( + return_value=True + ) + type(mock_motionmount_config_flow).can_authenticate = PropertyMock( + return_value=True + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_PIN_INPUT.copy(), + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == ZEROCONF_NAME + + assert result["data"] + assert result["data"][CONF_HOST] == HOST + assert result["data"][CONF_PORT] == PORT + + assert result["result"] + assert result["result"].unique_id == ZEROCONF_MAC + + async def test_zeroconf_connection_error( hass: HomeAssistant, mock_motionmount_config_flow: MagicMock, @@ -322,48 +310,6 @@ async def test_zeroconf_not_connected_error( assert result["reason"] == "not_connected" -async def test_show_zeroconf_form_old_ce_old_pro( - hass: HomeAssistant, - mock_motionmount_config_flow: MagicMock, -) -> None: - """Test that the zeroconf confirmation form is served.""" - mock_motionmount_config_flow.connect.side_effect = ( - motionmount.MotionMountResponseError(motionmount.MotionMountResponse.NotFound) - ) - - discovery_info = dataclasses.replace(MOCK_ZEROCONF_TVM_SERVICE_INFO_V1) - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_ZEROCONF}, - data=discovery_info, - ) - - assert result["step_id"] == "zeroconf_confirm" - assert result["type"] is FlowResultType.FORM - assert result["description_placeholders"] == {CONF_NAME: "My MotionMount"} - - -async def test_show_zeroconf_form_old_ce_new_pro( - hass: HomeAssistant, - mock_motionmount_config_flow: MagicMock, -) -> None: - """Test that the zeroconf confirmation form is served.""" - mock_motionmount_config_flow.connect.side_effect = ( - motionmount.MotionMountResponseError(motionmount.MotionMountResponse.NotFound) - ) - - discovery_info = dataclasses.replace(MOCK_ZEROCONF_TVM_SERVICE_INFO_V2) - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_ZEROCONF}, - data=discovery_info, - ) - - assert result["step_id"] == "zeroconf_confirm" - assert result["type"] is FlowResultType.FORM - assert result["description_placeholders"] == {CONF_NAME: "My MotionMount"} - - async def test_show_zeroconf_form_new_ce_old_pro( hass: HomeAssistant, mock_motionmount_config_flow: MagicMock, @@ -384,6 +330,21 @@ async def test_show_zeroconf_form_new_ce_old_pro( assert result["type"] is FlowResultType.FORM assert result["description_placeholders"] == {CONF_NAME: "My MotionMount"} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == ZEROCONF_NAME + + assert result["data"] + assert result["data"][CONF_HOST] == ZEROCONF_HOSTNAME + assert result["data"][CONF_PORT] == PORT + assert result["data"][CONF_NAME] == ZEROCONF_NAME + + assert result["result"] + assert result["result"].unique_id is None + async def test_show_zeroconf_form_new_ce_new_pro( hass: HomeAssistant, @@ -403,6 +364,21 @@ async def test_show_zeroconf_form_new_ce_new_pro( assert result["type"] is FlowResultType.FORM assert result["description_placeholders"] == {CONF_NAME: "My MotionMount"} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == ZEROCONF_NAME + + assert result["data"] + assert result["data"][CONF_HOST] == ZEROCONF_HOSTNAME + assert result["data"][CONF_PORT] == PORT + assert result["data"][CONF_NAME] == ZEROCONF_NAME + + assert result["result"] + assert result["result"].unique_id == ZEROCONF_MAC + async def test_zeroconf_device_exists_abort( hass: HomeAssistant, @@ -423,6 +399,346 @@ async def test_zeroconf_device_exists_abort( assert result["reason"] == "already_configured" +async def test_zeroconf_authentication_needed( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_motionmount_config_flow: MagicMock, +) -> None: + """Test that authentication is requested when needed.""" + type(mock_motionmount_config_flow).mac = PropertyMock(return_value=MAC) + type(mock_motionmount_config_flow).is_authenticated = PropertyMock( + return_value=False + ) + + discovery_info = dataclasses.replace(MOCK_ZEROCONF_TVM_SERVICE_INFO_V2) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=discovery_info, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "auth" + + # Now simulate the user entered the correct pin to finalize the test + type(mock_motionmount_config_flow).is_authenticated = PropertyMock( + return_value=True + ) + type(mock_motionmount_config_flow).can_authenticate = PropertyMock( + return_value=True + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_PIN_INPUT.copy(), + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == ZEROCONF_NAME + + assert result["data"] + assert result["data"][CONF_HOST] == ZEROCONF_HOSTNAME + assert result["data"][CONF_PORT] == PORT + assert result["data"][CONF_NAME] == ZEROCONF_NAME + + assert result["result"] + assert result["result"].unique_id == ZEROCONF_MAC + + +async def test_authentication_incorrect_then_correct_pin( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_motionmount_config_flow: MagicMock, +) -> None: + """Test that authentication is requested when needed.""" + type(mock_motionmount_config_flow).name = PropertyMock(return_value=ZEROCONF_NAME) + type(mock_motionmount_config_flow).mac = PropertyMock(return_value=MAC) + type(mock_motionmount_config_flow).is_authenticated = PropertyMock( + return_value=False + ) + type(mock_motionmount_config_flow).can_authenticate = PropertyMock( + return_value=True + ) + + user_input = MOCK_USER_INPUT.copy() + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data=user_input, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "auth" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_PIN_INPUT.copy(), + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "auth" + + assert result["errors"] + assert result["errors"][CONF_PIN] == CONF_PIN + + # Now simulate the user entered the correct pin to finalize the test + type(mock_motionmount_config_flow).is_authenticated = PropertyMock( + return_value=True + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_PIN_INPUT.copy(), + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == ZEROCONF_NAME + + assert result["data"] + assert result["data"][CONF_HOST] == HOST + assert result["data"][CONF_PORT] == PORT + + assert result["result"] + assert result["result"].unique_id == ZEROCONF_MAC + + +async def test_authentication_first_incorrect_pin_to_backoff( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_motionmount_config_flow: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test that authentication is requested when needed.""" + type(mock_motionmount_config_flow).name = PropertyMock(return_value=ZEROCONF_NAME) + type(mock_motionmount_config_flow).mac = PropertyMock(return_value=MAC) + type(mock_motionmount_config_flow).is_authenticated = PropertyMock( + return_value=False + ) + type(mock_motionmount_config_flow).can_authenticate = PropertyMock( + side_effect=[True, 1] + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data=MOCK_USER_INPUT.copy(), + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "auth" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_PIN_INPUT.copy(), + ) + + assert mock_motionmount_config_flow.authenticate.called + assert result["type"] is FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "backoff" + + freezer.tick(timedelta(seconds=2)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # Now simulate the user entered the correct pin to finalize the test + type(mock_motionmount_config_flow).is_authenticated = PropertyMock( + return_value=True + ) + type(mock_motionmount_config_flow).can_authenticate = PropertyMock( + return_value=True + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_PIN_INPUT.copy(), + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == ZEROCONF_NAME + + assert result["data"] + assert result["data"][CONF_HOST] == HOST + assert result["data"][CONF_PORT] == PORT + + assert result["result"] + assert result["result"].unique_id == ZEROCONF_MAC + + +async def test_authentication_multiple_incorrect_pins( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_motionmount_config_flow: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test that authentication is requested when needed.""" + type(mock_motionmount_config_flow).name = PropertyMock(return_value=ZEROCONF_NAME) + type(mock_motionmount_config_flow).mac = PropertyMock(return_value=MAC) + type(mock_motionmount_config_flow).is_authenticated = PropertyMock( + return_value=False + ) + type(mock_motionmount_config_flow).can_authenticate = PropertyMock(return_value=1) + + user_input = MOCK_USER_INPUT.copy() + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data=user_input, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "auth" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_PIN_INPUT.copy(), + ) + + assert result["type"] is FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "backoff" + + freezer.tick(timedelta(seconds=2)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # Now simulate the user entered the correct pin to finalize the test + type(mock_motionmount_config_flow).is_authenticated = PropertyMock( + return_value=True + ) + type(mock_motionmount_config_flow).can_authenticate = PropertyMock( + return_value=True + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_PIN_INPUT.copy(), + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == ZEROCONF_NAME + + assert result["data"] + assert result["data"][CONF_HOST] == HOST + assert result["data"][CONF_PORT] == PORT + + assert result["result"] + assert result["result"].unique_id == ZEROCONF_MAC + + +async def test_authentication_show_backoff_when_still_running( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_motionmount_config_flow: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test that authentication is requested when needed.""" + type(mock_motionmount_config_flow).name = PropertyMock(return_value=ZEROCONF_NAME) + type(mock_motionmount_config_flow).mac = PropertyMock(return_value=MAC) + type(mock_motionmount_config_flow).is_authenticated = PropertyMock( + return_value=False + ) + type(mock_motionmount_config_flow).can_authenticate = PropertyMock(return_value=1) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data=MOCK_USER_INPUT.copy(), + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "auth" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_PIN_INPUT.copy(), + ) + + assert result["type"] is FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "backoff" + + # This situation happens when the user cancels the progress dialog and tries to + # configure the MotionMount again + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=None, + ) + + assert result["type"] is FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "backoff" + + freezer.tick(timedelta(seconds=2)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # Now simulate the user entered the correct pin to finalize the test + type(mock_motionmount_config_flow).is_authenticated = PropertyMock( + return_value=True + ) + type(mock_motionmount_config_flow).can_authenticate = PropertyMock( + return_value=True + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_PIN_INPUT.copy(), + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == ZEROCONF_NAME + + assert result["data"] + assert result["data"][CONF_HOST] == HOST + assert result["data"][CONF_PORT] == PORT + + assert result["result"] + assert result["result"].unique_id == ZEROCONF_MAC + + +async def test_authentication_correct_pin( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_motionmount_config_flow: MagicMock, +) -> None: + """Test that authentication is requested when needed.""" + type(mock_motionmount_config_flow).name = PropertyMock(return_value=ZEROCONF_NAME) + type(mock_motionmount_config_flow).mac = PropertyMock(return_value=MAC) + type(mock_motionmount_config_flow).is_authenticated = PropertyMock( + return_value=False + ) + type(mock_motionmount_config_flow).can_authenticate = PropertyMock( + return_value=True + ) + + user_input = MOCK_USER_INPUT.copy() + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data=user_input, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "auth" + + type(mock_motionmount_config_flow).is_authenticated = PropertyMock( + return_value=True + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_PIN_INPUT.copy(), + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == ZEROCONF_NAME + + assert result["data"] + assert result["data"][CONF_HOST] == HOST + assert result["data"][CONF_PORT] == PORT + + assert result["result"] + assert result["result"].unique_id == ZEROCONF_MAC + + async def test_full_user_flow_implementation( hass: HomeAssistant, mock_motionmount_config_flow: MagicMock, @@ -459,7 +775,7 @@ async def test_full_zeroconf_flow_implementation( hass: HomeAssistant, mock_motionmount_config_flow: MagicMock, ) -> None: - """Test the full manual user flow from start to finish.""" + """Test the full zeroconf flow from start to finish.""" type(mock_motionmount_config_flow).name = PropertyMock(return_value=ZEROCONF_NAME) type(mock_motionmount_config_flow).mac = PropertyMock(return_value=MAC) @@ -487,3 +803,37 @@ async def test_full_zeroconf_flow_implementation( assert result["result"] assert result["result"].unique_id == ZEROCONF_MAC + + +async def test_full_reauth_flow_implementation( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_motionmount_config_flow: MagicMock, +) -> None: + """Test reauthentication.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_REAUTH, + "entry_id": mock_config_entry.entry_id, + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "auth" + + type(mock_motionmount_config_flow).can_authenticate = PropertyMock( + return_value=True + ) + type(mock_motionmount_config_flow).is_authenticated = PropertyMock( + return_value=True + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_PIN_INPUT.copy(), + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" From 52dc124cfeed917cc2a4b2f0f37206429df56b20 Mon Sep 17 00:00:00 2001 From: Roman Sivriver Date: Tue, 28 Jan 2025 10:46:08 -0500 Subject: [PATCH 0141/3148] Fix Telegram webhook registration if deregistration previously failed (#133398) --- homeassistant/components/telegram_bot/webhooks.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/telegram_bot/webhooks.py b/homeassistant/components/telegram_bot/webhooks.py index 3eb3c71a0bb..9bd360f5e41 100644 --- a/homeassistant/components/telegram_bot/webhooks.py +++ b/homeassistant/components/telegram_bot/webhooks.py @@ -109,13 +109,12 @@ class PushBot(BaseTelegramBotEntity): else: _LOGGER.debug("telegram webhook status: %s", current_status) - if current_status and current_status["url"] != self.webhook_url: - result = await self._try_to_set_webhook() - if result: - _LOGGER.debug("Set new telegram webhook %s", self.webhook_url) - else: - _LOGGER.error("Set telegram webhook failed %s", self.webhook_url) - return False + result = await self._try_to_set_webhook() + if result: + _LOGGER.debug("Set new telegram webhook %s", self.webhook_url) + else: + _LOGGER.error("Set telegram webhook failed %s", self.webhook_url) + return False return True From ae157e859229b4837c4917a9ad1d7611747fd1d3 Mon Sep 17 00:00:00 2001 From: Arie Catsman <120491684+catsmanac@users.noreply.github.com> Date: Tue, 28 Jan 2025 16:56:14 +0100 Subject: [PATCH 0142/3148] Parameterize enphase_envoy number tests. (#136631) --- tests/components/enphase_envoy/test_number.py | 98 +++++++++---------- 1 file changed, 44 insertions(+), 54 deletions(-) diff --git a/tests/components/enphase_envoy/test_number.py b/tests/components/enphase_envoy/test_number.py index dbf711cacaa..7f9293eef7c 100644 --- a/tests/components/enphase_envoy/test_number.py +++ b/tests/components/enphase_envoy/test_number.py @@ -21,9 +21,9 @@ from tests.common import MockConfigEntry, snapshot_platform @pytest.mark.parametrize( - ("mock_envoy"), + "mock_envoy", ["envoy_metered_batt_relay", "envoy_eu_batt"], - indirect=["mock_envoy"], + indirect=True, ) @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_number( @@ -40,14 +40,14 @@ async def test_number( @pytest.mark.parametrize( - ("mock_envoy"), + "mock_envoy", [ "envoy", "envoy_1p_metered", "envoy_nobatt_metered_3p", "envoy_tot_cons_metered", ], - indirect=["mock_envoy"], + indirect=True, ) async def test_no_number( hass: HomeAssistant, @@ -62,10 +62,10 @@ async def test_no_number( @pytest.mark.parametrize( - ("mock_envoy", "use_serial"), + ("mock_envoy", "use_serial", "expected_value", "test_value"), [ - ("envoy_metered_batt_relay", "enpower_654321"), - ("envoy_eu_batt", "envoy_1234"), + ("envoy_metered_batt_relay", "enpower_654321", 15.0, 30.0), + ("envoy_eu_batt", "envoy_1234", 0.0, 80.0), ], indirect=["mock_envoy"], ) @@ -74,6 +74,8 @@ async def test_number_operation_storage( mock_envoy: AsyncMock, config_entry: MockConfigEntry, use_serial: bool, + expected_value: float, + test_value: float, ) -> None: """Test enphase_envoy number storage entities operation.""" with patch("homeassistant.components.enphase_envoy.PLATFORMS", [Platform.NUMBER]): @@ -82,10 +84,8 @@ async def test_number_operation_storage( test_entity = f"{Platform.NUMBER}.{use_serial}_reserve_battery_level" assert (entity_state := hass.states.get(test_entity)) - assert mock_envoy.data.tariff.storage_settings.reserved_soc == float( - entity_state.state - ) - test_value = 30.0 + assert float(entity_state.state) == expected_value + await hass.services.async_call( NUMBER_DOMAIN, SERVICE_SET_VALUE, @@ -99,13 +99,27 @@ async def test_number_operation_storage( mock_envoy.set_reserve_soc.assert_awaited_once_with(test_value) +@pytest.mark.parametrize("mock_envoy", ["envoy_metered_batt_relay"], indirect=True) @pytest.mark.parametrize( - ("mock_envoy"), ["envoy_metered_batt_relay"], indirect=["mock_envoy"] + ("relay", "target", "expected_value", "test_value", "test_field"), + [ + ("NC1", "cutoff_battery_level", 25.0, 15.0, "soc_low"), + ("NC1", "restore_battery_level", 70.0, 75.0, "soc_high"), + ("NC2", "cutoff_battery_level", 30.0, 25.0, "soc_low"), + ("NC2", "restore_battery_level", 70.0, 80.0, "soc_high"), + ("NC3", "cutoff_battery_level", 30.0, 45.0, "soc_low"), + ("NC3", "restore_battery_level", 70.0, 90.0, "soc_high"), + ], ) async def test_number_operation_relays( hass: HomeAssistant, mock_envoy: AsyncMock, config_entry: MockConfigEntry, + relay: str, + target: str, + expected_value: float, + test_value: float, + test_field: str, ) -> None: """Test enphase_envoy number relay entities operation.""" with patch("homeassistant.components.enphase_envoy.PLATFORMS", [Platform.NUMBER]): @@ -113,48 +127,24 @@ async def test_number_operation_relays( entity_base = f"{Platform.NUMBER}." - for counter, (contact_id, dry_contact) in enumerate( - mock_envoy.data.dry_contact_settings.items() - ): - name = dry_contact.load_name.lower().replace(" ", "_") - test_entity = f"{entity_base}{name}_cutoff_battery_level" - assert (entity_state := hass.states.get(test_entity)) - assert mock_envoy.data.dry_contact_settings[contact_id].soc_low == float( - entity_state.state - ) - test_value = 10.0 + counter - await hass.services.async_call( - NUMBER_DOMAIN, - SERVICE_SET_VALUE, - { - ATTR_ENTITY_ID: test_entity, - ATTR_VALUE: test_value, - }, - blocking=True, - ) + assert (dry_contact := mock_envoy.data.dry_contact_settings[relay]) + assert (name := dry_contact.load_name.lower().replace(" ", "_")) - mock_envoy.update_dry_contact.assert_awaited_once_with( - {"id": contact_id, "soc_low": test_value} - ) - mock_envoy.update_dry_contact.reset_mock() + test_entity = f"{entity_base}{name}_{target}" - test_entity = f"{entity_base}{name}_restore_battery_level" - assert (entity_state := hass.states.get(test_entity)) - assert mock_envoy.data.dry_contact_settings[contact_id].soc_high == float( - entity_state.state - ) - test_value = 80.0 - counter - await hass.services.async_call( - NUMBER_DOMAIN, - SERVICE_SET_VALUE, - { - ATTR_ENTITY_ID: test_entity, - ATTR_VALUE: test_value, - }, - blocking=True, - ) + assert (entity_state := hass.states.get(test_entity)) + assert float(entity_state.state) == expected_value - mock_envoy.update_dry_contact.assert_awaited_once_with( - {"id": contact_id, "soc_high": test_value} - ) - mock_envoy.update_dry_contact.reset_mock() + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: test_entity, + ATTR_VALUE: test_value, + }, + blocking=True, + ) + + mock_envoy.update_dry_contact.assert_awaited_once_with( + {"id": relay, test_field: int(test_value)} + ) From 7cbc6f35d2ad35b27ce29854399758d15e59f291 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 28 Jan 2025 17:08:55 +0100 Subject: [PATCH 0143/3148] Fix all occurrences of "PIN" in MotionMount user strings (#136734) --- homeassistant/components/motionmount/strings.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/motionmount/strings.json b/homeassistant/components/motionmount/strings.json index 098a7a592f3..bef04634431 100644 --- a/homeassistant/components/motionmount/strings.json +++ b/homeassistant/components/motionmount/strings.json @@ -1,6 +1,6 @@ { "common": { - "incorrect_pin": "Pin is not correct" + "incorrect_pin": "PIN is not correct" }, "config": { "flow_title": "{name}", @@ -19,21 +19,21 @@ }, "auth": { "title": "Authenticate to your MotionMount", - "description": "Your MotionMount requires a pin to operate.", + "description": "Your MotionMount requires a PIN to operate.", "data": { "pin": "[%key:common::config_flow::data::pin%]" } }, "backoff": { "title": "Authenticate to your MotionMount", - "description": "Too many incorrect pin attempts." + "description": "Too many incorrect PIN attempts." } }, "error": { "pin": "[%key:component::motionmount::common::incorrect_pin%]" }, "progress": { - "progress_action": "Too many incorrect pin attempts. Please wait {timeout} s..." + "progress_action": "Too many incorrect PIN attempts. Please wait {timeout} s..." }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", @@ -83,7 +83,7 @@ "message": "Failed to communicate with MotionMount" }, "no_pin_provided": { - "message": "No pin provided" + "message": "No PIN provided" }, "incorrect_pin": { "message": "[%key:component::motionmount::common::incorrect_pin%]" From e9ef82f89895fef691c3c30fcf703a4da94913d4 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Tue, 28 Jan 2025 08:32:09 -0800 Subject: [PATCH 0144/3148] Bump python-roborock to 2.9.7 (#136727) --- 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 d104ebff12a..76d7ab98a34 100644 --- a/homeassistant/components/roborock/manifest.json +++ b/homeassistant/components/roborock/manifest.json @@ -7,7 +7,7 @@ "iot_class": "local_polling", "loggers": ["roborock"], "requirements": [ - "python-roborock==2.8.4", + "python-roborock==2.9.7", "vacuum-map-parser-roborock==0.1.2" ] } diff --git a/requirements_all.txt b/requirements_all.txt index 128586eb01e..287ca9364a5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2446,7 +2446,7 @@ python-rabbitair==0.0.8 python-ripple-api==0.0.3 # homeassistant.components.roborock -python-roborock==2.8.4 +python-roborock==2.9.7 # homeassistant.components.smarttub python-smarttub==0.0.38 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 117886a0bc8..d7220be9718 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1979,7 +1979,7 @@ python-picnic-api==1.1.0 python-rabbitair==0.0.8 # homeassistant.components.roborock -python-roborock==2.8.4 +python-roborock==2.9.7 # homeassistant.components.smarttub python-smarttub==0.0.38 From 661bacda1056864463b032e7d9748e3a894101d6 Mon Sep 17 00:00:00 2001 From: cdnninja Date: Tue, 28 Jan 2025 09:34:25 -0700 Subject: [PATCH 0145/3148] Add SmartTowerFan to VeSync Integration (#136596) --- homeassistant/components/vesync/const.py | 6 + homeassistant/components/vesync/fan.py | 14 +++ homeassistant/components/vesync/icons.json | 1 + homeassistant/components/vesync/strings.json | 1 + tests/components/vesync/common.py | 3 + .../vesync/fixtures/SmartTowerFan-detail.json | 37 +++++++ .../vesync/fixtures/vesync-devices.json | 9 ++ .../components/vesync/snapshots/test_fan.ambr | 103 ++++++++++++++++++ .../vesync/snapshots/test_light.ambr | 38 +++++++ .../vesync/snapshots/test_sensor.ambr | 38 +++++++ .../vesync/snapshots/test_switch.ambr | 38 +++++++ 11 files changed, 288 insertions(+) create mode 100644 tests/components/vesync/fixtures/SmartTowerFan-detail.json diff --git a/homeassistant/components/vesync/const.py b/homeassistant/components/vesync/const.py index 841185e4308..34454081567 100644 --- a/homeassistant/components/vesync/const.py +++ b/homeassistant/components/vesync/const.py @@ -48,6 +48,7 @@ DEV_TYPE_TO_HA = { "EverestAir": "fan", "Vital200S": "fan", "Vital100S": "fan", + "SmartTowerFan": "fan", "ESD16": "walldimmer", "ESWD16": "walldimmer", "ESL100": "bulb-dimmable", @@ -91,4 +92,9 @@ SKU_TO_BASE_DEVICE = { "LAP-EL551S-AEUR": "EverestAir", # Alt ID Model EverestAir "LAP-EL551S-WEU": "EverestAir", # Alt ID Model EverestAir "LAP-EL551S-WUS": "EverestAir", # Alt ID Model EverestAir + "SmartTowerFan": "SmartTowerFan", + "LTF-F422S-KEU": "SmartTowerFan", # Alt ID Model SmartTowerFan + "LTF-F422S-WUSR": "SmartTowerFan", # Alt ID Model SmartTowerFan + "LTF-F422_WJP": "SmartTowerFan", # Alt ID Model SmartTowerFan + "LTF-F422S-WUS": "SmartTowerFan", # Alt ID Model SmartTowerFan } diff --git a/homeassistant/components/vesync/fan.py b/homeassistant/components/vesync/fan.py index ba1880f2492..9744e5062f0 100644 --- a/homeassistant/components/vesync/fan.py +++ b/homeassistant/components/vesync/fan.py @@ -36,6 +36,9 @@ FAN_MODE_AUTO = "auto" FAN_MODE_SLEEP = "sleep" FAN_MODE_PET = "pet" FAN_MODE_TURBO = "turbo" +FAN_MODE_ADVANCED_SLEEP = "advancedSleep" +FAN_MODE_NORMAL = "normal" + PRESET_MODES = { "LV-PUR131S": [FAN_MODE_AUTO, FAN_MODE_SLEEP], @@ -46,6 +49,12 @@ PRESET_MODES = { "EverestAir": [FAN_MODE_AUTO, FAN_MODE_SLEEP, FAN_MODE_TURBO], "Vital200S": [FAN_MODE_AUTO, FAN_MODE_SLEEP, FAN_MODE_PET], "Vital100S": [FAN_MODE_AUTO, FAN_MODE_SLEEP, FAN_MODE_PET], + "SmartTowerFan": [ + FAN_MODE_ADVANCED_SLEEP, + FAN_MODE_AUTO, + FAN_MODE_TURBO, + FAN_MODE_NORMAL, + ], } SPEED_RANGE = { # off is not included "LV-PUR131S": (1, 3), @@ -56,6 +65,7 @@ SPEED_RANGE = { # off is not included "EverestAir": (1, 3), "Vital200S": (1, 4), "Vital100S": (1, 4), + "SmartTowerFan": (1, 13), } @@ -212,10 +222,14 @@ class VeSyncFanHA(VeSyncBaseEntity, FanEntity): self.smartfan.auto_mode() elif preset_mode == FAN_MODE_SLEEP: self.smartfan.sleep_mode() + elif preset_mode == FAN_MODE_ADVANCED_SLEEP: + self.smartfan.advanced_sleep_mode() elif preset_mode == FAN_MODE_PET: self.smartfan.pet_mode() elif preset_mode == FAN_MODE_TURBO: self.smartfan.turbo_mode() + elif preset_mode == FAN_MODE_NORMAL: + self.smartfan.normal_mode() self.schedule_update_ha_state() diff --git a/homeassistant/components/vesync/icons.json b/homeassistant/components/vesync/icons.json index e4769acc9a5..c11bd002049 100644 --- a/homeassistant/components/vesync/icons.json +++ b/homeassistant/components/vesync/icons.json @@ -7,6 +7,7 @@ "state": { "auto": "mdi:fan-auto", "sleep": "mdi:sleep", + "advanced_sleep": "mdi:sleep", "pet": "mdi:paw", "turbo": "mdi:weather-tornado" } diff --git a/homeassistant/components/vesync/strings.json b/homeassistant/components/vesync/strings.json index a23fe7936e7..87a8ea8746e 100644 --- a/homeassistant/components/vesync/strings.json +++ b/homeassistant/components/vesync/strings.json @@ -55,6 +55,7 @@ "state": { "auto": "Auto", "sleep": "Sleep", + "advanced_sleep": "Advanced sleep", "pet": "Pet", "turbo": "Turbo" } diff --git a/tests/components/vesync/common.py b/tests/components/vesync/common.py index ead3ecdc173..ee9f9b94052 100644 --- a/tests/components/vesync/common.py +++ b/tests/components/vesync/common.py @@ -51,6 +51,9 @@ DEVICE_FIXTURES: dict[str, list[tuple[str, str, str]]] = { ("post", "/inwallswitch/v1/device/devicedetail", "device-detail.json") ], "Dimmer Switch": [("post", "/dimmer/v1/device/devicedetail", "dimmer-detail.json")], + "SmartTowerFan": [ + ("post", "/cloud/v2/deviceManaged/bypassV2", "SmartTowerFan-detail.json") + ], } diff --git a/tests/components/vesync/fixtures/SmartTowerFan-detail.json b/tests/components/vesync/fixtures/SmartTowerFan-detail.json new file mode 100644 index 00000000000..061dcb5b0d0 --- /dev/null +++ b/tests/components/vesync/fixtures/SmartTowerFan-detail.json @@ -0,0 +1,37 @@ +{ + "traceId": "0000000000", + "code": 0, + "msg": "request success", + "module": null, + "stacktrace": null, + "result": { + "traceId": "0000000000", + "code": 0, + "result": { + "powerSwitch": 0, + "workMode": "normal", + "manualSpeedLevel": 1, + "fanSpeedLevel": 0, + "screenState": 0, + "screenSwitch": 0, + "oscillationSwitch": 1, + "oscillationState": 1, + "muteSwitch": 1, + "muteState": 1, + "timerRemain": 0, + "temperature": 717, + "humidity": 40, + "thermalComfort": 65, + "errorCode": 0, + "sleepPreference": { + "sleepPreferenceType": "default", + "oscillationSwitch": 0, + "initFanSpeedLevel": 0, + "fallAsleepRemain": 0, + "autoChangeFanLevelSwitch": 0 + }, + "scheduleCount": 0, + "displayingType": 0 + } + } +} diff --git a/tests/components/vesync/fixtures/vesync-devices.json b/tests/components/vesync/fixtures/vesync-devices.json index eac2bf9f5fa..bb32bae0435 100644 --- a/tests/components/vesync/fixtures/vesync-devices.json +++ b/tests/components/vesync/fixtures/vesync-devices.json @@ -108,6 +108,15 @@ "deviceStatus": "on", "connectionStatus": "online", "configModule": "configModule" + }, + { + "cid": "smarttowerfan", + "deviceType": "LTF-F422S-KEU", + "deviceName": "SmartTowerFan", + "subDeviceNo": null, + "deviceStatus": "on", + "connectionStatus": "online", + "configModule": "configModule" } ] } diff --git a/tests/components/vesync/snapshots/test_fan.ambr b/tests/components/vesync/snapshots/test_fan.ambr index 1dea5f28f2c..e1b630e8d81 100644 --- a/tests/components/vesync/snapshots/test_fan.ambr +++ b/tests/components/vesync/snapshots/test_fan.ambr @@ -576,6 +576,109 @@ list([ ]) # --- +# name: test_fan_state[SmartTowerFan][devices] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'vesync', + 'smarttowerfan', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'VeSync', + 'model': 'LTF-F422S-KEU', + 'model_id': None, + 'name': 'SmartTowerFan', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_fan_state[SmartTowerFan][entities] + list([ + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'preset_modes': list([ + 'advancedSleep', + 'auto', + 'turbo', + 'normal', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.smarttowerfan', + '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': None, + 'platform': 'vesync', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'vesync', + 'unique_id': 'smarttowerfan', + 'unit_of_measurement': None, + }), + ]) +# --- +# name: test_fan_state[SmartTowerFan][fan.smarttowerfan] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'child_lock': False, + 'friendly_name': 'SmartTowerFan', + 'mode': 'normal', + 'night_light': 'off', + 'percentage': None, + 'percentage_step': 7.6923076923076925, + 'preset_mode': None, + 'preset_modes': list([ + 'advancedSleep', + 'auto', + 'turbo', + 'normal', + ]), + 'screen_status': False, + 'supported_features': , + }), + 'context': , + 'entity_id': 'fan.smarttowerfan', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_fan_state[Temperature Light][devices] list([ DeviceRegistryEntrySnapshot({ diff --git a/tests/components/vesync/snapshots/test_light.ambr b/tests/components/vesync/snapshots/test_light.ambr index ba6c7ab51b9..74f63ce72a1 100644 --- a/tests/components/vesync/snapshots/test_light.ambr +++ b/tests/components/vesync/snapshots/test_light.ambr @@ -447,6 +447,44 @@ list([ ]) # --- +# name: test_light_state[SmartTowerFan][devices] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'vesync', + 'smarttowerfan', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'VeSync', + 'model': 'LTF-F422S-KEU', + 'model_id': None, + 'name': 'SmartTowerFan', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_light_state[SmartTowerFan][entities] + list([ + ]) +# --- # name: test_light_state[Temperature Light][devices] list([ DeviceRegistryEntrySnapshot({ diff --git a/tests/components/vesync/snapshots/test_sensor.ambr b/tests/components/vesync/snapshots/test_sensor.ambr index 50bee417a28..2525dcd642e 100644 --- a/tests/components/vesync/snapshots/test_sensor.ambr +++ b/tests/components/vesync/snapshots/test_sensor.ambr @@ -1155,6 +1155,44 @@ 'state': '0', }) # --- +# name: test_sensor_state[SmartTowerFan][devices] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'vesync', + 'smarttowerfan', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'VeSync', + 'model': 'LTF-F422S-KEU', + 'model_id': None, + 'name': 'SmartTowerFan', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_sensor_state[SmartTowerFan][entities] + list([ + ]) +# --- # name: test_sensor_state[Temperature Light][devices] list([ DeviceRegistryEntrySnapshot({ diff --git a/tests/components/vesync/snapshots/test_switch.ambr b/tests/components/vesync/snapshots/test_switch.ambr index 596aa0c94ad..0a72bb3ca47 100644 --- a/tests/components/vesync/snapshots/test_switch.ambr +++ b/tests/components/vesync/snapshots/test_switch.ambr @@ -385,6 +385,44 @@ 'state': 'on', }) # --- +# name: test_switch_state[SmartTowerFan][devices] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'vesync', + 'smarttowerfan', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'VeSync', + 'model': 'LTF-F422S-KEU', + 'model_id': None, + 'name': 'SmartTowerFan', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_switch_state[SmartTowerFan][entities] + list([ + ]) +# --- # name: test_switch_state[Temperature Light][devices] list([ DeviceRegistryEntrySnapshot({ From 3680e39c437a0e869d844e03ed5d6074f8002782 Mon Sep 17 00:00:00 2001 From: Sid <27780930+autinerd@users.noreply.github.com> Date: Tue, 28 Jan 2025 17:38:28 +0100 Subject: [PATCH 0146/3148] Add climate platform to eheimdigital (#135878) --- .../components/eheimdigital/__init__.py | 2 +- .../components/eheimdigital/climate.py | 139 +++++++++++ .../components/eheimdigital/const.py | 12 +- .../components/eheimdigital/strings.json | 12 + tests/components/eheimdigital/conftest.py | 27 ++- .../eheimdigital/snapshots/test_climate.ambr | 77 ++++++ tests/components/eheimdigital/test_climate.py | 219 ++++++++++++++++++ 7 files changed, 483 insertions(+), 5 deletions(-) create mode 100644 homeassistant/components/eheimdigital/climate.py create mode 100644 tests/components/eheimdigital/snapshots/test_climate.ambr create mode 100644 tests/components/eheimdigital/test_climate.py diff --git a/homeassistant/components/eheimdigital/__init__.py b/homeassistant/components/eheimdigital/__init__.py index cf08f45bed5..a555a87cfbc 100644 --- a/homeassistant/components/eheimdigital/__init__.py +++ b/homeassistant/components/eheimdigital/__init__.py @@ -10,7 +10,7 @@ from homeassistant.helpers.device_registry import DeviceEntry from .const import DOMAIN from .coordinator import EheimDigitalUpdateCoordinator -PLATFORMS = [Platform.LIGHT] +PLATFORMS = [Platform.CLIMATE, Platform.LIGHT] type EheimDigitalConfigEntry = ConfigEntry[EheimDigitalUpdateCoordinator] diff --git a/homeassistant/components/eheimdigital/climate.py b/homeassistant/components/eheimdigital/climate.py new file mode 100644 index 00000000000..16771ba227d --- /dev/null +++ b/homeassistant/components/eheimdigital/climate.py @@ -0,0 +1,139 @@ +"""EHEIM Digital climate.""" + +from typing import Any + +from eheimdigital.heater import EheimDigitalHeater +from eheimdigital.types import EheimDigitalClientError, HeaterMode, HeaterUnit + +from homeassistant.components.climate import ( + PRESET_NONE, + ClimateEntity, + ClimateEntityFeature, + HVACAction, + HVACMode, +) +from homeassistant.const import ( + ATTR_TEMPERATURE, + PRECISION_HALVES, + PRECISION_TENTHS, + UnitOfTemperature, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import EheimDigitalConfigEntry +from .const import HEATER_BIO_MODE, HEATER_PRESET_TO_HEATER_MODE, HEATER_SMART_MODE +from .coordinator import EheimDigitalUpdateCoordinator +from .entity import EheimDigitalEntity + +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + entry: EheimDigitalConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the callbacks for the coordinator so climate entities can be added as devices are found.""" + coordinator = entry.runtime_data + + async def async_setup_device_entities(device_address: str) -> None: + """Set up the light entities for a device.""" + device = coordinator.hub.devices[device_address] + + if isinstance(device, EheimDigitalHeater): + async_add_entities([EheimDigitalHeaterClimate(coordinator, device)]) + + coordinator.add_platform_callback(async_setup_device_entities) + + for device_address in entry.runtime_data.hub.devices: + await async_setup_device_entities(device_address) + + +class EheimDigitalHeaterClimate(EheimDigitalEntity[EheimDigitalHeater], ClimateEntity): + """Represent an EHEIM Digital heater.""" + + _attr_hvac_modes = [HVACMode.OFF, HVACMode.AUTO] + _attr_hvac_mode = HVACMode.OFF + _attr_precision = PRECISION_TENTHS + _attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_ON + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.PRESET_MODE + ) + _attr_target_temperature_step = PRECISION_HALVES + _attr_preset_modes = [PRESET_NONE, HEATER_BIO_MODE, HEATER_SMART_MODE] + _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_preset_mode = PRESET_NONE + _attr_translation_key = "heater" + + def __init__( + self, coordinator: EheimDigitalUpdateCoordinator, device: EheimDigitalHeater + ) -> None: + """Initialize an EHEIM Digital thermocontrol climate entity.""" + super().__init__(coordinator, device) + self._attr_unique_id = self._device_address + self._async_update_attrs() + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set the preset mode.""" + try: + if preset_mode in HEATER_PRESET_TO_HEATER_MODE: + await self._device.set_operation_mode( + HEATER_PRESET_TO_HEATER_MODE[preset_mode] + ) + except EheimDigitalClientError as err: + raise HomeAssistantError from err + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set a new temperature.""" + try: + if ATTR_TEMPERATURE in kwargs: + await self._device.set_target_temperature(kwargs[ATTR_TEMPERATURE]) + except EheimDigitalClientError as err: + raise HomeAssistantError from err + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set the heating mode.""" + try: + match hvac_mode: + case HVACMode.OFF: + await self._device.set_active(active=False) + case HVACMode.AUTO: + await self._device.set_active(active=True) + except EheimDigitalClientError as err: + raise HomeAssistantError from err + + def _async_update_attrs(self) -> None: + if self._device.temperature_unit == HeaterUnit.CELSIUS: + self._attr_min_temp = 18 + self._attr_max_temp = 32 + self._attr_temperature_unit = UnitOfTemperature.CELSIUS + elif self._device.temperature_unit == HeaterUnit.FAHRENHEIT: + self._attr_min_temp = 64 + self._attr_max_temp = 90 + self._attr_temperature_unit = UnitOfTemperature.FAHRENHEIT + + self._attr_current_temperature = self._device.current_temperature + self._attr_target_temperature = self._device.target_temperature + + if self._device.is_heating: + self._attr_hvac_action = HVACAction.HEATING + self._attr_hvac_mode = HVACMode.AUTO + elif self._device.is_active: + self._attr_hvac_action = HVACAction.IDLE + self._attr_hvac_mode = HVACMode.AUTO + else: + self._attr_hvac_action = HVACAction.OFF + self._attr_hvac_mode = HVACMode.OFF + + match self._device.operation_mode: + case HeaterMode.MANUAL: + self._attr_preset_mode = PRESET_NONE + case HeaterMode.BIO: + self._attr_preset_mode = HEATER_BIO_MODE + case HeaterMode.SMART: + self._attr_preset_mode = HEATER_SMART_MODE diff --git a/homeassistant/components/eheimdigital/const.py b/homeassistant/components/eheimdigital/const.py index 5ed9303be40..61b391b6c63 100644 --- a/homeassistant/components/eheimdigital/const.py +++ b/homeassistant/components/eheimdigital/const.py @@ -2,8 +2,9 @@ from logging import Logger, getLogger -from eheimdigital.types import LightMode +from eheimdigital.types import HeaterMode, LightMode +from homeassistant.components.climate import PRESET_NONE from homeassistant.components.light import EFFECT_OFF LOGGER: Logger = getLogger(__package__) @@ -15,3 +16,12 @@ EFFECT_TO_LIGHT_MODE = { EFFECT_DAYCL_MODE: LightMode.DAYCL_MODE, EFFECT_OFF: LightMode.MAN_MODE, } + +HEATER_BIO_MODE = "bio_mode" +HEATER_SMART_MODE = "smart_mode" + +HEATER_PRESET_TO_HEATER_MODE = { + HEATER_BIO_MODE: HeaterMode.BIO, + HEATER_SMART_MODE: HeaterMode.SMART, + PRESET_NONE: HeaterMode.MANUAL, +} diff --git a/homeassistant/components/eheimdigital/strings.json b/homeassistant/components/eheimdigital/strings.json index 0e6fa6a0814..ef6f6b10d0a 100644 --- a/homeassistant/components/eheimdigital/strings.json +++ b/homeassistant/components/eheimdigital/strings.json @@ -23,6 +23,18 @@ } }, "entity": { + "climate": { + "heater": { + "state_attributes": { + "preset_mode": { + "state": { + "bio_mode": "Bio mode", + "smart_mode": "Smart mode" + } + } + } + } + }, "light": { "channel": { "name": "Channel {channel_id}", diff --git a/tests/components/eheimdigital/conftest.py b/tests/components/eheimdigital/conftest.py index cdad628de6b..ef52eade9ae 100644 --- a/tests/components/eheimdigital/conftest.py +++ b/tests/components/eheimdigital/conftest.py @@ -4,8 +4,9 @@ from collections.abc import Generator from unittest.mock import AsyncMock, MagicMock, patch from eheimdigital.classic_led_ctrl import EheimDigitalClassicLEDControl +from eheimdigital.heater import EheimDigitalHeater from eheimdigital.hub import EheimDigitalHub -from eheimdigital.types import EheimDeviceType, LightMode +from eheimdigital.types import EheimDeviceType, HeaterMode, HeaterUnit, LightMode import pytest from homeassistant.components.eheimdigital.const import DOMAIN @@ -39,7 +40,26 @@ def classic_led_ctrl_mock(): @pytest.fixture -def eheimdigital_hub_mock(classic_led_ctrl_mock: MagicMock) -> Generator[AsyncMock]: +def heater_mock(): + """Mock a Heater device.""" + heater_mock = MagicMock(spec=EheimDigitalHeater) + heater_mock.mac_address = "00:00:00:00:00:02" + heater_mock.device_type = EheimDeviceType.VERSION_EHEIM_EXT_HEATER + heater_mock.name = "Mock Heater" + heater_mock.aquarium_name = "Mock Aquarium" + heater_mock.temperature_unit = HeaterUnit.CELSIUS + heater_mock.current_temperature = 24.2 + heater_mock.target_temperature = 25.5 + heater_mock.is_heating = True + heater_mock.is_active = True + heater_mock.operation_mode = HeaterMode.MANUAL + return heater_mock + + +@pytest.fixture +def eheimdigital_hub_mock( + classic_led_ctrl_mock: MagicMock, heater_mock: MagicMock +) -> Generator[AsyncMock]: """Mock eheimdigital hub.""" with ( patch( @@ -52,7 +72,8 @@ def eheimdigital_hub_mock(classic_led_ctrl_mock: MagicMock) -> Generator[AsyncMo ), ): eheimdigital_hub_mock.return_value.devices = { - "00:00:00:00:00:01": classic_led_ctrl_mock + "00:00:00:00:00:01": classic_led_ctrl_mock, + "00:00:00:00:00:02": heater_mock, } eheimdigital_hub_mock.return_value.main = classic_led_ctrl_mock yield eheimdigital_hub_mock diff --git a/tests/components/eheimdigital/snapshots/test_climate.ambr b/tests/components/eheimdigital/snapshots/test_climate.ambr new file mode 100644 index 00000000000..02d60677b24 --- /dev/null +++ b/tests/components/eheimdigital/snapshots/test_climate.ambr @@ -0,0 +1,77 @@ +# serializer version: 1 +# name: test_setup_heater[climate.mock_heater_none-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 32, + 'min_temp': 18, + 'preset_modes': list([ + 'none', + 'bio_mode', + 'smart_mode', + ]), + 'target_temp_step': 0.5, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.mock_heater_none', + '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': None, + 'platform': 'eheimdigital', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'heater', + 'unique_id': '00:00:00:00:00:02', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup_heater[climate.mock_heater_none-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 24.2, + 'friendly_name': 'Mock Heater None', + 'hvac_action': , + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 32, + 'min_temp': 18, + 'preset_mode': 'none', + 'preset_modes': list([ + 'none', + 'bio_mode', + 'smart_mode', + ]), + 'supported_features': , + 'target_temp_step': 0.5, + 'temperature': 25.5, + }), + 'context': , + 'entity_id': 'climate.mock_heater_none', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'auto', + }) +# --- diff --git a/tests/components/eheimdigital/test_climate.py b/tests/components/eheimdigital/test_climate.py new file mode 100644 index 00000000000..4e770882263 --- /dev/null +++ b/tests/components/eheimdigital/test_climate.py @@ -0,0 +1,219 @@ +"""Tests for the climate module.""" + +from unittest.mock import MagicMock, patch + +from eheimdigital.types import ( + EheimDeviceType, + EheimDigitalClientError, + HeaterMode, + HeaterUnit, +) +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.climate import ( + ATTR_HVAC_MODE, + ATTR_PRESET_MODE, + DOMAIN as CLIMATE_DOMAIN, + PRESET_NONE, + SERVICE_SET_HVAC_MODE, + SERVICE_SET_PRESET_MODE, + SERVICE_SET_TEMPERATURE, + HVACAction, + HVACMode, +) +from homeassistant.components.eheimdigital.const import ( + HEATER_BIO_MODE, + HEATER_SMART_MODE, +) +from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.usefixtures("heater_mock") +async def test_setup_heater( + hass: HomeAssistant, + eheimdigital_hub_mock: MagicMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test climate platform setup for heater.""" + mock_config_entry.add_to_hass(hass) + + with patch("homeassistant.components.eheimdigital.PLATFORMS", [Platform.CLIMATE]): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + await eheimdigital_hub_mock.call_args.kwargs["device_found_callback"]( + "00:00:00:00:00:02", EheimDeviceType.VERSION_EHEIM_EXT_HEATER + ) + await hass.async_block_till_done() + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize( + ("preset_mode", "heater_mode"), + [ + (PRESET_NONE, HeaterMode.MANUAL), + (HEATER_BIO_MODE, HeaterMode.BIO), + (HEATER_SMART_MODE, HeaterMode.SMART), + ], +) +async def test_set_preset_mode( + hass: HomeAssistant, + eheimdigital_hub_mock: MagicMock, + heater_mock: MagicMock, + mock_config_entry: MockConfigEntry, + preset_mode: str, + heater_mode: HeaterMode, +) -> None: + """Test setting a preset mode.""" + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + await eheimdigital_hub_mock.call_args.kwargs["device_found_callback"]( + "00:00:00:00:00:02", EheimDeviceType.VERSION_EHEIM_EXT_HEATER + ) + await hass.async_block_till_done() + + heater_mock.set_operation_mode.side_effect = EheimDigitalClientError + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: "climate.mock_heater_none", ATTR_PRESET_MODE: preset_mode}, + blocking=True, + ) + + heater_mock.set_operation_mode.side_effect = None + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: "climate.mock_heater_none", ATTR_PRESET_MODE: preset_mode}, + blocking=True, + ) + + heater_mock.set_operation_mode.assert_awaited_with(heater_mode) + + +async def test_set_temperature( + hass: HomeAssistant, + eheimdigital_hub_mock: MagicMock, + heater_mock: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setting a preset mode.""" + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + await eheimdigital_hub_mock.call_args.kwargs["device_found_callback"]( + "00:00:00:00:00:02", EheimDeviceType.VERSION_EHEIM_EXT_HEATER + ) + await hass.async_block_till_done() + + heater_mock.set_target_temperature.side_effect = EheimDigitalClientError + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: "climate.mock_heater_none", ATTR_TEMPERATURE: 26.0}, + blocking=True, + ) + + heater_mock.set_target_temperature.side_effect = None + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: "climate.mock_heater_none", ATTR_TEMPERATURE: 26.0}, + blocking=True, + ) + + heater_mock.set_target_temperature.assert_awaited_with(26.0) + + +@pytest.mark.parametrize( + ("hvac_mode", "active"), [(HVACMode.AUTO, True), (HVACMode.OFF, False)] +) +async def test_set_hvac_mode( + hass: HomeAssistant, + eheimdigital_hub_mock: MagicMock, + heater_mock: MagicMock, + mock_config_entry: MockConfigEntry, + hvac_mode: HVACMode, + active: bool, +) -> None: + """Test setting a preset mode.""" + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + await eheimdigital_hub_mock.call_args.kwargs["device_found_callback"]( + "00:00:00:00:00:02", EheimDeviceType.VERSION_EHEIM_EXT_HEATER + ) + await hass.async_block_till_done() + + heater_mock.set_active.side_effect = EheimDigitalClientError + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: "climate.mock_heater_none", ATTR_HVAC_MODE: hvac_mode}, + blocking=True, + ) + + heater_mock.set_active.side_effect = None + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: "climate.mock_heater_none", ATTR_HVAC_MODE: hvac_mode}, + blocking=True, + ) + + heater_mock.set_active.assert_awaited_with(active=active) + + +async def test_state_update( + hass: HomeAssistant, + eheimdigital_hub_mock: MagicMock, + mock_config_entry: MockConfigEntry, + heater_mock: MagicMock, +) -> None: + """Test the climate state update.""" + heater_mock.temperature_unit = HeaterUnit.FAHRENHEIT + heater_mock.is_heating = False + heater_mock.operation_mode = HeaterMode.BIO + + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await eheimdigital_hub_mock.call_args.kwargs["device_found_callback"]( + "00:00:00:00:00:02", EheimDeviceType.VERSION_EHEIM_EXT_HEATER + ) + await hass.async_block_till_done() + + assert (state := hass.states.get("climate.mock_heater_none")) + + assert state.attributes["hvac_action"] == HVACAction.IDLE + assert state.attributes["preset_mode"] == HEATER_BIO_MODE + + heater_mock.is_active = False + heater_mock.operation_mode = HeaterMode.SMART + + await eheimdigital_hub_mock.call_args.kwargs["receive_callback"]() + + assert (state := hass.states.get("climate.mock_heater_none")) + assert state.state == HVACMode.OFF + assert state.attributes["preset_mode"] == HEATER_SMART_MODE From 9b598ed69c1c0783de4b1acafaffd9eb1382ef68 Mon Sep 17 00:00:00 2001 From: Indu Prakash <6459774+iprak@users.noreply.github.com> Date: Tue, 28 Jan 2025 10:38:53 -0600 Subject: [PATCH 0147/3148] Add more tests to vesync (#135681) --- homeassistant/components/vesync/fan.py | 5 -- homeassistant/components/vesync/humidifier.py | 2 +- tests/components/vesync/conftest.py | 26 +++++++- .../vesync/fixtures/vesync-devices.json | 2 +- .../components/vesync/snapshots/test_fan.ambr | 2 +- .../vesync/snapshots/test_light.ambr | 2 +- .../vesync/snapshots/test_sensor.ambr | 4 +- .../vesync/snapshots/test_switch.ambr | 2 +- tests/components/vesync/test_humidifier.py | 62 ++++++++++++++++++- tests/components/vesync/test_init.py | 37 +++++++---- 10 files changed, 118 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/vesync/fan.py b/homeassistant/components/vesync/fan.py index 9744e5062f0..21a92a22db2 100644 --- a/homeassistant/components/vesync/fan.py +++ b/homeassistant/components/vesync/fan.py @@ -161,11 +161,6 @@ class VeSyncFanHA(VeSyncBaseEntity, FanEntity): return self.smartfan.mode return None - @property - def unique_info(self): - """Return the ID of this fan.""" - return self.smartfan.uuid - @property def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes of the fan.""" diff --git a/homeassistant/components/vesync/humidifier.py b/homeassistant/components/vesync/humidifier.py index 3d89d5dc6db..8557c7a8866 100644 --- a/homeassistant/components/vesync/humidifier.py +++ b/homeassistant/components/vesync/humidifier.py @@ -137,7 +137,7 @@ class VeSyncHumidifierHA(VeSyncBaseEntity, HumidifierEntity): @property def mode(self) -> str | None: """Get the current preset mode.""" - return _get_ha_mode(self.device.mode) + return None if self.device.mode is None else _get_ha_mode(self.device.mode) def set_humidity(self, humidity: int) -> None: """Set the target humidity of the device.""" diff --git a/tests/components/vesync/conftest.py b/tests/components/vesync/conftest.py index 8272da8dfad..a80c2631088 100644 --- a/tests/components/vesync/conftest.py +++ b/tests/components/vesync/conftest.py @@ -108,7 +108,31 @@ def outlet_fixture(): @pytest.fixture(name="humidifier") def humidifier_fixture(): """Create a mock VeSync humidifier fixture.""" - return Mock(VeSyncHumid200300S) + return Mock( + VeSyncHumid200300S, + cid="200s-humidifier", + config={ + "auto_target_humidity": 40, + "display": "true", + "automatic_stop": "true", + }, + details={ + "humidity": 35, + "mode": "manual", + }, + device_type="Classic200S", + device_name="Humidifier 200s", + device_status="on", + mist_level=6, + mist_modes=["auto", "manual"], + mode=None, + sub_device_no=0, + config_module="configModule", + connection_status="online", + current_firm_version="1.0.0", + water_lacks=False, + water_tank_lifted=False, + ) @pytest.fixture(name="humidifier_config_entry") diff --git a/tests/components/vesync/fixtures/vesync-devices.json b/tests/components/vesync/fixtures/vesync-devices.json index bb32bae0435..3109fd3ea40 100644 --- a/tests/components/vesync/fixtures/vesync-devices.json +++ b/tests/components/vesync/fixtures/vesync-devices.json @@ -6,7 +6,7 @@ "cid": "200s-humidifier", "deviceType": "Classic200S", "deviceName": "Humidifier 200s", - "subDeviceNo": null, + "subDeviceNo": 4321, "deviceStatus": "on", "connectionStatus": "online", "uuid": "00000000-1111-2222-3333-444444444444", diff --git a/tests/components/vesync/snapshots/test_fan.ambr b/tests/components/vesync/snapshots/test_fan.ambr index e1b630e8d81..fddc75630d2 100644 --- a/tests/components/vesync/snapshots/test_fan.ambr +++ b/tests/components/vesync/snapshots/test_fan.ambr @@ -477,7 +477,7 @@ 'identifiers': set({ tuple( 'vesync', - '200s-humidifier', + '200s-humidifier4321', ), }), 'is_new': False, diff --git a/tests/components/vesync/snapshots/test_light.ambr b/tests/components/vesync/snapshots/test_light.ambr index 74f63ce72a1..b89cf8cdd4d 100644 --- a/tests/components/vesync/snapshots/test_light.ambr +++ b/tests/components/vesync/snapshots/test_light.ambr @@ -348,7 +348,7 @@ 'identifiers': set({ tuple( 'vesync', - '200s-humidifier', + '200s-humidifier4321', ), }), 'is_new': False, diff --git a/tests/components/vesync/snapshots/test_sensor.ambr b/tests/components/vesync/snapshots/test_sensor.ambr index 2525dcd642e..ca7a5cf3ea6 100644 --- a/tests/components/vesync/snapshots/test_sensor.ambr +++ b/tests/components/vesync/snapshots/test_sensor.ambr @@ -664,7 +664,7 @@ 'identifiers': set({ tuple( 'vesync', - '200s-humidifier', + '200s-humidifier4321', ), }), 'is_new': False, @@ -715,7 +715,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '200s-humidifier-humidity', + 'unique_id': '200s-humidifier4321-humidity', 'unit_of_measurement': '%', }), ]) diff --git a/tests/components/vesync/snapshots/test_switch.ambr b/tests/components/vesync/snapshots/test_switch.ambr index 0a72bb3ca47..ec9cbc4398c 100644 --- a/tests/components/vesync/snapshots/test_switch.ambr +++ b/tests/components/vesync/snapshots/test_switch.ambr @@ -242,7 +242,7 @@ 'identifiers': set({ tuple( 'vesync', - '200s-humidifier', + '200s-humidifier4321', ), }), 'is_new': False, diff --git a/tests/components/vesync/test_humidifier.py b/tests/components/vesync/test_humidifier.py index 3b89ba8e742..b93c97baab6 100644 --- a/tests/components/vesync/test_humidifier.py +++ b/tests/components/vesync/test_humidifier.py @@ -1,6 +1,7 @@ """Tests for the humidifier platform.""" from contextlib import nullcontext +import logging from unittest.mock import patch import pytest @@ -12,7 +13,7 @@ from homeassistant.components.humidifier import ( SERVICE_SET_HUMIDITY, SERVICE_SET_MODE, ) -from homeassistant.config_entries import ConfigEntryState +from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_TURN_OFF, @@ -21,6 +22,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.helpers import entity_registry as er from .common import ( ENTITY_HUMIDIFIER, @@ -225,3 +227,61 @@ async def test_set_mode( ) await hass.async_block_till_done() method_mock.assert_called_once() + + +async def test_base_unique_id( + hass: HomeAssistant, + humidifier_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test that unique_id is based on subDeviceNo.""" + # vesync-device.json defines subDeviceNo for 200s-humidifier as 4321. + entity = entity_registry.async_get(ENTITY_HUMIDIFIER) + assert entity.unique_id.endswith("4321") + + +async def test_invalid_mist_modes( + hass: HomeAssistant, + config_entry: ConfigEntry, + humidifier, + manager, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test unsupported mist mode.""" + + humidifier.mist_modes = ["invalid_mode"] + + with patch( + "homeassistant.components.vesync.async_generate_device_list", + return_value=[humidifier], + ): + caplog.clear() + caplog.set_level(logging.WARNING) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert "Unknown mode 'invalid_mode'" in caplog.text + + +async def test_valid_mist_modes( + hass: HomeAssistant, + config_entry: ConfigEntry, + humidifier, + manager, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test supported mist mode.""" + + humidifier.mist_modes = ["auto", "manual"] + + with patch( + "homeassistant.components.vesync.async_generate_device_list", + return_value=[humidifier], + ): + caplog.clear() + caplog.set_level(logging.WARNING) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert "Unknown mode 'auto'" not in caplog.text + assert "Unknown mode 'manual'" not in caplog.text diff --git a/tests/components/vesync/test_init.py b/tests/components/vesync/test_init.py index 3b0df128240..7873b911f6f 100644 --- a/tests/components/vesync/test_init.py +++ b/tests/components/vesync/test_init.py @@ -90,23 +90,36 @@ async def test_async_setup_entry__loads_fans( assert hass.data[DOMAIN][VS_DEVICES] == [fan] -async def test_async_new_device_discovery__loads_fans( - hass: HomeAssistant, config_entry: ConfigEntry, manager: VeSync, fan +async def test_async_new_device_discovery( + hass: HomeAssistant, config_entry: ConfigEntry, manager: VeSync, fan, humidifier ) -> None: - """Test setup connects to vesync and loads fan as an update call.""" + """Test new device discovery.""" assert await hass.config_entries.async_setup(config_entry.entry_id) # Assert platforms loaded await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED assert not hass.data[DOMAIN][VS_DEVICES] - fans = [fan] - manager.fans = fans - manager._dev_list = { - "fans": fans, - } - await hass.services.async_call(DOMAIN, SERVICE_UPDATE_DEVS, {}, blocking=True) - assert manager.login.call_count == 1 - assert hass.data[DOMAIN][VS_MANAGER] == manager - assert hass.data[DOMAIN][VS_DEVICES] == [fan] + # Mock discovery of new fan which would get added to VS_DEVICES. + with patch( + "homeassistant.components.vesync.async_generate_device_list", + return_value=[fan], + ): + await hass.services.async_call(DOMAIN, SERVICE_UPDATE_DEVS, {}, blocking=True) + + assert manager.login.call_count == 1 + assert hass.data[DOMAIN][VS_MANAGER] == manager + assert hass.data[DOMAIN][VS_DEVICES] == [fan] + + # Mock discovery of new humidifier which would invoke discovery in all platforms. + # The mocked humidifier needs to have all properties populated for correct processing. + with patch( + "homeassistant.components.vesync.async_generate_device_list", + return_value=[humidifier], + ): + await hass.services.async_call(DOMAIN, SERVICE_UPDATE_DEVS, {}, blocking=True) + + assert manager.login.call_count == 1 + assert hass.data[DOMAIN][VS_MANAGER] == manager + assert hass.data[DOMAIN][VS_DEVICES] == [fan, humidifier] From 3eb1b182f5a127300768bd1e23071f38aba2831f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joris=20Pelgr=C3=B6m?= Date: Tue, 28 Jan 2025 17:42:26 +0100 Subject: [PATCH 0148/3148] Add config entry load/unload tests for LetPot (#136736) --- tests/components/letpot/__init__.py | 36 +++- tests/components/letpot/conftest.py | 46 ++++- tests/components/letpot/test_config_flow.py | 175 +++++++++----------- tests/components/letpot/test_init.py | 96 +++++++++++ 4 files changed, 252 insertions(+), 101 deletions(-) create mode 100644 tests/components/letpot/test_init.py diff --git a/tests/components/letpot/__init__.py b/tests/components/letpot/__init__.py index f7686f815fe..829d1df54f3 100644 --- a/tests/components/letpot/__init__.py +++ b/tests/components/letpot/__init__.py @@ -1,12 +1,42 @@ """Tests for the LetPot integration.""" -from letpot.models import AuthenticationInfo +import datetime + +from letpot.models import AuthenticationInfo, LetPotDeviceStatus + +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) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + AUTHENTICATION = AuthenticationInfo( access_token="access_token", - access_token_expires=0, + access_token_expires=1738368000, # 2025-02-01 00:00:00 GMT refresh_token="refresh_token", - refresh_token_expires=0, + refresh_token_expires=1740441600, # 2025-02-25 00:00:00 GMT user_id="a1b2c3d4e5f6a1b2c3d4e5f6", email="email@example.com", ) + +STATUS = LetPotDeviceStatus( + light_brightness=500, + light_mode=1, + light_schedule_end=datetime.time(12, 10), + light_schedule_start=datetime.time(12, 0), + online=True, + plant_days=1, + pump_mode=1, + pump_nutrient=None, + pump_status=0, + raw=[77, 0, 1, 18, 98, 1, 0, 0, 1, 1, 1, 0, 1, 12, 0, 12, 10, 1, 244, 0, 0, 0], + system_on=True, + system_sound=False, + system_state=0, +) diff --git a/tests/components/letpot/conftest.py b/tests/components/letpot/conftest.py index 4cd7ef442a6..7971bca50ae 100644 --- a/tests/components/letpot/conftest.py +++ b/tests/components/letpot/conftest.py @@ -3,6 +3,7 @@ from collections.abc import Generator from unittest.mock import AsyncMock, patch +from letpot.models import LetPotDevice import pytest from homeassistant.components.letpot.const import ( @@ -14,7 +15,7 @@ from homeassistant.components.letpot.const import ( ) from homeassistant.const import CONF_ACCESS_TOKEN, CONF_EMAIL -from . import AUTHENTICATION +from . import AUTHENTICATION, STATUS from tests.common import MockConfigEntry @@ -28,6 +29,49 @@ def mock_setup_entry() -> Generator[AsyncMock]: yield mock_setup_entry +@pytest.fixture +def mock_client() -> Generator[AsyncMock]: + """Mock a LetPotClient.""" + with ( + patch( + "homeassistant.components.letpot.LetPotClient", + autospec=True, + ) as mock_client, + patch( + "homeassistant.components.letpot.config_flow.LetPotClient", + new=mock_client, + ), + ): + client = mock_client.return_value + client.login.return_value = AUTHENTICATION + client.refresh_token.return_value = AUTHENTICATION + client.get_devices.return_value = [ + LetPotDevice( + serial_number="LPH21ABCD", + name="Garden", + device_type="LPH21", + is_online=True, + is_remote=False, + ) + ] + yield client + + +@pytest.fixture +def mock_device_client() -> Generator[AsyncMock]: + """Mock a LetPotDeviceClient.""" + with patch( + "homeassistant.components.letpot.coordinator.LetPotDeviceClient", + autospec=True, + ) as mock_device_client: + device_client = mock_device_client.return_value + device_client.device_model_code = "LPH21" + device_client.device_model_name = "LetPot Air" + device_client.get_current_status.return_value = STATUS + device_client.last_status.return_value = STATUS + yield device_client + + @pytest.fixture def mock_config_entry() -> MockConfigEntry: """Mock a config entry.""" diff --git a/tests/components/letpot/test_config_flow.py b/tests/components/letpot/test_config_flow.py index 425298dc231..a659b235213 100644 --- a/tests/components/letpot/test_config_flow.py +++ b/tests/components/letpot/test_config_flow.py @@ -2,7 +2,7 @@ import dataclasses from typing import Any -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock from letpot.exceptions import LetPotAuthenticationException, LetPotConnectionException import pytest @@ -39,7 +39,9 @@ def _assert_result_success(result: Any) -> None: assert result["result"].unique_id == AUTHENTICATION.user_id -async def test_full_flow(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: +async def test_full_flow( + hass: HomeAssistant, mock_client: AsyncMock, mock_setup_entry: AsyncMock +) -> None: """Test full flow with success.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} @@ -47,18 +49,13 @@ async def test_full_flow(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> No assert result["type"] is FlowResultType.FORM assert result["errors"] == {} - with patch( - "homeassistant.components.letpot.config_flow.LetPotClient.login", - return_value=AUTHENTICATION, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_EMAIL: "email@example.com", - CONF_PASSWORD: "test-password", - }, - ) - await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: "email@example.com", + CONF_PASSWORD: "test-password", + }, + ) _assert_result_success(result) assert len(mock_setup_entry.mock_calls) == 1 @@ -74,6 +71,7 @@ async def test_full_flow(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> No ) async def test_flow_exceptions( hass: HomeAssistant, + mock_client: AsyncMock, mock_setup_entry: AsyncMock, exception: Exception, error: str, @@ -83,41 +81,37 @@ async def test_flow_exceptions( DOMAIN, context={"source": SOURCE_USER} ) - with patch( - "homeassistant.components.letpot.config_flow.LetPotClient.login", - side_effect=exception, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_EMAIL: "email@example.com", - CONF_PASSWORD: "test-password", - }, - ) + mock_client.login.side_effect = exception + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: "email@example.com", + CONF_PASSWORD: "test-password", + }, + ) assert result["type"] is FlowResultType.FORM assert result["errors"] == {"base": error} # Retry to show recovery. - with patch( - "homeassistant.components.letpot.config_flow.LetPotClient.login", - return_value=AUTHENTICATION, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_EMAIL: "email@example.com", - CONF_PASSWORD: "test-password", - }, - ) - await hass.async_block_till_done() + mock_client.login.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: "email@example.com", + CONF_PASSWORD: "test-password", + }, + ) _assert_result_success(result) assert len(mock_setup_entry.mock_calls) == 1 async def test_flow_duplicate( - hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_config_entry: MockConfigEntry + hass: HomeAssistant, + mock_client: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, ) -> None: """Test flow aborts when trying to add a previously added account.""" mock_config_entry.add_to_hass(hass) @@ -130,18 +124,13 @@ async def test_flow_duplicate( assert result["step_id"] == "user" assert result["errors"] == {} - with patch( - "homeassistant.components.letpot.config_flow.LetPotClient.login", - return_value=AUTHENTICATION, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_EMAIL: "email@example.com", - CONF_PASSWORD: "test-password", - }, - ) - await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: "email@example.com", + CONF_PASSWORD: "test-password", + }, + ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" @@ -149,7 +138,10 @@ async def test_flow_duplicate( async def test_reauth_flow( - hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_config_entry: MockConfigEntry + hass: HomeAssistant, + mock_client: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, ) -> None: """Test reauth flow with success.""" mock_config_entry.add_to_hass(hass) @@ -163,15 +155,11 @@ async def test_reauth_flow( access_token="new_access_token", refresh_token="new_refresh_token", ) - with patch( - "homeassistant.components.letpot.config_flow.LetPotClient.login", - return_value=updated_auth, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_PASSWORD: "new-password"}, - ) - await hass.async_block_till_done() + mock_client.login.return_value = updated_auth + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_PASSWORD: "new-password"}, + ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reauth_successful" @@ -196,6 +184,7 @@ async def test_reauth_flow( ) async def test_reauth_exceptions( hass: HomeAssistant, + mock_client: AsyncMock, mock_setup_entry: AsyncMock, mock_config_entry: MockConfigEntry, exception: Exception, @@ -208,14 +197,11 @@ async def test_reauth_exceptions( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "reauth_confirm" - with patch( - "homeassistant.components.letpot.config_flow.LetPotClient.login", - side_effect=exception, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_PASSWORD: "new-password"}, - ) + mock_client.login.side_effect = exception + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_PASSWORD: "new-password"}, + ) assert result["type"] is FlowResultType.FORM assert result["errors"] == {"base": error} @@ -226,15 +212,12 @@ async def test_reauth_exceptions( access_token="new_access_token", refresh_token="new_refresh_token", ) - with patch( - "homeassistant.components.letpot.config_flow.LetPotClient.login", - return_value=updated_auth, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_PASSWORD: "new-password"}, - ) - await hass.async_block_till_done() + mock_client.login.return_value = updated_auth + mock_client.login.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_PASSWORD: "new-password"}, + ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reauth_successful" @@ -250,7 +233,10 @@ async def test_reauth_exceptions( async def test_reauth_different_user_id_new( - hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_config_entry: MockConfigEntry + hass: HomeAssistant, + mock_client: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, ) -> None: """Test reauth flow with different, new user ID updating the existing entry.""" mock_config_entry.add_to_hass(hass) @@ -263,15 +249,11 @@ async def test_reauth_different_user_id_new( assert result["step_id"] == "reauth_confirm" updated_auth = dataclasses.replace(AUTHENTICATION, user_id="new_user_id") - with patch( - "homeassistant.components.letpot.config_flow.LetPotClient.login", - return_value=updated_auth, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_PASSWORD: "new-password"}, - ) - await hass.async_block_till_done() + mock_client.login.return_value = updated_auth + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_PASSWORD: "new-password"}, + ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reauth_successful" @@ -289,7 +271,10 @@ async def test_reauth_different_user_id_new( async def test_reauth_different_user_id_existing( - hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_config_entry: MockConfigEntry + hass: HomeAssistant, + mock_client: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, ) -> None: """Test reauth flow with different, existing user ID aborting.""" mock_config_entry.add_to_hass(hass) @@ -303,15 +288,11 @@ async def test_reauth_different_user_id_existing( assert result["step_id"] == "reauth_confirm" updated_auth = dataclasses.replace(AUTHENTICATION, user_id="other_user_id") - with patch( - "homeassistant.components.letpot.config_flow.LetPotClient.login", - return_value=updated_auth, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_PASSWORD: "new-password"}, - ) - await hass.async_block_till_done() + mock_client.login.return_value = updated_auth + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_PASSWORD: "new-password"}, + ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" diff --git a/tests/components/letpot/test_init.py b/tests/components/letpot/test_init.py new file mode 100644 index 00000000000..178227a6506 --- /dev/null +++ b/tests/components/letpot/test_init.py @@ -0,0 +1,96 @@ +"""Test the LetPot integration initialization and setup.""" + +from unittest.mock import MagicMock + +from letpot.exceptions import LetPotAuthenticationException, LetPotConnectionException +import pytest + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from . import setup_integration + +from tests.common import MockConfigEntry + + +@pytest.mark.freeze_time("2025-01-31 00:00:00") +async def test_load_unload_config_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_client: MagicMock, + mock_device_client: MagicMock, +) -> None: + """Test config entry loading/unloading.""" + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.LOADED + mock_client.refresh_token.assert_not_called() # Didn't refresh valid token + mock_client.get_devices.assert_called_once() + mock_device_client.subscribe.assert_called_once() + mock_device_client.get_current_status.assert_called_once() + + 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 + mock_device_client.disconnect.assert_called_once() + + +@pytest.mark.freeze_time("2025-02-15 00:00:00") +async def test_refresh_authentication_on_load( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_client: MagicMock, + mock_device_client: MagicMock, +) -> None: + """Test expired access token refreshed when needed to load config entry.""" + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.LOADED + mock_client.refresh_token.assert_called_once() + + # Check loading continued as expected after refreshing token + mock_client.get_devices.assert_called_once() + mock_device_client.subscribe.assert_called_once() + mock_device_client.get_current_status.assert_called_once() + + +@pytest.mark.freeze_time("2025-03-01 00:00:00") +async def test_refresh_token_error_aborts( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_client: MagicMock, +) -> None: + """Test expired refresh token aborting config entry loading.""" + mock_client.refresh_token.side_effect = LetPotAuthenticationException + + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + mock_client.refresh_token.assert_called_once() + mock_client.get_devices.assert_not_called() + + +@pytest.mark.parametrize( + ("exception", "config_entry_state"), + [ + (LetPotAuthenticationException, ConfigEntryState.SETUP_ERROR), + (LetPotConnectionException, ConfigEntryState.SETUP_RETRY), + ], +) +async def test_get_devices_exceptions( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_client: MagicMock, + mock_device_client: MagicMock, + exception: Exception, + config_entry_state: ConfigEntryState, +) -> None: + """Test config entry errors if an exception is raised when getting devices.""" + mock_client.get_devices.side_effect = exception + + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is config_entry_state + mock_client.get_devices.assert_called_once() + mock_device_client.subscribe.assert_not_called() From 941461b4274835c16d4cc085bac5b5e9d8b8a9a9 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Wed, 29 Jan 2025 02:43:41 +1000 Subject: [PATCH 0149/3148] Add streaming to Teslemetry number platform (#136048) --- homeassistant/components/teslemetry/number.py | 151 +++++++++++++++--- tests/components/teslemetry/__init__.py | 13 ++ .../teslemetry/snapshots/test_number.ambr | 6 + tests/components/teslemetry/test_number.py | 39 ++++- 4 files changed, 182 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/teslemetry/number.py b/homeassistant/components/teslemetry/number.py index 9ba9c28b199..c44028f2da7 100644 --- a/homeassistant/components/teslemetry/number.py +++ b/homeassistant/components/teslemetry/number.py @@ -9,20 +9,33 @@ from typing import Any from tesla_fleet_api import EnergySpecific, VehicleSpecific from tesla_fleet_api.const import Scope +from teslemetry_stream import TeslemetryStreamVehicle from homeassistant.components.number import ( NumberDeviceClass, NumberEntity, NumberEntityDescription, NumberMode, + RestoreNumber, +) +from homeassistant.const import ( + PERCENTAGE, + PRECISION_WHOLE, + STATE_UNAVAILABLE, + STATE_UNKNOWN, + UnitOfElectricCurrent, ) -from homeassistant.const import PERCENTAGE, PRECISION_WHOLE, UnitOfElectricCurrent from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.icon import icon_for_battery_level from . import TeslemetryConfigEntry -from .entity import TeslemetryEnergyInfoEntity, TeslemetryVehicleEntity +from .entity import ( + TeslemetryEnergyInfoEntity, + TeslemetryRootEntity, + TeslemetryVehicleEntity, + TeslemetryVehicleStreamEntity, +) from .helpers import handle_command, handle_vehicle_command from .models import TeslemetryEnergyData, TeslemetryVehicleData @@ -33,12 +46,22 @@ PARALLEL_UPDATES = 0 class TeslemetryNumberVehicleEntityDescription(NumberEntityDescription): """Describes Teslemetry Number entity.""" - func: Callable[[VehicleSpecific, float], Awaitable[Any]] - native_min_value: float - native_max_value: float + func: Callable[[VehicleSpecific, int], Awaitable[Any]] min_key: str | None = None max_key: str + native_min_value: float + native_max_value: float scopes: list[Scope] + value_listener: Callable[ + [TeslemetryStreamVehicle, Callable[[int | None], None]], + Callable[[], None], + ] + max_listener: ( + Callable[ + [TeslemetryStreamVehicle, Callable[[int | None], None]], Callable[[], None] + ] + | None + ) = None VEHICLE_DESCRIPTIONS: tuple[TeslemetryNumberVehicleEntityDescription, ...] = ( @@ -52,7 +75,9 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryNumberVehicleEntityDescription, ...] = ( mode=NumberMode.AUTO, max_key="charge_state_charge_current_request_max", func=lambda api, value: api.set_charging_amps(value), - scopes=[Scope.VEHICLE_CHARGING_CMDS], + scopes=[Scope.VEHICLE_CHARGING_CMDS, Scope.VEHICLE_CMDS], + value_listener=lambda x, y: x.listen_ChargeCurrentRequest(y), + max_listener=lambda x, y: x.listen_ChargeCurrentRequestMax(y), ), TeslemetryNumberVehicleEntityDescription( key="charge_state_charge_limit_soc", @@ -62,10 +87,10 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryNumberVehicleEntityDescription, ...] = ( native_unit_of_measurement=PERCENTAGE, device_class=NumberDeviceClass.BATTERY, mode=NumberMode.AUTO, - min_key="charge_state_charge_limit_soc_min", max_key="charge_state_charge_limit_soc_max", func=lambda api, value: api.set_charge_limit(value), scopes=[Scope.VEHICLE_CHARGING_CMDS, Scope.VEHICLE_CMDS], + value_listener=lambda x, y: x.listen_ChargeLimitSoc(y), ), ) @@ -76,16 +101,29 @@ class TeslemetryNumberBatteryEntityDescription(NumberEntityDescription): func: Callable[[EnergySpecific, float], Awaitable[Any]] requires: str | None = None + scopes: list[Scope] ENERGY_INFO_DESCRIPTIONS: tuple[TeslemetryNumberBatteryEntityDescription, ...] = ( TeslemetryNumberBatteryEntityDescription( key="backup_reserve_percent", + native_step=PRECISION_WHOLE, + native_min_value=0, + native_max_value=100, + device_class=NumberDeviceClass.BATTERY, + native_unit_of_measurement=PERCENTAGE, + scopes=[Scope.ENERGY_CMDS], func=lambda api, value: api.backup(int(value)), requires="components_battery", ), TeslemetryNumberBatteryEntityDescription( key="off_grid_vehicle_charging_reserve_percent", + native_step=PRECISION_WHOLE, + native_min_value=0, + native_max_value=100, + device_class=NumberDeviceClass.BATTERY, + native_unit_of_measurement=PERCENTAGE, + scopes=[Scope.ENERGY_CMDS], func=lambda api, value: api.off_grid_vehicle_charging_reserve(int(value)), requires="components_off_grid_vehicle_charging_reserve_supported", ), @@ -101,8 +139,14 @@ async def async_setup_entry( async_add_entities( chain( - ( # Add vehicle entities - TeslemetryVehicleNumberEntity( + ( + TeslemetryPollingNumberEntity( + vehicle, + description, + entry.runtime_data.scopes, + ) + if vehicle.api.pre2021 or vehicle.firmware < "2024.26" + else TeslemetryStreamingNumberEntity( vehicle, description, entry.runtime_data.scopes, @@ -110,7 +154,7 @@ async def async_setup_entry( for vehicle in entry.runtime_data.vehicles for description in VEHICLE_DESCRIPTIONS ), - ( # Add energy site entities + ( TeslemetryEnergyInfoNumberSensorEntity( energysite, description, @@ -125,11 +169,25 @@ async def async_setup_entry( ) -class TeslemetryVehicleNumberEntity(TeslemetryVehicleEntity, NumberEntity): +class TeslemetryVehicleNumberEntity(TeslemetryRootEntity, NumberEntity): """Vehicle number entity base class.""" entity_description: TeslemetryNumberVehicleEntityDescription + async def async_set_native_value(self, value: float) -> None: + """Set new value.""" + value = int(value) + self.raise_for_scope(self.entity_description.scopes[0]) + await handle_vehicle_command(self.entity_description.func(self.api, value)) + self._attr_native_value = value + self.async_write_ha_state() + + +class TeslemetryPollingNumberEntity( + TeslemetryVehicleEntity, TeslemetryVehicleNumberEntity +): + """Vehicle polling number entity.""" + def __init__( self, data: TeslemetryVehicleData, @@ -148,26 +206,67 @@ class TeslemetryVehicleNumberEntity(TeslemetryVehicleEntity, NumberEntity): """Update the attributes of the entity.""" self._attr_native_value = self._value - if (min_key := self.entity_description.min_key) is not None: - self._attr_native_min_value = self.get_number( - min_key, - self.entity_description.native_min_value, - ) - else: - self._attr_native_min_value = self.entity_description.native_min_value - self._attr_native_max_value = self.get_number( self.entity_description.max_key, self.entity_description.native_max_value, ) - async def async_set_native_value(self, value: float) -> None: - """Set new value.""" - value = int(value) - self.raise_for_scope(self.entity_description.scopes[0]) - await self.wake_up_if_asleep() - await handle_vehicle_command(self.entity_description.func(self.api, value)) - self._attr_native_value = value + +class TeslemetryStreamingNumberEntity( + TeslemetryVehicleStreamEntity, TeslemetryVehicleNumberEntity, RestoreNumber +): + """Number entity for current charge.""" + + entity_description: TeslemetryNumberVehicleEntityDescription + + def __init__( + self, + data: TeslemetryVehicleData, + description: TeslemetryNumberVehicleEntityDescription, + scopes: list[Scope], + ) -> None: + """Initialize the Number entity.""" + self.scoped = any(scope in scopes for scope in description.scopes) + self.entity_description = description + self._attr_native_max_value = self.entity_description.native_max_value + super().__init__(data, description.key) + + async def async_added_to_hass(self) -> None: + """Handle entity which will be added.""" + await super().async_added_to_hass() + + # Restore state + if (last_state := await self.async_get_last_state()) and ( + last_number_data := await self.async_get_last_number_data() + ): + if last_state.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE): + self._attr_native_value = last_number_data.native_value + if last_number_data.native_max_value: + self._attr_native_max_value = last_number_data.native_max_value + + # Add listeners + self.async_on_remove( + self.entity_description.value_listener( + self.vehicle.stream_vehicle, self._value_callback + ) + ) + if self.entity_description.max_listener: + self.async_on_remove( + self.entity_description.max_listener( + self.vehicle.stream_vehicle, self._max_callback + ) + ) + + def _value_callback(self, value: int | None) -> None: + """Update the value of the entity.""" + self._attr_native_value = None if value is None else value + self.async_write_ha_state() + + def _max_callback(self, value: int | None) -> None: + """Update the value of the entity.""" + self._attr_native_max_value = ( + self.entity_description.native_max_value if value is None else value + ) self.async_write_ha_state() diff --git a/tests/components/teslemetry/__init__.py b/tests/components/teslemetry/__init__.py index b6b9df7eb4b..b5aae06168c 100644 --- a/tests/components/teslemetry/__init__.py +++ b/tests/components/teslemetry/__init__.py @@ -32,6 +32,19 @@ async def setup_platform(hass: HomeAssistant, platforms: list[Platform] | None = return mock_entry +async def reload_platform( + hass: HomeAssistant, entry: MockConfigEntry, platforms: list[Platform] | None = None +): + """Reload the Teslemetry platform.""" + + if platforms is None: + await hass.config_entries.async_reload(entry.entry_id) + else: + with patch("homeassistant.components.teslemetry.PLATFORMS", platforms): + await hass.config_entries.async_reload(entry.entry_id) + await hass.async_block_till_done() + + def assert_entities( hass: HomeAssistant, entry_id: str, diff --git a/tests/components/teslemetry/snapshots/test_number.ambr b/tests/components/teslemetry/snapshots/test_number.ambr index 0f30daf635e..8e8f10397d0 100644 --- a/tests/components/teslemetry/snapshots/test_number.ambr +++ b/tests/components/teslemetry/snapshots/test_number.ambr @@ -229,3 +229,9 @@ 'state': '80', }) # --- +# name: test_number_streaming[number.test_charge_current-state] + '24' +# --- +# name: test_number_streaming[number.test_charge_limit-state] + '99' +# --- diff --git a/tests/components/teslemetry/test_number.py b/tests/components/teslemetry/test_number.py index 65c03514d22..95eed5a3f1e 100644 --- a/tests/components/teslemetry/test_number.py +++ b/tests/components/teslemetry/test_number.py @@ -4,6 +4,7 @@ from unittest.mock import AsyncMock, patch import pytest from syrupy.assertion import SnapshotAssertion +from teslemetry_stream import Signal from homeassistant.components.number import ( ATTR_VALUE, @@ -14,7 +15,7 @@ from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from . import assert_entities, setup_platform +from . import assert_entities, reload_platform, setup_platform from .const import COMMAND_OK, VEHICLE_DATA_ALT @@ -23,6 +24,7 @@ async def test_number( hass: HomeAssistant, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, + mock_legacy: AsyncMock, ) -> None: """Tests that the number entities are correct.""" @@ -100,3 +102,38 @@ async def test_number_services( state = hass.states.get(entity_id) assert state.state == "88" call.assert_called_once() + + +async def test_number_streaming( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_vehicle_data: AsyncMock, + mock_add_listener: AsyncMock, +) -> None: + """Tests that the number entities with streaming are correct.""" + + entry = await setup_platform(hass, [Platform.NUMBER]) + + # Stream update + mock_add_listener.send( + { + "vin": VEHICLE_DATA_ALT["response"]["vin"], + "data": { + Signal.CHARGE_CURRENT_REQUEST: 24, + Signal.CHARGE_CURRENT_REQUEST_MAX: 32, + Signal.CHARGE_LIMIT_SOC: 99, + }, + "createdAt": "2024-10-04T10:45:17.537Z", + } + ) + await hass.async_block_till_done() + + await reload_platform(hass, entry, [Platform.NUMBER]) + + # Assert the entities restored their values + for entity_id in ( + "number.test_charge_current", + "number.test_charge_limit", + ): + state = hass.states.get(entity_id) + assert state.state == snapshot(name=f"{entity_id}-state") From 77d42f6c576ca4b6c5b428b7517895e66425c524 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Wed, 29 Jan 2025 02:44:05 +1000 Subject: [PATCH 0150/3148] Add streaming to Teslemetry lock platform (#136037) --- homeassistant/components/teslemetry/lock.py | 174 +++++++++++++++--- tests/components/teslemetry/__init__.py | 4 +- .../teslemetry/fixtures/vehicle_data_alt.json | 2 +- .../teslemetry/snapshots/test_lock.ambr | 106 +++++++++++ tests/components/teslemetry/test_lock.py | 79 +++++++- 5 files changed, 330 insertions(+), 35 deletions(-) diff --git a/homeassistant/components/teslemetry/lock.py b/homeassistant/components/teslemetry/lock.py index 4600391145b..18b88273bec 100644 --- a/homeassistant/components/teslemetry/lock.py +++ b/homeassistant/components/teslemetry/lock.py @@ -2,6 +2,7 @@ from __future__ import annotations +from itertools import chain from typing import Any from tesla_fleet_api.const import Scope @@ -10,10 +11,15 @@ from homeassistant.components.lock import LockEntity from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.restore_state import RestoreEntity from . import TeslemetryConfigEntry from .const import DOMAIN -from .entity import TeslemetryVehicleEntity +from .entity import ( + TeslemetryRootEntity, + TeslemetryVehicleEntity, + TeslemetryVehicleStreamEntity, +) from .helpers import handle_vehicle_command from .models import TeslemetryVehicleData @@ -30,31 +36,38 @@ async def async_setup_entry( """Set up the Teslemetry lock platform from a config entry.""" async_add_entities( - klass(vehicle, Scope.VEHICLE_CMDS in entry.runtime_data.scopes) - for klass in ( - TeslemetryVehicleLockEntity, - TeslemetryCableLockEntity, + chain( + ( + TeslemetryPollingVehicleLockEntity( + vehicle, Scope.VEHICLE_CMDS in entry.runtime_data.scopes + ) + if vehicle.api.pre2021 or vehicle.firmware < "2024.26" + else TeslemetryStreamingVehicleLockEntity( + vehicle, Scope.VEHICLE_CMDS in entry.runtime_data.scopes + ) + for vehicle in entry.runtime_data.vehicles + ), + ( + TeslemetryPollingCableLockEntity( + vehicle, Scope.VEHICLE_CMDS in entry.runtime_data.scopes + ) + if vehicle.api.pre2021 or vehicle.firmware < "2024.26" + else TeslemetryStreamingCableLockEntity( + vehicle, Scope.VEHICLE_CMDS in entry.runtime_data.scopes + ) + for vehicle in entry.runtime_data.vehicles + ), ) - for vehicle in entry.runtime_data.vehicles ) -class TeslemetryVehicleLockEntity(TeslemetryVehicleEntity, LockEntity): - """Lock entity for Teslemetry.""" - - def __init__(self, data: TeslemetryVehicleData, scoped: bool) -> None: - """Initialize the lock.""" - super().__init__(data, "vehicle_state_locked") - self.scoped = scoped - - def _async_update_attrs(self) -> None: - """Update entity attributes.""" - self._attr_is_locked = self._value +class TeslemetryVehicleLockEntity(TeslemetryRootEntity, LockEntity): + """Base vehicle lock entity for Teslemetry.""" async def async_lock(self, **kwargs: Any) -> None: """Lock the doors.""" self.raise_for_scope(Scope.VEHICLE_CMDS) - await self.wake_up_if_asleep() + await handle_vehicle_command(self.api.door_lock()) self._attr_is_locked = True self.async_write_ha_state() @@ -62,27 +75,65 @@ class TeslemetryVehicleLockEntity(TeslemetryVehicleEntity, LockEntity): async def async_unlock(self, **kwargs: Any) -> None: """Unlock the doors.""" self.raise_for_scope(Scope.VEHICLE_CMDS) - await self.wake_up_if_asleep() + await handle_vehicle_command(self.api.door_unlock()) self._attr_is_locked = False self.async_write_ha_state() -class TeslemetryCableLockEntity(TeslemetryVehicleEntity, LockEntity): - """Cable Lock entity for Teslemetry.""" +class TeslemetryPollingVehicleLockEntity( + TeslemetryVehicleEntity, TeslemetryVehicleLockEntity +): + """Polling vehicle lock entity for Teslemetry.""" - def __init__( - self, - data: TeslemetryVehicleData, - scoped: bool, - ) -> None: - """Initialize the lock.""" - super().__init__(data, "charge_state_charge_port_latch") + def __init__(self, data: TeslemetryVehicleData, scoped: bool) -> None: + """Initialize the sensor.""" + super().__init__( + data, + "vehicle_state_locked", + ) self.scoped = scoped def _async_update_attrs(self) -> None: """Update entity attributes.""" - self._attr_is_locked = self._value == ENGAGED + self._attr_is_locked = self._value + + +class TeslemetryStreamingVehicleLockEntity( + TeslemetryVehicleStreamEntity, TeslemetryVehicleLockEntity, RestoreEntity +): + """Streaming vehicle lock entity for Teslemetry.""" + + def __init__(self, data: TeslemetryVehicleData, scoped: bool) -> None: + """Initialize the sensor.""" + super().__init__( + data, + "vehicle_state_locked", + ) + self.scoped = scoped + + async def async_added_to_hass(self) -> None: + """Handle entity which will be added.""" + await super().async_added_to_hass() + + # Restore state + if (state := await self.async_get_last_state()) is not None: + if state.state == "locked": + self._attr_is_locked = True + elif state.state == "unlocked": + self._attr_is_locked = False + + # Add streaming listener + self.async_on_remove(self.vehicle.stream_vehicle.listen_Locked(self._callback)) + + def _callback(self, value: bool | None) -> None: + """Update entity attributes.""" + self._attr_is_locked = value + self.async_write_ha_state() + + +class TeslemetryCableLockEntity(TeslemetryRootEntity, LockEntity): + """Base cable Lock entity for Teslemetry.""" async def async_lock(self, **kwargs: Any) -> None: """Charge cable Lock cannot be manually locked.""" @@ -95,7 +146,70 @@ class TeslemetryCableLockEntity(TeslemetryVehicleEntity, LockEntity): async def async_unlock(self, **kwargs: Any) -> None: """Unlock charge cable lock.""" self.raise_for_scope(Scope.VEHICLE_CMDS) - await self.wake_up_if_asleep() + await handle_vehicle_command(self.api.charge_port_door_open()) self._attr_is_locked = False self.async_write_ha_state() + + +class TeslemetryPollingCableLockEntity( + TeslemetryVehicleEntity, TeslemetryCableLockEntity +): + """Polling cable lock entity for Teslemetry.""" + + def __init__( + self, + data: TeslemetryVehicleData, + scoped: bool, + ) -> None: + """Initialize the sensor.""" + super().__init__( + data, + "charge_state_charge_port_latch", + ) + self.scoped = scoped + + def _async_update_attrs(self) -> None: + """Update entity attributes.""" + if self._value is None: + self._attr_is_locked = None + self._attr_is_locked = self._value == ENGAGED + + +class TeslemetryStreamingCableLockEntity( + TeslemetryVehicleStreamEntity, TeslemetryCableLockEntity, RestoreEntity +): + """Streaming cable lock entity for Teslemetry.""" + + def __init__( + self, + data: TeslemetryVehicleData, + scoped: bool, + ) -> None: + """Initialize the sensor.""" + super().__init__( + data, + "charge_state_charge_port_latch", + ) + self.scoped = scoped + + async def async_added_to_hass(self) -> None: + """Handle entity which will be added.""" + await super().async_added_to_hass() + + # Restore state + if (state := await self.async_get_last_state()) is not None: + if state.state == "locked": + self._attr_is_locked = True + elif state.state == "unlocked": + self._attr_is_locked = False + + # Add streaming listener + self.async_on_remove( + self.vehicle.stream_vehicle.listen_ChargePortLatch(self._callback) + ) + + def _callback(self, value: str | None) -> None: + """Update entity attributes.""" + self._attr_is_locked = None if value is None else value == ENGAGED + self.async_write_ha_state() diff --git a/tests/components/teslemetry/__init__.py b/tests/components/teslemetry/__init__.py index b5aae06168c..59727926f03 100644 --- a/tests/components/teslemetry/__init__.py +++ b/tests/components/teslemetry/__init__.py @@ -14,7 +14,9 @@ from .const import CONFIG from tests.common import MockConfigEntry -async def setup_platform(hass: HomeAssistant, platforms: list[Platform] | None = None): +async def setup_platform( + hass: HomeAssistant, platforms: list[Platform] | None = None +) -> MockConfigEntry: """Set up the Teslemetry platform.""" mock_entry = MockConfigEntry( diff --git a/tests/components/teslemetry/fixtures/vehicle_data_alt.json b/tests/components/teslemetry/fixtures/vehicle_data_alt.json index 25b3878f4dd..ec524614d49 100644 --- a/tests/components/teslemetry/fixtures/vehicle_data_alt.json +++ b/tests/components/teslemetry/fixtures/vehicle_data_alt.json @@ -35,7 +35,7 @@ "charge_port_cold_weather_mode": false, "charge_port_color": "", "charge_port_door_open": true, - "charge_port_latch": "Engaged", + "charge_port_latch": null, "charge_rate": 0, "charger_actual_current": 0, "charger_phases": null, diff --git a/tests/components/teslemetry/snapshots/test_lock.ambr b/tests/components/teslemetry/snapshots/test_lock.ambr index 2130c4d9574..bb5693fe3ab 100644 --- a/tests/components/teslemetry/snapshots/test_lock.ambr +++ b/tests/components/teslemetry/snapshots/test_lock.ambr @@ -93,3 +93,109 @@ 'state': 'unlocked', }) # --- +# name: test_lock_alt[lock.test_charge_cable_lock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'lock', + 'entity_category': None, + 'entity_id': 'lock.test_charge_cable_lock', + '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': 'Charge cable lock', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charge_state_charge_port_latch', + 'unique_id': 'LRW3F7EK4NC700000-charge_state_charge_port_latch', + 'unit_of_measurement': None, + }) +# --- +# name: test_lock_alt[lock.test_charge_cable_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Charge cable lock', + 'supported_features': , + }), + 'context': , + 'entity_id': 'lock.test_charge_cable_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unlocked', + }) +# --- +# name: test_lock_alt[lock.test_lock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'lock', + 'entity_category': None, + 'entity_id': 'lock.test_lock', + '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': 'Lock', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'vehicle_state_locked', + 'unique_id': 'LRW3F7EK4NC700000-vehicle_state_locked', + 'unit_of_measurement': None, + }) +# --- +# name: test_lock_alt[lock.test_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Lock', + 'supported_features': , + }), + 'context': , + 'entity_id': 'lock.test_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unlocked', + }) +# --- +# name: test_lock_streaming[lock.test_charge_cable_lock-locked] + 'locked' +# --- +# name: test_lock_streaming[lock.test_charge_cable_lock-unlocked] + 'unlocked' +# --- +# name: test_lock_streaming[lock.test_lock-locked] + 'locked' +# --- +# name: test_lock_streaming[lock.test_lock-unlocked] + 'unlocked' +# --- diff --git a/tests/components/teslemetry/test_lock.py b/tests/components/teslemetry/test_lock.py index f7c9fea1400..848eee82c39 100644 --- a/tests/components/teslemetry/test_lock.py +++ b/tests/components/teslemetry/test_lock.py @@ -1,9 +1,10 @@ """Test the Teslemetry lock platform.""" -from unittest.mock import patch +from unittest.mock import AsyncMock, patch import pytest from syrupy.assertion import SnapshotAssertion +from teslemetry_stream.const import Signal from homeassistant.components.lock import ( DOMAIN as LOCK_DOMAIN, @@ -16,14 +17,15 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import entity_registry as er -from . import assert_entities, setup_platform -from .const import COMMAND_OK +from . import assert_entities, reload_platform, setup_platform +from .const import COMMAND_OK, VEHICLE_DATA_ALT async def test_lock( hass: HomeAssistant, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, + mock_legacy: AsyncMock, ) -> None: """Tests that the lock entities are correct.""" @@ -31,6 +33,20 @@ async def test_lock( assert_entities(hass, entry.entry_id, entity_registry, snapshot) +async def test_lock_alt( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + mock_vehicle_data: AsyncMock, + mock_legacy: AsyncMock, +) -> None: + """Tests that the lock entities are correct.""" + + mock_vehicle_data.return_value = VEHICLE_DATA_ALT + entry = await setup_platform(hass, [Platform.LOCK]) + assert_entities(hass, entry.entry_id, entity_registry, snapshot) + + async def test_lock_services( hass: HomeAssistant, ) -> None: @@ -91,3 +107,60 @@ async def test_lock_services( state = hass.states.get(entity_id) assert state.state == LockState.UNLOCKED call.assert_called_once() + + +async def test_lock_streaming( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_vehicle_data: AsyncMock, + mock_add_listener: AsyncMock, +) -> None: + """Tests that the lock entities with streaming are correct.""" + + entry = await setup_platform(hass, [Platform.LOCK]) + + # Stream update + mock_add_listener.send( + { + "vin": VEHICLE_DATA_ALT["response"]["vin"], + "data": { + Signal.LOCKED: True, + Signal.CHARGE_PORT_LATCH: "ChargePortLatchEngaged", + }, + "createdAt": "2024-10-04T10:45:17.537Z", + } + ) + await hass.async_block_till_done() + + await reload_platform(hass, entry, [Platform.LOCK]) + + # Assert the entities restored their values + for entity_id in ( + "lock.test_lock", + "lock.test_charge_cable_lock", + ): + state = hass.states.get(entity_id) + assert state.state == snapshot(name=f"{entity_id}-locked") + + # Stream update + mock_add_listener.send( + { + "vin": VEHICLE_DATA_ALT["response"]["vin"], + "data": { + Signal.LOCKED: False, + Signal.CHARGE_PORT_LATCH: "ChargePortLatchDisengaged", + }, + "createdAt": "2024-10-04T10:45:17.537Z", + } + ) + await hass.async_block_till_done() + + await reload_platform(hass, entry, [Platform.LOCK]) + + # Assert the entities restored their values + for entity_id in ( + "lock.test_lock", + "lock.test_charge_cable_lock", + ): + state = hass.states.get(entity_id) + assert state.state == snapshot(name=f"{entity_id}-unlocked") From c3db493f34023adc9d249d456485cb44d85b58a0 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Tue, 28 Jan 2025 16:50:06 +0000 Subject: [PATCH 0151/3148] Mark tplink quality_scale platinum (#136456) --- homeassistant/components/tplink/manifest.json | 1 + homeassistant/components/tplink/quality_scale.yaml | 4 ++-- script/hassfest/quality_scale.py | 1 - 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/tplink/manifest.json b/homeassistant/components/tplink/manifest.json index f55dfda1664..6f9eefbdabb 100644 --- a/homeassistant/components/tplink/manifest.json +++ b/homeassistant/components/tplink/manifest.json @@ -300,5 +300,6 @@ "documentation": "https://www.home-assistant.io/integrations/tplink", "iot_class": "local_polling", "loggers": ["kasa"], + "quality_scale": "platinum", "requirements": ["python-kasa[speedups]==0.10.0"] } diff --git a/homeassistant/components/tplink/quality_scale.yaml b/homeassistant/components/tplink/quality_scale.yaml index ced9cbcc831..f120945771c 100644 --- a/homeassistant/components/tplink/quality_scale.yaml +++ b/homeassistant/components/tplink/quality_scale.yaml @@ -44,12 +44,12 @@ rules: entity-category: done entity-disabled-by-default: done discovery: done - stale-devices: todo + stale-devices: done diagnostics: done exception-translations: done icon-translations: done reconfiguration-flow: done - dynamic-devices: todo + dynamic-devices: done discovery-update-info: done repair-issues: done docs-use-cases: done diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index 706a482523a..3eedc43f613 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -2131,7 +2131,6 @@ INTEGRATIONS_WITHOUT_SCALE = [ "torque", "touchline", "touchline_sl", - "tplink", "tplink_lte", "tplink_omada", "traccar", From a8c382566cae06c43a33c4d3d44c9bc92ef7b4d8 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Tue, 28 Jan 2025 17:57:02 +0100 Subject: [PATCH 0152/3148] Register service actions in async_setup of AVM Fritz!Box tools (#136380) * move service setup into integrations async_setup * move back to own module * add service test * remove unneccessary CONFIG_SCHEMA * remove unused constant FRITZ_SERVICES * Revert "remove unneccessary CONFIG_SCHEMA" This reverts commit cce1ba76a067895d62d0485479002c7bebbfb511. * remove useless CONFIG_SCHEMA from services.py * move logic of `service_fritzbox` into services.py * add more service tests * simplify logic, use ServiceValidationError --- homeassistant/components/fritz/__init__.py | 16 ++- homeassistant/components/fritz/const.py | 3 - homeassistant/components/fritz/coordinator.py | 34 +---- .../components/fritz/quality_scale.yaml | 4 +- homeassistant/components/fritz/services.py | 117 +++++++-------- tests/components/fritz/test_services.py | 134 ++++++++++++++++++ 6 files changed, 197 insertions(+), 111 deletions(-) create mode 100644 tests/components/fritz/test_services.py diff --git a/homeassistant/components/fritz/__init__.py b/homeassistant/components/fritz/__init__.py index 1e1830ca1c1..25888328cd2 100644 --- a/homeassistant/components/fritz/__init__.py +++ b/homeassistant/components/fritz/__init__.py @@ -12,6 +12,8 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.typing import ConfigType from .const import ( DATA_FRITZ, @@ -22,10 +24,18 @@ from .const import ( PLATFORMS, ) from .coordinator import AvmWrapper, FritzData -from .services import async_setup_services, async_unload_services +from .services import async_setup_services _LOGGER = logging.getLogger(__name__) +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up fritzboxtools integration.""" + await async_setup_services(hass) + return True + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up fritzboxtools from config entry.""" @@ -65,8 +75,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # Load the other platforms like switch await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - await async_setup_services(hass) - return True @@ -84,8 +92,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if unload_ok: hass.data[DOMAIN].pop(entry.entry_id) - await async_unload_services(hass) - return unload_ok diff --git a/homeassistant/components/fritz/const.py b/homeassistant/components/fritz/const.py index 9a266507c25..f8f5b43f4b1 100644 --- a/homeassistant/components/fritz/const.py +++ b/homeassistant/components/fritz/const.py @@ -56,9 +56,6 @@ ERROR_CANNOT_CONNECT = "cannot_connect" ERROR_UPNP_NOT_CONFIGURED = "upnp_not_configured" ERROR_UNKNOWN = "unknown_error" -FRITZ_SERVICES = "fritz_services" -SERVICE_SET_GUEST_WIFI_PW = "set_guest_wifi_password" - SWITCH_TYPE_DEFLECTION = "CallDeflection" SWITCH_TYPE_PORTFORWARD = "PortForward" SWITCH_TYPE_PROFILE = "Profile" diff --git a/homeassistant/components/fritz/coordinator.py b/homeassistant/components/fritz/coordinator.py index 52bff67c229..7f8ae6c5b3c 100644 --- a/homeassistant/components/fritz/coordinator.py +++ b/homeassistant/components/fritz/coordinator.py @@ -16,11 +16,10 @@ from fritzconnection.core.exceptions import ( FritzActionError, FritzConnectionException, FritzSecurityError, - FritzServiceError, ) from fritzconnection.lib.fritzhosts import FritzHosts from fritzconnection.lib.fritzstatus import FritzStatus -from fritzconnection.lib.fritzwlan import DEFAULT_PASSWORD_LENGTH, FritzGuestWLAN +from fritzconnection.lib.fritzwlan import FritzGuestWLAN import xmltodict from homeassistant.components.device_tracker import ( @@ -29,7 +28,7 @@ from homeassistant.components.device_tracker import ( DOMAIN as DEVICE_TRACKER_DOMAIN, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC @@ -46,7 +45,6 @@ from .const import ( DEFAULT_USERNAME, DOMAIN, FRITZ_EXCEPTIONS, - SERVICE_SET_GUEST_WIFI_PW, MeshRoles, ) @@ -693,34 +691,6 @@ class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]): device.id, remove_config_entry_id=config_entry.entry_id ) - async def service_fritzbox( - self, service_call: ServiceCall, config_entry: ConfigEntry - ) -> None: - """Define FRITZ!Box services.""" - _LOGGER.debug("FRITZ!Box service: %s", service_call.service) - - if not self.connection: - raise HomeAssistantError( - translation_domain=DOMAIN, translation_key="unable_to_connect" - ) - - try: - if service_call.service == SERVICE_SET_GUEST_WIFI_PW: - await self.async_trigger_set_guest_password( - service_call.data.get("password"), - service_call.data.get("length", DEFAULT_PASSWORD_LENGTH), - ) - return - - except (FritzServiceError, FritzActionError) as ex: - raise HomeAssistantError( - translation_domain=DOMAIN, translation_key="service_parameter_unknown" - ) from ex - except FritzConnectionException as ex: - raise HomeAssistantError( - translation_domain=DOMAIN, translation_key="service_not_supported" - ) from ex - class AvmWrapper(FritzBoxTools): """Setup AVM wrapper for API calls.""" diff --git a/homeassistant/components/fritz/quality_scale.yaml b/homeassistant/components/fritz/quality_scale.yaml index 06c572f93a6..d6fadd3a20e 100644 --- a/homeassistant/components/fritz/quality_scale.yaml +++ b/homeassistant/components/fritz/quality_scale.yaml @@ -1,8 +1,6 @@ rules: # Bronze - action-setup: - status: todo - comment: still in async_setup_entry, needs to be moved to async_setup + action-setup: done appropriate-polling: done brands: done common-modules: done diff --git a/homeassistant/components/fritz/services.py b/homeassistant/components/fritz/services.py index bace7480ba5..ac542be8631 100644 --- a/homeassistant/components/fritz/services.py +++ b/homeassistant/components/fritz/services.py @@ -1,21 +1,25 @@ """Services for Fritz integration.""" -from __future__ import annotations - import logging +from fritzconnection.core.exceptions import ( + FritzActionError, + FritzConnectionException, + FritzServiceError, +) +from fritzconnection.lib.fritzwlan import DEFAULT_PASSWORD_LENGTH import voluptuous as vol -from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant, ServiceCall -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers.service import async_extract_config_entry_ids -from .const import DOMAIN, FRITZ_SERVICES, SERVICE_SET_GUEST_WIFI_PW +from .const import DOMAIN from .coordinator import AvmWrapper _LOGGER = logging.getLogger(__name__) +SERVICE_SET_GUEST_WIFI_PW = "set_guest_wifi_password" SERVICE_SCHEMA_SET_GUEST_WIFI_PW = vol.Schema( { vol.Required("device_id"): str, @@ -24,71 +28,48 @@ SERVICE_SCHEMA_SET_GUEST_WIFI_PW = vol.Schema( } ) -SERVICE_LIST: list[tuple[str, vol.Schema | None]] = [ - (SERVICE_SET_GUEST_WIFI_PW, SERVICE_SCHEMA_SET_GUEST_WIFI_PW), -] + +async def _async_set_guest_wifi_password(service_call: ServiceCall) -> None: + """Call Fritz set guest wifi password service.""" + hass = service_call.hass + target_entry_ids = await async_extract_config_entry_ids(hass, service_call) + target_entries = [ + loaded_entry + for loaded_entry in hass.config_entries.async_loaded_entries(DOMAIN) + if loaded_entry.entry_id in target_entry_ids + ] + + if not target_entries: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="config_entry_not_found", + translation_placeholders={"service": service_call.service}, + ) + + for target_entry in target_entries: + _LOGGER.debug("Executing service %s", service_call.service) + avm_wrapper: AvmWrapper = hass.data[DOMAIN][target_entry.entry_id] + try: + await avm_wrapper.async_trigger_set_guest_password( + service_call.data.get("password"), + service_call.data.get("length", DEFAULT_PASSWORD_LENGTH), + ) + except (FritzServiceError, FritzActionError) as ex: + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="service_parameter_unknown" + ) from ex + except FritzConnectionException as ex: + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="service_not_supported" + ) from ex async def async_setup_services(hass: HomeAssistant) -> None: """Set up services for Fritz integration.""" - for service, _ in SERVICE_LIST: - if hass.services.has_service(DOMAIN, service): - return - - async def async_call_fritz_service(service_call: ServiceCall) -> None: - """Call correct Fritz service.""" - - if not ( - fritzbox_entry_ids := await _async_get_configured_avm_device( - hass, service_call - ) - ): - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="config_entry_not_found", - translation_placeholders={"service": service_call.service}, - ) - - for entry_id in fritzbox_entry_ids: - _LOGGER.debug("Executing service %s", service_call.service) - avm_wrapper: AvmWrapper = hass.data[DOMAIN][entry_id] - if config_entry := hass.config_entries.async_get_entry(entry_id): - await avm_wrapper.service_fritzbox(service_call, config_entry) - else: - _LOGGER.error( - "Executing service %s failed, no config entry found", - service_call.service, - ) - - for service, schema in SERVICE_LIST: - hass.services.async_register(DOMAIN, service, async_call_fritz_service, schema) - - -async def _async_get_configured_avm_device( - hass: HomeAssistant, service_call: ServiceCall -) -> list: - """Get FritzBoxTools class from config entry.""" - - list_entry_id: list = [] - for entry_id in await async_extract_config_entry_ids(hass, service_call): - config_entry = hass.config_entries.async_get_entry(entry_id) - if ( - config_entry - and config_entry.domain == DOMAIN - and config_entry.state == ConfigEntryState.LOADED - ): - list_entry_id.append(entry_id) - return list_entry_id - - -async def async_unload_services(hass: HomeAssistant) -> None: - """Unload services for Fritz integration.""" - - if not hass.data.get(FRITZ_SERVICES): - return - - hass.data[FRITZ_SERVICES] = False - - for service, _ in SERVICE_LIST: - hass.services.async_remove(DOMAIN, service) + hass.services.async_register( + DOMAIN, + SERVICE_SET_GUEST_WIFI_PW, + _async_set_guest_wifi_password, + SERVICE_SCHEMA_SET_GUEST_WIFI_PW, + ) diff --git a/tests/components/fritz/test_services.py b/tests/components/fritz/test_services.py new file mode 100644 index 00000000000..d7b85cbc448 --- /dev/null +++ b/tests/components/fritz/test_services.py @@ -0,0 +1,134 @@ +"""Tests for Fritz!Tools services.""" + +from unittest.mock import patch + +from fritzconnection.core.exceptions import FritzConnectionException, FritzServiceError +import pytest + +from homeassistant.components.fritz.const import DOMAIN +from homeassistant.components.fritz.services import SERVICE_SET_GUEST_WIFI_PW +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr +from homeassistant.setup import async_setup_component + +from .const import MOCK_USER_DATA + +from tests.common import MockConfigEntry + + +async def test_setup_services(hass: HomeAssistant) -> None: + """Test setup of Fritz!Tools services.""" + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + services = hass.services.async_services_for_domain(DOMAIN) + assert services + assert SERVICE_SET_GUEST_WIFI_PW in services + + +async def test_service_set_guest_wifi_password( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + caplog: pytest.LogCaptureFixture, + fc_class_mock, + fh_class_mock, +) -> None: + """Test service set_guest_wifi_password.""" + assert await async_setup_component(hass, DOMAIN, {}) + entry = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_DATA) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + device = device_registry.async_get_device( + identifiers={(DOMAIN, "1C:ED:6F:12:34:11")} + ) + assert device + with patch( + "homeassistant.components.fritz.coordinator.AvmWrapper.async_trigger_set_guest_password" + ) as mock_async_trigger_set_guest_password: + await hass.services.async_call( + DOMAIN, SERVICE_SET_GUEST_WIFI_PW, {"device_id": device.id} + ) + assert mock_async_trigger_set_guest_password.called + + +async def test_service_set_guest_wifi_password_unknown_parameter( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + caplog: pytest.LogCaptureFixture, + fc_class_mock, + fh_class_mock, +) -> None: + """Test service set_guest_wifi_password with unknown parameter.""" + assert await async_setup_component(hass, DOMAIN, {}) + entry = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_DATA) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + device = device_registry.async_get_device( + identifiers={(DOMAIN, "1C:ED:6F:12:34:11")} + ) + assert device + + with patch( + "homeassistant.components.fritz.coordinator.AvmWrapper.async_trigger_set_guest_password", + side_effect=FritzServiceError("boom"), + ) as mock_async_trigger_set_guest_password: + await hass.services.async_call( + DOMAIN, SERVICE_SET_GUEST_WIFI_PW, {"device_id": device.id} + ) + assert mock_async_trigger_set_guest_password.called + assert "HomeAssistantError: Action or parameter unknown" in caplog.text + + +async def test_service_set_guest_wifi_password_service_not_supported( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + caplog: pytest.LogCaptureFixture, + fc_class_mock, + fh_class_mock, +) -> None: + """Test service set_guest_wifi_password with connection error.""" + assert await async_setup_component(hass, DOMAIN, {}) + entry = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_DATA) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + device = device_registry.async_get_device( + identifiers={(DOMAIN, "1C:ED:6F:12:34:11")} + ) + assert device + + with patch( + "homeassistant.components.fritz.coordinator.AvmWrapper.async_trigger_set_guest_password", + side_effect=FritzConnectionException("boom"), + ) as mock_async_trigger_set_guest_password: + await hass.services.async_call( + DOMAIN, SERVICE_SET_GUEST_WIFI_PW, {"device_id": device.id} + ) + assert mock_async_trigger_set_guest_password.called + assert "HomeAssistantError: Action not supported" in caplog.text + + +async def test_service_set_guest_wifi_password_unloaded( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test service set_guest_wifi_password.""" + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + with patch( + "homeassistant.components.fritz.coordinator.AvmWrapper.async_trigger_set_guest_password" + ) as mock_async_trigger_set_guest_password: + await hass.services.async_call( + DOMAIN, SERVICE_SET_GUEST_WIFI_PW, {"device_id": "12345678"} + ) + assert not mock_async_trigger_set_guest_password.called + assert ( + 'ServiceValidationError: Failed to perform action "set_guest_wifi_password". Config entry for target not found' + in caplog.text + ) From cb407bdfc675d62bb4f5c52ef0acc825798f6237 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 28 Jan 2025 19:09:49 +0100 Subject: [PATCH 0153/3148] Add support for HomeWizard Plug-In Battery and v2 API (#136733) --- .../components/homewizard/__init__.py | 34 +- .../components/homewizard/config_flow.py | 205 +++-- homeassistant/components/homewizard/const.py | 2 - .../components/homewizard/coordinator.py | 6 +- .../components/homewizard/icons.json | 3 + .../components/homewizard/manifest.json | 4 +- .../components/homewizard/quality_scale.yaml | 10 +- .../components/homewizard/repairs.py | 79 ++ homeassistant/components/homewizard/sensor.py | 269 ++++--- .../components/homewizard/strings.json | 43 +- homeassistant/components/homewizard/switch.py | 2 +- homeassistant/generated/zeroconf.py | 5 + requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/homewizard/conftest.py | 78 +- .../homewizard/fixtures/HWE-BAT/data.json | 12 + .../homewizard/fixtures/HWE-BAT/device.json | 7 + .../homewizard/fixtures/HWE-BAT/system.json | 7 + .../homewizard/fixtures/v2/HWE-P1/device.json | 7 + .../fixtures/v2/HWE-P1/measurement.json | 48 ++ .../homewizard/fixtures/v2/HWE-P1/system.json | 8 + .../homewizard/fixtures/v2/generic/token.json | 4 + .../snapshots/test_diagnostics.ambr | 106 ++- .../homewizard/snapshots/test_sensor.ambr | 700 ++++++++++++++++++ .../components/homewizard/test_config_flow.py | 292 +++++++- .../components/homewizard/test_diagnostics.py | 1 + tests/components/homewizard/test_init.py | 54 +- tests/components/homewizard/test_repair.py | 82 ++ tests/components/homewizard/test_sensor.py | 83 +++ 29 files changed, 1916 insertions(+), 239 deletions(-) create mode 100644 homeassistant/components/homewizard/repairs.py create mode 100644 tests/components/homewizard/fixtures/HWE-BAT/data.json create mode 100644 tests/components/homewizard/fixtures/HWE-BAT/device.json create mode 100644 tests/components/homewizard/fixtures/HWE-BAT/system.json create mode 100644 tests/components/homewizard/fixtures/v2/HWE-P1/device.json create mode 100644 tests/components/homewizard/fixtures/v2/HWE-P1/measurement.json create mode 100644 tests/components/homewizard/fixtures/v2/HWE-P1/system.json create mode 100644 tests/components/homewizard/fixtures/v2/generic/token.json create mode 100644 tests/components/homewizard/test_repair.py diff --git a/homeassistant/components/homewizard/__init__.py b/homeassistant/components/homewizard/__init__.py index a911f5398da..1f29be8e6b6 100644 --- a/homeassistant/components/homewizard/__init__.py +++ b/homeassistant/components/homewizard/__init__.py @@ -1,12 +1,18 @@ """The Homewizard integration.""" -from homewizard_energy import HomeWizardEnergy, HomeWizardEnergyV1, HomeWizardEnergyV2 +from homewizard_energy import ( + HomeWizardEnergy, + HomeWizardEnergyV1, + HomeWizardEnergyV2, + has_v2_api, +) from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntry from homeassistant.const import CONF_IP_ADDRESS, CONF_TOKEN from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from .const import DOMAIN, PLATFORMS from .coordinator import HWEnergyDeviceUpdateCoordinator @@ -31,6 +37,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: HomeWizardConfigEntry) - clientsession=async_get_clientsession(hass), ) + await async_check_v2_support_and_create_issue(hass, entry) + coordinator = HWEnergyDeviceUpdateCoordinator(hass, api) try: await coordinator.async_config_entry_first_refresh() @@ -63,3 +71,27 @@ async def async_setup_entry(hass: HomeAssistant, entry: HomeWizardConfigEntry) - async def async_unload_entry(hass: HomeAssistant, entry: HomeWizardConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +async def async_check_v2_support_and_create_issue( + hass: HomeAssistant, entry: HomeWizardConfigEntry +) -> None: + """Check if the device supports v2 and create an issue if not.""" + + if not await has_v2_api(entry.data[CONF_IP_ADDRESS], async_get_clientsession(hass)): + return + + async_create_issue( + hass, + DOMAIN, + f"migrate_to_v2_api_{entry.entry_id}", + is_fixable=True, + is_persistent=False, + learn_more_url="https://home-assistant.io/integrations/homewizard/#which-button-do-i-need-to-press-to-configure-the-device", + translation_key="migrate_to_v2_api", + translation_placeholders={ + "title": entry.title, + }, + severity=IssueSeverity.WARNING, + data={"entry_id": entry.entry_id}, + ) diff --git a/homeassistant/components/homewizard/config_flow.py b/homeassistant/components/homewizard/config_flow.py index fe78385381c..c94f590f000 100644 --- a/homeassistant/components/homewizard/config_flow.py +++ b/homeassistant/components/homewizard/config_flow.py @@ -5,28 +5,31 @@ from __future__ import annotations from collections.abc import Mapping from typing import Any -from homewizard_energy import HomeWizardEnergyV1 -from homewizard_energy.errors import DisabledError, RequestError, UnsupportedError +from homewizard_energy import ( + HomeWizardEnergy, + HomeWizardEnergyV1, + HomeWizardEnergyV2, + has_v2_api, +) +from homewizard_energy.errors import ( + DisabledError, + RequestError, + UnauthorizedError, + UnsupportedError, +) from homewizard_energy.models import Device import voluptuous as vol from homeassistant.components import onboarding from homeassistant.config_entries import ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_IP_ADDRESS, CONF_PATH +from homeassistant.const import CONF_IP_ADDRESS, CONF_TOKEN from homeassistant.data_entry_flow import AbortFlow from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.selector import TextSelector from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo -from .const import ( - CONF_API_ENABLED, - CONF_PRODUCT_NAME, - CONF_PRODUCT_TYPE, - CONF_SERIAL, - DOMAIN, - LOGGER, -) +from .const import CONF_PRODUCT_NAME, CONF_PRODUCT_TYPE, CONF_SERIAL, DOMAIN, LOGGER class HomeWizardConfigFlow(ConfigFlow, domain=DOMAIN): @@ -46,10 +49,14 @@ class HomeWizardConfigFlow(ConfigFlow, domain=DOMAIN): errors: dict[str, str] | None = None if user_input is not None: try: - device_info = await self._async_try_connect(user_input[CONF_IP_ADDRESS]) + device_info = await async_try_connect(user_input[CONF_IP_ADDRESS]) except RecoverableError as ex: LOGGER.error(ex) errors = {"base": ex.error_code} + except UnauthorizedError: + # Device responded, so IP is correct. But we have to authorize + self.ip_address = user_input[CONF_IP_ADDRESS] + return await self.async_step_authorize() else: await self.async_set_unique_id( f"{device_info.product_type}_{device_info.serial}" @@ -73,22 +80,54 @@ class HomeWizardConfigFlow(ConfigFlow, domain=DOMAIN): errors=errors, ) + async def async_step_authorize( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Step where we attempt to get a token.""" + assert self.ip_address + + # Tell device we want a token, user must now press the button within 30 seconds + # The first attempt will always fail, but this opens the window to press the button + token = await async_request_token(self.ip_address) + errors: dict[str, str] | None = None + + if token is None: + if user_input is not None: + errors = {"base": "authorization_failed"} + + return self.async_show_form(step_id="authorize", errors=errors) + + # Now we got a token, we can ask for some more info + + async with HomeWizardEnergyV2(self.ip_address, token=token) as api: + device_info = await api.device() + + data = { + CONF_IP_ADDRESS: self.ip_address, + CONF_TOKEN: token, + } + + await self.async_set_unique_id( + f"{device_info.product_type}_{device_info.serial}" + ) + self._abort_if_unique_id_configured(updates=data) + return self.async_create_entry( + title=f"{device_info.product_name}", + data=data, + ) + async def async_step_zeroconf( self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle zeroconf discovery.""" + if ( - CONF_API_ENABLED not in discovery_info.properties - or CONF_PATH not in discovery_info.properties - or CONF_PRODUCT_NAME not in discovery_info.properties + CONF_PRODUCT_NAME not in discovery_info.properties or CONF_PRODUCT_TYPE not in discovery_info.properties or CONF_SERIAL not in discovery_info.properties ): return self.async_abort(reason="invalid_discovery_parameters") - if (discovery_info.properties[CONF_PATH]) != "/api/v1": - return self.async_abort(reason="unsupported_api_version") - self.ip_address = discovery_info.host self.product_type = discovery_info.properties[CONF_PRODUCT_TYPE] self.product_name = discovery_info.properties[CONF_PRODUCT_NAME] @@ -109,10 +148,12 @@ class HomeWizardConfigFlow(ConfigFlow, domain=DOMAIN): This flow is triggered only by DHCP discovery of known devices. """ try: - device = await self._async_try_connect(discovery_info.ip) + device = await async_try_connect(discovery_info.ip) except RecoverableError as ex: LOGGER.error(ex) return self.async_abort(reason="unknown") + except UnauthorizedError: + return self.async_abort(reason="unsupported_api_version") await self.async_set_unique_id( f"{device.product_type}_{discovery_info.macaddress}" @@ -139,10 +180,12 @@ class HomeWizardConfigFlow(ConfigFlow, domain=DOMAIN): errors: dict[str, str] | None = None if user_input is not None or not onboarding.async_is_onboarded(self.hass): try: - await self._async_try_connect(self.ip_address) + await async_try_connect(self.ip_address) except RecoverableError as ex: LOGGER.error(ex) errors = {"base": ex.error_code} + except UnauthorizedError: + return await self.async_step_authorize() else: return self.async_create_entry( title=self.product_name, @@ -172,25 +215,57 @@ class HomeWizardConfigFlow(ConfigFlow, domain=DOMAIN): self, entry_data: Mapping[str, Any] ) -> ConfigFlowResult: """Handle re-auth if API was disabled.""" - return await self.async_step_reauth_confirm() + self.ip_address = entry_data[CONF_IP_ADDRESS] - async def async_step_reauth_confirm( + # If token exists, we assume we use the v2 API and that the token has been invalidated + if entry_data.get(CONF_TOKEN): + return await self.async_step_reauth_confirm_update_token() + + # Else we assume we use the v1 API and that the API has been disabled + return await self.async_step_reauth_enable_api() + + async def async_step_reauth_enable_api( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Confirm reauth dialog.""" + """Confirm reauth dialog, where user is asked to re-enable the HomeWizard API.""" errors: dict[str, str] | None = None if user_input is not None: reauth_entry = self._get_reauth_entry() try: - await self._async_try_connect(reauth_entry.data[CONF_IP_ADDRESS]) + await async_try_connect(reauth_entry.data[CONF_IP_ADDRESS]) except RecoverableError as ex: LOGGER.error(ex) errors = {"base": ex.error_code} else: await self.hass.config_entries.async_reload(reauth_entry.entry_id) - return self.async_abort(reason="reauth_successful") + return self.async_abort(reason="reauth_enable_api_successful") - return self.async_show_form(step_id="reauth_confirm", errors=errors) + return self.async_show_form(step_id="reauth_enable_api", errors=errors) + + async def async_step_reauth_confirm_update_token( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm reauth dialog.""" + assert self.ip_address + + errors: dict[str, str] | None = None + + token = await async_request_token(self.ip_address) + + if user_input is not None: + if token is None: + errors = {"base": "authorization_failed"} + else: + return self.async_update_reload_and_abort( + self._get_reauth_entry(), + data_updates={ + CONF_TOKEN: token, + }, + ) + + return self.async_show_form( + step_id="reauth_confirm_update_token", errors=errors + ) async def async_step_reconfigure( self, user_input: dict[str, Any] | None = None @@ -199,7 +274,7 @@ class HomeWizardConfigFlow(ConfigFlow, domain=DOMAIN): errors: dict[str, str] = {} if user_input: try: - device_info = await self._async_try_connect(user_input[CONF_IP_ADDRESS]) + device_info = await async_try_connect(user_input[CONF_IP_ADDRESS]) except RecoverableError as ex: LOGGER.error(ex) @@ -230,37 +305,65 @@ class HomeWizardConfigFlow(ConfigFlow, domain=DOMAIN): errors=errors, ) - @staticmethod - async def _async_try_connect(ip_address: str) -> Device: - """Try to connect. - Make connection with device to test the connection - and to get info for unique_id. - """ +async def async_try_connect(ip_address: str) -> Device: + """Try to connect. + + Make connection with device to test the connection + and to get info for unique_id. + """ + + energy_api: HomeWizardEnergy + + # Determine if device is v1 or v2 capable + if await has_v2_api(ip_address): + energy_api = HomeWizardEnergyV2(ip_address) + else: energy_api = HomeWizardEnergyV1(ip_address) - try: - return await energy_api.device() - except DisabledError as ex: - raise RecoverableError( - "API disabled, API must be enabled in the app", "api_not_enabled" - ) from ex + try: + return await energy_api.device() - except UnsupportedError as ex: - LOGGER.error("API version unsuppored") - raise AbortFlow("unsupported_api_version") from ex + except DisabledError as ex: + raise RecoverableError( + "API disabled, API must be enabled in the app", "api_not_enabled" + ) from ex - except RequestError as ex: - raise RecoverableError( - "Device unreachable or unexpected response", "network_error" - ) from ex + except UnsupportedError as ex: + LOGGER.error("API version unsuppored") + raise AbortFlow("unsupported_api_version") from ex - except Exception as ex: - LOGGER.exception("Unexpected exception") - raise AbortFlow("unknown_error") from ex + except RequestError as ex: + raise RecoverableError( + "Device unreachable or unexpected response", "network_error" + ) from ex - finally: - await energy_api.close() + except UnauthorizedError as ex: + raise UnauthorizedError("Unauthorized") from ex + + except Exception as ex: + LOGGER.exception("Unexpected exception") + raise AbortFlow("unknown_error") from ex + + finally: + await energy_api.close() + + +async def async_request_token(ip_address: str) -> str | None: + """Try to request a token from the device. + + This method is used to request a token from the device, + it will return None if the token request failed. + """ + + api = HomeWizardEnergyV2(ip_address) + + try: + return await api.get_token("home-assistant") + except DisabledError: + return None + finally: + await api.close() class RecoverableError(HomeAssistantError): diff --git a/homeassistant/components/homewizard/const.py b/homeassistant/components/homewizard/const.py index 4bed4675833..e0448edaf86 100644 --- a/homeassistant/components/homewizard/const.py +++ b/homeassistant/components/homewizard/const.py @@ -13,8 +13,6 @@ PLATFORMS = [Platform.BUTTON, Platform.NUMBER, Platform.SENSOR, Platform.SWITCH] LOGGER = logging.getLogger(__package__) # Platform config. -CONF_API_ENABLED = "api_enabled" -CONF_DATA = "data" CONF_PRODUCT_NAME = "product_name" CONF_PRODUCT_TYPE = "product_type" CONF_SERIAL = "serial" diff --git a/homeassistant/components/homewizard/coordinator.py b/homeassistant/components/homewizard/coordinator.py index 7024c760b93..92beb99ad2c 100644 --- a/homeassistant/components/homewizard/coordinator.py +++ b/homeassistant/components/homewizard/coordinator.py @@ -3,11 +3,12 @@ from __future__ import annotations from homewizard_energy import HomeWizardEnergy -from homewizard_energy.errors import DisabledError, RequestError +from homewizard_energy.errors import DisabledError, RequestError, UnauthorizedError from homewizard_energy.models import CombinedModels as DeviceResponseEntry from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN, LOGGER, UPDATE_INTERVAL @@ -51,6 +52,9 @@ class HWEnergyDeviceUpdateCoordinator(DataUpdateCoordinator[DeviceResponseEntry] ex, translation_domain=DOMAIN, translation_key="api_disabled" ) from ex + except UnauthorizedError as ex: + raise ConfigEntryAuthFailed from ex + self.api_disabled = False self.data = data diff --git a/homeassistant/components/homewizard/icons.json b/homeassistant/components/homewizard/icons.json index e6b1a34841f..68ebd6b84d0 100644 --- a/homeassistant/components/homewizard/icons.json +++ b/homeassistant/components/homewizard/icons.json @@ -15,6 +15,9 @@ "any_power_fail_count": { "default": "mdi:transmission-tower-off" }, + "cycles": { + "default": "mdi:battery-sync-outline" + }, "dsmr_version": { "default": "mdi:counter" }, diff --git a/homeassistant/components/homewizard/manifest.json b/homeassistant/components/homewizard/manifest.json index 4cc94d09d74..b1a19134752 100644 --- a/homeassistant/components/homewizard/manifest.json +++ b/homeassistant/components/homewizard/manifest.json @@ -12,6 +12,6 @@ "iot_class": "local_polling", "loggers": ["homewizard_energy"], "quality_scale": "platinum", - "requirements": ["python-homewizard-energy==v8.1.1"], - "zeroconf": ["_hwenergy._tcp.local."] + "requirements": ["python-homewizard-energy==v8.2.0"], + "zeroconf": ["_hwenergy._tcp.local.", "_homewizard._tcp.local."] } diff --git a/homeassistant/components/homewizard/quality_scale.yaml b/homeassistant/components/homewizard/quality_scale.yaml index 423bc4dea49..008772a5a29 100644 --- a/homeassistant/components/homewizard/quality_scale.yaml +++ b/homeassistant/components/homewizard/quality_scale.yaml @@ -47,7 +47,10 @@ rules: devices: done diagnostics: done discovery-update-info: done - discovery: done + discovery: + status: done + comment: | + DHCP IP address updates are not supported for the v2 API. docs-data-update: done docs-examples: done docs-known-limitations: done @@ -66,10 +69,7 @@ rules: exception-translations: done icon-translations: done reconfiguration-flow: done - repair-issues: - status: exempt - comment: | - This integration does not raise any repairable issues. + repair-issues: done stale-devices: status: exempt comment: | diff --git a/homeassistant/components/homewizard/repairs.py b/homeassistant/components/homewizard/repairs.py new file mode 100644 index 00000000000..4c9a03b493f --- /dev/null +++ b/homeassistant/components/homewizard/repairs.py @@ -0,0 +1,79 @@ +"""Repairs for HomeWizard integration.""" + +from __future__ import annotations + +from homeassistant import data_entry_flow +from homeassistant.components.repairs import RepairsFlow +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_IP_ADDRESS, CONF_TOKEN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResult + +from .config_flow import async_request_token + + +class MigrateToV2ApiRepairFlow(RepairsFlow): + """Handler for an issue fixing flow.""" + + def __init__(self, entry: ConfigEntry) -> None: + """Create flow.""" + self.entry = entry + + async def async_step_init( + self, user_input: dict[str, str] | None = None + ) -> data_entry_flow.FlowResult: + """Handle the first step of a fix flow.""" + + return await self.async_step_confirm() + + async def async_step_confirm( + self, user_input: dict[str, str] | None = None + ) -> FlowResult: + """Handle the confirm step of a fix flow.""" + + if user_input is not None: + return await self.async_step_authorize() + + return self.async_show_form( + step_id="confirm", description_placeholders={"title": self.entry.title} + ) + + async def async_step_authorize( + self, user_input: dict[str, str] | None = None + ) -> FlowResult: + """Handle the authorize step of a fix flow.""" + + ip_address = self.entry.data[CONF_IP_ADDRESS] + + # Tell device we want a token, user must now press the button within 30 seconds + # The first attempt will always fail, but this opens the window to press the button + token = await async_request_token(ip_address) + errors: dict[str, str] | None = None + + if token is None: + if user_input is not None: + errors = {"base": "authorization_failed"} + + return self.async_show_form(step_id="authorize", errors=errors) + + data = {**self.entry.data, CONF_TOKEN: token} + self.hass.config_entries.async_update_entry(self.entry, data=data) + await self.hass.config_entries.async_reload(self.entry.entry_id) + return self.async_create_entry(data={}) + + +async def async_create_fix_flow( + hass: HomeAssistant, + issue_id: str, + data: dict[str, str | int | float | None] | None, +) -> RepairsFlow: + """Create flow.""" + assert data is not None + assert isinstance(data["entry_id"], str) + + if issue_id.startswith("migrate_to_v2_api_") and ( + entry := hass.config_entries.async_get_entry(data["entry_id"]) + ): + return MigrateToV2ApiRepairFlow(entry) + + raise ValueError(f"unknown repair {issue_id}") # pragma: no cover diff --git a/homeassistant/components/homewizard/sensor.py b/homeassistant/components/homewizard/sensor.py index 8a9738e7ae7..b6227a03bed 100644 --- a/homeassistant/components/homewizard/sensor.py +++ b/homeassistant/components/homewizard/sensor.py @@ -6,7 +6,7 @@ from collections.abc import Callable from dataclasses import dataclass from typing import Final -from homewizard_energy.models import ExternalDevice, Measurement +from homewizard_energy.models import CombinedModels, ExternalDevice from homeassistant.components.sensor import ( DEVICE_CLASS_UNITS, @@ -46,9 +46,9 @@ PARALLEL_UPDATES = 1 class HomeWizardSensorEntityDescription(SensorEntityDescription): """Class describing HomeWizard sensor entities.""" - enabled_fn: Callable[[Measurement], bool] = lambda x: True - has_fn: Callable[[Measurement], bool] - value_fn: Callable[[Measurement], StateType] + enabled_fn: Callable[[CombinedModels], bool] = lambda x: True + has_fn: Callable[[CombinedModels], bool] + value_fn: Callable[[CombinedModels], StateType] @dataclass(frozen=True, kw_only=True) @@ -69,35 +69,43 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( key="smr_version", translation_key="dsmr_version", entity_category=EntityCategory.DIAGNOSTIC, - has_fn=lambda data: data.protocol_version is not None, - value_fn=lambda data: data.protocol_version, + has_fn=lambda data: data.measurement.protocol_version is not None, + value_fn=lambda data: data.measurement.protocol_version, ), HomeWizardSensorEntityDescription( key="meter_model", translation_key="meter_model", entity_category=EntityCategory.DIAGNOSTIC, - has_fn=lambda data: data.meter_model is not None, - value_fn=lambda data: data.meter_model, + has_fn=lambda data: data.measurement.meter_model is not None, + value_fn=lambda data: data.measurement.meter_model, ), HomeWizardSensorEntityDescription( key="unique_meter_id", translation_key="unique_meter_id", entity_category=EntityCategory.DIAGNOSTIC, - has_fn=lambda data: data.unique_id is not None, - value_fn=lambda data: data.unique_id, + has_fn=lambda data: data.measurement.unique_id is not None, + value_fn=lambda data: data.measurement.unique_id, ), HomeWizardSensorEntityDescription( key="wifi_ssid", translation_key="wifi_ssid", entity_category=EntityCategory.DIAGNOSTIC, - has_fn=lambda data: data.wifi_ssid is not None, - value_fn=lambda data: data.wifi_ssid, + has_fn=( + lambda data: data.system is not None and data.system.wifi_ssid is not None + ), + value_fn=( + lambda data: data.system.wifi_ssid if data.system is not None else None + ), ), HomeWizardSensorEntityDescription( key="active_tariff", translation_key="active_tariff", - has_fn=lambda data: data.tariff is not None, - value_fn=lambda data: None if data.tariff is None else str(data.tariff), + has_fn=lambda data: data.measurement.tariff is not None, + value_fn=( + lambda data: None + if data.measurement.tariff is None + else str(data.measurement.tariff) + ), device_class=SensorDeviceClass.ENUM, options=["1", "2", "3", "4"], ), @@ -108,8 +116,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, - has_fn=lambda data: data.wifi_strength is not None, - value_fn=lambda data: data.wifi_strength, + has_fn=lambda data: data.measurement.wifi_strength is not None, + value_fn=lambda data: data.measurement.wifi_strength, ), HomeWizardSensorEntityDescription( key="total_power_import_kwh", @@ -117,8 +125,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, - has_fn=lambda data: data.energy_import_kwh is not None, - value_fn=lambda data: data.energy_import_kwh, + has_fn=lambda data: data.measurement.energy_import_kwh is not None, + value_fn=lambda data: data.measurement.energy_import_kwh, ), HomeWizardSensorEntityDescription( key="total_power_import_t1_kwh", @@ -129,10 +137,10 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( state_class=SensorStateClass.TOTAL_INCREASING, has_fn=lambda data: ( # SKT/SDM230/630 provides both total and tariff 1: duplicate. - data.energy_import_t1_kwh is not None - and data.energy_export_t2_kwh is not None + data.measurement.energy_import_t1_kwh is not None + and data.measurement.energy_export_t2_kwh is not None ), - value_fn=lambda data: data.energy_import_t1_kwh, + value_fn=lambda data: data.measurement.energy_import_t1_kwh, ), HomeWizardSensorEntityDescription( key="total_power_import_t2_kwh", @@ -141,8 +149,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, - has_fn=lambda data: data.energy_import_t2_kwh is not None, - value_fn=lambda data: data.energy_import_t2_kwh, + has_fn=lambda data: data.measurement.energy_import_t2_kwh is not None, + value_fn=lambda data: data.measurement.energy_import_t2_kwh, ), HomeWizardSensorEntityDescription( key="total_power_import_t3_kwh", @@ -151,8 +159,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, - has_fn=lambda data: data.energy_import_t3_kwh is not None, - value_fn=lambda data: data.energy_import_t3_kwh, + has_fn=lambda data: data.measurement.energy_import_t3_kwh is not None, + value_fn=lambda data: data.measurement.energy_import_t3_kwh, ), HomeWizardSensorEntityDescription( key="total_power_import_t4_kwh", @@ -161,8 +169,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, - has_fn=lambda data: data.energy_import_t4_kwh is not None, - value_fn=lambda data: data.energy_import_t4_kwh, + has_fn=lambda data: data.measurement.energy_import_t4_kwh is not None, + value_fn=lambda data: data.measurement.energy_import_t4_kwh, ), HomeWizardSensorEntityDescription( key="total_power_export_kwh", @@ -170,9 +178,9 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, - has_fn=lambda data: data.energy_export_kwh is not None, - enabled_fn=lambda data: data.energy_export_kwh != 0, - value_fn=lambda data: data.energy_export_kwh, + has_fn=lambda data: data.measurement.energy_export_kwh is not None, + enabled_fn=lambda data: data.measurement.energy_export_kwh != 0, + value_fn=lambda data: data.measurement.energy_export_kwh, ), HomeWizardSensorEntityDescription( key="total_power_export_t1_kwh", @@ -183,11 +191,11 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( state_class=SensorStateClass.TOTAL_INCREASING, has_fn=lambda data: ( # SKT/SDM230/630 provides both total and tariff 1: duplicate. - data.energy_export_t1_kwh is not None - and data.energy_export_t2_kwh is not None + data.measurement.energy_export_t1_kwh is not None + and data.measurement.energy_export_t2_kwh is not None ), - enabled_fn=lambda data: data.energy_export_t1_kwh != 0, - value_fn=lambda data: data.energy_export_t1_kwh, + enabled_fn=lambda data: data.measurement.energy_export_t1_kwh != 0, + value_fn=lambda data: data.measurement.energy_export_t1_kwh, ), HomeWizardSensorEntityDescription( key="total_power_export_t2_kwh", @@ -196,9 +204,9 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, - has_fn=lambda data: data.energy_export_t2_kwh is not None, - enabled_fn=lambda data: data.energy_export_t2_kwh != 0, - value_fn=lambda data: data.energy_export_t2_kwh, + has_fn=lambda data: data.measurement.energy_export_t2_kwh is not None, + enabled_fn=lambda data: data.measurement.energy_export_t2_kwh != 0, + value_fn=lambda data: data.measurement.energy_export_t2_kwh, ), HomeWizardSensorEntityDescription( key="total_power_export_t3_kwh", @@ -207,9 +215,9 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, - has_fn=lambda data: data.energy_export_t3_kwh is not None, - enabled_fn=lambda data: data.energy_export_t3_kwh != 0, - value_fn=lambda data: data.energy_export_t3_kwh, + has_fn=lambda data: data.measurement.energy_export_t3_kwh is not None, + enabled_fn=lambda data: data.measurement.energy_export_t3_kwh != 0, + value_fn=lambda data: data.measurement.energy_export_t3_kwh, ), HomeWizardSensorEntityDescription( key="total_power_export_t4_kwh", @@ -218,9 +226,9 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, - has_fn=lambda data: data.energy_export_t4_kwh is not None, - enabled_fn=lambda data: data.energy_export_t4_kwh != 0, - value_fn=lambda data: data.energy_export_t4_kwh, + has_fn=lambda data: data.measurement.energy_export_t4_kwh is not None, + enabled_fn=lambda data: data.measurement.energy_export_t4_kwh != 0, + value_fn=lambda data: data.measurement.energy_export_t4_kwh, ), HomeWizardSensorEntityDescription( key="active_power_w", @@ -228,8 +236,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, - has_fn=lambda data: data.power_w is not None, - value_fn=lambda data: data.power_w, + has_fn=lambda data: data.measurement.power_w is not None, + value_fn=lambda data: data.measurement.power_w, ), HomeWizardSensorEntityDescription( key="active_power_l1_w", @@ -239,8 +247,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, - has_fn=lambda data: data.power_l1_w is not None, - value_fn=lambda data: data.power_l1_w, + has_fn=lambda data: data.measurement.power_l1_w is not None, + value_fn=lambda data: data.measurement.power_l1_w, ), HomeWizardSensorEntityDescription( key="active_power_l2_w", @@ -250,8 +258,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, - has_fn=lambda data: data.power_l2_w is not None, - value_fn=lambda data: data.power_l2_w, + has_fn=lambda data: data.measurement.power_l2_w is not None, + value_fn=lambda data: data.measurement.power_l2_w, ), HomeWizardSensorEntityDescription( key="active_power_l3_w", @@ -261,8 +269,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, - has_fn=lambda data: data.power_l3_w is not None, - value_fn=lambda data: data.power_l3_w, + has_fn=lambda data: data.measurement.power_l3_w is not None, + value_fn=lambda data: data.measurement.power_l3_w, ), HomeWizardSensorEntityDescription( key="active_voltage_v", @@ -270,8 +278,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.voltage_v is not None, - value_fn=lambda data: data.voltage_v, + has_fn=lambda data: data.measurement.voltage_v is not None, + value_fn=lambda data: data.measurement.voltage_v, ), HomeWizardSensorEntityDescription( key="active_voltage_l1_v", @@ -281,8 +289,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.voltage_l1_v is not None, - value_fn=lambda data: data.voltage_l1_v, + has_fn=lambda data: data.measurement.voltage_l1_v is not None, + value_fn=lambda data: data.measurement.voltage_l1_v, ), HomeWizardSensorEntityDescription( key="active_voltage_l2_v", @@ -292,8 +300,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.voltage_l2_v is not None, - value_fn=lambda data: data.voltage_l2_v, + has_fn=lambda data: data.measurement.voltage_l2_v is not None, + value_fn=lambda data: data.measurement.voltage_l2_v, ), HomeWizardSensorEntityDescription( key="active_voltage_l3_v", @@ -303,8 +311,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.voltage_l3_v is not None, - value_fn=lambda data: data.voltage_l3_v, + has_fn=lambda data: data.measurement.voltage_l3_v is not None, + value_fn=lambda data: data.measurement.voltage_l3_v, ), HomeWizardSensorEntityDescription( key="active_current_a", @@ -312,8 +320,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.current_a is not None, - value_fn=lambda data: data.current_a, + has_fn=lambda data: data.measurement.current_a is not None, + value_fn=lambda data: data.measurement.current_a, ), HomeWizardSensorEntityDescription( key="active_current_l1_a", @@ -323,8 +331,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.current_l1_a is not None, - value_fn=lambda data: data.current_l1_a, + has_fn=lambda data: data.measurement.current_l1_a is not None, + value_fn=lambda data: data.measurement.current_l1_a, ), HomeWizardSensorEntityDescription( key="active_current_l2_a", @@ -334,8 +342,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.current_l2_a is not None, - value_fn=lambda data: data.current_l2_a, + has_fn=lambda data: data.measurement.current_l2_a is not None, + value_fn=lambda data: data.measurement.current_l2_a, ), HomeWizardSensorEntityDescription( key="active_current_l3_a", @@ -345,8 +353,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.current_l3_a is not None, - value_fn=lambda data: data.current_l3_a, + has_fn=lambda data: data.measurement.current_l3_a is not None, + value_fn=lambda data: data.measurement.current_l3_a, ), HomeWizardSensorEntityDescription( key="active_frequency_hz", @@ -354,8 +362,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.FREQUENCY, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.frequency_hz is not None, - value_fn=lambda data: data.frequency_hz, + has_fn=lambda data: data.measurement.frequency_hz is not None, + value_fn=lambda data: data.measurement.frequency_hz, ), HomeWizardSensorEntityDescription( key="active_apparent_power_va", @@ -363,8 +371,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.APPARENT_POWER, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.apparent_power_va is not None, - value_fn=lambda data: data.apparent_power_va, + has_fn=lambda data: data.measurement.apparent_power_va is not None, + value_fn=lambda data: data.measurement.apparent_power_va, ), HomeWizardSensorEntityDescription( key="active_apparent_power_l1_va", @@ -374,8 +382,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.APPARENT_POWER, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.apparent_power_l1_va is not None, - value_fn=lambda data: data.apparent_power_l1_va, + has_fn=lambda data: data.measurement.apparent_power_l1_va is not None, + value_fn=lambda data: data.measurement.apparent_power_l1_va, ), HomeWizardSensorEntityDescription( key="active_apparent_power_l2_va", @@ -385,8 +393,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.APPARENT_POWER, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.apparent_power_l2_va is not None, - value_fn=lambda data: data.apparent_power_l2_va, + has_fn=lambda data: data.measurement.apparent_power_l2_va is not None, + value_fn=lambda data: data.measurement.apparent_power_l2_va, ), HomeWizardSensorEntityDescription( key="active_apparent_power_l3_va", @@ -396,8 +404,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.APPARENT_POWER, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.apparent_power_l3_va is not None, - value_fn=lambda data: data.apparent_power_l3_va, + has_fn=lambda data: data.measurement.apparent_power_l3_va is not None, + value_fn=lambda data: data.measurement.apparent_power_l3_va, ), HomeWizardSensorEntityDescription( key="active_reactive_power_var", @@ -405,8 +413,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.REACTIVE_POWER, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.reactive_power_var is not None, - value_fn=lambda data: data.reactive_power_var, + has_fn=lambda data: data.measurement.reactive_power_var is not None, + value_fn=lambda data: data.measurement.reactive_power_var, ), HomeWizardSensorEntityDescription( key="active_reactive_power_l1_var", @@ -416,8 +424,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.REACTIVE_POWER, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.reactive_power_l1_var is not None, - value_fn=lambda data: data.reactive_power_l1_var, + has_fn=lambda data: data.measurement.reactive_power_l1_var is not None, + value_fn=lambda data: data.measurement.reactive_power_l1_var, ), HomeWizardSensorEntityDescription( key="active_reactive_power_l2_var", @@ -427,8 +435,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.REACTIVE_POWER, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.reactive_power_l2_var is not None, - value_fn=lambda data: data.reactive_power_l2_var, + has_fn=lambda data: data.measurement.reactive_power_l2_var is not None, + value_fn=lambda data: data.measurement.reactive_power_l2_var, ), HomeWizardSensorEntityDescription( key="active_reactive_power_l3_var", @@ -438,8 +446,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.REACTIVE_POWER, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.reactive_power_l3_var is not None, - value_fn=lambda data: data.reactive_power_l3_var, + has_fn=lambda data: data.measurement.reactive_power_l3_var is not None, + value_fn=lambda data: data.measurement.reactive_power_l3_var, ), HomeWizardSensorEntityDescription( key="active_power_factor", @@ -447,8 +455,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.POWER_FACTOR, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.power_factor is not None, - value_fn=lambda data: to_percentage(data.power_factor), + has_fn=lambda data: data.measurement.power_factor is not None, + value_fn=lambda data: to_percentage(data.measurement.power_factor), ), HomeWizardSensorEntityDescription( key="active_power_factor_l1", @@ -458,8 +466,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.POWER_FACTOR, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.power_factor_l1 is not None, - value_fn=lambda data: to_percentage(data.power_factor_l1), + has_fn=lambda data: data.measurement.power_factor_l1 is not None, + value_fn=lambda data: to_percentage(data.measurement.power_factor_l1), ), HomeWizardSensorEntityDescription( key="active_power_factor_l2", @@ -469,8 +477,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.POWER_FACTOR, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.power_factor_l2 is not None, - value_fn=lambda data: to_percentage(data.power_factor_l2), + has_fn=lambda data: data.measurement.power_factor_l2 is not None, + value_fn=lambda data: to_percentage(data.measurement.power_factor_l2), ), HomeWizardSensorEntityDescription( key="active_power_factor_l3", @@ -480,94 +488,94 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.POWER_FACTOR, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - has_fn=lambda data: data.power_factor_l3 is not None, - value_fn=lambda data: to_percentage(data.power_factor_l3), + has_fn=lambda data: data.measurement.power_factor_l3 is not None, + value_fn=lambda data: to_percentage(data.measurement.power_factor_l3), ), HomeWizardSensorEntityDescription( key="voltage_sag_l1_count", translation_key="voltage_sag_phase_count", translation_placeholders={"phase": "1"}, entity_category=EntityCategory.DIAGNOSTIC, - has_fn=lambda data: data.voltage_sag_l1_count is not None, - value_fn=lambda data: data.voltage_sag_l1_count, + has_fn=lambda data: data.measurement.voltage_sag_l1_count is not None, + value_fn=lambda data: data.measurement.voltage_sag_l1_count, ), HomeWizardSensorEntityDescription( key="voltage_sag_l2_count", translation_key="voltage_sag_phase_count", translation_placeholders={"phase": "2"}, entity_category=EntityCategory.DIAGNOSTIC, - has_fn=lambda data: data.voltage_sag_l2_count is not None, - value_fn=lambda data: data.voltage_sag_l2_count, + has_fn=lambda data: data.measurement.voltage_sag_l2_count is not None, + value_fn=lambda data: data.measurement.voltage_sag_l2_count, ), HomeWizardSensorEntityDescription( key="voltage_sag_l3_count", translation_key="voltage_sag_phase_count", translation_placeholders={"phase": "3"}, entity_category=EntityCategory.DIAGNOSTIC, - has_fn=lambda data: data.voltage_sag_l3_count is not None, - value_fn=lambda data: data.voltage_sag_l3_count, + has_fn=lambda data: data.measurement.voltage_sag_l3_count is not None, + value_fn=lambda data: data.measurement.voltage_sag_l3_count, ), HomeWizardSensorEntityDescription( key="voltage_swell_l1_count", translation_key="voltage_swell_phase_count", translation_placeholders={"phase": "1"}, entity_category=EntityCategory.DIAGNOSTIC, - has_fn=lambda data: data.voltage_swell_l1_count is not None, - value_fn=lambda data: data.voltage_swell_l1_count, + has_fn=lambda data: data.measurement.voltage_swell_l1_count is not None, + value_fn=lambda data: data.measurement.voltage_swell_l1_count, ), HomeWizardSensorEntityDescription( key="voltage_swell_l2_count", translation_key="voltage_swell_phase_count", translation_placeholders={"phase": "2"}, entity_category=EntityCategory.DIAGNOSTIC, - has_fn=lambda data: data.voltage_swell_l2_count is not None, - value_fn=lambda data: data.voltage_swell_l2_count, + has_fn=lambda data: data.measurement.voltage_swell_l2_count is not None, + value_fn=lambda data: data.measurement.voltage_swell_l2_count, ), HomeWizardSensorEntityDescription( key="voltage_swell_l3_count", translation_key="voltage_swell_phase_count", translation_placeholders={"phase": "3"}, entity_category=EntityCategory.DIAGNOSTIC, - has_fn=lambda data: data.voltage_swell_l3_count is not None, - value_fn=lambda data: data.voltage_swell_l3_count, + has_fn=lambda data: data.measurement.voltage_swell_l3_count is not None, + value_fn=lambda data: data.measurement.voltage_swell_l3_count, ), HomeWizardSensorEntityDescription( key="any_power_fail_count", translation_key="any_power_fail_count", entity_category=EntityCategory.DIAGNOSTIC, - has_fn=lambda data: data.any_power_fail_count is not None, - value_fn=lambda data: data.any_power_fail_count, + has_fn=lambda data: data.measurement.any_power_fail_count is not None, + value_fn=lambda data: data.measurement.any_power_fail_count, ), HomeWizardSensorEntityDescription( key="long_power_fail_count", translation_key="long_power_fail_count", entity_category=EntityCategory.DIAGNOSTIC, - has_fn=lambda data: data.long_power_fail_count is not None, - value_fn=lambda data: data.long_power_fail_count, + has_fn=lambda data: data.measurement.long_power_fail_count is not None, + value_fn=lambda data: data.measurement.long_power_fail_count, ), HomeWizardSensorEntityDescription( key="active_power_average_w", translation_key="active_power_average_w", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, - has_fn=lambda data: data.average_power_15m_w is not None, - value_fn=lambda data: data.average_power_15m_w, + has_fn=lambda data: data.measurement.average_power_15m_w is not None, + value_fn=lambda data: data.measurement.average_power_15m_w, ), HomeWizardSensorEntityDescription( key="monthly_power_peak_w", translation_key="monthly_power_peak_w", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, - has_fn=lambda data: data.monthly_power_peak_w is not None, - value_fn=lambda data: data.monthly_power_peak_w, + has_fn=lambda data: data.measurement.monthly_power_peak_w is not None, + value_fn=lambda data: data.measurement.monthly_power_peak_w, ), HomeWizardSensorEntityDescription( key="active_liter_lpm", translation_key="active_liter_lpm", native_unit_of_measurement=UnitOfVolumeFlowRate.LITERS_PER_MINUTE, state_class=SensorStateClass.MEASUREMENT, - has_fn=lambda data: data.active_liter_lpm is not None, - value_fn=lambda data: data.active_liter_lpm, + has_fn=lambda data: data.measurement.active_liter_lpm is not None, + value_fn=lambda data: data.measurement.active_liter_lpm, ), HomeWizardSensorEntityDescription( key="total_liter_m3", @@ -575,8 +583,26 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, device_class=SensorDeviceClass.WATER, state_class=SensorStateClass.TOTAL_INCREASING, - has_fn=lambda data: data.total_liter_m3 is not None, - value_fn=lambda data: data.total_liter_m3, + has_fn=lambda data: data.measurement.total_liter_m3 is not None, + value_fn=lambda data: data.measurement.total_liter_m3, + ), + HomeWizardSensorEntityDescription( + key="state_of_charge_pct", + translation_key="state_of_charge_pct", + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.BATTERY, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, + has_fn=lambda data: data.measurement.state_of_charge_pct is not None, + value_fn=lambda data: data.measurement.state_of_charge_pct, + ), + HomeWizardSensorEntityDescription( + key="cycles", + translation_key="cycles", + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.TOTAL_INCREASING, + has_fn=lambda data: data.measurement.cycles is not None, + value_fn=lambda data: data.measurement.cycles, ), ) @@ -622,16 +648,15 @@ async def async_setup_entry( ) -> None: """Initialize sensors.""" - measurement = entry.runtime_data.data.measurement - # Initialize default sensors entities: list = [ HomeWizardSensorEntity(entry.runtime_data, description) for description in SENSORS - if description.has_fn(measurement) + if description.has_fn(entry.runtime_data.data) ] # Initialize external devices + measurement = entry.runtime_data.data.measurement if measurement.external_devices is not None: for unique_id, device in measurement.external_devices.items(): if device.type is not None and ( @@ -661,13 +686,13 @@ class HomeWizardSensorEntity(HomeWizardEntity, SensorEntity): super().__init__(coordinator) self.entity_description = description self._attr_unique_id = f"{coordinator.config_entry.unique_id}_{description.key}" - if not description.enabled_fn(self.coordinator.data.measurement): + if not description.enabled_fn(self.coordinator.data): self._attr_entity_registry_enabled_default = False @property def native_value(self) -> StateType: """Return the sensor value.""" - return self.entity_description.value_fn(self.coordinator.data.measurement) + return self.entity_description.value_fn(self.coordinator.data) @property def available(self) -> bool: diff --git a/homeassistant/components/homewizard/strings.json b/homeassistant/components/homewizard/strings.json index 4309664c4c8..dbaef8439d9 100644 --- a/homeassistant/components/homewizard/strings.json +++ b/homeassistant/components/homewizard/strings.json @@ -15,9 +15,17 @@ "title": "Confirm", "description": "Do you want to set up {product_type} ({serial}) at {ip_address}?" }, - "reauth_confirm": { + "reauth_enable_api": { "description": "The local API is disabled. Go to the HomeWizard Energy app and enable the API in the device settings." }, + "reauth_confirm_update_token": { + "title": "Re-authenticate", + "description": "[%key:component::homewizard::config::step::authorize::description%]" + }, + "authorize": { + "title": "Authorize", + "description": "Press the button on the HomeWizard Energy device, then select the button below." + }, "reconfigure": { "description": "Update configuration for {title}.", "data": { @@ -30,7 +38,8 @@ }, "error": { "api_not_enabled": "The local API is disabled. Go to the HomeWizard Energy app and enable the API in the device settings.", - "network_error": "Device unreachable, make sure that you have entered the correct IP address and that the device is available in your network" + "network_error": "Device unreachable, make sure that you have entered the correct IP address and that the device is available in your network", + "authorization_failed": "Failed to authorize, make sure to press the button of the device within 30 seconds" }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", @@ -38,7 +47,8 @@ "device_not_supported": "This device is not supported", "unknown_error": "[%key:common::config_flow::error::unknown%]", "unsupported_api_version": "Detected unsupported API version", - "reauth_successful": "Enabling API was successful", + "reauth_enable_api_successful": "Enabling API was successful", + "reauth_successful": "Authorization successful", "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", "wrong_device": "The configured device is not the same found on this IP address." } @@ -121,6 +131,12 @@ }, "total_liter_m3": { "name": "Total water usage" + }, + "cycles": { + "name": "Battery cycles" + }, + "state_of_charge_pct": { + "name": "State of charge" } }, "switch": { @@ -139,5 +155,26 @@ "communication_error": { "message": "An error occurred while communicating with HomeWizard device" } + }, + "issues": { + "migrate_to_v2_api": { + "title": "Update authentication method", + "fix_flow": { + "step": { + "confirm": { + "title": "[%key:component::homewizard::issues::migrate_to_v2_api::title%]", + "description": "Your {title} now supports a more secure and feature-rich communication method. To take advantage of this, you need to reconfigure the integration.\n\nSelect **Submit** to start the reconfiguration." + }, + "authorize": { + "title": "[%key:component::homewizard::config::step::authorize::title%]", + "description": "[%key:component::homewizard::config::step::authorize::description%]" + } + }, + "error": { + "authorization_failed": "[%key:component::homewizard::config::error::authorization_failed%]", + "unknown_error": "[%key:common::config_flow::error::unknown%]" + } + } + } } } diff --git a/homeassistant/components/homewizard/switch.py b/homeassistant/components/homewizard/switch.py index 0878703e4d5..8ebb56433b1 100644 --- a/homeassistant/components/homewizard/switch.py +++ b/homeassistant/components/homewizard/switch.py @@ -59,7 +59,7 @@ SWITCHES = [ key="cloud_connection", translation_key="cloud_connection", entity_category=EntityCategory.CONFIG, - create_fn=lambda _: True, + create_fn=lambda x: x.device.supports_cloud_enable(), available_fn=lambda x: x.system is not None, is_on_fn=lambda x: x.system.cloud_enabled if x.system else None, set_fn=lambda api, active: api.system(cloud_enabled=active), diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index 203f01e7d68..be15d88aec2 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -522,6 +522,11 @@ ZEROCONF = { "domain": "homekit", }, ], + "_homewizard._tcp.local.": [ + { + "domain": "homewizard", + }, + ], "_hscp._tcp.local.": [ { "domain": "apple_tv", diff --git a/requirements_all.txt b/requirements_all.txt index 287ca9364a5..57c534d0e2c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2388,7 +2388,7 @@ python-gitlab==1.6.0 python-homeassistant-analytics==0.8.1 # homeassistant.components.homewizard -python-homewizard-energy==v8.1.1 +python-homewizard-energy==v8.2.0 # homeassistant.components.hp_ilo python-hpilo==4.4.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d7220be9718..c4aa58667c2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1933,7 +1933,7 @@ python-fullykiosk==0.0.14 python-homeassistant-analytics==0.8.1 # homeassistant.components.homewizard -python-homewizard-energy==v8.1.1 +python-homewizard-energy==v8.2.0 # homeassistant.components.izone python-izone==1.2.9 diff --git a/tests/components/homewizard/conftest.py b/tests/components/homewizard/conftest.py index b540ebac91a..f9c5e617904 100644 --- a/tests/components/homewizard/conftest.py +++ b/tests/components/homewizard/conftest.py @@ -3,11 +3,18 @@ from collections.abc import Generator from unittest.mock import AsyncMock, MagicMock, patch -from homewizard_energy.models import CombinedModels, Device, Measurement, State, System +from homewizard_energy.models import ( + CombinedModels, + Device, + Measurement, + State, + System, + Token, +) import pytest from homeassistant.components.homewizard.const import DOMAIN -from homeassistant.const import CONF_IP_ADDRESS +from homeassistant.const import CONF_IP_ADDRESS, CONF_TOKEN from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry, get_fixture_path, load_json_object_fixture @@ -65,6 +72,59 @@ def mock_homewizardenergy( yield client +@pytest.fixture +def mock_homewizardenergy_v2( + device_fixture: str, +) -> MagicMock: + """Return a mock bridge.""" + with ( + patch( + "homeassistant.components.homewizard.HomeWizardEnergyV2", + autospec=True, + ) as homewizard, + patch( + "homeassistant.components.homewizard.config_flow.HomeWizardEnergyV2", + new=homewizard, + ), + ): + client = homewizard.return_value + + client.combined.return_value = CombinedModels( + device=Device.from_dict( + load_json_object_fixture(f"v2/{device_fixture}/device.json", DOMAIN) + ), + measurement=Measurement.from_dict( + load_json_object_fixture( + f"v2/{device_fixture}/measurement.json", DOMAIN + ) + ), + state=( + State.from_dict( + load_json_object_fixture(f"v2/{device_fixture}/state.json", DOMAIN) + ) + if get_fixture_path(f"v2/{device_fixture}/state.json", DOMAIN).exists() + else None + ), + system=( + System.from_dict( + load_json_object_fixture(f"v2/{device_fixture}/system.json", DOMAIN) + ) + if get_fixture_path(f"v2/{device_fixture}/system.json", DOMAIN).exists() + else None + ), + ) + + # device() call is used during configuration flow + client.device.return_value = client.combined.return_value.device + + # Authorization flow is used during configuration flow + client.get_token.return_value = Token.from_dict( + load_json_object_fixture("v2/generic/token.json", DOMAIN) + ).token + + yield client + + @pytest.fixture def mock_setup_entry() -> Generator[AsyncMock]: """Mock setting up a config entry.""" @@ -90,6 +150,20 @@ def mock_config_entry() -> MockConfigEntry: ) +@pytest.fixture +def mock_config_entry_v2() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + title="Device", + domain=DOMAIN, + data={ + CONF_IP_ADDRESS: "127.0.0.1", + CONF_TOKEN: "00112233445566778899ABCDEFABCDEF", + }, + unique_id="HWE-P1_5c2fafabcdef", + ) + + @pytest.fixture async def init_integration( hass: HomeAssistant, diff --git a/tests/components/homewizard/fixtures/HWE-BAT/data.json b/tests/components/homewizard/fixtures/HWE-BAT/data.json new file mode 100644 index 00000000000..490120e7ffd --- /dev/null +++ b/tests/components/homewizard/fixtures/HWE-BAT/data.json @@ -0,0 +1,12 @@ +{ + "wifi_ssid": "simulating v1 support", + "wifi_strength": 100, + "total_power_import_kwh": 123.456, + "total_power_export_kwh": 123.456, + "active_power_w": 123, + "active_voltage_v": 230, + "active_current_a": 1.5, + "active_frequency_hz": 50, + "state_of_charge_pct": 50, + "cycles": 123 +} diff --git a/tests/components/homewizard/fixtures/HWE-BAT/device.json b/tests/components/homewizard/fixtures/HWE-BAT/device.json new file mode 100644 index 00000000000..c551dc34c91 --- /dev/null +++ b/tests/components/homewizard/fixtures/HWE-BAT/device.json @@ -0,0 +1,7 @@ +{ + "product_type": "HWE-BAT", + "product_name": "Plug-In Battery", + "serial": "5c2fafabcdef", + "firmware_version": "1.00", + "api_version": "v1" +} diff --git a/tests/components/homewizard/fixtures/HWE-BAT/system.json b/tests/components/homewizard/fixtures/HWE-BAT/system.json new file mode 100644 index 00000000000..b4094f497cb --- /dev/null +++ b/tests/components/homewizard/fixtures/HWE-BAT/system.json @@ -0,0 +1,7 @@ +{ + "wifi_ssid": "My Wi-Fi", + "wifi_rssi_db": -77, + "cloud_enabled": false, + "uptime_s": 356, + "status_led_brightness_pct": 100 +} diff --git a/tests/components/homewizard/fixtures/v2/HWE-P1/device.json b/tests/components/homewizard/fixtures/v2/HWE-P1/device.json new file mode 100644 index 00000000000..2dc3f0692a2 --- /dev/null +++ b/tests/components/homewizard/fixtures/v2/HWE-P1/device.json @@ -0,0 +1,7 @@ +{ + "product_type": "HWE-P1", + "product_name": "P1 meter", + "serial": "5c2fafabcdef", + "firmware_version": "4.19", + "api_version": "2.0.0" +} diff --git a/tests/components/homewizard/fixtures/v2/HWE-P1/measurement.json b/tests/components/homewizard/fixtures/v2/HWE-P1/measurement.json new file mode 100644 index 00000000000..2004b0cd37f --- /dev/null +++ b/tests/components/homewizard/fixtures/v2/HWE-P1/measurement.json @@ -0,0 +1,48 @@ +{ + "protocol_version": 50, + "meter_model": "ISKRA 2M550T-101", + "unique_id": "4E6576657220476F6E6E61204C657420596F7520446F776E", + "timestamp": "2024-06-28T14:12:34", + "tariff": 2, + "energy_import_kwh": 13779.338, + "energy_import_t1_kwh": 10830.511, + "energy_import_t2_kwh": 2948.827, + "energy_export_kwh": 1234.567, + "energy_export_t1_kwh": 234.567, + "energy_export_t2_kwh": 1000, + "power_w": -543, + "power_l1_w": -676, + "power_l2_w": 133, + "power_l3_w": 0, + "current_a": 6, + "current_l1_a": -4, + "current_l2_a": 2, + "current_l3_a": 0, + "voltage_sag_l1_count": 1, + "voltage_sag_l2_count": 1, + "voltage_sag_l3_count": 0, + "voltage_swell_l1_count": 0, + "voltage_swell_l2_count": 0, + "voltage_swell_l3_count": 0, + "any_power_fail_count": 4, + "long_power_fail_count": 5, + "average_power_15m_w": 123.0, + "monthly_power_peak_w": 1111.0, + "monthly_power_peak_timestamp": "2024-06-04T10:11:22", + "external": [ + { + "unique_id": "4E6576657220676F6E6E612072756E2061726F756E64", + "type": "gas_meter", + "timestamp": "2024-06-28T14:00:00", + "value": 2569.646, + "unit": "m3" + }, + { + "unique_id": "616E642064657365727420796F75", + "type": "water_meter", + "timestamp": "2024-06-28T14:05:00", + "value": 123.456, + "unit": "m3" + } + ] +} diff --git a/tests/components/homewizard/fixtures/v2/HWE-P1/system.json b/tests/components/homewizard/fixtures/v2/HWE-P1/system.json new file mode 100644 index 00000000000..38bcaeeb584 --- /dev/null +++ b/tests/components/homewizard/fixtures/v2/HWE-P1/system.json @@ -0,0 +1,8 @@ +{ + "wifi_ssid": "My Wi-Fi", + "wifi_rssi_db": -77, + "cloud_enabled": false, + "uptime_s": 356, + "status_led_brightness_pct": 100, + "api_v1_enabled": true +} diff --git a/tests/components/homewizard/fixtures/v2/generic/token.json b/tests/components/homewizard/fixtures/v2/generic/token.json new file mode 100644 index 00000000000..8fa1e9cb8d1 --- /dev/null +++ b/tests/components/homewizard/fixtures/v2/generic/token.json @@ -0,0 +1,4 @@ +{ + "token": "00112233445566778899aabbccddeeff", + "name": "local/new_user" +} diff --git a/tests/components/homewizard/snapshots/test_diagnostics.ambr b/tests/components/homewizard/snapshots/test_diagnostics.ambr index b8cf98d9211..192b9dbdc32 100644 --- a/tests/components/homewizard/snapshots/test_diagnostics.ambr +++ b/tests/components/homewizard/snapshots/test_diagnostics.ambr @@ -1,9 +1,99 @@ # serializer version: 1 +# name: test_diagnostics[HWE-BAT] + dict({ + 'data': dict({ + 'device': dict({ + 'api_version': '1.0.0', + 'firmware_version': '1.00', + 'id': '**REDACTED**', + 'model_name': 'Plug-In Battery', + 'product_name': 'Plug-In Battery', + 'product_type': 'HWE-BAT', + 'serial': '**REDACTED**', + }), + 'measurement': dict({ + 'active_liter_lpm': None, + 'any_power_fail_count': None, + 'apparent_power_l1_va': None, + 'apparent_power_l2_va': None, + 'apparent_power_l3_va': None, + 'apparent_power_va': None, + 'average_power_15m_w': None, + 'current_a': 1.5, + 'current_l1_a': None, + 'current_l2_a': None, + 'current_l3_a': None, + 'cycles': 123, + 'energy_export_kwh': 123.456, + 'energy_export_t1_kwh': None, + 'energy_export_t2_kwh': None, + 'energy_export_t3_kwh': None, + 'energy_export_t4_kwh': None, + 'energy_import_kwh': 123.456, + 'energy_import_t1_kwh': None, + 'energy_import_t2_kwh': None, + 'energy_import_t3_kwh': None, + 'energy_import_t4_kwh': None, + 'external_devices': None, + 'frequency_hz': 50.0, + 'long_power_fail_count': None, + 'meter_model': None, + 'monthly_power_peak_timestamp': None, + 'monthly_power_peak_w': None, + 'power_factor': None, + 'power_factor_l1': None, + 'power_factor_l2': None, + 'power_factor_l3': None, + 'power_l1_w': None, + 'power_l2_w': None, + 'power_l3_w': None, + 'power_w': 123.0, + 'protocol_version': None, + 'reactive_power_l1_var': None, + 'reactive_power_l2_var': None, + 'reactive_power_l3_var': None, + 'reactive_power_var': None, + 'state_of_charge_pct': 50.0, + 'tariff': None, + 'timestamp': None, + 'total_liter_m3': None, + 'unique_id': None, + 'voltage_l1_v': None, + 'voltage_l2_v': None, + 'voltage_l3_v': None, + 'voltage_sag_l1_count': None, + 'voltage_sag_l2_count': None, + 'voltage_sag_l3_count': None, + 'voltage_swell_l1_count': None, + 'voltage_swell_l2_count': None, + 'voltage_swell_l3_count': None, + 'voltage_v': 230.0, + 'wifi_ssid': '**REDACTED**', + 'wifi_strength': 100, + }), + 'state': None, + 'system': dict({ + 'api_v1_enabled': None, + 'cloud_enabled': False, + 'status_led_brightness_pct': 100, + 'uptime_s': 356, + 'wifi_rssi_db': -77, + 'wifi_ssid': '**REDACTED**', + }), + }), + 'entry': dict({ + 'ip_address': '**REDACTED**', + 'product_name': 'P1 Meter', + 'product_type': 'HWE-P1', + 'serial': '**REDACTED**', + }), + }) +# --- # name: test_diagnostics[HWE-KWH1] dict({ 'data': dict({ 'device': dict({ - 'api_version': 'v1', + 'api_version': '1.0.0', 'firmware_version': '3.06', 'id': '**REDACTED**', 'model_name': 'Wi-Fi kWh Meter 1-phase', @@ -93,7 +183,7 @@ dict({ 'data': dict({ 'device': dict({ - 'api_version': 'v1', + 'api_version': '1.0.0', 'firmware_version': '3.06', 'id': '**REDACTED**', 'model_name': 'Wi-Fi kWh Meter 3-phase', @@ -183,7 +273,7 @@ dict({ 'data': dict({ 'device': dict({ - 'api_version': 'v1', + 'api_version': '1.0.0', 'firmware_version': '4.19', 'id': '**REDACTED**', 'model_name': 'Wi-Fi P1 Meter', @@ -309,7 +399,7 @@ dict({ 'data': dict({ 'device': dict({ - 'api_version': 'v1', + 'api_version': '1.0.0', 'firmware_version': '3.03', 'id': '**REDACTED**', 'model_name': 'Wi-Fi Energy Socket', @@ -403,7 +493,7 @@ dict({ 'data': dict({ 'device': dict({ - 'api_version': 'v1', + 'api_version': '1.0.0', 'firmware_version': '4.07', 'id': '**REDACTED**', 'model_name': 'Wi-Fi Energy Socket', @@ -497,7 +587,7 @@ dict({ 'data': dict({ 'device': dict({ - 'api_version': 'v1', + 'api_version': '1.0.0', 'firmware_version': '2.03', 'id': '**REDACTED**', 'model_name': 'Wi-Fi Watermeter', @@ -587,7 +677,7 @@ dict({ 'data': dict({ 'device': dict({ - 'api_version': 'v1', + 'api_version': '1.0.0', 'firmware_version': '3.06', 'id': '**REDACTED**', 'model_name': 'Wi-Fi kWh Meter 1-phase', @@ -677,7 +767,7 @@ dict({ 'data': dict({ 'device': dict({ - 'api_version': 'v1', + 'api_version': '1.0.0', 'firmware_version': '3.06', 'id': '**REDACTED**', 'model_name': 'Wi-Fi kWh Meter 3-phase', diff --git a/tests/components/homewizard/snapshots/test_sensor.ambr b/tests/components/homewizard/snapshots/test_sensor.ambr index 31a949ca7bd..df445a9ddca 100644 --- a/tests/components/homewizard/snapshots/test_sensor.ambr +++ b/tests/components/homewizard/snapshots/test_sensor.ambr @@ -1,4 +1,704 @@ # serializer version: 1 +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_battery_cycles:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '5c:2f:af:ab:cd:ef', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '5c2fafabcdef', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'HomeWizard', + 'model': 'Plug-In Battery', + 'model_id': 'HWE-BAT', + 'name': 'Device', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '1.00', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_battery_cycles:entity-registry] + 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': , + 'entity_id': 'sensor.device_battery_cycles', + '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': 'Battery cycles', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'cycles', + 'unique_id': 'HWE-P1_5c2fafabcdef_cycles', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_battery_cycles:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Battery cycles', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.device_battery_cycles', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '123', + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_current:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '5c:2f:af:ab:cd:ef', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '5c2fafabcdef', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'HomeWizard', + 'model': 'Plug-In Battery', + 'model_id': 'HWE-BAT', + 'name': 'Device', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '1.00', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_current:entity-registry] + 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.device_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'HWE-P1_5c2fafabcdef_active_current_a', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_current:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Device Current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.5', + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_energy_export:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '5c:2f:af:ab:cd:ef', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '5c2fafabcdef', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'HomeWizard', + 'model': 'Plug-In Battery', + 'model_id': 'HWE-BAT', + 'name': 'Device', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '1.00', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_energy_export:entity-registry] + 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.device_energy_export', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy export', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_export_kwh', + 'unique_id': 'HWE-P1_5c2fafabcdef_total_power_export_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_energy_export:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Energy export', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_energy_export', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '123.456', + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_energy_import:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '5c:2f:af:ab:cd:ef', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '5c2fafabcdef', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'HomeWizard', + 'model': 'Plug-In Battery', + 'model_id': 'HWE-BAT', + 'name': 'Device', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '1.00', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_energy_import:entity-registry] + 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.device_energy_import', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy import', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_import_kwh', + 'unique_id': 'HWE-P1_5c2fafabcdef_total_power_import_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_energy_import:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Energy import', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_energy_import', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '123.456', + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_frequency:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '5c:2f:af:ab:cd:ef', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '5c2fafabcdef', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'HomeWizard', + 'model': 'Plug-In Battery', + 'model_id': 'HWE-BAT', + 'name': 'Device', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '1.00', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_frequency:entity-registry] + 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.device_frequency', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Frequency', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'HWE-P1_5c2fafabcdef_active_frequency_hz', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_frequency:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'frequency', + 'friendly_name': 'Device Frequency', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_frequency', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50.0', + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_power:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '5c:2f:af:ab:cd:ef', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '5c2fafabcdef', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'HomeWizard', + 'model': 'Plug-In Battery', + 'model_id': 'HWE-BAT', + 'name': 'Device', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '1.00', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_power:entity-registry] + 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.device_power', + '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': None, + 'original_name': 'Power', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'HWE-P1_5c2fafabcdef_active_power_w', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_power:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Device Power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '123.0', + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_state_of_charge:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '5c:2f:af:ab:cd:ef', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '5c2fafabcdef', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'HomeWizard', + 'model': 'Plug-In Battery', + 'model_id': 'HWE-BAT', + 'name': 'Device', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '1.00', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_state_of_charge:entity-registry] + 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.device_state_of_charge', + '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': None, + 'original_name': 'State of charge', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'state_of_charge_pct', + 'unique_id': 'HWE-P1_5c2fafabcdef_state_of_charge_pct', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_state_of_charge:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Device State of charge', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.device_state_of_charge', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50.0', + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_voltage:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '5c:2f:af:ab:cd:ef', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '5c2fafabcdef', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'HomeWizard', + 'model': 'Plug-In Battery', + 'model_id': 'HWE-BAT', + 'name': 'Device', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '1.00', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_voltage:entity-registry] + 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.device_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'HWE-P1_5c2fafabcdef_active_voltage_v', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_voltage:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Device Voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '230.0', + }) +# --- # name: test_sensors[HWE-KWH1-entity_ids7][sensor.device_apparent_power:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, diff --git a/tests/components/homewizard/test_config_flow.py b/tests/components/homewizard/test_config_flow.py index b2ae7bd45e0..c39853c3f9a 100644 --- a/tests/components/homewizard/test_config_flow.py +++ b/tests/components/homewizard/test_config_flow.py @@ -1,15 +1,20 @@ """Test the homewizard config flow.""" from ipaddress import ip_address -from unittest.mock import AsyncMock, MagicMock +from unittest.mock import AsyncMock, MagicMock, patch -from homewizard_energy.errors import DisabledError, RequestError, UnsupportedError +from homewizard_energy.errors import ( + DisabledError, + RequestError, + UnauthorizedError, + UnsupportedError, +) import pytest from syrupy.assertion import SnapshotAssertion from homeassistant import config_entries from homeassistant.components.homewizard.const import DOMAIN -from homeassistant.const import CONF_IP_ADDRESS +from homeassistant.const import CONF_IP_ADDRESS, CONF_TOKEN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo @@ -225,10 +230,10 @@ async def test_discovery_missing_data_in_service_info(hass: HomeAssistant) -> No type="", name="", properties={ - # "api_enabled": "1", --> removed + "api_enabled": "1", "path": "/api/v1", "product_name": "P1 meter", - "product_type": "HWE-P1", + # "product_type": "HWE-P1", --> removed "serial": "5c2fafabcdef", }, ), @@ -238,32 +243,6 @@ async def test_discovery_missing_data_in_service_info(hass: HomeAssistant) -> No assert result["reason"] == "invalid_discovery_parameters" -async def test_discovery_invalid_api(hass: HomeAssistant) -> None: - """Test discovery detecting invalid_api.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_ZEROCONF}, - data=ZeroconfServiceInfo( - ip_address=ip_address("127.0.0.1"), - ip_addresses=[ip_address("127.0.0.1")], - port=80, - hostname="p1meter-ddeeff.local.", - type="", - name="", - properties={ - "api_enabled": "1", - "path": "/api/not_v1", - "product_name": "P1 meter", - "product_type": "HWE-P1", - "serial": "5c2fafabcdef", - }, - ), - ) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "unsupported_api_version" - - async def test_dhcp_discovery_updates_entry( hass: HomeAssistant, mock_homewizardenergy: MagicMock, @@ -338,6 +317,32 @@ async def test_dhcp_discovery_ignores_unknown( assert result.get("reason") == "unknown" +async def test_dhcp_discovery_aborts_for_v2_api( + hass: HomeAssistant, + mock_homewizardenergy: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test DHCP discovery aborts when v2 API is detected. + + DHCP discovery requires authorization which is not yet implemented + """ + mock_homewizardenergy.device.side_effect = UnauthorizedError + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data=DhcpServiceInfo( + ip="1.0.0.127", + hostname="HW-p1meter-aabbcc", + macaddress="5c2fafabcdef", + ), + ) + + assert result.get("type") is FlowResultType.ABORT + assert result.get("reason") == "unsupported_api_version" + + async def test_discovery_flow_updates_new_ip( hass: HomeAssistant, mock_config_entry: MockConfigEntry, @@ -455,12 +460,12 @@ async def test_reauth_flow( result = await mock_config_entry.start_reauth_flow(hass) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "reauth_confirm" + assert result["step_id"] == "reauth_enable_api" result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "reauth_successful" + assert result["reason"] == "reauth_enable_api_successful" async def test_reauth_error( @@ -475,7 +480,7 @@ async def test_reauth_error( result = await mock_config_entry.start_reauth_flow(hass) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "reauth_confirm" + assert result["step_id"] == "reauth_enable_api" result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) @@ -609,3 +614,222 @@ async def test_reconfigure_cannot_connect( # changed entry assert mock_config_entry.data[CONF_IP_ADDRESS] == "1.0.0.127" + + +### TESTS FOR V2 IMPLEMENTATION ### + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_manual_flow_works_with_v2_api_support( + hass: HomeAssistant, + mock_homewizardenergy_v2: MagicMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test config flow accepts user configuration and triggers authorization when detected v2 support.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + # Simulate v2 support but not authorized + mock_homewizardenergy_v2.device.side_effect = UnauthorizedError + mock_homewizardenergy_v2.get_token.side_effect = DisabledError + + with patch( + "homeassistant.components.homewizard.config_flow.has_v2_api", return_value=True + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_IP_ADDRESS: "2.2.2.2"} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "authorize" + + # Simulate user authorizing + mock_homewizardenergy_v2.device.side_effect = None + mock_homewizardenergy_v2.get_token.side_effect = None + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_manual_flow_detects_failed_user_authorization( + hass: HomeAssistant, + mock_homewizardenergy_v2: MagicMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test config flow accepts user configuration and detects failed button press by user.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + # Simulate v2 support but not authorized + mock_homewizardenergy_v2.device.side_effect = UnauthorizedError + mock_homewizardenergy_v2.get_token.side_effect = DisabledError + + with patch( + "homeassistant.components.homewizard.config_flow.has_v2_api", return_value=True + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_IP_ADDRESS: "2.2.2.2"} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "authorize" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "authorize" + assert result["errors"] == {"base": "authorization_failed"} + + # Restore normal functionality + mock_homewizardenergy_v2.device.side_effect = None + mock_homewizardenergy_v2.get_token.side_effect = None + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_reauth_flow_updates_token( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_config_entry_v2: MockConfigEntry, + mock_homewizardenergy_v2: MagicMock, +) -> None: + """Test reauth flow token is updated.""" + + mock_config_entry_v2.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry_v2.entry_id) + await hass.async_block_till_done() + + result = await mock_config_entry_v2.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm_update_token" + + # Simulate user pressing the button and getting a new token + mock_homewizardenergy_v2.get_token.return_value = "cool_new_token" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + # Verify that the token was updated + await hass.async_block_till_done() + assert ( + hass.config_entries.async_entries(DOMAIN)[0].data.get(CONF_TOKEN) + == "cool_new_token" + ) + assert len(mock_setup_entry.mock_calls) == 2 + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_reauth_flow_handles_user_not_pressing_button( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_config_entry_v2: MockConfigEntry, + mock_homewizardenergy_v2: MagicMock, +) -> None: + """Test reauth flow token is updated.""" + + mock_config_entry_v2.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry_v2.entry_id) + await hass.async_block_till_done() + + result = await mock_config_entry_v2.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm_update_token" + assert result["errors"] is None + + # Simulate button not being pressed + mock_homewizardenergy_v2.get_token.side_effect = DisabledError + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "authorization_failed"} + + # Simulate user pressing the button and getting a new token + mock_homewizardenergy_v2.get_token.side_effect = None + mock_homewizardenergy_v2.get_token.return_value = "cool_new_token" + + # Successful reauth + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + # Verify that the token was updated + await hass.async_block_till_done() + assert ( + hass.config_entries.async_entries(DOMAIN)[0].data.get(CONF_TOKEN) + == "cool_new_token" + ) + assert len(mock_setup_entry.mock_calls) == 2 + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_discovery_with_v2_api_ask_authorization( + hass: HomeAssistant, + # mock_setup_entry: AsyncMock, + mock_homewizardenergy_v2: MagicMock, +) -> None: + """Test discovery detecting missing discovery info.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=ZeroconfServiceInfo( + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], + port=443, + hostname="p1meter-abcdef.local.", + type="", + name="", + properties={ + "api_version": "2.0.0", + "id": "appliance/p1dongle/5c2fafabcdef", + "product_name": "P1 meter", + "product_type": "HWE-P1", + "serial": "5c2fafabcdef", + }, + ), + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "discovery_confirm" + + mock_homewizardenergy_v2.device.side_effect = UnauthorizedError + mock_homewizardenergy_v2.get_token.side_effect = DisabledError + + with patch( + "homeassistant.components.homewizard.config_flow.has_v2_api", return_value=True + ): + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "authorize" + + mock_homewizardenergy_v2.get_token.side_effect = None + mock_homewizardenergy_v2.get_token.return_value = "cool_token" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"][CONF_TOKEN] == "cool_token" diff --git a/tests/components/homewizard/test_diagnostics.py b/tests/components/homewizard/test_diagnostics.py index e3d7f4e6da9..c7063d497c3 100644 --- a/tests/components/homewizard/test_diagnostics.py +++ b/tests/components/homewizard/test_diagnostics.py @@ -21,6 +21,7 @@ from tests.typing import ClientSessionGenerator "SDM630", "HWE-KWH1", "HWE-KWH3", + "HWE-BAT", ], ) async def test_diagnostics( diff --git a/tests/components/homewizard/test_init.py b/tests/components/homewizard/test_init.py index ed4bad8b2e8..77366da84c5 100644 --- a/tests/components/homewizard/test_init.py +++ b/tests/components/homewizard/test_init.py @@ -4,7 +4,7 @@ from datetime import timedelta from unittest.mock import MagicMock from freezegun.api import FrozenDateTimeFactory -from homewizard_energy.errors import DisabledError +from homewizard_energy.errors import DisabledError, UnauthorizedError import pytest from homeassistant.components.homewizard.const import DOMAIN @@ -14,12 +14,12 @@ from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry, async_fire_time_changed -async def test_load_unload( +async def test_load_unload_v1( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_homewizardenergy: MagicMock, ) -> None: - """Test loading and unloading of integration.""" + """Test loading and unloading of integration with v1 config.""" mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() @@ -33,6 +33,25 @@ async def test_load_unload( assert mock_config_entry.state is ConfigEntryState.NOT_LOADED +async def test_load_unload_v2( + hass: HomeAssistant, + mock_config_entry_v2: MockConfigEntry, + mock_homewizardenergy_v2: MagicMock, +) -> None: + """Test loading and unloading of integration with v2 config.""" + mock_config_entry_v2.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry_v2.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry_v2.state is ConfigEntryState.LOADED + assert len(mock_homewizardenergy_v2.combined.mock_calls) == 1 + + await hass.config_entries.async_unload(mock_config_entry_v2.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry_v2.state is ConfigEntryState.NOT_LOADED + + async def test_load_failed_host_unavailable( hass: HomeAssistant, mock_config_entry: MockConfigEntry, @@ -64,7 +83,7 @@ async def test_load_detect_api_disabled( assert len(flows) == 1 flow = flows[0] - assert flow.get("step_id") == "reauth_confirm" + assert flow.get("step_id") == "reauth_enable_api" assert flow.get("handler") == DOMAIN assert "context" in flow @@ -72,6 +91,31 @@ async def test_load_detect_api_disabled( assert flow["context"].get("entry_id") == mock_config_entry.entry_id +async def test_load_detect_invalid_token( + hass: HomeAssistant, + mock_config_entry_v2: MockConfigEntry, + mock_homewizardenergy_v2: MagicMock, +) -> None: + """Test setup detects invalid token.""" + mock_homewizardenergy_v2.combined.side_effect = UnauthorizedError() + mock_config_entry_v2.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry_v2.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry_v2.state is ConfigEntryState.SETUP_ERROR + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + + flow = flows[0] + assert flow.get("step_id") == "reauth_confirm_update_token" + assert flow.get("handler") == DOMAIN + + assert "context" in flow + assert flow["context"].get("source") == SOURCE_REAUTH + assert flow["context"].get("entry_id") == mock_config_entry_v2.entry_id + + @pytest.mark.usefixtures("mock_homewizardenergy") async def test_load_removes_reauth_flow( hass: HomeAssistant, @@ -128,5 +172,5 @@ async def test_disablederror_reloads_integration( assert len(flows) == 1 flow = flows[0] - assert flow.get("step_id") == "reauth_confirm" + assert flow.get("step_id") == "reauth_enable_api" assert flow.get("handler") == DOMAIN diff --git a/tests/components/homewizard/test_repair.py b/tests/components/homewizard/test_repair.py new file mode 100644 index 00000000000..a59d6f415dd --- /dev/null +++ b/tests/components/homewizard/test_repair.py @@ -0,0 +1,82 @@ +"""Test the homewizard config flow.""" + +from unittest.mock import MagicMock, patch + +from homewizard_energy.errors import DisabledError + +from homeassistant.components.homewizard.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_TOKEN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import issue_registry as ir +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry +from tests.components.repairs import ( + async_process_repairs_platforms, + process_repair_fix_flow, + start_repair_fix_flow, +) +from tests.typing import ClientSessionGenerator + + +async def test_repair_acquires_token( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_homewizardenergy: MagicMock, + mock_homewizardenergy_v2: MagicMock, + hass_client: ClientSessionGenerator, + issue_registry: ir.IssueRegistry, +) -> None: + """Test repair flow is able to obtain and use token.""" + + assert await async_setup_component(hass, "repairs", {}) + await async_process_repairs_platforms(hass) + client = await hass_client() + + mock_config_entry.add_to_hass(hass) + + with patch("homeassistant.components.homewizard.has_v2_api", return_value=True): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + + # Get active repair flow + issue_id = f"migrate_to_v2_api_{mock_config_entry.entry_id}" + issue = issue_registry.async_get_issue(DOMAIN, issue_id) + assert issue is not None + + assert issue.data.get("entry_id") == mock_config_entry.entry_id + + mock_homewizardenergy_v2.get_token.side_effect = DisabledError + + result = await start_repair_fix_flow(client, DOMAIN, issue_id) + + flow_id = result["flow_id"] + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "confirm" + + result = await process_repair_fix_flow(client, flow_id) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "authorize" + + # Simulate user not pressing the button + result = await process_repair_fix_flow(client, flow_id, json={}) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "authorize" + assert result["errors"] == {"base": "authorization_failed"} + + # Simulate user pressing the button and getting a new token + mock_homewizardenergy_v2.get_token.side_effect = None + mock_homewizardenergy_v2.get_token.return_value = "cool_token" + result = await process_repair_fix_flow(client, flow_id, json={}) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert mock_config_entry.data[CONF_TOKEN] == "cool_token" + assert mock_config_entry.state is ConfigEntryState.LOADED + + assert issue_registry.async_get_issue(DOMAIN, issue_id) is None diff --git a/tests/components/homewizard/test_sensor.py b/tests/components/homewizard/test_sensor.py index 128a3de2ebf..c1474c4b947 100644 --- a/tests/components/homewizard/test_sensor.py +++ b/tests/components/homewizard/test_sensor.py @@ -291,6 +291,19 @@ pytestmark = [ "sensor.water_meter_water", ], ), + ( + "HWE-BAT", + [ + "sensor.device_battery_cycles", + "sensor.device_current", + "sensor.device_energy_export", + "sensor.device_energy_import", + "sensor.device_frequency", + "sensor.device_power", + "sensor.device_state_of_charge", + "sensor.device_voltage", + ], + ), ], ) async def test_sensors( @@ -431,6 +444,14 @@ async def test_sensors( "sensor.device_wi_fi_strength", ], ), + ( + "HWE-BAT", + [ + "sensor.device_current", + "sensor.device_frequency", + "sensor.device_voltage", + ], + ), ], ) async def test_disabled_by_default_sensors( @@ -492,6 +513,7 @@ async def test_external_sensors_unreachable( "sensor.device_apparent_power_phase_3", "sensor.device_apparent_power", "sensor.device_average_demand", + "sensor.device_battery_cycles", "sensor.device_current_phase_1", "sensor.device_current_phase_2", "sensor.device_current_phase_3", @@ -521,6 +543,7 @@ async def test_external_sensors_unreachable( "sensor.device_reactive_power", "sensor.device_smart_meter_identifier", "sensor.device_smart_meter_model", + "sensor.device_state_of_charge", "sensor.device_tariff", "sensor.device_total_water_usage", "sensor.device_voltage_phase_1", @@ -543,6 +566,7 @@ async def test_external_sensors_unreachable( "sensor.device_apparent_power_phase_2", "sensor.device_apparent_power_phase_3", "sensor.device_average_demand", + "sensor.device_battery_cycles", "sensor.device_current_phase_1", "sensor.device_current_phase_2", "sensor.device_current_phase_3", @@ -568,6 +592,7 @@ async def test_external_sensors_unreachable( "sensor.device_reactive_power_phase_3", "sensor.device_smart_meter_identifier", "sensor.device_smart_meter_model", + "sensor.device_state_of_charge", "sensor.device_tariff", "sensor.device_total_water_usage", "sensor.device_voltage_phase_1", @@ -590,6 +615,7 @@ async def test_external_sensors_unreachable( "sensor.device_apparent_power_phase_3", "sensor.device_apparent_power", "sensor.device_average_demand", + "sensor.device_battery_cycles", "sensor.device_current_phase_1", "sensor.device_current_phase_2", "sensor.device_current_phase_3", @@ -623,6 +649,7 @@ async def test_external_sensors_unreachable( "sensor.device_reactive_power", "sensor.device_smart_meter_identifier", "sensor.device_smart_meter_model", + "sensor.device_state_of_charge", "sensor.device_tariff", "sensor.device_voltage_phase_1", "sensor.device_voltage_phase_2", @@ -644,6 +671,7 @@ async def test_external_sensors_unreachable( "sensor.device_apparent_power_phase_3", "sensor.device_average_demand", "sensor.device_average_demand", + "sensor.device_battery_cycles", "sensor.device_current_phase_1", "sensor.device_current_phase_2", "sensor.device_current_phase_3", @@ -670,6 +698,7 @@ async def test_external_sensors_unreachable( "sensor.device_reactive_power_phase_3", "sensor.device_smart_meter_identifier", "sensor.device_smart_meter_model", + "sensor.device_state_of_charge", "sensor.device_tariff", "sensor.device_total_water_usage", "sensor.device_voltage_phase_1", @@ -688,6 +717,7 @@ async def test_external_sensors_unreachable( "SDM630", [ "sensor.device_average_demand", + "sensor.device_battery_cycles", "sensor.device_current_phase_1", "sensor.device_current_phase_2", "sensor.device_current_phase_3", @@ -706,6 +736,7 @@ async def test_external_sensors_unreachable( "sensor.device_power_failures_detected", "sensor.device_smart_meter_identifier", "sensor.device_smart_meter_model", + "sensor.device_state_of_charge", "sensor.device_tariff", "sensor.device_total_water_usage", "sensor.device_voltage_phase_1", @@ -729,6 +760,7 @@ async def test_external_sensors_unreachable( "sensor.device_apparent_power_phase_3", "sensor.device_average_demand", "sensor.device_average_demand", + "sensor.device_battery_cycles", "sensor.device_current_phase_1", "sensor.device_current_phase_2", "sensor.device_current_phase_3", @@ -755,6 +787,7 @@ async def test_external_sensors_unreachable( "sensor.device_reactive_power_phase_3", "sensor.device_smart_meter_identifier", "sensor.device_smart_meter_model", + "sensor.device_state_of_charge", "sensor.device_tariff", "sensor.device_total_water_usage", "sensor.device_voltage_phase_1", @@ -773,6 +806,7 @@ async def test_external_sensors_unreachable( "HWE-KWH3", [ "sensor.device_average_demand", + "sensor.device_battery_cycles", "sensor.device_current_phase_1", "sensor.device_current_phase_2", "sensor.device_current_phase_3", @@ -791,6 +825,7 @@ async def test_external_sensors_unreachable( "sensor.device_power_failures_detected", "sensor.device_smart_meter_identifier", "sensor.device_smart_meter_model", + "sensor.device_state_of_charge", "sensor.device_tariff", "sensor.device_total_water_usage", "sensor.device_voltage_phase_1", @@ -806,6 +841,54 @@ async def test_external_sensors_unreachable( "sensor.device_water_usage", ], ), + ( + "HWE-BAT", + [ + "sensor.device_apparent_power_phase_1", + "sensor.device_apparent_power_phase_2", + "sensor.device_apparent_power_phase_3", + "sensor.device_apparent_power", + "sensor.device_average_demand", + "sensor.device_current_phase_1", + "sensor.device_current_phase_2", + "sensor.device_current_phase_3", + "sensor.device_dsmr_version", + "sensor.device_energy_export_tariff_1", + "sensor.device_energy_export_tariff_2", + "sensor.device_energy_export_tariff_4", + "sensor.device_energy_import_tariff_1", + "sensor.device_energy_import_tariff_2", + "sensor.device_energy_import_tariff_3", + "sensor.device_energy_import_tariff_4", + "sensor.device_long_power_failures_detected", + "sensor.device_peak_demand_current_month", + "sensor.device_power_factor_phase_1", + "sensor.device_power_factor_phase_2", + "sensor.device_power_factor_phase_3", + "sensor.device_power_factor", + "sensor.device_power_failures_detected", + "sensor.device_power_phase_1", + "sensor.device_power_phase_3", + "sensor.device_reactive_power_phase_1", + "sensor.device_reactive_power_phase_2", + "sensor.device_reactive_power_phase_3", + "sensor.device_reactive_power", + "sensor.device_smart_meter_identifier", + "sensor.device_smart_meter_model", + "sensor.device_tariff", + "sensor.device_total_water_usage", + "sensor.device_voltage_phase_1", + "sensor.device_voltage_phase_2", + "sensor.device_voltage_phase_3", + "sensor.device_voltage_sags_detected_phase_1", + "sensor.device_voltage_sags_detected_phase_2", + "sensor.device_voltage_sags_detected_phase_3", + "sensor.device_voltage_swells_detected_phase_1", + "sensor.device_voltage_swells_detected_phase_2", + "sensor.device_voltage_swells_detected_phase_3", + "sensor.device_water_usage", + ], + ), ], ) async def test_entities_not_created_for_device( From 0e263aa42736df64f12b3fc0c1a66b2d216626c7 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 28 Jan 2025 19:10:20 +0100 Subject: [PATCH 0154/3148] Standardize homeassistant imports in full-CI tests (#136735) --- tests/components/history/test_init.py | 2 +- tests/components/history/test_websocket_api.py | 2 +- tests/components/history/test_websocket_api_schema_32.py | 2 +- tests/components/light/test_device_condition.py | 2 +- tests/components/light/test_device_trigger.py | 2 +- tests/components/logbook/common.py | 2 +- tests/components/logbook/test_init.py | 4 ++-- tests/components/logbook/test_websocket_api.py | 2 +- .../recorder/auto_repairs/statistics/test_duplicates.py | 2 +- tests/components/recorder/common.py | 2 +- tests/components/recorder/db_schema_0.py | 2 +- tests/components/recorder/db_schema_16.py | 2 +- tests/components/recorder/db_schema_18.py | 2 +- tests/components/recorder/db_schema_22.py | 2 +- tests/components/recorder/db_schema_23.py | 2 +- tests/components/recorder/db_schema_23_with_newer_columns.py | 2 +- tests/components/recorder/db_schema_25.py | 2 +- tests/components/recorder/db_schema_28.py | 2 +- tests/components/recorder/db_schema_30.py | 2 +- tests/components/recorder/db_schema_32.py | 2 +- tests/components/recorder/db_schema_42.py | 2 +- tests/components/recorder/db_schema_43.py | 2 +- tests/components/recorder/db_schema_9.py | 2 +- tests/components/recorder/test_history.py | 2 +- tests/components/recorder/test_history_db_schema_32.py | 2 +- tests/components/recorder/test_history_db_schema_42.py | 4 ++-- tests/components/recorder/test_migrate.py | 2 +- tests/components/recorder/test_migration_from_schema_32.py | 2 +- tests/components/recorder/test_models.py | 2 +- tests/components/recorder/test_statistics.py | 2 +- tests/components/recorder/test_statistics_v23_migration.py | 2 +- tests/components/recorder/test_v32_migration.py | 2 +- tests/components/recorder/test_websocket_api.py | 2 +- tests/components/sensor/test_device_trigger.py | 2 +- tests/components/sensor/test_recorder.py | 2 +- tests/components/sensor/test_recorder_missing_stats.py | 2 +- 36 files changed, 38 insertions(+), 38 deletions(-) diff --git a/tests/components/history/test_init.py b/tests/components/history/test_init.py index 3b4b02a877e..f1890073567 100644 --- a/tests/components/history/test_init.py +++ b/tests/components/history/test_init.py @@ -16,7 +16,7 @@ from homeassistant.const import EVENT_HOMEASSISTANT_FINAL_WRITE from homeassistant.core import HomeAssistant, State from homeassistant.helpers.json import JSONEncoder from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.components.recorder.common import ( assert_dict_of_states_equal_without_context_and_last_changed, diff --git a/tests/components/history/test_websocket_api.py b/tests/components/history/test_websocket_api.py index 717840c6b05..01b49ad5575 100644 --- a/tests/components/history/test_websocket_api.py +++ b/tests/components/history/test_websocket_api.py @@ -14,7 +14,7 @@ from homeassistant.const import EVENT_HOMEASSISTANT_FINAL_WRITE, STATE_OFF, STAT from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.event import async_track_state_change_event from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed from tests.components.recorder.common import ( diff --git a/tests/components/history/test_websocket_api_schema_32.py b/tests/components/history/test_websocket_api_schema_32.py index 301de387c80..7b84c47e81b 100644 --- a/tests/components/history/test_websocket_api_schema_32.py +++ b/tests/components/history/test_websocket_api_schema_32.py @@ -6,7 +6,7 @@ from homeassistant.components import recorder from homeassistant.components.recorder import Recorder from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.components.recorder.common import ( async_recorder_block_till_done, diff --git a/tests/components/light/test_device_condition.py b/tests/components/light/test_device_condition.py index 94e12ffbfa5..2a5c9f0bb18 100644 --- a/tests/components/light/test_device_condition.py +++ b/tests/components/light/test_device_condition.py @@ -14,7 +14,7 @@ from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity_registry import RegistryEntryHider from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .common import MockLight diff --git a/tests/components/light/test_device_trigger.py b/tests/components/light/test_device_trigger.py index 4e8414edabc..ae54bbd2512 100644 --- a/tests/components/light/test_device_trigger.py +++ b/tests/components/light/test_device_trigger.py @@ -13,7 +13,7 @@ from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity_registry import RegistryEntryHider from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import ( MockConfigEntry, diff --git a/tests/components/logbook/common.py b/tests/components/logbook/common.py index abb118467f4..b303a34e151 100644 --- a/tests/components/logbook/common.py +++ b/tests/components/logbook/common.py @@ -16,7 +16,7 @@ from homeassistant.components.recorder.models import ( from homeassistant.core import Context from homeassistant.helpers import entity_registry as er from homeassistant.helpers.json import JSONEncoder -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util IDX_TO_NAME = dict(enumerate(EventAsRow._fields)) diff --git a/tests/components/logbook/test_init.py b/tests/components/logbook/test_init.py index 841c8ed1247..c62bdcaa824 100644 --- a/tests/components/logbook/test_init.py +++ b/tests/components/logbook/test_init.py @@ -10,6 +10,7 @@ from freezegun import freeze_time import pytest import voluptuous as vol +from homeassistant import core as ha from homeassistant.components import logbook, recorder # pylint: disable-next=hass-component-root-import @@ -40,12 +41,11 @@ from homeassistant.const import ( STATE_OFF, STATE_ON, ) -import homeassistant.core as ha from homeassistant.core import Event, HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entityfilter import CONF_ENTITY_GLOBS from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .common import MockRow, mock_humanify diff --git a/tests/components/logbook/test_websocket_api.py b/tests/components/logbook/test_websocket_api.py index 50139d0f4f7..7b2550ccc82 100644 --- a/tests/components/logbook/test_websocket_api.py +++ b/tests/components/logbook/test_websocket_api.py @@ -37,7 +37,7 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entityfilter import CONF_ENTITY_GLOBS from homeassistant.helpers.event import async_track_state_change_event from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed from tests.components.recorder.common import ( diff --git a/tests/components/recorder/auto_repairs/statistics/test_duplicates.py b/tests/components/recorder/auto_repairs/statistics/test_duplicates.py index 78a7ddaa300..2466a761364 100644 --- a/tests/components/recorder/auto_repairs/statistics/test_duplicates.py +++ b/tests/components/recorder/auto_repairs/statistics/test_duplicates.py @@ -17,7 +17,7 @@ from homeassistant.components.recorder.auto_repairs.statistics.duplicates import from homeassistant.components.recorder.statistics import async_add_external_statistics from homeassistant.components.recorder.util import session_scope from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from ...common import async_wait_recording_done diff --git a/tests/components/recorder/common.py b/tests/components/recorder/common.py index fbb0991c960..792000c3725 100644 --- a/tests/components/recorder/common.py +++ b/tests/components/recorder/common.py @@ -37,7 +37,7 @@ from homeassistant.components.recorder.db_schema import ( from homeassistant.components.recorder.tasks import RecorderTask, StatisticsTask from homeassistant.const import UnitOfTemperature from homeassistant.core import Event, HomeAssistant, State -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import db_schema_0 diff --git a/tests/components/recorder/db_schema_0.py b/tests/components/recorder/db_schema_0.py index 12336dcc96a..12228e99211 100644 --- a/tests/components/recorder/db_schema_0.py +++ b/tests/components/recorder/db_schema_0.py @@ -23,7 +23,7 @@ from sqlalchemy.orm.session import Session from homeassistant.core import Event, EventOrigin, State, split_entity_id from homeassistant.helpers.json import JSONEncoder -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util # SQLAlchemy Schema Base = declarative_base() diff --git a/tests/components/recorder/db_schema_16.py b/tests/components/recorder/db_schema_16.py index 522bd6ea367..3455af1d019 100644 --- a/tests/components/recorder/db_schema_16.py +++ b/tests/components/recorder/db_schema_16.py @@ -36,7 +36,7 @@ from homeassistant.const import ( ) from homeassistant.core import Context, Event, EventOrigin, State, split_entity_id from homeassistant.helpers.json import JSONEncoder -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util # SQLAlchemy Schema Base = declarative_base() diff --git a/tests/components/recorder/db_schema_18.py b/tests/components/recorder/db_schema_18.py index 026227f68a0..9e9dc786580 100644 --- a/tests/components/recorder/db_schema_18.py +++ b/tests/components/recorder/db_schema_18.py @@ -36,7 +36,7 @@ from homeassistant.const import ( ) from homeassistant.core import Context, Event, EventOrigin, State, split_entity_id from homeassistant.helpers.json import JSONEncoder -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util # SQLAlchemy Schema Base = declarative_base() diff --git a/tests/components/recorder/db_schema_22.py b/tests/components/recorder/db_schema_22.py index 770d25c9cf2..766ff88ff72 100644 --- a/tests/components/recorder/db_schema_22.py +++ b/tests/components/recorder/db_schema_22.py @@ -42,7 +42,7 @@ from homeassistant.const import ( ) from homeassistant.core import Context, Event, EventOrigin, State, split_entity_id from homeassistant.helpers.json import JSONEncoder -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util # SQLAlchemy Schema Base = declarative_base() diff --git a/tests/components/recorder/db_schema_23.py b/tests/components/recorder/db_schema_23.py index 8cf3e16e5a8..fe36029b61f 100644 --- a/tests/components/recorder/db_schema_23.py +++ b/tests/components/recorder/db_schema_23.py @@ -41,7 +41,7 @@ from homeassistant.const import ( ) from homeassistant.core import Context, Event, EventOrigin, State, split_entity_id from homeassistant.helpers.json import JSONEncoder -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util # SQLAlchemy Schema Base = declarative_base() diff --git a/tests/components/recorder/db_schema_23_with_newer_columns.py b/tests/components/recorder/db_schema_23_with_newer_columns.py index 2ba62ba78f5..a77bc1fcbd5 100644 --- a/tests/components/recorder/db_schema_23_with_newer_columns.py +++ b/tests/components/recorder/db_schema_23_with_newer_columns.py @@ -49,7 +49,7 @@ from homeassistant.const import ( ) from homeassistant.core import Context, Event, EventOrigin, State, split_entity_id from homeassistant.helpers.json import JSONEncoder -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util # SQLAlchemy Schema Base = declarative_base() diff --git a/tests/components/recorder/db_schema_25.py b/tests/components/recorder/db_schema_25.py index 3b7c4a300c2..bd3cb23bd07 100644 --- a/tests/components/recorder/db_schema_25.py +++ b/tests/components/recorder/db_schema_25.py @@ -37,7 +37,7 @@ from homeassistant.const import ( ) from homeassistant.core import Context, Event, EventOrigin, State, split_entity_id from homeassistant.helpers.typing import UNDEFINED, UndefinedType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util # SQLAlchemy Schema Base = declarative_base() diff --git a/tests/components/recorder/db_schema_28.py b/tests/components/recorder/db_schema_28.py index 4d7f893de25..7f34343d995 100644 --- a/tests/components/recorder/db_schema_28.py +++ b/tests/components/recorder/db_schema_28.py @@ -43,7 +43,7 @@ from homeassistant.const import ( MAX_LENGTH_STATE_STATE, ) from homeassistant.core import Context, Event, EventOrigin, State, split_entity_id -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util # SQLAlchemy Schema Base = declarative_base() diff --git a/tests/components/recorder/db_schema_30.py b/tests/components/recorder/db_schema_30.py index 97c33334111..185dce786de 100644 --- a/tests/components/recorder/db_schema_30.py +++ b/tests/components/recorder/db_schema_30.py @@ -50,7 +50,7 @@ from homeassistant.const import ( from homeassistant.core import Context, Event, EventOrigin, State, split_entity_id from homeassistant.helpers import entity_registry as er from homeassistant.helpers.json import JSON_DUMP, json_bytes -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from homeassistant.util.json import JSON_DECODE_EXCEPTIONS, json_loads ALL_DOMAIN_EXCLUDE_ATTRS = {ATTR_ATTRIBUTION, ATTR_RESTORED, ATTR_SUPPORTED_FEATURES} diff --git a/tests/components/recorder/db_schema_32.py b/tests/components/recorder/db_schema_32.py index 39ddb8e3148..daa7fb6977c 100644 --- a/tests/components/recorder/db_schema_32.py +++ b/tests/components/recorder/db_schema_32.py @@ -51,7 +51,7 @@ from homeassistant.const import ( from homeassistant.core import Context, Event, EventOrigin, State, split_entity_id from homeassistant.helpers import entity_registry as er from homeassistant.helpers.json import JSON_DUMP, json_bytes -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from homeassistant.util.json import JSON_DECODE_EXCEPTIONS, json_loads ALL_DOMAIN_EXCLUDE_ATTRS = {ATTR_ATTRIBUTION, ATTR_RESTORED, ATTR_SUPPORTED_FEATURES} diff --git a/tests/components/recorder/db_schema_42.py b/tests/components/recorder/db_schema_42.py index efeade46562..a5381d633cb 100644 --- a/tests/components/recorder/db_schema_42.py +++ b/tests/components/recorder/db_schema_42.py @@ -66,7 +66,7 @@ from homeassistant.const import ( ) from homeassistant.core import Context, Event, EventOrigin, State from homeassistant.helpers.json import JSON_DUMP, json_bytes, json_bytes_strip_null -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from homeassistant.util.json import ( JSON_DECODE_EXCEPTIONS, json_loads, diff --git a/tests/components/recorder/db_schema_43.py b/tests/components/recorder/db_schema_43.py index 8e77e8782ee..379e6fbd416 100644 --- a/tests/components/recorder/db_schema_43.py +++ b/tests/components/recorder/db_schema_43.py @@ -66,7 +66,7 @@ from homeassistant.const import ( ) from homeassistant.core import Context, Event, EventOrigin, EventStateChangedData, State from homeassistant.helpers.json import JSON_DUMP, json_bytes, json_bytes_strip_null -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from homeassistant.util.json import ( JSON_DECODE_EXCEPTIONS, json_loads, diff --git a/tests/components/recorder/db_schema_9.py b/tests/components/recorder/db_schema_9.py index f9a8c2d2cad..784e326e1c3 100644 --- a/tests/components/recorder/db_schema_9.py +++ b/tests/components/recorder/db_schema_9.py @@ -25,7 +25,7 @@ from sqlalchemy.orm.session import Session from homeassistant.core import Context, Event, EventOrigin, State, split_entity_id from homeassistant.helpers.json import JSONEncoder -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util # SQLAlchemy Schema Base = declarative_base() diff --git a/tests/components/recorder/test_history.py b/tests/components/recorder/test_history.py index d9dbbf191f6..166451cc971 100644 --- a/tests/components/recorder/test_history.py +++ b/tests/components/recorder/test_history.py @@ -22,7 +22,7 @@ from homeassistant.components.recorder.models import process_timestamp from homeassistant.components.recorder.util import session_scope from homeassistant.core import HomeAssistant, State from homeassistant.helpers.json import JSONEncoder -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .common import ( assert_dict_of_states_equal_without_context_and_last_changed, diff --git a/tests/components/recorder/test_history_db_schema_32.py b/tests/components/recorder/test_history_db_schema_32.py index bfe5c852ca6..142d2fc87f6 100644 --- a/tests/components/recorder/test_history_db_schema_32.py +++ b/tests/components/recorder/test_history_db_schema_32.py @@ -17,7 +17,7 @@ from homeassistant.components.recorder.models import process_timestamp from homeassistant.components.recorder.util import session_scope from homeassistant.core import HomeAssistant, State from homeassistant.helpers.json import JSONEncoder -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .common import ( assert_dict_of_states_equal_without_context_and_last_changed, diff --git a/tests/components/recorder/test_history_db_schema_42.py b/tests/components/recorder/test_history_db_schema_42.py index 23ac6f9fb8a..1523f373ea8 100644 --- a/tests/components/recorder/test_history_db_schema_42.py +++ b/tests/components/recorder/test_history_db_schema_42.py @@ -10,15 +10,15 @@ from unittest.mock import sentinel from freezegun import freeze_time import pytest +from homeassistant import core as ha from homeassistant.components import recorder from homeassistant.components.recorder import Recorder, history from homeassistant.components.recorder.filters import Filters from homeassistant.components.recorder.models import process_timestamp from homeassistant.components.recorder.util import session_scope -import homeassistant.core as ha from homeassistant.core import HomeAssistant, State from homeassistant.helpers.json import JSONEncoder -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .common import ( assert_dict_of_states_equal_without_context_and_last_changed, diff --git a/tests/components/recorder/test_migrate.py b/tests/components/recorder/test_migrate.py index e60a4705ac8..081394c780c 100644 --- a/tests/components/recorder/test_migrate.py +++ b/tests/components/recorder/test_migrate.py @@ -31,7 +31,7 @@ from homeassistant.components.recorder.db_schema import ( from homeassistant.components.recorder.util import session_scope from homeassistant.core import HomeAssistant, State from homeassistant.helpers import recorder as recorder_helper -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .common import async_wait_recording_done, create_engine_test from .conftest import InstrumentedMigration diff --git a/tests/components/recorder/test_migration_from_schema_32.py b/tests/components/recorder/test_migration_from_schema_32.py index 94b7518edb7..0a5f5d4da73 100644 --- a/tests/components/recorder/test_migration_from_schema_32.py +++ b/tests/components/recorder/test_migration_from_schema_32.py @@ -41,7 +41,7 @@ from homeassistant.components.recorder.util import ( session_scope, ) from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from homeassistant.util.ulid import bytes_to_ulid, ulid_at_time, ulid_to_bytes from .common import ( diff --git a/tests/components/recorder/test_models.py b/tests/components/recorder/test_models.py index b2894883ff2..689441260c7 100644 --- a/tests/components/recorder/test_models.py +++ b/tests/components/recorder/test_models.py @@ -5,6 +5,7 @@ from unittest.mock import PropertyMock import pytest +from homeassistant import core as ha from homeassistant.components.recorder.const import SupportedDialect from homeassistant.components.recorder.db_schema import ( EventData, @@ -18,7 +19,6 @@ from homeassistant.components.recorder.models import ( process_timestamp_to_utc_isoformat, ) from homeassistant.const import EVENT_STATE_CHANGED -import homeassistant.core as ha from homeassistant.exceptions import InvalidEntityFormatError from homeassistant.util import dt as dt_util from homeassistant.util.json import json_loads diff --git a/tests/components/recorder/test_statistics.py b/tests/components/recorder/test_statistics.py index 2baf7f2bcbc..6e192295c58 100644 --- a/tests/components/recorder/test_statistics.py +++ b/tests/components/recorder/test_statistics.py @@ -41,7 +41,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .common import ( assert_dict_of_states_equal_without_context_and_last_changed, diff --git a/tests/components/recorder/test_statistics_v23_migration.py b/tests/components/recorder/test_statistics_v23_migration.py index dafa4da81ee..49b8836af70 100644 --- a/tests/components/recorder/test_statistics_v23_migration.py +++ b/tests/components/recorder/test_statistics_v23_migration.py @@ -17,7 +17,7 @@ import pytest from homeassistant.components import recorder from homeassistant.components.recorder import get_instance from homeassistant.components.recorder.util import session_scope -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .common import ( CREATE_ENGINE_TARGET, diff --git a/tests/components/recorder/test_v32_migration.py b/tests/components/recorder/test_v32_migration.py index 58be23bdc85..c4c1285990d 100644 --- a/tests/components/recorder/test_v32_migration.py +++ b/tests/components/recorder/test_v32_migration.py @@ -17,7 +17,7 @@ from homeassistant.components.recorder.queries import select_event_type_ids from homeassistant.components.recorder.util import session_scope from homeassistant.const import EVENT_STATE_CHANGED from homeassistant.core import Event, EventOrigin, State -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .common import async_wait_recording_done from .conftest import instrument_migration diff --git a/tests/components/recorder/test_websocket_api.py b/tests/components/recorder/test_websocket_api.py index 94ed8da1b92..9e5172ae1f0 100644 --- a/tests/components/recorder/test_websocket_api.py +++ b/tests/components/recorder/test_websocket_api.py @@ -27,7 +27,7 @@ from homeassistant.components.sensor import UNIT_CONVERTERS from homeassistant.core import HomeAssistant from homeassistant.helpers import recorder as recorder_helper from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from homeassistant.util.unit_system import METRIC_SYSTEM, US_CUSTOMARY_SYSTEM from .common import ( diff --git a/tests/components/sensor/test_device_trigger.py b/tests/components/sensor/test_device_trigger.py index f50e92bc9df..f35c9520f71 100644 --- a/tests/components/sensor/test_device_trigger.py +++ b/tests/components/sensor/test_device_trigger.py @@ -21,7 +21,7 @@ from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity_registry import RegistryEntryHider from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from homeassistant.util.json import load_json from .common import UNITS_OF_MEASUREMENT, MockSensor diff --git a/tests/components/sensor/test_recorder.py b/tests/components/sensor/test_recorder.py index fcf5a711c46..615960defbb 100644 --- a/tests/components/sensor/test_recorder.py +++ b/tests/components/sensor/test_recorder.py @@ -40,7 +40,7 @@ from homeassistant.const import ATTR_FRIENDLY_NAME, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant, State from homeassistant.helpers import issue_registry as ir from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from homeassistant.util.unit_system import METRIC_SYSTEM, US_CUSTOMARY_SYSTEM from .common import MockSensor diff --git a/tests/components/sensor/test_recorder_missing_stats.py b/tests/components/sensor/test_recorder_missing_stats.py index 449ffd55727..fd28a7052a5 100644 --- a/tests/components/sensor/test_recorder_missing_stats.py +++ b/tests/components/sensor/test_recorder_missing_stats.py @@ -17,7 +17,7 @@ from homeassistant.components.recorder.util import session_scope from homeassistant.core import CoreState from homeassistant.helpers import recorder as recorder_helper from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import async_test_home_assistant from tests.components.recorder.common import ( From d5568ff95543018aaf488571bad45ed7d620ed4f Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 28 Jan 2025 19:11:19 +0100 Subject: [PATCH 0155/3148] Standardize homeassistant imports in full-CI components (#136731) Standardize homeassistant imports in components --- homeassistant/components/alexa/flash_briefings.py | 2 +- homeassistant/components/alexa/state_report.py | 2 +- .../components/application_credentials/__init__.py | 7 +++++-- homeassistant/components/auth/mfa_setup_flow.py | 2 +- homeassistant/components/automation/__init__.py | 3 +-- homeassistant/components/cloud/tts.py | 2 +- homeassistant/components/conversation/trigger.py | 2 +- homeassistant/components/demo/__init__.py | 3 +-- homeassistant/components/demo/calendar.py | 2 +- homeassistant/components/demo/config_flow.py | 2 +- homeassistant/components/demo/media_player.py | 2 +- homeassistant/components/demo/weather.py | 2 +- homeassistant/components/energy/sensor.py | 3 +-- homeassistant/components/ffmpeg/__init__.py | 2 +- homeassistant/components/ffmpeg/camera.py | 2 +- homeassistant/components/frontend/__init__.py | 3 +-- homeassistant/components/group/notify.py | 3 +-- homeassistant/components/hassio/auth.py | 2 +- homeassistant/components/hassio/websocket_api.py | 2 +- homeassistant/components/homeassistant/const.py | 2 +- .../components/homeassistant/triggers/time.py | 2 +- homeassistant/components/http/__init__.py | 8 ++++++-- homeassistant/components/http/ban.py | 2 +- homeassistant/components/input_boolean/__init__.py | 3 +-- homeassistant/components/input_button/__init__.py | 3 +-- homeassistant/components/input_datetime/__init__.py | 3 +-- homeassistant/components/input_number/__init__.py | 3 +-- homeassistant/components/input_select/__init__.py | 3 +-- homeassistant/components/input_text/__init__.py | 3 +-- homeassistant/components/logbook/processor.py | 2 +- homeassistant/components/logbook/rest_api.py | 2 +- homeassistant/components/logbook/websocket_api.py | 2 +- homeassistant/components/logger/__init__.py | 2 +- homeassistant/components/modbus/__init__.py | 2 +- homeassistant/components/modbus/modbus.py | 2 +- homeassistant/components/mqtt/alarm_control_panel.py | 4 ++-- homeassistant/components/mqtt/binary_sensor.py | 3 +-- homeassistant/components/mqtt/button.py | 2 +- homeassistant/components/mqtt/climate.py | 2 +- homeassistant/components/mqtt/cover.py | 2 +- homeassistant/components/mqtt/device_tracker.py | 2 +- homeassistant/components/mqtt/discovery.py | 3 +-- homeassistant/components/mqtt/event.py | 2 +- homeassistant/components/mqtt/fan.py | 2 +- homeassistant/components/mqtt/humidifier.py | 2 +- homeassistant/components/mqtt/lock.py | 2 +- homeassistant/components/mqtt/notify.py | 2 +- homeassistant/components/mqtt/scene.py | 2 +- homeassistant/components/mqtt/sensor.py | 2 +- homeassistant/components/mqtt/siren.py | 2 +- homeassistant/components/mqtt/switch.py | 2 +- homeassistant/components/mqtt/tag.py | 2 +- homeassistant/components/mqtt/vacuum.py | 2 +- homeassistant/components/mqtt/valve.py | 2 +- homeassistant/components/mqtt/water_heater.py | 2 +- homeassistant/components/network/const.py | 2 +- .../components/persistent_notification/__init__.py | 2 +- .../components/persistent_notification/trigger.py | 2 +- homeassistant/components/recorder/__init__.py | 2 +- homeassistant/components/recorder/core.py | 2 +- homeassistant/components/recorder/db_schema.py | 2 +- homeassistant/components/recorder/history/legacy.py | 2 +- homeassistant/components/recorder/history/modern.py | 2 +- homeassistant/components/recorder/models/legacy.py | 2 +- homeassistant/components/recorder/models/state.py | 2 +- homeassistant/components/recorder/models/time.py | 2 +- homeassistant/components/recorder/services.py | 4 ++-- .../recorder/table_managers/recorder_runs.py | 2 +- homeassistant/components/recorder/util.py | 2 +- homeassistant/components/script/__init__.py | 2 +- homeassistant/components/shopping_list/__init__.py | 2 +- homeassistant/components/shopping_list/intent.py | 3 +-- homeassistant/components/stream/__init__.py | 2 +- homeassistant/components/sun/trigger.py | 2 +- homeassistant/components/tag/__init__.py | 10 ++++++---- .../components/template/alarm_control_panel.py | 3 +-- homeassistant/components/template/binary_sensor.py | 3 +-- homeassistant/components/template/cover.py | 2 +- homeassistant/components/template/fan.py | 2 +- homeassistant/components/template/lock.py | 2 +- homeassistant/components/template/template_entity.py | 2 +- homeassistant/components/template/vacuum.py | 2 +- homeassistant/components/timer/__init__.py | 5 ++--- homeassistant/components/trace/__init__.py | 2 +- homeassistant/components/webhook/trigger.py | 2 +- 85 files changed, 102 insertions(+), 110 deletions(-) diff --git a/homeassistant/components/alexa/flash_briefings.py b/homeassistant/components/alexa/flash_briefings.py index 0d75ee04b7a..a37a95e59d5 100644 --- a/homeassistant/components/alexa/flash_briefings.py +++ b/homeassistant/components/alexa/flash_briefings.py @@ -12,7 +12,7 @@ from homeassistant.const import CONF_PASSWORD from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import template from homeassistant.helpers.typing import ConfigType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import ( API_PASSWORD, diff --git a/homeassistant/components/alexa/state_report.py b/homeassistant/components/alexa/state_report.py index 03b6a22007c..20e3ef1d7c7 100644 --- a/homeassistant/components/alexa/state_report.py +++ b/homeassistant/components/alexa/state_report.py @@ -24,7 +24,7 @@ from homeassistant.core import ( ) from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.significant_change import create_checker -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from homeassistant.util.json import JsonObjectType, json_loads_object from .const import ( diff --git a/homeassistant/components/application_credentials/__init__.py b/homeassistant/components/application_credentials/__init__.py index 0ee936aeef2..68f10df7886 100644 --- a/homeassistant/components/application_credentials/__init__.py +++ b/homeassistant/components/application_credentials/__init__.py @@ -26,8 +26,11 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import collection, config_entry_oauth2_flow -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import ( + collection, + config_entry_oauth2_flow, + config_validation as cv, +) from homeassistant.helpers.storage import Store from homeassistant.helpers.typing import ConfigType, VolDictType from homeassistant.loader import ( diff --git a/homeassistant/components/auth/mfa_setup_flow.py b/homeassistant/components/auth/mfa_setup_flow.py index c9efb081a01..6c85f5b7f55 100644 --- a/homeassistant/components/auth/mfa_setup_flow.py +++ b/homeassistant/components/auth/mfa_setup_flow.py @@ -12,7 +12,7 @@ from homeassistant import data_entry_flow from homeassistant.components import websocket_api from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import FlowContext -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.util.hass_dict import HassKey WS_TYPE_SETUP_MFA = "auth/setup_mfa" diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index 4e6b098ef1e..856060f8c75 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -48,8 +48,7 @@ from homeassistant.core import ( valid_entity_id, ) from homeassistant.exceptions import HomeAssistantError, ServiceNotFound, TemplateError -from homeassistant.helpers import condition -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import condition, config_validation as cv from homeassistant.helpers.entity import ToggleEntity from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.issue_registry import ( diff --git a/homeassistant/components/cloud/tts.py b/homeassistant/components/cloud/tts.py index 4dbee10fbaf..645ff4f9e75 100644 --- a/homeassistant/components/cloud/tts.py +++ b/homeassistant/components/cloud/tts.py @@ -22,7 +22,7 @@ from homeassistant.components.tts import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PLATFORM, Platform from homeassistant.core import HomeAssistant, async_get_hass, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/conversation/trigger.py b/homeassistant/components/conversation/trigger.py index 24eb54c5694..634ae1fd9aa 100644 --- a/homeassistant/components/conversation/trigger.py +++ b/homeassistant/components/conversation/trigger.py @@ -10,7 +10,7 @@ import voluptuous as vol from homeassistant.const import CONF_COMMAND, CONF_PLATFORM from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.script import ScriptRunResult from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import UNDEFINED, ConfigType diff --git a/homeassistant/components/demo/__init__.py b/homeassistant/components/demo/__init__.py index d088dfb140b..9314fc211de 100644 --- a/homeassistant/components/demo/__init__.py +++ b/homeassistant/components/demo/__init__.py @@ -4,7 +4,7 @@ from __future__ import annotations import asyncio -from homeassistant import config_entries, setup +from homeassistant import config_entries, core as ha, setup from homeassistant.components import persistent_notification from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -13,7 +13,6 @@ from homeassistant.const import ( Platform, UnitOfSoundPressure, ) -import homeassistant.core as ha from homeassistant.core import Event, HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.discovery import async_load_platform diff --git a/homeassistant/components/demo/calendar.py b/homeassistant/components/demo/calendar.py index d513bc38250..4e2fa7b3460 100644 --- a/homeassistant/components/demo/calendar.py +++ b/homeassistant/components/demo/calendar.py @@ -8,7 +8,7 @@ from homeassistant.components.calendar import CalendarEntity, CalendarEvent from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util async def async_setup_entry( diff --git a/homeassistant/components/demo/config_flow.py b/homeassistant/components/demo/config_flow.py index 53c1678aa81..6f8ee26f511 100644 --- a/homeassistant/components/demo/config_flow.py +++ b/homeassistant/components/demo/config_flow.py @@ -13,7 +13,7 @@ from homeassistant.config_entries import ( OptionsFlow, ) from homeassistant.core import callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from . import DOMAIN diff --git a/homeassistant/components/demo/media_player.py b/homeassistant/components/demo/media_player.py index 8ce77bcd615..fa3c3e3b2fc 100644 --- a/homeassistant/components/demo/media_player.py +++ b/homeassistant/components/demo/media_player.py @@ -16,7 +16,7 @@ from homeassistant.components.media_player import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util async def async_setup_entry( diff --git a/homeassistant/components/demo/weather.py b/homeassistant/components/demo/weather.py index fbc2b660efb..2468c54dde3 100644 --- a/homeassistant/components/demo/weather.py +++ b/homeassistant/components/demo/weather.py @@ -28,7 +28,7 @@ from homeassistant.const import UnitOfPressure, UnitOfSpeed, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_time_interval -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util CONDITION_CLASSES: dict[str, list[str]] = { ATTR_CONDITION_CLOUDY: [], diff --git a/homeassistant/components/energy/sensor.py b/homeassistant/components/energy/sensor.py index 199d18d6b07..eec92c32f98 100644 --- a/homeassistant/components/energy/sensor.py +++ b/homeassistant/components/energy/sensor.py @@ -29,8 +29,7 @@ from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_state_change_event from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.util import unit_conversion -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util, unit_conversion from homeassistant.util.unit_system import METRIC_SYSTEM from .const import DOMAIN diff --git a/homeassistant/components/ffmpeg/__init__.py b/homeassistant/components/ffmpeg/__init__.py index 6957702523f..fc5341b025e 100644 --- a/homeassistant/components/ffmpeg/__init__.py +++ b/homeassistant/components/ffmpeg/__init__.py @@ -17,7 +17,7 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, ) from homeassistant.core import Event, HomeAssistant, ServiceCall, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, diff --git a/homeassistant/components/ffmpeg/camera.py b/homeassistant/components/ffmpeg/camera.py index 2c46c4c29d1..03566ba162c 100644 --- a/homeassistant/components/ffmpeg/camera.py +++ b/homeassistant/components/ffmpeg/camera.py @@ -16,8 +16,8 @@ from homeassistant.components.camera import ( ) from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_aiohttp_proxy_stream -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 050d57fc358..6184d888004 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -26,8 +26,7 @@ from homeassistant.const import ( EVENT_THEMES_UPDATED, ) from homeassistant.core import HomeAssistant, ServiceCall, callback -from homeassistant.helpers import service -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, service from homeassistant.helpers.icon import async_get_icons from homeassistant.helpers.json import json_dumps_sorted from homeassistant.helpers.storage import Store diff --git a/homeassistant/components/group/notify.py b/homeassistant/components/group/notify.py index 5bba2a677d5..d6a9a6fd3c7 100644 --- a/homeassistant/components/group/notify.py +++ b/homeassistant/components/group/notify.py @@ -28,8 +28,7 @@ from homeassistant.const import ( STATE_UNAVAILABLE, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import entity_registry as er -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/hassio/auth.py b/homeassistant/components/hassio/auth.py index 6ca89ee24be..8589bc0f134 100644 --- a/homeassistant/components/hassio/auth.py +++ b/homeassistant/components/hassio/auth.py @@ -14,7 +14,7 @@ from homeassistant.auth.providers import homeassistant as auth_ha from homeassistant.components.http import KEY_HASS, KEY_HASS_USER, HomeAssistantView from homeassistant.components.http.data_validator import RequestDataValidator from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from .const import ATTR_ADDON, ATTR_PASSWORD, ATTR_USERNAME diff --git a/homeassistant/components/hassio/websocket_api.py b/homeassistant/components/hassio/websocket_api.py index 23fdc721168..c046e20feab 100644 --- a/homeassistant/components/hassio/websocket_api.py +++ b/homeassistant/components/hassio/websocket_api.py @@ -12,7 +12,7 @@ from homeassistant.components.websocket_api import ActiveConnection from homeassistant.const import ATTR_NAME from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import Unauthorized -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, diff --git a/homeassistant/components/homeassistant/const.py b/homeassistant/components/homeassistant/const.py index 7a51e218a16..7fad6728a74 100644 --- a/homeassistant/components/homeassistant/const.py +++ b/homeassistant/components/homeassistant/const.py @@ -4,7 +4,7 @@ from __future__ import annotations from typing import TYPE_CHECKING, Final -import homeassistant.core as ha +from homeassistant import core as ha from homeassistant.util.hass_dict import HassKey if TYPE_CHECKING: diff --git a/homeassistant/components/homeassistant/triggers/time.py b/homeassistant/components/homeassistant/triggers/time.py index 5cd1921d8a8..e07d806d3dc 100644 --- a/homeassistant/components/homeassistant/triggers/time.py +++ b/homeassistant/components/homeassistant/triggers/time.py @@ -35,7 +35,7 @@ from homeassistant.helpers.event import ( ) from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util _TIME_TRIGGER_ENTITY = vol.All(str, cv.entity_domain(["input_datetime", "sensor"])) _TIME_AT_SCHEMA = vol.Any(cv.time, _TIME_TRIGGER_ENTITY) diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py index 95cdee9ab9e..8ee27039441 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -37,8 +37,12 @@ from homeassistant.const import ( ) from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import frame, issue_registry as ir, storage -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import ( + config_validation as cv, + frame, + issue_registry as ir, + storage, +) from homeassistant.helpers.http import ( KEY_ALLOW_CONFIGURED_CORS, KEY_AUTHENTICATED, # noqa: F401 diff --git a/homeassistant/components/http/ban.py b/homeassistant/components/http/ban.py index b5093999836..821d44eebaa 100644 --- a/homeassistant/components/http/ban.py +++ b/homeassistant/components/http/ban.py @@ -26,7 +26,7 @@ import voluptuous as vol from homeassistant.config import load_yaml_config_file from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.hassio import get_supervisor_ip, is_hassio from homeassistant.util import dt as dt_util, yaml as yaml_util diff --git a/homeassistant/components/input_boolean/__init__.py b/homeassistant/components/input_boolean/__init__.py index 54457ab2fb7..a0a7514eaaf 100644 --- a/homeassistant/components/input_boolean/__init__.py +++ b/homeassistant/components/input_boolean/__init__.py @@ -19,8 +19,7 @@ from homeassistant.const import ( STATE_ON, ) from homeassistant.core import HomeAssistant, ServiceCall, callback -from homeassistant.helpers import collection -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import collection, config_validation as cv from homeassistant.helpers.entity import ToggleEntity from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.restore_state import RestoreEntity diff --git a/homeassistant/components/input_button/__init__.py b/homeassistant/components/input_button/__init__.py index 69ff235948d..12bc98f7674 100644 --- a/homeassistant/components/input_button/__init__.py +++ b/homeassistant/components/input_button/__init__.py @@ -16,8 +16,7 @@ from homeassistant.const import ( SERVICE_RELOAD, ) from homeassistant.core import HomeAssistant, ServiceCall, callback -from homeassistant.helpers import collection -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import collection, config_validation as cv from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.restore_state import RestoreEntity import homeassistant.helpers.service diff --git a/homeassistant/components/input_datetime/__init__.py b/homeassistant/components/input_datetime/__init__.py index 428ffccb7c1..60f882c2726 100644 --- a/homeassistant/components/input_datetime/__init__.py +++ b/homeassistant/components/input_datetime/__init__.py @@ -18,8 +18,7 @@ from homeassistant.const import ( SERVICE_RELOAD, ) from homeassistant.core import HomeAssistant, ServiceCall, callback -from homeassistant.helpers import collection -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import collection, config_validation as cv from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.restore_state import RestoreEntity import homeassistant.helpers.service diff --git a/homeassistant/components/input_number/__init__.py b/homeassistant/components/input_number/__init__.py index d52bfedfe77..3352b55442a 100644 --- a/homeassistant/components/input_number/__init__.py +++ b/homeassistant/components/input_number/__init__.py @@ -19,8 +19,7 @@ from homeassistant.const import ( SERVICE_RELOAD, ) from homeassistant.core import HomeAssistant, ServiceCall, callback -from homeassistant.helpers import collection -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import collection, config_validation as cv from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.restore_state import RestoreEntity import homeassistant.helpers.service diff --git a/homeassistant/components/input_select/__init__.py b/homeassistant/components/input_select/__init__.py index a117cf0a867..171998c02bc 100644 --- a/homeassistant/components/input_select/__init__.py +++ b/homeassistant/components/input_select/__init__.py @@ -27,8 +27,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import collection -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import collection, config_validation as cv from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.restore_state import RestoreEntity import homeassistant.helpers.service diff --git a/homeassistant/components/input_text/__init__.py b/homeassistant/components/input_text/__init__.py index 7d8f6663673..998bf35cd82 100644 --- a/homeassistant/components/input_text/__init__.py +++ b/homeassistant/components/input_text/__init__.py @@ -18,8 +18,7 @@ from homeassistant.const import ( SERVICE_RELOAD, ) from homeassistant.core import HomeAssistant, ServiceCall, callback -from homeassistant.helpers import collection -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import collection, config_validation as cv from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.restore_state import RestoreEntity import homeassistant.helpers.service diff --git a/homeassistant/components/logbook/processor.py b/homeassistant/components/logbook/processor.py index a53a604daae..1a139bb379e 100644 --- a/homeassistant/components/logbook/processor.py +++ b/homeassistant/components/logbook/processor.py @@ -36,7 +36,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, split_entity_id from homeassistant.helpers import entity_registry as er -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from homeassistant.util.event_type import EventType from .const import ( diff --git a/homeassistant/components/logbook/rest_api.py b/homeassistant/components/logbook/rest_api.py index c7ba196275b..e4a8e64cecf 100644 --- a/homeassistant/components/logbook/rest_api.py +++ b/homeassistant/components/logbook/rest_api.py @@ -17,7 +17,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import InvalidEntityFormatError from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .helpers import async_determine_event_types from .processor import EventProcessor diff --git a/homeassistant/components/logbook/websocket_api.py b/homeassistant/components/logbook/websocket_api.py index b295b845532..e3d0d8a29fa 100644 --- a/homeassistant/components/logbook/websocket_api.py +++ b/homeassistant/components/logbook/websocket_api.py @@ -17,8 +17,8 @@ from homeassistant.components.websocket_api import ActiveConnection, messages from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.helpers.json import json_bytes +from homeassistant.util import dt as dt_util from homeassistant.util.async_ import create_eager_task -import homeassistant.util.dt as dt_util from .const import DOMAIN from .helpers import ( diff --git a/homeassistant/components/logger/__init__.py b/homeassistant/components/logger/__init__.py index be6e8c1b24e..15283b246b2 100644 --- a/homeassistant/components/logger/__init__.py +++ b/homeassistant/components/logger/__init__.py @@ -9,7 +9,7 @@ import voluptuous as vol from homeassistant.const import EVENT_LOGGING_CHANGED # noqa: F401 from homeassistant.core import HomeAssistant, ServiceCall, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from . import websocket_api diff --git a/homeassistant/components/modbus/__init__.py b/homeassistant/components/modbus/__init__.py index 1a331e16482..5b1b78a5aef 100644 --- a/homeassistant/components/modbus/__init__.py +++ b/homeassistant/components/modbus/__init__.py @@ -48,7 +48,7 @@ from homeassistant.const import ( SERVICE_RELOAD, ) from homeassistant.core import Event, HomeAssistant, ServiceCall -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import async_get_platforms from homeassistant.helpers.reload import async_integration_yaml_config from homeassistant.helpers.service import async_register_admin_service diff --git a/homeassistant/components/modbus/modbus.py b/homeassistant/components/modbus/modbus.py index 319c68f50f0..81cfc3127d1 100644 --- a/homeassistant/components/modbus/modbus.py +++ b/homeassistant/components/modbus/modbus.py @@ -30,7 +30,7 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, ) from homeassistant.core import Event, HomeAssistant, ServiceCall, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.discovery import async_load_platform from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_call_later diff --git a/homeassistant/components/mqtt/alarm_control_panel.py b/homeassistant/components/mqtt/alarm_control_panel.py index 613f665c302..7bdc13d0522 100644 --- a/homeassistant/components/mqtt/alarm_control_panel.py +++ b/homeassistant/components/mqtt/alarm_control_panel.py @@ -6,7 +6,7 @@ import logging import voluptuous as vol -import homeassistant.components.alarm_control_panel as alarm +from homeassistant.components import alarm_control_panel as alarm from homeassistant.components.alarm_control_panel import ( AlarmControlPanelEntityFeature, AlarmControlPanelState, @@ -14,7 +14,7 @@ from homeassistant.components.alarm_control_panel import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_CODE, CONF_NAME, CONF_VALUE_TEMPLATE from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/mqtt/binary_sensor.py b/homeassistant/components/mqtt/binary_sensor.py index b49dc7aa24c..d736123eae8 100644 --- a/homeassistant/components/mqtt/binary_sensor.py +++ b/homeassistant/components/mqtt/binary_sensor.py @@ -26,9 +26,8 @@ from homeassistant.const import ( STATE_UNKNOWN, ) from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, event as evt from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.helpers.event as evt from homeassistant.helpers.event import async_call_later from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/mqtt/button.py b/homeassistant/components/mqtt/button.py index 8e5446b532e..b6056c2efd9 100644 --- a/homeassistant/components/mqtt/button.py +++ b/homeassistant/components/mqtt/button.py @@ -9,7 +9,7 @@ from homeassistant.components.button import DEVICE_CLASSES_SCHEMA, ButtonEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DEVICE_CLASS, CONF_NAME from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/mqtt/climate.py b/homeassistant/components/mqtt/climate.py index e62303472ed..12619609f64 100644 --- a/homeassistant/components/mqtt/climate.py +++ b/homeassistant/components/mqtt/climate.py @@ -44,7 +44,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.service_info.mqtt import ReceivePayloadType from homeassistant.helpers.template import Template diff --git a/homeassistant/components/mqtt/cover.py b/homeassistant/components/mqtt/cover.py index c7d041848f0..626e0cef64a 100644 --- a/homeassistant/components/mqtt/cover.py +++ b/homeassistant/components/mqtt/cover.py @@ -29,7 +29,7 @@ from homeassistant.const import ( STATE_OPENING, ) from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.service_info.mqtt import ReceivePayloadType from homeassistant.helpers.typing import ConfigType, VolSchemaType diff --git a/homeassistant/components/mqtt/device_tracker.py b/homeassistant/components/mqtt/device_tracker.py index bdf543e046a..d3ad57ef43d 100644 --- a/homeassistant/components/mqtt/device_tracker.py +++ b/homeassistant/components/mqtt/device_tracker.py @@ -21,7 +21,7 @@ from homeassistant.const import ( STATE_NOT_HOME, ) from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.service_info.mqtt import ReceivePayloadType from homeassistant.helpers.typing import ConfigType, VolSchemaType diff --git a/homeassistant/components/mqtt/discovery.py b/homeassistant/components/mqtt/discovery.py index 21d250db29a..a14240ce008 100644 --- a/homeassistant/components/mqtt/discovery.py +++ b/homeassistant/components/mqtt/discovery.py @@ -21,8 +21,7 @@ from homeassistant.config_entries import ( ) from homeassistant.const import CONF_DEVICE, CONF_PLATFORM from homeassistant.core import HassJobType, HomeAssistant, callback -from homeassistant.helpers import discovery_flow -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, discovery_flow from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, diff --git a/homeassistant/components/mqtt/event.py b/homeassistant/components/mqtt/event.py index f665f2c4016..5855f94dad7 100644 --- a/homeassistant/components/mqtt/event.py +++ b/homeassistant/components/mqtt/event.py @@ -17,7 +17,7 @@ from homeassistant.components.event import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DEVICE_CLASS, CONF_NAME, CONF_VALUE_TEMPLATE from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.service_info.mqtt import ReceivePayloadType from homeassistant.helpers.typing import ConfigType, VolSchemaType diff --git a/homeassistant/components/mqtt/fan.py b/homeassistant/components/mqtt/fan.py index 4d2e764a0d5..d8e96eb2734 100644 --- a/homeassistant/components/mqtt/fan.py +++ b/homeassistant/components/mqtt/fan.py @@ -27,7 +27,7 @@ from homeassistant.const import ( CONF_STATE, ) from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.service_info.mqtt import ReceivePayloadType from homeassistant.helpers.template import Template diff --git a/homeassistant/components/mqtt/humidifier.py b/homeassistant/components/mqtt/humidifier.py index 5d1af03ad24..bffe0ec1420 100644 --- a/homeassistant/components/mqtt/humidifier.py +++ b/homeassistant/components/mqtt/humidifier.py @@ -30,7 +30,7 @@ from homeassistant.const import ( CONF_STATE, ) from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.service_info.mqtt import ReceivePayloadType from homeassistant.helpers.template import Template diff --git a/homeassistant/components/mqtt/lock.py b/homeassistant/components/mqtt/lock.py index 2113dbbd5ba..895bfba3560 100644 --- a/homeassistant/components/mqtt/lock.py +++ b/homeassistant/components/mqtt/lock.py @@ -19,7 +19,7 @@ from homeassistant.const import ( CONF_VALUE_TEMPLATE, ) from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.service_info.mqtt import ReceivePayloadType from homeassistant.helpers.typing import ConfigType, TemplateVarsType diff --git a/homeassistant/components/mqtt/notify.py b/homeassistant/components/mqtt/notify.py index 84442e75e73..7e0a7fd4dd8 100644 --- a/homeassistant/components/mqtt/notify.py +++ b/homeassistant/components/mqtt/notify.py @@ -9,7 +9,7 @@ from homeassistant.components.notify import NotifyEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/mqtt/scene.py b/homeassistant/components/mqtt/scene.py index 314bd716ee0..c6651510a36 100644 --- a/homeassistant/components/mqtt/scene.py +++ b/homeassistant/components/mqtt/scene.py @@ -11,7 +11,7 @@ from homeassistant.components.scene import Scene from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME, CONF_PAYLOAD_ON from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/mqtt/sensor.py b/homeassistant/components/mqtt/sensor.py index bacbf4d323e..ad84ebb09a3 100644 --- a/homeassistant/components/mqtt/sensor.py +++ b/homeassistant/components/mqtt/sensor.py @@ -30,7 +30,7 @@ from homeassistant.const import ( STATE_UNKNOWN, ) from homeassistant.core import CALLBACK_TYPE, HomeAssistant, State, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_call_later from homeassistant.helpers.service_info.mqtt import ReceivePayloadType diff --git a/homeassistant/components/mqtt/siren.py b/homeassistant/components/mqtt/siren.py index 1cc5ba2d2e5..5e3ca76e722 100644 --- a/homeassistant/components/mqtt/siren.py +++ b/homeassistant/components/mqtt/siren.py @@ -28,7 +28,7 @@ from homeassistant.const import ( CONF_PAYLOAD_ON, ) from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.json import json_dumps from homeassistant.helpers.service_info.mqtt import ReceivePayloadType diff --git a/homeassistant/components/mqtt/switch.py b/homeassistant/components/mqtt/switch.py index 0a54bcdb378..a305fa83485 100644 --- a/homeassistant/components/mqtt/switch.py +++ b/homeassistant/components/mqtt/switch.py @@ -20,7 +20,7 @@ from homeassistant.const import ( STATE_ON, ) from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.service_info.mqtt import ReceivePayloadType diff --git a/homeassistant/components/mqtt/tag.py b/homeassistant/components/mqtt/tag.py index 680f252fb20..9a05d1896f7 100644 --- a/homeassistant/components/mqtt/tag.py +++ b/homeassistant/components/mqtt/tag.py @@ -12,7 +12,7 @@ from homeassistant.components import tag from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DEVICE, CONF_VALUE_TEMPLATE from homeassistant.core import HassJobType, HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.service_info.mqtt import ReceivePayloadType from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/mqtt/vacuum.py b/homeassistant/components/mqtt/vacuum.py index 743bfb363f3..ae6b25eff14 100644 --- a/homeassistant/components/mqtt/vacuum.py +++ b/homeassistant/components/mqtt/vacuum.py @@ -17,7 +17,7 @@ from homeassistant.components.vacuum import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_SUPPORTED_FEATURES, CONF_NAME from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.json import json_dumps from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, VolSchemaType diff --git a/homeassistant/components/mqtt/valve.py b/homeassistant/components/mqtt/valve.py index 50c5960f801..b380199332b 100644 --- a/homeassistant/components/mqtt/valve.py +++ b/homeassistant/components/mqtt/valve.py @@ -23,7 +23,7 @@ from homeassistant.const import ( CONF_VALUE_TEMPLATE, ) from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, VolSchemaType from homeassistant.util.json import JSON_DECODE_EXCEPTIONS, json_loads diff --git a/homeassistant/components/mqtt/water_heater.py b/homeassistant/components/mqtt/water_heater.py index 4c1d3fa8a53..967eceac326 100644 --- a/homeassistant/components/mqtt/water_heater.py +++ b/homeassistant/components/mqtt/water_heater.py @@ -35,7 +35,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.template import Template from homeassistant.helpers.typing import ConfigType, VolSchemaType diff --git a/homeassistant/components/network/const.py b/homeassistant/components/network/const.py index 6c5b6f80eda..120ae9dfd7c 100644 --- a/homeassistant/components/network/const.py +++ b/homeassistant/components/network/const.py @@ -6,7 +6,7 @@ from typing import Final import voluptuous as vol -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv DOMAIN: Final = "network" STORAGE_KEY: Final = "core.network" diff --git a/homeassistant/components/persistent_notification/__init__.py b/homeassistant/components/persistent_notification/__init__.py index a5eb8bb4f4d..2871f4b575a 100644 --- a/homeassistant/components/persistent_notification/__init__.py +++ b/homeassistant/components/persistent_notification/__init__.py @@ -20,7 +20,7 @@ from homeassistant.helpers.dispatcher import ( ) from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from homeassistant.util.signal_type import SignalType from homeassistant.util.uuid import random_uuid_hex diff --git a/homeassistant/components/persistent_notification/trigger.py b/homeassistant/components/persistent_notification/trigger.py index 431443d9139..8e0808f9879 100644 --- a/homeassistant/components/persistent_notification/trigger.py +++ b/homeassistant/components/persistent_notification/trigger.py @@ -9,7 +9,7 @@ import voluptuous as vol from homeassistant.const import CONF_PLATFORM from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.trigger import TriggerActionType, TriggerData, TriggerInfo from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/recorder/__init__.py b/homeassistant/components/recorder/__init__.py index a40760c67f4..5a95ace92cb 100644 --- a/homeassistant/components/recorder/__init__.py +++ b/homeassistant/components/recorder/__init__.py @@ -14,7 +14,7 @@ from homeassistant.const import ( EVENT_STATE_CHANGED, ) from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entityfilter import ( INCLUDE_EXCLUDE_BASE_FILTER_SCHEMA, INCLUDE_EXCLUDE_FILTER_SCHEMA_INNER, diff --git a/homeassistant/components/recorder/core.py b/homeassistant/components/recorder/core.py index fc8b136f38a..05a5731e791 100644 --- a/homeassistant/components/recorder/core.py +++ b/homeassistant/components/recorder/core.py @@ -45,7 +45,7 @@ from homeassistant.helpers.event import ( ) from homeassistant.helpers.start import async_at_started from homeassistant.helpers.typing import UNDEFINED, UndefinedType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from homeassistant.util.enum import try_parse_enum from homeassistant.util.event_type import EventType diff --git a/homeassistant/components/recorder/db_schema.py b/homeassistant/components/recorder/db_schema.py index cefce9c4e72..d1a2405406e 100644 --- a/homeassistant/components/recorder/db_schema.py +++ b/homeassistant/components/recorder/db_schema.py @@ -47,7 +47,7 @@ from homeassistant.const import ( ) from homeassistant.core import Context, Event, EventOrigin, EventStateChangedData, State from homeassistant.helpers.json import JSON_DUMP, json_bytes, json_bytes_strip_null -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from homeassistant.util.json import ( JSON_DECODE_EXCEPTIONS, json_loads, diff --git a/homeassistant/components/recorder/history/legacy.py b/homeassistant/components/recorder/history/legacy.py index dc49ebb9768..4323ad9466b 100644 --- a/homeassistant/components/recorder/history/legacy.py +++ b/homeassistant/components/recorder/history/legacy.py @@ -20,7 +20,7 @@ from sqlalchemy.sql.lambdas import StatementLambdaElement from homeassistant.const import COMPRESSED_STATE_LAST_UPDATED, COMPRESSED_STATE_STATE from homeassistant.core import HomeAssistant, State, split_entity_id from homeassistant.helpers.recorder import get_instance -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from ..db_schema import StateAttributes, States from ..filters import Filters diff --git a/homeassistant/components/recorder/history/modern.py b/homeassistant/components/recorder/history/modern.py index 2d8f4da5f38..aed2fcf8508 100644 --- a/homeassistant/components/recorder/history/modern.py +++ b/homeassistant/components/recorder/history/modern.py @@ -25,7 +25,7 @@ from sqlalchemy.orm.session import Session from homeassistant.const import COMPRESSED_STATE_LAST_UPDATED, COMPRESSED_STATE_STATE from homeassistant.core import HomeAssistant, State, split_entity_id from homeassistant.helpers.recorder import get_instance -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from ..const import LAST_REPORTED_SCHEMA_VERSION from ..db_schema import ( diff --git a/homeassistant/components/recorder/models/legacy.py b/homeassistant/components/recorder/models/legacy.py index b5e67ff050b..11ea9141fc0 100644 --- a/homeassistant/components/recorder/models/legacy.py +++ b/homeassistant/components/recorder/models/legacy.py @@ -14,7 +14,7 @@ from homeassistant.const import ( COMPRESSED_STATE_STATE, ) from homeassistant.core import Context, State -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .state_attributes import decode_attributes_from_source from .time import process_timestamp diff --git a/homeassistant/components/recorder/models/state.py b/homeassistant/components/recorder/models/state.py index 1ceaee633ae..919ee078a99 100644 --- a/homeassistant/components/recorder/models/state.py +++ b/homeassistant/components/recorder/models/state.py @@ -16,7 +16,7 @@ from homeassistant.const import ( COMPRESSED_STATE_STATE, ) from homeassistant.core import Context, State -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .state_attributes import decode_attributes_from_source diff --git a/homeassistant/components/recorder/models/time.py b/homeassistant/components/recorder/models/time.py index 33218000faa..91acad1500e 100644 --- a/homeassistant/components/recorder/models/time.py +++ b/homeassistant/components/recorder/models/time.py @@ -6,7 +6,7 @@ from datetime import datetime import logging from typing import overload -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/recorder/services.py b/homeassistant/components/recorder/services.py index 2be02fe8091..cc74d7a2376 100644 --- a/homeassistant/components/recorder/services.py +++ b/homeassistant/components/recorder/services.py @@ -9,13 +9,13 @@ import voluptuous as vol from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant, ServiceCall, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entityfilter import generate_filter from homeassistant.helpers.service import ( async_extract_entity_ids, async_register_admin_service, ) -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import ATTR_APPLY_FILTER, ATTR_KEEP_DAYS, ATTR_REPACK, DOMAIN from .core import Recorder diff --git a/homeassistant/components/recorder/table_managers/recorder_runs.py b/homeassistant/components/recorder/table_managers/recorder_runs.py index 4ca0aa18b88..191fa44c194 100644 --- a/homeassistant/components/recorder/table_managers/recorder_runs.py +++ b/homeassistant/components/recorder/table_managers/recorder_runs.py @@ -6,7 +6,7 @@ from datetime import datetime from sqlalchemy.orm.session import Session -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from ..db_schema import RecorderRuns diff --git a/homeassistant/components/recorder/util.py b/homeassistant/components/recorder/util.py index a1f8d90953c..a686c7c6498 100644 --- a/homeassistant/components/recorder/util.py +++ b/homeassistant/components/recorder/util.py @@ -34,7 +34,7 @@ from homeassistant.helpers.recorder import ( # noqa: F401 get_instance, session_scope, ) -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import DEFAULT_MAX_BIND_VARS, DOMAIN, SQLITE_URL_PREFIX, SupportedDialect from .db_schema import ( diff --git a/homeassistant/components/script/__init__.py b/homeassistant/components/script/__init__.py index 14104ad0219..dd293726484 100644 --- a/homeassistant/components/script/__init__.py +++ b/homeassistant/components/script/__init__.py @@ -39,7 +39,7 @@ from homeassistant.core import ( SupportsResponse, callback, ) -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.config_validation import make_entity_service_schema from homeassistant.helpers.entity import ToggleEntity from homeassistant.helpers.entity_component import EntityComponent diff --git a/homeassistant/components/shopping_list/__init__.py b/homeassistant/components/shopping_list/__init__.py index 531bbf37980..4ce596e72f0 100644 --- a/homeassistant/components/shopping_list/__init__.py +++ b/homeassistant/components/shopping_list/__init__.py @@ -17,7 +17,7 @@ from homeassistant.components.http.data_validator import RequestDataValidator from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_NAME, Platform from homeassistant.core import Context, HomeAssistant, ServiceCall, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.json import save_json from homeassistant.helpers.typing import ConfigType from homeassistant.util.json import JsonValueType, load_json_array diff --git a/homeassistant/components/shopping_list/intent.py b/homeassistant/components/shopping_list/intent.py index 1a6370f4168..118287f70d2 100644 --- a/homeassistant/components/shopping_list/intent.py +++ b/homeassistant/components/shopping_list/intent.py @@ -3,8 +3,7 @@ from __future__ import annotations from homeassistant.core import HomeAssistant -from homeassistant.helpers import intent -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, intent from . import DOMAIN, EVENT_SHOPPING_LIST_UPDATED diff --git a/homeassistant/components/stream/__init__.py b/homeassistant/components/stream/__init__.py index 2772fc2d30e..8fa4c69ac5a 100644 --- a/homeassistant/components/stream/__init__.py +++ b/homeassistant/components/stream/__init__.py @@ -33,7 +33,7 @@ from yarl import URL from homeassistant.const import EVENT_HOMEASSISTANT_STOP, EVENT_LOGGING_CHANGED from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from homeassistant.setup import SetupPhases, async_pause_setup from homeassistant.util.async_ import create_eager_task diff --git a/homeassistant/components/sun/trigger.py b/homeassistant/components/sun/trigger.py index 7724816d636..71498990b6f 100644 --- a/homeassistant/components/sun/trigger.py +++ b/homeassistant/components/sun/trigger.py @@ -11,7 +11,7 @@ from homeassistant.const import ( SUN_EVENT_SUNRISE, ) from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.event import async_track_sunrise, async_track_sunset from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/tag/__init__.py b/homeassistant/components/tag/__init__.py index 47c1d14ce60..8d42596d3db 100644 --- a/homeassistant/components/tag/__init__.py +++ b/homeassistant/components/tag/__init__.py @@ -13,14 +13,16 @@ from homeassistant.components import websocket_api from homeassistant.const import CONF_ID, CONF_NAME from homeassistant.core import Context, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import collection, entity_registry as er -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import ( + collection, + config_validation as cv, + entity_registry as er, +) from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.storage import Store from homeassistant.helpers.typing import ConfigType, VolDictType -from homeassistant.util import slugify -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util, slugify from homeassistant.util.hass_dict import HassKey from .const import DEFAULT_NAME, DEVICE_ID, DOMAIN, EVENT_TAG_SCANNED, LOGGER, TAG_ID diff --git a/homeassistant/components/template/alarm_control_panel.py b/homeassistant/components/template/alarm_control_panel.py index aa1f99f0423..a67e2969f9a 100644 --- a/homeassistant/components/template/alarm_control_panel.py +++ b/homeassistant/components/template/alarm_control_panel.py @@ -28,8 +28,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import TemplateError -from homeassistant.helpers import selector -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, selector from homeassistant.helpers.device import async_device_info_to_link_from_device_id from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback diff --git a/homeassistant/components/template/binary_sensor.py b/homeassistant/components/template/binary_sensor.py index 922f1d88ffb..3c6e4899502 100644 --- a/homeassistant/components/template/binary_sensor.py +++ b/homeassistant/components/template/binary_sensor.py @@ -40,8 +40,7 @@ from homeassistant.const import ( ) from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.exceptions import TemplateError -from homeassistant.helpers import selector, template -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, selector, template from homeassistant.helpers.device import async_device_info_to_link_from_device_id from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback diff --git a/homeassistant/components/template/cover.py b/homeassistant/components/template/cover.py index 2642ede9c3a..306b4405c6a 100644 --- a/homeassistant/components/template/cover.py +++ b/homeassistant/components/template/cover.py @@ -27,7 +27,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import TemplateError -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.script import Script diff --git a/homeassistant/components/template/fan.py b/homeassistant/components/template/fan.py index 7720ef7e1b3..6ed525fd45f 100644 --- a/homeassistant/components/template/fan.py +++ b/homeassistant/components/template/fan.py @@ -29,7 +29,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import TemplateError -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.script import Script diff --git a/homeassistant/components/template/lock.py b/homeassistant/components/template/lock.py index f194154a50c..0804f92e46d 100644 --- a/homeassistant/components/template/lock.py +++ b/homeassistant/components/template/lock.py @@ -21,7 +21,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ServiceValidationError, TemplateError -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.script import Script from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/template/template_entity.py b/homeassistant/components/template/template_entity.py index d025f052732..8f9edca5976 100644 --- a/homeassistant/components/template/template_entity.py +++ b/homeassistant/components/template/template_entity.py @@ -33,7 +33,7 @@ from homeassistant.core import ( validate_state, ) from homeassistant.exceptions import TemplateError -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import ( TrackTemplate, diff --git a/homeassistant/components/template/vacuum.py b/homeassistant/components/template/vacuum.py index 19029cc708b..b977f4e659a 100644 --- a/homeassistant/components/template/vacuum.py +++ b/homeassistant/components/template/vacuum.py @@ -30,7 +30,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import TemplateError -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.script import Script diff --git a/homeassistant/components/timer/__init__.py b/homeassistant/components/timer/__init__.py index 19b1de427ef..b0ade17b9c9 100644 --- a/homeassistant/components/timer/__init__.py +++ b/homeassistant/components/timer/__init__.py @@ -19,15 +19,14 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import collection -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import collection, config_validation as cv from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.helpers.restore_state import RestoreEntity import homeassistant.helpers.service from homeassistant.helpers.storage import Store from homeassistant.helpers.typing import ConfigType, VolDictType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/trace/__init__.py b/homeassistant/components/trace/__init__.py index 9ff645ce4d6..bb0f3e5251a 100644 --- a/homeassistant/components/trace/__init__.py +++ b/homeassistant/components/trace/__init__.py @@ -9,7 +9,7 @@ import voluptuous as vol from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import Event, HomeAssistant from homeassistant.exceptions import HomeAssistantError -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.json import ExtendedJSONEncoder from homeassistant.helpers.storage import Store from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/webhook/trigger.py b/homeassistant/components/webhook/trigger.py index b4fd3008cd8..907123561f7 100644 --- a/homeassistant/components/webhook/trigger.py +++ b/homeassistant/components/webhook/trigger.py @@ -11,7 +11,7 @@ import voluptuous as vol from homeassistant.const import CONF_PLATFORM, CONF_WEBHOOK_ID from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType From b7a344fd652b114492a44910c753ea0b057b563f Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 28 Jan 2025 19:11:48 +0100 Subject: [PATCH 0156/3148] Standardize homeassistant imports in core and base platforms (#136730) Standardize homeassistant imports in core --- homeassistant/auth/providers/trusted_networks.py | 2 +- homeassistant/components/alarm_control_panel/__init__.py | 2 +- .../components/alarm_control_panel/device_action.py | 3 +-- homeassistant/components/button/device_action.py | 3 +-- homeassistant/components/climate/device_action.py | 3 +-- homeassistant/components/cover/device_action.py | 3 +-- homeassistant/components/humidifier/device_action.py | 3 +-- homeassistant/components/humidifier/intent.py | 3 +-- homeassistant/components/image/__init__.py | 2 +- homeassistant/components/image_processing/__init__.py | 2 +- homeassistant/components/lock/__init__.py | 2 +- homeassistant/components/lock/device_action.py | 3 +-- homeassistant/components/notify/__init__.py | 4 ++-- homeassistant/components/notify/const.py | 2 +- homeassistant/components/number/device_action.py | 3 +-- homeassistant/components/select/device_action.py | 3 +-- homeassistant/components/switch/light.py | 3 +-- homeassistant/components/text/device_action.py | 3 +-- homeassistant/components/tts/__init__.py | 2 +- homeassistant/components/tts/legacy.py | 3 +-- homeassistant/components/tts/notify.py | 2 +- homeassistant/components/vacuum/device_action.py | 3 +-- homeassistant/components/water_heater/device_action.py | 3 +-- homeassistant/helpers/condition.py | 2 +- homeassistant/helpers/config_validation.py | 7 +++++-- homeassistant/helpers/issue_registry.py | 2 +- homeassistant/helpers/restore_state.py | 2 +- homeassistant/helpers/storage.py | 3 +-- homeassistant/helpers/trace.py | 2 +- homeassistant/scripts/ensure_config.py | 2 +- 30 files changed, 35 insertions(+), 47 deletions(-) diff --git a/homeassistant/auth/providers/trusted_networks.py b/homeassistant/auth/providers/trusted_networks.py index 799fd4d2e16..83299859de9 100644 --- a/homeassistant/auth/providers/trusted_networks.py +++ b/homeassistant/auth/providers/trusted_networks.py @@ -21,7 +21,7 @@ import voluptuous as vol from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.network import is_cloud_connection from .. import InvalidAuthError diff --git a/homeassistant/components/alarm_control_panel/__init__.py b/homeassistant/components/alarm_control_panel/__init__.py index 80a676a40fa..fde4638e179 100644 --- a/homeassistant/components/alarm_control_panel/__init__.py +++ b/homeassistant/components/alarm_control_panel/__init__.py @@ -24,7 +24,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ServiceValidationError -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.config_validation import make_entity_service_schema from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent diff --git a/homeassistant/components/alarm_control_panel/device_action.py b/homeassistant/components/alarm_control_panel/device_action.py index 72b1084d072..6779eada070 100644 --- a/homeassistant/components/alarm_control_panel/device_action.py +++ b/homeassistant/components/alarm_control_panel/device_action.py @@ -23,8 +23,7 @@ from homeassistant.const import ( SERVICE_ALARM_TRIGGER, ) from homeassistant.core import Context, HomeAssistant -from homeassistant.helpers import entity_registry as er -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.entity import get_supported_features from homeassistant.helpers.typing import ConfigType, TemplateVarsType diff --git a/homeassistant/components/button/device_action.py b/homeassistant/components/button/device_action.py index f4db7b619f8..30c0cc36835 100644 --- a/homeassistant/components/button/device_action.py +++ b/homeassistant/components/button/device_action.py @@ -13,8 +13,7 @@ from homeassistant.const import ( CONF_TYPE, ) from homeassistant.core import Context, HomeAssistant -from homeassistant.helpers import entity_registry as er -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.typing import ConfigType, TemplateVarsType from .const import DOMAIN, SERVICE_PRESS diff --git a/homeassistant/components/climate/device_action.py b/homeassistant/components/climate/device_action.py index 84f166b752e..c9d098d7be6 100644 --- a/homeassistant/components/climate/device_action.py +++ b/homeassistant/components/climate/device_action.py @@ -17,8 +17,7 @@ from homeassistant.const import ( ) from homeassistant.core import Context, HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import entity_registry as er -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.entity import get_capability, get_supported_features from homeassistant.helpers.typing import ConfigType, TemplateVarsType diff --git a/homeassistant/components/cover/device_action.py b/homeassistant/components/cover/device_action.py index acef2cde4d8..a982e99776b 100644 --- a/homeassistant/components/cover/device_action.py +++ b/homeassistant/components/cover/device_action.py @@ -20,8 +20,7 @@ from homeassistant.const import ( SERVICE_STOP_COVER, ) from homeassistant.core import Context, HomeAssistant -from homeassistant.helpers import entity_registry as er -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.entity import get_supported_features from homeassistant.helpers.typing import ConfigType, TemplateVarsType diff --git a/homeassistant/components/humidifier/device_action.py b/homeassistant/components/humidifier/device_action.py index 06440480277..9ff36412418 100644 --- a/homeassistant/components/humidifier/device_action.py +++ b/homeassistant/components/humidifier/device_action.py @@ -19,8 +19,7 @@ from homeassistant.const import ( ) from homeassistant.core import Context, HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import entity_registry as er -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.entity import get_capability, get_supported_features from homeassistant.helpers.typing import ConfigType, TemplateVarsType, VolDictType diff --git a/homeassistant/components/humidifier/intent.py b/homeassistant/components/humidifier/intent.py index 425fdbcc679..490143c728d 100644 --- a/homeassistant/components/humidifier/intent.py +++ b/homeassistant/components/humidifier/intent.py @@ -6,8 +6,7 @@ import voluptuous as vol from homeassistant.const import ATTR_ENTITY_ID, ATTR_MODE, STATE_OFF from homeassistant.core import HomeAssistant -from homeassistant.helpers import intent -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, intent from . import ( ATTR_AVAILABLE_MODES, diff --git a/homeassistant/components/image/__init__.py b/homeassistant/components/image/__init__.py index 1cf2de278d1..644d335bbca 100644 --- a/homeassistant/components/image/__init__.py +++ b/homeassistant/components/image/__init__.py @@ -28,7 +28,7 @@ from homeassistant.core import ( callback, ) from homeassistant.exceptions import HomeAssistantError -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.event import ( diff --git a/homeassistant/components/image_processing/__init__.py b/homeassistant/components/image_processing/__init__.py index 0ac8d39813b..06b6bb7a57f 100644 --- a/homeassistant/components/image_processing/__init__.py +++ b/homeassistant/components/image_processing/__init__.py @@ -20,7 +20,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.config_validation import make_entity_service_schema from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent diff --git a/homeassistant/components/lock/__init__.py b/homeassistant/components/lock/__init__.py index 60eb29240cd..05aed8a827f 100644 --- a/homeassistant/components/lock/__init__.py +++ b/homeassistant/components/lock/__init__.py @@ -29,7 +29,7 @@ from homeassistant.const import ( # noqa: F401 ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ServiceValidationError -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.deprecation import ( all_with_deprecated_constants, check_if_deprecated_constant, diff --git a/homeassistant/components/lock/device_action.py b/homeassistant/components/lock/device_action.py index a75966414f8..a396849f049 100644 --- a/homeassistant/components/lock/device_action.py +++ b/homeassistant/components/lock/device_action.py @@ -16,8 +16,7 @@ from homeassistant.const import ( SERVICE_UNLOCK, ) from homeassistant.core import Context, HomeAssistant -from homeassistant.helpers import entity_registry as er -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.entity import get_supported_features from homeassistant.helpers.typing import ConfigType, TemplateVarsType diff --git a/homeassistant/components/notify/__init__.py b/homeassistant/components/notify/__init__.py index 7f41817a683..97759db4c13 100644 --- a/homeassistant/components/notify/__init__.py +++ b/homeassistant/components/notify/__init__.py @@ -11,11 +11,11 @@ from typing import Any, final, override from propcache.api import cached_property import voluptuous as vol -import homeassistant.components.persistent_notification as pn +from homeassistant.components import persistent_notification as pn from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME, CONF_PLATFORM, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant, ServiceCall -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.restore_state import RestoreEntity diff --git a/homeassistant/components/notify/const.py b/homeassistant/components/notify/const.py index 29064f24a66..11ce4e801a1 100644 --- a/homeassistant/components/notify/const.py +++ b/homeassistant/components/notify/const.py @@ -4,7 +4,7 @@ import logging import voluptuous as vol -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv ATTR_DATA = "data" diff --git a/homeassistant/components/number/device_action.py b/homeassistant/components/number/device_action.py index 8882bb22a0d..6dd85e000bd 100644 --- a/homeassistant/components/number/device_action.py +++ b/homeassistant/components/number/device_action.py @@ -13,8 +13,7 @@ from homeassistant.const import ( CONF_TYPE, ) from homeassistant.core import Context, HomeAssistant -from homeassistant.helpers import entity_registry as er -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.typing import ConfigType, TemplateVarsType from .const import ATTR_VALUE, DOMAIN, SERVICE_SET_VALUE diff --git a/homeassistant/components/select/device_action.py b/homeassistant/components/select/device_action.py index a3827a23d41..1801d34d182 100644 --- a/homeassistant/components/select/device_action.py +++ b/homeassistant/components/select/device_action.py @@ -19,8 +19,7 @@ from homeassistant.const import ( ) from homeassistant.core import Context, HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import entity_registry as er -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.entity import get_capability from homeassistant.helpers.typing import ConfigType, TemplateVarsType diff --git a/homeassistant/components/switch/light.py b/homeassistant/components/switch/light.py index 48d555e6616..276496ce614 100644 --- a/homeassistant/components/switch/light.py +++ b/homeassistant/components/switch/light.py @@ -21,8 +21,7 @@ from homeassistant.const import ( STATE_UNAVAILABLE, ) from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback -from homeassistant.helpers import entity_registry as er -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_state_change_event from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/text/device_action.py b/homeassistant/components/text/device_action.py index 94269ac12fb..b1eca1e36b6 100644 --- a/homeassistant/components/text/device_action.py +++ b/homeassistant/components/text/device_action.py @@ -13,8 +13,7 @@ from homeassistant.const import ( CONF_TYPE, ) from homeassistant.core import Context, HomeAssistant -from homeassistant.helpers import entity_registry as er -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.typing import ConfigType, TemplateVarsType from .const import ATTR_VALUE, DOMAIN, SERVICE_SET_VALUE diff --git a/homeassistant/components/tts/__init__.py b/homeassistant/components/tts/__init__.py index bbe4d334def..6c7e521f3ef 100644 --- a/homeassistant/components/tts/__init__.py +++ b/homeassistant/components/tts/__init__.py @@ -43,7 +43,7 @@ from homeassistant.const import ( ) from homeassistant.core import HassJob, HomeAssistant, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.event import async_call_later from homeassistant.helpers.network import get_url diff --git a/homeassistant/components/tts/legacy.py b/homeassistant/components/tts/legacy.py index 54ea89cb674..6f0541734d1 100644 --- a/homeassistant/components/tts/legacy.py +++ b/homeassistant/components/tts/legacy.py @@ -27,8 +27,7 @@ from homeassistant.const import ( CONF_PLATFORM, ) from homeassistant.core import HomeAssistant, ServiceCall, callback -from homeassistant.helpers import discovery -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.service import async_set_service_schema from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.setup import ( diff --git a/homeassistant/components/tts/notify.py b/homeassistant/components/tts/notify.py index 429d46660e7..c4c1bb1ae15 100644 --- a/homeassistant/components/tts/notify.py +++ b/homeassistant/components/tts/notify.py @@ -13,7 +13,7 @@ from homeassistant.components.notify import ( ) from homeassistant.const import ATTR_ENTITY_ID, CONF_ENTITY_ID, CONF_NAME from homeassistant.core import HomeAssistant, split_entity_id -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import ATTR_LANGUAGE, ATTR_MEDIA_PLAYER_ENTITY_ID, ATTR_MESSAGE, DOMAIN diff --git a/homeassistant/components/vacuum/device_action.py b/homeassistant/components/vacuum/device_action.py index 82c00a57b5e..0ae03d9219e 100644 --- a/homeassistant/components/vacuum/device_action.py +++ b/homeassistant/components/vacuum/device_action.py @@ -13,8 +13,7 @@ from homeassistant.const import ( CONF_TYPE, ) from homeassistant.core import Context, HomeAssistant -from homeassistant.helpers import entity_registry as er -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.typing import ConfigType, TemplateVarsType from . import DOMAIN, SERVICE_RETURN_TO_BASE, SERVICE_START diff --git a/homeassistant/components/water_heater/device_action.py b/homeassistant/components/water_heater/device_action.py index 49cfc7e9a07..d68919ff8f3 100644 --- a/homeassistant/components/water_heater/device_action.py +++ b/homeassistant/components/water_heater/device_action.py @@ -15,8 +15,7 @@ from homeassistant.const import ( SERVICE_TURN_ON, ) from homeassistant.core import Context, HomeAssistant -from homeassistant.helpers import entity_registry as er -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.typing import ConfigType, TemplateVarsType from . import DOMAIN diff --git a/homeassistant/helpers/condition.py b/homeassistant/helpers/condition.py index 695af80bc1c..fa2dd42589b 100644 --- a/homeassistant/helpers/condition.py +++ b/homeassistant/helpers/condition.py @@ -56,8 +56,8 @@ from homeassistant.exceptions import ( TemplateError, ) from homeassistant.loader import IntegrationNotFound, async_get_integration +from homeassistant.util import dt as dt_util from homeassistant.util.async_ import run_callback_threadsafe -import homeassistant.util.dt as dt_util from . import config_validation as cv, entity_registry as er from .sun import get_astral_event_date diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index 2c8dbe69c22..4978158c0f6 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -107,8 +107,11 @@ from homeassistant.exceptions import HomeAssistantError, TemplateError from homeassistant.generated import currencies from homeassistant.generated.countries import COUNTRIES from homeassistant.generated.languages import LANGUAGES -from homeassistant.util import raise_if_invalid_path, slugify as util_slugify -import homeassistant.util.dt as dt_util +from homeassistant.util import ( + dt as dt_util, + raise_if_invalid_path, + slugify as util_slugify, +) from homeassistant.util.yaml.objects import NodeStrClass from . import script_variables as script_variables_helper, template as template_helper diff --git a/homeassistant/helpers/issue_registry.py b/homeassistant/helpers/issue_registry.py index 109d363d262..1a1373e19ef 100644 --- a/homeassistant/helpers/issue_registry.py +++ b/homeassistant/helpers/issue_registry.py @@ -12,8 +12,8 @@ from awesomeversion import AwesomeVersion, AwesomeVersionStrategy from homeassistant.const import __version__ as ha_version from homeassistant.core import HomeAssistant, callback +from homeassistant.util import dt as dt_util from homeassistant.util.async_ import run_callback_threadsafe -import homeassistant.util.dt as dt_util from homeassistant.util.event_type import EventType from homeassistant.util.hass_dict import HassKey diff --git a/homeassistant/helpers/restore_state.py b/homeassistant/helpers/restore_state.py index fd1f84a85ff..78812061a03 100644 --- a/homeassistant/helpers/restore_state.py +++ b/homeassistant/helpers/restore_state.py @@ -10,7 +10,7 @@ from typing import Any, Self, cast from homeassistant.const import ATTR_RESTORED, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant, State, callback, valid_entity_id from homeassistant.exceptions import HomeAssistantError -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from homeassistant.util.hass_dict import HassKey from homeassistant.util.json import json_loads diff --git a/homeassistant/helpers/storage.py b/homeassistant/helpers/storage.py index ac1fe3bb29d..fe94be68763 100644 --- a/homeassistant/helpers/storage.py +++ b/homeassistant/helpers/storage.py @@ -30,8 +30,7 @@ from homeassistant.core import ( ) from homeassistant.exceptions import HomeAssistantError from homeassistant.loader import bind_hass -from homeassistant.util import json as json_util -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util, json as json_util from homeassistant.util.file import WriteError from homeassistant.util.hass_dict import HassKey diff --git a/homeassistant/helpers/trace.py b/homeassistant/helpers/trace.py index d191d474480..ef11028515a 100644 --- a/homeassistant/helpers/trace.py +++ b/homeassistant/helpers/trace.py @@ -10,7 +10,7 @@ from functools import wraps from typing import Any from homeassistant.core import ServiceResponse -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .typing import TemplateVarsType diff --git a/homeassistant/scripts/ensure_config.py b/homeassistant/scripts/ensure_config.py index e1ae7bc9142..1d568ec68b0 100644 --- a/homeassistant/scripts/ensure_config.py +++ b/homeassistant/scripts/ensure_config.py @@ -4,7 +4,7 @@ import argparse import asyncio import os -import homeassistant.config as config_util +from homeassistant import config as config_util from homeassistant.core import HomeAssistant # mypy: allow-untyped-calls, allow-untyped-defs From 37b23a9691f2e18d3395bea8459635c7bf1eb117 Mon Sep 17 00:00:00 2001 From: "Teemu R." Date: Tue, 28 Jan 2025 19:17:34 +0100 Subject: [PATCH 0157/3148] Add pair/unpair buttons for tplink (#135847) --- homeassistant/components/tplink/button.py | 2 + homeassistant/components/tplink/strings.json | 6 ++ .../components/tplink/fixtures/features.json | 10 +++ .../tplink/snapshots/test_button.ambr | 79 +++++++++++++++++++ 4 files changed, 97 insertions(+) diff --git a/homeassistant/components/tplink/button.py b/homeassistant/components/tplink/button.py index 0a4517b967d..6d9269b8c44 100644 --- a/homeassistant/components/tplink/button.py +++ b/homeassistant/components/tplink/button.py @@ -70,6 +70,8 @@ BUTTON_DESCRIPTIONS: Final = [ key="tilt_down", available_fn=lambda dev: dev.is_on, ), + TPLinkButtonEntityDescription(key="pair"), + TPLinkButtonEntityDescription(key="unpair"), ] BUTTON_DESCRIPTIONS_MAP = {desc.key: desc for desc in BUTTON_DESCRIPTIONS} diff --git a/homeassistant/components/tplink/strings.json b/homeassistant/components/tplink/strings.json index fa284a3cc83..304bf353b7c 100644 --- a/homeassistant/components/tplink/strings.json +++ b/homeassistant/components/tplink/strings.json @@ -138,6 +138,12 @@ }, "tilt_down": { "name": "Tilt down" + }, + "pair": { + "name": "Pair new device" + }, + "unpair": { + "name": "Unpair device" } }, "camera": { diff --git a/tests/components/tplink/fixtures/features.json b/tests/components/tplink/fixtures/features.json index 3d27e63b06a..adb6c08ee50 100644 --- a/tests/components/tplink/fixtures/features.json +++ b/tests/components/tplink/fixtures/features.json @@ -370,5 +370,15 @@ "value": 10, "type": "Number", "category": "Config" + }, + "pair": { + "value": "", + "type": "Action", + "category": "Config" + }, + "unpair": { + "value": "", + "type": "Action", + "category": "Debug" } } diff --git a/tests/components/tplink/snapshots/test_button.ambr b/tests/components/tplink/snapshots/test_button.ambr index de626cd5818..087aec39cfc 100644 --- a/tests/components/tplink/snapshots/test_button.ambr +++ b/tests/components/tplink/snapshots/test_button.ambr @@ -1,4 +1,50 @@ # serializer version: 1 +# name: test_states[button.my_device_pair_new_device-entry] + 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.my_device_pair_new_device', + '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': 'Pair new device', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pair', + 'unique_id': '123456789ABCDEFGH_pair', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[button.my_device_pair_new_device-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'my_device Pair new device', + }), + 'context': , + 'entity_id': 'button.my_device_pair_new_device', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_states[button.my_device_pan_left-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -308,6 +354,39 @@ 'state': 'unknown', }) # --- +# name: test_states[button.my_device_unpair_device-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.my_device_unpair_device', + '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': 'Unpair device', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'unpair', + 'unique_id': '123456789ABCDEFGH_unpair', + 'unit_of_measurement': None, + }) +# --- # name: test_states[my_device-entry] DeviceRegistryEntrySnapshot({ 'area_id': None, From 404ca283c6c66206125fd6512a06ee0322c1787c Mon Sep 17 00:00:00 2001 From: Nathan Spencer Date: Tue, 28 Jan 2025 11:28:01 -0700 Subject: [PATCH 0158/3148] Let platforms decide entity creation in litterrobot (#136738) --- .../components/litterrobot/__init__.py | 43 ++++++------------- tests/components/litterrobot/conftest.py | 12 ++---- tests/components/litterrobot/test_vacuum.py | 4 -- 3 files changed, 15 insertions(+), 44 deletions(-) diff --git a/homeassistant/components/litterrobot/__init__.py b/homeassistant/components/litterrobot/__init__.py index 76274f987cd..1f926d37a61 100644 --- a/homeassistant/components/litterrobot/__init__.py +++ b/homeassistant/components/litterrobot/__init__.py @@ -2,8 +2,6 @@ from __future__ import annotations -from pylitterbot import FeederRobot, LitterRobot, LitterRobot3, LitterRobot4, Robot - from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntry @@ -11,29 +9,16 @@ from homeassistant.helpers.device_registry import DeviceEntry from .const import DOMAIN from .coordinator import LitterRobotConfigEntry, LitterRobotDataUpdateCoordinator -PLATFORMS_BY_TYPE = { - Robot: ( - Platform.BINARY_SENSOR, - Platform.SELECT, - Platform.SENSOR, - Platform.SWITCH, - ), - LitterRobot: (Platform.VACUUM,), - LitterRobot3: (Platform.BUTTON, Platform.TIME), - LitterRobot4: (Platform.UPDATE,), - FeederRobot: (Platform.BUTTON,), -} - - -def get_platforms_for_robots(robots: list[Robot]) -> set[Platform]: - """Get platforms for robots.""" - return { - platform - for robot in robots - for robot_type, platforms in PLATFORMS_BY_TYPE.items() - if isinstance(robot, robot_type) - for platform in platforms - } +PLATFORMS = [ + Platform.BINARY_SENSOR, + Platform.BUTTON, + Platform.SELECT, + Platform.SENSOR, + Platform.SWITCH, + Platform.TIME, + Platform.UPDATE, + Platform.VACUUM, +] async def async_setup_entry(hass: HomeAssistant, entry: LitterRobotConfigEntry) -> bool: @@ -41,9 +26,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: LitterRobotConfigEntry) coordinator = LitterRobotDataUpdateCoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() entry.runtime_data = coordinator - - if platforms := get_platforms_for_robots(coordinator.account.robots): - await hass.config_entries.async_forward_entry_setups(entry, platforms) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True @@ -52,9 +35,7 @@ async def async_unload_entry( ) -> bool: """Unload a config entry.""" await entry.runtime_data.account.disconnect() - - platforms = get_platforms_for_robots(entry.runtime_data.account.robots) - return await hass.config_entries.async_unload_platforms(entry, platforms) + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) async def async_remove_config_entry_device( diff --git a/tests/components/litterrobot/conftest.py b/tests/components/litterrobot/conftest.py index 5cd97e5937d..e60e0cbd36d 100644 --- a/tests/components/litterrobot/conftest.py +++ b/tests/components/litterrobot/conftest.py @@ -123,15 +123,9 @@ async def setup_integration( ) entry.add_to_hass(hass) - with ( - patch( - "homeassistant.components.litterrobot.coordinator.Account", - return_value=mock_account, - ), - patch( - "homeassistant.components.litterrobot.PLATFORMS_BY_TYPE", - {Robot: (platform_domain,)} if platform_domain else {}, - ), + with patch( + "homeassistant.components.litterrobot.coordinator.Account", + return_value=mock_account, ): await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/litterrobot/test_vacuum.py b/tests/components/litterrobot/test_vacuum.py index 0255e0e6a8a..911dfb3b880 100644 --- a/tests/components/litterrobot/test_vacuum.py +++ b/tests/components/litterrobot/test_vacuum.py @@ -33,7 +33,6 @@ async def test_vacuum( hass: HomeAssistant, entity_registry: er.EntityRegistry, mock_account: MagicMock ) -> None: """Tests the vacuum entity was set up.""" - entity_registry.async_get_or_create( VACUUM_DOMAIN, DOMAIN, @@ -44,7 +43,6 @@ async def test_vacuum( assert ent_reg_entry.unique_id == VACUUM_UNIQUE_ID await setup_integration(hass, mock_account, VACUUM_DOMAIN) - assert len(entity_registry.entities) == 1 assert hass.services.has_service(DOMAIN, SERVICE_SET_SLEEP_MODE) vacuum = hass.states.get(VACUUM_ENTITY_ID) @@ -63,8 +61,6 @@ async def test_no_robots( """Tests the vacuum entity was set up.""" entry = await setup_integration(hass, mock_account_with_no_robots, VACUUM_DOMAIN) - assert not hass.services.has_service(DOMAIN, SERVICE_SET_SLEEP_MODE) - assert len(entity_registry.entities) == 0 assert await hass.config_entries.async_unload(entry.entry_id) From bae9516fc23591b67f800aa64b99935564a4bc5f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 28 Jan 2025 08:44:25 -1000 Subject: [PATCH 0159/3148] Bump yeelight to 0.7.16 (#136679) --- homeassistant/components/yeelight/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/yeelight/manifest.json b/homeassistant/components/yeelight/manifest.json index 6efb66449ab..cf7bc9c9035 100644 --- a/homeassistant/components/yeelight/manifest.json +++ b/homeassistant/components/yeelight/manifest.json @@ -16,7 +16,7 @@ }, "iot_class": "local_push", "loggers": ["async_upnp_client", "yeelight"], - "requirements": ["yeelight==0.7.14", "async-upnp-client==0.43.0"], + "requirements": ["yeelight==0.7.16", "async-upnp-client==0.43.0"], "zeroconf": [ { "type": "_miio._udp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index 57c534d0e2c..8f1d1fa9a70 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3095,7 +3095,7 @@ yalexs-ble==2.5.6 yalexs==8.10.0 # homeassistant.components.yeelight -yeelight==0.7.14 +yeelight==0.7.16 # homeassistant.components.yeelightsunflower yeelightsunflower==0.0.10 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c4aa58667c2..43fbdb63b26 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2493,7 +2493,7 @@ yalexs-ble==2.5.6 yalexs==8.10.0 # homeassistant.components.yeelight -yeelight==0.7.14 +yeelight==0.7.16 # homeassistant.components.yolink yolink-api==0.4.7 From 55fc01be8e1ab00b95f7cabf5f11bd58bc177b1a Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Tue, 28 Jan 2025 20:55:06 +0200 Subject: [PATCH 0160/3148] Fix LG webOS TV actions not returning responses (#136743) --- .../components/webostv/media_player.py | 51 +++++++++++++------ .../webostv/snapshots/test_media_player.ambr | 18 +++++++ tests/components/webostv/test_media_player.py | 40 ++++++++++++--- 3 files changed, 87 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/webostv/media_player.py b/homeassistant/components/webostv/media_player.py index 796dede88b6..c8b871b3bf2 100644 --- a/homeassistant/components/webostv/media_player.py +++ b/homeassistant/components/webostv/media_player.py @@ -3,7 +3,7 @@ from __future__ import annotations import asyncio -from collections.abc import Awaitable, Callable, Coroutine +from collections.abc import Callable, Coroutine from contextlib import suppress from datetime import timedelta from functools import wraps @@ -23,7 +23,7 @@ from homeassistant.components.media_player import ( MediaType, ) from homeassistant.const import ATTR_COMMAND, ATTR_SUPPORTED_FEATURES -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, ServiceResponse, SupportsResponse from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -78,9 +78,24 @@ COMMAND_SCHEMA: VolDictType = { SOUND_OUTPUT_SCHEMA: VolDictType = {vol.Required(ATTR_SOUND_OUTPUT): cv.string} SERVICES = ( - (SERVICE_BUTTON, BUTTON_SCHEMA, "async_button"), - (SERVICE_COMMAND, COMMAND_SCHEMA, "async_command"), - (SERVICE_SELECT_SOUND_OUTPUT, SOUND_OUTPUT_SCHEMA, "async_select_sound_output"), + ( + SERVICE_BUTTON, + BUTTON_SCHEMA, + "async_button", + SupportsResponse.NONE, + ), + ( + SERVICE_COMMAND, + COMMAND_SCHEMA, + "async_command", + SupportsResponse.OPTIONAL, + ), + ( + SERVICE_SELECT_SOUND_OUTPUT, + SOUND_OUTPUT_SCHEMA, + "async_select_sound_output", + SupportsResponse.OPTIONAL, + ), ) @@ -92,19 +107,23 @@ async def async_setup_entry( """Set up the LG webOS TV platform.""" platform = entity_platform.async_get_current_platform() - for service_name, schema, method in SERVICES: - platform.async_register_entity_service(service_name, schema, method) + for service_name, schema, method, supports_response in SERVICES: + platform.async_register_entity_service( + service_name, schema, method, supports_response=supports_response + ) async_add_entities([LgWebOSMediaPlayerEntity(entry)]) -def cmd[_T: LgWebOSMediaPlayerEntity, **_P]( - func: Callable[Concatenate[_T, _P], Awaitable[None]], -) -> Callable[Concatenate[_T, _P], Coroutine[Any, Any, None]]: +def cmd[_R, **_P]( + func: Callable[Concatenate[LgWebOSMediaPlayerEntity, _P], Coroutine[Any, Any, _R]], +) -> Callable[Concatenate[LgWebOSMediaPlayerEntity, _P], Coroutine[Any, Any, _R]]: """Catch command exceptions.""" @wraps(func) - async def cmd_wrapper(self: _T, *args: _P.args, **kwargs: _P.kwargs) -> None: + async def cmd_wrapper( + self: LgWebOSMediaPlayerEntity, *args: _P.args, **kwargs: _P.kwargs + ) -> _R: """Wrap all command methods.""" if self.state is MediaPlayerState.OFF: raise HomeAssistantError( @@ -116,7 +135,7 @@ def cmd[_T: LgWebOSMediaPlayerEntity, **_P]( }, ) try: - await func(self, *args, **kwargs) + return await func(self, *args, **kwargs) except WEBOSTV_EXCEPTIONS as error: raise HomeAssistantError( translation_domain=DOMAIN, @@ -376,9 +395,9 @@ class LgWebOSMediaPlayerEntity(RestoreEntity, MediaPlayerEntity): await self._client.set_mute(mute) @cmd - async def async_select_sound_output(self, sound_output: str) -> None: + async def async_select_sound_output(self, sound_output: str) -> ServiceResponse: """Select the sound output.""" - await self._client.change_sound_output(sound_output) + return await self._client.change_sound_output(sound_output) @cmd async def async_media_play_pause(self) -> None: @@ -481,9 +500,9 @@ class LgWebOSMediaPlayerEntity(RestoreEntity, MediaPlayerEntity): await self._client.button(button) @cmd - async def async_command(self, command: str, **kwargs: Any) -> None: + async def async_command(self, command: str, **kwargs: Any) -> ServiceResponse: """Send a command.""" - await self._client.request(command, payload=kwargs.get(ATTR_PAYLOAD)) + return await self._client.request(command, payload=kwargs.get(ATTR_PAYLOAD)) async def _async_fetch_image(self, url: str) -> tuple[bytes | None, str | None]: """Retrieve an image. diff --git a/tests/components/webostv/snapshots/test_media_player.ambr b/tests/components/webostv/snapshots/test_media_player.ambr index 78c0bd517a6..35a703cc109 100644 --- a/tests/components/webostv/snapshots/test_media_player.ambr +++ b/tests/components/webostv/snapshots/test_media_player.ambr @@ -1,4 +1,14 @@ # serializer version: 1 +# name: test_command + dict({ + 'media_player.lg_webos_tv_model': dict({ + 'muted': False, + 'returnValue': True, + 'scenario': 'mastervolume_tv_speaker_ext', + 'volume': 1, + }), + }) +# --- # name: test_entity_attributes StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -57,3 +67,11 @@ 'via_device_id': None, }) # --- +# name: test_select_sound_output + dict({ + 'media_player.lg_webos_tv_model': dict({ + 'method': 'setSystemSettings', + 'returnValue': True, + }), + }) +# --- diff --git a/tests/components/webostv/test_media_player.py b/tests/components/webostv/test_media_player.py index d5241dbe668..820ab856ebb 100644 --- a/tests/components/webostv/test_media_player.py +++ b/tests/components/webostv/test_media_player.py @@ -229,17 +229,30 @@ async def test_button(hass: HomeAssistant, client) -> None: client.button.assert_called_with("test") -async def test_command(hass: HomeAssistant, client) -> None: +async def test_command( + hass: HomeAssistant, + client, + snapshot: SnapshotAssertion, +) -> None: """Test generic command functionality.""" await setup_webostv(hass) + client.request.return_value = { + "returnValue": True, + "scenario": "mastervolume_tv_speaker_ext", + "volume": 1, + "muted": False, + } data = { ATTR_ENTITY_ID: ENTITY_ID, - ATTR_COMMAND: "test", + ATTR_COMMAND: "audio/getVolume", } - await hass.services.async_call(DOMAIN, SERVICE_COMMAND, data, True) + response = await hass.services.async_call( + DOMAIN, SERVICE_COMMAND, data, True, return_response=True + ) await hass.async_block_till_done() - client.request.assert_called_with("test", payload=None) + client.request.assert_called_with("audio/getVolume", payload=None) + assert response == snapshot async def test_command_with_optional_arg(hass: HomeAssistant, client) -> None: @@ -258,17 +271,32 @@ async def test_command_with_optional_arg(hass: HomeAssistant, client) -> None: ) -async def test_select_sound_output(hass: HomeAssistant, client) -> None: +async def test_select_sound_output( + hass: HomeAssistant, + client, + snapshot: SnapshotAssertion, +) -> None: """Test select sound output service.""" await setup_webostv(hass) + client.change_sound_output.return_value = { + "returnValue": True, + "method": "setSystemSettings", + } data = { ATTR_ENTITY_ID: ENTITY_ID, ATTR_SOUND_OUTPUT: "external_speaker", } - await hass.services.async_call(DOMAIN, SERVICE_SELECT_SOUND_OUTPUT, data, True) + response = await hass.services.async_call( + DOMAIN, + SERVICE_SELECT_SOUND_OUTPUT, + data, + True, + return_response=True, + ) await hass.async_block_till_done() client.change_sound_output.assert_called_once_with("external_speaker") + assert response == snapshot async def test_device_info_startup_off( From ee1d76de9f5d22e4e24e84784bf0aafb03ea72e4 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 28 Jan 2025 20:37:01 +0100 Subject: [PATCH 0161/3148] Capitalize "Velbus", replace "service calls" with "actions" (#136744) --- homeassistant/components/velbus/strings.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/velbus/strings.json b/homeassistant/components/velbus/strings.json index 90938a6c1d2..69fc3d661e9 100644 --- a/homeassistant/components/velbus/strings.json +++ b/homeassistant/components/velbus/strings.json @@ -2,9 +2,9 @@ "config": { "step": { "user": { - "title": "Define the velbus connection type", + "title": "Define the Velbus connection type", "data": { - "name": "The name for this velbus connection", + "name": "The name for this Velbus connection", "port": "Connection string" } } @@ -31,21 +31,21 @@ "services": { "sync_clock": { "name": "Sync clock", - "description": "Syncs the velbus modules clock to the Home Assistant clock, this is the same as the 'sync clock' from VelbusLink.", + "description": "Syncs the Velbus modules clock to the Home Assistant clock, this is the same as the 'sync clock' from VelbusLink.", "fields": { "interface": { "name": "Interface", - "description": "The velbus interface to send the command to, this will be the same value as used during configuration." + "description": "The Velbus interface to send the command to, this will be the same value as used during configuration." }, "config_entry": { "name": "Config entry", - "description": "The config entry of the velbus integration" + "description": "The config entry of the Velbus integration" } } }, "scan": { "name": "Scan", - "description": "Scans the velbus modules, this will be needed if you see unknown module warnings in the logs, or when you added new modules.", + "description": "Scans the Velbus modules, this will be needed if you see unknown module warnings in the logs, or when you added new modules.", "fields": { "interface": { "name": "[%key:component::velbus::services::sync_clock::fields::interface::name%]", @@ -59,7 +59,7 @@ }, "clear_cache": { "name": "Clear cache", - "description": "Clears the velbuscache and then starts a new scan.", + "description": "Clears the Velbus cache and then starts a new scan.", "fields": { "interface": { "name": "[%key:component::velbus::services::sync_clock::fields::interface::name%]", @@ -101,7 +101,7 @@ "issues": { "deprecated_interface_parameter": { "title": "Deprecated 'interface' parameter", - "description": "The 'interface' parameter in the Velbus service calls is deprecated. The 'config_entry' parameter should be used going forward.\n\nPlease adjust your automations or scripts to fix this issue." + "description": "The 'interface' parameter in the Velbus actions is deprecated. The 'config_entry' parameter should be used going forward.\n\nPlease adjust your automations or scripts to fix this issue." } } } From 1face8df565e4ad16281bcfcf8dc7a280a15c203 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 28 Jan 2025 09:43:00 -1000 Subject: [PATCH 0162/3148] Bump habluetooth to 3.13.0 (#136749) --- 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 22f8aa8fdb8..b172a6c6aef 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -21,6 +21,6 @@ "bluetooth-auto-recovery==1.4.2", "bluetooth-data-tools==1.22.0", "dbus-fast==2.30.2", - "habluetooth==3.12.0" + "habluetooth==3.13.0" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index e29c0f25d7c..e147ce58c57 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -33,7 +33,7 @@ dbus-fast==2.30.2 fnv-hash-fast==1.2.2 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 -habluetooth==3.12.0 +habluetooth==3.13.0 hass-nabucasa==0.88.1 hassil==2.1.0 home-assistant-bluetooth==1.13.0 diff --git a/requirements_all.txt b/requirements_all.txt index 8f1d1fa9a70..88578c429ec 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1100,7 +1100,7 @@ ha-philipsjs==3.2.2 habiticalib==0.3.3 # homeassistant.components.bluetooth -habluetooth==3.12.0 +habluetooth==3.13.0 # homeassistant.components.cloud hass-nabucasa==0.88.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 43fbdb63b26..ce2eff26935 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -941,7 +941,7 @@ ha-philipsjs==3.2.2 habiticalib==0.3.3 # homeassistant.components.bluetooth -habluetooth==3.12.0 +habluetooth==3.13.0 # homeassistant.components.cloud hass-nabucasa==0.88.1 From 51ce6f093383c4065d9e350d78688391ca10c09e Mon Sep 17 00:00:00 2001 From: Richard Polzer Date: Tue, 28 Jan 2025 22:24:47 +0100 Subject: [PATCH 0163/3148] Update xknx to 3.5.0 (#136759) Dependency Bump 3.5.0 --- homeassistant/components/knx/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/knx/manifest.json b/homeassistant/components/knx/manifest.json index 73a61be68ee..acb9b9b61a0 100644 --- a/homeassistant/components/knx/manifest.json +++ b/homeassistant/components/knx/manifest.json @@ -10,7 +10,7 @@ "iot_class": "local_push", "loggers": ["xknx", "xknxproject"], "requirements": [ - "xknx==3.4.0", + "xknx==3.5.0", "xknxproject==3.8.1", "knx-frontend==2025.1.18.164225" ], diff --git a/requirements_all.txt b/requirements_all.txt index 88578c429ec..32be8cf54eb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3067,7 +3067,7 @@ xbox-webapi==2.1.0 xiaomi-ble==0.33.0 # homeassistant.components.knx -xknx==3.4.0 +xknx==3.5.0 # homeassistant.components.knx xknxproject==3.8.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ce2eff26935..db654e4bf4b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2468,7 +2468,7 @@ xbox-webapi==2.1.0 xiaomi-ble==0.33.0 # homeassistant.components.knx -xknx==3.4.0 +xknx==3.5.0 # homeassistant.components.knx xknxproject==3.8.1 From c46258fbf78ed94a6ed3c54fa0217f3c370d357a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Tue, 28 Jan 2025 21:39:33 +0000 Subject: [PATCH 0164/3148] Add volt/power/power_factor strings and state attrs for ZHA 3 phase meters (#133969) --- homeassistant/components/zha/sensor.py | 6 ++++++ homeassistant/components/zha/strings.json | 18 ++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/homeassistant/components/zha/sensor.py b/homeassistant/components/zha/sensor.py index 670d6af3c52..0506496f447 100644 --- a/homeassistant/components/zha/sensor.py +++ b/homeassistant/components/zha/sensor.py @@ -46,9 +46,15 @@ _EXTRA_STATE_ATTRIBUTES: set[str] = { "rms_current_max_ph_b", "rms_current_max_ph_c", "rms_voltage_max", + "rms_voltage_max_ph_b", + "rms_voltage_max_ph_c", "ac_frequency_max", "power_factor_max", + "power_factor_max_ph_b", + "power_factor_max_ph_c", "active_power_max", + "active_power_max_ph_b", + "active_power_max_ph_c", # Smart Energy metering "device_type", "status", diff --git a/homeassistant/components/zha/strings.json b/homeassistant/components/zha/strings.json index 35c9f35887d..f3320e7560e 100644 --- a/homeassistant/components/zha/strings.json +++ b/homeassistant/components/zha/strings.json @@ -1031,6 +1031,12 @@ } }, "sensor": { + "active_power_ph_b": { + "name": "Power phase B" + }, + "active_power_ph_c": { + "name": "Power phase C" + }, "analog_input": { "name": "Analog input" }, @@ -1046,12 +1052,24 @@ "instantaneous_demand": { "name": "Instantaneous demand" }, + "power_factor_ph_b": { + "name": "Power factor phase B" + }, + "power_factor_ph_c": { + "name": "Power factor phase C" + }, "rms_current_ph_b": { "name": "Current phase B" }, "rms_current_ph_c": { "name": "Current phase C" }, + "rms_voltage_ph_b": { + "name": "Voltage phase B" + }, + "rms_voltage_ph_c": { + "name": "Voltage phase C" + }, "summation_delivered": { "name": "Summation delivered" }, From 814e98f66aa4ae1bafeb1a254d8025d3bf296177 Mon Sep 17 00:00:00 2001 From: mkmer <7760516+mkmer@users.noreply.github.com> Date: Tue, 28 Jan 2025 16:50:01 -0500 Subject: [PATCH 0165/3148] Bump AIOSomecomfort to 0.0.32 (#136751) --- homeassistant/components/honeywell/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/honeywell/manifest.json b/homeassistant/components/honeywell/manifest.json index 36a4f497601..7fa102c6599 100644 --- a/homeassistant/components/honeywell/manifest.json +++ b/homeassistant/components/honeywell/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/honeywell", "iot_class": "cloud_polling", "loggers": ["somecomfort"], - "requirements": ["AIOSomecomfort==0.0.30"] + "requirements": ["AIOSomecomfort==0.0.32"] } diff --git a/requirements_all.txt b/requirements_all.txt index 32be8cf54eb..e4478503eb0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -7,7 +7,7 @@ AEMET-OpenData==0.6.4 # homeassistant.components.honeywell -AIOSomecomfort==0.0.30 +AIOSomecomfort==0.0.32 # homeassistant.components.adax Adax-local==0.1.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index db654e4bf4b..1a43f26f7e0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -7,7 +7,7 @@ AEMET-OpenData==0.6.4 # homeassistant.components.honeywell -AIOSomecomfort==0.0.30 +AIOSomecomfort==0.0.32 # homeassistant.components.adax Adax-local==0.1.5 From 77d9309b81d0ca190709158d06fa6f764e60b8d9 Mon Sep 17 00:00:00 2001 From: Richard Polzer Date: Tue, 28 Jan 2025 22:52:39 +0100 Subject: [PATCH 0166/3148] Add swing support for KNX climate entities (#136752) * added swing to knx climate * added tests for climate swing * removed type ignores * removed unreachable code --- homeassistant/components/knx/climate.py | 39 +++++++++++ homeassistant/components/knx/schema.py | 8 +++ tests/components/knx/test_climate.py | 88 +++++++++++++++++++++++++ 3 files changed, 135 insertions(+) diff --git a/homeassistant/components/knx/climate.py b/homeassistant/components/knx/climate.py index 2c0153c5d2b..e3bb63581e7 100644 --- a/homeassistant/components/knx/climate.py +++ b/homeassistant/components/knx/climate.py @@ -19,6 +19,8 @@ from homeassistant.components.climate import ( FAN_LOW, FAN_MEDIUM, FAN_ON, + SWING_OFF, + SWING_ON, ClimateEntity, ClimateEntityFeature, HVACAction, @@ -136,6 +138,14 @@ def _create_climate(xknx: XKNX, config: ConfigType) -> XknxClimate: ClimateSchema.CONF_FAN_SPEED_STATE_ADDRESS ), fan_speed_mode=config[ClimateSchema.CONF_FAN_SPEED_MODE], + group_address_swing=config.get(ClimateSchema.CONF_SWING_ADDRESS), + group_address_swing_state=config.get(ClimateSchema.CONF_SWING_STATE_ADDRESS), + group_address_horizontal_swing=config.get( + ClimateSchema.CONF_SWING_HORIZONTAL_ADDRESS + ), + group_address_horizontal_swing_state=config.get( + ClimateSchema.CONF_SWING_HORIZONTAL_STATE_ADDRESS + ), group_address_humidity_state=config.get( ClimateSchema.CONF_HUMIDITY_STATE_ADDRESS ), @@ -207,6 +217,13 @@ class KNXClimate(KnxYamlEntity, ClimateEntity): self._attr_fan_modes = [self.fan_zero_mode] + [ f"{percentage}%" for percentage in self._fan_modes_percentages[1:] ] + if self._device.swing.initialized: + self._attr_supported_features |= ClimateEntityFeature.SWING_MODE + self._attr_swing_modes = [SWING_ON, SWING_OFF] + + if self._device.horizontal_swing.initialized: + self._attr_supported_features |= ClimateEntityFeature.SWING_HORIZONTAL_MODE + self._attr_swing_horizontal_modes = [SWING_ON, SWING_OFF] self._attr_target_temperature_step = self._device.temperature_step self._attr_unique_id = ( @@ -399,6 +416,28 @@ class KNXClimate(KnxYamlEntity, ClimateEntity): await self._device.set_fan_speed(self._fan_modes_percentages[fan_mode_index]) + async def async_set_swing_mode(self, swing_mode: str) -> None: + """Set the swing setting.""" + await self._device.set_swing(swing_mode == SWING_ON) + + async def async_set_swing_horizontal_mode(self, swing_horizontal_mode: str) -> None: + """Set the horizontal swing setting.""" + await self._device.set_horizontal_swing(swing_horizontal_mode == SWING_ON) + + @property + def swing_mode(self) -> str | None: + """Return the swing setting.""" + if self._device.swing.value is not None: + return SWING_ON if self._device.swing.value else SWING_OFF + return None + + @property + def swing_horizontal_mode(self) -> str | None: + """Return the horizontal swing setting.""" + if self._device.horizontal_swing.value is not None: + return SWING_ON if self._device.horizontal_swing.value else SWING_OFF + return None + @property def current_humidity(self) -> float | None: """Return the current humidity.""" diff --git a/homeassistant/components/knx/schema.py b/homeassistant/components/knx/schema.py index 5c83da58c3a..1ac2b82247c 100644 --- a/homeassistant/components/knx/schema.py +++ b/homeassistant/components/knx/schema.py @@ -339,6 +339,10 @@ class ClimateSchema(KNXPlatformSchema): CONF_FAN_SPEED_MODE = "fan_speed_mode" CONF_FAN_ZERO_MODE = "fan_zero_mode" CONF_HUMIDITY_STATE_ADDRESS = "humidity_state_address" + CONF_SWING_ADDRESS = "swing_address" + CONF_SWING_STATE_ADDRESS = "swing_state_address" + CONF_SWING_HORIZONTAL_ADDRESS = "swing_horizontal_address" + CONF_SWING_HORIZONTAL_STATE_ADDRESS = "swing_horizontal_state_address" DEFAULT_NAME = "KNX Climate" DEFAULT_SETPOINT_SHIFT_MODE = "DPT6010" @@ -427,6 +431,10 @@ class ClimateSchema(KNXPlatformSchema): vol.Optional(CONF_FAN_ZERO_MODE, default=FAN_OFF): vol.Coerce( FanZeroMode ), + vol.Optional(CONF_SWING_ADDRESS): ga_list_validator, + vol.Optional(CONF_SWING_STATE_ADDRESS): ga_list_validator, + vol.Optional(CONF_SWING_HORIZONTAL_ADDRESS): ga_list_validator, + vol.Optional(CONF_SWING_HORIZONTAL_STATE_ADDRESS): ga_list_validator, vol.Optional(CONF_HUMIDITY_STATE_ADDRESS): ga_list_validator, } ), diff --git a/tests/components/knx/test_climate.py b/tests/components/knx/test_climate.py index 8fb348f1724..b5a90428ef2 100644 --- a/tests/components/knx/test_climate.py +++ b/tests/components/knx/test_climate.py @@ -850,3 +850,91 @@ async def test_climate_humidity(hass: HomeAssistant, knx: KNXTestKit) -> None: HVACMode.HEAT, current_humidity=45.6, ) + + +async def test_swing(hass: HomeAssistant, knx: KNXTestKit) -> None: + """Test KNX climate swing.""" + await knx.setup_integration( + { + ClimateSchema.PLATFORM: { + CONF_NAME: "test", + ClimateSchema.CONF_TEMPERATURE_ADDRESS: "1/2/3", + ClimateSchema.CONF_TARGET_TEMPERATURE_ADDRESS: "1/2/4", + ClimateSchema.CONF_TARGET_TEMPERATURE_STATE_ADDRESS: "1/2/5", + ClimateSchema.CONF_SWING_ADDRESS: "1/2/6", + ClimateSchema.CONF_SWING_STATE_ADDRESS: "1/2/7", + } + } + ) + + # read states state updater + await knx.assert_read("1/2/3") + await knx.assert_read("1/2/5") + + # StateUpdater initialize state + await knx.receive_response("1/2/5", RAW_FLOAT_22_0) + await knx.receive_response("1/2/3", RAW_FLOAT_21_0) + + # Query status + await knx.assert_read("1/2/7") + await knx.receive_response("1/2/7", True) + knx.assert_state( + "climate.test", + HVACMode.HEAT, + swing_mode="on", + swing_modes=["on", "off"], + ) + + # turn off + await hass.services.async_call( + "climate", + "set_swing_mode", + {"entity_id": "climate.test", "swing_mode": "off"}, + blocking=True, + ) + await knx.assert_write("1/2/6", False) + knx.assert_state("climate.test", HVACMode.HEAT, swing_mode="off") + + +async def test_horizontal_swing(hass: HomeAssistant, knx: KNXTestKit) -> None: + """Test KNX climate horizontal swing.""" + await knx.setup_integration( + { + ClimateSchema.PLATFORM: { + CONF_NAME: "test", + ClimateSchema.CONF_TEMPERATURE_ADDRESS: "1/2/3", + ClimateSchema.CONF_TARGET_TEMPERATURE_ADDRESS: "1/2/4", + ClimateSchema.CONF_TARGET_TEMPERATURE_STATE_ADDRESS: "1/2/5", + ClimateSchema.CONF_SWING_HORIZONTAL_ADDRESS: "1/2/6", + ClimateSchema.CONF_SWING_HORIZONTAL_STATE_ADDRESS: "1/2/7", + } + } + ) + + # read states state updater + await knx.assert_read("1/2/3") + await knx.assert_read("1/2/5") + + # StateUpdater initialize state + await knx.receive_response("1/2/5", RAW_FLOAT_22_0) + await knx.receive_response("1/2/3", RAW_FLOAT_21_0) + + # Query status + await knx.assert_read("1/2/7") + await knx.receive_response("1/2/7", True) + knx.assert_state( + "climate.test", + HVACMode.HEAT, + swing_horizontal_mode="on", + swing_horizontal_modes=["on", "off"], + ) + + # turn off + await hass.services.async_call( + "climate", + "set_swing_horizontal_mode", + {"entity_id": "climate.test", "swing_horizontal_mode": "off"}, + blocking=True, + ) + await knx.assert_write("1/2/6", False) + knx.assert_state("climate.test", HVACMode.HEAT, swing_horizontal_mode="off") From cc4abcadcdb0f31c6249f43af1b5829b853507cc Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Tue, 28 Jan 2025 23:32:13 +0100 Subject: [PATCH 0167/3148] Add translations for ZHA pilot wire mode and device mode (#136753) --- homeassistant/components/zha/icons.json | 6 ++++++ homeassistant/components/zha/strings.json | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/homeassistant/components/zha/icons.json b/homeassistant/components/zha/icons.json index 6ba4aab18ab..d43e213aa4a 100644 --- a/homeassistant/components/zha/icons.json +++ b/homeassistant/components/zha/icons.json @@ -124,6 +124,12 @@ }, "on_led_color": { "default": "mdi:palette" + }, + "device_mode": { + "default": "mdi:cogs" + }, + "pilot_wire_mode": { + "default": "mdi:radiator" } }, "sensor": { diff --git a/homeassistant/components/zha/strings.json b/homeassistant/components/zha/strings.json index f3320e7560e..5d4fec92af7 100644 --- a/homeassistant/components/zha/strings.json +++ b/homeassistant/components/zha/strings.json @@ -1028,6 +1028,12 @@ }, "operation_mode": { "name": "Operation mode" + }, + "device_mode": { + "name": "Device mode" + }, + "pilot_wire_mode": { + "name": "Pilot wire mode" } }, "sensor": { From c55caabbffeee93f70535d613f7f5982dd548bd9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 28 Jan 2025 13:05:53 -1000 Subject: [PATCH 0168/3148] Abort Bluetooth options flow if local adapters do not support passive scans (#136748) --- .../components/bluetooth/config_flow.py | 18 ++++++++++++- .../components/bluetooth/strings.json | 3 ++- .../components/bluetooth/test_config_flow.py | 27 +++++++++++++++++++ 3 files changed, 46 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/bluetooth/config_flow.py b/homeassistant/components/bluetooth/config_flow.py index 5bfe5e7089c..6425aabe12f 100644 --- a/homeassistant/components/bluetooth/config_flow.py +++ b/homeassistant/components/bluetooth/config_flow.py @@ -211,10 +211,16 @@ class BluetoothConfigFlow(ConfigFlow, domain=DOMAIN): @callback def async_get_options_flow( config_entry: ConfigEntry, - ) -> SchemaOptionsFlowHandler | RemoteAdapterOptionsFlowHandler: + ) -> ( + SchemaOptionsFlowHandler + | RemoteAdapterOptionsFlowHandler + | LocalNoPassiveOptionsFlowHandler + ): """Get the options flow for this handler.""" if CONF_SOURCE in config_entry.data: return RemoteAdapterOptionsFlowHandler() + if not (manager := get_manager()) or not manager.supports_passive_scan: + return LocalNoPassiveOptionsFlowHandler() return SchemaOptionsFlowHandler(config_entry, OPTIONS_FLOW) @classmethod @@ -232,3 +238,13 @@ class RemoteAdapterOptionsFlowHandler(OptionsFlow): ) -> ConfigFlowResult: """Handle options flow.""" return self.async_abort(reason="remote_adapters_not_supported") + + +class LocalNoPassiveOptionsFlowHandler(OptionsFlow): + """Handle a option flow for local adapters with no passive support.""" + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle options flow.""" + return self.async_abort(reason="local_adapters_no_passive_support") diff --git a/homeassistant/components/bluetooth/strings.json b/homeassistant/components/bluetooth/strings.json index 1b8231c66ca..5f9a380d631 100644 --- a/homeassistant/components/bluetooth/strings.json +++ b/homeassistant/components/bluetooth/strings.json @@ -35,7 +35,8 @@ } }, "abort": { - "remote_adapters_not_supported": "Bluetooth configuration for remote adapters is not supported." + "remote_adapters_not_supported": "Bluetooth configuration for remote adapters is not supported.", + "local_adapters_no_passive_support": "Local Bluetooth adapters that do not support passive scanning cannot be configured." } } } diff --git a/tests/components/bluetooth/test_config_flow.py b/tests/components/bluetooth/test_config_flow.py index abb3a5e2393..0070bebe4b6 100644 --- a/tests/components/bluetooth/test_config_flow.py +++ b/tests/components/bluetooth/test_config_flow.py @@ -487,6 +487,33 @@ async def test_options_flow_remote_adapter(hass: HomeAssistant) -> None: assert result["reason"] == "remote_adapters_not_supported" +@pytest.mark.usefixtures( + "one_adapter", "mock_bleak_scanner_start", "mock_bluetooth_adapters" +) +async def test_options_flow_local_no_passive_support(hass: HomeAssistant) -> None: + """Test options are not available for local adapters without passive support.""" + source_entry = MockConfigEntry( + domain="test", + ) + source_entry.add_to_hass(hass) + entry = MockConfigEntry( + domain=DOMAIN, + data={}, + options={}, + unique_id="BB:BB:BB:BB:BB:BB", + ) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + _get_manager()._adapters["hci0"]["passive_scan"] = False + + result = await hass.config_entries.options.async_init(entry.entry_id) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "local_adapters_no_passive_support" + + @pytest.mark.usefixtures("one_adapter") async def test_async_step_user_linux_adapter_is_ignored(hass: HomeAssistant) -> None: """Test we give a hint that the adapter is ignored.""" From 29a3f0a27193be0ac328da1148ef559c3278d376 Mon Sep 17 00:00:00 2001 From: hahn-th <15319212+hahn-th@users.noreply.github.com> Date: Wed, 29 Jan 2025 00:06:19 +0100 Subject: [PATCH 0169/3148] Bump homematicip to 1.1.7 (#136767) --- homeassistant/components/homematicip_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/homematicip_cloud/manifest.json b/homeassistant/components/homematicip_cloud/manifest.json index 6fc422498ab..414ba37709e 100644 --- a/homeassistant/components/homematicip_cloud/manifest.json +++ b/homeassistant/components/homematicip_cloud/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/homematicip_cloud", "iot_class": "cloud_push", "loggers": ["homematicip"], - "requirements": ["homematicip==1.1.6"] + "requirements": ["homematicip==1.1.7"] } diff --git a/requirements_all.txt b/requirements_all.txt index e4478503eb0..ba11e9c0d6d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1152,7 +1152,7 @@ home-assistant-intents==2025.1.1 homeconnect==0.8.0 # homeassistant.components.homematicip_cloud -homematicip==1.1.6 +homematicip==1.1.7 # homeassistant.components.horizon horimote==0.4.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1a43f26f7e0..4f7bf17edda 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -981,7 +981,7 @@ home-assistant-intents==2025.1.1 homeconnect==0.8.0 # homeassistant.components.homematicip_cloud -homematicip==1.1.6 +homematicip==1.1.7 # homeassistant.components.remember_the_milk httplib2==0.20.4 From 68dbe34b89076b7f921c8b6daaf36ecc20174dec Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 28 Jan 2025 13:06:24 -1000 Subject: [PATCH 0170/3148] Add Bluetooth WebSocket API to subscribe to scanner details (#136750) --- .../components/bluetooth/websocket_api.py | 64 +++++++- .../bluetooth/test_websocket_api.py | 142 +++++++++++++++++- 2 files changed, 201 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/bluetooth/websocket_api.py b/homeassistant/components/bluetooth/websocket_api.py index 2829617d09e..d21b11b050f 100644 --- a/homeassistant/components/bluetooth/websocket_api.py +++ b/homeassistant/components/bluetooth/websocket_api.py @@ -7,7 +7,12 @@ from functools import lru_cache, partial import time from typing import Any -from habluetooth import BluetoothScanningMode, HaBluetoothSlotAllocations +from habluetooth import ( + BluetoothScanningMode, + HaBluetoothSlotAllocations, + HaScannerRegistration, + HaScannerRegistrationEvent, +) from home_assistant_bluetooth import BluetoothServiceInfoBleak import voluptuous as vol @@ -16,6 +21,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.json import json_bytes from .api import _get_manager, async_register_callback +from .const import DOMAIN from .match import BluetoothCallbackMatcher from .models import BluetoothChange from .util import InvalidConfigEntryID, InvalidSource, config_entry_id_to_source @@ -26,6 +32,7 @@ def async_setup(hass: HomeAssistant) -> None: """Set up the bluetooth websocket API.""" websocket_api.async_register_command(hass, ws_subscribe_advertisements) websocket_api.async_register_command(hass, ws_subscribe_connection_allocations) + websocket_api.async_register_command(hass, ws_subscribe_scanner_details) @lru_cache(maxsize=1024) @@ -191,3 +198,58 @@ async def ws_subscribe_connection_allocations( connection.send_message( json_bytes(websocket_api.event_message(ws_msg_id, current_allocations)) ) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required("type"): "bluetooth/subscribe_scanner_details", + vol.Optional("config_entry_id"): str, + } +) +@websocket_api.async_response +async def ws_subscribe_scanner_details( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] +) -> None: + """Handle subscribe scanner details websocket command.""" + ws_msg_id = msg["id"] + source: str | None = None + if config_entry_id := msg.get("config_entry_id"): + if ( + not (entry := hass.config_entries.async_get_entry(config_entry_id)) + or entry.domain != DOMAIN + ): + connection.send_error( + ws_msg_id, + "invalid_config_entry_id", + f"Invalid config entry id: {config_entry_id}", + ) + return + source = entry.unique_id + assert source is not None + + def _async_event_message(message: dict[str, Any]) -> None: + connection.send_message( + json_bytes(websocket_api.event_message(ws_msg_id, message)) + ) + + def _async_registration_changed(registration: HaScannerRegistration) -> None: + added_event = HaScannerRegistrationEvent.ADDED + event_type = "add" if registration.event == added_event else "remove" + _async_event_message({event_type: [registration.scanner.details]}) + + manager = _get_manager(hass) + connection.subscriptions[ws_msg_id] = ( + manager.async_register_scanner_registration_callback( + _async_registration_changed, source + ) + ) + connection.send_message(json_bytes(websocket_api.result_message(ws_msg_id))) + if (scanners := manager.async_current_scanners()) and ( + matching_scanners := [ + scanner.details + for scanner in scanners + if source is None or scanner.source == source + ] + ): + _async_event_message({"add": matching_scanners}) diff --git a/tests/components/bluetooth/test_websocket_api.py b/tests/components/bluetooth/test_websocket_api.py index d9289fe8380..bacdbbd5eed 100644 --- a/tests/components/bluetooth/test_websocket_api.py +++ b/tests/components/bluetooth/test_websocket_api.py @@ -17,6 +17,7 @@ from . import ( HCI0_SOURCE_ADDRESS, HCI1_SOURCE_ADDRESS, NON_CONNECTABLE_REMOTE_SOURCE_ADDRESS, + FakeScanner, _get_manager, generate_advertisement_data, generate_ble_device, @@ -123,7 +124,7 @@ async def test_subscribe_advertisements( @pytest.mark.usefixtures("enable_bluetooth") -async def test_subscribe_subscribe_connection_allocations( +async def test_subscribe_connection_allocations( hass: HomeAssistant, register_hci0_scanner: None, register_hci1_scanner: None, @@ -201,7 +202,7 @@ async def test_subscribe_subscribe_connection_allocations( @pytest.mark.usefixtures("enable_bluetooth") -async def test_subscribe_subscribe_connection_allocations_specific_scanner( +async def test_subscribe_connection_allocations_specific_scanner( hass: HomeAssistant, register_non_connectable_scanner: None, hass_ws_client: WebSocketGenerator, @@ -237,7 +238,7 @@ async def test_subscribe_subscribe_connection_allocations_specific_scanner( @pytest.mark.usefixtures("enable_bluetooth") -async def test_subscribe_subscribe_connection_allocations_invalid_config_entry_id( +async def test_subscribe_connection_allocations_invalid_config_entry_id( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, ) -> None: @@ -258,7 +259,7 @@ async def test_subscribe_subscribe_connection_allocations_invalid_config_entry_i @pytest.mark.usefixtures("enable_bluetooth") -async def test_subscribe_subscribe_connection_allocations_invalid_scanner( +async def test_subscribe_connection_allocations_invalid_scanner( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, ) -> None: @@ -278,3 +279,136 @@ async def test_subscribe_subscribe_connection_allocations_invalid_scanner( assert not response["success"] assert response["error"]["code"] == "invalid_source" assert response["error"]["message"] == "Source invalid not found" + + +@pytest.mark.usefixtures("enable_bluetooth") +async def test_subscribe_scanner_details( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test bluetooth subscribe_connection_allocations.""" + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "bluetooth/subscribe_scanner_details", + } + ) + async with asyncio.timeout(1): + response = await client.receive_json() + assert response["success"] + + async with asyncio.timeout(1): + response = await client.receive_json() + + assert response["event"] == { + "add": [ + { + "adapter": "hci0", + "connectable": False, + "name": "hci0 (00:00:00:00:00:01)", + "source": "00:00:00:00:00:01", + } + ] + } + + manager = _get_manager() + hci3_scanner = FakeScanner("AA:BB:CC:DD:EE:33", "hci3") + cancel_hci3 = manager.async_register_hass_scanner(hci3_scanner) + + async with asyncio.timeout(1): + response = await client.receive_json() + assert response["event"] == { + "add": [ + { + "adapter": "hci3", + "connectable": False, + "name": "hci3 (AA:BB:CC:DD:EE:33)", + "source": "AA:BB:CC:DD:EE:33", + } + ] + } + cancel_hci3() + async with asyncio.timeout(1): + response = await client.receive_json() + assert response["event"] == { + "remove": [ + { + "adapter": "hci3", + "connectable": False, + "name": "hci3 (AA:BB:CC:DD:EE:33)", + "source": "AA:BB:CC:DD:EE:33", + } + ] + } + + +@pytest.mark.usefixtures("enable_bluetooth") +async def test_subscribe_scanner_details_specific_scanner( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test bluetooth subscribe_scanner_details for a specific source address.""" + entry = MockConfigEntry(domain=DOMAIN, unique_id="AA:BB:CC:DD:EE:33") + entry.add_to_hass(hass) + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "bluetooth/subscribe_scanner_details", + "config_entry_id": entry.entry_id, + } + ) + async with asyncio.timeout(1): + response = await client.receive_json() + assert response["success"] + manager = _get_manager() + hci3_scanner = FakeScanner("AA:BB:CC:DD:EE:33", "hci3") + cancel_hci3 = manager.async_register_hass_scanner(hci3_scanner) + + async with asyncio.timeout(1): + response = await client.receive_json() + assert response["event"] == { + "add": [ + { + "adapter": "hci3", + "connectable": False, + "name": "hci3 (AA:BB:CC:DD:EE:33)", + "source": "AA:BB:CC:DD:EE:33", + } + ] + } + cancel_hci3() + async with asyncio.timeout(1): + response = await client.receive_json() + assert response["event"] == { + "remove": [ + { + "adapter": "hci3", + "connectable": False, + "name": "hci3 (AA:BB:CC:DD:EE:33)", + "source": "AA:BB:CC:DD:EE:33", + } + ] + } + + +@pytest.mark.usefixtures("enable_bluetooth") +async def test_subscribe_scanner_details_invalid_config_entry_id( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test bluetooth subscribe_scanner_details for an invalid config entry id.""" + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "bluetooth/subscribe_scanner_details", + "config_entry_id": "non_existent", + } + ) + async with asyncio.timeout(1): + response = await client.receive_json() + assert not response["success"] + assert response["error"]["code"] == "invalid_config_entry_id" + assert response["error"]["message"] == "Invalid config entry id: non_existent" From eb4a05e3652b016056f9cf85e1095329bb7c1d27 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Tue, 28 Jan 2025 17:58:53 -0600 Subject: [PATCH 0171/3148] Bump hassil to 2.2.0 (#136787) --- .../components/conversation/manifest.json | 2 +- homeassistant/components/conversation/trigger.py | 14 ++++++++++++-- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- script/hassfest/docker/Dockerfile | 2 +- .../conversation/snapshots/test_http.ambr | 4 ++-- 7 files changed, 19 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/conversation/manifest.json b/homeassistant/components/conversation/manifest.json index 979ea7538c4..7ca1799b2d1 100644 --- a/homeassistant/components/conversation/manifest.json +++ b/homeassistant/components/conversation/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/conversation", "integration_type": "system", "quality_scale": "internal", - "requirements": ["hassil==2.1.0", "home-assistant-intents==2025.1.1"] + "requirements": ["hassil==2.2.0", "home-assistant-intents==2025.1.1"] } diff --git a/homeassistant/components/conversation/trigger.py b/homeassistant/components/conversation/trigger.py index 634ae1fd9aa..752e294a8b3 100644 --- a/homeassistant/components/conversation/trigger.py +++ b/homeassistant/components/conversation/trigger.py @@ -5,7 +5,12 @@ from __future__ import annotations from typing import Any from hassil.recognize import RecognizeResult -from hassil.util import PUNCTUATION_ALL +from hassil.util import ( + PUNCTUATION_END, + PUNCTUATION_END_WORD, + PUNCTUATION_START, + PUNCTUATION_START_WORD, +) import voluptuous as vol from homeassistant.const import CONF_COMMAND, CONF_PLATFORM @@ -22,7 +27,12 @@ from .models import ConversationInput def has_no_punctuation(value: list[str]) -> list[str]: """Validate result does not contain punctuation.""" for sentence in value: - if PUNCTUATION_ALL.search(sentence): + if ( + PUNCTUATION_START.search(sentence) + or PUNCTUATION_END.search(sentence) + or PUNCTUATION_START_WORD.search(sentence) + or PUNCTUATION_END_WORD.search(sentence) + ): raise vol.Invalid("sentence should not contain punctuation") return value diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index e147ce58c57..51393c2a516 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -35,7 +35,7 @@ go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 habluetooth==3.13.0 hass-nabucasa==0.88.1 -hassil==2.1.0 +hassil==2.2.0 home-assistant-bluetooth==1.13.0 home-assistant-frontend==20250109.2 home-assistant-intents==2025.1.1 diff --git a/requirements_all.txt b/requirements_all.txt index ba11e9c0d6d..a366b5f2f32 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1109,7 +1109,7 @@ hass-nabucasa==0.88.1 hass-splunk==0.1.1 # homeassistant.components.conversation -hassil==2.1.0 +hassil==2.2.0 # homeassistant.components.jewish_calendar hdate==0.11.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4f7bf17edda..a8e934d8dd2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -947,7 +947,7 @@ habluetooth==3.13.0 hass-nabucasa==0.88.1 # homeassistant.components.conversation -hassil==2.1.0 +hassil==2.2.0 # homeassistant.components.jewish_calendar hdate==0.11.1 diff --git a/script/hassfest/docker/Dockerfile b/script/hassfest/docker/Dockerfile index 21b98d30f1e..5700ca01462 100644 --- a/script/hassfest/docker/Dockerfile +++ b/script/hassfest/docker/Dockerfile @@ -25,7 +25,7 @@ RUN --mount=from=ghcr.io/astral-sh/uv:0.5.21,source=/uv,target=/bin/uv \ -c /usr/src/homeassistant/homeassistant/package_constraints.txt \ -r /usr/src/homeassistant/requirements.txt \ stdlib-list==0.10.0 pipdeptree==2.23.4 tqdm==4.66.5 ruff==0.9.1 \ - PyTurboJPEG==1.7.5 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 hassil==2.1.0 home-assistant-intents==2025.1.1 mutagen==1.47.0 pymicro-vad==1.0.1 pyspeex-noise==1.0.2 + PyTurboJPEG==1.7.5 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 hassil==2.2.0 home-assistant-intents==2025.1.1 mutagen==1.47.0 pymicro-vad==1.0.1 pyspeex-noise==1.0.2 LABEL "name"="hassfest" LABEL "maintainer"="Home Assistant " diff --git a/tests/components/conversation/snapshots/test_http.ambr b/tests/components/conversation/snapshots/test_http.ambr index 1102a41e6c3..3e71ee99382 100644 --- a/tests/components/conversation/snapshots/test_http.ambr +++ b/tests/components/conversation/snapshots/test_http.ambr @@ -638,7 +638,7 @@ 'brightness': dict({ 'name': 'brightness', 'text': '100', - 'value': 100, + 'value': 100.0, }), 'name': dict({ 'name': 'name', @@ -690,7 +690,7 @@ 'targets': dict({ }), 'unmatched_slots': dict({ - 'brightness': 1001, + 'brightness': 1001.0, }), }), ]), From 7d0e314c356e7663d77c6941e46844580218d835 Mon Sep 17 00:00:00 2001 From: Duco Sebel <74970928+DCSBL@users.noreply.github.com> Date: Wed, 29 Jan 2025 01:00:46 +0100 Subject: [PATCH 0172/3148] Bumb python-homewizard-energy to 8.3.0 (#136765) --- homeassistant/components/homewizard/manifest.json | 2 +- homeassistant/components/homewizard/sensor.py | 11 +++++++++-- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../homewizard/snapshots/test_diagnostics.ambr | 9 +++++++++ 5 files changed, 21 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/homewizard/manifest.json b/homeassistant/components/homewizard/manifest.json index b1a19134752..957ed912b7d 100644 --- a/homeassistant/components/homewizard/manifest.json +++ b/homeassistant/components/homewizard/manifest.json @@ -12,6 +12,6 @@ "iot_class": "local_polling", "loggers": ["homewizard_energy"], "quality_scale": "platinum", - "requirements": ["python-homewizard-energy==v8.2.0"], + "requirements": ["python-homewizard-energy==v8.3.0"], "zeroconf": ["_hwenergy._tcp.local.", "_homewizard._tcp.local."] } diff --git a/homeassistant/components/homewizard/sensor.py b/homeassistant/components/homewizard/sensor.py index b6227a03bed..02355bc6c5e 100644 --- a/homeassistant/components/homewizard/sensor.py +++ b/homeassistant/components/homewizard/sensor.py @@ -116,8 +116,15 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, - has_fn=lambda data: data.measurement.wifi_strength is not None, - value_fn=lambda data: data.measurement.wifi_strength, + has_fn=( + lambda data: data.system is not None + and data.system.wifi_strength_pct is not None + ), + value_fn=( + lambda data: data.system.wifi_strength_pct + if data.system is not None + else None + ), ), HomeWizardSensorEntityDescription( key="total_power_import_kwh", diff --git a/requirements_all.txt b/requirements_all.txt index a366b5f2f32..b0823505444 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2388,7 +2388,7 @@ python-gitlab==1.6.0 python-homeassistant-analytics==0.8.1 # homeassistant.components.homewizard -python-homewizard-energy==v8.2.0 +python-homewizard-energy==v8.3.0 # homeassistant.components.hp_ilo python-hpilo==4.4.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a8e934d8dd2..dfd3edcba67 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1933,7 +1933,7 @@ python-fullykiosk==0.0.14 python-homeassistant-analytics==0.8.1 # homeassistant.components.homewizard -python-homewizard-energy==v8.2.0 +python-homewizard-energy==v8.3.0 # homeassistant.components.izone python-izone==1.2.9 diff --git a/tests/components/homewizard/snapshots/test_diagnostics.ambr b/tests/components/homewizard/snapshots/test_diagnostics.ambr index 192b9dbdc32..2545f674bbd 100644 --- a/tests/components/homewizard/snapshots/test_diagnostics.ambr +++ b/tests/components/homewizard/snapshots/test_diagnostics.ambr @@ -79,6 +79,7 @@ 'uptime_s': 356, 'wifi_rssi_db': -77, 'wifi_ssid': '**REDACTED**', + 'wifi_strength_pct': 100, }), }), 'entry': dict({ @@ -169,6 +170,7 @@ 'uptime_s': None, 'wifi_rssi_db': None, 'wifi_ssid': '**REDACTED**', + 'wifi_strength_pct': 92, }), }), 'entry': dict({ @@ -259,6 +261,7 @@ 'uptime_s': None, 'wifi_rssi_db': None, 'wifi_ssid': '**REDACTED**', + 'wifi_strength_pct': 92, }), }), 'entry': dict({ @@ -385,6 +388,7 @@ 'uptime_s': None, 'wifi_rssi_db': None, 'wifi_ssid': '**REDACTED**', + 'wifi_strength_pct': 100, }), }), 'entry': dict({ @@ -479,6 +483,7 @@ 'uptime_s': None, 'wifi_rssi_db': None, 'wifi_ssid': '**REDACTED**', + 'wifi_strength_pct': 94, }), }), 'entry': dict({ @@ -573,6 +578,7 @@ 'uptime_s': None, 'wifi_rssi_db': None, 'wifi_ssid': '**REDACTED**', + 'wifi_strength_pct': 100, }), }), 'entry': dict({ @@ -663,6 +669,7 @@ 'uptime_s': None, 'wifi_rssi_db': None, 'wifi_ssid': '**REDACTED**', + 'wifi_strength_pct': 84, }), }), 'entry': dict({ @@ -753,6 +760,7 @@ 'uptime_s': None, 'wifi_rssi_db': None, 'wifi_ssid': '**REDACTED**', + 'wifi_strength_pct': 92, }), }), 'entry': dict({ @@ -843,6 +851,7 @@ 'uptime_s': None, 'wifi_rssi_db': None, 'wifi_ssid': '**REDACTED**', + 'wifi_strength_pct': 92, }), }), 'entry': dict({ From 898d12aa21e5e5eb01aee605b6f7098056f8e5db Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Wed, 29 Jan 2025 02:05:05 +0200 Subject: [PATCH 0173/3148] Bump aiowebostv to 0.6.1 (#136784) --- homeassistant/components/webostv/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/webostv/manifest.json b/homeassistant/components/webostv/manifest.json index f1a8e163398..174e8025dd0 100644 --- a/homeassistant/components/webostv/manifest.json +++ b/homeassistant/components/webostv/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/webostv", "iot_class": "local_push", "loggers": ["aiowebostv"], - "requirements": ["aiowebostv==0.6.0"], + "requirements": ["aiowebostv==0.6.1"], "ssdp": [ { "st": "urn:lge-com:service:webos-second-screen:1" diff --git a/requirements_all.txt b/requirements_all.txt index b0823505444..17cf0bcbd8d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -419,7 +419,7 @@ aiowaqi==3.1.0 aiowatttime==0.1.1 # homeassistant.components.webostv -aiowebostv==0.6.0 +aiowebostv==0.6.1 # homeassistant.components.withings aiowithings==3.1.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index dfd3edcba67..fda1b7c4630 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -401,7 +401,7 @@ aiowaqi==3.1.0 aiowatttime==0.1.1 # homeassistant.components.webostv -aiowebostv==0.6.0 +aiowebostv==0.6.1 # homeassistant.components.withings aiowithings==3.1.5 From fa2aeae30f15732e44c44ad80fd3a3b5c3899d4a Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Wed, 29 Jan 2025 01:05:32 +0100 Subject: [PATCH 0174/3148] Bump ZHA to 0.0.46 (#136785) --- homeassistant/components/zha/manifest.json | 2 +- homeassistant/components/zha/strings.json | 342 +++++++++++++++++++++ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 345 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index f9323fe99df..fa8bab409c9 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -21,7 +21,7 @@ "zha", "universal_silabs_flasher" ], - "requirements": ["zha==0.0.45"], + "requirements": ["zha==0.0.46"], "usb": [ { "vid": "10C4", diff --git a/homeassistant/components/zha/strings.json b/homeassistant/components/zha/strings.json index 5d4fec92af7..c73a0989faa 100644 --- a/homeassistant/components/zha/strings.json +++ b/homeassistant/components/zha/strings.json @@ -592,6 +592,24 @@ }, "window_detection": { "name": "Open window detection" + }, + "silence_alarm": { + "name": "Silence alarm" + }, + "preheat_active": { + "name": "Preheat active" + }, + "fault_alarm": { + "name": "Fault alarm" + }, + "led_indicator": { + "name": "LED indicator" + }, + "error_or_battery_low": { + "name": "Error or battery low" + }, + "flow_switch": { + "name": "Flow switch" } }, "button": { @@ -612,6 +630,9 @@ }, "restart_device": { "name": "Restart device" + }, + "frost_lock_reset": { + "name": "Frost lock reset" } }, "climate": { @@ -885,6 +906,144 @@ }, "fading_time": { "name": "Fading time" + }, + "temperature_offset": { + "name": "Temperature offset" + }, + "humidity_offset": { + "name": "Humidity offset" + }, + "comfort_temperature_min": { + "name": "Comfort temperature min" + }, + "comfort_temperature_max": { + "name": "Comfort temperature max" + }, + "comfort_humidity_min": { + "name": "Comfort humidity min" + }, + "comfort_humidity_max": { + "name": "Comfort humidity max" + }, + "measurement_interval": { + "name": "Measurement interval" + }, + "on_time": { + "name": "On time" + }, + "alarm_duration": { + "name": "Alarm duration" + }, + "max_set": { + "name": "Liquid max percentage" + }, + "mini_set": { + "name": "Liquid minimal percentage" + }, + "installation_height": { + "name": "Height from sensor to tank bottom" + }, + "liquid_depth_max": { + "name": "Height from sensor to liquid level" + }, + "interval_time": { + "name": "Interval time" + }, + "target_distance": { + "name": "Target distance" + }, + "hold_delay_time": { + "name": "Hold delay time" + }, + "breath_detection_max": { + "name": "Breath detection max" + }, + "breath_detection_min": { + "name": "Breath detection min" + }, + "small_move_detection_max": { + "name": "Small move detection max" + }, + "small_move_detection_min": { + "name": "Small move detection min" + }, + "small_move_sensitivity": { + "name": "Small move sensitivity" + }, + "breath_sensitivity": { + "name": "Breath sensitivity" + }, + "entry_sensitivity": { + "name": "Entry sensitivity" + }, + "entry_distance_indentation": { + "name": "Entry distance indentation" + }, + "illuminance_threshold": { + "name": "Illuminance threshold" + }, + "block_time": { + "name": "Block time" + }, + "motion_sensitivity": { + "name": "Motion sensitivity" + }, + "radar_sensitivity": { + "name": "Radar sensitivity" + }, + "motionless_detection": { + "name": "Motionless detection" + }, + "motionless_sensitivity": { + "name": "Motionless detection sensitivity" + }, + "output_time": { + "name": "Output time" + }, + "illuminance_interval": { + "name": "Illuminance interval" + }, + "temperature_report_interval": { + "name": "Temperature report interval" + }, + "humidity_report_interval": { + "name": "Humidity report interval" + }, + "alarm_temperature_max": { + "name": "Alarm temperature max" + }, + "alarm_temperature_min": { + "name": "Alarm temperature min" + }, + "temperature_sensitivity": { + "name": "Temperature sensitivity" + }, + "alarm_humidity_max": { + "name": "Alarm humidity max" + }, + "alarm_humidity_min": { + "name": "Alarm humidity min" + }, + "humidity_sensitivity": { + "name": "Humidity sensitivity" + }, + "deadzone_temperature": { + "name": "Deadzone temperature" + }, + "min_temperature": { + "name": "Min temperature" + }, + "max_temperature": { + "name": "Max temperature" + }, + "valve_countdown": { + "name": "Irrigation time" + }, + "quantitative_watering": { + "name": "Quantitative watering" + }, + "valve_duration": { + "name": "Irrigation duration" } }, "select": { @@ -1034,6 +1193,48 @@ }, "pilot_wire_mode": { "name": "Pilot wire mode" + }, + "alarm_ringtone": { + "name": "Alarm ringtone" + }, + "liquid_state": { + "name": "Liquid state" + }, + "breaker_mode": { + "name": "Breaker mode" + }, + "breaker_status": { + "name": "Breaker status" + }, + "status_indication": { + "name": "Status indication" + }, + "breaker_polarity": { + "name": "Breaker polarity" + }, + "work_mode": { + "name": "Work mode" + }, + "presence_sensitivity": { + "name": "Presence sensitivity" + }, + "fading_time": { + "name": "Fading time" + }, + "display_unit": { + "name": "Display unit" + }, + "alarm_mode": { + "name": "Alarm mode" + }, + "alarm_volume": { + "name": "Alarm volume" + }, + "working_day": { + "name": "Working day" + }, + "eco_mode": { + "name": "Eco mode" } }, "sensor": { @@ -1276,6 +1477,90 @@ }, "self_test": { "name": "Self test result" + }, + "voc_index": { + "name": "VOC index" + }, + "energy_ph_a": { + "name": "Energy phase A" + }, + "energy_ph_b": { + "name": "Energy phase B" + }, + "energy_ph_c": { + "name": "Energy phase C" + }, + "energy_produced": { + "name": "Energy produced" + }, + "energy_produced_ph_a": { + "name": "Energy produced phase A" + }, + "energy_produced_ph_b": { + "name": "Energy produced phase B" + }, + "energy_produced_ph_c": { + "name": "Energy produced phase C" + }, + "total_power_factor": { + "name": "Total power factor" + }, + "self_test_result": { + "name": "Self test result" + }, + "lower_explosive_limit": { + "name": "% Lower explosive limit" + }, + "liquid_depth": { + "name": "Liquid depth" + }, + "liquid_level_percent": { + "name": "Liquid level ratio" + }, + "target_distance": { + "name": "Target distance" + }, + "human_motion_state": { + "name": "Human motion state" + }, + "temperature_alarm": { + "name": "Temperature alarm" + }, + "humidity_alarm": { + "name": "Humidity alarm" + }, + "alarm_state": { + "name": "Alarm state" + }, + "power_type": { + "name": "Power type" + }, + "valve_position": { + "name": "Valve position" + }, + "time_left": { + "name": "Time left" + }, + "valve_status": { + "name": "Valve status" + }, + "valve_duration": { + "name": "Irrigation duration" + }, + "smart_irrigation": { + "name": "Smart irrigation" + }, + "surplus_flow": { + "name": "Surplus flow" + }, + "single_watering_duration": { + "name": "Single watering duration" + }, + "single_watering_amount": { + "name": "Single watering amount" + }, + "error_status": { + "name": "Error status" } }, "switch": { @@ -1404,6 +1689,63 @@ }, "find_switch": { "name": "Distance switch" + }, + "display_enabled": { + "name": "Display enabled" + }, + "show_smiley": { + "name": "Show smiley" + }, + "on_only_when_dark": { + "name": "On only when dark" + }, + "mute_siren": { + "name": "Mute siren" + }, + "self_test_switch": { + "name": "Self test" + }, + "output_switch": { + "name": "Output switch" + }, + "siren_on": { + "name": "Siren on" + }, + "enable_tamper_alarm": { + "name": "Enable tamper alarm" + }, + "temperature_alarm": { + "name": "Temperature alarm" + }, + "humidity_alarm": { + "name": "Humidity alarm" + }, + "silence_alarm": { + "name": "Silence alarm" + }, + "frost_protection": { + "name": "Frost protection" + }, + "factory_reset": { + "name": "Factory reset" + }, + "away_mode": { + "name": "Away mode" + }, + "schedule_enable": { + "name": "Schedule enable" + }, + "scale_protection": { + "name": "Scale protection" + }, + "frost_lock": { + "name": "Frost lock" + }, + "switch_enabled": { + "name": "Switch enabled" + }, + "total_flow_reset_switch": { + "name": "Total flow reset switch" } } } diff --git a/requirements_all.txt b/requirements_all.txt index 17cf0bcbd8d..71921b7d41c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3128,7 +3128,7 @@ zeroconf==0.141.0 zeversolar==0.3.2 # homeassistant.components.zha -zha==0.0.45 +zha==0.0.46 # homeassistant.components.zhong_hong zhong-hong-hvac==1.0.13 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fda1b7c4630..5ba9dd11345 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2517,7 +2517,7 @@ zeroconf==0.141.0 zeversolar==0.3.2 # homeassistant.components.zha -zha==0.0.45 +zha==0.0.46 # homeassistant.components.zwave_js zwave-js-server-python==0.60.0 From 177bb29f6912e786351ad5669675f52bbfb21d52 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Wed, 29 Jan 2025 01:08:27 +0100 Subject: [PATCH 0175/3148] Explicitly pass in the config_entry in Feedreader coordinator init (#136777) --- .../components/feedreader/__init__.py | 16 +++------- .../components/feedreader/coordinator.py | 29 ++++++++++--------- 2 files changed, 20 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/feedreader/__init__.py b/homeassistant/components/feedreader/__init__.py index 9faed54c041..31617cb220b 100644 --- a/homeassistant/components/feedreader/__init__.py +++ b/homeassistant/components/feedreader/__init__.py @@ -2,15 +2,12 @@ from __future__ import annotations -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_URL, Platform +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.util.hass_dict import HassKey -from .const import CONF_MAX_ENTRIES, DOMAIN -from .coordinator import FeedReaderCoordinator, StoredData - -type FeedReaderConfigEntry = ConfigEntry[FeedReaderCoordinator] +from .const import DOMAIN +from .coordinator import FeedReaderConfigEntry, FeedReaderCoordinator, StoredData CONF_URLS = "urls" @@ -23,12 +20,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: FeedReaderConfigEntry) - if not storage.is_initialized: await storage.async_setup() - coordinator = FeedReaderCoordinator( - hass, - entry.data[CONF_URL], - entry.options[CONF_MAX_ENTRIES], - storage, - ) + coordinator = FeedReaderCoordinator(hass, entry, storage) await coordinator.async_setup() diff --git a/homeassistant/components/feedreader/coordinator.py b/homeassistant/components/feedreader/coordinator.py index fc338d63268..9901bd9f1b4 100644 --- a/homeassistant/components/feedreader/coordinator.py +++ b/homeassistant/components/feedreader/coordinator.py @@ -13,13 +13,14 @@ from urllib.error import URLError import feedparser from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_URL from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.storage import Store from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import dt as dt_util -from .const import DEFAULT_SCAN_INTERVAL, DOMAIN, EVENT_FEEDREADER +from .const import CONF_MAX_ENTRIES, DEFAULT_SCAN_INTERVAL, DOMAIN, EVENT_FEEDREADER DELAY_SAVE = 30 STORAGE_VERSION = 1 @@ -27,37 +28,39 @@ STORAGE_VERSION = 1 _LOGGER = getLogger(__name__) +type FeedReaderConfigEntry = ConfigEntry[FeedReaderCoordinator] + class FeedReaderCoordinator( DataUpdateCoordinator[list[feedparser.FeedParserDict] | None] ): """Abstraction over Feedparser module.""" - config_entry: ConfigEntry + config_entry: FeedReaderConfigEntry def __init__( self, hass: HomeAssistant, - url: str, - max_entries: int, + config_entry: FeedReaderConfigEntry, storage: StoredData, ) -> None: """Initialize the FeedManager object, poll as per scan interval.""" - super().__init__( - hass=hass, - logger=_LOGGER, - name=f"{DOMAIN} {url}", - update_interval=DEFAULT_SCAN_INTERVAL, - ) - self.url = url + self.url = config_entry.data[CONF_URL] self.feed_author: str | None = None self.feed_version: str | None = None - self._max_entries = max_entries + self._max_entries = config_entry.options[CONF_MAX_ENTRIES] self._storage = storage self._last_entry_timestamp: struct_time | None = None self._event_type = EVENT_FEEDREADER self._feed: feedparser.FeedParserDict | None = None - self._feed_id = url + self._feed_id = self.url + super().__init__( + hass=hass, + logger=_LOGGER, + config_entry=config_entry, + name=f"{DOMAIN} {self.url}", + update_interval=DEFAULT_SCAN_INTERVAL, + ) @callback def _log_no_entries(self) -> None: From 032e17720c84759f0d027bc4d08b7116f920cc0b Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Wed, 29 Jan 2025 01:11:54 +0100 Subject: [PATCH 0176/3148] Explicitly pass in the config_entry in PEGELONLINE coordinator init (#136773) --- homeassistant/components/pegel_online/__init__.py | 11 +++++------ .../components/pegel_online/coordinator.py | 14 ++++++++++++-- .../components/pegel_online/diagnostics.py | 2 +- homeassistant/components/pegel_online/sensor.py | 3 +-- 4 files changed, 19 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/pegel_online/__init__.py b/homeassistant/components/pegel_online/__init__.py index 30e5f4d2a38..1c71603e41e 100644 --- a/homeassistant/components/pegel_online/__init__.py +++ b/homeassistant/components/pegel_online/__init__.py @@ -7,21 +7,18 @@ import logging from aiopegelonline import PegelOnline from aiopegelonline.const import CONNECT_ERRORS -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import CONF_STATION -from .coordinator import PegelOnlineDataUpdateCoordinator +from .coordinator import PegelOnlineConfigEntry, PegelOnlineDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) PLATFORMS = [Platform.SENSOR] -type PegelOnlineConfigEntry = ConfigEntry[PegelOnlineDataUpdateCoordinator] - async def async_setup_entry(hass: HomeAssistant, entry: PegelOnlineConfigEntry) -> bool: """Set up PEGELONLINE entry.""" @@ -35,7 +32,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: PegelOnlineConfigEntry) except CONNECT_ERRORS as err: raise ConfigEntryNotReady("Failed to connect") from err - coordinator = PegelOnlineDataUpdateCoordinator(hass, entry.title, api, station) + coordinator = PegelOnlineDataUpdateCoordinator(hass, entry, api, station) await coordinator.async_config_entry_first_refresh() @@ -46,6 +43,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: PegelOnlineConfigEntry) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, entry: PegelOnlineConfigEntry +) -> bool: """Unload PEGELONLINE entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/pegel_online/coordinator.py b/homeassistant/components/pegel_online/coordinator.py index c8233673fde..1e2471a59f2 100644 --- a/homeassistant/components/pegel_online/coordinator.py +++ b/homeassistant/components/pegel_online/coordinator.py @@ -4,6 +4,7 @@ import logging from aiopegelonline import CONNECT_ERRORS, PegelOnline, Station, StationMeasurements +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -11,12 +12,20 @@ from .const import DOMAIN, MIN_TIME_BETWEEN_UPDATES _LOGGER = logging.getLogger(__name__) +type PegelOnlineConfigEntry = ConfigEntry[PegelOnlineDataUpdateCoordinator] + class PegelOnlineDataUpdateCoordinator(DataUpdateCoordinator[StationMeasurements]): """DataUpdateCoordinator for the pegel_online integration.""" + config_entry: PegelOnlineConfigEntry + def __init__( - self, hass: HomeAssistant, name: str, api: PegelOnline, station: Station + self, + hass: HomeAssistant, + config_entry: PegelOnlineConfigEntry, + api: PegelOnline, + station: Station, ) -> None: """Initialize the PegelOnlineDataUpdateCoordinator.""" self.api = api @@ -24,7 +33,8 @@ class PegelOnlineDataUpdateCoordinator(DataUpdateCoordinator[StationMeasurements super().__init__( hass, _LOGGER, - name=name, + config_entry=config_entry, + name=config_entry.title, update_interval=MIN_TIME_BETWEEN_UPDATES, ) diff --git a/homeassistant/components/pegel_online/diagnostics.py b/homeassistant/components/pegel_online/diagnostics.py index b68437c5ee7..e3b4a166cb4 100644 --- a/homeassistant/components/pegel_online/diagnostics.py +++ b/homeassistant/components/pegel_online/diagnostics.py @@ -6,7 +6,7 @@ from typing import Any from homeassistant.core import HomeAssistant -from . import PegelOnlineConfigEntry +from .coordinator import PegelOnlineConfigEntry async def async_get_config_entry_diagnostics( diff --git a/homeassistant/components/pegel_online/sensor.py b/homeassistant/components/pegel_online/sensor.py index 50eb80bafa8..181c0f5dc6d 100644 --- a/homeassistant/components/pegel_online/sensor.py +++ b/homeassistant/components/pegel_online/sensor.py @@ -16,8 +16,7 @@ from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import PegelOnlineConfigEntry -from .coordinator import PegelOnlineDataUpdateCoordinator +from .coordinator import PegelOnlineConfigEntry, PegelOnlineDataUpdateCoordinator from .entity import PegelOnlineEntity From f98dc160f3fdd26b86146f8889c1230247b20424 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Wed, 29 Jan 2025 01:13:49 +0100 Subject: [PATCH 0177/3148] Explicitly pass in the config_entry in AVM Fritz!SmartHome coordinator init (#136769) --- homeassistant/components/fritzbox/__init__.py | 2 +- homeassistant/components/fritzbox/coordinator.py | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/fritzbox/__init__.py b/homeassistant/components/fritzbox/__init__.py index 07bc8fb15f2..afe6f1abba8 100644 --- a/homeassistant/components/fritzbox/__init__.py +++ b/homeassistant/components/fritzbox/__init__.py @@ -37,7 +37,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: FritzboxConfigEntry) -> await async_migrate_entries(hass, entry.entry_id, _update_unique_id) - coordinator = FritzboxDataUpdateCoordinator(hass, entry.entry_id) + coordinator = FritzboxDataUpdateCoordinator(hass, entry) await coordinator.async_setup() entry.runtime_data = coordinator diff --git a/homeassistant/components/fritzbox/coordinator.py b/homeassistant/components/fritzbox/coordinator.py index a6a30ffdc6a..34df3885deb 100644 --- a/homeassistant/components/fritzbox/coordinator.py +++ b/homeassistant/components/fritzbox/coordinator.py @@ -38,12 +38,13 @@ class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorDat fritz: Fritzhome has_templates: bool - def __init__(self, hass: HomeAssistant, name: str) -> None: + def __init__(self, hass: HomeAssistant, config_entry: FritzboxConfigEntry) -> None: """Initialize the Fritzbox Smarthome device coordinator.""" super().__init__( hass, LOGGER, - name=name, + config_entry=config_entry, + name=config_entry.entry_id, update_interval=timedelta(seconds=30), ) From ba2d1e698d1e418b6cbd02c6d62d98166cbe0e7f Mon Sep 17 00:00:00 2001 From: IceBotYT <34712694+IceBotYT@users.noreply.github.com> Date: Tue, 28 Jan 2025 19:15:06 -0500 Subject: [PATCH 0178/3148] Bump peco to 0.1.2 (#136732) --- homeassistant/components/peco/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/peco/manifest.json b/homeassistant/components/peco/manifest.json index 698981e9361..7dc80c6f837 100644 --- a/homeassistant/components/peco/manifest.json +++ b/homeassistant/components/peco/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/peco", "iot_class": "cloud_polling", - "requirements": ["peco==0.0.30"] + "requirements": ["peco==0.1.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 71921b7d41c..8fcf58b734a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1628,7 +1628,7 @@ pdunehd==1.3.2 peblar==0.4.0 # homeassistant.components.peco -peco==0.0.30 +peco==0.1.2 # homeassistant.components.pencom pencompy==0.0.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5ba9dd11345..9bfa0db1304 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1355,7 +1355,7 @@ pdunehd==1.3.2 peblar==0.4.0 # homeassistant.components.peco -peco==0.0.30 +peco==0.1.2 # homeassistant.components.escea pescea==1.0.12 From 01b278c5472d48257c1162d988823241ac866dca Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Wed, 29 Jan 2025 01:18:21 +0100 Subject: [PATCH 0179/3148] Explicitly pass in the config_entry in Tankerkoenig coordinator init (#136780) --- homeassistant/components/tankerkoenig/__init__.py | 6 +----- homeassistant/components/tankerkoenig/coordinator.py | 7 ++++--- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/tankerkoenig/__init__.py b/homeassistant/components/tankerkoenig/__init__.py index a500549a648..b2b60db9675 100644 --- a/homeassistant/components/tankerkoenig/__init__.py +++ b/homeassistant/components/tankerkoenig/__init__.py @@ -17,11 +17,7 @@ async def async_setup_entry( """Set a tankerkoenig configuration entry up.""" hass.data.setdefault(DOMAIN, {}) - coordinator = TankerkoenigDataUpdateCoordinator( - hass, - name=entry.unique_id or DOMAIN, - update_interval=DEFAULT_SCAN_INTERVAL, - ) + coordinator = TankerkoenigDataUpdateCoordinator(hass, entry, DEFAULT_SCAN_INTERVAL) await coordinator.async_setup() await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/tankerkoenig/coordinator.py b/homeassistant/components/tankerkoenig/coordinator.py index 17e94f62fe9..1f73d0577b3 100644 --- a/homeassistant/components/tankerkoenig/coordinator.py +++ b/homeassistant/components/tankerkoenig/coordinator.py @@ -24,7 +24,7 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import CONF_FUEL_TYPES, CONF_STATIONS +from .const import CONF_FUEL_TYPES, CONF_STATIONS, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -39,7 +39,7 @@ class TankerkoenigDataUpdateCoordinator(DataUpdateCoordinator[dict[str, PriceInf def __init__( self, hass: HomeAssistant, - name: str, + config_entry: TankerkoenigConfigEntry, update_interval: int, ) -> None: """Initialize the data object.""" @@ -47,7 +47,8 @@ class TankerkoenigDataUpdateCoordinator(DataUpdateCoordinator[dict[str, PriceInf super().__init__( hass=hass, logger=_LOGGER, - name=name, + config_entry=config_entry, + name=config_entry.unique_id or DOMAIN, update_interval=timedelta(minutes=update_interval), ) From e07e8b87069a46554d1ed5a343b4818d03dc7284 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Wed, 29 Jan 2025 01:19:16 +0100 Subject: [PATCH 0180/3148] Explicitly pass in the config_entry in Proximity coordinator init (#136775) --- homeassistant/components/proximity/__init__.py | 9 +++++---- .../components/proximity/coordinator.py | 18 ++++++++---------- 2 files changed, 13 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/proximity/__init__.py b/homeassistant/components/proximity/__init__.py index 763274243c5..2338464558d 100644 --- a/homeassistant/components/proximity/__init__.py +++ b/homeassistant/components/proximity/__init__.py @@ -4,7 +4,6 @@ from __future__ import annotations import logging -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.event import ( @@ -22,7 +21,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ProximityConfigEntry) -> """Set up Proximity from a config entry.""" _LOGGER.debug("setup %s with config:%s", entry.title, entry.data) - coordinator = ProximityDataUpdateCoordinator(hass, entry.title, dict(entry.data)) + coordinator = ProximityDataUpdateCoordinator(hass, entry) entry.async_on_unload( async_track_state_change_event( @@ -48,11 +47,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ProximityConfigEntry) -> return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: ProximityConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, [Platform.SENSOR]) -async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def _async_update_listener( + hass: HomeAssistant, entry: ProximityConfigEntry +) -> None: """Handle options update.""" await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/proximity/coordinator.py b/homeassistant/components/proximity/coordinator.py index a8dd85c1523..055c15125f1 100644 --- a/homeassistant/components/proximity/coordinator.py +++ b/homeassistant/components/proximity/coordinator.py @@ -23,7 +23,6 @@ from homeassistant.core import ( ) from homeassistant.helpers import entity_registry as er from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.util.location import distance @@ -75,16 +74,14 @@ class ProximityDataUpdateCoordinator(DataUpdateCoordinator[ProximityData]): config_entry: ProximityConfigEntry - def __init__( - self, hass: HomeAssistant, friendly_name: str, config: ConfigType - ) -> None: + def __init__(self, hass: HomeAssistant, config_entry: ProximityConfigEntry) -> None: """Initialize the Proximity coordinator.""" - self.ignored_zone_ids: list[str] = config[CONF_IGNORED_ZONES] - self.tracked_entities: list[str] = config[CONF_TRACKED_ENTITIES] - self.tolerance: int = config[CONF_TOLERANCE] - self.proximity_zone_id: str = config[CONF_ZONE] + self.ignored_zone_ids: list[str] = config_entry.data[CONF_IGNORED_ZONES] + self.tracked_entities: list[str] = config_entry.data[CONF_TRACKED_ENTITIES] + self.tolerance: int = config_entry.data[CONF_TOLERANCE] + self.proximity_zone_id: str = config_entry.data[CONF_ZONE] self.proximity_zone_name: str = self.proximity_zone_id.split(".")[-1] - self.unit_of_measurement: str = config.get( + self.unit_of_measurement: str = config_entry.data.get( CONF_UNIT_OF_MEASUREMENT, hass.config.units.length_unit ) self.entity_mapping: dict[str, list[str]] = defaultdict(list) @@ -92,7 +89,8 @@ class ProximityDataUpdateCoordinator(DataUpdateCoordinator[ProximityData]): super().__init__( hass, _LOGGER, - name=friendly_name, + config_entry=config_entry, + name=config_entry.title, update_interval=None, ) From c2cbbf1e1cc85064217d6f71304d5e2fe6d7107d Mon Sep 17 00:00:00 2001 From: "Teemu R." Date: Wed, 29 Jan 2025 01:23:29 +0100 Subject: [PATCH 0181/3148] Add more vacuum features for tplink (#136580) --- homeassistant/components/tplink/icons.json | 9 +++ homeassistant/components/tplink/number.py | 4 + homeassistant/components/tplink/sensor.py | 16 +++- homeassistant/components/tplink/strings.json | 23 ++++++ homeassistant/components/tplink/switch.py | 3 + tests/components/tplink/__init__.py | 5 ++ .../components/tplink/fixtures/features.json | 16 ++++ .../tplink/snapshots/test_number.ambr | 55 ++++++++++++++ .../tplink/snapshots/test_sensor.ambr | 76 +++++++++++++++++++ .../tplink/snapshots/test_switch.ambr | 46 +++++++++++ 10 files changed, 252 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/tplink/icons.json b/homeassistant/components/tplink/icons.json index e00e8f69467..15e9406b2c9 100644 --- a/homeassistant/components/tplink/icons.json +++ b/homeassistant/components/tplink/icons.json @@ -113,6 +113,9 @@ "state": { "on": "mdi:baby-face" } + }, + "carpet_boost": { + "default": "mdi:rug" } }, "sensor": { @@ -130,6 +133,9 @@ }, "water_alert_timestamp": { "default": "mdi:clock-alert-outline" + }, + "vacuum_error": { + "default": "mdi:alert-circle" } }, "number": { @@ -150,6 +156,9 @@ }, "tilt_step": { "default": "mdi:unfold-more-horizontal" + }, + "clean_count": { + "default": "mdi:counter" } } }, diff --git a/homeassistant/components/tplink/number.py b/homeassistant/components/tplink/number.py index 0af2b7403e8..b47c50d688f 100644 --- a/homeassistant/components/tplink/number.py +++ b/homeassistant/components/tplink/number.py @@ -65,6 +65,10 @@ NUMBER_DESCRIPTIONS: Final = ( key="tilt_step", mode=NumberMode.BOX, ), + TPLinkNumberEntityDescription( + key="clean_count", + mode=NumberMode.SLIDER, + ), ) NUMBER_DESCRIPTIONS_MAP = {desc.key: desc for desc in NUMBER_DESCRIPTIONS} diff --git a/homeassistant/components/tplink/sensor.py b/homeassistant/components/tplink/sensor.py index aaba6b2674d..0f5dbc0a2e3 100644 --- a/homeassistant/components/tplink/sensor.py +++ b/homeassistant/components/tplink/sensor.py @@ -2,10 +2,12 @@ from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass -from typing import TYPE_CHECKING, cast +from typing import TYPE_CHECKING, Any, cast from kasa import Feature +from kasa.smart.modules.clean import ErrorCode as VacuumError from homeassistant.components.sensor import ( DOMAIN as SENSOR_DOMAIN, @@ -28,6 +30,9 @@ class TPLinkSensorEntityDescription( ): """Base class for a TPLink feature based sensor entity description.""" + #: Optional callable to convert the value + convert_fn: Callable[[Any], Any] | None = None + # Coordinator is used to centralize the data updates PARALLEL_UPDATES = 0 @@ -115,6 +120,12 @@ SENSOR_DESCRIPTIONS: tuple[TPLinkSensorEntityDescription, ...] = ( TPLinkSensorEntityDescription( key="alarm_source", ), + TPLinkSensorEntityDescription( + key="vacuum_error", + device_class=SensorDeviceClass.ENUM, + options=[name.lower() for name in VacuumError._member_names_], + convert_fn=lambda x: x.name.lower(), + ), ) SENSOR_DESCRIPTIONS_MAP = {desc.key: desc for desc in SENSOR_DESCRIPTIONS} @@ -165,6 +176,9 @@ class TPLinkSensorEntity(CoordinatedTPLinkFeatureEntity, SensorEntity): # We probably do not need this, when we are rounding already? self._attr_suggested_display_precision = self._feature.precision_hint + if self.entity_description.convert_fn: + value = self.entity_description.convert_fn(value) + if TYPE_CHECKING: # pylint: disable-next=import-outside-toplevel from datetime import date, datetime diff --git a/homeassistant/components/tplink/strings.json b/homeassistant/components/tplink/strings.json index 304bf353b7c..034aff7a763 100644 --- a/homeassistant/components/tplink/strings.json +++ b/homeassistant/components/tplink/strings.json @@ -198,6 +198,23 @@ }, "alarm_source": { "name": "Alarm source" + }, + "vacuum_error": { + "name": "Error", + "state": { + "ok": "No error", + "sidebrushstuck": "Side brush stuck", + "mainbrushstuck": "Main brush stuck", + "wheelblocked": "Wheel blocked", + "trapped": "Unable to move", + "trappedcliff": "Unable to move (cliff sensor)", + "dustbinremoved": "Missing dust bin", + "unabletomove": "Unable to move", + "lidarblocked": "Lidar blocked", + "unabletofinddock": "Unable to find dock", + "batterylow": "Low on battery", + "unknowninternal": "Unknown error, report to upstream" + } } }, "switch": { @@ -233,6 +250,9 @@ }, "baby_cry_detection": { "name": "Baby cry detection" + }, + "carpet_boost": { + "name": "Carpet boost" } }, "number": { @@ -253,6 +273,9 @@ }, "tilt_step": { "name": "Tilt degrees" + }, + "clean_count": { + "name": "Clean count" } } }, diff --git a/homeassistant/components/tplink/switch.py b/homeassistant/components/tplink/switch.py index 04ca95273af..f08753def26 100644 --- a/homeassistant/components/tplink/switch.py +++ b/homeassistant/components/tplink/switch.py @@ -74,6 +74,9 @@ SWITCH_DESCRIPTIONS: tuple[TPLinkSwitchEntityDescription, ...] = ( TPLinkSwitchEntityDescription( key="baby_cry_detection", ), + TPLinkSwitchEntityDescription( + key="carpet_boost", + ), ) SWITCH_DESCRIPTIONS_MAP = {desc.key: desc for desc in SWITCH_DESCRIPTIONS} diff --git a/tests/components/tplink/__init__.py b/tests/components/tplink/__init__.py index 664fb96fe71..028215dc157 100644 --- a/tests/components/tplink/__init__.py +++ b/tests/components/tplink/__init__.py @@ -60,6 +60,7 @@ def _load_feature_fixtures(): FEATURES_FIXTURE = _load_feature_fixtures() +FIXTURE_ENUM_TYPES = {"CleanErrorCode": ErrorCode} async def setup_platform_for_device( @@ -275,6 +276,10 @@ def _mocked_feature( if fixture := FEATURES_FIXTURE.get(id): # copy the fixture so tests do not interfere with each other fixture = dict(fixture) + if enum_type := fixture.get("enum_type"): + val = FIXTURE_ENUM_TYPES[enum_type](fixture["value"]) + fixture["value"] = val + else: assert require_fixture is False, ( f"No fixture defined for feature {id} and require_fixture is True" diff --git a/tests/components/tplink/fixtures/features.json b/tests/components/tplink/fixtures/features.json index adb6c08ee50..d366a91c33c 100644 --- a/tests/components/tplink/fixtures/features.json +++ b/tests/components/tplink/fixtures/features.json @@ -371,6 +371,22 @@ "type": "Number", "category": "Config" }, + "clean_count": { + "value": 1, + "type": "Number", + "category": "Config" + }, + "carpet_boost": { + "value": true, + "type": "Switch", + "category": "Config" + }, + "vacuum_error": { + "value": 0, + "type": "Sensor", + "category": "Info", + "enum_type": "CleanErrorCode" + }, "pair": { "value": "", "type": "Action", diff --git a/tests/components/tplink/snapshots/test_number.ambr b/tests/components/tplink/snapshots/test_number.ambr index df5ef71bf44..6733c5423a0 100644 --- a/tests/components/tplink/snapshots/test_number.ambr +++ b/tests/components/tplink/snapshots/test_number.ambr @@ -35,6 +35,61 @@ 'via_device_id': None, }) # --- +# name: test_states[number.my_device_clean_count-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 65536, + 'min': 0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.my_device_clean_count', + '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': 'Clean count', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'clean_count', + 'unique_id': '123456789ABCDEFGH_clean_count', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[number.my_device_clean_count-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'my_device Clean count', + 'max': 65536, + 'min': 0, + 'mode': , + 'step': 1.0, + }), + 'context': , + 'entity_id': 'number.my_device_clean_count', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1', + }) +# --- # name: test_states[number.my_device_pan_degrees-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tplink/snapshots/test_sensor.ambr b/tests/components/tplink/snapshots/test_sensor.ambr index 461e8c6e505..e223a72dbc0 100644 --- a/tests/components/tplink/snapshots/test_sensor.ambr +++ b/tests/components/tplink/snapshots/test_sensor.ambr @@ -307,6 +307,82 @@ 'unit_of_measurement': None, }) # --- +# name: test_states[sensor.my_device_error-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'ok', + 'sidebrushstuck', + 'mainbrushstuck', + 'wheelblocked', + 'trapped', + 'trappedcliff', + 'dustbinremoved', + 'unabletomove', + 'lidarblocked', + 'unabletofinddock', + 'batterylow', + 'unknowninternal', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.my_device_error', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Error', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'vacuum_error', + 'unique_id': '123456789ABCDEFGH_vacuum_error', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[sensor.my_device_error-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'my_device Error', + 'options': list([ + 'ok', + 'sidebrushstuck', + 'mainbrushstuck', + 'wheelblocked', + 'trapped', + 'trappedcliff', + 'dustbinremoved', + 'unabletomove', + 'lidarblocked', + 'unabletofinddock', + 'batterylow', + 'unknowninternal', + ]), + }), + 'context': , + 'entity_id': 'sensor.my_device_error', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'ok', + }) +# --- # name: test_states[sensor.my_device_humidity-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tplink/snapshots/test_switch.ambr b/tests/components/tplink/snapshots/test_switch.ambr index 7adda900c02..f22f8d0cd36 100644 --- a/tests/components/tplink/snapshots/test_switch.ambr +++ b/tests/components/tplink/snapshots/test_switch.ambr @@ -219,6 +219,52 @@ 'state': 'on', }) # --- +# name: test_states[switch.my_device_carpet_boost-entry] + 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.my_device_carpet_boost', + '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': 'Carpet boost', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'carpet_boost', + 'unique_id': '123456789ABCDEFGH_carpet_boost', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[switch.my_device_carpet_boost-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'my_device Carpet boost', + }), + 'context': , + 'entity_id': 'switch.my_device_carpet_boost', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_states[switch.my_device_child_lock-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 3638eb1d34b0c66a98063a502e59bf80e8f15ee4 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Wed, 29 Jan 2025 01:25:34 +0100 Subject: [PATCH 0182/3148] Explicitly pass in the config_entry in Synology DSM coordinator init (#136772) --- homeassistant/components/synology_dsm/coordinator.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/synology_dsm/coordinator.py b/homeassistant/components/synology_dsm/coordinator.py index 357de10b5b8..30d1260ef32 100644 --- a/homeassistant/components/synology_dsm/coordinator.py +++ b/homeassistant/components/synology_dsm/coordinator.py @@ -59,6 +59,8 @@ def async_re_login_on_expired[_T: SynologyDSMUpdateCoordinator[Any], **_P, _R]( class SynologyDSMUpdateCoordinator[_DataT](DataUpdateCoordinator[_DataT]): """DataUpdateCoordinator base class for synology_dsm.""" + config_entry: ConfigEntry + def __init__( self, hass: HomeAssistant, @@ -68,10 +70,10 @@ class SynologyDSMUpdateCoordinator[_DataT](DataUpdateCoordinator[_DataT]): ) -> None: """Initialize synology_dsm DataUpdateCoordinator.""" self.api = api - self.entry = entry super().__init__( hass, _LOGGER, + config_entry=entry, name=f"{entry.title} {self.__class__.__name__}", update_interval=update_interval, ) @@ -174,7 +176,7 @@ class SynologyDSMCameraUpdateCoordinator( ): async_dispatcher_send( self.hass, - f"{SIGNAL_CAMERA_SOURCE_CHANGED}_{self.entry.entry_id}_{cam_id}", + f"{SIGNAL_CAMERA_SOURCE_CHANGED}_{self.config_entry.entry_id}_{cam_id}", cam_data_new.live_view.rtsp, ) From 7256575c09a8f85a1f3cf3ef8cd813186fae8ce1 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Wed, 29 Jan 2025 01:26:20 +0100 Subject: [PATCH 0183/3148] Explicitly pass in the config_entry in Nextcloud coordinator init (#136774) --- homeassistant/components/nextcloud/__init__.py | 5 +---- homeassistant/components/nextcloud/binary_sensor.py | 2 +- homeassistant/components/nextcloud/coordinator.py | 7 ++++++- homeassistant/components/nextcloud/entity.py | 3 +-- homeassistant/components/nextcloud/sensor.py | 2 +- homeassistant/components/nextcloud/update.py | 2 +- 6 files changed, 11 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/nextcloud/__init__.py b/homeassistant/components/nextcloud/__init__.py index a487a3f1414..3edff53919d 100644 --- a/homeassistant/components/nextcloud/__init__.py +++ b/homeassistant/components/nextcloud/__init__.py @@ -9,7 +9,6 @@ from nextcloudmonitor import ( NextcloudMonitorRequestError, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_PASSWORD, CONF_URL, @@ -21,15 +20,13 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import entity_registry as er -from .coordinator import NextcloudDataUpdateCoordinator +from .coordinator import NextcloudConfigEntry, NextcloudDataUpdateCoordinator PLATFORMS = (Platform.SENSOR, Platform.BINARY_SENSOR, Platform.UPDATE) _LOGGER = logging.getLogger(__name__) -type NextcloudConfigEntry = ConfigEntry[NextcloudDataUpdateCoordinator] - async def async_setup_entry(hass: HomeAssistant, entry: NextcloudConfigEntry) -> bool: """Set up the Nextcloud integration.""" diff --git a/homeassistant/components/nextcloud/binary_sensor.py b/homeassistant/components/nextcloud/binary_sensor.py index c9d19efbd45..10e1a000a68 100644 --- a/homeassistant/components/nextcloud/binary_sensor.py +++ b/homeassistant/components/nextcloud/binary_sensor.py @@ -12,7 +12,7 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import NextcloudConfigEntry +from .coordinator import NextcloudConfigEntry from .entity import NextcloudEntity BINARY_SENSORS: Final[list[BinarySensorEntityDescription]] = [ diff --git a/homeassistant/components/nextcloud/coordinator.py b/homeassistant/components/nextcloud/coordinator.py index b5dc5e29507..d6bccec07bb 100644 --- a/homeassistant/components/nextcloud/coordinator.py +++ b/homeassistant/components/nextcloud/coordinator.py @@ -14,12 +14,16 @@ from .const import DEFAULT_SCAN_INTERVAL _LOGGER = logging.getLogger(__name__) +type NextcloudConfigEntry = ConfigEntry[NextcloudDataUpdateCoordinator] + class NextcloudDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Nextcloud data update coordinator.""" + config_entry: NextcloudConfigEntry + def __init__( - self, hass: HomeAssistant, ncm: NextcloudMonitor, entry: ConfigEntry + self, hass: HomeAssistant, ncm: NextcloudMonitor, entry: NextcloudConfigEntry ) -> None: """Initialize the Nextcloud coordinator.""" self.ncm = ncm @@ -28,6 +32,7 @@ class NextcloudDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): super().__init__( hass, _LOGGER, + config_entry=entry, name=self.url, update_interval=DEFAULT_SCAN_INTERVAL, ) diff --git a/homeassistant/components/nextcloud/entity.py b/homeassistant/components/nextcloud/entity.py index 6632b2674eb..f2ebba7fdb2 100644 --- a/homeassistant/components/nextcloud/entity.py +++ b/homeassistant/components/nextcloud/entity.py @@ -6,9 +6,8 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import NextcloudConfigEntry from .const import DOMAIN -from .coordinator import NextcloudDataUpdateCoordinator +from .coordinator import NextcloudConfigEntry, NextcloudDataUpdateCoordinator class NextcloudEntity(CoordinatorEntity[NextcloudDataUpdateCoordinator]): diff --git a/homeassistant/components/nextcloud/sensor.py b/homeassistant/components/nextcloud/sensor.py index 19ac7bb0df7..a6722821012 100644 --- a/homeassistant/components/nextcloud/sensor.py +++ b/homeassistant/components/nextcloud/sensor.py @@ -23,7 +23,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.dt import utc_from_timestamp -from . import NextcloudConfigEntry +from .coordinator import NextcloudConfigEntry from .entity import NextcloudEntity UNIT_OF_LOAD: Final[str] = "load" diff --git a/homeassistant/components/nextcloud/update.py b/homeassistant/components/nextcloud/update.py index 5b9de52ad1d..aad6412b7b3 100644 --- a/homeassistant/components/nextcloud/update.py +++ b/homeassistant/components/nextcloud/update.py @@ -6,7 +6,7 @@ from homeassistant.components.update import UpdateEntity, UpdateEntityDescriptio from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import NextcloudConfigEntry +from .coordinator import NextcloudConfigEntry from .entity import NextcloudEntity From 64cda8cdb8a08fa3ab1f767cc8dcdc48d7201152 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Tue, 28 Jan 2025 18:32:08 -0600 Subject: [PATCH 0184/3148] Add VoIP announce (#136781) * Implement async_announce for VoIP * Add tests * Add network to voip dependencies --- .../components/voip/assist_satellite.py | 140 ++++++++++++++++-- homeassistant/components/voip/manifest.json | 2 +- tests/components/voip/test_voip.py | 99 ++++++++++++- 3 files changed, 227 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/voip/assist_satellite.py b/homeassistant/components/voip/assist_satellite.py index 0100435d6dc..738c3a1e235 100644 --- a/homeassistant/components/voip/assist_satellite.py +++ b/homeassistant/components/voip/assist_satellite.py @@ -8,23 +8,29 @@ from functools import partial import io import logging from pathlib import Path +import socket +import time from typing import TYPE_CHECKING, Any, Final import wave -from voip_utils import RtpDatagramProtocol +from voip_utils import SIP_PORT, RtpDatagramProtocol +from voip_utils.sip import SipEndpoint, get_sip_endpoint from homeassistant.components import tts from homeassistant.components.assist_pipeline import PipelineEvent, PipelineEventType from homeassistant.components.assist_satellite import ( + AssistSatelliteAnnouncement, AssistSatelliteConfiguration, AssistSatelliteEntity, AssistSatelliteEntityDescription, + AssistSatelliteEntityFeature, ) +from homeassistant.components.network import async_get_source_ip from homeassistant.config_entries import ConfigEntry from homeassistant.core import Context, HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import CHANNELS, DOMAIN, RATE, RTP_AUDIO_SETTINGS, WIDTH +from .const import CHANNELS, CONF_SIP_PORT, DOMAIN, RATE, RTP_AUDIO_SETTINGS, WIDTH from .devices import VoIPDevice from .entity import VoIPEntity @@ -34,6 +40,9 @@ if TYPE_CHECKING: _LOGGER = logging.getLogger(__name__) _PIPELINE_TIMEOUT_SEC: Final = 30 +_ANNOUNCEMENT_BEFORE_DELAY: Final = 0.5 +_ANNOUNCEMENT_AFTER_DELAY: Final = 1.0 +_ANNOUNCEMENT_HANGUP_SEC: Final = 0.5 class Tones(IntFlag): @@ -80,6 +89,7 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol entity_description = AssistSatelliteEntityDescription(key="assist_satellite") _attr_translation_key = "assist_satellite" _attr_name = None + _attr_supported_features = AssistSatelliteEntityFeature.ANNOUNCE def __init__( self, @@ -105,6 +115,12 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol self._tones = tones self._processing_tone_done = asyncio.Event() + self._announcement: AssistSatelliteAnnouncement | None = None + self._announcement_done = asyncio.Event() + self._check_announcement_ended_task: asyncio.Task | None = None + self._last_chunk_time: float | None = None + self._rtp_port: int | None = None + @property def pipeline_entity_id(self) -> str | None: """Return the entity ID of the pipeline to use for the next conversation.""" @@ -149,25 +165,108 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol """Set the current satellite configuration.""" raise NotImplementedError + async def async_announce(self, announcement: AssistSatelliteAnnouncement) -> None: + """Announce media on the satellite. + + Plays announcement in a loop, blocking until the caller hangs up. + """ + self._announcement_done.clear() + + if self._rtp_port is None: + # Choose random port for RTP + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.setblocking(False) + sock.bind(("", 0)) + _rtp_ip, self._rtp_port = sock.getsockname() + sock.close() + + # HA SIP server + source_ip = await async_get_source_ip(self.hass) + sip_port = self.config_entry.options.get(CONF_SIP_PORT, SIP_PORT) + source_endpoint = get_sip_endpoint(host=source_ip, port=sip_port) + + try: + # VoIP ID is SIP header + destination_endpoint = SipEndpoint(self.voip_device.voip_id) + except ValueError: + # VoIP ID is IP address + destination_endpoint = get_sip_endpoint( + host=self.voip_device.voip_id, port=SIP_PORT + ) + + self._announcement = announcement + + # Make the call + self.hass.data[DOMAIN].protocol.outgoing_call( + source=source_endpoint, + destination=destination_endpoint, + rtp_port=self._rtp_port, + ) + + await self._announcement_done.wait() + + async def _check_announcement_ended(self) -> None: + """Continuously checks if an audio chunk was received within a time limit. + + If not, the caller is presumed to have hung up and the announcement is ended. + """ + while self._announcement is not None: + if (self._last_chunk_time is not None) and ( + (time.monotonic() - self._last_chunk_time) > _ANNOUNCEMENT_HANGUP_SEC + ): + # Caller hung up + self._announcement = None + self._announcement_done.set() + self._check_announcement_ended_task = None + _LOGGER.debug("Announcement ended") + break + + await asyncio.sleep(_ANNOUNCEMENT_HANGUP_SEC / 2) + # ------------------------------------------------------------------------- # VoIP # ------------------------------------------------------------------------- def on_chunk(self, audio_bytes: bytes) -> None: """Handle raw audio chunk.""" - if self._run_pipeline_task is None: - # Run pipeline until voice command finishes, then start over - self._clear_audio_queue() - self._tts_done.clear() + self._last_chunk_time = time.monotonic() + + if self._announcement is None: + # Pipeline with STT + if self._run_pipeline_task is None: + # Run pipeline until voice command finishes, then start over + self._clear_audio_queue() + self._tts_done.clear() + self._run_pipeline_task = ( + self.config_entry.async_create_background_task( + self.hass, + self._run_pipeline(), + "voip_pipeline_run", + ) + ) + + self._audio_queue.put_nowait(audio_bytes) + elif self._run_pipeline_task is None: + # Announcement only + if self._check_announcement_ended_task is None: + # Check if caller hung up + self._check_announcement_ended_task = ( + self.config_entry.async_create_background_task( + self.hass, + self._check_announcement_ended(), + "voip_announcement_ended", + ) + ) + + # Play announcement (will repeat) self._run_pipeline_task = self.config_entry.async_create_background_task( self.hass, - self._run_pipeline(), - "voip_pipeline_run", + self._play_announcement(self._announcement), + "voip_play_announcement", ) - self._audio_queue.put_nowait(audio_bytes) - async def _run_pipeline(self) -> None: + """Run a pipeline with STT input and TTS output.""" _LOGGER.debug("Starting pipeline") self.async_set_context(Context(user_id=self.config_entry.data["user"])) @@ -209,6 +308,23 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol self._run_pipeline_task = None _LOGGER.debug("Pipeline finished") + async def _play_announcement( + self, announcement: AssistSatelliteAnnouncement + ) -> None: + """Play an announcement once.""" + _LOGGER.debug("Playing announcement") + + try: + await asyncio.sleep(_ANNOUNCEMENT_BEFORE_DELAY) + await self._send_tts(announcement.original_media_id, wait_for_tone=False) + await asyncio.sleep(_ANNOUNCEMENT_AFTER_DELAY) + except Exception: + _LOGGER.exception("Unexpected error while playing announcement") + raise + finally: + self._run_pipeline_task = None + _LOGGER.debug("Announcement finished") + def _clear_audio_queue(self) -> None: """Ensure audio queue is empty.""" while not self._audio_queue.empty(): @@ -239,7 +355,7 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol self._pipeline_had_error = True _LOGGER.warning(event) - async def _send_tts(self, media_id: str) -> None: + async def _send_tts(self, media_id: str, wait_for_tone: bool = True) -> None: """Send TTS audio to caller via RTP.""" try: if self.transport is None: @@ -253,7 +369,7 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol if extension != "wav": raise ValueError(f"Only WAV audio can be streamed, got {extension}") - if (self._tones & Tones.PROCESSING) == Tones.PROCESSING: + if wait_for_tone and ((self._tones & Tones.PROCESSING) == Tones.PROCESSING): # Don't overlap TTS and processing beep _LOGGER.debug("Waiting for processing tone") await self._processing_tone_done.wait() diff --git a/homeassistant/components/voip/manifest.json b/homeassistant/components/voip/manifest.json index e96039a6b45..b279665a03a 100644 --- a/homeassistant/components/voip/manifest.json +++ b/homeassistant/components/voip/manifest.json @@ -3,7 +3,7 @@ "name": "Voice over IP", "codeowners": ["@balloob", "@synesthesiam"], "config_flow": true, - "dependencies": ["assist_pipeline", "assist_satellite"], + "dependencies": ["assist_pipeline", "assist_satellite", "network"], "documentation": "https://www.home-assistant.io/integrations/voip", "iot_class": "local_push", "quality_scale": "internal", diff --git a/tests/components/voip/test_voip.py b/tests/components/voip/test_voip.py index 17af2748c1c..ac7c295c934 100644 --- a/tests/components/voip/test_voip.py +++ b/tests/components/voip/test_voip.py @@ -16,7 +16,7 @@ from homeassistant.components.assist_satellite import AssistSatelliteEntity # pylint: disable-next=hass-component-root-import from homeassistant.components.assist_satellite.entity import AssistSatelliteState -from homeassistant.components.voip import HassVoipDatagramProtocol +from homeassistant.components.voip import DOMAIN, HassVoipDatagramProtocol from homeassistant.components.voip.assist_satellite import Tones, VoipAssistSatellite from homeassistant.components.voip.devices import VoIPDevice, VoIPDevices from homeassistant.components.voip.voip import PreRecordMessageProtocol, make_protocol @@ -844,3 +844,100 @@ async def test_pipeline_error( assert sum(played_audio_bytes) > 0 assert played_audio_bytes == snapshot() + + +@pytest.mark.usefixtures("socket_enabled") +async def test_announce( + hass: HomeAssistant, + voip_devices: VoIPDevices, + voip_device: VoIPDevice, +) -> None: + """Test announcement.""" + assert await async_setup_component(hass, "voip", {}) + + satellite = async_get_satellite_entity(hass, voip.DOMAIN, voip_device.voip_id) + assert isinstance(satellite, VoipAssistSatellite) + assert ( + satellite.supported_features + & assist_satellite.AssistSatelliteEntityFeature.ANNOUNCE + ) + + announcement = assist_satellite.AssistSatelliteAnnouncement( + message="test announcement", + media_id=_MEDIA_ID, + original_media_id=_MEDIA_ID, + media_id_source="tts", + ) + + # Protocol has already been mocked, but "outgoing_call" is not async + mock_protocol: AsyncMock = hass.data[DOMAIN].protocol + mock_protocol.outgoing_call = Mock() + + with ( + patch( + "homeassistant.components.voip.assist_satellite.VoipAssistSatellite._send_tts", + ) as mock_send_tts, + ): + satellite.transport = Mock() + announce_task = hass.async_create_background_task( + satellite.async_announce(announcement), "voip_announce" + ) + await asyncio.sleep(0) + mock_protocol.outgoing_call.assert_called_once() + + # Trigger announcement + satellite.on_chunk(bytes(_ONE_SECOND)) + await announce_task + + mock_send_tts.assert_called_once_with(_MEDIA_ID, wait_for_tone=False) + + +@pytest.mark.usefixtures("socket_enabled") +async def test_voip_id_is_ip_address( + hass: HomeAssistant, + voip_devices: VoIPDevices, + voip_device: VoIPDevice, +) -> None: + """Test announcement when VoIP is an IP address instead of a SIP header.""" + assert await async_setup_component(hass, "voip", {}) + + satellite = async_get_satellite_entity(hass, voip.DOMAIN, voip_device.voip_id) + assert isinstance(satellite, VoipAssistSatellite) + assert ( + satellite.supported_features + & assist_satellite.AssistSatelliteEntityFeature.ANNOUNCE + ) + + announcement = assist_satellite.AssistSatelliteAnnouncement( + message="test announcement", + media_id=_MEDIA_ID, + original_media_id=_MEDIA_ID, + media_id_source="tts", + ) + + # Protocol has already been mocked, but "outgoing_call" is not async + mock_protocol: AsyncMock = hass.data[DOMAIN].protocol + mock_protocol.outgoing_call = Mock() + + with ( + patch.object(voip_device, "voip_id", "192.168.68.10"), + patch( + "homeassistant.components.voip.assist_satellite.VoipAssistSatellite._send_tts", + ) as mock_send_tts, + ): + satellite.transport = Mock() + announce_task = hass.async_create_background_task( + satellite.async_announce(announcement), "voip_announce" + ) + await asyncio.sleep(0) + mock_protocol.outgoing_call.assert_called_once() + assert ( + mock_protocol.outgoing_call.call_args.kwargs["destination"].host + == "192.168.68.10" + ) + + # Trigger announcement + satellite.on_chunk(bytes(_ONE_SECOND)) + await announce_task + + mock_send_tts.assert_called_once_with(_MEDIA_ID, wait_for_tone=False) From 9f586ea547ffad5ce222650b3b895df2cca22e32 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 28 Jan 2025 15:10:33 -1000 Subject: [PATCH 0185/3148] Bump habluetooth to 3.14.0 (#136791) --- homeassistant/components/bluetooth/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/bluetooth/test_diagnostics.py | 3 +++ 5 files changed, 7 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index b172a6c6aef..1fcd507da83 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -21,6 +21,6 @@ "bluetooth-auto-recovery==1.4.2", "bluetooth-data-tools==1.22.0", "dbus-fast==2.30.2", - "habluetooth==3.13.0" + "habluetooth==3.14.0" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 51393c2a516..8643d53ff68 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -33,7 +33,7 @@ dbus-fast==2.30.2 fnv-hash-fast==1.2.2 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 -habluetooth==3.13.0 +habluetooth==3.14.0 hass-nabucasa==0.88.1 hassil==2.2.0 home-assistant-bluetooth==1.13.0 diff --git a/requirements_all.txt b/requirements_all.txt index 8fcf58b734a..c67a83de01a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1100,7 +1100,7 @@ ha-philipsjs==3.2.2 habiticalib==0.3.3 # homeassistant.components.bluetooth -habluetooth==3.13.0 +habluetooth==3.14.0 # homeassistant.components.cloud hass-nabucasa==0.88.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9bfa0db1304..78b4ed27566 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -941,7 +941,7 @@ ha-philipsjs==3.2.2 habiticalib==0.3.3 # homeassistant.components.bluetooth -habluetooth==3.13.0 +habluetooth==3.14.0 # homeassistant.components.cloud hass-nabucasa==0.88.1 diff --git a/tests/components/bluetooth/test_diagnostics.py b/tests/components/bluetooth/test_diagnostics.py index be4412db4d8..384eae7e49a 100644 --- a/tests/components/bluetooth/test_diagnostics.py +++ b/tests/components/bluetooth/test_diagnostics.py @@ -133,6 +133,7 @@ async def test_diagnostics( } }, "manager": { + "allocations": {}, "adapters": { "hci0": { "address": "00:00:00:00:00:01", @@ -291,6 +292,7 @@ async def test_diagnostics_macos( } }, "manager": { + "allocations": {}, "adapters": { "Core Bluetooth": { "address": "00:00:00:00:00:00", @@ -484,6 +486,7 @@ async def test_diagnostics_remote_adapter( }, "dbus": {}, "manager": { + "allocations": {}, "adapters": { "hci0": { "address": "00:00:00:00:00:01", From bc7c5fbc860cea539e44fd3e4c2a5b20caa354a5 Mon Sep 17 00:00:00 2001 From: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> Date: Tue, 28 Jan 2025 21:44:57 -0600 Subject: [PATCH 0186/3148] Fix typing errors in HEOS tests (#136795) * Correct typing errors of mocked heos * Fix player related typing issues * Sort mocks --- tests/components/heos/__init__.py | 57 +++ tests/components/heos/conftest.py | 98 +++--- .../heos/snapshots/test_diagnostics.ambr | 11 +- tests/components/heos/test_config_flow.py | 52 +-- tests/components/heos/test_diagnostics.py | 10 +- tests/components/heos/test_init.py | 47 ++- tests/components/heos/test_media_player.py | 324 +++++++++--------- tests/components/heos/test_services.py | 14 +- 8 files changed, 343 insertions(+), 270 deletions(-) diff --git a/tests/components/heos/__init__.py b/tests/components/heos/__init__.py index 3a774529c69..cf0d10790b7 100644 --- a/tests/components/heos/__init__.py +++ b/tests/components/heos/__init__.py @@ -1 +1,58 @@ """Tests for the Heos component.""" + +from unittest.mock import AsyncMock + +from pyheos import Heos, HeosGroup, HeosOptions, HeosPlayer + + +class MockHeos(Heos): + """Defines a mocked HEOS API.""" + + def __init__(self, options: HeosOptions) -> None: + """Initialize the mock.""" + super().__init__(options) + # Overwrite the methods with async mocks, changing type + self.add_to_queue: AsyncMock = AsyncMock() + self.connect: AsyncMock = AsyncMock() + self.disconnect: AsyncMock = AsyncMock() + self.get_favorites: AsyncMock = AsyncMock() + self.get_groups: AsyncMock = AsyncMock() + self.get_input_sources: AsyncMock = AsyncMock() + self.get_playlists: AsyncMock = AsyncMock() + self.get_players: AsyncMock = AsyncMock() + self.load_players: AsyncMock = AsyncMock() + self.play_media: AsyncMock = AsyncMock() + self.play_preset_station: AsyncMock = AsyncMock() + self.play_url: AsyncMock = AsyncMock() + self.player_clear_queue: AsyncMock = AsyncMock() + self.player_get_quick_selects: AsyncMock = AsyncMock() + self.player_play_next: AsyncMock = AsyncMock() + self.player_play_previous: AsyncMock = AsyncMock() + self.player_play_quick_select: AsyncMock = AsyncMock() + self.player_set_mute: AsyncMock = AsyncMock() + self.player_set_play_mode: AsyncMock = AsyncMock() + self.player_set_play_state: AsyncMock = AsyncMock() + self.player_set_volume: AsyncMock = AsyncMock() + self.set_group: AsyncMock = AsyncMock() + self.sign_in: AsyncMock = AsyncMock() + self.sign_out: AsyncMock = AsyncMock() + + def mock_set_players(self, players: dict[int, HeosPlayer]) -> None: + """Set the players on the mock instance.""" + for player in players.values(): + player.heos = self + self._players = players + self._players_loaded = bool(players) + self.get_players.return_value = players + + def mock_set_groups(self, groups: dict[int, HeosGroup]) -> None: + """Set the groups on the mock instance.""" + for group in groups.values(): + group.heos = self + self._groups = groups + self._groups_loaded = bool(groups) + self.get_groups.return_value = groups + + def mock_set_signed_in_username(self, signed_in_username: str | None) -> None: + """Set the signed in status on the mock instance.""" + self._signed_in_username = signed_in_username diff --git a/tests/components/heos/conftest.py b/tests/components/heos/conftest.py index 5312b8295ed..5ec809b10e9 100644 --- a/tests/components/heos/conftest.py +++ b/tests/components/heos/conftest.py @@ -2,11 +2,10 @@ from __future__ import annotations -from collections.abc import AsyncIterator -from unittest.mock import AsyncMock, Mock, patch +from collections.abc import Iterator +from unittest.mock import Mock, patch from pyheos import ( - Heos, HeosGroup, HeosHost, HeosNowPlayingMedia, @@ -38,6 +37,8 @@ from homeassistant.helpers.service_info.ssdp import ( SsdpServiceInfo, ) +from . import MockHeos + from tests.common import MockConfigEntry @@ -64,6 +65,17 @@ def config_entry_options_fixture() -> MockConfigEntry: ) +@pytest.fixture(name="new_mock", autouse=True) +def new_heos_mock_fixture(controller: MockHeos) -> Iterator[Mock]: + """Patch the Heos class to return the mock instance.""" + new_mock = Mock(return_value=controller) + with ( + patch("homeassistant.components.heos.coordinator.Heos", new=new_mock), + patch("homeassistant.components.heos.config_flow.Heos", new=new_mock), + ): + yield new_mock + + @pytest_asyncio.fixture(name="controller", autouse=True) async def controller_fixture( players: dict[int, HeosPlayer], @@ -72,49 +84,38 @@ async def controller_fixture( playlists: list[MediaItem], change_data: PlayerUpdateResult, group: dict[int, HeosGroup], -) -> AsyncIterator[Heos]: + quick_selects: dict[int, str], +) -> MockHeos: """Create a mock Heos controller fixture.""" - mock_heos = Heos(HeosOptions(host="127.0.0.1")) - for player in players.values(): - player.heos = mock_heos - mock_heos.connect = AsyncMock() - mock_heos.disconnect = AsyncMock() - mock_heos.sign_in = AsyncMock() - mock_heos.sign_out = AsyncMock() - mock_heos.get_players = AsyncMock(return_value=players) - mock_heos._players = players - mock_heos.get_favorites = AsyncMock(return_value=favorites) - mock_heos.get_input_sources = AsyncMock(return_value=input_sources) - mock_heos.get_playlists = AsyncMock(return_value=playlists) - mock_heos.load_players = AsyncMock(return_value=change_data) - mock_heos._signed_in_username = "user@user.com" - mock_heos.get_groups = AsyncMock(return_value=group) - mock_heos._groups = group - mock_heos.set_group = AsyncMock(return_value=None) - new_mock = Mock(return_value=mock_heos) - mock_heos.new_mock = new_mock - with ( - patch("homeassistant.components.heos.coordinator.Heos", new=new_mock), - patch("homeassistant.components.heos.config_flow.Heos", new=new_mock), - ): - yield mock_heos + + mock_heos = MockHeos(HeosOptions(host="127.0.0.1")) + mock_heos.mock_set_signed_in_username("user@user.com") + mock_heos.mock_set_players(players) + mock_heos.mock_set_groups(group) + mock_heos.get_favorites.return_value = favorites + mock_heos.get_input_sources.return_value = input_sources + mock_heos.get_playlists.return_value = playlists + mock_heos.load_players.return_value = change_data + mock_heos.player_get_quick_selects.return_value = quick_selects + return mock_heos @pytest.fixture(name="system") -def system_info_fixture() -> dict[str, str]: +def system_info_fixture() -> HeosSystem: """Create a system info fixture.""" + main_host = HeosHost( + "Test Player", + "HEOS Drive HS2", + "123456", + "1.0.0", + "127.0.0.1", + NetworkType.WIRED, + ) return HeosSystem( "user@user.com", - "127.0.0.1", + main_host, hosts=[ - HeosHost( - "Test Player", - "HEOS Drive HS2", - "123456", - "1.0.0", - "127.0.0.1", - NetworkType.WIRED, - ), + main_host, HeosHost( "Test Player 2", "Speaker", @@ -128,7 +129,7 @@ def system_info_fixture() -> dict[str, str]: @pytest.fixture(name="players") -def players_fixture(quick_selects: dict[int, str]) -> dict[int, HeosPlayer]: +def players_fixture() -> dict[int, HeosPlayer]: """Create two mock HeosPlayers.""" players = {} for i in (1, 2): @@ -148,7 +149,6 @@ def players_fixture(quick_selects: dict[int, str]) -> dict[int, HeosPlayer]: shuffle=False, repeat=RepeatType.OFF, volume=25, - heos=None, ) player.now_playing_media = HeosNowPlayingMedia( type=MediaType.STATION, @@ -162,24 +162,6 @@ def players_fixture(quick_selects: dict[int, str]) -> dict[int, HeosPlayer]: queue_id=1, source_id=10, ) - player.add_to_queue = AsyncMock() - player.clear_queue = AsyncMock() - player.get_quick_selects = AsyncMock(return_value=quick_selects) - player.mute = AsyncMock() - player.pause = AsyncMock() - player.play = AsyncMock() - player.play_media = AsyncMock() - player.play_next = AsyncMock() - player.play_previous = AsyncMock() - player.play_preset_station = AsyncMock() - player.play_quick_select = AsyncMock() - player.play_url = AsyncMock() - player.set_mute = AsyncMock() - player.set_play_mode = AsyncMock() - player.set_quick_select = AsyncMock() - player.set_volume = AsyncMock() - player.stop = AsyncMock() - player.unmute = AsyncMock() players[player.player_id] = player return players diff --git a/tests/components/heos/snapshots/test_diagnostics.ambr b/tests/components/heos/snapshots/test_diagnostics.ambr index 6de0a645f17..1df2d172142 100644 --- a/tests/components/heos/snapshots/test_diagnostics.ambr +++ b/tests/components/heos/snapshots/test_diagnostics.ambr @@ -98,8 +98,15 @@ 'Speaker - Line In 1', ]), 'system': dict({ - 'connected_to_preferred_host': False, - 'host': '127.0.0.1', + 'connected_to_preferred_host': True, + 'host': dict({ + 'ip_address': '127.0.0.1', + 'model': 'HEOS Drive HS2', + 'name': 'Test Player', + 'network': 'wired', + 'serial': '**REDACTED**', + 'version': '1.0.0', + }), 'hosts': list([ dict({ 'ip_address': '127.0.0.1', diff --git a/tests/components/heos/test_config_flow.py b/tests/components/heos/test_config_flow.py index 39ede354496..cbc32526958 100644 --- a/tests/components/heos/test_config_flow.py +++ b/tests/components/heos/test_config_flow.py @@ -1,6 +1,8 @@ """Tests for the Heos config flow module.""" -from pyheos import CommandAuthenticationError, CommandFailedError, Heos, HeosError +from typing import Any + +from pyheos import CommandAuthenticationError, CommandFailedError, HeosError import pytest from homeassistant.components.heos.const import DOMAIN @@ -10,6 +12,8 @@ from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo +from . import MockHeos + from tests.common import MockConfigEntry @@ -38,7 +42,7 @@ async def test_no_host_shows_form(hass: HomeAssistant) -> None: async def test_cannot_connect_shows_error_form( - hass: HomeAssistant, controller: Heos + hass: HomeAssistant, controller: MockHeos ) -> None: """Test form is shown with error when cannot connect.""" controller.connect.side_effect = HeosError() @@ -47,13 +51,15 @@ async def test_cannot_connect_shows_error_form( ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" - assert result["errors"][CONF_HOST] == "cannot_connect" + errors = result["errors"] + assert errors is not None + assert errors[CONF_HOST] == "cannot_connect" assert controller.connect.call_count == 1 assert controller.disconnect.call_count == 1 async def test_create_entry_when_host_valid( - hass: HomeAssistant, controller: Heos + hass: HomeAssistant, controller: MockHeos ) -> None: """Test result type is create entry when host is valid.""" data = {CONF_HOST: "127.0.0.1"} @@ -70,7 +76,7 @@ async def test_create_entry_when_host_valid( async def test_create_entry_when_friendly_name_valid( - hass: HomeAssistant, controller: Heos + hass: HomeAssistant, controller: MockHeos ) -> None: """Test result type is create entry when friendly name is valid.""" hass.data[DOMAIN] = {"Office (127.0.0.1)": "127.0.0.1"} @@ -131,7 +137,7 @@ async def test_discovery_flow_aborts_already_setup( async def test_reconfigure_validates_and_updates_config( - hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Test reconfigure validates host and successfully updates.""" config_entry.add_to_hass(hass) @@ -139,9 +145,9 @@ async def test_reconfigure_validates_and_updates_config( assert config_entry.data[CONF_HOST] == "127.0.0.1" # Test reconfigure initially shows form with current host value. - host = next( - key.default() for key in result["data_schema"].schema if key == CONF_HOST - ) + schema = result["data_schema"] + assert schema is not None + host = next(key.default() for key in schema.schema if key == CONF_HOST) assert host == "127.0.0.1" assert result["errors"] == {} assert result["step_id"] == "reconfigure" @@ -161,7 +167,7 @@ async def test_reconfigure_validates_and_updates_config( async def test_reconfigure_cannot_connect_recovers( - hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Test reconfigure cannot connect and recovers.""" controller.connect.side_effect = HeosError() @@ -176,11 +182,13 @@ async def test_reconfigure_cannot_connect_recovers( assert controller.connect.call_count == 1 assert controller.disconnect.call_count == 1 - host = next( - key.default() for key in result["data_schema"].schema if key == CONF_HOST - ) + schema = result["data_schema"] + assert schema is not None + host = next(key.default() for key in schema.schema if key == CONF_HOST) assert host == "127.0.0.2" - assert result["errors"][CONF_HOST] == "cannot_connect" + errors = result["errors"] + assert errors is not None + assert errors[CONF_HOST] == "cannot_connect" assert result["step_id"] == "reconfigure" assert result["type"] is FlowResultType.FORM @@ -214,7 +222,7 @@ async def test_reconfigure_cannot_connect_recovers( async def test_options_flow_signs_in( hass: HomeAssistant, config_entry: MockConfigEntry, - controller: Heos, + controller: MockHeos, error: HeosError, expected_error_key: str, ) -> None: @@ -255,7 +263,7 @@ async def test_options_flow_signs_in( async def test_options_flow_signs_out( - hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Test options flow signs-out when credentials cleared.""" config_entry.add_to_hass(hass) @@ -268,7 +276,7 @@ async def test_options_flow_signs_out( assert result["type"] is FlowResultType.FORM # Fail to sign-out, show error - user_input = {} + user_input: dict[str, Any] = {} controller.sign_out.side_effect = HeosError() result = await hass.config_entries.options.async_configure( result["flow_id"], user_input @@ -301,7 +309,7 @@ async def test_options_flow_signs_out( async def test_options_flow_missing_one_param_recovers( hass: HomeAssistant, config_entry: MockConfigEntry, - controller: Heos, + controller: MockHeos, user_input: dict[str, str], expected_errors: dict[str, str], ) -> None: @@ -350,7 +358,7 @@ async def test_options_flow_missing_one_param_recovers( async def test_reauth_signs_in_aborts( hass: HomeAssistant, config_entry: MockConfigEntry, - controller: Heos, + controller: MockHeos, error: HeosError, expected_error_key: str, ) -> None: @@ -391,7 +399,7 @@ async def test_reauth_signs_in_aborts( async def test_reauth_signs_out( - hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Test reauth flow signs-out when credentials cleared and aborts.""" config_entry.add_to_hass(hass) @@ -404,7 +412,7 @@ async def test_reauth_signs_out( assert result["type"] is FlowResultType.FORM # Fail to sign-out, show error - user_input = {} + user_input: dict[str, Any] = {} controller.sign_out.side_effect = HeosError() result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input @@ -439,7 +447,7 @@ async def test_reauth_signs_out( async def test_reauth_flow_missing_one_param_recovers( hass: HomeAssistant, config_entry: MockConfigEntry, - controller: Heos, + controller: MockHeos, user_input: dict[str, str], expected_errors: dict[str, str], ) -> None: diff --git a/tests/components/heos/test_diagnostics.py b/tests/components/heos/test_diagnostics.py index d6fb8e1a8fe..2a7deccfb33 100644 --- a/tests/components/heos/test_diagnostics.py +++ b/tests/components/heos/test_diagnostics.py @@ -2,15 +2,17 @@ from unittest import mock -from pyheos import Heos, HeosSystem +from pyheos import HeosSystem import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from syrupy.filters import props from homeassistant.components.heos.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr +from . import MockHeos + from tests.common import MockConfigEntry from tests.components.diagnostics import ( get_diagnostics_for_config_entry, @@ -23,7 +25,7 @@ async def test_config_entry_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, config_entry: MockConfigEntry, - controller: Heos, + controller: MockHeos, system: HeosSystem, snapshot: SnapshotAssertion, ) -> None: @@ -77,7 +79,7 @@ async def test_device_diagnostics( assert await hass.config_entries.async_setup(config_entry.entry_id) device_registry = dr.async_get(hass) device = device_registry.async_get_device({(DOMAIN, "1")}) - + assert device is not None diagnostics = await get_diagnostics_for_device( hass, hass_client, config_entry, device ) diff --git a/tests/components/heos/test_init.py b/tests/components/heos/test_init.py index 4c5eee67e2c..27dea82dcf2 100644 --- a/tests/components/heos/test_init.py +++ b/tests/components/heos/test_init.py @@ -1,8 +1,9 @@ """Tests for the init module.""" from typing import cast +from unittest.mock import Mock -from pyheos import Heos, HeosError, HeosOptions, SignalHeosEvent, SignalType +from pyheos import HeosError, HeosOptions, SignalHeosEvent, SignalType import pytest from homeassistant.components.heos.const import DOMAIN @@ -11,13 +12,15 @@ from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr +from . import MockHeos + from tests.common import MockConfigEntry async def test_async_setup_entry_loads_platforms( hass: HomeAssistant, config_entry: MockConfigEntry, - controller: Heos, + controller: MockHeos, ) -> None: """Test load connects to heos, retrieves players, and loads platforms.""" config_entry.add_to_hass(hass) @@ -32,7 +35,10 @@ async def test_async_setup_entry_loads_platforms( async def test_async_setup_entry_with_options_loads_platforms( - hass: HomeAssistant, config_entry_options: MockConfigEntry, controller: Heos + hass: HomeAssistant, + config_entry_options: MockConfigEntry, + controller: MockHeos, + new_mock: Mock, ) -> None: """Test load connects to heos with options, retrieves players, and loads platforms.""" config_entry_options.add_to_hass(hass) @@ -40,8 +46,9 @@ async def test_async_setup_entry_with_options_loads_platforms( # Assert options passed and methods called assert config_entry_options.state is ConfigEntryState.LOADED - options = cast(HeosOptions, controller.new_mock.call_args[0][0]) + options = cast(HeosOptions, new_mock.call_args[0][0]) assert options.host == config_entry_options.data[CONF_HOST] + assert options.credentials is not None assert options.credentials.username == config_entry_options.options[CONF_USERNAME] assert options.credentials.password == config_entry_options.options[CONF_PASSWORD] assert controller.connect.call_count == 1 @@ -54,14 +61,14 @@ async def test_async_setup_entry_with_options_loads_platforms( async def test_async_setup_entry_auth_failure_starts_reauth( hass: HomeAssistant, config_entry_options: MockConfigEntry, - controller: Heos, + controller: MockHeos, ) -> None: """Test load with auth failure starts reauth, loads platforms.""" config_entry_options.add_to_hass(hass) # Simulates what happens when the controller can't sign-in during connection async def connect_send_auth_failure() -> None: - controller._signed_in_username = None + controller.mock_set_signed_in_username(None) await controller.dispatcher.wait_send( SignalType.HEOS_EVENT, SignalHeosEvent.USER_CREDENTIALS_INVALID ) @@ -76,19 +83,19 @@ async def test_async_setup_entry_auth_failure_starts_reauth( controller.disconnect.assert_not_called() assert config_entry_options.state is ConfigEntryState.LOADED assert any( - config_entry_options.async_get_active_flows(hass, sources=[SOURCE_REAUTH]) + config_entry_options.async_get_active_flows(hass, sources={SOURCE_REAUTH}) ) async def test_async_setup_entry_not_signed_in_loads_platforms( hass: HomeAssistant, config_entry: MockConfigEntry, - controller: Heos, + controller: MockHeos, caplog: pytest.LogCaptureFixture, ) -> None: """Test setup does not retrieve favorites when not logged in.""" config_entry.add_to_hass(hass) - controller._signed_in_username = None + controller.mock_set_signed_in_username(None) assert await hass.config_entries.async_setup(config_entry.entry_id) assert controller.connect.call_count == 1 assert controller.get_players.call_count == 1 @@ -102,7 +109,7 @@ async def test_async_setup_entry_not_signed_in_loads_platforms( async def test_async_setup_entry_connect_failure( - hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Connection failure raises ConfigEntryNotReady.""" config_entry.add_to_hass(hass) @@ -114,7 +121,7 @@ async def test_async_setup_entry_connect_failure( async def test_async_setup_entry_player_failure( - hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Failure to retrieve players raises ConfigEntryNotReady.""" config_entry.add_to_hass(hass) @@ -126,7 +133,7 @@ async def test_async_setup_entry_player_failure( async def test_async_setup_entry_favorites_failure( - hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Failure to retrieve favorites loads.""" config_entry.add_to_hass(hass) @@ -136,7 +143,7 @@ async def test_async_setup_entry_favorites_failure( async def test_async_setup_entry_inputs_failure( - hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Failure to retrieve inputs loads.""" config_entry.add_to_hass(hass) @@ -146,7 +153,7 @@ async def test_async_setup_entry_inputs_failure( async def test_unload_entry( - hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Test entries are unloaded correctly.""" config_entry.add_to_hass(hass) @@ -164,12 +171,14 @@ async def test_device_info( config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) device = device_registry.async_get_device({(DOMAIN, "1")}) + assert device is not None assert device.manufacturer == "HEOS" assert device.model == "Drive HS2" assert device.name == "Test Player" assert device.serial_number == "123456" assert device.sw_version == "1.0.0" device = device_registry.async_get_device({(DOMAIN, "2")}) + assert device is not None assert device.manufacturer == "HEOS" assert device.model == "Speaker" @@ -183,12 +192,14 @@ async def test_device_id_migration( config_entry.add_to_hass(hass) # Create a device with a legacy identifier device_registry.async_get_or_create( - config_entry_id=config_entry.entry_id, identifiers={(DOMAIN, 1)} + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, 1)}, # type: ignore[arg-type] ) device_registry.async_get_or_create( - config_entry_id=config_entry.entry_id, identifiers={("Other", 1)} + config_entry_id=config_entry.entry_id, + identifiers={("Other", 1)}, # type: ignore[arg-type] ) assert await hass.config_entries.async_setup(config_entry.entry_id) - assert device_registry.async_get_device({("Other", 1)}) is not None - assert device_registry.async_get_device({(DOMAIN, 1)}) is None + assert device_registry.async_get_device({("Other", 1)}) is not None # type: ignore[arg-type] + assert device_registry.async_get_device({(DOMAIN, 1)}) is None # type: ignore[arg-type] assert device_registry.async_get_device({(DOMAIN, "1")}) is not None diff --git a/tests/components/heos/test_media_player.py b/tests/components/heos/test_media_player.py index 8fc63bbc7ad..3768462eada 100644 --- a/tests/components/heos/test_media_player.py +++ b/tests/components/heos/test_media_player.py @@ -8,7 +8,6 @@ from freezegun.api import FrozenDateTimeFactory from pyheos import ( AddCriteriaType, CommandFailedError, - Heos, HeosError, MediaItem, MediaType as HeosMediaType, @@ -66,6 +65,8 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import device_registry as dr, entity_registry as er +from . import MockHeos + from tests.common import MockConfigEntry, async_fire_time_changed @@ -88,7 +89,7 @@ async def test_state_attributes( async def test_updates_from_signals( - hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Tests dispatched signals update player.""" config_entry.add_to_hass(hass) @@ -97,33 +98,36 @@ async def test_updates_from_signals( # Test player does not update for other players player.state = PlayState.PLAY - await player.heos.dispatcher.wait_send( + await controller.dispatcher.wait_send( SignalType.PLAYER_EVENT, 2, const.EVENT_PLAYER_STATE_CHANGED ) await hass.async_block_till_done() state = hass.states.get("media_player.test_player") + assert state is not None assert state.state == STATE_IDLE # Test player_update standard events player.state = PlayState.PLAY - await player.heos.dispatcher.wait_send( + await controller.dispatcher.wait_send( SignalType.PLAYER_EVENT, player.player_id, const.EVENT_PLAYER_STATE_CHANGED ) await hass.async_block_till_done() state = hass.states.get("media_player.test_player") + assert state is not None assert state.state == STATE_PLAYING # Test player_update progress events player.now_playing_media.duration = 360000 player.now_playing_media.current_position = 1000 - await player.heos.dispatcher.wait_send( + await controller.dispatcher.wait_send( SignalType.PLAYER_EVENT, player.player_id, const.EVENT_PLAYER_NOW_PLAYING_PROGRESS, ) await hass.async_block_till_done() state = hass.states.get("media_player.test_player") + assert state is not None assert state.attributes[ATTR_MEDIA_POSITION_UPDATED_AT] is not None assert state.attributes[ATTR_MEDIA_DURATION] == 360 assert state.attributes[ATTR_MEDIA_POSITION] == 1 @@ -132,7 +136,7 @@ async def test_updates_from_signals( async def test_updates_from_connection_event( hass: HomeAssistant, config_entry: MockConfigEntry, - controller: Heos, + controller: MockHeos, caplog: pytest.LogCaptureFixture, ) -> None: """Tests player updates from connection event after connection failure.""" @@ -142,34 +146,37 @@ async def test_updates_from_connection_event( # Connected player.available = True - await player.heos.dispatcher.wait_send( + await controller.dispatcher.wait_send( SignalType.HEOS_EVENT, SignalHeosEvent.CONNECTED ) await hass.async_block_till_done() state = hass.states.get("media_player.test_player") + assert state is not None assert state.state == STATE_IDLE assert controller.load_players.call_count == 1 # Disconnected controller.load_players.reset_mock() player.available = False - await player.heos.dispatcher.wait_send( + await controller.dispatcher.wait_send( SignalType.HEOS_EVENT, SignalHeosEvent.DISCONNECTED ) await hass.async_block_till_done() state = hass.states.get("media_player.test_player") + assert state is not None assert state.state == STATE_UNAVAILABLE assert controller.load_players.call_count == 0 # Connected handles refresh failure controller.load_players.reset_mock() - controller.load_players.side_effect = CommandFailedError(None, "Failure", 1) + controller.load_players.side_effect = CommandFailedError("", "Failure", 1) player.available = True - await player.heos.dispatcher.wait_send( + await controller.dispatcher.wait_send( SignalType.HEOS_EVENT, SignalHeosEvent.CONNECTED ) await hass.async_block_till_done() state = hass.states.get("media_player.test_player") + assert state is not None assert state.state == STATE_IDLE assert controller.load_players.call_count == 1 assert "Unable to refresh players" in caplog.text @@ -180,7 +187,7 @@ async def test_updates_from_connection_event_new_player_ids( entity_registry: er.EntityRegistry, device_registry: dr.DeviceRegistry, config_entry: MockConfigEntry, - controller: Heos, + controller: MockHeos, change_data_mapped_ids: PlayerUpdateResult, ) -> None: """Test player ids changed after reconnection updates ids.""" @@ -208,16 +215,15 @@ async def test_updates_from_connection_event_new_player_ids( async def test_updates_from_sources_updated( hass: HomeAssistant, config_entry: MockConfigEntry, - controller: Heos, + controller: MockHeos, freezer: FrozenDateTimeFactory, ) -> None: """Tests player updates from changes in sources list.""" config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) - player = controller.players[1] controller.get_input_sources.return_value = [] - await player.heos.dispatcher.wait_send( + await controller.dispatcher.wait_send( SignalType.CONTROLLER_EVENT, const.EVENT_SOURCES_CHANGED, {} ) freezer.tick(timedelta(seconds=1)) @@ -225,6 +231,7 @@ async def test_updates_from_sources_updated( await hass.async_block_till_done() state = hass.states.get("media_player.test_player") + assert state is not None assert state.attributes[ATTR_INPUT_SOURCE_LIST] == [ "Today's Hits Radio", "Classical MPR (Classical Music)", @@ -234,7 +241,7 @@ async def test_updates_from_sources_updated( async def test_updates_from_players_changed( hass: HomeAssistant, config_entry: MockConfigEntry, - controller: Heos, + controller: MockHeos, change_data: PlayerUpdateResult, ) -> None: """Test player updates from changes to available players.""" @@ -242,13 +249,17 @@ async def test_updates_from_players_changed( assert await hass.config_entries.async_setup(config_entry.entry_id) player = controller.players[1] - assert hass.states.get("media_player.test_player").state == STATE_IDLE + state = hass.states.get("media_player.test_player") + assert state is not None + assert state.state == STATE_IDLE player.state = PlayState.PLAY - await player.heos.dispatcher.wait_send( + await controller.dispatcher.wait_send( SignalType.CONTROLLER_EVENT, const.EVENT_PLAYERS_CHANGED, change_data ) await hass.async_block_till_done() - assert hass.states.get("media_player.test_player").state == STATE_PLAYING + state = hass.states.get("media_player.test_player") + assert state is not None + assert state.state == STATE_PLAYING async def test_updates_from_players_changed_new_ids( @@ -256,13 +267,12 @@ async def test_updates_from_players_changed_new_ids( entity_registry: er.EntityRegistry, device_registry: dr.DeviceRegistry, config_entry: MockConfigEntry, - controller: Heos, + controller: MockHeos, change_data_mapped_ids: PlayerUpdateResult, ) -> None: """Test player updates from changes to available players.""" config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) - player = controller.players[1] # Assert device registry matches current id assert device_registry.async_get_device(identifiers={(DOMAIN, "1")}) @@ -272,7 +282,7 @@ async def test_updates_from_players_changed_new_ids( == "media_player.test_player" ) - await player.heos.dispatcher.wait_send( + await controller.dispatcher.wait_send( SignalType.CONTROLLER_EVENT, const.EVENT_PLAYERS_CHANGED, change_data_mapped_ids, @@ -293,16 +303,15 @@ async def test_updates_from_players_changed_new_ids( async def test_updates_from_user_changed( hass: HomeAssistant, config_entry: MockConfigEntry, - controller: Heos, + controller: MockHeos, freezer: FrozenDateTimeFactory, ) -> None: """Tests player updates from changes in user.""" config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) - player = controller.players[1] - controller._signed_in_username = None - await player.heos.dispatcher.wait_send( + controller.mock_set_signed_in_username(None) + await controller.dispatcher.wait_send( SignalType.CONTROLLER_EVENT, const.EVENT_USER_CHANGED, None ) freezer.tick(timedelta(seconds=1)) @@ -310,6 +319,7 @@ async def test_updates_from_user_changed( await hass.async_block_till_done() state = hass.states.get("media_player.test_player") + assert state is not None assert state.attributes[ATTR_INPUT_SOURCE_LIST] == [ "HEOS Drive - Line In 1", "Speaker - Line In 1", @@ -317,22 +327,28 @@ async def test_updates_from_user_changed( async def test_updates_from_groups_changed( - hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Test player updates from changes to groups.""" config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() # Assert current state - assert hass.states.get("media_player.test_player").attributes[ - ATTR_GROUP_MEMBERS - ] == ["media_player.test_player", "media_player.test_player_2"] - assert hass.states.get("media_player.test_player_2").attributes[ - ATTR_GROUP_MEMBERS - ] == ["media_player.test_player", "media_player.test_player_2"] + state = hass.states.get("media_player.test_player") + assert state is not None + assert state.attributes[ATTR_GROUP_MEMBERS] == [ + "media_player.test_player", + "media_player.test_player_2", + ] + state = hass.states.get("media_player.test_player_2") + assert state is not None + assert state.attributes[ATTR_GROUP_MEMBERS] == [ + "media_player.test_player", + "media_player.test_player_2", + ] # Clear group information - controller._groups = {} + controller.mock_set_groups({}) for player in controller.players.values(): player.group_id = None await controller.dispatcher.wait_send( @@ -341,40 +357,37 @@ async def test_updates_from_groups_changed( await hass.async_block_till_done() # Assert groups changed - assert ( - hass.states.get("media_player.test_player").attributes[ATTR_GROUP_MEMBERS] - is None - ) - assert ( - hass.states.get("media_player.test_player_2").attributes[ATTR_GROUP_MEMBERS] - is None - ) + state = hass.states.get("media_player.test_player") + assert state is not None + assert state.attributes[ATTR_GROUP_MEMBERS] is None + + state = hass.states.get("media_player.test_player_2") + assert state is not None + assert state.attributes[ATTR_GROUP_MEMBERS] is None async def test_clear_playlist( - hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Test the clear playlist service.""" config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) - player = controller.players[1] await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_CLEAR_PLAYLIST, {ATTR_ENTITY_ID: "media_player.test_player"}, blocking=True, ) - assert player.clear_queue.call_count == 1 + assert controller.player_clear_queue.call_count == 1 async def test_clear_playlist_error( - hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Test error raised when clear playlist fails.""" config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) - player = controller.players[1] - player.clear_queue.side_effect = CommandFailedError(None, "Failure", 1) + controller.player_clear_queue.side_effect = CommandFailedError("", "Failure", 1) with pytest.raises( HomeAssistantError, match=re.escape("Unable to clear playlist: Failure (1)") ): @@ -384,33 +397,31 @@ async def test_clear_playlist_error( {ATTR_ENTITY_ID: "media_player.test_player"}, blocking=True, ) - assert player.clear_queue.call_count == 1 + assert controller.player_clear_queue.call_count == 1 async def test_pause( - hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Test the pause service.""" config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) - player = controller.players[1] await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_MEDIA_PAUSE, {ATTR_ENTITY_ID: "media_player.test_player"}, blocking=True, ) - assert player.pause.call_count == 1 + assert controller.player_set_play_state.call_count == 1 async def test_pause_error( - hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Test the pause service raises error.""" config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) - player = controller.players[1] - player.pause.side_effect = CommandFailedError(None, "Failure", 1) + controller.player_set_play_state.side_effect = CommandFailedError("", "Failure", 1) with pytest.raises( HomeAssistantError, match=re.escape("Unable to pause: Failure (1)") ): @@ -420,33 +431,31 @@ async def test_pause_error( {ATTR_ENTITY_ID: "media_player.test_player"}, blocking=True, ) - assert player.pause.call_count == 1 + assert controller.player_set_play_state.call_count == 1 async def test_play( - hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Test the play service.""" config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) - player = controller.players[1] await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_MEDIA_PLAY, {ATTR_ENTITY_ID: "media_player.test_player"}, blocking=True, ) - assert player.play.call_count == 1 + assert controller.player_set_play_state.call_count == 1 async def test_play_error( - hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Test the play service raises error.""" config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) - player = controller.players[1] - player.play.side_effect = CommandFailedError(None, "Failure", 1) + controller.player_set_play_state.side_effect = CommandFailedError("", "Failure", 1) with pytest.raises( HomeAssistantError, match=re.escape("Unable to play: Failure (1)") ): @@ -456,33 +465,31 @@ async def test_play_error( {ATTR_ENTITY_ID: "media_player.test_player"}, blocking=True, ) - assert player.play.call_count == 1 + assert controller.player_set_play_state.call_count == 1 async def test_previous_track( - hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Test the previous track service.""" config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) - player = controller.players[1] await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_MEDIA_PREVIOUS_TRACK, {ATTR_ENTITY_ID: "media_player.test_player"}, blocking=True, ) - assert player.play_previous.call_count == 1 + assert controller.player_play_previous.call_count == 1 async def test_previous_track_error( - hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Test the previous track service raises error.""" config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) - player = controller.players[1] - player.play_previous.side_effect = CommandFailedError(None, "Failure", 1) + controller.player_play_previous.side_effect = CommandFailedError("", "Failure", 1) with pytest.raises( HomeAssistantError, match=re.escape("Unable to move to previous track: Failure (1)"), @@ -493,33 +500,31 @@ async def test_previous_track_error( {ATTR_ENTITY_ID: "media_player.test_player"}, blocking=True, ) - assert player.play_previous.call_count == 1 + assert controller.player_play_previous.call_count == 1 async def test_next_track( - hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Test the next track service.""" config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) - player = controller.players[1] await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_MEDIA_NEXT_TRACK, {ATTR_ENTITY_ID: "media_player.test_player"}, blocking=True, ) - assert player.play_next.call_count == 1 + assert controller.player_play_next.call_count == 1 async def test_next_track_error( - hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Test the next track service raises error.""" config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) - player = controller.players[1] - player.play_next.side_effect = CommandFailedError(None, "Failure", 1) + controller.player_play_next.side_effect = CommandFailedError("", "Failure", 1) with pytest.raises( HomeAssistantError, match=re.escape("Unable to move to next track: Failure (1)"), @@ -530,33 +535,31 @@ async def test_next_track_error( {ATTR_ENTITY_ID: "media_player.test_player"}, blocking=True, ) - assert player.play_next.call_count == 1 + assert controller.player_play_next.call_count == 1 async def test_stop( - hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Test the stop service.""" config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) - player = controller.players[1] await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_MEDIA_STOP, {ATTR_ENTITY_ID: "media_player.test_player"}, blocking=True, ) - assert player.stop.call_count == 1 + assert controller.player_set_play_state.call_count == 1 async def test_stop_error( - hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Test the stop service raises error.""" config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) - player = controller.players[1] - player.stop.side_effect = CommandFailedError(None, "Failure", 1) + controller.player_set_play_state.side_effect = CommandFailedError("", "Failure", 1) with pytest.raises( HomeAssistantError, match=re.escape("Unable to stop: Failure (1)"), @@ -567,33 +570,31 @@ async def test_stop_error( {ATTR_ENTITY_ID: "media_player.test_player"}, blocking=True, ) - assert player.stop.call_count == 1 + assert controller.player_set_play_state.call_count == 1 async def test_volume_mute( - hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Test the volume mute service.""" config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) - player = controller.players[1] await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_VOLUME_MUTE, {ATTR_ENTITY_ID: "media_player.test_player", ATTR_MEDIA_VOLUME_MUTED: True}, blocking=True, ) - assert player.set_mute.call_count == 1 + assert controller.player_set_mute.call_count == 1 async def test_volume_mute_error( - hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Test the volume mute service raises error.""" config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) - player = controller.players[1] - player.set_mute.side_effect = CommandFailedError(None, "Failure", 1) + controller.player_set_mute.side_effect = CommandFailedError("", "Failure", 1) with pytest.raises( HomeAssistantError, match=re.escape("Unable to set mute: Failure (1)"), @@ -604,11 +605,11 @@ async def test_volume_mute_error( {ATTR_ENTITY_ID: "media_player.test_player", ATTR_MEDIA_VOLUME_MUTED: True}, blocking=True, ) - assert player.set_mute.call_count == 1 + assert controller.player_set_mute.call_count == 1 async def test_shuffle_set( - hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Test the shuffle set service.""" config_entry.add_to_hass(hass) @@ -620,17 +621,17 @@ async def test_shuffle_set( {ATTR_ENTITY_ID: "media_player.test_player", ATTR_MEDIA_SHUFFLE: True}, blocking=True, ) - player.set_play_mode.assert_called_once_with(player.repeat, True) + controller.player_set_play_mode.assert_called_once_with(1, player.repeat, True) async def test_shuffle_set_error( - hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Test the shuffle set service raises error.""" config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) player = controller.players[1] - player.set_play_mode.side_effect = CommandFailedError(None, "Failure", 1) + controller.player_set_play_mode.side_effect = CommandFailedError("", "Failure", 1) with pytest.raises( HomeAssistantError, match=re.escape("Unable to set shuffle: Failure (1)"), @@ -641,11 +642,11 @@ async def test_shuffle_set_error( {ATTR_ENTITY_ID: "media_player.test_player", ATTR_MEDIA_SHUFFLE: True}, blocking=True, ) - player.set_play_mode.assert_called_once_with(player.repeat, True) + controller.player_set_play_mode.assert_called_once_with(1, player.repeat, True) async def test_repeat_set( - hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Test the repeat set service.""" config_entry.add_to_hass(hass) @@ -657,17 +658,19 @@ async def test_repeat_set( {ATTR_ENTITY_ID: "media_player.test_player", ATTR_MEDIA_REPEAT: RepeatMode.ONE}, blocking=True, ) - player.set_play_mode.assert_called_once_with(RepeatType.ON_ONE, player.shuffle) + controller.player_set_play_mode.assert_called_once_with( + 1, RepeatType.ON_ONE, player.shuffle + ) async def test_repeat_set_error( - hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Test the repeat set service raises error.""" config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) player = controller.players[1] - player.set_play_mode.side_effect = CommandFailedError(None, "Failure", 1) + controller.player_set_play_mode.side_effect = CommandFailedError("", "Failure", 1) with pytest.raises( HomeAssistantError, match=re.escape("Unable to set repeat: Failure (1)"), @@ -681,33 +684,33 @@ async def test_repeat_set_error( }, blocking=True, ) - player.set_play_mode.assert_called_once_with(RepeatType.ON_ALL, player.shuffle) + controller.player_set_play_mode.assert_called_once_with( + 1, RepeatType.ON_ALL, player.shuffle + ) async def test_volume_set( - hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Test the volume set service.""" config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) - player = controller.players[1] await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_VOLUME_SET, {ATTR_ENTITY_ID: "media_player.test_player", ATTR_MEDIA_VOLUME_LEVEL: 1}, blocking=True, ) - player.set_volume.assert_called_once_with(100) + controller.player_set_volume.assert_called_once_with(1, 100) async def test_volume_set_error( - hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Test the volume set service raises error.""" config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) - player = controller.players[1] - player.set_volume.side_effect = CommandFailedError(None, "Failure", 1) + controller.player_set_volume.side_effect = CommandFailedError("", "Failure", 1) with pytest.raises( HomeAssistantError, match=re.escape("Unable to set volume level: Failure (1)"), @@ -718,13 +721,13 @@ async def test_volume_set_error( {ATTR_ENTITY_ID: "media_player.test_player", ATTR_MEDIA_VOLUME_LEVEL: 1}, blocking=True, ) - player.set_volume.assert_called_once_with(100) + controller.player_set_volume.assert_called_once_with(1, 100) async def test_select_favorite( hass: HomeAssistant, config_entry: MockConfigEntry, - controller: Heos, + controller: MockHeos, favorites: dict[int, MediaItem], ) -> None: """Tests selecting a music service favorite and state.""" @@ -739,22 +742,23 @@ async def test_select_favorite( {ATTR_ENTITY_ID: "media_player.test_player", ATTR_INPUT_SOURCE: favorite.name}, blocking=True, ) - player.play_preset_station.assert_called_once_with(1) + controller.play_preset_station.assert_called_once_with(1, 1) # Test state is matched by station name player.now_playing_media.type = HeosMediaType.STATION player.now_playing_media.station = favorite.name - await player.heos.dispatcher.wait_send( + await controller.dispatcher.wait_send( SignalType.PLAYER_EVENT, player.player_id, const.EVENT_PLAYER_STATE_CHANGED ) await hass.async_block_till_done() state = hass.states.get("media_player.test_player") + assert state is not None assert state.attributes[ATTR_INPUT_SOURCE] == favorite.name async def test_select_radio_favorite( hass: HomeAssistant, config_entry: MockConfigEntry, - controller: Heos, + controller: MockHeos, favorites: dict[int, MediaItem], ) -> None: """Tests selecting a radio favorite and state.""" @@ -769,32 +773,32 @@ async def test_select_radio_favorite( {ATTR_ENTITY_ID: "media_player.test_player", ATTR_INPUT_SOURCE: favorite.name}, blocking=True, ) - player.play_preset_station.assert_called_once_with(2) + controller.play_preset_station.assert_called_once_with(1, 2) # Test state is matched by album id player.now_playing_media.type = HeosMediaType.STATION player.now_playing_media.station = "Classical" player.now_playing_media.album_id = favorite.media_id - await player.heos.dispatcher.wait_send( + await controller.dispatcher.wait_send( SignalType.PLAYER_EVENT, player.player_id, const.EVENT_PLAYER_STATE_CHANGED ) await hass.async_block_till_done() state = hass.states.get("media_player.test_player") + assert state is not None assert state.attributes[ATTR_INPUT_SOURCE] == favorite.name async def test_select_radio_favorite_command_error( hass: HomeAssistant, config_entry: MockConfigEntry, - controller: Heos, + controller: MockHeos, favorites: dict[int, MediaItem], ) -> None: """Tests command error raises when playing favorite.""" config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) - player = controller.players[1] # Test set radio preset favorite = favorites[2] - player.play_preset_station.side_effect = CommandFailedError(None, "Failure", 1) + controller.play_preset_station.side_effect = CommandFailedError("", "Failure", 1) with pytest.raises( HomeAssistantError, match=re.escape("Unable to select source: Failure (1)"), @@ -808,7 +812,7 @@ async def test_select_radio_favorite_command_error( }, blocking=True, ) - player.play_preset_station.assert_called_once_with(2) + controller.play_preset_station.assert_called_once_with(1, 2) @pytest.mark.parametrize( @@ -821,7 +825,7 @@ async def test_select_radio_favorite_command_error( async def test_select_input_source( hass: HomeAssistant, config_entry: MockConfigEntry, - controller: Heos, + controller: MockHeos, input_sources: list[MediaItem], source_name: str, station: str, @@ -840,21 +844,24 @@ async def test_select_input_source( }, blocking=True, ) - input_sources = next( + input_source = next( input_sources for input_sources in input_sources if input_sources.name == source_name ) - player.play_media.assert_called_once_with(input_sources) + controller.play_media.assert_called_once_with( + 1, input_source, AddCriteriaType.PLAY_NOW + ) # Update the now_playing_media to reflect play_media player.now_playing_media.source_id = const.MUSIC_SOURCE_AUX_INPUT player.now_playing_media.station = station player.now_playing_media.media_id = const.INPUT_AUX_IN_1 - await player.heos.dispatcher.wait_send( + await controller.dispatcher.wait_send( SignalType.PLAYER_EVENT, player.player_id, const.EVENT_PLAYER_STATE_CHANGED ) await hass.async_block_till_done() state = hass.states.get("media_player.test_player") + assert state is not None assert state.attributes[ATTR_INPUT_SOURCE] == source_name @@ -879,15 +886,14 @@ async def test_select_input_unknown_raises( async def test_select_input_command_error( hass: HomeAssistant, config_entry: MockConfigEntry, - controller: Heos, + controller: MockHeos, input_sources: list[MediaItem], ) -> None: """Tests selecting an unknown input.""" config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) - player = controller.players[1] input_source = input_sources[0] - player.play_media.side_effect = CommandFailedError(None, "Failure", 1) + controller.play_media.side_effect = CommandFailedError("", "Failure", 1) with pytest.raises( HomeAssistantError, match=re.escape("Unable to select source: Failure (1)"), @@ -901,7 +907,9 @@ async def test_select_input_command_error( }, blocking=True, ) - player.play_media.assert_called_once_with(input_source) + controller.play_media.assert_called_once_with( + 1, input_source, AddCriteriaType.PLAY_NOW + ) async def test_unload_config_entry( @@ -911,20 +919,21 @@ async def test_unload_config_entry( config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) assert await hass.config_entries.async_unload(config_entry.entry_id) - assert hass.states.get("media_player.test_player").state == STATE_UNAVAILABLE + state = hass.states.get("media_player.test_player") + assert state is not None + assert state.state == STATE_UNAVAILABLE @pytest.mark.parametrize("media_type", [MediaType.URL, MediaType.MUSIC]) async def test_play_media( hass: HomeAssistant, config_entry: MockConfigEntry, - controller: Heos, + controller: MockHeos, media_type: MediaType, ) -> None: """Test the play media service with type url.""" config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) - player = controller.players[1] url = "http://news/podcast.mp3" await hass.services.async_call( MEDIA_PLAYER_DOMAIN, @@ -936,21 +945,20 @@ async def test_play_media( }, blocking=True, ) - player.play_url.assert_called_once_with(url) + controller.play_url.assert_called_once_with(1, url) @pytest.mark.parametrize("media_type", [MediaType.URL, MediaType.MUSIC]) async def test_play_media_error( hass: HomeAssistant, config_entry: MockConfigEntry, - controller: Heos, + controller: MockHeos, media_type: MediaType, ) -> None: """Test the play media service with type url error raises.""" config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) - player = controller.players[1] - player.play_url.side_effect = CommandFailedError(None, "Failure", 1) + controller.play_url.side_effect = CommandFailedError("", "Failure", 1) url = "http://news/podcast.mp3" with pytest.raises( HomeAssistantError, @@ -966,7 +974,7 @@ async def test_play_media_error( }, blocking=True, ) - player.play_url.assert_called_once_with(url) + controller.play_url.assert_called_once_with(1, url) @pytest.mark.parametrize( @@ -975,14 +983,13 @@ async def test_play_media_error( async def test_play_media_quick_select( hass: HomeAssistant, config_entry: MockConfigEntry, - controller: Heos, + controller: MockHeos, content_id: str, expected_index: int, ) -> None: """Test the play media service with type quick_select.""" config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) - player = controller.players[1] await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_PLAY_MEDIA, @@ -993,16 +1000,15 @@ async def test_play_media_quick_select( }, blocking=True, ) - player.play_quick_select.assert_called_once_with(expected_index) + controller.player_play_quick_select.assert_called_once_with(1, expected_index) async def test_play_media_quick_select_error( - hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Test the play media service with invalid quick_select raises.""" config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) - player = controller.players[1] with pytest.raises( HomeAssistantError, match=re.escape("Unable to play media: Invalid quick select 'Invalid'"), @@ -1017,7 +1023,7 @@ async def test_play_media_quick_select_error( }, blocking=True, ) - assert player.play_quick_select.call_count == 0 + assert controller.player_play_quick_select.call_count == 0 @pytest.mark.parametrize( @@ -1031,7 +1037,7 @@ async def test_play_media_quick_select_error( async def test_play_media_playlist( hass: HomeAssistant, config_entry: MockConfigEntry, - controller: Heos, + controller: MockHeos, playlists: list[MediaItem], enqueue: Any, criteria: AddCriteriaType, @@ -1039,7 +1045,6 @@ async def test_play_media_playlist( """Test the play media service with type playlist.""" config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) - player = controller.players[1] playlist = playlists[0] service_data = { ATTR_ENTITY_ID: "media_player.test_player", @@ -1054,16 +1059,15 @@ async def test_play_media_playlist( service_data, blocking=True, ) - player.play_media.assert_called_once_with(playlist, criteria) + controller.play_media.assert_called_once_with(1, playlist, criteria) async def test_play_media_playlist_error( - hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Test the play media service with an invalid playlist name.""" config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) - player = controller.players[1] with pytest.raises( HomeAssistantError, match=re.escape("Unable to play media: Invalid playlist 'Invalid'"), @@ -1078,7 +1082,7 @@ async def test_play_media_playlist_error( }, blocking=True, ) - assert player.add_to_queue.call_count == 0 + assert controller.add_to_queue.call_count == 0 @pytest.mark.parametrize( @@ -1087,14 +1091,13 @@ async def test_play_media_playlist_error( async def test_play_media_favorite( hass: HomeAssistant, config_entry: MockConfigEntry, - controller: Heos, + controller: MockHeos, content_id: str, expected_index: int, ) -> None: """Test the play media service with type favorite.""" config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) - player = controller.players[1] await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_PLAY_MEDIA, @@ -1105,16 +1108,15 @@ async def test_play_media_favorite( }, blocking=True, ) - player.play_preset_station.assert_called_once_with(expected_index) + controller.play_preset_station.assert_called_once_with(1, expected_index) async def test_play_media_favorite_error( - hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Test the play media service with an invalid favorite raises.""" config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) - player = controller.players[1] with pytest.raises( HomeAssistantError, match=re.escape("Unable to play media: Invalid favorite 'Invalid'"), @@ -1129,7 +1131,7 @@ async def test_play_media_favorite_error( }, blocking=True, ) - assert player.play_preset_station.call_count == 0 + assert controller.play_preset_station.call_count == 0 async def test_play_media_invalid_type( @@ -1165,7 +1167,7 @@ async def test_play_media_invalid_type( async def test_media_player_join_group( hass: HomeAssistant, config_entry: MockConfigEntry, - controller: Heos, + controller: MockHeos, members: list[str], expected: tuple[int, list[int]], ) -> None: @@ -1185,7 +1187,7 @@ async def test_media_player_join_group( async def test_media_player_join_group_error( - hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Test grouping of media players through the join service raises error.""" config_entry.add_to_hass(hass) @@ -1209,13 +1211,14 @@ async def test_media_player_join_group_error( async def test_media_player_group_members( hass: HomeAssistant, config_entry: MockConfigEntry, - controller: Heos, + controller: MockHeos, caplog: pytest.LogCaptureFixture, ) -> None: """Test group_members attribute.""" config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) player_entity = hass.states.get("media_player.test_player") + assert player_entity is not None assert player_entity.attributes[ATTR_GROUP_MEMBERS] == [ "media_player.test_player", "media_player.test_player_2", @@ -1227,16 +1230,17 @@ async def test_media_player_group_members( async def test_media_player_group_members_error( hass: HomeAssistant, config_entry: MockConfigEntry, - controller: Heos, + controller: MockHeos, caplog: pytest.LogCaptureFixture, ) -> None: """Test error in HEOS API.""" + controller.mock_set_groups({}) controller.get_groups.side_effect = HeosError("error") - controller._groups = {} config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) assert "Unable to retrieve groups" in caplog.text player_entity = hass.states.get("media_player.test_player") + assert player_entity is not None assert player_entity.attributes[ATTR_GROUP_MEMBERS] is None @@ -1247,7 +1251,7 @@ async def test_media_player_group_members_error( async def test_media_player_unjoin_group( hass: HomeAssistant, config_entry: MockConfigEntry, - controller: Heos, + controller: MockHeos, entity_id: str, expected_args: list[int], ) -> None: @@ -1266,7 +1270,7 @@ async def test_media_player_unjoin_group( async def test_media_player_unjoin_group_error( - hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Test ungrouping of media players through the unjoin service error raises.""" config_entry.add_to_hass(hass) @@ -1289,7 +1293,7 @@ async def test_media_player_unjoin_group_error( async def test_media_player_group_fails_when_entity_removed( hass: HomeAssistant, config_entry: MockConfigEntry, - controller: Heos, + controller: MockHeos, entity_registry: er.EntityRegistry, ) -> None: """Test grouping fails when entity removed.""" @@ -1316,7 +1320,7 @@ async def test_media_player_group_fails_when_entity_removed( async def test_media_player_group_fails_wrong_integration( hass: HomeAssistant, config_entry: MockConfigEntry, - controller: Heos, + controller: MockHeos, entity_registry: er.EntityRegistry, ) -> None: """Test grouping fails when trying to join from the wrong integration.""" diff --git a/tests/components/heos/test_services.py b/tests/components/heos/test_services.py index 8eda26d2b3d..151571ceb50 100644 --- a/tests/components/heos/test_services.py +++ b/tests/components/heos/test_services.py @@ -1,6 +1,6 @@ """Tests for the services module.""" -from pyheos import CommandAuthenticationError, Heos, HeosError +from pyheos import CommandAuthenticationError, HeosError import pytest from homeassistant.components.heos.const import ( @@ -13,11 +13,13 @@ from homeassistant.components.heos.const import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from . import MockHeos + from tests.common import MockConfigEntry async def test_sign_in( - hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Test the sign-in service.""" config_entry.add_to_hass(hass) @@ -34,7 +36,7 @@ async def test_sign_in( async def test_sign_in_failed( - hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Test sign-in service logs error when not connected.""" config_entry.add_to_hass(hass) @@ -56,7 +58,7 @@ async def test_sign_in_failed( async def test_sign_in_unknown_error( - hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Test sign-in service logs error for failure.""" config_entry.add_to_hass(hass) @@ -93,7 +95,7 @@ async def test_sign_in_not_loaded_raises( async def test_sign_out( - hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Test the sign-out service.""" config_entry.add_to_hass(hass) @@ -117,7 +119,7 @@ async def test_sign_out_not_loaded_raises( async def test_sign_out_unknown_error( - hass: HomeAssistant, config_entry: MockConfigEntry, controller: Heos + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: """Test the sign-out service.""" config_entry.add_to_hass(hass) From 688a1f1d52fdb9f12a6429dcaeeb2721a05c8e9f Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Wed, 29 Jan 2025 04:46:26 +0100 Subject: [PATCH 0187/3148] Add UI to create KNX BinarySensor entities (#136786) Update knx-frontend to 2025.1.28.225404 --- homeassistant/components/knx/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/knx/manifest.json b/homeassistant/components/knx/manifest.json index acb9b9b61a0..f34ce0f4589 100644 --- a/homeassistant/components/knx/manifest.json +++ b/homeassistant/components/knx/manifest.json @@ -12,7 +12,7 @@ "requirements": [ "xknx==3.5.0", "xknxproject==3.8.1", - "knx-frontend==2025.1.18.164225" + "knx-frontend==2025.1.28.225404" ], "single_config_entry": true } diff --git a/requirements_all.txt b/requirements_all.txt index c67a83de01a..9fceadde1b9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1272,7 +1272,7 @@ kiwiki-client==0.1.1 knocki==0.4.2 # homeassistant.components.knx -knx-frontend==2025.1.18.164225 +knx-frontend==2025.1.28.225404 # homeassistant.components.konnected konnected==1.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 78b4ed27566..ff43be037f7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1074,7 +1074,7 @@ kegtron-ble==0.4.0 knocki==0.4.2 # homeassistant.components.knx -knx-frontend==2025.1.18.164225 +knx-frontend==2025.1.28.225404 # homeassistant.components.konnected konnected==1.2.0 From f909b548111ab5059e273056ad7fe1ecedd460a5 Mon Sep 17 00:00:00 2001 From: Duco Sebel <74970928+DCSBL@users.noreply.github.com> Date: Wed, 29 Jan 2025 04:46:52 +0100 Subject: [PATCH 0188/3148] Redact stored authentication token in HomeWizard diagnostics (#136766) --- homeassistant/components/homewizard/diagnostics.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/homewizard/diagnostics.py b/homeassistant/components/homewizard/diagnostics.py index c776cdb18f2..12bd25671e0 100644 --- a/homeassistant/components/homewizard/diagnostics.py +++ b/homeassistant/components/homewizard/diagnostics.py @@ -16,6 +16,7 @@ TO_REDACT = { "gas_unique_id", "id", "serial", + "token", "unique_id", "unique_meter_id", "wifi_ssid", From d06b0fe3403589795cea5096a825f6cdcf7111d1 Mon Sep 17 00:00:00 2001 From: Petro31 <35082313+Petro31@users.noreply.github.com> Date: Tue, 28 Jan 2025 22:48:38 -0500 Subject: [PATCH 0189/3148] Reload template blueprints when reloading templates (#136794) --- homeassistant/components/template/__init__.py | 1 + tests/components/template/test_blueprint.py | 63 +++++++++++++++++++ 2 files changed, 64 insertions(+) diff --git a/homeassistant/components/template/__init__.py b/homeassistant/components/template/__init__.py index 7b7b5eb9b29..15a73cf3de5 100644 --- a/homeassistant/components/template/__init__.py +++ b/homeassistant/components/template/__init__.py @@ -53,6 +53,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def _reload_config(call: Event | ServiceCall) -> None: """Reload top-level + platforms.""" + await async_get_blueprints(hass).async_reset_cache() try: unprocessed_conf = await conf_util.async_hass_config_yaml(hass) except HomeAssistantError as err: diff --git a/tests/components/template/test_blueprint.py b/tests/components/template/test_blueprint.py index cb4e83d934c..dd008a27822 100644 --- a/tests/components/template/test_blueprint.py +++ b/tests/components/template/test_blueprint.py @@ -149,6 +149,69 @@ async def test_inverted_binary_sensor( ) +async def test_reload_template_when_blueprint_changes(hass: HomeAssistant) -> None: + """Test a template is updated at reload if the blueprint has changed.""" + hass.states.async_set("binary_sensor.foo", "on", {"friendly_name": "Foo"}) + config = { + DOMAIN: [ + { + "use_blueprint": { + "path": "inverted_binary_sensor.yaml", + "input": {"reference_entity": "binary_sensor.foo"}, + }, + "name": "Inverted foo", + }, + ] + } + with patch_blueprint( + "inverted_binary_sensor.yaml", + BUILTIN_BLUEPRINT_FOLDER / "inverted_binary_sensor.yaml", + ): + assert await async_setup_component(hass, DOMAIN, config) + + hass.states.async_set("binary_sensor.foo", "off", {"friendly_name": "Foo"}) + await hass.async_block_till_done() + + assert hass.states.get("binary_sensor.foo").state == "off" + + inverted = hass.states.get("binary_sensor.inverted_foo") + assert inverted + assert inverted.state == "on" + + # Reload the automations without any change, but with updated blueprint + blueprint_config = yaml_util.load_yaml( + BUILTIN_BLUEPRINT_FOLDER / "inverted_binary_sensor.yaml" + ) + blueprint_config["binary_sensor"]["state"] = "{{ states(reference_entity) }}" + with ( + patch( + "homeassistant.config.load_yaml_config_file", + autospec=True, + return_value=config, + ), + patch( + "homeassistant.components.blueprint.models.yaml_util.load_yaml_dict", + autospec=True, + return_value=blueprint_config, + ), + ): + await hass.services.async_call(DOMAIN, SERVICE_RELOAD, blocking=True) + + hass.states.async_set("binary_sensor.foo", "off", {"friendly_name": "Foo"}) + await hass.async_block_till_done() + + not_inverted = hass.states.get("binary_sensor.inverted_foo") + assert not_inverted + assert not_inverted.state == "off" + + hass.states.async_set("binary_sensor.foo", "on", {"friendly_name": "Foo"}) + await hass.async_block_till_done() + + not_inverted = hass.states.get("binary_sensor.inverted_foo") + assert not_inverted + assert not_inverted.state == "on" + + async def test_domain_blueprint(hass: HomeAssistant) -> None: """Test DomainBlueprint services.""" reload_handler_calls = async_mock_service(hass, DOMAIN, SERVICE_RELOAD) From 48dfa037bd8455d54ddfbb7d898edbf68a01ac1c Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Tue, 28 Jan 2025 22:25:35 -0600 Subject: [PATCH 0190/3148] Bump intents to 2025.1.28 (#136782) * Bump intents to 2025.1.28 * Fix snapshots --- homeassistant/components/conversation/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- script/hassfest/docker/Dockerfile | 2 +- .../components/assist_pipeline/snapshots/test_websocket.ambr | 4 ++-- tests/components/conversation/snapshots/test_http.ambr | 3 ++- 7 files changed, 9 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/conversation/manifest.json b/homeassistant/components/conversation/manifest.json index 7ca1799b2d1..0485cb75fcb 100644 --- a/homeassistant/components/conversation/manifest.json +++ b/homeassistant/components/conversation/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/conversation", "integration_type": "system", "quality_scale": "internal", - "requirements": ["hassil==2.2.0", "home-assistant-intents==2025.1.1"] + "requirements": ["hassil==2.2.0", "home-assistant-intents==2025.1.28"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 8643d53ff68..f7f30bf7d71 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -38,7 +38,7 @@ hass-nabucasa==0.88.1 hassil==2.2.0 home-assistant-bluetooth==1.13.0 home-assistant-frontend==20250109.2 -home-assistant-intents==2025.1.1 +home-assistant-intents==2025.1.28 httpx==0.28.1 ifaddr==0.2.0 Jinja2==3.1.5 diff --git a/requirements_all.txt b/requirements_all.txt index 9fceadde1b9..66ec0b992f3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1146,7 +1146,7 @@ holidays==0.65 home-assistant-frontend==20250109.2 # homeassistant.components.conversation -home-assistant-intents==2025.1.1 +home-assistant-intents==2025.1.28 # homeassistant.components.home_connect homeconnect==0.8.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ff43be037f7..bf28f289f2e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -975,7 +975,7 @@ holidays==0.65 home-assistant-frontend==20250109.2 # homeassistant.components.conversation -home-assistant-intents==2025.1.1 +home-assistant-intents==2025.1.28 # homeassistant.components.home_connect homeconnect==0.8.0 diff --git a/script/hassfest/docker/Dockerfile b/script/hassfest/docker/Dockerfile index 5700ca01462..2c433ba362e 100644 --- a/script/hassfest/docker/Dockerfile +++ b/script/hassfest/docker/Dockerfile @@ -25,7 +25,7 @@ RUN --mount=from=ghcr.io/astral-sh/uv:0.5.21,source=/uv,target=/bin/uv \ -c /usr/src/homeassistant/homeassistant/package_constraints.txt \ -r /usr/src/homeassistant/requirements.txt \ stdlib-list==0.10.0 pipdeptree==2.23.4 tqdm==4.66.5 ruff==0.9.1 \ - PyTurboJPEG==1.7.5 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 hassil==2.2.0 home-assistant-intents==2025.1.1 mutagen==1.47.0 pymicro-vad==1.0.1 pyspeex-noise==1.0.2 + PyTurboJPEG==1.7.5 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 hassil==2.2.0 home-assistant-intents==2025.1.28 mutagen==1.47.0 pymicro-vad==1.0.1 pyspeex-noise==1.0.2 LABEL "name"="hassfest" LABEL "maintainer"="Home Assistant " diff --git a/tests/components/assist_pipeline/snapshots/test_websocket.ambr b/tests/components/assist_pipeline/snapshots/test_websocket.ambr index 917a9b654d5..5f06172404b 100644 --- a/tests/components/assist_pipeline/snapshots/test_websocket.ambr +++ b/tests/components/assist_pipeline/snapshots/test_websocket.ambr @@ -710,7 +710,7 @@ 'speech': dict({ 'plain': dict({ 'extra_data': None, - 'speech': 'Sorry, I am not aware of any area called Are', + 'speech': 'Sorry, I am not aware of any area called Are the', }), }), }), @@ -756,7 +756,7 @@ 'speech': dict({ 'plain': dict({ 'extra_data': None, - 'speech': 'Sorry, I am not aware of any area called Are', + 'speech': 'Sorry, I am not aware of any area called Are the', }), }), }), diff --git a/tests/components/conversation/snapshots/test_http.ambr b/tests/components/conversation/snapshots/test_http.ambr index 3e71ee99382..c6ac6c2df9c 100644 --- a/tests/components/conversation/snapshots/test_http.ambr +++ b/tests/components/conversation/snapshots/test_http.ambr @@ -49,6 +49,7 @@ 'sk', 'sl', 'sr', + 'sr-Latn', 'sv', 'sw', 'te', @@ -539,7 +540,7 @@ 'name': 'HassTurnOn', }), 'match': True, - 'sentence_template': ' on [] ', + 'sentence_template': ' on [(|)] ', 'slots': dict({ 'area': 'kitchen', 'domain': 'light', From 94e4863cbe727ebe4094e2958d637262f94a6216 Mon Sep 17 00:00:00 2001 From: "Teemu R." Date: Wed, 29 Jan 2025 06:34:26 +0100 Subject: [PATCH 0191/3148] Add power protection entities for tplink (#132267) Co-authored-by: Steven B <51370195+sdb9696@users.noreply.github.com> Co-authored-by: J. Nick Koston --- .../components/tplink/binary_sensor.py | 4 ++ homeassistant/components/tplink/number.py | 4 ++ homeassistant/components/tplink/strings.json | 6 ++ .../components/tplink/fixtures/features.json | 11 ++++ .../tplink/snapshots/test_binary_sensor.ambr | 47 ++++++++++++++++ .../tplink/snapshots/test_number.ambr | 55 +++++++++++++++++++ 6 files changed, 127 insertions(+) diff --git a/homeassistant/components/tplink/binary_sensor.py b/homeassistant/components/tplink/binary_sensor.py index e08495f5c88..6986765b110 100644 --- a/homeassistant/components/tplink/binary_sensor.py +++ b/homeassistant/components/tplink/binary_sensor.py @@ -35,6 +35,10 @@ BINARY_SENSOR_DESCRIPTIONS: Final = ( key="overheated", device_class=BinarySensorDeviceClass.PROBLEM, ), + TPLinkBinarySensorEntityDescription( + key="overloaded", + device_class=BinarySensorDeviceClass.PROBLEM, + ), TPLinkBinarySensorEntityDescription( key="battery_low", device_class=BinarySensorDeviceClass.BATTERY, diff --git a/homeassistant/components/tplink/number.py b/homeassistant/components/tplink/number.py index b47c50d688f..a9d002c0083 100644 --- a/homeassistant/components/tplink/number.py +++ b/homeassistant/components/tplink/number.py @@ -65,6 +65,10 @@ NUMBER_DESCRIPTIONS: Final = ( key="tilt_step", mode=NumberMode.BOX, ), + TPLinkNumberEntityDescription( + key="power_protection_threshold", + mode=NumberMode.SLIDER, + ), TPLinkNumberEntityDescription( key="clean_count", mode=NumberMode.SLIDER, diff --git a/homeassistant/components/tplink/strings.json b/homeassistant/components/tplink/strings.json index 034aff7a763..fe661fa2529 100644 --- a/homeassistant/components/tplink/strings.json +++ b/homeassistant/components/tplink/strings.json @@ -109,6 +109,9 @@ "overheated": { "name": "Overheated" }, + "overloaded": { + "name": "Overloaded" + }, "cloud_connection": { "name": "Cloud connection" }, @@ -268,6 +271,9 @@ "temperature_offset": { "name": "Temperature offset" }, + "power_protection_threshold": { + "name": "Power protection" + }, "pan_step": { "name": "Pan degrees" }, diff --git a/tests/components/tplink/fixtures/features.json b/tests/components/tplink/fixtures/features.json index d366a91c33c..c49c5881d5c 100644 --- a/tests/components/tplink/fixtures/features.json +++ b/tests/components/tplink/fixtures/features.json @@ -195,6 +195,11 @@ "type": "BinarySensor", "category": "Info" }, + "overloaded": { + "value": false, + "type": "BinarySensor", + "category": "Info" + }, "battery_low": { "value": false, "type": "BinarySensor", @@ -284,6 +289,12 @@ "minimum_value": -10, "maximum_value": 10 }, + "power_protection_threshold": { + "value": 100, + "type": "Number", + "category": "Config", + "minimum_value": 0 + }, "target_temperature": { "value": false, "type": "Number", diff --git a/tests/components/tplink/snapshots/test_binary_sensor.ambr b/tests/components/tplink/snapshots/test_binary_sensor.ambr index e16d4409511..125592b053c 100644 --- a/tests/components/tplink/snapshots/test_binary_sensor.ambr +++ b/tests/components/tplink/snapshots/test_binary_sensor.ambr @@ -300,6 +300,53 @@ 'state': 'off', }) # --- +# name: test_states[binary_sensor.my_device_overloaded-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.my_device_overloaded', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Overloaded', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'overloaded', + 'unique_id': '123456789ABCDEFGH_overloaded', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[binary_sensor.my_device_overloaded-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'my_device Overloaded', + }), + 'context': , + 'entity_id': 'binary_sensor.my_device_overloaded', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_states[binary_sensor.my_device_temperature_warning-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tplink/snapshots/test_number.ambr b/tests/components/tplink/snapshots/test_number.ambr index 6733c5423a0..4bdb92aeab6 100644 --- a/tests/components/tplink/snapshots/test_number.ambr +++ b/tests/components/tplink/snapshots/test_number.ambr @@ -145,6 +145,61 @@ 'state': '10', }) # --- +# name: test_states[number.my_device_power_protection-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 65536, + 'min': 0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.my_device_power_protection', + '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': 'Power protection', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_protection_threshold', + 'unique_id': '123456789ABCDEFGH_power_protection_threshold', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[number.my_device_power_protection-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'my_device Power protection', + 'max': 65536, + 'min': 0, + 'mode': , + 'step': 1.0, + }), + 'context': , + 'entity_id': 'number.my_device_power_protection', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- # name: test_states[number.my_device_smooth_off-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From a2b5a96bc939b0111b8f01c47927589fa3173b2e Mon Sep 17 00:00:00 2001 From: tronikos Date: Tue, 28 Jan 2025 21:43:30 -0800 Subject: [PATCH 0192/3148] Add Google Drive integration for backup (#134576) * Add Google Drive integration for backup * Add test_config_flow * Stop using aiogoogle * address a few comments * Check folder exists in setup * fix test * address comments * fix * fix * Use ChunkAsyncStreamIterator in helpers * repair-issues: todo * Remove check if folder exists in the reatuh flow. This is done in setup. * single_config_entry": true * Add test_init.py * Store into backups.json to avoid 124 bytes per property limit * Address comments * autouse=True on setup_credentials * Store metadata in description and remove backups.json * improvements * timeout downloads * library * fixes * strings * review * ruff * fix test * Set unique_id * Use slugify in homeassistant.util * Fix * Remove RefreshError * review * push more fields to the test constant --------- Co-authored-by: Joostlek --- .strict-typing | 1 + CODEOWNERS | 2 + homeassistant/brands/google.json | 1 + .../components/google_drive/__init__.py | 65 +++ homeassistant/components/google_drive/api.py | 201 ++++++++ .../google_drive/application_credentials.py | 21 + .../components/google_drive/backup.py | 147 ++++++ .../components/google_drive/config_flow.py | 114 +++++ .../components/google_drive/const.py | 5 + .../components/google_drive/manifest.json | 14 + .../google_drive/quality_scale.yaml | 113 +++++ .../components/google_drive/strings.json | 40 ++ .../generated/application_credentials.py | 1 + homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + mypy.ini | 10 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/google_drive/__init__.py | 1 + tests/components/google_drive/conftest.py | 80 +++ .../google_drive/snapshots/test_backup.ambr | 237 +++++++++ .../snapshots/test_config_flow.ambr | 44 ++ tests/components/google_drive/test_backup.py | 461 ++++++++++++++++++ .../google_drive/test_config_flow.py | 363 ++++++++++++++ tests/components/google_drive/test_init.py | 164 +++++++ 25 files changed, 2098 insertions(+) create mode 100644 homeassistant/components/google_drive/__init__.py create mode 100644 homeassistant/components/google_drive/api.py create mode 100644 homeassistant/components/google_drive/application_credentials.py create mode 100644 homeassistant/components/google_drive/backup.py create mode 100644 homeassistant/components/google_drive/config_flow.py create mode 100644 homeassistant/components/google_drive/const.py create mode 100644 homeassistant/components/google_drive/manifest.json create mode 100644 homeassistant/components/google_drive/quality_scale.yaml create mode 100644 homeassistant/components/google_drive/strings.json create mode 100644 tests/components/google_drive/__init__.py create mode 100644 tests/components/google_drive/conftest.py create mode 100644 tests/components/google_drive/snapshots/test_backup.ambr create mode 100644 tests/components/google_drive/snapshots/test_config_flow.ambr create mode 100644 tests/components/google_drive/test_backup.py create mode 100644 tests/components/google_drive/test_config_flow.py create mode 100644 tests/components/google_drive/test_init.py diff --git a/.strict-typing b/.strict-typing index 811e5d54c81..1a5450d8eb4 100644 --- a/.strict-typing +++ b/.strict-typing @@ -217,6 +217,7 @@ homeassistant.components.goalzero.* homeassistant.components.google.* homeassistant.components.google_assistant_sdk.* homeassistant.components.google_cloud.* +homeassistant.components.google_drive.* homeassistant.components.google_photos.* homeassistant.components.google_sheets.* homeassistant.components.govee_ble.* diff --git a/CODEOWNERS b/CODEOWNERS index 68a33f34f9a..7baeea72178 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -566,6 +566,8 @@ build.json @home-assistant/supervisor /tests/components/google_assistant_sdk/ @tronikos /homeassistant/components/google_cloud/ @lufton @tronikos /tests/components/google_cloud/ @lufton @tronikos +/homeassistant/components/google_drive/ @tronikos +/tests/components/google_drive/ @tronikos /homeassistant/components/google_generative_ai_conversation/ @tronikos /tests/components/google_generative_ai_conversation/ @tronikos /homeassistant/components/google_mail/ @tkdrob diff --git a/homeassistant/brands/google.json b/homeassistant/brands/google.json index 028fa544a5f..872cfc0aac5 100644 --- a/homeassistant/brands/google.json +++ b/homeassistant/brands/google.json @@ -5,6 +5,7 @@ "google_assistant", "google_assistant_sdk", "google_cloud", + "google_drive", "google_generative_ai_conversation", "google_mail", "google_maps", diff --git a/homeassistant/components/google_drive/__init__.py b/homeassistant/components/google_drive/__init__.py new file mode 100644 index 00000000000..af93956931a --- /dev/null +++ b/homeassistant/components/google_drive/__init__.py @@ -0,0 +1,65 @@ +"""The Google Drive integration.""" + +from __future__ import annotations + +from collections.abc import Callable + +from google_drive_api.exceptions import GoogleDriveApiError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import instance_id +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.config_entry_oauth2_flow import ( + OAuth2Session, + async_get_config_entry_implementation, +) +from homeassistant.util.hass_dict import HassKey + +from .api import AsyncConfigEntryAuth, DriveClient +from .const import DOMAIN + +DATA_BACKUP_AGENT_LISTENERS: HassKey[list[Callable[[], None]]] = HassKey( + f"{DOMAIN}.backup_agent_listeners" +) + + +type GoogleDriveConfigEntry = ConfigEntry[DriveClient] + + +async def async_setup_entry(hass: HomeAssistant, entry: GoogleDriveConfigEntry) -> bool: + """Set up Google Drive from a config entry.""" + auth = AsyncConfigEntryAuth( + async_get_clientsession(hass), + OAuth2Session( + hass, entry, await async_get_config_entry_implementation(hass, entry) + ), + ) + + # Test we can refresh the token and raise ConfigEntryAuthFailed or ConfigEntryNotReady if not + await auth.async_get_access_token() + + client = DriveClient(await instance_id.async_get(hass), auth) + entry.runtime_data = client + + # Test we can access Google Drive and raise if not + try: + await client.async_create_ha_root_folder_if_not_exists() + except GoogleDriveApiError as err: + raise ConfigEntryNotReady from err + + return True + + +async def async_unload_entry( + hass: HomeAssistant, entry: GoogleDriveConfigEntry +) -> bool: + """Unload a config entry.""" + hass.loop.call_soon(_notify_backup_listeners, hass) + return True + + +def _notify_backup_listeners(hass: HomeAssistant) -> None: + for listener in hass.data.get(DATA_BACKUP_AGENT_LISTENERS, []): + listener() diff --git a/homeassistant/components/google_drive/api.py b/homeassistant/components/google_drive/api.py new file mode 100644 index 00000000000..a26512db35b --- /dev/null +++ b/homeassistant/components/google_drive/api.py @@ -0,0 +1,201 @@ +"""API for Google Drive bound to Home Assistant OAuth.""" + +from __future__ import annotations + +from collections.abc import AsyncIterator, Callable, Coroutine +import json +import logging +from typing import Any + +from aiohttp import ClientSession, ClientTimeout, StreamReader +from aiohttp.client_exceptions import ClientError, ClientResponseError +from google_drive_api.api import AbstractAuth, GoogleDriveApi + +from homeassistant.components.backup import AgentBackup +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_ACCESS_TOKEN +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + ConfigEntryNotReady, + HomeAssistantError, +) +from homeassistant.helpers import config_entry_oauth2_flow + +_UPLOAD_AND_DOWNLOAD_TIMEOUT = 12 * 3600 + +_LOGGER = logging.getLogger(__name__) + + +class AsyncConfigEntryAuth(AbstractAuth): + """Provide Google Drive authentication tied to an OAuth2 based config entry.""" + + def __init__( + self, + websession: ClientSession, + oauth_session: config_entry_oauth2_flow.OAuth2Session, + ) -> None: + """Initialize AsyncConfigEntryAuth.""" + super().__init__(websession) + self._oauth_session = oauth_session + + async def async_get_access_token(self) -> str: + """Return a valid access token.""" + try: + await self._oauth_session.async_ensure_token_valid() + except ClientError as ex: + if ( + self._oauth_session.config_entry.state + is ConfigEntryState.SETUP_IN_PROGRESS + ): + if isinstance(ex, ClientResponseError) and 400 <= ex.status < 500: + raise ConfigEntryAuthFailed( + "OAuth session is not valid, reauth required" + ) from ex + raise ConfigEntryNotReady from ex + if hasattr(ex, "status") and ex.status == 400: + self._oauth_session.config_entry.async_start_reauth( + self._oauth_session.hass + ) + raise HomeAssistantError(ex) from ex + return str(self._oauth_session.token[CONF_ACCESS_TOKEN]) + + +class AsyncConfigFlowAuth(AbstractAuth): + """Provide authentication tied to a fixed token for the config flow.""" + + def __init__( + self, + websession: ClientSession, + token: str, + ) -> None: + """Initialize AsyncConfigFlowAuth.""" + super().__init__(websession) + self._token = token + + async def async_get_access_token(self) -> str: + """Return a valid access token.""" + return self._token + + +class DriveClient: + """Google Drive client.""" + + def __init__( + self, + ha_instance_id: str, + auth: AbstractAuth, + ) -> None: + """Initialize Google Drive client.""" + self._ha_instance_id = ha_instance_id + self._api = GoogleDriveApi(auth) + + async def async_get_email_address(self) -> str: + """Get email address of the current user.""" + res = await self._api.get_user(params={"fields": "user(emailAddress)"}) + return str(res["user"]["emailAddress"]) + + async def async_create_ha_root_folder_if_not_exists(self) -> tuple[str, str]: + """Create Home Assistant folder if it doesn't exist.""" + fields = "id,name" + query = " and ".join( + [ + "properties has { key='home_assistant' and value='root' }", + f"properties has {{ key='instance_id' and value='{self._ha_instance_id}' }}", + "trashed=false", + ] + ) + res = await self._api.list_files( + params={"q": query, "fields": f"files({fields})"} + ) + for file in res["files"]: + _LOGGER.debug("Found existing folder: %s", file) + return str(file["id"]), str(file["name"]) + + file_metadata = { + "name": "Home Assistant", + "mimeType": "application/vnd.google-apps.folder", + "properties": { + "home_assistant": "root", + "instance_id": self._ha_instance_id, + }, + } + _LOGGER.debug("Creating new folder with metadata: %s", file_metadata) + res = await self._api.create_file(params={"fields": fields}, json=file_metadata) + _LOGGER.debug("Created folder: %s", res) + return str(res["id"]), str(res["name"]) + + async def async_upload_backup( + self, + open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]], + backup: AgentBackup, + ) -> None: + """Upload a backup.""" + folder_id, _ = await self.async_create_ha_root_folder_if_not_exists() + backup_metadata = { + "name": f"{backup.name} {backup.date}.tar", + "description": json.dumps(backup.as_dict()), + "parents": [folder_id], + "properties": { + "home_assistant": "backup", + "instance_id": self._ha_instance_id, + "backup_id": backup.backup_id, + }, + } + _LOGGER.debug( + "Uploading backup: %s with Google Drive metadata: %s", + backup.backup_id, + backup_metadata, + ) + await self._api.upload_file( + backup_metadata, + open_stream, + timeout=ClientTimeout(total=_UPLOAD_AND_DOWNLOAD_TIMEOUT), + ) + _LOGGER.debug( + "Uploaded backup: %s to: '%s'", + backup.backup_id, + backup_metadata["name"], + ) + + async def async_list_backups(self) -> list[AgentBackup]: + """List backups.""" + query = " and ".join( + [ + "properties has { key='home_assistant' and value='backup' }", + f"properties has {{ key='instance_id' and value='{self._ha_instance_id}' }}", + "trashed=false", + ] + ) + res = await self._api.list_files( + params={"q": query, "fields": "files(description)"} + ) + backups = [] + for file in res["files"]: + backup = AgentBackup.from_dict(json.loads(file["description"])) + backups.append(backup) + return backups + + async def async_get_backup_file_id(self, backup_id: str) -> str | None: + """Get file_id of backup if it exists.""" + query = " and ".join( + [ + "properties has { key='home_assistant' and value='backup' }", + f"properties has {{ key='instance_id' and value='{self._ha_instance_id}' }}", + f"properties has {{ key='backup_id' and value='{backup_id}' }}", + ] + ) + res = await self._api.list_files(params={"q": query, "fields": "files(id)"}) + for file in res["files"]: + return str(file["id"]) + return None + + async def async_delete(self, file_id: str) -> None: + """Delete file.""" + await self._api.delete_file(file_id) + + async def async_download(self, file_id: str) -> StreamReader: + """Download a file.""" + resp = await self._api.get_file_content( + file_id, timeout=ClientTimeout(total=_UPLOAD_AND_DOWNLOAD_TIMEOUT) + ) + return resp.content diff --git a/homeassistant/components/google_drive/application_credentials.py b/homeassistant/components/google_drive/application_credentials.py new file mode 100644 index 00000000000..c2f59b298cb --- /dev/null +++ b/homeassistant/components/google_drive/application_credentials.py @@ -0,0 +1,21 @@ +"""application_credentials platform for Google Drive.""" + +from homeassistant.components.application_credentials import AuthorizationServer +from homeassistant.core import HomeAssistant + + +async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer: + """Return authorization server.""" + return AuthorizationServer( + "https://accounts.google.com/o/oauth2/v2/auth", + "https://oauth2.googleapis.com/token", + ) + + +async def async_get_description_placeholders(hass: HomeAssistant) -> dict[str, str]: + """Return description placeholders for the credentials dialog.""" + return { + "oauth_consent_url": "https://console.cloud.google.com/apis/credentials/consent", + "more_info_url": "https://www.home-assistant.io/integrations/google_drive/", + "oauth_creds_url": "https://console.cloud.google.com/apis/credentials", + } diff --git a/homeassistant/components/google_drive/backup.py b/homeassistant/components/google_drive/backup.py new file mode 100644 index 00000000000..4c81f041c8b --- /dev/null +++ b/homeassistant/components/google_drive/backup.py @@ -0,0 +1,147 @@ +"""Backup platform for the Google Drive integration.""" + +from __future__ import annotations + +from collections.abc import AsyncIterator, Callable, Coroutine +import logging +from typing import Any + +from google_drive_api.exceptions import GoogleDriveApiError + +from homeassistant.components.backup import AgentBackup, BackupAgent, BackupAgentError +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.aiohttp_client import ChunkAsyncStreamIterator +from homeassistant.util import slugify + +from . import DATA_BACKUP_AGENT_LISTENERS, GoogleDriveConfigEntry +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +async def async_get_backup_agents( + hass: HomeAssistant, + **kwargs: Any, +) -> list[BackupAgent]: + """Return a list of backup agents.""" + entries = hass.config_entries.async_loaded_entries(DOMAIN) + return [GoogleDriveBackupAgent(entry) for entry in entries] + + +@callback +def async_register_backup_agents_listener( + hass: HomeAssistant, + *, + listener: Callable[[], None], + **kwargs: Any, +) -> Callable[[], None]: + """Register a listener to be called when agents are added or removed. + + :return: A function to unregister the listener. + """ + hass.data.setdefault(DATA_BACKUP_AGENT_LISTENERS, []).append(listener) + + @callback + def remove_listener() -> None: + """Remove the listener.""" + hass.data[DATA_BACKUP_AGENT_LISTENERS].remove(listener) + if not hass.data[DATA_BACKUP_AGENT_LISTENERS]: + del hass.data[DATA_BACKUP_AGENT_LISTENERS] + + return remove_listener + + +class GoogleDriveBackupAgent(BackupAgent): + """Google Drive backup agent.""" + + domain = DOMAIN + + def __init__(self, config_entry: GoogleDriveConfigEntry) -> None: + """Initialize the cloud backup sync agent.""" + super().__init__() + assert config_entry.unique_id + self.name = config_entry.title + self.unique_id = slugify(config_entry.unique_id) + self._client = config_entry.runtime_data + + async def async_upload_backup( + self, + *, + open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]], + backup: AgentBackup, + **kwargs: Any, + ) -> None: + """Upload a backup. + + :param open_stream: A function returning an async iterator that yields bytes. + :param backup: Metadata about the backup that should be uploaded. + """ + try: + await self._client.async_upload_backup(open_stream, backup) + except (GoogleDriveApiError, HomeAssistantError, TimeoutError) as err: + _LOGGER.error("Upload backup error: %s", err) + raise BackupAgentError("Failed to upload backup") from err + + async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]: + """List backups.""" + try: + return await self._client.async_list_backups() + except (GoogleDriveApiError, HomeAssistantError, TimeoutError) as err: + _LOGGER.error("List backups error: %s", err) + raise BackupAgentError("Failed to list backups") from err + + async def async_get_backup( + self, + backup_id: str, + **kwargs: Any, + ) -> AgentBackup | None: + """Return a backup.""" + backups = await self.async_list_backups() + for backup in backups: + if backup.backup_id == backup_id: + return backup + return None + + async def async_download_backup( + self, + backup_id: str, + **kwargs: Any, + ) -> AsyncIterator[bytes]: + """Download a backup file. + + :param backup_id: The ID of the backup that was returned in async_list_backups. + :return: An async iterator that yields bytes. + """ + _LOGGER.debug("Downloading backup_id: %s", backup_id) + try: + file_id = await self._client.async_get_backup_file_id(backup_id) + if file_id: + _LOGGER.debug("Downloading file_id: %s", file_id) + stream = await self._client.async_download(file_id) + return ChunkAsyncStreamIterator(stream) + except (GoogleDriveApiError, HomeAssistantError, TimeoutError) as err: + _LOGGER.error("Download backup error: %s", err) + raise BackupAgentError("Failed to download backup") from err + _LOGGER.error("Download backup_id: %s not found", backup_id) + raise BackupAgentError("Backup not found") + + async def async_delete_backup( + self, + backup_id: str, + **kwargs: Any, + ) -> None: + """Delete a backup file. + + :param backup_id: The ID of the backup that was returned in async_list_backups. + """ + _LOGGER.debug("Deleting backup_id: %s", backup_id) + try: + file_id = await self._client.async_get_backup_file_id(backup_id) + if file_id: + _LOGGER.debug("Deleting file_id: %s", file_id) + await self._client.async_delete(file_id) + _LOGGER.debug("Deleted backup_id: %s", backup_id) + except (GoogleDriveApiError, HomeAssistantError, TimeoutError) as err: + _LOGGER.error("Delete backup error: %s", err) + raise BackupAgentError("Failed to delete backup") from err diff --git a/homeassistant/components/google_drive/config_flow.py b/homeassistant/components/google_drive/config_flow.py new file mode 100644 index 00000000000..fb74af42210 --- /dev/null +++ b/homeassistant/components/google_drive/config_flow.py @@ -0,0 +1,114 @@ +"""Config flow for the Google Drive integration.""" + +from __future__ import annotations + +from collections.abc import Mapping +import logging +from typing import Any, cast + +from google_drive_api.exceptions import GoogleDriveApiError + +from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN +from homeassistant.helpers import config_entry_oauth2_flow, instance_id +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .api import AsyncConfigFlowAuth, DriveClient +from .const import DOMAIN + +DEFAULT_NAME = "Google Drive" +DRIVE_FOLDER_URL_PREFIX = "https://drive.google.com/drive/folders/" +OAUTH2_SCOPES = [ + "https://www.googleapis.com/auth/drive.file", +] + + +class OAuth2FlowHandler( + config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN +): + """Config flow to handle Google Drive OAuth2 authentication.""" + + DOMAIN = DOMAIN + + @property + def logger(self) -> logging.Logger: + """Return logger.""" + return logging.getLogger(__name__) + + @property + def extra_authorize_data(self) -> dict[str, Any]: + """Extra data that needs to be appended to the authorize url.""" + return { + "scope": " ".join(OAUTH2_SCOPES), + # Add params to ensure we get back a refresh token + "access_type": "offline", + "prompt": "consent", + } + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Perform reauth upon an API authentication error.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm reauth dialog.""" + if user_input is None: + return self.async_show_form(step_id="reauth_confirm") + return await self.async_step_user() + + async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResult: + """Create an entry for the flow, or update existing entry.""" + client = DriveClient( + await instance_id.async_get(self.hass), + AsyncConfigFlowAuth( + async_get_clientsession(self.hass), data[CONF_TOKEN][CONF_ACCESS_TOKEN] + ), + ) + + try: + email_address = await client.async_get_email_address() + except GoogleDriveApiError as err: + self.logger.error("Error getting email address: %s", err) + return self.async_abort( + reason="access_not_configured", + description_placeholders={"message": str(err)}, + ) + except Exception: + self.logger.exception("Unknown error occurred") + return self.async_abort(reason="unknown") + + await self.async_set_unique_id(email_address) + + if self.source == SOURCE_REAUTH: + reauth_entry = self._get_reauth_entry() + self._abort_if_unique_id_mismatch( + reason="wrong_account", + description_placeholders={"email": cast(str, reauth_entry.unique_id)}, + ) + return self.async_update_reload_and_abort(reauth_entry, data=data) + + self._abort_if_unique_id_configured() + + try: + ( + folder_id, + folder_name, + ) = await client.async_create_ha_root_folder_if_not_exists() + except GoogleDriveApiError as err: + self.logger.error("Error creating folder: %s", str(err)) + return self.async_abort( + reason="create_folder_failure", + description_placeholders={"message": str(err)}, + ) + + return self.async_create_entry( + title=DEFAULT_NAME, + data=data, + description_placeholders={ + "folder_name": folder_name, + "url": f"{DRIVE_FOLDER_URL_PREFIX}{folder_id}", + }, + ) diff --git a/homeassistant/components/google_drive/const.py b/homeassistant/components/google_drive/const.py new file mode 100644 index 00000000000..3f0b3e9d610 --- /dev/null +++ b/homeassistant/components/google_drive/const.py @@ -0,0 +1,5 @@ +"""Constants for the Google Drive integration.""" + +from __future__ import annotations + +DOMAIN = "google_drive" diff --git a/homeassistant/components/google_drive/manifest.json b/homeassistant/components/google_drive/manifest.json new file mode 100644 index 00000000000..a1abb9b260a --- /dev/null +++ b/homeassistant/components/google_drive/manifest.json @@ -0,0 +1,14 @@ +{ + "domain": "google_drive", + "name": "Google Drive", + "after_dependencies": ["backup"], + "codeowners": ["@tronikos"], + "config_flow": true, + "dependencies": ["application_credentials"], + "documentation": "https://www.home-assistant.io/integrations/google_drive", + "integration_type": "service", + "iot_class": "cloud_polling", + "loggers": ["google_drive_api"], + "quality_scale": "platinum", + "requirements": ["python-google-drive-api==0.0.2"] +} diff --git a/homeassistant/components/google_drive/quality_scale.yaml b/homeassistant/components/google_drive/quality_scale.yaml new file mode 100644 index 00000000000..70627a6a6d7 --- /dev/null +++ b/homeassistant/components/google_drive/quality_scale.yaml @@ -0,0 +1,113 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: No actions. + appropriate-polling: + status: exempt + comment: No polling. + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: No actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: No entities. + entity-unique-id: + status: exempt + comment: No entities. + has-entity-name: + status: exempt + comment: No entities. + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: done + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: No configuration options. + docs-installation-parameters: done + entity-unavailable: + status: exempt + comment: No entities. + integration-owner: done + log-when-unavailable: + status: exempt + comment: No entities. + parallel-updates: + status: exempt + comment: No actions and no entities. + reauthentication-flow: done + test-coverage: done + + # Gold + devices: + status: exempt + comment: No devices. + diagnostics: + status: exempt + comment: No data to diagnose. + discovery-update-info: + status: exempt + comment: No discovery. + discovery: + status: exempt + comment: No discovery. + docs-data-update: + status: exempt + comment: No updates. + docs-examples: + status: exempt + comment: | + This integration only serves backup. + docs-known-limitations: done + docs-supported-devices: + status: exempt + comment: No devices. + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done + dynamic-devices: + status: exempt + comment: No devices. + entity-category: + status: exempt + comment: No entities. + entity-device-class: + status: exempt + comment: No entities. + entity-disabled-by-default: + status: exempt + comment: No entities. + entity-translations: + status: exempt + comment: No entities. + exception-translations: done + icon-translations: + status: exempt + comment: No entities. + reconfiguration-flow: + status: exempt + comment: No configuration options. + repair-issues: + status: exempt + comment: No repairs. + stale-devices: + status: exempt + comment: No devices. + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/google_drive/strings.json b/homeassistant/components/google_drive/strings.json new file mode 100644 index 00000000000..3441bec4294 --- /dev/null +++ b/homeassistant/components/google_drive/strings.json @@ -0,0 +1,40 @@ +{ + "config": { + "step": { + "pick_implementation": { + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + }, + "reauth_confirm": { + "title": "[%key:common::config_flow::title::reauth%]", + "description": "The Google Drive integration needs to re-authenticate your account" + }, + "auth": { + "title": "Link Google Account" + } + }, + "abort": { + "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", + "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", + "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", + "missing_credentials": "[%key:common::config_flow::abort::oauth2_missing_credentials%]", + "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", + "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", + "user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]", + "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]", + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "access_not_configured": "Unable to access the Google Drive API:\n\n{message}", + "create_folder_failure": "Error while creating Google Drive folder:\n\n{message}", + "unknown": "[%key:common::config_flow::error::unknown%]", + "wrong_account": "Wrong account: Please authenticate with {email}." + }, + "create_entry": { + "default": "Using [{folder_name}]({url}) folder. Feel free to rename it in Google Drive as you wish." + } + }, + "application_credentials": { + "description": "Follow the [instructions]({more_info_url}) for [OAuth consent screen]({oauth_consent_url}) to give Home Assistant access to your Google Drive. You also need to create Application Credentials linked to your account:\n1. Go to [Credentials]({oauth_creds_url}) and select **Create Credentials**.\n1. From the drop-down list select **OAuth client ID**.\n1. Select **Web application** for the Application Type." + } +} diff --git a/homeassistant/generated/application_credentials.py b/homeassistant/generated/application_credentials.py index ef55798b3a0..08fe28e4df5 100644 --- a/homeassistant/generated/application_credentials.py +++ b/homeassistant/generated/application_credentials.py @@ -9,6 +9,7 @@ APPLICATION_CREDENTIALS = [ "geocaching", "google", "google_assistant_sdk", + "google_drive", "google_mail", "google_photos", "google_sheets", diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 12dda0f56be..921910d5046 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -230,6 +230,7 @@ FLOWS = { "google", "google_assistant_sdk", "google_cloud", + "google_drive", "google_generative_ai_conversation", "google_mail", "google_photos", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 53a485a1340..05227e20159 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -2295,6 +2295,12 @@ "iot_class": "cloud_push", "name": "Google Cloud" }, + "google_drive": { + "integration_type": "service", + "config_flow": true, + "iot_class": "cloud_polling", + "name": "Google Drive" + }, "google_generative_ai_conversation": { "integration_type": "service", "config_flow": true, diff --git a/mypy.ini b/mypy.ini index db1ec0a04e4..2139449ba8d 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1926,6 +1926,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.google_drive.*] +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.google_photos.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 66ec0b992f3..d6fac067973 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2384,6 +2384,9 @@ python-gc100==1.0.3a0 # homeassistant.components.gitlab_ci python-gitlab==1.6.0 +# homeassistant.components.google_drive +python-google-drive-api==0.0.2 + # homeassistant.components.analytics_insights python-homeassistant-analytics==0.8.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bf28f289f2e..366edfd23ce 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1929,6 +1929,9 @@ python-fullykiosk==0.0.14 # homeassistant.components.sms # python-gammu==3.2.4 +# homeassistant.components.google_drive +python-google-drive-api==0.0.2 + # homeassistant.components.analytics_insights python-homeassistant-analytics==0.8.1 diff --git a/tests/components/google_drive/__init__.py b/tests/components/google_drive/__init__.py new file mode 100644 index 00000000000..7a55f70a3d6 --- /dev/null +++ b/tests/components/google_drive/__init__.py @@ -0,0 +1 @@ +"""Tests for the Google Drive integration.""" diff --git a/tests/components/google_drive/conftest.py b/tests/components/google_drive/conftest.py new file mode 100644 index 00000000000..479412ddbe2 --- /dev/null +++ b/tests/components/google_drive/conftest.py @@ -0,0 +1,80 @@ +"""PyTest fixtures and test helpers.""" + +from collections.abc import Generator +import time +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from homeassistant.components.application_credentials import ( + ClientCredential, + async_import_client_credential, +) +from homeassistant.components.google_drive.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry + +CLIENT_ID = "1234" +CLIENT_SECRET = "5678" +HA_UUID = "0a123c" +TEST_AGENT_ID = "google_drive.testuser_domain_com" +TEST_USER_EMAIL = "testuser@domain.com" +CONFIG_ENTRY_TITLE = "Google Drive entry title" + + +@pytest.fixture(autouse=True) +async def setup_credentials(hass: HomeAssistant) -> None: + """Fixture to setup credentials.""" + assert await async_setup_component(hass, "application_credentials", {}) + await async_import_client_credential( + hass, + DOMAIN, + ClientCredential(CLIENT_ID, CLIENT_SECRET), + ) + + +@pytest.fixture +def mock_api() -> Generator[MagicMock]: + """Return a mocked GoogleDriveApi.""" + with patch( + "homeassistant.components.google_drive.api.GoogleDriveApi" + ) as mock_api_cl: + mock_api = mock_api_cl.return_value + yield mock_api + + +@pytest.fixture(autouse=True) +def mock_instance_id() -> Generator[AsyncMock]: + """Mock instance_id.""" + with patch( + "homeassistant.components.google_drive.config_flow.instance_id.async_get", + return_value=HA_UUID, + ): + yield + + +@pytest.fixture(name="expires_at") +def mock_expires_at() -> int: + """Fixture to set the oauth token expiration time.""" + return time.time() + 3600 + + +@pytest.fixture(name="config_entry") +def mock_config_entry(expires_at: int) -> MockConfigEntry: + """Fixture for MockConfigEntry.""" + return MockConfigEntry( + domain=DOMAIN, + unique_id=TEST_USER_EMAIL, + title=CONFIG_ENTRY_TITLE, + data={ + "auth_implementation": DOMAIN, + "token": { + "access_token": "mock-access-token", + "refresh_token": "mock-refresh-token", + "expires_at": expires_at, + "scope": "https://www.googleapis.com/auth/drive.file", + }, + }, + ) diff --git a/tests/components/google_drive/snapshots/test_backup.ambr b/tests/components/google_drive/snapshots/test_backup.ambr new file mode 100644 index 00000000000..0832682b74d --- /dev/null +++ b/tests/components/google_drive/snapshots/test_backup.ambr @@ -0,0 +1,237 @@ +# serializer version: 1 +# name: test_agents_delete + list([ + tuple( + 'list_files', + tuple( + ), + dict({ + 'params': dict({ + 'fields': 'files(id,name)', + 'q': "properties has { key='home_assistant' and value='root' } and properties has { key='instance_id' and value='0a123c' } and trashed=false", + }), + }), + ), + tuple( + 'list_files', + tuple( + ), + dict({ + 'params': dict({ + 'fields': 'files(id)', + 'q': "properties has { key='home_assistant' and value='backup' } and properties has { key='instance_id' and value='0a123c' } and properties has { key='backup_id' and value='test-backup' }", + }), + }), + ), + tuple( + 'delete_file', + tuple( + 'backup-file-id', + ), + dict({ + }), + ), + ]) +# --- +# name: test_agents_download + list([ + tuple( + 'list_files', + tuple( + ), + dict({ + 'params': dict({ + 'fields': 'files(id,name)', + 'q': "properties has { key='home_assistant' and value='root' } and properties has { key='instance_id' and value='0a123c' } and trashed=false", + }), + }), + ), + tuple( + 'list_files', + tuple( + ), + dict({ + 'params': dict({ + 'fields': 'files(description)', + 'q': "properties has { key='home_assistant' and value='backup' } and properties has { key='instance_id' and value='0a123c' } and trashed=false", + }), + }), + ), + tuple( + 'list_files', + tuple( + ), + dict({ + 'params': dict({ + 'fields': 'files(id)', + 'q': "properties has { key='home_assistant' and value='backup' } and properties has { key='instance_id' and value='0a123c' } and properties has { key='backup_id' and value='test-backup' }", + }), + }), + ), + tuple( + 'get_file_content', + tuple( + 'backup-file-id', + ), + dict({ + 'timeout': dict({ + 'ceil_threshold': 5, + 'connect': None, + 'sock_connect': None, + 'sock_read': None, + 'total': 43200, + }), + }), + ), + ]) +# --- +# name: test_agents_list_backups + list([ + tuple( + 'list_files', + tuple( + ), + dict({ + 'params': dict({ + 'fields': 'files(id,name)', + 'q': "properties has { key='home_assistant' and value='root' } and properties has { key='instance_id' and value='0a123c' } and trashed=false", + }), + }), + ), + tuple( + 'list_files', + tuple( + ), + dict({ + 'params': dict({ + 'fields': 'files(description)', + 'q': "properties has { key='home_assistant' and value='backup' } and properties has { key='instance_id' and value='0a123c' } and trashed=false", + }), + }), + ), + ]) +# --- +# name: test_agents_upload + list([ + tuple( + 'list_files', + tuple( + ), + dict({ + 'params': dict({ + 'fields': 'files(id,name)', + 'q': "properties has { key='home_assistant' and value='root' } and properties has { key='instance_id' and value='0a123c' } and trashed=false", + }), + }), + ), + tuple( + 'list_files', + tuple( + ), + dict({ + 'params': dict({ + 'fields': 'files(id,name)', + 'q': "properties has { key='home_assistant' and value='root' } and properties has { key='instance_id' and value='0a123c' } and trashed=false", + }), + }), + ), + tuple( + 'upload_file', + tuple( + dict({ + 'description': '{"addons": [{"name": "Test", "slug": "test", "version": "1.0.0"}], "backup_id": "test-backup", "date": "2025-01-01T01:23:45.678Z", "database_included": true, "extra_metadata": {"with_automatic_settings": false}, "folders": [], "homeassistant_included": true, "homeassistant_version": "2024.12.0", "name": "Test", "protected": false, "size": 987}', + 'name': 'Test 2025-01-01T01:23:45.678Z.tar', + 'parents': list([ + 'HA folder ID', + ]), + 'properties': dict({ + 'backup_id': 'test-backup', + 'home_assistant': 'backup', + 'instance_id': '0a123c', + }), + }), + "CoreBackupReaderWriter.async_receive_backup..open_backup() -> 'AsyncIterator[bytes]'", + ), + dict({ + 'timeout': dict({ + 'ceil_threshold': 5, + 'connect': None, + 'sock_connect': None, + 'sock_read': None, + 'total': 43200, + }), + }), + ), + ]) +# --- +# name: test_agents_upload_create_folder_if_missing + list([ + tuple( + 'list_files', + tuple( + ), + dict({ + 'params': dict({ + 'fields': 'files(id,name)', + 'q': "properties has { key='home_assistant' and value='root' } and properties has { key='instance_id' and value='0a123c' } and trashed=false", + }), + }), + ), + tuple( + 'list_files', + tuple( + ), + dict({ + 'params': dict({ + 'fields': 'files(id,name)', + 'q': "properties has { key='home_assistant' and value='root' } and properties has { key='instance_id' and value='0a123c' } and trashed=false", + }), + }), + ), + tuple( + 'create_file', + tuple( + ), + dict({ + 'json': dict({ + 'mimeType': 'application/vnd.google-apps.folder', + 'name': 'Home Assistant', + 'properties': dict({ + 'home_assistant': 'root', + 'instance_id': '0a123c', + }), + }), + 'params': dict({ + 'fields': 'id,name', + }), + }), + ), + tuple( + 'upload_file', + tuple( + dict({ + 'description': '{"addons": [{"name": "Test", "slug": "test", "version": "1.0.0"}], "backup_id": "test-backup", "date": "2025-01-01T01:23:45.678Z", "database_included": true, "extra_metadata": {"with_automatic_settings": false}, "folders": [], "homeassistant_included": true, "homeassistant_version": "2024.12.0", "name": "Test", "protected": false, "size": 987}', + 'name': 'Test 2025-01-01T01:23:45.678Z.tar', + 'parents': list([ + 'new folder id', + ]), + 'properties': dict({ + 'backup_id': 'test-backup', + 'home_assistant': 'backup', + 'instance_id': '0a123c', + }), + }), + "CoreBackupReaderWriter.async_receive_backup..open_backup() -> 'AsyncIterator[bytes]'", + ), + dict({ + 'timeout': dict({ + 'ceil_threshold': 5, + 'connect': None, + 'sock_connect': None, + 'sock_read': None, + 'total': 43200, + }), + }), + ), + ]) +# --- diff --git a/tests/components/google_drive/snapshots/test_config_flow.ambr b/tests/components/google_drive/snapshots/test_config_flow.ambr new file mode 100644 index 00000000000..68e5416c5ec --- /dev/null +++ b/tests/components/google_drive/snapshots/test_config_flow.ambr @@ -0,0 +1,44 @@ +# serializer version: 1 +# name: test_full_flow + list([ + tuple( + 'get_user', + tuple( + ), + dict({ + 'params': dict({ + 'fields': 'user(emailAddress)', + }), + }), + ), + tuple( + 'list_files', + tuple( + ), + dict({ + 'params': dict({ + 'fields': 'files(id,name)', + 'q': "properties has { key='home_assistant' and value='root' } and properties has { key='instance_id' and value='0a123c' } and trashed=false", + }), + }), + ), + tuple( + 'create_file', + tuple( + ), + dict({ + 'json': dict({ + 'mimeType': 'application/vnd.google-apps.folder', + 'name': 'Home Assistant', + 'properties': dict({ + 'home_assistant': 'root', + 'instance_id': '0a123c', + }), + }), + 'params': dict({ + 'fields': 'id,name', + }), + }), + ), + ]) +# --- diff --git a/tests/components/google_drive/test_backup.py b/tests/components/google_drive/test_backup.py new file mode 100644 index 00000000000..765f6bba887 --- /dev/null +++ b/tests/components/google_drive/test_backup.py @@ -0,0 +1,461 @@ +"""Test the Google Drive backup platform.""" + +from io import StringIO +import json +from typing import Any +from unittest.mock import AsyncMock, MagicMock, Mock, patch + +from aiohttp import ClientResponse +from google_drive_api.exceptions import GoogleDriveApiError +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.backup import ( + DOMAIN as BACKUP_DOMAIN, + AddonInfo, + AgentBackup, +) +from homeassistant.components.google_drive import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from .conftest import CONFIG_ENTRY_TITLE, TEST_AGENT_ID + +from tests.common import MockConfigEntry +from tests.test_util.aiohttp import mock_stream +from tests.typing import ClientSessionGenerator, WebSocketGenerator + +FOLDER_ID = "google-folder-id" +TEST_AGENT_BACKUP = AgentBackup( + addons=[AddonInfo(name="Test", slug="test", version="1.0.0")], + backup_id="test-backup", + database_included=True, + date="2025-01-01T01:23:45.678Z", + extra_metadata={ + "with_automatic_settings": False, + }, + folders=[], + homeassistant_included=True, + homeassistant_version="2024.12.0", + name="Test", + protected=False, + size=987, +) +TEST_AGENT_BACKUP_RESULT = { + "addons": [{"name": "Test", "slug": "test", "version": "1.0.0"}], + "backup_id": "test-backup", + "database_included": True, + "date": "2025-01-01T01:23:45.678Z", + "folders": [], + "homeassistant_included": True, + "homeassistant_version": "2024.12.0", + "name": "Test", + "protected": False, + "size": 987, + "agent_ids": [TEST_AGENT_ID], + "failed_agent_ids": [], + "with_automatic_settings": None, +} + + +@pytest.fixture(autouse=True) +async def setup_integration( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_api: MagicMock, +) -> None: + """Set up Google Drive integration.""" + config_entry.add_to_hass(hass) + assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}}) + mock_api.list_files = AsyncMock( + return_value={"files": [{"id": "HA folder ID", "name": "HA folder name"}]} + ) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + +async def test_agents_info( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test backup agent info.""" + client = await hass_ws_client(hass) + + await client.send_json_auto_id({"type": "backup/agents/info"}) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == { + "agents": [ + {"agent_id": "backup.local", "name": "local"}, + {"agent_id": TEST_AGENT_ID, "name": CONFIG_ENTRY_TITLE}, + ], + } + + config_entry = hass.config_entries.async_entries(DOMAIN)[0] + await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() + + await client.send_json_auto_id({"type": "backup/agents/info"}) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == { + "agents": [{"agent_id": "backup.local", "name": "local"}] + } + + +async def test_agents_list_backups( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_api: MagicMock, + snapshot: SnapshotAssertion, +) -> None: + """Test agent list backups.""" + mock_api.list_files = AsyncMock( + return_value={ + "files": [{"description": json.dumps(TEST_AGENT_BACKUP.as_dict())}] + } + ) + + client = await hass_ws_client(hass) + await client.send_json_auto_id({"type": "backup/info"}) + response = await client.receive_json() + + assert response["success"] + assert response["result"]["agent_errors"] == {} + assert response["result"]["backups"] == [TEST_AGENT_BACKUP_RESULT] + assert [tuple(mock_call) for mock_call in mock_api.mock_calls] == snapshot + + +async def test_agents_list_backups_fail( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_api: MagicMock, +) -> None: + """Test agent list backups fails.""" + mock_api.list_files = AsyncMock(side_effect=GoogleDriveApiError("some error")) + + client = await hass_ws_client(hass) + await client.send_json_auto_id({"type": "backup/info"}) + response = await client.receive_json() + + assert response["success"] + assert response["result"]["backups"] == [] + assert response["result"]["agent_errors"] == { + TEST_AGENT_ID: "Failed to list backups" + } + + +@pytest.mark.parametrize( + ("backup_id", "expected_result"), + [ + (TEST_AGENT_BACKUP.backup_id, TEST_AGENT_BACKUP_RESULT), + ("12345", None), + ], + ids=["found", "not_found"], +) +async def test_agents_get_backup( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_api: MagicMock, + backup_id: str, + expected_result: dict[str, Any] | None, +) -> None: + """Test agent get backup.""" + mock_api.list_files = AsyncMock( + return_value={ + "files": [{"description": json.dumps(TEST_AGENT_BACKUP.as_dict())}] + } + ) + client = await hass_ws_client(hass) + await client.send_json_auto_id({"type": "backup/details", "backup_id": backup_id}) + response = await client.receive_json() + + assert response["success"] + assert response["result"]["agent_errors"] == {} + assert response["result"]["backup"] == expected_result + + +async def test_agents_download( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_api: MagicMock, + snapshot: SnapshotAssertion, +) -> None: + """Test agent download backup.""" + mock_api.list_files = AsyncMock( + side_effect=[ + {"files": [{"description": json.dumps(TEST_AGENT_BACKUP.as_dict())}]}, + {"files": [{"id": "backup-file-id"}]}, + ] + ) + mock_response = AsyncMock(spec=ClientResponse) + mock_response.content = mock_stream(b"backup data") + mock_api.get_file_content = AsyncMock(return_value=mock_response) + + client = await hass_client() + resp = await client.get( + f"/api/backup/download/{TEST_AGENT_BACKUP.backup_id}?agent_id={TEST_AGENT_ID}" + ) + assert resp.status == 200 + assert await resp.content.read() == b"backup data" + + assert [tuple(mock_call) for mock_call in mock_api.mock_calls] == snapshot + + +async def test_agents_download_fail( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_api: MagicMock, +) -> None: + """Test agent download backup fails.""" + mock_api.list_files = AsyncMock( + side_effect=[ + {"files": [{"description": json.dumps(TEST_AGENT_BACKUP.as_dict())}]}, + {"files": [{"id": "backup-file-id"}]}, + ] + ) + mock_response = AsyncMock(spec=ClientResponse) + mock_response.content = mock_stream(b"backup data") + mock_api.get_file_content = AsyncMock(side_effect=GoogleDriveApiError("some error")) + + client = await hass_client() + resp = await client.get( + f"/api/backup/download/{TEST_AGENT_BACKUP.backup_id}?agent_id={TEST_AGENT_ID}" + ) + assert resp.status == 500 + content = await resp.content.read() + assert "Failed to download backup" in content.decode() + + +async def test_agents_download_file_not_found( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_api: MagicMock, +) -> None: + """Test agent download backup raises error if not found.""" + mock_api.list_files = AsyncMock( + side_effect=[ + {"files": [{"description": json.dumps(TEST_AGENT_BACKUP.as_dict())}]}, + {"files": []}, + ] + ) + + client = await hass_client() + resp = await client.get( + f"/api/backup/download/{TEST_AGENT_BACKUP.backup_id}?agent_id={TEST_AGENT_ID}" + ) + assert resp.status == 500 + content = await resp.content.read() + assert "Backup not found" in content.decode() + + +async def test_agents_download_metadata_not_found( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_api: MagicMock, +) -> None: + """Test agent download backup raises error if not found.""" + mock_api.list_files = AsyncMock( + return_value={ + "files": [{"description": json.dumps(TEST_AGENT_BACKUP.as_dict())}] + } + ) + + client = await hass_client() + backup_id = "1234" + assert backup_id != TEST_AGENT_BACKUP.backup_id + + resp = await client.get( + f"/api/backup/download/{backup_id}?agent_id={TEST_AGENT_ID}" + ) + assert resp.status == 404 + assert await resp.content.read() == b"" + + +async def test_agents_upload( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + caplog: pytest.LogCaptureFixture, + mock_api: MagicMock, + snapshot: SnapshotAssertion, +) -> None: + """Test agent upload backup.""" + mock_api.upload_file = AsyncMock(return_value=None) + + client = await hass_client() + + with ( + patch( + "homeassistant.components.backup.manager.BackupManager.async_get_backup", + ) as fetch_backup, + patch( + "homeassistant.components.backup.manager.read_backup", + return_value=TEST_AGENT_BACKUP, + ), + patch("pathlib.Path.open") as mocked_open, + ): + mocked_open.return_value.read = Mock(side_effect=[b"test", b""]) + fetch_backup.return_value = TEST_AGENT_BACKUP + resp = await client.post( + f"/api/backup/upload?agent_id={TEST_AGENT_ID}", + data={"file": StringIO("test")}, + ) + + assert resp.status == 201 + assert f"Uploading backup: {TEST_AGENT_BACKUP.backup_id}" in caplog.text + assert f"Uploaded backup: {TEST_AGENT_BACKUP.backup_id}" in caplog.text + + mock_api.upload_file.assert_called_once() + assert [tuple(mock_call) for mock_call in mock_api.mock_calls] == snapshot + + +async def test_agents_upload_create_folder_if_missing( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + caplog: pytest.LogCaptureFixture, + mock_api: MagicMock, + snapshot: SnapshotAssertion, +) -> None: + """Test agent upload backup creates folder if missing.""" + mock_api.list_files = AsyncMock(return_value={"files": []}) + mock_api.create_file = AsyncMock( + return_value={"id": "new folder id", "name": "Home Assistant"} + ) + mock_api.upload_file = AsyncMock(return_value=None) + + client = await hass_client() + + with ( + patch( + "homeassistant.components.backup.manager.BackupManager.async_get_backup", + ) as fetch_backup, + patch( + "homeassistant.components.backup.manager.read_backup", + return_value=TEST_AGENT_BACKUP, + ), + patch("pathlib.Path.open") as mocked_open, + ): + mocked_open.return_value.read = Mock(side_effect=[b"test", b""]) + fetch_backup.return_value = TEST_AGENT_BACKUP + resp = await client.post( + f"/api/backup/upload?agent_id={TEST_AGENT_ID}", + data={"file": StringIO("test")}, + ) + + assert resp.status == 201 + assert f"Uploading backup: {TEST_AGENT_BACKUP.backup_id}" in caplog.text + assert f"Uploaded backup: {TEST_AGENT_BACKUP.backup_id}" in caplog.text + + mock_api.create_file.assert_called_once() + mock_api.upload_file.assert_called_once() + assert [tuple(mock_call) for mock_call in mock_api.mock_calls] == snapshot + + +async def test_agents_upload_fail( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + caplog: pytest.LogCaptureFixture, + mock_api: MagicMock, +) -> None: + """Test agent upload backup fails.""" + mock_api.upload_file = AsyncMock(side_effect=GoogleDriveApiError("some error")) + + client = await hass_client() + + with ( + patch( + "homeassistant.components.backup.manager.BackupManager.async_get_backup", + ) as fetch_backup, + patch( + "homeassistant.components.backup.manager.read_backup", + return_value=TEST_AGENT_BACKUP, + ), + patch("pathlib.Path.open") as mocked_open, + ): + mocked_open.return_value.read = Mock(side_effect=[b"test", b""]) + fetch_backup.return_value = TEST_AGENT_BACKUP + resp = await client.post( + f"/api/backup/upload?agent_id={TEST_AGENT_ID}", + data={"file": StringIO("test")}, + ) + await hass.async_block_till_done() + + assert resp.status == 201 + assert "Upload backup error: some error" in caplog.text + + +async def test_agents_delete( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_api: MagicMock, + snapshot: SnapshotAssertion, +) -> None: + """Test agent delete backup.""" + mock_api.list_files = AsyncMock(return_value={"files": [{"id": "backup-file-id"}]}) + mock_api.delete_file = AsyncMock(return_value=None) + + client = await hass_ws_client(hass) + await client.send_json_auto_id( + { + "type": "backup/delete", + "backup_id": TEST_AGENT_BACKUP.backup_id, + } + ) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == {"agent_errors": {}} + + mock_api.delete_file.assert_called_once() + assert [tuple(mock_call) for mock_call in mock_api.mock_calls] == snapshot + + +async def test_agents_delete_fail( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_api: MagicMock, +) -> None: + """Test agent delete backup fails.""" + mock_api.list_files = AsyncMock(return_value={"files": [{"id": "backup-file-id"}]}) + mock_api.delete_file = AsyncMock(side_effect=GoogleDriveApiError("some error")) + + client = await hass_ws_client(hass) + await client.send_json_auto_id( + { + "type": "backup/delete", + "backup_id": TEST_AGENT_BACKUP.backup_id, + } + ) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == { + "agent_errors": {TEST_AGENT_ID: "Failed to delete backup"} + } + + +async def test_agents_delete_not_found( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_api: MagicMock, +) -> None: + """Test agent delete backup not found.""" + mock_api.list_files = AsyncMock(return_value={"files": []}) + + client = await hass_ws_client(hass) + backup_id = "1234" + + await client.send_json_auto_id( + { + "type": "backup/delete", + "backup_id": backup_id, + } + ) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == {"agent_errors": {}} + + mock_api.delete_file.assert_not_called() diff --git a/tests/components/google_drive/test_config_flow.py b/tests/components/google_drive/test_config_flow.py new file mode 100644 index 00000000000..10f73d53a66 --- /dev/null +++ b/tests/components/google_drive/test_config_flow.py @@ -0,0 +1,363 @@ +"""Test the Google Drive config flow.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +from google_drive_api.exceptions import GoogleDriveApiError +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.google_drive.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import config_entry_oauth2_flow + +from .conftest import CLIENT_ID, TEST_USER_EMAIL + +from tests.common import MockConfigEntry +from tests.test_util.aiohttp import AiohttpClientMocker +from tests.typing import ClientSessionGenerator + +GOOGLE_AUTH_URI = "https://accounts.google.com/o/oauth2/v2/auth" +GOOGLE_TOKEN_URI = "https://oauth2.googleapis.com/token" +FOLDER_ID = "google-folder-id" +FOLDER_NAME = "folder name" +TITLE = "Google Drive" + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_full_flow( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_api: MagicMock, + snapshot: SnapshotAssertion, +) -> None: + """Check full flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + assert result["url"] == ( + f"{GOOGLE_AUTH_URI}?response_type=code&client_id={CLIENT_ID}" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}&scope=https://www.googleapis.com/auth/drive.file" + "&access_type=offline&prompt=consent" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + # Prepare API responses + mock_api.get_user = AsyncMock( + return_value={"user": {"emailAddress": TEST_USER_EMAIL}} + ) + mock_api.list_files = AsyncMock(return_value={"files": []}) + mock_api.create_file = AsyncMock( + return_value={"id": FOLDER_ID, "name": FOLDER_NAME} + ) + + aioclient_mock.post( + GOOGLE_TOKEN_URI, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + }, + ) + + with patch( + "homeassistant.components.google_drive.async_setup_entry", return_value=True + ) as mock_setup: + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert len(mock_setup.mock_calls) == 1 + assert len(aioclient_mock.mock_calls) == 1 + assert [tuple(mock_call) for mock_call in mock_api.mock_calls] == snapshot + + assert result.get("type") is FlowResultType.CREATE_ENTRY + assert result.get("title") == TITLE + assert result.get("description_placeholders") == { + "folder_name": FOLDER_NAME, + "url": f"https://drive.google.com/drive/folders/{FOLDER_ID}", + } + assert "result" in result + assert result.get("result").unique_id == TEST_USER_EMAIL + assert "token" in result.get("result").data + assert result.get("result").data["token"].get("access_token") == "mock-access-token" + assert ( + result.get("result").data["token"].get("refresh_token") == "mock-refresh-token" + ) + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_create_folder_error( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_api: MagicMock, +) -> None: + """Test case where creating the folder fails.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + assert result["url"] == ( + f"{GOOGLE_AUTH_URI}?response_type=code&client_id={CLIENT_ID}" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}&scope=https://www.googleapis.com/auth/drive.file" + "&access_type=offline&prompt=consent" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + # Prepare API responses + mock_api.get_user = AsyncMock( + return_value={"user": {"emailAddress": TEST_USER_EMAIL}} + ) + mock_api.list_files = AsyncMock(return_value={"files": []}) + mock_api.create_file = AsyncMock(side_effect=GoogleDriveApiError("some error")) + + aioclient_mock.post( + GOOGLE_TOKEN_URI, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + }, + ) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result.get("type") is FlowResultType.ABORT + assert result.get("reason") == "create_folder_failure" + assert result.get("description_placeholders") == {"message": "some error"} + + +@pytest.mark.usefixtures("current_request_with_host") +@pytest.mark.parametrize( + ("exception", "expected_abort_reason", "expected_placeholders"), + [ + ( + GoogleDriveApiError("some error"), + "access_not_configured", + {"message": "some error"}, + ), + (Exception, "unknown", None), + ], + ids=["api_not_enabled", "general_exception"], +) +async def test_get_email_error( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_api: MagicMock, + exception: Exception, + expected_abort_reason, + expected_placeholders, +) -> None: + """Test case where getting the email address fails.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + assert result["url"] == ( + f"{GOOGLE_AUTH_URI}?response_type=code&client_id={CLIENT_ID}" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}&scope=https://www.googleapis.com/auth/drive.file" + "&access_type=offline&prompt=consent" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + # Prepare API responses + mock_api.get_user = AsyncMock(side_effect=exception) + aioclient_mock.post( + GOOGLE_TOKEN_URI, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + }, + ) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result.get("type") is FlowResultType.ABORT + assert result.get("reason") == expected_abort_reason + assert result.get("description_placeholders") == expected_placeholders + + +@pytest.mark.usefixtures("current_request_with_host") +@pytest.mark.parametrize( + ( + "new_email", + "expected_abort_reason", + "expected_placeholders", + "expected_access_token", + "expected_setup_calls", + ), + [ + (TEST_USER_EMAIL, "reauth_successful", None, "updated-access-token", 1), + ( + "other.user@domain.com", + "wrong_account", + {"email": TEST_USER_EMAIL}, + "mock-access-token", + 0, + ), + ], + ids=["reauth_successful", "wrong_account"], +) +async def test_reauth( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + config_entry: MockConfigEntry, + aioclient_mock: AiohttpClientMocker, + mock_api: MagicMock, + new_email: str, + expected_abort_reason: str, + expected_placeholders: dict[str, str] | None, + expected_access_token: str, + expected_setup_calls: int, +) -> None: + """Test the reauthentication flow.""" + config_entry.add_to_hass(hass) + result = await config_entry.start_reauth_flow(hass) + + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + assert result["url"] == ( + f"{GOOGLE_AUTH_URI}?response_type=code&client_id={CLIENT_ID}" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}&scope=https://www.googleapis.com/auth/drive.file" + "&access_type=offline&prompt=consent" + ) + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + # Prepare API responses + mock_api.get_user = AsyncMock(return_value={"user": {"emailAddress": new_email}}) + aioclient_mock.post( + GOOGLE_TOKEN_URI, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "updated-access-token", + "type": "Bearer", + "expires_in": 60, + }, + ) + + with patch( + "homeassistant.components.google_drive.async_setup_entry", return_value=True + ) as mock_setup: + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + await hass.async_block_till_done() + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert len(mock_setup.mock_calls) == expected_setup_calls + + assert result.get("type") is FlowResultType.ABORT + assert result.get("reason") == expected_abort_reason + assert result.get("description_placeholders") == expected_placeholders + + assert config_entry.unique_id == TEST_USER_EMAIL + assert "token" in config_entry.data + + # Verify access token is refreshed + assert config_entry.data["token"].get("access_token") == expected_access_token + assert config_entry.data["token"].get("refresh_token") == "mock-refresh-token" + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_already_configured( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + config_entry: MockConfigEntry, + aioclient_mock: AiohttpClientMocker, + mock_api: MagicMock, +) -> None: + """Test already configured account.""" + config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + assert result["url"] == ( + f"{GOOGLE_AUTH_URI}?response_type=code&client_id={CLIENT_ID}" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}&scope=https://www.googleapis.com/auth/drive.file" + "&access_type=offline&prompt=consent" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + # Prepare API responses + mock_api.get_user = AsyncMock( + return_value={"user": {"emailAddress": TEST_USER_EMAIL}} + ) + aioclient_mock.post( + GOOGLE_TOKEN_URI, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + }, + ) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result.get("type") is FlowResultType.ABORT + assert result.get("reason") == "already_configured" diff --git a/tests/components/google_drive/test_init.py b/tests/components/google_drive/test_init.py new file mode 100644 index 00000000000..8173e00fb54 --- /dev/null +++ b/tests/components/google_drive/test_init.py @@ -0,0 +1,164 @@ +"""Tests for Google Drive.""" + +from collections.abc import Awaitable, Callable, Coroutine +import http +import time +from typing import Any +from unittest.mock import AsyncMock, MagicMock + +from google_drive_api.exceptions import GoogleDriveApiError +import pytest + +from homeassistant.components.google_drive.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.test_util.aiohttp import AiohttpClientMocker + +type ComponentSetup = Callable[[], Awaitable[None]] + + +@pytest.fixture(name="setup_integration") +async def mock_setup_integration( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> Callable[[], Coroutine[Any, Any, None]]: + """Fixture for setting up the component.""" + config_entry.add_to_hass(hass) + + async def func() -> None: + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + return func + + +async def test_setup_success( + hass: HomeAssistant, + setup_integration: ComponentSetup, + mock_api: MagicMock, +) -> None: + """Test successful setup and unload.""" + # Setup looks up existing folder to make sure it still exists + mock_api.list_files = AsyncMock( + return_value={"files": [{"id": "HA folder ID", "name": "HA folder name"}]} + ) + + await setup_integration() + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + assert entries[0].state is ConfigEntryState.LOADED + + await hass.config_entries.async_unload(entries[0].entry_id) + await hass.async_block_till_done() + + assert entries[0].state is ConfigEntryState.NOT_LOADED + + +async def test_create_folder_if_missing( + hass: HomeAssistant, + setup_integration: ComponentSetup, + mock_api: MagicMock, +) -> None: + """Test folder is created if missing.""" + # Setup looks up existing folder to make sure it still exists + # and creates it if missing + mock_api.list_files = AsyncMock(return_value={"files": []}) + mock_api.create_file = AsyncMock( + return_value={"id": "new folder id", "name": "Home Assistant"} + ) + + await setup_integration() + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + assert entries[0].state is ConfigEntryState.LOADED + + mock_api.list_files.assert_called_once() + mock_api.create_file.assert_called_once() + + +async def test_setup_error( + hass: HomeAssistant, + setup_integration: ComponentSetup, + mock_api: MagicMock, +) -> None: + """Test setup error.""" + # Simulate failure looking up existing folder + mock_api.list_files = AsyncMock(side_effect=GoogleDriveApiError("some error")) + + await setup_integration() + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + assert entries[0].state is ConfigEntryState.SETUP_RETRY + + +@pytest.mark.parametrize("expires_at", [time.time() - 3600], ids=["expired"]) +async def test_expired_token_refresh_success( + hass: HomeAssistant, + setup_integration: ComponentSetup, + aioclient_mock: AiohttpClientMocker, + mock_api: MagicMock, +) -> None: + """Test expired token is refreshed.""" + # Setup looks up existing folder to make sure it still exists + mock_api.list_files = AsyncMock( + return_value={"files": [{"id": "HA folder ID", "name": "HA folder name"}]} + ) + aioclient_mock.post( + "https://oauth2.googleapis.com/token", + json={ + "access_token": "updated-access-token", + "refresh_token": "updated-refresh-token", + "expires_at": time.time() + 3600, + "expires_in": 3600, + }, + ) + + await setup_integration() + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + assert entries[0].state is ConfigEntryState.LOADED + assert entries[0].data["token"]["access_token"] == "updated-access-token" + assert entries[0].data["token"]["expires_in"] == 3600 + + +@pytest.mark.parametrize( + ("expires_at", "status", "expected_state"), + [ + ( + time.time() - 3600, + http.HTTPStatus.UNAUTHORIZED, + ConfigEntryState.SETUP_ERROR, + ), + ( + time.time() - 3600, + http.HTTPStatus.INTERNAL_SERVER_ERROR, + ConfigEntryState.SETUP_RETRY, + ), + ], + ids=["failure_requires_reauth", "transient_failure"], +) +async def test_expired_token_refresh_failure( + hass: HomeAssistant, + setup_integration: ComponentSetup, + aioclient_mock: AiohttpClientMocker, + status: http.HTTPStatus, + expected_state: ConfigEntryState, +) -> None: + """Test failure while refreshing token with a transient error.""" + + aioclient_mock.post( + "https://oauth2.googleapis.com/token", + status=status, + ) + + await setup_integration() + + # Verify a transient failure has occurred + entries = hass.config_entries.async_entries(DOMAIN) + assert entries[0].state is expected_state From a135b4bb432ec47c1900771277fc5dab2acd970e Mon Sep 17 00:00:00 2001 From: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> Date: Wed, 29 Jan 2025 00:28:13 -0600 Subject: [PATCH 0193/3148] Enable strict typing for HEOS (#136797) --- .strict-typing | 1 + homeassistant/components/heos/__init__.py | 2 +- homeassistant/components/heos/coordinator.py | 7 +++++-- homeassistant/components/heos/media_player.py | 4 ++-- homeassistant/components/heos/quality_scale.yaml | 2 +- homeassistant/components/heos/services.py | 2 +- mypy.ini | 10 ++++++++++ 7 files changed, 21 insertions(+), 7 deletions(-) diff --git a/.strict-typing b/.strict-typing index 1a5450d8eb4..4cebcb6f445 100644 --- a/.strict-typing +++ b/.strict-typing @@ -228,6 +228,7 @@ homeassistant.components.guardian.* homeassistant.components.habitica.* homeassistant.components.hardkernel.* homeassistant.components.hardware.* +homeassistant.components.heos.* homeassistant.components.here_travel_time.* homeassistant.components.history.* homeassistant.components.history_stats.* diff --git a/homeassistant/components/heos/__init__.py b/homeassistant/components/heos/__init__.py index b119ea83064..f76b95c271e 100644 --- a/homeassistant/components/heos/__init__.py +++ b/homeassistant/components/heos/__init__.py @@ -40,7 +40,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: HeosConfigEntry) -> bool ): for domain, player_id in device.identifiers: if domain == DOMAIN and not isinstance(player_id, str): - device_registry.async_update_device( + device_registry.async_update_device( # type: ignore[unreachable] device.id, new_identifiers={(DOMAIN, str(player_id))} ) break diff --git a/homeassistant/components/heos/coordinator.py b/homeassistant/components/heos/coordinator.py index dd0e0a19d0b..dc8989fd55b 100644 --- a/homeassistant/components/heos/coordinator.py +++ b/homeassistant/components/heos/coordinator.py @@ -8,6 +8,7 @@ entities to update. Entities subscribe to entity-specific updates within the ent from collections.abc import Callable, Sequence from datetime import datetime, timedelta import logging +from typing import Any from pyheos import ( Credentials, @@ -23,7 +24,7 @@ from pyheos import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform -from homeassistant.core import HassJob, HomeAssistant, callback +from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.event import async_call_later @@ -106,7 +107,9 @@ class HeosCoordinator(DataUpdateCoordinator[None]): await self.heos.disconnect() await super().async_shutdown() - def async_add_listener(self, update_callback, context=None) -> Callable[[], None]: + def async_add_listener( + self, update_callback: CALLBACK_TYPE, context: Any = None + ) -> Callable[[], None]: """Add a listener for the coordinator.""" remove_listener = super().async_add_listener(update_callback, context) # Update entities so group_member entity_ids fully populate. diff --git a/homeassistant/components/heos/media_player.py b/homeassistant/components/heos/media_player.py index 2f0945635c5..b53cb94d8e7 100644 --- a/homeassistant/components/heos/media_player.py +++ b/homeassistant/components/heos/media_player.py @@ -135,7 +135,7 @@ class HeosMediaPlayer(CoordinatorEntity[HeosCoordinator], MediaPlayerEntity): def __init__(self, coordinator: HeosCoordinator, player: HeosPlayer) -> None: """Initialize.""" - self._media_position_updated_at = None + self._media_position_updated_at: datetime | None = None self._player: HeosPlayer = player self._attr_unique_id = str(player.player_id) model_parts = player.model.split(maxsplit=1) @@ -151,7 +151,7 @@ class HeosMediaPlayer(CoordinatorEntity[HeosCoordinator], MediaPlayerEntity): ) super().__init__(coordinator, context=player.player_id) - async def _player_update(self, event): + async def _player_update(self, event: str) -> None: """Handle player attribute updated.""" if event == heos_const.EVENT_PLAYER_NOW_PLAYING_PROGRESS: self._media_position_updated_at = utcnow() diff --git a/homeassistant/components/heos/quality_scale.yaml b/homeassistant/components/heos/quality_scale.yaml index cc110c627f0..f5066d0a743 100644 --- a/homeassistant/components/heos/quality_scale.yaml +++ b/homeassistant/components/heos/quality_scale.yaml @@ -64,4 +64,4 @@ rules: inject-websession: status: done comment: The integration does not use websession - strict-typing: todo + strict-typing: done diff --git a/homeassistant/components/heos/services.py b/homeassistant/components/heos/services.py index 4dc3b247707..dc11bb7a76d 100644 --- a/homeassistant/components/heos/services.py +++ b/homeassistant/components/heos/services.py @@ -28,7 +28,7 @@ HEOS_SIGN_IN_SCHEMA = vol.Schema( HEOS_SIGN_OUT_SCHEMA = vol.Schema({}) -def register(hass: HomeAssistant): +def register(hass: HomeAssistant) -> None: """Register HEOS services.""" hass.services.async_register( DOMAIN, diff --git a/mypy.ini b/mypy.ini index 2139449ba8d..ddc5589dc09 100644 --- a/mypy.ini +++ b/mypy.ini @@ -2036,6 +2036,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.heos.*] +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.here_travel_time.*] check_untyped_defs = true disallow_incomplete_defs = true From d0a188b86d192bd4ba09c5fece94517dfae98c5a Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 29 Jan 2025 08:57:57 +0100 Subject: [PATCH 0194/3148] Standardize homeassistant imports in component tests (m-z) (#136807) --- tests/components/manual/test_alarm_control_panel.py | 2 +- tests/components/manual_mqtt/test_alarm_control_panel.py | 2 +- tests/components/media_player/test_async_helpers.py | 2 +- tests/components/media_player/test_device_trigger.py | 2 +- tests/components/melnor/test_sensor.py | 2 +- tests/components/melnor/test_time.py | 2 +- tests/components/mfi/test_sensor.py | 4 ++-- tests/components/mfi/test_switch.py | 4 ++-- tests/components/modbus/conftest.py | 2 +- tests/components/modbus/test_init.py | 2 +- tests/components/modbus/test_switch.py | 2 +- tests/components/motioneye/test_camera.py | 2 +- tests/components/motioneye/test_web_hooks.py | 2 +- tests/components/mqtt/test_binary_sensor.py | 2 +- tests/components/mqtt/test_init.py | 2 +- tests/components/mqtt/test_sensor.py | 2 +- tests/components/mqtt_eventstream/test_init.py | 4 ++-- tests/components/mqtt_statestream/test_init.py | 2 +- tests/components/nest/test_media_source.py | 2 +- tests/components/netgear_lte/test_init.py | 2 +- .../nsw_rural_fire_service_feed/test_geo_location.py | 2 +- tests/components/nuheat/test_climate.py | 2 +- tests/components/persistent_notification/conftest.py | 2 +- tests/components/persistent_notification/test_init.py | 2 +- tests/components/persistent_notification/test_trigger.py | 2 +- tests/components/plex/helpers.py | 2 +- tests/components/plex/test_init.py | 2 +- tests/components/powerwall/test_config_flow.py | 2 +- tests/components/powerwall/test_sensor.py | 2 +- tests/components/profiler/test_init.py | 2 +- tests/components/qld_bushfire/test_geo_location.py | 2 +- tests/components/radarr/test_sensor.py | 2 +- tests/components/remember_the_milk/test_init.py | 2 +- tests/components/remote/test_device_condition.py | 2 +- tests/components/remote/test_device_trigger.py | 2 +- tests/components/rflink/test_binary_sensor.py | 2 +- tests/components/roku/test_select.py | 2 +- tests/components/samsungtv/conftest.py | 2 +- tests/components/samsungtv/test_media_player.py | 2 +- tests/components/script/test_init.py | 3 +-- tests/components/sighthound/test_image_processing.py | 2 +- tests/components/sonos/test_init.py | 2 +- tests/components/speedtestdotnet/test_init.py | 2 +- tests/components/srp_energy/conftest.py | 2 +- tests/components/ssdp/test_init.py | 2 +- tests/components/steamist/test_switch.py | 2 +- tests/components/stream/test_hls.py | 2 +- tests/components/stream/test_recorder.py | 2 +- tests/components/subaru/conftest.py | 2 +- tests/components/sun/test_init.py | 2 +- tests/components/sun/test_sensor.py | 2 +- tests/components/sun/test_trigger.py | 2 +- tests/components/switch/test_device_condition.py | 2 +- tests/components/switch/test_device_trigger.py | 2 +- tests/components/tankerkoenig/test_coordinator.py | 2 +- tests/components/tasmota/test_binary_sensor.py | 4 ++-- tests/components/tcp/test_sensor.py | 2 +- tests/components/temper/test_sensor.py | 2 +- tests/components/template/test_binary_sensor.py | 2 +- tests/components/template/test_sensor.py | 2 +- tests/components/template/test_trigger.py | 2 +- tests/components/tesla_wall_connector/conftest.py | 2 +- tests/components/time_date/test_sensor.py | 2 +- tests/components/tod/test_binary_sensor.py | 2 +- tests/components/tomato/test_device_tracker.py | 2 +- tests/components/tplink/test_light.py | 2 +- tests/components/tts/test_init.py | 2 +- tests/components/unifi/conftest.py | 2 +- tests/components/unifi/test_button.py | 2 +- tests/components/unifi/test_device_tracker.py | 2 +- tests/components/unifi/test_hub.py | 2 +- tests/components/unifi/test_sensor.py | 2 +- tests/components/unifiprotect/conftest.py | 2 +- tests/components/unifiprotect/utils.py | 2 +- tests/components/universal/test_media_player.py | 2 +- tests/components/update/test_device_trigger.py | 2 +- tests/components/upnp/test_binary_sensor.py | 2 +- tests/components/upnp/test_sensor.py | 2 +- .../components/usgs_earthquakes_feed/test_geo_location.py | 2 +- tests/components/utility_meter/test_init.py | 8 +++++--- tests/components/utility_meter/test_sensor.py | 2 +- tests/components/vacuum/test_device_trigger.py | 2 +- tests/components/vizio/test_init.py | 2 +- tests/components/vultr/test_sensor.py | 3 +-- tests/components/whois/test_sensor.py | 2 +- tests/components/wled/test_coordinator.py | 2 +- tests/components/worldclock/test_sensor.py | 2 +- tests/components/wsdot/test_sensor.py | 2 +- tests/components/xiaomi/test_device_tracker.py | 2 +- tests/components/yale/test_binary_sensor.py | 2 +- tests/components/yale/test_event.py | 2 +- tests/components/yale/test_lock.py | 2 +- tests/components/yandex_transport/test_sensor.py | 2 +- tests/components/zerproc/test_light.py | 2 +- tests/components/zha/common.py | 2 +- tests/components/zha/conftest.py | 2 +- tests/components/zha/test_device_tracker.py | 2 +- tests/components/zha/test_helpers.py | 4 ++-- tests/components/zha/test_siren.py | 2 +- tests/components/zodiac/test_sensor.py | 2 +- 100 files changed, 109 insertions(+), 109 deletions(-) diff --git a/tests/components/manual/test_alarm_control_panel.py b/tests/components/manual/test_alarm_control_panel.py index 9fc92cd5458..941d7523220 100644 --- a/tests/components/manual/test_alarm_control_panel.py +++ b/tests/components/manual/test_alarm_control_panel.py @@ -28,7 +28,7 @@ from homeassistant.const import ( from homeassistant.core import CoreState, HomeAssistant, State from homeassistant.exceptions import ServiceValidationError from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed, mock_component, mock_restore_cache from tests.components.alarm_control_panel import common diff --git a/tests/components/manual_mqtt/test_alarm_control_panel.py b/tests/components/manual_mqtt/test_alarm_control_panel.py index 2b401cb10a0..9bb506b935a 100644 --- a/tests/components/manual_mqtt/test_alarm_control_panel.py +++ b/tests/components/manual_mqtt/test_alarm_control_panel.py @@ -20,7 +20,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import ( assert_setup_component, diff --git a/tests/components/media_player/test_async_helpers.py b/tests/components/media_player/test_async_helpers.py index 750d2861f21..680603c097d 100644 --- a/tests/components/media_player/test_async_helpers.py +++ b/tests/components/media_player/test_async_helpers.py @@ -2,7 +2,7 @@ import pytest -import homeassistant.components.media_player as mp +from homeassistant.components import media_player as mp from homeassistant.const import ( STATE_IDLE, STATE_OFF, diff --git a/tests/components/media_player/test_device_trigger.py b/tests/components/media_player/test_device_trigger.py index 4bb27b73f24..ae3a84e66a0 100644 --- a/tests/components/media_player/test_device_trigger.py +++ b/tests/components/media_player/test_device_trigger.py @@ -21,7 +21,7 @@ from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity_registry import RegistryEntryHider from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import ( MockConfigEntry, diff --git a/tests/components/melnor/test_sensor.py b/tests/components/melnor/test_sensor.py index a2ba23d9e61..23902a4b780 100644 --- a/tests/components/melnor/test_sensor.py +++ b/tests/components/melnor/test_sensor.py @@ -10,7 +10,7 @@ from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass from homeassistant.const import PERCENTAGE, SIGNAL_STRENGTH_DECIBELS_MILLIWATT from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .conftest import ( mock_config_entry, diff --git a/tests/components/melnor/test_time.py b/tests/components/melnor/test_time.py index 50b51d31ff8..f8a3adcf3d0 100644 --- a/tests/components/melnor/test_time.py +++ b/tests/components/melnor/test_time.py @@ -5,7 +5,7 @@ from __future__ import annotations from datetime import time, timedelta from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .conftest import ( mock_config_entry, diff --git a/tests/components/mfi/test_sensor.py b/tests/components/mfi/test_sensor.py index 37512ca78f8..8c21fa9cb36 100644 --- a/tests/components/mfi/test_sensor.py +++ b/tests/components/mfi/test_sensor.py @@ -7,8 +7,8 @@ from mficlient.client import FailedToLogin import pytest import requests -import homeassistant.components.mfi.sensor as mfi -import homeassistant.components.sensor as sensor_component +from homeassistant.components import sensor as sensor_component +from homeassistant.components.mfi import sensor as mfi from homeassistant.components.sensor import SensorDeviceClass from homeassistant.const import UnitOfTemperature from homeassistant.core import HomeAssistant diff --git a/tests/components/mfi/test_switch.py b/tests/components/mfi/test_switch.py index 03b5d5f2c0a..fb586073a3d 100644 --- a/tests/components/mfi/test_switch.py +++ b/tests/components/mfi/test_switch.py @@ -4,8 +4,8 @@ from unittest import mock import pytest -import homeassistant.components.mfi.switch as mfi -import homeassistant.components.switch as switch_component +from homeassistant.components import switch as switch_component +from homeassistant.components.mfi import switch as mfi from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component diff --git a/tests/components/modbus/conftest.py b/tests/components/modbus/conftest.py index cdea046ceea..0a2cbf44b9e 100644 --- a/tests/components/modbus/conftest.py +++ b/tests/components/modbus/conftest.py @@ -22,7 +22,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed, mock_restore_cache diff --git a/tests/components/modbus/test_init.py b/tests/components/modbus/test_init.py index e105818d193..7b76dbc3528 100644 --- a/tests/components/modbus/test_init.py +++ b/tests/components/modbus/test_init.py @@ -107,7 +107,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .conftest import ( TEST_ENTITY_NAME, diff --git a/tests/components/modbus/test_switch.py b/tests/components/modbus/test_switch.py index 4e0ad0841ea..4b2c123ba75 100644 --- a/tests/components/modbus/test_switch.py +++ b/tests/components/modbus/test_switch.py @@ -41,7 +41,7 @@ from homeassistant.const import ( ) from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, State from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .conftest import TEST_ENTITY_NAME, ReadResult diff --git a/tests/components/motioneye/test_camera.py b/tests/components/motioneye/test_camera.py index 8ef58cc968d..d9a9a847b63 100644 --- a/tests/components/motioneye/test_camera.py +++ b/tests/components/motioneye/test_camera.py @@ -45,8 +45,8 @@ from homeassistant.const import ATTR_DEVICE_ID, ATTR_ENTITY_ID, CONF_URL from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.util import dt as dt_util from homeassistant.util.aiohttp import MockRequest -import homeassistant.util.dt as dt_util from . import ( TEST_CAMERA, diff --git a/tests/components/motioneye/test_web_hooks.py b/tests/components/motioneye/test_web_hooks.py index fae7fccbb6d..bc345c0b66f 100644 --- a/tests/components/motioneye/test_web_hooks.py +++ b/tests/components/motioneye/test_web_hooks.py @@ -31,7 +31,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr from homeassistant.helpers.network import NoURLAvailableError from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import ( TEST_CAMERA, diff --git a/tests/components/mqtt/test_binary_sensor.py b/tests/components/mqtt/test_binary_sensor.py index d27163c3423..34be237fb72 100644 --- a/tests/components/mqtt/test_binary_sensor.py +++ b/tests/components/mqtt/test_binary_sensor.py @@ -21,7 +21,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, State, callback from homeassistant.helpers.typing import ConfigType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .test_common import ( help_custom_config, diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index 4e0873c6e1b..d05c340dac2 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -13,6 +13,7 @@ from freezegun.api import FrozenDateTimeFactory import pytest import voluptuous as vol +from homeassistant import core as ha from homeassistant.components import mqtt from homeassistant.components.mqtt import debug_info from homeassistant.components.mqtt.models import ( @@ -30,7 +31,6 @@ from homeassistant.const import ( STATE_UNAVAILABLE, STATE_UNKNOWN, ) -import homeassistant.core as ha from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import device_registry as dr, entity_registry as er, template diff --git a/tests/components/mqtt/test_sensor.py b/tests/components/mqtt/test_sensor.py index 7f418864872..6b3bbd6334c 100644 --- a/tests/components/mqtt/test_sensor.py +++ b/tests/components/mqtt/test_sensor.py @@ -23,7 +23,7 @@ from homeassistant.const import ( from homeassistant.core import Event, HomeAssistant, State, callback from homeassistant.helpers import device_registry as dr from homeassistant.helpers.typing import ConfigType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .test_common import ( help_custom_config, diff --git a/tests/components/mqtt_eventstream/test_init.py b/tests/components/mqtt_eventstream/test_init.py index b6c1940b149..cbf02299b09 100644 --- a/tests/components/mqtt_eventstream/test_init.py +++ b/tests/components/mqtt_eventstream/test_init.py @@ -5,12 +5,12 @@ from unittest.mock import ANY, patch import pytest -import homeassistant.components.mqtt_eventstream as eventstream +from homeassistant.components import mqtt_eventstream as eventstream from homeassistant.const import EVENT_STATE_CHANGED, MATCH_ALL from homeassistant.core import HomeAssistant, State, callback from homeassistant.helpers.json import JSONEncoder from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import ( async_fire_mqtt_message, diff --git a/tests/components/mqtt_statestream/test_init.py b/tests/components/mqtt_statestream/test_init.py index 9798477945c..63c3ea14e44 100644 --- a/tests/components/mqtt_statestream/test_init.py +++ b/tests/components/mqtt_statestream/test_init.py @@ -4,7 +4,7 @@ from unittest.mock import ANY, call import pytest -import homeassistant.components.mqtt_statestream as statestream +from homeassistant.components import mqtt_statestream as statestream from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import CoreState, HomeAssistant, State from homeassistant.setup import async_setup_component diff --git a/tests/components/nest/test_media_source.py b/tests/components/nest/test_media_source.py index 051f7bb87e4..d009e1185da 100644 --- a/tests/components/nest/test_media_source.py +++ b/tests/components/nest/test_media_source.py @@ -28,7 +28,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr from homeassistant.helpers.template import DATE_STR_FORMAT from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .common import ( DEVICE_ID, diff --git a/tests/components/netgear_lte/test_init.py b/tests/components/netgear_lte/test_init.py index 1bd3dff1eff..e853109e33e 100644 --- a/tests/components/netgear_lte/test_init.py +++ b/tests/components/netgear_lte/test_init.py @@ -12,7 +12,7 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .conftest import CONF_DATA diff --git a/tests/components/nsw_rural_fire_service_feed/test_geo_location.py b/tests/components/nsw_rural_fire_service_feed/test_geo_location.py index ad987325b97..96d5e815ff0 100644 --- a/tests/components/nsw_rural_fire_service_feed/test_geo_location.py +++ b/tests/components/nsw_rural_fire_service_feed/test_geo_location.py @@ -37,7 +37,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import assert_setup_component, async_fire_time_changed diff --git a/tests/components/nuheat/test_climate.py b/tests/components/nuheat/test_climate.py index bc00df126e5..5e3ba384b2d 100644 --- a/tests/components/nuheat/test_climate.py +++ b/tests/components/nuheat/test_climate.py @@ -6,7 +6,7 @@ from unittest.mock import patch from homeassistant.components.nuheat.const import DOMAIN from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .mocks import ( MOCK_CONFIG_ENTRY, diff --git a/tests/components/persistent_notification/conftest.py b/tests/components/persistent_notification/conftest.py index 29ba5a6008a..76fdc70ea7b 100644 --- a/tests/components/persistent_notification/conftest.py +++ b/tests/components/persistent_notification/conftest.py @@ -2,7 +2,7 @@ import pytest -import homeassistant.components.persistent_notification as pn +from homeassistant.components import persistent_notification as pn from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component diff --git a/tests/components/persistent_notification/test_init.py b/tests/components/persistent_notification/test_init.py index 956183d8420..89559d45dc4 100644 --- a/tests/components/persistent_notification/test_init.py +++ b/tests/components/persistent_notification/test_init.py @@ -1,6 +1,6 @@ """The tests for the persistent notification component.""" -import homeassistant.components.persistent_notification as pn +from homeassistant.components import persistent_notification as pn from homeassistant.components.websocket_api import TYPE_RESULT from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component diff --git a/tests/components/persistent_notification/test_trigger.py b/tests/components/persistent_notification/test_trigger.py index 16208143447..5e03fbf5f19 100644 --- a/tests/components/persistent_notification/test_trigger.py +++ b/tests/components/persistent_notification/test_trigger.py @@ -2,7 +2,7 @@ from typing import Any -import homeassistant.components.persistent_notification as pn +from homeassistant.components import persistent_notification as pn from homeassistant.components.persistent_notification import trigger from homeassistant.core import Context, HomeAssistant, callback diff --git a/tests/components/plex/helpers.py b/tests/components/plex/helpers.py index 434c31996e4..4dc80d3e7aa 100644 --- a/tests/components/plex/helpers.py +++ b/tests/components/plex/helpers.py @@ -7,7 +7,7 @@ from plexwebsocket import SIGNAL_CONNECTION_STATE, STATE_CONNECTED from homeassistant.core import HomeAssistant from homeassistant.helpers.typing import UNDEFINED, UndefinedType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed diff --git a/tests/components/plex/test_init.py b/tests/components/plex/test_init.py index 490091998ff..036b2d87f3f 100644 --- a/tests/components/plex/test_init.py +++ b/tests/components/plex/test_init.py @@ -25,7 +25,7 @@ from homeassistant.const import ( STATE_PLAYING, ) from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import DEFAULT_DATA, DEFAULT_OPTIONS, PLEX_DIRECT_URL from .helpers import trigger_plex_update, wait_for_debouncer diff --git a/tests/components/powerwall/test_config_flow.py b/tests/components/powerwall/test_config_flow.py index cd4f1250aa4..ab5034de637 100644 --- a/tests/components/powerwall/test_config_flow.py +++ b/tests/components/powerwall/test_config_flow.py @@ -17,7 +17,7 @@ from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .mocks import ( MOCK_GATEWAY_DIN, diff --git a/tests/components/powerwall/test_sensor.py b/tests/components/powerwall/test_sensor.py index fa2d986d12a..9b533304fbc 100644 --- a/tests/components/powerwall/test_sensor.py +++ b/tests/components/powerwall/test_sensor.py @@ -19,7 +19,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .mocks import MOCK_GATEWAY_DIN, _mock_powerwall_with_fixtures diff --git a/tests/components/profiler/test_init.py b/tests/components/profiler/test_init.py index 540e644aca4..e724a9e5cab 100644 --- a/tests/components/profiler/test_init.py +++ b/tests/components/profiler/test_init.py @@ -34,7 +34,7 @@ from homeassistant.components.profiler.const import DOMAIN from homeassistant.const import CONF_SCAN_INTERVAL, CONF_TYPE from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed diff --git a/tests/components/qld_bushfire/test_geo_location.py b/tests/components/qld_bushfire/test_geo_location.py index 20659182726..aefee4113cc 100644 --- a/tests/components/qld_bushfire/test_geo_location.py +++ b/tests/components/qld_bushfire/test_geo_location.py @@ -31,7 +31,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import assert_setup_component, async_fire_time_changed diff --git a/tests/components/radarr/test_sensor.py b/tests/components/radarr/test_sensor.py index 563ac504057..9139e13a957 100644 --- a/tests/components/radarr/test_sensor.py +++ b/tests/components/radarr/test_sensor.py @@ -18,7 +18,7 @@ from homeassistant.const import ( STATE_UNAVAILABLE, ) from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import setup_integration diff --git a/tests/components/remember_the_milk/test_init.py b/tests/components/remember_the_milk/test_init.py index c68fe14430a..3ada2d343fe 100644 --- a/tests/components/remember_the_milk/test_init.py +++ b/tests/components/remember_the_milk/test_init.py @@ -2,7 +2,7 @@ from unittest.mock import Mock, mock_open, patch -import homeassistant.components.remember_the_milk as rtm +from homeassistant.components import remember_the_milk as rtm from homeassistant.core import HomeAssistant from .const import JSON_STRING, PROFILE, TOKEN diff --git a/tests/components/remote/test_device_condition.py b/tests/components/remote/test_device_condition.py index 6c9334aeac4..b4dd513c317 100644 --- a/tests/components/remote/test_device_condition.py +++ b/tests/components/remote/test_device_condition.py @@ -14,7 +14,7 @@ from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity_registry import RegistryEntryHider from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import ( MockConfigEntry, diff --git a/tests/components/remote/test_device_trigger.py b/tests/components/remote/test_device_trigger.py index c647faba2c1..800d090fd7b 100644 --- a/tests/components/remote/test_device_trigger.py +++ b/tests/components/remote/test_device_trigger.py @@ -13,7 +13,7 @@ from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity_registry import RegistryEntryHider from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import ( MockConfigEntry, diff --git a/tests/components/rflink/test_binary_sensor.py b/tests/components/rflink/test_binary_sensor.py index 9329edb3a00..fd113bceaa0 100644 --- a/tests/components/rflink/test_binary_sensor.py +++ b/tests/components/rflink/test_binary_sensor.py @@ -18,7 +18,7 @@ from homeassistant.const import ( STATE_UNKNOWN, ) from homeassistant.core import CoreState, HomeAssistant, State, callback -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .test_init import mock_rflink diff --git a/tests/components/roku/test_select.py b/tests/components/roku/test_select.py index 78cd65250f8..a79a23782ce 100644 --- a/tests/components/roku/test_select.py +++ b/tests/components/roku/test_select.py @@ -22,7 +22,7 @@ from homeassistant.const import ATTR_ENTITY_ID, SERVICE_SELECT_OPTION from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed diff --git a/tests/components/samsungtv/conftest.py b/tests/components/samsungtv/conftest.py index ec12031ef96..105ef0f25ad 100644 --- a/tests/components/samsungtv/conftest.py +++ b/tests/components/samsungtv/conftest.py @@ -21,7 +21,7 @@ from samsungtvws.exceptions import ResponseError from samsungtvws.remote import ChannelEmitCommand from homeassistant.components.samsungtv.const import WEBSOCKET_SSL_PORT -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import SAMPLE_DEVICE_INFO_UE48JU6400, SAMPLE_DEVICE_INFO_WIFI diff --git a/tests/components/samsungtv/test_media_player.py b/tests/components/samsungtv/test_media_player.py index 1a7c8713b17..3d9633bbf96 100644 --- a/tests/components/samsungtv/test_media_player.py +++ b/tests/components/samsungtv/test_media_player.py @@ -78,7 +78,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceNotSupported from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import async_wait_config_entry_reload, setup_samsungtv_entry from .const import ( diff --git a/tests/components/script/test_init.py b/tests/components/script/test_init.py index 248ada605cc..3b0bff7e82e 100644 --- a/tests/components/script/test_init.py +++ b/tests/components/script/test_init.py @@ -42,8 +42,7 @@ from homeassistant.helpers.script import ( ) from homeassistant.helpers.service import async_get_all_descriptions from homeassistant.setup import async_setup_component -from homeassistant.util import yaml as yaml_util -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util, yaml as yaml_util from tests.common import ( MockConfigEntry, diff --git a/tests/components/sighthound/test_image_processing.py b/tests/components/sighthound/test_image_processing.py index 5db6347a832..ba03f6fc804 100644 --- a/tests/components/sighthound/test_image_processing.py +++ b/tests/components/sighthound/test_image_processing.py @@ -11,7 +11,7 @@ import pytest import simplehound.core as hound from homeassistant.components.image_processing import DOMAIN as IP_DOMAIN, SERVICE_SCAN -import homeassistant.components.sighthound.image_processing as sh +from homeassistant.components.sighthound import image_processing as sh from homeassistant.const import ( ATTR_ENTITY_ID, CONF_API_KEY, diff --git a/tests/components/sonos/test_init.py b/tests/components/sonos/test_init.py index 3fc8da6a952..a7ad2f4cb82 100644 --- a/tests/components/sonos/test_init.py +++ b/tests/components/sonos/test_init.py @@ -21,7 +21,7 @@ from homeassistant.helpers import entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .conftest import MockSoCo, SoCoMockFactory diff --git a/tests/components/speedtestdotnet/test_init.py b/tests/components/speedtestdotnet/test_init.py index 2e20aaa259c..1dd30c425b3 100644 --- a/tests/components/speedtestdotnet/test_init.py +++ b/tests/components/speedtestdotnet/test_init.py @@ -16,7 +16,7 @@ from homeassistant.components.speedtestdotnet.coordinator import ( from homeassistant.config_entries import ConfigEntryState from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed diff --git a/tests/components/srp_energy/conftest.py b/tests/components/srp_energy/conftest.py index b612bc9f3f3..b1d5b958d47 100644 --- a/tests/components/srp_energy/conftest.py +++ b/tests/components/srp_energy/conftest.py @@ -12,7 +12,7 @@ import pytest from homeassistant.components.srp_energy.const import DOMAIN, PHOENIX_TIME_ZONE from homeassistant.const import CONF_ID from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import MOCK_USAGE, TEST_CONFIG_HOME diff --git a/tests/components/ssdp/test_init.py b/tests/components/ssdp/test_init.py index 839509e756b..56623b51bb5 100644 --- a/tests/components/ssdp/test_init.py +++ b/tests/components/ssdp/test_init.py @@ -39,7 +39,7 @@ from homeassistant.helpers.service_info.ssdp import ( SsdpServiceInfo, ) from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import ( MockConfigEntry, diff --git a/tests/components/steamist/test_switch.py b/tests/components/steamist/test_switch.py index a20bebc4052..cd62c18590a 100644 --- a/tests/components/steamist/test_switch.py +++ b/tests/components/steamist/test_switch.py @@ -8,7 +8,7 @@ from unittest.mock import AsyncMock from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import ( MOCK_ASYNC_GET_STATUS_ACTIVE, diff --git a/tests/components/stream/test_hls.py b/tests/components/stream/test_hls.py index cd48fd94c24..c96b7d9427f 100644 --- a/tests/components/stream/test_hls.py +++ b/tests/components/stream/test_hls.py @@ -19,7 +19,7 @@ from homeassistant.components.stream.const import ( from homeassistant.components.stream.core import Orientation, Part from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .common import ( FAKE_TIME, diff --git a/tests/components/stream/test_recorder.py b/tests/components/stream/test_recorder.py index 8e079cded45..7c856180f77 100644 --- a/tests/components/stream/test_recorder.py +++ b/tests/components/stream/test_recorder.py @@ -21,7 +21,7 @@ from homeassistant.components.stream.fmp4utils import find_box from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .common import ( DefaultSegment as Segment, diff --git a/tests/components/subaru/conftest.py b/tests/components/subaru/conftest.py index e18ea8fd398..84d2fde97f7 100644 --- a/tests/components/subaru/conftest.py +++ b/tests/components/subaru/conftest.py @@ -33,7 +33,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.typing import UNDEFINED, UndefinedType from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .api_responses import TEST_VIN_2_EV, VEHICLE_DATA, VEHICLE_STATUS_EV diff --git a/tests/components/sun/test_init.py b/tests/components/sun/test_init.py index a30076d6d3c..3896498bbb0 100644 --- a/tests/components/sun/test_init.py +++ b/tests/components/sun/test_init.py @@ -13,7 +13,7 @@ from homeassistant.components.sun import entity from homeassistant.const import EVENT_STATE_CHANGED from homeassistant.core import HomeAssistant, callback from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed diff --git a/tests/components/sun/test_sensor.py b/tests/components/sun/test_sensor.py index 495a97b88fe..59e4e4c700b 100644 --- a/tests/components/sun/test_sensor.py +++ b/tests/components/sun/test_sensor.py @@ -12,7 +12,7 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util @pytest.mark.usefixtures("entity_registry_enabled_by_default") diff --git a/tests/components/sun/test_trigger.py b/tests/components/sun/test_trigger.py index 303ca3b80cd..a7aeae25ac7 100644 --- a/tests/components/sun/test_trigger.py +++ b/tests/components/sun/test_trigger.py @@ -16,7 +16,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed, mock_component diff --git a/tests/components/switch/test_device_condition.py b/tests/components/switch/test_device_condition.py index 7c4f434b0a4..5c5737804e1 100644 --- a/tests/components/switch/test_device_condition.py +++ b/tests/components/switch/test_device_condition.py @@ -14,7 +14,7 @@ from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity_registry import RegistryEntryHider from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import ( MockConfigEntry, diff --git a/tests/components/switch/test_device_trigger.py b/tests/components/switch/test_device_trigger.py index 08e6ab6d0f6..81f8a93611d 100644 --- a/tests/components/switch/test_device_trigger.py +++ b/tests/components/switch/test_device_trigger.py @@ -13,7 +13,7 @@ from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity_registry import RegistryEntryHider from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import ( MockConfigEntry, diff --git a/tests/components/tankerkoenig/test_coordinator.py b/tests/components/tankerkoenig/test_coordinator.py index 3ba0dc31c5f..ff2ceca7442 100644 --- a/tests/components/tankerkoenig/test_coordinator.py +++ b/tests/components/tankerkoenig/test_coordinator.py @@ -25,7 +25,7 @@ from homeassistant.const import ATTR_ID, CONF_SHOW_ON_MAP, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import CONFIG_DATA diff --git a/tests/components/tasmota/test_binary_sensor.py b/tests/components/tasmota/test_binary_sensor.py index 5abb9ab9bf2..ff951e058cb 100644 --- a/tests/components/tasmota/test_binary_sensor.py +++ b/tests/components/tasmota/test_binary_sensor.py @@ -13,6 +13,7 @@ from hatasmota.utils import ( ) import pytest +from homeassistant import core as ha from homeassistant.components.tasmota.const import DEFAULT_PREFIX from homeassistant.const import ( ATTR_ASSUMED_STATE, @@ -22,9 +23,8 @@ from homeassistant.const import ( STATE_UNKNOWN, Platform, ) -import homeassistant.core as ha from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .test_common import ( DEFAULT_CONFIG, diff --git a/tests/components/tcp/test_sensor.py b/tests/components/tcp/test_sensor.py index 27003df46cd..ade4b9f93d4 100644 --- a/tests/components/tcp/test_sensor.py +++ b/tests/components/tcp/test_sensor.py @@ -5,7 +5,7 @@ from unittest.mock import call, patch import pytest -import homeassistant.components.tcp.common as tcp +from homeassistant.components.tcp import common as tcp from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component diff --git a/tests/components/temper/test_sensor.py b/tests/components/temper/test_sensor.py index d1e74f1ab0f..445adc0b5bd 100644 --- a/tests/components/temper/test_sensor.py +++ b/tests/components/temper/test_sensor.py @@ -5,7 +5,7 @@ from unittest.mock import Mock, patch from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed diff --git a/tests/components/template/test_binary_sensor.py b/tests/components/template/test_binary_sensor.py index 3e3a629b4be..a7ee953bb09 100644 --- a/tests/components/template/test_binary_sensor.py +++ b/tests/components/template/test_binary_sensor.py @@ -23,7 +23,7 @@ from homeassistant.core import Context, CoreState, HomeAssistant, State from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity_component import async_update_entity from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import ( MockConfigEntry, diff --git a/tests/components/template/test_sensor.py b/tests/components/template/test_sensor.py index 929a890ab38..3bf91549114 100644 --- a/tests/components/template/test_sensor.py +++ b/tests/components/template/test_sensor.py @@ -28,7 +28,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.template import Template from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.setup import ATTR_COMPONENT, async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import ( MockConfigEntry, diff --git a/tests/components/template/test_trigger.py b/tests/components/template/test_trigger.py index a131f5f606b..49b89b61d34 100644 --- a/tests/components/template/test_trigger.py +++ b/tests/components/template/test_trigger.py @@ -16,7 +16,7 @@ from homeassistant.const import ( ) from homeassistant.core import Context, HomeAssistant, ServiceCall, callback from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed, mock_component diff --git a/tests/components/tesla_wall_connector/conftest.py b/tests/components/tesla_wall_connector/conftest.py index 9533b7e691e..e4499d6e308 100644 --- a/tests/components/tesla_wall_connector/conftest.py +++ b/tests/components/tesla_wall_connector/conftest.py @@ -14,7 +14,7 @@ from homeassistant.components.tesla_wall_connector.const import ( ) from homeassistant.const import CONF_HOST, CONF_SCAN_INTERVAL from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed diff --git a/tests/components/time_date/test_sensor.py b/tests/components/time_date/test_sensor.py index ddeec48b3d2..3daa0314cbd 100644 --- a/tests/components/time_date/test_sensor.py +++ b/tests/components/time_date/test_sensor.py @@ -8,7 +8,7 @@ import pytest from homeassistant.components.time_date.const import OPTION_TYPES from homeassistant.core import HomeAssistant from homeassistant.helpers import event -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import load_int diff --git a/tests/components/tod/test_binary_sensor.py b/tests/components/tod/test_binary_sensor.py index 47e64353004..8b9a81d7542 100644 --- a/tests/components/tod/test_binary_sensor.py +++ b/tests/components/tod/test_binary_sensor.py @@ -10,7 +10,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.helpers.sun import get_astral_event_date, get_astral_event_next from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import assert_setup_component, async_fire_time_changed diff --git a/tests/components/tomato/test_device_tracker.py b/tests/components/tomato/test_device_tracker.py index f50d999548f..e4f08f55dba 100644 --- a/tests/components/tomato/test_device_tracker.py +++ b/tests/components/tomato/test_device_tracker.py @@ -8,7 +8,7 @@ import requests_mock import voluptuous as vol from homeassistant.components.device_tracker import DOMAIN as DEVICE_TRACKER_DOMAIN -import homeassistant.components.tomato.device_tracker as tomato +from homeassistant.components.tomato import device_tracker as tomato from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, diff --git a/tests/components/tplink/test_light.py b/tests/components/tplink/test_light.py index 565d4f1221a..7ae21fb4a26 100644 --- a/tests/components/tplink/test_light.py +++ b/tests/components/tplink/test_light.py @@ -61,7 +61,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import ( _mocked_device, diff --git a/tests/components/tts/test_init.py b/tests/components/tts/test_init.py index 2159d92ae4b..d115546c9bc 100644 --- a/tests/components/tts/test_init.py +++ b/tests/components/tts/test_init.py @@ -23,7 +23,7 @@ from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN from homeassistant.core import HomeAssistant, State from homeassistant.exceptions import HomeAssistantError from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .common import ( DEFAULT_LANG, diff --git a/tests/components/unifi/conftest.py b/tests/components/unifi/conftest.py index 702f8629219..ec7a0595731 100644 --- a/tests/components/unifi/conftest.py +++ b/tests/components/unifi/conftest.py @@ -26,7 +26,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed from tests.test_util.aiohttp import AiohttpClientMocker diff --git a/tests/components/unifi/test_button.py b/tests/components/unifi/test_button.py index 6a493e32b02..94343d12ba2 100644 --- a/tests/components/unifi/test_button.py +++ b/tests/components/unifi/test_button.py @@ -21,7 +21,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_registry import RegistryEntryDisabler -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .conftest import ( ConfigEntryFactoryType, diff --git a/tests/components/unifi/test_device_tracker.py b/tests/components/unifi/test_device_tracker.py index b37e4f47137..39b70344db7 100644 --- a/tests/components/unifi/test_device_tracker.py +++ b/tests/components/unifi/test_device_tracker.py @@ -26,7 +26,7 @@ from homeassistant.components.unifi.const import ( from homeassistant.const import STATE_HOME, STATE_NOT_HOME, STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant, State from homeassistant.helpers import entity_registry as er -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .conftest import ( ConfigEntryFactoryType, diff --git a/tests/components/unifi/test_hub.py b/tests/components/unifi/test_hub.py index af134c7449b..5492f6fe0df 100644 --- a/tests/components/unifi/test_hub.py +++ b/tests/components/unifi/test_hub.py @@ -15,7 +15,7 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .conftest import ConfigEntryFactoryType, WebsocketStateManager diff --git a/tests/components/unifi/test_sensor.py b/tests/components/unifi/test_sensor.py index 5e47d263079..ee8b102edaa 100644 --- a/tests/components/unifi/test_sensor.py +++ b/tests/components/unifi/test_sensor.py @@ -37,7 +37,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_registry import RegistryEntryDisabler -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .conftest import ( ConfigEntryFactoryType, diff --git a/tests/components/unifiprotect/conftest.py b/tests/components/unifiprotect/conftest.py index 352c33297ba..c49ade514bc 100644 --- a/tests/components/unifiprotect/conftest.py +++ b/tests/components/unifiprotect/conftest.py @@ -33,7 +33,7 @@ from uiprotect.websocket import WebsocketState from homeassistant.components.unifiprotect.const import DOMAIN from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import _patch_discovery from .utils import MockUFPFixture diff --git a/tests/components/unifiprotect/utils.py b/tests/components/unifiprotect/utils.py index 5a1ffa8258e..7dd0362f17c 100644 --- a/tests/components/unifiprotect/utils.py +++ b/tests/components/unifiprotect/utils.py @@ -26,7 +26,7 @@ from homeassistant.core import HomeAssistant, split_entity_id from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity import EntityDescription from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed diff --git a/tests/components/universal/test_media_player.py b/tests/components/universal/test_media_player.py index 5be9cb3fe02..351e11db512 100644 --- a/tests/components/universal/test_media_player.py +++ b/tests/components/universal/test_media_player.py @@ -13,7 +13,7 @@ from homeassistant.components.media_player import ( MediaClass, MediaPlayerEntityFeature, ) -import homeassistant.components.universal.media_player as universal +from homeassistant.components.universal import media_player as universal from homeassistant.const import ( SERVICE_RELOAD, STATE_OFF, diff --git a/tests/components/update/test_device_trigger.py b/tests/components/update/test_device_trigger.py index 202b3d32509..55138430ca0 100644 --- a/tests/components/update/test_device_trigger.py +++ b/tests/components/update/test_device_trigger.py @@ -12,7 +12,7 @@ from homeassistant.const import CONF_PLATFORM, STATE_OFF, STATE_ON, EntityCatego from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .common import MockUpdateEntity diff --git a/tests/components/upnp/test_binary_sensor.py b/tests/components/upnp/test_binary_sensor.py index 087cd9e9fb4..d9b5b442b00 100644 --- a/tests/components/upnp/test_binary_sensor.py +++ b/tests/components/upnp/test_binary_sensor.py @@ -6,7 +6,7 @@ from async_upnp_client.profiles.igd import IgdDevice, IgdState from homeassistant.components.upnp.const import DEFAULT_SCAN_INTERVAL from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed diff --git a/tests/components/upnp/test_sensor.py b/tests/components/upnp/test_sensor.py index e9d8a9cce8f..e7461c91da4 100644 --- a/tests/components/upnp/test_sensor.py +++ b/tests/components/upnp/test_sensor.py @@ -7,7 +7,7 @@ import pytest from homeassistant.components.upnp.const import DEFAULT_SCAN_INTERVAL from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed diff --git a/tests/components/usgs_earthquakes_feed/test_geo_location.py b/tests/components/usgs_earthquakes_feed/test_geo_location.py index 40d19422ced..e412d53a0d0 100644 --- a/tests/components/usgs_earthquakes_feed/test_geo_location.py +++ b/tests/components/usgs_earthquakes_feed/test_geo_location.py @@ -35,7 +35,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import assert_setup_component, async_fire_time_changed diff --git a/tests/components/utility_meter/test_init.py b/tests/components/utility_meter/test_init.py index cd549c77913..eba7cf913db 100644 --- a/tests/components/utility_meter/test_init.py +++ b/tests/components/utility_meter/test_init.py @@ -12,9 +12,11 @@ from homeassistant.components.select import ( DOMAIN as SELECT_DOMAIN, SERVICE_SELECT_OPTION, ) +from homeassistant.components.utility_meter import ( + select as um_select, + sensor as um_sensor, +) from homeassistant.components.utility_meter.const import DOMAIN, SERVICE_RESET -import homeassistant.components.utility_meter.select as um_select -import homeassistant.components.utility_meter.sensor as um_sensor from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_UNIT_OF_MEASUREMENT, @@ -26,7 +28,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, State from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, mock_restore_cache diff --git a/tests/components/utility_meter/test_sensor.py b/tests/components/utility_meter/test_sensor.py index 348afac57f7..c671969c5ac 100644 --- a/tests/components/utility_meter/test_sensor.py +++ b/tests/components/utility_meter/test_sensor.py @@ -44,7 +44,7 @@ from homeassistant.const import ( from homeassistant.core import CoreState, HomeAssistant, State from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import ( MockConfigEntry, diff --git a/tests/components/vacuum/test_device_trigger.py b/tests/components/vacuum/test_device_trigger.py index 3a0cbafb4a1..381cc1caa47 100644 --- a/tests/components/vacuum/test_device_trigger.py +++ b/tests/components/vacuum/test_device_trigger.py @@ -13,7 +13,7 @@ from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity_registry import RegistryEntryHider from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import ( MockConfigEntry, diff --git a/tests/components/vizio/test_init.py b/tests/components/vizio/test_init.py index e004255ec6d..9d776ba6a59 100644 --- a/tests/components/vizio/test_init.py +++ b/tests/components/vizio/test_init.py @@ -7,7 +7,7 @@ import pytest from homeassistant.components.vizio.const import DOMAIN from homeassistant.const import STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import MOCK_SPEAKER_CONFIG, MOCK_USER_VALID_TV_CONFIG, UNIQUE_ID diff --git a/tests/components/vultr/test_sensor.py b/tests/components/vultr/test_sensor.py index f9f922b35d4..65be23fc168 100644 --- a/tests/components/vultr/test_sensor.py +++ b/tests/components/vultr/test_sensor.py @@ -4,8 +4,7 @@ import pytest import voluptuous as vol from homeassistant.components import vultr as base_vultr -from homeassistant.components.vultr import CONF_SUBSCRIPTION -import homeassistant.components.vultr.sensor as vultr +from homeassistant.components.vultr import CONF_SUBSCRIPTION, sensor as vultr from homeassistant.const import ( CONF_MONITORED_CONDITIONS, CONF_NAME, diff --git a/tests/components/whois/test_sensor.py b/tests/components/whois/test_sensor.py index d58cc342745..d290bc347a9 100644 --- a/tests/components/whois/test_sensor.py +++ b/tests/components/whois/test_sensor.py @@ -9,7 +9,7 @@ from homeassistant.components.whois.const import SCAN_INTERVAL from homeassistant.const import STATE_UNKNOWN from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed diff --git a/tests/components/wled/test_coordinator.py b/tests/components/wled/test_coordinator.py index 14e8b620983..e2935290f03 100644 --- a/tests/components/wled/test_coordinator.py +++ b/tests/components/wled/test_coordinator.py @@ -21,7 +21,7 @@ from homeassistant.const import ( STATE_UNAVAILABLE, ) from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed diff --git a/tests/components/worldclock/test_sensor.py b/tests/components/worldclock/test_sensor.py index f901f605730..4941462cb14 100644 --- a/tests/components/worldclock/test_sensor.py +++ b/tests/components/worldclock/test_sensor.py @@ -7,7 +7,7 @@ import pytest from homeassistant.components.worldclock.const import CONF_TIME_FORMAT, DEFAULT_NAME from homeassistant.const import CONF_NAME, CONF_TIME_ZONE from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry diff --git a/tests/components/wsdot/test_sensor.py b/tests/components/wsdot/test_sensor.py index 9f5ec92a5b6..ff3d4960735 100644 --- a/tests/components/wsdot/test_sensor.py +++ b/tests/components/wsdot/test_sensor.py @@ -5,7 +5,7 @@ import re import requests_mock -import homeassistant.components.wsdot.sensor as wsdot +from homeassistant.components.wsdot import sensor as wsdot from homeassistant.components.wsdot.sensor import ( ATTR_DESCRIPTION, ATTR_TIME_UPDATED, diff --git a/tests/components/xiaomi/test_device_tracker.py b/tests/components/xiaomi/test_device_tracker.py index 625e6f404ad..e3cc1898ce9 100644 --- a/tests/components/xiaomi/test_device_tracker.py +++ b/tests/components/xiaomi/test_device_tracker.py @@ -7,7 +7,7 @@ from unittest.mock import MagicMock, call, patch import requests from homeassistant.components.device_tracker import DOMAIN as DEVICE_TRACKER_DOMAIN -import homeassistant.components.xiaomi.device_tracker as xiaomi +from homeassistant.components.xiaomi import device_tracker as xiaomi from homeassistant.components.xiaomi.device_tracker import get_scanner from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PLATFORM, CONF_USERNAME from homeassistant.core import HomeAssistant diff --git a/tests/components/yale/test_binary_sensor.py b/tests/components/yale/test_binary_sensor.py index 811c845e359..16ec0ffbeb4 100644 --- a/tests/components/yale/test_binary_sensor.py +++ b/tests/components/yale/test_binary_sensor.py @@ -16,7 +16,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .mocks import ( _create_yale_with_devices, diff --git a/tests/components/yale/test_event.py b/tests/components/yale/test_event.py index 7aeb9d8f12b..ce7f2635eea 100644 --- a/tests/components/yale/test_event.py +++ b/tests/components/yale/test_event.py @@ -4,7 +4,7 @@ from freezegun.api import FrozenDateTimeFactory from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .mocks import ( _create_yale_with_devices, diff --git a/tests/components/yale/test_lock.py b/tests/components/yale/test_lock.py index f6b96120d0d..1a99cf967ba 100644 --- a/tests/components/yale/test_lock.py +++ b/tests/components/yale/test_lock.py @@ -20,7 +20,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceNotSupported from homeassistant.helpers import device_registry as dr, entity_registry as er -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .mocks import ( _create_yale_with_devices, diff --git a/tests/components/yandex_transport/test_sensor.py b/tests/components/yandex_transport/test_sensor.py index 13432850b2b..dd8e82278f3 100644 --- a/tests/components/yandex_transport/test_sensor.py +++ b/tests/components/yandex_transport/test_sensor.py @@ -10,7 +10,7 @@ from homeassistant.components import sensor from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import assert_setup_component, load_fixture diff --git a/tests/components/zerproc/test_light.py b/tests/components/zerproc/test_light.py index 724414b5965..6cadc025385 100644 --- a/tests/components/zerproc/test_light.py +++ b/tests/components/zerproc/test_light.py @@ -29,7 +29,7 @@ from homeassistant.const import ( STATE_UNAVAILABLE, ) from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed diff --git a/tests/components/zha/common.py b/tests/components/zha/common.py index 1dd1e5f81aa..89526f6431e 100644 --- a/tests/components/zha/common.py +++ b/tests/components/zha/common.py @@ -9,7 +9,7 @@ import zigpy.zcl.foundation as zcl_f from homeassistant.components.zha.helpers import ZHADeviceProxy from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed diff --git a/tests/components/zha/conftest.py b/tests/components/zha/conftest.py index 1b280ea499a..78d335469b8 100644 --- a/tests/components/zha/conftest.py +++ b/tests/components/zha/conftest.py @@ -25,7 +25,7 @@ from zigpy.zcl.clusters.general import Basic, Groups from zigpy.zcl.foundation import Status import zigpy.zdo.types as zdo_t -import homeassistant.components.zha.const as zha_const +from homeassistant.components.zha import const as zha_const from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component diff --git a/tests/components/zha/test_device_tracker.py b/tests/components/zha/test_device_tracker.py index ae96de44f17..8a587966f81 100644 --- a/tests/components/zha/test_device_tracker.py +++ b/tests/components/zha/test_device_tracker.py @@ -18,7 +18,7 @@ from homeassistant.components.zha.helpers import ( ) from homeassistant.const import STATE_HOME, STATE_NOT_HOME, Platform from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .common import find_entity_id, send_attributes_report from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE diff --git a/tests/components/zha/test_helpers.py b/tests/components/zha/test_helpers.py index f8a809df51e..f52b403869e 100644 --- a/tests/components/zha/test_helpers.py +++ b/tests/components/zha/test_helpers.py @@ -9,7 +9,7 @@ from zigpy.application import ControllerApplication from zigpy.types.basic import uint16_t from zigpy.zcl.clusters import lighting -import homeassistant.components.zha.const as zha_const +from homeassistant.components.zha import const as zha_const from homeassistant.components.zha.helpers import ( cluster_command_schema_to_vol_schema, convert_to_zcl_values, @@ -18,7 +18,7 @@ from homeassistant.components.zha.helpers import ( get_zha_data, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry diff --git a/tests/components/zha/test_siren.py b/tests/components/zha/test_siren.py index f9837a7d016..5849cc6f233 100644 --- a/tests/components/zha/test_siren.py +++ b/tests/components/zha/test_siren.py @@ -28,7 +28,7 @@ from homeassistant.components.zha.helpers import ( ) from homeassistant.const import STATE_OFF, STATE_ON, Platform from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .common import find_entity_id diff --git a/tests/components/zodiac/test_sensor.py b/tests/components/zodiac/test_sensor.py index 19b9733e4f5..880e5c889ec 100644 --- a/tests/components/zodiac/test_sensor.py +++ b/tests/components/zodiac/test_sensor.py @@ -23,7 +23,7 @@ from homeassistant.const import ATTR_DEVICE_CLASS from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry From 5038847d678e3bd77bfafb18a906bb75ae8886b3 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 29 Jan 2025 09:03:25 +0100 Subject: [PATCH 0195/3148] Use runtime_data in environment_canada (#136805) --- .../components/environment_canada/__init__.py | 43 ++++++++----------- .../components/environment_canada/camera.py | 8 ++-- .../environment_canada/coordinator.py | 40 ++++++++++++++--- .../environment_canada/diagnostics.py | 12 +++--- .../components/environment_canada/sensor.py | 18 ++++---- .../components/environment_canada/weather.py | 7 ++- 6 files changed, 76 insertions(+), 52 deletions(-) diff --git a/homeassistant/components/environment_canada/__init__.py b/homeassistant/components/environment_canada/__init__.py index 0b6eadf6d13..c87832de6ab 100644 --- a/homeassistant/components/environment_canada/__init__.py +++ b/homeassistant/components/environment_canada/__init__.py @@ -5,14 +5,13 @@ import logging from env_canada import ECAirQuality, ECRadar, ECWeather -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_LANGUAGE, CONF_LATITUDE, CONF_LONGITUDE, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from .const import CONF_STATION, DOMAIN -from .coordinator import ECDataUpdateCoordinator +from .coordinator import ECConfigEntry, ECDataUpdateCoordinator, ECRuntimeData DEFAULT_RADAR_UPDATE_INTERVAL = timedelta(minutes=5) DEFAULT_WEATHER_UPDATE_INTERVAL = timedelta(minutes=5) @@ -22,14 +21,13 @@ PLATFORMS = [Platform.CAMERA, Platform.SENSOR, Platform.WEATHER] _LOGGER = logging.getLogger(__name__) -async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, config_entry: ECConfigEntry) -> bool: """Set up EC as config entry.""" lat = config_entry.data.get(CONF_LATITUDE) lon = config_entry.data.get(CONF_LONGITUDE) station = config_entry.data.get(CONF_STATION) lang = config_entry.data.get(CONF_LANGUAGE, "English") - coordinators = {} errors = 0 weather_data = ECWeather( @@ -37,31 +35,31 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b coordinates=(lat, lon), language=lang.lower(), ) - coordinators["weather_coordinator"] = ECDataUpdateCoordinator( - hass, weather_data, "weather", DEFAULT_WEATHER_UPDATE_INTERVAL + weather_coordinator = ECDataUpdateCoordinator( + hass, config_entry, weather_data, "weather", DEFAULT_WEATHER_UPDATE_INTERVAL ) try: - await coordinators["weather_coordinator"].async_config_entry_first_refresh() + await weather_coordinator.async_config_entry_first_refresh() except ConfigEntryNotReady: errors = errors + 1 _LOGGER.warning("Unable to retrieve Environment Canada weather") radar_data = ECRadar(coordinates=(lat, lon)) - coordinators["radar_coordinator"] = ECDataUpdateCoordinator( - hass, radar_data, "radar", DEFAULT_RADAR_UPDATE_INTERVAL + radar_coordinator = ECDataUpdateCoordinator( + hass, config_entry, radar_data, "radar", DEFAULT_RADAR_UPDATE_INTERVAL ) try: - await coordinators["radar_coordinator"].async_config_entry_first_refresh() + await radar_coordinator.async_config_entry_first_refresh() except ConfigEntryNotReady: errors = errors + 1 _LOGGER.warning("Unable to retrieve Environment Canada radar") aqhi_data = ECAirQuality(coordinates=(lat, lon)) - coordinators["aqhi_coordinator"] = ECDataUpdateCoordinator( - hass, aqhi_data, "AQHI", DEFAULT_WEATHER_UPDATE_INTERVAL + aqhi_coordinator = ECDataUpdateCoordinator( + hass, config_entry, aqhi_data, "AQHI", DEFAULT_WEATHER_UPDATE_INTERVAL ) try: - await coordinators["aqhi_coordinator"].async_config_entry_first_refresh() + await aqhi_coordinator.async_config_entry_first_refresh() except ConfigEntryNotReady: errors = errors + 1 _LOGGER.warning("Unable to retrieve Environment Canada AQHI") @@ -69,26 +67,23 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b if errors == 3: raise ConfigEntryNotReady - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][config_entry.entry_id] = coordinators + config_entry.runtime_data = ECRuntimeData( + aqhi_coordinator=aqhi_coordinator, + radar_coordinator=radar_coordinator, + weather_coordinator=weather_coordinator, + ) await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, config_entry: ECConfigEntry) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms( - config_entry, PLATFORMS - ) - - hass.data[DOMAIN].pop(config_entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) -def device_info(config_entry: ConfigEntry) -> DeviceInfo: +def device_info(config_entry: ECConfigEntry) -> DeviceInfo: """Build and return the device info for EC.""" return DeviceInfo( entry_type=DeviceEntryType.SERVICE, diff --git a/homeassistant/components/environment_canada/camera.py b/homeassistant/components/environment_canada/camera.py index 1625cd253da..d0497d855e5 100644 --- a/homeassistant/components/environment_canada/camera.py +++ b/homeassistant/components/environment_canada/camera.py @@ -5,7 +5,6 @@ from __future__ import annotations import voluptuous as vol from homeassistant.components.camera import Camera -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import ( AddEntitiesCallback, @@ -15,7 +14,8 @@ from homeassistant.helpers.typing import VolDictType from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import device_info -from .const import ATTR_OBSERVATION_TIME, DOMAIN +from .const import ATTR_OBSERVATION_TIME +from .coordinator import ECConfigEntry SERVICE_SET_RADAR_TYPE = "set_radar_type" SET_RADAR_TYPE_SCHEMA: VolDictType = { @@ -25,11 +25,11 @@ SET_RADAR_TYPE_SCHEMA: VolDictType = { async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: ECConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Add a weather entity from a config_entry.""" - coordinator = hass.data[DOMAIN][config_entry.entry_id]["radar_coordinator"] + coordinator = config_entry.runtime_data.radar_coordinator async_add_entities([ECCamera(coordinator)]) platform = async_get_current_platform() diff --git a/homeassistant/components/environment_canada/coordinator.py b/homeassistant/components/environment_canada/coordinator.py index 8e77b309c78..8161e26028c 100644 --- a/homeassistant/components/environment_canada/coordinator.py +++ b/homeassistant/components/environment_canada/coordinator.py @@ -1,29 +1,59 @@ """Coordinator for the Environment Canada (EC) component.""" +from __future__ import annotations + +from dataclasses import dataclass +from datetime import timedelta import logging import xml.etree.ElementTree as ET -from env_canada import ec_exc +from env_canada import ECAirQuality, ECRadar, ECWeather, ec_exc +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN _LOGGER = logging.getLogger(__name__) +type ECConfigEntry = ConfigEntry[ECRuntimeData] -class ECDataUpdateCoordinator(DataUpdateCoordinator): + +@dataclass +class ECRuntimeData: + """Class to hold EC runtime data.""" + + aqhi_coordinator: ECDataUpdateCoordinator[ECAirQuality] + radar_coordinator: ECDataUpdateCoordinator[ECRadar] + weather_coordinator: ECDataUpdateCoordinator[ECWeather] + + +class ECDataUpdateCoordinator[_ECDataTypeT: ECAirQuality | ECRadar | ECWeather]( + DataUpdateCoordinator[_ECDataTypeT] +): """Class to manage fetching EC data.""" - def __init__(self, hass, ec_data, name, update_interval): + def __init__( + self, + hass: HomeAssistant, + entry: ECConfigEntry, + ec_data: _ECDataTypeT, + name: str, + update_interval: timedelta, + ) -> None: """Initialize global EC data updater.""" super().__init__( - hass, _LOGGER, name=f"{DOMAIN} {name}", update_interval=update_interval + hass, + _LOGGER, + config_entry=entry, + name=f"{DOMAIN} {name}", + update_interval=update_interval, ) self.ec_data = ec_data self.last_update_success = False - async def _async_update_data(self): + async def _async_update_data(self) -> _ECDataTypeT: """Fetch data from EC.""" try: await self.ec_data.update() diff --git a/homeassistant/components/environment_canada/diagnostics.py b/homeassistant/components/environment_canada/diagnostics.py index 0fb565fda59..024cca15f12 100644 --- a/homeassistant/components/environment_canada/diagnostics.py +++ b/homeassistant/components/environment_canada/diagnostics.py @@ -5,23 +5,21 @@ from __future__ import annotations from typing import Any from homeassistant.components.diagnostics import async_redact_data -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE from homeassistant.core import HomeAssistant -from .const import DOMAIN +from .coordinator import ECConfigEntry TO_REDACT = {CONF_LATITUDE, CONF_LONGITUDE} async def async_get_config_entry_diagnostics( - hass: HomeAssistant, config_entry: ConfigEntry + hass: HomeAssistant, config_entry: ECConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - coordinators = hass.data[DOMAIN][config_entry.entry_id] - weather_coord = coordinators["weather_coordinator"] - return { "config_entry_data": async_redact_data(dict(config_entry.data), TO_REDACT), - "weather_data": dict(weather_coord.ec_data.conditions), + "weather_data": dict( + config_entry.runtime_data.weather_coordinator.ec_data.conditions + ), } diff --git a/homeassistant/components/environment_canada/sensor.py b/homeassistant/components/environment_canada/sensor.py index 1a5d096203d..ddececa8132 100644 --- a/homeassistant/components/environment_canada/sensor.py +++ b/homeassistant/components/environment_canada/sensor.py @@ -12,7 +12,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_LOCATION, DEGREE, @@ -28,7 +27,8 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import device_info -from .const import ATTR_STATION, DOMAIN +from .const import ATTR_STATION +from .coordinator import ECConfigEntry ATTR_TIME = "alert time" @@ -251,15 +251,17 @@ ALERT_TYPES: tuple[ECSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: ECConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Add a weather entity from a config_entry.""" - coordinator = hass.data[DOMAIN][config_entry.entry_id]["weather_coordinator"] - sensors: list[ECBaseSensor] = [ECSensor(coordinator, desc) for desc in SENSOR_TYPES] - sensors.extend([ECAlertSensor(coordinator, desc) for desc in ALERT_TYPES]) - aqhi_coordinator = hass.data[DOMAIN][config_entry.entry_id]["aqhi_coordinator"] - sensors.append(ECSensor(aqhi_coordinator, AQHI_SENSOR)) + weather_coordinator = config_entry.runtime_data.weather_coordinator + sensors: list[ECBaseSensor] = [ + ECSensor(weather_coordinator, desc) for desc in SENSOR_TYPES + ] + sensors.extend([ECAlertSensor(weather_coordinator, desc) for desc in ALERT_TYPES]) + + sensors.append(ECSensor(config_entry.runtime_data.aqhi_coordinator, AQHI_SENSOR)) async_add_entities(sensors) diff --git a/homeassistant/components/environment_canada/weather.py b/homeassistant/components/environment_canada/weather.py index 1871062c2e9..e49164d6b81 100644 --- a/homeassistant/components/environment_canada/weather.py +++ b/homeassistant/components/environment_canada/weather.py @@ -27,7 +27,6 @@ from homeassistant.components.weather import ( SingleCoordinatorWeatherEntity, WeatherEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( UnitOfLength, UnitOfPressure, @@ -40,6 +39,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import device_info from .const import DOMAIN +from .coordinator import ECConfigEntry # Icon codes from http://dd.weatheroffice.ec.gc.ca/citypage_weather/ # docs/current_conditions_icon_code_descriptions_e.csv @@ -61,11 +61,10 @@ ICON_CONDITION_MAP = { async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: ECConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Add a weather entity from a config_entry.""" - coordinator = hass.data[DOMAIN][config_entry.entry_id]["weather_coordinator"] entity_registry = er.async_get(hass) # Remove hourly entity from legacy config entries @@ -76,7 +75,7 @@ async def async_setup_entry( ): entity_registry.async_remove(hourly_entity_id) - async_add_entities([ECWeather(coordinator)]) + async_add_entities([ECWeather(config_entry.runtime_data.weather_coordinator)]) def _calculate_unique_id(config_entry_unique_id: str | None, hourly: bool) -> str: From 0c6c9e0ae6e09e053d5f35785386cdce0b9d3997 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 29 Jan 2025 09:04:24 +0100 Subject: [PATCH 0196/3148] Use runtime_data in elmax (#136803) --- homeassistant/components/elmax/__init__.py | 30 +++++-------------- .../components/elmax/alarm_control_panel.py | 7 ++--- .../components/elmax/binary_sensor.py | 8 ++--- homeassistant/components/elmax/coordinator.py | 19 ++++++++---- homeassistant/components/elmax/cover.py | 8 ++--- homeassistant/components/elmax/switch.py | 8 ++--- .../elmax/test_alarm_control_panel.py | 2 +- 7 files changed, 34 insertions(+), 48 deletions(-) diff --git a/homeassistant/components/elmax/__init__.py b/homeassistant/components/elmax/__init__.py index d85e5778a39..ec293be8273 100644 --- a/homeassistant/components/elmax/__init__.py +++ b/homeassistant/components/elmax/__init__.py @@ -2,14 +2,10 @@ from __future__ import annotations -from datetime import timedelta -import logging - from elmax_api.exceptions import ElmaxBadLoginError from elmax_api.http import Elmax, ElmaxLocal, GenericElmax from elmax_api.model.panel import PanelEntry -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import Event, HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed @@ -27,17 +23,13 @@ from .const import ( CONF_ELMAX_PANEL_PIN, CONF_ELMAX_PASSWORD, CONF_ELMAX_USERNAME, - DOMAIN, ELMAX_PLATFORMS, - POLLING_SECONDS, ) -from .coordinator import ElmaxCoordinator - -_LOGGER = logging.getLogger(__name__) +from .coordinator import ElmaxConfigEntry, ElmaxCoordinator async def _load_elmax_panel_client( - entry: ConfigEntry, + entry: ElmaxConfigEntry, ) -> tuple[GenericElmax, PanelEntry]: # Connection mode was not present in initial version, default to cloud if not set mode = entry.data.get(CONF_ELMAX_MODE, CONF_ELMAX_MODE_CLOUD) @@ -87,7 +79,7 @@ async def _check_cloud_panel_status(client: Elmax, panel_id: str) -> PanelEntry: return panel -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: ElmaxConfigEntry) -> bool: """Set up elmax-cloud from a config entry.""" try: client, panel = await _load_elmax_panel_client(entry) @@ -98,11 +90,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # if there is something wrong with user credentials coordinator = ElmaxCoordinator( hass=hass, - logger=_LOGGER, + entry=entry, elmax_api_client=client, panel=panel, - name=f"Elmax Cloud {entry.entry_id}", - update_interval=timedelta(seconds=POLLING_SECONDS), ) async def _async_on_hass_stop(_: Event) -> None: @@ -117,7 +107,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await coordinator.async_config_entry_first_refresh() # Store a global reference to the coordinator for later use - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + entry.runtime_data = coordinator entry.async_on_unload(entry.add_update_listener(async_reload_entry)) @@ -126,15 +116,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def async_reload_entry(hass: HomeAssistant, entry: ElmaxConfigEntry) -> None: """Handle an options update.""" await hass.config_entries.async_reload(entry.entry_id) -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: ElmaxConfigEntry) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, ELMAX_PLATFORMS) - if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, ELMAX_PLATFORMS) diff --git a/homeassistant/components/elmax/alarm_control_panel.py b/homeassistant/components/elmax/alarm_control_panel.py index 841b94a3d72..139c9080c15 100644 --- a/homeassistant/components/elmax/alarm_control_panel.py +++ b/homeassistant/components/elmax/alarm_control_panel.py @@ -13,23 +13,22 @@ from homeassistant.components.alarm_control_panel import ( AlarmControlPanelState, CodeFormat, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError, InvalidStateError from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN -from .coordinator import ElmaxCoordinator +from .coordinator import ElmaxConfigEntry from .entity import ElmaxEntity async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: ElmaxConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Elmax area platform.""" - coordinator: ElmaxCoordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data known_devices = set() def _discover_new_devices(): diff --git a/homeassistant/components/elmax/binary_sensor.py b/homeassistant/components/elmax/binary_sensor.py index ec51f861819..351c386a084 100644 --- a/homeassistant/components/elmax/binary_sensor.py +++ b/homeassistant/components/elmax/binary_sensor.py @@ -8,22 +8,20 @@ from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN -from .coordinator import ElmaxCoordinator +from .coordinator import ElmaxConfigEntry from .entity import ElmaxEntity async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: ElmaxConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Elmax sensor platform.""" - coordinator: ElmaxCoordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data known_devices = set() def _discover_new_devices(): diff --git a/homeassistant/components/elmax/coordinator.py b/homeassistant/components/elmax/coordinator.py index 844a3413089..abcc098359e 100644 --- a/homeassistant/components/elmax/coordinator.py +++ b/homeassistant/components/elmax/coordinator.py @@ -4,7 +4,7 @@ from __future__ import annotations from asyncio import timeout from datetime import timedelta -from logging import Logger +import logging from elmax_api.exceptions import ( ElmaxApiError, @@ -22,11 +22,16 @@ from elmax_api.model.panel import PanelEntry, PanelStatus from elmax_api.push.push import PushNotificationHandler from httpx import ConnectError, ConnectTimeout +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import DEFAULT_TIMEOUT +from .const import DEFAULT_TIMEOUT, POLLING_SECONDS + +_LOGGER = logging.getLogger(__name__) + +type ElmaxConfigEntry = ConfigEntry[ElmaxCoordinator] class ElmaxCoordinator(DataUpdateCoordinator[PanelStatus]): @@ -37,11 +42,9 @@ class ElmaxCoordinator(DataUpdateCoordinator[PanelStatus]): def __init__( self, hass: HomeAssistant, - logger: Logger, + entry: ElmaxConfigEntry, elmax_api_client: GenericElmax, panel: PanelEntry, - name: str, - update_interval: timedelta, ) -> None: """Instantiate the object.""" self._client = elmax_api_client @@ -49,7 +52,11 @@ class ElmaxCoordinator(DataUpdateCoordinator[PanelStatus]): self._state_by_endpoint = {} self._push_notification_handler = None super().__init__( - hass=hass, logger=logger, name=name, update_interval=update_interval + hass=hass, + config_entry=entry, + logger=_LOGGER, + name=f"Elmax Cloud {entry.entry_id}", + update_interval=timedelta(seconds=POLLING_SECONDS), ) @property diff --git a/homeassistant/components/elmax/cover.py b/homeassistant/components/elmax/cover.py index 403bc51dbff..e98477fe496 100644 --- a/homeassistant/components/elmax/cover.py +++ b/homeassistant/components/elmax/cover.py @@ -9,12 +9,10 @@ from elmax_api.model.command import CoverCommand from elmax_api.model.cover_status import CoverStatus from homeassistant.components.cover import CoverEntity, CoverEntityFeature -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN -from .coordinator import ElmaxCoordinator +from .coordinator import ElmaxConfigEntry from .entity import ElmaxEntity _LOGGER = logging.getLogger(__name__) @@ -28,11 +26,11 @@ _COMMAND_BY_MOTION_STATUS = { # Maps the stop command to use for every cover mo async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: ElmaxConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Elmax cover platform.""" - coordinator: ElmaxCoordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data # Add the cover feature only if supported by the current panel. if coordinator.data is None or not coordinator.data.cover_feature: return diff --git a/homeassistant/components/elmax/switch.py b/homeassistant/components/elmax/switch.py index d0e52c556f6..70faa44cf01 100644 --- a/homeassistant/components/elmax/switch.py +++ b/homeassistant/components/elmax/switch.py @@ -8,12 +8,10 @@ from elmax_api.model.command import SwitchCommand from elmax_api.model.panel import PanelStatus from homeassistant.components.switch import SwitchEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN -from .coordinator import ElmaxCoordinator +from .coordinator import ElmaxConfigEntry from .entity import ElmaxEntity _LOGGER = logging.getLogger(__name__) @@ -21,11 +19,11 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: ElmaxConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Elmax switch platform.""" - coordinator: ElmaxCoordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data known_devices = set() def _discover_new_devices(): diff --git a/tests/components/elmax/test_alarm_control_panel.py b/tests/components/elmax/test_alarm_control_panel.py index 76dc8845662..88fc0a33c51 100644 --- a/tests/components/elmax/test_alarm_control_panel.py +++ b/tests/components/elmax/test_alarm_control_panel.py @@ -5,7 +5,7 @@ from unittest.mock import patch from syrupy import SnapshotAssertion -from homeassistant.components.elmax import POLLING_SECONDS +from homeassistant.components.elmax.const import POLLING_SECONDS from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er From 447096b295f426db2e81d4cb0387454c5df6f18c Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Wed, 29 Jan 2025 18:12:36 +1000 Subject: [PATCH 0197/3148] Fix percentage_charged in Teslemetry (#136798) Fix percentage_charged --- homeassistant/components/teslemetry/sensor.py | 40 ++++++++++++------- 1 file changed, 25 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/teslemetry/sensor.py b/homeassistant/components/teslemetry/sensor.py index 0fb0a6ee0e0..dd83ad04ed6 100644 --- a/homeassistant/components/teslemetry/sensor.py +++ b/homeassistant/components/teslemetry/sensor.py @@ -4,7 +4,7 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass -from datetime import timedelta +from datetime import datetime, timedelta from propcache.api import cached_property from teslemetry_stream import Signal @@ -369,8 +369,16 @@ VEHICLE_TIME_DESCRIPTIONS: tuple[TeslemetryTimeEntityDescription, ...] = ( ), ) -ENERGY_LIVE_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = ( - SensorEntityDescription( + +@dataclass(frozen=True, kw_only=True) +class TeslemetryEnergySensorEntityDescription(SensorEntityDescription): + """Describes Teslemetry Sensor entity.""" + + value_fn: Callable[[StateType], StateType | datetime] = lambda x: x + + +ENERGY_LIVE_DESCRIPTIONS: tuple[TeslemetryEnergySensorEntityDescription, ...] = ( + TeslemetryEnergySensorEntityDescription( key="solar_power", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPower.WATT, @@ -378,7 +386,7 @@ ENERGY_LIVE_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = ( suggested_display_precision=2, device_class=SensorDeviceClass.POWER, ), - SensorEntityDescription( + TeslemetryEnergySensorEntityDescription( key="energy_left", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, @@ -387,7 +395,7 @@ ENERGY_LIVE_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = ( device_class=SensorDeviceClass.ENERGY_STORAGE, entity_category=EntityCategory.DIAGNOSTIC, ), - SensorEntityDescription( + TeslemetryEnergySensorEntityDescription( key="total_pack_energy", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, @@ -397,14 +405,15 @@ ENERGY_LIVE_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = ( entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), - SensorEntityDescription( + TeslemetryEnergySensorEntityDescription( key="percentage_charged", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, suggested_display_precision=2, + value_fn=lambda value: value or 0, ), - SensorEntityDescription( + TeslemetryEnergySensorEntityDescription( key="battery_power", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPower.WATT, @@ -412,7 +421,7 @@ ENERGY_LIVE_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = ( suggested_display_precision=2, device_class=SensorDeviceClass.POWER, ), - SensorEntityDescription( + TeslemetryEnergySensorEntityDescription( key="load_power", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPower.WATT, @@ -420,7 +429,7 @@ ENERGY_LIVE_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = ( suggested_display_precision=2, device_class=SensorDeviceClass.POWER, ), - SensorEntityDescription( + TeslemetryEnergySensorEntityDescription( key="grid_power", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPower.WATT, @@ -428,7 +437,7 @@ ENERGY_LIVE_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = ( suggested_display_precision=2, device_class=SensorDeviceClass.POWER, ), - SensorEntityDescription( + TeslemetryEnergySensorEntityDescription( key="grid_services_power", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPower.WATT, @@ -436,7 +445,7 @@ ENERGY_LIVE_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = ( suggested_display_precision=2, device_class=SensorDeviceClass.POWER, ), - SensorEntityDescription( + TeslemetryEnergySensorEntityDescription( key="generator_power", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPower.WATT, @@ -445,7 +454,7 @@ ENERGY_LIVE_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = ( device_class=SensorDeviceClass.POWER, entity_registry_enabled_default=False, ), - SensorEntityDescription( + TeslemetryEnergySensorEntityDescription( key="island_status", device_class=SensorDeviceClass.ENUM, options=[ @@ -555,6 +564,7 @@ async def async_setup_entry( if energysite.live_coordinator for description in ENERGY_LIVE_DESCRIPTIONS if description.key in energysite.live_coordinator.data + or description.key == "percentage_charged" ) entities.extend( @@ -704,12 +714,12 @@ class TeslemetryVehicleTimeSensorEntity(TeslemetryVehicleEntity, SensorEntity): class TeslemetryEnergyLiveSensorEntity(TeslemetryEnergyLiveEntity, SensorEntity): """Base class for Teslemetry energy site metric sensors.""" - entity_description: SensorEntityDescription + entity_description: TeslemetryEnergySensorEntityDescription def __init__( self, data: TeslemetryEnergyData, - description: SensorEntityDescription, + description: TeslemetryEnergySensorEntityDescription, ) -> None: """Initialize the sensor.""" self.entity_description = description @@ -718,7 +728,7 @@ class TeslemetryEnergyLiveSensorEntity(TeslemetryEnergyLiveEntity, SensorEntity) def _async_update_attrs(self) -> None: """Update the attributes of the sensor.""" self._attr_available = not self.is_none - self._attr_native_value = self._value + self._attr_native_value = self.entity_description.value_fn(self._value) class TeslemetryWallConnectorSensorEntity(TeslemetryWallConnectorEntity, SensorEntity): From 609eb00a2615eaab29285ff0dc1913d6908b0632 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Wed, 29 Jan 2025 09:16:30 +0100 Subject: [PATCH 0198/3148] Add remaining Matter Operational State sensor discovery schemas (#136741) --- homeassistant/components/matter/sensor.py | 76 ++++++++++++++++++- homeassistant/components/matter/strings.json | 5 +- .../matter/snapshots/test_sensor.ambr | 66 ++++++++++++++++ 3 files changed, 144 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/matter/sensor.py b/homeassistant/components/matter/sensor.py index 40b25d14c46..eaab91136c9 100644 --- a/homeassistant/components/matter/sensor.py +++ b/homeassistant/components/matter/sensor.py @@ -72,6 +72,9 @@ OPERATIONAL_STATE_MAP = { clusters.OperationalState.Enums.OperationalStateEnum.kRunning: "running", clusters.OperationalState.Enums.OperationalStateEnum.kPaused: "paused", clusters.OperationalState.Enums.OperationalStateEnum.kError: "error", + clusters.RvcOperationalState.Enums.OperationalStateEnum.kSeekingCharger: "seeking_charger", + clusters.RvcOperationalState.Enums.OperationalStateEnum.kCharging: "charging", + clusters.RvcOperationalState.Enums.OperationalStateEnum.kDocked: "docked", } @@ -98,6 +101,18 @@ class MatterListSensorEntityDescription(MatterSensorEntityDescription): list_attribute: type[ClusterAttributeDescriptor] +@dataclass(frozen=True, kw_only=True) +class MatterOperationalStateSensorEntityDescription(MatterSensorEntityDescription): + """Describe Matter sensor entities from Matter OperationalState objects.""" + + # list attribute: the attribute descriptor to get the list of values (= list of structs) + # needs to be set for handling OperationalState not on the OperationalState cluster, but + # on one of its derived clusters (e.g. RvcOperationalState) + state_list_attribute: type[ClusterAttributeDescriptor] = ( + clusters.OperationalState.Attributes.OperationalStateList + ) + + class MatterSensor(MatterEntity, SensorEntity): """Representation of a Matter sensor.""" @@ -147,6 +162,7 @@ class MatterDraftElectricalMeasurementSensor(MatterEntity, SensorEntity): class MatterOperationalStateSensor(MatterSensor): """Representation of a sensor for Matter Operational State.""" + entity_description: MatterOperationalStateSensorEntityDescription states_map: dict[int, str] @callback @@ -157,10 +173,11 @@ class MatterOperationalStateSensor(MatterSensor): # therefore it is not possible to provide a fixed list of options # or to provide a mapping to a translateable string for all options operational_state_list = self.get_matter_attribute_value( - clusters.OperationalState.Attributes.OperationalStateList + self.entity_description.state_list_attribute ) if TYPE_CHECKING: operational_state_list = cast( + # cast to the generic OperationalStateStruct type just to help typing list[clusters.OperationalState.Structs.OperationalStateStruct], operational_state_list, ) @@ -782,7 +799,7 @@ DISCOVERY_SCHEMAS = [ ), MatterDiscoverySchema( platform=Platform.SENSOR, - entity_description=MatterSensorEntityDescription( + entity_description=MatterOperationalStateSensorEntityDescription( key="OperationalState", device_class=SensorDeviceClass.ENUM, translation_key="operational_state", @@ -806,6 +823,32 @@ DISCOVERY_SCHEMAS = [ clusters.OperationalState.Attributes.PhaseList, ), ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterListSensorEntityDescription( + key="RvcOperationalStateCurrentPhase", + translation_key="current_phase", + list_attribute=clusters.RvcOperationalState.Attributes.PhaseList, + ), + entity_class=MatterListSensor, + required_attributes=( + clusters.RvcOperationalState.Attributes.CurrentPhase, + clusters.RvcOperationalState.Attributes.PhaseList, + ), + ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterListSensorEntityDescription( + key="OvenCavityOperationalStateCurrentPhase", + translation_key="current_phase", + list_attribute=clusters.OvenCavityOperationalState.Attributes.PhaseList, + ), + entity_class=MatterListSensor, + required_attributes=( + clusters.OvenCavityOperationalState.Attributes.CurrentPhase, + clusters.OvenCavityOperationalState.Attributes.PhaseList, + ), + ), MatterDiscoverySchema( platform=Platform.SENSOR, entity_description=MatterSensorEntityDescription( @@ -820,4 +863,33 @@ DISCOVERY_SCHEMAS = [ device_type=(device_types.Thermostat,), allow_multi=True, # also used for climate entity ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterOperationalStateSensorEntityDescription( + key="RvcOperationalState", + device_class=SensorDeviceClass.ENUM, + translation_key="operational_state", + state_list_attribute=clusters.RvcOperationalState.Attributes.OperationalStateList, + ), + entity_class=MatterOperationalStateSensor, + required_attributes=( + clusters.RvcOperationalState.Attributes.OperationalState, + clusters.RvcOperationalState.Attributes.OperationalStateList, + ), + allow_multi=True, # also used for vacuum entity + ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterOperationalStateSensorEntityDescription( + key="OvenCavityOperationalState", + device_class=SensorDeviceClass.ENUM, + translation_key="operational_state", + state_list_attribute=clusters.OvenCavityOperationalState.Attributes.OperationalStateList, + ), + entity_class=MatterOperationalStateSensor, + required_attributes=( + clusters.OvenCavityOperationalState.Attributes.OperationalState, + clusters.OvenCavityOperationalState.Attributes.OperationalStateList, + ), + ), ] diff --git a/homeassistant/components/matter/strings.json b/homeassistant/components/matter/strings.json index 8bac67a4ca7..73ce41937fd 100644 --- a/homeassistant/components/matter/strings.json +++ b/homeassistant/components/matter/strings.json @@ -246,7 +246,10 @@ "stopped": "Stopped", "running": "Running", "paused": "[%key:common::state::paused%]", - "error": "Error" + "error": "Error", + "seeking_charger": "Seeking charger", + "charging": "Charging", + "docked": "Docked" } }, "switch_current_position": { diff --git a/tests/components/matter/snapshots/test_sensor.ambr b/tests/components/matter/snapshots/test_sensor.ambr index d9bc0bdf1fc..541f1bc178f 100644 --- a/tests/components/matter/snapshots/test_sensor.ambr +++ b/tests/components/matter/snapshots/test_sensor.ambr @@ -3412,6 +3412,72 @@ 'state': '28.3', }) # --- +# name: test_sensors[vacuum_cleaner][sensor.mock_vacuum_operational_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'stopped', + 'running', + 'paused', + 'error', + 'seeking_charger', + 'charging', + 'docked', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_vacuum_operational_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Operational state', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'operational_state', + 'unique_id': '00000000000004D2-0000000000000042-MatterNodeDevice-1-RvcOperationalState-97-4', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[vacuum_cleaner][sensor.mock_vacuum_operational_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Mock Vacuum Operational state', + 'options': list([ + 'stopped', + 'running', + 'paused', + 'error', + 'seeking_charger', + 'charging', + 'docked', + ]), + }), + 'context': , + 'entity_id': 'sensor.mock_vacuum_operational_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_sensors[yandex_smart_socket][sensor.yndx_00540_current-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 11671e1875f491b75f106455565512517b0163e7 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 29 Jan 2025 09:36:53 +0100 Subject: [PATCH 0199/3148] Use runtime_data in energenie_power_sockets (#136801) * Use runtime_data in energenie_power_sockets * Fix tests --- .../energenie_power_sockets/__init__.py | 20 ++++++++----------- .../energenie_power_sockets/switch.py | 6 +++--- .../energenie_power_sockets/conftest.py | 2 +- .../energenie_power_sockets/test_init.py | 3 --- .../energenie_power_sockets/test_switch.py | 2 -- 5 files changed, 12 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/energenie_power_sockets/__init__.py b/homeassistant/components/energenie_power_sockets/__init__.py index 12ddb0d1389..0496c6f9b92 100644 --- a/homeassistant/components/energenie_power_sockets/__init__.py +++ b/homeassistant/components/energenie_power_sockets/__init__.py @@ -8,12 +8,14 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady -from .const import CONF_DEVICE_API_ID, DOMAIN +from .const import CONF_DEVICE_API_ID PLATFORMS = [Platform.SWITCH] +type EnergenieConfigEntry = ConfigEntry[PowerStripUSB] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + +async def async_setup_entry(hass: HomeAssistant, entry: EnergenieConfigEntry) -> bool: """Set up Energenie Power Sockets.""" try: powerstrip: PowerStripUSB | None = get_device(entry.data[CONF_DEVICE_API_ID]) @@ -26,19 +28,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: "Can't access Energenie Power Sockets, will retry later." ) - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = powerstrip + entry.runtime_data = powerstrip + entry.async_on_unload(powerstrip.release) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: EnergenieConfigEntry) -> bool: """Unload config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - powerstrip = hass.data[DOMAIN].pop(entry.entry_id) - powerstrip.release() - - if not hass.data[DOMAIN]: - hass.data.pop(DOMAIN) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/energenie_power_sockets/switch.py b/homeassistant/components/energenie_power_sockets/switch.py index 1d5b9ed5197..e4fb7653e5e 100644 --- a/homeassistant/components/energenie_power_sockets/switch.py +++ b/homeassistant/components/energenie_power_sockets/switch.py @@ -7,22 +7,22 @@ from pyegps.exceptions import EgpsException from pyegps.powerstrip import PowerStrip from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback +from . import EnergenieConfigEntry from .const import DOMAIN async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: EnergenieConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Add EGPS sockets for passed config_entry in HA.""" - powerstrip: PowerStrip = hass.data[DOMAIN][config_entry.entry_id] + powerstrip = config_entry.runtime_data async_add_entities( ( diff --git a/tests/components/energenie_power_sockets/conftest.py b/tests/components/energenie_power_sockets/conftest.py index c142e436fd3..d0301034cf8 100644 --- a/tests/components/energenie_power_sockets/conftest.py +++ b/tests/components/energenie_power_sockets/conftest.py @@ -44,7 +44,7 @@ def get_pyegps_device_mock() -> MagicMock: fkObj = FakePowerStrip( devId=DEMO_CONFIG_DATA[CONF_DEVICE_API_ID], number_of_sockets=4 ) - fkObj.release = lambda: True + fkObj.release = lambda: None fkObj._status = [0, 1, 0, 1] usb_device_mock = MagicMock(wraps=fkObj) diff --git a/tests/components/energenie_power_sockets/test_init.py b/tests/components/energenie_power_sockets/test_init.py index 4e2fe51665b..a11cef319b2 100644 --- a/tests/components/energenie_power_sockets/test_init.py +++ b/tests/components/energenie_power_sockets/test_init.py @@ -4,7 +4,6 @@ from unittest.mock import MagicMock from pyegps.exceptions import UsbError -from homeassistant.components.energenie_power_sockets.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant @@ -24,13 +23,11 @@ async def test_load_unload_entry( await hass.async_block_till_done() assert entry.state is ConfigEntryState.LOADED - assert entry.entry_id in hass.data[DOMAIN] assert await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() assert entry.state is ConfigEntryState.NOT_LOADED - assert DOMAIN not in hass.data async def test_device_not_found_on_load_entry( diff --git a/tests/components/energenie_power_sockets/test_switch.py b/tests/components/energenie_power_sockets/test_switch.py index 4cd2bd60028..27f13390a83 100644 --- a/tests/components/energenie_power_sockets/test_switch.py +++ b/tests/components/energenie_power_sockets/test_switch.py @@ -6,7 +6,6 @@ from pyegps.exceptions import EgpsException import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components.energenie_power_sockets.const import DOMAIN from homeassistant.components.homeassistant import ( DOMAIN as HOME_ASSISTANT_DOMAIN, SERVICE_UPDATE_ENTITY, @@ -118,7 +117,6 @@ async def test_switch_setup( await hass.async_block_till_done() assert entry.state is ConfigEntryState.LOADED - assert entry.entry_id in hass.data[DOMAIN] state = hass.states.get(f"switch.{entity_name}") assert state == snapshot From 9169d55cf6d167c0bce1581a7e5845f456d19b9d Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Wed, 29 Jan 2025 09:49:02 +0100 Subject: [PATCH 0200/3148] Use ConfigEntry.runtime_data in AVM Fritz!Box tools (#136386) * implement FritzConfigEntry with runtime_data * use HassKey for platform global data * update quality scale * fix after rebase * use FritzConfigEntry everywhere possible * fix import of FritzConfigEntry in services.py * pass the config_entry explicitly in coordinator init * improve typing of FritzData * use FritzConfigEntry in config_flow.py --- homeassistant/components/fritz/__init__.py | 30 ++++++++----------- .../components/fritz/binary_sensor.py | 10 +++---- homeassistant/components/fritz/button.py | 18 +++++++---- homeassistant/components/fritz/config_flow.py | 12 +++----- homeassistant/components/fritz/const.py | 2 -- homeassistant/components/fritz/coordinator.py | 19 ++++++++---- .../components/fritz/device_tracker.py | 12 ++++---- homeassistant/components/fritz/diagnostics.py | 8 ++--- homeassistant/components/fritz/image.py | 8 ++--- .../components/fritz/quality_scale.yaml | 4 +-- homeassistant/components/fritz/sensor.py | 11 +++---- homeassistant/components/fritz/services.py | 6 ++-- homeassistant/components/fritz/switch.py | 12 ++++---- homeassistant/components/fritz/update.py | 10 +++---- 14 files changed, 81 insertions(+), 81 deletions(-) diff --git a/homeassistant/components/fritz/__init__.py b/homeassistant/components/fritz/__init__.py index 25888328cd2..05a2a07707f 100644 --- a/homeassistant/components/fritz/__init__.py +++ b/homeassistant/components/fritz/__init__.py @@ -2,7 +2,6 @@ import logging -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, @@ -16,14 +15,13 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from .const import ( - DATA_FRITZ, DEFAULT_SSL, DOMAIN, FRITZ_AUTH_EXCEPTIONS, FRITZ_EXCEPTIONS, PLATFORMS, ) -from .coordinator import AvmWrapper, FritzData +from .coordinator import FRITZ_DATA_KEY, AvmWrapper, FritzConfigEntry, FritzData from .services import async_setup_services _LOGGER = logging.getLogger(__name__) @@ -37,11 +35,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: FritzConfigEntry) -> bool: """Set up fritzboxtools from config entry.""" _LOGGER.debug("Setting up FRITZ!Box Tools component") avm_wrapper = AvmWrapper( hass=hass, + config_entry=entry, host=entry.data[CONF_HOST], port=entry.data[CONF_PORT], username=entry.data[CONF_USERNAME], @@ -64,11 +63,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await avm_wrapper.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = avm_wrapper + entry.runtime_data = avm_wrapper - if DATA_FRITZ not in hass.data: - hass.data[DATA_FRITZ] = FritzData() + if FRITZ_DATA_KEY not in hass.data: + hass.data[FRITZ_DATA_KEY] = FritzData() entry.async_on_unload(entry.add_update_listener(update_listener)) @@ -78,24 +76,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: FritzConfigEntry) -> bool: """Unload FRITZ!Box Tools config entry.""" - avm_wrapper: AvmWrapper = hass.data[DOMAIN][entry.entry_id] + avm_wrapper = entry.runtime_data - fritz_data = hass.data[DATA_FRITZ] + fritz_data = hass.data[FRITZ_DATA_KEY] fritz_data.tracked.pop(avm_wrapper.unique_id) if not bool(fritz_data.tracked): - hass.data.pop(DATA_FRITZ) + hass.data.pop(FRITZ_DATA_KEY) - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) -async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def update_listener(hass: HomeAssistant, entry: FritzConfigEntry) -> None: """Update when config_entry options update.""" if entry.options: await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/fritz/binary_sensor.py b/homeassistant/components/fritz/binary_sensor.py index cb1f698bdca..7553328a64c 100644 --- a/homeassistant/components/fritz/binary_sensor.py +++ b/homeassistant/components/fritz/binary_sensor.py @@ -11,13 +11,11 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN -from .coordinator import AvmWrapper, ConnectionInfo +from .coordinator import ConnectionInfo, FritzConfigEntry from .entity import FritzBoxBaseCoordinatorEntity, FritzEntityDescription _LOGGER = logging.getLogger(__name__) @@ -51,11 +49,13 @@ SENSOR_TYPES: tuple[FritzBinarySensorEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: FritzConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up entry.""" _LOGGER.debug("Setting up FRITZ!Box binary sensors") - avm_wrapper: AvmWrapper = hass.data[DOMAIN][entry.entry_id] + avm_wrapper = entry.runtime_data connection_info = await avm_wrapper.async_get_connection_info() diff --git a/homeassistant/components/fritz/button.py b/homeassistant/components/fritz/button.py index 263521d23f4..f3ffbe42099 100644 --- a/homeassistant/components/fritz/button.py +++ b/homeassistant/components/fritz/button.py @@ -12,15 +12,21 @@ from homeassistant.components.button import ( ButtonEntity, ButtonEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import BUTTON_TYPE_WOL, CONNECTION_TYPE_LAN, DATA_FRITZ, DOMAIN, MeshRoles -from .coordinator import AvmWrapper, FritzData, FritzDevice, _is_tracked +from .const import BUTTON_TYPE_WOL, CONNECTION_TYPE_LAN, DOMAIN, MeshRoles +from .coordinator import ( + FRITZ_DATA_KEY, + AvmWrapper, + FritzConfigEntry, + FritzData, + FritzDevice, + _is_tracked, +) from .entity import FritzDeviceBase _LOGGER = logging.getLogger(__name__) @@ -65,12 +71,12 @@ BUTTONS: Final = [ async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: FritzConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set buttons for device.""" _LOGGER.debug("Setting up buttons") - avm_wrapper: AvmWrapper = hass.data[DOMAIN][entry.entry_id] + avm_wrapper = entry.runtime_data entities_list: list[ButtonEntity] = [ FritzButton(avm_wrapper, entry.title, button) for button in BUTTONS @@ -80,7 +86,7 @@ async def async_setup_entry( async_add_entities(entities_list) return - data_fritz: FritzData = hass.data[DATA_FRITZ] + data_fritz = hass.data[FRITZ_DATA_KEY] entities_list += _async_wol_buttons_list(avm_wrapper, data_fritz) async_add_entities(entities_list) diff --git a/homeassistant/components/fritz/config_flow.py b/homeassistant/components/fritz/config_flow.py index 7b6057b3ba2..fb17f872cb6 100644 --- a/homeassistant/components/fritz/config_flow.py +++ b/homeassistant/components/fritz/config_flow.py @@ -17,12 +17,7 @@ from homeassistant.components.device_tracker import ( CONF_CONSIDER_HOME, DEFAULT_CONSIDER_HOME, ) -from homeassistant.config_entries import ( - ConfigEntry, - ConfigFlow, - ConfigFlowResult, - OptionsFlow, -) +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, @@ -53,6 +48,7 @@ from .const import ( ERROR_UPNP_NOT_CONFIGURED, FRITZ_AUTH_EXCEPTIONS, ) +from .coordinator import FritzConfigEntry _LOGGER = logging.getLogger(__name__) @@ -67,7 +63,7 @@ class FritzBoxToolsFlowHandler(ConfigFlow, domain=DOMAIN): @staticmethod @callback def async_get_options_flow( - config_entry: ConfigEntry, + config_entry: FritzConfigEntry, ) -> FritzBoxToolsOptionsFlowHandler: """Get the options flow for this handler.""" return FritzBoxToolsOptionsFlowHandler() @@ -116,7 +112,7 @@ class FritzBoxToolsFlowHandler(ConfigFlow, domain=DOMAIN): return None - async def async_check_configured_entry(self) -> ConfigEntry | None: + async def async_check_configured_entry(self) -> FritzConfigEntry | None: """Check if entry is configured.""" current_host = await self.hass.async_add_executor_job( socket.gethostbyname, self._host diff --git a/homeassistant/components/fritz/const.py b/homeassistant/components/fritz/const.py index f8f5b43f4b1..2237823bc3b 100644 --- a/homeassistant/components/fritz/const.py +++ b/homeassistant/components/fritz/const.py @@ -40,8 +40,6 @@ PLATFORMS = [ CONF_OLD_DISCOVERY = "old_discovery" DEFAULT_CONF_OLD_DISCOVERY = False -DATA_FRITZ = "fritz_data" - DSL_CONNECTION: Literal["dsl"] = "dsl" DEFAULT_DEVICE_NAME = "Unknown device" diff --git a/homeassistant/components/fritz/coordinator.py b/homeassistant/components/fritz/coordinator.py index 7f8ae6c5b3c..38d76c92871 100644 --- a/homeassistant/components/fritz/coordinator.py +++ b/homeassistant/components/fritz/coordinator.py @@ -36,6 +36,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import dt as dt_util +from homeassistant.util.hass_dict import HassKey from .const import ( CONF_OLD_DISCOVERY, @@ -50,8 +51,12 @@ from .const import ( _LOGGER = logging.getLogger(__name__) +FRITZ_DATA_KEY: HassKey[FritzData] = HassKey(DOMAIN) -def _is_tracked(mac: str, current_devices: ValuesView) -> bool: +type FritzConfigEntry = ConfigEntry[AvmWrapper] + + +def _is_tracked(mac: str, current_devices: ValuesView[set[str]]) -> bool: """Check if device is already tracked.""" return any(mac in tracked for tracked in current_devices) @@ -59,7 +64,7 @@ def _is_tracked(mac: str, current_devices: ValuesView) -> bool: def device_filter_out_from_trackers( mac: str, device: FritzDevice, - current_devices: ValuesView, + current_devices: ValuesView[set[str]], ) -> bool: """Check if device should be filtered out from trackers.""" reason: str | None = None @@ -160,11 +165,12 @@ class UpdateCoordinatorDataType(TypedDict): class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]): """FritzBoxTools class.""" - config_entry: ConfigEntry + config_entry: FritzConfigEntry def __init__( self, hass: HomeAssistant, + config_entry: FritzConfigEntry, password: str, port: int, username: str = DEFAULT_USERNAME, @@ -174,6 +180,7 @@ class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]): """Initialize FritzboxTools class.""" super().__init__( hass=hass, + config_entry=config_entry, logger=_LOGGER, name=f"{DOMAIN}-{host}-coordinator", update_interval=timedelta(seconds=30), @@ -869,9 +876,9 @@ class AvmWrapper(FritzBoxTools): class FritzData: """Storage class for platform global data.""" - tracked: dict = field(default_factory=dict) - profile_switches: dict = field(default_factory=dict) - wol_buttons: dict = field(default_factory=dict) + tracked: dict[str, set[str]] = field(default_factory=dict) + profile_switches: dict[str, set[str]] = field(default_factory=dict) + wol_buttons: dict[str, set[str]] = field(default_factory=dict) class FritzDevice: diff --git a/homeassistant/components/fritz/device_tracker.py b/homeassistant/components/fritz/device_tracker.py index d1270a0510c..ba3c9a5aab6 100644 --- a/homeassistant/components/fritz/device_tracker.py +++ b/homeassistant/components/fritz/device_tracker.py @@ -6,14 +6,14 @@ import datetime import logging from homeassistant.components.device_tracker import ScannerEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DATA_FRITZ, DOMAIN from .coordinator import ( + FRITZ_DATA_KEY, AvmWrapper, + FritzConfigEntry, FritzData, FritzDevice, device_filter_out_from_trackers, @@ -24,12 +24,14 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: FritzConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up device tracker for FRITZ!Box component.""" _LOGGER.debug("Starting FRITZ!Box device tracker") - avm_wrapper: AvmWrapper = hass.data[DOMAIN][entry.entry_id] - data_fritz: FritzData = hass.data[DATA_FRITZ] + avm_wrapper = entry.runtime_data + data_fritz = hass.data[FRITZ_DATA_KEY] @callback def update_avm_device() -> None: diff --git a/homeassistant/components/fritz/diagnostics.py b/homeassistant/components/fritz/diagnostics.py index 8823d55baa9..b9ae9edf04d 100644 --- a/homeassistant/components/fritz/diagnostics.py +++ b/homeassistant/components/fritz/diagnostics.py @@ -5,21 +5,19 @@ from __future__ import annotations from typing import Any from homeassistant.components.diagnostics import async_redact_data -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant -from .const import DOMAIN -from .coordinator import AvmWrapper +from .coordinator import FritzConfigEntry TO_REDACT = {CONF_USERNAME, CONF_PASSWORD} async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: FritzConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - avm_wrapper: AvmWrapper = hass.data[DOMAIN][entry.entry_id] + avm_wrapper = entry.runtime_data return { "entry": async_redact_data(entry.as_dict(), TO_REDACT), diff --git a/homeassistant/components/fritz/image.py b/homeassistant/components/fritz/image.py index 19c98446ccd..d305551b097 100644 --- a/homeassistant/components/fritz/image.py +++ b/homeassistant/components/fritz/image.py @@ -8,14 +8,12 @@ import logging from requests.exceptions import RequestException from homeassistant.components.image import ImageEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import dt as dt_util, slugify -from .const import DOMAIN -from .coordinator import AvmWrapper +from .coordinator import AvmWrapper, FritzConfigEntry from .entity import FritzBoxBaseEntity _LOGGER = logging.getLogger(__name__) @@ -23,11 +21,11 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: FritzConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up guest WiFi QR code for device.""" - avm_wrapper: AvmWrapper = hass.data[DOMAIN][entry.entry_id] + avm_wrapper = entry.runtime_data guest_wifi_info = await hass.async_add_executor_job( avm_wrapper.fritz_guest_wifi.get_info diff --git a/homeassistant/components/fritz/quality_scale.yaml b/homeassistant/components/fritz/quality_scale.yaml index d6fadd3a20e..805705eb4b4 100644 --- a/homeassistant/components/fritz/quality_scale.yaml +++ b/homeassistant/components/fritz/quality_scale.yaml @@ -22,9 +22,7 @@ rules: has-entity-name: status: todo comment: partially done - runtime-data: - status: todo - comment: still uses hass.data + runtime-data: done test-before-configure: done test-before-setup: done unique-config-entry: done diff --git a/homeassistant/components/fritz/sensor.py b/homeassistant/components/fritz/sensor.py index 11ee0ad5510..81b50bd21ac 100644 --- a/homeassistant/components/fritz/sensor.py +++ b/homeassistant/components/fritz/sensor.py @@ -15,7 +15,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( SIGNAL_STRENGTH_DECIBELS, EntityCategory, @@ -27,8 +26,8 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.util.dt import utcnow -from .const import DOMAIN, DSL_CONNECTION, UPTIME_DEVIATION -from .coordinator import AvmWrapper, ConnectionInfo +from .const import DSL_CONNECTION, UPTIME_DEVIATION +from .coordinator import ConnectionInfo, FritzConfigEntry from .entity import FritzBoxBaseCoordinatorEntity, FritzEntityDescription _LOGGER = logging.getLogger(__name__) @@ -267,11 +266,13 @@ SENSOR_TYPES: tuple[FritzSensorEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: FritzConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up entry.""" _LOGGER.debug("Setting up FRITZ!Box sensors") - avm_wrapper: AvmWrapper = hass.data[DOMAIN][entry.entry_id] + avm_wrapper = entry.runtime_data connection_info = await avm_wrapper.async_get_connection_info() diff --git a/homeassistant/components/fritz/services.py b/homeassistant/components/fritz/services.py index ac542be8631..02e6c91f4bf 100644 --- a/homeassistant/components/fritz/services.py +++ b/homeassistant/components/fritz/services.py @@ -15,7 +15,7 @@ from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers.service import async_extract_config_entry_ids from .const import DOMAIN -from .coordinator import AvmWrapper +from .coordinator import FritzConfigEntry _LOGGER = logging.getLogger(__name__) @@ -33,7 +33,7 @@ async def _async_set_guest_wifi_password(service_call: ServiceCall) -> None: """Call Fritz set guest wifi password service.""" hass = service_call.hass target_entry_ids = await async_extract_config_entry_ids(hass, service_call) - target_entries = [ + target_entries: list[FritzConfigEntry] = [ loaded_entry for loaded_entry in hass.config_entries.async_loaded_entries(DOMAIN) if loaded_entry.entry_id in target_entry_ids @@ -48,7 +48,7 @@ async def _async_set_guest_wifi_password(service_call: ServiceCall) -> None: for target_entry in target_entries: _LOGGER.debug("Executing service %s", service_call.service) - avm_wrapper: AvmWrapper = hass.data[DOMAIN][target_entry.entry_id] + avm_wrapper = target_entry.runtime_data try: await avm_wrapper.async_trigger_set_guest_password( service_call.data.get("password"), diff --git a/homeassistant/components/fritz/switch.py b/homeassistant/components/fritz/switch.py index 372af89cc9e..9c12fe0cecc 100644 --- a/homeassistant/components/fritz/switch.py +++ b/homeassistant/components/fritz/switch.py @@ -7,7 +7,6 @@ from typing import Any from homeassistant.components.network import async_get_source_ip from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo @@ -18,7 +17,6 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import slugify from .const import ( - DATA_FRITZ, DOMAIN, SWITCH_TYPE_DEFLECTION, SWITCH_TYPE_PORTFORWARD, @@ -28,7 +26,9 @@ from .const import ( MeshRoles, ) from .coordinator import ( + FRITZ_DATA_KEY, AvmWrapper, + FritzConfigEntry, FritzData, FritzDevice, SwitchInfo, @@ -220,12 +220,14 @@ async def async_all_entities_list( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: FritzConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up entry.""" _LOGGER.debug("Setting up switches") - avm_wrapper: AvmWrapper = hass.data[DOMAIN][entry.entry_id] - data_fritz: FritzData = hass.data[DATA_FRITZ] + avm_wrapper = entry.runtime_data + data_fritz = hass.data[FRITZ_DATA_KEY] _LOGGER.debug("Fritzbox services: %s", avm_wrapper.connection.services) diff --git a/homeassistant/components/fritz/update.py b/homeassistant/components/fritz/update.py index 6969f201f27..ad23a076ca6 100644 --- a/homeassistant/components/fritz/update.py +++ b/homeassistant/components/fritz/update.py @@ -11,13 +11,11 @@ from homeassistant.components.update import ( UpdateEntityDescription, UpdateEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN -from .coordinator import AvmWrapper +from .coordinator import AvmWrapper, FritzConfigEntry from .entity import FritzBoxBaseCoordinatorEntity, FritzEntityDescription _LOGGER = logging.getLogger(__name__) @@ -29,11 +27,13 @@ class FritzUpdateEntityDescription(UpdateEntityDescription, FritzEntityDescripti async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: FritzConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up AVM FRITZ!Box update entities.""" _LOGGER.debug("Setting up AVM FRITZ!Box update entities") - avm_wrapper: AvmWrapper = hass.data[DOMAIN][entry.entry_id] + avm_wrapper = entry.runtime_data entities = [FritzBoxUpdateEntity(avm_wrapper, entry.title)] From 7b1b2297185b98902e458c1c392151c14ddcbee0 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 29 Jan 2025 10:00:45 +0100 Subject: [PATCH 0201/3148] Standardize homeassistant imports in component tests (a-l) (#136806) --- tests/components/aemet/test_sensor.py | 2 +- tests/components/alarm_control_panel/test_device_trigger.py | 2 +- tests/components/api/test_init.py | 3 +-- tests/components/august/test_binary_sensor.py | 2 +- tests/components/august/test_lock.py | 2 +- tests/components/automation/test_init.py | 3 +-- tests/components/binary_sensor/test_device_condition.py | 2 +- tests/components/binary_sensor/test_device_trigger.py | 2 +- tests/components/bluetooth/test_base_scanner.py | 2 +- tests/components/calendar/test_init.py | 2 +- tests/components/calendar/test_trigger.py | 2 +- tests/components/clicksend_tts/test_notify.py | 2 +- tests/components/cloudflare/test_init.py | 2 +- tests/components/command_line/test_cover.py | 2 +- tests/components/command_line/test_init.py | 2 +- tests/components/command_line/test_switch.py | 2 +- tests/components/configurator/test_init.py | 2 +- tests/components/conversation/test_entity.py | 2 +- tests/components/cover/test_device_trigger.py | 2 +- tests/components/demo/test_cover.py | 2 +- tests/components/demo/test_geo_location.py | 2 +- tests/components/demo/test_notify.py | 3 +-- tests/components/derivative/test_sensor.py | 2 +- tests/components/device_automation/test_toggle_entity.py | 2 +- tests/components/device_tracker/test_init.py | 2 +- tests/components/dhcp/test_init.py | 2 +- tests/components/dremel_3d_printer/test_init.py | 2 +- tests/components/dynalite/test_init.py | 2 +- tests/components/eafm/test_sensor.py | 2 +- tests/components/efergy/test_sensor.py | 2 +- tests/components/electric_kiwi/test_sensor.py | 2 +- tests/components/emulated_hue/test_hue_api.py | 2 +- tests/components/energy/test_sensor.py | 2 +- tests/components/evohome/test_storage.py | 2 +- tests/components/facebook/test_notify.py | 2 +- tests/components/fan/test_device_trigger.py | 2 +- tests/components/feedreader/test_event.py | 2 +- tests/components/feedreader/test_init.py | 2 +- tests/components/file/test_notify.py | 2 +- tests/components/filter/test_sensor.py | 2 +- tests/components/flux/test_switch.py | 2 +- tests/components/flux_led/test_number.py | 2 +- tests/components/fritz/test_sensor.py | 2 +- tests/components/fritzbox/test_binary_sensor.py | 2 +- tests/components/fritzbox/test_button.py | 2 +- tests/components/fritzbox/test_climate.py | 2 +- tests/components/fritzbox/test_cover.py | 2 +- tests/components/fritzbox/test_light.py | 2 +- tests/components/fritzbox/test_sensor.py | 2 +- tests/components/fritzbox/test_switch.py | 2 +- tests/components/gdacs/test_geo_location.py | 2 +- tests/components/gdacs/test_sensor.py | 2 +- tests/components/generic_hygrostat/test_humidifier.py | 4 ++-- tests/components/generic_thermostat/test_climate.py | 3 +-- tests/components/geo_rss_events/test_sensor.py | 4 ++-- tests/components/geonetnz_quakes/test_geo_location.py | 2 +- tests/components/geonetnz_quakes/test_sensor.py | 2 +- tests/components/geonetnz_volcano/test_sensor.py | 2 +- tests/components/goalzero/test_init.py | 2 +- tests/components/google/test_calendar.py | 2 +- tests/components/google_mail/test_sensor.py | 2 +- tests/components/google_wifi/test_sensor.py | 2 +- tests/components/gree/test_bridge.py | 2 +- tests/components/group/test_cover.py | 2 +- tests/components/group/test_light.py | 3 +-- tests/components/hardware/test_websocket_api.py | 2 +- tests/components/hassio/test_sensor.py | 2 +- tests/components/hassio/test_update.py | 2 +- tests/components/history_stats/test_sensor.py | 5 ++--- tests/components/homeassistant/test_init.py | 3 +-- .../components/homeassistant/triggers/test_numeric_state.py | 2 +- tests/components/homeassistant/triggers/test_state.py | 2 +- tests/components/homeassistant/triggers/test_time.py | 2 +- .../components/homeassistant/triggers/test_time_pattern.py | 2 +- tests/components/homekit/test_type_lights.py | 2 +- tests/components/homekit/test_type_switches.py | 2 +- tests/components/homekit_controller/common.py | 2 +- tests/components/homekit_controller/conftest.py | 2 +- .../homekit_controller/specific_devices/test_koogeek_ls1.py | 2 +- tests/components/homewizard/test_number.py | 2 +- tests/components/homewizard/test_sensor.py | 2 +- tests/components/homewizard/test_switch.py | 2 +- tests/components/html5/test_notify.py | 2 +- tests/components/humidifier/test_device_trigger.py | 2 +- tests/components/ign_sismologia/test_geo_location.py | 2 +- tests/components/image_processing/test_init.py | 3 +-- tests/components/integration/test_sensor.py | 2 +- tests/components/islamic_prayer_times/__init__.py | 2 +- tests/components/islamic_prayer_times/test_init.py | 2 +- tests/components/jewish_calendar/__init__.py | 2 +- tests/components/jewish_calendar/test_binary_sensor.py | 2 +- tests/components/jewish_calendar/test_sensor.py | 2 +- tests/components/kitchen_sink/test_init.py | 2 +- tests/components/kulersky/test_light.py | 2 +- tests/components/lametric/test_switch.py | 2 +- tests/components/litejet/conftest.py | 2 +- tests/components/litejet/test_trigger.py | 2 +- tests/components/local_calendar/test_calendar.py | 2 +- tests/components/lock/test_device_trigger.py | 2 +- tests/components/lutron_caseta/test_config_flow.py | 6 ++++-- 100 files changed, 106 insertions(+), 112 deletions(-) diff --git a/tests/components/aemet/test_sensor.py b/tests/components/aemet/test_sensor.py index d0f577c8068..d4fca62e98b 100644 --- a/tests/components/aemet/test_sensor.py +++ b/tests/components/aemet/test_sensor.py @@ -4,7 +4,7 @@ from freezegun.api import FrozenDateTimeFactory from homeassistant.components.weather import ATTR_CONDITION_SNOWY from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .util import async_init_integration diff --git a/tests/components/alarm_control_panel/test_device_trigger.py b/tests/components/alarm_control_panel/test_device_trigger.py index 17a301ccdf1..3efacb80560 100644 --- a/tests/components/alarm_control_panel/test_device_trigger.py +++ b/tests/components/alarm_control_panel/test_device_trigger.py @@ -16,7 +16,7 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import ( MockConfigEntry, diff --git a/tests/components/api/test_init.py b/tests/components/api/test_init.py index abce262fd12..6363304effc 100644 --- a/tests/components/api/test_init.py +++ b/tests/components/api/test_init.py @@ -11,10 +11,9 @@ from aiohttp.test_utils import TestClient import pytest import voluptuous as vol -from homeassistant import const +from homeassistant import const, core as ha from homeassistant.auth.models import Credentials from homeassistant.bootstrap import DATA_LOGGING -import homeassistant.core as ha from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component diff --git a/tests/components/august/test_binary_sensor.py b/tests/components/august/test_binary_sensor.py index 4ae300ae56b..bcdd4d55330 100644 --- a/tests/components/august/test_binary_sensor.py +++ b/tests/components/august/test_binary_sensor.py @@ -18,7 +18,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .mocks import ( _create_august_with_devices, diff --git a/tests/components/august/test_lock.py b/tests/components/august/test_lock.py index eb177a35cfb..065ffef91ff 100644 --- a/tests/components/august/test_lock.py +++ b/tests/components/august/test_lock.py @@ -23,7 +23,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceNotSupported from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .mocks import ( _create_august_with_devices, diff --git a/tests/components/automation/test_init.py b/tests/components/automation/test_init.py index 6466e5e7f22..243e132dae2 100644 --- a/tests/components/automation/test_init.py +++ b/tests/components/automation/test_init.py @@ -51,8 +51,7 @@ from homeassistant.helpers.script import ( _async_stop_scripts_at_shutdown, ) from homeassistant.setup import async_setup_component -from homeassistant.util import yaml as yaml_util -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util, yaml as yaml_util from tests.common import ( MockConfigEntry, diff --git a/tests/components/binary_sensor/test_device_condition.py b/tests/components/binary_sensor/test_device_condition.py index 8a0132ff2af..59fbdf9a253 100644 --- a/tests/components/binary_sensor/test_device_condition.py +++ b/tests/components/binary_sensor/test_device_condition.py @@ -14,7 +14,7 @@ from homeassistant.const import CONF_PLATFORM, STATE_OFF, STATE_ON, EntityCatego from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .common import MockBinarySensor diff --git a/tests/components/binary_sensor/test_device_trigger.py b/tests/components/binary_sensor/test_device_trigger.py index 78e382f77bf..dd71c1e5d06 100644 --- a/tests/components/binary_sensor/test_device_trigger.py +++ b/tests/components/binary_sensor/test_device_trigger.py @@ -13,7 +13,7 @@ from homeassistant.const import CONF_PLATFORM, STATE_OFF, STATE_ON, EntityCatego from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .common import MockBinarySensor diff --git a/tests/components/bluetooth/test_base_scanner.py b/tests/components/bluetooth/test_base_scanner.py index e3bdca256c0..acd630863d2 100644 --- a/tests/components/bluetooth/test_base_scanner.py +++ b/tests/components/bluetooth/test_base_scanner.py @@ -29,7 +29,7 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from homeassistant.util.json import json_loads from . import ( diff --git a/tests/components/calendar/test_init.py b/tests/components/calendar/test_init.py index 36b102b933a..2d712f408c2 100644 --- a/tests/components/calendar/test_init.py +++ b/tests/components/calendar/test_init.py @@ -16,7 +16,7 @@ from homeassistant.components.calendar import DOMAIN, SERVICE_GET_EVENTS from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceNotSupported from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .conftest import MockCalendarEntity, MockConfigEntry diff --git a/tests/components/calendar/test_trigger.py b/tests/components/calendar/test_trigger.py index dfe4622e82e..b0d7944041d 100644 --- a/tests/components/calendar/test_trigger.py +++ b/tests/components/calendar/test_trigger.py @@ -25,7 +25,7 @@ from homeassistant.components.calendar.trigger import EVENT_END, EVENT_START from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .conftest import MockCalendarEntity diff --git a/tests/components/clicksend_tts/test_notify.py b/tests/components/clicksend_tts/test_notify.py index 892d7541354..811978eead5 100644 --- a/tests/components/clicksend_tts/test_notify.py +++ b/tests/components/clicksend_tts/test_notify.py @@ -9,7 +9,7 @@ import pytest import requests_mock from homeassistant.components import notify -import homeassistant.components.clicksend_tts.notify as cs_tts +from homeassistant.components.clicksend_tts import notify as cs_tts from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component diff --git a/tests/components/cloudflare/test_init.py b/tests/components/cloudflare/test_init.py index d629607e503..15a6c5740ff 100644 --- a/tests/components/cloudflare/test_init.py +++ b/tests/components/cloudflare/test_init.py @@ -15,7 +15,7 @@ from homeassistant.components.cloudflare.const import ( from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from homeassistant.util.location import LocationInfo from . import ENTRY_CONFIG, init_integration diff --git a/tests/components/command_line/test_cover.py b/tests/components/command_line/test_cover.py index 426968eccc5..a6e384fdd6b 100644 --- a/tests/components/command_line/test_cover.py +++ b/tests/components/command_line/test_cover.py @@ -32,7 +32,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import mock_asyncio_subprocess_run diff --git a/tests/components/command_line/test_init.py b/tests/components/command_line/test_init.py index 3fbd0e0f898..16a783d4f59 100644 --- a/tests/components/command_line/test_init.py +++ b/tests/components/command_line/test_init.py @@ -11,7 +11,7 @@ from homeassistant import config as hass_config from homeassistant.components.command_line.const import DOMAIN from homeassistant.const import SERVICE_RELOAD, STATE_ON, STATE_OPEN from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed, get_fixture_path diff --git a/tests/components/command_line/test_switch.py b/tests/components/command_line/test_switch.py index d62410fa792..6b34cf0fa77 100644 --- a/tests/components/command_line/test_switch.py +++ b/tests/components/command_line/test_switch.py @@ -30,7 +30,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import mock_asyncio_subprocess_run diff --git a/tests/components/configurator/test_init.py b/tests/components/configurator/test_init.py index a4faab483ee..1985c6e5c8c 100644 --- a/tests/components/configurator/test_init.py +++ b/tests/components/configurator/test_init.py @@ -5,7 +5,7 @@ from datetime import timedelta from homeassistant.components import configurator from homeassistant.const import ATTR_FRIENDLY_NAME from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed diff --git a/tests/components/conversation/test_entity.py b/tests/components/conversation/test_entity.py index 109c0ed361f..f03b24818bf 100644 --- a/tests/components/conversation/test_entity.py +++ b/tests/components/conversation/test_entity.py @@ -6,7 +6,7 @@ from homeassistant.components import conversation from homeassistant.core import Context, HomeAssistant, State from homeassistant.helpers import intent from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import mock_restore_cache diff --git a/tests/components/cover/test_device_trigger.py b/tests/components/cover/test_device_trigger.py index a6c10d4acf1..7901baaa3b8 100644 --- a/tests/components/cover/test_device_trigger.py +++ b/tests/components/cover/test_device_trigger.py @@ -13,7 +13,7 @@ from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity_registry import RegistryEntryHider from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .common import MockCover diff --git a/tests/components/demo/test_cover.py b/tests/components/demo/test_cover.py index 97cad5bbe14..dcec921c01d 100644 --- a/tests/components/demo/test_cover.py +++ b/tests/components/demo/test_cover.py @@ -31,7 +31,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import assert_setup_component, async_fire_time_changed diff --git a/tests/components/demo/test_geo_location.py b/tests/components/demo/test_geo_location.py index d3c2937d12b..a93c79828d6 100644 --- a/tests/components/demo/test_geo_location.py +++ b/tests/components/demo/test_geo_location.py @@ -15,7 +15,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import assert_setup_component, async_fire_time_changed diff --git a/tests/components/demo/test_notify.py b/tests/components/demo/test_notify.py index 98b3de8448a..f3677c6e373 100644 --- a/tests/components/demo/test_notify.py +++ b/tests/components/demo/test_notify.py @@ -6,8 +6,7 @@ from unittest.mock import patch import pytest from homeassistant.components import notify -from homeassistant.components.demo import DOMAIN -import homeassistant.components.demo.notify as demo +from homeassistant.components.demo import DOMAIN, notify as demo from homeassistant.const import Platform from homeassistant.core import Event, HomeAssistant from homeassistant.setup import async_setup_component diff --git a/tests/components/derivative/test_sensor.py b/tests/components/derivative/test_sensor.py index 4a4d8519b25..a543de974f1 100644 --- a/tests/components/derivative/test_sensor.py +++ b/tests/components/derivative/test_sensor.py @@ -13,7 +13,7 @@ from homeassistant.const import UnitOfPower, UnitOfTime from homeassistant.core import HomeAssistant, State from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry diff --git a/tests/components/device_automation/test_toggle_entity.py b/tests/components/device_automation/test_toggle_entity.py index be4d3bd4c9e..a7b2f8a3b75 100644 --- a/tests/components/device_automation/test_toggle_entity.py +++ b/tests/components/device_automation/test_toggle_entity.py @@ -9,7 +9,7 @@ from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed diff --git a/tests/components/device_tracker/test_init.py b/tests/components/device_tracker/test_init.py index 6226669aa0f..ea07365bd2f 100644 --- a/tests/components/device_tracker/test_init.py +++ b/tests/components/device_tracker/test_init.py @@ -28,7 +28,7 @@ from homeassistant.helpers import discovery from homeassistant.helpers.entity_registry import RegistryEntry from homeassistant.helpers.json import JSONEncoder from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import common from .common import MockScanner, mock_legacy_device_tracker_setup diff --git a/tests/components/dhcp/test_init.py b/tests/components/dhcp/test_init.py index 76f15eb3e51..223dc83f83a 100644 --- a/tests/components/dhcp/test_init.py +++ b/tests/components/dhcp/test_init.py @@ -36,7 +36,7 @@ from homeassistant.helpers.discovery_flow import DiscoveryKey from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import ( MockConfigEntry, diff --git a/tests/components/dremel_3d_printer/test_init.py b/tests/components/dremel_3d_printer/test_init.py index 6b008c7fac1..fda1ecc6cf6 100644 --- a/tests/components/dremel_3d_printer/test_init.py +++ b/tests/components/dremel_3d_printer/test_init.py @@ -12,7 +12,7 @@ from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed diff --git a/tests/components/dynalite/test_init.py b/tests/components/dynalite/test_init.py index 4bf4eb53ad6..3335e12b2a2 100644 --- a/tests/components/dynalite/test_init.py +++ b/tests/components/dynalite/test_init.py @@ -5,7 +5,7 @@ from unittest.mock import call, patch import pytest from voluptuous import MultipleInvalid -import homeassistant.components.dynalite.const as dynalite +from homeassistant.components.dynalite import const as dynalite from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component diff --git a/tests/components/eafm/test_sensor.py b/tests/components/eafm/test_sensor.py index add604167b9..11febb26669 100644 --- a/tests/components/eafm/test_sensor.py +++ b/tests/components/eafm/test_sensor.py @@ -12,7 +12,7 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed diff --git a/tests/components/efergy/test_sensor.py b/tests/components/efergy/test_sensor.py index addaa1b9c48..49c18bab239 100644 --- a/tests/components/efergy/test_sensor.py +++ b/tests/components/efergy/test_sensor.py @@ -19,7 +19,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import MULTI_SENSOR_TOKEN, mock_responses, setup_platform diff --git a/tests/components/electric_kiwi/test_sensor.py b/tests/components/electric_kiwi/test_sensor.py index bb3304ec66c..a85eb16a986 100644 --- a/tests/components/electric_kiwi/test_sensor.py +++ b/tests/components/electric_kiwi/test_sensor.py @@ -18,7 +18,7 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ATTR_ATTRIBUTION, ATTR_DEVICE_CLASS from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_registry import EntityRegistry -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .conftest import ComponentSetup, YieldFixture diff --git a/tests/components/emulated_hue/test_hue_api.py b/tests/components/emulated_hue/test_hue_api.py index 8a340d5e2dd..97dcc782096 100644 --- a/tests/components/emulated_hue/test_hue_api.py +++ b/tests/components/emulated_hue/test_hue_api.py @@ -58,7 +58,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.typing import ConfigType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from homeassistant.util.json import JsonObjectType from tests.common import ( diff --git a/tests/components/energy/test_sensor.py b/tests/components/energy/test_sensor.py index a27451b853d..a438842f8a5 100644 --- a/tests/components/energy/test_sensor.py +++ b/tests/components/energy/test_sensor.py @@ -28,7 +28,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from homeassistant.util.unit_system import METRIC_SYSTEM, US_CUSTOMARY_SYSTEM from tests.components.recorder.common import async_wait_recording_done diff --git a/tests/components/evohome/test_storage.py b/tests/components/evohome/test_storage.py index 4cc21078333..b3597352487 100644 --- a/tests/components/evohome/test_storage.py +++ b/tests/components/evohome/test_storage.py @@ -15,7 +15,7 @@ from homeassistant.components.evohome import ( dt_aware_to_naive, ) from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .conftest import setup_evohome from .const import ACCESS_TOKEN, REFRESH_TOKEN, SESSION_ID, USERNAME diff --git a/tests/components/facebook/test_notify.py b/tests/components/facebook/test_notify.py index 77ae544646d..db9cd86e086 100644 --- a/tests/components/facebook/test_notify.py +++ b/tests/components/facebook/test_notify.py @@ -5,7 +5,7 @@ from http import HTTPStatus import pytest import requests_mock -import homeassistant.components.facebook.notify as fb +from homeassistant.components.facebook import notify as fb from homeassistant.core import HomeAssistant diff --git a/tests/components/fan/test_device_trigger.py b/tests/components/fan/test_device_trigger.py index f4673636637..bef44c92f34 100644 --- a/tests/components/fan/test_device_trigger.py +++ b/tests/components/fan/test_device_trigger.py @@ -13,7 +13,7 @@ from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity_registry import RegistryEntryHider from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import ( MockConfigEntry, diff --git a/tests/components/feedreader/test_event.py b/tests/components/feedreader/test_event.py index 32f8ecb8080..8f5f3870bfe 100644 --- a/tests/components/feedreader/test_event.py +++ b/tests/components/feedreader/test_event.py @@ -13,7 +13,7 @@ from homeassistant.components.feedreader.event import ( ATTR_TITLE, ) from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import create_mock_entry from .const import VALID_CONFIG_DEFAULT diff --git a/tests/components/feedreader/test_init.py b/tests/components/feedreader/test_init.py index 9a2575bf591..5d2ac1a4406 100644 --- a/tests/components/feedreader/test_init.py +++ b/tests/components/feedreader/test_init.py @@ -14,7 +14,7 @@ from homeassistant.components.feedreader.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import Event, HomeAssistant from homeassistant.helpers import device_registry as dr -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import async_setup_config_entry, create_mock_entry from .const import ( diff --git a/tests/components/file/test_notify.py b/tests/components/file/test_notify.py index e7cb85a9cfc..44b9d61efec 100644 --- a/tests/components/file/test_notify.py +++ b/tests/components/file/test_notify.py @@ -12,7 +12,7 @@ from homeassistant.components.file import DOMAIN from homeassistant.components.notify import ATTR_TITLE_DEFAULT from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry diff --git a/tests/components/filter/test_sensor.py b/tests/components/filter/test_sensor.py index a3e0e58908a..4312047278f 100644 --- a/tests/components/filter/test_sensor.py +++ b/tests/components/filter/test_sensor.py @@ -32,7 +32,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, State from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import assert_setup_component, get_fixture_path diff --git a/tests/components/flux/test_switch.py b/tests/components/flux/test_switch.py index f7dc30db240..e1bd07cdfd7 100644 --- a/tests/components/flux/test_switch.py +++ b/tests/components/flux/test_switch.py @@ -17,7 +17,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, State from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import ( assert_setup_component, diff --git a/tests/components/flux_led/test_number.py b/tests/components/flux_led/test_number.py index 2ed0d34989f..8dd8196a2db 100644 --- a/tests/components/flux_led/test_number.py +++ b/tests/components/flux_led/test_number.py @@ -24,7 +24,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import ( DEFAULT_ENTRY_TITLE, diff --git a/tests/components/fritz/test_sensor.py b/tests/components/fritz/test_sensor.py index 7dec640b898..1b10ddb8fc1 100644 --- a/tests/components/fritz/test_sensor.py +++ b/tests/components/fritz/test_sensor.py @@ -14,7 +14,7 @@ from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.const import STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import MOCK_USER_DATA diff --git a/tests/components/fritzbox/test_binary_sensor.py b/tests/components/fritzbox/test_binary_sensor.py index f4cc1b2e2ca..594ed14a7d1 100644 --- a/tests/components/fritzbox/test_binary_sensor.py +++ b/tests/components/fritzbox/test_binary_sensor.py @@ -23,7 +23,7 @@ from homeassistant.const import ( STATE_UNAVAILABLE, ) from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import FritzDeviceBinarySensorMock, set_devices, setup_config_entry from .const import CONF_FAKE_NAME, MOCK_CONFIG diff --git a/tests/components/fritzbox/test_button.py b/tests/components/fritzbox/test_button.py index 913f828efbc..0053a8d3446 100644 --- a/tests/components/fritzbox/test_button.py +++ b/tests/components/fritzbox/test_button.py @@ -12,7 +12,7 @@ from homeassistant.const import ( STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import FritzEntityBaseMock, set_devices, setup_config_entry from .const import CONF_FAKE_NAME, MOCK_CONFIG diff --git a/tests/components/fritzbox/test_climate.py b/tests/components/fritzbox/test_climate.py index 29f5742216f..0fb5f5038c3 100644 --- a/tests/components/fritzbox/test_climate.py +++ b/tests/components/fritzbox/test_climate.py @@ -44,7 +44,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import ( FritzDeviceClimateMock, diff --git a/tests/components/fritzbox/test_cover.py b/tests/components/fritzbox/test_cover.py index f26e65fc28a..82723b083ae 100644 --- a/tests/components/fritzbox/test_cover.py +++ b/tests/components/fritzbox/test_cover.py @@ -20,7 +20,7 @@ from homeassistant.const import ( STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import ( FritzDeviceCoverMock, diff --git a/tests/components/fritzbox/test_light.py b/tests/components/fritzbox/test_light.py index 84fafe25521..071642fb358 100644 --- a/tests/components/fritzbox/test_light.py +++ b/tests/components/fritzbox/test_light.py @@ -31,7 +31,7 @@ from homeassistant.const import ( STATE_ON, ) from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import FritzDeviceLightMock, set_devices, setup_config_entry from .const import CONF_FAKE_NAME, MOCK_CONFIG diff --git a/tests/components/fritzbox/test_sensor.py b/tests/components/fritzbox/test_sensor.py index 0da040bbb5b..67b2c3e8ab6 100644 --- a/tests/components/fritzbox/test_sensor.py +++ b/tests/components/fritzbox/test_sensor.py @@ -24,7 +24,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import ( FritzDeviceClimateMock, diff --git a/tests/components/fritzbox/test_switch.py b/tests/components/fritzbox/test_switch.py index e394ccbc7f3..511725c663f 100644 --- a/tests/components/fritzbox/test_switch.py +++ b/tests/components/fritzbox/test_switch.py @@ -32,7 +32,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import FritzDeviceSwitchMock, set_devices, setup_config_entry from .const import CONF_FAKE_NAME, MOCK_CONFIG diff --git a/tests/components/gdacs/test_geo_location.py b/tests/components/gdacs/test_geo_location.py index 4ea28bd8fd3..68e2d061259 100644 --- a/tests/components/gdacs/test_geo_location.py +++ b/tests/components/gdacs/test_geo_location.py @@ -33,7 +33,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM from . import _generate_mock_feed_entry diff --git a/tests/components/gdacs/test_sensor.py b/tests/components/gdacs/test_sensor.py index 87b66295006..01609cf485e 100644 --- a/tests/components/gdacs/test_sensor.py +++ b/tests/components/gdacs/test_sensor.py @@ -24,7 +24,7 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_START, ) from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import _generate_mock_feed_entry diff --git a/tests/components/generic_hygrostat/test_humidifier.py b/tests/components/generic_hygrostat/test_humidifier.py index 33a8a0f37bd..3acb50fa38d 100644 --- a/tests/components/generic_hygrostat/test_humidifier.py +++ b/tests/components/generic_hygrostat/test_humidifier.py @@ -7,6 +7,7 @@ from freezegun.api import FrozenDateTimeFactory import pytest import voluptuous as vol +from homeassistant import core as ha from homeassistant.components import input_boolean, switch from homeassistant.components.generic_hygrostat import ( DOMAIN as GENERIC_HYDROSTAT_DOMAIN, @@ -28,7 +29,6 @@ from homeassistant.const import ( STATE_ON, STATE_UNAVAILABLE, ) -import homeassistant.core as ha from homeassistant.core import ( DOMAIN as HOMEASSISTANT_DOMAIN, CoreState, @@ -40,7 +40,7 @@ from homeassistant.core import ( from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.typing import StateType from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import ( MockConfigEntry, diff --git a/tests/components/generic_thermostat/test_climate.py b/tests/components/generic_thermostat/test_climate.py index 8cbbdbb49d4..7e2e92f025b 100644 --- a/tests/components/generic_thermostat/test_climate.py +++ b/tests/components/generic_thermostat/test_climate.py @@ -7,7 +7,7 @@ from freezegun import freeze_time import pytest import voluptuous as vol -from homeassistant import config as hass_config +from homeassistant import config as hass_config, core as ha from homeassistant.components import input_boolean, switch from homeassistant.components.climate import ( ATTR_PRESET_MODE, @@ -35,7 +35,6 @@ from homeassistant.const import ( STATE_UNKNOWN, UnitOfTemperature, ) -import homeassistant.core as ha from homeassistant.core import ( DOMAIN as HOMEASSISTANT_DOMAIN, CoreState, diff --git a/tests/components/geo_rss_events/test_sensor.py b/tests/components/geo_rss_events/test_sensor.py index d19262c3339..3b6ef8a0642 100644 --- a/tests/components/geo_rss_events/test_sensor.py +++ b/tests/components/geo_rss_events/test_sensor.py @@ -6,7 +6,7 @@ from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components import sensor -import homeassistant.components.geo_rss_events.sensor as geo_rss_events +from homeassistant.components.geo_rss_events import sensor as geo_rss_events from homeassistant.const import ( ATTR_FRIENDLY_NAME, ATTR_ICON, @@ -15,7 +15,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import assert_setup_component, async_fire_time_changed diff --git a/tests/components/geonetnz_quakes/test_geo_location.py b/tests/components/geonetnz_quakes/test_geo_location.py index 163bca775c9..fd8ba81fca7 100644 --- a/tests/components/geonetnz_quakes/test_geo_location.py +++ b/tests/components/geonetnz_quakes/test_geo_location.py @@ -31,7 +31,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM from . import _generate_mock_feed_entry diff --git a/tests/components/geonetnz_quakes/test_sensor.py b/tests/components/geonetnz_quakes/test_sensor.py index 82143baa374..2daeab9e7ef 100644 --- a/tests/components/geonetnz_quakes/test_sensor.py +++ b/tests/components/geonetnz_quakes/test_sensor.py @@ -23,7 +23,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import _generate_mock_feed_entry diff --git a/tests/components/geonetnz_volcano/test_sensor.py b/tests/components/geonetnz_volcano/test_sensor.py index d6ebbcd6582..a79d8512df6 100644 --- a/tests/components/geonetnz_volcano/test_sensor.py +++ b/tests/components/geonetnz_volcano/test_sensor.py @@ -25,7 +25,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM from . import _generate_mock_feed_entry diff --git a/tests/components/goalzero/test_init.py b/tests/components/goalzero/test_init.py index 1d44c7e808e..4817be1ce35 100644 --- a/tests/components/goalzero/test_init.py +++ b/tests/components/goalzero/test_init.py @@ -10,7 +10,7 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.const import STATE_ON, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import CONF_DATA, async_init_integration, create_entry, create_mocked_yeti diff --git a/tests/components/google/test_calendar.py b/tests/components/google/test_calendar.py index 305f30d99d4..3d10e753714 100644 --- a/tests/components/google/test_calendar.py +++ b/tests/components/google/test_calendar.py @@ -21,7 +21,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_registry import RegistryEntryDisabler from homeassistant.helpers.template import DATE_STR_FORMAT -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .conftest import ( CALENDAR_ID, diff --git a/tests/components/google_mail/test_sensor.py b/tests/components/google_mail/test_sensor.py index 6f2f1a4ec32..e9dd2da85de 100644 --- a/tests/components/google_mail/test_sensor.py +++ b/tests/components/google_mail/test_sensor.py @@ -12,7 +12,7 @@ from homeassistant.components.google_mail.const import DOMAIN from homeassistant.components.sensor import SensorDeviceClass from homeassistant.const import ATTR_DEVICE_CLASS, STATE_UNKNOWN from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .conftest import SENSOR, TOKEN, ComponentSetup diff --git a/tests/components/google_wifi/test_sensor.py b/tests/components/google_wifi/test_sensor.py index 18d96e3a1c0..88adcbf6587 100644 --- a/tests/components/google_wifi/test_sensor.py +++ b/tests/components/google_wifi/test_sensor.py @@ -7,7 +7,7 @@ from unittest.mock import Mock, patch import requests_mock -import homeassistant.components.google_wifi.sensor as google_wifi +from homeassistant.components.google_wifi import sensor as google_wifi from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util diff --git a/tests/components/gree/test_bridge.py b/tests/components/gree/test_bridge.py index ae2f0c74236..acfa1ba43f5 100644 --- a/tests/components/gree/test_bridge.py +++ b/tests/components/gree/test_bridge.py @@ -12,7 +12,7 @@ from homeassistant.components.gree.const import ( UPDATE_INTERVAL, ) from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .common import async_setup_gree, build_device_mock diff --git a/tests/components/group/test_cover.py b/tests/components/group/test_cover.py index b1f622569bd..ab92b18cc91 100644 --- a/tests/components/group/test_cover.py +++ b/tests/components/group/test_cover.py @@ -38,7 +38,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import assert_setup_component, async_fire_time_changed diff --git a/tests/components/group/test_light.py b/tests/components/group/test_light.py index 91604d663b3..dbd74e95780 100644 --- a/tests/components/group/test_light.py +++ b/tests/components/group/test_light.py @@ -6,8 +6,7 @@ from unittest.mock import MagicMock, patch import pytest from homeassistant import config as hass_config -from homeassistant.components.group import DOMAIN, SERVICE_RELOAD -import homeassistant.components.group.light as group +from homeassistant.components.group import DOMAIN, SERVICE_RELOAD, light as group from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_MODE, diff --git a/tests/components/hardware/test_websocket_api.py b/tests/components/hardware/test_websocket_api.py index 1379bdba120..64fcda02df4 100644 --- a/tests/components/hardware/test_websocket_api.py +++ b/tests/components/hardware/test_websocket_api.py @@ -10,7 +10,7 @@ import psutil_home_assistant as ha_psutil from homeassistant.components.hardware.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.typing import WebSocketGenerator diff --git a/tests/components/hassio/test_sensor.py b/tests/components/hassio/test_sensor.py index 7160a2cbf16..f4b01a85900 100644 --- a/tests/components/hassio/test_sensor.py +++ b/tests/components/hassio/test_sensor.py @@ -16,7 +16,7 @@ from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .common import MOCK_REPOSITORIES, MOCK_STORE_ADDONS diff --git a/tests/components/hassio/test_update.py b/tests/components/hassio/test_update.py index 732b2655107..62fe49c5f23 100644 --- a/tests/components/hassio/test_update.py +++ b/tests/components/hassio/test_update.py @@ -16,7 +16,7 @@ from homeassistant.const import __version__ as HAVERSION from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed from tests.test_util.aiohttp import AiohttpClientMocker diff --git a/tests/components/history_stats/test_sensor.py b/tests/components/history_stats/test_sensor.py index 3039612d1a0..721e540b04d 100644 --- a/tests/components/history_stats/test_sensor.py +++ b/tests/components/history_stats/test_sensor.py @@ -7,7 +7,7 @@ from freezegun import freeze_time import pytest import voluptuous as vol -from homeassistant import config as hass_config +from homeassistant import config as hass_config, core as ha from homeassistant.components.history_stats.const import ( CONF_END, CONF_START, @@ -27,12 +27,11 @@ from homeassistant.const import ( SERVICE_RELOAD, STATE_UNKNOWN, ) -import homeassistant.core as ha from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity_component import async_update_entity from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed, get_fixture_path from tests.components.recorder.common import async_wait_recording_done diff --git a/tests/components/homeassistant/test_init.py b/tests/components/homeassistant/test_init.py index 0aed3dc929e..4facd1695c5 100644 --- a/tests/components/homeassistant/test_init.py +++ b/tests/components/homeassistant/test_init.py @@ -6,7 +6,7 @@ import pytest import voluptuous as vol import yaml -from homeassistant import config +from homeassistant import config, core as ha from homeassistant.components.homeassistant import ( ATTR_ENTRY_ID, ATTR_SAFE_MODE, @@ -30,7 +30,6 @@ from homeassistant.const import ( STATE_OFF, STATE_ON, ) -import homeassistant.core as ha from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, Unauthorized from homeassistant.helpers import entity, entity_registry as er diff --git a/tests/components/homeassistant/triggers/test_numeric_state.py b/tests/components/homeassistant/triggers/test_numeric_state.py index fe4fb53962a..0d4294ca16f 100644 --- a/tests/components/homeassistant/triggers/test_numeric_state.py +++ b/tests/components/homeassistant/triggers/test_numeric_state.py @@ -21,7 +21,7 @@ from homeassistant.const import ( from homeassistant.core import Context, HomeAssistant, ServiceCall from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import assert_setup_component, async_fire_time_changed, mock_component diff --git a/tests/components/homeassistant/triggers/test_state.py b/tests/components/homeassistant/triggers/test_state.py index c3117bbb660..f6478e9dda0 100644 --- a/tests/components/homeassistant/triggers/test_state.py +++ b/tests/components/homeassistant/triggers/test_state.py @@ -17,7 +17,7 @@ from homeassistant.const import ( from homeassistant.core import Context, HomeAssistant, ServiceCall from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import assert_setup_component, async_fire_time_changed, mock_component diff --git a/tests/components/homeassistant/triggers/test_time.py b/tests/components/homeassistant/triggers/test_time.py index 40f62baa5e7..9a4f41d08e1 100644 --- a/tests/components/homeassistant/triggers/test_time.py +++ b/tests/components/homeassistant/triggers/test_time.py @@ -18,7 +18,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import assert_setup_component, async_fire_time_changed, mock_component diff --git a/tests/components/homeassistant/triggers/test_time_pattern.py b/tests/components/homeassistant/triggers/test_time_pattern.py index 7138fd7dd02..ffce8cd476b 100644 --- a/tests/components/homeassistant/triggers/test_time_pattern.py +++ b/tests/components/homeassistant/triggers/test_time_pattern.py @@ -11,7 +11,7 @@ from homeassistant.components.homeassistant.triggers import time_pattern from homeassistant.const import ATTR_ENTITY_ID, ENTITY_MATCH_ALL, SERVICE_TURN_OFF from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed, mock_component diff --git a/tests/components/homekit/test_type_lights.py b/tests/components/homekit/test_type_lights.py index c1870cecd9c..5bad7aa8f39 100644 --- a/tests/components/homekit/test_type_lights.py +++ b/tests/components/homekit/test_type_lights.py @@ -42,7 +42,7 @@ from homeassistant.const import ( ) from homeassistant.core import CoreState, Event, HomeAssistant from homeassistant.helpers import entity_registry as er -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed, async_mock_service diff --git a/tests/components/homekit/test_type_switches.py b/tests/components/homekit/test_type_switches.py index 0d19763e4c7..141141e7f15 100644 --- a/tests/components/homekit/test_type_switches.py +++ b/tests/components/homekit/test_type_switches.py @@ -42,7 +42,7 @@ from homeassistant.const import ( STATE_OPEN, ) from homeassistant.core import Event, HomeAssistant, split_entity_id -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed, async_mock_service diff --git a/tests/components/homekit_controller/common.py b/tests/components/homekit_controller/common.py index b94a267104b..e2aaf58d63e 100644 --- a/tests/components/homekit_controller/common.py +++ b/tests/components/homekit_controller/common.py @@ -32,7 +32,7 @@ from homeassistant.core import HomeAssistant, State, callback from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.service_info.bluetooth import BluetoothServiceInfo from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import ( MockConfigEntry, diff --git a/tests/components/homekit_controller/conftest.py b/tests/components/homekit_controller/conftest.py index eea3f4b67f2..4e787f305b6 100644 --- a/tests/components/homekit_controller/conftest.py +++ b/tests/components/homekit_controller/conftest.py @@ -9,7 +9,7 @@ from freezegun import freeze_time from freezegun.api import FrozenDateTimeFactory import pytest -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.components.light.conftest import mock_light_profiles # noqa: F401 diff --git a/tests/components/homekit_controller/specific_devices/test_koogeek_ls1.py b/tests/components/homekit_controller/specific_devices/test_koogeek_ls1.py index a16cd052c87..a71465716c4 100644 --- a/tests/components/homekit_controller/specific_devices/test_koogeek_ls1.py +++ b/tests/components/homekit_controller/specific_devices/test_koogeek_ls1.py @@ -12,7 +12,7 @@ from homeassistant.components.homekit_controller.connection import ( MAX_POLL_FAILURES_TO_DECLARE_UNAVAILABLE, ) from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from ..common import Helper, setup_accessories_from_file, setup_test_accessories diff --git a/tests/components/homewizard/test_number.py b/tests/components/homewizard/test_number.py index b668043608c..67e51cbafe2 100644 --- a/tests/components/homewizard/test_number.py +++ b/tests/components/homewizard/test_number.py @@ -14,7 +14,7 @@ from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed diff --git a/tests/components/homewizard/test_sensor.py b/tests/components/homewizard/test_sensor.py index c1474c4b947..d9698db7469 100644 --- a/tests/components/homewizard/test_sensor.py +++ b/tests/components/homewizard/test_sensor.py @@ -10,7 +10,7 @@ from homeassistant.components.homewizard.const import UPDATE_INTERVAL from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed diff --git a/tests/components/homewizard/test_switch.py b/tests/components/homewizard/test_switch.py index ccf99ee27fa..ae9b7653b6d 100644 --- a/tests/components/homewizard/test_switch.py +++ b/tests/components/homewizard/test_switch.py @@ -18,7 +18,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed diff --git a/tests/components/html5/test_notify.py b/tests/components/html5/test_notify.py index 0d9388907a9..f602a8f3807 100644 --- a/tests/components/html5/test_notify.py +++ b/tests/components/html5/test_notify.py @@ -8,7 +8,7 @@ from unittest.mock import mock_open, patch from aiohttp.hdrs import AUTHORIZATION from aiohttp.test_utils import TestClient -import homeassistant.components.html5.notify as html5 +from homeassistant.components.html5 import notify as html5 from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.setup import async_setup_component diff --git a/tests/components/humidifier/test_device_trigger.py b/tests/components/humidifier/test_device_trigger.py index 3bb1f8c2551..e1b2b2bff61 100644 --- a/tests/components/humidifier/test_device_trigger.py +++ b/tests/components/humidifier/test_device_trigger.py @@ -24,7 +24,7 @@ from homeassistant.helpers import ( ) from homeassistant.helpers.entity_registry import RegistryEntryHider from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import ( MockConfigEntry, diff --git a/tests/components/ign_sismologia/test_geo_location.py b/tests/components/ign_sismologia/test_geo_location.py index c26eae28086..2f946459bfe 100644 --- a/tests/components/ign_sismologia/test_geo_location.py +++ b/tests/components/ign_sismologia/test_geo_location.py @@ -32,7 +32,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import assert_setup_component, async_fire_time_changed diff --git a/tests/components/image_processing/test_init.py b/tests/components/image_processing/test_init.py index 3e7c8f2fb91..6ff6d925d7e 100644 --- a/tests/components/image_processing/test_init.py +++ b/tests/components/image_processing/test_init.py @@ -6,8 +6,7 @@ from unittest.mock import PropertyMock, patch import pytest -from homeassistant.components import http -import homeassistant.components.image_processing as ip +from homeassistant.components import http, image_processing as ip from homeassistant.const import ATTR_ENTITY_PICTURE from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError diff --git a/tests/components/integration/test_sensor.py b/tests/components/integration/test_sensor.py index 07390cd9571..ba4a6bdf198 100644 --- a/tests/components/integration/test_sensor.py +++ b/tests/components/integration/test_sensor.py @@ -28,7 +28,7 @@ from homeassistant.helpers import ( entity_registry as er, ) from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import ( MockConfigEntry, diff --git a/tests/components/islamic_prayer_times/__init__.py b/tests/components/islamic_prayer_times/__init__.py index 522006b0847..90a3a90c451 100644 --- a/tests/components/islamic_prayer_times/__init__.py +++ b/tests/components/islamic_prayer_times/__init__.py @@ -3,7 +3,7 @@ from datetime import datetime from homeassistant.const import CONF_LATITUDE, CONF_LOCATION, CONF_LONGITUDE, CONF_NAME -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util MOCK_USER_INPUT = { CONF_NAME: "Home", diff --git a/tests/components/islamic_prayer_times/test_init.py b/tests/components/islamic_prayer_times/test_init.py index 7961b79676b..5ae11d8f850 100644 --- a/tests/components/islamic_prayer_times/test_init.py +++ b/tests/components/islamic_prayer_times/test_init.py @@ -12,7 +12,7 @@ from homeassistant.config_entries import ConfigEntryState 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 homeassistant.util import dt as dt_util from . import NOW, PRAYER_TIMES diff --git a/tests/components/jewish_calendar/__init__.py b/tests/components/jewish_calendar/__init__.py index 440bffc2256..ba0a2b4835e 100644 --- a/tests/components/jewish_calendar/__init__.py +++ b/tests/components/jewish_calendar/__init__.py @@ -6,7 +6,7 @@ from datetime import datetime from freezegun import freeze_time as alter_time # noqa: F401 from homeassistant.components import jewish_calendar -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util _LatLng = namedtuple("_LatLng", ["lat", "lng"]) # noqa: PYI024 diff --git a/tests/components/jewish_calendar/test_binary_sensor.py b/tests/components/jewish_calendar/test_binary_sensor.py index 8abaaecb77d..5cfaaedfc72 100644 --- a/tests/components/jewish_calendar/test_binary_sensor.py +++ b/tests/components/jewish_calendar/test_binary_sensor.py @@ -16,7 +16,7 @@ from homeassistant.components.jewish_calendar.const import ( from homeassistant.const import CONF_LANGUAGE, CONF_PLATFORM, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import alter_time, make_jerusalem_test_params, make_nyc_test_params diff --git a/tests/components/jewish_calendar/test_sensor.py b/tests/components/jewish_calendar/test_sensor.py index 4897ef7749b..aac0f583b05 100644 --- a/tests/components/jewish_calendar/test_sensor.py +++ b/tests/components/jewish_calendar/test_sensor.py @@ -16,7 +16,7 @@ from homeassistant.components.jewish_calendar.const import ( from homeassistant.const import CONF_LANGUAGE, CONF_PLATFORM from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import alter_time, make_jerusalem_test_params, make_nyc_test_params diff --git a/tests/components/kitchen_sink/test_init.py b/tests/components/kitchen_sink/test_init.py index b832577a48a..7338c1dca99 100644 --- a/tests/components/kitchen_sink/test_init.py +++ b/tests/components/kitchen_sink/test_init.py @@ -17,7 +17,7 @@ from homeassistant.components.recorder.statistics import ( from homeassistant.components.repairs import DOMAIN as REPAIRS_DOMAIN from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM from tests.components.recorder.common import async_wait_recording_done diff --git a/tests/components/kulersky/test_light.py b/tests/components/kulersky/test_light.py index a2245e721c5..230a2562282 100644 --- a/tests/components/kulersky/test_light.py +++ b/tests/components/kulersky/test_light.py @@ -32,7 +32,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_component import async_update_entity -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed diff --git a/tests/components/lametric/test_switch.py b/tests/components/lametric/test_switch.py index 64ebe22e98b..3e73b710942 100644 --- a/tests/components/lametric/test_switch.py +++ b/tests/components/lametric/test_switch.py @@ -22,7 +22,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed diff --git a/tests/components/litejet/conftest.py b/tests/components/litejet/conftest.py index 41517acf1e9..975f943d2fa 100644 --- a/tests/components/litejet/conftest.py +++ b/tests/components/litejet/conftest.py @@ -5,7 +5,7 @@ from unittest.mock import AsyncMock, Mock, patch import pytest -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util @pytest.fixture diff --git a/tests/components/litejet/test_trigger.py b/tests/components/litejet/test_trigger.py index c13fda9068c..de99d701926 100644 --- a/tests/components/litejet/test_trigger.py +++ b/tests/components/litejet/test_trigger.py @@ -11,7 +11,7 @@ import pytest from homeassistant import setup from homeassistant.components import automation from homeassistant.core import HomeAssistant, ServiceCall -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import async_init_integration diff --git a/tests/components/local_calendar/test_calendar.py b/tests/components/local_calendar/test_calendar.py index 61908faeca6..0720e6d7ded 100644 --- a/tests/components/local_calendar/test_calendar.py +++ b/tests/components/local_calendar/test_calendar.py @@ -8,7 +8,7 @@ import pytest from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant from homeassistant.helpers.template import DATE_STR_FORMAT -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .conftest import ( FRIENDLY_NAME, diff --git a/tests/components/lock/test_device_trigger.py b/tests/components/lock/test_device_trigger.py index 3ecdf2a9bca..7d1c39d10f0 100644 --- a/tests/components/lock/test_device_trigger.py +++ b/tests/components/lock/test_device_trigger.py @@ -13,7 +13,7 @@ from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity_registry import RegistryEntryHider from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import ( MockConfigEntry, diff --git a/tests/components/lutron_caseta/test_config_flow.py b/tests/components/lutron_caseta/test_config_flow.py index cc80bc08817..bdbe6501470 100644 --- a/tests/components/lutron_caseta/test_config_flow.py +++ b/tests/components/lutron_caseta/test_config_flow.py @@ -10,8 +10,10 @@ from pylutron_caseta.smartbridge import Smartbridge import pytest from homeassistant import config_entries -from homeassistant.components.lutron_caseta import DOMAIN -import homeassistant.components.lutron_caseta.config_flow as CasetaConfigFlow +from homeassistant.components.lutron_caseta import ( + DOMAIN, + config_flow as CasetaConfigFlow, +) from homeassistant.components.lutron_caseta.const import ( CONF_CA_CERTS, CONF_CERTFILE, From 417003ad35029a8ca6fe6db4952b967a03ad0053 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 29 Jan 2025 10:23:37 +0100 Subject: [PATCH 0202/3148] Rename environment_canada entities (#136817) --- .../components/environment_canada/camera.py | 4 ++-- .../components/environment_canada/sensor.py | 18 +++++++++++------- .../components/environment_canada/weather.py | 4 ++-- 3 files changed, 15 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/environment_canada/camera.py b/homeassistant/components/environment_canada/camera.py index d0497d855e5..4a321e88e6d 100644 --- a/homeassistant/components/environment_canada/camera.py +++ b/homeassistant/components/environment_canada/camera.py @@ -30,7 +30,7 @@ async def async_setup_entry( ) -> None: """Add a weather entity from a config_entry.""" coordinator = config_entry.runtime_data.radar_coordinator - async_add_entities([ECCamera(coordinator)]) + async_add_entities([ECCameraEntity(coordinator)]) platform = async_get_current_platform() platform.async_register_entity_service( @@ -40,7 +40,7 @@ async def async_setup_entry( ) -class ECCamera(CoordinatorEntity, Camera): +class ECCameraEntity(CoordinatorEntity, Camera): """Implementation of an Environment Canada radar camera.""" _attr_has_entity_name = True diff --git a/homeassistant/components/environment_canada/sensor.py b/homeassistant/components/environment_canada/sensor.py index ddececa8132..2d7a9648446 100644 --- a/homeassistant/components/environment_canada/sensor.py +++ b/homeassistant/components/environment_canada/sensor.py @@ -256,16 +256,20 @@ async def async_setup_entry( ) -> None: """Add a weather entity from a config_entry.""" weather_coordinator = config_entry.runtime_data.weather_coordinator - sensors: list[ECBaseSensor] = [ - ECSensor(weather_coordinator, desc) for desc in SENSOR_TYPES + sensors: list[ECBaseSensorEntity] = [ + ECSensorEntity(weather_coordinator, desc) for desc in SENSOR_TYPES ] - sensors.extend([ECAlertSensor(weather_coordinator, desc) for desc in ALERT_TYPES]) + sensors.extend( + [ECAlertSensorEntity(weather_coordinator, desc) for desc in ALERT_TYPES] + ) - sensors.append(ECSensor(config_entry.runtime_data.aqhi_coordinator, AQHI_SENSOR)) + sensors.append( + ECSensorEntity(config_entry.runtime_data.aqhi_coordinator, AQHI_SENSOR) + ) async_add_entities(sensors) -class ECBaseSensor(CoordinatorEntity, SensorEntity): +class ECBaseSensorEntity(CoordinatorEntity, SensorEntity): """Environment Canada sensor base.""" entity_description: ECSensorEntityDescription @@ -289,7 +293,7 @@ class ECBaseSensor(CoordinatorEntity, SensorEntity): return value -class ECSensor(ECBaseSensor): +class ECSensorEntity(ECBaseSensorEntity): """Environment Canada sensor for conditions.""" def __init__(self, coordinator, description): @@ -301,7 +305,7 @@ class ECSensor(ECBaseSensor): } -class ECAlertSensor(ECBaseSensor): +class ECAlertSensorEntity(ECBaseSensorEntity): """Environment Canada sensor for alerts.""" @property diff --git a/homeassistant/components/environment_canada/weather.py b/homeassistant/components/environment_canada/weather.py index e49164d6b81..a5bc72856e7 100644 --- a/homeassistant/components/environment_canada/weather.py +++ b/homeassistant/components/environment_canada/weather.py @@ -75,7 +75,7 @@ async def async_setup_entry( ): entity_registry.async_remove(hourly_entity_id) - async_add_entities([ECWeather(config_entry.runtime_data.weather_coordinator)]) + async_add_entities([ECWeatherEntity(config_entry.runtime_data.weather_coordinator)]) def _calculate_unique_id(config_entry_unique_id: str | None, hourly: bool) -> str: @@ -83,7 +83,7 @@ def _calculate_unique_id(config_entry_unique_id: str | None, hourly: bool) -> st return f"{config_entry_unique_id}{'-hourly' if hourly else '-daily'}" -class ECWeather(SingleCoordinatorWeatherEntity): +class ECWeatherEntity(SingleCoordinatorWeatherEntity): """Representation of a weather condition.""" _attr_has_entity_name = True From b93c2382ce66631d964d5a3f714150f35e0215c1 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Wed, 29 Jan 2025 10:35:01 +0100 Subject: [PATCH 0203/3148] Add config flow to filter helper (#121522) Co-authored-by: Robert Resch --- homeassistant/components/filter/__init__.py | 25 +- .../components/filter/config_flow.py | 243 ++++++++++++++++++ homeassistant/components/filter/const.py | 36 +++ homeassistant/components/filter/manifest.json | 1 + homeassistant/components/filter/sensor.py | 81 ++++-- homeassistant/components/filter/strings.json | 192 ++++++++++++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 2 +- tests/components/filter/conftest.py | 93 +++++++ tests/components/filter/test_config_flow.py | 227 ++++++++++++++++ tests/components/filter/test_init.py | 20 ++ tests/components/filter/test_sensor.py | 51 +++- 12 files changed, 938 insertions(+), 34 deletions(-) create mode 100644 homeassistant/components/filter/config_flow.py create mode 100644 homeassistant/components/filter/const.py create mode 100644 tests/components/filter/conftest.py create mode 100644 tests/components/filter/test_config_flow.py create mode 100644 tests/components/filter/test_init.py diff --git a/homeassistant/components/filter/__init__.py b/homeassistant/components/filter/__init__.py index 7f3f6cbfffc..9a4f4913c9f 100644 --- a/homeassistant/components/filter/__init__.py +++ b/homeassistant/components/filter/__init__.py @@ -1,6 +1,25 @@ """The filter component.""" -from homeassistant.const import Platform +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant -DOMAIN = "filter" -PLATFORMS = [Platform.SENSOR] +from .const import PLATFORMS + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Filter from a config entry.""" + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + entry.async_on_unload(entry.add_update_listener(update_listener)) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload Filter config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Handle options update.""" + await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/filter/config_flow.py b/homeassistant/components/filter/config_flow.py new file mode 100644 index 00000000000..dac2d8995bf --- /dev/null +++ b/homeassistant/components/filter/config_flow.py @@ -0,0 +1,243 @@ +"""Config flow for filter.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, cast + +import voluptuous as vol + +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.const import CONF_ENTITY_ID, CONF_NAME +from homeassistant.helpers.schema_config_entry_flow import ( + SchemaCommonFlowHandler, + SchemaConfigFlowHandler, + SchemaFlowFormStep, +) +from homeassistant.helpers.selector import ( + DurationSelector, + DurationSelectorConfig, + EntitySelector, + EntitySelectorConfig, + NumberSelector, + NumberSelectorConfig, + NumberSelectorMode, + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, + TextSelector, +) + +from .const import ( + CONF_FILTER_LOWER_BOUND, + CONF_FILTER_NAME, + CONF_FILTER_PRECISION, + CONF_FILTER_RADIUS, + CONF_FILTER_TIME_CONSTANT, + CONF_FILTER_UPPER_BOUND, + CONF_FILTER_WINDOW_SIZE, + CONF_TIME_SMA_TYPE, + DEFAULT_FILTER_RADIUS, + DEFAULT_FILTER_TIME_CONSTANT, + DEFAULT_NAME, + DEFAULT_PRECISION, + DEFAULT_WINDOW_SIZE, + DOMAIN, + FILTER_NAME_LOWPASS, + FILTER_NAME_OUTLIER, + FILTER_NAME_RANGE, + FILTER_NAME_THROTTLE, + FILTER_NAME_TIME_SMA, + FILTER_NAME_TIME_THROTTLE, + TIME_SMA_LAST, +) + +FILTERS = [ + FILTER_NAME_LOWPASS, + FILTER_NAME_OUTLIER, + FILTER_NAME_RANGE, + FILTER_NAME_THROTTLE, + FILTER_NAME_TIME_SMA, + FILTER_NAME_TIME_THROTTLE, +] + + +async def get_next_step(user_input: dict[str, Any]) -> str: + """Return next step for options.""" + return cast(str, user_input[CONF_FILTER_NAME]) + + +async def validate_options( + handler: SchemaCommonFlowHandler, user_input: dict[str, Any] +) -> dict[str, Any]: + """Validate options selected.""" + + if CONF_FILTER_WINDOW_SIZE in user_input and isinstance( + user_input[CONF_FILTER_WINDOW_SIZE], float + ): + user_input[CONF_FILTER_WINDOW_SIZE] = int(user_input[CONF_FILTER_WINDOW_SIZE]) + if CONF_FILTER_TIME_CONSTANT in user_input: + user_input[CONF_FILTER_TIME_CONSTANT] = int( + user_input[CONF_FILTER_TIME_CONSTANT] + ) + if CONF_FILTER_PRECISION in user_input: + user_input[CONF_FILTER_PRECISION] = int(user_input[CONF_FILTER_PRECISION]) + + handler.parent_handler._async_abort_entries_match({**handler.options, **user_input}) # noqa: SLF001 + + return user_input + + +DATA_SCHEMA_SETUP = vol.Schema( + { + vol.Required(CONF_NAME, default=DEFAULT_NAME): TextSelector(), + vol.Required(CONF_ENTITY_ID): EntitySelector( + EntitySelectorConfig(domain=[SENSOR_DOMAIN]) + ), + vol.Required(CONF_FILTER_NAME): SelectSelector( + SelectSelectorConfig( + options=FILTERS, + mode=SelectSelectorMode.DROPDOWN, + translation_key=CONF_FILTER_NAME, + ) + ), + } +) + +BASE_OPTIONS_SCHEMA = { + vol.Optional(CONF_FILTER_PRECISION, default=DEFAULT_PRECISION): NumberSelector( + NumberSelectorConfig(min=0, step=1, mode=NumberSelectorMode.BOX) + ) +} + +OUTLIER_SCHEMA = vol.Schema( + { + vol.Optional( + CONF_FILTER_WINDOW_SIZE, default=DEFAULT_WINDOW_SIZE + ): NumberSelector( + NumberSelectorConfig(min=0, step=1, mode=NumberSelectorMode.BOX) + ), + vol.Optional(CONF_FILTER_RADIUS, default=DEFAULT_FILTER_RADIUS): NumberSelector( + NumberSelectorConfig(min=0, step="any", mode=NumberSelectorMode.BOX) + ), + } +).extend(BASE_OPTIONS_SCHEMA) + +LOWPASS_SCHEMA = vol.Schema( + { + vol.Optional( + CONF_FILTER_WINDOW_SIZE, default=DEFAULT_WINDOW_SIZE + ): NumberSelector( + NumberSelectorConfig(min=0, step=1, mode=NumberSelectorMode.BOX) + ), + vol.Optional( + CONF_FILTER_TIME_CONSTANT, default=DEFAULT_FILTER_TIME_CONSTANT + ): NumberSelector( + NumberSelectorConfig(min=0, step=1, mode=NumberSelectorMode.BOX) + ), + } +).extend(BASE_OPTIONS_SCHEMA) + +RANGE_SCHEMA = vol.Schema( + { + vol.Optional(CONF_FILTER_LOWER_BOUND): NumberSelector( + NumberSelectorConfig(min=0, step="any", mode=NumberSelectorMode.BOX) + ), + vol.Optional(CONF_FILTER_UPPER_BOUND): NumberSelector( + NumberSelectorConfig(min=0, step="any", mode=NumberSelectorMode.BOX) + ), + } +).extend(BASE_OPTIONS_SCHEMA) + +TIME_SMA_SCHEMA = vol.Schema( + { + vol.Optional(CONF_TIME_SMA_TYPE, default=TIME_SMA_LAST): SelectSelector( + SelectSelectorConfig( + options=[TIME_SMA_LAST], + mode=SelectSelectorMode.DROPDOWN, + translation_key=CONF_TIME_SMA_TYPE, + ) + ), + vol.Required(CONF_FILTER_WINDOW_SIZE): DurationSelector( + DurationSelectorConfig(enable_day=False, allow_negative=False) + ), + } +).extend(BASE_OPTIONS_SCHEMA) + +THROTTLE_SCHEMA = vol.Schema( + { + vol.Optional( + CONF_FILTER_WINDOW_SIZE, default=DEFAULT_WINDOW_SIZE + ): NumberSelector( + NumberSelectorConfig(min=0, step=1, mode=NumberSelectorMode.BOX) + ), + } +).extend(BASE_OPTIONS_SCHEMA) + +TIME_THROTTLE_SCHEMA = vol.Schema( + { + vol.Required(CONF_FILTER_WINDOW_SIZE): DurationSelector( + DurationSelectorConfig(enable_day=False, allow_negative=False) + ), + } +).extend(BASE_OPTIONS_SCHEMA) + +CONFIG_FLOW = { + "user": SchemaFlowFormStep( + schema=DATA_SCHEMA_SETUP, + next_step=get_next_step, + ), + "lowpass": SchemaFlowFormStep( + schema=LOWPASS_SCHEMA, validate_user_input=validate_options + ), + "outlier": SchemaFlowFormStep( + schema=OUTLIER_SCHEMA, validate_user_input=validate_options + ), + "range": SchemaFlowFormStep( + schema=RANGE_SCHEMA, validate_user_input=validate_options + ), + "time_simple_moving_average": SchemaFlowFormStep( + schema=TIME_SMA_SCHEMA, validate_user_input=validate_options + ), + "throttle": SchemaFlowFormStep( + schema=THROTTLE_SCHEMA, validate_user_input=validate_options + ), + "time_throttle": SchemaFlowFormStep( + schema=TIME_THROTTLE_SCHEMA, validate_user_input=validate_options + ), +} +OPTIONS_FLOW = { + "init": SchemaFlowFormStep( + schema=None, + next_step=get_next_step, + ), + "lowpass": SchemaFlowFormStep( + schema=LOWPASS_SCHEMA, validate_user_input=validate_options + ), + "outlier": SchemaFlowFormStep( + schema=OUTLIER_SCHEMA, validate_user_input=validate_options + ), + "range": SchemaFlowFormStep( + schema=RANGE_SCHEMA, validate_user_input=validate_options + ), + "time_simple_moving_average": SchemaFlowFormStep( + schema=TIME_SMA_SCHEMA, validate_user_input=validate_options + ), + "throttle": SchemaFlowFormStep( + schema=THROTTLE_SCHEMA, validate_user_input=validate_options + ), + "time_throttle": SchemaFlowFormStep( + schema=TIME_THROTTLE_SCHEMA, validate_user_input=validate_options + ), +} + + +class FilterConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN): + """Handle a config flow for Filter.""" + + config_flow = CONFIG_FLOW + options_flow = OPTIONS_FLOW + + def async_config_entry_title(self, options: Mapping[str, Any]) -> str: + """Return config entry title.""" + return cast(str, options[CONF_NAME]) diff --git a/homeassistant/components/filter/const.py b/homeassistant/components/filter/const.py new file mode 100644 index 00000000000..92d2498528e --- /dev/null +++ b/homeassistant/components/filter/const.py @@ -0,0 +1,36 @@ +"""The filter component constants.""" + +from homeassistant.const import Platform + +DOMAIN = "filter" +PLATFORMS = [Platform.SENSOR] + +CONF_INDEX = "index" + +FILTER_NAME_RANGE = "range" +FILTER_NAME_LOWPASS = "lowpass" +FILTER_NAME_OUTLIER = "outlier" +FILTER_NAME_THROTTLE = "throttle" +FILTER_NAME_TIME_THROTTLE = "time_throttle" +FILTER_NAME_TIME_SMA = "time_simple_moving_average" + +CONF_FILTERS = "filters" +CONF_FILTER_NAME = "filter" +CONF_FILTER_WINDOW_SIZE = "window_size" +CONF_FILTER_PRECISION = "precision" +CONF_FILTER_RADIUS = "radius" +CONF_FILTER_TIME_CONSTANT = "time_constant" +CONF_FILTER_LOWER_BOUND = "lower_bound" +CONF_FILTER_UPPER_BOUND = "upper_bound" +CONF_TIME_SMA_TYPE = "type" + +TIME_SMA_LAST = "last" + +WINDOW_SIZE_UNIT_NUMBER_EVENTS = 1 +WINDOW_SIZE_UNIT_TIME = 2 + +DEFAULT_NAME = "Filtered sensor" +DEFAULT_WINDOW_SIZE = 1 +DEFAULT_PRECISION = 2 +DEFAULT_FILTER_RADIUS = 2.0 +DEFAULT_FILTER_TIME_CONSTANT = 10 diff --git a/homeassistant/components/filter/manifest.json b/homeassistant/components/filter/manifest.json index 4d9a8992036..392351a235d 100644 --- a/homeassistant/components/filter/manifest.json +++ b/homeassistant/components/filter/manifest.json @@ -2,6 +2,7 @@ "domain": "filter", "name": "Filter", "codeowners": ["@dgomes"], + "config_flow": true, "dependencies": ["recorder"], "documentation": "https://www.home-assistant.io/integrations/filter", "integration_type": "helper", diff --git a/homeassistant/components/filter/sensor.py b/homeassistant/components/filter/sensor.py index 549d74ffd09..5bb6cadabc7 100644 --- a/homeassistant/components/filter/sensor.py +++ b/homeassistant/components/filter/sensor.py @@ -24,6 +24,7 @@ from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_ENTITY_ID, @@ -51,39 +52,37 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateTyp from homeassistant.util.decorator import Registry import homeassistant.util.dt as dt_util -from . import DOMAIN, PLATFORMS +from .const import ( + CONF_FILTER_LOWER_BOUND, + CONF_FILTER_NAME, + CONF_FILTER_PRECISION, + CONF_FILTER_RADIUS, + CONF_FILTER_TIME_CONSTANT, + CONF_FILTER_UPPER_BOUND, + CONF_FILTER_WINDOW_SIZE, + CONF_FILTERS, + CONF_TIME_SMA_TYPE, + DEFAULT_FILTER_RADIUS, + DEFAULT_FILTER_TIME_CONSTANT, + DEFAULT_PRECISION, + DEFAULT_WINDOW_SIZE, + DOMAIN, + FILTER_NAME_LOWPASS, + FILTER_NAME_OUTLIER, + FILTER_NAME_RANGE, + FILTER_NAME_THROTTLE, + FILTER_NAME_TIME_SMA, + FILTER_NAME_TIME_THROTTLE, + PLATFORMS, + TIME_SMA_LAST, + WINDOW_SIZE_UNIT_NUMBER_EVENTS, + WINDOW_SIZE_UNIT_TIME, +) _LOGGER = logging.getLogger(__name__) -FILTER_NAME_RANGE = "range" -FILTER_NAME_LOWPASS = "lowpass" -FILTER_NAME_OUTLIER = "outlier" -FILTER_NAME_THROTTLE = "throttle" -FILTER_NAME_TIME_THROTTLE = "time_throttle" -FILTER_NAME_TIME_SMA = "time_simple_moving_average" FILTERS: Registry[str, type[Filter]] = Registry() -CONF_FILTERS = "filters" -CONF_FILTER_NAME = "filter" -CONF_FILTER_WINDOW_SIZE = "window_size" -CONF_FILTER_PRECISION = "precision" -CONF_FILTER_RADIUS = "radius" -CONF_FILTER_TIME_CONSTANT = "time_constant" -CONF_FILTER_LOWER_BOUND = "lower_bound" -CONF_FILTER_UPPER_BOUND = "upper_bound" -CONF_TIME_SMA_TYPE = "type" - -TIME_SMA_LAST = "last" - -WINDOW_SIZE_UNIT_NUMBER_EVENTS = 1 -WINDOW_SIZE_UNIT_TIME = 2 - -DEFAULT_WINDOW_SIZE = 1 -DEFAULT_PRECISION = 2 -DEFAULT_FILTER_RADIUS = 2.0 -DEFAULT_FILTER_TIME_CONSTANT = 10 - -NAME_TEMPLATE = "{} filter" ICON = "mdi:chart-line-variant" FILTER_SCHEMA = vol.Schema({vol.Optional(CONF_FILTER_PRECISION): vol.Coerce(int)}) @@ -199,6 +198,32 @@ async def async_setup_platform( async_add_entities([SensorFilter(name, unique_id, entity_id, filters)]) +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Filter sensor entry.""" + name: str = entry.options[CONF_NAME] + entity_id: str = entry.options[CONF_ENTITY_ID] + + filter_config = { + k: v for k, v in entry.options.items() if k not in (CONF_NAME, CONF_ENTITY_ID) + } + if CONF_FILTER_WINDOW_SIZE in filter_config and isinstance( + filter_config[CONF_FILTER_WINDOW_SIZE], dict + ): + filter_config[CONF_FILTER_WINDOW_SIZE] = timedelta( + **filter_config[CONF_FILTER_WINDOW_SIZE] + ) + + filters = [ + FILTERS[filter_config.pop(CONF_FILTER_NAME)](entity=entity_id, **filter_config) + ] + + async_add_entities([SensorFilter(name, entry.entry_id, entity_id, filters)]) + + class SensorFilter(SensorEntity): """Representation of a Filter Sensor.""" diff --git a/homeassistant/components/filter/strings.json b/homeassistant/components/filter/strings.json index 2a83a05bb96..b0403227fd4 100644 --- a/homeassistant/components/filter/strings.json +++ b/homeassistant/components/filter/strings.json @@ -1,5 +1,197 @@ { "title": "Filter", + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + }, + "step": { + "user": { + "description": "Add a filter sensor. UI configuration is limited to a single filter, use YAML for filter chain.", + "data": { + "name": "[%key:common::config_flow::data::name%]", + "entity_id": "Entity", + "filter": "Filter" + }, + "data_description": { + "name": "Name for the created entity.", + "entity_id": "Entity to filter from.", + "filter": "Select filter to configure." + } + }, + "outlier": { + "description": "Read the documentation for further details on how to configure the filter sensor using these options.", + "data": { + "window_size": "Window size", + "precision": "Precision", + "radius": "Radius" + }, + "data_description": { + "window_size": "Size of the window of previous states.", + "precision": "Defines the number of decimal places of the calculated sensor value.", + "radius": "Band radius from median of previous states." + } + }, + "lowpass": { + "description": "[%key:component::filter::config::step::outlier::description%]", + "data": { + "window_size": "[%key:component::filter::config::step::outlier::data::window_size%]", + "precision": "[%key:component::filter::config::step::outlier::data::precision%]", + "time_constant": "Time constant" + }, + "data_description": { + "window_size": "[%key:component::filter::config::step::outlier::data_description::window_size%]", + "precision": "[%key:component::filter::config::step::outlier::data_description::precision%]", + "time_constant": "Loosely relates to the amount of time it takes for a state to influence the output." + } + }, + "range": { + "description": "[%key:component::filter::config::step::outlier::description%]", + "data": { + "precision": "[%key:component::filter::config::step::outlier::data::precision%]", + "lower_bound": "Lower bound", + "upper_bound": "Upper bound" + }, + "data_description": { + "precision": "[%key:component::filter::config::step::outlier::data_description::precision%]", + "lower_bound": "Lower bound for filter range.", + "upper_bound": "Upper bound for filter range." + } + }, + "time_simple_moving_average": { + "description": "[%key:component::filter::config::step::outlier::description%]", + "data": { + "window_size": "[%key:component::filter::config::step::outlier::data::window_size%]", + "precision": "[%key:component::filter::config::step::outlier::data::precision%]", + "type": "Type" + }, + "data_description": { + "window_size": "[%key:component::filter::config::step::outlier::data_description::window_size%]", + "precision": "[%key:component::filter::config::step::outlier::data_description::precision%]", + "type": "Defines the type of Simple Moving Average." + } + }, + "throttle": { + "description": "[%key:component::filter::config::step::outlier::description%]", + "data": { + "window_size": "[%key:component::filter::config::step::outlier::data::window_size%]", + "precision": "[%key:component::filter::config::step::outlier::data::precision%]" + }, + "data_description": { + "window_size": "[%key:component::filter::config::step::outlier::data_description::window_size%]", + "precision": "[%key:component::filter::config::step::outlier::data_description::precision%]" + } + }, + "time_throttle": { + "description": "[%key:component::filter::config::step::outlier::description%]", + "data": { + "window_size": "[%key:component::filter::config::step::outlier::data::window_size%]", + "precision": "[%key:component::filter::config::step::outlier::data::precision%]" + }, + "data_description": { + "window_size": "[%key:component::filter::config::step::outlier::data_description::window_size%]", + "precision": "[%key:component::filter::config::step::outlier::data_description::precision%]" + } + } + } + }, + "options": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + }, + "step": { + "outlier": { + "description": "[%key:component::filter::config::step::outlier::description%]", + "data": { + "window_size": "[%key:component::filter::config::step::outlier::data::window_size%]", + "precision": "[%key:component::filter::config::step::outlier::data::precision%]", + "radius": "[%key:component::filter::config::step::outlier::data::radius%]" + }, + "data_description": { + "window_size": "[%key:component::filter::config::step::outlier::data_description::window_size%]", + "precision": "[%key:component::filter::config::step::outlier::data_description::precision%]", + "radius": "[%key:component::filter::config::step::outlier::data_description::radius%]" + } + }, + "lowpass": { + "description": "[%key:component::filter::config::step::outlier::description%]", + "data": { + "window_size": "[%key:component::filter::config::step::outlier::data::window_size%]", + "precision": "[%key:component::filter::config::step::outlier::data::precision%]", + "time_constant": "[%key:component::filter::config::step::lowpass::data::time_constant%]" + }, + "data_description": { + "window_size": "[%key:component::filter::config::step::outlier::data_description::window_size%]", + "precision": "[%key:component::filter::config::step::outlier::data_description::precision%]", + "time_constant": "[%key:component::filter::config::step::lowpass::data_description::time_constant%]" + } + }, + "range": { + "description": "[%key:component::filter::config::step::outlier::description%]", + "data": { + "precision": "[%key:component::filter::config::step::outlier::data::precision%]", + "lower_bound": "[%key:component::filter::config::step::range::data::lower_bound%]", + "upper_bound": "[%key:component::filter::config::step::range::data::upper_bound%]" + }, + "data_description": { + "precision": "[%key:component::filter::config::step::outlier::data_description::precision%]", + "lower_bound": "[%key:component::filter::config::step::range::data_description::lower_bound%]", + "upper_bound": "[%key:component::filter::config::step::range::data_description::upper_bound%]" + } + }, + "time_simple_moving_average": { + "description": "[%key:component::filter::config::step::outlier::description%]", + "data": { + "window_size": "[%key:component::filter::config::step::outlier::data::window_size%]", + "precision": "[%key:component::filter::config::step::outlier::data::precision%]", + "type": "[%key:component::filter::config::step::time_simple_moving_average::data::type%]" + }, + "data_description": { + "window_size": "[%key:component::filter::config::step::outlier::data_description::window_size%]", + "precision": "[%key:component::filter::config::step::outlier::data_description::precision%]", + "type": "[%key:component::filter::config::step::time_simple_moving_average::data_description::type%]" + } + }, + "throttle": { + "description": "[%key:component::filter::config::step::outlier::description%]", + "data": { + "window_size": "[%key:component::filter::config::step::outlier::data::window_size%]", + "precision": "[%key:component::filter::config::step::outlier::data::precision%]" + }, + "data_description": { + "window_size": "[%key:component::filter::config::step::outlier::data_description::window_size%]", + "precision": "[%key:component::filter::config::step::outlier::data_description::precision%]" + } + }, + "time_throttle": { + "description": "[%key:component::filter::config::step::outlier::description%]", + "data": { + "window_size": "[%key:component::filter::config::step::outlier::data::window_size%]", + "precision": "[%key:component::filter::config::step::outlier::data::precision%]" + }, + "data_description": { + "window_size": "[%key:component::filter::config::step::outlier::data_description::window_size%]", + "precision": "[%key:component::filter::config::step::outlier::data_description::precision%]" + } + } + } + }, + "selector": { + "filter": { + "options": { + "range": "Range", + "lowpass": "Lowpass", + "outlier": "Outlier", + "throttle": "Throttle", + "time_throttle": "Time throttle", + "time_simple_moving_average": "Moving Average (Time based)" + } + }, + "type": { + "options": { + "last": "Last" + } + } + }, "services": { "reload": { "name": "[%key:common::action::reload%]", diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 921910d5046..3c8a1d40dc2 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -6,6 +6,7 @@ To update, run python3 -m script.hassfest FLOWS = { "helper": [ "derivative", + "filter", "generic_hygrostat", "generic_thermostat", "group", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 05227e20159..e8a4290bb7d 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -7436,7 +7436,7 @@ }, "filter": { "integration_type": "helper", - "config_flow": false, + "config_flow": true, "iot_class": "local_push" }, "generic_hygrostat": { diff --git a/tests/components/filter/conftest.py b/tests/components/filter/conftest.py new file mode 100644 index 00000000000..e703430446c --- /dev/null +++ b/tests/components/filter/conftest.py @@ -0,0 +1,93 @@ +"""Fixtures for the Filter integration.""" + +from __future__ import annotations + +from collections.abc import Generator +from datetime import timedelta +from typing import Any +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.components.filter.const import ( + CONF_FILTER_NAME, + CONF_FILTER_PRECISION, + CONF_FILTER_RADIUS, + CONF_FILTER_WINDOW_SIZE, + DEFAULT_FILTER_RADIUS, + DEFAULT_NAME, + DEFAULT_PRECISION, + DEFAULT_WINDOW_SIZE, + DOMAIN, + FILTER_NAME_OUTLIER, +) +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_ENTITY_ID, CONF_NAME +from homeassistant.core import HomeAssistant, State +import homeassistant.util.dt as dt_util + +from tests.common import MockConfigEntry + + +@pytest.fixture(name="values") +def values_fixture() -> list[State]: + """Fixture for a list of test States.""" + values = [] + raw_values = [20, 19, 18, 21, 22, 0] + timestamp = dt_util.utcnow() + for val in raw_values: + values.append(State("sensor.test_monitored", str(val), last_updated=timestamp)) + timestamp += timedelta(minutes=1) + return values + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Automatically patch setup_entry.""" + with patch( + "homeassistant.components.filter.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture(name="get_config") +async def get_config_to_integration_load() -> dict[str, Any]: + """Return configuration. + + To override the config, tests can be marked with: + @pytest.mark.parametrize("get_config", [{...}]) + """ + return { + CONF_NAME: DEFAULT_NAME, + CONF_ENTITY_ID: "sensor.test_monitored", + CONF_FILTER_NAME: FILTER_NAME_OUTLIER, + CONF_FILTER_WINDOW_SIZE: DEFAULT_WINDOW_SIZE, + CONF_FILTER_RADIUS: DEFAULT_FILTER_RADIUS, + CONF_FILTER_PRECISION: DEFAULT_PRECISION, + } + + +@pytest.fixture(name="loaded_entry") +async def load_integration( + hass: HomeAssistant, get_config: dict[str, Any], values: list[State] +) -> MockConfigEntry: + """Set up the Filter integration in Home Assistant.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + source=SOURCE_USER, + options=get_config, + entry_id="1", + ) + + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + for value in values: + hass.states.async_set(get_config["entity_id"], value.state) + await hass.async_block_till_done() + await hass.async_block_till_done() + + return config_entry diff --git a/tests/components/filter/test_config_flow.py b/tests/components/filter/test_config_flow.py new file mode 100644 index 00000000000..d4a7f7a854f --- /dev/null +++ b/tests/components/filter/test_config_flow.py @@ -0,0 +1,227 @@ +"""Test the Filter config flow.""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import AsyncMock + +import pytest + +from homeassistant import config_entries +from homeassistant.components.filter.const import ( + CONF_FILTER_LOWER_BOUND, + CONF_FILTER_NAME, + CONF_FILTER_PRECISION, + CONF_FILTER_RADIUS, + CONF_FILTER_TIME_CONSTANT, + CONF_FILTER_UPPER_BOUND, + CONF_FILTER_WINDOW_SIZE, + CONF_TIME_SMA_TYPE, + DEFAULT_FILTER_RADIUS, + DEFAULT_NAME, + DEFAULT_PRECISION, + DEFAULT_WINDOW_SIZE, + DOMAIN, + FILTER_NAME_LOWPASS, + FILTER_NAME_OUTLIER, + FILTER_NAME_RANGE, + FILTER_NAME_THROTTLE, + FILTER_NAME_TIME_SMA, + FILTER_NAME_TIME_THROTTLE, + TIME_SMA_LAST, +) +from homeassistant.components.recorder import Recorder +from homeassistant.const import CONF_ENTITY_ID, CONF_NAME +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +@pytest.mark.parametrize( + ("entry_config", "options", "result_options"), + [ + ( + {CONF_FILTER_NAME: FILTER_NAME_OUTLIER}, + { + CONF_FILTER_WINDOW_SIZE: 1.0, + CONF_FILTER_RADIUS: 2.0, + }, + { + CONF_FILTER_NAME: FILTER_NAME_OUTLIER, + CONF_FILTER_WINDOW_SIZE: 1, + CONF_FILTER_RADIUS: 2.0, + }, + ), + ( + {CONF_FILTER_NAME: FILTER_NAME_LOWPASS}, + { + CONF_FILTER_WINDOW_SIZE: 1.0, + CONF_FILTER_TIME_CONSTANT: 10.0, + }, + { + CONF_FILTER_NAME: FILTER_NAME_LOWPASS, + CONF_FILTER_WINDOW_SIZE: 1, + CONF_FILTER_TIME_CONSTANT: 10, + }, + ), + ( + {CONF_FILTER_NAME: FILTER_NAME_RANGE}, + { + CONF_FILTER_LOWER_BOUND: 1.0, + CONF_FILTER_UPPER_BOUND: 10.0, + }, + { + CONF_FILTER_NAME: FILTER_NAME_RANGE, + CONF_FILTER_LOWER_BOUND: 1.0, + CONF_FILTER_UPPER_BOUND: 10.0, + }, + ), + ( + {CONF_FILTER_NAME: FILTER_NAME_TIME_SMA}, + { + CONF_TIME_SMA_TYPE: TIME_SMA_LAST, + CONF_FILTER_WINDOW_SIZE: {"hours": 40, "minutes": 5, "seconds": 5}, + }, + { + CONF_FILTER_NAME: FILTER_NAME_TIME_SMA, + CONF_TIME_SMA_TYPE: TIME_SMA_LAST, + CONF_FILTER_WINDOW_SIZE: {"hours": 40, "minutes": 5, "seconds": 5}, + }, + ), + ( + {CONF_FILTER_NAME: FILTER_NAME_THROTTLE}, + { + CONF_FILTER_WINDOW_SIZE: 1.0, + }, + { + CONF_FILTER_NAME: FILTER_NAME_THROTTLE, + CONF_FILTER_WINDOW_SIZE: 1, + }, + ), + ( + {CONF_FILTER_NAME: FILTER_NAME_TIME_THROTTLE}, + { + CONF_FILTER_WINDOW_SIZE: {"hours": 40, "minutes": 5, "seconds": 5}, + }, + { + CONF_FILTER_NAME: FILTER_NAME_TIME_THROTTLE, + CONF_FILTER_WINDOW_SIZE: {"hours": 40, "minutes": 5, "seconds": 5}, + }, + ), + ], +) +async def test_form( + recorder_mock: Recorder, + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + entry_config: dict[str, Any], + options: dict[str, Any], + result_options: dict[str, Any], +) -> None: + """Test we get the form.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["step_id"] == "user" + assert result["type"] is FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_NAME: DEFAULT_NAME, + CONF_ENTITY_ID: "sensor.test_monitored", + **entry_config, + }, + ) + await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_FILTER_PRECISION: DEFAULT_PRECISION, **options}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["version"] == 1 + assert result["options"] == { + CONF_NAME: DEFAULT_NAME, + CONF_ENTITY_ID: "sensor.test_monitored", + CONF_FILTER_PRECISION: DEFAULT_PRECISION, + **result_options, + } + + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_options_flow( + recorder_mock: Recorder, hass: HomeAssistant, loaded_entry: MockConfigEntry +) -> None: + """Test options flow.""" + + result = await hass.config_entries.options.async_init(loaded_entry.entry_id) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "outlier" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_FILTER_WINDOW_SIZE: 2.0, + CONF_FILTER_RADIUS: 3.0, + CONF_FILTER_PRECISION: DEFAULT_PRECISION, + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_NAME: DEFAULT_NAME, + CONF_ENTITY_ID: "sensor.test_monitored", + CONF_FILTER_NAME: FILTER_NAME_OUTLIER, + CONF_FILTER_WINDOW_SIZE: 2, + CONF_FILTER_RADIUS: 3.0, + CONF_FILTER_PRECISION: DEFAULT_PRECISION, + } + + await hass.async_block_till_done() + + # Check the entity was updated, no new entity was created + assert len(hass.states.async_all()) == 2 + + state = hass.states.get("sensor.filtered_sensor") + assert state is not None + + +async def test_entry_already_exist( + recorder_mock: Recorder, hass: HomeAssistant, loaded_entry: MockConfigEntry +) -> None: + """Test abort when entry already exist.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["step_id"] == "user" + assert result["type"] is FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_NAME: DEFAULT_NAME, + CONF_ENTITY_ID: "sensor.test_monitored", + CONF_FILTER_NAME: FILTER_NAME_OUTLIER, + }, + ) + await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_FILTER_WINDOW_SIZE: DEFAULT_WINDOW_SIZE, + CONF_FILTER_RADIUS: DEFAULT_FILTER_RADIUS, + CONF_FILTER_PRECISION: DEFAULT_PRECISION, + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/filter/test_init.py b/tests/components/filter/test_init.py new file mode 100644 index 00000000000..a5d5cf84a67 --- /dev/null +++ b/tests/components/filter/test_init.py @@ -0,0 +1,20 @@ +"""Test Filter component setup process.""" + +from __future__ import annotations + +from homeassistant.components.recorder import Recorder +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_unload_entry( + recorder_mock: Recorder, hass: HomeAssistant, loaded_entry: MockConfigEntry +) -> None: + """Test unload an entry.""" + + assert loaded_entry.state is ConfigEntryState.LOADED + assert await hass.config_entries.async_unload(loaded_entry.entry_id) + await hass.async_block_till_done() + assert loaded_entry.state is ConfigEntryState.NOT_LOADED diff --git a/tests/components/filter/test_sensor.py b/tests/components/filter/test_sensor.py index 4312047278f..22db1c3cec2 100644 --- a/tests/components/filter/test_sensor.py +++ b/tests/components/filter/test_sensor.py @@ -6,8 +6,18 @@ from unittest.mock import patch import pytest from homeassistant import config as hass_config -from homeassistant.components.filter.sensor import ( +from homeassistant.components.filter.const import ( + CONF_FILTER_NAME, + CONF_FILTER_PRECISION, + CONF_FILTER_WINDOW_SIZE, + CONF_TIME_SMA_TYPE, + DEFAULT_NAME, + DEFAULT_PRECISION, DOMAIN, + FILTER_NAME_TIME_SMA, + TIME_SMA_LAST, +) +from homeassistant.components.filter.sensor import ( LowPassFilter, OutlierFilter, RangeFilter, @@ -24,6 +34,8 @@ from homeassistant.components.sensor import ( from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_UNIT_OF_MEASUREMENT, + CONF_ENTITY_ID, + CONF_NAME, SERVICE_RELOAD, STATE_UNAVAILABLE, STATE_UNKNOWN, @@ -34,7 +46,7 @@ from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util -from tests.common import assert_setup_component, get_fixture_path +from tests.common import MockConfigEntry, assert_setup_component, get_fixture_path @pytest.fixture(autouse=True, name="stub_blueprint_populate") @@ -97,6 +109,41 @@ async def test_chain( assert state.state == "18.05" +async def test_from_config_entry( + recorder_mock: Recorder, + hass: HomeAssistant, + loaded_entry: MockConfigEntry, +) -> None: + """Test if filter works loaded from config entry.""" + + state = hass.states.get("sensor.filtered_sensor") + assert state.state == "22.0" + + +@pytest.mark.parametrize( + "get_config", + [ + { + CONF_NAME: DEFAULT_NAME, + CONF_ENTITY_ID: "sensor.test_monitored", + CONF_FILTER_NAME: FILTER_NAME_TIME_SMA, + CONF_TIME_SMA_TYPE: TIME_SMA_LAST, + CONF_FILTER_WINDOW_SIZE: {"hours": 40, "minutes": 5, "seconds": 5}, + CONF_FILTER_PRECISION: DEFAULT_PRECISION, + } + ], +) +async def test_from_config_entry_duration( + recorder_mock: Recorder, + hass: HomeAssistant, + loaded_entry: MockConfigEntry, +) -> None: + """Test if filter works loaded from config entry with duration.""" + + state = hass.states.get("sensor.filtered_sensor") + assert state.state == "20.0" + + @pytest.mark.parametrize("missing", [True, False]) async def test_chain_history( recorder_mock: Recorder, From a6d132a3371d8e9f907c66ffd0cea88391963a64 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 29 Jan 2025 10:41:33 +0100 Subject: [PATCH 0204/3148] Simplify device_info access in environment_canada (#136816) * Simplify device_info access in environment_canada * Update homeassistant/components/environment_canada/coordinator.py --------- Co-authored-by: Joost Lekkerkerker --- .../components/environment_canada/__init__.py | 14 +------------- .../components/environment_canada/camera.py | 3 +-- .../components/environment_canada/coordinator.py | 7 +++++++ .../components/environment_canada/sensor.py | 3 +-- .../components/environment_canada/weather.py | 3 +-- 5 files changed, 11 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/environment_canada/__init__.py b/homeassistant/components/environment_canada/__init__.py index c87832de6ab..6afea2f983d 100644 --- a/homeassistant/components/environment_canada/__init__.py +++ b/homeassistant/components/environment_canada/__init__.py @@ -8,9 +8,8 @@ from env_canada import ECAirQuality, ECRadar, ECWeather from homeassistant.const import CONF_LANGUAGE, CONF_LATITUDE, CONF_LONGITUDE, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from .const import CONF_STATION, DOMAIN +from .const import CONF_STATION from .coordinator import ECConfigEntry, ECDataUpdateCoordinator, ECRuntimeData DEFAULT_RADAR_UPDATE_INTERVAL = timedelta(minutes=5) @@ -81,14 +80,3 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ECConfigEntry) -> async def async_unload_entry(hass: HomeAssistant, config_entry: ECConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) - - -def device_info(config_entry: ECConfigEntry) -> DeviceInfo: - """Build and return the device info for EC.""" - return DeviceInfo( - entry_type=DeviceEntryType.SERVICE, - identifiers={(DOMAIN, config_entry.entry_id)}, - manufacturer="Environment Canada", - name=config_entry.title, - configuration_url="https://weather.gc.ca/", - ) diff --git a/homeassistant/components/environment_canada/camera.py b/homeassistant/components/environment_canada/camera.py index 4a321e88e6d..fd82ac97bea 100644 --- a/homeassistant/components/environment_canada/camera.py +++ b/homeassistant/components/environment_canada/camera.py @@ -13,7 +13,6 @@ from homeassistant.helpers.entity_platform import ( from homeassistant.helpers.typing import VolDictType from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import device_info from .const import ATTR_OBSERVATION_TIME from .coordinator import ECConfigEntry @@ -55,7 +54,7 @@ class ECCameraEntity(CoordinatorEntity, Camera): self._attr_unique_id = f"{coordinator.config_entry.unique_id}-radar" self._attr_attribution = self.radar_object.metadata["attribution"] self._attr_entity_registry_enabled_default = False - self._attr_device_info = device_info(coordinator.config_entry) + self._attr_device_info = coordinator.device_info self.content_type = "image/gif" diff --git a/homeassistant/components/environment_canada/coordinator.py b/homeassistant/components/environment_canada/coordinator.py index 8161e26028c..e65d8f6e471 100644 --- a/homeassistant/components/environment_canada/coordinator.py +++ b/homeassistant/components/environment_canada/coordinator.py @@ -11,6 +11,7 @@ from env_canada import ECAirQuality, ECRadar, ECWeather, ec_exc from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN @@ -52,6 +53,12 @@ class ECDataUpdateCoordinator[_ECDataTypeT: ECAirQuality | ECRadar | ECWeather]( ) self.ec_data = ec_data self.last_update_success = False + self.device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, entry.entry_id)}, + manufacturer="Environment Canada", + configuration_url="https://weather.gc.ca/", + ) async def _async_update_data(self) -> _ECDataTypeT: """Fetch data from EC.""" diff --git a/homeassistant/components/environment_canada/sensor.py b/homeassistant/components/environment_canada/sensor.py index 2d7a9648446..1485f890cd2 100644 --- a/homeassistant/components/environment_canada/sensor.py +++ b/homeassistant/components/environment_canada/sensor.py @@ -26,7 +26,6 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import device_info from .const import ATTR_STATION from .coordinator import ECConfigEntry @@ -282,7 +281,7 @@ class ECBaseSensorEntity(CoordinatorEntity, SensorEntity): self._ec_data = coordinator.ec_data self._attr_attribution = self._ec_data.metadata["attribution"] self._attr_unique_id = f"{coordinator.config_entry.title}-{description.key}" - self._attr_device_info = device_info(coordinator.config_entry) + self._attr_device_info = coordinator.device_info @property def native_value(self): diff --git a/homeassistant/components/environment_canada/weather.py b/homeassistant/components/environment_canada/weather.py index a5bc72856e7..5cfe32f18dd 100644 --- a/homeassistant/components/environment_canada/weather.py +++ b/homeassistant/components/environment_canada/weather.py @@ -37,7 +37,6 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import device_info from .const import DOMAIN from .coordinator import ECConfigEntry @@ -104,7 +103,7 @@ class ECWeatherEntity(SingleCoordinatorWeatherEntity): self._attr_unique_id = _calculate_unique_id( coordinator.config_entry.unique_id, False ) - self._attr_device_info = device_info(coordinator.config_entry) + self._attr_device_info = coordinator.device_info @property def native_temperature(self): From 646e0d46266550d168373412e8bee3edae95401c Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 29 Jan 2025 10:42:30 +0100 Subject: [PATCH 0205/3148] Bump aiohasupervisor to version 0.2.2b6 (#136814) --- homeassistant/components/hassio/backup.py | 1 + homeassistant/components/hassio/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/hassio/test_backup.py | 26 ++++++++++++++++++- 8 files changed, 32 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/hassio/backup.py b/homeassistant/components/hassio/backup.py index 4a9bfaded15..9362c03b0be 100644 --- a/homeassistant/components/hassio/backup.py +++ b/homeassistant/components/hassio/backup.py @@ -44,6 +44,7 @@ from .const import DOMAIN, EVENT_SUPERVISOR_EVENT from .handler import get_supervisor_client LOCATION_CLOUD_BACKUP = ".cloud_backup" +LOCATION_LOCAL = ".local" MOUNT_JOBS = ("mount_manager_create_mount", "mount_manager_remove_mount") _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/hassio/manifest.json b/homeassistant/components/hassio/manifest.json index c9ecf6657e8..ccc0f23fb43 100644 --- a/homeassistant/components/hassio/manifest.json +++ b/homeassistant/components/hassio/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/hassio", "iot_class": "local_polling", "quality_scale": "internal", - "requirements": ["aiohasupervisor==0.2.2b5"], + "requirements": ["aiohasupervisor==0.2.2b6"], "single_config_entry": true } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index f7f30bf7d71..f29c00244a6 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -3,7 +3,7 @@ aiodhcpwatcher==1.0.2 aiodiscover==2.1.0 aiodns==3.2.0 -aiohasupervisor==0.2.2b5 +aiohasupervisor==0.2.2b6 aiohttp-asyncmdnsresolver==0.0.1 aiohttp-fast-zlib==0.2.0 aiohttp==3.11.11 diff --git a/pyproject.toml b/pyproject.toml index 0e67a78954b..5393193a41e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,7 +27,7 @@ dependencies = [ # Integrations may depend on hassio integration without listing it to # change behavior based on presence of supervisor. Deprecated with #127228 # Lib can be removed with 2025.11 - "aiohasupervisor==0.2.2b5", + "aiohasupervisor==0.2.2b6", "aiohttp==3.11.11", "aiohttp_cors==0.7.0", "aiohttp-fast-zlib==0.2.0", diff --git a/requirements.txt b/requirements.txt index 2ffb530393e..a98d53b6037 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,7 +4,7 @@ # Home Assistant Core aiodns==3.2.0 -aiohasupervisor==0.2.2b5 +aiohasupervisor==0.2.2b6 aiohttp==3.11.11 aiohttp_cors==0.7.0 aiohttp-fast-zlib==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index d6fac067973..c8d7ccbe50f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -261,7 +261,7 @@ aioguardian==2022.07.0 aioharmony==0.4.1 # homeassistant.components.hassio -aiohasupervisor==0.2.2b5 +aiohasupervisor==0.2.2b6 # homeassistant.components.homekit_controller aiohomekit==3.2.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 366edfd23ce..1a5c9ba91b9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -246,7 +246,7 @@ aioguardian==2022.07.0 aioharmony==0.4.1 # homeassistant.components.hassio -aiohasupervisor==0.2.2b5 +aiohasupervisor==0.2.2b6 # homeassistant.components.homekit_controller aiohomekit==3.2.7 diff --git a/tests/components/hassio/test_backup.py b/tests/components/hassio/test_backup.py index 8cf8d11af04..1a5701a79cf 100644 --- a/tests/components/hassio/test_backup.py +++ b/tests/components/hassio/test_backup.py @@ -35,7 +35,7 @@ from homeassistant.components.backup import ( Folder, ) from homeassistant.components.hassio import DOMAIN -from homeassistant.components.hassio.backup import LOCATION_CLOUD_BACKUP +from homeassistant.components.hassio.backup import LOCATION_CLOUD_BACKUP, LOCATION_LOCAL from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -53,6 +53,11 @@ TEST_BACKUP = supervisor_backups.Backup( ), date=datetime.fromisoformat("1970-01-01T00:00:00Z"), location=None, + location_attributes={ + LOCATION_LOCAL: supervisor_backups.BackupLocationAttributes( + protected=False, size_bytes=1048576 + ) + }, locations={None}, name="Test", protected=False, @@ -77,6 +82,7 @@ TEST_BACKUP_DETAILS = supervisor_backups.BackupComplete( homeassistant_exclude_database=False, homeassistant="2024.12.0", location=TEST_BACKUP.location, + location_attributes=TEST_BACKUP.location_attributes, locations=TEST_BACKUP.locations, name=TEST_BACKUP.name, protected=TEST_BACKUP.protected, @@ -97,6 +103,11 @@ TEST_BACKUP_2 = supervisor_backups.Backup( ), date=datetime.fromisoformat("1970-01-01T00:00:00Z"), location=None, + location_attributes={ + LOCATION_LOCAL: supervisor_backups.BackupLocationAttributes( + protected=False, size_bytes=1048576 + ) + }, locations={None}, name="Test", protected=False, @@ -121,6 +132,7 @@ TEST_BACKUP_DETAILS_2 = supervisor_backups.BackupComplete( homeassistant_exclude_database=False, homeassistant=None, location=TEST_BACKUP_2.location, + location_attributes=TEST_BACKUP_2.location_attributes, locations=TEST_BACKUP_2.locations, name=TEST_BACKUP_2.name, protected=TEST_BACKUP_2.protected, @@ -141,6 +153,11 @@ TEST_BACKUP_3 = supervisor_backups.Backup( ), date=datetime.fromisoformat("1970-01-01T00:00:00Z"), location="share", + location_attributes={ + LOCATION_LOCAL: supervisor_backups.BackupLocationAttributes( + protected=False, size_bytes=1048576 + ) + }, locations={"share"}, name="Test", protected=False, @@ -165,6 +182,7 @@ TEST_BACKUP_DETAILS_3 = supervisor_backups.BackupComplete( homeassistant_exclude_database=False, homeassistant=None, location=TEST_BACKUP_3.location, + location_attributes=TEST_BACKUP_3.location_attributes, locations=TEST_BACKUP_3.locations, name=TEST_BACKUP_3.name, protected=TEST_BACKUP_3.protected, @@ -186,6 +204,11 @@ TEST_BACKUP_4 = supervisor_backups.Backup( ), date=datetime.fromisoformat("1970-01-01T00:00:00Z"), location=None, + location_attributes={ + LOCATION_LOCAL: supervisor_backups.BackupLocationAttributes( + protected=False, size_bytes=1048576 + ) + }, locations={None}, name="Test", protected=False, @@ -210,6 +233,7 @@ TEST_BACKUP_DETAILS_4 = supervisor_backups.BackupComplete( homeassistant_exclude_database=True, homeassistant="2024.12.0", location=TEST_BACKUP.location, + location_attributes=TEST_BACKUP.location_attributes, locations=TEST_BACKUP.locations, name=TEST_BACKUP.name, protected=TEST_BACKUP.protected, From fe31dc936ce3c8af68966e5d5539cca1f8bbaba4 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Wed, 29 Jan 2025 10:49:49 +0100 Subject: [PATCH 0206/3148] Stop building wheels for 3.12 (#136811) --- .github/workflows/wheels.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index e8dafe88833..41e7b351184 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -131,7 +131,7 @@ jobs: strategy: fail-fast: false matrix: - abi: ["cp312", "cp313"] + abi: ["cp313"] arch: ${{ fromJson(needs.init.outputs.architectures) }} steps: - name: Checkout the repository @@ -180,7 +180,7 @@ jobs: strategy: fail-fast: false matrix: - abi: ["cp312", "cp313"] + abi: ["cp313"] arch: ${{ fromJson(needs.init.outputs.architectures) }} steps: - name: Checkout the repository From 60b6a11d4ec899ecd90f1054d1b8880a634749f9 Mon Sep 17 00:00:00 2001 From: Duco Sebel <74970928+DCSBL@users.noreply.github.com> Date: Wed, 29 Jan 2025 10:51:58 +0100 Subject: [PATCH 0207/3148] Add last restart sensor to HomeWizard (#136763) --- homeassistant/components/homewizard/sensor.py | 28 ++++++- .../components/homewizard/strings.json | 3 + .../homewizard/snapshots/test_sensor.ambr | 83 +++++++++++++++++++ tests/components/homewizard/test_sensor.py | 10 +++ 4 files changed, 122 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/homewizard/sensor.py b/homeassistant/components/homewizard/sensor.py index 02355bc6c5e..f47fcfc7ca7 100644 --- a/homeassistant/components/homewizard/sensor.py +++ b/homeassistant/components/homewizard/sensor.py @@ -4,6 +4,7 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass +from datetime import datetime, timedelta from typing import Final from homewizard_energy.models import CombinedModels, ExternalDevice @@ -33,6 +34,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType +from homeassistant.util.dt import utcnow from . import HomeWizardConfigEntry from .const import DOMAIN @@ -48,7 +50,7 @@ class HomeWizardSensorEntityDescription(SensorEntityDescription): enabled_fn: Callable[[CombinedModels], bool] = lambda x: True has_fn: Callable[[CombinedModels], bool] - value_fn: Callable[[CombinedModels], StateType] + value_fn: Callable[[CombinedModels], StateType | datetime] @dataclass(frozen=True, kw_only=True) @@ -64,6 +66,15 @@ def to_percentage(value: float | None) -> float | None: return value * 100 if value is not None else None +def time_to_datetime(value: int | None) -> datetime | None: + """Convert seconds to datetime when value is not None.""" + return ( + utcnow().replace(microsecond=0) - timedelta(seconds=value) + if value is not None + else None + ) + + SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( HomeWizardSensorEntityDescription( key="smr_version", @@ -611,6 +622,19 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( has_fn=lambda data: data.measurement.cycles is not None, value_fn=lambda data: data.measurement.cycles, ), + HomeWizardSensorEntityDescription( + key="last_restart", + translation_key="last_restart", + device_class=SensorDeviceClass.TIMESTAMP, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + has_fn=( + lambda data: data.system is not None and data.system.uptime_s is not None + ), + value_fn=( + lambda data: time_to_datetime(data.system.uptime_s) if data.system else None + ), + ), ) @@ -697,7 +721,7 @@ class HomeWizardSensorEntity(HomeWizardEntity, SensorEntity): self._attr_entity_registry_enabled_default = False @property - def native_value(self) -> StateType: + def native_value(self) -> StateType | datetime | None: """Return the sensor value.""" return self.entity_description.value_fn(self.coordinator.data) diff --git a/homeassistant/components/homewizard/strings.json b/homeassistant/components/homewizard/strings.json index dbaef8439d9..645c4292ae1 100644 --- a/homeassistant/components/homewizard/strings.json +++ b/homeassistant/components/homewizard/strings.json @@ -137,6 +137,9 @@ }, "state_of_charge_pct": { "name": "State of charge" + }, + "last_restart": { + "name": "Last restart" } }, "switch": { diff --git a/tests/components/homewizard/snapshots/test_sensor.ambr b/tests/components/homewizard/snapshots/test_sensor.ambr index df445a9ddca..622c6d8a852 100644 --- a/tests/components/homewizard/snapshots/test_sensor.ambr +++ b/tests/components/homewizard/snapshots/test_sensor.ambr @@ -432,6 +432,89 @@ 'state': '50.0', }) # --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_last_restart:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '5c:2f:af:ab:cd:ef', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '5c2fafabcdef', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'HomeWizard', + 'model': 'Plug-In Battery', + 'model_id': 'HWE-BAT', + 'name': 'Device', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '1.00', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_last_restart:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.device_last_restart', + '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 restart', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'last_restart', + 'unique_id': 'HWE-P1_5c2fafabcdef_last_restart', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_last_restart:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Device Last restart', + }), + 'context': , + 'entity_id': 'sensor.device_last_restart', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2025-01-28T21:39:04+00:00', + }) +# --- # name: test_sensors[HWE-BAT-entity_ids10][sensor.device_power:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, diff --git a/tests/components/homewizard/test_sensor.py b/tests/components/homewizard/test_sensor.py index d9698db7469..e4498d2d47a 100644 --- a/tests/components/homewizard/test_sensor.py +++ b/tests/components/homewizard/test_sensor.py @@ -19,6 +19,7 @@ pytestmark = [ ] +@pytest.mark.freeze_time("2025-01-28 21:45:00") @pytest.mark.usefixtures("entity_registry_enabled_by_default") @pytest.mark.parametrize( ("device_fixture", "entity_ids"), @@ -301,6 +302,7 @@ pytestmark = [ "sensor.device_frequency", "sensor.device_power", "sensor.device_state_of_charge", + "sensor.device_last_restart", "sensor.device_voltage", ], ), @@ -449,6 +451,7 @@ async def test_sensors( [ "sensor.device_current", "sensor.device_frequency", + "sensor.device_last_restart", "sensor.device_voltage", ], ), @@ -546,6 +549,7 @@ async def test_external_sensors_unreachable( "sensor.device_state_of_charge", "sensor.device_tariff", "sensor.device_total_water_usage", + "sensor.device_last_restart", "sensor.device_voltage_phase_1", "sensor.device_voltage_phase_2", "sensor.device_voltage_phase_3", @@ -595,6 +599,7 @@ async def test_external_sensors_unreachable( "sensor.device_state_of_charge", "sensor.device_tariff", "sensor.device_total_water_usage", + "sensor.device_last_restart", "sensor.device_voltage_phase_1", "sensor.device_voltage_phase_2", "sensor.device_voltage_phase_3", @@ -651,6 +656,7 @@ async def test_external_sensors_unreachable( "sensor.device_smart_meter_model", "sensor.device_state_of_charge", "sensor.device_tariff", + "sensor.device_last_restart", "sensor.device_voltage_phase_1", "sensor.device_voltage_phase_2", "sensor.device_voltage_phase_3", @@ -701,6 +707,7 @@ async def test_external_sensors_unreachable( "sensor.device_state_of_charge", "sensor.device_tariff", "sensor.device_total_water_usage", + "sensor.device_last_restart", "sensor.device_voltage_phase_1", "sensor.device_voltage_phase_2", "sensor.device_voltage_phase_3", @@ -739,6 +746,7 @@ async def test_external_sensors_unreachable( "sensor.device_state_of_charge", "sensor.device_tariff", "sensor.device_total_water_usage", + "sensor.device_last_restart", "sensor.device_voltage_phase_1", "sensor.device_voltage_phase_2", "sensor.device_voltage_phase_3", @@ -790,6 +798,7 @@ async def test_external_sensors_unreachable( "sensor.device_state_of_charge", "sensor.device_tariff", "sensor.device_total_water_usage", + "sensor.device_last_restart", "sensor.device_voltage_phase_1", "sensor.device_voltage_phase_2", "sensor.device_voltage_phase_3", @@ -828,6 +837,7 @@ async def test_external_sensors_unreachable( "sensor.device_state_of_charge", "sensor.device_tariff", "sensor.device_total_water_usage", + "sensor.device_last_restart", "sensor.device_voltage_phase_1", "sensor.device_voltage_phase_2", "sensor.device_voltage_phase_3", From 6b4ec3f3f4496a4d34ff237c1f0358819d5f581f Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Wed, 29 Jan 2025 09:55:19 +0000 Subject: [PATCH 0208/3148] Use translations for fan_speed in tplink vacuum entity (#136718) --- homeassistant/components/tplink/entity.py | 7 +++++- homeassistant/components/tplink/strings.json | 15 ++++++++++++ homeassistant/components/tplink/vacuum.py | 24 +++++++++++-------- tests/components/tplink/__init__.py | 2 +- .../tplink/snapshots/test_vacuum.ambr | 12 +++++----- tests/components/tplink/test_vacuum.py | 14 ++++++++--- 6 files changed, 53 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/tplink/entity.py b/homeassistant/components/tplink/entity.py index 6c21ab63285..15c07655e69 100644 --- a/homeassistant/components/tplink/entity.py +++ b/homeassistant/components/tplink/entity.py @@ -110,6 +110,9 @@ class TPLinkModuleEntityDescription(TPLinkEntityDescription): unique_id_fn: Callable[[Device, TPLinkModuleEntityDescription], str] = ( lambda device, desc: f"{legacy_device_id(device)}-{desc.key}" ) + entity_name_fn: ( + Callable[[Device, TPLinkModuleEntityDescription], str | None] | None + ) = None def async_refresh_after[_T: CoordinatedTPLinkEntity, **_P]( @@ -550,7 +553,9 @@ class CoordinatedTPLinkModuleEntity(CoordinatedTPLinkEntity, ABC): # the description should have a translation key. # HA logic is to name entities based on the following logic: # _attr_name > translation.name > description.name - if not description.translation_key: + if entity_name_fn := description.entity_name_fn: + self._attr_name = entity_name_fn(device, description) + elif not description.translation_key: if parent is None or parent.device_type is Device.Type.Hub: self._attr_name = None else: diff --git a/homeassistant/components/tplink/strings.json b/homeassistant/components/tplink/strings.json index fe661fa2529..fe1560b75d5 100644 --- a/homeassistant/components/tplink/strings.json +++ b/homeassistant/components/tplink/strings.json @@ -283,6 +283,21 @@ "clean_count": { "name": "Clean count" } + }, + "vacuum": { + "vacuum": { + "state_attributes": { + "fan_speed": { + "state": { + "quiet": "Quiet", + "standard": "Standard", + "turbo": "Turbo", + "max": "Max", + "ultra": "Ultra" + } + } + } + } } }, "device": { diff --git a/homeassistant/components/tplink/vacuum.py b/homeassistant/components/tplink/vacuum.py index 666584f4980..c62cd1d27c8 100644 --- a/homeassistant/components/tplink/vacuum.py +++ b/homeassistant/components/tplink/vacuum.py @@ -3,9 +3,9 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Any, cast +from typing import Any -from kasa import Device, Feature, Module +from kasa import Device, Module from kasa.smart.modules.clean import Clean, Status from homeassistant.components.vacuum import ( @@ -52,7 +52,10 @@ class TPLinkVacuumEntityDescription( VACUUM_DESCRIPTIONS: tuple[TPLinkVacuumEntityDescription, ...] = ( TPLinkVacuumEntityDescription( - key="vacuum", exists_fn=lambda dev, _: Module.Clean in dev.modules + key="vacuum", + translation_key="vacuum", + exists_fn=lambda dev, _: Module.Clean in dev.modules, + entity_name_fn=lambda _, __: None, ), ) @@ -97,7 +100,6 @@ class TPLinkVacuumEntity(CoordinatedTPLinkModuleEntity, StateVacuumEntity): | VacuumEntityFeature.START | VacuumEntityFeature.PAUSE | VacuumEntityFeature.RETURN_HOME - | VacuumEntityFeature.FAN_SPEED ) entity_description: TPLinkVacuumEntityDescription @@ -117,8 +119,11 @@ class TPLinkVacuumEntity(CoordinatedTPLinkModuleEntity, StateVacuumEntity): self._speaker_module = speaker self._attr_supported_features |= VacuumEntityFeature.LOCATE - # Needs to be initialized empty, as vacuumentity's capability_attributes accesses it - self._attr_fan_speed_list: list[str] = [] + if ( + fanspeed_feat := self._vacuum_module.get_feature("fan_speed_preset") + ) and fanspeed_feat.choices: + self._attr_supported_features |= VacuumEntityFeature.FAN_SPEED + self._attr_fan_speed_list = [c.lower() for c in fanspeed_feat.choices] @async_refresh_after async def async_start(self) -> None: @@ -138,7 +143,7 @@ class TPLinkVacuumEntity(CoordinatedTPLinkModuleEntity, StateVacuumEntity): @async_refresh_after async def async_set_fan_speed(self, fan_speed: str, **kwargs: Any) -> None: """Set fan speed.""" - await self._vacuum_module.set_fan_speed_preset(fan_speed) + await self._vacuum_module.set_fan_speed_preset(fan_speed.capitalize()) async def async_locate(self, **kwargs: Any) -> None: """Locate the device.""" @@ -152,7 +157,6 @@ class TPLinkVacuumEntity(CoordinatedTPLinkModuleEntity, StateVacuumEntity): def _async_update_attrs(self) -> bool: """Update the entity's attributes.""" self._attr_activity = STATUS_TO_ACTIVITY.get(self._vacuum_module.status) - fanspeeds = cast(Feature, self._vacuum_module.get_feature("fan_speed_preset")) - self._attr_fan_speed_list = cast(list[str], fanspeeds.choices) - self._attr_fan_speed = self._vacuum_module.fan_speed_preset + if self._vacuum_module.has_feature("fan_speed_preset"): + self._attr_fan_speed = self._vacuum_module.fan_speed_preset.lower() return True diff --git a/tests/components/tplink/__init__.py b/tests/components/tplink/__init__.py index 028215dc157..4737d7432df 100644 --- a/tests/components/tplink/__init__.py +++ b/tests/components/tplink/__init__.py @@ -106,7 +106,7 @@ async def snapshot_platform( if entity_entry.translation_key: key = f"component.{DOMAIN}.entity.{entity_entry.domain}.{entity_entry.translation_key}.name" single_device_class_translation = False - if key not in translations and entity_entry.original_device_class: + if key not in translations: # No name translation if entity_entry.original_device_class not in unique_device_classes: single_device_class_translation = True unique_device_classes.append(entity_entry.original_device_class) diff --git a/tests/components/tplink/snapshots/test_vacuum.ambr b/tests/components/tplink/snapshots/test_vacuum.ambr index a28a7d80ab4..c0a48327e26 100644 --- a/tests/components/tplink/snapshots/test_vacuum.ambr +++ b/tests/components/tplink/snapshots/test_vacuum.ambr @@ -42,8 +42,8 @@ 'area_id': None, 'capabilities': dict({ 'fan_speed_list': list([ - 'Quiet', - 'Max', + 'quiet', + 'max', ]), }), 'config_entry_id': , @@ -68,7 +68,7 @@ 'platform': 'tplink', 'previous_unique_id': None, 'supported_features': , - 'translation_key': None, + 'translation_key': 'vacuum', 'unique_id': '123456789ABCDEFGH-vacuum', 'unit_of_measurement': None, }) @@ -78,10 +78,10 @@ 'attributes': ReadOnlyDict({ 'battery_icon': 'mdi:battery-charging-100', 'battery_level': 100, - 'fan_speed': 'Max', + 'fan_speed': 'max', 'fan_speed_list': list([ - 'Quiet', - 'Max', + 'quiet', + 'max', ]), 'friendly_name': 'my_vacuum', 'supported_features': , diff --git a/tests/components/tplink/test_vacuum.py b/tests/components/tplink/test_vacuum.py index aac7c4f7fc8..55bb8c0b504 100644 --- a/tests/components/tplink/test_vacuum.py +++ b/tests/components/tplink/test_vacuum.py @@ -19,7 +19,11 @@ from homeassistant.components.vacuum import ( ) from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers import ( + device_registry as dr, + entity_registry as er, + translation, +) from . import DEVICE_ID, _mocked_device, setup_platform_for_device, snapshot_platform @@ -59,8 +63,12 @@ async def test_vacuum( state = hass.states.get(ENTITY_ID) assert state.state == VacuumActivity.DOCKED - assert state.attributes[ATTR_FAN_SPEED] == "Max" + assert state.attributes[ATTR_FAN_SPEED] == "max" assert state.attributes[ATTR_BATTERY_LEVEL] == 100 + result = translation.async_translate_state( + hass, "max", "vacuum", "tplink", "vacuum.state_attributes.fan_speed", None + ) + assert result == "Max" async def test_states( @@ -90,7 +98,7 @@ async def test_states( SERVICE_SET_FAN_SPEED, Module.Clean, "set_fan_speed_preset", - {ATTR_FAN_SPEED: "Quiet"}, + {ATTR_FAN_SPEED: "quiet"}, ), (SERVICE_LOCATE, Module.Speaker, "locate", {}), ], From c312796aae7d8de860bad42018dbf99d67f37170 Mon Sep 17 00:00:00 2001 From: Iskra kranj <162285659+iskrakranj@users.noreply.github.com> Date: Wed, 29 Jan 2025 10:57:22 +0100 Subject: [PATCH 0209/3148] Bump pyiskra to 0.1.15 (#136810) --- homeassistant/components/iskra/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/iskra/manifest.json b/homeassistant/components/iskra/manifest.json index 94f20b4d93c..caa176ab6b6 100644 --- a/homeassistant/components/iskra/manifest.json +++ b/homeassistant/components/iskra/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["pyiskra"], - "requirements": ["pyiskra==0.1.14"] + "requirements": ["pyiskra==0.1.15"] } diff --git a/requirements_all.txt b/requirements_all.txt index c8d7ccbe50f..0b36e1fb84e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2023,7 +2023,7 @@ pyiqvia==2022.04.0 pyirishrail==0.0.2 # homeassistant.components.iskra -pyiskra==0.1.14 +pyiskra==0.1.15 # homeassistant.components.iss pyiss==1.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1a5c9ba91b9..86ed63fe404 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1646,7 +1646,7 @@ pyipp==0.17.0 pyiqvia==2022.04.0 # homeassistant.components.iskra -pyiskra==0.1.14 +pyiskra==0.1.15 # homeassistant.components.iss pyiss==1.0.1 From e27a980742e949036b401d69cab1fbb19ed9013a Mon Sep 17 00:00:00 2001 From: Andrew Onyshchuk Date: Wed, 29 Jan 2025 01:57:49 -0800 Subject: [PATCH 0210/3148] vesync: report current humidity (#136799) --- homeassistant/components/vesync/humidifier.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/homeassistant/components/vesync/humidifier.py b/homeassistant/components/vesync/humidifier.py index 8557c7a8866..86e0d6b5d87 100644 --- a/homeassistant/components/vesync/humidifier.py +++ b/homeassistant/components/vesync/humidifier.py @@ -129,6 +129,11 @@ class VeSyncHumidifierHA(VeSyncBaseEntity, HumidifierEntity): """Return the available mist modes.""" return self._available_modes + @property + def current_humidity(self) -> int: + """Return the current humidity.""" + return self.device.humidity + @property def target_humidity(self) -> int: """Return the humidity we try to reach.""" From ce432555f040cedc9b79bfc8650b5e56ae2d2f7f Mon Sep 17 00:00:00 2001 From: cdnninja Date: Wed, 29 Jan 2025 02:59:34 -0700 Subject: [PATCH 0211/3148] Add binary sensor platform to VeSync (#134221) Co-authored-by: Joost Lekkerkerker --- homeassistant/components/vesync/__init__.py | 1 + .../components/vesync/binary_sensor.py | 106 ++++++++++++++++++ homeassistant/components/vesync/common.py | 22 ++++ homeassistant/components/vesync/strings.json | 8 ++ tests/components/vesync/test_diagnostics.py | 3 + tests/components/vesync/test_init.py | 2 + 6 files changed, 142 insertions(+) create mode 100644 homeassistant/components/vesync/binary_sensor.py diff --git a/homeassistant/components/vesync/__init__.py b/homeassistant/components/vesync/__init__.py index 240a793f518..27e626faeac 100644 --- a/homeassistant/components/vesync/__init__.py +++ b/homeassistant/components/vesync/__init__.py @@ -21,6 +21,7 @@ from .const import ( from .coordinator import VeSyncDataCoordinator PLATFORMS = [ + Platform.BINARY_SENSOR, Platform.FAN, Platform.HUMIDIFIER, Platform.LIGHT, diff --git a/homeassistant/components/vesync/binary_sensor.py b/homeassistant/components/vesync/binary_sensor.py new file mode 100644 index 00000000000..dd1b6398c06 --- /dev/null +++ b/homeassistant/components/vesync/binary_sensor.py @@ -0,0 +1,106 @@ +"""Binary Sensor for VeSync.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +import logging + +from pyvesync.vesyncbasedevice import VeSyncBaseDevice + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .common import rgetattr +from .const import DOMAIN, VS_COORDINATOR, VS_DEVICES, VS_DISCOVERY +from .coordinator import VeSyncDataCoordinator +from .entity import VeSyncBaseEntity + +_LOGGER = logging.getLogger(__name__) + + +@dataclass(frozen=True, kw_only=True) +class VeSyncBinarySensorEntityDescription(BinarySensorEntityDescription): + """A class that describes custom binary sensor entities.""" + + is_on: Callable[[VeSyncBaseDevice], bool] + + +SENSOR_DESCRIPTIONS: tuple[VeSyncBinarySensorEntityDescription, ...] = ( + VeSyncBinarySensorEntityDescription( + key="water_lacks", + translation_key="water_lacks", + is_on=lambda device: device.water_lacks, + device_class=BinarySensorDeviceClass.PROBLEM, + ), + VeSyncBinarySensorEntityDescription( + key="details.water_tank_lifted", + translation_key="water_tank_lifted", + is_on=lambda device: device.details["water_tank_lifted"], + device_class=BinarySensorDeviceClass.PROBLEM, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up binary_sensor platform.""" + + coordinator = hass.data[DOMAIN][VS_COORDINATOR] + + @callback + def discover(devices): + """Add new devices to platform.""" + _setup_entities(devices, async_add_entities) + + config_entry.async_on_unload( + async_dispatcher_connect(hass, VS_DISCOVERY.format(VS_DEVICES), discover) + ) + + _setup_entities(hass.data[DOMAIN][VS_DEVICES], async_add_entities, coordinator) + + +@callback +def _setup_entities(devices, async_add_entities, coordinator): + """Add entity.""" + async_add_entities( + ( + VeSyncBinarySensor(dev, description, coordinator) + for dev in devices + for description in SENSOR_DESCRIPTIONS + if rgetattr(dev, description.key) is not None + ), + ) + + +class VeSyncBinarySensor(BinarySensorEntity, VeSyncBaseEntity): + """Vesync binary sensor class.""" + + entity_description: VeSyncBinarySensorEntityDescription + + def __init__( + self, + device: VeSyncBaseDevice, + description: VeSyncBinarySensorEntityDescription, + coordinator: VeSyncDataCoordinator, + ) -> None: + """Initialize the sensor.""" + super().__init__(device, coordinator) + self.entity_description = description + self._attr_unique_id = f"{super().unique_id}-{description.key}" + + @property + def is_on(self) -> bool: + """Return true if the binary sensor is on.""" + _LOGGER.debug(rgetattr(self.device, self.entity_description.key)) + return self.entity_description.is_on(self.device) diff --git a/homeassistant/components/vesync/common.py b/homeassistant/components/vesync/common.py index c51b6a913d3..e2f4e1db2e4 100644 --- a/homeassistant/components/vesync/common.py +++ b/homeassistant/components/vesync/common.py @@ -12,6 +12,28 @@ from .const import VeSyncHumidifierDevice _LOGGER = logging.getLogger(__name__) +def rgetattr(obj: object, attr: str): + """Return a string in the form word.1.2.3 and return the item as 3. Note that this last value could be in a dict as well.""" + _this_func = rgetattr + sp = attr.split(".", 1) + if len(sp) == 1: + left, right = sp[0], "" + else: + left, right = sp + + if isinstance(obj, dict): + obj = obj.get(left) + elif hasattr(obj, left): + obj = getattr(obj, left) + else: + return None + + if right: + obj = _this_func(obj, right) + + return obj + + async def async_generate_device_list( hass: HomeAssistant, manager: VeSync ) -> list[VeSyncBaseDevice]: diff --git a/homeassistant/components/vesync/strings.json b/homeassistant/components/vesync/strings.json index 87a8ea8746e..3eb2a0c3fd5 100644 --- a/homeassistant/components/vesync/strings.json +++ b/homeassistant/components/vesync/strings.json @@ -43,6 +43,14 @@ "name": "Current voltage" } }, + "binary_sensor": { + "water_lacks": { + "name": "Low water" + }, + "water_tank_lifted": { + "name": "Water tank lifted" + } + }, "number": { "mist_level": { "name": "Mist level" diff --git a/tests/components/vesync/test_diagnostics.py b/tests/components/vesync/test_diagnostics.py index b948053c3a0..25aa5337281 100644 --- a/tests/components/vesync/test_diagnostics.py +++ b/tests/components/vesync/test_diagnostics.py @@ -101,6 +101,9 @@ async def test_async_get_device_diagnostics__single_fan( "home_assistant.entities.2.state.last_changed": (str,), "home_assistant.entities.2.state.last_reported": (str,), "home_assistant.entities.2.state.last_updated": (str,), + "home_assistant.entities.3.state.last_changed": (str,), + "home_assistant.entities.3.state.last_reported": (str,), + "home_assistant.entities.3.state.last_updated": (str,), } ) ) diff --git a/tests/components/vesync/test_init.py b/tests/components/vesync/test_init.py index 7873b911f6f..883e850fc62 100644 --- a/tests/components/vesync/test_init.py +++ b/tests/components/vesync/test_init.py @@ -48,6 +48,7 @@ async def test_async_setup_entry__no_devices( assert setups_mock.call_count == 1 assert setups_mock.call_args.args[0] == config_entry assert setups_mock.call_args.args[1] == [ + Platform.BINARY_SENSOR, Platform.FAN, Platform.HUMIDIFIER, Platform.LIGHT, @@ -78,6 +79,7 @@ async def test_async_setup_entry__loads_fans( assert setups_mock.call_count == 1 assert setups_mock.call_args.args[0] == config_entry assert setups_mock.call_args.args[1] == [ + Platform.BINARY_SENSOR, Platform.FAN, Platform.HUMIDIFIER, Platform.LIGHT, From 04d1d80917f48665af929a377f2d5b644ea9e2e8 Mon Sep 17 00:00:00 2001 From: Cyrill Raccaud Date: Wed, 29 Jan 2025 11:06:39 +0100 Subject: [PATCH 0212/3148] Add diagnostics for Cookidoo integration (#136770) Co-authored-by: Joost Lekkerkerker --- .../components/cookidoo/coordinator.py | 3 ++ .../components/cookidoo/diagnostics.py | 26 +++++++++++ .../components/cookidoo/quality_scale.yaml | 2 +- tests/components/cookidoo/conftest.py | 5 ++- .../cookidoo/fixtures/user_info.json | 7 +++ .../cookidoo/snapshots/test_diagnostics.ambr | 43 +++++++++++++++++++ tests/components/cookidoo/test_diagnostics.py | 29 +++++++++++++ 7 files changed, 113 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/cookidoo/diagnostics.py create mode 100644 tests/components/cookidoo/fixtures/user_info.json create mode 100644 tests/components/cookidoo/snapshots/test_diagnostics.ambr create mode 100644 tests/components/cookidoo/test_diagnostics.py diff --git a/homeassistant/components/cookidoo/coordinator.py b/homeassistant/components/cookidoo/coordinator.py index f99f58c2dd6..2ce61306afe 100644 --- a/homeassistant/components/cookidoo/coordinator.py +++ b/homeassistant/components/cookidoo/coordinator.py @@ -14,6 +14,7 @@ from cookidoo_api import ( CookidooIngredientItem, CookidooRequestException, CookidooSubscription, + CookidooUserInfo, ) from homeassistant.config_entries import ConfigEntry @@ -42,6 +43,7 @@ class CookidooDataUpdateCoordinator(DataUpdateCoordinator[CookidooData]): """A Cookidoo Data Update Coordinator.""" config_entry: CookidooConfigEntry + user: CookidooUserInfo def __init__( self, hass: HomeAssistant, cookidoo: Cookidoo, entry: CookidooConfigEntry @@ -59,6 +61,7 @@ class CookidooDataUpdateCoordinator(DataUpdateCoordinator[CookidooData]): async def _async_setup(self) -> None: try: await self.cookidoo.login() + self.user = await self.cookidoo.get_user_info() except CookidooRequestException as e: raise UpdateFailed( translation_domain=DOMAIN, diff --git a/homeassistant/components/cookidoo/diagnostics.py b/homeassistant/components/cookidoo/diagnostics.py new file mode 100644 index 00000000000..f981317df19 --- /dev/null +++ b/homeassistant/components/cookidoo/diagnostics.py @@ -0,0 +1,26 @@ +"""Diagnostics for the Cookidoo integration.""" + +from dataclasses import asdict +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.const import CONF_PASSWORD +from homeassistant.core import HomeAssistant + +from .coordinator import CookidooConfigEntry + +TO_REDACT = [ + CONF_PASSWORD, +] + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: CookidooConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + + return { + "entry_data": async_redact_data(entry.data, TO_REDACT), + "data": asdict(entry.runtime_data.data), + "user": asdict(entry.runtime_data.user), + } diff --git a/homeassistant/components/cookidoo/quality_scale.yaml b/homeassistant/components/cookidoo/quality_scale.yaml index 95a35829079..209f2ce5686 100644 --- a/homeassistant/components/cookidoo/quality_scale.yaml +++ b/homeassistant/components/cookidoo/quality_scale.yaml @@ -63,7 +63,7 @@ rules: stale-devices: status: exempt comment: No stale entities possible - diagnostics: todo + diagnostics: done exception-translations: done icon-translations: done reconfiguration-flow: done diff --git a/tests/components/cookidoo/conftest.py b/tests/components/cookidoo/conftest.py index 096b2abf958..7d84e7ac83e 100644 --- a/tests/components/cookidoo/conftest.py +++ b/tests/components/cookidoo/conftest.py @@ -9,6 +9,7 @@ from cookidoo_api import ( CookidooAuthResponse, CookidooIngredientItem, CookidooSubscription, + CookidooUserInfo, ) import pytest @@ -58,7 +59,9 @@ def mock_cookidoo_client() -> Generator[AsyncMock]: client.get_active_subscription.return_value = CookidooSubscription( **load_json_object_fixture("subscriptions.json", DOMAIN)["data"] ) - + client.get_user_info.return_value = CookidooUserInfo( + **load_json_object_fixture("user_info.json", DOMAIN)["data"] + ) client.login.return_value = CookidooAuthResponse( **load_json_object_fixture("login.json", DOMAIN) ) diff --git a/tests/components/cookidoo/fixtures/user_info.json b/tests/components/cookidoo/fixtures/user_info.json new file mode 100644 index 00000000000..1c99ae84823 --- /dev/null +++ b/tests/components/cookidoo/fixtures/user_info.json @@ -0,0 +1,7 @@ +{ + "data": { + "username": "username_1234", + "description": null, + "picture": null + } +} diff --git a/tests/components/cookidoo/snapshots/test_diagnostics.ambr b/tests/components/cookidoo/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..3dc799c1108 --- /dev/null +++ b/tests/components/cookidoo/snapshots/test_diagnostics.ambr @@ -0,0 +1,43 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'data': dict({ + 'additional_items': list([ + dict({ + 'id': 'unique_id_tomaten', + 'is_owned': False, + 'name': 'Tomaten', + }), + ]), + 'ingredient_items': list([ + dict({ + 'description': '200 g', + 'id': 'unique_id_mehl', + 'is_owned': False, + 'name': 'Mehl', + }), + ]), + 'subscription': dict({ + 'active': True, + 'expires': '2025-12-16T23:59:00Z', + 'extended_type': 'REGULAR', + 'start_date': '2024-12-16T00:00:00Z', + 'status': 'ACTIVE', + 'subscription_level': 'FULL', + 'subscription_source': 'COMMERCE', + 'type': 'REGULAR', + }), + }), + 'entry_data': dict({ + 'country': 'CH', + 'email': 'test-email', + 'language': 'de-CH', + 'password': '**REDACTED**', + }), + 'user': dict({ + 'description': None, + 'picture': None, + 'username': 'username_1234', + }), + }) +# --- diff --git a/tests/components/cookidoo/test_diagnostics.py b/tests/components/cookidoo/test_diagnostics.py new file mode 100644 index 00000000000..c253e1f6e09 --- /dev/null +++ b/tests/components/cookidoo/test_diagnostics.py @@ -0,0 +1,29 @@ +"""Tests for the diagnostics data provided by the Cookidoo integration.""" + +from unittest.mock import AsyncMock + +from syrupy import SnapshotAssertion + +from homeassistant.core import HomeAssistant + +from . import setup_integration + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def test_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_cookidoo_client: AsyncMock, + cookidoo_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics.""" + await setup_integration(hass, cookidoo_config_entry) + + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, cookidoo_config_entry) + == snapshot + ) From b73203fdf6b919c938b293e89dc407eb2788c9d3 Mon Sep 17 00:00:00 2001 From: David Knowles Date: Wed, 29 Jan 2025 05:06:59 -0500 Subject: [PATCH 0213/3148] Use the new hybrid Hydrawise client (#136522) Co-authored-by: Joost Lekkerkerker --- .../components/hydrawise/__init__.py | 19 +++-- .../components/hydrawise/config_flow.py | 42 +++++++---- .../components/hydrawise/coordinator.py | 8 +-- .../components/hydrawise/strings.json | 12 +++- homeassistant/components/hydrawise/switch.py | 6 +- tests/components/hydrawise/conftest.py | 7 +- .../components/hydrawise/test_config_flow.py | 72 ++++++++++++++----- 7 files changed, 118 insertions(+), 48 deletions(-) diff --git a/homeassistant/components/hydrawise/__init__.py b/homeassistant/components/hydrawise/__init__.py index ea5a5801e69..ee5a8a66610 100644 --- a/homeassistant/components/hydrawise/__init__.py +++ b/homeassistant/components/hydrawise/__init__.py @@ -1,9 +1,9 @@ """Support for Hydrawise cloud.""" -from pydrawise import auth, client +from pydrawise import auth, hybrid from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform +from homeassistant.const import CONF_API_KEY, CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed @@ -21,16 +21,21 @@ PLATFORMS: list[Platform] = [ Platform.VALVE, ] +_REQUIRED_AUTH_KEYS = (CONF_USERNAME, CONF_PASSWORD, CONF_API_KEY) + async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Set up Hydrawise from a config entry.""" - if CONF_USERNAME not in config_entry.data or CONF_PASSWORD not in config_entry.data: - # The GraphQL API requires username and password to authenticate. If either is - # missing, reauth is required. + if any(k not in config_entry.data for k in _REQUIRED_AUTH_KEYS): + # If we are missing any required authentication keys, trigger a reauth flow. raise ConfigEntryAuthFailed - hydrawise = client.Hydrawise( - auth.Auth(config_entry.data[CONF_USERNAME], config_entry.data[CONF_PASSWORD]), + hydrawise = hybrid.HybridClient( + auth.HybridAuth( + config_entry.data[CONF_USERNAME], + config_entry.data[CONF_PASSWORD], + config_entry.data[CONF_API_KEY], + ), app_id=APP_ID, ) diff --git a/homeassistant/components/hydrawise/config_flow.py b/homeassistant/components/hydrawise/config_flow.py index ed21e96cd0b..3a61908ee2d 100644 --- a/homeassistant/components/hydrawise/config_flow.py +++ b/homeassistant/components/hydrawise/config_flow.py @@ -6,25 +6,32 @@ from collections.abc import Mapping from typing import Any from aiohttp import ClientError -from pydrawise import auth as pydrawise_auth, client +from pydrawise import auth as pydrawise_auth, hybrid from pydrawise.exceptions import NotAuthorizedError import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import CONF_API_KEY, CONF_PASSWORD, CONF_USERNAME from .const import APP_ID, DOMAIN, LOGGER STEP_USER_DATA_SCHEMA = vol.Schema( - {vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str} + { + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + vol.Required(CONF_API_KEY): str, + } +) +STEP_REAUTH_DATA_SCHEMA = vol.Schema( + {vol.Required(CONF_PASSWORD): str, vol.Required(CONF_API_KEY): str} ) -STEP_REAUTH_DATA_SCHEMA = vol.Schema({vol.Required(CONF_PASSWORD): str}) class HydrawiseConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Hydrawise.""" VERSION = 1 + MINOR_VERSION = 2 async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -34,14 +41,19 @@ class HydrawiseConfigFlow(ConfigFlow, domain=DOMAIN): return self._show_user_form({}) username = user_input[CONF_USERNAME] password = user_input[CONF_PASSWORD] - unique_id, errors = await _authenticate(username, password) + api_key = user_input[CONF_API_KEY] + unique_id, errors = await _authenticate(username, password, api_key) if errors: return self._show_user_form(errors) await self.async_set_unique_id(unique_id) self._abort_if_unique_id_configured() return self.async_create_entry( title=username, - data={CONF_USERNAME: username, CONF_PASSWORD: password}, + data={ + CONF_USERNAME: username, + CONF_PASSWORD: password, + CONF_API_KEY: api_key, + }, ) def _show_user_form(self, errors: dict[str, str]) -> ConfigFlowResult: @@ -65,14 +77,20 @@ class HydrawiseConfigFlow(ConfigFlow, domain=DOMAIN): reauth_entry = self._get_reauth_entry() username = reauth_entry.data[CONF_USERNAME] password = user_input[CONF_PASSWORD] - user_id, errors = await _authenticate(username, password) + api_key = user_input[CONF_API_KEY] + user_id, errors = await _authenticate(username, password, api_key) if user_id is None: return self._show_reauth_form(errors) await self.async_set_unique_id(user_id) self._abort_if_unique_id_mismatch(reason="wrong_account") return self.async_update_reload_and_abort( - reauth_entry, data={CONF_USERNAME: username, CONF_PASSWORD: password} + reauth_entry, + data={ + CONF_USERNAME: username, + CONF_PASSWORD: password, + CONF_API_KEY: api_key, + }, ) def _show_reauth_form(self, errors: dict[str, str]) -> ConfigFlowResult: @@ -82,14 +100,14 @@ class HydrawiseConfigFlow(ConfigFlow, domain=DOMAIN): async def _authenticate( - username: str, password: str + username: str, password: str, api_key: str ) -> tuple[str | None, dict[str, str]]: """Authenticate with the Hydrawise API.""" unique_id = None errors: dict[str, str] = {} - auth = pydrawise_auth.Auth(username, password) + auth = pydrawise_auth.HybridAuth(username, password, api_key) try: - await auth.token() + await auth.check() except NotAuthorizedError: errors["base"] = "invalid_auth" except TimeoutError: @@ -99,7 +117,7 @@ async def _authenticate( return unique_id, errors try: - api = client.Hydrawise(auth, app_id=APP_ID) + api = hybrid.HybridClient(auth, app_id=APP_ID) # Don't fetch zones because we don't need them yet. user = await api.get_user(fetch_zones=False) except TimeoutError: diff --git a/homeassistant/components/hydrawise/coordinator.py b/homeassistant/components/hydrawise/coordinator.py index e82a4ec1588..4721a9fb154 100644 --- a/homeassistant/components/hydrawise/coordinator.py +++ b/homeassistant/components/hydrawise/coordinator.py @@ -4,7 +4,7 @@ from __future__ import annotations from dataclasses import dataclass, field -from pydrawise import Hydrawise +from pydrawise import HydrawiseBase from pydrawise.schema import Controller, ControllerWaterUseSummary, Sensor, User, Zone from homeassistant.core import HomeAssistant @@ -38,7 +38,7 @@ class HydrawiseUpdateCoordinators: class HydrawiseDataUpdateCoordinator(DataUpdateCoordinator[HydrawiseData]): """Base class for Hydrawise Data Update Coordinators.""" - api: Hydrawise + api: HydrawiseBase class HydrawiseMainDataUpdateCoordinator(HydrawiseDataUpdateCoordinator): @@ -49,7 +49,7 @@ class HydrawiseMainDataUpdateCoordinator(HydrawiseDataUpdateCoordinator): integration are updated in a timely manner. """ - def __init__(self, hass: HomeAssistant, api: Hydrawise) -> None: + def __init__(self, hass: HomeAssistant, api: HydrawiseBase) -> None: """Initialize HydrawiseDataUpdateCoordinator.""" super().__init__(hass, LOGGER, name=DOMAIN, update_interval=MAIN_SCAN_INTERVAL) self.api = api @@ -82,7 +82,7 @@ class HydrawiseWaterUseDataUpdateCoordinator(HydrawiseDataUpdateCoordinator): def __init__( self, hass: HomeAssistant, - api: Hydrawise, + api: HydrawiseBase, main_coordinator: HydrawiseMainDataUpdateCoordinator, ) -> None: """Initialize HydrawiseWaterUseDataUpdateCoordinator.""" diff --git a/homeassistant/components/hydrawise/strings.json b/homeassistant/components/hydrawise/strings.json index 74c63cbe758..47543aa2f8f 100644 --- a/homeassistant/components/hydrawise/strings.json +++ b/homeassistant/components/hydrawise/strings.json @@ -6,14 +6,22 @@ "description": "Please provide the username and password for your Hydrawise cloud account:", "data": { "username": "[%key:common::config_flow::data::username%]", - "password": "[%key:common::config_flow::data::password%]" + "password": "[%key:common::config_flow::data::password%]", + "api_key": "[%key:common::config_flow::data::api_key%]" + }, + "data_description": { + "api_key": "You can generate an API Key in the 'Account Details' section of the Hydrawise app" } }, "reauth_confirm": { "title": "[%key:common::config_flow::title::reauth%]", "description": "The Hydrawise integration needs to re-authenticate your account", "data": { - "password": "[%key:common::config_flow::data::password%]" + "password": "[%key:common::config_flow::data::password%]", + "api_key": "[%key:common::config_flow::data::api_key%]" + }, + "data_description": { + "api_key": "[%key:component::hydrawise::config::step::user::data_description::api_key%]" } } }, diff --git a/homeassistant/components/hydrawise/switch.py b/homeassistant/components/hydrawise/switch.py index 1addaf1ec92..62cd81a0481 100644 --- a/homeassistant/components/hydrawise/switch.py +++ b/homeassistant/components/hydrawise/switch.py @@ -7,7 +7,7 @@ from dataclasses import dataclass from datetime import timedelta from typing import Any -from pydrawise import Hydrawise, Zone +from pydrawise import HydrawiseBase, Zone from homeassistant.components.switch import ( SwitchDeviceClass, @@ -28,8 +28,8 @@ from .entity import HydrawiseEntity class HydrawiseSwitchEntityDescription(SwitchEntityDescription): """Describes Hydrawise binary sensor.""" - turn_on_fn: Callable[[Hydrawise, Zone], Coroutine[Any, Any, None]] - turn_off_fn: Callable[[Hydrawise, Zone], Coroutine[Any, Any, None]] + turn_on_fn: Callable[[HydrawiseBase, Zone], Coroutine[Any, Any, None]] + turn_off_fn: Callable[[HydrawiseBase, Zone], Coroutine[Any, Any, None]] value_fn: Callable[[Zone], bool] diff --git a/tests/components/hydrawise/conftest.py b/tests/components/hydrawise/conftest.py index 2de7fb1da9a..ad3a97fa6e0 100644 --- a/tests/components/hydrawise/conftest.py +++ b/tests/components/hydrawise/conftest.py @@ -63,7 +63,7 @@ def mock_pydrawise( controller_water_use_summary: ControllerWaterUseSummary, ) -> Generator[AsyncMock]: """Mock Hydrawise.""" - with patch("pydrawise.client.Hydrawise", autospec=True) as mock_pydrawise: + with patch("pydrawise.hybrid.HybridClient", autospec=True) as mock_pydrawise: user.controllers = [controller] controller.sensors = sensors mock_pydrawise.return_value.get_user.return_value = user @@ -76,8 +76,8 @@ def mock_pydrawise( @pytest.fixture def mock_auth() -> Generator[AsyncMock]: - """Mock pydrawise Auth.""" - with patch("pydrawise.auth.Auth", autospec=True) as mock_auth: + """Mock pydrawise HybridAuth.""" + with patch("pydrawise.auth.HybridAuth", autospec=True) as mock_auth: yield mock_auth.return_value @@ -215,6 +215,7 @@ def mock_config_entry() -> MockConfigEntry: data={ CONF_USERNAME: "asfd@asdf.com", CONF_PASSWORD: "__password__", + CONF_API_KEY: "abc123", }, unique_id="hydrawise-customerid", version=1, diff --git a/tests/components/hydrawise/test_config_flow.py b/tests/components/hydrawise/test_config_flow.py index cf723d885e1..594286b7f01 100644 --- a/tests/components/hydrawise/test_config_flow.py +++ b/tests/components/hydrawise/test_config_flow.py @@ -9,7 +9,7 @@ import pytest from homeassistant import config_entries from homeassistant.components.hydrawise.const import DOMAIN -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import CONF_API_KEY, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -35,7 +35,11 @@ async def test_form( result = await hass.config_entries.flow.async_configure( result["flow_id"], - {CONF_USERNAME: "asdf@asdf.com", CONF_PASSWORD: "__password__"}, + { + CONF_USERNAME: "asdf@asdf.com", + CONF_PASSWORD: "__password__", + CONF_API_KEY: "__api-key__", + }, ) mock_pydrawise.get_user.return_value = user await hass.async_block_till_done() @@ -45,9 +49,10 @@ async def test_form( assert result["data"] == { CONF_USERNAME: "asdf@asdf.com", CONF_PASSWORD: "__password__", + CONF_API_KEY: "__api-key__", } assert len(mock_setup_entry.mock_calls) == 1 - mock_auth.token.assert_awaited_once_with() + mock_auth.check.assert_awaited_once_with() mock_pydrawise.get_user.assert_awaited_once_with(fetch_zones=False) @@ -60,7 +65,11 @@ async def test_form_api_error( init_result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - data = {CONF_USERNAME: "asdf@asdf.com", CONF_PASSWORD: "__password__"} + data = { + CONF_USERNAME: "asdf@asdf.com", + CONF_PASSWORD: "__password__", + CONF_API_KEY: "__api-key__", + } result = await hass.config_entries.flow.async_configure( init_result["flow_id"], data ) @@ -77,11 +86,18 @@ async def test_form_auth_connect_timeout( hass: HomeAssistant, mock_auth: AsyncMock, mock_pydrawise: AsyncMock ) -> None: """Test we handle connection timeout errors.""" - mock_auth.token.side_effect = TimeoutError + mock_auth.check.side_effect = TimeoutError init_result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, + context={ + "source": config_entries.SOURCE_USER, + }, ) - data = {CONF_USERNAME: "asdf@asdf.com", CONF_PASSWORD: "__password__"} + data = { + CONF_USERNAME: "asdf@asdf.com", + CONF_PASSWORD: "__password__", + CONF_API_KEY: "__api-key__", + } result = await hass.config_entries.flow.async_configure( init_result["flow_id"], data ) @@ -89,7 +105,7 @@ async def test_form_auth_connect_timeout( assert result["type"] is FlowResultType.FORM assert result["errors"] == {"base": "timeout_connect"} - mock_auth.token.reset_mock(side_effect=True) + mock_auth.check.reset_mock(side_effect=True) result = await hass.config_entries.flow.async_configure(result["flow_id"], data) assert result["type"] is FlowResultType.CREATE_ENTRY @@ -102,7 +118,11 @@ async def test_form_client_connect_timeout( init_result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - data = {CONF_USERNAME: "asdf@asdf.com", CONF_PASSWORD: "__password__"} + data = { + CONF_USERNAME: "asdf@asdf.com", + CONF_PASSWORD: "__password__", + CONF_API_KEY: "__api-key__", + } result = await hass.config_entries.flow.async_configure( init_result["flow_id"], data ) @@ -120,19 +140,23 @@ async def test_form_not_authorized_error( hass: HomeAssistant, mock_auth: AsyncMock, mock_pydrawise: AsyncMock ) -> None: """Test we handle API errors.""" - mock_auth.token.side_effect = NotAuthorizedError + mock_auth.check.side_effect = NotAuthorizedError init_result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - data = {CONF_USERNAME: "asdf@asdf.com", CONF_PASSWORD: "__password__"} + data = { + CONF_USERNAME: "asdf@asdf.com", + CONF_PASSWORD: "__password__", + CONF_API_KEY: "__api-key__", + } result = await hass.config_entries.flow.async_configure( init_result["flow_id"], data ) assert result["type"] is FlowResultType.FORM assert result["errors"] == {"base": "invalid_auth"} - mock_auth.token.reset_mock(side_effect=True) + mock_auth.check.reset_mock(side_effect=True) result = await hass.config_entries.flow.async_configure(result["flow_id"], data) assert result["type"] is FlowResultType.CREATE_ENTRY @@ -150,6 +174,7 @@ async def test_reauth( data={ CONF_USERNAME: "asdf@asdf.com", CONF_PASSWORD: "bad-password", + CONF_API_KEY: "__api-key__", }, unique_id="hydrawise-12345", ) @@ -165,7 +190,11 @@ async def test_reauth( mock_pydrawise.get_user.return_value = user result = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_PASSWORD: "__password__"} + result["flow_id"], + { + CONF_PASSWORD: "__password__", + CONF_API_KEY: "__api-key__", + }, ) await hass.async_block_till_done() @@ -183,6 +212,7 @@ async def test_reauth_fails( data={ CONF_USERNAME: "asdf@asdf.com", CONF_PASSWORD: "bad-password", + CONF_API_KEY: "__api-key__", }, unique_id="hydrawise-12345", ) @@ -191,18 +221,26 @@ async def test_reauth_fails( result = await mock_config_entry.start_reauth_flow(hass) assert result["step_id"] == "reauth_confirm" - mock_auth.token.side_effect = NotAuthorizedError + mock_auth.check.side_effect = NotAuthorizedError result = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_PASSWORD: "__password__"} + result["flow_id"], + { + CONF_PASSWORD: "__password__", + CONF_API_KEY: "__api-key__", + }, ) assert result["type"] is FlowResultType.FORM assert result["errors"] == {"base": "invalid_auth"} - mock_auth.token.reset_mock(side_effect=True) + mock_auth.check.reset_mock(side_effect=True) mock_pydrawise.get_user.return_value = user result = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_PASSWORD: "__password__"} + result["flow_id"], + { + CONF_PASSWORD: "__password__", + CONF_API_KEY: "__api-key__", + }, ) assert result["type"] is FlowResultType.ABORT From 5e6f4a374e096e33a9717ffd0529f3c71c186162 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Wed, 29 Jan 2025 11:13:55 +0100 Subject: [PATCH 0214/3148] Bump deebot-client to 11.1.0b1 (#136818) --- homeassistant/components/ecovacs/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ecovacs/manifest.json b/homeassistant/components/ecovacs/manifest.json index 157d5b4a5ea..188f59f74e4 100644 --- a/homeassistant/components/ecovacs/manifest.json +++ b/homeassistant/components/ecovacs/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/ecovacs", "iot_class": "cloud_push", "loggers": ["sleekxmppfs", "sucks", "deebot_client"], - "requirements": ["py-sucks==0.9.10", "deebot-client==11.0.0"] + "requirements": ["py-sucks==0.9.10", "deebot-client==11.1.0b1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 0b36e1fb84e..b483ba42e6a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -747,7 +747,7 @@ debugpy==1.8.11 # decora==0.6 # homeassistant.components.ecovacs -deebot-client==11.0.0 +deebot-client==11.1.0b1 # homeassistant.components.ihc # homeassistant.components.namecheapdns diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 86ed63fe404..e92e8cf6ca3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -637,7 +637,7 @@ dbus-fast==2.30.2 debugpy==1.8.11 # homeassistant.components.ecovacs -deebot-client==11.0.0 +deebot-client==11.1.0b1 # homeassistant.components.ihc # homeassistant.components.namecheapdns From bfa7eaa221aa8be5df850faf1363d3d029c5678d Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 29 Jan 2025 11:31:54 +0100 Subject: [PATCH 0215/3148] Improve type hints in environment_canada sensors (#136813) * Use TypeVar * Use bound for TypeVar * Remove PEP 695 syntax * Add type alias to use new TypeVar syntax --------- Co-authored-by: Marc Mueller <30130371+cdce8p@users.noreply.github.com> --- .../environment_canada/coordinator.py | 11 +++++---- .../components/environment_canada/sensor.py | 24 ++++++++++++++----- 2 files changed, 24 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/environment_canada/coordinator.py b/homeassistant/components/environment_canada/coordinator.py index e65d8f6e471..e31e847cd2d 100644 --- a/homeassistant/components/environment_canada/coordinator.py +++ b/homeassistant/components/environment_canada/coordinator.py @@ -19,6 +19,7 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) type ECConfigEntry = ConfigEntry[ECRuntimeData] +type ECDataType = ECAirQuality | ECRadar | ECWeather @dataclass @@ -30,16 +31,16 @@ class ECRuntimeData: weather_coordinator: ECDataUpdateCoordinator[ECWeather] -class ECDataUpdateCoordinator[_ECDataTypeT: ECAirQuality | ECRadar | ECWeather]( - DataUpdateCoordinator[_ECDataTypeT] -): +class ECDataUpdateCoordinator[DataT: ECDataType](DataUpdateCoordinator[DataT]): """Class to manage fetching EC data.""" + config_entry: ECConfigEntry + def __init__( self, hass: HomeAssistant, entry: ECConfigEntry, - ec_data: _ECDataTypeT, + ec_data: DataT, name: str, update_interval: timedelta, ) -> None: @@ -60,7 +61,7 @@ class ECDataUpdateCoordinator[_ECDataTypeT: ECAirQuality | ECRadar | ECWeather]( configuration_url="https://weather.gc.ca/", ) - async def _async_update_data(self) -> _ECDataTypeT: + async def _async_update_data(self) -> DataT: """Fetch data from EC.""" try: await self.ec_data.update() diff --git a/homeassistant/components/environment_canada/sensor.py b/homeassistant/components/environment_canada/sensor.py index 1485f890cd2..989667fb1ac 100644 --- a/homeassistant/components/environment_canada/sensor.py +++ b/homeassistant/components/environment_canada/sensor.py @@ -6,6 +6,8 @@ from collections.abc import Callable from dataclasses import dataclass from typing import Any +from env_canada import ECWeather + from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, @@ -27,7 +29,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ATTR_STATION -from .coordinator import ECConfigEntry +from .coordinator import ECConfigEntry, ECDataType, ECDataUpdateCoordinator ATTR_TIME = "alert time" @@ -268,13 +270,19 @@ async def async_setup_entry( async_add_entities(sensors) -class ECBaseSensorEntity(CoordinatorEntity, SensorEntity): +class ECBaseSensorEntity[DataT: ECDataType]( + CoordinatorEntity[ECDataUpdateCoordinator[DataT]], SensorEntity +): """Environment Canada sensor base.""" entity_description: ECSensorEntityDescription _attr_has_entity_name = True - def __init__(self, coordinator, description): + def __init__( + self, + coordinator: ECDataUpdateCoordinator[DataT], + description: ECSensorEntityDescription, + ) -> None: """Initialize the base sensor.""" super().__init__(coordinator) self.entity_description = description @@ -292,10 +300,14 @@ class ECBaseSensorEntity(CoordinatorEntity, SensorEntity): return value -class ECSensorEntity(ECBaseSensorEntity): +class ECSensorEntity[DataT: ECDataType](ECBaseSensorEntity[DataT]): """Environment Canada sensor for conditions.""" - def __init__(self, coordinator, description): + def __init__( + self, + coordinator: ECDataUpdateCoordinator[DataT], + description: ECSensorEntityDescription, + ) -> None: """Initialize the sensor.""" super().__init__(coordinator, description) self._attr_extra_state_attributes = { @@ -304,7 +316,7 @@ class ECSensorEntity(ECBaseSensorEntity): } -class ECAlertSensorEntity(ECBaseSensorEntity): +class ECAlertSensorEntity(ECBaseSensorEntity[ECWeather]): """Environment Canada sensor for alerts.""" @property From a9433ca6977521b4c5f70139fd09e0807210b39a Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 29 Jan 2025 11:36:22 +0100 Subject: [PATCH 0216/3148] Standardize homeassistant imports in component (e-f) (#136824) --- homeassistant/components/ebox/sensor.py | 2 +- homeassistant/components/ebusd/__init__.py | 2 +- homeassistant/components/ebusd/sensor.py | 3 +-- homeassistant/components/ecoal_boiler/__init__.py | 2 +- homeassistant/components/ecobee/climate.py | 7 +++++-- homeassistant/components/eddystone_temperature/sensor.py | 2 +- homeassistant/components/edimax/switch.py | 2 +- homeassistant/components/egardia/__init__.py | 3 +-- homeassistant/components/eliqonline/sensor.py | 2 +- homeassistant/components/elkm1/__init__.py | 2 +- homeassistant/components/elkm1/alarm_control_panel.py | 3 +-- homeassistant/components/elv/__init__.py | 3 +-- homeassistant/components/emby/media_player.py | 4 ++-- homeassistant/components/emoncms/sensor.py | 3 +-- homeassistant/components/emoncms_history/__init__.py | 3 +-- homeassistant/components/emulated_hue/__init__.py | 2 +- homeassistant/components/emulated_kasa/__init__.py | 3 +-- homeassistant/components/emulated_roku/__init__.py | 2 +- homeassistant/components/enocean/__init__.py | 2 +- homeassistant/components/enocean/binary_sensor.py | 2 +- homeassistant/components/enocean/light.py | 2 +- homeassistant/components/enocean/sensor.py | 2 +- homeassistant/components/enphase_envoy/coordinator.py | 2 +- homeassistant/components/entur_public_transport/sensor.py | 5 ++--- homeassistant/components/envisalink/__init__.py | 2 +- homeassistant/components/envisalink/alarm_control_panel.py | 2 +- homeassistant/components/ephember/climate.py | 2 +- homeassistant/components/esphome/__init__.py | 2 +- homeassistant/components/esphome/datetime.py | 2 +- homeassistant/components/esphome/entity.py | 7 +++++-- homeassistant/components/esphome/manager.py | 7 +++++-- homeassistant/components/etherscan/sensor.py | 2 +- homeassistant/components/eufy/__init__.py | 3 +-- homeassistant/components/evohome/__init__.py | 5 ++--- homeassistant/components/evohome/climate.py | 2 +- homeassistant/components/evohome/entity.py | 2 +- homeassistant/components/evohome/helpers.py | 2 +- homeassistant/components/evohome/water_heater.py | 2 +- homeassistant/components/ezviz/siren.py | 2 +- homeassistant/components/facebook/notify.py | 2 +- homeassistant/components/fail2ban/sensor.py | 2 +- homeassistant/components/familyhub/camera.py | 2 +- homeassistant/components/feedreader/config_flow.py | 2 +- homeassistant/components/ffmpeg_motion/binary_sensor.py | 2 +- homeassistant/components/ffmpeg_noise/binary_sensor.py | 2 +- homeassistant/components/fido/sensor.py | 2 +- homeassistant/components/file/notify.py | 2 +- homeassistant/components/filesize/coordinator.py | 2 +- homeassistant/components/filter/sensor.py | 4 ++-- homeassistant/components/fints/sensor.py | 2 +- homeassistant/components/fivem/config_flow.py | 2 +- homeassistant/components/fixer/sensor.py | 2 +- homeassistant/components/fleetgo/device_tracker.py | 2 +- homeassistant/components/flexit/climate.py | 2 +- homeassistant/components/flic/binary_sensor.py | 2 +- homeassistant/components/flo/coordinator.py | 2 +- homeassistant/components/flock/notify.py | 2 +- homeassistant/components/flux_led/light.py | 3 +-- homeassistant/components/folder/sensor.py | 2 +- homeassistant/components/fortios/device_tracker.py | 2 +- homeassistant/components/foursquare/__init__.py | 2 +- homeassistant/components/free_mobile/notify.py | 2 +- homeassistant/components/freebox/sensor.py | 2 +- homeassistant/components/freedns/__init__.py | 2 +- homeassistant/components/fully_kiosk/image.py | 2 +- homeassistant/components/fully_kiosk/services.py | 3 +-- homeassistant/components/futurenow/light.py | 2 +- 67 files changed, 83 insertions(+), 86 deletions(-) diff --git a/homeassistant/components/ebox/sensor.py b/homeassistant/components/ebox/sensor.py index 691e9dd8275..a7628e78a9a 100644 --- a/homeassistant/components/ebox/sensor.py +++ b/homeassistant/components/ebox/sensor.py @@ -29,8 +29,8 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import Throttle diff --git a/homeassistant/components/ebusd/__init__.py b/homeassistant/components/ebusd/__init__.py index c9386999fae..4cb8d92c391 100644 --- a/homeassistant/components/ebusd/__init__.py +++ b/homeassistant/components/ebusd/__init__.py @@ -13,7 +13,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant, ServiceCall -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.discovery import load_platform from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/ebusd/sensor.py b/homeassistant/components/ebusd/sensor.py index 2eaaddf7e2f..ccd04be585e 100644 --- a/homeassistant/components/ebusd/sensor.py +++ b/homeassistant/components/ebusd/sensor.py @@ -9,8 +9,7 @@ from homeassistant.components.sensor import SensorEntity from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.util import Throttle -import homeassistant.util.dt as dt_util +from homeassistant.util import Throttle, dt as dt_util from .const import DOMAIN diff --git a/homeassistant/components/ecoal_boiler/__init__.py b/homeassistant/components/ecoal_boiler/__init__.py index e9b519c7095..0648dfb56bf 100644 --- a/homeassistant/components/ecoal_boiler/__init__.py +++ b/homeassistant/components/ecoal_boiler/__init__.py @@ -15,7 +15,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.discovery import load_platform from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/ecobee/climate.py b/homeassistant/components/ecobee/climate.py index 4e32990a661..743e2e1ba4b 100644 --- a/homeassistant/components/ecobee/climate.py +++ b/homeassistant/components/ecobee/climate.py @@ -32,8 +32,11 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.exceptions import ServiceValidationError -from homeassistant.helpers import device_registry as dr, entity_platform -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import ( + config_validation as cv, + device_registry as dr, + entity_platform, +) from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.unit_conversion import TemperatureConverter diff --git a/homeassistant/components/eddystone_temperature/sensor.py b/homeassistant/components/eddystone_temperature/sensor.py index 5dc30a575d7..1047c52e111 100644 --- a/homeassistant/components/eddystone_temperature/sensor.py +++ b/homeassistant/components/eddystone_temperature/sensor.py @@ -24,7 +24,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import Event, HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/edimax/switch.py b/homeassistant/components/edimax/switch.py index e0d063eb9fd..5482143fc37 100644 --- a/homeassistant/components/edimax/switch.py +++ b/homeassistant/components/edimax/switch.py @@ -13,7 +13,7 @@ from homeassistant.components.switch import ( ) from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/egardia/__init__.py b/homeassistant/components/egardia/__init__.py index 89dae7d23c9..eb6b4cd49d8 100644 --- a/homeassistant/components/egardia/__init__.py +++ b/homeassistant/components/egardia/__init__.py @@ -16,8 +16,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import discovery -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/eliqonline/sensor.py b/homeassistant/components/eliqonline/sensor.py index 7c9f76824e8..1a5490da0a5 100644 --- a/homeassistant/components/eliqonline/sensor.py +++ b/homeassistant/components/eliqonline/sensor.py @@ -16,8 +16,8 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_ACCESS_TOKEN, CONF_NAME, UnitOfPower from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/elkm1/__init__.py b/homeassistant/components/elkm1/__init__.py index 34a35fbeb09..5286b7ad66f 100644 --- a/homeassistant/components/elkm1/__init__.py +++ b/homeassistant/components/elkm1/__init__.py @@ -32,7 +32,7 @@ from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError from homeassistant.helpers import config_validation as cv from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.typing import ConfigType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from homeassistant.util.network import is_ip_address from .const import ( diff --git a/homeassistant/components/elkm1/alarm_control_panel.py b/homeassistant/components/elkm1/alarm_control_panel.py index f1ecf626263..ab51b6fe281 100644 --- a/homeassistant/components/elkm1/alarm_control_panel.py +++ b/homeassistant/components/elkm1/alarm_control_panel.py @@ -19,8 +19,7 @@ from homeassistant.components.alarm_control_panel import ( CodeFormat, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_platform -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import VolDictType diff --git a/homeassistant/components/elv/__init__.py b/homeassistant/components/elv/__init__.py index 208d19a0f8e..97f08c786f4 100644 --- a/homeassistant/components/elv/__init__.py +++ b/homeassistant/components/elv/__init__.py @@ -4,8 +4,7 @@ import voluptuous as vol from homeassistant.const import CONF_DEVICE, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import discovery -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.typing import ConfigType DOMAIN = "elv" diff --git a/homeassistant/components/emby/media_player.py b/homeassistant/components/emby/media_player.py index 21ee6449c11..812e58ecc19 100644 --- a/homeassistant/components/emby/media_player.py +++ b/homeassistant/components/emby/media_player.py @@ -24,10 +24,10 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, ) from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/emoncms/sensor.py b/homeassistant/components/emoncms/sensor.py index 291ecad0bd3..1920e06a8e8 100644 --- a/homeassistant/components/emoncms/sensor.py +++ b/homeassistant/components/emoncms/sensor.py @@ -38,8 +38,7 @@ from homeassistant.const import ( ) from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, callback from homeassistant.data_entry_flow import FlowResultType -from homeassistant.helpers import template -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, template from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/emoncms_history/__init__.py b/homeassistant/components/emoncms_history/__init__.py index 00af1fec6c6..2ab00d6ca42 100644 --- a/homeassistant/components/emoncms_history/__init__.py +++ b/homeassistant/components/emoncms_history/__init__.py @@ -16,8 +16,7 @@ from homeassistant.const import ( STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import state as state_helper -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, state as state_helper from homeassistant.helpers.event import track_point_in_time from homeassistant.helpers.typing import ConfigType from homeassistant.util import dt as dt_util diff --git a/homeassistant/components/emulated_hue/__init__.py b/homeassistant/components/emulated_hue/__init__.py index 3e229d07b6c..556831496c6 100644 --- a/homeassistant/components/emulated_hue/__init__.py +++ b/homeassistant/components/emulated_hue/__init__.py @@ -16,7 +16,7 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, ) from homeassistant.core import Event, HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from .config import ( diff --git a/homeassistant/components/emulated_kasa/__init__.py b/homeassistant/components/emulated_kasa/__init__.py index 408d8c4eff8..11f4ce80490 100644 --- a/homeassistant/components/emulated_kasa/__init__.py +++ b/homeassistant/components/emulated_kasa/__init__.py @@ -15,8 +15,7 @@ from homeassistant.const import ( STATE_ON, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.template import Template, is_template_string from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/emulated_roku/__init__.py b/homeassistant/components/emulated_roku/__init__.py index 4ebd31730bf..d4466f47ef2 100644 --- a/homeassistant/components/emulated_roku/__init__.py +++ b/homeassistant/components/emulated_roku/__init__.py @@ -7,7 +7,7 @@ from homeassistant.components.network import async_get_source_ip 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 import config_validation as cv from homeassistant.helpers.typing import ConfigType from .binding import EmulatedRoku diff --git a/homeassistant/components/enocean/__init__.py b/homeassistant/components/enocean/__init__.py index 9f53c79cc5b..c1db27c1c34 100644 --- a/homeassistant/components/enocean/__init__.py +++ b/homeassistant/components/enocean/__init__.py @@ -5,7 +5,7 @@ import voluptuous as vol from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_DEVICE from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from .const import DATA_ENOCEAN, DOMAIN, ENOCEAN_DONGLE diff --git a/homeassistant/components/enocean/binary_sensor.py b/homeassistant/components/enocean/binary_sensor.py index 01e39f96510..26039036ca0 100644 --- a/homeassistant/components/enocean/binary_sensor.py +++ b/homeassistant/components/enocean/binary_sensor.py @@ -13,7 +13,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import CONF_DEVICE_CLASS, CONF_ID, CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/enocean/light.py b/homeassistant/components/enocean/light.py index aae84e73848..6586714c1b6 100644 --- a/homeassistant/components/enocean/light.py +++ b/homeassistant/components/enocean/light.py @@ -16,7 +16,7 @@ from homeassistant.components.light import ( ) from homeassistant.const import CONF_ID, CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/enocean/sensor.py b/homeassistant/components/enocean/sensor.py index 98e32ce1a4f..2a4b9364d81 100644 --- a/homeassistant/components/enocean/sensor.py +++ b/homeassistant/components/enocean/sensor.py @@ -26,7 +26,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/enphase_envoy/coordinator.py b/homeassistant/components/enphase_envoy/coordinator.py index d92b998e731..8eb2b32ac7b 100644 --- a/homeassistant/components/enphase_envoy/coordinator.py +++ b/homeassistant/components/enphase_envoy/coordinator.py @@ -16,7 +16,7 @@ from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import DOMAIN, INVALID_AUTH_ERRORS diff --git a/homeassistant/components/entur_public_transport/sensor.py b/homeassistant/components/entur_public_transport/sensor.py index f88bb99cea0..8fa8a06e369 100644 --- a/homeassistant/components/entur_public_transport/sensor.py +++ b/homeassistant/components/entur_public_transport/sensor.py @@ -20,12 +20,11 @@ from homeassistant.const import ( UnitOfTime, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.util import Throttle -import homeassistant.util.dt as dt_util +from homeassistant.util import Throttle, dt as dt_util API_CLIENT_NAME = "homeassistant-{}" diff --git a/homeassistant/components/envisalink/__init__.py b/homeassistant/components/envisalink/__init__.py index 0146b650c22..919704a6728 100644 --- a/homeassistant/components/envisalink/__init__.py +++ b/homeassistant/components/envisalink/__init__.py @@ -14,7 +14,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant, ServiceCall, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.discovery import async_load_platform from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/envisalink/alarm_control_panel.py b/homeassistant/components/envisalink/alarm_control_panel.py index ce65178b8d8..9d1b6d0d7a1 100644 --- a/homeassistant/components/envisalink/alarm_control_panel.py +++ b/homeassistant/components/envisalink/alarm_control_panel.py @@ -14,7 +14,7 @@ from homeassistant.components.alarm_control_panel import ( ) from homeassistant.const import ATTR_ENTITY_ID, CONF_CODE from homeassistant.core import HomeAssistant, ServiceCall, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/ephember/climate.py b/homeassistant/components/ephember/climate.py index cedad8b76e2..f92be005db6 100644 --- a/homeassistant/components/ephember/climate.py +++ b/homeassistant/components/ephember/climate.py @@ -33,7 +33,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/esphome/__init__.py b/homeassistant/components/esphome/__init__.py index 5934c9a6f68..fee2531fa20 100644 --- a/homeassistant/components/esphome/__init__.py +++ b/homeassistant/components/esphome/__init__.py @@ -13,7 +13,7 @@ from homeassistant.const import ( __version__ as ha_version, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from .const import CONF_NOISE_PSK, DATA_FFMPEG_PROXY, DOMAIN diff --git a/homeassistant/components/esphome/datetime.py b/homeassistant/components/esphome/datetime.py index 20d0d651bba..d1bb0bb77ff 100644 --- a/homeassistant/components/esphome/datetime.py +++ b/homeassistant/components/esphome/datetime.py @@ -8,7 +8,7 @@ from functools import partial from aioesphomeapi import DateTimeInfo, DateTimeState from homeassistant.components.datetime import DateTimeEntity -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .entity import EsphomeEntity, esphome_state_property, platform_async_setup_entry diff --git a/homeassistant/components/esphome/entity.py b/homeassistant/components/esphome/entity.py index ae9e0d2491d..ff08e5f578a 100644 --- a/homeassistant/components/esphome/entity.py +++ b/homeassistant/components/esphome/entity.py @@ -19,8 +19,11 @@ import voluptuous as vol from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import device_registry as dr, entity_platform -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import ( + config_validation as cv, + device_registry as dr, + entity_platform, +) from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import AddEntitiesCallback diff --git a/homeassistant/components/esphome/manager.py b/homeassistant/components/esphome/manager.py index 494df51721a..93d6c53e590 100644 --- a/homeassistant/components/esphome/manager.py +++ b/homeassistant/components/esphome/manager.py @@ -41,8 +41,11 @@ from homeassistant.core import ( callback, ) from homeassistant.exceptions import TemplateError -from homeassistant.helpers import device_registry as dr, template -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import ( + config_validation as cv, + device_registry as dr, + template, +) from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.event import async_track_state_change_event from homeassistant.helpers.issue_registry import ( diff --git a/homeassistant/components/etherscan/sensor.py b/homeassistant/components/etherscan/sensor.py index e64b596a119..3e48307e8bf 100644 --- a/homeassistant/components/etherscan/sensor.py +++ b/homeassistant/components/etherscan/sensor.py @@ -13,7 +13,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_ADDRESS, CONF_NAME, CONF_TOKEN from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/eufy/__init__.py b/homeassistant/components/eufy/__init__.py index 8ebe3e08843..57f90503049 100644 --- a/homeassistant/components/eufy/__init__.py +++ b/homeassistant/components/eufy/__init__.py @@ -14,8 +14,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import discovery -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.typing import ConfigType DOMAIN = "eufy" diff --git a/homeassistant/components/evohome/__init__.py b/homeassistant/components/evohome/__init__.py index 612131919d4..97f7c2db54d 100644 --- a/homeassistant/components/evohome/__init__.py +++ b/homeassistant/components/evohome/__init__.py @@ -29,16 +29,15 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant, ServiceCall, callback -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.discovery import async_load_platform from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.service import verify_domain_control from homeassistant.helpers.storage import Store from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import ( ACCESS_TOKEN, diff --git a/homeassistant/components/evohome/climate.py b/homeassistant/components/evohome/climate.py index c71831fa4bc..64e7367bc32 100644 --- a/homeassistant/components/evohome/climate.py +++ b/homeassistant/components/evohome/climate.py @@ -34,7 +34,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import ( ATTR_DURATION_DAYS, diff --git a/homeassistant/components/evohome/entity.py b/homeassistant/components/evohome/entity.py index b5842c1073a..a42d8ef7582 100644 --- a/homeassistant/components/evohome/entity.py +++ b/homeassistant/components/evohome/entity.py @@ -16,7 +16,7 @@ from evohomeasync2.schema.const import ( from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import EvoBroker, EvoService from .const import DOMAIN diff --git a/homeassistant/components/evohome/helpers.py b/homeassistant/components/evohome/helpers.py index f84d2945779..0e2de36eb47 100644 --- a/homeassistant/components/evohome/helpers.py +++ b/homeassistant/components/evohome/helpers.py @@ -11,7 +11,7 @@ from typing import Any import evohomeasync2 as evo from homeassistant.const import CONF_SCAN_INTERVAL -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/evohome/water_heater.py b/homeassistant/components/evohome/water_heater.py index a50e16b5dda..2c3cf9de12d 100644 --- a/homeassistant/components/evohome/water_heater.py +++ b/homeassistant/components/evohome/water_heater.py @@ -29,7 +29,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import DOMAIN, EVO_FOLLOW, EVO_PERMOVER from .entity import EvoChild diff --git a/homeassistant/components/ezviz/siren.py b/homeassistant/components/ezviz/siren.py index a52e499eee2..5a612aa0772 100644 --- a/homeassistant/components/ezviz/siren.py +++ b/homeassistant/components/ezviz/siren.py @@ -16,8 +16,8 @@ from homeassistant.components.siren import ( from homeassistant.const import STATE_ON from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import event as evt from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.helpers.event as evt from homeassistant.helpers.restore_state import RestoreEntity from .coordinator import EzvizConfigEntry, EzvizDataUpdateCoordinator diff --git a/homeassistant/components/facebook/notify.py b/homeassistant/components/facebook/notify.py index 3319f6bdebd..edd46d24982 100644 --- a/homeassistant/components/facebook/notify.py +++ b/homeassistant/components/facebook/notify.py @@ -17,7 +17,7 @@ from homeassistant.components.notify import ( ) from homeassistant.const import CONTENT_TYPE_JSON from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/fail2ban/sensor.py b/homeassistant/components/fail2ban/sensor.py index 9e6d23556d2..d71d404c7a0 100644 --- a/homeassistant/components/fail2ban/sensor.py +++ b/homeassistant/components/fail2ban/sensor.py @@ -15,7 +15,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_FILE_PATH, CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/familyhub/camera.py b/homeassistant/components/familyhub/camera.py index 462983278b0..6be13b23568 100644 --- a/homeassistant/components/familyhub/camera.py +++ b/homeassistant/components/familyhub/camera.py @@ -11,8 +11,8 @@ from homeassistant.components.camera import ( ) from homeassistant.const import CONF_IP_ADDRESS, CONF_NAME from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/feedreader/config_flow.py b/homeassistant/components/feedreader/config_flow.py index f3e56ad1778..3d0fec1a6f5 100644 --- a/homeassistant/components/feedreader/config_flow.py +++ b/homeassistant/components/feedreader/config_flow.py @@ -18,7 +18,7 @@ from homeassistant.config_entries import ( ) from homeassistant.const import CONF_URL from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.selector import ( TextSelector, TextSelectorConfig, diff --git a/homeassistant/components/ffmpeg_motion/binary_sensor.py b/homeassistant/components/ffmpeg_motion/binary_sensor.py index 7dc32fd96a3..3adae8441df 100644 --- a/homeassistant/components/ffmpeg_motion/binary_sensor.py +++ b/homeassistant/components/ffmpeg_motion/binary_sensor.py @@ -23,7 +23,7 @@ from homeassistant.components.ffmpeg import ( ) from homeassistant.const import CONF_NAME, CONF_REPEAT from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/ffmpeg_noise/binary_sensor.py b/homeassistant/components/ffmpeg_noise/binary_sensor.py index abbf77eba6b..1623d7c7660 100644 --- a/homeassistant/components/ffmpeg_noise/binary_sensor.py +++ b/homeassistant/components/ffmpeg_noise/binary_sensor.py @@ -22,7 +22,7 @@ from homeassistant.components.ffmpeg import ( from homeassistant.components.ffmpeg_motion.binary_sensor import FFmpegBinarySensor from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/fido/sensor.py b/homeassistant/components/fido/sensor.py index bc6e6340111..86e81a596d7 100644 --- a/homeassistant/components/fido/sensor.py +++ b/homeassistant/components/fido/sensor.py @@ -28,8 +28,8 @@ from homeassistant.const import ( UnitOfTime, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import Throttle diff --git a/homeassistant/components/file/notify.py b/homeassistant/components/file/notify.py index 10e3d4a4ac6..3d61dbb04e0 100644 --- a/homeassistant/components/file/notify.py +++ b/homeassistant/components/file/notify.py @@ -15,7 +15,7 @@ from homeassistant.const import CONF_FILE_PATH, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import CONF_TIMESTAMP, DEFAULT_NAME, DOMAIN, FILE_ICON diff --git a/homeassistant/components/filesize/coordinator.py b/homeassistant/components/filesize/coordinator.py index 8350cee91bf..0c2a0277434 100644 --- a/homeassistant/components/filesize/coordinator.py +++ b/homeassistant/components/filesize/coordinator.py @@ -9,7 +9,7 @@ import pathlib from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import DOMAIN diff --git a/homeassistant/components/filter/sensor.py b/homeassistant/components/filter/sensor.py index 5bb6cadabc7..330e61f499e 100644 --- a/homeassistant/components/filter/sensor.py +++ b/homeassistant/components/filter/sensor.py @@ -43,14 +43,14 @@ from homeassistant.core import ( State, callback, ) -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_state_change_event from homeassistant.helpers.reload import async_setup_reload_service from homeassistant.helpers.start import async_at_started from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateType +from homeassistant.util import dt as dt_util from homeassistant.util.decorator import Registry -import homeassistant.util.dt as dt_util from .const import ( CONF_FILTER_LOWER_BOUND, diff --git a/homeassistant/components/fints/sensor.py b/homeassistant/components/fints/sensor.py index c85f08ba3d0..318325dbb09 100644 --- a/homeassistant/components/fints/sensor.py +++ b/homeassistant/components/fints/sensor.py @@ -18,7 +18,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_NAME, CONF_PIN, CONF_URL, CONF_USERNAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/fivem/config_flow.py b/homeassistant/components/fivem/config_flow.py index b5ced70b846..d5132627b9d 100644 --- a/homeassistant/components/fivem/config_flow.py +++ b/homeassistant/components/fivem/config_flow.py @@ -10,7 +10,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_PORT -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from .const import DOMAIN diff --git a/homeassistant/components/fixer/sensor.py b/homeassistant/components/fixer/sensor.py index f8b4546d4c7..3fb241208ad 100644 --- a/homeassistant/components/fixer/sensor.py +++ b/homeassistant/components/fixer/sensor.py @@ -16,7 +16,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_API_KEY, CONF_NAME, CONF_TARGET from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/fleetgo/device_tracker.py b/homeassistant/components/fleetgo/device_tracker.py index 008c0765c07..71f6c174dde 100644 --- a/homeassistant/components/fleetgo/device_tracker.py +++ b/homeassistant/components/fleetgo/device_tracker.py @@ -20,7 +20,7 @@ from homeassistant.const import ( CONF_USERNAME, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.event import track_utc_time_change from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/flexit/climate.py b/homeassistant/components/flexit/climate.py index 8be5df4eca7..32c94638b1f 100644 --- a/homeassistant/components/flexit/climate.py +++ b/homeassistant/components/flexit/climate.py @@ -29,7 +29,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/flic/binary_sensor.py b/homeassistant/components/flic/binary_sensor.py index cd160480674..281e960f222 100644 --- a/homeassistant/components/flic/binary_sensor.py +++ b/homeassistant/components/flic/binary_sensor.py @@ -20,7 +20,7 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/flo/coordinator.py b/homeassistant/components/flo/coordinator.py index 0edb80004fd..d0dd38bd490 100644 --- a/homeassistant/components/flo/coordinator.py +++ b/homeassistant/components/flo/coordinator.py @@ -12,7 +12,7 @@ from orjson import JSONDecodeError from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import DOMAIN as FLO_DOMAIN, LOGGER diff --git a/homeassistant/components/flock/notify.py b/homeassistant/components/flock/notify.py index 811ee51749c..f50e04cba36 100644 --- a/homeassistant/components/flock/notify.py +++ b/homeassistant/components/flock/notify.py @@ -14,8 +14,8 @@ from homeassistant.components.notify import ( ) from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/flux_led/light.py b/homeassistant/components/flux_led/light.py index ca7fb7aeea2..2a0b5795970 100644 --- a/homeassistant/components/flux_led/light.py +++ b/homeassistant/components/flux_led/light.py @@ -25,8 +25,7 @@ from homeassistant.components.light import ( ) from homeassistant.const import CONF_EFFECT from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import entity_platform -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import VolDictType from homeassistant.helpers.update_coordinator import CoordinatorEntity diff --git a/homeassistant/components/folder/sensor.py b/homeassistant/components/folder/sensor.py index 3a8a4fdc380..4667a6c348d 100644 --- a/homeassistant/components/folder/sensor.py +++ b/homeassistant/components/folder/sensor.py @@ -16,7 +16,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import UnitOfInformation from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/fortios/device_tracker.py b/homeassistant/components/fortios/device_tracker.py index af2bc92a065..4360dd031c7 100644 --- a/homeassistant/components/fortios/device_tracker.py +++ b/homeassistant/components/fortios/device_tracker.py @@ -19,7 +19,7 @@ from homeassistant.components.device_tracker import ( ) from homeassistant.const import CONF_HOST, CONF_TOKEN, CONF_VERIFY_SSL from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/foursquare/__init__.py b/homeassistant/components/foursquare/__init__.py index 12a29fd632e..25effac073d 100644 --- a/homeassistant/components/foursquare/__init__.py +++ b/homeassistant/components/foursquare/__init__.py @@ -10,7 +10,7 @@ import voluptuous as vol from homeassistant.components.http import KEY_HASS, HomeAssistantView from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.core import HomeAssistant, ServiceCall -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/free_mobile/notify.py b/homeassistant/components/free_mobile/notify.py index 90c8ef3246e..c7e3071c771 100644 --- a/homeassistant/components/free_mobile/notify.py +++ b/homeassistant/components/free_mobile/notify.py @@ -14,7 +14,7 @@ from homeassistant.components.notify import ( ) from homeassistant.const import CONF_ACCESS_TOKEN, CONF_USERNAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/freebox/sensor.py b/homeassistant/components/freebox/sensor.py index 097c8c138ee..588992a7f21 100644 --- a/homeassistant/components/freebox/sensor.py +++ b/homeassistant/components/freebox/sensor.py @@ -16,7 +16,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import DOMAIN from .entity import FreeboxHomeEntity diff --git a/homeassistant/components/freedns/__init__.py b/homeassistant/components/freedns/__init__.py index 5338c0d0700..460ad163f61 100644 --- a/homeassistant/components/freedns/__init__.py +++ b/homeassistant/components/freedns/__init__.py @@ -9,8 +9,8 @@ import voluptuous as vol from homeassistant.const import CONF_ACCESS_TOKEN, CONF_SCAN_INTERVAL, CONF_URL from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/fully_kiosk/image.py b/homeassistant/components/fully_kiosk/image.py index 00318a77ab5..e1a4240c9e9 100644 --- a/homeassistant/components/fully_kiosk/image.py +++ b/homeassistant/components/fully_kiosk/image.py @@ -12,7 +12,7 @@ from homeassistant.components.image import ImageEntity, ImageEntityDescription from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import FullyKioskConfigEntry from .coordinator import FullyKioskDataUpdateCoordinator diff --git a/homeassistant/components/fully_kiosk/services.py b/homeassistant/components/fully_kiosk/services.py index bff78aa627a..ac6faf76a9d 100644 --- a/homeassistant/components/fully_kiosk/services.py +++ b/homeassistant/components/fully_kiosk/services.py @@ -8,8 +8,7 @@ from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import ATTR_DEVICE_ID from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import device_registry as dr -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, device_registry as dr from .const import ( ATTR_APPLICATION, diff --git a/homeassistant/components/futurenow/light.py b/homeassistant/components/futurenow/light.py index d1ad6f42083..e9dcfd7a151 100644 --- a/homeassistant/components/futurenow/light.py +++ b/homeassistant/components/futurenow/light.py @@ -15,7 +15,7 @@ from homeassistant.components.light import ( ) from homeassistant.const import CONF_DEVICES, CONF_HOST, CONF_NAME, CONF_PORT from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType From 4f6a5bb65b416d2ac121154ca4e99488efdc3ab2 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 29 Jan 2025 11:37:16 +0100 Subject: [PATCH 0217/3148] Standardize homeassistant imports in component (c-d) (#136823) --- homeassistant/components/caldav/calendar.py | 2 +- homeassistant/components/canary/__init__.py | 2 +- homeassistant/components/cast/media_player.py | 2 +- homeassistant/components/ccm15/config_flow.py | 2 +- homeassistant/components/cisco_ios/device_tracker.py | 2 +- .../components/cisco_mobility_express/device_tracker.py | 2 +- homeassistant/components/cisco_webex_teams/notify.py | 2 +- homeassistant/components/citybikes/sensor.py | 2 +- homeassistant/components/clementine/media_player.py | 2 +- homeassistant/components/clickatell/notify.py | 2 +- homeassistant/components/clicksend/notify.py | 2 +- homeassistant/components/clicksend_tts/notify.py | 2 +- homeassistant/components/cmus/media_player.py | 2 +- homeassistant/components/co2signal/config_flow.py | 2 +- homeassistant/components/coinbase/config_flow.py | 2 +- homeassistant/components/color_extractor/__init__.py | 3 +-- homeassistant/components/comed_hourly_pricing/sensor.py | 2 +- homeassistant/components/comelit/config_flow.py | 2 +- homeassistant/components/comfoconnect/__init__.py | 3 +-- homeassistant/components/comfoconnect/sensor.py | 2 +- homeassistant/components/command_line/__init__.py | 3 +-- homeassistant/components/concord232/alarm_control_panel.py | 2 +- homeassistant/components/concord232/binary_sensor.py | 4 ++-- homeassistant/components/counter/__init__.py | 3 +-- homeassistant/components/cppm_tracker/device_tracker.py | 2 +- homeassistant/components/cups/sensor.py | 2 +- homeassistant/components/currencylayer/sensor.py | 2 +- homeassistant/components/danfoss_air/__init__.py | 3 +-- homeassistant/components/datadog/__init__.py | 3 +-- homeassistant/components/ddwrt/device_tracker.py | 2 +- homeassistant/components/debugpy/__init__.py | 2 +- homeassistant/components/deconz/sensor.py | 2 +- homeassistant/components/decora/light.py | 2 +- homeassistant/components/decora_wifi/light.py | 2 +- homeassistant/components/delijn/sensor.py | 2 +- homeassistant/components/deluge/config_flow.py | 2 +- homeassistant/components/denon/media_player.py | 2 +- homeassistant/components/device_sun_light_trigger/__init__.py | 4 ++-- homeassistant/components/devolo_home_network/image.py | 2 +- homeassistant/components/digital_ocean/__init__.py | 2 +- homeassistant/components/digital_ocean/binary_sensor.py | 2 +- homeassistant/components/digital_ocean/switch.py | 2 +- homeassistant/components/discogs/sensor.py | 2 +- .../components/dlib_face_identify/image_processing.py | 2 +- homeassistant/components/dnsip/config_flow.py | 2 +- homeassistant/components/dominos/__init__.py | 2 +- homeassistant/components/doods/image_processing.py | 3 +-- homeassistant/components/doorbird/__init__.py | 3 +-- homeassistant/components/doorbird/camera.py | 2 +- homeassistant/components/dovado/__init__.py | 2 +- homeassistant/components/dovado/sensor.py | 2 +- homeassistant/components/downloader/__init__.py | 2 +- homeassistant/components/dremel_3d_printer/config_flow.py | 2 +- homeassistant/components/dublin_bus_transport/sensor.py | 4 ++-- homeassistant/components/duckdns/__init__.py | 2 +- homeassistant/components/dwd_weather_warnings/config_flow.py | 3 +-- homeassistant/components/dweet/__init__.py | 3 +-- homeassistant/components/dweet/sensor.py | 2 +- 58 files changed, 61 insertions(+), 71 deletions(-) diff --git a/homeassistant/components/caldav/calendar.py b/homeassistant/components/caldav/calendar.py index fb53947a723..c2bf1b2dce1 100644 --- a/homeassistant/components/caldav/calendar.py +++ b/homeassistant/components/caldav/calendar.py @@ -23,7 +23,7 @@ from homeassistant.const import ( CONF_VERIFY_SSL, ) from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/canary/__init__.py b/homeassistant/components/canary/__init__.py index a28c37580ce..b0e59e49a6f 100644 --- a/homeassistant/components/canary/__init__.py +++ b/homeassistant/components/canary/__init__.py @@ -15,7 +15,7 @@ from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import CONF_PASSWORD, CONF_TIMEOUT, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from .const import ( diff --git a/homeassistant/components/cast/media_player.py b/homeassistant/components/cast/media_player.py index 28db97a857d..3cc17fae43b 100644 --- a/homeassistant/components/cast/media_player.py +++ b/homeassistant/components/cast/media_player.py @@ -53,7 +53,7 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.network import NoURLAvailableError, get_url, is_hass_url -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from homeassistant.util.logging import async_create_catching_coro from .const import ( diff --git a/homeassistant/components/ccm15/config_flow.py b/homeassistant/components/ccm15/config_flow.py index 0e49e0929e5..c059796045c 100644 --- a/homeassistant/components/ccm15/config_flow.py +++ b/homeassistant/components/ccm15/config_flow.py @@ -10,7 +10,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_PORT -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from .const import DEFAULT_TIMEOUT, DOMAIN diff --git a/homeassistant/components/cisco_ios/device_tracker.py b/homeassistant/components/cisco_ios/device_tracker.py index b882f046a8e..0477ebb111c 100644 --- a/homeassistant/components/cisco_ios/device_tracker.py +++ b/homeassistant/components/cisco_ios/device_tracker.py @@ -14,7 +14,7 @@ from homeassistant.components.device_tracker import ( ) from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/cisco_mobility_express/device_tracker.py b/homeassistant/components/cisco_mobility_express/device_tracker.py index 2c7398ae172..78bbcc9edbc 100644 --- a/homeassistant/components/cisco_mobility_express/device_tracker.py +++ b/homeassistant/components/cisco_mobility_express/device_tracker.py @@ -20,7 +20,7 @@ from homeassistant.const import ( CONF_VERIFY_SSL, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/cisco_webex_teams/notify.py b/homeassistant/components/cisco_webex_teams/notify.py index 74d033c62d4..2f76ed8f65a 100644 --- a/homeassistant/components/cisco_webex_teams/notify.py +++ b/homeassistant/components/cisco_webex_teams/notify.py @@ -14,7 +14,7 @@ from homeassistant.components.notify import ( ) from homeassistant.const import CONF_TOKEN from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/citybikes/sensor.py b/homeassistant/components/citybikes/sensor.py index 6cd401989c8..e08b651dd70 100644 --- a/homeassistant/components/citybikes/sensor.py +++ b/homeassistant/components/citybikes/sensor.py @@ -28,8 +28,8 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_time_interval diff --git a/homeassistant/components/clementine/media_player.py b/homeassistant/components/clementine/media_player.py index 233ffc840c0..04c1305cb13 100644 --- a/homeassistant/components/clementine/media_player.py +++ b/homeassistant/components/clementine/media_player.py @@ -17,7 +17,7 @@ from homeassistant.components.media_player import ( ) from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST, CONF_NAME, CONF_PORT from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/clickatell/notify.py b/homeassistant/components/clickatell/notify.py index c8d96d48faf..9a5a5160ada 100644 --- a/homeassistant/components/clickatell/notify.py +++ b/homeassistant/components/clickatell/notify.py @@ -15,7 +15,7 @@ from homeassistant.components.notify import ( ) from homeassistant.const import CONF_API_KEY, CONF_RECIPIENT from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/clicksend/notify.py b/homeassistant/components/clicksend/notify.py index d00d7b413cc..53f16875d6f 100644 --- a/homeassistant/components/clicksend/notify.py +++ b/homeassistant/components/clicksend/notify.py @@ -22,7 +22,7 @@ from homeassistant.const import ( CONTENT_TYPE_JSON, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/clicksend_tts/notify.py b/homeassistant/components/clicksend_tts/notify.py index 6b5f2040448..5a08ccd7988 100644 --- a/homeassistant/components/clicksend_tts/notify.py +++ b/homeassistant/components/clicksend_tts/notify.py @@ -21,7 +21,7 @@ from homeassistant.const import ( CONTENT_TYPE_JSON, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/cmus/media_player.py b/homeassistant/components/cmus/media_player.py index d55e9ca8f0b..a1f303809d0 100644 --- a/homeassistant/components/cmus/media_player.py +++ b/homeassistant/components/cmus/media_player.py @@ -17,7 +17,7 @@ from homeassistant.components.media_player import ( ) from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_PORT from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/co2signal/config_flow.py b/homeassistant/components/co2signal/config_flow.py index 0d357cce199..530496811d9 100644 --- a/homeassistant/components/co2signal/config_flow.py +++ b/homeassistant/components/co2signal/config_flow.py @@ -20,8 +20,8 @@ from homeassistant.const import ( CONF_LATITUDE, CONF_LONGITUDE, ) +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.selector import ( SelectSelector, SelectSelectorConfig, diff --git a/homeassistant/components/coinbase/config_flow.py b/homeassistant/components/coinbase/config_flow.py index 2b58f2b2f37..3234ec29679 100644 --- a/homeassistant/components/coinbase/config_flow.py +++ b/homeassistant/components/coinbase/config_flow.py @@ -15,7 +15,7 @@ from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFl from homeassistant.const import CONF_API_KEY, CONF_API_TOKEN, CONF_API_VERSION from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from . import CoinbaseConfigEntry, get_accounts from .const import ( diff --git a/homeassistant/components/color_extractor/__init__.py b/homeassistant/components/color_extractor/__init__.py index 81cd55564b9..775adb6a7d5 100644 --- a/homeassistant/components/color_extractor/__init__.py +++ b/homeassistant/components/color_extractor/__init__.py @@ -17,8 +17,7 @@ from homeassistant.components.light import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import SERVICE_TURN_ON as LIGHT_SERVICE_TURN_ON from homeassistant.core import HomeAssistant, ServiceCall -from homeassistant.helpers import aiohttp_client -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import aiohttp_client, config_validation as cv from homeassistant.helpers.typing import ConfigType from .const import ATTR_PATH, ATTR_URL, DOMAIN, SERVICE_TURN_ON diff --git a/homeassistant/components/comed_hourly_pricing/sensor.py b/homeassistant/components/comed_hourly_pricing/sensor.py index b47255828e8..ac217eeb353 100644 --- a/homeassistant/components/comed_hourly_pricing/sensor.py +++ b/homeassistant/components/comed_hourly_pricing/sensor.py @@ -17,8 +17,8 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_NAME, CONF_OFFSET, CURRENCY_CENT, UnitOfEnergy from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/comelit/config_flow.py b/homeassistant/components/comelit/config_flow.py index 46fc13796a0..f29cc62136b 100644 --- a/homeassistant/components/comelit/config_flow.py +++ b/homeassistant/components/comelit/config_flow.py @@ -18,7 +18,7 @@ from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_PIN, CONF_PORT, CONF_TYPE from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from .const import _LOGGER, DEFAULT_PORT, DEVICE_TYPE_LIST, DOMAIN diff --git a/homeassistant/components/comfoconnect/__init__.py b/homeassistant/components/comfoconnect/__init__.py index 4e0671fd134..b28f7a748e1 100644 --- a/homeassistant/components/comfoconnect/__init__.py +++ b/homeassistant/components/comfoconnect/__init__.py @@ -14,8 +14,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import Event, HomeAssistant -from homeassistant.helpers import discovery -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.dispatcher import dispatcher_send from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/comfoconnect/sensor.py b/homeassistant/components/comfoconnect/sensor.py index 6a15e37f3f1..fbe958e6d67 100644 --- a/homeassistant/components/comfoconnect/sensor.py +++ b/homeassistant/components/comfoconnect/sensor.py @@ -48,7 +48,7 @@ from homeassistant.const import ( UnitOfVolumeFlowRate, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/command_line/__init__.py b/homeassistant/components/command_line/__init__.py index 2440fcde76c..1832e83e7dd 100644 --- a/homeassistant/components/command_line/__init__.py +++ b/homeassistant/components/command_line/__init__.py @@ -52,8 +52,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import Event, HomeAssistant, ServiceCall -from homeassistant.helpers import discovery -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.entity_platform import async_get_platforms from homeassistant.helpers.reload import async_integration_yaml_config from homeassistant.helpers.service import async_register_admin_service diff --git a/homeassistant/components/concord232/alarm_control_panel.py b/homeassistant/components/concord232/alarm_control_panel.py index 02453b56376..61cf2aebb31 100644 --- a/homeassistant/components/concord232/alarm_control_panel.py +++ b/homeassistant/components/concord232/alarm_control_panel.py @@ -18,7 +18,7 @@ from homeassistant.components.alarm_control_panel import ( ) from homeassistant.const import CONF_CODE, CONF_HOST, CONF_MODE, CONF_NAME, CONF_PORT from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/concord232/binary_sensor.py b/homeassistant/components/concord232/binary_sensor.py index 2b86e72e63c..a60cf31a646 100644 --- a/homeassistant/components/concord232/binary_sensor.py +++ b/homeassistant/components/concord232/binary_sensor.py @@ -17,10 +17,10 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/counter/__init__.py b/homeassistant/components/counter/__init__.py index f0a14aa7951..e84a92328b2 100644 --- a/homeassistant/components/counter/__init__.py +++ b/homeassistant/components/counter/__init__.py @@ -16,8 +16,7 @@ from homeassistant.const import ( CONF_NAME, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import collection -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import collection, config_validation as cv from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.storage import Store diff --git a/homeassistant/components/cppm_tracker/device_tracker.py b/homeassistant/components/cppm_tracker/device_tracker.py index b6fdc0a8889..3b2682d4e32 100644 --- a/homeassistant/components/cppm_tracker/device_tracker.py +++ b/homeassistant/components/cppm_tracker/device_tracker.py @@ -15,7 +15,7 @@ from homeassistant.components.device_tracker import ( ) from homeassistant.const import CONF_API_KEY, CONF_CLIENT_ID, CONF_HOST from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType SCAN_INTERVAL = timedelta(seconds=120) diff --git a/homeassistant/components/cups/sensor.py b/homeassistant/components/cups/sensor.py index 7f45e99f93d..701bad3f104 100644 --- a/homeassistant/components/cups/sensor.py +++ b/homeassistant/components/cups/sensor.py @@ -16,7 +16,7 @@ from homeassistant.components.sensor import ( from homeassistant.const import CONF_HOST, CONF_PORT, PERCENTAGE from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/currencylayer/sensor.py b/homeassistant/components/currencylayer/sensor.py index 01dec10efe0..7c985b12ba4 100644 --- a/homeassistant/components/currencylayer/sensor.py +++ b/homeassistant/components/currencylayer/sensor.py @@ -14,7 +14,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_API_KEY, CONF_BASE, CONF_NAME, CONF_QUOTE from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/danfoss_air/__init__.py b/homeassistant/components/danfoss_air/__init__.py index 5e4880705d5..d7c16d5da09 100644 --- a/homeassistant/components/danfoss_air/__init__.py +++ b/homeassistant/components/danfoss_air/__init__.py @@ -9,8 +9,7 @@ import voluptuous as vol from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import discovery -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.typing import ConfigType from homeassistant.util import Throttle diff --git a/homeassistant/components/datadog/__init__.py b/homeassistant/components/datadog/__init__.py index 2d550e48e2f..fa852399b09 100644 --- a/homeassistant/components/datadog/__init__.py +++ b/homeassistant/components/datadog/__init__.py @@ -14,8 +14,7 @@ from homeassistant.const import ( STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import state as state_helper -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, state as state_helper from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/ddwrt/device_tracker.py b/homeassistant/components/ddwrt/device_tracker.py index d72496e4d1e..e93b7e14e05 100644 --- a/homeassistant/components/ddwrt/device_tracker.py +++ b/homeassistant/components/ddwrt/device_tracker.py @@ -22,7 +22,7 @@ from homeassistant.const import ( CONF_VERIFY_SSL, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/debugpy/__init__.py b/homeassistant/components/debugpy/__init__.py index 5caf517a483..cef98211d9e 100644 --- a/homeassistant/components/debugpy/__init__.py +++ b/homeassistant/components/debugpy/__init__.py @@ -11,7 +11,7 @@ import voluptuous as vol from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant, ServiceCall -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.service import async_register_admin_service from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/deconz/sensor.py b/homeassistant/components/deconz/sensor.py index 576d356bca9..3003fb1008d 100644 --- a/homeassistant/components/deconz/sensor.py +++ b/homeassistant/components/deconz/sensor.py @@ -52,7 +52,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import DeconzConfigEntry from .const import ATTR_DARK, ATTR_ON diff --git a/homeassistant/components/decora/light.py b/homeassistant/components/decora/light.py index cef7b98a2c1..a7d14b83aca 100644 --- a/homeassistant/components/decora/light.py +++ b/homeassistant/components/decora/light.py @@ -21,7 +21,7 @@ from homeassistant.components.light import ( LightEntity, ) from homeassistant.const import CONF_API_KEY, CONF_DEVICES, CONF_NAME -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv if TYPE_CHECKING: from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/decora_wifi/light.py b/homeassistant/components/decora_wifi/light.py index 63ab5c2bf02..9ad1d9ced04 100644 --- a/homeassistant/components/decora_wifi/light.py +++ b/homeassistant/components/decora_wifi/light.py @@ -22,7 +22,7 @@ from homeassistant.components.light import ( ) from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/delijn/sensor.py b/homeassistant/components/delijn/sensor.py index 017a4c5b2fa..7f94f272c0d 100644 --- a/homeassistant/components/delijn/sensor.py +++ b/homeassistant/components/delijn/sensor.py @@ -16,8 +16,8 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/deluge/config_flow.py b/homeassistant/components/deluge/config_flow.py index d58f23464d1..19afe26e8f9 100644 --- a/homeassistant/components/deluge/config_flow.py +++ b/homeassistant/components/deluge/config_flow.py @@ -11,7 +11,7 @@ import voluptuous as vol from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from .const import ( CONF_WEB_PORT, diff --git a/homeassistant/components/denon/media_player.py b/homeassistant/components/denon/media_player.py index 2f46cd42294..9e7cebe0702 100644 --- a/homeassistant/components/denon/media_player.py +++ b/homeassistant/components/denon/media_player.py @@ -15,7 +15,7 @@ from homeassistant.components.media_player import ( ) from homeassistant.const import CONF_HOST, CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/device_sun_light_trigger/__init__.py b/homeassistant/components/device_sun_light_trigger/__init__.py index 6781b9afaf7..ee427eb1ba6 100644 --- a/homeassistant/components/device_sun_light_trigger/__init__.py +++ b/homeassistant/components/device_sun_light_trigger/__init__.py @@ -29,14 +29,14 @@ from homeassistant.const import ( SUN_EVENT_SUNSET, ) from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.event import ( async_track_point_in_utc_time, async_track_state_change_event, ) from homeassistant.helpers.sun import get_astral_event_next, is_up from homeassistant.helpers.typing import ConfigType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util DOMAIN = "device_sun_light_trigger" CONF_DEVICE_GROUP = "device_group" diff --git a/homeassistant/components/devolo_home_network/image.py b/homeassistant/components/devolo_home_network/image.py index 240686ed3bb..91e8dd83b7d 100644 --- a/homeassistant/components/devolo_home_network/image.py +++ b/homeassistant/components/devolo_home_network/image.py @@ -13,7 +13,7 @@ from homeassistant.components.image import ImageEntity, ImageEntityDescription from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import DevoloHomeNetworkConfigEntry from .const import IMAGE_GUEST_WIFI, SWITCH_GUEST_WIFI diff --git a/homeassistant/components/digital_ocean/__init__.py b/homeassistant/components/digital_ocean/__init__.py index e5b62d430b6..306ddc8e9a5 100644 --- a/homeassistant/components/digital_ocean/__init__.py +++ b/homeassistant/components/digital_ocean/__init__.py @@ -8,7 +8,7 @@ import voluptuous as vol from homeassistant.const import CONF_ACCESS_TOKEN, Platform from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from homeassistant.util import Throttle diff --git a/homeassistant/components/digital_ocean/binary_sensor.py b/homeassistant/components/digital_ocean/binary_sensor.py index 0d4b31faa2c..f0bb6eba049 100644 --- a/homeassistant/components/digital_ocean/binary_sensor.py +++ b/homeassistant/components/digital_ocean/binary_sensor.py @@ -12,7 +12,7 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/digital_ocean/switch.py b/homeassistant/components/digital_ocean/switch.py index 856c9301cfd..409fa63c1c2 100644 --- a/homeassistant/components/digital_ocean/switch.py +++ b/homeassistant/components/digital_ocean/switch.py @@ -12,7 +12,7 @@ from homeassistant.components.switch import ( SwitchEntity, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/discogs/sensor.py b/homeassistant/components/discogs/sensor.py index 3cea6ec4dac..3c64b9020c3 100644 --- a/homeassistant/components/discogs/sensor.py +++ b/homeassistant/components/discogs/sensor.py @@ -16,8 +16,8 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_MONITORED_CONDITIONS, CONF_NAME, CONF_TOKEN from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import SERVER_SOFTWARE -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/dlib_face_identify/image_processing.py b/homeassistant/components/dlib_face_identify/image_processing.py index e17f892a7fe..fee9f8dab3c 100644 --- a/homeassistant/components/dlib_face_identify/image_processing.py +++ b/homeassistant/components/dlib_face_identify/image_processing.py @@ -15,7 +15,7 @@ from homeassistant.components.image_processing import ( ) from homeassistant.const import ATTR_NAME, CONF_ENTITY_ID, CONF_NAME, CONF_SOURCE from homeassistant.core import HomeAssistant, split_entity_id -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/dnsip/config_flow.py b/homeassistant/components/dnsip/config_flow.py index 8c2cfa5e556..9e98178e718 100644 --- a/homeassistant/components/dnsip/config_flow.py +++ b/homeassistant/components/dnsip/config_flow.py @@ -18,7 +18,7 @@ from homeassistant.config_entries import ( ) from homeassistant.const import CONF_NAME, CONF_PORT from homeassistant.core import callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from .const import ( CONF_HOSTNAME, diff --git a/homeassistant/components/dominos/__init__.py b/homeassistant/components/dominos/__init__.py index 9b11b667e84..6fccecfec5c 100644 --- a/homeassistant/components/dominos/__init__.py +++ b/homeassistant/components/dominos/__init__.py @@ -9,7 +9,7 @@ import voluptuous as vol from homeassistant.components import http from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/doods/image_processing.py b/homeassistant/components/doods/image_processing.py index 7b055c6dd05..bcc6e7a8050 100644 --- a/homeassistant/components/doods/image_processing.py +++ b/homeassistant/components/doods/image_processing.py @@ -25,8 +25,7 @@ from homeassistant.const import ( CONF_URL, ) from homeassistant.core import HomeAssistant, split_entity_id -from homeassistant.helpers import template -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, template from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util.pil import draw_box diff --git a/homeassistant/components/doorbird/__init__.py b/homeassistant/components/doorbird/__init__.py index c943fa68766..5090f309c49 100644 --- a/homeassistant/components/doorbird/__init__.py +++ b/homeassistant/components/doorbird/__init__.py @@ -17,9 +17,8 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers import issue_registry as ir +from homeassistant.helpers import config_validation as cv, issue_registry as ir from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType from .const import CONF_EVENTS, DOMAIN, PLATFORMS diff --git a/homeassistant/components/doorbird/camera.py b/homeassistant/components/doorbird/camera.py index 640d6630c18..45f37527ac1 100644 --- a/homeassistant/components/doorbird/camera.py +++ b/homeassistant/components/doorbird/camera.py @@ -10,7 +10,7 @@ import aiohttp from homeassistant.components.camera import Camera, CameraEntityFeature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .entity import DoorBirdEntity from .models import DoorBirdConfigEntry, DoorBirdData diff --git a/homeassistant/components/dovado/__init__.py b/homeassistant/components/dovado/__init__.py index 5f63bbd0b2b..0a5fb602a08 100644 --- a/homeassistant/components/dovado/__init__.py +++ b/homeassistant/components/dovado/__init__.py @@ -15,7 +15,7 @@ from homeassistant.const import ( DEVICE_DEFAULT_NAME, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from homeassistant.util import Throttle diff --git a/homeassistant/components/dovado/sensor.py b/homeassistant/components/dovado/sensor.py index 013b51bfc8f..e35fdeb2dc0 100644 --- a/homeassistant/components/dovado/sensor.py +++ b/homeassistant/components/dovado/sensor.py @@ -16,7 +16,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_SENSORS, PERCENTAGE, UnitOfInformation from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/downloader/__init__.py b/homeassistant/components/downloader/__init__.py index 75e1103a712..1a45886879a 100644 --- a/homeassistant/components/downloader/__init__.py +++ b/homeassistant/components/downloader/__init__.py @@ -12,7 +12,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, ServiceCall -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.service import async_register_admin_service from homeassistant.util import raise_if_invalid_filename, raise_if_invalid_path diff --git a/homeassistant/components/dremel_3d_printer/config_flow.py b/homeassistant/components/dremel_3d_printer/config_flow.py index 913180db0f7..0cb5c7d9cfc 100644 --- a/homeassistant/components/dremel_3d_printer/config_flow.py +++ b/homeassistant/components/dremel_3d_printer/config_flow.py @@ -11,7 +11,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from .const import DOMAIN, LOGGER diff --git a/homeassistant/components/dublin_bus_transport/sensor.py b/homeassistant/components/dublin_bus_transport/sensor.py index 5fc3453fca6..8720be7330f 100644 --- a/homeassistant/components/dublin_bus_transport/sensor.py +++ b/homeassistant/components/dublin_bus_transport/sensor.py @@ -20,10 +20,10 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_NAME, UnitOfTime from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util _RESOURCE = "https://data.dublinked.ie/cgi-bin/rtpi/realtimebusinformation" diff --git a/homeassistant/components/duckdns/__init__.py b/homeassistant/components/duckdns/__init__.py index 557178de571..a49bfde2785 100644 --- a/homeassistant/components/duckdns/__init__.py +++ b/homeassistant/components/duckdns/__init__.py @@ -18,8 +18,8 @@ from homeassistant.core import ( ServiceCall, callback, ) +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.event import async_call_later from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass diff --git a/homeassistant/components/dwd_weather_warnings/config_flow.py b/homeassistant/components/dwd_weather_warnings/config_flow.py index f148f4e05ac..064cf52d04d 100644 --- a/homeassistant/components/dwd_weather_warnings/config_flow.py +++ b/homeassistant/components/dwd_weather_warnings/config_flow.py @@ -8,8 +8,7 @@ from dwdwfsapi import DwdWeatherWarningsAPI import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult -from homeassistant.helpers import entity_registry as er -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.selector import EntitySelector, EntitySelectorConfig from .const import CONF_REGION_DEVICE_TRACKER, CONF_REGION_IDENTIFIER, DOMAIN diff --git a/homeassistant/components/dweet/__init__.py b/homeassistant/components/dweet/__init__.py index c1232bab2cf..b43ce3db8c1 100644 --- a/homeassistant/components/dweet/__init__.py +++ b/homeassistant/components/dweet/__init__.py @@ -14,8 +14,7 @@ from homeassistant.const import ( STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import state as state_helper -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, state as state_helper from homeassistant.helpers.typing import ConfigType from homeassistant.util import Throttle diff --git a/homeassistant/components/dweet/sensor.py b/homeassistant/components/dweet/sensor.py index 10109189eb0..6110f17f826 100644 --- a/homeassistant/components/dweet/sensor.py +++ b/homeassistant/components/dweet/sensor.py @@ -20,7 +20,7 @@ from homeassistant.const import ( CONF_VALUE_TEMPLATE, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType From 3472e0e370ba3f50cb4099a012dc2a0ec1d98ea6 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 29 Jan 2025 11:37:48 +0100 Subject: [PATCH 0218/3148] Standardize homeassistant imports in component (a-b) (#136821) --- homeassistant/components/accuweather/config_flow.py | 2 +- homeassistant/components/acer_projector/switch.py | 2 +- homeassistant/components/actiontec/device_tracker.py | 2 +- homeassistant/components/ads/__init__.py | 2 +- homeassistant/components/ads/binary_sensor.py | 2 +- homeassistant/components/ads/cover.py | 2 +- homeassistant/components/ads/light.py | 2 +- homeassistant/components/ads/select.py | 2 +- homeassistant/components/ads/sensor.py | 2 +- homeassistant/components/ads/switch.py | 2 +- homeassistant/components/ads/valve.py | 2 +- homeassistant/components/aftership/const.py | 2 +- homeassistant/components/aftership/sensor.py | 2 +- homeassistant/components/airly/config_flow.py | 2 +- homeassistant/components/airnow/config_flow.py | 2 +- homeassistant/components/alarmdecoder/alarm_control_panel.py | 3 +-- homeassistant/components/alert/__init__.py | 2 +- homeassistant/components/alpha_vantage/sensor.py | 2 +- homeassistant/components/amazon_polly/tts.py | 2 +- homeassistant/components/amcrest/__init__.py | 3 +-- homeassistant/components/ampio/air_quality.py | 2 +- homeassistant/components/analytics/__init__.py | 2 +- homeassistant/components/analytics/analytics.py | 2 +- homeassistant/components/anel_pwrctrl/switch.py | 2 +- homeassistant/components/anthemav/config_flow.py | 2 +- homeassistant/components/apache_kafka/__init__.py | 2 +- homeassistant/components/apcupsd/config_flow.py | 3 +-- homeassistant/components/api/__init__.py | 2 +- homeassistant/components/apple_tv/media_player.py | 2 +- homeassistant/components/apprise/notify.py | 2 +- homeassistant/components/aprilaire/config_flow.py | 2 +- homeassistant/components/aprs/device_tracker.py | 2 +- homeassistant/components/apsystems/config_flow.py | 2 +- homeassistant/components/aqualogic/sensor.py | 2 +- homeassistant/components/aqualogic/switch.py | 2 +- homeassistant/components/aquostv/media_player.py | 2 +- homeassistant/components/arest/binary_sensor.py | 2 +- homeassistant/components/arest/sensor.py | 2 +- homeassistant/components/arest/switch.py | 2 +- homeassistant/components/arris_tg2492lg/device_tracker.py | 2 +- homeassistant/components/aruba/device_tracker.py | 2 +- homeassistant/components/aten_pe/switch.py | 2 +- homeassistant/components/atome/sensor.py | 2 +- homeassistant/components/august/lock.py | 2 +- homeassistant/components/avion/light.py | 2 +- homeassistant/components/azure_event_hub/__init__.py | 2 +- homeassistant/components/azure_service_bus/notify.py | 2 +- homeassistant/components/baidu/tts.py | 2 +- homeassistant/components/balboa/__init__.py | 2 +- homeassistant/components/bayesian/binary_sensor.py | 3 +-- homeassistant/components/bbox/device_tracker.py | 5 ++--- homeassistant/components/bbox/sensor.py | 2 +- homeassistant/components/beewi_smartclim/sensor.py | 2 +- homeassistant/components/bitcoin/sensor.py | 2 +- homeassistant/components/bizkaibus/sensor.py | 2 +- homeassistant/components/blackbird/media_player.py | 2 +- homeassistant/components/blink/camera.py | 3 +-- homeassistant/components/blockchain/sensor.py | 2 +- homeassistant/components/bluesound/media_player.py | 2 +- .../components/bluetooth_le_tracker/device_tracker.py | 4 ++-- homeassistant/components/bluetooth_tracker/device_tracker.py | 2 +- homeassistant/components/bmw_connected_drive/__init__.py | 2 +- homeassistant/components/broadlink/switch.py | 2 +- homeassistant/components/bt_home_hub_5/device_tracker.py | 2 +- homeassistant/components/bt_smarthub/device_tracker.py | 2 +- homeassistant/components/buienradar/config_flow.py | 3 +-- 66 files changed, 68 insertions(+), 75 deletions(-) diff --git a/homeassistant/components/accuweather/config_flow.py b/homeassistant/components/accuweather/config_flow.py index 71f7de89528..3e65374f391 100644 --- a/homeassistant/components/accuweather/config_flow.py +++ b/homeassistant/components/accuweather/config_flow.py @@ -12,8 +12,8 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from .const import DOMAIN diff --git a/homeassistant/components/acer_projector/switch.py b/homeassistant/components/acer_projector/switch.py index c1463cd9a08..846164202d8 100644 --- a/homeassistant/components/acer_projector/switch.py +++ b/homeassistant/components/acer_projector/switch.py @@ -22,7 +22,7 @@ from homeassistant.const import ( STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/actiontec/device_tracker.py b/homeassistant/components/actiontec/device_tracker.py index 273ca6a772f..41876ce478f 100644 --- a/homeassistant/components/actiontec/device_tracker.py +++ b/homeassistant/components/actiontec/device_tracker.py @@ -15,7 +15,7 @@ from homeassistant.components.device_tracker import ( ) from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from .const import LEASES_REGEX diff --git a/homeassistant/components/ads/__init__.py b/homeassistant/components/ads/__init__.py index 892390a91eb..da34bd36e2c 100644 --- a/homeassistant/components/ads/__init__.py +++ b/homeassistant/components/ads/__init__.py @@ -12,7 +12,7 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, ) from homeassistant.core import HomeAssistant, ServiceCall -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from .const import CONF_ADS_VAR, DATA_ADS, DOMAIN, AdsType diff --git a/homeassistant/components/ads/binary_sensor.py b/homeassistant/components/ads/binary_sensor.py index 72a12506dc1..560d090caf0 100644 --- a/homeassistant/components/ads/binary_sensor.py +++ b/homeassistant/components/ads/binary_sensor.py @@ -13,7 +13,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import CONF_DEVICE_CLASS, CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/ads/cover.py b/homeassistant/components/ads/cover.py index c7b0f4f2f8a..15d5b3a7d09 100644 --- a/homeassistant/components/ads/cover.py +++ b/homeassistant/components/ads/cover.py @@ -17,7 +17,7 @@ from homeassistant.components.cover import ( ) from homeassistant.const import CONF_DEVICE_CLASS, CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/ads/light.py b/homeassistant/components/ads/light.py index 5ea4868bf11..3de223e5fc4 100644 --- a/homeassistant/components/ads/light.py +++ b/homeassistant/components/ads/light.py @@ -15,7 +15,7 @@ from homeassistant.components.light import ( ) from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/ads/select.py b/homeassistant/components/ads/select.py index 39f813dec27..e31e089d669 100644 --- a/homeassistant/components/ads/select.py +++ b/homeassistant/components/ads/select.py @@ -11,7 +11,7 @@ from homeassistant.components.select import ( ) from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/ads/sensor.py b/homeassistant/components/ads/sensor.py index 09579161a94..0fd1b84ffd1 100644 --- a/homeassistant/components/ads/sensor.py +++ b/homeassistant/components/ads/sensor.py @@ -15,7 +15,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_DEVICE_CLASS, CONF_NAME, CONF_UNIT_OF_MEASUREMENT from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateType diff --git a/homeassistant/components/ads/switch.py b/homeassistant/components/ads/switch.py index 0412a127c95..2506757e9d2 100644 --- a/homeassistant/components/ads/switch.py +++ b/homeassistant/components/ads/switch.py @@ -13,7 +13,7 @@ from homeassistant.components.switch import ( ) from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/ads/valve.py b/homeassistant/components/ads/valve.py index b94215ec9ea..a251e14b3c3 100644 --- a/homeassistant/components/ads/valve.py +++ b/homeassistant/components/ads/valve.py @@ -14,7 +14,7 @@ from homeassistant.components.valve import ( ) from homeassistant.const import CONF_DEVICE_CLASS, CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/aftership/const.py b/homeassistant/components/aftership/const.py index 385570e145f..c5d7b00a942 100644 --- a/homeassistant/components/aftership/const.py +++ b/homeassistant/components/aftership/const.py @@ -7,7 +7,7 @@ from typing import Final import voluptuous as vol -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv DOMAIN: Final = "aftership" diff --git a/homeassistant/components/aftership/sensor.py b/homeassistant/components/aftership/sensor.py index c019634197d..085be2499d4 100644 --- a/homeassistant/components/aftership/sensor.py +++ b/homeassistant/components/aftership/sensor.py @@ -9,7 +9,7 @@ from pyaftership import AfterShip, AfterShipException from homeassistant.components.sensor import SensorEntity from homeassistant.core import HomeAssistant, ServiceCall -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, diff --git a/homeassistant/components/airly/config_flow.py b/homeassistant/components/airly/config_flow.py index 2811156ac90..de60ef84efa 100644 --- a/homeassistant/components/airly/config_flow.py +++ b/homeassistant/components/airly/config_flow.py @@ -13,8 +13,8 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from .const import CONF_USE_NEAREST, DOMAIN, NO_AIRLY_SENSORS diff --git a/homeassistant/components/airnow/config_flow.py b/homeassistant/components/airnow/config_flow.py index d0ab16e9758..7cd113125a8 100644 --- a/homeassistant/components/airnow/config_flow.py +++ b/homeassistant/components/airnow/config_flow.py @@ -18,8 +18,8 @@ from homeassistant.config_entries import ( from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_RADIUS from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from .const import DOMAIN diff --git a/homeassistant/components/alarmdecoder/alarm_control_panel.py b/homeassistant/components/alarmdecoder/alarm_control_panel.py index cf72133ea12..d7092bbe1c4 100644 --- a/homeassistant/components/alarmdecoder/alarm_control_panel.py +++ b/homeassistant/components/alarmdecoder/alarm_control_panel.py @@ -12,8 +12,7 @@ from homeassistant.components.alarm_control_panel import ( ) from homeassistant.const import ATTR_CODE from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_platform -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback diff --git a/homeassistant/components/alert/__init__.py b/homeassistant/components/alert/__init__.py index 12341c158c0..b6ce87941f6 100644 --- a/homeassistant/components/alert/__init__.py +++ b/homeassistant/components/alert/__init__.py @@ -15,7 +15,7 @@ from homeassistant.const import ( STATE_ON, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/alpha_vantage/sensor.py b/homeassistant/components/alpha_vantage/sensor.py index 506cb41659a..48d3ae6f526 100644 --- a/homeassistant/components/alpha_vantage/sensor.py +++ b/homeassistant/components/alpha_vantage/sensor.py @@ -16,7 +16,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_API_KEY, CONF_CURRENCY, CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/amazon_polly/tts.py b/homeassistant/components/amazon_polly/tts.py index 62852848a9c..985b3b6dd7c 100644 --- a/homeassistant/components/amazon_polly/tts.py +++ b/homeassistant/components/amazon_polly/tts.py @@ -22,7 +22,7 @@ from homeassistant.generated.amazon_polly import ( SUPPORTED_REGIONS, SUPPORTED_VOICES, ) -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import ( diff --git a/homeassistant/components/amcrest/__init__.py b/homeassistant/components/amcrest/__init__.py index 624e0145b86..313d3263932 100644 --- a/homeassistant/components/amcrest/__init__.py +++ b/homeassistant/components/amcrest/__init__.py @@ -37,8 +37,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import Unauthorized, UnknownUser -from homeassistant.helpers import discovery -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.dispatcher import async_dispatcher_send, dispatcher_send from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.service import async_extract_entity_ids diff --git a/homeassistant/components/ampio/air_quality.py b/homeassistant/components/ampio/air_quality.py index 05581df6371..ce2830d5b14 100644 --- a/homeassistant/components/ampio/air_quality.py +++ b/homeassistant/components/ampio/air_quality.py @@ -14,8 +14,8 @@ from homeassistant.components.air_quality import ( ) from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import Throttle diff --git a/homeassistant/components/analytics/__init__.py b/homeassistant/components/analytics/__init__.py index 9bcddcb868f..0df3b8138e2 100644 --- a/homeassistant/components/analytics/__init__.py +++ b/homeassistant/components/analytics/__init__.py @@ -7,7 +7,7 @@ import voluptuous as vol from homeassistant.components import websocket_api from homeassistant.const import EVENT_HOMEASSISTANT_STARTED from homeassistant.core import Event, HassJob, HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.event import async_call_later, async_track_time_interval from homeassistant.helpers.typing import ConfigType from homeassistant.util.hass_dict import HassKey diff --git a/homeassistant/components/analytics/analytics.py b/homeassistant/components/analytics/analytics.py index 9260642a58f..9339e2986e5 100644 --- a/homeassistant/components/analytics/analytics.py +++ b/homeassistant/components/analytics/analytics.py @@ -11,6 +11,7 @@ import uuid import aiohttp +from homeassistant import config as conf_util from homeassistant.components import hassio from homeassistant.components.api import ATTR_INSTALLATION_TYPE from homeassistant.components.automation import DOMAIN as AUTOMATION_DOMAIN @@ -22,7 +23,6 @@ from homeassistant.components.recorder import ( DOMAIN as RECORDER_DOMAIN, get_instance as get_recorder_instance, ) -import homeassistant.config as conf_util from homeassistant.config_entries import SOURCE_IGNORE from homeassistant.const import ATTR_DOMAIN, __version__ as HA_VERSION from homeassistant.core import HomeAssistant, callback diff --git a/homeassistant/components/anel_pwrctrl/switch.py b/homeassistant/components/anel_pwrctrl/switch.py index 6b27a61e065..97691c8b028 100644 --- a/homeassistant/components/anel_pwrctrl/switch.py +++ b/homeassistant/components/anel_pwrctrl/switch.py @@ -15,7 +15,7 @@ from homeassistant.components.switch import ( ) from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import Throttle diff --git a/homeassistant/components/anthemav/config_flow.py b/homeassistant/components/anthemav/config_flow.py index 400ac6d5899..fe9c6513041 100644 --- a/homeassistant/components/anthemav/config_flow.py +++ b/homeassistant/components/anthemav/config_flow.py @@ -12,7 +12,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_MAC, CONF_MODEL, CONF_PORT -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.device_registry import format_mac from .const import DEFAULT_NAME, DEFAULT_PORT, DEVICE_TIMEOUT_SECONDS, DOMAIN diff --git a/homeassistant/components/apache_kafka/__init__.py b/homeassistant/components/apache_kafka/__init__.py index 68d3f58a63a..40f71ec4e4b 100644 --- a/homeassistant/components/apache_kafka/__init__.py +++ b/homeassistant/components/apache_kafka/__init__.py @@ -18,7 +18,7 @@ from homeassistant.const import ( EVENT_STATE_CHANGED, ) from homeassistant.core import Event, EventStateChangedData, HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entityfilter import FILTER_SCHEMA, EntityFilter from homeassistant.helpers.typing import ConfigType from homeassistant.util import ssl as ssl_util diff --git a/homeassistant/components/apcupsd/config_flow.py b/homeassistant/components/apcupsd/config_flow.py index 00f757a1fd7..b65c9c33265 100644 --- a/homeassistant/components/apcupsd/config_flow.py +++ b/homeassistant/components/apcupsd/config_flow.py @@ -10,8 +10,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_PORT -from homeassistant.helpers import selector -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, selector from .const import CONNECTION_TIMEOUT, DOMAIN from .coordinator import APCUPSdData diff --git a/homeassistant/components/api/__init__.py b/homeassistant/components/api/__init__.py index ba71fb0def1..d183d46a717 100644 --- a/homeassistant/components/api/__init__.py +++ b/homeassistant/components/api/__init__.py @@ -11,6 +11,7 @@ from aiohttp import web from aiohttp.web_exceptions import HTTPBadRequest import voluptuous as vol +from homeassistant import core as ha from homeassistant.auth.models import User from homeassistant.auth.permissions.const import POLICY_READ from homeassistant.components.http import ( @@ -36,7 +37,6 @@ from homeassistant.const import ( URL_API_STREAM, URL_API_TEMPLATE, ) -import homeassistant.core as ha from homeassistant.core import Event, EventStateChangedData, HomeAssistant from homeassistant.exceptions import ( InvalidEntityFormatError, diff --git a/homeassistant/components/apple_tv/media_player.py b/homeassistant/components/apple_tv/media_player.py index c6b71c64b4f..8a2336eea3b 100644 --- a/homeassistant/components/apple_tv/media_player.py +++ b/homeassistant/components/apple_tv/media_player.py @@ -40,7 +40,7 @@ from homeassistant.components.media_player import ( from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import AppleTvConfigEntry, AppleTVManager from .browse_media import build_app_list diff --git a/homeassistant/components/apprise/notify.py b/homeassistant/components/apprise/notify.py index eb4e21c127f..a2efcb577d3 100644 --- a/homeassistant/components/apprise/notify.py +++ b/homeassistant/components/apprise/notify.py @@ -17,7 +17,7 @@ from homeassistant.components.notify import ( ) from homeassistant.const import CONF_URL from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/aprilaire/config_flow.py b/homeassistant/components/aprilaire/config_flow.py index f6c33f75e53..0b4f9af3401 100644 --- a/homeassistant/components/aprilaire/config_flow.py +++ b/homeassistant/components/aprilaire/config_flow.py @@ -10,7 +10,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_PORT -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.device_registry import format_mac from .const import DOMAIN diff --git a/homeassistant/components/aprs/device_tracker.py b/homeassistant/components/aprs/device_tracker.py index fc23fc5e436..fc3dbcabfe8 100644 --- a/homeassistant/components/aprs/device_tracker.py +++ b/homeassistant/components/aprs/device_tracker.py @@ -26,7 +26,7 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, ) from homeassistant.core import Event, HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import slugify diff --git a/homeassistant/components/apsystems/config_flow.py b/homeassistant/components/apsystems/config_flow.py index 5f2f1393aa0..9be0b5f4cf7 100644 --- a/homeassistant/components/apsystems/config_flow.py +++ b/homeassistant/components/apsystems/config_flow.py @@ -8,8 +8,8 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_IP_ADDRESS, CONF_PORT +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from .const import DEFAULT_PORT, DOMAIN diff --git a/homeassistant/components/aqualogic/sensor.py b/homeassistant/components/aqualogic/sensor.py index 9c2ee9957af..e0cae5df162 100644 --- a/homeassistant/components/aqualogic/sensor.py +++ b/homeassistant/components/aqualogic/sensor.py @@ -19,7 +19,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/aqualogic/switch.py b/homeassistant/components/aqualogic/switch.py index ed0cc463263..667842a020c 100644 --- a/homeassistant/components/aqualogic/switch.py +++ b/homeassistant/components/aqualogic/switch.py @@ -13,7 +13,7 @@ from homeassistant.components.switch import ( ) from homeassistant.const import CONF_MONITORED_CONDITIONS from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/aquostv/media_player.py b/homeassistant/components/aquostv/media_player.py index 343cb6492da..90660028b83 100644 --- a/homeassistant/components/aquostv/media_player.py +++ b/homeassistant/components/aquostv/media_player.py @@ -24,7 +24,7 @@ from homeassistant.const import ( CONF_USERNAME, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/arest/binary_sensor.py b/homeassistant/components/arest/binary_sensor.py index 00d4d6bbf9b..a99ef049543 100644 --- a/homeassistant/components/arest/binary_sensor.py +++ b/homeassistant/components/arest/binary_sensor.py @@ -16,7 +16,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import CONF_DEVICE_CLASS, CONF_NAME, CONF_PIN, CONF_RESOURCE from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import Throttle diff --git a/homeassistant/components/arest/sensor.py b/homeassistant/components/arest/sensor.py index 8c68c13018b..6554704b230 100644 --- a/homeassistant/components/arest/sensor.py +++ b/homeassistant/components/arest/sensor.py @@ -22,7 +22,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import TemplateError -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import Throttle diff --git a/homeassistant/components/arest/switch.py b/homeassistant/components/arest/switch.py index bcdba36cb58..7539336c38b 100644 --- a/homeassistant/components/arest/switch.py +++ b/homeassistant/components/arest/switch.py @@ -15,7 +15,7 @@ from homeassistant.components.switch import ( ) from homeassistant.const import CONF_NAME, CONF_RESOURCE from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/arris_tg2492lg/device_tracker.py b/homeassistant/components/arris_tg2492lg/device_tracker.py index c3650587690..828528508ec 100644 --- a/homeassistant/components/arris_tg2492lg/device_tracker.py +++ b/homeassistant/components/arris_tg2492lg/device_tracker.py @@ -13,8 +13,8 @@ from homeassistant.components.device_tracker import ( ) from homeassistant.const import CONF_HOST, CONF_PASSWORD from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType DEFAULT_HOST = "192.168.178.1" diff --git a/homeassistant/components/aruba/device_tracker.py b/homeassistant/components/aruba/device_tracker.py index 911fab441e5..c2f0d44a6f8 100644 --- a/homeassistant/components/aruba/device_tracker.py +++ b/homeassistant/components/aruba/device_tracker.py @@ -16,7 +16,7 @@ from homeassistant.components.device_tracker import ( ) from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/aten_pe/switch.py b/homeassistant/components/aten_pe/switch.py index 39b18089284..30afab16011 100644 --- a/homeassistant/components/aten_pe/switch.py +++ b/homeassistant/components/aten_pe/switch.py @@ -16,7 +16,7 @@ from homeassistant.components.switch import ( from homeassistant.const import CONF_HOST, CONF_PORT, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/atome/sensor.py b/homeassistant/components/atome/sensor.py index fd8250e899f..a1254c1ff49 100644 --- a/homeassistant/components/atome/sensor.py +++ b/homeassistant/components/atome/sensor.py @@ -22,7 +22,7 @@ from homeassistant.const import ( UnitOfPower, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import Throttle diff --git a/homeassistant/components/august/lock.py b/homeassistant/components/august/lock.py index fe5d90371ad..c681cc98808 100644 --- a/homeassistant/components/august/lock.py +++ b/homeassistant/components/august/lock.py @@ -16,7 +16,7 @@ from homeassistant.const import ATTR_BATTERY_LEVEL from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import AugustConfigEntry, AugustData from .entity import AugustEntity diff --git a/homeassistant/components/avion/light.py b/homeassistant/components/avion/light.py index 687405e3064..5b9371e0e2b 100644 --- a/homeassistant/components/avion/light.py +++ b/homeassistant/components/avion/light.py @@ -23,7 +23,7 @@ from homeassistant.const import ( CONF_USERNAME, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/azure_event_hub/__init__.py b/homeassistant/components/azure_event_hub/__init__.py index bc9d34e728e..abe6cdfe15f 100644 --- a/homeassistant/components/azure_event_hub/__init__.py +++ b/homeassistant/components/azure_event_hub/__init__.py @@ -19,7 +19,7 @@ from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import MATCH_ALL from homeassistant.core import Event, HomeAssistant, State from homeassistant.exceptions import ConfigEntryNotReady -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entityfilter import FILTER_SCHEMA, EntityFilter from homeassistant.helpers.event import async_call_later from homeassistant.helpers.json import JSONEncoder diff --git a/homeassistant/components/azure_service_bus/notify.py b/homeassistant/components/azure_service_bus/notify.py index a0aa36804c3..83eb8076fef 100644 --- a/homeassistant/components/azure_service_bus/notify.py +++ b/homeassistant/components/azure_service_bus/notify.py @@ -23,7 +23,7 @@ from homeassistant.components.notify import ( ) from homeassistant.const import CONTENT_TYPE_JSON from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType CONF_CONNECTION_STRING = "connection_string" diff --git a/homeassistant/components/baidu/tts.py b/homeassistant/components/baidu/tts.py index cdb6697d143..064dfb8d24c 100644 --- a/homeassistant/components/baidu/tts.py +++ b/homeassistant/components/baidu/tts.py @@ -11,7 +11,7 @@ from homeassistant.components.tts import ( Provider, ) from homeassistant.const import CONF_API_KEY -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/balboa/__init__.py b/homeassistant/components/balboa/__init__.py index 7838db16820..c982d59d513 100644 --- a/homeassistant/components/balboa/__init__.py +++ b/homeassistant/components/balboa/__init__.py @@ -12,7 +12,7 @@ from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.event import async_track_time_interval -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import CONF_SYNC_TIME, DEFAULT_SYNC_TIME diff --git a/homeassistant/components/bayesian/binary_sensor.py b/homeassistant/components/bayesian/binary_sensor.py index 74e3db34b68..32f43983991 100644 --- a/homeassistant/components/bayesian/binary_sensor.py +++ b/homeassistant/components/bayesian/binary_sensor.py @@ -31,8 +31,7 @@ from homeassistant.const import ( ) from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback from homeassistant.exceptions import ConditionError, TemplateError -from homeassistant.helpers import condition -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import condition, config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import ( TrackTemplate, diff --git a/homeassistant/components/bbox/device_tracker.py b/homeassistant/components/bbox/device_tracker.py index 12174d395f7..18b62f2a506 100644 --- a/homeassistant/components/bbox/device_tracker.py +++ b/homeassistant/components/bbox/device_tracker.py @@ -16,10 +16,9 @@ from homeassistant.components.device_tracker import ( ) from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType -from homeassistant.util import Throttle -import homeassistant.util.dt as dt_util +from homeassistant.util import Throttle, dt as dt_util _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/bbox/sensor.py b/homeassistant/components/bbox/sensor.py index 72fa870efbf..fed059247d0 100644 --- a/homeassistant/components/bbox/sensor.py +++ b/homeassistant/components/bbox/sensor.py @@ -18,7 +18,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_MONITORED_VARIABLES, CONF_NAME, UnitOfDataRate from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import Throttle diff --git a/homeassistant/components/beewi_smartclim/sensor.py b/homeassistant/components/beewi_smartclim/sensor.py index 1c80f62e64f..3a0a6f21f98 100644 --- a/homeassistant/components/beewi_smartclim/sensor.py +++ b/homeassistant/components/beewi_smartclim/sensor.py @@ -12,7 +12,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_MAC, CONF_NAME, PERCENTAGE, UnitOfTemperature from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/bitcoin/sensor.py b/homeassistant/components/bitcoin/sensor.py index e4da2ddc2f4..cb7bc5a043b 100644 --- a/homeassistant/components/bitcoin/sensor.py +++ b/homeassistant/components/bitcoin/sensor.py @@ -15,7 +15,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_CURRENCY, CONF_DISPLAY_OPTIONS, UnitOfTime from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/bizkaibus/sensor.py b/homeassistant/components/bizkaibus/sensor.py index 3efddf0b0d7..085c0093073 100644 --- a/homeassistant/components/bizkaibus/sensor.py +++ b/homeassistant/components/bizkaibus/sensor.py @@ -13,7 +13,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_NAME, UnitOfTime from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/blackbird/media_player.py b/homeassistant/components/blackbird/media_player.py index 37672e98e0b..2d39512cbe0 100644 --- a/homeassistant/components/blackbird/media_player.py +++ b/homeassistant/components/blackbird/media_player.py @@ -22,7 +22,7 @@ from homeassistant.const import ( CONF_TYPE, ) from homeassistant.core import HomeAssistant, ServiceCall -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/blink/camera.py b/homeassistant/components/blink/camera.py index 56a84135a9b..e35dd20eea7 100644 --- a/homeassistant/components/blink/camera.py +++ b/homeassistant/components/blink/camera.py @@ -13,8 +13,7 @@ from homeassistant.components.camera import Camera from homeassistant.const import CONF_FILE_PATH, CONF_FILENAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError -from homeassistant.helpers import entity_platform -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity diff --git a/homeassistant/components/blockchain/sensor.py b/homeassistant/components/blockchain/sensor.py index 8ae091fa95e..a6aedb2c472 100644 --- a/homeassistant/components/blockchain/sensor.py +++ b/homeassistant/components/blockchain/sensor.py @@ -14,7 +14,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/bluesound/media_player.py b/homeassistant/components/bluesound/media_player.py index 12e2f537935..6bb3c101cd1 100644 --- a/homeassistant/components/bluesound/media_player.py +++ b/homeassistant/components/bluesound/media_player.py @@ -34,7 +34,7 @@ from homeassistant.helpers.dispatcher import ( ) from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import ATTR_BLUESOUND_GROUP, ATTR_MASTER, DOMAIN from .coordinator import BluesoundCoordinator diff --git a/homeassistant/components/bluetooth_le_tracker/device_tracker.py b/homeassistant/components/bluetooth_le_tracker/device_tracker.py index 25e620ff15d..25a1aa60a1d 100644 --- a/homeassistant/components/bluetooth_le_tracker/device_tracker.py +++ b/homeassistant/components/bluetooth_le_tracker/device_tracker.py @@ -24,10 +24,10 @@ from homeassistant.components.device_tracker.legacy import ( ) from homeassistant.const import CONF_SCAN_INTERVAL, EVENT_HOMEASSISTANT_STOP from homeassistant.core import Event, HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/bluetooth_tracker/device_tracker.py b/homeassistant/components/bluetooth_tracker/device_tracker.py index 1d64d31a248..17d166f2b32 100644 --- a/homeassistant/components/bluetooth_tracker/device_tracker.py +++ b/homeassistant/components/bluetooth_tracker/device_tracker.py @@ -27,7 +27,7 @@ from homeassistant.components.device_tracker.legacy import ( ) from homeassistant.const import CONF_DEVICE_ID from homeassistant.core import HomeAssistant, ServiceCall -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/bmw_connected_drive/__init__.py b/homeassistant/components/bmw_connected_drive/__init__.py index 05fa3e3cab0..287cb226b51 100644 --- a/homeassistant/components/bmw_connected_drive/__init__.py +++ b/homeassistant/components/bmw_connected_drive/__init__.py @@ -9,11 +9,11 @@ import voluptuous as vol from homeassistant.const import CONF_DEVICE_ID, CONF_ENTITY_ID, CONF_NAME, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import ( + config_validation as cv, device_registry as dr, discovery, entity_registry as er, ) -import homeassistant.helpers.config_validation as cv from .const import ATTR_VIN, CONF_READ_ONLY, DOMAIN from .coordinator import BMWConfigEntry, BMWDataUpdateCoordinator diff --git a/homeassistant/components/broadlink/switch.py b/homeassistant/components/broadlink/switch.py index cc3b9dad464..9098440a5c4 100644 --- a/homeassistant/components/broadlink/switch.py +++ b/homeassistant/components/broadlink/switch.py @@ -29,7 +29,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/bt_home_hub_5/device_tracker.py b/homeassistant/components/bt_home_hub_5/device_tracker.py index cbd06381578..84450573989 100644 --- a/homeassistant/components/bt_home_hub_5/device_tracker.py +++ b/homeassistant/components/bt_home_hub_5/device_tracker.py @@ -14,7 +14,7 @@ from homeassistant.components.device_tracker import ( ) from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/bt_smarthub/device_tracker.py b/homeassistant/components/bt_smarthub/device_tracker.py index 29f60bd317f..57ceb01700d 100644 --- a/homeassistant/components/bt_smarthub/device_tracker.py +++ b/homeassistant/components/bt_smarthub/device_tracker.py @@ -15,7 +15,7 @@ from homeassistant.components.device_tracker import ( ) from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/buienradar/config_flow.py b/homeassistant/components/buienradar/config_flow.py index 45ad9028eb0..12f292036df 100644 --- a/homeassistant/components/buienradar/config_flow.py +++ b/homeassistant/components/buienradar/config_flow.py @@ -10,8 +10,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_COUNTRY_CODE, CONF_LATITUDE, CONF_LONGITUDE from homeassistant.core import callback -from homeassistant.helpers import selector -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, selector from homeassistant.helpers.schema_config_entry_flow import ( SchemaCommonFlowHandler, SchemaFlowFormStep, From aa6ffb3da56458975678a19e3f1e68cd807e5e0d Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 29 Jan 2025 11:39:40 +0100 Subject: [PATCH 0219/3148] Improve type hints in environment_canada camera and weather (#136819) --- homeassistant/components/environment_canada/camera.py | 7 ++++--- homeassistant/components/environment_canada/weather.py | 10 +++++++--- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/environment_canada/camera.py b/homeassistant/components/environment_canada/camera.py index fd82ac97bea..3ba059e2206 100644 --- a/homeassistant/components/environment_canada/camera.py +++ b/homeassistant/components/environment_canada/camera.py @@ -2,6 +2,7 @@ from __future__ import annotations +from env_canada import ECRadar import voluptuous as vol from homeassistant.components.camera import Camera @@ -14,7 +15,7 @@ from homeassistant.helpers.typing import VolDictType from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ATTR_OBSERVATION_TIME -from .coordinator import ECConfigEntry +from .coordinator import ECConfigEntry, ECDataUpdateCoordinator SERVICE_SET_RADAR_TYPE = "set_radar_type" SET_RADAR_TYPE_SCHEMA: VolDictType = { @@ -39,13 +40,13 @@ async def async_setup_entry( ) -class ECCameraEntity(CoordinatorEntity, Camera): +class ECCameraEntity(CoordinatorEntity[ECDataUpdateCoordinator[ECRadar]], Camera): """Implementation of an Environment Canada radar camera.""" _attr_has_entity_name = True _attr_translation_key = "radar" - def __init__(self, coordinator): + def __init__(self, coordinator: ECDataUpdateCoordinator[ECRadar]) -> None: """Initialize the camera.""" super().__init__(coordinator) Camera.__init__(self) diff --git a/homeassistant/components/environment_canada/weather.py b/homeassistant/components/environment_canada/weather.py index 5cfe32f18dd..156b9f4152b 100644 --- a/homeassistant/components/environment_canada/weather.py +++ b/homeassistant/components/environment_canada/weather.py @@ -4,6 +4,8 @@ from __future__ import annotations from typing import Any +from env_canada import ECWeather + from homeassistant.components.weather import ( ATTR_CONDITION_CLEAR_NIGHT, ATTR_CONDITION_CLOUDY, @@ -38,7 +40,7 @@ from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN -from .coordinator import ECConfigEntry +from .coordinator import ECConfigEntry, ECDataUpdateCoordinator # Icon codes from http://dd.weatheroffice.ec.gc.ca/citypage_weather/ # docs/current_conditions_icon_code_descriptions_e.csv @@ -82,7 +84,9 @@ def _calculate_unique_id(config_entry_unique_id: str | None, hourly: bool) -> st return f"{config_entry_unique_id}{'-hourly' if hourly else '-daily'}" -class ECWeatherEntity(SingleCoordinatorWeatherEntity): +class ECWeatherEntity( + SingleCoordinatorWeatherEntity[ECDataUpdateCoordinator[ECWeather]] +): """Representation of a weather condition.""" _attr_has_entity_name = True @@ -94,7 +98,7 @@ class ECWeatherEntity(SingleCoordinatorWeatherEntity): WeatherEntityFeature.FORECAST_DAILY | WeatherEntityFeature.FORECAST_HOURLY ) - def __init__(self, coordinator): + def __init__(self, coordinator: ECDataUpdateCoordinator[ECWeather]) -> None: """Initialize Environment Canada weather.""" super().__init__(coordinator) self.ec_data = coordinator.ec_data From ea62da553e793e4490b3cf9979a839d60f930ade Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Wed, 29 Jan 2025 20:41:33 +1000 Subject: [PATCH 0220/3148] Correct the behavior of the Charge switch in Tessie/Teslemetry/Tesla Fleet (#136562) --- .../components/tesla_fleet/icons.json | 2 +- .../components/tesla_fleet/strings.json | 2 +- .../components/tesla_fleet/switch.py | 41 +++++++------------ .../components/teslemetry/icons.json | 2 +- .../components/teslemetry/strings.json | 2 +- homeassistant/components/teslemetry/switch.py | 41 +++++++------------ homeassistant/components/tessie/strings.json | 2 +- homeassistant/components/tessie/switch.py | 38 +++++++---------- .../tesla_fleet/snapshots/test_switch.ambr | 6 +-- .../teslemetry/snapshots/test_switch.ambr | 6 +-- .../tessie/snapshots/test_switch.ambr | 4 +- tests/components/tessie/test_switch.py | 2 +- 12 files changed, 58 insertions(+), 90 deletions(-) diff --git a/homeassistant/components/tesla_fleet/icons.json b/homeassistant/components/tesla_fleet/icons.json index c806138c219..f907107fd40 100644 --- a/homeassistant/components/tesla_fleet/icons.json +++ b/homeassistant/components/tesla_fleet/icons.json @@ -298,7 +298,7 @@ } }, "switch": { - "charge_state_user_charge_enable_request": { + "charge_state_charging_state": { "default": "mdi:ev-station" }, "climate_state_auto_seat_climate_left": { diff --git a/homeassistant/components/tesla_fleet/strings.json b/homeassistant/components/tesla_fleet/strings.json index c438bfff50f..540ea2b7135 100644 --- a/homeassistant/components/tesla_fleet/strings.json +++ b/homeassistant/components/tesla_fleet/strings.json @@ -522,7 +522,7 @@ } }, "switch": { - "charge_state_user_charge_enable_request": { + "charge_state_charging_state": { "name": "Charge" }, "climate_state_auto_seat_climate_left": { diff --git a/homeassistant/components/tesla_fleet/switch.py b/homeassistant/components/tesla_fleet/switch.py index d602cff78c0..054ea84cbe1 100644 --- a/homeassistant/components/tesla_fleet/switch.py +++ b/homeassistant/components/tesla_fleet/switch.py @@ -16,6 +16,7 @@ from homeassistant.components.switch import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType from . import TeslaFleetConfigEntry from .entity import TeslaFleetEnergyInfoEntity, TeslaFleetVehicleEntity @@ -32,6 +33,8 @@ class TeslaFleetSwitchEntityDescription(SwitchEntityDescription): on_func: Callable off_func: Callable scopes: list[Scope] + value_func: Callable[[StateType], bool] = bool + unique_id: str | None = None VEHICLE_DESCRIPTIONS: tuple[TeslaFleetSwitchEntityDescription, ...] = ( @@ -77,13 +80,14 @@ VEHICLE_DESCRIPTIONS: tuple[TeslaFleetSwitchEntityDescription, ...] = ( ), scopes=[Scope.VEHICLE_CMDS], ), -) - -VEHICLE_CHARGE_DESCRIPTION = TeslaFleetSwitchEntityDescription( - key="charge_state_user_charge_enable_request", - on_func=lambda api: api.charge_start(), - off_func=lambda api: api.charge_stop(), - scopes=[Scope.VEHICLE_CHARGING_CMDS, Scope.VEHICLE_CMDS], + TeslaFleetSwitchEntityDescription( + key="charge_state_charging_state", + unique_id="charge_state_user_charge_enable_request", + on_func=lambda api: api.charge_start(), + off_func=lambda api: api.charge_stop(), + value_func=lambda state: state in {"Starting", "Charging"}, + scopes=[Scope.VEHICLE_CHARGING_CMDS, Scope.VEHICLE_CMDS], + ), ) @@ -103,12 +107,6 @@ async def async_setup_entry( for vehicle in entry.runtime_data.vehicles for description in VEHICLE_DESCRIPTIONS ), - ( - TeslaFleetChargeSwitchEntity( - vehicle, VEHICLE_CHARGE_DESCRIPTION, entry.runtime_data.scopes - ) - for vehicle in entry.runtime_data.vehicles - ), ( TeslaFleetChargeFromGridSwitchEntity( energysite, @@ -144,16 +142,18 @@ class TeslaFleetVehicleSwitchEntity(TeslaFleetVehicleEntity, TeslaFleetSwitchEnt scopes: list[Scope], ) -> None: """Initialize the Switch.""" - super().__init__(data, description.key) self.entity_description = description self.scoped = any(scope in scopes for scope in description.scopes) + super().__init__(data, description.key) + if description.unique_id: + self._attr_unique_id = f"{data.vin}-{description.unique_id}" def _async_update_attrs(self) -> None: """Update the attributes of the sensor.""" if self._value is None: self._attr_is_on = None else: - self._attr_is_on = bool(self._value) + self._attr_is_on = self.entity_description.value_func(self._value) async def async_turn_on(self, **kwargs: Any) -> None: """Turn on the Switch.""" @@ -172,17 +172,6 @@ class TeslaFleetVehicleSwitchEntity(TeslaFleetVehicleEntity, TeslaFleetSwitchEnt self.async_write_ha_state() -class TeslaFleetChargeSwitchEntity(TeslaFleetVehicleSwitchEntity): - """Entity class for TeslaFleet charge switch.""" - - def _async_update_attrs(self) -> None: - """Update the attributes of the entity.""" - if self._value is None: - self._attr_is_on = self.get("charge_state_charge_enable_request") - else: - self._attr_is_on = self._value - - class TeslaFleetChargeFromGridSwitchEntity( TeslaFleetEnergyInfoEntity, TeslaFleetSwitchEntity ): diff --git a/homeassistant/components/teslemetry/icons.json b/homeassistant/components/teslemetry/icons.json index 6559acf89dc..9996a508177 100644 --- a/homeassistant/components/teslemetry/icons.json +++ b/homeassistant/components/teslemetry/icons.json @@ -291,7 +291,7 @@ } }, "switch": { - "charge_state_user_charge_enable_request": { + "charge_state_charging_state": { "default": "mdi:ev-station" }, "climate_state_auto_seat_climate_left": { diff --git a/homeassistant/components/teslemetry/strings.json b/homeassistant/components/teslemetry/strings.json index 8dc8b053712..68ad12a46b6 100644 --- a/homeassistant/components/teslemetry/strings.json +++ b/homeassistant/components/teslemetry/strings.json @@ -608,7 +608,7 @@ } }, "switch": { - "charge_state_user_charge_enable_request": { + "charge_state_charging_state": { "name": "Charge" }, "climate_state_auto_seat_climate_left": { diff --git a/homeassistant/components/teslemetry/switch.py b/homeassistant/components/teslemetry/switch.py index 6a1cff4c5da..f810dee8554 100644 --- a/homeassistant/components/teslemetry/switch.py +++ b/homeassistant/components/teslemetry/switch.py @@ -16,6 +16,7 @@ from homeassistant.components.switch import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType from . import TeslemetryConfigEntry from .entity import TeslemetryEnergyInfoEntity, TeslemetryVehicleEntity @@ -32,6 +33,8 @@ class TeslemetrySwitchEntityDescription(SwitchEntityDescription): on_func: Callable off_func: Callable scopes: list[Scope] + value_func: Callable[[StateType], bool] = bool + unique_id: str | None = None VEHICLE_DESCRIPTIONS: tuple[TeslemetrySwitchEntityDescription, ...] = ( @@ -77,13 +80,14 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetrySwitchEntityDescription, ...] = ( ), scopes=[Scope.VEHICLE_CMDS], ), -) - -VEHICLE_CHARGE_DESCRIPTION = TeslemetrySwitchEntityDescription( - key="charge_state_user_charge_enable_request", - on_func=lambda api: api.charge_start(), - off_func=lambda api: api.charge_stop(), - scopes=[Scope.VEHICLE_CMDS, Scope.VEHICLE_CHARGING_CMDS], + TeslemetrySwitchEntityDescription( + key="charge_state_charging_state", + unique_id="charge_state_user_charge_enable_request", + on_func=lambda api: api.charge_start(), + off_func=lambda api: api.charge_stop(), + value_func=lambda state: state in {"Starting", "Charging"}, + scopes=[Scope.VEHICLE_CMDS, Scope.VEHICLE_CHARGING_CMDS], + ), ) @@ -104,12 +108,6 @@ async def async_setup_entry( for description in VEHICLE_DESCRIPTIONS if description.key in vehicle.coordinator.data ), - ( - TeslemetryChargeSwitchEntity( - vehicle, VEHICLE_CHARGE_DESCRIPTION, entry.runtime_data.scopes - ) - for vehicle in entry.runtime_data.vehicles - ), ( TeslemetryChargeFromGridSwitchEntity( energysite, @@ -145,13 +143,15 @@ class TeslemetryVehicleSwitchEntity(TeslemetryVehicleEntity, TeslemetrySwitchEnt scopes: list[Scope], ) -> None: """Initialize the Switch.""" - super().__init__(data, description.key) self.entity_description = description self.scoped = any(scope in scopes for scope in description.scopes) + super().__init__(data, description.key) + if description.unique_id: + self._attr_unique_id = f"{data.vin}-{description.unique_id}" def _async_update_attrs(self) -> None: """Update the attributes of the sensor.""" - self._attr_is_on = bool(self._value) + self._attr_is_on = self.entity_description.value_func(self._value) async def async_turn_on(self, **kwargs: Any) -> None: """Turn on the Switch.""" @@ -170,17 +170,6 @@ class TeslemetryVehicleSwitchEntity(TeslemetryVehicleEntity, TeslemetrySwitchEnt self.async_write_ha_state() -class TeslemetryChargeSwitchEntity(TeslemetryVehicleSwitchEntity): - """Entity class for Teslemetry charge switch.""" - - def _async_update_attrs(self) -> None: - """Update the attributes of the entity.""" - if self._value is None: - self._attr_is_on = self.get("charge_state_charge_enable_request") - else: - self._attr_is_on = self._value - - class TeslemetryChargeFromGridSwitchEntity( TeslemetryEnergyInfoEntity, TeslemetrySwitchEntity ): diff --git a/homeassistant/components/tessie/strings.json b/homeassistant/components/tessie/strings.json index 4ac645a0270..8384bb3d8fb 100644 --- a/homeassistant/components/tessie/strings.json +++ b/homeassistant/components/tessie/strings.json @@ -459,7 +459,7 @@ } }, "switch": { - "charge_state_charge_enable_request": { + "charge_state_charging_state": { "name": "Charge" }, "climate_state_defrost_mode": { diff --git a/homeassistant/components/tessie/switch.py b/homeassistant/components/tessie/switch.py index f0088a4444f..dba00a85bb2 100644 --- a/homeassistant/components/tessie/switch.py +++ b/homeassistant/components/tessie/switch.py @@ -27,6 +27,7 @@ from homeassistant.components.switch import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType from . import TessieConfigEntry from .entity import TessieEnergyEntity, TessieEntity @@ -40,6 +41,8 @@ class TessieSwitchEntityDescription(SwitchEntityDescription): on_func: Callable off_func: Callable + value_func: Callable[[StateType], bool] = bool + unique_id: str | None = None DESCRIPTIONS: tuple[TessieSwitchEntityDescription, ...] = ( @@ -63,12 +66,13 @@ DESCRIPTIONS: tuple[TessieSwitchEntityDescription, ...] = ( on_func=lambda: start_steering_wheel_heater, off_func=lambda: stop_steering_wheel_heater, ), -) - -CHARGE_DESCRIPTION: TessieSwitchEntityDescription = TessieSwitchEntityDescription( - key="charge_state_charge_enable_request", - on_func=lambda: start_charging, - off_func=lambda: stop_charging, + TessieSwitchEntityDescription( + key="charge_state_charging_state", + unique_id="charge_state_charge_enable_request", + on_func=lambda: start_charging, + off_func=lambda: stop_charging, + value_func=lambda state: state in {"Starting", "Charging"}, + ), ) PARALLEL_UPDATES = 0 @@ -89,10 +93,6 @@ async def async_setup_entry( for description in DESCRIPTIONS if description.key in vehicle.data_coordinator.data ), - ( - TessieChargeSwitchEntity(vehicle, CHARGE_DESCRIPTION) - for vehicle in entry.runtime_data.vehicles - ), ( TessieChargeFromGridSwitchEntity(energysite) for energysite in entry.runtime_data.energysites @@ -120,13 +120,15 @@ class TessieSwitchEntity(TessieEntity, SwitchEntity): description: TessieSwitchEntityDescription, ) -> None: """Initialize the Switch.""" - super().__init__(vehicle, description.key) self.entity_description = description + super().__init__(vehicle, description.key) + if description.unique_id: + self._attr_unique_id = f"{vehicle.vin}-{description.unique_id}" @property def is_on(self) -> bool: """Return the state of the Switch.""" - return self._value + return self.entity_description.value_func(self._value) async def async_turn_on(self, **kwargs: Any) -> None: """Turn on the Switch.""" @@ -139,18 +141,6 @@ class TessieSwitchEntity(TessieEntity, SwitchEntity): self.set((self.entity_description.key, False)) -class TessieChargeSwitchEntity(TessieSwitchEntity): - """Entity class for Tessie charge switch.""" - - @property - def is_on(self) -> bool: - """Return the state of the Switch.""" - - if (charge := self.get("charge_state_user_charge_enable_request")) is not None: - return charge - return self._value - - class TessieChargeFromGridSwitchEntity(TessieEnergyEntity, SwitchEntity): """Entity class for Charge From Grid switch.""" diff --git a/tests/components/tesla_fleet/snapshots/test_switch.ambr b/tests/components/tesla_fleet/snapshots/test_switch.ambr index 2d69a7d314a..43d59a9da85 100644 --- a/tests/components/tesla_fleet/snapshots/test_switch.ambr +++ b/tests/components/tesla_fleet/snapshots/test_switch.ambr @@ -262,7 +262,7 @@ 'platform': 'tesla_fleet', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'charge_state_user_charge_enable_request', + 'translation_key': 'charge_state_charging_state', 'unique_id': 'LRWXF7EK4KC700000-charge_state_user_charge_enable_request', 'unit_of_measurement': None, }) @@ -278,7 +278,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'on', + 'state': 'off', }) # --- # name: test_switch[switch.test_defrost-entry] @@ -456,7 +456,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'on', + 'state': 'off', }) # --- # name: test_switch_alt[switch.test_defrost-statealt] diff --git a/tests/components/teslemetry/snapshots/test_switch.ambr b/tests/components/teslemetry/snapshots/test_switch.ambr index 5693d4bdd5e..b34d9c65393 100644 --- a/tests/components/teslemetry/snapshots/test_switch.ambr +++ b/tests/components/teslemetry/snapshots/test_switch.ambr @@ -262,7 +262,7 @@ 'platform': 'teslemetry', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'charge_state_user_charge_enable_request', + 'translation_key': 'charge_state_charging_state', 'unique_id': 'LRW3F7EK4NC700000-charge_state_user_charge_enable_request', 'unit_of_measurement': None, }) @@ -278,7 +278,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'on', + 'state': 'off', }) # --- # name: test_switch[switch.test_defrost-entry] @@ -456,7 +456,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'on', + 'state': 'off', }) # --- # name: test_switch_alt[switch.test_defrost-statealt] diff --git a/tests/components/tessie/snapshots/test_switch.ambr b/tests/components/tessie/snapshots/test_switch.ambr index 3b7a3623de8..35e36010830 100644 --- a/tests/components/tessie/snapshots/test_switch.ambr +++ b/tests/components/tessie/snapshots/test_switch.ambr @@ -119,7 +119,7 @@ 'platform': 'tessie', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'charge_state_charge_enable_request', + 'translation_key': 'charge_state_charging_state', 'unique_id': 'VINVINVIN-charge_state_charge_enable_request', 'unit_of_measurement': None, }) @@ -351,6 +351,6 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'on', + 'state': 'off', }) # --- diff --git a/tests/components/tessie/test_switch.py b/tests/components/tessie/test_switch.py index 499e529b2e8..690ad7d1ab4 100644 --- a/tests/components/tessie/test_switch.py +++ b/tests/components/tessie/test_switch.py @@ -20,7 +20,7 @@ from .common import RESPONSE_OK, assert_entities, setup_platform async def test_switches( hass: HomeAssistant, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry ) -> None: - """Tests that the switche entities are correct.""" + """Tests that the switch entities are correct.""" entry = await setup_platform(hass, [Platform.SWITCH]) From ccdcba97b58c3242130a20cd59c8c246562f1f19 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 29 Jan 2025 11:56:40 +0100 Subject: [PATCH 0221/3148] Standardize homeassistant imports in component (l-m) (#136827) --- homeassistant/components/lacrosse/sensor.py | 2 +- homeassistant/components/lametric/__init__.py | 3 +-- homeassistant/components/lannouncer/notify.py | 2 +- homeassistant/components/lcn/config_flow.py | 2 +- homeassistant/components/lcn/schemas.py | 2 +- homeassistant/components/lcn/services.py | 3 +-- homeassistant/components/lcn/websocket.py | 7 +++++-- homeassistant/components/lifx/__init__.py | 2 +- homeassistant/components/lifx/light.py | 3 +-- homeassistant/components/lifx/manager.py | 2 +- homeassistant/components/lifx_cloud/scene.py | 2 +- homeassistant/components/lightwave/__init__.py | 2 +- homeassistant/components/limitlessled/light.py | 2 +- homeassistant/components/linksys_smart/device_tracker.py | 2 +- homeassistant/components/linode/__init__.py | 2 +- homeassistant/components/linode/binary_sensor.py | 2 +- homeassistant/components/linode/switch.py | 2 +- homeassistant/components/linux_battery/sensor.py | 2 +- homeassistant/components/litejet/config_flow.py | 2 +- homeassistant/components/litejet/trigger.py | 4 ++-- homeassistant/components/litterrobot/time.py | 2 +- homeassistant/components/litterrobot/vacuum.py | 2 +- homeassistant/components/locative/__init__.py | 3 +-- homeassistant/components/logentries/__init__.py | 3 +-- homeassistant/components/london_air/sensor.py | 2 +- homeassistant/components/london_underground/sensor.py | 2 +- homeassistant/components/luci/device_tracker.py | 2 +- homeassistant/components/luftdaten/config_flow.py | 2 +- homeassistant/components/lutron_caseta/__init__.py | 7 +++++-- homeassistant/components/lyric/climate.py | 3 +-- homeassistant/components/mailgun/__init__.py | 3 +-- homeassistant/components/manual/alarm_control_panel.py | 4 ++-- .../components/manual_mqtt/alarm_control_panel.py | 4 ++-- homeassistant/components/marytts/tts.py | 2 +- homeassistant/components/matrix/__init__.py | 2 +- homeassistant/components/matrix/notify.py | 2 +- homeassistant/components/maxcube/__init__.py | 2 +- homeassistant/components/mealie/coordinator.py | 2 +- homeassistant/components/mediaroom/media_player.py | 2 +- homeassistant/components/meraki/device_tracker.py | 2 +- homeassistant/components/message_bird/notify.py | 2 +- homeassistant/components/met/config_flow.py | 2 +- homeassistant/components/met_eireann/__init__.py | 2 +- homeassistant/components/met_eireann/config_flow.py | 2 +- homeassistant/components/meteo_france/__init__.py | 2 +- homeassistant/components/meteoalarm/binary_sensor.py | 4 ++-- homeassistant/components/mfi/sensor.py | 2 +- homeassistant/components/mfi/switch.py | 2 +- homeassistant/components/microsoft/tts.py | 2 +- homeassistant/components/microsoft_face/__init__.py | 2 +- .../components/microsoft_face_detect/image_processing.py | 2 +- .../components/microsoft_face_identify/image_processing.py | 2 +- homeassistant/components/mikrotik/device.py | 3 +-- homeassistant/components/mikrotik/device_tracker.py | 2 +- homeassistant/components/minio/__init__.py | 2 +- homeassistant/components/mobile_app/notify.py | 2 +- homeassistant/components/mochad/__init__.py | 2 +- homeassistant/components/mold_indicator/sensor.py | 2 +- homeassistant/components/moon/sensor.py | 2 +- homeassistant/components/mpd/media_player.py | 5 ++--- homeassistant/components/mqtt_eventstream/__init__.py | 2 +- homeassistant/components/mqtt_json/device_tracker.py | 2 +- homeassistant/components/mqtt_room/sensor.py | 2 +- homeassistant/components/mqtt_statestream/__init__.py | 2 +- homeassistant/components/msteams/notify.py | 2 +- homeassistant/components/music_assistant/__init__.py | 3 +-- homeassistant/components/music_assistant/actions.py | 2 +- homeassistant/components/music_assistant/media_player.py | 3 +-- homeassistant/components/music_assistant/schemas.py | 2 +- homeassistant/components/mvglive/sensor.py | 2 +- homeassistant/components/mycroft/__init__.py | 3 +-- homeassistant/components/mysensors/config_flow.py | 3 +-- homeassistant/components/mysensors/gateway.py | 2 +- homeassistant/components/mysensors/helpers.py | 2 +- homeassistant/components/mythicbeastsdns/__init__.py | 2 +- 75 files changed, 88 insertions(+), 95 deletions(-) diff --git a/homeassistant/components/lacrosse/sensor.py b/homeassistant/components/lacrosse/sensor.py index d7df7a08e76..2cdf28d5e69 100644 --- a/homeassistant/components/lacrosse/sensor.py +++ b/homeassistant/components/lacrosse/sensor.py @@ -28,7 +28,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_point_in_utc_time diff --git a/homeassistant/components/lametric/__init__.py b/homeassistant/components/lametric/__init__.py index 779cfa10445..89659fbd2c0 100644 --- a/homeassistant/components/lametric/__init__.py +++ b/homeassistant/components/lametric/__init__.py @@ -4,8 +4,7 @@ from homeassistant.components import notify as hass_notify from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import discovery -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.typing import ConfigType from .const import DOMAIN, PLATFORMS diff --git a/homeassistant/components/lannouncer/notify.py b/homeassistant/components/lannouncer/notify.py index 6c3cd1922cf..fe486660438 100644 --- a/homeassistant/components/lannouncer/notify.py +++ b/homeassistant/components/lannouncer/notify.py @@ -15,7 +15,7 @@ from homeassistant.components.notify import ( ) from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType ATTR_METHOD = "method" diff --git a/homeassistant/components/lcn/config_flow.py b/homeassistant/components/lcn/config_flow.py index a1be32704f7..63e0d8c8b26 100644 --- a/homeassistant/components/lcn/config_flow.py +++ b/homeassistant/components/lcn/config_flow.py @@ -20,7 +20,7 @@ from homeassistant.const import ( CONF_USERNAME, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from . import PchkConnectionManager diff --git a/homeassistant/components/lcn/schemas.py b/homeassistant/components/lcn/schemas.py index 809701c680a..d90e264692c 100644 --- a/homeassistant/components/lcn/schemas.py +++ b/homeassistant/components/lcn/schemas.py @@ -9,7 +9,7 @@ from homeassistant.const import ( CONF_UNIT_OF_MEASUREMENT, UnitOfTemperature, ) -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import VolDictType from .const import ( diff --git a/homeassistant/components/lcn/services.py b/homeassistant/components/lcn/services.py index a6c42de0487..2694bed31d2 100644 --- a/homeassistant/components/lcn/services.py +++ b/homeassistant/components/lcn/services.py @@ -20,8 +20,7 @@ from homeassistant.core import ( SupportsResponse, ) from homeassistant.exceptions import ServiceValidationError -from homeassistant.helpers import device_registry as dr -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from .const import ( diff --git a/homeassistant/components/lcn/websocket.py b/homeassistant/components/lcn/websocket.py index 46df71d4235..9084ec838d9 100644 --- a/homeassistant/components/lcn/websocket.py +++ b/homeassistant/components/lcn/websocket.py @@ -22,8 +22,11 @@ from homeassistant.const import ( CONF_RESOURCE, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr, entity_registry as er -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import ( + config_validation as cv, + device_registry as dr, + entity_registry as er, +) from .const import ( ADD_ENTITIES_CALLBACKS, diff --git a/homeassistant/components/lifx/__init__.py b/homeassistant/components/lifx/__init__.py index 974292c6e80..2847862029f 100644 --- a/homeassistant/components/lifx/__init__.py +++ b/homeassistant/components/lifx/__init__.py @@ -22,7 +22,7 @@ from homeassistant.const import ( ) from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.event import async_call_later, async_track_time_interval from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/lifx/light.py b/homeassistant/components/lifx/light.py index 22bcef4915e..2a8031b3874 100644 --- a/homeassistant/components/lifx/light.py +++ b/homeassistant/components/lifx/light.py @@ -21,8 +21,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import CALLBACK_TYPE, HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import entity_platform -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_call_later from homeassistant.helpers.typing import VolDictType diff --git a/homeassistant/components/lifx/manager.py b/homeassistant/components/lifx/manager.py index 16c39c25219..887bc3c3527 100644 --- a/homeassistant/components/lifx/manager.py +++ b/homeassistant/components/lifx/manager.py @@ -27,7 +27,7 @@ from homeassistant.components.light import ( ) from homeassistant.const import ATTR_MODE from homeassistant.core import HomeAssistant, ServiceCall, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.service import async_extract_referenced_entity_ids from .const import _ATTR_COLOR_TEMP, ATTR_THEME, DATA_LIFX_MANAGER, DOMAIN diff --git a/homeassistant/components/lifx_cloud/scene.py b/homeassistant/components/lifx_cloud/scene.py index b40cb081ed7..f6ba01dbdae 100644 --- a/homeassistant/components/lifx_cloud/scene.py +++ b/homeassistant/components/lifx_cloud/scene.py @@ -14,8 +14,8 @@ import voluptuous as vol from homeassistant.components.scene import Scene from homeassistant.const import CONF_PLATFORM, CONF_TIMEOUT, CONF_TOKEN from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/lightwave/__init__.py b/homeassistant/components/lightwave/__init__.py index 6c462b040d4..ef2a69c9f4f 100644 --- a/homeassistant/components/lightwave/__init__.py +++ b/homeassistant/components/lightwave/__init__.py @@ -13,7 +13,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.discovery import async_load_platform from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/limitlessled/light.py b/homeassistant/components/limitlessled/light.py index 4b2b75be9d7..22e2071b6a7 100644 --- a/homeassistant/components/limitlessled/light.py +++ b/homeassistant/components/limitlessled/light.py @@ -34,7 +34,7 @@ from homeassistant.components.light import ( ) from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT, CONF_TYPE, STATE_ON from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/linksys_smart/device_tracker.py b/homeassistant/components/linksys_smart/device_tracker.py index 596b7012140..c3b0b666d50 100644 --- a/homeassistant/components/linksys_smart/device_tracker.py +++ b/homeassistant/components/linksys_smart/device_tracker.py @@ -15,7 +15,7 @@ from homeassistant.components.device_tracker import ( ) from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType DEFAULT_TIMEOUT = 10 diff --git a/homeassistant/components/linode/__init__.py b/homeassistant/components/linode/__init__.py index 80c082344e7..d59c849f8f0 100644 --- a/homeassistant/components/linode/__init__.py +++ b/homeassistant/components/linode/__init__.py @@ -8,7 +8,7 @@ import voluptuous as vol from homeassistant.const import CONF_ACCESS_TOKEN, Platform from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from homeassistant.util import Throttle diff --git a/homeassistant/components/linode/binary_sensor.py b/homeassistant/components/linode/binary_sensor.py index d0c49c7171b..93bdef4a1f4 100644 --- a/homeassistant/components/linode/binary_sensor.py +++ b/homeassistant/components/linode/binary_sensor.py @@ -12,7 +12,7 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/linode/switch.py b/homeassistant/components/linode/switch.py index abaf77648ef..74d2099a844 100644 --- a/homeassistant/components/linode/switch.py +++ b/homeassistant/components/linode/switch.py @@ -12,7 +12,7 @@ from homeassistant.components.switch import ( SwitchEntity, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/linux_battery/sensor.py b/homeassistant/components/linux_battery/sensor.py index 789195e1169..fffb6357a28 100644 --- a/homeassistant/components/linux_battery/sensor.py +++ b/homeassistant/components/linux_battery/sensor.py @@ -15,7 +15,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import ATTR_NAME, ATTR_SERIAL_NUMBER, CONF_NAME, PERCENTAGE from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/litejet/config_flow.py b/homeassistant/components/litejet/config_flow.py index 9aa0b19c506..aeae8f52144 100644 --- a/homeassistant/components/litejet/config_flow.py +++ b/homeassistant/components/litejet/config_flow.py @@ -16,7 +16,7 @@ from homeassistant.config_entries import ( ) from homeassistant.const import CONF_PORT from homeassistant.core import callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from .const import CONF_DEFAULT_TRANSITION, DOMAIN diff --git a/homeassistant/components/litejet/trigger.py b/homeassistant/components/litejet/trigger.py index 2786cc8b76a..a35bf6fb65e 100644 --- a/homeassistant/components/litejet/trigger.py +++ b/homeassistant/components/litejet/trigger.py @@ -11,11 +11,11 @@ import voluptuous as vol from homeassistant.const import CONF_PLATFORM from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.event import track_point_in_utc_time from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import DOMAIN diff --git a/homeassistant/components/litterrobot/time.py b/homeassistant/components/litterrobot/time.py index 6e3743059b3..3fa93b14dd9 100644 --- a/homeassistant/components/litterrobot/time.py +++ b/homeassistant/components/litterrobot/time.py @@ -13,7 +13,7 @@ from homeassistant.components.time import TimeEntity, TimeEntityDescription from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .coordinator import LitterRobotConfigEntry from .entity import LitterRobotEntity, _RobotT diff --git a/homeassistant/components/litterrobot/vacuum.py b/homeassistant/components/litterrobot/vacuum.py index 2f9e2e9b24d..314fab6a621 100644 --- a/homeassistant/components/litterrobot/vacuum.py +++ b/homeassistant/components/litterrobot/vacuum.py @@ -18,7 +18,7 @@ from homeassistant.components.vacuum import ( from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .coordinator import LitterRobotConfigEntry from .entity import LitterRobotEntity diff --git a/homeassistant/components/locative/__init__.py b/homeassistant/components/locative/__init__.py index ff2c2c4c3a3..4154f343f42 100644 --- a/homeassistant/components/locative/__init__.py +++ b/homeassistant/components/locative/__init__.py @@ -19,8 +19,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_entry_flow -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_entry_flow, config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_send _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/logentries/__init__.py b/homeassistant/components/logentries/__init__.py index 8ddf4a1a543..68d6af7e7dd 100644 --- a/homeassistant/components/logentries/__init__.py +++ b/homeassistant/components/logentries/__init__.py @@ -8,8 +8,7 @@ import voluptuous as vol from homeassistant.const import CONF_TOKEN, EVENT_STATE_CHANGED from homeassistant.core import HomeAssistant -from homeassistant.helpers import state as state_helper -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, state as state_helper from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/london_air/sensor.py b/homeassistant/components/london_air/sensor.py index 16dbfa5b871..81133433d05 100644 --- a/homeassistant/components/london_air/sensor.py +++ b/homeassistant/components/london_air/sensor.py @@ -14,7 +14,7 @@ from homeassistant.components.sensor import ( SensorEntity, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import Throttle diff --git a/homeassistant/components/london_underground/sensor.py b/homeassistant/components/london_underground/sensor.py index 015f7e8ecdc..645f8f48ae2 100644 --- a/homeassistant/components/london_underground/sensor.py +++ b/homeassistant/components/london_underground/sensor.py @@ -14,8 +14,8 @@ from homeassistant.components.sensor import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.update_coordinator import CoordinatorEntity diff --git a/homeassistant/components/luci/device_tracker.py b/homeassistant/components/luci/device_tracker.py index cf04cdb292a..0ce92538472 100644 --- a/homeassistant/components/luci/device_tracker.py +++ b/homeassistant/components/luci/device_tracker.py @@ -20,7 +20,7 @@ from homeassistant.const import ( CONF_VERIFY_SSL, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/luftdaten/config_flow.py b/homeassistant/components/luftdaten/config_flow.py index ba14afeb092..1ee444d5c84 100644 --- a/homeassistant/components/luftdaten/config_flow.py +++ b/homeassistant/components/luftdaten/config_flow.py @@ -11,7 +11,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_SHOW_ON_MAP from homeassistant.core import callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from .const import CONF_SENSOR_ID, DOMAIN diff --git a/homeassistant/components/lutron_caseta/__init__.py b/homeassistant/components/lutron_caseta/__init__.py index 26fc5ba153e..d697d6244b5 100644 --- a/homeassistant/components/lutron_caseta/__init__.py +++ b/homeassistant/components/lutron_caseta/__init__.py @@ -17,8 +17,11 @@ from homeassistant import config_entries from homeassistant.const import ATTR_DEVICE_ID, CONF_HOST, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import device_registry as dr, entity_registry as er -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import ( + config_validation as cv, + device_registry as dr, + entity_registry as er, +) from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/lyric/climate.py b/homeassistant/components/lyric/climate.py index 87b5d566bb8..c5d17cfb176 100644 --- a/homeassistant/components/lyric/climate.py +++ b/homeassistant/components/lyric/climate.py @@ -34,8 +34,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import entity_platform -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import VolDictType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator diff --git a/homeassistant/components/mailgun/__init__.py b/homeassistant/components/mailgun/__init__.py index 72617b2f42d..eb704a2d797 100644 --- a/homeassistant/components/mailgun/__init__.py +++ b/homeassistant/components/mailgun/__init__.py @@ -12,8 +12,7 @@ from homeassistant.components import webhook from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_DOMAIN, CONF_WEBHOOK_ID from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_entry_flow -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_entry_flow, config_validation as cv from homeassistant.helpers.typing import ConfigType from .const import DOMAIN diff --git a/homeassistant/components/manual/alarm_control_panel.py b/homeassistant/components/manual/alarm_control_panel.py index 244f38e0902..2b4d680208e 100644 --- a/homeassistant/components/manual/alarm_control_panel.py +++ b/homeassistant/components/manual/alarm_control_panel.py @@ -25,13 +25,13 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ServiceValidationError -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_point_in_time from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.template import Template from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util DOMAIN = "manual" diff --git a/homeassistant/components/manual_mqtt/alarm_control_panel.py b/homeassistant/components/manual_mqtt/alarm_control_panel.py index 768690e8ec5..cb03b71ce22 100644 --- a/homeassistant/components/manual_mqtt/alarm_control_panel.py +++ b/homeassistant/components/manual_mqtt/alarm_control_panel.py @@ -26,14 +26,14 @@ from homeassistant.const import ( ) from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import ( async_track_point_in_time, async_track_state_change_event, ) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/marytts/tts.py b/homeassistant/components/marytts/tts.py index 89832c01937..08d78ecf5c3 100644 --- a/homeassistant/components/marytts/tts.py +++ b/homeassistant/components/marytts/tts.py @@ -11,7 +11,7 @@ from homeassistant.components.tts import ( Provider, ) from homeassistant.const import CONF_EFFECT, CONF_HOST, CONF_PORT -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv CONF_VOICE = "voice" CONF_CODEC = "codec" diff --git a/homeassistant/components/matrix/__init__.py b/homeassistant/components/matrix/__init__.py index e1b488c0fce..8640aa4d074 100644 --- a/homeassistant/components/matrix/__init__.py +++ b/homeassistant/components/matrix/__init__.py @@ -39,7 +39,7 @@ from homeassistant.const import ( ) from homeassistant.core import Event as HassEvent, HomeAssistant, ServiceCall from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.json import save_json from homeassistant.helpers.typing import ConfigType from homeassistant.util.json import JsonObjectType, load_json_object diff --git a/homeassistant/components/matrix/notify.py b/homeassistant/components/matrix/notify.py index b05c7952d1f..0fc08e6c5aa 100644 --- a/homeassistant/components/matrix/notify.py +++ b/homeassistant/components/matrix/notify.py @@ -14,7 +14,7 @@ from homeassistant.components.notify import ( BaseNotificationService, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import RoomID diff --git a/homeassistant/components/maxcube/__init__.py b/homeassistant/components/maxcube/__init__.py index d4a3a45f441..4e79a00fed0 100644 --- a/homeassistant/components/maxcube/__init__.py +++ b/homeassistant/components/maxcube/__init__.py @@ -10,7 +10,7 @@ import voluptuous as vol from homeassistant.components import persistent_notification from homeassistant.const import CONF_HOST, CONF_PORT, CONF_SCAN_INTERVAL, Platform from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.discovery import load_platform from homeassistant.helpers.typing import ConfigType from homeassistant.util.dt import now diff --git a/homeassistant/components/mealie/coordinator.py b/homeassistant/components/mealie/coordinator.py index 7d4f23d706e..cf8dfb5bc90 100644 --- a/homeassistant/components/mealie/coordinator.py +++ b/homeassistant/components/mealie/coordinator.py @@ -21,7 +21,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import DOMAIN, LOGGER diff --git a/homeassistant/components/mediaroom/media_player.py b/homeassistant/components/mediaroom/media_player.py index 97b61da437a..4561c38ce80 100644 --- a/homeassistant/components/mediaroom/media_player.py +++ b/homeassistant/components/mediaroom/media_player.py @@ -29,7 +29,7 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, ) from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect, dispatcher_send from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/meraki/device_tracker.py b/homeassistant/components/meraki/device_tracker.py index 0eb3742a878..70995fc69b5 100644 --- a/homeassistant/components/meraki/device_tracker.py +++ b/homeassistant/components/meraki/device_tracker.py @@ -15,7 +15,7 @@ from homeassistant.components.device_tracker import ( ) from homeassistant.components.http import KEY_HASS, HomeAssistantView from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType CONF_VALIDATOR = "validator" diff --git a/homeassistant/components/message_bird/notify.py b/homeassistant/components/message_bird/notify.py index 6da0e8176ef..c5cbe695243 100644 --- a/homeassistant/components/message_bird/notify.py +++ b/homeassistant/components/message_bird/notify.py @@ -15,7 +15,7 @@ from homeassistant.components.notify import ( ) from homeassistant.const import CONF_API_KEY, CONF_SENDER from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/met/config_flow.py b/homeassistant/components/met/config_flow.py index 62964d22bb1..e5db80b2997 100644 --- a/homeassistant/components/met/config_flow.py +++ b/homeassistant/components/met/config_flow.py @@ -20,7 +20,7 @@ from homeassistant.const import ( UnitOfLength, ) from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.selector import ( NumberSelector, NumberSelectorConfig, diff --git a/homeassistant/components/met_eireann/__init__.py b/homeassistant/components/met_eireann/__init__.py index ab2695cbd11..01917707bf7 100644 --- a/homeassistant/components/met_eireann/__init__.py +++ b/homeassistant/components/met_eireann/__init__.py @@ -12,7 +12,7 @@ from homeassistant.const import CONF_ELEVATION, CONF_LATITUDE, CONF_LONGITUDE, P from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import DOMAIN diff --git a/homeassistant/components/met_eireann/config_flow.py b/homeassistant/components/met_eireann/config_flow.py index 422b46827da..761d0655237 100644 --- a/homeassistant/components/met_eireann/config_flow.py +++ b/homeassistant/components/met_eireann/config_flow.py @@ -6,7 +6,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_ELEVATION, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from .const import DOMAIN, HOME_LOCATION_NAME diff --git a/homeassistant/components/meteo_france/__init__.py b/homeassistant/components/meteo_france/__init__.py index 4b79b046b75..5c4ada6b5f1 100644 --- a/homeassistant/components/meteo_france/__init__.py +++ b/homeassistant/components/meteo_france/__init__.py @@ -13,7 +13,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import ( diff --git a/homeassistant/components/meteoalarm/binary_sensor.py b/homeassistant/components/meteoalarm/binary_sensor.py index 3400ca52f50..95124445363 100644 --- a/homeassistant/components/meteoalarm/binary_sensor.py +++ b/homeassistant/components/meteoalarm/binary_sensor.py @@ -15,10 +15,10 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/mfi/sensor.py b/homeassistant/components/mfi/sensor.py index b93cc669e62..f666e2d614a 100644 --- a/homeassistant/components/mfi/sensor.py +++ b/homeassistant/components/mfi/sensor.py @@ -25,7 +25,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/mfi/switch.py b/homeassistant/components/mfi/switch.py index 833a2c21301..2a05018f301 100644 --- a/homeassistant/components/mfi/switch.py +++ b/homeassistant/components/mfi/switch.py @@ -22,7 +22,7 @@ from homeassistant.const import ( CONF_VERIFY_SSL, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/microsoft/tts.py b/homeassistant/components/microsoft/tts.py index aa33072089f..b5e770601b4 100644 --- a/homeassistant/components/microsoft/tts.py +++ b/homeassistant/components/microsoft/tts.py @@ -13,7 +13,7 @@ from homeassistant.components.tts import ( ) from homeassistant.const import CONF_API_KEY, CONF_REGION, CONF_TYPE, PERCENTAGE from homeassistant.generated.microsoft_tts import SUPPORTED_LANGUAGES -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv CONF_GENDER = "gender" CONF_OUTPUT = "output" diff --git a/homeassistant/components/microsoft_face/__init__.py b/homeassistant/components/microsoft_face/__init__.py index fa4de7f9c99..23c9885e0c5 100644 --- a/homeassistant/components/microsoft_face/__init__.py +++ b/homeassistant/components/microsoft_face/__init__.py @@ -16,8 +16,8 @@ from homeassistant.components import camera from homeassistant.const import ATTR_NAME, CONF_API_KEY, CONF_TIMEOUT, CONTENT_TYPE_JSON from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/microsoft_face_detect/image_processing.py b/homeassistant/components/microsoft_face_detect/image_processing.py index 80037a29fa8..ce49f0b1f65 100644 --- a/homeassistant/components/microsoft_face_detect/image_processing.py +++ b/homeassistant/components/microsoft_face_detect/image_processing.py @@ -17,7 +17,7 @@ from homeassistant.components.microsoft_face import DATA_MICROSOFT_FACE from homeassistant.const import CONF_ENTITY_ID, CONF_NAME, CONF_SOURCE from homeassistant.core import HomeAssistant, split_entity_id from homeassistant.exceptions import HomeAssistantError -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/microsoft_face_identify/image_processing.py b/homeassistant/components/microsoft_face_identify/image_processing.py index 03a6ad22fcd..025a7eccdda 100644 --- a/homeassistant/components/microsoft_face_identify/image_processing.py +++ b/homeassistant/components/microsoft_face_identify/image_processing.py @@ -16,7 +16,7 @@ from homeassistant.components.microsoft_face import DATA_MICROSOFT_FACE from homeassistant.const import ATTR_NAME, CONF_ENTITY_ID, CONF_NAME, CONF_SOURCE from homeassistant.core import HomeAssistant, split_entity_id from homeassistant.exceptions import HomeAssistantError -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/mikrotik/device.py b/homeassistant/components/mikrotik/device.py index bf3cb47adc3..7963c48d936 100644 --- a/homeassistant/components/mikrotik/device.py +++ b/homeassistant/components/mikrotik/device.py @@ -5,8 +5,7 @@ from __future__ import annotations from datetime import datetime from typing import Any -from homeassistant.util import slugify -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util, slugify from .const import ATTR_DEVICE_TRACKER diff --git a/homeassistant/components/mikrotik/device_tracker.py b/homeassistant/components/mikrotik/device_tracker.py index c2d9e0d2f33..19d5c789c09 100644 --- a/homeassistant/components/mikrotik/device_tracker.py +++ b/homeassistant/components/mikrotik/device_tracker.py @@ -12,7 +12,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import MikrotikConfigEntry from .coordinator import Device, MikrotikDataUpdateCoordinator diff --git a/homeassistant/components/minio/__init__.py b/homeassistant/components/minio/__init__.py index 57a9632a6ff..18a82f3a8ed 100644 --- a/homeassistant/components/minio/__init__.py +++ b/homeassistant/components/minio/__init__.py @@ -11,7 +11,7 @@ import voluptuous as vol from homeassistant.const import EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant, ServiceCall -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from .minio_helper import MinioEventThread, create_minio_client diff --git a/homeassistant/components/mobile_app/notify.py b/homeassistant/components/mobile_app/notify.py index c0efd302c47..1980c80ce69 100644 --- a/homeassistant/components/mobile_app/notify.py +++ b/homeassistant/components/mobile_app/notify.py @@ -21,7 +21,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import ( ATTR_APP_DATA, diff --git a/homeassistant/components/mochad/__init__.py b/homeassistant/components/mochad/__init__.py index c8714c902a3..9e992b5babd 100644 --- a/homeassistant/components/mochad/__init__.py +++ b/homeassistant/components/mochad/__init__.py @@ -13,7 +13,7 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/mold_indicator/sensor.py b/homeassistant/components/mold_indicator/sensor.py index 262d13ad3af..750ddce8513 100644 --- a/homeassistant/components/mold_indicator/sensor.py +++ b/homeassistant/components/mold_indicator/sensor.py @@ -34,7 +34,7 @@ from homeassistant.core import ( State, callback, ) -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.device import async_device_info_to_link_from_entity from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_state_change_event diff --git a/homeassistant/components/moon/sensor.py b/homeassistant/components/moon/sensor.py index 1e2674a24bf..09048579859 100644 --- a/homeassistant/components/moon/sensor.py +++ b/homeassistant/components/moon/sensor.py @@ -9,7 +9,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import DOMAIN diff --git a/homeassistant/components/mpd/media_player.py b/homeassistant/components/mpd/media_player.py index a79d933a782..db3901016f7 100644 --- a/homeassistant/components/mpd/media_player.py +++ b/homeassistant/components/mpd/media_player.py @@ -29,11 +29,10 @@ from homeassistant.components.media_player import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_PORT from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.util import Throttle -import homeassistant.util.dt as dt_util +from homeassistant.util import Throttle, dt as dt_util from .const import DOMAIN, LOGGER diff --git a/homeassistant/components/mqtt_eventstream/__init__.py b/homeassistant/components/mqtt_eventstream/__init__.py index 5e677d13cfe..20602e03f81 100644 --- a/homeassistant/components/mqtt_eventstream/__init__.py +++ b/homeassistant/components/mqtt_eventstream/__init__.py @@ -19,7 +19,7 @@ from homeassistant.const import ( MATCH_ALL, ) from homeassistant.core import EventOrigin, HomeAssistant, State, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.json import JSONEncoder from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/mqtt_json/device_tracker.py b/homeassistant/components/mqtt_json/device_tracker.py index 3200da56cf6..6f4e83799d1 100644 --- a/homeassistant/components/mqtt_json/device_tracker.py +++ b/homeassistant/components/mqtt_json/device_tracker.py @@ -21,7 +21,7 @@ from homeassistant.const import ( CONF_DEVICES, ) from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/mqtt_room/sensor.py b/homeassistant/components/mqtt_room/sensor.py index 849d4562423..242c39cb983 100644 --- a/homeassistant/components/mqtt_room/sensor.py +++ b/homeassistant/components/mqtt_room/sensor.py @@ -25,7 +25,7 @@ from homeassistant.const import ( STATE_NOT_HOME, ) from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util, slugify diff --git a/homeassistant/components/mqtt_statestream/__init__.py b/homeassistant/components/mqtt_statestream/__init__.py index 3a0953a0158..9a08fa2c73a 100644 --- a/homeassistant/components/mqtt_statestream/__init__.py +++ b/homeassistant/components/mqtt_statestream/__init__.py @@ -9,7 +9,7 @@ from homeassistant.components import mqtt from homeassistant.components.mqtt import valid_publish_topic from homeassistant.const import EVENT_HOMEASSISTANT_STOP, EVENT_STATE_CHANGED from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entityfilter import ( INCLUDE_EXCLUDE_BASE_FILTER_SCHEMA, convert_include_exclude_filter, diff --git a/homeassistant/components/msteams/notify.py b/homeassistant/components/msteams/notify.py index a4de5d126d5..06f9bc42e91 100644 --- a/homeassistant/components/msteams/notify.py +++ b/homeassistant/components/msteams/notify.py @@ -16,7 +16,7 @@ from homeassistant.components.notify import ( ) from homeassistant.const import CONF_URL from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/music_assistant/__init__.py b/homeassistant/components/music_assistant/__init__.py index 052f4f556c1..e569bb93a42 100644 --- a/homeassistant/components/music_assistant/__init__.py +++ b/homeassistant/components/music_assistant/__init__.py @@ -15,9 +15,8 @@ from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import CONF_URL, EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.core import Event, HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.issue_registry import ( IssueSeverity, async_create_issue, diff --git a/homeassistant/components/music_assistant/actions.py b/homeassistant/components/music_assistant/actions.py index f3297bf0a6f..bcd33b7fd6c 100644 --- a/homeassistant/components/music_assistant/actions.py +++ b/homeassistant/components/music_assistant/actions.py @@ -16,7 +16,7 @@ from homeassistant.core import ( callback, ) from homeassistant.exceptions import ServiceValidationError -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from .const import ( ATTR_ALBUM_ARTISTS_ONLY, diff --git a/homeassistant/components/music_assistant/media_player.py b/homeassistant/components/music_assistant/media_player.py index 9aa7498a2ee..4a7e20046b2 100644 --- a/homeassistant/components/music_assistant/media_player.py +++ b/homeassistant/components/music_assistant/media_player.py @@ -39,8 +39,7 @@ from homeassistant.components.media_player import ( from homeassistant.const import ATTR_NAME, STATE_OFF from homeassistant.core import HomeAssistant, ServiceResponse, SupportsResponse from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import entity_registry as er -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.entity_platform import ( AddEntitiesCallback, async_get_current_platform, diff --git a/homeassistant/components/music_assistant/schemas.py b/homeassistant/components/music_assistant/schemas.py index 9caae2ee0b4..d8c4fe1649d 100644 --- a/homeassistant/components/music_assistant/schemas.py +++ b/homeassistant/components/music_assistant/schemas.py @@ -8,7 +8,7 @@ from music_assistant_models.enums import MediaType import voluptuous as vol from homeassistant.const import ATTR_NAME -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from .const import ( ATTR_ACTIVE, diff --git a/homeassistant/components/mvglive/sensor.py b/homeassistant/components/mvglive/sensor.py index b482de8130c..d8b43517711 100644 --- a/homeassistant/components/mvglive/sensor.py +++ b/homeassistant/components/mvglive/sensor.py @@ -16,7 +16,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_NAME, UnitOfTime from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/mycroft/__init__.py b/homeassistant/components/mycroft/__init__.py index 557eca972e6..e5893e57a8e 100644 --- a/homeassistant/components/mycroft/__init__.py +++ b/homeassistant/components/mycroft/__init__.py @@ -4,8 +4,7 @@ import voluptuous as vol from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import discovery -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.typing import ConfigType DOMAIN = "mycroft" diff --git a/homeassistant/components/mysensors/config_flow.py b/homeassistant/components/mysensors/config_flow.py index f3fb03ffac8..e616e325835 100644 --- a/homeassistant/components/mysensors/config_flow.py +++ b/homeassistant/components/mysensors/config_flow.py @@ -20,8 +20,7 @@ from homeassistant.components.mqtt import ( from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_DEVICE from homeassistant.core import callback -from homeassistant.helpers import selector -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, selector from homeassistant.helpers.typing import VolDictType from .const import ( diff --git a/homeassistant/components/mysensors/gateway.py b/homeassistant/components/mysensors/gateway.py index fa3464c0088..bdc83f30b21 100644 --- a/homeassistant/components/mysensors/gateway.py +++ b/homeassistant/components/mysensors/gateway.py @@ -22,7 +22,7 @@ from homeassistant.components.mqtt import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DEVICE, EVENT_HOMEASSISTANT_STOP from homeassistant.core import Event, HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.service_info.mqtt import ReceivePayloadType from homeassistant.setup import SetupPhases, async_pause_setup from homeassistant.util.unit_system import METRIC_SYSTEM diff --git a/homeassistant/components/mysensors/helpers.py b/homeassistant/components/mysensors/helpers.py index 74dc99e76d3..c96ad6cea8e 100644 --- a/homeassistant/components/mysensors/helpers.py +++ b/homeassistant/components/mysensors/helpers.py @@ -14,7 +14,7 @@ import voluptuous as vol from homeassistant.const import CONF_NAME, Platform from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.util.decorator import Registry diff --git a/homeassistant/components/mythicbeastsdns/__init__.py b/homeassistant/components/mythicbeastsdns/__init__.py index f4de18aa0ef..58ac6051c8a 100644 --- a/homeassistant/components/mythicbeastsdns/__init__.py +++ b/homeassistant/components/mythicbeastsdns/__init__.py @@ -12,8 +12,8 @@ from homeassistant.const import ( CONF_SCAN_INTERVAL, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.typing import ConfigType From 9046ab025021979d955d14892e7f354b9d132b13 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 29 Jan 2025 11:56:50 +0100 Subject: [PATCH 0222/3148] Standardize homeassistant imports in component (i-k) (#136826) --- homeassistant/components/iammeter/sensor.py | 8 ++++++-- homeassistant/components/ibeacon/config_flow.py | 2 +- homeassistant/components/icloud/__init__.py | 2 +- homeassistant/components/idteck_prox/__init__.py | 2 +- homeassistant/components/ifttt/__init__.py | 3 +-- homeassistant/components/ifttt/alarm_control_panel.py | 2 +- homeassistant/components/ign_sismologia/geo_location.py | 2 +- homeassistant/components/ihc/__init__.py | 2 +- homeassistant/components/ihc/auto_setup.py | 3 +-- homeassistant/components/ihc/manual_setup.py | 3 +-- homeassistant/components/ihc/service_functions.py | 2 +- homeassistant/components/image_upload/__init__.py | 2 +- homeassistant/components/imap/__init__.py | 2 +- homeassistant/components/influxdb/__init__.py | 7 +++++-- homeassistant/components/influxdb/const.py | 2 +- homeassistant/components/influxdb/sensor.py | 2 +- homeassistant/components/insteon/api/properties.py | 2 +- homeassistant/components/insteon/schemas.py | 2 +- homeassistant/components/intesishome/climate.py | 2 +- homeassistant/components/ios/notify.py | 2 +- homeassistant/components/iperf3/__init__.py | 2 +- homeassistant/components/ipma/config_flow.py | 2 +- homeassistant/components/irish_rail_transport/sensor.py | 2 +- .../components/islamic_prayer_times/coordinator.py | 2 +- homeassistant/components/israel_rail/coordinator.py | 2 +- homeassistant/components/isy994/services.py | 2 +- homeassistant/components/itach/remote.py | 2 +- homeassistant/components/itunes/media_player.py | 2 +- homeassistant/components/izone/__init__.py | 2 +- homeassistant/components/jewish_calendar/binary_sensor.py | 2 +- homeassistant/components/jewish_calendar/sensor.py | 2 +- homeassistant/components/joaoapps_join/__init__.py | 2 +- homeassistant/components/joaoapps_join/notify.py | 2 +- homeassistant/components/kankun/switch.py | 2 +- homeassistant/components/keba/__init__.py | 3 +-- homeassistant/components/keenetic_ndms2/device_tracker.py | 2 +- homeassistant/components/keenetic_ndms2/router.py | 2 +- homeassistant/components/keyboard_remote/__init__.py | 2 +- homeassistant/components/kira/__init__.py | 3 +-- homeassistant/components/kitchen_sink/__init__.py | 2 +- homeassistant/components/kitchen_sink/weather.py | 2 +- homeassistant/components/kiwi/lock.py | 2 +- homeassistant/components/knx/datetime.py | 2 +- homeassistant/components/knx/schema.py | 2 +- homeassistant/components/knx/services.py | 2 +- homeassistant/components/knx/telegrams.py | 2 +- homeassistant/components/knx/validation.py | 2 +- homeassistant/components/kodi/media_player.py | 2 +- homeassistant/components/kodi/notify.py | 2 +- homeassistant/components/kwb/sensor.py | 2 +- 50 files changed, 59 insertions(+), 57 deletions(-) diff --git a/homeassistant/components/iammeter/sensor.py b/homeassistant/components/iammeter/sensor.py index 1069c6696fc..047281bdb27 100644 --- a/homeassistant/components/iammeter/sensor.py +++ b/homeassistant/components/iammeter/sensor.py @@ -32,8 +32,12 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady -from homeassistant.helpers import debounce, entity_registry as er, update_coordinator -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import ( + config_validation as cv, + debounce, + entity_registry as er, + update_coordinator, +) from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/ibeacon/config_flow.py b/homeassistant/components/ibeacon/config_flow.py index c00398e39b0..5850a623ad8 100644 --- a/homeassistant/components/ibeacon/config_flow.py +++ b/homeassistant/components/ibeacon/config_flow.py @@ -15,7 +15,7 @@ from homeassistant.config_entries import ( OptionsFlow, ) from homeassistant.core import callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import VolDictType from .const import CONF_ALLOW_NAMELESS_UUIDS, DOMAIN diff --git a/homeassistant/components/icloud/__init__.py b/homeassistant/components/icloud/__init__.py index 5bdfd00dc60..4ed66be6a4b 100644 --- a/homeassistant/components/icloud/__init__.py +++ b/homeassistant/components/icloud/__init__.py @@ -9,7 +9,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant, ServiceCall -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.storage import Store from homeassistant.util import slugify diff --git a/homeassistant/components/idteck_prox/__init__.py b/homeassistant/components/idteck_prox/__init__.py index 7b92499a197..68969f1eced 100644 --- a/homeassistant/components/idteck_prox/__init__.py +++ b/homeassistant/components/idteck_prox/__init__.py @@ -14,7 +14,7 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, ) from homeassistant.core import Event, HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/ifttt/__init__.py b/homeassistant/components/ifttt/__init__.py index e3db68e2302..c5682e5a8d9 100644 --- a/homeassistant/components/ifttt/__init__.py +++ b/homeassistant/components/ifttt/__init__.py @@ -15,8 +15,7 @@ from homeassistant.components import webhook from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_WEBHOOK_ID from homeassistant.core import HomeAssistant, ServiceCall -from homeassistant.helpers import config_entry_flow -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_entry_flow, config_validation as cv from homeassistant.helpers.typing import ConfigType from .const import DOMAIN diff --git a/homeassistant/components/ifttt/alarm_control_panel.py b/homeassistant/components/ifttt/alarm_control_panel.py index 739352485bd..f36fe8e672b 100644 --- a/homeassistant/components/ifttt/alarm_control_panel.py +++ b/homeassistant/components/ifttt/alarm_control_panel.py @@ -21,7 +21,7 @@ from homeassistant.const import ( CONF_OPTIMISTIC, ) from homeassistant.core import HomeAssistant, ServiceCall -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/ign_sismologia/geo_location.py b/homeassistant/components/ign_sismologia/geo_location.py index 7076d6a77a9..e99f2b23ca0 100644 --- a/homeassistant/components/ign_sismologia/geo_location.py +++ b/homeassistant/components/ign_sismologia/geo_location.py @@ -26,7 +26,7 @@ from homeassistant.const import ( UnitOfLength, ) from homeassistant.core import Event, HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect, dispatcher_send from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import track_time_interval diff --git a/homeassistant/components/ihc/__init__.py b/homeassistant/components/ihc/__init__.py index d443ac335db..0fc62301984 100644 --- a/homeassistant/components/ihc/__init__.py +++ b/homeassistant/components/ihc/__init__.py @@ -7,7 +7,7 @@ import voluptuous as vol from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from .auto_setup import autosetup_ihc_products diff --git a/homeassistant/components/ihc/auto_setup.py b/homeassistant/components/ihc/auto_setup.py index 2d6e59131cd..9b711875167 100644 --- a/homeassistant/components/ihc/auto_setup.py +++ b/homeassistant/components/ihc/auto_setup.py @@ -9,8 +9,7 @@ import voluptuous as vol from homeassistant.config import load_yaml_config_file from homeassistant.const import CONF_TYPE, CONF_UNIT_OF_MEASUREMENT, UnitOfTemperature from homeassistant.core import HomeAssistant -from homeassistant.helpers import discovery -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, discovery from .const import ( AUTO_SETUP_YAML, diff --git a/homeassistant/components/ihc/manual_setup.py b/homeassistant/components/ihc/manual_setup.py index c453494e263..f17920145e7 100644 --- a/homeassistant/components/ihc/manual_setup.py +++ b/homeassistant/components/ihc/manual_setup.py @@ -15,8 +15,7 @@ from homeassistant.const import ( CONF_USERNAME, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import discovery -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.typing import ConfigType from .const import ( diff --git a/homeassistant/components/ihc/service_functions.py b/homeassistant/components/ihc/service_functions.py index 61eba4791ac..d5507328e73 100644 --- a/homeassistant/components/ihc/service_functions.py +++ b/homeassistant/components/ihc/service_functions.py @@ -3,7 +3,7 @@ import voluptuous as vol from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from .const import ( ATTR_CONTROLLER_ID, diff --git a/homeassistant/components/image_upload/__init__.py b/homeassistant/components/image_upload/__init__.py index 5e9cf8c4e0e..2bf28d13fd2 100644 --- a/homeassistant/components/image_upload/__init__.py +++ b/homeassistant/components/image_upload/__init__.py @@ -22,7 +22,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import collection, config_validation as cv from homeassistant.helpers.storage import Store from homeassistant.helpers.typing import ConfigType, VolDictType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import DOMAIN diff --git a/homeassistant/components/imap/__init__.py b/homeassistant/components/imap/__init__.py index f62edf1451f..5349f249ab3 100644 --- a/homeassistant/components/imap/__init__.py +++ b/homeassistant/components/imap/__init__.py @@ -23,7 +23,7 @@ from homeassistant.exceptions import ( ConfigEntryNotReady, ServiceValidationError, ) -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from .const import CONF_ENABLE_PUSH, DOMAIN diff --git a/homeassistant/components/influxdb/__init__.py b/homeassistant/components/influxdb/__init__.py index b1c0cc53d61..95a94cf8fa0 100644 --- a/homeassistant/components/influxdb/__init__.py +++ b/homeassistant/components/influxdb/__init__.py @@ -40,8 +40,11 @@ from homeassistant.const import ( STATE_UNKNOWN, ) from homeassistant.core import Event, HomeAssistant, State, callback -from homeassistant.helpers import event as event_helper, state as state_helper -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import ( + config_validation as cv, + event as event_helper, + state as state_helper, +) from homeassistant.helpers.entity_values import EntityValues from homeassistant.helpers.entityfilter import ( INCLUDE_EXCLUDE_BASE_FILTER_SCHEMA, diff --git a/homeassistant/components/influxdb/const.py b/homeassistant/components/influxdb/const.py index cab9d1e4c41..78cb7908eec 100644 --- a/homeassistant/components/influxdb/const.py +++ b/homeassistant/components/influxdb/const.py @@ -16,7 +16,7 @@ from homeassistant.const import ( CONF_USERNAME, CONF_VERIFY_SSL, ) -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv CONF_DB_NAME = "database" CONF_BUCKET = "bucket" diff --git a/homeassistant/components/influxdb/sensor.py b/homeassistant/components/influxdb/sensor.py index cc601888f56..30319416a61 100644 --- a/homeassistant/components/influxdb/sensor.py +++ b/homeassistant/components/influxdb/sensor.py @@ -23,7 +23,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady, TemplateError -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import Throttle diff --git a/homeassistant/components/insteon/api/properties.py b/homeassistant/components/insteon/api/properties.py index 4d36f1d71e5..ac633e2a457 100644 --- a/homeassistant/components/insteon/api/properties.py +++ b/homeassistant/components/insteon/api/properties.py @@ -22,7 +22,7 @@ import voluptuous_serialize from homeassistant.components import websocket_api from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from ..const import ( DEVICE_ADDRESS, diff --git a/homeassistant/components/insteon/schemas.py b/homeassistant/components/insteon/schemas.py index 4cf8d49d170..70458dc5d6f 100644 --- a/homeassistant/components/insteon/schemas.py +++ b/homeassistant/components/insteon/schemas.py @@ -16,7 +16,7 @@ from homeassistant.const import ( CONF_USERNAME, ENTITY_MATCH_ALL, ) -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from .const import ( CONF_CAT, diff --git a/homeassistant/components/intesishome/climate.py b/homeassistant/components/intesishome/climate.py index 1a1f58a6b80..a04a6ee6377 100644 --- a/homeassistant/components/intesishome/climate.py +++ b/homeassistant/components/intesishome/climate.py @@ -32,8 +32,8 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_call_later from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/ios/notify.py b/homeassistant/components/ios/notify.py index b5bd0aea58f..cf70a97f52a 100644 --- a/homeassistant/components/ios/notify.py +++ b/homeassistant/components/ios/notify.py @@ -18,7 +18,7 @@ from homeassistant.components.notify import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import device_name_for_push_id, devices_with_push, enabled_push_ids diff --git a/homeassistant/components/iperf3/__init__.py b/homeassistant/components/iperf3/__init__.py index a621f1fb27e..3fbe447f9fb 100644 --- a/homeassistant/components/iperf3/__init__.py +++ b/homeassistant/components/iperf3/__init__.py @@ -24,7 +24,7 @@ from homeassistant.const import ( UnitOfDataRate, ) from homeassistant.core import HomeAssistant, ServiceCall -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.discovery import async_load_platform from homeassistant.helpers.dispatcher import dispatcher_send from homeassistant.helpers.event import async_track_time_interval diff --git a/homeassistant/components/ipma/config_flow.py b/homeassistant/components/ipma/config_flow.py index a0ecf1f582e..9b0fbe29736 100644 --- a/homeassistant/components/ipma/config_flow.py +++ b/homeassistant/components/ipma/config_flow.py @@ -10,8 +10,8 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from .const import DOMAIN diff --git a/homeassistant/components/irish_rail_transport/sensor.py b/homeassistant/components/irish_rail_transport/sensor.py index 2765a14b7a3..cd0ccccaece 100644 --- a/homeassistant/components/irish_rail_transport/sensor.py +++ b/homeassistant/components/irish_rail_transport/sensor.py @@ -14,7 +14,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_NAME, UnitOfTime from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/islamic_prayer_times/coordinator.py b/homeassistant/components/islamic_prayer_times/coordinator.py index 7005bee3585..35903afa393 100644 --- a/homeassistant/components/islamic_prayer_times/coordinator.py +++ b/homeassistant/components/islamic_prayer_times/coordinator.py @@ -13,7 +13,7 @@ from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback 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 homeassistant.util import dt as dt_util from .const import ( CONF_CALC_METHOD, diff --git a/homeassistant/components/israel_rail/coordinator.py b/homeassistant/components/israel_rail/coordinator.py index d707f8c5ea6..b022e3fd790 100644 --- a/homeassistant/components/israel_rail/coordinator.py +++ b/homeassistant/components/israel_rail/coordinator.py @@ -13,7 +13,7 @@ from israelrailapi.train_station import station_name_to_id from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import DEFAULT_SCAN_INTERVAL, DEPARTURES_COUNT, DOMAIN diff --git a/homeassistant/components/isy994/services.py b/homeassistant/components/isy994/services.py index 1cd46446ed6..6546aec6efa 100644 --- a/homeassistant/components/isy994/services.py +++ b/homeassistant/components/isy994/services.py @@ -14,7 +14,7 @@ from homeassistant.const import ( CONF_UNIT_OF_MEASUREMENT, ) from homeassistant.core import HomeAssistant, ServiceCall, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import async_get_platforms from homeassistant.helpers.service import entity_service_call diff --git a/homeassistant/components/itach/remote.py b/homeassistant/components/itach/remote.py index 986dbfb8b95..235d290cccb 100644 --- a/homeassistant/components/itach/remote.py +++ b/homeassistant/components/itach/remote.py @@ -24,7 +24,7 @@ from homeassistant.const import ( DEVICE_DEFAULT_NAME, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/itunes/media_player.py b/homeassistant/components/itunes/media_player.py index 0f241041c0d..92e3aefe975 100644 --- a/homeassistant/components/itunes/media_player.py +++ b/homeassistant/components/itunes/media_player.py @@ -16,7 +16,7 @@ from homeassistant.components.media_player import ( ) from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT, CONF_SSL from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/izone/__init__.py b/homeassistant/components/izone/__init__.py index c00f2d1f83f..1fd9a03e05f 100644 --- a/homeassistant/components/izone/__init__.py +++ b/homeassistant/components/izone/__init__.py @@ -6,7 +6,7 @@ from homeassistant import config_entries from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_EXCLUDE, EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from .const import DATA_CONFIG, IZONE diff --git a/homeassistant/components/jewish_calendar/binary_sensor.py b/homeassistant/components/jewish_calendar/binary_sensor.py index 9fd1371f8a8..85519bf37b0 100644 --- a/homeassistant/components/jewish_calendar/binary_sensor.py +++ b/homeassistant/components/jewish_calendar/binary_sensor.py @@ -18,7 +18,7 @@ from homeassistant.const import EntityCategory from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.helpers import event from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .entity import JewishCalendarConfigEntry, JewishCalendarEntity diff --git a/homeassistant/components/jewish_calendar/sensor.py b/homeassistant/components/jewish_calendar/sensor.py index d3e70eb411c..5e02435ed06 100644 --- a/homeassistant/components/jewish_calendar/sensor.py +++ b/homeassistant/components/jewish_calendar/sensor.py @@ -18,7 +18,7 @@ from homeassistant.const import SUN_EVENT_SUNSET, EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.sun import get_astral_event_date -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .entity import JewishCalendarConfigEntry, JewishCalendarEntity diff --git a/homeassistant/components/joaoapps_join/__init__.py b/homeassistant/components/joaoapps_join/__init__.py index f537866054f..89b5748a714 100644 --- a/homeassistant/components/joaoapps_join/__init__.py +++ b/homeassistant/components/joaoapps_join/__init__.py @@ -15,7 +15,7 @@ import voluptuous as vol from homeassistant.const import CONF_API_KEY, CONF_DEVICE_ID, CONF_NAME from homeassistant.core import HomeAssistant, ServiceCall -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/joaoapps_join/notify.py b/homeassistant/components/joaoapps_join/notify.py index 7fab894b0e4..a3432b96b13 100644 --- a/homeassistant/components/joaoapps_join/notify.py +++ b/homeassistant/components/joaoapps_join/notify.py @@ -16,7 +16,7 @@ from homeassistant.components.notify import ( ) from homeassistant.const import CONF_API_KEY, CONF_DEVICE_ID from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/kankun/switch.py b/homeassistant/components/kankun/switch.py index cd91b7660c8..51bddebeb77 100644 --- a/homeassistant/components/kankun/switch.py +++ b/homeassistant/components/kankun/switch.py @@ -22,7 +22,7 @@ from homeassistant.const import ( CONF_USERNAME, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/keba/__init__.py b/homeassistant/components/keba/__init__.py index 34eb7c99166..2c372cf1a25 100644 --- a/homeassistant/components/keba/__init__.py +++ b/homeassistant/components/keba/__init__.py @@ -8,8 +8,7 @@ import voluptuous as vol from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant, ServiceCall -from homeassistant.helpers import discovery -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/keenetic_ndms2/device_tracker.py b/homeassistant/components/keenetic_ndms2/device_tracker.py index efd2a88b1f8..0f5166e16dd 100644 --- a/homeassistant/components/keenetic_ndms2/device_tracker.py +++ b/homeassistant/components/keenetic_ndms2/device_tracker.py @@ -15,7 +15,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import DOMAIN, ROUTER from .router import KeeneticRouter diff --git a/homeassistant/components/keenetic_ndms2/router.py b/homeassistant/components/keenetic_ndms2/router.py index 5a4f32a05cd..8c3079b910d 100644 --- a/homeassistant/components/keenetic_ndms2/router.py +++ b/homeassistant/components/keenetic_ndms2/router.py @@ -22,7 +22,7 @@ from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_call_later -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import ( CONF_CONSIDER_HOME, diff --git a/homeassistant/components/keyboard_remote/__init__.py b/homeassistant/components/keyboard_remote/__init__.py index 5831a770466..979aeb73e45 100644 --- a/homeassistant/components/keyboard_remote/__init__.py +++ b/homeassistant/components/keyboard_remote/__init__.py @@ -14,7 +14,7 @@ import voluptuous as vol from homeassistant.const import EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/kira/__init__.py b/homeassistant/components/kira/__init__.py index 52618a125b6..092fdf8398c 100644 --- a/homeassistant/components/kira/__init__.py +++ b/homeassistant/components/kira/__init__.py @@ -21,8 +21,7 @@ from homeassistant.const import ( STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import discovery -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.typing import ConfigType DOMAIN = "kira" diff --git a/homeassistant/components/kitchen_sink/__init__.py b/homeassistant/components/kitchen_sink/__init__.py index 88d0c868636..09a72fc529c 100644 --- a/homeassistant/components/kitchen_sink/__init__.py +++ b/homeassistant/components/kitchen_sink/__init__.py @@ -24,7 +24,7 @@ from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN diff --git a/homeassistant/components/kitchen_sink/weather.py b/homeassistant/components/kitchen_sink/weather.py index 8a12cb4bdb9..e94e823c692 100644 --- a/homeassistant/components/kitchen_sink/weather.py +++ b/homeassistant/components/kitchen_sink/weather.py @@ -28,7 +28,7 @@ from homeassistant.const import UnitOfPressure, UnitOfSpeed, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_time_interval -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util CONDITION_CLASSES: dict[str, list[str]] = { ATTR_CONDITION_CLOUDY: [], diff --git a/homeassistant/components/kiwi/lock.py b/homeassistant/components/kiwi/lock.py index 887747d4ca4..d378fcbcbed 100644 --- a/homeassistant/components/kiwi/lock.py +++ b/homeassistant/components/kiwi/lock.py @@ -21,7 +21,7 @@ from homeassistant.const import ( CONF_USERNAME, ) from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_call_later from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/knx/datetime.py b/homeassistant/components/knx/datetime.py index caeaed6da93..b75e1a14f67 100644 --- a/homeassistant/components/knx/datetime.py +++ b/homeassistant/components/knx/datetime.py @@ -21,7 +21,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import KNXModule from .const import ( diff --git a/homeassistant/components/knx/schema.py b/homeassistant/components/knx/schema.py index 1ac2b82247c..c9fe0bfc34e 100644 --- a/homeassistant/components/knx/schema.py +++ b/homeassistant/components/knx/schema.py @@ -41,7 +41,7 @@ from homeassistant.const import ( CONF_VALUE_TEMPLATE, Platform, ) -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import ENTITY_CATEGORIES_SCHEMA from .const import ( diff --git a/homeassistant/components/knx/services.py b/homeassistant/components/knx/services.py index 6c392902737..f0f760180f4 100644 --- a/homeassistant/components/knx/services.py +++ b/homeassistant/components/knx/services.py @@ -15,7 +15,7 @@ from xknx.telegram.apci import GroupValueRead, GroupValueResponse, GroupValueWri from homeassistant.const import CONF_TYPE, SERVICE_RELOAD from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError, ServiceValidationError -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.service import async_register_admin_service from .const import ( diff --git a/homeassistant/components/knx/telegrams.py b/homeassistant/components/knx/telegrams.py index dcd5f477679..df49c84b6d5 100644 --- a/homeassistant/components/knx/telegrams.py +++ b/homeassistant/components/knx/telegrams.py @@ -15,7 +15,7 @@ from xknx.telegram.apci import GroupValueResponse, GroupValueWrite from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.storage import Store -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from homeassistant.util.signal_type import SignalType from .const import DOMAIN diff --git a/homeassistant/components/knx/validation.py b/homeassistant/components/knx/validation.py index 0283b65f899..6a2224c5561 100644 --- a/homeassistant/components/knx/validation.py +++ b/homeassistant/components/knx/validation.py @@ -10,7 +10,7 @@ from xknx.dpt import DPTBase, DPTNumeric, DPTString from xknx.exceptions import CouldNotParseAddress from xknx.telegram.address import IndividualAddress, parse_device_group_address -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv def dpt_subclass_validator(dpt_base_class: type[DPTBase]) -> Callable[[Any], str | int]: diff --git a/homeassistant/components/kodi/media_player.py b/homeassistant/components/kodi/media_player.py index cdbe4e334cb..bbddbd9f348 100644 --- a/homeassistant/components/kodi/media_player.py +++ b/homeassistant/components/kodi/media_player.py @@ -50,7 +50,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.network import is_internal_request from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, VolDictType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .browse_media import ( build_item_response, diff --git a/homeassistant/components/kodi/notify.py b/homeassistant/components/kodi/notify.py index c811a073cbb..8360f74ce24 100644 --- a/homeassistant/components/kodi/notify.py +++ b/homeassistant/components/kodi/notify.py @@ -24,8 +24,8 @@ from homeassistant.const import ( CONF_USERNAME, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/kwb/sensor.py b/homeassistant/components/kwb/sensor.py index dbe57f9a517..0074c3a4344 100644 --- a/homeassistant/components/kwb/sensor.py +++ b/homeassistant/components/kwb/sensor.py @@ -18,7 +18,7 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType From b594c29171ff74dfd5681185a97b2d22b77b5218 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 29 Jan 2025 11:57:01 +0100 Subject: [PATCH 0223/3148] Standardize homeassistant imports in component (g-h) (#136825) --- homeassistant/components/garadget/cover.py | 2 +- homeassistant/components/gardena_bluetooth/__init__.py | 2 +- homeassistant/components/gardena_bluetooth/sensor.py | 2 +- homeassistant/components/gc100/__init__.py | 2 +- homeassistant/components/gc100/binary_sensor.py | 2 +- homeassistant/components/gc100/switch.py | 2 +- homeassistant/components/geniushub/entity.py | 2 +- homeassistant/components/geniushub/sensor.py | 2 +- homeassistant/components/geo_rss_events/sensor.py | 2 +- homeassistant/components/geofency/__init__.py | 3 +-- homeassistant/components/github/config_flow.py | 2 +- homeassistant/components/gitlab_ci/sensor.py | 2 +- homeassistant/components/gitter/sensor.py | 2 +- homeassistant/components/goodwe/sensor.py | 2 +- homeassistant/components/google/__init__.py | 3 +-- homeassistant/components/google_cloud/helpers.py | 2 +- homeassistant/components/google_maps/device_tracker.py | 2 +- homeassistant/components/google_pubsub/__init__.py | 2 +- homeassistant/components/google_sheets/__init__.py | 2 +- homeassistant/components/google_travel_time/config_flow.py | 2 +- homeassistant/components/google_travel_time/sensor.py | 2 +- homeassistant/components/google_wifi/sensor.py | 2 +- homeassistant/components/gpslogger/__init__.py | 3 +-- homeassistant/components/graphite/__init__.py | 3 +-- homeassistant/components/greeneye_monitor/__init__.py | 2 +- homeassistant/components/greenwave/light.py | 2 +- homeassistant/components/gstreamer/media_player.py | 2 +- homeassistant/components/gtfs/sensor.py | 5 ++--- homeassistant/components/hardware/websocket_api.py | 2 +- homeassistant/components/harman_kardon_avr/media_player.py | 2 +- homeassistant/components/harmony/remote.py | 3 +-- homeassistant/components/haveibeenpwned/sensor.py | 5 ++--- homeassistant/components/hddtemp/sensor.py | 2 +- homeassistant/components/hdmi_cec/__init__.py | 3 +-- homeassistant/components/heatmiser/climate.py | 2 +- homeassistant/components/heos/__init__.py | 3 +-- homeassistant/components/here_travel_time/config_flow.py | 2 +- homeassistant/components/here_travel_time/coordinator.py | 2 +- homeassistant/components/hikvision/binary_sensor.py | 2 +- homeassistant/components/hikvisioncam/switch.py | 2 +- homeassistant/components/hisense_aehw4a1/__init__.py | 2 +- homeassistant/components/history/__init__.py | 4 ++-- homeassistant/components/history/websocket_api.py | 2 +- homeassistant/components/history_stats/data.py | 2 +- homeassistant/components/history_stats/helpers.py | 2 +- homeassistant/components/history_stats/sensor.py | 2 +- homeassistant/components/hitron_coda/device_tracker.py | 2 +- homeassistant/components/hlk_sw16/__init__.py | 2 +- homeassistant/components/home_connect/sensor.py | 3 +-- homeassistant/components/homekit/util.py | 2 +- homeassistant/components/homematic/__init__.py | 3 +-- homeassistant/components/homematic/entity.py | 2 +- homeassistant/components/homematic/notify.py | 3 +-- homeassistant/components/homematicip_cloud/services.py | 2 +- homeassistant/components/homeworks/__init__.py | 2 +- homeassistant/components/horizon/media_player.py | 2 +- homeassistant/components/hp_ilo/sensor.py | 2 +- homeassistant/components/hue/services.py | 2 +- homeassistant/components/hvv_departures/config_flow.py | 3 +-- homeassistant/components/hyperion/config_flow.py | 2 +- 60 files changed, 63 insertions(+), 76 deletions(-) diff --git a/homeassistant/components/garadget/cover.py b/homeassistant/components/garadget/cover.py index 82045e91321..ef11038aee4 100644 --- a/homeassistant/components/garadget/cover.py +++ b/homeassistant/components/garadget/cover.py @@ -23,7 +23,7 @@ from homeassistant.const import ( CONF_USERNAME, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import track_utc_time_change from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/gardena_bluetooth/__init__.py b/homeassistant/components/gardena_bluetooth/__init__.py index 7aae629974c..47034e61fb9 100644 --- a/homeassistant/components/gardena_bluetooth/__init__.py +++ b/homeassistant/components/gardena_bluetooth/__init__.py @@ -15,7 +15,7 @@ from homeassistant.const import CONF_ADDRESS, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.device_registry import DeviceInfo -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import DOMAIN from .coordinator import DeviceUnavailable, GardenaBluetoothCoordinator diff --git a/homeassistant/components/gardena_bluetooth/sensor.py b/homeassistant/components/gardena_bluetooth/sensor.py index ee8a2663218..c07d2ba6866 100644 --- a/homeassistant/components/gardena_bluetooth/sensor.py +++ b/homeassistant/components/gardena_bluetooth/sensor.py @@ -17,7 +17,7 @@ from homeassistant.components.sensor import ( from homeassistant.const import PERCENTAGE, EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import GardenaBluetoothConfigEntry from .coordinator import GardenaBluetoothCoordinator diff --git a/homeassistant/components/gc100/__init__.py b/homeassistant/components/gc100/__init__.py index 57c8e92499f..a43741b9249 100644 --- a/homeassistant/components/gc100/__init__.py +++ b/homeassistant/components/gc100/__init__.py @@ -5,7 +5,7 @@ import voluptuous as vol from homeassistant.const import CONF_HOST, CONF_PORT, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType CONF_PORTS = "ports" diff --git a/homeassistant/components/gc100/binary_sensor.py b/homeassistant/components/gc100/binary_sensor.py index 55df72cc3b9..cef798935cb 100644 --- a/homeassistant/components/gc100/binary_sensor.py +++ b/homeassistant/components/gc100/binary_sensor.py @@ -10,7 +10,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import DEVICE_DEFAULT_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/gc100/switch.py b/homeassistant/components/gc100/switch.py index 1bcdc7365cf..23b178cc647 100644 --- a/homeassistant/components/gc100/switch.py +++ b/homeassistant/components/gc100/switch.py @@ -12,7 +12,7 @@ from homeassistant.components.switch import ( ) from homeassistant.const import DEVICE_DEFAULT_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/geniushub/entity.py b/homeassistant/components/geniushub/entity.py index 7c40c41bda5..24917ab5e95 100644 --- a/homeassistant/components/geniushub/entity.py +++ b/homeassistant/components/geniushub/entity.py @@ -6,7 +6,7 @@ from typing import Any from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import ATTR_DURATION, ATTR_ZONE_MODE, DOMAIN, SVC_SET_ZONE_OVERRIDE diff --git a/homeassistant/components/geniushub/sensor.py b/homeassistant/components/geniushub/sensor.py index cfe4107428c..a558ad18672 100644 --- a/homeassistant/components/geniushub/sensor.py +++ b/homeassistant/components/geniushub/sensor.py @@ -9,7 +9,7 @@ from homeassistant.components.sensor import SensorDeviceClass, SensorEntity from homeassistant.const import PERCENTAGE from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import GeniusHubConfigEntry from .entity import GeniusDevice, GeniusEntity diff --git a/homeassistant/components/geo_rss_events/sensor.py b/homeassistant/components/geo_rss_events/sensor.py index 0dc8918b7dd..079a47a6c27 100644 --- a/homeassistant/components/geo_rss_events/sensor.py +++ b/homeassistant/components/geo_rss_events/sensor.py @@ -28,7 +28,7 @@ from homeassistant.const import ( UnitOfLength, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/geofency/__init__.py b/homeassistant/components/geofency/__init__.py index d38514fc412..46a3482ce1e 100644 --- a/homeassistant/components/geofency/__init__.py +++ b/homeassistant/components/geofency/__init__.py @@ -16,8 +16,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_entry_flow -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_entry_flow, config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.typing import ConfigType from homeassistant.util import slugify diff --git a/homeassistant/components/github/config_flow.py b/homeassistant/components/github/config_flow.py index 9977f9d84cc..17338119b9f 100644 --- a/homeassistant/components/github/config_flow.py +++ b/homeassistant/components/github/config_flow.py @@ -23,11 +23,11 @@ from homeassistant.config_entries import ( ) from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import ( SERVER_SOFTWARE, async_get_clientsession, ) -import homeassistant.helpers.config_validation as cv from .const import CLIENT_ID, CONF_REPOSITORIES, DEFAULT_REPOSITORIES, DOMAIN, LOGGER diff --git a/homeassistant/components/gitlab_ci/sensor.py b/homeassistant/components/gitlab_ci/sensor.py index 6ed3112b2af..933ba0e482e 100644 --- a/homeassistant/components/gitlab_ci/sensor.py +++ b/homeassistant/components/gitlab_ci/sensor.py @@ -14,7 +14,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_NAME, CONF_SCAN_INTERVAL, CONF_TOKEN, CONF_URL from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import Throttle diff --git a/homeassistant/components/gitter/sensor.py b/homeassistant/components/gitter/sensor.py index bc444655908..957ac4e9d8c 100644 --- a/homeassistant/components/gitter/sensor.py +++ b/homeassistant/components/gitter/sensor.py @@ -14,7 +14,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_API_KEY, CONF_NAME, CONF_ROOM from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/goodwe/sensor.py b/homeassistant/components/goodwe/sensor.py index 03912c9a1ec..5a88ac612da 100644 --- a/homeassistant/components/goodwe/sensor.py +++ b/homeassistant/components/goodwe/sensor.py @@ -37,7 +37,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_point_in_time from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import DOMAIN, KEY_COORDINATOR, KEY_DEVICE_INFO, KEY_INVERTER from .coordinator import GoodweUpdateCoordinator diff --git a/homeassistant/components/google/__init__.py b/homeassistant/components/google/__init__.py index 2ad400aabab..2b7aeadc0ba 100644 --- a/homeassistant/components/google/__init__.py +++ b/homeassistant/components/google/__init__.py @@ -28,9 +28,8 @@ from homeassistant.exceptions import ( ConfigEntryNotReady, HomeAssistantError, ) -from homeassistant.helpers import config_entry_oauth2_flow +from homeassistant.helpers import config_entry_oauth2_flow, config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import generate_entity_id from .api import ApiAuthImpl, get_feature_access diff --git a/homeassistant/components/google_cloud/helpers.py b/homeassistant/components/google_cloud/helpers.py index f1adc42b4cd..f71ccea00cc 100644 --- a/homeassistant/components/google_cloud/helpers.py +++ b/homeassistant/components/google_cloud/helpers.py @@ -12,7 +12,7 @@ from google.oauth2.service_account import Credentials import voluptuous as vol from homeassistant.components.tts import CONF_LANG -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.selector import ( NumberSelector, NumberSelectorConfig, diff --git a/homeassistant/components/google_maps/device_tracker.py b/homeassistant/components/google_maps/device_tracker.py index 31eca8fba01..fd50295a6a1 100644 --- a/homeassistant/components/google_maps/device_tracker.py +++ b/homeassistant/components/google_maps/device_tracker.py @@ -22,7 +22,7 @@ from homeassistant.const import ( CONF_USERNAME, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.event import track_time_interval from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util, slugify diff --git a/homeassistant/components/google_pubsub/__init__.py b/homeassistant/components/google_pubsub/__init__.py index f289fae2456..ace56bf9354 100644 --- a/homeassistant/components/google_pubsub/__init__.py +++ b/homeassistant/components/google_pubsub/__init__.py @@ -12,7 +12,7 @@ import voluptuous as vol from homeassistant.const import EVENT_STATE_CHANGED, STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.core import Event, EventStateChangedData, HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entityfilter import FILTER_SCHEMA from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/google_sheets/__init__.py b/homeassistant/components/google_sheets/__init__.py index 3f34b23d522..942db675b5a 100644 --- a/homeassistant/components/google_sheets/__init__.py +++ b/homeassistant/components/google_sheets/__init__.py @@ -20,11 +20,11 @@ from homeassistant.exceptions import ( ConfigEntryNotReady, HomeAssistantError, ) +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.config_entry_oauth2_flow import ( OAuth2Session, async_get_config_entry_implementation, ) -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.selector import ConfigEntrySelector from .const import DEFAULT_ACCESS, DOMAIN diff --git a/homeassistant/components/google_travel_time/config_flow.py b/homeassistant/components/google_travel_time/config_flow.py index 08de293bc7d..a29d3d75b3e 100644 --- a/homeassistant/components/google_travel_time/config_flow.py +++ b/homeassistant/components/google_travel_time/config_flow.py @@ -14,7 +14,7 @@ from homeassistant.config_entries import ( ) from homeassistant.const import CONF_API_KEY, CONF_LANGUAGE, CONF_MODE, CONF_NAME from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.selector import ( SelectSelector, SelectSelectorConfig, diff --git a/homeassistant/components/google_travel_time/sensor.py b/homeassistant/components/google_travel_time/sensor.py index a764036321b..a3f9c236136 100644 --- a/homeassistant/components/google_travel_time/sensor.py +++ b/homeassistant/components/google_travel_time/sensor.py @@ -25,7 +25,7 @@ from homeassistant.core import CoreState, HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.location import find_coordinates -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import ( ATTRIBUTION, diff --git a/homeassistant/components/google_wifi/sensor.py b/homeassistant/components/google_wifi/sensor.py index 3dd421d99da..6ce1c49410f 100644 --- a/homeassistant/components/google_wifi/sensor.py +++ b/homeassistant/components/google_wifi/sensor.py @@ -21,7 +21,7 @@ from homeassistant.const import ( UnitOfTime, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import Throttle, dt as dt_util diff --git a/homeassistant/components/gpslogger/__init__.py b/homeassistant/components/gpslogger/__init__.py index 50a98e277a6..7c7612ed201 100644 --- a/homeassistant/components/gpslogger/__init__.py +++ b/homeassistant/components/gpslogger/__init__.py @@ -10,8 +10,7 @@ from homeassistant.components.device_tracker import ATTR_BATTERY from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE, CONF_WEBHOOK_ID, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_entry_flow -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_entry_flow, config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_send from .const import ( diff --git a/homeassistant/components/graphite/__init__.py b/homeassistant/components/graphite/__init__.py index 336ca6ba2cb..8d1864f5522 100644 --- a/homeassistant/components/graphite/__init__.py +++ b/homeassistant/components/graphite/__init__.py @@ -19,8 +19,7 @@ from homeassistant.const import ( EVENT_STATE_CHANGED, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import state -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, state from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/greeneye_monitor/__init__.py b/homeassistant/components/greeneye_monitor/__init__.py index 083d431e338..e3acbcd56e9 100644 --- a/homeassistant/components/greeneye_monitor/__init__.py +++ b/homeassistant/components/greeneye_monitor/__init__.py @@ -17,7 +17,7 @@ from homeassistant.const import ( UnitOfTime, ) from homeassistant.core import Event, HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.discovery import async_load_platform from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/greenwave/light.py b/homeassistant/components/greenwave/light.py index 89d3ca3a535..9b7a3cf29ea 100644 --- a/homeassistant/components/greenwave/light.py +++ b/homeassistant/components/greenwave/light.py @@ -18,7 +18,7 @@ from homeassistant.components.light import ( ) from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import Throttle diff --git a/homeassistant/components/gstreamer/media_player.py b/homeassistant/components/gstreamer/media_player.py index fd9de62c016..bb78aff8faf 100644 --- a/homeassistant/components/gstreamer/media_player.py +++ b/homeassistant/components/gstreamer/media_player.py @@ -20,7 +20,7 @@ from homeassistant.components.media_player import ( ) from homeassistant.const import CONF_NAME, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/gtfs/sensor.py b/homeassistant/components/gtfs/sensor.py index f9e9c31ce46..2637a55f772 100644 --- a/homeassistant/components/gtfs/sensor.py +++ b/homeassistant/components/gtfs/sensor.py @@ -19,11 +19,10 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_NAME, CONF_OFFSET, STATE_UNKNOWN from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.util import slugify -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util, slugify _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/hardware/websocket_api.py b/homeassistant/components/hardware/websocket_api.py index dfbcfd4c4ac..7224c0f8f7e 100644 --- a/homeassistant/components/hardware/websocket_api.py +++ b/homeassistant/components/hardware/websocket_api.py @@ -14,7 +14,7 @@ from homeassistant.components import websocket_api from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.event import async_track_time_interval -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import DOMAIN from .hardware import async_process_hardware_platforms diff --git a/homeassistant/components/harman_kardon_avr/media_player.py b/homeassistant/components/harman_kardon_avr/media_player.py index b8d9f27bcf1..22bc1a6d529 100644 --- a/homeassistant/components/harman_kardon_avr/media_player.py +++ b/homeassistant/components/harman_kardon_avr/media_player.py @@ -13,7 +13,7 @@ from homeassistant.components.media_player import ( ) from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/harmony/remote.py b/homeassistant/components/harmony/remote.py index efbd4b2ac02..43bf0a348c0 100644 --- a/homeassistant/components/harmony/remote.py +++ b/homeassistant/components/harmony/remote.py @@ -20,8 +20,7 @@ from homeassistant.components.remote import ( RemoteEntityFeature, ) from homeassistant.core import HassJob, HomeAssistant, callback -from homeassistant.helpers import entity_platform -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity diff --git a/homeassistant/components/haveibeenpwned/sensor.py b/homeassistant/components/haveibeenpwned/sensor.py index 1aebe696e82..d9d2889848e 100644 --- a/homeassistant/components/haveibeenpwned/sensor.py +++ b/homeassistant/components/haveibeenpwned/sensor.py @@ -15,12 +15,11 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_API_KEY, CONF_EMAIL from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import track_point_in_time from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.util import Throttle -import homeassistant.util.dt as dt_util +from homeassistant.util import Throttle, dt as dt_util _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/hddtemp/sensor.py b/homeassistant/components/hddtemp/sensor.py index 7ff00b8e282..4d9bbeb9516 100644 --- a/homeassistant/components/hddtemp/sensor.py +++ b/homeassistant/components/hddtemp/sensor.py @@ -23,7 +23,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/hdmi_cec/__init__.py b/homeassistant/components/hdmi_cec/__init__.py index 6b4a949c0fc..3e31dd73b5d 100644 --- a/homeassistant/components/hdmi_cec/__init__.py +++ b/homeassistant/components/hdmi_cec/__init__.py @@ -33,8 +33,7 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, ) from homeassistant.core import HassJob, HomeAssistant, ServiceCall, callback -from homeassistant.helpers import discovery, event -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, discovery, event from homeassistant.helpers.typing import ConfigType from .const import DOMAIN, EVENT_HDMI_CEC_UNAVAILABLE diff --git a/homeassistant/components/heatmiser/climate.py b/homeassistant/components/heatmiser/climate.py index de66315a467..f44156bdcb0 100644 --- a/homeassistant/components/heatmiser/climate.py +++ b/homeassistant/components/heatmiser/climate.py @@ -23,7 +23,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/heos/__init__.py b/homeassistant/components/heos/__init__.py index f76b95c271e..d735469c5cb 100644 --- a/homeassistant/components/heos/__init__.py +++ b/homeassistant/components/heos/__init__.py @@ -6,8 +6,7 @@ from datetime import timedelta from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.typing import ConfigType from . import services diff --git a/homeassistant/components/here_travel_time/config_flow.py b/homeassistant/components/here_travel_time/config_flow.py index c2b70de148c..6425b5ffbed 100644 --- a/homeassistant/components/here_travel_time/config_flow.py +++ b/homeassistant/components/here_travel_time/config_flow.py @@ -31,7 +31,7 @@ from homeassistant.const import ( CONF_NAME, ) from homeassistant.core import callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.selector import ( EntitySelector, LocationSelector, diff --git a/homeassistant/components/here_travel_time/coordinator.py b/homeassistant/components/here_travel_time/coordinator.py index 6591f4cb5cc..65e1305e44e 100644 --- a/homeassistant/components/here_travel_time/coordinator.py +++ b/homeassistant/components/here_travel_time/coordinator.py @@ -27,7 +27,7 @@ import voluptuous as vol from homeassistant.const import UnitOfLength from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.location import find_coordinates from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import dt as dt_util diff --git a/homeassistant/components/hikvision/binary_sensor.py b/homeassistant/components/hikvision/binary_sensor.py index 0656733db6b..76cca5079e4 100644 --- a/homeassistant/components/hikvision/binary_sensor.py +++ b/homeassistant/components/hikvision/binary_sensor.py @@ -27,7 +27,7 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import track_point_in_utc_time from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/hikvisioncam/switch.py b/homeassistant/components/hikvisioncam/switch.py index 653d5a07174..aa16097f402 100644 --- a/homeassistant/components/hikvisioncam/switch.py +++ b/homeassistant/components/hikvisioncam/switch.py @@ -23,7 +23,7 @@ from homeassistant.const import ( STATE_ON, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/hisense_aehw4a1/__init__.py b/homeassistant/components/hisense_aehw4a1/__init__.py index d20f6d13217..3694853fb5a 100644 --- a/homeassistant/components/hisense_aehw4a1/__init__.py +++ b/homeassistant/components/hisense_aehw4a1/__init__.py @@ -12,7 +12,7 @@ from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_IP_ADDRESS, Platform from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from .const import DOMAIN diff --git a/homeassistant/components/history/__init__.py b/homeassistant/components/history/__init__.py index ba4614bbc35..fd82b74b048 100644 --- a/homeassistant/components/history/__init__.py +++ b/homeassistant/components/history/__init__.py @@ -15,10 +15,10 @@ from homeassistant.components.recorder import get_instance, history from homeassistant.components.recorder.util import session_scope from homeassistant.const import CONF_EXCLUDE, CONF_INCLUDE from homeassistant.core import HomeAssistant, valid_entity_id -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entityfilter import INCLUDE_EXCLUDE_BASE_FILTER_SCHEMA from homeassistant.helpers.typing import ConfigType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import websocket_api from .const import DOMAIN diff --git a/homeassistant/components/history/websocket_api.py b/homeassistant/components/history/websocket_api.py index e6c91453213..c57e766eaed 100644 --- a/homeassistant/components/history/websocket_api.py +++ b/homeassistant/components/history/websocket_api.py @@ -35,8 +35,8 @@ from homeassistant.helpers.event import ( async_track_state_change_event, ) from homeassistant.helpers.json import json_bytes +from homeassistant.util import dt as dt_util from homeassistant.util.async_ import create_eager_task -import homeassistant.util.dt as dt_util from .const import EVENT_COALESCE_TIME, MAX_PENDING_HISTORY_STATES from .helpers import entities_may_have_state_changes_after, has_states_before diff --git a/homeassistant/components/history_stats/data.py b/homeassistant/components/history_stats/data.py index 83528b73f6f..a69abe26f6c 100644 --- a/homeassistant/components/history_stats/data.py +++ b/homeassistant/components/history_stats/data.py @@ -10,7 +10,7 @@ import math from homeassistant.components.recorder import get_instance, history from homeassistant.core import Event, EventStateChangedData, HomeAssistant, State from homeassistant.helpers.template import Template -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .helpers import async_calculate_period, floored_timestamp diff --git a/homeassistant/components/history_stats/helpers.py b/homeassistant/components/history_stats/helpers.py index 33a45d10735..99214a51369 100644 --- a/homeassistant/components/history_stats/helpers.py +++ b/homeassistant/components/history_stats/helpers.py @@ -9,7 +9,7 @@ import math from homeassistant.core import callback from homeassistant.exceptions import TemplateError from homeassistant.helpers.template import Template -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/history_stats/sensor.py b/homeassistant/components/history_stats/sensor.py index e1241034aeb..b25daf56598 100644 --- a/homeassistant/components/history_stats/sensor.py +++ b/homeassistant/components/history_stats/sensor.py @@ -25,7 +25,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import PlatformNotReady -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.device import async_device_info_to_link_from_entity from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.reload import async_setup_reload_service diff --git a/homeassistant/components/hitron_coda/device_tracker.py b/homeassistant/components/hitron_coda/device_tracker.py index 2126f5834ce..25de2d8956e 100644 --- a/homeassistant/components/hitron_coda/device_tracker.py +++ b/homeassistant/components/hitron_coda/device_tracker.py @@ -16,7 +16,7 @@ from homeassistant.components.device_tracker import ( ) from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_TYPE, CONF_USERNAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/hlk_sw16/__init__.py b/homeassistant/components/hlk_sw16/__init__.py index ce37be96dcd..ebd92908b93 100644 --- a/homeassistant/components/hlk_sw16/__init__.py +++ b/homeassistant/components/hlk_sw16/__init__.py @@ -8,7 +8,7 @@ import voluptuous as vol from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT, CONF_SWITCHES, Platform from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/home_connect/sensor.py b/homeassistant/components/home_connect/sensor.py index 7b82ef8b676..c11254d2c02 100644 --- a/homeassistant/components/home_connect/sensor.py +++ b/homeassistant/components/home_connect/sensor.py @@ -14,8 +14,7 @@ from homeassistant.components.sensor import ( from homeassistant.const import PERCENTAGE, UnitOfTime, UnitOfVolume from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.util import slugify -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util, slugify from . import HomeConnectConfigEntry from .const import ( diff --git a/homeassistant/components/homekit/util.py b/homeassistant/components/homekit/util.py index a0dfcea7616..c36738b286d 100644 --- a/homeassistant/components/homekit/util.py +++ b/homeassistant/components/homekit/util.py @@ -47,7 +47,7 @@ from homeassistant.core import ( callback, split_entity_id, ) -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.storage import STORAGE_DIR from homeassistant.util.unit_conversion import TemperatureConverter diff --git a/homeassistant/components/homematic/__init__.py b/homeassistant/components/homematic/__init__.py index f0fc2a40278..710f2ede52b 100644 --- a/homeassistant/components/homematic/__init__.py +++ b/homeassistant/components/homematic/__init__.py @@ -24,8 +24,7 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, ) from homeassistant.core import HomeAssistant, ServiceCall -from homeassistant.helpers import discovery -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.typing import ConfigType from .const import ( diff --git a/homeassistant/components/homematic/entity.py b/homeassistant/components/homematic/entity.py index ac0a05d24c1..5a5b2a3b8c8 100644 --- a/homeassistant/components/homematic/entity.py +++ b/homeassistant/components/homematic/entity.py @@ -10,7 +10,7 @@ from pyhomematic import HMConnection from pyhomematic.devicetypes.generic import HMGeneric from homeassistant.const import ATTR_NAME -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.event import track_time_interval diff --git a/homeassistant/components/homematic/notify.py b/homeassistant/components/homematic/notify.py index ced8ea6a951..1f89abea5cc 100644 --- a/homeassistant/components/homematic/notify.py +++ b/homeassistant/components/homematic/notify.py @@ -10,8 +10,7 @@ from homeassistant.components.notify import ( BaseNotificationService, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv -import homeassistant.helpers.template as template_helper +from homeassistant.helpers import config_validation as cv, template as template_helper from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import ( diff --git a/homeassistant/components/homematicip_cloud/services.py b/homeassistant/components/homematicip_cloud/services.py index 69765ccc601..7a4dfd4916f 100644 --- a/homeassistant/components/homematicip_cloud/services.py +++ b/homeassistant/components/homematicip_cloud/services.py @@ -14,7 +14,7 @@ import voluptuous as vol from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.exceptions import ServiceValidationError -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.config_validation import comp_entity_ids from homeassistant.helpers.service import ( async_register_admin_service, diff --git a/homeassistant/components/homeworks/__init__.py b/homeassistant/components/homeworks/__init__.py index e9e8c969b61..75fdeb4f8cc 100644 --- a/homeassistant/components/homeworks/__init__.py +++ b/homeassistant/components/homeworks/__init__.py @@ -30,7 +30,7 @@ from homeassistant.const import ( ) from homeassistant.core import Event, HomeAssistant, ServiceCall, callback from homeassistant.exceptions import ConfigEntryNotReady, ServiceValidationError -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.dispatcher import async_dispatcher_connect, dispatcher_send from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/horizon/media_player.py b/homeassistant/components/horizon/media_player.py index ba3ca5e2e35..d1b733ab84a 100644 --- a/homeassistant/components/horizon/media_player.py +++ b/homeassistant/components/horizon/media_player.py @@ -21,7 +21,7 @@ from homeassistant.components.media_player import ( from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/hp_ilo/sensor.py b/homeassistant/components/hp_ilo/sensor.py index 0eeb443cf2d..b4263f53d24 100644 --- a/homeassistant/components/hp_ilo/sensor.py +++ b/homeassistant/components/hp_ilo/sensor.py @@ -24,7 +24,7 @@ from homeassistant.const import ( CONF_VALUE_TEMPLATE, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import Throttle diff --git a/homeassistant/components/hue/services.py b/homeassistant/components/hue/services.py index 30555339f19..de6da161fba 100644 --- a/homeassistant/components/hue/services.py +++ b/homeassistant/components/hue/services.py @@ -9,7 +9,7 @@ from aiohue import HueBridgeV1, HueBridgeV2 import voluptuous as vol from homeassistant.core import HomeAssistant, ServiceCall -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.service import verify_domain_control from .bridge import HueBridge diff --git a/homeassistant/components/hvv_departures/config_flow.py b/homeassistant/components/hvv_departures/config_flow.py index 536b8f18259..d76ccef7cab 100644 --- a/homeassistant/components/hvv_departures/config_flow.py +++ b/homeassistant/components/hvv_departures/config_flow.py @@ -17,8 +17,7 @@ from homeassistant.config_entries import ( ) from homeassistant.const import CONF_HOST, CONF_OFFSET, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import callback -from homeassistant.helpers import aiohttp_client -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import aiohttp_client, config_validation as cv from .const import CONF_FILTER, CONF_REAL_TIME, CONF_STATION, DOMAIN from .hub import GTIHub diff --git a/homeassistant/components/hyperion/config_flow.py b/homeassistant/components/hyperion/config_flow.py index 045fbd986cc..72e76ef8667 100644 --- a/homeassistant/components/hyperion/config_flow.py +++ b/homeassistant/components/hyperion/config_flow.py @@ -28,7 +28,7 @@ from homeassistant.const import ( CONF_TOKEN, ) from homeassistant.core import callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.service_info.ssdp import ATTR_UPNP_SERIAL, SsdpServiceInfo from . import create_hyperion_client From ddb71a85b3fff393143e448519ed531b091ef06a Mon Sep 17 00:00:00 2001 From: Nathan Spencer Date: Wed, 29 Jan 2025 03:58:14 -0700 Subject: [PATCH 0224/3148] Update quality scale for litterrobot (#136764) --- .../components/litterrobot/manifest.json | 1 + .../components/litterrobot/quality_scale.yaml | 44 ++++--------------- script/hassfest/quality_scale.py | 1 - 3 files changed, 10 insertions(+), 36 deletions(-) diff --git a/homeassistant/components/litterrobot/manifest.json b/homeassistant/components/litterrobot/manifest.json index 4f1deb9a567..f7563296711 100644 --- a/homeassistant/components/litterrobot/manifest.json +++ b/homeassistant/components/litterrobot/manifest.json @@ -12,5 +12,6 @@ "integration_type": "hub", "iot_class": "cloud_push", "loggers": ["pylitterbot"], + "quality_scale": "bronze", "requirements": ["pylitterbot==2024.0.0"] } diff --git a/homeassistant/components/litterrobot/quality_scale.yaml b/homeassistant/components/litterrobot/quality_scale.yaml index d5f943943bc..82f01f64d18 100644 --- a/homeassistant/components/litterrobot/quality_scale.yaml +++ b/homeassistant/components/litterrobot/quality_scale.yaml @@ -1,40 +1,20 @@ rules: - # Adjust platform files for consistent flow: - # [entity description classes] - # [entity descriptions] - # [async_setup_entry] - # [entity classes]) - # Remove RequiredKeyMixins and add kw_only to classes - # Wrap multiline lambdas in parenthesis - # Extend entity description in switch.py to use value_fn instead of getattr - # Deprecate extra state attributes in vacuum.py # Bronze - action-setup: - status: todo - comment: | - Action async_set_sleep_mode is currently setup in the vacuum platform + action-setup: done appropriate-polling: status: done comment: | Primarily relies on push data, but polls every 5 minutes for missed updates brands: done - common-modules: - status: todo - comment: | - hub.py should be renamed to coordinator.py and updated accordingly - Also should not need to return bool (never used) + common-modules: done config-flow-test-coverage: done config-flow: done dependency-transparency: done - docs-actions: - status: todo - comment: Can be finished after async_set_sleep_mode is moved to async_setup + docs-actions: done docs-high-level-description: done - docs-installation-instructions: todo - docs-removal-instructions: todo - entity-event-setup: - status: todo - comment: Do we need to subscribe to both the coordinator and robot itself? + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: done entity-unique-id: done has-entity-name: done runtime-data: done @@ -61,9 +41,7 @@ rules: Other fields can be moved to const.py. Consider snapshots and testing data updates # Gold - devices: - status: done - comment: Currently uses the device_info property, could be moved to _attr_device_info + devices: done diagnostics: todo discovery-update-info: status: done @@ -81,16 +59,12 @@ rules: dynamic-devices: todo entity-category: done entity-device-class: done - entity-disabled-by-default: - status: todo - comment: Check if we should disable any entities by default + entity-disabled-by-default: done entity-translations: status: todo comment: Make sure all translated states are in sentence case exception-translations: todo - icon-translations: - status: todo - comment: BRIGHTNESS_LEVEL_ICON_MAP should be migrated to icons.json + icon-translations: done reconfiguration-flow: todo repair-issues: status: done diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index 3eedc43f613..72c1cfae219 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -1671,7 +1671,6 @@ INTEGRATIONS_WITHOUT_SCALE = [ "linux_battery", "lirc", "litejet", - "litterrobot", "livisi", "llamalab_automate", "local_calendar", From 95c632e2836f1757bddfec10296d1e107cd9dc8b Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 29 Jan 2025 12:14:21 +0100 Subject: [PATCH 0225/3148] Standardize homeassistant imports in component (t-u) (#136833) --- homeassistant/components/tami4/config_flow.py | 2 +- homeassistant/components/tank_utility/sensor.py | 2 +- homeassistant/components/tankerkoenig/config_flow.py | 2 +- homeassistant/components/tapsaff/binary_sensor.py | 2 +- homeassistant/components/tasmota/binary_sensor.py | 2 +- homeassistant/components/tcp/common.py | 2 +- homeassistant/components/tellstick/__init__.py | 3 +-- homeassistant/components/tellstick/sensor.py | 2 +- homeassistant/components/telnet/switch.py | 2 +- homeassistant/components/tensorflow/image_processing.py | 3 +-- homeassistant/components/tesla_fleet/__init__.py | 3 +-- homeassistant/components/teslemetry/__init__.py | 3 +-- homeassistant/components/tfiac/climate.py | 2 +- homeassistant/components/thermoworks_smoke/sensor.py | 2 +- homeassistant/components/thingspeak/__init__.py | 3 +-- homeassistant/components/thinkingcleaner/sensor.py | 2 +- homeassistant/components/thinkingcleaner/switch.py | 2 +- homeassistant/components/thomson/device_tracker.py | 2 +- homeassistant/components/tibber/__init__.py | 2 +- homeassistant/components/time_date/sensor.py | 4 ++-- homeassistant/components/tmb/sensor.py | 2 +- homeassistant/components/todoist/calendar.py | 2 +- homeassistant/components/tomato/device_tracker.py | 2 +- homeassistant/components/torque/sensor.py | 2 +- homeassistant/components/touchline/climate.py | 2 +- homeassistant/components/tplink/light.py | 2 +- homeassistant/components/traccar/__init__.py | 3 +-- homeassistant/components/trafikverket_train/config_flow.py | 2 +- .../components/trafikverket_weatherstation/config_flow.py | 2 +- homeassistant/components/transport_nsw/sensor.py | 2 +- homeassistant/components/travisci/sensor.py | 2 +- homeassistant/components/trend/binary_sensor.py | 3 +-- homeassistant/components/twentemilieu/calendar.py | 2 +- homeassistant/components/twilio/__init__.py | 3 +-- homeassistant/components/twilio_call/notify.py | 2 +- homeassistant/components/twilio_sms/notify.py | 2 +- homeassistant/components/twitter/notify.py | 2 +- homeassistant/components/ubus/device_tracker.py | 2 +- homeassistant/components/uk_transport/sensor.py | 5 ++--- homeassistant/components/unifi/config_flow.py | 2 +- homeassistant/components/unifi/device_tracker.py | 2 +- homeassistant/components/unifi/hub/entity_helper.py | 2 +- homeassistant/components/unifi/image.py | 2 +- homeassistant/components/unifi/sensor.py | 3 +-- homeassistant/components/unifi_direct/device_tracker.py | 2 +- homeassistant/components/unifiled/light.py | 2 +- homeassistant/components/upb/const.py | 2 +- homeassistant/components/upc_connect/device_tracker.py | 2 +- homeassistant/components/uptime/sensor.py | 2 +- .../components/usgs_earthquakes_feed/geo_location.py | 3 +-- homeassistant/components/utility_meter/__init__.py | 7 +++++-- homeassistant/components/utility_meter/sensor.py | 3 +-- homeassistant/components/uvc/camera.py | 2 +- 53 files changed, 59 insertions(+), 68 deletions(-) diff --git a/homeassistant/components/tami4/config_flow.py b/homeassistant/components/tami4/config_flow.py index 72b19470f45..a58c801c403 100644 --- a/homeassistant/components/tami4/config_flow.py +++ b/homeassistant/components/tami4/config_flow.py @@ -11,7 +11,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.exceptions import HomeAssistantError -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from .const import CONF_PHONE, CONF_REFRESH_TOKEN, DOMAIN diff --git a/homeassistant/components/tank_utility/sensor.py b/homeassistant/components/tank_utility/sensor.py index 6d4327a1d06..e9377e346d4 100644 --- a/homeassistant/components/tank_utility/sensor.py +++ b/homeassistant/components/tank_utility/sensor.py @@ -15,7 +15,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_DEVICES, CONF_EMAIL, CONF_PASSWORD, PERCENTAGE from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/tankerkoenig/config_flow.py b/homeassistant/components/tankerkoenig/config_flow.py index 509f293665d..8796ae46ab7 100644 --- a/homeassistant/components/tankerkoenig/config_flow.py +++ b/homeassistant/components/tankerkoenig/config_flow.py @@ -31,8 +31,8 @@ from homeassistant.const import ( UnitOfLength, ) from homeassistant.core import callback +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.selector import ( LocationSelector, NumberSelector, diff --git a/homeassistant/components/tapsaff/binary_sensor.py b/homeassistant/components/tapsaff/binary_sensor.py index 0eb612bdc8e..beba9c91538 100644 --- a/homeassistant/components/tapsaff/binary_sensor.py +++ b/homeassistant/components/tapsaff/binary_sensor.py @@ -14,7 +14,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import CONF_LOCATION, CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/tasmota/binary_sensor.py b/homeassistant/components/tasmota/binary_sensor.py index 8a4b501af05..22cdf1a5ff0 100644 --- a/homeassistant/components/tasmota/binary_sensor.py +++ b/homeassistant/components/tasmota/binary_sensor.py @@ -14,9 +14,9 @@ from homeassistant.components import binary_sensor from homeassistant.components.binary_sensor import BinarySensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import event as evt from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.helpers.event as evt from .const import DATA_REMOVE_DISCOVER_COMPONENT from .discovery import TASMOTA_DISCOVERY_ENTITY_NEW diff --git a/homeassistant/components/tcp/common.py b/homeassistant/components/tcp/common.py index a89cd999ddd..1263effa96b 100644 --- a/homeassistant/components/tcp/common.py +++ b/homeassistant/components/tcp/common.py @@ -17,7 +17,7 @@ from homeassistant.const import ( CONF_VALUE_TEMPLATE, CONF_VERIFY_SSL, ) -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from .const import ( CONF_BUFFER_SIZE, diff --git a/homeassistant/components/tellstick/__init__.py b/homeassistant/components/tellstick/__init__.py index 9d120b7aaa8..6ccc1f14b5f 100644 --- a/homeassistant/components/tellstick/__init__.py +++ b/homeassistant/components/tellstick/__init__.py @@ -9,8 +9,7 @@ import voluptuous as vol from homeassistant.const import CONF_HOST, CONF_PORT, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import discovery -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/tellstick/sensor.py b/homeassistant/components/tellstick/sensor.py index 1e27511bd84..c777aa6f01f 100644 --- a/homeassistant/components/tellstick/sensor.py +++ b/homeassistant/components/tellstick/sensor.py @@ -23,7 +23,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/telnet/switch.py b/homeassistant/components/telnet/switch.py index 0178a6521c4..0fa1076c943 100644 --- a/homeassistant/components/telnet/switch.py +++ b/homeassistant/components/telnet/switch.py @@ -26,7 +26,7 @@ from homeassistant.const import ( CONF_VALUE_TEMPLATE, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.template import Template from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/tensorflow/image_processing.py b/homeassistant/components/tensorflow/image_processing.py index f4a3a7bfe07..15addd3513d 100644 --- a/homeassistant/components/tensorflow/image_processing.py +++ b/homeassistant/components/tensorflow/image_processing.py @@ -26,8 +26,7 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_START, ) from homeassistant.core import HomeAssistant, split_entity_id -from homeassistant.helpers import template -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, template from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util.pil import draw_box diff --git a/homeassistant/components/tesla_fleet/__init__.py b/homeassistant/components/tesla_fleet/__init__.py index 945c6351cfc..634e8f845f9 100644 --- a/homeassistant/components/tesla_fleet/__init__.py +++ b/homeassistant/components/tesla_fleet/__init__.py @@ -25,13 +25,12 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.config_entry_oauth2_flow import ( OAuth2Session, async_get_config_entry_implementation, ) -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.device_registry import DeviceInfo from .const import DOMAIN, LOGGER, MODELS diff --git a/homeassistant/components/teslemetry/__init__.py b/homeassistant/components/teslemetry/__init__.py index b9cbc64dcd9..6e60b34825f 100644 --- a/homeassistant/components/teslemetry/__init__.py +++ b/homeassistant/components/teslemetry/__init__.py @@ -18,9 +18,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ACCESS_TOKEN, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/tfiac/climate.py b/homeassistant/components/tfiac/climate.py index e3aa9060787..9571597abe6 100644 --- a/homeassistant/components/tfiac/climate.py +++ b/homeassistant/components/tfiac/climate.py @@ -26,7 +26,7 @@ from homeassistant.components.climate import ( ) from homeassistant.const import ATTR_TEMPERATURE, CONF_HOST, UnitOfTemperature from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/thermoworks_smoke/sensor.py b/homeassistant/components/thermoworks_smoke/sensor.py index 7dc845ecf60..7ce0dfb9993 100644 --- a/homeassistant/components/thermoworks_smoke/sensor.py +++ b/homeassistant/components/thermoworks_smoke/sensor.py @@ -27,7 +27,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/thingspeak/__init__.py b/homeassistant/components/thingspeak/__init__.py index fdf06a9709a..1798e4f1de0 100644 --- a/homeassistant/components/thingspeak/__init__.py +++ b/homeassistant/components/thingspeak/__init__.py @@ -14,8 +14,7 @@ from homeassistant.const import ( STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import event, state as state_helper -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, event, state as state_helper from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/thinkingcleaner/sensor.py b/homeassistant/components/thinkingcleaner/sensor.py index 4d28912e20d..ccdc1ada48e 100644 --- a/homeassistant/components/thinkingcleaner/sensor.py +++ b/homeassistant/components/thinkingcleaner/sensor.py @@ -15,7 +15,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_HOST, PERCENTAGE from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/thinkingcleaner/switch.py b/homeassistant/components/thinkingcleaner/switch.py index 76c7cdb0db2..8397eeedc23 100644 --- a/homeassistant/components/thinkingcleaner/switch.py +++ b/homeassistant/components/thinkingcleaner/switch.py @@ -17,7 +17,7 @@ from homeassistant.components.switch import ( ) from homeassistant.const import CONF_HOST, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/thomson/device_tracker.py b/homeassistant/components/thomson/device_tracker.py index 4e44b2b1ffd..f003264b6d7 100644 --- a/homeassistant/components/thomson/device_tracker.py +++ b/homeassistant/components/thomson/device_tracker.py @@ -15,7 +15,7 @@ from homeassistant.components.device_tracker import ( ) from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/tibber/__init__.py b/homeassistant/components/tibber/__init__.py index 9b5c7ee1168..424b35b963b 100644 --- a/homeassistant/components/tibber/__init__.py +++ b/homeassistant/components/tibber/__init__.py @@ -9,8 +9,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ACCESS_TOKEN, EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.core import Event, HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType from homeassistant.util import dt as dt_util, ssl as ssl_util diff --git a/homeassistant/components/time_date/sensor.py b/homeassistant/components/time_date/sensor.py index 245d10bebba..1e86a1ba6c6 100644 --- a/homeassistant/components/time_date/sensor.py +++ b/homeassistant/components/time_date/sensor.py @@ -17,11 +17,11 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DISPLAY_OPTIONS, EVENT_CORE_CONFIG_UPDATE from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import OPTION_TYPES diff --git a/homeassistant/components/tmb/sensor.py b/homeassistant/components/tmb/sensor.py index 126c3128f91..cbf3b073578 100644 --- a/homeassistant/components/tmb/sensor.py +++ b/homeassistant/components/tmb/sensor.py @@ -15,7 +15,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_NAME, UnitOfTime from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import Throttle diff --git a/homeassistant/components/todoist/calendar.py b/homeassistant/components/todoist/calendar.py index 62f9fafc02a..94581439ae9 100644 --- a/homeassistant/components/todoist/calendar.py +++ b/homeassistant/components/todoist/calendar.py @@ -22,8 +22,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ID, CONF_NAME, CONF_TOKEN, EVENT_HOMEASSISTANT_STOP from homeassistant.core import Event, HomeAssistant, ServiceCall, callback from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.update_coordinator import CoordinatorEntity diff --git a/homeassistant/components/tomato/device_tracker.py b/homeassistant/components/tomato/device_tracker.py index dfa8d2bd4e1..2cef5eea0cf 100644 --- a/homeassistant/components/tomato/device_tracker.py +++ b/homeassistant/components/tomato/device_tracker.py @@ -24,7 +24,7 @@ from homeassistant.const import ( CONF_VERIFY_SSL, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType CONF_HTTP_ID = "http_id" diff --git a/homeassistant/components/torque/sensor.py b/homeassistant/components/torque/sensor.py index 543046fac1c..8d4183e2961 100644 --- a/homeassistant/components/torque/sensor.py +++ b/homeassistant/components/torque/sensor.py @@ -14,7 +14,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_EMAIL, CONF_NAME, DEGREE from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/touchline/climate.py b/homeassistant/components/touchline/climate.py index e9d27341cb7..f7eec7c54f9 100644 --- a/homeassistant/components/touchline/climate.py +++ b/homeassistant/components/touchline/climate.py @@ -15,7 +15,7 @@ from homeassistant.components.climate import ( ) from homeassistant.const import ATTR_TEMPERATURE, CONF_HOST, UnitOfTemperature from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/tplink/light.py b/homeassistant/components/tplink/light.py index c1311c256df..718b5ed7120 100644 --- a/homeassistant/components/tplink/light.py +++ b/homeassistant/components/tplink/light.py @@ -28,7 +28,7 @@ from homeassistant.components.light import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import VolDictType diff --git a/homeassistant/components/traccar/__init__.py b/homeassistant/components/traccar/__init__.py index fe08c3db234..5b9bc2551b7 100644 --- a/homeassistant/components/traccar/__init__.py +++ b/homeassistant/components/traccar/__init__.py @@ -9,8 +9,7 @@ from homeassistant.components import webhook from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_ID, CONF_WEBHOOK_ID, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_entry_flow -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_entry_flow, config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_send from .const import ( diff --git a/homeassistant/components/trafikverket_train/config_flow.py b/homeassistant/components/trafikverket_train/config_flow.py index da1fb0f7622..57d74eef78a 100644 --- a/homeassistant/components/trafikverket_train/config_flow.py +++ b/homeassistant/components/trafikverket_train/config_flow.py @@ -24,8 +24,8 @@ from homeassistant.config_entries import ( ) from homeassistant.const import CONF_API_KEY, CONF_NAME, CONF_WEEKDAY, WEEKDAYS from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.selector import ( SelectOptionDict, SelectSelector, diff --git a/homeassistant/components/trafikverket_weatherstation/config_flow.py b/homeassistant/components/trafikverket_weatherstation/config_flow.py index 28b9a124fc6..f4316b887b3 100644 --- a/homeassistant/components/trafikverket_weatherstation/config_flow.py +++ b/homeassistant/components/trafikverket_weatherstation/config_flow.py @@ -15,8 +15,8 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_API_KEY +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.selector import ( TextSelector, TextSelectorConfig, diff --git a/homeassistant/components/transport_nsw/sensor.py b/homeassistant/components/transport_nsw/sensor.py index 5628274b967..49a11a57f65 100644 --- a/homeassistant/components/transport_nsw/sensor.py +++ b/homeassistant/components/transport_nsw/sensor.py @@ -16,7 +16,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import ATTR_MODE, CONF_API_KEY, CONF_NAME, UnitOfTime from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/travisci/sensor.py b/homeassistant/components/travisci/sensor.py index fe4a6541d9e..8193c5a67dc 100644 --- a/homeassistant/components/travisci/sensor.py +++ b/homeassistant/components/travisci/sensor.py @@ -22,7 +22,7 @@ from homeassistant.const import ( UnitOfTime, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/trend/binary_sensor.py b/homeassistant/components/trend/binary_sensor.py index 9691ecf0744..e5ff5c64a8b 100644 --- a/homeassistant/components/trend/binary_sensor.py +++ b/homeassistant/components/trend/binary_sensor.py @@ -32,8 +32,7 @@ from homeassistant.const import ( STATE_UNKNOWN, ) from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback -from homeassistant.helpers import device_registry as dr -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.device import async_device_info_to_link_from_entity from homeassistant.helpers.entity import generate_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback diff --git a/homeassistant/components/twentemilieu/calendar.py b/homeassistant/components/twentemilieu/calendar.py index 69c509b9edf..606fb4913d1 100644 --- a/homeassistant/components/twentemilieu/calendar.py +++ b/homeassistant/components/twentemilieu/calendar.py @@ -8,7 +8,7 @@ from homeassistant.components.calendar import CalendarEntity, CalendarEvent from homeassistant.const import CONF_ID from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import WASTE_TYPE_TO_DESCRIPTION from .coordinator import TwenteMilieuConfigEntry diff --git a/homeassistant/components/twilio/__init__.py b/homeassistant/components/twilio/__init__.py index b54af031af3..7ed65bdd54b 100644 --- a/homeassistant/components/twilio/__init__.py +++ b/homeassistant/components/twilio/__init__.py @@ -8,8 +8,7 @@ from homeassistant.components import webhook from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_WEBHOOK_ID from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_entry_flow -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_entry_flow, config_validation as cv from homeassistant.helpers.typing import ConfigType from .const import DOMAIN diff --git a/homeassistant/components/twilio_call/notify.py b/homeassistant/components/twilio_call/notify.py index ab79ea9692d..4c432e0aeb5 100644 --- a/homeassistant/components/twilio_call/notify.py +++ b/homeassistant/components/twilio_call/notify.py @@ -15,7 +15,7 @@ from homeassistant.components.notify import ( ) from homeassistant.components.twilio import DATA_TWILIO from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/twilio_sms/notify.py b/homeassistant/components/twilio_sms/notify.py index 531fadcf259..a3f824f375f 100644 --- a/homeassistant/components/twilio_sms/notify.py +++ b/homeassistant/components/twilio_sms/notify.py @@ -14,7 +14,7 @@ from homeassistant.components.notify import ( ) from homeassistant.components.twilio import DATA_TWILIO from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/twitter/notify.py b/homeassistant/components/twitter/notify.py index eef51ca9613..f94bcd54459 100644 --- a/homeassistant/components/twitter/notify.py +++ b/homeassistant/components/twitter/notify.py @@ -21,7 +21,7 @@ from homeassistant.components.notify import ( ) from homeassistant.const import CONF_ACCESS_TOKEN, CONF_USERNAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.event import async_track_point_in_time from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/ubus/device_tracker.py b/homeassistant/components/ubus/device_tracker.py index 285a176af0a..7c50b69683f 100644 --- a/homeassistant/components/ubus/device_tracker.py +++ b/homeassistant/components/ubus/device_tracker.py @@ -15,7 +15,7 @@ from homeassistant.components.device_tracker import ( ) from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/uk_transport/sensor.py b/homeassistant/components/uk_transport/sensor.py index a86f7a1cc83..b06d0e24891 100644 --- a/homeassistant/components/uk_transport/sensor.py +++ b/homeassistant/components/uk_transport/sensor.py @@ -17,11 +17,10 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_MODE, UnitOfTime from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.util import Throttle -import homeassistant.util.dt as dt_util +from homeassistant.util import Throttle, dt as dt_util _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/unifi/config_flow.py b/homeassistant/components/unifi/config_flow.py index 479055b84eb..3878e4c60eb 100644 --- a/homeassistant/components/unifi/config_flow.py +++ b/homeassistant/components/unifi/config_flow.py @@ -33,7 +33,7 @@ from homeassistant.const import ( CONF_VERIFY_SSL, ) from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.service_info.ssdp import ( ATTR_UPNP_MODEL_DESCRIPTION, diff --git a/homeassistant/components/unifi/device_tracker.py b/homeassistant/components/unifi/device_tracker.py index eebffc63277..da5ca74fc37 100644 --- a/homeassistant/components/unifi/device_tracker.py +++ b/homeassistant/components/unifi/device_tracker.py @@ -27,7 +27,7 @@ from homeassistant.core import Event as core_Event, HomeAssistant, callback from homeassistant.helpers import entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import UnifiConfigEntry from .const import DOMAIN as UNIFI_DOMAIN diff --git a/homeassistant/components/unifi/hub/entity_helper.py b/homeassistant/components/unifi/hub/entity_helper.py index 782b026d6e4..b353ba6fc5c 100644 --- a/homeassistant/components/unifi/hub/entity_helper.py +++ b/homeassistant/components/unifi/hub/entity_helper.py @@ -10,7 +10,7 @@ from aiounifi.models.device import DeviceSetPoePortModeRequest from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_call_later, async_track_time_interval -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util class UnifiEntityHelper: diff --git a/homeassistant/components/unifi/image.py b/homeassistant/components/unifi/image.py index 1f54f56b194..f1ada9a01e0 100644 --- a/homeassistant/components/unifi/image.py +++ b/homeassistant/components/unifi/image.py @@ -17,7 +17,7 @@ from homeassistant.components.image import ImageEntity, ImageEntityDescription from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import UnifiConfigEntry from .entity import ( diff --git a/homeassistant/components/unifi/sensor.py b/homeassistant/components/unifi/sensor.py index 194a8575174..fd78c606043 100644 --- a/homeassistant/components/unifi/sensor.py +++ b/homeassistant/components/unifi/sensor.py @@ -48,8 +48,7 @@ from homeassistant.core import Event as core_Event, HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType -from homeassistant.util import slugify -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util, slugify from . import UnifiConfigEntry from .const import DEVICE_STATES diff --git a/homeassistant/components/unifi_direct/device_tracker.py b/homeassistant/components/unifi_direct/device_tracker.py index d5e2e926114..1d7511aaae8 100644 --- a/homeassistant/components/unifi_direct/device_tracker.py +++ b/homeassistant/components/unifi_direct/device_tracker.py @@ -15,7 +15,7 @@ from homeassistant.components.device_tracker import ( ) from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/unifiled/light.py b/homeassistant/components/unifiled/light.py index 4e1981875f4..dbc73177f21 100644 --- a/homeassistant/components/unifiled/light.py +++ b/homeassistant/components/unifiled/light.py @@ -16,7 +16,7 @@ from homeassistant.components.light import ( ) from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/upb/const.py b/homeassistant/components/upb/const.py index 16f2f1b7923..6e063c5a088 100644 --- a/homeassistant/components/upb/const.py +++ b/homeassistant/components/upb/const.py @@ -2,7 +2,7 @@ import voluptuous as vol -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import VolDictType DOMAIN = "upb" diff --git a/homeassistant/components/upc_connect/device_tracker.py b/homeassistant/components/upc_connect/device_tracker.py index c279be78666..bdaf01518f1 100644 --- a/homeassistant/components/upc_connect/device_tracker.py +++ b/homeassistant/components/upc_connect/device_tracker.py @@ -15,8 +15,8 @@ from homeassistant.components.device_tracker import ( ) from homeassistant.const import CONF_HOST, CONF_PASSWORD, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/uptime/sensor.py b/homeassistant/components/uptime/sensor.py index 266542de9d6..25917d09096 100644 --- a/homeassistant/components/uptime/sensor.py +++ b/homeassistant/components/uptime/sensor.py @@ -7,7 +7,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import DOMAIN diff --git a/homeassistant/components/usgs_earthquakes_feed/geo_location.py b/homeassistant/components/usgs_earthquakes_feed/geo_location.py index aa9817eab7d..3dd380e79a8 100644 --- a/homeassistant/components/usgs_earthquakes_feed/geo_location.py +++ b/homeassistant/components/usgs_earthquakes_feed/geo_location.py @@ -27,8 +27,7 @@ from homeassistant.const import ( UnitOfLength, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import aiohttp_client -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import aiohttp_client, config_validation as cv from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, diff --git a/homeassistant/components/utility_meter/__init__.py b/homeassistant/components/utility_meter/__init__.py index aac31e085a0..e2b3411c193 100644 --- a/homeassistant/components/utility_meter/__init__.py +++ b/homeassistant/components/utility_meter/__init__.py @@ -11,8 +11,11 @@ from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_ENTITY_ID, CONF_NAME, CONF_UNIQUE_ID, Platform from homeassistant.core import HomeAssistant, split_entity_id -from homeassistant.helpers import discovery, entity_registry as er -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import ( + config_validation as cv, + discovery, + entity_registry as er, +) from homeassistant.helpers.device import ( async_remove_stale_devices_links_keep_entity_device, ) diff --git a/homeassistant/components/utility_meter/sensor.py b/homeassistant/components/utility_meter/sensor.py index 9c13aa1984a..cd65c42b22a 100644 --- a/homeassistant/components/utility_meter/sensor.py +++ b/homeassistant/components/utility_meter/sensor.py @@ -49,8 +49,7 @@ from homeassistant.helpers.event import ( from homeassistant.helpers.start import async_at_started from homeassistant.helpers.template import is_number from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.util import slugify -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util, slugify from homeassistant.util.enum import try_parse_enum from .const import ( diff --git a/homeassistant/components/uvc/camera.py b/homeassistant/components/uvc/camera.py index a6f0202ee25..0e09408551d 100644 --- a/homeassistant/components/uvc/camera.py +++ b/homeassistant/components/uvc/camera.py @@ -20,7 +20,7 @@ from homeassistant.components.camera import ( from homeassistant.const import CONF_PASSWORD, CONF_PORT, CONF_SSL from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util.dt import utc_from_timestamp From c486cc8cbb70c1190d08549d1e47a4f8b043b448 Mon Sep 17 00:00:00 2001 From: dontinelli <73341522+dontinelli@users.noreply.github.com> Date: Wed, 29 Jan 2025 12:14:39 +0100 Subject: [PATCH 0226/3148] Add image entity for fyta (#135105) --- homeassistant/components/fyta/__init__.py | 1 + homeassistant/components/fyta/image.py | 64 +++++++++ tests/components/fyta/conftest.py | 10 ++ .../fyta/fixtures/plant_status1.json | 4 +- .../fyta/fixtures/plant_status1_update.json | 30 ++++ .../fyta/fixtures/plant_status3.json | 4 +- .../fyta/snapshots/test_diagnostics.ambr | 4 +- .../components/fyta/snapshots/test_image.ambr | 97 +++++++++++++ tests/components/fyta/test_image.py | 129 ++++++++++++++++++ 9 files changed, 337 insertions(+), 6 deletions(-) create mode 100644 homeassistant/components/fyta/image.py create mode 100644 tests/components/fyta/fixtures/plant_status1_update.json create mode 100644 tests/components/fyta/snapshots/test_image.ambr create mode 100644 tests/components/fyta/test_image.py diff --git a/homeassistant/components/fyta/__init__.py b/homeassistant/components/fyta/__init__.py index 77724e3f673..ab4a74c627a 100644 --- a/homeassistant/components/fyta/__init__.py +++ b/homeassistant/components/fyta/__init__.py @@ -25,6 +25,7 @@ _LOGGER = logging.getLogger(__name__) PLATFORMS = [ Platform.BINARY_SENSOR, + Platform.IMAGE, Platform.SENSOR, ] type FytaConfigEntry = ConfigEntry[FytaCoordinator] diff --git a/homeassistant/components/fyta/image.py b/homeassistant/components/fyta/image.py new file mode 100644 index 00000000000..f03df969dcc --- /dev/null +++ b/homeassistant/components/fyta/image.py @@ -0,0 +1,64 @@ +"""Entity for Fyta plant image.""" + +from __future__ import annotations + +from datetime import datetime + +from homeassistant.components.image import ImageEntity, ImageEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import FytaConfigEntry +from .coordinator import FytaCoordinator +from .entity import FytaPlantEntity + + +async def async_setup_entry( + hass: HomeAssistant, entry: FytaConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the FYTA plant images.""" + coordinator = entry.runtime_data + + description = ImageEntityDescription(key="plant_image") + + async_add_entities( + FytaPlantImageEntity(coordinator, entry, description, plant_id) + for plant_id in coordinator.fyta.plant_list + if plant_id in coordinator.data + ) + + def _async_add_new_device(plant_id: int) -> None: + async_add_entities( + [FytaPlantImageEntity(coordinator, entry, description, plant_id)] + ) + + coordinator.new_device_callbacks.append(_async_add_new_device) + + +class FytaPlantImageEntity(FytaPlantEntity, ImageEntity): + """Represents a Fyta image.""" + + entity_description: ImageEntityDescription + + def __init__( + self, + coordinator: FytaCoordinator, + entry: ConfigEntry, + description: ImageEntityDescription, + plant_id: int, + ) -> None: + """Initiatlize Fyta Image entity.""" + super().__init__(coordinator, entry, description, plant_id) + ImageEntity.__init__(self, coordinator.hass) + + self._attr_name = None + + @property + def image_url(self) -> str: + """Return the image_url for this sensor.""" + image = self.plant.plant_origin_path + if image != self._attr_image_url: + self._attr_image_last_updated = datetime.now() + + return image diff --git a/tests/components/fyta/conftest.py b/tests/components/fyta/conftest.py index 299b96be959..92abab7091a 100644 --- a/tests/components/fyta/conftest.py +++ b/tests/components/fyta/conftest.py @@ -81,3 +81,13 @@ def mock_setup_entry() -> Generator[AsyncMock]: "homeassistant.components.fyta.async_setup_entry", return_value=True ) as mock_setup_entry: yield mock_setup_entry + + +@pytest.fixture(autouse=True) +def mock_getrandbits(): + """Mock image access token which normally is randomized.""" + with patch( + "homeassistant.components.image.SystemRandom.getrandbits", + return_value=1, + ): + yield diff --git a/tests/components/fyta/fixtures/plant_status1.json b/tests/components/fyta/fixtures/plant_status1.json index ca5662714a0..21e1fcfb0ab 100644 --- a/tests/components/fyta/fixtures/plant_status1.json +++ b/tests/components/fyta/fixtures/plant_status1.json @@ -19,8 +19,8 @@ "online": true, "ph": null, "plant_id": 0, - "plant_origin_path": "", - "plant_thumb_path": "", + "plant_origin_path": "http://www.plant_picture.com/picture", + "plant_thumb_path": "http://www.plant_picture.com/picture_thumb", "is_productive_plant": false, "salinity": 1, "salinity_status": 4, diff --git a/tests/components/fyta/fixtures/plant_status1_update.json b/tests/components/fyta/fixtures/plant_status1_update.json new file mode 100644 index 00000000000..98a4c6a9d91 --- /dev/null +++ b/tests/components/fyta/fixtures/plant_status1_update.json @@ -0,0 +1,30 @@ +{ + "battery_level": 80, + "fertilisation": { + "was_repotted": true + }, + "low_battery": false, + "last_updated": "2023-01-10 10:10:00", + "light": 2, + "light_status": 3, + "nickname": "Gummibaum", + "nutrients_status": 3, + "moisture": 61, + "moisture_status": 3, + "sensor_available": true, + "sensor_id": "FD:1D:B7:E3:D0:E2", + "sensor_update_available": true, + "sw_version": "1.0", + "status": 1, + "online": true, + "ph": null, + "plant_id": 0, + "plant_origin_path": "http://www.plant_picture.com/picture1", + "plant_thumb_path": "http://www.plant_picture.com/picture_thumb", + "is_productive_plant": false, + "salinity": 1, + "salinity_status": 4, + "scientific_name": "Ficus elastica", + "temperature": 25.2, + "temperature_status": 3 +} diff --git a/tests/components/fyta/fixtures/plant_status3.json b/tests/components/fyta/fixtures/plant_status3.json index 2bedd196fe1..4bb4e0b81a7 100644 --- a/tests/components/fyta/fixtures/plant_status3.json +++ b/tests/components/fyta/fixtures/plant_status3.json @@ -19,8 +19,8 @@ "online": true, "ph": 7, "plant_id": 0, - "plant_origin_path": "", - "plant_thumb_path": "", + "plant_origin_path": "http://www.plant_picture.com/picture", + "plant_thumb_path": "http://www.plant_picture.com/picture_thumb", "is_productive_plant": true, "salinity": 1, "salinity_status": 4, diff --git a/tests/components/fyta/snapshots/test_diagnostics.ambr b/tests/components/fyta/snapshots/test_diagnostics.ambr index b4da0238db0..a252e81952c 100644 --- a/tests/components/fyta/snapshots/test_diagnostics.ambr +++ b/tests/components/fyta/snapshots/test_diagnostics.ambr @@ -43,8 +43,8 @@ 'online': True, 'ph': None, 'plant_id': 0, - 'plant_origin_path': '', - 'plant_thumb_path': '', + 'plant_origin_path': 'http://www.plant_picture.com/picture', + 'plant_thumb_path': 'http://www.plant_picture.com/picture_thumb', 'productive_plant': False, 'repotted': True, 'salinity': 1.0, diff --git a/tests/components/fyta/snapshots/test_image.ambr b/tests/components/fyta/snapshots/test_image.ambr new file mode 100644 index 00000000000..95e25e0a4d7 --- /dev/null +++ b/tests/components/fyta/snapshots/test_image.ambr @@ -0,0 +1,97 @@ +# serializer version: 1 +# name: test_all_entities[image.gummibaum-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'image', + 'entity_category': None, + 'entity_id': 'image.gummibaum', + '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': None, + 'platform': 'fyta', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'ce5f5431554d101905d31797e1232da8-0-plant_image', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[image.gummibaum-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'access_token': '1', + 'entity_picture': '/api/image_proxy/image.gummibaum?token=1', + 'friendly_name': 'Gummibaum', + }), + 'context': , + 'entity_id': 'image.gummibaum', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[image.kakaobaum-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'image', + 'entity_category': None, + 'entity_id': 'image.kakaobaum', + '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': None, + 'platform': 'fyta', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'ce5f5431554d101905d31797e1232da8-1-plant_image', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[image.kakaobaum-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'access_token': '1', + 'entity_picture': '/api/image_proxy/image.kakaobaum?token=1', + 'friendly_name': 'Kakaobaum', + }), + 'context': , + 'entity_id': 'image.kakaobaum', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/fyta/test_image.py b/tests/components/fyta/test_image.py new file mode 100644 index 00000000000..4feb125bd15 --- /dev/null +++ b/tests/components/fyta/test_image.py @@ -0,0 +1,129 @@ +"""Test the Home Assistant fyta sensor module.""" + +from datetime import timedelta +from unittest.mock import AsyncMock + +from freezegun.api import FrozenDateTimeFactory +from fyta_cli.fyta_exceptions import FytaConnectionError, FytaPlantError +from fyta_cli.fyta_models import Plant +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.fyta.const import DOMAIN as FYTA_DOMAIN +from homeassistant.components.image import ImageEntity +from homeassistant.const import STATE_UNAVAILABLE, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_platform + +from tests.common import ( + MockConfigEntry, + async_fire_time_changed, + load_json_object_fixture, + snapshot_platform, +) + + +async def test_all_entities( + hass: HomeAssistant, + mock_fyta_connector: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test all entities.""" + + await setup_platform(hass, mock_config_entry, [Platform.IMAGE]) + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + assert len(hass.states.async_all("image")) == 2 + + +@pytest.mark.parametrize( + "exception", + [ + FytaConnectionError, + FytaPlantError, + ], +) +async def test_connection_error( + hass: HomeAssistant, + exception: Exception, + mock_fyta_connector: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test connection error.""" + await setup_platform(hass, mock_config_entry, [Platform.IMAGE]) + + mock_fyta_connector.update_all_plants.side_effect = exception + + freezer.tick(delta=timedelta(hours=12)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get("image.gummibaum").state == STATE_UNAVAILABLE + + +async def test_add_remove_entities( + hass: HomeAssistant, + mock_fyta_connector: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test if entities are added and old are removed.""" + + await setup_platform(hass, mock_config_entry, [Platform.IMAGE]) + + assert hass.states.get("image.gummibaum") is not None + + plants: dict[int, Plant] = { + 0: Plant.from_dict(load_json_object_fixture("plant_status1.json", FYTA_DOMAIN)), + 2: Plant.from_dict(load_json_object_fixture("plant_status3.json", FYTA_DOMAIN)), + } + mock_fyta_connector.update_all_plants.return_value = plants + mock_fyta_connector.plant_list = { + 0: "Kautschukbaum", + 2: "Tomatenpflanze", + } + + freezer.tick(delta=timedelta(minutes=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get("image.kakaobaum") is None + assert hass.states.get("image.tomatenpflanze") is not None + + +async def test_update_image( + hass: HomeAssistant, + mock_fyta_connector: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test if entity picture is updated.""" + + await setup_platform(hass, mock_config_entry, [Platform.IMAGE]) + + image_entity: ImageEntity = hass.data["domain_entities"]["image"]["image.gummibaum"] + + assert image_entity.image_url == "http://www.plant_picture.com/picture" + + plants: dict[int, Plant] = { + 0: Plant.from_dict( + load_json_object_fixture("plant_status1_update.json", FYTA_DOMAIN) + ), + 2: Plant.from_dict(load_json_object_fixture("plant_status3.json", FYTA_DOMAIN)), + } + mock_fyta_connector.update_all_plants.return_value = plants + mock_fyta_connector.plant_list = { + 0: "Kautschukbaum", + 2: "Tomatenpflanze", + } + + freezer.tick(delta=timedelta(minutes=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert image_entity.image_url == "http://www.plant_picture.com/picture1" From ebda2f99946d6d3c2b7794a2540159e7d6c049f8 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 29 Jan 2025 12:23:49 +0100 Subject: [PATCH 0227/3148] Standardize homeassistant imports in component (n-p) (#136830) --- homeassistant/components/nad/media_player.py | 2 +- homeassistant/components/namecheapdns/__init__.py | 2 +- homeassistant/components/nederlandse_spoorwegen/sensor.py | 2 +- homeassistant/components/netdata/sensor.py | 2 +- homeassistant/components/netio/switch.py | 2 +- homeassistant/components/neurio_energy/sensor.py | 5 ++--- homeassistant/components/nexia/climate.py | 3 +-- homeassistant/components/nfandroidtv/__init__.py | 3 +-- homeassistant/components/nfandroidtv/notify.py | 2 +- homeassistant/components/niko_home_control/light.py | 3 +-- homeassistant/components/nilu/air_quality.py | 2 +- homeassistant/components/nina/config_flow.py | 3 +-- homeassistant/components/nissan_leaf/__init__.py | 2 +- homeassistant/components/nmap_tracker/__init__.py | 5 ++--- homeassistant/components/nmap_tracker/config_flow.py | 2 +- homeassistant/components/nmbs/__init__.py | 2 +- homeassistant/components/nmbs/sensor.py | 4 ++-- homeassistant/components/no_ip/__init__.py | 2 +- homeassistant/components/noaa_tides/sensor.py | 2 +- homeassistant/components/nobo_hub/__init__.py | 2 +- homeassistant/components/norway_air/air_quality.py | 2 +- homeassistant/components/notify_events/__init__.py | 3 +-- homeassistant/components/nsw_fuel_station/sensor.py | 2 +- homeassistant/components/numato/__init__.py | 2 +- homeassistant/components/nut/device_action.py | 3 +-- homeassistant/components/nx584/binary_sensor.py | 2 +- homeassistant/components/oasa_telematics/sensor.py | 2 +- homeassistant/components/octoprint/__init__.py | 3 +-- homeassistant/components/octoprint/config_flow.py | 2 +- homeassistant/components/octoprint/coordinator.py | 2 +- homeassistant/components/oem/climate.py | 2 +- homeassistant/components/ohmconnect/sensor.py | 2 +- homeassistant/components/ombi/__init__.py | 2 +- homeassistant/components/onvif/device.py | 2 +- .../components/openalpr_cloud/image_processing.py | 2 +- homeassistant/components/openevse/sensor.py | 2 +- homeassistant/components/openhardwaremonitor/sensor.py | 2 +- homeassistant/components/opensensemap/air_quality.py | 2 +- homeassistant/components/opensky/config_flow.py | 2 +- homeassistant/components/opentherm_gw/config_flow.py | 2 +- homeassistant/components/openweathermap/config_flow.py | 2 +- homeassistant/components/opnsense/__init__.py | 2 +- homeassistant/components/opple/light.py | 2 +- homeassistant/components/oru/sensor.py | 2 +- homeassistant/components/orvibo/switch.py | 2 +- homeassistant/components/osoenergy/water_heater.py | 2 +- .../components/overkiz/climate/evo_home_controller.py | 2 +- homeassistant/components/owntracks/__init__.py | 2 +- homeassistant/components/panasonic_bluray/media_player.py | 2 +- homeassistant/components/panasonic_viera/__init__.py | 2 +- homeassistant/components/panel_custom/__init__.py | 2 +- homeassistant/components/pencom/switch.py | 2 +- homeassistant/components/permobil/config_flow.py | 3 +-- homeassistant/components/picnic/services.py | 2 +- homeassistant/components/pilight/__init__.py | 2 +- homeassistant/components/pilight/entity.py | 2 +- homeassistant/components/pilight/light.py | 2 +- homeassistant/components/pilight/sensor.py | 2 +- homeassistant/components/pilight/switch.py | 2 +- homeassistant/components/pioneer/media_player.py | 2 +- homeassistant/components/pjlink/media_player.py | 2 +- homeassistant/components/plaato/__init__.py | 2 +- homeassistant/components/plaato/config_flow.py | 2 +- homeassistant/components/plant/__init__.py | 2 +- homeassistant/components/plex/config_flow.py | 3 +-- homeassistant/components/pocketcasts/sensor.py | 2 +- homeassistant/components/profiler/__init__.py | 2 +- homeassistant/components/proliphix/climate.py | 2 +- homeassistant/components/prometheus/__init__.py | 7 +++++-- homeassistant/components/prowl/notify.py | 2 +- homeassistant/components/proxmoxve/__init__.py | 2 +- homeassistant/components/proxy/camera.py | 2 +- homeassistant/components/pulseaudio_loopback/switch.py | 2 +- homeassistant/components/push/camera.py | 2 +- homeassistant/components/pushsafer/notify.py | 2 +- homeassistant/components/pyload/config_flow.py | 2 +- homeassistant/components/python_script/__init__.py | 3 +-- 77 files changed, 84 insertions(+), 93 deletions(-) diff --git a/homeassistant/components/nad/media_player.py b/homeassistant/components/nad/media_player.py index e3c22b42d28..c1efa18f72b 100644 --- a/homeassistant/components/nad/media_player.py +++ b/homeassistant/components/nad/media_player.py @@ -13,7 +13,7 @@ from homeassistant.components.media_player import ( ) from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT, CONF_TYPE from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/namecheapdns/__init__.py b/homeassistant/components/namecheapdns/__init__.py index 43310c5e922..7fbd49d979b 100644 --- a/homeassistant/components/namecheapdns/__init__.py +++ b/homeassistant/components/namecheapdns/__init__.py @@ -8,8 +8,8 @@ import voluptuous as vol from homeassistant.const import CONF_DOMAIN, CONF_HOST, CONF_PASSWORD from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/nederlandse_spoorwegen/sensor.py b/homeassistant/components/nederlandse_spoorwegen/sensor.py index ce3e7d3a002..ff3eea9252c 100644 --- a/homeassistant/components/nederlandse_spoorwegen/sensor.py +++ b/homeassistant/components/nederlandse_spoorwegen/sensor.py @@ -17,7 +17,7 @@ from homeassistant.components.sensor import ( from homeassistant.const import CONF_API_KEY, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import Throttle diff --git a/homeassistant/components/netdata/sensor.py b/homeassistant/components/netdata/sensor.py index f33349c56ce..4346cbe8689 100644 --- a/homeassistant/components/netdata/sensor.py +++ b/homeassistant/components/netdata/sensor.py @@ -22,7 +22,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.httpx_client import get_async_client from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/netio/switch.py b/homeassistant/components/netio/switch.py index 5c2b93bcae7..4560b7a2ecc 100644 --- a/homeassistant/components/netio/switch.py +++ b/homeassistant/components/netio/switch.py @@ -25,7 +25,7 @@ from homeassistant.const import ( STATE_ON, ) from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/neurio_energy/sensor.py b/homeassistant/components/neurio_energy/sensor.py index 5c6482da59a..7a7ceff338e 100644 --- a/homeassistant/components/neurio_energy/sensor.py +++ b/homeassistant/components/neurio_energy/sensor.py @@ -17,11 +17,10 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_API_KEY, UnitOfEnergy, UnitOfPower from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.util import Throttle -import homeassistant.util.dt as dt_util +from homeassistant.util import Throttle, dt as dt_util _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/nexia/climate.py b/homeassistant/components/nexia/climate.py index becd664756b..81e7800fd01 100644 --- a/homeassistant/components/nexia/climate.py +++ b/homeassistant/components/nexia/climate.py @@ -32,8 +32,7 @@ from homeassistant.components.climate import ( ) from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_platform -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import VolDictType diff --git a/homeassistant/components/nfandroidtv/__init__.py b/homeassistant/components/nfandroidtv/__init__.py index ae7a4e615d4..50674a7ed46 100644 --- a/homeassistant/components/nfandroidtv/__init__.py +++ b/homeassistant/components/nfandroidtv/__init__.py @@ -6,8 +6,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import discovery -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.typing import ConfigType from .const import DATA_HASS_CONFIG, DOMAIN diff --git a/homeassistant/components/nfandroidtv/notify.py b/homeassistant/components/nfandroidtv/notify.py index dd6b15400d9..f6d9bcde432 100644 --- a/homeassistant/components/nfandroidtv/notify.py +++ b/homeassistant/components/nfandroidtv/notify.py @@ -20,7 +20,7 @@ from homeassistant.components.notify import ( from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import ( diff --git a/homeassistant/components/niko_home_control/light.py b/homeassistant/components/niko_home_control/light.py index 80f47e56438..5c2b372fd25 100644 --- a/homeassistant/components/niko_home_control/light.py +++ b/homeassistant/components/niko_home_control/light.py @@ -18,8 +18,7 @@ from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import CONF_HOST from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from homeassistant.helpers import issue_registry as ir -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, issue_registry as ir from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/nilu/air_quality.py b/homeassistant/components/nilu/air_quality.py index 7600a878548..31259349dea 100644 --- a/homeassistant/components/nilu/air_quality.py +++ b/homeassistant/components/nilu/air_quality.py @@ -34,7 +34,7 @@ from homeassistant.const import ( CONF_SHOW_ON_MAP, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import Throttle diff --git a/homeassistant/components/nina/config_flow.py b/homeassistant/components/nina/config_flow.py index a1ba9ae0c61..24c016e5e64 100644 --- a/homeassistant/components/nina/config_flow.py +++ b/homeassistant/components/nina/config_flow.py @@ -14,9 +14,8 @@ from homeassistant.config_entries import ( OptionsFlow, ) from homeassistant.core import callback -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import VolDictType from .const import ( diff --git a/homeassistant/components/nissan_leaf/__init__.py b/homeassistant/components/nissan_leaf/__init__.py index 865ae33b38c..4f24cde0578 100644 --- a/homeassistant/components/nissan_leaf/__init__.py +++ b/homeassistant/components/nissan_leaf/__init__.py @@ -18,7 +18,7 @@ import voluptuous as vol from homeassistant.const import CONF_PASSWORD, CONF_REGION, CONF_USERNAME, Platform from homeassistant.core import CALLBACK_TYPE, HomeAssistant, ServiceCall -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.discovery import load_platform from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_track_point_in_utc_time diff --git a/homeassistant/components/nmap_tracker/__init__.py b/homeassistant/components/nmap_tracker/__init__.py index dcb4e1361fd..72bf9284573 100644 --- a/homeassistant/components/nmap_tracker/__init__.py +++ b/homeassistant/components/nmap_tracker/__init__.py @@ -21,12 +21,11 @@ from homeassistant.components.device_tracker import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_EXCLUDE, CONF_HOSTS, EVENT_HOMEASSISTANT_STARTED from homeassistant.core import CoreState, HomeAssistant, callback -from homeassistant.helpers import entity_registry as er -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_track_time_interval -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import ( CONF_HOME_INTERVAL, diff --git a/homeassistant/components/nmap_tracker/config_flow.py b/homeassistant/components/nmap_tracker/config_flow.py index e05150995aa..1f436edd60c 100644 --- a/homeassistant/components/nmap_tracker/config_flow.py +++ b/homeassistant/components/nmap_tracker/config_flow.py @@ -22,7 +22,7 @@ from homeassistant.config_entries import ( ) from homeassistant.const import CONF_EXCLUDE, CONF_HOSTS from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import VolDictType from .const import ( diff --git a/homeassistant/components/nmbs/__init__.py b/homeassistant/components/nmbs/__init__.py index 9972d41ac7b..7d06baf37b6 100644 --- a/homeassistant/components/nmbs/__init__.py +++ b/homeassistant/components/nmbs/__init__.py @@ -7,7 +7,7 @@ from pyrail import iRail from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from .const import DOMAIN diff --git a/homeassistant/components/nmbs/sensor.py b/homeassistant/components/nmbs/sensor.py index 85ae56144a0..ca18d3b1bbd 100644 --- a/homeassistant/components/nmbs/sensor.py +++ b/homeassistant/components/nmbs/sensor.py @@ -22,11 +22,11 @@ from homeassistant.const import ( UnitOfTime, ) from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import ( # noqa: F401 CONF_EXCLUDE_VIAS, diff --git a/homeassistant/components/no_ip/__init__.py b/homeassistant/components/no_ip/__init__.py index cb02490ac08..c23177ddf94 100644 --- a/homeassistant/components/no_ip/__init__.py +++ b/homeassistant/components/no_ip/__init__.py @@ -11,11 +11,11 @@ import voluptuous as vol from homeassistant.const import CONF_DOMAIN, CONF_PASSWORD, CONF_TIMEOUT, CONF_USERNAME from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import ( SERVER_SOFTWARE, async_get_clientsession, ) -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/noaa_tides/sensor.py b/homeassistant/components/noaa_tides/sensor.py index b165478927e..f6ec9dc4bf2 100644 --- a/homeassistant/components/noaa_tides/sensor.py +++ b/homeassistant/components/noaa_tides/sensor.py @@ -17,7 +17,7 @@ from homeassistant.components.sensor import ( from homeassistant.const import CONF_NAME, CONF_TIME_ZONE, CONF_UNIT_SYSTEM from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util.unit_system import METRIC_SYSTEM diff --git a/homeassistant/components/nobo_hub/__init__.py b/homeassistant/components/nobo_hub/__init__.py index 5b777205c8d..3bbf46f0264 100644 --- a/homeassistant/components/nobo_hub/__init__.py +++ b/homeassistant/components/nobo_hub/__init__.py @@ -7,7 +7,7 @@ from pynobo import nobo from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_IP_ADDRESS, EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import CONF_AUTO_DISCOVERED, CONF_SERIAL, DOMAIN diff --git a/homeassistant/components/norway_air/air_quality.py b/homeassistant/components/norway_air/air_quality.py index bba4737550b..36de8c8b1ad 100644 --- a/homeassistant/components/norway_air/air_quality.py +++ b/homeassistant/components/norway_air/air_quality.py @@ -14,8 +14,8 @@ from homeassistant.components.air_quality import ( ) from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/notify_events/__init__.py b/homeassistant/components/notify_events/__init__.py index 2be97d709a9..76cfd9be4ff 100644 --- a/homeassistant/components/notify_events/__init__.py +++ b/homeassistant/components/notify_events/__init__.py @@ -4,8 +4,7 @@ import voluptuous as vol from homeassistant.const import CONF_TOKEN, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import discovery -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.typing import ConfigType from .const import DOMAIN diff --git a/homeassistant/components/nsw_fuel_station/sensor.py b/homeassistant/components/nsw_fuel_station/sensor.py index f99790664da..7ae9b3a4d9f 100644 --- a/homeassistant/components/nsw_fuel_station/sensor.py +++ b/homeassistant/components/nsw_fuel_station/sensor.py @@ -12,7 +12,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CURRENCY_CENT, UnitOfVolume from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.update_coordinator import ( diff --git a/homeassistant/components/numato/__init__.py b/homeassistant/components/numato/__init__.py index 00122132d44..d3882bea290 100644 --- a/homeassistant/components/numato/__init__.py +++ b/homeassistant/components/numato/__init__.py @@ -18,7 +18,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import Event, HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.discovery import load_platform from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/nut/device_action.py b/homeassistant/components/nut/device_action.py index a051f843226..ffaa195deaf 100644 --- a/homeassistant/components/nut/device_action.py +++ b/homeassistant/components/nut/device_action.py @@ -7,8 +7,7 @@ import voluptuous as vol from homeassistant.components.device_automation import InvalidDeviceAutomationConfig from homeassistant.const import CONF_DEVICE_ID, CONF_DOMAIN, CONF_TYPE from homeassistant.core import Context, HomeAssistant -from homeassistant.helpers import device_registry as dr -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.typing import ConfigType, TemplateVarsType from . import NutRuntimeData diff --git a/homeassistant/components/nx584/binary_sensor.py b/homeassistant/components/nx584/binary_sensor.py index 04e79716423..69e2f626049 100644 --- a/homeassistant/components/nx584/binary_sensor.py +++ b/homeassistant/components/nx584/binary_sensor.py @@ -18,7 +18,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/oasa_telematics/sensor.py b/homeassistant/components/oasa_telematics/sensor.py index fef4cef48af..ddf4942ef25 100644 --- a/homeassistant/components/oasa_telematics/sensor.py +++ b/homeassistant/components/oasa_telematics/sensor.py @@ -16,7 +16,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util diff --git a/homeassistant/components/octoprint/__init__.py b/homeassistant/components/octoprint/__init__.py index 2b081eae45a..59fd04357eb 100644 --- a/homeassistant/components/octoprint/__init__.py +++ b/homeassistant/components/octoprint/__init__.py @@ -28,8 +28,7 @@ from homeassistant.const import ( ) from homeassistant.core import Event, HomeAssistant, ServiceCall, callback from homeassistant.exceptions import ServiceValidationError -from homeassistant.helpers import device_registry as dr -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.typing import ConfigType from homeassistant.util import slugify as util_slugify from homeassistant.util.ssl import get_default_context, get_default_no_verify_context diff --git a/homeassistant/components/octoprint/config_flow.py b/homeassistant/components/octoprint/config_flow.py index 627ca999acd..010b45e5a1c 100644 --- a/homeassistant/components/octoprint/config_flow.py +++ b/homeassistant/components/octoprint/config_flow.py @@ -24,7 +24,7 @@ from homeassistant.const import ( ) from homeassistant.data_entry_flow import AbortFlow from homeassistant.exceptions import HomeAssistantError -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from homeassistant.util.ssl import get_default_context, get_default_no_verify_context diff --git a/homeassistant/components/octoprint/coordinator.py b/homeassistant/components/octoprint/coordinator.py index c6d7373a002..d4f8f652b80 100644 --- a/homeassistant/components/octoprint/coordinator.py +++ b/homeassistant/components/octoprint/coordinator.py @@ -16,7 +16,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import DOMAIN diff --git a/homeassistant/components/oem/climate.py b/homeassistant/components/oem/climate.py index 4cecb9ff195..e5ccdf6ede8 100644 --- a/homeassistant/components/oem/climate.py +++ b/homeassistant/components/oem/climate.py @@ -25,7 +25,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/ohmconnect/sensor.py b/homeassistant/components/ohmconnect/sensor.py index b32db33cc2d..287842178d8 100644 --- a/homeassistant/components/ohmconnect/sensor.py +++ b/homeassistant/components/ohmconnect/sensor.py @@ -15,7 +15,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_ID, CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import Throttle diff --git a/homeassistant/components/ombi/__init__.py b/homeassistant/components/ombi/__init__.py index d63f72592f8..c3a51bacce2 100644 --- a/homeassistant/components/ombi/__init__.py +++ b/homeassistant/components/ombi/__init__.py @@ -16,7 +16,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant, ServiceCall -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.discovery import load_platform from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/onvif/device.py b/homeassistant/components/onvif/device.py index f15f6637ab9..6d1a340fc7b 100644 --- a/homeassistant/components/onvif/device.py +++ b/homeassistant/components/onvif/device.py @@ -25,7 +25,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant, callback -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import ( ABSOLUTE_MOVE, diff --git a/homeassistant/components/openalpr_cloud/image_processing.py b/homeassistant/components/openalpr_cloud/image_processing.py index e8a8d6859c1..2bdf9947fe2 100644 --- a/homeassistant/components/openalpr_cloud/image_processing.py +++ b/homeassistant/components/openalpr_cloud/image_processing.py @@ -26,8 +26,8 @@ from homeassistant.const import ( CONF_SOURCE, ) from homeassistant.core import HomeAssistant, callback, split_entity_id +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util.async_ import run_callback_threadsafe diff --git a/homeassistant/components/openevse/sensor.py b/homeassistant/components/openevse/sensor.py index c228b6c1a14..de86e3d581f 100644 --- a/homeassistant/components/openevse/sensor.py +++ b/homeassistant/components/openevse/sensor.py @@ -23,7 +23,7 @@ from homeassistant.const import ( UnitOfTime, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/openhardwaremonitor/sensor.py b/homeassistant/components/openhardwaremonitor/sensor.py index 30801a59436..4aa334da3a7 100644 --- a/homeassistant/components/openhardwaremonitor/sensor.py +++ b/homeassistant/components/openhardwaremonitor/sensor.py @@ -16,7 +16,7 @@ from homeassistant.components.sensor import ( from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import Throttle diff --git a/homeassistant/components/opensensemap/air_quality.py b/homeassistant/components/opensensemap/air_quality.py index eb8435751c0..19d19f19a54 100644 --- a/homeassistant/components/opensensemap/air_quality.py +++ b/homeassistant/components/opensensemap/air_quality.py @@ -16,8 +16,8 @@ from homeassistant.components.air_quality import ( from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import Throttle diff --git a/homeassistant/components/opensky/config_flow.py b/homeassistant/components/opensky/config_flow.py index 867a4781265..5e53a805753 100644 --- a/homeassistant/components/opensky/config_flow.py +++ b/homeassistant/components/opensky/config_flow.py @@ -23,8 +23,8 @@ from homeassistant.const import ( CONF_USERNAME, ) from homeassistant.core import callback +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from .const import ( CONF_ALTITUDE, diff --git a/homeassistant/components/opentherm_gw/config_flow.py b/homeassistant/components/opentherm_gw/config_flow.py index 80c16ee88e1..bcbf279f3f7 100644 --- a/homeassistant/components/opentherm_gw/config_flow.py +++ b/homeassistant/components/opentherm_gw/config_flow.py @@ -25,7 +25,7 @@ from homeassistant.const import ( PRECISION_WHOLE, ) from homeassistant.core import callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from . import DOMAIN from .const import ( diff --git a/homeassistant/components/openweathermap/config_flow.py b/homeassistant/components/openweathermap/config_flow.py index 8d33e117287..4c66778119e 100644 --- a/homeassistant/components/openweathermap/config_flow.py +++ b/homeassistant/components/openweathermap/config_flow.py @@ -19,7 +19,7 @@ from homeassistant.const import ( CONF_NAME, ) from homeassistant.core import callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from .const import ( CONFIG_FLOW_VERSION, diff --git a/homeassistant/components/opnsense/__init__.py b/homeassistant/components/opnsense/__init__.py index d2ee2e2dfbd..66f35a51b87 100644 --- a/homeassistant/components/opnsense/__init__.py +++ b/homeassistant/components/opnsense/__init__.py @@ -8,7 +8,7 @@ import voluptuous as vol from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL, Platform from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.discovery import load_platform from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/opple/light.py b/homeassistant/components/opple/light.py index da2993d1996..e804f06faa3 100644 --- a/homeassistant/components/opple/light.py +++ b/homeassistant/components/opple/light.py @@ -17,7 +17,7 @@ from homeassistant.components.light import ( ) from homeassistant.const import CONF_HOST, CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/oru/sensor.py b/homeassistant/components/oru/sensor.py index 213350db6a4..450c56ae50e 100644 --- a/homeassistant/components/oru/sensor.py +++ b/homeassistant/components/oru/sensor.py @@ -15,7 +15,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import UnitOfEnergy from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/orvibo/switch.py b/homeassistant/components/orvibo/switch.py index 2f990333cf6..211abc838e7 100644 --- a/homeassistant/components/orvibo/switch.py +++ b/homeassistant/components/orvibo/switch.py @@ -20,7 +20,7 @@ from homeassistant.const import ( CONF_SWITCHES, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/osoenergy/water_heater.py b/homeassistant/components/osoenergy/water_heater.py index ff117d6577d..b3281193da3 100644 --- a/homeassistant/components/osoenergy/water_heater.py +++ b/homeassistant/components/osoenergy/water_heater.py @@ -20,7 +20,7 @@ from homeassistant.const import UnitOfTemperature from homeassistant.core import HomeAssistant, ServiceResponse, SupportsResponse from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from homeassistant.util.json import JsonValueType from .const import DOMAIN diff --git a/homeassistant/components/overkiz/climate/evo_home_controller.py b/homeassistant/components/overkiz/climate/evo_home_controller.py index 272acbb13b9..e0cb8be7380 100644 --- a/homeassistant/components/overkiz/climate/evo_home_controller.py +++ b/homeassistant/components/overkiz/climate/evo_home_controller.py @@ -11,7 +11,7 @@ from homeassistant.components.climate import ( HVACMode, ) from homeassistant.const import UnitOfTemperature -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from ..entity import OverkizDataUpdateCoordinator, OverkizEntity diff --git a/homeassistant/components/owntracks/__init__.py b/homeassistant/components/owntracks/__init__.py index 720c3718a4f..623e5e17b66 100644 --- a/homeassistant/components/owntracks/__init__.py +++ b/homeassistant/components/owntracks/__init__.py @@ -18,7 +18,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, diff --git a/homeassistant/components/panasonic_bluray/media_player.py b/homeassistant/components/panasonic_bluray/media_player.py index a7cb0780ca9..b0e23031a24 100644 --- a/homeassistant/components/panasonic_bluray/media_player.py +++ b/homeassistant/components/panasonic_bluray/media_player.py @@ -15,7 +15,7 @@ from homeassistant.components.media_player import ( ) from homeassistant.const import CONF_HOST, CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util.dt import utcnow diff --git a/homeassistant/components/panasonic_viera/__init__.py b/homeassistant/components/panasonic_viera/__init__.py index 69800d2ef1e..6dacc08077d 100644 --- a/homeassistant/components/panasonic_viera/__init__.py +++ b/homeassistant/components/panasonic_viera/__init__.py @@ -13,7 +13,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, CONF_PORT, Platform from homeassistant.core import Context, HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.script import Script from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/panel_custom/__init__.py b/homeassistant/components/panel_custom/__init__.py index 89ad6066f48..db9c35a7608 100644 --- a/homeassistant/components/panel_custom/__init__.py +++ b/homeassistant/components/panel_custom/__init__.py @@ -8,7 +8,7 @@ import voluptuous as vol from homeassistant.components import frontend from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass diff --git a/homeassistant/components/pencom/switch.py b/homeassistant/components/pencom/switch.py index d16c7e1600c..d9d89494bd9 100644 --- a/homeassistant/components/pencom/switch.py +++ b/homeassistant/components/pencom/switch.py @@ -15,7 +15,7 @@ from homeassistant.components.switch import ( from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/permobil/config_flow.py b/homeassistant/components/permobil/config_flow.py index 07ddefa9dce..e0fb55a0363 100644 --- a/homeassistant/components/permobil/config_flow.py +++ b/homeassistant/components/permobil/config_flow.py @@ -17,9 +17,8 @@ import voluptuous as vol from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_CODE, CONF_EMAIL, CONF_REGION, CONF_TOKEN, CONF_TTL from homeassistant.core import HomeAssistant, async_get_hass -from homeassistant.helpers import selector +from homeassistant.helpers import config_validation as cv, selector from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.selector import ( TextSelector, TextSelectorConfig, diff --git a/homeassistant/components/picnic/services.py b/homeassistant/components/picnic/services.py index c01fc00a29e..bbc775891b7 100644 --- a/homeassistant/components/picnic/services.py +++ b/homeassistant/components/picnic/services.py @@ -8,7 +8,7 @@ from python_picnic_api import PicnicAPI import voluptuous as vol from homeassistant.core import HomeAssistant, ServiceCall -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from .const import ( ATTR_AMOUNT, diff --git a/homeassistant/components/pilight/__init__.py b/homeassistant/components/pilight/__init__.py index 21d5603e4c2..5f1238772b0 100644 --- a/homeassistant/components/pilight/__init__.py +++ b/homeassistant/components/pilight/__init__.py @@ -21,7 +21,7 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, ) from homeassistant.core import HomeAssistant, ServiceCall -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.event import track_point_in_utc_time from homeassistant.helpers.typing import ConfigType from homeassistant.util import dt as dt_util diff --git a/homeassistant/components/pilight/entity.py b/homeassistant/components/pilight/entity.py index d2d83813516..fbb924d7f8f 100644 --- a/homeassistant/components/pilight/entity.py +++ b/homeassistant/components/pilight/entity.py @@ -10,7 +10,7 @@ from homeassistant.const import ( STATE_OFF, STATE_ON, ) -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.restore_state import RestoreEntity from . import DOMAIN, EVENT, SERVICE_NAME diff --git a/homeassistant/components/pilight/light.py b/homeassistant/components/pilight/light.py index c3d1a3c234c..9e1ecbf59d4 100644 --- a/homeassistant/components/pilight/light.py +++ b/homeassistant/components/pilight/light.py @@ -14,7 +14,7 @@ from homeassistant.components.light import ( ) from homeassistant.const import CONF_LIGHTS from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/pilight/sensor.py b/homeassistant/components/pilight/sensor.py index 5ab80f57dc6..532681e2b93 100644 --- a/homeassistant/components/pilight/sensor.py +++ b/homeassistant/components/pilight/sensor.py @@ -12,7 +12,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_NAME, CONF_PAYLOAD, CONF_UNIT_OF_MEASUREMENT from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/pilight/switch.py b/homeassistant/components/pilight/switch.py index a1976921269..9b812075e17 100644 --- a/homeassistant/components/pilight/switch.py +++ b/homeassistant/components/pilight/switch.py @@ -10,7 +10,7 @@ from homeassistant.components.switch import ( ) from homeassistant.const import CONF_SWITCHES from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/pioneer/media_player.py b/homeassistant/components/pioneer/media_player.py index 02072b6cb43..385acbe4818 100644 --- a/homeassistant/components/pioneer/media_player.py +++ b/homeassistant/components/pioneer/media_player.py @@ -16,7 +16,7 @@ from homeassistant.components.media_player import ( ) from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT, CONF_TIMEOUT from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/pjlink/media_player.py b/homeassistant/components/pjlink/media_player.py index 93f8ea5ad9b..1e035205f8f 100644 --- a/homeassistant/components/pjlink/media_player.py +++ b/homeassistant/components/pjlink/media_player.py @@ -14,7 +14,7 @@ from homeassistant.components.media_player import ( ) from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_PORT from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/plaato/__init__.py b/homeassistant/components/plaato/__init__.py index 585b6ecfd82..6001a243a2d 100644 --- a/homeassistant/components/plaato/__init__.py +++ b/homeassistant/components/plaato/__init__.py @@ -32,7 +32,7 @@ from homeassistant.const import ( UnitOfVolume, ) from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_send from .const import ( diff --git a/homeassistant/components/plaato/config_flow.py b/homeassistant/components/plaato/config_flow.py index f398a733cd6..9adfb4a14fe 100644 --- a/homeassistant/components/plaato/config_flow.py +++ b/homeassistant/components/plaato/config_flow.py @@ -16,7 +16,7 @@ from homeassistant.config_entries import ( ) from homeassistant.const import CONF_SCAN_INTERVAL, CONF_TOKEN, CONF_WEBHOOK_ID from homeassistant.core import callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from .const import ( CONF_CLOUDHOOK, diff --git a/homeassistant/components/plant/__init__.py b/homeassistant/components/plant/__init__.py index 48c606865df..27993a93779 100644 --- a/homeassistant/components/plant/__init__.py +++ b/homeassistant/components/plant/__init__.py @@ -32,7 +32,7 @@ from homeassistant.core import ( callback, ) from homeassistant.exceptions import HomeAssistantError -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.event import async_track_state_change_event diff --git a/homeassistant/components/plex/config_flow.py b/homeassistant/components/plex/config_flow.py index ae7cbb12574..3c9f35b20a4 100644 --- a/homeassistant/components/plex/config_flow.py +++ b/homeassistant/components/plex/config_flow.py @@ -36,9 +36,8 @@ from homeassistant.const import ( CONF_VERIFY_SSL, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import discovery_flow +from homeassistant.helpers import config_validation as cv, discovery_flow from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from .const import ( AUTH_CALLBACK_NAME, diff --git a/homeassistant/components/pocketcasts/sensor.py b/homeassistant/components/pocketcasts/sensor.py index 1f6af298688..bbe75ae544c 100644 --- a/homeassistant/components/pocketcasts/sensor.py +++ b/homeassistant/components/pocketcasts/sensor.py @@ -14,7 +14,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/profiler/__init__.py b/homeassistant/components/profiler/__init__.py index 9b2b9736574..04dc6d76a5e 100644 --- a/homeassistant/components/profiler/__init__.py +++ b/homeassistant/components/profiler/__init__.py @@ -22,7 +22,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_SCAN_INTERVAL, CONF_TYPE from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.service import async_register_admin_service diff --git a/homeassistant/components/proliphix/climate.py b/homeassistant/components/proliphix/climate.py index be7d394993a..03f53dec390 100644 --- a/homeassistant/components/proliphix/climate.py +++ b/homeassistant/components/proliphix/climate.py @@ -23,7 +23,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/prometheus/__init__.py b/homeassistant/components/prometheus/__init__.py index ab012847bba..3adc33e9935 100644 --- a/homeassistant/components/prometheus/__init__.py +++ b/homeassistant/components/prometheus/__init__.py @@ -63,8 +63,11 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import Event, EventStateChangedData, HomeAssistant, State -from homeassistant.helpers import entityfilter, state as state_helper -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import ( + config_validation as cv, + entityfilter, + state as state_helper, +) from homeassistant.helpers.entity_registry import ( EVENT_ENTITY_REGISTRY_UPDATED, EventEntityRegistryUpdatedData, diff --git a/homeassistant/components/prowl/notify.py b/homeassistant/components/prowl/notify.py index 1118e747275..e9d2bbde4e5 100644 --- a/homeassistant/components/prowl/notify.py +++ b/homeassistant/components/prowl/notify.py @@ -17,8 +17,8 @@ from homeassistant.components.notify import ( ) from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/proxmoxve/__init__.py b/homeassistant/components/proxmoxve/__init__.py index 6d6771debc4..0db6ea28652 100644 --- a/homeassistant/components/proxmoxve/__init__.py +++ b/homeassistant/components/proxmoxve/__init__.py @@ -20,7 +20,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.discovery import async_load_platform from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator diff --git a/homeassistant/components/proxy/camera.py b/homeassistant/components/proxy/camera.py index e5e3d01591a..f6e909f13d1 100644 --- a/homeassistant/components/proxy/camera.py +++ b/homeassistant/components/proxy/camera.py @@ -23,7 +23,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/pulseaudio_loopback/switch.py b/homeassistant/components/pulseaudio_loopback/switch.py index 4ab1f905068..1974363a8e3 100644 --- a/homeassistant/components/pulseaudio_loopback/switch.py +++ b/homeassistant/components/pulseaudio_loopback/switch.py @@ -14,7 +14,7 @@ from homeassistant.components.switch import ( ) from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/push/camera.py b/homeassistant/components/push/camera.py index 37ac6144d0d..603fe89d542 100644 --- a/homeassistant/components/push/camera.py +++ b/homeassistant/components/push/camera.py @@ -24,7 +24,7 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/pushsafer/notify.py b/homeassistant/components/pushsafer/notify.py index b5c517c8662..faca654b420 100644 --- a/homeassistant/components/pushsafer/notify.py +++ b/homeassistant/components/pushsafer/notify.py @@ -21,7 +21,7 @@ from homeassistant.components.notify import ( ) from homeassistant.const import ATTR_ICON from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/pyload/config_flow.py b/homeassistant/components/pyload/config_flow.py index 5df11711d6f..b9bfc579cfc 100644 --- a/homeassistant/components/pyload/config_flow.py +++ b/homeassistant/components/pyload/config_flow.py @@ -22,8 +22,8 @@ from homeassistant.const import ( CONF_VERIFY_SSL, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_create_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.selector import ( TextSelector, TextSelectorConfig, diff --git a/homeassistant/components/python_script/__init__.py b/homeassistant/components/python_script/__init__.py index dbd1a5dce4b..0729d73a034 100644 --- a/homeassistant/components/python_script/__init__.py +++ b/homeassistant/components/python_script/__init__.py @@ -36,8 +36,7 @@ from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers.service import async_set_service_schema from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass -from homeassistant.util import raise_if_invalid_filename -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util, raise_if_invalid_filename from homeassistant.util.yaml.loader import load_yaml_dict _LOGGER = logging.getLogger(__name__) From 1ef809c716f4f923347892ab6a9b4711cd1f3eed Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 29 Jan 2025 12:24:09 +0100 Subject: [PATCH 0228/3148] Standardize homeassistant imports in component (q-r) (#136831) --- homeassistant/components/qld_bushfire/geo_location.py | 2 +- homeassistant/components/quantum_gateway/device_tracker.py | 2 +- homeassistant/components/qvr_pro/__init__.py | 2 +- homeassistant/components/qwikswitch/__init__.py | 2 +- homeassistant/components/raincloud/__init__.py | 2 +- homeassistant/components/raincloud/binary_sensor.py | 2 +- homeassistant/components/raincloud/sensor.py | 2 +- homeassistant/components/raincloud/switch.py | 2 +- homeassistant/components/random/binary_sensor.py | 2 +- homeassistant/components/random/config_flow.py | 2 +- homeassistant/components/random/sensor.py | 2 +- homeassistant/components/raspyrfm/switch.py | 2 +- homeassistant/components/recswitch/switch.py | 2 +- homeassistant/components/reddit/sensor.py | 2 +- homeassistant/components/rejseplanen/sensor.py | 4 ++-- homeassistant/components/remember_the_milk/__init__.py | 2 +- homeassistant/components/remote_rpi_gpio/binary_sensor.py | 2 +- homeassistant/components/remote_rpi_gpio/switch.py | 2 +- homeassistant/components/renson/fan.py | 3 +-- homeassistant/components/repetier/__init__.py | 2 +- homeassistant/components/rest/binary_sensor.py | 2 +- homeassistant/components/rest/notify.py | 2 +- homeassistant/components/rest/schema.py | 2 +- homeassistant/components/rest/sensor.py | 2 +- homeassistant/components/rest_command/__init__.py | 2 +- homeassistant/components/rflink/__init__.py | 2 +- homeassistant/components/rflink/binary_sensor.py | 3 +-- homeassistant/components/rflink/const.py | 2 +- homeassistant/components/rflink/cover.py | 2 +- homeassistant/components/rflink/light.py | 2 +- homeassistant/components/rflink/sensor.py | 2 +- homeassistant/components/rflink/switch.py | 2 +- homeassistant/components/rfxtrx/device_action.py | 2 +- homeassistant/components/ring/light.py | 2 +- homeassistant/components/ring/switch.py | 2 +- homeassistant/components/ripple/sensor.py | 2 +- homeassistant/components/rmvtransport/sensor.py | 2 +- homeassistant/components/roborock/image.py | 3 +-- homeassistant/components/rocketchat/notify.py | 2 +- homeassistant/components/romy/config_flow.py | 2 +- homeassistant/components/roomba/entity.py | 2 +- homeassistant/components/roon/config_flow.py | 2 +- homeassistant/components/route53/__init__.py | 2 +- homeassistant/components/rss_feed_template/__init__.py | 2 +- homeassistant/components/rtorrent/sensor.py | 2 +- homeassistant/components/russound_rnet/media_player.py | 2 +- 46 files changed, 47 insertions(+), 50 deletions(-) diff --git a/homeassistant/components/qld_bushfire/geo_location.py b/homeassistant/components/qld_bushfire/geo_location.py index c1266ab951b..c235d441133 100644 --- a/homeassistant/components/qld_bushfire/geo_location.py +++ b/homeassistant/components/qld_bushfire/geo_location.py @@ -26,7 +26,7 @@ from homeassistant.const import ( UnitOfLength, ) from homeassistant.core import Event, HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect, dispatcher_send from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import track_time_interval diff --git a/homeassistant/components/quantum_gateway/device_tracker.py b/homeassistant/components/quantum_gateway/device_tracker.py index dc68472d94e..6491dca2e2c 100644 --- a/homeassistant/components/quantum_gateway/device_tracker.py +++ b/homeassistant/components/quantum_gateway/device_tracker.py @@ -15,7 +15,7 @@ from homeassistant.components.device_tracker import ( ) from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_SSL from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/qvr_pro/__init__.py b/homeassistant/components/qvr_pro/__init__.py index 9aad94790c6..98f0bcbaf99 100644 --- a/homeassistant/components/qvr_pro/__init__.py +++ b/homeassistant/components/qvr_pro/__init__.py @@ -15,7 +15,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant, ServiceCall -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.discovery import load_platform from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/qwikswitch/__init__.py b/homeassistant/components/qwikswitch/__init__.py index 776e32dded1..d3cf2ff3d9b 100644 --- a/homeassistant/components/qwikswitch/__init__.py +++ b/homeassistant/components/qwikswitch/__init__.py @@ -18,8 +18,8 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.discovery import load_platform from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/raincloud/__init__.py b/homeassistant/components/raincloud/__init__.py index f1eef40f307..0ee12612323 100644 --- a/homeassistant/components/raincloud/__init__.py +++ b/homeassistant/components/raincloud/__init__.py @@ -10,7 +10,7 @@ import voluptuous as vol from homeassistant.components import persistent_notification from homeassistant.const import CONF_PASSWORD, CONF_SCAN_INTERVAL, CONF_USERNAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import dispatcher_send from homeassistant.helpers.event import track_time_interval from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/raincloud/binary_sensor.py b/homeassistant/components/raincloud/binary_sensor.py index 2696c192ed6..84621aba99d 100644 --- a/homeassistant/components/raincloud/binary_sensor.py +++ b/homeassistant/components/raincloud/binary_sensor.py @@ -12,7 +12,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import CONF_MONITORED_CONDITIONS from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/raincloud/sensor.py b/homeassistant/components/raincloud/sensor.py index 1f9d8d7b2c5..8aaec605c04 100644 --- a/homeassistant/components/raincloud/sensor.py +++ b/homeassistant/components/raincloud/sensor.py @@ -12,7 +12,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_MONITORED_CONDITIONS, PERCENTAGE, UnitOfTime from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.icon import icon_for_battery_level from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/raincloud/switch.py b/homeassistant/components/raincloud/switch.py index 59a11a6b167..babadcba676 100644 --- a/homeassistant/components/raincloud/switch.py +++ b/homeassistant/components/raincloud/switch.py @@ -13,7 +13,7 @@ from homeassistant.components.switch import ( ) from homeassistant.const import CONF_MONITORED_CONDITIONS from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/random/binary_sensor.py b/homeassistant/components/random/binary_sensor.py index ae9a5886d59..fadc966bc3d 100644 --- a/homeassistant/components/random/binary_sensor.py +++ b/homeassistant/components/random/binary_sensor.py @@ -16,7 +16,7 @@ from homeassistant.components.binary_sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DEVICE_CLASS, CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/random/config_flow.py b/homeassistant/components/random/config_flow.py index 35b7757580e..406100388e6 100644 --- a/homeassistant/components/random/config_flow.py +++ b/homeassistant/components/random/config_flow.py @@ -17,7 +17,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.schema_config_entry_flow import ( SchemaCommonFlowHandler, SchemaConfigFlowHandler, diff --git a/homeassistant/components/random/sensor.py b/homeassistant/components/random/sensor.py index aad4fcb851c..590b391c3a0 100644 --- a/homeassistant/components/random/sensor.py +++ b/homeassistant/components/random/sensor.py @@ -21,7 +21,7 @@ from homeassistant.const import ( CONF_UNIT_OF_MEASUREMENT, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/raspyrfm/switch.py b/homeassistant/components/raspyrfm/switch.py index 37835ecb40a..b9506c3688c 100644 --- a/homeassistant/components/raspyrfm/switch.py +++ b/homeassistant/components/raspyrfm/switch.py @@ -25,7 +25,7 @@ from homeassistant.const import ( DEVICE_DEFAULT_NAME, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/recswitch/switch.py b/homeassistant/components/recswitch/switch.py index 78fc0a805f6..f5b566ce59d 100644 --- a/homeassistant/components/recswitch/switch.py +++ b/homeassistant/components/recswitch/switch.py @@ -14,7 +14,7 @@ from homeassistant.components.switch import ( ) from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/reddit/sensor.py b/homeassistant/components/reddit/sensor.py index 35962ac091b..564cc6c3c06 100644 --- a/homeassistant/components/reddit/sensor.py +++ b/homeassistant/components/reddit/sensor.py @@ -21,7 +21,7 @@ from homeassistant.const import ( CONF_USERNAME, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/rejseplanen/sensor.py b/homeassistant/components/rejseplanen/sensor.py index 40b27014211..1d9b281e9b7 100644 --- a/homeassistant/components/rejseplanen/sensor.py +++ b/homeassistant/components/rejseplanen/sensor.py @@ -20,10 +20,10 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_NAME, UnitOfTime from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/remember_the_milk/__init__.py b/homeassistant/components/remember_the_milk/__init__.py index d544c42efe1..0d1c54efb56 100644 --- a/homeassistant/components/remember_the_milk/__init__.py +++ b/homeassistant/components/remember_the_milk/__init__.py @@ -10,7 +10,7 @@ import voluptuous as vol from homeassistant.components import configurator from homeassistant.const import CONF_API_KEY, CONF_ID, CONF_NAME, CONF_TOKEN from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/remote_rpi_gpio/binary_sensor.py b/homeassistant/components/remote_rpi_gpio/binary_sensor.py index b3a8075c6ba..42e8517c1e8 100644 --- a/homeassistant/components/remote_rpi_gpio/binary_sensor.py +++ b/homeassistant/components/remote_rpi_gpio/binary_sensor.py @@ -11,7 +11,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/remote_rpi_gpio/switch.py b/homeassistant/components/remote_rpi_gpio/switch.py index bf31e4bb55a..91b389c5a1e 100644 --- a/homeassistant/components/remote_rpi_gpio/switch.py +++ b/homeassistant/components/remote_rpi_gpio/switch.py @@ -12,7 +12,7 @@ from homeassistant.components.switch import ( ) from homeassistant.const import CONF_HOST, DEVICE_DEFAULT_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/renson/fan.py b/homeassistant/components/renson/fan.py index 56b3655ef94..00edd4547cb 100644 --- a/homeassistant/components/renson/fan.py +++ b/homeassistant/components/renson/fan.py @@ -18,8 +18,7 @@ import voluptuous as vol from homeassistant.components.fan import FanEntity, FanEntityFeature from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import entity_platform -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import VolDictType from homeassistant.util.percentage import ( diff --git a/homeassistant/components/repetier/__init__.py b/homeassistant/components/repetier/__init__.py index 27ddc62a847..16c92d6cd37 100644 --- a/homeassistant/components/repetier/__init__.py +++ b/homeassistant/components/repetier/__init__.py @@ -21,7 +21,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.discovery import load_platform from homeassistant.helpers.dispatcher import dispatcher_send from homeassistant.helpers.event import track_time_interval diff --git a/homeassistant/components/rest/binary_sensor.py b/homeassistant/components/rest/binary_sensor.py index c976506d1ba..fa5bd388009 100644 --- a/homeassistant/components/rest/binary_sensor.py +++ b/homeassistant/components/rest/binary_sensor.py @@ -25,7 +25,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.template import Template from homeassistant.helpers.trigger_template_entity import ( diff --git a/homeassistant/components/rest/notify.py b/homeassistant/components/rest/notify.py index 1ca3c55e2b2..ace216e1918 100644 --- a/homeassistant/components/rest/notify.py +++ b/homeassistant/components/rest/notify.py @@ -31,7 +31,7 @@ from homeassistant.const import ( HTTP_DIGEST_AUTHENTICATION, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.httpx_client import get_async_client from homeassistant.helpers.template import Template from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/rest/schema.py b/homeassistant/components/rest/schema.py index f7fd8a36113..62ed2d5c5b2 100644 --- a/homeassistant/components/rest/schema.py +++ b/homeassistant/components/rest/schema.py @@ -26,7 +26,7 @@ from homeassistant.const import ( HTTP_BASIC_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION, ) -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.trigger_template_entity import ( CONF_AVAILABILITY, TEMPLATE_ENTITY_BASE_SCHEMA, diff --git a/homeassistant/components/rest/sensor.py b/homeassistant/components/rest/sensor.py index fc6ce8c6749..b95e6dd72b7 100644 --- a/homeassistant/components/rest/sensor.py +++ b/homeassistant/components/rest/sensor.py @@ -29,7 +29,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.template import Template from homeassistant.helpers.trigger_template_entity import ( diff --git a/homeassistant/components/rest_command/__init__.py b/homeassistant/components/rest_command/__init__.py index ee93fde35fa..fe3702510af 100644 --- a/homeassistant/components/rest_command/__init__.py +++ b/homeassistant/components/rest_command/__init__.py @@ -30,8 +30,8 @@ from homeassistant.core import ( callback, ) from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.reload import async_integration_yaml_config from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/rflink/__init__.py b/homeassistant/components/rflink/__init__.py index 7e86854dbce..85195fb1581 100644 --- a/homeassistant/components/rflink/__init__.py +++ b/homeassistant/components/rflink/__init__.py @@ -18,7 +18,7 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, ) from homeassistant.core import CoreState, HassJob, HomeAssistant, ServiceCall, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, diff --git a/homeassistant/components/rflink/binary_sensor.py b/homeassistant/components/rflink/binary_sensor.py index 29046ba7616..43a7c03c67b 100644 --- a/homeassistant/components/rflink/binary_sensor.py +++ b/homeassistant/components/rflink/binary_sensor.py @@ -20,9 +20,8 @@ from homeassistant.const import ( STATE_ON, ) from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, event as evt from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.helpers.event as evt from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/rflink/const.py b/homeassistant/components/rflink/const.py index cc52ea978bd..83eb2915f70 100644 --- a/homeassistant/components/rflink/const.py +++ b/homeassistant/components/rflink/const.py @@ -4,7 +4,7 @@ from __future__ import annotations import voluptuous as vol -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv CONF_ALIASES = "aliases" CONF_GROUP_ALIASES = "group_aliases" diff --git a/homeassistant/components/rflink/cover.py b/homeassistant/components/rflink/cover.py index 695825cf31b..8b21bc9274d 100644 --- a/homeassistant/components/rflink/cover.py +++ b/homeassistant/components/rflink/cover.py @@ -14,7 +14,7 @@ from homeassistant.components.cover import ( ) from homeassistant.const import CONF_DEVICES, CONF_NAME, CONF_TYPE from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/rflink/light.py b/homeassistant/components/rflink/light.py index 00117140abb..2a5b1ccf8d7 100644 --- a/homeassistant/components/rflink/light.py +++ b/homeassistant/components/rflink/light.py @@ -16,7 +16,7 @@ from homeassistant.components.light import ( ) from homeassistant.const import CONF_DEVICES, CONF_NAME, CONF_TYPE from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/rflink/sensor.py b/homeassistant/components/rflink/sensor.py index 89632ac50b3..027c39da70f 100644 --- a/homeassistant/components/rflink/sensor.py +++ b/homeassistant/components/rflink/sensor.py @@ -35,7 +35,7 @@ from homeassistant.const import ( UnitOfVolumetricFlux, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/rflink/switch.py b/homeassistant/components/rflink/switch.py index 23b93896878..bbbce2b8e9a 100644 --- a/homeassistant/components/rflink/switch.py +++ b/homeassistant/components/rflink/switch.py @@ -10,7 +10,7 @@ from homeassistant.components.switch import ( ) from homeassistant.const import CONF_DEVICES, CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/rfxtrx/device_action.py b/homeassistant/components/rfxtrx/device_action.py index 405daa37ec5..c3f61dee026 100644 --- a/homeassistant/components/rfxtrx/device_action.py +++ b/homeassistant/components/rfxtrx/device_action.py @@ -9,7 +9,7 @@ import voluptuous as vol from homeassistant.components.device_automation import InvalidDeviceAutomationConfig from homeassistant.const import CONF_DEVICE_ID, CONF_DOMAIN, CONF_TYPE from homeassistant.core import Context, HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType, TemplateVarsType from . import DATA_RFXOBJECT, DOMAIN diff --git a/homeassistant/components/ring/light.py b/homeassistant/components/ring/light.py index 9ae0bac1004..62c5217a89b 100644 --- a/homeassistant/components/ring/light.py +++ b/homeassistant/components/ring/light.py @@ -10,7 +10,7 @@ from ring_doorbell import RingStickUpCam from homeassistant.components.light import ColorMode, LightEntity from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import RingConfigEntry from .coordinator import RingDataCoordinator diff --git a/homeassistant/components/ring/switch.py b/homeassistant/components/ring/switch.py index e81d483adf3..cab5654fc5a 100644 --- a/homeassistant/components/ring/switch.py +++ b/homeassistant/components/ring/switch.py @@ -12,7 +12,7 @@ from homeassistant.components.switch import SwitchEntity, SwitchEntityDescriptio from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import RingConfigEntry from .coordinator import RingDataCoordinator diff --git a/homeassistant/components/ripple/sensor.py b/homeassistant/components/ripple/sensor.py index 72510ea251d..30d2d77dcb4 100644 --- a/homeassistant/components/ripple/sensor.py +++ b/homeassistant/components/ripple/sensor.py @@ -13,7 +13,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_ADDRESS, CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/rmvtransport/sensor.py b/homeassistant/components/rmvtransport/sensor.py index ac6c66bb6d2..c3217d9334e 100644 --- a/homeassistant/components/rmvtransport/sensor.py +++ b/homeassistant/components/rmvtransport/sensor.py @@ -20,7 +20,7 @@ from homeassistant.components.sensor import ( from homeassistant.const import CONF_NAME, CONF_TIMEOUT, UnitOfTime from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import Throttle diff --git a/homeassistant/components/roborock/image.py b/homeassistant/components/roborock/image.py index 8717920b907..3818a039fb8 100644 --- a/homeassistant/components/roborock/image.py +++ b/homeassistant/components/roborock/image.py @@ -17,8 +17,7 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.util import slugify -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util, slugify from . import RoborockConfigEntry from .const import DEFAULT_DRAWABLES, DOMAIN, DRAWABLES, IMAGE_CACHE_INTERVAL, MAP_SLEEP diff --git a/homeassistant/components/rocketchat/notify.py b/homeassistant/components/rocketchat/notify.py index a06226d22ee..20ae0708c15 100644 --- a/homeassistant/components/rocketchat/notify.py +++ b/homeassistant/components/rocketchat/notify.py @@ -19,7 +19,7 @@ from homeassistant.components.notify import ( ) from homeassistant.const import CONF_PASSWORD, CONF_ROOM, CONF_URL, CONF_USERNAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/romy/config_flow.py b/homeassistant/components/romy/config_flow.py index 6bb5c337b29..48558cd98c7 100644 --- a/homeassistant/components/romy/config_flow.py +++ b/homeassistant/components/romy/config_flow.py @@ -7,7 +7,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_PASSWORD -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DOMAIN, LOGGER diff --git a/homeassistant/components/roomba/entity.py b/homeassistant/components/roomba/entity.py index 69e8d5b5414..ae5577da4e4 100644 --- a/homeassistant/components/roomba/entity.py +++ b/homeassistant/components/roomba/entity.py @@ -6,7 +6,7 @@ from homeassistant.const import ATTR_CONNECTIONS from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import roomba_reported_state from .const import DOMAIN diff --git a/homeassistant/components/roon/config_flow.py b/homeassistant/components/roon/config_flow.py index b896f6775ae..3421cbf646c 100644 --- a/homeassistant/components/roon/config_flow.py +++ b/homeassistant/components/roon/config_flow.py @@ -11,7 +11,7 @@ from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from .const import ( AUTHENTICATE_TIMEOUT, diff --git a/homeassistant/components/route53/__init__.py b/homeassistant/components/route53/__init__.py index 92094b0b608..2c9824d0628 100644 --- a/homeassistant/components/route53/__init__.py +++ b/homeassistant/components/route53/__init__.py @@ -12,7 +12,7 @@ import voluptuous as vol from homeassistant.const import CONF_DOMAIN, CONF_TTL, CONF_ZONE from homeassistant.core import HomeAssistant, ServiceCall -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.event import track_time_interval from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/rss_feed_template/__init__.py b/homeassistant/components/rss_feed_template/__init__.py index 89624c922e6..98d0e1bf790 100644 --- a/homeassistant/components/rss_feed_template/__init__.py +++ b/homeassistant/components/rss_feed_template/__init__.py @@ -9,7 +9,7 @@ import voluptuous as vol from homeassistant.components.http import HomeAssistantView from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.template import Template from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/rtorrent/sensor.py b/homeassistant/components/rtorrent/sensor.py index 654288927d3..70fe7919edb 100644 --- a/homeassistant/components/rtorrent/sensor.py +++ b/homeassistant/components/rtorrent/sensor.py @@ -22,7 +22,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/russound_rnet/media_player.py b/homeassistant/components/russound_rnet/media_player.py index f8369ed64ca..48808930d9f 100644 --- a/homeassistant/components/russound_rnet/media_player.py +++ b/homeassistant/components/russound_rnet/media_player.py @@ -16,7 +16,7 @@ from homeassistant.components.media_player import ( ) from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType From 844259bd6c767ec8917e367335b5448b68f37a77 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 29 Jan 2025 12:24:31 +0100 Subject: [PATCH 0229/3148] Standardize homeassistant imports in component (s) (#136832) --- homeassistant/components/saj/sensor.py | 2 +- homeassistant/components/schedule/__init__.py | 2 +- homeassistant/components/schluter/__init__.py | 3 +-- homeassistant/components/scrape/__init__.py | 7 +++++-- homeassistant/components/screenlogic/config_flow.py | 2 +- homeassistant/components/scsgate/__init__.py | 2 +- homeassistant/components/scsgate/cover.py | 2 +- homeassistant/components/scsgate/light.py | 2 +- homeassistant/components/scsgate/switch.py | 2 +- homeassistant/components/sendgrid/notify.py | 2 +- homeassistant/components/serial/sensor.py | 2 +- homeassistant/components/serial_pm/sensor.py | 2 +- homeassistant/components/sesame/lock.py | 2 +- .../components/seven_segments/image_processing.py | 2 +- homeassistant/components/sharkiq/vacuum.py | 3 +-- homeassistant/components/shodan/sensor.py | 2 +- homeassistant/components/sigfox/sensor.py | 2 +- homeassistant/components/sighthound/image_processing.py | 4 ++-- homeassistant/components/signal_messenger/notify.py | 2 +- homeassistant/components/sinch/notify.py | 2 +- homeassistant/components/sisyphus/__init__.py | 2 +- homeassistant/components/sky_hub/device_tracker.py | 2 +- homeassistant/components/sky_remote/config_flow.py | 2 +- homeassistant/components/skybeacon/sensor.py | 2 +- homeassistant/components/slack/sensor.py | 2 +- homeassistant/components/sleepiq/__init__.py | 3 +-- homeassistant/components/sma/config_flow.py | 2 +- homeassistant/components/smarty/__init__.py | 3 +-- homeassistant/components/smarty/sensor.py | 2 +- homeassistant/components/smtp/notify.py | 4 ++-- homeassistant/components/snmp/device_tracker.py | 2 +- homeassistant/components/snmp/sensor.py | 2 +- homeassistant/components/snmp/switch.py | 2 +- homeassistant/components/solaredge_local/sensor.py | 2 +- homeassistant/components/solax/config_flow.py | 2 +- homeassistant/components/soma/__init__.py | 2 +- homeassistant/components/sonarr/coordinator.py | 2 +- homeassistant/components/sonarr/sensor.py | 2 +- homeassistant/components/sony_projector/switch.py | 2 +- homeassistant/components/soundtouch/__init__.py | 2 +- homeassistant/components/spaceapi/__init__.py | 6 +++--- homeassistant/components/spc/__init__.py | 3 +-- homeassistant/components/splunk/__init__.py | 3 +-- homeassistant/components/spotify/coordinator.py | 2 +- homeassistant/components/sql/__init__.py | 3 +-- homeassistant/components/starlingbank/sensor.py | 2 +- homeassistant/components/startca/sensor.py | 2 +- homeassistant/components/statsd/__init__.py | 3 +-- homeassistant/components/stiebel_eltron/__init__.py | 3 +-- homeassistant/components/streamlabswater/__init__.py | 2 +- homeassistant/components/supervisord/sensor.py | 2 +- homeassistant/components/supla/__init__.py | 2 +- homeassistant/components/swiss_hydrological_data/sensor.py | 2 +- .../components/swiss_public_transport/config_flow.py | 2 +- .../components/swiss_public_transport/coordinator.py | 2 +- homeassistant/components/swiss_public_transport/helper.py | 2 +- homeassistant/components/swisscom/device_tracker.py | 2 +- homeassistant/components/switchbee/config_flow.py | 2 +- homeassistant/components/switchbot_cloud/climate.py | 2 +- homeassistant/components/switchmate/switch.py | 2 +- homeassistant/components/synology_chat/notify.py | 2 +- homeassistant/components/synology_dsm/config_flow.py | 2 +- homeassistant/components/synology_srm/device_tracker.py | 2 +- homeassistant/components/system_log/__init__.py | 2 +- 64 files changed, 72 insertions(+), 78 deletions(-) diff --git a/homeassistant/components/saj/sensor.py b/homeassistant/components/saj/sensor.py index c8b40fd5476..89b6658c418 100644 --- a/homeassistant/components/saj/sensor.py +++ b/homeassistant/components/saj/sensor.py @@ -31,7 +31,7 @@ from homeassistant.const import ( ) from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.exceptions import PlatformNotReady -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_call_later from homeassistant.helpers.start import async_at_start diff --git a/homeassistant/components/schedule/__init__.py b/homeassistant/components/schedule/__init__.py index 30ca44fe3ee..20dc9c1256a 100644 --- a/homeassistant/components/schedule/__init__.py +++ b/homeassistant/components/schedule/__init__.py @@ -19,6 +19,7 @@ from homeassistant.const import ( STATE_ON, ) from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.collection import ( CollectionEntity, DictStorageCollection, @@ -28,7 +29,6 @@ from homeassistant.helpers.collection import ( YamlCollection, sync_entity_lifecycle, ) -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.helpers.service import async_register_admin_service diff --git a/homeassistant/components/schluter/__init__.py b/homeassistant/components/schluter/__init__.py index 907841a2e5e..f7a8b631a05 100644 --- a/homeassistant/components/schluter/__init__.py +++ b/homeassistant/components/schluter/__init__.py @@ -9,8 +9,7 @@ import voluptuous as vol from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import discovery -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.typing import ConfigType from .const import DOMAIN diff --git a/homeassistant/components/scrape/__init__.py b/homeassistant/components/scrape/__init__.py index ff991c5f348..68a8cf62fe4 100644 --- a/homeassistant/components/scrape/__init__.py +++ b/homeassistant/components/scrape/__init__.py @@ -19,8 +19,11 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import discovery, entity_registry as er -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import ( + config_validation as cv, + discovery, + entity_registry as er, +) from homeassistant.helpers.device_registry import DeviceEntry from homeassistant.helpers.trigger_template_entity import ( CONF_AVAILABILITY, diff --git a/homeassistant/components/screenlogic/config_flow.py b/homeassistant/components/screenlogic/config_flow.py index 54067055a69..0fdf5d96445 100644 --- a/homeassistant/components/screenlogic/config_flow.py +++ b/homeassistant/components/screenlogic/config_flow.py @@ -18,7 +18,7 @@ from homeassistant.config_entries import ( ) from homeassistant.const import CONF_IP_ADDRESS, CONF_PORT, CONF_SCAN_INTERVAL from homeassistant.core import callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo diff --git a/homeassistant/components/scsgate/__init__.py b/homeassistant/components/scsgate/__init__.py index 9aabb315942..636c157b076 100644 --- a/homeassistant/components/scsgate/__init__.py +++ b/homeassistant/components/scsgate/__init__.py @@ -11,7 +11,7 @@ import voluptuous as vol from homeassistant.const import CONF_DEVICE, CONF_NAME, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/scsgate/cover.py b/homeassistant/components/scsgate/cover.py index b6d3317555c..4c4d2c2949a 100644 --- a/homeassistant/components/scsgate/cover.py +++ b/homeassistant/components/scsgate/cover.py @@ -18,7 +18,7 @@ from homeassistant.components.cover import ( ) from homeassistant.const import CONF_DEVICES, CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/scsgate/light.py b/homeassistant/components/scsgate/light.py index 23b73a0fd6b..0addbda9e09 100644 --- a/homeassistant/components/scsgate/light.py +++ b/homeassistant/components/scsgate/light.py @@ -15,7 +15,7 @@ from homeassistant.components.light import ( ) from homeassistant.const import ATTR_ENTITY_ID, ATTR_STATE, CONF_DEVICES, CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/scsgate/switch.py b/homeassistant/components/scsgate/switch.py index abc906a5533..4607d65ac7a 100644 --- a/homeassistant/components/scsgate/switch.py +++ b/homeassistant/components/scsgate/switch.py @@ -15,7 +15,7 @@ from homeassistant.components.switch import ( ) from homeassistant.const import ATTR_ENTITY_ID, ATTR_STATE, CONF_DEVICES, CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/sendgrid/notify.py b/homeassistant/components/sendgrid/notify.py index 86f01804574..4dbb95085cb 100644 --- a/homeassistant/components/sendgrid/notify.py +++ b/homeassistant/components/sendgrid/notify.py @@ -21,7 +21,7 @@ from homeassistant.const import ( CONTENT_TYPE_TEXT_PLAIN, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/serial/sensor.py b/homeassistant/components/serial/sensor.py index a09401473b2..4d43408397f 100644 --- a/homeassistant/components/serial/sensor.py +++ b/homeassistant/components/serial/sensor.py @@ -16,7 +16,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_NAME, CONF_VALUE_TEMPLATE, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/serial_pm/sensor.py b/homeassistant/components/serial_pm/sensor.py index b454424591d..570d1ac0d63 100644 --- a/homeassistant/components/serial_pm/sensor.py +++ b/homeassistant/components/serial_pm/sensor.py @@ -13,7 +13,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/sesame/lock.py b/homeassistant/components/sesame/lock.py index ad8b26f7034..5165d3d4798 100644 --- a/homeassistant/components/sesame/lock.py +++ b/homeassistant/components/sesame/lock.py @@ -13,7 +13,7 @@ from homeassistant.components.lock import ( ) from homeassistant.const import ATTR_BATTERY_LEVEL, ATTR_DEVICE_ID, CONF_API_KEY from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/seven_segments/image_processing.py b/homeassistant/components/seven_segments/image_processing.py index 63fd27e0dd0..bda17b75081 100644 --- a/homeassistant/components/seven_segments/image_processing.py +++ b/homeassistant/components/seven_segments/image_processing.py @@ -17,7 +17,7 @@ from homeassistant.components.image_processing import ( ) from homeassistant.const import CONF_ENTITY_ID, CONF_NAME, CONF_SOURCE from homeassistant.core import HomeAssistant, split_entity_id -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/sharkiq/vacuum.py b/homeassistant/components/sharkiq/vacuum.py index 873d3fbd290..332d95b0a3e 100644 --- a/homeassistant/components/sharkiq/vacuum.py +++ b/homeassistant/components/sharkiq/vacuum.py @@ -16,8 +16,7 @@ from homeassistant.components.vacuum import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError -from homeassistant.helpers import entity_platform -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity diff --git a/homeassistant/components/shodan/sensor.py b/homeassistant/components/shodan/sensor.py index 867b58ad1ba..ef0f4dafd83 100644 --- a/homeassistant/components/shodan/sensor.py +++ b/homeassistant/components/shodan/sensor.py @@ -14,7 +14,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_API_KEY, CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/sigfox/sensor.py b/homeassistant/components/sigfox/sensor.py index 8f9190e4436..aece5675cbc 100644 --- a/homeassistant/components/sigfox/sensor.py +++ b/homeassistant/components/sigfox/sensor.py @@ -17,7 +17,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/sighthound/image_processing.py b/homeassistant/components/sighthound/image_processing.py index acc8309af26..222b61456c4 100644 --- a/homeassistant/components/sighthound/image_processing.py +++ b/homeassistant/components/sighthound/image_processing.py @@ -22,10 +22,10 @@ from homeassistant.const import ( CONF_SOURCE, ) from homeassistant.core import HomeAssistant, split_entity_id -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from homeassistant.util.pil import draw_box _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/signal_messenger/notify.py b/homeassistant/components/signal_messenger/notify.py index 53a255da5ff..bc007eaa689 100644 --- a/homeassistant/components/signal_messenger/notify.py +++ b/homeassistant/components/signal_messenger/notify.py @@ -15,7 +15,7 @@ from homeassistant.components.notify import ( BaseNotificationService, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/sinch/notify.py b/homeassistant/components/sinch/notify.py index 16780a05704..8c906d26c23 100644 --- a/homeassistant/components/sinch/notify.py +++ b/homeassistant/components/sinch/notify.py @@ -23,7 +23,7 @@ from homeassistant.components.notify import ( ) from homeassistant.const import CONF_API_KEY, CONF_SENDER from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType DOMAIN = "sinch" diff --git a/homeassistant/components/sisyphus/__init__.py b/homeassistant/components/sisyphus/__init__.py index da8d670d412..1406826e471 100644 --- a/homeassistant/components/sisyphus/__init__.py +++ b/homeassistant/components/sisyphus/__init__.py @@ -8,8 +8,8 @@ import voluptuous as vol from homeassistant.const import CONF_HOST, CONF_NAME, EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.discovery import async_load_platform from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/sky_hub/device_tracker.py b/homeassistant/components/sky_hub/device_tracker.py index b0ad48ed985..7507175b321 100644 --- a/homeassistant/components/sky_hub/device_tracker.py +++ b/homeassistant/components/sky_hub/device_tracker.py @@ -14,8 +14,8 @@ from homeassistant.components.device_tracker import ( ) from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/sky_remote/config_flow.py b/homeassistant/components/sky_remote/config_flow.py index a55dfb2a52b..13cddf99332 100644 --- a/homeassistant/components/sky_remote/config_flow.py +++ b/homeassistant/components/sky_remote/config_flow.py @@ -8,7 +8,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_PORT -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from .const import DEFAULT_PORT, DOMAIN, LEGACY_PORT diff --git a/homeassistant/components/skybeacon/sensor.py b/homeassistant/components/skybeacon/sensor.py index 6cb5064b40e..650e62bc4a1 100644 --- a/homeassistant/components/skybeacon/sensor.py +++ b/homeassistant/components/skybeacon/sensor.py @@ -25,7 +25,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/slack/sensor.py b/homeassistant/components/slack/sensor.py index d53555ba82a..ca8c9830818 100644 --- a/homeassistant/components/slack/sensor.py +++ b/homeassistant/components/slack/sensor.py @@ -12,7 +12,7 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import ATTR_SNOOZE, DOMAIN, SLACK_DATA from .entity import SlackEntity diff --git a/homeassistant/components/sleepiq/__init__.py b/homeassistant/components/sleepiq/__init__.py index 6506be06e72..4f54b4cd305 100644 --- a/homeassistant/components/sleepiq/__init__.py +++ b/homeassistant/components/sleepiq/__init__.py @@ -17,9 +17,8 @@ from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, PRESSURE, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType from .const import DOMAIN, IS_IN_BED, SLEEP_NUMBER diff --git a/homeassistant/components/sma/config_flow.py b/homeassistant/components/sma/config_flow.py index 4b3e01a79a8..3f5eb635989 100644 --- a/homeassistant/components/sma/config_flow.py +++ b/homeassistant/components/sma/config_flow.py @@ -11,8 +11,8 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_SSL, CONF_VERIFY_SSL from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from .const import CONF_GROUP, DOMAIN, GROUPS diff --git a/homeassistant/components/smarty/__init__.py b/homeassistant/components/smarty/__init__.py index 0d043804c3d..0e1e99aa444 100644 --- a/homeassistant/components/smarty/__init__.py +++ b/homeassistant/components/smarty/__init__.py @@ -9,8 +9,7 @@ from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import CONF_HOST, CONF_NAME, Platform from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from homeassistant.helpers import issue_registry as ir -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, issue_registry as ir from homeassistant.helpers.typing import ConfigType from .const import DOMAIN diff --git a/homeassistant/components/smarty/sensor.py b/homeassistant/components/smarty/sensor.py index 9d847003a59..48b169c104e 100644 --- a/homeassistant/components/smarty/sensor.py +++ b/homeassistant/components/smarty/sensor.py @@ -17,7 +17,7 @@ from homeassistant.components.sensor import ( from homeassistant.const import REVOLUTIONS_PER_MINUTE, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .coordinator import SmartyConfigEntry, SmartyCoordinator from .entity import SmartyEntity diff --git a/homeassistant/components/smtp/notify.py b/homeassistant/components/smtp/notify.py index 5d19a705d87..e86b22690a4 100644 --- a/homeassistant/components/smtp/notify.py +++ b/homeassistant/components/smtp/notify.py @@ -34,10 +34,10 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.reload import setup_reload_service from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from homeassistant.util.ssl import client_context from .const import ( diff --git a/homeassistant/components/snmp/device_tracker.py b/homeassistant/components/snmp/device_tracker.py index 4c2b2b25ad8..f69c844f191 100644 --- a/homeassistant/components/snmp/device_tracker.py +++ b/homeassistant/components/snmp/device_tracker.py @@ -24,7 +24,7 @@ from homeassistant.components.device_tracker import ( ) from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from .const import ( diff --git a/homeassistant/components/snmp/sensor.py b/homeassistant/components/snmp/sensor.py index 4586d0600e9..0baecd68ec4 100644 --- a/homeassistant/components/snmp/sensor.py +++ b/homeassistant/components/snmp/sensor.py @@ -37,7 +37,7 @@ from homeassistant.const import ( STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.template import Template from homeassistant.helpers.trigger_template_entity import ( diff --git a/homeassistant/components/snmp/switch.py b/homeassistant/components/snmp/switch.py index 2f9f8b0bfb7..fd405567d60 100644 --- a/homeassistant/components/snmp/switch.py +++ b/homeassistant/components/snmp/switch.py @@ -44,7 +44,7 @@ from homeassistant.const import ( CONF_USERNAME, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/solaredge_local/sensor.py b/homeassistant/components/solaredge_local/sensor.py index a7940aa34b5..80c418ef132 100644 --- a/homeassistant/components/solaredge_local/sensor.py +++ b/homeassistant/components/solaredge_local/sensor.py @@ -29,7 +29,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import Throttle diff --git a/homeassistant/components/solax/config_flow.py b/homeassistant/components/solax/config_flow.py index e6c60667869..5baead641fc 100644 --- a/homeassistant/components/solax/config_flow.py +++ b/homeassistant/components/solax/config_flow.py @@ -11,7 +11,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD, CONF_PORT -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from .const import DOMAIN diff --git a/homeassistant/components/soma/__init__.py b/homeassistant/components/soma/__init__.py index 9ffe5539ff3..127b51338ee 100644 --- a/homeassistant/components/soma/__init__.py +++ b/homeassistant/components/soma/__init__.py @@ -9,7 +9,7 @@ from homeassistant import config_entries from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PORT, Platform from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from .const import API, DEVICES, DOMAIN, HOST, PORT diff --git a/homeassistant/components/sonarr/coordinator.py b/homeassistant/components/sonarr/coordinator.py index 2d807bcf140..25fc736212b 100644 --- a/homeassistant/components/sonarr/coordinator.py +++ b/homeassistant/components/sonarr/coordinator.py @@ -22,7 +22,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import CONF_UPCOMING_DAYS, CONF_WANTED_MAX_ITEMS, DOMAIN, LOGGER diff --git a/homeassistant/components/sonarr/sensor.py b/homeassistant/components/sonarr/sensor.py index f25c885ed84..fa7d0aa7756 100644 --- a/homeassistant/components/sonarr/sensor.py +++ b/homeassistant/components/sonarr/sensor.py @@ -25,7 +25,7 @@ from homeassistant.const import UnitOfInformation from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import DOMAIN from .coordinator import SonarrDataT, SonarrDataUpdateCoordinator diff --git a/homeassistant/components/sony_projector/switch.py b/homeassistant/components/sony_projector/switch.py index e018c06e050..f024c4ef4f7 100644 --- a/homeassistant/components/sony_projector/switch.py +++ b/homeassistant/components/sony_projector/switch.py @@ -14,7 +14,7 @@ from homeassistant.components.switch import ( ) from homeassistant.const import CONF_HOST, CONF_NAME, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/soundtouch/__init__.py b/homeassistant/components/soundtouch/__init__.py index c35c1e6f9c3..49750bc9baf 100644 --- a/homeassistant/components/soundtouch/__init__.py +++ b/homeassistant/components/soundtouch/__init__.py @@ -9,7 +9,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant, ServiceCall -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from .const import ( diff --git a/homeassistant/components/spaceapi/__init__.py b/homeassistant/components/spaceapi/__init__.py index 90281fe311c..6ef643488ad 100644 --- a/homeassistant/components/spaceapi/__init__.py +++ b/homeassistant/components/spaceapi/__init__.py @@ -5,6 +5,7 @@ import math import voluptuous as vol +from homeassistant import core as ha from homeassistant.components.http import KEY_HASS, HomeAssistantView from homeassistant.const import ( ATTR_ENTITY_ID, @@ -21,11 +22,10 @@ from homeassistant.const import ( CONF_STATE, CONF_URL, ) -import homeassistant.core as ha from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util ATTR_ADDRESS = "address" ATTR_SPACEFED = "spacefed" diff --git a/homeassistant/components/spc/__init__.py b/homeassistant/components/spc/__init__.py index 3d9467f2041..2fed542e382 100644 --- a/homeassistant/components/spc/__init__.py +++ b/homeassistant/components/spc/__init__.py @@ -9,8 +9,7 @@ import voluptuous as vol from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import aiohttp_client, discovery -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import aiohttp_client, config_validation as cv, discovery from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/splunk/__init__.py b/homeassistant/components/splunk/__init__.py index 4294020eeee..6ef8fed78d6 100644 --- a/homeassistant/components/splunk/__init__.py +++ b/homeassistant/components/splunk/__init__.py @@ -19,9 +19,8 @@ from homeassistant.const import ( EVENT_STATE_CHANGED, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import state as state_helper +from homeassistant.helpers import config_validation as cv, state as state_helper from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entityfilter import FILTER_SCHEMA from homeassistant.helpers.json import JSONEncoder from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/spotify/coordinator.py b/homeassistant/components/spotify/coordinator.py index a86544d883e..8b8539d715a 100644 --- a/homeassistant/components/spotify/coordinator.py +++ b/homeassistant/components/spotify/coordinator.py @@ -18,7 +18,7 @@ from spotifyaio import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import DOMAIN diff --git a/homeassistant/components/sql/__init__.py b/homeassistant/components/sql/__init__.py index 71e3671ce96..1b9e8502209 100644 --- a/homeassistant/components/sql/__init__.py +++ b/homeassistant/components/sql/__init__.py @@ -24,8 +24,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import discovery -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.trigger_template_entity import ( CONF_AVAILABILITY, CONF_PICTURE, diff --git a/homeassistant/components/starlingbank/sensor.py b/homeassistant/components/starlingbank/sensor.py index 282323d8b7b..063919179ac 100644 --- a/homeassistant/components/starlingbank/sensor.py +++ b/homeassistant/components/starlingbank/sensor.py @@ -15,7 +15,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_ACCESS_TOKEN, CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/startca/sensor.py b/homeassistant/components/startca/sensor.py index 5fc4872a754..62e02426fcb 100644 --- a/homeassistant/components/startca/sensor.py +++ b/homeassistant/components/startca/sensor.py @@ -25,8 +25,8 @@ from homeassistant.const import ( UnitOfInformation, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import Throttle diff --git a/homeassistant/components/statsd/__init__.py b/homeassistant/components/statsd/__init__.py index 50b74b20028..4e8e5b7f942 100644 --- a/homeassistant/components/statsd/__init__.py +++ b/homeassistant/components/statsd/__init__.py @@ -7,8 +7,7 @@ import voluptuous as vol from homeassistant.const import CONF_HOST, CONF_PORT, CONF_PREFIX, EVENT_STATE_CHANGED from homeassistant.core import HomeAssistant -from homeassistant.helpers import state as state_helper -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, state as state_helper from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/stiebel_eltron/__init__.py b/homeassistant/components/stiebel_eltron/__init__.py index 80c1dad3ee8..94a3bd1058b 100644 --- a/homeassistant/components/stiebel_eltron/__init__.py +++ b/homeassistant/components/stiebel_eltron/__init__.py @@ -9,8 +9,7 @@ import voluptuous as vol from homeassistant.const import CONF_NAME, DEVICE_DEFAULT_NAME, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import discovery -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.typing import ConfigType from homeassistant.util import Throttle diff --git a/homeassistant/components/streamlabswater/__init__.py b/homeassistant/components/streamlabswater/__init__.py index 5eeb40630f8..313fc1f24c5 100644 --- a/homeassistant/components/streamlabswater/__init__.py +++ b/homeassistant/components/streamlabswater/__init__.py @@ -6,7 +6,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, Platform from homeassistant.core import HomeAssistant, ServiceCall -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from .const import DOMAIN from .coordinator import StreamlabsCoordinator diff --git a/homeassistant/components/supervisord/sensor.py b/homeassistant/components/supervisord/sensor.py index 24189fb7de0..c443e1e63df 100644 --- a/homeassistant/components/supervisord/sensor.py +++ b/homeassistant/components/supervisord/sensor.py @@ -13,7 +13,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_URL from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/supla/__init__.py b/homeassistant/components/supla/__init__.py index 8f04b5b662e..62f9b4b232d 100644 --- a/homeassistant/components/supla/__init__.py +++ b/homeassistant/components/supla/__init__.py @@ -11,8 +11,8 @@ import voluptuous as vol from homeassistant.const import CONF_ACCESS_TOKEN, Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.discovery import async_load_platform from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator diff --git a/homeassistant/components/swiss_hydrological_data/sensor.py b/homeassistant/components/swiss_hydrological_data/sensor.py index 3d88182eaa4..897b440a934 100644 --- a/homeassistant/components/swiss_hydrological_data/sensor.py +++ b/homeassistant/components/swiss_hydrological_data/sensor.py @@ -14,7 +14,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_MONITORED_CONDITIONS from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import Throttle diff --git a/homeassistant/components/swiss_public_transport/config_flow.py b/homeassistant/components/swiss_public_transport/config_flow.py index 58d674f0c26..4dc6efc2e85 100644 --- a/homeassistant/components/swiss_public_transport/config_flow.py +++ b/homeassistant/components/swiss_public_transport/config_flow.py @@ -11,8 +11,8 @@ from opendata_transport.exceptions import ( import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.selector import ( DurationSelector, SelectSelector, diff --git a/homeassistant/components/swiss_public_transport/coordinator.py b/homeassistant/components/swiss_public_transport/coordinator.py index c4cf2390dd0..81322117a6f 100644 --- a/homeassistant/components/swiss_public_transport/coordinator.py +++ b/homeassistant/components/swiss_public_transport/coordinator.py @@ -15,7 +15,7 @@ from opendata_transport.exceptions import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from homeassistant.util.json import JsonValueType from .const import CONNECTIONS_COUNT, DEFAULT_UPDATE_TIME, DOMAIN diff --git a/homeassistant/components/swiss_public_transport/helper.py b/homeassistant/components/swiss_public_transport/helper.py index 704479b77d6..e41901337f4 100644 --- a/homeassistant/components/swiss_public_transport/helper.py +++ b/homeassistant/components/swiss_public_transport/helper.py @@ -6,7 +6,7 @@ from typing import Any from opendata_transport import OpendataTransport -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import ( CONF_DESTINATION, diff --git a/homeassistant/components/swisscom/device_tracker.py b/homeassistant/components/swisscom/device_tracker.py index 66537a4311e..842dc657817 100644 --- a/homeassistant/components/swisscom/device_tracker.py +++ b/homeassistant/components/swisscom/device_tracker.py @@ -15,7 +15,7 @@ from homeassistant.components.device_tracker import ( ) from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/switchbee/config_flow.py b/homeassistant/components/switchbee/config_flow.py index c8d3d58ee09..b2cd53398ab 100644 --- a/homeassistant/components/switchbee/config_flow.py +++ b/homeassistant/components/switchbee/config_flow.py @@ -13,8 +13,8 @@ from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.device_registry import format_mac from .const import DOMAIN diff --git a/homeassistant/components/switchbot_cloud/climate.py b/homeassistant/components/switchbot_cloud/climate.py index 4e05e9e9a1e..9e996649e8c 100644 --- a/homeassistant/components/switchbot_cloud/climate.py +++ b/homeassistant/components/switchbot_cloud/climate.py @@ -4,7 +4,7 @@ from typing import Any from switchbot_api import AirConditionerCommands -import homeassistant.components.climate as FanState +from homeassistant.components import climate as FanState from homeassistant.components.climate import ( ClimateEntity, ClimateEntityFeature, diff --git a/homeassistant/components/switchmate/switch.py b/homeassistant/components/switchmate/switch.py index 8484eb5a2d1..0b449c65194 100644 --- a/homeassistant/components/switchmate/switch.py +++ b/homeassistant/components/switchmate/switch.py @@ -14,7 +14,7 @@ from homeassistant.components.switch import ( ) from homeassistant.const import CONF_MAC, CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/synology_chat/notify.py b/homeassistant/components/synology_chat/notify.py index 38c302b7968..37ea3238a06 100644 --- a/homeassistant/components/synology_chat/notify.py +++ b/homeassistant/components/synology_chat/notify.py @@ -16,7 +16,7 @@ from homeassistant.components.notify import ( ) from homeassistant.const import CONF_RESOURCE, CONF_VERIFY_SSL from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType ATTR_FILE_URL = "file_url" diff --git a/homeassistant/components/synology_dsm/config_flow.py b/homeassistant/components/synology_dsm/config_flow.py index 30f5078f19d..b4453366718 100644 --- a/homeassistant/components/synology_dsm/config_flow.py +++ b/homeassistant/components/synology_dsm/config_flow.py @@ -40,8 +40,8 @@ from homeassistant.const import ( ) from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.selector import ( SelectOptionDict, SelectSelector, diff --git a/homeassistant/components/synology_srm/device_tracker.py b/homeassistant/components/synology_srm/device_tracker.py index 3e0e7add185..b916be84acf 100644 --- a/homeassistant/components/synology_srm/device_tracker.py +++ b/homeassistant/components/synology_srm/device_tracker.py @@ -21,7 +21,7 @@ from homeassistant.const import ( CONF_VERIFY_SSL, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/system_log/__init__.py b/homeassistant/components/system_log/__init__.py index 191a2b5feb8..facfb270627 100644 --- a/homeassistant/components/system_log/__init__.py +++ b/homeassistant/components/system_log/__init__.py @@ -16,7 +16,7 @@ from homeassistant import __path__ as HOMEASSISTANT_PATH from homeassistant.components import websocket_api from homeassistant.const import EVENT_HOMEASSISTANT_CLOSE from homeassistant.core import Event, HomeAssistant, ServiceCall, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType type KeyType = tuple[str, tuple[str, int], tuple[str, int, str] | None] From 706a01837cf23a7b68831645a40e798b850ece9f Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 29 Jan 2025 12:25:04 +0100 Subject: [PATCH 0230/3148] Standardize homeassistant imports in component (v-z) (#136834) --- homeassistant/components/vasttrafik/sensor.py | 2 +- homeassistant/components/velux/config_flow.py | 2 +- homeassistant/components/venstar/climate.py | 2 +- homeassistant/components/versasense/__init__.py | 3 +-- homeassistant/components/vesync/config_flow.py | 2 +- homeassistant/components/viaggiatreno/sensor.py | 2 +- homeassistant/components/vicare/climate.py | 3 +-- homeassistant/components/vicare/config_flow.py | 2 +- homeassistant/components/vizio/const.py | 2 +- homeassistant/components/vlc/media_player.py | 4 ++-- homeassistant/components/vlc_telnet/media_player.py | 2 +- homeassistant/components/voicerss/tts.py | 2 +- homeassistant/components/volkszaehler/sensor.py | 2 +- homeassistant/components/vultr/__init__.py | 2 +- homeassistant/components/vultr/binary_sensor.py | 2 +- homeassistant/components/vultr/sensor.py | 2 +- homeassistant/components/vultr/switch.py | 2 +- homeassistant/components/w800rf32/__init__.py | 2 +- homeassistant/components/wake_on_lan/__init__.py | 2 +- homeassistant/components/wake_on_lan/switch.py | 3 +-- homeassistant/components/watson_iot/__init__.py | 3 +-- homeassistant/components/watson_tts/tts.py | 2 +- homeassistant/components/wirelesstag/__init__.py | 2 +- homeassistant/components/wirelesstag/binary_sensor.py | 2 +- homeassistant/components/wirelesstag/sensor.py | 2 +- homeassistant/components/wirelesstag/switch.py | 2 +- homeassistant/components/workday/binary_sensor.py | 2 +- homeassistant/components/worldclock/sensor.py | 2 +- homeassistant/components/worldtidesinfo/sensor.py | 2 +- homeassistant/components/worxlandroid/sensor.py | 2 +- homeassistant/components/wsdot/sensor.py | 2 +- homeassistant/components/x10/light.py | 2 +- homeassistant/components/xiaomi/device_tracker.py | 2 +- homeassistant/components/xiaomi_aqara/__init__.py | 3 +-- homeassistant/components/xiaomi_miio/device_tracker.py | 2 +- homeassistant/components/xiaomi_miio/fan.py | 2 +- homeassistant/components/xiaomi_miio/light.py | 2 +- homeassistant/components/xiaomi_miio/switch.py | 2 +- homeassistant/components/xiaomi_tv/media_player.py | 2 +- homeassistant/components/xmpp/notify.py | 3 +-- homeassistant/components/xs1/__init__.py | 3 +-- homeassistant/components/yale/lock.py | 2 +- homeassistant/components/yale_smart_alarm/config_flow.py | 2 +- homeassistant/components/yandex_transport/sensor.py | 4 ++-- homeassistant/components/yandextts/tts.py | 2 +- homeassistant/components/yeelight/__init__.py | 2 +- homeassistant/components/yeelight/config_flow.py | 2 +- homeassistant/components/zabbix/__init__.py | 7 +++++-- homeassistant/components/zabbix/sensor.py | 2 +- homeassistant/components/zestimate/sensor.py | 2 +- homeassistant/components/zha/__init__.py | 3 +-- homeassistant/components/zha/websocket_api.py | 3 +-- homeassistant/components/zhong_hong/climate.py | 2 +- homeassistant/components/ziggo_mediabox_xl/media_player.py | 2 +- homeassistant/components/zoneminder/__init__.py | 2 +- homeassistant/components/zoneminder/sensor.py | 2 +- homeassistant/components/zoneminder/switch.py | 2 +- homeassistant/components/zwave_js/config_validation.py | 2 +- homeassistant/components/zwave_js/services.py | 7 +++++-- 59 files changed, 69 insertions(+), 72 deletions(-) diff --git a/homeassistant/components/vasttrafik/sensor.py b/homeassistant/components/vasttrafik/sensor.py index 48f659103e1..424ffdc0ed2 100644 --- a/homeassistant/components/vasttrafik/sensor.py +++ b/homeassistant/components/vasttrafik/sensor.py @@ -14,7 +14,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_DELAY, CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import Throttle diff --git a/homeassistant/components/velux/config_flow.py b/homeassistant/components/velux/config_flow.py index fba023f7638..24f65aa3b0b 100644 --- a/homeassistant/components/velux/config_flow.py +++ b/homeassistant/components/velux/config_flow.py @@ -7,7 +7,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigEntryState, ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME, CONF_PASSWORD -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo diff --git a/homeassistant/components/venstar/climate.py b/homeassistant/components/venstar/climate.py index c5323e1e9a8..50f6508e7ed 100644 --- a/homeassistant/components/venstar/climate.py +++ b/homeassistant/components/venstar/climate.py @@ -32,7 +32,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/versasense/__init__.py b/homeassistant/components/versasense/__init__.py index ed4a8edf32c..cbd69ba0a81 100644 --- a/homeassistant/components/versasense/__init__.py +++ b/homeassistant/components/versasense/__init__.py @@ -7,8 +7,7 @@ import voluptuous as vol from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import aiohttp_client -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import aiohttp_client, config_validation as cv from homeassistant.helpers.discovery import async_load_platform from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/vesync/config_flow.py b/homeassistant/components/vesync/config_flow.py index 6115cb9ee76..e19c46e5490 100644 --- a/homeassistant/components/vesync/config_flow.py +++ b/homeassistant/components/vesync/config_flow.py @@ -8,7 +8,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from .const import DOMAIN diff --git a/homeassistant/components/viaggiatreno/sensor.py b/homeassistant/components/viaggiatreno/sensor.py index cb652270c69..4a75f5cccd2 100644 --- a/homeassistant/components/viaggiatreno/sensor.py +++ b/homeassistant/components/viaggiatreno/sensor.py @@ -16,8 +16,8 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import UnitOfTime from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/vicare/climate.py b/homeassistant/components/vicare/climate.py index 62231a4e2fe..f62fdc363a6 100644 --- a/homeassistant/components/vicare/climate.py +++ b/homeassistant/components/vicare/climate.py @@ -32,8 +32,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError -from homeassistant.helpers import entity_platform -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN diff --git a/homeassistant/components/vicare/config_flow.py b/homeassistant/components/vicare/config_flow.py index 36db8e92cc7..c1d4adda62a 100644 --- a/homeassistant/components/vicare/config_flow.py +++ b/homeassistant/components/vicare/config_flow.py @@ -14,7 +14,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_CLIENT_ID, CONF_PASSWORD, CONF_USERNAME -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo diff --git a/homeassistant/components/vizio/const.py b/homeassistant/components/vizio/const.py index 8451ae747de..fbfaf222cad 100644 --- a/homeassistant/components/vizio/const.py +++ b/homeassistant/components/vizio/const.py @@ -10,7 +10,7 @@ from homeassistant.components.media_player import ( MediaPlayerDeviceClass, MediaPlayerEntityFeature, ) -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import VolDictType SERVICE_UPDATE_SETTING = "update_setting" diff --git a/homeassistant/components/vlc/media_player.py b/homeassistant/components/vlc/media_player.py index cd05c919d58..d1a481a99b1 100644 --- a/homeassistant/components/vlc/media_player.py +++ b/homeassistant/components/vlc/media_player.py @@ -20,10 +20,10 @@ from homeassistant.components.media_player import ( ) from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/vlc_telnet/media_player.py b/homeassistant/components/vlc_telnet/media_player.py index b95e987aef8..9597c706570 100644 --- a/homeassistant/components/vlc_telnet/media_player.py +++ b/homeassistant/components/vlc_telnet/media_player.py @@ -23,7 +23,7 @@ from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import VlcConfigEntry from .const import DEFAULT_NAME, DOMAIN, LOGGER diff --git a/homeassistant/components/voicerss/tts.py b/homeassistant/components/voicerss/tts.py index 9f1615ffa01..6bf42d86836 100644 --- a/homeassistant/components/voicerss/tts.py +++ b/homeassistant/components/voicerss/tts.py @@ -13,8 +13,8 @@ from homeassistant.components.tts import ( Provider, ) from homeassistant.const import CONF_API_KEY +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/volkszaehler/sensor.py b/homeassistant/components/volkszaehler/sensor.py index c4fa7b1088b..5bd4a63c923 100644 --- a/homeassistant/components/volkszaehler/sensor.py +++ b/homeassistant/components/volkszaehler/sensor.py @@ -26,8 +26,8 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import Throttle diff --git a/homeassistant/components/vultr/__init__.py b/homeassistant/components/vultr/__init__.py index 36f43cf0ac0..66527bf458e 100644 --- a/homeassistant/components/vultr/__init__.py +++ b/homeassistant/components/vultr/__init__.py @@ -9,7 +9,7 @@ from vultr import Vultr as VultrAPI from homeassistant.components import persistent_notification from homeassistant.const import CONF_API_KEY, Platform from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from homeassistant.util import Throttle diff --git a/homeassistant/components/vultr/binary_sensor.py b/homeassistant/components/vultr/binary_sensor.py index 6a697eebe11..3972de8a625 100644 --- a/homeassistant/components/vultr/binary_sensor.py +++ b/homeassistant/components/vultr/binary_sensor.py @@ -13,7 +13,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/vultr/sensor.py b/homeassistant/components/vultr/sensor.py index 843aa416297..c392c382cbd 100644 --- a/homeassistant/components/vultr/sensor.py +++ b/homeassistant/components/vultr/sensor.py @@ -14,7 +14,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_MONITORED_CONDITIONS, CONF_NAME, UnitOfInformation from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/vultr/switch.py b/homeassistant/components/vultr/switch.py index b03d613895a..0b1f2247684 100644 --- a/homeassistant/components/vultr/switch.py +++ b/homeassistant/components/vultr/switch.py @@ -13,7 +13,7 @@ from homeassistant.components.switch import ( ) from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/w800rf32/__init__.py b/homeassistant/components/w800rf32/__init__.py index 62b9ba810d9..7dab0b137c5 100644 --- a/homeassistant/components/w800rf32/__init__.py +++ b/homeassistant/components/w800rf32/__init__.py @@ -11,7 +11,7 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import dispatcher_send from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/wake_on_lan/__init__.py b/homeassistant/components/wake_on_lan/__init__.py index efd72c4564c..d68d950e641 100644 --- a/homeassistant/components/wake_on_lan/__init__.py +++ b/homeassistant/components/wake_on_lan/__init__.py @@ -9,7 +9,7 @@ import wakeonlan from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_BROADCAST_ADDRESS, CONF_BROADCAST_PORT, CONF_MAC from homeassistant.core import HomeAssistant, ServiceCall -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from .const import DOMAIN, PLATFORMS diff --git a/homeassistant/components/wake_on_lan/switch.py b/homeassistant/components/wake_on_lan/switch.py index fcf8936d498..16df34c1d1b 100644 --- a/homeassistant/components/wake_on_lan/switch.py +++ b/homeassistant/components/wake_on_lan/switch.py @@ -21,8 +21,7 @@ from homeassistant.const import ( CONF_NAME, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.script import Script from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/watson_iot/__init__.py b/homeassistant/components/watson_iot/__init__.py index de8c85f5ff0..0130b53930b 100644 --- a/homeassistant/components/watson_iot/__init__.py +++ b/homeassistant/components/watson_iot/__init__.py @@ -23,8 +23,7 @@ from homeassistant.const import ( STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import state as state_helper -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, state as state_helper from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/watson_tts/tts.py b/homeassistant/components/watson_tts/tts.py index 373d17438c9..194e0905ff0 100644 --- a/homeassistant/components/watson_tts/tts.py +++ b/homeassistant/components/watson_tts/tts.py @@ -10,7 +10,7 @@ from homeassistant.components.tts import ( PLATFORM_SCHEMA as TTS_PLATFORM_SCHEMA, Provider, ) -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/wirelesstag/__init__.py b/homeassistant/components/wirelesstag/__init__.py index a32e940073b..806e7abed00 100644 --- a/homeassistant/components/wirelesstag/__init__.py +++ b/homeassistant/components/wirelesstag/__init__.py @@ -10,7 +10,7 @@ from wirelesstagpy.exceptions import WirelessTagsException from homeassistant.components import persistent_notification from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import dispatcher_send from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/wirelesstag/binary_sensor.py b/homeassistant/components/wirelesstag/binary_sensor.py index 9e8075dd874..8a0957e16e3 100644 --- a/homeassistant/components/wirelesstag/binary_sensor.py +++ b/homeassistant/components/wirelesstag/binary_sensor.py @@ -10,7 +10,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import CONF_MONITORED_CONDITIONS, STATE_OFF, STATE_ON, Platform from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/wirelesstag/sensor.py b/homeassistant/components/wirelesstag/sensor.py index 7a3cbe5efe2..9b92480ecf9 100644 --- a/homeassistant/components/wirelesstag/sensor.py +++ b/homeassistant/components/wirelesstag/sensor.py @@ -15,7 +15,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_MONITORED_CONDITIONS, Platform from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/wirelesstag/switch.py b/homeassistant/components/wirelesstag/switch.py index cae5d63988c..9fa630d4f55 100644 --- a/homeassistant/components/wirelesstag/switch.py +++ b/homeassistant/components/wirelesstag/switch.py @@ -13,7 +13,7 @@ from homeassistant.components.switch import ( ) from homeassistant.const import CONF_MONITORED_CONDITIONS, Platform from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/workday/binary_sensor.py b/homeassistant/components/workday/binary_sensor.py index 3684208f102..3aad6d805d0 100644 --- a/homeassistant/components/workday/binary_sensor.py +++ b/homeassistant/components/workday/binary_sensor.py @@ -23,7 +23,7 @@ from homeassistant.core import ( SupportsResponse, callback, ) -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import ( AddEntitiesCallback, diff --git a/homeassistant/components/worldclock/sensor.py b/homeassistant/components/worldclock/sensor.py index 89ea14bbbd0..88e5a317cdd 100644 --- a/homeassistant/components/worldclock/sensor.py +++ b/homeassistant/components/worldclock/sensor.py @@ -10,7 +10,7 @@ from homeassistant.const import CONF_NAME, CONF_TIME_ZONE from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .const import CONF_TIME_FORMAT, DOMAIN diff --git a/homeassistant/components/worldtidesinfo/sensor.py b/homeassistant/components/worldtidesinfo/sensor.py index 45f39894abb..1a64954bb4a 100644 --- a/homeassistant/components/worldtidesinfo/sensor.py +++ b/homeassistant/components/worldtidesinfo/sensor.py @@ -15,7 +15,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/worxlandroid/sensor.py b/homeassistant/components/worxlandroid/sensor.py index 50700b78f35..ed3312fc950 100644 --- a/homeassistant/components/worxlandroid/sensor.py +++ b/homeassistant/components/worxlandroid/sensor.py @@ -14,8 +14,8 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_HOST, CONF_PIN, CONF_TIMEOUT, PERCENTAGE from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/wsdot/sensor.py b/homeassistant/components/wsdot/sensor.py index 73714b75c95..8ae93c809f2 100644 --- a/homeassistant/components/wsdot/sensor.py +++ b/homeassistant/components/wsdot/sensor.py @@ -17,7 +17,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import ATTR_NAME, CONF_API_KEY, CONF_ID, CONF_NAME, UnitOfTime from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/x10/light.py b/homeassistant/components/x10/light.py index d98f1f51d54..fbdebe11657 100644 --- a/homeassistant/components/x10/light.py +++ b/homeassistant/components/x10/light.py @@ -16,7 +16,7 @@ from homeassistant.components.light import ( ) from homeassistant.const import CONF_DEVICES, CONF_ID, CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/xiaomi/device_tracker.py b/homeassistant/components/xiaomi/device_tracker.py index 9d4a29d2c78..5968a17f418 100644 --- a/homeassistant/components/xiaomi/device_tracker.py +++ b/homeassistant/components/xiaomi/device_tracker.py @@ -15,7 +15,7 @@ from homeassistant.components.device_tracker import ( ) from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/xiaomi_aqara/__init__.py b/homeassistant/components/xiaomi_aqara/__init__.py index b7f4aa1942e..579994aaf6b 100644 --- a/homeassistant/components/xiaomi_aqara/__init__.py +++ b/homeassistant/components/xiaomi_aqara/__init__.py @@ -17,8 +17,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant, ServiceCall, callback -from homeassistant.helpers import device_registry as dr -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.typing import ConfigType from .const import ( diff --git a/homeassistant/components/xiaomi_miio/device_tracker.py b/homeassistant/components/xiaomi_miio/device_tracker.py index 1dfc5e53410..518003ceedb 100644 --- a/homeassistant/components/xiaomi_miio/device_tracker.py +++ b/homeassistant/components/xiaomi_miio/device_tracker.py @@ -14,7 +14,7 @@ from homeassistant.components.device_tracker import ( ) from homeassistant.const import CONF_HOST, CONF_TOKEN from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/xiaomi_miio/fan.py b/homeassistant/components/xiaomi_miio/fan.py index e1de3f56252..12ed9f7195b 100644 --- a/homeassistant/components/xiaomi_miio/fan.py +++ b/homeassistant/components/xiaomi_miio/fan.py @@ -33,7 +33,7 @@ from homeassistant.components.fan import FanEntity, FanEntityFeature from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_ENTITY_ID, CONF_DEVICE, CONF_MODEL from homeassistant.core import HomeAssistant, ServiceCall, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.percentage import ( percentage_to_ranged_value, diff --git a/homeassistant/components/xiaomi_miio/light.py b/homeassistant/components/xiaomi_miio/light.py index 3f1f8b926b3..c1f778928d9 100644 --- a/homeassistant/components/xiaomi_miio/light.py +++ b/homeassistant/components/xiaomi_miio/light.py @@ -42,7 +42,7 @@ from homeassistant.const import ( CONF_TOKEN, ) from homeassistant.core import HomeAssistant, ServiceCall -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import color as color_util, dt as dt_util diff --git a/homeassistant/components/xiaomi_miio/switch.py b/homeassistant/components/xiaomi_miio/switch.py index 02f4d4e94e5..b4c4300dbe8 100644 --- a/homeassistant/components/xiaomi_miio/switch.py +++ b/homeassistant/components/xiaomi_miio/switch.py @@ -29,7 +29,7 @@ from homeassistant.const import ( EntityCategory, ) from homeassistant.core import HomeAssistant, ServiceCall, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( diff --git a/homeassistant/components/xiaomi_tv/media_player.py b/homeassistant/components/xiaomi_tv/media_player.py index 675c802f79c..19cb4faf2b9 100644 --- a/homeassistant/components/xiaomi_tv/media_player.py +++ b/homeassistant/components/xiaomi_tv/media_player.py @@ -15,7 +15,7 @@ from homeassistant.components.media_player import ( ) from homeassistant.const import CONF_HOST, CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/xmpp/notify.py b/homeassistant/components/xmpp/notify.py index 3fb5dd166a1..968f925d1e8 100644 --- a/homeassistant/components/xmpp/notify.py +++ b/homeassistant/components/xmpp/notify.py @@ -35,8 +35,7 @@ from homeassistant.const import ( CONF_SENDER, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv -import homeassistant.helpers.template as template_helper +from homeassistant.helpers import config_validation as cv, template as template_helper from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/xs1/__init__.py b/homeassistant/components/xs1/__init__.py index 6f7197817d7..15fb9d021c6 100644 --- a/homeassistant/components/xs1/__init__.py +++ b/homeassistant/components/xs1/__init__.py @@ -14,8 +14,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import discovery -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/yale/lock.py b/homeassistant/components/yale/lock.py index b911c92ba0f..7fdad118cde 100644 --- a/homeassistant/components/yale/lock.py +++ b/homeassistant/components/yale/lock.py @@ -16,7 +16,7 @@ from homeassistant.const import ATTR_BATTERY_LEVEL from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from . import YaleConfigEntry, YaleData from .entity import YaleEntity diff --git a/homeassistant/components/yale_smart_alarm/config_flow.py b/homeassistant/components/yale_smart_alarm/config_flow.py index 3ceee367284..1aaad2aa63a 100644 --- a/homeassistant/components/yale_smart_alarm/config_flow.py +++ b/homeassistant/components/yale_smart_alarm/config_flow.py @@ -17,7 +17,7 @@ from homeassistant.config_entries import ( ) from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from .const import ( CONF_AREA_ID, diff --git a/homeassistant/components/yandex_transport/sensor.py b/homeassistant/components/yandex_transport/sensor.py index 95c4785a341..f87d29fffed 100644 --- a/homeassistant/components/yandex_transport/sensor.py +++ b/homeassistant/components/yandex_transport/sensor.py @@ -15,11 +15,11 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_create_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/yandextts/tts.py b/homeassistant/components/yandextts/tts.py index 850afd05150..c7621eb639a 100644 --- a/homeassistant/components/yandextts/tts.py +++ b/homeassistant/components/yandextts/tts.py @@ -13,8 +13,8 @@ from homeassistant.components.tts import ( Provider, ) from homeassistant.const import CONF_API_KEY +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/yeelight/__init__.py b/homeassistant/components/yeelight/__init__.py index 9b71bbc3b16..0b3ceaf2aee 100644 --- a/homeassistant/components/yeelight/__init__.py +++ b/homeassistant/components/yeelight/__init__.py @@ -19,7 +19,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType, VolDictType from .const import ( diff --git a/homeassistant/components/yeelight/config_flow.py b/homeassistant/components/yeelight/config_flow.py index 35892764bcb..15975ba22bd 100644 --- a/homeassistant/components/yeelight/config_flow.py +++ b/homeassistant/components/yeelight/config_flow.py @@ -22,7 +22,7 @@ from homeassistant.config_entries import ( from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_ID, CONF_MODEL, CONF_NAME from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo diff --git a/homeassistant/components/zabbix/__init__.py b/homeassistant/components/zabbix/__init__.py index 05881d649cf..524bac271de 100644 --- a/homeassistant/components/zabbix/__init__.py +++ b/homeassistant/components/zabbix/__init__.py @@ -27,8 +27,11 @@ from homeassistant.const import ( STATE_UNKNOWN, ) from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback -from homeassistant.helpers import event as event_helper, state as state_helper -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import ( + config_validation as cv, + event as event_helper, + state as state_helper, +) from homeassistant.helpers.entityfilter import ( INCLUDE_EXCLUDE_BASE_FILTER_SCHEMA, convert_include_exclude_filter, diff --git a/homeassistant/components/zabbix/sensor.py b/homeassistant/components/zabbix/sensor.py index 7728233ebc0..27d7e71d8d9 100644 --- a/homeassistant/components/zabbix/sensor.py +++ b/homeassistant/components/zabbix/sensor.py @@ -15,7 +15,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateType diff --git a/homeassistant/components/zestimate/sensor.py b/homeassistant/components/zestimate/sensor.py index 12831c96932..ec8850b187d 100644 --- a/homeassistant/components/zestimate/sensor.py +++ b/homeassistant/components/zestimate/sensor.py @@ -15,7 +15,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_API_KEY, CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/zha/__init__.py b/homeassistant/components/zha/__init__.py index 1897b741d87..28f029b62d5 100644 --- a/homeassistant/components/zha/__init__.py +++ b/homeassistant/components/zha/__init__.py @@ -21,8 +21,7 @@ from homeassistant.const import ( ) from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady -from homeassistant.helpers import device_registry as dr -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/zha/websocket_api.py b/homeassistant/components/zha/websocket_api.py index 5ffd7117d93..d562a807a4f 100644 --- a/homeassistant/components/zha/websocket_api.py +++ b/homeassistant/components/zha/websocket_api.py @@ -59,8 +59,7 @@ from homeassistant.components import websocket_api from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_COMMAND, ATTR_ID, ATTR_NAME from homeassistant.core import HomeAssistant, ServiceCall, callback -from homeassistant.helpers import entity_registry as er -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.service import async_register_admin_service from homeassistant.helpers.typing import VolDictType, VolSchemaType diff --git a/homeassistant/components/zhong_hong/climate.py b/homeassistant/components/zhong_hong/climate.py index b5acc230472..af3287d3068 100644 --- a/homeassistant/components/zhong_hong/climate.py +++ b/homeassistant/components/zhong_hong/climate.py @@ -24,7 +24,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, diff --git a/homeassistant/components/ziggo_mediabox_xl/media_player.py b/homeassistant/components/ziggo_mediabox_xl/media_player.py index 6e858b454e9..fe180208801 100644 --- a/homeassistant/components/ziggo_mediabox_xl/media_player.py +++ b/homeassistant/components/ziggo_mediabox_xl/media_player.py @@ -16,7 +16,7 @@ from homeassistant.components.media_player import ( ) from homeassistant.const import CONF_HOST, CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/zoneminder/__init__.py b/homeassistant/components/zoneminder/__init__.py index e87a2b1531d..c2e57b0448b 100644 --- a/homeassistant/components/zoneminder/__init__.py +++ b/homeassistant/components/zoneminder/__init__.py @@ -18,7 +18,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant, ServiceCall -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.discovery import async_load_platform from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/zoneminder/sensor.py b/homeassistant/components/zoneminder/sensor.py index 75769d9fd98..4f79f8876e5 100644 --- a/homeassistant/components/zoneminder/sensor.py +++ b/homeassistant/components/zoneminder/sensor.py @@ -16,7 +16,7 @@ from homeassistant.components.sensor import ( from homeassistant.const import CONF_MONITORED_CONDITIONS from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/zoneminder/switch.py b/homeassistant/components/zoneminder/switch.py index 23adf2f4c88..13da0927196 100644 --- a/homeassistant/components/zoneminder/switch.py +++ b/homeassistant/components/zoneminder/switch.py @@ -16,7 +16,7 @@ from homeassistant.components.switch import ( from homeassistant.const import CONF_COMMAND_OFF, CONF_COMMAND_ON from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/zwave_js/config_validation.py b/homeassistant/components/zwave_js/config_validation.py index 30bc2f16789..2615bfc72b3 100644 --- a/homeassistant/components/zwave_js/config_validation.py +++ b/homeassistant/components/zwave_js/config_validation.py @@ -4,7 +4,7 @@ from typing import Any import voluptuous as vol -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv # Validates that a bitmask is provided in hex form and converts it to decimal # int equivalent since that's what the library uses diff --git a/homeassistant/components/zwave_js/services.py b/homeassistant/components/zwave_js/services.py index fe293fd178b..8389eff8cb2 100644 --- a/homeassistant/components/zwave_js/services.py +++ b/homeassistant/components/zwave_js/services.py @@ -29,8 +29,11 @@ from zwave_js_server.util.node import ( from homeassistant.const import ATTR_AREA_ID, ATTR_DEVICE_ID, ATTR_ENTITY_ID from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import device_registry as dr, entity_registry as er -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import ( + config_validation as cv, + device_registry as dr, + entity_registry as er, +) from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.group import expand_entity_ids From 7249c02655760080c75e687a2ca64e5c719f04fc Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 29 Jan 2025 12:32:18 +0100 Subject: [PATCH 0231/3148] Add backup endpoints to the onboarding integration (#136051) * Add backup endpoints to the onboarding integration * Add backup as after dependency of onboarding * Add test snapshots * Fix stale docstrings * Add utility function for getting the backup manager instance * Return backup_id when uploading backup * Change /api/onboarding/backup/restore to accept a JSON body * Fix with_backup_manager --- homeassistant/components/backup/__init__.py | 1 + homeassistant/components/backup/http.py | 12 +- homeassistant/components/backup/manager.py | 13 +- .../components/onboarding/manifest.json | 2 +- homeassistant/components/onboarding/views.py | 129 +++++++- tests/components/backup/test_http.py | 2 + .../onboarding/snapshots/test_views.ambr | 58 ++++ tests/components/onboarding/test_views.py | 311 +++++++++++++++++- 8 files changed, 514 insertions(+), 14 deletions(-) create mode 100644 tests/components/onboarding/snapshots/test_views.ambr diff --git a/homeassistant/components/backup/__init__.py b/homeassistant/components/backup/__init__.py index 10294f6ff12..ce3fea80f67 100644 --- a/homeassistant/components/backup/__init__.py +++ b/homeassistant/components/backup/__init__.py @@ -53,6 +53,7 @@ __all__ = [ "NewBackup", "RestoreBackupEvent", "WrittenBackup", + "async_get_manager", ] CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) diff --git a/homeassistant/components/backup/http.py b/homeassistant/components/backup/http.py index b909b2728a7..3d3877cc2f7 100644 --- a/homeassistant/components/backup/http.py +++ b/homeassistant/components/backup/http.py @@ -144,13 +144,17 @@ class DownloadBackupView(HomeAssistantView): class UploadBackupView(HomeAssistantView): - """Generate backup view.""" + """Upload backup view.""" url = "/api/backup/upload" name = "api:backup:upload" @require_admin async def post(self, request: Request) -> Response: + """Upload a backup file.""" + return await self._post(request) + + async def _post(self, request: Request) -> Response: """Upload a backup file.""" try: agent_ids = request.query.getall("agent_id") @@ -161,7 +165,9 @@ class UploadBackupView(HomeAssistantView): contents = cast(BodyPartReader, await reader.next()) try: - await manager.async_receive_backup(contents=contents, agent_ids=agent_ids) + backup_id = await manager.async_receive_backup( + contents=contents, agent_ids=agent_ids + ) except OSError as err: return Response( body=f"Can't write backup file: {err}", @@ -175,4 +181,4 @@ class UploadBackupView(HomeAssistantView): except asyncio.CancelledError: return Response(status=HTTPStatus.INTERNAL_SERVER_ERROR) - return Response(status=HTTPStatus.CREATED) + return self.json({"backup_id": backup_id}, status_code=HTTPStatus.CREATED) diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index 4a871cdf73e..19ebb8011ee 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -298,6 +298,7 @@ class BackupManager: # Latest backup event and backup event subscribers self.last_event: ManagerStateEvent = IdleEvent() + self.last_non_idle_event: ManagerStateEvent | None = None self._backup_event_subscriptions: list[Callable[[ManagerStateEvent], None]] = [] async def async_setup(self) -> None: @@ -620,7 +621,7 @@ class BackupManager: *, agent_ids: list[str], contents: aiohttp.BodyPartReader, - ) -> None: + ) -> str: """Receive and store a backup file from upload.""" if self.state is not BackupManagerState.IDLE: raise BackupManagerError(f"Backup manager busy: {self.state}") @@ -632,7 +633,9 @@ class BackupManager: ) ) try: - await self._async_receive_backup(agent_ids=agent_ids, contents=contents) + backup_id = await self._async_receive_backup( + agent_ids=agent_ids, contents=contents + ) except Exception: self.async_on_backup_event( ReceiveBackupEvent( @@ -650,6 +653,7 @@ class BackupManager: state=ReceiveBackupState.COMPLETED, ) ) + return backup_id finally: self.async_on_backup_event(IdleEvent()) @@ -658,7 +662,7 @@ class BackupManager: *, agent_ids: list[str], contents: aiohttp.BodyPartReader, - ) -> None: + ) -> str: """Receive and store a backup file from upload.""" contents.chunk_size = BUF_SIZE self.async_on_backup_event( @@ -687,6 +691,7 @@ class BackupManager: ) await written_backup.release_stream() self.known_backups.add(written_backup.backup, agent_errors) + return written_backup.backup.backup_id async def async_create_backup( self, @@ -1041,6 +1046,8 @@ class BackupManager: if (current_state := self.state) != (new_state := event.manager_state): LOGGER.debug("Backup state: %s -> %s", current_state, new_state) self.last_event = event + if not isinstance(event, IdleEvent): + self.last_non_idle_event = event for subscription in self._backup_event_subscriptions: subscription(event) diff --git a/homeassistant/components/onboarding/manifest.json b/homeassistant/components/onboarding/manifest.json index 918d845993a..8e253d4bff9 100644 --- a/homeassistant/components/onboarding/manifest.json +++ b/homeassistant/components/onboarding/manifest.json @@ -1,7 +1,7 @@ { "domain": "onboarding", "name": "Home Assistant Onboarding", - "after_dependencies": ["hassio"], + "after_dependencies": ["backup", "hassio"], "codeowners": ["@home-assistant/core"], "dependencies": ["auth", "http", "person"], "documentation": "https://www.home-assistant.io/integrations/onboarding", diff --git a/homeassistant/components/onboarding/views.py b/homeassistant/components/onboarding/views.py index b33440a9eb7..edf0b615779 100644 --- a/homeassistant/components/onboarding/views.py +++ b/homeassistant/components/onboarding/views.py @@ -3,9 +3,10 @@ from __future__ import annotations import asyncio -from collections.abc import Coroutine +from collections.abc import Callable, Coroutine +from functools import wraps from http import HTTPStatus -from typing import TYPE_CHECKING, Any, cast +from typing import TYPE_CHECKING, Any, Concatenate, cast from aiohttp import web from aiohttp.web_exceptions import HTTPUnauthorized @@ -15,10 +16,18 @@ from homeassistant.auth.const import GROUP_ID_ADMIN from homeassistant.auth.providers.homeassistant import HassAuthProvider from homeassistant.components import person from homeassistant.components.auth import indieauth +from homeassistant.components.backup import ( + BackupManager, + Folder, + IncorrectPasswordError, + async_get_manager as async_get_backup_manager, + http as backup_http, +) from homeassistant.components.http import KEY_HASS, KEY_HASS_REFRESH_TOKEN_ID from homeassistant.components.http.data_validator import RequestDataValidator from homeassistant.components.http.view import HomeAssistantView from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import area_registry as ar from homeassistant.helpers.hassio import is_hassio from homeassistant.helpers.system_info import async_get_system_info @@ -50,6 +59,9 @@ async def async_setup( hass.http.register_view(CoreConfigOnboardingView(data, store)) hass.http.register_view(IntegrationOnboardingView(data, store)) hass.http.register_view(AnalyticsOnboardingView(data, store)) + hass.http.register_view(BackupInfoView(data)) + hass.http.register_view(RestoreBackupView(data)) + hass.http.register_view(UploadBackupView(data)) class OnboardingView(HomeAssistantView): @@ -312,6 +324,119 @@ class AnalyticsOnboardingView(_BaseOnboardingView): return self.json({}) +class BackupOnboardingView(HomeAssistantView): + """Backup onboarding view.""" + + requires_auth = False + + def __init__(self, data: OnboardingStoreData) -> None: + """Initialize the view.""" + self._data = data + + +def with_backup_manager[_ViewT: BackupOnboardingView, **_P]( + func: Callable[ + Concatenate[_ViewT, BackupManager, web.Request, _P], + Coroutine[Any, Any, web.Response], + ], +) -> Callable[Concatenate[_ViewT, web.Request, _P], Coroutine[Any, Any, web.Response]]: + """Home Assistant API decorator to check onboarding and inject manager.""" + + @wraps(func) + async def with_backup( + self: _ViewT, + request: web.Request, + *args: _P.args, + **kwargs: _P.kwargs, + ) -> web.Response: + """Check admin and call function.""" + if self._data["done"]: + raise HTTPUnauthorized + + try: + manager = async_get_backup_manager(request.app[KEY_HASS]) + except HomeAssistantError: + return self.json( + {"error": "backup_disabled"}, + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + ) + + return await func(self, manager, request, *args, **kwargs) + + return with_backup + + +class BackupInfoView(BackupOnboardingView): + """Get backup info view.""" + + url = "/api/onboarding/backup/info" + name = "api:onboarding:backup:info" + + @with_backup_manager + async def get(self, manager: BackupManager, request: web.Request) -> web.Response: + """Return backup info.""" + backups, _ = await manager.async_get_backups() + return self.json( + { + "backups": [backup.as_frontend_json() for backup in backups.values()], + "state": manager.state, + "last_non_idle_event": manager.last_non_idle_event, + } + ) + + +class RestoreBackupView(BackupOnboardingView): + """Restore backup view.""" + + url = "/api/onboarding/backup/restore" + name = "api:onboarding:backup:restore" + + @RequestDataValidator( + vol.Schema( + { + vol.Required("backup_id"): str, + vol.Required("agent_id"): str, + vol.Optional("password"): str, + vol.Optional("restore_addons"): [str], + vol.Optional("restore_database", default=True): bool, + vol.Optional("restore_folders"): [vol.Coerce(Folder)], + } + ) + ) + @with_backup_manager + async def post( + self, manager: BackupManager, request: web.Request, data: dict[str, Any] + ) -> web.Response: + """Restore a backup.""" + try: + await manager.async_restore_backup( + data["backup_id"], + agent_id=data["agent_id"], + password=data.get("password"), + restore_addons=data.get("restore_addons"), + restore_database=data["restore_database"], + restore_folders=data.get("restore_folders"), + restore_homeassistant=True, + ) + except IncorrectPasswordError: + return self.json( + {"message": "incorrect_password"}, status_code=HTTPStatus.BAD_REQUEST + ) + return web.Response(status=HTTPStatus.OK) + + +class UploadBackupView(BackupOnboardingView, backup_http.UploadBackupView): + """Upload backup view.""" + + url = "/api/onboarding/backup/upload" + name = "api:onboarding:backup:upload" + + @with_backup_manager + async def post(self, manager: BackupManager, request: web.Request) -> web.Response: + """Upload a backup file.""" + return await self._post(request) + + @callback def _async_get_hass_provider(hass: HomeAssistant) -> HassAuthProvider: """Get the Home Assistant auth provider.""" diff --git a/tests/components/backup/test_http.py b/tests/components/backup/test_http.py index b7b86cc1d45..ee6803655d5 100644 --- a/tests/components/backup/test_http.py +++ b/tests/components/backup/test_http.py @@ -233,12 +233,14 @@ async def test_uploading_a_backup_file( with patch( "homeassistant.components.backup.manager.BackupManager.async_receive_backup", + return_value=TEST_BACKUP_ABC123.backup_id, ) as async_receive_backup_mock: resp = await client.post( "/api/backup/upload?agent_id=backup.local", data={"file": StringIO("test")}, ) assert resp.status == 201 + assert await resp.json() == {"backup_id": TEST_BACKUP_ABC123.backup_id} assert async_receive_backup_mock.called diff --git a/tests/components/onboarding/snapshots/test_views.ambr b/tests/components/onboarding/snapshots/test_views.ambr new file mode 100644 index 00000000000..90428055823 --- /dev/null +++ b/tests/components/onboarding/snapshots/test_views.ambr @@ -0,0 +1,58 @@ +# serializer version: 1 +# name: test_onboarding_backup_info + dict({ + 'backups': list([ + dict({ + 'addons': list([ + dict({ + 'name': 'Test', + 'slug': 'test', + 'version': '1.0.0', + }), + ]), + 'agent_ids': list([ + 'backup.local', + ]), + 'backup_id': 'abc123', + 'database_included': True, + 'date': '1970-01-01T00:00:00.000Z', + 'failed_agent_ids': list([ + ]), + 'folders': list([ + 'media', + 'share', + ]), + 'homeassistant_included': True, + 'homeassistant_version': '2024.12.0', + 'name': 'Test', + 'protected': False, + 'size': 0, + 'with_automatic_settings': True, + }), + dict({ + 'addons': list([ + ]), + 'agent_ids': list([ + 'test.remote', + ]), + 'backup_id': 'def456', + 'database_included': False, + 'date': '1980-01-01T00:00:00.000Z', + 'failed_agent_ids': list([ + ]), + 'folders': list([ + 'media', + 'share', + ]), + 'homeassistant_included': True, + 'homeassistant_version': '2024.12.0', + 'name': 'Test 2', + 'protected': False, + 'size': 1, + 'with_automatic_settings': None, + }), + ]), + 'last_non_idle_event': None, + 'state': 'idle', + }) +# --- diff --git a/tests/components/onboarding/test_views.py b/tests/components/onboarding/test_views.py index 35f6b7d739c..683d2c370f2 100644 --- a/tests/components/onboarding/test_views.py +++ b/tests/components/onboarding/test_views.py @@ -3,13 +3,15 @@ import asyncio from collections.abc import AsyncGenerator from http import HTTPStatus +from io import StringIO import os from typing import Any -from unittest.mock import AsyncMock, Mock, patch +from unittest.mock import ANY, AsyncMock, Mock, patch import pytest +from syrupy import SnapshotAssertion -from homeassistant.components import onboarding +from homeassistant.components import backup, onboarding from homeassistant.components.onboarding import const, views from homeassistant.core import HomeAssistant from homeassistant.helpers import area_registry as ar @@ -649,12 +651,28 @@ async def test_onboarding_installation_type( assert resp_content["installation_type"] == "Home Assistant Core" -async def test_onboarding_installation_type_after_done( +@pytest.mark.parametrize( + ("method", "view", "kwargs"), + [ + ("get", "installation_type", {}), + ("get", "backup/info", {}), + ( + "post", + "backup/restore", + {"json": {"backup_id": "abc123", "agent_id": "test"}}, + ), + ("post", "backup/upload", {}), + ], +) +async def test_onboarding_view_after_done( hass: HomeAssistant, hass_storage: dict[str, Any], hass_client: ClientSessionGenerator, + method: str, + view: str, + kwargs: dict[str, Any], ) -> None: - """Test raising for installation type after onboarding.""" + """Test raising after onboarding.""" mock_storage(hass_storage, {"done": [const.STEP_USER]}) assert await async_setup_component(hass, "onboarding", {}) @@ -662,7 +680,7 @@ async def test_onboarding_installation_type_after_done( client = await hass_client() - resp = await client.get("/api/onboarding/installation_type") + resp = await client.request(method, f"/api/onboarding/{view}", **kwargs) assert resp.status == 401 @@ -726,3 +744,286 @@ async def test_complete_onboarding( listener_3 = Mock() onboarding.async_add_listener(hass, listener_3) listener_3.assert_called_once_with() + + +@pytest.mark.parametrize( + ("method", "view", "kwargs"), + [ + ("get", "backup/info", {}), + ( + "post", + "backup/restore", + {"json": {"backup_id": "abc123", "agent_id": "test"}}, + ), + ("post", "backup/upload", {}), + ], +) +async def test_onboarding_backup_view_without_backup( + hass: HomeAssistant, + hass_storage: dict[str, Any], + hass_client: ClientSessionGenerator, + method: str, + view: str, + kwargs: dict[str, Any], +) -> None: + """Test interacting with backup wievs when backup integration is missing.""" + mock_storage(hass_storage, {"done": []}) + + assert await async_setup_component(hass, "onboarding", {}) + await hass.async_block_till_done() + + client = await hass_client() + + resp = await client.request(method, f"/api/onboarding/{view}", **kwargs) + + assert resp.status == 500 + assert await resp.json() == {"error": "backup_disabled"} + + +async def test_onboarding_backup_info( + hass: HomeAssistant, + hass_storage: dict[str, Any], + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, +) -> None: + """Test returning installation type during onboarding.""" + mock_storage(hass_storage, {"done": []}) + + assert await async_setup_component(hass, "onboarding", {}) + assert await async_setup_component(hass, "backup", {}) + await hass.async_block_till_done() + + client = await hass_client() + + backups = { + "abc123": backup.ManagerBackup( + addons=[backup.AddonInfo(name="Test", slug="test", version="1.0.0")], + backup_id="abc123", + date="1970-01-01T00:00:00.000Z", + database_included=True, + extra_metadata={"instance_id": "abc123", "with_automatic_settings": True}, + folders=[backup.Folder.MEDIA, backup.Folder.SHARE], + homeassistant_included=True, + homeassistant_version="2024.12.0", + name="Test", + protected=False, + size=0, + agent_ids=["backup.local"], + failed_agent_ids=[], + with_automatic_settings=True, + ), + "def456": backup.ManagerBackup( + addons=[], + backup_id="def456", + date="1980-01-01T00:00:00.000Z", + database_included=False, + extra_metadata={ + "instance_id": "unknown_uuid", + "with_automatic_settings": True, + }, + folders=[backup.Folder.MEDIA, backup.Folder.SHARE], + homeassistant_included=True, + homeassistant_version="2024.12.0", + name="Test 2", + protected=False, + size=1, + agent_ids=["test.remote"], + failed_agent_ids=[], + with_automatic_settings=None, + ), + } + + with patch( + "homeassistant.components.backup.manager.BackupManager.async_get_backups", + return_value=(backups, {}), + ): + resp = await client.get("/api/onboarding/backup/info") + + assert resp.status == 200 + assert await resp.json() == snapshot + + +@pytest.mark.parametrize( + ("params", "expected_kwargs"), + [ + ( + {"backup_id": "abc123", "agent_id": "backup.local"}, + { + "agent_id": "backup.local", + "password": None, + "restore_addons": None, + "restore_database": True, + "restore_folders": None, + "restore_homeassistant": True, + }, + ), + ( + { + "backup_id": "abc123", + "agent_id": "backup.local", + "password": "hunter2", + "restore_addons": ["addon_1"], + "restore_database": True, + "restore_folders": ["media"], + }, + { + "agent_id": "backup.local", + "password": "hunter2", + "restore_addons": ["addon_1"], + "restore_database": True, + "restore_folders": [backup.Folder.MEDIA], + "restore_homeassistant": True, + }, + ), + ( + { + "backup_id": "abc123", + "agent_id": "backup.local", + "password": "hunter2", + "restore_addons": ["addon_1", "addon_2"], + "restore_database": False, + "restore_folders": ["media", "share"], + }, + { + "agent_id": "backup.local", + "password": "hunter2", + "restore_addons": ["addon_1", "addon_2"], + "restore_database": False, + "restore_folders": [backup.Folder.MEDIA, backup.Folder.SHARE], + "restore_homeassistant": True, + }, + ), + ], +) +async def test_onboarding_backup_restore( + hass: HomeAssistant, + hass_storage: dict[str, Any], + hass_client: ClientSessionGenerator, + params: dict[str, Any], + expected_kwargs: dict[str, Any], +) -> None: + """Test returning installation type during onboarding.""" + mock_storage(hass_storage, {"done": []}) + + assert await async_setup_component(hass, "onboarding", {}) + assert await async_setup_component(hass, "backup", {}) + await hass.async_block_till_done() + + client = await hass_client() + + with patch( + "homeassistant.components.backup.manager.BackupManager.async_restore_backup", + ) as mock_restore: + resp = await client.post("/api/onboarding/backup/restore", json=params) + assert resp.status == 200 + mock_restore.assert_called_once_with("abc123", **expected_kwargs) + + +@pytest.mark.parametrize( + ("params", "restore_error", "expected_status", "expected_message", "restore_calls"), + [ + # Missing agent_id + ( + {"backup_id": "abc123"}, + None, + 400, + "Message format incorrect: required key not provided @ data['agent_id']", + 0, + ), + # Missing backup_id + ( + {"agent_id": "backup.local"}, + None, + 400, + "Message format incorrect: required key not provided @ data['backup_id']", + 0, + ), + # Invalid restore_database + ( + { + "backup_id": "abc123", + "agent_id": "backup.local", + "restore_database": "yes_please", + }, + None, + 400, + "Message format incorrect: expected bool for dictionary value @ data['restore_database']", + 0, + ), + # Invalid folder + ( + { + "backup_id": "abc123", + "agent_id": "backup.local", + "restore_folders": ["invalid"], + }, + None, + 400, + "Message format incorrect: expected Folder or one of 'share', 'addons/local', 'ssl', 'media' @ data['restore_folders'][0]", + 0, + ), + # Wrong password + ( + {"backup_id": "abc123", "agent_id": "backup.local"}, + backup.IncorrectPasswordError, + 400, + "incorrect_password", + 1, + ), + ], +) +async def test_onboarding_backup_restore_error( + hass: HomeAssistant, + hass_storage: dict[str, Any], + hass_client: ClientSessionGenerator, + params: dict[str, Any], + restore_error: Exception | None, + expected_status: int, + expected_message: str, + restore_calls: int, +) -> None: + """Test returning installation type during onboarding.""" + mock_storage(hass_storage, {"done": []}) + + assert await async_setup_component(hass, "onboarding", {}) + assert await async_setup_component(hass, "backup", {}) + await hass.async_block_till_done() + + client = await hass_client() + + with patch( + "homeassistant.components.backup.manager.BackupManager.async_restore_backup", + side_effect=restore_error, + ) as mock_restore: + resp = await client.post("/api/onboarding/backup/restore", json=params) + + assert resp.status == expected_status + assert await resp.json() == {"message": expected_message} + assert len(mock_restore.mock_calls) == restore_calls + + +async def test_onboarding_backup_upload( + hass: HomeAssistant, + hass_storage: dict[str, Any], + hass_client: ClientSessionGenerator, +) -> None: + """Test returning installation type during onboarding.""" + mock_storage(hass_storage, {"done": []}) + + assert await async_setup_component(hass, "onboarding", {}) + assert await async_setup_component(hass, "backup", {}) + await hass.async_block_till_done() + + client = await hass_client() + + with patch( + "homeassistant.components.backup.manager.BackupManager.async_receive_backup", + return_value="abc123", + ) as mock_receive: + resp = await client.post( + "/api/onboarding/backup/upload?agent_id=backup.local", + data={"file": StringIO("test")}, + ) + assert resp.status == 201 + assert await resp.json() == {"backup_id": "abc123"} + mock_receive.assert_called_once_with(agent_ids=["backup.local"], contents=ANY) From bc2976904e23e9997d87c6505af8f831863c7b42 Mon Sep 17 00:00:00 2001 From: Duco Sebel <74970928+DCSBL@users.noreply.github.com> Date: Wed, 29 Jan 2025 12:54:08 +0100 Subject: [PATCH 0232/3148] Rename HomeWizard last restart sensor to Uptime (#136829) --- homeassistant/components/homewizard/sensor.py | 4 +- .../components/homewizard/strings.json | 4 +- .../homewizard/snapshots/test_sensor.ambr | 166 +++++++++--------- tests/components/homewizard/test_sensor.py | 18 +- 4 files changed, 96 insertions(+), 96 deletions(-) diff --git a/homeassistant/components/homewizard/sensor.py b/homeassistant/components/homewizard/sensor.py index f47fcfc7ca7..582c65f2838 100644 --- a/homeassistant/components/homewizard/sensor.py +++ b/homeassistant/components/homewizard/sensor.py @@ -623,8 +623,8 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( value_fn=lambda data: data.measurement.cycles, ), HomeWizardSensorEntityDescription( - key="last_restart", - translation_key="last_restart", + key="uptime", + translation_key="uptime", device_class=SensorDeviceClass.TIMESTAMP, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, diff --git a/homeassistant/components/homewizard/strings.json b/homeassistant/components/homewizard/strings.json index 645c4292ae1..806dbf6e083 100644 --- a/homeassistant/components/homewizard/strings.json +++ b/homeassistant/components/homewizard/strings.json @@ -138,8 +138,8 @@ "state_of_charge_pct": { "name": "State of charge" }, - "last_restart": { - "name": "Last restart" + "uptime": { + "name": "Uptime" } }, "switch": { diff --git a/tests/components/homewizard/snapshots/test_sensor.ambr b/tests/components/homewizard/snapshots/test_sensor.ambr index 622c6d8a852..692383b4794 100644 --- a/tests/components/homewizard/snapshots/test_sensor.ambr +++ b/tests/components/homewizard/snapshots/test_sensor.ambr @@ -432,89 +432,6 @@ 'state': '50.0', }) # --- -# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_last_restart:device-registry] - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - tuple( - 'mac', - '5c:2f:af:ab:cd:ef', - ), - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'homewizard', - '5c2fafabcdef', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'HomeWizard', - 'model': 'Plug-In Battery', - 'model_id': 'HWE-BAT', - 'name': 'Device', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': '1.00', - 'via_device_id': None, - }) -# --- -# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_last_restart:entity-registry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': , - 'entity_id': 'sensor.device_last_restart', - '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 restart', - 'platform': 'homewizard', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'last_restart', - 'unique_id': 'HWE-P1_5c2fafabcdef_last_restart', - 'unit_of_measurement': None, - }) -# --- -# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_last_restart:state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'timestamp', - 'friendly_name': 'Device Last restart', - }), - 'context': , - 'entity_id': 'sensor.device_last_restart', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '2025-01-28T21:39:04+00:00', - }) -# --- # name: test_sensors[HWE-BAT-entity_ids10][sensor.device_power:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -695,6 +612,89 @@ 'state': '50.0', }) # --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_uptime:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '5c:2f:af:ab:cd:ef', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '5c2fafabcdef', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'HomeWizard', + 'model': 'Plug-In Battery', + 'model_id': 'HWE-BAT', + 'name': 'Device', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '1.00', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_uptime:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.device_uptime', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Uptime', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'uptime', + 'unique_id': 'HWE-P1_5c2fafabcdef_uptime', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_uptime:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Device Uptime', + }), + 'context': , + 'entity_id': 'sensor.device_uptime', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2025-01-28T21:39:04+00:00', + }) +# --- # name: test_sensors[HWE-BAT-entity_ids10][sensor.device_voltage:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, diff --git a/tests/components/homewizard/test_sensor.py b/tests/components/homewizard/test_sensor.py index e4498d2d47a..94a59551eb4 100644 --- a/tests/components/homewizard/test_sensor.py +++ b/tests/components/homewizard/test_sensor.py @@ -302,7 +302,7 @@ pytestmark = [ "sensor.device_frequency", "sensor.device_power", "sensor.device_state_of_charge", - "sensor.device_last_restart", + "sensor.device_uptime", "sensor.device_voltage", ], ), @@ -451,7 +451,7 @@ async def test_sensors( [ "sensor.device_current", "sensor.device_frequency", - "sensor.device_last_restart", + "sensor.device_uptime", "sensor.device_voltage", ], ), @@ -549,7 +549,7 @@ async def test_external_sensors_unreachable( "sensor.device_state_of_charge", "sensor.device_tariff", "sensor.device_total_water_usage", - "sensor.device_last_restart", + "sensor.device_uptime", "sensor.device_voltage_phase_1", "sensor.device_voltage_phase_2", "sensor.device_voltage_phase_3", @@ -599,7 +599,7 @@ async def test_external_sensors_unreachable( "sensor.device_state_of_charge", "sensor.device_tariff", "sensor.device_total_water_usage", - "sensor.device_last_restart", + "sensor.device_uptime", "sensor.device_voltage_phase_1", "sensor.device_voltage_phase_2", "sensor.device_voltage_phase_3", @@ -656,7 +656,7 @@ async def test_external_sensors_unreachable( "sensor.device_smart_meter_model", "sensor.device_state_of_charge", "sensor.device_tariff", - "sensor.device_last_restart", + "sensor.device_uptime", "sensor.device_voltage_phase_1", "sensor.device_voltage_phase_2", "sensor.device_voltage_phase_3", @@ -707,7 +707,7 @@ async def test_external_sensors_unreachable( "sensor.device_state_of_charge", "sensor.device_tariff", "sensor.device_total_water_usage", - "sensor.device_last_restart", + "sensor.device_uptime", "sensor.device_voltage_phase_1", "sensor.device_voltage_phase_2", "sensor.device_voltage_phase_3", @@ -746,7 +746,7 @@ async def test_external_sensors_unreachable( "sensor.device_state_of_charge", "sensor.device_tariff", "sensor.device_total_water_usage", - "sensor.device_last_restart", + "sensor.device_uptime", "sensor.device_voltage_phase_1", "sensor.device_voltage_phase_2", "sensor.device_voltage_phase_3", @@ -798,7 +798,7 @@ async def test_external_sensors_unreachable( "sensor.device_state_of_charge", "sensor.device_tariff", "sensor.device_total_water_usage", - "sensor.device_last_restart", + "sensor.device_uptime", "sensor.device_voltage_phase_1", "sensor.device_voltage_phase_2", "sensor.device_voltage_phase_3", @@ -837,7 +837,7 @@ async def test_external_sensors_unreachable( "sensor.device_state_of_charge", "sensor.device_tariff", "sensor.device_total_water_usage", - "sensor.device_last_restart", + "sensor.device_uptime", "sensor.device_voltage_phase_1", "sensor.device_voltage_phase_2", "sensor.device_voltage_phase_3", From c974251faa76add41a88ffa64f85e4bcf97b5269 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Wed, 29 Jan 2025 12:55:59 +0100 Subject: [PATCH 0233/3148] Fix command latency in AVM Fritz!SmartHome (#136739) --- homeassistant/components/fritzbox/climate.py | 2 +- homeassistant/components/fritzbox/cover.py | 8 +++---- homeassistant/components/fritzbox/light.py | 15 +++++++------ homeassistant/components/fritzbox/switch.py | 4 ++-- tests/components/fritzbox/test_climate.py | 22 ++++++++++---------- tests/components/fritzbox/test_cover.py | 2 +- tests/components/fritzbox/test_light.py | 12 +++++------ 7 files changed, 34 insertions(+), 31 deletions(-) diff --git a/homeassistant/components/fritzbox/climate.py b/homeassistant/components/fritzbox/climate.py index d5a81fdef1a..87a87ac691f 100644 --- a/homeassistant/components/fritzbox/climate.py +++ b/homeassistant/components/fritzbox/climate.py @@ -141,7 +141,7 @@ class FritzboxThermostat(FritzBoxDeviceEntity, ClimateEntity): await self.async_set_hvac_mode(hvac_mode) elif target_temp is not None: await self.hass.async_add_executor_job( - self.data.set_target_temperature, target_temp + self.data.set_target_temperature, target_temp, True ) else: return diff --git a/homeassistant/components/fritzbox/cover.py b/homeassistant/components/fritzbox/cover.py index de87d6f8852..070bb868298 100644 --- a/homeassistant/components/fritzbox/cover.py +++ b/homeassistant/components/fritzbox/cover.py @@ -71,21 +71,21 @@ class FritzboxCover(FritzBoxDeviceEntity, CoverEntity): async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" - await self.hass.async_add_executor_job(self.data.set_blind_open) + await self.hass.async_add_executor_job(self.data.set_blind_open, True) await self.coordinator.async_refresh() async def async_close_cover(self, **kwargs: Any) -> None: """Close the cover.""" - await self.hass.async_add_executor_job(self.data.set_blind_close) + await self.hass.async_add_executor_job(self.data.set_blind_close, True) await self.coordinator.async_refresh() async def async_set_cover_position(self, **kwargs: Any) -> None: """Move the cover to a specific position.""" await self.hass.async_add_executor_job( - self.data.set_level_percentage, 100 - kwargs[ATTR_POSITION] + self.data.set_level_percentage, 100 - kwargs[ATTR_POSITION], True ) async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the cover.""" - await self.hass.async_add_executor_job(self.data.set_blind_stop) + await self.hass.async_add_executor_job(self.data.set_blind_stop, True) await self.coordinator.async_refresh() diff --git a/homeassistant/components/fritzbox/light.py b/homeassistant/components/fritzbox/light.py index 36cb7dc8cff..f6a1ba4cc94 100644 --- a/homeassistant/components/fritzbox/light.py +++ b/homeassistant/components/fritzbox/light.py @@ -122,7 +122,7 @@ class FritzboxLight(FritzBoxDeviceEntity, LightEntity): """Turn the light on.""" if kwargs.get(ATTR_BRIGHTNESS) is not None: level = kwargs[ATTR_BRIGHTNESS] - await self.hass.async_add_executor_job(self.data.set_level, level) + await self.hass.async_add_executor_job(self.data.set_level, level, True) if kwargs.get(ATTR_HS_COLOR) is not None: # Try setunmappedcolor first. This allows free color selection, # but we don't know if its supported by all devices. @@ -133,7 +133,10 @@ class FritzboxLight(FritzBoxDeviceEntity, LightEntity): cast(float, kwargs[ATTR_HS_COLOR][1]) * 255.0 / 100.0 ) await self.hass.async_add_executor_job( - self.data.set_unmapped_color, (unmapped_hue, unmapped_saturation) + self.data.set_unmapped_color, + (unmapped_hue, unmapped_saturation), + 0, + True, ) # This will raise 400 BAD REQUEST if the setunmappedcolor is not available except HTTPError as err: @@ -152,18 +155,18 @@ class FritzboxLight(FritzBoxDeviceEntity, LightEntity): key=lambda x: abs(x - unmapped_saturation), ) await self.hass.async_add_executor_job( - self.data.set_color, (hue, saturation) + self.data.set_color, (hue, saturation), 0, True ) if kwargs.get(ATTR_COLOR_TEMP_KELVIN) is not None: await self.hass.async_add_executor_job( - self.data.set_color_temp, kwargs[ATTR_COLOR_TEMP_KELVIN] + self.data.set_color_temp, kwargs[ATTR_COLOR_TEMP_KELVIN], 0, True ) - await self.hass.async_add_executor_job(self.data.set_state_on) + await self.hass.async_add_executor_job(self.data.set_state_on, True) await self.coordinator.async_refresh() async def async_turn_off(self, **kwargs: Any) -> None: """Turn the light off.""" - await self.hass.async_add_executor_job(self.data.set_state_off) + await self.hass.async_add_executor_job(self.data.set_state_off, True) await self.coordinator.async_refresh() diff --git a/homeassistant/components/fritzbox/switch.py b/homeassistant/components/fritzbox/switch.py index 18b676d449e..d83793c77dc 100644 --- a/homeassistant/components/fritzbox/switch.py +++ b/homeassistant/components/fritzbox/switch.py @@ -51,13 +51,13 @@ class FritzboxSwitch(FritzBoxDeviceEntity, SwitchEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Turn the switch on.""" self.check_lock_state() - await self.hass.async_add_executor_job(self.data.set_switch_state_on) + await self.hass.async_add_executor_job(self.data.set_switch_state_on, True) await self.coordinator.async_refresh() async def async_turn_off(self, **kwargs: Any) -> None: """Turn the switch off.""" self.check_lock_state() - await self.hass.async_add_executor_job(self.data.set_switch_state_off) + await self.hass.async_add_executor_job(self.data.set_switch_state_off, True) await self.coordinator.async_refresh() def check_lock_state(self) -> None: diff --git a/tests/components/fritzbox/test_climate.py b/tests/components/fritzbox/test_climate.py index 0fb5f5038c3..87e6d36e3b6 100644 --- a/tests/components/fritzbox/test_climate.py +++ b/tests/components/fritzbox/test_climate.py @@ -273,20 +273,20 @@ async def test_update_error(hass: HomeAssistant, fritz: Mock) -> None: @pytest.mark.parametrize( ("service_data", "expected_call_args"), [ - ({ATTR_TEMPERATURE: 23}, [call(23)]), + ({ATTR_TEMPERATURE: 23}, [call(23, True)]), ( { ATTR_HVAC_MODE: HVACMode.OFF, ATTR_TEMPERATURE: 23, }, - [call(0)], + [call(0, True)], ), ( { ATTR_HVAC_MODE: HVACMode.HEAT, ATTR_TEMPERATURE: 23, }, - [call(23)], + [call(23, True)], ), ], ) @@ -316,14 +316,14 @@ async def test_set_temperature( ("service_data", "target_temperature", "current_preset", "expected_call_args"), [ # mode off always sets target temperature to 0 - ({ATTR_HVAC_MODE: HVACMode.OFF}, 22, PRESET_COMFORT, [call(0)]), - ({ATTR_HVAC_MODE: HVACMode.OFF}, 16, PRESET_ECO, [call(0)]), - ({ATTR_HVAC_MODE: HVACMode.OFF}, 16, None, [call(0)]), + ({ATTR_HVAC_MODE: HVACMode.OFF}, 22, PRESET_COMFORT, [call(0, True)]), + ({ATTR_HVAC_MODE: HVACMode.OFF}, 16, PRESET_ECO, [call(0, True)]), + ({ATTR_HVAC_MODE: HVACMode.OFF}, 16, None, [call(0, True)]), # mode heat sets target temperature based on current scheduled preset, # when not already in mode heat - ({ATTR_HVAC_MODE: HVACMode.HEAT}, 0.0, PRESET_COMFORT, [call(22)]), - ({ATTR_HVAC_MODE: HVACMode.HEAT}, 0.0, PRESET_ECO, [call(16)]), - ({ATTR_HVAC_MODE: HVACMode.HEAT}, 0.0, None, [call(22)]), + ({ATTR_HVAC_MODE: HVACMode.HEAT}, 0.0, PRESET_COMFORT, [call(22, True)]), + ({ATTR_HVAC_MODE: HVACMode.HEAT}, 0.0, PRESET_ECO, [call(16, True)]), + ({ATTR_HVAC_MODE: HVACMode.HEAT}, 0.0, None, [call(22, True)]), # mode heat does not set target temperature, when already in mode heat ({ATTR_HVAC_MODE: HVACMode.HEAT}, 16, PRESET_COMFORT, []), ({ATTR_HVAC_MODE: HVACMode.HEAT}, 16, PRESET_ECO, []), @@ -380,7 +380,7 @@ async def test_set_preset_mode_comfort(hass: HomeAssistant, fritz: Mock) -> None {ATTR_ENTITY_ID: ENTITY_ID, ATTR_PRESET_MODE: PRESET_COMFORT}, True, ) - assert device.set_target_temperature.call_args_list == [call(22)] + assert device.set_target_temperature.call_args_list == [call(22, True)] async def test_set_preset_mode_eco(hass: HomeAssistant, fritz: Mock) -> None: @@ -396,7 +396,7 @@ async def test_set_preset_mode_eco(hass: HomeAssistant, fritz: Mock) -> None: {ATTR_ENTITY_ID: ENTITY_ID, ATTR_PRESET_MODE: PRESET_ECO}, True, ) - assert device.set_target_temperature.call_args_list == [call(16)] + assert device.set_target_temperature.call_args_list == [call(16, True)] async def test_preset_mode_update(hass: HomeAssistant, fritz: Mock) -> None: diff --git a/tests/components/fritzbox/test_cover.py b/tests/components/fritzbox/test_cover.py index 82723b083ae..535306e4ef2 100644 --- a/tests/components/fritzbox/test_cover.py +++ b/tests/components/fritzbox/test_cover.py @@ -99,7 +99,7 @@ async def test_set_position_cover(hass: HomeAssistant, fritz: Mock) -> None: {ATTR_ENTITY_ID: ENTITY_ID, ATTR_POSITION: 50}, True, ) - assert device.set_level_percentage.call_args_list == [call(50)] + assert device.set_level_percentage.call_args_list == [call(50, True)] async def test_stop_cover(hass: HomeAssistant, fritz: Mock) -> None: diff --git a/tests/components/fritzbox/test_light.py b/tests/components/fritzbox/test_light.py index 071642fb358..47209075a86 100644 --- a/tests/components/fritzbox/test_light.py +++ b/tests/components/fritzbox/test_light.py @@ -155,8 +155,8 @@ async def test_turn_on(hass: HomeAssistant, fritz: Mock) -> None: assert device.set_state_on.call_count == 1 assert device.set_level.call_count == 1 assert device.set_color_temp.call_count == 1 - assert device.set_color_temp.call_args_list == [call(3000)] - assert device.set_level.call_args_list == [call(100)] + assert device.set_color_temp.call_args_list == [call(3000, 0, True)] + assert device.set_level.call_args_list == [call(100, True)] async def test_turn_on_color(hass: HomeAssistant, fritz: Mock) -> None: @@ -178,9 +178,9 @@ async def test_turn_on_color(hass: HomeAssistant, fritz: Mock) -> None: assert device.set_state_on.call_count == 1 assert device.set_level.call_count == 1 assert device.set_unmapped_color.call_count == 1 - assert device.set_level.call_args_list == [call(100)] + assert device.set_level.call_args_list == [call(100, True)] assert device.set_unmapped_color.call_args_list == [ - call((100, round(70 * 255.0 / 100.0))) + call((100, round(70 * 255.0 / 100.0)), 0, True) ] @@ -212,8 +212,8 @@ async def test_turn_on_color_unsupported_api_method( assert device.set_state_on.call_count == 1 assert device.set_level.call_count == 1 assert device.set_color.call_count == 1 - assert device.set_level.call_args_list == [call(100)] - assert device.set_color.call_args_list == [call((100, 70))] + assert device.set_level.call_args_list == [call(100, True)] + assert device.set_color.call_args_list == [call((100, 70), 0, True)] # test for unknown error error.response.status_code = 500 From 40f92b7b6b3be4fa117d9a8f28d320243c1eaf1e Mon Sep 17 00:00:00 2001 From: Thomas D <11554546+thomasddn@users.noreply.github.com> Date: Wed, 29 Jan 2025 13:02:20 +0100 Subject: [PATCH 0234/3148] Bump qbusmqttapi to 1.2.4 (#136835) --- homeassistant/components/qbus/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/qbus/manifest.json b/homeassistant/components/qbus/manifest.json index ac76110363f..b7d277f3953 100644 --- a/homeassistant/components/qbus/manifest.json +++ b/homeassistant/components/qbus/manifest.json @@ -13,5 +13,5 @@ "cloudapp/QBUSMQTTGW/+/state" ], "quality_scale": "bronze", - "requirements": ["qbusmqttapi==1.2.3"] + "requirements": ["qbusmqttapi==1.2.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index b483ba42e6a..05d040af2b0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2564,7 +2564,7 @@ pyzerproc==0.4.8 qbittorrent-api==2024.2.59 # homeassistant.components.qbus -qbusmqttapi==1.2.3 +qbusmqttapi==1.2.4 # homeassistant.components.qingping qingping-ble==0.10.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e92e8cf6ca3..236b908f6d1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2076,7 +2076,7 @@ pyzerproc==0.4.8 qbittorrent-api==2024.2.59 # homeassistant.components.qbus -qbusmqttapi==1.2.3 +qbusmqttapi==1.2.4 # homeassistant.components.qingping qingping-ble==0.10.0 From b6cc5090e4e83677282feaa9b7cc16e0f3d5117a Mon Sep 17 00:00:00 2001 From: Christopher Fenner <9592452+CFenner@users.noreply.github.com> Date: Wed, 29 Jan 2025 13:39:05 +0100 Subject: [PATCH 0235/3148] Update photovoltaic related labels in ViCare (#136430) --- homeassistant/components/vicare/strings.json | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/vicare/strings.json b/homeassistant/components/vicare/strings.json index a8636f651f3..26ca0f5a264 100644 --- a/homeassistant/components/vicare/strings.json +++ b/homeassistant/components/vicare/strings.json @@ -377,25 +377,25 @@ "name": "Energy export to grid" }, "photovoltaic_power_production_current": { - "name": "Solar power" + "name": "PV power" }, "photovoltaic_energy_production_today": { - "name": "Solar energy production today" + "name": "PV energy production today" }, "photovoltaic_energy_production_this_week": { - "name": "Solar energy production this week" + "name": "PV energy production this week" }, "photovoltaic_energy_production_this_month": { - "name": "Solar energy production this month" + "name": "PV energy production this month" }, "photovoltaic_energy_production_this_year": { - "name": "Solar energy production this year" + "name": "PV energy production this year" }, "photovoltaic_energy_production_total": { - "name": "Solar energy production total" + "name": "PV energy production total" }, "photovoltaic_status": { - "name": "Solar state", + "name": "PV state", "state": { "ready": "Standby", "production": "Producing" From 20ab6e2279d72dcde7ef61222dfb4866fbb1a1a9 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 29 Jan 2025 13:39:31 +0100 Subject: [PATCH 0236/3148] Standardize remaining homeassistant imports (#136836) --- homeassistant/components/config/config_entries.py | 2 +- tests/components/filter/conftest.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/config/config_entries.py b/homeassistant/components/config/config_entries.py index da50f7e93a1..4a070a87734 100644 --- a/homeassistant/components/config/config_entries.py +++ b/homeassistant/components/config/config_entries.py @@ -17,7 +17,7 @@ from homeassistant.components.http import KEY_HASS, HomeAssistantView, require_a from homeassistant.components.http.data_validator import RequestDataValidator from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import DependencyError, Unauthorized -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.data_entry_flow import ( FlowManagerIndexView, FlowManagerResourceView, diff --git a/tests/components/filter/conftest.py b/tests/components/filter/conftest.py index e703430446c..a576a2edb37 100644 --- a/tests/components/filter/conftest.py +++ b/tests/components/filter/conftest.py @@ -24,7 +24,7 @@ from homeassistant.components.filter.const import ( from homeassistant.config_entries import SOURCE_USER from homeassistant.const import CONF_ENTITY_ID, CONF_NAME from homeassistant.core import HomeAssistant, State -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry From 3e513dda626f0af9d7c6b2f05e525de31afec47f Mon Sep 17 00:00:00 2001 From: Tom Date: Wed, 29 Jan 2025 13:40:05 +0100 Subject: [PATCH 0237/3148] IQS completion of documentation for Plugwise (#134051) --- .../components/plugwise/manifest.json | 1 + .../components/plugwise/quality_scale.yaml | 36 +++++-------------- script/hassfest/quality_scale.py | 1 - 3 files changed, 10 insertions(+), 28 deletions(-) diff --git a/homeassistant/components/plugwise/manifest.json b/homeassistant/components/plugwise/manifest.json index ae60d4d7452..f7bd646f801 100644 --- a/homeassistant/components/plugwise/manifest.json +++ b/homeassistant/components/plugwise/manifest.json @@ -7,6 +7,7 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["plugwise"], + "quality_scale": "platinum", "requirements": ["plugwise==1.6.4"], "zeroconf": ["_plugwise._tcp.local."] } diff --git a/homeassistant/components/plugwise/quality_scale.yaml b/homeassistant/components/plugwise/quality_scale.yaml index a7b955b4713..55abf3c330e 100644 --- a/homeassistant/components/plugwise/quality_scale.yaml +++ b/homeassistant/components/plugwise/quality_scale.yaml @@ -15,12 +15,8 @@ rules: status: exempt comment: Plugwise integration has no custom actions common-modules: done - docs-high-level-description: - status: todo - comment: Rewrite top section, docs PR prepared waiting for 36087 merge - docs-installation-instructions: - status: todo - comment: Docs PR 36087 + docs-high-level-description: done + docs-installation-instructions: done docs-removal-instructions: done docs-actions: done brands: done @@ -35,9 +31,7 @@ rules: parallel-updates: done test-coverage: done integration-owner: done - docs-installation-parameters: - status: todo - comment: Docs PR 36087 (partial) + todo rewrite generically (PR prepared) + docs-installation-parameters: done docs-configuration-parameters: status: exempt comment: Plugwise has no options flow @@ -58,25 +52,13 @@ rules: repair-issues: status: exempt comment: This integration does not have repairs - docs-use-cases: - status: todo - comment: Check for completeness, PR prepared waiting for 36087 merge - docs-supported-devices: - status: todo - comment: The list is there but could be improved for readability, PR prepared waiting for 36087 merge - docs-supported-functions: - status: todo - comment: Check for completeness, PR prepared waiting for 36087 merge + docs-use-cases: done + docs-supported-devices: done + docs-supported-functions: done docs-data-update: done - docs-known-limitations: - status: todo - comment: Partial in 36087 but could be more elaborate - docs-troubleshooting: - status: todo - comment: Check for completeness, PR prepared waiting for 36087 merge - docs-examples: - status: todo - comment: Check for completeness, PR prepared waiting for 36087 merge + docs-known-limitations: done + docs-troubleshooting: done + docs-examples: done ## Platinum async-dependency: done inject-websession: done diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index 72c1cfae219..a1ad52e6aa8 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -1872,7 +1872,6 @@ INTEGRATIONS_WITHOUT_SCALE = [ "pioneer", "pjlink", "plaato", - "plugwise", "plant", "plex", "plum_lightpad", From 9a687e7f945870341b0ccc2c593713e23a28b728 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 29 Jan 2025 14:04:17 +0100 Subject: [PATCH 0238/3148] Add support for per-backup agent encryption flag (#136622) * Add support for per-backup agent encryption flag * Adjust * Don't attempt decrypting an unprotected backup * Address review comments * Add some tests * Add fixture * Rename fixture * Correct condition for when we should encrypt or decrypt * Update tests in integrations * Improve test coverage * Fix onedrive tests * Add test * Improve cipher worker shutdown * Improve test coverage * Fix google_drive tests * Move inner class _CipherBackupStreamer._WorkerStatus to module scope --- homeassistant/components/backup/config.py | 44 + homeassistant/components/backup/http.py | 6 +- homeassistant/components/backup/manager.py | 103 ++- homeassistant/components/backup/models.py | 20 +- homeassistant/components/backup/store.py | 4 +- homeassistant/components/backup/util.py | 205 +++- homeassistant/components/backup/websocket.py | 3 +- tests/components/backup/conftest.py | 1 + .../test_backups/c0cb53bd.tar.decrypted | Bin 0 -> 10240 bytes .../backup/snapshots/test_backup.ambr | 11 +- .../backup/snapshots/test_store.ambr | 14 + .../backup/snapshots/test_websocket.ambr | 873 +++++++++++++++--- tests/components/backup/test_manager.py | 265 +++++- tests/components/backup/test_store.py | 1 + tests/components/backup/test_util.py | 258 +++++- tests/components/backup/test_websocket.py | 270 ++++-- tests/components/cloud/test_backup.py | 8 +- tests/components/google_drive/test_backup.py | 4 +- tests/components/hassio/test_backup.py | 8 +- tests/components/kitchen_sink/test_backup.py | 8 +- tests/components/onedrive/test_backup.py | 15 +- tests/components/synology_dsm/test_backup.py | 18 +- 22 files changed, 1791 insertions(+), 348 deletions(-) create mode 100644 tests/components/backup/fixtures/test_backups/c0cb53bd.tar.decrypted diff --git a/homeassistant/components/backup/config.py b/homeassistant/components/backup/config.py index 1d1b8046360..0baefe1f52d 100644 --- a/homeassistant/components/backup/config.py +++ b/homeassistant/components/backup/config.py @@ -40,6 +40,7 @@ BACKUP_START_TIME_JITTER = 60 * 60 class StoredBackupConfig(TypedDict): """Represent the stored backup config.""" + agents: dict[str, StoredAgentConfig] create_backup: StoredCreateBackupConfig last_attempted_automatic_backup: str | None last_completed_automatic_backup: str | None @@ -51,6 +52,7 @@ class StoredBackupConfig(TypedDict): class BackupConfigData: """Represent loaded backup config data.""" + agents: dict[str, AgentConfig] create_backup: CreateBackupConfig last_attempted_automatic_backup: datetime | None = None last_completed_automatic_backup: datetime | None = None @@ -84,6 +86,10 @@ class BackupConfigData: days = [Day(day) for day in data["schedule"]["days"]] return cls( + agents={ + agent_id: AgentConfig(protected=agent_data["protected"]) + for agent_id, agent_data in data["agents"].items() + }, create_backup=CreateBackupConfig( agent_ids=data["create_backup"]["agent_ids"], include_addons=data["create_backup"]["include_addons"], @@ -120,6 +126,9 @@ class BackupConfigData: last_completed = None return StoredBackupConfig( + agents={ + agent_id: agent.to_dict() for agent_id, agent in self.agents.items() + }, create_backup=self.create_backup.to_dict(), last_attempted_automatic_backup=last_attempted, last_completed_automatic_backup=last_completed, @@ -134,6 +143,7 @@ class BackupConfig: def __init__(self, hass: HomeAssistant, manager: BackupManager) -> None: """Initialize backup config.""" self.data = BackupConfigData( + agents={}, create_backup=CreateBackupConfig(), retention=RetentionConfig(), schedule=BackupSchedule(), @@ -149,11 +159,20 @@ class BackupConfig: async def update( self, *, + agents: dict[str, AgentParametersDict] | UndefinedType = UNDEFINED, create_backup: CreateBackupParametersDict | UndefinedType = UNDEFINED, retention: RetentionParametersDict | UndefinedType = UNDEFINED, schedule: ScheduleParametersDict | UndefinedType = UNDEFINED, ) -> None: """Update config.""" + if agents is not UNDEFINED: + for agent_id, agent_config in agents.items(): + if agent_id not in self.data.agents: + self.data.agents[agent_id] = AgentConfig(**agent_config) + else: + self.data.agents[agent_id] = replace( + self.data.agents[agent_id], **agent_config + ) if create_backup is not UNDEFINED: self.data.create_backup = replace(self.data.create_backup, **create_backup) if retention is not UNDEFINED: @@ -170,6 +189,31 @@ class BackupConfig: self._manager.store.save() +@dataclass(kw_only=True) +class AgentConfig: + """Represent the config for an agent.""" + + protected: bool + + def to_dict(self) -> StoredAgentConfig: + """Convert agent config to a dict.""" + return { + "protected": self.protected, + } + + +class StoredAgentConfig(TypedDict): + """Represent the stored config for an agent.""" + + protected: bool + + +class AgentParametersDict(TypedDict, total=False): + """Represent the parameters for an agent.""" + + protected: bool + + @dataclass(kw_only=True) class RetentionConfig: """Represent the backup retention configuration.""" diff --git a/homeassistant/components/backup/http.py b/homeassistant/components/backup/http.py index 3d3877cc2f7..6b06db4601d 100644 --- a/homeassistant/components/backup/http.py +++ b/homeassistant/components/backup/http.py @@ -69,7 +69,7 @@ class DownloadBackupView(HomeAssistantView): CONTENT_DISPOSITION: f"attachment; filename={slugify(backup.name)}.tar" } - if not password: + if not password or not backup.protected: return await self._send_backup_no_password( request, headers, backup_id, agent_id, agent, manager ) @@ -123,13 +123,13 @@ class DownloadBackupView(HomeAssistantView): worker_done_event = asyncio.Event() - def on_done() -> None: + def on_done(error: Exception | None) -> None: """Call by the worker thread when it's done.""" hass.loop.call_soon_threadsafe(worker_done_event.set) stream = util.AsyncIteratorWriter(hass) worker = threading.Thread( - target=util.decrypt_backup, args=[reader, stream, password, on_done] + target=util.decrypt_backup, args=[reader, stream, password, on_done, 0, []] ) try: worker.start() diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index 19ebb8011ee..1f439160381 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -5,7 +5,7 @@ from __future__ import annotations import abc import asyncio from collections.abc import AsyncIterator, Callable, Coroutine -from dataclasses import dataclass +from dataclasses import dataclass, replace from enum import StrEnum import hashlib import io @@ -46,10 +46,12 @@ from .const import ( EXCLUDE_FROM_BACKUP, LOGGER, ) -from .models import AgentBackup, BackupError, BackupManagerError, Folder +from .models import AgentBackup, BackupError, BackupManagerError, BaseBackup, Folder from .store import BackupStore from .util import ( AsyncIteratorReader, + DecryptedBackupStreamer, + EncryptedBackupStreamer, make_backup_dir, read_backup, validate_password, @@ -65,10 +67,18 @@ class NewBackup: @dataclass(frozen=True, kw_only=True, slots=True) -class ManagerBackup(AgentBackup): +class AgentBackupStatus: + """Agent specific backup attributes.""" + + protected: bool + size: int + + +@dataclass(frozen=True, kw_only=True, slots=True) +class ManagerBackup(BaseBackup): """Backup class.""" - agent_ids: list[str] + agents: dict[str, AgentBackupStatus] failed_agent_ids: list[str] with_automatic_settings: bool | None @@ -437,20 +447,61 @@ class BackupManager: backup: AgentBackup, agent_ids: list[str], open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]], + password: str | None, ) -> dict[str, Exception]: """Upload a backup to selected agents.""" agent_errors: dict[str, Exception] = {} LOGGER.debug("Uploading backup %s to agents %s", backup.backup_id, agent_ids) - sync_backup_results = await asyncio.gather( - *( - self.backup_agents[agent_id].async_upload_backup( - open_stream=open_stream, - backup=backup, + async def upload_backup_to_agent(agent_id: str) -> None: + """Upload backup to a single agent, and encrypt or decrypt as needed.""" + config = self.config.data.agents.get(agent_id) + should_encrypt = config.protected if config else password is not None + streamer: DecryptedBackupStreamer | EncryptedBackupStreamer | None = None + if should_encrypt == backup.protected or password is None: + # The backup we're uploading is already in the correct state, or we + # don't have a password to encrypt or decrypt it + LOGGER.debug( + "Uploading backup %s to agent %s as is", backup.backup_id, agent_id ) - for agent_id in agent_ids - ), + open_stream_func = open_stream + _backup = backup + elif should_encrypt: + # The backup we're uploading is not encrypted, but the agent requires it + LOGGER.debug( + "Uploading encrypted backup %s to agent %s", + backup.backup_id, + agent_id, + ) + streamer = EncryptedBackupStreamer( + self.hass, backup, open_stream, password + ) + else: + # The backup we're uploading is encrypted, but the agent requires it + # decrypted + LOGGER.debug( + "Uploading decrypted backup %s to agent %s", + backup.backup_id, + agent_id, + ) + streamer = DecryptedBackupStreamer( + self.hass, backup, open_stream, password + ) + if streamer: + open_stream_func = streamer.open_stream + _backup = replace( + backup, protected=should_encrypt, size=streamer.size() + ) + await self.backup_agents[agent_id].async_upload_backup( + open_stream=open_stream_func, + backup=_backup, + ) + if streamer: + await streamer.wait() + + sync_backup_results = await asyncio.gather( + *(upload_backup_to_agent(agent_id) for agent_id in agent_ids), return_exceptions=True, ) for idx, result in enumerate(sync_backup_results): @@ -506,7 +557,7 @@ class BackupManager: agent_backup, await instance_id.async_get(self.hass) ) backups[backup_id] = ManagerBackup( - agent_ids=[], + agents={}, addons=agent_backup.addons, backup_id=backup_id, date=agent_backup.date, @@ -517,11 +568,12 @@ class BackupManager: homeassistant_included=agent_backup.homeassistant_included, homeassistant_version=agent_backup.homeassistant_version, name=agent_backup.name, - protected=agent_backup.protected, - size=agent_backup.size, with_automatic_settings=with_automatic_settings, ) - backups[backup_id].agent_ids.append(agent_ids[idx]) + backups[backup_id].agents[agent_ids[idx]] = AgentBackupStatus( + protected=agent_backup.protected, + size=agent_backup.size, + ) return (backups, agent_errors) @@ -557,7 +609,7 @@ class BackupManager: result, await instance_id.async_get(self.hass) ) backup = ManagerBackup( - agent_ids=[], + agents={}, addons=result.addons, backup_id=result.backup_id, date=result.date, @@ -568,11 +620,12 @@ class BackupManager: homeassistant_included=result.homeassistant_included, homeassistant_version=result.homeassistant_version, name=result.name, - protected=result.protected, - size=result.size, with_automatic_settings=with_automatic_settings, ) - backup.agent_ids.append(agent_ids[idx]) + backup.agents[agent_ids[idx]] = AgentBackupStatus( + protected=result.protected, + size=result.size, + ) return (backup, agent_errors) @@ -688,6 +741,9 @@ class BackupManager: backup=written_backup.backup, agent_ids=agent_ids, open_stream=written_backup.open_stream, + # When receiving a backup, we don't decrypt or encrypt it according to the + # agent settings, we just upload it as is. + password=None, ) await written_backup.release_stream() self.known_backups.add(written_backup.backup, agent_errors) @@ -855,7 +911,7 @@ class BackupManager: raise BackupManagerError(str(err)) from err backup_finish_task = self._backup_finish_task = self.hass.async_create_task( - self._async_finish_backup(agent_ids, with_automatic_settings), + self._async_finish_backup(agent_ids, with_automatic_settings, password), name="backup_manager_finish_backup", ) if not raise_task_error: @@ -872,7 +928,7 @@ class BackupManager: return new_backup async def _async_finish_backup( - self, agent_ids: list[str], with_automatic_settings: bool + self, agent_ids: list[str], with_automatic_settings: bool, password: str | None ) -> None: """Finish a backup.""" if TYPE_CHECKING: @@ -906,6 +962,7 @@ class BackupManager: backup=written_backup.backup, agent_ids=agent_ids, open_stream=written_backup.open_stream, + password=password, ) finally: await written_backup.release_stream() @@ -1269,6 +1326,10 @@ class CoreBackupReaderWriter(BackupReaderWriter): """Generate a backup.""" manager = self._hass.data[DATA_MANAGER] + agent_config = manager.config.data.agents.get(self._local_agent_id) + if agent_config and not agent_config.protected: + password = None + local_agent_tar_file_path = None if self._local_agent_id in agent_ids: local_agent = manager.local_backup_agents[self._local_agent_id] diff --git a/homeassistant/components/backup/models.py b/homeassistant/components/backup/models.py index f2a83f50c17..1543d577964 100644 --- a/homeassistant/components/backup/models.py +++ b/homeassistant/components/backup/models.py @@ -28,7 +28,7 @@ class Folder(StrEnum): @dataclass(frozen=True, kw_only=True) -class AgentBackup: +class BaseBackup: """Base backup class.""" addons: list[AddonInfo] @@ -40,12 +40,6 @@ class AgentBackup: homeassistant_included: bool homeassistant_version: str | None # None if homeassistant_included is False name: str - protected: bool - size: int - - def as_dict(self) -> dict: - """Return a dict representation of this backup.""" - return asdict(self) def as_frontend_json(self) -> dict: """Return a dict representation of this backup for sending to frontend.""" @@ -53,6 +47,18 @@ class AgentBackup: key: val for key, val in asdict(self).items() if key != "extra_metadata" } + +@dataclass(frozen=True, kw_only=True) +class AgentBackup(BaseBackup): + """Agent backup class.""" + + protected: bool + size: int + + def as_dict(self) -> dict: + """Return a dict representation of this backup.""" + return asdict(self) + @classmethod def from_dict(cls, data: dict[str, Any]) -> Self: """Create an instance from a JSON serialization.""" diff --git a/homeassistant/components/backup/store.py b/homeassistant/components/backup/store.py index 0e1c49426c5..3e2a88b8168 100644 --- a/homeassistant/components/backup/store.py +++ b/homeassistant/components/backup/store.py @@ -48,7 +48,9 @@ class _BackupStore(Store[StoredBackupData]): data = old_data if old_major_version == 1: if old_minor_version < 2: - # Version 1.2 adds configurable backup time and custom days + # Version 1.2 adds per agent settings, configurable backup time + # and custom days + data["config"]["agents"] = {} data["config"]["schedule"]["time"] = None if (state := data["config"]["schedule"]["state"]) in ("daily", "never"): data["config"]["schedule"]["days"] = [] diff --git a/homeassistant/components/backup/util.py b/homeassistant/components/backup/util.py index e5acf974012..bea3fe1f4ef 100644 --- a/homeassistant/components/backup/util.py +++ b/homeassistant/components/backup/util.py @@ -3,14 +3,17 @@ from __future__ import annotations import asyncio -from collections.abc import AsyncIterator, Callable +from collections.abc import AsyncIterator, Callable, Coroutine import copy +from dataclasses import dataclass, replace from io import BytesIO import json +import os from pathlib import Path, PurePath from queue import SimpleQueue import tarfile -from typing import IO, Self, cast +import threading +from typing import IO, Any, Self, cast import aiohttp from securetar import SecureTarError, SecureTarFile, SecureTarReadError @@ -30,6 +33,12 @@ class DecryptError(HomeAssistantError): _message = "Unexpected error during decryption." +class EncryptError(HomeAssistantError): + """Error during encryption.""" + + _message = "Unexpected error during encryption." + + class UnsupportedSecureTarVersion(DecryptError): """Unsupported securetar version.""" @@ -179,6 +188,7 @@ class AsyncIteratorWriter: def __init__(self, hass: HomeAssistant) -> None: """Initialize the wrapper.""" self._hass = hass + self._pos: int = 0 self._queue: asyncio.Queue[bytes | None] = asyncio.Queue(maxsize=1) def __aiter__(self) -> Self: @@ -191,9 +201,14 @@ class AsyncIteratorWriter: return data raise StopAsyncIteration + def tell(self) -> int: + """Return the current position in the iterator.""" + return self._pos + def write(self, s: bytes, /) -> int: """Write data to the iterator.""" asyncio.run_coroutine_threadsafe(self._queue.put(s), self._hass.loop).result() + self._pos += len(s) return len(s) @@ -230,9 +245,12 @@ def decrypt_backup( input_stream: IO[bytes], output_stream: IO[bytes], password: str | None, - on_done: Callable[[], None], + on_done: Callable[[Exception | None], None], + minimum_size: int, + nonces: list[bytes], ) -> None: """Decrypt a backup.""" + error: Exception | None = None try: with ( tarfile.open( @@ -245,9 +263,14 @@ def decrypt_backup( _decrypt_backup(input_tar, output_tar, password) except (DecryptError, SecureTarError, tarfile.TarError) as err: LOGGER.warning("Error decrypting backup: %s", err) + error = err + else: + # Pad the output stream to the requested minimum size + padding = max(minimum_size - output_stream.tell(), 0) + output_stream.write(b"\0" * padding) finally: output_stream.write(b"") # Write an empty chunk to signal the end of the stream - on_done() + on_done(error) def _decrypt_backup( @@ -288,6 +311,180 @@ def _decrypt_backup( output_tar.addfile(decrypted_obj, decrypted) +def encrypt_backup( + input_stream: IO[bytes], + output_stream: IO[bytes], + password: str | None, + on_done: Callable[[Exception | None], None], + minimum_size: int, + nonces: list[bytes], +) -> None: + """Encrypt a backup.""" + error: Exception | None = None + try: + with ( + tarfile.open( + fileobj=input_stream, mode="r|", bufsize=BUF_SIZE + ) as input_tar, + tarfile.open( + fileobj=output_stream, mode="w|", bufsize=BUF_SIZE + ) as output_tar, + ): + _encrypt_backup(input_tar, output_tar, password, nonces) + except (EncryptError, SecureTarError, tarfile.TarError) as err: + LOGGER.warning("Error encrypting backup: %s", err) + error = err + else: + # Pad the output stream to the requested minimum size + padding = max(minimum_size - output_stream.tell(), 0) + output_stream.write(b"\0" * padding) + finally: + output_stream.write(b"") # Write an empty chunk to signal the end of the stream + on_done(error) + + +def _encrypt_backup( + input_tar: tarfile.TarFile, + output_tar: tarfile.TarFile, + password: str | None, + nonces: list[bytes], +) -> None: + """Encrypt a backup.""" + inner_tar_idx = 0 + for obj in input_tar: + # We compare with PurePath to avoid issues with different path separators, + # for example when backup.json is added as "./backup.json" + if PurePath(obj.name) == PurePath("backup.json"): + # Rewrite the backup.json file to indicate that the backup is encrypted + if not (reader := input_tar.extractfile(obj)): + raise EncryptError + metadata = json_loads_object(reader.read()) + metadata["protected"] = True + updated_metadata_b = json.dumps(metadata).encode() + metadata_obj = copy.deepcopy(obj) + metadata_obj.size = len(updated_metadata_b) + output_tar.addfile(metadata_obj, BytesIO(updated_metadata_b)) + continue + if not obj.name.endswith((".tar", ".tgz", ".tar.gz")): + output_tar.addfile(obj, input_tar.extractfile(obj)) + continue + istf = SecureTarFile( + None, # Not used + gzip=False, + key=password_to_key(password) if password is not None else None, + mode="r", + fileobj=input_tar.extractfile(obj), + nonce=nonces[inner_tar_idx], + ) + inner_tar_idx += 1 + with istf.encrypt(obj) as encrypted: + encrypted_obj = copy.deepcopy(obj) + encrypted_obj.size = encrypted.encrypted_size + output_tar.addfile(encrypted_obj, encrypted) + + +@dataclass(kw_only=True) +class _CipherWorkerStatus: + done: asyncio.Event + error: Exception | None = None + thread: threading.Thread + + +class _CipherBackupStreamer: + """Encrypt or decrypt a backup.""" + + _cipher_func: Callable[ + [ + IO[bytes], + IO[bytes], + str | None, + Callable[[Exception | None], None], + int, + list[bytes], + ], + None, + ] + + def __init__( + self, + hass: HomeAssistant, + backup: AgentBackup, + open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]], + password: str | None, + ) -> None: + """Initialize.""" + self._workers: list[_CipherWorkerStatus] = [] + self._backup = backup + self._hass = hass + self._open_stream = open_stream + self._password = password + self._nonces: list[bytes] = [] + + def size(self) -> int: + """Return the maximum size of the decrypted or encrypted backup.""" + return self._backup.size + self._num_tar_files() * tarfile.RECORDSIZE + + def _num_tar_files(self) -> int: + """Return the number of inner tar files.""" + b = self._backup + return len(b.addons) + len(b.folders) + b.homeassistant_included + 1 + + async def open_stream(self) -> AsyncIterator[bytes]: + """Open a stream.""" + + def on_done(error: Exception | None) -> None: + """Call by the worker thread when it's done.""" + worker_status.error = error + self._hass.loop.call_soon_threadsafe(worker_status.done.set) + + stream = await self._open_stream() + reader = AsyncIteratorReader(self._hass, stream) + writer = AsyncIteratorWriter(self._hass) + worker = threading.Thread( + target=self._cipher_func, + args=[reader, writer, self._password, on_done, self.size(), self._nonces], + ) + worker_status = _CipherWorkerStatus(done=asyncio.Event(), thread=worker) + self._workers.append(worker_status) + worker.start() + return writer + + async def wait(self) -> None: + """Wait for the worker threads to finish.""" + await asyncio.gather(*(worker.done.wait() for worker in self._workers)) + + +class DecryptedBackupStreamer(_CipherBackupStreamer): + """Decrypt a backup.""" + + _cipher_func = staticmethod(decrypt_backup) + + def backup(self) -> AgentBackup: + """Return the decrypted backup.""" + return replace(self._backup, protected=False, size=self.size()) + + +class EncryptedBackupStreamer(_CipherBackupStreamer): + """Encrypt a backup.""" + + def __init__( + self, + hass: HomeAssistant, + backup: AgentBackup, + open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]], + password: str | None, + ) -> None: + """Initialize.""" + super().__init__(hass, backup, open_stream, password) + self._nonces = [os.urandom(16) for _ in range(self._num_tar_files())] + + _cipher_func = staticmethod(encrypt_backup) + + def backup(self) -> AgentBackup: + """Return the encrypted backup.""" + return replace(self._backup, protected=True, size=self.size()) + + async def receive_file( hass: HomeAssistant, contents: aiohttp.BodyPartReader, path: Path ) -> None: diff --git a/homeassistant/components/backup/websocket.py b/homeassistant/components/backup/websocket.py index 74f56102670..d8a425ab6ba 100644 --- a/homeassistant/components/backup/websocket.py +++ b/homeassistant/components/backup/websocket.py @@ -198,7 +198,7 @@ async def handle_can_decrypt_on_download( vol.Optional("include_folders"): [vol.Coerce(Folder)], vol.Optional("include_homeassistant", default=True): bool, vol.Optional("name"): str, - vol.Optional("password"): str, + vol.Optional("password"): vol.Any(str, None), } ) @websocket_api.async_response @@ -344,6 +344,7 @@ async def handle_config_info( @websocket_api.websocket_command( { vol.Required("type"): "backup/config/update", + vol.Optional("agents"): vol.Schema({str: {"protected": bool}}), vol.Optional("create_backup"): vol.Schema( { vol.Optional("agent_ids"): vol.All([str], vol.Unique()), diff --git a/tests/components/backup/conftest.py b/tests/components/backup/conftest.py index 7831efeff9a..bef48498ede 100644 --- a/tests/components/backup/conftest.py +++ b/tests/components/backup/conftest.py @@ -72,6 +72,7 @@ def mock_create_backup() -> Generator[AsyncMock]: """Mock manager create backup.""" mock_written_backup = MagicMock(spec_set=WrittenBackup) mock_written_backup.backup.backup_id = "abc123" + mock_written_backup.backup.protected = False mock_written_backup.open_stream = AsyncMock() mock_written_backup.release_stream = AsyncMock() fut: Future[MagicMock] = Future() diff --git a/tests/components/backup/fixtures/test_backups/c0cb53bd.tar.decrypted b/tests/components/backup/fixtures/test_backups/c0cb53bd.tar.decrypted new file mode 100644 index 0000000000000000000000000000000000000000..c97533fc1afb35fafb7be57651fc72e4069f44b3 GIT binary patch literal 10240 zcmdPXPfASAE-lc@D$dVipbanp0y7g61`rJd=(K^Uu^~(hB5!D5WMasmU_cw^pqg4* zT#{G>v>sJ-#PF(>lJj#5ic*VRNu4klY zpqG+bW}pNzC@(P=>?vnpl;`IvK+?Sesyd*uf};GA)Z`LyaDW{F6f4dtO$Qm8Y>=E} zYMhh;a(YQ+0ob^L#G;bS#2k+&OtSGgy(-F3x(X0%-mF4Lvv$u149cV3u8SC14{#Ab0aR)fEwiu z#}G))FG|$|)_{8HRW$P+C{yFB|IJK{7z|C!O^ggpOwCMzWq~0Gj@JJ)ix4D(<-0jJ zR-f)jXZp|ZcElJxxFZT|7>;M;dW=HA|yIR4BHo>;un?)+hk0>-8bvYCy!p}Q~n z`|s-X$OwGyTE!bS>j=kEp2=1pd6b%BY;^MK`X*cndo6GiYHg=zif>8FPOb#7{4Iw5|p4L4d_^LiZEiSAmx*7&MTgiYCm_`hR4%|FPEp#uk=l<`$#%KTUGn;4&0c z{~OV`0YFauZ)QAN|I;I-jMo37_5Z;4|BVr20ibaL;P{^*u>C*U|EGNkU}TP^|8HVw zWNbWI{nI}52i^uy{ck)N>wlBc`kx-DW3>Js+4Vm?(%7gSqaiRF0;3@?8UmvsFd71* QAut*OqaiRF0)rz203J%KF#rGn literal 0 HcmV?d00001 diff --git a/tests/components/backup/snapshots/test_backup.ambr b/tests/components/backup/snapshots/test_backup.ambr index 1a6774e7a95..441f79276a5 100644 --- a/tests/components/backup/snapshots/test_backup.ambr +++ b/tests/components/backup/snapshots/test_backup.ambr @@ -62,9 +62,12 @@ 'version': '1.0.0', }), ]), - 'agent_ids': list([ - 'backup.local', - ]), + 'agents': dict({ + 'backup.local': dict({ + 'protected': False, + 'size': 0, + }), + }), 'backup_id': 'abc123', 'database_included': True, 'date': '1970-01-01T00:00:00.000Z', @@ -77,8 +80,6 @@ 'homeassistant_included': True, 'homeassistant_version': '2024.12.0', 'name': 'Test', - 'protected': False, - 'size': 0, 'with_automatic_settings': True, }), ]), diff --git a/tests/components/backup/snapshots/test_store.ambr b/tests/components/backup/snapshots/test_store.ambr index 45af91645ad..7069860638a 100644 --- a/tests/components/backup/snapshots/test_store.ambr +++ b/tests/components/backup/snapshots/test_store.ambr @@ -11,6 +11,8 @@ }), ]), 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ ]), @@ -53,6 +55,8 @@ }), ]), 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -96,6 +100,11 @@ }), ]), 'config': dict({ + 'agents': dict({ + 'test.remote': dict({ + 'protected': True, + }), + }), 'create_backup': dict({ 'agent_ids': list([ ]), @@ -138,6 +147,11 @@ }), ]), 'config': dict({ + 'agents': dict({ + 'test.remote': dict({ + 'protected': True, + }), + }), 'create_backup': dict({ 'agent_ids': list([ 'test-agent', diff --git a/tests/components/backup/snapshots/test_websocket.ambr b/tests/components/backup/snapshots/test_websocket.ambr index 634404b09cd..f5a22201138 100644 --- a/tests/components/backup/snapshots/test_websocket.ambr +++ b/tests/components/backup/snapshots/test_websocket.ambr @@ -234,6 +234,8 @@ 'id': 1, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ ]), @@ -269,6 +271,8 @@ 'id': 1, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -316,6 +320,8 @@ 'id': 1, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -352,6 +358,8 @@ 'id': 1, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -388,6 +396,8 @@ 'id': 1, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -425,6 +435,8 @@ 'id': 1, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -461,6 +473,8 @@ 'id': 1, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -494,11 +508,59 @@ 'type': 'result', }) # --- -# name: test_config_update[command0] +# name: test_config_info[storage_data7] dict({ 'id': 1, 'result': dict({ 'config': dict({ + 'agents': dict({ + 'test-agent1': dict({ + 'protected': True, + }), + 'test-agent2': dict({ + 'protected': False, + }), + }), + 'create_backup': dict({ + 'agent_ids': list([ + 'test-agent', + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': False, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': '2024-11-17T04:55:00+01:00', + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + 'mon', + 'sun', + ]), + 'recurrence': 'custom_days', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update[commands0] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ ]), @@ -529,11 +591,13 @@ 'type': 'result', }) # --- -# name: test_config_update[command0].1 +# name: test_config_update[commands0].1 dict({ 'id': 3, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -565,12 +629,14 @@ 'type': 'result', }) # --- -# name: test_config_update[command0].2 +# name: test_config_update[commands0].2 dict({ 'data': dict({ 'backups': list([ ]), 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -602,11 +668,13 @@ 'version': 1, }) # --- -# name: test_config_update[command10] +# name: test_config_update[commands10] dict({ 'id': 1, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ ]), @@ -637,11 +705,13 @@ 'type': 'result', }) # --- -# name: test_config_update[command10].1 +# name: test_config_update[commands10].1 dict({ 'id': 3, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -673,12 +743,14 @@ 'type': 'result', }) # --- -# name: test_config_update[command10].2 +# name: test_config_update[commands10].2 dict({ 'data': dict({ 'backups': list([ ]), 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -710,11 +782,13 @@ 'version': 1, }) # --- -# name: test_config_update[command11] +# name: test_config_update[commands11] dict({ 'id': 1, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ ]), @@ -745,11 +819,13 @@ 'type': 'result', }) # --- -# name: test_config_update[command11].1 +# name: test_config_update[commands11].1 dict({ 'id': 3, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -781,12 +857,14 @@ 'type': 'result', }) # --- -# name: test_config_update[command11].2 +# name: test_config_update[commands11].2 dict({ 'data': dict({ 'backups': list([ ]), 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -818,11 +896,13 @@ 'version': 1, }) # --- -# name: test_config_update[command1] +# name: test_config_update[commands12] dict({ 'id': 1, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ ]), @@ -853,11 +933,304 @@ 'type': 'result', }) # --- -# name: test_config_update[command1].1 +# name: test_config_update[commands12].1 dict({ 'id': 3, 'result': dict({ 'config': dict({ + 'agents': dict({ + 'test-agent1': dict({ + 'protected': True, + }), + 'test-agent2': dict({ + 'protected': False, + }), + }), + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update[commands12].2 + dict({ + 'data': dict({ + 'backups': list([ + ]), + 'config': dict({ + 'agents': dict({ + 'test-agent1': dict({ + 'protected': True, + }), + 'test-agent2': dict({ + 'protected': False, + }), + }), + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'state': 'never', + 'time': None, + }), + }), + }), + 'key': 'backup', + 'minor_version': 2, + 'version': 1, + }) +# --- +# name: test_config_update[commands13] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + }), + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update[commands13].1 + dict({ + 'id': 3, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + 'test-agent1': dict({ + 'protected': True, + }), + 'test-agent2': dict({ + 'protected': False, + }), + }), + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update[commands13].2 + dict({ + 'id': 5, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + 'test-agent1': dict({ + 'protected': False, + }), + 'test-agent2': dict({ + 'protected': True, + }), + }), + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update[commands13].3 + dict({ + 'data': dict({ + 'backups': list([ + ]), + 'config': dict({ + 'agents': dict({ + 'test-agent1': dict({ + 'protected': False, + }), + 'test-agent2': dict({ + 'protected': True, + }), + }), + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'state': 'never', + 'time': None, + }), + }), + }), + 'key': 'backup', + 'minor_version': 2, + 'version': 1, + }) +# --- +# name: test_config_update[commands1] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + }), + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update[commands1].1 + dict({ + 'id': 3, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -889,12 +1262,14 @@ 'type': 'result', }) # --- -# name: test_config_update[command1].2 +# name: test_config_update[commands1].2 dict({ 'data': dict({ 'backups': list([ ]), 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -926,11 +1301,13 @@ 'version': 1, }) # --- -# name: test_config_update[command2] +# name: test_config_update[commands2] dict({ 'id': 1, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ ]), @@ -961,11 +1338,13 @@ 'type': 'result', }) # --- -# name: test_config_update[command2].1 +# name: test_config_update[commands2].1 dict({ 'id': 3, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -998,12 +1377,14 @@ 'type': 'result', }) # --- -# name: test_config_update[command2].2 +# name: test_config_update[commands2].2 dict({ 'data': dict({ 'backups': list([ ]), 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -1036,11 +1417,13 @@ 'version': 1, }) # --- -# name: test_config_update[command3] +# name: test_config_update[commands3] dict({ 'id': 1, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ ]), @@ -1071,11 +1454,13 @@ 'type': 'result', }) # --- -# name: test_config_update[command3].1 +# name: test_config_update[commands3].1 dict({ 'id': 3, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -1107,12 +1492,14 @@ 'type': 'result', }) # --- -# name: test_config_update[command3].2 +# name: test_config_update[commands3].2 dict({ 'data': dict({ 'backups': list([ ]), 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -1144,11 +1531,13 @@ 'version': 1, }) # --- -# name: test_config_update[command4] +# name: test_config_update[commands4] dict({ 'id': 1, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ ]), @@ -1179,11 +1568,13 @@ 'type': 'result', }) # --- -# name: test_config_update[command4].1 +# name: test_config_update[commands4].1 dict({ 'id': 3, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -1217,12 +1608,14 @@ 'type': 'result', }) # --- -# name: test_config_update[command4].2 +# name: test_config_update[commands4].2 dict({ 'data': dict({ 'backups': list([ ]), 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -1256,11 +1649,13 @@ 'version': 1, }) # --- -# name: test_config_update[command5] +# name: test_config_update[commands5] dict({ 'id': 1, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ ]), @@ -1291,11 +1686,13 @@ 'type': 'result', }) # --- -# name: test_config_update[command5].1 +# name: test_config_update[commands5].1 dict({ 'id': 3, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -1331,12 +1728,14 @@ 'type': 'result', }) # --- -# name: test_config_update[command5].2 +# name: test_config_update[commands5].2 dict({ 'data': dict({ 'backups': list([ ]), 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -1372,11 +1771,13 @@ 'version': 1, }) # --- -# name: test_config_update[command6] +# name: test_config_update[commands6] dict({ 'id': 1, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ ]), @@ -1407,11 +1808,13 @@ 'type': 'result', }) # --- -# name: test_config_update[command6].1 +# name: test_config_update[commands6].1 dict({ 'id': 3, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -1443,12 +1846,14 @@ 'type': 'result', }) # --- -# name: test_config_update[command6].2 +# name: test_config_update[commands6].2 dict({ 'data': dict({ 'backups': list([ ]), 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -1480,11 +1885,13 @@ 'version': 1, }) # --- -# name: test_config_update[command7] +# name: test_config_update[commands7] dict({ 'id': 1, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ ]), @@ -1515,11 +1922,13 @@ 'type': 'result', }) # --- -# name: test_config_update[command7].1 +# name: test_config_update[commands7].1 dict({ 'id': 3, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -1551,12 +1960,14 @@ 'type': 'result', }) # --- -# name: test_config_update[command7].2 +# name: test_config_update[commands7].2 dict({ 'data': dict({ 'backups': list([ ]), 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -1588,11 +1999,13 @@ 'version': 1, }) # --- -# name: test_config_update[command8] +# name: test_config_update[commands8] dict({ 'id': 1, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ ]), @@ -1623,11 +2036,13 @@ 'type': 'result', }) # --- -# name: test_config_update[command8].1 +# name: test_config_update[commands8].1 dict({ 'id': 3, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -1659,12 +2074,14 @@ 'type': 'result', }) # --- -# name: test_config_update[command8].2 +# name: test_config_update[commands8].2 dict({ 'data': dict({ 'backups': list([ ]), 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -1696,11 +2113,13 @@ 'version': 1, }) # --- -# name: test_config_update[command9] +# name: test_config_update[commands9] dict({ 'id': 1, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ ]), @@ -1731,11 +2150,13 @@ 'type': 'result', }) # --- -# name: test_config_update[command9].1 +# name: test_config_update[commands9].1 dict({ 'id': 3, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -1767,12 +2188,14 @@ 'type': 'result', }) # --- -# name: test_config_update[command9].2 +# name: test_config_update[commands9].2 dict({ 'data': dict({ 'backups': list([ ]), 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -1809,6 +2232,8 @@ 'id': 1, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ ]), @@ -1844,6 +2269,8 @@ 'id': 3, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ ]), @@ -1879,6 +2306,8 @@ 'id': 1, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ ]), @@ -1914,6 +2343,8 @@ 'id': 3, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ ]), @@ -1949,6 +2380,8 @@ 'id': 1, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ ]), @@ -1984,6 +2417,8 @@ 'id': 3, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ ]), @@ -2019,6 +2454,8 @@ 'id': 1, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ ]), @@ -2054,6 +2491,8 @@ 'id': 3, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ ]), @@ -2089,6 +2528,8 @@ 'id': 1, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ ]), @@ -2124,6 +2565,8 @@ 'id': 3, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ ]), @@ -2159,6 +2602,8 @@ 'id': 1, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ ]), @@ -2194,6 +2639,8 @@ 'id': 3, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ ]), @@ -2229,6 +2676,8 @@ 'id': 1, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ ]), @@ -2264,6 +2713,8 @@ 'id': 3, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ ]), @@ -2299,6 +2750,8 @@ 'id': 1, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ ]), @@ -2334,6 +2787,8 @@ 'id': 3, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ ]), @@ -2369,6 +2824,8 @@ 'id': 1, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ ]), @@ -2404,6 +2861,82 @@ 'id': 3, 'result': dict({ 'config': dict({ + 'agents': dict({ + }), + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update_errors[command9] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + }), + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update_errors[command9].1 + dict({ + 'id': 3, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + }), 'create_backup': dict({ 'agent_ids': list([ ]), @@ -2494,9 +3027,12 @@ 'version': '1.0.0', }), ]), - 'agent_ids': list([ - 'backup.local', - ]), + 'agents': dict({ + 'backup.local': dict({ + 'protected': False, + 'size': 0, + }), + }), 'backup_id': 'abc123', 'database_included': True, 'date': '1970-01-01T00:00:00.000Z', @@ -2509,8 +3045,6 @@ 'homeassistant_included': True, 'homeassistant_version': '2024.12.0', 'name': 'Test', - 'protected': False, - 'size': 0, 'with_automatic_settings': True, }), ]), @@ -2566,9 +3100,12 @@ 'version': '1.0.0', }), ]), - 'agent_ids': list([ - 'test.remote', - ]), + 'agents': dict({ + 'test.remote': dict({ + 'protected': False, + 'size': 0, + }), + }), 'backup_id': 'abc123', 'database_included': True, 'date': '1970-01-01T00:00:00.000Z', @@ -2581,8 +3118,6 @@ 'homeassistant_included': True, 'homeassistant_version': '2024.12.0', 'name': 'Test', - 'protected': False, - 'size': 0, 'with_automatic_settings': True, }), ]), @@ -2621,9 +3156,12 @@ 'version': '1.0.0', }), ]), - 'agent_ids': list([ - 'test.remote', - ]), + 'agents': dict({ + 'test.remote': dict({ + 'protected': False, + 'size': 0, + }), + }), 'backup_id': 'abc123', 'database_included': True, 'date': '1970-01-01T00:00:00.000Z', @@ -2636,8 +3174,6 @@ 'homeassistant_included': True, 'homeassistant_version': '2024.12.0', 'name': 'Test', - 'protected': False, - 'size': 0, 'with_automatic_settings': True, }), ]), @@ -2660,9 +3196,12 @@ dict({ 'addons': list([ ]), - 'agent_ids': list([ - 'test.remote', - ]), + 'agents': dict({ + 'test.remote': dict({ + 'protected': False, + 'size': 1, + }), + }), 'backup_id': 'def456', 'database_included': False, 'date': '1980-01-01T00:00:00.000Z', @@ -2675,8 +3214,6 @@ 'homeassistant_included': True, 'homeassistant_version': '2024.12.0', 'name': 'Test 2', - 'protected': False, - 'size': 1, 'with_automatic_settings': None, }), ]), @@ -2710,9 +3247,12 @@ dict({ 'addons': list([ ]), - 'agent_ids': list([ - 'test.remote', - ]), + 'agents': dict({ + 'test.remote': dict({ + 'protected': False, + 'size': 1, + }), + }), 'backup_id': 'def456', 'database_included': False, 'date': '1980-01-01T00:00:00.000Z', @@ -2725,8 +3265,6 @@ 'homeassistant_included': True, 'homeassistant_version': '2024.12.0', 'name': 'Test 2', - 'protected': False, - 'size': 1, 'with_automatic_settings': None, }), ]), @@ -2754,10 +3292,16 @@ 'version': '1.0.0', }), ]), - 'agent_ids': list([ - 'test.remote', - 'backup.local', - ]), + 'agents': dict({ + 'backup.local': dict({ + 'protected': False, + 'size': 0, + }), + 'test.remote': dict({ + 'protected': False, + 'size': 0, + }), + }), 'backup_id': 'abc123', 'database_included': True, 'date': '1970-01-01T00:00:00.000Z', @@ -2770,8 +3314,6 @@ 'homeassistant_included': True, 'homeassistant_version': '2024.12.0', 'name': 'Test', - 'protected': False, - 'size': 0, 'with_automatic_settings': True, }), ]), @@ -2810,9 +3352,12 @@ 'version': '1.0.0', }), ]), - 'agent_ids': list([ - 'test.remote', - ]), + 'agents': dict({ + 'test.remote': dict({ + 'protected': False, + 'size': 0, + }), + }), 'backup_id': 'abc123', 'database_included': True, 'date': '1970-01-01T00:00:00.000Z', @@ -2825,8 +3370,6 @@ 'homeassistant_included': True, 'homeassistant_version': '2024.12.0', 'name': 'Test', - 'protected': False, - 'size': 0, 'with_automatic_settings': True, }), ]), @@ -2866,9 +3409,12 @@ 'version': '1.0.0', }), ]), - 'agent_ids': list([ - 'domain.test', - ]), + 'agents': dict({ + 'domain.test': dict({ + 'protected': False, + 'size': 13, + }), + }), 'backup_id': 'abc123', 'database_included': True, 'date': '1970-01-01T00:00:00Z', @@ -2881,8 +3427,6 @@ 'homeassistant_included': True, 'homeassistant_version': '2024.12.0', 'name': 'Test', - 'protected': False, - 'size': 13, 'with_automatic_settings': None, }), ]), @@ -2922,9 +3466,12 @@ 'version': '1.0.0', }), ]), - 'agent_ids': list([ - 'domain.test', - ]), + 'agents': dict({ + 'domain.test': dict({ + 'protected': False, + 'size': 13, + }), + }), 'backup_id': 'abc123', 'database_included': True, 'date': '1970-01-01T00:00:00Z', @@ -2938,8 +3485,6 @@ 'homeassistant_included': True, 'homeassistant_version': '2024.12.0', 'name': 'Test', - 'protected': False, - 'size': 13, 'with_automatic_settings': None, }), ]), @@ -2978,9 +3523,12 @@ 'version': '1.0.0', }), ]), - 'agent_ids': list([ - 'domain.test', - ]), + 'agents': dict({ + 'domain.test': dict({ + 'protected': False, + 'size': 13, + }), + }), 'backup_id': 'abc123', 'database_included': True, 'date': '1970-01-01T00:00:00Z', @@ -2993,8 +3541,6 @@ 'homeassistant_included': True, 'homeassistant_version': '2024.12.0', 'name': 'Test', - 'protected': False, - 'size': 13, 'with_automatic_settings': None, }), ]), @@ -3033,9 +3579,12 @@ 'version': '1.0.0', }), ]), - 'agent_ids': list([ - 'domain.test', - ]), + 'agents': dict({ + 'domain.test': dict({ + 'protected': False, + 'size': 13, + }), + }), 'backup_id': 'abc123', 'database_included': True, 'date': '1970-01-01T00:00:00Z', @@ -3048,8 +3597,6 @@ 'homeassistant_included': True, 'homeassistant_version': '2024.12.0', 'name': 'Test', - 'protected': False, - 'size': 13, 'with_automatic_settings': None, }), ]), @@ -3088,9 +3635,12 @@ 'version': '1.0.0', }), ]), - 'agent_ids': list([ - 'domain.test', - ]), + 'agents': dict({ + 'domain.test': dict({ + 'protected': False, + 'size': 13, + }), + }), 'backup_id': 'abc123', 'database_included': True, 'date': '1970-01-01T00:00:00Z', @@ -3103,8 +3653,6 @@ 'homeassistant_included': True, 'homeassistant_version': '2024.12.0', 'name': 'Test', - 'protected': False, - 'size': 13, 'with_automatic_settings': None, }), ]), @@ -3143,9 +3691,12 @@ 'version': '1.0.0', }), ]), - 'agent_ids': list([ - 'domain.test', - ]), + 'agents': dict({ + 'domain.test': dict({ + 'protected': False, + 'size': 13, + }), + }), 'backup_id': 'abc123', 'database_included': True, 'date': '1970-01-01T00:00:00Z', @@ -3159,8 +3710,6 @@ 'homeassistant_included': True, 'homeassistant_version': '2024.12.0', 'name': 'Test', - 'protected': False, - 'size': 13, 'with_automatic_settings': None, }), ]), @@ -3199,9 +3748,12 @@ 'version': '1.0.0', }), ]), - 'agent_ids': list([ - 'backup.local', - ]), + 'agents': dict({ + 'backup.local': dict({ + 'protected': False, + 'size': 0, + }), + }), 'backup_id': 'abc123', 'database_included': True, 'date': '1970-01-01T00:00:00.000Z', @@ -3214,8 +3766,6 @@ 'homeassistant_included': True, 'homeassistant_version': '2024.12.0', 'name': 'Test', - 'protected': False, - 'size': 0, 'with_automatic_settings': True, }), }), @@ -3237,9 +3787,12 @@ 'version': '1.0.0', }), ]), - 'agent_ids': list([ - 'test.remote', - ]), + 'agents': dict({ + 'test.remote': dict({ + 'protected': False, + 'size': 0, + }), + }), 'backup_id': 'abc123', 'database_included': True, 'date': '1970-01-01T00:00:00.000Z', @@ -3252,8 +3805,6 @@ 'homeassistant_included': True, 'homeassistant_version': '2024.12.0', 'name': 'Test', - 'protected': False, - 'size': 0, 'with_automatic_settings': True, }), }), @@ -3287,10 +3838,16 @@ 'version': '1.0.0', }), ]), - 'agent_ids': list([ - 'test.remote', - 'backup.local', - ]), + 'agents': dict({ + 'backup.local': dict({ + 'protected': False, + 'size': 0, + }), + 'test.remote': dict({ + 'protected': False, + 'size': 0, + }), + }), 'backup_id': 'abc123', 'database_included': True, 'date': '1970-01-01T00:00:00.000Z', @@ -3303,8 +3860,6 @@ 'homeassistant_included': True, 'homeassistant_version': '2024.12.0', 'name': 'Test', - 'protected': False, - 'size': 0, 'with_automatic_settings': True, }), }), @@ -3327,9 +3882,12 @@ 'version': '1.0.0', }), ]), - 'agent_ids': list([ - 'backup.local', - ]), + 'agents': dict({ + 'backup.local': dict({ + 'protected': False, + 'size': 0, + }), + }), 'backup_id': 'abc123', 'database_included': True, 'date': '1970-01-01T00:00:00.000Z', @@ -3342,8 +3900,6 @@ 'homeassistant_included': True, 'homeassistant_version': '2024.12.0', 'name': 'Test', - 'protected': False, - 'size': 0, 'with_automatic_settings': True, }), }), @@ -3602,9 +4158,12 @@ 'version': '1.0.0', }), ]), - 'agent_ids': list([ - 'backup.local', - ]), + 'agents': dict({ + 'backup.local': dict({ + 'protected': False, + 'size': 0, + }), + }), 'backup_id': 'abc123', 'database_included': True, 'date': '1970-01-01T00:00:00.000Z', @@ -3617,8 +4176,6 @@ 'homeassistant_included': True, 'homeassistant_version': '2024.12.0', 'name': 'Test', - 'protected': False, - 'size': 0, 'with_automatic_settings': True, }), ]), @@ -3646,9 +4203,12 @@ 'version': '1.0.0', }), ]), - 'agent_ids': list([ - 'backup.local', - ]), + 'agents': dict({ + 'backup.local': dict({ + 'protected': False, + 'size': 0, + }), + }), 'backup_id': 'abc123', 'database_included': True, 'date': '1970-01-01T00:00:00.000Z', @@ -3661,8 +4221,6 @@ 'homeassistant_included': True, 'homeassistant_version': '2024.12.0', 'name': 'Test', - 'protected': False, - 'size': 0, 'with_automatic_settings': True, }), ]), @@ -3690,10 +4248,16 @@ 'version': '1.0.0', }), ]), - 'agent_ids': list([ - 'test.remote', - 'backup.local', - ]), + 'agents': dict({ + 'backup.local': dict({ + 'protected': False, + 'size': 0, + }), + 'test.remote': dict({ + 'protected': False, + 'size': 0, + }), + }), 'backup_id': 'abc123', 'database_included': True, 'date': '1970-01-01T00:00:00.000Z', @@ -3706,8 +4270,6 @@ 'homeassistant_included': True, 'homeassistant_version': '2024.12.0', 'name': 'Test', - 'protected': False, - 'size': 0, 'with_automatic_settings': True, }), ]), @@ -3730,9 +4292,12 @@ dict({ 'addons': list([ ]), - 'agent_ids': list([ - 'test.remote', - ]), + 'agents': dict({ + 'test.remote': dict({ + 'protected': False, + 'size': 1, + }), + }), 'backup_id': 'def456', 'database_included': False, 'date': '1980-01-01T00:00:00.000Z', @@ -3745,8 +4310,6 @@ 'homeassistant_included': True, 'homeassistant_version': '2024.12.0', 'name': 'Test 2', - 'protected': False, - 'size': 1, 'with_automatic_settings': None, }), dict({ @@ -3757,9 +4320,12 @@ 'version': '1.0.0', }), ]), - 'agent_ids': list([ - 'backup.local', - ]), + 'agents': dict({ + 'backup.local': dict({ + 'protected': False, + 'size': 0, + }), + }), 'backup_id': 'abc123', 'database_included': True, 'date': '1970-01-01T00:00:00.000Z', @@ -3772,8 +4338,6 @@ 'homeassistant_included': True, 'homeassistant_version': '2024.12.0', 'name': 'Test', - 'protected': False, - 'size': 0, 'with_automatic_settings': True, }), ]), @@ -3802,9 +4366,12 @@ 'version': '1.0.0', }), ]), - 'agent_ids': list([ - 'backup.local', - ]), + 'agents': dict({ + 'backup.local': dict({ + 'protected': False, + 'size': 0, + }), + }), 'backup_id': 'abc123', 'database_included': True, 'date': '1970-01-01T00:00:00.000Z', @@ -3817,8 +4384,6 @@ 'homeassistant_included': True, 'homeassistant_version': '2024.12.0', 'name': 'Test', - 'protected': False, - 'size': 0, 'with_automatic_settings': True, }), ]), diff --git a/tests/components/backup/test_manager.py b/tests/components/backup/test_manager.py index f2c2e5c5b05..d2993e53410 100644 --- a/tests/components/backup/test_manager.py +++ b/tests/components/backup/test_manager.py @@ -487,11 +487,13 @@ async def test_initiate_backup( result = await ws_client.receive_json() backup_data = result["result"]["backup"] - backup_agent_ids = backup_data.pop("agent_ids") - assert backup_agent_ids == agent_ids assert backup_data == { "addons": [], + "agents": { + agent_id: {"protected": bool(password), "size": ANY} + for agent_id in agent_ids + }, "backup_id": backup_id, "database_included": include_database, "date": ANY, @@ -500,8 +502,6 @@ async def test_initiate_backup( "homeassistant_included": True, "homeassistant_version": "2025.1.0", "name": name, - "protected": bool(password), - "size": ANY, "with_automatic_settings": False, } @@ -543,9 +543,7 @@ async def test_initiate_backup_with_agent_error( "version": "1.0.0", }, ], - "agent_ids": [ - "test.remote", - ], + "agents": {"test.remote": {"protected": False, "size": 0}}, "backup_id": "backup1", "database_included": True, "date": "1970-01-01T00:00:00.000Z", @@ -557,15 +555,11 @@ async def test_initiate_backup_with_agent_error( "homeassistant_included": True, "homeassistant_version": "2024.12.0", "name": "Test", - "protected": False, - "size": 0, "with_automatic_settings": True, }, { "addons": [], - "agent_ids": [ - "test.remote", - ], + "agents": {"test.remote": {"protected": False, "size": 1}}, "backup_id": "backup2", "database_included": False, "date": "1980-01-01T00:00:00.000Z", @@ -577,8 +571,6 @@ async def test_initiate_backup_with_agent_error( "homeassistant_included": True, "homeassistant_version": "2024.12.0", "name": "Test 2", - "protected": False, - "size": 1, "with_automatic_settings": None, }, { @@ -589,9 +581,7 @@ async def test_initiate_backup_with_agent_error( "version": "1.0.0", }, ], - "agent_ids": [ - "test.remote", - ], + "agents": {"test.remote": {"protected": False, "size": 0}}, "backup_id": "backup3", "database_included": True, "date": "1970-01-01T00:00:00.000Z", @@ -603,8 +593,6 @@ async def test_initiate_backup_with_agent_error( "homeassistant_included": True, "homeassistant_version": "2024.12.0", "name": "Test", - "protected": False, - "size": 0, "with_automatic_settings": True, }, ] @@ -714,7 +702,7 @@ async def test_initiate_backup_with_agent_error( new_expected_backup_data = { "addons": [], - "agent_ids": ["backup.local"], + "agents": {"backup.local": {"protected": False, "size": 123}}, "backup_id": "abc123", "database_included": True, "date": ANY, @@ -723,8 +711,6 @@ async def test_initiate_backup_with_agent_error( "homeassistant_included": True, "homeassistant_version": "2025.1.0", "name": "Custom backup 2025.1.0", - "protected": False, - "size": 123, "with_automatic_settings": False, } @@ -1633,9 +1619,7 @@ async def test_receive_backup_agent_error( "version": "1.0.0", }, ], - "agent_ids": [ - "test.remote", - ], + "agents": {"test.remote": {"protected": False, "size": 0}}, "backup_id": "backup1", "database_included": True, "date": "1970-01-01T00:00:00.000Z", @@ -1647,15 +1631,11 @@ async def test_receive_backup_agent_error( "homeassistant_included": True, "homeassistant_version": "2024.12.0", "name": "Test", - "protected": False, - "size": 0, "with_automatic_settings": True, }, { "addons": [], - "agent_ids": [ - "test.remote", - ], + "agents": {"test.remote": {"protected": False, "size": 1}}, "backup_id": "backup2", "database_included": False, "date": "1980-01-01T00:00:00.000Z", @@ -1667,8 +1647,6 @@ async def test_receive_backup_agent_error( "homeassistant_included": True, "homeassistant_version": "2024.12.0", "name": "Test 2", - "protected": False, - "size": 1, "with_automatic_settings": None, }, { @@ -1679,9 +1657,7 @@ async def test_receive_backup_agent_error( "version": "1.0.0", }, ], - "agent_ids": [ - "test.remote", - ], + "agents": {"test.remote": {"protected": False, "size": 0}}, "backup_id": "backup3", "database_included": True, "date": "1970-01-01T00:00:00.000Z", @@ -1693,8 +1669,6 @@ async def test_receive_backup_agent_error( "homeassistant_included": True, "homeassistant_version": "2024.12.0", "name": "Test", - "protected": False, - "size": 0, "with_automatic_settings": True, }, ] @@ -2936,3 +2910,220 @@ async def test_restore_backup_file_error( assert open_mock.return_value.close.call_count == close_call_count assert mocked_write_text.call_count == write_text_call_count assert mocked_service_call.call_count == 0 + + +@pytest.mark.parametrize( + ("commands", "password", "protected_backup"), + [ + ( + [], + None, + {"backup.local": False, "test.remote": False}, + ), + ( + [], + "hunter2", + {"backup.local": True, "test.remote": True}, + ), + ( + [ + { + "type": "backup/config/update", + "agents": { + "backup.local": {"protected": False}, + "test.remote": {"protected": False}, + }, + } + ], + "hunter2", + {"backup.local": False, "test.remote": False}, + ), + ( + [ + { + "type": "backup/config/update", + "agents": { + "backup.local": {"protected": False}, + "test.remote": {"protected": True}, + }, + } + ], + "hunter2", + {"backup.local": False, "test.remote": True}, + ), + ( + [ + { + "type": "backup/config/update", + "agents": { + "backup.local": {"protected": True}, + "test.remote": {"protected": False}, + }, + } + ], + "hunter2", + {"backup.local": True, "test.remote": False}, + ), + ( + [ + { + "type": "backup/config/update", + "agents": { + "backup.local": {"protected": True}, + "test.remote": {"protected": True}, + }, + } + ], + "hunter2", + {"backup.local": True, "test.remote": True}, + ), + ( + [ + { + "type": "backup/config/update", + "agents": { + "backup.local": {"protected": False}, + "test.remote": {"protected": True}, + }, + } + ], + None, + {"backup.local": False, "test.remote": False}, + ), + ], +) +@pytest.mark.usefixtures("mock_backup_generation") +async def test_initiate_backup_per_agent_encryption( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + generate_backup_id: MagicMock, + path_glob: MagicMock, + commands: dict[str, Any], + password: str | None, + protected_backup: dict[str, bool], +) -> None: + """Test generate backup where encryption is selectively set on agents.""" + agent_ids = ["backup.local", "test.remote"] + local_agent = local_backup_platform.CoreLocalBackupAgent(hass) + remote_agent = BackupAgentTest("remote", backups=[]) + + with patch( + "homeassistant.components.backup.backup.async_get_backup_agents" + ) as core_get_backup_agents: + core_get_backup_agents.return_value = [local_agent] + await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + await setup_backup_platform( + hass, + domain="test", + platform=Mock( + async_get_backup_agents=AsyncMock(return_value=[remote_agent]), + spec_set=BackupAgentPlatformProtocol, + ), + ) + + ws_client = await hass_ws_client(hass) + + path_glob.return_value = [] + + await ws_client.send_json_auto_id({"type": "backup/info"}) + result = await ws_client.receive_json() + assert result["success"] is True + assert result["result"] == { + "backups": [], + "agent_errors": {}, + "last_attempted_automatic_backup": None, + "last_completed_automatic_backup": None, + "next_automatic_backup": None, + "next_automatic_backup_additional": False, + } + + for command in commands: + await ws_client.send_json_auto_id(command) + result = await ws_client.receive_json() + assert result["success"] is True + + await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) + + result = await ws_client.receive_json() + assert result["event"] == {"manager_state": BackupManagerState.IDLE} + + result = await ws_client.receive_json() + assert result["success"] is True + + with ( + patch("pathlib.Path.open", mock_open(read_data=b"test")), + ): + await ws_client.send_json_auto_id( + { + "type": "backup/generate", + "agent_ids": agent_ids, + "password": password, + "name": "test", + } + ) + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.CREATE_BACKUP, + "reason": None, + "stage": None, + "state": CreateBackupState.IN_PROGRESS, + } + result = await ws_client.receive_json() + assert result["success"] is True + + backup_id = result["result"]["backup_job_id"] + assert backup_id == generate_backup_id.return_value + + await hass.async_block_till_done() + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.CREATE_BACKUP, + "reason": None, + "stage": CreateBackupStage.HOME_ASSISTANT, + "state": CreateBackupState.IN_PROGRESS, + } + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.CREATE_BACKUP, + "reason": None, + "stage": CreateBackupStage.UPLOAD_TO_AGENTS, + "state": CreateBackupState.IN_PROGRESS, + } + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.CREATE_BACKUP, + "reason": None, + "stage": None, + "state": CreateBackupState.COMPLETED, + } + + result = await ws_client.receive_json() + assert result["event"] == {"manager_state": BackupManagerState.IDLE} + + await ws_client.send_json_auto_id( + {"type": "backup/details", "backup_id": backup_id} + ) + result = await ws_client.receive_json() + + backup_data = result["result"]["backup"] + + assert backup_data == { + "addons": [], + "agents": { + agent_id: {"protected": protected_backup[agent_id], "size": ANY} + for agent_id in agent_ids + }, + "backup_id": backup_id, + "database_included": True, + "date": ANY, + "failed_agent_ids": [], + "folders": [], + "homeassistant_included": True, + "homeassistant_version": "2025.1.0", + "name": "test", + "with_automatic_settings": False, + } diff --git a/tests/components/backup/test_store.py b/tests/components/backup/test_store.py index cc84b66340c..f05afbea9ec 100644 --- a/tests/components/backup/test_store.py +++ b/tests/components/backup/test_store.py @@ -66,6 +66,7 @@ def mock_delay_save() -> Generator[None]: } ], "config": { + "agents": {"test.remote": {"protected": True}}, "create_backup": { "agent_ids": [], "include_addons": None, diff --git a/tests/components/backup/test_util.py b/tests/components/backup/test_util.py index 60cfc77b1aa..db759805c8f 100644 --- a/tests/components/backup/test_util.py +++ b/tests/components/backup/test_util.py @@ -2,13 +2,24 @@ from __future__ import annotations +from collections.abc import AsyncIterator +import dataclasses import tarfile from unittest.mock import Mock, patch import pytest +import securetar -from homeassistant.components.backup import AddonInfo, AgentBackup, Folder -from homeassistant.components.backup.util import read_backup, validate_password +from homeassistant.components.backup import DOMAIN, AddonInfo, AgentBackup, Folder +from homeassistant.components.backup.util import ( + DecryptedBackupStreamer, + EncryptedBackupStreamer, + read_backup, + validate_password, +) +from homeassistant.core import HomeAssistant + +from tests.common import get_fixture_path @pytest.mark.parametrize( @@ -130,3 +141,246 @@ def test_validate_password_no_homeassistant() -> None: KeyError ) assert validate_password(mock_path, "hunter2") is False + + +async def test_decrypted_backup_streamer(hass: HomeAssistant) -> None: + """Test the decrypted backup streamer.""" + decrypted_backup_path = get_fixture_path( + "test_backups/c0cb53bd.tar.decrypted", DOMAIN + ) + encrypted_backup_path = get_fixture_path("test_backups/c0cb53bd.tar", DOMAIN) + backup = AgentBackup( + addons=["addon_1", "addon_2"], + backup_id="1234", + date="2024-12-02T07:23:58.261875-05:00", + database_included=False, + extra_metadata={}, + folders=[], + homeassistant_included=True, + homeassistant_version="2024.12.0.dev0", + name="test", + protected=True, + size=encrypted_backup_path.stat().st_size, + ) + expected_padding = b"\0" * 40960 # 4 x 10240 byte of padding + + async def send_backup() -> AsyncIterator[bytes]: + f = encrypted_backup_path.open("rb") + while chunk := f.read(1024): + yield chunk + + async def open_backup() -> AsyncIterator[bytes]: + return send_backup() + + decryptor = DecryptedBackupStreamer(hass, backup, open_backup, "hunter2") + assert decryptor.backup() == dataclasses.replace( + backup, protected=False, size=backup.size + len(expected_padding) + ) + decrypted_stream = await decryptor.open_stream() + decrypted_output = b"" + async for chunk in decrypted_stream: + decrypted_output += chunk + await decryptor.wait() + + # Expect the output to match the stored decrypted backup file, with additional + # padding. + decrypted_backup_data = decrypted_backup_path.read_bytes() + assert decrypted_output == decrypted_backup_data + expected_padding + + +async def test_decrypted_backup_streamer_wrong_password(hass: HomeAssistant) -> None: + """Test the decrypted backup streamer with wrong password.""" + encrypted_backup_path = get_fixture_path("test_backups/c0cb53bd.tar", DOMAIN) + backup = AgentBackup( + addons=["addon_1", "addon_2"], + backup_id="1234", + date="2024-12-02T07:23:58.261875-05:00", + database_included=False, + extra_metadata={}, + folders=[], + homeassistant_included=True, + homeassistant_version="2024.12.0.dev0", + name="test", + protected=True, + size=encrypted_backup_path.stat().st_size, + ) + + async def send_backup() -> AsyncIterator[bytes]: + f = encrypted_backup_path.open("rb") + while chunk := f.read(1024): + yield chunk + + async def open_backup() -> AsyncIterator[bytes]: + return send_backup() + + decryptor = DecryptedBackupStreamer(hass, backup, open_backup, "wrong_password") + decrypted_stream = await decryptor.open_stream() + async for _ in decrypted_stream: + pass + + await decryptor.wait() + assert isinstance(decryptor._workers[0].error, securetar.SecureTarReadError) + + +async def test_encrypted_backup_streamer(hass: HomeAssistant) -> None: + """Test the encrypted backup streamer.""" + decrypted_backup_path = get_fixture_path( + "test_backups/c0cb53bd.tar.decrypted", DOMAIN + ) + encrypted_backup_path = get_fixture_path("test_backups/c0cb53bd.tar", DOMAIN) + backup = AgentBackup( + addons=["addon_1", "addon_2"], + backup_id="1234", + date="2024-12-02T07:23:58.261875-05:00", + database_included=False, + extra_metadata={}, + folders=[], + homeassistant_included=True, + homeassistant_version="2024.12.0.dev0", + name="test", + protected=False, + size=decrypted_backup_path.stat().st_size, + ) + expected_padding = b"\0" * 40960 # 4 x 10240 byte of padding + + async def send_backup() -> AsyncIterator[bytes]: + f = decrypted_backup_path.open("rb") + while chunk := f.read(1024): + yield chunk + + async def open_backup() -> AsyncIterator[bytes]: + return send_backup() + + # Patch os.urandom to return values matching the nonce used in the encrypted + # test backup. The backup has three inner tar files, but we need an extra nonce + # for a future planned supervisor.tar. + with patch("os.urandom") as mock_randbytes: + mock_randbytes.side_effect = ( + bytes.fromhex("bd34ea6fc93b0614ce7af2b44b4f3957"), + bytes.fromhex("1296d6f7554e2cb629a3dc4082bae36c"), + bytes.fromhex("8b7a58e48faf2efb23845eb3164382e0"), + bytes.fromhex("00000000000000000000000000000000"), + ) + encryptor = EncryptedBackupStreamer(hass, backup, open_backup, "hunter2") + assert encryptor.backup() == dataclasses.replace( + backup, protected=True, size=backup.size + len(expected_padding) + ) + + encrypted_stream = await encryptor.open_stream() + encrypted_output = b"" + async for chunk in encrypted_stream: + encrypted_output += chunk + await encryptor.wait() + + # Expect the output to match the stored encrypted backup file, with additional + # padding. + encrypted_backup_data = encrypted_backup_path.read_bytes() + assert encrypted_output == encrypted_backup_data + expected_padding + + +async def test_encrypted_backup_streamer_random_nonce(hass: HomeAssistant) -> None: + """Test the encrypted backup streamer.""" + decrypted_backup_path = get_fixture_path( + "test_backups/c0cb53bd.tar.decrypted", DOMAIN + ) + encrypted_backup_path = get_fixture_path("test_backups/c0cb53bd.tar", DOMAIN) + backup = AgentBackup( + addons=["addon_1", "addon_2"], + backup_id="1234", + date="2024-12-02T07:23:58.261875-05:00", + database_included=False, + extra_metadata={}, + folders=[], + homeassistant_included=True, + homeassistant_version="2024.12.0.dev0", + name="test", + protected=False, + size=decrypted_backup_path.stat().st_size, + ) + + async def send_backup() -> AsyncIterator[bytes]: + f = decrypted_backup_path.open("rb") + while chunk := f.read(1024): + yield chunk + + async def open_backup() -> AsyncIterator[bytes]: + return send_backup() + + encryptor1 = EncryptedBackupStreamer(hass, backup, open_backup, "hunter2") + encryptor2 = EncryptedBackupStreamer(hass, backup, open_backup, "hunter2") + + async def read_stream(stream: AsyncIterator[bytes]) -> bytes: + output = b"" + async for chunk in stream: + output += chunk + return output + + # When reading twice from the same streamer, the same nonce is used. + encrypted_output1 = await read_stream(await encryptor1.open_stream()) + encrypted_output2 = await read_stream(await encryptor1.open_stream()) + assert encrypted_output1 == encrypted_output2 + + encrypted_output3 = await read_stream(await encryptor2.open_stream()) + encrypted_output4 = await read_stream(await encryptor2.open_stream()) + assert encrypted_output3 == encrypted_output4 + + # Wait for workers to terminate + await encryptor1.wait() + await encryptor2.wait() + + # Output from the two streames should differ but have the same length. + assert encrypted_output1 != encrypted_output3 + assert len(encrypted_output1) == len(encrypted_output3) + + # Expect the output length to match the stored encrypted backup file, with + # additional padding. + encrypted_backup_data = encrypted_backup_path.read_bytes() + # 4 x 10240 byte of padding + assert len(encrypted_output1) == len(encrypted_backup_data) + 40960 + assert encrypted_output1[: len(encrypted_backup_data)] != encrypted_backup_data + + +async def test_encrypted_backup_streamer_error(hass: HomeAssistant) -> None: + """Test the encrypted backup streamer.""" + decrypted_backup_path = get_fixture_path( + "test_backups/c0cb53bd.tar.decrypted", DOMAIN + ) + backup = AgentBackup( + addons=["addon_1", "addon_2"], + backup_id="1234", + date="2024-12-02T07:23:58.261875-05:00", + database_included=False, + extra_metadata={}, + folders=[], + homeassistant_included=True, + homeassistant_version="2024.12.0.dev0", + name="test", + protected=False, + size=decrypted_backup_path.stat().st_size, + ) + + async def send_backup() -> AsyncIterator[bytes]: + f = decrypted_backup_path.open("rb") + while chunk := f.read(1024): + yield chunk + + async def open_backup() -> AsyncIterator[bytes]: + return send_backup() + + # Patch os.urandom to return values matching the nonce used in the encrypted + # test backup. The backup has three inner tar files, but we need an extra nonce + # for a future planned supervisor.tar. + encryptor = EncryptedBackupStreamer(hass, backup, open_backup, "hunter2") + + with patch( + "homeassistant.components.backup.util.tarfile.open", + side_effect=tarfile.TarError, + ): + encrypted_stream = await encryptor.open_stream() + async for _ in encrypted_stream: + pass + + # Expect the output to match the stored encrypted backup file, with additional + # padding. + await encryptor.wait() + assert isinstance(encryptor._workers[0].error, tarfile.TarError) diff --git a/tests/components/backup/test_websocket.py b/tests/components/backup/test_websocket.py index 0fd0ba308b3..613c0b69b6b 100644 --- a/tests/components/backup/test_websocket.py +++ b/tests/components/backup/test_websocket.py @@ -56,6 +56,7 @@ BACKUP_CALL = call( DEFAULT_STORAGE_DATA: dict[str, Any] = { "backups": [], "config": { + "agents": {}, "create_backup": { "agent_ids": [], "include_addons": None, @@ -587,6 +588,8 @@ async def test_generate_with_default_settings_calls_create( last_completed_automatic_backup: str, ) -> None: """Test backup/generate_with_automatic_settings calls async_initiate_backup.""" + created_backup: MagicMock = create_backup.return_value[1].result().backup + created_backup.protected = create_backup_settings["password"] is not None client = await hass_ws_client(hass) await hass.config.async_set_time_zone("Europe/Amsterdam") freezer.move_to("2024-11-13T12:01:00+01:00") @@ -913,6 +916,7 @@ async def test_agents_info( "data": { "backups": [], "config": { + "agents": {}, "create_backup": { "agent_ids": ["test-agent"], "include_addons": ["test-addon"], @@ -943,6 +947,7 @@ async def test_agents_info( "data": { "backups": [], "config": { + "agents": {}, "create_backup": { "agent_ids": ["test-agent"], "include_addons": None, @@ -973,6 +978,7 @@ async def test_agents_info( "data": { "backups": [], "config": { + "agents": {}, "create_backup": { "agent_ids": ["test-agent"], "include_addons": None, @@ -1003,6 +1009,7 @@ async def test_agents_info( "data": { "backups": [], "config": { + "agents": {}, "create_backup": { "agent_ids": ["test-agent"], "include_addons": None, @@ -1033,6 +1040,7 @@ async def test_agents_info( "data": { "backups": [], "config": { + "agents": {}, "create_backup": { "agent_ids": ["test-agent"], "include_addons": None, @@ -1063,6 +1071,41 @@ async def test_agents_info( "data": { "backups": [], "config": { + "agents": {}, + "create_backup": { + "agent_ids": ["test-agent"], + "include_addons": None, + "include_all_addons": False, + "include_database": False, + "include_folders": None, + "name": None, + "password": None, + }, + "retention": {"copies": None, "days": None}, + "last_attempted_automatic_backup": None, + "last_completed_automatic_backup": None, + "schedule": { + "days": ["mon", "sun"], + "recurrence": "custom_days", + "state": "never", + "time": None, + }, + }, + }, + "key": DOMAIN, + "version": store.STORAGE_VERSION, + "minor_version": store.STORAGE_VERSION_MINOR, + }, + }, + { + "backup": { + "data": { + "backups": [], + "config": { + "agents": { + "test-agent1": {"protected": True}, + "test-agent2": {"protected": False}, + }, "create_backup": { "agent_ids": ["test-agent"], "include_addons": None, @@ -1115,80 +1158,130 @@ async def test_config_info( @pytest.mark.usefixtures("create_backup", "delete_backup", "get_backups") @pytest.mark.parametrize( - "command", + "commands", [ - { - "type": "backup/config/update", - "create_backup": {"agent_ids": ["test-agent"]}, - "retention": {"copies": None, "days": 7}, - }, - { - "type": "backup/config/update", - "create_backup": {"agent_ids": ["test-agent"]}, - "schedule": {"recurrence": "daily", "time": "06:00"}, - }, - { - "type": "backup/config/update", - "create_backup": {"agent_ids": ["test-agent"]}, - "schedule": {"days": ["mon"], "recurrence": "custom_days"}, - }, - { - "type": "backup/config/update", - "create_backup": {"agent_ids": ["test-agent"]}, - "schedule": {"recurrence": "never"}, - }, - { - "type": "backup/config/update", - "create_backup": {"agent_ids": ["test-agent"]}, - "schedule": {"days": ["mon", "sun"], "recurrence": "custom_days"}, - }, - { - "type": "backup/config/update", - "create_backup": { - "agent_ids": ["test-agent"], - "include_addons": ["test-addon"], - "include_folders": ["media"], - "name": "test-name", - "password": "test-password", + [ + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test-agent"]}, + "retention": {"copies": None, "days": 7}, + } + ], + [ + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test-agent"]}, + "schedule": {"recurrence": "daily", "time": "06:00"}, + } + ], + [ + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test-agent"]}, + "schedule": {"days": ["mon"], "recurrence": "custom_days"}, + } + ], + [ + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test-agent"]}, + "schedule": {"recurrence": "never"}, + } + ], + [ + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test-agent"]}, + "schedule": {"days": ["mon", "sun"], "recurrence": "custom_days"}, + } + ], + [ + { + "type": "backup/config/update", + "create_backup": { + "agent_ids": ["test-agent"], + "include_addons": ["test-addon"], + "include_folders": ["media"], + "name": "test-name", + "password": "test-password", + }, + "schedule": {"recurrence": "daily"}, + } + ], + [ + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test-agent"]}, + "retention": {"copies": 3, "days": 7}, + "schedule": {"recurrence": "daily"}, + } + ], + [ + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test-agent"]}, + "retention": {"copies": None, "days": None}, + "schedule": {"recurrence": "daily"}, + } + ], + [ + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test-agent"]}, + "retention": {"copies": 3, "days": None}, + "schedule": {"recurrence": "daily"}, + } + ], + [ + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test-agent"]}, + "retention": {"copies": None, "days": 7}, + "schedule": {"recurrence": "daily"}, + } + ], + [ + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test-agent"]}, + "retention": {"copies": 3}, + "schedule": {"recurrence": "daily"}, + } + ], + [ + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test-agent"]}, + "retention": {"days": 7}, + "schedule": {"recurrence": "daily"}, + } + ], + [ + { + "type": "backup/config/update", + "agents": { + "test-agent1": {"protected": True}, + "test-agent2": {"protected": False}, + }, + } + ], + [ + # Test we can update AgentConfig + { + "type": "backup/config/update", + "agents": { + "test-agent1": {"protected": True}, + "test-agent2": {"protected": False}, + }, }, - "schedule": {"recurrence": "daily"}, - }, - { - "type": "backup/config/update", - "create_backup": {"agent_ids": ["test-agent"]}, - "retention": {"copies": 3, "days": 7}, - "schedule": {"recurrence": "daily"}, - }, - { - "type": "backup/config/update", - "create_backup": {"agent_ids": ["test-agent"]}, - "retention": {"copies": None, "days": None}, - "schedule": {"recurrence": "daily"}, - }, - { - "type": "backup/config/update", - "create_backup": {"agent_ids": ["test-agent"]}, - "retention": {"copies": 3, "days": None}, - "schedule": {"recurrence": "daily"}, - }, - { - "type": "backup/config/update", - "create_backup": {"agent_ids": ["test-agent"]}, - "retention": {"copies": None, "days": 7}, - "schedule": {"recurrence": "daily"}, - }, - { - "type": "backup/config/update", - "create_backup": {"agent_ids": ["test-agent"]}, - "retention": {"copies": 3}, - "schedule": {"recurrence": "daily"}, - }, - { - "type": "backup/config/update", - "create_backup": {"agent_ids": ["test-agent"]}, - "retention": {"days": 7}, - "schedule": {"recurrence": "daily"}, - }, + { + "type": "backup/config/update", + "agents": { + "test-agent1": {"protected": False}, + "test-agent2": {"protected": True}, + }, + }, + ], ], ) @patch("homeassistant.components.backup.config.random.randint", Mock(return_value=600)) @@ -1197,7 +1290,7 @@ async def test_config_update( hass_ws_client: WebSocketGenerator, freezer: FrozenDateTimeFactory, snapshot: SnapshotAssertion, - command: dict[str, Any], + commands: dict[str, Any], hass_storage: dict[str, Any], ) -> None: """Test updating the backup config.""" @@ -1211,14 +1304,14 @@ async def test_config_update( await client.send_json_auto_id({"type": "backup/config/info"}) assert await client.receive_json() == snapshot - await client.send_json_auto_id(command) - result = await client.receive_json() + for command in commands: + await client.send_json_auto_id(command) + result = await client.receive_json() + assert result["success"] - assert result["success"] - - await client.send_json_auto_id({"type": "backup/config/info"}) - assert await client.receive_json() == snapshot - await hass.async_block_till_done() + await client.send_json_auto_id({"type": "backup/config/info"}) + assert await client.receive_json() == snapshot + await hass.async_block_till_done() # Trigger store write freezer.tick(60) @@ -1274,6 +1367,10 @@ async def test_config_update( "type": "backup/config/update", "create_backup": {"include_folders": ["media", "media"]}, }, + { + "type": "backup/config/update", + "agents": {"test-agent1": {"favorite": True}}, + }, ], ) async def test_config_update_errors( @@ -1600,10 +1697,14 @@ async def test_config_schedule_logic( create_backup_side_effect: list[Exception | None] | None, ) -> None: """Test config schedule logic.""" + created_backup: MagicMock = create_backup.return_value[1].result().backup + created_backup.protected = True + client = await hass_ws_client(hass) storage_data = { "backups": [], "config": { + "agents": {}, "create_backup": { "agent_ids": ["test.test-agent"], "include_addons": ["test-addon"], @@ -2057,10 +2158,14 @@ async def test_config_retention_copies_logic( delete_args_list: Any, ) -> None: """Test config backup retention copies logic.""" + created_backup: MagicMock = create_backup.return_value[1].result().backup + created_backup.protected = True + client = await hass_ws_client(hass) storage_data = { "backups": [], "config": { + "agents": {}, "create_backup": { "agent_ids": ["test-agent"], "include_addons": ["test-addon"], @@ -2320,10 +2425,14 @@ async def test_config_retention_copies_logic_manual_backup( delete_args_list: Any, ) -> None: """Test config backup retention copies logic for manual backup.""" + created_backup: MagicMock = create_backup.return_value[1].result().backup + created_backup.protected = True + client = await hass_ws_client(hass) storage_data = { "backups": [], "config": { + "agents": {}, "create_backup": { "agent_ids": ["test-agent"], "include_addons": ["test-addon"], @@ -2750,6 +2859,7 @@ async def test_config_retention_days_logic( storage_data = { "backups": [], "config": { + "agents": {}, "create_backup": { "agent_ids": ["test-agent"], "include_addons": ["test-addon"], diff --git a/tests/components/cloud/test_backup.py b/tests/components/cloud/test_backup.py index 373bd164c0c..516dacd5f3d 100644 --- a/tests/components/cloud/test_backup.py +++ b/tests/components/cloud/test_backup.py @@ -170,6 +170,7 @@ async def test_agents_list_backups( assert response["result"]["backups"] == [ { "addons": [], + "agents": {"cloud.cloud": {"protected": False, "size": 34519040}}, "backup_id": "23e64aec", "date": "2024-11-22T11:48:48.727189+01:00", "database_included": True, @@ -177,9 +178,6 @@ async def test_agents_list_backups( "homeassistant_included": True, "homeassistant_version": "2024.12.0.dev0", "name": "Core 2024.12.0.dev0", - "protected": False, - "size": 34519040, - "agent_ids": ["cloud.cloud"], "failed_agent_ids": [], "with_automatic_settings": None, } @@ -219,6 +217,7 @@ async def test_agents_list_backups_fail_cloud( "23e64aec", { "addons": [], + "agents": {"cloud.cloud": {"protected": False, "size": 34519040}}, "backup_id": "23e64aec", "date": "2024-11-22T11:48:48.727189+01:00", "database_included": True, @@ -226,9 +225,6 @@ async def test_agents_list_backups_fail_cloud( "homeassistant_included": True, "homeassistant_version": "2024.12.0.dev0", "name": "Core 2024.12.0.dev0", - "protected": False, - "size": 34519040, - "agent_ids": ["cloud.cloud"], "failed_agent_ids": [], "with_automatic_settings": None, }, diff --git a/tests/components/google_drive/test_backup.py b/tests/components/google_drive/test_backup.py index 765f6bba887..62b7930012c 100644 --- a/tests/components/google_drive/test_backup.py +++ b/tests/components/google_drive/test_backup.py @@ -43,6 +43,7 @@ TEST_AGENT_BACKUP = AgentBackup( ) TEST_AGENT_BACKUP_RESULT = { "addons": [{"name": "Test", "slug": "test", "version": "1.0.0"}], + "agents": {TEST_AGENT_ID: {"protected": False, "size": 987}}, "backup_id": "test-backup", "database_included": True, "date": "2025-01-01T01:23:45.678Z", @@ -50,9 +51,6 @@ TEST_AGENT_BACKUP_RESULT = { "homeassistant_included": True, "homeassistant_version": "2024.12.0", "name": "Test", - "protected": False, - "size": 987, - "agent_ids": [TEST_AGENT_ID], "failed_agent_ids": [], "with_automatic_settings": None, } diff --git a/tests/components/hassio/test_backup.py b/tests/components/hassio/test_backup.py index 1a5701a79cf..1c257416ad0 100644 --- a/tests/components/hassio/test_backup.py +++ b/tests/components/hassio/test_backup.py @@ -407,7 +407,7 @@ async def test_agent_info( "addons": [ {"name": "Terminal & SSH", "slug": "core_ssh", "version": "9.14.0"} ], - "agent_ids": ["hassio.local"], + "agents": {"hassio.local": {"protected": False, "size": 1048576}}, "backup_id": "abc123", "database_included": True, "date": "1970-01-01T00:00:00+00:00", @@ -416,8 +416,6 @@ async def test_agent_info( "homeassistant_included": True, "homeassistant_version": "2024.12.0", "name": "Test", - "protected": False, - "size": 1048576, "with_automatic_settings": None, }, ), @@ -428,7 +426,7 @@ async def test_agent_info( "addons": [ {"name": "Terminal & SSH", "slug": "core_ssh", "version": "9.14.0"} ], - "agent_ids": ["hassio.local"], + "agents": {"hassio.local": {"protected": False, "size": 1048576}}, "backup_id": "abc123", "database_included": False, "date": "1970-01-01T00:00:00+00:00", @@ -437,8 +435,6 @@ async def test_agent_info( "homeassistant_included": False, "homeassistant_version": None, "name": "Test", - "protected": False, - "size": 1048576, "with_automatic_settings": None, }, ), diff --git a/tests/components/kitchen_sink/test_backup.py b/tests/components/kitchen_sink/test_backup.py index 827bde39d7d..a664b91393d 100644 --- a/tests/components/kitchen_sink/test_backup.py +++ b/tests/components/kitchen_sink/test_backup.py @@ -102,7 +102,7 @@ async def test_agents_list_backups( assert response["result"]["backups"] == [ { "addons": [{"name": "Test", "slug": "test", "version": "1.0.0"}], - "agent_ids": ["kitchen_sink.syncer"], + "agents": {"kitchen_sink.syncer": {"protected": False, "size": 1234}}, "backup_id": "abc123", "database_included": False, "date": "1970-01-01T00:00:00Z", @@ -111,8 +111,6 @@ async def test_agents_list_backups( "homeassistant_included": True, "homeassistant_version": "2024.12.0", "name": "Kitchen sink syncer", - "protected": False, - "size": 1234, "with_automatic_settings": None, } ] @@ -185,7 +183,7 @@ async def test_agents_upload( assert len(backup_list) == 2 assert backup_list[1] == { "addons": [{"name": "Test", "slug": "test", "version": "1.0.0"}], - "agent_ids": ["kitchen_sink.syncer"], + "agents": {"kitchen_sink.syncer": {"protected": False, "size": 0.0}}, "backup_id": "test-backup", "database_included": True, "date": "1970-01-01T00:00:00.000Z", @@ -194,8 +192,6 @@ async def test_agents_upload( "homeassistant_included": True, "homeassistant_version": "2024.12.0", "name": "Test", - "protected": False, - "size": 0.0, "with_automatic_settings": False, } diff --git a/tests/components/onedrive/test_backup.py b/tests/components/onedrive/test_backup.py index a3cfbe95a46..a3d1129377f 100644 --- a/tests/components/onedrive/test_backup.py +++ b/tests/components/onedrive/test_backup.py @@ -81,6 +81,9 @@ async def test_agents_list_backups( assert response["result"]["backups"] == [ { "addons": [], + "agents": { + "onedrive.mock_drive_id": {"protected": False, "size": 34519040} + }, "backup_id": "23e64aec", "date": "2024-11-22T11:48:48.727189+01:00", "database_included": True, @@ -88,9 +91,6 @@ async def test_agents_list_backups( "homeassistant_included": True, "homeassistant_version": "2024.12.0.dev0", "name": "Core 2024.12.0.dev0", - "protected": False, - "size": 34519040, - "agent_ids": [f"{DOMAIN}.{mock_config_entry.unique_id}"], "failed_agent_ids": [], "with_automatic_settings": None, } @@ -117,6 +117,12 @@ async def test_agents_get_backup( assert response["result"]["agent_errors"] == {} assert response["result"]["backup"] == { "addons": [], + "agents": { + f"{DOMAIN}.{mock_config_entry.unique_id}": { + "protected": False, + "size": 34519040, + } + }, "backup_id": "23e64aec", "date": "2024-11-22T11:48:48.727189+01:00", "database_included": True, @@ -124,9 +130,6 @@ async def test_agents_get_backup( "homeassistant_included": True, "homeassistant_version": "2024.12.0.dev0", "name": "Core 2024.12.0.dev0", - "protected": False, - "size": 34519040, - "agent_ids": [f"{DOMAIN}.{mock_config_entry.unique_id}"], "failed_agent_ids": [], "with_automatic_settings": None, } diff --git a/tests/components/synology_dsm/test_backup.py b/tests/components/synology_dsm/test_backup.py index 436e3666176..0d4fd0dc080 100644 --- a/tests/components/synology_dsm/test_backup.py +++ b/tests/components/synology_dsm/test_backup.py @@ -290,6 +290,12 @@ async def test_agents_list_backups( assert response["result"]["backups"] == [ { "addons": [], + "agents": { + "synology_dsm.mocked_syno_dsm_entry": { + "protected": True, + "size": 13916160, + } + }, "backup_id": "abcd12ef", "date": "2025-01-09T20:14:35.457323+01:00", "database_included": True, @@ -297,9 +303,6 @@ async def test_agents_list_backups( "homeassistant_included": True, "homeassistant_version": "2025.2.0.dev0", "name": "Automatic backup 2025.2.0.dev0", - "protected": True, - "size": 13916160, - "agent_ids": ["synology_dsm.mocked_syno_dsm_entry"], "failed_agent_ids": [], "with_automatic_settings": None, } @@ -355,6 +358,12 @@ async def test_agents_list_backups_disabled_filestation( "abcd12ef", { "addons": [], + "agents": { + "synology_dsm.mocked_syno_dsm_entry": { + "protected": True, + "size": 13916160, + } + }, "backup_id": "abcd12ef", "date": "2025-01-09T20:14:35.457323+01:00", "database_included": True, @@ -362,9 +371,6 @@ async def test_agents_list_backups_disabled_filestation( "homeassistant_included": True, "homeassistant_version": "2025.2.0.dev0", "name": "Automatic backup 2025.2.0.dev0", - "protected": True, - "size": 13916160, - "agent_ids": ["synology_dsm.mocked_syno_dsm_entry"], "failed_agent_ids": [], "with_automatic_settings": None, }, From 32829596ebce2ba87f7d0ff7f987a728795886b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Wed, 29 Jan 2025 14:17:00 +0100 Subject: [PATCH 0239/3148] Add select platform discovery schemas for the Matter LaundryWasherControls cluster (#136261) --- homeassistant/components/matter/icons.json | 3 + homeassistant/components/matter/select.py | 95 ++++++++++++++- homeassistant/components/matter/strings.json | 12 ++ .../fixtures/nodes/silabs_laundrywasher.json | 2 +- .../matter/snapshots/test_select.ambr | 114 ++++++++++++++++++ tests/components/matter/test_select.py | 54 +++++++++ 6 files changed, 274 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/matter/icons.json b/homeassistant/components/matter/icons.json index 4f3e532d877..f9217cabcc4 100644 --- a/homeassistant/components/matter/icons.json +++ b/homeassistant/components/matter/icons.json @@ -37,6 +37,9 @@ } }, "select": { + "laundry_washer_spin_speed": { + "default": "mdi:reload" + }, "temperature_level": { "default": "mdi:thermometer" } diff --git a/homeassistant/components/matter/select.py b/homeassistant/components/matter/select.py index b10f4e0e484..ab3e708d7a9 100644 --- a/homeassistant/components/matter/select.py +++ b/homeassistant/components/matter/select.py @@ -20,6 +20,17 @@ from .entity import MatterEntity, MatterEntityDescription from .helpers import get_matter from .models import MatterDiscoverySchema +NUMBER_OF_RINSES_STATE_MAP = { + clusters.LaundryWasherControls.Enums.NumberOfRinsesEnum.kNone: "off", + clusters.LaundryWasherControls.Enums.NumberOfRinsesEnum.kNormal: "normal", + clusters.LaundryWasherControls.Enums.NumberOfRinsesEnum.kExtra: "extra", + clusters.LaundryWasherControls.Enums.NumberOfRinsesEnum.kMax: "max", + clusters.LaundryWasherControls.Enums.NumberOfRinsesEnum.kUnknownEnumValue: None, +} +NUMBER_OF_RINSES_STATE_MAP_REVERSE = { + v: k for k, v in NUMBER_OF_RINSES_STATE_MAP.items() +} + type SelectCluster = ( clusters.ModeSelect | clusters.OvenMode @@ -48,15 +59,27 @@ class MatterSelectEntityDescription(SelectEntityDescription, MatterEntityDescrip """Describe Matter select entities.""" +@dataclass(frozen=True, kw_only=True) +class MatterMapSelectEntityDescription(MatterSelectEntityDescription): + """Describe Matter select entities for MatterMapSelectEntityDescription.""" + + measurement_to_ha: Callable[[int], str | None] + ha_to_native_value: Callable[[str], int | None] + + # list attribute: the attribute descriptor to get the list of values (= list of integers) + list_attribute: type[ClusterAttributeDescriptor] + + @dataclass(frozen=True, kw_only=True) class MatterListSelectEntityDescription(MatterSelectEntityDescription): """Describe Matter select entities for MatterListSelectEntity.""" - # command: a callback to create the command to send to the device - # the callback's argument will be the index of the selected list value - command: Callable[[int], ClusterCommand] # list attribute: the attribute descriptor to get the list of values (= list of strings) list_attribute: type[ClusterAttributeDescriptor] + # command: a custom callback to create the command to send to the device + # the callback's argument will be the index of the selected list value + # if omitted the command will just be a write_attribute command to the primary attribute + command: Callable[[int], ClusterCommand] | None = None class MatterAttributeSelectEntity(MatterEntity, SelectEntity): @@ -84,6 +107,29 @@ class MatterAttributeSelectEntity(MatterEntity, SelectEntity): self._attr_current_option = value_convert(value) +class MatterMapSelectEntity(MatterAttributeSelectEntity): + """Representation of a Matter select entity where the options are defined in a State map.""" + + entity_description: MatterMapSelectEntityDescription + + @callback + def _update_from_device(self) -> None: + """Update from device.""" + # the options can dynamically change based on the state of the device + available_values = cast( + list[int], + self.get_matter_attribute_value(self.entity_description.list_attribute), + ) + # map available (int) values to string representation + self._attr_options = [ + mapped_value + for value in available_values + if (mapped_value := self.entity_description.measurement_to_ha(value)) + ] + # use base implementation from MatterAttributeSelectEntity to set the current option + super()._update_from_device() + + class MatterModeSelectEntity(MatterAttributeSelectEntity): """Representation of a select entity from Matter (Mode) Cluster attribute(s).""" @@ -125,8 +171,19 @@ class MatterListSelectEntity(MatterEntity, SelectEntity): async def async_select_option(self, option: str) -> None: """Change the selected option.""" option_id = self._attr_options.index(option) - await self.send_device_command( - self.entity_description.command(option_id), + + if TYPE_CHECKING: + assert option_id is not None + + if self.entity_description.command: + # custom command defined to set the new value + await self.send_device_command( + self.entity_description.command(option_id), + ) + return + # regular write attribute to set the new value + await self.write_attribute( + value=option_id, ) @callback @@ -328,4 +385,32 @@ DISCOVERY_SCHEMAS = [ clusters.TemperatureControl.Attributes.SupportedTemperatureLevels, ), ), + MatterDiscoverySchema( + platform=Platform.SELECT, + entity_description=MatterListSelectEntityDescription( + key="LaundryWasherControlsSpinSpeed", + translation_key="laundry_washer_spin_speed", + list_attribute=clusters.LaundryWasherControls.Attributes.SpinSpeeds, + ), + entity_class=MatterListSelectEntity, + required_attributes=( + clusters.LaundryWasherControls.Attributes.SpinSpeedCurrent, + clusters.LaundryWasherControls.Attributes.SpinSpeeds, + ), + ), + MatterDiscoverySchema( + platform=Platform.SELECT, + entity_description=MatterMapSelectEntityDescription( + key="MatterLaundryWasherNumberOfRinses", + translation_key="laundry_washer_number_of_rinses", + list_attribute=clusters.LaundryWasherControls.Attributes.SupportedRinses, + measurement_to_ha=NUMBER_OF_RINSES_STATE_MAP.get, + ha_to_native_value=NUMBER_OF_RINSES_STATE_MAP_REVERSE.get, + ), + entity_class=MatterMapSelectEntity, + required_attributes=( + clusters.LaundryWasherControls.Attributes.NumberOfRinses, + clusters.LaundryWasherControls.Attributes.SupportedRinses, + ), + ), ] diff --git a/homeassistant/components/matter/strings.json b/homeassistant/components/matter/strings.json index 73ce41937fd..f1a123c61be 100644 --- a/homeassistant/components/matter/strings.json +++ b/homeassistant/components/matter/strings.json @@ -205,6 +205,18 @@ }, "temperature_display_mode": { "name": "Temperature display mode" + }, + "laundry_washer_number_of_rinses": { + "name": "Number of rinses", + "state": { + "off": "[%key:common::state::off%]", + "normal": "Normal", + "extra": "Extra", + "max": "Max" + } + }, + "laundry_washer_spin_speed": { + "name": "Spin speed" } }, "sensor": { diff --git a/tests/components/matter/fixtures/nodes/silabs_laundrywasher.json b/tests/components/matter/fixtures/nodes/silabs_laundrywasher.json index a91584d7212..3b1ed0043de 100644 --- a/tests/components/matter/fixtures/nodes/silabs_laundrywasher.json +++ b/tests/components/matter/fixtures/nodes/silabs_laundrywasher.json @@ -656,7 +656,7 @@ "1/83/0": ["Off", "Low", "Medium", "High"], "1/83/1": 0, "1/83/2": 0, - "1/83/3": [1, 2], + "1/83/3": [0, 1], "1/83/65532": 3, "1/83/65533": 1, "1/83/65528": [], diff --git a/tests/components/matter/snapshots/test_select.ambr b/tests/components/matter/snapshots/test_select.ambr index 9a2639ba7e1..e9aa169b4fd 100644 --- a/tests/components/matter/snapshots/test_select.ambr +++ b/tests/components/matter/snapshots/test_select.ambr @@ -1620,6 +1620,120 @@ 'state': 'unknown', }) # --- +# name: test_selects[silabs_laundrywasher][select.laundrywasher_number_of_rinses-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'off', + 'normal', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.laundrywasher_number_of_rinses', + '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': 'Number of rinses', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'laundry_washer_number_of_rinses', + 'unique_id': '00000000000004D2-000000000000001D-MatterNodeDevice-1-MatterLaundryWasherNumberOfRinses-83-2', + 'unit_of_measurement': None, + }) +# --- +# name: test_selects[silabs_laundrywasher][select.laundrywasher_number_of_rinses-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'LaundryWasher Number of rinses', + 'options': list([ + 'off', + 'normal', + ]), + }), + 'context': , + 'entity_id': 'select.laundrywasher_number_of_rinses', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_selects[silabs_laundrywasher][select.laundrywasher_spin_speed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'Off', + 'Low', + 'Medium', + 'High', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.laundrywasher_spin_speed', + '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': 'Spin speed', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'laundry_washer_spin_speed', + 'unique_id': '00000000000004D2-000000000000001D-MatterNodeDevice-1-LaundryWasherControlsSpinSpeed-83-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_selects[silabs_laundrywasher][select.laundrywasher_spin_speed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'LaundryWasher Spin speed', + 'options': list([ + 'Off', + 'Low', + 'Medium', + 'High', + ]), + }), + 'context': , + 'entity_id': 'select.laundrywasher_spin_speed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Off', + }) +# --- # name: test_selects[silabs_laundrywasher][select.laundrywasher_temperature_level-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/matter/test_select.py b/tests/components/matter/test_select.py index 3643aa83fca..2403b4b1623 100644 --- a/tests/components/matter/test_select.py +++ b/tests/components/matter/test_select.py @@ -4,6 +4,7 @@ from unittest.mock import MagicMock, call from chip.clusters import Objects as clusters from matter_server.client.models.node import MatterNode +from matter_server.common.helpers.util import create_attribute_path_from_attribute import pytest from syrupy import SnapshotAssertion @@ -144,3 +145,56 @@ async def test_list_select_entities( await trigger_subscription_callback(hass, matter_client) state = hass.states.get("select.laundrywasher_temperature_level") assert state.state == "unknown" + + # SpinSpeedCurrent + matter_client.write_attribute.reset_mock() + state = hass.states.get("select.laundrywasher_spin_speed") + assert state + assert state.state == "Off" + assert state.attributes["options"] == ["Off", "Low", "Medium", "High"] + set_node_attribute(matter_node, 1, 83, 1, 3) + await trigger_subscription_callback(hass, matter_client) + state = hass.states.get("select.laundrywasher_spin_speed") + assert state.state == "High" + # test select option + await hass.services.async_call( + "select", + "select_option", + { + "entity_id": "select.laundrywasher_spin_speed", + "option": "High", + }, + blocking=True, + ) + assert matter_client.write_attribute.call_count == 1 + assert matter_client.write_attribute.call_args == call( + node_id=matter_node.node_id, + attribute_path=create_attribute_path_from_attribute( + endpoint_id=1, + attribute=clusters.LaundryWasherControls.Attributes.SpinSpeedCurrent, + ), + value=3, + ) + # test that an invalid value (e.g. 253) leads to an unknown state + set_node_attribute(matter_node, 1, 83, 1, 253) + await trigger_subscription_callback(hass, matter_client) + state = hass.states.get("select.laundrywasher_spin_speed") + assert state.state == "unknown" + + +@pytest.mark.parametrize("node_fixture", ["silabs_laundrywasher"]) +async def test_map_select_entities( + hass: HomeAssistant, + matter_client: MagicMock, + matter_node: MatterNode, +) -> None: + """Test MatterMapSelectEntity entities are discovered and working from a laundrywasher fixture.""" + # NumberOfRinses + state = hass.states.get("select.laundrywasher_number_of_rinses") + assert state + assert state.state == "off" + assert state.attributes["options"] == ["off", "normal"] + set_node_attribute(matter_node, 1, 83, 2, 1) + await trigger_subscription_callback(hass, matter_client) + state = hass.states.get("select.laundrywasher_number_of_rinses") + assert state.state == "normal" From d9deba3916f535af3b109465cb48a166ccf7ebba Mon Sep 17 00:00:00 2001 From: Simon Lamon <32477463+silamon@users.noreply.github.com> Date: Wed, 29 Jan 2025 14:25:28 +0100 Subject: [PATCH 0240/3148] Take exclude vias in unique ids for nmbs (#136590) --- homeassistant/components/nmbs/config_flow.py | 8 ++++--- homeassistant/components/nmbs/sensor.py | 15 +++++++++---- tests/components/nmbs/test_config_flow.py | 23 +++++++++++++++++++- 3 files changed, 38 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/nmbs/config_flow.py b/homeassistant/components/nmbs/config_flow.py index 24ef8cd4995..e45b2d9adeb 100644 --- a/homeassistant/components/nmbs/config_flow.py +++ b/homeassistant/components/nmbs/config_flow.py @@ -79,8 +79,9 @@ class NMBSConfigFlow(ConfigFlow, domain=DOMAIN): for station in self.stations if station["id"] == user_input[CONF_STATION_TO] ] + vias = "_excl_vias" if user_input.get(CONF_EXCLUDE_VIAS) else "" await self.async_set_unique_id( - f"{user_input[CONF_STATION_FROM]}_{user_input[CONF_STATION_TO]}" + f"{user_input[CONF_STATION_FROM]}_{user_input[CONF_STATION_TO]}{vias}" ) self._abort_if_unique_id_configured() @@ -154,12 +155,13 @@ class NMBSConfigFlow(ConfigFlow, domain=DOMAIN): user_input[CONF_STATION_LIVE] = station_live["id"] entity_registry = er.async_get(self.hass) prefix = "live" + vias = "_excl_vias" if user_input.get(CONF_EXCLUDE_VIAS, False) else "" if entity_id := entity_registry.async_get_entity_id( Platform.SENSOR, DOMAIN, f"{prefix}_{station_live['standardname']}_{station_from['standardname']}_{station_to['standardname']}", ): - new_unique_id = f"{DOMAIN}_{prefix}_{station_live['id']}_{station_from['id']}_{station_to['id']}" + new_unique_id = f"{DOMAIN}_{prefix}_{station_live['id']}_{station_from['id']}_{station_to['id']}{vias}" entity_registry.async_update_entity( entity_id, new_unique_id=new_unique_id ) @@ -168,7 +170,7 @@ class NMBSConfigFlow(ConfigFlow, domain=DOMAIN): DOMAIN, f"{prefix}_{station_live['name']}_{station_from['name']}_{station_to['name']}", ): - new_unique_id = f"{DOMAIN}_{prefix}_{station_live['id']}_{station_from['id']}_{station_to['id']}" + new_unique_id = f"{DOMAIN}_{prefix}_{station_live['id']}_{station_from['id']}_{station_to['id']}{vias}" entity_registry.async_update_entity( entity_id, new_unique_id=new_unique_id ) diff --git a/homeassistant/components/nmbs/sensor.py b/homeassistant/components/nmbs/sensor.py index ca18d3b1bbd..6d13777e10a 100644 --- a/homeassistant/components/nmbs/sensor.py +++ b/homeassistant/components/nmbs/sensor.py @@ -170,8 +170,10 @@ async def async_setup_entry( NMBSSensor( api_client, name, show_on_map, station_from, station_to, excl_vias ), - NMBSLiveBoard(api_client, station_from, station_from, station_to), - NMBSLiveBoard(api_client, station_to, station_from, station_to), + NMBSLiveBoard( + api_client, station_from, station_from, station_to, excl_vias + ), + NMBSLiveBoard(api_client, station_to, station_from, station_to, excl_vias), ] ) @@ -187,12 +189,15 @@ class NMBSLiveBoard(SensorEntity): live_station: dict[str, Any], station_from: dict[str, Any], station_to: dict[str, Any], + excl_vias: bool, ) -> None: """Initialize the sensor for getting liveboard data.""" self._station = live_station self._api_client = api_client self._station_from = station_from self._station_to = station_to + + self._excl_vias = excl_vias self._attrs: dict[str, Any] | None = {} self._state: str | None = None @@ -210,7 +215,8 @@ class NMBSLiveBoard(SensorEntity): unique_id = ( f"{self._station['id']}_{self._station_from['id']}_{self._station_to['id']}" ) - return f"nmbs_live_{unique_id}" + vias = "_excl_vias" if self._excl_vias else "" + return f"nmbs_live_{unique_id}{vias}" @property def icon(self) -> str: @@ -303,7 +309,8 @@ class NMBSSensor(SensorEntity): """Return the unique ID.""" unique_id = f"{self._station_from['id']}_{self._station_to['id']}" - return f"nmbs_connection_{unique_id}" + vias = "_excl_vias" if self._excl_vias else "" + return f"nmbs_connection_{unique_id}{vias}" @property def name(self) -> str: diff --git a/tests/components/nmbs/test_config_flow.py b/tests/components/nmbs/test_config_flow.py index 6e55f89e54a..ff4c5bdf72a 100644 --- a/tests/components/nmbs/test_config_flow.py +++ b/tests/components/nmbs/test_config_flow.py @@ -6,6 +6,7 @@ from unittest.mock import AsyncMock import pytest from homeassistant import config_entries +from homeassistant.components.nmbs.config_flow import CONF_EXCLUDE_VIAS from homeassistant.components.nmbs.const import ( CONF_STATION_FROM, CONF_STATION_LIVE, @@ -120,6 +121,23 @@ async def test_abort_if_exists( assert result["reason"] == "already_configured" +async def test_dont_abort_if_exists_when_vias_differs( + hass: HomeAssistant, mock_nmbs_client: AsyncMock, mock_config_entry: MockConfigEntry +) -> None: + """Test aborting the flow if the entry already exists.""" + mock_config_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={ + CONF_STATION_FROM: DUMMY_DATA["STAT_BRUSSELS_NORTH"], + CONF_STATION_TO: DUMMY_DATA["STAT_BRUSSELS_SOUTH"], + CONF_EXCLUDE_VIAS: True, + }, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + + async def test_unavailable_api( hass: HomeAssistant, mock_nmbs_client: AsyncMock ) -> None: @@ -158,7 +176,10 @@ async def test_import( CONF_STATION_LIVE: "BE.NMBS.008813003", CONF_STATION_TO: "BE.NMBS.008814001", } - assert result["result"].unique_id == "BE.NMBS.008812005_BE.NMBS.008814001" + assert ( + result["result"].unique_id + == f"{DUMMY_DATA['STAT_BRUSSELS_NORTH']}_{DUMMY_DATA['STAT_BRUSSELS_SOUTH']}" + ) async def test_step_import_abort_if_already_setup( From 6d91f8d86c47e12887c3bd90da442dbda22708bd Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 29 Jan 2025 14:36:05 +0100 Subject: [PATCH 0241/3148] Fix spelling of "API" for consistency in Home Assistant UI (#136842) --- homeassistant/components/weatherflow_cloud/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/weatherflow_cloud/strings.json b/homeassistant/components/weatherflow_cloud/strings.json index f707cbb0353..d22c62a030c 100644 --- a/homeassistant/components/weatherflow_cloud/strings.json +++ b/homeassistant/components/weatherflow_cloud/strings.json @@ -4,7 +4,7 @@ "user": { "description": "Set up a WeatherFlow Forecast Station", "data": { - "api_token": "Personal api token" + "api_token": "Personal API token" } }, "reauth_confirm": { From c7176f68492523945940a5a91beefb7a572b84df Mon Sep 17 00:00:00 2001 From: "Teemu R." Date: Wed, 29 Jan 2025 15:23:54 +0100 Subject: [PATCH 0242/3148] Add consumables for tplink tapo vacuums (#136510) Co-authored-by: Steven B <51370195+sdb9696@users.noreply.github.com> Co-authored-by: J. Nick Koston --- homeassistant/components/tplink/button.py | 15 + homeassistant/components/tplink/icons.json | 41 +- homeassistant/components/tplink/sensor.py | 74 ++++ homeassistant/components/tplink/strings.json | 45 +++ tests/components/tplink/__init__.py | 4 +- .../components/tplink/fixtures/features.json | 85 +++++ .../tplink/snapshots/test_button.ambr | 165 ++++++++ .../tplink/snapshots/test_sensor.ambr | 360 ++++++++++++++++++ 8 files changed, 787 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/tplink/button.py b/homeassistant/components/tplink/button.py index 6d9269b8c44..4279a233d21 100644 --- a/homeassistant/components/tplink/button.py +++ b/homeassistant/components/tplink/button.py @@ -72,6 +72,21 @@ BUTTON_DESCRIPTIONS: Final = [ ), TPLinkButtonEntityDescription(key="pair"), TPLinkButtonEntityDescription(key="unpair"), + TPLinkButtonEntityDescription( + key="main_brush_reset", + ), + TPLinkButtonEntityDescription( + key="side_brush_reset", + ), + TPLinkButtonEntityDescription( + key="sensor_reset", + ), + TPLinkButtonEntityDescription( + key="filter_reset", + ), + TPLinkButtonEntityDescription( + key="charging_contacts_reset", + ), ] BUTTON_DESCRIPTIONS_MAP = {desc.key: desc for desc in BUTTON_DESCRIPTIONS} diff --git a/homeassistant/components/tplink/icons.json b/homeassistant/components/tplink/icons.json index 15e9406b2c9..73bb40a8386 100644 --- a/homeassistant/components/tplink/icons.json +++ b/homeassistant/components/tplink/icons.json @@ -32,7 +32,20 @@ }, "tilt_down": { "default": "mdi:chevron-down" - } + }, + "main_brush_reset": { + "default": "mdi:brush" + }, + "side_brush_reset": { + "default": "mdi:brush" + }, + "sensor_reset": { + "default": "mdi:eye-outline" + }, + "filter_reset": { + "default": "mdi:air-filter" + }, + "charging_contacts_reset": {} }, "select": { "light_preset": { @@ -134,6 +147,32 @@ "water_alert_timestamp": { "default": "mdi:clock-alert-outline" }, + "main_brush_remaining": { + "default": "mdi:brush" + }, + "main_brush_used": { + "default": "mdi:brush" + }, + "side_brush_remaining": { + "default": "mdi:brush" + }, + "side_brush_used": { + "default": "mdi:brush" + }, + "filter_remaining": { + "default": "mdi:air-filter" + }, + "filter_used": { + "default": "mdi:air-filter" + }, + "sensor_remaining": { + "default": "mdi:eye-outline" + }, + "sensor_used": { + "default": "mdi:eye-outline" + }, + "charging_contacts_remaining": {}, + "charging_contacts_used": {}, "vacuum_error": { "default": "mdi:alert-circle" } diff --git a/homeassistant/components/tplink/sensor.py b/homeassistant/components/tplink/sensor.py index 0f5dbc0a2e3..4c38591b64d 100644 --- a/homeassistant/components/tplink/sensor.py +++ b/homeassistant/components/tplink/sensor.py @@ -4,6 +4,7 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass +from operator import methodcaller from typing import TYPE_CHECKING, Any, cast from kasa import Feature @@ -16,6 +17,7 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) +from homeassistant.const import UnitOfTime from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -37,6 +39,8 @@ class TPLinkSensorEntityDescription( # Coordinator is used to centralize the data updates PARALLEL_UPDATES = 0 +_TOTAL_SECONDS_METHOD_CALLER = methodcaller("total_seconds") + SENSOR_DESCRIPTIONS: tuple[TPLinkSensorEntityDescription, ...] = ( TPLinkSensorEntityDescription( key="current_consumption", @@ -120,6 +124,76 @@ SENSOR_DESCRIPTIONS: tuple[TPLinkSensorEntityDescription, ...] = ( TPLinkSensorEntityDescription( key="alarm_source", ), + TPLinkSensorEntityDescription( + key="main_brush_remaining", + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.HOURS, + convert_fn=_TOTAL_SECONDS_METHOD_CALLER, + ), + TPLinkSensorEntityDescription( + key="main_brush_used", + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.HOURS, + convert_fn=_TOTAL_SECONDS_METHOD_CALLER, + ), + TPLinkSensorEntityDescription( + key="side_brush_remaining", + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.HOURS, + convert_fn=_TOTAL_SECONDS_METHOD_CALLER, + ), + TPLinkSensorEntityDescription( + key="side_brush_used", + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.HOURS, + convert_fn=_TOTAL_SECONDS_METHOD_CALLER, + ), + TPLinkSensorEntityDescription( + key="filter_remaining", + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.HOURS, + convert_fn=_TOTAL_SECONDS_METHOD_CALLER, + ), + TPLinkSensorEntityDescription( + key="filter_used", + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.HOURS, + convert_fn=_TOTAL_SECONDS_METHOD_CALLER, + ), + TPLinkSensorEntityDescription( + key="sensor_remaining", + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.HOURS, + convert_fn=_TOTAL_SECONDS_METHOD_CALLER, + ), + TPLinkSensorEntityDescription( + key="sensor_used", + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.HOURS, + convert_fn=_TOTAL_SECONDS_METHOD_CALLER, + ), + TPLinkSensorEntityDescription( + key="charging_contacts_remaining", + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.HOURS, + convert_fn=_TOTAL_SECONDS_METHOD_CALLER, + ), + TPLinkSensorEntityDescription( + key="charging_contacts_used", + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.HOURS, + convert_fn=_TOTAL_SECONDS_METHOD_CALLER, + ), TPLinkSensorEntityDescription( key="vacuum_error", device_class=SensorDeviceClass.ENUM, diff --git a/homeassistant/components/tplink/strings.json b/homeassistant/components/tplink/strings.json index fe1560b75d5..2714e92bd5c 100644 --- a/homeassistant/components/tplink/strings.json +++ b/homeassistant/components/tplink/strings.json @@ -147,6 +147,21 @@ }, "unpair": { "name": "Unpair device" + }, + "main_brush_reset": { + "name": "Reset main brush consumable" + }, + "side_brush_reset": { + "name": "Reset side brush consumable" + }, + "sensor_reset": { + "name": "Reset sensor consumable" + }, + "filter_reset": { + "name": "Reset filter consumable" + }, + "charging_contacts_reset": { + "name": "Reset charging contacts consumable" } }, "camera": { @@ -202,6 +217,36 @@ "alarm_source": { "name": "Alarm source" }, + "main_brush_remaining": { + "name": "Main brush remaining" + }, + "main_brush_used": { + "name": "Main brush used" + }, + "side_brush_remaining": { + "name": "Side brush remaining" + }, + "side_brush_used": { + "name": "Side brush used" + }, + "filter_remaining": { + "name": "Filter remaining" + }, + "filter_used": { + "name": "Filter used" + }, + "sensor_remaining": { + "name": "Sensor remaining" + }, + "sensor_used": { + "name": "Sensor used" + }, + "charging_contacts_remaining": { + "name": "Charging contacts remaining" + }, + "charging_contacts_used": { + "name": "Charging contacts used" + }, "vacuum_error": { "name": "Error", "state": { diff --git a/tests/components/tplink/__init__.py b/tests/components/tplink/__init__.py index 4737d7432df..851d05636b0 100644 --- a/tests/components/tplink/__init__.py +++ b/tests/components/tplink/__init__.py @@ -2,7 +2,7 @@ from collections import namedtuple from dataclasses import replace -from datetime import datetime +from datetime import datetime, timedelta from typing import Any from unittest.mock import AsyncMock, MagicMock, patch @@ -279,6 +279,8 @@ def _mocked_feature( if enum_type := fixture.get("enum_type"): val = FIXTURE_ENUM_TYPES[enum_type](fixture["value"]) fixture["value"] = val + if timedelta_type := fixture.get("timedelta_type"): + fixture["value"] = timedelta(**{timedelta_type: fixture["value"]}) else: assert require_fixture is False, ( diff --git a/tests/components/tplink/fixtures/features.json b/tests/components/tplink/fixtures/features.json index c49c5881d5c..45b85da4583 100644 --- a/tests/components/tplink/fixtures/features.json +++ b/tests/components/tplink/fixtures/features.json @@ -407,5 +407,90 @@ "value": "", "type": "Action", "category": "Debug" + }, + "main_brush_reset": { + "value": "", + "type": "Action", + "category": "Debug" + }, + "side_brush_reset": { + "value": "", + "type": "Action", + "category": "Debug" + }, + "sensor_reset": { + "value": "", + "type": "Action", + "category": "Debug" + }, + "filter_reset": { + "value": "", + "type": "Action", + "category": "Debug" + }, + "charging_contacts_reset": { + "value": "", + "type": "Action", + "category": "Debug" + }, + "main_brush_remaining": { + "value": 360, + "type": "Sensor", + "category": "Debug", + "timedelta_type": "minutes" + }, + "main_brush_used": { + "value": 360, + "type": "Sensor", + "category": "Debug", + "timedelta_type": "minutes" + }, + "side_brush_remaining": { + "value": 360, + "type": "Sensor", + "category": "Debug", + "timedelta_type": "minutes" + }, + "side_brush_used": { + "value": 360, + "type": "Sensor", + "category": "Debug", + "timedelta_type": "minutes" + }, + "filter_remaining": { + "value": 360, + "type": "Sensor", + "category": "Debug", + "timedelta_type": "minutes" + }, + "filter_used": { + "value": 360, + "type": "Sensor", + "category": "Debug", + "timedelta_type": "minutes" + }, + "sensor_remaining": { + "value": 360, + "type": "Sensor", + "category": "Debug", + "timedelta_type": "minutes" + }, + "sensor_used": { + "value": 360, + "type": "Sensor", + "category": "Debug", + "timedelta_type": "minutes" + }, + "charging_contacts_remaining": { + "value": 360, + "type": "Sensor", + "category": "Debug", + "timedelta_type": "minutes" + }, + "charging_contacts_used": { + "value": 360, + "type": "Sensor", + "category": "Debug", + "timedelta_type": "minutes" } } diff --git a/tests/components/tplink/snapshots/test_button.ambr b/tests/components/tplink/snapshots/test_button.ambr index 087aec39cfc..c0c74e11923 100644 --- a/tests/components/tplink/snapshots/test_button.ambr +++ b/tests/components/tplink/snapshots/test_button.ambr @@ -137,6 +137,171 @@ 'state': 'unknown', }) # --- +# name: test_states[button.my_device_reset_charging_contacts_consumable-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.my_device_reset_charging_contacts_consumable', + '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': 'Reset charging contacts consumable', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charging_contacts_reset', + 'unique_id': '123456789ABCDEFGH_charging_contacts_reset', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[button.my_device_reset_filter_consumable-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.my_device_reset_filter_consumable', + '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': 'Reset filter consumable', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'filter_reset', + 'unique_id': '123456789ABCDEFGH_filter_reset', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[button.my_device_reset_main_brush_consumable-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.my_device_reset_main_brush_consumable', + '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': 'Reset main brush consumable', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'main_brush_reset', + 'unique_id': '123456789ABCDEFGH_main_brush_reset', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[button.my_device_reset_sensor_consumable-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.my_device_reset_sensor_consumable', + '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': 'Reset sensor consumable', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'sensor_reset', + 'unique_id': '123456789ABCDEFGH_sensor_reset', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[button.my_device_reset_side_brush_consumable-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.my_device_reset_side_brush_consumable', + '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': 'Reset side brush consumable', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'side_brush_reset', + 'unique_id': '123456789ABCDEFGH_side_brush_reset', + 'unit_of_measurement': None, + }) +# --- # name: test_states[button.my_device_restart-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tplink/snapshots/test_sensor.ambr b/tests/components/tplink/snapshots/test_sensor.ambr index e223a72dbc0..344b9e28b98 100644 --- a/tests/components/tplink/snapshots/test_sensor.ambr +++ b/tests/components/tplink/snapshots/test_sensor.ambr @@ -166,6 +166,78 @@ 'state': '85', }) # --- +# name: test_states[sensor.my_device_charging_contacts_remaining-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.my_device_charging_contacts_remaining', + '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': 'Charging contacts remaining', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charging_contacts_remaining', + 'unique_id': '123456789ABCDEFGH_charging_contacts_remaining', + 'unit_of_measurement': , + }) +# --- +# name: test_states[sensor.my_device_charging_contacts_used-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.my_device_charging_contacts_used', + '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': 'Charging contacts used', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charging_contacts_used', + 'unique_id': '123456789ABCDEFGH_charging_contacts_used', + 'unit_of_measurement': , + }) +# --- # name: test_states[sensor.my_device_current-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -383,6 +455,78 @@ 'state': 'ok', }) # --- +# name: test_states[sensor.my_device_filter_remaining-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.my_device_filter_remaining', + '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': 'Filter remaining', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'filter_remaining', + 'unique_id': '123456789ABCDEFGH_filter_remaining', + 'unit_of_measurement': , + }) +# --- +# name: test_states[sensor.my_device_filter_used-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.my_device_filter_used', + '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': 'Filter used', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'filter_used', + 'unique_id': '123456789ABCDEFGH_filter_used', + 'unit_of_measurement': , + }) +# --- # name: test_states[sensor.my_device_humidity-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -481,6 +625,78 @@ 'state': '2024-06-24T09:03:11+00:00', }) # --- +# name: test_states[sensor.my_device_main_brush_remaining-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.my_device_main_brush_remaining', + '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': 'Main brush remaining', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'main_brush_remaining', + 'unique_id': '123456789ABCDEFGH_main_brush_remaining', + 'unit_of_measurement': , + }) +# --- +# name: test_states[sensor.my_device_main_brush_used-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.my_device_main_brush_used', + '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': 'Main brush used', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'main_brush_used', + 'unique_id': '123456789ABCDEFGH_main_brush_used', + 'unit_of_measurement': , + }) +# --- # name: test_states[sensor.my_device_on_since-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -547,6 +763,150 @@ 'unit_of_measurement': '%', }) # --- +# name: test_states[sensor.my_device_sensor_remaining-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.my_device_sensor_remaining', + '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': 'Sensor remaining', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'sensor_remaining', + 'unique_id': '123456789ABCDEFGH_sensor_remaining', + 'unit_of_measurement': , + }) +# --- +# name: test_states[sensor.my_device_sensor_used-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.my_device_sensor_used', + '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': 'Sensor used', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'sensor_used', + 'unique_id': '123456789ABCDEFGH_sensor_used', + 'unit_of_measurement': , + }) +# --- +# name: test_states[sensor.my_device_side_brush_remaining-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.my_device_side_brush_remaining', + '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': 'Side brush remaining', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'side_brush_remaining', + 'unique_id': '123456789ABCDEFGH_side_brush_remaining', + 'unit_of_measurement': , + }) +# --- +# name: test_states[sensor.my_device_side_brush_used-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.my_device_side_brush_used', + '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': 'Side brush used', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'side_brush_used', + 'unique_id': '123456789ABCDEFGH_side_brush_used', + 'unit_of_measurement': , + }) +# --- # name: test_states[sensor.my_device_signal_level-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 653ff4717105b9c33d5312eeba65c75073af35bb Mon Sep 17 00:00:00 2001 From: "Teemu R." Date: Wed, 29 Jan 2025 15:56:47 +0100 Subject: [PATCH 0243/3148] Add cleaning statistics for tplink (#135784) Co-authored-by: Steven B <51370195+sdb9696@users.noreply.github.com> --- homeassistant/components/tplink/const.py | 6 +- homeassistant/components/tplink/sensor.py | 44 +++ homeassistant/components/tplink/strings.json | 27 ++ tests/components/tplink/__init__.py | 9 +- .../components/tplink/fixtures/features.json | 55 +++ .../tplink/snapshots/test_sensor.ambr | 359 ++++++++++++++++++ 6 files changed, 497 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/tplink/const.py b/homeassistant/components/tplink/const.py index ad17aadeb5b..2df7101791a 100644 --- a/homeassistant/components/tplink/const.py +++ b/homeassistant/components/tplink/const.py @@ -4,7 +4,9 @@ from __future__ import annotations from typing import Final -from homeassistant.const import Platform, UnitOfTemperature +from kasa.smart.modules.clean import AreaUnit + +from homeassistant.const import Platform, UnitOfArea, UnitOfTemperature DOMAIN = "tplink" @@ -47,4 +49,6 @@ PLATFORMS: Final = [ UNIT_MAPPING = { "celsius": UnitOfTemperature.CELSIUS, "fahrenheit": UnitOfTemperature.FAHRENHEIT, + AreaUnit.Sqm: UnitOfArea.SQUARE_METERS, + AreaUnit.Sqft: UnitOfArea.SQUARE_FEET, } diff --git a/homeassistant/components/tplink/sensor.py b/homeassistant/components/tplink/sensor.py index 4c38591b64d..38aab26cf8b 100644 --- a/homeassistant/components/tplink/sensor.py +++ b/homeassistant/components/tplink/sensor.py @@ -124,6 +124,50 @@ SENSOR_DESCRIPTIONS: tuple[TPLinkSensorEntityDescription, ...] = ( TPLinkSensorEntityDescription( key="alarm_source", ), + # Vacuum cleaning records + TPLinkSensorEntityDescription( + key="clean_time", + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.MINUTES, + convert_fn=_TOTAL_SECONDS_METHOD_CALLER, + ), + TPLinkSensorEntityDescription( + key="clean_area", + device_class=SensorDeviceClass.AREA, + ), + TPLinkSensorEntityDescription( + key="clean_progress", + ), + TPLinkSensorEntityDescription( + key="last_clean_time", + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.MINUTES, + convert_fn=_TOTAL_SECONDS_METHOD_CALLER, + ), + TPLinkSensorEntityDescription( + key="last_clean_area", + device_class=SensorDeviceClass.AREA, + ), + TPLinkSensorEntityDescription( + key="last_clean_timestamp", + device_class=SensorDeviceClass.TIMESTAMP, + ), + TPLinkSensorEntityDescription( + key="total_clean_time", + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.MINUTES, + convert_fn=_TOTAL_SECONDS_METHOD_CALLER, + ), + TPLinkSensorEntityDescription( + key="total_clean_area", + device_class=SensorDeviceClass.AREA, + ), + TPLinkSensorEntityDescription( + key="total_clean_count", + ), TPLinkSensorEntityDescription( key="main_brush_remaining", device_class=SensorDeviceClass.DURATION, diff --git a/homeassistant/components/tplink/strings.json b/homeassistant/components/tplink/strings.json index 2714e92bd5c..ded4806a726 100644 --- a/homeassistant/components/tplink/strings.json +++ b/homeassistant/components/tplink/strings.json @@ -217,6 +217,33 @@ "alarm_source": { "name": "Alarm source" }, + "clean_area": { + "name": "Cleaning area" + }, + "clean_time": { + "name": "Cleaning time" + }, + "clean_progress": { + "name": "Cleaning progress" + }, + "total_clean_area": { + "name": "Total cleaning area" + }, + "total_clean_time": { + "name": "Total cleaning time" + }, + "total_clean_count": { + "name": "Total cleaning count" + }, + "last_clean_area": { + "name": "Last cleaned area" + }, + "last_clean_time": { + "name": "Last cleaned time" + }, + "last_clean_timestamp": { + "name": "Last clean start" + }, "main_brush_remaining": { "name": "Main brush remaining" }, diff --git a/tests/components/tplink/__init__.py b/tests/components/tplink/__init__.py index 851d05636b0..ac5bb347765 100644 --- a/tests/components/tplink/__init__.py +++ b/tests/components/tplink/__init__.py @@ -18,7 +18,7 @@ from kasa import ( from kasa.interfaces import Fan, Light, LightEffect, LightState, Thermostat from kasa.smart.modules import Speaker from kasa.smart.modules.alarm import Alarm -from kasa.smart.modules.clean import Clean, ErrorCode, Status +from kasa.smart.modules.clean import AreaUnit, Clean, ErrorCode, Status from kasa.smartcam.modules.camera import LOCAL_STREAMING_PORT, Camera from syrupy import SnapshotAssertion @@ -60,7 +60,7 @@ def _load_feature_fixtures(): FEATURES_FIXTURE = _load_feature_fixtures() -FIXTURE_ENUM_TYPES = {"CleanErrorCode": ErrorCode} +FIXTURE_ENUM_TYPES = {"CleanErrorCode": ErrorCode, "CleanAreaUnit": AreaUnit} async def setup_platform_for_device( @@ -276,12 +276,17 @@ def _mocked_feature( if fixture := FEATURES_FIXTURE.get(id): # copy the fixture so tests do not interfere with each other fixture = dict(fixture) + if enum_type := fixture.get("enum_type"): val = FIXTURE_ENUM_TYPES[enum_type](fixture["value"]) fixture["value"] = val if timedelta_type := fixture.get("timedelta_type"): fixture["value"] = timedelta(**{timedelta_type: fixture["value"]}) + if unit_enum_type := fixture.get("unit_enum_type"): + val = FIXTURE_ENUM_TYPES[unit_enum_type](fixture["unit"]) + fixture["unit"] = val + else: assert require_fixture is False, ( f"No fixture defined for feature {id} and require_fixture is True" diff --git a/tests/components/tplink/fixtures/features.json b/tests/components/tplink/fixtures/features.json index 45b85da4583..81277ddd3ae 100644 --- a/tests/components/tplink/fixtures/features.json +++ b/tests/components/tplink/fixtures/features.json @@ -341,6 +341,61 @@ "Connection 2" ] }, + "clean_time": { + "type": "Sensor", + "category": "Info", + "value": 12, + "timedelta_type": "minutes" + }, + "clean_area": { + "type": "Sensor", + "category": "Info", + "value": 2, + "unit": 1, + "unit_enum_type": "CleanAreaUnit" + }, + "clean_progress": { + "type": "Sensor", + "category": "Info", + "value": 30, + "unit": "%" + }, + "total_clean_time": { + "type": "Sensor", + "category": "Debug", + "value": 120, + "timedelta_type": "minutes" + }, + "total_clean_area": { + "type": "Sensor", + "category": "Debug", + "value": 2, + "unit": 1, + "unit_enum_type": "CleanAreaUnit" + }, + "last_clean_time": { + "type": "Sensor", + "category": "Debug", + "value": 60, + "timedelta_type": "minutes" + }, + "last_clean_area": { + "type": "Sensor", + "category": "Debug", + "value": 2, + "unit": 1, + "unit_enum_type": "CleanAreaUnit" + }, + "last_clean_timestamp": { + "type": "Sensor", + "category": "Debug", + "value": "2024-06-24 10:03:11.046643+01:00" + }, + "total_clean_count": { + "type": "Sensor", + "category": "Debug", + "value": 12 + }, "alarm_volume": { "value": "normal", "type": "Choice", diff --git a/tests/components/tplink/snapshots/test_sensor.ambr b/tests/components/tplink/snapshots/test_sensor.ambr index 344b9e28b98..0d1cc9a03e4 100644 --- a/tests/components/tplink/snapshots/test_sensor.ambr +++ b/tests/components/tplink/snapshots/test_sensor.ambr @@ -238,6 +238,155 @@ 'unit_of_measurement': , }) # --- +# name: test_states[sensor.my_device_cleaning_area-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.my_device_cleaning_area', + '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': 'Cleaning area', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'clean_area', + 'unique_id': '123456789ABCDEFGH_clean_area', + 'unit_of_measurement': , + }) +# --- +# name: test_states[sensor.my_device_cleaning_area-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'area', + 'friendly_name': 'my_device Cleaning area', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.my_device_cleaning_area', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.2', + }) +# --- +# name: test_states[sensor.my_device_cleaning_progress-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.my_device_cleaning_progress', + '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': 'Cleaning progress', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'clean_progress', + 'unique_id': '123456789ABCDEFGH_clean_progress', + 'unit_of_measurement': '%', + }) +# --- +# name: test_states[sensor.my_device_cleaning_progress-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'my_device Cleaning progress', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.my_device_cleaning_progress', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '30', + }) +# --- +# name: test_states[sensor.my_device_cleaning_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.my_device_cleaning_time', + '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': 'Cleaning time', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'clean_time', + 'unique_id': '123456789ABCDEFGH_clean_time', + 'unit_of_measurement': , + }) +# --- +# name: test_states[sensor.my_device_cleaning_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'my_device Cleaning time', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.my_device_cleaning_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '12.00', + }) +# --- # name: test_states[sensor.my_device_current-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -578,6 +727,111 @@ 'state': '12', }) # --- +# name: test_states[sensor.my_device_last_clean_start-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.my_device_last_clean_start', + '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 clean start', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'last_clean_timestamp', + 'unique_id': '123456789ABCDEFGH_last_clean_timestamp', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[sensor.my_device_last_cleaned_area-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.my_device_last_cleaned_area', + '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': 'Last cleaned area', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'last_clean_area', + 'unique_id': '123456789ABCDEFGH_last_clean_area', + 'unit_of_measurement': , + }) +# --- +# name: test_states[sensor.my_device_last_cleaned_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.my_device_last_cleaned_time', + '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': 'Last cleaned time', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'last_clean_time', + 'unique_id': '123456789ABCDEFGH_last_clean_time', + 'unit_of_measurement': , + }) +# --- # name: test_states[sensor.my_device_last_water_leak_alert-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1167,6 +1421,111 @@ 'state': '5.23', }) # --- +# name: test_states[sensor.my_device_total_cleaning_area-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.my_device_total_cleaning_area', + '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': 'Total cleaning area', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_clean_area', + 'unique_id': '123456789ABCDEFGH_total_clean_area', + 'unit_of_measurement': , + }) +# --- +# name: test_states[sensor.my_device_total_cleaning_count-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.my_device_total_cleaning_count', + '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': 'Total cleaning count', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_clean_count', + 'unique_id': '123456789ABCDEFGH_total_clean_count', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[sensor.my_device_total_cleaning_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.my_device_total_cleaning_time', + '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': 'Total cleaning time', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_clean_time', + 'unique_id': '123456789ABCDEFGH_total_clean_time', + 'unit_of_measurement': , + }) +# --- # name: test_states[sensor.my_device_total_consumption-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 83b34c6fafec4fdc80b2c2180740dde56dc410e1 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 29 Jan 2025 16:15:20 +0100 Subject: [PATCH 0244/3148] Adjust deprecation in water heater (#136577) --- .../components/water_heater/__init__.py | 25 ++++++++++---- tests/components/water_heater/test_init.py | 33 ++++++++++++++----- 2 files changed, 44 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/water_heater/__init__.py b/homeassistant/components/water_heater/__init__.py index 3e1387cb714..c9155950680 100644 --- a/homeassistant/components/water_heater/__init__.py +++ b/homeassistant/components/water_heater/__init__.py @@ -25,7 +25,12 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.deprecation import deprecated_class +from homeassistant.helpers.deprecation import ( + DeprecatedConstant, + all_with_deprecated_constants, + check_if_deprecated_constant, + dir_with_deprecated_constants, +) from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.temperature import display_temp as show_temp @@ -134,11 +139,11 @@ class WaterHeaterEntityDescription(EntityDescription, frozen_or_thawed=True): """A class that describes water heater entities.""" -@deprecated_class("WaterHeaterEntityDescription", breaks_in_ha_version="2026.1") -class WaterHeaterEntityEntityDescription( - WaterHeaterEntityDescription, frozen_or_thawed=True -): - """A (deprecated) class that describes water heater entities.""" +_DEPRECATED_WaterHeaterEntityEntityDescription = DeprecatedConstant( + WaterHeaterEntityDescription, + "WaterHeaterEntityDescription", + breaks_in_ha_version="2026.1", +) CACHED_PROPERTIES_WITH_ATTR_ = { @@ -414,3 +419,11 @@ async def async_service_temperature_set( kwargs[value] = temp await entity.async_set_temperature(**kwargs) + + +# These can be removed if no deprecated constant are in this module anymore +__getattr__ = ft.partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = ft.partial( + dir_with_deprecated_constants, module_globals_keys=[*globals().keys()] +) +__all__ = all_with_deprecated_constants(globals()) diff --git a/tests/components/water_heater/test_init.py b/tests/components/water_heater/test_init.py index 09a0a711582..67f0c1de36e 100644 --- a/tests/components/water_heater/test_init.py +++ b/tests/components/water_heater/test_init.py @@ -2,19 +2,20 @@ from __future__ import annotations +from typing import Any from unittest import mock from unittest.mock import AsyncMock, MagicMock import pytest import voluptuous as vol +from homeassistant.components import water_heater from homeassistant.components.water_heater import ( DOMAIN, SERVICE_SET_OPERATION_MODE, SET_TEMPERATURE_SCHEMA, WaterHeaterEntity, WaterHeaterEntityDescription, - WaterHeaterEntityEntityDescription, WaterHeaterEntityFeature, ) from homeassistant.config_entries import ConfigEntry @@ -29,6 +30,7 @@ from tests.common import ( MockModule, MockPlatform, async_mock_service, + import_and_test_deprecated_constant, mock_integration, mock_platform, ) @@ -209,12 +211,27 @@ async def test_operation_mode_validation( @pytest.mark.parametrize( - ("class_name", "expected_log"), - [(WaterHeaterEntityDescription, False), (WaterHeaterEntityEntityDescription, True)], + ("constant_name", "replacement_name", "replacement"), + [ + ( + "WaterHeaterEntityEntityDescription", + "WaterHeaterEntityDescription", + WaterHeaterEntityDescription, + ), + ], ) -async def test_deprecated_entity_description( - caplog: pytest.LogCaptureFixture, class_name: type, expected_log: bool +def test_deprecated_constants( + caplog: pytest.LogCaptureFixture, + constant_name: str, + replacement_name: str, + replacement: Any, ) -> None: - """Test deprecated WaterHeaterEntityEntityDescription logs warning.""" - class_name(key="test") - assert ("is a deprecated class" in caplog.text) is expected_log + """Test deprecated automation constants.""" + import_and_test_deprecated_constant( + caplog, + water_heater, + constant_name, + replacement_name, + replacement, + "2026.1", + ) From 8ab6bec746a97bf7e7db65a71451a3ca043c3d70 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 29 Jan 2025 10:42:39 -0500 Subject: [PATCH 0245/3148] Migrate Google Gen AI to ChatSession (#136779) * Migrate Google Gen AI to ChatSession * Remove unused method --- .../conversation.py | 199 +++++++----------- .../snapshots/test_conversation.ambr | 40 ++++ .../test_conversation.py | 13 +- 3 files changed, 128 insertions(+), 124 deletions(-) diff --git a/homeassistant/components/google_generative_ai_conversation/conversation.py b/homeassistant/components/google_generative_ai_conversation/conversation.py index 81cc7ab8a73..db2df9cddd3 100644 --- a/homeassistant/components/google_generative_ai_conversation/conversation.py +++ b/homeassistant/components/google_generative_ai_conversation/conversation.py @@ -11,18 +11,15 @@ import google.generativeai as genai from google.generativeai import protos import google.generativeai.types as genai_types from google.protobuf.json_format import MessageToDict -import voluptuous as vol from voluptuous_openapi import convert from homeassistant.components import assist_pipeline, conversation -from homeassistant.components.conversation import trace from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_LLM_HASS_API, MATCH_ALL from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError, TemplateError -from homeassistant.helpers import device_registry as dr, intent, llm, template +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr, intent, llm from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.util import ulid as ulid_util from .const import ( CONF_CHAT_MODEL, @@ -152,6 +149,17 @@ def _escape_decode(value: Any) -> Any: return value +def _chat_message_convert( + message: conversation.Content | conversation.NativeContent[genai_types.ContentDict], +) -> genai_types.ContentDict: + """Convert any native chat message for this agent to the native format.""" + if message.role == "native": + return message.content + + role = "model" if message.role == "assistant" else message.role + return {"role": role, "parts": message.content} + + class GoogleGenerativeAIConversationEntity( conversation.ConversationEntity, conversation.AbstractConversationAgent ): @@ -163,7 +171,6 @@ class GoogleGenerativeAIConversationEntity( def __init__(self, entry: ConfigEntry) -> None: """Initialize the agent.""" self.entry = entry - self.history: dict[str, list[genai_types.ContentType]] = {} self._attr_unique_id = entry.entry_id self._attr_device_info = dr.DeviceInfo( identifiers={(DOMAIN, entry.entry_id)}, @@ -202,49 +209,37 @@ class GoogleGenerativeAIConversationEntity( self, user_input: conversation.ConversationInput ) -> conversation.ConversationResult: """Process a sentence.""" - result = conversation.ConversationResult( - response=intent.IntentResponse(language=user_input.language), - conversation_id=user_input.conversation_id or ulid_util.ulid_now(), - ) - assert result.conversation_id + async with conversation.async_get_chat_session( + self.hass, user_input + ) as session: + return await self._async_handle_message(user_input, session) - llm_context = llm.LLMContext( - platform=DOMAIN, - context=user_input.context, - user_prompt=user_input.text, - language=user_input.language, - assistant=conversation.DOMAIN, - device_id=user_input.device_id, - ) - llm_api: llm.APIInstance | None = None - tools: list[dict[str, Any]] | None = None - if self.entry.options.get(CONF_LLM_HASS_API): - try: - llm_api = await llm.async_get_api( - self.hass, - self.entry.options[CONF_LLM_HASS_API], - llm_context, - ) - except HomeAssistantError as err: - LOGGER.error("Error getting LLM API: %s", err) - result.response.async_set_error( - intent.IntentResponseErrorCode.UNKNOWN, - f"Error preparing LLM API: {err}", - ) - return result - tools = [ - _format_tool(tool, llm_api.custom_serializer) for tool in llm_api.tools - ] + async def _async_handle_message( + self, + user_input: conversation.ConversationInput, + session: conversation.ChatSession[genai_types.ContentDict], + ) -> conversation.ConversationResult: + """Call the API.""" + + assert user_input.agent_id + options = self.entry.options try: - prompt = await self._async_render_prompt(user_input, llm_api, llm_context) - except TemplateError as err: - LOGGER.error("Error rendering prompt: %s", err) - result.response.async_set_error( - intent.IntentResponseErrorCode.UNKNOWN, - f"Sorry, I had a problem with my template: {err}", + await session.async_update_llm_data( + DOMAIN, + user_input, + options.get(CONF_LLM_HASS_API), + options.get(CONF_PROMPT), ) - return result + except conversation.ConverseError as err: + return err.as_conversation_result() + + tools: list[dict[str, Any]] | None = None + if session.llm_api: + tools = [ + _format_tool(tool, session.llm_api.custom_serializer) + for tool in session.llm_api.tools + ] model_name = self.entry.options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL) # Gemini 1.0 doesn't support system_instruction while 1.5 does. @@ -254,6 +249,9 @@ class GoogleGenerativeAIConversationEntity( "gemini-1.0" not in model_name and "gemini-pro" not in model_name ) + prompt, *messages = [ + _chat_message_convert(message) for message in session.async_get_messages() + ] model = genai.GenerativeModel( model_name=model_name, generation_config={ @@ -281,27 +279,15 @@ class GoogleGenerativeAIConversationEntity( ), }, tools=tools or None, - system_instruction=prompt if supports_system_instruction else None, + system_instruction=prompt["parts"] if supports_system_instruction else None, ) - messages = self.history.get(result.conversation_id, []) if not supports_system_instruction: - if not messages: - messages = [{}, {"role": "model", "parts": "Ok"}] - messages[0] = {"role": "user", "parts": prompt} - - LOGGER.debug("Input: '%s' with history: %s", user_input.text, messages) - trace.async_conversation_trace_append( - trace.ConversationTraceEventType.AGENT_DETAIL, - { - # Make a copy to attach it to the trace event. - "messages": messages[:] - if supports_system_instruction - else messages[2:], - "prompt": prompt, - "tools": [*llm_api.tools] if llm_api else None, - }, - ) + messages = [ + {"role": "user", "parts": prompt["parts"]}, + {"role": "model", "parts": "Ok"}, + *messages, + ] chat = model.start_chat(history=messages) chat_request = user_input.text @@ -326,24 +312,30 @@ class GoogleGenerativeAIConversationEntity( f"Sorry, I had a problem talking to Google Generative AI: {err}" ) - result.response.async_set_error( - intent.IntentResponseErrorCode.UNKNOWN, - error, - ) - return result + raise HomeAssistantError(error) from err LOGGER.debug("Response: %s", chat_response.parts) if not chat_response.parts: - result.response.async_set_error( - intent.IntentResponseErrorCode.UNKNOWN, - "Sorry, I had a problem getting a response from Google Generative AI.", + raise HomeAssistantError( + "Sorry, I had a problem getting a response from Google Generative AI." ) - return result - self.history[result.conversation_id] = chat.history + content = " ".join( + [part.text.strip() for part in chat_response.parts if part.text] + ) + if content: + session.async_add_message( + conversation.Content( + role="assistant", + agent_id=user_input.agent_id, + content=content, + ) + ) + function_calls = [ part.function_call for part in chat_response.parts if part.function_call ] - if not function_calls or not llm_api: + + if not function_calls or not session.llm_api: break tool_responses = [] @@ -351,16 +343,8 @@ class GoogleGenerativeAIConversationEntity( tool_call = MessageToDict(function_call._pb) # noqa: SLF001 tool_name = tool_call["name"] tool_args = _escape_decode(tool_call["args"]) - LOGGER.debug("Tool call: %s(%s)", tool_name, tool_args) tool_input = llm.ToolInput(tool_name=tool_name, tool_args=tool_args) - try: - function_response = await llm_api.async_call_tool(tool_input) - except (HomeAssistantError, vol.Invalid) as e: - function_response = {"error": type(e).__name__} - if str(e): - function_response["error_text"] = str(e) - - LOGGER.debug("Tool response: %s", function_response) + function_response = await session.async_call_tool(tool_input) tool_responses.append( protos.Part( function_response=protos.FunctionResponse( @@ -369,47 +353,20 @@ class GoogleGenerativeAIConversationEntity( ) ) chat_request = protos.Content(parts=tool_responses) + session.async_add_message( + conversation.NativeContent( + agent_id=user_input.agent_id, + content=chat_request, + ) + ) - result.response.async_set_speech( + response = intent.IntentResponse(language=user_input.language) + response.async_set_speech( " ".join([part.text.strip() for part in chat_response.parts if part.text]) ) - return result - - async def _async_render_prompt( - self, - user_input: conversation.ConversationInput, - llm_api: llm.APIInstance | None, - llm_context: llm.LLMContext, - ) -> str: - user_name: str | None = None - if ( - user_input.context - and user_input.context.user_id - and ( - user := await self.hass.auth.async_get_user(user_input.context.user_id) - ) - ): - user_name = user.name - - parts = [ - template.Template( - llm.BASE_PROMPT - + self.entry.options.get(CONF_PROMPT, llm.DEFAULT_INSTRUCTIONS_PROMPT), - self.hass, - ).async_render( - { - "ha_name": self.hass.config.location_name, - "user_name": user_name, - "llm_context": llm_context, - }, - parse_result=False, - ) - ] - - if llm_api: - parts.append(llm_api.api_prompt) - - return "\n".join(parts) + return conversation.ConversationResult( + response=response, conversation_id=session.conversation_id + ) async def _async_entry_update_listener( self, hass: HomeAssistant, entry: ConfigEntry diff --git a/tests/components/google_generative_ai_conversation/snapshots/test_conversation.ambr b/tests/components/google_generative_ai_conversation/snapshots/test_conversation.ambr index 65238c5212a..21458abb7c8 100644 --- a/tests/components/google_generative_ai_conversation/snapshots/test_conversation.ambr +++ b/tests/components/google_generative_ai_conversation/snapshots/test_conversation.ambr @@ -42,6 +42,10 @@ 'parts': 'Ok', 'role': 'model', }), + dict({ + 'parts': '1st user request', + 'role': 'user', + }), ]), }), ), @@ -102,6 +106,10 @@ 'parts': '1st model response', 'role': 'model', }), + dict({ + 'parts': '2nd user request', + 'role': 'user', + }), ]), }), ), @@ -150,6 +158,10 @@ ), dict({ 'history': list([ + dict({ + 'parts': '1st user request', + 'role': 'user', + }), ]), }), ), @@ -202,6 +214,10 @@ 'parts': '1st model response', 'role': 'model', }), + dict({ + 'parts': '2nd user request', + 'role': 'user', + }), ]), }), ), @@ -250,6 +266,10 @@ ), dict({ 'history': list([ + dict({ + 'parts': 'hello', + 'role': 'user', + }), ]), }), ), @@ -298,6 +318,10 @@ ), dict({ 'history': list([ + dict({ + 'parts': 'hello', + 'role': 'user', + }), ]), }), ), @@ -347,6 +371,10 @@ ), dict({ 'history': list([ + dict({ + 'parts': 'hello', + 'role': 'user', + }), ]), }), ), @@ -396,6 +424,10 @@ ), dict({ 'history': list([ + dict({ + 'parts': 'hello', + 'role': 'user', + }), ]), }), ), @@ -482,6 +514,10 @@ ), dict({ 'history': list([ + dict({ + 'parts': 'Please call the test function', + 'role': 'user', + }), ]), }), ), @@ -558,6 +594,10 @@ ), dict({ 'history': list([ + dict({ + 'parts': 'Please call the test function', + 'role': 'user', + }), ]), }), ), diff --git a/tests/components/google_generative_ai_conversation/test_conversation.py b/tests/components/google_generative_ai_conversation/test_conversation.py index df0b11487d8..a87056275dc 100644 --- a/tests/components/google_generative_ai_conversation/test_conversation.py +++ b/tests/components/google_generative_ai_conversation/test_conversation.py @@ -208,6 +208,7 @@ async def test_function_call( chat_response = MagicMock() mock_chat.send_message_async.return_value = chat_response mock_part = MagicMock() + mock_part.text = "" mock_part.function_call = FunctionCall( name="test_tool", args={ @@ -284,8 +285,12 @@ async def test_function_call( ] # AGENT_DETAIL event contains the raw prompt passed to the model detail_event = trace_events[1] - assert "Answer in plain text" in detail_event["data"]["prompt"] - assert [t.name for t in detail_event["data"]["tools"]] == ["test_tool"] + assert "Answer in plain text" in detail_event["data"]["messages"][0]["content"] + assert [ + p.function_response.name + for p in detail_event["data"]["messages"][2]["content"].parts + if p.function_response + ] == ["test_tool"] @patch( @@ -315,6 +320,7 @@ async def test_function_call_without_parameters( chat_response = MagicMock() mock_chat.send_message_async.return_value = chat_response mock_part = MagicMock() + mock_part.text = "" mock_part.function_call = FunctionCall(name="test_tool", args={}) def tool_call( @@ -403,6 +409,7 @@ async def test_function_exception( chat_response = MagicMock() mock_chat.send_message_async.return_value = chat_response mock_part = MagicMock() + mock_part.text = "" mock_part.function_call = FunctionCall(name="test_tool", args={"param1": 1}) def tool_call( @@ -543,7 +550,7 @@ async def test_invalid_llm_api( assert result.response.response_type == intent.IntentResponseType.ERROR, result assert result.response.error_code == "unknown", result assert result.response.as_dict()["speech"]["plain"]["speech"] == ( - "Error preparing LLM API: API invalid_llm_api not found" + "Error preparing LLM API" ) From b2ec72d75fd93d13b0b63b165909d2b353bf6608 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 29 Jan 2025 16:58:33 +0100 Subject: [PATCH 0246/3148] Persist backup restore status after core restart (#136838) * Persist backup restore status after core restart * Don't blow up if restore result file can't be removed * Update tests --- homeassistant/backup_restore.py | 32 ++++- homeassistant/components/backup/__init__.py | 2 + homeassistant/components/backup/manager.py | 68 +++++++++- homeassistant/components/backup/websocket.py | 2 + homeassistant/components/hassio/backup.py | 8 ++ .../backup/snapshots/test_backup.ambr | 10 ++ .../backup/snapshots/test_websocket.ambr | 42 ++++++ tests/components/backup/test_manager.py | 121 ++++++++++++++++++ tests/components/cloud/test_backup.py | 2 + .../onboarding/snapshots/test_views.ambr | 22 ++-- tests/components/onboarding/test_views.py | 12 +- tests/components/synology_dsm/test_backup.py | 2 + tests/test_backup_restore.py | 53 ++++++++ 13 files changed, 356 insertions(+), 20 deletions(-) diff --git a/homeassistant/backup_restore.py b/homeassistant/backup_restore.py index 3d24d807a06..9287aa2bf1b 100644 --- a/homeassistant/backup_restore.py +++ b/homeassistant/backup_restore.py @@ -18,6 +18,7 @@ import securetar from .const import __version__ as HA_VERSION RESTORE_BACKUP_FILE = ".HA_RESTORE" +RESTORE_BACKUP_RESULT_FILE = ".HA_RESTORE_RESULT" KEEP_BACKUPS = ("backups",) KEEP_DATABASE = ( "home-assistant_v2.db", @@ -62,7 +63,10 @@ def restore_backup_file_content(config_dir: Path) -> RestoreBackupFileContent | restore_database=instruction_content["restore_database"], restore_homeassistant=instruction_content["restore_homeassistant"], ) - except (FileNotFoundError, KeyError, json.JSONDecodeError): + except FileNotFoundError: + return None + except (KeyError, json.JSONDecodeError) as err: + _write_restore_result_file(config_dir, False, err) return None finally: # Always remove the backup instruction file to prevent a boot loop @@ -159,6 +163,23 @@ def _extract_backup( ) +def _write_restore_result_file( + config_dir: Path, success: bool, error: Exception | None +) -> None: + """Write the restore result file.""" + result_path = config_dir.joinpath(RESTORE_BACKUP_RESULT_FILE) + result_path.write_text( + json.dumps( + { + "success": success, + "error": str(error) if error else None, + "error_type": str(type(error).__name__) if error else None, + } + ), + encoding="utf-8", + ) + + def restore_backup(config_dir_path: str) -> bool: """Restore the backup file if any. @@ -177,7 +198,14 @@ def restore_backup(config_dir_path: str) -> bool: restore_content=restore_content, ) except FileNotFoundError as err: - raise ValueError(f"Backup file {backup_file_path} does not exist") from err + file_not_found = ValueError(f"Backup file {backup_file_path} does not exist") + _write_restore_result_file(config_dir, False, file_not_found) + raise file_not_found from err + except Exception as err: + _write_restore_result_file(config_dir, False, err) + raise + else: + _write_restore_result_file(config_dir, True, None) if restore_content.remove_after_restore: backup_file_path.unlink(missing_ok=True) _LOGGER.info("Restore complete, restarting") diff --git a/homeassistant/components/backup/__init__.py b/homeassistant/components/backup/__init__.py index ce3fea80f67..d3903c2d679 100644 --- a/homeassistant/components/backup/__init__.py +++ b/homeassistant/components/backup/__init__.py @@ -26,6 +26,7 @@ from .manager import ( BackupReaderWriterError, CoreBackupReaderWriter, CreateBackupEvent, + IdleEvent, IncorrectPasswordError, ManagerBackup, NewBackup, @@ -47,6 +48,7 @@ __all__ = [ "BackupReaderWriterError", "CreateBackupEvent", "Folder", + "IdleEvent", "IncorrectPasswordError", "LocalBackupAgent", "ManagerBackup", diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index 1f439160381..fc56505e343 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -19,7 +19,11 @@ from typing import IO, TYPE_CHECKING, Any, Protocol, TypedDict, cast import aiohttp from securetar import SecureTarFile, atomic_contents_add -from homeassistant.backup_restore import RESTORE_BACKUP_FILE, password_to_key +from homeassistant.backup_restore import ( + RESTORE_BACKUP_FILE, + RESTORE_BACKUP_RESULT_FILE, + password_to_key, +) from homeassistant.const import __version__ as HAVERSION from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import ( @@ -28,7 +32,7 @@ from homeassistant.helpers import ( issue_registry as ir, ) from homeassistant.helpers.json import json_bytes -from homeassistant.util import dt as dt_util +from homeassistant.util import dt as dt_util, json as json_util from . import util as backup_util from .agent import ( @@ -261,6 +265,14 @@ class BackupReaderWriter(abc.ABC): ) -> None: """Restore a backup.""" + @abc.abstractmethod + async def async_resume_restore_progress_after_restart( + self, + *, + on_progress: Callable[[RestoreBackupEvent | IdleEvent], None], + ) -> None: + """Get restore events after core restart.""" + class BackupReaderWriterError(BackupError): """Backup reader/writer error.""" @@ -318,6 +330,10 @@ class BackupManager: self.config.load(stored["config"]) self.known_backups.load(stored["backups"]) + await self._reader_writer.async_resume_restore_progress_after_restart( + on_progress=self.async_on_backup_event + ) + await self.load_platforms() @property @@ -1605,6 +1621,54 @@ class CoreBackupReaderWriter(BackupReaderWriter): ) await self._hass.services.async_call("homeassistant", "restart", blocking=True) + async def async_resume_restore_progress_after_restart( + self, + *, + on_progress: Callable[[RestoreBackupEvent | IdleEvent], None], + ) -> None: + """Check restore status after core restart.""" + + def _read_restore_file() -> json_util.JsonObjectType | None: + """Read the restore file.""" + result_path = Path(self._hass.config.path(RESTORE_BACKUP_RESULT_FILE)) + + try: + restore_result = json_util.json_loads_object(result_path.read_bytes()) + except FileNotFoundError: + return None + finally: + try: + result_path.unlink(missing_ok=True) + except OSError as err: + LOGGER.warning( + "Unexpected error deleting backup restore result file: %s %s", + type(err), + err, + ) + + return restore_result + + restore_result = await self._hass.async_add_executor_job(_read_restore_file) + if not restore_result: + return + + success = restore_result["success"] + if not success: + LOGGER.warning( + "Backup restore failed with %s: %s", + restore_result["error_type"], + restore_result["error"], + ) + state = RestoreBackupState.COMPLETED if success else RestoreBackupState.FAILED + on_progress( + RestoreBackupEvent( + reason=cast(str, restore_result["error"]), + stage=None, + state=state, + ) + ) + on_progress(IdleEvent()) + def _generate_backup_id(date: str, name: str) -> str: """Generate a backup ID.""" diff --git a/homeassistant/components/backup/websocket.py b/homeassistant/components/backup/websocket.py index d8a425ab6ba..feb762bb50b 100644 --- a/homeassistant/components/backup/websocket.py +++ b/homeassistant/components/backup/websocket.py @@ -60,8 +60,10 @@ async def handle_info( "backups": [backup.as_frontend_json() for backup in backups.values()], "last_attempted_automatic_backup": manager.config.data.last_attempted_automatic_backup, "last_completed_automatic_backup": manager.config.data.last_completed_automatic_backup, + "last_non_idle_event": manager.last_non_idle_event, "next_automatic_backup": manager.config.data.schedule.next_automatic_backup, "next_automatic_backup_additional": manager.config.data.schedule.next_automatic_backup_additional, + "state": manager.state, }, ) diff --git a/homeassistant/components/hassio/backup.py b/homeassistant/components/hassio/backup.py index 9362c03b0be..5318e4cd351 100644 --- a/homeassistant/components/hassio/backup.py +++ b/homeassistant/components/hassio/backup.py @@ -29,6 +29,7 @@ from homeassistant.components.backup import ( BackupReaderWriterError, CreateBackupEvent, Folder, + IdleEvent, IncorrectPasswordError, NewBackup, RestoreBackupEvent, @@ -456,6 +457,13 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): finally: unsub() + async def async_resume_restore_progress_after_restart( + self, + *, + on_progress: Callable[[RestoreBackupEvent | IdleEvent], None], + ) -> None: + """Check restore status after core restart.""" + @callback def _async_listen_job_events( self, job_id: str, on_event: Callable[[Mapping[str, Any]], None] diff --git a/tests/components/backup/snapshots/test_backup.ambr b/tests/components/backup/snapshots/test_backup.ambr index 441f79276a5..032eb7ac537 100644 --- a/tests/components/backup/snapshots/test_backup.ambr +++ b/tests/components/backup/snapshots/test_backup.ambr @@ -85,8 +85,10 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, + 'state': 'idle', }), 'success': True, 'type': 'result', @@ -117,8 +119,10 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, + 'state': 'idle', }), 'success': True, 'type': 'result', @@ -149,8 +153,10 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, + 'state': 'idle', }), 'success': True, 'type': 'result', @@ -181,8 +187,10 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, + 'state': 'idle', }), 'success': True, 'type': 'result', @@ -213,8 +221,10 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, + 'state': 'idle', }), 'success': True, 'type': 'result', diff --git a/tests/components/backup/snapshots/test_websocket.ambr b/tests/components/backup/snapshots/test_websocket.ambr index f5a22201138..7ea911496de 100644 --- a/tests/components/backup/snapshots/test_websocket.ambr +++ b/tests/components/backup/snapshots/test_websocket.ambr @@ -2977,8 +2977,10 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, + 'state': 'idle', }), 'success': True, 'type': 'result', @@ -3005,8 +3007,10 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, + 'state': 'idle', }), 'success': True, 'type': 'result', @@ -3050,8 +3054,10 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, + 'state': 'idle', }), 'success': True, 'type': 'result', @@ -3078,8 +3084,10 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, + 'state': 'idle', }), 'success': True, 'type': 'result', @@ -3123,8 +3131,10 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, + 'state': 'idle', }), 'success': True, 'type': 'result', @@ -3179,8 +3189,10 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, + 'state': 'idle', }), 'success': True, 'type': 'result', @@ -3219,8 +3231,10 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, + 'state': 'idle', }), 'success': True, 'type': 'result', @@ -3270,8 +3284,10 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, + 'state': 'idle', }), 'success': True, 'type': 'result', @@ -3319,8 +3335,10 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, + 'state': 'idle', }), 'success': True, 'type': 'result', @@ -3375,8 +3393,10 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, + 'state': 'idle', }), 'success': True, 'type': 'result', @@ -3432,8 +3452,10 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, + 'state': 'idle', }), 'success': True, 'type': 'result', @@ -3490,8 +3512,10 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, + 'state': 'idle', }), 'success': True, 'type': 'result', @@ -3546,8 +3570,10 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, + 'state': 'idle', }), 'success': True, 'type': 'result', @@ -3602,8 +3628,10 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, + 'state': 'idle', }), 'success': True, 'type': 'result', @@ -3658,8 +3686,10 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, + 'state': 'idle', }), 'success': True, 'type': 'result', @@ -3715,8 +3745,10 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, + 'state': 'idle', }), 'success': True, 'type': 'result', @@ -4181,8 +4213,10 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, + 'state': 'idle', }), 'success': True, 'type': 'result', @@ -4226,8 +4260,10 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, + 'state': 'idle', }), 'success': True, 'type': 'result', @@ -4275,8 +4311,10 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, + 'state': 'idle', }), 'success': True, 'type': 'result', @@ -4343,8 +4381,10 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, + 'state': 'idle', }), 'success': True, 'type': 'result', @@ -4389,8 +4429,10 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, + 'state': 'idle', }), 'success': True, 'type': 'result', diff --git a/tests/components/backup/test_manager.py b/tests/components/backup/test_manager.py index d2993e53410..5e5b0df74cd 100644 --- a/tests/components/backup/test_manager.py +++ b/tests/components/backup/test_manager.py @@ -397,8 +397,10 @@ async def test_initiate_backup( "agent_errors": {}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, + "last_non_idle_event": None, "next_automatic_backup": None, "next_automatic_backup_additional": False, + "state": "idle", } await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) @@ -626,8 +628,10 @@ async def test_initiate_backup_with_agent_error( "agent_errors": {}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, + "last_non_idle_event": None, "next_automatic_backup": None, "next_automatic_backup_additional": False, + "state": "idle", } await ws_client.send_json_auto_id( @@ -724,8 +728,15 @@ async def test_initiate_backup_with_agent_error( "agent_errors": {}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, + "last_non_idle_event": { + "manager_state": "create_backup", + "reason": "upload_failed", + "stage": None, + "state": "failed", + }, "next_automatic_backup": None, "next_automatic_backup_additional": False, + "state": "idle", } await hass.async_block_till_done() @@ -993,8 +1004,10 @@ async def test_initiate_backup_non_agent_upload_error( "agent_errors": {}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, + "last_non_idle_event": None, "next_automatic_backup": None, "next_automatic_backup_additional": False, + "state": "idle", } await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) @@ -1109,8 +1122,10 @@ async def test_initiate_backup_with_task_error( "agent_errors": {}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, + "last_non_idle_event": None, "next_automatic_backup": None, "next_automatic_backup_additional": False, + "state": "idle", } await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) @@ -1217,8 +1232,10 @@ async def test_initiate_backup_file_error( "agent_errors": {}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, + "last_non_idle_event": None, "next_automatic_backup": None, "next_automatic_backup_additional": False, + "state": "idle", } await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) @@ -1703,8 +1720,10 @@ async def test_receive_backup_agent_error( "agent_errors": {}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, + "last_non_idle_event": None, "next_automatic_backup": None, "next_automatic_backup_additional": False, + "state": "idle", } await ws_client.send_json_auto_id( @@ -1786,8 +1805,15 @@ async def test_receive_backup_agent_error( "agent_errors": {}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, + "last_non_idle_event": { + "manager_state": "receive_backup", + "reason": None, + "stage": None, + "state": "completed", + }, "next_automatic_backup": None, "next_automatic_backup_additional": False, + "state": "idle", } await hass.async_block_till_done() @@ -1848,8 +1874,10 @@ async def test_receive_backup_non_agent_upload_error( "agent_errors": {}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, + "last_non_idle_event": None, "next_automatic_backup": None, "next_automatic_backup_additional": False, + "state": "idle", } await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) @@ -1973,8 +2001,10 @@ async def test_receive_backup_file_write_error( "agent_errors": {}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, + "last_non_idle_event": None, "next_automatic_backup": None, "next_automatic_backup_additional": False, + "state": "idle", } await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) @@ -2086,8 +2116,10 @@ async def test_receive_backup_read_tar_error( "agent_errors": {}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, + "last_non_idle_event": None, "next_automatic_backup": None, "next_automatic_backup_additional": False, + "state": "idle", } await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) @@ -2264,8 +2296,10 @@ async def test_receive_backup_file_read_error( "agent_errors": {}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, + "last_non_idle_event": None, "next_automatic_backup": None, "next_automatic_backup_additional": False, + "state": "idle", } await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) @@ -3034,8 +3068,10 @@ async def test_initiate_backup_per_agent_encryption( "agent_errors": {}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, + "last_non_idle_event": None, "next_automatic_backup": None, "next_automatic_backup_additional": False, + "state": "idle", } for command in commands: @@ -3127,3 +3163,88 @@ async def test_initiate_backup_per_agent_encryption( "name": "test", "with_automatic_settings": False, } + + +@pytest.mark.parametrize( + ("restore_result", "last_non_idle_event"), + [ + ( + {"error": None, "error_type": None, "success": True}, + { + "manager_state": "restore_backup", + "reason": None, + "stage": None, + "state": "completed", + }, + ), + ( + {"error": "Boom!", "error_type": "ValueError", "success": False}, + { + "manager_state": "restore_backup", + "reason": "Boom!", + "stage": None, + "state": "failed", + }, + ), + ], +) +async def test_restore_progress_after_restart( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + restore_result: dict[str, Any], + last_non_idle_event: dict[str, Any], +) -> None: + """Test restore backup progress after restart.""" + + with patch( + "pathlib.Path.read_bytes", return_value=json.dumps(restore_result).encode() + ): + await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + ws_client = await hass_ws_client(hass) + await ws_client.send_json_auto_id({"type": "backup/info"}) + result = await ws_client.receive_json() + assert result["success"] is True + assert result["result"] == { + "agent_errors": {}, + "backups": [], + "last_attempted_automatic_backup": None, + "last_completed_automatic_backup": None, + "last_non_idle_event": last_non_idle_event, + "next_automatic_backup": None, + "next_automatic_backup_additional": False, + "state": "idle", + } + + +async def test_restore_progress_after_restart_fail_to_remove( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test restore backup progress after restart when failing to remove result file.""" + + with patch("pathlib.Path.unlink", side_effect=OSError("Boom!")): + await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + ws_client = await hass_ws_client(hass) + await ws_client.send_json_auto_id({"type": "backup/info"}) + result = await ws_client.receive_json() + assert result["success"] is True + assert result["result"] == { + "agent_errors": {}, + "backups": [], + "last_attempted_automatic_backup": None, + "last_completed_automatic_backup": None, + "last_non_idle_event": None, + "next_automatic_backup": None, + "next_automatic_backup_additional": False, + "state": "idle", + } + + assert ( + "Unexpected error deleting backup restore result file: Boom!" + in caplog.text + ) diff --git a/tests/components/cloud/test_backup.py b/tests/components/cloud/test_backup.py index 516dacd5f3d..c2513168ab9 100644 --- a/tests/components/cloud/test_backup.py +++ b/tests/components/cloud/test_backup.py @@ -205,8 +205,10 @@ async def test_agents_list_backups_fail_cloud( "backups": [], "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, + "last_non_idle_event": None, "next_automatic_backup": None, "next_automatic_backup_additional": False, + "state": "idle", } diff --git a/tests/components/onboarding/snapshots/test_views.ambr b/tests/components/onboarding/snapshots/test_views.ambr index 90428055823..b57c6cf96dd 100644 --- a/tests/components/onboarding/snapshots/test_views.ambr +++ b/tests/components/onboarding/snapshots/test_views.ambr @@ -10,9 +10,12 @@ 'version': '1.0.0', }), ]), - 'agent_ids': list([ - 'backup.local', - ]), + 'agents': dict({ + 'backup.local': dict({ + 'protected': True, + 'size': 0, + }), + }), 'backup_id': 'abc123', 'database_included': True, 'date': '1970-01-01T00:00:00.000Z', @@ -25,16 +28,17 @@ 'homeassistant_included': True, 'homeassistant_version': '2024.12.0', 'name': 'Test', - 'protected': False, - 'size': 0, 'with_automatic_settings': True, }), dict({ 'addons': list([ ]), - 'agent_ids': list([ - 'test.remote', - ]), + 'agents': dict({ + 'test.remote': dict({ + 'protected': True, + 'size': 0, + }), + }), 'backup_id': 'def456', 'database_included': False, 'date': '1980-01-01T00:00:00.000Z', @@ -47,8 +51,6 @@ 'homeassistant_included': True, 'homeassistant_version': '2024.12.0', 'name': 'Test 2', - 'protected': False, - 'size': 1, 'with_automatic_settings': None, }), ]), diff --git a/tests/components/onboarding/test_views.py b/tests/components/onboarding/test_views.py index 683d2c370f2..98f6426609e 100644 --- a/tests/components/onboarding/test_views.py +++ b/tests/components/onboarding/test_views.py @@ -798,6 +798,9 @@ async def test_onboarding_backup_info( backups = { "abc123": backup.ManagerBackup( addons=[backup.AddonInfo(name="Test", slug="test", version="1.0.0")], + agents={ + "backup.local": backup.manager.AgentBackupStatus(protected=True, size=0) + }, backup_id="abc123", date="1970-01-01T00:00:00.000Z", database_included=True, @@ -806,14 +809,14 @@ async def test_onboarding_backup_info( homeassistant_included=True, homeassistant_version="2024.12.0", name="Test", - protected=False, - size=0, - agent_ids=["backup.local"], failed_agent_ids=[], with_automatic_settings=True, ), "def456": backup.ManagerBackup( addons=[], + agents={ + "test.remote": backup.manager.AgentBackupStatus(protected=True, size=0) + }, backup_id="def456", date="1980-01-01T00:00:00.000Z", database_included=False, @@ -825,9 +828,6 @@ async def test_onboarding_backup_info( homeassistant_included=True, homeassistant_version="2024.12.0", name="Test 2", - protected=False, - size=1, - agent_ids=["test.remote"], failed_agent_ids=[], with_automatic_settings=None, ), diff --git a/tests/components/synology_dsm/test_backup.py b/tests/components/synology_dsm/test_backup.py index 0d4fd0dc080..cdbc5934c5f 100644 --- a/tests/components/synology_dsm/test_backup.py +++ b/tests/components/synology_dsm/test_backup.py @@ -332,8 +332,10 @@ async def test_agents_list_backups_error( "backups": [], "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, + "last_non_idle_event": None, "next_automatic_backup": None, "next_automatic_backup_additional": False, + "state": "idle", } diff --git a/tests/test_backup_restore.py b/tests/test_backup_restore.py index 10ea64a6a61..4c6bc930667 100644 --- a/tests/test_backup_restore.py +++ b/tests/test_backup_restore.py @@ -1,7 +1,10 @@ """Test methods in backup_restore.""" +from collections.abc import Generator +import json from pathlib import Path import tarfile +from typing import Any from unittest import mock import pytest @@ -11,6 +14,23 @@ from homeassistant import backup_restore from .common import get_test_config_dir +@pytest.fixture(autouse=True) +def remove_restore_result_file() -> Generator[None, Any, Any]: + """Remove the restore result file.""" + yield + Path(get_test_config_dir(".HA_RESTORE_RESULT")).unlink(missing_ok=True) + + +def restore_result_file_content() -> dict[str, Any] | None: + """Return the content of the restore result file.""" + try: + return json.loads( + Path(get_test_config_dir(".HA_RESTORE_RESULT")).read_text("utf-8") + ) + except FileNotFoundError: + return None + + @pytest.mark.parametrize( ("side_effect", "content", "expected"), [ @@ -87,6 +107,11 @@ def test_restoring_backup_that_does_not_exist() -> None: ), ): assert backup_restore.restore_backup(Path(get_test_config_dir())) is False + assert restore_result_file_content() == { + "error": f"Backup file {backup_file_path} does not exist", + "error_type": "ValueError", + "success": False, + } def test_restoring_backup_when_instructions_can_not_be_read() -> None: @@ -98,6 +123,7 @@ def test_restoring_backup_when_instructions_can_not_be_read() -> None: ), ): assert backup_restore.restore_backup(Path(get_test_config_dir())) is False + assert restore_result_file_content() is None def test_restoring_backup_that_is_not_a_file() -> None: @@ -121,6 +147,11 @@ def test_restoring_backup_that_is_not_a_file() -> None: ), ): assert backup_restore.restore_backup(Path(get_test_config_dir())) is False + assert restore_result_file_content() == { + "error": f"Backup file {backup_file_path} does not exist", + "error_type": "ValueError", + "success": False, + } def test_aborting_for_older_versions() -> None: @@ -152,6 +183,13 @@ def test_aborting_for_older_versions() -> None: ), ): assert backup_restore.restore_backup(config_dir) is True + assert restore_result_file_content() == { + "error": ( + "You need at least Home Assistant version 9999.99.99 to restore this backup" + ), + "error_type": "ValueError", + "success": False, + } @pytest.mark.parametrize( @@ -280,6 +318,11 @@ def test_removal_of_current_configuration_when_restoring( assert removed_directories == { Path(config_dir, d) for d in expected_removed_directories } + assert restore_result_file_content() == { + "error": None, + "error_type": None, + "success": True, + } def test_extracting_the_contents_of_a_backup_file() -> None: @@ -332,6 +375,11 @@ def test_extracting_the_contents_of_a_backup_file() -> None: assert { member.name for member in extractall_mock.mock_calls[-1].kwargs["members"] } == {"data", "data/.HA_VERSION", "data/.storage", "data/www"} + assert restore_result_file_content() == { + "error": None, + "error_type": None, + "success": True, + } @pytest.mark.parametrize( @@ -362,6 +410,11 @@ def test_remove_backup_file_after_restore( assert mock_unlink.call_count == unlink_calls for call in mock_unlink.mock_calls: assert call.args[0] == backup_file_path + assert restore_result_file_content() == { + "error": None, + "error_type": None, + "success": True, + } @pytest.mark.parametrize( From fa6df1cc2525241dd6672eee43983d5ccdac1531 Mon Sep 17 00:00:00 2001 From: Lars Date: Wed, 29 Jan 2025 17:15:54 +0100 Subject: [PATCH 0247/3148] Check for fullcolorsupport in fritzbox light (#136850) --- homeassistant/components/fritzbox/light.py | 25 ++++++++-------------- tests/components/fritzbox/test_light.py | 23 +++++--------------- 2 files changed, 14 insertions(+), 34 deletions(-) diff --git a/homeassistant/components/fritzbox/light.py b/homeassistant/components/fritzbox/light.py index f6a1ba4cc94..94d7d320704 100644 --- a/homeassistant/components/fritzbox/light.py +++ b/homeassistant/components/fritzbox/light.py @@ -4,8 +4,6 @@ from __future__ import annotations from typing import Any, cast -from requests.exceptions import HTTPError - from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_TEMP_KELVIN, @@ -124,27 +122,22 @@ class FritzboxLight(FritzBoxDeviceEntity, LightEntity): level = kwargs[ATTR_BRIGHTNESS] await self.hass.async_add_executor_job(self.data.set_level, level, True) if kwargs.get(ATTR_HS_COLOR) is not None: - # Try setunmappedcolor first. This allows free color selection, - # but we don't know if its supported by all devices. - try: - # HA gives 0..360 for hue, fritz light only supports 0..359 - unmapped_hue = int(kwargs[ATTR_HS_COLOR][0] % 360) - unmapped_saturation = round( - cast(float, kwargs[ATTR_HS_COLOR][1]) * 255.0 / 100.0 - ) + # HA gives 0..360 for hue, fritz light only supports 0..359 + unmapped_hue = int(kwargs[ATTR_HS_COLOR][0] % 360) + unmapped_saturation = round( + cast(float, kwargs[ATTR_HS_COLOR][1]) * 255.0 / 100.0 + ) + if self.data.fullcolorsupport: + LOGGER.debug("device has fullcolorsupport, using 'setunmappedcolor'") await self.hass.async_add_executor_job( self.data.set_unmapped_color, (unmapped_hue, unmapped_saturation), 0, True, ) - # This will raise 400 BAD REQUEST if the setunmappedcolor is not available - except HTTPError as err: - if err.response.status_code != 400: - raise + else: LOGGER.debug( - "fritzbox does not support method 'setunmappedcolor', fallback to" - " 'setcolor'" + "device has no fullcolorsupport, using supported colors with 'setcolor'" ) # find supported hs values closest to what user selected hue = min( diff --git a/tests/components/fritzbox/test_light.py b/tests/components/fritzbox/test_light.py index 47209075a86..fe8bb32066e 100644 --- a/tests/components/fritzbox/test_light.py +++ b/tests/components/fritzbox/test_light.py @@ -3,7 +3,6 @@ from datetime import timedelta from unittest.mock import Mock, call -import pytest from requests.exceptions import HTTPError from homeassistant.components.fritzbox.const import ( @@ -166,6 +165,7 @@ async def test_turn_on_color(hass: HomeAssistant, fritz: Mock) -> None: device.get_colors.return_value = { "Red": [("100", "70", "10"), ("100", "50", "10"), ("100", "30", "10")] } + device.fullcolorsupport = True assert await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) @@ -178,13 +178,14 @@ async def test_turn_on_color(hass: HomeAssistant, fritz: Mock) -> None: assert device.set_state_on.call_count == 1 assert device.set_level.call_count == 1 assert device.set_unmapped_color.call_count == 1 + assert device.set_color.call_count == 0 assert device.set_level.call_args_list == [call(100, True)] assert device.set_unmapped_color.call_args_list == [ call((100, round(70 * 255.0 / 100.0)), 0, True) ] -async def test_turn_on_color_unsupported_api_method( +async def test_turn_on_color_no_fullcolorsupport( hass: HomeAssistant, fritz: Mock ) -> None: """Test turn device on in mapped color mode if unmapped is not supported.""" @@ -193,16 +194,11 @@ async def test_turn_on_color_unsupported_api_method( device.get_colors.return_value = { "Red": [("100", "70", "10"), ("100", "50", "10"), ("100", "30", "10")] } + device.fullcolorsupport = False assert await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) - # test fallback to `setcolor` - error = HTTPError("Bad Request") - error.response = Mock() - error.response.status_code = 400 - device.set_unmapped_color.side_effect = error - await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, @@ -212,19 +208,10 @@ async def test_turn_on_color_unsupported_api_method( assert device.set_state_on.call_count == 1 assert device.set_level.call_count == 1 assert device.set_color.call_count == 1 + assert device.set_unmapped_color.call_count == 0 assert device.set_level.call_args_list == [call(100, True)] assert device.set_color.call_args_list == [call((100, 70), 0, True)] - # test for unknown error - error.response.status_code = 500 - with pytest.raises(HTTPError, match="Bad Request"): - await hass.services.async_call( - LIGHT_DOMAIN, - SERVICE_TURN_ON, - {ATTR_ENTITY_ID: ENTITY_ID, ATTR_BRIGHTNESS: 100, ATTR_HS_COLOR: (100, 70)}, - True, - ) - async def test_turn_off(hass: HomeAssistant, fritz: Mock) -> None: """Test turn device off.""" From 35e395277058b9d2c078f0f9ee08824a6e8cde06 Mon Sep 17 00:00:00 2001 From: Nathan Spencer Date: Wed, 29 Jan 2025 09:28:09 -0700 Subject: [PATCH 0248/3148] Add DHCP discovery to balboa (#136762) --- .../components/balboa/config_flow.py | 47 ++++++- homeassistant/components/balboa/manifest.json | 8 ++ homeassistant/components/balboa/strings.json | 4 + homeassistant/generated/dhcp.py | 8 ++ tests/components/balboa/test_config_flow.py | 124 +++++++++++++++++- 5 files changed, 182 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/balboa/config_flow.py b/homeassistant/components/balboa/config_flow.py index fccfeceb331..24375ad4e55 100644 --- a/homeassistant/components/balboa/config_flow.py +++ b/homeassistant/components/balboa/config_flow.py @@ -10,7 +10,7 @@ from pybalboa.exceptions import SpaConnectionError import voluptuous as vol from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_HOST +from homeassistant.const import CONF_HOST, CONF_MODEL from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import format_mac @@ -18,6 +18,7 @@ from homeassistant.helpers.schema_config_entry_flow import ( SchemaFlowFormStep, SchemaOptionsFlowHandler, ) +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .const import CONF_SYNC_TIME, DOMAIN @@ -55,7 +56,8 @@ class BalboaSpaClientFlowHandler(ConfigFlow, domain=DOMAIN): VERSION = 1 - _host: str | None + _host: str + _model: str @staticmethod @callback @@ -63,6 +65,43 @@ class BalboaSpaClientFlowHandler(ConfigFlow, domain=DOMAIN): """Get the options flow for this handler.""" return SchemaOptionsFlowHandler(config_entry, OPTIONS_FLOW) + async def async_step_dhcp( + self, discovery_info: DhcpServiceInfo + ) -> ConfigFlowResult: + """Handle DHCP discovery.""" + await self.async_set_unique_id(format_mac(discovery_info.macaddress)) + self._abort_if_unique_id_configured(updates={CONF_HOST: discovery_info.ip}) + self._async_abort_entries_match({CONF_HOST: discovery_info.ip}) + + error = None + try: + info = await validate_input({CONF_HOST: discovery_info.ip}) + except CannotConnect: + error = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception") + error = "unknown" + if not error: + self._host = discovery_info.ip + self._model = info["title"] + self.context["title_placeholders"] = {CONF_MODEL: self._model} + return await self.async_step_discovery_confirm() + return self.async_abort(reason=error) + + async def async_step_discovery_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Allow the user to confirm adding the device.""" + if user_input is not None: + data = {CONF_HOST: self._host} + return self.async_create_entry(title=self._model, data=data) + + self._set_confirm_only() + return self.async_show_form( + step_id="discovery_confirm", + description_placeholders={CONF_HOST: self._host}, + ) + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -78,7 +117,9 @@ class BalboaSpaClientFlowHandler(ConfigFlow, domain=DOMAIN): _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: - await self.async_set_unique_id(info["formatted_mac"]) + await self.async_set_unique_id( + info["formatted_mac"], raise_on_progress=False + ) self._abort_if_unique_id_configured() return self.async_create_entry(title=info["title"], data=user_input) diff --git a/homeassistant/components/balboa/manifest.json b/homeassistant/components/balboa/manifest.json index d7c15bab88f..867e277358c 100644 --- a/homeassistant/components/balboa/manifest.json +++ b/homeassistant/components/balboa/manifest.json @@ -3,6 +3,14 @@ "name": "Balboa Spa Client", "codeowners": ["@garbled1", "@natekspencer"], "config_flow": true, + "dhcp": [ + { + "registered_devices": true + }, + { + "macaddress": "001527*" + } + ], "documentation": "https://www.home-assistant.io/integrations/balboa", "iot_class": "local_push", "loggers": ["pybalboa"], diff --git a/homeassistant/components/balboa/strings.json b/homeassistant/components/balboa/strings.json index 6ced7dfd8c3..c00567a6052 100644 --- a/homeassistant/components/balboa/strings.json +++ b/homeassistant/components/balboa/strings.json @@ -1,5 +1,6 @@ { "config": { + "flow_title": "{model}", "step": { "user": { "description": "Connect to the Balboa Wi-Fi device", @@ -9,6 +10,9 @@ "data_description": { "host": "Hostname or IP address of your Balboa Spa Wi-Fi Device. For example, 192.168.1.58." } + }, + "confirm_discovery": { + "description": "Do you want to set up the spa at {host}?" } }, "error": { diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py index 7d14ab0f444..b9d51ac1006 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -61,6 +61,14 @@ DHCP: Final[list[dict[str, str | bool]]] = [ "hostname": "axis-e82725*", "macaddress": "E82725*", }, + { + "domain": "balboa", + "registered_devices": True, + }, + { + "domain": "balboa", + "macaddress": "001527*", + }, { "domain": "blink", "hostname": "blink*", diff --git a/tests/components/balboa/test_config_flow.py b/tests/components/balboa/test_config_flow.py index afa170577df..d81edaad3b4 100644 --- a/tests/components/balboa/test_config_flow.py +++ b/tests/components/balboa/test_config_flow.py @@ -3,19 +3,23 @@ from unittest.mock import MagicMock, patch from pybalboa.exceptions import SpaConnectionError +import pytest from homeassistant import config_entries from homeassistant.components.balboa.const import CONF_SYNC_TIME, DOMAIN from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from tests.common import MockConfigEntry -TEST_DATA = { - CONF_HOST: "1.1.1.1", -} -TEST_ID = "FakeBalboa" +TEST_HOST = "1.1.1.1" +TEST_DATA = {CONF_HOST: TEST_HOST} +TEST_MAC = "ef:ef:ef:c0:ff:ee" +TEST_DHCP_SERVICE_INFO = DhcpServiceInfo( + ip=TEST_HOST, macaddress=TEST_MAC.replace(":", ""), hostname="fakespa" +) async def test_form(hass: HomeAssistant, client: MagicMock) -> None: @@ -107,7 +111,7 @@ async def test_unknown_error(hass: HomeAssistant, client: MagicMock) -> None: async def test_already_configured(hass: HomeAssistant, client: MagicMock) -> None: """Test when provided credentials are already configured.""" - MockConfigEntry(domain=DOMAIN, data=TEST_DATA, unique_id=TEST_ID).add_to_hass(hass) + MockConfigEntry(domain=DOMAIN, data=TEST_DATA, unique_id=TEST_MAC).add_to_hass(hass) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -138,7 +142,7 @@ async def test_already_configured(hass: HomeAssistant, client: MagicMock) -> Non async def test_options_flow(hass: HomeAssistant, client: MagicMock) -> None: """Test specifying non default settings using options flow.""" - config_entry = MockConfigEntry(domain=DOMAIN, data=TEST_DATA, unique_id=TEST_ID) + config_entry = MockConfigEntry(domain=DOMAIN, data=TEST_DATA, unique_id=TEST_MAC) config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) @@ -161,3 +165,111 @@ async def test_options_flow(hass: HomeAssistant, client: MagicMock) -> None: assert result["type"] is FlowResultType.CREATE_ENTRY assert dict(config_entry.options) == {CONF_SYNC_TIME: True} + + +async def test_dhcp_discovery(hass: HomeAssistant, client: MagicMock) -> None: + """Test we can process the discovery from dhcp.""" + with patch( + "homeassistant.components.balboa.config_flow.SpaClient.__aenter__", + return_value=client, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data=TEST_DHCP_SERVICE_INFO, + ) + + assert result["type"] is FlowResultType.FORM + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "FakeSpa" + assert result["data"] == TEST_DATA + assert result["result"].unique_id == TEST_MAC + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data=TEST_DHCP_SERVICE_INFO, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_dhcp_discovery_updates_host( + hass: HomeAssistant, client: MagicMock +) -> None: + """Test dhcp discovery updates host and aborts.""" + entry = MockConfigEntry(domain=DOMAIN, data=TEST_DATA, unique_id=TEST_MAC) + entry.add_to_hass(hass) + + updated_ip = "1.1.1.2" + TEST_DHCP_SERVICE_INFO.ip = updated_ip + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data=TEST_DHCP_SERVICE_INFO, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + assert entry.data[CONF_HOST] == updated_ip + + +@pytest.mark.parametrize( + ("side_effect", "reason"), + [ + (SpaConnectionError, "cannot_connect"), + (Exception, "unknown"), + ], +) +async def test_dhcp_discovery_failed( + hass: HomeAssistant, client: MagicMock, side_effect: Exception, reason: str +) -> None: + """Test failed setup from dhcp.""" + with patch( + "homeassistant.components.balboa.config_flow.SpaClient.__aenter__", + return_value=client, + side_effect=side_effect(), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data=TEST_DHCP_SERVICE_INFO, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == reason + + +async def test_dhcp_discovery_manual_user_setup( + hass: HomeAssistant, client: MagicMock +) -> None: + """Test dhcp discovery with manual user setup.""" + with patch( + "homeassistant.components.balboa.config_flow.SpaClient.__aenter__", + return_value=client, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data=TEST_DHCP_SERVICE_INFO, + ) + + assert result["type"] is FlowResultType.FORM + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + TEST_DATA, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == TEST_DATA From 63f34e346a7a78e5d9e1d9265ab20d334dcb7157 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 29 Jan 2025 17:28:32 +0100 Subject: [PATCH 0249/3148] Fix spelling of "API" for consistency in Home Assistant UI (#136843) --- homeassistant/components/fivem/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/fivem/strings.json b/homeassistant/components/fivem/strings.json index fd58922a481..f925a625259 100644 --- a/homeassistant/components/fivem/strings.json +++ b/homeassistant/components/fivem/strings.json @@ -14,7 +14,7 @@ }, "error": { "cannot_connect": "Failed to connect. Please check the host and port and try again. Also ensure that you are running the latest FiveM server.", - "invalid_game_name": "The api of the game you are trying to connect to is not a FiveM game.", + "invalid_game_name": "The API of the game you are trying to connect to is not a FiveM game.", "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { From acbf40c384f6420ead8ca1bd5e0bd232cb06c3a4 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 29 Jan 2025 17:33:31 +0100 Subject: [PATCH 0250/3148] Update frontend to 20250129.0 (#136852) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 2724569d1ed..f4e426485c8 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -21,5 +21,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20250109.2"] + "requirements": ["home-assistant-frontend==20250129.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index f29c00244a6..6d9e8f43755 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -37,7 +37,7 @@ habluetooth==3.14.0 hass-nabucasa==0.88.1 hassil==2.2.0 home-assistant-bluetooth==1.13.0 -home-assistant-frontend==20250109.2 +home-assistant-frontend==20250129.0 home-assistant-intents==2025.1.28 httpx==0.28.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 05d040af2b0..0ab09a60906 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1143,7 +1143,7 @@ hole==0.8.0 holidays==0.65 # homeassistant.components.frontend -home-assistant-frontend==20250109.2 +home-assistant-frontend==20250129.0 # homeassistant.components.conversation home-assistant-intents==2025.1.28 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 236b908f6d1..e2ab69f3cf2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -972,7 +972,7 @@ hole==0.8.0 holidays==0.65 # homeassistant.components.frontend -home-assistant-frontend==20250109.2 +home-assistant-frontend==20250129.0 # homeassistant.components.conversation home-assistant-intents==2025.1.28 From 72caf9d5a240b0719af5fe7e615c3c10fb59e090 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Wed, 29 Jan 2025 17:41:28 +0100 Subject: [PATCH 0251/3148] Tweak Matter discovery to ignore empty lists (#136854) --- homeassistant/components/matter/discovery.py | 84 ++++++++++----- homeassistant/components/matter/models.py | 37 +++++-- homeassistant/components/matter/select.py | 22 ++++ homeassistant/components/matter/sensor.py | 12 +++ .../matter/snapshots/test_select.ambr | 102 ------------------ 5 files changed, 119 insertions(+), 138 deletions(-) diff --git a/homeassistant/components/matter/discovery.py b/homeassistant/components/matter/discovery.py index 7ca64482763..7102b693e45 100644 --- a/homeassistant/components/matter/discovery.py +++ b/homeassistant/components/matter/discovery.py @@ -19,7 +19,7 @@ from .event import DISCOVERY_SCHEMAS as EVENT_SCHEMAS from .fan import DISCOVERY_SCHEMAS as FAN_SCHEMAS from .light import DISCOVERY_SCHEMAS as LIGHT_SCHEMAS from .lock import DISCOVERY_SCHEMAS as LOCK_SCHEMAS -from .models import MatterDiscoverySchema, MatterEntityInfo +from .models import UNSET, MatterDiscoverySchema, MatterEntityInfo from .number import DISCOVERY_SCHEMAS as NUMBER_SCHEMAS from .select import DISCOVERY_SCHEMAS as SELECT_SCHEMAS from .sensor import DISCOVERY_SCHEMAS as SENSOR_SCHEMAS @@ -67,6 +67,8 @@ def async_discover_entities( if any(x in schema.required_attributes for x in discovered_attributes): continue + primary_attribute = schema.required_attributes[0] + # check vendor_id if ( schema.vendor_id is not None @@ -121,31 +123,6 @@ def async_discover_entities( ): continue - # check if value exists but is none/null - if not schema.allow_none_value and any( - endpoint.get_attribute_value(None, val_schema) in (None, NullValue) - for val_schema in schema.required_attributes - ): - continue - - # check for required value in (primary) attribute - primary_attribute = schema.required_attributes[0] - primary_value = endpoint.get_attribute_value(None, primary_attribute) - if schema.value_contains is not None and ( - isinstance(primary_value, list) - and schema.value_contains not in primary_value - ): - continue - - # check for value that may not be present - if schema.value_is_not is not None and ( - schema.value_is_not == primary_value - or ( - isinstance(primary_value, list) and schema.value_is_not in primary_value - ) - ): - continue - # check for required value in cluster featuremap if schema.featuremap_contains is not None and ( not bool( @@ -159,6 +136,61 @@ def async_discover_entities( ): continue + # BEGIN checks on actual attribute values + # these are the least likely to be used and least efficient, so they are checked last + + # check if PRIMARY value exists but is none/null + if not schema.allow_none_value and any( + endpoint.get_attribute_value(None, val_schema) in (None, NullValue) + for val_schema in schema.required_attributes + ): + continue + + # check for required value in PRIMARY attribute + primary_value = endpoint.get_attribute_value(None, primary_attribute) + if schema.value_contains is not UNSET and ( + isinstance(primary_value, list) + and schema.value_contains not in primary_value + ): + continue + + # check for value that may not be present in PRIMARY attribute + if schema.value_is_not is not UNSET and ( + schema.value_is_not == primary_value + or ( + isinstance(primary_value, list) and schema.value_is_not in primary_value + ) + ): + continue + + # check for value that may not be present in SECONDARY attribute + secondary_attribute = ( + schema.required_attributes[1] + if len(schema.required_attributes) > 1 + else None + ) + secondary_value = ( + endpoint.get_attribute_value(None, secondary_attribute) + if secondary_attribute + else None + ) + if schema.secondary_value_is_not is not UNSET and ( + (schema.secondary_value_is_not == secondary_value) + or ( + isinstance(secondary_value, list) + and schema.secondary_value_is_not in secondary_value + ) + ): + continue + + # check for required value in SECONDARY attribute + if schema.secondary_value_contains is not UNSET and ( + isinstance(secondary_value, list) + and schema.secondary_value_contains not in secondary_value + ): + continue + + # FINISH all validation checks # all checks passed, this value belongs to an entity attributes_to_watch = list(schema.required_attributes) diff --git a/homeassistant/components/matter/models.py b/homeassistant/components/matter/models.py index ea80d0eb903..4af7cc3c026 100644 --- a/homeassistant/components/matter/models.py +++ b/homeassistant/components/matter/models.py @@ -18,6 +18,14 @@ type SensorValueTypes = type[ ] +# A sentinel object to detect if a parameter is supplied or not. +class _UNSET_TYPE: + pass + + +UNSET = _UNSET_TYPE() + + class MatterDeviceInfo(TypedDict): """Dictionary with Matter Device info. @@ -111,16 +119,6 @@ class MatterDiscoverySchema: # are not discovered by other entities optional_attributes: tuple[type[ClusterAttributeDescriptor], ...] | None = None - # [optional] the primary attribute value must contain this value - # for example for the AcceptedCommandList - # NOTE: only works for list values - value_contains: Any | None = None - - # [optional] the primary attribute value must NOT have this value - # for example to filter out invalid values (such as empty string instead of null) - # in case of a list value, the list may not contain this value - value_is_not: Any | None = None - # [optional] the primary attribute's cluster featuremap must contain this value # for example for the DoorSensor on a DoorLock Cluster featuremap_contains: int | None = None @@ -131,3 +129,22 @@ class MatterDiscoverySchema: # [optional] the primary attribute value may not be null/None allow_none_value: bool = False + + # [optional] the primary attribute value must contain this value + # for example for the AcceptedCommandList + # NOTE: only works for list values + value_contains: Any = UNSET + + # [optional] the secondary (required) attribute value must contain this value + # for example for the AcceptedCommandList + # NOTE: only works for list values + secondary_value_contains: Any = UNSET + + # [optional] the primary attribute value must NOT have this value + # for example to filter out invalid values (such as empty string instead of null) + # in case of a list value, the list may not contain this value + value_is_not: Any = UNSET + + # [optional] the secondary (required) attribute value must NOT have this value + # for example to filter out empty lists in list sensor values + secondary_value_is_not: Any = UNSET diff --git a/homeassistant/components/matter/select.py b/homeassistant/components/matter/select.py index ab3e708d7a9..dd4f8314bef 100644 --- a/homeassistant/components/matter/select.py +++ b/homeassistant/components/matter/select.py @@ -217,6 +217,8 @@ DISCOVERY_SCHEMAS = [ clusters.ModeSelect.Attributes.CurrentMode, clusters.ModeSelect.Attributes.SupportedModes, ), + # don't discover this entry if the supported modes list is empty + secondary_value_is_not=[], ), MatterDiscoverySchema( platform=Platform.SELECT, @@ -229,6 +231,8 @@ DISCOVERY_SCHEMAS = [ clusters.OvenMode.Attributes.CurrentMode, clusters.OvenMode.Attributes.SupportedModes, ), + # don't discover this entry if the supported modes list is empty + secondary_value_is_not=[], ), MatterDiscoverySchema( platform=Platform.SELECT, @@ -241,6 +245,8 @@ DISCOVERY_SCHEMAS = [ clusters.LaundryWasherMode.Attributes.CurrentMode, clusters.LaundryWasherMode.Attributes.SupportedModes, ), + # don't discover this entry if the supported modes list is empty + secondary_value_is_not=[], ), MatterDiscoverySchema( platform=Platform.SELECT, @@ -253,6 +259,8 @@ DISCOVERY_SCHEMAS = [ clusters.RefrigeratorAndTemperatureControlledCabinetMode.Attributes.CurrentMode, clusters.RefrigeratorAndTemperatureControlledCabinetMode.Attributes.SupportedModes, ), + # don't discover this entry if the supported modes list is empty + secondary_value_is_not=[], ), MatterDiscoverySchema( platform=Platform.SELECT, @@ -265,6 +273,8 @@ DISCOVERY_SCHEMAS = [ clusters.RvcCleanMode.Attributes.CurrentMode, clusters.RvcCleanMode.Attributes.SupportedModes, ), + # don't discover this entry if the supported modes list is empty + secondary_value_is_not=[], ), MatterDiscoverySchema( platform=Platform.SELECT, @@ -277,6 +287,8 @@ DISCOVERY_SCHEMAS = [ clusters.DishwasherMode.Attributes.CurrentMode, clusters.DishwasherMode.Attributes.SupportedModes, ), + # don't discover this entry if the supported modes list is empty + secondary_value_is_not=[], ), MatterDiscoverySchema( platform=Platform.SELECT, @@ -289,6 +301,8 @@ DISCOVERY_SCHEMAS = [ clusters.EnergyEvseMode.Attributes.CurrentMode, clusters.EnergyEvseMode.Attributes.SupportedModes, ), + # don't discover this entry if the supported modes list is empty + secondary_value_is_not=[], ), MatterDiscoverySchema( platform=Platform.SELECT, @@ -301,6 +315,8 @@ DISCOVERY_SCHEMAS = [ clusters.DeviceEnergyManagementMode.Attributes.CurrentMode, clusters.DeviceEnergyManagementMode.Attributes.SupportedModes, ), + # don't discover this entry if the supported modes list is empty + secondary_value_is_not=[], ), MatterDiscoverySchema( platform=Platform.SELECT, @@ -384,6 +400,8 @@ DISCOVERY_SCHEMAS = [ clusters.TemperatureControl.Attributes.SelectedTemperatureLevel, clusters.TemperatureControl.Attributes.SupportedTemperatureLevels, ), + # don't discover this entry if the supported levels list is empty + secondary_value_is_not=[], ), MatterDiscoverySchema( platform=Platform.SELECT, @@ -397,6 +415,8 @@ DISCOVERY_SCHEMAS = [ clusters.LaundryWasherControls.Attributes.SpinSpeedCurrent, clusters.LaundryWasherControls.Attributes.SpinSpeeds, ), + # don't discover this entry if the spinspeeds list is empty + secondary_value_is_not=[], ), MatterDiscoverySchema( platform=Platform.SELECT, @@ -412,5 +432,7 @@ DISCOVERY_SCHEMAS = [ clusters.LaundryWasherControls.Attributes.NumberOfRinses, clusters.LaundryWasherControls.Attributes.SupportedRinses, ), + # don't discover this entry if the supported rinses list is empty + secondary_value_is_not=[], ), ] diff --git a/homeassistant/components/matter/sensor.py b/homeassistant/components/matter/sensor.py index eaab91136c9..3503e112db5 100644 --- a/homeassistant/components/matter/sensor.py +++ b/homeassistant/components/matter/sensor.py @@ -809,6 +809,8 @@ DISCOVERY_SCHEMAS = [ clusters.OperationalState.Attributes.OperationalState, clusters.OperationalState.Attributes.OperationalStateList, ), + # don't discover this entry if the supported state list is empty + secondary_value_is_not=[], ), MatterDiscoverySchema( platform=Platform.SENSOR, @@ -822,6 +824,8 @@ DISCOVERY_SCHEMAS = [ clusters.OperationalState.Attributes.CurrentPhase, clusters.OperationalState.Attributes.PhaseList, ), + # don't discover this entry if the supported state list is empty + secondary_value_is_not=[], ), MatterDiscoverySchema( platform=Platform.SENSOR, @@ -835,6 +839,8 @@ DISCOVERY_SCHEMAS = [ clusters.RvcOperationalState.Attributes.CurrentPhase, clusters.RvcOperationalState.Attributes.PhaseList, ), + # don't discover this entry if the supported state list is empty + secondary_value_is_not=[], ), MatterDiscoverySchema( platform=Platform.SENSOR, @@ -848,6 +854,8 @@ DISCOVERY_SCHEMAS = [ clusters.OvenCavityOperationalState.Attributes.CurrentPhase, clusters.OvenCavityOperationalState.Attributes.PhaseList, ), + # don't discover this entry if the supported state list is empty + secondary_value_is_not=[], ), MatterDiscoverySchema( platform=Platform.SENSOR, @@ -877,6 +885,8 @@ DISCOVERY_SCHEMAS = [ clusters.RvcOperationalState.Attributes.OperationalStateList, ), allow_multi=True, # also used for vacuum entity + # don't discover this entry if the supported state list is empty + secondary_value_is_not=[], ), MatterDiscoverySchema( platform=Platform.SENSOR, @@ -891,5 +901,7 @@ DISCOVERY_SCHEMAS = [ clusters.OvenCavityOperationalState.Attributes.OperationalState, clusters.OvenCavityOperationalState.Attributes.OperationalStateList, ), + # don't discover this entry if the supported state list is empty + secondary_value_is_not=[], ), ] diff --git a/tests/components/matter/snapshots/test_select.ambr b/tests/components/matter/snapshots/test_select.ambr index e9aa169b4fd..d7ddf636ff9 100644 --- a/tests/components/matter/snapshots/test_select.ambr +++ b/tests/components/matter/snapshots/test_select.ambr @@ -1518,108 +1518,6 @@ 'state': 'previous', }) # --- -# name: test_selects[silabs_dishwasher][select.dishwasher_mode-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'options': list([ - ]), - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'select', - 'entity_category': None, - 'entity_id': 'select.dishwasher_mode', - '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': 'Mode', - 'platform': 'matter', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'mode', - 'unique_id': '00000000000004D2-0000000000000036-MatterNodeDevice-1-MatterDishwasherMode-89-1', - 'unit_of_measurement': None, - }) -# --- -# name: test_selects[silabs_dishwasher][select.dishwasher_mode-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Dishwasher Mode', - 'options': list([ - ]), - }), - 'context': , - 'entity_id': 'select.dishwasher_mode', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_selects[silabs_laundrywasher][select.laundrywasher_mode-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'options': list([ - ]), - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'select', - 'entity_category': None, - 'entity_id': 'select.laundrywasher_mode', - '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': 'Mode', - 'platform': 'matter', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'mode', - 'unique_id': '00000000000004D2-000000000000001D-MatterNodeDevice-1-MatterLaundryWasherMode-81-1', - 'unit_of_measurement': None, - }) -# --- -# name: test_selects[silabs_laundrywasher][select.laundrywasher_mode-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'LaundryWasher Mode', - 'options': list([ - ]), - }), - 'context': , - 'entity_id': 'select.laundrywasher_mode', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- # name: test_selects[silabs_laundrywasher][select.laundrywasher_number_of_rinses-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 3118831557e461628607ef0f6bfd7977c54817fb Mon Sep 17 00:00:00 2001 From: Tomer <57483589+tomer-w@users.noreply.github.com> Date: Wed, 29 Jan 2025 18:43:25 +0200 Subject: [PATCH 0252/3148] Ease understanding of integration failures (#134475) Co-authored-by: Shay Levy Co-authored-by: David Bonnes --- homeassistant/helpers/entity_platform.py | 5 +++-- homeassistant/setup.py | 4 ++-- tests/components/evohome/test_init.py | 2 +- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index 0d7614c569c..c8cc6979226 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -426,11 +426,12 @@ class EntityPlatform: type(exc).__name__, ) return False - except Exception: + except Exception as exc: logger.exception( - "Error while setting up %s platform for %s", + "Error while setting up %s platform for %s: %s", self.platform_name, self.domain, + exc, # noqa: TRY401 ) return False else: diff --git a/homeassistant/setup.py b/homeassistant/setup.py index 331389da7c6..1fa93a80cd5 100644 --- a/homeassistant/setup.py +++ b/homeassistant/setup.py @@ -425,8 +425,8 @@ async def _async_setup_component( ) return False # pylint: disable-next=broad-except - except (asyncio.CancelledError, SystemExit, Exception): - _LOGGER.exception("Error during setup of component %s", domain) + except (asyncio.CancelledError, SystemExit, Exception) as exc: + _LOGGER.exception("Error during setup of component %s: %s", domain, exc) # noqa: TRY401 async_notify_setup_error(hass, domain, integration.documentation) return False finally: diff --git a/tests/components/evohome/test_init.py b/tests/components/evohome/test_init.py index 49a854016ea..9b5fe6ad62d 100644 --- a/tests/components/evohome/test_init.py +++ b/tests/components/evohome/test_init.py @@ -25,7 +25,7 @@ SETUP_FAILED_ANTICIPATED = ( SETUP_FAILED_UNEXPECTED = ( "homeassistant.setup", logging.ERROR, - "Error during setup of component evohome", + "Error during setup of component evohome: ", ) AUTHENTICATION_FAILED = ( "homeassistant.components.evohome.helpers", From 660653e226fa0d672c5837be85744ff1745ad7a3 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 29 Jan 2025 17:44:29 +0100 Subject: [PATCH 0253/3148] Interrupt _CipherBackupStreamer workers (#136845) * Interrupt _CipherBackupStreamer workers * Fix cleanup * Only abort live threads --- homeassistant/components/backup/util.py | 94 +++++++++++++++---------- 1 file changed, 57 insertions(+), 37 deletions(-) diff --git a/homeassistant/components/backup/util.py b/homeassistant/components/backup/util.py index bea3fe1f4ef..2416aa5f28e 100644 --- a/homeassistant/components/backup/util.py +++ b/homeassistant/components/backup/util.py @@ -12,7 +12,6 @@ import os from pathlib import Path, PurePath from queue import SimpleQueue import tarfile -import threading from typing import IO, Any, Self, cast import aiohttp @@ -22,6 +21,7 @@ from homeassistant.backup_restore import password_to_key from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.util.json import JsonObjectType, json_loads_object +from homeassistant.util.thread import ThreadWithException from .const import BUF_SIZE, LOGGER from .models import AddonInfo, AgentBackup, Folder @@ -57,6 +57,12 @@ class BackupEmpty(DecryptError): _message = "No tar files found in the backup." +class AbortCipher(HomeAssistantError): + """Abort the cipher operation.""" + + _message = "Abort cipher operation." + + def make_backup_dir(path: Path) -> None: """Create a backup directory if it does not exist.""" path.mkdir(exist_ok=True) @@ -252,24 +258,29 @@ def decrypt_backup( """Decrypt a backup.""" error: Exception | None = None try: - with ( - tarfile.open( - fileobj=input_stream, mode="r|", bufsize=BUF_SIZE - ) as input_tar, - tarfile.open( - fileobj=output_stream, mode="w|", bufsize=BUF_SIZE - ) as output_tar, - ): - _decrypt_backup(input_tar, output_tar, password) - except (DecryptError, SecureTarError, tarfile.TarError) as err: - LOGGER.warning("Error decrypting backup: %s", err) - error = err - else: - # Pad the output stream to the requested minimum size - padding = max(minimum_size - output_stream.tell(), 0) - output_stream.write(b"\0" * padding) + try: + with ( + tarfile.open( + fileobj=input_stream, mode="r|", bufsize=BUF_SIZE + ) as input_tar, + tarfile.open( + fileobj=output_stream, mode="w|", bufsize=BUF_SIZE + ) as output_tar, + ): + _decrypt_backup(input_tar, output_tar, password) + except (DecryptError, SecureTarError, tarfile.TarError) as err: + LOGGER.warning("Error decrypting backup: %s", err) + error = err + else: + # Pad the output stream to the requested minimum size + padding = max(minimum_size - output_stream.tell(), 0) + output_stream.write(b"\0" * padding) + finally: + # Write an empty chunk to signal the end of the stream + output_stream.write(b"") + except AbortCipher: + LOGGER.debug("Cipher operation aborted") finally: - output_stream.write(b"") # Write an empty chunk to signal the end of the stream on_done(error) @@ -322,24 +333,29 @@ def encrypt_backup( """Encrypt a backup.""" error: Exception | None = None try: - with ( - tarfile.open( - fileobj=input_stream, mode="r|", bufsize=BUF_SIZE - ) as input_tar, - tarfile.open( - fileobj=output_stream, mode="w|", bufsize=BUF_SIZE - ) as output_tar, - ): - _encrypt_backup(input_tar, output_tar, password, nonces) - except (EncryptError, SecureTarError, tarfile.TarError) as err: - LOGGER.warning("Error encrypting backup: %s", err) - error = err - else: - # Pad the output stream to the requested minimum size - padding = max(minimum_size - output_stream.tell(), 0) - output_stream.write(b"\0" * padding) + try: + with ( + tarfile.open( + fileobj=input_stream, mode="r|", bufsize=BUF_SIZE + ) as input_tar, + tarfile.open( + fileobj=output_stream, mode="w|", bufsize=BUF_SIZE + ) as output_tar, + ): + _encrypt_backup(input_tar, output_tar, password, nonces) + except (EncryptError, SecureTarError, tarfile.TarError) as err: + LOGGER.warning("Error encrypting backup: %s", err) + error = err + else: + # Pad the output stream to the requested minimum size + padding = max(minimum_size - output_stream.tell(), 0) + output_stream.write(b"\0" * padding) + finally: + # Write an empty chunk to signal the end of the stream + output_stream.write(b"") + except AbortCipher: + LOGGER.debug("Cipher operation aborted") finally: - output_stream.write(b"") # Write an empty chunk to signal the end of the stream on_done(error) @@ -387,7 +403,7 @@ def _encrypt_backup( class _CipherWorkerStatus: done: asyncio.Event error: Exception | None = None - thread: threading.Thread + thread: ThreadWithException class _CipherBackupStreamer: @@ -440,7 +456,7 @@ class _CipherBackupStreamer: stream = await self._open_stream() reader = AsyncIteratorReader(self._hass, stream) writer = AsyncIteratorWriter(self._hass) - worker = threading.Thread( + worker = ThreadWithException( target=self._cipher_func, args=[reader, writer, self._password, on_done, self.size(), self._nonces], ) @@ -451,6 +467,10 @@ class _CipherBackupStreamer: async def wait(self) -> None: """Wait for the worker threads to finish.""" + for worker in self._workers: + if not worker.thread.is_alive(): + continue + worker.thread.raise_exc(AbortCipher) await asyncio.gather(*(worker.done.wait() for worker in self._workers)) From 89e6791fee25de98a0038b5d1e5f4e9ba7a64ea8 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 29 Jan 2025 17:50:36 +0100 Subject: [PATCH 0254/3148] Use runtime_data in control4 (#136403) --- homeassistant/components/control4/__init__.py | 102 ++++++++++-------- .../components/control4/config_flow.py | 10 +- homeassistant/components/control4/const.py | 8 -- .../components/control4/director_utils.py | 19 ++-- homeassistant/components/control4/entity.py | 9 +- homeassistant/components/control4/light.py | 32 +++--- .../components/control4/media_player.py | 32 +++--- 7 files changed, 101 insertions(+), 111 deletions(-) diff --git a/homeassistant/components/control4/__init__.py b/homeassistant/components/control4/__init__.py index 8d0eb72a73b..df5771fe5bb 100644 --- a/homeassistant/components/control4/__init__.py +++ b/homeassistant/components/control4/__init__.py @@ -2,8 +2,10 @@ from __future__ import annotations +from dataclasses import dataclass import json import logging +from typing import Any from aiohttp import client_exceptions from pyControl4.account import C4Account @@ -25,14 +27,7 @@ from homeassistant.helpers import aiohttp_client, device_registry as dr from .const import ( API_RETRY_TIMES, - CONF_ACCOUNT, - CONF_CONFIG_LISTENER, CONF_CONTROLLER_UNIQUE_ID, - CONF_DIRECTOR, - CONF_DIRECTOR_ALL_ITEMS, - CONF_DIRECTOR_MODEL, - CONF_DIRECTOR_SW_VERSION, - CONF_UI_CONFIGURATION, DEFAULT_SCAN_INTERVAL, DOMAIN, ) @@ -42,6 +37,23 @@ _LOGGER = logging.getLogger(__name__) PLATFORMS = [Platform.LIGHT, Platform.MEDIA_PLAYER] +@dataclass +class Control4RuntimeData: + """Control4 runtime data.""" + + account: C4Account + controller_unique_id: str + director: C4Director + director_all_items: list[dict[str, Any]] + director_model: str + director_sw_version: str + scan_interval: int + ui_configuration: dict[str, Any] | None + + +type Control4ConfigEntry = ConfigEntry[Control4RuntimeData] + + async def call_c4_api_retry(func, *func_args): """Call C4 API function and retry on failure.""" # Ruff doesn't understand this loop - the exception is always raised after the retries @@ -54,10 +66,8 @@ async def call_c4_api_retry(func, *func_args): raise ConfigEntryNotReady(exception) from exception -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: Control4ConfigEntry) -> bool: """Set up Control4 from a config entry.""" - hass.data.setdefault(DOMAIN, {}) - entry_data = hass.data[DOMAIN].setdefault(entry.entry_id, {}) account_session = aiohttp_client.async_get_clientsession(hass) config = entry.data @@ -76,10 +86,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: exception, ) return False - entry_data[CONF_ACCOUNT] = account - controller_unique_id = config[CONF_CONTROLLER_UNIQUE_ID] - entry_data[CONF_CONTROLLER_UNIQUE_ID] = controller_unique_id + controller_unique_id: str = config[CONF_CONTROLLER_UNIQUE_ID] director_token_dict = await call_c4_api_retry( account.getDirectorBearerToken, controller_unique_id @@ -89,15 +97,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: director = C4Director( config[CONF_HOST], director_token_dict[CONF_TOKEN], director_session ) - entry_data[CONF_DIRECTOR] = director controller_href = (await call_c4_api_retry(account.getAccountControllers))["href"] - entry_data[CONF_DIRECTOR_SW_VERSION] = await call_c4_api_retry( + director_sw_version = await call_c4_api_retry( account.getControllerOSVersion, controller_href ) _, model, mac_address = controller_unique_id.split("_", 3) - entry_data[CONF_DIRECTOR_MODEL] = model.upper() + director_model = model.upper() device_registry = dr.async_get(hass) device_registry.async_get_or_create( @@ -106,57 +113,60 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: connections={(dr.CONNECTION_NETWORK_MAC, mac_address)}, manufacturer="Control4", name=controller_unique_id, - model=entry_data[CONF_DIRECTOR_MODEL], - sw_version=entry_data[CONF_DIRECTOR_SW_VERSION], + model=director_model, + sw_version=director_sw_version, ) # Store all items found on controller for platforms to use - director_all_items = await director.getAllItemInfo() - director_all_items = json.loads(director_all_items) - entry_data[CONF_DIRECTOR_ALL_ITEMS] = director_all_items - - # Check if OS version is 3 or higher to get UI configuration - entry_data[CONF_UI_CONFIGURATION] = None - if int(entry_data[CONF_DIRECTOR_SW_VERSION].split(".")[0]) >= 3: - entry_data[CONF_UI_CONFIGURATION] = json.loads( - await director.getUiConfiguration() - ) - - # Load options from config entry - entry_data[CONF_SCAN_INTERVAL] = entry.options.get( - CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL + director_all_items: list[dict[str, Any]] = json.loads( + await director.getAllItemInfo() ) - entry_data[CONF_CONFIG_LISTENER] = entry.add_update_listener(update_listener) + # Check if OS version is 3 or higher to get UI configuration + ui_configuration: dict[str, Any] | None = None + if int(director_sw_version.split(".")[0]) >= 3: + ui_configuration = json.loads(await director.getUiConfiguration()) + + # Load options from config entry + scan_interval: int = entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) + + entry.runtime_data = Control4RuntimeData( + account=account, + controller_unique_id=controller_unique_id, + director=director, + director_all_items=director_all_items, + director_model=director_model, + director_sw_version=director_sw_version, + scan_interval=scan_interval, + ui_configuration=ui_configuration, + ) + + entry.async_on_unload(entry.add_update_listener(update_listener)) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def update_listener(hass: HomeAssistant, config_entry: ConfigEntry) -> None: +async def update_listener( + hass: HomeAssistant, config_entry: Control4ConfigEntry +) -> None: """Update when config_entry options update.""" _LOGGER.debug("Config entry was updated, rerunning setup") await hass.config_entries.async_reload(config_entry.entry_id) -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: Control4ConfigEntry) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - - hass.data[DOMAIN][entry.entry_id][CONF_CONFIG_LISTENER]() - if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) - _LOGGER.debug("Unloaded entry for %s", entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) -async def get_items_of_category(hass: HomeAssistant, entry: ConfigEntry, category: str): +async def get_items_of_category( + hass: HomeAssistant, entry: Control4ConfigEntry, category: str +): """Return a list of all Control4 items with the specified category.""" - director_all_items = hass.data[DOMAIN][entry.entry_id][CONF_DIRECTOR_ALL_ITEMS] return [ item - for item in director_all_items + for item in entry.runtime_data.director_all_items if "categories" in item and category in item["categories"] ] diff --git a/homeassistant/components/control4/config_flow.py b/homeassistant/components/control4/config_flow.py index 19fae1ef7ca..3ca96ca4e52 100644 --- a/homeassistant/components/control4/config_flow.py +++ b/homeassistant/components/control4/config_flow.py @@ -11,12 +11,7 @@ from pyControl4.director import C4Director from pyControl4.error_handling import NotFound, Unauthorized import voluptuous as vol -from homeassistant.config_entries import ( - ConfigEntry, - ConfigFlow, - ConfigFlowResult, - OptionsFlow, -) +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, @@ -28,6 +23,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import aiohttp_client, config_validation as cv from homeassistant.helpers.device_registry import format_mac +from . import Control4ConfigEntry from .const import ( CONF_CONTROLLER_UNIQUE_ID, DEFAULT_SCAN_INTERVAL, @@ -151,7 +147,7 @@ class Control4ConfigFlow(ConfigFlow, domain=DOMAIN): @staticmethod @callback def async_get_options_flow( - config_entry: ConfigEntry, + config_entry: Control4ConfigEntry, ) -> OptionsFlowHandler: """Get the options flow for this handler.""" return OptionsFlowHandler() diff --git a/homeassistant/components/control4/const.py b/homeassistant/components/control4/const.py index 57074c00108..2fe9c42849b 100644 --- a/homeassistant/components/control4/const.py +++ b/homeassistant/components/control4/const.py @@ -7,14 +7,6 @@ MIN_SCAN_INTERVAL = 1 API_RETRY_TIMES = 5 -CONF_ACCOUNT = "account" -CONF_DIRECTOR = "director" -CONF_DIRECTOR_SW_VERSION = "director_sw_version" -CONF_DIRECTOR_MODEL = "director_model" -CONF_DIRECTOR_ALL_ITEMS = "director_all_items" -CONF_UI_CONFIGURATION = "ui_configuration" CONF_CONTROLLER_UNIQUE_ID = "controller_unique_id" -CONF_CONFIG_LISTENER = "config_listener" - CONTROL4_ENTITY_TYPE = 7 diff --git a/homeassistant/components/control4/director_utils.py b/homeassistant/components/control4/director_utils.py index 5e57237337c..a26c5f9f413 100644 --- a/homeassistant/components/control4/director_utils.py +++ b/homeassistant/components/control4/director_utils.py @@ -8,21 +8,21 @@ from pyControl4.account import C4Account from pyControl4.director import C4Director from pyControl4.error_handling import BadToken -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.helpers import aiohttp_client -from .const import CONF_ACCOUNT, CONF_CONTROLLER_UNIQUE_ID, CONF_DIRECTOR, DOMAIN +from . import Control4ConfigEntry +from .const import CONF_CONTROLLER_UNIQUE_ID _LOGGER = logging.getLogger(__name__) async def _update_variables_for_config_entry( - hass: HomeAssistant, entry: ConfigEntry, variable_names: set[str] + hass: HomeAssistant, entry: Control4ConfigEntry, variable_names: set[str] ) -> dict[int, dict[str, Any]]: """Retrieve data from the Control4 director.""" - director: C4Director = hass.data[DOMAIN][entry.entry_id][CONF_DIRECTOR] + director = entry.runtime_data.director data = await director.getAllItemVariableValue(variable_names) result_dict: defaultdict[int, dict[str, Any]] = defaultdict(dict) for item in data: @@ -31,7 +31,7 @@ async def _update_variables_for_config_entry( async def update_variables_for_config_entry( - hass: HomeAssistant, entry: ConfigEntry, variable_names: set[str] + hass: HomeAssistant, entry: Control4ConfigEntry, variable_names: set[str] ) -> dict[int, dict[str, Any]]: """Try to Retrieve data from the Control4 director for update_coordinator.""" try: @@ -42,8 +42,8 @@ async def update_variables_for_config_entry( return await _update_variables_for_config_entry(hass, entry, variable_names) -async def refresh_tokens(hass: HomeAssistant, entry: ConfigEntry): - """Store updated authentication and director tokens in hass.data.""" +async def refresh_tokens(hass: HomeAssistant, entry: Control4ConfigEntry): + """Store updated authentication and director tokens in runtime_data.""" config = entry.data account_session = aiohttp_client.async_get_clientsession(hass) @@ -59,6 +59,5 @@ async def refresh_tokens(hass: HomeAssistant, entry: ConfigEntry): ) _LOGGER.debug("Saving new tokens in hass data") - entry_data = hass.data[DOMAIN][entry.entry_id] - entry_data[CONF_ACCOUNT] = account - entry_data[CONF_DIRECTOR] = director + entry.runtime_data.account = account + entry.runtime_data.director = director diff --git a/homeassistant/components/control4/entity.py b/homeassistant/components/control4/entity.py index fdb22e6578d..f7ca0e1fabc 100644 --- a/homeassistant/components/control4/entity.py +++ b/homeassistant/components/control4/entity.py @@ -10,7 +10,8 @@ from homeassistant.helpers.update_coordinator import ( DataUpdateCoordinator, ) -from .const import CONF_CONTROLLER_UNIQUE_ID, DOMAIN +from . import Control4RuntimeData +from .const import DOMAIN class Control4Entity(CoordinatorEntity[Any]): @@ -18,7 +19,7 @@ class Control4Entity(CoordinatorEntity[Any]): def __init__( self, - entry_data: dict, + runtime_data: Control4RuntimeData, coordinator: DataUpdateCoordinator[Any], name: str | None, idx: int, @@ -29,11 +30,11 @@ class Control4Entity(CoordinatorEntity[Any]): ) -> None: """Initialize a Control4 entity.""" super().__init__(coordinator) - self.entry_data = entry_data + self.runtime_data = runtime_data self._attr_name = name self._attr_unique_id = str(idx) self._idx = idx - self._controller_unique_id = entry_data[CONF_CONTROLLER_UNIQUE_ID] + self._controller_unique_id = runtime_data.controller_unique_id self._device_name = device_name self._device_manufacturer = device_manufacturer self._device_model = device_model diff --git a/homeassistant/components/control4/light.py b/homeassistant/components/control4/light.py index 927f4643619..cedfbeb49c3 100644 --- a/homeassistant/components/control4/light.py +++ b/homeassistant/components/control4/light.py @@ -17,14 +17,12 @@ from homeassistant.components.light import ( LightEntity, LightEntityFeature, ) -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_SCAN_INTERVAL from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from . import get_items_of_category -from .const import CONF_DIRECTOR, CONTROL4_ENTITY_TYPE, DOMAIN +from . import Control4ConfigEntry, Control4RuntimeData, get_items_of_category +from .const import CONTROL4_ENTITY_TYPE from .director_utils import update_variables_for_config_entry from .entity import Control4Entity @@ -36,15 +34,13 @@ CONTROL4_DIMMER_VARS = ["LIGHT_LEVEL", "Brightness Percent"] async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: Control4ConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up Control4 lights from a config entry.""" - entry_data = hass.data[DOMAIN][entry.entry_id] - scan_interval = entry_data[CONF_SCAN_INTERVAL] - _LOGGER.debug( - "Scan interval = %s", - scan_interval, - ) + runtime_data = entry.runtime_data + _LOGGER.debug("Scan interval = %s", runtime_data.scan_interval) async def async_update_data_non_dimmer() -> dict[int, dict[str, Any]]: """Fetch data from Control4 director for non-dimmer lights.""" @@ -69,14 +65,14 @@ async def async_setup_entry( _LOGGER, name="light", update_method=async_update_data_non_dimmer, - update_interval=timedelta(seconds=scan_interval), + update_interval=timedelta(seconds=runtime_data.scan_interval), ) dimmer_coordinator = DataUpdateCoordinator[dict[int, dict[str, Any]]]( hass, _LOGGER, name="light", update_method=async_update_data_dimmer, - update_interval=timedelta(seconds=scan_interval), + update_interval=timedelta(seconds=runtime_data.scan_interval), ) # Fetch initial data so we have data when entities subscribe @@ -118,7 +114,7 @@ async def async_setup_entry( item_is_dimmer = False item_coordinator = non_dimmer_coordinator else: - director = entry_data[CONF_DIRECTOR] + director = runtime_data.director item_variables = await director.getItemVariables(item_id) _LOGGER.warning( ( @@ -132,7 +128,7 @@ async def async_setup_entry( entity_list.append( Control4Light( - entry_data, + runtime_data, item_coordinator, item_name, item_id, @@ -154,7 +150,7 @@ class Control4Light(Control4Entity, LightEntity): def __init__( self, - entry_data: dict, + runtime_data: Control4RuntimeData, coordinator: DataUpdateCoordinator[dict[int, dict[str, Any]]], name: str, idx: int, @@ -166,7 +162,7 @@ class Control4Light(Control4Entity, LightEntity): ) -> None: """Initialize Control4 light entity.""" super().__init__( - entry_data, + runtime_data, coordinator, name, idx, @@ -188,7 +184,7 @@ class Control4Light(Control4Entity, LightEntity): This exists so the director token used is always the latest one, without needing to re-init the entire entity. """ - return C4Light(self.entry_data[CONF_DIRECTOR], self._idx) + return C4Light(self.runtime_data.director, self._idx) @property def is_on(self): diff --git a/homeassistant/components/control4/media_player.py b/homeassistant/components/control4/media_player.py index 9e3421817a3..bd8e3fb38fe 100644 --- a/homeassistant/components/control4/media_player.py +++ b/homeassistant/components/control4/media_player.py @@ -18,13 +18,11 @@ from homeassistant.components.media_player import ( MediaPlayerState, MediaType, ) -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_SCAN_INTERVAL from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import CONF_DIRECTOR, CONF_DIRECTOR_ALL_ITEMS, CONF_UI_CONFIGURATION, DOMAIN +from . import Control4ConfigEntry, Control4RuntimeData from .director_utils import update_variables_for_config_entry from .entity import Control4Entity @@ -67,22 +65,23 @@ class _RoomSource: name: str -async def get_rooms(hass: HomeAssistant, entry: ConfigEntry): +async def get_rooms(hass: HomeAssistant, entry: Control4ConfigEntry): """Return a list of all Control4 rooms.""" - director_all_items = hass.data[DOMAIN][entry.entry_id][CONF_DIRECTOR_ALL_ITEMS] return [ item - for item in director_all_items + for item in entry.runtime_data.director_all_items if "typeName" in item and item["typeName"] == "room" ] async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: Control4ConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up Control4 rooms from a config entry.""" - entry_data = hass.data[DOMAIN][entry.entry_id] - ui_config = entry_data[CONF_UI_CONFIGURATION] + runtime_data = entry.runtime_data + ui_config = runtime_data.ui_configuration # OS 2 will not have a ui_configuration if not ui_config: @@ -93,7 +92,7 @@ async def async_setup_entry( if not all_rooms: return - scan_interval = entry_data[CONF_SCAN_INTERVAL] + scan_interval = runtime_data.scan_interval _LOGGER.debug("Scan interval = %s", scan_interval) async def async_update_data() -> dict[int, dict[str, Any]]: @@ -116,10 +115,7 @@ async def async_setup_entry( # Fetch initial data so we have data when entities subscribe await coordinator.async_refresh() - items_by_id = { - item["id"]: item - for item in hass.data[DOMAIN][entry.entry_id][CONF_DIRECTOR_ALL_ITEMS] - } + items_by_id = {item["id"]: item for item in runtime_data.director_all_items} item_to_parent_map = { k: item["parentId"] for k, item in items_by_id.items() @@ -156,7 +152,7 @@ async def async_setup_entry( hidden = room["roomHidden"] entity_list.append( Control4Room( - entry_data, + runtime_data, coordinator, room["name"], room_id, @@ -182,7 +178,7 @@ class Control4Room(Control4Entity, MediaPlayerEntity): def __init__( self, - entry_data: dict, + runtime_data: Control4RuntimeData, coordinator: DataUpdateCoordinator[dict[int, dict[str, Any]]], name: str, room_id: int, @@ -192,7 +188,7 @@ class Control4Room(Control4Entity, MediaPlayerEntity): ) -> None: """Initialize Control4 room entity.""" super().__init__( - entry_data, + runtime_data, coordinator, None, room_id, @@ -220,7 +216,7 @@ class Control4Room(Control4Entity, MediaPlayerEntity): This exists so the director token used is always the latest one, without needing to re-init the entire entity. """ - return C4Room(self.entry_data[CONF_DIRECTOR], self._idx) + return C4Room(self.runtime_data.director, self._idx) def _get_device_from_variable(self, var: str) -> int | None: current_device = self.coordinator.data[self._idx][var] From a61399f18975c3c0de69b762a9bd05f3cc674c5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Wed, 29 Jan 2025 17:09:44 +0000 Subject: [PATCH 0255/3148] Simplify Whirlpool auth flows (#136856) --- .../components/whirlpool/config_flow.py | 74 +++++++------------ .../components/whirlpool/test_config_flow.py | 25 +++++-- 2 files changed, 46 insertions(+), 53 deletions(-) diff --git a/homeassistant/components/whirlpool/config_flow.py b/homeassistant/components/whirlpool/config_flow.py index 069a5ca1e4f..44445dee03f 100644 --- a/homeassistant/components/whirlpool/config_flow.py +++ b/homeassistant/components/whirlpool/config_flow.py @@ -15,7 +15,6 @@ from whirlpool.backendselector import BackendSelector from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_PASSWORD, CONF_REGION, CONF_USERNAME from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import CONF_BRAND, CONF_BRANDS_MAP, CONF_REGIONS_MAP, DOMAIN @@ -40,31 +39,39 @@ REAUTH_SCHEMA = vol.Schema( ) -async def validate_input(hass: HomeAssistant, data: dict[str, str]) -> dict[str, str]: - """Validate the user input allows us to connect. +async def authenticate( + hass: HomeAssistant, data: dict[str, str], check_appliances_exist: bool +) -> str | None: + """Authenticate with the api. - Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. + data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. + Returns the error translation key if authentication fails, or None on success. """ session = async_get_clientsession(hass) region = CONF_REGIONS_MAP[data[CONF_REGION]] brand = CONF_BRANDS_MAP[data[CONF_BRAND]] backend_selector = BackendSelector(brand, region) auth = Auth(backend_selector, data[CONF_USERNAME], data[CONF_PASSWORD], session) + try: await auth.do_auth() - except (TimeoutError, ClientError) as exc: - raise CannotConnect from exc + except (TimeoutError, ClientError): + return "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception") + return "unknown" if not auth.is_access_token_valid(): - raise InvalidAuth + return "invalid_auth" - appliances_manager = AppliancesManager(backend_selector, auth, session) - await appliances_manager.fetch_appliances() + if check_appliances_exist: + appliances_manager = AppliancesManager(backend_selector, auth, session) + await appliances_manager.fetch_appliances() - if not appliances_manager.aircons and not appliances_manager.washer_dryers: - raise NoAppliances + if not appliances_manager.aircons and not appliances_manager.washer_dryers: + return "no_appliances" - return {"title": data[CONF_USERNAME]} + return None class WhirlpoolConfigFlow(ConfigFlow, domain=DOMAIN): @@ -90,14 +97,10 @@ class WhirlpoolConfigFlow(ConfigFlow, domain=DOMAIN): brand = user_input[CONF_BRAND] data = {**reauth_entry.data, CONF_PASSWORD: password, CONF_BRAND: brand} - try: - await validate_input(self.hass, data) - except InvalidAuth: - errors["base"] = "invalid_auth" - except (CannotConnect, TimeoutError): - errors["base"] = "cannot_connect" - else: + error_key = await authenticate(self.hass, data, False) + if not error_key: return self.async_update_reload_and_abort(reauth_entry, data=data) + errors["base"] = error_key return self.async_show_form( step_id="reauth_confirm", @@ -113,38 +116,17 @@ class WhirlpoolConfigFlow(ConfigFlow, domain=DOMAIN): step_id="user", data_schema=STEP_USER_DATA_SCHEMA ) - errors = {} - - try: - info = await validate_input(self.hass, user_input) - except CannotConnect: - errors["base"] = "cannot_connect" - except InvalidAuth: - errors["base"] = "invalid_auth" - except NoAppliances: - errors["base"] = "no_appliances" - except Exception: - _LOGGER.exception("Unexpected exception") - errors["base"] = "unknown" - else: + error_key = await authenticate(self.hass, user_input, True) + if not error_key: await self.async_set_unique_id( user_input[CONF_USERNAME].lower(), raise_on_progress=False ) self._abort_if_unique_id_configured() - return self.async_create_entry(title=info["title"], data=user_input) + return self.async_create_entry( + title=user_input[CONF_USERNAME], data=user_input + ) + errors = {"base": error_key} return self.async_show_form( step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors ) - - -class CannotConnect(HomeAssistantError): - """Error to indicate we cannot connect.""" - - -class InvalidAuth(HomeAssistantError): - """Error to indicate there is invalid auth.""" - - -class NoAppliances(HomeAssistantError): - """Error to indicate no supported appliances in the user account.""" diff --git a/tests/components/whirlpool/test_config_flow.py b/tests/components/whirlpool/test_config_flow.py index e451fda82ad..a82c2a22695 100644 --- a/tests/components/whirlpool/test_config_flow.py +++ b/tests/components/whirlpool/test_config_flow.py @@ -3,7 +3,6 @@ from unittest.mock import MagicMock, patch import aiohttp -from aiohttp.client_exceptions import ClientConnectionError import pytest from homeassistant import config_entries @@ -219,7 +218,7 @@ async def test_reauth_flow(hass: HomeAssistant, region, brand) -> None: @pytest.mark.usefixtures("mock_appliances_manager_api", "mock_whirlpool_setup_entry") -async def test_reauth_flow_auth_error( +async def test_reauth_flow_invalid_auth( hass: HomeAssistant, region, brand, mock_auth_api: MagicMock ) -> None: """Test an authorization error reauth flow.""" @@ -247,8 +246,21 @@ async def test_reauth_flow_auth_error( @pytest.mark.usefixtures("mock_appliances_manager_api", "mock_whirlpool_setup_entry") -async def test_reauth_flow_connnection_error( - hass: HomeAssistant, region, brand, mock_auth_api: MagicMock +@pytest.mark.parametrize( + ("exception", "expected_error"), + [ + (aiohttp.ClientConnectionError, "cannot_connect"), + (TimeoutError, "cannot_connect"), + (Exception, "unknown"), + ], +) +async def test_reauth_flow_auth_error( + hass: HomeAssistant, + exception: Exception, + expected_error: str, + region, + brand, + mock_auth_api: MagicMock, ) -> None: """Test a connection error reauth flow.""" @@ -265,11 +277,10 @@ async def test_reauth_flow_connnection_error( assert result["type"] is FlowResultType.FORM assert result["errors"] == {} - mock_auth_api.return_value.do_auth.side_effect = ClientConnectionError + mock_auth_api.return_value.do_auth.side_effect = exception result2 = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_PASSWORD: "new-password", CONF_BRAND: brand[0]}, ) - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "cannot_connect"} + assert result2["errors"] == {"base": expected_error} From 4ce891512e14e6907e3f15a340b42bcde7a2d5ad Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Wed, 29 Jan 2025 12:16:28 -0500 Subject: [PATCH 0256/3148] Add ability to cache Roborock maps instead of always reloading (#112047) Co-authored-by: Paulus Schoutsen Co-authored-by: Allen Porter Co-authored-by: Joost Lekkerkerker Co-authored-by: Allen Porter Co-authored-by: Robert Resch --- homeassistant/components/roborock/__init__.py | 6 + homeassistant/components/roborock/const.py | 2 + .../components/roborock/coordinator.py | 7 + homeassistant/components/roborock/image.py | 151 +++++++++--------- .../components/roborock/roborock_storage.py | 81 ++++++++++ tests/components/roborock/conftest.py | 23 +++ tests/components/roborock/test_image.py | 136 +++++++++++++++- tests/components/roborock/test_init.py | 58 +++++++ 8 files changed, 380 insertions(+), 84 deletions(-) create mode 100644 homeassistant/components/roborock/roborock_storage.py diff --git a/homeassistant/components/roborock/__init__.py b/homeassistant/components/roborock/__init__.py index 9ab9226c9a5..1b34dc891d1 100644 --- a/homeassistant/components/roborock/__init__.py +++ b/homeassistant/components/roborock/__init__.py @@ -28,6 +28,7 @@ from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from .const import CONF_BASE_URL, CONF_USER_DATA, DOMAIN, PLATFORMS from .coordinator import RoborockDataUpdateCoordinator, RoborockDataUpdateCoordinatorA01 +from .roborock_storage import async_remove_map_storage SCAN_INTERVAL = timedelta(seconds=30) @@ -259,3 +260,8 @@ async def update_listener(hass: HomeAssistant, entry: RoborockConfigEntry) -> No """Handle options update.""" # Reload entry to update data await hass.config_entries.async_reload(entry.entry_id) + + +async def async_remove_entry(hass: HomeAssistant, entry: RoborockConfigEntry) -> None: + """Handle removal of an entry.""" + await async_remove_map_storage(hass, entry.entry_id) diff --git a/homeassistant/components/roborock/const.py b/homeassistant/components/roborock/const.py index 4a9bd14bfe1..cc8d34fbadc 100644 --- a/homeassistant/components/roborock/const.py +++ b/homeassistant/components/roborock/const.py @@ -49,5 +49,7 @@ IMAGE_CACHE_INTERVAL = 90 MAP_SLEEP = 3 GET_MAPS_SERVICE_NAME = "get_maps" +MAP_FILE_FORMAT = "PNG" +MAP_FILENAME_SUFFIX = ".png" SET_VACUUM_GOTO_POSITION_SERVICE_NAME = "set_vacuum_goto_position" GET_VACUUM_CURRENT_POSITION_SERVICE_NAME = "get_vacuum_current_position" diff --git a/homeassistant/components/roborock/coordinator.py b/homeassistant/components/roborock/coordinator.py index d34ba49da52..36333f1c55e 100644 --- a/homeassistant/components/roborock/coordinator.py +++ b/homeassistant/components/roborock/coordinator.py @@ -16,6 +16,7 @@ from roborock.version_1_apis.roborock_local_client_v1 import RoborockLocalClient from roborock.version_1_apis.roborock_mqtt_client_v1 import RoborockMqttClientV1 from roborock.version_a01_apis import RoborockClientA01 +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_CONNECTIONS from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr @@ -26,6 +27,7 @@ from homeassistant.util import slugify from .const import DOMAIN from .models import RoborockA01HassDeviceInfo, RoborockHassDeviceInfo, RoborockMapInfo +from .roborock_storage import RoborockMapStorage SCAN_INTERVAL = timedelta(seconds=30) @@ -35,6 +37,8 @@ _LOGGER = logging.getLogger(__name__) class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): """Class to manage fetching data from the API.""" + config_entry: ConfigEntry + def __init__( self, hass: HomeAssistant, @@ -72,6 +76,9 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): # Maps from map flag to map name self.maps: dict[int, RoborockMapInfo] = {} self._home_data_rooms = {str(room.id): room.name for room in home_data_rooms} + self.map_storage = RoborockMapStorage( + hass, self.config_entry.entry_id, slugify(self.duid) + ) async def _async_setup(self) -> None: """Set up the coordinator.""" diff --git a/homeassistant/components/roborock/image.py b/homeassistant/components/roborock/image.py index 3818a039fb8..b0de4f9caa5 100644 --- a/homeassistant/components/roborock/image.py +++ b/homeassistant/components/roborock/image.py @@ -1,26 +1,33 @@ """Support for Roborock image.""" import asyncio +from collections.abc import Callable from datetime import datetime import io -from itertools import chain from roborock import RoborockCommand from vacuum_map_parser_base.config.color import ColorsPalette -from vacuum_map_parser_base.config.drawable import Drawable from vacuum_map_parser_base.config.image_config import ImageConfig from vacuum_map_parser_base.config.size import Sizes from vacuum_map_parser_roborock.map_data_parser import RoborockMapDataParser from homeassistant.components.image import ImageEntity +from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.util import dt as dt_util, slugify +from homeassistant.util import dt as dt_util from . import RoborockConfigEntry -from .const import DEFAULT_DRAWABLES, DOMAIN, DRAWABLES, IMAGE_CACHE_INTERVAL, MAP_SLEEP +from .const import ( + DEFAULT_DRAWABLES, + DOMAIN, + DRAWABLES, + IMAGE_CACHE_INTERVAL, + MAP_FILE_FORMAT, + MAP_SLEEP, +) from .coordinator import RoborockDataUpdateCoordinator from .entity import RoborockCoordinatedEntityV1 @@ -37,17 +44,35 @@ async def async_setup_entry( for drawable, default_value in DEFAULT_DRAWABLES.items() if config_entry.options.get(DRAWABLES, {}).get(drawable, default_value) ] - entities = list( - chain.from_iterable( - await asyncio.gather( - *( - create_coordinator_maps(coord, drawables) - for coord in config_entry.runtime_data.v1 - ) - ) - ) + parser = RoborockMapDataParser( + ColorsPalette(), Sizes(), drawables, ImageConfig(), [] + ) + + def parse_image(map_bytes: bytes) -> bytes | None: + parsed_map = parser.parse(map_bytes) + if parsed_map.image is None: + return None + img_byte_arr = io.BytesIO() + parsed_map.image.data.save(img_byte_arr, format=MAP_FILE_FORMAT) + return img_byte_arr.getvalue() + + await asyncio.gather( + *(refresh_coordinators(hass, coord) for coord in config_entry.runtime_data.v1) + ) + async_add_entities( + ( + RoborockMap( + config_entry, + f"{coord.duid_slug}_map_{map_info.name}", + coord, + map_info.flag, + map_info.name, + parse_image, + ) + for coord in config_entry.runtime_data.v1 + for map_info in coord.maps.values() + ), ) - async_add_entities(entities) class RoborockMap(RoborockCoordinatedEntityV1, ImageEntity): @@ -55,39 +80,27 @@ class RoborockMap(RoborockCoordinatedEntityV1, ImageEntity): _attr_has_entity_name = True image_last_updated: datetime + _attr_name: str def __init__( self, + config_entry: ConfigEntry, unique_id: str, coordinator: RoborockDataUpdateCoordinator, map_flag: int, - starting_map: bytes, map_name: str, - drawables: list[Drawable], + parser: Callable[[bytes], bytes | None], ) -> None: """Initialize a Roborock map.""" RoborockCoordinatedEntityV1.__init__(self, unique_id, coordinator) ImageEntity.__init__(self, coordinator.hass) + self.config_entry = config_entry self._attr_name = map_name - self.parser = RoborockMapDataParser( - ColorsPalette(), Sizes(), drawables, ImageConfig(), [] - ) - self._attr_image_last_updated = dt_util.utcnow() + self.parser = parser self.map_flag = map_flag - try: - self.cached_map = self._create_image(starting_map) - except HomeAssistantError: - # If we failed to update the image on init, - # we set cached_map to empty bytes - # so that we are unavailable and can try again later. - self.cached_map = b"" + self.cached_map = b"" self._attr_entity_category = EntityCategory.DIAGNOSTIC - @property - def available(self) -> bool: - """Determines if the entity is available.""" - return self.cached_map != b"" - @property def is_selected(self) -> bool: """Return if this map is the currently selected map.""" @@ -106,6 +119,14 @@ class RoborockMap(RoborockCoordinatedEntityV1, ImageEntity): and bool(self.coordinator.roborock_device_info.props.status.in_cleaning) ) + async def async_added_to_hass(self) -> None: + """When entity is added to hass load any previously cached maps from disk.""" + await super().async_added_to_hass() + content = await self.coordinator.map_storage.async_load_map(self.map_flag) + self.cached_map = content or b"" + self._attr_image_last_updated = dt_util.utcnow() + self.async_write_ha_state() + def _handle_coordinator_update(self) -> None: # Bump last updated every third time the coordinator runs, so that async_image # will be called and we will evaluate on the new coordinator data if we should @@ -126,47 +147,40 @@ class RoborockMap(RoborockCoordinatedEntityV1, ImageEntity): ), return_exceptions=True, ) - if not isinstance(response[0], bytes): + if ( + not isinstance(response[0], bytes) + or (content := self.parser(response[0])) is None + ): raise HomeAssistantError( translation_domain=DOMAIN, translation_key="map_failure", ) - map_data = response[0] - self.cached_map = self._create_image(map_data) + if self.cached_map != content: + self.cached_map = content + self.config_entry.async_create_task( + self.hass, + self.coordinator.map_storage.async_save_map( + self.map_flag, + content, + ), + f"{self.unique_id} map", + ) return self.cached_map - def _create_image(self, map_bytes: bytes) -> bytes: - """Create an image using the map parser.""" - parsed_map = self.parser.parse(map_bytes) - if parsed_map.image is None: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="map_failure", - ) - img_byte_arr = io.BytesIO() - parsed_map.image.data.save(img_byte_arr, format="PNG") - return img_byte_arr.getvalue() - -async def create_coordinator_maps( - coord: RoborockDataUpdateCoordinator, drawables: list[Drawable] -) -> list[RoborockMap]: +async def refresh_coordinators( + hass: HomeAssistant, coord: RoborockDataUpdateCoordinator +) -> None: """Get the starting map information for all maps for this device. The following steps must be done synchronously. Only one map can be loaded at a time per device. """ - entities = [] cur_map = coord.current_map # This won't be None at this point as the coordinator will have run first. assert cur_map is not None - # Sort the maps so that we start with the current map and we can skip the - # load_multi_map call. - maps_info = sorted( - coord.maps.items(), key=lambda data: data[0] == cur_map, reverse=True - ) - for map_flag, map_info in maps_info: - # Load the map - so we can access it with get_map_v1 + map_flags = sorted(coord.maps, key=lambda data: data == cur_map, reverse=True) + for map_flag in map_flags: if map_flag != cur_map: # Only change the map and sleep if we have multiple maps. await coord.api.send_command(RoborockCommand.LOAD_MULTI_MAP, [map_flag]) @@ -174,28 +188,11 @@ async def create_coordinator_maps( # We cannot get the map until the roborock servers fully process the # map change. await asyncio.sleep(MAP_SLEEP) - # Get the map data - map_update = await asyncio.gather( - *[coord.cloud_api.get_map_v1(), coord.set_current_map_rooms()], - return_exceptions=True, - ) - # If we fail to get the map, we should set it to empty byte, - # still create it, and set it as unavailable. - api_data: bytes = map_update[0] if isinstance(map_update[0], bytes) else b"" - entities.append( - RoborockMap( - f"{slugify(coord.duid)}_map_{map_info.name}", - coord, - map_flag, - api_data, - map_info.name, - drawables, - ) - ) + await coord.set_current_map_rooms() + if len(coord.maps) != 1: # Set the map back to the map the user previously had selected so that it # does not change the end user's app. # Only needs to happen when we changed maps above. await coord.cloud_api.send_command(RoborockCommand.LOAD_MULTI_MAP, [cur_map]) coord.current_map = cur_map - return entities diff --git a/homeassistant/components/roborock/roborock_storage.py b/homeassistant/components/roborock/roborock_storage.py new file mode 100644 index 00000000000..62e15e889be --- /dev/null +++ b/homeassistant/components/roborock/roborock_storage.py @@ -0,0 +1,81 @@ +"""Roborock storage.""" + +import logging +from pathlib import Path +import shutil + +from homeassistant.core import HomeAssistant + +from .const import DOMAIN, MAP_FILENAME_SUFFIX + +_LOGGER = logging.getLogger(__name__) + +STORAGE_PATH = f".storage/{DOMAIN}" +MAPS_PATH = "maps" + + +def _storage_path_prefix(hass: HomeAssistant, entry_id: str) -> Path: + return Path(hass.config.path(STORAGE_PATH)) / entry_id + + +class RoborockMapStorage: + """Store and retrieve maps for a Roborock device. + + An instance of RoborockMapStorage is created for each device and manages + local storage of maps for that device. + """ + + def __init__(self, hass: HomeAssistant, entry_id: str, device_id_slug: str) -> None: + """Initialize RoborockMapStorage.""" + self._hass = hass + self._path_prefix = ( + _storage_path_prefix(hass, entry_id) / MAPS_PATH / device_id_slug + ) + + async def async_load_map(self, map_flag: int) -> bytes | None: + """Load maps from disk.""" + filename = self._path_prefix / f"{map_flag}{MAP_FILENAME_SUFFIX}" + return await self._hass.async_add_executor_job(self._load_map, filename) + + def _load_map(self, filename: Path) -> bytes | None: + """Load maps from disk.""" + if not filename.exists(): + return None + try: + return filename.read_bytes() + except OSError as err: + _LOGGER.debug("Unable to read map file: %s %s", filename, err) + return None + + async def async_save_map(self, map_flag: int, content: bytes) -> None: + """Write map if it should be updated.""" + filename = self._path_prefix / f"{map_flag}{MAP_FILENAME_SUFFIX}" + await self._hass.async_add_executor_job(self._save_map, filename, content) + + def _save_map(self, filename: Path, content: bytes) -> None: + """Write the map to disk.""" + _LOGGER.debug("Saving map to disk: %s", filename) + try: + filename.parent.mkdir(parents=True, exist_ok=True) + except OSError as err: + _LOGGER.error("Unable to create map directory: %s %s", filename, err) + return + try: + filename.write_bytes(content) + except OSError as err: + _LOGGER.error("Unable to write map file: %s %s", filename, err) + + +async def async_remove_map_storage(hass: HomeAssistant, entry_id: str) -> None: + """Remove all map storage associated with a config entry.""" + + def remove(path_prefix: Path) -> None: + try: + if path_prefix.exists(): + shutil.rmtree(path_prefix, ignore_errors=True) + except OSError as err: + _LOGGER.error("Unable to remove map files in %s: %s", path_prefix, err) + + path_prefix = _storage_path_prefix(hass, entry_id) + _LOGGER.debug("Removing maps from disk store: %s", path_prefix) + await hass.async_add_executor_job(remove, path_prefix) diff --git a/tests/components/roborock/conftest.py b/tests/components/roborock/conftest.py index 4df5f479b7c..e5fc5cb7eb6 100644 --- a/tests/components/roborock/conftest.py +++ b/tests/components/roborock/conftest.py @@ -2,8 +2,11 @@ from collections.abc import Generator from copy import deepcopy +import pathlib +import shutil from typing import Any from unittest.mock import Mock, patch +import uuid import pytest from roborock import RoborockCategory, RoomMapping @@ -70,6 +73,9 @@ def bypass_api_fixture() -> None: with ( patch("homeassistant.components.roborock.RoborockMqttClientV1.async_connect"), patch("homeassistant.components.roborock.RoborockMqttClientV1._send_command"), + patch( + "homeassistant.components.roborock.coordinator.RoborockMqttClientV1._send_command" + ), patch( "homeassistant.components.roborock.RoborockApiClient.get_home_data_v2", return_value=HOME_DATA, @@ -196,6 +202,7 @@ async def setup_entry( hass: HomeAssistant, bypass_api_fixture, mock_roborock_entry: MockConfigEntry, + cleanup_map_storage: pathlib.Path, platforms: list[Platform], ) -> Generator[MockConfigEntry]: """Set up the Roborock platform.""" @@ -203,3 +210,19 @@ async def setup_entry( assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() yield mock_roborock_entry + + +@pytest.fixture +def cleanup_map_storage( + hass: HomeAssistant, mock_roborock_entry: MockConfigEntry +) -> Generator[pathlib.Path]: + """Test cleanup, remove any map storage persisted during the test.""" + tmp_path = str(uuid.uuid4()) + with patch( + "homeassistant.components.roborock.roborock_storage.STORAGE_PATH", new=tmp_path + ): + storage_path = ( + pathlib.Path(hass.config.path(tmp_path)) / mock_roborock_entry.entry_id + ) + yield storage_path + shutil.rmtree(str(storage_path), ignore_errors=True) diff --git a/tests/components/roborock/test_image.py b/tests/components/roborock/test_image.py index e240dccf7eb..90886f25929 100644 --- a/tests/components/roborock/test_image.py +++ b/tests/components/roborock/test_image.py @@ -3,13 +3,16 @@ import copy from datetime import timedelta from http import HTTPStatus +import io from unittest.mock import patch +from PIL import Image import pytest from roborock import RoborockException +from vacuum_map_parser_base.map_data import ImageConfig, ImageData from homeassistant.components.roborock import DOMAIN -from homeassistant.const import STATE_UNAVAILABLE, Platform +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -32,22 +35,27 @@ async def test_floorplan_image( hass_client: ClientSessionGenerator, ) -> None: """Test floor plan map image is correctly set up.""" - # Setup calls the image parsing the first time and caches it. assert len(hass.states.async_all("image")) == 4 assert hass.states.get("image.roborock_s7_maxv_upstairs") is not None - # call a second time -should return cached data + # Load the image on demand client = await hass_client() resp = await client.get("/api/image_proxy/image.roborock_s7_maxv_upstairs") assert resp.status == HTTPStatus.OK body = await resp.read() assert body is not None - # Call a third time - this time forcing it to update - now = dt_util.utcnow() + timedelta(seconds=91) + assert body[0:4] == b"\x89PNG" + + # Call a second time - this time forcing it to update - and save new image + now = dt_util.utcnow() + timedelta(minutes=61) # Copy the device prop so we don't override it prop = copy.deepcopy(PROP) prop.status.in_cleaning = 1 + new_map_data = copy.deepcopy(MAP_DATA) + new_map_data.image = ImageData( + 100, 10, 10, 10, 10, ImageConfig(), Image.new("RGB", (2, 2)), lambda p: p + ) with ( patch( "homeassistant.components.roborock.coordinator.RoborockLocalClientV1.get_prop", @@ -56,6 +64,10 @@ async def test_floorplan_image( patch( "homeassistant.components.roborock.image.dt_util.utcnow", return_value=now ), + patch( + "homeassistant.components.roborock.image.RoborockMapDataParser.parse", + return_value=new_map_data, + ) as parse_map, ): async_fire_time_changed(hass, now) await hass.async_block_till_done() @@ -63,6 +75,7 @@ async def test_floorplan_image( assert resp.status == HTTPStatus.OK body = await resp.read() assert body is not None + assert parse_map.call_count == 1 async def test_floorplan_image_failed_parse( @@ -97,13 +110,101 @@ async def test_floorplan_image_failed_parse( assert not resp.ok +async def test_load_stored_image( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + setup_entry: MockConfigEntry, +) -> None: + """Test that we correctly load an image from storage when it already exists.""" + img_byte_arr = io.BytesIO() + MAP_DATA.image.data.save(img_byte_arr, format="PNG") + img_bytes = img_byte_arr.getvalue() + + # Load the image on demand, which should ensure it is cached on disk + client = await hass_client() + resp = await client.get("/api/image_proxy/image.roborock_s7_maxv_upstairs") + assert resp.status == HTTPStatus.OK + + with patch( + "homeassistant.components.roborock.image.RoborockMapDataParser.parse", + ) as parse_map: + # Reload the config entry so that the map is saved in storage and entities exist. + await hass.config_entries.async_reload(setup_entry.entry_id) + await hass.async_block_till_done() + assert hass.states.get("image.roborock_s7_maxv_upstairs") is not None + client = await hass_client() + resp = await client.get("/api/image_proxy/image.roborock_s7_maxv_upstairs") + # Test that we can get the image and it correctly serialized and unserialized. + assert resp.status == HTTPStatus.OK + body = await resp.read() + assert body == img_bytes + + # Ensure that we never tried to update the map, and only used the cached image. + assert parse_map.call_count == 0 + + +async def test_fail_to_save_image( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_roborock_entry: MockConfigEntry, + bypass_api_fixture, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that we gracefully handle a oserror on saving an image.""" + # Reload the config entry so that the map is saved in storage and entities exist. + with patch( + "homeassistant.components.roborock.roborock_storage.Path.write_bytes", + side_effect=OSError, + ): + await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + # Ensure that map is still working properly. + assert hass.states.get("image.roborock_s7_maxv_upstairs") is not None + client = await hass_client() + resp = await client.get("/api/image_proxy/image.roborock_s7_maxv_upstairs") + # Test that we can get the image and it correctly serialized and unserialized. + assert resp.status == HTTPStatus.OK + + assert "Unable to write map file" in caplog.text + + +async def test_fail_to_load_image( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + setup_entry: MockConfigEntry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that we gracefully handle failing to load an image.""" + with ( + patch( + "homeassistant.components.roborock.image.RoborockMapDataParser.parse", + ) as parse_map, + patch( + "homeassistant.components.roborock.roborock_storage.Path.exists", + return_value=True, + ), + patch( + "homeassistant.components.roborock.roborock_storage.Path.read_bytes", + side_effect=OSError, + ) as read_bytes, + ): + # Reload the config entry so that the map is saved in storage and entities exist. + await hass.config_entries.async_reload(setup_entry.entry_id) + await hass.async_block_till_done() + assert read_bytes.call_count == 4 + # Ensure that we never updated the map manually since we couldn't load it. + assert parse_map.call_count == 0 + assert "Unable to read map file" in caplog.text + + async def test_fail_parse_on_startup( hass: HomeAssistant, hass_client: ClientSessionGenerator, mock_roborock_entry: MockConfigEntry, bypass_api_fixture, ) -> None: - """Test that if we fail parsing on startup, we create the entity but set it as unavailable.""" + """Test that if we fail parsing on startup, we still create the entity.""" map_data = copy.deepcopy(MAP_DATA) map_data.image = None with patch( @@ -115,7 +216,28 @@ async def test_fail_parse_on_startup( assert ( image_entity := hass.states.get("image.roborock_s7_maxv_upstairs") ) is not None - assert image_entity.state == STATE_UNAVAILABLE + assert image_entity.state + + +async def test_fail_get_map_on_startup( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_roborock_entry: MockConfigEntry, + bypass_api_fixture, +) -> None: + """Test that if we fail getting map on startup, we can still create the entity.""" + with ( + patch( + "homeassistant.components.roborock.coordinator.RoborockMqttClientV1.get_map_v1", + return_value=None, + ), + ): + await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + assert ( + image_entity := hass.states.get("image.roborock_s7_maxv_upstairs") + ) is not None + assert image_entity.state async def test_fail_updating_image( diff --git a/tests/components/roborock/test_init.py b/tests/components/roborock/test_init.py index f4f490e68d9..efd1c3f66f4 100644 --- a/tests/components/roborock/test_init.py +++ b/tests/components/roborock/test_init.py @@ -1,6 +1,8 @@ """Test for Roborock init.""" from copy import deepcopy +from http import HTTPStatus +import pathlib from unittest.mock import patch import pytest @@ -13,12 +15,14 @@ from roborock import ( from homeassistant.components.roborock.const import DOMAIN from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from .mock_data import HOME_DATA from tests.common import MockConfigEntry +from tests.typing import ClientSessionGenerator async def test_unload_entry( @@ -163,6 +167,60 @@ async def test_reauth_started( assert flows[0]["step_id"] == "reauth_confirm" +@pytest.mark.parametrize("platforms", [[Platform.IMAGE]]) +async def test_remove_from_hass( + hass: HomeAssistant, + bypass_api_fixture, + setup_entry: MockConfigEntry, + hass_client: ClientSessionGenerator, + cleanup_map_storage: pathlib.Path, +) -> None: + """Test that removing from hass removes any existing images.""" + + # Ensure some image content is cached + assert hass.states.get("image.roborock_s7_maxv_upstairs") is not None + client = await hass_client() + resp = await client.get("/api/image_proxy/image.roborock_s7_maxv_upstairs") + assert resp.status == HTTPStatus.OK + + assert cleanup_map_storage.exists() + paths = list(cleanup_map_storage.walk()) + assert len(paths) == 3 # One map image and two directories + + await hass.config_entries.async_remove(setup_entry.entry_id) + # After removal, directories should be empty. + assert not cleanup_map_storage.exists() + + +@pytest.mark.parametrize("platforms", [[Platform.IMAGE]]) +async def test_oserror_remove_image( + hass: HomeAssistant, + bypass_api_fixture, + setup_entry: MockConfigEntry, + cleanup_map_storage: pathlib.Path, + hass_client: ClientSessionGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that we gracefully handle failing to remove an image.""" + + # Ensure some image content is cached + assert hass.states.get("image.roborock_s7_maxv_upstairs") is not None + client = await hass_client() + resp = await client.get("/api/image_proxy/image.roborock_s7_maxv_upstairs") + assert resp.status == HTTPStatus.OK + + assert cleanup_map_storage.exists() + paths = list(cleanup_map_storage.walk()) + assert len(paths) == 3 # One map image and two directories + + with patch( + "homeassistant.components.roborock.roborock_storage.shutil.rmtree", + side_effect=OSError, + ): + await hass.config_entries.async_remove(setup_entry.entry_id) + assert "Unable to remove map files" in caplog.text + + async def test_not_supported_protocol( hass: HomeAssistant, bypass_api_fixture, From 6a8e45c51e24603aa1a3fc170c64ae021776a506 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Wed, 29 Jan 2025 17:20:14 +0000 Subject: [PATCH 0257/3148] Update whirlpool-sixth-sense to 0.18.12 (#136851) --- homeassistant/components/whirlpool/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/whirlpool/manifest.json b/homeassistant/components/whirlpool/manifest.json index b463a1a76f8..67901eea482 100644 --- a/homeassistant/components/whirlpool/manifest.json +++ b/homeassistant/components/whirlpool/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "cloud_push", "loggers": ["whirlpool"], - "requirements": ["whirlpool-sixth-sense==0.18.11"] + "requirements": ["whirlpool-sixth-sense==0.18.12"] } diff --git a/requirements_all.txt b/requirements_all.txt index 0ab09a60906..c87835f9153 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3043,7 +3043,7 @@ webmin-xmlrpc==0.0.2 weheat==2025.1.15 # homeassistant.components.whirlpool -whirlpool-sixth-sense==0.18.11 +whirlpool-sixth-sense==0.18.12 # homeassistant.components.whois whois==0.9.27 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e2ab69f3cf2..968eec09d28 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2447,7 +2447,7 @@ webmin-xmlrpc==0.0.2 weheat==2025.1.15 # homeassistant.components.whirlpool -whirlpool-sixth-sense==0.18.11 +whirlpool-sixth-sense==0.18.12 # homeassistant.components.whois whois==0.9.27 From 823df4242d0cf3532ad5f33c3d5caef5bb709191 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 29 Jan 2025 18:23:25 +0100 Subject: [PATCH 0258/3148] Add support for per-backup agent encryption flag to hassio (#136828) * Add support for per-backup agent encryption flag to hassio * Improve comment * Set password to None when supervisor should not encrypt --- homeassistant/components/hassio/backup.py | 89 +++++-- tests/components/hassio/test_backup.py | 282 +++++++++++++++++++++- 2 files changed, 350 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/hassio/backup.py b/homeassistant/components/hassio/backup.py index 5318e4cd351..afeee1f4469 100644 --- a/homeassistant/components/hassio/backup.py +++ b/homeassistant/components/hassio/backup.py @@ -97,7 +97,7 @@ def async_register_backup_agents_listener( def _backup_details_to_agent_backup( - details: supervisor_backups.BackupComplete, + details: supervisor_backups.BackupComplete, location: str | None ) -> AgentBackup: """Convert a supervisor backup details object to an agent backup.""" homeassistant_included = details.homeassistant is not None @@ -109,6 +109,7 @@ def _backup_details_to_agent_backup( AddonInfo(name=addon.name, slug=addon.slug, version=addon.version) for addon in details.addons ] + location = location or LOCATION_LOCAL return AgentBackup( addons=addons, backup_id=details.slug, @@ -119,8 +120,8 @@ def _backup_details_to_agent_backup( homeassistant_included=homeassistant_included, homeassistant_version=details.homeassistant, name=details.name, - protected=details.protected, - size=details.size_bytes, + protected=details.location_attributes[location].protected, + size=details.location_attributes[location].size_bytes, ) @@ -158,8 +159,23 @@ class SupervisorBackupAgent(BackupAgent): ) -> None: """Upload a backup. - Not required for supervisor, the SupervisorBackupReaderWriter stores files. + The upload will be skipped if the backup already exists in the agent's location. """ + if await self.async_get_backup(backup.backup_id): + _LOGGER.debug( + "Backup %s already exists in location %s", + backup.backup_id, + self.location, + ) + return + stream = await open_stream() + upload_options = supervisor_backups.UploadBackupOptions( + location={self.location} + ) + await self._client.backups.upload_backup( + stream, + upload_options, + ) async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]: """List backups.""" @@ -169,7 +185,7 @@ class SupervisorBackupAgent(BackupAgent): if not backup.locations or self.location not in backup.locations: continue details = await self._client.backups.backup_info(backup.slug) - result.append(_backup_details_to_agent_backup(details)) + result.append(_backup_details_to_agent_backup(details, self.location)) return result async def async_get_backup( @@ -181,7 +197,7 @@ class SupervisorBackupAgent(BackupAgent): details = await self._client.backups.backup_info(backup_id) if self.location not in details.locations: return None - return _backup_details_to_agent_backup(details) + return _backup_details_to_agent_backup(details, self.location) async def async_delete_backup(self, backup_id: str, **kwargs: Any) -> None: """Remove a backup.""" @@ -246,7 +262,41 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): for agent_id in agent_ids if manager.backup_agents[agent_id].domain == DOMAIN ] - locations = [agent.location for agent in hassio_agents] + + # Supervisor does not support creating backups spread across multiple + # locations, where some locations are encrypted and some are not. + # It's inefficient to let core do all the copying so we want to let + # supervisor handle as much as possible. + # Therefore, we split the locations into two lists: encrypted and decrypted. + # The longest list will be sent to supervisor, and the remaining locations + # will be handled by async_upload_backup. + # If the lists are the same length, it does not matter which one we send, + # we send the encrypted list to have a well defined behavior. + encrypted_locations: list[str | None] = [] + decrypted_locations: list[str | None] = [] + agents_settings = manager.config.data.agents + for hassio_agent in hassio_agents: + if password is not None: + if agent_settings := agents_settings.get(hassio_agent.agent_id): + if agent_settings.protected: + encrypted_locations.append(hassio_agent.location) + else: + decrypted_locations.append(hassio_agent.location) + else: + encrypted_locations.append(hassio_agent.location) + else: + decrypted_locations.append(hassio_agent.location) + _LOGGER.debug("Encrypted locations: %s", encrypted_locations) + _LOGGER.debug("Decrypted locations: %s", decrypted_locations) + if hassio_agents: + if len(encrypted_locations) >= len(decrypted_locations): + locations = encrypted_locations + else: + locations = decrypted_locations + password = None + else: + locations = [] + locations = locations or [LOCATION_CLOUD_BACKUP] try: backup = await self._client.backups.partial_backup( @@ -257,7 +307,7 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): name=backup_name, password=password, compressed=True, - location=locations or LOCATION_CLOUD_BACKUP, + location=locations, homeassistant_exclude_database=not include_database, background=True, extra=extra_metadata, @@ -267,7 +317,9 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): raise BackupReaderWriterError(f"Error creating backup: {err}") from err backup_task = self._hass.async_create_task( self._async_wait_for_backup( - backup, remove_after_upload=not bool(locations) + backup, + locations, + remove_after_upload=locations == [LOCATION_CLOUD_BACKUP], ), name="backup_manager_create_backup", eager_start=False, # To ensure the task is not started before we return @@ -276,7 +328,11 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): return (NewBackup(backup_job_id=backup.job_id), backup_task) async def _async_wait_for_backup( - self, backup: supervisor_backups.NewBackup, *, remove_after_upload: bool + self, + backup: supervisor_backups.NewBackup, + locations: list[str | None], + *, + remove_after_upload: bool, ) -> WrittenBackup: """Wait for a backup to complete.""" backup_complete = asyncio.Event() @@ -327,7 +383,7 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): ) from err return WrittenBackup( - backup=_backup_details_to_agent_backup(details), + backup=_backup_details_to_agent_backup(details, locations[0]), open_stream=open_backup, release_stream=remove_backup, ) @@ -347,20 +403,19 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): for agent_id in agent_ids if manager.backup_agents[agent_id].domain == DOMAIN ] - locations = {agent.location for agent in hassio_agents} + locations = [agent.location for agent in hassio_agents] + locations = locations or [LOCATION_CLOUD_BACKUP] backup_id = await self._client.backups.upload_backup( stream, - supervisor_backups.UploadBackupOptions( - location=locations or {LOCATION_CLOUD_BACKUP} - ), + supervisor_backups.UploadBackupOptions(location=set(locations)), ) async def open_backup() -> AsyncIterator[bytes]: return await self._client.backups.download_backup(backup_id) async def remove_backup() -> None: - if locations: + if locations != [LOCATION_CLOUD_BACKUP]: return await self._client.backups.remove_backup( backup_id, @@ -372,7 +427,7 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): details = await self._client.backups.backup_info(backup_id) return WrittenBackup( - backup=_backup_details_to_agent_backup(details), + backup=_backup_details_to_agent_backup(details, locations[0]), open_stream=open_backup, release_stream=remove_backup, ) diff --git a/tests/components/hassio/test_backup.py b/tests/components/hassio/test_backup.py index 1c257416ad0..7c2bf8921ef 100644 --- a/tests/components/hassio/test_backup.py +++ b/tests/components/hassio/test_backup.py @@ -245,6 +245,56 @@ TEST_BACKUP_DETAILS_4 = supervisor_backups.BackupComplete( type=TEST_BACKUP.type, ) +TEST_BACKUP_5 = supervisor_backups.Backup( + compressed=False, + content=supervisor_backups.BackupContent( + addons=["ssl"], + folders=[supervisor_backups.Folder.SHARE], + homeassistant=True, + ), + date=datetime.fromisoformat("1970-01-01T00:00:00Z"), + location=LOCATION_CLOUD_BACKUP, + location_attributes={ + LOCATION_CLOUD_BACKUP: supervisor_backups.BackupLocationAttributes( + protected=False, size_bytes=1048576 + ) + }, + locations={LOCATION_CLOUD_BACKUP}, + name="Test", + protected=False, + size=1.0, + size_bytes=1048576, + slug="abc123", + type=supervisor_backups.BackupType.PARTIAL, +) +TEST_BACKUP_DETAILS_5 = supervisor_backups.BackupComplete( + addons=[ + supervisor_backups.BackupAddon( + name="Terminal & SSH", + size=0.0, + slug="core_ssh", + version="9.14.0", + ) + ], + compressed=TEST_BACKUP_5.compressed, + date=TEST_BACKUP_5.date, + extra=None, + folders=[supervisor_backups.Folder.SHARE], + homeassistant_exclude_database=False, + homeassistant="2024.12.0", + location=TEST_BACKUP_5.location, + location_attributes=TEST_BACKUP_5.location_attributes, + locations=TEST_BACKUP_5.locations, + name=TEST_BACKUP_5.name, + protected=TEST_BACKUP_5.protected, + repositories=[], + size=TEST_BACKUP_5.size, + size_bytes=TEST_BACKUP_5.size_bytes, + slug=TEST_BACKUP_5.slug, + supervisor_version="2024.11.2", + type=TEST_BACKUP_5.type, +) + @pytest.fixture(autouse=True) def fixture_supervisor_environ() -> Generator[None]: @@ -821,6 +871,230 @@ async def test_reader_writer_create( assert response["event"] == {"manager_state": "idle"} +@pytest.mark.usefixtures("hassio_client") +@pytest.mark.parametrize( + ( + "commands", + "password", + "agent_ids", + "password_sent_to_supervisor", + "create_locations", + "create_protected", + "upload_locations", + ), + [ + ( + [], + None, + ["hassio.local", "hassio.share1", "hassio.share2", "hassio.share3"], + None, + [None, "share1", "share2", "share3"], + False, + [], + ), + ( + [], + "hunter2", + ["hassio.local", "hassio.share1", "hassio.share2", "hassio.share3"], + "hunter2", + [None, "share1", "share2", "share3"], + True, + [], + ), + ( + [ + { + "type": "backup/config/update", + "agents": { + "hassio.local": {"protected": False}, + }, + } + ], + "hunter2", + ["hassio.local", "hassio.share1", "hassio.share2", "hassio.share3"], + "hunter2", + ["share1", "share2", "share3"], + True, + [None], + ), + ( + [ + { + "type": "backup/config/update", + "agents": { + "hassio.local": {"protected": False}, + "hassio.share1": {"protected": False}, + }, + } + ], + "hunter2", + ["hassio.local", "hassio.share1", "hassio.share2", "hassio.share3"], + "hunter2", + ["share2", "share3"], + True, + [None, "share1"], + ), + ( + [ + { + "type": "backup/config/update", + "agents": { + "hassio.local": {"protected": False}, + "hassio.share1": {"protected": False}, + "hassio.share2": {"protected": False}, + }, + } + ], + "hunter2", + ["hassio.local", "hassio.share1", "hassio.share2", "hassio.share3"], + None, + [None, "share1", "share2"], + True, + ["share3"], + ), + ( + [ + { + "type": "backup/config/update", + "agents": { + "hassio.local": {"protected": False}, + }, + } + ], + "hunter2", + ["hassio.local"], + None, + [None], + False, + [], + ), + ], +) +async def test_reader_writer_create_per_agent_encryption( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + supervisor_client: AsyncMock, + commands: dict[str, Any], + password: str | None, + agent_ids: list[str], + password_sent_to_supervisor: str | None, + create_locations: list[str | None], + create_protected: bool, + upload_locations: list[str | None], +) -> None: + """Test generating a backup.""" + client = await hass_ws_client(hass) + mounts = MountsInfo( + default_backup_mount=None, + mounts=[ + supervisor_mounts.CIFSMountResponse( + share=f"share{i}", + name=f"share{i}", + read_only=False, + state=supervisor_mounts.MountState.ACTIVE, + user_path=f"share{i}", + usage=supervisor_mounts.MountUsage.BACKUP, + server=f"share{i}", + type=supervisor_mounts.MountType.CIFS, + ) + for i in range(1, 4) + ], + ) + supervisor_client.backups.partial_backup.return_value.job_id = "abc123" + supervisor_client.backups.backup_info.return_value = replace( + TEST_BACKUP_DETAILS, + locations=create_locations, + location_attributes={ + location or LOCATION_LOCAL: supervisor_backups.BackupLocationAttributes( + protected=create_protected, + size_bytes=TEST_BACKUP_DETAILS.size_bytes, + ) + for location in create_locations + }, + ) + supervisor_client.mounts.info.return_value = mounts + assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}}) + + for command in commands: + await client.send_json_auto_id(command) + result = await client.receive_json() + assert result["success"] is True + + await client.send_json_auto_id({"type": "backup/subscribe_events"}) + response = await client.receive_json() + assert response["event"] == {"manager_state": "idle"} + response = await client.receive_json() + assert response["success"] + + await client.send_json_auto_id( + { + "type": "backup/generate", + "agent_ids": agent_ids, + "name": "Test", + "password": password, + } + ) + response = await client.receive_json() + assert response["event"] == { + "manager_state": "create_backup", + "reason": None, + "stage": None, + "state": "in_progress", + } + + response = await client.receive_json() + assert response["success"] + assert response["result"] == {"backup_job_id": "abc123"} + + supervisor_client.backups.partial_backup.assert_called_once_with( + replace( + DEFAULT_BACKUP_OPTIONS, + password=password_sent_to_supervisor, + location=create_locations, + ) + ) + + await client.send_json_auto_id( + { + "type": "supervisor/event", + "data": { + "event": "job", + "data": {"done": True, "uuid": "abc123", "reference": "test_slug"}, + }, + } + ) + response = await client.receive_json() + assert response["success"] + + response = await client.receive_json() + assert response["event"] == { + "manager_state": "create_backup", + "reason": None, + "stage": "upload_to_agents", + "state": "in_progress", + } + + response = await client.receive_json() + assert response["event"] == { + "manager_state": "create_backup", + "reason": None, + "stage": None, + "state": "completed", + } + + assert len(supervisor_client.backups.upload_backup.mock_calls) == len( + upload_locations + ) + for call in supervisor_client.backups.upload_backup.mock_calls: + upload_call_locations: set = call.args[1].location + assert len(upload_call_locations) == 1 + assert upload_call_locations.pop() in upload_locations + supervisor_client.backups.remove_backup.assert_not_called() + + response = await client.receive_json() + assert response["event"] == {"manager_state": "idle"} + + @pytest.mark.usefixtures("hassio_client", "setup_integration") @pytest.mark.parametrize( ("side_effect", "error_code", "error_message", "expected_reason"), @@ -969,7 +1243,7 @@ async def test_reader_writer_create_download_remove_error( """Test download and remove error when generating a backup.""" client = await hass_ws_client(hass) supervisor_client.backups.partial_backup.return_value.job_id = "abc123" - supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS + supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS_5 method_mock = getattr(supervisor_client.backups, method) method_mock.side_effect = exception @@ -1129,7 +1403,7 @@ async def test_reader_writer_create_remote_backup( """Test generating a backup which will be uploaded to a remote agent.""" client = await hass_ws_client(hass) supervisor_client.backups.partial_backup.return_value.job_id = "abc123" - supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS + supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS_5 remote_agent = BackupAgentTest("remote") await _setup_backup_platform( @@ -1163,7 +1437,7 @@ async def test_reader_writer_create_remote_backup( assert response["result"] == {"backup_job_id": "abc123"} supervisor_client.backups.partial_backup.assert_called_once_with( - replace(DEFAULT_BACKUP_OPTIONS, location=LOCATION_CLOUD_BACKUP), + replace(DEFAULT_BACKUP_OPTIONS, location=[LOCATION_CLOUD_BACKUP]), ) await client.send_json_auto_id( @@ -1280,7 +1554,7 @@ async def test_agent_receive_remote_backup( """Test receiving a backup which will be uploaded to a remote agent.""" client = await hass_client() backup_id = "test-backup" - supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS + supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS_5 supervisor_client.backups.upload_backup.return_value = "test_slug" test_backup = AgentBackup( addons=[AddonInfo(name="Test", slug="test", version="1.0.0")], From 8749210d1b2b065e5da837971b26a08053f85e9a Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 29 Jan 2025 18:23:25 +0100 Subject: [PATCH 0259/3148] Add support for per-backup agent encryption flag to hassio (#136828) * Add support for per-backup agent encryption flag to hassio * Improve comment * Set password to None when supervisor should not encrypt --- homeassistant/components/hassio/backup.py | 89 +++++-- tests/components/hassio/test_backup.py | 282 +++++++++++++++++++++- 2 files changed, 350 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/hassio/backup.py b/homeassistant/components/hassio/backup.py index 5318e4cd351..afeee1f4469 100644 --- a/homeassistant/components/hassio/backup.py +++ b/homeassistant/components/hassio/backup.py @@ -97,7 +97,7 @@ def async_register_backup_agents_listener( def _backup_details_to_agent_backup( - details: supervisor_backups.BackupComplete, + details: supervisor_backups.BackupComplete, location: str | None ) -> AgentBackup: """Convert a supervisor backup details object to an agent backup.""" homeassistant_included = details.homeassistant is not None @@ -109,6 +109,7 @@ def _backup_details_to_agent_backup( AddonInfo(name=addon.name, slug=addon.slug, version=addon.version) for addon in details.addons ] + location = location or LOCATION_LOCAL return AgentBackup( addons=addons, backup_id=details.slug, @@ -119,8 +120,8 @@ def _backup_details_to_agent_backup( homeassistant_included=homeassistant_included, homeassistant_version=details.homeassistant, name=details.name, - protected=details.protected, - size=details.size_bytes, + protected=details.location_attributes[location].protected, + size=details.location_attributes[location].size_bytes, ) @@ -158,8 +159,23 @@ class SupervisorBackupAgent(BackupAgent): ) -> None: """Upload a backup. - Not required for supervisor, the SupervisorBackupReaderWriter stores files. + The upload will be skipped if the backup already exists in the agent's location. """ + if await self.async_get_backup(backup.backup_id): + _LOGGER.debug( + "Backup %s already exists in location %s", + backup.backup_id, + self.location, + ) + return + stream = await open_stream() + upload_options = supervisor_backups.UploadBackupOptions( + location={self.location} + ) + await self._client.backups.upload_backup( + stream, + upload_options, + ) async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]: """List backups.""" @@ -169,7 +185,7 @@ class SupervisorBackupAgent(BackupAgent): if not backup.locations or self.location not in backup.locations: continue details = await self._client.backups.backup_info(backup.slug) - result.append(_backup_details_to_agent_backup(details)) + result.append(_backup_details_to_agent_backup(details, self.location)) return result async def async_get_backup( @@ -181,7 +197,7 @@ class SupervisorBackupAgent(BackupAgent): details = await self._client.backups.backup_info(backup_id) if self.location not in details.locations: return None - return _backup_details_to_agent_backup(details) + return _backup_details_to_agent_backup(details, self.location) async def async_delete_backup(self, backup_id: str, **kwargs: Any) -> None: """Remove a backup.""" @@ -246,7 +262,41 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): for agent_id in agent_ids if manager.backup_agents[agent_id].domain == DOMAIN ] - locations = [agent.location for agent in hassio_agents] + + # Supervisor does not support creating backups spread across multiple + # locations, where some locations are encrypted and some are not. + # It's inefficient to let core do all the copying so we want to let + # supervisor handle as much as possible. + # Therefore, we split the locations into two lists: encrypted and decrypted. + # The longest list will be sent to supervisor, and the remaining locations + # will be handled by async_upload_backup. + # If the lists are the same length, it does not matter which one we send, + # we send the encrypted list to have a well defined behavior. + encrypted_locations: list[str | None] = [] + decrypted_locations: list[str | None] = [] + agents_settings = manager.config.data.agents + for hassio_agent in hassio_agents: + if password is not None: + if agent_settings := agents_settings.get(hassio_agent.agent_id): + if agent_settings.protected: + encrypted_locations.append(hassio_agent.location) + else: + decrypted_locations.append(hassio_agent.location) + else: + encrypted_locations.append(hassio_agent.location) + else: + decrypted_locations.append(hassio_agent.location) + _LOGGER.debug("Encrypted locations: %s", encrypted_locations) + _LOGGER.debug("Decrypted locations: %s", decrypted_locations) + if hassio_agents: + if len(encrypted_locations) >= len(decrypted_locations): + locations = encrypted_locations + else: + locations = decrypted_locations + password = None + else: + locations = [] + locations = locations or [LOCATION_CLOUD_BACKUP] try: backup = await self._client.backups.partial_backup( @@ -257,7 +307,7 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): name=backup_name, password=password, compressed=True, - location=locations or LOCATION_CLOUD_BACKUP, + location=locations, homeassistant_exclude_database=not include_database, background=True, extra=extra_metadata, @@ -267,7 +317,9 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): raise BackupReaderWriterError(f"Error creating backup: {err}") from err backup_task = self._hass.async_create_task( self._async_wait_for_backup( - backup, remove_after_upload=not bool(locations) + backup, + locations, + remove_after_upload=locations == [LOCATION_CLOUD_BACKUP], ), name="backup_manager_create_backup", eager_start=False, # To ensure the task is not started before we return @@ -276,7 +328,11 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): return (NewBackup(backup_job_id=backup.job_id), backup_task) async def _async_wait_for_backup( - self, backup: supervisor_backups.NewBackup, *, remove_after_upload: bool + self, + backup: supervisor_backups.NewBackup, + locations: list[str | None], + *, + remove_after_upload: bool, ) -> WrittenBackup: """Wait for a backup to complete.""" backup_complete = asyncio.Event() @@ -327,7 +383,7 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): ) from err return WrittenBackup( - backup=_backup_details_to_agent_backup(details), + backup=_backup_details_to_agent_backup(details, locations[0]), open_stream=open_backup, release_stream=remove_backup, ) @@ -347,20 +403,19 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): for agent_id in agent_ids if manager.backup_agents[agent_id].domain == DOMAIN ] - locations = {agent.location for agent in hassio_agents} + locations = [agent.location for agent in hassio_agents] + locations = locations or [LOCATION_CLOUD_BACKUP] backup_id = await self._client.backups.upload_backup( stream, - supervisor_backups.UploadBackupOptions( - location=locations or {LOCATION_CLOUD_BACKUP} - ), + supervisor_backups.UploadBackupOptions(location=set(locations)), ) async def open_backup() -> AsyncIterator[bytes]: return await self._client.backups.download_backup(backup_id) async def remove_backup() -> None: - if locations: + if locations != [LOCATION_CLOUD_BACKUP]: return await self._client.backups.remove_backup( backup_id, @@ -372,7 +427,7 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): details = await self._client.backups.backup_info(backup_id) return WrittenBackup( - backup=_backup_details_to_agent_backup(details), + backup=_backup_details_to_agent_backup(details, locations[0]), open_stream=open_backup, release_stream=remove_backup, ) diff --git a/tests/components/hassio/test_backup.py b/tests/components/hassio/test_backup.py index 1c257416ad0..7c2bf8921ef 100644 --- a/tests/components/hassio/test_backup.py +++ b/tests/components/hassio/test_backup.py @@ -245,6 +245,56 @@ TEST_BACKUP_DETAILS_4 = supervisor_backups.BackupComplete( type=TEST_BACKUP.type, ) +TEST_BACKUP_5 = supervisor_backups.Backup( + compressed=False, + content=supervisor_backups.BackupContent( + addons=["ssl"], + folders=[supervisor_backups.Folder.SHARE], + homeassistant=True, + ), + date=datetime.fromisoformat("1970-01-01T00:00:00Z"), + location=LOCATION_CLOUD_BACKUP, + location_attributes={ + LOCATION_CLOUD_BACKUP: supervisor_backups.BackupLocationAttributes( + protected=False, size_bytes=1048576 + ) + }, + locations={LOCATION_CLOUD_BACKUP}, + name="Test", + protected=False, + size=1.0, + size_bytes=1048576, + slug="abc123", + type=supervisor_backups.BackupType.PARTIAL, +) +TEST_BACKUP_DETAILS_5 = supervisor_backups.BackupComplete( + addons=[ + supervisor_backups.BackupAddon( + name="Terminal & SSH", + size=0.0, + slug="core_ssh", + version="9.14.0", + ) + ], + compressed=TEST_BACKUP_5.compressed, + date=TEST_BACKUP_5.date, + extra=None, + folders=[supervisor_backups.Folder.SHARE], + homeassistant_exclude_database=False, + homeassistant="2024.12.0", + location=TEST_BACKUP_5.location, + location_attributes=TEST_BACKUP_5.location_attributes, + locations=TEST_BACKUP_5.locations, + name=TEST_BACKUP_5.name, + protected=TEST_BACKUP_5.protected, + repositories=[], + size=TEST_BACKUP_5.size, + size_bytes=TEST_BACKUP_5.size_bytes, + slug=TEST_BACKUP_5.slug, + supervisor_version="2024.11.2", + type=TEST_BACKUP_5.type, +) + @pytest.fixture(autouse=True) def fixture_supervisor_environ() -> Generator[None]: @@ -821,6 +871,230 @@ async def test_reader_writer_create( assert response["event"] == {"manager_state": "idle"} +@pytest.mark.usefixtures("hassio_client") +@pytest.mark.parametrize( + ( + "commands", + "password", + "agent_ids", + "password_sent_to_supervisor", + "create_locations", + "create_protected", + "upload_locations", + ), + [ + ( + [], + None, + ["hassio.local", "hassio.share1", "hassio.share2", "hassio.share3"], + None, + [None, "share1", "share2", "share3"], + False, + [], + ), + ( + [], + "hunter2", + ["hassio.local", "hassio.share1", "hassio.share2", "hassio.share3"], + "hunter2", + [None, "share1", "share2", "share3"], + True, + [], + ), + ( + [ + { + "type": "backup/config/update", + "agents": { + "hassio.local": {"protected": False}, + }, + } + ], + "hunter2", + ["hassio.local", "hassio.share1", "hassio.share2", "hassio.share3"], + "hunter2", + ["share1", "share2", "share3"], + True, + [None], + ), + ( + [ + { + "type": "backup/config/update", + "agents": { + "hassio.local": {"protected": False}, + "hassio.share1": {"protected": False}, + }, + } + ], + "hunter2", + ["hassio.local", "hassio.share1", "hassio.share2", "hassio.share3"], + "hunter2", + ["share2", "share3"], + True, + [None, "share1"], + ), + ( + [ + { + "type": "backup/config/update", + "agents": { + "hassio.local": {"protected": False}, + "hassio.share1": {"protected": False}, + "hassio.share2": {"protected": False}, + }, + } + ], + "hunter2", + ["hassio.local", "hassio.share1", "hassio.share2", "hassio.share3"], + None, + [None, "share1", "share2"], + True, + ["share3"], + ), + ( + [ + { + "type": "backup/config/update", + "agents": { + "hassio.local": {"protected": False}, + }, + } + ], + "hunter2", + ["hassio.local"], + None, + [None], + False, + [], + ), + ], +) +async def test_reader_writer_create_per_agent_encryption( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + supervisor_client: AsyncMock, + commands: dict[str, Any], + password: str | None, + agent_ids: list[str], + password_sent_to_supervisor: str | None, + create_locations: list[str | None], + create_protected: bool, + upload_locations: list[str | None], +) -> None: + """Test generating a backup.""" + client = await hass_ws_client(hass) + mounts = MountsInfo( + default_backup_mount=None, + mounts=[ + supervisor_mounts.CIFSMountResponse( + share=f"share{i}", + name=f"share{i}", + read_only=False, + state=supervisor_mounts.MountState.ACTIVE, + user_path=f"share{i}", + usage=supervisor_mounts.MountUsage.BACKUP, + server=f"share{i}", + type=supervisor_mounts.MountType.CIFS, + ) + for i in range(1, 4) + ], + ) + supervisor_client.backups.partial_backup.return_value.job_id = "abc123" + supervisor_client.backups.backup_info.return_value = replace( + TEST_BACKUP_DETAILS, + locations=create_locations, + location_attributes={ + location or LOCATION_LOCAL: supervisor_backups.BackupLocationAttributes( + protected=create_protected, + size_bytes=TEST_BACKUP_DETAILS.size_bytes, + ) + for location in create_locations + }, + ) + supervisor_client.mounts.info.return_value = mounts + assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}}) + + for command in commands: + await client.send_json_auto_id(command) + result = await client.receive_json() + assert result["success"] is True + + await client.send_json_auto_id({"type": "backup/subscribe_events"}) + response = await client.receive_json() + assert response["event"] == {"manager_state": "idle"} + response = await client.receive_json() + assert response["success"] + + await client.send_json_auto_id( + { + "type": "backup/generate", + "agent_ids": agent_ids, + "name": "Test", + "password": password, + } + ) + response = await client.receive_json() + assert response["event"] == { + "manager_state": "create_backup", + "reason": None, + "stage": None, + "state": "in_progress", + } + + response = await client.receive_json() + assert response["success"] + assert response["result"] == {"backup_job_id": "abc123"} + + supervisor_client.backups.partial_backup.assert_called_once_with( + replace( + DEFAULT_BACKUP_OPTIONS, + password=password_sent_to_supervisor, + location=create_locations, + ) + ) + + await client.send_json_auto_id( + { + "type": "supervisor/event", + "data": { + "event": "job", + "data": {"done": True, "uuid": "abc123", "reference": "test_slug"}, + }, + } + ) + response = await client.receive_json() + assert response["success"] + + response = await client.receive_json() + assert response["event"] == { + "manager_state": "create_backup", + "reason": None, + "stage": "upload_to_agents", + "state": "in_progress", + } + + response = await client.receive_json() + assert response["event"] == { + "manager_state": "create_backup", + "reason": None, + "stage": None, + "state": "completed", + } + + assert len(supervisor_client.backups.upload_backup.mock_calls) == len( + upload_locations + ) + for call in supervisor_client.backups.upload_backup.mock_calls: + upload_call_locations: set = call.args[1].location + assert len(upload_call_locations) == 1 + assert upload_call_locations.pop() in upload_locations + supervisor_client.backups.remove_backup.assert_not_called() + + response = await client.receive_json() + assert response["event"] == {"manager_state": "idle"} + + @pytest.mark.usefixtures("hassio_client", "setup_integration") @pytest.mark.parametrize( ("side_effect", "error_code", "error_message", "expected_reason"), @@ -969,7 +1243,7 @@ async def test_reader_writer_create_download_remove_error( """Test download and remove error when generating a backup.""" client = await hass_ws_client(hass) supervisor_client.backups.partial_backup.return_value.job_id = "abc123" - supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS + supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS_5 method_mock = getattr(supervisor_client.backups, method) method_mock.side_effect = exception @@ -1129,7 +1403,7 @@ async def test_reader_writer_create_remote_backup( """Test generating a backup which will be uploaded to a remote agent.""" client = await hass_ws_client(hass) supervisor_client.backups.partial_backup.return_value.job_id = "abc123" - supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS + supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS_5 remote_agent = BackupAgentTest("remote") await _setup_backup_platform( @@ -1163,7 +1437,7 @@ async def test_reader_writer_create_remote_backup( assert response["result"] == {"backup_job_id": "abc123"} supervisor_client.backups.partial_backup.assert_called_once_with( - replace(DEFAULT_BACKUP_OPTIONS, location=LOCATION_CLOUD_BACKUP), + replace(DEFAULT_BACKUP_OPTIONS, location=[LOCATION_CLOUD_BACKUP]), ) await client.send_json_auto_id( @@ -1280,7 +1554,7 @@ async def test_agent_receive_remote_backup( """Test receiving a backup which will be uploaded to a remote agent.""" client = await hass_client() backup_id = "test-backup" - supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS + supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS_5 supervisor_client.backups.upload_backup.return_value = "test_slug" test_backup = AgentBackup( addons=[AddonInfo(name="Test", slug="test", version="1.0.0")], From 1d196e1b1f60402b19445a09da4313a652fb4a86 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 29 Jan 2025 18:22:41 +0100 Subject: [PATCH 0260/3148] Bump version to 2025.2.0b0 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 699aebcafdf..3fc165526ee 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -25,7 +25,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2025 MINOR_VERSION: Final = 2 -PATCH_VERSION: Final = "0.dev0" +PATCH_VERSION: Final = "0b0" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 13, 0) diff --git a/pyproject.toml b/pyproject.toml index 5393193a41e..55d7e7d2231 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.2.0.dev0" +version = "2025.2.0b0" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 46cef2986c531e2f2d530fa474e4796b067f65d9 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 29 Jan 2025 19:32:36 +0100 Subject: [PATCH 0261/3148] Bump version to 2025.3.0 (#136859) --- .github/workflows/ci.yaml | 2 +- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index a58648212e3..863c861db75 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -40,7 +40,7 @@ env: CACHE_VERSION: 11 UV_CACHE_VERSION: 1 MYPY_CACHE_VERSION: 9 - HA_SHORT_VERSION: "2025.2" + HA_SHORT_VERSION: "2025.3" DEFAULT_PYTHON: "3.13" ALL_PYTHON_VERSIONS: "['3.13']" # 10.3 is the oldest supported version diff --git a/homeassistant/const.py b/homeassistant/const.py index 699aebcafdf..bdce303e64a 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -24,7 +24,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2025 -MINOR_VERSION: Final = 2 +MINOR_VERSION: Final = 3 PATCH_VERSION: Final = "0.dev0" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" diff --git a/pyproject.toml b/pyproject.toml index 5393193a41e..31aeb180b8c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.2.0.dev0" +version = "2025.3.0.dev0" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From b500fde46843ac29e81a979ce366b221b3ab0fa8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Wed, 29 Jan 2025 18:51:09 +0000 Subject: [PATCH 0262/3148] Handle locked account error in Whirlpool (#136861) --- homeassistant/components/whirlpool/__init__.py | 6 +++++- homeassistant/components/whirlpool/config_flow.py | 4 +++- homeassistant/components/whirlpool/strings.json | 9 +++++++++ tests/components/whirlpool/test_config_flow.py | 3 +++ tests/components/whirlpool/test_init.py | 13 +++++++++++++ 5 files changed, 33 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/whirlpool/__init__.py b/homeassistant/components/whirlpool/__init__.py index 64adcda4742..6231324bb0d 100644 --- a/homeassistant/components/whirlpool/__init__.py +++ b/homeassistant/components/whirlpool/__init__.py @@ -5,7 +5,7 @@ import logging from aiohttp import ClientError from whirlpool.appliancesmanager import AppliancesManager -from whirlpool.auth import Auth +from whirlpool.auth import AccountLockedError as WhirlpoolAccountLocked, Auth from whirlpool.backendselector import BackendSelector from homeassistant.config_entries import ConfigEntry @@ -39,6 +39,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: WhirlpoolConfigEntry) -> await auth.do_auth(store=False) except (ClientError, TimeoutError) as ex: raise ConfigEntryNotReady("Cannot connect") from ex + except WhirlpoolAccountLocked as ex: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, translation_key="account_locked" + ) from ex if not auth.is_access_token_valid(): _LOGGER.error("Authentication failed") diff --git a/homeassistant/components/whirlpool/config_flow.py b/homeassistant/components/whirlpool/config_flow.py index 44445dee03f..19715643e3a 100644 --- a/homeassistant/components/whirlpool/config_flow.py +++ b/homeassistant/components/whirlpool/config_flow.py @@ -9,7 +9,7 @@ from typing import Any from aiohttp import ClientError import voluptuous as vol from whirlpool.appliancesmanager import AppliancesManager -from whirlpool.auth import Auth +from whirlpool.auth import AccountLockedError as WhirlpoolAccountLocked, Auth from whirlpool.backendselector import BackendSelector from homeassistant.config_entries import ConfigFlow, ConfigFlowResult @@ -55,6 +55,8 @@ async def authenticate( try: await auth.do_auth() + except WhirlpoolAccountLocked: + return "account_locked" except (TimeoutError, ClientError): return "cannot_connect" except Exception: diff --git a/homeassistant/components/whirlpool/strings.json b/homeassistant/components/whirlpool/strings.json index 09257652ece..95df3fb9098 100644 --- a/homeassistant/components/whirlpool/strings.json +++ b/homeassistant/components/whirlpool/strings.json @@ -1,4 +1,7 @@ { + "common": { + "account_locked_error": "The account is locked. Please follow the instructions in the manufacturer's app to unlock it" + }, "config": { "step": { "user": { @@ -31,6 +34,7 @@ "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" }, "error": { + "account_locked": "[%key:component::whirlpool::common::account_locked_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%]", @@ -85,5 +89,10 @@ "name": "End time" } } + }, + "exceptions": { + "account_locked": { + "message": "[%key:component::whirlpool::common::account_locked_error%]" + } } } diff --git a/tests/components/whirlpool/test_config_flow.py b/tests/components/whirlpool/test_config_flow.py index a82c2a22695..e01fbc07b51 100644 --- a/tests/components/whirlpool/test_config_flow.py +++ b/tests/components/whirlpool/test_config_flow.py @@ -4,6 +4,7 @@ from unittest.mock import MagicMock, patch import aiohttp import pytest +from whirlpool.auth import AccountLockedError from homeassistant import config_entries from homeassistant.components.whirlpool.const import CONF_BRAND, DOMAIN @@ -82,6 +83,7 @@ async def test_form_invalid_auth( @pytest.mark.parametrize( ("exception", "expected_error"), [ + (AccountLockedError, "account_locked"), (aiohttp.ClientConnectionError, "cannot_connect"), (TimeoutError, "cannot_connect"), (Exception, "unknown"), @@ -249,6 +251,7 @@ async def test_reauth_flow_invalid_auth( @pytest.mark.parametrize( ("exception", "expected_error"), [ + (AccountLockedError, "account_locked"), (aiohttp.ClientConnectionError, "cannot_connect"), (TimeoutError, "cannot_connect"), (Exception, "unknown"), diff --git a/tests/components/whirlpool/test_init.py b/tests/components/whirlpool/test_init.py index f9d28e78a06..8f082ff6294 100644 --- a/tests/components/whirlpool/test_init.py +++ b/tests/components/whirlpool/test_init.py @@ -3,6 +3,7 @@ from unittest.mock import AsyncMock, MagicMock import aiohttp +from whirlpool.auth import AccountLockedError from whirlpool.backendselector import Brand, Region from homeassistant.components.whirlpool.const import DOMAIN @@ -104,6 +105,18 @@ async def test_setup_auth_failed( assert entry.state is ConfigEntryState.SETUP_ERROR +async def test_setup_auth_account_locked( + hass: HomeAssistant, + mock_auth_api: MagicMock, + mock_aircon_api_instances: MagicMock, +) -> None: + """Test setup with failed auth due to account being locked.""" + mock_auth_api.return_value.do_auth.side_effect = AccountLockedError + entry = await init_integration(hass) + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert entry.state is ConfigEntryState.SETUP_ERROR + + async def test_setup_fetch_appliances_failed( hass: HomeAssistant, mock_appliances_manager_api: MagicMock, From d206553a0da4bcafa2e840cebd29bd4baeba25bc Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Wed, 29 Jan 2025 12:52:32 -0600 Subject: [PATCH 0263/3148] Cancel call if user does not pick up (#136858) --- .../components/voip/assist_satellite.py | 64 ++++++++++++++----- homeassistant/components/voip/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/voip/test_voip.py | 40 ++++++++++++ 5 files changed, 90 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/voip/assist_satellite.py b/homeassistant/components/voip/assist_satellite.py index 738c3a1e235..6cacdd79af4 100644 --- a/homeassistant/components/voip/assist_satellite.py +++ b/homeassistant/components/voip/assist_satellite.py @@ -14,7 +14,7 @@ from typing import TYPE_CHECKING, Any, Final import wave from voip_utils import SIP_PORT, RtpDatagramProtocol -from voip_utils.sip import SipEndpoint, get_sip_endpoint +from voip_utils.sip import SipDatagramProtocol, SipEndpoint, get_sip_endpoint from homeassistant.components import tts from homeassistant.components.assist_pipeline import PipelineEvent, PipelineEventType @@ -43,6 +43,7 @@ _PIPELINE_TIMEOUT_SEC: Final = 30 _ANNOUNCEMENT_BEFORE_DELAY: Final = 0.5 _ANNOUNCEMENT_AFTER_DELAY: Final = 1.0 _ANNOUNCEMENT_HANGUP_SEC: Final = 0.5 +_ANNOUNCEMENT_RING_TIMEOUT: Final = 30 class Tones(IntFlag): @@ -116,7 +117,8 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol self._processing_tone_done = asyncio.Event() self._announcement: AssistSatelliteAnnouncement | None = None - self._announcement_done = asyncio.Event() + self._announcement_future: asyncio.Future[Any] = asyncio.Future() + self._announcment_start_time: float = 0.0 self._check_announcement_ended_task: asyncio.Task | None = None self._last_chunk_time: float | None = None self._rtp_port: int | None = None @@ -170,7 +172,7 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol Plays announcement in a loop, blocking until the caller hangs up. """ - self._announcement_done.clear() + self._announcement_future = asyncio.Future() if self._rtp_port is None: # Choose random port for RTP @@ -194,16 +196,34 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol host=self.voip_device.voip_id, port=SIP_PORT ) + # Reset state so we can time out if needed + self._last_chunk_time = None + self._announcment_start_time = time.monotonic() self._announcement = announcement # Make the call - self.hass.data[DOMAIN].protocol.outgoing_call( + sip_protocol: SipDatagramProtocol = self.hass.data[DOMAIN].protocol + call_info = sip_protocol.outgoing_call( source=source_endpoint, destination=destination_endpoint, rtp_port=self._rtp_port, ) - await self._announcement_done.wait() + # Check if caller hung up or didn't pick up + self._check_announcement_ended_task = ( + self.config_entry.async_create_background_task( + self.hass, + self._check_announcement_ended(), + "voip_announcement_ended", + ) + ) + + try: + await self._announcement_future + except TimeoutError: + # Stop ringing + sip_protocol.cancel_call(call_info) + raise async def _check_announcement_ended(self) -> None: """Continuously checks if an audio chunk was received within a time limit. @@ -211,12 +231,32 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol If not, the caller is presumed to have hung up and the announcement is ended. """ while self._announcement is not None: + current_time = time.monotonic() + _LOGGER.debug( + "%s %s %s", + self._last_chunk_time, + current_time, + self._announcment_start_time, + ) + if (self._last_chunk_time is None) and ( + (current_time - self._announcment_start_time) + > _ANNOUNCEMENT_RING_TIMEOUT + ): + # Ring timeout + self._announcement = None + self._check_announcement_ended_task = None + self._announcement_future.set_exception( + TimeoutError("User did not pick up in time") + ) + _LOGGER.debug("Timed out waiting for the user to pick up the phone") + break + if (self._last_chunk_time is not None) and ( - (time.monotonic() - self._last_chunk_time) > _ANNOUNCEMENT_HANGUP_SEC + (current_time - self._last_chunk_time) > _ANNOUNCEMENT_HANGUP_SEC ): # Caller hung up self._announcement = None - self._announcement_done.set() + self._announcement_future.set_result(None) self._check_announcement_ended_task = None _LOGGER.debug("Announcement ended") break @@ -248,16 +288,6 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol self._audio_queue.put_nowait(audio_bytes) elif self._run_pipeline_task is None: # Announcement only - if self._check_announcement_ended_task is None: - # Check if caller hung up - self._check_announcement_ended_task = ( - self.config_entry.async_create_background_task( - self.hass, - self._check_announcement_ended(), - "voip_announcement_ended", - ) - ) - # Play announcement (will repeat) self._run_pipeline_task = self.config_entry.async_create_background_task( self.hass, diff --git a/homeassistant/components/voip/manifest.json b/homeassistant/components/voip/manifest.json index b279665a03a..e3b2861dbe5 100644 --- a/homeassistant/components/voip/manifest.json +++ b/homeassistant/components/voip/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/voip", "iot_class": "local_push", "quality_scale": "internal", - "requirements": ["voip-utils==0.3.0"] + "requirements": ["voip-utils==0.3.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index c87835f9153..9e6da1045a4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2991,7 +2991,7 @@ venstarcolortouch==0.19 vilfo-api-client==0.5.0 # homeassistant.components.voip -voip-utils==0.3.0 +voip-utils==0.3.1 # homeassistant.components.volkszaehler volkszaehler==0.4.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 968eec09d28..76ae46099c2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2407,7 +2407,7 @@ venstarcolortouch==0.19 vilfo-api-client==0.5.0 # homeassistant.components.voip -voip-utils==0.3.0 +voip-utils==0.3.1 # homeassistant.components.volvooncall volvooncall==0.10.3 diff --git a/tests/components/voip/test_voip.py b/tests/components/voip/test_voip.py index ac7c295c934..306857a1a44 100644 --- a/tests/components/voip/test_voip.py +++ b/tests/components/voip/test_voip.py @@ -941,3 +941,43 @@ async def test_voip_id_is_ip_address( await announce_task mock_send_tts.assert_called_once_with(_MEDIA_ID, wait_for_tone=False) + + +@pytest.mark.usefixtures("socket_enabled") +async def test_announce_timeout( + hass: HomeAssistant, + voip_devices: VoIPDevices, + voip_device: VoIPDevice, +) -> None: + """Test announcement when user does not pick up the phone in time.""" + assert await async_setup_component(hass, "voip", {}) + + satellite = async_get_satellite_entity(hass, voip.DOMAIN, voip_device.voip_id) + assert isinstance(satellite, VoipAssistSatellite) + assert ( + satellite.supported_features + & assist_satellite.AssistSatelliteEntityFeature.ANNOUNCE + ) + + announcement = assist_satellite.AssistSatelliteAnnouncement( + message="test announcement", + media_id=_MEDIA_ID, + original_media_id=_MEDIA_ID, + media_id_source="tts", + ) + + # Protocol has already been mocked, but some methods are not async + mock_protocol: AsyncMock = hass.data[DOMAIN].protocol + mock_protocol.outgoing_call = Mock() + mock_protocol.cancel_call = Mock() + + # Very short timeout which will trigger because we don't send any audio in + with ( + patch( + "homeassistant.components.voip.assist_satellite._ANNOUNCEMENT_RING_TIMEOUT", + 0.01, + ), + ): + satellite.transport = Mock() + with pytest.raises(TimeoutError): + await satellite.async_announce(announcement) From 5286bd8f0c65deaa313cafa02b5b334ebc23c4c2 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 29 Jan 2025 19:55:02 +0100 Subject: [PATCH 0264/3148] Persist hassio backup restore status after core restart (#136857) * Persist hassio backup restore status after core restart * Remove useless condition --- homeassistant/components/backup/__init__.py | 2 + homeassistant/components/hassio/backup.py | 43 ++++++++++++ tests/components/conftest.py | 1 + tests/components/hassio/test_backup.py | 74 ++++++++++++++++++++- 4 files changed, 119 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/backup/__init__.py b/homeassistant/components/backup/__init__.py index d3903c2d679..3003f94c2ed 100644 --- a/homeassistant/components/backup/__init__.py +++ b/homeassistant/components/backup/__init__.py @@ -31,6 +31,7 @@ from .manager import ( ManagerBackup, NewBackup, RestoreBackupEvent, + RestoreBackupState, WrittenBackup, ) from .models import AddonInfo, AgentBackup, Folder @@ -54,6 +55,7 @@ __all__ = [ "ManagerBackup", "NewBackup", "RestoreBackupEvent", + "RestoreBackupState", "WrittenBackup", "async_get_manager", ] diff --git a/homeassistant/components/hassio/backup.py b/homeassistant/components/hassio/backup.py index afeee1f4469..6b63ab92d5c 100644 --- a/homeassistant/components/hassio/backup.py +++ b/homeassistant/components/hassio/backup.py @@ -5,8 +5,10 @@ from __future__ import annotations import asyncio from collections.abc import AsyncIterator, Callable, Coroutine, Mapping import logging +import os from pathlib import Path from typing import Any, cast +from uuid import UUID from aiohasupervisor import SupervisorClient from aiohasupervisor.exceptions import ( @@ -33,6 +35,7 @@ from homeassistant.components.backup import ( IncorrectPasswordError, NewBackup, RestoreBackupEvent, + RestoreBackupState, WrittenBackup, async_get_manager as async_get_backup_manager, ) @@ -47,6 +50,7 @@ from .handler import get_supervisor_client LOCATION_CLOUD_BACKUP = ".cloud_backup" LOCATION_LOCAL = ".local" MOUNT_JOBS = ("mount_manager_create_mount", "mount_manager_remove_mount") +RESTORE_JOB_ID_ENV = "SUPERVISOR_RESTORE_JOB_ID" _LOGGER = logging.getLogger(__name__) @@ -518,6 +522,37 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): on_progress: Callable[[RestoreBackupEvent | IdleEvent], None], ) -> None: """Check restore status after core restart.""" + if not (restore_job_id := os.environ.get(RESTORE_JOB_ID_ENV)): + _LOGGER.debug("No restore job ID found in environment") + return + + _LOGGER.debug("Found restore job ID %s in environment", restore_job_id) + + @callback + def on_job_progress(data: Mapping[str, Any]) -> None: + """Handle backup restore progress.""" + if data.get("done") is not True: + on_progress( + RestoreBackupEvent( + reason="", stage=None, state=RestoreBackupState.IN_PROGRESS + ) + ) + return + + on_progress( + RestoreBackupEvent( + reason="", stage=None, state=RestoreBackupState.COMPLETED + ) + ) + on_progress(IdleEvent()) + unsub() + + unsub = self._async_listen_job_events(restore_job_id, on_job_progress) + try: + await self._get_job_state(restore_job_id, on_job_progress) + except SupervisorError as err: + _LOGGER.debug("Could not get restore job %s: %s", restore_job_id, err) + unsub() @callback def _async_listen_job_events( @@ -546,6 +581,14 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): ) return unsub + async def _get_job_state( + self, job_id: str, on_event: Callable[[Mapping[str, Any]], None] + ) -> None: + """Poll a job for its state.""" + job = await self._client.jobs.get_job(UUID(job_id)) + _LOGGER.debug("Job state: %s", job) + on_event(job.to_dict()) + async def _default_agent(client: SupervisorClient) -> str: """Return the default agent for creating a backup.""" diff --git a/tests/components/conftest.py b/tests/components/conftest.py index 0cd33e28d35..ebf390e30d7 100644 --- a/tests/components/conftest.py +++ b/tests/components/conftest.py @@ -535,6 +535,7 @@ def supervisor_client() -> Generator[AsyncMock]: supervisor_client.discovery = AsyncMock() supervisor_client.homeassistant = AsyncMock() supervisor_client.host = AsyncMock() + supervisor_client.jobs = AsyncMock() supervisor_client.mounts.info.return_value = mounts_info_mock supervisor_client.os = AsyncMock() supervisor_client.resolution = AsyncMock() diff --git a/tests/components/hassio/test_backup.py b/tests/components/hassio/test_backup.py index 7c2bf8921ef..49360783517 100644 --- a/tests/components/hassio/test_backup.py +++ b/tests/components/hassio/test_backup.py @@ -13,6 +13,7 @@ from io import StringIO import os from typing import Any from unittest.mock import ANY, AsyncMock, Mock, patch +from uuid import UUID from aiohasupervisor.exceptions import ( SupervisorBadRequestError, @@ -21,6 +22,7 @@ from aiohasupervisor.exceptions import ( ) from aiohasupervisor.models import ( backups as supervisor_backups, + jobs as supervisor_jobs, mounts as supervisor_mounts, ) from aiohasupervisor.models.mounts import MountsInfo @@ -35,7 +37,11 @@ from homeassistant.components.backup import ( Folder, ) from homeassistant.components.hassio import DOMAIN -from homeassistant.components.hassio.backup import LOCATION_CLOUD_BACKUP, LOCATION_LOCAL +from homeassistant.components.hassio.backup import ( + LOCATION_CLOUD_BACKUP, + LOCATION_LOCAL, + RESTORE_JOB_ID_ENV, +) from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -1802,3 +1808,69 @@ async def test_reader_writer_restore_wrong_parameters( "code": "home_assistant_error", "message": expected_error, } + + +@pytest.mark.usefixtures("hassio_client") +async def test_restore_progress_after_restart( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + supervisor_client: AsyncMock, +) -> None: + """Test restore backup progress after restart.""" + + supervisor_client.jobs.get_job.return_value = supervisor_jobs.Job( + name="backup_manager_partial_backup", + reference="1ef41507", + uuid=UUID("d17bd02be1f0437fa7264b16d38f700e"), + progress=0.0, + stage="copy_additional_locations", + done=True, + errors=[], + child_jobs=[], + ) + + with patch.dict( + os.environ, + MOCK_ENVIRON | {RESTORE_JOB_ID_ENV: "d17bd02be1f0437fa7264b16d38f700e"}, + ): + assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}}) + + client = await hass_ws_client(hass) + + await client.send_json_auto_id({"type": "backup/info"}) + response = await client.receive_json() + + assert response["success"] + assert response["result"]["last_non_idle_event"] == { + "manager_state": "restore_backup", + "reason": "", + "stage": None, + "state": "completed", + } + assert response["result"]["state"] == "idle" + + +@pytest.mark.usefixtures("hassio_client") +async def test_restore_progress_after_restart_unknown_job( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + supervisor_client: AsyncMock, +) -> None: + """Test restore backup progress after restart.""" + + supervisor_client.jobs.get_job.side_effect = SupervisorError + + with patch.dict( + os.environ, + MOCK_ENVIRON | {RESTORE_JOB_ID_ENV: "d17bd02be1f0437fa7264b16d38f700e"}, + ): + assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}}) + + client = await hass_ws_client(hass) + + await client.send_json_auto_id({"type": "backup/info"}) + response = await client.receive_json() + + assert response["success"] + assert response["result"]["last_non_idle_event"] is None + assert response["result"]["state"] == "idle" From edabf0f8dd5b52132f4043c686f63a1ba9f4b70f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Jan 2025 09:09:00 -1000 Subject: [PATCH 0265/3148] Fix incorrect Bluetooth source address when restoring data from D-Bus (#136862) --- homeassistant/components/bluetooth/util.py | 10 +++++++++- tests/components/bluetooth/test_manager.py | 4 +++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/bluetooth/util.py b/homeassistant/components/bluetooth/util.py index ca2e0180c00..738a61b6f33 100644 --- a/homeassistant/components/bluetooth/util.py +++ b/homeassistant/components/bluetooth/util.py @@ -39,6 +39,10 @@ def async_load_history_from_system( now_monotonic = monotonic_time_coarse() connectable_loaded_history: dict[str, BluetoothServiceInfoBleak] = {} all_loaded_history: dict[str, BluetoothServiceInfoBleak] = {} + adapter_to_source_address = { + adapter: details[ADAPTER_ADDRESS] + for adapter, details in adapters.adapters.items() + } # Restore local adapters for address, history in adapters.history.items(): @@ -50,7 +54,11 @@ def async_load_history_from_system( BluetoothServiceInfoBleak.from_device_and_advertisement_data( history.device, history.advertisement_data, - history.source, + # history.source is really the adapter name + # for historical compatibility since BlueZ + # does not know the MAC address of the adapter + # so we need to convert it to the source address (MAC) + adapter_to_source_address.get(history.source, history.source), now_monotonic, True, ) diff --git a/tests/components/bluetooth/test_manager.py b/tests/components/bluetooth/test_manager.py index c7fc80ba068..be23a536f49 100644 --- a/tests/components/bluetooth/test_manager.py +++ b/tests/components/bluetooth/test_manager.py @@ -426,7 +426,7 @@ async def test_restore_history_from_dbus( address: AdvertisementHistory( ble_device, generate_advertisement_data(local_name="name"), - HCI0_SOURCE_ADDRESS, + "hci0", ) } @@ -438,6 +438,8 @@ async def test_restore_history_from_dbus( await hass.async_block_till_done() assert bluetooth.async_ble_device_from_address(hass, address) is ble_device + info = bluetooth.async_last_service_info(hass, address, False) + assert info.source == "00:00:00:00:00:01" @pytest.mark.usefixtures("one_adapter") From aca9607e2fb7b35d62c1179ce79a3d484a6a0437 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 29 Jan 2025 21:58:06 +0100 Subject: [PATCH 0266/3148] Bump backup store to version 1.3 (#136870) Co-authored-by: Paulus Schoutsen --- homeassistant/components/backup/store.py | 8 ++++-- .../backup/snapshots/test_store.ambr | 8 +++--- .../backup/snapshots/test_websocket.ambr | 28 +++++++++---------- 3 files changed, 23 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/backup/store.py b/homeassistant/components/backup/store.py index 3e2a88b8168..9b4af823c77 100644 --- a/homeassistant/components/backup/store.py +++ b/homeassistant/components/backup/store.py @@ -16,7 +16,7 @@ if TYPE_CHECKING: STORE_DELAY_SAVE = 30 STORAGE_KEY = DOMAIN STORAGE_VERSION = 1 -STORAGE_VERSION_MINOR = 2 +STORAGE_VERSION_MINOR = 3 class StoredBackupData(TypedDict): @@ -47,8 +47,10 @@ class _BackupStore(Store[StoredBackupData]): """Migrate to the new version.""" data = old_data if old_major_version == 1: - if old_minor_version < 2: - # Version 1.2 adds per agent settings, configurable backup time + if old_minor_version < 3: + # Version 1.2 bumped to 1.3 because 1.2 was changed several + # times during development. + # Version 1.3 adds per agent settings, configurable backup time # and custom days data["config"]["agents"] = {} data["config"]["schedule"]["time"] = None diff --git a/tests/components/backup/snapshots/test_store.ambr b/tests/components/backup/snapshots/test_store.ambr index 7069860638a..2fd81d6841a 100644 --- a/tests/components/backup/snapshots/test_store.ambr +++ b/tests/components/backup/snapshots/test_store.ambr @@ -39,7 +39,7 @@ }), }), 'key': 'backup', - 'minor_version': 2, + 'minor_version': 3, 'version': 1, }) # --- @@ -84,7 +84,7 @@ }), }), 'key': 'backup', - 'minor_version': 2, + 'minor_version': 3, 'version': 1, }) # --- @@ -131,7 +131,7 @@ }), }), 'key': 'backup', - 'minor_version': 2, + 'minor_version': 3, 'version': 1, }) # --- @@ -179,7 +179,7 @@ }), }), 'key': 'backup', - 'minor_version': 2, + 'minor_version': 3, 'version': 1, }) # --- diff --git a/tests/components/backup/snapshots/test_websocket.ambr b/tests/components/backup/snapshots/test_websocket.ambr index 7ea911496de..08c19906241 100644 --- a/tests/components/backup/snapshots/test_websocket.ambr +++ b/tests/components/backup/snapshots/test_websocket.ambr @@ -664,7 +664,7 @@ }), }), 'key': 'backup', - 'minor_version': 2, + 'minor_version': 3, 'version': 1, }) # --- @@ -778,7 +778,7 @@ }), }), 'key': 'backup', - 'minor_version': 2, + 'minor_version': 3, 'version': 1, }) # --- @@ -892,7 +892,7 @@ }), }), 'key': 'backup', - 'minor_version': 2, + 'minor_version': 3, 'version': 1, }) # --- @@ -1016,7 +1016,7 @@ }), }), 'key': 'backup', - 'minor_version': 2, + 'minor_version': 3, 'version': 1, }) # --- @@ -1183,7 +1183,7 @@ }), }), 'key': 'backup', - 'minor_version': 2, + 'minor_version': 3, 'version': 1, }) # --- @@ -1297,7 +1297,7 @@ }), }), 'key': 'backup', - 'minor_version': 2, + 'minor_version': 3, 'version': 1, }) # --- @@ -1413,7 +1413,7 @@ }), }), 'key': 'backup', - 'minor_version': 2, + 'minor_version': 3, 'version': 1, }) # --- @@ -1527,7 +1527,7 @@ }), }), 'key': 'backup', - 'minor_version': 2, + 'minor_version': 3, 'version': 1, }) # --- @@ -1645,7 +1645,7 @@ }), }), 'key': 'backup', - 'minor_version': 2, + 'minor_version': 3, 'version': 1, }) # --- @@ -1767,7 +1767,7 @@ }), }), 'key': 'backup', - 'minor_version': 2, + 'minor_version': 3, 'version': 1, }) # --- @@ -1881,7 +1881,7 @@ }), }), 'key': 'backup', - 'minor_version': 2, + 'minor_version': 3, 'version': 1, }) # --- @@ -1995,7 +1995,7 @@ }), }), 'key': 'backup', - 'minor_version': 2, + 'minor_version': 3, 'version': 1, }) # --- @@ -2109,7 +2109,7 @@ }), }), 'key': 'backup', - 'minor_version': 2, + 'minor_version': 3, 'version': 1, }) # --- @@ -2223,7 +2223,7 @@ }), }), 'key': 'backup', - 'minor_version': 2, + 'minor_version': 3, 'version': 1, }) # --- From 6247a847bf9ae912ab152397e20e47df3591b644 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 29 Jan 2025 19:55:02 +0100 Subject: [PATCH 0267/3148] Persist hassio backup restore status after core restart (#136857) * Persist hassio backup restore status after core restart * Remove useless condition --- homeassistant/components/backup/__init__.py | 2 + homeassistant/components/hassio/backup.py | 43 ++++++++++++ tests/components/conftest.py | 1 + tests/components/hassio/test_backup.py | 74 ++++++++++++++++++++- 4 files changed, 119 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/backup/__init__.py b/homeassistant/components/backup/__init__.py index d3903c2d679..3003f94c2ed 100644 --- a/homeassistant/components/backup/__init__.py +++ b/homeassistant/components/backup/__init__.py @@ -31,6 +31,7 @@ from .manager import ( ManagerBackup, NewBackup, RestoreBackupEvent, + RestoreBackupState, WrittenBackup, ) from .models import AddonInfo, AgentBackup, Folder @@ -54,6 +55,7 @@ __all__ = [ "ManagerBackup", "NewBackup", "RestoreBackupEvent", + "RestoreBackupState", "WrittenBackup", "async_get_manager", ] diff --git a/homeassistant/components/hassio/backup.py b/homeassistant/components/hassio/backup.py index afeee1f4469..6b63ab92d5c 100644 --- a/homeassistant/components/hassio/backup.py +++ b/homeassistant/components/hassio/backup.py @@ -5,8 +5,10 @@ from __future__ import annotations import asyncio from collections.abc import AsyncIterator, Callable, Coroutine, Mapping import logging +import os from pathlib import Path from typing import Any, cast +from uuid import UUID from aiohasupervisor import SupervisorClient from aiohasupervisor.exceptions import ( @@ -33,6 +35,7 @@ from homeassistant.components.backup import ( IncorrectPasswordError, NewBackup, RestoreBackupEvent, + RestoreBackupState, WrittenBackup, async_get_manager as async_get_backup_manager, ) @@ -47,6 +50,7 @@ from .handler import get_supervisor_client LOCATION_CLOUD_BACKUP = ".cloud_backup" LOCATION_LOCAL = ".local" MOUNT_JOBS = ("mount_manager_create_mount", "mount_manager_remove_mount") +RESTORE_JOB_ID_ENV = "SUPERVISOR_RESTORE_JOB_ID" _LOGGER = logging.getLogger(__name__) @@ -518,6 +522,37 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): on_progress: Callable[[RestoreBackupEvent | IdleEvent], None], ) -> None: """Check restore status after core restart.""" + if not (restore_job_id := os.environ.get(RESTORE_JOB_ID_ENV)): + _LOGGER.debug("No restore job ID found in environment") + return + + _LOGGER.debug("Found restore job ID %s in environment", restore_job_id) + + @callback + def on_job_progress(data: Mapping[str, Any]) -> None: + """Handle backup restore progress.""" + if data.get("done") is not True: + on_progress( + RestoreBackupEvent( + reason="", stage=None, state=RestoreBackupState.IN_PROGRESS + ) + ) + return + + on_progress( + RestoreBackupEvent( + reason="", stage=None, state=RestoreBackupState.COMPLETED + ) + ) + on_progress(IdleEvent()) + unsub() + + unsub = self._async_listen_job_events(restore_job_id, on_job_progress) + try: + await self._get_job_state(restore_job_id, on_job_progress) + except SupervisorError as err: + _LOGGER.debug("Could not get restore job %s: %s", restore_job_id, err) + unsub() @callback def _async_listen_job_events( @@ -546,6 +581,14 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): ) return unsub + async def _get_job_state( + self, job_id: str, on_event: Callable[[Mapping[str, Any]], None] + ) -> None: + """Poll a job for its state.""" + job = await self._client.jobs.get_job(UUID(job_id)) + _LOGGER.debug("Job state: %s", job) + on_event(job.to_dict()) + async def _default_agent(client: SupervisorClient) -> str: """Return the default agent for creating a backup.""" diff --git a/tests/components/conftest.py b/tests/components/conftest.py index 0cd33e28d35..ebf390e30d7 100644 --- a/tests/components/conftest.py +++ b/tests/components/conftest.py @@ -535,6 +535,7 @@ def supervisor_client() -> Generator[AsyncMock]: supervisor_client.discovery = AsyncMock() supervisor_client.homeassistant = AsyncMock() supervisor_client.host = AsyncMock() + supervisor_client.jobs = AsyncMock() supervisor_client.mounts.info.return_value = mounts_info_mock supervisor_client.os = AsyncMock() supervisor_client.resolution = AsyncMock() diff --git a/tests/components/hassio/test_backup.py b/tests/components/hassio/test_backup.py index 7c2bf8921ef..49360783517 100644 --- a/tests/components/hassio/test_backup.py +++ b/tests/components/hassio/test_backup.py @@ -13,6 +13,7 @@ from io import StringIO import os from typing import Any from unittest.mock import ANY, AsyncMock, Mock, patch +from uuid import UUID from aiohasupervisor.exceptions import ( SupervisorBadRequestError, @@ -21,6 +22,7 @@ from aiohasupervisor.exceptions import ( ) from aiohasupervisor.models import ( backups as supervisor_backups, + jobs as supervisor_jobs, mounts as supervisor_mounts, ) from aiohasupervisor.models.mounts import MountsInfo @@ -35,7 +37,11 @@ from homeassistant.components.backup import ( Folder, ) from homeassistant.components.hassio import DOMAIN -from homeassistant.components.hassio.backup import LOCATION_CLOUD_BACKUP, LOCATION_LOCAL +from homeassistant.components.hassio.backup import ( + LOCATION_CLOUD_BACKUP, + LOCATION_LOCAL, + RESTORE_JOB_ID_ENV, +) from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -1802,3 +1808,69 @@ async def test_reader_writer_restore_wrong_parameters( "code": "home_assistant_error", "message": expected_error, } + + +@pytest.mark.usefixtures("hassio_client") +async def test_restore_progress_after_restart( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + supervisor_client: AsyncMock, +) -> None: + """Test restore backup progress after restart.""" + + supervisor_client.jobs.get_job.return_value = supervisor_jobs.Job( + name="backup_manager_partial_backup", + reference="1ef41507", + uuid=UUID("d17bd02be1f0437fa7264b16d38f700e"), + progress=0.0, + stage="copy_additional_locations", + done=True, + errors=[], + child_jobs=[], + ) + + with patch.dict( + os.environ, + MOCK_ENVIRON | {RESTORE_JOB_ID_ENV: "d17bd02be1f0437fa7264b16d38f700e"}, + ): + assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}}) + + client = await hass_ws_client(hass) + + await client.send_json_auto_id({"type": "backup/info"}) + response = await client.receive_json() + + assert response["success"] + assert response["result"]["last_non_idle_event"] == { + "manager_state": "restore_backup", + "reason": "", + "stage": None, + "state": "completed", + } + assert response["result"]["state"] == "idle" + + +@pytest.mark.usefixtures("hassio_client") +async def test_restore_progress_after_restart_unknown_job( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + supervisor_client: AsyncMock, +) -> None: + """Test restore backup progress after restart.""" + + supervisor_client.jobs.get_job.side_effect = SupervisorError + + with patch.dict( + os.environ, + MOCK_ENVIRON | {RESTORE_JOB_ID_ENV: "d17bd02be1f0437fa7264b16d38f700e"}, + ): + assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}}) + + client = await hass_ws_client(hass) + + await client.send_json_auto_id({"type": "backup/info"}) + response = await client.receive_json() + + assert response["success"] + assert response["result"]["last_non_idle_event"] is None + assert response["result"]["state"] == "idle" From d338b0a2ffa4374c89d9feb8e6d6a9b5e7e2ef09 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Wed, 29 Jan 2025 12:52:32 -0600 Subject: [PATCH 0268/3148] Cancel call if user does not pick up (#136858) --- .../components/voip/assist_satellite.py | 64 ++++++++++++++----- homeassistant/components/voip/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/voip/test_voip.py | 40 ++++++++++++ 5 files changed, 90 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/voip/assist_satellite.py b/homeassistant/components/voip/assist_satellite.py index 738c3a1e235..6cacdd79af4 100644 --- a/homeassistant/components/voip/assist_satellite.py +++ b/homeassistant/components/voip/assist_satellite.py @@ -14,7 +14,7 @@ from typing import TYPE_CHECKING, Any, Final import wave from voip_utils import SIP_PORT, RtpDatagramProtocol -from voip_utils.sip import SipEndpoint, get_sip_endpoint +from voip_utils.sip import SipDatagramProtocol, SipEndpoint, get_sip_endpoint from homeassistant.components import tts from homeassistant.components.assist_pipeline import PipelineEvent, PipelineEventType @@ -43,6 +43,7 @@ _PIPELINE_TIMEOUT_SEC: Final = 30 _ANNOUNCEMENT_BEFORE_DELAY: Final = 0.5 _ANNOUNCEMENT_AFTER_DELAY: Final = 1.0 _ANNOUNCEMENT_HANGUP_SEC: Final = 0.5 +_ANNOUNCEMENT_RING_TIMEOUT: Final = 30 class Tones(IntFlag): @@ -116,7 +117,8 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol self._processing_tone_done = asyncio.Event() self._announcement: AssistSatelliteAnnouncement | None = None - self._announcement_done = asyncio.Event() + self._announcement_future: asyncio.Future[Any] = asyncio.Future() + self._announcment_start_time: float = 0.0 self._check_announcement_ended_task: asyncio.Task | None = None self._last_chunk_time: float | None = None self._rtp_port: int | None = None @@ -170,7 +172,7 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol Plays announcement in a loop, blocking until the caller hangs up. """ - self._announcement_done.clear() + self._announcement_future = asyncio.Future() if self._rtp_port is None: # Choose random port for RTP @@ -194,16 +196,34 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol host=self.voip_device.voip_id, port=SIP_PORT ) + # Reset state so we can time out if needed + self._last_chunk_time = None + self._announcment_start_time = time.monotonic() self._announcement = announcement # Make the call - self.hass.data[DOMAIN].protocol.outgoing_call( + sip_protocol: SipDatagramProtocol = self.hass.data[DOMAIN].protocol + call_info = sip_protocol.outgoing_call( source=source_endpoint, destination=destination_endpoint, rtp_port=self._rtp_port, ) - await self._announcement_done.wait() + # Check if caller hung up or didn't pick up + self._check_announcement_ended_task = ( + self.config_entry.async_create_background_task( + self.hass, + self._check_announcement_ended(), + "voip_announcement_ended", + ) + ) + + try: + await self._announcement_future + except TimeoutError: + # Stop ringing + sip_protocol.cancel_call(call_info) + raise async def _check_announcement_ended(self) -> None: """Continuously checks if an audio chunk was received within a time limit. @@ -211,12 +231,32 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol If not, the caller is presumed to have hung up and the announcement is ended. """ while self._announcement is not None: + current_time = time.monotonic() + _LOGGER.debug( + "%s %s %s", + self._last_chunk_time, + current_time, + self._announcment_start_time, + ) + if (self._last_chunk_time is None) and ( + (current_time - self._announcment_start_time) + > _ANNOUNCEMENT_RING_TIMEOUT + ): + # Ring timeout + self._announcement = None + self._check_announcement_ended_task = None + self._announcement_future.set_exception( + TimeoutError("User did not pick up in time") + ) + _LOGGER.debug("Timed out waiting for the user to pick up the phone") + break + if (self._last_chunk_time is not None) and ( - (time.monotonic() - self._last_chunk_time) > _ANNOUNCEMENT_HANGUP_SEC + (current_time - self._last_chunk_time) > _ANNOUNCEMENT_HANGUP_SEC ): # Caller hung up self._announcement = None - self._announcement_done.set() + self._announcement_future.set_result(None) self._check_announcement_ended_task = None _LOGGER.debug("Announcement ended") break @@ -248,16 +288,6 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol self._audio_queue.put_nowait(audio_bytes) elif self._run_pipeline_task is None: # Announcement only - if self._check_announcement_ended_task is None: - # Check if caller hung up - self._check_announcement_ended_task = ( - self.config_entry.async_create_background_task( - self.hass, - self._check_announcement_ended(), - "voip_announcement_ended", - ) - ) - # Play announcement (will repeat) self._run_pipeline_task = self.config_entry.async_create_background_task( self.hass, diff --git a/homeassistant/components/voip/manifest.json b/homeassistant/components/voip/manifest.json index b279665a03a..e3b2861dbe5 100644 --- a/homeassistant/components/voip/manifest.json +++ b/homeassistant/components/voip/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/voip", "iot_class": "local_push", "quality_scale": "internal", - "requirements": ["voip-utils==0.3.0"] + "requirements": ["voip-utils==0.3.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index c87835f9153..9e6da1045a4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2991,7 +2991,7 @@ venstarcolortouch==0.19 vilfo-api-client==0.5.0 # homeassistant.components.voip -voip-utils==0.3.0 +voip-utils==0.3.1 # homeassistant.components.volkszaehler volkszaehler==0.4.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 968eec09d28..76ae46099c2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2407,7 +2407,7 @@ venstarcolortouch==0.19 vilfo-api-client==0.5.0 # homeassistant.components.voip -voip-utils==0.3.0 +voip-utils==0.3.1 # homeassistant.components.volvooncall volvooncall==0.10.3 diff --git a/tests/components/voip/test_voip.py b/tests/components/voip/test_voip.py index ac7c295c934..306857a1a44 100644 --- a/tests/components/voip/test_voip.py +++ b/tests/components/voip/test_voip.py @@ -941,3 +941,43 @@ async def test_voip_id_is_ip_address( await announce_task mock_send_tts.assert_called_once_with(_MEDIA_ID, wait_for_tone=False) + + +@pytest.mark.usefixtures("socket_enabled") +async def test_announce_timeout( + hass: HomeAssistant, + voip_devices: VoIPDevices, + voip_device: VoIPDevice, +) -> None: + """Test announcement when user does not pick up the phone in time.""" + assert await async_setup_component(hass, "voip", {}) + + satellite = async_get_satellite_entity(hass, voip.DOMAIN, voip_device.voip_id) + assert isinstance(satellite, VoipAssistSatellite) + assert ( + satellite.supported_features + & assist_satellite.AssistSatelliteEntityFeature.ANNOUNCE + ) + + announcement = assist_satellite.AssistSatelliteAnnouncement( + message="test announcement", + media_id=_MEDIA_ID, + original_media_id=_MEDIA_ID, + media_id_source="tts", + ) + + # Protocol has already been mocked, but some methods are not async + mock_protocol: AsyncMock = hass.data[DOMAIN].protocol + mock_protocol.outgoing_call = Mock() + mock_protocol.cancel_call = Mock() + + # Very short timeout which will trigger because we don't send any audio in + with ( + patch( + "homeassistant.components.voip.assist_satellite._ANNOUNCEMENT_RING_TIMEOUT", + 0.01, + ), + ): + satellite.transport = Mock() + with pytest.raises(TimeoutError): + await satellite.async_announce(announcement) From 0f97747d276093141124988d353799230d9d1087 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Wed, 29 Jan 2025 18:51:09 +0000 Subject: [PATCH 0269/3148] Handle locked account error in Whirlpool (#136861) --- homeassistant/components/whirlpool/__init__.py | 6 +++++- homeassistant/components/whirlpool/config_flow.py | 4 +++- homeassistant/components/whirlpool/strings.json | 9 +++++++++ tests/components/whirlpool/test_config_flow.py | 3 +++ tests/components/whirlpool/test_init.py | 13 +++++++++++++ 5 files changed, 33 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/whirlpool/__init__.py b/homeassistant/components/whirlpool/__init__.py index 64adcda4742..6231324bb0d 100644 --- a/homeassistant/components/whirlpool/__init__.py +++ b/homeassistant/components/whirlpool/__init__.py @@ -5,7 +5,7 @@ import logging from aiohttp import ClientError from whirlpool.appliancesmanager import AppliancesManager -from whirlpool.auth import Auth +from whirlpool.auth import AccountLockedError as WhirlpoolAccountLocked, Auth from whirlpool.backendselector import BackendSelector from homeassistant.config_entries import ConfigEntry @@ -39,6 +39,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: WhirlpoolConfigEntry) -> await auth.do_auth(store=False) except (ClientError, TimeoutError) as ex: raise ConfigEntryNotReady("Cannot connect") from ex + except WhirlpoolAccountLocked as ex: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, translation_key="account_locked" + ) from ex if not auth.is_access_token_valid(): _LOGGER.error("Authentication failed") diff --git a/homeassistant/components/whirlpool/config_flow.py b/homeassistant/components/whirlpool/config_flow.py index 44445dee03f..19715643e3a 100644 --- a/homeassistant/components/whirlpool/config_flow.py +++ b/homeassistant/components/whirlpool/config_flow.py @@ -9,7 +9,7 @@ from typing import Any from aiohttp import ClientError import voluptuous as vol from whirlpool.appliancesmanager import AppliancesManager -from whirlpool.auth import Auth +from whirlpool.auth import AccountLockedError as WhirlpoolAccountLocked, Auth from whirlpool.backendselector import BackendSelector from homeassistant.config_entries import ConfigFlow, ConfigFlowResult @@ -55,6 +55,8 @@ async def authenticate( try: await auth.do_auth() + except WhirlpoolAccountLocked: + return "account_locked" except (TimeoutError, ClientError): return "cannot_connect" except Exception: diff --git a/homeassistant/components/whirlpool/strings.json b/homeassistant/components/whirlpool/strings.json index 09257652ece..95df3fb9098 100644 --- a/homeassistant/components/whirlpool/strings.json +++ b/homeassistant/components/whirlpool/strings.json @@ -1,4 +1,7 @@ { + "common": { + "account_locked_error": "The account is locked. Please follow the instructions in the manufacturer's app to unlock it" + }, "config": { "step": { "user": { @@ -31,6 +34,7 @@ "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" }, "error": { + "account_locked": "[%key:component::whirlpool::common::account_locked_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%]", @@ -85,5 +89,10 @@ "name": "End time" } } + }, + "exceptions": { + "account_locked": { + "message": "[%key:component::whirlpool::common::account_locked_error%]" + } } } diff --git a/tests/components/whirlpool/test_config_flow.py b/tests/components/whirlpool/test_config_flow.py index a82c2a22695..e01fbc07b51 100644 --- a/tests/components/whirlpool/test_config_flow.py +++ b/tests/components/whirlpool/test_config_flow.py @@ -4,6 +4,7 @@ from unittest.mock import MagicMock, patch import aiohttp import pytest +from whirlpool.auth import AccountLockedError from homeassistant import config_entries from homeassistant.components.whirlpool.const import CONF_BRAND, DOMAIN @@ -82,6 +83,7 @@ async def test_form_invalid_auth( @pytest.mark.parametrize( ("exception", "expected_error"), [ + (AccountLockedError, "account_locked"), (aiohttp.ClientConnectionError, "cannot_connect"), (TimeoutError, "cannot_connect"), (Exception, "unknown"), @@ -249,6 +251,7 @@ async def test_reauth_flow_invalid_auth( @pytest.mark.parametrize( ("exception", "expected_error"), [ + (AccountLockedError, "account_locked"), (aiohttp.ClientConnectionError, "cannot_connect"), (TimeoutError, "cannot_connect"), (Exception, "unknown"), diff --git a/tests/components/whirlpool/test_init.py b/tests/components/whirlpool/test_init.py index f9d28e78a06..8f082ff6294 100644 --- a/tests/components/whirlpool/test_init.py +++ b/tests/components/whirlpool/test_init.py @@ -3,6 +3,7 @@ from unittest.mock import AsyncMock, MagicMock import aiohttp +from whirlpool.auth import AccountLockedError from whirlpool.backendselector import Brand, Region from homeassistant.components.whirlpool.const import DOMAIN @@ -104,6 +105,18 @@ async def test_setup_auth_failed( assert entry.state is ConfigEntryState.SETUP_ERROR +async def test_setup_auth_account_locked( + hass: HomeAssistant, + mock_auth_api: MagicMock, + mock_aircon_api_instances: MagicMock, +) -> None: + """Test setup with failed auth due to account being locked.""" + mock_auth_api.return_value.do_auth.side_effect = AccountLockedError + entry = await init_integration(hass) + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert entry.state is ConfigEntryState.SETUP_ERROR + + async def test_setup_fetch_appliances_failed( hass: HomeAssistant, mock_appliances_manager_api: MagicMock, From 9c0fa327a6a8708ab50b09a8d7137ead6271ea77 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Jan 2025 09:09:00 -1000 Subject: [PATCH 0270/3148] Fix incorrect Bluetooth source address when restoring data from D-Bus (#136862) --- homeassistant/components/bluetooth/util.py | 10 +++++++++- tests/components/bluetooth/test_manager.py | 4 +++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/bluetooth/util.py b/homeassistant/components/bluetooth/util.py index ca2e0180c00..738a61b6f33 100644 --- a/homeassistant/components/bluetooth/util.py +++ b/homeassistant/components/bluetooth/util.py @@ -39,6 +39,10 @@ def async_load_history_from_system( now_monotonic = monotonic_time_coarse() connectable_loaded_history: dict[str, BluetoothServiceInfoBleak] = {} all_loaded_history: dict[str, BluetoothServiceInfoBleak] = {} + adapter_to_source_address = { + adapter: details[ADAPTER_ADDRESS] + for adapter, details in adapters.adapters.items() + } # Restore local adapters for address, history in adapters.history.items(): @@ -50,7 +54,11 @@ def async_load_history_from_system( BluetoothServiceInfoBleak.from_device_and_advertisement_data( history.device, history.advertisement_data, - history.source, + # history.source is really the adapter name + # for historical compatibility since BlueZ + # does not know the MAC address of the adapter + # so we need to convert it to the source address (MAC) + adapter_to_source_address.get(history.source, history.source), now_monotonic, True, ) diff --git a/tests/components/bluetooth/test_manager.py b/tests/components/bluetooth/test_manager.py index c7fc80ba068..be23a536f49 100644 --- a/tests/components/bluetooth/test_manager.py +++ b/tests/components/bluetooth/test_manager.py @@ -426,7 +426,7 @@ async def test_restore_history_from_dbus( address: AdvertisementHistory( ble_device, generate_advertisement_data(local_name="name"), - HCI0_SOURCE_ADDRESS, + "hci0", ) } @@ -438,6 +438,8 @@ async def test_restore_history_from_dbus( await hass.async_block_till_done() assert bluetooth.async_ble_device_from_address(hass, address) is ble_device + info = bluetooth.async_last_service_info(hass, address, False) + assert info.source == "00:00:00:00:00:01" @pytest.mark.usefixtures("one_adapter") From 49b90fc140e17a88477523db44ca4624c81b6d8f Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 29 Jan 2025 21:58:06 +0100 Subject: [PATCH 0271/3148] Bump backup store to version 1.3 (#136870) Co-authored-by: Paulus Schoutsen --- homeassistant/components/backup/store.py | 8 ++++-- .../backup/snapshots/test_store.ambr | 8 +++--- .../backup/snapshots/test_websocket.ambr | 28 +++++++++---------- 3 files changed, 23 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/backup/store.py b/homeassistant/components/backup/store.py index 3e2a88b8168..9b4af823c77 100644 --- a/homeassistant/components/backup/store.py +++ b/homeassistant/components/backup/store.py @@ -16,7 +16,7 @@ if TYPE_CHECKING: STORE_DELAY_SAVE = 30 STORAGE_KEY = DOMAIN STORAGE_VERSION = 1 -STORAGE_VERSION_MINOR = 2 +STORAGE_VERSION_MINOR = 3 class StoredBackupData(TypedDict): @@ -47,8 +47,10 @@ class _BackupStore(Store[StoredBackupData]): """Migrate to the new version.""" data = old_data if old_major_version == 1: - if old_minor_version < 2: - # Version 1.2 adds per agent settings, configurable backup time + if old_minor_version < 3: + # Version 1.2 bumped to 1.3 because 1.2 was changed several + # times during development. + # Version 1.3 adds per agent settings, configurable backup time # and custom days data["config"]["agents"] = {} data["config"]["schedule"]["time"] = None diff --git a/tests/components/backup/snapshots/test_store.ambr b/tests/components/backup/snapshots/test_store.ambr index 7069860638a..2fd81d6841a 100644 --- a/tests/components/backup/snapshots/test_store.ambr +++ b/tests/components/backup/snapshots/test_store.ambr @@ -39,7 +39,7 @@ }), }), 'key': 'backup', - 'minor_version': 2, + 'minor_version': 3, 'version': 1, }) # --- @@ -84,7 +84,7 @@ }), }), 'key': 'backup', - 'minor_version': 2, + 'minor_version': 3, 'version': 1, }) # --- @@ -131,7 +131,7 @@ }), }), 'key': 'backup', - 'minor_version': 2, + 'minor_version': 3, 'version': 1, }) # --- @@ -179,7 +179,7 @@ }), }), 'key': 'backup', - 'minor_version': 2, + 'minor_version': 3, 'version': 1, }) # --- diff --git a/tests/components/backup/snapshots/test_websocket.ambr b/tests/components/backup/snapshots/test_websocket.ambr index 7ea911496de..08c19906241 100644 --- a/tests/components/backup/snapshots/test_websocket.ambr +++ b/tests/components/backup/snapshots/test_websocket.ambr @@ -664,7 +664,7 @@ }), }), 'key': 'backup', - 'minor_version': 2, + 'minor_version': 3, 'version': 1, }) # --- @@ -778,7 +778,7 @@ }), }), 'key': 'backup', - 'minor_version': 2, + 'minor_version': 3, 'version': 1, }) # --- @@ -892,7 +892,7 @@ }), }), 'key': 'backup', - 'minor_version': 2, + 'minor_version': 3, 'version': 1, }) # --- @@ -1016,7 +1016,7 @@ }), }), 'key': 'backup', - 'minor_version': 2, + 'minor_version': 3, 'version': 1, }) # --- @@ -1183,7 +1183,7 @@ }), }), 'key': 'backup', - 'minor_version': 2, + 'minor_version': 3, 'version': 1, }) # --- @@ -1297,7 +1297,7 @@ }), }), 'key': 'backup', - 'minor_version': 2, + 'minor_version': 3, 'version': 1, }) # --- @@ -1413,7 +1413,7 @@ }), }), 'key': 'backup', - 'minor_version': 2, + 'minor_version': 3, 'version': 1, }) # --- @@ -1527,7 +1527,7 @@ }), }), 'key': 'backup', - 'minor_version': 2, + 'minor_version': 3, 'version': 1, }) # --- @@ -1645,7 +1645,7 @@ }), }), 'key': 'backup', - 'minor_version': 2, + 'minor_version': 3, 'version': 1, }) # --- @@ -1767,7 +1767,7 @@ }), }), 'key': 'backup', - 'minor_version': 2, + 'minor_version': 3, 'version': 1, }) # --- @@ -1881,7 +1881,7 @@ }), }), 'key': 'backup', - 'minor_version': 2, + 'minor_version': 3, 'version': 1, }) # --- @@ -1995,7 +1995,7 @@ }), }), 'key': 'backup', - 'minor_version': 2, + 'minor_version': 3, 'version': 1, }) # --- @@ -2109,7 +2109,7 @@ }), }), 'key': 'backup', - 'minor_version': 2, + 'minor_version': 3, 'version': 1, }) # --- @@ -2223,7 +2223,7 @@ }), }), 'key': 'backup', - 'minor_version': 2, + 'minor_version': 3, 'version': 1, }) # --- From 9c8d31a3d5c33af3e4c6847612471501136ad691 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 29 Jan 2025 21:18:11 +0000 Subject: [PATCH 0272/3148] Bump version to 2025.2.0b1 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 3fc165526ee..77b223fcbcf 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -25,7 +25,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2025 MINOR_VERSION: Final = 2 -PATCH_VERSION: Final = "0b0" +PATCH_VERSION: Final = "0b1" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 13, 0) diff --git a/pyproject.toml b/pyproject.toml index 55d7e7d2231..a592b8a194d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.2.0b0" +version = "2025.2.0b1" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 40662896621a377a09796ba73385d8d9b033b364 Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Wed, 29 Jan 2025 23:32:16 +0100 Subject: [PATCH 0273/3148] Update quality scale in Onkyo (#136710) --- homeassistant/components/onkyo/quality_scale.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/onkyo/quality_scale.yaml b/homeassistant/components/onkyo/quality_scale.yaml index cdcf88e72d7..4b9fbe7c019 100644 --- a/homeassistant/components/onkyo/quality_scale.yaml +++ b/homeassistant/components/onkyo/quality_scale.yaml @@ -16,7 +16,7 @@ rules: docs-actions: done docs-high-level-description: done docs-installation-instructions: done - docs-removal-instructions: todo + docs-removal-instructions: done entity-event-setup: status: done comment: | @@ -45,8 +45,8 @@ rules: # Gold devices: todo diagnostics: todo - discovery: todo - discovery-update-info: todo + discovery: done + discovery-update-info: done docs-data-update: todo docs-examples: todo docs-known-limitations: todo From 4e3e1e91b7adac7142870ed401c3eeee06c1ad58 Mon Sep 17 00:00:00 2001 From: TimL Date: Thu, 30 Jan 2025 12:01:39 +1100 Subject: [PATCH 0274/3148] Fix loading of SMLIGHT integration when no internet is available (#136497) * Don't fail to load integration if internet unavailable * Add test case for no internet * Also test we recover after internet returns --- .../components/smlight/coordinator.py | 16 ++++--- tests/components/smlight/test_init.py | 44 ++++++++++++++++++- tests/components/smlight/test_update.py | 2 +- 3 files changed, 53 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/smlight/coordinator.py b/homeassistant/components/smlight/coordinator.py index 5b38ec4a89e..6be36439e9f 100644 --- a/homeassistant/components/smlight/coordinator.py +++ b/homeassistant/components/smlight/coordinator.py @@ -144,11 +144,15 @@ class SmFirmwareUpdateCoordinator(SmBaseDataUpdateCoordinator[SmFwData]): async def _internal_update_data(self) -> SmFwData: """Fetch data from the SMLIGHT device.""" info = await self.client.get_info() + esp_firmware = None + zb_firmware = None - return SmFwData( - info=info, - esp_firmware=await self.client.get_firmware_version(info.fw_channel), - zb_firmware=await self.client.get_firmware_version( + try: + esp_firmware = await self.client.get_firmware_version(info.fw_channel) + zb_firmware = await self.client.get_firmware_version( info.fw_channel, device=info.model, mode="zigbee" - ), - ) + ) + except SmlightConnectionError as err: + self.async_set_update_error(err) + + return SmFwData(info=info, esp_firmware=esp_firmware, zb_firmware=zb_firmware) diff --git a/tests/components/smlight/test_init.py b/tests/components/smlight/test_init.py index afc53932fb0..d0c5e494ae8 100644 --- a/tests/components/smlight/test_init.py +++ b/tests/components/smlight/test_init.py @@ -8,9 +8,14 @@ from pysmlight.exceptions import SmlightAuthError, SmlightConnectionError, Smlig import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components.smlight.const import DOMAIN, SCAN_INTERVAL +from homeassistant.components.smlight.const import ( + DOMAIN, + SCAN_FIRMWARE_INTERVAL, + SCAN_INTERVAL, +) +from homeassistant.components.update import ATTR_INSTALLED_VERSION from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.const import STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr from homeassistant.helpers.issue_registry import IssueRegistry @@ -73,6 +78,41 @@ async def test_async_setup_missing_credentials( assert progress[0]["context"]["unique_id"] == "aa:bb:cc:dd:ee:ff" +async def test_async_setup_no_internet( + hass: HomeAssistant, + mock_config_entry_host: MockConfigEntry, + mock_smlight_client: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test we still load integration when no internet is available.""" + mock_smlight_client.get_firmware_version.side_effect = SmlightConnectionError + + await setup_integration(hass, mock_config_entry_host) + + entity = hass.states.get("update.mock_title_core_firmware") + assert entity is not None + assert entity.state == STATE_UNKNOWN + + freezer.tick(SCAN_FIRMWARE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + entity = hass.states.get("update.mock_title_core_firmware") + assert entity is not None + assert entity.state == STATE_UNKNOWN + + mock_smlight_client.get_firmware_version.side_effect = None + + freezer.tick(SCAN_FIRMWARE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + entity = hass.states.get("update.mock_title_core_firmware") + assert entity is not None + assert entity.state == STATE_ON + assert entity.attributes[ATTR_INSTALLED_VERSION] == "v2.3.6" + + @pytest.mark.parametrize("error", [SmlightConnectionError, SmlightAuthError]) async def test_update_failed( hass: HomeAssistant, diff --git a/tests/components/smlight/test_update.py b/tests/components/smlight/test_update.py index 0bb2e34d7ca..4fca7369116 100644 --- a/tests/components/smlight/test_update.py +++ b/tests/components/smlight/test_update.py @@ -81,7 +81,7 @@ async def test_update_setup( mock_config_entry: MockConfigEntry, snapshot: SnapshotAssertion, ) -> None: - """Test setup of SMLIGHT switches.""" + """Test setup of SMLIGHT update entities.""" entry = await setup_integration(hass, mock_config_entry) await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) From b637129208e45620a1e6d264b92844f16787f49b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Thu, 30 Jan 2025 02:42:41 +0100 Subject: [PATCH 0275/3148] Migrate from homeconnect dependency to aiohomeconnect (#136116) * Migrate from homeconnect dependency to aiohomeconnect * Reload the integration if there is an API error on event stream * fix typos at coordinator tests * Setup config entry at coordinator tests * fix ruff * Bump aiohomeconnect to version 0.11.4 * Fix set program options * Use context based updates at coordinator * Improved how `context_callbacks` cache is invalidated * fix * fixes and improvements at coordinator Co-authored-by: Martin Hjelmare * Remove stale Entity inheritance * Small improvement for light subscriptions * Remove non-needed function It had its purpose before some refactoring before the firs commit, no is no needed as is only used at HomeConnectEntity constructor * Static methods and variables at conftest * Refresh the data after an event stream interruption * Cleaned debug logs * Fetch programs at coordinator * Improvements Co-authored-by: Martin Hjelmare * Simplify obtaining power settings from coordinator data Co-authored-by: Martin Hjelmare * Remove unnecessary statement * use `is UNDEFINED` instead of `isinstance` * Request power setting only when it is strictly necessary * Bump aiohomeconnect to 0.12.1 * use raw keys for diagnostics * Use keyword arguments where needed * Remove unnecessary statements Co-authored-by: Martin Hjelmare --------- Co-authored-by: Martin Hjelmare --- .../components/home_connect/__init__.py | 325 +++++----- homeassistant/components/home_connect/api.py | 79 +-- .../home_connect/application_credentials.py | 4 +- .../components/home_connect/binary_sensor.py | 102 ++- .../components/home_connect/const.py | 129 +--- .../components/home_connect/coordinator.py | 258 ++++++++ .../components/home_connect/diagnostics.py | 49 +- .../components/home_connect/entity.py | 59 +- .../components/home_connect/light.py | 241 +++---- .../components/home_connect/manifest.json | 2 +- .../components/home_connect/number.py | 101 +-- .../components/home_connect/select.py | 278 ++------- .../components/home_connect/sensor.py | 224 ++++--- .../components/home_connect/strings.json | 39 +- .../components/home_connect/switch.py | 290 ++++----- homeassistant/components/home_connect/time.py | 61 +- .../components/home_connect/utils.py | 29 + requirements_all.txt | 6 +- requirements_test_all.txt | 6 +- tests/components/home_connect/conftest.py | 380 ++++++----- .../home_connect/fixtures/settings.json | 24 +- .../snapshots/test_diagnostics.ambr | 588 +++++++----------- .../home_connect/test_binary_sensor.py | 145 +++-- .../home_connect/test_config_flow.py | 7 +- .../home_connect/test_coordinator.py | 367 +++++++++++ .../home_connect/test_diagnostics.py | 90 +-- tests/components/home_connect/test_init.py | 220 ++++--- tests/components/home_connect/test_light.py | 400 +++++++----- tests/components/home_connect/test_number.py | 154 +++-- tests/components/home_connect/test_select.py | 202 +++--- tests/components/home_connect/test_sensor.py | 249 +++++--- tests/components/home_connect/test_switch.py | 548 +++++++++------- tests/components/home_connect/test_time.py | 102 ++- 33 files changed, 3117 insertions(+), 2641 deletions(-) create mode 100644 homeassistant/components/home_connect/coordinator.py create mode 100644 homeassistant/components/home_connect/utils.py create mode 100644 tests/components/home_connect/test_coordinator.py diff --git a/homeassistant/components/home_connect/__init__.py b/homeassistant/components/home_connect/__init__.py index d7c042c2a91..a019ae0f250 100644 --- a/homeassistant/components/home_connect/__init__.py +++ b/homeassistant/components/home_connect/__init__.py @@ -2,17 +2,16 @@ from __future__ import annotations -from datetime import timedelta import logging -import re from typing import Any, cast -from requests import HTTPError +from aiohomeconnect.client import Client as HomeConnectClient +from aiohomeconnect.model import CommandKey, Option, OptionKey +from aiohomeconnect.model.error import HomeConnectError import voluptuous as vol -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_DEVICE_ID, Platform -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import ( config_entry_oauth2_flow, @@ -21,16 +20,13 @@ from homeassistant.helpers import ( ) from homeassistant.helpers.entity_registry import RegistryEntry, async_migrate_entries from homeassistant.helpers.typing import ConfigType -from homeassistant.util import Throttle -from . import api +from .api import AsyncConfigEntryAuth from .const import ( ATTR_KEY, ATTR_PROGRAM, ATTR_UNIT, ATTR_VALUE, - BSH_PAUSE, - BSH_RESUME, DOMAIN, OLD_NEW_UNIQUE_ID_SUFFIX_MAP, SERVICE_OPTION_ACTIVE, @@ -44,15 +40,11 @@ from .const import ( SVE_TRANSLATION_PLACEHOLDER_PROGRAM, SVE_TRANSLATION_PLACEHOLDER_VALUE, ) - -type HomeConnectConfigEntry = ConfigEntry[api.ConfigEntryAuth] +from .coordinator import HomeConnectConfigEntry, HomeConnectCoordinator +from .utils import get_dict_from_home_connect_error _LOGGER = logging.getLogger(__name__) -RE_CAMEL_CASE = re.compile(r"(? api.HomeConnectAppliance: - """Return a Home Connect appliance instance given a device id or a device entry.""" - if device_id is not None and device_entry is None: - device_registry = dr.async_get(hass) - device_entry = device_registry.async_get(device_id) - assert device_entry, "Either a device id or a device entry must be provided" +async def _get_client_and_ha_id( + hass: HomeAssistant, device_id: str +) -> tuple[HomeConnectClient, str]: + device_registry = dr.async_get(hass) + device_entry = device_registry.async_get(device_id) + if device_entry is None: + raise ServiceValidationError("Device entry not found for device id") + entry: HomeConnectConfigEntry | None = None + for entry_id in device_entry.config_entries: + _entry = hass.config_entries.async_get_entry(entry_id) + assert _entry + if _entry.domain == DOMAIN: + entry = cast(HomeConnectConfigEntry, _entry) + break + if entry is None: + raise ServiceValidationError( + "Home Connect config entry not found for that device id" + ) ha_id = next( ( @@ -119,158 +118,148 @@ def _get_appliance( ), None, ) - assert ha_id - - def find_appliance( - entry: HomeConnectConfigEntry, - ) -> api.HomeConnectAppliance | None: - for device in entry.runtime_data.devices: - appliance = device.appliance - if appliance.haId == ha_id: - return appliance - return None - - if entry is None: - for entry_id in device_entry.config_entries: - entry = hass.config_entries.async_get_entry(entry_id) - assert entry - if entry.domain == DOMAIN: - entry = cast(HomeConnectConfigEntry, entry) - if (appliance := find_appliance(entry)) is not None: - return appliance - elif (appliance := find_appliance(entry)) is not None: - return appliance - raise ValueError(f"Appliance for device id {device_entry.id} not found") - - -def _get_appliance_or_raise_service_validation_error( - hass: HomeAssistant, device_id: str -) -> api.HomeConnectAppliance: - """Return a Home Connect appliance instance or raise a service validation error.""" - try: - return _get_appliance(hass, device_id) - except (ValueError, AssertionError) as err: + if ha_id is None: raise ServiceValidationError( translation_domain=DOMAIN, translation_key="appliance_not_found", translation_placeholders={ "device_id": device_id, }, - ) from err - - -async def _run_appliance_service[*_Ts]( - hass: HomeAssistant, - appliance: api.HomeConnectAppliance, - method: str, - *args: *_Ts, - error_translation_key: str, - error_translation_placeholders: dict[str, str], -) -> None: - try: - await hass.async_add_executor_job(getattr(appliance, method), *args) - except api.HomeConnectError as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key=error_translation_key, - translation_placeholders={ - **get_dict_from_home_connect_error(err), - **error_translation_placeholders, - }, - ) from err + ) + return entry.runtime_data.client, ha_id async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Home Connect component.""" - async def _async_service_program(call, method): + async def _async_service_program(call: ServiceCall, start: bool): """Execute calls to services taking a program.""" program = call.data[ATTR_PROGRAM] - device_id = call.data[ATTR_DEVICE_ID] - - options = [] + client, ha_id = await _get_client_and_ha_id(hass, call.data[ATTR_DEVICE_ID]) option_key = call.data.get(ATTR_KEY) - if option_key is not None: - option = {ATTR_KEY: option_key, ATTR_VALUE: call.data[ATTR_VALUE]} - - option_unit = call.data.get(ATTR_UNIT) - if option_unit is not None: - option[ATTR_UNIT] = option_unit - - options.append(option) - await _run_appliance_service( - hass, - _get_appliance_or_raise_service_validation_error(hass, device_id), - method, - program, - options, - error_translation_key=method, - error_translation_placeholders={ - SVE_TRANSLATION_PLACEHOLDER_PROGRAM: program, - }, + options = ( + [ + Option( + OptionKey(option_key), + call.data[ATTR_VALUE], + unit=call.data.get(ATTR_UNIT), + ) + ] + if option_key is not None + else None ) - async def _async_service_command(call, command): - """Execute calls to services executing a command.""" - device_id = call.data[ATTR_DEVICE_ID] + try: + if start: + await client.start_program(ha_id, program_key=program, options=options) + else: + await client.set_selected_program( + ha_id, program_key=program, options=options + ) + except HomeConnectError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="start_program" if start else "select_program", + translation_placeholders={ + **get_dict_from_home_connect_error(err), + SVE_TRANSLATION_PLACEHOLDER_PROGRAM: program, + }, + ) from err - appliance = _get_appliance_or_raise_service_validation_error(hass, device_id) - await _run_appliance_service( - hass, - appliance, - "execute_command", - command, - error_translation_key="execute_command", - error_translation_placeholders={"command": command}, - ) - - async def _async_service_key_value(call, method): - """Execute calls to services taking a key and value.""" - key = call.data[ATTR_KEY] + async def _async_service_set_program_options(call: ServiceCall, active: bool): + """Execute calls to services taking a program.""" + option_key = call.data[ATTR_KEY] value = call.data[ATTR_VALUE] unit = call.data.get(ATTR_UNIT) - device_id = call.data[ATTR_DEVICE_ID] + client, ha_id = await _get_client_and_ha_id(hass, call.data[ATTR_DEVICE_ID]) - await _run_appliance_service( - hass, - _get_appliance_or_raise_service_validation_error(hass, device_id), - method, - *((key, value) if unit is None else (key, value, unit)), - error_translation_key=method, - error_translation_placeholders={ - SVE_TRANSLATION_PLACEHOLDER_KEY: key, - SVE_TRANSLATION_PLACEHOLDER_VALUE: str(value), - }, - ) + try: + if active: + await client.set_active_program_option( + ha_id, + option_key=OptionKey(option_key), + value=value, + unit=unit, + ) + else: + await client.set_selected_program_option( + ha_id, + option_key=OptionKey(option_key), + value=value, + unit=unit, + ) + except HomeConnectError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="set_options_active_program" + if active + else "set_options_selected_program", + translation_placeholders={ + **get_dict_from_home_connect_error(err), + SVE_TRANSLATION_PLACEHOLDER_KEY: option_key, + SVE_TRANSLATION_PLACEHOLDER_VALUE: str(value), + }, + ) from err - async def async_service_option_active(call): + async def _async_service_command(call: ServiceCall, command_key: CommandKey): + """Execute calls to services executing a command.""" + client, ha_id = await _get_client_and_ha_id(hass, call.data[ATTR_DEVICE_ID]) + + try: + await client.put_command(ha_id, command_key=command_key, value=True) + except HomeConnectError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="execute_command", + translation_placeholders={ + **get_dict_from_home_connect_error(err), + "command": command_key.value, + }, + ) from err + + async def async_service_option_active(call: ServiceCall): """Service for setting an option for an active program.""" - await _async_service_key_value(call, "set_options_active_program") + await _async_service_set_program_options(call, True) - async def async_service_option_selected(call): + async def async_service_option_selected(call: ServiceCall): """Service for setting an option for a selected program.""" - await _async_service_key_value(call, "set_options_selected_program") + await _async_service_set_program_options(call, False) - async def async_service_setting(call): + async def async_service_setting(call: ServiceCall): """Service for changing a setting.""" - await _async_service_key_value(call, "set_setting") + key = call.data[ATTR_KEY] + value = call.data[ATTR_VALUE] + client, ha_id = await _get_client_and_ha_id(hass, call.data[ATTR_DEVICE_ID]) - async def async_service_pause_program(call): + try: + await client.set_setting(ha_id, setting_key=key, value=value) + except HomeConnectError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="set_setting", + translation_placeholders={ + **get_dict_from_home_connect_error(err), + SVE_TRANSLATION_PLACEHOLDER_KEY: key, + SVE_TRANSLATION_PLACEHOLDER_VALUE: str(value), + }, + ) from err + + async def async_service_pause_program(call: ServiceCall): """Service for pausing a program.""" - await _async_service_command(call, BSH_PAUSE) + await _async_service_command(call, CommandKey.BSH_COMMON_PAUSE_PROGRAM) - async def async_service_resume_program(call): + async def async_service_resume_program(call: ServiceCall): """Service for resuming a paused program.""" - await _async_service_command(call, BSH_RESUME) + await _async_service_command(call, CommandKey.BSH_COMMON_RESUME_PROGRAM) - async def async_service_select_program(call): + async def async_service_select_program(call: ServiceCall): """Service for selecting a program.""" - await _async_service_program(call, "select_program") + await _async_service_program(call, False) - async def async_service_start_program(call): + async def async_service_start_program(call: ServiceCall): """Service for starting a program.""" - await _async_service_program(call, "start_program") + await _async_service_program(call, True) hass.services.async_register( DOMAIN, @@ -323,12 +312,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: HomeConnectConfigEntry) ) ) - entry.runtime_data = api.ConfigEntryAuth(hass, entry, implementation) + session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation) - await update_all_devices(hass, entry) + config_entry_auth = AsyncConfigEntryAuth(hass, session) + + home_connect_client = HomeConnectClient(config_entry_auth) + + coordinator = HomeConnectCoordinator(hass, entry, home_connect_client) + await coordinator.async_config_entry_first_refresh() + + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + entry.runtime_data.start_event_listener() + return True @@ -339,21 +337,6 @@ async def async_unload_entry( return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) -@Throttle(SCAN_INTERVAL) -async def update_all_devices( - hass: HomeAssistant, entry: HomeConnectConfigEntry -) -> None: - """Update all the devices.""" - hc_api = entry.runtime_data - - try: - await hass.async_add_executor_job(hc_api.get_devices) - for device in hc_api.devices: - await hass.async_add_executor_job(device.initialize) - except HTTPError as err: - _LOGGER.warning("Cannot update devices: %s", err.response.status_code) - - async def async_migrate_entry( hass: HomeAssistant, entry: HomeConnectConfigEntry ) -> bool: @@ -382,25 +365,3 @@ async def async_migrate_entry( _LOGGER.debug("Migration to version %s successful", entry.version) return True - - -def get_dict_from_home_connect_error(err: api.HomeConnectError) -> dict[str, Any]: - """Return a dict from a Home Connect error.""" - return { - "description": cast(dict[str, Any], err.args[0]).get("description", "?") - if len(err.args) > 0 and isinstance(err.args[0], dict) - else err.args[0] - if len(err.args) > 0 and isinstance(err.args[0], str) - else "?", - } - - -def bsh_key_to_translation_key(bsh_key: str) -> str: - """Convert a BSH key to a translation key format. - - This function takes a BSH key, such as `Dishcare.Dishwasher.Program.Eco50`, - and converts it to a translation key format, such as `dishcare_dishwasher_bsh_key_eco50`. - """ - return "_".join( - RE_CAMEL_CASE.sub("_", split) for split in bsh_key.split(".") - ).lower() diff --git a/homeassistant/components/home_connect/api.py b/homeassistant/components/home_connect/api.py index 453f926c402..5d711dae032 100644 --- a/homeassistant/components/home_connect/api.py +++ b/homeassistant/components/home_connect/api.py @@ -1,85 +1,28 @@ """API for Home Connect bound to HASS OAuth.""" -from asyncio import run_coroutine_threadsafe -import logging +from aiohomeconnect.client import AbstractAuth +from aiohomeconnect.const import API_ENDPOINT -import homeconnect -from homeconnect.api import HomeConnectAppliance, HomeConnectError - -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import config_entry_oauth2_flow -from homeassistant.helpers.dispatcher import dispatcher_send - -from .const import ATTR_KEY, ATTR_VALUE, BSH_ACTIVE_PROGRAM, SIGNAL_UPDATE_ENTITIES - -_LOGGER = logging.getLogger(__name__) +from homeassistant.helpers.httpx_client import get_async_client -class ConfigEntryAuth(homeconnect.HomeConnectAPI): +class AsyncConfigEntryAuth(AbstractAuth): """Provide Home Connect authentication tied to an OAuth2 based config entry.""" def __init__( self, hass: HomeAssistant, - config_entry: ConfigEntry, - implementation: config_entry_oauth2_flow.AbstractOAuth2Implementation, + oauth_session: config_entry_oauth2_flow.OAuth2Session, ) -> None: """Initialize Home Connect Auth.""" self.hass = hass - self.config_entry = config_entry - self.session = config_entry_oauth2_flow.OAuth2Session( - hass, config_entry, implementation - ) - super().__init__(self.session.token) - self.devices: list[HomeConnectDevice] = [] + super().__init__(get_async_client(hass), host=API_ENDPOINT) + self.session = oauth_session - def refresh_tokens(self) -> dict: - """Refresh and return new Home Connect tokens using Home Assistant OAuth2 session.""" - run_coroutine_threadsafe( - self.session.async_ensure_token_valid(), self.hass.loop - ).result() + async def async_get_access_token(self) -> str: + """Return a valid access token.""" + await self.session.async_ensure_token_valid() - return self.session.token - - def get_devices(self) -> list[HomeConnectAppliance]: - """Get a dictionary of devices.""" - appl: list[HomeConnectAppliance] = self.get_appliances() - self.devices = [HomeConnectDevice(self.hass, app) for app in appl] - return self.devices - - -class HomeConnectDevice: - """Generic Home Connect device.""" - - def __init__(self, hass: HomeAssistant, appliance: HomeConnectAppliance) -> None: - """Initialize the device class.""" - self.hass = hass - self.appliance = appliance - - def initialize(self) -> None: - """Fetch the info needed to initialize the device.""" - try: - self.appliance.get_status() - except (HomeConnectError, ValueError): - _LOGGER.debug("Unable to fetch appliance status. Probably offline") - try: - self.appliance.get_settings() - except (HomeConnectError, ValueError): - _LOGGER.debug("Unable to fetch settings. Probably offline") - try: - program_active = self.appliance.get_programs_active() - except (HomeConnectError, ValueError): - _LOGGER.debug("Unable to fetch active programs. Probably offline") - program_active = None - if program_active and ATTR_KEY in program_active: - self.appliance.status[BSH_ACTIVE_PROGRAM] = { - ATTR_VALUE: program_active[ATTR_KEY] - } - self.appliance.listen_events(callback=self.event_callback) - - def event_callback(self, appliance: HomeConnectAppliance) -> None: - """Handle event.""" - _LOGGER.debug("Update triggered on %s", appliance.name) - _LOGGER.debug(self.appliance.status) - dispatcher_send(self.hass, SIGNAL_UPDATE_ENTITIES, appliance.haId) + return self.session.token["access_token"] diff --git a/homeassistant/components/home_connect/application_credentials.py b/homeassistant/components/home_connect/application_credentials.py index 3d5a407b487..d66255e6810 100644 --- a/homeassistant/components/home_connect/application_credentials.py +++ b/homeassistant/components/home_connect/application_credentials.py @@ -1,10 +1,10 @@ """Application credentials platform for Home Connect.""" +from aiohomeconnect.const import OAUTH2_AUTHORIZE, OAUTH2_TOKEN + from homeassistant.components.application_credentials import AuthorizationServer from homeassistant.core import HomeAssistant -from .const import OAUTH2_AUTHORIZE, OAUTH2_TOKEN - async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer: """Return authorization server.""" diff --git a/homeassistant/components/home_connect/binary_sensor.py b/homeassistant/components/home_connect/binary_sensor.py index f9775918f16..90743c829e2 100644 --- a/homeassistant/components/home_connect/binary_sensor.py +++ b/homeassistant/components/home_connect/binary_sensor.py @@ -1,7 +1,9 @@ """Provides a binary sensor for Home Connect.""" from dataclasses import dataclass -import logging +from typing import cast + +from aiohomeconnect.model import StatusKey from homeassistant.components.automation import automations_with_entity from homeassistant.components.binary_sensor import ( @@ -19,26 +21,21 @@ from homeassistant.helpers.issue_registry import ( async_delete_issue, ) -from . import HomeConnectConfigEntry -from .api import HomeConnectDevice from .const import ( - ATTR_VALUE, - BSH_DOOR_STATE, BSH_DOOR_STATE_CLOSED, BSH_DOOR_STATE_LOCKED, BSH_DOOR_STATE_OPEN, - BSH_REMOTE_CONTROL_ACTIVATION_STATE, - BSH_REMOTE_START_ALLOWANCE_STATE, DOMAIN, - REFRIGERATION_STATUS_DOOR_CHILLER, REFRIGERATION_STATUS_DOOR_CLOSED, - REFRIGERATION_STATUS_DOOR_FREEZER, REFRIGERATION_STATUS_DOOR_OPEN, - REFRIGERATION_STATUS_DOOR_REFRIGERATOR, +) +from .coordinator import ( + HomeConnectApplianceData, + HomeConnectConfigEntry, + HomeConnectCoordinator, ) from .entity import HomeConnectEntity -_LOGGER = logging.getLogger(__name__) REFRIGERATION_DOOR_BOOLEAN_MAP = { REFRIGERATION_STATUS_DOOR_CLOSED: False, REFRIGERATION_STATUS_DOOR_OPEN: True, @@ -54,19 +51,19 @@ class HomeConnectBinarySensorEntityDescription(BinarySensorEntityDescription): BINARY_SENSORS = ( HomeConnectBinarySensorEntityDescription( - key=BSH_REMOTE_CONTROL_ACTIVATION_STATE, + key=StatusKey.BSH_COMMON_REMOTE_CONTROL_ACTIVE, translation_key="remote_control", ), HomeConnectBinarySensorEntityDescription( - key=BSH_REMOTE_START_ALLOWANCE_STATE, + key=StatusKey.BSH_COMMON_REMOTE_CONTROL_START_ALLOWED, translation_key="remote_start", ), HomeConnectBinarySensorEntityDescription( - key="BSH.Common.Status.LocalControlActive", + key=StatusKey.BSH_COMMON_LOCAL_CONTROL_ACTIVE, translation_key="local_control", ), HomeConnectBinarySensorEntityDescription( - key="BSH.Common.Status.BatteryChargingState", + key=StatusKey.BSH_COMMON_BATTERY_CHARGING_STATE, device_class=BinarySensorDeviceClass.BATTERY_CHARGING, boolean_map={ "BSH.Common.EnumType.BatteryChargingState.Charging": True, @@ -75,7 +72,7 @@ BINARY_SENSORS = ( translation_key="battery_charging_state", ), HomeConnectBinarySensorEntityDescription( - key="BSH.Common.Status.ChargingConnection", + key=StatusKey.BSH_COMMON_CHARGING_CONNECTION, device_class=BinarySensorDeviceClass.PLUG, boolean_map={ "BSH.Common.EnumType.ChargingConnection.Connected": True, @@ -84,31 +81,31 @@ BINARY_SENSORS = ( translation_key="charging_connection", ), HomeConnectBinarySensorEntityDescription( - key="ConsumerProducts.CleaningRobot.Status.DustBoxInserted", + key=StatusKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_DUST_BOX_INSERTED, translation_key="dust_box_inserted", ), HomeConnectBinarySensorEntityDescription( - key="ConsumerProducts.CleaningRobot.Status.Lifted", + key=StatusKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_LIFTED, translation_key="lifted", ), HomeConnectBinarySensorEntityDescription( - key="ConsumerProducts.CleaningRobot.Status.Lost", + key=StatusKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_LOST, translation_key="lost", ), HomeConnectBinarySensorEntityDescription( - key=REFRIGERATION_STATUS_DOOR_CHILLER, + key=StatusKey.REFRIGERATION_COMMON_DOOR_CHILLER_COMMON, boolean_map=REFRIGERATION_DOOR_BOOLEAN_MAP, device_class=BinarySensorDeviceClass.DOOR, translation_key="chiller_door", ), HomeConnectBinarySensorEntityDescription( - key=REFRIGERATION_STATUS_DOOR_FREEZER, + key=StatusKey.REFRIGERATION_COMMON_DOOR_FREEZER, boolean_map=REFRIGERATION_DOOR_BOOLEAN_MAP, device_class=BinarySensorDeviceClass.DOOR, translation_key="freezer_door", ), HomeConnectBinarySensorEntityDescription( - key=REFRIGERATION_STATUS_DOOR_REFRIGERATOR, + key=StatusKey.REFRIGERATION_COMMON_DOOR_REFRIGERATOR, boolean_map=REFRIGERATION_DOOR_BOOLEAN_MAP, device_class=BinarySensorDeviceClass.DOOR, translation_key="refrigerator_door", @@ -123,19 +120,17 @@ async def async_setup_entry( ) -> None: """Set up the Home Connect binary sensor.""" - def get_entities() -> list[BinarySensorEntity]: - entities: list[BinarySensorEntity] = [] - for device in entry.runtime_data.devices: - entities.extend( - HomeConnectBinarySensor(device, description) - for description in BINARY_SENSORS - if description.key in device.appliance.status - ) - if BSH_DOOR_STATE in device.appliance.status: - entities.append(HomeConnectDoorBinarySensor(device)) - return entities + entities: list[BinarySensorEntity] = [] + for appliance in entry.runtime_data.data.values(): + entities.extend( + HomeConnectBinarySensor(entry.runtime_data, appliance, description) + for description in BINARY_SENSORS + if description.key in appliance.status + ) + if StatusKey.BSH_COMMON_DOOR_STATE in appliance.status: + entities.append(HomeConnectDoorBinarySensor(entry.runtime_data, appliance)) - async_add_entities(await hass.async_add_executor_job(get_entities), True) + async_add_entities(entities) class HomeConnectBinarySensor(HomeConnectEntity, BinarySensorEntity): @@ -143,25 +138,15 @@ class HomeConnectBinarySensor(HomeConnectEntity, BinarySensorEntity): entity_description: HomeConnectBinarySensorEntityDescription - @property - def available(self) -> bool: - """Return true if the binary sensor is available.""" - return self._attr_is_on is not None - - async def async_update(self) -> None: - """Update the binary sensor's status.""" - if not self.device.appliance.status or not ( - status := self.device.appliance.status.get(self.bsh_key, {}).get(ATTR_VALUE) - ): - self._attr_is_on = None - return - if self.entity_description.boolean_map: - self._attr_is_on = self.entity_description.boolean_map.get(status) - elif status not in [True, False]: - self._attr_is_on = None - else: + def update_native_value(self) -> None: + """Set the native value of the binary sensor.""" + status = self.appliance.status[cast(StatusKey, self.bsh_key)].value + if isinstance(status, bool): self._attr_is_on = status - _LOGGER.debug("Updated, new state: %s", self._attr_is_on) + elif self.entity_description.boolean_map: + self._attr_is_on = self.entity_description.boolean_map.get(status) + else: + self._attr_is_on = None class HomeConnectDoorBinarySensor(HomeConnectBinarySensor): @@ -171,13 +156,15 @@ class HomeConnectDoorBinarySensor(HomeConnectBinarySensor): def __init__( self, - device: HomeConnectDevice, + coordinator: HomeConnectCoordinator, + appliance: HomeConnectApplianceData, ) -> None: """Initialize the entity.""" super().__init__( - device, + coordinator, + appliance, HomeConnectBinarySensorEntityDescription( - key=BSH_DOOR_STATE, + key=StatusKey.BSH_COMMON_DOOR_STATE, device_class=BinarySensorDeviceClass.DOOR, boolean_map={ BSH_DOOR_STATE_CLOSED: False, @@ -186,8 +173,8 @@ class HomeConnectDoorBinarySensor(HomeConnectBinarySensor): }, ), ) - self._attr_unique_id = f"{device.appliance.haId}-Door" - self._attr_name = f"{device.appliance.name} Door" + self._attr_unique_id = f"{appliance.info.ha_id}-Door" + self._attr_name = f"{appliance.info.name} Door" async def async_added_to_hass(self) -> None: """Call when entity is added to hass.""" @@ -234,6 +221,7 @@ class HomeConnectDoorBinarySensor(HomeConnectBinarySensor): async def async_will_remove_from_hass(self) -> None: """Call when entity will be removed from hass.""" + await super().async_will_remove_from_hass() async_delete_issue( self.hass, DOMAIN, f"deprecated_binary_common_door_sensor_{self.entity_id}" ) diff --git a/homeassistant/components/home_connect/const.py b/homeassistant/components/home_connect/const.py index e20cf3b1fa0..127aa1ffe92 100644 --- a/homeassistant/components/home_connect/const.py +++ b/homeassistant/components/home_connect/const.py @@ -1,9 +1,9 @@ """Constants for the Home Connect integration.""" +from aiohomeconnect.model import EventKey, SettingKey, StatusKey + DOMAIN = "home_connect" -OAUTH2_AUTHORIZE = "https://api.home-connect.com/security/oauth/authorize" -OAUTH2_TOKEN = "https://api.home-connect.com/security/oauth/token" APPLIANCES_WITH_PROGRAMS = ( "CleaningRobot", @@ -17,93 +17,35 @@ APPLIANCES_WITH_PROGRAMS = ( "WasherDryer", ) -BSH_POWER_STATE = "BSH.Common.Setting.PowerState" + BSH_POWER_ON = "BSH.Common.EnumType.PowerState.On" BSH_POWER_OFF = "BSH.Common.EnumType.PowerState.Off" BSH_POWER_STANDBY = "BSH.Common.EnumType.PowerState.Standby" -BSH_SELECTED_PROGRAM = "BSH.Common.Root.SelectedProgram" -BSH_ACTIVE_PROGRAM = "BSH.Common.Root.ActiveProgram" -BSH_REMOTE_CONTROL_ACTIVATION_STATE = "BSH.Common.Status.RemoteControlActive" -BSH_REMOTE_START_ALLOWANCE_STATE = "BSH.Common.Status.RemoteControlStartAllowed" -BSH_CHILD_LOCK_STATE = "BSH.Common.Setting.ChildLock" -BSH_REMAINING_PROGRAM_TIME = "BSH.Common.Option.RemainingProgramTime" -BSH_COMMON_OPTION_DURATION = "BSH.Common.Option.Duration" -BSH_COMMON_OPTION_PROGRAM_PROGRESS = "BSH.Common.Option.ProgramProgress" BSH_EVENT_PRESENT_STATE_PRESENT = "BSH.Common.EnumType.EventPresentState.Present" BSH_EVENT_PRESENT_STATE_CONFIRMED = "BSH.Common.EnumType.EventPresentState.Confirmed" BSH_EVENT_PRESENT_STATE_OFF = "BSH.Common.EnumType.EventPresentState.Off" -BSH_OPERATION_STATE = "BSH.Common.Status.OperationState" + BSH_OPERATION_STATE_RUN = "BSH.Common.EnumType.OperationState.Run" BSH_OPERATION_STATE_PAUSE = "BSH.Common.EnumType.OperationState.Pause" BSH_OPERATION_STATE_FINISHED = "BSH.Common.EnumType.OperationState.Finished" -COOKING_LIGHTING = "Cooking.Common.Setting.Lighting" -COOKING_LIGHTING_BRIGHTNESS = "Cooking.Common.Setting.LightingBrightness" - -COFFEE_EVENT_BEAN_CONTAINER_EMPTY = ( - "ConsumerProducts.CoffeeMaker.Event.BeanContainerEmpty" -) -COFFEE_EVENT_WATER_TANK_EMPTY = "ConsumerProducts.CoffeeMaker.Event.WaterTankEmpty" -COFFEE_EVENT_DRIP_TRAY_FULL = "ConsumerProducts.CoffeeMaker.Event.DripTrayFull" - -DISHWASHER_EVENT_SALT_NEARLY_EMPTY = "Dishcare.Dishwasher.Event.SaltNearlyEmpty" -DISHWASHER_EVENT_RINSE_AID_NEARLY_EMPTY = ( - "Dishcare.Dishwasher.Event.RinseAidNearlyEmpty" -) - -REFRIGERATION_INTERNAL_LIGHT_POWER = "Refrigeration.Common.Setting.Light.Internal.Power" -REFRIGERATION_INTERNAL_LIGHT_BRIGHTNESS = ( - "Refrigeration.Common.Setting.Light.Internal.Brightness" -) -REFRIGERATION_EXTERNAL_LIGHT_POWER = "Refrigeration.Common.Setting.Light.External.Power" -REFRIGERATION_EXTERNAL_LIGHT_BRIGHTNESS = ( - "Refrigeration.Common.Setting.Light.External.Brightness" -) - -REFRIGERATION_SUPERMODEFREEZER = "Refrigeration.FridgeFreezer.Setting.SuperModeFreezer" -REFRIGERATION_SUPERMODEREFRIGERATOR = ( - "Refrigeration.FridgeFreezer.Setting.SuperModeRefrigerator" -) -REFRIGERATION_DISPENSER = "Refrigeration.Common.Setting.Dispenser.Enabled" - -REFRIGERATION_STATUS_DOOR_CHILLER = "Refrigeration.Common.Status.Door.ChillerCommon" -REFRIGERATION_STATUS_DOOR_FREEZER = "Refrigeration.Common.Status.Door.Freezer" -REFRIGERATION_STATUS_DOOR_REFRIGERATOR = "Refrigeration.Common.Status.Door.Refrigerator" REFRIGERATION_STATUS_DOOR_CLOSED = "Refrigeration.Common.EnumType.Door.States.Closed" REFRIGERATION_STATUS_DOOR_OPEN = "Refrigeration.Common.EnumType.Door.States.Open" -REFRIGERATION_EVENT_DOOR_ALARM_REFRIGERATOR = ( - "Refrigeration.FridgeFreezer.Event.DoorAlarmRefrigerator" -) -REFRIGERATION_EVENT_DOOR_ALARM_FREEZER = ( - "Refrigeration.FridgeFreezer.Event.DoorAlarmFreezer" -) -REFRIGERATION_EVENT_TEMP_ALARM_FREEZER = ( - "Refrigeration.FridgeFreezer.Event.TemperatureAlarmFreezer" -) - -BSH_AMBIENT_LIGHT_ENABLED = "BSH.Common.Setting.AmbientLightEnabled" -BSH_AMBIENT_LIGHT_BRIGHTNESS = "BSH.Common.Setting.AmbientLightBrightness" -BSH_AMBIENT_LIGHT_COLOR = "BSH.Common.Setting.AmbientLightColor" BSH_AMBIENT_LIGHT_COLOR_CUSTOM_COLOR = ( "BSH.Common.EnumType.AmbientLightColor.CustomColor" ) -BSH_AMBIENT_LIGHT_CUSTOM_COLOR = "BSH.Common.Setting.AmbientLightCustomColor" -BSH_DOOR_STATE = "BSH.Common.Status.DoorState" + BSH_DOOR_STATE_CLOSED = "BSH.Common.EnumType.DoorState.Closed" BSH_DOOR_STATE_LOCKED = "BSH.Common.EnumType.DoorState.Locked" BSH_DOOR_STATE_OPEN = "BSH.Common.EnumType.DoorState.Open" -BSH_PAUSE = "BSH.Common.Command.PauseProgram" -BSH_RESUME = "BSH.Common.Command.ResumeProgram" - -SIGNAL_UPDATE_ENTITIES = "home_connect.update_entities" SERVICE_OPTION_ACTIVE = "set_option_active" SERVICE_OPTION_SELECTED = "set_option_selected" @@ -113,51 +55,44 @@ SERVICE_SELECT_PROGRAM = "select_program" SERVICE_SETTING = "change_setting" SERVICE_START_PROGRAM = "start_program" -ATTR_ALLOWED_VALUES = "allowedvalues" -ATTR_AMBIENT = "ambient" -ATTR_BSH_KEY = "bsh_key" -ATTR_CONSTRAINTS = "constraints" -ATTR_DESC = "desc" -ATTR_DEVICE = "device" + ATTR_KEY = "key" ATTR_PROGRAM = "program" -ATTR_SENSOR_TYPE = "sensor_type" -ATTR_SIGN = "sign" -ATTR_STEPSIZE = "stepsize" ATTR_UNIT = "unit" ATTR_VALUE = "value" -SVE_TRANSLATION_KEY_SET_SETTING = "set_setting_entity" +SVE_TRANSLATION_KEY_SET_SETTING = "set_setting_entity" SVE_TRANSLATION_PLACEHOLDER_APPLIANCE_NAME = "appliance_name" SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID = "entity_id" SVE_TRANSLATION_PLACEHOLDER_PROGRAM = "program" SVE_TRANSLATION_PLACEHOLDER_KEY = "key" SVE_TRANSLATION_PLACEHOLDER_VALUE = "value" + OLD_NEW_UNIQUE_ID_SUFFIX_MAP = { - "ChildLock": BSH_CHILD_LOCK_STATE, - "Operation State": BSH_OPERATION_STATE, - "Light": COOKING_LIGHTING, - "AmbientLight": BSH_AMBIENT_LIGHT_ENABLED, - "Power": BSH_POWER_STATE, - "Remaining Program Time": BSH_REMAINING_PROGRAM_TIME, - "Duration": BSH_COMMON_OPTION_DURATION, - "Program Progress": BSH_COMMON_OPTION_PROGRAM_PROGRESS, - "Remote Control": BSH_REMOTE_CONTROL_ACTIVATION_STATE, - "Remote Start": BSH_REMOTE_START_ALLOWANCE_STATE, - "Supermode Freezer": REFRIGERATION_SUPERMODEFREEZER, - "Supermode Refrigerator": REFRIGERATION_SUPERMODEREFRIGERATOR, - "Dispenser Enabled": REFRIGERATION_DISPENSER, - "Internal Light": REFRIGERATION_INTERNAL_LIGHT_POWER, - "External Light": REFRIGERATION_EXTERNAL_LIGHT_POWER, - "Chiller Door": REFRIGERATION_STATUS_DOOR_CHILLER, - "Freezer Door": REFRIGERATION_STATUS_DOOR_FREEZER, - "Refrigerator Door": REFRIGERATION_STATUS_DOOR_REFRIGERATOR, - "Door Alarm Freezer": REFRIGERATION_EVENT_DOOR_ALARM_FREEZER, - "Door Alarm Refrigerator": REFRIGERATION_EVENT_DOOR_ALARM_REFRIGERATOR, - "Temperature Alarm Freezer": REFRIGERATION_EVENT_TEMP_ALARM_FREEZER, - "Bean Container Empty": COFFEE_EVENT_BEAN_CONTAINER_EMPTY, - "Water Tank Empty": COFFEE_EVENT_WATER_TANK_EMPTY, - "Drip Tray Full": COFFEE_EVENT_DRIP_TRAY_FULL, + "ChildLock": SettingKey.BSH_COMMON_CHILD_LOCK, + "Operation State": StatusKey.BSH_COMMON_OPERATION_STATE, + "Light": SettingKey.COOKING_COMMON_LIGHTING, + "AmbientLight": SettingKey.BSH_COMMON_AMBIENT_LIGHT_ENABLED, + "Power": SettingKey.BSH_COMMON_POWER_STATE, + "Remaining Program Time": EventKey.BSH_COMMON_OPTION_REMAINING_PROGRAM_TIME, + "Duration": EventKey.BSH_COMMON_OPTION_DURATION, + "Program Progress": EventKey.BSH_COMMON_OPTION_PROGRAM_PROGRESS, + "Remote Control": StatusKey.BSH_COMMON_REMOTE_CONTROL_ACTIVE, + "Remote Start": StatusKey.BSH_COMMON_REMOTE_CONTROL_START_ALLOWED, + "Supermode Freezer": SettingKey.REFRIGERATION_FRIDGE_FREEZER_SUPER_MODE_FREEZER, + "Supermode Refrigerator": SettingKey.REFRIGERATION_FRIDGE_FREEZER_SUPER_MODE_REFRIGERATOR, + "Dispenser Enabled": SettingKey.REFRIGERATION_COMMON_DISPENSER_ENABLED, + "Internal Light": SettingKey.REFRIGERATION_COMMON_LIGHT_INTERNAL_POWER, + "External Light": SettingKey.REFRIGERATION_COMMON_LIGHT_EXTERNAL_POWER, + "Chiller Door": StatusKey.REFRIGERATION_COMMON_DOOR_CHILLER, + "Freezer Door": StatusKey.REFRIGERATION_COMMON_DOOR_FREEZER, + "Refrigerator Door": StatusKey.REFRIGERATION_COMMON_DOOR_REFRIGERATOR, + "Door Alarm Freezer": EventKey.REFRIGERATION_FRIDGE_FREEZER_EVENT_DOOR_ALARM_FREEZER, + "Door Alarm Refrigerator": EventKey.REFRIGERATION_FRIDGE_FREEZER_EVENT_DOOR_ALARM_REFRIGERATOR, + "Temperature Alarm Freezer": EventKey.REFRIGERATION_FRIDGE_FREEZER_EVENT_TEMPERATURE_ALARM_FREEZER, + "Bean Container Empty": EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_BEAN_CONTAINER_EMPTY, + "Water Tank Empty": EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_WATER_TANK_EMPTY, + "Drip Tray Full": EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_DRIP_TRAY_FULL, } diff --git a/homeassistant/components/home_connect/coordinator.py b/homeassistant/components/home_connect/coordinator.py new file mode 100644 index 00000000000..2c70d74150e --- /dev/null +++ b/homeassistant/components/home_connect/coordinator.py @@ -0,0 +1,258 @@ +"""Coordinator for Home Connect.""" + +import asyncio +from collections import defaultdict +from collections.abc import Callable +from dataclasses import dataclass, field +import logging +from typing import Any + +from aiohomeconnect.client import Client as HomeConnectClient +from aiohomeconnect.model import ( + Event, + EventKey, + EventMessage, + EventType, + GetSetting, + HomeAppliance, + SettingKey, + Status, + StatusKey, +) +from aiohomeconnect.model.error import ( + EventStreamInterruptedError, + HomeConnectApiError, + HomeConnectError, + HomeConnectRequestError, +) +from aiohomeconnect.model.program import EnumerateAvailableProgram +from propcache.api import cached_property + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import APPLIANCES_WITH_PROGRAMS, DOMAIN +from .utils import get_dict_from_home_connect_error + +_LOGGER = logging.getLogger(__name__) + +type HomeConnectConfigEntry = ConfigEntry[HomeConnectCoordinator] + +EVENT_STREAM_RECONNECT_DELAY = 30 + + +@dataclass(frozen=True, kw_only=True) +class HomeConnectApplianceData: + """Class to hold Home Connect appliance data.""" + + events: dict[EventKey, Event] = field(default_factory=dict) + info: HomeAppliance + programs: list[EnumerateAvailableProgram] = field(default_factory=list) + settings: dict[SettingKey, GetSetting] + status: dict[StatusKey, Status] + + def update(self, other: "HomeConnectApplianceData") -> None: + """Update data with data from other instance.""" + self.events.update(other.events) + self.info.connected = other.info.connected + self.programs.clear() + self.programs.extend(other.programs) + self.settings.update(other.settings) + self.status.update(other.status) + + +class HomeConnectCoordinator( + DataUpdateCoordinator[dict[str, HomeConnectApplianceData]] +): + """Class to manage fetching Home Connect data.""" + + config_entry: HomeConnectConfigEntry + + def __init__( + self, + hass: HomeAssistant, + config_entry: HomeConnectConfigEntry, + client: HomeConnectClient, + ) -> None: + """Initialize.""" + super().__init__( + hass, + _LOGGER, + config_entry=config_entry, + name=config_entry.entry_id, + ) + self.client = client + + @cached_property + def context_listeners(self) -> dict[tuple[str, EventKey], list[CALLBACK_TYPE]]: + """Return a dict of all listeners registered for a given context.""" + listeners: dict[tuple[str, EventKey], list[CALLBACK_TYPE]] = defaultdict(list) + for listener, context in list(self._listeners.values()): + assert isinstance(context, tuple) + listeners[context].append(listener) + return listeners + + @callback + def async_add_listener( + self, update_callback: CALLBACK_TYPE, context: Any = None + ) -> Callable[[], None]: + """Listen for data updates.""" + remove_listener = super().async_add_listener(update_callback, context) + self.__dict__.pop("context_listeners", None) + + def remove_listener_and_invalidate_context_listeners() -> None: + remove_listener() + self.__dict__.pop("context_listeners", None) + + return remove_listener_and_invalidate_context_listeners + + @callback + def start_event_listener(self) -> None: + """Start event listener.""" + self.config_entry.async_create_background_task( + self.hass, + self._event_listener(), + f"home_connect-events_listener_task-{self.config_entry.entry_id}", + ) + + async def _event_listener(self) -> None: + """Match event with listener for event type.""" + while True: + try: + async for event_message in self.client.stream_all_events(): + match event_message.type: + case EventType.STATUS: + statuses = self.data[event_message.ha_id].status + for event in event_message.data.items: + status_key = StatusKey(event.key) + if status_key in statuses: + statuses[status_key].value = event.value + else: + statuses[status_key] = Status( + key=status_key, + raw_key=status_key.value, + value=event.value, + ) + + case EventType.NOTIFY: + settings = self.data[event_message.ha_id].settings + events = self.data[event_message.ha_id].events + for event in event_message.data.items: + if event.key in SettingKey: + setting_key = SettingKey(event.key) + if setting_key in settings: + settings[setting_key].value = event.value + else: + settings[setting_key] = GetSetting( + key=setting_key, + raw_key=setting_key.value, + value=event.value, + ) + else: + events[event.key] = event + + case EventType.EVENT: + events = self.data[event_message.ha_id].events + for event in event_message.data.items: + events[event.key] = event + + self._call_event_listener(event_message) + + except (EventStreamInterruptedError, HomeConnectRequestError) as error: + _LOGGER.debug( + "Non-breaking error (%s) while listening for events," + " continuing in 30 seconds", + type(error).__name__, + ) + await asyncio.sleep(EVENT_STREAM_RECONNECT_DELAY) + except HomeConnectApiError as error: + _LOGGER.error("Error while listening for events: %s", error) + self.hass.config_entries.async_schedule_reload( + self.config_entry.entry_id + ) + break + # if there was a non-breaking error, we continue listening + # but we need to refresh the data to get the possible changes + # that happened while the event stream was interrupted + await self.async_refresh() + + @callback + def _call_event_listener(self, event_message: EventMessage): + """Call listener for event.""" + for event in event_message.data.items: + for listener in self.context_listeners.get( + (event_message.ha_id, event.key), [] + ): + listener() + + async def _async_update_data(self) -> dict[str, HomeConnectApplianceData]: + """Fetch data from Home Connect.""" + try: + appliances = await self.client.get_home_appliances() + except HomeConnectError as error: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="fetch_api_error", + translation_placeholders=get_dict_from_home_connect_error(error), + ) from error + + appliances_data = self.data or {} + for appliance in appliances.homeappliances: + try: + settings = { + setting.key: setting + for setting in ( + await self.client.get_settings(appliance.ha_id) + ).settings + } + except HomeConnectError as error: + _LOGGER.debug( + "Error fetching settings for %s: %s", + appliance.ha_id, + error + if isinstance(error, HomeConnectApiError) + else type(error).__name__, + ) + settings = {} + try: + status = { + status.key: status + for status in (await self.client.get_status(appliance.ha_id)).status + } + except HomeConnectError as error: + _LOGGER.debug( + "Error fetching status for %s: %s", + appliance.ha_id, + error + if isinstance(error, HomeConnectApiError) + else type(error).__name__, + ) + status = {} + appliance_data = HomeConnectApplianceData( + info=appliance, settings=settings, status=status + ) + if appliance.ha_id in appliances_data: + appliances_data[appliance.ha_id].update(appliance_data) + appliance_data = appliances_data[appliance.ha_id] + else: + appliances_data[appliance.ha_id] = appliance_data + if ( + appliance.type in APPLIANCES_WITH_PROGRAMS + and not appliance_data.programs + ): + try: + appliance_data.programs.extend( + ( + await self.client.get_available_programs(appliance.ha_id) + ).programs + ) + except HomeConnectError as error: + _LOGGER.debug( + "Error fetching programs for %s: %s", + appliance.ha_id, + error + if isinstance(error, HomeConnectApiError) + else type(error).__name__, + ) + return appliances_data diff --git a/homeassistant/components/home_connect/diagnostics.py b/homeassistant/components/home_connect/diagnostics.py index e095bc503ab..fd74277a815 100644 --- a/homeassistant/components/home_connect/diagnostics.py +++ b/homeassistant/components/home_connect/diagnostics.py @@ -4,33 +4,25 @@ from __future__ import annotations from typing import Any -from homeconnect.api import HomeConnectAppliance, HomeConnectError +from aiohomeconnect.client import Client as HomeConnectClient from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntry -from . import HomeConnectConfigEntry, _get_appliance -from .api import HomeConnectDevice +from .const import DOMAIN +from .coordinator import HomeConnectApplianceData, HomeConnectConfigEntry -def _generate_appliance_diagnostics(appliance: HomeConnectAppliance) -> dict[str, Any]: - try: - programs = appliance.get_programs_available() - except HomeConnectError: - programs = None +async def _generate_appliance_diagnostics( + client: HomeConnectClient, appliance: HomeConnectApplianceData +) -> dict[str, Any]: return { - "connected": appliance.connected, - "status": appliance.status, - "programs": programs, - } - - -def _generate_entry_diagnostics( - devices: list[HomeConnectDevice], -) -> dict[str, dict[str, Any]]: - return { - device.appliance.haId: _generate_appliance_diagnostics(device.appliance) - for device in devices + **appliance.info.to_dict(), + "status": {key.value: status.value for key, status in appliance.status.items()}, + "settings": { + key.value: setting.value for key, setting in appliance.settings.items() + }, + "programs": [program.raw_key for program in appliance.programs], } @@ -38,14 +30,21 @@ async def async_get_config_entry_diagnostics( hass: HomeAssistant, entry: HomeConnectConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - return await hass.async_add_executor_job( - _generate_entry_diagnostics, entry.runtime_data.devices - ) + return { + appliance.info.ha_id: await _generate_appliance_diagnostics( + entry.runtime_data.client, appliance + ) + for appliance in entry.runtime_data.data.values() + } async def async_get_device_diagnostics( hass: HomeAssistant, entry: HomeConnectConfigEntry, device: DeviceEntry ) -> dict[str, Any]: """Return diagnostics for a device.""" - appliance = _get_appliance(hass, device_entry=device, entry=entry) - return await hass.async_add_executor_job(_generate_appliance_diagnostics, appliance) + ha_id = next( + (identifier[1] for identifier in device.identifiers if identifier[0] == DOMAIN), + ) + return await _generate_appliance_diagnostics( + entry.runtime_data.client, entry.runtime_data.data[ha_id] + ) diff --git a/homeassistant/components/home_connect/entity.py b/homeassistant/components/home_connect/entity.py index 0ae4a28b8d4..ba8500fe8b6 100644 --- a/homeassistant/components/home_connect/entity.py +++ b/homeassistant/components/home_connect/entity.py @@ -1,55 +1,56 @@ """Home Connect entity base class.""" +from abc import abstractmethod import logging +from aiohomeconnect.model import EventKey + from homeassistant.core import callback from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity import Entity, EntityDescription +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .api import HomeConnectDevice -from .const import DOMAIN, SIGNAL_UPDATE_ENTITIES +from .const import DOMAIN +from .coordinator import HomeConnectApplianceData, HomeConnectCoordinator _LOGGER = logging.getLogger(__name__) -class HomeConnectEntity(Entity): +class HomeConnectEntity(CoordinatorEntity[HomeConnectCoordinator]): """Generic Home Connect entity (base class).""" _attr_should_poll = False _attr_has_entity_name = True - def __init__(self, device: HomeConnectDevice, desc: EntityDescription) -> None: + def __init__( + self, + coordinator: HomeConnectCoordinator, + appliance: HomeConnectApplianceData, + desc: EntityDescription, + ) -> None: """Initialize the entity.""" - self.device = device + super().__init__(coordinator, (appliance.info.ha_id, EventKey(desc.key))) + self.appliance = appliance self.entity_description = desc - self._attr_unique_id = f"{device.appliance.haId}-{self.bsh_key}" + self._attr_unique_id = f"{appliance.info.ha_id}-{desc.key}" self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, device.appliance.haId)}, - manufacturer=device.appliance.brand, - model=device.appliance.vib, - name=device.appliance.name, + identifiers={(DOMAIN, appliance.info.ha_id)}, + manufacturer=appliance.info.brand, + model=appliance.info.vib, + name=appliance.info.name, ) + self.update_native_value() - async def async_added_to_hass(self) -> None: - """Register callbacks.""" - self.async_on_remove( - async_dispatcher_connect( - self.hass, SIGNAL_UPDATE_ENTITIES, self._update_callback - ) - ) + @abstractmethod + def update_native_value(self) -> None: + """Set the value of the entity.""" @callback - def _update_callback(self, ha_id: str) -> None: - """Update data.""" - if ha_id == self.device.appliance.haId: - self.async_entity_update() - - @callback - def async_entity_update(self) -> None: - """Update the entity.""" - _LOGGER.debug("Entity update triggered on %s", self) - self.async_schedule_update_ha_state(True) + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self.update_native_value() + self.async_write_ha_state() + _LOGGER.debug("Updated %s, new state: %s", self.entity_id, self.state) @property def bsh_key(self) -> str: diff --git a/homeassistant/components/home_connect/light.py b/homeassistant/components/home_connect/light.py index 3e81bcbddad..9d1c4d7a55b 100644 --- a/homeassistant/components/home_connect/light.py +++ b/homeassistant/components/home_connect/light.py @@ -2,10 +2,10 @@ from dataclasses import dataclass import logging -from math import ceil -from typing import Any +from typing import Any, cast -from homeconnect.api import HomeConnectError +from aiohomeconnect.model import EventKey, SettingKey +from aiohomeconnect.model.error import HomeConnectError from homeassistant.components.light import ( ATTR_BRIGHTNESS, @@ -20,25 +20,18 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import color as color_util -from . import HomeConnectConfigEntry, get_dict_from_home_connect_error -from .api import HomeConnectDevice from .const import ( - ATTR_VALUE, - BSH_AMBIENT_LIGHT_BRIGHTNESS, - BSH_AMBIENT_LIGHT_COLOR, BSH_AMBIENT_LIGHT_COLOR_CUSTOM_COLOR, - BSH_AMBIENT_LIGHT_CUSTOM_COLOR, - BSH_AMBIENT_LIGHT_ENABLED, - COOKING_LIGHTING, - COOKING_LIGHTING_BRIGHTNESS, DOMAIN, - REFRIGERATION_EXTERNAL_LIGHT_BRIGHTNESS, - REFRIGERATION_EXTERNAL_LIGHT_POWER, - REFRIGERATION_INTERNAL_LIGHT_BRIGHTNESS, - REFRIGERATION_INTERNAL_LIGHT_POWER, SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID, ) +from .coordinator import ( + HomeConnectApplianceData, + HomeConnectConfigEntry, + HomeConnectCoordinator, +) from .entity import HomeConnectEntity +from .utils import get_dict_from_home_connect_error _LOGGER = logging.getLogger(__name__) @@ -47,38 +40,38 @@ _LOGGER = logging.getLogger(__name__) class HomeConnectLightEntityDescription(LightEntityDescription): """Light entity description.""" - brightness_key: str | None = None - color_key: str | None = None + brightness_key: SettingKey | None = None + color_key: SettingKey | None = None enable_custom_color_value_key: str | None = None - custom_color_key: str | None = None + custom_color_key: SettingKey | None = None brightness_scale: tuple[float, float] = (0.0, 100.0) LIGHTS: tuple[HomeConnectLightEntityDescription, ...] = ( HomeConnectLightEntityDescription( - key=REFRIGERATION_INTERNAL_LIGHT_POWER, - brightness_key=REFRIGERATION_INTERNAL_LIGHT_BRIGHTNESS, + key=SettingKey.REFRIGERATION_COMMON_LIGHT_INTERNAL_POWER, + brightness_key=SettingKey.REFRIGERATION_COMMON_LIGHT_INTERNAL_BRIGHTNESS, brightness_scale=(1.0, 100.0), translation_key="internal_light", ), HomeConnectLightEntityDescription( - key=REFRIGERATION_EXTERNAL_LIGHT_POWER, - brightness_key=REFRIGERATION_EXTERNAL_LIGHT_BRIGHTNESS, + key=SettingKey.REFRIGERATION_COMMON_LIGHT_EXTERNAL_POWER, + brightness_key=SettingKey.REFRIGERATION_COMMON_LIGHT_EXTERNAL_BRIGHTNESS, brightness_scale=(1.0, 100.0), translation_key="external_light", ), HomeConnectLightEntityDescription( - key=COOKING_LIGHTING, - brightness_key=COOKING_LIGHTING_BRIGHTNESS, + key=SettingKey.COOKING_COMMON_LIGHTING, + brightness_key=SettingKey.COOKING_COMMON_LIGHTING_BRIGHTNESS, brightness_scale=(10.0, 100.0), translation_key="cooking_lighting", ), HomeConnectLightEntityDescription( - key=BSH_AMBIENT_LIGHT_ENABLED, - brightness_key=BSH_AMBIENT_LIGHT_BRIGHTNESS, - color_key=BSH_AMBIENT_LIGHT_COLOR, + key=SettingKey.BSH_COMMON_AMBIENT_LIGHT_ENABLED, + brightness_key=SettingKey.BSH_COMMON_AMBIENT_LIGHT_BRIGHTNESS, + color_key=SettingKey.BSH_COMMON_AMBIENT_LIGHT_COLOR, enable_custom_color_value_key=BSH_AMBIENT_LIGHT_COLOR_CUSTOM_COLOR, - custom_color_key=BSH_AMBIENT_LIGHT_CUSTOM_COLOR, + custom_color_key=SettingKey.BSH_COMMON_AMBIENT_LIGHT_CUSTOM_COLOR, brightness_scale=(10.0, 100.0), translation_key="ambient_light", ), @@ -92,16 +85,14 @@ async def async_setup_entry( ) -> None: """Set up the Home Connect light.""" - def get_entities() -> list[LightEntity]: - """Get a list of entities.""" - return [ - HomeConnectLight(device, description) + async_add_entities( + [ + HomeConnectLight(entry.runtime_data, appliance, description) for description in LIGHTS - for device in entry.runtime_data.devices - if description.key in device.appliance.status - ] - - async_add_entities(await hass.async_add_executor_job(get_entities), True) + for appliance in entry.runtime_data.data.values() + if description.key in appliance.settings + ], + ) class HomeConnectLight(HomeConnectEntity, LightEntity): @@ -110,13 +101,17 @@ class HomeConnectLight(HomeConnectEntity, LightEntity): entity_description: LightEntityDescription def __init__( - self, device: HomeConnectDevice, desc: HomeConnectLightEntityDescription + self, + coordinator: HomeConnectCoordinator, + appliance: HomeConnectApplianceData, + desc: HomeConnectLightEntityDescription, ) -> None: """Initialize the entity.""" - super().__init__(device, desc) - def get_setting_key_if_setting_exists(setting_key: str | None) -> str | None: - if setting_key and setting_key in device.appliance.status: + def get_setting_key_if_setting_exists( + setting_key: SettingKey | None, + ) -> SettingKey | None: + if setting_key and setting_key in appliance.settings: return setting_key return None @@ -131,6 +126,8 @@ class HomeConnectLight(HomeConnectEntity, LightEntity): ) self._brightness_scale = desc.brightness_scale + super().__init__(coordinator, appliance, desc) + match (self._brightness_key, self._custom_color_key): case (None, None): self._attr_color_mode = ColorMode.ONOFF @@ -144,10 +141,11 @@ class HomeConnectLight(HomeConnectEntity, LightEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Switch the light on, change brightness, change color.""" - _LOGGER.debug("Switching light on for: %s", self.name) try: - await self.hass.async_add_executor_job( - self.device.appliance.set_setting, self.bsh_key, True + await self.coordinator.client.set_setting( + self.appliance.info.ha_id, + setting_key=SettingKey(self.bsh_key), + value=True, ) except HomeConnectError as err: raise HomeAssistantError( @@ -158,15 +156,15 @@ class HomeConnectLight(HomeConnectEntity, LightEntity): SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID: self.entity_id, }, ) from err - if self._custom_color_key: + if self._color_key and self._custom_color_key: if ( ATTR_RGB_COLOR in kwargs or ATTR_HS_COLOR in kwargs ) and self._enable_custom_color_value_key: try: - await self.hass.async_add_executor_job( - self.device.appliance.set_setting, - self._color_key, - self._enable_custom_color_value_key, + await self.coordinator.client.set_setting( + self.appliance.info.ha_id, + setting_key=self._color_key, + value=self._enable_custom_color_value_key, ) except HomeConnectError as err: raise HomeAssistantError( @@ -181,10 +179,10 @@ class HomeConnectLight(HomeConnectEntity, LightEntity): if ATTR_RGB_COLOR in kwargs: hex_val = color_util.color_rgb_to_hex(*kwargs[ATTR_RGB_COLOR]) try: - await self.hass.async_add_executor_job( - self.device.appliance.set_setting, - self._custom_color_key, - f"#{hex_val}", + await self.coordinator.client.set_setting( + self.appliance.info.ha_id, + setting_key=self._custom_color_key, + value=f"#{hex_val}", ) except HomeConnectError as err: raise HomeAssistantError( @@ -195,10 +193,11 @@ class HomeConnectLight(HomeConnectEntity, LightEntity): SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID: self.entity_id, }, ) from err - elif (ATTR_BRIGHTNESS in kwargs or ATTR_HS_COLOR in kwargs) and ( - self._attr_brightness is not None or ATTR_BRIGHTNESS in kwargs + return + if (self._attr_brightness is not None or ATTR_BRIGHTNESS in kwargs) and ( + self._attr_hs_color is not None or ATTR_HS_COLOR in kwargs ): - brightness = 10 + ceil( + brightness = round( color_util.brightness_to_value( self._brightness_scale, kwargs.get(ATTR_BRIGHTNESS, self._attr_brightness), @@ -207,41 +206,36 @@ class HomeConnectLight(HomeConnectEntity, LightEntity): hs_color = kwargs.get(ATTR_HS_COLOR, self._attr_hs_color) - if hs_color is not None: - rgb = color_util.color_hsv_to_RGB( - hs_color[0], hs_color[1], brightness + rgb = color_util.color_hsv_to_RGB(hs_color[0], hs_color[1], brightness) + hex_val = color_util.color_rgb_to_hex(*rgb) + try: + await self.coordinator.client.set_setting( + self.appliance.info.ha_id, + setting_key=self._custom_color_key, + value=f"#{hex_val}", ) - hex_val = color_util.color_rgb_to_hex(*rgb) - try: - await self.hass.async_add_executor_job( - self.device.appliance.set_setting, - self._custom_color_key, - f"#{hex_val}", - ) - except HomeConnectError as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="set_light_color", - translation_placeholders={ - **get_dict_from_home_connect_error(err), - SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID: self.entity_id, - }, - ) from err + except HomeConnectError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="set_light_color", + translation_placeholders={ + **get_dict_from_home_connect_error(err), + SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID: self.entity_id, + }, + ) from err + return - elif self._brightness_key and ATTR_BRIGHTNESS in kwargs: - _LOGGER.debug( - "Changing brightness for: %s, to: %s", - self.name, - kwargs[ATTR_BRIGHTNESS], - ) - brightness = ceil( + if self._brightness_key and ATTR_BRIGHTNESS in kwargs: + brightness = round( color_util.brightness_to_value( self._brightness_scale, kwargs[ATTR_BRIGHTNESS] ) ) try: - await self.hass.async_add_executor_job( - self.device.appliance.set_setting, self._brightness_key, brightness + await self.coordinator.client.set_setting( + self.appliance.info.ha_id, + setting_key=self._brightness_key, + value=brightness, ) except HomeConnectError as err: raise HomeAssistantError( @@ -253,14 +247,13 @@ class HomeConnectLight(HomeConnectEntity, LightEntity): }, ) from err - self.async_entity_update() - async def async_turn_off(self, **kwargs: Any) -> None: """Switch the light off.""" - _LOGGER.debug("Switching light off for: %s", self.name) try: - await self.hass.async_add_executor_job( - self.device.appliance.set_setting, self.bsh_key, False + await self.coordinator.client.set_setting( + self.appliance.info.ha_id, + setting_key=SettingKey(self.bsh_key), + value=False, ) except HomeConnectError as err: raise HomeAssistantError( @@ -271,30 +264,50 @@ class HomeConnectLight(HomeConnectEntity, LightEntity): SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID: self.entity_id, }, ) from err - self.async_entity_update() - async def async_update(self) -> None: + async def async_added_to_hass(self) -> None: + """Register listener.""" + await super().async_added_to_hass() + keys_to_listen = [] + if self._brightness_key: + keys_to_listen.append(self._brightness_key) + if self._color_key and self._custom_color_key: + keys_to_listen.extend([self._color_key, self._custom_color_key]) + for key in keys_to_listen: + self.async_on_remove( + self.coordinator.async_add_listener( + self._handle_coordinator_update, + ( + self.appliance.info.ha_id, + EventKey(key), + ), + ) + ) + + def update_native_value(self) -> None: """Update the light's status.""" - if self.device.appliance.status.get(self.bsh_key, {}).get(ATTR_VALUE) is True: - self._attr_is_on = True - elif ( - self.device.appliance.status.get(self.bsh_key, {}).get(ATTR_VALUE) is False - ): - self._attr_is_on = False - else: - self._attr_is_on = None + self._attr_is_on = self.appliance.settings[SettingKey(self.bsh_key)].value - _LOGGER.debug("Updated, new light state: %s", self._attr_is_on) - - if self._custom_color_key: - color = self.device.appliance.status.get(self._custom_color_key, {}) - - if not color: + if self._brightness_key: + brightness = cast( + float, self.appliance.settings[self._brightness_key].value + ) + self._attr_brightness = color_util.value_to_brightness( + self._brightness_scale, brightness + ) + _LOGGER.debug( + "Updated %s, new brightness: %s", self.entity_id, self._attr_brightness + ) + if self._color_key and self._custom_color_key: + color = cast(str, self.appliance.settings[self._color_key].value) + if color != self._enable_custom_color_value_key: self._attr_rgb_color = None self._attr_hs_color = None - self._attr_brightness = None else: - color_value = color.get(ATTR_VALUE)[1:] + custom_color = cast( + str, self.appliance.settings[self._custom_color_key].value + ) + color_value = custom_color[1:] rgb = color_util.rgb_hex_to_rgb_list(color_value) self._attr_rgb_color = (rgb[0], rgb[1], rgb[2]) hsv = color_util.color_RGB_to_hsv(*rgb) @@ -303,16 +316,8 @@ class HomeConnectLight(HomeConnectEntity, LightEntity): self._brightness_scale, hsv[2] ) _LOGGER.debug( - "Updated, new color (%s) and new brightness (%s) ", + "Updated %s, new color (%s) and new brightness (%s) ", + self.entity_id, color_value, self._attr_brightness, ) - elif self._brightness_key: - brightness = self.device.appliance.status.get(self._brightness_key, {}) - if brightness is None: - self._attr_brightness = None - else: - self._attr_brightness = color_util.value_to_brightness( - self._brightness_scale, brightness[ATTR_VALUE] - ) - _LOGGER.debug("Updated, new brightness: %s", self._attr_brightness) diff --git a/homeassistant/components/home_connect/manifest.json b/homeassistant/components/home_connect/manifest.json index e041e13d36b..905a7c67f11 100644 --- a/homeassistant/components/home_connect/manifest.json +++ b/homeassistant/components/home_connect/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/home_connect", "iot_class": "cloud_push", "loggers": ["homeconnect"], - "requirements": ["homeconnect==0.8.0"] + "requirements": ["aiohomeconnect==0.12.1"] } diff --git a/homeassistant/components/home_connect/number.py b/homeassistant/components/home_connect/number.py index 0703b4772bb..7c6101950bf 100644 --- a/homeassistant/components/home_connect/number.py +++ b/homeassistant/components/home_connect/number.py @@ -1,12 +1,12 @@ """Provides number enties for Home Connect.""" import logging +from typing import cast -from homeconnect.api import HomeConnectError +from aiohomeconnect.model import GetSetting, SettingKey +from aiohomeconnect.model.error import HomeConnectError from homeassistant.components.number import ( - ATTR_MAX, - ATTR_MIN, NumberDeviceClass, NumberEntity, NumberEntityDescription, @@ -15,66 +15,63 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import HomeConnectConfigEntry, get_dict_from_home_connect_error from .const import ( - ATTR_CONSTRAINTS, - ATTR_STEPSIZE, - ATTR_UNIT, - ATTR_VALUE, DOMAIN, SVE_TRANSLATION_KEY_SET_SETTING, SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID, SVE_TRANSLATION_PLACEHOLDER_KEY, SVE_TRANSLATION_PLACEHOLDER_VALUE, ) +from .coordinator import HomeConnectConfigEntry from .entity import HomeConnectEntity +from .utils import get_dict_from_home_connect_error _LOGGER = logging.getLogger(__name__) NUMBERS = ( NumberEntityDescription( - key="Refrigeration.FridgeFreezer.Setting.SetpointTemperatureRefrigerator", + key=SettingKey.REFRIGERATION_FRIDGE_FREEZER_SETPOINT_TEMPERATURE_REFRIGERATOR, device_class=NumberDeviceClass.TEMPERATURE, translation_key="refrigerator_setpoint_temperature", ), NumberEntityDescription( - key="Refrigeration.FridgeFreezer.Setting.SetpointTemperatureFreezer", + key=SettingKey.REFRIGERATION_FRIDGE_FREEZER_SETPOINT_TEMPERATURE_FREEZER, device_class=NumberDeviceClass.TEMPERATURE, translation_key="freezer_setpoint_temperature", ), NumberEntityDescription( - key="Refrigeration.Common.Setting.BottleCooler.SetpointTemperature", + key=SettingKey.REFRIGERATION_COMMON_BOTTLE_COOLER_SETPOINT_TEMPERATURE, device_class=NumberDeviceClass.TEMPERATURE, translation_key="bottle_cooler_setpoint_temperature", ), NumberEntityDescription( - key="Refrigeration.Common.Setting.ChillerLeft.SetpointTemperature", + key=SettingKey.REFRIGERATION_COMMON_CHILLER_LEFT_SETPOINT_TEMPERATURE, device_class=NumberDeviceClass.TEMPERATURE, translation_key="chiller_left_setpoint_temperature", ), NumberEntityDescription( - key="Refrigeration.Common.Setting.ChillerCommon.SetpointTemperature", + key=SettingKey.REFRIGERATION_COMMON_CHILLER_COMMON_SETPOINT_TEMPERATURE, device_class=NumberDeviceClass.TEMPERATURE, translation_key="chiller_setpoint_temperature", ), NumberEntityDescription( - key="Refrigeration.Common.Setting.ChillerRight.SetpointTemperature", + key=SettingKey.REFRIGERATION_COMMON_CHILLER_RIGHT_SETPOINT_TEMPERATURE, device_class=NumberDeviceClass.TEMPERATURE, translation_key="chiller_right_setpoint_temperature", ), NumberEntityDescription( - key="Refrigeration.Common.Setting.WineCompartment.SetpointTemperature", + key=SettingKey.REFRIGERATION_COMMON_WINE_COMPARTMENT_SETPOINT_TEMPERATURE, device_class=NumberDeviceClass.TEMPERATURE, translation_key="wine_compartment_setpoint_temperature", ), NumberEntityDescription( - key="Refrigeration.Common.Setting.WineCompartment2.SetpointTemperature", + key=SettingKey.REFRIGERATION_COMMON_WINE_COMPARTMENT_2_SETPOINT_TEMPERATURE, device_class=NumberDeviceClass.TEMPERATURE, translation_key="wine_compartment_2_setpoint_temperature", ), NumberEntityDescription( - key="Refrigeration.Common.Setting.WineCompartment3.SetpointTemperature", + key=SettingKey.REFRIGERATION_COMMON_WINE_COMPARTMENT_3_SETPOINT_TEMPERATURE, device_class=NumberDeviceClass.TEMPERATURE, translation_key="wine_compartment_3_setpoint_temperature", ), @@ -87,17 +84,14 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Home Connect number.""" - - def get_entities() -> list[HomeConnectNumberEntity]: - """Get a list of entities.""" - return [ - HomeConnectNumberEntity(device, description) + async_add_entities( + [ + HomeConnectNumberEntity(entry.runtime_data, appliance, description) for description in NUMBERS - for device in entry.runtime_data.devices - if description.key in device.appliance.status - ] - - async_add_entities(await hass.async_add_executor_job(get_entities), True) + for appliance in entry.runtime_data.data.values() + if description.key in appliance.settings + ], + ) class HomeConnectNumberEntity(HomeConnectEntity, NumberEntity): @@ -112,10 +106,10 @@ class HomeConnectNumberEntity(HomeConnectEntity, NumberEntity): self.entity_id, ) try: - await self.hass.async_add_executor_job( - self.device.appliance.set_setting, - self.bsh_key, - value, + await self.coordinator.client.set_setting( + self.appliance.info.ha_id, + setting_key=SettingKey(self.bsh_key), + value=value, ) except HomeConnectError as err: raise HomeAssistantError( @@ -132,34 +126,41 @@ class HomeConnectNumberEntity(HomeConnectEntity, NumberEntity): async def async_fetch_constraints(self) -> None: """Fetch the max and min values and step for the number entity.""" try: - data = await self.hass.async_add_executor_job( - self.device.appliance.get, f"/settings/{self.bsh_key}" + data = await self.coordinator.client.get_setting( + self.appliance.info.ha_id, setting_key=SettingKey(self.bsh_key) ) except HomeConnectError as err: _LOGGER.error("An error occurred: %s", err) - return - if not data or not (constraints := data.get(ATTR_CONSTRAINTS)): - return - self._attr_native_max_value = constraints.get(ATTR_MAX) - self._attr_native_min_value = constraints.get(ATTR_MIN) - self._attr_native_step = constraints.get(ATTR_STEPSIZE) - self._attr_native_unit_of_measurement = data.get(ATTR_UNIT) + else: + self.set_constraints(data) - async def async_update(self) -> None: - """Update the number setting status.""" - if not (data := self.device.appliance.status.get(self.bsh_key)): - _LOGGER.error("No value for %s", self.bsh_key) - self._attr_native_value = None + def set_constraints(self, setting: GetSetting) -> None: + """Set constraints for the number entity.""" + if not (constraints := setting.constraints): return - self._attr_native_value = data.get(ATTR_VALUE, None) - _LOGGER.debug("Updated, new value: %s", self._attr_native_value) + if constraints.max: + self._attr_native_max_value = constraints.max + if constraints.min: + self._attr_native_min_value = constraints.min + if constraints.step_size: + self._attr_native_step = constraints.step_size + else: + self._attr_native_step = 0.1 if setting.type == "Double" else 1 + def update_native_value(self) -> None: + """Update status when an event for the entity is received.""" + data = self.appliance.settings[cast(SettingKey, self.bsh_key)] + self._attr_native_value = cast(float, data.value) + + async def async_added_to_hass(self) -> None: + """When entity is added to hass.""" + await super().async_added_to_hass() + data = self.appliance.settings[cast(SettingKey, self.bsh_key)] + self._attr_native_unit_of_measurement = data.unit + self.set_constraints(data) if ( not hasattr(self, "_attr_native_min_value") - or self._attr_native_min_value is None or not hasattr(self, "_attr_native_max_value") - or self._attr_native_max_value is None or not hasattr(self, "_attr_native_step") - or self._attr_native_step is None ): await self.async_fetch_constraints() diff --git a/homeassistant/components/home_connect/select.py b/homeassistant/components/home_connect/select.py index a4a5861afbe..c7408094aed 100644 --- a/homeassistant/components/home_connect/select.py +++ b/homeassistant/components/home_connect/select.py @@ -1,191 +1,28 @@ """Provides a select platform for Home Connect.""" -import contextlib -import logging +from typing import cast -from homeconnect.api import HomeConnectError +from aiohomeconnect.model import EventKey, ProgramKey +from aiohomeconnect.model.error import HomeConnectError from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import ( +from .const import APPLIANCES_WITH_PROGRAMS, DOMAIN, SVE_TRANSLATION_PLACEHOLDER_PROGRAM +from .coordinator import ( + HomeConnectApplianceData, HomeConnectConfigEntry, - bsh_key_to_translation_key, - get_dict_from_home_connect_error, -) -from .api import HomeConnectDevice -from .const import ( - APPLIANCES_WITH_PROGRAMS, - ATTR_VALUE, - BSH_ACTIVE_PROGRAM, - BSH_SELECTED_PROGRAM, - DOMAIN, - SVE_TRANSLATION_PLACEHOLDER_PROGRAM, + HomeConnectCoordinator, ) from .entity import HomeConnectEntity - -_LOGGER = logging.getLogger(__name__) +from .utils import bsh_key_to_translation_key, get_dict_from_home_connect_error TRANSLATION_KEYS_PROGRAMS_MAP = { - bsh_key_to_translation_key(program): program - for program in ( - "ConsumerProducts.CleaningRobot.Program.Cleaning.CleanAll", - "ConsumerProducts.CleaningRobot.Program.Cleaning.CleanMap", - "ConsumerProducts.CleaningRobot.Program.Basic.GoHome", - "ConsumerProducts.CoffeeMaker.Program.Beverage.Ristretto", - "ConsumerProducts.CoffeeMaker.Program.Beverage.Espresso", - "ConsumerProducts.CoffeeMaker.Program.Beverage.EspressoDoppio", - "ConsumerProducts.CoffeeMaker.Program.Beverage.Coffee", - "ConsumerProducts.CoffeeMaker.Program.Beverage.XLCoffee", - "ConsumerProducts.CoffeeMaker.Program.Beverage.CaffeGrande", - "ConsumerProducts.CoffeeMaker.Program.Beverage.EspressoMacchiato", - "ConsumerProducts.CoffeeMaker.Program.Beverage.Cappuccino", - "ConsumerProducts.CoffeeMaker.Program.Beverage.LatteMacchiato", - "ConsumerProducts.CoffeeMaker.Program.Beverage.CaffeLatte", - "ConsumerProducts.CoffeeMaker.Program.Beverage.MilkFroth", - "ConsumerProducts.CoffeeMaker.Program.Beverage.WarmMilk", - "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.KleinerBrauner", - "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.GrosserBrauner", - "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.Verlaengerter", - "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.VerlaengerterBraun", - "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.WienerMelange", - "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.FlatWhite", - "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.Cortado", - "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.CafeCortado", - "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.CafeConLeche", - "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.CafeAuLait", - "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.Doppio", - "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.Kaapi", - "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.KoffieVerkeerd", - "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.Galao", - "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.Garoto", - "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.Americano", - "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.RedEye", - "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.BlackEye", - "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.DeadEye", - "ConsumerProducts.CoffeeMaker.Program.Beverage.HotWater", - "Dishcare.Dishwasher.Program.PreRinse", - "Dishcare.Dishwasher.Program.Auto1", - "Dishcare.Dishwasher.Program.Auto2", - "Dishcare.Dishwasher.Program.Auto3", - "Dishcare.Dishwasher.Program.Eco50", - "Dishcare.Dishwasher.Program.Quick45", - "Dishcare.Dishwasher.Program.Intensiv70", - "Dishcare.Dishwasher.Program.Normal65", - "Dishcare.Dishwasher.Program.Glas40", - "Dishcare.Dishwasher.Program.GlassCare", - "Dishcare.Dishwasher.Program.NightWash", - "Dishcare.Dishwasher.Program.Quick65", - "Dishcare.Dishwasher.Program.Normal45", - "Dishcare.Dishwasher.Program.Intensiv45", - "Dishcare.Dishwasher.Program.AutoHalfLoad", - "Dishcare.Dishwasher.Program.IntensivPower", - "Dishcare.Dishwasher.Program.MagicDaily", - "Dishcare.Dishwasher.Program.Super60", - "Dishcare.Dishwasher.Program.Kurz60", - "Dishcare.Dishwasher.Program.ExpressSparkle65", - "Dishcare.Dishwasher.Program.MachineCare", - "Dishcare.Dishwasher.Program.SteamFresh", - "Dishcare.Dishwasher.Program.MaximumCleaning", - "Dishcare.Dishwasher.Program.MixedLoad", - "LaundryCare.Dryer.Program.Cotton", - "LaundryCare.Dryer.Program.Synthetic", - "LaundryCare.Dryer.Program.Mix", - "LaundryCare.Dryer.Program.Blankets", - "LaundryCare.Dryer.Program.BusinessShirts", - "LaundryCare.Dryer.Program.DownFeathers", - "LaundryCare.Dryer.Program.Hygiene", - "LaundryCare.Dryer.Program.Jeans", - "LaundryCare.Dryer.Program.Outdoor", - "LaundryCare.Dryer.Program.SyntheticRefresh", - "LaundryCare.Dryer.Program.Towels", - "LaundryCare.Dryer.Program.Delicates", - "LaundryCare.Dryer.Program.Super40", - "LaundryCare.Dryer.Program.Shirts15", - "LaundryCare.Dryer.Program.Pillow", - "LaundryCare.Dryer.Program.AntiShrink", - "LaundryCare.Dryer.Program.MyTime.MyDryingTime", - "LaundryCare.Dryer.Program.TimeCold", - "LaundryCare.Dryer.Program.TimeWarm", - "LaundryCare.Dryer.Program.InBasket", - "LaundryCare.Dryer.Program.TimeColdFix.TimeCold20", - "LaundryCare.Dryer.Program.TimeColdFix.TimeCold30", - "LaundryCare.Dryer.Program.TimeColdFix.TimeCold60", - "LaundryCare.Dryer.Program.TimeWarmFix.TimeWarm30", - "LaundryCare.Dryer.Program.TimeWarmFix.TimeWarm40", - "LaundryCare.Dryer.Program.TimeWarmFix.TimeWarm60", - "LaundryCare.Dryer.Program.Dessous", - "Cooking.Common.Program.Hood.Automatic", - "Cooking.Common.Program.Hood.Venting", - "Cooking.Common.Program.Hood.DelayedShutOff", - "Cooking.Oven.Program.HeatingMode.PreHeating", - "Cooking.Oven.Program.HeatingMode.HotAir", - "Cooking.Oven.Program.HeatingMode.HotAirEco", - "Cooking.Oven.Program.HeatingMode.HotAirGrilling", - "Cooking.Oven.Program.HeatingMode.TopBottomHeating", - "Cooking.Oven.Program.HeatingMode.TopBottomHeatingEco", - "Cooking.Oven.Program.HeatingMode.BottomHeating", - "Cooking.Oven.Program.HeatingMode.PizzaSetting", - "Cooking.Oven.Program.HeatingMode.SlowCook", - "Cooking.Oven.Program.HeatingMode.IntensiveHeat", - "Cooking.Oven.Program.HeatingMode.KeepWarm", - "Cooking.Oven.Program.HeatingMode.PreheatOvenware", - "Cooking.Oven.Program.HeatingMode.FrozenHeatupSpecial", - "Cooking.Oven.Program.HeatingMode.Desiccation", - "Cooking.Oven.Program.HeatingMode.Defrost", - "Cooking.Oven.Program.HeatingMode.Proof", - "Cooking.Oven.Program.HeatingMode.HotAir30Steam", - "Cooking.Oven.Program.HeatingMode.HotAir60Steam", - "Cooking.Oven.Program.HeatingMode.HotAir80Steam", - "Cooking.Oven.Program.HeatingMode.HotAir100Steam", - "Cooking.Oven.Program.HeatingMode.SabbathProgramme", - "Cooking.Oven.Program.Microwave.90Watt", - "Cooking.Oven.Program.Microwave.180Watt", - "Cooking.Oven.Program.Microwave.360Watt", - "Cooking.Oven.Program.Microwave.600Watt", - "Cooking.Oven.Program.Microwave.900Watt", - "Cooking.Oven.Program.Microwave.1000Watt", - "Cooking.Oven.Program.Microwave.Max", - "Cooking.Oven.Program.HeatingMode.WarmingDrawer", - "LaundryCare.Washer.Program.Cotton", - "LaundryCare.Washer.Program.Cotton.CottonEco", - "LaundryCare.Washer.Program.Cotton.Eco4060", - "LaundryCare.Washer.Program.Cotton.Colour", - "LaundryCare.Washer.Program.EasyCare", - "LaundryCare.Washer.Program.Mix", - "LaundryCare.Washer.Program.Mix.NightWash", - "LaundryCare.Washer.Program.DelicatesSilk", - "LaundryCare.Washer.Program.Wool", - "LaundryCare.Washer.Program.Sensitive", - "LaundryCare.Washer.Program.Auto30", - "LaundryCare.Washer.Program.Auto40", - "LaundryCare.Washer.Program.Auto60", - "LaundryCare.Washer.Program.Chiffon", - "LaundryCare.Washer.Program.Curtains", - "LaundryCare.Washer.Program.DarkWash", - "LaundryCare.Washer.Program.Dessous", - "LaundryCare.Washer.Program.Monsoon", - "LaundryCare.Washer.Program.Outdoor", - "LaundryCare.Washer.Program.PlushToy", - "LaundryCare.Washer.Program.ShirtsBlouses", - "LaundryCare.Washer.Program.SportFitness", - "LaundryCare.Washer.Program.Towels", - "LaundryCare.Washer.Program.WaterProof", - "LaundryCare.Washer.Program.PowerSpeed59", - "LaundryCare.Washer.Program.Super153045.Super15", - "LaundryCare.Washer.Program.Super153045.Super1530", - "LaundryCare.Washer.Program.DownDuvet.Duvet", - "LaundryCare.Washer.Program.Rinse.RinseSpinDrain", - "LaundryCare.Washer.Program.DrumClean", - "LaundryCare.WasherDryer.Program.Cotton", - "LaundryCare.WasherDryer.Program.Cotton.Eco4060", - "LaundryCare.WasherDryer.Program.Mix", - "LaundryCare.WasherDryer.Program.EasyCare", - "LaundryCare.WasherDryer.Program.WashAndDry60", - "LaundryCare.WasherDryer.Program.WashAndDry90", - ) + bsh_key_to_translation_key(program.value): cast(ProgramKey, program) + for program in ProgramKey + if program != ProgramKey.UNKNOWN } PROGRAMS_TRANSLATION_KEYS_MAP = { @@ -194,11 +31,11 @@ PROGRAMS_TRANSLATION_KEYS_MAP = { PROGRAM_SELECT_ENTITY_DESCRIPTIONS = ( SelectEntityDescription( - key=BSH_ACTIVE_PROGRAM, + key=EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM, translation_key="active_program", ), SelectEntityDescription( - key=BSH_SELECTED_PROGRAM, + key=EventKey.BSH_COMMON_ROOT_SELECTED_PROGRAM, translation_key="selected_program", ), ) @@ -211,31 +48,12 @@ async def async_setup_entry( ) -> None: """Set up the Home Connect select entities.""" - def get_entities() -> list[HomeConnectProgramSelectEntity]: - """Get a list of entities.""" - entities: list[HomeConnectProgramSelectEntity] = [] - programs_not_found = set() - for device in entry.runtime_data.devices: - if device.appliance.type in APPLIANCES_WITH_PROGRAMS: - with contextlib.suppress(HomeConnectError): - programs = device.appliance.get_programs_available() - if programs: - for program in programs.copy(): - if program not in PROGRAMS_TRANSLATION_KEYS_MAP: - programs.remove(program) - if program not in programs_not_found: - _LOGGER.info( - 'The program "%s" is not part of the official Home Connect API specification', - program, - ) - programs_not_found.add(program) - entities.extend( - HomeConnectProgramSelectEntity(device, programs, desc) - for desc in PROGRAM_SELECT_ENTITY_DESCRIPTIONS - ) - return entities - - async_add_entities(await hass.async_add_executor_job(get_entities), True) + async_add_entities( + HomeConnectProgramSelectEntity(entry.runtime_data, appliance, desc) + for appliance in entry.runtime_data.data.values() + for desc in PROGRAM_SELECT_ENTITY_DESCRIPTIONS + if appliance.info.type in APPLIANCES_WITH_PROGRAMS + ) class HomeConnectProgramSelectEntity(HomeConnectEntity, SelectEntity): @@ -243,48 +61,45 @@ class HomeConnectProgramSelectEntity(HomeConnectEntity, SelectEntity): def __init__( self, - device: HomeConnectDevice, - programs: list[str], + coordinator: HomeConnectCoordinator, + appliance: HomeConnectApplianceData, desc: SelectEntityDescription, ) -> None: """Initialize the entity.""" super().__init__( - device, + coordinator, + appliance, desc, ) self._attr_options = [ - PROGRAMS_TRANSLATION_KEYS_MAP[program] for program in programs + PROGRAMS_TRANSLATION_KEYS_MAP[program.key] + for program in appliance.programs + if program.key != ProgramKey.UNKNOWN ] - self.start_on_select = desc.key == BSH_ACTIVE_PROGRAM + self.start_on_select = desc.key == EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM + self._attr_current_option = None - async def async_update(self) -> None: - """Update the program selection status.""" - program = self.device.appliance.status.get(self.bsh_key, {}).get(ATTR_VALUE) - if not program: - program_translation_key = None - elif not ( - program_translation_key := PROGRAMS_TRANSLATION_KEYS_MAP.get(program) - ): - _LOGGER.debug( - 'The program "%s" is not part of the official Home Connect API specification', - program, - ) - self._attr_current_option = program_translation_key - _LOGGER.debug("Updated, new program: %s", self._attr_current_option) + def update_native_value(self) -> None: + """Set the program value.""" + event = self.appliance.events.get(cast(EventKey, self.bsh_key)) + self._attr_current_option = ( + PROGRAMS_TRANSLATION_KEYS_MAP.get(cast(ProgramKey, event.value)) + if event + else None + ) async def async_select_option(self, option: str) -> None: """Select new program.""" - bsh_key = TRANSLATION_KEYS_PROGRAMS_MAP[option] - _LOGGER.debug( - "Starting program: %s" if self.start_on_select else "Selecting program: %s", - bsh_key, - ) - if self.start_on_select: - target = self.device.appliance.start_program - else: - target = self.device.appliance.select_program + program_key = TRANSLATION_KEYS_PROGRAMS_MAP[option] try: - await self.hass.async_add_executor_job(target, bsh_key) + if self.start_on_select: + await self.coordinator.client.start_program( + self.appliance.info.ha_id, program_key=program_key + ) + else: + await self.coordinator.client.set_selected_program( + self.appliance.info.ha_id, program_key=program_key + ) except HomeConnectError as err: if self.start_on_select: translation_key = "start_program" @@ -295,7 +110,6 @@ class HomeConnectProgramSelectEntity(HomeConnectEntity, SelectEntity): translation_key=translation_key, translation_placeholders={ **get_dict_from_home_connect_error(err), - SVE_TRANSLATION_PLACEHOLDER_PROGRAM: bsh_key, + SVE_TRANSLATION_PLACEHOLDER_PROGRAM: program_key.value, }, ) from err - self.async_entity_update() diff --git a/homeassistant/components/home_connect/sensor.py b/homeassistant/components/home_connect/sensor.py index c11254d2c02..5e7c417a172 100644 --- a/homeassistant/components/home_connect/sensor.py +++ b/homeassistant/components/home_connect/sensor.py @@ -1,10 +1,11 @@ """Provides a sensor for Home Connect.""" from dataclasses import dataclass -from datetime import datetime, timedelta -import logging +from datetime import timedelta from typing import cast +from aiohomeconnect.model import EventKey, StatusKey + from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, @@ -12,38 +13,26 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.const import PERCENTAGE, UnitOfTime, UnitOfVolume -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import dt as dt_util, slugify -from . import HomeConnectConfigEntry from .const import ( APPLIANCES_WITH_PROGRAMS, - ATTR_VALUE, - BSH_DOOR_STATE, - BSH_OPERATION_STATE, BSH_OPERATION_STATE_FINISHED, BSH_OPERATION_STATE_PAUSE, BSH_OPERATION_STATE_RUN, - COFFEE_EVENT_BEAN_CONTAINER_EMPTY, - COFFEE_EVENT_DRIP_TRAY_FULL, - COFFEE_EVENT_WATER_TANK_EMPTY, - DISHWASHER_EVENT_RINSE_AID_NEARLY_EMPTY, - DISHWASHER_EVENT_SALT_NEARLY_EMPTY, - REFRIGERATION_EVENT_DOOR_ALARM_FREEZER, - REFRIGERATION_EVENT_DOOR_ALARM_REFRIGERATOR, - REFRIGERATION_EVENT_TEMP_ALARM_FREEZER, ) +from .coordinator import HomeConnectConfigEntry from .entity import HomeConnectEntity -_LOGGER = logging.getLogger(__name__) - - EVENT_OPTIONS = ["confirmed", "off", "present"] @dataclass(frozen=True, kw_only=True) -class HomeConnectSensorEntityDescription(SensorEntityDescription): +class HomeConnectSensorEntityDescription( + SensorEntityDescription, +): """Entity Description class for sensors.""" default_value: str | None = None @@ -52,7 +41,7 @@ class HomeConnectSensorEntityDescription(SensorEntityDescription): BSH_PROGRAM_SENSORS = ( HomeConnectSensorEntityDescription( - key="BSH.Common.Option.RemainingProgramTime", + key=EventKey.BSH_COMMON_OPTION_REMAINING_PROGRAM_TIME, device_class=SensorDeviceClass.TIMESTAMP, translation_key="program_finish_time", appliance_types=( @@ -67,13 +56,13 @@ BSH_PROGRAM_SENSORS = ( ), ), HomeConnectSensorEntityDescription( - key="BSH.Common.Option.Duration", + key=EventKey.BSH_COMMON_OPTION_DURATION, device_class=SensorDeviceClass.DURATION, native_unit_of_measurement=UnitOfTime.SECONDS, appliance_types=("Oven",), ), HomeConnectSensorEntityDescription( - key="BSH.Common.Option.ProgramProgress", + key=EventKey.BSH_COMMON_OPTION_PROGRAM_PROGRESS, native_unit_of_measurement=PERCENTAGE, translation_key="program_progress", appliance_types=APPLIANCES_WITH_PROGRAMS, @@ -82,7 +71,7 @@ BSH_PROGRAM_SENSORS = ( SENSORS = ( HomeConnectSensorEntityDescription( - key=BSH_OPERATION_STATE, + key=StatusKey.BSH_COMMON_OPERATION_STATE, device_class=SensorDeviceClass.ENUM, options=[ "inactive", @@ -98,7 +87,7 @@ SENSORS = ( translation_key="operation_state", ), HomeConnectSensorEntityDescription( - key=BSH_DOOR_STATE, + key=StatusKey.BSH_COMMON_DOOR_STATE, device_class=SensorDeviceClass.ENUM, options=[ "closed", @@ -108,59 +97,59 @@ SENSORS = ( translation_key="door", ), HomeConnectSensorEntityDescription( - key="ConsumerProducts.CoffeeMaker.Status.BeverageCounterCoffee", + key=StatusKey.CONSUMER_PRODUCTS_COFFEE_MAKER_BEVERAGE_COUNTER_COFFEE, state_class=SensorStateClass.TOTAL_INCREASING, translation_key="coffee_counter", ), HomeConnectSensorEntityDescription( - key="ConsumerProducts.CoffeeMaker.Status.BeverageCounterPowderCoffee", + key=StatusKey.CONSUMER_PRODUCTS_COFFEE_MAKER_BEVERAGE_COUNTER_POWDER_COFFEE, state_class=SensorStateClass.TOTAL_INCREASING, translation_key="powder_coffee_counter", ), HomeConnectSensorEntityDescription( - key="ConsumerProducts.CoffeeMaker.Status.BeverageCounterHotWater", + key=StatusKey.CONSUMER_PRODUCTS_COFFEE_MAKER_BEVERAGE_COUNTER_HOT_WATER, native_unit_of_measurement=UnitOfVolume.MILLILITERS, device_class=SensorDeviceClass.VOLUME, state_class=SensorStateClass.TOTAL_INCREASING, translation_key="hot_water_counter", ), HomeConnectSensorEntityDescription( - key="ConsumerProducts.CoffeeMaker.Status.BeverageCounterHotWaterCups", + key=StatusKey.CONSUMER_PRODUCTS_COFFEE_MAKER_BEVERAGE_COUNTER_HOT_WATER_CUPS, state_class=SensorStateClass.TOTAL_INCREASING, translation_key="hot_water_cups_counter", ), HomeConnectSensorEntityDescription( - key="ConsumerProducts.CoffeeMaker.Status.BeverageCounterHotMilk", + key=StatusKey.CONSUMER_PRODUCTS_COFFEE_MAKER_BEVERAGE_COUNTER_HOT_MILK, state_class=SensorStateClass.TOTAL_INCREASING, translation_key="hot_milk_counter", ), HomeConnectSensorEntityDescription( - key="ConsumerProducts.CoffeeMaker.Status.BeverageCounterFrothyMilk", + key=StatusKey.CONSUMER_PRODUCTS_COFFEE_MAKER_BEVERAGE_COUNTER_FROTHY_MILK, state_class=SensorStateClass.TOTAL_INCREASING, translation_key="frothy_milk_counter", ), HomeConnectSensorEntityDescription( - key="ConsumerProducts.CoffeeMaker.Status.BeverageCounterMilk", + key=StatusKey.CONSUMER_PRODUCTS_COFFEE_MAKER_BEVERAGE_COUNTER_MILK, state_class=SensorStateClass.TOTAL_INCREASING, translation_key="milk_counter", ), HomeConnectSensorEntityDescription( - key="ConsumerProducts.CoffeeMaker.Status.BeverageCounterCoffeeAndMilk", + key=StatusKey.CONSUMER_PRODUCTS_COFFEE_MAKER_BEVERAGE_COUNTER_COFFEE_AND_MILK, state_class=SensorStateClass.TOTAL_INCREASING, translation_key="coffee_and_milk_counter", ), HomeConnectSensorEntityDescription( - key="ConsumerProducts.CoffeeMaker.Status.BeverageCounterRistrettoEspresso", + key=StatusKey.CONSUMER_PRODUCTS_COFFEE_MAKER_BEVERAGE_COUNTER_RISTRETTO_ESPRESSO, state_class=SensorStateClass.TOTAL_INCREASING, translation_key="ristretto_espresso_counter", ), HomeConnectSensorEntityDescription( - key="BSH.Common.Status.BatteryLevel", + key=StatusKey.BSH_COMMON_BATTERY_LEVEL, device_class=SensorDeviceClass.BATTERY, translation_key="battery_level", ), HomeConnectSensorEntityDescription( - key="BSH.Common.Status.Video.CameraState", + key=StatusKey.BSH_COMMON_VIDEO_CAMERA_STATE, device_class=SensorDeviceClass.ENUM, options=[ "disabled", @@ -174,7 +163,7 @@ SENSORS = ( translation_key="camera_state", ), HomeConnectSensorEntityDescription( - key="ConsumerProducts.CleaningRobot.Status.LastSelectedMap", + key=StatusKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_LAST_SELECTED_MAP, device_class=SensorDeviceClass.ENUM, options=[ "tempmap", @@ -188,7 +177,7 @@ SENSORS = ( EVENT_SENSORS = ( HomeConnectSensorEntityDescription( - key=REFRIGERATION_EVENT_DOOR_ALARM_FREEZER, + key=EventKey.REFRIGERATION_FRIDGE_FREEZER_EVENT_DOOR_ALARM_FREEZER, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, default_value="off", @@ -196,7 +185,7 @@ EVENT_SENSORS = ( appliance_types=("FridgeFreezer", "Freezer"), ), HomeConnectSensorEntityDescription( - key=REFRIGERATION_EVENT_DOOR_ALARM_REFRIGERATOR, + key=EventKey.REFRIGERATION_FRIDGE_FREEZER_EVENT_DOOR_ALARM_REFRIGERATOR, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, default_value="off", @@ -204,7 +193,7 @@ EVENT_SENSORS = ( appliance_types=("FridgeFreezer", "Refrigerator"), ), HomeConnectSensorEntityDescription( - key=REFRIGERATION_EVENT_TEMP_ALARM_FREEZER, + key=EventKey.REFRIGERATION_FRIDGE_FREEZER_EVENT_TEMPERATURE_ALARM_FREEZER, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, default_value="off", @@ -212,7 +201,7 @@ EVENT_SENSORS = ( appliance_types=("FridgeFreezer", "Freezer"), ), HomeConnectSensorEntityDescription( - key=COFFEE_EVENT_BEAN_CONTAINER_EMPTY, + key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_BEAN_CONTAINER_EMPTY, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, default_value="off", @@ -220,7 +209,7 @@ EVENT_SENSORS = ( appliance_types=("CoffeeMaker",), ), HomeConnectSensorEntityDescription( - key=COFFEE_EVENT_WATER_TANK_EMPTY, + key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_WATER_TANK_EMPTY, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, default_value="off", @@ -228,7 +217,7 @@ EVENT_SENSORS = ( appliance_types=("CoffeeMaker",), ), HomeConnectSensorEntityDescription( - key=COFFEE_EVENT_DRIP_TRAY_FULL, + key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_DRIP_TRAY_FULL, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, default_value="off", @@ -236,7 +225,7 @@ EVENT_SENSORS = ( appliance_types=("CoffeeMaker",), ), HomeConnectSensorEntityDescription( - key=DISHWASHER_EVENT_SALT_NEARLY_EMPTY, + key=EventKey.DISHCARE_DISHWASHER_EVENT_SALT_NEARLY_EMPTY, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, default_value="off", @@ -244,7 +233,7 @@ EVENT_SENSORS = ( appliance_types=("Dishwasher",), ), HomeConnectSensorEntityDescription( - key=DISHWASHER_EVENT_RINSE_AID_NEARLY_EMPTY, + key=EventKey.DISHCARE_DISHWASHER_EVENT_RINSE_AID_NEARLY_EMPTY, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, default_value="off", @@ -261,33 +250,30 @@ async def async_setup_entry( ) -> None: """Set up the Home Connect sensor.""" - def get_entities() -> list[SensorEntity]: - """Get a list of entities.""" - entities: list[SensorEntity] = [] - for device in entry.runtime_data.devices: - entities.extend( - HomeConnectSensor( - device, - description, - ) - for description in EVENT_SENSORS - if description.appliance_types - and device.appliance.type in description.appliance_types + entities: list[SensorEntity] = [] + for appliance in entry.runtime_data.data.values(): + entities.extend( + HomeConnectEventSensor( + entry.runtime_data, + appliance, + description, ) - entities.extend( - HomeConnectProgramSensor(device, desc) - for desc in BSH_PROGRAM_SENSORS - if desc.appliance_types - and device.appliance.type in desc.appliance_types - ) - entities.extend( - HomeConnectSensor(device, description) - for description in SENSORS - if description.key in device.appliance.status - ) - return entities + for description in EVENT_SENSORS + if description.appliance_types + and appliance.info.type in description.appliance_types + ) + entities.extend( + HomeConnectProgramSensor(entry.runtime_data, appliance, desc) + for desc in BSH_PROGRAM_SENSORS + if desc.appliance_types and appliance.info.type in desc.appliance_types + ) + entities.extend( + HomeConnectSensor(entry.runtime_data, appliance, description) + for description in SENSORS + if description.key in appliance.status + ) - async_add_entities(await hass.async_add_executor_job(get_entities), True) + async_add_entities(entities) class HomeConnectSensor(HomeConnectEntity, SensorEntity): @@ -295,44 +281,25 @@ class HomeConnectSensor(HomeConnectEntity, SensorEntity): entity_description: HomeConnectSensorEntityDescription - async def async_update(self) -> None: - """Update the sensor's status.""" - appliance_status = self.device.appliance.status - if ( - self.bsh_key not in appliance_status - or ATTR_VALUE not in appliance_status[self.bsh_key] - ): - self._attr_native_value = self.entity_description.default_value - _LOGGER.debug("Updated, new state: %s", self._attr_native_value) - return - status = appliance_status[self.bsh_key] + def update_native_value(self) -> None: + """Set the value of the sensor.""" + status = self.appliance.status[cast(StatusKey, self.bsh_key)].value + self._update_native_value(status) + + def _update_native_value(self, status: str | float) -> None: + """Set the value of the sensor based on the given value.""" match self.device_class: case SensorDeviceClass.TIMESTAMP: - if ATTR_VALUE not in status: - self._attr_native_value = None - elif ( - self._attr_native_value is not None - and isinstance(self._attr_native_value, datetime) - and self._attr_native_value < dt_util.utcnow() - ): - # if the date is supposed to be in the future but we're - # already past it, set state to None. - self._attr_native_value = None - else: - seconds = float(status[ATTR_VALUE]) - self._attr_native_value = dt_util.utcnow() + timedelta( - seconds=seconds - ) + self._attr_native_value = dt_util.utcnow() + timedelta( + seconds=cast(float, status) + ) case SensorDeviceClass.ENUM: # Value comes back as an enum, we only really care about the # last part, so split it off # https://developer.home-connect.com/docs/status/operation_state - self._attr_native_value = slugify( - cast(str, status.get(ATTR_VALUE)).split(".")[-1] - ) + self._attr_native_value = slugify(cast(str, status).split(".")[-1]) case _: - self._attr_native_value = status.get(ATTR_VALUE) - _LOGGER.debug("Updated, new state: %s", self._attr_native_value) + self._attr_native_value = status class HomeConnectProgramSensor(HomeConnectSensor): @@ -340,6 +307,31 @@ class HomeConnectProgramSensor(HomeConnectSensor): program_running: bool = False + async def async_added_to_hass(self) -> None: + """Register listener.""" + await super().async_added_to_hass() + self.async_on_remove( + self.coordinator.async_add_listener( + self._handle_operation_state_event, + (self.appliance.info.ha_id, EventKey.BSH_COMMON_STATUS_OPERATION_STATE), + ) + ) + + @callback + def _handle_operation_state_event(self) -> None: + """Update status when an event for the entity is received.""" + self.program_running = ( + status := self.appliance.status.get(StatusKey.BSH_COMMON_OPERATION_STATE) + ) is not None and status.value in [ + BSH_OPERATION_STATE_RUN, + BSH_OPERATION_STATE_PAUSE, + BSH_OPERATION_STATE_FINISHED, + ] + if not self.program_running: + # reset the value when the program is not running, paused or finished + self._attr_native_value = None + self.async_write_ha_state() + @property def available(self) -> bool: """Return true if the sensor is available.""" @@ -347,20 +339,20 @@ class HomeConnectProgramSensor(HomeConnectSensor): # Otherwise, some sensors report erroneous values. return super().available and self.program_running - async def async_update(self) -> None: + def update_native_value(self) -> None: + """Update the program sensor's status.""" + event = self.appliance.events.get(cast(EventKey, self.bsh_key)) + if event: + self._update_native_value(event.value) + + +class HomeConnectEventSensor(HomeConnectSensor): + """Sensor class for Home Connect events.""" + + def update_native_value(self) -> None: """Update the sensor's status.""" - self.program_running = ( - BSH_OPERATION_STATE in (appliance_status := self.device.appliance.status) - and ATTR_VALUE in appliance_status[BSH_OPERATION_STATE] - and appliance_status[BSH_OPERATION_STATE][ATTR_VALUE] - in [ - BSH_OPERATION_STATE_RUN, - BSH_OPERATION_STATE_PAUSE, - BSH_OPERATION_STATE_FINISHED, - ] - ) - if self.program_running: - await super().async_update() - else: - # reset the value when the program is not running, paused or finished - self._attr_native_value = None + event = self.appliance.events.get(cast(EventKey, self.bsh_key)) + if event: + self._update_native_value(event.value) + elif not self._attr_native_value: + self._attr_native_value = self.entity_description.default_value diff --git a/homeassistant/components/home_connect/strings.json b/homeassistant/components/home_connect/strings.json index 7ededaae5b7..d163d04a6f7 100644 --- a/homeassistant/components/home_connect/strings.json +++ b/homeassistant/components/home_connect/strings.json @@ -26,64 +26,67 @@ "message": "Appliance for device ID {device_id} not found" }, "turn_on_light": { - "message": "Error turning on {entity_id}: {description}" + "message": "Error turning on {entity_id}: {error}" }, "turn_off_light": { - "message": "Error turning off {entity_id}: {description}" + "message": "Error turning off {entity_id}: {error}" }, "set_light_brightness": { - "message": "Error setting brightness of {entity_id}: {description}" + "message": "Error setting brightness of {entity_id}: {error}" }, "select_light_custom_color": { - "message": "Error selecting custom color of {entity_id}: {description}" + "message": "Error selecting custom color of {entity_id}: {error}" }, "set_light_color": { - "message": "Error setting color of {entity_id}: {description}" + "message": "Error setting color of {entity_id}: {error}" }, "set_setting_entity": { - "message": "Error assigning the value \"{value}\" to the setting \"{key}\" for {entity_id}: {description}" + "message": "Error assigning the value \"{value}\" to the setting \"{key}\" for {entity_id}: {error}" }, "set_setting": { - "message": "Error assigning the value \"{value}\" to the setting \"{key}\": {description}" + "message": "Error assigning the value \"{value}\" to the setting \"{key}\": {error}" }, "turn_on": { - "message": "Error turning on {entity_id} ({key}): {description}" + "message": "Error turning on {entity_id} ({key}): {error}" }, "turn_off": { - "message": "Error turning off {entity_id} ({key}): {description}" + "message": "Error turning off {entity_id} ({key}): {error}" }, "select_program": { - "message": "Error selecting program {program}: {description}" + "message": "Error selecting program {program}: {error}" }, "start_program": { - "message": "Error starting program {program}: {description}" + "message": "Error starting program {program}: {error}" }, "pause_program": { - "message": "Error pausing program: {description}" + "message": "Error pausing program: {error}" }, "stop_program": { - "message": "Error stopping program: {description}" + "message": "Error stopping program: {error}" }, "set_options_active_program": { - "message": "Error setting options for the active program: {description}" + "message": "Error setting options for the active program: {error}" }, "set_options_selected_program": { - "message": "Error setting options for the selected program: {description}" + "message": "Error setting options for the selected program: {error}" }, "execute_command": { - "message": "Error executing command {command}: {description}" + "message": "Error executing command {command}: {error}" }, "power_on": { - "message": "Error turning on {appliance_name}: {description}" + "message": "Error turning on {appliance_name}: {error}" }, "power_off": { - "message": "Error turning off {appliance_name} with value \"{value}\": {description}" + "message": "Error turning off {appliance_name} with value \"{value}\": {error}" }, "turn_off_not_supported": { "message": "{appliance_name} does not support turning off or entering standby mode." }, "unable_to_retrieve_turn_off": { "message": "Unable to turn off {appliance_name} because its support for turning off or entering standby mode could not be determined." + }, + "fetch_api_error": { + "message": "Error obtaining data from the API: {error}" } }, "issues": { diff --git a/homeassistant/components/home_connect/switch.py b/homeassistant/components/home_connect/switch.py index 1bd02e03eb1..c3a0858e0bb 100644 --- a/homeassistant/components/home_connect/switch.py +++ b/homeassistant/components/home_connect/switch.py @@ -1,10 +1,11 @@ """Provides a switch for Home Connect.""" -import contextlib import logging -from typing import Any +from typing import Any, cast -from homeconnect.api import HomeConnectError +from aiohomeconnect.model import EventKey, ProgramKey, SettingKey +from aiohomeconnect.model.error import HomeConnectError +from aiohomeconnect.model.program import EnumerateAvailableProgram from homeassistant.components.automation import automations_with_entity from homeassistant.components.script import scripts_with_entity @@ -18,87 +19,83 @@ from homeassistant.helpers.issue_registry import ( async_create_issue, async_delete_issue, ) +from homeassistant.helpers.typing import UNDEFINED, UndefinedType -from . import HomeConnectConfigEntry, get_dict_from_home_connect_error from .const import ( - APPLIANCES_WITH_PROGRAMS, - ATTR_ALLOWED_VALUES, - ATTR_CONSTRAINTS, - ATTR_VALUE, - BSH_ACTIVE_PROGRAM, - BSH_CHILD_LOCK_STATE, BSH_POWER_OFF, BSH_POWER_ON, BSH_POWER_STANDBY, - BSH_POWER_STATE, DOMAIN, - REFRIGERATION_DISPENSER, - REFRIGERATION_SUPERMODEFREEZER, - REFRIGERATION_SUPERMODEREFRIGERATOR, SVE_TRANSLATION_PLACEHOLDER_APPLIANCE_NAME, SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID, SVE_TRANSLATION_PLACEHOLDER_KEY, SVE_TRANSLATION_PLACEHOLDER_VALUE, ) -from .entity import HomeConnectDevice, HomeConnectEntity +from .coordinator import ( + HomeConnectApplianceData, + HomeConnectConfigEntry, + HomeConnectCoordinator, +) +from .entity import HomeConnectEntity +from .utils import get_dict_from_home_connect_error _LOGGER = logging.getLogger(__name__) SWITCHES = ( SwitchEntityDescription( - key=BSH_CHILD_LOCK_STATE, + key=SettingKey.BSH_COMMON_CHILD_LOCK, translation_key="child_lock", ), SwitchEntityDescription( - key="ConsumerProducts.CoffeeMaker.Setting.CupWarmer", + key=SettingKey.CONSUMER_PRODUCTS_COFFEE_MAKER_CUP_WARMER, translation_key="cup_warmer", ), SwitchEntityDescription( - key=REFRIGERATION_SUPERMODEFREEZER, + key=SettingKey.REFRIGERATION_FRIDGE_FREEZER_SUPER_MODE_FREEZER, translation_key="freezer_super_mode", ), SwitchEntityDescription( - key=REFRIGERATION_SUPERMODEREFRIGERATOR, + key=SettingKey.REFRIGERATION_FRIDGE_FREEZER_SUPER_MODE_REFRIGERATOR, translation_key="refrigerator_super_mode", ), SwitchEntityDescription( - key="Refrigeration.Common.Setting.EcoMode", + key=SettingKey.REFRIGERATION_COMMON_ECO_MODE, translation_key="eco_mode", ), SwitchEntityDescription( - key="Cooking.Oven.Setting.SabbathMode", + key=SettingKey.COOKING_OVEN_SABBATH_MODE, translation_key="sabbath_mode", ), SwitchEntityDescription( - key="Refrigeration.Common.Setting.SabbathMode", + key=SettingKey.REFRIGERATION_COMMON_SABBATH_MODE, translation_key="sabbath_mode", ), SwitchEntityDescription( - key="Refrigeration.Common.Setting.VacationMode", + key=SettingKey.REFRIGERATION_COMMON_VACATION_MODE, translation_key="vacation_mode", ), SwitchEntityDescription( - key="Refrigeration.Common.Setting.FreshMode", + key=SettingKey.REFRIGERATION_COMMON_FRESH_MODE, translation_key="fresh_mode", ), SwitchEntityDescription( - key=REFRIGERATION_DISPENSER, + key=SettingKey.REFRIGERATION_COMMON_DISPENSER_ENABLED, translation_key="dispenser_enabled", ), SwitchEntityDescription( - key="Refrigeration.Common.Setting.Door.AssistantFridge", + key=SettingKey.REFRIGERATION_COMMON_DOOR_ASSISTANT_FRIDGE, translation_key="door_assistant_fridge", ), SwitchEntityDescription( - key="Refrigeration.Common.Setting.Door.AssistantFreezer", + key=SettingKey.REFRIGERATION_COMMON_DOOR_ASSISTANT_FREEZER, translation_key="door_assistant_freezer", ), ) POWER_SWITCH_DESCRIPTION = SwitchEntityDescription( - key=BSH_POWER_STATE, + key=SettingKey.BSH_COMMON_POWER_STATE, translation_key="power", ) @@ -110,29 +107,26 @@ async def async_setup_entry( ) -> None: """Set up the Home Connect switch.""" - def get_entities() -> list[SwitchEntity]: - """Get a list of entities.""" - entities: list[SwitchEntity] = [] - for device in entry.runtime_data.devices: - if device.appliance.type in APPLIANCES_WITH_PROGRAMS: - with contextlib.suppress(HomeConnectError): - programs = device.appliance.get_programs_available() - if programs: - entities.extend( - HomeConnectProgramSwitch(device, program) - for program in programs - ) - if BSH_POWER_STATE in device.appliance.status: - entities.append(HomeConnectPowerSwitch(device)) - entities.extend( - HomeConnectSwitch(device, description) - for description in SWITCHES - if description.key in device.appliance.status + entities: list[SwitchEntity] = [] + for appliance in entry.runtime_data.data.values(): + entities.extend( + HomeConnectProgramSwitch(entry.runtime_data, appliance, program) + for program in appliance.programs + if program.key != ProgramKey.UNKNOWN + ) + if SettingKey.BSH_COMMON_POWER_STATE in appliance.settings: + entities.append( + HomeConnectPowerSwitch( + entry.runtime_data, appliance, POWER_SWITCH_DESCRIPTION + ) ) + entities.extend( + HomeConnectSwitch(entry.runtime_data, appliance, description) + for description in SWITCHES + if description.key in appliance.settings + ) - return entities - - async_add_entities(await hass.async_add_executor_job(get_entities), True) + async_add_entities(entities) class HomeConnectSwitch(HomeConnectEntity, SwitchEntity): @@ -140,11 +134,11 @@ class HomeConnectSwitch(HomeConnectEntity, SwitchEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Turn on setting.""" - - _LOGGER.debug("Turning on %s", self.entity_description.key) try: - await self.hass.async_add_executor_job( - self.device.appliance.set_setting, self.entity_description.key, True + await self.coordinator.client.set_setting( + self.appliance.info.ha_id, + setting_key=SettingKey(self.bsh_key), + value=True, ) except HomeConnectError as err: self._attr_available = False @@ -158,19 +152,15 @@ class HomeConnectSwitch(HomeConnectEntity, SwitchEntity): }, ) from err - self._attr_available = True - self.async_entity_update() - async def async_turn_off(self, **kwargs: Any) -> None: """Turn off setting.""" - - _LOGGER.debug("Turning off %s", self.entity_description.key) try: - await self.hass.async_add_executor_job( - self.device.appliance.set_setting, self.entity_description.key, False + await self.coordinator.client.set_setting( + self.appliance.info.ha_id, + setting_key=SettingKey(self.bsh_key), + value=False, ) except HomeConnectError as err: - _LOGGER.error("Error while trying to turn off: %s", err) self._attr_available = False raise HomeAssistantError( translation_domain=DOMAIN, @@ -182,38 +172,35 @@ class HomeConnectSwitch(HomeConnectEntity, SwitchEntity): }, ) from err - self._attr_available = True - self.async_entity_update() - - async def async_update(self) -> None: + def update_native_value(self) -> None: """Update the switch's status.""" - - self._attr_is_on = self.device.appliance.status.get( - self.entity_description.key, {} - ).get(ATTR_VALUE) - self._attr_available = True - _LOGGER.debug( - "Updated %s, new state: %s", - self.entity_description.key, - self._attr_is_on, - ) + self._attr_is_on = self.appliance.settings[SettingKey(self.bsh_key)].value class HomeConnectProgramSwitch(HomeConnectEntity, SwitchEntity): """Switch class for Home Connect.""" - def __init__(self, device: HomeConnectDevice, program_name: str) -> None: + def __init__( + self, + coordinator: HomeConnectCoordinator, + appliance: HomeConnectApplianceData, + program: EnumerateAvailableProgram, + ) -> None: """Initialize the entity.""" - desc = " ".join(["Program", program_name.split(".")[-1]]) - if device.appliance.type == "WasherDryer": + desc = " ".join(["Program", program.key.split(".")[-1]]) + if appliance.info.type == "WasherDryer": desc = " ".join( - ["Program", program_name.split(".")[-3], program_name.split(".")[-1]] + ["Program", program.key.split(".")[-3], program.key.split(".")[-1]] ) - super().__init__(device, SwitchEntityDescription(key=program_name)) - self._attr_name = f"{device.appliance.name} {desc}" - self._attr_unique_id = f"{device.appliance.haId}-{desc}" + super().__init__( + coordinator, + appliance, + SwitchEntityDescription(key=EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM), + ) + self._attr_name = f"{appliance.info.name} {desc}" + self._attr_unique_id = f"{appliance.info.ha_id}-{desc}" self._attr_has_entity_name = False - self.program_name = program_name + self.program = program async def async_added_to_hass(self) -> None: """Call when entity is added to hass.""" @@ -266,10 +253,9 @@ class HomeConnectProgramSwitch(HomeConnectEntity, SwitchEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Start the program.""" - _LOGGER.debug("Tried to turn on program %s", self.program_name) try: - await self.hass.async_add_executor_job( - self.device.appliance.start_program, self.program_name + await self.coordinator.client.start_program( + self.appliance.info.ha_id, program_key=self.program.key ) except HomeConnectError as err: raise HomeAssistantError( @@ -277,16 +263,14 @@ class HomeConnectProgramSwitch(HomeConnectEntity, SwitchEntity): translation_key="start_program", translation_placeholders={ **get_dict_from_home_connect_error(err), - "program": self.program_name, + "program": self.program.key, }, ) from err - self.async_entity_update() async def async_turn_off(self, **kwargs: Any) -> None: """Stop the program.""" - _LOGGER.debug("Tried to stop program %s", self.program_name) try: - await self.hass.async_add_executor_job(self.device.appliance.stop_program) + await self.coordinator.client.stop_program(self.appliance.info.ha_id) except HomeConnectError as err: raise HomeAssistantError( translation_domain=DOMAIN, @@ -295,48 +279,25 @@ class HomeConnectProgramSwitch(HomeConnectEntity, SwitchEntity): **get_dict_from_home_connect_error(err), }, ) from err - self.async_entity_update() - async def async_update(self) -> None: - """Update the switch's status.""" - state = self.device.appliance.status.get(BSH_ACTIVE_PROGRAM, {}) - if state.get(ATTR_VALUE) == self.program_name: - self._attr_is_on = True - else: - self._attr_is_on = False - _LOGGER.debug("Updated, new state: %s", self._attr_is_on) + def update_native_value(self) -> None: + """Update the switch's status based on if the program related to this entity is currently active.""" + event = self.appliance.events.get(EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM) + self._attr_is_on = bool(event and event.value == self.program.key) class HomeConnectPowerSwitch(HomeConnectEntity, SwitchEntity): """Power switch class for Home Connect.""" - power_off_state: str | None - - def __init__(self, device: HomeConnectDevice) -> None: - """Initialize the entity.""" - super().__init__( - device, - POWER_SWITCH_DESCRIPTION, - ) - if ( - power_state := device.appliance.status.get(BSH_POWER_STATE, {}).get( - ATTR_VALUE - ) - ) and power_state in [BSH_POWER_OFF, BSH_POWER_STANDBY]: - self.power_off_state = power_state - - async def async_added_to_hass(self) -> None: - """Add the entity to the hass instance.""" - await super().async_added_to_hass() - if not hasattr(self, "power_off_state"): - await self.async_fetch_power_off_state() + power_off_state: str | None | UndefinedType = UNDEFINED async def async_turn_on(self, **kwargs: Any) -> None: """Switch the device on.""" - _LOGGER.debug("Tried to switch on %s", self.name) try: - await self.hass.async_add_executor_job( - self.device.appliance.set_setting, BSH_POWER_STATE, BSH_POWER_ON + await self.coordinator.client.set_setting( + self.appliance.info.ha_id, + setting_key=SettingKey.BSH_COMMON_POWER_STATE, + value=BSH_POWER_ON, ) except HomeConnectError as err: self._attr_is_on = False @@ -345,36 +306,36 @@ class HomeConnectPowerSwitch(HomeConnectEntity, SwitchEntity): translation_key="power_on", translation_placeholders={ **get_dict_from_home_connect_error(err), - SVE_TRANSLATION_PLACEHOLDER_APPLIANCE_NAME: self.device.appliance.name, + SVE_TRANSLATION_PLACEHOLDER_APPLIANCE_NAME: self.appliance.info.name, }, ) from err - self.async_entity_update() async def async_turn_off(self, **kwargs: Any) -> None: """Switch the device off.""" - if not hasattr(self, "power_off_state"): - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="unable_to_retrieve_turn_off", - translation_placeholders={ - SVE_TRANSLATION_PLACEHOLDER_APPLIANCE_NAME: self.device.appliance.name - }, - ) + if self.power_off_state is UNDEFINED: + await self.async_fetch_power_off_state() + if self.power_off_state is UNDEFINED: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="unable_to_retrieve_turn_off", + translation_placeholders={ + SVE_TRANSLATION_PLACEHOLDER_APPLIANCE_NAME: self.appliance.info.name + }, + ) if self.power_off_state is None: raise HomeAssistantError( translation_domain=DOMAIN, translation_key="turn_off_not_supported", translation_placeholders={ - SVE_TRANSLATION_PLACEHOLDER_APPLIANCE_NAME: self.device.appliance.name + SVE_TRANSLATION_PLACEHOLDER_APPLIANCE_NAME: self.appliance.info.name }, ) - _LOGGER.debug("tried to switch off %s", self.name) try: - await self.hass.async_add_executor_job( - self.device.appliance.set_setting, - BSH_POWER_STATE, - self.power_off_state, + await self.coordinator.client.set_setting( + self.appliance.info.ha_id, + setting_key=SettingKey.BSH_COMMON_POWER_STATE, + value=self.power_off_state, ) except HomeConnectError as err: self._attr_is_on = True @@ -383,46 +344,51 @@ class HomeConnectPowerSwitch(HomeConnectEntity, SwitchEntity): translation_key="power_off", translation_placeholders={ **get_dict_from_home_connect_error(err), - SVE_TRANSLATION_PLACEHOLDER_APPLIANCE_NAME: self.device.appliance.name, + SVE_TRANSLATION_PLACEHOLDER_APPLIANCE_NAME: self.appliance.info.name, SVE_TRANSLATION_PLACEHOLDER_VALUE: self.power_off_state, }, ) from err - self.async_entity_update() - async def async_update(self) -> None: - """Update the switch's status.""" - if ( - self.device.appliance.status.get(BSH_POWER_STATE, {}).get(ATTR_VALUE) - == BSH_POWER_ON - ): + def update_native_value(self) -> None: + """Set the value of the entity.""" + power_state = self.appliance.settings[SettingKey.BSH_COMMON_POWER_STATE] + value = cast(str, power_state.value) + if value == BSH_POWER_ON: self._attr_is_on = True elif ( - hasattr(self, "power_off_state") - and self.device.appliance.status.get(BSH_POWER_STATE, {}).get(ATTR_VALUE) - == self.power_off_state + isinstance(self.power_off_state, str) + and self.power_off_state + and value == self.power_off_state ): self._attr_is_on = False + elif self.power_off_state is UNDEFINED and value in [ + BSH_POWER_OFF, + BSH_POWER_STANDBY, + ]: + self.power_off_state = value + self._attr_is_on = False else: self._attr_is_on = None - _LOGGER.debug("Updated, new state: %s", self._attr_is_on) async def async_fetch_power_off_state(self) -> None: """Fetch the power off state.""" - try: - data = await self.hass.async_add_executor_job( - self.device.appliance.get, f"/settings/{self.bsh_key}" - ) - except HomeConnectError as err: - _LOGGER.error("An error occurred: %s", err) - return - if not data or not ( - allowed_values := data.get(ATTR_CONSTRAINTS, {}).get(ATTR_ALLOWED_VALUES) - ): + data = self.appliance.settings[SettingKey.BSH_COMMON_POWER_STATE] + + if not data.constraints or not data.constraints.allowed_values: + try: + data = await self.coordinator.client.get_setting( + self.appliance.info.ha_id, + setting_key=SettingKey.BSH_COMMON_POWER_STATE, + ) + except HomeConnectError as err: + _LOGGER.error("An error occurred fetching the power settings: %s", err) + return + if not data.constraints or not data.constraints.allowed_values: return - if BSH_POWER_OFF in allowed_values: + if BSH_POWER_OFF in data.constraints.allowed_values: self.power_off_state = BSH_POWER_OFF - elif BSH_POWER_STANDBY in allowed_values: + elif BSH_POWER_STANDBY in data.constraints.allowed_values: self.power_off_state = BSH_POWER_STANDBY else: self.power_off_state = None diff --git a/homeassistant/components/home_connect/time.py b/homeassistant/components/home_connect/time.py index c1f125cd2f7..5ed07424082 100644 --- a/homeassistant/components/home_connect/time.py +++ b/homeassistant/components/home_connect/time.py @@ -1,32 +1,30 @@ """Provides time enties for Home Connect.""" from datetime import time -import logging +from typing import cast -from homeconnect.api import HomeConnectError +from aiohomeconnect.model import SettingKey +from aiohomeconnect.model.error import HomeConnectError from homeassistant.components.time import TimeEntity, TimeEntityDescription from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import HomeConnectConfigEntry, get_dict_from_home_connect_error from .const import ( - ATTR_VALUE, DOMAIN, SVE_TRANSLATION_KEY_SET_SETTING, SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID, SVE_TRANSLATION_PLACEHOLDER_KEY, SVE_TRANSLATION_PLACEHOLDER_VALUE, ) +from .coordinator import HomeConnectConfigEntry from .entity import HomeConnectEntity - -_LOGGER = logging.getLogger(__name__) - +from .utils import get_dict_from_home_connect_error TIME_ENTITIES = ( TimeEntityDescription( - key="BSH.Common.Setting.AlarmClock", + key=SettingKey.BSH_COMMON_ALARM_CLOCK, translation_key="alarm_clock", ), ) @@ -39,16 +37,14 @@ async def async_setup_entry( ) -> None: """Set up the Home Connect switch.""" - def get_entities() -> list[HomeConnectTimeEntity]: - """Get a list of entities.""" - return [ - HomeConnectTimeEntity(device, description) + async_add_entities( + [ + HomeConnectTimeEntity(entry.runtime_data, appliance, description) for description in TIME_ENTITIES - for device in entry.runtime_data.devices - if description.key in device.appliance.status - ] - - async_add_entities(await hass.async_add_executor_job(get_entities), True) + for appliance in entry.runtime_data.data.values() + if description.key in appliance.settings + ], + ) def seconds_to_time(seconds: int) -> time: @@ -68,17 +64,11 @@ class HomeConnectTimeEntity(HomeConnectEntity, TimeEntity): async def async_set_value(self, value: time) -> None: """Set the native value of the entity.""" - _LOGGER.debug( - "Tried to set value %s to %s for %s", - value, - self.bsh_key, - self.entity_id, - ) try: - await self.hass.async_add_executor_job( - self.device.appliance.set_setting, - self.bsh_key, - time_to_seconds(value), + await self.coordinator.client.set_setting( + self.appliance.info.ha_id, + setting_key=SettingKey(self.bsh_key), + value=time_to_seconds(value), ) except HomeConnectError as err: raise HomeAssistantError( @@ -92,16 +82,7 @@ class HomeConnectTimeEntity(HomeConnectEntity, TimeEntity): }, ) from err - async def async_update(self) -> None: - """Update the Time setting status.""" - data = self.device.appliance.status.get(self.bsh_key) - if data is None: - _LOGGER.error("No value for %s", self.bsh_key) - self._attr_native_value = None - return - seconds = data.get(ATTR_VALUE, None) - if seconds is not None: - self._attr_native_value = seconds_to_time(seconds) - else: - self._attr_native_value = None - _LOGGER.debug("Updated, new value: %s", self._attr_native_value) + def update_native_value(self) -> None: + """Set the value of the entity.""" + data = self.appliance.settings[cast(SettingKey, self.bsh_key)] + self._attr_native_value = seconds_to_time(data.value) diff --git a/homeassistant/components/home_connect/utils.py b/homeassistant/components/home_connect/utils.py new file mode 100644 index 00000000000..108465072e1 --- /dev/null +++ b/homeassistant/components/home_connect/utils.py @@ -0,0 +1,29 @@ +"""Utility functions for Home Connect.""" + +import re + +from aiohomeconnect.model.error import HomeConnectApiError, HomeConnectError + +RE_CAMEL_CASE = re.compile(r"(? dict[str, str]: + """Return a translation string from a Home Connect error.""" + return { + "error": str(err) + if isinstance(err, HomeConnectApiError) + else type(err).__name__ + } + + +def bsh_key_to_translation_key(bsh_key: str) -> str: + """Convert a BSH key to a translation key format. + + This function takes a BSH key, such as `Dishcare.Dishwasher.Program.Eco50`, + and converts it to a translation key format, such as `dishcare_dishwasher_bsh_key_eco50`. + """ + return "_".join( + RE_CAMEL_CASE.sub("_", split) for split in bsh_key.split(".") + ).lower() diff --git a/requirements_all.txt b/requirements_all.txt index 9e6da1045a4..731b1cdeb67 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -263,6 +263,9 @@ aioharmony==0.4.1 # homeassistant.components.hassio aiohasupervisor==0.2.2b6 +# homeassistant.components.home_connect +aiohomeconnect==0.12.1 + # homeassistant.components.homekit_controller aiohomekit==3.2.7 @@ -1148,9 +1151,6 @@ home-assistant-frontend==20250129.0 # homeassistant.components.conversation home-assistant-intents==2025.1.28 -# homeassistant.components.home_connect -homeconnect==0.8.0 - # homeassistant.components.homematicip_cloud homematicip==1.1.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 76ae46099c2..db89f8db9d0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -248,6 +248,9 @@ aioharmony==0.4.1 # homeassistant.components.hassio aiohasupervisor==0.2.2b6 +# homeassistant.components.home_connect +aiohomeconnect==0.12.1 + # homeassistant.components.homekit_controller aiohomekit==3.2.7 @@ -977,9 +980,6 @@ home-assistant-frontend==20250129.0 # homeassistant.components.conversation home-assistant-intents==2025.1.28 -# homeassistant.components.home_connect -homeconnect==0.8.0 - # homeassistant.components.homematicip_cloud homematicip==1.1.7 diff --git a/tests/components/home_connect/conftest.py b/tests/components/home_connect/conftest.py index 2ac8c851e1b..af039f04c03 100644 --- a/tests/components/home_connect/conftest.py +++ b/tests/components/home_connect/conftest.py @@ -1,18 +1,32 @@ """Test fixtures for home_connect.""" -from collections.abc import Awaitable, Callable, Generator +import asyncio +from collections.abc import AsyncGenerator, Awaitable, Callable +import copy import time -from typing import Any -from unittest.mock import MagicMock, Mock, PropertyMock, patch +from typing import Any, cast +from unittest.mock import AsyncMock, MagicMock, patch -from homeconnect.api import HomeConnectAppliance, HomeConnectError +from aiohomeconnect.client import Client as HomeConnectClient +from aiohomeconnect.model import ( + ArrayOfAvailablePrograms, + ArrayOfEvents, + ArrayOfHomeAppliances, + ArrayOfSettings, + ArrayOfStatus, + Event, + EventKey, + EventMessage, + EventType, + Option, +) +from aiohomeconnect.model.error import HomeConnectApiError, HomeConnectError import pytest from homeassistant.components.application_credentials import ( ClientCredential, async_import_client_credential, ) -from homeassistant.components.home_connect import update_all_devices from homeassistant.components.home_connect.const import DOMAIN from homeassistant.const import Platform from homeassistant.core import HomeAssistant @@ -20,12 +34,17 @@ from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry, load_json_object_fixture -MOCK_APPLIANCES_PROPERTIES = { - x["name"]: x - for x in load_json_object_fixture("home_connect/appliances.json")["data"][ - "homeappliances" - ] -} +MOCK_APPLIANCES = ArrayOfHomeAppliances.from_dict( + load_json_object_fixture("home_connect/appliances.json")["data"] +) +MOCK_PROGRAMS: dict[str, Any] = load_json_object_fixture( + "home_connect/programs-available.json" +) +MOCK_SETTINGS: dict[str, Any] = load_json_object_fixture("home_connect/settings.json") +MOCK_STATUS = ArrayOfStatus.from_dict( + load_json_object_fixture("home_connect/status.json")["data"] +) + CLIENT_ID = "1234" CLIENT_SECRET = "5678" @@ -102,32 +121,23 @@ def platforms() -> list[Platform]: return [] -async def bypass_throttle(hass: HomeAssistant, config_entry: MockConfigEntry): - """Add kwarg to disable throttle.""" - await update_all_devices(hass, config_entry, no_throttle=True) - - -@pytest.fixture(name="bypass_throttle") -def mock_bypass_throttle() -> Generator[None]: - """Fixture to bypass the throttle decorator in __init__.""" - with patch( - "homeassistant.components.home_connect.update_all_devices", - side_effect=bypass_throttle, - ): - yield - - @pytest.fixture(name="integration_setup") async def mock_integration_setup( hass: HomeAssistant, platforms: list[Platform], config_entry: MockConfigEntry, -) -> Callable[[], Awaitable[bool]]: +) -> Callable[[MagicMock], Awaitable[bool]]: """Fixture to set up the integration.""" config_entry.add_to_hass(hass) - async def run() -> bool: - with patch("homeassistant.components.home_connect.PLATFORMS", platforms): + async def run(client: MagicMock) -> bool: + with ( + patch("homeassistant.components.home_connect.PLATFORMS", platforms), + patch( + "homeassistant.components.home_connect.HomeConnectClient" + ) as client_mock, + ): + client_mock.return_value = client result = await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() return result @@ -135,125 +145,205 @@ async def mock_integration_setup( return run -@pytest.fixture(name="get_appliances") -def mock_get_appliances() -> Generator[MagicMock]: - """Mock ConfigEntryAuth parent (HomeAssistantAPI) method.""" - with patch( - "homeassistant.components.home_connect.api.ConfigEntryAuth.get_appliances", - ) as mock: - yield mock +def _get_set_program_side_effect( + event_queue: asyncio.Queue[list[EventMessage]], event_key: EventKey +): + """Set program side effect.""" + + async def set_program_side_effect(ha_id: str, *_, **kwargs) -> None: + await event_queue.put( + [ + EventMessage( + ha_id, + EventType.NOTIFY, + ArrayOfEvents( + [ + Event( + key=event_key, + raw_key=event_key.value, + timestamp=0, + level="", + handling="", + value=str(kwargs["program_key"]), + ), + *[ + Event( + key=(option_event := EventKey(option.key)), + raw_key=option_event.value, + timestamp=0, + level="", + handling="", + value=str(option.key), + ) + for option in cast( + list[Option], kwargs.get("options", []) + ) + ], + ] + ), + ), + ] + ) + + return set_program_side_effect -@pytest.fixture(name="appliance") -def mock_appliance(request: pytest.FixtureRequest) -> MagicMock: +def _get_set_key_value_side_effect( + event_queue: asyncio.Queue[list[EventMessage]], parameter_key: str +): + """Set program options side effect.""" + + async def set_key_value_side_effect(ha_id: str, *_, **kwargs) -> None: + event_key = EventKey(kwargs[parameter_key]) + await event_queue.put( + [ + EventMessage( + ha_id, + EventType.NOTIFY, + ArrayOfEvents( + [ + Event( + key=event_key, + raw_key=event_key.value, + timestamp=0, + level="", + handling="", + value=kwargs["value"], + ) + ] + ), + ), + ] + ) + + return set_key_value_side_effect + + +async def _get_available_programs_side_effect(ha_id: str) -> ArrayOfAvailablePrograms: + """Get available programs.""" + appliance_type = next( + appliance + for appliance in MOCK_APPLIANCES.homeappliances + if appliance.ha_id == ha_id + ).type + if appliance_type not in MOCK_PROGRAMS: + raise HomeConnectApiError("error.key", "error description") + + return ArrayOfAvailablePrograms.from_dict(MOCK_PROGRAMS[appliance_type]["data"]) + + +async def _get_settings_side_effect(ha_id: str) -> ArrayOfSettings: + """Get settings.""" + return ArrayOfSettings.from_dict( + MOCK_SETTINGS.get( + next( + appliance + for appliance in MOCK_APPLIANCES.homeappliances + if appliance.ha_id == ha_id + ).type, + {}, + ).get("data", {"settings": []}) + ) + + +@pytest.fixture(name="client") +def mock_client(request: pytest.FixtureRequest) -> MagicMock: + """Fixture to mock Client from HomeConnect.""" + + mock = MagicMock( + autospec=HomeConnectClient, + ) + + event_queue: asyncio.Queue[list[EventMessage]] = asyncio.Queue() + + async def add_events(events: list[EventMessage]) -> None: + await event_queue.put(events) + + mock.add_events = add_events + + async def stream_all_events() -> AsyncGenerator[EventMessage]: + """Mock stream_all_events.""" + while True: + for event in await event_queue.get(): + yield event + + mock.get_home_appliances = AsyncMock(return_value=MOCK_APPLIANCES) + mock.stream_all_events = stream_all_events + mock.start_program = AsyncMock( + side_effect=_get_set_program_side_effect( + event_queue, EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM + ) + ) + mock.set_selected_program = AsyncMock( + side_effect=_get_set_program_side_effect( + event_queue, EventKey.BSH_COMMON_ROOT_SELECTED_PROGRAM + ), + ) + mock.set_active_program_option = AsyncMock( + side_effect=_get_set_key_value_side_effect(event_queue, "option_key"), + ) + mock.set_selected_program_option = AsyncMock( + side_effect=_get_set_key_value_side_effect(event_queue, "option_key"), + ) + mock.set_setting = AsyncMock( + side_effect=_get_set_key_value_side_effect(event_queue, "setting_key"), + ) + mock.get_settings = AsyncMock(side_effect=_get_settings_side_effect) + mock.get_status = AsyncMock(return_value=copy.deepcopy(MOCK_STATUS)) + mock.get_available_programs = AsyncMock( + side_effect=_get_available_programs_side_effect + ) + mock.put_command = AsyncMock() + + mock.side_effect = mock + return mock + + +@pytest.fixture(name="client_with_exception") +def mock_client_with_exception(request: pytest.FixtureRequest) -> MagicMock: + """Fixture to mock Client from HomeConnect that raise exceptions.""" + mock = MagicMock( + autospec=HomeConnectClient, + ) + + exception = HomeConnectError() + if hasattr(request, "param") and request.param: + exception = request.param + + event_queue: asyncio.Queue[list[EventMessage]] = asyncio.Queue() + + async def stream_all_events() -> AsyncGenerator[EventMessage]: + """Mock stream_all_events.""" + while True: + for event in await event_queue.get(): + yield event + + mock.get_home_appliances = AsyncMock(return_value=MOCK_APPLIANCES) + mock.stream_all_events = stream_all_events + + mock.start_program = AsyncMock(side_effect=exception) + mock.stop_program = AsyncMock(side_effect=exception) + mock.get_available_programs = AsyncMock(side_effect=exception) + mock.set_selected_program = AsyncMock(side_effect=exception) + mock.set_active_program_option = AsyncMock(side_effect=exception) + mock.set_selected_program_option = AsyncMock(side_effect=exception) + mock.set_setting = AsyncMock(side_effect=exception) + mock.get_settings = AsyncMock(side_effect=exception) + mock.get_setting = AsyncMock(side_effect=exception) + mock.get_status = AsyncMock(side_effect=exception) + mock.get_available_programs = AsyncMock(side_effect=exception) + mock.put_command = AsyncMock(side_effect=exception) + + return mock + + +@pytest.fixture(name="appliance_ha_id") +def mock_appliance_ha_id(request: pytest.FixtureRequest) -> str: """Fixture to mock Appliance.""" app = "Washer" if hasattr(request, "param") and request.param: app = request.param - - mock = MagicMock( - autospec=HomeConnectAppliance, - **MOCK_APPLIANCES_PROPERTIES.get(app), - ) - mock.name = app - type(mock).status = PropertyMock(return_value={}) - mock.get.return_value = {} - mock.get_programs_available.return_value = [] - mock.get_status.return_value = {} - mock.get_settings.return_value = {} - - return mock - - -@pytest.fixture(name="problematic_appliance") -def mock_problematic_appliance(request: pytest.FixtureRequest) -> Mock: - """Fixture to mock a problematic Appliance.""" - app = "Washer" - if hasattr(request, "param") and request.param: - app = request.param - - mock = Mock( - autospec=HomeConnectAppliance, - **MOCK_APPLIANCES_PROPERTIES.get(app), - ) - mock.name = app - type(mock).status = PropertyMock(return_value={}) - mock.get.side_effect = HomeConnectError - mock.get_programs_active.side_effect = HomeConnectError - mock.get_programs_available.side_effect = HomeConnectError - mock.start_program.side_effect = HomeConnectError - mock.select_program.side_effect = HomeConnectError - mock.pause_program.side_effect = HomeConnectError - mock.stop_program.side_effect = HomeConnectError - mock.set_options_active_program.side_effect = HomeConnectError - mock.set_options_selected_program.side_effect = HomeConnectError - mock.get_status.side_effect = HomeConnectError - mock.get_settings.side_effect = HomeConnectError - mock.set_setting.side_effect = HomeConnectError - mock.set_setting.side_effect = HomeConnectError - mock.execute_command.side_effect = HomeConnectError - - return mock - - -def get_all_appliances(): - """Return a list of `HomeConnectAppliance` instances for all appliances.""" - - appliances = {} - - data = load_json_object_fixture("home_connect/appliances.json").get("data") - programs_active = load_json_object_fixture("home_connect/programs-active.json") - programs_available = load_json_object_fixture( - "home_connect/programs-available.json" - ) - - def listen_callback(mock, callback): - callback["callback"](mock) - - for home_appliance in data["homeappliances"]: - api_status = load_json_object_fixture("home_connect/status.json") - api_settings = load_json_object_fixture("home_connect/settings.json") - - ha_id = home_appliance["haId"] - ha_type = home_appliance["type"] - - appliance = MagicMock(spec=HomeConnectAppliance, **home_appliance) - appliance.name = home_appliance["name"] - appliance.listen_events.side_effect = ( - lambda app=appliance, **x: listen_callback(app, x) - ) - appliance.get_programs_active.return_value = programs_active.get( - ha_type, {} - ).get("data", {}) - appliance.get_programs_available.return_value = [ - program["key"] - for program in programs_available.get(ha_type, {}) - .get("data", {}) - .get("programs", []) - ] - appliance.get_status.return_value = HomeConnectAppliance.json2dict( - api_status.get("data", {}).get("status", []) - ) - appliance.get_settings.return_value = HomeConnectAppliance.json2dict( - api_settings.get(ha_type, {}).get("data", {}).get("settings", []) - ) - setattr(appliance, "status", {}) - appliance.status.update(appliance.get_status.return_value) - appliance.status.update(appliance.get_settings.return_value) - appliance.set_setting.side_effect = ( - lambda x, y, appliance=appliance: appliance.status.update({x: {"value": y}}) - ) - appliance.start_program.side_effect = ( - lambda x, appliance=appliance: appliance.status.update( - {"BSH.Common.Root.ActiveProgram": {"value": x}} - ) - ) - appliance.stop_program.side_effect = ( - lambda appliance=appliance: appliance.status.update( - {"BSH.Common.Root.ActiveProgram": {}} - ) - ) - - appliances[ha_id] = appliance - - return list(appliances.values()) + for appliance in MOCK_APPLIANCES.homeappliances: + if appliance.type == app: + return appliance.ha_id + raise ValueError(f"Appliance {app} not found") diff --git a/tests/components/home_connect/fixtures/settings.json b/tests/components/home_connect/fixtures/settings.json index 1b9bec57276..a357d8fb43e 100644 --- a/tests/components/home_connect/fixtures/settings.json +++ b/tests/components/home_connect/fixtures/settings.json @@ -2,6 +2,11 @@ "Dishwasher": { "data": { "settings": [ + { + "key": "BSH.Common.Setting.ChildLock", + "value": false, + "type": "Boolean" + }, { "key": "BSH.Common.Setting.AmbientLightEnabled", "value": true, @@ -26,7 +31,13 @@ { "key": "BSH.Common.Setting.PowerState", "value": "BSH.Common.EnumType.PowerState.On", - "type": "BSH.Common.EnumType.PowerState" + "type": "BSH.Common.EnumType.PowerState", + "constraints": { + "allowedvalues": [ + "BSH.Common.EnumType.PowerState.On", + "BSH.Common.EnumType.PowerState.Off" + ] + } }, { "key": "BSH.Common.Setting.ChildLock", @@ -92,6 +103,11 @@ "key": "BSH.Common.Setting.PowerState", "value": "BSH.Common.EnumType.PowerState.On", "type": "BSH.Common.EnumType.PowerState" + }, + { + "key": "BSH.Common.Setting.AlarmClock", + "value": 0, + "type": "Integer" } ] } @@ -154,6 +170,12 @@ "max": 100, "access": "readWrite" } + }, + { + "key": "Refrigeration.FridgeFreezer.Setting.SetpointTemperatureRefrigerator", + "value": 8, + "unit": "°C", + "type": "Double" } ] } diff --git a/tests/components/home_connect/snapshots/test_diagnostics.ambr b/tests/components/home_connect/snapshots/test_diagnostics.ambr index f3131eac52f..f3c73a32d95 100644 --- a/tests/components/home_connect/snapshots/test_diagnostics.ambr +++ b/tests/components/home_connect/snapshots/test_diagnostics.ambr @@ -2,255 +2,209 @@ # name: test_async_get_config_entry_diagnostics dict({ 'BOSCH-000000000-000000000000': dict({ + 'brand': 'BOSCH', 'connected': True, + 'e_number': 'HCS000000/00', + 'ha_id': 'BOSCH-000000000-000000000000', + 'name': 'DNE', 'programs': list([ ]), - 'status': dict({ - 'BSH.Common.Status.DoorState': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Closed', - }), - 'BSH.Common.Status.OperationState': dict({ - 'value': 'BSH.Common.EnumType.OperationState.Ready', - }), - 'BSH.Common.Status.RemoteControlActive': dict({ - 'value': True, - }), - 'BSH.Common.Status.RemoteControlStartAllowed': dict({ - 'value': True, - }), - 'Refrigeration.Common.Status.Door.Refrigerator': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Open', - }), + 'settings': dict({ }), + 'status': dict({ + 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed', + 'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready', + 'BSH.Common.Status.RemoteControlActive': True, + 'BSH.Common.Status.RemoteControlStartAllowed': True, + 'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open', + }), + 'type': 'DNE', + 'vib': 'HCS000000', }), 'BOSCH-HCS000000-D00000000001': dict({ + 'brand': 'BOSCH', 'connected': True, + 'e_number': 'HCS000000/01', + 'ha_id': 'BOSCH-HCS000000-D00000000001', + 'name': 'WasherDryer', 'programs': list([ 'LaundryCare.WasherDryer.Program.Mix', 'LaundryCare.Washer.Option.Temperature', ]), - 'status': dict({ - 'BSH.Common.Status.DoorState': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Closed', - }), - 'BSH.Common.Status.OperationState': dict({ - 'value': 'BSH.Common.EnumType.OperationState.Ready', - }), - 'BSH.Common.Status.RemoteControlActive': dict({ - 'value': True, - }), - 'BSH.Common.Status.RemoteControlStartAllowed': dict({ - 'value': True, - }), - 'Refrigeration.Common.Status.Door.Refrigerator': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Open', - }), + 'settings': dict({ }), + 'status': dict({ + 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed', + 'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready', + 'BSH.Common.Status.RemoteControlActive': True, + 'BSH.Common.Status.RemoteControlStartAllowed': True, + 'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open', + }), + 'type': 'WasherDryer', + 'vib': 'HCS000001', }), 'BOSCH-HCS000000-D00000000002': dict({ + 'brand': 'BOSCH', 'connected': True, + 'e_number': 'HCS000000/02', + 'ha_id': 'BOSCH-HCS000000-D00000000002', + 'name': 'Refrigerator', 'programs': list([ ]), - 'status': dict({ - 'BSH.Common.Status.DoorState': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Closed', - }), - 'BSH.Common.Status.OperationState': dict({ - 'value': 'BSH.Common.EnumType.OperationState.Ready', - }), - 'BSH.Common.Status.RemoteControlActive': dict({ - 'value': True, - }), - 'BSH.Common.Status.RemoteControlStartAllowed': dict({ - 'value': True, - }), - 'Refrigeration.Common.Status.Door.Refrigerator': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Open', - }), + 'settings': dict({ }), + 'status': dict({ + 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed', + 'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready', + 'BSH.Common.Status.RemoteControlActive': True, + 'BSH.Common.Status.RemoteControlStartAllowed': True, + 'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open', + }), + 'type': 'Refrigerator', + 'vib': 'HCS000002', }), 'BOSCH-HCS000000-D00000000003': dict({ + 'brand': 'BOSCH', 'connected': True, + 'e_number': 'HCS000000/03', + 'ha_id': 'BOSCH-HCS000000-D00000000003', + 'name': 'Freezer', 'programs': list([ ]), - 'status': dict({ - 'BSH.Common.Status.DoorState': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Closed', - }), - 'BSH.Common.Status.OperationState': dict({ - 'value': 'BSH.Common.EnumType.OperationState.Ready', - }), - 'BSH.Common.Status.RemoteControlActive': dict({ - 'value': True, - }), - 'BSH.Common.Status.RemoteControlStartAllowed': dict({ - 'value': True, - }), - 'Refrigeration.Common.Status.Door.Refrigerator': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Open', - }), + 'settings': dict({ }), + 'status': dict({ + 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed', + 'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready', + 'BSH.Common.Status.RemoteControlActive': True, + 'BSH.Common.Status.RemoteControlStartAllowed': True, + 'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open', + }), + 'type': 'Freezer', + 'vib': 'HCS000003', }), 'BOSCH-HCS000000-D00000000004': dict({ + 'brand': 'BOSCH', 'connected': True, + 'e_number': 'HCS000000/04', + 'ha_id': 'BOSCH-HCS000000-D00000000004', + 'name': 'Hood', 'programs': list([ ]), - 'status': dict({ - 'BSH.Common.Setting.AmbientLightBrightness': dict({ - 'type': 'Double', - 'unit': '%', - 'value': 70, - }), - 'BSH.Common.Setting.AmbientLightColor': dict({ - 'type': 'BSH.Common.EnumType.AmbientLightColor', - 'value': 'BSH.Common.EnumType.AmbientLightColor.Color43', - }), - 'BSH.Common.Setting.AmbientLightCustomColor': dict({ - 'type': 'String', - 'value': '#4a88f8', - }), - 'BSH.Common.Setting.AmbientLightEnabled': dict({ - 'type': 'Boolean', - 'value': True, - }), - 'BSH.Common.Setting.ColorTemperature': dict({ - 'type': 'BSH.Common.EnumType.ColorTemperature', - 'value': 'Cooking.Hood.EnumType.ColorTemperature.warmToNeutral', - }), - 'BSH.Common.Status.DoorState': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Closed', - }), - 'BSH.Common.Status.OperationState': dict({ - 'value': 'BSH.Common.EnumType.OperationState.Ready', - }), - 'BSH.Common.Status.RemoteControlActive': dict({ - 'value': True, - }), - 'BSH.Common.Status.RemoteControlStartAllowed': dict({ - 'value': True, - }), - 'Cooking.Common.Setting.Lighting': dict({ - 'type': 'Boolean', - 'value': True, - }), - 'Cooking.Common.Setting.LightingBrightness': dict({ - 'type': 'Double', - 'unit': '%', - 'value': 70, - }), - 'Cooking.Hood.Setting.ColorTemperaturePercent': dict({ - 'type': 'Double', - 'unit': '%', - 'value': 70, - }), - 'Refrigeration.Common.Status.Door.Refrigerator': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Open', - }), + 'settings': dict({ + 'BSH.Common.Setting.AmbientLightBrightness': 70, + 'BSH.Common.Setting.AmbientLightColor': 'BSH.Common.EnumType.AmbientLightColor.Color43', + 'BSH.Common.Setting.AmbientLightCustomColor': '#4a88f8', + 'BSH.Common.Setting.AmbientLightEnabled': True, + 'Cooking.Common.Setting.Lighting': True, + 'Cooking.Common.Setting.LightingBrightness': 70, + 'Cooking.Hood.Setting.ColorTemperaturePercent': 70, + 'unknown': 'Cooking.Hood.EnumType.ColorTemperature.warmToNeutral', }), + 'status': dict({ + 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed', + 'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready', + 'BSH.Common.Status.RemoteControlActive': True, + 'BSH.Common.Status.RemoteControlStartAllowed': True, + 'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open', + }), + 'type': 'Hood', + 'vib': 'HCS000004', }), 'BOSCH-HCS000000-D00000000005': dict({ + 'brand': 'BOSCH', 'connected': True, + 'e_number': 'HCS000000/05', + 'ha_id': 'BOSCH-HCS000000-D00000000005', + 'name': 'Hob', 'programs': list([ ]), - 'status': dict({ - 'BSH.Common.Status.DoorState': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Closed', - }), - 'BSH.Common.Status.OperationState': dict({ - 'value': 'BSH.Common.EnumType.OperationState.Ready', - }), - 'BSH.Common.Status.RemoteControlActive': dict({ - 'value': True, - }), - 'BSH.Common.Status.RemoteControlStartAllowed': dict({ - 'value': True, - }), - 'Refrigeration.Common.Status.Door.Refrigerator': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Open', - }), + 'settings': dict({ }), + 'status': dict({ + 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed', + 'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready', + 'BSH.Common.Status.RemoteControlActive': True, + 'BSH.Common.Status.RemoteControlStartAllowed': True, + 'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open', + }), + 'type': 'Hob', + 'vib': 'HCS000005', }), 'BOSCH-HCS000000-D00000000006': dict({ + 'brand': 'BOSCH', 'connected': True, + 'e_number': 'HCS000000/06', + 'ha_id': 'BOSCH-HCS000000-D00000000006', + 'name': 'CookProcessor', 'programs': list([ ]), - 'status': dict({ - 'BSH.Common.Status.DoorState': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Closed', - }), - 'BSH.Common.Status.OperationState': dict({ - 'value': 'BSH.Common.EnumType.OperationState.Ready', - }), - 'BSH.Common.Status.RemoteControlActive': dict({ - 'value': True, - }), - 'BSH.Common.Status.RemoteControlStartAllowed': dict({ - 'value': True, - }), - 'Refrigeration.Common.Status.Door.Refrigerator': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Open', - }), + 'settings': dict({ }), + 'status': dict({ + 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed', + 'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready', + 'BSH.Common.Status.RemoteControlActive': True, + 'BSH.Common.Status.RemoteControlStartAllowed': True, + 'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open', + }), + 'type': 'CookProcessor', + 'vib': 'HCS000006', }), 'BOSCH-HCS01OVN1-43E0065FE245': dict({ + 'brand': 'BOSCH', 'connected': True, + 'e_number': 'HCS01OVN1/03', + 'ha_id': 'BOSCH-HCS01OVN1-43E0065FE245', + 'name': 'Oven', 'programs': list([ 'Cooking.Oven.Program.HeatingMode.HotAir', 'Cooking.Oven.Program.HeatingMode.TopBottomHeating', 'Cooking.Oven.Program.HeatingMode.PizzaSetting', ]), - 'status': dict({ - 'BSH.Common.Root.ActiveProgram': dict({ - 'value': 'Cooking.Oven.Program.HeatingMode.HotAir', - }), - 'BSH.Common.Setting.PowerState': dict({ - 'type': 'BSH.Common.EnumType.PowerState', - 'value': 'BSH.Common.EnumType.PowerState.On', - }), - 'BSH.Common.Status.DoorState': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Closed', - }), - 'BSH.Common.Status.OperationState': dict({ - 'value': 'BSH.Common.EnumType.OperationState.Ready', - }), - 'BSH.Common.Status.RemoteControlActive': dict({ - 'value': True, - }), - 'BSH.Common.Status.RemoteControlStartAllowed': dict({ - 'value': True, - }), - 'Refrigeration.Common.Status.Door.Refrigerator': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Open', - }), + 'settings': dict({ + 'BSH.Common.Setting.AlarmClock': 0, + 'BSH.Common.Setting.PowerState': 'BSH.Common.EnumType.PowerState.On', }), + 'status': dict({ + 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed', + 'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready', + 'BSH.Common.Status.RemoteControlActive': True, + 'BSH.Common.Status.RemoteControlStartAllowed': True, + 'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open', + }), + 'type': 'Oven', + 'vib': 'HCS01OVN1', }), 'BOSCH-HCS04DYR1-831694AE3C5A': dict({ + 'brand': 'BOSCH', 'connected': True, + 'e_number': 'HCS04DYR1/03', + 'ha_id': 'BOSCH-HCS04DYR1-831694AE3C5A', + 'name': 'Dryer', 'programs': list([ 'LaundryCare.Dryer.Program.Cotton', 'LaundryCare.Dryer.Program.Synthetic', 'LaundryCare.Dryer.Program.Mix', ]), - 'status': dict({ - 'BSH.Common.Status.DoorState': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Closed', - }), - 'BSH.Common.Status.OperationState': dict({ - 'value': 'BSH.Common.EnumType.OperationState.Ready', - }), - 'BSH.Common.Status.RemoteControlActive': dict({ - 'value': True, - }), - 'BSH.Common.Status.RemoteControlStartAllowed': dict({ - 'value': True, - }), - 'Refrigeration.Common.Status.Door.Refrigerator': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Open', - }), + 'settings': dict({ }), + 'status': dict({ + 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed', + 'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready', + 'BSH.Common.Status.RemoteControlActive': True, + 'BSH.Common.Status.RemoteControlStartAllowed': True, + 'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open', + }), + 'type': 'Dryer', + 'vib': 'HCS04DYR1', }), 'BOSCH-HCS06COM1-D70390681C2C': dict({ + 'brand': 'BOSCH', 'connected': True, + 'e_number': 'HCS06COM1/03', + 'ha_id': 'BOSCH-HCS06COM1-D70390681C2C', + 'name': 'CoffeeMaker', 'programs': list([ 'ConsumerProducts.CoffeeMaker.Program.Beverage.Espresso', 'ConsumerProducts.CoffeeMaker.Program.Beverage.EspressoMacchiato', @@ -259,26 +213,24 @@ 'ConsumerProducts.CoffeeMaker.Program.Beverage.LatteMacchiato', 'ConsumerProducts.CoffeeMaker.Program.Beverage.CaffeLatte', ]), - 'status': dict({ - 'BSH.Common.Status.DoorState': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Closed', - }), - 'BSH.Common.Status.OperationState': dict({ - 'value': 'BSH.Common.EnumType.OperationState.Ready', - }), - 'BSH.Common.Status.RemoteControlActive': dict({ - 'value': True, - }), - 'BSH.Common.Status.RemoteControlStartAllowed': dict({ - 'value': True, - }), - 'Refrigeration.Common.Status.Door.Refrigerator': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Open', - }), + 'settings': dict({ }), + 'status': dict({ + 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed', + 'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready', + 'BSH.Common.Status.RemoteControlActive': True, + 'BSH.Common.Status.RemoteControlStartAllowed': True, + 'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open', + }), + 'type': 'CoffeeMaker', + 'vib': 'HCS06COM1', }), 'SIEMENS-HCS02DWH1-6BE58C26DCC1': dict({ + 'brand': 'SIEMENS', 'connected': True, + 'e_number': 'HCS02DWH1/03', + 'ha_id': 'SIEMENS-HCS02DWH1-6BE58C26DCC1', + 'name': 'Dishwasher', 'programs': list([ 'Dishcare.Dishwasher.Program.Auto1', 'Dishcare.Dishwasher.Program.Auto2', @@ -286,51 +238,30 @@ 'Dishcare.Dishwasher.Program.Eco50', 'Dishcare.Dishwasher.Program.Quick45', ]), - 'status': dict({ - 'BSH.Common.Setting.AmbientLightBrightness': dict({ - 'type': 'Double', - 'unit': '%', - 'value': 70, - }), - 'BSH.Common.Setting.AmbientLightColor': dict({ - 'type': 'BSH.Common.EnumType.AmbientLightColor', - 'value': 'BSH.Common.EnumType.AmbientLightColor.Color43', - }), - 'BSH.Common.Setting.AmbientLightCustomColor': dict({ - 'type': 'String', - 'value': '#4a88f8', - }), - 'BSH.Common.Setting.AmbientLightEnabled': dict({ - 'type': 'Boolean', - 'value': True, - }), - 'BSH.Common.Setting.ChildLock': dict({ - 'type': 'Boolean', - 'value': False, - }), - 'BSH.Common.Setting.PowerState': dict({ - 'type': 'BSH.Common.EnumType.PowerState', - 'value': 'BSH.Common.EnumType.PowerState.On', - }), - 'BSH.Common.Status.DoorState': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Closed', - }), - 'BSH.Common.Status.OperationState': dict({ - 'value': 'BSH.Common.EnumType.OperationState.Ready', - }), - 'BSH.Common.Status.RemoteControlActive': dict({ - 'value': True, - }), - 'BSH.Common.Status.RemoteControlStartAllowed': dict({ - 'value': True, - }), - 'Refrigeration.Common.Status.Door.Refrigerator': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Open', - }), + 'settings': dict({ + 'BSH.Common.Setting.AmbientLightBrightness': 70, + 'BSH.Common.Setting.AmbientLightColor': 'BSH.Common.EnumType.AmbientLightColor.Color43', + 'BSH.Common.Setting.AmbientLightCustomColor': '#4a88f8', + 'BSH.Common.Setting.AmbientLightEnabled': True, + 'BSH.Common.Setting.ChildLock': False, + 'BSH.Common.Setting.PowerState': 'BSH.Common.EnumType.PowerState.On', }), + 'status': dict({ + 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed', + 'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready', + 'BSH.Common.Status.RemoteControlActive': True, + 'BSH.Common.Status.RemoteControlStartAllowed': True, + 'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open', + }), + 'type': 'Dishwasher', + 'vib': 'HCS02DWH1', }), 'SIEMENS-HCS03WCH1-7BC6383CF794': dict({ + 'brand': 'SIEMENS', 'connected': True, + 'e_number': 'HCS03WCH1/03', + 'ha_id': 'SIEMENS-HCS03WCH1-7BC6383CF794', + 'name': 'Washer', 'programs': list([ 'LaundryCare.Washer.Program.Cotton', 'LaundryCare.Washer.Program.EasyCare', @@ -338,97 +269,55 @@ 'LaundryCare.Washer.Program.DelicatesSilk', 'LaundryCare.Washer.Program.Wool', ]), - 'status': dict({ - 'BSH.Common.Root.ActiveProgram': dict({ - 'value': 'BSH.Common.Root.ActiveProgram', - }), - 'BSH.Common.Setting.ChildLock': dict({ - 'type': 'Boolean', - 'value': False, - }), - 'BSH.Common.Setting.PowerState': dict({ - 'type': 'BSH.Common.EnumType.PowerState', - 'value': 'BSH.Common.EnumType.PowerState.On', - }), - 'BSH.Common.Status.DoorState': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Closed', - }), - 'BSH.Common.Status.OperationState': dict({ - 'value': 'BSH.Common.EnumType.OperationState.Ready', - }), - 'BSH.Common.Status.RemoteControlActive': dict({ - 'value': True, - }), - 'BSH.Common.Status.RemoteControlStartAllowed': dict({ - 'value': True, - }), - 'Refrigeration.Common.Status.Door.Refrigerator': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Open', - }), + 'settings': dict({ + 'BSH.Common.Setting.ChildLock': False, + 'BSH.Common.Setting.PowerState': 'BSH.Common.EnumType.PowerState.On', }), + 'status': dict({ + 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed', + 'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready', + 'BSH.Common.Status.RemoteControlActive': True, + 'BSH.Common.Status.RemoteControlStartAllowed': True, + 'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open', + }), + 'type': 'Washer', + 'vib': 'HCS03WCH1', }), 'SIEMENS-HCS05FRF1-304F4F9E541D': dict({ + 'brand': 'SIEMENS', 'connected': True, + 'e_number': 'HCS05FRF1/03', + 'ha_id': 'SIEMENS-HCS05FRF1-304F4F9E541D', + 'name': 'FridgeFreezer', 'programs': list([ ]), - 'status': dict({ - 'BSH.Common.Status.DoorState': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Closed', - }), - 'BSH.Common.Status.OperationState': dict({ - 'value': 'BSH.Common.EnumType.OperationState.Ready', - }), - 'BSH.Common.Status.RemoteControlActive': dict({ - 'value': True, - }), - 'BSH.Common.Status.RemoteControlStartAllowed': dict({ - 'value': True, - }), - 'Refrigeration.Common.Setting.Dispenser.Enabled': dict({ - 'constraints': dict({ - 'access': 'readWrite', - }), - 'type': 'Boolean', - 'value': False, - }), - 'Refrigeration.Common.Setting.Light.External.Brightness': dict({ - 'constraints': dict({ - 'access': 'readWrite', - 'max': 100, - 'min': 0, - }), - 'type': 'Double', - 'unit': '%', - 'value': 70, - }), - 'Refrigeration.Common.Setting.Light.External.Power': dict({ - 'type': 'Boolean', - 'value': True, - }), - 'Refrigeration.Common.Status.Door.Refrigerator': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Open', - }), - 'Refrigeration.FridgeFreezer.Setting.SuperModeFreezer': dict({ - 'constraints': dict({ - 'access': 'readWrite', - }), - 'type': 'Boolean', - 'value': False, - }), - 'Refrigeration.FridgeFreezer.Setting.SuperModeRefrigerator': dict({ - 'constraints': dict({ - 'access': 'readWrite', - }), - 'type': 'Boolean', - 'value': False, - }), + 'settings': dict({ + 'Refrigeration.Common.Setting.Dispenser.Enabled': False, + 'Refrigeration.Common.Setting.Light.External.Brightness': 70, + 'Refrigeration.Common.Setting.Light.External.Power': True, + 'Refrigeration.FridgeFreezer.Setting.SetpointTemperatureRefrigerator': 8, + 'Refrigeration.FridgeFreezer.Setting.SuperModeFreezer': False, + 'Refrigeration.FridgeFreezer.Setting.SuperModeRefrigerator': False, }), + 'status': dict({ + 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed', + 'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready', + 'BSH.Common.Status.RemoteControlActive': True, + 'BSH.Common.Status.RemoteControlStartAllowed': True, + 'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open', + }), + 'type': 'FridgeFreezer', + 'vib': 'HCS05FRF1', }), }) # --- # name: test_async_get_device_diagnostics dict({ + 'brand': 'SIEMENS', 'connected': True, + 'e_number': 'HCS02DWH1/03', + 'ha_id': 'SIEMENS-HCS02DWH1-6BE58C26DCC1', + 'name': 'Dishwasher', 'programs': list([ 'Dishcare.Dishwasher.Program.Auto1', 'Dishcare.Dishwasher.Program.Auto2', @@ -436,47 +325,22 @@ 'Dishcare.Dishwasher.Program.Eco50', 'Dishcare.Dishwasher.Program.Quick45', ]), - 'status': dict({ - 'BSH.Common.Setting.AmbientLightBrightness': dict({ - 'type': 'Double', - 'unit': '%', - 'value': 70, - }), - 'BSH.Common.Setting.AmbientLightColor': dict({ - 'type': 'BSH.Common.EnumType.AmbientLightColor', - 'value': 'BSH.Common.EnumType.AmbientLightColor.Color43', - }), - 'BSH.Common.Setting.AmbientLightCustomColor': dict({ - 'type': 'String', - 'value': '#4a88f8', - }), - 'BSH.Common.Setting.AmbientLightEnabled': dict({ - 'type': 'Boolean', - 'value': True, - }), - 'BSH.Common.Setting.ChildLock': dict({ - 'type': 'Boolean', - 'value': False, - }), - 'BSH.Common.Setting.PowerState': dict({ - 'type': 'BSH.Common.EnumType.PowerState', - 'value': 'BSH.Common.EnumType.PowerState.On', - }), - 'BSH.Common.Status.DoorState': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Closed', - }), - 'BSH.Common.Status.OperationState': dict({ - 'value': 'BSH.Common.EnumType.OperationState.Ready', - }), - 'BSH.Common.Status.RemoteControlActive': dict({ - 'value': True, - }), - 'BSH.Common.Status.RemoteControlStartAllowed': dict({ - 'value': True, - }), - 'Refrigeration.Common.Status.Door.Refrigerator': dict({ - 'value': 'BSH.Common.EnumType.DoorState.Open', - }), + 'settings': dict({ + 'BSH.Common.Setting.AmbientLightBrightness': 70, + 'BSH.Common.Setting.AmbientLightColor': 'BSH.Common.EnumType.AmbientLightColor.Color43', + 'BSH.Common.Setting.AmbientLightCustomColor': '#4a88f8', + 'BSH.Common.Setting.AmbientLightEnabled': True, + 'BSH.Common.Setting.ChildLock': False, + 'BSH.Common.Setting.PowerState': 'BSH.Common.EnumType.PowerState.On', }), + 'status': dict({ + 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed', + 'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready', + 'BSH.Common.Status.RemoteControlActive': True, + 'BSH.Common.Status.RemoteControlStartAllowed': True, + 'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open', + }), + 'type': 'Dishwasher', + 'vib': 'HCS02DWH1', }) # --- diff --git a/tests/components/home_connect/test_binary_sensor.py b/tests/components/home_connect/test_binary_sensor.py index 8e108cc2b0a..182051ad64a 100644 --- a/tests/components/home_connect/test_binary_sensor.py +++ b/tests/components/home_connect/test_binary_sensor.py @@ -1,32 +1,29 @@ """Tests for home_connect binary_sensor entities.""" from collections.abc import Awaitable, Callable -from unittest.mock import MagicMock, Mock +from unittest.mock import MagicMock -from homeconnect.api import HomeConnectAPI +from aiohomeconnect.model import ArrayOfEvents, Event, EventKey, EventMessage, EventType import pytest from homeassistant.components import automation, script from homeassistant.components.automation import automations_with_entity from homeassistant.components.home_connect.const import ( - BSH_DOOR_STATE, BSH_DOOR_STATE_CLOSED, BSH_DOOR_STATE_LOCKED, BSH_DOOR_STATE_OPEN, DOMAIN, REFRIGERATION_STATUS_DOOR_CLOSED, REFRIGERATION_STATUS_DOOR_OPEN, - REFRIGERATION_STATUS_DOOR_REFRIGERATOR, ) from homeassistant.components.script import scripts_with_entity from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, Platform +from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import issue_registry as ir -from homeassistant.helpers.entity_component import async_update_entity +import homeassistant.helpers.issue_registry as ir from homeassistant.setup import async_setup_component -from tests.common import MockConfigEntry, load_json_object_fixture +from tests.common import MockConfigEntry @pytest.fixture @@ -35,123 +32,166 @@ def platforms() -> list[str]: return [Platform.BINARY_SENSOR] -@pytest.mark.usefixtures("bypass_throttle") async def test_binary_sensors( config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: MagicMock, - appliance: Mock, + client: MagicMock, ) -> None: """Test binary sensor entities.""" - get_appliances.return_value = [appliance] assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED @pytest.mark.parametrize( - ("state", "expected"), + ("value", "expected"), [ (BSH_DOOR_STATE_CLOSED, "off"), (BSH_DOOR_STATE_LOCKED, "off"), (BSH_DOOR_STATE_OPEN, "on"), - ("", "unavailable"), + ("", STATE_UNKNOWN), ], ) -@pytest.mark.usefixtures("bypass_throttle") async def test_binary_sensors_door_states( + appliance_ha_id: str, expected: str, - state: str, + value: str, hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: MagicMock, - appliance: Mock, + client: MagicMock, ) -> None: """Tests for Appliance door states.""" entity_id = "binary_sensor.washer_door" - get_appliances.return_value = [appliance] assert config_entry.state == ConfigEntryState.NOT_LOADED - appliance.status.update({BSH_DOOR_STATE: {"value": state}}) - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED - await async_update_entity(hass, entity_id) + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.STATUS, + ArrayOfEvents( + [ + Event( + key=EventKey.BSH_COMMON_STATUS_DOOR_STATE, + raw_key=EventKey.BSH_COMMON_STATUS_DOOR_STATE.value, + timestamp=0, + level="", + handling="", + value=value, + ) + ], + ), + ) + ] + ) await hass.async_block_till_done() assert hass.states.is_state(entity_id, expected) @pytest.mark.parametrize( - ("entity_id", "status_key", "event_value_update", "expected", "appliance"), + ("entity_id", "event_key", "event_value_update", "expected", "appliance_ha_id"), [ + ( + "binary_sensor.washer_remote_control", + EventKey.BSH_COMMON_STATUS_REMOTE_CONTROL_ACTIVE, + False, + STATE_OFF, + "Washer", + ), + ( + "binary_sensor.washer_remote_control", + EventKey.BSH_COMMON_STATUS_REMOTE_CONTROL_ACTIVE, + True, + STATE_ON, + "Washer", + ), + ( + "binary_sensor.washer_remote_control", + EventKey.BSH_COMMON_STATUS_REMOTE_CONTROL_ACTIVE, + "", + STATE_UNKNOWN, + "Washer", + ), ( "binary_sensor.fridgefreezer_refrigerator_door", - REFRIGERATION_STATUS_DOOR_REFRIGERATOR, + EventKey.REFRIGERATION_COMMON_STATUS_DOOR_REFRIGERATOR, REFRIGERATION_STATUS_DOOR_CLOSED, STATE_OFF, "FridgeFreezer", ), ( "binary_sensor.fridgefreezer_refrigerator_door", - REFRIGERATION_STATUS_DOOR_REFRIGERATOR, + EventKey.REFRIGERATION_COMMON_STATUS_DOOR_REFRIGERATOR, REFRIGERATION_STATUS_DOOR_OPEN, STATE_ON, "FridgeFreezer", ), ( "binary_sensor.fridgefreezer_refrigerator_door", - REFRIGERATION_STATUS_DOOR_REFRIGERATOR, + EventKey.REFRIGERATION_COMMON_STATUS_DOOR_REFRIGERATOR, "", - STATE_UNAVAILABLE, + STATE_UNKNOWN, "FridgeFreezer", ), ], - indirect=["appliance"], + indirect=["appliance_ha_id"], ) -@pytest.mark.usefixtures("bypass_throttle") -async def test_bianry_sensors_fridge_door_states( +async def test_binary_sensors_functionality( entity_id: str, - status_key: str, + event_key: EventKey, event_value_update: str, - appliance: Mock, + appliance_ha_id: str, expected: str, hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: MagicMock, + client: MagicMock, ) -> None: """Tests for Home Connect Fridge appliance door states.""" - appliance.status.update( - HomeConnectAPI.json2dict( - load_json_object_fixture("home_connect/status.json")["data"]["status"] - ) - ) - get_appliances.return_value = [appliance] assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED - appliance.status.update({status_key: {"value": event_value_update}}) - await async_update_entity(hass, entity_id) + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.STATUS, + ArrayOfEvents( + [ + Event( + key=event_key, + raw_key=event_key.value, + timestamp=0, + level="", + handling="", + value=event_value_update, + ) + ], + ), + ) + ] + ) await hass.async_block_till_done() assert hass.states.is_state(entity_id, expected) @pytest.mark.usefixtures("entity_registry_enabled_by_default") -@pytest.mark.usefixtures("bypass_throttle") async def test_create_issue( hass: HomeAssistant, - appliance: Mock, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: MagicMock, + client: MagicMock, issue_registry: ir.IssueRegistry, ) -> None: """Test we create an issue when an automation or script is using a deprecated entity.""" entity_id = "binary_sensor.washer_door" - get_appliances.return_value = [appliance] issue_id = f"deprecated_binary_common_door_sensor_{entity_id}" assert await async_setup_component( @@ -189,8 +229,7 @@ async def test_create_issue( ) assert config_entry.state == ConfigEntryState.NOT_LOADED - appliance.status.update({BSH_DOOR_STATE: {"value": BSH_DOOR_STATE_OPEN}}) - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED assert automations_with_entity(hass, entity_id)[0] == "automation.test" diff --git a/tests/components/home_connect/test_config_flow.py b/tests/components/home_connect/test_config_flow.py index 80f53e20b39..c015a881343 100644 --- a/tests/components/home_connect/test_config_flow.py +++ b/tests/components/home_connect/test_config_flow.py @@ -3,6 +3,7 @@ from http import HTTPStatus from unittest.mock import patch +from aiohomeconnect.const import OAUTH2_AUTHORIZE, OAUTH2_TOKEN import pytest from homeassistant import config_entries, setup @@ -10,11 +11,7 @@ from homeassistant.components.application_credentials import ( ClientCredential, async_import_client_credential, ) -from homeassistant.components.home_connect.const import ( - DOMAIN, - OAUTH2_AUTHORIZE, - OAUTH2_TOKEN, -) +from homeassistant.components.home_connect.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import config_entry_oauth2_flow diff --git a/tests/components/home_connect/test_coordinator.py b/tests/components/home_connect/test_coordinator.py new file mode 100644 index 00000000000..51f42a98f42 --- /dev/null +++ b/tests/components/home_connect/test_coordinator.py @@ -0,0 +1,367 @@ +"""Test for Home Connect coordinator.""" + +from collections.abc import Awaitable, Callable +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +from aiohomeconnect.model import ( + ArrayOfEvents, + ArrayOfSettings, + ArrayOfStatus, + Event, + EventKey, + EventMessage, + EventType, + Status, + StatusKey, +) +from aiohomeconnect.model.error import ( + EventStreamInterruptedError, + HomeConnectApiError, + HomeConnectError, + HomeConnectRequestError, +) +import pytest + +from homeassistant.components.home_connect.const import ( + BSH_DOOR_STATE_LOCKED, + BSH_DOOR_STATE_OPEN, + BSH_EVENT_PRESENT_STATE_PRESENT, + BSH_POWER_OFF, +) +from homeassistant.config_entries import ConfigEntries, ConfigEntryState +from homeassistant.const import EVENT_STATE_REPORTED, Platform +from homeassistant.core import ( + Event as HassEvent, + EventStateReportedData, + HomeAssistant, + callback, +) +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry + + +@pytest.fixture +def platforms() -> list[str]: + """Fixture to specify platforms to test.""" + return [Platform.SENSOR, Platform.SWITCH] + + +async def test_coordinator_update( + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, +) -> None: + """Test that the coordinator can update.""" + assert config_entry.state == ConfigEntryState.NOT_LOADED + await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + +async def test_coordinator_update_failing_get_appliances( + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client_with_exception: MagicMock, +) -> None: + """Test that the coordinator raises ConfigEntryNotReady when it fails to get appliances.""" + client_with_exception.get_home_appliances.return_value = None + client_with_exception.get_home_appliances.side_effect = HomeConnectError() + + assert config_entry.state == ConfigEntryState.NOT_LOADED + await integration_setup(client_with_exception) + assert config_entry.state == ConfigEntryState.SETUP_RETRY + + +async def test_coordinator_update_failing_get_settings_status( + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client_with_exception: MagicMock, +) -> None: + """Test that although is not possible to get settings and status, the config entry is loaded. + + This is for cases where some appliances are reachable and some are not in the same configuration entry. + """ + # Get home appliances does pass at client_with_exception.get_home_appliances mock, so no need to mock it again + assert config_entry.state == ConfigEntryState.NOT_LOADED + await integration_setup(client_with_exception) + assert config_entry.state == ConfigEntryState.LOADED + + +@pytest.mark.parametrize("appliance_ha_id", ["Dishwasher"], indirect=True) +@pytest.mark.parametrize( + ("event_type", "event_key", "event_value", "entity_id"), + [ + ( + EventType.STATUS, + EventKey.BSH_COMMON_STATUS_DOOR_STATE, + BSH_DOOR_STATE_OPEN, + "sensor.dishwasher_door", + ), + ( + EventType.NOTIFY, + EventKey.BSH_COMMON_SETTING_POWER_STATE, + BSH_POWER_OFF, + "switch.dishwasher_power", + ), + ( + EventType.EVENT, + EventKey.DISHCARE_DISHWASHER_EVENT_SALT_NEARLY_EMPTY, + BSH_EVENT_PRESENT_STATE_PRESENT, + "sensor.dishwasher_salt_nearly_empty", + ), + ], +) +async def test_event_listener( + event_type: EventType, + event_key: EventKey, + event_value: str, + entity_id: str, + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + appliance_ha_id: str, + entity_registry: er.EntityRegistry, +) -> None: + """Test that the event listener works.""" + assert config_entry.state == ConfigEntryState.NOT_LOADED + await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + state = hass.states.get(entity_id) + assert state + event_message = EventMessage( + appliance_ha_id, + event_type, + ArrayOfEvents( + [ + Event( + key=event_key, + raw_key=event_key.value, + timestamp=0, + level="", + handling="", + value=event_value, + ) + ], + ), + ) + await client.add_events([event_message]) + await hass.async_block_till_done() + + new_state = hass.states.get(entity_id) + assert new_state + assert new_state.state != state.state + + # Following, we are gonna check that the listeners are clean up correctly + new_entity_id = entity_id + "_new" + listener = MagicMock() + + @callback + def listener_callback(event: HassEvent[EventStateReportedData]) -> None: + listener(event.data["entity_id"]) + + @callback + def event_filter(_: EventStateReportedData) -> bool: + return True + + hass.bus.async_listen(EVENT_STATE_REPORTED, listener_callback, event_filter) + + entity_registry.async_update_entity(entity_id, new_entity_id=new_entity_id) + await hass.async_block_till_done() + await client.add_events([event_message]) + await hass.async_block_till_done() + + # Because the entity's id has been updated, the entity has been unloaded + # and the listener has been removed, and the new entity adds a new listener, + # so the only entity that should report states is the one with the new entity id + listener.assert_called_once_with(new_entity_id) + + +async def tests_receive_setting_and_status_for_first_time_at_events( + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + appliance_ha_id: str, +) -> None: + """Test that the event listener is capable of receiving settings and status for the first time.""" + client.get_setting = AsyncMock(return_value=ArrayOfSettings([])) + client.get_status = AsyncMock(return_value=ArrayOfStatus([])) + + assert config_entry.state == ConfigEntryState.NOT_LOADED + await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.NOTIFY, + ArrayOfEvents( + [ + Event( + key=EventKey.LAUNDRY_CARE_WASHER_SETTING_I_DOS_1_BASE_LEVEL, + raw_key=EventKey.LAUNDRY_CARE_WASHER_SETTING_I_DOS_1_BASE_LEVEL.value, + timestamp=0, + level="", + handling="", + value="some value", + ) + ], + ), + ), + EventMessage( + appliance_ha_id, + EventType.STATUS, + ArrayOfEvents( + [ + Event( + key=EventKey.BSH_COMMON_STATUS_DOOR_STATE, + raw_key=EventKey.BSH_COMMON_STATUS_DOOR_STATE.value, + timestamp=0, + level="", + handling="", + value="some value", + ) + ], + ), + ), + ] + ) + await hass.async_block_till_done() + assert len(config_entry._background_tasks) == 1 + assert config_entry.state == ConfigEntryState.LOADED + + +async def test_event_listener_error( + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client_with_exception: MagicMock, +) -> None: + """Test that the configuration entry is reloaded when the event stream raises an API error.""" + client_with_exception.stream_all_events = MagicMock( + side_effect=HomeConnectApiError("error.key", "error description") + ) + + with patch.object( + ConfigEntries, + "async_schedule_reload", + ) as mock_schedule_reload: + await integration_setup(client_with_exception) + await hass.async_block_till_done() + + client_with_exception.stream_all_events.assert_called_once() + mock_schedule_reload.assert_called_once_with(config_entry.entry_id) + assert not config_entry._background_tasks + + +@pytest.mark.parametrize( + "exception", + [HomeConnectRequestError(), EventStreamInterruptedError()], +) +@pytest.mark.parametrize( + ( + "entity_id", + "initial_state", + "status_key", + "status_value", + "after_refresh_expected_state", + "event_key", + "event_value", + "after_event_expected_state", + ), + [ + ( + "sensor.washer_door", + "closed", + StatusKey.BSH_COMMON_DOOR_STATE, + BSH_DOOR_STATE_LOCKED, + "locked", + EventKey.BSH_COMMON_STATUS_DOOR_STATE, + BSH_DOOR_STATE_OPEN, + "open", + ), + ], +) +@patch( + "homeassistant.components.home_connect.coordinator.EVENT_STREAM_RECONNECT_DELAY", 0 +) +async def test_event_listener_resilience( + entity_id: str, + initial_state: str, + status_key: StatusKey, + status_value: Any, + after_refresh_expected_state: str, + event_key: EventKey, + event_value: Any, + after_event_expected_state: str, + exception: HomeConnectError, + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + appliance_ha_id: str, +) -> None: + """Test that the event listener is resilient to interruptions.""" + future = hass.loop.create_future() + + async def stream_exception(): + yield await future + + client.stream_all_events = MagicMock( + side_effect=[stream_exception(), client.stream_all_events()] + ) + + assert config_entry.state == ConfigEntryState.NOT_LOADED + await integration_setup(client) + await hass.async_block_till_done() + + assert config_entry.state == ConfigEntryState.LOADED + assert len(config_entry._background_tasks) == 1 + + assert hass.states.is_state(entity_id, initial_state) + + client.get_status.return_value = ArrayOfStatus( + [Status(key=status_key, raw_key=status_key.value, value=status_value)], + ) + await hass.async_block_till_done() + future.set_exception(exception) + await hass.async_block_till_done() + await hass.async_block_till_done() + + assert client.stream_all_events.call_count == 2 + assert hass.states.is_state(entity_id, after_refresh_expected_state) + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.STATUS, + ArrayOfEvents( + [ + Event( + key=event_key, + raw_key=event_key.value, + timestamp=0, + level="", + handling="", + value=event_value, + ) + ], + ), + ), + ] + ) + await hass.async_block_till_done() + + assert hass.states.is_state(entity_id, after_event_expected_state) diff --git a/tests/components/home_connect/test_diagnostics.py b/tests/components/home_connect/test_diagnostics.py index f2db6e2b67a..ab6823411dc 100644 --- a/tests/components/home_connect/test_diagnostics.py +++ b/tests/components/home_connect/test_diagnostics.py @@ -1,11 +1,9 @@ """Test diagnostics for Home Connect.""" from collections.abc import Awaitable, Callable -from unittest.mock import MagicMock, Mock +from unittest.mock import MagicMock -from homeconnect.api import HomeConnectError -import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion from homeassistant.components.home_connect.const import DOMAIN from homeassistant.components.home_connect.diagnostics import ( @@ -16,43 +14,37 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr -from .conftest import get_all_appliances - from tests.common import MockConfigEntry -@pytest.mark.usefixtures("bypass_throttle") async def test_async_get_config_entry_diagnostics( hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: MagicMock, + client: MagicMock, snapshot: SnapshotAssertion, ) -> None: """Test config entry diagnostics.""" - get_appliances.side_effect = get_all_appliances assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED assert await async_get_config_entry_diagnostics(hass, config_entry) == snapshot -@pytest.mark.usefixtures("bypass_throttle") async def test_async_get_device_diagnostics( hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: MagicMock, + client: MagicMock, device_registry: dr.DeviceRegistry, snapshot: SnapshotAssertion, ) -> None: """Test device config entry diagnostics.""" - get_appliances.side_effect = get_all_appliances assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED device = device_registry.async_get_or_create( @@ -61,69 +53,3 @@ async def test_async_get_device_diagnostics( ) assert await async_get_device_diagnostics(hass, config_entry, device) == snapshot - - -@pytest.mark.usefixtures("bypass_throttle") -async def test_async_device_diagnostics_not_found( - hass: HomeAssistant, - config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], - setup_credentials: None, - get_appliances: MagicMock, - device_registry: dr.DeviceRegistry, -) -> None: - """Test device config entry diagnostics.""" - get_appliances.side_effect = get_all_appliances - assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() - assert config_entry.state == ConfigEntryState.LOADED - - device = device_registry.async_get_or_create( - config_entry_id=config_entry.entry_id, - identifiers={(DOMAIN, "Random-Device-ID")}, - ) - - with pytest.raises(ValueError): - await async_get_device_diagnostics(hass, config_entry, device) - - -@pytest.mark.parametrize( - ("api_error", "expected_connection_status"), - [ - (HomeConnectError(), "unknown"), - ( - HomeConnectError( - { - "key": "SDK.Error.HomeAppliance.Connection.Initialization.Failed", - } - ), - "offline", - ), - ], -) -@pytest.mark.usefixtures("bypass_throttle") -async def test_async_device_diagnostics_api_error( - api_error: HomeConnectError, - expected_connection_status: str, - hass: HomeAssistant, - config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], - setup_credentials: None, - get_appliances: MagicMock, - appliance: Mock, - device_registry: dr.DeviceRegistry, -) -> None: - """Test device config entry diagnostics.""" - appliance.get_programs_available.side_effect = api_error - get_appliances.return_value = [appliance] - assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() - assert config_entry.state == ConfigEntryState.LOADED - - device = device_registry.async_get_or_create( - config_entry_id=config_entry.entry_id, - identifiers={(DOMAIN, appliance.haId)}, - ) - - diagnostics = await async_get_device_diagnostics(hass, config_entry, device) - assert diagnostics["programs"] is None diff --git a/tests/components/home_connect/test_init.py b/tests/components/home_connect/test_init.py index 69601efb42d..f62feca700a 100644 --- a/tests/components/home_connect/test_init.py +++ b/tests/components/home_connect/test_init.py @@ -2,27 +2,18 @@ from collections.abc import Awaitable, Callable from typing import Any -from unittest.mock import MagicMock, Mock, patch +from unittest.mock import MagicMock, patch -from freezegun.api import FrozenDateTimeFactory +from aiohomeconnect.const import OAUTH2_TOKEN +from aiohomeconnect.model import SettingKey, StatusKey +from aiohomeconnect.model.error import HomeConnectError import pytest -from requests import HTTPError import requests_mock +import respx from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN -from homeassistant.components.home_connect import ( - SCAN_INTERVAL, - bsh_key_to_translation_key, -) -from homeassistant.components.home_connect.const import ( - BSH_CHILD_LOCK_STATE, - BSH_OPERATION_STATE, - BSH_POWER_STATE, - BSH_REMOTE_START_ALLOWANCE_STATE, - COOKING_LIGHTING, - DOMAIN, - OAUTH2_TOKEN, -) +from homeassistant.components.home_connect.const import DOMAIN +from homeassistant.components.home_connect.utils import bsh_key_to_translation_key from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN @@ -39,7 +30,6 @@ from .conftest import ( FAKE_ACCESS_TOKEN, FAKE_REFRESH_TOKEN, SERVER_ACCESS_TOKEN, - get_all_appliances, ) from tests.common import MockConfigEntry @@ -126,28 +116,26 @@ SERVICE_PROGRAM_CALL_PARAMS = [ ] SERVICE_APPLIANCE_METHOD_MAPPING = { - "set_option_active": "set_options_active_program", - "set_option_selected": "set_options_selected_program", + "set_option_active": "set_active_program_option", + "set_option_selected": "set_selected_program_option", "change_setting": "set_setting", - "pause_program": "execute_command", - "resume_program": "execute_command", - "select_program": "select_program", + "pause_program": "put_command", + "resume_program": "put_command", + "select_program": "set_selected_program", "start_program": "start_program", } -@pytest.mark.usefixtures("bypass_throttle") -async def test_api_setup( +async def test_entry_setup( hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: MagicMock, + client: MagicMock, ) -> None: """Test setup and unload.""" - get_appliances.side_effect = get_all_appliances assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED assert await hass.config_entries.async_unload(config_entry.entry_id) @@ -156,72 +144,60 @@ async def test_api_setup( assert config_entry.state == ConfigEntryState.NOT_LOADED -async def test_update_throttle( - appliance: Mock, - freezer: FrozenDateTimeFactory, - hass: HomeAssistant, - config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], - setup_credentials: None, - get_appliances: MagicMock, -) -> None: - """Test to check Throttle functionality.""" - assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() - assert config_entry.state == ConfigEntryState.LOADED - get_appliances_call_count = get_appliances.call_count - - # First re-load after 1 minute is not blocked. - assert await hass.config_entries.async_unload(config_entry.entry_id) - assert config_entry.state == ConfigEntryState.NOT_LOADED - freezer.tick(SCAN_INTERVAL.seconds + 0.1) - assert await hass.config_entries.async_setup(config_entry.entry_id) - assert get_appliances.call_count == get_appliances_call_count + 1 - - # Second re-load is blocked by Throttle. - assert await hass.config_entries.async_unload(config_entry.entry_id) - assert config_entry.state == ConfigEntryState.NOT_LOADED - freezer.tick(SCAN_INTERVAL.seconds - 0.1) - assert await hass.config_entries.async_setup(config_entry.entry_id) - assert get_appliances.call_count == get_appliances_call_count + 1 - - -@pytest.mark.usefixtures("bypass_throttle") async def test_exception_handling( - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], config_entry: MockConfigEntry, setup_credentials: None, - get_appliances: MagicMock, - problematic_appliance: Mock, + client_with_exception: MagicMock, ) -> None: """Test exception handling.""" - get_appliances.return_value = [problematic_appliance] assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client_with_exception) assert config_entry.state == ConfigEntryState.LOADED @pytest.mark.parametrize("token_expiration_time", [12345]) -@pytest.mark.usefixtures("bypass_throttle") +@respx.mock async def test_token_refresh_success( - integration_setup: Callable[[], Awaitable[bool]], + hass: HomeAssistant, + platforms: list[Platform], + integration_setup: Callable[[MagicMock], Awaitable[bool]], config_entry: MockConfigEntry, aioclient_mock: AiohttpClientMocker, requests_mock: requests_mock.Mocker, setup_credentials: None, + client: MagicMock, ) -> None: """Test where token is expired and the refresh attempt succeeds.""" assert config_entry.data["token"]["access_token"] == FAKE_ACCESS_TOKEN requests_mock.post(OAUTH2_TOKEN, json=SERVER_ACCESS_TOKEN) - requests_mock.get("/api/homeappliances", json={"data": {"homeappliances": []}}) - aioclient_mock.post( OAUTH2_TOKEN, json=SERVER_ACCESS_TOKEN, ) - assert await integration_setup() + appliances = client.get_home_appliances.return_value + + async def mock_get_home_appliances(): + await client._auth.async_get_access_token() + return appliances + + client.get_home_appliances.return_value = None + client.get_home_appliances.side_effect = mock_get_home_appliances + + def init_side_effect(auth) -> MagicMock: + client._auth = auth + return client + + assert config_entry.state == ConfigEntryState.NOT_LOADED + with ( + patch("homeassistant.components.home_connect.PLATFORMS", platforms), + patch("homeassistant.components.home_connect.HomeConnectClient") as client_mock, + ): + client_mock.side_effect = MagicMock(side_effect=init_side_effect) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() assert config_entry.state == ConfigEntryState.LOADED # Verify token request @@ -240,45 +216,43 @@ async def test_token_refresh_success( ) -@pytest.mark.usefixtures("bypass_throttle") -async def test_http_error( +async def test_client_error( config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: MagicMock, + client_with_exception: MagicMock, ) -> None: - """Test HTTP errors during setup integration.""" - get_appliances.side_effect = HTTPError(response=MagicMock()) + """Test client errors during setup integration.""" + client_with_exception.get_home_appliances.return_value = None + client_with_exception.get_home_appliances.side_effect = HomeConnectError() assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() - assert config_entry.state == ConfigEntryState.LOADED - assert get_appliances.call_count == 1 + assert not await integration_setup(client_with_exception) + assert config_entry.state == ConfigEntryState.SETUP_RETRY + assert client_with_exception.get_home_appliances.call_count == 1 @pytest.mark.parametrize( "service_call", SERVICE_KV_CALL_PARAMS + SERVICE_COMMAND_CALL_PARAMS + SERVICE_PROGRAM_CALL_PARAMS, ) -@pytest.mark.usefixtures("bypass_throttle") async def test_services( - service_call: list[dict[str, Any]], + service_call: dict[str, Any], hass: HomeAssistant, device_registry: dr.DeviceRegistry, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: MagicMock, - appliance: Mock, + client: MagicMock, + appliance_ha_id: str, ) -> None: """Create and test services.""" - get_appliances.return_value = [appliance] assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED device_entry = device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, - identifiers={(DOMAIN, appliance.haId)}, + identifiers={(DOMAIN, appliance_ha_id)}, ) service_name = service_call["service"] @@ -286,8 +260,7 @@ async def test_services( await hass.services.async_call(**service_call) await hass.async_block_till_done() assert ( - getattr(appliance, SERVICE_APPLIANCE_METHOD_MAPPING[service_name]).call_count - == 1 + getattr(client, SERVICE_APPLIANCE_METHOD_MAPPING[service_name]).call_count == 1 ) @@ -295,26 +268,24 @@ async def test_services( "service_call", SERVICE_KV_CALL_PARAMS + SERVICE_COMMAND_CALL_PARAMS + SERVICE_PROGRAM_CALL_PARAMS, ) -@pytest.mark.usefixtures("bypass_throttle") async def test_services_exception( - service_call: list[dict[str, Any]], + service_call: dict[str, Any], hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: MagicMock, - problematic_appliance: Mock, + client_with_exception: MagicMock, + appliance_ha_id: str, device_registry: dr.DeviceRegistry, ) -> None: """Raise a HomeAssistantError when there is an API error.""" - get_appliances.return_value = [problematic_appliance] assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client_with_exception) assert config_entry.state == ConfigEntryState.LOADED device_entry = device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, - identifiers={(DOMAIN, problematic_appliance.haId)}, + identifiers={(DOMAIN, appliance_ha_id)}, ) service_call["service_data"]["device_id"] = device_entry.id @@ -323,25 +294,47 @@ async def test_services_exception( await hass.services.async_call(**service_call) -@pytest.mark.usefixtures("bypass_throttle") async def test_services_appliance_not_found( hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: MagicMock, - appliance: Mock, + client: MagicMock, + device_registry: dr.DeviceRegistry, ) -> None: """Raise a ServiceValidationError when device id does not match.""" - get_appliances.return_value = [appliance] assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED service_call = SERVICE_KV_CALL_PARAMS[0] service_call["service_data"]["device_id"] = "DOES_NOT_EXISTS" + with pytest.raises(ServiceValidationError, match=r"Device entry.*not found"): + await hass.services.async_call(**service_call) + + unrelated_config_entry = MockConfigEntry( + domain="TEST", + ) + unrelated_config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=unrelated_config_entry.entry_id, + identifiers={("RANDOM", "ABCD")}, + ) + service_call["service_data"]["device_id"] = device_entry.id + + with pytest.raises( + ServiceValidationError, match=r"Home Connect config entry.*not found" + ): + await hass.services.async_call(**service_call) + + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={("RANDOM", "ABCD")}, + ) + service_call["service_data"]["device_id"] = device_entry.id + with pytest.raises(ServiceValidationError, match=r"Appliance.*not found"): await hass.services.async_call(**service_call) @@ -351,7 +344,7 @@ async def test_entity_migration( device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, config_entry_v1_1: MockConfigEntry, - appliance: Mock, + appliance_ha_id: str, platforms: list[Platform], ) -> None: """Test entity migration.""" @@ -360,34 +353,39 @@ async def test_entity_migration( device_entry = device_registry.async_get_or_create( config_entry_id=config_entry_v1_1.entry_id, - identifiers={(DOMAIN, appliance.haId)}, + identifiers={(DOMAIN, appliance_ha_id)}, ) test_entities = [ ( SENSOR_DOMAIN, "Operation State", - BSH_OPERATION_STATE, + StatusKey.BSH_COMMON_OPERATION_STATE, ), ( SWITCH_DOMAIN, "ChildLock", - BSH_CHILD_LOCK_STATE, + SettingKey.BSH_COMMON_CHILD_LOCK, ), ( SWITCH_DOMAIN, "Power", - BSH_POWER_STATE, + SettingKey.BSH_COMMON_POWER_STATE, ), ( BINARY_SENSOR_DOMAIN, "Remote Start", - BSH_REMOTE_START_ALLOWANCE_STATE, + StatusKey.BSH_COMMON_REMOTE_CONTROL_START_ALLOWED, ), ( LIGHT_DOMAIN, "Light", - COOKING_LIGHTING, + SettingKey.COOKING_COMMON_LIGHTING, + ), + ( # An already migrated entity + SWITCH_DOMAIN, + SettingKey.REFRIGERATION_COMMON_VACATION_MODE, + SettingKey.REFRIGERATION_COMMON_VACATION_MODE, ), ] @@ -395,7 +393,7 @@ async def test_entity_migration( entity_registry.async_get_or_create( domain, DOMAIN, - f"{appliance.haId}-{old_unique_id_suffix}", + f"{appliance_ha_id}-{old_unique_id_suffix}", device_id=device_entry.id, config_entry=config_entry_v1_1, ) @@ -406,7 +404,7 @@ async def test_entity_migration( for domain, _, expected_unique_id_suffix in test_entities: assert entity_registry.async_get_entity_id( - domain, DOMAIN, f"{appliance.haId}-{expected_unique_id_suffix}" + domain, DOMAIN, f"{appliance_ha_id}-{expected_unique_id_suffix}" ) assert config_entry_v1_1.minor_version == 2 diff --git a/tests/components/home_connect/test_light.py b/tests/components/home_connect/test_light.py index 471ddf0ec54..4f8cb60d881 100644 --- a/tests/components/home_connect/test_light.py +++ b/tests/components/home_connect/test_light.py @@ -1,20 +1,24 @@ """Tests for home_connect light entities.""" -from collections.abc import Awaitable, Callable, Generator -from unittest.mock import MagicMock, Mock +from collections.abc import Awaitable, Callable +from typing import Any +from unittest.mock import MagicMock, call -from homeconnect.api import HomeConnectAppliance, HomeConnectError +from aiohomeconnect.model import ( + ArrayOfEvents, + ArrayOfSettings, + Event, + EventKey, + EventMessage, + EventType, + GetSetting, + SettingKey, +) +from aiohomeconnect.model.error import HomeConnectError import pytest from homeassistant.components.home_connect.const import ( - BSH_AMBIENT_LIGHT_BRIGHTNESS, - BSH_AMBIENT_LIGHT_COLOR, - BSH_AMBIENT_LIGHT_CUSTOM_COLOR, - BSH_AMBIENT_LIGHT_ENABLED, - COOKING_LIGHTING, - COOKING_LIGHTING_BRIGHTNESS, - REFRIGERATION_EXTERNAL_LIGHT_BRIGHTNESS, - REFRIGERATION_EXTERNAL_LIGHT_POWER, + BSH_AMBIENT_LIGHT_COLOR_CUSTOM_COLOR, ) from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN from homeassistant.config_entries import ConfigEntryState @@ -23,26 +27,15 @@ from homeassistant.const import ( SERVICE_TURN_ON, STATE_OFF, STATE_ON, - STATE_UNKNOWN, Platform, ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from .conftest import get_all_appliances - -from tests.common import MockConfigEntry, load_json_object_fixture +from tests.common import MockConfigEntry TEST_HC_APP = "Hood" -SETTINGS_STATUS = { - setting.pop("key"): setting - for setting in load_json_object_fixture("home_connect/settings.json") - .get(TEST_HC_APP) - .get("data") - .get("settings") -} - @pytest.fixture def platforms() -> list[str]: @@ -51,29 +44,31 @@ def platforms() -> list[str]: async def test_light( - bypass_throttle: Generator[None], - hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: Mock, + client: MagicMock, ) -> None: """Test switch entities.""" - get_appliances.side_effect = get_all_appliances assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED @pytest.mark.parametrize( - ("entity_id", "status", "service", "service_data", "state", "appliance"), + ( + "entity_id", + "set_settings_args", + "service", + "exprected_attributes", + "state", + "appliance_ha_id", + ), [ ( "light.hood_functional_light", { - COOKING_LIGHTING: { - "value": True, - }, + SettingKey.COOKING_COMMON_LIGHTING: True, }, SERVICE_TURN_ON, {}, @@ -83,58 +78,18 @@ async def test_light( ( "light.hood_functional_light", { - COOKING_LIGHTING: { - "value": True, - }, - COOKING_LIGHTING_BRIGHTNESS: {"value": 70}, + SettingKey.COOKING_COMMON_LIGHTING: True, + SettingKey.COOKING_COMMON_LIGHTING_BRIGHTNESS: 80, }, SERVICE_TURN_ON, - {"brightness": 200}, + {"brightness": 199}, STATE_ON, "Hood", ), ( "light.hood_functional_light", { - COOKING_LIGHTING: {"value": False}, - COOKING_LIGHTING_BRIGHTNESS: {"value": 70}, - }, - SERVICE_TURN_OFF, - {}, - STATE_OFF, - "Hood", - ), - ( - "light.hood_functional_light", - { - COOKING_LIGHTING: { - "value": None, - }, - COOKING_LIGHTING_BRIGHTNESS: None, - }, - SERVICE_TURN_ON, - {}, - STATE_UNKNOWN, - "Hood", - ), - ( - "light.hood_ambient_light", - { - BSH_AMBIENT_LIGHT_ENABLED: { - "value": True, - }, - BSH_AMBIENT_LIGHT_BRIGHTNESS: {"value": 70}, - }, - SERVICE_TURN_ON, - {"brightness": 200}, - STATE_ON, - "Hood", - ), - ( - "light.hood_ambient_light", - { - BSH_AMBIENT_LIGHT_ENABLED: {"value": False}, - BSH_AMBIENT_LIGHT_BRIGHTNESS: {"value": 70}, + SettingKey.COOKING_COMMON_LIGHTING: False, }, SERVICE_TURN_OFF, {}, @@ -144,8 +99,28 @@ async def test_light( ( "light.hood_ambient_light", { - BSH_AMBIENT_LIGHT_ENABLED: {"value": True}, - BSH_AMBIENT_LIGHT_CUSTOM_COLOR: {}, + SettingKey.BSH_COMMON_AMBIENT_LIGHT_ENABLED: True, + SettingKey.BSH_COMMON_AMBIENT_LIGHT_BRIGHTNESS: 80, + }, + SERVICE_TURN_ON, + {"brightness": 199}, + STATE_ON, + "Hood", + ), + ( + "light.hood_ambient_light", + { + SettingKey.BSH_COMMON_AMBIENT_LIGHT_ENABLED: False, + }, + SERVICE_TURN_OFF, + {}, + STATE_OFF, + "Hood", + ), + ( + "light.hood_ambient_light", + { + SettingKey.BSH_COMMON_AMBIENT_LIGHT_ENABLED: True, }, SERVICE_TURN_ON, {}, @@ -155,15 +130,28 @@ async def test_light( ( "light.hood_ambient_light", { - BSH_AMBIENT_LIGHT_ENABLED: {"value": True}, - BSH_AMBIENT_LIGHT_COLOR: { - "value": "", - }, - BSH_AMBIENT_LIGHT_CUSTOM_COLOR: {}, + SettingKey.BSH_COMMON_AMBIENT_LIGHT_ENABLED: True, + SettingKey.BSH_COMMON_AMBIENT_LIGHT_COLOR: BSH_AMBIENT_LIGHT_COLOR_CUSTOM_COLOR, + SettingKey.BSH_COMMON_AMBIENT_LIGHT_CUSTOM_COLOR: "#ffff00", }, SERVICE_TURN_ON, { - "rgb_color": [255, 255, 0], + "rgb_color": (255, 255, 0), + }, + STATE_ON, + "Hood", + ), + ( + "light.hood_ambient_light", + { + SettingKey.BSH_COMMON_AMBIENT_LIGHT_ENABLED: True, + SettingKey.BSH_COMMON_AMBIENT_LIGHT_COLOR: BSH_AMBIENT_LIGHT_COLOR_CUSTOM_COLOR, + SettingKey.BSH_COMMON_AMBIENT_LIGHT_CUSTOM_COLOR: "#b5adcc", + }, + SERVICE_TURN_ON, + { + "hs_color": (255.484, 15.196), + "brightness": 199, }, STATE_ON, "Hood", @@ -171,10 +159,7 @@ async def test_light( ( "light.fridgefreezer_external_light", { - REFRIGERATION_EXTERNAL_LIGHT_POWER: { - "value": True, - }, - REFRIGERATION_EXTERNAL_LIGHT_BRIGHTNESS: {"value": 75}, + SettingKey.REFRIGERATION_COMMON_LIGHT_EXTERNAL_POWER: True, }, SERVICE_TURN_ON, {}, @@ -182,167 +167,268 @@ async def test_light( "FridgeFreezer", ), ], - indirect=["appliance"], + indirect=["appliance_ha_id"], ) async def test_light_functionality( entity_id: str, - status: dict, + set_settings_args: dict[SettingKey, Any], service: str, - service_data: dict, + exprected_attributes: dict[str, Any], state: str, - appliance: Mock, - bypass_throttle: Generator[None], + appliance_ha_id: str, hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: MagicMock, + client: MagicMock, ) -> None: """Test light functionality.""" - appliance.status.update( - HomeConnectAppliance.json2dict( - load_json_object_fixture("home_connect/settings.json") - .get(appliance.name) - .get("data") - .get("settings") - ) - ) - get_appliances.return_value = [appliance] - assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED - appliance.status.update(status) + service_data = exprected_attributes.copy() service_data["entity_id"] = entity_id await hass.services.async_call( LIGHT_DOMAIN, service, - service_data, - blocking=True, + {key: value for key, value in service_data.items() if value is not None}, ) - assert hass.states.is_state(entity_id, state) + await hass.async_block_till_done() + client.set_setting.assert_has_calls( + [ + call(appliance_ha_id, setting_key=setting_key, value=value) + for setting_key, value in set_settings_args.items() + ] + ) + entity_state = hass.states.get(entity_id) + assert entity_state is not None + assert entity_state.state == state + for key, value in exprected_attributes.items(): + assert entity_state.attributes[key] == value @pytest.mark.parametrize( ( "entity_id", - "status", + "events", + "appliance_ha_id", + ), + [ + ( + "light.hood_ambient_light", + { + EventKey.BSH_COMMON_SETTING_AMBIENT_LIGHT_COLOR: "BSH.Common.EnumType.AmbientLightColor.Color1", + }, + "Hood", + ), + ], + indirect=["appliance_ha_id"], +) +async def test_light_color_different_than_custom( + entity_id: str, + events: dict[EventKey, Any], + appliance_ha_id: str, + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, +) -> None: + """Test that light color attributes are not set if color is different than custom.""" + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + { + "rgb_color": (255, 255, 0), + "entity_id": entity_id, + }, + ) + await hass.async_block_till_done() + entity_state = hass.states.get(entity_id) + assert entity_state is not None + assert entity_state.state == STATE_ON + assert entity_state.attributes["rgb_color"] is not None + assert entity_state.attributes["hs_color"] is not None + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.NOTIFY, + ArrayOfEvents( + [ + Event( + key=event_key, + raw_key=event_key.value, + timestamp=0, + level="", + handling="", + value=value, + ) + for event_key, value in events.items() + ] + ), + ) + ] + ) + await hass.async_block_till_done() + + entity_state = hass.states.get(entity_id) + assert entity_state is not None + assert entity_state.state == STATE_ON + assert entity_state.attributes["rgb_color"] is None + assert entity_state.attributes["hs_color"] is None + + +@pytest.mark.parametrize( + ( + "entity_id", + "setting", "service", "service_data", - "mock_attr", "attr_side_effect", - "problematic_appliance", "exception_match", ), [ ( "light.hood_functional_light", { - COOKING_LIGHTING: { - "value": False, - }, + SettingKey.COOKING_COMMON_LIGHTING: True, }, SERVICE_TURN_ON, {}, - "set_setting", [HomeConnectError, HomeConnectError], - "Hood", r"Error.*turn.*on.*", ), ( "light.hood_functional_light", { - COOKING_LIGHTING: { - "value": True, - }, - COOKING_LIGHTING_BRIGHTNESS: {"value": 70}, + SettingKey.COOKING_COMMON_LIGHTING: True, + SettingKey.COOKING_COMMON_LIGHTING_BRIGHTNESS: 70, }, SERVICE_TURN_ON, {"brightness": 200}, - "set_setting", [HomeConnectError, HomeConnectError], - "Hood", r"Error.*turn.*on.*", ), ( "light.hood_functional_light", { - COOKING_LIGHTING: {"value": False}, + SettingKey.COOKING_COMMON_LIGHTING: False, }, SERVICE_TURN_OFF, {}, - "set_setting", [HomeConnectError, HomeConnectError], - "Hood", r"Error.*turn.*off.*", ), ( "light.hood_ambient_light", { - BSH_AMBIENT_LIGHT_ENABLED: { - "value": True, - }, - BSH_AMBIENT_LIGHT_BRIGHTNESS: {"value": 70}, + SettingKey.BSH_COMMON_AMBIENT_LIGHT_ENABLED: True, + SettingKey.BSH_COMMON_AMBIENT_LIGHT_BRIGHTNESS: 70, }, SERVICE_TURN_ON, {}, - "set_setting", [HomeConnectError, HomeConnectError], - "Hood", r"Error.*turn.*on.*", ), ( "light.hood_ambient_light", { - BSH_AMBIENT_LIGHT_ENABLED: { - "value": True, - }, - BSH_AMBIENT_LIGHT_BRIGHTNESS: {"value": 70}, + SettingKey.BSH_COMMON_AMBIENT_LIGHT_ENABLED: True, + SettingKey.BSH_COMMON_AMBIENT_LIGHT_BRIGHTNESS: 70, }, SERVICE_TURN_ON, {"brightness": 200}, - "set_setting", [HomeConnectError, None, HomeConnectError], - "Hood", + r"Error.*set.*brightness.*", + ), + ( + "light.hood_ambient_light", + { + SettingKey.BSH_COMMON_AMBIENT_LIGHT_ENABLED: True, + SettingKey.BSH_COMMON_AMBIENT_LIGHT_BRIGHTNESS: 70, + SettingKey.BSH_COMMON_AMBIENT_LIGHT_COLOR: 70, + SettingKey.BSH_COMMON_AMBIENT_LIGHT_CUSTOM_COLOR: "#ffff00", + }, + SERVICE_TURN_ON, + {"rgb_color": (255, 255, 0)}, + [HomeConnectError, None, HomeConnectError], + r"Error.*select.*custom color.*", + ), + ( + "light.hood_ambient_light", + { + SettingKey.BSH_COMMON_AMBIENT_LIGHT_ENABLED: True, + SettingKey.BSH_COMMON_AMBIENT_LIGHT_BRIGHTNESS: 70, + SettingKey.BSH_COMMON_AMBIENT_LIGHT_COLOR: BSH_AMBIENT_LIGHT_COLOR_CUSTOM_COLOR, + SettingKey.BSH_COMMON_AMBIENT_LIGHT_CUSTOM_COLOR: "#ffff00", + }, + SERVICE_TURN_ON, + {"rgb_color": (255, 255, 0)}, + [HomeConnectError, None, None, HomeConnectError], + r"Error.*set.*color.*", + ), + ( + "light.hood_ambient_light", + { + SettingKey.BSH_COMMON_AMBIENT_LIGHT_ENABLED: True, + SettingKey.BSH_COMMON_AMBIENT_LIGHT_COLOR: BSH_AMBIENT_LIGHT_COLOR_CUSTOM_COLOR, + SettingKey.BSH_COMMON_AMBIENT_LIGHT_CUSTOM_COLOR: "#b5adcc", + }, + SERVICE_TURN_ON, + { + "hs_color": (255.484, 15.196), + "brightness": 199, + }, + [HomeConnectError, None, None, HomeConnectError], r"Error.*set.*color.*", ), ], - indirect=["problematic_appliance"], ) -async def test_switch_exception_handling( +async def test_light_exception_handling( entity_id: str, - status: dict, + setting: dict[SettingKey, dict[str, Any]], service: str, service_data: dict, - mock_attr: str, - attr_side_effect: list, - problematic_appliance: Mock, + attr_side_effect: list[type[HomeConnectError] | None], exception_match: str, - bypass_throttle: Generator[None], hass: HomeAssistant, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], config_entry: MockConfigEntry, setup_credentials: None, - get_appliances: MagicMock, + client_with_exception: MagicMock, ) -> None: """Test light exception handling.""" - problematic_appliance.status.update(SETTINGS_STATUS) - problematic_appliance.set_setting.side_effect = attr_side_effect - get_appliances.return_value = [problematic_appliance] - + client_with_exception.get_settings.side_effect = None + client_with_exception.get_settings.return_value = ArrayOfSettings( + [ + GetSetting( + key=setting_key, + raw_key=setting_key.value, + value=value, + ) + for setting_key, value in setting.items() + ] + ) + client_with_exception.set_setting.side_effect = [ + exception() if exception else None for exception in attr_side_effect + ] assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client_with_exception) assert config_entry.state == ConfigEntryState.LOADED # Assert that an exception is called. with pytest.raises(HomeConnectError): - getattr(problematic_appliance, mock_attr)() + await client_with_exception.set_setting() - problematic_appliance.status.update(status) service_data["entity_id"] = entity_id with pytest.raises(HomeAssistantError, match=exception_match): await hass.services.async_call( LIGHT_DOMAIN, service, service_data, blocking=True ) - assert getattr(problematic_appliance, mock_attr).call_count == len(attr_side_effect) + assert client_with_exception.set_setting.call_count == len(attr_side_effect) diff --git a/tests/components/home_connect/test_number.py b/tests/components/home_connect/test_number.py index bce19161cf8..371aed928dd 100644 --- a/tests/components/home_connect/test_number.py +++ b/tests/components/home_connect/test_number.py @@ -1,22 +1,17 @@ """Tests for home_connect number entities.""" -from collections.abc import Awaitable, Callable, Generator +from collections.abc import Awaitable, Callable import random -from unittest.mock import MagicMock, Mock +from unittest.mock import AsyncMock, MagicMock -from homeconnect.api import HomeConnectError +from aiohomeconnect.model import ArrayOfSettings, GetSetting, SettingKey +from aiohomeconnect.model.error import HomeConnectError +from aiohomeconnect.model.setting import SettingConstraints import pytest -from homeassistant.components.home_connect.const import ( - ATTR_CONSTRAINTS, - ATTR_STEPSIZE, - ATTR_UNIT, - ATTR_VALUE, -) from homeassistant.components.number import ( - ATTR_MAX, - ATTR_MIN, ATTR_VALUE as SERVICE_ATTR_VALUE, + DEFAULT_MAX_VALUE, DEFAULT_MIN_VALUE, DOMAIN as NUMBER_DOMAIN, SERVICE_SET_VALUE, @@ -26,8 +21,6 @@ from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from .conftest import get_all_appliances - from tests.common import MockConfigEntry @@ -38,25 +31,24 @@ def platforms() -> list[str]: async def test_number( - bypass_throttle: Generator[None], - hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: Mock, + client: MagicMock, ) -> None: """Test number entity.""" - get_appliances.side_effect = get_all_appliances assert config_entry.state is ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state is ConfigEntryState.LOADED -@pytest.mark.parametrize("appliance", ["Refrigerator"], indirect=True) +@pytest.mark.parametrize("appliance_ha_id", ["FridgeFreezer"], indirect=True) @pytest.mark.parametrize( ( "entity_id", "setting_key", + "type", + "expected_state", "min_value", "max_value", "step_size", @@ -64,102 +56,132 @@ async def test_number( ), [ ( - f"{NUMBER_DOMAIN.lower()}.refrigerator_refrigerator_temperature", - "Refrigeration.FridgeFreezer.Setting.SetpointTemperatureRefrigerator", + f"{NUMBER_DOMAIN.lower()}.fridgefreezer_refrigerator_temperature", + SettingKey.REFRIGERATION_FRIDGE_FREEZER_SETPOINT_TEMPERATURE_REFRIGERATOR, + "Double", + 8, 7, 15, 0.1, "°C", ), + ( + f"{NUMBER_DOMAIN.lower()}.fridgefreezer_refrigerator_temperature", + SettingKey.REFRIGERATION_FRIDGE_FREEZER_SETPOINT_TEMPERATURE_REFRIGERATOR, + "Double", + 8, + 7, + 15, + 5, + "°C", + ), ], ) -@pytest.mark.usefixtures("bypass_throttle") async def test_number_entity_functionality( - appliance: Mock, + appliance_ha_id: str, entity_id: str, - setting_key: str, - bypass_throttle: Generator[None], + setting_key: SettingKey, + type: str, + expected_state: int, min_value: int, max_value: int, step_size: float, unit_of_measurement: str, hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: MagicMock, + client: MagicMock, ) -> None: """Test number entity functionality.""" - appliance.get.side_effect = [ - { - ATTR_CONSTRAINTS: { - ATTR_MIN: min_value, - ATTR_MAX: max_value, - ATTR_STEPSIZE: step_size, - }, - ATTR_UNIT: unit_of_measurement, - } - ] - get_appliances.return_value = [appliance] - current_value = min_value - appliance.status.update({setting_key: {ATTR_VALUE: current_value}}) + client.get_setting.side_effect = None + client.get_setting = AsyncMock( + return_value=GetSetting( + key=setting_key, + raw_key=setting_key.value, + value="", # This should not change the value + unit=unit_of_measurement, + type=type, + constraints=SettingConstraints( + min=min_value, + max=max_value, + step_size=step_size if isinstance(step_size, int) else None, + ), + ) + ) assert config_entry.state is ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state is ConfigEntryState.LOADED - assert hass.states.is_state(entity_id, str(current_value)) - state = hass.states.get(entity_id) - assert state.attributes["min"] == min_value - assert state.attributes["max"] == max_value - assert state.attributes["step"] == step_size - assert state.attributes["unit_of_measurement"] == unit_of_measurement + entity_state = hass.states.get(entity_id) + assert entity_state + assert entity_state.state == str(expected_state) + attributes = entity_state.attributes + assert attributes["min"] == min_value + assert attributes["max"] == max_value + assert attributes["step"] == step_size + assert attributes["unit_of_measurement"] == unit_of_measurement - new_value = random.randint(min_value + 1, max_value) + value = random.choice( + [num for num in range(min_value, max_value + 1) if num != expected_state] + ) await hass.services.async_call( NUMBER_DOMAIN, SERVICE_SET_VALUE, { ATTR_ENTITY_ID: entity_id, - SERVICE_ATTR_VALUE: new_value, + SERVICE_ATTR_VALUE: value, }, - blocking=True, ) - appliance.set_setting.assert_called_once_with(setting_key, new_value) + await hass.async_block_till_done() + client.set_setting.assert_awaited_once_with( + appliance_ha_id, setting_key=setting_key, value=value + ) + assert hass.states.is_state(entity_id, str(float(value))) -@pytest.mark.parametrize("problematic_appliance", ["Refrigerator"], indirect=True) @pytest.mark.parametrize( ("entity_id", "setting_key", "mock_attr"), [ ( - f"{NUMBER_DOMAIN.lower()}.refrigerator_refrigerator_temperature", - "Refrigeration.FridgeFreezer.Setting.SetpointTemperatureRefrigerator", + f"{NUMBER_DOMAIN.lower()}.fridgefreezer_refrigerator_temperature", + SettingKey.REFRIGERATION_FRIDGE_FREEZER_SETPOINT_TEMPERATURE_REFRIGERATOR, "set_setting", ), ], ) -@pytest.mark.usefixtures("bypass_throttle") async def test_number_entity_error( - problematic_appliance: Mock, entity_id: str, - setting_key: str, + setting_key: SettingKey, mock_attr: str, hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: MagicMock, + client_with_exception: MagicMock, ) -> None: """Test number entity error.""" - get_appliances.return_value = [problematic_appliance] - + client_with_exception.get_settings.side_effect = None + client_with_exception.get_settings.return_value = ArrayOfSettings( + [ + GetSetting( + key=setting_key, + raw_key=setting_key.value, + value=DEFAULT_MIN_VALUE, + constraints=SettingConstraints( + min=int(DEFAULT_MIN_VALUE), + max=int(DEFAULT_MAX_VALUE), + step_size=1, + ), + ) + ] + ) assert config_entry.state is ConfigEntryState.NOT_LOADED - problematic_appliance.status.update({setting_key: {}}) - assert await integration_setup() + assert await integration_setup(client_with_exception) assert config_entry.state is ConfigEntryState.LOADED with pytest.raises(HomeConnectError): - getattr(problematic_appliance, mock_attr)() + await getattr(client_with_exception, mock_attr)() with pytest.raises( HomeAssistantError, match=r"Error.*assign.*value.*to.*setting.*" @@ -173,4 +195,4 @@ async def test_number_entity_error( }, blocking=True, ) - assert getattr(problematic_appliance, mock_attr).call_count == 2 + assert getattr(client_with_exception, mock_attr).call_count == 2 diff --git a/tests/components/home_connect/test_select.py b/tests/components/home_connect/test_select.py index af975979196..6ebd37266cd 100644 --- a/tests/components/home_connect/test_select.py +++ b/tests/components/home_connect/test_select.py @@ -1,39 +1,38 @@ """Tests for home_connect select entities.""" -from collections.abc import Awaitable, Callable, Generator -from unittest.mock import MagicMock, Mock +from collections.abc import Awaitable, Callable +from unittest.mock import MagicMock -from homeconnect.api import HomeConnectError +from aiohomeconnect.model import ( + ArrayOfAvailablePrograms, + ArrayOfEvents, + Event, + EventKey, + EventMessage, + EventType, + ProgramKey, +) +from aiohomeconnect.model.error import HomeConnectError +from aiohomeconnect.model.program import EnumerateAvailableProgram import pytest -from homeassistant.components.home_connect.const import ( - BSH_ACTIVE_PROGRAM, - BSH_SELECTED_PROGRAM, -) from homeassistant.components.select import ( ATTR_OPTION, ATTR_OPTIONS, DOMAIN as SELECT_DOMAIN, ) from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import ATTR_ENTITY_ID, SERVICE_SELECT_OPTION, Platform +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_SELECT_OPTION, + STATE_UNKNOWN, + Platform, +) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er -from .conftest import get_all_appliances - -from tests.common import MockConfigEntry, load_json_object_fixture - -SETTINGS_STATUS = { - setting.pop("key"): setting - for setting in load_json_object_fixture("home_connect/settings.json") - .get("Washer") - .get("data") - .get("settings") -} - -PROGRAM = "Dishcare.Dishwasher.Program.Eco50" +from tests.common import MockConfigEntry @pytest.fixture @@ -43,119 +42,148 @@ def platforms() -> list[str]: async def test_select( - bypass_throttle: Generator[None], - hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: Mock, + client: MagicMock, ) -> None: """Test select entity.""" - get_appliances.side_effect = get_all_appliances assert config_entry.state is ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state is ConfigEntryState.LOADED async def test_filter_unknown_programs( - bypass_throttle: Generator[None], - hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: Mock, - appliance: Mock, + client: MagicMock, entity_registry: er.EntityRegistry, ) -> None: - """Test select that programs that are not part of the official Home Connect API specification are filtered out. - - We use two programs to ensure that programs are iterated over a copy of the list, - and it does not raise problems when removing an element from the original list. - """ - appliance.status.update(SETTINGS_STATUS) - appliance.get_programs_available.return_value = [ - PROGRAM, - "NonOfficialProgram", - "AntotherNonOfficialProgram", - ] - get_appliances.return_value = [appliance] + """Test select that only known programs are shown.""" + client.get_available_programs.side_effect = None + client.get_available_programs.return_value = ArrayOfAvailablePrograms( + [ + EnumerateAvailableProgram( + key=ProgramKey.DISHCARE_DISHWASHER_ECO_50, + raw_key=ProgramKey.DISHCARE_DISHWASHER_ECO_50.value, + ), + EnumerateAvailableProgram( + key=ProgramKey.UNKNOWN, + raw_key="an unknown program", + ), + ] + ) assert config_entry.state is ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state is ConfigEntryState.LOADED - entity = entity_registry.async_get("select.washer_selected_program") + entity = entity_registry.async_get("select.dishwasher_selected_program") assert entity - assert entity.capabilities.get(ATTR_OPTIONS) == [ - "dishcare_dishwasher_program_eco_50" - ] + assert entity.capabilities + assert entity.capabilities[ATTR_OPTIONS] == ["dishcare_dishwasher_program_eco_50"] @pytest.mark.parametrize( - ("entity_id", "status", "program_to_set"), + ( + "appliance_ha_id", + "entity_id", + "mock_method", + "program_key", + "program_to_set", + "event_key", + ), [ ( - "select.washer_selected_program", - {BSH_SELECTED_PROGRAM: {"value": PROGRAM}}, + "Dishwasher", + "select.dishwasher_selected_program", + "set_selected_program", + ProgramKey.DISHCARE_DISHWASHER_ECO_50, "dishcare_dishwasher_program_eco_50", + EventKey.BSH_COMMON_ROOT_SELECTED_PROGRAM, ), ( - "select.washer_active_program", - {BSH_ACTIVE_PROGRAM: {"value": PROGRAM}}, + "Dishwasher", + "select.dishwasher_active_program", + "start_program", + ProgramKey.DISHCARE_DISHWASHER_ECO_50, "dishcare_dishwasher_program_eco_50", + EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM, ), ], + indirect=["appliance_ha_id"], ) -async def test_select_functionality( +async def test_select_program_functionality( + appliance_ha_id: str, entity_id: str, - status: dict, + mock_method: str, + program_key: ProgramKey, program_to_set: str, - bypass_throttle: Generator[None], + event_key: EventKey, hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - appliance: Mock, - get_appliances: MagicMock, + client: MagicMock, ) -> None: """Test select functionality.""" - appliance.status.update(SETTINGS_STATUS) - appliance.get_programs_available.return_value = [PROGRAM] - get_appliances.return_value = [appliance] - assert config_entry.state is ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state is ConfigEntryState.LOADED - appliance.status.update(status) + assert hass.states.is_state(entity_id, "unknown") await hass.services.async_call( SELECT_DOMAIN, SERVICE_SELECT_OPTION, {ATTR_ENTITY_ID: entity_id, ATTR_OPTION: program_to_set}, - blocking=True, + ) + await hass.async_block_till_done() + getattr(client, mock_method).assert_awaited_once_with( + appliance_ha_id, program_key=program_key ) assert hass.states.is_state(entity_id, program_to_set) + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.NOTIFY, + ArrayOfEvents( + [ + Event( + key=event_key, + raw_key=event_key.value, + timestamp=0, + level="", + handling="", + value="A not known program", + ) + ] + ), + ) + ] + ) + await hass.async_block_till_done() + assert hass.states.is_state(entity_id, STATE_UNKNOWN) + @pytest.mark.parametrize( ( "entity_id", - "status", "program_to_set", "mock_attr", "exception_match", ), [ ( - "select.washer_selected_program", - {BSH_SELECTED_PROGRAM: {"value": PROGRAM}}, + "select.dishwasher_selected_program", "dishcare_dishwasher_program_eco_50", - "select_program", + "set_selected_program", r"Error.*select.*program.*", ), ( - "select.washer_active_program", - {BSH_ACTIVE_PROGRAM: {"value": PROGRAM}}, + "select.dishwasher_active_program", "dishcare_dishwasher_program_eco_50", "start_program", r"Error.*start.*program.*", @@ -164,32 +192,36 @@ async def test_select_functionality( ) async def test_select_exception_handling( entity_id: str, - status: dict, program_to_set: str, mock_attr: str, exception_match: str, - bypass_throttle: Generator[None], hass: HomeAssistant, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], config_entry: MockConfigEntry, setup_credentials: None, - problematic_appliance: Mock, - get_appliances: MagicMock, + client_with_exception: MagicMock, ) -> None: """Test exception handling.""" - problematic_appliance.get_programs_available.side_effect = None - problematic_appliance.get_programs_available.return_value = [PROGRAM] - get_appliances.return_value = [problematic_appliance] + client_with_exception.get_available_programs.side_effect = None + client_with_exception.get_available_programs.return_value = ( + ArrayOfAvailablePrograms( + [ + EnumerateAvailableProgram( + key=ProgramKey.DISHCARE_DISHWASHER_ECO_50, + raw_key=ProgramKey.DISHCARE_DISHWASHER_ECO_50.value, + ) + ] + ) + ) assert config_entry.state is ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client_with_exception) assert config_entry.state is ConfigEntryState.LOADED # Assert that an exception is called. with pytest.raises(HomeConnectError): - getattr(problematic_appliance, mock_attr)() + await getattr(client_with_exception, mock_attr)() - problematic_appliance.status.update(status) with pytest.raises(HomeAssistantError, match=exception_match): await hass.services.async_call( SELECT_DOMAIN, @@ -197,4 +229,4 @@ async def test_select_exception_handling( {"entity_id": entity_id, "option": program_to_set}, blocking=True, ) - assert getattr(problematic_appliance, mock_attr).call_count == 2 + assert getattr(client_with_exception, mock_attr).call_count == 2 diff --git a/tests/components/home_connect/test_sensor.py b/tests/components/home_connect/test_sensor.py index f2ee3b13922..ce06a841bbb 100644 --- a/tests/components/home_connect/test_sensor.py +++ b/tests/components/home_connect/test_sensor.py @@ -1,75 +1,77 @@ """Tests for home_connect sensor entities.""" from collections.abc import Awaitable, Callable -from unittest.mock import MagicMock, Mock +from unittest.mock import MagicMock +from aiohomeconnect.model import ( + ArrayOfEvents, + Event, + EventKey, + EventMessage, + EventType, + Status, + StatusKey, +) from freezegun.api import FrozenDateTimeFactory -from homeconnect.api import HomeConnectAPI import pytest from homeassistant.components.home_connect.const import ( - BSH_DOOR_STATE, BSH_DOOR_STATE_CLOSED, BSH_DOOR_STATE_LOCKED, BSH_DOOR_STATE_OPEN, BSH_EVENT_PRESENT_STATE_CONFIRMED, BSH_EVENT_PRESENT_STATE_OFF, BSH_EVENT_PRESENT_STATE_PRESENT, - COFFEE_EVENT_BEAN_CONTAINER_EMPTY, - REFRIGERATION_EVENT_DOOR_ALARM_FREEZER, ) from homeassistant.config_entries import ConfigEntryState from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_component import async_update_entity -from tests.common import MockConfigEntry, load_json_object_fixture +from tests.common import MockConfigEntry TEST_HC_APP = "Dishwasher" EVENT_PROG_DELAYED_START = { - "BSH.Common.Status.OperationState": { - "value": "BSH.Common.EnumType.OperationState.DelayedStart" - }, -} - -EVENT_PROG_REMAIN_NO_VALUE = { - "BSH.Common.Option.RemainingProgramTime": {}, - "BSH.Common.Status.OperationState": { - "value": "BSH.Common.EnumType.OperationState.DelayedStart" + EventType.STATUS: { + EventKey.BSH_COMMON_STATUS_OPERATION_STATE: "BSH.Common.EnumType.OperationState.DelayedStart", }, } EVENT_PROG_RUN = { - "BSH.Common.Option.RemainingProgramTime": {"value": "0"}, - "BSH.Common.Option.ProgramProgress": {"value": "60"}, - "BSH.Common.Status.OperationState": { - "value": "BSH.Common.EnumType.OperationState.Run" + EventType.STATUS: { + EventKey.BSH_COMMON_STATUS_OPERATION_STATE: "BSH.Common.EnumType.OperationState.Run", + }, + EventType.EVENT: { + EventKey.BSH_COMMON_OPTION_REMAINING_PROGRAM_TIME: 0, + EventKey.BSH_COMMON_OPTION_PROGRAM_PROGRESS: 60, }, } - EVENT_PROG_UPDATE_1 = { - "BSH.Common.Option.RemainingProgramTime": {"value": "0"}, - "BSH.Common.Option.ProgramProgress": {"value": "80"}, - "BSH.Common.Status.OperationState": { - "value": "BSH.Common.EnumType.OperationState.Run" + EventType.EVENT: { + EventKey.BSH_COMMON_OPTION_REMAINING_PROGRAM_TIME: 0, + EventKey.BSH_COMMON_OPTION_PROGRAM_PROGRESS: 80, + }, + EventType.STATUS: { + EventKey.BSH_COMMON_STATUS_OPERATION_STATE: "BSH.Common.EnumType.OperationState.Run", }, } EVENT_PROG_UPDATE_2 = { - "BSH.Common.Option.RemainingProgramTime": {"value": "20"}, - "BSH.Common.Option.ProgramProgress": {"value": "99"}, - "BSH.Common.Status.OperationState": { - "value": "BSH.Common.EnumType.OperationState.Run" + EventType.EVENT: { + EventKey.BSH_COMMON_OPTION_REMAINING_PROGRAM_TIME: 20, + EventKey.BSH_COMMON_OPTION_PROGRAM_PROGRESS: 99, + }, + EventType.STATUS: { + EventKey.BSH_COMMON_STATUS_OPERATION_STATE: "BSH.Common.EnumType.OperationState.Run", }, } EVENT_PROG_END = { - "BSH.Common.Status.OperationState": { - "value": "BSH.Common.EnumType.OperationState.Ready" + EventType.STATUS: { + EventKey.BSH_COMMON_STATUS_OPERATION_STATE: "BSH.Common.EnumType.OperationState.Ready", }, } @@ -80,22 +82,19 @@ def platforms() -> list[str]: return [Platform.SENSOR] -@pytest.mark.usefixtures("bypass_throttle") async def test_sensors( config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: MagicMock, - appliance: Mock, + client: MagicMock, ) -> None: """Test sensor entities.""" - get_appliances.return_value = [appliance] assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED -# Appliance program sequence with a delayed start. +# Appliance_ha_id program sequence with a delayed start. PROGRAM_SEQUENCE_EVENTS = ( EVENT_PROG_DELAYED_START, EVENT_PROG_RUN, @@ -130,7 +129,7 @@ ENTITY_ID_STATES = { } -@pytest.mark.parametrize("appliance", [TEST_HC_APP], indirect=True) +@pytest.mark.parametrize("appliance_ha_id", [TEST_HC_APP], indirect=True) @pytest.mark.parametrize( ("states", "event_run"), list( @@ -141,17 +140,16 @@ ENTITY_ID_STATES = { ) ), ) -@pytest.mark.usefixtures("bypass_throttle") async def test_event_sensors( - appliance: Mock, + client: MagicMock, + appliance_ha_id: str, states: tuple, - event_run: dict, + event_run: dict[EventType, dict[EventKey, str | int]], freezer: FrozenDateTimeFactory, hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: MagicMock, ) -> None: """Test sequence for sensors that are only available after an event happens.""" entity_ids = ENTITY_ID_STATES.keys() @@ -159,24 +157,48 @@ async def test_event_sensors( time_to_freeze = "2021-01-09 12:00:00+00:00" freezer.move_to(time_to_freeze) - get_appliances.return_value = [appliance] - assert config_entry.state == ConfigEntryState.NOT_LOADED - appliance.get_programs_available = MagicMock(return_value=["dummy_program"]) - appliance.status.update(EVENT_PROG_DELAYED_START) - assert await integration_setup() + client.get_status.return_value.status.extend( + Status( + key=StatusKey(event_key.value), + raw_key=event_key.value, + value=value, + ) + for event_key, value in EVENT_PROG_DELAYED_START[EventType.STATUS].items() + ) + assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED - appliance.status.update(event_run) + await client.add_events( + [ + EventMessage( + appliance_ha_id, + event_type, + ArrayOfEvents( + [ + Event( + key=event_key, + raw_key=event_key.value, + timestamp=0, + level="", + handling="", + value=value, + ) + ], + ), + ) + for event_type, events in event_run.items() + for event_key, value in events.items() + ] + ) + await hass.async_block_till_done() for entity_id, state in zip(entity_ids, states, strict=False): - await async_update_entity(hass, entity_id) - await hass.async_block_till_done() assert hass.states.is_state(entity_id, state) # Program sequence for SensorDeviceClass.TIMESTAMP edge cases. PROGRAM_SEQUENCE_EDGE_CASE = [ - EVENT_PROG_REMAIN_NO_VALUE, + EVENT_PROG_DELAYED_START, EVENT_PROG_RUN, EVENT_PROG_END, EVENT_PROG_END, @@ -191,60 +213,86 @@ ENTITY_ID_EDGE_CASE_STATES = [ ] -@pytest.mark.parametrize("appliance", [TEST_HC_APP], indirect=True) -@pytest.mark.usefixtures("bypass_throttle") +@pytest.mark.parametrize("appliance_ha_id", [TEST_HC_APP], indirect=True) async def test_remaining_prog_time_edge_cases( - appliance: Mock, + appliance_ha_id: str, freezer: FrozenDateTimeFactory, hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: MagicMock, + client: MagicMock, ) -> None: """Run program sequence to test edge cases for the remaining_prog_time entity.""" - get_appliances.return_value = [appliance] entity_id = "sensor.dishwasher_program_finish_time" time_to_freeze = "2021-01-09 12:00:00+00:00" freezer.move_to(time_to_freeze) assert config_entry.state == ConfigEntryState.NOT_LOADED - appliance.get_programs_available = MagicMock(return_value=["dummy_program"]) - appliance.status.update(EVENT_PROG_REMAIN_NO_VALUE) - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED for ( event, expected_state, ) in zip(PROGRAM_SEQUENCE_EDGE_CASE, ENTITY_ID_EDGE_CASE_STATES, strict=False): - appliance.status.update(event) - await async_update_entity(hass, entity_id) + await client.add_events( + [ + EventMessage( + appliance_ha_id, + event_type, + ArrayOfEvents( + [ + Event( + key=event_key, + raw_key=event_key.value, + timestamp=0, + level="", + handling="", + value=value, + ) + ], + ), + ) + for event_type, events in event.items() + for event_key, value in events.items() + ] + ) await hass.async_block_till_done() freezer.tick() assert hass.states.is_state(entity_id, expected_state) @pytest.mark.parametrize( - ("entity_id", "status_key", "event_value_update", "expected", "appliance"), + ( + "entity_id", + "event_key", + "event_type", + "event_value_update", + "expected", + "appliance_ha_id", + ), [ ( "sensor.dishwasher_door", - BSH_DOOR_STATE, + EventKey.BSH_COMMON_STATUS_DOOR_STATE, + EventType.STATUS, BSH_DOOR_STATE_LOCKED, "locked", "Dishwasher", ), ( "sensor.dishwasher_door", - BSH_DOOR_STATE, + EventKey.BSH_COMMON_STATUS_DOOR_STATE, + EventType.STATUS, BSH_DOOR_STATE_CLOSED, "closed", "Dishwasher", ), ( "sensor.dishwasher_door", - BSH_DOOR_STATE, + EventKey.BSH_COMMON_STATUS_DOOR_STATE, + EventType.STATUS, BSH_DOOR_STATE_OPEN, "open", "Dishwasher", @@ -252,33 +300,38 @@ async def test_remaining_prog_time_edge_cases( ( "sensor.fridgefreezer_freezer_door_alarm", "EVENT_NOT_IN_STATUS_YET_SO_SET_TO_OFF", + EventType.EVENT, "", "off", "FridgeFreezer", ), ( "sensor.fridgefreezer_freezer_door_alarm", - REFRIGERATION_EVENT_DOOR_ALARM_FREEZER, + EventKey.REFRIGERATION_FRIDGE_FREEZER_EVENT_DOOR_ALARM_FREEZER, + EventType.EVENT, BSH_EVENT_PRESENT_STATE_OFF, "off", "FridgeFreezer", ), ( "sensor.fridgefreezer_freezer_door_alarm", - REFRIGERATION_EVENT_DOOR_ALARM_FREEZER, + EventKey.REFRIGERATION_FRIDGE_FREEZER_EVENT_DOOR_ALARM_FREEZER, + EventType.EVENT, BSH_EVENT_PRESENT_STATE_PRESENT, "present", "FridgeFreezer", ), ( "sensor.fridgefreezer_freezer_door_alarm", - REFRIGERATION_EVENT_DOOR_ALARM_FREEZER, + EventKey.REFRIGERATION_FRIDGE_FREEZER_EVENT_DOOR_ALARM_FREEZER, + EventType.EVENT, BSH_EVENT_PRESENT_STATE_CONFIRMED, "confirmed", "FridgeFreezer", ), ( "sensor.coffeemaker_bean_container_empty", + EventType.EVENT, "EVENT_NOT_IN_STATUS_YET_SO_SET_TO_OFF", "", "off", @@ -286,52 +339,68 @@ async def test_remaining_prog_time_edge_cases( ), ( "sensor.coffeemaker_bean_container_empty", - COFFEE_EVENT_BEAN_CONTAINER_EMPTY, + EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_BEAN_CONTAINER_EMPTY, + EventType.EVENT, BSH_EVENT_PRESENT_STATE_OFF, "off", "CoffeeMaker", ), ( "sensor.coffeemaker_bean_container_empty", - COFFEE_EVENT_BEAN_CONTAINER_EMPTY, + EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_BEAN_CONTAINER_EMPTY, + EventType.EVENT, BSH_EVENT_PRESENT_STATE_PRESENT, "present", "CoffeeMaker", ), ( "sensor.coffeemaker_bean_container_empty", - COFFEE_EVENT_BEAN_CONTAINER_EMPTY, + EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_BEAN_CONTAINER_EMPTY, + EventType.EVENT, BSH_EVENT_PRESENT_STATE_CONFIRMED, "confirmed", "CoffeeMaker", ), ], - indirect=["appliance"], + indirect=["appliance_ha_id"], ) -@pytest.mark.usefixtures("bypass_throttle") async def test_sensors_states( entity_id: str, - status_key: str, + event_key: EventKey, + event_type: EventType, event_value_update: str, - appliance: Mock, + appliance_ha_id: str, expected: str, hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: MagicMock, + client: MagicMock, ) -> None: - """Tests for Appliance alarm sensors.""" - appliance.status.update( - HomeConnectAPI.json2dict( - load_json_object_fixture("home_connect/status.json")["data"]["status"] - ) - ) - get_appliances.return_value = [appliance] + """Tests for Appliance_ha_id alarm sensors.""" assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED - appliance.status.update({status_key: {"value": event_value_update}}) - await async_update_entity(hass, entity_id) + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + event_type, + ArrayOfEvents( + [ + Event( + key=event_key, + raw_key=str(event_key), + timestamp=0, + level="", + handling="", + value=event_value_update, + ) + ], + ), + ), + ] + ) await hass.async_block_till_done() assert hass.states.is_state(entity_id, expected) diff --git a/tests/components/home_connect/test_switch.py b/tests/components/home_connect/test_switch.py index 80bfcf9db96..10d393423be 100644 --- a/tests/components/home_connect/test_switch.py +++ b/tests/components/home_connect/test_switch.py @@ -1,24 +1,34 @@ """Tests for home_connect sensor entities.""" -from collections.abc import Awaitable, Callable, Generator -from unittest.mock import MagicMock, Mock +from collections.abc import Awaitable, Callable +from typing import Any +from unittest.mock import AsyncMock, MagicMock -from homeconnect.api import HomeConnectAppliance, HomeConnectError +from aiohomeconnect.model import ( + ArrayOfSettings, + Event, + EventKey, + EventMessage, + GetSetting, + ProgramKey, + SettingKey, +) +from aiohomeconnect.model.error import HomeConnectError +from aiohomeconnect.model.event import ArrayOfEvents, EventType +from aiohomeconnect.model.program import ( + ArrayOfAvailablePrograms, + EnumerateAvailableProgram, +) +from aiohomeconnect.model.setting import SettingConstraints import pytest from homeassistant.components import automation, script from homeassistant.components.automation import automations_with_entity from homeassistant.components.home_connect.const import ( - ATTR_ALLOWED_VALUES, - ATTR_CONSTRAINTS, - BSH_ACTIVE_PROGRAM, - BSH_CHILD_LOCK_STATE, BSH_POWER_OFF, BSH_POWER_ON, BSH_POWER_STANDBY, - BSH_POWER_STATE, DOMAIN, - REFRIGERATION_SUPERMODEFREEZER, ) from homeassistant.components.script import scripts_with_entity from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN @@ -36,19 +46,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import issue_registry as ir from homeassistant.setup import async_setup_component -from .conftest import get_all_appliances - -from tests.common import MockConfigEntry, load_json_object_fixture - -SETTINGS_STATUS = { - setting.pop("key"): setting - for setting in load_json_object_fixture("home_connect/settings.json") - .get("Dishwasher") - .get("data") - .get("settings") -} - -PROGRAM = "LaundryCare.Dryer.Program.Mix" +from tests.common import MockConfigEntry @pytest.fixture @@ -58,231 +56,285 @@ def platforms() -> list[str]: async def test_switches( - bypass_throttle: Generator[None], hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: Mock, + client: MagicMock, ) -> None: """Test switch entities.""" - get_appliances.side_effect = get_all_appliances assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED -@pytest.mark.parametrize( - ("entity_id", "status", "service", "state", "appliance"), - [ - ( - "switch.dishwasher_program_mix", - {BSH_ACTIVE_PROGRAM: {"value": PROGRAM}}, - SERVICE_TURN_ON, - STATE_ON, - "Dishwasher", - ), - ( - "switch.dishwasher_program_mix", - {BSH_ACTIVE_PROGRAM: {"value": ""}}, - SERVICE_TURN_OFF, - STATE_OFF, - "Dishwasher", - ), - ( - "switch.dishwasher_child_lock", - {BSH_CHILD_LOCK_STATE: {"value": True}}, - SERVICE_TURN_ON, - STATE_ON, - "Dishwasher", - ), - ( - "switch.dishwasher_child_lock", - {BSH_CHILD_LOCK_STATE: {"value": False}}, - SERVICE_TURN_OFF, - STATE_OFF, - "Dishwasher", - ), - ], - indirect=["appliance"], -) -async def test_switch_functionality( - entity_id: str, - status: dict, - service: str, - state: str, - bypass_throttle: Generator[None], - hass: HomeAssistant, - config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], - setup_credentials: None, - appliance: Mock, - get_appliances: MagicMock, -) -> None: - """Test switch functionality.""" - appliance.status.update(SETTINGS_STATUS) - appliance.get_programs_available.return_value = [PROGRAM] - get_appliances.return_value = [appliance] - - assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() - assert config_entry.state == ConfigEntryState.LOADED - - appliance.status.update(status) - await hass.services.async_call( - SWITCH_DOMAIN, service, {"entity_id": entity_id}, blocking=True - ) - assert hass.states.is_state(entity_id, state) - - @pytest.mark.parametrize( ( "entity_id", - "status", + "service", + "settings_key_arg", + "setting_value_arg", + "state", + "appliance_ha_id", + ), + [ + ( + "switch.dishwasher_child_lock", + SERVICE_TURN_ON, + SettingKey.BSH_COMMON_CHILD_LOCK, + True, + STATE_ON, + "Dishwasher", + ), + ( + "switch.dishwasher_child_lock", + SERVICE_TURN_OFF, + SettingKey.BSH_COMMON_CHILD_LOCK, + False, + STATE_OFF, + "Dishwasher", + ), + ], + indirect=["appliance_ha_id"], +) +async def test_switch_functionality( + entity_id: str, + settings_key_arg: SettingKey, + setting_value_arg: Any, + service: str, + state: str, + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + appliance_ha_id: str, + client: MagicMock, +) -> None: + """Test switch functionality.""" + + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + await hass.services.async_call(SWITCH_DOMAIN, service, {ATTR_ENTITY_ID: entity_id}) + await hass.async_block_till_done() + client.set_setting.assert_awaited_once_with( + appliance_ha_id, setting_key=settings_key_arg, value=setting_value_arg + ) + assert hass.states.is_state(entity_id, state) + + +@pytest.mark.parametrize( + ("entity_id", "program_key", "appliance_ha_id"), + [ + ( + "switch.dryer_program_mix", + ProgramKey.LAUNDRY_CARE_DRYER_MIX, + "Dryer", + ), + ], + indirect=["appliance_ha_id"], +) +async def test_program_switch_functionality( + entity_id: str, + program_key: ProgramKey, + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + appliance_ha_id: str, + client: MagicMock, +) -> None: + """Test switch functionality.""" + + async def mock_stop_program(ha_id: str) -> None: + """Mock stop program.""" + await client.add_events( + [ + EventMessage( + ha_id, + EventType.NOTIFY, + ArrayOfEvents( + [ + Event( + key=EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM, + raw_key=EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM.value, + timestamp=0, + level="", + handling="", + value=ProgramKey.UNKNOWN, + ) + ] + ), + ), + ] + ) + + client.stop_program = AsyncMock(side_effect=mock_stop_program) + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + await hass.services.async_call( + SWITCH_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id} + ) + await hass.async_block_till_done() + assert hass.states.is_state(entity_id, STATE_ON) + client.start_program.assert_awaited_once_with( + appliance_ha_id, program_key=program_key + ) + + await hass.services.async_call( + SWITCH_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id} + ) + await hass.async_block_till_done() + assert hass.states.is_state(entity_id, STATE_OFF) + client.stop_program.assert_awaited_once_with(appliance_ha_id) + + +@pytest.mark.parametrize( + ( + "entity_id", "service", "mock_attr", - "problematic_appliance", "exception_match", ), [ ( - "switch.dishwasher_program_mix", - {BSH_ACTIVE_PROGRAM: {"value": PROGRAM}}, + "switch.dishwasher_program_eco50", SERVICE_TURN_ON, "start_program", - "Dishwasher", r"Error.*start.*program.*", ), ( - "switch.dishwasher_program_mix", - {BSH_ACTIVE_PROGRAM: {"value": PROGRAM}}, + "switch.dishwasher_program_eco50", SERVICE_TURN_OFF, "stop_program", - "Dishwasher", r"Error.*stop.*program.*", ), ( "switch.dishwasher_power", - {BSH_POWER_STATE: {"value": BSH_POWER_OFF}}, SERVICE_TURN_OFF, "set_setting", - "Dishwasher", r"Error.*turn.*off.*", ), ( "switch.dishwasher_power", - {BSH_POWER_STATE: {"value": ""}}, SERVICE_TURN_ON, "set_setting", - "Dishwasher", r"Error.*turn.*on.*", ), ( "switch.dishwasher_child_lock", - {BSH_CHILD_LOCK_STATE: {"value": ""}}, SERVICE_TURN_ON, "set_setting", - "Dishwasher", r"Error.*turn.*on.*", ), ( "switch.dishwasher_child_lock", - {BSH_CHILD_LOCK_STATE: {"value": ""}}, SERVICE_TURN_OFF, "set_setting", - "Dishwasher", r"Error.*turn.*off.*", ), ], - indirect=["problematic_appliance"], ) async def test_switch_exception_handling( entity_id: str, - status: dict, service: str, mock_attr: str, exception_match: str, - bypass_throttle: Generator[None], hass: HomeAssistant, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], config_entry: MockConfigEntry, setup_credentials: None, - problematic_appliance: Mock, - get_appliances: MagicMock, + client_with_exception: MagicMock, ) -> None: """Test exception handling.""" - problematic_appliance.get_programs_available.side_effect = None - problematic_appliance.get_programs_available.return_value = [PROGRAM] - get_appliances.return_value = [problematic_appliance] + client_with_exception.get_available_programs.side_effect = None + client_with_exception.get_available_programs.return_value = ( + ArrayOfAvailablePrograms( + [ + EnumerateAvailableProgram( + key=ProgramKey.DISHCARE_DISHWASHER_ECO_50, + raw_key=ProgramKey.DISHCARE_DISHWASHER_ECO_50.value, + ) + ] + ) + ) + client_with_exception.get_settings.side_effect = None + client_with_exception.get_settings.return_value = ArrayOfSettings( + [ + GetSetting( + key=SettingKey.BSH_COMMON_CHILD_LOCK, + raw_key=SettingKey.BSH_COMMON_CHILD_LOCK.value, + value=False, + ), + GetSetting( + key=SettingKey.BSH_COMMON_POWER_STATE, + raw_key=SettingKey.BSH_COMMON_POWER_STATE.value, + value=BSH_POWER_ON, + constraints=SettingConstraints( + allowed_values=[BSH_POWER_ON, BSH_POWER_OFF] + ), + ), + ] + ) assert config_entry.state == ConfigEntryState.NOT_LOADED - problematic_appliance.status.update(status) - assert await integration_setup() + assert await integration_setup(client_with_exception) assert config_entry.state == ConfigEntryState.LOADED # Assert that an exception is called. with pytest.raises(HomeConnectError): - getattr(problematic_appliance, mock_attr)() + await getattr(client_with_exception, mock_attr)() with pytest.raises(HomeAssistantError, match=exception_match): await hass.services.async_call( - SWITCH_DOMAIN, service, {"entity_id": entity_id}, blocking=True + SWITCH_DOMAIN, service, {ATTR_ENTITY_ID: entity_id}, blocking=True ) - assert getattr(problematic_appliance, mock_attr).call_count == 2 + assert getattr(client_with_exception, mock_attr).call_count == 2 @pytest.mark.parametrize( - ("entity_id", "status", "service", "state", "appliance"), + ("entity_id", "status", "service", "state", "appliance_ha_id"), [ ( "switch.fridgefreezer_freezer_super_mode", - {REFRIGERATION_SUPERMODEFREEZER: {"value": True}}, + {SettingKey.REFRIGERATION_FRIDGE_FREEZER_SUPER_MODE_FREEZER: True}, SERVICE_TURN_ON, STATE_ON, "FridgeFreezer", ), ( "switch.fridgefreezer_freezer_super_mode", - {REFRIGERATION_SUPERMODEFREEZER: {"value": False}}, + {SettingKey.REFRIGERATION_FRIDGE_FREEZER_SUPER_MODE_FREEZER: False}, SERVICE_TURN_OFF, STATE_OFF, "FridgeFreezer", ), ], - indirect=["appliance"], + indirect=["appliance_ha_id"], ) async def test_ent_desc_switch_functionality( entity_id: str, status: dict, service: str, state: str, - bypass_throttle: Generator[None], hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - appliance: Mock, - get_appliances: MagicMock, + appliance_ha_id: str, + client: MagicMock, ) -> None: """Test switch functionality - entity description setup.""" - appliance.status.update( - HomeConnectAppliance.json2dict( - load_json_object_fixture("home_connect/settings.json") - .get(appliance.name) - .get("data") - .get("settings") - ) - ) - get_appliances.return_value = [appliance] assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED - appliance.status.update(status) - await hass.services.async_call( - SWITCH_DOMAIN, service, {ATTR_ENTITY_ID: entity_id}, blocking=True - ) + await hass.services.async_call(SWITCH_DOMAIN, service, {ATTR_ENTITY_ID: entity_id}) + await hass.async_block_till_done() assert hass.states.is_state(entity_id, state) @@ -292,13 +344,13 @@ async def test_ent_desc_switch_functionality( "status", "service", "mock_attr", - "problematic_appliance", + "appliance_ha_id", "exception_match", ), [ ( "switch.fridgefreezer_freezer_super_mode", - {REFRIGERATION_SUPERMODEFREEZER: {"value": ""}}, + {SettingKey.REFRIGERATION_FRIDGE_FREEZER_SUPER_MODE_FREEZER: ""}, SERVICE_TURN_ON, "set_setting", "FridgeFreezer", @@ -306,203 +358,257 @@ async def test_ent_desc_switch_functionality( ), ( "switch.fridgefreezer_freezer_super_mode", - {REFRIGERATION_SUPERMODEFREEZER: {"value": ""}}, + {SettingKey.REFRIGERATION_FRIDGE_FREEZER_SUPER_MODE_FREEZER: ""}, SERVICE_TURN_OFF, "set_setting", "FridgeFreezer", r"Error.*turn.*off.*", ), ], - indirect=["problematic_appliance"], + indirect=["appliance_ha_id"], ) async def test_ent_desc_switch_exception_handling( entity_id: str, - status: dict, + status: dict[SettingKey, str], service: str, mock_attr: str, exception_match: str, - bypass_throttle: Generator[None], hass: HomeAssistant, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], config_entry: MockConfigEntry, setup_credentials: None, - problematic_appliance: Mock, - get_appliances: MagicMock, + appliance_ha_id: str, + client_with_exception: MagicMock, ) -> None: """Test switch exception handling - entity description setup.""" - problematic_appliance.status.update( - HomeConnectAppliance.json2dict( - load_json_object_fixture("home_connect/settings.json") - .get(problematic_appliance.name) - .get("data") - .get("settings") - ) + client_with_exception.get_settings.side_effect = None + client_with_exception.get_settings.return_value = ArrayOfSettings( + [ + GetSetting( + key=key, + raw_key=key.value, + value=value, + ) + for key, value in status.items() + ] ) - get_appliances.return_value = [problematic_appliance] - assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client_with_exception) assert config_entry.state == ConfigEntryState.LOADED # Assert that an exception is called. with pytest.raises(HomeConnectError): - getattr(problematic_appliance, mock_attr)() - - problematic_appliance.status.update(status) + await client_with_exception.set_setting() with pytest.raises(HomeAssistantError, match=exception_match): await hass.services.async_call( SWITCH_DOMAIN, service, {ATTR_ENTITY_ID: entity_id}, blocking=True ) - assert getattr(problematic_appliance, mock_attr).call_count == 2 + assert client_with_exception.set_setting.call_count == 2 @pytest.mark.parametrize( - ("entity_id", "status", "allowed_values", "service", "power_state", "appliance"), + ( + "entity_id", + "allowed_values", + "service", + "setting_value_arg", + "power_state", + "appliance_ha_id", + ), [ ( "switch.dishwasher_power", - {BSH_POWER_STATE: {"value": BSH_POWER_ON}}, [BSH_POWER_ON, BSH_POWER_OFF], SERVICE_TURN_ON, + BSH_POWER_ON, STATE_ON, "Dishwasher", ), ( "switch.dishwasher_power", - {BSH_POWER_STATE: {"value": BSH_POWER_OFF}}, [BSH_POWER_ON, BSH_POWER_OFF], SERVICE_TURN_OFF, + BSH_POWER_OFF, STATE_OFF, "Dishwasher", ), ( "switch.dishwasher_power", - {BSH_POWER_STATE: {"value": BSH_POWER_ON}}, [BSH_POWER_ON, BSH_POWER_STANDBY], SERVICE_TURN_ON, + BSH_POWER_ON, STATE_ON, "Dishwasher", ), ( "switch.dishwasher_power", - {BSH_POWER_STATE: {"value": BSH_POWER_STANDBY}}, [BSH_POWER_ON, BSH_POWER_STANDBY], SERVICE_TURN_OFF, + BSH_POWER_STANDBY, STATE_OFF, "Dishwasher", ), ], - indirect=["appliance"], + indirect=["appliance_ha_id"], ) -@pytest.mark.usefixtures("bypass_throttle") async def test_power_swtich( entity_id: str, - status: dict, - allowed_values: list[str], + allowed_values: list[str | None] | None, service: str, + setting_value_arg: str, power_state: str, hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - appliance: Mock, - get_appliances: MagicMock, + appliance_ha_id: str, + client: MagicMock, ) -> None: """Test power switch functionality.""" - appliance.get.side_effect = [ - { - ATTR_CONSTRAINTS: { - ATTR_ALLOWED_VALUES: allowed_values, - }, - } - ] - appliance.status.update(SETTINGS_STATUS) - appliance.status.update(status) - get_appliances.return_value = [appliance] + client.get_settings.side_effect = None + client.get_settings.return_value = ArrayOfSettings( + [ + GetSetting( + key=SettingKey.BSH_COMMON_POWER_STATE, + raw_key=SettingKey.BSH_COMMON_POWER_STATE.value, + value="", + constraints=SettingConstraints( + allowed_values=allowed_values, + ), + ) + ] + ) assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED - await hass.services.async_call( - SWITCH_DOMAIN, service, {ATTR_ENTITY_ID: entity_id}, blocking=True + await hass.services.async_call(SWITCH_DOMAIN, service, {ATTR_ENTITY_ID: entity_id}) + await hass.async_block_till_done() + client.set_setting.assert_awaited_once_with( + appliance_ha_id, + setting_key=SettingKey.BSH_COMMON_POWER_STATE, + value=setting_value_arg, ) assert hass.states.is_state(entity_id, power_state) @pytest.mark.parametrize( - ("entity_id", "allowed_values", "service", "appliance", "exception_match"), + ("initial_value"), + [ + (BSH_POWER_OFF), + (BSH_POWER_STANDBY), + ], +) +async def test_power_switch_fetch_off_state_from_current_value( + initial_value: str, + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, +) -> None: + """Test power switch functionality to fetch the off state from the current value.""" + client.get_settings.side_effect = None + client.get_settings.return_value = ArrayOfSettings( + [ + GetSetting( + key=SettingKey.BSH_COMMON_POWER_STATE, + raw_key=SettingKey.BSH_COMMON_POWER_STATE.value, + value=initial_value, + ) + ] + ) + + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + assert hass.states.is_state("switch.dishwasher_power", STATE_OFF) + + +@pytest.mark.parametrize( + ("entity_id", "allowed_values", "service", "exception_match"), [ ( "switch.dishwasher_power", [BSH_POWER_ON], SERVICE_TURN_OFF, - "Dishwasher", r".*not support.*turn.*off.*", ), ( "switch.dishwasher_power", None, SERVICE_TURN_OFF, - "Dishwasher", + r".*Unable.*turn.*off.*support.*not.*determined.*", + ), + ( + "switch.dishwasher_power", + HomeConnectError(), + SERVICE_TURN_OFF, r".*Unable.*turn.*off.*support.*not.*determined.*", ), ], - indirect=["appliance"], ) -@pytest.mark.usefixtures("bypass_throttle") async def test_power_switch_service_validation_errors( entity_id: str, - allowed_values: list[str], + allowed_values: list[str | None] | None | HomeConnectError, service: str, hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - appliance: Mock, exception_match: str, - get_appliances: MagicMock, + client: MagicMock, ) -> None: """Test power switch functionality validation errors.""" - if allowed_values: - appliance.get.side_effect = [ - { - ATTR_CONSTRAINTS: { - ATTR_ALLOWED_VALUES: allowed_values, - }, - } - ] - appliance.status.update(SETTINGS_STATUS) - get_appliances.return_value = [appliance] + client.get_settings.side_effect = None + if isinstance(allowed_values, HomeConnectError): + exception = allowed_values + client.get_settings.return_value = ArrayOfSettings( + [ + GetSetting( + key=SettingKey.BSH_COMMON_POWER_STATE, + raw_key=SettingKey.BSH_COMMON_POWER_STATE.value, + value=BSH_POWER_ON, + ) + ] + ) + client.get_setting = AsyncMock(side_effect=exception) + else: + setting = GetSetting( + key=SettingKey.BSH_COMMON_POWER_STATE, + raw_key=SettingKey.BSH_COMMON_POWER_STATE.value, + value=BSH_POWER_ON, + constraints=SettingConstraints( + allowed_values=allowed_values, + ), + ) + client.get_settings.return_value = ArrayOfSettings([setting]) + client.get_setting = AsyncMock(return_value=setting) assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED - appliance.status.update({BSH_POWER_STATE: {"value": BSH_POWER_ON}}) - with pytest.raises(HomeAssistantError, match=exception_match): await hass.services.async_call( - SWITCH_DOMAIN, service, {"entity_id": entity_id}, blocking=True + SWITCH_DOMAIN, service, {ATTR_ENTITY_ID: entity_id}, blocking=True ) @pytest.mark.usefixtures("entity_registry_enabled_by_default") -@pytest.mark.usefixtures("bypass_throttle") async def test_create_issue( hass: HomeAssistant, - appliance: Mock, + appliance_ha_id: str, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: MagicMock, + client: MagicMock, issue_registry: ir.IssueRegistry, ) -> None: """Test we create an issue when an automation or script is using a deprecated entity.""" entity_id = "switch.washer_program_mix" - appliance.status.update(SETTINGS_STATUS) - appliance.get_programs_available.return_value = [PROGRAM] - get_appliances.return_value = [appliance] issue_id = f"deprecated_program_switch_{entity_id}" assert await async_setup_component( @@ -539,7 +645,7 @@ async def test_create_issue( ) assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED assert automations_with_entity(hass, entity_id)[0] == "automation.test" diff --git a/tests/components/home_connect/test_time.py b/tests/components/home_connect/test_time.py index 1401e07b05a..95f9ddeba80 100644 --- a/tests/components/home_connect/test_time.py +++ b/tests/components/home_connect/test_time.py @@ -1,21 +1,19 @@ """Tests for home_connect time entities.""" -from collections.abc import Awaitable, Callable, Generator +from collections.abc import Awaitable, Callable from datetime import time -from unittest.mock import MagicMock, Mock +from unittest.mock import MagicMock -from homeconnect.api import HomeConnectError +from aiohomeconnect.model import ArrayOfSettings, GetSetting, SettingKey +from aiohomeconnect.model.error import HomeConnectError import pytest -from homeassistant.components.home_connect.const import ATTR_VALUE from homeassistant.components.time import DOMAIN as TIME_DOMAIN, SERVICE_SET_VALUE from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ATTR_ENTITY_ID, ATTR_TIME, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from .conftest import get_all_appliances - from tests.common import MockConfigEntry @@ -26,114 +24,98 @@ def platforms() -> list[str]: async def test_time( - bypass_throttle: Generator[None], - hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: Mock, + client: MagicMock, ) -> None: """Test time entity.""" - get_appliances.side_effect = get_all_appliances assert config_entry.state is ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state is ConfigEntryState.LOADED -@pytest.mark.parametrize("appliance", ["Oven"], indirect=True) +@pytest.mark.parametrize("appliance_ha_id", ["Oven"], indirect=True) @pytest.mark.parametrize( - ("entity_id", "setting_key", "setting_value", "expected_state"), + ("entity_id", "setting_key"), [ ( f"{TIME_DOMAIN}.oven_alarm_clock", - "BSH.Common.Setting.AlarmClock", - {ATTR_VALUE: 59}, - str(time(second=59)), - ), - ( - f"{TIME_DOMAIN}.oven_alarm_clock", - "BSH.Common.Setting.AlarmClock", - {ATTR_VALUE: None}, - "unknown", - ), - ( - f"{TIME_DOMAIN}.oven_alarm_clock", - "BSH.Common.Setting.AlarmClock", - None, - "unknown", + SettingKey.BSH_COMMON_ALARM_CLOCK, ), ], ) -@pytest.mark.usefixtures("bypass_throttle") async def test_time_entity_functionality( - appliance: Mock, + appliance_ha_id: str, entity_id: str, - setting_key: str, - setting_value: dict, - expected_state: str, - bypass_throttle: Generator[None], + setting_key: SettingKey, hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: MagicMock, + client: MagicMock, ) -> None: """Test time entity functionality.""" - get_appliances.return_value = [appliance] - appliance.status.update({setting_key: setting_value}) - assert config_entry.state is ConfigEntryState.NOT_LOADED - assert await integration_setup() + assert await integration_setup(client) assert config_entry.state is ConfigEntryState.LOADED - assert hass.states.is_state(entity_id, expected_state) - new_value = 30 - assert hass.states.get(entity_id).state != new_value + value = 30 + entity_state = hass.states.get(entity_id) + assert entity_state is not None + assert entity_state.state != value await hass.services.async_call( TIME_DOMAIN, SERVICE_SET_VALUE, { ATTR_ENTITY_ID: entity_id, - ATTR_TIME: time(second=new_value), + ATTR_TIME: time(second=value), }, - blocking=True, ) - appliance.set_setting.assert_called_once_with(setting_key, new_value) + await hass.async_block_till_done() + client.set_setting.assert_awaited_once_with( + appliance_ha_id, setting_key=setting_key, value=value + ) + assert hass.states.is_state(entity_id, str(time(second=value))) -@pytest.mark.parametrize("problematic_appliance", ["Oven"], indirect=True) @pytest.mark.parametrize( ("entity_id", "setting_key", "mock_attr"), [ ( f"{TIME_DOMAIN}.oven_alarm_clock", - "BSH.Common.Setting.AlarmClock", + SettingKey.BSH_COMMON_ALARM_CLOCK, "set_setting", ), ], ) -@pytest.mark.usefixtures("bypass_throttle") async def test_time_entity_error( - problematic_appliance: Mock, entity_id: str, - setting_key: str, + setting_key: SettingKey, mock_attr: str, hass: HomeAssistant, config_entry: MockConfigEntry, - integration_setup: Callable[[], Awaitable[bool]], + integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - get_appliances: MagicMock, + client_with_exception: MagicMock, ) -> None: """Test time entity error.""" - get_appliances.return_value = [problematic_appliance] - + client_with_exception.get_settings.side_effect = None + client_with_exception.get_settings.return_value = ArrayOfSettings( + [ + GetSetting( + key=setting_key, + raw_key=setting_key.value, + value=30, + ) + ] + ) assert config_entry.state is ConfigEntryState.NOT_LOADED - problematic_appliance.status.update({setting_key: {}}) - assert await integration_setup() + assert await integration_setup(client_with_exception) assert config_entry.state is ConfigEntryState.LOADED with pytest.raises(HomeConnectError): - getattr(problematic_appliance, mock_attr)() + await getattr(client_with_exception, mock_attr)() with pytest.raises( HomeAssistantError, match=r"Error.*assign.*value.*to.*setting.*" @@ -147,4 +129,4 @@ async def test_time_entity_error( }, blocking=True, ) - assert getattr(problematic_appliance, mock_attr).call_count == 2 + assert getattr(client_with_exception, mock_attr).call_count == 2 From 427c437a68654d149a99ead7dbf94c74f6e35773 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 29 Jan 2025 21:32:10 -0500 Subject: [PATCH 0276/3148] Add start_conversation service to Assist Satellite (#134921) * Add start_conversation service to Assist Satellite * Fix tests * Implement start_conversation in voip * Update homeassistant/components/assist_satellite/entity.py --------- Co-authored-by: Michael Hansen --- .../components/assist_pipeline/pipeline.py | 1 + .../components/assist_satellite/__init__.py | 15 ++ .../components/assist_satellite/const.py | 3 + .../components/assist_satellite/entity.py | 59 +++++++- .../components/assist_satellite/icons.json | 3 + .../components/assist_satellite/services.yaml | 20 +++ .../components/assist_satellite/strings.json | 18 +++ .../components/voip/assist_satellite.py | 38 ++++-- homeassistant/helpers/service.py | 2 + tests/components/assist_satellite/conftest.py | 15 +- .../assist_satellite/test_entity.py | 128 ++++++++++++++++-- tests/components/voip/test_voip.py | 107 ++++++++++++++- 12 files changed, 384 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/assist_pipeline/pipeline.py b/homeassistant/components/assist_pipeline/pipeline.py index 9fdcc2bf690..1d320d79bf2 100644 --- a/homeassistant/components/assist_pipeline/pipeline.py +++ b/homeassistant/components/assist_pipeline/pipeline.py @@ -1122,6 +1122,7 @@ class PipelineRun: context=user_input.context, language=user_input.language, agent_id=user_input.agent_id, + extra_system_prompt=user_input.extra_system_prompt, ) speech = conversation_result.response.speech.get("plain", {}).get( "speech", "" diff --git a/homeassistant/components/assist_satellite/__init__.py b/homeassistant/components/assist_satellite/__init__.py index 47b0123a244..038ff517264 100644 --- a/homeassistant/components/assist_satellite/__init__.py +++ b/homeassistant/components/assist_satellite/__init__.py @@ -63,6 +63,21 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: "async_internal_announce", [AssistSatelliteEntityFeature.ANNOUNCE], ) + component.async_register_entity_service( + "start_conversation", + vol.All( + cv.make_entity_service_schema( + { + vol.Optional("start_message"): str, + vol.Optional("start_media_id"): str, + vol.Optional("extra_system_prompt"): str, + } + ), + cv.has_at_least_one_key("start_message", "start_media_id"), + ), + "async_internal_start_conversation", + [AssistSatelliteEntityFeature.START_CONVERSATION], + ) hass.data[CONNECTION_TEST_DATA] = {} async_register_websocket_api(hass) hass.http.register_view(ConnectionTestView()) diff --git a/homeassistant/components/assist_satellite/const.py b/homeassistant/components/assist_satellite/const.py index 61ac7ecb39d..f7ac7e524b4 100644 --- a/homeassistant/components/assist_satellite/const.py +++ b/homeassistant/components/assist_satellite/const.py @@ -26,3 +26,6 @@ class AssistSatelliteEntityFeature(IntFlag): ANNOUNCE = 1 """Device supports remotely triggered announcements.""" + + START_CONVERSATION = 2 + """Device supports starting conversations.""" diff --git a/homeassistant/components/assist_satellite/entity.py b/homeassistant/components/assist_satellite/entity.py index e9a5d22c0d0..927229c9756 100644 --- a/homeassistant/components/assist_satellite/entity.py +++ b/homeassistant/components/assist_satellite/entity.py @@ -10,7 +10,7 @@ import logging import time from typing import Any, Final, Literal, final -from homeassistant.components import media_source, stt, tts +from homeassistant.components import conversation, media_source, stt, tts from homeassistant.components.assist_pipeline import ( OPTION_PREFERRED, AudioSettings, @@ -27,6 +27,7 @@ from homeassistant.components.tts import ( generate_media_source_id as tts_generate_media_source_id, ) from homeassistant.core import Context, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity from homeassistant.helpers.entity import EntityDescription @@ -117,6 +118,7 @@ class AssistSatelliteEntity(entity.Entity): _run_has_tts: bool = False _is_announcing = False + _extra_system_prompt: str | None = None _wake_word_intercept_future: asyncio.Future[str | None] | None = None _attr_tts_options: dict[str, Any] | None = None _pipeline_task: asyncio.Task | None = None @@ -216,6 +218,60 @@ class AssistSatelliteEntity(entity.Entity): """ raise NotImplementedError + async def async_internal_start_conversation( + self, + start_message: str | None = None, + start_media_id: str | None = None, + extra_system_prompt: str | None = None, + ) -> None: + """Start a conversation from the satellite. + + If start_media_id is not provided, message is synthesized to + audio with the selected pipeline. + + If start_media_id is provided, it is played directly. It is possible + to omit the message and the satellite will not show any text. + + Calls async_start_conversation. + """ + await self._cancel_running_pipeline() + + # The Home Assistant built-in agent doesn't support conversations. + pipeline = async_get_pipeline(self.hass, self._resolve_pipeline()) + if pipeline.conversation_engine == conversation.HOME_ASSISTANT_AGENT: + raise HomeAssistantError( + "Built-in conversation agent does not support starting conversations" + ) + + if start_message is None: + start_message = "" + + announcement = await self._resolve_announcement_media_id( + start_message, start_media_id + ) + + if self._is_announcing: + raise SatelliteBusyError + + self._is_announcing = True + # Provide our start info to the LLM so it understands context of incoming message + if extra_system_prompt is not None: + self._extra_system_prompt = extra_system_prompt + else: + self._extra_system_prompt = start_message or None + + try: + await self.async_start_conversation(announcement) + finally: + self._is_announcing = False + self._extra_system_prompt = None + + async def async_start_conversation( + self, start_announcement: AssistSatelliteAnnouncement + ) -> None: + """Start a conversation from the satellite.""" + raise NotImplementedError + async def async_accept_pipeline_from_satellite( self, audio_stream: AsyncIterable[bytes], @@ -302,6 +358,7 @@ class AssistSatelliteEntity(entity.Entity): ), start_stage=start_stage, end_stage=end_stage, + conversation_extra_system_prompt=self._extra_system_prompt, ), f"{self.entity_id}_pipeline", ) diff --git a/homeassistant/components/assist_satellite/icons.json b/homeassistant/components/assist_satellite/icons.json index a98c3aefc5b..1ed29541621 100644 --- a/homeassistant/components/assist_satellite/icons.json +++ b/homeassistant/components/assist_satellite/icons.json @@ -7,6 +7,9 @@ "services": { "announce": { "service": "mdi:bullhorn" + }, + "start_conversation": { + "service": "mdi:forum" } } } diff --git a/homeassistant/components/assist_satellite/services.yaml b/homeassistant/components/assist_satellite/services.yaml index e7fefc4705f..89a20ada6f3 100644 --- a/homeassistant/components/assist_satellite/services.yaml +++ b/homeassistant/components/assist_satellite/services.yaml @@ -14,3 +14,23 @@ announce: required: false selector: text: +start_conversation: + target: + entity: + domain: assist_satellite + supported_features: + - assist_satellite.AssistSatelliteEntityFeature.START_CONVERSATION + fields: + start_message: + required: false + example: "You left the lights on in the living room. Turn them off?" + selector: + text: + start_media_id: + required: false + selector: + text: + extra_system_prompt: + required: false + selector: + text: diff --git a/homeassistant/components/assist_satellite/strings.json b/homeassistant/components/assist_satellite/strings.json index 7f1426ef529..e83f4666b5d 100644 --- a/homeassistant/components/assist_satellite/strings.json +++ b/homeassistant/components/assist_satellite/strings.json @@ -25,6 +25,24 @@ "description": "The media ID to announce instead of using text-to-speech." } } + }, + "start_conversation": { + "name": "Start Conversation", + "description": "Start a conversation from a satellite.", + "fields": { + "start_message": { + "name": "Message", + "description": "The message to start with." + }, + "start_media_id": { + "name": "Media ID", + "description": "The media ID to start with instead of using text-to-speech." + }, + "extra_system_prompt": { + "name": "Extra system prompt", + "description": "Provide background information to the AI about the request." + } + } } } } diff --git a/homeassistant/components/voip/assist_satellite.py b/homeassistant/components/voip/assist_satellite.py index 6cacdd79af4..1877b8c655c 100644 --- a/homeassistant/components/voip/assist_satellite.py +++ b/homeassistant/components/voip/assist_satellite.py @@ -90,7 +90,10 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol entity_description = AssistSatelliteEntityDescription(key="assist_satellite") _attr_translation_key = "assist_satellite" _attr_name = None - _attr_supported_features = AssistSatelliteEntityFeature.ANNOUNCE + _attr_supported_features = ( + AssistSatelliteEntityFeature.ANNOUNCE + | AssistSatelliteEntityFeature.START_CONVERSATION + ) def __init__( self, @@ -122,6 +125,7 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol self._check_announcement_ended_task: asyncio.Task | None = None self._last_chunk_time: float | None = None self._rtp_port: int | None = None + self._run_pipeline_after_announce: bool = False @property def pipeline_entity_id(self) -> str | None: @@ -172,7 +176,17 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol Plays announcement in a loop, blocking until the caller hangs up. """ + await self._do_announce(announcement, run_pipeline_after=False) + + async def _do_announce( + self, announcement: AssistSatelliteAnnouncement, run_pipeline_after: bool + ) -> None: + """Announce media on the satellite. + + Optionally run a voice pipeline after the announcement has finished. + """ self._announcement_future = asyncio.Future() + self._run_pipeline_after_announce = run_pipeline_after if self._rtp_port is None: # Choose random port for RTP @@ -232,12 +246,6 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol """ while self._announcement is not None: current_time = time.monotonic() - _LOGGER.debug( - "%s %s %s", - self._last_chunk_time, - current_time, - self._announcment_start_time, - ) if (self._last_chunk_time is None) and ( (current_time - self._announcment_start_time) > _ANNOUNCEMENT_RING_TIMEOUT @@ -263,6 +271,12 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol await asyncio.sleep(_ANNOUNCEMENT_HANGUP_SEC / 2) + async def async_start_conversation( + self, start_announcement: AssistSatelliteAnnouncement + ) -> None: + """Start a conversation from the satellite.""" + await self._do_announce(start_announcement, run_pipeline_after=True) + # ------------------------------------------------------------------------- # VoIP # ------------------------------------------------------------------------- @@ -347,7 +361,10 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol try: await asyncio.sleep(_ANNOUNCEMENT_BEFORE_DELAY) await self._send_tts(announcement.original_media_id, wait_for_tone=False) - await asyncio.sleep(_ANNOUNCEMENT_AFTER_DELAY) + + if not self._run_pipeline_after_announce: + # Delay before looping announcement + await asyncio.sleep(_ANNOUNCEMENT_AFTER_DELAY) except Exception: _LOGGER.exception("Unexpected error while playing announcement") raise @@ -355,6 +372,11 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol self._run_pipeline_task = None _LOGGER.debug("Announcement finished") + if self._run_pipeline_after_announce: + # Clear announcement to allow pipeline to run + self._announcement = None + self._announcement_future.set_result(None) + def _clear_audio_queue(self) -> None: """Ensure audio queue is empty.""" while not self._audio_queue.empty(): diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index 255739c0059..4873d935537 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -88,6 +88,7 @@ def _base_components() -> dict[str, ModuleType]: # pylint: disable-next=import-outside-toplevel from homeassistant.components import ( alarm_control_panel, + assist_satellite, calendar, camera, climate, @@ -108,6 +109,7 @@ def _base_components() -> dict[str, ModuleType]: return { "alarm_control_panel": alarm_control_panel, + "assist_satellite": assist_satellite, "calendar": calendar, "camera": camera, "climate": climate, diff --git a/tests/components/assist_satellite/conftest.py b/tests/components/assist_satellite/conftest.py index d75cbd072e0..0cc0e94e149 100644 --- a/tests/components/assist_satellite/conftest.py +++ b/tests/components/assist_satellite/conftest.py @@ -40,6 +40,8 @@ def mock_tts(mock_tts_cache_dir: pathlib.Path) -> None: class MockAssistSatellite(AssistSatelliteEntity): """Mock Assist Satellite Entity.""" + _attr_tts_options = {"test-option": "test-value"} + def __init__(self, name: str, features: AssistSatelliteEntityFeature) -> None: """Initialize the mock entity.""" self._attr_unique_id = ulid_hex() @@ -67,6 +69,7 @@ class MockAssistSatellite(AssistSatelliteEntity): active_wake_words=["1234"], max_active_wake_words=1, ) + self.start_conversations = [] def on_pipeline_event(self, event: PipelineEvent) -> None: """Handle pipeline events.""" @@ -87,11 +90,21 @@ class MockAssistSatellite(AssistSatelliteEntity): """Set the current satellite configuration.""" self.config = config + async def async_start_conversation( + self, start_announcement: AssistSatelliteConfiguration + ) -> None: + """Start a conversation from the satellite.""" + self.start_conversations.append((self._extra_system_prompt, start_announcement)) + @pytest.fixture def entity() -> MockAssistSatellite: """Mock Assist Satellite Entity.""" - return MockAssistSatellite("Test Entity", AssistSatelliteEntityFeature.ANNOUNCE) + return MockAssistSatellite( + "Test Entity", + AssistSatelliteEntityFeature.ANNOUNCE + | AssistSatelliteEntityFeature.START_CONVERSATION, + ) @pytest.fixture diff --git a/tests/components/assist_satellite/test_entity.py b/tests/components/assist_satellite/test_entity.py index c3464beac97..46facb80844 100644 --- a/tests/components/assist_satellite/test_entity.py +++ b/tests/components/assist_satellite/test_entity.py @@ -25,11 +25,24 @@ from homeassistant.components.assist_satellite.entity import AssistSatelliteStat from homeassistant.components.media_source import PlayMedia from homeassistant.config_entries import ConfigEntry from homeassistant.core import Context, HomeAssistant +from homeassistant.exceptions import HomeAssistantError from . import ENTITY_ID from .conftest import MockAssistSatellite +@pytest.fixture(autouse=True) +async def set_pipeline_tts(hass: HomeAssistant, init_components: ConfigEntry) -> None: + """Set up a pipeline with a TTS engine.""" + await async_update_pipeline( + hass, + async_get_pipeline(hass), + tts_engine="tts.mock_entity", + tts_language="en", + tts_voice="test-voice", + ) + + async def test_entity_state( hass: HomeAssistant, init_components: ConfigEntry, entity: MockAssistSatellite ) -> None: @@ -64,7 +77,7 @@ async def test_entity_state( assert kwargs["stt_stream"] is audio_stream assert kwargs["pipeline_id"] is None assert kwargs["device_id"] is entity.device_entry.id - assert kwargs["tts_audio_output"] is None + assert kwargs["tts_audio_output"] == {"test-option": "test-value"} assert kwargs["wake_word_phrase"] is None assert kwargs["audio_settings"] == AudioSettings( silence_seconds=vad.VadSensitivity.to_seconds(vad.VadSensitivity.DEFAULT) @@ -200,24 +213,12 @@ async def test_announce( expected_params: tuple[str, str], ) -> None: """Test announcing on a device.""" - await async_update_pipeline( - hass, - async_get_pipeline(hass), - tts_engine="tts.mock_entity", - tts_language="en", - tts_voice="test-voice", - ) - - entity._attr_tts_options = {"test-option": "test-value"} - original_announce = entity.async_announce - announce_started = asyncio.Event() async def async_announce(announcement): # Verify state change assert entity.state == AssistSatelliteState.RESPONDING await original_announce(announcement) - announce_started.set() def tts_generate_media_source_id( hass: HomeAssistant, @@ -475,3 +476,104 @@ async def test_vad_sensitivity_entity_not_found( with pytest.raises(RuntimeError): await entity.async_accept_pipeline_from_satellite(audio_stream) + + +@pytest.mark.parametrize( + ("service_data", "expected_params"), + [ + ( + { + "start_message": "Hello", + "extra_system_prompt": "Better system prompt", + }, + ( + "Better system prompt", + AssistSatelliteAnnouncement( + message="Hello", + media_id="https://www.home-assistant.io/resolved.mp3", + original_media_id="media-source://generated", + media_id_source="tts", + ), + ), + ), + ( + { + "start_message": "Hello", + "start_media_id": "media-source://given", + }, + ( + "Hello", + AssistSatelliteAnnouncement( + message="Hello", + media_id="https://www.home-assistant.io/resolved.mp3", + original_media_id="media-source://given", + media_id_source="media_id", + ), + ), + ), + ( + {"start_media_id": "http://example.com/given.mp3"}, + ( + None, + AssistSatelliteAnnouncement( + message="", + media_id="http://example.com/given.mp3", + original_media_id="http://example.com/given.mp3", + media_id_source="url", + ), + ), + ), + ], +) +async def test_start_conversation( + hass: HomeAssistant, + init_components: ConfigEntry, + entity: MockAssistSatellite, + service_data: dict, + expected_params: tuple[str, str], +) -> None: + """Test starting a conversation on a device.""" + await async_update_pipeline( + hass, + async_get_pipeline(hass), + conversation_engine="conversation.some_llm", + ) + + with ( + patch( + "homeassistant.components.assist_satellite.entity.tts_generate_media_source_id", + return_value="media-source://generated", + ), + patch( + "homeassistant.components.media_source.async_resolve_media", + return_value=PlayMedia( + url="https://www.home-assistant.io/resolved.mp3", + mime_type="audio/mp3", + ), + ), + ): + await hass.services.async_call( + "assist_satellite", + "start_conversation", + service_data, + target={"entity_id": "assist_satellite.test_entity"}, + blocking=True, + ) + + assert entity.start_conversations[0] == expected_params + + +async def test_start_conversation_reject_builtin_agent( + hass: HomeAssistant, + init_components: ConfigEntry, + entity: MockAssistSatellite, +) -> None: + """Test starting a conversation on a device.""" + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + "assist_satellite", + "start_conversation", + {"start_message": "Hey!"}, + target={"entity_id": "assist_satellite.test_entity"}, + blocking=True, + ) diff --git a/tests/components/voip/test_voip.py b/tests/components/voip/test_voip.py index 306857a1a44..442f4a62392 100644 --- a/tests/components/voip/test_voip.py +++ b/tests/components/voip/test_voip.py @@ -887,7 +887,8 @@ async def test_announce( # Trigger announcement satellite.on_chunk(bytes(_ONE_SECOND)) - await announce_task + async with asyncio.timeout(1): + await announce_task mock_send_tts.assert_called_once_with(_MEDIA_ID, wait_for_tone=False) @@ -938,7 +939,8 @@ async def test_voip_id_is_ip_address( # Trigger announcement satellite.on_chunk(bytes(_ONE_SECOND)) - await announce_task + async with asyncio.timeout(1): + await announce_task mock_send_tts.assert_called_once_with(_MEDIA_ID, wait_for_tone=False) @@ -981,3 +983,104 @@ async def test_announce_timeout( satellite.transport = Mock() with pytest.raises(TimeoutError): await satellite.async_announce(announcement) + + +@pytest.mark.usefixtures("socket_enabled") +async def test_start_conversation( + hass: HomeAssistant, + voip_devices: VoIPDevices, + voip_device: VoIPDevice, +) -> None: + """Test start conversation.""" + assert await async_setup_component(hass, "voip", {}) + + satellite = async_get_satellite_entity(hass, voip.DOMAIN, voip_device.voip_id) + assert isinstance(satellite, VoipAssistSatellite) + assert ( + satellite.supported_features + & assist_satellite.AssistSatelliteEntityFeature.START_CONVERSATION + ) + + announcement = assist_satellite.AssistSatelliteAnnouncement( + message="test announcement", + media_id=_MEDIA_ID, + original_media_id=_MEDIA_ID, + media_id_source="tts", + ) + + # Protocol has already been mocked, but "outgoing_call" is not async + mock_protocol: AsyncMock = hass.data[DOMAIN].protocol + mock_protocol.outgoing_call = Mock() + + tts_sent = asyncio.Event() + + async def _send_tts(*args, **kwargs): + tts_sent.set() + + async def async_pipeline_from_audio_stream( + hass: HomeAssistant, + context: Context, + *args, + device_id: str | None, + tts_audio_output: str | dict[str, Any] | None, + **kwargs, + ): + event_callback = kwargs["event_callback"] + + # Fake tts result + event_callback( + assist_pipeline.PipelineEvent( + type=assist_pipeline.PipelineEventType.TTS_START, + data={ + "engine": "test", + "language": hass.config.language, + "voice": "test", + "tts_input": "fake-text", + }, + ) + ) + + # Proceed with media output + event_callback( + assist_pipeline.PipelineEvent( + type=assist_pipeline.PipelineEventType.TTS_END, + data={"tts_output": {"media_id": _MEDIA_ID}}, + ) + ) + + event_callback( + assist_pipeline.PipelineEvent( + type=assist_pipeline.PipelineEventType.RUN_END + ) + ) + + with ( + patch( + "homeassistant.components.voip.assist_satellite.VoipAssistSatellite._send_tts", + new=_send_tts, + ), + patch( + "homeassistant.components.assist_satellite.entity.async_pipeline_from_audio_stream", + new=async_pipeline_from_audio_stream, + ), + ): + satellite.transport = Mock() + conversation_task = hass.async_create_background_task( + satellite.async_start_conversation(announcement), "voip_start_conversation" + ) + await asyncio.sleep(0) + mock_protocol.outgoing_call.assert_called_once() + + # Trigger announcement and wait for it to finish + satellite.on_chunk(bytes(_ONE_SECOND)) + async with asyncio.timeout(1): + await tts_sent.wait() + + tts_sent.clear() + + # Trigger pipeline + satellite.on_chunk(bytes(_ONE_SECOND)) + async with asyncio.timeout(1): + # Wait for TTS + await tts_sent.wait() + await conversation_task From 64b056fbe998cf7231906c26f4daab02bb4124a5 Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Thu, 30 Jan 2025 03:57:36 +0100 Subject: [PATCH 0277/3148] Bump ZHA to 0.0.47 (#136883) --- homeassistant/components/zha/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index fa8bab409c9..6a42bc986e9 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -21,7 +21,7 @@ "zha", "universal_silabs_flasher" ], - "requirements": ["zha==0.0.46"], + "requirements": ["zha==0.0.47"], "usb": [ { "vid": "10C4", diff --git a/requirements_all.txt b/requirements_all.txt index 731b1cdeb67..16079cca64d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3131,7 +3131,7 @@ zeroconf==0.141.0 zeversolar==0.3.2 # homeassistant.components.zha -zha==0.0.46 +zha==0.0.47 # homeassistant.components.zhong_hong zhong-hong-hvac==1.0.13 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index db89f8db9d0..a5bd58dff58 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2520,7 +2520,7 @@ zeroconf==0.141.0 zeversolar==0.3.2 # homeassistant.components.zha -zha==0.0.46 +zha==0.0.47 # homeassistant.components.zwave_js zwave-js-server-python==0.60.0 From a8175b785f1445319cec7edae411a78272d51707 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 30 Jan 2025 08:42:23 +0100 Subject: [PATCH 0278/3148] Bump github/codeql-action from 3.28.6 to 3.28.8 (#136890) Bumps [github/codeql-action](https://github.com/github/codeql-action) from 3.28.6 to 3.28.8. - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/v3.28.6...v3.28.8) --- updated-dependencies: - dependency-name: github/codeql-action dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] 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 d7f46b176cd..c1272759acc 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -24,11 +24,11 @@ jobs: uses: actions/checkout@v4.2.2 - name: Initialize CodeQL - uses: github/codeql-action/init@v3.28.6 + uses: github/codeql-action/init@v3.28.8 with: languages: python - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3.28.6 + uses: github/codeql-action/analyze@v3.28.8 with: category: "/language:python" From 97fcbed6e08e3a37eb8d852f695a9d5bdfca514d Mon Sep 17 00:00:00 2001 From: Arie Catsman <120491684+catsmanac@users.noreply.github.com> Date: Thu, 30 Jan 2025 10:07:10 +0100 Subject: [PATCH 0279/3148] Add error handling to enphase_envoy switch platform action (#136837) * Add error handling to enphase_envoy switch platform action * Use decorators for exception handling --- .../components/enphase_envoy/entity.py | 37 ++++- .../components/enphase_envoy/strings.json | 3 + .../components/enphase_envoy/switch.py | 8 +- tests/components/enphase_envoy/test_switch.py | 137 ++++++++++++++++++ 4 files changed, 183 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/enphase_envoy/entity.py b/homeassistant/components/enphase_envoy/entity.py index 491951625ee..04987d861d2 100644 --- a/homeassistant/components/enphase_envoy/entity.py +++ b/homeassistant/components/enphase_envoy/entity.py @@ -2,13 +2,22 @@ from __future__ import annotations -from pyenphase import EnvoyData +from collections.abc import Callable, Coroutine +from typing import Any, Concatenate +from httpx import HTTPError +from pyenphase import EnvoyData +from pyenphase.exceptions import EnvoyError + +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity +from .const import DOMAIN from .coordinator import EnphaseUpdateCoordinator +ACTIONERRORS = (EnvoyError, HTTPError) + class EnvoyBaseEntity(CoordinatorEntity[EnphaseUpdateCoordinator]): """Defines a base envoy entity.""" @@ -33,3 +42,29 @@ class EnvoyBaseEntity(CoordinatorEntity[EnphaseUpdateCoordinator]): data = self.coordinator.envoy.data assert data is not None return data + + +def exception_handler[_EntityT: EnvoyBaseEntity, **_P]( + func: Callable[Concatenate[_EntityT, _P], Coroutine[Any, Any, Any]], +) -> Callable[Concatenate[_EntityT, _P], Coroutine[Any, Any, None]]: + """Decorate Enphase Envoy calls to handle exceptions. + + A decorator that wraps the passed in function, catches enphase_envoy errors. + """ + + async def handler(self: _EntityT, *args: _P.args, **kwargs: _P.kwargs) -> None: + try: + await func(self, *args, **kwargs) + except ACTIONERRORS as error: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="action_error", + translation_placeholders={ + "host": self.coordinator.envoy.host, + "args": error.args[0], + "action": func.__name__, + "entity": self.entity_id, + }, + ) from error + + return handler diff --git a/homeassistant/components/enphase_envoy/strings.json b/homeassistant/components/enphase_envoy/strings.json index 589dc52f71d..e99c45c5c7a 100644 --- a/homeassistant/components/enphase_envoy/strings.json +++ b/homeassistant/components/enphase_envoy/strings.json @@ -400,6 +400,9 @@ }, "envoy_error": { "message": "Error communicating with Envoy API on {host}: {args}" + }, + "action_error": { + "message": "Failed to execute {action} for {entity}, host: {host}: {args}" } } } diff --git a/homeassistant/components/enphase_envoy/switch.py b/homeassistant/components/enphase_envoy/switch.py index 7074f341cc8..8a3ca493562 100644 --- a/homeassistant/components/enphase_envoy/switch.py +++ b/homeassistant/components/enphase_envoy/switch.py @@ -18,7 +18,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN from .coordinator import EnphaseConfigEntry, EnphaseUpdateCoordinator -from .entity import EnvoyBaseEntity +from .entity import EnvoyBaseEntity, exception_handler PARALLEL_UPDATES = 1 @@ -147,11 +147,13 @@ class EnvoyEnpowerSwitchEntity(EnvoyBaseEntity, SwitchEntity): assert enpower is not None return self.entity_description.value_fn(enpower) + @exception_handler async def async_turn_on(self, **kwargs: Any) -> None: """Turn on the Enpower switch.""" await self.entity_description.turn_on_fn(self.envoy) await self.coordinator.async_request_refresh() + @exception_handler async def async_turn_off(self, **kwargs: Any) -> None: """Turn off the Enpower switch.""" await self.entity_description.turn_off_fn(self.envoy) @@ -195,11 +197,13 @@ class EnvoyDryContactSwitchEntity(EnvoyBaseEntity, SwitchEntity): assert relay is not None return self.entity_description.value_fn(relay) + @exception_handler async def async_turn_on(self, **kwargs: Any) -> None: """Turn on (close) the dry contact.""" if await self.entity_description.turn_on_fn(self.envoy, self.relay_id): self.async_write_ha_state() + @exception_handler async def async_turn_off(self, **kwargs: Any) -> None: """Turn off (open) the dry contact.""" if await self.entity_description.turn_off_fn(self.envoy, self.relay_id): @@ -252,11 +256,13 @@ class EnvoyStorageSettingsSwitchEntity(EnvoyBaseEntity, SwitchEntity): assert self.data.tariff.storage_settings is not None return self.entity_description.value_fn(self.data.tariff.storage_settings) + @exception_handler async def async_turn_on(self, **kwargs: Any) -> None: """Turn on the storage settings switch.""" await self.entity_description.turn_on_fn(self.envoy) await self.coordinator.async_request_refresh() + @exception_handler async def async_turn_off(self, **kwargs: Any) -> None: """Turn off the storage switch.""" await self.entity_description.turn_off_fn(self.envoy) diff --git a/tests/components/enphase_envoy/test_switch.py b/tests/components/enphase_envoy/test_switch.py index f30cba4d201..d15c0ad740f 100644 --- a/tests/components/enphase_envoy/test_switch.py +++ b/tests/components/enphase_envoy/test_switch.py @@ -2,6 +2,7 @@ from unittest.mock import AsyncMock, patch +from pyenphase.exceptions import EnvoyError import pytest from syrupy.assertion import SnapshotAssertion @@ -16,6 +17,7 @@ from homeassistant.const import ( STATE_ON, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from . import setup_integration @@ -112,6 +114,46 @@ async def test_switch_grid_operation( mock_envoy.go_off_grid.reset_mock() +@pytest.mark.parametrize("mock_envoy", ["envoy_metered_batt_relay"], indirect=True) +async def test_switch_grid_operation_with_error( + hass: HomeAssistant, + mock_envoy: AsyncMock, + config_entry: MockConfigEntry, +) -> None: + """Test switch platform operation for grid switches when error occurs.""" + with patch("homeassistant.components.enphase_envoy.PLATFORMS", [Platform.SWITCH]): + await setup_integration(hass, config_entry) + + sn = mock_envoy.data.enpower.serial_number + test_entity = f"{Platform.SWITCH}.enpower_{sn}_grid_enabled" + + mock_envoy.go_off_grid.side_effect = EnvoyError("Test") + mock_envoy.go_on_grid.side_effect = EnvoyError("Test") + + with pytest.raises( + HomeAssistantError, + match=f"Failed to execute async_turn_off for {test_entity}, host", + ): + # test grid status switch operation + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: test_entity}, + blocking=True, + ) + + with pytest.raises( + HomeAssistantError, + match=f"Failed to execute async_turn_on for {test_entity}, host", + ): + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: test_entity}, + blocking=True, + ) + + @pytest.mark.parametrize( ("mock_envoy", "use_serial"), [ @@ -165,6 +207,53 @@ async def test_switch_charge_from_grid_operation( mock_envoy.disable_charge_from_grid.reset_mock() +@pytest.mark.parametrize( + ("mock_envoy", "use_serial"), + [ + ("envoy_metered_batt_relay", "enpower_654321"), + ("envoy_eu_batt", "envoy_1234"), + ], + indirect=["mock_envoy"], +) +async def test_switch_charge_from_grid_operation_with_error( + hass: HomeAssistant, + mock_envoy: AsyncMock, + config_entry: MockConfigEntry, + use_serial: str, +) -> None: + """Test switch platform operation for charge from grid switches.""" + with patch("homeassistant.components.enphase_envoy.PLATFORMS", [Platform.SWITCH]): + await setup_integration(hass, config_entry) + + test_entity = f"{Platform.SWITCH}.{use_serial}_charge_from_grid" + + mock_envoy.disable_charge_from_grid.side_effect = EnvoyError("Test") + mock_envoy.enable_charge_from_grid.side_effect = EnvoyError("Test") + + with pytest.raises( + HomeAssistantError, + match=f"Failed to execute async_turn_off for {test_entity}, host", + ): + # test grid status switch operation + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: test_entity}, + blocking=True, + ) + + with pytest.raises( + HomeAssistantError, + match=f"Failed to execute async_turn_on for {test_entity}, host", + ): + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: test_entity}, + blocking=True, + ) + + @pytest.mark.parametrize( ("mock_envoy", "entity_states"), [ @@ -232,3 +321,51 @@ async def test_switch_relay_operation( assert mock_envoy.close_dry_contact.await_count == close_count mock_envoy.open_dry_contact.reset_mock() mock_envoy.close_dry_contact.reset_mock() + + +@pytest.mark.parametrize( + ("mock_envoy", "relay"), + [("envoy_metered_batt_relay", "NC1")], + indirect=["mock_envoy"], +) +async def test_switch_relay_operation_with_error( + hass: HomeAssistant, + mock_envoy: AsyncMock, + config_entry: MockConfigEntry, + relay: str, +) -> None: + """Test enphase_envoy switch relay entities operation.""" + with patch("homeassistant.components.enphase_envoy.PLATFORMS", [Platform.SWITCH]): + await setup_integration(hass, config_entry) + + entity_base = f"{Platform.SWITCH}." + + assert (dry_contact := mock_envoy.data.dry_contact_settings[relay]) + assert (name := dry_contact.load_name.lower().replace(" ", "_")) + + test_entity = f"{entity_base}{name}" + + mock_envoy.close_dry_contact.side_effect = EnvoyError("Test") + mock_envoy.open_dry_contact.side_effect = EnvoyError("Test") + + with pytest.raises( + HomeAssistantError, + match=f"Failed to execute async_turn_off for {test_entity}, host", + ): + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: test_entity}, + blocking=True, + ) + + with pytest.raises( + HomeAssistantError, + match=f"Failed to execute async_turn_on for {test_entity}, host", + ): + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: test_entity}, + blocking=True, + ) From 708ae09c7abd1a4a91fa6dfbeb4aacbc392f78fe Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Thu, 30 Jan 2025 01:07:55 -0800 Subject: [PATCH 0280/3148] Bump nest to 7.1.1 (#136888) --- 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 f7e78b2d538..cd961276082 100644 --- a/homeassistant/components/nest/manifest.json +++ b/homeassistant/components/nest/manifest.json @@ -19,5 +19,5 @@ "documentation": "https://www.home-assistant.io/integrations/nest", "iot_class": "cloud_push", "loggers": ["google_nest_sdm"], - "requirements": ["google-nest-sdm==7.1.0"] + "requirements": ["google-nest-sdm==7.1.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 16079cca64d..90a8709395f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1036,7 +1036,7 @@ google-cloud-texttospeech==2.17.2 google-generativeai==0.8.2 # homeassistant.components.nest -google-nest-sdm==7.1.0 +google-nest-sdm==7.1.1 # homeassistant.components.google_photos google-photos-library-api==0.12.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a5bd58dff58..c7a0959bbb8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -886,7 +886,7 @@ google-cloud-texttospeech==2.17.2 google-generativeai==0.8.2 # homeassistant.components.nest -google-nest-sdm==7.1.0 +google-nest-sdm==7.1.1 # homeassistant.components.google_photos google-photos-library-api==0.12.1 From 1b5316b269ac69f43988653664aea774f3796149 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 30 Jan 2025 11:09:07 +0100 Subject: [PATCH 0281/3148] Ignore dangling symlinks when restoring backup (#136893) --- homeassistant/backup_restore.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/backup_restore.py b/homeassistant/backup_restore.py index 9287aa2bf1b..4d309469017 100644 --- a/homeassistant/backup_restore.py +++ b/homeassistant/backup_restore.py @@ -146,6 +146,7 @@ def _extract_backup( config_dir, dirs_exist_ok=True, ignore=shutil.ignore_patterns(*(keep)), + ignore_dangling_symlinks=True, ) elif restore_content.restore_database: for entry in KEEP_DATABASE: From 52feeedd2b3d36c347dd9a860b0ee638b4513d63 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 30 Jan 2025 11:09:31 +0100 Subject: [PATCH 0282/3148] Poll supervisor job state when creating or restoring a backup (#136891) * Poll supervisor job state when creating or restoring a backup * Update tests * Add tests for create and restore jobs finishing early --- homeassistant/components/hassio/backup.py | 8 +- tests/components/hassio/test_backup.py | 180 ++++++++++++++++------ 2 files changed, 136 insertions(+), 52 deletions(-) diff --git a/homeassistant/components/hassio/backup.py b/homeassistant/components/hassio/backup.py index 6b63ab92d5c..b81605264be 100644 --- a/homeassistant/components/hassio/backup.py +++ b/homeassistant/components/hassio/backup.py @@ -350,8 +350,9 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): backup_id = data.get("reference") backup_complete.set() + unsub = self._async_listen_job_events(backup.job_id, on_job_progress) try: - unsub = self._async_listen_job_events(backup.job_id, on_job_progress) + await self._get_job_state(backup.job_id, on_job_progress) await backup_complete.wait() finally: unsub() @@ -506,12 +507,13 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): @callback def on_job_progress(data: Mapping[str, Any]) -> None: - """Handle backup progress.""" + """Handle backup restore progress.""" if data.get("done") is True: restore_complete.set() + unsub = self._async_listen_job_events(job.job_id, on_job_progress) try: - unsub = self._async_listen_job_events(job.job_id, on_job_progress) + await self._get_job_state(job.job_id, on_job_progress) await restore_complete.wait() finally: unsub() diff --git a/tests/components/hassio/test_backup.py b/tests/components/hassio/test_backup.py index 49360783517..f7379b81a14 100644 --- a/tests/components/hassio/test_backup.py +++ b/tests/components/hassio/test_backup.py @@ -301,6 +301,28 @@ TEST_BACKUP_DETAILS_5 = supervisor_backups.BackupComplete( type=TEST_BACKUP_5.type, ) +TEST_JOB_ID = "d17bd02be1f0437fa7264b16d38f700e" +TEST_JOB_NOT_DONE = supervisor_jobs.Job( + name="backup_manager_partial_backup", + reference="1ef41507", + uuid=UUID(TEST_JOB_ID), + progress=0.0, + stage="copy_additional_locations", + done=False, + errors=[], + child_jobs=[], +) +TEST_JOB_DONE = supervisor_jobs.Job( + name="backup_manager_partial_backup", + reference="1ef41507", + uuid=UUID(TEST_JOB_ID), + progress=0.0, + stage="copy_additional_locations", + done=True, + errors=[], + child_jobs=[], +) + @pytest.fixture(autouse=True) def fixture_supervisor_environ() -> Generator[None]: @@ -813,8 +835,9 @@ async def test_reader_writer_create( ) -> None: """Test generating a backup.""" client = await hass_ws_client(hass) - supervisor_client.backups.partial_backup.return_value.job_id = "abc123" + supervisor_client.backups.partial_backup.return_value.job_id = TEST_JOB_ID supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS + supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE await client.send_json_auto_id({"type": "backup/subscribe_events"}) response = await client.receive_json() @@ -836,7 +859,7 @@ async def test_reader_writer_create( response = await client.receive_json() assert response["success"] - assert response["result"] == {"backup_job_id": "abc123"} + assert response["result"] == {"backup_job_id": TEST_JOB_ID} supervisor_client.backups.partial_backup.assert_called_once_with( expected_supervisor_options @@ -847,7 +870,7 @@ async def test_reader_writer_create( "type": "supervisor/event", "data": { "event": "job", - "data": {"done": True, "uuid": "abc123", "reference": "test_slug"}, + "data": {"done": True, "uuid": TEST_JOB_ID, "reference": "test_slug"}, }, } ) @@ -877,6 +900,66 @@ async def test_reader_writer_create( assert response["event"] == {"manager_state": "idle"} +@pytest.mark.usefixtures("hassio_client", "setup_integration") +async def test_reader_writer_create_job_done( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + supervisor_client: AsyncMock, +) -> None: + """Test generating a backup, and backup job finishes early.""" + client = await hass_ws_client(hass) + supervisor_client.backups.partial_backup.return_value.job_id = TEST_JOB_ID + supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS + supervisor_client.jobs.get_job.return_value = TEST_JOB_DONE + + await client.send_json_auto_id({"type": "backup/subscribe_events"}) + response = await client.receive_json() + assert response["event"] == {"manager_state": "idle"} + response = await client.receive_json() + assert response["success"] + + await client.send_json_auto_id( + {"type": "backup/generate", "agent_ids": ["hassio.local"], "name": "Test"} + ) + response = await client.receive_json() + assert response["event"] == { + "manager_state": "create_backup", + "reason": None, + "stage": None, + "state": "in_progress", + } + + response = await client.receive_json() + assert response["success"] + assert response["result"] == {"backup_job_id": TEST_JOB_ID} + + supervisor_client.backups.partial_backup.assert_called_once_with( + DEFAULT_BACKUP_OPTIONS + ) + + response = await client.receive_json() + assert response["event"] == { + "manager_state": "create_backup", + "reason": None, + "stage": "upload_to_agents", + "state": "in_progress", + } + + response = await client.receive_json() + assert response["event"] == { + "manager_state": "create_backup", + "reason": None, + "stage": None, + "state": "completed", + } + + supervisor_client.backups.download_backup.assert_not_called() + supervisor_client.backups.remove_backup.assert_not_called() + + response = await client.receive_json() + assert response["event"] == {"manager_state": "idle"} + + @pytest.mark.usefixtures("hassio_client") @pytest.mark.parametrize( ( @@ -1006,7 +1089,7 @@ async def test_reader_writer_create_per_agent_encryption( for i in range(1, 4) ], ) - supervisor_client.backups.partial_backup.return_value.job_id = "abc123" + supervisor_client.backups.partial_backup.return_value.job_id = TEST_JOB_ID supervisor_client.backups.backup_info.return_value = replace( TEST_BACKUP_DETAILS, locations=create_locations, @@ -1018,6 +1101,7 @@ async def test_reader_writer_create_per_agent_encryption( for location in create_locations }, ) + supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE supervisor_client.mounts.info.return_value = mounts assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}}) @@ -1050,7 +1134,7 @@ async def test_reader_writer_create_per_agent_encryption( response = await client.receive_json() assert response["success"] - assert response["result"] == {"backup_job_id": "abc123"} + assert response["result"] == {"backup_job_id": TEST_JOB_ID} supervisor_client.backups.partial_backup.assert_called_once_with( replace( @@ -1065,7 +1149,7 @@ async def test_reader_writer_create_per_agent_encryption( "type": "supervisor/event", "data": { "event": "job", - "data": {"done": True, "uuid": "abc123", "reference": "test_slug"}, + "data": {"done": True, "uuid": TEST_JOB_ID, "reference": "test_slug"}, }, } ) @@ -1176,7 +1260,8 @@ async def test_reader_writer_create_missing_reference_error( ) -> None: """Test missing reference error when generating a backup.""" client = await hass_ws_client(hass) - supervisor_client.backups.partial_backup.return_value.job_id = "abc123" + supervisor_client.backups.partial_backup.return_value.job_id = TEST_JOB_ID + supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE await client.send_json_auto_id({"type": "backup/subscribe_events"}) response = await client.receive_json() @@ -1197,7 +1282,7 @@ async def test_reader_writer_create_missing_reference_error( response = await client.receive_json() assert response["success"] - assert response["result"] == {"backup_job_id": "abc123"} + assert response["result"] == {"backup_job_id": TEST_JOB_ID} assert supervisor_client.backups.partial_backup.call_count == 1 @@ -1206,7 +1291,7 @@ async def test_reader_writer_create_missing_reference_error( "type": "supervisor/event", "data": { "event": "job", - "data": {"done": True, "uuid": "abc123"}, + "data": {"done": True, "uuid": TEST_JOB_ID}, }, } ) @@ -1248,8 +1333,9 @@ async def test_reader_writer_create_download_remove_error( ) -> None: """Test download and remove error when generating a backup.""" client = await hass_ws_client(hass) - supervisor_client.backups.partial_backup.return_value.job_id = "abc123" + supervisor_client.backups.partial_backup.return_value.job_id = TEST_JOB_ID supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS_5 + supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE method_mock = getattr(supervisor_client.backups, method) method_mock.side_effect = exception @@ -1282,7 +1368,7 @@ async def test_reader_writer_create_download_remove_error( response = await client.receive_json() assert response["success"] - assert response["result"] == {"backup_job_id": "abc123"} + assert response["result"] == {"backup_job_id": TEST_JOB_ID} assert supervisor_client.backups.partial_backup.call_count == 1 @@ -1291,7 +1377,7 @@ async def test_reader_writer_create_download_remove_error( "type": "supervisor/event", "data": { "event": "job", - "data": {"done": True, "uuid": "abc123", "reference": "test_slug"}, + "data": {"done": True, "uuid": TEST_JOB_ID, "reference": "test_slug"}, }, } ) @@ -1334,8 +1420,9 @@ async def test_reader_writer_create_info_error( ) -> None: """Test backup info error when generating a backup.""" client = await hass_ws_client(hass) - supervisor_client.backups.partial_backup.return_value.job_id = "abc123" + supervisor_client.backups.partial_backup.return_value.job_id = TEST_JOB_ID supervisor_client.backups.backup_info.side_effect = exception + supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE remote_agent = BackupAgentTest("remote") await _setup_backup_platform( @@ -1366,7 +1453,7 @@ async def test_reader_writer_create_info_error( response = await client.receive_json() assert response["success"] - assert response["result"] == {"backup_job_id": "abc123"} + assert response["result"] == {"backup_job_id": TEST_JOB_ID} assert supervisor_client.backups.partial_backup.call_count == 1 @@ -1375,7 +1462,7 @@ async def test_reader_writer_create_info_error( "type": "supervisor/event", "data": { "event": "job", - "data": {"done": True, "uuid": "abc123", "reference": "test_slug"}, + "data": {"done": True, "uuid": TEST_JOB_ID, "reference": "test_slug"}, }, } ) @@ -1408,8 +1495,9 @@ async def test_reader_writer_create_remote_backup( ) -> None: """Test generating a backup which will be uploaded to a remote agent.""" client = await hass_ws_client(hass) - supervisor_client.backups.partial_backup.return_value.job_id = "abc123" + supervisor_client.backups.partial_backup.return_value.job_id = TEST_JOB_ID supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS_5 + supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE remote_agent = BackupAgentTest("remote") await _setup_backup_platform( @@ -1440,7 +1528,7 @@ async def test_reader_writer_create_remote_backup( response = await client.receive_json() assert response["success"] - assert response["result"] == {"backup_job_id": "abc123"} + assert response["result"] == {"backup_job_id": TEST_JOB_ID} supervisor_client.backups.partial_backup.assert_called_once_with( replace(DEFAULT_BACKUP_OPTIONS, location=[LOCATION_CLOUD_BACKUP]), @@ -1451,7 +1539,7 @@ async def test_reader_writer_create_remote_backup( "type": "supervisor/event", "data": { "event": "job", - "data": {"done": True, "uuid": "abc123", "reference": "test_slug"}, + "data": {"done": True, "uuid": TEST_JOB_ID, "reference": "test_slug"}, }, } ) @@ -1510,7 +1598,7 @@ async def test_reader_writer_create_wrong_parameters( ) -> None: """Test generating a backup.""" client = await hass_ws_client(hass) - supervisor_client.backups.partial_backup.return_value.job_id = "abc123" + supervisor_client.backups.partial_backup.return_value.job_id = TEST_JOB_ID supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS await client.send_json_auto_id({"type": "backup/subscribe_events"}) @@ -1614,17 +1702,33 @@ async def test_agent_receive_remote_backup( ) +@pytest.mark.parametrize( + ("get_job_result", "supervisor_events"), + [ + ( + TEST_JOB_NOT_DONE, + [{"event": "job", "data": {"done": True, "uuid": TEST_JOB_ID}}], + ), + ( + TEST_JOB_DONE, + [], + ), + ], +) @pytest.mark.usefixtures("hassio_client", "setup_integration") async def test_reader_writer_restore( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, supervisor_client: AsyncMock, + get_job_result: supervisor_jobs.Job, + supervisor_events: list[dict[str, Any]], ) -> None: """Test restoring a backup.""" client = await hass_ws_client(hass) - supervisor_client.backups.partial_restore.return_value.job_id = "abc123" + supervisor_client.backups.partial_restore.return_value.job_id = TEST_JOB_ID supervisor_client.backups.list.return_value = [TEST_BACKUP] supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS + supervisor_client.jobs.get_job.return_value = get_job_result await client.send_json_auto_id({"type": "backup/subscribe_events"}) response = await client.receive_json() @@ -1657,17 +1761,10 @@ async def test_reader_writer_restore( ), ) - await client.send_json_auto_id( - { - "type": "supervisor/event", - "data": { - "event": "job", - "data": {"done": True, "uuid": "abc123"}, - }, - } - ) - response = await client.receive_json() - assert response["success"] + for event in supervisor_events: + await client.send_json_auto_id({"type": "supervisor/event", "data": event}) + response = await client.receive_json() + assert response["success"] response = await client.receive_json() assert response["event"] == { @@ -1818,21 +1915,9 @@ async def test_restore_progress_after_restart( ) -> None: """Test restore backup progress after restart.""" - supervisor_client.jobs.get_job.return_value = supervisor_jobs.Job( - name="backup_manager_partial_backup", - reference="1ef41507", - uuid=UUID("d17bd02be1f0437fa7264b16d38f700e"), - progress=0.0, - stage="copy_additional_locations", - done=True, - errors=[], - child_jobs=[], - ) + supervisor_client.jobs.get_job.return_value = TEST_JOB_DONE - with patch.dict( - os.environ, - MOCK_ENVIRON | {RESTORE_JOB_ID_ENV: "d17bd02be1f0437fa7264b16d38f700e"}, - ): + with patch.dict(os.environ, MOCK_ENVIRON | {RESTORE_JOB_ID_ENV: TEST_JOB_ID}): assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}}) client = await hass_ws_client(hass) @@ -1860,10 +1945,7 @@ async def test_restore_progress_after_restart_unknown_job( supervisor_client.jobs.get_job.side_effect = SupervisorError - with patch.dict( - os.environ, - MOCK_ENVIRON | {RESTORE_JOB_ID_ENV: "d17bd02be1f0437fa7264b16d38f700e"}, - ): + with patch.dict(os.environ, MOCK_ENVIRON | {RESTORE_JOB_ID_ENV: TEST_JOB_ID}): assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}}) client = await hass_ws_client(hass) From 9eb383f3148c559995631bc4ae44269f67d9f3cd Mon Sep 17 00:00:00 2001 From: TimL Date: Thu, 30 Jan 2025 21:11:40 +1100 Subject: [PATCH 0283/3148] Bump Pysmlight to v0.2.0 (#136886) * Bump pysmlight to v0.2.0 * Update info.json fixture with radios list * Update diagnostics snapshot --- homeassistant/components/smlight/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/smlight/fixtures/info.json | 14 +++++++++++++- .../smlight/snapshots/test_diagnostics.ambr | 18 ++++++++++++++++++ 5 files changed, 34 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/smlight/manifest.json b/homeassistant/components/smlight/manifest.json index 3a8578c8a59..9410e54cee1 100644 --- a/homeassistant/components/smlight/manifest.json +++ b/homeassistant/components/smlight/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/smlight", "integration_type": "device", "iot_class": "local_push", - "requirements": ["pysmlight==0.1.6"], + "requirements": ["pysmlight==0.2.0"], "zeroconf": [ { "type": "_slzb-06._tcp.local." diff --git a/requirements_all.txt b/requirements_all.txt index 90a8709395f..5cea3cd444e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2310,7 +2310,7 @@ pysmarty2==0.10.1 pysml==0.0.12 # homeassistant.components.smlight -pysmlight==0.1.6 +pysmlight==0.2.0 # homeassistant.components.snmp pysnmp==6.2.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c7a0959bbb8..a6c6271c39a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1882,7 +1882,7 @@ pysmarty2==0.10.1 pysml==0.0.12 # homeassistant.components.smlight -pysmlight==0.1.6 +pysmlight==0.2.0 # homeassistant.components.snmp pysnmp==6.2.6 diff --git a/tests/components/smlight/fixtures/info.json b/tests/components/smlight/fixtures/info.json index e3defb4410e..b94fdc3d61c 100644 --- a/tests/components/smlight/fixtures/info.json +++ b/tests/components/smlight/fixtures/info.json @@ -15,5 +15,17 @@ "zb_hw": "CC2652P7", "zb_ram_size": 152, "zb_version": "20240314", - "zb_type": 0 + "zb_type": 0, + "radios": [ + { + "chip_index": 0, + "zb_hw": "CC2652P7", + "zb_version": "20240314", + "zb_type": 0, + "zb_channel": 0, + "zb_ram_size": 152, + "zb_flash_size": 704, + "radioModes": [true, true, true, false, false] + } + ] } diff --git a/tests/components/smlight/snapshots/test_diagnostics.ambr b/tests/components/smlight/snapshots/test_diagnostics.ambr index 97177de1704..5ee6cd19676 100644 --- a/tests/components/smlight/snapshots/test_diagnostics.ambr +++ b/tests/components/smlight/snapshots/test_diagnostics.ambr @@ -10,6 +10,24 @@ 'hostname': 'SLZB-06p7', 'legacy_api': 0, 'model': 'SLZB-06p7', + 'radios': list([ + dict({ + 'chip_index': 0, + 'radioModes': list([ + True, + True, + True, + False, + False, + ]), + 'zb_channel': 0, + 'zb_flash_size': 704, + 'zb_hw': 'CC2652P7', + 'zb_ram_size': 152, + 'zb_type': 0, + 'zb_version': '20240314', + }), + ]), 'ram_total': 296, 'sw_version': 'v2.3.6', 'wifi_mode': 0, From 5dd147e83b3f02e7da75c33513760967b51850b9 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 30 Jan 2025 11:46:27 +0100 Subject: [PATCH 0284/3148] Add missing discovery string from onewire (#136892) --- homeassistant/components/onewire/config_flow.py | 1 + homeassistant/components/onewire/strings.json | 3 +++ 2 files changed, 4 insertions(+) diff --git a/homeassistant/components/onewire/config_flow.py b/homeassistant/components/onewire/config_flow.py index e40e99d0903..8a5623772f7 100644 --- a/homeassistant/components/onewire/config_flow.py +++ b/homeassistant/components/onewire/config_flow.py @@ -147,6 +147,7 @@ class OneWireFlowHandler(ConfigFlow, domain=DOMAIN): return self.async_show_form( step_id="discovery_confirm", + description_placeholders={"host": self._discovery_data[CONF_HOST]}, errors=errors, ) diff --git a/homeassistant/components/onewire/strings.json b/homeassistant/components/onewire/strings.json index 9613a927f8d..8f46369a70b 100644 --- a/homeassistant/components/onewire/strings.json +++ b/homeassistant/components/onewire/strings.json @@ -8,6 +8,9 @@ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" }, "step": { + "discovery_confirm": { + "description": "Do you want to set up OWServer from {host}?" + }, "reconfigure": { "data": { "host": "[%key:common::config_flow::data::host%]", From 76570b51443bb0efb01b91e8cc9f2d2157f00a4a Mon Sep 17 00:00:00 2001 From: Duco Sebel <74970928+DCSBL@users.noreply.github.com> Date: Thu, 30 Jan 2025 13:47:33 +0100 Subject: [PATCH 0285/3148] Remove stale translation string in HomeWizard (#136917) Remove stale translation in HomeWizard --- homeassistant/components/homewizard/strings.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/homeassistant/components/homewizard/strings.json b/homeassistant/components/homewizard/strings.json index 806dbf6e083..02b18d5fa4e 100644 --- a/homeassistant/components/homewizard/strings.json +++ b/homeassistant/components/homewizard/strings.json @@ -174,8 +174,7 @@ } }, "error": { - "authorization_failed": "[%key:component::homewizard::config::error::authorization_failed%]", - "unknown_error": "[%key:common::config_flow::error::unknown%]" + "authorization_failed": "[%key:component::homewizard::config::error::authorization_failed%]" } } } From 1c4ddb36d5586052d13f6fb515ffb006a32e7ac5 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Thu, 30 Jan 2025 14:16:51 +0100 Subject: [PATCH 0286/3148] Convert valve position to int for Shelly BLU TRV (#136912) --- homeassistant/components/shelly/number.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/shelly/number.py b/homeassistant/components/shelly/number.py index 7140c79fbb6..5f0567d034a 100644 --- a/homeassistant/components/shelly/number.py +++ b/homeassistant/components/shelly/number.py @@ -208,7 +208,7 @@ RPC_NUMBERS: Final = { method_params_fn=lambda idx, value: { "id": idx, "method": "Trv.SetPosition", - "params": {"id": 0, "pos": value}, + "params": {"id": 0, "pos": int(value)}, }, removal_condition=lambda config, _status, key: config[key].get("enable", True) is True, From bab616fa61a8a94d6144776a32e0c6d444f702a7 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 30 Jan 2025 15:25:16 +0100 Subject: [PATCH 0287/3148] Fix handling of renamed backup files in the core writer (#136898) * Fix handling of renamed backup files in the core writer * Adjust mocking * Raise BackupAgentError instead of KeyError in get_backup_path * Add specific error indicating backup not found * Fix tests * Ensure backups are loaded * Fix tests --- homeassistant/components/backup/agent.py | 13 ++- homeassistant/components/backup/backup.py | 41 ++++++--- homeassistant/components/backup/manager.py | 32 +++---- tests/components/backup/common.py | 17 +++- tests/components/backup/conftest.py | 10 ++- .../backup/snapshots/test_backup.ambr | 33 +++++-- tests/components/backup/test_backup.py | 50 ++++++++--- tests/components/backup/test_http.py | 29 +++--- tests/components/backup/test_manager.py | 90 +++++++++++++++---- 9 files changed, 234 insertions(+), 81 deletions(-) diff --git a/homeassistant/components/backup/agent.py b/homeassistant/components/backup/agent.py index 33656b6edcc..297ccd6f685 100644 --- a/homeassistant/components/backup/agent.py +++ b/homeassistant/components/backup/agent.py @@ -27,6 +27,12 @@ class BackupAgentUnreachableError(BackupAgentError): _message = "The backup agent is unreachable." +class BackupNotFound(BackupAgentError): + """Raised when a backup is not found.""" + + error_code = "backup_not_found" + + class BackupAgent(abc.ABC): """Backup agent interface.""" @@ -94,11 +100,16 @@ class LocalBackupAgent(BackupAgent): @abc.abstractmethod def get_backup_path(self, backup_id: str) -> Path: - """Return the local path to a backup. + """Return the local path to an existing backup. The method should return the path to the backup file with the specified id. + Raises BackupAgentError if the backup does not exist. """ + @abc.abstractmethod + def get_new_backup_path(self, backup: AgentBackup) -> Path: + """Return the local path to a new backup.""" + class BackupAgentPlatformProtocol(Protocol): """Define the format of backup platforms which implement backup agents.""" diff --git a/homeassistant/components/backup/backup.py b/homeassistant/components/backup/backup.py index 3f60bd0b88e..c76b50b5935 100644 --- a/homeassistant/components/backup/backup.py +++ b/homeassistant/components/backup/backup.py @@ -11,7 +11,7 @@ from typing import Any from homeassistant.core import HomeAssistant from homeassistant.helpers.hassio import is_hassio -from .agent import BackupAgent, LocalBackupAgent +from .agent import BackupAgent, BackupNotFound, LocalBackupAgent from .const import DOMAIN, LOGGER from .models import AgentBackup from .util import read_backup @@ -39,7 +39,7 @@ class CoreLocalBackupAgent(LocalBackupAgent): super().__init__() self._hass = hass self._backup_dir = Path(hass.config.path("backups")) - self._backups: dict[str, AgentBackup] = {} + self._backups: dict[str, tuple[AgentBackup, Path]] = {} self._loaded_backups = False async def _load_backups(self) -> None: @@ -49,13 +49,13 @@ class CoreLocalBackupAgent(LocalBackupAgent): self._backups = backups self._loaded_backups = True - def _read_backups(self) -> dict[str, AgentBackup]: + def _read_backups(self) -> dict[str, tuple[AgentBackup, Path]]: """Read backups from disk.""" - backups: dict[str, AgentBackup] = {} + backups: dict[str, tuple[AgentBackup, Path]] = {} for backup_path in self._backup_dir.glob("*.tar"): try: backup = read_backup(backup_path) - backups[backup.backup_id] = backup + backups[backup.backup_id] = (backup, backup_path) except (OSError, TarError, json.JSONDecodeError, KeyError) as err: LOGGER.warning("Unable to read backup %s: %s", backup_path, err) return backups @@ -76,13 +76,13 @@ class CoreLocalBackupAgent(LocalBackupAgent): **kwargs: Any, ) -> None: """Upload a backup.""" - self._backups[backup.backup_id] = backup + self._backups[backup.backup_id] = (backup, self.get_new_backup_path(backup)) async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]: """List backups.""" if not self._loaded_backups: await self._load_backups() - return list(self._backups.values()) + return [backup for backup, _ in self._backups.values()] async def async_get_backup( self, @@ -93,10 +93,10 @@ class CoreLocalBackupAgent(LocalBackupAgent): if not self._loaded_backups: await self._load_backups() - if not (backup := self._backups.get(backup_id)): + if backup_id not in self._backups: return None - backup_path = self.get_backup_path(backup_id) + backup, backup_path = self._backups[backup_id] if not await self._hass.async_add_executor_job(backup_path.exists): LOGGER.debug( ( @@ -112,15 +112,28 @@ class CoreLocalBackupAgent(LocalBackupAgent): return backup def get_backup_path(self, backup_id: str) -> Path: - """Return the local path to a backup.""" - return self._backup_dir / f"{backup_id}.tar" + """Return the local path to an existing backup. + + Raises BackupAgentError if the backup does not exist. + """ + try: + return self._backups[backup_id][1] + except KeyError as err: + raise BackupNotFound(f"Backup {backup_id} does not exist") from err + + def get_new_backup_path(self, backup: AgentBackup) -> Path: + """Return the local path to a new backup.""" + return self._backup_dir / f"{backup.backup_id}.tar" async def async_delete_backup(self, backup_id: str, **kwargs: Any) -> None: """Delete a backup file.""" - if await self.async_get_backup(backup_id) is None: - return + if not self._loaded_backups: + await self._load_backups() - backup_path = self.get_backup_path(backup_id) + try: + backup_path = self.get_backup_path(backup_id) + except BackupNotFound: + return await self._hass.async_add_executor_job(backup_path.unlink, True) LOGGER.debug("Deleted backup located at %s", backup_path) self._backups.pop(backup_id) diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index fc56505e343..d1f27fa270b 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -1346,10 +1346,24 @@ class CoreBackupReaderWriter(BackupReaderWriter): if agent_config and not agent_config.protected: password = None + backup = AgentBackup( + addons=[], + backup_id=backup_id, + database_included=include_database, + date=date_str, + extra_metadata=extra_metadata, + folders=[], + homeassistant_included=True, + homeassistant_version=HAVERSION, + name=backup_name, + protected=password is not None, + size=0, + ) + local_agent_tar_file_path = None if self._local_agent_id in agent_ids: local_agent = manager.local_backup_agents[self._local_agent_id] - local_agent_tar_file_path = local_agent.get_backup_path(backup_id) + local_agent_tar_file_path = local_agent.get_new_backup_path(backup) on_progress( CreateBackupEvent( @@ -1391,19 +1405,7 @@ class CoreBackupReaderWriter(BackupReaderWriter): # ValueError from json_bytes raise BackupReaderWriterError(str(err)) from err else: - backup = AgentBackup( - addons=[], - backup_id=backup_id, - database_included=include_database, - date=date_str, - extra_metadata=extra_metadata, - folders=[], - homeassistant_included=True, - homeassistant_version=HAVERSION, - name=backup_name, - protected=password is not None, - size=size_in_bytes, - ) + backup = replace(backup, size=size_in_bytes) async_add_executor_job = self._hass.async_add_executor_job @@ -1517,7 +1519,7 @@ class CoreBackupReaderWriter(BackupReaderWriter): manager = self._hass.data[DATA_MANAGER] if self._local_agent_id in agent_ids: local_agent = manager.local_backup_agents[self._local_agent_id] - tar_file_path = local_agent.get_backup_path(backup.backup_id) + tar_file_path = local_agent.get_new_backup_path(backup) await async_add_executor_job(make_backup_dir, tar_file_path.parent) await async_add_executor_job(shutil.move, temp_file, tar_file_path) else: diff --git a/tests/components/backup/common.py b/tests/components/backup/common.py index 97236ee995d..a7888dbd08c 100644 --- a/tests/components/backup/common.py +++ b/tests/components/backup/common.py @@ -2,7 +2,7 @@ from __future__ import annotations -from collections.abc import AsyncIterator, Callable, Coroutine +from collections.abc import AsyncIterator, Callable, Coroutine, Iterable from pathlib import Path from typing import Any from unittest.mock import ANY, AsyncMock, Mock, patch @@ -52,10 +52,17 @@ TEST_BACKUP_DEF456 = AgentBackup( protected=False, size=1, ) +TEST_BACKUP_PATH_DEF456 = Path("custom_def456.tar") TEST_DOMAIN = "test" +async def aiter_from_iter(iterable: Iterable) -> AsyncIterator: + """Convert an iterable to an async iterator.""" + for i in iterable: + yield i + + class BackupAgentTest(BackupAgent): """Test backup agent.""" @@ -162,7 +169,13 @@ async def setup_backup_integration( if with_hassio and agent_id == LOCAL_AGENT_ID: continue agent = hass.data[DATA_MANAGER].backup_agents[agent_id] - agent._backups = {backups.backup_id: backups for backups in agent_backups} + + async def open_stream() -> AsyncIterator[bytes]: + """Open a stream.""" + return aiter_from_iter((b"backup data",)) + + for backup in agent_backups: + await agent.async_upload_backup(open_stream=open_stream, backup=backup) if agent_id == LOCAL_AGENT_ID: agent._loaded_backups = True diff --git a/tests/components/backup/conftest.py b/tests/components/backup/conftest.py index bef48498ede..d0d9ac7e0e1 100644 --- a/tests/components/backup/conftest.py +++ b/tests/components/backup/conftest.py @@ -13,7 +13,7 @@ from homeassistant.components.backup import DOMAIN from homeassistant.components.backup.manager import NewBackup, WrittenBackup from homeassistant.core import HomeAssistant -from .common import TEST_BACKUP_PATH_ABC123 +from .common import TEST_BACKUP_PATH_ABC123, TEST_BACKUP_PATH_DEF456 from tests.common import get_fixture_path @@ -38,10 +38,14 @@ def mocked_tarfile_fixture() -> Generator[Mock]: @pytest.fixture(name="path_glob") -def path_glob_fixture() -> Generator[MagicMock]: +def path_glob_fixture(hass: HomeAssistant) -> Generator[MagicMock]: """Mock path glob.""" with patch( - "pathlib.Path.glob", return_value=[TEST_BACKUP_PATH_ABC123] + "pathlib.Path.glob", + return_value=[ + Path(hass.config.path()) / "backups" / TEST_BACKUP_PATH_ABC123, + Path(hass.config.path()) / "backups" / TEST_BACKUP_PATH_DEF456, + ], ) as path_glob: yield path_glob diff --git a/tests/components/backup/snapshots/test_backup.ambr b/tests/components/backup/snapshots/test_backup.ambr index 032eb7ac537..68b00632a6b 100644 --- a/tests/components/backup/snapshots/test_backup.ambr +++ b/tests/components/backup/snapshots/test_backup.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_delete_backup[found_backups0-True-1] +# name: test_delete_backup[found_backups0-abc123-1-unlink_path0] dict({ 'id': 1, 'result': dict({ @@ -10,7 +10,7 @@ 'type': 'result', }) # --- -# name: test_delete_backup[found_backups1-False-0] +# name: test_delete_backup[found_backups1-def456-1-unlink_path1] dict({ 'id': 1, 'result': dict({ @@ -21,7 +21,7 @@ 'type': 'result', }) # --- -# name: test_delete_backup[found_backups2-True-0] +# name: test_delete_backup[found_backups2-abc123-0-None] dict({ 'id': 1, 'result': dict({ @@ -32,7 +32,7 @@ 'type': 'result', }) # --- -# name: test_load_backups[None] +# name: test_load_backups[mock_read_backup] dict({ 'id': 1, 'result': dict({ @@ -47,7 +47,7 @@ 'type': 'result', }) # --- -# name: test_load_backups[None].1 +# name: test_load_backups[mock_read_backup].1 dict({ 'id': 2, 'result': dict({ @@ -82,6 +82,29 @@ 'name': 'Test', 'with_automatic_settings': True, }), + dict({ + 'addons': list([ + ]), + 'agents': dict({ + 'backup.local': dict({ + 'protected': False, + 'size': 1, + }), + }), + 'backup_id': 'def456', + 'database_included': False, + 'date': '1980-01-01T00:00:00.000Z', + 'failed_agent_ids': list([ + ]), + 'folders': list([ + 'media', + 'share', + ]), + 'homeassistant_included': True, + 'homeassistant_version': '2024.12.0', + 'name': 'Test 2', + 'with_automatic_settings': None, + }), ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, diff --git a/tests/components/backup/test_backup.py b/tests/components/backup/test_backup.py index 02252ef6fa5..ce34c51c105 100644 --- a/tests/components/backup/test_backup.py +++ b/tests/components/backup/test_backup.py @@ -12,21 +12,35 @@ from unittest.mock import MagicMock, mock_open, patch import pytest from syrupy import SnapshotAssertion -from homeassistant.components.backup import DOMAIN +from homeassistant.components.backup import DOMAIN, AgentBackup from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from .common import TEST_BACKUP_ABC123, TEST_BACKUP_PATH_ABC123 +from .common import ( + TEST_BACKUP_ABC123, + TEST_BACKUP_DEF456, + TEST_BACKUP_PATH_ABC123, + TEST_BACKUP_PATH_DEF456, +) from tests.typing import ClientSessionGenerator, WebSocketGenerator +def mock_read_backup(backup_path: Path) -> AgentBackup: + """Mock read backup.""" + mock_backups = { + "abc123": TEST_BACKUP_ABC123, + "custom_def456": TEST_BACKUP_DEF456, + } + return mock_backups[backup_path.stem] + + @pytest.fixture(name="read_backup") def read_backup_fixture(path_glob: MagicMock) -> Generator[MagicMock]: """Mock read backup.""" with patch( "homeassistant.components.backup.backup.read_backup", - return_value=TEST_BACKUP_ABC123, + side_effect=mock_read_backup, ) as read_backup: yield read_backup @@ -34,7 +48,7 @@ def read_backup_fixture(path_glob: MagicMock) -> Generator[MagicMock]: @pytest.mark.parametrize( "side_effect", [ - None, + mock_read_backup, OSError("Boom"), TarError("Boom"), json.JSONDecodeError("Boom", "test", 1), @@ -94,11 +108,21 @@ async def test_upload( @pytest.mark.usefixtures("read_backup") @pytest.mark.parametrize( - ("found_backups", "backup_exists", "unlink_calls"), + ("found_backups", "backup_id", "unlink_calls", "unlink_path"), [ - ([TEST_BACKUP_PATH_ABC123], True, 1), - ([TEST_BACKUP_PATH_ABC123], False, 0), - (([], True, 0)), + ( + [TEST_BACKUP_PATH_ABC123, TEST_BACKUP_PATH_DEF456], + TEST_BACKUP_ABC123.backup_id, + 1, + TEST_BACKUP_PATH_ABC123, + ), + ( + [TEST_BACKUP_PATH_ABC123, TEST_BACKUP_PATH_DEF456], + TEST_BACKUP_DEF456.backup_id, + 1, + TEST_BACKUP_PATH_DEF456, + ), + (([], TEST_BACKUP_ABC123.backup_id, 0, None)), ], ) async def test_delete_backup( @@ -108,8 +132,9 @@ async def test_delete_backup( snapshot: SnapshotAssertion, path_glob: MagicMock, found_backups: list[Path], - backup_exists: bool, + backup_id: str, unlink_calls: int, + unlink_path: Path | None, ) -> None: """Test delete backup.""" assert await async_setup_component(hass, DOMAIN, {}) @@ -118,12 +143,13 @@ async def test_delete_backup( path_glob.return_value = found_backups with ( - patch("pathlib.Path.exists", return_value=backup_exists), - patch("pathlib.Path.unlink") as unlink, + patch("pathlib.Path.unlink", autospec=True) as unlink, ): await client.send_json_auto_id( - {"type": "backup/delete", "backup_id": TEST_BACKUP_ABC123.backup_id} + {"type": "backup/delete", "backup_id": backup_id} ) assert await client.receive_json() == snapshot assert unlink.call_count == unlink_calls + for call in unlink.mock_calls: + assert call.args[0] == unlink_path diff --git a/tests/components/backup/test_http.py b/tests/components/backup/test_http.py index ee6803655d5..aac39c04d31 100644 --- a/tests/components/backup/test_http.py +++ b/tests/components/backup/test_http.py @@ -1,7 +1,7 @@ """Tests for the Backup integration.""" import asyncio -from collections.abc import AsyncIterator, Iterable +from collections.abc import AsyncIterator from io import BytesIO, StringIO import json import tarfile @@ -15,7 +15,12 @@ from homeassistant.components.backup import AddonInfo, AgentBackup, Folder from homeassistant.components.backup.const import DATA_MANAGER, DOMAIN from homeassistant.core import HomeAssistant -from .common import TEST_BACKUP_ABC123, BackupAgentTest, setup_backup_integration +from .common import ( + TEST_BACKUP_ABC123, + BackupAgentTest, + aiter_from_iter, + setup_backup_integration, +) from tests.common import MockUser, get_fixture_path from tests.typing import ClientSessionGenerator @@ -35,6 +40,9 @@ async def test_downloading_local_backup( "homeassistant.components.backup.backup.CoreLocalBackupAgent.async_get_backup", return_value=TEST_BACKUP_ABC123, ), + patch( + "homeassistant.components.backup.backup.CoreLocalBackupAgent.get_backup_path", + ), patch("pathlib.Path.exists", return_value=True), patch( "homeassistant.components.backup.http.FileResponse", @@ -73,9 +81,14 @@ async def test_downloading_local_encrypted_backup_file_not_found( await setup_backup_integration(hass) client = await hass_client() - with patch( - "homeassistant.components.backup.backup.CoreLocalBackupAgent.async_get_backup", - return_value=TEST_BACKUP_ABC123, + with ( + patch( + "homeassistant.components.backup.backup.CoreLocalBackupAgent.async_get_backup", + return_value=TEST_BACKUP_ABC123, + ), + patch( + "homeassistant.components.backup.backup.CoreLocalBackupAgent.get_backup_path", + ), ): resp = await client.get( "/api/backup/download/abc123?agent_id=backup.local&password=blah" @@ -93,12 +106,6 @@ async def test_downloading_local_encrypted_backup( await _test_downloading_encrypted_backup(hass_client, "backup.local") -async def aiter_from_iter(iterable: Iterable) -> AsyncIterator: - """Convert an iterable to an async iterator.""" - for i in iterable: - yield i - - @patch.object(BackupAgentTest, "async_download_backup") async def test_downloading_remote_encrypted_backup( download_mock, diff --git a/tests/components/backup/test_manager.py b/tests/components/backup/test_manager.py index 5e5b0df74cd..69994028297 100644 --- a/tests/components/backup/test_manager.py +++ b/tests/components/backup/test_manager.py @@ -54,6 +54,8 @@ from .common import ( LOCAL_AGENT_ID, TEST_BACKUP_ABC123, TEST_BACKUP_DEF456, + TEST_BACKUP_PATH_ABC123, + TEST_BACKUP_PATH_DEF456, BackupAgentTest, setup_backup_platform, ) @@ -89,6 +91,15 @@ def generate_backup_id_fixture() -> Generator[MagicMock]: yield mock +def mock_read_backup(backup_path: Path) -> AgentBackup: + """Mock read backup.""" + mock_backups = { + "abc123": TEST_BACKUP_ABC123, + "custom_def456": TEST_BACKUP_DEF456, + } + return mock_backups[backup_path.stem] + + @pytest.mark.usefixtures("mock_backup_generation") async def test_create_backup_service( hass: HomeAssistant, @@ -1311,7 +1322,11 @@ class LocalBackupAgentTest(BackupAgentTest, LocalBackupAgent): """Local backup agent.""" def get_backup_path(self, backup_id: str) -> Path: - """Return the local path to a backup.""" + """Return the local path to an existing backup.""" + return Path("test.tar") + + def get_new_backup_path(self, backup: AgentBackup) -> Path: + """Return the local path to a new backup.""" return Path("test.tar") @@ -2023,10 +2038,6 @@ async def test_receive_backup_file_write_error( with ( patch("pathlib.Path.open", open_mock), - patch( - "homeassistant.components.backup.manager.read_backup", - return_value=TEST_BACKUP_ABC123, - ), ): resp = await client.post( "/api/backup/upload?agent_id=test.remote", @@ -2375,18 +2386,61 @@ async def test_receive_backup_file_read_error( @pytest.mark.usefixtures("path_glob") @pytest.mark.parametrize( - ("agent_id", "password_param", "restore_database", "restore_homeassistant", "dir"), + ( + "agent_id", + "backup_id", + "password_param", + "backup_path", + "restore_database", + "restore_homeassistant", + "dir", + ), [ - (LOCAL_AGENT_ID, {}, True, False, "backups"), - (LOCAL_AGENT_ID, {"password": "abc123"}, False, True, "backups"), - ("test.remote", {}, True, True, "tmp_backups"), + ( + LOCAL_AGENT_ID, + TEST_BACKUP_ABC123.backup_id, + {}, + TEST_BACKUP_PATH_ABC123, + True, + False, + "backups", + ), + ( + LOCAL_AGENT_ID, + TEST_BACKUP_DEF456.backup_id, + {}, + TEST_BACKUP_PATH_DEF456, + True, + False, + "backups", + ), + ( + LOCAL_AGENT_ID, + TEST_BACKUP_ABC123.backup_id, + {"password": "abc123"}, + TEST_BACKUP_PATH_ABC123, + False, + True, + "backups", + ), + ( + "test.remote", + TEST_BACKUP_ABC123.backup_id, + {}, + TEST_BACKUP_PATH_ABC123, + True, + True, + "tmp_backups", + ), ], ) async def test_restore_backup( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, agent_id: str, + backup_id: str, password_param: dict[str, str], + backup_path: Path, restore_database: bool, restore_homeassistant: bool, dir: str, @@ -2426,14 +2480,14 @@ async def test_restore_backup( patch.object(remote_agent, "async_download_backup") as download_mock, patch( "homeassistant.components.backup.backup.read_backup", - return_value=TEST_BACKUP_ABC123, + side_effect=mock_read_backup, ), ): download_mock.return_value.__aiter__.return_value = iter((b"backup data",)) await ws_client.send_json_auto_id( { "type": "backup/restore", - "backup_id": TEST_BACKUP_ABC123.backup_id, + "backup_id": backup_id, "agent_id": agent_id, "restore_database": restore_database, "restore_homeassistant": restore_homeassistant, @@ -2473,17 +2527,17 @@ async def test_restore_backup( result = await ws_client.receive_json() assert result["success"] is True - backup_path = f"{hass.config.path()}/{dir}/abc123.tar" + full_backup_path = f"{hass.config.path()}/{dir}/{backup_path.name}" expected_restore_file = json.dumps( { - "path": backup_path, + "path": full_backup_path, "password": password, "remove_after_restore": agent_id != LOCAL_AGENT_ID, "restore_database": restore_database, "restore_homeassistant": restore_homeassistant, } ) - validate_password_mock.assert_called_once_with(Path(backup_path), password) + validate_password_mock.assert_called_once_with(Path(full_backup_path), password) assert mocked_write_text.call_args[0][0] == expected_restore_file assert mocked_service_call.called @@ -2533,7 +2587,7 @@ async def test_restore_backup_wrong_password( patch.object(remote_agent, "async_download_backup") as download_mock, patch( "homeassistant.components.backup.backup.read_backup", - return_value=TEST_BACKUP_ABC123, + side_effect=mock_read_backup, ), ): download_mock.return_value.__aiter__.return_value = iter((b"backup data",)) @@ -2581,8 +2635,8 @@ async def test_restore_backup_wrong_password( ("parameters", "expected_error", "expected_reason"), [ ( - {"backup_id": TEST_BACKUP_DEF456.backup_id}, - f"Backup def456 not found in agent {LOCAL_AGENT_ID}", + {"backup_id": "no_such_backup"}, + f"Backup no_such_backup not found in agent {LOCAL_AGENT_ID}", "backup_manager_error", ), ( @@ -2629,7 +2683,7 @@ async def test_restore_backup_wrong_parameters( patch("homeassistant.core.ServiceRegistry.async_call") as mocked_service_call, patch( "homeassistant.components.backup.backup.read_backup", - return_value=TEST_BACKUP_ABC123, + side_effect=mock_read_backup, ), ): await ws_client.send_json_auto_id( From 232e99b62ed388bb36b81ff56db43f81b878d606 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 30 Jan 2025 16:16:22 +0100 Subject: [PATCH 0288/3148] Create Xbox signed session in executor (#136927) --- homeassistant/components/xbox/__init__.py | 4 +++- homeassistant/components/xbox/api.py | 6 ++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/xbox/__init__.py b/homeassistant/components/xbox/__init__.py index 5282a34903a..ab0d510a709 100644 --- a/homeassistant/components/xbox/__init__.py +++ b/homeassistant/components/xbox/__init__.py @@ -6,6 +6,7 @@ import logging from xbox.webapi.api.client import XboxLiveClient from xbox.webapi.api.provider.smartglass.models import SmartglassConsoleList +from xbox.webapi.common.signed_session import SignedSession from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform @@ -36,7 +37,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) ) session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation) - auth = api.AsyncConfigEntryAuth(session) + signed_session = await hass.async_add_executor_job(SignedSession) + auth = api.AsyncConfigEntryAuth(signed_session, session) client = XboxLiveClient(auth) consoles: SmartglassConsoleList = await client.smartglass.get_console_list() diff --git a/homeassistant/components/xbox/api.py b/homeassistant/components/xbox/api.py index d4c47e4cc39..9fa7c14b5c9 100644 --- a/homeassistant/components/xbox/api.py +++ b/homeassistant/components/xbox/api.py @@ -11,10 +11,12 @@ from homeassistant.util.dt import utc_from_timestamp class AsyncConfigEntryAuth(AuthenticationManager): """Provide xbox authentication tied to an OAuth2 based config entry.""" - def __init__(self, oauth_session: OAuth2Session) -> None: + def __init__( + self, signed_session: SignedSession, oauth_session: OAuth2Session + ) -> None: """Initialize xbox auth.""" # Leaving out client credentials as they are handled by Home Assistant - super().__init__(SignedSession(), "", "", "") + super().__init__(signed_session, "", "", "") self._oauth_session = oauth_session self.oauth = self._get_oauth_token() From 773375e7b0d5cb3c197ca2c318c2f67bb9d10631 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 30 Jan 2025 16:16:39 +0100 Subject: [PATCH 0289/3148] Fix Sonos importing deprecating constant (#136926) --- homeassistant/components/sonos/__init__.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sonos/__init__.py b/homeassistant/components/sonos/__init__.py index 98bff8d2934..d530fa21e39 100644 --- a/homeassistant/components/sonos/__init__.py +++ b/homeassistant/components/sonos/__init__.py @@ -34,7 +34,11 @@ from homeassistant.helpers import ( ) from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_call_later, async_track_time_interval -from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_MODEL_NAME, + ATTR_UPNP_UDN, + SsdpServiceInfo, +) from homeassistant.helpers.typing import ConfigType from homeassistant.util.async_ import create_eager_task @@ -503,7 +507,7 @@ class SonosDiscoveryManager: def _async_ssdp_discovered_player( self, info: SsdpServiceInfo, change: ssdp.SsdpChange ) -> None: - uid = info.upnp[ssdp.ATTR_UPNP_UDN] + uid = info.upnp[ATTR_UPNP_UDN] if not uid.startswith("uuid:RINCON_"): return uid = uid[5:] @@ -522,7 +526,7 @@ class SonosDiscoveryManager: cast(str, urlparse(info.ssdp_location).hostname), uid, info.ssdp_headers.get("X-RINCON-BOOTSEQ"), - cast(str, info.upnp.get(ssdp.ATTR_UPNP_MODEL_NAME)), + cast(str, info.upnp.get(ATTR_UPNP_MODEL_NAME)), None, ) From d148bd9b0cf128c74b971a25012c0363ee63ddbc Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Thu, 30 Jan 2025 16:33:59 +0100 Subject: [PATCH 0290/3148] Fix onedrive does not fail on delete not found (#136910) * Fix onedrive does not fail on delete not found * Fix onedrive does not fail on delete not found --- homeassistant/components/onedrive/backup.py | 8 ++++++- tests/components/onedrive/test_backup.py | 24 ++++++++++++++++++++- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/onedrive/backup.py b/homeassistant/components/onedrive/backup.py index a5a5c019797..94d60bc6398 100644 --- a/homeassistant/components/onedrive/backup.py +++ b/homeassistant/components/onedrive/backup.py @@ -190,7 +190,13 @@ class OneDriveBackupAgent(BackupAgent): **kwargs: Any, ) -> None: """Delete a backup file.""" - await self._get_backup_file_item(backup_id).delete() + + try: + await self._get_backup_file_item(backup_id).delete() + except APIError as err: + if err.response_status_code == 404: + return + raise @handle_backup_errors async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]: diff --git a/tests/components/onedrive/test_backup.py b/tests/components/onedrive/test_backup.py index a3d1129377f..3492202d3fe 100644 --- a/tests/components/onedrive/test_backup.py +++ b/tests/components/onedrive/test_backup.py @@ -156,6 +156,28 @@ async def test_agents_delete( mock_drive_items.delete.assert_called_once() +async def test_agents_delete_not_found_does_not_throw( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_drive_items: MagicMock, +) -> None: + """Test agent delete backup.""" + mock_drive_items.delete = AsyncMock(side_effect=APIError(response_status_code=404)) + client = await hass_ws_client(hass) + + await client.send_json_auto_id( + { + "type": "backup/delete", + "backup_id": BACKUP_METADATA["backup_id"], + } + ) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == {"agent_errors": {}} + mock_drive_items.delete.assert_called_once() + + async def test_agents_upload( hass_client: ClientSessionGenerator, caplog: pytest.LogCaptureFixture, @@ -257,7 +279,7 @@ async def test_agents_download( ("side_effect", "error"), [ ( - APIError(response_status_code=404, message="File not found."), + APIError(response_status_code=500), "Backup operation failed", ), (TimeoutError(), "Backup operation timed out"), From 8db6a6cf176122746901414e7638e94be9fe62c9 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 30 Jan 2025 16:47:09 +0100 Subject: [PATCH 0291/3148] Shorten the integration name for `incomfort` (#136930) --- .../components/incomfort/manifest.json | 2 +- .../components/incomfort/strings.json | 22 +++++++++---------- homeassistant/generated/integrations.json | 2 +- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/incomfort/manifest.json b/homeassistant/components/incomfort/manifest.json index f4d752bfa48..d02b1d27554 100644 --- a/homeassistant/components/incomfort/manifest.json +++ b/homeassistant/components/incomfort/manifest.json @@ -1,6 +1,6 @@ { "domain": "incomfort", - "name": "Intergas InComfort/Intouch Lan2RF gateway", + "name": "Intergas gateway", "codeowners": ["@jbouwh"], "config_flow": true, "dhcp": [ diff --git a/homeassistant/components/incomfort/strings.json b/homeassistant/components/incomfort/strings.json index 4c47d4c57ad..15e28b6e0b9 100644 --- a/homeassistant/components/incomfort/strings.json +++ b/homeassistant/components/incomfort/strings.json @@ -2,20 +2,20 @@ "config": { "step": { "user": { - "description": "Set up new Intergas InComfort Lan2RF Gateway, some older systems might not need credentials to be set up. For newer devices authentication is required.", + "description": "Set up new Intergas gateway, some older systems might not need credentials to be set up. For newer devices authentication is required.", "data": { "host": "[%key:common::config_flow::data::host%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" }, "data_description": { - "host": "Hostname or IP-address of the Intergas InComfort Lan2RF Gateway.", + "host": "Hostname or IP-address of the Intergas gateway.", "username": "The username to log into the gateway. This is `admin` in most cases.", - "password": "The password to log into the gateway, is printed at the bottom of the Lan2RF Gateway or is `intergas` for some older devices." + "password": "The password to log into the gateway, is printed at the bottom of the gateway or is `intergas` for some older devices." } }, "dhcp_auth": { - "title": "Set up Intergas InComfort Lan2RF Gateway", + "title": "Set up Intergas gateway", "description": "Please enter authentication details for gateway {host}", "data": { "username": "[%key:common::config_flow::data::username%]", @@ -23,12 +23,12 @@ }, "data_description": { "username": "The username to log into the gateway. This is `admin` in most cases.", - "password": "The password to log into the gateway, is printed at the bottom of the Lan2RF Gateway or is `intergas` for some older devices." + "password": "The password to log into the gateway, is printed at the bottom of the Gateway or is `intergas` for some older devices." } }, "dhcp_confirm": { - "title": "Set up Intergas InComfort Lan2RF Gateway", - "description": "Do you want to set up the discovered Intergas InComfort Lan2RF Gateway ({host})?" + "title": "Set up Intergas gateway", + "description": "Do you want to set up the discovered Intergas gateway ({host})?" }, "reauth_confirm": { "data": { @@ -48,9 +48,9 @@ "error": { "auth_error": "Invalid credentials.", "no_heaters": "No heaters found.", - "not_found": "No Lan2RF gateway found.", - "timeout_error": "Time out when connecting to Lan2RF gateway.", - "unknown": "Unknown error when connecting to Lan2RF gateway." + "not_found": "No gateway found.", + "timeout_error": "Time out when connecting to the gateway.", + "unknown": "Unknown error when connecting to the gateway." } }, "exceptions": { @@ -70,7 +70,7 @@ "options": { "step": { "init": { - "title": "Intergas InComfort Lan2RF Gateway options", + "title": "Intergas gateway options", "data": { "legacy_setpoint_status": "Legacy setpoint handling" }, diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index e8a4290bb7d..cab624ecb5b 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -2866,7 +2866,7 @@ "iot_class": "local_polling" }, "incomfort": { - "name": "Intergas InComfort/Intouch Lan2RF gateway", + "name": "Intergas gateway", "integration_type": "hub", "config_flow": true, "iot_class": "local_polling" From 6dd2d46328391689fade057dd5a6c09e24ed75e7 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Thu, 30 Jan 2025 16:59:39 +0100 Subject: [PATCH 0292/3148] Fix backup related translations in Synology DSM (#136931) refernce backup related strings in option-flow strings --- homeassistant/components/synology_dsm/strings.json | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/synology_dsm/strings.json b/homeassistant/components/synology_dsm/strings.json index 3d64f908256..d6d40be3fea 100644 --- a/homeassistant/components/synology_dsm/strings.json +++ b/homeassistant/components/synology_dsm/strings.json @@ -70,7 +70,13 @@ "data": { "scan_interval": "Minutes between scans", "timeout": "Timeout (seconds)", - "snap_profile_type": "Quality level of camera snapshots (0:high 1:medium 2:low)" + "snap_profile_type": "Quality level of camera snapshots (0:high 1:medium 2:low)", + "backup_share": "[%key:component::synology_dsm::config::step::backup_share::data::backup_share%]", + "backup_path": "[%key:component::synology_dsm::config::step::backup_share::data::backup_path%]" + }, + "data_description": { + "backup_share": "[%key:component::synology_dsm::config::step::backup_share::data_description::backup_share%]", + "backup_path": "[%key:component::synology_dsm::config::step::backup_share::data_description::backup_path%]" } } } From 63af407f8fb5f1e3d748a2e5d1cb0e8134a3a501 Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Thu, 30 Jan 2025 17:08:35 +0100 Subject: [PATCH 0293/3148] Pick onedrive owner from a more reliable source (#136929) * Pick onedrive owner from a more reliable source * fix --- homeassistant/components/onedrive/config_flow.py | 7 +++++-- tests/components/onedrive/conftest.py | 2 +- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/onedrive/config_flow.py b/homeassistant/components/onedrive/config_flow.py index 83f6dd6e2ee..09c0d1b44cc 100644 --- a/homeassistant/components/onedrive/config_flow.py +++ b/homeassistant/components/onedrive/config_flow.py @@ -78,7 +78,7 @@ class OneDriveConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN): self.logger.exception("Unknown error") return self.async_abort(reason="unknown") - drive = response.json() + drive: dict = response.json() await self.async_set_unique_id(drive["parentReference"]["driveId"]) @@ -94,7 +94,10 @@ class OneDriveConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN): self._abort_if_unique_id_configured() - title = f"{drive['shared']['owner']['user']['displayName']}'s OneDrive" + user = drive.get("createdBy", {}).get("user", {}).get("displayName") + + title = f"{user}'s OneDrive" if user else "OneDrive" + return self.async_create_entry(title=title, data=data) async def async_step_reauth( diff --git a/tests/components/onedrive/conftest.py b/tests/components/onedrive/conftest.py index 0cca8e9df0b..65142217017 100644 --- a/tests/components/onedrive/conftest.py +++ b/tests/components/onedrive/conftest.py @@ -88,7 +88,7 @@ def mock_adapter() -> Generator[MagicMock]: status_code=200, json={ "parentReference": {"driveId": "mock_drive_id"}, - "shared": {"owner": {"user": {"displayName": "John Doe"}}}, + "createdBy": {"user": {"displayName": "John Doe"}}, }, ) yield adapter From ec53b08e0907177091edb7c5a7aa6f746e520171 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 30 Jan 2025 17:32:01 +0100 Subject: [PATCH 0294/3148] Don't blow up when a backup doesn't exist on supervisor (#136907) --- homeassistant/components/hassio/backup.py | 9 +-- tests/components/hassio/test_backup.py | 96 ++++++++++++++++++++--- 2 files changed, 91 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/hassio/backup.py b/homeassistant/components/hassio/backup.py index b81605264be..b9439183d8c 100644 --- a/homeassistant/components/hassio/backup.py +++ b/homeassistant/components/hassio/backup.py @@ -198,7 +198,10 @@ class SupervisorBackupAgent(BackupAgent): **kwargs: Any, ) -> AgentBackup | None: """Return a backup.""" - details = await self._client.backups.backup_info(backup_id) + try: + details = await self._client.backups.backup_info(backup_id) + except SupervisorNotFoundError: + return None if self.location not in details.locations: return None return _backup_details_to_agent_backup(details, self.location) @@ -212,10 +215,6 @@ class SupervisorBackupAgent(BackupAgent): location={self.location} ), ) - except SupervisorBadRequestError as err: - if err.args[0] != "Backup does not exist": - raise - _LOGGER.debug("Backup %s does not exist", backup_id) except SupervisorNotFoundError: _LOGGER.debug("Backup %s does not exist", backup_id) diff --git a/tests/components/hassio/test_backup.py b/tests/components/hassio/test_backup.py index f7379b81a14..9ba73ade1a3 100644 --- a/tests/components/hassio/test_backup.py +++ b/tests/components/hassio/test_backup.py @@ -544,7 +544,7 @@ async def test_agent_download( hass_client: ClientSessionGenerator, supervisor_client: AsyncMock, ) -> None: - """Test agent download backup, when cloud user is logged in.""" + """Test agent download backup.""" client = await hass_client() backup_id = "abc123" supervisor_client.backups.list.return_value = [TEST_BACKUP] @@ -568,7 +568,7 @@ async def test_agent_download_unavailable_backup( hass_client: ClientSessionGenerator, supervisor_client: AsyncMock, ) -> None: - """Test agent download backup, when cloud user is logged in.""" + """Test agent download backup which does not exist.""" client = await hass_client() backup_id = "abc123" supervisor_client.backups.list.return_value = [TEST_BACKUP_3] @@ -630,6 +630,91 @@ async def test_agent_upload( supervisor_client.backups.remove_backup.assert_not_called() +@pytest.mark.usefixtures("hassio_client", "setup_integration") +async def test_agent_get_backup( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + supervisor_client: AsyncMock, +) -> None: + """Test agent get backup.""" + client = await hass_ws_client(hass) + supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS + backup_id = "abc123" + + await client.send_json_auto_id( + { + "type": "backup/details", + "backup_id": backup_id, + } + ) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == { + "agent_errors": {}, + "backup": { + "addons": [ + {"name": "Terminal & SSH", "slug": "core_ssh", "version": "9.14.0"} + ], + "agents": {"hassio.local": {"protected": False, "size": 1048576}}, + "backup_id": "abc123", + "database_included": True, + "date": "1970-01-01T00:00:00+00:00", + "failed_agent_ids": [], + "folders": ["share"], + "homeassistant_included": True, + "homeassistant_version": "2024.12.0", + "name": "Test", + "with_automatic_settings": None, + }, + } + supervisor_client.backups.backup_info.assert_called_once_with(backup_id) + + +@pytest.mark.usefixtures("hassio_client", "setup_integration") +@pytest.mark.parametrize( + ("backup_info_side_effect", "expected_response"), + [ + ( + SupervisorBadRequestError("blah"), + { + "success": False, + "error": {"code": "unknown_error", "message": "Unknown error"}, + }, + ), + ( + SupervisorNotFoundError(), + { + "success": True, + "result": {"agent_errors": {}, "backup": None}, + }, + ), + ], +) +async def test_agent_get_backup_with_error( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + supervisor_client: AsyncMock, + backup_info_side_effect: Exception, + expected_response: dict[str, Any], +) -> None: + """Test agent get backup.""" + client = await hass_ws_client(hass) + backup_id = "abc123" + + supervisor_client.backups.backup_info.side_effect = backup_info_side_effect + await client.send_json_auto_id( + { + "type": "backup/details", + "backup_id": backup_id, + } + ) + response = await client.receive_json() + + assert response == {"id": 1, "type": "result"} | expected_response + supervisor_client.backups.backup_info.assert_called_once_with(backup_id) + + @pytest.mark.usefixtures("hassio_client", "setup_integration") async def test_agent_delete_backup( hass: HomeAssistant, @@ -666,13 +751,6 @@ async def test_agent_delete_backup( "error": {"code": "unknown_error", "message": "Unknown error"}, }, ), - ( - SupervisorBadRequestError("Backup does not exist"), - { - "success": True, - "result": {"agent_errors": {}}, - }, - ), ( SupervisorNotFoundError(), { From eca93f1f4e318a787d1d7f18bd9a0b6913c45c72 Mon Sep 17 00:00:00 2001 From: moritzthecat Date: Thu, 30 Jan 2025 17:33:41 +0100 Subject: [PATCH 0295/3148] Add DS2450 to onewire integration (#136882) * add DS2450 to onewire integration * added tests for DS2450 in const.py * Update homeassistant/components/onewire/sensor.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * spelling change voltage -> Voltage * use translation key * tests run after en.json edited * Update homeassistant/components/onewire/strings.json Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * naming convention adapted * Update homeassistant/components/onewire/sensor.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * adatpt owfs namings to HA namings. volt -> voltage * Apply suggestions from code review --------- Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- homeassistant/components/onewire/const.py | 2 + homeassistant/components/onewire/sensor.py | 28 ++ homeassistant/components/onewire/strings.json | 6 + tests/components/onewire/const.py | 13 + .../onewire/snapshots/test_init.ambr | 32 ++ .../onewire/snapshots/test_sensor.ambr | 424 ++++++++++++++++++ 6 files changed, 505 insertions(+) diff --git a/homeassistant/components/onewire/const.py b/homeassistant/components/onewire/const.py index 2ab44c47892..57cdd8c483c 100644 --- a/homeassistant/components/onewire/const.py +++ b/homeassistant/components/onewire/const.py @@ -10,6 +10,7 @@ DOMAIN = "onewire" DEVICE_KEYS_0_3 = range(4) DEVICE_KEYS_0_7 = range(8) DEVICE_KEYS_A_B = ("A", "B") +DEVICE_KEYS_A_D = ("A", "B", "C", "D") DEVICE_SUPPORT = { "05": (), @@ -17,6 +18,7 @@ DEVICE_SUPPORT = { "12": (), "1D": (), "1F": (), + "20": (), "22": (), "26": (), "28": (), diff --git a/homeassistant/components/onewire/sensor.py b/homeassistant/components/onewire/sensor.py index 1c4047abf0a..04141f87847 100644 --- a/homeassistant/components/onewire/sensor.py +++ b/homeassistant/components/onewire/sensor.py @@ -33,6 +33,7 @@ from homeassistant.helpers.typing import StateType from .const import ( DEVICE_KEYS_0_3, DEVICE_KEYS_A_B, + DEVICE_KEYS_A_D, OPTION_ENTRY_DEVICE_OPTIONS, OPTION_ENTRY_SENSOR_PRECISION, PRECISION_MAPPING_FAMILY_28, @@ -108,6 +109,33 @@ DEVICE_SENSORS: dict[str, tuple[OneWireSensorEntityDescription, ...]] = { state_class=SensorStateClass.MEASUREMENT, ), ), + "20": tuple( + [ + OneWireSensorEntityDescription( + key=f"latestvolt.{device_key}", + device_class=SensorDeviceClass.VOLTAGE, + entity_registry_enabled_default=False, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + read_mode=READ_MODE_FLOAT, + state_class=SensorStateClass.MEASUREMENT, + translation_key="latest_voltage_id", + translation_placeholders={"id": str(device_key)}, + ) + for device_key in DEVICE_KEYS_A_D + ] + + [ + OneWireSensorEntityDescription( + key=f"volt.{device_key}", + device_class=SensorDeviceClass.VOLTAGE, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + read_mode=READ_MODE_FLOAT, + state_class=SensorStateClass.MEASUREMENT, + translation_key="voltage_id", + translation_placeholders={"id": str(device_key)}, + ) + for device_key in DEVICE_KEYS_A_D + ] + ), "22": (SIMPLE_TEMPERATURE_SENSOR_DESCRIPTION,), "26": ( SIMPLE_TEMPERATURE_SENSOR_DESCRIPTION, diff --git a/homeassistant/components/onewire/strings.json b/homeassistant/components/onewire/strings.json index 8f46369a70b..46f41503d97 100644 --- a/homeassistant/components/onewire/strings.json +++ b/homeassistant/components/onewire/strings.json @@ -74,12 +74,18 @@ "humidity_raw": { "name": "Raw humidity" }, + "latest_voltage_id": { + "name": "Latest voltage {id}" + }, "moisture_id": { "name": "Moisture {id}" }, "thermocouple_temperature_k": { "name": "Thermocouple K temperature" }, + "voltage_id": { + "name": "Voltage {id}" + }, "voltage_vad": { "name": "VAD voltage" }, diff --git a/tests/components/onewire/const.py b/tests/components/onewire/const.py index 4c05442eadc..370bcc871c6 100644 --- a/tests/components/onewire/const.py +++ b/tests/components/onewire/const.py @@ -65,6 +65,19 @@ MOCK_OWPROXY_DEVICES = { }, }, }, + "20.111111111111": { + ATTR_INJECT_READS: { + "/type": [b"DS2450"], + "/volt.A": [b" 1.1"], + "/volt.B": [b" 2.2"], + "/volt.C": [b" 3.3"], + "/volt.D": [b" 4.4"], + "/latestvolt.A": [b" 1.11"], + "/latestvolt.B": [b" 2.22"], + "/latestvolt.C": [b" 3.33"], + "/latestvolt.D": [b" 4.44"], + } + }, "22.111111111111": { ATTR_INJECT_READS: { "/type": [b"DS1822"], diff --git a/tests/components/onewire/snapshots/test_init.ambr b/tests/components/onewire/snapshots/test_init.ambr index 159f3acea42..ee5d6d99158 100644 --- a/tests/components/onewire/snapshots/test_init.ambr +++ b/tests/components/onewire/snapshots/test_init.ambr @@ -159,6 +159,38 @@ 'via_device_id': None, }) # --- +# name: test_registry[20.111111111111-entry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'onewire', + '20.111111111111', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Maxim Integrated', + 'model': 'DS2450', + 'model_id': 'DS2450', + 'name': '20.111111111111', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '111111111111', + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- # name: test_registry[22.111111111111-entry] DeviceRegistryEntrySnapshot({ 'area_id': None, diff --git a/tests/components/onewire/snapshots/test_sensor.ambr b/tests/components/onewire/snapshots/test_sensor.ambr index 1b8484b27a4..b963e29d160 100644 --- a/tests/components/onewire/snapshots/test_sensor.ambr +++ b/tests/components/onewire/snapshots/test_sensor.ambr @@ -260,6 +260,430 @@ 'state': '248125', }) # --- +# name: test_sensors[sensor.20_111111111111_latest_voltage_a-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.20_111111111111_latest_voltage_a', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Latest voltage A', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'latest_voltage_id', + 'unique_id': '/20.111111111111/latestvolt.A', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.20_111111111111_latest_voltage_a-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'device_file': '/20.111111111111/latestvolt.A', + 'friendly_name': '20.111111111111 Latest voltage A', + 'raw_value': 1.11, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.20_111111111111_latest_voltage_a', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.11', + }) +# --- +# name: test_sensors[sensor.20_111111111111_latest_voltage_b-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.20_111111111111_latest_voltage_b', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Latest voltage B', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'latest_voltage_id', + 'unique_id': '/20.111111111111/latestvolt.B', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.20_111111111111_latest_voltage_b-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'device_file': '/20.111111111111/latestvolt.B', + 'friendly_name': '20.111111111111 Latest voltage B', + 'raw_value': 2.22, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.20_111111111111_latest_voltage_b', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.22', + }) +# --- +# name: test_sensors[sensor.20_111111111111_latest_voltage_c-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.20_111111111111_latest_voltage_c', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Latest voltage C', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'latest_voltage_id', + 'unique_id': '/20.111111111111/latestvolt.C', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.20_111111111111_latest_voltage_c-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'device_file': '/20.111111111111/latestvolt.C', + 'friendly_name': '20.111111111111 Latest voltage C', + 'raw_value': 3.33, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.20_111111111111_latest_voltage_c', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3.33', + }) +# --- +# name: test_sensors[sensor.20_111111111111_latest_voltage_d-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.20_111111111111_latest_voltage_d', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Latest voltage D', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'latest_voltage_id', + 'unique_id': '/20.111111111111/latestvolt.D', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.20_111111111111_latest_voltage_d-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'device_file': '/20.111111111111/latestvolt.D', + 'friendly_name': '20.111111111111 Latest voltage D', + 'raw_value': 4.44, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.20_111111111111_latest_voltage_d', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4.44', + }) +# --- +# name: test_sensors[sensor.20_111111111111_voltage_a-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.20_111111111111_voltage_a', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage A', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_id', + 'unique_id': '/20.111111111111/volt.A', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.20_111111111111_voltage_a-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'device_file': '/20.111111111111/volt.A', + 'friendly_name': '20.111111111111 Voltage A', + 'raw_value': 1.1, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.20_111111111111_voltage_a', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.1', + }) +# --- +# name: test_sensors[sensor.20_111111111111_voltage_b-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.20_111111111111_voltage_b', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage B', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_id', + 'unique_id': '/20.111111111111/volt.B', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.20_111111111111_voltage_b-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'device_file': '/20.111111111111/volt.B', + 'friendly_name': '20.111111111111 Voltage B', + 'raw_value': 2.2, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.20_111111111111_voltage_b', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.2', + }) +# --- +# name: test_sensors[sensor.20_111111111111_voltage_c-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.20_111111111111_voltage_c', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage C', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_id', + 'unique_id': '/20.111111111111/volt.C', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.20_111111111111_voltage_c-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'device_file': '/20.111111111111/volt.C', + 'friendly_name': '20.111111111111 Voltage C', + 'raw_value': 3.3, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.20_111111111111_voltage_c', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3.3', + }) +# --- +# name: test_sensors[sensor.20_111111111111_voltage_d-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.20_111111111111_voltage_d', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage D', + 'platform': 'onewire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_id', + 'unique_id': '/20.111111111111/volt.D', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.20_111111111111_voltage_d-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'device_file': '/20.111111111111/volt.D', + 'friendly_name': '20.111111111111 Voltage D', + 'raw_value': 4.4, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.20_111111111111_voltage_d', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4.4', + }) +# --- # name: test_sensors[sensor.22_111111111111_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From f501b55aedbd1830475d815ad5c0fe394a9ab598 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Thu, 30 Jan 2025 17:43:48 +0100 Subject: [PATCH 0296/3148] Fix KeyError for Shelly virtual number component (#136932) --- homeassistant/components/shelly/number.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/shelly/number.py b/homeassistant/components/shelly/number.py index 5f0567d034a..c4420783bbb 100644 --- a/homeassistant/components/shelly/number.py +++ b/homeassistant/components/shelly/number.py @@ -186,7 +186,7 @@ RPC_NUMBERS: Final = { mode_fn=lambda config: VIRTUAL_NUMBER_MODE_MAP.get( config["meta"]["ui"]["view"], NumberMode.BOX ), - step_fn=lambda config: config["meta"]["ui"]["step"], + step_fn=lambda config: config["meta"]["ui"].get("step"), # If the unit is not set, the device sends an empty string unit=lambda config: config["meta"]["ui"]["unit"] if config["meta"]["ui"]["unit"] From 3dc52774fc250e6437d54e84968b72c8377b837a Mon Sep 17 00:00:00 2001 From: tronikos Date: Thu, 30 Jan 2025 09:15:13 -0800 Subject: [PATCH 0297/3148] Don't log errors when raising a backup exception in Google Drive (#136916) --- homeassistant/components/google_drive/backup.py | 13 ++++--------- tests/components/google_drive/test_backup.py | 6 +++--- 2 files changed, 7 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/google_drive/backup.py b/homeassistant/components/google_drive/backup.py index 4c81f041c8b..73e5902f8f5 100644 --- a/homeassistant/components/google_drive/backup.py +++ b/homeassistant/components/google_drive/backup.py @@ -80,16 +80,14 @@ class GoogleDriveBackupAgent(BackupAgent): try: await self._client.async_upload_backup(open_stream, backup) except (GoogleDriveApiError, HomeAssistantError, TimeoutError) as err: - _LOGGER.error("Upload backup error: %s", err) - raise BackupAgentError("Failed to upload backup") from err + raise BackupAgentError(f"Failed to upload backup: {err}") from err async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]: """List backups.""" try: return await self._client.async_list_backups() except (GoogleDriveApiError, HomeAssistantError, TimeoutError) as err: - _LOGGER.error("List backups error: %s", err) - raise BackupAgentError("Failed to list backups") from err + raise BackupAgentError(f"Failed to list backups: {err}") from err async def async_get_backup( self, @@ -121,9 +119,7 @@ class GoogleDriveBackupAgent(BackupAgent): stream = await self._client.async_download(file_id) return ChunkAsyncStreamIterator(stream) except (GoogleDriveApiError, HomeAssistantError, TimeoutError) as err: - _LOGGER.error("Download backup error: %s", err) - raise BackupAgentError("Failed to download backup") from err - _LOGGER.error("Download backup_id: %s not found", backup_id) + raise BackupAgentError(f"Failed to download backup: {err}") from err raise BackupAgentError("Backup not found") async def async_delete_backup( @@ -143,5 +139,4 @@ class GoogleDriveBackupAgent(BackupAgent): await self._client.async_delete(file_id) _LOGGER.debug("Deleted backup_id: %s", backup_id) except (GoogleDriveApiError, HomeAssistantError, TimeoutError) as err: - _LOGGER.error("Delete backup error: %s", err) - raise BackupAgentError("Failed to delete backup") from err + raise BackupAgentError(f"Failed to delete backup: {err}") from err diff --git a/tests/components/google_drive/test_backup.py b/tests/components/google_drive/test_backup.py index 62b7930012c..7e455ebb535 100644 --- a/tests/components/google_drive/test_backup.py +++ b/tests/components/google_drive/test_backup.py @@ -141,7 +141,7 @@ async def test_agents_list_backups_fail( assert response["success"] assert response["result"]["backups"] == [] assert response["result"]["agent_errors"] == { - TEST_AGENT_ID: "Failed to list backups" + TEST_AGENT_ID: "Failed to list backups: some error" } @@ -381,7 +381,7 @@ async def test_agents_upload_fail( await hass.async_block_till_done() assert resp.status == 201 - assert "Upload backup error: some error" in caplog.text + assert "Failed to upload backup: some error" in caplog.text async def test_agents_delete( @@ -430,7 +430,7 @@ async def test_agents_delete_fail( assert response["success"] assert response["result"] == { - "agent_errors": {TEST_AGENT_ID: "Failed to delete backup"} + "agent_errors": {TEST_AGENT_ID: "Failed to delete backup: some error"} } From c3b0bc3e0db5f0faa0914eeca92ebe14ec4d98c6 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 30 Jan 2025 18:15:54 +0100 Subject: [PATCH 0298/3148] Show name of the backup agents in issue (#136925) * Show name of the backup agents in issue * Show name of the backup agents in issue * Update homeassistant/components/backup/manager.py Co-authored-by: Martin Hjelmare --------- Co-authored-by: Martin Hjelmare --- homeassistant/components/backup/manager.py | 6 +++++- tests/components/backup/test_manager.py | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index d1f27fa270b..1dbd8f8547d 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -1166,7 +1166,11 @@ class BackupManager: learn_more_url="homeassistant://config/backup", severity=ir.IssueSeverity.WARNING, translation_key="automatic_backup_failed_upload_agents", - translation_placeholders={"failed_agents": ", ".join(agent_errors)}, + translation_placeholders={ + "failed_agents": ", ".join( + self.backup_agents[agent_id].name for agent_id in agent_errors + ) + }, ) async def async_can_decrypt_on_download( diff --git a/tests/components/backup/test_manager.py b/tests/components/backup/test_manager.py index 69994028297..4a8d2360d3f 100644 --- a/tests/components/backup/test_manager.py +++ b/tests/components/backup/test_manager.py @@ -908,7 +908,7 @@ async def delayed_boom(*args, **kwargs) -> tuple[NewBackup, Any]: { (DOMAIN, "automatic_backup_failed"): { "translation_key": "automatic_backup_failed_upload_agents", - "translation_placeholders": {"failed_agents": "test.remote"}, + "translation_placeholders": {"failed_agents": "remote"}, } }, ), From 6858f2a3d2deb6facce4815d514426dfb68e3e9e Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Thu, 30 Jan 2025 18:38:11 +0100 Subject: [PATCH 0299/3148] Update frontend to 20250130.0 (#136937) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index f4e426485c8..b545026059c 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -21,5 +21,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20250129.0"] + "requirements": ["home-assistant-frontend==20250130.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 6d9e8f43755..01cfc57f3a8 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -37,7 +37,7 @@ habluetooth==3.14.0 hass-nabucasa==0.88.1 hassil==2.2.0 home-assistant-bluetooth==1.13.0 -home-assistant-frontend==20250129.0 +home-assistant-frontend==20250130.0 home-assistant-intents==2025.1.28 httpx==0.28.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 5cea3cd444e..cdc710bc3c1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1146,7 +1146,7 @@ hole==0.8.0 holidays==0.65 # homeassistant.components.frontend -home-assistant-frontend==20250129.0 +home-assistant-frontend==20250130.0 # homeassistant.components.conversation home-assistant-intents==2025.1.28 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a6c6271c39a..ce31cb1dbc1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -975,7 +975,7 @@ hole==0.8.0 holidays==0.65 # homeassistant.components.frontend -home-assistant-frontend==20250129.0 +home-assistant-frontend==20250130.0 # homeassistant.components.conversation home-assistant-intents==2025.1.28 From f391438d0ad6e8c70403fa0b9319c1226d4e03a2 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 29 Jan 2025 21:32:10 -0500 Subject: [PATCH 0300/3148] Add start_conversation service to Assist Satellite (#134921) * Add start_conversation service to Assist Satellite * Fix tests * Implement start_conversation in voip * Update homeassistant/components/assist_satellite/entity.py --------- Co-authored-by: Michael Hansen --- .../components/assist_pipeline/pipeline.py | 1 + .../components/assist_satellite/__init__.py | 15 ++ .../components/assist_satellite/const.py | 3 + .../components/assist_satellite/entity.py | 59 +++++++- .../components/assist_satellite/icons.json | 3 + .../components/assist_satellite/services.yaml | 20 +++ .../components/assist_satellite/strings.json | 18 +++ .../components/voip/assist_satellite.py | 38 ++++-- homeassistant/helpers/service.py | 2 + tests/components/assist_satellite/conftest.py | 15 +- .../assist_satellite/test_entity.py | 128 ++++++++++++++++-- tests/components/voip/test_voip.py | 107 ++++++++++++++- 12 files changed, 384 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/assist_pipeline/pipeline.py b/homeassistant/components/assist_pipeline/pipeline.py index 9fdcc2bf690..1d320d79bf2 100644 --- a/homeassistant/components/assist_pipeline/pipeline.py +++ b/homeassistant/components/assist_pipeline/pipeline.py @@ -1122,6 +1122,7 @@ class PipelineRun: context=user_input.context, language=user_input.language, agent_id=user_input.agent_id, + extra_system_prompt=user_input.extra_system_prompt, ) speech = conversation_result.response.speech.get("plain", {}).get( "speech", "" diff --git a/homeassistant/components/assist_satellite/__init__.py b/homeassistant/components/assist_satellite/__init__.py index 47b0123a244..038ff517264 100644 --- a/homeassistant/components/assist_satellite/__init__.py +++ b/homeassistant/components/assist_satellite/__init__.py @@ -63,6 +63,21 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: "async_internal_announce", [AssistSatelliteEntityFeature.ANNOUNCE], ) + component.async_register_entity_service( + "start_conversation", + vol.All( + cv.make_entity_service_schema( + { + vol.Optional("start_message"): str, + vol.Optional("start_media_id"): str, + vol.Optional("extra_system_prompt"): str, + } + ), + cv.has_at_least_one_key("start_message", "start_media_id"), + ), + "async_internal_start_conversation", + [AssistSatelliteEntityFeature.START_CONVERSATION], + ) hass.data[CONNECTION_TEST_DATA] = {} async_register_websocket_api(hass) hass.http.register_view(ConnectionTestView()) diff --git a/homeassistant/components/assist_satellite/const.py b/homeassistant/components/assist_satellite/const.py index 61ac7ecb39d..f7ac7e524b4 100644 --- a/homeassistant/components/assist_satellite/const.py +++ b/homeassistant/components/assist_satellite/const.py @@ -26,3 +26,6 @@ class AssistSatelliteEntityFeature(IntFlag): ANNOUNCE = 1 """Device supports remotely triggered announcements.""" + + START_CONVERSATION = 2 + """Device supports starting conversations.""" diff --git a/homeassistant/components/assist_satellite/entity.py b/homeassistant/components/assist_satellite/entity.py index e9a5d22c0d0..927229c9756 100644 --- a/homeassistant/components/assist_satellite/entity.py +++ b/homeassistant/components/assist_satellite/entity.py @@ -10,7 +10,7 @@ import logging import time from typing import Any, Final, Literal, final -from homeassistant.components import media_source, stt, tts +from homeassistant.components import conversation, media_source, stt, tts from homeassistant.components.assist_pipeline import ( OPTION_PREFERRED, AudioSettings, @@ -27,6 +27,7 @@ from homeassistant.components.tts import ( generate_media_source_id as tts_generate_media_source_id, ) from homeassistant.core import Context, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity from homeassistant.helpers.entity import EntityDescription @@ -117,6 +118,7 @@ class AssistSatelliteEntity(entity.Entity): _run_has_tts: bool = False _is_announcing = False + _extra_system_prompt: str | None = None _wake_word_intercept_future: asyncio.Future[str | None] | None = None _attr_tts_options: dict[str, Any] | None = None _pipeline_task: asyncio.Task | None = None @@ -216,6 +218,60 @@ class AssistSatelliteEntity(entity.Entity): """ raise NotImplementedError + async def async_internal_start_conversation( + self, + start_message: str | None = None, + start_media_id: str | None = None, + extra_system_prompt: str | None = None, + ) -> None: + """Start a conversation from the satellite. + + If start_media_id is not provided, message is synthesized to + audio with the selected pipeline. + + If start_media_id is provided, it is played directly. It is possible + to omit the message and the satellite will not show any text. + + Calls async_start_conversation. + """ + await self._cancel_running_pipeline() + + # The Home Assistant built-in agent doesn't support conversations. + pipeline = async_get_pipeline(self.hass, self._resolve_pipeline()) + if pipeline.conversation_engine == conversation.HOME_ASSISTANT_AGENT: + raise HomeAssistantError( + "Built-in conversation agent does not support starting conversations" + ) + + if start_message is None: + start_message = "" + + announcement = await self._resolve_announcement_media_id( + start_message, start_media_id + ) + + if self._is_announcing: + raise SatelliteBusyError + + self._is_announcing = True + # Provide our start info to the LLM so it understands context of incoming message + if extra_system_prompt is not None: + self._extra_system_prompt = extra_system_prompt + else: + self._extra_system_prompt = start_message or None + + try: + await self.async_start_conversation(announcement) + finally: + self._is_announcing = False + self._extra_system_prompt = None + + async def async_start_conversation( + self, start_announcement: AssistSatelliteAnnouncement + ) -> None: + """Start a conversation from the satellite.""" + raise NotImplementedError + async def async_accept_pipeline_from_satellite( self, audio_stream: AsyncIterable[bytes], @@ -302,6 +358,7 @@ class AssistSatelliteEntity(entity.Entity): ), start_stage=start_stage, end_stage=end_stage, + conversation_extra_system_prompt=self._extra_system_prompt, ), f"{self.entity_id}_pipeline", ) diff --git a/homeassistant/components/assist_satellite/icons.json b/homeassistant/components/assist_satellite/icons.json index a98c3aefc5b..1ed29541621 100644 --- a/homeassistant/components/assist_satellite/icons.json +++ b/homeassistant/components/assist_satellite/icons.json @@ -7,6 +7,9 @@ "services": { "announce": { "service": "mdi:bullhorn" + }, + "start_conversation": { + "service": "mdi:forum" } } } diff --git a/homeassistant/components/assist_satellite/services.yaml b/homeassistant/components/assist_satellite/services.yaml index e7fefc4705f..89a20ada6f3 100644 --- a/homeassistant/components/assist_satellite/services.yaml +++ b/homeassistant/components/assist_satellite/services.yaml @@ -14,3 +14,23 @@ announce: required: false selector: text: +start_conversation: + target: + entity: + domain: assist_satellite + supported_features: + - assist_satellite.AssistSatelliteEntityFeature.START_CONVERSATION + fields: + start_message: + required: false + example: "You left the lights on in the living room. Turn them off?" + selector: + text: + start_media_id: + required: false + selector: + text: + extra_system_prompt: + required: false + selector: + text: diff --git a/homeassistant/components/assist_satellite/strings.json b/homeassistant/components/assist_satellite/strings.json index 7f1426ef529..e83f4666b5d 100644 --- a/homeassistant/components/assist_satellite/strings.json +++ b/homeassistant/components/assist_satellite/strings.json @@ -25,6 +25,24 @@ "description": "The media ID to announce instead of using text-to-speech." } } + }, + "start_conversation": { + "name": "Start Conversation", + "description": "Start a conversation from a satellite.", + "fields": { + "start_message": { + "name": "Message", + "description": "The message to start with." + }, + "start_media_id": { + "name": "Media ID", + "description": "The media ID to start with instead of using text-to-speech." + }, + "extra_system_prompt": { + "name": "Extra system prompt", + "description": "Provide background information to the AI about the request." + } + } } } } diff --git a/homeassistant/components/voip/assist_satellite.py b/homeassistant/components/voip/assist_satellite.py index 6cacdd79af4..1877b8c655c 100644 --- a/homeassistant/components/voip/assist_satellite.py +++ b/homeassistant/components/voip/assist_satellite.py @@ -90,7 +90,10 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol entity_description = AssistSatelliteEntityDescription(key="assist_satellite") _attr_translation_key = "assist_satellite" _attr_name = None - _attr_supported_features = AssistSatelliteEntityFeature.ANNOUNCE + _attr_supported_features = ( + AssistSatelliteEntityFeature.ANNOUNCE + | AssistSatelliteEntityFeature.START_CONVERSATION + ) def __init__( self, @@ -122,6 +125,7 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol self._check_announcement_ended_task: asyncio.Task | None = None self._last_chunk_time: float | None = None self._rtp_port: int | None = None + self._run_pipeline_after_announce: bool = False @property def pipeline_entity_id(self) -> str | None: @@ -172,7 +176,17 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol Plays announcement in a loop, blocking until the caller hangs up. """ + await self._do_announce(announcement, run_pipeline_after=False) + + async def _do_announce( + self, announcement: AssistSatelliteAnnouncement, run_pipeline_after: bool + ) -> None: + """Announce media on the satellite. + + Optionally run a voice pipeline after the announcement has finished. + """ self._announcement_future = asyncio.Future() + self._run_pipeline_after_announce = run_pipeline_after if self._rtp_port is None: # Choose random port for RTP @@ -232,12 +246,6 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol """ while self._announcement is not None: current_time = time.monotonic() - _LOGGER.debug( - "%s %s %s", - self._last_chunk_time, - current_time, - self._announcment_start_time, - ) if (self._last_chunk_time is None) and ( (current_time - self._announcment_start_time) > _ANNOUNCEMENT_RING_TIMEOUT @@ -263,6 +271,12 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol await asyncio.sleep(_ANNOUNCEMENT_HANGUP_SEC / 2) + async def async_start_conversation( + self, start_announcement: AssistSatelliteAnnouncement + ) -> None: + """Start a conversation from the satellite.""" + await self._do_announce(start_announcement, run_pipeline_after=True) + # ------------------------------------------------------------------------- # VoIP # ------------------------------------------------------------------------- @@ -347,7 +361,10 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol try: await asyncio.sleep(_ANNOUNCEMENT_BEFORE_DELAY) await self._send_tts(announcement.original_media_id, wait_for_tone=False) - await asyncio.sleep(_ANNOUNCEMENT_AFTER_DELAY) + + if not self._run_pipeline_after_announce: + # Delay before looping announcement + await asyncio.sleep(_ANNOUNCEMENT_AFTER_DELAY) except Exception: _LOGGER.exception("Unexpected error while playing announcement") raise @@ -355,6 +372,11 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol self._run_pipeline_task = None _LOGGER.debug("Announcement finished") + if self._run_pipeline_after_announce: + # Clear announcement to allow pipeline to run + self._announcement = None + self._announcement_future.set_result(None) + def _clear_audio_queue(self) -> None: """Ensure audio queue is empty.""" while not self._audio_queue.empty(): diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index 255739c0059..4873d935537 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -88,6 +88,7 @@ def _base_components() -> dict[str, ModuleType]: # pylint: disable-next=import-outside-toplevel from homeassistant.components import ( alarm_control_panel, + assist_satellite, calendar, camera, climate, @@ -108,6 +109,7 @@ def _base_components() -> dict[str, ModuleType]: return { "alarm_control_panel": alarm_control_panel, + "assist_satellite": assist_satellite, "calendar": calendar, "camera": camera, "climate": climate, diff --git a/tests/components/assist_satellite/conftest.py b/tests/components/assist_satellite/conftest.py index d75cbd072e0..0cc0e94e149 100644 --- a/tests/components/assist_satellite/conftest.py +++ b/tests/components/assist_satellite/conftest.py @@ -40,6 +40,8 @@ def mock_tts(mock_tts_cache_dir: pathlib.Path) -> None: class MockAssistSatellite(AssistSatelliteEntity): """Mock Assist Satellite Entity.""" + _attr_tts_options = {"test-option": "test-value"} + def __init__(self, name: str, features: AssistSatelliteEntityFeature) -> None: """Initialize the mock entity.""" self._attr_unique_id = ulid_hex() @@ -67,6 +69,7 @@ class MockAssistSatellite(AssistSatelliteEntity): active_wake_words=["1234"], max_active_wake_words=1, ) + self.start_conversations = [] def on_pipeline_event(self, event: PipelineEvent) -> None: """Handle pipeline events.""" @@ -87,11 +90,21 @@ class MockAssistSatellite(AssistSatelliteEntity): """Set the current satellite configuration.""" self.config = config + async def async_start_conversation( + self, start_announcement: AssistSatelliteConfiguration + ) -> None: + """Start a conversation from the satellite.""" + self.start_conversations.append((self._extra_system_prompt, start_announcement)) + @pytest.fixture def entity() -> MockAssistSatellite: """Mock Assist Satellite Entity.""" - return MockAssistSatellite("Test Entity", AssistSatelliteEntityFeature.ANNOUNCE) + return MockAssistSatellite( + "Test Entity", + AssistSatelliteEntityFeature.ANNOUNCE + | AssistSatelliteEntityFeature.START_CONVERSATION, + ) @pytest.fixture diff --git a/tests/components/assist_satellite/test_entity.py b/tests/components/assist_satellite/test_entity.py index c3464beac97..46facb80844 100644 --- a/tests/components/assist_satellite/test_entity.py +++ b/tests/components/assist_satellite/test_entity.py @@ -25,11 +25,24 @@ from homeassistant.components.assist_satellite.entity import AssistSatelliteStat from homeassistant.components.media_source import PlayMedia from homeassistant.config_entries import ConfigEntry from homeassistant.core import Context, HomeAssistant +from homeassistant.exceptions import HomeAssistantError from . import ENTITY_ID from .conftest import MockAssistSatellite +@pytest.fixture(autouse=True) +async def set_pipeline_tts(hass: HomeAssistant, init_components: ConfigEntry) -> None: + """Set up a pipeline with a TTS engine.""" + await async_update_pipeline( + hass, + async_get_pipeline(hass), + tts_engine="tts.mock_entity", + tts_language="en", + tts_voice="test-voice", + ) + + async def test_entity_state( hass: HomeAssistant, init_components: ConfigEntry, entity: MockAssistSatellite ) -> None: @@ -64,7 +77,7 @@ async def test_entity_state( assert kwargs["stt_stream"] is audio_stream assert kwargs["pipeline_id"] is None assert kwargs["device_id"] is entity.device_entry.id - assert kwargs["tts_audio_output"] is None + assert kwargs["tts_audio_output"] == {"test-option": "test-value"} assert kwargs["wake_word_phrase"] is None assert kwargs["audio_settings"] == AudioSettings( silence_seconds=vad.VadSensitivity.to_seconds(vad.VadSensitivity.DEFAULT) @@ -200,24 +213,12 @@ async def test_announce( expected_params: tuple[str, str], ) -> None: """Test announcing on a device.""" - await async_update_pipeline( - hass, - async_get_pipeline(hass), - tts_engine="tts.mock_entity", - tts_language="en", - tts_voice="test-voice", - ) - - entity._attr_tts_options = {"test-option": "test-value"} - original_announce = entity.async_announce - announce_started = asyncio.Event() async def async_announce(announcement): # Verify state change assert entity.state == AssistSatelliteState.RESPONDING await original_announce(announcement) - announce_started.set() def tts_generate_media_source_id( hass: HomeAssistant, @@ -475,3 +476,104 @@ async def test_vad_sensitivity_entity_not_found( with pytest.raises(RuntimeError): await entity.async_accept_pipeline_from_satellite(audio_stream) + + +@pytest.mark.parametrize( + ("service_data", "expected_params"), + [ + ( + { + "start_message": "Hello", + "extra_system_prompt": "Better system prompt", + }, + ( + "Better system prompt", + AssistSatelliteAnnouncement( + message="Hello", + media_id="https://www.home-assistant.io/resolved.mp3", + original_media_id="media-source://generated", + media_id_source="tts", + ), + ), + ), + ( + { + "start_message": "Hello", + "start_media_id": "media-source://given", + }, + ( + "Hello", + AssistSatelliteAnnouncement( + message="Hello", + media_id="https://www.home-assistant.io/resolved.mp3", + original_media_id="media-source://given", + media_id_source="media_id", + ), + ), + ), + ( + {"start_media_id": "http://example.com/given.mp3"}, + ( + None, + AssistSatelliteAnnouncement( + message="", + media_id="http://example.com/given.mp3", + original_media_id="http://example.com/given.mp3", + media_id_source="url", + ), + ), + ), + ], +) +async def test_start_conversation( + hass: HomeAssistant, + init_components: ConfigEntry, + entity: MockAssistSatellite, + service_data: dict, + expected_params: tuple[str, str], +) -> None: + """Test starting a conversation on a device.""" + await async_update_pipeline( + hass, + async_get_pipeline(hass), + conversation_engine="conversation.some_llm", + ) + + with ( + patch( + "homeassistant.components.assist_satellite.entity.tts_generate_media_source_id", + return_value="media-source://generated", + ), + patch( + "homeassistant.components.media_source.async_resolve_media", + return_value=PlayMedia( + url="https://www.home-assistant.io/resolved.mp3", + mime_type="audio/mp3", + ), + ), + ): + await hass.services.async_call( + "assist_satellite", + "start_conversation", + service_data, + target={"entity_id": "assist_satellite.test_entity"}, + blocking=True, + ) + + assert entity.start_conversations[0] == expected_params + + +async def test_start_conversation_reject_builtin_agent( + hass: HomeAssistant, + init_components: ConfigEntry, + entity: MockAssistSatellite, +) -> None: + """Test starting a conversation on a device.""" + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + "assist_satellite", + "start_conversation", + {"start_message": "Hey!"}, + target={"entity_id": "assist_satellite.test_entity"}, + blocking=True, + ) diff --git a/tests/components/voip/test_voip.py b/tests/components/voip/test_voip.py index 306857a1a44..442f4a62392 100644 --- a/tests/components/voip/test_voip.py +++ b/tests/components/voip/test_voip.py @@ -887,7 +887,8 @@ async def test_announce( # Trigger announcement satellite.on_chunk(bytes(_ONE_SECOND)) - await announce_task + async with asyncio.timeout(1): + await announce_task mock_send_tts.assert_called_once_with(_MEDIA_ID, wait_for_tone=False) @@ -938,7 +939,8 @@ async def test_voip_id_is_ip_address( # Trigger announcement satellite.on_chunk(bytes(_ONE_SECOND)) - await announce_task + async with asyncio.timeout(1): + await announce_task mock_send_tts.assert_called_once_with(_MEDIA_ID, wait_for_tone=False) @@ -981,3 +983,104 @@ async def test_announce_timeout( satellite.transport = Mock() with pytest.raises(TimeoutError): await satellite.async_announce(announcement) + + +@pytest.mark.usefixtures("socket_enabled") +async def test_start_conversation( + hass: HomeAssistant, + voip_devices: VoIPDevices, + voip_device: VoIPDevice, +) -> None: + """Test start conversation.""" + assert await async_setup_component(hass, "voip", {}) + + satellite = async_get_satellite_entity(hass, voip.DOMAIN, voip_device.voip_id) + assert isinstance(satellite, VoipAssistSatellite) + assert ( + satellite.supported_features + & assist_satellite.AssistSatelliteEntityFeature.START_CONVERSATION + ) + + announcement = assist_satellite.AssistSatelliteAnnouncement( + message="test announcement", + media_id=_MEDIA_ID, + original_media_id=_MEDIA_ID, + media_id_source="tts", + ) + + # Protocol has already been mocked, but "outgoing_call" is not async + mock_protocol: AsyncMock = hass.data[DOMAIN].protocol + mock_protocol.outgoing_call = Mock() + + tts_sent = asyncio.Event() + + async def _send_tts(*args, **kwargs): + tts_sent.set() + + async def async_pipeline_from_audio_stream( + hass: HomeAssistant, + context: Context, + *args, + device_id: str | None, + tts_audio_output: str | dict[str, Any] | None, + **kwargs, + ): + event_callback = kwargs["event_callback"] + + # Fake tts result + event_callback( + assist_pipeline.PipelineEvent( + type=assist_pipeline.PipelineEventType.TTS_START, + data={ + "engine": "test", + "language": hass.config.language, + "voice": "test", + "tts_input": "fake-text", + }, + ) + ) + + # Proceed with media output + event_callback( + assist_pipeline.PipelineEvent( + type=assist_pipeline.PipelineEventType.TTS_END, + data={"tts_output": {"media_id": _MEDIA_ID}}, + ) + ) + + event_callback( + assist_pipeline.PipelineEvent( + type=assist_pipeline.PipelineEventType.RUN_END + ) + ) + + with ( + patch( + "homeassistant.components.voip.assist_satellite.VoipAssistSatellite._send_tts", + new=_send_tts, + ), + patch( + "homeassistant.components.assist_satellite.entity.async_pipeline_from_audio_stream", + new=async_pipeline_from_audio_stream, + ), + ): + satellite.transport = Mock() + conversation_task = hass.async_create_background_task( + satellite.async_start_conversation(announcement), "voip_start_conversation" + ) + await asyncio.sleep(0) + mock_protocol.outgoing_call.assert_called_once() + + # Trigger announcement and wait for it to finish + satellite.on_chunk(bytes(_ONE_SECOND)) + async with asyncio.timeout(1): + await tts_sent.wait() + + tts_sent.clear() + + # Trigger pipeline + satellite.on_chunk(bytes(_ONE_SECOND)) + async with asyncio.timeout(1): + # Wait for TTS + await tts_sent.wait() + await conversation_task From 55ac0b0f3760b09feee55ee6780de74d947a7d95 Mon Sep 17 00:00:00 2001 From: TimL Date: Thu, 30 Jan 2025 12:01:39 +1100 Subject: [PATCH 0301/3148] Fix loading of SMLIGHT integration when no internet is available (#136497) * Don't fail to load integration if internet unavailable * Add test case for no internet * Also test we recover after internet returns --- .../components/smlight/coordinator.py | 16 ++++--- tests/components/smlight/test_init.py | 44 ++++++++++++++++++- tests/components/smlight/test_update.py | 2 +- 3 files changed, 53 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/smlight/coordinator.py b/homeassistant/components/smlight/coordinator.py index 5b38ec4a89e..6be36439e9f 100644 --- a/homeassistant/components/smlight/coordinator.py +++ b/homeassistant/components/smlight/coordinator.py @@ -144,11 +144,15 @@ class SmFirmwareUpdateCoordinator(SmBaseDataUpdateCoordinator[SmFwData]): async def _internal_update_data(self) -> SmFwData: """Fetch data from the SMLIGHT device.""" info = await self.client.get_info() + esp_firmware = None + zb_firmware = None - return SmFwData( - info=info, - esp_firmware=await self.client.get_firmware_version(info.fw_channel), - zb_firmware=await self.client.get_firmware_version( + try: + esp_firmware = await self.client.get_firmware_version(info.fw_channel) + zb_firmware = await self.client.get_firmware_version( info.fw_channel, device=info.model, mode="zigbee" - ), - ) + ) + except SmlightConnectionError as err: + self.async_set_update_error(err) + + return SmFwData(info=info, esp_firmware=esp_firmware, zb_firmware=zb_firmware) diff --git a/tests/components/smlight/test_init.py b/tests/components/smlight/test_init.py index afc53932fb0..d0c5e494ae8 100644 --- a/tests/components/smlight/test_init.py +++ b/tests/components/smlight/test_init.py @@ -8,9 +8,14 @@ from pysmlight.exceptions import SmlightAuthError, SmlightConnectionError, Smlig import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components.smlight.const import DOMAIN, SCAN_INTERVAL +from homeassistant.components.smlight.const import ( + DOMAIN, + SCAN_FIRMWARE_INTERVAL, + SCAN_INTERVAL, +) +from homeassistant.components.update import ATTR_INSTALLED_VERSION from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.const import STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr from homeassistant.helpers.issue_registry import IssueRegistry @@ -73,6 +78,41 @@ async def test_async_setup_missing_credentials( assert progress[0]["context"]["unique_id"] == "aa:bb:cc:dd:ee:ff" +async def test_async_setup_no_internet( + hass: HomeAssistant, + mock_config_entry_host: MockConfigEntry, + mock_smlight_client: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test we still load integration when no internet is available.""" + mock_smlight_client.get_firmware_version.side_effect = SmlightConnectionError + + await setup_integration(hass, mock_config_entry_host) + + entity = hass.states.get("update.mock_title_core_firmware") + assert entity is not None + assert entity.state == STATE_UNKNOWN + + freezer.tick(SCAN_FIRMWARE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + entity = hass.states.get("update.mock_title_core_firmware") + assert entity is not None + assert entity.state == STATE_UNKNOWN + + mock_smlight_client.get_firmware_version.side_effect = None + + freezer.tick(SCAN_FIRMWARE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + entity = hass.states.get("update.mock_title_core_firmware") + assert entity is not None + assert entity.state == STATE_ON + assert entity.attributes[ATTR_INSTALLED_VERSION] == "v2.3.6" + + @pytest.mark.parametrize("error", [SmlightConnectionError, SmlightAuthError]) async def test_update_failed( hass: HomeAssistant, diff --git a/tests/components/smlight/test_update.py b/tests/components/smlight/test_update.py index 0bb2e34d7ca..4fca7369116 100644 --- a/tests/components/smlight/test_update.py +++ b/tests/components/smlight/test_update.py @@ -81,7 +81,7 @@ async def test_update_setup( mock_config_entry: MockConfigEntry, snapshot: SnapshotAssertion, ) -> None: - """Test setup of SMLIGHT switches.""" + """Test setup of SMLIGHT update entities.""" entry = await setup_integration(hass, mock_config_entry) await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) From ff64e5a312e113c77d83a8a6155cbac28ce9e3e6 Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Thu, 30 Jan 2025 03:57:36 +0100 Subject: [PATCH 0302/3148] Bump ZHA to 0.0.47 (#136883) --- homeassistant/components/zha/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index fa8bab409c9..6a42bc986e9 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -21,7 +21,7 @@ "zha", "universal_silabs_flasher" ], - "requirements": ["zha==0.0.46"], + "requirements": ["zha==0.0.47"], "usb": [ { "vid": "10C4", diff --git a/requirements_all.txt b/requirements_all.txt index 9e6da1045a4..a6b56e80d44 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3131,7 +3131,7 @@ zeroconf==0.141.0 zeversolar==0.3.2 # homeassistant.components.zha -zha==0.0.46 +zha==0.0.47 # homeassistant.components.zhong_hong zhong-hong-hvac==1.0.13 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 76ae46099c2..4e6d43a6b96 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2520,7 +2520,7 @@ zeroconf==0.141.0 zeversolar==0.3.2 # homeassistant.components.zha -zha==0.0.46 +zha==0.0.47 # homeassistant.components.zwave_js zwave-js-server-python==0.60.0 From 8babdc0b717a5f6bac127545d602eb8a1873590b Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Thu, 30 Jan 2025 01:07:55 -0800 Subject: [PATCH 0303/3148] Bump nest to 7.1.1 (#136888) --- 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 f7e78b2d538..cd961276082 100644 --- a/homeassistant/components/nest/manifest.json +++ b/homeassistant/components/nest/manifest.json @@ -19,5 +19,5 @@ "documentation": "https://www.home-assistant.io/integrations/nest", "iot_class": "cloud_push", "loggers": ["google_nest_sdm"], - "requirements": ["google-nest-sdm==7.1.0"] + "requirements": ["google-nest-sdm==7.1.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index a6b56e80d44..533a77d4981 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1033,7 +1033,7 @@ google-cloud-texttospeech==2.17.2 google-generativeai==0.8.2 # homeassistant.components.nest -google-nest-sdm==7.1.0 +google-nest-sdm==7.1.1 # homeassistant.components.google_photos google-photos-library-api==0.12.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4e6d43a6b96..4491e64d808 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -883,7 +883,7 @@ google-cloud-texttospeech==2.17.2 google-generativeai==0.8.2 # homeassistant.components.nest -google-nest-sdm==7.1.0 +google-nest-sdm==7.1.1 # homeassistant.components.google_photos google-photos-library-api==0.12.1 From 0764aca2f13a13f17151bbbf84f4689d9fd31ddc Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 30 Jan 2025 11:09:31 +0100 Subject: [PATCH 0304/3148] Poll supervisor job state when creating or restoring a backup (#136891) * Poll supervisor job state when creating or restoring a backup * Update tests * Add tests for create and restore jobs finishing early --- homeassistant/components/hassio/backup.py | 8 +- tests/components/hassio/test_backup.py | 180 ++++++++++++++++------ 2 files changed, 136 insertions(+), 52 deletions(-) diff --git a/homeassistant/components/hassio/backup.py b/homeassistant/components/hassio/backup.py index 6b63ab92d5c..b81605264be 100644 --- a/homeassistant/components/hassio/backup.py +++ b/homeassistant/components/hassio/backup.py @@ -350,8 +350,9 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): backup_id = data.get("reference") backup_complete.set() + unsub = self._async_listen_job_events(backup.job_id, on_job_progress) try: - unsub = self._async_listen_job_events(backup.job_id, on_job_progress) + await self._get_job_state(backup.job_id, on_job_progress) await backup_complete.wait() finally: unsub() @@ -506,12 +507,13 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): @callback def on_job_progress(data: Mapping[str, Any]) -> None: - """Handle backup progress.""" + """Handle backup restore progress.""" if data.get("done") is True: restore_complete.set() + unsub = self._async_listen_job_events(job.job_id, on_job_progress) try: - unsub = self._async_listen_job_events(job.job_id, on_job_progress) + await self._get_job_state(job.job_id, on_job_progress) await restore_complete.wait() finally: unsub() diff --git a/tests/components/hassio/test_backup.py b/tests/components/hassio/test_backup.py index 49360783517..f7379b81a14 100644 --- a/tests/components/hassio/test_backup.py +++ b/tests/components/hassio/test_backup.py @@ -301,6 +301,28 @@ TEST_BACKUP_DETAILS_5 = supervisor_backups.BackupComplete( type=TEST_BACKUP_5.type, ) +TEST_JOB_ID = "d17bd02be1f0437fa7264b16d38f700e" +TEST_JOB_NOT_DONE = supervisor_jobs.Job( + name="backup_manager_partial_backup", + reference="1ef41507", + uuid=UUID(TEST_JOB_ID), + progress=0.0, + stage="copy_additional_locations", + done=False, + errors=[], + child_jobs=[], +) +TEST_JOB_DONE = supervisor_jobs.Job( + name="backup_manager_partial_backup", + reference="1ef41507", + uuid=UUID(TEST_JOB_ID), + progress=0.0, + stage="copy_additional_locations", + done=True, + errors=[], + child_jobs=[], +) + @pytest.fixture(autouse=True) def fixture_supervisor_environ() -> Generator[None]: @@ -813,8 +835,9 @@ async def test_reader_writer_create( ) -> None: """Test generating a backup.""" client = await hass_ws_client(hass) - supervisor_client.backups.partial_backup.return_value.job_id = "abc123" + supervisor_client.backups.partial_backup.return_value.job_id = TEST_JOB_ID supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS + supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE await client.send_json_auto_id({"type": "backup/subscribe_events"}) response = await client.receive_json() @@ -836,7 +859,7 @@ async def test_reader_writer_create( response = await client.receive_json() assert response["success"] - assert response["result"] == {"backup_job_id": "abc123"} + assert response["result"] == {"backup_job_id": TEST_JOB_ID} supervisor_client.backups.partial_backup.assert_called_once_with( expected_supervisor_options @@ -847,7 +870,7 @@ async def test_reader_writer_create( "type": "supervisor/event", "data": { "event": "job", - "data": {"done": True, "uuid": "abc123", "reference": "test_slug"}, + "data": {"done": True, "uuid": TEST_JOB_ID, "reference": "test_slug"}, }, } ) @@ -877,6 +900,66 @@ async def test_reader_writer_create( assert response["event"] == {"manager_state": "idle"} +@pytest.mark.usefixtures("hassio_client", "setup_integration") +async def test_reader_writer_create_job_done( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + supervisor_client: AsyncMock, +) -> None: + """Test generating a backup, and backup job finishes early.""" + client = await hass_ws_client(hass) + supervisor_client.backups.partial_backup.return_value.job_id = TEST_JOB_ID + supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS + supervisor_client.jobs.get_job.return_value = TEST_JOB_DONE + + await client.send_json_auto_id({"type": "backup/subscribe_events"}) + response = await client.receive_json() + assert response["event"] == {"manager_state": "idle"} + response = await client.receive_json() + assert response["success"] + + await client.send_json_auto_id( + {"type": "backup/generate", "agent_ids": ["hassio.local"], "name": "Test"} + ) + response = await client.receive_json() + assert response["event"] == { + "manager_state": "create_backup", + "reason": None, + "stage": None, + "state": "in_progress", + } + + response = await client.receive_json() + assert response["success"] + assert response["result"] == {"backup_job_id": TEST_JOB_ID} + + supervisor_client.backups.partial_backup.assert_called_once_with( + DEFAULT_BACKUP_OPTIONS + ) + + response = await client.receive_json() + assert response["event"] == { + "manager_state": "create_backup", + "reason": None, + "stage": "upload_to_agents", + "state": "in_progress", + } + + response = await client.receive_json() + assert response["event"] == { + "manager_state": "create_backup", + "reason": None, + "stage": None, + "state": "completed", + } + + supervisor_client.backups.download_backup.assert_not_called() + supervisor_client.backups.remove_backup.assert_not_called() + + response = await client.receive_json() + assert response["event"] == {"manager_state": "idle"} + + @pytest.mark.usefixtures("hassio_client") @pytest.mark.parametrize( ( @@ -1006,7 +1089,7 @@ async def test_reader_writer_create_per_agent_encryption( for i in range(1, 4) ], ) - supervisor_client.backups.partial_backup.return_value.job_id = "abc123" + supervisor_client.backups.partial_backup.return_value.job_id = TEST_JOB_ID supervisor_client.backups.backup_info.return_value = replace( TEST_BACKUP_DETAILS, locations=create_locations, @@ -1018,6 +1101,7 @@ async def test_reader_writer_create_per_agent_encryption( for location in create_locations }, ) + supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE supervisor_client.mounts.info.return_value = mounts assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}}) @@ -1050,7 +1134,7 @@ async def test_reader_writer_create_per_agent_encryption( response = await client.receive_json() assert response["success"] - assert response["result"] == {"backup_job_id": "abc123"} + assert response["result"] == {"backup_job_id": TEST_JOB_ID} supervisor_client.backups.partial_backup.assert_called_once_with( replace( @@ -1065,7 +1149,7 @@ async def test_reader_writer_create_per_agent_encryption( "type": "supervisor/event", "data": { "event": "job", - "data": {"done": True, "uuid": "abc123", "reference": "test_slug"}, + "data": {"done": True, "uuid": TEST_JOB_ID, "reference": "test_slug"}, }, } ) @@ -1176,7 +1260,8 @@ async def test_reader_writer_create_missing_reference_error( ) -> None: """Test missing reference error when generating a backup.""" client = await hass_ws_client(hass) - supervisor_client.backups.partial_backup.return_value.job_id = "abc123" + supervisor_client.backups.partial_backup.return_value.job_id = TEST_JOB_ID + supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE await client.send_json_auto_id({"type": "backup/subscribe_events"}) response = await client.receive_json() @@ -1197,7 +1282,7 @@ async def test_reader_writer_create_missing_reference_error( response = await client.receive_json() assert response["success"] - assert response["result"] == {"backup_job_id": "abc123"} + assert response["result"] == {"backup_job_id": TEST_JOB_ID} assert supervisor_client.backups.partial_backup.call_count == 1 @@ -1206,7 +1291,7 @@ async def test_reader_writer_create_missing_reference_error( "type": "supervisor/event", "data": { "event": "job", - "data": {"done": True, "uuid": "abc123"}, + "data": {"done": True, "uuid": TEST_JOB_ID}, }, } ) @@ -1248,8 +1333,9 @@ async def test_reader_writer_create_download_remove_error( ) -> None: """Test download and remove error when generating a backup.""" client = await hass_ws_client(hass) - supervisor_client.backups.partial_backup.return_value.job_id = "abc123" + supervisor_client.backups.partial_backup.return_value.job_id = TEST_JOB_ID supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS_5 + supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE method_mock = getattr(supervisor_client.backups, method) method_mock.side_effect = exception @@ -1282,7 +1368,7 @@ async def test_reader_writer_create_download_remove_error( response = await client.receive_json() assert response["success"] - assert response["result"] == {"backup_job_id": "abc123"} + assert response["result"] == {"backup_job_id": TEST_JOB_ID} assert supervisor_client.backups.partial_backup.call_count == 1 @@ -1291,7 +1377,7 @@ async def test_reader_writer_create_download_remove_error( "type": "supervisor/event", "data": { "event": "job", - "data": {"done": True, "uuid": "abc123", "reference": "test_slug"}, + "data": {"done": True, "uuid": TEST_JOB_ID, "reference": "test_slug"}, }, } ) @@ -1334,8 +1420,9 @@ async def test_reader_writer_create_info_error( ) -> None: """Test backup info error when generating a backup.""" client = await hass_ws_client(hass) - supervisor_client.backups.partial_backup.return_value.job_id = "abc123" + supervisor_client.backups.partial_backup.return_value.job_id = TEST_JOB_ID supervisor_client.backups.backup_info.side_effect = exception + supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE remote_agent = BackupAgentTest("remote") await _setup_backup_platform( @@ -1366,7 +1453,7 @@ async def test_reader_writer_create_info_error( response = await client.receive_json() assert response["success"] - assert response["result"] == {"backup_job_id": "abc123"} + assert response["result"] == {"backup_job_id": TEST_JOB_ID} assert supervisor_client.backups.partial_backup.call_count == 1 @@ -1375,7 +1462,7 @@ async def test_reader_writer_create_info_error( "type": "supervisor/event", "data": { "event": "job", - "data": {"done": True, "uuid": "abc123", "reference": "test_slug"}, + "data": {"done": True, "uuid": TEST_JOB_ID, "reference": "test_slug"}, }, } ) @@ -1408,8 +1495,9 @@ async def test_reader_writer_create_remote_backup( ) -> None: """Test generating a backup which will be uploaded to a remote agent.""" client = await hass_ws_client(hass) - supervisor_client.backups.partial_backup.return_value.job_id = "abc123" + supervisor_client.backups.partial_backup.return_value.job_id = TEST_JOB_ID supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS_5 + supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE remote_agent = BackupAgentTest("remote") await _setup_backup_platform( @@ -1440,7 +1528,7 @@ async def test_reader_writer_create_remote_backup( response = await client.receive_json() assert response["success"] - assert response["result"] == {"backup_job_id": "abc123"} + assert response["result"] == {"backup_job_id": TEST_JOB_ID} supervisor_client.backups.partial_backup.assert_called_once_with( replace(DEFAULT_BACKUP_OPTIONS, location=[LOCATION_CLOUD_BACKUP]), @@ -1451,7 +1539,7 @@ async def test_reader_writer_create_remote_backup( "type": "supervisor/event", "data": { "event": "job", - "data": {"done": True, "uuid": "abc123", "reference": "test_slug"}, + "data": {"done": True, "uuid": TEST_JOB_ID, "reference": "test_slug"}, }, } ) @@ -1510,7 +1598,7 @@ async def test_reader_writer_create_wrong_parameters( ) -> None: """Test generating a backup.""" client = await hass_ws_client(hass) - supervisor_client.backups.partial_backup.return_value.job_id = "abc123" + supervisor_client.backups.partial_backup.return_value.job_id = TEST_JOB_ID supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS await client.send_json_auto_id({"type": "backup/subscribe_events"}) @@ -1614,17 +1702,33 @@ async def test_agent_receive_remote_backup( ) +@pytest.mark.parametrize( + ("get_job_result", "supervisor_events"), + [ + ( + TEST_JOB_NOT_DONE, + [{"event": "job", "data": {"done": True, "uuid": TEST_JOB_ID}}], + ), + ( + TEST_JOB_DONE, + [], + ), + ], +) @pytest.mark.usefixtures("hassio_client", "setup_integration") async def test_reader_writer_restore( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, supervisor_client: AsyncMock, + get_job_result: supervisor_jobs.Job, + supervisor_events: list[dict[str, Any]], ) -> None: """Test restoring a backup.""" client = await hass_ws_client(hass) - supervisor_client.backups.partial_restore.return_value.job_id = "abc123" + supervisor_client.backups.partial_restore.return_value.job_id = TEST_JOB_ID supervisor_client.backups.list.return_value = [TEST_BACKUP] supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS + supervisor_client.jobs.get_job.return_value = get_job_result await client.send_json_auto_id({"type": "backup/subscribe_events"}) response = await client.receive_json() @@ -1657,17 +1761,10 @@ async def test_reader_writer_restore( ), ) - await client.send_json_auto_id( - { - "type": "supervisor/event", - "data": { - "event": "job", - "data": {"done": True, "uuid": "abc123"}, - }, - } - ) - response = await client.receive_json() - assert response["success"] + for event in supervisor_events: + await client.send_json_auto_id({"type": "supervisor/event", "data": event}) + response = await client.receive_json() + assert response["success"] response = await client.receive_json() assert response["event"] == { @@ -1818,21 +1915,9 @@ async def test_restore_progress_after_restart( ) -> None: """Test restore backup progress after restart.""" - supervisor_client.jobs.get_job.return_value = supervisor_jobs.Job( - name="backup_manager_partial_backup", - reference="1ef41507", - uuid=UUID("d17bd02be1f0437fa7264b16d38f700e"), - progress=0.0, - stage="copy_additional_locations", - done=True, - errors=[], - child_jobs=[], - ) + supervisor_client.jobs.get_job.return_value = TEST_JOB_DONE - with patch.dict( - os.environ, - MOCK_ENVIRON | {RESTORE_JOB_ID_ENV: "d17bd02be1f0437fa7264b16d38f700e"}, - ): + with patch.dict(os.environ, MOCK_ENVIRON | {RESTORE_JOB_ID_ENV: TEST_JOB_ID}): assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}}) client = await hass_ws_client(hass) @@ -1860,10 +1945,7 @@ async def test_restore_progress_after_restart_unknown_job( supervisor_client.jobs.get_job.side_effect = SupervisorError - with patch.dict( - os.environ, - MOCK_ENVIRON | {RESTORE_JOB_ID_ENV: "d17bd02be1f0437fa7264b16d38f700e"}, - ): + with patch.dict(os.environ, MOCK_ENVIRON | {RESTORE_JOB_ID_ENV: TEST_JOB_ID}): assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}}) client = await hass_ws_client(hass) From 5e646a3cb69747b85ebc46f0a8fdd7537902ea5c Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 30 Jan 2025 11:46:27 +0100 Subject: [PATCH 0305/3148] Add missing discovery string from onewire (#136892) --- homeassistant/components/onewire/config_flow.py | 1 + homeassistant/components/onewire/strings.json | 3 +++ 2 files changed, 4 insertions(+) diff --git a/homeassistant/components/onewire/config_flow.py b/homeassistant/components/onewire/config_flow.py index e40e99d0903..8a5623772f7 100644 --- a/homeassistant/components/onewire/config_flow.py +++ b/homeassistant/components/onewire/config_flow.py @@ -147,6 +147,7 @@ class OneWireFlowHandler(ConfigFlow, domain=DOMAIN): return self.async_show_form( step_id="discovery_confirm", + description_placeholders={"host": self._discovery_data[CONF_HOST]}, errors=errors, ) diff --git a/homeassistant/components/onewire/strings.json b/homeassistant/components/onewire/strings.json index 9613a927f8d..8f46369a70b 100644 --- a/homeassistant/components/onewire/strings.json +++ b/homeassistant/components/onewire/strings.json @@ -8,6 +8,9 @@ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" }, "step": { + "discovery_confirm": { + "description": "Do you want to set up OWServer from {host}?" + }, "reconfigure": { "data": { "host": "[%key:common::config_flow::data::host%]", From aed779172d90c55a4435558a4678fff393eeddb8 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 30 Jan 2025 11:09:07 +0100 Subject: [PATCH 0306/3148] Ignore dangling symlinks when restoring backup (#136893) --- homeassistant/backup_restore.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/backup_restore.py b/homeassistant/backup_restore.py index 9287aa2bf1b..4d309469017 100644 --- a/homeassistant/backup_restore.py +++ b/homeassistant/backup_restore.py @@ -146,6 +146,7 @@ def _extract_backup( config_dir, dirs_exist_ok=True, ignore=shutil.ignore_patterns(*(keep)), + ignore_dangling_symlinks=True, ) elif restore_content.restore_database: for entry in KEEP_DATABASE: From b300fb1fabc6163e62847c71afe6172f52cc48ce Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 30 Jan 2025 15:25:16 +0100 Subject: [PATCH 0307/3148] Fix handling of renamed backup files in the core writer (#136898) * Fix handling of renamed backup files in the core writer * Adjust mocking * Raise BackupAgentError instead of KeyError in get_backup_path * Add specific error indicating backup not found * Fix tests * Ensure backups are loaded * Fix tests --- homeassistant/components/backup/agent.py | 13 ++- homeassistant/components/backup/backup.py | 41 ++++++--- homeassistant/components/backup/manager.py | 32 +++---- tests/components/backup/common.py | 17 +++- tests/components/backup/conftest.py | 10 ++- .../backup/snapshots/test_backup.ambr | 33 +++++-- tests/components/backup/test_backup.py | 50 ++++++++--- tests/components/backup/test_http.py | 29 +++--- tests/components/backup/test_manager.py | 90 +++++++++++++++---- 9 files changed, 234 insertions(+), 81 deletions(-) diff --git a/homeassistant/components/backup/agent.py b/homeassistant/components/backup/agent.py index 33656b6edcc..297ccd6f685 100644 --- a/homeassistant/components/backup/agent.py +++ b/homeassistant/components/backup/agent.py @@ -27,6 +27,12 @@ class BackupAgentUnreachableError(BackupAgentError): _message = "The backup agent is unreachable." +class BackupNotFound(BackupAgentError): + """Raised when a backup is not found.""" + + error_code = "backup_not_found" + + class BackupAgent(abc.ABC): """Backup agent interface.""" @@ -94,11 +100,16 @@ class LocalBackupAgent(BackupAgent): @abc.abstractmethod def get_backup_path(self, backup_id: str) -> Path: - """Return the local path to a backup. + """Return the local path to an existing backup. The method should return the path to the backup file with the specified id. + Raises BackupAgentError if the backup does not exist. """ + @abc.abstractmethod + def get_new_backup_path(self, backup: AgentBackup) -> Path: + """Return the local path to a new backup.""" + class BackupAgentPlatformProtocol(Protocol): """Define the format of backup platforms which implement backup agents.""" diff --git a/homeassistant/components/backup/backup.py b/homeassistant/components/backup/backup.py index 3f60bd0b88e..c76b50b5935 100644 --- a/homeassistant/components/backup/backup.py +++ b/homeassistant/components/backup/backup.py @@ -11,7 +11,7 @@ from typing import Any from homeassistant.core import HomeAssistant from homeassistant.helpers.hassio import is_hassio -from .agent import BackupAgent, LocalBackupAgent +from .agent import BackupAgent, BackupNotFound, LocalBackupAgent from .const import DOMAIN, LOGGER from .models import AgentBackup from .util import read_backup @@ -39,7 +39,7 @@ class CoreLocalBackupAgent(LocalBackupAgent): super().__init__() self._hass = hass self._backup_dir = Path(hass.config.path("backups")) - self._backups: dict[str, AgentBackup] = {} + self._backups: dict[str, tuple[AgentBackup, Path]] = {} self._loaded_backups = False async def _load_backups(self) -> None: @@ -49,13 +49,13 @@ class CoreLocalBackupAgent(LocalBackupAgent): self._backups = backups self._loaded_backups = True - def _read_backups(self) -> dict[str, AgentBackup]: + def _read_backups(self) -> dict[str, tuple[AgentBackup, Path]]: """Read backups from disk.""" - backups: dict[str, AgentBackup] = {} + backups: dict[str, tuple[AgentBackup, Path]] = {} for backup_path in self._backup_dir.glob("*.tar"): try: backup = read_backup(backup_path) - backups[backup.backup_id] = backup + backups[backup.backup_id] = (backup, backup_path) except (OSError, TarError, json.JSONDecodeError, KeyError) as err: LOGGER.warning("Unable to read backup %s: %s", backup_path, err) return backups @@ -76,13 +76,13 @@ class CoreLocalBackupAgent(LocalBackupAgent): **kwargs: Any, ) -> None: """Upload a backup.""" - self._backups[backup.backup_id] = backup + self._backups[backup.backup_id] = (backup, self.get_new_backup_path(backup)) async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]: """List backups.""" if not self._loaded_backups: await self._load_backups() - return list(self._backups.values()) + return [backup for backup, _ in self._backups.values()] async def async_get_backup( self, @@ -93,10 +93,10 @@ class CoreLocalBackupAgent(LocalBackupAgent): if not self._loaded_backups: await self._load_backups() - if not (backup := self._backups.get(backup_id)): + if backup_id not in self._backups: return None - backup_path = self.get_backup_path(backup_id) + backup, backup_path = self._backups[backup_id] if not await self._hass.async_add_executor_job(backup_path.exists): LOGGER.debug( ( @@ -112,15 +112,28 @@ class CoreLocalBackupAgent(LocalBackupAgent): return backup def get_backup_path(self, backup_id: str) -> Path: - """Return the local path to a backup.""" - return self._backup_dir / f"{backup_id}.tar" + """Return the local path to an existing backup. + + Raises BackupAgentError if the backup does not exist. + """ + try: + return self._backups[backup_id][1] + except KeyError as err: + raise BackupNotFound(f"Backup {backup_id} does not exist") from err + + def get_new_backup_path(self, backup: AgentBackup) -> Path: + """Return the local path to a new backup.""" + return self._backup_dir / f"{backup.backup_id}.tar" async def async_delete_backup(self, backup_id: str, **kwargs: Any) -> None: """Delete a backup file.""" - if await self.async_get_backup(backup_id) is None: - return + if not self._loaded_backups: + await self._load_backups() - backup_path = self.get_backup_path(backup_id) + try: + backup_path = self.get_backup_path(backup_id) + except BackupNotFound: + return await self._hass.async_add_executor_job(backup_path.unlink, True) LOGGER.debug("Deleted backup located at %s", backup_path) self._backups.pop(backup_id) diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index fc56505e343..d1f27fa270b 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -1346,10 +1346,24 @@ class CoreBackupReaderWriter(BackupReaderWriter): if agent_config and not agent_config.protected: password = None + backup = AgentBackup( + addons=[], + backup_id=backup_id, + database_included=include_database, + date=date_str, + extra_metadata=extra_metadata, + folders=[], + homeassistant_included=True, + homeassistant_version=HAVERSION, + name=backup_name, + protected=password is not None, + size=0, + ) + local_agent_tar_file_path = None if self._local_agent_id in agent_ids: local_agent = manager.local_backup_agents[self._local_agent_id] - local_agent_tar_file_path = local_agent.get_backup_path(backup_id) + local_agent_tar_file_path = local_agent.get_new_backup_path(backup) on_progress( CreateBackupEvent( @@ -1391,19 +1405,7 @@ class CoreBackupReaderWriter(BackupReaderWriter): # ValueError from json_bytes raise BackupReaderWriterError(str(err)) from err else: - backup = AgentBackup( - addons=[], - backup_id=backup_id, - database_included=include_database, - date=date_str, - extra_metadata=extra_metadata, - folders=[], - homeassistant_included=True, - homeassistant_version=HAVERSION, - name=backup_name, - protected=password is not None, - size=size_in_bytes, - ) + backup = replace(backup, size=size_in_bytes) async_add_executor_job = self._hass.async_add_executor_job @@ -1517,7 +1519,7 @@ class CoreBackupReaderWriter(BackupReaderWriter): manager = self._hass.data[DATA_MANAGER] if self._local_agent_id in agent_ids: local_agent = manager.local_backup_agents[self._local_agent_id] - tar_file_path = local_agent.get_backup_path(backup.backup_id) + tar_file_path = local_agent.get_new_backup_path(backup) await async_add_executor_job(make_backup_dir, tar_file_path.parent) await async_add_executor_job(shutil.move, temp_file, tar_file_path) else: diff --git a/tests/components/backup/common.py b/tests/components/backup/common.py index 97236ee995d..a7888dbd08c 100644 --- a/tests/components/backup/common.py +++ b/tests/components/backup/common.py @@ -2,7 +2,7 @@ from __future__ import annotations -from collections.abc import AsyncIterator, Callable, Coroutine +from collections.abc import AsyncIterator, Callable, Coroutine, Iterable from pathlib import Path from typing import Any from unittest.mock import ANY, AsyncMock, Mock, patch @@ -52,10 +52,17 @@ TEST_BACKUP_DEF456 = AgentBackup( protected=False, size=1, ) +TEST_BACKUP_PATH_DEF456 = Path("custom_def456.tar") TEST_DOMAIN = "test" +async def aiter_from_iter(iterable: Iterable) -> AsyncIterator: + """Convert an iterable to an async iterator.""" + for i in iterable: + yield i + + class BackupAgentTest(BackupAgent): """Test backup agent.""" @@ -162,7 +169,13 @@ async def setup_backup_integration( if with_hassio and agent_id == LOCAL_AGENT_ID: continue agent = hass.data[DATA_MANAGER].backup_agents[agent_id] - agent._backups = {backups.backup_id: backups for backups in agent_backups} + + async def open_stream() -> AsyncIterator[bytes]: + """Open a stream.""" + return aiter_from_iter((b"backup data",)) + + for backup in agent_backups: + await agent.async_upload_backup(open_stream=open_stream, backup=backup) if agent_id == LOCAL_AGENT_ID: agent._loaded_backups = True diff --git a/tests/components/backup/conftest.py b/tests/components/backup/conftest.py index bef48498ede..d0d9ac7e0e1 100644 --- a/tests/components/backup/conftest.py +++ b/tests/components/backup/conftest.py @@ -13,7 +13,7 @@ from homeassistant.components.backup import DOMAIN from homeassistant.components.backup.manager import NewBackup, WrittenBackup from homeassistant.core import HomeAssistant -from .common import TEST_BACKUP_PATH_ABC123 +from .common import TEST_BACKUP_PATH_ABC123, TEST_BACKUP_PATH_DEF456 from tests.common import get_fixture_path @@ -38,10 +38,14 @@ def mocked_tarfile_fixture() -> Generator[Mock]: @pytest.fixture(name="path_glob") -def path_glob_fixture() -> Generator[MagicMock]: +def path_glob_fixture(hass: HomeAssistant) -> Generator[MagicMock]: """Mock path glob.""" with patch( - "pathlib.Path.glob", return_value=[TEST_BACKUP_PATH_ABC123] + "pathlib.Path.glob", + return_value=[ + Path(hass.config.path()) / "backups" / TEST_BACKUP_PATH_ABC123, + Path(hass.config.path()) / "backups" / TEST_BACKUP_PATH_DEF456, + ], ) as path_glob: yield path_glob diff --git a/tests/components/backup/snapshots/test_backup.ambr b/tests/components/backup/snapshots/test_backup.ambr index 032eb7ac537..68b00632a6b 100644 --- a/tests/components/backup/snapshots/test_backup.ambr +++ b/tests/components/backup/snapshots/test_backup.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_delete_backup[found_backups0-True-1] +# name: test_delete_backup[found_backups0-abc123-1-unlink_path0] dict({ 'id': 1, 'result': dict({ @@ -10,7 +10,7 @@ 'type': 'result', }) # --- -# name: test_delete_backup[found_backups1-False-0] +# name: test_delete_backup[found_backups1-def456-1-unlink_path1] dict({ 'id': 1, 'result': dict({ @@ -21,7 +21,7 @@ 'type': 'result', }) # --- -# name: test_delete_backup[found_backups2-True-0] +# name: test_delete_backup[found_backups2-abc123-0-None] dict({ 'id': 1, 'result': dict({ @@ -32,7 +32,7 @@ 'type': 'result', }) # --- -# name: test_load_backups[None] +# name: test_load_backups[mock_read_backup] dict({ 'id': 1, 'result': dict({ @@ -47,7 +47,7 @@ 'type': 'result', }) # --- -# name: test_load_backups[None].1 +# name: test_load_backups[mock_read_backup].1 dict({ 'id': 2, 'result': dict({ @@ -82,6 +82,29 @@ 'name': 'Test', 'with_automatic_settings': True, }), + dict({ + 'addons': list([ + ]), + 'agents': dict({ + 'backup.local': dict({ + 'protected': False, + 'size': 1, + }), + }), + 'backup_id': 'def456', + 'database_included': False, + 'date': '1980-01-01T00:00:00.000Z', + 'failed_agent_ids': list([ + ]), + 'folders': list([ + 'media', + 'share', + ]), + 'homeassistant_included': True, + 'homeassistant_version': '2024.12.0', + 'name': 'Test 2', + 'with_automatic_settings': None, + }), ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, diff --git a/tests/components/backup/test_backup.py b/tests/components/backup/test_backup.py index 02252ef6fa5..ce34c51c105 100644 --- a/tests/components/backup/test_backup.py +++ b/tests/components/backup/test_backup.py @@ -12,21 +12,35 @@ from unittest.mock import MagicMock, mock_open, patch import pytest from syrupy import SnapshotAssertion -from homeassistant.components.backup import DOMAIN +from homeassistant.components.backup import DOMAIN, AgentBackup from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from .common import TEST_BACKUP_ABC123, TEST_BACKUP_PATH_ABC123 +from .common import ( + TEST_BACKUP_ABC123, + TEST_BACKUP_DEF456, + TEST_BACKUP_PATH_ABC123, + TEST_BACKUP_PATH_DEF456, +) from tests.typing import ClientSessionGenerator, WebSocketGenerator +def mock_read_backup(backup_path: Path) -> AgentBackup: + """Mock read backup.""" + mock_backups = { + "abc123": TEST_BACKUP_ABC123, + "custom_def456": TEST_BACKUP_DEF456, + } + return mock_backups[backup_path.stem] + + @pytest.fixture(name="read_backup") def read_backup_fixture(path_glob: MagicMock) -> Generator[MagicMock]: """Mock read backup.""" with patch( "homeassistant.components.backup.backup.read_backup", - return_value=TEST_BACKUP_ABC123, + side_effect=mock_read_backup, ) as read_backup: yield read_backup @@ -34,7 +48,7 @@ def read_backup_fixture(path_glob: MagicMock) -> Generator[MagicMock]: @pytest.mark.parametrize( "side_effect", [ - None, + mock_read_backup, OSError("Boom"), TarError("Boom"), json.JSONDecodeError("Boom", "test", 1), @@ -94,11 +108,21 @@ async def test_upload( @pytest.mark.usefixtures("read_backup") @pytest.mark.parametrize( - ("found_backups", "backup_exists", "unlink_calls"), + ("found_backups", "backup_id", "unlink_calls", "unlink_path"), [ - ([TEST_BACKUP_PATH_ABC123], True, 1), - ([TEST_BACKUP_PATH_ABC123], False, 0), - (([], True, 0)), + ( + [TEST_BACKUP_PATH_ABC123, TEST_BACKUP_PATH_DEF456], + TEST_BACKUP_ABC123.backup_id, + 1, + TEST_BACKUP_PATH_ABC123, + ), + ( + [TEST_BACKUP_PATH_ABC123, TEST_BACKUP_PATH_DEF456], + TEST_BACKUP_DEF456.backup_id, + 1, + TEST_BACKUP_PATH_DEF456, + ), + (([], TEST_BACKUP_ABC123.backup_id, 0, None)), ], ) async def test_delete_backup( @@ -108,8 +132,9 @@ async def test_delete_backup( snapshot: SnapshotAssertion, path_glob: MagicMock, found_backups: list[Path], - backup_exists: bool, + backup_id: str, unlink_calls: int, + unlink_path: Path | None, ) -> None: """Test delete backup.""" assert await async_setup_component(hass, DOMAIN, {}) @@ -118,12 +143,13 @@ async def test_delete_backup( path_glob.return_value = found_backups with ( - patch("pathlib.Path.exists", return_value=backup_exists), - patch("pathlib.Path.unlink") as unlink, + patch("pathlib.Path.unlink", autospec=True) as unlink, ): await client.send_json_auto_id( - {"type": "backup/delete", "backup_id": TEST_BACKUP_ABC123.backup_id} + {"type": "backup/delete", "backup_id": backup_id} ) assert await client.receive_json() == snapshot assert unlink.call_count == unlink_calls + for call in unlink.mock_calls: + assert call.args[0] == unlink_path diff --git a/tests/components/backup/test_http.py b/tests/components/backup/test_http.py index ee6803655d5..aac39c04d31 100644 --- a/tests/components/backup/test_http.py +++ b/tests/components/backup/test_http.py @@ -1,7 +1,7 @@ """Tests for the Backup integration.""" import asyncio -from collections.abc import AsyncIterator, Iterable +from collections.abc import AsyncIterator from io import BytesIO, StringIO import json import tarfile @@ -15,7 +15,12 @@ from homeassistant.components.backup import AddonInfo, AgentBackup, Folder from homeassistant.components.backup.const import DATA_MANAGER, DOMAIN from homeassistant.core import HomeAssistant -from .common import TEST_BACKUP_ABC123, BackupAgentTest, setup_backup_integration +from .common import ( + TEST_BACKUP_ABC123, + BackupAgentTest, + aiter_from_iter, + setup_backup_integration, +) from tests.common import MockUser, get_fixture_path from tests.typing import ClientSessionGenerator @@ -35,6 +40,9 @@ async def test_downloading_local_backup( "homeassistant.components.backup.backup.CoreLocalBackupAgent.async_get_backup", return_value=TEST_BACKUP_ABC123, ), + patch( + "homeassistant.components.backup.backup.CoreLocalBackupAgent.get_backup_path", + ), patch("pathlib.Path.exists", return_value=True), patch( "homeassistant.components.backup.http.FileResponse", @@ -73,9 +81,14 @@ async def test_downloading_local_encrypted_backup_file_not_found( await setup_backup_integration(hass) client = await hass_client() - with patch( - "homeassistant.components.backup.backup.CoreLocalBackupAgent.async_get_backup", - return_value=TEST_BACKUP_ABC123, + with ( + patch( + "homeassistant.components.backup.backup.CoreLocalBackupAgent.async_get_backup", + return_value=TEST_BACKUP_ABC123, + ), + patch( + "homeassistant.components.backup.backup.CoreLocalBackupAgent.get_backup_path", + ), ): resp = await client.get( "/api/backup/download/abc123?agent_id=backup.local&password=blah" @@ -93,12 +106,6 @@ async def test_downloading_local_encrypted_backup( await _test_downloading_encrypted_backup(hass_client, "backup.local") -async def aiter_from_iter(iterable: Iterable) -> AsyncIterator: - """Convert an iterable to an async iterator.""" - for i in iterable: - yield i - - @patch.object(BackupAgentTest, "async_download_backup") async def test_downloading_remote_encrypted_backup( download_mock, diff --git a/tests/components/backup/test_manager.py b/tests/components/backup/test_manager.py index 5e5b0df74cd..69994028297 100644 --- a/tests/components/backup/test_manager.py +++ b/tests/components/backup/test_manager.py @@ -54,6 +54,8 @@ from .common import ( LOCAL_AGENT_ID, TEST_BACKUP_ABC123, TEST_BACKUP_DEF456, + TEST_BACKUP_PATH_ABC123, + TEST_BACKUP_PATH_DEF456, BackupAgentTest, setup_backup_platform, ) @@ -89,6 +91,15 @@ def generate_backup_id_fixture() -> Generator[MagicMock]: yield mock +def mock_read_backup(backup_path: Path) -> AgentBackup: + """Mock read backup.""" + mock_backups = { + "abc123": TEST_BACKUP_ABC123, + "custom_def456": TEST_BACKUP_DEF456, + } + return mock_backups[backup_path.stem] + + @pytest.mark.usefixtures("mock_backup_generation") async def test_create_backup_service( hass: HomeAssistant, @@ -1311,7 +1322,11 @@ class LocalBackupAgentTest(BackupAgentTest, LocalBackupAgent): """Local backup agent.""" def get_backup_path(self, backup_id: str) -> Path: - """Return the local path to a backup.""" + """Return the local path to an existing backup.""" + return Path("test.tar") + + def get_new_backup_path(self, backup: AgentBackup) -> Path: + """Return the local path to a new backup.""" return Path("test.tar") @@ -2023,10 +2038,6 @@ async def test_receive_backup_file_write_error( with ( patch("pathlib.Path.open", open_mock), - patch( - "homeassistant.components.backup.manager.read_backup", - return_value=TEST_BACKUP_ABC123, - ), ): resp = await client.post( "/api/backup/upload?agent_id=test.remote", @@ -2375,18 +2386,61 @@ async def test_receive_backup_file_read_error( @pytest.mark.usefixtures("path_glob") @pytest.mark.parametrize( - ("agent_id", "password_param", "restore_database", "restore_homeassistant", "dir"), + ( + "agent_id", + "backup_id", + "password_param", + "backup_path", + "restore_database", + "restore_homeassistant", + "dir", + ), [ - (LOCAL_AGENT_ID, {}, True, False, "backups"), - (LOCAL_AGENT_ID, {"password": "abc123"}, False, True, "backups"), - ("test.remote", {}, True, True, "tmp_backups"), + ( + LOCAL_AGENT_ID, + TEST_BACKUP_ABC123.backup_id, + {}, + TEST_BACKUP_PATH_ABC123, + True, + False, + "backups", + ), + ( + LOCAL_AGENT_ID, + TEST_BACKUP_DEF456.backup_id, + {}, + TEST_BACKUP_PATH_DEF456, + True, + False, + "backups", + ), + ( + LOCAL_AGENT_ID, + TEST_BACKUP_ABC123.backup_id, + {"password": "abc123"}, + TEST_BACKUP_PATH_ABC123, + False, + True, + "backups", + ), + ( + "test.remote", + TEST_BACKUP_ABC123.backup_id, + {}, + TEST_BACKUP_PATH_ABC123, + True, + True, + "tmp_backups", + ), ], ) async def test_restore_backup( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, agent_id: str, + backup_id: str, password_param: dict[str, str], + backup_path: Path, restore_database: bool, restore_homeassistant: bool, dir: str, @@ -2426,14 +2480,14 @@ async def test_restore_backup( patch.object(remote_agent, "async_download_backup") as download_mock, patch( "homeassistant.components.backup.backup.read_backup", - return_value=TEST_BACKUP_ABC123, + side_effect=mock_read_backup, ), ): download_mock.return_value.__aiter__.return_value = iter((b"backup data",)) await ws_client.send_json_auto_id( { "type": "backup/restore", - "backup_id": TEST_BACKUP_ABC123.backup_id, + "backup_id": backup_id, "agent_id": agent_id, "restore_database": restore_database, "restore_homeassistant": restore_homeassistant, @@ -2473,17 +2527,17 @@ async def test_restore_backup( result = await ws_client.receive_json() assert result["success"] is True - backup_path = f"{hass.config.path()}/{dir}/abc123.tar" + full_backup_path = f"{hass.config.path()}/{dir}/{backup_path.name}" expected_restore_file = json.dumps( { - "path": backup_path, + "path": full_backup_path, "password": password, "remove_after_restore": agent_id != LOCAL_AGENT_ID, "restore_database": restore_database, "restore_homeassistant": restore_homeassistant, } ) - validate_password_mock.assert_called_once_with(Path(backup_path), password) + validate_password_mock.assert_called_once_with(Path(full_backup_path), password) assert mocked_write_text.call_args[0][0] == expected_restore_file assert mocked_service_call.called @@ -2533,7 +2587,7 @@ async def test_restore_backup_wrong_password( patch.object(remote_agent, "async_download_backup") as download_mock, patch( "homeassistant.components.backup.backup.read_backup", - return_value=TEST_BACKUP_ABC123, + side_effect=mock_read_backup, ), ): download_mock.return_value.__aiter__.return_value = iter((b"backup data",)) @@ -2581,8 +2635,8 @@ async def test_restore_backup_wrong_password( ("parameters", "expected_error", "expected_reason"), [ ( - {"backup_id": TEST_BACKUP_DEF456.backup_id}, - f"Backup def456 not found in agent {LOCAL_AGENT_ID}", + {"backup_id": "no_such_backup"}, + f"Backup no_such_backup not found in agent {LOCAL_AGENT_ID}", "backup_manager_error", ), ( @@ -2629,7 +2683,7 @@ async def test_restore_backup_wrong_parameters( patch("homeassistant.core.ServiceRegistry.async_call") as mocked_service_call, patch( "homeassistant.components.backup.backup.read_backup", - return_value=TEST_BACKUP_ABC123, + side_effect=mock_read_backup, ), ): await ws_client.send_json_auto_id( From fad3d5d29324ed2ef7c2fc28ae1cd99abeaa36ac Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 30 Jan 2025 17:32:01 +0100 Subject: [PATCH 0308/3148] Don't blow up when a backup doesn't exist on supervisor (#136907) --- homeassistant/components/hassio/backup.py | 9 +-- tests/components/hassio/test_backup.py | 96 ++++++++++++++++++++--- 2 files changed, 91 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/hassio/backup.py b/homeassistant/components/hassio/backup.py index b81605264be..b9439183d8c 100644 --- a/homeassistant/components/hassio/backup.py +++ b/homeassistant/components/hassio/backup.py @@ -198,7 +198,10 @@ class SupervisorBackupAgent(BackupAgent): **kwargs: Any, ) -> AgentBackup | None: """Return a backup.""" - details = await self._client.backups.backup_info(backup_id) + try: + details = await self._client.backups.backup_info(backup_id) + except SupervisorNotFoundError: + return None if self.location not in details.locations: return None return _backup_details_to_agent_backup(details, self.location) @@ -212,10 +215,6 @@ class SupervisorBackupAgent(BackupAgent): location={self.location} ), ) - except SupervisorBadRequestError as err: - if err.args[0] != "Backup does not exist": - raise - _LOGGER.debug("Backup %s does not exist", backup_id) except SupervisorNotFoundError: _LOGGER.debug("Backup %s does not exist", backup_id) diff --git a/tests/components/hassio/test_backup.py b/tests/components/hassio/test_backup.py index f7379b81a14..9ba73ade1a3 100644 --- a/tests/components/hassio/test_backup.py +++ b/tests/components/hassio/test_backup.py @@ -544,7 +544,7 @@ async def test_agent_download( hass_client: ClientSessionGenerator, supervisor_client: AsyncMock, ) -> None: - """Test agent download backup, when cloud user is logged in.""" + """Test agent download backup.""" client = await hass_client() backup_id = "abc123" supervisor_client.backups.list.return_value = [TEST_BACKUP] @@ -568,7 +568,7 @@ async def test_agent_download_unavailable_backup( hass_client: ClientSessionGenerator, supervisor_client: AsyncMock, ) -> None: - """Test agent download backup, when cloud user is logged in.""" + """Test agent download backup which does not exist.""" client = await hass_client() backup_id = "abc123" supervisor_client.backups.list.return_value = [TEST_BACKUP_3] @@ -630,6 +630,91 @@ async def test_agent_upload( supervisor_client.backups.remove_backup.assert_not_called() +@pytest.mark.usefixtures("hassio_client", "setup_integration") +async def test_agent_get_backup( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + supervisor_client: AsyncMock, +) -> None: + """Test agent get backup.""" + client = await hass_ws_client(hass) + supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS + backup_id = "abc123" + + await client.send_json_auto_id( + { + "type": "backup/details", + "backup_id": backup_id, + } + ) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == { + "agent_errors": {}, + "backup": { + "addons": [ + {"name": "Terminal & SSH", "slug": "core_ssh", "version": "9.14.0"} + ], + "agents": {"hassio.local": {"protected": False, "size": 1048576}}, + "backup_id": "abc123", + "database_included": True, + "date": "1970-01-01T00:00:00+00:00", + "failed_agent_ids": [], + "folders": ["share"], + "homeassistant_included": True, + "homeassistant_version": "2024.12.0", + "name": "Test", + "with_automatic_settings": None, + }, + } + supervisor_client.backups.backup_info.assert_called_once_with(backup_id) + + +@pytest.mark.usefixtures("hassio_client", "setup_integration") +@pytest.mark.parametrize( + ("backup_info_side_effect", "expected_response"), + [ + ( + SupervisorBadRequestError("blah"), + { + "success": False, + "error": {"code": "unknown_error", "message": "Unknown error"}, + }, + ), + ( + SupervisorNotFoundError(), + { + "success": True, + "result": {"agent_errors": {}, "backup": None}, + }, + ), + ], +) +async def test_agent_get_backup_with_error( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + supervisor_client: AsyncMock, + backup_info_side_effect: Exception, + expected_response: dict[str, Any], +) -> None: + """Test agent get backup.""" + client = await hass_ws_client(hass) + backup_id = "abc123" + + supervisor_client.backups.backup_info.side_effect = backup_info_side_effect + await client.send_json_auto_id( + { + "type": "backup/details", + "backup_id": backup_id, + } + ) + response = await client.receive_json() + + assert response == {"id": 1, "type": "result"} | expected_response + supervisor_client.backups.backup_info.assert_called_once_with(backup_id) + + @pytest.mark.usefixtures("hassio_client", "setup_integration") async def test_agent_delete_backup( hass: HomeAssistant, @@ -666,13 +751,6 @@ async def test_agent_delete_backup( "error": {"code": "unknown_error", "message": "Unknown error"}, }, ), - ( - SupervisorBadRequestError("Backup does not exist"), - { - "success": True, - "result": {"agent_errors": {}}, - }, - ), ( SupervisorNotFoundError(), { From 9e23ff9a4d41ffb0e094646295acb4f49a57ee47 Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Thu, 30 Jan 2025 16:33:59 +0100 Subject: [PATCH 0309/3148] Fix onedrive does not fail on delete not found (#136910) * Fix onedrive does not fail on delete not found * Fix onedrive does not fail on delete not found --- homeassistant/components/onedrive/backup.py | 8 ++++++- tests/components/onedrive/test_backup.py | 24 ++++++++++++++++++++- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/onedrive/backup.py b/homeassistant/components/onedrive/backup.py index a5a5c019797..94d60bc6398 100644 --- a/homeassistant/components/onedrive/backup.py +++ b/homeassistant/components/onedrive/backup.py @@ -190,7 +190,13 @@ class OneDriveBackupAgent(BackupAgent): **kwargs: Any, ) -> None: """Delete a backup file.""" - await self._get_backup_file_item(backup_id).delete() + + try: + await self._get_backup_file_item(backup_id).delete() + except APIError as err: + if err.response_status_code == 404: + return + raise @handle_backup_errors async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]: diff --git a/tests/components/onedrive/test_backup.py b/tests/components/onedrive/test_backup.py index a3d1129377f..3492202d3fe 100644 --- a/tests/components/onedrive/test_backup.py +++ b/tests/components/onedrive/test_backup.py @@ -156,6 +156,28 @@ async def test_agents_delete( mock_drive_items.delete.assert_called_once() +async def test_agents_delete_not_found_does_not_throw( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_drive_items: MagicMock, +) -> None: + """Test agent delete backup.""" + mock_drive_items.delete = AsyncMock(side_effect=APIError(response_status_code=404)) + client = await hass_ws_client(hass) + + await client.send_json_auto_id( + { + "type": "backup/delete", + "backup_id": BACKUP_METADATA["backup_id"], + } + ) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == {"agent_errors": {}} + mock_drive_items.delete.assert_called_once() + + async def test_agents_upload( hass_client: ClientSessionGenerator, caplog: pytest.LogCaptureFixture, @@ -257,7 +279,7 @@ async def test_agents_download( ("side_effect", "error"), [ ( - APIError(response_status_code=404, message="File not found."), + APIError(response_status_code=500), "Backup operation failed", ), (TimeoutError(), "Backup operation timed out"), From 613f0add7684b020a7bf86e6b6633083135d36d1 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Thu, 30 Jan 2025 14:16:51 +0100 Subject: [PATCH 0310/3148] Convert valve position to int for Shelly BLU TRV (#136912) --- homeassistant/components/shelly/number.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/shelly/number.py b/homeassistant/components/shelly/number.py index 7140c79fbb6..5f0567d034a 100644 --- a/homeassistant/components/shelly/number.py +++ b/homeassistant/components/shelly/number.py @@ -208,7 +208,7 @@ RPC_NUMBERS: Final = { method_params_fn=lambda idx, value: { "id": idx, "method": "Trv.SetPosition", - "params": {"id": 0, "pos": value}, + "params": {"id": 0, "pos": int(value)}, }, removal_condition=lambda config, _status, key: config[key].get("enable", True) is True, From 08bb027eac0eac853753826ec53ac9ca487c04ec Mon Sep 17 00:00:00 2001 From: tronikos Date: Thu, 30 Jan 2025 09:15:13 -0800 Subject: [PATCH 0311/3148] Don't log errors when raising a backup exception in Google Drive (#136916) --- homeassistant/components/google_drive/backup.py | 13 ++++--------- tests/components/google_drive/test_backup.py | 6 +++--- 2 files changed, 7 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/google_drive/backup.py b/homeassistant/components/google_drive/backup.py index 4c81f041c8b..73e5902f8f5 100644 --- a/homeassistant/components/google_drive/backup.py +++ b/homeassistant/components/google_drive/backup.py @@ -80,16 +80,14 @@ class GoogleDriveBackupAgent(BackupAgent): try: await self._client.async_upload_backup(open_stream, backup) except (GoogleDriveApiError, HomeAssistantError, TimeoutError) as err: - _LOGGER.error("Upload backup error: %s", err) - raise BackupAgentError("Failed to upload backup") from err + raise BackupAgentError(f"Failed to upload backup: {err}") from err async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]: """List backups.""" try: return await self._client.async_list_backups() except (GoogleDriveApiError, HomeAssistantError, TimeoutError) as err: - _LOGGER.error("List backups error: %s", err) - raise BackupAgentError("Failed to list backups") from err + raise BackupAgentError(f"Failed to list backups: {err}") from err async def async_get_backup( self, @@ -121,9 +119,7 @@ class GoogleDriveBackupAgent(BackupAgent): stream = await self._client.async_download(file_id) return ChunkAsyncStreamIterator(stream) except (GoogleDriveApiError, HomeAssistantError, TimeoutError) as err: - _LOGGER.error("Download backup error: %s", err) - raise BackupAgentError("Failed to download backup") from err - _LOGGER.error("Download backup_id: %s not found", backup_id) + raise BackupAgentError(f"Failed to download backup: {err}") from err raise BackupAgentError("Backup not found") async def async_delete_backup( @@ -143,5 +139,4 @@ class GoogleDriveBackupAgent(BackupAgent): await self._client.async_delete(file_id) _LOGGER.debug("Deleted backup_id: %s", backup_id) except (GoogleDriveApiError, HomeAssistantError, TimeoutError) as err: - _LOGGER.error("Delete backup error: %s", err) - raise BackupAgentError("Failed to delete backup") from err + raise BackupAgentError(f"Failed to delete backup: {err}") from err diff --git a/tests/components/google_drive/test_backup.py b/tests/components/google_drive/test_backup.py index 62b7930012c..7e455ebb535 100644 --- a/tests/components/google_drive/test_backup.py +++ b/tests/components/google_drive/test_backup.py @@ -141,7 +141,7 @@ async def test_agents_list_backups_fail( assert response["success"] assert response["result"]["backups"] == [] assert response["result"]["agent_errors"] == { - TEST_AGENT_ID: "Failed to list backups" + TEST_AGENT_ID: "Failed to list backups: some error" } @@ -381,7 +381,7 @@ async def test_agents_upload_fail( await hass.async_block_till_done() assert resp.status == 201 - assert "Upload backup error: some error" in caplog.text + assert "Failed to upload backup: some error" in caplog.text async def test_agents_delete( @@ -430,7 +430,7 @@ async def test_agents_delete_fail( assert response["success"] assert response["result"] == { - "agent_errors": {TEST_AGENT_ID: "Failed to delete backup"} + "agent_errors": {TEST_AGENT_ID: "Failed to delete backup: some error"} } From b70598673b02f46b71e3699e376b5ffb26824fb0 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 30 Jan 2025 18:15:54 +0100 Subject: [PATCH 0312/3148] Show name of the backup agents in issue (#136925) * Show name of the backup agents in issue * Show name of the backup agents in issue * Update homeassistant/components/backup/manager.py Co-authored-by: Martin Hjelmare --------- Co-authored-by: Martin Hjelmare --- homeassistant/components/backup/manager.py | 6 +++++- tests/components/backup/test_manager.py | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index d1f27fa270b..1dbd8f8547d 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -1166,7 +1166,11 @@ class BackupManager: learn_more_url="homeassistant://config/backup", severity=ir.IssueSeverity.WARNING, translation_key="automatic_backup_failed_upload_agents", - translation_placeholders={"failed_agents": ", ".join(agent_errors)}, + translation_placeholders={ + "failed_agents": ", ".join( + self.backup_agents[agent_id].name for agent_id in agent_errors + ) + }, ) async def async_can_decrypt_on_download( diff --git a/tests/components/backup/test_manager.py b/tests/components/backup/test_manager.py index 69994028297..4a8d2360d3f 100644 --- a/tests/components/backup/test_manager.py +++ b/tests/components/backup/test_manager.py @@ -908,7 +908,7 @@ async def delayed_boom(*args, **kwargs) -> tuple[NewBackup, Any]: { (DOMAIN, "automatic_backup_failed"): { "translation_key": "automatic_backup_failed_upload_agents", - "translation_placeholders": {"failed_agents": "test.remote"}, + "translation_placeholders": {"failed_agents": "remote"}, } }, ), From f479ed4ff04a0d724bd403c219bcaa5a475fd39d Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 30 Jan 2025 16:16:39 +0100 Subject: [PATCH 0313/3148] Fix Sonos importing deprecating constant (#136926) --- homeassistant/components/sonos/__init__.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sonos/__init__.py b/homeassistant/components/sonos/__init__.py index 98bff8d2934..d530fa21e39 100644 --- a/homeassistant/components/sonos/__init__.py +++ b/homeassistant/components/sonos/__init__.py @@ -34,7 +34,11 @@ from homeassistant.helpers import ( ) from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_call_later, async_track_time_interval -from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo +from homeassistant.helpers.service_info.ssdp import ( + ATTR_UPNP_MODEL_NAME, + ATTR_UPNP_UDN, + SsdpServiceInfo, +) from homeassistant.helpers.typing import ConfigType from homeassistant.util.async_ import create_eager_task @@ -503,7 +507,7 @@ class SonosDiscoveryManager: def _async_ssdp_discovered_player( self, info: SsdpServiceInfo, change: ssdp.SsdpChange ) -> None: - uid = info.upnp[ssdp.ATTR_UPNP_UDN] + uid = info.upnp[ATTR_UPNP_UDN] if not uid.startswith("uuid:RINCON_"): return uid = uid[5:] @@ -522,7 +526,7 @@ class SonosDiscoveryManager: cast(str, urlparse(info.ssdp_location).hostname), uid, info.ssdp_headers.get("X-RINCON-BOOTSEQ"), - cast(str, info.upnp.get(ssdp.ATTR_UPNP_MODEL_NAME)), + cast(str, info.upnp.get(ATTR_UPNP_MODEL_NAME)), None, ) From 07acabdb366bc4a79e6c7f045a24f098b3fafa1f Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 30 Jan 2025 16:16:22 +0100 Subject: [PATCH 0314/3148] Create Xbox signed session in executor (#136927) --- homeassistant/components/xbox/__init__.py | 4 +++- homeassistant/components/xbox/api.py | 6 ++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/xbox/__init__.py b/homeassistant/components/xbox/__init__.py index 5282a34903a..ab0d510a709 100644 --- a/homeassistant/components/xbox/__init__.py +++ b/homeassistant/components/xbox/__init__.py @@ -6,6 +6,7 @@ import logging from xbox.webapi.api.client import XboxLiveClient from xbox.webapi.api.provider.smartglass.models import SmartglassConsoleList +from xbox.webapi.common.signed_session import SignedSession from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform @@ -36,7 +37,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) ) session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation) - auth = api.AsyncConfigEntryAuth(session) + signed_session = await hass.async_add_executor_job(SignedSession) + auth = api.AsyncConfigEntryAuth(signed_session, session) client = XboxLiveClient(auth) consoles: SmartglassConsoleList = await client.smartglass.get_console_list() diff --git a/homeassistant/components/xbox/api.py b/homeassistant/components/xbox/api.py index d4c47e4cc39..9fa7c14b5c9 100644 --- a/homeassistant/components/xbox/api.py +++ b/homeassistant/components/xbox/api.py @@ -11,10 +11,12 @@ from homeassistant.util.dt import utc_from_timestamp class AsyncConfigEntryAuth(AuthenticationManager): """Provide xbox authentication tied to an OAuth2 based config entry.""" - def __init__(self, oauth_session: OAuth2Session) -> None: + def __init__( + self, signed_session: SignedSession, oauth_session: OAuth2Session + ) -> None: """Initialize xbox auth.""" # Leaving out client credentials as they are handled by Home Assistant - super().__init__(SignedSession(), "", "", "") + super().__init__(signed_session, "", "", "") self._oauth_session = oauth_session self.oauth = self._get_oauth_token() From 252b13e63a0e4c1f884c9a264ed950bcbab5dbd7 Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Thu, 30 Jan 2025 17:08:35 +0100 Subject: [PATCH 0315/3148] Pick onedrive owner from a more reliable source (#136929) * Pick onedrive owner from a more reliable source * fix --- homeassistant/components/onedrive/config_flow.py | 7 +++++-- tests/components/onedrive/conftest.py | 2 +- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/onedrive/config_flow.py b/homeassistant/components/onedrive/config_flow.py index 83f6dd6e2ee..09c0d1b44cc 100644 --- a/homeassistant/components/onedrive/config_flow.py +++ b/homeassistant/components/onedrive/config_flow.py @@ -78,7 +78,7 @@ class OneDriveConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN): self.logger.exception("Unknown error") return self.async_abort(reason="unknown") - drive = response.json() + drive: dict = response.json() await self.async_set_unique_id(drive["parentReference"]["driveId"]) @@ -94,7 +94,10 @@ class OneDriveConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN): self._abort_if_unique_id_configured() - title = f"{drive['shared']['owner']['user']['displayName']}'s OneDrive" + user = drive.get("createdBy", {}).get("user", {}).get("displayName") + + title = f"{user}'s OneDrive" if user else "OneDrive" + return self.async_create_entry(title=title, data=data) async def async_step_reauth( diff --git a/tests/components/onedrive/conftest.py b/tests/components/onedrive/conftest.py index 0cca8e9df0b..65142217017 100644 --- a/tests/components/onedrive/conftest.py +++ b/tests/components/onedrive/conftest.py @@ -88,7 +88,7 @@ def mock_adapter() -> Generator[MagicMock]: status_code=200, json={ "parentReference": {"driveId": "mock_drive_id"}, - "shared": {"owner": {"user": {"displayName": "John Doe"}}}, + "createdBy": {"user": {"displayName": "John Doe"}}, }, ) yield adapter From ad6c3f9e1097903c06e4df62816d5463f1dfa80b Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Thu, 30 Jan 2025 16:59:39 +0100 Subject: [PATCH 0316/3148] Fix backup related translations in Synology DSM (#136931) refernce backup related strings in option-flow strings --- homeassistant/components/synology_dsm/strings.json | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/synology_dsm/strings.json b/homeassistant/components/synology_dsm/strings.json index 3d64f908256..d6d40be3fea 100644 --- a/homeassistant/components/synology_dsm/strings.json +++ b/homeassistant/components/synology_dsm/strings.json @@ -70,7 +70,13 @@ "data": { "scan_interval": "Minutes between scans", "timeout": "Timeout (seconds)", - "snap_profile_type": "Quality level of camera snapshots (0:high 1:medium 2:low)" + "snap_profile_type": "Quality level of camera snapshots (0:high 1:medium 2:low)", + "backup_share": "[%key:component::synology_dsm::config::step::backup_share::data::backup_share%]", + "backup_path": "[%key:component::synology_dsm::config::step::backup_share::data::backup_path%]" + }, + "data_description": { + "backup_share": "[%key:component::synology_dsm::config::step::backup_share::data_description::backup_share%]", + "backup_path": "[%key:component::synology_dsm::config::step::backup_share::data_description::backup_path%]" } } } From 74f0af1ba1b01e4163f9367ee0a462dd5209497f Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Thu, 30 Jan 2025 17:43:48 +0100 Subject: [PATCH 0317/3148] Fix KeyError for Shelly virtual number component (#136932) --- homeassistant/components/shelly/number.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/shelly/number.py b/homeassistant/components/shelly/number.py index 5f0567d034a..c4420783bbb 100644 --- a/homeassistant/components/shelly/number.py +++ b/homeassistant/components/shelly/number.py @@ -186,7 +186,7 @@ RPC_NUMBERS: Final = { mode_fn=lambda config: VIRTUAL_NUMBER_MODE_MAP.get( config["meta"]["ui"]["view"], NumberMode.BOX ), - step_fn=lambda config: config["meta"]["ui"]["step"], + step_fn=lambda config: config["meta"]["ui"].get("step"), # If the unit is not set, the device sends an empty string unit=lambda config: config["meta"]["ui"]["unit"] if config["meta"]["ui"]["unit"] From 659a0df9abfbf51125a498e6927ac1c6307412d2 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Thu, 30 Jan 2025 18:38:11 +0100 Subject: [PATCH 0318/3148] Update frontend to 20250130.0 (#136937) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index f4e426485c8..b545026059c 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -21,5 +21,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20250129.0"] + "requirements": ["home-assistant-frontend==20250130.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 6d9e8f43755..01cfc57f3a8 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -37,7 +37,7 @@ habluetooth==3.14.0 hass-nabucasa==0.88.1 hassil==2.2.0 home-assistant-bluetooth==1.13.0 -home-assistant-frontend==20250129.0 +home-assistant-frontend==20250130.0 home-assistant-intents==2025.1.28 httpx==0.28.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 533a77d4981..2bf3b5f1943 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1143,7 +1143,7 @@ hole==0.8.0 holidays==0.65 # homeassistant.components.frontend -home-assistant-frontend==20250129.0 +home-assistant-frontend==20250130.0 # homeassistant.components.conversation home-assistant-intents==2025.1.28 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4491e64d808..6c5f81e6a2c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -972,7 +972,7 @@ hole==0.8.0 holidays==0.65 # homeassistant.components.frontend -home-assistant-frontend==20250129.0 +home-assistant-frontend==20250130.0 # homeassistant.components.conversation home-assistant-intents==2025.1.28 From 3847057444bcc192e0a93e3646535c1310b42e9f Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Thu, 30 Jan 2025 19:28:55 +0100 Subject: [PATCH 0319/3148] Bump version to 2025.2.0b2 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 77b223fcbcf..271226e92e2 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -25,7 +25,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2025 MINOR_VERSION: Final = 2 -PATCH_VERSION: Final = "0b1" +PATCH_VERSION: Final = "0b2" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 13, 0) diff --git a/pyproject.toml b/pyproject.toml index a592b8a194d..2e7b2dfcbc1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.2.0b1" +version = "2025.2.0b2" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From cf737356fd33bd25bb286b54cb8284eb7f0c9759 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 30 Jan 2025 12:55:14 -0600 Subject: [PATCH 0320/3148] Bump zeroconf to 0.142.0 (#136940) changelog: https://github.com/python-zeroconf/python-zeroconf/compare/0.141.0...0.142.0 --- homeassistant/components/zeroconf/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/zeroconf/manifest.json b/homeassistant/components/zeroconf/manifest.json index 6fe2b5b1923..be6f2d111d7 100644 --- a/homeassistant/components/zeroconf/manifest.json +++ b/homeassistant/components/zeroconf/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "loggers": ["zeroconf"], "quality_scale": "internal", - "requirements": ["zeroconf==0.141.0"] + "requirements": ["zeroconf==0.142.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 01cfc57f3a8..a15e1bb61be 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -73,7 +73,7 @@ voluptuous-serialize==2.6.0 voluptuous==0.15.2 webrtc-models==0.3.0 yarl==1.18.3 -zeroconf==0.141.0 +zeroconf==0.142.0 # Constrain pycryptodome to avoid vulnerability # see https://github.com/home-assistant/core/pull/16238 diff --git a/pyproject.toml b/pyproject.toml index 31aeb180b8c..edc039286d7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -82,7 +82,7 @@ dependencies = [ "voluptuous-openapi==0.0.6", "yarl==1.18.3", "webrtc-models==0.3.0", - "zeroconf==0.141.0" + "zeroconf==0.142.0" ] [project.urls] diff --git a/requirements.txt b/requirements.txt index a98d53b6037..412252a0846 100644 --- a/requirements.txt +++ b/requirements.txt @@ -51,4 +51,4 @@ voluptuous-serialize==2.6.0 voluptuous-openapi==0.0.6 yarl==1.18.3 webrtc-models==0.3.0 -zeroconf==0.141.0 +zeroconf==0.142.0 diff --git a/requirements_all.txt b/requirements_all.txt index cdc710bc3c1..02091e9ec2a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3125,7 +3125,7 @@ zamg==0.3.6 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.141.0 +zeroconf==0.142.0 # homeassistant.components.zeversolar zeversolar==0.3.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ce31cb1dbc1..11905283d4d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2514,7 +2514,7 @@ yt-dlp[default]==2025.01.26 zamg==0.3.6 # homeassistant.components.zeroconf -zeroconf==0.141.0 +zeroconf==0.142.0 # homeassistant.components.zeversolar zeversolar==0.3.2 From b12598d9633e16af9d2330b40db304dec13b2874 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 30 Jan 2025 13:38:27 -0600 Subject: [PATCH 0321/3148] Bump aiohttp-asyncmdnsresolver to 0.0.2 (#136942) --- 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 a15e1bb61be..891d91e134b 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -4,7 +4,7 @@ aiodhcpwatcher==1.0.2 aiodiscover==2.1.0 aiodns==3.2.0 aiohasupervisor==0.2.2b6 -aiohttp-asyncmdnsresolver==0.0.1 +aiohttp-asyncmdnsresolver==0.0.2 aiohttp-fast-zlib==0.2.0 aiohttp==3.11.11 aiohttp_cors==0.7.0 diff --git a/pyproject.toml b/pyproject.toml index edc039286d7..74e3d51a222 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,7 +31,7 @@ dependencies = [ "aiohttp==3.11.11", "aiohttp_cors==0.7.0", "aiohttp-fast-zlib==0.2.0", - "aiohttp-asyncmdnsresolver==0.0.1", + "aiohttp-asyncmdnsresolver==0.0.2", "aiozoneinfo==0.2.1", "astral==2.2", "async-interrupt==1.2.0", diff --git a/requirements.txt b/requirements.txt index 412252a0846..77fd3887db4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,7 +8,7 @@ aiohasupervisor==0.2.2b6 aiohttp==3.11.11 aiohttp_cors==0.7.0 aiohttp-fast-zlib==0.2.0 -aiohttp-asyncmdnsresolver==0.0.1 +aiohttp-asyncmdnsresolver==0.0.2 aiozoneinfo==0.2.1 astral==2.2 async-interrupt==1.2.0 From acb3f4ed78720b84b40a9c79a5763bad8c1afe54 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 30 Jan 2025 21:03:47 +0100 Subject: [PATCH 0322/3148] Add software version to onewire device info (#136934) --- .../components/onewire/onewirehub.py | 3 ++ tests/components/onewire/__init__.py | 4 +- .../onewire/snapshots/test_diagnostics.ambr | 1 + .../onewire/snapshots/test_init.ambr | 44 +++++++++---------- 4 files changed, 29 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/onewire/onewirehub.py b/homeassistant/components/onewire/onewirehub.py index a8d8dd06034..d65d7a90950 100644 --- a/homeassistant/components/onewire/onewirehub.py +++ b/homeassistant/components/onewire/onewirehub.py @@ -58,6 +58,7 @@ class OneWireHub: owproxy: protocol._Proxy devices: list[OWDeviceDescription] + _version: str def __init__(self, hass: HomeAssistant, config_entry: OneWireConfigEntry) -> None: """Initialize.""" @@ -73,6 +74,7 @@ class OneWireHub: port = self._config_entry.data[CONF_PORT] _LOGGER.debug("Initializing connection to %s:%s", host, port) self.owproxy = protocol.proxy(host, port) + self._version = self.owproxy.read(protocol.PTH_VERSION).decode() self.devices = _discover_devices(self.owproxy) async def initialize(self) -> None: @@ -85,6 +87,7 @@ class OneWireHub: """Populate the device registry.""" device_registry = dr.async_get(self._hass) for device in devices: + device.device_info["sw_version"] = self._version device_registry.async_get_or_create( config_entry_id=self._config_entry.entry_id, **device.device_info, diff --git a/tests/components/onewire/__init__.py b/tests/components/onewire/__init__.py index 9c025fe33af..595b660b722 100644 --- a/tests/components/onewire/__init__.py +++ b/tests/components/onewire/__init__.py @@ -13,7 +13,9 @@ from .const import ATTR_INJECT_READS, MOCK_OWPROXY_DEVICES def setup_owproxy_mock_devices(owproxy: MagicMock, device_ids: list[str]) -> None: """Set up mock for owproxy.""" dir_side_effect: dict[str, list] = {} - read_side_effect: dict[str, list] = {} + read_side_effect: dict[str, list] = { + "/system/configuration/version": [b"3.2"], + } # Setup directory listing dir_side_effect["/"] = [[f"/{device_id}/" for device_id in device_ids]] diff --git a/tests/components/onewire/snapshots/test_diagnostics.ambr b/tests/components/onewire/snapshots/test_diagnostics.ambr index 6c5631331ca..c60d0a9748b 100644 --- a/tests/components/onewire/snapshots/test_diagnostics.ambr +++ b/tests/components/onewire/snapshots/test_diagnostics.ambr @@ -15,6 +15,7 @@ 'model_id': 'HB_HUB', 'name': 'EF.111111111113', 'serial_number': '111111111113', + 'sw_version': '3.2', }), 'family': 'EF', 'id': 'EF.111111111113', diff --git a/tests/components/onewire/snapshots/test_init.ambr b/tests/components/onewire/snapshots/test_init.ambr index ee5d6d99158..5666dab6383 100644 --- a/tests/components/onewire/snapshots/test_init.ambr +++ b/tests/components/onewire/snapshots/test_init.ambr @@ -27,7 +27,7 @@ 'primary_config_entry': , 'serial_number': '111111111111', 'suggested_area': None, - 'sw_version': None, + 'sw_version': '3.2', 'via_device_id': None, }) # --- @@ -59,7 +59,7 @@ 'primary_config_entry': , 'serial_number': '111111111111', 'suggested_area': None, - 'sw_version': None, + 'sw_version': '3.2', 'via_device_id': None, }) # --- @@ -91,7 +91,7 @@ 'primary_config_entry': , 'serial_number': '111111111111', 'suggested_area': None, - 'sw_version': None, + 'sw_version': '3.2', 'via_device_id': None, }) # --- @@ -123,7 +123,7 @@ 'primary_config_entry': , 'serial_number': '111111111111', 'suggested_area': None, - 'sw_version': None, + 'sw_version': '3.2', 'via_device_id': , }) # --- @@ -155,7 +155,7 @@ 'primary_config_entry': , 'serial_number': '111111111111', 'suggested_area': None, - 'sw_version': None, + 'sw_version': '3.2', 'via_device_id': None, }) # --- @@ -187,7 +187,7 @@ 'primary_config_entry': , 'serial_number': '111111111111', 'suggested_area': None, - 'sw_version': None, + 'sw_version': '3.2', 'via_device_id': None, }) # --- @@ -219,7 +219,7 @@ 'primary_config_entry': , 'serial_number': '111111111111', 'suggested_area': None, - 'sw_version': None, + 'sw_version': '3.2', 'via_device_id': None, }) # --- @@ -251,7 +251,7 @@ 'primary_config_entry': , 'serial_number': '111111111111', 'suggested_area': None, - 'sw_version': None, + 'sw_version': '3.2', 'via_device_id': None, }) # --- @@ -283,7 +283,7 @@ 'primary_config_entry': , 'serial_number': '111111111111', 'suggested_area': None, - 'sw_version': None, + 'sw_version': '3.2', 'via_device_id': None, }) # --- @@ -315,7 +315,7 @@ 'primary_config_entry': , 'serial_number': '222222222222', 'suggested_area': None, - 'sw_version': None, + 'sw_version': '3.2', 'via_device_id': None, }) # --- @@ -347,7 +347,7 @@ 'primary_config_entry': , 'serial_number': '222222222223', 'suggested_area': None, - 'sw_version': None, + 'sw_version': '3.2', 'via_device_id': None, }) # --- @@ -379,7 +379,7 @@ 'primary_config_entry': , 'serial_number': '111111111111', 'suggested_area': None, - 'sw_version': None, + 'sw_version': '3.2', 'via_device_id': None, }) # --- @@ -411,7 +411,7 @@ 'primary_config_entry': , 'serial_number': '111111111111', 'suggested_area': None, - 'sw_version': None, + 'sw_version': '3.2', 'via_device_id': None, }) # --- @@ -443,7 +443,7 @@ 'primary_config_entry': , 'serial_number': '111111111111', 'suggested_area': None, - 'sw_version': None, + 'sw_version': '3.2', 'via_device_id': None, }) # --- @@ -475,7 +475,7 @@ 'primary_config_entry': , 'serial_number': '111111111111', 'suggested_area': None, - 'sw_version': None, + 'sw_version': '3.2', 'via_device_id': None, }) # --- @@ -507,7 +507,7 @@ 'primary_config_entry': , 'serial_number': '111111111111', 'suggested_area': None, - 'sw_version': None, + 'sw_version': '3.2', 'via_device_id': None, }) # --- @@ -539,7 +539,7 @@ 'primary_config_entry': , 'serial_number': '111111111111', 'suggested_area': None, - 'sw_version': None, + 'sw_version': '3.2', 'via_device_id': None, }) # --- @@ -571,7 +571,7 @@ 'primary_config_entry': , 'serial_number': '222222222222', 'suggested_area': None, - 'sw_version': None, + 'sw_version': '3.2', 'via_device_id': None, }) # --- @@ -603,7 +603,7 @@ 'primary_config_entry': , 'serial_number': '111111111111', 'suggested_area': None, - 'sw_version': None, + 'sw_version': '3.2', 'via_device_id': None, }) # --- @@ -635,7 +635,7 @@ 'primary_config_entry': , 'serial_number': '111111111111', 'suggested_area': None, - 'sw_version': None, + 'sw_version': '3.2', 'via_device_id': None, }) # --- @@ -667,7 +667,7 @@ 'primary_config_entry': , 'serial_number': '111111111112', 'suggested_area': None, - 'sw_version': None, + 'sw_version': '3.2', 'via_device_id': None, }) # --- @@ -699,7 +699,7 @@ 'primary_config_entry': , 'serial_number': '111111111113', 'suggested_area': None, - 'sw_version': None, + 'sw_version': '3.2', 'via_device_id': None, }) # --- From ea496290c268c9e190e4b1a4fcfeebe74fc2689f Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Thu, 30 Jan 2025 21:59:00 +0100 Subject: [PATCH 0323/3148] Update knx-frontend to 2025.1.30.194235 (#136954) --- homeassistant/components/knx/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/knx/manifest.json b/homeassistant/components/knx/manifest.json index f34ce0f4589..86c050443e3 100644 --- a/homeassistant/components/knx/manifest.json +++ b/homeassistant/components/knx/manifest.json @@ -12,7 +12,7 @@ "requirements": [ "xknx==3.5.0", "xknxproject==3.8.1", - "knx-frontend==2025.1.28.225404" + "knx-frontend==2025.1.30.194235" ], "single_config_entry": true } diff --git a/requirements_all.txt b/requirements_all.txt index 02091e9ec2a..f3c22e1b215 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1272,7 +1272,7 @@ kiwiki-client==0.1.1 knocki==0.4.2 # homeassistant.components.knx -knx-frontend==2025.1.28.225404 +knx-frontend==2025.1.30.194235 # homeassistant.components.konnected konnected==1.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 11905283d4d..f481aea392a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1074,7 +1074,7 @@ kegtron-ble==0.4.0 knocki==0.4.2 # homeassistant.components.knx -knx-frontend==2025.1.28.225404 +knx-frontend==2025.1.30.194235 # homeassistant.components.konnected konnected==1.2.0 From 00f8afe33280617c6859d61f5ce23bc570706399 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Thu, 30 Jan 2025 16:01:24 -0600 Subject: [PATCH 0324/3148] Consume extra system prompt in first pipeline (#136958) --- homeassistant/components/assist_satellite/entity.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/assist_satellite/entity.py b/homeassistant/components/assist_satellite/entity.py index 927229c9756..0229e0358b1 100644 --- a/homeassistant/components/assist_satellite/entity.py +++ b/homeassistant/components/assist_satellite/entity.py @@ -264,7 +264,6 @@ class AssistSatelliteEntity(entity.Entity): await self.async_start_conversation(announcement) finally: self._is_announcing = False - self._extra_system_prompt = None async def async_start_conversation( self, start_announcement: AssistSatelliteAnnouncement @@ -282,6 +281,10 @@ class AssistSatelliteEntity(entity.Entity): """Triggers an Assist pipeline in Home Assistant from a satellite.""" await self._cancel_running_pipeline() + # Consume system prompt in first pipeline + extra_system_prompt = self._extra_system_prompt + self._extra_system_prompt = None + if self._wake_word_intercept_future and start_stage in ( PipelineStage.WAKE_WORD, PipelineStage.STT, @@ -358,7 +361,7 @@ class AssistSatelliteEntity(entity.Entity): ), start_stage=start_stage, end_stage=end_stage, - conversation_extra_system_prompt=self._extra_system_prompt, + conversation_extra_system_prompt=extra_system_prompt, ), f"{self.entity_id}_pipeline", ) From f93b1cc950415b13daddb3281e65740cf9ef9911 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 30 Jan 2025 23:03:56 +0100 Subject: [PATCH 0325/3148] Make assist_satellite action descriptions consistent (#136955) - use third-person singular for descriptive language, following HA standards - use "a satellite" in both descriptions to match - use sentence-casing for "Start conversation" action name --- homeassistant/components/assist_satellite/strings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/assist_satellite/strings.json b/homeassistant/components/assist_satellite/strings.json index e83f4666b5d..fa2dc984ab7 100644 --- a/homeassistant/components/assist_satellite/strings.json +++ b/homeassistant/components/assist_satellite/strings.json @@ -14,7 +14,7 @@ "services": { "announce": { "name": "Announce", - "description": "Let the satellite announce a message.", + "description": "Lets a satellite announce a message.", "fields": { "message": { "name": "Message", @@ -27,8 +27,8 @@ } }, "start_conversation": { - "name": "Start Conversation", - "description": "Start a conversation from a satellite.", + "name": "Start conversation", + "description": "Starts a conversation from a satellite.", "fields": { "start_message": { "name": "Message", From 6c93d6a2d0ec982dc10f2ea4ef4cc939c8294635 Mon Sep 17 00:00:00 2001 From: tronikos Date: Thu, 30 Jan 2025 22:59:03 -0800 Subject: [PATCH 0326/3148] Include the redirect URL in the Google Drive instructions (#136906) * Include the redirect URL in the Google Drive instructions * Apply suggestions from code review Co-authored-by: Martin Hjelmare --------- Co-authored-by: Martin Hjelmare --- .../google_drive/application_credentials.py | 2 ++ .../components/google_drive/strings.json | 2 +- .../helpers/config_entry_oauth2_flow.py | 26 ++++++++++++------- 3 files changed, 19 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/google_drive/application_credentials.py b/homeassistant/components/google_drive/application_credentials.py index c2f59b298cb..1c4421623d4 100644 --- a/homeassistant/components/google_drive/application_credentials.py +++ b/homeassistant/components/google_drive/application_credentials.py @@ -2,6 +2,7 @@ from homeassistant.components.application_credentials import AuthorizationServer from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_entry_oauth2_flow async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer: @@ -18,4 +19,5 @@ async def async_get_description_placeholders(hass: HomeAssistant) -> dict[str, s "oauth_consent_url": "https://console.cloud.google.com/apis/credentials/consent", "more_info_url": "https://www.home-assistant.io/integrations/google_drive/", "oauth_creds_url": "https://console.cloud.google.com/apis/credentials", + "redirect_url": config_entry_oauth2_flow.async_get_redirect_uri(hass), } diff --git a/homeassistant/components/google_drive/strings.json b/homeassistant/components/google_drive/strings.json index 3441bec4294..e6658fb08e9 100644 --- a/homeassistant/components/google_drive/strings.json +++ b/homeassistant/components/google_drive/strings.json @@ -35,6 +35,6 @@ } }, "application_credentials": { - "description": "Follow the [instructions]({more_info_url}) for [OAuth consent screen]({oauth_consent_url}) to give Home Assistant access to your Google Drive. You also need to create Application Credentials linked to your account:\n1. Go to [Credentials]({oauth_creds_url}) and select **Create Credentials**.\n1. From the drop-down list select **OAuth client ID**.\n1. Select **Web application** for the Application Type." + "description": "Follow the [instructions]({more_info_url}) to configure the Cloud Console:\n\n1. Go to the [OAuth consent screen]({oauth_consent_url}) and configure\n1. Go to [Credentials]({oauth_creds_url}) and select **Create Credentials**.\n1. From the drop-down list select **OAuth client ID**.\n1. Select **Web application** for the Application Type.\n1. Add `{redirect_url}` under *Authorized redirect URI*." } } diff --git a/homeassistant/helpers/config_entry_oauth2_flow.py b/homeassistant/helpers/config_entry_oauth2_flow.py index c2a61335769..24a9de5b562 100644 --- a/homeassistant/helpers/config_entry_oauth2_flow.py +++ b/homeassistant/helpers/config_entry_oauth2_flow.py @@ -55,6 +55,21 @@ OAUTH_AUTHORIZE_URL_TIMEOUT_SEC = 30 OAUTH_TOKEN_TIMEOUT_SEC = 30 +@callback +def async_get_redirect_uri(hass: HomeAssistant) -> str: + """Return the redirect uri.""" + if "my" in hass.config.components: + return MY_AUTH_CALLBACK_PATH + + if (req := http.current_request.get()) is None: + raise RuntimeError("No current request in context") + + if (ha_host := req.headers.get(HEADER_FRONTEND_BASE)) is None: + raise RuntimeError("No header in request") + + return f"{ha_host}{AUTH_CALLBACK_PATH}" + + class AbstractOAuth2Implementation(ABC): """Base class to abstract OAuth2 authentication.""" @@ -144,16 +159,7 @@ class LocalOAuth2Implementation(AbstractOAuth2Implementation): @property def redirect_uri(self) -> str: """Return the redirect uri.""" - if "my" in self.hass.config.components: - return MY_AUTH_CALLBACK_PATH - - if (req := http.current_request.get()) is None: - raise RuntimeError("No current request in context") - - if (ha_host := req.headers.get(HEADER_FRONTEND_BASE)) is None: - raise RuntimeError("No header in request") - - return f"{ha_host}{AUTH_CALLBACK_PATH}" + return async_get_redirect_uri(self.hass) @property def extra_authorize_data(self) -> dict: From 4613087e864ae89f98b8a4132b51df62be18adaf Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Fri, 31 Jan 2025 09:23:03 +0200 Subject: [PATCH 0327/3148] Add serial number to LG webOS TV device info (#136968) --- homeassistant/components/webostv/media_player.py | 3 +++ tests/components/webostv/conftest.py | 2 +- tests/components/webostv/snapshots/test_diagnostics.ambr | 1 + tests/components/webostv/snapshots/test_media_player.ambr | 2 +- 4 files changed, 6 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/webostv/media_player.py b/homeassistant/components/webostv/media_player.py index c8b871b3bf2..076b6caad24 100644 --- a/homeassistant/components/webostv/media_player.py +++ b/homeassistant/components/webostv/media_player.py @@ -284,6 +284,9 @@ class LgWebOSMediaPlayerEntity(RestoreEntity, MediaPlayerEntity): if model := self._client.system_info.get("modelName"): self._attr_device_info["model"] = model + if serial_number := self._client.system_info.get("serialNumber"): + self._attr_device_info["serial_number"] = serial_number + self._attr_extra_state_attributes = {} if self._client.sound_output is not None or self.state != MediaPlayerState.OFF: self._attr_extra_state_attributes = { diff --git a/tests/components/webostv/conftest.py b/tests/components/webostv/conftest.py index bf007f5b936..c6594746cc5 100644 --- a/tests/components/webostv/conftest.py +++ b/tests/components/webostv/conftest.py @@ -42,7 +42,7 @@ def client_fixture(): client = mock_client_class.return_value client.hello_info = {"deviceUUID": FAKE_UUID} client.software_info = {"major_ver": "major", "minor_ver": "minor"} - client.system_info = {"modelName": TV_MODEL} + client.system_info = {"modelName": TV_MODEL, "serialNumber": "1234567890"} client.client_key = CLIENT_KEY client.apps = MOCK_APPS client.inputs = MOCK_INPUTS diff --git a/tests/components/webostv/snapshots/test_diagnostics.ambr b/tests/components/webostv/snapshots/test_diagnostics.ambr index a9bd6e91ee0..07ee50af1f8 100644 --- a/tests/components/webostv/snapshots/test_diagnostics.ambr +++ b/tests/components/webostv/snapshots/test_diagnostics.ambr @@ -41,6 +41,7 @@ 'sound_output': 'speaker', 'system_info': dict({ 'modelName': 'MODEL', + 'serialNumber': '1234567890', }), }), 'entry': dict({ diff --git a/tests/components/webostv/snapshots/test_media_player.ambr b/tests/components/webostv/snapshots/test_media_player.ambr index 35a703cc109..23f45a0f325 100644 --- a/tests/components/webostv/snapshots/test_media_player.ambr +++ b/tests/components/webostv/snapshots/test_media_player.ambr @@ -61,7 +61,7 @@ 'name': 'LG webOS TV MODEL', 'name_by_user': None, 'primary_config_entry': , - 'serial_number': None, + 'serial_number': '1234567890', 'suggested_area': None, 'sw_version': 'major.minor', 'via_device_id': None, From 4d4e11a0eb90639ec91a9d927e89b7a834becec3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Fri, 31 Jan 2025 08:26:57 +0100 Subject: [PATCH 0328/3148] Fetch all programs instead of only the available ones at Home Connect (#136949) Fetch all programs instead of only the available ones --- .../components/home_connect/coordinator.py | 8 ++--- .../components/home_connect/switch.py | 4 +-- tests/components/home_connect/conftest.py | 19 +++++------- ...{programs-available.json => programs.json} | 0 tests/components/home_connect/test_select.py | 30 +++++++++---------- tests/components/home_connect/test_switch.py | 23 ++++++-------- 6 files changed, 35 insertions(+), 49 deletions(-) rename tests/components/home_connect/fixtures/{programs-available.json => programs.json} (100%) diff --git a/homeassistant/components/home_connect/coordinator.py b/homeassistant/components/home_connect/coordinator.py index 2c70d74150e..29bd961220e 100644 --- a/homeassistant/components/home_connect/coordinator.py +++ b/homeassistant/components/home_connect/coordinator.py @@ -25,7 +25,7 @@ from aiohomeconnect.model.error import ( HomeConnectError, HomeConnectRequestError, ) -from aiohomeconnect.model.program import EnumerateAvailableProgram +from aiohomeconnect.model.program import EnumerateProgram from propcache.api import cached_property from homeassistant.config_entries import ConfigEntry @@ -48,7 +48,7 @@ class HomeConnectApplianceData: events: dict[EventKey, Event] = field(default_factory=dict) info: HomeAppliance - programs: list[EnumerateAvailableProgram] = field(default_factory=list) + programs: list[EnumerateProgram] = field(default_factory=list) settings: dict[SettingKey, GetSetting] status: dict[StatusKey, Status] @@ -243,9 +243,7 @@ class HomeConnectCoordinator( ): try: appliance_data.programs.extend( - ( - await self.client.get_available_programs(appliance.ha_id) - ).programs + (await self.client.get_all_programs(appliance.ha_id)).programs ) except HomeConnectError as error: _LOGGER.debug( diff --git a/homeassistant/components/home_connect/switch.py b/homeassistant/components/home_connect/switch.py index c3a0858e0bb..521252ccc2f 100644 --- a/homeassistant/components/home_connect/switch.py +++ b/homeassistant/components/home_connect/switch.py @@ -5,7 +5,7 @@ from typing import Any, cast from aiohomeconnect.model import EventKey, ProgramKey, SettingKey from aiohomeconnect.model.error import HomeConnectError -from aiohomeconnect.model.program import EnumerateAvailableProgram +from aiohomeconnect.model.program import EnumerateProgram from homeassistant.components.automation import automations_with_entity from homeassistant.components.script import scripts_with_entity @@ -184,7 +184,7 @@ class HomeConnectProgramSwitch(HomeConnectEntity, SwitchEntity): self, coordinator: HomeConnectCoordinator, appliance: HomeConnectApplianceData, - program: EnumerateAvailableProgram, + program: EnumerateProgram, ) -> None: """Initialize the entity.""" desc = " ".join(["Program", program.key.split(".")[-1]]) diff --git a/tests/components/home_connect/conftest.py b/tests/components/home_connect/conftest.py index af039f04c03..ae98c69d242 100644 --- a/tests/components/home_connect/conftest.py +++ b/tests/components/home_connect/conftest.py @@ -9,9 +9,9 @@ from unittest.mock import AsyncMock, MagicMock, patch from aiohomeconnect.client import Client as HomeConnectClient from aiohomeconnect.model import ( - ArrayOfAvailablePrograms, ArrayOfEvents, ArrayOfHomeAppliances, + ArrayOfPrograms, ArrayOfSettings, ArrayOfStatus, Event, @@ -37,9 +37,7 @@ from tests.common import MockConfigEntry, load_json_object_fixture MOCK_APPLIANCES = ArrayOfHomeAppliances.from_dict( load_json_object_fixture("home_connect/appliances.json")["data"] ) -MOCK_PROGRAMS: dict[str, Any] = load_json_object_fixture( - "home_connect/programs-available.json" -) +MOCK_PROGRAMS: dict[str, Any] = load_json_object_fixture("home_connect/programs.json") MOCK_SETTINGS: dict[str, Any] = load_json_object_fixture("home_connect/settings.json") MOCK_STATUS = ArrayOfStatus.from_dict( load_json_object_fixture("home_connect/status.json")["data"] @@ -219,8 +217,8 @@ def _get_set_key_value_side_effect( return set_key_value_side_effect -async def _get_available_programs_side_effect(ha_id: str) -> ArrayOfAvailablePrograms: - """Get available programs.""" +async def _get_all_programs_side_effect(ha_id: str) -> ArrayOfPrograms: + """Get all programs.""" appliance_type = next( appliance for appliance in MOCK_APPLIANCES.homeappliances @@ -229,7 +227,7 @@ async def _get_available_programs_side_effect(ha_id: str) -> ArrayOfAvailablePro if appliance_type not in MOCK_PROGRAMS: raise HomeConnectApiError("error.key", "error description") - return ArrayOfAvailablePrograms.from_dict(MOCK_PROGRAMS[appliance_type]["data"]) + return ArrayOfPrograms.from_dict(MOCK_PROGRAMS[appliance_type]["data"]) async def _get_settings_side_effect(ha_id: str) -> ArrayOfSettings: @@ -290,9 +288,7 @@ def mock_client(request: pytest.FixtureRequest) -> MagicMock: ) mock.get_settings = AsyncMock(side_effect=_get_settings_side_effect) mock.get_status = AsyncMock(return_value=copy.deepcopy(MOCK_STATUS)) - mock.get_available_programs = AsyncMock( - side_effect=_get_available_programs_side_effect - ) + mock.get_all_programs = AsyncMock(side_effect=_get_all_programs_side_effect) mock.put_command = AsyncMock() mock.side_effect = mock @@ -323,7 +319,6 @@ def mock_client_with_exception(request: pytest.FixtureRequest) -> MagicMock: mock.start_program = AsyncMock(side_effect=exception) mock.stop_program = AsyncMock(side_effect=exception) - mock.get_available_programs = AsyncMock(side_effect=exception) mock.set_selected_program = AsyncMock(side_effect=exception) mock.set_active_program_option = AsyncMock(side_effect=exception) mock.set_selected_program_option = AsyncMock(side_effect=exception) @@ -331,7 +326,7 @@ def mock_client_with_exception(request: pytest.FixtureRequest) -> MagicMock: mock.get_settings = AsyncMock(side_effect=exception) mock.get_setting = AsyncMock(side_effect=exception) mock.get_status = AsyncMock(side_effect=exception) - mock.get_available_programs = AsyncMock(side_effect=exception) + mock.get_all_programs = AsyncMock(side_effect=exception) mock.put_command = AsyncMock(side_effect=exception) return mock diff --git a/tests/components/home_connect/fixtures/programs-available.json b/tests/components/home_connect/fixtures/programs.json similarity index 100% rename from tests/components/home_connect/fixtures/programs-available.json rename to tests/components/home_connect/fixtures/programs.json diff --git a/tests/components/home_connect/test_select.py b/tests/components/home_connect/test_select.py index 6ebd37266cd..a0cdd15bf31 100644 --- a/tests/components/home_connect/test_select.py +++ b/tests/components/home_connect/test_select.py @@ -4,8 +4,8 @@ from collections.abc import Awaitable, Callable from unittest.mock import MagicMock from aiohomeconnect.model import ( - ArrayOfAvailablePrograms, ArrayOfEvents, + ArrayOfPrograms, Event, EventKey, EventMessage, @@ -13,7 +13,7 @@ from aiohomeconnect.model import ( ProgramKey, ) from aiohomeconnect.model.error import HomeConnectError -from aiohomeconnect.model.program import EnumerateAvailableProgram +from aiohomeconnect.model.program import EnumerateProgram import pytest from homeassistant.components.select import ( @@ -61,14 +61,14 @@ async def test_filter_unknown_programs( entity_registry: er.EntityRegistry, ) -> None: """Test select that only known programs are shown.""" - client.get_available_programs.side_effect = None - client.get_available_programs.return_value = ArrayOfAvailablePrograms( + client.get_all_programs.side_effect = None + client.get_all_programs.return_value = ArrayOfPrograms( [ - EnumerateAvailableProgram( + EnumerateProgram( key=ProgramKey.DISHCARE_DISHWASHER_ECO_50, raw_key=ProgramKey.DISHCARE_DISHWASHER_ECO_50.value, ), - EnumerateAvailableProgram( + EnumerateProgram( key=ProgramKey.UNKNOWN, raw_key="an unknown program", ), @@ -202,16 +202,14 @@ async def test_select_exception_handling( client_with_exception: MagicMock, ) -> None: """Test exception handling.""" - client_with_exception.get_available_programs.side_effect = None - client_with_exception.get_available_programs.return_value = ( - ArrayOfAvailablePrograms( - [ - EnumerateAvailableProgram( - key=ProgramKey.DISHCARE_DISHWASHER_ECO_50, - raw_key=ProgramKey.DISHCARE_DISHWASHER_ECO_50.value, - ) - ] - ) + client_with_exception.get_all_programs.side_effect = None + client_with_exception.get_all_programs.return_value = ArrayOfPrograms( + [ + EnumerateProgram( + key=ProgramKey.DISHCARE_DISHWASHER_ECO_50, + raw_key=ProgramKey.DISHCARE_DISHWASHER_ECO_50.value, + ) + ] ) assert config_entry.state is ConfigEntryState.NOT_LOADED diff --git a/tests/components/home_connect/test_switch.py b/tests/components/home_connect/test_switch.py index 10d393423be..4d6b59eddd9 100644 --- a/tests/components/home_connect/test_switch.py +++ b/tests/components/home_connect/test_switch.py @@ -15,10 +15,7 @@ from aiohomeconnect.model import ( ) from aiohomeconnect.model.error import HomeConnectError from aiohomeconnect.model.event import ArrayOfEvents, EventType -from aiohomeconnect.model.program import ( - ArrayOfAvailablePrograms, - EnumerateAvailableProgram, -) +from aiohomeconnect.model.program import ArrayOfPrograms, EnumerateProgram from aiohomeconnect.model.setting import SettingConstraints import pytest @@ -250,16 +247,14 @@ async def test_switch_exception_handling( client_with_exception: MagicMock, ) -> None: """Test exception handling.""" - client_with_exception.get_available_programs.side_effect = None - client_with_exception.get_available_programs.return_value = ( - ArrayOfAvailablePrograms( - [ - EnumerateAvailableProgram( - key=ProgramKey.DISHCARE_DISHWASHER_ECO_50, - raw_key=ProgramKey.DISHCARE_DISHWASHER_ECO_50.value, - ) - ] - ) + client_with_exception.get_all_programs.side_effect = None + client_with_exception.get_all_programs.return_value = ArrayOfPrograms( + [ + EnumerateProgram( + key=ProgramKey.DISHCARE_DISHWASHER_ECO_50, + raw_key=ProgramKey.DISHCARE_DISHWASHER_ECO_50.value, + ) + ] ) client_with_exception.get_settings.side_effect = None client_with_exception.get_settings.return_value = ArrayOfSettings( From 99e307fe5a752dc4c732b90e24d8b4c81b61b231 Mon Sep 17 00:00:00 2001 From: tronikos Date: Thu, 30 Jan 2025 23:33:58 -0800 Subject: [PATCH 0329/3148] Bump opower to 0.8.9 (#136911) * Bump opower to 0.8.9 * mypy --- homeassistant/components/opower/coordinator.py | 14 ++++++-------- homeassistant/components/opower/manifest.json | 2 +- homeassistant/components/opower/sensor.py | 8 ++++---- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 13 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/opower/coordinator.py b/homeassistant/components/opower/coordinator.py index f6f3524d630..6957ae4984c 100644 --- a/homeassistant/components/opower/coordinator.py +++ b/homeassistant/components/opower/coordinator.py @@ -5,18 +5,16 @@ import logging from types import MappingProxyType from typing import Any, cast -import aiohttp from opower import ( Account, AggregateType, - CannotConnect, CostRead, Forecast, - InvalidAuth, MeterType, Opower, ReadResolution, ) +from opower.exceptions import ApiException, CannotConnect, InvalidAuth from homeassistant.components.recorder import get_instance from homeassistant.components.recorder.models import StatisticData, StatisticMetaData @@ -89,7 +87,7 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, Forecast]]): raise UpdateFailed(f"Error during login: {err}") from err try: forecasts: list[Forecast] = await self.api.async_get_forecast() - except aiohttp.ClientError as err: + except ApiException as err: _LOGGER.error("Error getting forecasts: %s", err) raise _LOGGER.debug("Updating sensor data with: %s", forecasts) @@ -102,7 +100,7 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, Forecast]]): """Insert Opower statistics.""" try: accounts = await self.api.async_get_accounts() - except aiohttp.ClientError as err: + except ApiException as err: _LOGGER.error("Error getting accounts: %s", err) raise for account in accounts: @@ -271,7 +269,7 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, Forecast]]): cost_reads = await self.api.async_get_cost_reads( account, AggregateType.BILL, start, end ) - except aiohttp.ClientError as err: + except ApiException as err: _LOGGER.error("Error getting monthly cost reads: %s", err) raise _LOGGER.debug("Got %s monthly cost reads", len(cost_reads)) @@ -290,7 +288,7 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, Forecast]]): daily_cost_reads = await self.api.async_get_cost_reads( account, AggregateType.DAY, start, end ) - except aiohttp.ClientError as err: + except ApiException as err: _LOGGER.error("Error getting daily cost reads: %s", err) raise _LOGGER.debug("Got %s daily cost reads", len(daily_cost_reads)) @@ -308,7 +306,7 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, Forecast]]): hourly_cost_reads = await self.api.async_get_cost_reads( account, AggregateType.HOUR, start, end ) - except aiohttp.ClientError as err: + except ApiException as err: _LOGGER.error("Error getting hourly cost reads: %s", err) raise _LOGGER.debug("Got %s hourly cost reads", len(hourly_cost_reads)) diff --git a/homeassistant/components/opower/manifest.json b/homeassistant/components/opower/manifest.json index 7227f7171ac..d168cba5752 100644 --- a/homeassistant/components/opower/manifest.json +++ b/homeassistant/components/opower/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/opower", "iot_class": "cloud_polling", "loggers": ["opower"], - "requirements": ["opower==0.8.8"] + "requirements": ["opower==0.8.9"] } diff --git a/homeassistant/components/opower/sensor.py b/homeassistant/components/opower/sensor.py index 7f8eb22d1e6..f9d0fe62332 100644 --- a/homeassistant/components/opower/sensor.py +++ b/homeassistant/components/opower/sensor.py @@ -97,7 +97,7 @@ ELEC_SENSORS: tuple[OpowerEntityDescription, ...] = ( device_class=SensorDeviceClass.DATE, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, - value_fn=lambda data: data.start_date, + value_fn=lambda data: str(data.start_date), ), OpowerEntityDescription( key="elec_end_date", @@ -105,7 +105,7 @@ ELEC_SENSORS: tuple[OpowerEntityDescription, ...] = ( device_class=SensorDeviceClass.DATE, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, - value_fn=lambda data: data.end_date, + value_fn=lambda data: str(data.end_date), ), ) GAS_SENSORS: tuple[OpowerEntityDescription, ...] = ( @@ -169,7 +169,7 @@ GAS_SENSORS: tuple[OpowerEntityDescription, ...] = ( device_class=SensorDeviceClass.DATE, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, - value_fn=lambda data: data.start_date, + value_fn=lambda data: str(data.start_date), ), OpowerEntityDescription( key="gas_end_date", @@ -177,7 +177,7 @@ GAS_SENSORS: tuple[OpowerEntityDescription, ...] = ( device_class=SensorDeviceClass.DATE, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, - value_fn=lambda data: data.end_date, + value_fn=lambda data: str(data.end_date), ), ) diff --git a/requirements_all.txt b/requirements_all.txt index f3c22e1b215..dc5dc04420f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1592,7 +1592,7 @@ openwrt-luci-rpc==1.1.17 openwrt-ubus-rpc==0.0.2 # homeassistant.components.opower -opower==0.8.8 +opower==0.8.9 # homeassistant.components.oralb oralb-ble==0.17.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f481aea392a..8707b3ff044 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1328,7 +1328,7 @@ openhomedevice==2.2.0 openwebifpy==4.3.1 # homeassistant.components.opower -opower==0.8.8 +opower==0.8.9 # homeassistant.components.oralb oralb-ble==0.17.6 From fc979cd564ee2d5fd27e05b32fa6f11b343ee4d5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 31 Jan 2025 01:34:39 -0600 Subject: [PATCH 0330/3148] Bump habluetooth to 3.15.0 (#136973) --- 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 1fcd507da83..38677400418 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -21,6 +21,6 @@ "bluetooth-auto-recovery==1.4.2", "bluetooth-data-tools==1.22.0", "dbus-fast==2.30.2", - "habluetooth==3.14.0" + "habluetooth==3.15.0" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 891d91e134b..64353901fbf 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -33,7 +33,7 @@ dbus-fast==2.30.2 fnv-hash-fast==1.2.2 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 -habluetooth==3.14.0 +habluetooth==3.15.0 hass-nabucasa==0.88.1 hassil==2.2.0 home-assistant-bluetooth==1.13.0 diff --git a/requirements_all.txt b/requirements_all.txt index dc5dc04420f..679c496e5a5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1103,7 +1103,7 @@ ha-philipsjs==3.2.2 habiticalib==0.3.3 # homeassistant.components.bluetooth -habluetooth==3.14.0 +habluetooth==3.15.0 # homeassistant.components.cloud hass-nabucasa==0.88.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8707b3ff044..09478cb6447 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -944,7 +944,7 @@ ha-philipsjs==3.2.2 habiticalib==0.3.3 # homeassistant.components.bluetooth -habluetooth==3.14.0 +habluetooth==3.15.0 # homeassistant.components.cloud hass-nabucasa==0.88.1 From 270108e8e4c9484fae94bbc10f2cb895a956596c Mon Sep 17 00:00:00 2001 From: Austin Mroczek Date: Fri, 31 Jan 2025 01:36:06 -0800 Subject: [PATCH 0331/3148] Bump total-connect-client to 2025.1.4 (#136793) --- .../totalconnect/alarm_control_panel.py | 4 +- .../components/totalconnect/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/totalconnect/common.py | 39 +++++++++++-------- .../totalconnect/test_config_flow.py | 20 +++++++--- 6 files changed, 43 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/totalconnect/alarm_control_panel.py b/homeassistant/components/totalconnect/alarm_control_panel.py index 48ba78acc92..021d1c7b886 100644 --- a/homeassistant/components/totalconnect/alarm_control_panel.py +++ b/homeassistant/components/totalconnect/alarm_control_panel.py @@ -73,7 +73,7 @@ class TotalConnectAlarm(TotalConnectLocationEntity, AlarmControlPanelEntity): ) -> None: """Initialize the TotalConnect status.""" super().__init__(coordinator, location) - self._partition_id = partition_id + self._partition_id = int(partition_id) self._partition = self._location.partitions[partition_id] """ @@ -81,7 +81,7 @@ class TotalConnectAlarm(TotalConnectLocationEntity, AlarmControlPanelEntity): for most users with new support for partitions. Add _# for partition 2 and beyond. """ - if partition_id == 1: + if int(partition_id) == 1: self._attr_name = None self._attr_unique_id = str(location.location_id) else: diff --git a/homeassistant/components/totalconnect/manifest.json b/homeassistant/components/totalconnect/manifest.json index 33306a7adba..6aff1ea392b 100644 --- a/homeassistant/components/totalconnect/manifest.json +++ b/homeassistant/components/totalconnect/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/totalconnect", "iot_class": "cloud_polling", "loggers": ["total_connect_client"], - "requirements": ["total-connect-client==2024.12"] + "requirements": ["total-connect-client==2025.1.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index 679c496e5a5..a60f19c6ef3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2902,7 +2902,7 @@ tololib==1.1.0 toonapi==0.3.0 # homeassistant.components.totalconnect -total-connect-client==2024.12 +total-connect-client==2025.1.4 # homeassistant.components.tplink_lte tp-connected==0.0.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 09478cb6447..9c461aabfe8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2330,7 +2330,7 @@ tololib==1.1.0 toonapi==0.3.0 # homeassistant.components.totalconnect -total-connect-client==2024.12 +total-connect-client==2025.1.4 # homeassistant.components.tplink_omada tplink-omada-client==1.4.3 diff --git a/tests/components/totalconnect/common.py b/tests/components/totalconnect/common.py index 828cad71e07..34d451ec0b8 100644 --- a/tests/components/totalconnect/common.py +++ b/tests/components/totalconnect/common.py @@ -49,20 +49,15 @@ USER = { "UserFeatureList": "Master=0,User Administration=0,Configuration Administration=0", } -RESPONSE_AUTHENTICATE = { +RESPONSE_SESSION_DETAILS = { "ResultCode": ResultCode.SUCCESS.value, - "SessionID": 1, + "ResultData": "Success", + "SessionID": "12345", "Locations": LOCATIONS, "ModuleFlags": MODULE_FLAGS, "UserInfo": USER, } -RESPONSE_AUTHENTICATE_FAILED = { - "ResultCode": ResultCode.BAD_USER_OR_PASSWORD.value, - "ResultData": "test bad authentication", -} - - PARTITION_DISARMED = { "PartitionID": "1", "ArmingState": ArmingState.DISARMED, @@ -359,13 +354,13 @@ OPTIONS_DATA = {AUTO_BYPASS: False, CODE_REQUIRED: False} OPTIONS_DATA_CODE_REQUIRED = {AUTO_BYPASS: False, CODE_REQUIRED: True} PARTITION_DETAILS_1 = { - "PartitionID": 1, + "PartitionID": "1", "ArmingState": ArmingState.DISARMED.value, "PartitionName": "Test1", } PARTITION_DETAILS_2 = { - "PartitionID": 2, + "PartitionID": "2", "ArmingState": ArmingState.DISARMED.value, "PartitionName": "Test2", } @@ -402,6 +397,12 @@ RESPONSE_GET_ZONE_DETAILS_SUCCESS = { TOTALCONNECT_REQUEST = ( "homeassistant.components.totalconnect.TotalConnectClient.request" ) +TOTALCONNECT_GET_CONFIG = ( + "homeassistant.components.totalconnect.TotalConnectClient._get_configuration" +) +TOTALCONNECT_REQUEST_TOKEN = ( + "homeassistant.components.totalconnect.TotalConnectClient._request_token" +) async def setup_platform( @@ -420,7 +421,7 @@ async def setup_platform( mock_entry.add_to_hass(hass) responses = [ - RESPONSE_AUTHENTICATE, + RESPONSE_SESSION_DETAILS, RESPONSE_PARTITION_DETAILS, RESPONSE_GET_ZONE_DETAILS_SUCCESS, RESPONSE_DISARMED, @@ -433,6 +434,8 @@ async def setup_platform( TOTALCONNECT_REQUEST, side_effect=responses, ) as mock_request, + patch(TOTALCONNECT_GET_CONFIG, side_effect=None), + patch(TOTALCONNECT_REQUEST_TOKEN, side_effect=None), ): assert await async_setup_component(hass, DOMAIN, {}) assert mock_request.call_count == 5 @@ -448,17 +451,21 @@ async def init_integration(hass: HomeAssistant) -> MockConfigEntry: mock_entry.add_to_hass(hass) responses = [ - RESPONSE_AUTHENTICATE, + RESPONSE_SESSION_DETAILS, RESPONSE_PARTITION_DETAILS, RESPONSE_GET_ZONE_DETAILS_SUCCESS, RESPONSE_DISARMED, RESPONSE_DISARMED, ] - with patch( - TOTALCONNECT_REQUEST, - side_effect=responses, - ) as mock_request: + with ( + patch( + TOTALCONNECT_REQUEST, + side_effect=responses, + ) as mock_request, + patch(TOTALCONNECT_GET_CONFIG, side_effect=None), + patch(TOTALCONNECT_REQUEST_TOKEN, side_effect=None), + ): await hass.config_entries.async_setup(mock_entry.entry_id) assert mock_request.call_count == 5 await hass.async_block_till_done() diff --git a/tests/components/totalconnect/test_config_flow.py b/tests/components/totalconnect/test_config_flow.py index 86419bff817..f5020394bce 100644 --- a/tests/components/totalconnect/test_config_flow.py +++ b/tests/components/totalconnect/test_config_flow.py @@ -18,13 +18,15 @@ from homeassistant.data_entry_flow import FlowResultType from .common import ( CONFIG_DATA, CONFIG_DATA_NO_USERCODES, - RESPONSE_AUTHENTICATE, RESPONSE_DISARMED, RESPONSE_GET_ZONE_DETAILS_SUCCESS, RESPONSE_PARTITION_DETAILS, + RESPONSE_SESSION_DETAILS, RESPONSE_SUCCESS, RESPONSE_USER_CODE_INVALID, + TOTALCONNECT_GET_CONFIG, TOTALCONNECT_REQUEST, + TOTALCONNECT_REQUEST_TOKEN, USERNAME, ) @@ -48,7 +50,7 @@ async def test_user_show_locations(hass: HomeAssistant) -> None: """Test user locations form.""" # user/pass provided, so check if valid then ask for usercodes on locations form responses = [ - RESPONSE_AUTHENTICATE, + RESPONSE_SESSION_DETAILS, RESPONSE_PARTITION_DETAILS, RESPONSE_GET_ZONE_DETAILS_SUCCESS, RESPONSE_DISARMED, @@ -61,6 +63,8 @@ async def test_user_show_locations(hass: HomeAssistant) -> None: TOTALCONNECT_REQUEST, side_effect=responses, ) as mock_request, + patch(TOTALCONNECT_GET_CONFIG, side_effect=None), + patch(TOTALCONNECT_REQUEST_TOKEN, side_effect=None), patch( "homeassistant.components.totalconnect.async_setup_entry", return_value=True ), @@ -180,7 +184,7 @@ async def test_reauth(hass: HomeAssistant) -> None: async def test_no_locations(hass: HomeAssistant) -> None: """Test with no user locations.""" responses = [ - RESPONSE_AUTHENTICATE, + RESPONSE_SESSION_DETAILS, RESPONSE_PARTITION_DETAILS, RESPONSE_GET_ZONE_DETAILS_SUCCESS, RESPONSE_DISARMED, @@ -191,6 +195,8 @@ async def test_no_locations(hass: HomeAssistant) -> None: TOTALCONNECT_REQUEST, side_effect=responses, ) as mock_request, + patch(TOTALCONNECT_GET_CONFIG, side_effect=None), + patch(TOTALCONNECT_REQUEST_TOKEN, side_effect=None), patch( "homeassistant.components.totalconnect.async_setup_entry", return_value=True ), @@ -221,7 +227,7 @@ async def test_options_flow(hass: HomeAssistant) -> None: config_entry.add_to_hass(hass) responses = [ - RESPONSE_AUTHENTICATE, + RESPONSE_SESSION_DETAILS, RESPONSE_PARTITION_DETAILS, RESPONSE_GET_ZONE_DETAILS_SUCCESS, RESPONSE_DISARMED, @@ -229,7 +235,11 @@ async def test_options_flow(hass: HomeAssistant) -> None: RESPONSE_DISARMED, ] - with patch(TOTALCONNECT_REQUEST, side_effect=responses): + with ( + patch(TOTALCONNECT_REQUEST, side_effect=responses), + patch(TOTALCONNECT_GET_CONFIG, side_effect=None), + patch(TOTALCONNECT_REQUEST_TOKEN, side_effect=None), + ): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() From f1c720606f1fa0fa71c544da0b9124be8430315b Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Fri, 31 Jan 2025 10:38:30 +0100 Subject: [PATCH 0332/3148] Fixes to the user-facing strings of energenie_power_sockets (#136844) --- homeassistant/components/energenie_power_sockets/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/energenie_power_sockets/strings.json b/homeassistant/components/energenie_power_sockets/strings.json index e193b06b25f..4e4e49c68fb 100644 --- a/homeassistant/components/energenie_power_sockets/strings.json +++ b/homeassistant/components/energenie_power_sockets/strings.json @@ -3,7 +3,7 @@ "config": { "step": { "user": { - "title": "Searching for Energenie-Power-Sockets Devices.", + "title": "Searching for Energenie Power Sockets devices", "description": "Choose a discovered device.", "data": { "device": "[%key:common::config_flow::data::device%]" @@ -13,7 +13,7 @@ "abort": { "usb_error": "Couldn't access USB devices!", "no_device": "Unable to discover any (new) supported device.", - "device_not_found": "No device was found for the given id.", + "device_not_found": "No device was found for the given ID.", "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } }, From ab5583ed40a8c0ebf03c4c051dd67d3c2fd777e3 Mon Sep 17 00:00:00 2001 From: Avi Miller Date: Fri, 31 Jan 2025 20:55:42 +1100 Subject: [PATCH 0333/3148] Suppress color_temp warning if color_temp_kelvin is provided (#136884) --- homeassistant/components/lifx/util.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/lifx/util.py b/homeassistant/components/lifx/util.py index 3d37f1c3bc5..8286622e6f3 100644 --- a/homeassistant/components/lifx/util.py +++ b/homeassistant/components/lifx/util.py @@ -113,7 +113,7 @@ def find_hsbk(hass: HomeAssistant, **kwargs: Any) -> list[float | int | None] | saturation = int(saturation / 100 * 65535) kelvin = 3500 - if _ATTR_COLOR_TEMP in kwargs: + if ATTR_COLOR_TEMP_KELVIN not in kwargs and _ATTR_COLOR_TEMP in kwargs: # added in 2025.1, can be removed in 2026.1 _LOGGER.warning( "The 'color_temp' parameter is deprecated. Please use 'color_temp_kelvin' for" From 3fb70316daadb48bcdfc6d1d71895006276f8458 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Fri, 31 Jan 2025 10:10:57 +0000 Subject: [PATCH 0334/3148] Fix error messaging for cascading service calls (#136966) --- homeassistant/components/websocket_api/commands.py | 8 ++++---- tests/components/websocket_api/test_commands.py | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/websocket_api/commands.py b/homeassistant/components/websocket_api/commands.py index cfa132b71eb..4a360b4a43c 100644 --- a/homeassistant/components/websocket_api/commands.py +++ b/homeassistant/components/websocket_api/commands.py @@ -275,10 +275,10 @@ async def handle_call_service( translation_domain=const.DOMAIN, translation_key="child_service_not_found", translation_placeholders={ - "domain": err.domain, - "service": err.service, - "child_domain": msg["domain"], - "child_service": msg["service"], + "domain": msg["domain"], + "service": msg["service"], + "child_domain": err.domain, + "child_service": err.service, }, ) except vol.Invalid as err: diff --git a/tests/components/websocket_api/test_commands.py b/tests/components/websocket_api/test_commands.py index 22e839d84e4..2ddb5c628c7 100644 --- a/tests/components/websocket_api/test_commands.py +++ b/tests/components/websocket_api/test_commands.py @@ -460,10 +460,10 @@ async def test_call_service_child_not_found( "domain_test.test_service which was not found." ) assert msg["error"]["translation_placeholders"] == { - "domain": "non", - "service": "existing", - "child_domain": "domain_test", - "child_service": "test_service", + "domain": "domain_test", + "service": "test_service", + "child_domain": "non", + "child_service": "existing", } assert msg["error"]["translation_key"] == "child_service_not_found" assert msg["error"]["translation_domain"] == "websocket_api" From 230e101ee4b4fdc691de4cd3911d742ff86e57fe Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Fri, 31 Jan 2025 11:23:33 +0100 Subject: [PATCH 0335/3148] Retry backup uploads in onedrive (#136980) * Retry backup uploads in onedrive * no exponential backup on timeout --- homeassistant/components/onedrive/backup.py | 34 ++++- tests/components/onedrive/conftest.py | 7 + tests/components/onedrive/test_backup.py | 138 +++++++++++++++++++- 3 files changed, 171 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/onedrive/backup.py b/homeassistant/components/onedrive/backup.py index 94d60bc6398..7f4bd5a0738 100644 --- a/homeassistant/components/onedrive/backup.py +++ b/homeassistant/components/onedrive/backup.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio from collections.abc import AsyncIterator, Callable, Coroutine from functools import wraps import html @@ -9,7 +10,7 @@ import json import logging from typing import Any, Concatenate, cast -from httpx import Response +from httpx import Response, TimeoutException from kiota_abstractions.api_error import APIError from kiota_abstractions.authentication import AnonymousAuthenticationProvider from kiota_abstractions.headers_collection import HeadersCollection @@ -42,6 +43,7 @@ from .const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN _LOGGER = logging.getLogger(__name__) UPLOAD_CHUNK_SIZE = 16 * 320 * 1024 # 5.2MB +MAX_RETRIES = 5 async def async_get_backup_agents( @@ -96,7 +98,7 @@ def handle_backup_errors[_R, **P]( ) _LOGGER.debug("Full error: %s", err, exc_info=True) raise BackupAgentError("Backup operation failed") from err - except TimeoutError as err: + except TimeoutException as err: _LOGGER.error( "Error during backup in %s: Timeout", func.__name__, @@ -268,6 +270,7 @@ class OneDriveBackupAgent(BackupAgent): start = 0 buffer: list[bytes] = [] buffer_size = 0 + retries = 0 async for chunk in stream: buffer.append(chunk) @@ -279,11 +282,28 @@ class OneDriveBackupAgent(BackupAgent): buffer_size > UPLOAD_CHUNK_SIZE ): # Loop in case the buffer is >= UPLOAD_CHUNK_SIZE * 2 slice_start = uploaded_chunks * UPLOAD_CHUNK_SIZE - await async_upload( - start, - start + UPLOAD_CHUNK_SIZE - 1, - chunk_data[slice_start : slice_start + UPLOAD_CHUNK_SIZE], - ) + try: + await async_upload( + start, + start + UPLOAD_CHUNK_SIZE - 1, + chunk_data[slice_start : slice_start + UPLOAD_CHUNK_SIZE], + ) + except APIError as err: + if ( + err.response_status_code and err.response_status_code < 500 + ): # no retry on 4xx errors + raise + if retries < MAX_RETRIES: + await asyncio.sleep(2**retries) + retries += 1 + continue + raise + except TimeoutException: + if retries < MAX_RETRIES: + retries += 1 + continue + raise + retries = 0 start += UPLOAD_CHUNK_SIZE uploaded_chunks += 1 buffer_size -= UPLOAD_CHUNK_SIZE diff --git a/tests/components/onedrive/conftest.py b/tests/components/onedrive/conftest.py index 65142217017..649966a7828 100644 --- a/tests/components/onedrive/conftest.py +++ b/tests/components/onedrive/conftest.py @@ -176,3 +176,10 @@ def mock_instance_id() -> Generator[AsyncMock]: return_value="9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0", ): yield + + +@pytest.fixture(autouse=True) +def mock_asyncio_sleep() -> Generator[AsyncMock]: + """Mock asyncio.sleep.""" + with patch("homeassistant.components.onedrive.backup.asyncio.sleep", AsyncMock()): + yield diff --git a/tests/components/onedrive/test_backup.py b/tests/components/onedrive/test_backup.py index 3492202d3fe..162ecb7d92a 100644 --- a/tests/components/onedrive/test_backup.py +++ b/tests/components/onedrive/test_backup.py @@ -8,8 +8,10 @@ from io import StringIO from json import dumps from unittest.mock import Mock, patch +from httpx import TimeoutException from kiota_abstractions.api_error import APIError from msgraph.generated.models.drive_item import DriveItem +from msgraph_core.models import LargeFileUploadSession import pytest from homeassistant.components.backup import DOMAIN as BACKUP_DOMAIN, AgentBackup @@ -255,6 +257,140 @@ async def test_broken_upload_session( assert "Failed to start backup upload" in caplog.text +@pytest.mark.parametrize( + "side_effect", + [ + APIError(response_status_code=500), + TimeoutException("Timeout"), + ], +) +async def test_agents_upload_errors_retried( + hass_client: ClientSessionGenerator, + caplog: pytest.LogCaptureFixture, + mock_drive_items: MagicMock, + mock_config_entry: MockConfigEntry, + mock_adapter: MagicMock, + side_effect: Exception, +) -> None: + """Test agent upload backup.""" + client = await hass_client() + test_backup = AgentBackup.from_dict(BACKUP_METADATA) + + mock_adapter.send_async.side_effect = [ + side_effect, + LargeFileUploadSession(next_expected_ranges=["2-"]), + LargeFileUploadSession(next_expected_ranges=["2-"]), + ] + + with ( + patch( + "homeassistant.components.backup.manager.BackupManager.async_get_backup", + ) as fetch_backup, + patch( + "homeassistant.components.backup.manager.read_backup", + return_value=test_backup, + ), + patch("pathlib.Path.open") as mocked_open, + patch("homeassistant.components.onedrive.backup.UPLOAD_CHUNK_SIZE", 3), + ): + mocked_open.return_value.read = Mock(side_effect=[b"test", b""]) + fetch_backup.return_value = test_backup + resp = await client.post( + f"/api/backup/upload?agent_id={DOMAIN}.{mock_config_entry.unique_id}", + data={"file": StringIO("test")}, + ) + + assert resp.status == 201 + assert mock_adapter.send_async.call_count == 3 + assert f"Uploading backup {test_backup.backup_id}" in caplog.text + mock_drive_items.patch.assert_called_once() + + +async def test_agents_upload_4xx_errors_not_retried( + hass_client: ClientSessionGenerator, + caplog: pytest.LogCaptureFixture, + mock_drive_items: MagicMock, + mock_config_entry: MockConfigEntry, + mock_adapter: MagicMock, +) -> None: + """Test agent upload backup.""" + client = await hass_client() + test_backup = AgentBackup.from_dict(BACKUP_METADATA) + + mock_adapter.send_async.side_effect = APIError(response_status_code=404) + + with ( + patch( + "homeassistant.components.backup.manager.BackupManager.async_get_backup", + ) as fetch_backup, + patch( + "homeassistant.components.backup.manager.read_backup", + return_value=test_backup, + ), + patch("pathlib.Path.open") as mocked_open, + patch("homeassistant.components.onedrive.backup.UPLOAD_CHUNK_SIZE", 3), + ): + mocked_open.return_value.read = Mock(side_effect=[b"test", b""]) + fetch_backup.return_value = test_backup + resp = await client.post( + f"/api/backup/upload?agent_id={DOMAIN}.{mock_config_entry.unique_id}", + data={"file": StringIO("test")}, + ) + + assert resp.status == 201 + assert mock_adapter.send_async.call_count == 1 + assert f"Uploading backup {test_backup.backup_id}" in caplog.text + assert mock_drive_items.patch.call_count == 0 + assert "Backup operation failed" in caplog.text + + +@pytest.mark.parametrize( + ("side_effect", "error"), + [ + (APIError(response_status_code=500), "Backup operation failed"), + (TimeoutException("Timeout"), "Backup operation timed out"), + ], +) +async def test_agents_upload_fails_after_max_retries( + hass_client: ClientSessionGenerator, + caplog: pytest.LogCaptureFixture, + mock_drive_items: MagicMock, + mock_config_entry: MockConfigEntry, + mock_adapter: MagicMock, + side_effect: Exception, + error: str, +) -> None: + """Test agent upload backup.""" + client = await hass_client() + test_backup = AgentBackup.from_dict(BACKUP_METADATA) + + mock_adapter.send_async.side_effect = side_effect + + with ( + patch( + "homeassistant.components.backup.manager.BackupManager.async_get_backup", + ) as fetch_backup, + patch( + "homeassistant.components.backup.manager.read_backup", + return_value=test_backup, + ), + patch("pathlib.Path.open") as mocked_open, + patch("homeassistant.components.onedrive.backup.UPLOAD_CHUNK_SIZE", 3), + ): + mocked_open.return_value.read = Mock(side_effect=[b"test", b""]) + fetch_backup.return_value = test_backup + resp = await client.post( + f"/api/backup/upload?agent_id={DOMAIN}.{mock_config_entry.unique_id}", + data={"file": StringIO("test")}, + ) + + assert resp.status == 201 + assert mock_adapter.send_async.call_count == 6 + assert f"Uploading backup {test_backup.backup_id}" in caplog.text + assert mock_drive_items.patch.call_count == 0 + assert error in caplog.text + + async def test_agents_download( hass_client: ClientSessionGenerator, mock_drive_items: MagicMock, @@ -282,7 +418,7 @@ async def test_agents_download( APIError(response_status_code=500), "Backup operation failed", ), - (TimeoutError(), "Backup operation timed out"), + (TimeoutException("Timeout"), "Backup operation timed out"), ], ) async def test_delete_error( From e57832705428ad8a7ffeef0c9706f2f6aeee57cb Mon Sep 17 00:00:00 2001 From: Markus Adrario Date: Fri, 31 Jan 2025 11:46:12 +0100 Subject: [PATCH 0336/3148] Add more Homee cover tests (#136568) --- tests/components/homee/__init__.py | 40 ++- tests/components/homee/conftest.py | 2 + tests/components/homee/fixtures/cover3.json | 101 ------ tests/components/homee/fixtures/cover4.json | 101 ------ ...r1.json => cover_with_position_slats.json} | 8 +- ...r2.json => cover_with_slats_position.json} | 100 ++---- .../fixtures/cover_without_position.json | 48 +++ tests/components/homee/test_cover.py | 329 ++++++++++++------ 8 files changed, 358 insertions(+), 371 deletions(-) delete mode 100644 tests/components/homee/fixtures/cover3.json delete mode 100644 tests/components/homee/fixtures/cover4.json rename tests/components/homee/fixtures/{cover1.json => cover_with_position_slats.json} (95%) rename tests/components/homee/fixtures/{cover2.json => cover_with_slats_position.json} (50%) create mode 100644 tests/components/homee/fixtures/cover_without_position.json diff --git a/tests/components/homee/__init__.py b/tests/components/homee/__init__.py index 95fc6099269..a5f8ae00d1e 100644 --- a/tests/components/homee/__init__.py +++ b/tests/components/homee/__init__.py @@ -1,8 +1,14 @@ """Tests for the homee component.""" +from typing import Any +from unittest.mock import AsyncMock + +from pyHomee.model import HomeeAttribute, HomeeNode + +from homeassistant.components.homee.const import DOMAIN from homeassistant.core import HomeAssistant -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, load_json_object_fixture async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: @@ -11,3 +17,35 @@ async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() + + +def build_mock_node(file: str) -> AsyncMock: + """Build a mocked Homee node from a json representation.""" + json_node = load_json_object_fixture(file, DOMAIN) + mock_node = AsyncMock(spec=HomeeNode) + + def get_attributes(attributes: list[Any]) -> list[AsyncMock]: + mock_attributes: list[AsyncMock] = [] + for attribute in attributes: + att = AsyncMock(spec=HomeeAttribute) + for key, value in attribute.items(): + setattr(att, key, value) + att.is_reversed = False + att.get_value = ( + lambda att=att: att.data if att.unit == "text" else att.current_value + ) + mock_attributes.append(att) + return mock_attributes + + for key, value in json_node.items(): + if key != "attributes": + setattr(mock_node, key, value) + + mock_node.attributes = get_attributes(json_node["attributes"]) + + def attribute_by_type(type, instance=0) -> HomeeAttribute | None: + return {attr.type: attr for attr in mock_node.attributes}.get(type) + + mock_node.get_attribute_by_type = attribute_by_type + + return mock_node diff --git a/tests/components/homee/conftest.py b/tests/components/homee/conftest.py index fb94ba0bbcc..5a3234e896b 100644 --- a/tests/components/homee/conftest.py +++ b/tests/components/homee/conftest.py @@ -61,6 +61,8 @@ def mock_homee() -> Generator[AsyncMock]: homee.settings = MagicMock() homee.settings.uid = HOMEE_ID homee.settings.homee_name = HOMEE_NAME + homee.settings.version = "1.2.3" + homee.settings.mac_address = "00:05:55:11:ee:cc" homee.reconnect_interval = 10 homee.connected = True diff --git a/tests/components/homee/fixtures/cover3.json b/tests/components/homee/fixtures/cover3.json deleted file mode 100644 index 0d3d5ea57e2..00000000000 --- a/tests/components/homee/fixtures/cover3.json +++ /dev/null @@ -1,101 +0,0 @@ -{ - "id": 3, - "name": "Test%20Cover", - "profile": 2002, - "image": "default", - "favorite": 0, - "order": 4, - "protocol": 23, - "routing": 0, - "state": 1, - "state_changed": 1687175681, - "added": 1672086680, - "history": 1, - "cube_type": 14, - "note": "TestCoverDevice", - "services": 7, - "phonetic_name": "", - "owner": 2, - "security": 0, - "attributes": [ - { - "id": 1, - "node_id": 3, - "instance": 0, - "minimum": 0, - "maximum": 4, - "current_value": 3.0, - "target_value": 0.0, - "last_value": 1.0, - "unit": "n%2Fa", - "step_value": 1.0, - "editable": 1, - "type": 135, - "state": 1, - "last_changed": 1687175680, - "changed_by": 1, - "changed_by_id": 0, - "based_on": 1, - "data": "", - "name": "", - "options": { - "can_observe": [300], - "observes": [75], - "automations": ["toggle"] - } - }, - { - "id": 2, - "node_id": 3, - "instance": 0, - "minimum": 0, - "maximum": 100, - "current_value": 75.0, - "target_value": 0.0, - "last_value": 100.0, - "unit": "%25", - "step_value": 0.5, - "editable": 1, - "type": 15, - "state": 1, - "last_changed": 1687175680, - "changed_by": 1, - "changed_by_id": 0, - "based_on": 1, - "data": "", - "name": "", - "options": { - "automations": ["step"], - "history": { - "day": 35, - "week": 5, - "month": 1 - } - } - }, - { - "id": 3, - "node_id": 3, - "instance": 0, - "minimum": -45, - "maximum": 90, - "current_value": 56.0, - "target_value": 56.0, - "last_value": 0.0, - "unit": "%C2%B0", - "step_value": 1.0, - "editable": 1, - "type": 113, - "state": 1, - "last_changed": 1678284920, - "changed_by": 1, - "changed_by_id": 0, - "based_on": 1, - "data": "", - "name": "", - "options": { - "automations": ["step"] - } - } - ] -} diff --git a/tests/components/homee/fixtures/cover4.json b/tests/components/homee/fixtures/cover4.json deleted file mode 100644 index a3de555794a..00000000000 --- a/tests/components/homee/fixtures/cover4.json +++ /dev/null @@ -1,101 +0,0 @@ -{ - "id": 3, - "name": "Test%20Cover", - "profile": 2002, - "image": "default", - "favorite": 0, - "order": 4, - "protocol": 23, - "routing": 0, - "state": 1, - "state_changed": 1687175681, - "added": 1672086680, - "history": 1, - "cube_type": 14, - "note": "TestCoverDevice", - "services": 7, - "phonetic_name": "", - "owner": 2, - "security": 0, - "attributes": [ - { - "id": 1, - "node_id": 3, - "instance": 0, - "minimum": 0, - "maximum": 4, - "current_value": 4.0, - "target_value": 1.0, - "last_value": 0.0, - "unit": "n%2Fa", - "step_value": 1.0, - "editable": 1, - "type": 135, - "state": 1, - "last_changed": 1687175680, - "changed_by": 1, - "changed_by_id": 0, - "based_on": 1, - "data": "", - "name": "", - "options": { - "can_observe": [300], - "observes": [75], - "automations": ["toggle"] - } - }, - { - "id": 2, - "node_id": 3, - "instance": 0, - "minimum": 0, - "maximum": 100, - "current_value": 25.0, - "target_value": 100.0, - "last_value": 0.0, - "unit": "%25", - "step_value": 0.5, - "editable": 1, - "type": 15, - "state": 1, - "last_changed": 1687175680, - "changed_by": 1, - "changed_by_id": 0, - "based_on": 1, - "data": "", - "name": "", - "options": { - "automations": ["step"], - "history": { - "day": 35, - "week": 5, - "month": 1 - } - } - }, - { - "id": 3, - "node_id": 3, - "instance": 0, - "minimum": -45, - "maximum": 90, - "current_value": -11.0, - "target_value": 0.0, - "last_value": -45.0, - "unit": "%C2%B0", - "step_value": 1.0, - "editable": 1, - "type": 113, - "state": 1, - "last_changed": 1678284920, - "changed_by": 1, - "changed_by_id": 0, - "based_on": 1, - "data": "", - "name": "", - "options": { - "automations": ["step"] - } - } - ] -} diff --git a/tests/components/homee/fixtures/cover1.json b/tests/components/homee/fixtures/cover_with_position_slats.json similarity index 95% rename from tests/components/homee/fixtures/cover1.json rename to tests/components/homee/fixtures/cover_with_position_slats.json index 8fedfb19d4f..8fd0d6f44fe 100644 --- a/tests/components/homee/fixtures/cover1.json +++ b/tests/components/homee/fixtures/cover_with_position_slats.json @@ -1,6 +1,6 @@ { "id": 3, - "name": "Test%20Cover", + "name": "Test Cover", "profile": 2002, "image": "default", "favorite": 0, @@ -27,7 +27,7 @@ "current_value": 1.0, "target_value": 1.0, "last_value": 4.0, - "unit": "n%2Fa", + "unit": "n/a", "step_value": 1.0, "editable": 1, "type": 135, @@ -53,7 +53,7 @@ "current_value": 0.0, "target_value": 0.0, "last_value": 0.0, - "unit": "%25", + "unit": "%", "step_value": 0.5, "editable": 1, "type": 15, @@ -82,7 +82,7 @@ "current_value": -45.0, "target_value": 0.0, "last_value": -45.0, - "unit": "%C2%B0", + "unit": "°", "step_value": 1.0, "editable": 1, "type": 113, diff --git a/tests/components/homee/fixtures/cover2.json b/tests/components/homee/fixtures/cover_with_slats_position.json similarity index 50% rename from tests/components/homee/fixtures/cover2.json rename to tests/components/homee/fixtures/cover_with_slats_position.json index b53c3d49b62..4b6eb466a85 100644 --- a/tests/components/homee/fixtures/cover2.json +++ b/tests/components/homee/fixtures/cover_with_slats_position.json @@ -1,19 +1,19 @@ { "id": 1, - "name": "Test%20Cover", + "name": "Test Slats", "profile": 2002, "image": "default", "favorite": 0, - "order": 4, + "order": 1, "protocol": 23, "routing": 0, "state": 1, - "state_changed": 1687175681, - "added": 1672086680, + "state_changed": 1676901608, + "added": 1672148537, "history": 1, "cube_type": 14, - "note": "TestCoverDevice", - "services": 7, + "note": "", + "services": 70, "phonetic_name": "", "owner": 2, "security": 0, @@ -22,67 +22,12 @@ "id": 1, "node_id": 1, "instance": 0, - "minimum": 0, - "maximum": 4, - "current_value": 1.0, - "target_value": 1.0, - "last_value": 0.0, - "unit": "n%2Fa", - "step_value": 1.0, - "editable": 1, - "type": 135, - "state": 1, - "last_changed": 1687175680, - "changed_by": 1, - "changed_by_id": 0, - "based_on": 1, - "data": "", - "name": "", - "options": { - "can_observe": [300], - "observes": [75], - "automations": ["toggle"] - } - }, - { - "id": 2, - "node_id": 1, - "instance": 0, - "minimum": 0, - "maximum": 100, - "current_value": 100.0, - "target_value": 0.0, - "last_value": 0.0, - "unit": "%25", - "step_value": 0.5, - "editable": 1, - "type": 15, - "state": 1, - "last_changed": 1687175680, - "changed_by": 1, - "changed_by_id": 0, - "based_on": 1, - "data": "", - "name": "", - "options": { - "automations": ["step"], - "history": { - "day": 35, - "week": 5, - "month": 1 - } - } - }, - { - "id": 3, - "node_id": 1, - "instance": 0, "minimum": -45, "maximum": 90, - "current_value": 90.0, - "target_value": 0.0, - "last_value": -45.0, - "unit": "%C2%B0", + "current_value": 1.0, + "target_value": 1.0, + "last_value": -21.0, + "unit": "°", "step_value": 1.0, "editable": 1, "type": 113, @@ -96,6 +41,31 @@ "options": { "automations": ["step"] } + }, + { + "id": 2, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 2, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 1.0, + "unit": "n/a", + "step_value": 1.0, + "editable": 1, + "type": 337, + "state": 1, + "last_changed": 1678284911, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "can_observe": [300], + "observes": [72] + } } ] } diff --git a/tests/components/homee/fixtures/cover_without_position.json b/tests/components/homee/fixtures/cover_without_position.json new file mode 100644 index 00000000000..e2bc6c7a38d --- /dev/null +++ b/tests/components/homee/fixtures/cover_without_position.json @@ -0,0 +1,48 @@ +{ + "id": 3, + "name": "Test Cover", + "profile": 2002, + "image": "default", + "favorite": 0, + "order": 4, + "protocol": 23, + "routing": 0, + "state": 1, + "state_changed": 1687175681, + "added": 1672086680, + "history": 1, + "cube_type": 14, + "note": "TestCoverDevice", + "services": 7, + "phonetic_name": "", + "owner": 2, + "security": 0, + "attributes": [ + { + "id": 1, + "node_id": 3, + "instance": 0, + "minimum": 0, + "maximum": 4, + "current_value": 1.0, + "target_value": 1.0, + "last_value": 4.0, + "unit": "n/a", + "step_value": 1.0, + "editable": 1, + "type": 135, + "state": 1, + "last_changed": 1687175680, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "can_observe": [300], + "observes": [75], + "automations": ["toggle"] + } + } + ] +} diff --git a/tests/components/homee/test_cover.py b/tests/components/homee/test_cover.py index a7feaa10b66..d52f3fa3164 100644 --- a/tests/components/homee/test_cover.py +++ b/tests/components/homee/test_cover.py @@ -1,97 +1,38 @@ """Test homee covers.""" -from unittest.mock import AsyncMock, MagicMock +from unittest.mock import MagicMock -from pyHomee import HomeeNode - -from homeassistant.components.cover import DOMAIN as COVER_DOMAIN, CoverState -from homeassistant.components.homee.const import DOMAIN -from homeassistant.const import ATTR_ENTITY_ID, SERVICE_CLOSE_COVER, SERVICE_OPEN_COVER +from homeassistant.components.cover import ( + ATTR_POSITION, + ATTR_TILT_POSITION, + DOMAIN as COVER_DOMAIN, + CoverEntityFeature, + CoverState, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_CLOSE_COVER, + SERVICE_CLOSE_COVER_TILT, + SERVICE_OPEN_COVER, + SERVICE_OPEN_COVER_TILT, + SERVICE_SET_COVER_POSITION, + SERVICE_SET_COVER_TILT_POSITION, + SERVICE_STOP_COVER, +) from homeassistant.core import HomeAssistant -from . import setup_integration +from . import build_mock_node, setup_integration -from tests.common import MockConfigEntry, load_json_object_fixture +from tests.common import MockConfigEntry -async def test_cover_open( - hass: HomeAssistant, mock_homee: AsyncMock, mock_config_entry: MockConfigEntry -) -> None: - """Test an open cover.""" - # Cover open, tilt open. - cover_json = load_json_object_fixture("cover1.json", DOMAIN) - cover_node = HomeeNode(cover_json) - mock_homee.nodes = [cover_node] - - await setup_integration(hass, mock_config_entry) - - assert hass.states.get("cover.test_cover").state == CoverState.OPEN - - attributes = hass.states.get("cover.test_cover").attributes - assert attributes.get("supported_features") == 143 - assert attributes.get("current_position") == 100 - assert attributes.get("current_tilt_position") == 100 - - -async def test_cover_closed( - hass: HomeAssistant, mock_homee: MagicMock, mock_config_entry: MockConfigEntry -) -> None: - """Test a closed cover.""" - # Cover closed, tilt closed. - cover_json = load_json_object_fixture("cover2.json", DOMAIN) - cover_node = HomeeNode(cover_json) - mock_homee.nodes = [cover_node] - - await setup_integration(hass, mock_config_entry) - - assert hass.states.get("cover.test_cover").state == CoverState.CLOSED - attributes = hass.states.get("cover.test_cover").attributes - assert attributes.get("current_position") == 0 - assert attributes.get("current_tilt_position") == 0 - - -async def test_cover_opening( - hass: HomeAssistant, mock_homee: MagicMock, mock_config_entry: MockConfigEntry -) -> None: - """Test an opening cover.""" - # opening, 75% homee / 25% HA - cover_json = load_json_object_fixture("cover3.json", DOMAIN) - cover_node = HomeeNode(cover_json) - mock_homee.nodes = [cover_node] - - await setup_integration(hass, mock_config_entry) - - assert hass.states.get("cover.test_cover").state == CoverState.OPENING - attributes = hass.states.get("cover.test_cover").attributes - assert attributes.get("current_position") == 25 - assert attributes.get("current_tilt_position") == 25 - - -async def test_cover_closing( - hass: HomeAssistant, mock_homee: MagicMock, mock_config_entry: MockConfigEntry -) -> None: - """Test a closing cover.""" - # closing, 25% homee / 75% HA - cover_json = load_json_object_fixture("cover4.json", DOMAIN) - cover_node = HomeeNode(cover_json) - mock_homee.nodes = [cover_node] - - await setup_integration(hass, mock_config_entry) - - assert hass.states.get("cover.test_cover").state == CoverState.CLOSING - attributes = hass.states.get("cover.test_cover").attributes - assert attributes.get("current_position") == 75 - assert attributes.get("current_tilt_position") == 74 - - -async def test_open_cover( - hass: HomeAssistant, mock_homee: MagicMock, mock_config_entry: MockConfigEntry +async def test_open_close_stop_cover( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, ) -> None: """Test opening the cover.""" - # Cover closed, tilt closed. - cover_json = load_json_object_fixture("cover2.json", DOMAIN) - cover_node = HomeeNode(cover_json) - mock_homee.nodes = [cover_node] + mock_homee.nodes = [build_mock_node("cover_with_position_slats.json")] await setup_integration(hass, mock_config_entry) @@ -101,24 +42,214 @@ async def test_open_cover( {ATTR_ENTITY_ID: "cover.test_cover"}, blocking=True, ) - mock_homee.set_value.assert_called_once_with(cover_node.id, 1, 0) - - -async def test_close_cover( - hass: HomeAssistant, mock_homee: MagicMock, mock_config_entry: MockConfigEntry -) -> None: - """Test opening the cover.""" - # Cover open, tilt open. - cover_json = load_json_object_fixture("cover1.json", DOMAIN) - cover_node = HomeeNode(cover_json) - mock_homee.nodes = [cover_node] - - await setup_integration(hass, mock_config_entry) - await hass.services.async_call( COVER_DOMAIN, SERVICE_CLOSE_COVER, {ATTR_ENTITY_ID: "cover.test_cover"}, blocking=True, ) - mock_homee.set_value.assert_called_once_with(cover_node.id, 1, 1) + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_STOP_COVER, + {ATTR_ENTITY_ID: "cover.test_cover"}, + blocking=True, + ) + + calls = mock_homee.set_value.call_args_list + for index, call in enumerate(calls): + assert call[0] == (mock_homee.nodes[0].id, 1, index) + + +async def test_set_cover_position( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setting the cover position.""" + mock_homee.nodes = [build_mock_node("cover_with_position_slats.json")] + + await setup_integration(hass, mock_config_entry) + + # Slats have a range of -45 to 90. + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_SET_COVER_POSITION, + {ATTR_ENTITY_ID: "cover.test_slats", ATTR_POSITION: 100}, + blocking=True, + ) + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_SET_COVER_POSITION, + {ATTR_ENTITY_ID: "cover.test_slats", ATTR_POSITION: 0}, + blocking=True, + ) + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_SET_COVER_POSITION, + {ATTR_ENTITY_ID: "cover.test_slats", ATTR_POSITION: 50}, + blocking=True, + ) + + calls = mock_homee.set_value.call_args_list + positions = [0, 100, 50] + for call in calls: + assert call[0] == (1, 2, positions.pop(0)) + + +async def test_close_open_slats( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test closing and opening slats.""" + mock_homee.nodes = [build_mock_node("cover_with_slats_position.json")] + + await setup_integration(hass, mock_config_entry) + + attributes = hass.states.get("cover.test_slats").attributes + assert attributes.get("supported_features") == ( + CoverEntityFeature.OPEN_TILT + | CoverEntityFeature.CLOSE_TILT + | CoverEntityFeature.SET_TILT_POSITION + ) + + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER_TILT, + {ATTR_ENTITY_ID: "cover.test_slats"}, + blocking=True, + ) + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER_TILT, + {ATTR_ENTITY_ID: "cover.test_slats"}, + blocking=True, + ) + + calls = mock_homee.set_value.call_args_list + for index, call in enumerate(calls, start=1): + assert call[0] == (mock_homee.nodes[0].id, 2, index) + + +async def test_set_slat_position( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setting slats position.""" + mock_homee.nodes = [build_mock_node("cover_with_slats_position.json")] + + await setup_integration(hass, mock_config_entry) + + # Slats have a range of -45 to 90 on this device. + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_SET_COVER_TILT_POSITION, + {ATTR_ENTITY_ID: "cover.test_slats", ATTR_TILT_POSITION: 100}, + blocking=True, + ) + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_SET_COVER_TILT_POSITION, + {ATTR_ENTITY_ID: "cover.test_slats", ATTR_TILT_POSITION: 0}, + blocking=True, + ) + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_SET_COVER_TILT_POSITION, + {ATTR_ENTITY_ID: "cover.test_slats", ATTR_TILT_POSITION: 50}, + blocking=True, + ) + + calls = mock_homee.set_value.call_args_list + positions = [-45, 90, 22.5] + for call in calls: + assert call[0] == (1, 1, positions.pop(0)) + + +async def test_cover_positions( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test an open cover.""" + # Cover open, tilt open. + # mock_homee.nodes = [cover] + mock_homee.nodes = [build_mock_node("cover_with_position_slats.json")] + cover = mock_homee.nodes[0] + + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("cover.test_cover").state == CoverState.OPEN + + attributes = hass.states.get("cover.test_cover").attributes + assert attributes.get("supported_features") == ( + CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE + | CoverEntityFeature.SET_POSITION + | CoverEntityFeature.STOP + | CoverEntityFeature.SET_TILT_POSITION + ) + assert attributes.get("current_position") == 100 + assert attributes.get("current_tilt_position") == 100 + + cover.attributes[0].current_value = 1 + cover.attributes[1].current_value = 100 + cover.attributes[2].current_value = 90 + cover.add_on_changed_listener.call_args_list[0][0][0](cover) + await hass.async_block_till_done() + + attributes = hass.states.get("cover.test_cover").attributes + assert attributes.get("current_position") == 0 + assert attributes.get("current_tilt_position") == 0 + assert hass.states.get("cover.test_cover").state == CoverState.CLOSED + + cover.attributes[0].current_value = 3 + cover.attributes[1].current_value = 75 + cover.attributes[2].current_value = 56 + cover.add_on_changed_listener.call_args_list[0][0][0](cover) + await hass.async_block_till_done() + + assert hass.states.get("cover.test_cover").state == CoverState.OPENING + attributes = hass.states.get("cover.test_cover").attributes + assert attributes.get("current_position") == 25 + assert attributes.get("current_tilt_position") == 25 + + cover.attributes[0].current_value = 4 + cover.attributes[1].current_value = 25 + cover.attributes[2].current_value = -11 + cover.add_on_changed_listener.call_args_list[0][0][0](cover) + await hass.async_block_till_done() + + assert hass.states.get("cover.test_cover").state == CoverState.CLOSING + attributes = hass.states.get("cover.test_cover").attributes + assert attributes.get("current_position") == 75 + assert attributes.get("current_tilt_position") == 74 + + +async def test_reversed_cover( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test a cover with inverted UP_DOWN attribute without position.""" + mock_homee.nodes = [build_mock_node("cover_without_position.json")] + cover = mock_homee.nodes[0] + + await setup_integration(hass, mock_config_entry) + + cover.attributes[0].is_reversed = True + cover.add_on_changed_listener.call_args_list[0][0][0](cover) + await hass.async_block_till_done() + + attributes = hass.states.get("cover.test_cover").attributes + assert attributes.get("supported_features") == ( + CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE | CoverEntityFeature.STOP + ) + assert hass.states.get("cover.test_cover").state == CoverState.OPEN + + cover.attributes[0].current_value = 0 + cover.add_on_changed_listener.call_args_list[0][0][0](cover) + await hass.async_block_till_done() + + assert hass.states.get("cover.test_cover").state == CoverState.CLOSED From e512ad7a81f559c088d0789f3024fd4cd22396c5 Mon Sep 17 00:00:00 2001 From: Cyrill Raccaud Date: Fri, 31 Jan 2025 12:10:44 +0100 Subject: [PATCH 0337/3148] Fix missing duration translation for Swiss public transport integration (#136982) --- .../swiss_public_transport/icons.json | 2 +- .../swiss_public_transport/sensor.py | 2 + .../swiss_public_transport/strings.json | 4 +- .../snapshots/test_sensor.ambr | 101 +++++++++--------- .../swiss_public_transport/test_sensor.py | 2 +- 5 files changed, 58 insertions(+), 53 deletions(-) diff --git a/homeassistant/components/swiss_public_transport/icons.json b/homeassistant/components/swiss_public_transport/icons.json index 06a640a06b2..45cf4713705 100644 --- a/homeassistant/components/swiss_public_transport/icons.json +++ b/homeassistant/components/swiss_public_transport/icons.json @@ -10,7 +10,7 @@ "departure2": { "default": "mdi:bus-clock" }, - "duration": { + "trip_duration": { "default": "mdi:timeline-clock" }, "transfers": { diff --git a/homeassistant/components/swiss_public_transport/sensor.py b/homeassistant/components/swiss_public_transport/sensor.py index a0131938a37..c8075a6746c 100644 --- a/homeassistant/components/swiss_public_transport/sensor.py +++ b/homeassistant/components/swiss_public_transport/sensor.py @@ -56,8 +56,10 @@ SENSORS: tuple[SwissPublicTransportSensorEntityDescription, ...] = ( ], SwissPublicTransportSensorEntityDescription( key="duration", + translation_key="trip_duration", device_class=SensorDeviceClass.DURATION, native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.HOURS, value_fn=lambda data_connection: data_connection["duration"], ), SwissPublicTransportSensorEntityDescription( diff --git a/homeassistant/components/swiss_public_transport/strings.json b/homeassistant/components/swiss_public_transport/strings.json index ef8cc5595e3..270cb097e0a 100644 --- a/homeassistant/components/swiss_public_transport/strings.json +++ b/homeassistant/components/swiss_public_transport/strings.json @@ -64,8 +64,8 @@ "departure2": { "name": "Departure +2" }, - "duration": { - "name": "Duration" + "trip_duration": { + "name": "Trip duration" }, "transfers": { "name": "Transfers" diff --git a/tests/components/swiss_public_transport/snapshots/test_sensor.ambr b/tests/components/swiss_public_transport/snapshots/test_sensor.ambr index dbd689fc8f6..b8ad82c7b79 100644 --- a/tests/components/swiss_public_transport/snapshots/test_sensor.ambr +++ b/tests/components/swiss_public_transport/snapshots/test_sensor.ambr @@ -192,55 +192,6 @@ 'state': '2024-01-06T17:05:00+00:00', }) # --- -# name: test_all_entities[sensor.zurich_bern_duration-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.zurich_bern_duration', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Duration', - 'platform': 'swiss_public_transport', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'Zürich Bern_duration', - 'unit_of_measurement': , - }) -# --- -# name: test_all_entities[sensor.zurich_bern_duration-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by transport.opendata.ch', - 'device_class': 'duration', - 'friendly_name': 'Zürich Bern Duration', - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.zurich_bern_duration', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '10', - }) -# --- # name: test_all_entities[sensor.zurich_bern_line-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -382,3 +333,55 @@ 'state': '0', }) # --- +# name: test_all_entities[sensor.zurich_bern_trip_duration-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.zurich_bern_trip_duration', + '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': 'Trip duration', + 'platform': 'swiss_public_transport', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'trip_duration', + 'unique_id': 'Zürich Bern_duration', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.zurich_bern_trip_duration-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by transport.opendata.ch', + 'device_class': 'duration', + 'friendly_name': 'Zürich Bern Trip duration', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.zurich_bern_trip_duration', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.003', + }) +# --- diff --git a/tests/components/swiss_public_transport/test_sensor.py b/tests/components/swiss_public_transport/test_sensor.py index 4afdd88c9de..6e832728277 100644 --- a/tests/components/swiss_public_transport/test_sensor.py +++ b/tests/components/swiss_public_transport/test_sensor.py @@ -83,7 +83,7 @@ async def test_fetching_data( hass.states.get("sensor.zurich_bern_departure_2").state == "2024-01-06T17:05:00+00:00" ) - assert hass.states.get("sensor.zurich_bern_duration").state == "10" + assert hass.states.get("sensor.zurich_bern_trip_duration").state == "0.003" assert hass.states.get("sensor.zurich_bern_platform").state == "0" assert hass.states.get("sensor.zurich_bern_transfers").state == "0" assert hass.states.get("sensor.zurich_bern_delay").state == "0" From 010cad08c05988dbcedff9232fb4f76d4ce1f691 Mon Sep 17 00:00:00 2001 From: Gerben Jongerius Date: Fri, 31 Jan 2025 12:12:07 +0100 Subject: [PATCH 0338/3148] Add tariff sensor and peak sensors (#136919) --- homeassistant/components/youless/sensor.py | 34 +++- homeassistant/components/youless/strings.json | 9 + tests/components/youless/__init__.py | 5 + tests/components/youless/fixtures/device.json | 2 +- tests/components/youless/fixtures/phase.json | 15 ++ .../youless/snapshots/test_sensor.ambr | 176 +++++++++++++++++- tests/components/youless/test_init.py | 2 +- 7 files changed, 231 insertions(+), 12 deletions(-) create mode 100644 tests/components/youless/fixtures/phase.json diff --git a/homeassistant/components/youless/sensor.py b/homeassistant/components/youless/sensor.py index 3afb215ed5f..db8244c0b06 100644 --- a/homeassistant/components/youless/sensor.py +++ b/homeassistant/components/youless/sensor.py @@ -36,7 +36,7 @@ class YouLessSensorEntityDescription(SensorEntityDescription): """Describes a YouLess sensor entity.""" device_group: str - value_func: Callable[[YoulessAPI], float | None] + value_func: Callable[[YoulessAPI], float | None | str] SENSOR_TYPES: tuple[YouLessSensorEntityDescription, ...] = ( @@ -212,6 +212,38 @@ SENSOR_TYPES: tuple[YouLessSensorEntityDescription, ...] = ( lambda device: device.phase3.current.value if device.phase1 else None ), ), + YouLessSensorEntityDescription( + key="tariff", + device_group="power", + translation_key="active_tariff", + device_class=SensorDeviceClass.ENUM, + options=["1", "2"], + value_func=( + lambda device: str(device.current_tariff) if device.current_tariff else None + ), + ), + YouLessSensorEntityDescription( + key="average_peak", + device_group="power", + translation_key="average_peak", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPower.WATT, + value_func=( + lambda device: device.average_power.value if device.average_power else None + ), + ), + YouLessSensorEntityDescription( + key="month_peak", + device_group="power", + translation_key="month_peak", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPower.WATT, + value_func=( + lambda device: device.peak_power.value if device.peak_power else None + ), + ), YouLessSensorEntityDescription( key="delivery_low", device_group="delivery", diff --git a/homeassistant/components/youless/strings.json b/homeassistant/components/youless/strings.json index 8a3f6cb5d8b..c735e2b2ff2 100644 --- a/homeassistant/components/youless/strings.json +++ b/homeassistant/components/youless/strings.json @@ -52,6 +52,9 @@ "active_current_phase_a": { "name": "Current phase {phase}" }, + "active_tariff": { + "name": "Tariff" + }, "total_energy_import_tariff_kwh": { "name": "Energy import tariff {tariff}" }, @@ -66,6 +69,12 @@ }, "active_s0_w": { "name": "Current usage" + }, + "average_peak": { + "name": "Average peak" + }, + "month_peak": { + "name": "Month peak" } } } diff --git a/tests/components/youless/__init__.py b/tests/components/youless/__init__.py index 8770a7e2dc8..03db24cb7f7 100644 --- a/tests/components/youless/__init__.py +++ b/tests/components/youless/__init__.py @@ -25,6 +25,11 @@ async def init_component(hass: HomeAssistant) -> MockConfigEntry: json=load_json_array_fixture("enologic.json", youless.DOMAIN), headers={"Content-Type": "application/json"}, ) + mock.get( + "http://1.1.1.1/f", + json=load_json_object_fixture("phase.json", youless.DOMAIN), + headers={"Content-Type": "application/json"}, + ) entry = MockConfigEntry( domain=youless.DOMAIN, diff --git a/tests/components/youless/fixtures/device.json b/tests/components/youless/fixtures/device.json index 7d089851923..82d07dba739 100644 --- a/tests/components/youless/fixtures/device.json +++ b/tests/components/youless/fixtures/device.json @@ -1,5 +1,5 @@ { "model": "LS120", - "fw": "1.4.2-EL", + "fw": "1.5.1-EL", "mac": "de2:2d2:3d23" } diff --git a/tests/components/youless/fixtures/phase.json b/tests/components/youless/fixtures/phase.json new file mode 100644 index 00000000000..8a5aa3215ef --- /dev/null +++ b/tests/components/youless/fixtures/phase.json @@ -0,0 +1,15 @@ +{ + "tr": 1, + "i1": 0.123, + "v1": 240, + "l1": 462, + "v2": 240, + "l2": 230, + "i2": 0.123, + "v3": 240, + "l3": 230, + "i3": 0.123, + "pp": 1200, + "pts": 2501301621, + "pa": 400 +} diff --git a/tests/components/youless/snapshots/test_sensor.ambr b/tests/components/youless/snapshots/test_sensor.ambr index 0647d854d2a..9e79b5b9b5e 100644 --- a/tests/components/youless/snapshots/test_sensor.ambr +++ b/tests/components/youless/snapshots/test_sensor.ambr @@ -152,6 +152,57 @@ 'state': '1624.264', }) # --- +# name: test_sensors[sensor.power_meter_average_peak-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.power_meter_average_peak', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Average peak', + 'platform': 'youless', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'average_peak', + 'unique_id': 'youless_localhost_average_peak', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.power_meter_average_peak-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Power meter Average peak', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.power_meter_average_peak', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '400', + }) +# --- # name: test_sensors[sensor.power_meter_current_phase_1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -200,7 +251,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '0.123', }) # --- # name: test_sensors[sensor.power_meter_current_phase_2-entry] @@ -251,7 +302,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '0.123', }) # --- # name: test_sensors[sensor.power_meter_current_phase_3-entry] @@ -302,7 +353,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '0.123', }) # --- # name: test_sensors[sensor.power_meter_current_power_usage-entry] @@ -458,6 +509,57 @@ 'state': '4490.631', }) # --- +# name: test_sensors[sensor.power_meter_month_peak-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.power_meter_month_peak', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Month peak', + 'platform': 'youless', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'month_peak', + 'unique_id': 'youless_localhost_month_peak', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.power_meter_month_peak-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Power meter Month peak', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.power_meter_month_peak', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1200', + }) +# --- # name: test_sensors[sensor.power_meter_power_phase_1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -506,7 +608,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '462', }) # --- # name: test_sensors[sensor.power_meter_power_phase_2-entry] @@ -557,7 +659,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '230', }) # --- # name: test_sensors[sensor.power_meter_power_phase_3-entry] @@ -608,7 +710,63 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '230', + }) +# --- +# name: test_sensors[sensor.power_meter_tariff-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + '1', + '2', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.power_meter_tariff', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tariff', + 'platform': 'youless', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_tariff', + 'unique_id': 'youless_localhost_tariff', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.power_meter_tariff-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Power meter Tariff', + 'options': list([ + '1', + '2', + ]), + }), + 'context': , + 'entity_id': 'sensor.power_meter_tariff', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1', }) # --- # name: test_sensors[sensor.power_meter_total_energy_import-entry] @@ -710,7 +868,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '240', }) # --- # name: test_sensors[sensor.power_meter_voltage_phase_2-entry] @@ -761,7 +919,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '240', }) # --- # name: test_sensors[sensor.power_meter_voltage_phase_3-entry] @@ -812,7 +970,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '240', }) # --- # name: test_sensors[sensor.s0_meter_current_usage-entry] diff --git a/tests/components/youless/test_init.py b/tests/components/youless/test_init.py index 29db8c66af0..9f0956cea35 100644 --- a/tests/components/youless/test_init.py +++ b/tests/components/youless/test_init.py @@ -15,4 +15,4 @@ async def test_async_setup_entry(hass: HomeAssistant) -> None: assert await setup.async_setup_component(hass, youless.DOMAIN, {}) assert entry.state is ConfigEntryState.LOADED - assert len(hass.states.async_entity_ids()) == 19 + assert len(hass.states.async_entity_ids()) == 22 From a7903d344f2889dc95001ab259d45fce52218ccf Mon Sep 17 00:00:00 2001 From: Jan Stienstra <65826735+RunC0deRun@users.noreply.github.com> Date: Fri, 31 Jan 2025 12:29:00 +0100 Subject: [PATCH 0339/3148] Bump jellyfin-apiclient-python to 1.10.0 (#136872) --- homeassistant/components/jellyfin/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/jellyfin/manifest.json b/homeassistant/components/jellyfin/manifest.json index 19358cff17c..810b9ea45a9 100644 --- a/homeassistant/components/jellyfin/manifest.json +++ b/homeassistant/components/jellyfin/manifest.json @@ -7,6 +7,6 @@ "integration_type": "service", "iot_class": "local_polling", "loggers": ["jellyfin_apiclient_python"], - "requirements": ["jellyfin-apiclient-python==1.9.2"], + "requirements": ["jellyfin-apiclient-python==1.10.0"], "single_config_entry": true } diff --git a/requirements_all.txt b/requirements_all.txt index a60f19c6ef3..8a579ca6ba0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1247,7 +1247,7 @@ israel-rail-api==0.1.2 jaraco.abode==6.2.1 # homeassistant.components.jellyfin -jellyfin-apiclient-python==1.9.2 +jellyfin-apiclient-python==1.10.0 # homeassistant.components.command_line # homeassistant.components.rest diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9c461aabfe8..7612c8466d3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1058,7 +1058,7 @@ israel-rail-api==0.1.2 jaraco.abode==6.2.1 # homeassistant.components.jellyfin -jellyfin-apiclient-python==1.9.2 +jellyfin-apiclient-python==1.10.0 # homeassistant.components.command_line # homeassistant.components.rest From 50f3d79fb21495d1f6d6d52b7dc858c7bfea7fb9 Mon Sep 17 00:00:00 2001 From: Andrew Jackson Date: Fri, 31 Jan 2025 11:29:23 +0000 Subject: [PATCH 0340/3148] Add post action to mastodon (#134788) Co-authored-by: Joost Lekkerkerker --- homeassistant/components/mastodon/__init__.py | 24 +- homeassistant/components/mastodon/const.py | 7 + .../components/mastodon/coordinator.py | 15 ++ homeassistant/components/mastodon/icons.json | 5 + homeassistant/components/mastodon/notify.py | 68 +++-- .../components/mastodon/quality_scale.yaml | 8 +- homeassistant/components/mastodon/services.py | 142 ++++++++++ .../components/mastodon/services.yaml | 30 +++ .../components/mastodon/strings.json | 65 +++++ homeassistant/components/mastodon/utils.py | 11 + tests/components/mastodon/test_notify.py | 27 ++ tests/components/mastodon/test_services.py | 246 ++++++++++++++++++ 12 files changed, 607 insertions(+), 41 deletions(-) create mode 100644 homeassistant/components/mastodon/services.py create mode 100644 homeassistant/components/mastodon/services.yaml create mode 100644 tests/components/mastodon/test_services.py diff --git a/homeassistant/components/mastodon/__init__.py b/homeassistant/components/mastodon/__init__.py index f7f974ffbb0..2f713a97dfe 100644 --- a/homeassistant/components/mastodon/__init__.py +++ b/homeassistant/components/mastodon/__init__.py @@ -2,11 +2,8 @@ from __future__ import annotations -from dataclasses import dataclass - from mastodon.Mastodon import Mastodon, MastodonError -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_ACCESS_TOKEN, CONF_CLIENT_ID, @@ -16,27 +13,24 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import discovery +from homeassistant.helpers import config_validation as cv, discovery +from homeassistant.helpers.typing import ConfigType from homeassistant.util import slugify from .const import CONF_BASE_URL, DOMAIN, LOGGER -from .coordinator import MastodonCoordinator +from .coordinator import MastodonConfigEntry, MastodonCoordinator, MastodonData +from .services import setup_services from .utils import construct_mastodon_username, create_mastodon_client PLATFORMS: list[Platform] = [Platform.NOTIFY, Platform.SENSOR] - -@dataclass -class MastodonData: - """Mastodon data type.""" - - client: Mastodon - instance: dict - account: dict - coordinator: MastodonCoordinator +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) -type MastodonConfigEntry = ConfigEntry[MastodonData] +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the Mastodon component.""" + setup_services(hass) + return True async def async_setup_entry(hass: HomeAssistant, entry: MastodonConfigEntry) -> bool: diff --git a/homeassistant/components/mastodon/const.py b/homeassistant/components/mastodon/const.py index e0593d15d2c..b7e86eaad5a 100644 --- a/homeassistant/components/mastodon/const.py +++ b/homeassistant/components/mastodon/const.py @@ -19,3 +19,10 @@ ACCOUNT_USERNAME: Final = "username" ACCOUNT_FOLLOWERS_COUNT: Final = "followers_count" ACCOUNT_FOLLOWING_COUNT: Final = "following_count" ACCOUNT_STATUSES_COUNT: Final = "statuses_count" + +ATTR_CONFIG_ENTRY_ID = "config_entry_id" +ATTR_STATUS = "status" +ATTR_VISIBILITY = "visibility" +ATTR_CONTENT_WARNING = "content_warning" +ATTR_MEDIA_WARNING = "media_warning" +ATTR_MEDIA = "media" diff --git a/homeassistant/components/mastodon/coordinator.py b/homeassistant/components/mastodon/coordinator.py index f1332a0ea43..4c6fe6b1c88 100644 --- a/homeassistant/components/mastodon/coordinator.py +++ b/homeassistant/components/mastodon/coordinator.py @@ -2,18 +2,33 @@ from __future__ import annotations +from dataclasses import dataclass from datetime import timedelta from typing import Any from mastodon import Mastodon from mastodon.Mastodon import MastodonError +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import LOGGER +@dataclass +class MastodonData: + """Mastodon data type.""" + + client: Mastodon + instance: dict + account: dict + coordinator: MastodonCoordinator + + +type MastodonConfigEntry = ConfigEntry[MastodonData] + + class MastodonCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Class to manage fetching Mastodon data.""" diff --git a/homeassistant/components/mastodon/icons.json b/homeassistant/components/mastodon/icons.json index 082e27a64c2..e7272c2b6f8 100644 --- a/homeassistant/components/mastodon/icons.json +++ b/homeassistant/components/mastodon/icons.json @@ -11,5 +11,10 @@ "default": "mdi:message-text" } } + }, + "services": { + "post": { + "service": "mdi:message-text" + } } } diff --git a/homeassistant/components/mastodon/notify.py b/homeassistant/components/mastodon/notify.py index dd76d44a02c..8e7e9dc1947 100644 --- a/homeassistant/components/mastodon/notify.py +++ b/homeassistant/components/mastodon/notify.py @@ -2,7 +2,6 @@ from __future__ import annotations -import mimetypes from typing import Any, cast from mastodon import Mastodon @@ -16,15 +15,21 @@ from homeassistant.components.notify import ( ) from homeassistant.const import CONF_ACCESS_TOKEN, CONF_CLIENT_ID, CONF_CLIENT_SECRET from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_validation as cv +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import config_validation as cv, issue_registry as ir from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .const import CONF_BASE_URL, DEFAULT_URL, LOGGER +from .const import ( + ATTR_CONTENT_WARNING, + ATTR_MEDIA_WARNING, + CONF_BASE_URL, + DEFAULT_URL, + DOMAIN, +) +from .utils import get_media_type ATTR_MEDIA = "media" ATTR_TARGET = "target" -ATTR_MEDIA_WARNING = "media_warning" -ATTR_CONTENT_WARNING = "content_warning" PLATFORM_SCHEMA = NOTIFY_PLATFORM_SCHEMA.extend( { @@ -67,6 +72,17 @@ class MastodonNotificationService(BaseNotificationService): def send_message(self, message: str = "", **kwargs: Any) -> None: """Toot a message, with media perhaps.""" + ir.create_issue( + self.hass, + DOMAIN, + "deprecated_notify_action_mastodon", + breaks_in_ha_version="2025.9.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=ir.IssueSeverity.WARNING, + translation_key="deprecated_notify_action", + ) + target = None if (target_list := kwargs.get(ATTR_TARGET)) is not None: target = cast(list[str], target_list)[0] @@ -82,8 +98,11 @@ class MastodonNotificationService(BaseNotificationService): media = data.get(ATTR_MEDIA) if media: if not self.hass.config.is_allowed_path(media): - LOGGER.warning("'%s' is not a whitelisted directory", media) - return + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="not_whitelisted_directory", + translation_placeholders={"media": media}, + ) mediadata = self._upload_media(media) sensitive = data.get(ATTR_MEDIA_WARNING) @@ -93,34 +112,39 @@ class MastodonNotificationService(BaseNotificationService): try: self.client.status_post( message, - media_ids=mediadata["id"], - sensitive=sensitive, visibility=target, spoiler_text=content_warning, + media_ids=mediadata["id"], + sensitive=sensitive, ) - except MastodonAPIError: - LOGGER.error("Unable to send message") + except MastodonAPIError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="unable_to_send_message", + ) from err + else: try: self.client.status_post( message, visibility=target, spoiler_text=content_warning ) - except MastodonAPIError: - LOGGER.error("Unable to send message") + except MastodonAPIError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="unable_to_send_message", + ) from err def _upload_media(self, media_path: Any = None) -> Any: """Upload media.""" with open(media_path, "rb"): - media_type = self._media_type(media_path) + media_type = get_media_type(media_path) try: mediadata = self.client.media_post(media_path, mime_type=media_type) - except MastodonAPIError: - LOGGER.error("Unable to upload image %s", media_path) + except MastodonAPIError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="unable_to_upload_image", + translation_placeholders={"media_path": media_path}, + ) from err return mediadata - - def _media_type(self, media_path: Any = None) -> Any: - """Get media Type.""" - (media_type, _) = mimetypes.guess_type(media_path) - - return media_type diff --git a/homeassistant/components/mastodon/quality_scale.yaml b/homeassistant/components/mastodon/quality_scale.yaml index 86702095e95..43636ed6924 100644 --- a/homeassistant/components/mastodon/quality_scale.yaml +++ b/homeassistant/components/mastodon/quality_scale.yaml @@ -29,7 +29,7 @@ rules: action-exceptions: status: todo comment: | - Legacy Notify needs rewriting once Notify architecture stabilizes. + Awaiting legacy Notify deprecation. config-entry-unloading: done docs-configuration-parameters: status: exempt @@ -42,7 +42,7 @@ rules: parallel-updates: status: todo comment: | - Does not set parallel-updates on notify platform. + Awaiting legacy Notify deprecation. reauthentication-flow: status: todo comment: | @@ -50,7 +50,7 @@ rules: test-coverage: status: todo comment: | - Legacy Notify needs rewriting once Notify architecture stabilizes. + Awaiting legacy Notify deprecation. # Gold devices: done @@ -78,7 +78,7 @@ rules: entity-device-class: done entity-disabled-by-default: done entity-translations: done - exception-translations: todo + exception-translations: done icon-translations: done reconfiguration-flow: status: todo diff --git a/homeassistant/components/mastodon/services.py b/homeassistant/components/mastodon/services.py new file mode 100644 index 00000000000..ab3a89c0c4b --- /dev/null +++ b/homeassistant/components/mastodon/services.py @@ -0,0 +1,142 @@ +"""Define services for the Mastodon integration.""" + +from enum import StrEnum +from functools import partial +from typing import Any, cast + +from mastodon import Mastodon +from mastodon.Mastodon import MastodonAPIError +import voluptuous as vol + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant, ServiceCall, ServiceResponse +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError + +from . import MastodonConfigEntry +from .const import ( + ATTR_CONFIG_ENTRY_ID, + ATTR_CONTENT_WARNING, + ATTR_MEDIA, + ATTR_MEDIA_WARNING, + ATTR_STATUS, + ATTR_VISIBILITY, + DOMAIN, +) +from .utils import get_media_type + + +class StatusVisibility(StrEnum): + """StatusVisibility model.""" + + PUBLIC = "public" + UNLISTED = "unlisted" + PRIVATE = "private" + DIRECT = "direct" + + +SERVICE_POST = "post" +SERVICE_POST_SCHEMA = vol.Schema( + { + vol.Required(ATTR_CONFIG_ENTRY_ID): str, + vol.Required(ATTR_STATUS): str, + vol.Optional(ATTR_VISIBILITY): vol.In([x.lower() for x in StatusVisibility]), + vol.Optional(ATTR_CONTENT_WARNING): str, + vol.Optional(ATTR_MEDIA): str, + vol.Optional(ATTR_MEDIA_WARNING): bool, + } +) + + +def async_get_entry(hass: HomeAssistant, config_entry_id: str) -> MastodonConfigEntry: + """Get the Mastodon config entry.""" + if not (entry := hass.config_entries.async_get_entry(config_entry_id)): + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="integration_not_found", + translation_placeholders={"target": DOMAIN}, + ) + if entry.state is not ConfigEntryState.LOADED: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="not_loaded", + translation_placeholders={"target": entry.title}, + ) + return cast(MastodonConfigEntry, entry) + + +def setup_services(hass: HomeAssistant) -> None: + """Set up the services for the Mastodon integration.""" + + async def async_post(call: ServiceCall) -> ServiceResponse: + """Post a status.""" + entry = async_get_entry(hass, call.data[ATTR_CONFIG_ENTRY_ID]) + client = entry.runtime_data.client + + status = call.data[ATTR_STATUS] + + visibility: str | None = ( + StatusVisibility(call.data[ATTR_VISIBILITY]) + if ATTR_VISIBILITY in call.data + else None + ) + spoiler_text: str | None = call.data.get(ATTR_CONTENT_WARNING) + media_path: str | None = call.data.get(ATTR_MEDIA) + media_warning: str | None = call.data.get(ATTR_MEDIA_WARNING) + + await hass.async_add_executor_job( + partial( + _post, + client=client, + status=status, + visibility=visibility, + spoiler_text=spoiler_text, + media_path=media_path, + sensitive=media_warning, + ) + ) + + return None + + def _post(client: Mastodon, **kwargs: Any) -> None: + """Post to Mastodon.""" + + media_data: dict[str, Any] | None = None + + media_path = kwargs.get("media_path") + if media_path: + if not hass.config.is_allowed_path(media_path): + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="not_whitelisted_directory", + translation_placeholders={"media": media_path}, + ) + + media_type = get_media_type(media_path) + try: + media_data = client.media_post( + media_file=media_path, mime_type=media_type + ) + + except MastodonAPIError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="unable_to_upload_image", + translation_placeholders={"media_path": media_path}, + ) from err + + kwargs.pop("media_path", None) + + try: + media_ids: str | None = None + if media_data: + media_ids = media_data["id"] + client.status_post(media_ids=media_ids, **kwargs) + except MastodonAPIError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="unable_to_send_message", + ) from err + + hass.services.async_register( + DOMAIN, SERVICE_POST, async_post, schema=SERVICE_POST_SCHEMA + ) diff --git a/homeassistant/components/mastodon/services.yaml b/homeassistant/components/mastodon/services.yaml new file mode 100644 index 00000000000..161a0d152ca --- /dev/null +++ b/homeassistant/components/mastodon/services.yaml @@ -0,0 +1,30 @@ +post: + fields: + config_entry_id: + required: true + selector: + config_entry: + integration: mastodon + status: + required: true + selector: + text: + visibility: + selector: + select: + options: + - public + - unlisted + - private + - direct + translation_key: post_visibility + content_warning: + selector: + text: + media: + selector: + text: + media_warning: + required: true + selector: + boolean: diff --git a/homeassistant/components/mastodon/strings.json b/homeassistant/components/mastodon/strings.json index 9df94ecf204..87858f768e4 100644 --- a/homeassistant/components/mastodon/strings.json +++ b/homeassistant/components/mastodon/strings.json @@ -25,6 +25,29 @@ "unknown": "Unknown error occured when connecting to the Mastodon instance." } }, + "exceptions": { + "not_loaded": { + "message": "{target} is not loaded." + }, + "integration_not_found": { + "message": "Integration \"{target}\" not found in registry." + }, + "unable_to_send_message": { + "message": "Unable to send message." + }, + "unable_to_upload_image": { + "message": "Unable to upload image {media_path}." + }, + "not_whitelisted_directory": { + "message": "{media} is not a whitelisted directory." + } + }, + "issues": { + "deprecated_notify_action": { + "title": "Deprecated Notify action used for Mastodon", + "description": "The Notify action for Mastodon is deprecated.\n\nUse the `mastodon.post` action instead." + } + }, "entity": { "sensor": { "followers": { @@ -40,5 +63,47 @@ "unit_of_measurement": "posts" } } + }, + "services": { + "post": { + "name": "Post", + "description": "Posts a status on your Mastodon account.", + "fields": { + "config_entry_id": { + "name": "Mastodon account", + "description": "Select the Mastodon account to post to." + }, + "status": { + "name": "Status", + "description": "The status to post." + }, + "visibility": { + "name": "Visibility", + "description": "The visibility of the post (default: account setting)." + }, + "content_warning": { + "name": "Content warning", + "description": "A content warning will be shown before the status text is shown (default: no content warning)." + }, + "media": { + "name": "Media", + "description": "Attach an image or video to the post." + }, + "media_warning": { + "name": "Media warning", + "description": "If an image or video is attached, will mark the media as sensitive (default: no media warning)." + } + } + } + }, + "selector": { + "post_visibility": { + "options": { + "public": "Public - Visible to everyone", + "unlisted": "Unlisted - Public but not shown in public timelines", + "private": "Private - Followers only", + "direct": "Direct - Mentioned accounts only" + } + } } } diff --git a/homeassistant/components/mastodon/utils.py b/homeassistant/components/mastodon/utils.py index 8e1bd697027..e9c2567b675 100644 --- a/homeassistant/components/mastodon/utils.py +++ b/homeassistant/components/mastodon/utils.py @@ -2,6 +2,9 @@ from __future__ import annotations +import mimetypes +from typing import Any + from mastodon import Mastodon from .const import ACCOUNT_USERNAME, DEFAULT_NAME, INSTANCE_DOMAIN, INSTANCE_URI @@ -30,3 +33,11 @@ def construct_mastodon_username( ) return DEFAULT_NAME + + +def get_media_type(media_path: Any = None) -> Any: + """Get media type.""" + + (media_type, _) = mimetypes.guess_type(media_path) + + return media_type diff --git a/tests/components/mastodon/test_notify.py b/tests/components/mastodon/test_notify.py index ab2d7456baf..4242f88d34a 100644 --- a/tests/components/mastodon/test_notify.py +++ b/tests/components/mastodon/test_notify.py @@ -2,10 +2,13 @@ from unittest.mock import AsyncMock +from mastodon.Mastodon import MastodonAPIError +import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.notify import DOMAIN as NOTIFY_DOMAIN from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from . import setup_integration @@ -36,3 +39,27 @@ async def test_notify( ) assert mock_mastodon_client.status_post.assert_called_once + + +async def test_notify_failed( + hass: HomeAssistant, + mock_mastodon_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the notify raising an error.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + mock_mastodon_client.status_post.side_effect = MastodonAPIError + + with pytest.raises(HomeAssistantError, match="Unable to send message"): + await hass.services.async_call( + NOTIFY_DOMAIN, + "trwnh_mastodon_social", + { + "message": "test toot", + }, + blocking=True, + return_response=False, + ) diff --git a/tests/components/mastodon/test_services.py b/tests/components/mastodon/test_services.py new file mode 100644 index 00000000000..b958bcff74c --- /dev/null +++ b/tests/components/mastodon/test_services.py @@ -0,0 +1,246 @@ +"""Tests for the Mastodon services.""" + +from unittest.mock import AsyncMock, Mock, patch + +from mastodon.Mastodon import MastodonAPIError +import pytest + +from homeassistant.components.mastodon.const import ( + ATTR_CONFIG_ENTRY_ID, + ATTR_CONTENT_WARNING, + ATTR_MEDIA, + ATTR_STATUS, + ATTR_VISIBILITY, + DOMAIN, +) +from homeassistant.components.mastodon.services import SERVICE_POST +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError + +from . import setup_integration + +from tests.common import MockConfigEntry + + +@pytest.mark.parametrize( + ("payload", "kwargs"), + [ + ( + { + ATTR_STATUS: "test toot", + }, + { + "status": "test toot", + "spoiler_text": None, + "visibility": None, + "media_ids": None, + "sensitive": None, + }, + ), + ( + {ATTR_STATUS: "test toot", ATTR_VISIBILITY: "private"}, + { + "status": "test toot", + "spoiler_text": None, + "visibility": "private", + "media_ids": None, + "sensitive": None, + }, + ), + ( + { + ATTR_STATUS: "test toot", + ATTR_CONTENT_WARNING: "Spoiler", + ATTR_VISIBILITY: "private", + }, + { + "status": "test toot", + "spoiler_text": "Spoiler", + "visibility": "private", + "media_ids": None, + "sensitive": None, + }, + ), + ( + { + ATTR_STATUS: "test toot", + ATTR_CONTENT_WARNING: "Spoiler", + ATTR_MEDIA: "/image.jpg", + }, + { + "status": "test toot", + "spoiler_text": "Spoiler", + "visibility": None, + "media_ids": "1", + "sensitive": None, + }, + ), + ], +) +async def test_service_post( + hass: HomeAssistant, + mock_mastodon_client: AsyncMock, + mock_config_entry: MockConfigEntry, + payload: dict[str, str], + kwargs: dict[str, str | None], +) -> None: + """Test the post service.""" + + await setup_integration(hass, mock_config_entry) + + with ( + patch.object(hass.config, "is_allowed_path", return_value=True), + patch.object(mock_mastodon_client, "media_post", return_value={"id": "1"}), + ): + await hass.services.async_call( + DOMAIN, + SERVICE_POST, + { + ATTR_CONFIG_ENTRY_ID: mock_config_entry.entry_id, + } + | payload, + blocking=True, + return_response=False, + ) + + mock_mastodon_client.status_post.assert_called_with(**kwargs) + + mock_mastodon_client.status_post.reset_mock() + + +@pytest.mark.parametrize( + ("payload", "kwargs"), + [ + ( + { + ATTR_STATUS: "test toot", + }, + {"status": "test toot", "spoiler_text": None, "visibility": None}, + ), + ( + { + ATTR_STATUS: "test toot", + ATTR_CONTENT_WARNING: "Spoiler", + ATTR_MEDIA: "/image.jpg", + }, + { + "status": "test toot", + "spoiler_text": "Spoiler", + "visibility": None, + "media_ids": "1", + "sensitive": None, + }, + ), + ], +) +async def test_post_service_failed( + hass: HomeAssistant, + mock_mastodon_client: AsyncMock, + mock_config_entry: MockConfigEntry, + payload: dict[str, str], + kwargs: dict[str, str | None], +) -> None: + """Test the post service raising an error.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + hass.config.is_allowed_path = Mock(return_value=True) + mock_mastodon_client.media_post.return_value = {"id": "1"} + + mock_mastodon_client.status_post.side_effect = MastodonAPIError + + with pytest.raises(HomeAssistantError, match="Unable to send message"): + await hass.services.async_call( + DOMAIN, + SERVICE_POST, + {ATTR_CONFIG_ENTRY_ID: mock_config_entry.entry_id} | payload, + blocking=True, + return_response=False, + ) + + +async def test_post_media_upload_failed( + hass: HomeAssistant, + mock_mastodon_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the post service raising an error because media upload fails.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + payload = {"status": "test toot", "media": "/fail.jpg"} + + mock_mastodon_client.media_post.side_effect = MastodonAPIError + + with ( + patch.object(hass.config, "is_allowed_path", return_value=True), + pytest.raises(HomeAssistantError, match="Unable to upload image /fail.jpg"), + ): + await hass.services.async_call( + DOMAIN, + SERVICE_POST, + {ATTR_CONFIG_ENTRY_ID: mock_config_entry.entry_id} | payload, + blocking=True, + return_response=False, + ) + + +async def test_post_path_not_whitelisted( + hass: HomeAssistant, + mock_mastodon_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the post service raising an error because the file path is not whitelisted.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + payload = {"status": "test toot", "media": "/fail.jpg"} + + with pytest.raises( + HomeAssistantError, match="/fail.jpg is not a whitelisted directory" + ): + await hass.services.async_call( + DOMAIN, + SERVICE_POST, + {ATTR_CONFIG_ENTRY_ID: mock_config_entry.entry_id} | payload, + blocking=True, + return_response=False, + ) + + +async def test_service_entry_availability( + hass: HomeAssistant, + mock_mastodon_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the services without valid entry.""" + mock_config_entry.add_to_hass(hass) + mock_config_entry2 = MockConfigEntry(domain=DOMAIN) + mock_config_entry2.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + payload = {"status": "test toot"} + + with pytest.raises(ServiceValidationError, match="Mock Title is not loaded"): + await hass.services.async_call( + DOMAIN, + SERVICE_POST, + {ATTR_CONFIG_ENTRY_ID: mock_config_entry2.entry_id} | payload, + blocking=True, + return_response=False, + ) + + with pytest.raises( + ServiceValidationError, match='Integration "mastodon" not found in registry' + ): + await hass.services.async_call( + DOMAIN, + SERVICE_POST, + {ATTR_CONFIG_ENTRY_ID: "bad-config_id"} | payload, + blocking=True, + return_response=False, + ) From d83c335ed6926950285dda8e1c16b22db507b83a Mon Sep 17 00:00:00 2001 From: Christopher Fenner <9592452+CFenner@users.noreply.github.com> Date: Fri, 31 Jan 2025 12:45:58 +0100 Subject: [PATCH 0341/3148] Add support for standby quickmode to ViCare integration (#133156) --- .../components/vicare/binary_sensor.py | 4 +- homeassistant/components/vicare/button.py | 2 +- homeassistant/components/vicare/fan.py | 33 +- homeassistant/components/vicare/number.py | 4 +- homeassistant/components/vicare/sensor.py | 4 +- homeassistant/components/vicare/utils.py | 10 +- .../components/vicare/fixtures/VitoPure.json | 645 ++++++++++++++++++ .../components/vicare/snapshots/test_fan.ambr | 64 +- tests/components/vicare/test_fan.py | 5 +- 9 files changed, 754 insertions(+), 17 deletions(-) create mode 100644 tests/components/vicare/fixtures/VitoPure.json diff --git a/homeassistant/components/vicare/binary_sensor.py b/homeassistant/components/vicare/binary_sensor.py index ced02dae97e..61a5abce942 100644 --- a/homeassistant/components/vicare/binary_sensor.py +++ b/homeassistant/components/vicare/binary_sensor.py @@ -125,7 +125,7 @@ def _build_entities( device.api, ) for description in GLOBAL_SENSORS - if is_supported(description.key, description, device.api) + if is_supported(description.key, description.value_getter, device.api) ) # add component entities for component_list, entity_description_list in ( @@ -143,7 +143,7 @@ def _build_entities( ) for component in component_list for description in entity_description_list - if is_supported(description.key, description, component) + if is_supported(description.key, description.value_getter, component) ) return entities diff --git a/homeassistant/components/vicare/button.py b/homeassistant/components/vicare/button.py index ad7d600eba3..65182990bfb 100644 --- a/homeassistant/components/vicare/button.py +++ b/homeassistant/components/vicare/button.py @@ -59,7 +59,7 @@ def _build_entities( ) for device in device_list for description in BUTTON_DESCRIPTIONS - if is_supported(description.key, description, device.api) + if is_supported(description.key, description.value_getter, device.api) ] diff --git a/homeassistant/components/vicare/fan.py b/homeassistant/components/vicare/fan.py index 190a893157c..10983a7ad24 100644 --- a/homeassistant/components/vicare/fan.py +++ b/homeassistant/components/vicare/fan.py @@ -5,6 +5,7 @@ from __future__ import annotations from contextlib import suppress import enum import logging +from typing import Any from PyViCare.PyViCareDevice import Device as PyViCareDevice from PyViCare.PyViCareDeviceConfig import PyViCareDeviceConfig @@ -25,7 +26,7 @@ from homeassistant.util.percentage import ( from .entity import ViCareEntity from .types import ViCareConfigEntry, ViCareDevice -from .utils import filter_state, get_device_serial +from .utils import filter_state, get_device_serial, is_supported _LOGGER = logging.getLogger(__name__) @@ -73,6 +74,12 @@ class VentilationMode(enum.StrEnum): return None +class VentilationQuickmode(enum.StrEnum): + """ViCare ventilation quickmodes.""" + + STANDBY = "standby" + + HA_TO_VICARE_MODE_VENTILATION = { VentilationMode.PERMANENT: "permanent", VentilationMode.VENTILATION: "ventilation", @@ -147,6 +154,19 @@ class ViCareFan(ViCareEntity, FanEntity): if supported_levels is not None and len(supported_levels) > 0: self._attr_supported_features |= FanEntityFeature.SET_SPEED + # evaluate quickmodes + quickmodes: list[str] = ( + device.getVentilationQuickmodes() + if is_supported( + "getVentilationQuickmodes", + lambda api: api.getVentilationQuickmodes(), + device, + ) + else [] + ) + if VentilationQuickmode.STANDBY in quickmodes: + self._attr_supported_features |= FanEntityFeature.TURN_OFF + def update(self) -> None: """Update state of fan.""" level: str | None = None @@ -155,6 +175,7 @@ class ViCareFan(ViCareEntity, FanEntity): self._attr_preset_mode = VentilationMode.from_vicare_mode( self._api.getActiveVentilationMode() ) + with suppress(PyViCareNotSupportedFeatureError): level = filter_state(self._api.getVentilationLevel()) if level is not None and level in ORDERED_NAMED_FAN_SPEEDS: @@ -175,8 +196,12 @@ class ViCareFan(ViCareEntity, FanEntity): @property def is_on(self) -> bool | None: """Return true if the entity is on.""" - # Viessmann ventilation unit cannot be turned off - return True + return self.percentage is not None and self.percentage > 0 + + def turn_off(self, **kwargs: Any) -> None: + """Turn the entity off.""" + + self._api.activateVentilationQuickmode(str(VentilationQuickmode.STANDBY)) @property def icon(self) -> str | None: @@ -206,6 +231,8 @@ class ViCareFan(ViCareEntity, FanEntity): """Set the speed of the fan, as a percentage.""" if self._attr_preset_mode != str(VentilationMode.PERMANENT): self.set_preset_mode(VentilationMode.PERMANENT) + elif self._api.getVentilationQuickmode(VentilationQuickmode.STANDBY): + self._api.deactivateVentilationQuickmode(str(VentilationQuickmode.STANDBY)) level = percentage_to_ordered_list_item(ORDERED_NAMED_FAN_SPEEDS, percentage) _LOGGER.debug("changing ventilation level to %s", level) diff --git a/homeassistant/components/vicare/number.py b/homeassistant/components/vicare/number.py index 8ffaa727634..534c0752cc1 100644 --- a/homeassistant/components/vicare/number.py +++ b/homeassistant/components/vicare/number.py @@ -353,7 +353,7 @@ def _build_entities( device.api, ) for description in DEVICE_ENTITY_DESCRIPTIONS - if is_supported(description.key, description, device.api) + if is_supported(description.key, description.value_getter, device.api) ) # add component entities entities.extend( @@ -366,7 +366,7 @@ def _build_entities( ) for circuit in get_circuits(device.api) for description in CIRCUIT_ENTITY_DESCRIPTIONS - if is_supported(description.key, description, circuit) + if is_supported(description.key, description.value_getter, circuit) ) return entities diff --git a/homeassistant/components/vicare/sensor.py b/homeassistant/components/vicare/sensor.py index 091deeba2a9..c99e7857d9b 100644 --- a/homeassistant/components/vicare/sensor.py +++ b/homeassistant/components/vicare/sensor.py @@ -1007,7 +1007,7 @@ def _build_entities( device.api, ) for description in GLOBAL_SENSORS - if is_supported(description.key, description, device.api) + if is_supported(description.key, description.value_getter, device.api) ) # add component entities for component_list, entity_description_list in ( @@ -1025,7 +1025,7 @@ def _build_entities( ) for component in component_list for description in entity_description_list - if is_supported(description.key, description, component) + if is_supported(description.key, description.value_getter, component) ) return entities diff --git a/homeassistant/components/vicare/utils.py b/homeassistant/components/vicare/utils.py index a2c31df4259..ef018a60f16 100644 --- a/homeassistant/components/vicare/utils.py +++ b/homeassistant/components/vicare/utils.py @@ -2,7 +2,7 @@ from __future__ import annotations -from collections.abc import Mapping +from collections.abc import Callable, Mapping import logging from typing import Any @@ -30,7 +30,7 @@ from .const import ( VICARE_TOKEN_FILENAME, HeatingType, ) -from .types import ViCareConfigEntry, ViCareRequiredKeysMixin +from .types import ViCareConfigEntry _LOGGER = logging.getLogger(__name__) @@ -81,12 +81,12 @@ def get_device_serial(device: PyViCareDevice) -> str | None: def is_supported( name: str, - entity_description: ViCareRequiredKeysMixin, + getter: Callable[[PyViCareDevice], Any], vicare_device, ) -> bool: """Check if the PyViCare device supports the requested sensor.""" try: - entity_description.value_getter(vicare_device) + getter(vicare_device) except PyViCareNotSupportedFeatureError: _LOGGER.debug("Feature not supported %s", name) return False @@ -131,5 +131,5 @@ def get_compressors(device: PyViCareDevice) -> list[PyViCareHeatingDeviceCompone def filter_state(state: str) -> str | None: - """Remove invalid states.""" + """Return the state if not 'nothing' or 'unknown'.""" return None if state in ("nothing", "unknown") else state diff --git a/tests/components/vicare/fixtures/VitoPure.json b/tests/components/vicare/fixtures/VitoPure.json new file mode 100644 index 00000000000..1e1cdef97ec --- /dev/null +++ b/tests/components/vicare/fixtures/VitoPure.json @@ -0,0 +1,645 @@ +{ + "data": [ + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "ventilation", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": true + } + }, + "timestamp": "2024-12-17T07:50:28.062Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "ventilation.levels.levelFour", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-12-17T08:16:15.525Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.levels.levelFour" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "ventilation.levels.levelOne", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-12-17T08:16:15.525Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.levels.levelOne" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "ventilation.levels.levelThree", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-12-17T08:16:15.525Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.levels.levelThree" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "ventilation.levels.levelTwo", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-12-17T08:16:15.525Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.levels.levelTwo" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "ventilation.operating.modes.filterChange", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2024-12-17T07:50:28.062Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.operating.modes.filterChange" + }, + { + "apiVersion": 1, + "commands": { + "setLevel": { + "isExecutable": true, + "name": "setLevel", + "params": { + "level": { + "constraints": { + "enum": ["levelOne", "levelTwo", "levelThree", "levelFour"] + }, + "required": true, + "type": "string" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.operating.modes.permanent/commands/setLevel" + } + }, + "deviceId": "0", + "feature": "ventilation.operating.modes.permanent", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2024-12-17T13:24:03.411Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.operating.modes.permanent" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "ventilation.operating.modes.sensorDriven", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": true + } + }, + "timestamp": "2024-12-17T08:16:15.525Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.operating.modes.sensorDriven" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "ventilation.operating.modes.ventilation", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2024-12-17T07:50:28.062Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.operating.modes.ventilation" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "ventilation.operating.programs.active", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "string", + "value": "levelTwo" + } + }, + "timestamp": "2024-12-17T13:24:03.411Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.operating.programs.active" + }, + { + "apiVersion": 1, + "commands": { + "activate": { + "isExecutable": true, + "name": "activate", + "params": { + "timeout": { + "constraints": { + "max": 1440, + "min": 1, + "stepping": 1 + }, + "required": false, + "type": "number" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.quickmodes.forcedLevelFour/commands/activate" + }, + "deactivate": { + "isExecutable": true, + "name": "deactivate", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.quickmodes.forcedLevelFour/commands/deactivate" + }, + "setDefaultRuntime": { + "isExecutable": true, + "name": "setDefaultRuntime", + "params": { + "defaultRuntime": { + "constraints": { + "max": 1440, + "min": 1, + "stepping": 1 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.quickmodes.forcedLevelFour/commands/setDefaultRuntime" + }, + "setTimeout": { + "isExecutable": true, + "name": "setTimeout", + "params": { + "timeout": { + "constraints": { + "max": 1440, + "min": 1, + "stepping": 1 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.quickmodes.forcedLevelFour/commands/setTimeout" + } + }, + "deviceId": "0", + "feature": "ventilation.quickmodes.forcedLevelFour", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + }, + "defaultRuntime": { + "type": "number", + "unit": "minutes", + "value": 30 + }, + "isActiveWritable": { + "type": "boolean", + "value": true + } + }, + "timestamp": "2024-12-17T13:24:04.515Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.quickmodes.forcedLevelFour" + }, + { + "apiVersion": 1, + "commands": { + "activate": { + "isExecutable": true, + "name": "activate", + "params": { + "timeout": { + "constraints": { + "max": 1440, + "min": 1, + "stepping": 1 + }, + "required": false, + "type": "number" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.quickmodes.silent/commands/activate" + }, + "deactivate": { + "isExecutable": true, + "name": "deactivate", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.quickmodes.silent/commands/deactivate" + }, + "setDefaultRuntime": { + "isExecutable": true, + "name": "setDefaultRuntime", + "params": { + "defaultRuntime": { + "constraints": { + "max": 1440, + "min": 1, + "stepping": 1 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.quickmodes.silent/commands/setDefaultRuntime" + }, + "setTimeout": { + "isExecutable": true, + "name": "setTimeout", + "params": { + "timeout": { + "constraints": { + "max": 1440, + "min": 1, + "stepping": 1 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.quickmodes.silent/commands/setTimeout" + } + }, + "deviceId": "0", + "feature": "ventilation.quickmodes.silent", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + }, + "defaultRuntime": { + "type": "number", + "unit": "minutes", + "value": 30 + }, + "isActiveWritable": { + "type": "boolean", + "value": true + } + }, + "timestamp": "2024-12-17T13:24:04.515Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.quickmodes.silent" + }, + { + "apiVersion": 1, + "commands": { + "activate": { + "isExecutable": true, + "name": "activate", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.quickmodes.standby/commands/activate" + }, + "deactivate": { + "isExecutable": true, + "name": "deactivate", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.quickmodes.standby/commands/deactivate" + } + }, + "deviceId": "0", + "feature": "ventilation.quickmodes.standby", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": true + } + }, + "timestamp": "2024-12-17T13:24:04.515Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.quickmodes.standby" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "device.productIdentification", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "product": { + "type": "object", + "value": { + "busAddress": 0, + "busType": "OwnBus", + "productFamily": "B_00059_VP300", + "viessmannIdentificationNumber": "################" + } + } + }, + "timestamp": "2024-12-17T07:50:28.062Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/device.productIdentification" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "device.messages.errors.raw", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "entries": { + "type": "array", + "value": [] + } + }, + "timestamp": "2024-12-17T07:50:28.062Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/device.messages.errors.raw" + }, + { + "apiVersion": 1, + "commands": { + "activate": { + "isExecutable": true, + "name": "activate", + "params": { + "begin": { + "constraints": { + "regEx": "^[\\d]{2}-[\\d]{2}$" + }, + "required": true, + "type": "string" + }, + "end": { + "constraints": { + "regEx": "^[\\d]{2}-[\\d]{2}$" + }, + "required": true, + "type": "string" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/device.time.daylightSaving/commands/activate" + }, + "deactivate": { + "isExecutable": true, + "name": "deactivate", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/device.time.daylightSaving/commands/deactivate" + } + }, + "deviceId": "0", + "feature": "device.time.daylightSaving", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2024-12-17T07:50:28.062Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/device.time.daylightSaving" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.boiler.serial", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "string", + "value": "################" + } + }, + "timestamp": "2024-12-17T07:50:28.062Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.boiler.serial" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.device.variant", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "string", + "value": "Vitopure350" + } + }, + "timestamp": "2024-12-17T07:50:28.062Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.device.variant" + }, + { + "apiVersion": 1, + "commands": { + "setMode": { + "isExecutable": true, + "name": "setMode", + "params": { + "mode": { + "constraints": { + "enum": ["permanent", "ventilation", "sensorDriven"] + }, + "required": true, + "type": "string" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.operating.modes.active/commands/setMode" + }, + "setModeContinuousSensorOverride": { + "isExecutable": false, + "name": "setModeContinuousSensorOverride", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.operating.modes.active/commands/setModeContinuousSensorOverride" + } + }, + "deviceId": "0", + "feature": "ventilation.operating.modes.active", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "string", + "value": "sensorDriven" + } + }, + "timestamp": "2024-12-17T08:16:15.525Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.operating.modes.active" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "ventilation.operating.state", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "demand": { + "type": "string", + "value": "unknown" + }, + "level": { + "type": "string", + "value": "unknown" + }, + "reason": { + "type": "string", + "value": "standby" + } + }, + "timestamp": "2024-12-17T13:24:04.515Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.operating.state" + }, + { + "apiVersion": 1, + "commands": { + "resetSchedule": { + "isExecutable": false, + "name": "resetSchedule", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.schedule/commands/resetSchedule" + }, + "setSchedule": { + "isExecutable": true, + "name": "setSchedule", + "params": { + "newSchedule": { + "constraints": { + "defaultMode": "standby", + "maxEntries": 4, + "modes": ["levelOne", "levelTwo", "levelThree", "levelFour"], + "overlapAllowed": false, + "resolution": 10 + }, + "required": true, + "type": "Schedule" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.schedule/commands/setSchedule" + } + }, + "deviceId": "0", + "feature": "ventilation.schedule", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": true + }, + "entries": { + "type": "Schedule", + "value": { + "fri": [ + { + "end": "22:00", + "mode": "levelTwo", + "position": 0, + "start": "06:00" + } + ], + "mon": [ + { + "end": "22:00", + "mode": "levelTwo", + "position": 0, + "start": "06:00" + } + ], + "sat": [ + { + "end": "22:00", + "mode": "levelTwo", + "position": 0, + "start": "06:00" + } + ], + "sun": [ + { + "end": "22:00", + "mode": "levelTwo", + "position": 0, + "start": "06:00" + } + ], + "thu": [ + { + "end": "22:00", + "mode": "levelTwo", + "position": 0, + "start": "06:00" + } + ], + "tue": [ + { + "end": "22:00", + "mode": "levelTwo", + "position": 0, + "start": "06:00" + } + ], + "wed": [ + { + "end": "22:00", + "mode": "levelTwo", + "position": 0, + "start": "06:00" + } + ] + } + } + }, + "timestamp": "2024-12-17T07:50:28.062Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.schedule" + } + ] +} diff --git a/tests/components/vicare/snapshots/test_fan.ambr b/tests/components/vicare/snapshots/test_fan.ambr index 3ecc4277fd9..745e77dac5c 100644 --- a/tests/components/vicare/snapshots/test_fan.ambr +++ b/tests/components/vicare/snapshots/test_fan.ambr @@ -60,6 +60,68 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'on', + 'state': 'off', + }) +# --- +# name: test_all_entities[fan.model1_ventilation-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'preset_modes': list([ + , + , + , + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.model1_ventilation', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:fan', + 'original_name': 'Ventilation', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'ventilation', + 'unique_id': 'gateway1_deviceId1-ventilation', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[fan.model1_ventilation-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'model1 Ventilation', + 'icon': 'mdi:fan', + 'percentage': 0, + 'percentage_step': 25.0, + 'preset_mode': None, + 'preset_modes': list([ + , + , + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'fan.model1_ventilation', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', }) # --- diff --git a/tests/components/vicare/test_fan.py b/tests/components/vicare/test_fan.py index aaf6a968ffd..5683f48f01f 100644 --- a/tests/components/vicare/test_fan.py +++ b/tests/components/vicare/test_fan.py @@ -23,7 +23,10 @@ async def test_all_entities( entity_registry: er.EntityRegistry, ) -> None: """Test all entities.""" - fixtures: list[Fixture] = [Fixture({"type:ventilation"}, "vicare/ViAir300F.json")] + fixtures: list[Fixture] = [ + Fixture({"type:ventilation"}, "vicare/ViAir300F.json"), + Fixture({"type:ventilation"}, "vicare/VitoPure.json"), + ] with ( patch(f"{MODULE}.login", return_value=MockPyViCare(fixtures)), patch(f"{MODULE}.PLATFORMS", [Platform.FAN]), From cde59613a58cdfccc4aae56b51412c7634c664f1 Mon Sep 17 00:00:00 2001 From: Sid <27780930+autinerd@users.noreply.github.com> Date: Fri, 31 Jan 2025 12:52:17 +0100 Subject: [PATCH 0342/3148] Refactor eheimdigital platform async_setup_entry (#136745) --- .../components/eheimdigital/climate.py | 21 +++-- .../components/eheimdigital/coordinator.py | 9 ++- .../components/eheimdigital/light.py | 31 ++++---- .../eheimdigital/snapshots/test_climate.ambr | 76 +++++++++++++++++++ tests/components/eheimdigital/test_climate.py | 35 +++++++++ 5 files changed, 148 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/eheimdigital/climate.py b/homeassistant/components/eheimdigital/climate.py index 16771ba227d..9b1f825dece 100644 --- a/homeassistant/components/eheimdigital/climate.py +++ b/homeassistant/components/eheimdigital/climate.py @@ -2,6 +2,7 @@ from typing import Any +from eheimdigital.device import EheimDigitalDevice from eheimdigital.heater import EheimDigitalHeater from eheimdigital.types import EheimDigitalClientError, HeaterMode, HeaterUnit @@ -39,17 +40,23 @@ async def async_setup_entry( """Set up the callbacks for the coordinator so climate entities can be added as devices are found.""" coordinator = entry.runtime_data - async def async_setup_device_entities(device_address: str) -> None: - """Set up the light entities for a device.""" - device = coordinator.hub.devices[device_address] + def async_setup_device_entities( + device_address: str | dict[str, EheimDigitalDevice], + ) -> None: + """Set up the climate entities for one or multiple devices.""" + entities: list[EheimDigitalHeaterClimate] = [] + if isinstance(device_address, str): + device_address = {device_address: coordinator.hub.devices[device_address]} + for device in device_address.values(): + if isinstance(device, EheimDigitalHeater): + entities.append(EheimDigitalHeaterClimate(coordinator, device)) + coordinator.known_devices.add(device.mac_address) - if isinstance(device, EheimDigitalHeater): - async_add_entities([EheimDigitalHeaterClimate(coordinator, device)]) + async_add_entities(entities) coordinator.add_platform_callback(async_setup_device_entities) - for device_address in entry.runtime_data.hub.devices: - await async_setup_device_entities(device_address) + async_setup_device_entities(coordinator.hub.devices) class EheimDigitalHeaterClimate(EheimDigitalEntity[EheimDigitalHeater], ClimateEntity): diff --git a/homeassistant/components/eheimdigital/coordinator.py b/homeassistant/components/eheimdigital/coordinator.py index f122a1227c5..ee4f09426b7 100644 --- a/homeassistant/components/eheimdigital/coordinator.py +++ b/homeassistant/components/eheimdigital/coordinator.py @@ -2,8 +2,7 @@ from __future__ import annotations -from collections.abc import Callable, Coroutine -from typing import Any +from collections.abc import Callable from aiohttp import ClientError from eheimdigital.device import EheimDigitalDevice @@ -19,7 +18,9 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .const import DOMAIN, LOGGER -type AsyncSetupDeviceEntitiesCallback = Callable[[str], Coroutine[Any, Any, None]] +type AsyncSetupDeviceEntitiesCallback = Callable[ + [str | dict[str, EheimDigitalDevice]], None +] class EheimDigitalUpdateCoordinator( @@ -61,7 +62,7 @@ class EheimDigitalUpdateCoordinator( if device_address not in self.known_devices: for platform_callback in self.platform_callbacks: - await platform_callback(device_address) + platform_callback(device_address) async def _async_receive_callback(self) -> None: self.async_set_updated_data(self.hub.devices) diff --git a/homeassistant/components/eheimdigital/light.py b/homeassistant/components/eheimdigital/light.py index a119e0bda8d..5ae0a6e866a 100644 --- a/homeassistant/components/eheimdigital/light.py +++ b/homeassistant/components/eheimdigital/light.py @@ -3,6 +3,7 @@ from typing import Any from eheimdigital.classic_led_ctrl import EheimDigitalClassicLEDControl +from eheimdigital.device import EheimDigitalDevice from eheimdigital.types import EheimDigitalClientError, LightMode from homeassistant.components.light import ( @@ -37,24 +38,28 @@ async def async_setup_entry( """Set up the callbacks for the coordinator so lights can be added as devices are found.""" coordinator = entry.runtime_data - async def async_setup_device_entities(device_address: str) -> None: - """Set up the light entities for a device.""" - device = coordinator.hub.devices[device_address] + def async_setup_device_entities( + device_address: str | dict[str, EheimDigitalDevice], + ) -> None: + """Set up the light entities for one or multiple devices.""" entities: list[EheimDigitalClassicLEDControlLight] = [] + if isinstance(device_address, str): + device_address = {device_address: coordinator.hub.devices[device_address]} + for device in device_address.values(): + if isinstance(device, EheimDigitalClassicLEDControl): + for channel in range(2): + if len(device.tankconfig[channel]) > 0: + entities.append( + EheimDigitalClassicLEDControlLight( + coordinator, device, channel + ) + ) + coordinator.known_devices.add(device.mac_address) - if isinstance(device, EheimDigitalClassicLEDControl): - for channel in range(2): - if len(device.tankconfig[channel]) > 0: - entities.append( - EheimDigitalClassicLEDControlLight(coordinator, device, channel) - ) - coordinator.known_devices.add(device.mac_address) async_add_entities(entities) coordinator.add_platform_callback(async_setup_device_entities) - - for device_address in entry.runtime_data.hub.devices: - await async_setup_device_entities(device_address) + async_setup_device_entities(coordinator.hub.devices) class EheimDigitalClassicLEDControlLight( diff --git a/tests/components/eheimdigital/snapshots/test_climate.ambr b/tests/components/eheimdigital/snapshots/test_climate.ambr index 02d60677b24..d81c59e5af1 100644 --- a/tests/components/eheimdigital/snapshots/test_climate.ambr +++ b/tests/components/eheimdigital/snapshots/test_climate.ambr @@ -1,4 +1,80 @@ # serializer version: 1 +# name: test_dynamic_new_devices[climate.mock_heater_none-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 32, + 'min_temp': 18, + 'preset_modes': list([ + 'none', + 'bio_mode', + 'smart_mode', + ]), + 'target_temp_step': 0.5, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.mock_heater_none', + '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': None, + 'platform': 'eheimdigital', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'heater', + 'unique_id': '00:00:00:00:00:02', + 'unit_of_measurement': None, + }) +# --- +# name: test_dynamic_new_devices[climate.mock_heater_none-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 24.2, + 'friendly_name': 'Mock Heater None', + 'hvac_action': , + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 32, + 'min_temp': 18, + 'preset_mode': 'none', + 'preset_modes': list([ + 'none', + 'bio_mode', + 'smart_mode', + ]), + 'supported_features': , + 'target_temp_step': 0.5, + 'temperature': 25.5, + }), + 'context': , + 'entity_id': 'climate.mock_heater_none', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'auto', + }) +# --- # name: test_setup_heater[climate.mock_heater_none-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/eheimdigital/test_climate.py b/tests/components/eheimdigital/test_climate.py index 4e770882263..f64b7d7e740 100644 --- a/tests/components/eheimdigital/test_climate.py +++ b/tests/components/eheimdigital/test_climate.py @@ -56,6 +56,41 @@ async def test_setup_heater( await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) +async def test_dynamic_new_devices( + hass: HomeAssistant, + eheimdigital_hub_mock: MagicMock, + heater_mock: MagicMock, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, +) -> None: + """Test light platform setup with at first no devices and dynamically adding a device.""" + mock_config_entry.add_to_hass(hass) + + eheimdigital_hub_mock.return_value.devices = {} + + with patch("homeassistant.components.eheimdigital.PLATFORMS", [Platform.CLIMATE]): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + assert ( + len( + entity_registry.entities.get_entries_for_config_entry_id( + mock_config_entry.entry_id + ) + ) + == 0 + ) + + eheimdigital_hub_mock.return_value.devices = {"00:00:00:00:00:02": heater_mock} + + await eheimdigital_hub_mock.call_args.kwargs["device_found_callback"]( + "00:00:00:00:00:02", EheimDeviceType.VERSION_EHEIM_EXT_HEATER + ) + await hass.async_block_till_done() + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + @pytest.mark.parametrize( ("preset_mode", "heater_mode"), [ From f21ab24b8b812ce6e3ca63138af60877a1519a51 Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Fri, 31 Jan 2025 12:55:51 +0100 Subject: [PATCH 0343/3148] Add sensors for drink stats per key to lamarzocco (#136582) * Add sensors for drink stats per key to lamarzocco * Add icon * Use UOM translations * fix tests * remove translation key * Update sensor.py Co-authored-by: Joost Lekkerkerker --------- Co-authored-by: Joost Lekkerkerker --- .../components/lamarzocco/icons.json | 3 + homeassistant/components/lamarzocco/sensor.py | 64 +++++- .../components/lamarzocco/strings.json | 10 +- .../lamarzocco/snapshots/test_sensor.ambr | 208 +++++++++++++++++- tests/components/lamarzocco/test_sensor.py | 1 + 5 files changed, 275 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/lamarzocco/icons.json b/homeassistant/components/lamarzocco/icons.json index 79267b4abd4..2be882fafea 100644 --- a/homeassistant/components/lamarzocco/icons.json +++ b/homeassistant/components/lamarzocco/icons.json @@ -95,6 +95,9 @@ "drink_stats_flushing": { "default": "mdi:chart-line" }, + "drink_stats_coffee_key": { + "default": "mdi:chart-scatter-plot" + }, "shot_timer": { "default": "mdi:timer" }, diff --git a/homeassistant/components/lamarzocco/sensor.py b/homeassistant/components/lamarzocco/sensor.py index 406e8e40e92..a2d6143daa5 100644 --- a/homeassistant/components/lamarzocco/sensor.py +++ b/homeassistant/components/lamarzocco/sensor.py @@ -3,7 +3,7 @@ from collections.abc import Callable from dataclasses import dataclass -from pylamarzocco.const import BoilerType, MachineModel +from pylamarzocco.const import KEYS_PER_MODEL, BoilerType, MachineModel, PhysicalKey from pylamarzocco.devices.machine import LaMarzoccoMachine from homeassistant.components.sensor import ( @@ -21,7 +21,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .coordinator import LaMarzoccoConfigEntry +from .coordinator import LaMarzoccoConfigEntry, LaMarzoccoUpdateCoordinator from .entity import LaMarzoccoEntity, LaMarzoccoEntityDescription, LaMarzoccScaleEntity # Coordinator is used to centralize the data updates @@ -37,6 +37,15 @@ class LaMarzoccoSensorEntityDescription( value_fn: Callable[[LaMarzoccoMachine], float | int] +@dataclass(frozen=True, kw_only=True) +class LaMarzoccoKeySensorEntityDescription( + LaMarzoccoEntityDescription, SensorEntityDescription +): + """Description of a keyed La Marzocco sensor.""" + + value_fn: Callable[[LaMarzoccoMachine, PhysicalKey], int | None] + + ENTITIES: tuple[LaMarzoccoSensorEntityDescription, ...] = ( LaMarzoccoSensorEntityDescription( key="shot_timer", @@ -79,7 +88,6 @@ STATISTIC_ENTITIES: tuple[LaMarzoccoSensorEntityDescription, ...] = ( LaMarzoccoSensorEntityDescription( key="drink_stats_coffee", translation_key="drink_stats_coffee", - native_unit_of_measurement="drinks", state_class=SensorStateClass.TOTAL_INCREASING, value_fn=lambda device: device.statistics.total_coffee, available_fn=lambda device: len(device.statistics.drink_stats) > 0, @@ -88,7 +96,6 @@ STATISTIC_ENTITIES: tuple[LaMarzoccoSensorEntityDescription, ...] = ( LaMarzoccoSensorEntityDescription( key="drink_stats_flushing", translation_key="drink_stats_flushing", - native_unit_of_measurement="drinks", state_class=SensorStateClass.TOTAL_INCREASING, value_fn=lambda device: device.statistics.total_flushes, available_fn=lambda device: len(device.statistics.drink_stats) > 0, @@ -96,6 +103,18 @@ STATISTIC_ENTITIES: tuple[LaMarzoccoSensorEntityDescription, ...] = ( ), ) +KEY_STATISTIC_ENTITIES: tuple[LaMarzoccoKeySensorEntityDescription, ...] = ( + LaMarzoccoKeySensorEntityDescription( + key="drink_stats_coffee_key", + translation_key="drink_stats_coffee_key", + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda device, key: device.statistics.drink_stats.get(key), + available_fn=lambda device: len(device.statistics.drink_stats) > 0, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), +) + SCALE_ENTITIES: tuple[LaMarzoccoSensorEntityDescription, ...] = ( LaMarzoccoSensorEntityDescription( key="scale_battery", @@ -120,6 +139,8 @@ async def async_setup_entry( """Set up sensor entities.""" config_coordinator = entry.runtime_data.config_coordinator + entities: list[LaMarzoccoSensorEntity | LaMarzoccoKeySensorEntity] = [] + entities = [ LaMarzoccoSensorEntity(config_coordinator, description) for description in ENTITIES @@ -142,6 +163,14 @@ async def async_setup_entry( if description.supported_fn(statistics_coordinator) ) + num_keys = KEYS_PER_MODEL[MachineModel(config_coordinator.device.model)] + if num_keys > 0: + entities.extend( + LaMarzoccoKeySensorEntity(statistics_coordinator, description, key) + for description in KEY_STATISTIC_ENTITIES + for key in range(1, num_keys + 1) + ) + def _async_add_new_scale() -> None: async_add_entities( LaMarzoccoScaleSensorEntity(config_coordinator, description) @@ -159,11 +188,36 @@ class LaMarzoccoSensorEntity(LaMarzoccoEntity, SensorEntity): entity_description: LaMarzoccoSensorEntityDescription @property - def native_value(self) -> int | float: + def native_value(self) -> int | float | None: """State of the sensor.""" return self.entity_description.value_fn(self.coordinator.device) +class LaMarzoccoKeySensorEntity(LaMarzoccoEntity, SensorEntity): + """Sensor for a La Marzocco key.""" + + entity_description: LaMarzoccoKeySensorEntityDescription + + def __init__( + self, + coordinator: LaMarzoccoUpdateCoordinator, + description: LaMarzoccoKeySensorEntityDescription, + key: int, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator, description) + self.key = key + self._attr_translation_placeholders = {"key": str(key)} + self._attr_unique_id = f"{super()._attr_unique_id}_key{key}" + + @property + def native_value(self) -> int | None: + """State of the sensor.""" + return self.entity_description.value_fn( + self.coordinator.device, PhysicalKey(self.key) + ) + + class LaMarzoccoScaleSensorEntity(LaMarzoccoSensorEntity, LaMarzoccScaleEntity): """Sensor for a La Marzocco scale.""" diff --git a/homeassistant/components/lamarzocco/strings.json b/homeassistant/components/lamarzocco/strings.json index cc96e4615dc..62050685c27 100644 --- a/homeassistant/components/lamarzocco/strings.json +++ b/homeassistant/components/lamarzocco/strings.json @@ -175,10 +175,16 @@ "name": "Current steam temperature" }, "drink_stats_coffee": { - "name": "Total coffees made" + "name": "Total coffees made", + "unit_of_measurement": "coffees" + }, + "drink_stats_coffee_key": { + "name": "Coffees made Key {key}", + "unit_of_measurement": "coffees" }, "drink_stats_flushing": { - "name": "Total flushes made" + "name": "Total flushes made", + "unit_of_measurement": "flushes" }, "shot_timer": { "name": "Shot timer" diff --git a/tests/components/lamarzocco/snapshots/test_sensor.ambr b/tests/components/lamarzocco/snapshots/test_sensor.ambr index 9e2eae482d2..be2b1672cb9 100644 --- a/tests/components/lamarzocco/snapshots/test_sensor.ambr +++ b/tests/components/lamarzocco/snapshots/test_sensor.ambr @@ -50,6 +50,206 @@ 'unit_of_measurement': '%', }) # --- +# name: test_sensors[sensor.gs012345_coffees_made_key_1-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': , + 'entity_id': 'sensor.gs012345_coffees_made_key_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': 'Coffees made Key 1', + 'platform': 'lamarzocco', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'drink_stats_coffee_key', + 'unique_id': 'GS012345_drink_stats_coffee_key_key1', + 'unit_of_measurement': 'coffees', + }) +# --- +# name: test_sensors[sensor.gs012345_coffees_made_key_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'GS012345 Coffees made Key 1', + 'state_class': , + 'unit_of_measurement': 'coffees', + }), + 'context': , + 'entity_id': 'sensor.gs012345_coffees_made_key_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1047', + }) +# --- +# name: test_sensors[sensor.gs012345_coffees_made_key_2-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': , + 'entity_id': 'sensor.gs012345_coffees_made_key_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': 'Coffees made Key 2', + 'platform': 'lamarzocco', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'drink_stats_coffee_key', + 'unique_id': 'GS012345_drink_stats_coffee_key_key2', + 'unit_of_measurement': 'coffees', + }) +# --- +# name: test_sensors[sensor.gs012345_coffees_made_key_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'GS012345 Coffees made Key 2', + 'state_class': , + 'unit_of_measurement': 'coffees', + }), + 'context': , + 'entity_id': 'sensor.gs012345_coffees_made_key_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '560', + }) +# --- +# name: test_sensors[sensor.gs012345_coffees_made_key_3-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': , + 'entity_id': 'sensor.gs012345_coffees_made_key_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': 'Coffees made Key 3', + 'platform': 'lamarzocco', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'drink_stats_coffee_key', + 'unique_id': 'GS012345_drink_stats_coffee_key_key3', + 'unit_of_measurement': 'coffees', + }) +# --- +# name: test_sensors[sensor.gs012345_coffees_made_key_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'GS012345 Coffees made Key 3', + 'state_class': , + 'unit_of_measurement': 'coffees', + }), + 'context': , + 'entity_id': 'sensor.gs012345_coffees_made_key_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '468', + }) +# --- +# name: test_sensors[sensor.gs012345_coffees_made_key_4-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': , + 'entity_id': 'sensor.gs012345_coffees_made_key_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': 'Coffees made Key 4', + 'platform': 'lamarzocco', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'drink_stats_coffee_key', + 'unique_id': 'GS012345_drink_stats_coffee_key_key4', + 'unit_of_measurement': 'coffees', + }) +# --- +# name: test_sensors[sensor.gs012345_coffees_made_key_4-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'GS012345 Coffees made Key 4', + 'state_class': , + 'unit_of_measurement': 'coffees', + }), + 'context': , + 'entity_id': 'sensor.gs012345_coffees_made_key_4', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '312', + }) +# --- # name: test_sensors[sensor.gs012345_current_coffee_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -241,7 +441,7 @@ 'supported_features': 0, 'translation_key': 'drink_stats_coffee', 'unique_id': 'GS012345_drink_stats_coffee', - 'unit_of_measurement': 'drinks', + 'unit_of_measurement': 'coffees', }) # --- # name: test_sensors[sensor.gs012345_total_coffees_made-state] @@ -249,7 +449,7 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'GS012345 Total coffees made', 'state_class': , - 'unit_of_measurement': 'drinks', + 'unit_of_measurement': 'coffees', }), 'context': , 'entity_id': 'sensor.gs012345_total_coffees_made', @@ -291,7 +491,7 @@ 'supported_features': 0, 'translation_key': 'drink_stats_flushing', 'unique_id': 'GS012345_drink_stats_flushing', - 'unit_of_measurement': 'drinks', + 'unit_of_measurement': 'flushes', }) # --- # name: test_sensors[sensor.gs012345_total_flushes_made-state] @@ -299,7 +499,7 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'GS012345 Total flushes made', 'state_class': , - 'unit_of_measurement': 'drinks', + 'unit_of_measurement': 'flushes', }), 'context': , 'entity_id': 'sensor.gs012345_total_flushes_made', diff --git a/tests/components/lamarzocco/test_sensor.py b/tests/components/lamarzocco/test_sensor.py index 3385e2b3891..43a0826d551 100644 --- a/tests/components/lamarzocco/test_sensor.py +++ b/tests/components/lamarzocco/test_sensor.py @@ -18,6 +18,7 @@ from . import async_init_integration from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_sensors( hass: HomeAssistant, mock_lamarzocco: MagicMock, From c7041a97be79def58ffc771e2e3b2702f4c8b9cd Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Fri, 31 Jan 2025 12:03:13 +0000 Subject: [PATCH 0344/3148] Do not duplicate device class translations in ring integration (#136868) --- .../components/ring/binary_sensor.py | 1 - homeassistant/components/ring/sensor.py | 1 - homeassistant/components/ring/strings.json | 6 - tests/components/ring/common.py | 60 ++++ .../ring/snapshots/test_binary_sensor.ambr | 6 +- .../ring/snapshots/test_sensor.ambr | 318 +++++++++++++++++- tests/components/ring/test_binary_sensor.py | 10 +- tests/components/ring/test_number.py | 5 +- tests/components/ring/test_sensor.py | 9 +- 9 files changed, 387 insertions(+), 29 deletions(-) diff --git a/homeassistant/components/ring/binary_sensor.py b/homeassistant/components/ring/binary_sensor.py index 2c458985498..da0e0cc1d9b 100644 --- a/homeassistant/components/ring/binary_sensor.py +++ b/homeassistant/components/ring/binary_sensor.py @@ -55,7 +55,6 @@ BINARY_SENSOR_TYPES: tuple[RingBinarySensorEntityDescription, ...] = ( ), RingBinarySensorEntityDescription( key=KIND_MOTION, - translation_key=KIND_MOTION, device_class=BinarySensorDeviceClass.MOTION, capability=RingCapability.MOTION_DETECTION, deprecated_info=DeprecatedInfo( diff --git a/homeassistant/components/ring/sensor.py b/homeassistant/components/ring/sensor.py index cf851a113bc..a2f72b94336 100644 --- a/homeassistant/components/ring/sensor.py +++ b/homeassistant/components/ring/sensor.py @@ -258,7 +258,6 @@ SENSOR_TYPES: tuple[RingSensorEntityDescription[Any], ...] = ( ), RingSensorEntityDescription[RingGeneric]( key="wifi_signal_strength", - translation_key="wifi_signal_strength", native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, device_class=SensorDeviceClass.SIGNAL_STRENGTH, entity_category=EntityCategory.DIAGNOSTIC, diff --git a/homeassistant/components/ring/strings.json b/homeassistant/components/ring/strings.json index 8320a3ec47f..219463d92d9 100644 --- a/homeassistant/components/ring/strings.json +++ b/homeassistant/components/ring/strings.json @@ -56,9 +56,6 @@ "binary_sensor": { "ding": { "name": "Ding" - }, - "motion": { - "name": "Motion" } }, "event": { @@ -122,9 +119,6 @@ }, "wifi_signal_category": { "name": "Wi-Fi signal category" - }, - "wifi_signal_strength": { - "name": "Wi-Fi signal strength" } }, "switch": { diff --git a/tests/components/ring/common.py b/tests/components/ring/common.py index 22fa1c2bf32..e7af1d94855 100644 --- a/tests/components/ring/common.py +++ b/tests/components/ring/common.py @@ -6,6 +6,7 @@ from homeassistant.components.automation import DOMAIN as AUTOMATION_DOMAIN from homeassistant.components.ring import DOMAIN from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er, translation from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry @@ -35,3 +36,62 @@ async def setup_automation(hass: HomeAssistant, alias: str, entity_id: str) -> N } }, ) + + +async def async_check_entity_translations( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + config_entry_id: str, + platform_domain: str, +) -> None: + """Check that entity translations are used correctly. + + Check no unused translations in strings. + Check no translation_key defined when translation not in strings. + Check no translation defined when device class translation can be used. + """ + entity_entries = er.async_entries_for_config_entry(entity_registry, config_entry_id) + + assert entity_entries + assert len({entity_entry.domain for entity_entry in entity_entries}) == 1, ( + "Limit the loaded platforms to 1 platform." + ) + + translations = await translation.async_get_translations( + hass, "en", "entity", [DOMAIN] + ) + device_class_translations = await translation.async_get_translations( + hass, "en", "entity_component", [platform_domain] + ) + unique_device_classes = set() + used_translation_keys = set() + for entity_entry in entity_entries: + dc_translation = None + if entity_entry.original_device_class: + dc_translation_key = f"component.{platform_domain}.entity_component.{entity_entry.original_device_class.value}.name" + dc_translation = device_class_translations.get(dc_translation_key) + + if entity_entry.translation_key: + key = f"component.{DOMAIN}.entity.{entity_entry.domain}.{entity_entry.translation_key}.name" + entity_translation = translations.get(key) + assert entity_translation, ( + f"Translation key {entity_entry.translation_key} defined for {entity_entry.entity_id} not in strings.json" + ) + assert dc_translation != entity_translation, ( + f"Translation {key} is defined the same as the device class translation." + ) + used_translation_keys.add(key) + + else: + unique_key = (entity_entry.device_id, entity_entry.original_device_class) + assert unique_key not in unique_device_classes, ( + f"No translation key and multiple entities using {entity_entry.original_device_class}" + ) + unique_device_classes.add(entity_entry.original_device_class) + + for defined_key in translations: + if defined_key.split(".")[3] != platform_domain: + continue + assert defined_key in used_translation_keys, ( + f"Translation key {defined_key} unused." + ) diff --git a/tests/components/ring/snapshots/test_binary_sensor.ambr b/tests/components/ring/snapshots/test_binary_sensor.ambr index 2f8e4d8a219..84c727e6340 100644 --- a/tests/components/ring/snapshots/test_binary_sensor.ambr +++ b/tests/components/ring/snapshots/test_binary_sensor.ambr @@ -75,7 +75,7 @@ 'platform': 'ring', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'motion', + 'translation_key': None, 'unique_id': '987654-motion', 'unit_of_measurement': None, }) @@ -123,7 +123,7 @@ 'platform': 'ring', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'motion', + 'translation_key': None, 'unique_id': '765432-motion', 'unit_of_measurement': None, }) @@ -219,7 +219,7 @@ 'platform': 'ring', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'motion', + 'translation_key': None, 'unique_id': '345678-motion', 'unit_of_measurement': None, }) diff --git a/tests/components/ring/snapshots/test_sensor.ambr b/tests/components/ring/snapshots/test_sensor.ambr index 9fd1ac7ba84..a90bb3fe5f6 100644 --- a/tests/components/ring/snapshots/test_sensor.ambr +++ b/tests/components/ring/snapshots/test_sensor.ambr @@ -117,11 +117,11 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Wi-Fi signal strength', + 'original_name': 'Signal strength', 'platform': 'ring', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'wifi_signal_strength', + 'translation_key': None, 'unique_id': '123456-wifi_signal_strength', 'unit_of_measurement': 'dBm', }) @@ -131,7 +131,7 @@ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by Ring.com', 'device_class': 'signal_strength', - 'friendly_name': 'Downstairs Wi-Fi signal strength', + 'friendly_name': 'Downstairs Signal strength', 'unit_of_measurement': 'dBm', }), 'context': , @@ -294,6 +294,102 @@ 'state': 'unknown', }) # --- +# name: test_states[sensor.front_door_last_ding-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.front_door_last_ding', + '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 ding', + 'platform': 'ring', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'last_ding', + 'unique_id': '987654-last_ding', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[sensor.front_door_last_ding-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Ring.com', + 'device_class': 'timestamp', + 'friendly_name': 'Front Door Last ding', + }), + 'context': , + 'entity_id': 'sensor.front_door_last_ding', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_states[sensor.front_door_last_motion-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.front_door_last_motion', + '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 motion', + 'platform': 'ring', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'last_motion', + 'unique_id': '987654-last_motion', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[sensor.front_door_last_motion-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Ring.com', + 'device_class': 'timestamp', + 'friendly_name': 'Front Door Last motion', + }), + 'context': , + 'entity_id': 'sensor.front_door_last_motion', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_states[sensor.front_door_volume-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -412,11 +508,11 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Wi-Fi signal strength', + 'original_name': 'Signal strength', 'platform': 'ring', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'wifi_signal_strength', + 'translation_key': None, 'unique_id': '987654-wifi_signal_strength', 'unit_of_measurement': 'dBm', }) @@ -426,7 +522,7 @@ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by Ring.com', 'device_class': 'signal_strength', - 'friendly_name': 'Front Door Wi-Fi signal strength', + 'friendly_name': 'Front Door Signal strength', 'unit_of_measurement': 'dBm', }), 'context': , @@ -485,6 +581,102 @@ 'state': 'unknown', }) # --- +# name: test_states[sensor.front_last_ding-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.front_last_ding', + '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 ding', + 'platform': 'ring', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'last_ding', + 'unique_id': '765432-last_ding', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[sensor.front_last_ding-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Ring.com', + 'device_class': 'timestamp', + 'friendly_name': 'Front Last ding', + }), + 'context': , + 'entity_id': 'sensor.front_last_ding', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_states[sensor.front_last_motion-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.front_last_motion', + '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 motion', + 'platform': 'ring', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'last_motion', + 'unique_id': '765432-last_motion', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[sensor.front_last_motion-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Ring.com', + 'device_class': 'timestamp', + 'friendly_name': 'Front Last motion', + }), + 'context': , + 'entity_id': 'sensor.front_last_motion', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_states[sensor.front_wifi_signal_category-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -556,11 +748,11 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Wi-Fi signal strength', + 'original_name': 'Signal strength', 'platform': 'ring', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'wifi_signal_strength', + 'translation_key': None, 'unique_id': '765432-wifi_signal_strength', 'unit_of_measurement': 'dBm', }) @@ -570,7 +762,7 @@ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by Ring.com', 'device_class': 'signal_strength', - 'friendly_name': 'Front Wi-Fi signal strength', + 'friendly_name': 'Front Signal strength', 'unit_of_measurement': 'dBm', }), 'context': , @@ -893,11 +1085,11 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Wi-Fi signal strength', + 'original_name': 'Signal strength', 'platform': 'ring', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'wifi_signal_strength', + 'translation_key': None, 'unique_id': '185036587-wifi_signal_strength', 'unit_of_measurement': 'dBm', }) @@ -907,7 +1099,7 @@ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by Ring.com', 'device_class': 'signal_strength', - 'friendly_name': 'Ingress Wi-Fi signal strength', + 'friendly_name': 'Ingress Signal strength', 'unit_of_measurement': 'dBm', }), 'context': , @@ -1018,6 +1210,102 @@ 'state': 'unknown', }) # --- +# name: test_states[sensor.internal_last_ding-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.internal_last_ding', + '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 ding', + 'platform': 'ring', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'last_ding', + 'unique_id': '345678-last_ding', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[sensor.internal_last_ding-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Ring.com', + 'device_class': 'timestamp', + 'friendly_name': 'Internal Last ding', + }), + 'context': , + 'entity_id': 'sensor.internal_last_ding', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_states[sensor.internal_last_motion-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.internal_last_motion', + '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 motion', + 'platform': 'ring', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'last_motion', + 'unique_id': '345678-last_motion', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[sensor.internal_last_motion-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Ring.com', + 'device_class': 'timestamp', + 'friendly_name': 'Internal Last motion', + }), + 'context': , + 'entity_id': 'sensor.internal_last_motion', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_states[sensor.internal_wifi_signal_category-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1089,11 +1377,11 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Wi-Fi signal strength', + 'original_name': 'Signal strength', 'platform': 'ring', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'wifi_signal_strength', + 'translation_key': None, 'unique_id': '345678-wifi_signal_strength', 'unit_of_measurement': 'dBm', }) @@ -1103,7 +1391,7 @@ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by Ring.com', 'device_class': 'signal_strength', - 'friendly_name': 'Internal Wi-Fi signal strength', + 'friendly_name': 'Internal Signal strength', 'unit_of_measurement': 'dBm', }), 'context': , diff --git a/tests/components/ring/test_binary_sensor.py b/tests/components/ring/test_binary_sensor.py index 81d7d6e6687..c588b022265 100644 --- a/tests/components/ring/test_binary_sensor.py +++ b/tests/components/ring/test_binary_sensor.py @@ -18,7 +18,12 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er, issue_registry as ir from homeassistant.setup import async_setup_component -from .common import MockConfigEntry, setup_automation, setup_platform +from .common import ( + MockConfigEntry, + async_check_entity_translations, + setup_automation, + setup_platform, +) from .device_mocks import ( FRONT_DEVICE_ID, FRONT_DOOR_DEVICE_ID, @@ -67,6 +72,9 @@ async def test_states( ) -> None: """Test states.""" await setup_platform(hass, Platform.BINARY_SENSOR) + await async_check_entity_translations( + hass, entity_registry, mock_config_entry.entry_id, BINARY_SENSOR_DOMAIN + ) await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/ring/test_number.py b/tests/components/ring/test_number.py index aa484c6a7b2..9f1581742f2 100644 --- a/tests/components/ring/test_number.py +++ b/tests/components/ring/test_number.py @@ -14,7 +14,7 @@ from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from .common import MockConfigEntry, setup_platform +from .common import MockConfigEntry, async_check_entity_translations, setup_platform from tests.common import snapshot_platform @@ -54,6 +54,9 @@ async def test_states( mock_config_entry.add_to_hass(hass) await setup_platform(hass, Platform.NUMBER) + await async_check_entity_translations( + hass, entity_registry, mock_config_entry.entry_id, NUMBER_DOMAIN + ) await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/ring/test_sensor.py b/tests/components/ring/test_sensor.py index 48f679c4524..dcd3d5bddd6 100644 --- a/tests/components/ring/test_sensor.py +++ b/tests/components/ring/test_sensor.py @@ -16,7 +16,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component -from .common import MockConfigEntry, setup_platform +from .common import MockConfigEntry, async_check_entity_translations, setup_platform from .device_mocks import ( DOWNSTAIRS_DEVICE_ID, FRONT_DEVICE_ID, @@ -57,6 +57,10 @@ def create_deprecated_and_disabled_sensor_entities( create_entry("ingress", "doorbell_volume", INGRESS_DEVICE_ID) create_entry("ingress", "mic_volume", INGRESS_DEVICE_ID) create_entry("ingress", "voice_volume", INGRESS_DEVICE_ID) + for desc in ("last_motion", "last_ding"): + create_entry("front", desc, FRONT_DEVICE_ID) + create_entry("front_door", desc, FRONT_DOOR_DEVICE_ID) + create_entry("internal", desc, INTERNAL_DEVICE_ID) # Disabled for desc in ("wifi_signal_category", "wifi_signal_strength"): @@ -78,6 +82,9 @@ async def test_states( """Test states.""" mock_config_entry.add_to_hass(hass) await setup_platform(hass, Platform.SENSOR) + await async_check_entity_translations( + hass, entity_registry, mock_config_entry.entry_id, SENSOR_DOMAIN + ) await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) From 66f048f49f73d2c4c49583f685bb7894c856ce01 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Fri, 31 Jan 2025 13:15:22 +0100 Subject: [PATCH 0345/3148] Make Reolink reboot button always available (#136667) --- homeassistant/components/reolink/button.py | 3 ++- homeassistant/components/reolink/entity.py | 4 ++++ homeassistant/components/reolink/switch.py | 19 +------------------ 3 files changed, 7 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/reolink/button.py b/homeassistant/components/reolink/button.py index 6b1fcc65a2f..c1a2aed4119 100644 --- a/homeassistant/components/reolink/button.py +++ b/homeassistant/components/reolink/button.py @@ -138,6 +138,7 @@ BUTTON_ENTITIES = ( HOST_BUTTON_ENTITIES = ( ReolinkHostButtonEntityDescription( key="reboot", + always_available=True, device_class=ButtonDeviceClass.RESTART, entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, @@ -218,7 +219,7 @@ class ReolinkButtonEntity(ReolinkChannelCoordinatorEntity, ButtonEntity): class ReolinkHostButtonEntity(ReolinkHostCoordinatorEntity, ButtonEntity): - """Base button entity class for Reolink IP cameras.""" + """Base button entity class for Reolink hosts.""" entity_description: ReolinkHostButtonEntityDescription diff --git a/homeassistant/components/reolink/entity.py b/homeassistant/components/reolink/entity.py index 63c95c25025..e3a84579865 100644 --- a/homeassistant/components/reolink/entity.py +++ b/homeassistant/components/reolink/entity.py @@ -25,6 +25,7 @@ class ReolinkEntityDescription(EntityDescription): cmd_key: str | None = None cmd_id: int | None = None + always_available: bool = False @dataclass(frozen=True, kw_only=True) @@ -92,6 +93,9 @@ class ReolinkHostCoordinatorEntity(CoordinatorEntity[DataUpdateCoordinator[None] @property def available(self) -> bool: """Return True if entity is available.""" + if self.entity_description.always_available: + return True + return ( self._host.api.session_active and not self._host.api.baichuan.privacy_mode() diff --git a/homeassistant/components/reolink/switch.py b/homeassistant/components/reolink/switch.py index cecb0b0000f..a0b8824782a 100644 --- a/homeassistant/components/reolink/switch.py +++ b/homeassistant/components/reolink/switch.py @@ -206,11 +206,9 @@ SWITCH_ENTITIES = ( value=lambda api, ch: api.pir_reduce_alarm(ch) is True, method=lambda api, ch, value: api.set_pir(ch, reduce_alarm=value), ), -) - -AVAILABILITY_SWITCH_ENTITIES = ( ReolinkSwitchEntityDescription( key="privacy_mode", + always_available=True, translation_key="privacy_mode", entity_category=EntityCategory.CONFIG, supported=lambda api, ch: api.supported(ch, "privacy_mode"), @@ -355,12 +353,6 @@ async def async_setup_entry( for entity_description in CHIME_SWITCH_ENTITIES for chime in reolink_data.host.api.chime_list ) - entities.extend( - ReolinkAvailabilitySwitchEntity(reolink_data, channel, entity_description) - for entity_description in AVAILABILITY_SWITCH_ENTITIES - for channel in reolink_data.host.api.channels - if entity_description.supported(reolink_data.host.api, channel) - ) # Can be removed in HA 2025.4.0 depricated_dict = {} @@ -426,15 +418,6 @@ class ReolinkSwitchEntity(ReolinkChannelCoordinatorEntity, SwitchEntity): self.async_write_ha_state() -class ReolinkAvailabilitySwitchEntity(ReolinkSwitchEntity): - """Switch entity class for Reolink IP cameras which will be available even if API is unavailable.""" - - @property - def available(self) -> bool: - """Return True if entity is available.""" - return self._host.api.camera_online(self._channel) - - class ReolinkNVRSwitchEntity(ReolinkHostCoordinatorEntity, SwitchEntity): """Switch entity class for Reolink NVR features.""" From b702d88ab7b356744969dd93b9b6c320ff227cd4 Mon Sep 17 00:00:00 2001 From: RJPoelstra <36924801+RJPoelstra@users.noreply.github.com> Date: Fri, 31 Jan 2025 13:17:22 +0100 Subject: [PATCH 0346/3148] Use runtime_data in motionmount integration (#136999) --- homeassistant/components/motionmount/__init__.py | 12 ++++++++---- .../components/motionmount/binary_sensor.py | 13 ++++++++----- homeassistant/components/motionmount/entity.py | 6 ++++-- homeassistant/components/motionmount/number.py | 16 +++++++++++----- homeassistant/components/motionmount/select.py | 10 ++++++---- homeassistant/components/motionmount/sensor.py | 13 ++++++++----- 6 files changed, 45 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/motionmount/__init__.py b/homeassistant/components/motionmount/__init__.py index 9b27ce9bc6c..9c2ac6fa180 100644 --- a/homeassistant/components/motionmount/__init__.py +++ b/homeassistant/components/motionmount/__init__.py @@ -14,6 +14,8 @@ from homeassistant.helpers.device_registry import format_mac from .const import DOMAIN, EMPTY_MAC +type MotionMountConfigEntry = ConfigEntry[motionmount.MotionMount] + PLATFORMS: list[Platform] = [ Platform.BINARY_SENSOR, Platform.NUMBER, @@ -22,7 +24,7 @@ PLATFORMS: list[Platform] = [ ] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: MotionMountConfigEntry) -> bool: """Set up Vogel's MotionMount from a config entry.""" host = entry.data[CONF_HOST] @@ -65,17 +67,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) # Store an API object for your platforms to access - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = mm + entry.runtime_data = mm await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, entry: MotionMountConfigEntry +) -> bool: """Unload a config entry.""" if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - mm: motionmount.MotionMount = hass.data[DOMAIN].pop(entry.entry_id) + mm = entry.runtime_data await mm.disconnect() return unload_ok diff --git a/homeassistant/components/motionmount/binary_sensor.py b/homeassistant/components/motionmount/binary_sensor.py index 45b6e821440..f19af67e198 100644 --- a/homeassistant/components/motionmount/binary_sensor.py +++ b/homeassistant/components/motionmount/binary_sensor.py @@ -6,19 +6,20 @@ from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from . import MotionMountConfigEntry from .entity import MotionMountEntity async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: MotionMountConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up Vogel's MotionMount from a config entry.""" - mm = hass.data[DOMAIN][entry.entry_id] + mm = entry.runtime_data async_add_entities([MotionMountMovingSensor(mm, entry)]) @@ -29,7 +30,9 @@ class MotionMountMovingSensor(MotionMountEntity, BinarySensorEntity): _attr_device_class = BinarySensorDeviceClass.MOVING _attr_translation_key = "motionmount_is_moving" - def __init__(self, mm: motionmount.MotionMount, config_entry: ConfigEntry) -> None: + def __init__( + self, mm: motionmount.MotionMount, config_entry: MotionMountConfigEntry + ) -> None: """Initialize moving binary sensor entity.""" super().__init__(mm, config_entry) self._attr_unique_id = f"{self._base_unique_id}-moving" diff --git a/homeassistant/components/motionmount/entity.py b/homeassistant/components/motionmount/entity.py index 57a5f638d54..81d4d0119b5 100644 --- a/homeassistant/components/motionmount/entity.py +++ b/homeassistant/components/motionmount/entity.py @@ -5,12 +5,12 @@ from typing import TYPE_CHECKING import motionmount -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_CONNECTIONS, ATTR_IDENTIFIERS, CONF_PIN from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo, format_mac from homeassistant.helpers.entity import Entity +from . import MotionMountConfigEntry from .const import DOMAIN, EMPTY_MAC _LOGGER = logging.getLogger(__name__) @@ -22,7 +22,9 @@ class MotionMountEntity(Entity): _attr_should_poll = False _attr_has_entity_name = True - def __init__(self, mm: motionmount.MotionMount, config_entry: ConfigEntry) -> None: + def __init__( + self, mm: motionmount.MotionMount, config_entry: MotionMountConfigEntry + ) -> None: """Initialize general MotionMount entity.""" self.mm = mm self.config_entry = config_entry diff --git a/homeassistant/components/motionmount/number.py b/homeassistant/components/motionmount/number.py index b42c04a6588..6305820174f 100644 --- a/homeassistant/components/motionmount/number.py +++ b/homeassistant/components/motionmount/number.py @@ -5,21 +5,23 @@ import socket import motionmount from homeassistant.components.number import NumberEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback +from . import MotionMountConfigEntry from .const import DOMAIN from .entity import MotionMountEntity async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: MotionMountConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up Vogel's MotionMount from a config entry.""" - mm: motionmount.MotionMount = hass.data[DOMAIN][entry.entry_id] + mm = entry.runtime_data async_add_entities( ( @@ -37,7 +39,9 @@ class MotionMountExtension(MotionMountEntity, NumberEntity): _attr_native_unit_of_measurement = PERCENTAGE _attr_translation_key = "motionmount_extension" - def __init__(self, mm: motionmount.MotionMount, config_entry: ConfigEntry) -> None: + def __init__( + self, mm: motionmount.MotionMount, config_entry: MotionMountConfigEntry + ) -> None: """Initialize Extension number.""" super().__init__(mm, config_entry) self._attr_unique_id = f"{self._base_unique_id}-extension" @@ -66,7 +70,9 @@ class MotionMountTurn(MotionMountEntity, NumberEntity): _attr_native_unit_of_measurement = PERCENTAGE _attr_translation_key = "motionmount_turn" - def __init__(self, mm: motionmount.MotionMount, config_entry: ConfigEntry) -> None: + def __init__( + self, mm: motionmount.MotionMount, config_entry: MotionMountConfigEntry + ) -> None: """Initialize Turn number.""" super().__init__(mm, config_entry) self._attr_unique_id = f"{self._base_unique_id}-turn" diff --git a/homeassistant/components/motionmount/select.py b/homeassistant/components/motionmount/select.py index 23fcf576af0..31c5056b91f 100644 --- a/homeassistant/components/motionmount/select.py +++ b/homeassistant/components/motionmount/select.py @@ -7,11 +7,11 @@ import socket import motionmount from homeassistant.components.select import SelectEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback +from . import MotionMountConfigEntry from .const import DOMAIN, WALL_PRESET_NAME from .entity import MotionMountEntity @@ -20,10 +20,12 @@ SCAN_INTERVAL = timedelta(seconds=60) async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: MotionMountConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up Vogel's MotionMount from a config entry.""" - mm = hass.data[DOMAIN][entry.entry_id] + mm = entry.runtime_data async_add_entities([MotionMountPresets(mm, entry)], True) @@ -37,7 +39,7 @@ class MotionMountPresets(MotionMountEntity, SelectEntity): def __init__( self, mm: motionmount.MotionMount, - config_entry: ConfigEntry, + config_entry: MotionMountConfigEntry, ) -> None: """Initialize Preset selector.""" super().__init__(mm, config_entry) diff --git a/homeassistant/components/motionmount/sensor.py b/homeassistant/components/motionmount/sensor.py index 933b637b0c2..8e55fad4a8b 100644 --- a/homeassistant/components/motionmount/sensor.py +++ b/homeassistant/components/motionmount/sensor.py @@ -3,19 +3,20 @@ import motionmount from homeassistant.components.sensor import SensorDeviceClass, SensorEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from . import MotionMountConfigEntry from .entity import MotionMountEntity async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: MotionMountConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up Vogel's MotionMount from a config entry.""" - mm = hass.data[DOMAIN][entry.entry_id] + mm = entry.runtime_data async_add_entities((MotionMountErrorStatusSensor(mm, entry),)) @@ -27,7 +28,9 @@ class MotionMountErrorStatusSensor(MotionMountEntity, SensorEntity): _attr_options = ["none", "motor", "internal"] _attr_translation_key = "motionmount_error_status" - def __init__(self, mm: motionmount.MotionMount, config_entry: ConfigEntry) -> None: + def __init__( + self, mm: motionmount.MotionMount, config_entry: MotionMountConfigEntry + ) -> None: """Initialize sensor entiry.""" super().__init__(mm, config_entry) self._attr_unique_id = f"{self._base_unique_id}-error-status" From 8eb9cc0e8ef35273fc698878d2ea25f82981906d Mon Sep 17 00:00:00 2001 From: Cyrill Raccaud Date: Fri, 31 Jan 2025 13:19:04 +0100 Subject: [PATCH 0347/3148] Remove the unparsed config flow error from Swiss public transport (#136998) --- homeassistant/components/swiss_public_transport/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/swiss_public_transport/strings.json b/homeassistant/components/swiss_public_transport/strings.json index 270cb097e0a..64817f89f42 100644 --- a/homeassistant/components/swiss_public_transport/strings.json +++ b/homeassistant/components/swiss_public_transport/strings.json @@ -28,7 +28,7 @@ "time_station": "Usually the departure time of a connection when it leaves the start station is tracked. Alternatively, track the time when the connection arrives at its end station.", "time_mode": "Time mode lets you change the departure timing and fix it to a specific time (e.g. 7:12:00 AM every morning) or add a moving offset (e.g. +00:05:00 taking into account the time to walk to the station)." }, - "description": "Provide start and end station for your connection,\nand optionally up to 5 via stations.\n\nCheck the [stationboard]({stationboard_url}) for valid stations.", + "description": "Provide start and end station for your connection,\nand optionally up to 5 via stations.\n\nConsult the stationboard linked above.", "title": "Swiss Public Transport" }, "time_fixed": { From 0773e37dab53438e6e95e4c81b46de13ebb12191 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Fri, 31 Jan 2025 13:23:44 +0100 Subject: [PATCH 0348/3148] Create/delete lists at runtime in Bring integration (#130098) --- homeassistant/components/bring/coordinator.py | 34 +++++- homeassistant/components/bring/entity.py | 14 +-- .../components/bring/quality_scale.yaml | 4 +- homeassistant/components/bring/sensor.py | 35 ++++-- homeassistant/components/bring/todo.py | 28 +++-- tests/components/bring/fixtures/items.json | 2 +- tests/components/bring/fixtures/items2.json | 46 ++++++++ .../bring/fixtures/items_invitation.json | 2 +- .../bring/fixtures/items_shared.json | 2 +- tests/components/bring/fixtures/lists2.json | 9 ++ .../bring/snapshots/test_diagnostics.ambr | 4 +- tests/components/bring/test_diagnostics.py | 11 +- tests/components/bring/test_init.py | 101 +++++++++++++++++- tests/components/bring/test_sensor.py | 6 +- tests/components/bring/test_todo.py | 11 +- 15 files changed, 266 insertions(+), 43 deletions(-) create mode 100644 tests/components/bring/fixtures/items2.json create mode 100644 tests/components/bring/fixtures/lists2.json diff --git a/homeassistant/components/bring/coordinator.py b/homeassistant/components/bring/coordinator.py index 0511d285afc..9473d0614e3 100644 --- a/homeassistant/components/bring/coordinator.py +++ b/homeassistant/components/bring/coordinator.py @@ -19,6 +19,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_EMAIL from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN @@ -39,6 +40,7 @@ class BringDataUpdateCoordinator(DataUpdateCoordinator[dict[str, BringData]]): config_entry: ConfigEntry user_settings: BringUserSettingsResponse + lists: list[BringList] def __init__(self, hass: HomeAssistant, bring: Bring) -> None: """Initialize the Bring data coordinator.""" @@ -49,10 +51,13 @@ class BringDataUpdateCoordinator(DataUpdateCoordinator[dict[str, BringData]]): update_interval=timedelta(seconds=90), ) self.bring = bring + self.previous_lists: set[str] = set() async def _async_update_data(self) -> dict[str, BringData]: + """Fetch the latest data from bring.""" + try: - lists_response = await self.bring.load_lists() + self.lists = (await self.bring.load_lists()).lists except BringRequestException as e: raise UpdateFailed("Unable to connect and retrieve data from bring") from e except BringParseException as e: @@ -72,8 +77,14 @@ class BringDataUpdateCoordinator(DataUpdateCoordinator[dict[str, BringData]]): ) from exc return self.data + if self.previous_lists - ( + current_lists := {lst.listUuid for lst in self.lists} + ): + self._purge_deleted_lists() + self.previous_lists = current_lists + list_dict: dict[str, BringData] = {} - for lst in lists_response.lists: + for lst in self.lists: if (ctx := set(self.async_contexts())) and lst.listUuid not in ctx: continue try: @@ -95,6 +106,7 @@ class BringDataUpdateCoordinator(DataUpdateCoordinator[dict[str, BringData]]): try: await self.bring.login() self.user_settings = await self.bring.get_all_user_settings() + self.lists = (await self.bring.load_lists()).lists except BringRequestException as e: raise ConfigEntryNotReady( translation_domain=DOMAIN, @@ -111,3 +123,21 @@ class BringDataUpdateCoordinator(DataUpdateCoordinator[dict[str, BringData]]): translation_key="setup_authentication_exception", translation_placeholders={CONF_EMAIL: self.bring.mail}, ) from e + self._purge_deleted_lists() + + def _purge_deleted_lists(self) -> None: + """Purge device entries of deleted lists.""" + + device_reg = dr.async_get(self.hass) + identifiers = { + (DOMAIN, f"{self.config_entry.unique_id}_{lst.listUuid}") + for lst in self.lists + } + for device in dr.async_entries_for_config_entry( + device_reg, self.config_entry.entry_id + ): + if not set(device.identifiers) & identifiers: + _LOGGER.debug("Removing obsolete device entry %s", device.name) + device_reg.async_update_device( + device.id, remove_config_entry_id=self.config_entry.entry_id + ) diff --git a/homeassistant/components/bring/entity.py b/homeassistant/components/bring/entity.py index 74076d66df9..3de0140d82c 100644 --- a/homeassistant/components/bring/entity.py +++ b/homeassistant/components/bring/entity.py @@ -2,11 +2,13 @@ from __future__ import annotations +from bring_api.types import BringList + from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN -from .coordinator import BringData, BringDataUpdateCoordinator +from .coordinator import BringDataUpdateCoordinator class BringBaseEntity(CoordinatorEntity[BringDataUpdateCoordinator]): @@ -17,20 +19,20 @@ class BringBaseEntity(CoordinatorEntity[BringDataUpdateCoordinator]): def __init__( self, coordinator: BringDataUpdateCoordinator, - bring_list: BringData, + bring_list: BringList, ) -> None: """Initialize the entity.""" - super().__init__(coordinator, bring_list.lst.listUuid) + super().__init__(coordinator, bring_list.listUuid) - self._list_uuid = bring_list.lst.listUuid + self._list_uuid = bring_list.listUuid self.device_info = DeviceInfo( entry_type=DeviceEntryType.SERVICE, - name=bring_list.lst.name, + name=bring_list.name, identifiers={ (DOMAIN, f"{coordinator.config_entry.unique_id}_{self._list_uuid}") }, manufacturer="Bring! Labs AG", model="Bring! Grocery Shopping List", - configuration_url=f"https://web.getbring.com/app/lists/{list(self.coordinator.data.keys()).index(self._list_uuid)}", + configuration_url=f"https://web.getbring.com/app/lists/{list(self.coordinator.lists).index(bring_list)}", ) diff --git a/homeassistant/components/bring/quality_scale.yaml b/homeassistant/components/bring/quality_scale.yaml index 1fdb3f13f1b..0b4191d5c61 100644 --- a/homeassistant/components/bring/quality_scale.yaml +++ b/homeassistant/components/bring/quality_scale.yaml @@ -53,7 +53,7 @@ rules: docs-supported-functions: todo docs-troubleshooting: todo docs-use-cases: todo - dynamic-devices: todo + dynamic-devices: done entity-category: done entity-device-class: done entity-disabled-by-default: done @@ -65,7 +65,7 @@ rules: status: exempt comment: | no repairs - stale-devices: todo + stale-devices: done # Platinum async-dependency: done inject-websession: done diff --git a/homeassistant/components/bring/sensor.py b/homeassistant/components/bring/sensor.py index 02bd0e50788..651307a2eee 100644 --- a/homeassistant/components/bring/sensor.py +++ b/homeassistant/components/bring/sensor.py @@ -8,6 +8,7 @@ from enum import StrEnum from bring_api import BringUserSettingsResponse from bring_api.const import BRING_SUPPORTED_LOCALES +from bring_api.types import BringList from homeassistant.components.sensor import ( SensorDeviceClass, @@ -15,7 +16,7 @@ from homeassistant.components.sensor import ( SensorEntityDescription, ) from homeassistant.const import EntityCategory -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType @@ -90,16 +91,28 @@ async def async_setup_entry( ) -> None: """Set up the sensor platform.""" coordinator = config_entry.runtime_data + lists_added: set[str] = set() - async_add_entities( - BringSensorEntity( - coordinator, - bring_list, - description, - ) - for description in SENSOR_DESCRIPTIONS - for bring_list in coordinator.data.values() - ) + @callback + def add_entities() -> None: + """Add sensor entities.""" + nonlocal lists_added + + if new_lists := {lst.listUuid for lst in coordinator.lists} - lists_added: + async_add_entities( + BringSensorEntity( + coordinator, + bring_list, + description, + ) + for description in SENSOR_DESCRIPTIONS + for bring_list in coordinator.lists + if bring_list.listUuid in new_lists + ) + lists_added |= new_lists + + coordinator.async_add_listener(add_entities) + add_entities() class BringSensorEntity(BringBaseEntity, SensorEntity): @@ -110,7 +123,7 @@ class BringSensorEntity(BringBaseEntity, SensorEntity): def __init__( self, coordinator: BringDataUpdateCoordinator, - bring_list: BringData, + bring_list: BringList, entity_description: BringSensorEntityDescription, ) -> None: """Initialize the entity.""" diff --git a/homeassistant/components/bring/todo.py b/homeassistant/components/bring/todo.py index 7ab60084314..ad4de4196c1 100644 --- a/homeassistant/components/bring/todo.py +++ b/homeassistant/components/bring/todo.py @@ -12,6 +12,7 @@ from bring_api import ( BringNotificationType, BringRequestException, ) +from bring_api.types import BringList import voluptuous as vol from homeassistant.components.todo import ( @@ -20,7 +21,7 @@ from homeassistant.components.todo import ( TodoListEntity, TodoListEntityFeature, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -45,14 +46,23 @@ async def async_setup_entry( ) -> None: """Set up the sensor from a config entry created in the integrations UI.""" coordinator = config_entry.runtime_data + lists_added: set[str] = set() - async_add_entities( - BringTodoListEntity( - coordinator, - bring_list=bring_list, - ) - for bring_list in coordinator.data.values() - ) + @callback + def add_entities() -> None: + """Add or remove todo list entities.""" + nonlocal lists_added + + if new_lists := {lst.listUuid for lst in coordinator.lists} - lists_added: + async_add_entities( + BringTodoListEntity(coordinator, bring_list) + for bring_list in coordinator.lists + if bring_list.listUuid in new_lists + ) + lists_added |= new_lists + + coordinator.async_add_listener(add_entities) + add_entities() platform = entity_platform.async_get_current_platform() @@ -81,7 +91,7 @@ class BringTodoListEntity(BringBaseEntity, TodoListEntity): ) def __init__( - self, coordinator: BringDataUpdateCoordinator, bring_list: BringData + self, coordinator: BringDataUpdateCoordinator, bring_list: BringList ) -> None: """Initialize the entity.""" super().__init__(coordinator, bring_list) diff --git a/tests/components/bring/fixtures/items.json b/tests/components/bring/fixtures/items.json index eecdbaac8c7..02bfdc9e038 100644 --- a/tests/components/bring/fixtures/items.json +++ b/tests/components/bring/fixtures/items.json @@ -1,5 +1,5 @@ { - "uuid": "77a151f8-77c4-47a3-8295-c750a0e69d4f", + "uuid": "e542eef6-dba7-4c31-a52c-29e6ab9d83a5", "status": "REGISTERED", "items": { "purchase": [ diff --git a/tests/components/bring/fixtures/items2.json b/tests/components/bring/fixtures/items2.json new file mode 100644 index 00000000000..c8f2a5e9d02 --- /dev/null +++ b/tests/components/bring/fixtures/items2.json @@ -0,0 +1,46 @@ +{ + "uuid": "b4776778-7f6c-496e-951b-92a35d3db0dd", + "status": "REGISTERED", + "items": { + "purchase": [ + { + "uuid": "b5d0790b-5f32-4d5c-91da-e29066f167de", + "itemId": "Paprika", + "specification": "Rot", + "attributes": [ + { + "type": "PURCHASE_CONDITIONS", + "content": { + "urgent": true, + "convenient": true, + "discounted": true + } + } + ] + }, + { + "uuid": "72d370ab-d8ca-4e41-b956-91df94795b4e", + "itemId": "Pouletbrüstli", + "specification": "Bio", + "attributes": [ + { + "type": "PURCHASE_CONDITIONS", + "content": { + "urgent": true, + "convenient": true, + "discounted": true + } + } + ] + } + ], + "recently": [ + { + "uuid": "fc8db30a-647e-4e6c-9d71-3b85d6a2d954", + "itemId": "Ananas", + "specification": "", + "attributes": [] + } + ] + } +} diff --git a/tests/components/bring/fixtures/items_invitation.json b/tests/components/bring/fixtures/items_invitation.json index be3671c359a..6b6623011da 100644 --- a/tests/components/bring/fixtures/items_invitation.json +++ b/tests/components/bring/fixtures/items_invitation.json @@ -1,5 +1,5 @@ { - "uuid": "77a151f8-77c4-47a3-8295-c750a0e69d4f", + "uuid": "e542eef6-dba7-4c31-a52c-29e6ab9d83a5", "status": "INVITATION", "items": { "purchase": [ diff --git a/tests/components/bring/fixtures/items_shared.json b/tests/components/bring/fixtures/items_shared.json index 5e381d27ca8..6892e07e4e6 100644 --- a/tests/components/bring/fixtures/items_shared.json +++ b/tests/components/bring/fixtures/items_shared.json @@ -1,5 +1,5 @@ { - "uuid": "77a151f8-77c4-47a3-8295-c750a0e69d4f", + "uuid": "e542eef6-dba7-4c31-a52c-29e6ab9d83a5", "status": "SHARED", "items": { "purchase": [ diff --git a/tests/components/bring/fixtures/lists2.json b/tests/components/bring/fixtures/lists2.json new file mode 100644 index 00000000000..511de7bd181 --- /dev/null +++ b/tests/components/bring/fixtures/lists2.json @@ -0,0 +1,9 @@ +{ + "lists": [ + { + "listUuid": "e542eef6-dba7-4c31-a52c-29e6ab9d83a5", + "name": "Einkauf", + "theme": "ch.publisheria.bring.theme.home" + } + ] +} diff --git a/tests/components/bring/snapshots/test_diagnostics.ambr b/tests/components/bring/snapshots/test_diagnostics.ambr index 5955ded832a..740f4902fc3 100644 --- a/tests/components/bring/snapshots/test_diagnostics.ambr +++ b/tests/components/bring/snapshots/test_diagnostics.ambr @@ -47,7 +47,7 @@ ]), }), 'status': 'REGISTERED', - 'uuid': '77a151f8-77c4-47a3-8295-c750a0e69d4f', + 'uuid': 'b4776778-7f6c-496e-951b-92a35d3db0dd', }), 'lst': dict({ 'listUuid': 'b4776778-7f6c-496e-951b-92a35d3db0dd', @@ -101,7 +101,7 @@ ]), }), 'status': 'REGISTERED', - 'uuid': '77a151f8-77c4-47a3-8295-c750a0e69d4f', + 'uuid': 'e542eef6-dba7-4c31-a52c-29e6ab9d83a5', }), 'lst': dict({ 'listUuid': 'e542eef6-dba7-4c31-a52c-29e6ab9d83a5', diff --git a/tests/components/bring/test_diagnostics.py b/tests/components/bring/test_diagnostics.py index a86de5a0d2d..c4b8defca82 100644 --- a/tests/components/bring/test_diagnostics.py +++ b/tests/components/bring/test_diagnostics.py @@ -1,11 +1,15 @@ """Test for diagnostics platform of the Bring! integration.""" +from unittest.mock import AsyncMock + +from bring_api import BringItemsResponse import pytest from syrupy.assertion import SnapshotAssertion +from homeassistant.components.bring.const import DOMAIN from homeassistant.core import HomeAssistant -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, load_fixture from tests.components.diagnostics import get_diagnostics_for_config_entry from tests.typing import ClientSessionGenerator @@ -16,8 +20,13 @@ async def test_diagnostics( hass_client: ClientSessionGenerator, bring_config_entry: MockConfigEntry, snapshot: SnapshotAssertion, + mock_bring_client: AsyncMock, ) -> None: """Test diagnostics.""" + mock_bring_client.get_list.side_effect = [ + BringItemsResponse.from_json(load_fixture("items.json", DOMAIN)), + BringItemsResponse.from_json(load_fixture("items2.json", DOMAIN)), + ] bring_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(bring_config_entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/bring/test_init.py b/tests/components/bring/test_init.py index 8c215e024d5..a77c709315f 100644 --- a/tests/components/bring/test_init.py +++ b/tests/components/bring/test_init.py @@ -3,7 +3,12 @@ from datetime import timedelta from unittest.mock import AsyncMock -from bring_api import BringAuthException, BringParseException, BringRequestException +from bring_api import ( + BringAuthException, + BringListResponse, + BringParseException, + BringRequestException, +) from freezegun.api import FrozenDateTimeFactory import pytest @@ -16,7 +21,7 @@ from homeassistant.helpers import device_registry as dr from .conftest import UUID -from tests.common import MockConfigEntry, async_fire_time_changed +from tests.common import MockConfigEntry, async_fire_time_changed, load_fixture async def setup_integration( @@ -115,6 +120,25 @@ async def test_config_entry_not_ready( assert bring_config_entry.state is ConfigEntryState.SETUP_RETRY +@pytest.mark.parametrize("exception", [BringRequestException, BringParseException]) +async def test_config_entry_not_ready_udpdate_failed( + hass: HomeAssistant, + bring_config_entry: MockConfigEntry, + mock_bring_client: AsyncMock, + exception: Exception, +) -> None: + """Test config entry not ready from update failed in _async_update_data.""" + mock_bring_client.load_lists.side_effect = [ + mock_bring_client.load_lists.return_value, + exception, + ] + bring_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(bring_config_entry.entry_id) + await hass.async_block_till_done() + + assert bring_config_entry.state is ConfigEntryState.SETUP_RETRY + + @pytest.mark.parametrize( ("exception", "state"), [ @@ -133,7 +157,10 @@ async def test_config_entry_not_ready_auth_error( ) -> None: """Test config entry not ready from authentication error.""" - mock_bring_client.load_lists.side_effect = BringAuthException + mock_bring_client.load_lists.side_effect = [ + mock_bring_client.load_lists.return_value, + BringAuthException, + ] mock_bring_client.retrieve_new_access_token.side_effect = exception bring_config_entry.add_to_hass(hass) @@ -170,3 +197,71 @@ async def test_coordinator_skips_deactivated( await hass.async_block_till_done() assert mock_bring_client.get_list.await_count == 1 + + +async def test_purge_devices( + hass: HomeAssistant, + bring_config_entry: MockConfigEntry, + mock_bring_client: AsyncMock, + device_registry: dr.DeviceRegistry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test removing device entry of deleted list.""" + list_uuid = "b4776778-7f6c-496e-951b-92a35d3db0dd" + await setup_integration(hass, bring_config_entry) + + assert bring_config_entry.state is ConfigEntryState.LOADED + + assert device_registry.async_get_device( + {(DOMAIN, f"{bring_config_entry.unique_id}_{list_uuid}")} + ) + + mock_bring_client.load_lists.return_value = BringListResponse.from_json( + load_fixture("lists2.json", DOMAIN) + ) + + freezer.tick(timedelta(seconds=90)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert ( + device_registry.async_get_device( + {(DOMAIN, f"{bring_config_entry.unique_id}_{list_uuid}")} + ) + is None + ) + + +async def test_create_devices( + hass: HomeAssistant, + bring_config_entry: MockConfigEntry, + mock_bring_client: AsyncMock, + device_registry: dr.DeviceRegistry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test create device entry for new lists.""" + list_uuid = "b4776778-7f6c-496e-951b-92a35d3db0dd" + mock_bring_client.load_lists.return_value = BringListResponse.from_json( + load_fixture("lists2.json", DOMAIN) + ) + await setup_integration(hass, bring_config_entry) + + assert bring_config_entry.state is ConfigEntryState.LOADED + + assert ( + device_registry.async_get_device( + {(DOMAIN, f"{bring_config_entry.unique_id}_{list_uuid}")} + ) + is None + ) + + mock_bring_client.load_lists.return_value = BringListResponse.from_json( + load_fixture("lists.json", DOMAIN) + ) + freezer.tick(timedelta(seconds=90)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert device_registry.async_get_device( + {(DOMAIN, f"{bring_config_entry.unique_id}_{list_uuid}")} + ) diff --git a/tests/components/bring/test_sensor.py b/tests/components/bring/test_sensor.py index 442fea5a247..f704debcea9 100644 --- a/tests/components/bring/test_sensor.py +++ b/tests/components/bring/test_sensor.py @@ -26,15 +26,19 @@ def sensor_only() -> Generator[None]: yield -@pytest.mark.usefixtures("mock_bring_client") async def test_setup( hass: HomeAssistant, bring_config_entry: MockConfigEntry, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, + mock_bring_client: AsyncMock, ) -> None: """Snapshot test states of sensor platform.""" + mock_bring_client.get_list.side_effect = [ + BringItemsResponse.from_json(load_fixture("items.json", DOMAIN)), + BringItemsResponse.from_json(load_fixture("items2.json", DOMAIN)), + ] bring_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(bring_config_entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/bring/test_todo.py b/tests/components/bring/test_todo.py index 9cc4ae3d888..9df7b892db8 100644 --- a/tests/components/bring/test_todo.py +++ b/tests/components/bring/test_todo.py @@ -4,10 +4,11 @@ from collections.abc import Generator import re from unittest.mock import AsyncMock, patch -from bring_api import BringItemOperation, BringRequestException +from bring_api import BringItemOperation, BringItemsResponse, BringRequestException import pytest from syrupy.assertion import SnapshotAssertion +from homeassistant.components.bring.const import DOMAIN from homeassistant.components.todo import ( ATTR_DESCRIPTION, ATTR_ITEM, @@ -21,7 +22,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er -from tests.common import MockConfigEntry, snapshot_platform +from tests.common import MockConfigEntry, load_fixture, snapshot_platform @pytest.fixture(autouse=True) @@ -40,9 +41,13 @@ async def test_todo( bring_config_entry: MockConfigEntry, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, + mock_bring_client: AsyncMock, ) -> None: """Snapshot test states of todo platform.""" - + mock_bring_client.get_list.side_effect = [ + BringItemsResponse.from_json(load_fixture("items.json", DOMAIN)), + BringItemsResponse.from_json(load_fixture("items2.json", DOMAIN)), + ] bring_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(bring_config_entry.entry_id) await hass.async_block_till_done() From d4a355e6847fbb8612c3446471b302017f3c4553 Mon Sep 17 00:00:00 2001 From: RJPoelstra <36924801+RJPoelstra@users.noreply.github.com> Date: Fri, 31 Jan 2025 13:29:07 +0100 Subject: [PATCH 0349/3148] Bump python-MotionMount to 2.3.0 (#136985) --- homeassistant/components/motionmount/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/motionmount/manifest.json b/homeassistant/components/motionmount/manifest.json index 1fa3d31cfab..422be417006 100644 --- a/homeassistant/components/motionmount/manifest.json +++ b/homeassistant/components/motionmount/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/motionmount", "integration_type": "device", "iot_class": "local_push", - "requirements": ["python-MotionMount==2.2.0"], + "requirements": ["python-MotionMount==2.3.0"], "zeroconf": ["_tvm._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 8a579ca6ba0..6bb68d58a50 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2346,7 +2346,7 @@ pytautulli==23.1.1 pythinkingcleaner==0.0.3 # homeassistant.components.motionmount -python-MotionMount==2.2.0 +python-MotionMount==2.3.0 # homeassistant.components.awair python-awair==0.2.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7612c8466d3..0f7cef8c557 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1912,7 +1912,7 @@ pyswitchbee==1.8.3 pytautulli==23.1.1 # homeassistant.components.motionmount -python-MotionMount==2.2.0 +python-MotionMount==2.3.0 # homeassistant.components.awair python-awair==0.2.4 From 21ffcf853b31500cbdd1a85489395de5c81bd4dd Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Fri, 31 Jan 2025 13:39:59 +0100 Subject: [PATCH 0350/3148] Call backup listener during setup in onedrive (#136990) --- homeassistant/components/onedrive/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/onedrive/__init__.py b/homeassistant/components/onedrive/__init__.py index 7419ca6e20c..4ae5ac73560 100644 --- a/homeassistant/components/onedrive/__init__.py +++ b/homeassistant/components/onedrive/__init__.py @@ -97,6 +97,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: OneDriveConfigEntry) -> backup_folder_id=backup_folder_id, ) + _async_notify_backup_listeners_soon(hass) + return True From 84ae476b678fa0e593e83a01db16359ca021189b Mon Sep 17 00:00:00 2001 From: Jakob Schlyter Date: Fri, 31 Jan 2025 15:22:25 +0100 Subject: [PATCH 0351/3148] Energy distance units (#136933) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Abílio Costa --- homeassistant/components/number/const.py | 11 +++++ .../components/recorder/statistics.py | 2 + .../components/recorder/websocket_api.py | 2 + homeassistant/components/sensor/const.py | 14 +++++++ .../components/sensor/device_condition.py | 3 ++ .../components/sensor/device_trigger.py | 3 ++ homeassistant/components/sensor/strings.json | 5 +++ homeassistant/const.py | 9 +++++ homeassistant/util/unit_conversion.py | 28 +++++++++++++ tests/util/test_unit_conversion.py | 40 +++++++++++++++++++ 10 files changed, 117 insertions(+) diff --git a/homeassistant/components/number/const.py b/homeassistant/components/number/const.py index 1a9c6c91ca7..463fcc919c7 100644 --- a/homeassistant/components/number/const.py +++ b/homeassistant/components/number/const.py @@ -23,6 +23,7 @@ from homeassistant.const import ( UnitOfElectricCurrent, UnitOfElectricPotential, UnitOfEnergy, + UnitOfEnergyDistance, UnitOfFrequency, UnitOfInformation, UnitOfIrradiance, @@ -166,6 +167,15 @@ class NumberDeviceClass(StrEnum): Unit of measurement: `J`, `kJ`, `MJ`, `GJ`, `mWh`, `Wh`, `kWh`, `MWh`, `GWh`, `TWh`, `cal`, `kcal`, `Mcal`, `Gcal` """ + ENERGY_DISTANCE = "energy_distance" + """Energy distance. + + Use this device class for sensors measuring energy by distance, for example the amount + of electric energy consumed by an electric car. + + Unit of measurement: `kWh/100km`, `mi/kWh`, `km/kWh` + """ + ENERGY_STORAGE = "energy_storage" """Stored energy. @@ -447,6 +457,7 @@ DEVICE_CLASS_UNITS: dict[NumberDeviceClass, set[type[StrEnum] | str | None]] = { UnitOfTime.MILLISECONDS, }, NumberDeviceClass.ENERGY: set(UnitOfEnergy), + NumberDeviceClass.ENERGY_DISTANCE: set(UnitOfEnergyDistance), NumberDeviceClass.ENERGY_STORAGE: set(UnitOfEnergy), NumberDeviceClass.FREQUENCY: set(UnitOfFrequency), NumberDeviceClass.GAS: { diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index 8995f57ef30..2b6640270ed 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -38,6 +38,7 @@ from homeassistant.util.unit_conversion import ( ElectricCurrentConverter, ElectricPotentialConverter, EnergyConverter, + EnergyDistanceConverter, InformationConverter, MassConverter, PowerConverter, @@ -147,6 +148,7 @@ STATISTIC_UNIT_TO_UNIT_CONVERTER: dict[str | None, type[BaseUnitConverter]] = { for unit in ElectricPotentialConverter.VALID_UNITS }, **{unit: EnergyConverter for unit in EnergyConverter.VALID_UNITS}, + **{unit: EnergyDistanceConverter for unit in EnergyDistanceConverter.VALID_UNITS}, **{unit: InformationConverter for unit in InformationConverter.VALID_UNITS}, **{unit: MassConverter for unit in MassConverter.VALID_UNITS}, **{unit: PowerConverter for unit in PowerConverter.VALID_UNITS}, diff --git a/homeassistant/components/recorder/websocket_api.py b/homeassistant/components/recorder/websocket_api.py index ee5c5dd6d75..03d9e725170 100644 --- a/homeassistant/components/recorder/websocket_api.py +++ b/homeassistant/components/recorder/websocket_api.py @@ -25,6 +25,7 @@ from homeassistant.util.unit_conversion import ( ElectricCurrentConverter, ElectricPotentialConverter, EnergyConverter, + EnergyDistanceConverter, InformationConverter, MassConverter, PowerConverter, @@ -67,6 +68,7 @@ UNIT_SCHEMA = vol.Schema( vol.Optional("electric_current"): vol.In(ElectricCurrentConverter.VALID_UNITS), vol.Optional("voltage"): vol.In(ElectricPotentialConverter.VALID_UNITS), vol.Optional("energy"): vol.In(EnergyConverter.VALID_UNITS), + vol.Optional("energy_distance"): vol.In(EnergyDistanceConverter.VALID_UNITS), vol.Optional("information"): vol.In(InformationConverter.VALID_UNITS), vol.Optional("mass"): vol.In(MassConverter.VALID_UNITS), vol.Optional("power"): vol.In(PowerConverter.VALID_UNITS), diff --git a/homeassistant/components/sensor/const.py b/homeassistant/components/sensor/const.py index aaa14f4637c..59a87c419e0 100644 --- a/homeassistant/components/sensor/const.py +++ b/homeassistant/components/sensor/const.py @@ -23,6 +23,7 @@ from homeassistant.const import ( UnitOfElectricCurrent, UnitOfElectricPotential, UnitOfEnergy, + UnitOfEnergyDistance, UnitOfFrequency, UnitOfInformation, UnitOfIrradiance, @@ -51,6 +52,7 @@ from homeassistant.util.unit_conversion import ( ElectricCurrentConverter, ElectricPotentialConverter, EnergyConverter, + EnergyDistanceConverter, InformationConverter, MassConverter, PowerConverter, @@ -194,6 +196,15 @@ class SensorDeviceClass(StrEnum): Unit of measurement: `J`, `kJ`, `MJ`, `GJ`, `mWh`, `Wh`, `kWh`, `MWh`, `GWh`, `TWh`, `cal`, `kcal`, `Mcal`, `Gcal` """ + ENERGY_DISTANCE = "energy_distance" + """Energy distance. + + Use this device class for sensors measuring energy by distance, for example the amount + of electric energy consumed by an electric car. + + Unit of measurement: `kWh/100km`, `mi/kWh`, `km/kWh` + """ + ENERGY_STORAGE = "energy_storage" """Stored energy. @@ -500,6 +511,7 @@ UNIT_CONVERTERS: dict[SensorDeviceClass | str | None, type[BaseUnitConverter]] = SensorDeviceClass.DISTANCE: DistanceConverter, SensorDeviceClass.DURATION: DurationConverter, SensorDeviceClass.ENERGY: EnergyConverter, + SensorDeviceClass.ENERGY_DISTANCE: EnergyDistanceConverter, SensorDeviceClass.ENERGY_STORAGE: EnergyConverter, SensorDeviceClass.GAS: VolumeConverter, SensorDeviceClass.POWER: PowerConverter, @@ -541,6 +553,7 @@ DEVICE_CLASS_UNITS: dict[SensorDeviceClass, set[type[StrEnum] | str | None]] = { UnitOfTime.MILLISECONDS, }, SensorDeviceClass.ENERGY: set(UnitOfEnergy), + SensorDeviceClass.ENERGY_DISTANCE: set(UnitOfEnergyDistance), SensorDeviceClass.ENERGY_STORAGE: set(UnitOfEnergy), SensorDeviceClass.FREQUENCY: set(UnitOfFrequency), SensorDeviceClass.GAS: { @@ -622,6 +635,7 @@ DEVICE_CLASS_STATE_CLASSES: dict[SensorDeviceClass, set[SensorStateClass]] = { SensorStateClass.TOTAL, SensorStateClass.TOTAL_INCREASING, }, + SensorDeviceClass.ENERGY_DISTANCE: {SensorStateClass.MEASUREMENT}, SensorDeviceClass.ENERGY_STORAGE: {SensorStateClass.MEASUREMENT}, SensorDeviceClass.ENUM: set(), SensorDeviceClass.FREQUENCY: {SensorStateClass.MEASUREMENT}, diff --git a/homeassistant/components/sensor/device_condition.py b/homeassistant/components/sensor/device_condition.py index fc25dce18fc..4a68fbabe8f 100644 --- a/homeassistant/components/sensor/device_condition.py +++ b/homeassistant/components/sensor/device_condition.py @@ -48,6 +48,7 @@ CONF_IS_DATA_SIZE = "is_data_size" CONF_IS_DISTANCE = "is_distance" CONF_IS_DURATION = "is_duration" CONF_IS_ENERGY = "is_energy" +CONF_IS_ENERGY_DISTANCE = "is_energy_distance" CONF_IS_FREQUENCY = "is_frequency" CONF_IS_HUMIDITY = "is_humidity" CONF_IS_GAS = "is_gas" @@ -102,6 +103,7 @@ ENTITY_CONDITIONS = { SensorDeviceClass.DISTANCE: [{CONF_TYPE: CONF_IS_DISTANCE}], SensorDeviceClass.DURATION: [{CONF_TYPE: CONF_IS_DURATION}], SensorDeviceClass.ENERGY: [{CONF_TYPE: CONF_IS_ENERGY}], + SensorDeviceClass.ENERGY_DISTANCE: [{CONF_TYPE: CONF_IS_ENERGY_DISTANCE}], SensorDeviceClass.ENERGY_STORAGE: [{CONF_TYPE: CONF_IS_ENERGY}], SensorDeviceClass.FREQUENCY: [{CONF_TYPE: CONF_IS_FREQUENCY}], SensorDeviceClass.GAS: [{CONF_TYPE: CONF_IS_GAS}], @@ -168,6 +170,7 @@ CONDITION_SCHEMA = vol.All( CONF_IS_DISTANCE, CONF_IS_DURATION, CONF_IS_ENERGY, + CONF_IS_ENERGY_DISTANCE, CONF_IS_FREQUENCY, CONF_IS_GAS, CONF_IS_HUMIDITY, diff --git a/homeassistant/components/sensor/device_trigger.py b/homeassistant/components/sensor/device_trigger.py index d75b3aa6e41..0003b83d05a 100644 --- a/homeassistant/components/sensor/device_trigger.py +++ b/homeassistant/components/sensor/device_trigger.py @@ -47,6 +47,7 @@ CONF_DATA_SIZE = "data_size" CONF_DISTANCE = "distance" CONF_DURATION = "duration" CONF_ENERGY = "energy" +CONF_ENERGY_DISTANCE = "energy_distance" CONF_FREQUENCY = "frequency" CONF_GAS = "gas" CONF_HUMIDITY = "humidity" @@ -101,6 +102,7 @@ ENTITY_TRIGGERS = { SensorDeviceClass.DISTANCE: [{CONF_TYPE: CONF_DISTANCE}], SensorDeviceClass.DURATION: [{CONF_TYPE: CONF_DURATION}], SensorDeviceClass.ENERGY: [{CONF_TYPE: CONF_ENERGY}], + SensorDeviceClass.ENERGY_DISTANCE: [{CONF_TYPE: CONF_ENERGY_DISTANCE}], SensorDeviceClass.ENERGY_STORAGE: [{CONF_TYPE: CONF_ENERGY}], SensorDeviceClass.FREQUENCY: [{CONF_TYPE: CONF_FREQUENCY}], SensorDeviceClass.GAS: [{CONF_TYPE: CONF_GAS}], @@ -168,6 +170,7 @@ TRIGGER_SCHEMA = vol.All( CONF_DISTANCE, CONF_DURATION, CONF_ENERGY, + CONF_ENERGY_DISTANCE, CONF_FREQUENCY, CONF_GAS, CONF_HUMIDITY, diff --git a/homeassistant/components/sensor/strings.json b/homeassistant/components/sensor/strings.json index d44d621f82d..dcbb4d3c826 100644 --- a/homeassistant/components/sensor/strings.json +++ b/homeassistant/components/sensor/strings.json @@ -17,6 +17,7 @@ "is_distance": "Current {entity_name} distance", "is_duration": "Current {entity_name} duration", "is_energy": "Current {entity_name} energy", + "is_energy_distance": "Current {entity_name} energy per distance", "is_frequency": "Current {entity_name} frequency", "is_gas": "Current {entity_name} gas", "is_humidity": "Current {entity_name} humidity", @@ -69,6 +70,7 @@ "distance": "{entity_name} distance changes", "duration": "{entity_name} duration changes", "energy": "{entity_name} energy changes", + "energy_distance": "{entity_name} energy per distance changes", "frequency": "{entity_name} frequency changes", "gas": "{entity_name} gas changes", "humidity": "{entity_name} humidity changes", @@ -183,6 +185,9 @@ "energy": { "name": "Energy" }, + "energy_distance": { + "name": "Energy per distance" + }, "energy_storage": { "name": "Stored energy" }, diff --git a/homeassistant/const.py b/homeassistant/const.py index bdce303e64a..7775b618795 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -632,6 +632,15 @@ class UnitOfEnergy(StrEnum): GIGA_CALORIE = "Gcal" +# Energy Distance units +class UnitOfEnergyDistance(StrEnum): + """Energy Distance units.""" + + KILO_WATT_HOUR_PER_100_KM = "kWh/100km" + MILES_PER_KILO_WATT_HOUR = "mi/kWh" + KM_PER_KILO_WATT_HOUR = "km/kWh" + + # Electric_current units class UnitOfElectricCurrent(StrEnum): """Electric current units.""" diff --git a/homeassistant/util/unit_conversion.py b/homeassistant/util/unit_conversion.py index ad320cdb9ae..67258c9cd09 100644 --- a/homeassistant/util/unit_conversion.py +++ b/homeassistant/util/unit_conversion.py @@ -17,6 +17,7 @@ from homeassistant.const import ( UnitOfElectricCurrent, UnitOfElectricPotential, UnitOfEnergy, + UnitOfEnergyDistance, UnitOfInformation, UnitOfLength, UnitOfMass, @@ -90,6 +91,7 @@ class BaseUnitConverter: VALID_UNITS: set[str | None] _UNIT_CONVERSION: dict[str | None, float] + _UNIT_INVERSES: set[str] = set() @classmethod def convert(cls, value: float, from_unit: str | None, to_unit: str | None) -> float: @@ -105,6 +107,8 @@ class BaseUnitConverter: if from_unit == to_unit: return lambda value: value from_ratio, to_ratio = cls._get_from_to_ratio(from_unit, to_unit) + if cls._are_unit_inverses(from_unit, to_unit): + return lambda val: to_ratio / (val / from_ratio) return lambda val: (val / from_ratio) * to_ratio @classmethod @@ -129,6 +133,8 @@ class BaseUnitConverter: if from_unit == to_unit: return lambda value: value from_ratio, to_ratio = cls._get_from_to_ratio(from_unit, to_unit) + if cls._are_unit_inverses(from_unit, to_unit): + return lambda val: None if val is None else to_ratio / (val / from_ratio) return lambda val: None if val is None else (val / from_ratio) * to_ratio @classmethod @@ -138,6 +144,12 @@ class BaseUnitConverter: from_ratio, to_ratio = cls._get_from_to_ratio(from_unit, to_unit) return from_ratio / to_ratio + @classmethod + @lru_cache + def _are_unit_inverses(cls, from_unit: str | None, to_unit: str | None) -> bool: + """Return true if one unit is an inverse but not the other.""" + return (from_unit in cls._UNIT_INVERSES) != (to_unit in cls._UNIT_INVERSES) + class DataRateConverter(BaseUnitConverter): """Utility to convert data rate values.""" @@ -284,6 +296,22 @@ class EnergyConverter(BaseUnitConverter): VALID_UNITS = set(UnitOfEnergy) +class EnergyDistanceConverter(BaseUnitConverter): + """Utility to convert vehicle energy consumption values.""" + + UNIT_CLASS = "energy_distance" + _UNIT_CONVERSION: dict[str | None, float] = { + UnitOfEnergyDistance.KILO_WATT_HOUR_PER_100_KM: 1, + UnitOfEnergyDistance.MILES_PER_KILO_WATT_HOUR: 100 * _KM_TO_M / _MILE_TO_M, + UnitOfEnergyDistance.KM_PER_KILO_WATT_HOUR: 100, + } + _UNIT_INVERSES: set[str] = { + UnitOfEnergyDistance.MILES_PER_KILO_WATT_HOUR, + UnitOfEnergyDistance.KM_PER_KILO_WATT_HOUR, + } + VALID_UNITS = set(UnitOfEnergyDistance) + + class InformationConverter(BaseUnitConverter): """Utility to convert information values.""" diff --git a/tests/util/test_unit_conversion.py b/tests/util/test_unit_conversion.py index 1336364f4cb..aeea4ad9a5a 100644 --- a/tests/util/test_unit_conversion.py +++ b/tests/util/test_unit_conversion.py @@ -18,6 +18,7 @@ from homeassistant.const import ( UnitOfElectricCurrent, UnitOfElectricPotential, UnitOfEnergy, + UnitOfEnergyDistance, UnitOfInformation, UnitOfLength, UnitOfMass, @@ -43,6 +44,7 @@ from homeassistant.util.unit_conversion import ( ElectricCurrentConverter, ElectricPotentialConverter, EnergyConverter, + EnergyDistanceConverter, InformationConverter, MassConverter, PowerConverter, @@ -79,6 +81,7 @@ _ALL_CONVERTERS: dict[type[BaseUnitConverter], list[str | None]] = { SpeedConverter, TemperatureConverter, UnitlessRatioConverter, + EnergyDistanceConverter, VolumeConverter, VolumeFlowRateConverter, ) @@ -115,6 +118,11 @@ _GET_UNIT_RATIO: dict[type[BaseUnitConverter], tuple[str | None, str | None, flo 1000, ), EnergyConverter: (UnitOfEnergy.WATT_HOUR, UnitOfEnergy.KILO_WATT_HOUR, 1000), + EnergyDistanceConverter: ( + UnitOfEnergyDistance.MILES_PER_KILO_WATT_HOUR, + UnitOfEnergyDistance.KM_PER_KILO_WATT_HOUR, + 0.621371, + ), InformationConverter: (UnitOfInformation.BITS, UnitOfInformation.BYTES, 8), MassConverter: (UnitOfMass.STONES, UnitOfMass.KILOGRAMS, 0.157473), PowerConverter: (UnitOfPower.WATT, UnitOfPower.KILO_WATT, 1000), @@ -486,6 +494,38 @@ _CONVERTED_VALUE: dict[ (10, UnitOfEnergy.GIGA_CALORIE, 10000, UnitOfEnergy.MEGA_CALORIE), (10, UnitOfEnergy.GIGA_CALORIE, 11.622222, UnitOfEnergy.MEGA_WATT_HOUR), ], + EnergyDistanceConverter: [ + ( + 10, + UnitOfEnergyDistance.KILO_WATT_HOUR_PER_100_KM, + 6.213712, + UnitOfEnergyDistance.MILES_PER_KILO_WATT_HOUR, + ), + ( + 25, + UnitOfEnergyDistance.KILO_WATT_HOUR_PER_100_KM, + 4, + UnitOfEnergyDistance.KM_PER_KILO_WATT_HOUR, + ), + ( + 20, + UnitOfEnergyDistance.MILES_PER_KILO_WATT_HOUR, + 3.106856, + UnitOfEnergyDistance.KILO_WATT_HOUR_PER_100_KM, + ), + ( + 10, + UnitOfEnergyDistance.MILES_PER_KILO_WATT_HOUR, + 16.09344, + UnitOfEnergyDistance.KM_PER_KILO_WATT_HOUR, + ), + ( + 16.09344, + UnitOfEnergyDistance.KM_PER_KILO_WATT_HOUR, + 10, + UnitOfEnergyDistance.MILES_PER_KILO_WATT_HOUR, + ), + ], InformationConverter: [ (8e3, UnitOfInformation.BITS, 8, UnitOfInformation.KILOBITS), (8e6, UnitOfInformation.BITS, 8, UnitOfInformation.MEGABITS), From 6f1539f60defe7150777014001806182aabdc9eb Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 31 Jan 2025 16:32:11 +0100 Subject: [PATCH 0352/3148] Use device name as entity name in Eheim digital climate (#136997) --- .../components/eheimdigital/climate.py | 1 + .../eheimdigital/snapshots/test_climate.ambr | 20 +++++++++---------- tests/components/eheimdigital/test_climate.py | 16 +++++++-------- 3 files changed, 19 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/eheimdigital/climate.py b/homeassistant/components/eheimdigital/climate.py index 9b1f825dece..7ad06659089 100644 --- a/homeassistant/components/eheimdigital/climate.py +++ b/homeassistant/components/eheimdigital/climate.py @@ -76,6 +76,7 @@ class EheimDigitalHeaterClimate(EheimDigitalEntity[EheimDigitalHeater], ClimateE _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_preset_mode = PRESET_NONE _attr_translation_key = "heater" + _attr_name = None def __init__( self, coordinator: EheimDigitalUpdateCoordinator, device: EheimDigitalHeater diff --git a/tests/components/eheimdigital/snapshots/test_climate.ambr b/tests/components/eheimdigital/snapshots/test_climate.ambr index d81c59e5af1..171d3d427fc 100644 --- a/tests/components/eheimdigital/snapshots/test_climate.ambr +++ b/tests/components/eheimdigital/snapshots/test_climate.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_dynamic_new_devices[climate.mock_heater_none-entry] +# name: test_dynamic_new_devices[climate.mock_heater-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -24,7 +24,7 @@ 'disabled_by': None, 'domain': 'climate', 'entity_category': None, - 'entity_id': 'climate.mock_heater_none', + 'entity_id': 'climate.mock_heater', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -45,11 +45,11 @@ 'unit_of_measurement': None, }) # --- -# name: test_dynamic_new_devices[climate.mock_heater_none-state] +# name: test_dynamic_new_devices[climate.mock_heater-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'current_temperature': 24.2, - 'friendly_name': 'Mock Heater None', + 'friendly_name': 'Mock Heater', 'hvac_action': , 'hvac_modes': list([ , @@ -68,14 +68,14 @@ 'temperature': 25.5, }), 'context': , - 'entity_id': 'climate.mock_heater_none', + 'entity_id': 'climate.mock_heater', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'auto', }) # --- -# name: test_setup_heater[climate.mock_heater_none-entry] +# name: test_setup_heater[climate.mock_heater-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -100,7 +100,7 @@ 'disabled_by': None, 'domain': 'climate', 'entity_category': None, - 'entity_id': 'climate.mock_heater_none', + 'entity_id': 'climate.mock_heater', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -121,11 +121,11 @@ 'unit_of_measurement': None, }) # --- -# name: test_setup_heater[climate.mock_heater_none-state] +# name: test_setup_heater[climate.mock_heater-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'current_temperature': 24.2, - 'friendly_name': 'Mock Heater None', + 'friendly_name': 'Mock Heater', 'hvac_action': , 'hvac_modes': list([ , @@ -144,7 +144,7 @@ 'temperature': 25.5, }), 'context': , - 'entity_id': 'climate.mock_heater_none', + 'entity_id': 'climate.mock_heater', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/eheimdigital/test_climate.py b/tests/components/eheimdigital/test_climate.py index f64b7d7e740..f1f29ce9d34 100644 --- a/tests/components/eheimdigital/test_climate.py +++ b/tests/components/eheimdigital/test_climate.py @@ -123,7 +123,7 @@ async def test_set_preset_mode( await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_PRESET_MODE, - {ATTR_ENTITY_ID: "climate.mock_heater_none", ATTR_PRESET_MODE: preset_mode}, + {ATTR_ENTITY_ID: "climate.mock_heater", ATTR_PRESET_MODE: preset_mode}, blocking=True, ) @@ -132,7 +132,7 @@ async def test_set_preset_mode( await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_PRESET_MODE, - {ATTR_ENTITY_ID: "climate.mock_heater_none", ATTR_PRESET_MODE: preset_mode}, + {ATTR_ENTITY_ID: "climate.mock_heater", ATTR_PRESET_MODE: preset_mode}, blocking=True, ) @@ -161,7 +161,7 @@ async def test_set_temperature( await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, - {ATTR_ENTITY_ID: "climate.mock_heater_none", ATTR_TEMPERATURE: 26.0}, + {ATTR_ENTITY_ID: "climate.mock_heater", ATTR_TEMPERATURE: 26.0}, blocking=True, ) @@ -170,7 +170,7 @@ async def test_set_temperature( await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, - {ATTR_ENTITY_ID: "climate.mock_heater_none", ATTR_TEMPERATURE: 26.0}, + {ATTR_ENTITY_ID: "climate.mock_heater", ATTR_TEMPERATURE: 26.0}, blocking=True, ) @@ -204,7 +204,7 @@ async def test_set_hvac_mode( await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, - {ATTR_ENTITY_ID: "climate.mock_heater_none", ATTR_HVAC_MODE: hvac_mode}, + {ATTR_ENTITY_ID: "climate.mock_heater", ATTR_HVAC_MODE: hvac_mode}, blocking=True, ) @@ -213,7 +213,7 @@ async def test_set_hvac_mode( await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, - {ATTR_ENTITY_ID: "climate.mock_heater_none", ATTR_HVAC_MODE: hvac_mode}, + {ATTR_ENTITY_ID: "climate.mock_heater", ATTR_HVAC_MODE: hvac_mode}, blocking=True, ) @@ -239,7 +239,7 @@ async def test_state_update( ) await hass.async_block_till_done() - assert (state := hass.states.get("climate.mock_heater_none")) + assert (state := hass.states.get("climate.mock_heater")) assert state.attributes["hvac_action"] == HVACAction.IDLE assert state.attributes["preset_mode"] == HEATER_BIO_MODE @@ -249,6 +249,6 @@ async def test_state_update( await eheimdigital_hub_mock.call_args.kwargs["receive_callback"]() - assert (state := hass.states.get("climate.mock_heater_none")) + assert (state := hass.states.get("climate.mock_heater")) assert state.state == HVACMode.OFF assert state.attributes["preset_mode"] == HEATER_SMART_MODE From 64814e086f255575821f508858e970f8fc057093 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 31 Jan 2025 16:50:30 +0100 Subject: [PATCH 0353/3148] Make sure we load the backup integration before frontend (#137010) --- homeassistant/bootstrap.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index d89a9595868..8c27f41aabe 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -161,6 +161,10 @@ FRONTEND_INTEGRATIONS = { # integrations can be removed and database migration status is # visible in frontend "frontend", + # Backup is an after dependency of frontend, after dependencies + # are not promoted from stage 2 to earlier stages, so we need to + # add it here. + "backup", } RECORDER_INTEGRATIONS = { # Setup after frontend From fafeedd01bd365ba697a1050cb24c9de27e9d958 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Fri, 31 Jan 2025 17:26:43 +0100 Subject: [PATCH 0354/3148] Revert previous PR and remove URL from error message instead (#137018) --- homeassistant/components/swiss_public_transport/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/swiss_public_transport/strings.json b/homeassistant/components/swiss_public_transport/strings.json index 64817f89f42..1cdbd527467 100644 --- a/homeassistant/components/swiss_public_transport/strings.json +++ b/homeassistant/components/swiss_public_transport/strings.json @@ -2,7 +2,7 @@ "config": { "error": { "cannot_connect": "Cannot connect to server", - "bad_config": "Request failed due to bad config: Check at [stationboard]({stationboard_url}) if your station names are valid", + "bad_config": "Request failed due to bad config: Check the stationboard linked above if your station names are valid", "too_many_via_stations": "Too many via stations, only up to 5 via stations are allowed per connection.", "unknown": "An unknown error was raised by python-opendata-transport" }, @@ -28,7 +28,7 @@ "time_station": "Usually the departure time of a connection when it leaves the start station is tracked. Alternatively, track the time when the connection arrives at its end station.", "time_mode": "Time mode lets you change the departure timing and fix it to a specific time (e.g. 7:12:00 AM every morning) or add a moving offset (e.g. +00:05:00 taking into account the time to walk to the station)." }, - "description": "Provide start and end station for your connection,\nand optionally up to 5 via stations.\n\nConsult the stationboard linked above.", + "description": "Provide start and end station for your connection, and optionally up to 5 via stations.\n\nCheck the [stationboard]({stationboard_url}) for valid stations.", "title": "Swiss Public Transport" }, "time_fixed": { From f5924146c18ba3e7e9d4e77d90849d555c3df76b Mon Sep 17 00:00:00 2001 From: RJPoelstra <36924801+RJPoelstra@users.noreply.github.com> Date: Fri, 31 Jan 2025 17:29:59 +0100 Subject: [PATCH 0355/3148] Add data_description's to motionmount integration (#137014) * Add data_description's * Use more common terminology --- homeassistant/components/motionmount/strings.json | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/homeassistant/components/motionmount/strings.json b/homeassistant/components/motionmount/strings.json index bef04634431..1fcb6c47c99 100644 --- a/homeassistant/components/motionmount/strings.json +++ b/homeassistant/components/motionmount/strings.json @@ -11,6 +11,10 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]" + }, + "data_description": { + "host": "The hostname or IP address of the MotionMount.", + "port": "The port of the MotionMount." } }, "zeroconf_confirm": { @@ -22,6 +26,9 @@ "description": "Your MotionMount requires a PIN to operate.", "data": { "pin": "[%key:common::config_flow::data::pin%]" + }, + "data_description": { + "pin": "The user level PIN configured on the MotionMount." } }, "backoff": { From b85b834bdc7023ce3a51579a3164c64b2a001e4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joris=20Pelgr=C3=B6m?= Date: Fri, 31 Jan 2025 17:31:31 +0100 Subject: [PATCH 0356/3148] Bump letpot to 0.4.0 (#137007) * Bump letpot to 0.4.0 * Fix test item --- homeassistant/components/letpot/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/letpot/__init__.py | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/letpot/manifest.json b/homeassistant/components/letpot/manifest.json index 691584abc13..d08b5f70a51 100644 --- a/homeassistant/components/letpot/manifest.json +++ b/homeassistant/components/letpot/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "cloud_push", "quality_scale": "bronze", - "requirements": ["letpot==0.3.0"] + "requirements": ["letpot==0.4.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 6bb68d58a50..67d5910562c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1305,7 +1305,7 @@ led-ble==1.1.4 lektricowifi==0.0.43 # homeassistant.components.letpot -letpot==0.3.0 +letpot==0.4.0 # homeassistant.components.foscam libpyfoscam==1.2.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0f7cef8c557..bebc407d809 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1104,7 +1104,7 @@ led-ble==1.1.4 lektricowifi==0.0.43 # homeassistant.components.letpot -letpot==0.3.0 +letpot==0.4.0 # homeassistant.components.foscam libpyfoscam==1.2.2 diff --git a/tests/components/letpot/__init__.py b/tests/components/letpot/__init__.py index 829d1df54f3..ac552f907d4 100644 --- a/tests/components/letpot/__init__.py +++ b/tests/components/letpot/__init__.py @@ -2,7 +2,7 @@ import datetime -from letpot.models import AuthenticationInfo, LetPotDeviceStatus +from letpot.models import AuthenticationInfo, LetPotDeviceErrors, LetPotDeviceStatus from homeassistant.core import HomeAssistant @@ -26,6 +26,7 @@ AUTHENTICATION = AuthenticationInfo( ) STATUS = LetPotDeviceStatus( + errors=LetPotDeviceErrors(low_water=False), light_brightness=500, light_mode=1, light_schedule_end=datetime.time(12, 10), @@ -38,5 +39,4 @@ STATUS = LetPotDeviceStatus( raw=[77, 0, 1, 18, 98, 1, 0, 0, 1, 1, 1, 0, 1, 12, 0, 12, 10, 1, 244, 0, 0, 0], system_on=True, system_sound=False, - system_state=0, ) From e18dc063ba7da827506defa4f06189bf17cd44d6 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 31 Jan 2025 17:33:30 +0100 Subject: [PATCH 0357/3148] Make backup file names more user friendly (#136928) * Make backup file names more user friendly * Strip backup name * Strip backup name * Underscores --- homeassistant/components/backup/backup.py | 4 +- homeassistant/components/backup/manager.py | 2 +- homeassistant/components/backup/util.py | 9 ++ homeassistant/components/backup/websocket.py | 2 +- tests/components/backup/test_backup.py | 4 +- tests/components/backup/test_manager.py | 139 +++++++++++++++++-- tests/components/backup/test_util.py | 28 ++++ 7 files changed, 171 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/backup/backup.py b/homeassistant/components/backup/backup.py index c76b50b5935..b6282186c06 100644 --- a/homeassistant/components/backup/backup.py +++ b/homeassistant/components/backup/backup.py @@ -14,7 +14,7 @@ from homeassistant.helpers.hassio import is_hassio from .agent import BackupAgent, BackupNotFound, LocalBackupAgent from .const import DOMAIN, LOGGER from .models import AgentBackup -from .util import read_backup +from .util import read_backup, suggested_filename async def async_get_backup_agents( @@ -123,7 +123,7 @@ class CoreLocalBackupAgent(LocalBackupAgent): def get_new_backup_path(self, backup: AgentBackup) -> Path: """Return the local path to a new backup.""" - return self._backup_dir / f"{backup.backup_id}.tar" + return self._backup_dir / suggested_filename(backup) async def async_delete_backup(self, backup_id: str, **kwargs: Any) -> None: """Delete a backup file.""" diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index 1dbd8f8547d..2576eb8d1f0 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -898,7 +898,7 @@ class BackupManager: ) backup_name = ( - name + (name if name is None else name.strip()) or f"{'Automatic' if with_automatic_settings else 'Custom'} backup {HAVERSION}" ) extra_metadata = extra_metadata or {} diff --git a/homeassistant/components/backup/util.py b/homeassistant/components/backup/util.py index 2416aa5f28e..e9d597aa709 100644 --- a/homeassistant/components/backup/util.py +++ b/homeassistant/components/backup/util.py @@ -20,6 +20,7 @@ from securetar import SecureTarError, SecureTarFile, SecureTarReadError from homeassistant.backup_restore import password_to_key from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.util import dt as dt_util from homeassistant.util.json import JsonObjectType, json_loads_object from homeassistant.util.thread import ThreadWithException @@ -117,6 +118,14 @@ def read_backup(backup_path: Path) -> AgentBackup: ) +def suggested_filename(backup: AgentBackup) -> str: + """Suggest a filename for the backup.""" + date = dt_util.parse_datetime(backup.date, raise_on_error=True) + return "_".join( + f"{backup.name} - {date.strftime('%Y-%m-%d %H.%M %S%f')}.tar".split() + ) + + def validate_password(path: Path, password: str | None) -> bool: """Validate the password.""" with tarfile.open(path, "r:", bufsize=BUF_SIZE) as backup_file: diff --git a/homeassistant/components/backup/websocket.py b/homeassistant/components/backup/websocket.py index feb762bb50b..93dd81c3c14 100644 --- a/homeassistant/components/backup/websocket.py +++ b/homeassistant/components/backup/websocket.py @@ -199,7 +199,7 @@ async def handle_can_decrypt_on_download( vol.Optional("include_database", default=True): bool, vol.Optional("include_folders"): [vol.Coerce(Folder)], vol.Optional("include_homeassistant", default=True): bool, - vol.Optional("name"): str, + vol.Optional("name"): vol.Any(str, None), vol.Optional("password"): vol.Any(str, None), } ) diff --git a/tests/components/backup/test_backup.py b/tests/components/backup/test_backup.py index ce34c51c105..c441cae292c 100644 --- a/tests/components/backup/test_backup.py +++ b/tests/components/backup/test_backup.py @@ -103,7 +103,9 @@ async def test_upload( assert resp.status == 201 assert open_mock.call_count == 1 assert move_mock.call_count == 1 - assert move_mock.mock_calls[0].args[1].name == "abc123.tar" + assert ( + move_mock.mock_calls[0].args[1].name == "Test_-_1970-01-01_00.00_00000000.tar" + ) @pytest.mark.usefixtures("read_backup") diff --git a/tests/components/backup/test_manager.py b/tests/components/backup/test_manager.py index 4a8d2360d3f..b98cec47e8d 100644 --- a/tests/components/backup/test_manager.py +++ b/tests/components/backup/test_manager.py @@ -21,6 +21,7 @@ from unittest.mock import ( patch, ) +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components.backup import ( @@ -236,6 +237,64 @@ async def test_create_backup_service( "password": None, }, ), + ( + { + "agent_ids": ["backup.local"], + "extra_metadata": {"custom": "data"}, + "include_addons": None, + "include_all_addons": False, + "include_database": True, + "include_folders": None, + "include_homeassistant": True, + "name": "user defined name", + "password": None, + }, + { + "agent_ids": ["backup.local"], + "backup_name": "user defined name", + "extra_metadata": { + "custom": "data", + "instance_id": ANY, + "with_automatic_settings": False, + }, + "include_addons": None, + "include_all_addons": False, + "include_database": True, + "include_folders": None, + "include_homeassistant": True, + "on_progress": ANY, + "password": None, + }, + ), + ( + { + "agent_ids": ["backup.local"], + "extra_metadata": {"custom": "data"}, + "include_addons": None, + "include_all_addons": False, + "include_database": True, + "include_folders": None, + "include_homeassistant": True, + "name": " ", # Name which is just whitespace + "password": None, + }, + { + "agent_ids": ["backup.local"], + "backup_name": "Custom backup 2025.1.0", + "extra_metadata": { + "custom": "data", + "instance_id": ANY, + "with_automatic_settings": False, + }, + "include_addons": None, + "include_all_addons": False, + "include_database": True, + "include_folders": None, + "include_homeassistant": True, + "on_progress": ANY, + "password": None, + }, + ), ], ) async def test_async_create_backup( @@ -345,18 +404,70 @@ async def test_create_backup_wrong_parameters( @pytest.mark.usefixtures("mock_backup_generation") @pytest.mark.parametrize( - ("agent_ids", "backup_directory", "temp_file_unlink_call_count"), + ( + "agent_ids", + "backup_directory", + "name", + "expected_name", + "expected_filename", + "temp_file_unlink_call_count", + ), [ - ([LOCAL_AGENT_ID], "backups", 0), - (["test.remote"], "tmp_backups", 1), - ([LOCAL_AGENT_ID, "test.remote"], "backups", 0), + ( + [LOCAL_AGENT_ID], + "backups", + None, + "Custom backup 2025.1.0", + "Custom_backup_2025.1.0_-_2025-01-30_05.42_12345678.tar", + 0, + ), + ( + ["test.remote"], + "tmp_backups", + None, + "Custom backup 2025.1.0", + "abc123.tar", # We don't use friendly name for temporary backups + 1, + ), + ( + [LOCAL_AGENT_ID, "test.remote"], + "backups", + None, + "Custom backup 2025.1.0", + "Custom_backup_2025.1.0_-_2025-01-30_05.42_12345678.tar", + 0, + ), + ( + [LOCAL_AGENT_ID], + "backups", + "custom_name", + "custom_name", + "custom_name_-_2025-01-30_05.42_12345678.tar", + 0, + ), + ( + ["test.remote"], + "tmp_backups", + "custom_name", + "custom_name", + "abc123.tar", # We don't use friendly name for temporary backups + 1, + ), + ( + [LOCAL_AGENT_ID, "test.remote"], + "backups", + "custom_name", + "custom_name", + "custom_name_-_2025-01-30_05.42_12345678.tar", + 0, + ), ], ) @pytest.mark.parametrize( "params", [ {}, - {"include_database": True, "name": "abc123"}, + {"include_database": True}, {"include_database": False}, {"password": "pass123"}, ], @@ -364,6 +475,7 @@ async def test_create_backup_wrong_parameters( async def test_initiate_backup( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, + freezer: FrozenDateTimeFactory, mocked_json_bytes: Mock, mocked_tarfile: Mock, generate_backup_id: MagicMock, @@ -371,6 +483,9 @@ async def test_initiate_backup( params: dict[str, Any], agent_ids: list[str], backup_directory: str, + name: str | None, + expected_name: str, + expected_filename: str, temp_file_unlink_call_count: int, ) -> None: """Test generate backup.""" @@ -393,9 +508,9 @@ async def test_initiate_backup( ) ws_client = await hass_ws_client(hass) + freezer.move_to("2025-01-30 13:42:12.345678") include_database = params.get("include_database", True) - name = params.get("name", "Custom backup 2025.1.0") password = params.get("password") path_glob.return_value = [] @@ -427,7 +542,7 @@ async def test_initiate_backup( patch("pathlib.Path.unlink") as unlink_mock, ): await ws_client.send_json_auto_id( - {"type": "backup/generate", "agent_ids": agent_ids} | params + {"type": "backup/generate", "agent_ids": agent_ids, "name": name} | params ) result = await ws_client.receive_json() assert result["event"] == { @@ -487,7 +602,7 @@ async def test_initiate_backup( "exclude_database": not include_database, "version": "2025.1.0", }, - "name": name, + "name": expected_name, "protected": bool(password), "slug": backup_id, "type": "partial", @@ -514,7 +629,7 @@ async def test_initiate_backup( "folders": [], "homeassistant_included": True, "homeassistant_version": "2025.1.0", - "name": name, + "name": expected_name, "with_automatic_settings": False, } @@ -528,7 +643,7 @@ async def test_initiate_backup( tar_file_path = str(mocked_tarfile.call_args_list[0][0][0]) backup_directory = hass.config.path(backup_directory) - assert tar_file_path == f"{backup_directory}/{backup_id}.tar" + assert tar_file_path == f"{backup_directory}/{expected_filename}" @pytest.mark.usefixtures("mock_backup_generation") @@ -1482,7 +1597,7 @@ async def test_exception_platform_post(hass: HomeAssistant) -> None: "agent_id=backup.local&agent_id=test.remote", 2, 1, - ["abc123.tar"], + ["Test_-_1970-01-01_00.00_00000000.tar"], {TEST_BACKUP_ABC123.backup_id: TEST_BACKUP_ABC123}, b"test", 0, @@ -1491,7 +1606,7 @@ async def test_exception_platform_post(hass: HomeAssistant) -> None: "agent_id=backup.local", 1, 1, - ["abc123.tar"], + ["Test_-_1970-01-01_00.00_00000000.tar"], {}, None, 0, diff --git a/tests/components/backup/test_util.py b/tests/components/backup/test_util.py index db759805c8f..3bcb53f7c86 100644 --- a/tests/components/backup/test_util.py +++ b/tests/components/backup/test_util.py @@ -15,6 +15,7 @@ from homeassistant.components.backup.util import ( DecryptedBackupStreamer, EncryptedBackupStreamer, read_backup, + suggested_filename, validate_password, ) from homeassistant.core import HomeAssistant @@ -384,3 +385,30 @@ async def test_encrypted_backup_streamer_error(hass: HomeAssistant) -> None: # padding. await encryptor.wait() assert isinstance(encryptor._workers[0].error, tarfile.TarError) + + +@pytest.mark.parametrize( + ("name", "resulting_filename"), + [ + ("test", "test_-_2025-01-30_13.42_12345678.tar"), + (" leading spaces", "leading_spaces_-_2025-01-30_13.42_12345678.tar"), + ("trailing spaces ", "trailing_spaces_-_2025-01-30_13.42_12345678.tar"), + ("double spaces ", "double_spaces_-_2025-01-30_13.42_12345678.tar"), + ], +) +def test_suggested_filename(name: str, resulting_filename: str) -> None: + """Test suggesting a filename.""" + backup = AgentBackup( + addons=[], + backup_id="1234", + date="2025-01-30 13:42:12.345678-05:00", + database_included=False, + extra_metadata={}, + folders=[], + homeassistant_included=True, + homeassistant_version="2024.12.0.dev0", + name=name, + protected=False, + size=1234, + ) + assert suggested_filename(backup) == resulting_filename From b1c3d0857a712f6f835c3de07169c4a0db693b21 Mon Sep 17 00:00:00 2001 From: Nathan Spencer Date: Fri, 31 Jan 2025 09:35:08 -0700 Subject: [PATCH 0358/3148] Add pets to litterrobot integration (#136865) --- .../components/litterrobot/__init__.py | 9 ++++- .../components/litterrobot/binary_sensor.py | 12 +++--- .../components/litterrobot/button.py | 10 ++--- .../components/litterrobot/coordinator.py | 2 + .../components/litterrobot/entity.py | 40 +++++++++++++------ .../components/litterrobot/select.py | 20 +++++----- .../components/litterrobot/sensor.py | 32 +++++++++++---- .../components/litterrobot/switch.py | 12 +++--- homeassistant/components/litterrobot/time.py | 12 +++--- tests/components/litterrobot/common.py | 10 +++++ tests/components/litterrobot/conftest.py | 19 ++++++++- tests/components/litterrobot/test_sensor.py | 10 +++++ 12 files changed, 133 insertions(+), 55 deletions(-) diff --git a/homeassistant/components/litterrobot/__init__.py b/homeassistant/components/litterrobot/__init__.py index 1f926d37a61..2823450d9ad 100644 --- a/homeassistant/components/litterrobot/__init__.py +++ b/homeassistant/components/litterrobot/__init__.py @@ -2,6 +2,8 @@ from __future__ import annotations +import itertools + from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntry @@ -46,6 +48,9 @@ async def async_remove_config_entry_device( identifier for identifier in device_entry.identifiers if identifier[0] == DOMAIN - for robot in entry.runtime_data.account.robots - if robot.serial == identifier[1] + for _id in itertools.chain( + (robot.serial for robot in entry.runtime_data.account.robots), + (pet.id for pet in entry.runtime_data.account.pets), + ) + if _id == identifier[1] ) diff --git a/homeassistant/components/litterrobot/binary_sensor.py b/homeassistant/components/litterrobot/binary_sensor.py index e6cf23fa27c..700985d285f 100644 --- a/homeassistant/components/litterrobot/binary_sensor.py +++ b/homeassistant/components/litterrobot/binary_sensor.py @@ -18,16 +18,16 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from .coordinator import LitterRobotConfigEntry -from .entity import LitterRobotEntity, _RobotT +from .entity import LitterRobotEntity, _WhiskerEntityT @dataclass(frozen=True, kw_only=True) class RobotBinarySensorEntityDescription( - BinarySensorEntityDescription, Generic[_RobotT] + BinarySensorEntityDescription, Generic[_WhiskerEntityT] ): """A class that describes robot binary sensor entities.""" - is_on_fn: Callable[[_RobotT], bool] + is_on_fn: Callable[[_WhiskerEntityT], bool] BINARY_SENSOR_MAP: dict[type[Robot], tuple[RobotBinarySensorEntityDescription, ...]] = { @@ -78,10 +78,12 @@ async def async_setup_entry( ) -class LitterRobotBinarySensorEntity(LitterRobotEntity[_RobotT], BinarySensorEntity): +class LitterRobotBinarySensorEntity( + LitterRobotEntity[_WhiskerEntityT], BinarySensorEntity +): """Litter-Robot binary sensor entity.""" - entity_description: RobotBinarySensorEntityDescription[_RobotT] + entity_description: RobotBinarySensorEntityDescription[_WhiskerEntityT] @property def is_on(self) -> bool: diff --git a/homeassistant/components/litterrobot/button.py b/homeassistant/components/litterrobot/button.py index 01888e7fbae..758548b3a67 100644 --- a/homeassistant/components/litterrobot/button.py +++ b/homeassistant/components/litterrobot/button.py @@ -14,14 +14,14 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from .coordinator import LitterRobotConfigEntry -from .entity import LitterRobotEntity, _RobotT +from .entity import LitterRobotEntity, _WhiskerEntityT @dataclass(frozen=True, kw_only=True) -class RobotButtonEntityDescription(ButtonEntityDescription, Generic[_RobotT]): +class RobotButtonEntityDescription(ButtonEntityDescription, Generic[_WhiskerEntityT]): """A class that describes robot button entities.""" - press_fn: Callable[[_RobotT], Coroutine[Any, Any, bool]] + press_fn: Callable[[_WhiskerEntityT], Coroutine[Any, Any, bool]] ROBOT_BUTTON_MAP: dict[type[Robot], RobotButtonEntityDescription] = { @@ -62,10 +62,10 @@ async def async_setup_entry( ) -class LitterRobotButtonEntity(LitterRobotEntity[_RobotT], ButtonEntity): +class LitterRobotButtonEntity(LitterRobotEntity[_WhiskerEntityT], ButtonEntity): """Litter-Robot button entity.""" - entity_description: RobotButtonEntityDescription[_RobotT] + entity_description: RobotButtonEntityDescription[_WhiskerEntityT] async def async_press(self) -> None: """Press the button.""" diff --git a/homeassistant/components/litterrobot/coordinator.py b/homeassistant/components/litterrobot/coordinator.py index a56a6607d32..c99d4794ff6 100644 --- a/homeassistant/components/litterrobot/coordinator.py +++ b/homeassistant/components/litterrobot/coordinator.py @@ -47,6 +47,7 @@ class LitterRobotDataUpdateCoordinator(DataUpdateCoordinator[None]): async def _async_update_data(self) -> None: """Update all device states from the Litter-Robot API.""" await self.account.refresh_robots() + await self.account.load_pets() async def _async_setup(self) -> None: """Set up the coordinator.""" @@ -56,6 +57,7 @@ class LitterRobotDataUpdateCoordinator(DataUpdateCoordinator[None]): password=self.config_entry.data[CONF_PASSWORD], load_robots=True, subscribe_for_updates=True, + load_pets=True, ) except LitterRobotLoginException as ex: raise ConfigEntryAuthFailed("Invalid credentials") from ex diff --git a/homeassistant/components/litterrobot/entity.py b/homeassistant/components/litterrobot/entity.py index 36cbbb730ce..9e9cc8f0740 100644 --- a/homeassistant/components/litterrobot/entity.py +++ b/homeassistant/components/litterrobot/entity.py @@ -4,7 +4,7 @@ from __future__ import annotations from typing import Generic, TypeVar -from pylitterbot import Robot +from pylitterbot import Pet, Robot from pylitterbot.robot import EVENT_UPDATE from homeassistant.helpers.device_registry import DeviceInfo @@ -14,11 +14,31 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN from .coordinator import LitterRobotDataUpdateCoordinator -_RobotT = TypeVar("_RobotT", bound=Robot) +_WhiskerEntityT = TypeVar("_WhiskerEntityT", bound=Robot | Pet) + + +def get_device_info(whisker_entity: _WhiskerEntityT) -> DeviceInfo: + """Get device info for a robot or pet.""" + if isinstance(whisker_entity, Robot): + return DeviceInfo( + identifiers={(DOMAIN, whisker_entity.serial)}, + manufacturer="Whisker", + model=whisker_entity.model, + name=whisker_entity.name, + serial_number=whisker_entity.serial, + sw_version=getattr(whisker_entity, "firmware", None), + ) + breed = ", ".join(breed for breed in whisker_entity.breeds or []) + return DeviceInfo( + identifiers={(DOMAIN, whisker_entity.id)}, + manufacturer="Whisker", + model=f"{breed} {whisker_entity.pet_type}".strip().capitalize(), + name=whisker_entity.name, + ) class LitterRobotEntity( - CoordinatorEntity[LitterRobotDataUpdateCoordinator], Generic[_RobotT] + CoordinatorEntity[LitterRobotDataUpdateCoordinator], Generic[_WhiskerEntityT] ): """Generic Litter-Robot entity representing common data and methods.""" @@ -26,7 +46,7 @@ class LitterRobotEntity( def __init__( self, - robot: _RobotT, + robot: _WhiskerEntityT, coordinator: LitterRobotDataUpdateCoordinator, description: EntityDescription, ) -> None: @@ -34,15 +54,9 @@ class LitterRobotEntity( super().__init__(coordinator) self.robot = robot self.entity_description = description - self._attr_unique_id = f"{robot.serial}-{description.key}" - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, robot.serial)}, - manufacturer="Whisker", - model=robot.model, - name=robot.name, - serial_number=robot.serial, - sw_version=getattr(robot, "firmware", None), - ) + _id = robot.serial if isinstance(robot, Robot) else robot.id + self._attr_unique_id = f"{_id}-{description.key}" + self._attr_device_info = get_device_info(robot) async def async_added_to_hass(self) -> None: """Set up a listener for the entity.""" diff --git a/homeassistant/components/litterrobot/select.py b/homeassistant/components/litterrobot/select.py index 1a3d2fc2fb4..f6e3781f3df 100644 --- a/homeassistant/components/litterrobot/select.py +++ b/homeassistant/components/litterrobot/select.py @@ -15,21 +15,21 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from .coordinator import LitterRobotConfigEntry, LitterRobotDataUpdateCoordinator -from .entity import LitterRobotEntity, _RobotT +from .entity import LitterRobotEntity, _WhiskerEntityT _CastTypeT = TypeVar("_CastTypeT", int, float, str) @dataclass(frozen=True, kw_only=True) class RobotSelectEntityDescription( - SelectEntityDescription, Generic[_RobotT, _CastTypeT] + SelectEntityDescription, Generic[_WhiskerEntityT, _CastTypeT] ): """A class that describes robot select entities.""" entity_category: EntityCategory = EntityCategory.CONFIG - current_fn: Callable[[_RobotT], _CastTypeT | None] - options_fn: Callable[[_RobotT], list[_CastTypeT]] - select_fn: Callable[[_RobotT, str], Coroutine[Any, Any, bool]] + current_fn: Callable[[_WhiskerEntityT], _CastTypeT | None] + options_fn: Callable[[_WhiskerEntityT], list[_CastTypeT]] + select_fn: Callable[[_WhiskerEntityT, str], Coroutine[Any, Any, bool]] ROBOT_SELECT_MAP: dict[type[Robot], RobotSelectEntityDescription] = { @@ -83,17 +83,19 @@ async def async_setup_entry( class LitterRobotSelectEntity( - LitterRobotEntity[_RobotT], SelectEntity, Generic[_RobotT, _CastTypeT] + LitterRobotEntity[_WhiskerEntityT], + SelectEntity, + Generic[_WhiskerEntityT, _CastTypeT], ): """Litter-Robot Select.""" - entity_description: RobotSelectEntityDescription[_RobotT, _CastTypeT] + entity_description: RobotSelectEntityDescription[_WhiskerEntityT, _CastTypeT] def __init__( self, - robot: _RobotT, + robot: _WhiskerEntityT, coordinator: LitterRobotDataUpdateCoordinator, - description: RobotSelectEntityDescription[_RobotT, _CastTypeT], + description: RobotSelectEntityDescription[_WhiskerEntityT, _CastTypeT], ) -> None: """Initialize a Litter-Robot select entity.""" super().__init__(robot, coordinator, description) diff --git a/homeassistant/components/litterrobot/sensor.py b/homeassistant/components/litterrobot/sensor.py index 6545d7c7ae7..3e25a0556c6 100644 --- a/homeassistant/components/litterrobot/sensor.py +++ b/homeassistant/components/litterrobot/sensor.py @@ -7,7 +7,7 @@ from dataclasses import dataclass from datetime import datetime from typing import Any, Generic -from pylitterbot import FeederRobot, LitterRobot, LitterRobot4, Robot +from pylitterbot import FeederRobot, LitterRobot, LitterRobot4, Pet, Robot from homeassistant.components.sensor import ( SensorDeviceClass, @@ -20,7 +20,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from .coordinator import LitterRobotConfigEntry -from .entity import LitterRobotEntity, _RobotT +from .entity import LitterRobotEntity, _WhiskerEntityT def icon_for_gauge_level(gauge_level: int | None = None, offset: int = 0) -> str: @@ -35,11 +35,11 @@ def icon_for_gauge_level(gauge_level: int | None = None, offset: int = 0) -> str @dataclass(frozen=True, kw_only=True) -class RobotSensorEntityDescription(SensorEntityDescription, Generic[_RobotT]): +class RobotSensorEntityDescription(SensorEntityDescription, Generic[_WhiskerEntityT]): """A class that describes robot sensor entities.""" icon_fn: Callable[[Any], str | None] = lambda _: None - value_fn: Callable[[_RobotT], float | datetime | str | None] + value_fn: Callable[[_WhiskerEntityT], float | datetime | str | None] ROBOT_SENSOR_MAP: dict[type[Robot], list[RobotSensorEntityDescription]] = { @@ -146,6 +146,16 @@ ROBOT_SENSOR_MAP: dict[type[Robot], list[RobotSensorEntityDescription]] = { ], } +PET_SENSORS: list[RobotSensorEntityDescription] = [ + RobotSensorEntityDescription[Pet]( + key="weight", + device_class=SensorDeviceClass.WEIGHT, + native_unit_of_measurement=UnitOfMass.POUNDS, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda pet: pet.weight, + ) +] + async def async_setup_entry( hass: HomeAssistant, @@ -154,7 +164,7 @@ async def async_setup_entry( ) -> None: """Set up Litter-Robot sensors using config entry.""" coordinator = entry.runtime_data - async_add_entities( + entities: list[LitterRobotSensorEntity] = [ LitterRobotSensorEntity( robot=robot, coordinator=coordinator, description=description ) @@ -162,13 +172,21 @@ async def async_setup_entry( for robot_type, entity_descriptions in ROBOT_SENSOR_MAP.items() if isinstance(robot, robot_type) for description in entity_descriptions + ] + entities.extend( + LitterRobotSensorEntity( + robot=pet, coordinator=coordinator, description=description + ) + for pet in coordinator.account.pets + for description in PET_SENSORS ) + async_add_entities(entities) -class LitterRobotSensorEntity(LitterRobotEntity[_RobotT], SensorEntity): +class LitterRobotSensorEntity(LitterRobotEntity[_WhiskerEntityT], SensorEntity): """Litter-Robot sensor entity.""" - entity_description: RobotSensorEntityDescription[_RobotT] + entity_description: RobotSensorEntityDescription[_WhiskerEntityT] @property def native_value(self) -> float | datetime | str | None: diff --git a/homeassistant/components/litterrobot/switch.py b/homeassistant/components/litterrobot/switch.py index 7ded89d552b..4839748c068 100644 --- a/homeassistant/components/litterrobot/switch.py +++ b/homeassistant/components/litterrobot/switch.py @@ -14,16 +14,16 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from .coordinator import LitterRobotConfigEntry -from .entity import LitterRobotEntity, _RobotT +from .entity import LitterRobotEntity, _WhiskerEntityT @dataclass(frozen=True, kw_only=True) -class RobotSwitchEntityDescription(SwitchEntityDescription, Generic[_RobotT]): +class RobotSwitchEntityDescription(SwitchEntityDescription, Generic[_WhiskerEntityT]): """A class that describes robot switch entities.""" entity_category: EntityCategory = EntityCategory.CONFIG - set_fn: Callable[[_RobotT, bool], Coroutine[Any, Any, bool]] - value_fn: Callable[[_RobotT], bool] + set_fn: Callable[[_WhiskerEntityT, bool], Coroutine[Any, Any, bool]] + value_fn: Callable[[_WhiskerEntityT], bool] ROBOT_SWITCHES = [ @@ -57,10 +57,10 @@ async def async_setup_entry( ) -class RobotSwitchEntity(LitterRobotEntity[_RobotT], SwitchEntity): +class RobotSwitchEntity(LitterRobotEntity[_WhiskerEntityT], SwitchEntity): """Litter-Robot switch entity.""" - entity_description: RobotSwitchEntityDescription[_RobotT] + entity_description: RobotSwitchEntityDescription[_WhiskerEntityT] @property def is_on(self) -> bool | None: diff --git a/homeassistant/components/litterrobot/time.py b/homeassistant/components/litterrobot/time.py index 3fa93b14dd9..69d81d63eae 100644 --- a/homeassistant/components/litterrobot/time.py +++ b/homeassistant/components/litterrobot/time.py @@ -16,15 +16,15 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import dt as dt_util from .coordinator import LitterRobotConfigEntry -from .entity import LitterRobotEntity, _RobotT +from .entity import LitterRobotEntity, _WhiskerEntityT @dataclass(frozen=True, kw_only=True) -class RobotTimeEntityDescription(TimeEntityDescription, Generic[_RobotT]): +class RobotTimeEntityDescription(TimeEntityDescription, Generic[_WhiskerEntityT]): """A class that describes robot time entities.""" - value_fn: Callable[[_RobotT], time | None] - set_fn: Callable[[_RobotT, time], Coroutine[Any, Any, bool]] + value_fn: Callable[[_WhiskerEntityT], time | None] + set_fn: Callable[[_WhiskerEntityT, time], Coroutine[Any, Any, bool]] def _as_local_time(start: datetime | None) -> time | None: @@ -64,10 +64,10 @@ async def async_setup_entry( ) -class LitterRobotTimeEntity(LitterRobotEntity[_RobotT], TimeEntity): +class LitterRobotTimeEntity(LitterRobotEntity[_WhiskerEntityT], TimeEntity): """Litter-Robot time entity.""" - entity_description: RobotTimeEntityDescription[_RobotT] + entity_description: RobotTimeEntityDescription[_WhiskerEntityT] @property def native_value(self) -> time | None: diff --git a/tests/components/litterrobot/common.py b/tests/components/litterrobot/common.py index b29fa753801..d96ce06ca59 100644 --- a/tests/components/litterrobot/common.py +++ b/tests/components/litterrobot/common.py @@ -150,5 +150,15 @@ FEEDER_ROBOT_DATA = { }, ], } +PET_DATA = { + "petId": "PET-123", + "userId": "1234567", + "createdAt": "2023-04-27T23:26:49.813Z", + "name": "Kitty", + "type": "CAT", + "gender": "FEMALE", + "lastWeightReading": 9.1, + "breeds": ["sphynx"], +} VACUUM_ENTITY_ID = "vacuum.test_litter_box" diff --git a/tests/components/litterrobot/conftest.py b/tests/components/litterrobot/conftest.py index e60e0cbd36d..d22c4b2ec49 100644 --- a/tests/components/litterrobot/conftest.py +++ b/tests/components/litterrobot/conftest.py @@ -5,13 +5,20 @@ from __future__ import annotations from typing import Any from unittest.mock import AsyncMock, MagicMock, patch -from pylitterbot import Account, FeederRobot, LitterRobot3, LitterRobot4, Robot +from pylitterbot import Account, FeederRobot, LitterRobot3, LitterRobot4, Pet, Robot from pylitterbot.exceptions import InvalidCommandException import pytest from homeassistant.core import HomeAssistant -from .common import CONFIG, DOMAIN, FEEDER_ROBOT_DATA, ROBOT_4_DATA, ROBOT_DATA +from .common import ( + CONFIG, + DOMAIN, + FEEDER_ROBOT_DATA, + PET_DATA, + ROBOT_4_DATA, + ROBOT_DATA, +) from tests.common import MockConfigEntry @@ -50,6 +57,7 @@ def create_mock_account( skip_robots: bool = False, v4: bool = False, feeder: bool = False, + pet: bool = False, ) -> MagicMock: """Create a mock Litter-Robot account.""" account = MagicMock(spec=Account) @@ -60,6 +68,7 @@ def create_mock_account( if skip_robots else [create_mock_robot(robot_data, account, v4, feeder, side_effect)] ) + account.pets = [Pet(PET_DATA, account.session)] if pet else [] return account @@ -81,6 +90,12 @@ def mock_account_with_feederrobot() -> MagicMock: return create_mock_account(feeder=True) +@pytest.fixture +def mock_account_with_pet() -> MagicMock: + """Mock account with Feeder-Robot.""" + return create_mock_account(pet=True) + + @pytest.fixture def mock_account_with_no_robots() -> MagicMock: """Mock a Litter-Robot account.""" diff --git a/tests/components/litterrobot/test_sensor.py b/tests/components/litterrobot/test_sensor.py index 360d13096a7..e290d96fcf4 100644 --- a/tests/components/litterrobot/test_sensor.py +++ b/tests/components/litterrobot/test_sensor.py @@ -104,3 +104,13 @@ async def test_feeder_robot_sensor( sensor = hass.states.get("sensor.test_food_level") assert sensor.state == "10" assert sensor.attributes["unit_of_measurement"] == PERCENTAGE + + +async def test_pet_weight_sensor( + hass: HomeAssistant, mock_account_with_pet: MagicMock +) -> None: + """Tests pet weight sensors.""" + await setup_integration(hass, mock_account_with_pet, PLATFORM_DOMAIN) + sensor = hass.states.get("sensor.kitty_weight") + assert sensor.state == "9.1" + assert sensor.attributes["unit_of_measurement"] == UnitOfMass.POUNDS From e0bf248867b244866e01039f3a95f56193d9ab5b Mon Sep 17 00:00:00 2001 From: Duco Sebel <74970928+DCSBL@users.noreply.github.com> Date: Fri, 31 Jan 2025 17:49:25 +0100 Subject: [PATCH 0359/3148] Bumb python-homewizard-energy to 8.3.2 (#136995) --- homeassistant/components/homewizard/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/homewizard/manifest.json b/homeassistant/components/homewizard/manifest.json index 957ed912b7d..51a315b2286 100644 --- a/homeassistant/components/homewizard/manifest.json +++ b/homeassistant/components/homewizard/manifest.json @@ -12,6 +12,6 @@ "iot_class": "local_polling", "loggers": ["homewizard_energy"], "quality_scale": "platinum", - "requirements": ["python-homewizard-energy==v8.3.0"], + "requirements": ["python-homewizard-energy==v8.3.2"], "zeroconf": ["_hwenergy._tcp.local.", "_homewizard._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 67d5910562c..637e25cec04 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2391,7 +2391,7 @@ python-google-drive-api==0.0.2 python-homeassistant-analytics==0.8.1 # homeassistant.components.homewizard -python-homewizard-energy==v8.3.0 +python-homewizard-energy==v8.3.2 # homeassistant.components.hp_ilo python-hpilo==4.4.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bebc407d809..ebe7c1e9fe7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1936,7 +1936,7 @@ python-google-drive-api==0.0.2 python-homeassistant-analytics==0.8.1 # homeassistant.components.homewizard -python-homewizard-energy==v8.3.0 +python-homewizard-energy==v8.3.2 # homeassistant.components.izone python-izone==1.2.9 From 64f679ba8f065d04875c76f9db00b138f5da985f Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 31 Jan 2025 18:20:30 +0100 Subject: [PATCH 0360/3148] Make supervisor backup file names more user friendly (#137020) --- homeassistant/components/backup/__init__.py | 3 +++ homeassistant/components/backup/util.py | 11 +++++++---- homeassistant/components/hassio/backup.py | 17 ++++++++++++++--- tests/components/hassio/test_backup.py | 14 ++++++++++++++ 4 files changed, 38 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/backup/__init__.py b/homeassistant/components/backup/__init__.py index 3003f94c2ed..86e5b95d196 100644 --- a/homeassistant/components/backup/__init__.py +++ b/homeassistant/components/backup/__init__.py @@ -35,6 +35,7 @@ from .manager import ( WrittenBackup, ) from .models import AddonInfo, AgentBackup, Folder +from .util import suggested_filename, suggested_filename_from_name_date from .websocket import async_register_websocket_handlers __all__ = [ @@ -58,6 +59,8 @@ __all__ = [ "RestoreBackupState", "WrittenBackup", "async_get_manager", + "suggested_filename", + "suggested_filename_from_name_date", ] CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) diff --git a/homeassistant/components/backup/util.py b/homeassistant/components/backup/util.py index e9d597aa709..fbb13b4721a 100644 --- a/homeassistant/components/backup/util.py +++ b/homeassistant/components/backup/util.py @@ -118,12 +118,15 @@ def read_backup(backup_path: Path) -> AgentBackup: ) +def suggested_filename_from_name_date(name: str, date_str: str) -> str: + """Suggest a filename for the backup.""" + date = dt_util.parse_datetime(date_str, raise_on_error=True) + return "_".join(f"{name} - {date.strftime('%Y-%m-%d %H.%M %S%f')}.tar".split()) + + def suggested_filename(backup: AgentBackup) -> str: """Suggest a filename for the backup.""" - date = dt_util.parse_datetime(backup.date, raise_on_error=True) - return "_".join( - f"{backup.name} - {date.strftime('%Y-%m-%d %H.%M %S%f')}.tar".split() - ) + return suggested_filename_from_name_date(backup.name, backup.date) def validate_password(path: Path, password: str | None) -> bool: diff --git a/homeassistant/components/hassio/backup.py b/homeassistant/components/hassio/backup.py index b9439183d8c..24a1743155e 100644 --- a/homeassistant/components/hassio/backup.py +++ b/homeassistant/components/hassio/backup.py @@ -6,7 +6,7 @@ import asyncio from collections.abc import AsyncIterator, Callable, Coroutine, Mapping import logging import os -from pathlib import Path +from pathlib import Path, PurePath from typing import Any, cast from uuid import UUID @@ -38,11 +38,14 @@ from homeassistant.components.backup import ( RestoreBackupState, WrittenBackup, async_get_manager as async_get_backup_manager, + suggested_filename as suggested_backup_filename, + suggested_filename_from_name_date, ) from homeassistant.const import __version__ as HAVERSION from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.util import dt as dt_util from .const import DOMAIN, EVENT_SUPERVISOR_EVENT from .handler import get_supervisor_client @@ -113,12 +116,15 @@ def _backup_details_to_agent_backup( AddonInfo(name=addon.name, slug=addon.slug, version=addon.version) for addon in details.addons ] + extra_metadata = details.extra or {} location = location or LOCATION_LOCAL return AgentBackup( addons=addons, backup_id=details.slug, database_included=database_included, - date=details.date.isoformat(), + date=extra_metadata.get( + "supervisor.backup_request_date", details.date.isoformat() + ), extra_metadata=details.extra or {}, folders=[Folder(folder) for folder in details.folders], homeassistant_included=homeassistant_included, @@ -174,7 +180,8 @@ class SupervisorBackupAgent(BackupAgent): return stream = await open_stream() upload_options = supervisor_backups.UploadBackupOptions( - location={self.location} + location={self.location}, + filename=PurePath(suggested_backup_filename(backup)), ) await self._client.backups.upload_backup( stream, @@ -301,6 +308,9 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): locations = [] locations = locations or [LOCATION_CLOUD_BACKUP] + date = dt_util.now().isoformat() + extra_metadata = extra_metadata | {"supervisor.backup_request_date": date} + filename = suggested_filename_from_name_date(backup_name, date) try: backup = await self._client.backups.partial_backup( supervisor_backups.PartialBackupOptions( @@ -314,6 +324,7 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): homeassistant_exclude_database=not include_database, background=True, extra=extra_metadata, + filename=PurePath(filename), ) ) except SupervisorError as err: diff --git a/tests/components/hassio/test_backup.py b/tests/components/hassio/test_backup.py index 9ba73ade1a3..d001a358640 100644 --- a/tests/components/hassio/test_backup.py +++ b/tests/components/hassio/test_backup.py @@ -11,6 +11,7 @@ from dataclasses import replace from datetime import datetime from io import StringIO import os +from pathlib import PurePath from typing import Any from unittest.mock import ANY, AsyncMock, Mock, patch from uuid import UUID @@ -26,6 +27,7 @@ from aiohasupervisor.models import ( mounts as supervisor_mounts, ) from aiohasupervisor.models.mounts import MountsInfo +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components.backup import ( @@ -854,8 +856,10 @@ DEFAULT_BACKUP_OPTIONS = supervisor_backups.PartialBackupOptions( compressed=True, extra={ "instance_id": ANY, + "supervisor.backup_request_date": "2025-01-30T05:42:12.345678-08:00", "with_automatic_settings": False, }, + filename=PurePath("Test_-_2025-01-30_05.42_12345678.tar"), folders={"ssl"}, homeassistant_exclude_database=False, homeassistant=True, @@ -907,12 +911,14 @@ DEFAULT_BACKUP_OPTIONS = supervisor_backups.PartialBackupOptions( async def test_reader_writer_create( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, + freezer: FrozenDateTimeFactory, supervisor_client: AsyncMock, extra_generate_options: dict[str, Any], expected_supervisor_options: supervisor_backups.PartialBackupOptions, ) -> None: """Test generating a backup.""" client = await hass_ws_client(hass) + freezer.move_to("2025-01-30 13:42:12.345678") supervisor_client.backups.partial_backup.return_value.job_id = TEST_JOB_ID supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE @@ -982,10 +988,12 @@ async def test_reader_writer_create( async def test_reader_writer_create_job_done( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, + freezer: FrozenDateTimeFactory, supervisor_client: AsyncMock, ) -> None: """Test generating a backup, and backup job finishes early.""" client = await hass_ws_client(hass) + freezer.move_to("2025-01-30 13:42:12.345678") supervisor_client.backups.partial_backup.return_value.job_id = TEST_JOB_ID supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS supervisor_client.jobs.get_job.return_value = TEST_JOB_DONE @@ -1140,6 +1148,7 @@ async def test_reader_writer_create_job_done( async def test_reader_writer_create_per_agent_encryption( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, + freezer: FrozenDateTimeFactory, supervisor_client: AsyncMock, commands: dict[str, Any], password: str | None, @@ -1151,6 +1160,7 @@ async def test_reader_writer_create_per_agent_encryption( ) -> None: """Test generating a backup.""" client = await hass_ws_client(hass) + freezer.move_to("2025-01-30 13:42:12.345678") mounts = MountsInfo( default_backup_mount=None, mounts=[ @@ -1170,6 +1180,7 @@ async def test_reader_writer_create_per_agent_encryption( supervisor_client.backups.partial_backup.return_value.job_id = TEST_JOB_ID supervisor_client.backups.backup_info.return_value = replace( TEST_BACKUP_DETAILS, + extra=DEFAULT_BACKUP_OPTIONS.extra, locations=create_locations, location_attributes={ location or LOCATION_LOCAL: supervisor_backups.BackupLocationAttributes( @@ -1254,6 +1265,7 @@ async def test_reader_writer_create_per_agent_encryption( upload_locations ) for call in supervisor_client.backups.upload_backup.mock_calls: + assert call.args[1].filename == PurePath("Test_-_2025-01-30_05.42_12345678.tar") upload_call_locations: set = call.args[1].location assert len(upload_call_locations) == 1 assert upload_call_locations.pop() in upload_locations @@ -1569,10 +1581,12 @@ async def test_reader_writer_create_info_error( async def test_reader_writer_create_remote_backup( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, + freezer: FrozenDateTimeFactory, supervisor_client: AsyncMock, ) -> None: """Test generating a backup which will be uploaded to a remote agent.""" client = await hass_ws_client(hass) + freezer.move_to("2025-01-30 13:42:12.345678") supervisor_client.backups.partial_backup.return_value.job_id = TEST_JOB_ID supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS_5 supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE From c4cb94bddd92a6400ad79ee3b2b07564fd560175 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 31 Jan 2025 11:29:44 -0600 Subject: [PATCH 0361/3148] Bump habluetooth to 3.17.0 (#137022) --- 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 38677400418..d6ed9281099 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -21,6 +21,6 @@ "bluetooth-auto-recovery==1.4.2", "bluetooth-data-tools==1.22.0", "dbus-fast==2.30.2", - "habluetooth==3.15.0" + "habluetooth==3.17.0" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 64353901fbf..a7fbe090f23 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -33,7 +33,7 @@ dbus-fast==2.30.2 fnv-hash-fast==1.2.2 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 -habluetooth==3.15.0 +habluetooth==3.17.0 hass-nabucasa==0.88.1 hassil==2.2.0 home-assistant-bluetooth==1.13.0 diff --git a/requirements_all.txt b/requirements_all.txt index 637e25cec04..f2a36a4329e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1103,7 +1103,7 @@ ha-philipsjs==3.2.2 habiticalib==0.3.3 # homeassistant.components.bluetooth -habluetooth==3.15.0 +habluetooth==3.17.0 # homeassistant.components.cloud hass-nabucasa==0.88.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ebe7c1e9fe7..3a26c0786d9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -944,7 +944,7 @@ ha-philipsjs==3.2.2 habiticalib==0.3.3 # homeassistant.components.bluetooth -habluetooth==3.15.0 +habluetooth==3.17.0 # homeassistant.components.cloud hass-nabucasa==0.88.1 From f8f12957b5c8ba1d62005c1e41388e48a13d0815 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 31 Jan 2025 12:15:31 -0600 Subject: [PATCH 0362/3148] Bump bleak-esphome to 2.6.0 (#137025) --- homeassistant/components/eq3btsmart/manifest.json | 2 +- homeassistant/components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/eq3btsmart/manifest.json b/homeassistant/components/eq3btsmart/manifest.json index bab62723c82..3a55730c60f 100644 --- a/homeassistant/components/eq3btsmart/manifest.json +++ b/homeassistant/components/eq3btsmart/manifest.json @@ -22,5 +22,5 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["eq3btsmart"], - "requirements": ["eq3btsmart==1.4.1", "bleak-esphome==2.2.0"] + "requirements": ["eq3btsmart==1.4.1", "bleak-esphome==2.6.0"] } diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index ecc7afb3661..9585be72c63 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -18,7 +18,7 @@ "requirements": [ "aioesphomeapi==29.0.0", "esphome-dashboard-api==1.2.3", - "bleak-esphome==2.2.0" + "bleak-esphome==2.6.0" ], "zeroconf": ["_esphomelib._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index f2a36a4329e..b2695471121 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -597,7 +597,7 @@ bizkaibus==0.1.1 # homeassistant.components.eq3btsmart # homeassistant.components.esphome -bleak-esphome==2.2.0 +bleak-esphome==2.6.0 # homeassistant.components.bluetooth bleak-retry-connector==3.8.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3a26c0786d9..fdb6c498f34 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -528,7 +528,7 @@ bimmer-connected[china]==0.17.2 # homeassistant.components.eq3btsmart # homeassistant.components.esphome -bleak-esphome==2.2.0 +bleak-esphome==2.6.0 # homeassistant.components.bluetooth bleak-retry-connector==3.8.0 From 256157d41377c4e01cb8696e16834f46eb77fcfe Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Fri, 31 Jan 2025 19:25:24 +0100 Subject: [PATCH 0363/3148] Update frontend to 20250131.0 (#137024) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index b545026059c..2ecb165554a 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -21,5 +21,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20250130.0"] + "requirements": ["home-assistant-frontend==20250131.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index a7fbe090f23..2d4e92e2e9a 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -37,7 +37,7 @@ habluetooth==3.17.0 hass-nabucasa==0.88.1 hassil==2.2.0 home-assistant-bluetooth==1.13.0 -home-assistant-frontend==20250130.0 +home-assistant-frontend==20250131.0 home-assistant-intents==2025.1.28 httpx==0.28.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index b2695471121..5e182110235 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1146,7 +1146,7 @@ hole==0.8.0 holidays==0.65 # homeassistant.components.frontend -home-assistant-frontend==20250130.0 +home-assistant-frontend==20250131.0 # homeassistant.components.conversation home-assistant-intents==2025.1.28 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fdb6c498f34..86557711111 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -975,7 +975,7 @@ hole==0.8.0 holidays==0.65 # homeassistant.components.frontend -home-assistant-frontend==20250130.0 +home-assistant-frontend==20250131.0 # homeassistant.components.conversation home-assistant-intents==2025.1.28 From 065cdf421f947451599c67d83b8a4725b99ab4d7 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 31 Jan 2025 19:33:48 +0100 Subject: [PATCH 0364/3148] Delete old addon update backups when updating addon (#136977) * Delete old addon update backups when updating addon * Address review comments * Add tests --- homeassistant/components/backup/config.py | 77 ++-------- homeassistant/components/backup/manager.py | 64 +++++++++ homeassistant/components/hassio/backup.py | 23 ++- tests/components/hassio/test_update.py | 129 ++++++++++++++++- tests/components/hassio/test_websocket_api.py | 133 +++++++++++++++++- 5 files changed, 350 insertions(+), 76 deletions(-) diff --git a/homeassistant/components/backup/config.py b/homeassistant/components/backup/config.py index 0baefe1f52d..4d0cd82bc44 100644 --- a/homeassistant/components/backup/config.py +++ b/homeassistant/components/backup/config.py @@ -2,8 +2,6 @@ from __future__ import annotations -import asyncio -from collections.abc import Callable from dataclasses import dataclass, field, replace import datetime as dt from datetime import datetime, timedelta @@ -252,7 +250,7 @@ class RetentionConfig: """Delete backups older than days.""" self._schedule_next(manager) - def _backups_filter( + def _delete_filter( backups: dict[str, ManagerBackup], ) -> dict[str, ManagerBackup]: """Return backups older than days to delete.""" @@ -269,7 +267,9 @@ class RetentionConfig: < now } - await _delete_filtered_backups(manager, _backups_filter) + await manager.async_delete_filtered_backups( + include_filter=_automatic_backups_filter, delete_filter=_delete_filter + ) manager.remove_next_delete_event = async_call_later( manager.hass, timedelta(days=1), _delete_backups @@ -521,74 +521,21 @@ class CreateBackupParametersDict(TypedDict, total=False): password: str | None -async def _delete_filtered_backups( - manager: BackupManager, - backup_filter: Callable[[dict[str, ManagerBackup]], dict[str, ManagerBackup]], -) -> None: - """Delete backups parsed with a filter. - - :param manager: The backup manager. - :param backup_filter: A filter that should return the backups to delete. - """ - backups, get_agent_errors = await manager.async_get_backups() - if get_agent_errors: - LOGGER.debug( - "Error getting backups; continuing anyway: %s", - get_agent_errors, - ) - - # only delete backups that are created with the saved automatic settings - backups = { +def _automatic_backups_filter( + backups: dict[str, ManagerBackup], +) -> dict[str, ManagerBackup]: + """Return automatic backups.""" + return { backup_id: backup for backup_id, backup in backups.items() if backup.with_automatic_settings } - LOGGER.debug("Total automatic backups: %s", backups) - - filtered_backups = backup_filter(backups) - - if not filtered_backups: - return - - # always delete oldest backup first - filtered_backups = dict( - sorted( - filtered_backups.items(), - key=lambda backup_item: backup_item[1].date, - ) - ) - - if len(filtered_backups) >= len(backups): - # Never delete the last backup. - last_backup = filtered_backups.popitem() - LOGGER.debug("Keeping the last backup: %s", last_backup) - - LOGGER.debug("Backups to delete: %s", filtered_backups) - - if not filtered_backups: - return - - backup_ids = list(filtered_backups) - delete_results = await asyncio.gather( - *(manager.async_delete_backup(backup_id) for backup_id in filtered_backups) - ) - agent_errors = { - backup_id: error - for backup_id, error in zip(backup_ids, delete_results, strict=True) - if error - } - if agent_errors: - LOGGER.error( - "Error deleting old copies: %s", - agent_errors, - ) - async def delete_backups_exceeding_configured_count(manager: BackupManager) -> None: """Delete backups exceeding the configured retention count.""" - def _backups_filter( + def _delete_filter( backups: dict[str, ManagerBackup], ) -> dict[str, ManagerBackup]: """Return oldest backups more numerous than copies to delete.""" @@ -603,4 +550,6 @@ async def delete_backups_exceeding_configured_count(manager: BackupManager) -> N )[: max(len(backups) - manager.config.data.retention.copies, 0)] ) - await _delete_filtered_backups(manager, _backups_filter) + await manager.async_delete_filtered_backups( + include_filter=_automatic_backups_filter, delete_filter=_delete_filter + ) diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index 2576eb8d1f0..42b5f522ecd 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -685,6 +685,70 @@ class BackupManager: return agent_errors + async def async_delete_filtered_backups( + self, + *, + include_filter: Callable[[dict[str, ManagerBackup]], dict[str, ManagerBackup]], + delete_filter: Callable[[dict[str, ManagerBackup]], dict[str, ManagerBackup]], + ) -> None: + """Delete backups parsed with a filter. + + :param include_filter: A filter that should return the backups to consider for + deletion. Note: The newest of the backups returned by include_filter will + unconditionally be kept, even if delete_filter returns all backups. + :param delete_filter: A filter that should return the backups to delete. + """ + backups, get_agent_errors = await self.async_get_backups() + if get_agent_errors: + LOGGER.debug( + "Error getting backups; continuing anyway: %s", + get_agent_errors, + ) + + # Run the include filter first to ensure we only consider backups that + # should be included in the deletion process. + backups = include_filter(backups) + + LOGGER.debug("Total automatic backups: %s", backups) + + backups_to_delete = delete_filter(backups) + + if not backups_to_delete: + return + + # always delete oldest backup first + backups_to_delete = dict( + sorted( + backups_to_delete.items(), + key=lambda backup_item: backup_item[1].date, + ) + ) + + if len(backups_to_delete) >= len(backups): + # Never delete the last backup. + last_backup = backups_to_delete.popitem() + LOGGER.debug("Keeping the last backup: %s", last_backup) + + LOGGER.debug("Backups to delete: %s", backups_to_delete) + + if not backups_to_delete: + return + + backup_ids = list(backups_to_delete) + delete_results = await asyncio.gather( + *(self.async_delete_backup(backup_id) for backup_id in backups_to_delete) + ) + agent_errors = { + backup_id: error + for backup_id, error in zip(backup_ids, delete_results, strict=True) + if error + } + if agent_errors: + LOGGER.error( + "Error deleting old copies: %s", + agent_errors, + ) + async def async_receive_backup( self, *, diff --git a/homeassistant/components/hassio/backup.py b/homeassistant/components/hassio/backup.py index 24a1743155e..495e953df9d 100644 --- a/homeassistant/components/hassio/backup.py +++ b/homeassistant/components/hassio/backup.py @@ -33,6 +33,7 @@ from homeassistant.components.backup import ( Folder, IdleEvent, IncorrectPasswordError, + ManagerBackup, NewBackup, RestoreBackupEvent, RestoreBackupState, @@ -54,6 +55,8 @@ LOCATION_CLOUD_BACKUP = ".cloud_backup" LOCATION_LOCAL = ".local" MOUNT_JOBS = ("mount_manager_create_mount", "mount_manager_remove_mount") RESTORE_JOB_ID_ENV = "SUPERVISOR_RESTORE_JOB_ID" +# Set on backups automatically created when updating an addon +TAG_ADDON_UPDATE = "supervisor.addon_update" _LOGGER = logging.getLogger(__name__) @@ -625,10 +628,20 @@ async def backup_addon_before_update( else: password = None + def addon_update_backup_filter( + backups: dict[str, ManagerBackup], + ) -> dict[str, ManagerBackup]: + """Return addon update backups.""" + return { + backup_id: backup + for backup_id, backup in backups.items() + if backup.extra_metadata.get(TAG_ADDON_UPDATE) == addon + } + try: await backup_manager.async_create_backup( agent_ids=[await _default_agent(client)], - extra_metadata={"supervisor.addon_update": addon}, + extra_metadata={TAG_ADDON_UPDATE: addon}, include_addons=[addon], include_all_addons=False, include_database=False, @@ -639,6 +652,14 @@ async def backup_addon_before_update( ) except BackupManagerError as err: raise HomeAssistantError(f"Error creating backup: {err}") from err + else: + try: + await backup_manager.async_delete_filtered_backups( + include_filter=addon_update_backup_filter, + delete_filter=lambda backups: backups, + ) + except BackupManagerError as err: + raise HomeAssistantError(f"Error deleting old backups: {err}") from err async def backup_core_before_update(hass: HomeAssistant) -> None: diff --git a/tests/components/hassio/test_update.py b/tests/components/hassio/test_update.py index 62fe49c5f23..332f2050cf2 100644 --- a/tests/components/hassio/test_update.py +++ b/tests/components/hassio/test_update.py @@ -3,13 +3,13 @@ from datetime import timedelta import os from typing import Any -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, MagicMock, patch from aiohasupervisor import SupervisorBadRequestError, SupervisorError from aiohasupervisor.models import HomeAssistantUpdateOptions, StoreAddonUpdate import pytest -from homeassistant.components.backup import BackupManagerError +from homeassistant.components.backup import BackupManagerError, ManagerBackup from homeassistant.components.hassio import DOMAIN from homeassistant.components.hassio.const import REQUEST_REFRESH_DELAY from homeassistant.const import __version__ as HAVERSION @@ -338,6 +338,113 @@ async def test_update_addon_with_backup( update_addon.assert_called_once_with("test", StoreAddonUpdate(backup=False)) +@pytest.mark.parametrize( + ("backups", "removed_backups"), + [ + ( + {}, + [], + ), + ( + { + "backup-1": MagicMock( + date="2024-11-10T04:45:00+01:00", + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-2": MagicMock( + date="2024-11-11T04:45:00+01:00", + with_automatic_settings=False, + spec=ManagerBackup, + ), + "backup-3": MagicMock( + date="2024-11-11T04:45:00+01:00", + extra_metadata={"supervisor.addon_update": "other"}, + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-4": MagicMock( + date="2024-11-11T04:45:00+01:00", + extra_metadata={"supervisor.addon_update": "other"}, + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-5": MagicMock( + date="2024-11-11T04:45:00+01:00", + extra_metadata={"supervisor.addon_update": "test"}, + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-6": MagicMock( + date="2024-11-12T04:45:00+01:00", + extra_metadata={"supervisor.addon_update": "test"}, + with_automatic_settings=True, + spec=ManagerBackup, + ), + }, + ["backup-5"], + ), + ], +) +async def test_update_addon_with_backup_removes_old_backups( + hass: HomeAssistant, + supervisor_client: AsyncMock, + update_addon: AsyncMock, + backups: dict[str, ManagerBackup], + removed_backups: list[str], +) -> None: + """Test updating addon update entity.""" + config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) + config_entry.add_to_hass(hass) + + with patch.dict(os.environ, MOCK_ENVIRON): + result = await async_setup_component( + hass, + "hassio", + {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + ) + assert result + assert await async_setup_component(hass, "backup", {}) + await hass.async_block_till_done() + + supervisor_client.mounts.info.return_value.default_backup_mount = None + with ( + patch( + "homeassistant.components.backup.manager.BackupManager.async_create_backup", + ) as mock_create_backup, + patch( + "homeassistant.components.backup.manager.BackupManager.async_delete_backup", + autospec=True, + return_value={}, + ) as async_delete_backup, + patch( + "homeassistant.components.backup.manager.BackupManager.async_get_backups", + return_value=(backups, {}), + ), + ): + await hass.services.async_call( + "update", + "install", + {"entity_id": "update.test_update", "backup": True}, + blocking=True, + ) + mock_create_backup.assert_called_once_with( + agent_ids=["hassio.local"], + extra_metadata={"supervisor.addon_update": "test"}, + include_addons=["test"], + include_all_addons=False, + include_database=False, + include_folders=None, + include_homeassistant=False, + name="test 2.0.0", + password=None, + ) + assert len(async_delete_backup.mock_calls) == len(removed_backups) + for call in async_delete_backup.mock_calls: + assert call.args[1] in removed_backups + update_addon.assert_called_once_with("test", StoreAddonUpdate(backup=False)) + + async def test_update_os(hass: HomeAssistant, supervisor_client: AsyncMock) -> None: """Test updating OS update entity.""" config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) @@ -550,9 +657,19 @@ async def test_update_addon_with_error( ) +@pytest.mark.parametrize( + ("create_backup_error", "delete_filtered_backups_error", "message"), + [ + (BackupManagerError, None, r"^Error creating backup: "), + (None, BackupManagerError, r"^Error deleting old backups: "), + ], +) async def test_update_addon_with_backup_and_error( hass: HomeAssistant, supervisor_client: AsyncMock, + create_backup_error: Exception | None, + delete_filtered_backups_error: Exception | None, + message: str, ) -> None: """Test updating addon update entity with error.""" config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) @@ -573,9 +690,13 @@ async def test_update_addon_with_backup_and_error( with ( patch( "homeassistant.components.backup.manager.BackupManager.async_create_backup", - side_effect=BackupManagerError, + side_effect=create_backup_error, ), - pytest.raises(HomeAssistantError, match=r"^Error creating backup:"), + patch( + "homeassistant.components.backup.manager.BackupManager.async_delete_filtered_backups", + side_effect=delete_filtered_backups_error, + ), + pytest.raises(HomeAssistantError, match=message), ): assert not await hass.services.async_call( "update", diff --git a/tests/components/hassio/test_websocket_api.py b/tests/components/hassio/test_websocket_api.py index ab8dc1475e2..bcac19e0fa3 100644 --- a/tests/components/hassio/test_websocket_api.py +++ b/tests/components/hassio/test_websocket_api.py @@ -2,13 +2,13 @@ import os from typing import Any -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, MagicMock, patch from aiohasupervisor import SupervisorError from aiohasupervisor.models import HomeAssistantUpdateOptions, StoreAddonUpdate import pytest -from homeassistant.components.backup import BackupManagerError +from homeassistant.components.backup import BackupManagerError, ManagerBackup from homeassistant.components.hassio import DOMAIN from homeassistant.components.hassio.const import ( ATTR_DATA, @@ -457,6 +457,114 @@ async def test_update_addon_with_backup( update_addon.assert_called_once_with("test", StoreAddonUpdate(backup=False)) +@pytest.mark.parametrize( + ("backups", "removed_backups"), + [ + ( + {}, + [], + ), + ( + { + "backup-1": MagicMock( + date="2024-11-10T04:45:00+01:00", + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-2": MagicMock( + date="2024-11-11T04:45:00+01:00", + with_automatic_settings=False, + spec=ManagerBackup, + ), + "backup-3": MagicMock( + date="2024-11-11T04:45:00+01:00", + extra_metadata={"supervisor.addon_update": "other"}, + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-4": MagicMock( + date="2024-11-11T04:45:00+01:00", + extra_metadata={"supervisor.addon_update": "other"}, + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-5": MagicMock( + date="2024-11-11T04:45:00+01:00", + extra_metadata={"supervisor.addon_update": "test"}, + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-6": MagicMock( + date="2024-11-12T04:45:00+01:00", + extra_metadata={"supervisor.addon_update": "test"}, + with_automatic_settings=True, + spec=ManagerBackup, + ), + }, + ["backup-5"], + ), + ], +) +async def test_update_addon_with_backup_removes_old_backups( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + supervisor_client: AsyncMock, + update_addon: AsyncMock, + backups: dict[str, ManagerBackup], + removed_backups: list[str], +) -> None: + """Test updating addon update entity.""" + config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) + config_entry.add_to_hass(hass) + + with patch.dict(os.environ, MOCK_ENVIRON): + result = await async_setup_component( + hass, + "hassio", + {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + ) + assert result + assert await async_setup_component(hass, "backup", {}) + await hass.async_block_till_done() + + client = await hass_ws_client(hass) + supervisor_client.mounts.info.return_value.default_backup_mount = None + with ( + patch( + "homeassistant.components.backup.manager.BackupManager.async_create_backup", + ) as mock_create_backup, + patch( + "homeassistant.components.backup.manager.BackupManager.async_delete_backup", + autospec=True, + return_value={}, + ) as async_delete_backup, + patch( + "homeassistant.components.backup.manager.BackupManager.async_get_backups", + return_value=(backups, {}), + ), + ): + await client.send_json_auto_id( + {"type": "hassio/update/addon", "addon": "test", "backup": True} + ) + result = await client.receive_json() + assert result["success"] + mock_create_backup.assert_called_once_with( + agent_ids=["hassio.local"], + extra_metadata={"supervisor.addon_update": "test"}, + include_addons=["test"], + include_all_addons=False, + include_database=False, + include_folders=None, + include_homeassistant=False, + name="test 2.0.0", + password=None, + ) + assert len(async_delete_backup.mock_calls) == len(removed_backups) + for call in async_delete_backup.mock_calls: + assert call.args[1] in removed_backups + update_addon.assert_called_once_with("test", StoreAddonUpdate(backup=False)) + + async def test_update_core( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, @@ -622,10 +730,20 @@ async def test_update_addon_with_error( } +@pytest.mark.parametrize( + ("create_backup_error", "delete_filtered_backups_error", "message"), + [ + (BackupManagerError, None, "Error creating backup: "), + (None, BackupManagerError, "Error deleting old backups: "), + ], +) async def test_update_addon_with_backup_and_error( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, supervisor_client: AsyncMock, + create_backup_error: Exception | None, + delete_filtered_backups_error: Exception | None, + message: str, ) -> None: """Test updating addon with backup and error.""" client = await hass_ws_client(hass) @@ -647,7 +765,11 @@ async def test_update_addon_with_backup_and_error( with ( patch( "homeassistant.components.backup.manager.BackupManager.async_create_backup", - side_effect=BackupManagerError, + side_effect=create_backup_error, + ), + patch( + "homeassistant.components.backup.manager.BackupManager.async_delete_filtered_backups", + side_effect=delete_filtered_backups_error, ), ): await client.send_json_auto_id( @@ -655,10 +777,7 @@ async def test_update_addon_with_backup_and_error( ) result = await client.receive_json() assert not result["success"] - assert result["error"] == { - "code": "home_assistant_error", - "message": "Error creating backup: ", - } + assert result["error"] == {"code": "home_assistant_error", "message": message} async def test_update_core_with_error( From 9bc3c417aea0d949c33cb07021d2eea86100ea34 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Fri, 31 Jan 2025 19:36:40 +0100 Subject: [PATCH 0365/3148] Add codeowner to Home Connect (#137029) --- CODEOWNERS | 4 ++-- homeassistant/components/home_connect/manifest.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 7baeea72178..635f53d346f 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -625,8 +625,8 @@ build.json @home-assistant/supervisor /tests/components/hlk_sw16/ @jameshilliard /homeassistant/components/holiday/ @jrieger @gjohansson-ST /tests/components/holiday/ @jrieger @gjohansson-ST -/homeassistant/components/home_connect/ @DavidMStraub @Diegorro98 -/tests/components/home_connect/ @DavidMStraub @Diegorro98 +/homeassistant/components/home_connect/ @DavidMStraub @Diegorro98 @MartinHjelmare +/tests/components/home_connect/ @DavidMStraub @Diegorro98 @MartinHjelmare /homeassistant/components/homeassistant/ @home-assistant/core /tests/components/homeassistant/ @home-assistant/core /homeassistant/components/homeassistant_alerts/ @home-assistant/core diff --git a/homeassistant/components/home_connect/manifest.json b/homeassistant/components/home_connect/manifest.json index 905a7c67f11..1d9f3f363aa 100644 --- a/homeassistant/components/home_connect/manifest.json +++ b/homeassistant/components/home_connect/manifest.json @@ -1,7 +1,7 @@ { "domain": "home_connect", "name": "Home Connect", - "codeowners": ["@DavidMStraub", "@Diegorro98"], + "codeowners": ["@DavidMStraub", "@Diegorro98", "@MartinHjelmare"], "config_flow": true, "dependencies": ["application_credentials"], "documentation": "https://www.home-assistant.io/integrations/home_connect", From df59b1d4fac0678b8a1371c9d0083be63e7d6185 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Fri, 31 Jan 2025 10:45:01 -0800 Subject: [PATCH 0366/3148] Persist roborock maps to disk only on shutdown (#136889) * Persist roborock maps to disk only on shutdown * Rename on_unload to on_stop * Spawn 1 executor thread and block writes to disk * Update tests/components/roborock/test_image.py Co-authored-by: Joost Lekkerkerker * Use config entry setup instead of component setup --------- Co-authored-by: Joost Lekkerkerker --- homeassistant/components/roborock/__init__.py | 24 ++++++++++------ .../components/roborock/coordinator.py | 18 ++++++++---- homeassistant/components/roborock/image.py | 10 ++----- .../components/roborock/roborock_storage.py | 20 +++++++++++-- tests/components/roborock/conftest.py | 10 +++++-- tests/components/roborock/test_image.py | 28 +++++++++++-------- tests/components/roborock/test_init.py | 8 ++++++ 7 files changed, 79 insertions(+), 39 deletions(-) diff --git a/homeassistant/components/roborock/__init__.py b/homeassistant/components/roborock/__init__.py index 1b34dc891d1..b383c1acfd7 100644 --- a/homeassistant/components/roborock/__init__.py +++ b/homeassistant/components/roborock/__init__.py @@ -22,7 +22,7 @@ from roborock.version_a01_apis import RoborockMqttClientA01 from roborock.web_api import RoborockApiClient from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_USERNAME +from homeassistant.const import CONF_USERNAME, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady @@ -118,13 +118,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: RoborockConfigEntry) -> ) valid_coordinators = RoborockCoordinators(v1_coords, a01_coords) - async def on_unload() -> None: - release_tasks = set() - for coordinator in valid_coordinators.values(): - release_tasks.add(coordinator.release()) - await asyncio.gather(*release_tasks) + async def on_stop(_: Any) -> None: + _LOGGER.debug("Shutting down roborock") + await asyncio.gather( + *( + coordinator.async_shutdown() + for coordinator in valid_coordinators.values() + ) + ) - entry.async_on_unload(on_unload) + entry.async_on_unload( + hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_STOP, + on_stop, + ) + ) entry.runtime_data = valid_coordinators await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -209,7 +217,7 @@ async def setup_device_v1( try: await coordinator.async_config_entry_first_refresh() except ConfigEntryNotReady as ex: - await coordinator.release() + await coordinator.async_shutdown() if isinstance(coordinator.api, RoborockMqttClientV1): _LOGGER.warning( "Not setting up %s because the we failed to get data for the first time using the online client. " diff --git a/homeassistant/components/roborock/coordinator.py b/homeassistant/components/roborock/coordinator.py index 36333f1c55e..8860a5c1f43 100644 --- a/homeassistant/components/roborock/coordinator.py +++ b/homeassistant/components/roborock/coordinator.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio from datetime import timedelta import logging @@ -116,10 +117,14 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): # Right now this should never be called if the cloud api is the primary api, # but in the future if it is, a new else should be added. - async def release(self) -> None: - """Disconnect from API.""" - await self.api.async_release() - await self.cloud_api.async_release() + async def async_shutdown(self) -> None: + """Shutdown the coordinator.""" + await super().async_shutdown() + await asyncio.gather( + self.map_storage.flush(), + self.api.async_release(), + self.cloud_api.async_release(), + ) async def _update_device_prop(self) -> None: """Update device properties.""" @@ -226,8 +231,9 @@ class RoborockDataUpdateCoordinatorA01( ) -> dict[RoborockDyadDataProtocol | RoborockZeoProtocol, StateType]: return await self.api.update_values(self.request_protocols) - async def release(self) -> None: - """Disconnect from API.""" + async def async_shutdown(self) -> None: + """Shutdown the coordinator on config entry unload.""" + await super().async_shutdown() await self.api.async_release() @cached_property diff --git a/homeassistant/components/roborock/image.py b/homeassistant/components/roborock/image.py index b0de4f9caa5..b4776c27164 100644 --- a/homeassistant/components/roborock/image.py +++ b/homeassistant/components/roborock/image.py @@ -157,13 +157,9 @@ class RoborockMap(RoborockCoordinatedEntityV1, ImageEntity): ) if self.cached_map != content: self.cached_map = content - self.config_entry.async_create_task( - self.hass, - self.coordinator.map_storage.async_save_map( - self.map_flag, - content, - ), - f"{self.unique_id} map", + await self.coordinator.map_storage.async_save_map( + self.map_flag, + content, ) return self.cached_map diff --git a/homeassistant/components/roborock/roborock_storage.py b/homeassistant/components/roborock/roborock_storage.py index 62e15e889be..8a469b0a38e 100644 --- a/homeassistant/components/roborock/roborock_storage.py +++ b/homeassistant/components/roborock/roborock_storage.py @@ -31,6 +31,7 @@ class RoborockMapStorage: self._path_prefix = ( _storage_path_prefix(hass, entry_id) / MAPS_PATH / device_id_slug ) + self._write_queue: dict[int, bytes] = {} async def async_load_map(self, map_flag: int) -> bytes | None: """Load maps from disk.""" @@ -48,9 +49,22 @@ class RoborockMapStorage: return None async def async_save_map(self, map_flag: int, content: bytes) -> None: - """Write map if it should be updated.""" - filename = self._path_prefix / f"{map_flag}{MAP_FILENAME_SUFFIX}" - await self._hass.async_add_executor_job(self._save_map, filename, content) + """Save the map to a pending write queue.""" + self._write_queue[map_flag] = content + + async def flush(self) -> None: + """Flush all maps to disk.""" + _LOGGER.debug("Flushing %s maps to disk", len(self._write_queue)) + + queue = self._write_queue.copy() + + def _flush_all() -> None: + for map_flag, content in queue.items(): + filename = self._path_prefix / f"{map_flag}{MAP_FILENAME_SUFFIX}" + self._save_map(filename, content) + + await self._hass.async_add_executor_job(_flush_all) + self._write_queue.clear() def _save_map(self, filename: Path, content: bytes) -> None: """Write the map to disk.""" diff --git a/tests/components/roborock/conftest.py b/tests/components/roborock/conftest.py index e5fc5cb7eb6..43e5148c9a8 100644 --- a/tests/components/roborock/conftest.py +++ b/tests/components/roborock/conftest.py @@ -19,9 +19,9 @@ from homeassistant.components.roborock.const import ( CONF_USER_DATA, DOMAIN, ) +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_USERNAME, Platform from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component from .mock_data import ( BASE_URL, @@ -207,13 +207,13 @@ async def setup_entry( ) -> Generator[MockConfigEntry]: """Set up the Roborock platform.""" with patch("homeassistant.components.roborock.PLATFORMS", platforms): - assert await async_setup_component(hass, DOMAIN, {}) + await hass.config_entries.async_setup(mock_roborock_entry.entry_id) await hass.async_block_till_done() yield mock_roborock_entry @pytest.fixture -def cleanup_map_storage( +async def cleanup_map_storage( hass: HomeAssistant, mock_roborock_entry: MockConfigEntry ) -> Generator[pathlib.Path]: """Test cleanup, remove any map storage persisted during the test.""" @@ -225,4 +225,8 @@ def cleanup_map_storage( pathlib.Path(hass.config.path(tmp_path)) / mock_roborock_entry.entry_id ) yield storage_path + # We need to first unload the config entry because unloading it will + # persist any unsaved maps to storage. + if mock_roborock_entry.state is ConfigEntryState.LOADED: + await hass.config_entries.async_unload(mock_roborock_entry.entry_id) shutil.rmtree(str(storage_path), ignore_errors=True) diff --git a/tests/components/roborock/test_image.py b/tests/components/roborock/test_image.py index 90886f25929..fd6c8b2796a 100644 --- a/tests/components/roborock/test_image.py +++ b/tests/components/roborock/test_image.py @@ -12,6 +12,7 @@ from roborock import RoborockException from vacuum_map_parser_base.map_data import ImageConfig, ImageData from homeassistant.components.roborock import DOMAIN +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -120,7 +121,7 @@ async def test_load_stored_image( MAP_DATA.image.data.save(img_byte_arr, format="PNG") img_bytes = img_byte_arr.getvalue() - # Load the image on demand, which should ensure it is cached on disk + # Load the image on demand, which should queue it to be cached on disk client = await hass_client() resp = await client.get("/api/image_proxy/image.roborock_s7_maxv_upstairs") assert resp.status == HTTPStatus.OK @@ -151,22 +152,25 @@ async def test_fail_to_save_image( caplog: pytest.LogCaptureFixture, ) -> None: """Test that we gracefully handle a oserror on saving an image.""" - # Reload the config entry so that the map is saved in storage and entities exist. + await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + # Ensure that map is still working properly. + assert hass.states.get("image.roborock_s7_maxv_upstairs") is not None + client = await hass_client() + resp = await client.get("/api/image_proxy/image.roborock_s7_maxv_upstairs") + # Test that we can get the image and it correctly serialized and unserialized. + assert resp.status == HTTPStatus.OK + with patch( "homeassistant.components.roborock.roborock_storage.Path.write_bytes", side_effect=OSError, ): - await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() + await hass.config_entries.async_unload(mock_roborock_entry.entry_id) + assert "Unable to write map file" in caplog.text - # Ensure that map is still working properly. - assert hass.states.get("image.roborock_s7_maxv_upstairs") is not None - client = await hass_client() - resp = await client.get("/api/image_proxy/image.roborock_s7_maxv_upstairs") - # Test that we can get the image and it correctly serialized and unserialized. - assert resp.status == HTTPStatus.OK - - assert "Unable to write map file" in caplog.text + # Config entry is unloaded successfully + assert mock_roborock_entry.state is ConfigEntryState.NOT_LOADED async def test_fail_to_load_image( diff --git a/tests/components/roborock/test_init.py b/tests/components/roborock/test_init.py index efd1c3f66f4..904a3af89d6 100644 --- a/tests/components/roborock/test_init.py +++ b/tests/components/roborock/test_init.py @@ -183,6 +183,10 @@ async def test_remove_from_hass( resp = await client.get("/api/image_proxy/image.roborock_s7_maxv_upstairs") assert resp.status == HTTPStatus.OK + assert not cleanup_map_storage.exists() + + # Flush to disk + await hass.config_entries.async_unload(setup_entry.entry_id) assert cleanup_map_storage.exists() paths = list(cleanup_map_storage.walk()) assert len(paths) == 3 # One map image and two directories @@ -209,6 +213,10 @@ async def test_oserror_remove_image( resp = await client.get("/api/image_proxy/image.roborock_s7_maxv_upstairs") assert resp.status == HTTPStatus.OK + # Image content is saved when unloading + assert not cleanup_map_storage.exists() + await hass.config_entries.async_unload(setup_entry.entry_id) + assert cleanup_map_storage.exists() paths = list(cleanup_map_storage.walk()) assert len(paths) == 3 # One map image and two directories From 92dd18a9bed750869a460f45159fcefcfafdeec1 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Fri, 31 Jan 2025 19:48:47 +0100 Subject: [PATCH 0367/3148] Ensure Reolink can start when privacy mode is enabled (#136514) * Allow startup when privacy mode is enabled * Add tests * remove duplicate privacy_mode * fix tests * Apply suggestions from code review Co-authored-by: Robert Resch * Store in subfolder and cleanup when removed * Add tests and fixes * fix styling * rename CONF_PRIVACY to CONF_SUPPORTS_PRIVACY_MODE * use helper store --------- Co-authored-by: Robert Resch --- homeassistant/components/reolink/__init__.py | 34 +++++++++++++------ .../components/reolink/config_flow.py | 5 ++- homeassistant/components/reolink/const.py | 1 + homeassistant/components/reolink/host.py | 30 ++++++++++++++-- homeassistant/components/reolink/util.py | 14 ++++++-- tests/components/reolink/conftest.py | 13 ++++++- tests/components/reolink/test_config_flow.py | 11 +++++- tests/components/reolink/test_init.py | 12 +++++++ 8 files changed, 102 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/reolink/__init__.py b/homeassistant/components/reolink/__init__.py index 576ab3c64f8..71ca5428740 100644 --- a/homeassistant/components/reolink/__init__.py +++ b/homeassistant/components/reolink/__init__.py @@ -28,11 +28,11 @@ from homeassistant.helpers.event import async_call_later from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import CONF_USE_HTTPS, DOMAIN +from .const import CONF_SUPPORTS_PRIVACY_MODE, CONF_USE_HTTPS, DOMAIN from .exceptions import PasswordIncompatible, ReolinkException, UserNotAdmin from .host import ReolinkHost from .services import async_setup_services -from .util import ReolinkConfigEntry, ReolinkData, get_device_uid_and_ch +from .util import ReolinkConfigEntry, ReolinkData, get_device_uid_and_ch, get_store from .views import PlaybackProxyView _LOGGER = logging.getLogger(__name__) @@ -67,7 +67,9 @@ async def async_setup_entry( hass: HomeAssistant, config_entry: ReolinkConfigEntry ) -> bool: """Set up Reolink from a config entry.""" - host = ReolinkHost(hass, config_entry.data, config_entry.options) + host = ReolinkHost( + hass, config_entry.data, config_entry.options, config_entry.entry_id + ) try: await host.async_init() @@ -92,21 +94,25 @@ async def async_setup_entry( hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, host.stop) ) - # update the port info if needed for the next time + # update the config info if needed for the next time if ( host.api.port != config_entry.data[CONF_PORT] or host.api.use_https != config_entry.data[CONF_USE_HTTPS] + or host.api.supported(None, "privacy_mode") + != config_entry.data.get(CONF_SUPPORTS_PRIVACY_MODE) ): - _LOGGER.warning( - "HTTP(s) port of Reolink %s, changed from %s to %s", - host.api.nvr_name, - config_entry.data[CONF_PORT], - host.api.port, - ) + if host.api.port != config_entry.data[CONF_PORT]: + _LOGGER.warning( + "HTTP(s) port of Reolink %s, changed from %s to %s", + host.api.nvr_name, + config_entry.data[CONF_PORT], + host.api.port, + ) data = { **config_entry.data, CONF_PORT: host.api.port, CONF_USE_HTTPS: host.api.use_https, + CONF_SUPPORTS_PRIVACY_MODE: host.api.supported(None, "privacy_mode"), } hass.config_entries.async_update_entry(config_entry, data=data) @@ -248,6 +254,14 @@ async def async_unload_entry( return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) +async def async_remove_entry( + hass: HomeAssistant, config_entry: ReolinkConfigEntry +) -> None: + """Handle removal of an entry.""" + store = get_store(hass, config_entry.entry_id) + await store.async_remove() + + async def async_remove_config_entry_device( hass: HomeAssistant, config_entry: ReolinkConfigEntry, device: dr.DeviceEntry ) -> bool: diff --git a/homeassistant/components/reolink/config_flow.py b/homeassistant/components/reolink/config_flow.py index e15a43e360b..7943cadef21 100644 --- a/homeassistant/components/reolink/config_flow.py +++ b/homeassistant/components/reolink/config_flow.py @@ -37,7 +37,7 @@ from homeassistant.helpers import config_validation as cv, selector from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo -from .const import CONF_USE_HTTPS, DOMAIN +from .const import CONF_SUPPORTS_PRIVACY_MODE, CONF_USE_HTTPS, DOMAIN from .exceptions import ( PasswordIncompatible, ReolinkException, @@ -287,6 +287,9 @@ class ReolinkFlowHandler(ConfigFlow, domain=DOMAIN): if not errors: user_input[CONF_PORT] = host.api.port user_input[CONF_USE_HTTPS] = host.api.use_https + user_input[CONF_SUPPORTS_PRIVACY_MODE] = host.api.supported( + None, "privacy_mode" + ) mac_address = format_mac(host.api.mac_address) await self.async_set_unique_id(mac_address, raise_on_progress=False) diff --git a/homeassistant/components/reolink/const.py b/homeassistant/components/reolink/const.py index 8aa01bfac41..7bd93337c46 100644 --- a/homeassistant/components/reolink/const.py +++ b/homeassistant/components/reolink/const.py @@ -3,3 +3,4 @@ DOMAIN = "reolink" CONF_USE_HTTPS = "use_https" +CONF_SUPPORTS_PRIVACY_MODE = "privacy_mode_supported" diff --git a/homeassistant/components/reolink/host.py b/homeassistant/components/reolink/host.py index e9b86f1e297..a23f53ff9cd 100644 --- a/homeassistant/components/reolink/host.py +++ b/homeassistant/components/reolink/host.py @@ -30,15 +30,17 @@ from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_call_later from homeassistant.helpers.network import NoURLAvailableError, get_url +from homeassistant.helpers.storage import Store from homeassistant.util.ssl import SSLCipherList -from .const import CONF_USE_HTTPS, DOMAIN +from .const import CONF_SUPPORTS_PRIVACY_MODE, CONF_USE_HTTPS, DOMAIN from .exceptions import ( PasswordIncompatible, ReolinkSetupException, ReolinkWebhookException, UserNotAdmin, ) +from .util import get_store DEFAULT_TIMEOUT = 30 FIRST_TCP_PUSH_TIMEOUT = 10 @@ -64,9 +66,12 @@ class ReolinkHost: hass: HomeAssistant, config: Mapping[str, Any], options: Mapping[str, Any], + config_entry_id: str | None = None, ) -> None: """Initialize Reolink Host. Could be either NVR, or Camera.""" self._hass: HomeAssistant = hass + self._config_entry_id = config_entry_id + self._config = config self._unique_id: str = "" def get_aiohttp_session() -> aiohttp.ClientSession: @@ -150,6 +155,14 @@ class ReolinkHost: f"a-z, A-Z, 0-9 or {ALLOWED_SPECIAL_CHARS}" ) + store: Store[str] | None = None + if self._config_entry_id is not None: + store = get_store(self._hass, self._config_entry_id) + if self._config.get(CONF_SUPPORTS_PRIVACY_MODE): + data = await store.async_load() + if data: + self._api.set_raw_host_data(data) + await self._api.get_host_data() if self._api.mac_address is None: @@ -161,6 +174,19 @@ class ReolinkHost: f"'{self._api.user_level}', only admin users can change camera settings" ) + self.privacy_mode = self._api.baichuan.privacy_mode() + + if ( + store + and self._api.supported(None, "privacy_mode") + and not self.privacy_mode + ): + _LOGGER.debug( + "Saving raw host data for next reload in case privacy mode is enabled" + ) + data = self._api.get_raw_host_data() + await store.async_save(data) + onvif_supported = self._api.supported(None, "ONVIF") self._onvif_push_supported = onvif_supported self._onvif_long_poll_supported = onvif_supported @@ -235,8 +261,6 @@ class ReolinkHost: self._hass, FIRST_TCP_PUSH_TIMEOUT, self._async_check_tcp_push ) - self.privacy_mode = self._api.baichuan.privacy_mode() - ch_list: list[int | None] = [None] if self._api.is_nvr: ch_list.extend(self._api.channels) diff --git a/homeassistant/components/reolink/util.py b/homeassistant/components/reolink/util.py index e43391f19fb..a5556b66a33 100644 --- a/homeassistant/components/reolink/util.py +++ b/homeassistant/components/reolink/util.py @@ -4,7 +4,7 @@ from __future__ import annotations from collections.abc import Awaitable, Callable, Coroutine from dataclasses import dataclass -from typing import Any +from typing import TYPE_CHECKING, Any from reolink_aio.exceptions import ( ApiError, @@ -26,10 +26,15 @@ from homeassistant.components.media_source import Unresolvable from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.storage import Store from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import DOMAIN -from .host import ReolinkHost + +if TYPE_CHECKING: + from .host import ReolinkHost + +STORAGE_VERSION = 1 type ReolinkConfigEntry = config_entries.ConfigEntry[ReolinkData] @@ -64,6 +69,11 @@ def get_host(hass: HomeAssistant, config_entry_id: str) -> ReolinkHost: return config_entry.runtime_data.host +def get_store(hass: HomeAssistant, config_entry_id: str) -> Store[str]: + """Return the reolink store.""" + return Store[str](hass, STORAGE_VERSION, f"{DOMAIN}.{config_entry_id}.json") + + def get_device_uid_and_ch( device: dr.DeviceEntry, host: ReolinkHost ) -> tuple[list[str], int | None, bool]: diff --git a/tests/components/reolink/conftest.py b/tests/components/reolink/conftest.py index f8012f91351..2862aa55b4d 100644 --- a/tests/components/reolink/conftest.py +++ b/tests/components/reolink/conftest.py @@ -9,7 +9,11 @@ from reolink_aio.baichuan import Baichuan from reolink_aio.exceptions import ReolinkError from homeassistant.components.reolink.config_flow import DEFAULT_PROTOCOL -from homeassistant.components.reolink.const import CONF_USE_HTTPS, DOMAIN +from homeassistant.components.reolink.const import ( + CONF_SUPPORTS_PRIVACY_MODE, + CONF_USE_HTTPS, + DOMAIN, +) from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, @@ -43,6 +47,7 @@ TEST_HOST_MODEL = "RLN8-410" TEST_ITEM_NUMBER = "P000" TEST_CAM_MODEL = "RLC-123" TEST_DUO_MODEL = "Reolink Duo PoE" +TEST_PRIVACY = True @pytest.fixture @@ -65,6 +70,7 @@ def reolink_connect_class() -> Generator[MagicMock]: host_mock = host_mock_class.return_value host_mock.get_host_data.return_value = None host_mock.get_states.return_value = None + host_mock.supported.return_value = True host_mock.check_new_firmware.return_value = False host_mock.unsubscribe.return_value = True host_mock.logout.return_value = True @@ -113,6 +119,9 @@ def reolink_connect_class() -> Generator[MagicMock]: host_mock.capabilities = {"Host": ["RTSP"], "0": ["motion_detection"]} host_mock.checked_api_versions = {"GetEvents": 1} host_mock.abilities = {"abilityChn": [{"aiTrack": {"permit": 0, "ver": 0}}]} + host_mock.get_raw_host_data.return_value = ( + "{'host':'TEST_RESPONSE','channel':'TEST_RESPONSE'}" + ) # enums host_mock.whiteled_mode.return_value = 1 @@ -128,6 +137,7 @@ def reolink_connect_class() -> Generator[MagicMock]: host_mock.baichuan.events_active = False host_mock.baichuan.privacy_mode.return_value = False host_mock.baichuan.subscribe_events.side_effect = ReolinkError("Test error") + yield host_mock_class @@ -158,6 +168,7 @@ def config_entry(hass: HomeAssistant) -> MockConfigEntry: CONF_PASSWORD: TEST_PASSWORD, CONF_PORT: TEST_PORT, CONF_USE_HTTPS: TEST_USE_HTTPS, + CONF_SUPPORTS_PRIVACY_MODE: TEST_PRIVACY, }, options={ CONF_PROTOCOL: DEFAULT_PROTOCOL, diff --git a/tests/components/reolink/test_config_flow.py b/tests/components/reolink/test_config_flow.py index 4d474588f38..4fe671f8cca 100644 --- a/tests/components/reolink/test_config_flow.py +++ b/tests/components/reolink/test_config_flow.py @@ -18,7 +18,11 @@ from reolink_aio.exceptions import ( from homeassistant import config_entries from homeassistant.components.reolink import DEVICE_UPDATE_INTERVAL from homeassistant.components.reolink.config_flow import DEFAULT_PROTOCOL -from homeassistant.components.reolink.const import CONF_USE_HTTPS, DOMAIN +from homeassistant.components.reolink.const import ( + CONF_SUPPORTS_PRIVACY_MODE, + CONF_USE_HTTPS, + DOMAIN, +) from homeassistant.components.reolink.exceptions import ReolinkWebhookException from homeassistant.components.reolink.host import DEFAULT_TIMEOUT from homeassistant.config_entries import ConfigEntryState @@ -43,6 +47,7 @@ from .conftest import ( TEST_PASSWORD, TEST_PASSWORD2, TEST_PORT, + TEST_PRIVACY, TEST_USE_HTTPS, TEST_USERNAME, TEST_USERNAME2, @@ -82,6 +87,7 @@ async def test_config_flow_manual_success( CONF_PASSWORD: TEST_PASSWORD, CONF_PORT: TEST_PORT, CONF_USE_HTTPS: TEST_USE_HTTPS, + CONF_SUPPORTS_PRIVACY_MODE: TEST_PRIVACY, } assert result["options"] == { CONF_PROTOCOL: DEFAULT_PROTOCOL, @@ -133,6 +139,7 @@ async def test_config_flow_privacy_success( CONF_PASSWORD: TEST_PASSWORD, CONF_PORT: TEST_PORT, CONF_USE_HTTPS: TEST_USE_HTTPS, + CONF_SUPPORTS_PRIVACY_MODE: TEST_PRIVACY, } assert result["options"] == { CONF_PROTOCOL: DEFAULT_PROTOCOL, @@ -294,6 +301,7 @@ async def test_config_flow_errors( CONF_PASSWORD: TEST_PASSWORD, CONF_PORT: TEST_PORT, CONF_USE_HTTPS: TEST_USE_HTTPS, + CONF_SUPPORTS_PRIVACY_MODE: TEST_PRIVACY, } assert result["options"] == { CONF_PROTOCOL: DEFAULT_PROTOCOL, @@ -465,6 +473,7 @@ async def test_dhcp_flow(hass: HomeAssistant, mock_setup_entry: MagicMock) -> No CONF_PASSWORD: TEST_PASSWORD, CONF_PORT: TEST_PORT, CONF_USE_HTTPS: TEST_USE_HTTPS, + CONF_SUPPORTS_PRIVACY_MODE: TEST_PRIVACY, } assert result["options"] == { CONF_PROTOCOL: DEFAULT_PROTOCOL, diff --git a/tests/components/reolink/test_init.py b/tests/components/reolink/test_init.py index 7895923dd12..25029375eb6 100644 --- a/tests/components/reolink/test_init.py +++ b/tests/components/reolink/test_init.py @@ -859,3 +859,15 @@ async def test_privacy_mode_change_callback( assert reolink_connect.get_states.call_count >= 1 assert hass.states.get(entity_id).state == STATE_ON + + +async def test_remove( + hass: HomeAssistant, + reolink_connect: MagicMock, + config_entry: MockConfigEntry, +) -> None: + """Test removing of the reolink integration.""" + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert await hass.config_entries.async_remove(config_entry.entry_id) From f75a61ac904f689a7e9df233ade94c0bf8672991 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 31 Jan 2025 12:52:38 -0600 Subject: [PATCH 0368/3148] Bump SQLAlchemy to 2.0.37 (#137028) changelog: https://docs.sqlalchemy.org/en/20/changelog/changelog_20.html#change-2.0.37 There is a bug fix that likely affects us that could lead to corrupted queries https://docs.sqlalchemy.org/en/20/changelog/changelog_20.html#change-e4d04d8eb1bccee16b74f5662aff8edd --- homeassistant/components/recorder/manifest.json | 2 +- homeassistant/components/sql/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/recorder/manifest.json b/homeassistant/components/recorder/manifest.json index d3b6e52ad11..7cef284ef60 100644 --- a/homeassistant/components/recorder/manifest.json +++ b/homeassistant/components/recorder/manifest.json @@ -7,7 +7,7 @@ "iot_class": "local_push", "quality_scale": "internal", "requirements": [ - "SQLAlchemy==2.0.36", + "SQLAlchemy==2.0.37", "fnv-hash-fast==1.2.2", "psutil-home-assistant==0.0.1" ] diff --git a/homeassistant/components/sql/manifest.json b/homeassistant/components/sql/manifest.json index 01c95d6c5e4..0094770d53b 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.36", "sqlparse==0.5.0"] + "requirements": ["SQLAlchemy==2.0.37", "sqlparse==0.5.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 2d4e92e2e9a..0a1b97abc55 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -61,7 +61,7 @@ PyTurboJPEG==1.7.5 PyYAML==6.0.2 requests==2.32.3 securetar==2025.1.4 -SQLAlchemy==2.0.36 +SQLAlchemy==2.0.37 standard-aifc==3.13.0;python_version>='3.13' standard-telnetlib==3.13.0;python_version>='3.13' typing-extensions>=4.12.2,<5.0 diff --git a/pyproject.toml b/pyproject.toml index 74e3d51a222..3ad3240907c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -67,7 +67,7 @@ dependencies = [ "PyYAML==6.0.2", "requests==2.32.3", "securetar==2025.1.4", - "SQLAlchemy==2.0.36", + "SQLAlchemy==2.0.37", "standard-aifc==3.13.0;python_version>='3.13'", "standard-telnetlib==3.13.0;python_version>='3.13'", "typing-extensions>=4.12.2,<5.0", diff --git a/requirements.txt b/requirements.txt index 77fd3887db4..02f3849148b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -39,7 +39,7 @@ python-slugify==8.0.4 PyYAML==6.0.2 requests==2.32.3 securetar==2025.1.4 -SQLAlchemy==2.0.36 +SQLAlchemy==2.0.37 standard-aifc==3.13.0;python_version>='3.13' standard-telnetlib==3.13.0;python_version>='3.13' typing-extensions>=4.12.2,<5.0 diff --git a/requirements_all.txt b/requirements_all.txt index 5e182110235..1cfea1bb0e1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -116,7 +116,7 @@ RtmAPI==0.7.2 # homeassistant.components.recorder # homeassistant.components.sql -SQLAlchemy==2.0.36 +SQLAlchemy==2.0.37 # homeassistant.components.tami4 Tami4EdgeAPI==3.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 86557711111..7b77388556d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -110,7 +110,7 @@ RtmAPI==0.7.2 # homeassistant.components.recorder # homeassistant.components.sql -SQLAlchemy==2.0.36 +SQLAlchemy==2.0.37 # homeassistant.components.tami4 Tami4EdgeAPI==3.0 From df166d178c8fc2b3f7589af6bf6a8d0790c6c776 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Fri, 31 Jan 2025 20:17:14 +0100 Subject: [PATCH 0369/3148] Bump deebot-client to 11.1.0b2 (#137030) --- homeassistant/components/ecovacs/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ecovacs/manifest.json b/homeassistant/components/ecovacs/manifest.json index 188f59f74e4..16929e1741a 100644 --- a/homeassistant/components/ecovacs/manifest.json +++ b/homeassistant/components/ecovacs/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/ecovacs", "iot_class": "cloud_push", "loggers": ["sleekxmppfs", "sucks", "deebot_client"], - "requirements": ["py-sucks==0.9.10", "deebot-client==11.1.0b1"] + "requirements": ["py-sucks==0.9.10", "deebot-client==11.1.0b2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 1cfea1bb0e1..bb48565e2ee 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -750,7 +750,7 @@ debugpy==1.8.11 # decora==0.6 # homeassistant.components.ecovacs -deebot-client==11.1.0b1 +deebot-client==11.1.0b2 # homeassistant.components.ihc # homeassistant.components.namecheapdns diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7b77388556d..1695de16332 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -640,7 +640,7 @@ dbus-fast==2.30.2 debugpy==1.8.11 # homeassistant.components.ecovacs -deebot-client==11.1.0b1 +deebot-client==11.1.0b2 # homeassistant.components.ihc # homeassistant.components.namecheapdns From 9a55b5e3f7622c3949eef07cca83c7d488e7592a Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Fri, 31 Jan 2025 19:48:47 +0100 Subject: [PATCH 0370/3148] Ensure Reolink can start when privacy mode is enabled (#136514) * Allow startup when privacy mode is enabled * Add tests * remove duplicate privacy_mode * fix tests * Apply suggestions from code review Co-authored-by: Robert Resch * Store in subfolder and cleanup when removed * Add tests and fixes * fix styling * rename CONF_PRIVACY to CONF_SUPPORTS_PRIVACY_MODE * use helper store --------- Co-authored-by: Robert Resch --- homeassistant/components/reolink/__init__.py | 34 +++++++++++++------ .../components/reolink/config_flow.py | 5 ++- homeassistant/components/reolink/const.py | 1 + homeassistant/components/reolink/host.py | 30 ++++++++++++++-- homeassistant/components/reolink/util.py | 14 ++++++-- tests/components/reolink/conftest.py | 13 ++++++- tests/components/reolink/test_config_flow.py | 11 +++++- tests/components/reolink/test_init.py | 12 +++++++ 8 files changed, 102 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/reolink/__init__.py b/homeassistant/components/reolink/__init__.py index 576ab3c64f8..71ca5428740 100644 --- a/homeassistant/components/reolink/__init__.py +++ b/homeassistant/components/reolink/__init__.py @@ -28,11 +28,11 @@ from homeassistant.helpers.event import async_call_later from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import CONF_USE_HTTPS, DOMAIN +from .const import CONF_SUPPORTS_PRIVACY_MODE, CONF_USE_HTTPS, DOMAIN from .exceptions import PasswordIncompatible, ReolinkException, UserNotAdmin from .host import ReolinkHost from .services import async_setup_services -from .util import ReolinkConfigEntry, ReolinkData, get_device_uid_and_ch +from .util import ReolinkConfigEntry, ReolinkData, get_device_uid_and_ch, get_store from .views import PlaybackProxyView _LOGGER = logging.getLogger(__name__) @@ -67,7 +67,9 @@ async def async_setup_entry( hass: HomeAssistant, config_entry: ReolinkConfigEntry ) -> bool: """Set up Reolink from a config entry.""" - host = ReolinkHost(hass, config_entry.data, config_entry.options) + host = ReolinkHost( + hass, config_entry.data, config_entry.options, config_entry.entry_id + ) try: await host.async_init() @@ -92,21 +94,25 @@ async def async_setup_entry( hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, host.stop) ) - # update the port info if needed for the next time + # update the config info if needed for the next time if ( host.api.port != config_entry.data[CONF_PORT] or host.api.use_https != config_entry.data[CONF_USE_HTTPS] + or host.api.supported(None, "privacy_mode") + != config_entry.data.get(CONF_SUPPORTS_PRIVACY_MODE) ): - _LOGGER.warning( - "HTTP(s) port of Reolink %s, changed from %s to %s", - host.api.nvr_name, - config_entry.data[CONF_PORT], - host.api.port, - ) + if host.api.port != config_entry.data[CONF_PORT]: + _LOGGER.warning( + "HTTP(s) port of Reolink %s, changed from %s to %s", + host.api.nvr_name, + config_entry.data[CONF_PORT], + host.api.port, + ) data = { **config_entry.data, CONF_PORT: host.api.port, CONF_USE_HTTPS: host.api.use_https, + CONF_SUPPORTS_PRIVACY_MODE: host.api.supported(None, "privacy_mode"), } hass.config_entries.async_update_entry(config_entry, data=data) @@ -248,6 +254,14 @@ async def async_unload_entry( return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) +async def async_remove_entry( + hass: HomeAssistant, config_entry: ReolinkConfigEntry +) -> None: + """Handle removal of an entry.""" + store = get_store(hass, config_entry.entry_id) + await store.async_remove() + + async def async_remove_config_entry_device( hass: HomeAssistant, config_entry: ReolinkConfigEntry, device: dr.DeviceEntry ) -> bool: diff --git a/homeassistant/components/reolink/config_flow.py b/homeassistant/components/reolink/config_flow.py index e15a43e360b..7943cadef21 100644 --- a/homeassistant/components/reolink/config_flow.py +++ b/homeassistant/components/reolink/config_flow.py @@ -37,7 +37,7 @@ from homeassistant.helpers import config_validation as cv, selector from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo -from .const import CONF_USE_HTTPS, DOMAIN +from .const import CONF_SUPPORTS_PRIVACY_MODE, CONF_USE_HTTPS, DOMAIN from .exceptions import ( PasswordIncompatible, ReolinkException, @@ -287,6 +287,9 @@ class ReolinkFlowHandler(ConfigFlow, domain=DOMAIN): if not errors: user_input[CONF_PORT] = host.api.port user_input[CONF_USE_HTTPS] = host.api.use_https + user_input[CONF_SUPPORTS_PRIVACY_MODE] = host.api.supported( + None, "privacy_mode" + ) mac_address = format_mac(host.api.mac_address) await self.async_set_unique_id(mac_address, raise_on_progress=False) diff --git a/homeassistant/components/reolink/const.py b/homeassistant/components/reolink/const.py index 8aa01bfac41..7bd93337c46 100644 --- a/homeassistant/components/reolink/const.py +++ b/homeassistant/components/reolink/const.py @@ -3,3 +3,4 @@ DOMAIN = "reolink" CONF_USE_HTTPS = "use_https" +CONF_SUPPORTS_PRIVACY_MODE = "privacy_mode_supported" diff --git a/homeassistant/components/reolink/host.py b/homeassistant/components/reolink/host.py index e9b86f1e297..a23f53ff9cd 100644 --- a/homeassistant/components/reolink/host.py +++ b/homeassistant/components/reolink/host.py @@ -30,15 +30,17 @@ from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_call_later from homeassistant.helpers.network import NoURLAvailableError, get_url +from homeassistant.helpers.storage import Store from homeassistant.util.ssl import SSLCipherList -from .const import CONF_USE_HTTPS, DOMAIN +from .const import CONF_SUPPORTS_PRIVACY_MODE, CONF_USE_HTTPS, DOMAIN from .exceptions import ( PasswordIncompatible, ReolinkSetupException, ReolinkWebhookException, UserNotAdmin, ) +from .util import get_store DEFAULT_TIMEOUT = 30 FIRST_TCP_PUSH_TIMEOUT = 10 @@ -64,9 +66,12 @@ class ReolinkHost: hass: HomeAssistant, config: Mapping[str, Any], options: Mapping[str, Any], + config_entry_id: str | None = None, ) -> None: """Initialize Reolink Host. Could be either NVR, or Camera.""" self._hass: HomeAssistant = hass + self._config_entry_id = config_entry_id + self._config = config self._unique_id: str = "" def get_aiohttp_session() -> aiohttp.ClientSession: @@ -150,6 +155,14 @@ class ReolinkHost: f"a-z, A-Z, 0-9 or {ALLOWED_SPECIAL_CHARS}" ) + store: Store[str] | None = None + if self._config_entry_id is not None: + store = get_store(self._hass, self._config_entry_id) + if self._config.get(CONF_SUPPORTS_PRIVACY_MODE): + data = await store.async_load() + if data: + self._api.set_raw_host_data(data) + await self._api.get_host_data() if self._api.mac_address is None: @@ -161,6 +174,19 @@ class ReolinkHost: f"'{self._api.user_level}', only admin users can change camera settings" ) + self.privacy_mode = self._api.baichuan.privacy_mode() + + if ( + store + and self._api.supported(None, "privacy_mode") + and not self.privacy_mode + ): + _LOGGER.debug( + "Saving raw host data for next reload in case privacy mode is enabled" + ) + data = self._api.get_raw_host_data() + await store.async_save(data) + onvif_supported = self._api.supported(None, "ONVIF") self._onvif_push_supported = onvif_supported self._onvif_long_poll_supported = onvif_supported @@ -235,8 +261,6 @@ class ReolinkHost: self._hass, FIRST_TCP_PUSH_TIMEOUT, self._async_check_tcp_push ) - self.privacy_mode = self._api.baichuan.privacy_mode() - ch_list: list[int | None] = [None] if self._api.is_nvr: ch_list.extend(self._api.channels) diff --git a/homeassistant/components/reolink/util.py b/homeassistant/components/reolink/util.py index e43391f19fb..a5556b66a33 100644 --- a/homeassistant/components/reolink/util.py +++ b/homeassistant/components/reolink/util.py @@ -4,7 +4,7 @@ from __future__ import annotations from collections.abc import Awaitable, Callable, Coroutine from dataclasses import dataclass -from typing import Any +from typing import TYPE_CHECKING, Any from reolink_aio.exceptions import ( ApiError, @@ -26,10 +26,15 @@ from homeassistant.components.media_source import Unresolvable from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.storage import Store from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import DOMAIN -from .host import ReolinkHost + +if TYPE_CHECKING: + from .host import ReolinkHost + +STORAGE_VERSION = 1 type ReolinkConfigEntry = config_entries.ConfigEntry[ReolinkData] @@ -64,6 +69,11 @@ def get_host(hass: HomeAssistant, config_entry_id: str) -> ReolinkHost: return config_entry.runtime_data.host +def get_store(hass: HomeAssistant, config_entry_id: str) -> Store[str]: + """Return the reolink store.""" + return Store[str](hass, STORAGE_VERSION, f"{DOMAIN}.{config_entry_id}.json") + + def get_device_uid_and_ch( device: dr.DeviceEntry, host: ReolinkHost ) -> tuple[list[str], int | None, bool]: diff --git a/tests/components/reolink/conftest.py b/tests/components/reolink/conftest.py index f8012f91351..2862aa55b4d 100644 --- a/tests/components/reolink/conftest.py +++ b/tests/components/reolink/conftest.py @@ -9,7 +9,11 @@ from reolink_aio.baichuan import Baichuan from reolink_aio.exceptions import ReolinkError from homeassistant.components.reolink.config_flow import DEFAULT_PROTOCOL -from homeassistant.components.reolink.const import CONF_USE_HTTPS, DOMAIN +from homeassistant.components.reolink.const import ( + CONF_SUPPORTS_PRIVACY_MODE, + CONF_USE_HTTPS, + DOMAIN, +) from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, @@ -43,6 +47,7 @@ TEST_HOST_MODEL = "RLN8-410" TEST_ITEM_NUMBER = "P000" TEST_CAM_MODEL = "RLC-123" TEST_DUO_MODEL = "Reolink Duo PoE" +TEST_PRIVACY = True @pytest.fixture @@ -65,6 +70,7 @@ def reolink_connect_class() -> Generator[MagicMock]: host_mock = host_mock_class.return_value host_mock.get_host_data.return_value = None host_mock.get_states.return_value = None + host_mock.supported.return_value = True host_mock.check_new_firmware.return_value = False host_mock.unsubscribe.return_value = True host_mock.logout.return_value = True @@ -113,6 +119,9 @@ def reolink_connect_class() -> Generator[MagicMock]: host_mock.capabilities = {"Host": ["RTSP"], "0": ["motion_detection"]} host_mock.checked_api_versions = {"GetEvents": 1} host_mock.abilities = {"abilityChn": [{"aiTrack": {"permit": 0, "ver": 0}}]} + host_mock.get_raw_host_data.return_value = ( + "{'host':'TEST_RESPONSE','channel':'TEST_RESPONSE'}" + ) # enums host_mock.whiteled_mode.return_value = 1 @@ -128,6 +137,7 @@ def reolink_connect_class() -> Generator[MagicMock]: host_mock.baichuan.events_active = False host_mock.baichuan.privacy_mode.return_value = False host_mock.baichuan.subscribe_events.side_effect = ReolinkError("Test error") + yield host_mock_class @@ -158,6 +168,7 @@ def config_entry(hass: HomeAssistant) -> MockConfigEntry: CONF_PASSWORD: TEST_PASSWORD, CONF_PORT: TEST_PORT, CONF_USE_HTTPS: TEST_USE_HTTPS, + CONF_SUPPORTS_PRIVACY_MODE: TEST_PRIVACY, }, options={ CONF_PROTOCOL: DEFAULT_PROTOCOL, diff --git a/tests/components/reolink/test_config_flow.py b/tests/components/reolink/test_config_flow.py index 4d474588f38..4fe671f8cca 100644 --- a/tests/components/reolink/test_config_flow.py +++ b/tests/components/reolink/test_config_flow.py @@ -18,7 +18,11 @@ from reolink_aio.exceptions import ( from homeassistant import config_entries from homeassistant.components.reolink import DEVICE_UPDATE_INTERVAL from homeassistant.components.reolink.config_flow import DEFAULT_PROTOCOL -from homeassistant.components.reolink.const import CONF_USE_HTTPS, DOMAIN +from homeassistant.components.reolink.const import ( + CONF_SUPPORTS_PRIVACY_MODE, + CONF_USE_HTTPS, + DOMAIN, +) from homeassistant.components.reolink.exceptions import ReolinkWebhookException from homeassistant.components.reolink.host import DEFAULT_TIMEOUT from homeassistant.config_entries import ConfigEntryState @@ -43,6 +47,7 @@ from .conftest import ( TEST_PASSWORD, TEST_PASSWORD2, TEST_PORT, + TEST_PRIVACY, TEST_USE_HTTPS, TEST_USERNAME, TEST_USERNAME2, @@ -82,6 +87,7 @@ async def test_config_flow_manual_success( CONF_PASSWORD: TEST_PASSWORD, CONF_PORT: TEST_PORT, CONF_USE_HTTPS: TEST_USE_HTTPS, + CONF_SUPPORTS_PRIVACY_MODE: TEST_PRIVACY, } assert result["options"] == { CONF_PROTOCOL: DEFAULT_PROTOCOL, @@ -133,6 +139,7 @@ async def test_config_flow_privacy_success( CONF_PASSWORD: TEST_PASSWORD, CONF_PORT: TEST_PORT, CONF_USE_HTTPS: TEST_USE_HTTPS, + CONF_SUPPORTS_PRIVACY_MODE: TEST_PRIVACY, } assert result["options"] == { CONF_PROTOCOL: DEFAULT_PROTOCOL, @@ -294,6 +301,7 @@ async def test_config_flow_errors( CONF_PASSWORD: TEST_PASSWORD, CONF_PORT: TEST_PORT, CONF_USE_HTTPS: TEST_USE_HTTPS, + CONF_SUPPORTS_PRIVACY_MODE: TEST_PRIVACY, } assert result["options"] == { CONF_PROTOCOL: DEFAULT_PROTOCOL, @@ -465,6 +473,7 @@ async def test_dhcp_flow(hass: HomeAssistant, mock_setup_entry: MagicMock) -> No CONF_PASSWORD: TEST_PASSWORD, CONF_PORT: TEST_PORT, CONF_USE_HTTPS: TEST_USE_HTTPS, + CONF_SUPPORTS_PRIVACY_MODE: TEST_PRIVACY, } assert result["options"] == { CONF_PROTOCOL: DEFAULT_PROTOCOL, diff --git a/tests/components/reolink/test_init.py b/tests/components/reolink/test_init.py index 7895923dd12..25029375eb6 100644 --- a/tests/components/reolink/test_init.py +++ b/tests/components/reolink/test_init.py @@ -859,3 +859,15 @@ async def test_privacy_mode_change_callback( assert reolink_connect.get_states.call_count >= 1 assert hass.states.get(entity_id).state == STATE_ON + + +async def test_remove( + hass: HomeAssistant, + reolink_connect: MagicMock, + config_entry: MockConfigEntry, +) -> None: + """Test removing of the reolink integration.""" + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert await hass.config_entries.async_remove(config_entry.entry_id) From a955901d4025c33a49cf2b53e231d0ff5c85eaa5 Mon Sep 17 00:00:00 2001 From: Sid <27780930+autinerd@users.noreply.github.com> Date: Fri, 31 Jan 2025 12:52:17 +0100 Subject: [PATCH 0371/3148] Refactor eheimdigital platform async_setup_entry (#136745) --- .../components/eheimdigital/climate.py | 21 +++-- .../components/eheimdigital/coordinator.py | 9 ++- .../components/eheimdigital/light.py | 31 ++++---- .../eheimdigital/snapshots/test_climate.ambr | 76 +++++++++++++++++++ tests/components/eheimdigital/test_climate.py | 35 +++++++++ 5 files changed, 148 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/eheimdigital/climate.py b/homeassistant/components/eheimdigital/climate.py index 16771ba227d..9b1f825dece 100644 --- a/homeassistant/components/eheimdigital/climate.py +++ b/homeassistant/components/eheimdigital/climate.py @@ -2,6 +2,7 @@ from typing import Any +from eheimdigital.device import EheimDigitalDevice from eheimdigital.heater import EheimDigitalHeater from eheimdigital.types import EheimDigitalClientError, HeaterMode, HeaterUnit @@ -39,17 +40,23 @@ async def async_setup_entry( """Set up the callbacks for the coordinator so climate entities can be added as devices are found.""" coordinator = entry.runtime_data - async def async_setup_device_entities(device_address: str) -> None: - """Set up the light entities for a device.""" - device = coordinator.hub.devices[device_address] + def async_setup_device_entities( + device_address: str | dict[str, EheimDigitalDevice], + ) -> None: + """Set up the climate entities for one or multiple devices.""" + entities: list[EheimDigitalHeaterClimate] = [] + if isinstance(device_address, str): + device_address = {device_address: coordinator.hub.devices[device_address]} + for device in device_address.values(): + if isinstance(device, EheimDigitalHeater): + entities.append(EheimDigitalHeaterClimate(coordinator, device)) + coordinator.known_devices.add(device.mac_address) - if isinstance(device, EheimDigitalHeater): - async_add_entities([EheimDigitalHeaterClimate(coordinator, device)]) + async_add_entities(entities) coordinator.add_platform_callback(async_setup_device_entities) - for device_address in entry.runtime_data.hub.devices: - await async_setup_device_entities(device_address) + async_setup_device_entities(coordinator.hub.devices) class EheimDigitalHeaterClimate(EheimDigitalEntity[EheimDigitalHeater], ClimateEntity): diff --git a/homeassistant/components/eheimdigital/coordinator.py b/homeassistant/components/eheimdigital/coordinator.py index f122a1227c5..ee4f09426b7 100644 --- a/homeassistant/components/eheimdigital/coordinator.py +++ b/homeassistant/components/eheimdigital/coordinator.py @@ -2,8 +2,7 @@ from __future__ import annotations -from collections.abc import Callable, Coroutine -from typing import Any +from collections.abc import Callable from aiohttp import ClientError from eheimdigital.device import EheimDigitalDevice @@ -19,7 +18,9 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .const import DOMAIN, LOGGER -type AsyncSetupDeviceEntitiesCallback = Callable[[str], Coroutine[Any, Any, None]] +type AsyncSetupDeviceEntitiesCallback = Callable[ + [str | dict[str, EheimDigitalDevice]], None +] class EheimDigitalUpdateCoordinator( @@ -61,7 +62,7 @@ class EheimDigitalUpdateCoordinator( if device_address not in self.known_devices: for platform_callback in self.platform_callbacks: - await platform_callback(device_address) + platform_callback(device_address) async def _async_receive_callback(self) -> None: self.async_set_updated_data(self.hub.devices) diff --git a/homeassistant/components/eheimdigital/light.py b/homeassistant/components/eheimdigital/light.py index a119e0bda8d..5ae0a6e866a 100644 --- a/homeassistant/components/eheimdigital/light.py +++ b/homeassistant/components/eheimdigital/light.py @@ -3,6 +3,7 @@ from typing import Any from eheimdigital.classic_led_ctrl import EheimDigitalClassicLEDControl +from eheimdigital.device import EheimDigitalDevice from eheimdigital.types import EheimDigitalClientError, LightMode from homeassistant.components.light import ( @@ -37,24 +38,28 @@ async def async_setup_entry( """Set up the callbacks for the coordinator so lights can be added as devices are found.""" coordinator = entry.runtime_data - async def async_setup_device_entities(device_address: str) -> None: - """Set up the light entities for a device.""" - device = coordinator.hub.devices[device_address] + def async_setup_device_entities( + device_address: str | dict[str, EheimDigitalDevice], + ) -> None: + """Set up the light entities for one or multiple devices.""" entities: list[EheimDigitalClassicLEDControlLight] = [] + if isinstance(device_address, str): + device_address = {device_address: coordinator.hub.devices[device_address]} + for device in device_address.values(): + if isinstance(device, EheimDigitalClassicLEDControl): + for channel in range(2): + if len(device.tankconfig[channel]) > 0: + entities.append( + EheimDigitalClassicLEDControlLight( + coordinator, device, channel + ) + ) + coordinator.known_devices.add(device.mac_address) - if isinstance(device, EheimDigitalClassicLEDControl): - for channel in range(2): - if len(device.tankconfig[channel]) > 0: - entities.append( - EheimDigitalClassicLEDControlLight(coordinator, device, channel) - ) - coordinator.known_devices.add(device.mac_address) async_add_entities(entities) coordinator.add_platform_callback(async_setup_device_entities) - - for device_address in entry.runtime_data.hub.devices: - await async_setup_device_entities(device_address) + async_setup_device_entities(coordinator.hub.devices) class EheimDigitalClassicLEDControlLight( diff --git a/tests/components/eheimdigital/snapshots/test_climate.ambr b/tests/components/eheimdigital/snapshots/test_climate.ambr index 02d60677b24..d81c59e5af1 100644 --- a/tests/components/eheimdigital/snapshots/test_climate.ambr +++ b/tests/components/eheimdigital/snapshots/test_climate.ambr @@ -1,4 +1,80 @@ # serializer version: 1 +# name: test_dynamic_new_devices[climate.mock_heater_none-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 32, + 'min_temp': 18, + 'preset_modes': list([ + 'none', + 'bio_mode', + 'smart_mode', + ]), + 'target_temp_step': 0.5, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.mock_heater_none', + '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': None, + 'platform': 'eheimdigital', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'heater', + 'unique_id': '00:00:00:00:00:02', + 'unit_of_measurement': None, + }) +# --- +# name: test_dynamic_new_devices[climate.mock_heater_none-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 24.2, + 'friendly_name': 'Mock Heater None', + 'hvac_action': , + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 32, + 'min_temp': 18, + 'preset_mode': 'none', + 'preset_modes': list([ + 'none', + 'bio_mode', + 'smart_mode', + ]), + 'supported_features': , + 'target_temp_step': 0.5, + 'temperature': 25.5, + }), + 'context': , + 'entity_id': 'climate.mock_heater_none', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'auto', + }) +# --- # name: test_setup_heater[climate.mock_heater_none-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/eheimdigital/test_climate.py b/tests/components/eheimdigital/test_climate.py index 4e770882263..f64b7d7e740 100644 --- a/tests/components/eheimdigital/test_climate.py +++ b/tests/components/eheimdigital/test_climate.py @@ -56,6 +56,41 @@ async def test_setup_heater( await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) +async def test_dynamic_new_devices( + hass: HomeAssistant, + eheimdigital_hub_mock: MagicMock, + heater_mock: MagicMock, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, +) -> None: + """Test light platform setup with at first no devices and dynamically adding a device.""" + mock_config_entry.add_to_hass(hass) + + eheimdigital_hub_mock.return_value.devices = {} + + with patch("homeassistant.components.eheimdigital.PLATFORMS", [Platform.CLIMATE]): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + assert ( + len( + entity_registry.entities.get_entries_for_config_entry_id( + mock_config_entry.entry_id + ) + ) + == 0 + ) + + eheimdigital_hub_mock.return_value.devices = {"00:00:00:00:00:02": heater_mock} + + await eheimdigital_hub_mock.call_args.kwargs["device_found_callback"]( + "00:00:00:00:00:02", EheimDeviceType.VERSION_EHEIM_EXT_HEATER + ) + await hass.async_block_till_done() + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + @pytest.mark.parametrize( ("preset_mode", "heater_mode"), [ From 833b17a8ee0dad73b8916d90fa86b885983c3d66 Mon Sep 17 00:00:00 2001 From: Austin Mroczek Date: Fri, 31 Jan 2025 01:36:06 -0800 Subject: [PATCH 0372/3148] Bump total-connect-client to 2025.1.4 (#136793) --- .../totalconnect/alarm_control_panel.py | 4 +- .../components/totalconnect/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/totalconnect/common.py | 39 +++++++++++-------- .../totalconnect/test_config_flow.py | 20 +++++++--- 6 files changed, 43 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/totalconnect/alarm_control_panel.py b/homeassistant/components/totalconnect/alarm_control_panel.py index 48ba78acc92..021d1c7b886 100644 --- a/homeassistant/components/totalconnect/alarm_control_panel.py +++ b/homeassistant/components/totalconnect/alarm_control_panel.py @@ -73,7 +73,7 @@ class TotalConnectAlarm(TotalConnectLocationEntity, AlarmControlPanelEntity): ) -> None: """Initialize the TotalConnect status.""" super().__init__(coordinator, location) - self._partition_id = partition_id + self._partition_id = int(partition_id) self._partition = self._location.partitions[partition_id] """ @@ -81,7 +81,7 @@ class TotalConnectAlarm(TotalConnectLocationEntity, AlarmControlPanelEntity): for most users with new support for partitions. Add _# for partition 2 and beyond. """ - if partition_id == 1: + if int(partition_id) == 1: self._attr_name = None self._attr_unique_id = str(location.location_id) else: diff --git a/homeassistant/components/totalconnect/manifest.json b/homeassistant/components/totalconnect/manifest.json index 33306a7adba..6aff1ea392b 100644 --- a/homeassistant/components/totalconnect/manifest.json +++ b/homeassistant/components/totalconnect/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/totalconnect", "iot_class": "cloud_polling", "loggers": ["total_connect_client"], - "requirements": ["total-connect-client==2024.12"] + "requirements": ["total-connect-client==2025.1.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index 2bf3b5f1943..dc1bfd1a839 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2902,7 +2902,7 @@ tololib==1.1.0 toonapi==0.3.0 # homeassistant.components.totalconnect -total-connect-client==2024.12 +total-connect-client==2025.1.4 # homeassistant.components.tplink_lte tp-connected==0.0.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6c5f81e6a2c..98706c45443 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2330,7 +2330,7 @@ tololib==1.1.0 toonapi==0.3.0 # homeassistant.components.totalconnect -total-connect-client==2024.12 +total-connect-client==2025.1.4 # homeassistant.components.tplink_omada tplink-omada-client==1.4.3 diff --git a/tests/components/totalconnect/common.py b/tests/components/totalconnect/common.py index 828cad71e07..34d451ec0b8 100644 --- a/tests/components/totalconnect/common.py +++ b/tests/components/totalconnect/common.py @@ -49,20 +49,15 @@ USER = { "UserFeatureList": "Master=0,User Administration=0,Configuration Administration=0", } -RESPONSE_AUTHENTICATE = { +RESPONSE_SESSION_DETAILS = { "ResultCode": ResultCode.SUCCESS.value, - "SessionID": 1, + "ResultData": "Success", + "SessionID": "12345", "Locations": LOCATIONS, "ModuleFlags": MODULE_FLAGS, "UserInfo": USER, } -RESPONSE_AUTHENTICATE_FAILED = { - "ResultCode": ResultCode.BAD_USER_OR_PASSWORD.value, - "ResultData": "test bad authentication", -} - - PARTITION_DISARMED = { "PartitionID": "1", "ArmingState": ArmingState.DISARMED, @@ -359,13 +354,13 @@ OPTIONS_DATA = {AUTO_BYPASS: False, CODE_REQUIRED: False} OPTIONS_DATA_CODE_REQUIRED = {AUTO_BYPASS: False, CODE_REQUIRED: True} PARTITION_DETAILS_1 = { - "PartitionID": 1, + "PartitionID": "1", "ArmingState": ArmingState.DISARMED.value, "PartitionName": "Test1", } PARTITION_DETAILS_2 = { - "PartitionID": 2, + "PartitionID": "2", "ArmingState": ArmingState.DISARMED.value, "PartitionName": "Test2", } @@ -402,6 +397,12 @@ RESPONSE_GET_ZONE_DETAILS_SUCCESS = { TOTALCONNECT_REQUEST = ( "homeassistant.components.totalconnect.TotalConnectClient.request" ) +TOTALCONNECT_GET_CONFIG = ( + "homeassistant.components.totalconnect.TotalConnectClient._get_configuration" +) +TOTALCONNECT_REQUEST_TOKEN = ( + "homeassistant.components.totalconnect.TotalConnectClient._request_token" +) async def setup_platform( @@ -420,7 +421,7 @@ async def setup_platform( mock_entry.add_to_hass(hass) responses = [ - RESPONSE_AUTHENTICATE, + RESPONSE_SESSION_DETAILS, RESPONSE_PARTITION_DETAILS, RESPONSE_GET_ZONE_DETAILS_SUCCESS, RESPONSE_DISARMED, @@ -433,6 +434,8 @@ async def setup_platform( TOTALCONNECT_REQUEST, side_effect=responses, ) as mock_request, + patch(TOTALCONNECT_GET_CONFIG, side_effect=None), + patch(TOTALCONNECT_REQUEST_TOKEN, side_effect=None), ): assert await async_setup_component(hass, DOMAIN, {}) assert mock_request.call_count == 5 @@ -448,17 +451,21 @@ async def init_integration(hass: HomeAssistant) -> MockConfigEntry: mock_entry.add_to_hass(hass) responses = [ - RESPONSE_AUTHENTICATE, + RESPONSE_SESSION_DETAILS, RESPONSE_PARTITION_DETAILS, RESPONSE_GET_ZONE_DETAILS_SUCCESS, RESPONSE_DISARMED, RESPONSE_DISARMED, ] - with patch( - TOTALCONNECT_REQUEST, - side_effect=responses, - ) as mock_request: + with ( + patch( + TOTALCONNECT_REQUEST, + side_effect=responses, + ) as mock_request, + patch(TOTALCONNECT_GET_CONFIG, side_effect=None), + patch(TOTALCONNECT_REQUEST_TOKEN, side_effect=None), + ): await hass.config_entries.async_setup(mock_entry.entry_id) assert mock_request.call_count == 5 await hass.async_block_till_done() diff --git a/tests/components/totalconnect/test_config_flow.py b/tests/components/totalconnect/test_config_flow.py index 86419bff817..f5020394bce 100644 --- a/tests/components/totalconnect/test_config_flow.py +++ b/tests/components/totalconnect/test_config_flow.py @@ -18,13 +18,15 @@ from homeassistant.data_entry_flow import FlowResultType from .common import ( CONFIG_DATA, CONFIG_DATA_NO_USERCODES, - RESPONSE_AUTHENTICATE, RESPONSE_DISARMED, RESPONSE_GET_ZONE_DETAILS_SUCCESS, RESPONSE_PARTITION_DETAILS, + RESPONSE_SESSION_DETAILS, RESPONSE_SUCCESS, RESPONSE_USER_CODE_INVALID, + TOTALCONNECT_GET_CONFIG, TOTALCONNECT_REQUEST, + TOTALCONNECT_REQUEST_TOKEN, USERNAME, ) @@ -48,7 +50,7 @@ async def test_user_show_locations(hass: HomeAssistant) -> None: """Test user locations form.""" # user/pass provided, so check if valid then ask for usercodes on locations form responses = [ - RESPONSE_AUTHENTICATE, + RESPONSE_SESSION_DETAILS, RESPONSE_PARTITION_DETAILS, RESPONSE_GET_ZONE_DETAILS_SUCCESS, RESPONSE_DISARMED, @@ -61,6 +63,8 @@ async def test_user_show_locations(hass: HomeAssistant) -> None: TOTALCONNECT_REQUEST, side_effect=responses, ) as mock_request, + patch(TOTALCONNECT_GET_CONFIG, side_effect=None), + patch(TOTALCONNECT_REQUEST_TOKEN, side_effect=None), patch( "homeassistant.components.totalconnect.async_setup_entry", return_value=True ), @@ -180,7 +184,7 @@ async def test_reauth(hass: HomeAssistant) -> None: async def test_no_locations(hass: HomeAssistant) -> None: """Test with no user locations.""" responses = [ - RESPONSE_AUTHENTICATE, + RESPONSE_SESSION_DETAILS, RESPONSE_PARTITION_DETAILS, RESPONSE_GET_ZONE_DETAILS_SUCCESS, RESPONSE_DISARMED, @@ -191,6 +195,8 @@ async def test_no_locations(hass: HomeAssistant) -> None: TOTALCONNECT_REQUEST, side_effect=responses, ) as mock_request, + patch(TOTALCONNECT_GET_CONFIG, side_effect=None), + patch(TOTALCONNECT_REQUEST_TOKEN, side_effect=None), patch( "homeassistant.components.totalconnect.async_setup_entry", return_value=True ), @@ -221,7 +227,7 @@ async def test_options_flow(hass: HomeAssistant) -> None: config_entry.add_to_hass(hass) responses = [ - RESPONSE_AUTHENTICATE, + RESPONSE_SESSION_DETAILS, RESPONSE_PARTITION_DETAILS, RESPONSE_GET_ZONE_DETAILS_SUCCESS, RESPONSE_DISARMED, @@ -229,7 +235,11 @@ async def test_options_flow(hass: HomeAssistant) -> None: RESPONSE_DISARMED, ] - with patch(TOTALCONNECT_REQUEST, side_effect=responses): + with ( + patch(TOTALCONNECT_REQUEST, side_effect=responses), + patch(TOTALCONNECT_GET_CONFIG, side_effect=None), + patch(TOTALCONNECT_REQUEST_TOKEN, side_effect=None), + ): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() From 04a7c6f15e1aea1eda29bf651fe3a3452a9d460f Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Fri, 31 Jan 2025 10:38:30 +0100 Subject: [PATCH 0373/3148] Fixes to the user-facing strings of energenie_power_sockets (#136844) --- homeassistant/components/energenie_power_sockets/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/energenie_power_sockets/strings.json b/homeassistant/components/energenie_power_sockets/strings.json index e193b06b25f..4e4e49c68fb 100644 --- a/homeassistant/components/energenie_power_sockets/strings.json +++ b/homeassistant/components/energenie_power_sockets/strings.json @@ -3,7 +3,7 @@ "config": { "step": { "user": { - "title": "Searching for Energenie-Power-Sockets Devices.", + "title": "Searching for Energenie Power Sockets devices", "description": "Choose a discovered device.", "data": { "device": "[%key:common::config_flow::data::device%]" @@ -13,7 +13,7 @@ "abort": { "usb_error": "Couldn't access USB devices!", "no_device": "Unable to discover any (new) supported device.", - "device_not_found": "No device was found for the given id.", + "device_not_found": "No device was found for the given ID.", "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } }, From 5cec045cac5a0c82bc24093cef940dc08584fbce Mon Sep 17 00:00:00 2001 From: Jan Stienstra <65826735+RunC0deRun@users.noreply.github.com> Date: Fri, 31 Jan 2025 12:29:00 +0100 Subject: [PATCH 0374/3148] Bump jellyfin-apiclient-python to 1.10.0 (#136872) --- homeassistant/components/jellyfin/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/jellyfin/manifest.json b/homeassistant/components/jellyfin/manifest.json index 19358cff17c..810b9ea45a9 100644 --- a/homeassistant/components/jellyfin/manifest.json +++ b/homeassistant/components/jellyfin/manifest.json @@ -7,6 +7,6 @@ "integration_type": "service", "iot_class": "local_polling", "loggers": ["jellyfin_apiclient_python"], - "requirements": ["jellyfin-apiclient-python==1.9.2"], + "requirements": ["jellyfin-apiclient-python==1.10.0"], "single_config_entry": true } diff --git a/requirements_all.txt b/requirements_all.txt index dc1bfd1a839..84a2cd7ad2e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1247,7 +1247,7 @@ israel-rail-api==0.1.2 jaraco.abode==6.2.1 # homeassistant.components.jellyfin -jellyfin-apiclient-python==1.9.2 +jellyfin-apiclient-python==1.10.0 # homeassistant.components.command_line # homeassistant.components.rest diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 98706c45443..326670c9f63 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1058,7 +1058,7 @@ israel-rail-api==0.1.2 jaraco.abode==6.2.1 # homeassistant.components.jellyfin -jellyfin-apiclient-python==1.9.2 +jellyfin-apiclient-python==1.10.0 # homeassistant.components.command_line # homeassistant.components.rest From a74328e60061051f14a18c949c9901e824ef9d5e Mon Sep 17 00:00:00 2001 From: Avi Miller Date: Fri, 31 Jan 2025 20:55:42 +1100 Subject: [PATCH 0375/3148] Suppress color_temp warning if color_temp_kelvin is provided (#136884) --- homeassistant/components/lifx/util.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/lifx/util.py b/homeassistant/components/lifx/util.py index 3d37f1c3bc5..8286622e6f3 100644 --- a/homeassistant/components/lifx/util.py +++ b/homeassistant/components/lifx/util.py @@ -113,7 +113,7 @@ def find_hsbk(hass: HomeAssistant, **kwargs: Any) -> list[float | int | None] | saturation = int(saturation / 100 * 65535) kelvin = 3500 - if _ATTR_COLOR_TEMP in kwargs: + if ATTR_COLOR_TEMP_KELVIN not in kwargs and _ATTR_COLOR_TEMP in kwargs: # added in 2025.1, can be removed in 2026.1 _LOGGER.warning( "The 'color_temp' parameter is deprecated. Please use 'color_temp_kelvin' for" From 9cd48dd452a5cafdc6d427803865a773a164eaf3 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Fri, 31 Jan 2025 10:45:01 -0800 Subject: [PATCH 0376/3148] Persist roborock maps to disk only on shutdown (#136889) * Persist roborock maps to disk only on shutdown * Rename on_unload to on_stop * Spawn 1 executor thread and block writes to disk * Update tests/components/roborock/test_image.py Co-authored-by: Joost Lekkerkerker * Use config entry setup instead of component setup --------- Co-authored-by: Joost Lekkerkerker --- homeassistant/components/roborock/__init__.py | 24 ++++++++++------ .../components/roborock/coordinator.py | 18 ++++++++---- homeassistant/components/roborock/image.py | 10 ++----- .../components/roborock/roborock_storage.py | 20 +++++++++++-- tests/components/roborock/conftest.py | 10 +++++-- tests/components/roborock/test_image.py | 28 +++++++++++-------- tests/components/roborock/test_init.py | 8 ++++++ 7 files changed, 79 insertions(+), 39 deletions(-) diff --git a/homeassistant/components/roborock/__init__.py b/homeassistant/components/roborock/__init__.py index 1b34dc891d1..b383c1acfd7 100644 --- a/homeassistant/components/roborock/__init__.py +++ b/homeassistant/components/roborock/__init__.py @@ -22,7 +22,7 @@ from roborock.version_a01_apis import RoborockMqttClientA01 from roborock.web_api import RoborockApiClient from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_USERNAME +from homeassistant.const import CONF_USERNAME, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady @@ -118,13 +118,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: RoborockConfigEntry) -> ) valid_coordinators = RoborockCoordinators(v1_coords, a01_coords) - async def on_unload() -> None: - release_tasks = set() - for coordinator in valid_coordinators.values(): - release_tasks.add(coordinator.release()) - await asyncio.gather(*release_tasks) + async def on_stop(_: Any) -> None: + _LOGGER.debug("Shutting down roborock") + await asyncio.gather( + *( + coordinator.async_shutdown() + for coordinator in valid_coordinators.values() + ) + ) - entry.async_on_unload(on_unload) + entry.async_on_unload( + hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_STOP, + on_stop, + ) + ) entry.runtime_data = valid_coordinators await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -209,7 +217,7 @@ async def setup_device_v1( try: await coordinator.async_config_entry_first_refresh() except ConfigEntryNotReady as ex: - await coordinator.release() + await coordinator.async_shutdown() if isinstance(coordinator.api, RoborockMqttClientV1): _LOGGER.warning( "Not setting up %s because the we failed to get data for the first time using the online client. " diff --git a/homeassistant/components/roborock/coordinator.py b/homeassistant/components/roborock/coordinator.py index 36333f1c55e..8860a5c1f43 100644 --- a/homeassistant/components/roborock/coordinator.py +++ b/homeassistant/components/roborock/coordinator.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio from datetime import timedelta import logging @@ -116,10 +117,14 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): # Right now this should never be called if the cloud api is the primary api, # but in the future if it is, a new else should be added. - async def release(self) -> None: - """Disconnect from API.""" - await self.api.async_release() - await self.cloud_api.async_release() + async def async_shutdown(self) -> None: + """Shutdown the coordinator.""" + await super().async_shutdown() + await asyncio.gather( + self.map_storage.flush(), + self.api.async_release(), + self.cloud_api.async_release(), + ) async def _update_device_prop(self) -> None: """Update device properties.""" @@ -226,8 +231,9 @@ class RoborockDataUpdateCoordinatorA01( ) -> dict[RoborockDyadDataProtocol | RoborockZeoProtocol, StateType]: return await self.api.update_values(self.request_protocols) - async def release(self) -> None: - """Disconnect from API.""" + async def async_shutdown(self) -> None: + """Shutdown the coordinator on config entry unload.""" + await super().async_shutdown() await self.api.async_release() @cached_property diff --git a/homeassistant/components/roborock/image.py b/homeassistant/components/roborock/image.py index b0de4f9caa5..b4776c27164 100644 --- a/homeassistant/components/roborock/image.py +++ b/homeassistant/components/roborock/image.py @@ -157,13 +157,9 @@ class RoborockMap(RoborockCoordinatedEntityV1, ImageEntity): ) if self.cached_map != content: self.cached_map = content - self.config_entry.async_create_task( - self.hass, - self.coordinator.map_storage.async_save_map( - self.map_flag, - content, - ), - f"{self.unique_id} map", + await self.coordinator.map_storage.async_save_map( + self.map_flag, + content, ) return self.cached_map diff --git a/homeassistant/components/roborock/roborock_storage.py b/homeassistant/components/roborock/roborock_storage.py index 62e15e889be..8a469b0a38e 100644 --- a/homeassistant/components/roborock/roborock_storage.py +++ b/homeassistant/components/roborock/roborock_storage.py @@ -31,6 +31,7 @@ class RoborockMapStorage: self._path_prefix = ( _storage_path_prefix(hass, entry_id) / MAPS_PATH / device_id_slug ) + self._write_queue: dict[int, bytes] = {} async def async_load_map(self, map_flag: int) -> bytes | None: """Load maps from disk.""" @@ -48,9 +49,22 @@ class RoborockMapStorage: return None async def async_save_map(self, map_flag: int, content: bytes) -> None: - """Write map if it should be updated.""" - filename = self._path_prefix / f"{map_flag}{MAP_FILENAME_SUFFIX}" - await self._hass.async_add_executor_job(self._save_map, filename, content) + """Save the map to a pending write queue.""" + self._write_queue[map_flag] = content + + async def flush(self) -> None: + """Flush all maps to disk.""" + _LOGGER.debug("Flushing %s maps to disk", len(self._write_queue)) + + queue = self._write_queue.copy() + + def _flush_all() -> None: + for map_flag, content in queue.items(): + filename = self._path_prefix / f"{map_flag}{MAP_FILENAME_SUFFIX}" + self._save_map(filename, content) + + await self._hass.async_add_executor_job(_flush_all) + self._write_queue.clear() def _save_map(self, filename: Path, content: bytes) -> None: """Write the map to disk.""" diff --git a/tests/components/roborock/conftest.py b/tests/components/roborock/conftest.py index e5fc5cb7eb6..43e5148c9a8 100644 --- a/tests/components/roborock/conftest.py +++ b/tests/components/roborock/conftest.py @@ -19,9 +19,9 @@ from homeassistant.components.roborock.const import ( CONF_USER_DATA, DOMAIN, ) +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_USERNAME, Platform from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component from .mock_data import ( BASE_URL, @@ -207,13 +207,13 @@ async def setup_entry( ) -> Generator[MockConfigEntry]: """Set up the Roborock platform.""" with patch("homeassistant.components.roborock.PLATFORMS", platforms): - assert await async_setup_component(hass, DOMAIN, {}) + await hass.config_entries.async_setup(mock_roborock_entry.entry_id) await hass.async_block_till_done() yield mock_roborock_entry @pytest.fixture -def cleanup_map_storage( +async def cleanup_map_storage( hass: HomeAssistant, mock_roborock_entry: MockConfigEntry ) -> Generator[pathlib.Path]: """Test cleanup, remove any map storage persisted during the test.""" @@ -225,4 +225,8 @@ def cleanup_map_storage( pathlib.Path(hass.config.path(tmp_path)) / mock_roborock_entry.entry_id ) yield storage_path + # We need to first unload the config entry because unloading it will + # persist any unsaved maps to storage. + if mock_roborock_entry.state is ConfigEntryState.LOADED: + await hass.config_entries.async_unload(mock_roborock_entry.entry_id) shutil.rmtree(str(storage_path), ignore_errors=True) diff --git a/tests/components/roborock/test_image.py b/tests/components/roborock/test_image.py index 90886f25929..fd6c8b2796a 100644 --- a/tests/components/roborock/test_image.py +++ b/tests/components/roborock/test_image.py @@ -12,6 +12,7 @@ from roborock import RoborockException from vacuum_map_parser_base.map_data import ImageConfig, ImageData from homeassistant.components.roborock import DOMAIN +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -120,7 +121,7 @@ async def test_load_stored_image( MAP_DATA.image.data.save(img_byte_arr, format="PNG") img_bytes = img_byte_arr.getvalue() - # Load the image on demand, which should ensure it is cached on disk + # Load the image on demand, which should queue it to be cached on disk client = await hass_client() resp = await client.get("/api/image_proxy/image.roborock_s7_maxv_upstairs") assert resp.status == HTTPStatus.OK @@ -151,22 +152,25 @@ async def test_fail_to_save_image( caplog: pytest.LogCaptureFixture, ) -> None: """Test that we gracefully handle a oserror on saving an image.""" - # Reload the config entry so that the map is saved in storage and entities exist. + await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + # Ensure that map is still working properly. + assert hass.states.get("image.roborock_s7_maxv_upstairs") is not None + client = await hass_client() + resp = await client.get("/api/image_proxy/image.roborock_s7_maxv_upstairs") + # Test that we can get the image and it correctly serialized and unserialized. + assert resp.status == HTTPStatus.OK + with patch( "homeassistant.components.roborock.roborock_storage.Path.write_bytes", side_effect=OSError, ): - await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() + await hass.config_entries.async_unload(mock_roborock_entry.entry_id) + assert "Unable to write map file" in caplog.text - # Ensure that map is still working properly. - assert hass.states.get("image.roborock_s7_maxv_upstairs") is not None - client = await hass_client() - resp = await client.get("/api/image_proxy/image.roborock_s7_maxv_upstairs") - # Test that we can get the image and it correctly serialized and unserialized. - assert resp.status == HTTPStatus.OK - - assert "Unable to write map file" in caplog.text + # Config entry is unloaded successfully + assert mock_roborock_entry.state is ConfigEntryState.NOT_LOADED async def test_fail_to_load_image( diff --git a/tests/components/roborock/test_init.py b/tests/components/roborock/test_init.py index efd1c3f66f4..904a3af89d6 100644 --- a/tests/components/roborock/test_init.py +++ b/tests/components/roborock/test_init.py @@ -183,6 +183,10 @@ async def test_remove_from_hass( resp = await client.get("/api/image_proxy/image.roborock_s7_maxv_upstairs") assert resp.status == HTTPStatus.OK + assert not cleanup_map_storage.exists() + + # Flush to disk + await hass.config_entries.async_unload(setup_entry.entry_id) assert cleanup_map_storage.exists() paths = list(cleanup_map_storage.walk()) assert len(paths) == 3 # One map image and two directories @@ -209,6 +213,10 @@ async def test_oserror_remove_image( resp = await client.get("/api/image_proxy/image.roborock_s7_maxv_upstairs") assert resp.status == HTTPStatus.OK + # Image content is saved when unloading + assert not cleanup_map_storage.exists() + await hass.config_entries.async_unload(setup_entry.entry_id) + assert cleanup_map_storage.exists() paths = list(cleanup_map_storage.walk()) assert len(paths) == 3 # One map image and two directories From c9fd27555c477ff1028c8dc5e6e04d815f4bfb7e Mon Sep 17 00:00:00 2001 From: tronikos Date: Thu, 30 Jan 2025 22:59:03 -0800 Subject: [PATCH 0377/3148] Include the redirect URL in the Google Drive instructions (#136906) * Include the redirect URL in the Google Drive instructions * Apply suggestions from code review Co-authored-by: Martin Hjelmare --------- Co-authored-by: Martin Hjelmare --- .../google_drive/application_credentials.py | 2 ++ .../components/google_drive/strings.json | 2 +- .../helpers/config_entry_oauth2_flow.py | 26 ++++++++++++------- 3 files changed, 19 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/google_drive/application_credentials.py b/homeassistant/components/google_drive/application_credentials.py index c2f59b298cb..1c4421623d4 100644 --- a/homeassistant/components/google_drive/application_credentials.py +++ b/homeassistant/components/google_drive/application_credentials.py @@ -2,6 +2,7 @@ from homeassistant.components.application_credentials import AuthorizationServer from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_entry_oauth2_flow async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer: @@ -18,4 +19,5 @@ async def async_get_description_placeholders(hass: HomeAssistant) -> dict[str, s "oauth_consent_url": "https://console.cloud.google.com/apis/credentials/consent", "more_info_url": "https://www.home-assistant.io/integrations/google_drive/", "oauth_creds_url": "https://console.cloud.google.com/apis/credentials", + "redirect_url": config_entry_oauth2_flow.async_get_redirect_uri(hass), } diff --git a/homeassistant/components/google_drive/strings.json b/homeassistant/components/google_drive/strings.json index 3441bec4294..e6658fb08e9 100644 --- a/homeassistant/components/google_drive/strings.json +++ b/homeassistant/components/google_drive/strings.json @@ -35,6 +35,6 @@ } }, "application_credentials": { - "description": "Follow the [instructions]({more_info_url}) for [OAuth consent screen]({oauth_consent_url}) to give Home Assistant access to your Google Drive. You also need to create Application Credentials linked to your account:\n1. Go to [Credentials]({oauth_creds_url}) and select **Create Credentials**.\n1. From the drop-down list select **OAuth client ID**.\n1. Select **Web application** for the Application Type." + "description": "Follow the [instructions]({more_info_url}) to configure the Cloud Console:\n\n1. Go to the [OAuth consent screen]({oauth_consent_url}) and configure\n1. Go to [Credentials]({oauth_creds_url}) and select **Create Credentials**.\n1. From the drop-down list select **OAuth client ID**.\n1. Select **Web application** for the Application Type.\n1. Add `{redirect_url}` under *Authorized redirect URI*." } } diff --git a/homeassistant/helpers/config_entry_oauth2_flow.py b/homeassistant/helpers/config_entry_oauth2_flow.py index c2a61335769..24a9de5b562 100644 --- a/homeassistant/helpers/config_entry_oauth2_flow.py +++ b/homeassistant/helpers/config_entry_oauth2_flow.py @@ -55,6 +55,21 @@ OAUTH_AUTHORIZE_URL_TIMEOUT_SEC = 30 OAUTH_TOKEN_TIMEOUT_SEC = 30 +@callback +def async_get_redirect_uri(hass: HomeAssistant) -> str: + """Return the redirect uri.""" + if "my" in hass.config.components: + return MY_AUTH_CALLBACK_PATH + + if (req := http.current_request.get()) is None: + raise RuntimeError("No current request in context") + + if (ha_host := req.headers.get(HEADER_FRONTEND_BASE)) is None: + raise RuntimeError("No header in request") + + return f"{ha_host}{AUTH_CALLBACK_PATH}" + + class AbstractOAuth2Implementation(ABC): """Base class to abstract OAuth2 authentication.""" @@ -144,16 +159,7 @@ class LocalOAuth2Implementation(AbstractOAuth2Implementation): @property def redirect_uri(self) -> str: """Return the redirect uri.""" - if "my" in self.hass.config.components: - return MY_AUTH_CALLBACK_PATH - - if (req := http.current_request.get()) is None: - raise RuntimeError("No current request in context") - - if (ha_host := req.headers.get(HEADER_FRONTEND_BASE)) is None: - raise RuntimeError("No header in request") - - return f"{ha_host}{AUTH_CALLBACK_PATH}" + return async_get_redirect_uri(self.hass) @property def extra_authorize_data(self) -> dict: From a391f0a7cc9ee7d745bac5f35aae42cf414ea3c7 Mon Sep 17 00:00:00 2001 From: tronikos Date: Thu, 30 Jan 2025 23:33:58 -0800 Subject: [PATCH 0378/3148] Bump opower to 0.8.9 (#136911) * Bump opower to 0.8.9 * mypy --- homeassistant/components/opower/coordinator.py | 14 ++++++-------- homeassistant/components/opower/manifest.json | 2 +- homeassistant/components/opower/sensor.py | 8 ++++---- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 13 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/opower/coordinator.py b/homeassistant/components/opower/coordinator.py index f6f3524d630..6957ae4984c 100644 --- a/homeassistant/components/opower/coordinator.py +++ b/homeassistant/components/opower/coordinator.py @@ -5,18 +5,16 @@ import logging from types import MappingProxyType from typing import Any, cast -import aiohttp from opower import ( Account, AggregateType, - CannotConnect, CostRead, Forecast, - InvalidAuth, MeterType, Opower, ReadResolution, ) +from opower.exceptions import ApiException, CannotConnect, InvalidAuth from homeassistant.components.recorder import get_instance from homeassistant.components.recorder.models import StatisticData, StatisticMetaData @@ -89,7 +87,7 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, Forecast]]): raise UpdateFailed(f"Error during login: {err}") from err try: forecasts: list[Forecast] = await self.api.async_get_forecast() - except aiohttp.ClientError as err: + except ApiException as err: _LOGGER.error("Error getting forecasts: %s", err) raise _LOGGER.debug("Updating sensor data with: %s", forecasts) @@ -102,7 +100,7 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, Forecast]]): """Insert Opower statistics.""" try: accounts = await self.api.async_get_accounts() - except aiohttp.ClientError as err: + except ApiException as err: _LOGGER.error("Error getting accounts: %s", err) raise for account in accounts: @@ -271,7 +269,7 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, Forecast]]): cost_reads = await self.api.async_get_cost_reads( account, AggregateType.BILL, start, end ) - except aiohttp.ClientError as err: + except ApiException as err: _LOGGER.error("Error getting monthly cost reads: %s", err) raise _LOGGER.debug("Got %s monthly cost reads", len(cost_reads)) @@ -290,7 +288,7 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, Forecast]]): daily_cost_reads = await self.api.async_get_cost_reads( account, AggregateType.DAY, start, end ) - except aiohttp.ClientError as err: + except ApiException as err: _LOGGER.error("Error getting daily cost reads: %s", err) raise _LOGGER.debug("Got %s daily cost reads", len(daily_cost_reads)) @@ -308,7 +306,7 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, Forecast]]): hourly_cost_reads = await self.api.async_get_cost_reads( account, AggregateType.HOUR, start, end ) - except aiohttp.ClientError as err: + except ApiException as err: _LOGGER.error("Error getting hourly cost reads: %s", err) raise _LOGGER.debug("Got %s hourly cost reads", len(hourly_cost_reads)) diff --git a/homeassistant/components/opower/manifest.json b/homeassistant/components/opower/manifest.json index 7227f7171ac..d168cba5752 100644 --- a/homeassistant/components/opower/manifest.json +++ b/homeassistant/components/opower/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/opower", "iot_class": "cloud_polling", "loggers": ["opower"], - "requirements": ["opower==0.8.8"] + "requirements": ["opower==0.8.9"] } diff --git a/homeassistant/components/opower/sensor.py b/homeassistant/components/opower/sensor.py index 7f8eb22d1e6..f9d0fe62332 100644 --- a/homeassistant/components/opower/sensor.py +++ b/homeassistant/components/opower/sensor.py @@ -97,7 +97,7 @@ ELEC_SENSORS: tuple[OpowerEntityDescription, ...] = ( device_class=SensorDeviceClass.DATE, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, - value_fn=lambda data: data.start_date, + value_fn=lambda data: str(data.start_date), ), OpowerEntityDescription( key="elec_end_date", @@ -105,7 +105,7 @@ ELEC_SENSORS: tuple[OpowerEntityDescription, ...] = ( device_class=SensorDeviceClass.DATE, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, - value_fn=lambda data: data.end_date, + value_fn=lambda data: str(data.end_date), ), ) GAS_SENSORS: tuple[OpowerEntityDescription, ...] = ( @@ -169,7 +169,7 @@ GAS_SENSORS: tuple[OpowerEntityDescription, ...] = ( device_class=SensorDeviceClass.DATE, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, - value_fn=lambda data: data.start_date, + value_fn=lambda data: str(data.start_date), ), OpowerEntityDescription( key="gas_end_date", @@ -177,7 +177,7 @@ GAS_SENSORS: tuple[OpowerEntityDescription, ...] = ( device_class=SensorDeviceClass.DATE, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, - value_fn=lambda data: data.end_date, + value_fn=lambda data: str(data.end_date), ), ) diff --git a/requirements_all.txt b/requirements_all.txt index 84a2cd7ad2e..2d1d4b1cca9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1592,7 +1592,7 @@ openwrt-luci-rpc==1.1.17 openwrt-ubus-rpc==0.0.2 # homeassistant.components.opower -opower==0.8.8 +opower==0.8.9 # homeassistant.components.oralb oralb-ble==0.17.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 326670c9f63..d699ae56a97 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1328,7 +1328,7 @@ openhomedevice==2.2.0 openwebifpy==4.3.1 # homeassistant.components.opower -opower==0.8.8 +opower==0.8.9 # homeassistant.components.oralb oralb-ble==0.17.6 From 6e55ba137add6061b6ed056aeeb0a24498553ebb Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 31 Jan 2025 17:33:30 +0100 Subject: [PATCH 0379/3148] Make backup file names more user friendly (#136928) * Make backup file names more user friendly * Strip backup name * Strip backup name * Underscores --- homeassistant/components/backup/backup.py | 4 +- homeassistant/components/backup/manager.py | 2 +- homeassistant/components/backup/util.py | 9 ++ homeassistant/components/backup/websocket.py | 2 +- tests/components/backup/test_backup.py | 4 +- tests/components/backup/test_manager.py | 139 +++++++++++++++++-- tests/components/backup/test_util.py | 28 ++++ 7 files changed, 171 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/backup/backup.py b/homeassistant/components/backup/backup.py index c76b50b5935..b6282186c06 100644 --- a/homeassistant/components/backup/backup.py +++ b/homeassistant/components/backup/backup.py @@ -14,7 +14,7 @@ from homeassistant.helpers.hassio import is_hassio from .agent import BackupAgent, BackupNotFound, LocalBackupAgent from .const import DOMAIN, LOGGER from .models import AgentBackup -from .util import read_backup +from .util import read_backup, suggested_filename async def async_get_backup_agents( @@ -123,7 +123,7 @@ class CoreLocalBackupAgent(LocalBackupAgent): def get_new_backup_path(self, backup: AgentBackup) -> Path: """Return the local path to a new backup.""" - return self._backup_dir / f"{backup.backup_id}.tar" + return self._backup_dir / suggested_filename(backup) async def async_delete_backup(self, backup_id: str, **kwargs: Any) -> None: """Delete a backup file.""" diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index 1dbd8f8547d..2576eb8d1f0 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -898,7 +898,7 @@ class BackupManager: ) backup_name = ( - name + (name if name is None else name.strip()) or f"{'Automatic' if with_automatic_settings else 'Custom'} backup {HAVERSION}" ) extra_metadata = extra_metadata or {} diff --git a/homeassistant/components/backup/util.py b/homeassistant/components/backup/util.py index 2416aa5f28e..e9d597aa709 100644 --- a/homeassistant/components/backup/util.py +++ b/homeassistant/components/backup/util.py @@ -20,6 +20,7 @@ from securetar import SecureTarError, SecureTarFile, SecureTarReadError from homeassistant.backup_restore import password_to_key from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.util import dt as dt_util from homeassistant.util.json import JsonObjectType, json_loads_object from homeassistant.util.thread import ThreadWithException @@ -117,6 +118,14 @@ def read_backup(backup_path: Path) -> AgentBackup: ) +def suggested_filename(backup: AgentBackup) -> str: + """Suggest a filename for the backup.""" + date = dt_util.parse_datetime(backup.date, raise_on_error=True) + return "_".join( + f"{backup.name} - {date.strftime('%Y-%m-%d %H.%M %S%f')}.tar".split() + ) + + def validate_password(path: Path, password: str | None) -> bool: """Validate the password.""" with tarfile.open(path, "r:", bufsize=BUF_SIZE) as backup_file: diff --git a/homeassistant/components/backup/websocket.py b/homeassistant/components/backup/websocket.py index feb762bb50b..93dd81c3c14 100644 --- a/homeassistant/components/backup/websocket.py +++ b/homeassistant/components/backup/websocket.py @@ -199,7 +199,7 @@ async def handle_can_decrypt_on_download( vol.Optional("include_database", default=True): bool, vol.Optional("include_folders"): [vol.Coerce(Folder)], vol.Optional("include_homeassistant", default=True): bool, - vol.Optional("name"): str, + vol.Optional("name"): vol.Any(str, None), vol.Optional("password"): vol.Any(str, None), } ) diff --git a/tests/components/backup/test_backup.py b/tests/components/backup/test_backup.py index ce34c51c105..c441cae292c 100644 --- a/tests/components/backup/test_backup.py +++ b/tests/components/backup/test_backup.py @@ -103,7 +103,9 @@ async def test_upload( assert resp.status == 201 assert open_mock.call_count == 1 assert move_mock.call_count == 1 - assert move_mock.mock_calls[0].args[1].name == "abc123.tar" + assert ( + move_mock.mock_calls[0].args[1].name == "Test_-_1970-01-01_00.00_00000000.tar" + ) @pytest.mark.usefixtures("read_backup") diff --git a/tests/components/backup/test_manager.py b/tests/components/backup/test_manager.py index 4a8d2360d3f..b98cec47e8d 100644 --- a/tests/components/backup/test_manager.py +++ b/tests/components/backup/test_manager.py @@ -21,6 +21,7 @@ from unittest.mock import ( patch, ) +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components.backup import ( @@ -236,6 +237,64 @@ async def test_create_backup_service( "password": None, }, ), + ( + { + "agent_ids": ["backup.local"], + "extra_metadata": {"custom": "data"}, + "include_addons": None, + "include_all_addons": False, + "include_database": True, + "include_folders": None, + "include_homeassistant": True, + "name": "user defined name", + "password": None, + }, + { + "agent_ids": ["backup.local"], + "backup_name": "user defined name", + "extra_metadata": { + "custom": "data", + "instance_id": ANY, + "with_automatic_settings": False, + }, + "include_addons": None, + "include_all_addons": False, + "include_database": True, + "include_folders": None, + "include_homeassistant": True, + "on_progress": ANY, + "password": None, + }, + ), + ( + { + "agent_ids": ["backup.local"], + "extra_metadata": {"custom": "data"}, + "include_addons": None, + "include_all_addons": False, + "include_database": True, + "include_folders": None, + "include_homeassistant": True, + "name": " ", # Name which is just whitespace + "password": None, + }, + { + "agent_ids": ["backup.local"], + "backup_name": "Custom backup 2025.1.0", + "extra_metadata": { + "custom": "data", + "instance_id": ANY, + "with_automatic_settings": False, + }, + "include_addons": None, + "include_all_addons": False, + "include_database": True, + "include_folders": None, + "include_homeassistant": True, + "on_progress": ANY, + "password": None, + }, + ), ], ) async def test_async_create_backup( @@ -345,18 +404,70 @@ async def test_create_backup_wrong_parameters( @pytest.mark.usefixtures("mock_backup_generation") @pytest.mark.parametrize( - ("agent_ids", "backup_directory", "temp_file_unlink_call_count"), + ( + "agent_ids", + "backup_directory", + "name", + "expected_name", + "expected_filename", + "temp_file_unlink_call_count", + ), [ - ([LOCAL_AGENT_ID], "backups", 0), - (["test.remote"], "tmp_backups", 1), - ([LOCAL_AGENT_ID, "test.remote"], "backups", 0), + ( + [LOCAL_AGENT_ID], + "backups", + None, + "Custom backup 2025.1.0", + "Custom_backup_2025.1.0_-_2025-01-30_05.42_12345678.tar", + 0, + ), + ( + ["test.remote"], + "tmp_backups", + None, + "Custom backup 2025.1.0", + "abc123.tar", # We don't use friendly name for temporary backups + 1, + ), + ( + [LOCAL_AGENT_ID, "test.remote"], + "backups", + None, + "Custom backup 2025.1.0", + "Custom_backup_2025.1.0_-_2025-01-30_05.42_12345678.tar", + 0, + ), + ( + [LOCAL_AGENT_ID], + "backups", + "custom_name", + "custom_name", + "custom_name_-_2025-01-30_05.42_12345678.tar", + 0, + ), + ( + ["test.remote"], + "tmp_backups", + "custom_name", + "custom_name", + "abc123.tar", # We don't use friendly name for temporary backups + 1, + ), + ( + [LOCAL_AGENT_ID, "test.remote"], + "backups", + "custom_name", + "custom_name", + "custom_name_-_2025-01-30_05.42_12345678.tar", + 0, + ), ], ) @pytest.mark.parametrize( "params", [ {}, - {"include_database": True, "name": "abc123"}, + {"include_database": True}, {"include_database": False}, {"password": "pass123"}, ], @@ -364,6 +475,7 @@ async def test_create_backup_wrong_parameters( async def test_initiate_backup( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, + freezer: FrozenDateTimeFactory, mocked_json_bytes: Mock, mocked_tarfile: Mock, generate_backup_id: MagicMock, @@ -371,6 +483,9 @@ async def test_initiate_backup( params: dict[str, Any], agent_ids: list[str], backup_directory: str, + name: str | None, + expected_name: str, + expected_filename: str, temp_file_unlink_call_count: int, ) -> None: """Test generate backup.""" @@ -393,9 +508,9 @@ async def test_initiate_backup( ) ws_client = await hass_ws_client(hass) + freezer.move_to("2025-01-30 13:42:12.345678") include_database = params.get("include_database", True) - name = params.get("name", "Custom backup 2025.1.0") password = params.get("password") path_glob.return_value = [] @@ -427,7 +542,7 @@ async def test_initiate_backup( patch("pathlib.Path.unlink") as unlink_mock, ): await ws_client.send_json_auto_id( - {"type": "backup/generate", "agent_ids": agent_ids} | params + {"type": "backup/generate", "agent_ids": agent_ids, "name": name} | params ) result = await ws_client.receive_json() assert result["event"] == { @@ -487,7 +602,7 @@ async def test_initiate_backup( "exclude_database": not include_database, "version": "2025.1.0", }, - "name": name, + "name": expected_name, "protected": bool(password), "slug": backup_id, "type": "partial", @@ -514,7 +629,7 @@ async def test_initiate_backup( "folders": [], "homeassistant_included": True, "homeassistant_version": "2025.1.0", - "name": name, + "name": expected_name, "with_automatic_settings": False, } @@ -528,7 +643,7 @@ async def test_initiate_backup( tar_file_path = str(mocked_tarfile.call_args_list[0][0][0]) backup_directory = hass.config.path(backup_directory) - assert tar_file_path == f"{backup_directory}/{backup_id}.tar" + assert tar_file_path == f"{backup_directory}/{expected_filename}" @pytest.mark.usefixtures("mock_backup_generation") @@ -1482,7 +1597,7 @@ async def test_exception_platform_post(hass: HomeAssistant) -> None: "agent_id=backup.local&agent_id=test.remote", 2, 1, - ["abc123.tar"], + ["Test_-_1970-01-01_00.00_00000000.tar"], {TEST_BACKUP_ABC123.backup_id: TEST_BACKUP_ABC123}, b"test", 0, @@ -1491,7 +1606,7 @@ async def test_exception_platform_post(hass: HomeAssistant) -> None: "agent_id=backup.local", 1, 1, - ["abc123.tar"], + ["Test_-_1970-01-01_00.00_00000000.tar"], {}, None, 0, diff --git a/tests/components/backup/test_util.py b/tests/components/backup/test_util.py index db759805c8f..3bcb53f7c86 100644 --- a/tests/components/backup/test_util.py +++ b/tests/components/backup/test_util.py @@ -15,6 +15,7 @@ from homeassistant.components.backup.util import ( DecryptedBackupStreamer, EncryptedBackupStreamer, read_backup, + suggested_filename, validate_password, ) from homeassistant.core import HomeAssistant @@ -384,3 +385,30 @@ async def test_encrypted_backup_streamer_error(hass: HomeAssistant) -> None: # padding. await encryptor.wait() assert isinstance(encryptor._workers[0].error, tarfile.TarError) + + +@pytest.mark.parametrize( + ("name", "resulting_filename"), + [ + ("test", "test_-_2025-01-30_13.42_12345678.tar"), + (" leading spaces", "leading_spaces_-_2025-01-30_13.42_12345678.tar"), + ("trailing spaces ", "trailing_spaces_-_2025-01-30_13.42_12345678.tar"), + ("double spaces ", "double_spaces_-_2025-01-30_13.42_12345678.tar"), + ], +) +def test_suggested_filename(name: str, resulting_filename: str) -> None: + """Test suggesting a filename.""" + backup = AgentBackup( + addons=[], + backup_id="1234", + date="2025-01-30 13:42:12.345678-05:00", + database_included=False, + extra_metadata={}, + folders=[], + homeassistant_included=True, + homeassistant_version="2024.12.0.dev0", + name=name, + protected=False, + size=1234, + ) + assert suggested_filename(backup) == resulting_filename From eca30717a95f3719c889f8272dd5f94542ad47d7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 30 Jan 2025 12:55:14 -0600 Subject: [PATCH 0380/3148] Bump zeroconf to 0.142.0 (#136940) changelog: https://github.com/python-zeroconf/python-zeroconf/compare/0.141.0...0.142.0 --- homeassistant/components/zeroconf/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/zeroconf/manifest.json b/homeassistant/components/zeroconf/manifest.json index 6fe2b5b1923..be6f2d111d7 100644 --- a/homeassistant/components/zeroconf/manifest.json +++ b/homeassistant/components/zeroconf/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "loggers": ["zeroconf"], "quality_scale": "internal", - "requirements": ["zeroconf==0.141.0"] + "requirements": ["zeroconf==0.142.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 01cfc57f3a8..a15e1bb61be 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -73,7 +73,7 @@ voluptuous-serialize==2.6.0 voluptuous==0.15.2 webrtc-models==0.3.0 yarl==1.18.3 -zeroconf==0.141.0 +zeroconf==0.142.0 # Constrain pycryptodome to avoid vulnerability # see https://github.com/home-assistant/core/pull/16238 diff --git a/pyproject.toml b/pyproject.toml index 2e7b2dfcbc1..fb8545681e8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -82,7 +82,7 @@ dependencies = [ "voluptuous-openapi==0.0.6", "yarl==1.18.3", "webrtc-models==0.3.0", - "zeroconf==0.141.0" + "zeroconf==0.142.0" ] [project.urls] diff --git a/requirements.txt b/requirements.txt index a98d53b6037..412252a0846 100644 --- a/requirements.txt +++ b/requirements.txt @@ -51,4 +51,4 @@ voluptuous-serialize==2.6.0 voluptuous-openapi==0.0.6 yarl==1.18.3 webrtc-models==0.3.0 -zeroconf==0.141.0 +zeroconf==0.142.0 diff --git a/requirements_all.txt b/requirements_all.txt index 2d1d4b1cca9..00702b6914f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3125,7 +3125,7 @@ zamg==0.3.6 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.141.0 +zeroconf==0.142.0 # homeassistant.components.zeversolar zeversolar==0.3.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d699ae56a97..325c01b0708 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2514,7 +2514,7 @@ yt-dlp[default]==2025.01.26 zamg==0.3.6 # homeassistant.components.zeroconf -zeroconf==0.141.0 +zeroconf==0.142.0 # homeassistant.components.zeversolar zeversolar==0.3.2 From eb344ba3359f059f1743ff2787679bfe7aaa2e98 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 30 Jan 2025 13:38:27 -0600 Subject: [PATCH 0381/3148] Bump aiohttp-asyncmdnsresolver to 0.0.2 (#136942) --- 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 a15e1bb61be..891d91e134b 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -4,7 +4,7 @@ aiodhcpwatcher==1.0.2 aiodiscover==2.1.0 aiodns==3.2.0 aiohasupervisor==0.2.2b6 -aiohttp-asyncmdnsresolver==0.0.1 +aiohttp-asyncmdnsresolver==0.0.2 aiohttp-fast-zlib==0.2.0 aiohttp==3.11.11 aiohttp_cors==0.7.0 diff --git a/pyproject.toml b/pyproject.toml index fb8545681e8..e3bee8e6608 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,7 +31,7 @@ dependencies = [ "aiohttp==3.11.11", "aiohttp_cors==0.7.0", "aiohttp-fast-zlib==0.2.0", - "aiohttp-asyncmdnsresolver==0.0.1", + "aiohttp-asyncmdnsresolver==0.0.2", "aiozoneinfo==0.2.1", "astral==2.2", "async-interrupt==1.2.0", diff --git a/requirements.txt b/requirements.txt index 412252a0846..77fd3887db4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,7 +8,7 @@ aiohasupervisor==0.2.2b6 aiohttp==3.11.11 aiohttp_cors==0.7.0 aiohttp-fast-zlib==0.2.0 -aiohttp-asyncmdnsresolver==0.0.1 +aiohttp-asyncmdnsresolver==0.0.2 aiozoneinfo==0.2.1 astral==2.2 async-interrupt==1.2.0 From 71a40d9234d98fa6487290de5ee42d22b5282146 Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Thu, 30 Jan 2025 21:59:00 +0100 Subject: [PATCH 0382/3148] Update knx-frontend to 2025.1.30.194235 (#136954) --- homeassistant/components/knx/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/knx/manifest.json b/homeassistant/components/knx/manifest.json index f34ce0f4589..86c050443e3 100644 --- a/homeassistant/components/knx/manifest.json +++ b/homeassistant/components/knx/manifest.json @@ -12,7 +12,7 @@ "requirements": [ "xknx==3.5.0", "xknxproject==3.8.1", - "knx-frontend==2025.1.28.225404" + "knx-frontend==2025.1.30.194235" ], "single_config_entry": true } diff --git a/requirements_all.txt b/requirements_all.txt index 00702b6914f..348c6e81aa4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1272,7 +1272,7 @@ kiwiki-client==0.1.1 knocki==0.4.2 # homeassistant.components.knx -knx-frontend==2025.1.28.225404 +knx-frontend==2025.1.30.194235 # homeassistant.components.konnected konnected==1.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 325c01b0708..d90fa84e2a3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1074,7 +1074,7 @@ kegtron-ble==0.4.0 knocki==0.4.2 # homeassistant.components.knx -knx-frontend==2025.1.28.225404 +knx-frontend==2025.1.30.194235 # homeassistant.components.konnected konnected==1.2.0 From ad86f9efd5ea15ecf61462ef0965f411494e31b4 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Thu, 30 Jan 2025 16:01:24 -0600 Subject: [PATCH 0383/3148] Consume extra system prompt in first pipeline (#136958) --- homeassistant/components/assist_satellite/entity.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/assist_satellite/entity.py b/homeassistant/components/assist_satellite/entity.py index 927229c9756..0229e0358b1 100644 --- a/homeassistant/components/assist_satellite/entity.py +++ b/homeassistant/components/assist_satellite/entity.py @@ -264,7 +264,6 @@ class AssistSatelliteEntity(entity.Entity): await self.async_start_conversation(announcement) finally: self._is_announcing = False - self._extra_system_prompt = None async def async_start_conversation( self, start_announcement: AssistSatelliteAnnouncement @@ -282,6 +281,10 @@ class AssistSatelliteEntity(entity.Entity): """Triggers an Assist pipeline in Home Assistant from a satellite.""" await self._cancel_running_pipeline() + # Consume system prompt in first pipeline + extra_system_prompt = self._extra_system_prompt + self._extra_system_prompt = None + if self._wake_word_intercept_future and start_stage in ( PipelineStage.WAKE_WORD, PipelineStage.STT, @@ -358,7 +361,7 @@ class AssistSatelliteEntity(entity.Entity): ), start_stage=start_stage, end_stage=end_stage, - conversation_extra_system_prompt=self._extra_system_prompt, + conversation_extra_system_prompt=extra_system_prompt, ), f"{self.entity_id}_pipeline", ) From c77bca1e4417faea5914d02f54a8600906f914c8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 31 Jan 2025 01:34:39 -0600 Subject: [PATCH 0384/3148] Bump habluetooth to 3.15.0 (#136973) --- 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 1fcd507da83..38677400418 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -21,6 +21,6 @@ "bluetooth-auto-recovery==1.4.2", "bluetooth-data-tools==1.22.0", "dbus-fast==2.30.2", - "habluetooth==3.14.0" + "habluetooth==3.15.0" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 891d91e134b..64353901fbf 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -33,7 +33,7 @@ dbus-fast==2.30.2 fnv-hash-fast==1.2.2 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 -habluetooth==3.14.0 +habluetooth==3.15.0 hass-nabucasa==0.88.1 hassil==2.2.0 home-assistant-bluetooth==1.13.0 diff --git a/requirements_all.txt b/requirements_all.txt index 348c6e81aa4..b63d203b0e0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1100,7 +1100,7 @@ ha-philipsjs==3.2.2 habiticalib==0.3.3 # homeassistant.components.bluetooth -habluetooth==3.14.0 +habluetooth==3.15.0 # homeassistant.components.cloud hass-nabucasa==0.88.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d90fa84e2a3..573ed230cb5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -941,7 +941,7 @@ ha-philipsjs==3.2.2 habiticalib==0.3.3 # homeassistant.components.bluetooth -habluetooth==3.14.0 +habluetooth==3.15.0 # homeassistant.components.cloud hass-nabucasa==0.88.1 From 26ae498974e018b18dcb8db6f0b29eb5e4059d74 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 31 Jan 2025 19:33:48 +0100 Subject: [PATCH 0385/3148] Delete old addon update backups when updating addon (#136977) * Delete old addon update backups when updating addon * Address review comments * Add tests --- homeassistant/components/backup/config.py | 77 ++-------- homeassistant/components/backup/manager.py | 64 +++++++++ homeassistant/components/hassio/backup.py | 23 ++- tests/components/hassio/test_update.py | 129 ++++++++++++++++- tests/components/hassio/test_websocket_api.py | 133 +++++++++++++++++- 5 files changed, 350 insertions(+), 76 deletions(-) diff --git a/homeassistant/components/backup/config.py b/homeassistant/components/backup/config.py index 0baefe1f52d..4d0cd82bc44 100644 --- a/homeassistant/components/backup/config.py +++ b/homeassistant/components/backup/config.py @@ -2,8 +2,6 @@ from __future__ import annotations -import asyncio -from collections.abc import Callable from dataclasses import dataclass, field, replace import datetime as dt from datetime import datetime, timedelta @@ -252,7 +250,7 @@ class RetentionConfig: """Delete backups older than days.""" self._schedule_next(manager) - def _backups_filter( + def _delete_filter( backups: dict[str, ManagerBackup], ) -> dict[str, ManagerBackup]: """Return backups older than days to delete.""" @@ -269,7 +267,9 @@ class RetentionConfig: < now } - await _delete_filtered_backups(manager, _backups_filter) + await manager.async_delete_filtered_backups( + include_filter=_automatic_backups_filter, delete_filter=_delete_filter + ) manager.remove_next_delete_event = async_call_later( manager.hass, timedelta(days=1), _delete_backups @@ -521,74 +521,21 @@ class CreateBackupParametersDict(TypedDict, total=False): password: str | None -async def _delete_filtered_backups( - manager: BackupManager, - backup_filter: Callable[[dict[str, ManagerBackup]], dict[str, ManagerBackup]], -) -> None: - """Delete backups parsed with a filter. - - :param manager: The backup manager. - :param backup_filter: A filter that should return the backups to delete. - """ - backups, get_agent_errors = await manager.async_get_backups() - if get_agent_errors: - LOGGER.debug( - "Error getting backups; continuing anyway: %s", - get_agent_errors, - ) - - # only delete backups that are created with the saved automatic settings - backups = { +def _automatic_backups_filter( + backups: dict[str, ManagerBackup], +) -> dict[str, ManagerBackup]: + """Return automatic backups.""" + return { backup_id: backup for backup_id, backup in backups.items() if backup.with_automatic_settings } - LOGGER.debug("Total automatic backups: %s", backups) - - filtered_backups = backup_filter(backups) - - if not filtered_backups: - return - - # always delete oldest backup first - filtered_backups = dict( - sorted( - filtered_backups.items(), - key=lambda backup_item: backup_item[1].date, - ) - ) - - if len(filtered_backups) >= len(backups): - # Never delete the last backup. - last_backup = filtered_backups.popitem() - LOGGER.debug("Keeping the last backup: %s", last_backup) - - LOGGER.debug("Backups to delete: %s", filtered_backups) - - if not filtered_backups: - return - - backup_ids = list(filtered_backups) - delete_results = await asyncio.gather( - *(manager.async_delete_backup(backup_id) for backup_id in filtered_backups) - ) - agent_errors = { - backup_id: error - for backup_id, error in zip(backup_ids, delete_results, strict=True) - if error - } - if agent_errors: - LOGGER.error( - "Error deleting old copies: %s", - agent_errors, - ) - async def delete_backups_exceeding_configured_count(manager: BackupManager) -> None: """Delete backups exceeding the configured retention count.""" - def _backups_filter( + def _delete_filter( backups: dict[str, ManagerBackup], ) -> dict[str, ManagerBackup]: """Return oldest backups more numerous than copies to delete.""" @@ -603,4 +550,6 @@ async def delete_backups_exceeding_configured_count(manager: BackupManager) -> N )[: max(len(backups) - manager.config.data.retention.copies, 0)] ) - await _delete_filtered_backups(manager, _backups_filter) + await manager.async_delete_filtered_backups( + include_filter=_automatic_backups_filter, delete_filter=_delete_filter + ) diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index 2576eb8d1f0..42b5f522ecd 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -685,6 +685,70 @@ class BackupManager: return agent_errors + async def async_delete_filtered_backups( + self, + *, + include_filter: Callable[[dict[str, ManagerBackup]], dict[str, ManagerBackup]], + delete_filter: Callable[[dict[str, ManagerBackup]], dict[str, ManagerBackup]], + ) -> None: + """Delete backups parsed with a filter. + + :param include_filter: A filter that should return the backups to consider for + deletion. Note: The newest of the backups returned by include_filter will + unconditionally be kept, even if delete_filter returns all backups. + :param delete_filter: A filter that should return the backups to delete. + """ + backups, get_agent_errors = await self.async_get_backups() + if get_agent_errors: + LOGGER.debug( + "Error getting backups; continuing anyway: %s", + get_agent_errors, + ) + + # Run the include filter first to ensure we only consider backups that + # should be included in the deletion process. + backups = include_filter(backups) + + LOGGER.debug("Total automatic backups: %s", backups) + + backups_to_delete = delete_filter(backups) + + if not backups_to_delete: + return + + # always delete oldest backup first + backups_to_delete = dict( + sorted( + backups_to_delete.items(), + key=lambda backup_item: backup_item[1].date, + ) + ) + + if len(backups_to_delete) >= len(backups): + # Never delete the last backup. + last_backup = backups_to_delete.popitem() + LOGGER.debug("Keeping the last backup: %s", last_backup) + + LOGGER.debug("Backups to delete: %s", backups_to_delete) + + if not backups_to_delete: + return + + backup_ids = list(backups_to_delete) + delete_results = await asyncio.gather( + *(self.async_delete_backup(backup_id) for backup_id in backups_to_delete) + ) + agent_errors = { + backup_id: error + for backup_id, error in zip(backup_ids, delete_results, strict=True) + if error + } + if agent_errors: + LOGGER.error( + "Error deleting old copies: %s", + agent_errors, + ) + async def async_receive_backup( self, *, diff --git a/homeassistant/components/hassio/backup.py b/homeassistant/components/hassio/backup.py index b9439183d8c..59242a32708 100644 --- a/homeassistant/components/hassio/backup.py +++ b/homeassistant/components/hassio/backup.py @@ -33,6 +33,7 @@ from homeassistant.components.backup import ( Folder, IdleEvent, IncorrectPasswordError, + ManagerBackup, NewBackup, RestoreBackupEvent, RestoreBackupState, @@ -51,6 +52,8 @@ LOCATION_CLOUD_BACKUP = ".cloud_backup" LOCATION_LOCAL = ".local" MOUNT_JOBS = ("mount_manager_create_mount", "mount_manager_remove_mount") RESTORE_JOB_ID_ENV = "SUPERVISOR_RESTORE_JOB_ID" +# Set on backups automatically created when updating an addon +TAG_ADDON_UPDATE = "supervisor.addon_update" _LOGGER = logging.getLogger(__name__) @@ -614,10 +617,20 @@ async def backup_addon_before_update( else: password = None + def addon_update_backup_filter( + backups: dict[str, ManagerBackup], + ) -> dict[str, ManagerBackup]: + """Return addon update backups.""" + return { + backup_id: backup + for backup_id, backup in backups.items() + if backup.extra_metadata.get(TAG_ADDON_UPDATE) == addon + } + try: await backup_manager.async_create_backup( agent_ids=[await _default_agent(client)], - extra_metadata={"supervisor.addon_update": addon}, + extra_metadata={TAG_ADDON_UPDATE: addon}, include_addons=[addon], include_all_addons=False, include_database=False, @@ -628,6 +641,14 @@ async def backup_addon_before_update( ) except BackupManagerError as err: raise HomeAssistantError(f"Error creating backup: {err}") from err + else: + try: + await backup_manager.async_delete_filtered_backups( + include_filter=addon_update_backup_filter, + delete_filter=lambda backups: backups, + ) + except BackupManagerError as err: + raise HomeAssistantError(f"Error deleting old backups: {err}") from err async def backup_core_before_update(hass: HomeAssistant) -> None: diff --git a/tests/components/hassio/test_update.py b/tests/components/hassio/test_update.py index 62fe49c5f23..332f2050cf2 100644 --- a/tests/components/hassio/test_update.py +++ b/tests/components/hassio/test_update.py @@ -3,13 +3,13 @@ from datetime import timedelta import os from typing import Any -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, MagicMock, patch from aiohasupervisor import SupervisorBadRequestError, SupervisorError from aiohasupervisor.models import HomeAssistantUpdateOptions, StoreAddonUpdate import pytest -from homeassistant.components.backup import BackupManagerError +from homeassistant.components.backup import BackupManagerError, ManagerBackup from homeassistant.components.hassio import DOMAIN from homeassistant.components.hassio.const import REQUEST_REFRESH_DELAY from homeassistant.const import __version__ as HAVERSION @@ -338,6 +338,113 @@ async def test_update_addon_with_backup( update_addon.assert_called_once_with("test", StoreAddonUpdate(backup=False)) +@pytest.mark.parametrize( + ("backups", "removed_backups"), + [ + ( + {}, + [], + ), + ( + { + "backup-1": MagicMock( + date="2024-11-10T04:45:00+01:00", + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-2": MagicMock( + date="2024-11-11T04:45:00+01:00", + with_automatic_settings=False, + spec=ManagerBackup, + ), + "backup-3": MagicMock( + date="2024-11-11T04:45:00+01:00", + extra_metadata={"supervisor.addon_update": "other"}, + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-4": MagicMock( + date="2024-11-11T04:45:00+01:00", + extra_metadata={"supervisor.addon_update": "other"}, + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-5": MagicMock( + date="2024-11-11T04:45:00+01:00", + extra_metadata={"supervisor.addon_update": "test"}, + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-6": MagicMock( + date="2024-11-12T04:45:00+01:00", + extra_metadata={"supervisor.addon_update": "test"}, + with_automatic_settings=True, + spec=ManagerBackup, + ), + }, + ["backup-5"], + ), + ], +) +async def test_update_addon_with_backup_removes_old_backups( + hass: HomeAssistant, + supervisor_client: AsyncMock, + update_addon: AsyncMock, + backups: dict[str, ManagerBackup], + removed_backups: list[str], +) -> None: + """Test updating addon update entity.""" + config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) + config_entry.add_to_hass(hass) + + with patch.dict(os.environ, MOCK_ENVIRON): + result = await async_setup_component( + hass, + "hassio", + {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + ) + assert result + assert await async_setup_component(hass, "backup", {}) + await hass.async_block_till_done() + + supervisor_client.mounts.info.return_value.default_backup_mount = None + with ( + patch( + "homeassistant.components.backup.manager.BackupManager.async_create_backup", + ) as mock_create_backup, + patch( + "homeassistant.components.backup.manager.BackupManager.async_delete_backup", + autospec=True, + return_value={}, + ) as async_delete_backup, + patch( + "homeassistant.components.backup.manager.BackupManager.async_get_backups", + return_value=(backups, {}), + ), + ): + await hass.services.async_call( + "update", + "install", + {"entity_id": "update.test_update", "backup": True}, + blocking=True, + ) + mock_create_backup.assert_called_once_with( + agent_ids=["hassio.local"], + extra_metadata={"supervisor.addon_update": "test"}, + include_addons=["test"], + include_all_addons=False, + include_database=False, + include_folders=None, + include_homeassistant=False, + name="test 2.0.0", + password=None, + ) + assert len(async_delete_backup.mock_calls) == len(removed_backups) + for call in async_delete_backup.mock_calls: + assert call.args[1] in removed_backups + update_addon.assert_called_once_with("test", StoreAddonUpdate(backup=False)) + + async def test_update_os(hass: HomeAssistant, supervisor_client: AsyncMock) -> None: """Test updating OS update entity.""" config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) @@ -550,9 +657,19 @@ async def test_update_addon_with_error( ) +@pytest.mark.parametrize( + ("create_backup_error", "delete_filtered_backups_error", "message"), + [ + (BackupManagerError, None, r"^Error creating backup: "), + (None, BackupManagerError, r"^Error deleting old backups: "), + ], +) async def test_update_addon_with_backup_and_error( hass: HomeAssistant, supervisor_client: AsyncMock, + create_backup_error: Exception | None, + delete_filtered_backups_error: Exception | None, + message: str, ) -> None: """Test updating addon update entity with error.""" config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) @@ -573,9 +690,13 @@ async def test_update_addon_with_backup_and_error( with ( patch( "homeassistant.components.backup.manager.BackupManager.async_create_backup", - side_effect=BackupManagerError, + side_effect=create_backup_error, ), - pytest.raises(HomeAssistantError, match=r"^Error creating backup:"), + patch( + "homeassistant.components.backup.manager.BackupManager.async_delete_filtered_backups", + side_effect=delete_filtered_backups_error, + ), + pytest.raises(HomeAssistantError, match=message), ): assert not await hass.services.async_call( "update", diff --git a/tests/components/hassio/test_websocket_api.py b/tests/components/hassio/test_websocket_api.py index ab8dc1475e2..bcac19e0fa3 100644 --- a/tests/components/hassio/test_websocket_api.py +++ b/tests/components/hassio/test_websocket_api.py @@ -2,13 +2,13 @@ import os from typing import Any -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, MagicMock, patch from aiohasupervisor import SupervisorError from aiohasupervisor.models import HomeAssistantUpdateOptions, StoreAddonUpdate import pytest -from homeassistant.components.backup import BackupManagerError +from homeassistant.components.backup import BackupManagerError, ManagerBackup from homeassistant.components.hassio import DOMAIN from homeassistant.components.hassio.const import ( ATTR_DATA, @@ -457,6 +457,114 @@ async def test_update_addon_with_backup( update_addon.assert_called_once_with("test", StoreAddonUpdate(backup=False)) +@pytest.mark.parametrize( + ("backups", "removed_backups"), + [ + ( + {}, + [], + ), + ( + { + "backup-1": MagicMock( + date="2024-11-10T04:45:00+01:00", + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-2": MagicMock( + date="2024-11-11T04:45:00+01:00", + with_automatic_settings=False, + spec=ManagerBackup, + ), + "backup-3": MagicMock( + date="2024-11-11T04:45:00+01:00", + extra_metadata={"supervisor.addon_update": "other"}, + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-4": MagicMock( + date="2024-11-11T04:45:00+01:00", + extra_metadata={"supervisor.addon_update": "other"}, + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-5": MagicMock( + date="2024-11-11T04:45:00+01:00", + extra_metadata={"supervisor.addon_update": "test"}, + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-6": MagicMock( + date="2024-11-12T04:45:00+01:00", + extra_metadata={"supervisor.addon_update": "test"}, + with_automatic_settings=True, + spec=ManagerBackup, + ), + }, + ["backup-5"], + ), + ], +) +async def test_update_addon_with_backup_removes_old_backups( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + supervisor_client: AsyncMock, + update_addon: AsyncMock, + backups: dict[str, ManagerBackup], + removed_backups: list[str], +) -> None: + """Test updating addon update entity.""" + config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) + config_entry.add_to_hass(hass) + + with patch.dict(os.environ, MOCK_ENVIRON): + result = await async_setup_component( + hass, + "hassio", + {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + ) + assert result + assert await async_setup_component(hass, "backup", {}) + await hass.async_block_till_done() + + client = await hass_ws_client(hass) + supervisor_client.mounts.info.return_value.default_backup_mount = None + with ( + patch( + "homeassistant.components.backup.manager.BackupManager.async_create_backup", + ) as mock_create_backup, + patch( + "homeassistant.components.backup.manager.BackupManager.async_delete_backup", + autospec=True, + return_value={}, + ) as async_delete_backup, + patch( + "homeassistant.components.backup.manager.BackupManager.async_get_backups", + return_value=(backups, {}), + ), + ): + await client.send_json_auto_id( + {"type": "hassio/update/addon", "addon": "test", "backup": True} + ) + result = await client.receive_json() + assert result["success"] + mock_create_backup.assert_called_once_with( + agent_ids=["hassio.local"], + extra_metadata={"supervisor.addon_update": "test"}, + include_addons=["test"], + include_all_addons=False, + include_database=False, + include_folders=None, + include_homeassistant=False, + name="test 2.0.0", + password=None, + ) + assert len(async_delete_backup.mock_calls) == len(removed_backups) + for call in async_delete_backup.mock_calls: + assert call.args[1] in removed_backups + update_addon.assert_called_once_with("test", StoreAddonUpdate(backup=False)) + + async def test_update_core( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, @@ -622,10 +730,20 @@ async def test_update_addon_with_error( } +@pytest.mark.parametrize( + ("create_backup_error", "delete_filtered_backups_error", "message"), + [ + (BackupManagerError, None, "Error creating backup: "), + (None, BackupManagerError, "Error deleting old backups: "), + ], +) async def test_update_addon_with_backup_and_error( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, supervisor_client: AsyncMock, + create_backup_error: Exception | None, + delete_filtered_backups_error: Exception | None, + message: str, ) -> None: """Test updating addon with backup and error.""" client = await hass_ws_client(hass) @@ -647,7 +765,11 @@ async def test_update_addon_with_backup_and_error( with ( patch( "homeassistant.components.backup.manager.BackupManager.async_create_backup", - side_effect=BackupManagerError, + side_effect=create_backup_error, + ), + patch( + "homeassistant.components.backup.manager.BackupManager.async_delete_filtered_backups", + side_effect=delete_filtered_backups_error, ), ): await client.send_json_auto_id( @@ -655,10 +777,7 @@ async def test_update_addon_with_backup_and_error( ) result = await client.receive_json() assert not result["success"] - assert result["error"] == { - "code": "home_assistant_error", - "message": "Error creating backup: ", - } + assert result["error"] == {"code": "home_assistant_error", "message": message} async def test_update_core_with_error( From 0272d37e88a852a7da1420907e440338f9cb5d68 Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Fri, 31 Jan 2025 11:23:33 +0100 Subject: [PATCH 0386/3148] Retry backup uploads in onedrive (#136980) * Retry backup uploads in onedrive * no exponential backup on timeout --- homeassistant/components/onedrive/backup.py | 34 ++++- tests/components/onedrive/conftest.py | 7 + tests/components/onedrive/test_backup.py | 138 +++++++++++++++++++- 3 files changed, 171 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/onedrive/backup.py b/homeassistant/components/onedrive/backup.py index 94d60bc6398..7f4bd5a0738 100644 --- a/homeassistant/components/onedrive/backup.py +++ b/homeassistant/components/onedrive/backup.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio from collections.abc import AsyncIterator, Callable, Coroutine from functools import wraps import html @@ -9,7 +10,7 @@ import json import logging from typing import Any, Concatenate, cast -from httpx import Response +from httpx import Response, TimeoutException from kiota_abstractions.api_error import APIError from kiota_abstractions.authentication import AnonymousAuthenticationProvider from kiota_abstractions.headers_collection import HeadersCollection @@ -42,6 +43,7 @@ from .const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN _LOGGER = logging.getLogger(__name__) UPLOAD_CHUNK_SIZE = 16 * 320 * 1024 # 5.2MB +MAX_RETRIES = 5 async def async_get_backup_agents( @@ -96,7 +98,7 @@ def handle_backup_errors[_R, **P]( ) _LOGGER.debug("Full error: %s", err, exc_info=True) raise BackupAgentError("Backup operation failed") from err - except TimeoutError as err: + except TimeoutException as err: _LOGGER.error( "Error during backup in %s: Timeout", func.__name__, @@ -268,6 +270,7 @@ class OneDriveBackupAgent(BackupAgent): start = 0 buffer: list[bytes] = [] buffer_size = 0 + retries = 0 async for chunk in stream: buffer.append(chunk) @@ -279,11 +282,28 @@ class OneDriveBackupAgent(BackupAgent): buffer_size > UPLOAD_CHUNK_SIZE ): # Loop in case the buffer is >= UPLOAD_CHUNK_SIZE * 2 slice_start = uploaded_chunks * UPLOAD_CHUNK_SIZE - await async_upload( - start, - start + UPLOAD_CHUNK_SIZE - 1, - chunk_data[slice_start : slice_start + UPLOAD_CHUNK_SIZE], - ) + try: + await async_upload( + start, + start + UPLOAD_CHUNK_SIZE - 1, + chunk_data[slice_start : slice_start + UPLOAD_CHUNK_SIZE], + ) + except APIError as err: + if ( + err.response_status_code and err.response_status_code < 500 + ): # no retry on 4xx errors + raise + if retries < MAX_RETRIES: + await asyncio.sleep(2**retries) + retries += 1 + continue + raise + except TimeoutException: + if retries < MAX_RETRIES: + retries += 1 + continue + raise + retries = 0 start += UPLOAD_CHUNK_SIZE uploaded_chunks += 1 buffer_size -= UPLOAD_CHUNK_SIZE diff --git a/tests/components/onedrive/conftest.py b/tests/components/onedrive/conftest.py index 65142217017..649966a7828 100644 --- a/tests/components/onedrive/conftest.py +++ b/tests/components/onedrive/conftest.py @@ -176,3 +176,10 @@ def mock_instance_id() -> Generator[AsyncMock]: return_value="9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0", ): yield + + +@pytest.fixture(autouse=True) +def mock_asyncio_sleep() -> Generator[AsyncMock]: + """Mock asyncio.sleep.""" + with patch("homeassistant.components.onedrive.backup.asyncio.sleep", AsyncMock()): + yield diff --git a/tests/components/onedrive/test_backup.py b/tests/components/onedrive/test_backup.py index 3492202d3fe..162ecb7d92a 100644 --- a/tests/components/onedrive/test_backup.py +++ b/tests/components/onedrive/test_backup.py @@ -8,8 +8,10 @@ from io import StringIO from json import dumps from unittest.mock import Mock, patch +from httpx import TimeoutException from kiota_abstractions.api_error import APIError from msgraph.generated.models.drive_item import DriveItem +from msgraph_core.models import LargeFileUploadSession import pytest from homeassistant.components.backup import DOMAIN as BACKUP_DOMAIN, AgentBackup @@ -255,6 +257,140 @@ async def test_broken_upload_session( assert "Failed to start backup upload" in caplog.text +@pytest.mark.parametrize( + "side_effect", + [ + APIError(response_status_code=500), + TimeoutException("Timeout"), + ], +) +async def test_agents_upload_errors_retried( + hass_client: ClientSessionGenerator, + caplog: pytest.LogCaptureFixture, + mock_drive_items: MagicMock, + mock_config_entry: MockConfigEntry, + mock_adapter: MagicMock, + side_effect: Exception, +) -> None: + """Test agent upload backup.""" + client = await hass_client() + test_backup = AgentBackup.from_dict(BACKUP_METADATA) + + mock_adapter.send_async.side_effect = [ + side_effect, + LargeFileUploadSession(next_expected_ranges=["2-"]), + LargeFileUploadSession(next_expected_ranges=["2-"]), + ] + + with ( + patch( + "homeassistant.components.backup.manager.BackupManager.async_get_backup", + ) as fetch_backup, + patch( + "homeassistant.components.backup.manager.read_backup", + return_value=test_backup, + ), + patch("pathlib.Path.open") as mocked_open, + patch("homeassistant.components.onedrive.backup.UPLOAD_CHUNK_SIZE", 3), + ): + mocked_open.return_value.read = Mock(side_effect=[b"test", b""]) + fetch_backup.return_value = test_backup + resp = await client.post( + f"/api/backup/upload?agent_id={DOMAIN}.{mock_config_entry.unique_id}", + data={"file": StringIO("test")}, + ) + + assert resp.status == 201 + assert mock_adapter.send_async.call_count == 3 + assert f"Uploading backup {test_backup.backup_id}" in caplog.text + mock_drive_items.patch.assert_called_once() + + +async def test_agents_upload_4xx_errors_not_retried( + hass_client: ClientSessionGenerator, + caplog: pytest.LogCaptureFixture, + mock_drive_items: MagicMock, + mock_config_entry: MockConfigEntry, + mock_adapter: MagicMock, +) -> None: + """Test agent upload backup.""" + client = await hass_client() + test_backup = AgentBackup.from_dict(BACKUP_METADATA) + + mock_adapter.send_async.side_effect = APIError(response_status_code=404) + + with ( + patch( + "homeassistant.components.backup.manager.BackupManager.async_get_backup", + ) as fetch_backup, + patch( + "homeassistant.components.backup.manager.read_backup", + return_value=test_backup, + ), + patch("pathlib.Path.open") as mocked_open, + patch("homeassistant.components.onedrive.backup.UPLOAD_CHUNK_SIZE", 3), + ): + mocked_open.return_value.read = Mock(side_effect=[b"test", b""]) + fetch_backup.return_value = test_backup + resp = await client.post( + f"/api/backup/upload?agent_id={DOMAIN}.{mock_config_entry.unique_id}", + data={"file": StringIO("test")}, + ) + + assert resp.status == 201 + assert mock_adapter.send_async.call_count == 1 + assert f"Uploading backup {test_backup.backup_id}" in caplog.text + assert mock_drive_items.patch.call_count == 0 + assert "Backup operation failed" in caplog.text + + +@pytest.mark.parametrize( + ("side_effect", "error"), + [ + (APIError(response_status_code=500), "Backup operation failed"), + (TimeoutException("Timeout"), "Backup operation timed out"), + ], +) +async def test_agents_upload_fails_after_max_retries( + hass_client: ClientSessionGenerator, + caplog: pytest.LogCaptureFixture, + mock_drive_items: MagicMock, + mock_config_entry: MockConfigEntry, + mock_adapter: MagicMock, + side_effect: Exception, + error: str, +) -> None: + """Test agent upload backup.""" + client = await hass_client() + test_backup = AgentBackup.from_dict(BACKUP_METADATA) + + mock_adapter.send_async.side_effect = side_effect + + with ( + patch( + "homeassistant.components.backup.manager.BackupManager.async_get_backup", + ) as fetch_backup, + patch( + "homeassistant.components.backup.manager.read_backup", + return_value=test_backup, + ), + patch("pathlib.Path.open") as mocked_open, + patch("homeassistant.components.onedrive.backup.UPLOAD_CHUNK_SIZE", 3), + ): + mocked_open.return_value.read = Mock(side_effect=[b"test", b""]) + fetch_backup.return_value = test_backup + resp = await client.post( + f"/api/backup/upload?agent_id={DOMAIN}.{mock_config_entry.unique_id}", + data={"file": StringIO("test")}, + ) + + assert resp.status == 201 + assert mock_adapter.send_async.call_count == 6 + assert f"Uploading backup {test_backup.backup_id}" in caplog.text + assert mock_drive_items.patch.call_count == 0 + assert error in caplog.text + + async def test_agents_download( hass_client: ClientSessionGenerator, mock_drive_items: MagicMock, @@ -282,7 +418,7 @@ async def test_agents_download( APIError(response_status_code=500), "Backup operation failed", ), - (TimeoutError(), "Backup operation timed out"), + (TimeoutException("Timeout"), "Backup operation timed out"), ], ) async def test_delete_error( From 6bab5b2c320737b35ca22a3f268a3c00052ad55b Mon Sep 17 00:00:00 2001 From: Cyrill Raccaud Date: Fri, 31 Jan 2025 12:10:44 +0100 Subject: [PATCH 0387/3148] Fix missing duration translation for Swiss public transport integration (#136982) --- .../swiss_public_transport/icons.json | 2 +- .../swiss_public_transport/sensor.py | 2 + .../swiss_public_transport/strings.json | 4 +- .../snapshots/test_sensor.ambr | 101 +++++++++--------- .../swiss_public_transport/test_sensor.py | 2 +- 5 files changed, 58 insertions(+), 53 deletions(-) diff --git a/homeassistant/components/swiss_public_transport/icons.json b/homeassistant/components/swiss_public_transport/icons.json index 06a640a06b2..45cf4713705 100644 --- a/homeassistant/components/swiss_public_transport/icons.json +++ b/homeassistant/components/swiss_public_transport/icons.json @@ -10,7 +10,7 @@ "departure2": { "default": "mdi:bus-clock" }, - "duration": { + "trip_duration": { "default": "mdi:timeline-clock" }, "transfers": { diff --git a/homeassistant/components/swiss_public_transport/sensor.py b/homeassistant/components/swiss_public_transport/sensor.py index a0131938a37..c8075a6746c 100644 --- a/homeassistant/components/swiss_public_transport/sensor.py +++ b/homeassistant/components/swiss_public_transport/sensor.py @@ -56,8 +56,10 @@ SENSORS: tuple[SwissPublicTransportSensorEntityDescription, ...] = ( ], SwissPublicTransportSensorEntityDescription( key="duration", + translation_key="trip_duration", device_class=SensorDeviceClass.DURATION, native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.HOURS, value_fn=lambda data_connection: data_connection["duration"], ), SwissPublicTransportSensorEntityDescription( diff --git a/homeassistant/components/swiss_public_transport/strings.json b/homeassistant/components/swiss_public_transport/strings.json index ef8cc5595e3..270cb097e0a 100644 --- a/homeassistant/components/swiss_public_transport/strings.json +++ b/homeassistant/components/swiss_public_transport/strings.json @@ -64,8 +64,8 @@ "departure2": { "name": "Departure +2" }, - "duration": { - "name": "Duration" + "trip_duration": { + "name": "Trip duration" }, "transfers": { "name": "Transfers" diff --git a/tests/components/swiss_public_transport/snapshots/test_sensor.ambr b/tests/components/swiss_public_transport/snapshots/test_sensor.ambr index dbd689fc8f6..b8ad82c7b79 100644 --- a/tests/components/swiss_public_transport/snapshots/test_sensor.ambr +++ b/tests/components/swiss_public_transport/snapshots/test_sensor.ambr @@ -192,55 +192,6 @@ 'state': '2024-01-06T17:05:00+00:00', }) # --- -# name: test_all_entities[sensor.zurich_bern_duration-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.zurich_bern_duration', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Duration', - 'platform': 'swiss_public_transport', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'Zürich Bern_duration', - 'unit_of_measurement': , - }) -# --- -# name: test_all_entities[sensor.zurich_bern_duration-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by transport.opendata.ch', - 'device_class': 'duration', - 'friendly_name': 'Zürich Bern Duration', - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.zurich_bern_duration', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '10', - }) -# --- # name: test_all_entities[sensor.zurich_bern_line-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -382,3 +333,55 @@ 'state': '0', }) # --- +# name: test_all_entities[sensor.zurich_bern_trip_duration-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.zurich_bern_trip_duration', + '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': 'Trip duration', + 'platform': 'swiss_public_transport', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'trip_duration', + 'unique_id': 'Zürich Bern_duration', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.zurich_bern_trip_duration-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by transport.opendata.ch', + 'device_class': 'duration', + 'friendly_name': 'Zürich Bern Trip duration', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.zurich_bern_trip_duration', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.003', + }) +# --- diff --git a/tests/components/swiss_public_transport/test_sensor.py b/tests/components/swiss_public_transport/test_sensor.py index 4afdd88c9de..6e832728277 100644 --- a/tests/components/swiss_public_transport/test_sensor.py +++ b/tests/components/swiss_public_transport/test_sensor.py @@ -83,7 +83,7 @@ async def test_fetching_data( hass.states.get("sensor.zurich_bern_departure_2").state == "2024-01-06T17:05:00+00:00" ) - assert hass.states.get("sensor.zurich_bern_duration").state == "10" + assert hass.states.get("sensor.zurich_bern_trip_duration").state == "0.003" assert hass.states.get("sensor.zurich_bern_platform").state == "0" assert hass.states.get("sensor.zurich_bern_transfers").state == "0" assert hass.states.get("sensor.zurich_bern_delay").state == "0" From 00298db465eef687dc14f728e1cd157a15096aeb Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Fri, 31 Jan 2025 13:39:59 +0100 Subject: [PATCH 0388/3148] Call backup listener during setup in onedrive (#136990) --- homeassistant/components/onedrive/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/onedrive/__init__.py b/homeassistant/components/onedrive/__init__.py index 7419ca6e20c..4ae5ac73560 100644 --- a/homeassistant/components/onedrive/__init__.py +++ b/homeassistant/components/onedrive/__init__.py @@ -97,6 +97,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: OneDriveConfigEntry) -> backup_folder_id=backup_folder_id, ) + _async_notify_backup_listeners_soon(hass) + return True From c28d465f3b8d8c4296e5131f0418e7ef321c8f8b Mon Sep 17 00:00:00 2001 From: Duco Sebel <74970928+DCSBL@users.noreply.github.com> Date: Fri, 31 Jan 2025 17:49:25 +0100 Subject: [PATCH 0389/3148] Bumb python-homewizard-energy to 8.3.2 (#136995) --- homeassistant/components/homewizard/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/homewizard/manifest.json b/homeassistant/components/homewizard/manifest.json index 957ed912b7d..51a315b2286 100644 --- a/homeassistant/components/homewizard/manifest.json +++ b/homeassistant/components/homewizard/manifest.json @@ -12,6 +12,6 @@ "iot_class": "local_polling", "loggers": ["homewizard_energy"], "quality_scale": "platinum", - "requirements": ["python-homewizard-energy==v8.3.0"], + "requirements": ["python-homewizard-energy==v8.3.2"], "zeroconf": ["_hwenergy._tcp.local.", "_homewizard._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index b63d203b0e0..50994859d2f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2391,7 +2391,7 @@ python-google-drive-api==0.0.2 python-homeassistant-analytics==0.8.1 # homeassistant.components.homewizard -python-homewizard-energy==v8.3.0 +python-homewizard-energy==v8.3.2 # homeassistant.components.hp_ilo python-hpilo==4.4.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 573ed230cb5..8a2b74c5ce9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1936,7 +1936,7 @@ python-google-drive-api==0.0.2 python-homeassistant-analytics==0.8.1 # homeassistant.components.homewizard -python-homewizard-energy==v8.3.0 +python-homewizard-energy==v8.3.2 # homeassistant.components.izone python-izone==1.2.9 From 07b85163d522d3b69d2a4b37db91d430aac104c9 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 31 Jan 2025 16:32:11 +0100 Subject: [PATCH 0390/3148] Use device name as entity name in Eheim digital climate (#136997) --- .../components/eheimdigital/climate.py | 1 + .../eheimdigital/snapshots/test_climate.ambr | 20 +++++++++---------- tests/components/eheimdigital/test_climate.py | 16 +++++++-------- 3 files changed, 19 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/eheimdigital/climate.py b/homeassistant/components/eheimdigital/climate.py index 9b1f825dece..7ad06659089 100644 --- a/homeassistant/components/eheimdigital/climate.py +++ b/homeassistant/components/eheimdigital/climate.py @@ -76,6 +76,7 @@ class EheimDigitalHeaterClimate(EheimDigitalEntity[EheimDigitalHeater], ClimateE _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_preset_mode = PRESET_NONE _attr_translation_key = "heater" + _attr_name = None def __init__( self, coordinator: EheimDigitalUpdateCoordinator, device: EheimDigitalHeater diff --git a/tests/components/eheimdigital/snapshots/test_climate.ambr b/tests/components/eheimdigital/snapshots/test_climate.ambr index d81c59e5af1..171d3d427fc 100644 --- a/tests/components/eheimdigital/snapshots/test_climate.ambr +++ b/tests/components/eheimdigital/snapshots/test_climate.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_dynamic_new_devices[climate.mock_heater_none-entry] +# name: test_dynamic_new_devices[climate.mock_heater-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -24,7 +24,7 @@ 'disabled_by': None, 'domain': 'climate', 'entity_category': None, - 'entity_id': 'climate.mock_heater_none', + 'entity_id': 'climate.mock_heater', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -45,11 +45,11 @@ 'unit_of_measurement': None, }) # --- -# name: test_dynamic_new_devices[climate.mock_heater_none-state] +# name: test_dynamic_new_devices[climate.mock_heater-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'current_temperature': 24.2, - 'friendly_name': 'Mock Heater None', + 'friendly_name': 'Mock Heater', 'hvac_action': , 'hvac_modes': list([ , @@ -68,14 +68,14 @@ 'temperature': 25.5, }), 'context': , - 'entity_id': 'climate.mock_heater_none', + 'entity_id': 'climate.mock_heater', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'auto', }) # --- -# name: test_setup_heater[climate.mock_heater_none-entry] +# name: test_setup_heater[climate.mock_heater-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -100,7 +100,7 @@ 'disabled_by': None, 'domain': 'climate', 'entity_category': None, - 'entity_id': 'climate.mock_heater_none', + 'entity_id': 'climate.mock_heater', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -121,11 +121,11 @@ 'unit_of_measurement': None, }) # --- -# name: test_setup_heater[climate.mock_heater_none-state] +# name: test_setup_heater[climate.mock_heater-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'current_temperature': 24.2, - 'friendly_name': 'Mock Heater None', + 'friendly_name': 'Mock Heater', 'hvac_action': , 'hvac_modes': list([ , @@ -144,7 +144,7 @@ 'temperature': 25.5, }), 'context': , - 'entity_id': 'climate.mock_heater_none', + 'entity_id': 'climate.mock_heater', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/eheimdigital/test_climate.py b/tests/components/eheimdigital/test_climate.py index f64b7d7e740..f1f29ce9d34 100644 --- a/tests/components/eheimdigital/test_climate.py +++ b/tests/components/eheimdigital/test_climate.py @@ -123,7 +123,7 @@ async def test_set_preset_mode( await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_PRESET_MODE, - {ATTR_ENTITY_ID: "climate.mock_heater_none", ATTR_PRESET_MODE: preset_mode}, + {ATTR_ENTITY_ID: "climate.mock_heater", ATTR_PRESET_MODE: preset_mode}, blocking=True, ) @@ -132,7 +132,7 @@ async def test_set_preset_mode( await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_PRESET_MODE, - {ATTR_ENTITY_ID: "climate.mock_heater_none", ATTR_PRESET_MODE: preset_mode}, + {ATTR_ENTITY_ID: "climate.mock_heater", ATTR_PRESET_MODE: preset_mode}, blocking=True, ) @@ -161,7 +161,7 @@ async def test_set_temperature( await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, - {ATTR_ENTITY_ID: "climate.mock_heater_none", ATTR_TEMPERATURE: 26.0}, + {ATTR_ENTITY_ID: "climate.mock_heater", ATTR_TEMPERATURE: 26.0}, blocking=True, ) @@ -170,7 +170,7 @@ async def test_set_temperature( await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, - {ATTR_ENTITY_ID: "climate.mock_heater_none", ATTR_TEMPERATURE: 26.0}, + {ATTR_ENTITY_ID: "climate.mock_heater", ATTR_TEMPERATURE: 26.0}, blocking=True, ) @@ -204,7 +204,7 @@ async def test_set_hvac_mode( await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, - {ATTR_ENTITY_ID: "climate.mock_heater_none", ATTR_HVAC_MODE: hvac_mode}, + {ATTR_ENTITY_ID: "climate.mock_heater", ATTR_HVAC_MODE: hvac_mode}, blocking=True, ) @@ -213,7 +213,7 @@ async def test_set_hvac_mode( await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, - {ATTR_ENTITY_ID: "climate.mock_heater_none", ATTR_HVAC_MODE: hvac_mode}, + {ATTR_ENTITY_ID: "climate.mock_heater", ATTR_HVAC_MODE: hvac_mode}, blocking=True, ) @@ -239,7 +239,7 @@ async def test_state_update( ) await hass.async_block_till_done() - assert (state := hass.states.get("climate.mock_heater_none")) + assert (state := hass.states.get("climate.mock_heater")) assert state.attributes["hvac_action"] == HVACAction.IDLE assert state.attributes["preset_mode"] == HEATER_BIO_MODE @@ -249,6 +249,6 @@ async def test_state_update( await eheimdigital_hub_mock.call_args.kwargs["receive_callback"]() - assert (state := hass.states.get("climate.mock_heater_none")) + assert (state := hass.states.get("climate.mock_heater")) assert state.state == HVACMode.OFF assert state.attributes["preset_mode"] == HEATER_SMART_MODE From 3107b813337cbe717f873b7eb292d92f368d4d3a Mon Sep 17 00:00:00 2001 From: Cyrill Raccaud Date: Fri, 31 Jan 2025 13:19:04 +0100 Subject: [PATCH 0391/3148] Remove the unparsed config flow error from Swiss public transport (#136998) --- homeassistant/components/swiss_public_transport/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/swiss_public_transport/strings.json b/homeassistant/components/swiss_public_transport/strings.json index 270cb097e0a..64817f89f42 100644 --- a/homeassistant/components/swiss_public_transport/strings.json +++ b/homeassistant/components/swiss_public_transport/strings.json @@ -28,7 +28,7 @@ "time_station": "Usually the departure time of a connection when it leaves the start station is tracked. Alternatively, track the time when the connection arrives at its end station.", "time_mode": "Time mode lets you change the departure timing and fix it to a specific time (e.g. 7:12:00 AM every morning) or add a moving offset (e.g. +00:05:00 taking into account the time to walk to the station)." }, - "description": "Provide start and end station for your connection,\nand optionally up to 5 via stations.\n\nCheck the [stationboard]({stationboard_url}) for valid stations.", + "description": "Provide start and end station for your connection,\nand optionally up to 5 via stations.\n\nConsult the stationboard linked above.", "title": "Swiss Public Transport" }, "time_fixed": { From f4166c53909989e8efe60b5e6e72274c5f6e1e0d Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 31 Jan 2025 16:50:30 +0100 Subject: [PATCH 0392/3148] Make sure we load the backup integration before frontend (#137010) --- homeassistant/bootstrap.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index d89a9595868..8c27f41aabe 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -161,6 +161,10 @@ FRONTEND_INTEGRATIONS = { # integrations can be removed and database migration status is # visible in frontend "frontend", + # Backup is an after dependency of frontend, after dependencies + # are not promoted from stage 2 to earlier stages, so we need to + # add it here. + "backup", } RECORDER_INTEGRATIONS = { # Setup after frontend From 4fe76ec78ce6e7781ab119fd29e6df00fddfcd8a Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Fri, 31 Jan 2025 17:26:43 +0100 Subject: [PATCH 0393/3148] Revert previous PR and remove URL from error message instead (#137018) --- homeassistant/components/swiss_public_transport/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/swiss_public_transport/strings.json b/homeassistant/components/swiss_public_transport/strings.json index 64817f89f42..1cdbd527467 100644 --- a/homeassistant/components/swiss_public_transport/strings.json +++ b/homeassistant/components/swiss_public_transport/strings.json @@ -2,7 +2,7 @@ "config": { "error": { "cannot_connect": "Cannot connect to server", - "bad_config": "Request failed due to bad config: Check at [stationboard]({stationboard_url}) if your station names are valid", + "bad_config": "Request failed due to bad config: Check the stationboard linked above if your station names are valid", "too_many_via_stations": "Too many via stations, only up to 5 via stations are allowed per connection.", "unknown": "An unknown error was raised by python-opendata-transport" }, @@ -28,7 +28,7 @@ "time_station": "Usually the departure time of a connection when it leaves the start station is tracked. Alternatively, track the time when the connection arrives at its end station.", "time_mode": "Time mode lets you change the departure timing and fix it to a specific time (e.g. 7:12:00 AM every morning) or add a moving offset (e.g. +00:05:00 taking into account the time to walk to the station)." }, - "description": "Provide start and end station for your connection,\nand optionally up to 5 via stations.\n\nConsult the stationboard linked above.", + "description": "Provide start and end station for your connection, and optionally up to 5 via stations.\n\nCheck the [stationboard]({stationboard_url}) for valid stations.", "title": "Swiss Public Transport" }, "time_fixed": { From b412164440d5797be059fc45d7a0453c3b012d20 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 31 Jan 2025 18:20:30 +0100 Subject: [PATCH 0394/3148] Make supervisor backup file names more user friendly (#137020) --- homeassistant/components/backup/__init__.py | 3 +++ homeassistant/components/backup/util.py | 11 +++++++---- homeassistant/components/hassio/backup.py | 17 ++++++++++++++--- tests/components/hassio/test_backup.py | 14 ++++++++++++++ 4 files changed, 38 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/backup/__init__.py b/homeassistant/components/backup/__init__.py index 3003f94c2ed..86e5b95d196 100644 --- a/homeassistant/components/backup/__init__.py +++ b/homeassistant/components/backup/__init__.py @@ -35,6 +35,7 @@ from .manager import ( WrittenBackup, ) from .models import AddonInfo, AgentBackup, Folder +from .util import suggested_filename, suggested_filename_from_name_date from .websocket import async_register_websocket_handlers __all__ = [ @@ -58,6 +59,8 @@ __all__ = [ "RestoreBackupState", "WrittenBackup", "async_get_manager", + "suggested_filename", + "suggested_filename_from_name_date", ] CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) diff --git a/homeassistant/components/backup/util.py b/homeassistant/components/backup/util.py index e9d597aa709..fbb13b4721a 100644 --- a/homeassistant/components/backup/util.py +++ b/homeassistant/components/backup/util.py @@ -118,12 +118,15 @@ def read_backup(backup_path: Path) -> AgentBackup: ) +def suggested_filename_from_name_date(name: str, date_str: str) -> str: + """Suggest a filename for the backup.""" + date = dt_util.parse_datetime(date_str, raise_on_error=True) + return "_".join(f"{name} - {date.strftime('%Y-%m-%d %H.%M %S%f')}.tar".split()) + + def suggested_filename(backup: AgentBackup) -> str: """Suggest a filename for the backup.""" - date = dt_util.parse_datetime(backup.date, raise_on_error=True) - return "_".join( - f"{backup.name} - {date.strftime('%Y-%m-%d %H.%M %S%f')}.tar".split() - ) + return suggested_filename_from_name_date(backup.name, backup.date) def validate_password(path: Path, password: str | None) -> bool: diff --git a/homeassistant/components/hassio/backup.py b/homeassistant/components/hassio/backup.py index 59242a32708..495e953df9d 100644 --- a/homeassistant/components/hassio/backup.py +++ b/homeassistant/components/hassio/backup.py @@ -6,7 +6,7 @@ import asyncio from collections.abc import AsyncIterator, Callable, Coroutine, Mapping import logging import os -from pathlib import Path +from pathlib import Path, PurePath from typing import Any, cast from uuid import UUID @@ -39,11 +39,14 @@ from homeassistant.components.backup import ( RestoreBackupState, WrittenBackup, async_get_manager as async_get_backup_manager, + suggested_filename as suggested_backup_filename, + suggested_filename_from_name_date, ) from homeassistant.const import __version__ as HAVERSION from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.util import dt as dt_util from .const import DOMAIN, EVENT_SUPERVISOR_EVENT from .handler import get_supervisor_client @@ -116,12 +119,15 @@ def _backup_details_to_agent_backup( AddonInfo(name=addon.name, slug=addon.slug, version=addon.version) for addon in details.addons ] + extra_metadata = details.extra or {} location = location or LOCATION_LOCAL return AgentBackup( addons=addons, backup_id=details.slug, database_included=database_included, - date=details.date.isoformat(), + date=extra_metadata.get( + "supervisor.backup_request_date", details.date.isoformat() + ), extra_metadata=details.extra or {}, folders=[Folder(folder) for folder in details.folders], homeassistant_included=homeassistant_included, @@ -177,7 +183,8 @@ class SupervisorBackupAgent(BackupAgent): return stream = await open_stream() upload_options = supervisor_backups.UploadBackupOptions( - location={self.location} + location={self.location}, + filename=PurePath(suggested_backup_filename(backup)), ) await self._client.backups.upload_backup( stream, @@ -304,6 +311,9 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): locations = [] locations = locations or [LOCATION_CLOUD_BACKUP] + date = dt_util.now().isoformat() + extra_metadata = extra_metadata | {"supervisor.backup_request_date": date} + filename = suggested_filename_from_name_date(backup_name, date) try: backup = await self._client.backups.partial_backup( supervisor_backups.PartialBackupOptions( @@ -317,6 +327,7 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): homeassistant_exclude_database=not include_database, background=True, extra=extra_metadata, + filename=PurePath(filename), ) ) except SupervisorError as err: diff --git a/tests/components/hassio/test_backup.py b/tests/components/hassio/test_backup.py index 9ba73ade1a3..d001a358640 100644 --- a/tests/components/hassio/test_backup.py +++ b/tests/components/hassio/test_backup.py @@ -11,6 +11,7 @@ from dataclasses import replace from datetime import datetime from io import StringIO import os +from pathlib import PurePath from typing import Any from unittest.mock import ANY, AsyncMock, Mock, patch from uuid import UUID @@ -26,6 +27,7 @@ from aiohasupervisor.models import ( mounts as supervisor_mounts, ) from aiohasupervisor.models.mounts import MountsInfo +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components.backup import ( @@ -854,8 +856,10 @@ DEFAULT_BACKUP_OPTIONS = supervisor_backups.PartialBackupOptions( compressed=True, extra={ "instance_id": ANY, + "supervisor.backup_request_date": "2025-01-30T05:42:12.345678-08:00", "with_automatic_settings": False, }, + filename=PurePath("Test_-_2025-01-30_05.42_12345678.tar"), folders={"ssl"}, homeassistant_exclude_database=False, homeassistant=True, @@ -907,12 +911,14 @@ DEFAULT_BACKUP_OPTIONS = supervisor_backups.PartialBackupOptions( async def test_reader_writer_create( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, + freezer: FrozenDateTimeFactory, supervisor_client: AsyncMock, extra_generate_options: dict[str, Any], expected_supervisor_options: supervisor_backups.PartialBackupOptions, ) -> None: """Test generating a backup.""" client = await hass_ws_client(hass) + freezer.move_to("2025-01-30 13:42:12.345678") supervisor_client.backups.partial_backup.return_value.job_id = TEST_JOB_ID supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE @@ -982,10 +988,12 @@ async def test_reader_writer_create( async def test_reader_writer_create_job_done( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, + freezer: FrozenDateTimeFactory, supervisor_client: AsyncMock, ) -> None: """Test generating a backup, and backup job finishes early.""" client = await hass_ws_client(hass) + freezer.move_to("2025-01-30 13:42:12.345678") supervisor_client.backups.partial_backup.return_value.job_id = TEST_JOB_ID supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS supervisor_client.jobs.get_job.return_value = TEST_JOB_DONE @@ -1140,6 +1148,7 @@ async def test_reader_writer_create_job_done( async def test_reader_writer_create_per_agent_encryption( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, + freezer: FrozenDateTimeFactory, supervisor_client: AsyncMock, commands: dict[str, Any], password: str | None, @@ -1151,6 +1160,7 @@ async def test_reader_writer_create_per_agent_encryption( ) -> None: """Test generating a backup.""" client = await hass_ws_client(hass) + freezer.move_to("2025-01-30 13:42:12.345678") mounts = MountsInfo( default_backup_mount=None, mounts=[ @@ -1170,6 +1180,7 @@ async def test_reader_writer_create_per_agent_encryption( supervisor_client.backups.partial_backup.return_value.job_id = TEST_JOB_ID supervisor_client.backups.backup_info.return_value = replace( TEST_BACKUP_DETAILS, + extra=DEFAULT_BACKUP_OPTIONS.extra, locations=create_locations, location_attributes={ location or LOCATION_LOCAL: supervisor_backups.BackupLocationAttributes( @@ -1254,6 +1265,7 @@ async def test_reader_writer_create_per_agent_encryption( upload_locations ) for call in supervisor_client.backups.upload_backup.mock_calls: + assert call.args[1].filename == PurePath("Test_-_2025-01-30_05.42_12345678.tar") upload_call_locations: set = call.args[1].location assert len(upload_call_locations) == 1 assert upload_call_locations.pop() in upload_locations @@ -1569,10 +1581,12 @@ async def test_reader_writer_create_info_error( async def test_reader_writer_create_remote_backup( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, + freezer: FrozenDateTimeFactory, supervisor_client: AsyncMock, ) -> None: """Test generating a backup which will be uploaded to a remote agent.""" client = await hass_ws_client(hass) + freezer.move_to("2025-01-30 13:42:12.345678") supervisor_client.backups.partial_backup.return_value.job_id = TEST_JOB_ID supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS_5 supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE From e86a633c23ebc4a7e28e4f5090b189bab51ac321 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 31 Jan 2025 11:29:44 -0600 Subject: [PATCH 0395/3148] Bump habluetooth to 3.17.0 (#137022) --- 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 38677400418..d6ed9281099 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -21,6 +21,6 @@ "bluetooth-auto-recovery==1.4.2", "bluetooth-data-tools==1.22.0", "dbus-fast==2.30.2", - "habluetooth==3.15.0" + "habluetooth==3.17.0" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 64353901fbf..a7fbe090f23 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -33,7 +33,7 @@ dbus-fast==2.30.2 fnv-hash-fast==1.2.2 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 -habluetooth==3.15.0 +habluetooth==3.17.0 hass-nabucasa==0.88.1 hassil==2.2.0 home-assistant-bluetooth==1.13.0 diff --git a/requirements_all.txt b/requirements_all.txt index 50994859d2f..c955d01ac48 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1100,7 +1100,7 @@ ha-philipsjs==3.2.2 habiticalib==0.3.3 # homeassistant.components.bluetooth -habluetooth==3.15.0 +habluetooth==3.17.0 # homeassistant.components.cloud hass-nabucasa==0.88.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8a2b74c5ce9..1eef877f6c5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -941,7 +941,7 @@ ha-philipsjs==3.2.2 habiticalib==0.3.3 # homeassistant.components.bluetooth -habluetooth==3.15.0 +habluetooth==3.17.0 # homeassistant.components.cloud hass-nabucasa==0.88.1 From ae79b0940140ab1ca99afb0191011027bf5b94e0 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Fri, 31 Jan 2025 19:25:24 +0100 Subject: [PATCH 0396/3148] Update frontend to 20250131.0 (#137024) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index b545026059c..2ecb165554a 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -21,5 +21,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20250130.0"] + "requirements": ["home-assistant-frontend==20250131.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index a7fbe090f23..2d4e92e2e9a 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -37,7 +37,7 @@ habluetooth==3.17.0 hass-nabucasa==0.88.1 hassil==2.2.0 home-assistant-bluetooth==1.13.0 -home-assistant-frontend==20250130.0 +home-assistant-frontend==20250131.0 home-assistant-intents==2025.1.28 httpx==0.28.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index c955d01ac48..e2f5a70d8b3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1143,7 +1143,7 @@ hole==0.8.0 holidays==0.65 # homeassistant.components.frontend -home-assistant-frontend==20250130.0 +home-assistant-frontend==20250131.0 # homeassistant.components.conversation home-assistant-intents==2025.1.28 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1eef877f6c5..eb4cba20f67 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -972,7 +972,7 @@ hole==0.8.0 holidays==0.65 # homeassistant.components.frontend -home-assistant-frontend==20250130.0 +home-assistant-frontend==20250131.0 # homeassistant.components.conversation home-assistant-intents==2025.1.28 From ca2a555037d7dc5dada5096f30a45ffb8275c978 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 31 Jan 2025 12:15:31 -0600 Subject: [PATCH 0397/3148] Bump bleak-esphome to 2.6.0 (#137025) --- homeassistant/components/eq3btsmart/manifest.json | 2 +- homeassistant/components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/eq3btsmart/manifest.json b/homeassistant/components/eq3btsmart/manifest.json index bab62723c82..3a55730c60f 100644 --- a/homeassistant/components/eq3btsmart/manifest.json +++ b/homeassistant/components/eq3btsmart/manifest.json @@ -22,5 +22,5 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["eq3btsmart"], - "requirements": ["eq3btsmart==1.4.1", "bleak-esphome==2.2.0"] + "requirements": ["eq3btsmart==1.4.1", "bleak-esphome==2.6.0"] } diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index ecc7afb3661..9585be72c63 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -18,7 +18,7 @@ "requirements": [ "aioesphomeapi==29.0.0", "esphome-dashboard-api==1.2.3", - "bleak-esphome==2.2.0" + "bleak-esphome==2.6.0" ], "zeroconf": ["_esphomelib._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index e2f5a70d8b3..660e0a0bc35 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -594,7 +594,7 @@ bizkaibus==0.1.1 # homeassistant.components.eq3btsmart # homeassistant.components.esphome -bleak-esphome==2.2.0 +bleak-esphome==2.6.0 # homeassistant.components.bluetooth bleak-retry-connector==3.8.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index eb4cba20f67..c6b315d85aa 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -525,7 +525,7 @@ bimmer-connected[china]==0.17.2 # homeassistant.components.eq3btsmart # homeassistant.components.esphome -bleak-esphome==2.2.0 +bleak-esphome==2.6.0 # homeassistant.components.bluetooth bleak-retry-connector==3.8.0 From 7deb1715ddf0d8959e62100fe0b279f09e933306 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 31 Jan 2025 12:52:38 -0600 Subject: [PATCH 0398/3148] Bump SQLAlchemy to 2.0.37 (#137028) changelog: https://docs.sqlalchemy.org/en/20/changelog/changelog_20.html#change-2.0.37 There is a bug fix that likely affects us that could lead to corrupted queries https://docs.sqlalchemy.org/en/20/changelog/changelog_20.html#change-e4d04d8eb1bccee16b74f5662aff8edd --- homeassistant/components/recorder/manifest.json | 2 +- homeassistant/components/sql/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/recorder/manifest.json b/homeassistant/components/recorder/manifest.json index d3b6e52ad11..7cef284ef60 100644 --- a/homeassistant/components/recorder/manifest.json +++ b/homeassistant/components/recorder/manifest.json @@ -7,7 +7,7 @@ "iot_class": "local_push", "quality_scale": "internal", "requirements": [ - "SQLAlchemy==2.0.36", + "SQLAlchemy==2.0.37", "fnv-hash-fast==1.2.2", "psutil-home-assistant==0.0.1" ] diff --git a/homeassistant/components/sql/manifest.json b/homeassistant/components/sql/manifest.json index 01c95d6c5e4..0094770d53b 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.36", "sqlparse==0.5.0"] + "requirements": ["SQLAlchemy==2.0.37", "sqlparse==0.5.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 2d4e92e2e9a..0a1b97abc55 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -61,7 +61,7 @@ PyTurboJPEG==1.7.5 PyYAML==6.0.2 requests==2.32.3 securetar==2025.1.4 -SQLAlchemy==2.0.36 +SQLAlchemy==2.0.37 standard-aifc==3.13.0;python_version>='3.13' standard-telnetlib==3.13.0;python_version>='3.13' typing-extensions>=4.12.2,<5.0 diff --git a/pyproject.toml b/pyproject.toml index e3bee8e6608..74d634ea1a6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -67,7 +67,7 @@ dependencies = [ "PyYAML==6.0.2", "requests==2.32.3", "securetar==2025.1.4", - "SQLAlchemy==2.0.36", + "SQLAlchemy==2.0.37", "standard-aifc==3.13.0;python_version>='3.13'", "standard-telnetlib==3.13.0;python_version>='3.13'", "typing-extensions>=4.12.2,<5.0", diff --git a/requirements.txt b/requirements.txt index 77fd3887db4..02f3849148b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -39,7 +39,7 @@ python-slugify==8.0.4 PyYAML==6.0.2 requests==2.32.3 securetar==2025.1.4 -SQLAlchemy==2.0.36 +SQLAlchemy==2.0.37 standard-aifc==3.13.0;python_version>='3.13' standard-telnetlib==3.13.0;python_version>='3.13' typing-extensions>=4.12.2,<5.0 diff --git a/requirements_all.txt b/requirements_all.txt index 660e0a0bc35..cc5ed9ee62d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -116,7 +116,7 @@ RtmAPI==0.7.2 # homeassistant.components.recorder # homeassistant.components.sql -SQLAlchemy==2.0.36 +SQLAlchemy==2.0.37 # homeassistant.components.tami4 Tami4EdgeAPI==3.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c6b315d85aa..d0797b8f4a6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -110,7 +110,7 @@ RtmAPI==0.7.2 # homeassistant.components.recorder # homeassistant.components.sql -SQLAlchemy==2.0.36 +SQLAlchemy==2.0.37 # homeassistant.components.tami4 Tami4EdgeAPI==3.0 From 5450ed8445af41857160c616730ba8b078ee3864 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Fri, 31 Jan 2025 20:17:14 +0100 Subject: [PATCH 0399/3148] Bump deebot-client to 11.1.0b2 (#137030) --- homeassistant/components/ecovacs/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ecovacs/manifest.json b/homeassistant/components/ecovacs/manifest.json index 188f59f74e4..16929e1741a 100644 --- a/homeassistant/components/ecovacs/manifest.json +++ b/homeassistant/components/ecovacs/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/ecovacs", "iot_class": "cloud_push", "loggers": ["sleekxmppfs", "sucks", "deebot_client"], - "requirements": ["py-sucks==0.9.10", "deebot-client==11.1.0b1"] + "requirements": ["py-sucks==0.9.10", "deebot-client==11.1.0b2"] } diff --git a/requirements_all.txt b/requirements_all.txt index cc5ed9ee62d..f321be6254f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -747,7 +747,7 @@ debugpy==1.8.11 # decora==0.6 # homeassistant.components.ecovacs -deebot-client==11.1.0b1 +deebot-client==11.1.0b2 # homeassistant.components.ihc # homeassistant.components.namecheapdns diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d0797b8f4a6..28f181530a3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -637,7 +637,7 @@ dbus-fast==2.30.2 debugpy==1.8.11 # homeassistant.components.ecovacs -deebot-client==11.1.0b1 +deebot-client==11.1.0b2 # homeassistant.components.ihc # homeassistant.components.namecheapdns From e1105ef2fa224fc24742178834804be2b43c5d73 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 31 Jan 2025 19:25:16 +0000 Subject: [PATCH 0400/3148] Bump version to 2025.2.0b3 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 271226e92e2..939eb70c3e4 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -25,7 +25,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2025 MINOR_VERSION: Final = 2 -PATCH_VERSION: Final = "0b2" +PATCH_VERSION: Final = "0b3" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 13, 0) diff --git a/pyproject.toml b/pyproject.toml index 74d634ea1a6..c8159776f8a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.2.0b2" +version = "2025.2.0b3" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 4a2e9db9fe91f7d64ecbcf09559a22dc3beedfdb Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Fri, 31 Jan 2025 20:59:34 +0100 Subject: [PATCH 0401/3148] Use readable backup names for onedrive (#137031) * Use readable names for onedrive * ensure filename is fixed * fix import --- homeassistant/components/onedrive/backup.py | 67 ++++++++++++--------- tests/components/onedrive/conftest.py | 5 +- tests/components/onedrive/test_backup.py | 38 ++---------- 3 files changed, 49 insertions(+), 61 deletions(-) diff --git a/homeassistant/components/onedrive/backup.py b/homeassistant/components/onedrive/backup.py index 7f4bd5a0738..a7bac5d01fc 100644 --- a/homeassistant/components/onedrive/backup.py +++ b/homeassistant/components/onedrive/backup.py @@ -34,7 +34,12 @@ from msgraph.generated.models.drive_item_uploadable_properties import ( ) from msgraph_core.models import LargeFileUploadSession -from homeassistant.components.backup import AgentBackup, BackupAgent, BackupAgentError +from homeassistant.components.backup import ( + AgentBackup, + BackupAgent, + BackupAgentError, + suggested_filename, +) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.httpx_client import get_async_client @@ -130,6 +135,10 @@ class OneDriveBackupAgent(BackupAgent): ) -> AsyncIterator[bytes]: """Download a backup file.""" # this forces the query to return a raw httpx response, but breaks typing + backup = await self._find_item_by_backup_id(backup_id) + if backup is None or backup.id is None: + raise BackupAgentError("Backup not found") + request_config = ( ContentRequestBuilder.ContentRequestBuilderGetRequestConfiguration( options=[ResponseHandlerOption(NativeResponseHandler())], @@ -137,7 +146,7 @@ class OneDriveBackupAgent(BackupAgent): ) response = cast( Response, - await self._get_backup_file_item(backup_id).content.get( + await self._items.by_drive_item_id(backup.id).content.get( request_configuration=request_config ), ) @@ -162,9 +171,10 @@ class OneDriveBackupAgent(BackupAgent): }, ) ) - upload_session = await self._get_backup_file_item( - backup.backup_id - ).create_upload_session.post(upload_session_request_body) + file_item = self._get_backup_file_item(suggested_filename(backup)) + upload_session = await file_item.create_upload_session.post( + upload_session_request_body + ) if upload_session is None or upload_session.upload_url is None: raise BackupAgentError( @@ -181,9 +191,7 @@ class OneDriveBackupAgent(BackupAgent): description = json.dumps(backup_dict) _LOGGER.debug("Creating metadata: %s", description) - await self._get_backup_file_item(backup.backup_id).patch( - DriveItem(description=description) - ) + await file_item.patch(DriveItem(description=description)) @handle_backup_errors async def async_delete_backup( @@ -192,13 +200,10 @@ class OneDriveBackupAgent(BackupAgent): **kwargs: Any, ) -> None: """Delete a backup file.""" - - try: - await self._get_backup_file_item(backup_id).delete() - except APIError as err: - if err.response_status_code == 404: - return - raise + backup = await self._find_item_by_backup_id(backup_id) + if backup is None or backup.id is None: + return + await self._items.by_drive_item_id(backup.id).delete() @handle_backup_errors async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]: @@ -218,18 +223,12 @@ class OneDriveBackupAgent(BackupAgent): self, backup_id: str, **kwargs: Any ) -> AgentBackup | None: """Return a backup.""" - try: - drive_item = await self._get_backup_file_item(backup_id).get() - except APIError as err: - if err.response_status_code == 404: - return None - raise - if ( - drive_item is not None - and (description := drive_item.description) is not None - ): - return self._backup_from_description(description) - return None + backup = await self._find_item_by_backup_id(backup_id) + if backup is None: + return None + + assert backup.description # already checked in _find_item_by_backup_id + return self._backup_from_description(backup.description) def _backup_from_description(self, description: str) -> AgentBackup: """Create a backup object from a description.""" @@ -238,8 +237,20 @@ class OneDriveBackupAgent(BackupAgent): ) # OneDrive encodes the description on save automatically return AgentBackup.from_dict(json.loads(description)) + async def _find_item_by_backup_id(self, backup_id: str) -> DriveItem | None: + """Find a backup item by its backup ID.""" + + items = await self._items.by_drive_item_id(f"{self._folder_id}").children.get() + if items and (values := items.value): + for item in values: + if (description := item.description) is None: + continue + if backup_id in description: + return item + return None + def _get_backup_file_item(self, backup_id: str) -> DriveItemItemRequestBuilder: - return self._items.by_drive_item_id(f"{self._folder_id}:/{backup_id}.tar:") + return self._items.by_drive_item_id(f"{self._folder_id}:/{backup_id}:") async def _upload_file( self, upload_url: str, stream: AsyncIterator[bytes], total_size: int diff --git a/tests/components/onedrive/conftest.py b/tests/components/onedrive/conftest.py index 649966a7828..205f5837ee7 100644 --- a/tests/components/onedrive/conftest.py +++ b/tests/components/onedrive/conftest.py @@ -125,7 +125,10 @@ def mock_graph_client(mock_adapter: MagicMock) -> Generator[MagicMock]: drive_items.children.get = AsyncMock( return_value=DriveItemCollectionResponse( value=[ - DriveItem(description=escape(dumps(BACKUP_METADATA))), + DriveItem( + id=BACKUP_METADATA["backup_id"], + description=escape(dumps(BACKUP_METADATA)), + ), DriveItem(), ] ) diff --git a/tests/components/onedrive/test_backup.py b/tests/components/onedrive/test_backup.py index 162ecb7d92a..0114d924e1a 100644 --- a/tests/components/onedrive/test_backup.py +++ b/tests/components/onedrive/test_backup.py @@ -164,7 +164,7 @@ async def test_agents_delete_not_found_does_not_throw( mock_drive_items: MagicMock, ) -> None: """Test agent delete backup.""" - mock_drive_items.delete = AsyncMock(side_effect=APIError(response_status_code=404)) + mock_drive_items.children.get = AsyncMock(return_value=[]) client = await hass_ws_client(hass) await client.send_json_auto_id( @@ -177,7 +177,7 @@ async def test_agents_delete_not_found_does_not_throw( assert response["success"] assert response["result"] == {"agent_errors": {}} - mock_drive_items.delete.assert_called_once() + assert mock_drive_items.delete.call_count == 0 async def test_agents_upload( @@ -448,22 +448,14 @@ async def test_delete_error( } -@pytest.mark.parametrize( - "problem", - [ - AsyncMock(return_value=None), - AsyncMock(side_effect=APIError(response_status_code=404)), - ], -) async def test_agents_backup_not_found( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, mock_drive_items: MagicMock, - problem: AsyncMock, ) -> None: """Test backup not found.""" - mock_drive_items.get = problem + mock_drive_items.children.get = AsyncMock(return_value=[]) backup_id = BACKUP_METADATA["backup_id"] client = await hass_ws_client(hass) await client.send_json_auto_id({"type": "backup/details", "backup_id": backup_id}) @@ -473,26 +465,6 @@ async def test_agents_backup_not_found( assert response["result"]["backup"] is None -async def test_agents_backup_error( - hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, - mock_drive_items: MagicMock, - mock_config_entry: MockConfigEntry, -) -> None: - """Test backup not found.""" - - mock_drive_items.get = AsyncMock(side_effect=APIError(response_status_code=500)) - backup_id = BACKUP_METADATA["backup_id"] - client = await hass_ws_client(hass) - await client.send_json_auto_id({"type": "backup/details", "backup_id": backup_id}) - response = await client.receive_json() - - assert response["success"] - assert response["result"]["agent_errors"] == { - f"{DOMAIN}.{mock_config_entry.unique_id}": "Backup operation failed" - } - - async def test_reauth_on_403( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, @@ -501,7 +473,9 @@ async def test_reauth_on_403( ) -> None: """Test we re-authenticate on 403.""" - mock_drive_items.get = AsyncMock(side_effect=APIError(response_status_code=403)) + mock_drive_items.children.get = AsyncMock( + side_effect=APIError(response_status_code=403) + ) backup_id = BACKUP_METADATA["backup_id"] client = await hass_ws_client(hass) await client.send_json_auto_id({"type": "backup/details", "backup_id": backup_id}) From 164d38ac0df5b590ef18dd0bc9481da1e674da85 Mon Sep 17 00:00:00 2001 From: Ernst Klamer Date: Fri, 31 Jan 2025 21:03:17 +0100 Subject: [PATCH 0402/3148] Bump bthome-ble to 3.11.0 (#137032) bump bthome-ble to 3.11.0 --- homeassistant/components/bthome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/bthome/manifest.json b/homeassistant/components/bthome/manifest.json index ad06f648d14..3783c087971 100644 --- a/homeassistant/components/bthome/manifest.json +++ b/homeassistant/components/bthome/manifest.json @@ -20,5 +20,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/bthome", "iot_class": "local_push", - "requirements": ["bthome-ble==3.9.1"] + "requirements": ["bthome-ble==3.11.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index bb48565e2ee..b6b21975aee 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -668,7 +668,7 @@ brunt==1.2.0 bt-proximity==0.2.1 # homeassistant.components.bthome -bthome-ble==3.9.1 +bthome-ble==3.11.0 # homeassistant.components.bt_home_hub_5 bthomehub5-devicelist==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1695de16332..d8c9fd3613d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -585,7 +585,7 @@ brottsplatskartan==1.0.5 brunt==1.2.0 # homeassistant.components.bthome -bthome-ble==3.9.1 +bthome-ble==3.11.0 # homeassistant.components.buienradar buienradar==1.0.6 From 7103ea7e8f4a0f9def0731829f18c30cc3d1d5c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joris=20Pelgr=C3=B6m?= Date: Fri, 31 Jan 2025 21:28:23 +0100 Subject: [PATCH 0403/3148] Add exception handling for updating LetPot time entities (#137033) * Handle exceptions for entity edits for LetPot * Set exception-translations: done --- homeassistant/components/letpot/entity.py | 30 +++++++++++ .../components/letpot/quality_scale.yaml | 4 +- homeassistant/components/letpot/strings.json | 8 +++ homeassistant/components/letpot/time.py | 3 +- tests/components/letpot/test_time.py | 52 +++++++++++++++++++ 5 files changed, 94 insertions(+), 3 deletions(-) create mode 100644 tests/components/letpot/test_time.py diff --git a/homeassistant/components/letpot/entity.py b/homeassistant/components/letpot/entity.py index c9a8953b5d5..b4d505f4092 100644 --- a/homeassistant/components/letpot/entity.py +++ b/homeassistant/components/letpot/entity.py @@ -1,5 +1,11 @@ """Base class for LetPot entities.""" +from collections.abc import Callable, Coroutine +from typing import Any, Concatenate + +from letpot.exceptions import LetPotConnectionException, LetPotException + +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -23,3 +29,27 @@ class LetPotEntity(CoordinatorEntity[LetPotDeviceCoordinator]): model_id=coordinator.device_client.device_model_code, serial_number=coordinator.device.serial_number, ) + + +def exception_handler[_EntityT: LetPotEntity, **_P]( + func: Callable[Concatenate[_EntityT, _P], Coroutine[Any, Any, Any]], +) -> Callable[Concatenate[_EntityT, _P], Coroutine[Any, Any, None]]: + """Decorate the function to catch LetPot exceptions and raise them correctly.""" + + async def handler(self: _EntityT, *args: _P.args, **kwargs: _P.kwargs) -> None: + try: + await func(self, *args, **kwargs) + except LetPotConnectionException as exception: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="communication_error", + translation_placeholders={"exception": str(exception)}, + ) from exception + except LetPotException as exception: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="unknown_error", + translation_placeholders={"exception": str(exception)}, + ) from exception + + return handler diff --git a/homeassistant/components/letpot/quality_scale.yaml b/homeassistant/components/letpot/quality_scale.yaml index 74b948ffbf7..7f8c3d3c04c 100644 --- a/homeassistant/components/letpot/quality_scale.yaml +++ b/homeassistant/components/letpot/quality_scale.yaml @@ -29,7 +29,7 @@ rules: unique-config-entry: done # Silver - action-exceptions: todo + action-exceptions: done config-entry-unloading: status: done comment: | @@ -63,7 +63,7 @@ rules: entity-device-class: todo entity-disabled-by-default: todo entity-translations: done - exception-translations: todo + exception-translations: done icon-translations: todo reconfiguration-flow: todo repair-issues: todo diff --git a/homeassistant/components/letpot/strings.json b/homeassistant/components/letpot/strings.json index 93913c2bc4d..94d3ad02cfa 100644 --- a/homeassistant/components/letpot/strings.json +++ b/homeassistant/components/letpot/strings.json @@ -40,5 +40,13 @@ "name": "Light on" } } + }, + "exceptions": { + "communication_error": { + "message": "An error occurred while communicating with the LetPot device: {exception}" + }, + "unknown_error": { + "message": "An unknown error occurred while communicating with the LetPot device: {exception}" + } } } diff --git a/homeassistant/components/letpot/time.py b/homeassistant/components/letpot/time.py index 229f02e0806..80ce9743d8c 100644 --- a/homeassistant/components/letpot/time.py +++ b/homeassistant/components/letpot/time.py @@ -15,7 +15,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import LetPotConfigEntry from .coordinator import LetPotDeviceCoordinator -from .entity import LetPotEntity +from .entity import LetPotEntity, exception_handler # Each change pushes a 'full' device status with the change. The library will cache # pending changes to avoid overwriting, but try to avoid a lot of parallelism. @@ -86,6 +86,7 @@ class LetPotTimeEntity(LetPotEntity, TimeEntity): """Return the time.""" return self.entity_description.value_fn(self.coordinator.data) + @exception_handler async def async_set_value(self, value: time) -> None: """Set the time.""" await self.entity_description.set_value_fn( diff --git a/tests/components/letpot/test_time.py b/tests/components/letpot/test_time.py new file mode 100644 index 00000000000..44a03e565c0 --- /dev/null +++ b/tests/components/letpot/test_time.py @@ -0,0 +1,52 @@ +"""Test time entities for the LetPot integration.""" + +from datetime import time +from unittest.mock import MagicMock + +from letpot.exceptions import LetPotConnectionException, LetPotException +import pytest + +from homeassistant.components.time import SERVICE_SET_VALUE +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError + +from . import setup_integration + +from tests.common import MockConfigEntry + + +@pytest.mark.parametrize( + ("exception", "user_error"), + [ + ( + LetPotConnectionException("Connection failed"), + "An error occurred while communicating with the LetPot device: Connection failed", + ), + ( + LetPotException("Random thing failed"), + "An unknown error occurred while communicating with the LetPot device: Random thing failed", + ), + ], +) +async def test_time_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_client: MagicMock, + mock_device_client: MagicMock, + exception: Exception, + user_error: str, +) -> None: + """Test time entity exception handling.""" + await setup_integration(hass, mock_config_entry) + + mock_device_client.set_light_schedule.side_effect = exception + + assert hass.states.get("time.garden_light_on") is not None + with pytest.raises(HomeAssistantError, match=user_error): + await hass.services.async_call( + "time", + SERVICE_SET_VALUE, + service_data={"time": time(hour=7, minute=0)}, + blocking=True, + target={"entity_id": "time.garden_light_on"}, + ) From d51e72cd9500c201f9e11c363fe7d8ce63519406 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 31 Jan 2025 21:29:31 +0100 Subject: [PATCH 0404/3148] Update Overseerr string to mention CSRF (#137001) * Update Overseerr string to mention CSRF * Update homeassistant/components/overseerr/strings.json * Update homeassistant/components/overseerr/strings.json --------- Co-authored-by: Paulus Schoutsen --- homeassistant/components/overseerr/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/overseerr/strings.json b/homeassistant/components/overseerr/strings.json index 5053bcedc41..14650fd5c25 100644 --- a/homeassistant/components/overseerr/strings.json +++ b/homeassistant/components/overseerr/strings.json @@ -27,7 +27,7 @@ }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "invalid_auth": "Authentication failed. Your API key is invalid or CSRF protection is turned on, preventing authentication.", "invalid_host": "The provided URL is not a valid host." } }, From 7a0400154e4a4e4f5f2def5b7b64c9aa17ee8094 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 31 Jan 2025 15:00:39 -0600 Subject: [PATCH 0405/3148] Bump zeroconf to 0.143.0 (#137035) --- homeassistant/components/zeroconf/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/zeroconf/manifest.json b/homeassistant/components/zeroconf/manifest.json index be6f2d111d7..f4a78cd99e9 100644 --- a/homeassistant/components/zeroconf/manifest.json +++ b/homeassistant/components/zeroconf/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "loggers": ["zeroconf"], "quality_scale": "internal", - "requirements": ["zeroconf==0.142.0"] + "requirements": ["zeroconf==0.143.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 0a1b97abc55..88527d7169a 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -73,7 +73,7 @@ voluptuous-serialize==2.6.0 voluptuous==0.15.2 webrtc-models==0.3.0 yarl==1.18.3 -zeroconf==0.142.0 +zeroconf==0.143.0 # Constrain pycryptodome to avoid vulnerability # see https://github.com/home-assistant/core/pull/16238 diff --git a/pyproject.toml b/pyproject.toml index 3ad3240907c..5c3b794569c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -82,7 +82,7 @@ dependencies = [ "voluptuous-openapi==0.0.6", "yarl==1.18.3", "webrtc-models==0.3.0", - "zeroconf==0.142.0" + "zeroconf==0.143.0" ] [project.urls] diff --git a/requirements.txt b/requirements.txt index 02f3849148b..13f19304cbb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -51,4 +51,4 @@ voluptuous-serialize==2.6.0 voluptuous-openapi==0.0.6 yarl==1.18.3 webrtc-models==0.3.0 -zeroconf==0.142.0 +zeroconf==0.143.0 diff --git a/requirements_all.txt b/requirements_all.txt index b6b21975aee..4e950d754f4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3125,7 +3125,7 @@ zamg==0.3.6 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.142.0 +zeroconf==0.143.0 # homeassistant.components.zeversolar zeversolar==0.3.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d8c9fd3613d..e894044334f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2514,7 +2514,7 @@ yt-dlp[default]==2025.01.26 zamg==0.3.6 # homeassistant.components.zeroconf -zeroconf==0.142.0 +zeroconf==0.143.0 # homeassistant.components.zeversolar zeversolar==0.3.2 From dc7f44535639bf9c55965a58ef8db4ba30157ea2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 31 Jan 2025 15:18:19 -0600 Subject: [PATCH 0406/3148] Bump bthome-ble to 3.12.3 (#137036) --- homeassistant/components/bthome/manifest.json | 2 +- homeassistant/components/bthome/sensor.py | 36 +++++++++++++------ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 29 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/bthome/manifest.json b/homeassistant/components/bthome/manifest.json index 3783c087971..c8577113804 100644 --- a/homeassistant/components/bthome/manifest.json +++ b/homeassistant/components/bthome/manifest.json @@ -20,5 +20,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/bthome", "iot_class": "local_push", - "requirements": ["bthome-ble==3.11.0"] + "requirements": ["bthome-ble==3.12.3"] } diff --git a/homeassistant/components/bthome/sensor.py b/homeassistant/components/bthome/sensor.py index 417df9f5068..e46cbbea700 100644 --- a/homeassistant/components/bthome/sensor.py +++ b/homeassistant/components/bthome/sensor.py @@ -67,6 +67,16 @@ SENSOR_DESCRIPTIONS = { state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, ), + # Conductivity (µS/cm) + ( + BTHomeSensorDeviceClass.CONDUCTIVITY, + Units.CONDUCTIVITY, + ): SensorEntityDescription( + key=f"{BTHomeSensorDeviceClass.CONDUCTIVITY}_{Units.CONDUCTIVITY}", + device_class=SensorDeviceClass.CONDUCTIVITY, + native_unit_of_measurement=UnitOfConductivity.MICROSIEMENS_PER_CM, + state_class=SensorStateClass.MEASUREMENT, + ), # Count (-) (BTHomeSensorDeviceClass.COUNT, None): SensorEntityDescription( key=str(BTHomeSensorDeviceClass.COUNT), @@ -99,6 +109,12 @@ SENSOR_DESCRIPTIONS = { native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, ), + # Directions (°) + (BTHomeExtendedSensorDeviceClass.DIRECTION, Units.DEGREE): SensorEntityDescription( + key=f"{BTHomeExtendedSensorDeviceClass.DIRECTION}_{Units.DEGREE}", + native_unit_of_measurement=DEGREE, + state_class=SensorStateClass.MEASUREMENT, + ), # Distance (mm) ( BTHomeSensorDeviceClass.DISTANCE, @@ -221,6 +237,16 @@ SENSOR_DESCRIPTIONS = { native_unit_of_measurement=UnitOfPower.WATT, state_class=SensorStateClass.MEASUREMENT, ), + # Precipitation (mm) + ( + BTHomeExtendedSensorDeviceClass.PRECIPITATION, + Units.LENGTH_MILLIMETERS, + ): SensorEntityDescription( + key=f"{BTHomeExtendedSensorDeviceClass.PRECIPITATION}_{Units.LENGTH_MILLIMETERS}", + device_class=SensorDeviceClass.PRECIPITATION, + native_unit_of_measurement=UnitOfLength.MILLIMETERS, + state_class=SensorStateClass.MEASUREMENT, + ), # Pressure (mbar) (BTHomeSensorDeviceClass.PRESSURE, Units.PRESSURE_MBAR): SensorEntityDescription( key=f"{BTHomeSensorDeviceClass.PRESSURE}_{Units.PRESSURE_MBAR}", @@ -357,16 +383,6 @@ SENSOR_DESCRIPTIONS = { native_unit_of_measurement=UnitOfVolume.LITERS, state_class=SensorStateClass.TOTAL, ), - # Conductivity (µS/cm) - ( - BTHomeSensorDeviceClass.CONDUCTIVITY, - Units.CONDUCTIVITY, - ): SensorEntityDescription( - key=f"{BTHomeSensorDeviceClass.CONDUCTIVITY}_{Units.CONDUCTIVITY}", - device_class=SensorDeviceClass.CONDUCTIVITY, - native_unit_of_measurement=UnitOfConductivity.MICROSIEMENS_PER_CM, - state_class=SensorStateClass.MEASUREMENT, - ), } diff --git a/requirements_all.txt b/requirements_all.txt index 4e950d754f4..80ac251e862 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -668,7 +668,7 @@ brunt==1.2.0 bt-proximity==0.2.1 # homeassistant.components.bthome -bthome-ble==3.11.0 +bthome-ble==3.12.3 # homeassistant.components.bt_home_hub_5 bthomehub5-devicelist==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e894044334f..492b67251fc 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -585,7 +585,7 @@ brottsplatskartan==1.0.5 brunt==1.2.0 # homeassistant.components.bthome -bthome-ble==3.11.0 +bthome-ble==3.12.3 # homeassistant.components.buienradar buienradar==1.0.6 From 5fa5bd130273a71f922730be49993b47c7b50e42 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 31 Jan 2025 16:30:20 -0600 Subject: [PATCH 0407/3148] Bump aiohttp-asyncmdnsresolver to 0.0.3 (#137040) --- 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 88527d7169a..76bfa8b1ded 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -4,7 +4,7 @@ aiodhcpwatcher==1.0.2 aiodiscover==2.1.0 aiodns==3.2.0 aiohasupervisor==0.2.2b6 -aiohttp-asyncmdnsresolver==0.0.2 +aiohttp-asyncmdnsresolver==0.0.3 aiohttp-fast-zlib==0.2.0 aiohttp==3.11.11 aiohttp_cors==0.7.0 diff --git a/pyproject.toml b/pyproject.toml index 5c3b794569c..afed8fd7091 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,7 +31,7 @@ dependencies = [ "aiohttp==3.11.11", "aiohttp_cors==0.7.0", "aiohttp-fast-zlib==0.2.0", - "aiohttp-asyncmdnsresolver==0.0.2", + "aiohttp-asyncmdnsresolver==0.0.3", "aiozoneinfo==0.2.1", "astral==2.2", "async-interrupt==1.2.0", diff --git a/requirements.txt b/requirements.txt index 13f19304cbb..a58065a3a7a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,7 +8,7 @@ aiohasupervisor==0.2.2b6 aiohttp==3.11.11 aiohttp_cors==0.7.0 aiohttp-fast-zlib==0.2.0 -aiohttp-asyncmdnsresolver==0.0.2 +aiohttp-asyncmdnsresolver==0.0.3 aiozoneinfo==0.2.1 astral==2.2 async-interrupt==1.2.0 From 7040614433de7cf44c3ee1e1defcf5381eb6aef4 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Fri, 31 Jan 2025 23:56:45 +0100 Subject: [PATCH 0408/3148] Fix one occurrence of "api" to match all other in sensibo and HA (#137037) --- homeassistant/components/sensibo/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/sensibo/strings.json b/homeassistant/components/sensibo/strings.json index c5ff0f135e6..6c5210d12bf 100644 --- a/homeassistant/components/sensibo/strings.json +++ b/homeassistant/components/sensibo/strings.json @@ -18,7 +18,7 @@ "api_key": "[%key:common::config_flow::data::api_key%]" }, "data_description": { - "api_key": "Follow the [documentation]({url}) to get your api key" + "api_key": "Follow the [documentation]({url}) to get your API key" } }, "reauth_confirm": { From c35e7715b7c830e23de90751b44da2521c155d4b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 31 Jan 2025 18:13:27 -0600 Subject: [PATCH 0409/3148] Bump habluetooth to 3.17.1 (#137045) --- .../components/bluetooth/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../components/bluetooth/test_diagnostics.py | 33 +++++++++++++++++-- .../bluetooth/test_websocket_api.py | 22 +++++++++++-- 6 files changed, 54 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index d6ed9281099..51358f8a656 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -21,6 +21,6 @@ "bluetooth-auto-recovery==1.4.2", "bluetooth-data-tools==1.22.0", "dbus-fast==2.30.2", - "habluetooth==3.17.0" + "habluetooth==3.17.1" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 76bfa8b1ded..40bb031d2ec 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -33,7 +33,7 @@ dbus-fast==2.30.2 fnv-hash-fast==1.2.2 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 -habluetooth==3.17.0 +habluetooth==3.17.1 hass-nabucasa==0.88.1 hassil==2.2.0 home-assistant-bluetooth==1.13.0 diff --git a/requirements_all.txt b/requirements_all.txt index 80ac251e862..a4df828bb66 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1103,7 +1103,7 @@ ha-philipsjs==3.2.2 habiticalib==0.3.3 # homeassistant.components.bluetooth -habluetooth==3.17.0 +habluetooth==3.17.1 # homeassistant.components.cloud hass-nabucasa==0.88.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 492b67251fc..ac40911c5bc 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -944,7 +944,7 @@ ha-philipsjs==3.2.2 habiticalib==0.3.3 # homeassistant.components.bluetooth -habluetooth==3.17.0 +habluetooth==3.17.1 # homeassistant.components.cloud hass-nabucasa==0.88.1 diff --git a/tests/components/bluetooth/test_diagnostics.py b/tests/components/bluetooth/test_diagnostics.py index 384eae7e49a..682cff62969 100644 --- a/tests/components/bluetooth/test_diagnostics.py +++ b/tests/components/bluetooth/test_diagnostics.py @@ -133,7 +133,20 @@ async def test_diagnostics( } }, "manager": { - "allocations": {}, + "allocations": { + "00:00:00:00:00:01": { + "allocated": [], + "free": 5, + "slots": 5, + "source": "00:00:00:00:00:01", + }, + "00:00:00:00:00:02": { + "allocated": [], + "free": 2, + "slots": 2, + "source": "00:00:00:00:00:02", + }, + }, "adapters": { "hci0": { "address": "00:00:00:00:00:01", @@ -292,7 +305,14 @@ async def test_diagnostics_macos( } }, "manager": { - "allocations": {}, + "allocations": { + "Core Bluetooth": { + "allocated": [], + "free": 5, + "slots": 5, + "source": "Core Bluetooth", + }, + }, "adapters": { "Core Bluetooth": { "address": "00:00:00:00:00:00", @@ -486,7 +506,14 @@ async def test_diagnostics_remote_adapter( }, "dbus": {}, "manager": { - "allocations": {}, + "allocations": { + "00:00:00:00:00:01": { + "allocated": [], + "free": 5, + "slots": 5, + "source": "00:00:00:00:00:01", + }, + }, "adapters": { "hci0": { "address": "00:00:00:00:00:01", diff --git a/tests/components/bluetooth/test_websocket_api.py b/tests/components/bluetooth/test_websocket_api.py index bacdbbd5eed..57199d04078 100644 --- a/tests/components/bluetooth/test_websocket_api.py +++ b/tests/components/bluetooth/test_websocket_api.py @@ -159,12 +159,30 @@ async def test_subscribe_connection_allocations( response = await client.receive_json() assert response["event"] == [ + { + "allocated": [], + "free": 5, + "slots": 5, + "source": "00:00:00:00:00:01", + }, + { + "allocated": [], + "free": 5, + "slots": 5, + "source": HCI0_SOURCE_ADDRESS, + }, + { + "allocated": [], + "free": 5, + "slots": 5, + "source": HCI1_SOURCE_ADDRESS, + }, { "allocated": [], "free": 0, "slots": 0, "source": NON_CONNECTABLE_REMOTE_SOURCE_ADDRESS, - } + }, ] manager = _get_manager() @@ -184,7 +202,7 @@ async def test_subscribe_connection_allocations( "free": 4, "slots": 5, "source": "AA:BB:CC:DD:EE:11", - } + }, ] manager.async_on_allocation_changed( Allocations( From e56772d37b1cba5143bbf8042c1f78c0587d5585 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sat, 1 Feb 2025 01:38:11 +0100 Subject: [PATCH 0410/3148] Bump aioimaplib to version 2.0.1 (#137049) --- homeassistant/components/imap/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/imap/manifest.json b/homeassistant/components/imap/manifest.json index a3370de94ca..515fee0e721 100644 --- a/homeassistant/components/imap/manifest.json +++ b/homeassistant/components/imap/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/imap", "iot_class": "cloud_push", "loggers": ["aioimaplib"], - "requirements": ["aioimaplib==2.0.0"] + "requirements": ["aioimaplib==2.0.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index a4df828bb66..ce9c538fbc1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -276,7 +276,7 @@ aiohttp_sse==2.2.0 aiohue==4.7.3 # homeassistant.components.imap -aioimaplib==2.0.0 +aioimaplib==2.0.1 # homeassistant.components.apache_kafka aiokafka==0.10.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ac40911c5bc..bd338b85532 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -261,7 +261,7 @@ aiohttp_sse==2.2.0 aiohue==4.7.3 # homeassistant.components.imap -aioimaplib==2.0.0 +aioimaplib==2.0.1 # homeassistant.components.apache_kafka aiokafka==0.10.0 From 5da9bfe0e3b658e12baa710948b99ae1cc5e7cb5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Sat, 1 Feb 2025 01:03:20 +0000 Subject: [PATCH 0411/3148] Add dev docs and frontend PR links to PR template (#137034) --- .github/PULL_REQUEST_TEMPLATE.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 23365feffb7..792dacd8032 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -46,6 +46,8 @@ - This PR fixes or closes issue: fixes # - This PR is related to issue: - Link to documentation pull request: +- Link to developer documentation pull request: +- Link to frontend pull request: ## Checklist 2: Use config entry ID as base for unique IDs. @@ -152,7 +131,9 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> async def _async_migrate_device_identifiers( - hass: HomeAssistant, config_entry: ConfigEntry, old_unique_id: str | None + hass: HomeAssistant, + config_entry: MinecraftServerConfigEntry, + old_unique_id: str | None, ) -> None: """Migrate the device identifiers to the new format.""" device_registry = dr.async_get(hass) diff --git a/homeassistant/components/minecraft_server/binary_sensor.py b/homeassistant/components/minecraft_server/binary_sensor.py index d2c8aca57e4..39e12228451 100644 --- a/homeassistant/components/minecraft_server/binary_sensor.py +++ b/homeassistant/components/minecraft_server/binary_sensor.py @@ -5,12 +5,10 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN -from .coordinator import MinecraftServerCoordinator +from .coordinator import MinecraftServerConfigEntry, MinecraftServerCoordinator from .entity import MinecraftServerEntity KEY_STATUS = "status" @@ -27,11 +25,11 @@ BINARY_SENSOR_DESCRIPTIONS = [ async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: MinecraftServerConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Minecraft Server binary sensor platform.""" - coordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data # Add binary sensor entities. async_add_entities( @@ -49,7 +47,7 @@ class MinecraftServerBinarySensorEntity(MinecraftServerEntity, BinarySensorEntit self, coordinator: MinecraftServerCoordinator, description: BinarySensorEntityDescription, - config_entry: ConfigEntry, + config_entry: MinecraftServerConfigEntry, ) -> None: """Initialize binary sensor base entity.""" super().__init__(coordinator, config_entry) diff --git a/homeassistant/components/minecraft_server/coordinator.py b/homeassistant/components/minecraft_server/coordinator.py index f66e4acf214..2cd1c1a94ab 100644 --- a/homeassistant/components/minecraft_server/coordinator.py +++ b/homeassistant/components/minecraft_server/coordinator.py @@ -6,17 +6,22 @@ from datetime import timedelta import logging from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_NAME +from homeassistant.const import CONF_ADDRESS, CONF_NAME, CONF_TYPE from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .api import ( MinecraftServer, + MinecraftServerAddressError, MinecraftServerConnectionError, MinecraftServerData, MinecraftServerNotInitializedError, + MinecraftServerType, ) +type MinecraftServerConfigEntry = ConfigEntry[MinecraftServerCoordinator] + SCAN_INTERVAL = timedelta(seconds=60) _LOGGER = logging.getLogger(__name__) @@ -25,16 +30,15 @@ _LOGGER = logging.getLogger(__name__) class MinecraftServerCoordinator(DataUpdateCoordinator[MinecraftServerData]): """Minecraft Server data update coordinator.""" - config_entry: ConfigEntry + config_entry: MinecraftServerConfigEntry + _api: MinecraftServer def __init__( self, hass: HomeAssistant, - config_entry: ConfigEntry, - api: MinecraftServer, + config_entry: MinecraftServerConfigEntry, ) -> None: """Initialize coordinator instance.""" - self._api = api super().__init__( hass=hass, @@ -44,6 +48,22 @@ class MinecraftServerCoordinator(DataUpdateCoordinator[MinecraftServerData]): update_interval=SCAN_INTERVAL, ) + async def _async_setup(self) -> None: + """Set up the Minecraft Server data coordinator.""" + + # Create API instance. + self._api = MinecraftServer( + self.hass, + self.config_entry.data.get(CONF_TYPE, MinecraftServerType.JAVA_EDITION), + self.config_entry.data[CONF_ADDRESS], + ) + + # Initialize API instance. + try: + await self._api.async_initialize() + except MinecraftServerAddressError as error: + raise ConfigEntryNotReady(f"Initialization failed: {error}") from error + async def _async_update_data(self) -> MinecraftServerData: """Get updated data from the server.""" try: diff --git a/homeassistant/components/minecraft_server/diagnostics.py b/homeassistant/components/minecraft_server/diagnostics.py index 0bcffe1434a..61a65f9c2dd 100644 --- a/homeassistant/components/minecraft_server/diagnostics.py +++ b/homeassistant/components/minecraft_server/diagnostics.py @@ -5,20 +5,19 @@ from dataclasses import asdict from typing import Any from homeassistant.components.diagnostics import async_redact_data -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ADDRESS, CONF_NAME from homeassistant.core import HomeAssistant -from .const import DOMAIN +from .coordinator import MinecraftServerConfigEntry TO_REDACT: Iterable[Any] = {CONF_ADDRESS, CONF_NAME, "players_list"} async def async_get_config_entry_diagnostics( - hass: HomeAssistant, config_entry: ConfigEntry + hass: HomeAssistant, config_entry: MinecraftServerConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - coordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data return { "config_entry": { diff --git a/homeassistant/components/minecraft_server/quality_scale.yaml b/homeassistant/components/minecraft_server/quality_scale.yaml index fc3db3b3075..eeda413f2ad 100644 --- a/homeassistant/components/minecraft_server/quality_scale.yaml +++ b/homeassistant/components/minecraft_server/quality_scale.yaml @@ -29,7 +29,7 @@ rules: status: done comment: Using confid entry ID as the dependency mcstatus doesn't provide a unique information. has-entity-name: done - runtime-data: todo + runtime-data: done test-before-configure: done test-before-setup: status: done diff --git a/homeassistant/components/minecraft_server/sensor.py b/homeassistant/components/minecraft_server/sensor.py index 50571123003..6effa53fbf2 100644 --- a/homeassistant/components/minecraft_server/sensor.py +++ b/homeassistant/components/minecraft_server/sensor.py @@ -7,15 +7,14 @@ from dataclasses import dataclass from typing import Any from homeassistant.components.sensor import SensorEntity, SensorEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_TYPE, EntityCategory, UnitOfTime from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from .api import MinecraftServerData, MinecraftServerType -from .const import DOMAIN, KEY_LATENCY, KEY_MOTD -from .coordinator import MinecraftServerCoordinator +from .const import KEY_LATENCY, KEY_MOTD +from .coordinator import MinecraftServerConfigEntry, MinecraftServerCoordinator from .entity import MinecraftServerEntity ATTR_PLAYERS_LIST = "players_list" @@ -158,11 +157,11 @@ SENSOR_DESCRIPTIONS = [ async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: MinecraftServerConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Minecraft Server sensor platform.""" - coordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data # Add sensor entities. async_add_entities( @@ -184,7 +183,7 @@ class MinecraftServerSensorEntity(MinecraftServerEntity, SensorEntity): self, coordinator: MinecraftServerCoordinator, description: MinecraftServerSensorEntityDescription, - config_entry: ConfigEntry, + config_entry: MinecraftServerConfigEntry, ) -> None: """Initialize sensor base entity.""" super().__init__(coordinator, config_entry) From 648c750a0fd2e7a7da4fe8e78b1dc38402f0f23b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Feb 2025 13:21:21 -0600 Subject: [PATCH 1684/3148] Bump ulid-transform to 1.2.1 (#139054) changelog: https://github.com/Bluetooth-Devices/ulid-transform/compare/v1.2.0...v1.2.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 7847599223c..40f7e511332 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -65,7 +65,7 @@ SQLAlchemy==2.0.38 standard-aifc==3.13.0 standard-telnetlib==3.13.0 typing-extensions>=4.12.2,<5.0 -ulid-transform==1.2.0 +ulid-transform==1.2.1 urllib3>=1.26.5,<2 uv==0.6.1 voluptuous-openapi==0.0.6 diff --git a/pyproject.toml b/pyproject.toml index 0a4228496e3..b43e4d284ca 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -71,7 +71,7 @@ dependencies = [ "standard-aifc==3.13.0", "standard-telnetlib==3.13.0", "typing-extensions>=4.12.2,<5.0", - "ulid-transform==1.2.0", + "ulid-transform==1.2.1", # Constrain urllib3 to ensure we deal with CVE-2020-26137 and CVE-2021-33503 # Temporary setting an upper bound, to prevent compat issues with urllib3>=2 # https://github.com/home-assistant/core/issues/97248 diff --git a/requirements.txt b/requirements.txt index 2bacda6b017..962cab71a53 100644 --- a/requirements.txt +++ b/requirements.txt @@ -43,7 +43,7 @@ SQLAlchemy==2.0.38 standard-aifc==3.13.0 standard-telnetlib==3.13.0 typing-extensions>=4.12.2,<5.0 -ulid-transform==1.2.0 +ulid-transform==1.2.1 urllib3>=1.26.5,<2 uv==0.6.1 voluptuous==0.15.2 From f3dd772b4386b94f5d96477c55f614ae2e607459 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Frederic=20Mari=C3=ABn?= Date: Sat, 22 Feb 2025 20:25:19 +0100 Subject: [PATCH 1685/3148] Bump pyrisco to 0.6.7 (#139065) --- homeassistant/components/risco/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/risco/manifest.json b/homeassistant/components/risco/manifest.json index 149b8761589..43d471172d6 100644 --- a/homeassistant/components/risco/manifest.json +++ b/homeassistant/components/risco/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/risco", "iot_class": "local_push", "loggers": ["pyrisco"], - "requirements": ["pyrisco==0.6.5"] + "requirements": ["pyrisco==0.6.7"] } diff --git a/requirements_all.txt b/requirements_all.txt index 90065832988..7596d1e7d5f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2250,7 +2250,7 @@ pyrecswitch==1.0.2 pyrepetierng==0.1.0 # homeassistant.components.risco -pyrisco==0.6.5 +pyrisco==0.6.7 # homeassistant.components.rituals_perfume_genie pyrituals==0.0.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b1017a3c420..0e868a77f0c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1834,7 +1834,7 @@ pyrail==0.0.3 pyrainbird==6.0.1 # homeassistant.components.risco -pyrisco==0.6.5 +pyrisco==0.6.7 # homeassistant.components.rituals_perfume_genie pyrituals==0.0.6 From 6c0c4bfd74eedf8a7faf84edc378f06d25e83170 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 22 Feb 2025 20:53:53 +0100 Subject: [PATCH 1686/3148] Bump pyfritzhome to 0.6.17 (#139066) bump pyfritzhome to 0.6.17 --- homeassistant/components/fritzbox/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/fritzbox/manifest.json b/homeassistant/components/fritzbox/manifest.json index 92405a977ee..f6155024cbf 100644 --- a/homeassistant/components/fritzbox/manifest.json +++ b/homeassistant/components/fritzbox/manifest.json @@ -7,7 +7,7 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["pyfritzhome"], - "requirements": ["pyfritzhome==0.6.16"], + "requirements": ["pyfritzhome==0.6.17"], "ssdp": [ { "st": "urn:schemas-upnp-org:device:fritzbox:1" diff --git a/requirements_all.txt b/requirements_all.txt index 7596d1e7d5f..0ffd8b7e781 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1972,7 +1972,7 @@ pyforked-daapd==0.1.14 pyfreedompro==1.1.0 # homeassistant.components.fritzbox -pyfritzhome==0.6.16 +pyfritzhome==0.6.17 # homeassistant.components.ifttt pyfttt==0.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0e868a77f0c..6d070883303 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1607,7 +1607,7 @@ pyforked-daapd==0.1.14 pyfreedompro==1.1.0 # homeassistant.components.fritzbox -pyfritzhome==0.6.16 +pyfritzhome==0.6.17 # homeassistant.components.ifttt pyfttt==0.3 From a0c278135590a8cc65ae344838f39cbf6682225c Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 22 Feb 2025 20:56:05 +0100 Subject: [PATCH 1687/3148] Fix docstring parameter in entity platform (#139070) Fix docstring --- homeassistant/helpers/entity_platform.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index adf34f3b285..11a9786f86e 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -659,7 +659,7 @@ class EntityPlatform: This method must be run in the event loop. - :param subentry_id: subentry which the entities should be added to + :param config_subentry_id: subentry which the entities should be added to """ if config_subentry_id and ( not self.config_entry From 92788a04ff0f86d17130e022b606e487af5d0b6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Sat, 22 Feb 2025 21:08:39 +0100 Subject: [PATCH 1688/3148] Add entities that represent program options to Home Connect (#138674) * Add program options as entities * Use program options constraints * Only fetch the available options on refresh * Extract the option definitions getter from the loop * Add the option entities only when it is required * Fix typo --- .../components/home_connect/common.py | 102 +++++- .../components/home_connect/coordinator.py | 101 +++++- .../components/home_connect/entity.py | 63 +++- .../components/home_connect/icons.json | 33 ++ .../components/home_connect/number.py | 91 +++++- .../components/home_connect/select.py | 245 +++++++++++++- .../components/home_connect/sensor.py | 8 +- .../components/home_connect/strings.json | 251 +++++++++++++++ .../components/home_connect/switch.py | 89 +++++- tests/components/home_connect/conftest.py | 41 +++ .../home_connect/fixtures/settings.json | 5 + .../snapshots/test_diagnostics.ambr | 1 + tests/components/home_connect/test_entity.py | 299 ++++++++++++++++++ tests/components/home_connect/test_number.py | 163 +++++++++- tests/components/home_connect/test_select.py | 152 ++++++++- tests/components/home_connect/test_switch.py | 118 ++++++- 16 files changed, 1729 insertions(+), 33 deletions(-) create mode 100644 tests/components/home_connect/test_entity.py diff --git a/homeassistant/components/home_connect/common.py b/homeassistant/components/home_connect/common.py index c27230c01d8..a9f48eea5ba 100644 --- a/homeassistant/components/home_connect/common.py +++ b/homeassistant/components/home_connect/common.py @@ -1,5 +1,6 @@ """Common callbacks for all Home Connect platforms.""" +from collections import defaultdict from collections.abc import Callable from functools import partial from typing import cast @@ -9,7 +10,32 @@ from aiohomeconnect.model import EventKey from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import HomeConnectApplianceData, HomeConnectConfigEntry -from .entity import HomeConnectEntity +from .entity import HomeConnectEntity, HomeConnectOptionEntity + + +def _create_option_entities( + entry: HomeConnectConfigEntry, + appliance: HomeConnectApplianceData, + known_entity_unique_ids: dict[str, str], + get_option_entities_for_appliance: Callable[ + [HomeConnectConfigEntry, HomeConnectApplianceData], + list[HomeConnectOptionEntity], + ], + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Create the required option entities for the appliances.""" + option_entities_to_add = [ + entity + for entity in get_option_entities_for_appliance(entry, appliance) + if entity.unique_id not in known_entity_unique_ids + ] + known_entity_unique_ids.update( + { + cast(str, entity.unique_id): appliance.info.ha_id + for entity in option_entities_to_add + } + ) + async_add_entities(option_entities_to_add) def _handle_paired_or_connected_appliance( @@ -18,6 +44,12 @@ def _handle_paired_or_connected_appliance( get_entities_for_appliance: Callable[ [HomeConnectConfigEntry, HomeConnectApplianceData], list[HomeConnectEntity] ], + get_option_entities_for_appliance: Callable[ + [HomeConnectConfigEntry, HomeConnectApplianceData], + list[HomeConnectOptionEntity], + ] + | None, + changed_options_listener_remove_callbacks: dict[str, list[Callable[[], None]]], async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Handle a new paired appliance or an appliance that has been connected. @@ -34,6 +66,28 @@ def _handle_paired_or_connected_appliance( for entity in get_entities_for_appliance(entry, appliance) if entity.unique_id not in known_entity_unique_ids ] + if get_option_entities_for_appliance: + entities_to_add.extend( + entity + for entity in get_option_entities_for_appliance(entry, appliance) + if entity.unique_id not in known_entity_unique_ids + ) + changed_options_listener_remove_callback = ( + entry.runtime_data.async_add_listener( + partial( + _create_option_entities, + entry, + appliance, + known_entity_unique_ids, + get_option_entities_for_appliance, + async_add_entities, + ), + ) + ) + entry.async_on_unload(changed_options_listener_remove_callback) + changed_options_listener_remove_callbacks[appliance.info.ha_id].append( + changed_options_listener_remove_callback + ) known_entity_unique_ids.update( { cast(str, entity.unique_id): appliance.info.ha_id @@ -47,11 +101,17 @@ def _handle_paired_or_connected_appliance( def _handle_depaired_appliance( entry: HomeConnectConfigEntry, known_entity_unique_ids: dict[str, str], + changed_options_listener_remove_callbacks: dict[str, list[Callable[[], None]]], ) -> None: """Handle a removed appliance.""" for entity_unique_id, appliance_id in known_entity_unique_ids.copy().items(): if appliance_id not in entry.runtime_data.data: known_entity_unique_ids.pop(entity_unique_id, None) + if appliance_id in changed_options_listener_remove_callbacks: + for listener in changed_options_listener_remove_callbacks.pop( + appliance_id + ): + listener() def setup_home_connect_entry( @@ -60,13 +120,44 @@ def setup_home_connect_entry( [HomeConnectConfigEntry, HomeConnectApplianceData], list[HomeConnectEntity] ], async_add_entities: AddConfigEntryEntitiesCallback, + get_option_entities_for_appliance: Callable[ + [HomeConnectConfigEntry, HomeConnectApplianceData], + list[HomeConnectOptionEntity], + ] + | None = None, ) -> None: """Set up the callbacks for paired and depaired appliances.""" known_entity_unique_ids: dict[str, str] = {} + changed_options_listener_remove_callbacks: dict[str, list[Callable[[], None]]] = ( + defaultdict(list) + ) entities: list[HomeConnectEntity] = [] for appliance in entry.runtime_data.data.values(): entities_to_add = get_entities_for_appliance(entry, appliance) + if get_option_entities_for_appliance: + entities_to_add.extend(get_option_entities_for_appliance(entry, appliance)) + for event_key in ( + EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM, + EventKey.BSH_COMMON_ROOT_SELECTED_PROGRAM, + ): + changed_options_listener_remove_callback = ( + entry.runtime_data.async_add_listener( + partial( + _create_option_entities, + entry, + appliance, + known_entity_unique_ids, + get_option_entities_for_appliance, + async_add_entities, + ), + (appliance.info.ha_id, event_key), + ) + ) + entry.async_on_unload(changed_options_listener_remove_callback) + changed_options_listener_remove_callbacks[appliance.info.ha_id].append( + changed_options_listener_remove_callback + ) known_entity_unique_ids.update( { cast(str, entity.unique_id): appliance.info.ha_id @@ -83,6 +174,8 @@ def setup_home_connect_entry( entry, known_entity_unique_ids, get_entities_for_appliance, + get_option_entities_for_appliance, + changed_options_listener_remove_callbacks, async_add_entities, ), ( @@ -93,7 +186,12 @@ def setup_home_connect_entry( ) entry.async_on_unload( entry.runtime_data.async_add_special_listener( - partial(_handle_depaired_appliance, entry, known_entity_unique_ids), + partial( + _handle_depaired_appliance, + entry, + known_entity_unique_ids, + changed_options_listener_remove_callbacks, + ), (EventKey.BSH_COMMON_APPLIANCE_DEPAIRED,), ) ) diff --git a/homeassistant/components/home_connect/coordinator.py b/homeassistant/components/home_connect/coordinator.py index ceedde7fe72..b5f0f711597 100644 --- a/homeassistant/components/home_connect/coordinator.py +++ b/homeassistant/components/home_connect/coordinator.py @@ -7,7 +7,7 @@ from collections import defaultdict from collections.abc import Callable from dataclasses import dataclass import logging -from typing import Any +from typing import Any, cast from aiohomeconnect.client import Client as HomeConnectClient from aiohomeconnect.model import ( @@ -17,6 +17,8 @@ from aiohomeconnect.model import ( EventType, GetSetting, HomeAppliance, + OptionKey, + ProgramKey, SettingKey, Status, StatusKey, @@ -28,7 +30,7 @@ from aiohomeconnect.model.error import ( HomeConnectRequestError, UnauthorizedError, ) -from aiohomeconnect.model.program import EnumerateProgram +from aiohomeconnect.model.program import EnumerateProgram, ProgramDefinitionOption from propcache.api import cached_property from homeassistant.config_entries import ConfigEntry @@ -53,6 +55,7 @@ class HomeConnectApplianceData: events: dict[EventKey, Event] info: HomeAppliance + options: dict[OptionKey, ProgramDefinitionOption] programs: list[EnumerateProgram] settings: dict[SettingKey, GetSetting] status: dict[StatusKey, Status] @@ -61,6 +64,8 @@ class HomeConnectApplianceData: """Update data with data from other instance.""" self.events.update(other.events) self.info.connected = other.info.connected + self.options.clear() + self.options.update(other.options) self.programs.clear() self.programs.extend(other.programs) self.settings.update(other.settings) @@ -172,8 +177,9 @@ class HomeConnectCoordinator( settings = self.data[event_message_ha_id].settings events = self.data[event_message_ha_id].events for event in event_message.data.items: - if event.key in SettingKey: - setting_key = SettingKey(event.key) + event_key = event.key + if event_key in SettingKey: + setting_key = SettingKey(event_key) if setting_key in settings: settings[setting_key].value = event.value else: @@ -183,7 +189,16 @@ class HomeConnectCoordinator( value=event.value, ) else: - events[event.key] = event + if event_key in ( + EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM, + EventKey.BSH_COMMON_ROOT_SELECTED_PROGRAM, + ): + await self.update_options( + event_message_ha_id, + event_key, + ProgramKey(cast(str, event.value)), + ) + events[event_key] = event self._call_event_listener(event_message) case EventType.EVENT: @@ -338,6 +353,7 @@ class HomeConnectCoordinator( programs = [] events = {} + options = {} if appliance.type in APPLIANCES_WITH_PROGRAMS: try: all_programs = await self.client.get_all_programs(appliance.ha_id) @@ -351,15 +367,17 @@ class HomeConnectCoordinator( ) else: programs.extend(all_programs.programs) + current_program_key = None + program_options = None for program, event_key in ( - ( - all_programs.active, - EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM, - ), ( all_programs.selected, EventKey.BSH_COMMON_ROOT_SELECTED_PROGRAM, ), + ( + all_programs.active, + EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM, + ), ): if program and program.key: events[event_key] = Event( @@ -370,10 +388,30 @@ class HomeConnectCoordinator( "", program.key, ) + current_program_key = program.key + program_options = program.options + if current_program_key: + options = await self.get_options_definitions( + appliance.ha_id, current_program_key + ) + for option in program_options or []: + option_event_key = EventKey(option.key) + events[option_event_key] = Event( + option_event_key, + option.key, + 0, + "", + "", + option.value, + option.name, + display_value=option.display_value, + unit=option.unit, + ) appliance_data = HomeConnectApplianceData( events=events, info=appliance, + options=options, programs=programs, settings=settings, status=status, @@ -383,3 +421,48 @@ class HomeConnectCoordinator( appliance_data = appliance_data_to_update return appliance_data + + async def get_options_definitions( + self, ha_id: str, program_key: ProgramKey + ) -> dict[OptionKey, ProgramDefinitionOption]: + """Get options with constraints for appliance.""" + return { + option.key: option + for option in ( + await self.client.get_available_program(ha_id, program_key=program_key) + ).options + or [] + } + + async def update_options( + self, ha_id: str, event_key: EventKey, program_key: ProgramKey + ) -> None: + """Update options for appliance.""" + options = self.data[ha_id].options + events = self.data[ha_id].events + options_to_notify = options.copy() + options.clear() + if program_key is not ProgramKey.UNKNOWN: + options.update(await self.get_options_definitions(ha_id, program_key)) + + for option in options.values(): + option_value = option.constraints.default if option.constraints else None + if option_value is not None: + option_event_key = EventKey(option.key) + events[option_event_key] = Event( + option_event_key, + option.key.value, + 0, + "", + "", + option_value, + option.name, + unit=option.unit, + ) + options_to_notify.update(options) + for option_key in options_to_notify: + for listener in self.context_listeners.get( + (ha_id, EventKey(option_key)), + [], + ): + listener() diff --git a/homeassistant/components/home_connect/entity.py b/homeassistant/components/home_connect/entity.py index 8eb9d757f14..52eaaecace7 100644 --- a/homeassistant/components/home_connect/entity.py +++ b/homeassistant/components/home_connect/entity.py @@ -1,17 +1,22 @@ """Home Connect entity base class.""" from abc import abstractmethod +import contextlib import logging +from typing import cast -from aiohomeconnect.model import EventKey +from aiohomeconnect.model import EventKey, OptionKey +from aiohomeconnect.model.error import ActiveProgramNotSetError, HomeConnectError from homeassistant.core import callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN from .coordinator import HomeConnectApplianceData, HomeConnectCoordinator +from .utils import get_dict_from_home_connect_error _LOGGER = logging.getLogger(__name__) @@ -60,3 +65,59 @@ class HomeConnectEntity(CoordinatorEntity[HomeConnectCoordinator]): return ( self.appliance.info.connected and self._attr_available and super().available ) + + +class HomeConnectOptionEntity(HomeConnectEntity): + """Class for entities that represents program options.""" + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return super().available and self.bsh_key in self.appliance.options + + @property + def option_value(self) -> str | int | float | bool | None: + """Return the state of the entity.""" + if event := self.appliance.events.get(EventKey(self.bsh_key)): + return event.value + return None + + async def async_set_option(self, value: str | float | bool) -> None: + """Set an option for the entity.""" + try: + # We try to set the active program option first, + # if it fails we try to set the selected program option + with contextlib.suppress(ActiveProgramNotSetError): + await self.coordinator.client.set_active_program_option( + self.appliance.info.ha_id, + option_key=self.bsh_key, + value=value, + ) + _LOGGER.debug( + "Updated %s for the active program, new state: %s", + self.entity_id, + self.state, + ) + return + + await self.coordinator.client.set_selected_program_option( + self.appliance.info.ha_id, + option_key=self.bsh_key, + value=value, + ) + _LOGGER.debug( + "Updated %s for the selected program, new state: %s", + self.entity_id, + self.state, + ) + except HomeConnectError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="set_option", + translation_placeholders=get_dict_from_home_connect_error(err), + ) from err + + @property + def bsh_key(self) -> OptionKey: + """Return the BSH key.""" + return cast(OptionKey, self.entity_description.key) diff --git a/homeassistant/components/home_connect/icons.json b/homeassistant/components/home_connect/icons.json index 6b604fc004e..651c00328b6 100644 --- a/homeassistant/components/home_connect/icons.json +++ b/homeassistant/components/home_connect/icons.json @@ -208,6 +208,39 @@ }, "door-assistant_freezer": { "default": "mdi:door" + }, + "silence_on_demand": { + "default": "mdi:volume-mute", + "state": { + "on": "mdi:volume-mute", + "off": "mdi:volume-high" + } + }, + "half_load": { + "default": "mdi:fraction-one-half" + }, + "hygiene_plus": { + "default": "mdi:silverware-clean" + }, + "eco_dry": { + "default": "mdi:sprout" + }, + "fast_pre_heat": { + "default": "mdi:fire" + }, + "i_dos_1_active": { + "default": "mdi:numeric-1-circle" + }, + "i_dos_2_active": { + "default": "mdi:numeric-2-circle" + } + }, + "time": { + "start_in_relative": { + "default": "mdi:progress-clock" + }, + "finish_in_relative": { + "default": "mdi:progress-clock" } } } diff --git a/homeassistant/components/home_connect/number.py b/homeassistant/components/home_connect/number.py index 26c4aa02372..63df33e5432 100644 --- a/homeassistant/components/home_connect/number.py +++ b/homeassistant/components/home_connect/number.py @@ -3,7 +3,7 @@ import logging from typing import cast -from aiohomeconnect.model import GetSetting, SettingKey +from aiohomeconnect.model import GetSetting, OptionKey, SettingKey from aiohomeconnect.model.error import HomeConnectError from homeassistant.components.number import ( @@ -11,6 +11,7 @@ from homeassistant.components.number import ( NumberEntity, NumberEntityDescription, ) +from homeassistant.const import UnitOfTemperature, UnitOfTime, UnitOfVolume from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -24,11 +25,17 @@ from .const import ( SVE_TRANSLATION_PLACEHOLDER_VALUE, ) from .coordinator import HomeConnectApplianceData, HomeConnectConfigEntry -from .entity import HomeConnectEntity +from .entity import HomeConnectEntity, HomeConnectOptionEntity from .utils import get_dict_from_home_connect_error _LOGGER = logging.getLogger(__name__) +UNIT_MAP = { + "seconds": UnitOfTime.SECONDS, + "ml": UnitOfVolume.MILLILITERS, + "°C": UnitOfTemperature.CELSIUS, + "°F": UnitOfTemperature.FAHRENHEIT, +} NUMBERS = ( NumberEntityDescription( @@ -88,6 +95,32 @@ NUMBERS = ( ), ) +NUMBER_OPTIONS = ( + NumberEntityDescription( + key=OptionKey.BSH_COMMON_DURATION, + translation_key="duration", + ), + NumberEntityDescription( + key=OptionKey.BSH_COMMON_FINISH_IN_RELATIVE, + translation_key="finish_in_relative", + ), + NumberEntityDescription( + key=OptionKey.BSH_COMMON_START_IN_RELATIVE, + translation_key="start_in_relative", + ), + NumberEntityDescription( + key=OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_FILL_QUANTITY, + translation_key="fill_quantity", + device_class=NumberDeviceClass.VOLUME, + native_step=1, + ), + NumberEntityDescription( + key=OptionKey.COOKING_OVEN_SETPOINT_TEMPERATURE, + translation_key="setpoint_temperature", + device_class=NumberDeviceClass.TEMPERATURE, + ), +) + def _get_entities_for_appliance( entry: HomeConnectConfigEntry, @@ -101,6 +134,18 @@ def _get_entities_for_appliance( ] +def _get_option_entities_for_appliance( + entry: HomeConnectConfigEntry, + appliance: HomeConnectApplianceData, +) -> list[HomeConnectOptionEntity]: + """Get a list of currently available option entities.""" + return [ + HomeConnectOptionNumberEntity(entry.runtime_data, appliance, description) + for description in NUMBER_OPTIONS + if description.key in appliance.options + ] + + async def async_setup_entry( hass: HomeAssistant, entry: HomeConnectConfigEntry, @@ -111,6 +156,7 @@ async def async_setup_entry( entry, _get_entities_for_appliance, async_add_entities, + _get_option_entities_for_appliance, ) @@ -184,3 +230,44 @@ class HomeConnectNumberEntity(HomeConnectEntity, NumberEntity): or not hasattr(self, "_attr_native_step") ): await self.async_fetch_constraints() + + +class HomeConnectOptionNumberEntity(HomeConnectOptionEntity, NumberEntity): + """Number option class for Home Connect.""" + + async def async_set_native_value(self, value: float) -> None: + """Set the native value of the entity.""" + await self.async_set_option(value) + + def update_native_value(self) -> None: + """Set the value of the entity.""" + self._attr_native_value = cast(float | None, self.option_value) + option_definition = self.appliance.options.get(self.bsh_key) + if option_definition: + if option_definition.unit: + candidate_unit = UNIT_MAP.get( + option_definition.unit, option_definition.unit + ) + if ( + not hasattr(self, "_attr_native_unit_of_measurement") + or candidate_unit != self._attr_native_unit_of_measurement + ): + self._attr_native_unit_of_measurement = candidate_unit + self.__dict__.pop("unit_of_measurement", None) + option_constraints = option_definition.constraints + if option_constraints: + if ( + not hasattr(self, "_attr_native_min_value") + or self._attr_native_min_value != option_constraints.min + ) and option_constraints.min: + self._attr_native_min_value = option_constraints.min + if ( + not hasattr(self, "_attr_native_max_value") + or self._attr_native_max_value != option_constraints.max + ) and option_constraints.max: + self._attr_native_max_value = option_constraints.max + if ( + not hasattr(self, "_attr_native_step") + or self._attr_native_step != option_constraints.step_size + ) and option_constraints.step_size: + self._attr_native_step = option_constraints.step_size diff --git a/homeassistant/components/home_connect/select.py b/homeassistant/components/home_connect/select.py index bc281e3d928..f5298056080 100644 --- a/homeassistant/components/home_connect/select.py +++ b/homeassistant/components/home_connect/select.py @@ -5,7 +5,7 @@ from dataclasses import dataclass from typing import Any, cast from aiohomeconnect.client import Client as HomeConnectClient -from aiohomeconnect.model import EventKey, ProgramKey +from aiohomeconnect.model import EventKey, OptionKey, ProgramKey from aiohomeconnect.model.error import HomeConnectError from aiohomeconnect.model.program import Execution @@ -17,17 +17,32 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .common import setup_home_connect_entry from .const import ( APPLIANCES_WITH_PROGRAMS, + BEAN_AMOUNT_OPTIONS, + BEAN_CONTAINER_OPTIONS, + CLEANING_MODE_OPTIONS, + COFFEE_MILK_RATIO_OPTIONS, + COFFEE_TEMPERATURE_OPTIONS, DOMAIN, + DRYING_TARGET_OPTIONS, + FLOW_RATE_OPTIONS, + HOT_WATER_TEMPERATURE_OPTIONS, + INTENSIVE_LEVEL_OPTIONS, PROGRAMS_TRANSLATION_KEYS_MAP, + REFERENCE_MAP_ID_OPTIONS, + SPIN_SPEED_OPTIONS, SVE_TRANSLATION_PLACEHOLDER_PROGRAM, + TEMPERATURE_OPTIONS, TRANSLATION_KEYS_PROGRAMS_MAP, + VARIO_PERFECT_OPTIONS, + VENTING_LEVEL_OPTIONS, + WARMING_LEVEL_OPTIONS, ) from .coordinator import ( HomeConnectApplianceData, HomeConnectConfigEntry, HomeConnectCoordinator, ) -from .entity import HomeConnectEntity +from .entity import HomeConnectEntity, HomeConnectOptionEntity from .utils import get_dict_from_home_connect_error @@ -44,6 +59,16 @@ class HomeConnectProgramSelectEntityDescription( error_translation_key: str +@dataclass(frozen=True, kw_only=True) +class HomeConnectSelectOptionEntityDescription( + SelectEntityDescription, +): + """Entity Description class for options that have enumeration values.""" + + translation_key_values: dict[str, str] + values_translation_key: dict[str, str] + + PROGRAM_SELECT_ENTITY_DESCRIPTIONS = ( HomeConnectProgramSelectEntityDescription( key=EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM, @@ -65,6 +90,159 @@ PROGRAM_SELECT_ENTITY_DESCRIPTIONS = ( ), ) +PROGRAM_SELECT_OPTION_ENTITY_DESCRIPTIONS = ( + HomeConnectSelectOptionEntityDescription( + key=OptionKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_REFERENCE_MAP_ID, + translation_key="reference_map_id", + options=list(REFERENCE_MAP_ID_OPTIONS.keys()), + translation_key_values=REFERENCE_MAP_ID_OPTIONS, + values_translation_key={ + value: translation_key + for translation_key, value in REFERENCE_MAP_ID_OPTIONS.items() + }, + ), + HomeConnectSelectOptionEntityDescription( + key=OptionKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_CLEANING_MODE, + translation_key="reference_map_id", + options=list(CLEANING_MODE_OPTIONS.keys()), + translation_key_values=CLEANING_MODE_OPTIONS, + values_translation_key={ + value: translation_key + for translation_key, value in CLEANING_MODE_OPTIONS.items() + }, + ), + HomeConnectSelectOptionEntityDescription( + key=OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_BEAN_AMOUNT, + translation_key="bean_amount", + options=list(BEAN_AMOUNT_OPTIONS.keys()), + translation_key_values=BEAN_AMOUNT_OPTIONS, + values_translation_key={ + value: translation_key + for translation_key, value in BEAN_AMOUNT_OPTIONS.items() + }, + ), + HomeConnectSelectOptionEntityDescription( + key=OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_COFFEE_TEMPERATURE, + translation_key="coffee_temperature", + options=list(COFFEE_TEMPERATURE_OPTIONS.keys()), + translation_key_values=COFFEE_TEMPERATURE_OPTIONS, + values_translation_key={ + value: translation_key + for translation_key, value in COFFEE_TEMPERATURE_OPTIONS.items() + }, + ), + HomeConnectSelectOptionEntityDescription( + key=OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_BEAN_CONTAINER_SELECTION, + translation_key="bean_container", + options=list(BEAN_CONTAINER_OPTIONS.keys()), + translation_key_values=BEAN_CONTAINER_OPTIONS, + values_translation_key={ + value: translation_key + for translation_key, value in BEAN_CONTAINER_OPTIONS.items() + }, + ), + HomeConnectSelectOptionEntityDescription( + key=OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_FLOW_RATE, + translation_key="flow_rate", + options=list(FLOW_RATE_OPTIONS.keys()), + translation_key_values=FLOW_RATE_OPTIONS, + values_translation_key={ + value: translation_key + for translation_key, value in FLOW_RATE_OPTIONS.items() + }, + ), + HomeConnectSelectOptionEntityDescription( + key=OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_COFFEE_MILK_RATIO, + translation_key="coffee_milk_ratio", + options=list(COFFEE_MILK_RATIO_OPTIONS.keys()), + translation_key_values=COFFEE_MILK_RATIO_OPTIONS, + values_translation_key={ + value: translation_key + for translation_key, value in FLOW_RATE_OPTIONS.items() + }, + ), + HomeConnectSelectOptionEntityDescription( + key=OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_HOT_WATER_TEMPERATURE, + translation_key="hot_water_temperature", + options=list(HOT_WATER_TEMPERATURE_OPTIONS.keys()), + translation_key_values=HOT_WATER_TEMPERATURE_OPTIONS, + values_translation_key={ + value: translation_key + for translation_key, value in HOT_WATER_TEMPERATURE_OPTIONS.items() + }, + ), + HomeConnectSelectOptionEntityDescription( + key=OptionKey.LAUNDRY_CARE_DRYER_DRYING_TARGET, + translation_key="drying_target", + options=list(DRYING_TARGET_OPTIONS.keys()), + translation_key_values=DRYING_TARGET_OPTIONS, + values_translation_key={ + value: translation_key + for translation_key, value in DRYING_TARGET_OPTIONS.items() + }, + ), + HomeConnectSelectOptionEntityDescription( + key=OptionKey.COOKING_COMMON_HOOD_VENTING_LEVEL, + translation_key="venting_level", + options=list(VENTING_LEVEL_OPTIONS.keys()), + translation_key_values=VENTING_LEVEL_OPTIONS, + values_translation_key={ + value: translation_key + for translation_key, value in VENTING_LEVEL_OPTIONS.items() + }, + ), + HomeConnectSelectOptionEntityDescription( + key=OptionKey.COOKING_COMMON_HOOD_INTENSIVE_LEVEL, + translation_key="intensive_level", + options=list(INTENSIVE_LEVEL_OPTIONS.keys()), + translation_key_values=INTENSIVE_LEVEL_OPTIONS, + values_translation_key={ + value: translation_key + for translation_key, value in INTENSIVE_LEVEL_OPTIONS.items() + }, + ), + HomeConnectSelectOptionEntityDescription( + key=OptionKey.COOKING_OVEN_WARMING_LEVEL, + translation_key="warming_level", + options=list(WARMING_LEVEL_OPTIONS.keys()), + translation_key_values=WARMING_LEVEL_OPTIONS, + values_translation_key={ + value: translation_key + for translation_key, value in WARMING_LEVEL_OPTIONS.items() + }, + ), + HomeConnectSelectOptionEntityDescription( + key=OptionKey.LAUNDRY_CARE_WASHER_TEMPERATURE, + translation_key="washer_temperature", + options=list(TEMPERATURE_OPTIONS.keys()), + translation_key_values=TEMPERATURE_OPTIONS, + values_translation_key={ + value: translation_key + for translation_key, value in TEMPERATURE_OPTIONS.items() + }, + ), + HomeConnectSelectOptionEntityDescription( + key=OptionKey.LAUNDRY_CARE_WASHER_SPIN_SPEED, + translation_key="spin_speed", + options=list(SPIN_SPEED_OPTIONS.keys()), + translation_key_values=SPIN_SPEED_OPTIONS, + values_translation_key={ + value: translation_key + for translation_key, value in SPIN_SPEED_OPTIONS.items() + }, + ), + HomeConnectSelectOptionEntityDescription( + key=OptionKey.LAUNDRY_CARE_COMMON_VARIO_PERFECT, + translation_key="vario_perfect", + options=list(VARIO_PERFECT_OPTIONS.keys()), + translation_key_values=VARIO_PERFECT_OPTIONS, + values_translation_key={ + value: translation_key + for translation_key, value in VARIO_PERFECT_OPTIONS.items() + }, + ), +) + def _get_entities_for_appliance( entry: HomeConnectConfigEntry, @@ -81,6 +259,18 @@ def _get_entities_for_appliance( ) +def _get_option_entities_for_appliance( + entry: HomeConnectConfigEntry, + appliance: HomeConnectApplianceData, +) -> list[HomeConnectOptionEntity]: + """Get a list of entities.""" + return [ + HomeConnectSelectOptionEntity(entry.runtime_data, appliance, desc) + for desc in PROGRAM_SELECT_OPTION_ENTITY_DESCRIPTIONS + if desc.key in appliance.options + ] + + async def async_setup_entry( hass: HomeAssistant, entry: HomeConnectConfigEntry, @@ -91,6 +281,7 @@ async def async_setup_entry( entry, _get_entities_for_appliance, async_add_entities, + _get_option_entities_for_appliance, ) @@ -148,3 +339,53 @@ class HomeConnectProgramSelectEntity(HomeConnectEntity, SelectEntity): SVE_TRANSLATION_PLACEHOLDER_PROGRAM: program_key.value, }, ) from err + + +class HomeConnectSelectOptionEntity(HomeConnectOptionEntity, SelectEntity): + """Select option class for Home Connect.""" + + entity_description: HomeConnectSelectOptionEntityDescription + _original_option_keys: set[str | None] + + def __init__( + self, + coordinator: HomeConnectCoordinator, + appliance: HomeConnectApplianceData, + desc: HomeConnectSelectOptionEntityDescription, + ) -> None: + """Initialize the entity.""" + self._original_option_keys = set(desc.values_translation_key.keys()) + super().__init__( + coordinator, + appliance, + desc, + ) + + async def async_select_option(self, option: str) -> None: + """Select new option.""" + await self.async_set_option( + self.entity_description.translation_key_values[option] + ) + + def update_native_value(self) -> None: + """Set the value of the entity.""" + self._attr_current_option = ( + self.entity_description.values_translation_key.get( + cast(str, self.option_value), None + ) + if self.option_value is not None + else None + ) + if ( + (option_definition := self.appliance.options.get(self.bsh_key)) + and (option_constraints := option_definition.constraints) + and option_constraints.allowed_values + and self._original_option_keys != set(option_constraints.allowed_values) + ): + self._original_option_keys = set(option_constraints.allowed_values) + self._attr_options = [ + self.entity_description.values_translation_key[option] + for option in self._original_option_keys + if option is not None + ] + self.__dict__.pop("options", None) diff --git a/homeassistant/components/home_connect/sensor.py b/homeassistant/components/home_connect/sensor.py index d9f45c8c31d..88dd017e7d9 100644 --- a/homeassistant/components/home_connect/sensor.py +++ b/homeassistant/components/home_connect/sensor.py @@ -12,7 +12,7 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfTime, UnitOfVolume +from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfVolume from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util, slugify @@ -56,12 +56,6 @@ BSH_PROGRAM_SENSORS = ( "WasherDryer", ), ), - HomeConnectSensorEntityDescription( - key=EventKey.BSH_COMMON_OPTION_DURATION, - device_class=SensorDeviceClass.DURATION, - native_unit_of_measurement=UnitOfTime.SECONDS, - appliance_types=("Oven",), - ), HomeConnectSensorEntityDescription( key=EventKey.BSH_COMMON_OPTION_PROGRAM_PROGRESS, native_unit_of_measurement=PERCENTAGE, diff --git a/homeassistant/components/home_connect/strings.json b/homeassistant/components/home_connect/strings.json index 3ac9f90ba81..8a4dd68530f 100644 --- a/homeassistant/components/home_connect/strings.json +++ b/homeassistant/components/home_connect/strings.json @@ -98,6 +98,9 @@ }, "required_program_or_one_option_at_least": { "message": "A program or at least one of the possible options for a program should be specified" + }, + "set_option": { + "message": "Error setting the option for the program: {error}" } }, "issues": { @@ -859,6 +862,21 @@ }, "washer_i_dos_2_base_level": { "name": "i-Dos 2 base level" + }, + "duration": { + "name": "[%key:component::home_connect::services::set_program_and_options::fields::b_s_h_common_option_duration::name%]" + }, + "start_in_relative": { + "name": "[%key:component::home_connect::services::set_program_and_options::fields::b_s_h_common_option_start_in_relative::name%]" + }, + "finish_in_relative": { + "name": "[%key:component::home_connect::services::set_program_and_options::fields::b_s_h_common_option_finish_in_relative::name%]" + }, + "fill_quantity": { + "name": "[%key:component::home_connect::services::set_program_and_options::fields::consumer_products_coffee_maker_option_fill_quantity::name%]" + }, + "setpoint_temperature": { + "name": "[%key:component::home_connect::services::set_program_and_options::fields::cooking_oven_option_setpoint_temperature::name%]" } }, "select": { @@ -1179,6 +1197,200 @@ "laundry_care_washer_dryer_program_wash_and_dry_60": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_dryer_program_wash_and_dry_60%]", "laundry_care_washer_dryer_program_wash_and_dry_90": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_dryer_program_wash_and_dry_90%]" } + }, + "reference_map_id": { + "name": "[%key:component::home_connect::services::set_program_and_options::fields::consumer_products_cleaning_robot_option_reference_map_id::name%]", + "state": { + "consumer_products_cleaning_robot_enum_type_available_maps_temp_map": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_temp_map%]", + "consumer_products_cleaning_robot_enum_type_available_maps_map1": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_map1%]", + "consumer_products_cleaning_robot_enum_type_available_maps_map2": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_map2%]", + "consumer_products_cleaning_robot_enum_type_available_maps_map3": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_map3%]" + } + }, + "cleaning_mode": { + "name": "[%key:component::home_connect::services::set_program_and_options::fields::consumer_products_cleaning_robot_option_cleaning_mode::name%]", + "state": { + "consumer_products_cleaning_robot_enum_type_cleaning_modes_silent": "[%key:component::home_connect::selector::cleaning_mode::options::consumer_products_cleaning_robot_enum_type_cleaning_modes_silent%]", + "consumer_products_cleaning_robot_enum_type_cleaning_modes_standard": "[%key:component::home_connect::selector::cleaning_mode::options::consumer_products_cleaning_robot_enum_type_cleaning_modes_standard%]", + "consumer_products_cleaning_robot_enum_type_cleaning_modes_power": "[%key:component::home_connect::selector::cleaning_mode::options::consumer_products_cleaning_robot_enum_type_cleaning_modes_power%]" + } + }, + "bean_amount": { + "name": "[%key:component::home_connect::services::set_program_and_options::fields::consumer_products_coffee_maker_option_bean_amount::name%]", + "state": { + "consumer_products_coffee_maker_enum_type_bean_amount_very_mild": "[%key:component::home_connect::selector::bean_amount::options::consumer_products_coffee_maker_enum_type_bean_amount_very_mild%]", + "consumer_products_coffee_maker_enum_type_bean_amount_mild": "[%key:component::home_connect::selector::bean_amount::options::consumer_products_coffee_maker_enum_type_bean_amount_mild%]", + "consumer_products_coffee_maker_enum_type_bean_amount_mild_plus": "[%key:component::home_connect::selector::bean_amount::options::consumer_products_coffee_maker_enum_type_bean_amount_mild_plus%]", + "consumer_products_coffee_maker_enum_type_bean_amount_normal": "[%key:component::home_connect::selector::bean_amount::options::consumer_products_coffee_maker_enum_type_bean_amount_normal%]", + "consumer_products_coffee_maker_enum_type_bean_amount_normal_plus": "[%key:component::home_connect::selector::bean_amount::options::consumer_products_coffee_maker_enum_type_bean_amount_normal_plus%]", + "consumer_products_coffee_maker_enum_type_bean_amount_strong": "[%key:component::home_connect::selector::bean_amount::options::consumer_products_coffee_maker_enum_type_bean_amount_strong%]", + "consumer_products_coffee_maker_enum_type_bean_amount_strong_plus": "[%key:component::home_connect::selector::bean_amount::options::consumer_products_coffee_maker_enum_type_bean_amount_strong_plus%]", + "consumer_products_coffee_maker_enum_type_bean_amount_very_strong": "[%key:component::home_connect::selector::bean_amount::options::consumer_products_coffee_maker_enum_type_bean_amount_very_strong%]", + "consumer_products_coffee_maker_enum_type_bean_amount_very_strong_plus": "[%key:component::home_connect::selector::bean_amount::options::consumer_products_coffee_maker_enum_type_bean_amount_very_strong_plus%]", + "consumer_products_coffee_maker_enum_type_bean_amount_extra_strong": "[%key:component::home_connect::selector::bean_amount::options::consumer_products_coffee_maker_enum_type_bean_amount_extra_strong%]", + "consumer_products_coffee_maker_enum_type_bean_amount_double_shot": "[%key:component::home_connect::selector::bean_amount::options::consumer_products_coffee_maker_enum_type_bean_amount_double_shot%]", + "consumer_products_coffee_maker_enum_type_bean_amount_double_shot_plus": "[%key:component::home_connect::selector::bean_amount::options::consumer_products_coffee_maker_enum_type_bean_amount_double_shot_plus%]", + "consumer_products_coffee_maker_enum_type_bean_amount_double_shot_plus_plus": "[%key:component::home_connect::selector::bean_amount::options::consumer_products_coffee_maker_enum_type_bean_amount_double_shot_plus_plus%]", + "consumer_products_coffee_maker_enum_type_bean_amount_triple_shot": "[%key:component::home_connect::selector::bean_amount::options::consumer_products_coffee_maker_enum_type_bean_amount_triple_shot%]", + "consumer_products_coffee_maker_enum_type_bean_amount_triple_shot_plus": "[%key:component::home_connect::selector::bean_amount::options::consumer_products_coffee_maker_enum_type_bean_amount_triple_shot_plus%]", + "consumer_products_coffee_maker_enum_type_bean_amount_coffee_ground": "[%key:component::home_connect::selector::bean_amount::options::consumer_products_coffee_maker_enum_type_bean_amount_coffee_ground%]" + } + }, + "coffee_temperature": { + "name": "[%key:component::home_connect::services::set_program_and_options::fields::consumer_products_coffee_maker_option_coffee_temperature::name%]", + "state": { + "consumer_products_coffee_maker_enum_type_coffee_temperature_88_c": "[%key:component::home_connect::selector::coffee_temperature::options::consumer_products_coffee_maker_enum_type_coffee_temperature_88_c%]", + "consumer_products_coffee_maker_enum_type_coffee_temperature_90_c": "[%key:component::home_connect::selector::coffee_temperature::options::consumer_products_coffee_maker_enum_type_coffee_temperature_90_c%]", + "consumer_products_coffee_maker_enum_type_coffee_temperature_92_c": "[%key:component::home_connect::selector::coffee_temperature::options::consumer_products_coffee_maker_enum_type_coffee_temperature_92_c%]", + "consumer_products_coffee_maker_enum_type_coffee_temperature_94_c": "[%key:component::home_connect::selector::coffee_temperature::options::consumer_products_coffee_maker_enum_type_coffee_temperature_94_c%]", + "consumer_products_coffee_maker_enum_type_coffee_temperature_95_c": "[%key:component::home_connect::selector::coffee_temperature::options::consumer_products_coffee_maker_enum_type_coffee_temperature_95_c%]", + "consumer_products_coffee_maker_enum_type_coffee_temperature_96_c": "[%key:component::home_connect::selector::coffee_temperature::options::consumer_products_coffee_maker_enum_type_coffee_temperature_96_c%]" + } + }, + "bean_container": { + "name": "[%key:component::home_connect::services::set_program_and_options::fields::consumer_products_coffee_maker_option_bean_container::name%]", + "state": { + "consumer_products_coffee_maker_enum_type_bean_container_selection_right": "[%key:component::home_connect::selector::bean_container::options::consumer_products_coffee_maker_enum_type_bean_container_selection_right%]", + "consumer_products_coffee_maker_enum_type_bean_container_selection_left": "[%key:component::home_connect::selector::bean_container::options::consumer_products_coffee_maker_enum_type_bean_container_selection_left%]" + } + }, + "flow_rate": { + "name": "[%key:component::home_connect::services::set_program_and_options::fields::consumer_products_coffee_maker_option_flow_rate::name%]", + "state": { + "consumer_products_coffee_maker_enum_type_flow_rate_normal": "[%key:component::home_connect::selector::flow_rate::options::consumer_products_coffee_maker_enum_type_flow_rate_normal%]", + "consumer_products_coffee_maker_enum_type_flow_rate_intense": "[%key:component::home_connect::selector::flow_rate::options::consumer_products_coffee_maker_enum_type_flow_rate_intense%]", + "consumer_products_coffee_maker_enum_type_flow_rate_intense_plus": "[%key:component::home_connect::selector::flow_rate::options::consumer_products_coffee_maker_enum_type_flow_rate_intense_plus%]" + } + }, + "coffee_milk_ratio": { + "name": "[%key:component::home_connect::services::set_program_and_options::fields::consumer_products_coffee_maker_option_coffee_milk_ratio::name%]", + "state": { + "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_10_percent": "[%key:component::home_connect::selector::coffee_milk_ratio::options::consumer_products_coffee_maker_enum_type_coffee_milk_ratio_10_percent%]", + "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_20_percent": "[%key:component::home_connect::selector::coffee_milk_ratio::options::consumer_products_coffee_maker_enum_type_coffee_milk_ratio_20_percent%]", + "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_25_percent": "[%key:component::home_connect::selector::coffee_milk_ratio::options::consumer_products_coffee_maker_enum_type_coffee_milk_ratio_25_percent%]", + "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_30_percent": "[%key:component::home_connect::selector::coffee_milk_ratio::options::consumer_products_coffee_maker_enum_type_coffee_milk_ratio_30_percent%]", + "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_40_percent": "[%key:component::home_connect::selector::coffee_milk_ratio::options::consumer_products_coffee_maker_enum_type_coffee_milk_ratio_40_percent%]", + "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_50_percent": "[%key:component::home_connect::selector::coffee_milk_ratio::options::consumer_products_coffee_maker_enum_type_coffee_milk_ratio_50_percent%]", + "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_55_percent": "[%key:component::home_connect::selector::coffee_milk_ratio::options::consumer_products_coffee_maker_enum_type_coffee_milk_ratio_55_percent%]", + "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_60_percent": "[%key:component::home_connect::selector::coffee_milk_ratio::options::consumer_products_coffee_maker_enum_type_coffee_milk_ratio_60_percent%]", + "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_65_percent": "[%key:component::home_connect::selector::coffee_milk_ratio::options::consumer_products_coffee_maker_enum_type_coffee_milk_ratio_65_percent%]", + "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_67_percent": "[%key:component::home_connect::selector::coffee_milk_ratio::options::consumer_products_coffee_maker_enum_type_coffee_milk_ratio_67_percent%]", + "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_70_percent": "[%key:component::home_connect::selector::coffee_milk_ratio::options::consumer_products_coffee_maker_enum_type_coffee_milk_ratio_70_percent%]", + "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_75_percent": "[%key:component::home_connect::selector::coffee_milk_ratio::options::consumer_products_coffee_maker_enum_type_coffee_milk_ratio_75_percent%]", + "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_80_percent": "[%key:component::home_connect::selector::coffee_milk_ratio::options::consumer_products_coffee_maker_enum_type_coffee_milk_ratio_80_percent%]", + "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_85_percent": "[%key:component::home_connect::selector::coffee_milk_ratio::options::consumer_products_coffee_maker_enum_type_coffee_milk_ratio_85_percent%]", + "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_90_percent": "[%key:component::home_connect::selector::coffee_milk_ratio::options::consumer_products_coffee_maker_enum_type_coffee_milk_ratio_90_percent%]" + } + }, + "hot_water_temperature": { + "name": "[%key:component::home_connect::services::set_program_and_options::fields::consumer_products_coffee_maker_option_hot_water_temperature::name%]", + "state": { + "consumer_products_coffee_maker_enum_type_hot_water_temperature_white_tea": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_white_tea%]", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_green_tea": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_green_tea%]", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_black_tea": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_black_tea%]", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_50_c": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_50_c%]", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_55_c": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_55_c%]", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_60_c": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_60_c%]", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_65_c": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_65_c%]", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_70_c": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_70_c%]", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_75_c": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_75_c%]", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_80_c": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_80_c%]", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_85_c": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_85_c%]", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_90_c": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_90_c%]", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_95_c": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_95_c%]", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_97_c": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_97_c%]", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_122_f": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_122_f%]", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_131_f": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_131_f%]", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_140_f": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_140_f%]", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_149_f": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_149_f%]", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_158_f": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_158_f%]", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_167_f": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_167_f%]", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_176_f": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_176_f%]", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_185_f": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_185_f%]", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_194_f": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_194_f%]", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_203_f": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_203_f%]", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_max": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_max%]" + } + }, + "drying_target": { + "name": "[%key:component::home_connect::services::set_program_and_options::fields::laundry_care_dryer_option_drying_target::name%]", + "state": { + "laundry_care_dryer_enum_type_drying_target_iron_dry": "[%key:component::home_connect::selector::drying_target::options::laundry_care_dryer_enum_type_drying_target_iron_dry%]", + "laundry_care_dryer_enum_type_drying_target_gentle_dry": "[%key:component::home_connect::selector::drying_target::options::laundry_care_dryer_enum_type_drying_target_gentle_dry%]", + "laundry_care_dryer_enum_type_drying_target_cupboard_dry": "[%key:component::home_connect::selector::drying_target::options::laundry_care_dryer_enum_type_drying_target_cupboard_dry%]", + "laundry_care_dryer_enum_type_drying_target_cupboard_dry_plus": "[%key:component::home_connect::selector::drying_target::options::laundry_care_dryer_enum_type_drying_target_cupboard_dry_plus%]", + "laundry_care_dryer_enum_type_drying_target_extra_dry": "[%key:component::home_connect::selector::drying_target::options::laundry_care_dryer_enum_type_drying_target_extra_dry%]" + } + }, + "venting_level": { + "name": "[%key:component::home_connect::services::set_program_and_options::fields::cooking_hood_option_venting_level::name%]", + "state": { + "cooking_hood_enum_type_stage_fan_off": "[%key:component::home_connect::selector::venting_level::options::cooking_hood_enum_type_stage_fan_off%]", + "cooking_hood_enum_type_stage_fan_stage01": "[%key:component::home_connect::selector::venting_level::options::cooking_hood_enum_type_stage_fan_stage01%]", + "cooking_hood_enum_type_stage_fan_stage02": "[%key:component::home_connect::selector::venting_level::options::cooking_hood_enum_type_stage_fan_stage02%]", + "cooking_hood_enum_type_stage_fan_stage03": "[%key:component::home_connect::selector::venting_level::options::cooking_hood_enum_type_stage_fan_stage03%]", + "cooking_hood_enum_type_stage_fan_stage04": "[%key:component::home_connect::selector::venting_level::options::cooking_hood_enum_type_stage_fan_stage04%]", + "cooking_hood_enum_type_stage_fan_stage05": "[%key:component::home_connect::selector::venting_level::options::cooking_hood_enum_type_stage_fan_stage05%]" + } + }, + "intensive_level": { + "name": "[%key:component::home_connect::services::set_program_and_options::fields::cooking_hood_option_intensive_level::name%]", + "state": { + "cooking_hood_enum_type_intensive_stage_intensive_stage_off": "[%key:component::home_connect::selector::intensive_level::options::cooking_hood_enum_type_intensive_stage_intensive_stage_off%]", + "cooking_hood_enum_type_intensive_stage_intensive_stage1": "[%key:component::home_connect::selector::intensive_level::options::cooking_hood_enum_type_intensive_stage_intensive_stage1%]", + "cooking_hood_enum_type_intensive_stage_intensive_stage2": "[%key:component::home_connect::selector::intensive_level::options::cooking_hood_enum_type_intensive_stage_intensive_stage2%]" + } + }, + "warming_level": { + "name": "[%key:component::home_connect::services::set_program_and_options::fields::cooking_oven_option_warming_level::name%]", + "state": { + "cooking_oven_enum_type_warming_level_low": "[%key:component::home_connect::selector::warming_level::options::cooking_oven_enum_type_warming_level_low%]", + "cooking_oven_enum_type_warming_level_medium": "[%key:component::home_connect::selector::warming_level::options::cooking_oven_enum_type_warming_level_medium%]", + "cooking_oven_enum_type_warming_level_high": "[%key:component::home_connect::selector::warming_level::options::cooking_oven_enum_type_warming_level_high%]" + } + }, + "washer_temperature": { + "name": "[%key:component::home_connect::services::set_program_and_options::fields::laundry_care_washer_option_temperature::name%]", + "state": { + "laundry_care_washer_enum_type_temperature_cold": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_cold%]", + "laundry_care_washer_enum_type_temperature_g_c20": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c20%]", + "laundry_care_washer_enum_type_temperature_g_c30": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c30%]", + "laundry_care_washer_enum_type_temperature_g_c40": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c40%]", + "laundry_care_washer_enum_type_temperature_g_c50": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c50%]", + "laundry_care_washer_enum_type_temperature_g_c60": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c60%]", + "laundry_care_washer_enum_type_temperature_g_c70": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c70%]", + "laundry_care_washer_enum_type_temperature_g_c80": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c80%]", + "laundry_care_washer_enum_type_temperature_g_c90": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c90%]", + "laundry_care_washer_enum_type_temperature_ul_cold": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_ul_cold%]", + "laundry_care_washer_enum_type_temperature_ul_warm": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_ul_warm%]", + "laundry_care_washer_enum_type_temperature_ul_hot": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_ul_hot%]", + "laundry_care_washer_enum_type_temperature_ul_extra_hot": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_ul_extra_hot%]" + } + }, + "spin_speed": { + "name": "[%key:component::home_connect::services::set_program_and_options::fields::laundry_care_washer_option_spin_speed::name%]", + "state": { + "laundry_care_washer_enum_type_spin_speed_off": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_off%]", + "laundry_care_washer_enum_type_spin_speed_r_p_m400": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m400%]", + "laundry_care_washer_enum_type_spin_speed_r_p_m600": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m600%]", + "laundry_care_washer_enum_type_spin_speed_r_p_m800": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m800%]", + "laundry_care_washer_enum_type_spin_speed_r_p_m1000": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m1000%]", + "laundry_care_washer_enum_type_spin_speed_r_p_m1200": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m1200%]", + "laundry_care_washer_enum_type_spin_speed_r_p_m1400": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m1400%]", + "laundry_care_washer_enum_type_spin_speed_r_p_m1600": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m1600%]", + "laundry_care_washer_enum_type_spin_speed_ul_off": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_ul_off%]", + "laundry_care_washer_enum_type_spin_speed_ul_low": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_ul_low%]", + "laundry_care_washer_enum_type_spin_speed_ul_medium": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_ul_medium%]", + "laundry_care_washer_enum_type_spin_speed_ul_high": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_ul_high%]" + } + }, + "vario_perfect": { + "name": "[%key:component::home_connect::services::set_program_and_options::fields::laundry_care_washer_option_vario_perfect::name%]", + "state": { + "laundry_care_common_enum_type_vario_perfect_off": "[%key:component::home_connect::selector::vario_perfect::options::laundry_care_common_enum_type_vario_perfect_off%]", + "laundry_care_common_enum_type_vario_perfect_eco_perfect": "[%key:component::home_connect::selector::vario_perfect::options::laundry_care_common_enum_type_vario_perfect_eco_perfect%]", + "laundry_care_common_enum_type_vario_perfect_speed_perfect": "[%key:component::home_connect::selector::vario_perfect::options::laundry_care_common_enum_type_vario_perfect_speed_perfect%]" + } } }, "sensor": { @@ -1365,6 +1577,45 @@ }, "door_assistant_freezer": { "name": "Freezer door assistant" + }, + "multiple_beverages": { + "name": "[%key:component::home_connect::services::set_program_and_options::fields::consumer_products_coffee_maker_option_multiple_beverages::name%]" + }, + "intensiv_zone": { + "name": "[%key:component::home_connect::services::set_program_and_options::fields::dishcare_dishwasher_option_intensiv_zone::name%]" + }, + "brilliance_dry": { + "name": "[%key:component::home_connect::services::set_program_and_options::fields::dishcare_dishwasher_option_brilliance_dry::name%]" + }, + "vario_speed_plus": { + "name": "[%key:component::home_connect::services::set_program_and_options::fields::dishcare_dishwasher_option_vario_speed_plus::name%]" + }, + "silence_on_demand": { + "name": "[%key:component::home_connect::services::set_program_and_options::fields::dishcare_dishwasher_option_silence_on_demand::name%]" + }, + "half_load": { + "name": "[%key:component::home_connect::services::set_program_and_options::fields::dishcare_dishwasher_option_half_load::name%]" + }, + "extra_dry": { + "name": "[%key:component::home_connect::services::set_program_and_options::fields::dishcare_dishwasher_option_extra_dry::name%]" + }, + "hygiene_plus": { + "name": "[%key:component::home_connect::services::set_program_and_options::fields::dishcare_dishwasher_option_hygiene_plus::name%]" + }, + "eco_dry": { + "name": "[%key:component::home_connect::services::set_program_and_options::fields::dishcare_dishwasher_option_eco_dry::name%]" + }, + "zeolite_dry": { + "name": "[%key:component::home_connect::services::set_program_and_options::fields::dishcare_dishwasher_option_zeolite_dry::name%]" + }, + "fast_pre_heat": { + "name": "[%key:component::home_connect::services::set_program_and_options::fields::cooking_oven_option_fast_pre_heat::name%]" + }, + "i_dos1_active": { + "name": "[%key:component::home_connect::services::set_program_and_options::fields::laundry_care_washer_option_i_dos1_active::name%]" + }, + "i_dos2_active": { + "name": "[%key:component::home_connect::services::set_program_and_options::fields::laundry_care_washer_option_i_dos2_active::name%]" } }, "time": { diff --git a/homeassistant/components/home_connect/switch.py b/homeassistant/components/home_connect/switch.py index 7dc375f430d..d5a92eef2a4 100644 --- a/homeassistant/components/home_connect/switch.py +++ b/homeassistant/components/home_connect/switch.py @@ -3,7 +3,7 @@ import logging from typing import Any, cast -from aiohomeconnect.model import EventKey, ProgramKey, SettingKey +from aiohomeconnect.model import EventKey, OptionKey, ProgramKey, SettingKey from aiohomeconnect.model.error import HomeConnectError from aiohomeconnect.model.program import EnumerateProgram @@ -37,7 +37,7 @@ from .coordinator import ( HomeConnectConfigEntry, HomeConnectCoordinator, ) -from .entity import HomeConnectEntity +from .entity import HomeConnectEntity, HomeConnectOptionEntity from .utils import get_dict_from_home_connect_error _LOGGER = logging.getLogger(__name__) @@ -100,6 +100,61 @@ POWER_SWITCH_DESCRIPTION = SwitchEntityDescription( translation_key="power", ) +SWITCH_OPTIONS = ( + SwitchEntityDescription( + key=OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_MULTIPLE_BEVERAGES, + translation_key="multiple_beverages", + ), + SwitchEntityDescription( + key=OptionKey.DISHCARE_DISHWASHER_INTENSIV_ZONE, + translation_key="intensiv_zone", + ), + SwitchEntityDescription( + key=OptionKey.DISHCARE_DISHWASHER_BRILLIANCE_DRY, + translation_key="brilliance_dry", + ), + SwitchEntityDescription( + key=OptionKey.DISHCARE_DISHWASHER_VARIO_SPEED_PLUS, + translation_key="vario_speed_plus", + ), + SwitchEntityDescription( + key=OptionKey.DISHCARE_DISHWASHER_SILENCE_ON_DEMAND, + translation_key="silence_on_demand", + ), + SwitchEntityDescription( + key=OptionKey.DISHCARE_DISHWASHER_HALF_LOAD, + translation_key="half_load", + ), + SwitchEntityDescription( + key=OptionKey.DISHCARE_DISHWASHER_EXTRA_DRY, + translation_key="extra_dry", + ), + SwitchEntityDescription( + key=OptionKey.DISHCARE_DISHWASHER_HYGIENE_PLUS, + translation_key="hygiene_plus", + ), + SwitchEntityDescription( + key=OptionKey.DISHCARE_DISHWASHER_ECO_DRY, + translation_key="eco_dry", + ), + SwitchEntityDescription( + key=OptionKey.DISHCARE_DISHWASHER_ZEOLITE_DRY, + translation_key="zeolite_dry", + ), + SwitchEntityDescription( + key=OptionKey.COOKING_OVEN_FAST_PRE_HEAT, + translation_key="fast_pre_heat", + ), + SwitchEntityDescription( + key=OptionKey.LAUNDRY_CARE_WASHER_I_DOS_1_ACTIVE, + translation_key="i_dos1_active", + ), + SwitchEntityDescription( + key=OptionKey.LAUNDRY_CARE_WASHER_I_DOS_2_ACTIVE, + translation_key="i_dos2_active", + ), +) + def _get_entities_for_appliance( entry: HomeConnectConfigEntry, @@ -123,10 +178,21 @@ def _get_entities_for_appliance( for description in SWITCHES if description.key in appliance.settings ) - return entities +def _get_option_entities_for_appliance( + entry: HomeConnectConfigEntry, + appliance: HomeConnectApplianceData, +) -> list[HomeConnectOptionEntity]: + """Get a list of currently available option entities.""" + return [ + HomeConnectSwitchOptionEntity(entry.runtime_data, appliance, description) + for description in SWITCH_OPTIONS + if description.key in appliance.options + ] + + async def async_setup_entry( hass: HomeAssistant, entry: HomeConnectConfigEntry, @@ -137,6 +203,7 @@ async def async_setup_entry( entry, _get_entities_for_appliance, async_add_entities, + _get_option_entities_for_appliance, ) @@ -403,3 +470,19 @@ class HomeConnectPowerSwitch(HomeConnectEntity, SwitchEntity): self.power_off_state = BSH_POWER_STANDBY else: self.power_off_state = None + + +class HomeConnectSwitchOptionEntity(HomeConnectOptionEntity, SwitchEntity): + """Switch option class for Home Connect.""" + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on the option.""" + await self.async_set_option(True) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off the option.""" + await self.async_set_option(False) + + def update_native_value(self) -> None: + """Set the value of the entity.""" + self._attr_is_on = cast(bool | None, self.option_value) diff --git a/tests/components/home_connect/conftest.py b/tests/components/home_connect/conftest.py index 7b74c2290c3..e0d60dc8614 100644 --- a/tests/components/home_connect/conftest.py +++ b/tests/components/home_connect/conftest.py @@ -23,6 +23,8 @@ from aiohomeconnect.model import ( HomeAppliance, Option, Program, + ProgramDefinition, + ProgramKey, SettingKey, ) from aiohomeconnect.model.error import HomeConnectApiError, HomeConnectError @@ -339,6 +341,29 @@ def mock_client(request: pytest.FixtureRequest) -> MagicMock: mock.add_events = add_events + async def set_program_option_side_effect(ha_id: str, *_, **kwargs) -> None: + event_key = EventKey(kwargs["option_key"]) + await event_queue.put( + [ + EventMessage( + ha_id, + EventType.NOTIFY, + ArrayOfEvents( + [ + Event( + key=event_key, + raw_key=event_key.value, + timestamp=0, + level="", + handling="", + value=kwargs["value"], + ) + ] + ), + ), + ] + ) + async def stream_all_events() -> AsyncGenerator[EventMessage]: """Mock stream_all_events.""" while True: @@ -380,6 +405,17 @@ def mock_client(request: pytest.FixtureRequest) -> MagicMock: mock.get_status = AsyncMock(return_value=copy.deepcopy(MOCK_STATUS)) mock.get_all_programs = AsyncMock(side_effect=_get_all_programs_side_effect) mock.put_command = AsyncMock() + mock.get_available_program = AsyncMock( + return_value=ProgramDefinition(ProgramKey.UNKNOWN, options=[]) + ) + mock.get_active_program_options = AsyncMock(return_value=ArrayOfOptions([])) + mock.get_selected_program_options = AsyncMock(return_value=ArrayOfOptions([])) + mock.set_active_program_option = AsyncMock( + side_effect=set_program_option_side_effect + ) + mock.set_selected_program_option = AsyncMock( + side_effect=set_program_option_side_effect + ) mock.side_effect = mock return mock @@ -420,6 +456,11 @@ def mock_client_with_exception(request: pytest.FixtureRequest) -> MagicMock: mock.get_status = AsyncMock(side_effect=exception) mock.get_all_programs = AsyncMock(side_effect=exception) mock.put_command = AsyncMock(side_effect=exception) + mock.get_available_program = AsyncMock(side_effect=exception) + mock.get_active_program_options = AsyncMock(side_effect=exception) + mock.get_selected_program_options = AsyncMock(side_effect=exception) + mock.set_active_program_option = AsyncMock(side_effect=exception) + mock.set_selected_program_option = AsyncMock(side_effect=exception) return mock diff --git a/tests/components/home_connect/fixtures/settings.json b/tests/components/home_connect/fixtures/settings.json index a357d8fb43e..8f649e5790b 100644 --- a/tests/components/home_connect/fixtures/settings.json +++ b/tests/components/home_connect/fixtures/settings.json @@ -124,6 +124,11 @@ "key": "BSH.Common.Setting.ChildLock", "value": false, "type": "Boolean" + }, + { + "key": "LaundryCare.Washer.Setting.IDos2BaseLevel", + "value": 0, + "type": "Integer" } ] } diff --git a/tests/components/home_connect/snapshots/test_diagnostics.ambr b/tests/components/home_connect/snapshots/test_diagnostics.ambr index f3c73a32d95..512da8bd970 100644 --- a/tests/components/home_connect/snapshots/test_diagnostics.ambr +++ b/tests/components/home_connect/snapshots/test_diagnostics.ambr @@ -272,6 +272,7 @@ 'settings': dict({ 'BSH.Common.Setting.ChildLock': False, 'BSH.Common.Setting.PowerState': 'BSH.Common.EnumType.PowerState.On', + 'LaundryCare.Washer.Setting.IDos2BaseLevel': 0, }), 'status': dict({ 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed', diff --git a/tests/components/home_connect/test_entity.py b/tests/components/home_connect/test_entity.py new file mode 100644 index 00000000000..272fc21ba62 --- /dev/null +++ b/tests/components/home_connect/test_entity.py @@ -0,0 +1,299 @@ +"""Tests for Home Connect entity base classes.""" + +from collections.abc import Awaitable, Callable +from unittest.mock import AsyncMock, MagicMock + +from aiohomeconnect.model import ( + ArrayOfEvents, + ArrayOfPrograms, + Event, + EventKey, + EventMessage, + EventType, + Option, + OptionKey, + Program, + ProgramDefinition, + ProgramKey, +) +from aiohomeconnect.model.error import ( + ActiveProgramNotSetError, + HomeConnectError, + SelectedProgramNotSetError, +) +from aiohomeconnect.model.program import ( + ProgramDefinitionConstraints, + ProgramDefinitionOption, +) +import pytest + +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_TURN_OFF, + STATE_OFF, + STATE_ON, + STATE_UNAVAILABLE, + STATE_UNKNOWN, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError + +from tests.common import MockConfigEntry + + +@pytest.fixture +def platforms() -> list[str]: + """Fixture to specify platforms to test.""" + return [Platform.SWITCH] + + +@pytest.mark.parametrize( + ("array_of_programs_program_arg", "event_key"), + [ + ( + "active", + EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM, + ), + ( + "selected", + EventKey.BSH_COMMON_ROOT_SELECTED_PROGRAM, + ), + ], +) +@pytest.mark.parametrize( + ( + "appliance_ha_id", + "option_entity_id", + "options_state_stage_1", + "options_availability_stage_2", + "option_without_default", + "option_without_constraints", + ), + [ + ( + "Dishwasher", + { + OptionKey.DISHCARE_DISHWASHER_HALF_LOAD: "switch.dishwasher_half_load", + OptionKey.DISHCARE_DISHWASHER_SILENCE_ON_DEMAND: "switch.dishwasher_silence_on_demand", + OptionKey.DISHCARE_DISHWASHER_ECO_DRY: "switch.dishwasher_eco_dry", + }, + [(STATE_ON, True), (STATE_OFF, False), (None, None)], + [False, True, True], + ( + OptionKey.DISHCARE_DISHWASHER_HYGIENE_PLUS, + "switch.dishwasher_hygiene_plus", + ), + (OptionKey.DISHCARE_DISHWASHER_EXTRA_DRY, "switch.dishwasher_extra_dry"), + ) + ], + indirect=["appliance_ha_id"], +) +async def test_program_options_retrieval( + array_of_programs_program_arg: str, + event_key: EventKey, + appliance_ha_id: str, + option_entity_id: dict[OptionKey, str], + options_state_stage_1: list[tuple[str, bool | None]], + options_availability_stage_2: list[bool], + option_without_default: tuple[OptionKey, str], + option_without_constraints: tuple[OptionKey, str], + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, +) -> None: + """Test that the options are correctly retrieved at the start and updated on program updates.""" + original_get_all_programs_mock = client.get_all_programs.side_effect + options_values = [ + Option( + option_key, + value, + ) + for option_key, (_, value) in zip( + option_entity_id.keys(), options_state_stage_1, strict=True + ) + if value is not None + ] + + async def get_all_programs_with_options_mock(ha_id: str) -> ArrayOfPrograms: + if ha_id != appliance_ha_id: + return await original_get_all_programs_mock(ha_id) + + array_of_programs: ArrayOfPrograms = await original_get_all_programs_mock(ha_id) + return ArrayOfPrograms( + **( + { + "programs": array_of_programs.programs, + array_of_programs_program_arg: Program( + array_of_programs.programs[0].key, options=options_values + ), + } + ) + ) + + client.get_all_programs = AsyncMock(side_effect=get_all_programs_with_options_mock) + client.get_available_program = AsyncMock( + return_value=ProgramDefinition( + ProgramKey.UNKNOWN, + options=[ + ProgramDefinitionOption( + option_key, + "Boolean", + constraints=ProgramDefinitionConstraints( + default=False, + ), + ) + for option_key, (_, value) in zip( + option_entity_id.keys(), options_state_stage_1, strict=True + ) + if value is not None + ], + ) + ) + + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + for entity_id, (state, _) in zip( + option_entity_id.values(), options_state_stage_1, strict=True + ): + if state is not None: + assert hass.states.is_state(entity_id, state) + else: + assert not hass.states.get(entity_id) + + client.get_available_program = AsyncMock( + return_value=ProgramDefinition( + ProgramKey.UNKNOWN, + options=[ + *[ + ProgramDefinitionOption( + option_key, + "Boolean", + constraints=ProgramDefinitionConstraints( + default=False, + ), + ) + for option_key, available in zip( + option_entity_id.keys(), + options_availability_stage_2, + strict=True, + ) + if available + ], + ProgramDefinitionOption( + option_without_default[0], + "Boolean", + constraints=ProgramDefinitionConstraints(), + ), + ProgramDefinitionOption( + option_without_constraints[0], + "Boolean", + ), + ], + ) + ) + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.NOTIFY, + data=ArrayOfEvents( + [ + Event( + key=event_key, + raw_key=event_key.value, + timestamp=0, + level="", + handling="", + value=ProgramKey.DISHCARE_DISHWASHER_AUTO_1, + ) + ] + ), + ) + ] + ) + await hass.async_block_till_done() + + # Verify default values + # Every time the program is updated, the available options should use the default value if existing + for entity_id, available in zip( + option_entity_id.values(), options_availability_stage_2, strict=True + ): + assert hass.states.is_state( + entity_id, STATE_OFF if available else STATE_UNAVAILABLE + ) + for _, entity_id in (option_without_default, option_without_constraints): + assert hass.states.is_state(entity_id, STATE_UNKNOWN) + + +@pytest.mark.parametrize( + ( + "set_active_program_option_side_effect", + "set_selected_program_option_side_effect", + ), + [ + ( + ActiveProgramNotSetError("error.key"), + SelectedProgramNotSetError("error.key"), + ), + ( + HomeConnectError(), + None, + ), + ( + ActiveProgramNotSetError("error.key"), + HomeConnectError(), + ), + ], +) +async def test_option_entity_functionality_exception( + set_active_program_option_side_effect: HomeConnectError | None, + set_selected_program_option_side_effect: HomeConnectError | None, + appliance_ha_id: str, + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, +) -> None: + """Test that the option entity handles exceptions correctly.""" + entity_id = "switch.washer_i_dos_1_active" + + client.get_available_program = AsyncMock( + return_value=ProgramDefinition( + ProgramKey.UNKNOWN, + options=[ + ProgramDefinitionOption( + OptionKey.LAUNDRY_CARE_WASHER_I_DOS_1_ACTIVE, + "Boolean", + ) + ], + ) + ) + + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + assert hass.states.get(entity_id) + + if set_active_program_option_side_effect: + client.set_active_program_option = AsyncMock( + side_effect=set_active_program_option_side_effect + ) + if set_selected_program_option_side_effect: + client.set_selected_program_option = AsyncMock( + side_effect=set_selected_program_option_side_effect + ) + + with pytest.raises(HomeAssistantError, match=r"Error.*setting.*option.*"): + await hass.services.async_call( + SWITCH_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id}, blocking=True + ) diff --git a/tests/components/home_connect/test_number.py b/tests/components/home_connect/test_number.py index edab86cf819..214dcb6137c 100644 --- a/tests/components/home_connect/test_number.py +++ b/tests/components/home_connect/test_number.py @@ -7,17 +7,34 @@ from unittest.mock import AsyncMock, MagicMock from aiohomeconnect.model import ( ArrayOfEvents, ArrayOfSettings, + Event, + EventKey, EventMessage, EventType, GetSetting, + OptionKey, + ProgramDefinition, + ProgramKey, SettingKey, ) -from aiohomeconnect.model.error import HomeConnectApiError, HomeConnectError +from aiohomeconnect.model.error import ( + ActiveProgramNotSetError, + HomeConnectApiError, + HomeConnectError, + SelectedProgramNotSetError, +) +from aiohomeconnect.model.program import ( + ProgramDefinitionConstraints, + ProgramDefinitionOption, +) from aiohomeconnect.model.setting import SettingConstraints import pytest from homeassistant.components.home_connect.const import DOMAIN from homeassistant.components.number import ( + ATTR_MAX, + ATTR_MIN, + ATTR_STEP, ATTR_VALUE as SERVICE_ATTR_VALUE, DEFAULT_MAX_VALUE, DEFAULT_MIN_VALUE, @@ -51,7 +68,6 @@ async def test_number( assert config_entry.state is ConfigEntryState.LOADED -@pytest.mark.parametrize("appliance_ha_id", ["FridgeFreezer"], indirect=True) async def test_paired_depaired_devices_flow( appliance_ha_id: str, hass: HomeAssistant, @@ -63,6 +79,17 @@ async def test_paired_depaired_devices_flow( entity_registry: er.EntityRegistry, ) -> None: """Test that removed devices are correctly removed from and added to hass on API events.""" + client.get_available_program = AsyncMock( + return_value=ProgramDefinition( + ProgramKey.UNKNOWN, + options=[ + ProgramDefinitionOption( + OptionKey.BSH_COMMON_FINISH_IN_RELATIVE, + "Integer", + ) + ], + ) + ) assert config_entry.state == ConfigEntryState.NOT_LOADED assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED @@ -369,3 +396,135 @@ async def test_number_entity_error( blocking=True, ) assert getattr(client_with_exception, mock_attr).call_count == 2 + + +@pytest.mark.parametrize( + ( + "set_active_program_options_side_effect", + "set_selected_program_options_side_effect", + "called_mock_method", + ), + [ + ( + None, + SelectedProgramNotSetError("error.key"), + "set_active_program_option", + ), + ( + ActiveProgramNotSetError("error.key"), + None, + "set_selected_program_option", + ), + ], +) +@pytest.mark.parametrize( + ("appliance_ha_id", "entity_id", "option_key", "min", "max", "step_size", "unit"), + [ + ( + "Oven", + "number.oven_setpoint_temperature", + OptionKey.COOKING_OVEN_SETPOINT_TEMPERATURE, + 50, + 260, + 1, + "°C", + ), + ], + indirect=["appliance_ha_id"], +) +async def test_options_functionality( + entity_id: str, + option_key: OptionKey, + appliance_ha_id: str, + min: int, + max: int, + step_size: int, + unit: str, + set_active_program_options_side_effect: ActiveProgramNotSetError | None, + set_selected_program_options_side_effect: SelectedProgramNotSetError | None, + called_mock_method: str, + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, +) -> None: + """Test options functionality.""" + + async def set_program_option_side_effect(ha_id: str, *_, **kwargs) -> None: + event_key = EventKey(kwargs["option_key"]) + await client.add_events( + [ + EventMessage( + ha_id, + EventType.NOTIFY, + ArrayOfEvents( + [ + Event( + key=event_key, + raw_key=event_key.value, + timestamp=0, + level="", + handling="", + value=kwargs["value"], + unit=unit, + ) + ] + ), + ), + ] + ) + + called_mock = AsyncMock(side_effect=set_program_option_side_effect) + if set_active_program_options_side_effect: + client.set_active_program_option.side_effect = ( + set_active_program_options_side_effect + ) + else: + assert set_selected_program_options_side_effect + client.set_selected_program_option.side_effect = ( + set_selected_program_options_side_effect + ) + setattr(client, called_mock_method, called_mock) + client.get_available_program = AsyncMock( + return_value=ProgramDefinition( + ProgramKey.UNKNOWN, + options=[ + ProgramDefinitionOption( + option_key, + "Double", + unit=unit, + constraints=ProgramDefinitionConstraints( + min=min, + max=max, + step_size=step_size, + ), + ) + ], + ) + ) + + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + entity_state = hass.states.get(entity_id) + assert entity_state + assert entity_state.attributes["unit_of_measurement"] == unit + assert entity_state.attributes[ATTR_MIN] == min + assert entity_state.attributes[ATTR_MAX] == max + assert entity_state.attributes[ATTR_STEP] == step_size + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: entity_id, SERVICE_ATTR_VALUE: 80}, + ) + await hass.async_block_till_done() + + assert called_mock.called + assert called_mock.call_args.args == (appliance_ha_id,) + assert called_mock.call_args.kwargs == { + "option_key": option_key, + "value": 80, + } + assert hass.states.is_state(entity_id, "80.0") diff --git a/tests/components/home_connect/test_select.py b/tests/components/home_connect/test_select.py index a1e6fafd768..917c092136e 100644 --- a/tests/components/home_connect/test_select.py +++ b/tests/components/home_connect/test_select.py @@ -1,7 +1,7 @@ """Tests for home_connect select entities.""" from collections.abc import Awaitable, Callable -from unittest.mock import MagicMock +from unittest.mock import AsyncMock, MagicMock from aiohomeconnect.model import ( ArrayOfEvents, @@ -10,13 +10,21 @@ from aiohomeconnect.model import ( EventKey, EventMessage, EventType, + OptionKey, + ProgramDefinition, ProgramKey, ) -from aiohomeconnect.model.error import HomeConnectError +from aiohomeconnect.model.error import ( + ActiveProgramNotSetError, + HomeConnectError, + SelectedProgramNotSetError, +) from aiohomeconnect.model.program import ( EnumerateProgram, EnumerateProgramConstraints, Execution, + ProgramDefinitionConstraints, + ProgramDefinitionOption, ) import pytest @@ -70,6 +78,17 @@ async def test_paired_depaired_devices_flow( entity_registry: er.EntityRegistry, ) -> None: """Test that removed devices are correctly removed from and added to hass on API events.""" + client.get_available_program = AsyncMock( + return_value=ProgramDefinition( + ProgramKey.UNKNOWN, + options=[ + ProgramDefinitionOption( + OptionKey.LAUNDRY_CARE_WASHER_TEMPERATURE, + "Enumeration", + ) + ], + ) + ) assert config_entry.state == ConfigEntryState.NOT_LOADED assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED @@ -413,3 +432,132 @@ async def test_select_exception_handling( blocking=True, ) assert getattr(client_with_exception, mock_attr).call_count == 2 + + +@pytest.mark.parametrize( + ( + "set_active_program_options_side_effect", + "set_selected_program_options_side_effect", + "called_mock_method", + ), + [ + ( + None, + SelectedProgramNotSetError("error.key"), + "set_active_program_option", + ), + ( + ActiveProgramNotSetError("error.key"), + None, + "set_selected_program_option", + ), + ], +) +@pytest.mark.parametrize( + ("entity_id", "option_key", "allowed_values", "expected_options"), + [ + ( + "select.washer_temperature", + OptionKey.LAUNDRY_CARE_WASHER_TEMPERATURE, + None, + { + "laundry_care_washer_enum_type_temperature_cold", + "laundry_care_washer_enum_type_temperature_g_c_20", + "laundry_care_washer_enum_type_temperature_g_c_30", + "laundry_care_washer_enum_type_temperature_g_c_40", + "laundry_care_washer_enum_type_temperature_g_c_50", + "laundry_care_washer_enum_type_temperature_g_c_60", + "laundry_care_washer_enum_type_temperature_g_c_70", + "laundry_care_washer_enum_type_temperature_g_c_80", + "laundry_care_washer_enum_type_temperature_g_c_90", + "laundry_care_washer_enum_type_temperature_ul_cold", + "laundry_care_washer_enum_type_temperature_ul_warm", + "laundry_care_washer_enum_type_temperature_ul_hot", + "laundry_care_washer_enum_type_temperature_ul_extra_hot", + }, + ), + ( + "select.washer_temperature", + OptionKey.LAUNDRY_CARE_WASHER_TEMPERATURE, + [ + "LaundryCare.Washer.EnumType.Temperature.UlCold", + "LaundryCare.Washer.EnumType.Temperature.UlWarm", + "LaundryCare.Washer.EnumType.Temperature.UlHot", + "LaundryCare.Washer.EnumType.Temperature.UlExtraHot", + ], + { + "laundry_care_washer_enum_type_temperature_ul_cold", + "laundry_care_washer_enum_type_temperature_ul_warm", + "laundry_care_washer_enum_type_temperature_ul_hot", + "laundry_care_washer_enum_type_temperature_ul_extra_hot", + }, + ), + ], +) +async def test_options_functionality( + entity_id: str, + option_key: OptionKey, + allowed_values: list[str | None] | None, + expected_options: set[str], + appliance_ha_id: str, + set_active_program_options_side_effect: ActiveProgramNotSetError | None, + set_selected_program_options_side_effect: SelectedProgramNotSetError | None, + called_mock_method: str, + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, +) -> None: + """Test options functionality.""" + if set_active_program_options_side_effect: + client.set_active_program_option.side_effect = ( + set_active_program_options_side_effect + ) + else: + assert set_selected_program_options_side_effect + client.set_selected_program_option.side_effect = ( + set_selected_program_options_side_effect + ) + called_mock: AsyncMock = getattr(client, called_mock_method) + client.get_available_program = AsyncMock( + return_value=ProgramDefinition( + ProgramKey.UNKNOWN, + options=[ + ProgramDefinitionOption( + option_key, + "Enumeration", + constraints=ProgramDefinitionConstraints( + allowed_values=allowed_values + ), + ) + ], + ) + ) + + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + entity_state = hass.states.get(entity_id) + assert entity_state + assert set(entity_state.attributes[ATTR_OPTIONS]) == expected_options + + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: entity_id, + ATTR_OPTION: "laundry_care_washer_enum_type_temperature_ul_warm", + }, + ) + await hass.async_block_till_done() + + assert called_mock.called + assert called_mock.call_args.args == (appliance_ha_id,) + assert called_mock.call_args.kwargs == { + "option_key": option_key, + "value": "LaundryCare.Washer.EnumType.Temperature.UlWarm", + } + assert hass.states.is_state( + entity_id, "laundry_care_washer_enum_type_temperature_ul_warm" + ) diff --git a/tests/components/home_connect/test_switch.py b/tests/components/home_connect/test_switch.py index d4e0f999197..1b38809dc05 100644 --- a/tests/components/home_connect/test_switch.py +++ b/tests/components/home_connect/test_switch.py @@ -5,17 +5,26 @@ from typing import Any from unittest.mock import AsyncMock, MagicMock from aiohomeconnect.model import ( + ArrayOfEvents, + ArrayOfPrograms, ArrayOfSettings, Event, EventKey, EventMessage, + EventType, GetSetting, + OptionKey, + ProgramDefinition, ProgramKey, SettingKey, ) -from aiohomeconnect.model.error import HomeConnectApiError, HomeConnectError -from aiohomeconnect.model.event import ArrayOfEvents, EventType -from aiohomeconnect.model.program import ArrayOfPrograms, EnumerateProgram +from aiohomeconnect.model.error import ( + ActiveProgramNotSetError, + HomeConnectApiError, + HomeConnectError, + SelectedProgramNotSetError, +) +from aiohomeconnect.model.program import EnumerateProgram, ProgramDefinitionOption from aiohomeconnect.model.setting import SettingConstraints import pytest @@ -81,6 +90,17 @@ async def test_paired_depaired_devices_flow( entity_registry: er.EntityRegistry, ) -> None: """Test that removed devices are correctly removed from and added to hass on API events.""" + client.get_available_program = AsyncMock( + return_value=ProgramDefinition( + ProgramKey.UNKNOWN, + options=[ + ProgramDefinitionOption( + OptionKey.LAUNDRY_CARE_WASHER_I_DOS_1_ACTIVE, + "Boolean", + ) + ], + ) + ) assert config_entry.state == ConfigEntryState.NOT_LOADED assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED @@ -840,3 +860,95 @@ async def test_create_issue( # Assert the issue is no longer present assert not issue_registry.async_get_issue(DOMAIN, issue_id) assert len(issue_registry.issues) == 0 + + +@pytest.mark.parametrize( + ( + "set_active_program_options_side_effect", + "set_selected_program_options_side_effect", + "called_mock_method", + ), + [ + ( + None, + SelectedProgramNotSetError("error.key"), + "set_active_program_option", + ), + ( + ActiveProgramNotSetError("error.key"), + None, + "set_selected_program_option", + ), + ], +) +@pytest.mark.parametrize( + ("entity_id", "option_key", "appliance_ha_id"), + [ + ( + "switch.dishwasher_half_load", + OptionKey.DISHCARE_DISHWASHER_HALF_LOAD, + "Dishwasher", + ) + ], + indirect=["appliance_ha_id"], +) +async def test_options_functionality( + entity_id: str, + option_key: OptionKey, + appliance_ha_id: str, + set_active_program_options_side_effect: ActiveProgramNotSetError | None, + set_selected_program_options_side_effect: SelectedProgramNotSetError | None, + called_mock_method: str, + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, +) -> None: + """Test options functionality.""" + if set_active_program_options_side_effect: + client.set_active_program_option.side_effect = ( + set_active_program_options_side_effect + ) + else: + assert set_selected_program_options_side_effect + client.set_selected_program_option.side_effect = ( + set_selected_program_options_side_effect + ) + called_mock: AsyncMock = getattr(client, called_mock_method) + client.get_available_program = AsyncMock( + return_value=ProgramDefinition( + ProgramKey.UNKNOWN, options=[ProgramDefinitionOption(option_key, "Boolean")] + ) + ) + + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + assert hass.states.get(entity_id) + + await hass.services.async_call( + SWITCH_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id} + ) + await hass.async_block_till_done() + + assert called_mock.called + assert called_mock.call_args.args == (appliance_ha_id,) + assert called_mock.call_args.kwargs == { + "option_key": option_key, + "value": False, + } + assert hass.states.is_state(entity_id, STATE_OFF) + + await hass.services.async_call( + SWITCH_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id} + ) + await hass.async_block_till_done() + + assert called_mock.called + assert called_mock.call_args.args == (appliance_ha_id,) + assert called_mock.call_args.kwargs == { + "option_key": option_key, + "value": True, + } + assert hass.states.is_state(entity_id, STATE_ON) From 98c6a578b7da32fb4da67c37693244f73311aed9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Sat, 22 Feb 2025 21:14:11 +0100 Subject: [PATCH 1689/3148] Add buttons to Home Connect (#138792) * Add buttons * Fix stale documentation --- .../components/home_connect/__init__.py | 1 + .../components/home_connect/button.py | 160 +++++++++ .../components/home_connect/coordinator.py | 14 + .../components/home_connect/strings.json | 17 + tests/components/home_connect/conftest.py | 18 + .../fixtures/available_commands.json | 142 ++++++++ tests/components/home_connect/test_button.py | 315 ++++++++++++++++++ 7 files changed, 667 insertions(+) create mode 100644 homeassistant/components/home_connect/button.py create mode 100644 tests/components/home_connect/fixtures/available_commands.json create mode 100644 tests/components/home_connect/test_button.py diff --git a/homeassistant/components/home_connect/__init__.py b/homeassistant/components/home_connect/__init__.py index b4ceb11be92..637fd7aa3a8 100644 --- a/homeassistant/components/home_connect/__init__.py +++ b/homeassistant/components/home_connect/__init__.py @@ -187,6 +187,7 @@ SERVICE_COMMAND_SCHEMA = vol.Schema({vol.Required(ATTR_DEVICE_ID): str}) PLATFORMS = [ Platform.BINARY_SENSOR, + Platform.BUTTON, Platform.LIGHT, Platform.NUMBER, Platform.SELECT, diff --git a/homeassistant/components/home_connect/button.py b/homeassistant/components/home_connect/button.py new file mode 100644 index 00000000000..138979409a5 --- /dev/null +++ b/homeassistant/components/home_connect/button.py @@ -0,0 +1,160 @@ +"""Provides button entities for Home Connect.""" + +from aiohomeconnect.model import CommandKey, EventKey +from aiohomeconnect.model.error import HomeConnectError + +from homeassistant.components.button import ButtonEntity, ButtonEntityDescription +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .common import setup_home_connect_entry +from .const import APPLIANCES_WITH_PROGRAMS, DOMAIN +from .coordinator import ( + HomeConnectApplianceData, + HomeConnectConfigEntry, + HomeConnectCoordinator, +) +from .entity import HomeConnectEntity +from .utils import get_dict_from_home_connect_error + + +class HomeConnectCommandButtonEntityDescription(ButtonEntityDescription): + """Describes Home Connect button entity.""" + + key: CommandKey + + +COMMAND_BUTTONS = ( + HomeConnectCommandButtonEntityDescription( + key=CommandKey.BSH_COMMON_OPEN_DOOR, + translation_key="open_door", + ), + HomeConnectCommandButtonEntityDescription( + key=CommandKey.BSH_COMMON_PARTLY_OPEN_DOOR, + translation_key="partly_open_door", + ), + HomeConnectCommandButtonEntityDescription( + key=CommandKey.BSH_COMMON_PAUSE_PROGRAM, + translation_key="pause_program", + ), + HomeConnectCommandButtonEntityDescription( + key=CommandKey.BSH_COMMON_RESUME_PROGRAM, + translation_key="resume_program", + ), +) + + +def _get_entities_for_appliance( + entry: HomeConnectConfigEntry, + appliance: HomeConnectApplianceData, +) -> list[HomeConnectEntity]: + """Get a list of entities.""" + entities: list[HomeConnectEntity] = [] + entities.extend( + HomeConnectCommandButtonEntity(entry.runtime_data, appliance, description) + for description in COMMAND_BUTTONS + if description.key in appliance.commands + ) + if appliance.info.type in APPLIANCES_WITH_PROGRAMS: + entities.append( + HomeConnectStopProgramButtonEntity(entry.runtime_data, appliance) + ) + + return entities + + +async def async_setup_entry( + hass: HomeAssistant, + entry: HomeConnectConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Home Connect button entities.""" + setup_home_connect_entry( + entry, + _get_entities_for_appliance, + async_add_entities, + ) + + +class HomeConnectButtonEntity(HomeConnectEntity, ButtonEntity): + """Describes Home Connect button entity.""" + + entity_description: ButtonEntityDescription + + def __init__( + self, + coordinator: HomeConnectCoordinator, + appliance: HomeConnectApplianceData, + desc: ButtonEntityDescription, + ) -> None: + """Initialize the entity.""" + super().__init__( + coordinator, + appliance, + # The entity is subscribed to the appliance connected event, + # but it will receive also the disconnected event + ButtonEntityDescription( + key=EventKey.BSH_COMMON_APPLIANCE_CONNECTED, + ), + ) + self.entity_description = desc + self.appliance = appliance + self.unique_id = f"{appliance.info.ha_id}-{desc.key}" + + def update_native_value(self) -> None: + """Set the value of the entity.""" + + +class HomeConnectCommandButtonEntity(HomeConnectButtonEntity): + """Button entity for Home Connect commands.""" + + entity_description: HomeConnectCommandButtonEntityDescription + + async def async_press(self) -> None: + """Press the button.""" + try: + await self.coordinator.client.put_command( + self.appliance.info.ha_id, + command_key=self.entity_description.key, + value=True, + ) + except HomeConnectError as error: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="execute_command", + translation_placeholders={ + **get_dict_from_home_connect_error(error), + "command": self.entity_description.key, + }, + ) from error + + +class HomeConnectStopProgramButtonEntity(HomeConnectButtonEntity): + """Button entity for stopping a program.""" + + def __init__( + self, + coordinator: HomeConnectCoordinator, + appliance: HomeConnectApplianceData, + ) -> None: + """Initialize the entity.""" + super().__init__( + coordinator, + appliance, + ButtonEntityDescription( + key="StopProgram", + translation_key="stop_program", + ), + ) + + async def async_press(self) -> None: + """Press the button.""" + try: + await self.coordinator.client.stop_program(self.appliance.info.ha_id) + except HomeConnectError as error: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="stop_program", + translation_placeholders=get_dict_from_home_connect_error(error), + ) from error diff --git a/homeassistant/components/home_connect/coordinator.py b/homeassistant/components/home_connect/coordinator.py index b5f0f711597..80ae8173d86 100644 --- a/homeassistant/components/home_connect/coordinator.py +++ b/homeassistant/components/home_connect/coordinator.py @@ -11,6 +11,7 @@ from typing import Any, cast from aiohomeconnect.client import Client as HomeConnectClient from aiohomeconnect.model import ( + CommandKey, Event, EventKey, EventMessage, @@ -53,6 +54,7 @@ EVENT_STREAM_RECONNECT_DELAY = 30 class HomeConnectApplianceData: """Class to hold Home Connect appliance data.""" + commands: set[CommandKey] events: dict[EventKey, Event] info: HomeAppliance options: dict[OptionKey, ProgramDefinitionOption] @@ -62,6 +64,7 @@ class HomeConnectApplianceData: def update(self, other: HomeConnectApplianceData) -> None: """Update data with data from other instance.""" + self.commands.update(other.commands) self.events.update(other.events) self.info.connected = other.info.connected self.options.clear() @@ -408,7 +411,18 @@ class HomeConnectCoordinator( unit=option.unit, ) + try: + commands = { + command.key + for command in ( + await self.client.get_available_commands(appliance.ha_id) + ).commands + } + except HomeConnectError: + commands = set() + appliance_data = HomeConnectApplianceData( + commands=commands, events=events, info=appliance, options=options, diff --git a/homeassistant/components/home_connect/strings.json b/homeassistant/components/home_connect/strings.json index 8a4dd68530f..db53e76fb95 100644 --- a/homeassistant/components/home_connect/strings.json +++ b/homeassistant/components/home_connect/strings.json @@ -815,6 +815,23 @@ "name": "Wine compartment door" } }, + "button": { + "open_door": { + "name": "Open door" + }, + "partly_open_door": { + "name": "Partly open door" + }, + "pause_program": { + "name": "Pause program" + }, + "resume_program": { + "name": "Resume program" + }, + "stop_program": { + "name": "Stop program" + } + }, "light": { "cooking_lighting": { "name": "Functional light" diff --git a/tests/components/home_connect/conftest.py b/tests/components/home_connect/conftest.py index e0d60dc8614..49cbc89ba41 100644 --- a/tests/components/home_connect/conftest.py +++ b/tests/components/home_connect/conftest.py @@ -9,6 +9,7 @@ from unittest.mock import AsyncMock, MagicMock, patch from aiohomeconnect.client import Client as HomeConnectClient from aiohomeconnect.model import ( + ArrayOfCommands, ArrayOfEvents, ArrayOfHomeAppliances, ArrayOfOptions, @@ -50,6 +51,9 @@ MOCK_SETTINGS: dict[str, Any] = load_json_object_fixture("home_connect/settings. MOCK_STATUS = ArrayOfStatus.from_dict( load_json_object_fixture("home_connect/status.json")["data"] ) +MOCK_AVAILABLE_COMMANDS: dict[str, Any] = load_json_object_fixture( + "home_connect/available_commands.json" +) CLIENT_ID = "1234" @@ -326,6 +330,14 @@ async def _get_setting_side_effect(ha_id: str, setting_key: SettingKey): raise HomeConnectApiError("error.key", "error description") +async def _get_available_commands_side_effect(ha_id: str) -> ArrayOfCommands: + """Get available commands.""" + for appliance in MOCK_APPLIANCES.homeappliances: + if appliance.ha_id == ha_id and appliance.type in MOCK_AVAILABLE_COMMANDS: + return ArrayOfCommands.from_dict(MOCK_AVAILABLE_COMMANDS[appliance.type]) + raise HomeConnectApiError("error.key", "error description") + + @pytest.fixture(name="client") def mock_client(request: pytest.FixtureRequest) -> MagicMock: """Fixture to mock Client from HomeConnect.""" @@ -385,6 +397,7 @@ def mock_client(request: pytest.FixtureRequest) -> MagicMock: event_queue, EventKey.BSH_COMMON_ROOT_SELECTED_PROGRAM ), ) + mock.stop_program = AsyncMock() mock.set_active_program_option = AsyncMock( side_effect=_get_set_program_options_side_effect(event_queue), ) @@ -404,6 +417,9 @@ def mock_client(request: pytest.FixtureRequest) -> MagicMock: mock.get_setting = AsyncMock(side_effect=_get_setting_side_effect) mock.get_status = AsyncMock(return_value=copy.deepcopy(MOCK_STATUS)) mock.get_all_programs = AsyncMock(side_effect=_get_all_programs_side_effect) + mock.get_available_commands = AsyncMock( + side_effect=_get_available_commands_side_effect + ) mock.put_command = AsyncMock() mock.get_available_program = AsyncMock( return_value=ProgramDefinition(ProgramKey.UNKNOWN, options=[]) @@ -446,6 +462,7 @@ def mock_client_with_exception(request: pytest.FixtureRequest) -> MagicMock: mock.start_program = AsyncMock(side_effect=exception) mock.stop_program = AsyncMock(side_effect=exception) mock.set_selected_program = AsyncMock(side_effect=exception) + mock.stop_program = AsyncMock(side_effect=exception) mock.set_active_program_option = AsyncMock(side_effect=exception) mock.set_active_program_options = AsyncMock(side_effect=exception) mock.set_selected_program_option = AsyncMock(side_effect=exception) @@ -455,6 +472,7 @@ def mock_client_with_exception(request: pytest.FixtureRequest) -> MagicMock: mock.get_setting = AsyncMock(side_effect=exception) mock.get_status = AsyncMock(side_effect=exception) mock.get_all_programs = AsyncMock(side_effect=exception) + mock.get_available_commands = AsyncMock(side_effect=exception) mock.put_command = AsyncMock(side_effect=exception) mock.get_available_program = AsyncMock(side_effect=exception) mock.get_active_program_options = AsyncMock(side_effect=exception) diff --git a/tests/components/home_connect/fixtures/available_commands.json b/tests/components/home_connect/fixtures/available_commands.json new file mode 100644 index 00000000000..e4ed6c21b7c --- /dev/null +++ b/tests/components/home_connect/fixtures/available_commands.json @@ -0,0 +1,142 @@ +{ + "Cooktop": { + "commands": [ + { + "key": "BSH.Common.Command.AcknowledgeEvent", + "name": "Acknowledge event" + } + ] + }, + "Hood": { + "commands": [ + { + "key": "BSH.Common.Command.AcknowledgeEvent", + "name": "Acknowledge event" + } + ] + }, + "Oven": { + "commands": [ + { + "key": "BSH.Common.Command.PauseProgram", + "name": "Stop program" + }, + { + "key": "BSH.Common.Command.ResumeProgram", + "name": "Continue program" + }, + { + "key": "BSH.Common.Command.OpenDoor", + "name": "Open door" + }, + { + "key": "BSH.Common.Command.PartlyOpenDoor", + "name": "Partly open door" + }, + { + "key": "BSH.Common.Command.AcknowledgeEvent", + "name": "Acknowledge event" + } + ] + }, + "CleaningRobot": { + "commands": [ + { + "key": "BSH.Common.Command.PauseProgram", + "name": "Stop program" + }, + { + "key": "BSH.Common.Command.ResumeProgram", + "name": "Continue program" + }, + { + "key": "BSH.Common.Command.AcknowledgeEvent", + "name": "Acknowledge event" + } + ] + }, + "Dishwasher": { + "commands": [ + { + "key": "BSH.Common.Command.ResumeProgram", + "name": "Continue program" + }, + { + "key": "BSH.Common.Command.AcknowledgeEvent", + "name": "Acknowledge event" + } + ] + }, + "Dryer": { + "commands": [ + { + "key": "BSH.Common.Command.PauseProgram", + "name": "Stop program" + }, + { + "key": "BSH.Common.Command.ResumeProgram", + "name": "Continue program" + }, + { + "key": "BSH.Common.Command.AcknowledgeEvent", + "name": "Acknowledge event" + } + ] + }, + "Washer": { + "commands": [ + { + "key": "BSH.Common.Command.PauseProgram", + "name": "Stop program" + }, + { + "key": "BSH.Common.Command.ResumeProgram", + "name": "Continue program" + }, + { + "key": "BSH.Common.Command.AcknowledgeEvent", + "name": "Acknowledge event" + } + ] + }, + "WasherDryer": { + "commands": [ + { + "key": "BSH.Common.Command.PauseProgram", + "name": "Stop program" + }, + { + "key": "BSH.Common.Command.ResumeProgram", + "name": "Continue program" + }, + { + "key": "BSH.Common.Command.AcknowledgeEvent", + "name": "Acknowledge event" + } + ] + }, + "Freezer": { + "commands": [ + { + "key": "BSH.Common.Command.OpenDoor", + "name": "Open door" + } + ] + }, + "FridgeFreezer": { + "commands": [ + { + "key": "BSH.Common.Command.OpenDoor", + "name": "Open door" + } + ] + }, + "Refrigerator": { + "commands": [ + { + "key": "BSH.Common.Command.OpenDoor", + "name": "Open door" + } + ] + } +} diff --git a/tests/components/home_connect/test_button.py b/tests/components/home_connect/test_button.py new file mode 100644 index 00000000000..5af7e40ca43 --- /dev/null +++ b/tests/components/home_connect/test_button.py @@ -0,0 +1,315 @@ +"""Tests for home_connect button entities.""" + +from collections.abc import Awaitable, Callable +from typing import Any +from unittest.mock import AsyncMock, MagicMock + +from aiohomeconnect.model import ArrayOfCommands, CommandKey, EventMessage +from aiohomeconnect.model.command import Command +from aiohomeconnect.model.error import HomeConnectApiError +from aiohomeconnect.model.event import ArrayOfEvents, EventType +import pytest + +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +from homeassistant.components.home_connect.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from tests.common import MockConfigEntry + + +@pytest.fixture +def platforms() -> list[str]: + """Fixture to specify platforms to test.""" + return [Platform.BUTTON] + + +async def test_buttons( + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, +) -> None: + """Test button entities.""" + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + +async def test_paired_depaired_devices_flow( + appliance_ha_id: str, + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test that removed devices are correctly removed from and added to hass on API events.""" + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + assert device + entity_entries = entity_registry.entities.get_entries_for_device_id(device.id) + assert entity_entries + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.DEPAIRED, + data=ArrayOfEvents([]), + ) + ] + ) + await hass.async_block_till_done() + + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + assert not device + for entity_entry in entity_entries: + assert not entity_registry.async_get(entity_entry.entity_id) + + # Now that all everything related to the device is removed, pair it again + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.PAIRED, + data=ArrayOfEvents([]), + ) + ] + ) + await hass.async_block_till_done() + + assert device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + for entity_entry in entity_entries: + assert entity_registry.async_get(entity_entry.entity_id) + + +async def test_connected_devices( + appliance_ha_id: str, + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test that devices reconnected. + + Specifically those devices whose settings, status, etc. could + not be obtained while disconnected and once connected, the entities are added. + """ + get_available_commands_original_mock = client.get_available_commands + get_available_programs_mock = client.get_available_programs + + async def get_available_commands_side_effect(ha_id: str): + if ha_id == appliance_ha_id: + raise HomeConnectApiError( + "SDK.Error.HomeAppliance.Connection.Initialization.Failed" + ) + return await get_available_commands_original_mock.side_effect(ha_id) + + async def get_available_programs_side_effect(ha_id: str): + if ha_id == appliance_ha_id: + raise HomeConnectApiError( + "SDK.Error.HomeAppliance.Connection.Initialization.Failed" + ) + return await get_available_programs_mock.side_effect(ha_id) + + client.get_available_commands = AsyncMock( + side_effect=get_available_commands_side_effect + ) + client.get_available_programs = AsyncMock( + side_effect=get_available_programs_side_effect + ) + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + client.get_available_commands = get_available_commands_original_mock + client.get_available_programs = get_available_programs_mock + + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + assert device + entity_entries = entity_registry.entities.get_entries_for_device_id(device.id) + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.CONNECTED, + data=ArrayOfEvents([]), + ) + ] + ) + await hass.async_block_till_done() + + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + assert device + new_entity_entries = entity_registry.entities.get_entries_for_device_id(device.id) + assert len(new_entity_entries) > len(entity_entries) + + +async def test_button_entity_availabilty( + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + appliance_ha_id: str, +) -> None: + """Test if button entities availability are based on the appliance connection state.""" + entity_ids = [ + "button.washer_pause_program", + "button.washer_stop_program", + ] + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + for entity_id in entity_ids: + state = hass.states.get(entity_id) + assert state + assert state.state != STATE_UNAVAILABLE + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.DISCONNECTED, + ArrayOfEvents([]), + ) + ] + ) + await hass.async_block_till_done() + + for entity_id in entity_ids: + assert hass.states.is_state(entity_id, STATE_UNAVAILABLE) + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.CONNECTED, + ArrayOfEvents([]), + ) + ] + ) + await hass.async_block_till_done() + + for entity_id in entity_ids: + state = hass.states.get(entity_id) + assert state + assert state.state != STATE_UNAVAILABLE + + +@pytest.mark.parametrize( + ("entity_id", "method_call", "expected_kwargs"), + [ + ( + "button.washer_pause_program", + "put_command", + {"command_key": CommandKey.BSH_COMMON_PAUSE_PROGRAM, "value": True}, + ), + ("button.washer_stop_program", "stop_program", {}), + ], +) +async def test_button_functionality( + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + entity_id: str, + method_call: str, + expected_kwargs: dict[str, Any], + appliance_ha_id: str, +) -> None: + """Test if button entities availability are based on the appliance connection state.""" + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + entity = hass.states.get(entity_id) + assert entity + assert entity.state != STATE_UNAVAILABLE + + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + getattr(client, method_call).assert_called_with(appliance_ha_id, **expected_kwargs) + + +async def test_command_button_exception( + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client_with_exception: MagicMock, +) -> None: + """Test if button entities availability are based on the appliance connection state.""" + entity_id = "button.washer_pause_program" + + client_with_exception.get_available_commands = AsyncMock( + return_value=ArrayOfCommands( + [ + Command( + CommandKey.BSH_COMMON_PAUSE_PROGRAM, + "Pause Program", + ) + ] + ) + ) + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client_with_exception) + assert config_entry.state == ConfigEntryState.LOADED + + entity = hass.states.get(entity_id) + assert entity + assert entity.state != STATE_UNAVAILABLE + + with pytest.raises(HomeAssistantError, match=r"Error.*executing.*command"): + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + +async def test_stop_program_button_exception( + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client_with_exception: MagicMock, +) -> None: + """Test if button entities availability are based on the appliance connection state.""" + entity_id = "button.washer_stop_program" + + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client_with_exception) + assert config_entry.state == ConfigEntryState.LOADED + + entity = hass.states.get(entity_id) + assert entity + assert entity.state != STATE_UNAVAILABLE + + with pytest.raises(HomeAssistantError, match=r"Error.*stop.*program"): + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) From 93b01a3bc39d8ad079ee500196af0e09c9e6814a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Feb 2025 14:39:12 -0600 Subject: [PATCH 1690/3148] Fix minimum schema version to run event_id_post_migration (#139014) * Fix minimum version to run event_id_post_migration The table rebuild to fix the foreign key constraint was added in https://github.com/home-assistant/core/pull/120779 but the schema version was not bumped so we need to make sure any database that was created with schema 43 or older still has the migration run as otherwise they will not be able to purge the database with SQLite since each delete in the events table will due a full table scan of the states table to look for a foreign key that is not there fixes #138818 * Apply suggestions from code review * Update homeassistant/components/recorder/migration.py * Update homeassistant/components/recorder/migration.py * Update homeassistant/components/recorder/const.py * Apply suggestions from code review * Apply suggestions from code review * Apply suggestions from code review * Apply suggestions from code review * update tests, add more cover * update tests, add more cover * Update tests/components/recorder/test_migration_run_time_migrations_remember.py --- homeassistant/components/recorder/const.py | 5 ++ .../components/recorder/migration.py | 13 +++++- .../recorder/test_migration_from_schema_32.py | 15 ++++-- ..._migration_run_time_migrations_remember.py | 46 +++++++++++++++++-- 4 files changed, 68 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/recorder/const.py b/homeassistant/components/recorder/const.py index c91845e8436..b7ee984558c 100644 --- a/homeassistant/components/recorder/const.py +++ b/homeassistant/components/recorder/const.py @@ -50,6 +50,11 @@ STATES_META_SCHEMA_VERSION = 38 LAST_REPORTED_SCHEMA_VERSION = 43 LEGACY_STATES_EVENT_ID_INDEX_SCHEMA_VERSION = 28 +LEGACY_STATES_EVENT_FOREIGN_KEYS_FIXED_SCHEMA_VERSION = 43 +# https://github.com/home-assistant/core/pull/120779 +# fixed the foreign keys in the states table but it did +# not bump the schema version which means only databases +# created with schema 44 and later do not need the rebuild. INTEGRATION_PLATFORM_COMPILE_STATISTICS = "compile_statistics" INTEGRATION_PLATFORM_LIST_STATISTIC_IDS = "list_statistic_ids" diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index c6cdd6d317f..3aa12f2b1f9 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -52,6 +52,7 @@ from .auto_repairs.statistics.schema import ( from .const import ( CONTEXT_ID_AS_BINARY_SCHEMA_VERSION, EVENT_TYPE_IDS_SCHEMA_VERSION, + LEGACY_STATES_EVENT_FOREIGN_KEYS_FIXED_SCHEMA_VERSION, LEGACY_STATES_EVENT_ID_INDEX_SCHEMA_VERSION, STATES_META_SCHEMA_VERSION, SupportedDialect, @@ -2490,9 +2491,10 @@ class BaseMigration(ABC): if self.initial_schema_version > self.max_initial_schema_version: _LOGGER.debug( "Data migration '%s' not needed, database created with version %s " - "after migrator was added", + "after migrator was added in version %s", self.migration_id, self.initial_schema_version, + self.max_initial_schema_version, ) return False if self.start_schema_version < self.required_schema_version: @@ -2868,7 +2870,14 @@ class EventIDPostMigration(BaseRunTimeMigration): """Migration to remove old event_id index from states.""" migration_id = "event_id_post_migration" - max_initial_schema_version = LEGACY_STATES_EVENT_ID_INDEX_SCHEMA_VERSION - 1 + # Note we don't subtract 1 from the max_initial_schema_version + # in this case because we need to run this migration on databases + # version >= 43 because the schema was not bumped when the table + # rebuild was added in + # https://github.com/home-assistant/core/pull/120779 + # which means its only safe to assume version 44 and later + # do not need the table rebuild + max_initial_schema_version = LEGACY_STATES_EVENT_FOREIGN_KEYS_FIXED_SCHEMA_VERSION task = MigrationTask migration_version = 2 diff --git a/tests/components/recorder/test_migration_from_schema_32.py b/tests/components/recorder/test_migration_from_schema_32.py index 0a5f5d4da73..012e227c11a 100644 --- a/tests/components/recorder/test_migration_from_schema_32.py +++ b/tests/components/recorder/test_migration_from_schema_32.py @@ -225,6 +225,7 @@ async def test_migrate_events_context_ids( patch.object(recorder, "db_schema", old_db_schema), patch.object(migration, "SCHEMA_VERSION", old_db_schema.SCHEMA_VERSION), patch.object(migration.EventsContextIDMigration, "migrate_data"), + patch.object(migration.EventIDPostMigration, "migrate_data"), patch(CREATE_ENGINE_TARGET, new=_create_engine_test), ): async with ( @@ -282,6 +283,7 @@ async def test_migrate_events_context_ids( patch( "sqlalchemy.schema.Index.create", autospec=True, wraps=Index.create ) as wrapped_idx_create, + patch.object(migration.EventIDPostMigration, "migrate_data"), ): async with async_test_recorder( hass, wait_recorder=False, wait_recorder_setup=False @@ -588,6 +590,7 @@ async def test_migrate_states_context_ids( patch.object(recorder, "db_schema", old_db_schema), patch.object(migration, "SCHEMA_VERSION", old_db_schema.SCHEMA_VERSION), patch.object(migration.StatesContextIDMigration, "migrate_data"), + patch.object(migration.EventIDPostMigration, "migrate_data"), patch(CREATE_ENGINE_TARGET, new=_create_engine_test), ): async with ( @@ -640,6 +643,7 @@ async def test_migrate_states_context_ids( patch( "sqlalchemy.schema.Index.create", autospec=True, wraps=Index.create ) as wrapped_idx_create, + patch.object(migration.EventIDPostMigration, "migrate_data"), ): async with async_test_recorder( hass, wait_recorder=False, wait_recorder_setup=False @@ -1127,6 +1131,7 @@ async def test_post_migrate_entity_ids( patch.object(migration, "SCHEMA_VERSION", old_db_schema.SCHEMA_VERSION), patch.object(migration.EntityIDMigration, "migrate_data"), patch.object(migration.EntityIDPostMigration, "migrate_data"), + patch.object(migration.EventIDPostMigration, "migrate_data"), patch(CREATE_ENGINE_TARGET, new=_create_engine_test), ): async with ( @@ -1158,9 +1163,12 @@ async def test_post_migrate_entity_ids( return {state.state: state.entity_id for state in states} # Run again with new schema, let migration run - with patch( - "sqlalchemy.schema.Index.create", autospec=True, wraps=Index.create - ) as wrapped_idx_create: + with ( + patch( + "sqlalchemy.schema.Index.create", autospec=True, wraps=Index.create + ) as wrapped_idx_create, + patch.object(migration.EventIDPostMigration, "migrate_data"), + ): async with ( async_test_home_assistant() as hass, async_test_recorder(hass) as instance, @@ -1169,7 +1177,6 @@ async def test_post_migrate_entity_ids( await hass.async_block_till_done() await async_wait_recording_done(hass) - await async_wait_recording_done(hass) states_by_state = await instance.async_add_executor_job( _fetch_migrated_states diff --git a/tests/components/recorder/test_migration_run_time_migrations_remember.py b/tests/components/recorder/test_migration_run_time_migrations_remember.py index 43a1b028348..350126b4c72 100644 --- a/tests/components/recorder/test_migration_run_time_migrations_remember.py +++ b/tests/components/recorder/test_migration_run_time_migrations_remember.py @@ -115,7 +115,7 @@ def _create_engine_test( "event_context_id_as_binary": (0, 1), "event_type_id_migration": (2, 1), "entity_id_migration": (2, 1), - "event_id_post_migration": (0, 0), + "event_id_post_migration": (1, 1), "entity_id_post_migration": (0, 1), }, [ @@ -131,7 +131,7 @@ def _create_engine_test( "event_context_id_as_binary": (0, 0), "event_type_id_migration": (2, 1), "entity_id_migration": (2, 1), - "event_id_post_migration": (0, 0), + "event_id_post_migration": (1, 1), "entity_id_post_migration": (0, 1), }, ["ix_states_entity_id_last_updated_ts"], @@ -143,13 +143,43 @@ def _create_engine_test( "event_context_id_as_binary": (0, 0), "event_type_id_migration": (0, 0), "entity_id_migration": (2, 1), - "event_id_post_migration": (0, 0), + "event_id_post_migration": (1, 1), "entity_id_post_migration": (0, 1), }, ["ix_states_entity_id_last_updated_ts"], ), ( 38, + { + "state_context_id_as_binary": (0, 0), + "event_context_id_as_binary": (0, 0), + "event_type_id_migration": (0, 0), + "entity_id_migration": (0, 0), + "event_id_post_migration": (1, 1), + "entity_id_post_migration": (0, 0), + }, + [], + ), + ( + 43, + { + "state_context_id_as_binary": (0, 0), + "event_context_id_as_binary": (0, 0), + "event_type_id_migration": (0, 0), + "entity_id_migration": (0, 0), + # Schema was not bumped when the SQLite + # table rebuild was implemented so we need + # run event_id_post_migration up until + # schema 44 since its the first one we can + # be sure has the foreign key constraint was removed + # via https://github.com/home-assistant/core/pull/120779 + "event_id_post_migration": (1, 1), + "entity_id_post_migration": (0, 0), + }, + [], + ), + ( + 44, { "state_context_id_as_binary": (0, 0), "event_context_id_as_binary": (0, 0), @@ -266,8 +296,14 @@ async def test_data_migrator_logic( # the expected number of times. for migrator, mock in migrator_mocks.items(): needs_migrate_calls, migrate_data_calls = expected_migrator_calls[migrator] - assert len(mock["needs_migrate"].mock_calls) == needs_migrate_calls - assert len(mock["migrate_data"].mock_calls) == migrate_data_calls + assert len(mock["needs_migrate"].mock_calls) == needs_migrate_calls, ( + f"Expected {migrator} needs_migrate to be called {needs_migrate_calls} times," + f" got {len(mock['needs_migrate'].mock_calls)}" + ) + assert len(mock["migrate_data"].mock_calls) == migrate_data_calls, ( + f"Expected {migrator} migrate_data to be called {migrate_data_calls} times, " + f"got {len(mock['migrate_data'].mock_calls)}" + ) @pytest.mark.parametrize("enable_migrate_state_context_ids", [True]) From d821aa91626845d2f33e3fdf463edbd6c0697387 Mon Sep 17 00:00:00 2001 From: LG-ThinQ-Integration Date: Sun, 23 Feb 2025 05:51:54 +0900 Subject: [PATCH 1691/3148] Fix dryer's remaining time issue (#138764) Fix dryer's remain_time issue Co-authored-by: yunseon.park --- homeassistant/components/lg_thinq/sensor.py | 48 ++++++++++++--------- 1 file changed, 28 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/lg_thinq/sensor.py b/homeassistant/components/lg_thinq/sensor.py index 95198d931a1..754b07cb2db 100644 --- a/homeassistant/components/lg_thinq/sensor.py +++ b/homeassistant/components/lg_thinq/sensor.py @@ -581,36 +581,44 @@ class ThinQSensorEntity(ThinQEntity, SensorEntity): local_now = datetime.now( tz=dt_util.get_time_zone(self.coordinator.hass.config.time_zone) ) - if value in [0, None, time.min]: - # Reset to None + self._device_state = ( + self.coordinator.data[self._device_state_id].value + if self._device_state_id in self.coordinator.data + else None + ) + if value in [0, None, time.min] or ( + self._device_state == "power_off" + and self.entity_description.key + in [TimerProperty.REMAIN, TimerProperty.TOTAL] + ): + # Reset to None when power_off value = None elif self.entity_description.device_class == SensorDeviceClass.TIMESTAMP: if self.entity_description.key in TIME_SENSOR_DESC: - # Set timestamp for time + # Set timestamp for absolute time value = local_now.replace(hour=value.hour, minute=value.minute) else: # Set timestamp for delta - new_state = ( - self.coordinator.data[self._device_state_id].value - if self._device_state_id in self.coordinator.data - else None - ) - if ( - self.native_value is not None - and self._device_state == new_state - ): - # Skip update when same state - return - - self._device_state = new_state - time_delta = timedelta( + event_data = timedelta( hours=value.hour, minutes=value.minute, seconds=value.second ) - value = ( - (local_now - time_delta) + new_time = ( + (local_now - event_data) if self.entity_description.key == TimerProperty.RUNNING - else (local_now + time_delta) + else (local_now + event_data) ) + # The remain_time may change during the wash/dry operation depending on various reasons. + # If there is a diff of more than 60sec, the new timestamp is used + if ( + parse_native_value := dt_util.parse_datetime( + str(self.native_value) + ) + ) is None or abs(new_time - parse_native_value) > timedelta( + seconds=60 + ): + value = new_time + else: + value = self.native_value elif self.entity_description.device_class == SensorDeviceClass.DURATION: # Set duration value = self._get_duration( From 5a0a3d27d9098c3d572a430c4907bd319930b263 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Feb 2025 15:11:28 -0600 Subject: [PATCH 1692/3148] Bump aiodiscover to 2.6.1 (#139055) changelog: https://github.com/Bluetooth-Devices/aiodiscover/compare/v2.6.0...v2.6.1 --- homeassistant/components/dhcp/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/dhcp/manifest.json b/homeassistant/components/dhcp/manifest.json index 382a9b94ff7..65d43f80abe 100644 --- a/homeassistant/components/dhcp/manifest.json +++ b/homeassistant/components/dhcp/manifest.json @@ -15,7 +15,7 @@ "quality_scale": "internal", "requirements": [ "aiodhcpwatcher==1.1.1", - "aiodiscover==2.6.0", + "aiodiscover==2.6.1", "cached-ipaddress==0.8.1" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 40f7e511332..967ce98a705 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -1,7 +1,7 @@ # Automatically generated by gen_requirements_all.py, do not edit aiodhcpwatcher==1.1.1 -aiodiscover==2.6.0 +aiodiscover==2.6.1 aiodns==3.2.0 aiohasupervisor==0.3.0 aiohttp-asyncmdnsresolver==0.1.1 diff --git a/requirements_all.txt b/requirements_all.txt index 0ffd8b7e781..ab0a714e296 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -219,7 +219,7 @@ aiocomelit==0.10.1 aiodhcpwatcher==1.1.1 # homeassistant.components.dhcp -aiodiscover==2.6.0 +aiodiscover==2.6.1 # homeassistant.components.dnsip aiodns==3.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6d070883303..5b03f3e9197 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -207,7 +207,7 @@ aiocomelit==0.10.1 aiodhcpwatcher==1.1.1 # homeassistant.components.dhcp -aiodiscover==2.6.0 +aiodiscover==2.6.1 # homeassistant.components.dnsip aiodns==3.2.0 From 17c1c0e1553fab9edd0691d35913d184c4bf6b35 Mon Sep 17 00:00:00 2001 From: Indu Prakash <6459774+iprak@users.noreply.github.com> Date: Sat, 22 Feb 2025 17:35:32 -0600 Subject: [PATCH 1693/3148] Remove unnecessary debug message from vesync (#139083) Remove unnecessary debug write --- homeassistant/components/vesync/binary_sensor.py | 1 - 1 file changed, 1 deletion(-) diff --git a/homeassistant/components/vesync/binary_sensor.py b/homeassistant/components/vesync/binary_sensor.py index 620222e4d2f..7b6f14e04dc 100644 --- a/homeassistant/components/vesync/binary_sensor.py +++ b/homeassistant/components/vesync/binary_sensor.py @@ -102,5 +102,4 @@ class VeSyncBinarySensor(BinarySensorEntity, VeSyncBaseEntity): @property def is_on(self) -> bool: """Return true if the binary sensor is on.""" - _LOGGER.debug(rgetattr(self.device, self.entity_description.key)) return self.entity_description.is_on(self.device) From b1b65e4d568514c63dd5af6936404ac0d876bf8b Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 23 Feb 2025 00:59:51 +0100 Subject: [PATCH 1694/3148] Bump py-synologydsm-api to 2.7.0 (#139082) bump py-synologydsm-api to 2.7.0 --- homeassistant/components/synology_dsm/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/synology_dsm/manifest.json b/homeassistant/components/synology_dsm/manifest.json index d076d843c36..dc5634e7a84 100644 --- a/homeassistant/components/synology_dsm/manifest.json +++ b/homeassistant/components/synology_dsm/manifest.json @@ -7,7 +7,7 @@ "documentation": "https://www.home-assistant.io/integrations/synology_dsm", "iot_class": "local_polling", "loggers": ["synology_dsm"], - "requirements": ["py-synologydsm-api==2.6.3"], + "requirements": ["py-synologydsm-api==2.7.0"], "ssdp": [ { "manufacturer": "Synology", diff --git a/requirements_all.txt b/requirements_all.txt index ab0a714e296..d55aec73653 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1749,7 +1749,7 @@ py-schluter==0.1.7 py-sucks==0.9.10 # homeassistant.components.synology_dsm -py-synologydsm-api==2.6.3 +py-synologydsm-api==2.7.0 # homeassistant.components.atome pyAtome==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5b03f3e9197..f751c87ace6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1447,7 +1447,7 @@ py-nightscout==1.2.2 py-sucks==0.9.10 # homeassistant.components.synology_dsm -py-synologydsm-api==2.6.3 +py-synologydsm-api==2.7.0 # homeassistant.components.hdmi_cec pyCEC==0.5.2 From 5b0eca7f8578c6e40154a00780d52613c1ffb453 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Sun, 23 Feb 2025 01:42:25 +0100 Subject: [PATCH 1695/3148] Add select setting entities to Home Connect (#138884) * Add select setting entities * Improvements --- .../components/home_connect/const.py | 4 +- .../components/home_connect/select.py | 225 +++++++++++++----- .../components/home_connect/strings.json | 26 ++ .../home_connect/fixtures/settings.json | 11 +- .../snapshots/test_diagnostics.ambr | 2 +- tests/components/home_connect/test_select.py | 130 ++++++++++ 6 files changed, 340 insertions(+), 58 deletions(-) diff --git a/homeassistant/components/home_connect/const.py b/homeassistant/components/home_connect/const.py index 3a22297ebee..692a5e91851 100644 --- a/homeassistant/components/home_connect/const.py +++ b/homeassistant/components/home_connect/const.py @@ -87,7 +87,7 @@ PROGRAMS_TRANSLATION_KEYS_MAP = { value: key for key, value in TRANSLATION_KEYS_PROGRAMS_MAP.items() } -REFERENCE_MAP_ID_OPTIONS = { +AVAILABLE_MAPS_ENUM = { bsh_key_to_translation_key(option): option for option in ( "ConsumerProducts.CleaningRobot.EnumType.AvailableMaps.TempMap", @@ -305,7 +305,7 @@ PROGRAM_ENUM_OPTIONS = { for option_key, options in ( ( OptionKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_REFERENCE_MAP_ID, - REFERENCE_MAP_ID_OPTIONS, + AVAILABLE_MAPS_ENUM, ), ( OptionKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_CLEANING_MODE, diff --git a/homeassistant/components/home_connect/select.py b/homeassistant/components/home_connect/select.py index f5298056080..e4d50b0d5e9 100644 --- a/homeassistant/components/home_connect/select.py +++ b/homeassistant/components/home_connect/select.py @@ -5,7 +5,7 @@ from dataclasses import dataclass from typing import Any, cast from aiohomeconnect.client import Client as HomeConnectClient -from aiohomeconnect.model import EventKey, OptionKey, ProgramKey +from aiohomeconnect.model import EventKey, OptionKey, ProgramKey, SettingKey from aiohomeconnect.model.error import HomeConnectError from aiohomeconnect.model.program import Execution @@ -17,6 +17,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .common import setup_home_connect_entry from .const import ( APPLIANCES_WITH_PROGRAMS, + AVAILABLE_MAPS_ENUM, BEAN_AMOUNT_OPTIONS, BEAN_CONTAINER_OPTIONS, CLEANING_MODE_OPTIONS, @@ -28,9 +29,12 @@ from .const import ( HOT_WATER_TEMPERATURE_OPTIONS, INTENSIVE_LEVEL_OPTIONS, PROGRAMS_TRANSLATION_KEYS_MAP, - REFERENCE_MAP_ID_OPTIONS, SPIN_SPEED_OPTIONS, + SVE_TRANSLATION_KEY_SET_SETTING, + SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID, + SVE_TRANSLATION_PLACEHOLDER_KEY, SVE_TRANSLATION_PLACEHOLDER_PROGRAM, + SVE_TRANSLATION_PLACEHOLDER_VALUE, TEMPERATURE_OPTIONS, TRANSLATION_KEYS_PROGRAMS_MAP, VARIO_PERFECT_OPTIONS, @@ -43,7 +47,30 @@ from .coordinator import ( HomeConnectCoordinator, ) from .entity import HomeConnectEntity, HomeConnectOptionEntity -from .utils import get_dict_from_home_connect_error +from .utils import bsh_key_to_translation_key, get_dict_from_home_connect_error + +FUNCTIONAL_LIGHT_COLOR_TEMPERATURE_ENUM = { + bsh_key_to_translation_key(option): option + for option in ( + "Cooking.Hood.EnumType.ColorTemperature.custom", + "Cooking.Hood.EnumType.ColorTemperature.warm", + "Cooking.Hood.EnumType.ColorTemperature.warmToNeutral", + "Cooking.Hood.EnumType.ColorTemperature.neutral", + "Cooking.Hood.EnumType.ColorTemperature.neutralToCold", + "Cooking.Hood.EnumType.ColorTemperature.cold", + ) +} + +AMBIENT_LIGHT_COLOR_TEMPERATURE_ENUM = { + **{ + bsh_key_to_translation_key(option): option + for option in ("BSH.Common.EnumType.AmbientLightColor.CustomColor",) + }, + **{ + str(option): f"BSH.Common.EnumType.AmbientLightColor.Color{option}" + for option in range(1, 100) + }, +} @dataclass(frozen=True, kw_only=True) @@ -60,10 +87,8 @@ class HomeConnectProgramSelectEntityDescription( @dataclass(frozen=True, kw_only=True) -class HomeConnectSelectOptionEntityDescription( - SelectEntityDescription, -): - """Entity Description class for options that have enumeration values.""" +class HomeConnectSelectEntityDescription(SelectEntityDescription): + """Entity Description class for settings and options that have enumeration values.""" translation_key_values: dict[str, str] values_translation_key: dict[str, str] @@ -90,151 +115,184 @@ PROGRAM_SELECT_ENTITY_DESCRIPTIONS = ( ), ) -PROGRAM_SELECT_OPTION_ENTITY_DESCRIPTIONS = ( - HomeConnectSelectOptionEntityDescription( - key=OptionKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_REFERENCE_MAP_ID, - translation_key="reference_map_id", - options=list(REFERENCE_MAP_ID_OPTIONS.keys()), - translation_key_values=REFERENCE_MAP_ID_OPTIONS, +SELECT_ENTITY_DESCRIPTIONS = ( + HomeConnectSelectEntityDescription( + key=SettingKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_CURRENT_MAP, + translation_key="current_map", + options=list(AVAILABLE_MAPS_ENUM), + translation_key_values=AVAILABLE_MAPS_ENUM, values_translation_key={ value: translation_key - for translation_key, value in REFERENCE_MAP_ID_OPTIONS.items() + for translation_key, value in AVAILABLE_MAPS_ENUM.items() }, ), - HomeConnectSelectOptionEntityDescription( - key=OptionKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_CLEANING_MODE, + HomeConnectSelectEntityDescription( + key=SettingKey.COOKING_HOOD_COLOR_TEMPERATURE, + translation_key="functional_light_color_temperature", + options=list(FUNCTIONAL_LIGHT_COLOR_TEMPERATURE_ENUM), + translation_key_values=FUNCTIONAL_LIGHT_COLOR_TEMPERATURE_ENUM, + values_translation_key={ + value: translation_key + for translation_key, value in FUNCTIONAL_LIGHT_COLOR_TEMPERATURE_ENUM.items() + }, + ), + HomeConnectSelectEntityDescription( + key=SettingKey.BSH_COMMON_AMBIENT_LIGHT_COLOR, + translation_key="ambient_light_color", + options=list(AMBIENT_LIGHT_COLOR_TEMPERATURE_ENUM), + translation_key_values=AMBIENT_LIGHT_COLOR_TEMPERATURE_ENUM, + values_translation_key={ + value: translation_key + for translation_key, value in AMBIENT_LIGHT_COLOR_TEMPERATURE_ENUM.items() + }, + ), +) + +PROGRAM_SELECT_OPTION_ENTITY_DESCRIPTIONS = ( + HomeConnectSelectEntityDescription( + key=OptionKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_REFERENCE_MAP_ID, translation_key="reference_map_id", - options=list(CLEANING_MODE_OPTIONS.keys()), + options=list(AVAILABLE_MAPS_ENUM), + translation_key_values=AVAILABLE_MAPS_ENUM, + values_translation_key={ + value: translation_key + for translation_key, value in AVAILABLE_MAPS_ENUM.items() + }, + ), + HomeConnectSelectEntityDescription( + key=OptionKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_CLEANING_MODE, + translation_key="cleaning_mode", + options=list(CLEANING_MODE_OPTIONS), translation_key_values=CLEANING_MODE_OPTIONS, values_translation_key={ value: translation_key for translation_key, value in CLEANING_MODE_OPTIONS.items() }, ), - HomeConnectSelectOptionEntityDescription( + HomeConnectSelectEntityDescription( key=OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_BEAN_AMOUNT, translation_key="bean_amount", - options=list(BEAN_AMOUNT_OPTIONS.keys()), + options=list(BEAN_AMOUNT_OPTIONS), translation_key_values=BEAN_AMOUNT_OPTIONS, values_translation_key={ value: translation_key for translation_key, value in BEAN_AMOUNT_OPTIONS.items() }, ), - HomeConnectSelectOptionEntityDescription( + HomeConnectSelectEntityDescription( key=OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_COFFEE_TEMPERATURE, translation_key="coffee_temperature", - options=list(COFFEE_TEMPERATURE_OPTIONS.keys()), + options=list(COFFEE_TEMPERATURE_OPTIONS), translation_key_values=COFFEE_TEMPERATURE_OPTIONS, values_translation_key={ value: translation_key for translation_key, value in COFFEE_TEMPERATURE_OPTIONS.items() }, ), - HomeConnectSelectOptionEntityDescription( + HomeConnectSelectEntityDescription( key=OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_BEAN_CONTAINER_SELECTION, translation_key="bean_container", - options=list(BEAN_CONTAINER_OPTIONS.keys()), + options=list(BEAN_CONTAINER_OPTIONS), translation_key_values=BEAN_CONTAINER_OPTIONS, values_translation_key={ value: translation_key for translation_key, value in BEAN_CONTAINER_OPTIONS.items() }, ), - HomeConnectSelectOptionEntityDescription( + HomeConnectSelectEntityDescription( key=OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_FLOW_RATE, translation_key="flow_rate", - options=list(FLOW_RATE_OPTIONS.keys()), + options=list(FLOW_RATE_OPTIONS), translation_key_values=FLOW_RATE_OPTIONS, values_translation_key={ value: translation_key for translation_key, value in FLOW_RATE_OPTIONS.items() }, ), - HomeConnectSelectOptionEntityDescription( + HomeConnectSelectEntityDescription( key=OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_COFFEE_MILK_RATIO, translation_key="coffee_milk_ratio", - options=list(COFFEE_MILK_RATIO_OPTIONS.keys()), + options=list(COFFEE_MILK_RATIO_OPTIONS), translation_key_values=COFFEE_MILK_RATIO_OPTIONS, values_translation_key={ value: translation_key for translation_key, value in FLOW_RATE_OPTIONS.items() }, ), - HomeConnectSelectOptionEntityDescription( + HomeConnectSelectEntityDescription( key=OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_HOT_WATER_TEMPERATURE, translation_key="hot_water_temperature", - options=list(HOT_WATER_TEMPERATURE_OPTIONS.keys()), + options=list(HOT_WATER_TEMPERATURE_OPTIONS), translation_key_values=HOT_WATER_TEMPERATURE_OPTIONS, values_translation_key={ value: translation_key for translation_key, value in HOT_WATER_TEMPERATURE_OPTIONS.items() }, ), - HomeConnectSelectOptionEntityDescription( + HomeConnectSelectEntityDescription( key=OptionKey.LAUNDRY_CARE_DRYER_DRYING_TARGET, translation_key="drying_target", - options=list(DRYING_TARGET_OPTIONS.keys()), + options=list(DRYING_TARGET_OPTIONS), translation_key_values=DRYING_TARGET_OPTIONS, values_translation_key={ value: translation_key for translation_key, value in DRYING_TARGET_OPTIONS.items() }, ), - HomeConnectSelectOptionEntityDescription( + HomeConnectSelectEntityDescription( key=OptionKey.COOKING_COMMON_HOOD_VENTING_LEVEL, translation_key="venting_level", - options=list(VENTING_LEVEL_OPTIONS.keys()), + options=list(VENTING_LEVEL_OPTIONS), translation_key_values=VENTING_LEVEL_OPTIONS, values_translation_key={ value: translation_key for translation_key, value in VENTING_LEVEL_OPTIONS.items() }, ), - HomeConnectSelectOptionEntityDescription( + HomeConnectSelectEntityDescription( key=OptionKey.COOKING_COMMON_HOOD_INTENSIVE_LEVEL, translation_key="intensive_level", - options=list(INTENSIVE_LEVEL_OPTIONS.keys()), + options=list(INTENSIVE_LEVEL_OPTIONS), translation_key_values=INTENSIVE_LEVEL_OPTIONS, values_translation_key={ value: translation_key for translation_key, value in INTENSIVE_LEVEL_OPTIONS.items() }, ), - HomeConnectSelectOptionEntityDescription( + HomeConnectSelectEntityDescription( key=OptionKey.COOKING_OVEN_WARMING_LEVEL, translation_key="warming_level", - options=list(WARMING_LEVEL_OPTIONS.keys()), + options=list(WARMING_LEVEL_OPTIONS), translation_key_values=WARMING_LEVEL_OPTIONS, values_translation_key={ value: translation_key for translation_key, value in WARMING_LEVEL_OPTIONS.items() }, ), - HomeConnectSelectOptionEntityDescription( + HomeConnectSelectEntityDescription( key=OptionKey.LAUNDRY_CARE_WASHER_TEMPERATURE, translation_key="washer_temperature", - options=list(TEMPERATURE_OPTIONS.keys()), + options=list(TEMPERATURE_OPTIONS), translation_key_values=TEMPERATURE_OPTIONS, values_translation_key={ value: translation_key for translation_key, value in TEMPERATURE_OPTIONS.items() }, ), - HomeConnectSelectOptionEntityDescription( + HomeConnectSelectEntityDescription( key=OptionKey.LAUNDRY_CARE_WASHER_SPIN_SPEED, translation_key="spin_speed", - options=list(SPIN_SPEED_OPTIONS.keys()), + options=list(SPIN_SPEED_OPTIONS), translation_key_values=SPIN_SPEED_OPTIONS, values_translation_key={ value: translation_key for translation_key, value in SPIN_SPEED_OPTIONS.items() }, ), - HomeConnectSelectOptionEntityDescription( + HomeConnectSelectEntityDescription( key=OptionKey.LAUNDRY_CARE_COMMON_VARIO_PERFECT, translation_key="vario_perfect", - options=list(VARIO_PERFECT_OPTIONS.keys()), + options=list(VARIO_PERFECT_OPTIONS), translation_key_values=VARIO_PERFECT_OPTIONS, values_translation_key={ value: translation_key @@ -249,14 +307,21 @@ def _get_entities_for_appliance( appliance: HomeConnectApplianceData, ) -> list[HomeConnectEntity]: """Get a list of entities.""" - return ( - [ - HomeConnectProgramSelectEntity(entry.runtime_data, appliance, desc) - for desc in PROGRAM_SELECT_ENTITY_DESCRIPTIONS - ] - if appliance.info.type in APPLIANCES_WITH_PROGRAMS - else [] - ) + return [ + *( + [ + HomeConnectProgramSelectEntity(entry.runtime_data, appliance, desc) + for desc in PROGRAM_SELECT_ENTITY_DESCRIPTIONS + ] + if appliance.info.type in APPLIANCES_WITH_PROGRAMS + else [] + ), + *[ + HomeConnectSelectEntity(entry.runtime_data, appliance, desc) + for desc in SELECT_ENTITY_DESCRIPTIONS + if desc.key in appliance.settings + ], + ] def _get_option_entities_for_appliance( @@ -341,17 +406,71 @@ class HomeConnectProgramSelectEntity(HomeConnectEntity, SelectEntity): ) from err +class HomeConnectSelectEntity(HomeConnectEntity, SelectEntity): + """Select setting class for Home Connect.""" + + entity_description: HomeConnectSelectEntityDescription + + def __init__( + self, + coordinator: HomeConnectCoordinator, + appliance: HomeConnectApplianceData, + desc: HomeConnectSelectEntityDescription, + ) -> None: + """Initialize the entity.""" + super().__init__( + coordinator, + appliance, + desc, + ) + setting = appliance.settings.get(cast(SettingKey, desc.key)) + if setting and setting.constraints and setting.constraints.allowed_values: + self._attr_options = [ + desc.values_translation_key[option] + for option in setting.constraints.allowed_values + if option in desc.values_translation_key + ] + + async def async_select_option(self, option: str) -> None: + """Select new option.""" + value = self.entity_description.translation_key_values[option] + try: + await self.coordinator.client.set_setting( + self.appliance.info.ha_id, + setting_key=cast(SettingKey, self.bsh_key), + value=value, + ) + except HomeConnectError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key=SVE_TRANSLATION_KEY_SET_SETTING, + translation_placeholders={ + **get_dict_from_home_connect_error(err), + SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID: self.entity_id, + SVE_TRANSLATION_PLACEHOLDER_KEY: self.bsh_key, + SVE_TRANSLATION_PLACEHOLDER_VALUE: value, + }, + ) from err + + def update_native_value(self) -> None: + """Set the value of the entity.""" + data = self.appliance.settings[cast(SettingKey, self.bsh_key)] + self._attr_current_option = self.entity_description.values_translation_key.get( + data.value + ) + + class HomeConnectSelectOptionEntity(HomeConnectOptionEntity, SelectEntity): """Select option class for Home Connect.""" - entity_description: HomeConnectSelectOptionEntityDescription + entity_description: HomeConnectSelectEntityDescription _original_option_keys: set[str | None] def __init__( self, coordinator: HomeConnectCoordinator, appliance: HomeConnectApplianceData, - desc: HomeConnectSelectOptionEntityDescription, + desc: HomeConnectSelectEntityDescription, ) -> None: """Initialize the entity.""" self._original_option_keys = set(desc.values_translation_key.keys()) diff --git a/homeassistant/components/home_connect/strings.json b/homeassistant/components/home_connect/strings.json index db53e76fb95..dde002d1caa 100644 --- a/homeassistant/components/home_connect/strings.json +++ b/homeassistant/components/home_connect/strings.json @@ -1215,6 +1215,32 @@ "laundry_care_washer_dryer_program_wash_and_dry_90": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_dryer_program_wash_and_dry_90%]" } }, + "current_map": { + "name": "Current map", + "state": { + "consumer_products_cleaning_robot_enum_type_available_maps_temp_map": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_temp_map%]", + "consumer_products_cleaning_robot_enum_type_available_maps_map1": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_map1%]", + "consumer_products_cleaning_robot_enum_type_available_maps_map2": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_map2%]", + "consumer_products_cleaning_robot_enum_type_available_maps_map3": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_map3%]" + } + }, + "functional_light_color_temperature": { + "name": "Functional light color temperature", + "state": { + "cooking_hood_enum_type_color_temperature_custom": "Custom", + "cooking_hood_enum_type_color_temperature_warm": "Warm", + "cooking_hood_enum_type_color_temperature_warm_to_neutral": "Warm to Neutral", + "cooking_hood_enum_type_color_temperature_neutral": "Neutral", + "cooking_hood_enum_type_color_temperature_neutral_to_cold": "Neutral to Cold", + "cooking_hood_enum_type_color_temperature_cold": "Cold" + } + }, + "ambient_light_color": { + "name": "Ambient light color", + "state": { + "b_s_h_common_enum_type_ambient_light_color_custom_color": "Custom" + } + }, "reference_map_id": { "name": "[%key:component::home_connect::services::set_program_and_options::fields::consumer_products_cleaning_robot_option_reference_map_id::name%]", "state": { diff --git a/tests/components/home_connect/fixtures/settings.json b/tests/components/home_connect/fixtures/settings.json index 8f649e5790b..bd1bea18365 100644 --- a/tests/components/home_connect/fixtures/settings.json +++ b/tests/components/home_connect/fixtures/settings.json @@ -68,9 +68,16 @@ "type": "Double" }, { - "key": "BSH.Common.Setting.ColorTemperature", + "key": "Cooking.Hood.Setting.ColorTemperature", "value": "Cooking.Hood.EnumType.ColorTemperature.warmToNeutral", - "type": "BSH.Common.EnumType.ColorTemperature" + "type": "BSH.Common.EnumType.ColorTemperature", + "constraints": { + "allowedvalues": [ + "Cooking.Hood.EnumType.ColorTemperature.warm", + "Cooking.Hood.EnumType.ColorTemperature.neutral", + "Cooking.Hood.EnumType.ColorTemperature.cold" + ] + } }, { "key": "BSH.Common.Setting.AmbientLightEnabled", diff --git a/tests/components/home_connect/snapshots/test_diagnostics.ambr b/tests/components/home_connect/snapshots/test_diagnostics.ambr index 512da8bd970..28f45ce97ba 100644 --- a/tests/components/home_connect/snapshots/test_diagnostics.ambr +++ b/tests/components/home_connect/snapshots/test_diagnostics.ambr @@ -98,8 +98,8 @@ 'BSH.Common.Setting.AmbientLightEnabled': True, 'Cooking.Common.Setting.Lighting': True, 'Cooking.Common.Setting.LightingBrightness': 70, + 'Cooking.Hood.Setting.ColorTemperature': 'Cooking.Hood.EnumType.ColorTemperature.warmToNeutral', 'Cooking.Hood.Setting.ColorTemperaturePercent': 70, - 'unknown': 'Cooking.Hood.EnumType.ColorTemperature.warmToNeutral', }), 'status': dict({ 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed', diff --git a/tests/components/home_connect/test_select.py b/tests/components/home_connect/test_select.py index 917c092136e..d98dbd8e5f6 100644 --- a/tests/components/home_connect/test_select.py +++ b/tests/components/home_connect/test_select.py @@ -6,13 +6,16 @@ from unittest.mock import AsyncMock, MagicMock from aiohomeconnect.model import ( ArrayOfEvents, ArrayOfPrograms, + ArrayOfSettings, Event, EventKey, EventMessage, EventType, + GetSetting, OptionKey, ProgramDefinition, ProgramKey, + SettingKey, ) from aiohomeconnect.model.error import ( ActiveProgramNotSetError, @@ -26,6 +29,7 @@ from aiohomeconnect.model.program import ( ProgramDefinitionConstraints, ProgramDefinitionOption, ) +from aiohomeconnect.model.setting import SettingConstraints import pytest from homeassistant.components.home_connect.const import DOMAIN @@ -434,6 +438,132 @@ async def test_select_exception_handling( assert getattr(client_with_exception, mock_attr).call_count == 2 +@pytest.mark.parametrize("appliance_ha_id", ["Hood"], indirect=True) +@pytest.mark.parametrize( + ( + "entity_id", + "setting_key", + "expected_options", + "value_to_set", + "expected_value_call_arg", + ), + [ + ( + "select.hood_functional_light_color_temperature", + SettingKey.COOKING_HOOD_COLOR_TEMPERATURE, + { + "cooking_hood_enum_type_color_temperature_warm", + "cooking_hood_enum_type_color_temperature_neutral", + "cooking_hood_enum_type_color_temperature_cold", + }, + "cooking_hood_enum_type_color_temperature_neutral", + "Cooking.Hood.EnumType.ColorTemperature.neutral", + ), + ( + "select.hood_ambient_light_color", + SettingKey.BSH_COMMON_AMBIENT_LIGHT_COLOR, + { + "b_s_h_common_enum_type_ambient_light_color_custom_color", + *[str(i) for i in range(1, 100)], + }, + "42", + "BSH.Common.EnumType.AmbientLightColor.Color42", + ), + ], +) +async def test_select_functionality( + appliance_ha_id: str, + entity_id: str, + setting_key: SettingKey, + expected_options: set[str], + value_to_set: str, + expected_value_call_arg: str, + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, +) -> None: + """Test select functionality.""" + assert config_entry.state is ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state is ConfigEntryState.LOADED + + entity_state = hass.states.get(entity_id) + assert entity_state + assert set(entity_state.attributes[ATTR_OPTIONS]) == expected_options + + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: entity_id, "option": value_to_set}, + ) + await hass.async_block_till_done() + + client.set_setting.assert_called_once() + assert client.set_setting.call_args.args == (appliance_ha_id,) + assert client.set_setting.call_args.kwargs == { + "setting_key": setting_key, + "value": expected_value_call_arg, + } + assert hass.states.is_state(entity_id, value_to_set) + + +@pytest.mark.parametrize( + ("entity_id", "setting_key", "allowed_value", "value_to_set", "mock_attr"), + [ + ( + "select.hood_functional_light_color_temperature", + SettingKey.COOKING_HOOD_COLOR_TEMPERATURE, + "Cooking.Hood.EnumType.ColorTemperature.neutral", + "cooking_hood_enum_type_color_temperature_neutral", + "set_setting", + ), + ], +) +async def test_select_entity_error( + entity_id: str, + setting_key: SettingKey, + allowed_value: str, + value_to_set: str, + mock_attr: str, + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client_with_exception: MagicMock, +) -> None: + """Test select entity error.""" + client_with_exception.get_settings.side_effect = None + client_with_exception.get_settings.return_value = ArrayOfSettings( + [ + GetSetting( + key=setting_key, + raw_key=setting_key.value, + value=value_to_set, + constraints=SettingConstraints(allowed_values=[allowed_value]), + ) + ] + ) + assert config_entry.state is ConfigEntryState.NOT_LOADED + assert await integration_setup(client_with_exception) + assert config_entry.state is ConfigEntryState.LOADED + + with pytest.raises(HomeConnectError): + await getattr(client_with_exception, mock_attr)() + + with pytest.raises( + HomeAssistantError, match=r"Error.*assign.*value.*to.*setting.*" + ): + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: entity_id, "option": value_to_set}, + blocking=True, + ) + assert getattr(client_with_exception, mock_attr).call_count == 2 + + @pytest.mark.parametrize( ( "set_active_program_options_side_effect", From 8ce2727447c8b0c3b79c4a5ac0cdac1ca0db2828 Mon Sep 17 00:00:00 2001 From: javers99 <90975080+javers99@users.noreply.github.com> Date: Sun, 23 Feb 2025 00:45:44 +0000 Subject: [PATCH 1696/3148] Fix typo in SSH connection string for cisco ios device_tracker (#138584) Update device_tracker.py Typo in "uft-8" -> pxssh.pxssh(encoding="utf-8") --- homeassistant/components/cisco_ios/device_tracker.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/cisco_ios/device_tracker.py b/homeassistant/components/cisco_ios/device_tracker.py index 0477ebb111c..6cc403817cf 100644 --- a/homeassistant/components/cisco_ios/device_tracker.py +++ b/homeassistant/components/cisco_ios/device_tracker.py @@ -104,7 +104,7 @@ class CiscoDeviceScanner(DeviceScanner): """Open connection to the router and get arp entries.""" try: - cisco_ssh: pxssh.pxssh[str] = pxssh.pxssh(encoding="uft-8") + cisco_ssh: pxssh.pxssh[str] = pxssh.pxssh(encoding="utf-8") cisco_ssh.login( self.host, self.username, From 0797c3228b513086ab98e48d2cfc3a09bbd4b4ca Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Sun, 23 Feb 2025 08:35:00 +0000 Subject: [PATCH 1697/3148] Bump pyprosegur to 0.0.14 (#139077) bump pyprosegur --- homeassistant/components/prosegur/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/prosegur/manifest.json b/homeassistant/components/prosegur/manifest.json index 6419b81aa7f..2e649ebd5bd 100644 --- a/homeassistant/components/prosegur/manifest.json +++ b/homeassistant/components/prosegur/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/prosegur", "iot_class": "cloud_polling", "loggers": ["pyprosegur"], - "requirements": ["pyprosegur==0.0.13"] + "requirements": ["pyprosegur==0.0.14"] } diff --git a/requirements_all.txt b/requirements_all.txt index d55aec73653..ef4360a2061 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2223,7 +2223,7 @@ pypoint==3.0.0 pyprof2calltree==1.4.5 # homeassistant.components.prosegur -pyprosegur==0.0.13 +pyprosegur==0.0.14 # homeassistant.components.prusalink pyprusalink==2.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f751c87ace6..b78b82d8f2e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1816,7 +1816,7 @@ pypoint==3.0.0 pyprof2calltree==1.4.5 # homeassistant.components.prosegur -pyprosegur==0.0.13 +pyprosegur==0.0.14 # homeassistant.components.prusalink pyprusalink==2.1.1 From 91668e99e326fcdf8dec20a3faa7f8640d7005bc Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 23 Feb 2025 04:51:25 -0500 Subject: [PATCH 1698/3148] OpenAI to report when running out of funds (#139088) --- .../openai_conversation/conversation.py | 3 ++ .../openai_conversation/test_conversation.py | 31 +++++++++++++++---- 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/openai_conversation/conversation.py b/homeassistant/components/openai_conversation/conversation.py index fddabb740ac..cc09ec77c0e 100644 --- a/homeassistant/components/openai_conversation/conversation.py +++ b/homeassistant/components/openai_conversation/conversation.py @@ -287,6 +287,9 @@ class OpenAIConversationEntity( try: result = await client.chat.completions.create(**model_args) + except openai.RateLimitError as err: + LOGGER.error("Rate limited by OpenAI: %s", err) + raise HomeAssistantError("Rate limited or insufficient funds") from err except openai.OpenAIError as err: LOGGER.error("Error talking to OpenAI: %s", err) raise HomeAssistantError("Error talking to OpenAI") from err diff --git a/tests/components/openai_conversation/test_conversation.py b/tests/components/openai_conversation/test_conversation.py index 2c956b7e63f..238fd5f2d7b 100644 --- a/tests/components/openai_conversation/test_conversation.py +++ b/tests/components/openai_conversation/test_conversation.py @@ -4,7 +4,7 @@ from collections.abc import Generator from unittest.mock import AsyncMock, patch from httpx import Response -from openai import RateLimitError +from openai import AuthenticationError, RateLimitError from openai.types.chat.chat_completion_chunk import ( ChatCompletionChunk, Choice, @@ -94,23 +94,42 @@ async def test_entity( ) +@pytest.mark.parametrize( + ("exception", "message"), + [ + ( + RateLimitError( + response=Response(status_code=429, request=""), body=None, message=None + ), + "Rate limited or insufficient funds", + ), + ( + AuthenticationError( + response=Response(status_code=401, request=""), body=None, message=None + ), + "Error talking to OpenAI", + ), + ], +) async def test_error_handling( - hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_init_component + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_init_component, + exception, + message, ) -> None: """Test that we handle errors when calling completion API.""" with patch( "openai.resources.chat.completions.AsyncCompletions.create", new_callable=AsyncMock, - side_effect=RateLimitError( - response=Response(status_code=None, request=""), body=None, message=None - ), + side_effect=exception, ): result = await conversation.async_converse( hass, "hello", None, Context(), agent_id=mock_config_entry.entry_id ) assert result.response.response_type == intent.IntentResponseType.ERROR, result - assert result.response.error_code == "unknown", result + assert result.response.speech["plain"]["speech"] == message, result.response.speech async def test_conversation_agent( From 746d1800f98021d0cab182af0d75c6d5081dad9b Mon Sep 17 00:00:00 2001 From: David Bonnes Date: Sun, 23 Feb 2025 11:43:25 +0000 Subject: [PATCH 1699/3148] Add tests to Evohome for its native services (#139104) initial commit --- homeassistant/components/evohome/__init__.py | 20 +- homeassistant/components/evohome/climate.py | 21 +-- homeassistant/components/evohome/const.py | 7 +- tests/components/evohome/test_evo_services.py | 177 ++++++++++++++++++ tests/components/evohome/test_init.py | 42 +---- 5 files changed, 202 insertions(+), 65 deletions(-) create mode 100644 tests/components/evohome/test_evo_services.py diff --git a/homeassistant/components/evohome/__init__.py b/homeassistant/components/evohome/__init__.py index e322e266b8a..9dce352df30 100644 --- a/homeassistant/components/evohome/__init__.py +++ b/homeassistant/components/evohome/__init__.py @@ -25,6 +25,7 @@ import voluptuous as vol from homeassistant.const import ( ATTR_ENTITY_ID, + ATTR_MODE, CONF_PASSWORD, CONF_SCAN_INTERVAL, CONF_USERNAME, @@ -40,11 +41,10 @@ from homeassistant.helpers.typing import ConfigType from homeassistant.util.hass_dict import HassKey from .const import ( - ATTR_DURATION_DAYS, - ATTR_DURATION_HOURS, + ATTR_DURATION, ATTR_DURATION_UNTIL, - ATTR_SYSTEM_MODE, - ATTR_ZONE_TEMP, + ATTR_PERIOD, + ATTR_SETPOINT, CONF_LOCATION_IDX, DOMAIN, SCAN_INTERVAL_DEFAULT, @@ -81,7 +81,7 @@ RESET_ZONE_OVERRIDE_SCHEMA: Final = vol.Schema( SET_ZONE_OVERRIDE_SCHEMA: Final = vol.Schema( { vol.Required(ATTR_ENTITY_ID): cv.entity_id, - vol.Required(ATTR_ZONE_TEMP): vol.All( + vol.Required(ATTR_SETPOINT): vol.All( vol.Coerce(float), vol.Range(min=4.0, max=35.0) ), vol.Optional(ATTR_DURATION_UNTIL): vol.All( @@ -222,7 +222,7 @@ def setup_service_functions( # Permanent-only modes will use this schema perm_modes = [m[SZ_SYSTEM_MODE] for m in modes if not m[SZ_CAN_BE_TEMPORARY]] if perm_modes: # any of: "Auto", "HeatingOff": permanent only - schema = vol.Schema({vol.Required(ATTR_SYSTEM_MODE): vol.In(perm_modes)}) + schema = vol.Schema({vol.Required(ATTR_MODE): vol.In(perm_modes)}) system_mode_schemas.append(schema) modes = [m for m in modes if m[SZ_CAN_BE_TEMPORARY]] @@ -232,8 +232,8 @@ def setup_service_functions( if temp_modes: # any of: "AutoWithEco", permanent or for 0-24 hours schema = vol.Schema( { - vol.Required(ATTR_SYSTEM_MODE): vol.In(temp_modes), - vol.Optional(ATTR_DURATION_HOURS): vol.All( + vol.Required(ATTR_MODE): vol.In(temp_modes), + vol.Optional(ATTR_DURATION): vol.All( cv.time_period, vol.Range(min=timedelta(hours=0), max=timedelta(hours=24)), ), @@ -246,8 +246,8 @@ def setup_service_functions( if temp_modes: # any of: "Away", "Custom", "DayOff", permanent or for 1-99 days schema = vol.Schema( { - vol.Required(ATTR_SYSTEM_MODE): vol.In(temp_modes), - vol.Optional(ATTR_DURATION_DAYS): vol.All( + vol.Required(ATTR_MODE): vol.In(temp_modes), + vol.Optional(ATTR_PERIOD): vol.All( cv.time_period, vol.Range(min=timedelta(days=1), max=timedelta(days=99)), ), diff --git a/homeassistant/components/evohome/climate.py b/homeassistant/components/evohome/climate.py index 8a455b300f8..b44dc9791b0 100644 --- a/homeassistant/components/evohome/climate.py +++ b/homeassistant/components/evohome/climate.py @@ -29,7 +29,7 @@ from homeassistant.components.climate import ( ClimateEntityFeature, HVACMode, ) -from homeassistant.const import PRECISION_TENTHS, UnitOfTemperature +from homeassistant.const import ATTR_MODE, PRECISION_TENTHS, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -38,11 +38,10 @@ from homeassistant.util import dt as dt_util from . import EVOHOME_KEY from .const import ( - ATTR_DURATION_DAYS, - ATTR_DURATION_HOURS, + ATTR_DURATION, ATTR_DURATION_UNTIL, - ATTR_SYSTEM_MODE, - ATTR_ZONE_TEMP, + ATTR_PERIOD, + ATTR_SETPOINT, EvoService, ) from .coordinator import EvoDataUpdateCoordinator @@ -180,7 +179,7 @@ class EvoZone(EvoChild, EvoClimateEntity): return # otherwise it is EvoService.SET_ZONE_OVERRIDE - temperature = max(min(data[ATTR_ZONE_TEMP], self.max_temp), self.min_temp) + temperature = max(min(data[ATTR_SETPOINT], self.max_temp), self.min_temp) if ATTR_DURATION_UNTIL in data: duration: timedelta = data[ATTR_DURATION_UNTIL] @@ -349,16 +348,16 @@ class EvoController(EvoClimateEntity): Data validation is not required, it will have been done upstream. """ if service == EvoService.SET_SYSTEM_MODE: - mode = data[ATTR_SYSTEM_MODE] + mode = data[ATTR_MODE] else: # otherwise it is EvoService.RESET_SYSTEM mode = EvoSystemMode.AUTO_WITH_RESET - if ATTR_DURATION_DAYS in data: + if ATTR_PERIOD in data: until = dt_util.start_of_local_day() - until += data[ATTR_DURATION_DAYS] + until += data[ATTR_PERIOD] - elif ATTR_DURATION_HOURS in data: - until = dt_util.now() + data[ATTR_DURATION_HOURS] + elif ATTR_DURATION in data: + until = dt_util.now() + data[ATTR_DURATION] else: until = None diff --git a/homeassistant/components/evohome/const.py b/homeassistant/components/evohome/const.py index 12642addfa4..9da5969df1e 100644 --- a/homeassistant/components/evohome/const.py +++ b/homeassistant/components/evohome/const.py @@ -18,11 +18,10 @@ USER_DATA: Final = "user_data" SCAN_INTERVAL_DEFAULT: Final = timedelta(seconds=300) SCAN_INTERVAL_MINIMUM: Final = timedelta(seconds=60) -ATTR_SYSTEM_MODE: Final = "mode" -ATTR_DURATION_DAYS: Final = "period" -ATTR_DURATION_HOURS: Final = "duration" +ATTR_PERIOD: Final = "period" # number of days +ATTR_DURATION: Final = "duration" # number of minutes, <24h -ATTR_ZONE_TEMP: Final = "setpoint" +ATTR_SETPOINT: Final = "setpoint" ATTR_DURATION_UNTIL: Final = "duration" diff --git a/tests/components/evohome/test_evo_services.py b/tests/components/evohome/test_evo_services.py new file mode 100644 index 00000000000..c9f20aecd4f --- /dev/null +++ b/tests/components/evohome/test_evo_services.py @@ -0,0 +1,177 @@ +"""The tests for the native services of Evohome.""" + +from __future__ import annotations + +from datetime import UTC, datetime +from unittest.mock import patch + +from evohomeasync2 import EvohomeClient +from freezegun.api import FrozenDateTimeFactory +import pytest + +from homeassistant.components.evohome.const import ( + ATTR_DURATION, + ATTR_PERIOD, + ATTR_SETPOINT, + DOMAIN, + EvoService, +) +from homeassistant.const import ATTR_ENTITY_ID, ATTR_MODE +from homeassistant.core import HomeAssistant + + +@pytest.mark.parametrize("install", ["default"]) +async def test_service_refresh_system( + hass: HomeAssistant, + evohome: EvohomeClient, +) -> None: + """Test Evohome's refresh_system service (for all temperature control systems).""" + + # EvoService.REFRESH_SYSTEM + with patch("evohomeasync2.location.Location.update") as mock_fcn: + await hass.services.async_call( + DOMAIN, + EvoService.REFRESH_SYSTEM, + {}, + blocking=True, + ) + + mock_fcn.assert_awaited_once_with() + + +@pytest.mark.parametrize("install", ["default"]) +async def test_service_reset_system( + hass: HomeAssistant, + ctl_id: str, +) -> None: + """Test Evohome's reset_system service (for a temperature control system).""" + + # EvoService.RESET_SYSTEM (if SZ_AUTO_WITH_RESET in modes) + with patch("evohomeasync2.control_system.ControlSystem.set_mode") as mock_fcn: + await hass.services.async_call( + DOMAIN, + EvoService.RESET_SYSTEM, + {}, + blocking=True, + ) + + mock_fcn.assert_awaited_once_with("AutoWithReset", until=None) + + +@pytest.mark.parametrize("install", ["default"]) +async def test_ctl_set_system_mode( + hass: HomeAssistant, + ctl_id: str, + freezer: FrozenDateTimeFactory, +) -> None: + """Test Evohome's set_system_mode service (for a temperature control system).""" + + # EvoService.SET_SYSTEM_MODE: Auto + with patch("evohomeasync2.control_system.ControlSystem.set_mode") as mock_fcn: + await hass.services.async_call( + DOMAIN, + EvoService.SET_SYSTEM_MODE, + { + ATTR_MODE: "Auto", + }, + blocking=True, + ) + + mock_fcn.assert_awaited_once_with("Auto", until=None) + + freezer.move_to("2024-07-10T12:00:00+00:00") + + # EvoService.SET_SYSTEM_MODE: AutoWithEco, hours=12 + with patch("evohomeasync2.control_system.ControlSystem.set_mode") as mock_fcn: + await hass.services.async_call( + DOMAIN, + EvoService.SET_SYSTEM_MODE, + { + ATTR_MODE: "AutoWithEco", + ATTR_DURATION: {"hours": 12}, + }, + blocking=True, + ) + + mock_fcn.assert_awaited_once_with( + "AutoWithEco", until=datetime(2024, 7, 11, 0, 0, tzinfo=UTC) + ) + + # EvoService.SET_SYSTEM_MODE: Away, days=7 + with patch("evohomeasync2.control_system.ControlSystem.set_mode") as mock_fcn: + await hass.services.async_call( + DOMAIN, + EvoService.SET_SYSTEM_MODE, + { + ATTR_MODE: "Away", + ATTR_PERIOD: {"days": 7}, + }, + blocking=True, + ) + + mock_fcn.assert_awaited_once_with( + "Away", until=datetime(2024, 7, 16, 23, 0, tzinfo=UTC) + ) + + +@pytest.mark.parametrize("install", ["default"]) +async def test_zone_clear_zone_override( + hass: HomeAssistant, + zone_id: str, +) -> None: + """Test Evohome's clear_zone_override service (for a heating zone).""" + + # EvoZoneMode.FOLLOW_SCHEDULE + with patch("evohomeasync2.zone.Zone.reset") as mock_fcn: + await hass.services.async_call( + DOMAIN, + EvoService.RESET_ZONE_OVERRIDE, + { + ATTR_ENTITY_ID: zone_id, + }, + blocking=True, + ) + + mock_fcn.assert_awaited_once_with() + + +@pytest.mark.parametrize("install", ["default"]) +async def test_zone_set_zone_override( + hass: HomeAssistant, + zone_id: str, + freezer: FrozenDateTimeFactory, +) -> None: + """Test Evohome's set_zone_override service (for a heating zone).""" + + freezer.move_to("2024-07-10T12:00:00+00:00") + + # EvoZoneMode.PERMANENT_OVERRIDE + with patch("evohomeasync2.zone.Zone.set_temperature") as mock_fcn: + await hass.services.async_call( + DOMAIN, + EvoService.SET_ZONE_OVERRIDE, + { + ATTR_ENTITY_ID: zone_id, + ATTR_SETPOINT: 19.5, + }, + blocking=True, + ) + + mock_fcn.assert_awaited_once_with(19.5, until=None) + + # EvoZoneMode.TEMPORARY_OVERRIDE + with patch("evohomeasync2.zone.Zone.set_temperature") as mock_fcn: + await hass.services.async_call( + DOMAIN, + EvoService.SET_ZONE_OVERRIDE, + { + ATTR_ENTITY_ID: zone_id, + ATTR_SETPOINT: 19.5, + ATTR_DURATION: {"minutes": 135}, + }, + blocking=True, + ) + + mock_fcn.assert_awaited_once_with( + 19.5, until=datetime(2024, 7, 10, 14, 15, tzinfo=UTC) + ) diff --git a/tests/components/evohome/test_init.py b/tests/components/evohome/test_init.py index d327bdf14b4..53b9258523d 100644 --- a/tests/components/evohome/test_init.py +++ b/tests/components/evohome/test_init.py @@ -1,4 +1,4 @@ -"""The tests for evohome.""" +"""The tests for Evohome.""" from __future__ import annotations @@ -11,7 +11,7 @@ from evohomeasync2 import EvohomeClient, exceptions as exc import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components.evohome.const import DOMAIN, EvoService +from homeassistant.components.evohome.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -187,41 +187,3 @@ async def test_setup( """ assert hass.services.async_services_for_domain(DOMAIN).keys() == snapshot - - -@pytest.mark.parametrize("install", ["default"]) -async def test_service_refresh_system( - hass: HomeAssistant, - evohome: EvohomeClient, -) -> None: - """Test EvoService.REFRESH_SYSTEM of an evohome system.""" - - # EvoService.REFRESH_SYSTEM - with patch("evohomeasync2.location.Location.update") as mock_fcn: - await hass.services.async_call( - DOMAIN, - EvoService.REFRESH_SYSTEM, - {}, - blocking=True, - ) - - mock_fcn.assert_awaited_once_with() - - -@pytest.mark.parametrize("install", ["default"]) -async def test_service_reset_system( - hass: HomeAssistant, - evohome: EvohomeClient, -) -> None: - """Test EvoService.RESET_SYSTEM of an evohome system.""" - - # EvoService.RESET_SYSTEM (if SZ_AUTO_WITH_RESET in modes) - with patch("evohomeasync2.control_system.ControlSystem.set_mode") as mock_fcn: - await hass.services.async_call( - DOMAIN, - EvoService.RESET_SYSTEM, - {}, - blocking=True, - ) - - mock_fcn.assert_awaited_once_with("AutoWithReset", until=None) From f7a6d163bb132c15d827bd15f33c183afe861a94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Sun, 23 Feb 2025 12:44:55 +0100 Subject: [PATCH 1700/3148] Add Home Connect functional light color temperature percent setting (#139096) Add functional light color temperature percent setting --- homeassistant/components/home_connect/number.py | 5 +++++ homeassistant/components/home_connect/strings.json | 3 +++ 2 files changed, 8 insertions(+) diff --git a/homeassistant/components/home_connect/number.py b/homeassistant/components/home_connect/number.py index 63df33e5432..27b4bc7eb6f 100644 --- a/homeassistant/components/home_connect/number.py +++ b/homeassistant/components/home_connect/number.py @@ -83,6 +83,11 @@ NUMBERS = ( device_class=NumberDeviceClass.TEMPERATURE, translation_key="wine_compartment_3_setpoint_temperature", ), + NumberEntityDescription( + key=SettingKey.COOKING_HOOD_COLOR_TEMPERATURE_PERCENT, + translation_key="color_temperature_percent", + native_unit_of_measurement="%", + ), NumberEntityDescription( key=SettingKey.LAUNDRY_CARE_WASHER_I_DOS_1_BASE_LEVEL, device_class=NumberDeviceClass.VOLUME, diff --git a/homeassistant/components/home_connect/strings.json b/homeassistant/components/home_connect/strings.json index dde002d1caa..d6330c8b78b 100644 --- a/homeassistant/components/home_connect/strings.json +++ b/homeassistant/components/home_connect/strings.json @@ -874,6 +874,9 @@ "wine_compartment_3_setpoint_temperature": { "name": "Wine compartment 3 temperature" }, + "color_temperature_percent": { + "name": "Functional light color temperature percent" + }, "washer_i_dos_1_base_level": { "name": "i-Dos 1 base level" }, From 4ca39636e27ccfaa271c0bc4784404111874255a Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 23 Feb 2025 13:27:14 +0100 Subject: [PATCH 1701/3148] Backup location feature requires Synology DSM 6.0 and higher (#139106) * the filestation api requires dsm 6.0 * fix tests --- .../components/synology_dsm/common.py | 10 +++++++-- tests/components/synology_dsm/common.py | 22 +++++++++++++++++++ tests/components/synology_dsm/conftest.py | 3 +++ tests/components/synology_dsm/test_backup.py | 7 +++--- .../synology_dsm/test_config_flow.py | 11 +++++----- .../synology_dsm/test_media_source.py | 2 ++ tests/components/synology_dsm/test_repairs.py | 5 +++-- 7 files changed, 48 insertions(+), 12 deletions(-) create mode 100644 tests/components/synology_dsm/common.py diff --git a/homeassistant/components/synology_dsm/common.py b/homeassistant/components/synology_dsm/common.py index d61944c146d..2e80624ca5d 100644 --- a/homeassistant/components/synology_dsm/common.py +++ b/homeassistant/components/synology_dsm/common.py @@ -7,6 +7,7 @@ from collections.abc import Callable from contextlib import suppress import logging +from awesomeversion import AwesomeVersion from synology_dsm import SynologyDSM from synology_dsm.api.core.security import SynoCoreSecurity from synology_dsm.api.core.system import SynoCoreSystem @@ -135,6 +136,9 @@ class SynoApi: ) await self.async_login() + self.information = self.dsm.information + await self.information.update() + # check if surveillance station is used self._with_surveillance_station = bool( self.dsm.apis.get(SynoSurveillanceStation.CAMERA_API_KEY) @@ -165,7 +169,10 @@ class SynoApi: LOGGER.debug("Disabled fetching upgrade data during setup: %s", ex) # check if file station is used and permitted - self._with_file_station = bool(self.dsm.apis.get(SynoFileStation.LIST_API_KEY)) + self._with_file_station = bool( + self.information.awesome_version >= AwesomeVersion("6.0") + and self.dsm.apis.get(SynoFileStation.LIST_API_KEY) + ) if self._with_file_station: shares: list | None = None with suppress(*SYNOLOGY_CONNECTION_EXCEPTIONS): @@ -317,7 +324,6 @@ class SynoApi: async def _fetch_device_configuration(self) -> None: """Fetch initial device config.""" - self.information = self.dsm.information self.network = self.dsm.network await self.network.update() diff --git a/tests/components/synology_dsm/common.py b/tests/components/synology_dsm/common.py new file mode 100644 index 00000000000..e98b0d21d66 --- /dev/null +++ b/tests/components/synology_dsm/common.py @@ -0,0 +1,22 @@ +"""Configure Synology DSM tests.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, Mock + +from awesomeversion import AwesomeVersion + +from .consts import SERIAL + + +def mock_dsm_information( + serial: str | None = SERIAL, + update_result: bool = True, + awesome_version: str = "7.2", +) -> Mock: + """Mock SynologyDSM information.""" + return Mock( + serial=serial, + update=AsyncMock(return_value=update_result), + awesome_version=AwesomeVersion(awesome_version), + ) diff --git a/tests/components/synology_dsm/conftest.py b/tests/components/synology_dsm/conftest.py index 331c879332d..96d6453cf16 100644 --- a/tests/components/synology_dsm/conftest.py +++ b/tests/components/synology_dsm/conftest.py @@ -8,6 +8,8 @@ import pytest from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component +from .common import mock_dsm_information + @pytest.fixture def mock_setup_entry() -> Generator[AsyncMock]: @@ -31,6 +33,7 @@ def fixture_dsm(): dsm.login = AsyncMock(return_value=True) dsm.update = AsyncMock(return_value=True) + dsm.information = mock_dsm_information() dsm.network.update = AsyncMock(return_value=True) dsm.surveillance_station.update = AsyncMock(return_value=True) dsm.upgrade.update = AsyncMock(return_value=True) diff --git a/tests/components/synology_dsm/test_backup.py b/tests/components/synology_dsm/test_backup.py index ea68bbc991c..8e98f4dffa9 100644 --- a/tests/components/synology_dsm/test_backup.py +++ b/tests/components/synology_dsm/test_backup.py @@ -31,7 +31,8 @@ from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util.aiohttp import MockStreamReader -from .consts import HOST, MACS, PASSWORD, PORT, SERIAL, USE_SSL, USERNAME +from .common import mock_dsm_information +from .consts import HOST, MACS, PASSWORD, PORT, USE_SSL, USERNAME from tests.common import MockConfigEntry from tests.typing import ClientSessionGenerator, WebSocketGenerator @@ -99,7 +100,7 @@ def mock_dsm_with_filestation(): volumes_ids=["volume_1"], update=AsyncMock(return_value=True), ) - dsm.information = Mock(serial=SERIAL) + dsm.information = mock_dsm_information() dsm.file = AsyncMock( get_shared_folders=AsyncMock( return_value=[ @@ -147,12 +148,12 @@ def mock_dsm_without_filestation(): dsm.upgrade.update = AsyncMock(return_value=True) dsm.utilisation = Mock(cpu_user_load=1, update=AsyncMock(return_value=True)) dsm.network = Mock(update=AsyncMock(return_value=True), macs=MACS) + dsm.information = mock_dsm_information() dsm.storage = Mock( disks_ids=["sda", "sdb", "sdc"], volumes_ids=["volume_1"], update=AsyncMock(return_value=True), ) - dsm.information = Mock(serial=SERIAL) dsm.file = None yield dsm diff --git a/tests/components/synology_dsm/test_config_flow.py b/tests/components/synology_dsm/test_config_flow.py index b25cf7a81ac..932cf057d3d 100644 --- a/tests/components/synology_dsm/test_config_flow.py +++ b/tests/components/synology_dsm/test_config_flow.py @@ -40,6 +40,7 @@ from homeassistant.helpers.service_info.ssdp import ( ) from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo +from .common import mock_dsm_information from .consts import ( DEVICE_TOKEN, HOST, @@ -72,7 +73,7 @@ def mock_controller_service(): volumes_ids=["volume_1"], update=AsyncMock(return_value=True), ) - dsm.information = Mock(serial=SERIAL) + dsm.information = mock_dsm_information() dsm.file = AsyncMock(get_shared_folders=AsyncMock(return_value=None)) yield dsm @@ -95,7 +96,7 @@ def mock_controller_service_2sa(): volumes_ids=["volume_1"], update=AsyncMock(return_value=True), ) - dsm.information = Mock(serial=SERIAL) + dsm.information = mock_dsm_information() dsm.file = AsyncMock(get_shared_folders=AsyncMock(return_value=None)) yield dsm @@ -116,7 +117,7 @@ def mock_controller_service_vdsm(): volumes_ids=["volume_1"], update=AsyncMock(return_value=True), ) - dsm.information = Mock(serial=SERIAL) + dsm.information = mock_dsm_information() dsm.file = AsyncMock(get_shared_folders=AsyncMock(return_value=None)) yield dsm @@ -137,7 +138,7 @@ def mock_controller_service_with_filestation(): volumes_ids=["volume_1"], update=AsyncMock(return_value=True), ) - dsm.information = Mock(serial=SERIAL) + dsm.information = mock_dsm_information() dsm.file = AsyncMock( get_shared_folders=AsyncMock( return_value=[ @@ -170,7 +171,7 @@ def mock_controller_service_failed(): volumes_ids=[], update=AsyncMock(return_value=True), ) - dsm.information = Mock(serial=None) + dsm.information = mock_dsm_information(serial=None) dsm.file = AsyncMock(get_shared_folders=AsyncMock(return_value=None)) yield dsm diff --git a/tests/components/synology_dsm/test_media_source.py b/tests/components/synology_dsm/test_media_source.py index baa91822ca0..dd454f92137 100644 --- a/tests/components/synology_dsm/test_media_source.py +++ b/tests/components/synology_dsm/test_media_source.py @@ -33,6 +33,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.util.aiohttp import MockRequest +from .common import mock_dsm_information from .consts import HOST, MACS, PASSWORD, PORT, USE_SSL, USERNAME from tests.common import MockConfigEntry @@ -44,6 +45,7 @@ def dsm_with_photos() -> MagicMock: dsm = MagicMock() dsm.login = AsyncMock(return_value=True) dsm.update = AsyncMock(return_value=True) + dsm.information = mock_dsm_information() dsm.network.update = AsyncMock(return_value=True) dsm.surveillance_station.update = AsyncMock(return_value=True) dsm.upgrade.update = AsyncMock(return_value=True) diff --git a/tests/components/synology_dsm/test_repairs.py b/tests/components/synology_dsm/test_repairs.py index b2e7352f214..0dea980b553 100644 --- a/tests/components/synology_dsm/test_repairs.py +++ b/tests/components/synology_dsm/test_repairs.py @@ -25,7 +25,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import issue_registry as ir from homeassistant.setup import async_setup_component -from .consts import HOST, MACS, PASSWORD, PORT, SERIAL, USE_SSL, USERNAME +from .common import mock_dsm_information +from .consts import HOST, MACS, PASSWORD, PORT, USE_SSL, USERNAME from tests.common import ANY, MockConfigEntry from tests.components.repairs import process_repair_fix_flow, start_repair_fix_flow @@ -48,7 +49,7 @@ def mock_dsm_with_filestation(): volumes_ids=["volume_1"], update=AsyncMock(return_value=True), ) - dsm.information = Mock(serial=SERIAL) + dsm.information = mock_dsm_information() dsm.file = AsyncMock( get_shared_folders=AsyncMock( return_value=[ From 6ebda9322ddb170493d685ff0c374cdfa7c2fd88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Sun, 23 Feb 2025 13:54:02 +0100 Subject: [PATCH 1702/3148] Fetch allowed values for select entities at Home Connect (#139103) Fetch allowed values for enum settings --- .../components/home_connect/select.py | 30 +++++++--- tests/components/home_connect/test_select.py | 57 +++++++++++++++++++ 2 files changed, 80 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/home_connect/select.py b/homeassistant/components/home_connect/select.py index e4d50b0d5e9..d5657387358 100644 --- a/homeassistant/components/home_connect/select.py +++ b/homeassistant/components/home_connect/select.py @@ -1,6 +1,7 @@ """Provides a select platform for Home Connect.""" from collections.abc import Callable, Coroutine +import contextlib from dataclasses import dataclass from typing import Any, cast @@ -423,13 +424,6 @@ class HomeConnectSelectEntity(HomeConnectEntity, SelectEntity): appliance, desc, ) - setting = appliance.settings.get(cast(SettingKey, desc.key)) - if setting and setting.constraints and setting.constraints.allowed_values: - self._attr_options = [ - desc.values_translation_key[option] - for option in setting.constraints.allowed_values - if option in desc.values_translation_key - ] async def async_select_option(self, option: str) -> None: """Select new option.""" @@ -459,6 +453,28 @@ class HomeConnectSelectEntity(HomeConnectEntity, SelectEntity): data.value ) + async def async_added_to_hass(self) -> None: + """When entity is added to hass.""" + await super().async_added_to_hass() + setting = self.appliance.settings.get(cast(SettingKey, self.bsh_key)) + if ( + not setting + or not setting.constraints + or not setting.constraints.allowed_values + ): + with contextlib.suppress(HomeConnectError): + setting = await self.coordinator.client.get_setting( + self.appliance.info.ha_id, + setting_key=cast(SettingKey, self.bsh_key), + ) + + if setting and setting.constraints and setting.constraints.allowed_values: + self._attr_options = [ + self.entity_description.values_translation_key[option] + for option in setting.constraints.allowed_values + if option in self.entity_description.values_translation_key + ] + class HomeConnectSelectOptionEntity(HomeConnectOptionEntity, SelectEntity): """Select option class for Home Connect.""" diff --git a/tests/components/home_connect/test_select.py b/tests/components/home_connect/test_select.py index d98dbd8e5f6..22ece365e6b 100644 --- a/tests/components/home_connect/test_select.py +++ b/tests/components/home_connect/test_select.py @@ -509,6 +509,63 @@ async def test_select_functionality( assert hass.states.is_state(entity_id, value_to_set) +@pytest.mark.parametrize("appliance_ha_id", ["Hood"], indirect=True) +@pytest.mark.parametrize( + ( + "entity_id", + "test_setting_key", + "allowed_values", + "expected_options", + ), + [ + ( + "select.hood_ambient_light_color", + SettingKey.BSH_COMMON_AMBIENT_LIGHT_COLOR, + [f"BSH.Common.EnumType.AmbientLightColor.Color{i}" for i in range(50)], + {str(i) for i in range(1, 50)}, + ), + ], +) +async def test_fetch_allowed_values( + appliance_ha_id: str, + entity_id: str, + test_setting_key: SettingKey, + allowed_values: list[str | None], + expected_options: set[str], + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, +) -> None: + """Test fetch allowed values.""" + original_get_setting_side_effect = client.get_setting + + async def get_setting_side_effect( + ha_id: str, setting_key: SettingKey + ) -> GetSetting: + if ha_id != appliance_ha_id or setting_key != test_setting_key: + return await original_get_setting_side_effect(ha_id, setting_key) + return GetSetting( + key=test_setting_key, + raw_key=test_setting_key.value, + value="", # Not important + constraints=SettingConstraints( + allowed_values=allowed_values, + ), + ) + + client.get_setting = AsyncMock(side_effect=get_setting_side_effect) + + assert config_entry.state is ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state is ConfigEntryState.LOADED + + entity_state = hass.states.get(entity_id) + assert entity_state + assert set(entity_state.attributes[ATTR_OPTIONS]) == expected_options + + @pytest.mark.parametrize( ("entity_id", "setting_key", "allowed_value", "value_to_set", "mock_attr"), [ From bd919159e58034073eadad8d18fa4faa81df3c6c Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 23 Feb 2025 13:59:30 +0100 Subject: [PATCH 1703/3148] Bump aiohue to 4.7.4 (#139108) --- homeassistant/components/hue/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/hue/manifest.json b/homeassistant/components/hue/manifest.json index 22f1d3991e7..8bc3d84bd50 100644 --- a/homeassistant/components/hue/manifest.json +++ b/homeassistant/components/hue/manifest.json @@ -10,6 +10,6 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["aiohue"], - "requirements": ["aiohue==4.7.3"], + "requirements": ["aiohue==4.7.4"], "zeroconf": ["_hue._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index ef4360a2061..cb03d16903d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -273,7 +273,7 @@ aiohomekit==3.2.7 aiohttp_sse==2.2.0 # homeassistant.components.hue -aiohue==4.7.3 +aiohue==4.7.4 # homeassistant.components.imap aioimaplib==2.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b78b82d8f2e..af58c786530 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -258,7 +258,7 @@ aiohomekit==3.2.7 aiohttp_sse==2.2.0 # homeassistant.components.hue -aiohue==4.7.3 +aiohue==4.7.4 # homeassistant.components.imap aioimaplib==2.0.1 From 15ca2fe4890fe801b9e51ea7fe9e7420f61e0314 Mon Sep 17 00:00:00 2001 From: Tom Brien Date: Sun, 23 Feb 2025 13:21:41 +0000 Subject: [PATCH 1704/3148] Waze action support entities (#139068) --- .../components/waze_travel_time/__init__.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/waze_travel_time/__init__.py b/homeassistant/components/waze_travel_time/__init__.py index 34f22c9218f..3a91690ef07 100644 --- a/homeassistant/components/waze_travel_time/__init__.py +++ b/homeassistant/components/waze_travel_time/__init__.py @@ -17,6 +17,7 @@ from homeassistant.core import ( SupportsResponse, ) from homeassistant.helpers.httpx_client import get_async_client +from homeassistant.helpers.location import find_coordinates from homeassistant.helpers.selector import ( BooleanSelector, SelectSelector, @@ -115,10 +116,21 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b client = WazeRouteCalculator( region=service.data[CONF_REGION].upper(), client=httpx_client ) + + origin_coordinates = find_coordinates(hass, service.data[CONF_ORIGIN]) + destination_coordinates = find_coordinates(hass, service.data[CONF_DESTINATION]) + + origin = origin_coordinates if origin_coordinates else service.data[CONF_ORIGIN] + destination = ( + destination_coordinates + if destination_coordinates + else service.data[CONF_DESTINATION] + ) + response = await async_get_travel_times( client=client, - origin=service.data[CONF_ORIGIN], - destination=service.data[CONF_DESTINATION], + origin=origin, + destination=destination, vehicle_type=service.data[CONF_VEHICLE_TYPE], avoid_toll_roads=service.data[CONF_AVOID_TOLL_ROADS], avoid_subscription_roads=service.data[CONF_AVOID_SUBSCRIPTION_ROADS], From 800fe1b01e2d89d37eff2ce3cdc0c2c1885f7916 Mon Sep 17 00:00:00 2001 From: Andre Lengwenus Date: Sun, 23 Feb 2025 14:42:54 +0100 Subject: [PATCH 1705/3148] Remove individual lcn devices for each entity (#136450) --- homeassistant/components/lcn/__init__.py | 4 ++ homeassistant/components/lcn/entity.py | 35 +++++----------- homeassistant/components/lcn/helpers.py | 44 --------------------- tests/components/lcn/test_device_trigger.py | 16 ++++---- 4 files changed, 23 insertions(+), 76 deletions(-) diff --git a/homeassistant/components/lcn/__init__.py b/homeassistant/components/lcn/__init__.py index 58924413c56..256e132b30d 100644 --- a/homeassistant/components/lcn/__init__.py +++ b/homeassistant/components/lcn/__init__.py @@ -49,6 +49,7 @@ from .helpers import ( InputType, async_update_config_entry, generate_unique_id, + purge_device_registry, register_lcn_address_devices, register_lcn_host_device, ) @@ -120,6 +121,9 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b register_lcn_host_device(hass, config_entry) register_lcn_address_devices(hass, config_entry) + # clean up orphaned devices + purge_device_registry(hass, config_entry.entry_id, {**config_entry.data}) + # forward config_entry to components await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) diff --git a/homeassistant/components/lcn/entity.py b/homeassistant/components/lcn/entity.py index 12d8f966801..ffb680c4237 100644 --- a/homeassistant/components/lcn/entity.py +++ b/homeassistant/components/lcn/entity.py @@ -3,19 +3,18 @@ from collections.abc import Callable from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_ADDRESS, CONF_DOMAIN, CONF_NAME, CONF_RESOURCE +from homeassistant.const import CONF_ADDRESS, CONF_NAME, CONF_RESOURCE from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity from homeassistant.helpers.typing import ConfigType -from .const import CONF_DOMAIN_DATA, DOMAIN +from .const import DOMAIN from .helpers import ( AddressType, DeviceConnectionType, InputType, generate_unique_id, get_device_connection, - get_device_model, ) @@ -36,6 +35,14 @@ class LcnEntity(Entity): self.address: AddressType = config[CONF_ADDRESS] self._unregister_for_inputs: Callable | None = None self._name: str = config[CONF_NAME] + self._attr_device_info = DeviceInfo( + identifiers={ + ( + DOMAIN, + generate_unique_id(self.config_entry.entry_id, self.address), + ) + }, + ) @property def unique_id(self) -> str: @@ -44,28 +51,6 @@ class LcnEntity(Entity): self.config_entry.entry_id, self.address, self.config[CONF_RESOURCE] ) - @property - def device_info(self) -> DeviceInfo | None: - """Return device specific attributes.""" - address = f"{'g' if self.address[2] else 'm'}{self.address[0]:03d}{self.address[1]:03d}" - model = ( - "LCN resource" - f" ({get_device_model(self.config[CONF_DOMAIN], self.config[CONF_DOMAIN_DATA])})" - ) - - return DeviceInfo( - identifiers={(DOMAIN, self.unique_id)}, - name=f"{address}.{self.config[CONF_RESOURCE]}", - model=model, - manufacturer="Issendorff", - via_device=( - DOMAIN, - generate_unique_id( - self.config_entry.entry_id, self.config[CONF_ADDRESS] - ), - ), - ) - async def async_added_to_hass(self) -> None: """Run when entity about to be added to hass.""" self.device_connection = get_device_connection( diff --git a/homeassistant/components/lcn/helpers.py b/homeassistant/components/lcn/helpers.py index b999c6f3770..2176c669251 100644 --- a/homeassistant/components/lcn/helpers.py +++ b/homeassistant/components/lcn/helpers.py @@ -4,7 +4,6 @@ from __future__ import annotations import asyncio from copy import deepcopy -from itertools import chain import re from typing import cast @@ -22,7 +21,6 @@ from homeassistant.const import ( CONF_NAME, CONF_RESOURCE, CONF_SENSORS, - CONF_SOURCE, CONF_SWITCHES, ) from homeassistant.core import HomeAssistant @@ -30,23 +28,14 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.typing import ConfigType from .const import ( - BINSENSOR_PORTS, CONF_CLIMATES, CONF_HARDWARE_SERIAL, CONF_HARDWARE_TYPE, - CONF_OUTPUT, CONF_SCENES, CONF_SOFTWARE_SERIAL, CONNECTION, DEVICE_CONNECTIONS, DOMAIN, - LED_PORTS, - LOGICOP_PORTS, - OUTPUT_PORTS, - S0_INPUTS, - SETPOINTS, - THRESHOLDS, - VARIABLES, ) # typing @@ -96,31 +85,6 @@ def get_resource(domain_name: str, domain_data: ConfigType) -> str: raise ValueError("Unknown domain") -def get_device_model(domain_name: str, domain_data: ConfigType) -> str: - """Return the model for the specified domain_data.""" - if domain_name in ("switch", "light"): - return "Output" if domain_data[CONF_OUTPUT] in OUTPUT_PORTS else "Relay" - if domain_name in ("binary_sensor", "sensor"): - if domain_data[CONF_SOURCE] in BINSENSOR_PORTS: - return "Binary Sensor" - if domain_data[CONF_SOURCE] in chain( - VARIABLES, SETPOINTS, THRESHOLDS, S0_INPUTS - ): - return "Variable" - if domain_data[CONF_SOURCE] in LED_PORTS: - return "Led" - if domain_data[CONF_SOURCE] in LOGICOP_PORTS: - return "Logical Operation" - return "Key" - if domain_name == "cover": - return "Motor" - if domain_name == "climate": - return "Regulator" - if domain_name == "scene": - return "Scene" - raise ValueError("Unknown domain") - - def generate_unique_id( entry_id: str, address: AddressType, @@ -169,13 +133,6 @@ def purge_device_registry( ) -> None: """Remove orphans from device registry which are not in entry data.""" device_registry = dr.async_get(hass) - entity_registry = er.async_get(hass) - - # Find all devices that are referenced in the entity registry. - references_entities = { - entry.device_id - for entry in entity_registry.entities.get_entries_for_config_entry_id(entry_id) - } # Find device that references the host. references_host = set() @@ -198,7 +155,6 @@ def purge_device_registry( entry.id for entry in dr.async_entries_for_config_entry(device_registry, entry_id) } - - references_entities - references_host - references_entry_data ) diff --git a/tests/components/lcn/test_device_trigger.py b/tests/components/lcn/test_device_trigger.py index 6537c108981..94eb96591e2 100644 --- a/tests/components/lcn/test_device_trigger.py +++ b/tests/components/lcn/test_device_trigger.py @@ -45,9 +45,14 @@ async def test_get_triggers_module_device( ) ] - triggers = await async_get_device_automations( - hass, DeviceAutomationType.TRIGGER, device.id - ) + triggers = [ + trigger + for trigger in await async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, device.id + ) + if trigger[CONF_DOMAIN] == DOMAIN + ] + assert triggers == unordered(expected_triggers) @@ -63,11 +68,8 @@ async def test_get_triggers_non_module_device( identifiers={(DOMAIN, entry.entry_id)} ) group_device = get_device(hass, entry, (0, 5, True)) - resource_device = device_registry.async_get_device( - identifiers={(DOMAIN, f"{entry.entry_id}-m000007-output1")} - ) - for device in (host_device, group_device, resource_device): + for device in (host_device, group_device): triggers = await async_get_device_automations( hass, DeviceAutomationType.TRIGGER, device.id ) From c1e5673cbd11b84d7146eaa4fddd07308ebcc447 Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Sun, 23 Feb 2025 14:46:37 +0100 Subject: [PATCH 1706/3148] Allow rename of the backup folder for OneDrive (#138407) --- homeassistant/components/onedrive/__init__.py | 104 ++++++--- homeassistant/components/onedrive/backup.py | 2 +- .../components/onedrive/config_flow.py | 158 +++++++++++-- homeassistant/components/onedrive/const.py | 2 + .../components/onedrive/quality_scale.yaml | 5 +- .../components/onedrive/strings.json | 28 ++- tests/components/onedrive/conftest.py | 113 +++++++++- tests/components/onedrive/const.py | 45 +--- tests/components/onedrive/test_config_flow.py | 212 +++++++++++++++++- tests/components/onedrive/test_init.py | 128 ++++++++++- 10 files changed, 681 insertions(+), 116 deletions(-) diff --git a/homeassistant/components/onedrive/__init__.py b/homeassistant/components/onedrive/__init__.py index 4aa11daf39d..6805b073ea2 100644 --- a/homeassistant/components/onedrive/__init__.py +++ b/homeassistant/components/onedrive/__init__.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Awaitable, Callable from html import unescape from json import dumps, loads import logging @@ -10,10 +11,10 @@ from typing import cast from onedrive_personal_sdk import OneDriveClient from onedrive_personal_sdk.exceptions import ( AuthenticationError, - HttpRequestException, + NotFoundError, OneDriveException, ) -from onedrive_personal_sdk.models.items import ItemUpdate +from onedrive_personal_sdk.models.items import Item, ItemUpdate from homeassistant.const import CONF_ACCESS_TOKEN, Platform from homeassistant.core import HomeAssistant, callback @@ -25,7 +26,7 @@ from homeassistant.helpers.config_entry_oauth2_flow import ( ) from homeassistant.helpers.instance_id import async_get as async_get_instance_id -from .const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN +from .const import CONF_FOLDER_ID, CONF_FOLDER_NAME, DATA_BACKUP_AGENT_LISTENERS, DOMAIN from .coordinator import ( OneDriveConfigEntry, OneDriveRuntimeData, @@ -50,33 +51,38 @@ async def async_setup_entry(hass: HomeAssistant, entry: OneDriveConfigEntry) -> client = OneDriveClient(get_access_token, async_get_clientsession(hass)) # get approot, will be created automatically if it does not exist - try: - approot = await client.get_approot() - except AuthenticationError as err: - raise ConfigEntryAuthFailed( - translation_domain=DOMAIN, translation_key="authentication_failed" - ) from err - except (HttpRequestException, OneDriveException, TimeoutError) as err: - _LOGGER.debug("Failed to get approot", exc_info=True) - raise ConfigEntryNotReady( - translation_domain=DOMAIN, - translation_key="failed_to_get_folder", - translation_placeholders={"folder": "approot"}, - ) from err + approot = await _handle_item_operation(client.get_approot, "approot") + folder_name = entry.data[CONF_FOLDER_NAME] - instance_id = await async_get_instance_id(hass) - backup_folder_name = f"backups_{instance_id[:8]}" try: - backup_folder = await client.create_folder( - parent_id=approot.id, name=backup_folder_name + backup_folder = await _handle_item_operation( + lambda: client.get_drive_item(path_or_id=entry.data[CONF_FOLDER_ID]), + folder_name, + ) + except NotFoundError: + _LOGGER.debug("Creating backup folder %s", folder_name) + backup_folder = await _handle_item_operation( + lambda: client.create_folder(parent_id=approot.id, name=folder_name), + folder_name, + ) + hass.config_entries.async_update_entry( + entry, data={**entry.data, CONF_FOLDER_ID: backup_folder.id} + ) + + # write instance id to description + if backup_folder.description != (instance_id := await async_get_instance_id(hass)): + await _handle_item_operation( + lambda: client.update_drive_item( + backup_folder.id, ItemUpdate(description=instance_id) + ), + folder_name, + ) + + # update in case folder was renamed manually inside OneDrive + if backup_folder.name != entry.data[CONF_FOLDER_NAME]: + hass.config_entries.async_update_entry( + entry, data={**entry.data, CONF_FOLDER_NAME: backup_folder.name} ) - except (HttpRequestException, OneDriveException, TimeoutError) as err: - _LOGGER.debug("Failed to create backup folder", exc_info=True) - raise ConfigEntryNotReady( - translation_domain=DOMAIN, - translation_key="failed_to_get_folder", - translation_placeholders={"folder": backup_folder_name}, - ) from err coordinator = OneDriveUpdateCoordinator(hass, entry, client) await coordinator.async_config_entry_first_refresh() @@ -152,3 +158,47 @@ async def _migrate_backup_files(client: OneDriveClient, backup_folder_id: str) - data=ItemUpdate(description=""), ) _LOGGER.debug("Migrated backup file %s", file.name) + + +async def async_migrate_entry(hass: HomeAssistant, entry: OneDriveConfigEntry) -> bool: + """Migrate old entry.""" + if entry.version > 1: + # This means the user has downgraded from a future version + return False + + if (version := entry.version) == 1 and (minor_version := entry.minor_version) == 1: + _LOGGER.debug( + "Migrating OneDrive config entry from version %s.%s", version, minor_version + ) + + instance_id = await async_get_instance_id(hass) + hass.config_entries.async_update_entry( + entry, + data={ + **entry.data, + CONF_FOLDER_ID: "id", # will be updated during setup_entry + CONF_FOLDER_NAME: f"backups_{instance_id[:8]}", + }, + ) + _LOGGER.debug("Migration to version 1.2 successful") + return True + + +async def _handle_item_operation( + func: Callable[[], Awaitable[Item]], folder: str +) -> Item: + try: + return await func() + except NotFoundError: + raise + except AuthenticationError as err: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, translation_key="authentication_failed" + ) from err + except (OneDriveException, TimeoutError) as err: + _LOGGER.debug("Failed to get approot", exc_info=True) + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="failed_to_get_folder", + translation_placeholders={"folder": folder}, + ) from err diff --git a/homeassistant/components/onedrive/backup.py b/homeassistant/components/onedrive/backup.py index f8a2a6699c4..9c7371bee4b 100644 --- a/homeassistant/components/onedrive/backup.py +++ b/homeassistant/components/onedrive/backup.py @@ -74,7 +74,7 @@ def async_register_backup_agents_listener( def handle_backup_errors[_R, **P]( func: Callable[Concatenate[OneDriveBackupAgent, P], Coroutine[Any, Any, _R]], ) -> Callable[Concatenate[OneDriveBackupAgent, P], Coroutine[Any, Any, _R]]: - """Handle backup errors with a specific translation key.""" + """Handle backup errors.""" @wraps(func) async def wrapper( diff --git a/homeassistant/components/onedrive/config_flow.py b/homeassistant/components/onedrive/config_flow.py index 06c9ec253e3..3374c0369ee 100644 --- a/homeassistant/components/onedrive/config_flow.py +++ b/homeassistant/components/onedrive/config_flow.py @@ -8,22 +8,47 @@ from typing import Any, cast from onedrive_personal_sdk.clients.client import OneDriveClient from onedrive_personal_sdk.exceptions import OneDriveException +from onedrive_personal_sdk.models.items import AppRoot, ItemUpdate import voluptuous as vol -from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult, OptionsFlow +from homeassistant.config_entries import ( + SOURCE_REAUTH, + SOURCE_RECONFIGURE, + SOURCE_USER, + ConfigFlowResult, + OptionsFlow, +) from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN from homeassistant.core import callback from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.config_entry_oauth2_flow import AbstractOAuth2FlowHandler +from homeassistant.helpers.instance_id import async_get as async_get_instance_id -from .const import CONF_DELETE_PERMANENTLY, DOMAIN, OAUTH_SCOPES +from .const import ( + CONF_DELETE_PERMANENTLY, + CONF_FOLDER_ID, + CONF_FOLDER_NAME, + DOMAIN, + OAUTH_SCOPES, +) from .coordinator import OneDriveConfigEntry +FOLDER_NAME_SCHEMA = vol.Schema({vol.Required(CONF_FOLDER_NAME): str}) + class OneDriveConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN): """Config flow to handle OneDrive OAuth2 authentication.""" DOMAIN = DOMAIN + MINOR_VERSION = 2 + + client: OneDriveClient + approot: AppRoot + + def __init__(self) -> None: + """Initialize the OneDrive config flow.""" + super().__init__() + self.step_data: dict[str, Any] = {} @property def logger(self) -> logging.Logger: @@ -35,6 +60,15 @@ class OneDriveConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN): """Extra data that needs to be appended to the authorize url.""" return {"scope": " ".join(OAUTH_SCOPES)} + @property + def apps_folder(self) -> str: + """Return the name of the Apps folder (translated).""" + return ( + path.split("/")[-1] + if (path := self.approot.parent_reference.path) + else "Apps" + ) + async def async_oauth_create_entry( self, data: dict[str, Any], @@ -44,12 +78,12 @@ class OneDriveConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN): async def get_access_token() -> str: return cast(str, data[CONF_TOKEN][CONF_ACCESS_TOKEN]) - graph_client = OneDriveClient( + self.client = OneDriveClient( get_access_token, async_get_clientsession(self.hass) ) try: - approot = await graph_client.get_approot() + self.approot = await self.client.get_approot() except OneDriveException: self.logger.exception("Failed to connect to OneDrive") return self.async_abort(reason="connection_error") @@ -57,26 +91,118 @@ class OneDriveConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN): self.logger.exception("Unknown error") return self.async_abort(reason="unknown") - await self.async_set_unique_id(approot.parent_reference.drive_id) + await self.async_set_unique_id(self.approot.parent_reference.drive_id) - if self.source == SOURCE_REAUTH: - reauth_entry = self._get_reauth_entry() + if self.source != SOURCE_USER: self._abort_if_unique_id_mismatch( reason="wrong_drive", ) + + if self.source == SOURCE_REAUTH: + reauth_entry = self._get_reauth_entry() return self.async_update_reload_and_abort( entry=reauth_entry, data=data, ) - self._abort_if_unique_id_configured() + if self.source != SOURCE_RECONFIGURE: + self._abort_if_unique_id_configured() - title = ( - f"{approot.created_by.user.display_name}'s OneDrive" - if approot.created_by.user and approot.created_by.user.display_name - else "OneDrive" + self.step_data = data + + if self.source == SOURCE_RECONFIGURE: + return await self.async_step_reconfigure_folder() + + return await self.async_step_folder_name() + + async def async_step_folder_name( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Step to ask for the folder name.""" + errors: dict[str, str] = {} + instance_id = await async_get_instance_id(self.hass) + if user_input is not None: + try: + folder = await self.client.create_folder( + self.approot.id, user_input[CONF_FOLDER_NAME] + ) + except OneDriveException: + self.logger.debug("Failed to create folder", exc_info=True) + errors["base"] = "folder_creation_error" + else: + if folder.description and folder.description != instance_id: + errors[CONF_FOLDER_NAME] = "folder_already_in_use" + if not errors: + title = ( + f"{self.approot.created_by.user.display_name}'s OneDrive" + if self.approot.created_by.user + and self.approot.created_by.user.display_name + else "OneDrive" + ) + return self.async_create_entry( + title=title, + data={ + **self.step_data, + CONF_FOLDER_ID: folder.id, + CONF_FOLDER_NAME: user_input[CONF_FOLDER_NAME], + }, + ) + + default_folder_name = ( + f"backups_{instance_id[:8]}" + if user_input is None + else user_input[CONF_FOLDER_NAME] + ) + + return self.async_show_form( + step_id="folder_name", + data_schema=self.add_suggested_values_to_schema( + FOLDER_NAME_SCHEMA, {CONF_FOLDER_NAME: default_folder_name} + ), + description_placeholders={ + "apps_folder": self.apps_folder, + "approot": self.approot.name, + }, + errors=errors, + ) + + async def async_step_reconfigure_folder( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Reconfigure the folder name.""" + errors: dict[str, str] = {} + reconfigure_entry = self._get_reconfigure_entry() + + if user_input is not None: + if ( + new_folder_name := user_input[CONF_FOLDER_NAME] + ) != reconfigure_entry.data[CONF_FOLDER_NAME]: + try: + await self.client.update_drive_item( + reconfigure_entry.data[CONF_FOLDER_ID], + ItemUpdate(name=new_folder_name), + ) + except OneDriveException: + self.logger.debug("Failed to update folder", exc_info=True) + errors["base"] = "folder_rename_error" + if not errors: + return self.async_update_reload_and_abort( + reconfigure_entry, + data={**reconfigure_entry.data, CONF_FOLDER_NAME: new_folder_name}, + ) + + return self.async_show_form( + step_id="reconfigure_folder", + data_schema=self.add_suggested_values_to_schema( + FOLDER_NAME_SCHEMA, + {CONF_FOLDER_NAME: reconfigure_entry.data[CONF_FOLDER_NAME]}, + ), + description_placeholders={ + "apps_folder": self.apps_folder, + "approot": self.approot.name, + }, + errors=errors, ) - return self.async_create_entry(title=title, data=data) async def async_step_reauth( self, entry_data: Mapping[str, Any] @@ -92,6 +218,12 @@ class OneDriveConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN): return self.async_show_form(step_id="reauth_confirm") return await self.async_step_user() + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Reconfigure the entry.""" + return await self.async_step_user() + @staticmethod @callback def async_get_options_flow( diff --git a/homeassistant/components/onedrive/const.py b/homeassistant/components/onedrive/const.py index 7aefa26ea81..fd21d84369c 100644 --- a/homeassistant/components/onedrive/const.py +++ b/homeassistant/components/onedrive/const.py @@ -6,6 +6,8 @@ from typing import Final from homeassistant.util.hass_dict import HassKey DOMAIN: Final = "onedrive" +CONF_FOLDER_NAME: Final = "folder_name" +CONF_FOLDER_ID: Final = "folder_id" CONF_DELETE_PERMANENTLY: Final = "delete_permanently" diff --git a/homeassistant/components/onedrive/quality_scale.yaml b/homeassistant/components/onedrive/quality_scale.yaml index 44754e76f2c..dd9e7f26102 100644 --- a/homeassistant/components/onedrive/quality_scale.yaml +++ b/homeassistant/components/onedrive/quality_scale.yaml @@ -73,10 +73,7 @@ rules: entity-translations: done exception-translations: done icon-translations: done - reconfiguration-flow: - status: exempt - comment: | - Nothing to reconfigure. + reconfiguration-flow: done repair-issues: done stale-devices: status: exempt diff --git a/homeassistant/components/onedrive/strings.json b/homeassistant/components/onedrive/strings.json index 27afe3e8a9b..37e19eb68ca 100644 --- a/homeassistant/components/onedrive/strings.json +++ b/homeassistant/components/onedrive/strings.json @@ -7,6 +7,26 @@ "reauth_confirm": { "title": "[%key:common::config_flow::title::reauth%]", "description": "The OneDrive integration needs to re-authenticate your account" + }, + "folder_name": { + "title": "Pick a folder name", + "description": "This name will be used to create a folder that is specific for this Home Assistant instance. This folder will be created inside `{apps_folder}/{approot}`", + "data": { + "folder_name": "Folder name" + }, + "data_description": { + "folder_name": "Name of the folder" + } + }, + "reconfigure_folder": { + "title": "Change the folder name", + "description": "Rename the instance specific folder inside `{apps_folder}/{approot}`. This will only rename the folder (and does not select another folder), so make sure the new name is not already in use.", + "data": { + "folder_name": "[%key:component::onedrive::config::step::folder_name::data::folder_name%]" + }, + "data_description": { + "folder_name": "[%key:component::onedrive::config::step::folder_name::data_description::folder_name%]" + } } }, "abort": { @@ -23,10 +43,16 @@ "connection_error": "Failed to connect to OneDrive.", "wrong_drive": "New account does not contain previously configured OneDrive.", "unknown": "[%key:common::config_flow::error::unknown%]", - "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" }, "create_entry": { "default": "[%key:common::config_flow::create_entry::authenticated%]" + }, + "error": { + "folder_rename_error": "Failed to rename folder", + "folder_creation_error": "Failed to create folder", + "folder_already_in_use": "Folder already used for backups from another Home Assistant instance" } }, "options": { diff --git a/tests/components/onedrive/conftest.py b/tests/components/onedrive/conftest.py index ed419c820a9..8ff650012f9 100644 --- a/tests/components/onedrive/conftest.py +++ b/tests/components/onedrive/conftest.py @@ -5,13 +5,28 @@ from json import dumps import time from unittest.mock import AsyncMock, MagicMock, patch +from onedrive_personal_sdk.const import DriveState, DriveType +from onedrive_personal_sdk.models.items import ( + AppRoot, + Drive, + DriveQuota, + Folder, + IdentitySet, + ItemParentReference, + User, +) import pytest from homeassistant.components.application_credentials import ( ClientCredential, async_import_client_credential, ) -from homeassistant.components.onedrive.const import DOMAIN, OAUTH_SCOPES +from homeassistant.components.onedrive.const import ( + CONF_FOLDER_ID, + CONF_FOLDER_NAME, + DOMAIN, + OAUTH_SCOPES, +) from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -19,10 +34,9 @@ from .const import ( BACKUP_METADATA, CLIENT_ID, CLIENT_SECRET, - MOCK_APPROOT, + IDENTITY_SET, + INSTANCE_ID, MOCK_BACKUP_FILE, - MOCK_BACKUP_FOLDER, - MOCK_DRIVE, MOCK_METADATA_FILE, ) @@ -66,8 +80,11 @@ def mock_config_entry(expires_at: int, scopes: list[str]) -> MockConfigEntry: "expires_at": expires_at, "scope": " ".join(scopes), }, + CONF_FOLDER_NAME: "backups_123", + CONF_FOLDER_ID: "my_folder_id", }, unique_id="mock_drive_id", + minor_version=2, ) @@ -87,14 +104,80 @@ def mock_onedrive_client_init() -> Generator[MagicMock]: yield onedrive_client +@pytest.fixture +def mock_approot() -> AppRoot: + """Return a mocked approot.""" + return AppRoot( + id="id", + child_count=0, + size=0, + name="name", + parent_reference=ItemParentReference( + drive_id="mock_drive_id", id="id", path="path" + ), + created_by=IdentitySet( + user=User( + display_name="John Doe", + id="id", + email="john@doe.com", + ) + ), + ) + + +@pytest.fixture +def mock_drive() -> Drive: + """Return a mocked drive.""" + return Drive( + id="mock_drive_id", + name="My Drive", + drive_type=DriveType.PERSONAL, + owner=IDENTITY_SET, + quota=DriveQuota( + deleted=5, + remaining=805306368, + state=DriveState.NEARING, + total=5368709120, + used=4250000000, + ), + ) + + +@pytest.fixture +def mock_folder() -> Folder: + """Return a mocked backup folder.""" + return Folder( + id="my_folder_id", + name="name", + size=0, + child_count=0, + description="9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0", + parent_reference=ItemParentReference( + drive_id="mock_drive_id", id="id", path="path" + ), + created_by=IdentitySet( + user=User( + display_name="John Doe", + id="id", + email="john@doe.com", + ), + ), + ) + + @pytest.fixture(autouse=True) -def mock_onedrive_client(mock_onedrive_client_init: MagicMock) -> Generator[MagicMock]: +def mock_onedrive_client( + mock_onedrive_client_init: MagicMock, + mock_approot: AppRoot, + mock_drive: Drive, + mock_folder: Folder, +) -> Generator[MagicMock]: """Return a mocked GraphServiceClient.""" client = mock_onedrive_client_init.return_value - client.get_approot.return_value = MOCK_APPROOT - client.create_folder.return_value = MOCK_BACKUP_FOLDER + client.get_approot.return_value = mock_approot + client.create_folder.return_value = mock_folder client.list_drive_items.return_value = [MOCK_BACKUP_FILE, MOCK_METADATA_FILE] - client.get_drive_item.return_value = MOCK_BACKUP_FILE + client.get_drive_item.return_value = mock_folder client.upload_file.return_value = MOCK_METADATA_FILE class MockStreamReader: @@ -105,7 +188,7 @@ def mock_onedrive_client(mock_onedrive_client_init: MagicMock) -> Generator[Magi return dumps(BACKUP_METADATA).encode() client.download_drive_item.return_value = MockStreamReader() - client.get_drive.return_value = MOCK_DRIVE + client.get_drive.return_value = mock_drive return client @@ -131,8 +214,14 @@ def mock_setup_entry() -> Generator[AsyncMock]: @pytest.fixture(autouse=True) def mock_instance_id() -> Generator[AsyncMock]: """Mock the instance ID.""" - with patch( - "homeassistant.components.onedrive.async_get_instance_id", - return_value="9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0", + with ( + patch( + "homeassistant.components.onedrive.async_get_instance_id", + return_value=INSTANCE_ID, + ) as mock_instance_id, + patch( + "homeassistant.components.onedrive.config_flow.async_get_instance_id", + new=mock_instance_id, + ), ): yield diff --git a/tests/components/onedrive/const.py b/tests/components/onedrive/const.py index 0c04a6f4c82..6e91a7ef0ea 100644 --- a/tests/components/onedrive/const.py +++ b/tests/components/onedrive/const.py @@ -3,13 +3,8 @@ from html import escape from json import dumps -from onedrive_personal_sdk.const import DriveState, DriveType from onedrive_personal_sdk.models.items import ( - AppRoot, - Drive, - DriveQuota, File, - Folder, Hashes, IdentitySet, ItemParentReference, @@ -34,6 +29,8 @@ BACKUP_METADATA = { "size": 34519040, } +INSTANCE_ID = "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0" + IDENTITY_SET = IdentitySet( user=User( display_name="John Doe", @@ -42,28 +39,6 @@ IDENTITY_SET = IdentitySet( ) ) -MOCK_APPROOT = AppRoot( - id="id", - child_count=0, - size=0, - name="name", - parent_reference=ItemParentReference( - drive_id="mock_drive_id", id="id", path="path" - ), - created_by=IDENTITY_SET, -) - -MOCK_BACKUP_FOLDER = Folder( - id="id", - name="name", - size=0, - child_count=0, - parent_reference=ItemParentReference( - drive_id="mock_drive_id", id="id", path="path" - ), - created_by=IDENTITY_SET, -) - MOCK_BACKUP_FILE = File( id="id", name="23e64aec.tar", @@ -75,7 +50,6 @@ MOCK_BACKUP_FILE = File( quick_xor_hash="hash", ), mime_type="application/x-tar", - description="", created_by=IDENTITY_SET, ) @@ -101,18 +75,3 @@ MOCK_METADATA_FILE = File( ), created_by=IDENTITY_SET, ) - - -MOCK_DRIVE = Drive( - id="mock_drive_id", - name="My Drive", - drive_type=DriveType.PERSONAL, - owner=IDENTITY_SET, - quota=DriveQuota( - deleted=5, - remaining=805306368, - state=DriveState.NEARING, - total=5368709120, - used=4250000000, - ), -) diff --git a/tests/components/onedrive/test_config_flow.py b/tests/components/onedrive/test_config_flow.py index 1ae92332075..81cd44bd041 100644 --- a/tests/components/onedrive/test_config_flow.py +++ b/tests/components/onedrive/test_config_flow.py @@ -4,11 +4,14 @@ from http import HTTPStatus from unittest.mock import AsyncMock, MagicMock from onedrive_personal_sdk.exceptions import OneDriveException +from onedrive_personal_sdk.models.items import AppRoot, Folder, ItemUpdate import pytest from homeassistant import config_entries from homeassistant.components.onedrive.const import ( CONF_DELETE_PERMANENTLY, + CONF_FOLDER_ID, + CONF_FOLDER_NAME, DOMAIN, OAUTH2_AUTHORIZE, OAUTH2_TOKEN, @@ -20,7 +23,7 @@ from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import config_entry_oauth2_flow from . import setup_integration -from .const import CLIENT_ID, MOCK_APPROOT +from .const import CLIENT_ID from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker @@ -85,6 +88,11 @@ async def test_full_flow( token_callback = mock_onedrive_client_init.call_args[0][0] assert await token_callback() == "mock-access-token" + assert result["type"] is FlowResultType.FORM + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_FOLDER_NAME: "myFolder"} + ) + assert result["type"] is FlowResultType.CREATE_ENTRY assert len(hass.config_entries.async_entries(DOMAIN)) == 1 assert len(mock_setup_entry.mock_calls) == 1 @@ -92,6 +100,8 @@ async def test_full_flow( assert result["result"].unique_id == "mock_drive_id" assert result["data"][CONF_TOKEN][CONF_ACCESS_TOKEN] == "mock-access-token" assert result["data"][CONF_TOKEN]["refresh_token"] == "mock-refresh-token" + assert result["data"][CONF_FOLDER_NAME] == "myFolder" + assert result["data"][CONF_FOLDER_ID] == "my_folder_id" @pytest.mark.usefixtures("current_request_with_host") @@ -101,10 +111,11 @@ async def test_full_flow_with_owner_not_found( aioclient_mock: AiohttpClientMocker, mock_setup_entry: AsyncMock, mock_onedrive_client: MagicMock, + mock_approot: MagicMock, ) -> None: """Ensure we get a default title if the drive's owner can't be read.""" - mock_onedrive_client.get_approot.return_value.created_by.user = None + mock_approot.created_by.user = None result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -112,6 +123,11 @@ async def test_full_flow_with_owner_not_found( await _do_get_token(hass, result, hass_client_no_auth, aioclient_mock) result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] is FlowResultType.FORM + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_FOLDER_NAME: "myFolder"} + ) + assert result["type"] is FlowResultType.CREATE_ENTRY assert len(hass.config_entries.async_entries(DOMAIN)) == 1 assert len(mock_setup_entry.mock_calls) == 1 @@ -119,6 +135,94 @@ async def test_full_flow_with_owner_not_found( assert result["result"].unique_id == "mock_drive_id" assert result["data"][CONF_TOKEN][CONF_ACCESS_TOKEN] == "mock-access-token" assert result["data"][CONF_TOKEN]["refresh_token"] == "mock-refresh-token" + assert result["data"][CONF_FOLDER_NAME] == "myFolder" + assert result["data"][CONF_FOLDER_ID] == "my_folder_id" + + mock_onedrive_client.reset_mock() + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_folder_already_in_use( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_setup_entry: AsyncMock, + mock_onedrive_client: MagicMock, + mock_instance_id: AsyncMock, + mock_folder: Folder, +) -> None: + """Ensure a folder that is already in use is not allowed.""" + + mock_folder.description = "1234" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + await _do_get_token(hass, result, hass_client_no_auth, aioclient_mock) + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.FORM + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_FOLDER_NAME: "myFolder"} + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {CONF_FOLDER_NAME: "folder_already_in_use"} + + # clear error and try again + mock_onedrive_client.create_folder.return_value.description = mock_instance_id + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_FOLDER_NAME: "myFolder"} + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "John Doe's OneDrive" + assert result["result"].unique_id == "mock_drive_id" + assert result["data"][CONF_TOKEN][CONF_ACCESS_TOKEN] == "mock-access-token" + assert result["data"][CONF_TOKEN]["refresh_token"] == "mock-refresh-token" + assert result["data"][CONF_FOLDER_NAME] == "myFolder" + assert result["data"][CONF_FOLDER_ID] == "my_folder_id" + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_error_during_folder_creation( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_setup_entry: AsyncMock, + mock_onedrive_client: MagicMock, +) -> None: + """Ensure we can create the backup folder.""" + + mock_onedrive_client.create_folder.side_effect = OneDriveException() + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + await _do_get_token(hass, result, hass_client_no_auth, aioclient_mock) + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.FORM + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_FOLDER_NAME: "myFolder"} + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "folder_creation_error"} + + mock_onedrive_client.create_folder.side_effect = None + + # clear error and try again + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_FOLDER_NAME: "myFolder"} + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "John Doe's OneDrive" + assert result["result"].unique_id == "mock_drive_id" + assert result["data"][CONF_TOKEN][CONF_ACCESS_TOKEN] == "mock-access-token" + assert result["data"][CONF_TOKEN]["refresh_token"] == "mock-refresh-token" + assert result["data"][CONF_FOLDER_NAME] == "myFolder" + assert result["data"][CONF_FOLDER_ID] == "my_folder_id" @pytest.mark.usefixtures("current_request_with_host") @@ -205,11 +309,11 @@ async def test_reauth_flow_id_changed( mock_setup_entry: AsyncMock, mock_config_entry: MockConfigEntry, mock_onedrive_client: MagicMock, + mock_approot: AppRoot, ) -> None: """Test that the reauth flow fails on a different drive id.""" - app_root = MOCK_APPROOT - app_root.parent_reference.drive_id = "other_drive_id" - mock_onedrive_client.get_approot.return_value = app_root + + mock_approot.parent_reference.drive_id = "other_drive_id" await setup_integration(hass, mock_config_entry) @@ -226,6 +330,104 @@ async def test_reauth_flow_id_changed( assert result["reason"] == "wrong_drive" +@pytest.mark.usefixtures("current_request_with_host") +async def test_reconfigure_flow( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_onedrive_client: MagicMock, + mock_config_entry: MockConfigEntry, + mock_setup_entry: AsyncMock, +) -> None: + """Testing reconfgure flow.""" + await setup_integration(hass, mock_config_entry) + + result = await mock_config_entry.start_reconfigure_flow(hass) + await _do_get_token(hass, result, hass_client_no_auth, aioclient_mock) + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure_folder" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_FOLDER_NAME: "newFolder"} + ) + + assert result["type"] is FlowResultType.ABORT + mock_onedrive_client.update_drive_item.assert_called_once_with( + mock_config_entry.data[CONF_FOLDER_ID], ItemUpdate(name="newFolder") + ) + assert mock_config_entry.data[CONF_FOLDER_NAME] == "newFolder" + assert mock_config_entry.data[CONF_TOKEN][CONF_ACCESS_TOKEN] == "mock-access-token" + assert mock_config_entry.data[CONF_TOKEN]["refresh_token"] == "mock-refresh-token" + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_reconfigure_flow_error( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_onedrive_client: MagicMock, + mock_config_entry: MockConfigEntry, + mock_setup_entry: AsyncMock, +) -> None: + """Testing reconfgure flow errors.""" + mock_config_entry.add_to_hass(hass) + await hass.async_block_till_done() + + result = await mock_config_entry.start_reconfigure_flow(hass) + await _do_get_token(hass, result, hass_client_no_auth, aioclient_mock) + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure_folder" + + mock_onedrive_client.update_drive_item.side_effect = OneDriveException() + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_FOLDER_NAME: "newFolder"} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure_folder" + assert result["errors"] == {"base": "folder_rename_error"} + + # clear side effect + mock_onedrive_client.update_drive_item.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_FOLDER_NAME: "newFolder"} + ) + + assert mock_config_entry.data[CONF_FOLDER_NAME] == "newFolder" + assert mock_config_entry.data[CONF_TOKEN][CONF_ACCESS_TOKEN] == "mock-access-token" + assert mock_config_entry.data[CONF_TOKEN]["refresh_token"] == "mock-refresh-token" + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_reconfigure_flow_id_changed( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, + mock_onedrive_client: MagicMock, + mock_approot: AppRoot, +) -> None: + """Test that the reconfigure flow fails on a different drive id.""" + + mock_approot.parent_reference.drive_id = "other_drive_id" + + mock_config_entry.add_to_hass(hass) + await hass.async_block_till_done() + + result = await mock_config_entry.start_reconfigure_flow(hass) + await _do_get_token(hass, result, hass_client_no_auth, aioclient_mock) + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "wrong_drive" + + async def test_options_flow( hass: HomeAssistant, mock_config_entry: MockConfigEntry, diff --git a/tests/components/onedrive/test_init.py b/tests/components/onedrive/test_init.py index b4ec138ebf4..41c1966a4ae 100644 --- a/tests/components/onedrive/test_init.py +++ b/tests/components/onedrive/test_init.py @@ -1,22 +1,31 @@ """Test the OneDrive setup.""" -from copy import deepcopy +from copy import copy from html import escape from json import dumps from unittest.mock import MagicMock from onedrive_personal_sdk.const import DriveState -from onedrive_personal_sdk.exceptions import AuthenticationError, OneDriveException +from onedrive_personal_sdk.exceptions import ( + AuthenticationError, + NotFoundError, + OneDriveException, +) +from onedrive_personal_sdk.models.items import AppRoot, Drive, Folder, ItemUpdate import pytest from syrupy import SnapshotAssertion -from homeassistant.components.onedrive.const import DOMAIN +from homeassistant.components.onedrive.const import ( + CONF_FOLDER_ID, + CONF_FOLDER_NAME, + DOMAIN, +) from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, issue_registry as ir from . import setup_integration -from .const import BACKUP_METADATA, MOCK_BACKUP_FILE, MOCK_DRIVE +from .const import BACKUP_METADATA, INSTANCE_ID, MOCK_BACKUP_FILE from tests.common import MockConfigEntry @@ -72,11 +81,64 @@ async def test_get_integration_folder_error( mock_onedrive_client: MagicMock, caplog: pytest.LogCaptureFixture, ) -> None: - """Test faulty approot retrieval.""" - mock_onedrive_client.create_folder.side_effect = OneDriveException() + """Test faulty integration folder retrieval.""" + mock_onedrive_client.get_drive_item.side_effect = OneDriveException() await setup_integration(hass, mock_config_entry) assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY - assert "Failed to get backups_9f86d081 folder" in caplog.text + assert "Failed to get backups_123 folder" in caplog.text + + +async def test_get_integration_folder_creation( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_onedrive_client: MagicMock, + mock_approot: AppRoot, + mock_folder: Folder, +) -> None: + """Test faulty integration folder creation.""" + folder_name = copy(mock_config_entry.data[CONF_FOLDER_NAME]) + mock_onedrive_client.get_drive_item.side_effect = NotFoundError(404, "Not found") + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.LOADED + mock_onedrive_client.create_folder.assert_called_once_with( + parent_id=mock_approot.id, + name=folder_name, + ) + # ensure the folder id and name are updated + assert mock_config_entry.data[CONF_FOLDER_ID] == mock_folder.id + assert mock_config_entry.data[CONF_FOLDER_NAME] == mock_folder.name + + +async def test_get_integration_folder_creation_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_onedrive_client: MagicMock, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test faulty integration folder creation error.""" + mock_onedrive_client.get_drive_item.side_effect = NotFoundError(404, "Not found") + mock_onedrive_client.create_folder.side_effect = OneDriveException() + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + assert "Failed to get backups_123 folder" in caplog.text + + +async def test_update_instance_id_description( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_onedrive_client: MagicMock, + mock_folder: Folder, +) -> None: + """Test we write the instance id to the folder.""" + mock_folder.description = "" + await setup_integration(hass, mock_config_entry) + await hass.async_block_till_done() + + mock_onedrive_client.update_drive_item.assert_called_with( + mock_folder.id, ItemUpdate(description=INSTANCE_ID) + ) async def test_migrate_metadata_files( @@ -125,12 +187,13 @@ async def test_device( mock_config_entry: MockConfigEntry, device_registry: dr.DeviceRegistry, snapshot: SnapshotAssertion, + mock_drive: Drive, ) -> None: """Test the device.""" await setup_integration(hass, mock_config_entry) - device = device_registry.async_get_device({(DOMAIN, MOCK_DRIVE.id)}) + device = device_registry.async_get_device({(DOMAIN, mock_drive.id)}) assert device assert device == snapshot @@ -154,17 +217,62 @@ async def test_data_cap_issues( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_onedrive_client: MagicMock, + mock_drive: Drive, drive_state: DriveState, issue_key: str, issue_exists: bool, ) -> None: """Make sure we get issues for high data usage.""" - mock_drive = deepcopy(MOCK_DRIVE) assert mock_drive.quota mock_drive.quota.state = drive_state - mock_onedrive_client.get_drive.return_value = mock_drive + await setup_integration(hass, mock_config_entry) issue_registry = ir.async_get(hass) issue = issue_registry.async_get_issue(DOMAIN, issue_key) assert (issue is not None) == issue_exists + + +async def test_1_1_to_1_2_migration( + hass: HomeAssistant, + mock_onedrive_client: MagicMock, + mock_config_entry: MockConfigEntry, + mock_folder: Folder, +) -> None: + """Test migration from 1.1 to 1.2.""" + old_config_entry = MockConfigEntry( + unique_id="mock_drive_id", + title="John Doe's OneDrive", + domain=DOMAIN, + data={ + "auth_implementation": mock_config_entry.data["auth_implementation"], + "token": mock_config_entry.data["token"], + }, + ) + + # will always 404 after migration, because of dummy id + mock_onedrive_client.get_drive_item.side_effect = NotFoundError(404, "Not found") + + await setup_integration(hass, old_config_entry) + assert old_config_entry.data[CONF_FOLDER_ID] == mock_folder.id + assert old_config_entry.data[CONF_FOLDER_NAME] == mock_folder.name + + +async def test_migration_guard_against_major_downgrade( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test migration guards against major downgrades.""" + old_config_entry = MockConfigEntry( + unique_id="mock_drive_id", + title="John Doe's OneDrive", + domain=DOMAIN, + data={ + "auth_implementation": mock_config_entry.data["auth_implementation"], + "token": mock_config_entry.data["token"], + }, + version=2, + ) + + await setup_integration(hass, old_config_entry) + assert old_config_entry.state is ConfigEntryState.MIGRATION_ERROR From 1cd82ab8eea77d09e1261401fa7ec23362f59330 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Sun, 23 Feb 2025 16:18:20 +0100 Subject: [PATCH 1707/3148] Deprecate Home Connect command actions (#139093) * Deprecate command actions * Improve issue description * Improve issue description Co-authored-by: Martin Hjelmare --------- Co-authored-by: Martin Hjelmare --- .../components/home_connect/__init__.py | 12 +++++++++++ .../components/home_connect/strings.json | 4 ++++ tests/components/home_connect/test_init.py | 21 ++++++++++++++++--- 3 files changed, 34 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/home_connect/__init__.py b/homeassistant/components/home_connect/__init__.py index 637fd7aa3a8..51b38bf7cd3 100644 --- a/homeassistant/components/home_connect/__init__.py +++ b/homeassistant/components/home_connect/__init__.py @@ -405,6 +405,17 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: """Execute calls to services executing a command.""" client, ha_id = await _get_client_and_ha_id(hass, call.data[ATTR_DEVICE_ID]) + async_create_issue( + hass, + DOMAIN, + "deprecated_command_actions", + breaks_in_ha_version="2025.9.0", + is_fixable=True, + is_persistent=True, + severity=IssueSeverity.WARNING, + translation_key="deprecated_command_actions", + ) + try: await client.put_command(ha_id, command_key=command_key, value=True) except HomeConnectError as err: @@ -610,6 +621,7 @@ async def async_unload_entry( ) -> bool: """Unload a config entry.""" async_delete_issue(hass, DOMAIN, "deprecated_set_program_and_option_actions") + async_delete_issue(hass, DOMAIN, "deprecated_command_actions") return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/home_connect/strings.json b/homeassistant/components/home_connect/strings.json index d6330c8b78b..977ad1f36f0 100644 --- a/homeassistant/components/home_connect/strings.json +++ b/homeassistant/components/home_connect/strings.json @@ -108,6 +108,10 @@ "title": "Deprecated binary door sensor detected in some automations or scripts", "description": "The binary door sensor `{entity}`, which is deprecated, is used in the following automations or scripts:\n{items}\n\nA sensor entity with additional possible states is available and should be used going forward; Please use it on the above automations or scripts to fix this issue." }, + "deprecated_command_actions": { + "title": "The command related actions are deprecated in favor of the new buttons", + "description": "The `pause_program` and `resume_program` actions have been deprecated in favor of new button entities, if the command is available for your appliance. Please update your automations, scripts and panels that use this action to use the button entities instead, and press on submit to fix the issue." + }, "deprecated_program_switch": { "title": "Deprecated program switch detected in some automations or scripts", "description": "Program switches are deprecated and {entity_id} is used in the following automations or scripts:\n{items}\n\nYou can use the active program select entity to run the program without any additional options and get the current running program on the above automations or scripts to fix this issue." diff --git a/tests/components/home_connect/test_init.py b/tests/components/home_connect/test_init.py index 5e309a7446e..06498f891db 100644 --- a/tests/components/home_connect/test_init.py +++ b/tests/components/home_connect/test_init.py @@ -338,11 +338,27 @@ async def test_key_value_services( @pytest.mark.parametrize( - "service_call", - DEPRECATED_SERVICE_KV_CALL_PARAMS + SERVICE_PROGRAM_CALL_PARAMS, + ("service_call", "issue_id"), + [ + *zip( + DEPRECATED_SERVICE_KV_CALL_PARAMS + SERVICE_PROGRAM_CALL_PARAMS, + ["deprecated_set_program_and_option_actions"] + * ( + len(DEPRECATED_SERVICE_KV_CALL_PARAMS) + + len(SERVICE_PROGRAM_CALL_PARAMS) + ), + strict=True, + ), + *zip( + SERVICE_COMMAND_CALL_PARAMS, + ["deprecated_command_actions"] * len(SERVICE_COMMAND_CALL_PARAMS), + strict=True, + ), + ], ) async def test_programs_and_options_actions_deprecation( service_call: dict[str, Any], + issue_id: str, hass: HomeAssistant, device_registry: dr.DeviceRegistry, config_entry: MockConfigEntry, @@ -354,7 +370,6 @@ async def test_programs_and_options_actions_deprecation( hass_client: ClientSessionGenerator, ) -> None: """Test deprecated service keys.""" - issue_id = "deprecated_set_program_and_option_actions" assert config_entry.state == ConfigEntryState.NOT_LOADED assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED From 0b961d98f58fbb61791f80fcc35a2dd80c621e66 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Sun, 23 Feb 2025 16:32:55 +0100 Subject: [PATCH 1708/3148] Move remember the milk config storage to own module (#138999) --- .../components/remember_the_milk/__init__.py | 130 ++---------------- .../components/remember_the_milk/const.py | 5 + .../components/remember_the_milk/entity.py | 22 ++- .../components/remember_the_milk/storage.py | 115 ++++++++++++++++ .../{test_init.py => test_storage.py} | 14 +- 5 files changed, 148 insertions(+), 138 deletions(-) create mode 100644 homeassistant/components/remember_the_milk/const.py create mode 100644 homeassistant/components/remember_the_milk/storage.py rename tests/components/remember_the_milk/{test_init.py => test_storage.py} (90%) diff --git a/homeassistant/components/remember_the_milk/__init__.py b/homeassistant/components/remember_the_milk/__init__.py index 2a95ed46b20..fc192bd538a 100644 --- a/homeassistant/components/remember_the_milk/__init__.py +++ b/homeassistant/components/remember_the_milk/__init__.py @@ -1,33 +1,25 @@ """Support to interact with Remember The Milk.""" -import json -import logging -from pathlib import Path - from rtmapi import Rtm import voluptuous as vol from homeassistant.components import configurator -from homeassistant.const import CONF_API_KEY, CONF_ID, CONF_NAME, CONF_TOKEN +from homeassistant.const import CONF_API_KEY, CONF_ID, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType +from .const import LOGGER from .entity import RememberTheMilkEntity +from .storage import RememberTheMilkConfiguration # httplib2 is a transitive dependency from RtmAPI. If this dependency is not # set explicitly, the library does not work. -_LOGGER = logging.getLogger(__name__) DOMAIN = "remember_the_milk" -DEFAULT_NAME = DOMAIN CONF_SHARED_SECRET = "shared_secret" -CONF_ID_MAP = "id_map" -CONF_LIST_ID = "list_id" -CONF_TIMESERIES_ID = "timeseries_id" -CONF_TASK_ID = "task_id" RTM_SCHEMA = vol.Schema( { @@ -41,7 +33,6 @@ CONFIG_SCHEMA = vol.Schema( {DOMAIN: vol.All(cv.ensure_list, [RTM_SCHEMA])}, extra=vol.ALLOW_EXTRA ) -CONFIG_FILE_NAME = ".remember_the_milk.conf" SERVICE_CREATE_TASK = "create_task" SERVICE_COMPLETE_TASK = "complete_task" @@ -54,17 +45,17 @@ SERVICE_SCHEMA_COMPLETE_TASK = vol.Schema({vol.Required(CONF_ID): cv.string}) def setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Remember the milk component.""" - component = EntityComponent[RememberTheMilkEntity](_LOGGER, DOMAIN, hass) + component = EntityComponent[RememberTheMilkEntity](LOGGER, DOMAIN, hass) stored_rtm_config = RememberTheMilkConfiguration(hass) for rtm_config in config[DOMAIN]: account_name = rtm_config[CONF_NAME] - _LOGGER.debug("Adding Remember the milk account %s", account_name) + LOGGER.debug("Adding Remember the milk account %s", account_name) api_key = rtm_config[CONF_API_KEY] shared_secret = rtm_config[CONF_SHARED_SECRET] token = stored_rtm_config.get_token(account_name) if token: - _LOGGER.debug("found token for account %s", account_name) + LOGGER.debug("found token for account %s", account_name) _create_instance( hass, account_name, @@ -79,7 +70,7 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool: hass, account_name, api_key, shared_secret, stored_rtm_config, component ) - _LOGGER.debug("Finished adding all Remember the milk accounts") + LOGGER.debug("Finished adding all Remember the milk accounts") return True @@ -110,21 +101,21 @@ def _register_new_account( request_id = None api = Rtm(api_key, shared_secret, "write", None) url, frob = api.authenticate_desktop() - _LOGGER.debug("Sent authentication request to server") + LOGGER.debug("Sent authentication request to server") def register_account_callback(fields: list[dict[str, str]]) -> None: """Call for register the configurator.""" api.retrieve_token(frob) token = api.token if api.token is None: - _LOGGER.error("Failed to register, please try again") + LOGGER.error("Failed to register, please try again") configurator.notify_errors( hass, request_id, "Failed to register, please try again." ) return stored_rtm_config.set_token(account_name, token) - _LOGGER.debug("Retrieved new token from server") + LOGGER.debug("Retrieved new token from server") _create_instance( hass, @@ -152,104 +143,3 @@ def _register_new_account( link_url=url, submit_caption="login completed", ) - - -class RememberTheMilkConfiguration: - """Internal configuration data for RememberTheMilk class. - - This class stores the authentication token it get from the backend. - """ - - def __init__(self, hass: HomeAssistant) -> None: - """Create new instance of configuration.""" - self._config_file_path = hass.config.path(CONFIG_FILE_NAME) - self._config = {} - _LOGGER.debug("Loading configuration from file: %s", self._config_file_path) - try: - self._config = json.loads( - Path(self._config_file_path).read_text(encoding="utf8") - ) - except FileNotFoundError: - _LOGGER.debug("Missing configuration file: %s", self._config_file_path) - except OSError: - _LOGGER.debug( - "Failed to read from configuration file, %s, using empty configuration", - self._config_file_path, - ) - except ValueError: - _LOGGER.error( - "Failed to parse configuration file, %s, using empty configuration", - self._config_file_path, - ) - - def _save_config(self) -> None: - """Write the configuration to a file.""" - Path(self._config_file_path).write_text( - json.dumps(self._config), encoding="utf8" - ) - - def get_token(self, profile_name: str) -> str | None: - """Get the server token for a profile.""" - if profile_name in self._config: - return self._config[profile_name][CONF_TOKEN] - return None - - def set_token(self, profile_name: str, token: str) -> None: - """Store a new server token for a profile.""" - self._initialize_profile(profile_name) - self._config[profile_name][CONF_TOKEN] = token - self._save_config() - - def delete_token(self, profile_name: str) -> None: - """Delete a token for a profile. - - Usually called when the token has expired. - """ - self._config.pop(profile_name, None) - self._save_config() - - def _initialize_profile(self, profile_name: str) -> None: - """Initialize the data structures for a profile.""" - if profile_name not in self._config: - self._config[profile_name] = {} - if CONF_ID_MAP not in self._config[profile_name]: - self._config[profile_name][CONF_ID_MAP] = {} - - def get_rtm_id( - self, profile_name: str, hass_id: str - ) -> tuple[str, str, str] | None: - """Get the RTM ids for a Home Assistant task ID. - - The id of a RTM tasks consists of the tuple: - list id, timeseries id and the task id. - """ - self._initialize_profile(profile_name) - ids = self._config[profile_name][CONF_ID_MAP].get(hass_id) - if ids is None: - return None - return ids[CONF_LIST_ID], ids[CONF_TIMESERIES_ID], ids[CONF_TASK_ID] - - def set_rtm_id( - self, - profile_name: str, - hass_id: str, - list_id: str, - time_series_id: str, - rtm_task_id: str, - ) -> None: - """Add/Update the RTM task ID for a Home Assistant task IS.""" - self._initialize_profile(profile_name) - id_tuple = { - CONF_LIST_ID: list_id, - CONF_TIMESERIES_ID: time_series_id, - CONF_TASK_ID: rtm_task_id, - } - self._config[profile_name][CONF_ID_MAP][hass_id] = id_tuple - self._save_config() - - def delete_rtm_id(self, profile_name: str, hass_id: str) -> None: - """Delete a key mapping.""" - self._initialize_profile(profile_name) - if hass_id in self._config[profile_name][CONF_ID_MAP]: - del self._config[profile_name][CONF_ID_MAP][hass_id] - self._save_config() diff --git a/homeassistant/components/remember_the_milk/const.py b/homeassistant/components/remember_the_milk/const.py new file mode 100644 index 00000000000..2fccbf3ee52 --- /dev/null +++ b/homeassistant/components/remember_the_milk/const.py @@ -0,0 +1,5 @@ +"""Constants for the Remember The Milk integration.""" + +import logging + +LOGGER = logging.getLogger(__package__) diff --git a/homeassistant/components/remember_the_milk/entity.py b/homeassistant/components/remember_the_milk/entity.py index 5f618a96c11..bf75debe367 100644 --- a/homeassistant/components/remember_the_milk/entity.py +++ b/homeassistant/components/remember_the_milk/entity.py @@ -1,14 +1,12 @@ """Support to interact with Remember The Milk.""" -import logging - from rtmapi import Rtm, RtmRequestFailedException from homeassistant.const import CONF_ID, CONF_NAME, STATE_OK from homeassistant.core import ServiceCall from homeassistant.helpers.entity import Entity -_LOGGER = logging.getLogger(__name__) +from .const import LOGGER class RememberTheMilkEntity(Entity): @@ -24,7 +22,7 @@ class RememberTheMilkEntity(Entity): self._rtm_api = Rtm(api_key, shared_secret, "delete", token) self._token_valid = None self._check_token() - _LOGGER.debug("Instance created for account %s", self._name) + LOGGER.debug("Instance created for account %s", self._name) def _check_token(self): """Check if the API token is still valid. @@ -34,7 +32,7 @@ class RememberTheMilkEntity(Entity): """ valid = self._rtm_api.token_valid() if not valid: - _LOGGER.error( + LOGGER.error( "Token for account %s is invalid. You need to register again!", self.name, ) @@ -64,7 +62,7 @@ class RememberTheMilkEntity(Entity): result = self._rtm_api.rtm.tasks.add( timeline=timeline, name=task_name, parse="1" ) - _LOGGER.debug( + LOGGER.debug( "Created new task '%s' in account %s", task_name, self.name ) if hass_id is not None: @@ -83,14 +81,14 @@ class RememberTheMilkEntity(Entity): task_id=rtm_id[2], timeline=timeline, ) - _LOGGER.debug( + LOGGER.debug( "Updated task with id '%s' in account %s to name %s", hass_id, self.name, task_name, ) except RtmRequestFailedException as rtm_exception: - _LOGGER.error( + LOGGER.error( "Error creating new Remember The Milk task for account %s: %s", self._name, rtm_exception, @@ -101,7 +99,7 @@ class RememberTheMilkEntity(Entity): hass_id = call.data[CONF_ID] rtm_id = self._rtm_config.get_rtm_id(self._name, hass_id) if rtm_id is None: - _LOGGER.error( + LOGGER.error( ( "Could not find task with ID %s in account %s. " "So task could not be closed" @@ -120,11 +118,9 @@ class RememberTheMilkEntity(Entity): timeline=timeline, ) self._rtm_config.delete_rtm_id(self._name, hass_id) - _LOGGER.debug( - "Completed task with id %s in account %s", hass_id, self._name - ) + LOGGER.debug("Completed task with id %s in account %s", hass_id, self._name) except RtmRequestFailedException as rtm_exception: - _LOGGER.error( + LOGGER.error( "Error creating new Remember The Milk task for account %s: %s", self._name, rtm_exception, diff --git a/homeassistant/components/remember_the_milk/storage.py b/homeassistant/components/remember_the_milk/storage.py new file mode 100644 index 00000000000..ae51acd963b --- /dev/null +++ b/homeassistant/components/remember_the_milk/storage.py @@ -0,0 +1,115 @@ +"""Store RTM configuration in Home Assistant storage.""" + +from __future__ import annotations + +import json +from pathlib import Path + +from homeassistant.const import CONF_TOKEN +from homeassistant.core import HomeAssistant + +from .const import LOGGER + +CONFIG_FILE_NAME = ".remember_the_milk.conf" +CONF_ID_MAP = "id_map" +CONF_LIST_ID = "list_id" +CONF_TASK_ID = "task_id" +CONF_TIMESERIES_ID = "timeseries_id" + + +class RememberTheMilkConfiguration: + """Internal configuration data for Remember The Milk.""" + + def __init__(self, hass: HomeAssistant) -> None: + """Create new instance of configuration.""" + self._config_file_path = hass.config.path(CONFIG_FILE_NAME) + self._config = {} + LOGGER.debug("Loading configuration from file: %s", self._config_file_path) + try: + self._config = json.loads( + Path(self._config_file_path).read_text(encoding="utf8") + ) + except FileNotFoundError: + LOGGER.debug("Missing configuration file: %s", self._config_file_path) + except OSError: + LOGGER.debug( + "Failed to read from configuration file, %s, using empty configuration", + self._config_file_path, + ) + except ValueError: + LOGGER.error( + "Failed to parse configuration file, %s, using empty configuration", + self._config_file_path, + ) + + def _save_config(self) -> None: + """Write the configuration to a file.""" + Path(self._config_file_path).write_text( + json.dumps(self._config), encoding="utf8" + ) + + def get_token(self, profile_name: str) -> str | None: + """Get the server token for a profile.""" + if profile_name in self._config: + return self._config[profile_name][CONF_TOKEN] + return None + + def set_token(self, profile_name: str, token: str) -> None: + """Store a new server token for a profile.""" + self._initialize_profile(profile_name) + self._config[profile_name][CONF_TOKEN] = token + self._save_config() + + def delete_token(self, profile_name: str) -> None: + """Delete a token for a profile. + + Usually called when the token has expired. + """ + self._config.pop(profile_name, None) + self._save_config() + + def _initialize_profile(self, profile_name: str) -> None: + """Initialize the data structures for a profile.""" + if profile_name not in self._config: + self._config[profile_name] = {} + if CONF_ID_MAP not in self._config[profile_name]: + self._config[profile_name][CONF_ID_MAP] = {} + + def get_rtm_id( + self, profile_name: str, hass_id: str + ) -> tuple[str, str, str] | None: + """Get the RTM ids for a Home Assistant task ID. + + The id of a RTM tasks consists of the tuple: + list id, timeseries id and the task id. + """ + self._initialize_profile(profile_name) + ids = self._config[profile_name][CONF_ID_MAP].get(hass_id) + if ids is None: + return None + return ids[CONF_LIST_ID], ids[CONF_TIMESERIES_ID], ids[CONF_TASK_ID] + + def set_rtm_id( + self, + profile_name: str, + hass_id: str, + list_id: str, + time_series_id: str, + rtm_task_id: str, + ) -> None: + """Add/Update the RTM task ID for a Home Assistant task IS.""" + self._initialize_profile(profile_name) + id_tuple = { + CONF_LIST_ID: list_id, + CONF_TIMESERIES_ID: time_series_id, + CONF_TASK_ID: rtm_task_id, + } + self._config[profile_name][CONF_ID_MAP][hass_id] = id_tuple + self._save_config() + + def delete_rtm_id(self, profile_name: str, hass_id: str) -> None: + """Delete a key mapping.""" + self._initialize_profile(profile_name) + if hass_id in self._config[profile_name][CONF_ID_MAP]: + del self._config[profile_name][CONF_ID_MAP][hass_id] + self._save_config() diff --git a/tests/components/remember_the_milk/test_init.py b/tests/components/remember_the_milk/test_storage.py similarity index 90% rename from tests/components/remember_the_milk/test_init.py rename to tests/components/remember_the_milk/test_storage.py index 517c8cebc0e..6ae774a3d0d 100644 --- a/tests/components/remember_the_milk/test_init.py +++ b/tests/components/remember_the_milk/test_storage.py @@ -14,7 +14,9 @@ from .const import JSON_STRING, PROFILE, TOKEN def test_set_get_delete_token(hass: HomeAssistant) -> None: """Test set, get and delete token.""" open_mock = mock_open() - with patch("homeassistant.components.remember_the_milk.Path.open", open_mock): + with patch( + "homeassistant.components.remember_the_milk.storage.Path.open", open_mock + ): config = rtm.RememberTheMilkConfiguration(hass) assert open_mock.return_value.write.call_count == 0 assert config.get_token(PROFILE) is None @@ -42,7 +44,7 @@ def test_config_load(hass: HomeAssistant) -> None: """Test loading from the file.""" with ( patch( - "homeassistant.components.remember_the_milk.Path.open", + "homeassistant.components.remember_the_milk.storage.Path.open", mock_open(read_data=JSON_STRING), ), ): @@ -61,7 +63,7 @@ def test_config_load_file_error(hass: HomeAssistant, side_effect: Exception) -> config = rtm.RememberTheMilkConfiguration(hass) with ( patch( - "homeassistant.components.remember_the_milk.Path.open", + "homeassistant.components.remember_the_milk.storage.Path.open", side_effect=side_effect, ), ): @@ -78,7 +80,7 @@ def test_config_load_invalid_data(hass: HomeAssistant) -> None: config = rtm.RememberTheMilkConfiguration(hass) with ( patch( - "homeassistant.components.remember_the_milk.Path.open", + "homeassistant.components.remember_the_milk.storage.Path.open", mock_open(read_data="random characters"), ), ): @@ -98,7 +100,9 @@ def test_config_set_delete_id(hass: HomeAssistant) -> None: rtm_id = "3" open_mock = mock_open() config = rtm.RememberTheMilkConfiguration(hass) - with patch("homeassistant.components.remember_the_milk.Path.open", open_mock): + with patch( + "homeassistant.components.remember_the_milk.storage.Path.open", open_mock + ): config = rtm.RememberTheMilkConfiguration(hass) assert open_mock.return_value.write.call_count == 0 assert config.get_rtm_id(PROFILE, hass_id) is None From 4f5c7353f8563124cb8e5d368e65171a28ec3b08 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Sun, 23 Feb 2025 17:34:17 +0100 Subject: [PATCH 1709/3148] Test remember the milk configurator (#139122) --- .../components/remember_the_milk/conftest.py | 12 +++- tests/components/remember_the_milk/const.py | 5 ++ .../remember_the_milk/test_entity.py | 8 +-- .../components/remember_the_milk/test_init.py | 65 +++++++++++++++++++ 4 files changed, 81 insertions(+), 9 deletions(-) create mode 100644 tests/components/remember_the_milk/test_init.py diff --git a/tests/components/remember_the_milk/conftest.py b/tests/components/remember_the_milk/conftest.py index f7257f35c64..ac80cf2972b 100644 --- a/tests/components/remember_the_milk/conftest.py +++ b/tests/components/remember_the_milk/conftest.py @@ -13,8 +13,16 @@ from .const import TOKEN @pytest.fixture(name="client") def client_fixture() -> Generator[MagicMock]: """Create a mock client.""" - with patch("homeassistant.components.remember_the_milk.entity.Rtm") as client_class: - client = client_class.return_value + client = MagicMock() + with ( + patch( + "homeassistant.components.remember_the_milk.entity.Rtm" + ) as entity_client_class, + patch("homeassistant.components.remember_the_milk.Rtm") as client_class, + ): + entity_client_class.return_value = client + client_class.return_value = client + client.token = TOKEN client.token_valid.return_value = True timelines = MagicMock() timelines.timeline.value = "1234" diff --git a/tests/components/remember_the_milk/const.py b/tests/components/remember_the_milk/const.py index 3f1d0067219..bed39eec5f8 100644 --- a/tests/components/remember_the_milk/const.py +++ b/tests/components/remember_the_milk/const.py @@ -3,6 +3,11 @@ import json PROFILE = "myprofile" +CONFIG = { + "name": f"{PROFILE}", + "api_key": "test-api-key", + "shared_secret": "test-shared-secret", +} TOKEN = "mytoken" JSON_STRING = json.dumps( { diff --git a/tests/components/remember_the_milk/test_entity.py b/tests/components/remember_the_milk/test_entity.py index e9d7a16d7ab..bdd4189e394 100644 --- a/tests/components/remember_the_milk/test_entity.py +++ b/tests/components/remember_the_milk/test_entity.py @@ -10,13 +10,7 @@ from homeassistant.components.remember_the_milk import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from .const import PROFILE - -CONFIG = { - "name": f"{PROFILE}", - "api_key": "test-api-key", - "shared_secret": "test-shared-secret", -} +from .const import CONFIG, PROFILE @pytest.mark.parametrize( diff --git a/tests/components/remember_the_milk/test_init.py b/tests/components/remember_the_milk/test_init.py new file mode 100644 index 00000000000..feed2894d86 --- /dev/null +++ b/tests/components/remember_the_milk/test_init.py @@ -0,0 +1,65 @@ +"""Test the Remember The Milk integration.""" + +from collections.abc import Generator +from unittest.mock import MagicMock, patch + +import pytest + +from homeassistant.components.remember_the_milk import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from .const import CONFIG, PROFILE, TOKEN + + +@pytest.fixture(autouse=True) +def configure_id() -> Generator[str]: + """Fixture to return a configure_id.""" + mock_id = "1-1" + with patch( + "homeassistant.components.configurator.Configurator._generate_unique_id" + ) as generate_id: + generate_id.return_value = mock_id + yield mock_id + + +@pytest.mark.parametrize( + ("token", "rtm_entity_exists", "configurator_end_state"), + [(TOKEN, True, "configured"), (None, False, "configure")], +) +async def test_configurator( + hass: HomeAssistant, + client: MagicMock, + storage: MagicMock, + configure_id: str, + token: str | None, + rtm_entity_exists: bool, + configurator_end_state: str, +) -> None: + """Test configurator.""" + storage.get_token.return_value = None + client.authenticate_desktop.return_value = ("test-url", "test-frob") + client.token = token + rtm_entity_id = f"{DOMAIN}.{PROFILE}" + configure_entity_id = f"configurator.{DOMAIN}_{PROFILE}" + + assert await async_setup_component(hass, DOMAIN, {DOMAIN: CONFIG}) + await hass.async_block_till_done() + + assert hass.states.get(rtm_entity_id) is None + state = hass.states.get(configure_entity_id) + assert state + assert state.state == "configure" + + await hass.services.async_call( + "configurator", + "configure", + {"configure_id": configure_id}, + blocking=True, + ) + await hass.async_block_till_done() + + assert bool(hass.states.get(rtm_entity_id)) == rtm_entity_exists + state = hass.states.get(configure_entity_id) + assert state + assert state.state == configurator_end_state From 3d507c7b442abd599972008214ada53bea2a867a Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Sun, 23 Feb 2025 18:40:31 +0100 Subject: [PATCH 1710/3148] Change backup listener calls for existing backup integrations (#138988) --- .../components/google_drive/__init__.py | 19 +++++----------- homeassistant/components/onedrive/__init__.py | 20 ++++++----------- .../components/synology_dsm/__init__.py | 22 ++++++++----------- 3 files changed, 22 insertions(+), 39 deletions(-) diff --git a/homeassistant/components/google_drive/__init__.py b/homeassistant/components/google_drive/__init__.py index b30bc2ae1f6..d5252bd01ea 100644 --- a/homeassistant/components/google_drive/__init__.py +++ b/homeassistant/components/google_drive/__init__.py @@ -7,7 +7,7 @@ from collections.abc import Callable from google_drive_api.exceptions import GoogleDriveApiError from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import instance_id from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -49,7 +49,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: GoogleDriveConfigEntry) except GoogleDriveApiError as err: raise ConfigEntryNotReady from err - _async_notify_backup_listeners_soon(hass) + def async_notify_backup_listeners() -> None: + for listener in hass.data.get(DATA_BACKUP_AGENT_LISTENERS, []): + listener() + + entry.async_on_unload(entry.async_on_state_change(async_notify_backup_listeners)) return True @@ -58,15 +62,4 @@ async def async_unload_entry( hass: HomeAssistant, entry: GoogleDriveConfigEntry ) -> bool: """Unload a config entry.""" - _async_notify_backup_listeners_soon(hass) return True - - -def _async_notify_backup_listeners(hass: HomeAssistant) -> None: - for listener in hass.data.get(DATA_BACKUP_AGENT_LISTENERS, []): - listener() - - -@callback -def _async_notify_backup_listeners_soon(hass: HomeAssistant) -> None: - hass.loop.call_soon(_async_notify_backup_listeners, hass) diff --git a/homeassistant/components/onedrive/__init__.py b/homeassistant/components/onedrive/__init__.py index 6805b073ea2..454c782af92 100644 --- a/homeassistant/components/onedrive/__init__.py +++ b/homeassistant/components/onedrive/__init__.py @@ -17,7 +17,7 @@ from onedrive_personal_sdk.exceptions import ( from onedrive_personal_sdk.models.items import Item, ItemUpdate from homeassistant.const import CONF_ACCESS_TOKEN, Platform -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.config_entry_oauth2_flow import ( @@ -102,7 +102,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: OneDriveConfigEntry) -> translation_key="failed_to_migrate_files", ) from err - _async_notify_backup_listeners_soon(hass) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) async def update_listener(hass: HomeAssistant, entry: OneDriveConfigEntry) -> None: @@ -110,25 +109,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: OneDriveConfigEntry) -> entry.async_on_unload(entry.add_update_listener(update_listener)) + def async_notify_backup_listeners() -> None: + for listener in hass.data.get(DATA_BACKUP_AGENT_LISTENERS, []): + listener() + + entry.async_on_unload(entry.async_on_state_change(async_notify_backup_listeners)) + return True async def async_unload_entry(hass: HomeAssistant, entry: OneDriveConfigEntry) -> bool: """Unload a OneDrive config entry.""" - _async_notify_backup_listeners_soon(hass) return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) -def _async_notify_backup_listeners(hass: HomeAssistant) -> None: - for listener in hass.data.get(DATA_BACKUP_AGENT_LISTENERS, []): - listener() - - -@callback -def _async_notify_backup_listeners_soon(hass: HomeAssistant) -> None: - hass.loop.call_soon(_async_notify_backup_listeners, hass) - - async def _migrate_backup_files(client: OneDriveClient, backup_folder_id: str) -> None: """Migrate backup files to metadata version 2.""" files = await client.list_drive_items(backup_folder_id) diff --git a/homeassistant/components/synology_dsm/__init__.py b/homeassistant/components/synology_dsm/__init__.py index 97095f5d299..1b26b7df84d 100644 --- a/homeassistant/components/synology_dsm/__init__.py +++ b/homeassistant/components/synology_dsm/__init__.py @@ -11,7 +11,7 @@ from synology_dsm.exceptions import SynologyDSMNotLoggedInException from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_MAC, CONF_SCAN_INTERVAL, CONF_VERIFY_SSL -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr @@ -131,7 +131,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.async_on_unload(entry.add_update_listener(_async_update_listener)) if entry.options[CONF_BACKUP_SHARE]: - _async_notify_backup_listeners_soon(hass) + + def async_notify_backup_listeners() -> None: + for listener in hass.data.get(DATA_BACKUP_AGENT_LISTENERS, []): + listener() + + entry.async_on_unload( + entry.async_on_state_change(async_notify_backup_listeners) + ) return True @@ -142,20 +149,9 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry_data: SynologyDSMData = hass.data[DOMAIN][entry.unique_id] await entry_data.api.async_unload() hass.data[DOMAIN].pop(entry.unique_id) - _async_notify_backup_listeners_soon(hass) return unload_ok -def _async_notify_backup_listeners(hass: HomeAssistant) -> None: - for listener in hass.data.get(DATA_BACKUP_AGENT_LISTENERS, []): - listener() - - -@callback -def _async_notify_backup_listeners_soon(hass: HomeAssistant) -> None: - hass.loop.call_soon(_async_notify_backup_listeners, hass) - - async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: """Handle options update.""" await hass.config_entries.async_reload(entry.entry_id) From 6ad6e82a2306ff09d19e7acfc614a6df5760d1f7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 23 Feb 2025 12:41:38 -0600 Subject: [PATCH 1711/3148] Bump thermobeacon-ble to 0.8.0 (#139119) --- homeassistant/components/thermobeacon/manifest.json | 8 +++++++- homeassistant/generated/bluetooth.py | 9 +++++++++ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 18 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/thermobeacon/manifest.json b/homeassistant/components/thermobeacon/manifest.json index ce6a3f71ef3..e060cbd91bf 100644 --- a/homeassistant/components/thermobeacon/manifest.json +++ b/homeassistant/components/thermobeacon/manifest.json @@ -14,6 +14,12 @@ "manufacturer_data_start": [0], "connectable": false }, + { + "service_uuid": "0000fff0-0000-1000-8000-00805f9b34fb", + "manufacturer_id": 20, + "manufacturer_data_start": [0], + "connectable": false + }, { "service_uuid": "0000fff0-0000-1000-8000-00805f9b34fb", "manufacturer_id": 21, @@ -48,5 +54,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/thermobeacon", "iot_class": "local_push", - "requirements": ["thermobeacon-ble==0.7.0"] + "requirements": ["thermobeacon-ble==0.8.0"] } diff --git a/homeassistant/generated/bluetooth.py b/homeassistant/generated/bluetooth.py index 447b6d284f0..587fea8b941 100644 --- a/homeassistant/generated/bluetooth.py +++ b/homeassistant/generated/bluetooth.py @@ -688,6 +688,15 @@ BLUETOOTH: Final[list[dict[str, bool | str | int | list[int]]]] = [ "manufacturer_id": 17, "service_uuid": "0000fff0-0000-1000-8000-00805f9b34fb", }, + { + "connectable": False, + "domain": "thermobeacon", + "manufacturer_data_start": [ + 0, + ], + "manufacturer_id": 20, + "service_uuid": "0000fff0-0000-1000-8000-00805f9b34fb", + }, { "connectable": False, "domain": "thermobeacon", diff --git a/requirements_all.txt b/requirements_all.txt index cb03d16903d..04cc0c38d67 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2884,7 +2884,7 @@ tessie-api==0.1.1 # tf-models-official==2.5.0 # homeassistant.components.thermobeacon -thermobeacon-ble==0.7.0 +thermobeacon-ble==0.8.0 # homeassistant.components.thermopro thermopro-ble==0.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index af58c786530..f72da658fb2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2321,7 +2321,7 @@ teslemetry-stream==0.6.10 tessie-api==0.1.1 # homeassistant.components.thermobeacon -thermobeacon-ble==0.7.0 +thermobeacon-ble==0.8.0 # homeassistant.components.thermopro thermopro-ble==0.11.0 From 8f9f9bc8e7ea7cd5f7f233329ac75a4494ed6d96 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Sun, 23 Feb 2025 19:59:10 +0100 Subject: [PATCH 1712/3148] Complete remember the milk typing (#139123) --- .strict-typing | 1 + .../components/remember_the_milk/__init__.py | 20 ++++++++++++++----- .../components/remember_the_milk/entity.py | 18 ++++++++++++----- .../components/remember_the_milk/storage.py | 3 ++- mypy.ini | 10 ++++++++++ 5 files changed, 41 insertions(+), 11 deletions(-) diff --git a/.strict-typing b/.strict-typing index 682e2c920ce..95eb2abb4b4 100644 --- a/.strict-typing +++ b/.strict-typing @@ -407,6 +407,7 @@ homeassistant.components.raspberry_pi.* homeassistant.components.rdw.* homeassistant.components.recollect_waste.* homeassistant.components.recorder.* +homeassistant.components.remember_the_milk.* homeassistant.components.remote.* homeassistant.components.renault.* homeassistant.components.reolink.* diff --git a/homeassistant/components/remember_the_milk/__init__.py b/homeassistant/components/remember_the_milk/__init__.py index fc192bd538a..df9eec0622f 100644 --- a/homeassistant/components/remember_the_milk/__init__.py +++ b/homeassistant/components/remember_the_milk/__init__.py @@ -75,8 +75,14 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool: def _create_instance( - hass, account_name, api_key, shared_secret, token, stored_rtm_config, component -): + hass: HomeAssistant, + account_name: str, + api_key: str, + shared_secret: str, + token: str, + stored_rtm_config: RememberTheMilkConfiguration, + component: EntityComponent[RememberTheMilkEntity], +) -> None: entity = RememberTheMilkEntity( account_name, api_key, shared_secret, token, stored_rtm_config ) @@ -96,9 +102,13 @@ def _create_instance( def _register_new_account( - hass, account_name, api_key, shared_secret, stored_rtm_config, component -): - request_id = None + hass: HomeAssistant, + account_name: str, + api_key: str, + shared_secret: str, + stored_rtm_config: RememberTheMilkConfiguration, + component: EntityComponent[RememberTheMilkEntity], +) -> None: api = Rtm(api_key, shared_secret, "write", None) url, frob = api.authenticate_desktop() LOGGER.debug("Sent authentication request to server") diff --git a/homeassistant/components/remember_the_milk/entity.py b/homeassistant/components/remember_the_milk/entity.py index bf75debe367..be69d16f72f 100644 --- a/homeassistant/components/remember_the_milk/entity.py +++ b/homeassistant/components/remember_the_milk/entity.py @@ -7,12 +7,20 @@ from homeassistant.core import ServiceCall from homeassistant.helpers.entity import Entity from .const import LOGGER +from .storage import RememberTheMilkConfiguration class RememberTheMilkEntity(Entity): """Representation of an interface to Remember The Milk.""" - def __init__(self, name, api_key, shared_secret, token, rtm_config): + def __init__( + self, + name: str, + api_key: str, + shared_secret: str, + token: str, + rtm_config: RememberTheMilkConfiguration, + ) -> None: """Create new instance of Remember The Milk component.""" self._name = name self._api_key = api_key @@ -20,11 +28,11 @@ class RememberTheMilkEntity(Entity): self._token = token self._rtm_config = rtm_config self._rtm_api = Rtm(api_key, shared_secret, "delete", token) - self._token_valid = None + self._token_valid = False self._check_token() LOGGER.debug("Instance created for account %s", self._name) - def _check_token(self): + def _check_token(self) -> bool: """Check if the API token is still valid. If it is not valid any more, delete it from the configuration. This @@ -127,12 +135,12 @@ class RememberTheMilkEntity(Entity): ) @property - def name(self): + def name(self) -> str: """Return the name of the device.""" return self._name @property - def state(self): + def state(self) -> str: """Return the state of the device.""" if not self._token_valid: return "API token invalid" diff --git a/homeassistant/components/remember_the_milk/storage.py b/homeassistant/components/remember_the_milk/storage.py index ae51acd963b..593abb7da2c 100644 --- a/homeassistant/components/remember_the_milk/storage.py +++ b/homeassistant/components/remember_the_milk/storage.py @@ -4,6 +4,7 @@ from __future__ import annotations import json from pathlib import Path +from typing import cast from homeassistant.const import CONF_TOKEN from homeassistant.core import HomeAssistant @@ -51,7 +52,7 @@ class RememberTheMilkConfiguration: def get_token(self, profile_name: str) -> str | None: """Get the server token for a profile.""" if profile_name in self._config: - return self._config[profile_name][CONF_TOKEN] + return cast(str, self._config[profile_name][CONF_TOKEN]) return None def set_token(self, profile_name: str, token: str) -> None: diff --git a/mypy.ini b/mypy.ini index 4c062c99aec..a04242dc66d 100644 --- a/mypy.ini +++ b/mypy.ini @@ -3826,6 +3826,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.remember_the_milk.*] +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.remote.*] check_untyped_defs = true disallow_incomplete_defs = true From d62c18c225b1d9eb752d50c1c000a83ad7dc689d Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Sun, 23 Feb 2025 20:06:28 +0100 Subject: [PATCH 1713/3148] Fix flakey onedrive tests (#139129) --- tests/components/onedrive/conftest.py | 68 +++++++++++++++++++----- tests/components/onedrive/const.py | 48 +---------------- tests/components/onedrive/test_backup.py | 7 ++- tests/components/onedrive/test_init.py | 7 +-- 4 files changed, 65 insertions(+), 65 deletions(-) diff --git a/tests/components/onedrive/conftest.py b/tests/components/onedrive/conftest.py index 8ff650012f9..74232f2cc39 100644 --- a/tests/components/onedrive/conftest.py +++ b/tests/components/onedrive/conftest.py @@ -1,6 +1,7 @@ """Fixtures for OneDrive tests.""" from collections.abc import AsyncIterator, Generator +from html import escape from json import dumps import time from unittest.mock import AsyncMock, MagicMock, patch @@ -10,7 +11,9 @@ from onedrive_personal_sdk.models.items import ( AppRoot, Drive, DriveQuota, + File, Folder, + Hashes, IdentitySet, ItemParentReference, User, @@ -30,15 +33,7 @@ from homeassistant.components.onedrive.const import ( from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from .const import ( - BACKUP_METADATA, - CLIENT_ID, - CLIENT_SECRET, - IDENTITY_SET, - INSTANCE_ID, - MOCK_BACKUP_FILE, - MOCK_METADATA_FILE, -) +from .const import BACKUP_METADATA, CLIENT_ID, CLIENT_SECRET, IDENTITY_SET, INSTANCE_ID from tests.common import MockConfigEntry @@ -165,20 +160,67 @@ def mock_folder() -> Folder: ) +@pytest.fixture +def mock_backup_file() -> File: + """Return a mocked backup file.""" + return File( + id="id", + name="23e64aec.tar", + size=34519040, + parent_reference=ItemParentReference( + drive_id="mock_drive_id", id="id", path="path" + ), + hashes=Hashes( + quick_xor_hash="hash", + ), + mime_type="application/x-tar", + created_by=IDENTITY_SET, + ) + + +@pytest.fixture +def mock_metadata_file() -> File: + """Return a mocked metadata file.""" + return File( + id="id", + name="23e64aec.tar", + size=34519040, + parent_reference=ItemParentReference( + drive_id="mock_drive_id", id="id", path="path" + ), + hashes=Hashes( + quick_xor_hash="hash", + ), + mime_type="application/x-tar", + description=escape( + dumps( + { + "metadata_version": 2, + "backup_id": "23e64aec", + "backup_file_id": "id", + } + ) + ), + created_by=IDENTITY_SET, + ) + + @pytest.fixture(autouse=True) def mock_onedrive_client( mock_onedrive_client_init: MagicMock, mock_approot: AppRoot, mock_drive: Drive, mock_folder: Folder, + mock_backup_file: File, + mock_metadata_file: File, ) -> Generator[MagicMock]: """Return a mocked GraphServiceClient.""" client = mock_onedrive_client_init.return_value client.get_approot.return_value = mock_approot client.create_folder.return_value = mock_folder - client.list_drive_items.return_value = [MOCK_BACKUP_FILE, MOCK_METADATA_FILE] + client.list_drive_items.return_value = [mock_backup_file, mock_metadata_file] client.get_drive_item.return_value = mock_folder - client.upload_file.return_value = MOCK_METADATA_FILE + client.upload_file.return_value = mock_metadata_file class MockStreamReader: async def iter_chunked(self, chunk_size: int) -> AsyncIterator[bytes]: @@ -193,12 +235,12 @@ def mock_onedrive_client( @pytest.fixture -def mock_large_file_upload_client() -> Generator[AsyncMock]: +def mock_large_file_upload_client(mock_backup_file: File) -> Generator[AsyncMock]: """Return a mocked LargeFileUploadClient upload.""" with patch( "homeassistant.components.onedrive.backup.LargeFileUploadClient.upload" ) as mock_upload: - mock_upload.return_value = MOCK_BACKUP_FILE + mock_upload.return_value = mock_backup_file yield mock_upload diff --git a/tests/components/onedrive/const.py b/tests/components/onedrive/const.py index 6e91a7ef0ea..4e67c358179 100644 --- a/tests/components/onedrive/const.py +++ b/tests/components/onedrive/const.py @@ -1,15 +1,6 @@ """Consts for OneDrive tests.""" -from html import escape -from json import dumps - -from onedrive_personal_sdk.models.items import ( - File, - Hashes, - IdentitySet, - ItemParentReference, - User, -) +from onedrive_personal_sdk.models.items import IdentitySet, User CLIENT_ID = "1234" CLIENT_SECRET = "5678" @@ -38,40 +29,3 @@ IDENTITY_SET = IdentitySet( email="john@doe.com", ) ) - -MOCK_BACKUP_FILE = File( - id="id", - name="23e64aec.tar", - size=34519040, - parent_reference=ItemParentReference( - drive_id="mock_drive_id", id="id", path="path" - ), - hashes=Hashes( - quick_xor_hash="hash", - ), - mime_type="application/x-tar", - created_by=IDENTITY_SET, -) - -MOCK_METADATA_FILE = File( - id="id", - name="23e64aec.tar", - size=34519040, - parent_reference=ItemParentReference( - drive_id="mock_drive_id", id="id", path="path" - ), - hashes=Hashes( - quick_xor_hash="hash", - ), - mime_type="application/x-tar", - description=escape( - dumps( - { - "metadata_version": 2, - "backup_id": "23e64aec", - "backup_file_id": "id", - } - ) - ), - created_by=IDENTITY_SET, -) diff --git a/tests/components/onedrive/test_backup.py b/tests/components/onedrive/test_backup.py index 41ecbdb240f..c307e5190c1 100644 --- a/tests/components/onedrive/test_backup.py +++ b/tests/components/onedrive/test_backup.py @@ -11,6 +11,7 @@ from onedrive_personal_sdk.exceptions import ( HashMismatchError, OneDriveException, ) +from onedrive_personal_sdk.models.items import File import pytest from homeassistant.components.backup import DOMAIN as BACKUP_DOMAIN, AgentBackup @@ -23,7 +24,7 @@ from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from . import setup_integration -from .const import BACKUP_METADATA, MOCK_BACKUP_FILE, MOCK_METADATA_FILE +from .const import BACKUP_METADATA from tests.common import AsyncMock, MockConfigEntry from tests.typing import ClientSessionGenerator, MagicMock, WebSocketGenerator @@ -248,12 +249,14 @@ async def test_error_on_agents_download( hass_client: ClientSessionGenerator, mock_onedrive_client: MagicMock, mock_config_entry: MockConfigEntry, + mock_backup_file: File, + mock_metadata_file: File, ) -> None: """Test we get not found on an not existing backup on download.""" client = await hass_client() backup_id = BACKUP_METADATA["backup_id"] mock_onedrive_client.list_drive_items.side_effect = [ - [MOCK_BACKUP_FILE, MOCK_METADATA_FILE], + [mock_backup_file, mock_metadata_file], [], ] diff --git a/tests/components/onedrive/test_init.py b/tests/components/onedrive/test_init.py index 41c1966a4ae..c7765e0a7f8 100644 --- a/tests/components/onedrive/test_init.py +++ b/tests/components/onedrive/test_init.py @@ -11,7 +11,7 @@ from onedrive_personal_sdk.exceptions import ( NotFoundError, OneDriveException, ) -from onedrive_personal_sdk.models.items import AppRoot, Drive, Folder, ItemUpdate +from onedrive_personal_sdk.models.items import AppRoot, Drive, File, Folder, ItemUpdate import pytest from syrupy import SnapshotAssertion @@ -25,7 +25,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, issue_registry as ir from . import setup_integration -from .const import BACKUP_METADATA, INSTANCE_ID, MOCK_BACKUP_FILE +from .const import BACKUP_METADATA, INSTANCE_ID from tests.common import MockConfigEntry @@ -145,9 +145,10 @@ async def test_migrate_metadata_files( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_onedrive_client: MagicMock, + mock_backup_file: File, ) -> None: """Test migration of metadata files.""" - MOCK_BACKUP_FILE.description = escape( + mock_backup_file.description = escape( dumps({**BACKUP_METADATA, "metadata_version": 1}) ) await setup_integration(hass, mock_config_entry) From 580c6f26840778669981027664e059a53d05f406 Mon Sep 17 00:00:00 2001 From: SLaks Date: Sun, 23 Feb 2025 19:11:38 -0500 Subject: [PATCH 1714/3148] Allow arbitrary Gemini attachments (#138751) * Gemini: Allow arbitrary attachments This lets me use Gemini to extract information from PDFs, HTML, or other files. * Gemini: Only add deprecation warning when deprecated parameter has a value * Gemini: Use Files.upload() for both images and other files This simplifies the code. Within the Google client, this takes a different codepath (it uploads images as a file instead of re-saving them into inline bytes). I think that's a feature (it's probably more efficient?). * Gemini: Deduplicate filenames --- .../__init__.py | 55 ++++++++++++------- .../services.yaml | 5 ++ .../strings.json | 13 ++++- .../snapshots/test_init.ambr | 3 +- .../test_init.py | 33 ++--------- 5 files changed, 59 insertions(+), 50 deletions(-) diff --git a/homeassistant/components/google_generative_ai_conversation/__init__.py b/homeassistant/components/google_generative_ai_conversation/__init__.py index e9ab5cbdd3e..33e361d1433 100644 --- a/homeassistant/components/google_generative_ai_conversation/__init__.py +++ b/homeassistant/components/google_generative_ai_conversation/__init__.py @@ -2,12 +2,10 @@ from __future__ import annotations -import mimetypes from pathlib import Path from google import genai # type: ignore[attr-defined] from google.genai.errors import APIError, ClientError -from PIL import Image from requests.exceptions import Timeout import voluptuous as vol @@ -26,6 +24,7 @@ from homeassistant.exceptions import ( HomeAssistantError, ) from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType from .const import ( @@ -38,6 +37,7 @@ from .const import ( SERVICE_GENERATE_CONTENT = "generate_content" CONF_IMAGE_FILENAME = "image_filename" +CONF_FILENAMES = "filenames" CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) PLATFORMS = (Platform.CONVERSATION,) @@ -50,31 +50,43 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def generate_content(call: ServiceCall) -> ServiceResponse: """Generate content from text and optionally images.""" + + if call.data[CONF_IMAGE_FILENAME]: + # Deprecated in 2025.3, to remove in 2025.9 + async_create_issue( + hass, + DOMAIN, + "deprecated_image_filename_parameter", + breaks_in_ha_version="2025.9.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_image_filename_parameter", + ) + prompt_parts = [call.data[CONF_PROMPT]] - def append_images_to_prompt(): - image_filenames = call.data[CONF_IMAGE_FILENAME] - for image_filename in image_filenames: - if not hass.config.is_allowed_path(image_filename): - raise HomeAssistantError( - f"Cannot read `{image_filename}`, no access to path; " - "`allowlist_external_dirs` may need to be adjusted in " - "`configuration.yaml`" - ) - if not Path(image_filename).exists(): - raise HomeAssistantError(f"`{image_filename}` does not exist") - mime_type, _ = mimetypes.guess_type(image_filename) - if mime_type is None or not mime_type.startswith("image"): - raise HomeAssistantError(f"`{image_filename}` is not an image") - prompt_parts.append(Image.open(image_filename)) - - await hass.async_add_executor_job(append_images_to_prompt) - config_entry: GoogleGenerativeAIConfigEntry = hass.config_entries.async_entries( DOMAIN )[0] + client = config_entry.runtime_data + def append_files_to_prompt(): + image_filenames = call.data[CONF_IMAGE_FILENAME] + filenames = call.data[CONF_FILENAMES] + for filename in set(image_filenames + filenames): + if not hass.config.is_allowed_path(filename): + raise HomeAssistantError( + f"Cannot read `{filename}`, no access to path; " + "`allowlist_external_dirs` may need to be adjusted in " + "`configuration.yaml`" + ) + if not Path(filename).exists(): + raise HomeAssistantError(f"`{filename}` does not exist") + prompt_parts.append(client.files.upload(file=filename)) + + await hass.async_add_executor_job(append_files_to_prompt) + try: response = await client.aio.models.generate_content( model=RECOMMENDED_CHAT_MODEL, contents=prompt_parts @@ -105,6 +117,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: vol.Optional(CONF_IMAGE_FILENAME, default=[]): vol.All( cv.ensure_list, [cv.string] ), + vol.Optional(CONF_FILENAMES, default=[]): vol.All( + cv.ensure_list, [cv.string] + ), } ), supports_response=SupportsResponse.ONLY, diff --git a/homeassistant/components/google_generative_ai_conversation/services.yaml b/homeassistant/components/google_generative_ai_conversation/services.yaml index f35697b89f8..82190d64540 100644 --- a/homeassistant/components/google_generative_ai_conversation/services.yaml +++ b/homeassistant/components/google_generative_ai_conversation/services.yaml @@ -9,3 +9,8 @@ generate_content: required: false selector: object: + filenames: + required: false + selector: + text: + multiple: true diff --git a/homeassistant/components/google_generative_ai_conversation/strings.json b/homeassistant/components/google_generative_ai_conversation/strings.json index 9fea4805d38..772fadb089c 100644 --- a/homeassistant/components/google_generative_ai_conversation/strings.json +++ b/homeassistant/components/google_generative_ai_conversation/strings.json @@ -56,10 +56,21 @@ }, "image_filename": { "name": "Image filename", - "description": "Images", + "description": "Deprecated. Use filenames instead.", + "example": "/config/www/image.jpg" + }, + "filenames": { + "name": "Attachment filenames", + "description": "Attachments to add to the prompt (images, PDFs, etc)", "example": "/config/www/image.jpg" } } } + }, + "issues": { + "deprecated_image_filename_parameter": { + "title": "Deprecated 'image_filename' parameter", + "description": "The 'image_filename' parameter in Google Generative AI actions is deprecated. Please edit scripts and automations to use 'filenames' intead." + } } } diff --git a/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr b/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr index e2d93611ea6..8e6231cbffd 100644 --- a/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr +++ b/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr @@ -8,7 +8,8 @@ dict({ 'contents': list([ 'Describe this image from my doorbell camera', - b'image bytes', + b'some file', + b'some file', ]), 'model': 'models/gemini-2.0-flash', }), diff --git a/tests/components/google_generative_ai_conversation/test_init.py b/tests/components/google_generative_ai_conversation/test_init.py index f2e3ac10733..0dad485812e 100644 --- a/tests/components/google_generative_ai_conversation/test_init.py +++ b/tests/components/google_generative_ai_conversation/test_init.py @@ -66,8 +66,8 @@ async def test_generate_content_service_with_image( ), ) as mock_generate, patch( - "homeassistant.components.google_generative_ai_conversation.Image.open", - return_value=b"image bytes", + "google.genai.files.Files.upload", + return_value=b"some file", ), patch("pathlib.Path.exists", return_value=True), patch.object(hass.config, "is_allowed_path", return_value=True), @@ -77,7 +77,7 @@ async def test_generate_content_service_with_image( "generate_content", { "prompt": "Describe this image from my doorbell camera", - "image_filename": "doorbell_snapshot.jpg", + "filenames": ["doorbell_snapshot.jpg", "context.txt", "context.txt"], }, blocking=True, return_response=True, @@ -161,7 +161,7 @@ async def test_generate_content_service_with_image_not_allowed_path( "generate_content", { "prompt": "Describe this image from my doorbell camera", - "image_filename": "doorbell_snapshot.jpg", + "filenames": "doorbell_snapshot.jpg", }, blocking=True, return_response=True, @@ -186,30 +186,7 @@ async def test_generate_content_service_with_image_not_exists( "generate_content", { "prompt": "Describe this image from my doorbell camera", - "image_filename": "doorbell_snapshot.jpg", - }, - blocking=True, - return_response=True, - ) - - -@pytest.mark.usefixtures("mock_init_component") -async def test_generate_content_service_with_non_image(hass: HomeAssistant) -> None: - """Test generate content service with a non image.""" - with ( - patch("pathlib.Path.exists", return_value=True), - patch.object(hass.config, "is_allowed_path", return_value=True), - patch("pathlib.Path.exists", return_value=True), - pytest.raises( - HomeAssistantError, match="`doorbell_snapshot.mp4` is not an image" - ), - ): - await hass.services.async_call( - "google_generative_ai_conversation", - "generate_content", - { - "prompt": "Describe this image from my doorbell camera", - "image_filename": "doorbell_snapshot.mp4", + "filenames": "doorbell_snapshot.jpg", }, blocking=True, return_response=True, From db5bf417904a77fa2be75e555fac639400599b70 Mon Sep 17 00:00:00 2001 From: Pete Sage <76050312+PeteRager@users.noreply.github.com> Date: Sun, 23 Feb 2025 22:37:25 -0500 Subject: [PATCH 1715/3148] bump soco to 0.30.9 (#139143) --- 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 bb3d99c4c93..5bbfc33ae5b 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", "sonos_websocket"], - "requirements": ["soco==0.30.8", "sonos-websocket==0.1.3"], + "requirements": ["soco==0.30.9", "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 04cc0c38d67..179f82d04c1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2754,7 +2754,7 @@ smart-meter-texas==0.5.5 snapcast==2.3.6 # homeassistant.components.sonos -soco==0.30.8 +soco==0.30.9 # homeassistant.components.solaredge_local solaredge-local==0.2.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f72da658fb2..2b15ecf055d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2221,7 +2221,7 @@ smart-meter-texas==0.5.5 snapcast==2.3.6 # homeassistant.components.sonos -soco==0.30.8 +soco==0.30.9 # homeassistant.components.solarlog solarlog_cli==0.4.0 From ea1045d826f7ed317ec578e6063bc67fcf20aa99 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 24 Feb 2025 08:42:15 +0100 Subject: [PATCH 1716/3148] Bump github/codeql-action from 3.28.9 to 3.28.10 (#139162) --- .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 a4469cde0d8..4bdddf50c25 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -24,11 +24,11 @@ jobs: uses: actions/checkout@v4.2.2 - name: Initialize CodeQL - uses: github/codeql-action/init@v3.28.9 + uses: github/codeql-action/init@v3.28.10 with: languages: python - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3.28.9 + uses: github/codeql-action/analyze@v3.28.10 with: category: "/language:python" From 8c4b8028cf515adbf005691fdf7eba46a1686181 Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Mon, 24 Feb 2025 09:52:53 +0200 Subject: [PATCH 1717/3148] Bump aiowebostv to 0.7.0 (#139145) --- .../components/webostv/config_flow.py | 8 +- .../components/webostv/diagnostics.py | 18 ++--- homeassistant/components/webostv/helpers.py | 8 +- .../components/webostv/manifest.json | 2 +- .../components/webostv/media_player.py | 67 ++++++++-------- homeassistant/components/webostv/notify.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/webostv/conftest.py | 33 ++++---- tests/components/webostv/test_config_flow.py | 6 +- tests/components/webostv/test_media_player.py | 76 +++++++++---------- tests/components/webostv/test_notify.py | 2 +- 12 files changed, 117 insertions(+), 109 deletions(-) diff --git a/homeassistant/components/webostv/config_flow.py b/homeassistant/components/webostv/config_flow.py index fbc3eb958dd..80c8fb7f8f2 100644 --- a/homeassistant/components/webostv/config_flow.py +++ b/homeassistant/components/webostv/config_flow.py @@ -92,13 +92,13 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): errors["base"] = "cannot_connect" else: await self.async_set_unique_id( - client.hello_info["deviceUUID"], raise_on_progress=False + client.tv_info.hello["deviceUUID"], raise_on_progress=False ) self._abort_if_unique_id_configured({CONF_HOST: self._host}) data = {CONF_HOST: self._host, CONF_CLIENT_SECRET: client.client_key} if not self._name: - self._name = f"{DEFAULT_NAME} {client.system_info['modelName']}" + self._name = f"{DEFAULT_NAME} {client.tv_info.system['modelName']}" return self.async_create_entry(title=self._name, data=data) return self.async_show_form(step_id="pairing", errors=errors) @@ -176,7 +176,7 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): except WEBOSTV_EXCEPTIONS: errors["base"] = "cannot_connect" else: - await self.async_set_unique_id(client.hello_info["deviceUUID"]) + await self.async_set_unique_id(client.tv_info.hello["deviceUUID"]) self._abort_if_unique_id_mismatch(reason="wrong_device") data = {CONF_HOST: host, CONF_CLIENT_SECRET: client.client_key} return self.async_update_reload_and_abort(reconfigure_entry, data=data) @@ -214,7 +214,7 @@ class OptionsFlowHandler(OptionsFlow): sources_list = [] try: client = await async_control_connect(self.hass, self.host, self.key) - sources_list = get_sources(client) + sources_list = get_sources(client.tv_state) except WebOsTvPairError: errors["base"] = "error_pairing" except WEBOSTV_EXCEPTIONS: diff --git a/homeassistant/components/webostv/diagnostics.py b/homeassistant/components/webostv/diagnostics.py index 7fb64a2cb8f..393a6a066ff 100644 --- a/homeassistant/components/webostv/diagnostics.py +++ b/homeassistant/components/webostv/diagnostics.py @@ -32,15 +32,15 @@ async def async_get_config_entry_diagnostics( client_data = { "is_registered": client.is_registered(), "is_connected": client.is_connected(), - "current_app_id": client.current_app_id, - "current_channel": client.current_channel, - "apps": client.apps, - "inputs": client.inputs, - "system_info": client.system_info, - "software_info": client.software_info, - "hello_info": client.hello_info, - "sound_output": client.sound_output, - "is_on": client.is_on, + "current_app_id": client.tv_state.current_app_id, + "current_channel": client.tv_state.current_channel, + "apps": client.tv_state.apps, + "inputs": client.tv_state.inputs, + "system_info": client.tv_info.system, + "software_info": client.tv_info.software, + "hello_info": client.tv_info.hello, + "sound_output": client.tv_state.sound_output, + "is_on": client.tv_state.is_on, } return async_redact_data( diff --git a/homeassistant/components/webostv/helpers.py b/homeassistant/components/webostv/helpers.py index 3c509a56d1e..f70f250f91d 100644 --- a/homeassistant/components/webostv/helpers.py +++ b/homeassistant/components/webostv/helpers.py @@ -4,7 +4,7 @@ from __future__ import annotations import logging -from aiowebostv import WebOsClient +from aiowebostv import WebOsClient, WebOsTvState from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import CONF_CLIENT_SECRET, CONF_HOST @@ -83,16 +83,16 @@ def async_get_client_by_device_entry( ) -def get_sources(client: WebOsClient) -> list[str]: +def get_sources(tv_state: WebOsTvState) -> list[str]: """Construct sources list.""" sources = [] found_live_tv = False - for app in client.apps.values(): + for app in tv_state.apps.values(): sources.append(app["title"]) if app["id"] == LIVE_TV_APP_ID: found_live_tv = True - for source in client.inputs.values(): + for source in tv_state.inputs.values(): sources.append(source["label"]) if source["appId"] == LIVE_TV_APP_ID: found_live_tv = True diff --git a/homeassistant/components/webostv/manifest.json b/homeassistant/components/webostv/manifest.json index 5fbcf759ee3..45c9628539c 100644 --- a/homeassistant/components/webostv/manifest.json +++ b/homeassistant/components/webostv/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/webostv", "iot_class": "local_push", "loggers": ["aiowebostv"], - "requirements": ["aiowebostv==0.6.2"], + "requirements": ["aiowebostv==0.7.0"], "ssdp": [ { "st": "urn:lge-com:service:webos-second-screen:1" diff --git a/homeassistant/components/webostv/media_player.py b/homeassistant/components/webostv/media_player.py index 33c09aa8708..780e9f418a5 100644 --- a/homeassistant/components/webostv/media_player.py +++ b/homeassistant/components/webostv/media_player.py @@ -11,7 +11,7 @@ from http import HTTPStatus import logging from typing import Any, Concatenate, cast -from aiowebostv import WebOsClient, WebOsTvPairError +from aiowebostv import WebOsTvPairError, WebOsTvState import voluptuous as vol from homeassistant import util @@ -205,51 +205,52 @@ class LgWebOSMediaPlayerEntity(RestoreEntity, MediaPlayerEntity): """Call disconnect on removal.""" self._client.unregister_state_update_callback(self.async_handle_state_update) - async def async_handle_state_update(self, _client: WebOsClient) -> None: + async def async_handle_state_update(self, tv_state: WebOsTvState) -> None: """Update state from WebOsClient.""" self._update_states() self.async_write_ha_state() def _update_states(self) -> None: """Update entity state attributes.""" + tv_state = self._client.tv_state self._update_sources() self._attr_state = ( - MediaPlayerState.ON if self._client.is_on else MediaPlayerState.OFF + MediaPlayerState.ON if tv_state.is_on else MediaPlayerState.OFF ) - self._attr_is_volume_muted = cast(bool, self._client.muted) + self._attr_is_volume_muted = cast(bool, tv_state.muted) self._attr_volume_level = None - if self._client.volume is not None: - self._attr_volume_level = self._client.volume / 100.0 + if tv_state.volume is not None: + self._attr_volume_level = tv_state.volume / 100.0 self._attr_source = self._current_source self._attr_source_list = sorted(self._source_list) self._attr_media_content_type = None - if self._client.current_app_id == LIVE_TV_APP_ID: + if tv_state.current_app_id == LIVE_TV_APP_ID: self._attr_media_content_type = MediaType.CHANNEL self._attr_media_title = None - if (self._client.current_app_id == LIVE_TV_APP_ID) and ( - self._client.current_channel is not None + if (tv_state.current_app_id == LIVE_TV_APP_ID) and ( + tv_state.current_channel is not None ): self._attr_media_title = cast( - str, self._client.current_channel.get("channelName") + str, tv_state.current_channel.get("channelName") ) self._attr_media_image_url = None - if self._client.current_app_id in self._client.apps: - icon: str = self._client.apps[self._client.current_app_id]["largeIcon"] + if tv_state.current_app_id in tv_state.apps: + icon: str = tv_state.apps[tv_state.current_app_id]["largeIcon"] if not icon.startswith("http"): - icon = self._client.apps[self._client.current_app_id]["icon"] + icon = tv_state.apps[tv_state.current_app_id]["icon"] self._attr_media_image_url = icon if self.state != MediaPlayerState.OFF or not self._supported_features: supported = SUPPORT_WEBOSTV - if self._client.sound_output == "external_speaker": + if tv_state.sound_output == "external_speaker": supported = supported | SUPPORT_WEBOSTV_VOLUME - elif self._client.sound_output != "lineout": + elif tv_state.sound_output != "lineout": supported = ( supported | SUPPORT_WEBOSTV_VOLUME @@ -265,9 +266,9 @@ class LgWebOSMediaPlayerEntity(RestoreEntity, MediaPlayerEntity): ) self._attr_assumed_state = True - if self._client.is_on and self._client.media_state: + if tv_state.is_on and tv_state.media_state: self._attr_assumed_state = False - for entry in self._client.media_state: + for entry in tv_state.media_state: if entry.get("playState") == "playing": self._attr_state = MediaPlayerState.PLAYING elif entry.get("playState") == "paused": @@ -275,35 +276,37 @@ class LgWebOSMediaPlayerEntity(RestoreEntity, MediaPlayerEntity): elif entry.get("playState") == "unloaded": self._attr_state = MediaPlayerState.IDLE + tv_info = self._client.tv_info if self.state != MediaPlayerState.OFF: - maj_v = self._client.software_info.get("major_ver") - min_v = self._client.software_info.get("minor_ver") + maj_v = tv_info.software.get("major_ver") + min_v = tv_info.software.get("minor_ver") if maj_v and min_v: self._attr_device_info["sw_version"] = f"{maj_v}.{min_v}" - if model := self._client.system_info.get("modelName"): + if model := tv_info.system.get("modelName"): self._attr_device_info["model"] = model - if serial_number := self._client.system_info.get("serialNumber"): + if serial_number := tv_info.system.get("serialNumber"): self._attr_device_info["serial_number"] = serial_number self._attr_extra_state_attributes = {} - if self._client.sound_output is not None or self.state != MediaPlayerState.OFF: + if tv_state.sound_output is not None or self.state != MediaPlayerState.OFF: self._attr_extra_state_attributes = { - ATTR_SOUND_OUTPUT: self._client.sound_output + ATTR_SOUND_OUTPUT: tv_state.sound_output } def _update_sources(self) -> None: """Update list of sources from current source, apps, inputs and configured list.""" + tv_state = self._client.tv_state source_list = self._source_list self._source_list = {} conf_sources = self._sources found_live_tv = False - for app in self._client.apps.values(): + for app in tv_state.apps.values(): if app["id"] == LIVE_TV_APP_ID: found_live_tv = True - if app["id"] == self._client.current_app_id: + if app["id"] == tv_state.current_app_id: self._current_source = app["title"] self._source_list[app["title"]] = app elif ( @@ -314,10 +317,10 @@ class LgWebOSMediaPlayerEntity(RestoreEntity, MediaPlayerEntity): ): self._source_list[app["title"]] = app - for source in self._client.inputs.values(): + for source in tv_state.inputs.values(): if source["appId"] == LIVE_TV_APP_ID: found_live_tv = True - if source["appId"] == self._client.current_app_id: + if source["appId"] == tv_state.current_app_id: self._current_source = source["label"] self._source_list[source["label"]] = source elif ( @@ -334,7 +337,7 @@ class LgWebOSMediaPlayerEntity(RestoreEntity, MediaPlayerEntity): # not appear in the app or input lists in some cases elif not found_live_tv: app = {"id": LIVE_TV_APP_ID, "title": "Live TV"} - if self._client.current_app_id == LIVE_TV_APP_ID: + if tv_state.current_app_id == LIVE_TV_APP_ID: self._current_source = app["title"] self._source_list["Live TV"] = app elif ( @@ -434,12 +437,12 @@ class LgWebOSMediaPlayerEntity(RestoreEntity, MediaPlayerEntity): """Play a piece of media.""" _LOGGER.debug("Call play media type <%s>, Id <%s>", media_type, media_id) - if media_type == MediaType.CHANNEL and self._client.channels: + if media_type == MediaType.CHANNEL and self._client.tv_state.channels: _LOGGER.debug("Searching channel") partial_match_channel_id = None perfect_match_channel_id = None - for channel in self._client.channels: + for channel in self._client.tv_state.channels: if media_id == channel["channelNumber"]: perfect_match_channel_id = channel["channelId"] continue @@ -484,7 +487,7 @@ class LgWebOSMediaPlayerEntity(RestoreEntity, MediaPlayerEntity): @cmd async def async_media_next_track(self) -> None: """Send next track command.""" - if self._client.current_app_id == LIVE_TV_APP_ID: + if self._client.tv_state.current_app_id == LIVE_TV_APP_ID: await self._client.channel_up() else: await self._client.fast_forward() @@ -492,7 +495,7 @@ class LgWebOSMediaPlayerEntity(RestoreEntity, MediaPlayerEntity): @cmd async def async_media_previous_track(self) -> None: """Send the previous track command.""" - if self._client.current_app_id == LIVE_TV_APP_ID: + if self._client.tv_state.current_app_id == LIVE_TV_APP_ID: await self._client.channel_down() else: await self._client.rewind() diff --git a/homeassistant/components/webostv/notify.py b/homeassistant/components/webostv/notify.py index 2393cb4cd07..3966cea5e92 100644 --- a/homeassistant/components/webostv/notify.py +++ b/homeassistant/components/webostv/notify.py @@ -49,7 +49,7 @@ class LgWebOSNotificationService(BaseNotificationService): data = kwargs[ATTR_DATA] icon_path = data.get(ATTR_ICON) if data else None - if not client.is_on: + if not client.tv_state.is_on: raise HomeAssistantError( translation_domain=DOMAIN, translation_key="notify_device_off", diff --git a/requirements_all.txt b/requirements_all.txt index 179f82d04c1..7c9d90ad8df 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -422,7 +422,7 @@ aiowaqi==3.1.0 aiowatttime==0.1.1 # homeassistant.components.webostv -aiowebostv==0.6.2 +aiowebostv==0.7.0 # homeassistant.components.withings aiowithings==3.1.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2b15ecf055d..b9a7579d7f1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -404,7 +404,7 @@ aiowaqi==3.1.0 aiowatttime==0.1.1 # homeassistant.components.webostv -aiowebostv==0.6.2 +aiowebostv==0.7.0 # homeassistant.components.withings aiowithings==3.1.5 diff --git a/tests/components/webostv/conftest.py b/tests/components/webostv/conftest.py index c6594746cc5..7fbd8d667e2 100644 --- a/tests/components/webostv/conftest.py +++ b/tests/components/webostv/conftest.py @@ -3,6 +3,7 @@ from collections.abc import Generator from unittest.mock import AsyncMock, Mock, patch +from aiowebostv import WebOsTvInfo, WebOsTvState import pytest from homeassistant.components.webostv.const import LIVE_TV_APP_ID @@ -40,26 +41,30 @@ def client_fixture(): ), ): client = mock_client_class.return_value - client.hello_info = {"deviceUUID": FAKE_UUID} - client.software_info = {"major_ver": "major", "minor_ver": "minor"} - client.system_info = {"modelName": TV_MODEL, "serialNumber": "1234567890"} + client.tv_info = WebOsTvInfo( + hello={"deviceUUID": FAKE_UUID}, + system={"modelName": TV_MODEL, "serialNumber": "1234567890"}, + software={"major_ver": "major", "minor_ver": "minor"}, + ) client.client_key = CLIENT_KEY - client.apps = MOCK_APPS - client.inputs = MOCK_INPUTS - client.current_app_id = LIVE_TV_APP_ID + client.tv_state = WebOsTvState( + apps=MOCK_APPS, + inputs=MOCK_INPUTS, + current_app_id=LIVE_TV_APP_ID, + channels=[CHANNEL_1, CHANNEL_2], + current_channel=CHANNEL_1, + volume=37, + sound_output="speaker", + muted=False, + is_on=True, + media_state=[{"playState": ""}], + ) - client.channels = [CHANNEL_1, CHANNEL_2] - client.current_channel = CHANNEL_1 - - client.volume = 37 - client.sound_output = "speaker" - client.muted = False - client.is_on = True client.is_registered = Mock(return_value=True) client.is_connected = Mock(return_value=True) async def mock_state_update_callback(): - await client.register_state_update_callback.call_args[0][0](client) + await client.register_state_update_callback.call_args[0][0](client.tv_state) client.mock_state_update = AsyncMock(side_effect=mock_state_update_callback) diff --git a/tests/components/webostv/test_config_flow.py b/tests/components/webostv/test_config_flow.py index 34ab39618d8..564ff9afa9b 100644 --- a/tests/components/webostv/test_config_flow.py +++ b/tests/components/webostv/test_config_flow.py @@ -84,8 +84,8 @@ async def test_options_flow_live_tv_in_apps( hass: HomeAssistant, client, apps, inputs ) -> None: """Test options config flow Live TV found in apps.""" - client.apps = apps - client.inputs = inputs + client.tv_state.apps = apps + client.tv_state.inputs = inputs entry = await setup_webostv(hass) result = await hass.config_entries.options.async_init(entry.entry_id) @@ -411,7 +411,7 @@ async def test_reconfigure_wrong_device(hass: HomeAssistant, client) -> None: assert result["type"] is FlowResultType.FORM assert result["step_id"] == "reconfigure" - client.hello_info = {"deviceUUID": "wrong_uuid"} + client.tv_info.hello = {"deviceUUID": "wrong_uuid"} result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={CONF_HOST: "new_host"}, diff --git a/tests/components/webostv/test_media_player.py b/tests/components/webostv/test_media_player.py index 679092efe3b..59e3fc68cf7 100644 --- a/tests/components/webostv/test_media_player.py +++ b/tests/components/webostv/test_media_player.py @@ -156,7 +156,7 @@ async def test_media_next_previous_track( getattr(client, client_call[1]).assert_called_once() # check next/previous for not Live TV channels - client.current_app_id = "in1" + client.tv_state.current_app_id = "in1" data = {ATTR_ENTITY_ID: ENTITY_ID} await hass.services.async_call(MP_DOMAIN, service, data, True) @@ -303,8 +303,8 @@ async def test_device_info_startup_off( hass: HomeAssistant, client, device_registry: dr.DeviceRegistry ) -> None: """Test device info when device is off at startup.""" - client.system_info = None - client.is_on = False + client.tv_info.system = {} + client.tv_state.is_on = False entry = await setup_webostv(hass) await client.mock_state_update() @@ -335,14 +335,14 @@ async def test_entity_attributes( assert state == snapshot(exclude=props("entity_picture")) # Volume level not available - client.volume = None + client.tv_state.volume = None await client.mock_state_update() attrs = hass.states.get(ENTITY_ID).attributes assert attrs.get(ATTR_MEDIA_VOLUME_LEVEL) is None # Channel change - client.current_channel = CHANNEL_2 + client.tv_state.current_channel = CHANNEL_2 await client.mock_state_update() attrs = hass.states.get(ENTITY_ID).attributes @@ -353,8 +353,8 @@ async def test_entity_attributes( assert device == snapshot # Sound output when off - client.sound_output = None - client.is_on = False + client.tv_state.sound_output = None + client.tv_state.is_on = False await client.mock_state_update() state = hass.states.get(ENTITY_ID) @@ -410,13 +410,13 @@ async def test_update_sources_live_tv_find(hass: HomeAssistant, client) -> None: assert len(sources) == 3 # Live TV is current app - client.apps = { + client.tv_state.apps = { LIVE_TV_APP_ID: { "title": "Live TV", "id": "some_id", }, } - client.current_app_id = "some_id" + client.tv_state.current_app_id = "some_id" await client.mock_state_update() sources = hass.states.get(ENTITY_ID).attributes[ATTR_INPUT_SOURCE_LIST] @@ -424,7 +424,7 @@ async def test_update_sources_live_tv_find(hass: HomeAssistant, client) -> None: assert len(sources) == 3 # Live TV is is in inputs - client.inputs = { + client.tv_state.inputs = { LIVE_TV_APP_ID: { "label": "Live TV", "id": "some_id", @@ -438,7 +438,7 @@ async def test_update_sources_live_tv_find(hass: HomeAssistant, client) -> None: assert len(sources) == 1 # Live TV is current input - client.inputs = { + client.tv_state.inputs = { LIVE_TV_APP_ID: { "label": "Live TV", "id": "some_id", @@ -452,7 +452,7 @@ async def test_update_sources_live_tv_find(hass: HomeAssistant, client) -> None: assert len(sources) == 1 # Live TV not found - client.current_app_id = "other_id" + client.tv_state.current_app_id = "other_id" await client.mock_state_update() sources = hass.states.get(ENTITY_ID).attributes[ATTR_INPUT_SOURCE_LIST] @@ -460,8 +460,8 @@ async def test_update_sources_live_tv_find(hass: HomeAssistant, client) -> None: assert len(sources) == 1 # Live TV not found in sources/apps but is current app - client.apps = {} - client.current_app_id = LIVE_TV_APP_ID + client.tv_state.apps = {} + client.tv_state.current_app_id = LIVE_TV_APP_ID await client.mock_state_update() sources = hass.states.get(ENTITY_ID).attributes[ATTR_INPUT_SOURCE_LIST] @@ -469,7 +469,7 @@ async def test_update_sources_live_tv_find(hass: HomeAssistant, client) -> None: assert len(sources) == 1 # Bad update, keep old update - client.inputs = {} + client.tv_state.inputs = {} await client.mock_state_update() sources = hass.states.get(ENTITY_ID).attributes[ATTR_INPUT_SOURCE_LIST] @@ -543,7 +543,7 @@ async def test_control_error_handling( """Test control errors handling.""" await setup_webostv(hass) client.play.side_effect = exception - client.is_on = is_on + client.tv_state.is_on = is_on await client.mock_state_update() data = {ATTR_ENTITY_ID: ENTITY_ID} @@ -566,7 +566,7 @@ async def test_turn_off_when_device_is_off(hass: HomeAssistant, client) -> None: async def test_supported_features(hass: HomeAssistant, client) -> None: """Test test supported features.""" - client.sound_output = "lineout" + client.tv_state.sound_output = "lineout" await setup_webostv(hass) await client.mock_state_update() @@ -577,7 +577,7 @@ async def test_supported_features(hass: HomeAssistant, client) -> None: assert attrs[ATTR_SUPPORTED_FEATURES] == supported # Support volume mute, step - client.sound_output = "external_speaker" + client.tv_state.sound_output = "external_speaker" await client.mock_state_update() supported = supported | SUPPORT_WEBOSTV_VOLUME attrs = hass.states.get(ENTITY_ID).attributes @@ -585,7 +585,7 @@ async def test_supported_features(hass: HomeAssistant, client) -> None: assert attrs[ATTR_SUPPORTED_FEATURES] == supported # Support volume mute, step, set - client.sound_output = "speaker" + client.tv_state.sound_output = "speaker" await client.mock_state_update() supported = supported | SUPPORT_WEBOSTV_VOLUME | MediaPlayerEntityFeature.VOLUME_SET attrs = hass.states.get(ENTITY_ID).attributes @@ -623,8 +623,8 @@ async def test_supported_features(hass: HomeAssistant, client) -> None: async def test_cached_supported_features(hass: HomeAssistant, client) -> None: """Test test supported features.""" - client.is_on = False - client.sound_output = None + client.tv_state.is_on = False + client.tv_state.sound_output = None supported = ( SUPPORT_WEBOSTV | SUPPORT_WEBOSTV_VOLUME | MediaPlayerEntityFeature.TURN_ON ) @@ -652,8 +652,8 @@ async def test_cached_supported_features(hass: HomeAssistant, client) -> None: ) # TV on, support volume mute, step - client.is_on = True - client.sound_output = "external_speaker" + client.tv_state.is_on = True + client.tv_state.sound_output = "external_speaker" await client.mock_state_update() supported = SUPPORT_WEBOSTV | SUPPORT_WEBOSTV_VOLUME @@ -662,8 +662,8 @@ async def test_cached_supported_features(hass: HomeAssistant, client) -> None: assert attrs[ATTR_SUPPORTED_FEATURES] == supported # TV off, support volume mute, step - client.is_on = False - client.sound_output = None + client.tv_state.is_on = False + client.tv_state.sound_output = None await client.mock_state_update() supported = SUPPORT_WEBOSTV | SUPPORT_WEBOSTV_VOLUME @@ -672,8 +672,8 @@ async def test_cached_supported_features(hass: HomeAssistant, client) -> None: assert attrs[ATTR_SUPPORTED_FEATURES] == supported # TV on, support volume mute, step, set - client.is_on = True - client.sound_output = "speaker" + client.tv_state.is_on = True + client.tv_state.sound_output = "speaker" await client.mock_state_update() supported = ( @@ -684,8 +684,8 @@ async def test_cached_supported_features(hass: HomeAssistant, client) -> None: assert attrs[ATTR_SUPPORTED_FEATURES] == supported # TV off, support volume mute, step, set - client.is_on = False - client.sound_output = None + client.tv_state.is_on = False + client.tv_state.sound_output = None await client.mock_state_update() supported = ( @@ -728,8 +728,8 @@ async def test_cached_supported_features(hass: HomeAssistant, client) -> None: async def test_supported_features_no_cache(hass: HomeAssistant, client) -> None: """Test supported features if device is off and no cache.""" - client.is_on = False - client.sound_output = None + client.tv_state.is_on = False + client.tv_state.sound_output = None await setup_webostv(hass) supported = ( @@ -772,7 +772,7 @@ async def test_get_image_http( ) -> None: """Test get image via http.""" url = "http://something/valid_icon" - client.apps[LIVE_TV_APP_ID]["icon"] = url + client.tv_state.apps[LIVE_TV_APP_ID]["icon"] = url await setup_webostv(hass) await client.mock_state_update() @@ -797,7 +797,7 @@ async def test_get_image_http_error( ) -> None: """Test get image via http error.""" url = "http://something/icon_error" - client.apps[LIVE_TV_APP_ID]["icon"] = url + client.tv_state.apps[LIVE_TV_APP_ID]["icon"] = url await setup_webostv(hass) await client.mock_state_update() @@ -823,7 +823,7 @@ async def test_get_image_https( ) -> None: """Test get image via http.""" url = "https://something/valid_icon_https" - client.apps[LIVE_TV_APP_ID]["icon"] = url + client.tv_state.apps[LIVE_TV_APP_ID]["icon"] = url await setup_webostv(hass) await client.mock_state_update() @@ -871,18 +871,18 @@ async def test_update_media_state(hass: HomeAssistant, client) -> None: """Test updating media state.""" await setup_webostv(hass) - client.media_state = [{"playState": "playing"}] + client.tv_state.media_state = [{"playState": "playing"}] await client.mock_state_update() assert hass.states.get(ENTITY_ID).state == MediaPlayerState.PLAYING - client.media_state = [{"playState": "paused"}] + client.tv_state.media_state = [{"playState": "paused"}] await client.mock_state_update() assert hass.states.get(ENTITY_ID).state == MediaPlayerState.PAUSED - client.media_state = [{"playState": "unloaded"}] + client.tv_state.media_state = [{"playState": "unloaded"}] await client.mock_state_update() assert hass.states.get(ENTITY_ID).state == MediaPlayerState.IDLE - client.is_on = False + client.tv_state.is_on = False await client.mock_state_update() assert hass.states.get(ENTITY_ID).state == STATE_OFF diff --git a/tests/components/webostv/test_notify.py b/tests/components/webostv/test_notify.py index fd56f0ea0bb..e64d58b8f91 100644 --- a/tests/components/webostv/test_notify.py +++ b/tests/components/webostv/test_notify.py @@ -104,7 +104,7 @@ async def test_errors( ) -> None: """Test error scenarios.""" await setup_webostv(hass) - client.is_on = is_on + client.tv_state.is_on = is_on assert hass.services.has_service("notify", SERVICE_NAME) From 183bbcd1e196f80bfeae2916a4eaffedf5df3d64 Mon Sep 17 00:00:00 2001 From: tronikos Date: Sun, 23 Feb 2025 23:53:23 -0800 Subject: [PATCH 1718/3148] Bump androidtvremote2 to 0.2.0 (#139141) --- homeassistant/components/androidtv_remote/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/androidtv_remote/manifest.json b/homeassistant/components/androidtv_remote/manifest.json index d9c2dd05c44..1c45e825359 100644 --- a/homeassistant/components/androidtv_remote/manifest.json +++ b/homeassistant/components/androidtv_remote/manifest.json @@ -7,6 +7,6 @@ "integration_type": "device", "iot_class": "local_push", "loggers": ["androidtvremote2"], - "requirements": ["androidtvremote2==0.1.2"], + "requirements": ["androidtvremote2==0.2.0"], "zeroconf": ["_androidtvremote2._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 7c9d90ad8df..d8e24dcc73b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -461,7 +461,7 @@ amcrest==1.9.8 androidtv[async]==0.0.75 # homeassistant.components.androidtv_remote -androidtvremote2==0.1.2 +androidtvremote2==0.2.0 # homeassistant.components.anel_pwrctrl anel-pwrctrl-homeassistant==0.0.1.dev2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b9a7579d7f1..3c8f2a803fb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -437,7 +437,7 @@ amberelectric==2.0.12 androidtv[async]==0.0.75 # homeassistant.components.androidtv_remote -androidtvremote2==0.1.2 +androidtvremote2==0.2.0 # homeassistant.components.anova anova-wifi==0.17.0 From 8c42db7501afa55535c0a0ce388369693885e716 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 24 Feb 2025 09:12:35 +0100 Subject: [PATCH 1719/3148] Bump actions/upload-artifact from 4.6.0 to 4.6.1 (#139161) --- .github/workflows/builder.yml | 2 +- .github/workflows/ci.yaml | 22 +++++++++++----------- .github/workflows/wheels.yml | 8 ++++---- 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index ffefee0d84e..88f6f37d6d6 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -69,7 +69,7 @@ jobs: run: find ./homeassistant/components/*/translations -name "*.json" | tar zcvf translations.tar.gz -T - - name: Upload translations - uses: actions/upload-artifact@v4.6.0 + uses: actions/upload-artifact@v4.6.1 with: name: translations path: translations.tar.gz diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 6eafa360e83..2aead92791a 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -537,7 +537,7 @@ jobs: python --version uv pip freeze >> pip_freeze.txt - name: Upload pip_freeze artifact - uses: actions/upload-artifact@v4.6.0 + uses: actions/upload-artifact@v4.6.1 with: name: pip-freeze-${{ matrix.python-version }} path: pip_freeze.txt @@ -661,7 +661,7 @@ jobs: . venv/bin/activate python -m script.licenses extract --output-file=licenses-${{ matrix.python-version }}.json - name: Upload licenses - uses: actions/upload-artifact@v4.6.0 + uses: actions/upload-artifact@v4.6.1 with: name: licenses-${{ github.run_number }}-${{ matrix.python-version }} path: licenses-${{ matrix.python-version }}.json @@ -877,7 +877,7 @@ jobs: . venv/bin/activate python -m script.split_tests ${{ needs.info.outputs.test_group_count }} tests - name: Upload pytest_buckets - uses: actions/upload-artifact@v4.6.0 + uses: actions/upload-artifact@v4.6.1 with: name: pytest_buckets path: pytest_buckets.txt @@ -980,14 +980,14 @@ jobs: 2>&1 | tee pytest-${{ matrix.python-version }}-${{ matrix.group }}.txt - name: Upload pytest output if: success() || failure() && steps.pytest-full.conclusion == 'failure' - uses: actions/upload-artifact@v4.6.0 + uses: actions/upload-artifact@v4.6.1 with: name: pytest-${{ github.run_number }}-${{ matrix.python-version }}-${{ matrix.group }} path: pytest-*.txt overwrite: true - name: Upload coverage artifact if: needs.info.outputs.skip_coverage != 'true' - uses: actions/upload-artifact@v4.6.0 + uses: actions/upload-artifact@v4.6.1 with: name: coverage-${{ matrix.python-version }}-${{ matrix.group }} path: coverage.xml @@ -1108,7 +1108,7 @@ jobs: 2>&1 | tee pytest-${{ matrix.python-version }}-${mariadb}.txt - name: Upload pytest output if: success() || failure() && steps.pytest-partial.conclusion == 'failure' - uses: actions/upload-artifact@v4.6.0 + uses: actions/upload-artifact@v4.6.1 with: name: pytest-${{ github.run_number }}-${{ matrix.python-version }}-${{ steps.pytest-partial.outputs.mariadb }} @@ -1116,7 +1116,7 @@ jobs: overwrite: true - name: Upload coverage artifact if: needs.info.outputs.skip_coverage != 'true' - uses: actions/upload-artifact@v4.6.0 + uses: actions/upload-artifact@v4.6.1 with: name: coverage-${{ matrix.python-version }}-${{ steps.pytest-partial.outputs.mariadb }} @@ -1239,7 +1239,7 @@ jobs: 2>&1 | tee pytest-${{ matrix.python-version }}-${postgresql}.txt - name: Upload pytest output if: success() || failure() && steps.pytest-partial.conclusion == 'failure' - uses: actions/upload-artifact@v4.6.0 + uses: actions/upload-artifact@v4.6.1 with: name: pytest-${{ github.run_number }}-${{ matrix.python-version }}-${{ steps.pytest-partial.outputs.postgresql }} @@ -1247,7 +1247,7 @@ jobs: overwrite: true - name: Upload coverage artifact if: needs.info.outputs.skip_coverage != 'true' - uses: actions/upload-artifact@v4.6.0 + uses: actions/upload-artifact@v4.6.1 with: name: coverage-${{ matrix.python-version }}-${{ steps.pytest-partial.outputs.postgresql }} @@ -1382,14 +1382,14 @@ jobs: 2>&1 | tee pytest-${{ matrix.python-version }}-${{ matrix.group }}.txt - name: Upload pytest output if: success() || failure() && steps.pytest-partial.conclusion == 'failure' - uses: actions/upload-artifact@v4.6.0 + uses: actions/upload-artifact@v4.6.1 with: name: pytest-${{ github.run_number }}-${{ matrix.python-version }}-${{ matrix.group }} path: pytest-*.txt overwrite: true - name: Upload coverage artifact if: needs.info.outputs.skip_coverage != 'true' - uses: actions/upload-artifact@v4.6.0 + uses: actions/upload-artifact@v4.6.1 with: name: coverage-${{ matrix.python-version }}-${{ matrix.group }} path: coverage.xml diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 41e7b351184..743ae869ab9 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -91,7 +91,7 @@ jobs: ) > build_constraints.txt - name: Upload env_file - uses: actions/upload-artifact@v4.6.0 + uses: actions/upload-artifact@v4.6.1 with: name: env_file path: ./.env_file @@ -99,14 +99,14 @@ jobs: overwrite: true - name: Upload build_constraints - uses: actions/upload-artifact@v4.6.0 + uses: actions/upload-artifact@v4.6.1 with: name: build_constraints path: ./build_constraints.txt overwrite: true - name: Upload requirements_diff - uses: actions/upload-artifact@v4.6.0 + uses: actions/upload-artifact@v4.6.1 with: name: requirements_diff path: ./requirements_diff.txt @@ -118,7 +118,7 @@ jobs: python -m script.gen_requirements_all ci - name: Upload requirements_all_wheels - uses: actions/upload-artifact@v4.6.0 + uses: actions/upload-artifact@v4.6.1 with: name: requirements_all_wheels path: ./requirements_all_wheels_*.txt From 7f494c235c52938156d7d7a3d671528bc5f0ded0 Mon Sep 17 00:00:00 2001 From: Philipp S Date: Mon, 24 Feb 2025 09:28:23 +0100 Subject: [PATCH 1720/3148] Consider the zone radius in proximity distance calculation (#138819) * Fix proximity distance calculation The distance is now calculated to the edge of the zone instead of the centre * Adjust proximity test expectations to corrected distance calculation * Add proximity tests for zone changes * Improve comment on proximity distance calculation Co-authored-by: Michael <35783820+mib1185@users.noreply.github.com> * Apply suggestions from code review --------- Co-authored-by: Michael <35783820+mib1185@users.noreply.github.com> --- .../components/proximity/coordinator.py | 11 +- .../proximity/snapshots/test_diagnostics.ambr | 8 +- tests/components/proximity/test_init.py | 150 ++++++++++++++---- 3 files changed, 133 insertions(+), 36 deletions(-) diff --git a/homeassistant/components/proximity/coordinator.py b/homeassistant/components/proximity/coordinator.py index 055c15125f1..856138c9051 100644 --- a/homeassistant/components/proximity/coordinator.py +++ b/homeassistant/components/proximity/coordinator.py @@ -164,7 +164,7 @@ class ProximityDataUpdateCoordinator(DataUpdateCoordinator[ProximityData]): ) return None - distance_to_zone = distance( + distance_to_centre = distance( zone.attributes[ATTR_LATITUDE], zone.attributes[ATTR_LONGITUDE], latitude, @@ -172,8 +172,13 @@ class ProximityDataUpdateCoordinator(DataUpdateCoordinator[ProximityData]): ) # it is ensured, that distance can't be None, since zones must have lat/lon coordinates - assert distance_to_zone is not None - return round(distance_to_zone) + assert distance_to_centre is not None + + zone_radius: float = zone.attributes["radius"] + if zone_radius > distance_to_centre: + # we've arrived the zone + return 0 + return round(distance_to_centre - zone_radius) def _calc_direction_of_travel( self, diff --git a/tests/components/proximity/snapshots/test_diagnostics.ambr b/tests/components/proximity/snapshots/test_diagnostics.ambr index 42ec74710f9..f6cd4393511 100644 --- a/tests/components/proximity/snapshots/test_diagnostics.ambr +++ b/tests/components/proximity/snapshots/test_diagnostics.ambr @@ -5,19 +5,19 @@ 'entities': dict({ 'device_tracker.test1': dict({ 'dir_of_travel': None, - 'dist_to_zone': 2218752, + 'dist_to_zone': 2218742, 'is_in_ignored_zone': False, 'name': 'test1', }), 'device_tracker.test2': dict({ 'dir_of_travel': None, - 'dist_to_zone': 4077309, + 'dist_to_zone': 4077299, 'is_in_ignored_zone': False, 'name': 'test2', }), 'device_tracker.test3': dict({ 'dir_of_travel': None, - 'dist_to_zone': 4077309, + 'dist_to_zone': 4077299, 'is_in_ignored_zone': False, 'name': 'test3', }), @@ -42,7 +42,7 @@ }), 'proximity': dict({ 'dir_of_travel': None, - 'dist_to_zone': 2218752, + 'dist_to_zone': 2218742, 'nearest': 'test1', }), 'tracked_states': dict({ diff --git a/tests/components/proximity/test_init.py b/tests/components/proximity/test_init.py index 22a546e6abe..e9340014207 100644 --- a/tests/components/proximity/test_init.py +++ b/tests/components/proximity/test_init.py @@ -128,7 +128,7 @@ async def test_device_tracker_test1_away(hass: HomeAssistant) -> None: entity_base_name = "sensor.home_test1" state = hass.states.get(f"{entity_base_name}_distance") - assert state.state == "2218752" + assert state.state == "2218742" state = hass.states.get(f"{entity_base_name}_direction_of_travel") assert state.state == STATE_UNKNOWN @@ -152,7 +152,7 @@ async def test_device_tracker_test1_awayfurther( entity_base_name = "sensor.home_test1" state = hass.states.get(f"{entity_base_name}_distance") - assert state.state == "2218752" + assert state.state == "2218742" state = hass.states.get(f"{entity_base_name}_direction_of_travel") assert state.state == STATE_UNKNOWN @@ -169,7 +169,7 @@ async def test_device_tracker_test1_awayfurther( entity_base_name = "sensor.home_test1" state = hass.states.get(f"{entity_base_name}_distance") - assert state.state == "4625264" + assert state.state == "4625254" state = hass.states.get(f"{entity_base_name}_direction_of_travel") assert state.state == "away_from" @@ -193,7 +193,7 @@ async def test_device_tracker_test1_awaycloser( entity_base_name = "sensor.home_test1" state = hass.states.get(f"{entity_base_name}_distance") - assert state.state == "4625264" + assert state.state == "4625254" state = hass.states.get(f"{entity_base_name}_direction_of_travel") assert state.state == STATE_UNKNOWN @@ -210,7 +210,7 @@ async def test_device_tracker_test1_awaycloser( entity_base_name = "sensor.home_test1" state = hass.states.get(f"{entity_base_name}_distance") - assert state.state == "2218752" + assert state.state == "2218742" state = hass.states.get(f"{entity_base_name}_direction_of_travel") assert state.state == "towards" @@ -272,7 +272,7 @@ async def test_device_tracker_test1_awayfurther_a_bit(hass: HomeAssistant) -> No entity_base_name = "sensor.home_test1" state = hass.states.get(f"{entity_base_name}_distance") - assert state.state == "2218752" + assert state.state == "2218742" state = hass.states.get(f"{entity_base_name}_direction_of_travel") assert state.state == STATE_UNKNOWN @@ -289,7 +289,7 @@ async def test_device_tracker_test1_awayfurther_a_bit(hass: HomeAssistant) -> No entity_base_name = "sensor.home_test1" state = hass.states.get(f"{entity_base_name}_distance") - assert state.state == "2218752" + assert state.state == "2218742" state = hass.states.get(f"{entity_base_name}_direction_of_travel") assert state.state == "stationary" @@ -360,7 +360,7 @@ async def test_device_tracker_test1_awayfurther_than_test2_first_test1( entity_base_name = "sensor.home_test1" state = hass.states.get(f"{entity_base_name}_distance") - assert state.state == "2218752" + assert state.state == "2218742" state = hass.states.get(f"{entity_base_name}_direction_of_travel") assert state.state == STATE_UNKNOWN @@ -383,13 +383,13 @@ async def test_device_tracker_test1_awayfurther_than_test2_first_test1( entity_base_name = "sensor.home_test1" state = hass.states.get(f"{entity_base_name}_distance") - assert state.state == "2218752" + assert state.state == "2218742" state = hass.states.get(f"{entity_base_name}_direction_of_travel") assert state.state == STATE_UNKNOWN entity_base_name = "sensor.home_test2" state = hass.states.get(f"{entity_base_name}_distance") - assert state.state == "4625264" + assert state.state == "4625254" state = hass.states.get(f"{entity_base_name}_direction_of_travel") assert state.state == STATE_UNKNOWN @@ -432,7 +432,7 @@ async def test_device_tracker_test1_awayfurther_than_test2_first_test2( entity_base_name = "sensor.home_test2" state = hass.states.get(f"{entity_base_name}_distance") - assert state.state == "4625264" + assert state.state == "4625254" state = hass.states.get(f"{entity_base_name}_direction_of_travel") assert state.state == STATE_UNKNOWN @@ -449,13 +449,13 @@ async def test_device_tracker_test1_awayfurther_than_test2_first_test2( entity_base_name = "sensor.home_test1" state = hass.states.get(f"{entity_base_name}_distance") - assert state.state == "2218752" + assert state.state == "2218742" state = hass.states.get(f"{entity_base_name}_direction_of_travel") assert state.state == STATE_UNKNOWN entity_base_name = "sensor.home_test2" state = hass.states.get(f"{entity_base_name}_distance") - assert state.state == "4625264" + assert state.state == "4625254" state = hass.states.get(f"{entity_base_name}_direction_of_travel") assert state.state == STATE_UNKNOWN @@ -489,7 +489,7 @@ async def test_device_tracker_test1_awayfurther_test2_in_ignored_zone( entity_base_name = "sensor.home_test1" state = hass.states.get(f"{entity_base_name}_distance") - assert state.state == "2218752" + assert state.state == "2218742" state = hass.states.get(f"{entity_base_name}_direction_of_travel") assert state.state == STATE_UNKNOWN @@ -562,7 +562,7 @@ async def test_device_tracker_test1_awayfurther_test2_first( entity_base_name = "sensor.home_test2" state = hass.states.get(f"{entity_base_name}_distance") - assert state.state == "2218752" + assert state.state == "2218742" state = hass.states.get(f"{entity_base_name}_direction_of_travel") assert state.state == STATE_UNKNOWN @@ -602,7 +602,7 @@ async def test_device_tracker_test1_nearest_after_test2_in_ignored_zone( entity_base_name = "sensor.home_test1" state = hass.states.get(f"{entity_base_name}_distance") - assert state.state == "2218752" + assert state.state == "2218742" state = hass.states.get(f"{entity_base_name}_direction_of_travel") assert state.state == STATE_UNKNOWN @@ -625,13 +625,13 @@ async def test_device_tracker_test1_nearest_after_test2_in_ignored_zone( entity_base_name = "sensor.home_test1" state = hass.states.get(f"{entity_base_name}_distance") - assert state.state == "2218752" + assert state.state == "2218742" state = hass.states.get(f"{entity_base_name}_direction_of_travel") assert state.state == STATE_UNKNOWN entity_base_name = "sensor.home_test2" state = hass.states.get(f"{entity_base_name}_distance") - assert state.state == "989156" + assert state.state == "989146" state = hass.states.get(f"{entity_base_name}_direction_of_travel") assert state.state == STATE_UNKNOWN @@ -648,13 +648,13 @@ async def test_device_tracker_test1_nearest_after_test2_in_ignored_zone( entity_base_name = "sensor.home_test1" state = hass.states.get(f"{entity_base_name}_distance") - assert state.state == "2218752" + assert state.state == "2218742" state = hass.states.get(f"{entity_base_name}_direction_of_travel") assert state.state == STATE_UNKNOWN entity_base_name = "sensor.home_test2" state = hass.states.get(f"{entity_base_name}_distance") - assert state.state == "1364567" + assert state.state == "1364557" state = hass.states.get(f"{entity_base_name}_direction_of_travel") assert state.state == "away_from" @@ -693,15 +693,15 @@ async def test_nearest_sensors(hass: HomeAssistant, config_zones) -> None: state = hass.states.get("sensor.home_nearest_device") assert state.state == "test1" state = hass.states.get("sensor.home_nearest_distance") - assert state.state == "1615590" + assert state.state == "1615580" state = hass.states.get("sensor.home_test1_direction_of_travel") assert state.state == "towards" state = hass.states.get("sensor.home_test1_distance") - assert state.state == "1615590" + assert state.state == "1615580" state = hass.states.get("sensor.home_test1_direction_of_travel") assert state.state == "towards" state = hass.states.get("sensor.home_test2_distance") - assert state.state == "5176058" + assert state.state == "5176048" state = hass.states.get("sensor.home_test2_direction_of_travel") assert state.state == "away_from" @@ -715,15 +715,15 @@ async def test_nearest_sensors(hass: HomeAssistant, config_zones) -> None: state = hass.states.get("sensor.home_nearest_device") assert state.state == "test1" state = hass.states.get("sensor.home_nearest_distance") - assert state.state == "1615590" + assert state.state == "1615580" state = hass.states.get("sensor.home_nearest_direction_of_travel") assert state.state == "towards" state = hass.states.get("sensor.home_test1_distance") - assert state.state == "1615590" + assert state.state == "1615580" state = hass.states.get("sensor.home_test1_direction_of_travel") assert state.state == "towards" state = hass.states.get("sensor.home_test2_distance") - assert state.state == "4611404" + assert state.state == "4611394" state = hass.states.get("sensor.home_test2_direction_of_travel") assert state.state == "towards" @@ -737,15 +737,15 @@ async def test_nearest_sensors(hass: HomeAssistant, config_zones) -> None: state = hass.states.get("sensor.home_nearest_device") assert state.state == "test1" state = hass.states.get("sensor.home_nearest_distance") - assert state.state == "2204122" + assert state.state == "2204112" state = hass.states.get("sensor.home_nearest_direction_of_travel") assert state.state == "away_from" state = hass.states.get("sensor.home_test1_distance") - assert state.state == "2204122" + assert state.state == "2204112" state = hass.states.get("sensor.home_test1_direction_of_travel") assert state.state == "away_from" state = hass.states.get("sensor.home_test2_distance") - assert state.state == "4611404" + assert state.state == "4611394" state = hass.states.get("sensor.home_test2_direction_of_travel") assert state.state == "towards" @@ -919,3 +919,95 @@ async def test_tracked_zone_is_removed(hass: HomeAssistant) -> None: assert state.state == STATE_UNAVAILABLE state = hass.states.get(f"{entity_base_name}_direction_of_travel") assert state.state == STATE_UNAVAILABLE + + +async def test_tracked_zone_radius_is_changed(hass: HomeAssistant) -> None: + """Test that radius of the tracked zone is changed.""" + entry = await async_setup_single_entry( + hass, "zone.home", ["device_tracker.test1"], [], 1 + ) + + hass.states.async_set( + "device_tracker.test1", + "not_home", + {"friendly_name": "test1", "latitude": 20.10000001, "longitude": 10.1}, + ) + await hass.async_block_till_done() + + # check sensor entities before radius change + state = hass.states.get("sensor.home_nearest_device") + assert state.state == "test1" + + entity_base_name = "sensor.home_test1" + state = hass.states.get(f"{entity_base_name}_distance") + assert state.state == "2218742" + state = hass.states.get(f"{entity_base_name}_direction_of_travel") + assert state.state == STATE_UNKNOWN + + # change radius of tracked zone + hass.states.async_set( + "zone.home", + "zoning", + {"name": "Home", "latitude": 2.1, "longitude": 1.1, "radius": 110}, + ) + await hass.config_entries.async_reload(entry.entry_id) + await hass.async_block_till_done() + radius = hass.states.get("zone.home").attributes["radius"] + assert radius == 110 + + # check sensor entities after radius change + state = hass.states.get("sensor.home_nearest_device") + assert state.state == "test1" + + entity_base_name = "sensor.home_test1" + state = hass.states.get(f"{entity_base_name}_distance") + assert state.state == "2218642" + state = hass.states.get(f"{entity_base_name}_direction_of_travel") + assert state.state == STATE_UNKNOWN + + +async def test_tracked_zone_location_is_changed(hass: HomeAssistant) -> None: + """Test that gps location of the tracked zone is changed.""" + entry = await async_setup_single_entry( + hass, "zone.home", ["device_tracker.test1"], [], 1 + ) + + hass.states.async_set( + "device_tracker.test1", + "not_home", + {"friendly_name": "test1", "latitude": 20.1, "longitude": 10.1}, + ) + await hass.async_block_till_done() + + # check sensor entities before location change + state = hass.states.get("sensor.home_nearest_device") + assert state.state == "test1" + + entity_base_name = "sensor.home_test1" + state = hass.states.get(f"{entity_base_name}_distance") + assert state.state == "2218742" + state = hass.states.get(f"{entity_base_name}_direction_of_travel") + assert state.state == STATE_UNKNOWN + + # change location of tracked zone + hass.states.async_set( + "zone.home", + "zoning", + {"name": "Home", "latitude": 10, "longitude": 5, "radius": 10}, + ) + await hass.config_entries.async_reload(entry.entry_id) + await hass.async_block_till_done() + latitude = hass.states.get("zone.home").attributes["latitude"] + assert latitude == 10 + longitude = hass.states.get("zone.home").attributes["longitude"] + assert longitude == 5 + + # check sensor entities after location change + state = hass.states.get("sensor.home_nearest_device") + assert state.state == "test1" + + entity_base_name = "sensor.home_test1" + state = hass.states.get(f"{entity_base_name}_distance") + assert state.state == "1244478" + state = hass.states.get(f"{entity_base_name}_direction_of_travel") + assert state.state == STATE_UNKNOWN From 257242e6e3b5f94a0483b189a9aeb660960a3609 Mon Sep 17 00:00:00 2001 From: LG-ThinQ-Integration Date: Mon, 24 Feb 2025 17:37:25 +0900 Subject: [PATCH 1721/3148] Remove unnecessary min/max setting of WATER_HEATER (#138969) Remove unnecessary min/max setting Co-authored-by: yunseon.park --- homeassistant/components/lg_thinq/number.py | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/lg_thinq/number.py b/homeassistant/components/lg_thinq/number.py index 0cbfcf9b5c8..7003519e0ce 100644 --- a/homeassistant/components/lg_thinq/number.py +++ b/homeassistant/components/lg_thinq/number.py @@ -118,16 +118,7 @@ DEVICE_TYPE_NUMBER_MAP: dict[DeviceType, tuple[NumberEntityDescription, ...]] = DeviceType.WASHTOWER_DRYER: WASHER_NUMBERS, DeviceType.WASHTOWER: WASHER_NUMBERS, DeviceType.WASHTOWER_WASHER: WASHER_NUMBERS, - DeviceType.WATER_HEATER: ( - NumberEntityDescription( - key=ThinQProperty.TARGET_TEMPERATURE, - native_max_value=60, - native_min_value=35, - native_step=1, - native_unit_of_measurement=UnitOfTemperature.CELSIUS, - translation_key=ThinQProperty.TARGET_TEMPERATURE, - ), - ), + DeviceType.WATER_HEATER: (NUMBER_DESC[ThinQProperty.TARGET_TEMPERATURE],), DeviceType.WINE_CELLAR: ( NUMBER_DESC[ThinQProperty.LIGHT_STATUS], NUMBER_DESC[ThinQProperty.TARGET_TEMPERATURE], @@ -179,7 +170,7 @@ class ThinQNumberEntity(ThinQEntity, NumberEntity): ) is not None: self._attr_native_unit_of_measurement = unit_of_measurement - # Undate range. + # Update range. if ( self.entity_description.native_min_value is None and (min_value := self.data.min) is not None From fc8affd243968d02782dff70d98a644dccf22df8 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 24 Feb 2025 12:33:14 +0100 Subject: [PATCH 1722/3148] Remove setup of rpi_power from onboarding (#139168) * Remove setup of rpi_power from onboarding * Remove test --- .../components/onboarding/manifest.json | 2 +- homeassistant/components/onboarding/views.py | 11 -------- tests/components/onboarding/test_views.py | 26 ------------------- 3 files changed, 1 insertion(+), 38 deletions(-) diff --git a/homeassistant/components/onboarding/manifest.json b/homeassistant/components/onboarding/manifest.json index 8e253d4bff9..3634894cd00 100644 --- a/homeassistant/components/onboarding/manifest.json +++ b/homeassistant/components/onboarding/manifest.json @@ -1,7 +1,7 @@ { "domain": "onboarding", "name": "Home Assistant Onboarding", - "after_dependencies": ["backup", "hassio"], + "after_dependencies": ["backup"], "codeowners": ["@home-assistant/core"], "dependencies": ["auth", "http", "person"], "documentation": "https://www.home-assistant.io/integrations/onboarding", diff --git a/homeassistant/components/onboarding/views.py b/homeassistant/components/onboarding/views.py index ea955987d80..b392c6b57b0 100644 --- a/homeassistant/components/onboarding/views.py +++ b/homeassistant/components/onboarding/views.py @@ -29,7 +29,6 @@ from homeassistant.components.http.view import HomeAssistantView from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import area_registry as ar -from homeassistant.helpers.hassio import is_hassio from homeassistant.helpers.system_info import async_get_system_info from homeassistant.helpers.translation import async_get_translations from homeassistant.setup import async_setup_component @@ -224,16 +223,6 @@ class CoreConfigOnboardingView(_BaseOnboardingView): "shopping_list", ] - # pylint: disable-next=import-outside-toplevel - from homeassistant.components import hassio - - if ( - is_hassio(hass) - and (core_info := hassio.get_core_info(hass)) - and "raspberrypi" in core_info["machine"] - ): - onboard_integrations.append("rpi_power") - for domain in onboard_integrations: # Create tasks so onboarding isn't affected # by errors in these integrations. diff --git a/tests/components/onboarding/test_views.py b/tests/components/onboarding/test_views.py index 99623cb6efe..08d21a13331 100644 --- a/tests/components/onboarding/test_views.py +++ b/tests/components/onboarding/test_views.py @@ -529,32 +529,6 @@ async def test_onboarding_core_sets_up_radio_browser( assert len(hass.config_entries.async_entries("radio_browser")) == 1 -async def test_onboarding_core_sets_up_rpi_power( - hass: HomeAssistant, - hass_storage: dict[str, Any], - hass_client: ClientSessionGenerator, - aioclient_mock: AiohttpClientMocker, - rpi, - mock_default_integrations, -) -> None: - """Test that the core step sets up rpi_power on RPi.""" - mock_storage(hass_storage, {"done": [const.STEP_USER]}) - - assert await async_setup_component(hass, "onboarding", {}) - await hass.async_block_till_done() - - client = await hass_client() - - resp = await client.post("/api/onboarding/core_config") - - assert resp.status == 200 - - await hass.async_block_till_done() - - rpi_power_state = hass.states.get("binary_sensor.rpi_power_status") - assert rpi_power_state - - async def test_onboarding_core_no_rpi_power( hass: HomeAssistant, hass_storage: dict[str, Any], From d9eb248e91c11bdec4173f65ccf4734c8122aee5 Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Mon, 24 Feb 2025 13:23:39 +0100 Subject: [PATCH 1723/3148] Better handle runtime recovery mode in bootstrap (#138624) * Better handle runtime recovery mode in bootstrap * Add test --- homeassistant/bootstrap.py | 66 ++++++++++++++++++++------------------ tests/test_bootstrap.py | 7 +++- 2 files changed, 41 insertions(+), 32 deletions(-) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 7c5cb7dce4c..9cfc1c95d8b 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -328,10 +328,10 @@ async def async_setup_hass( block_async_io.enable() - config_dict = None - basic_setup_success = False - if not (recovery_mode := runtime_config.recovery_mode): + config_dict = None + basic_setup_success = False + await hass.async_add_executor_job(conf_util.process_ha_config_upgrade, hass) try: @@ -349,39 +349,43 @@ async def async_setup_hass( await async_from_config_dict(config_dict, hass) is not None ) - if config_dict is None: - recovery_mode = True - await stop_hass(hass) - hass = await create_hass() + if config_dict is None: + recovery_mode = True + await stop_hass(hass) + hass = await create_hass() - elif not basic_setup_success: - _LOGGER.warning("Unable to set up core integrations. Activating recovery mode") - recovery_mode = True - await stop_hass(hass) - hass = await create_hass() + elif not basic_setup_success: + _LOGGER.warning( + "Unable to set up core integrations. Activating recovery mode" + ) + recovery_mode = True + await stop_hass(hass) + hass = await create_hass() - elif any(domain not in hass.config.components for domain in CRITICAL_INTEGRATIONS): - _LOGGER.warning( - "Detected that %s did not load. Activating recovery mode", - ",".join(CRITICAL_INTEGRATIONS), - ) + elif any( + domain not in hass.config.components for domain in CRITICAL_INTEGRATIONS + ): + _LOGGER.warning( + "Detected that %s did not load. Activating recovery mode", + ",".join(CRITICAL_INTEGRATIONS), + ) - old_config = hass.config - old_logging = hass.data.get(DATA_LOGGING) + old_config = hass.config + old_logging = hass.data.get(DATA_LOGGING) - recovery_mode = True - await stop_hass(hass) - hass = await create_hass() + recovery_mode = True + await stop_hass(hass) + hass = await create_hass() - if old_logging: - hass.data[DATA_LOGGING] = old_logging - hass.config.debug = old_config.debug - hass.config.skip_pip = old_config.skip_pip - hass.config.skip_pip_packages = old_config.skip_pip_packages - hass.config.internal_url = old_config.internal_url - hass.config.external_url = old_config.external_url - # Setup loader cache after the config dir has been set - loader.async_setup(hass) + if old_logging: + hass.data[DATA_LOGGING] = old_logging + hass.config.debug = old_config.debug + hass.config.skip_pip = old_config.skip_pip + hass.config.skip_pip_packages = old_config.skip_pip_packages + hass.config.internal_url = old_config.internal_url + hass.config.external_url = old_config.external_url + # Setup loader cache after the config dir has been set + loader.async_setup(hass) if recovery_mode: _LOGGER.info("Starting in recovery mode") diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index d554ca9449a..0d7c8614c6f 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -12,7 +12,7 @@ from unittest.mock import AsyncMock, Mock, patch import pytest -from homeassistant import bootstrap, config as config_util, loader, runner +from homeassistant import bootstrap, config as config_util, core, loader, runner from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( BASE_PLATFORMS, @@ -787,6 +787,9 @@ async def test_setup_hass_recovery_mode( ) -> None: """Test it works.""" with ( + patch( + "homeassistant.core.HomeAssistant", wraps=core.HomeAssistant + ) as mock_hass, patch("homeassistant.components.browser.setup") as browser_setup, patch( "homeassistant.config_entries.ConfigEntries.async_domains", @@ -805,6 +808,8 @@ async def test_setup_hass_recovery_mode( ), ) + mock_hass.assert_called_once() + assert "recovery_mode" in hass.config.components assert len(mock_mount_local_lib_path.mock_calls) == 0 From 571349e3a28dab5704477833e9ceed54dcf482de Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Mon, 24 Feb 2025 07:45:10 -0500 Subject: [PATCH 1724/3148] Add Snoo integration (#134243) Co-authored-by: Joost Lekkerkerker --- CODEOWNERS | 2 + homeassistant/components/snoo/__init__.py | 63 ++++++++++ homeassistant/components/snoo/config_flow.py | 68 ++++++++++ homeassistant/components/snoo/const.py | 3 + homeassistant/components/snoo/coordinator.py | 39 ++++++ homeassistant/components/snoo/entity.py | 37 ++++++ homeassistant/components/snoo/manifest.json | 11 ++ .../components/snoo/quality_scale.yaml | 72 +++++++++++ homeassistant/components/snoo/sensor.py | 71 +++++++++++ homeassistant/components/snoo/strings.json | 44 +++++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/snoo/__init__.py | 38 ++++++ tests/components/snoo/conftest.py | 73 +++++++++++ tests/components/snoo/const.py | 34 +++++ tests/components/snoo/test_config_flow.py | 118 ++++++++++++++++++ tests/components/snoo/test_init.py | 14 +++ 19 files changed, 700 insertions(+) create mode 100644 homeassistant/components/snoo/__init__.py create mode 100644 homeassistant/components/snoo/config_flow.py create mode 100644 homeassistant/components/snoo/const.py create mode 100644 homeassistant/components/snoo/coordinator.py create mode 100644 homeassistant/components/snoo/entity.py create mode 100644 homeassistant/components/snoo/manifest.json create mode 100644 homeassistant/components/snoo/quality_scale.yaml create mode 100644 homeassistant/components/snoo/sensor.py create mode 100644 homeassistant/components/snoo/strings.json create mode 100644 tests/components/snoo/__init__.py create mode 100644 tests/components/snoo/conftest.py create mode 100644 tests/components/snoo/const.py create mode 100644 tests/components/snoo/test_config_flow.py create mode 100644 tests/components/snoo/test_init.py diff --git a/CODEOWNERS b/CODEOWNERS index 6a66c24c7e8..3397948d7c8 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1413,6 +1413,8 @@ build.json @home-assistant/supervisor /tests/components/snapcast/ @luar123 /homeassistant/components/snmp/ @nmaggioni /tests/components/snmp/ @nmaggioni +/homeassistant/components/snoo/ @Lash-L +/tests/components/snoo/ @Lash-L /homeassistant/components/snooz/ @AustinBrunkhorst /tests/components/snooz/ @AustinBrunkhorst /homeassistant/components/solaredge/ @frenck @bdraco diff --git a/homeassistant/components/snoo/__init__.py b/homeassistant/components/snoo/__init__.py new file mode 100644 index 00000000000..aaf0c828830 --- /dev/null +++ b/homeassistant/components/snoo/__init__.py @@ -0,0 +1,63 @@ +"""The Happiest Baby Snoo integration.""" + +from __future__ import annotations + +import asyncio +import logging + +from python_snoo.exceptions import InvalidSnooAuth, SnooAuthException, SnooDeviceError +from python_snoo.snoo import Snoo + +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .coordinator import SnooConfigEntry, SnooCoordinator + +_LOGGER = logging.getLogger(__name__) + +PLATFORMS: list[Platform] = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: SnooConfigEntry) -> bool: + """Set up Happiest Baby Snoo from a config entry.""" + + snoo = Snoo( + email=entry.data[CONF_USERNAME], + password=entry.data[CONF_PASSWORD], + clientsession=async_get_clientsession(hass), + ) + + try: + await snoo.authorize() + except (SnooAuthException, InvalidSnooAuth) as ex: + raise ConfigEntryNotReady from ex + try: + devices = await snoo.get_devices() + except SnooDeviceError as ex: + raise ConfigEntryNotReady from ex + coordinators: dict[str, SnooCoordinator] = {} + tasks = [] + for device in devices: + coordinators[device.serialNumber] = SnooCoordinator(hass, device, snoo) + tasks.append(coordinators[device.serialNumber].setup()) + await asyncio.gather(*tasks) + entry.runtime_data = coordinators + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: SnooConfigEntry) -> bool: + """Unload a config entry.""" + disconnects = await asyncio.gather( + *(coordinator.snoo.disconnect() for coordinator in entry.runtime_data.values()), + return_exceptions=True, + ) + for disconnect in disconnects: + if isinstance(disconnect, Exception): + _LOGGER.warning( + "Failed to disconnect a logger with exception: %s", disconnect + ) + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/snoo/config_flow.py b/homeassistant/components/snoo/config_flow.py new file mode 100644 index 00000000000..986ef6a0071 --- /dev/null +++ b/homeassistant/components/snoo/config_flow.py @@ -0,0 +1,68 @@ +"""Config flow for the Happiest Baby Snoo integration.""" + +from __future__ import annotations + +import logging +from typing import Any + +import jwt +from python_snoo.exceptions import InvalidSnooAuth, SnooAuthException +from python_snoo.snoo import Snoo +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + } +) + + +class SnooConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Happiest Baby Snoo.""" + + 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 is not None: + hub = Snoo( + email=user_input[CONF_USERNAME], + password=user_input[CONF_PASSWORD], + clientsession=async_get_clientsession(self.hass), + ) + + try: + tokens = await hub.authorize() + except SnooAuthException: + errors["base"] = "cannot_connect" + except InvalidSnooAuth: + errors["base"] = "invalid_auth" + except Exception: + _LOGGER.exception("Unexpected exception %s") + errors["base"] = "unknown" + else: + user_uuid = jwt.decode( + tokens.aws_access, options={"verify_signature": False} + )["username"] + await self.async_set_unique_id(user_uuid) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title=user_input[CONF_USERNAME], data=user_input + ) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) diff --git a/homeassistant/components/snoo/const.py b/homeassistant/components/snoo/const.py new file mode 100644 index 00000000000..ff8afe25056 --- /dev/null +++ b/homeassistant/components/snoo/const.py @@ -0,0 +1,3 @@ +"""Constants for the Happiest Baby Snoo integration.""" + +DOMAIN = "snoo" diff --git a/homeassistant/components/snoo/coordinator.py b/homeassistant/components/snoo/coordinator.py new file mode 100644 index 00000000000..bc06d20955c --- /dev/null +++ b/homeassistant/components/snoo/coordinator.py @@ -0,0 +1,39 @@ +"""Support for Snoo Coordinators.""" + +import logging + +from python_snoo.containers import SnooData, SnooDevice +from python_snoo.snoo import Snoo + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +type SnooConfigEntry = ConfigEntry[dict[str, SnooCoordinator]] + +_LOGGER = logging.getLogger(__name__) + + +class SnooCoordinator(DataUpdateCoordinator[SnooData]): + """Snoo coordinator.""" + + config_entry: SnooConfigEntry + + def __init__(self, hass: HomeAssistant, device: SnooDevice, snoo: Snoo) -> None: + """Set up Snoo Coordinator.""" + super().__init__( + hass, + name=device.name, + logger=_LOGGER, + ) + self.device_unique_id = device.serialNumber + self.device = device + self.sensor_data_set: bool = False + self.snoo = snoo + + async def setup(self) -> None: + """Perform setup needed on every coordintaor creation.""" + await self.snoo.subscribe(self.device, self.async_set_updated_data) + # After we subscribe - get the status so that we have something to start with. + # We only need to do this once. The device will auto update otherwise. + await self.snoo.get_status(self.device) diff --git a/homeassistant/components/snoo/entity.py b/homeassistant/components/snoo/entity.py new file mode 100644 index 00000000000..25f54344674 --- /dev/null +++ b/homeassistant/components/snoo/entity.py @@ -0,0 +1,37 @@ +"""Base entity for the Snoo integration.""" + +from __future__ import annotations + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import SnooCoordinator + + +class SnooDescriptionEntity(CoordinatorEntity[SnooCoordinator]): + """Defines an Snoo entity that uses a description.""" + + _attr_has_entity_name = True + + def __init__( + self, coordinator: SnooCoordinator, description: EntityDescription + ) -> None: + """Initialize the Snoo entity.""" + super().__init__(coordinator) + self.device = coordinator.device + self.entity_description = description + self._attr_unique_id = f"{coordinator.device_unique_id}_{description.key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, coordinator.device_unique_id)}, + name=self.device.name, + manufacturer="Happiest Baby", + model="Snoo", + serial_number=self.device.serialNumber, + ) + + @property + def available(self) -> bool: + """Return if entity is available.""" + return self.coordinator.data is not None and super().available diff --git a/homeassistant/components/snoo/manifest.json b/homeassistant/components/snoo/manifest.json new file mode 100644 index 00000000000..3dca8cfe7dd --- /dev/null +++ b/homeassistant/components/snoo/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "snoo", + "name": "Happiest Baby Snoo", + "codeowners": ["@Lash-L"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/snoo", + "iot_class": "cloud_push", + "loggers": ["snoo"], + "quality_scale": "bronze", + "requirements": ["python-snoo==0.6.0"] +} diff --git a/homeassistant/components/snoo/quality_scale.yaml b/homeassistant/components/snoo/quality_scale.yaml new file mode 100644 index 00000000000..f10bccb131a --- /dev/null +++ b/homeassistant/components/snoo/quality_scale.yaml @@ -0,0 +1,72 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + This integration does not provide additional actions. + appropriate-polling: + status: exempt + comment: | + This integration does not poll. + brands: done + common-modules: + status: done + comment: | + There are no common patterns currenty. + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: | + This integration does not provide additional actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: done + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: todo + config-entry-unloading: done + docs-configuration-parameters: todo + docs-installation-parameters: todo + entity-unavailable: todo + integration-owner: done + log-when-unavailable: todo + parallel-updates: todo + reauthentication-flow: todo + test-coverage: todo + + # Gold + devices: done + diagnostics: todo + discovery-update-info: todo + discovery: todo + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: todo + entity-category: done + entity-device-class: done + entity-disabled-by-default: todo + entity-translations: done + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + repair-issues: todo + stale-devices: todo + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: todo diff --git a/homeassistant/components/snoo/sensor.py b/homeassistant/components/snoo/sensor.py new file mode 100644 index 00000000000..e45b2b88592 --- /dev/null +++ b/homeassistant/components/snoo/sensor.py @@ -0,0 +1,71 @@ +"""Support for Snoo Sensors.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from python_snoo.containers import SnooData, SnooStates + +from homeassistant.components.sensor import ( + EntityCategory, + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + StateType, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import SnooConfigEntry +from .entity import SnooDescriptionEntity + + +@dataclass(frozen=True, kw_only=True) +class SnooSensorEntityDescription(SensorEntityDescription): + """Describes a Snoo sensor.""" + + value_fn: Callable[[SnooData], StateType] + + +SENSOR_DESCRIPTIONS: list[SnooSensorEntityDescription] = [ + SnooSensorEntityDescription( + key="state", + translation_key="state", + value_fn=lambda data: data.state_machine.state.name, + device_class=SensorDeviceClass.ENUM, + options=[e.name for e in SnooStates], + ), + SnooSensorEntityDescription( + key="time_left", + translation_key="time_left", + value_fn=lambda data: data.state_machine.time_left_timestamp, + device_class=SensorDeviceClass.TIMESTAMP, + entity_category=EntityCategory.DIAGNOSTIC, + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + entry: SnooConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Snoo device.""" + coordinators = entry.runtime_data + async_add_entities( + SnooSensor(coordinator, description) + for coordinator in coordinators.values() + for description in SENSOR_DESCRIPTIONS + ) + + +class SnooSensor(SnooDescriptionEntity, SensorEntity): + """A sensor using Snoo coordinator.""" + + entity_description: SnooSensorEntityDescription + + @property + def native_value(self) -> StateType: + """Return the value reported by the sensor.""" + return self.entity_description.value_fn(self.coordinator.data) diff --git a/homeassistant/components/snoo/strings.json b/homeassistant/components/snoo/strings.json new file mode 100644 index 00000000000..567fa30fca7 --- /dev/null +++ b/homeassistant/components/snoo/strings.json @@ -0,0 +1,44 @@ +{ + "config": { + "step": { + "user": { + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "username": "Your Snoo username or email", + "password": "Your Snoo password" + } + } + }, + "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%]" + } + }, + "entity": { + "sensor": { + "state": { + "name": "State", + "state": { + "baseline": "Baseline", + "level1": "Level 1", + "level2": "Level 2", + "level3": "Level 3", + "level4": "Level 4", + "stop": "Stopped", + "pretimeout": "Pre-timeout", + "timeout": "Timeout" + } + }, + "time_left": { + "name": "Time left" + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 40af1df86cd..c92235aae47 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -575,6 +575,7 @@ FLOWS = { "smlight", "sms", "snapcast", + "snoo", "snooz", "solaredge", "solarlog", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 2d28d4f46d7..6f4315c43dc 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -5916,6 +5916,12 @@ "config_flow": false, "iot_class": "local_polling" }, + "snoo": { + "name": "Happiest Baby Snoo", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_push" + }, "snooz": { "name": "Snooz", "integration_type": "hub", diff --git a/requirements_all.txt b/requirements_all.txt index d8e24dcc73b..50c4ad93559 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2463,6 +2463,9 @@ python-roborock==2.11.1 # homeassistant.components.smarttub python-smarttub==0.0.38 +# homeassistant.components.snoo +python-snoo==0.6.0 + # homeassistant.components.songpal python-songpal==0.16.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3c8f2a803fb..a1c713424b4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1996,6 +1996,9 @@ python-roborock==2.11.1 # homeassistant.components.smarttub python-smarttub==0.0.38 +# homeassistant.components.snoo +python-snoo==0.6.0 + # homeassistant.components.songpal python-songpal==0.16.2 diff --git a/tests/components/snoo/__init__.py b/tests/components/snoo/__init__.py new file mode 100644 index 00000000000..f8529251720 --- /dev/null +++ b/tests/components/snoo/__init__.py @@ -0,0 +1,38 @@ +"""Tests for the Happiest Baby Snoo integration.""" + +from homeassistant.components.snoo.const import DOMAIN +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +def create_entry( + hass: HomeAssistant, +) -> ConfigEntry: + """Add config entry in Home Assistant.""" + entry = MockConfigEntry( + domain=DOMAIN, + title="test-username", + data={ + CONF_USERNAME: "test-username", + CONF_PASSWORD: "sample", + }, + # This is also gotten from the fake jwt + unique_id="123e4567-e89b-12d3-a456-426614174000", + version=1, + ) + entry.add_to_hass(hass) + return entry + + +async def async_init_integration(hass: HomeAssistant) -> ConfigEntry: + """Set up the Snoo integration in Home Assistant.""" + + entry = create_entry(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + return entry diff --git a/tests/components/snoo/conftest.py b/tests/components/snoo/conftest.py new file mode 100644 index 00000000000..33642e67ff5 --- /dev/null +++ b/tests/components/snoo/conftest.py @@ -0,0 +1,73 @@ +"""Common fixtures for the Happiest Baby Snoo tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest +from python_snoo.containers import SnooDevice +from python_snoo.snoo import Snoo + +from .const import MOCK_AMAZON_AUTH, MOCK_SNOO_AUTH, MOCK_SNOO_DEVICES + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.snoo.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +class MockedSnoo(Snoo): + """Mock the Snoo object.""" + + def __init__(self, email, password, clientsession) -> None: + """Set up a Mocked Snoo.""" + super().__init__(email, password, clientsession) + self.auth_error = None + + async def subscribe(self, device: SnooDevice, function): + """Mock the subscribe function.""" + return AsyncMock() + + async def send_command(self, command: str, device: SnooDevice, **kwargs): + """Mock the send command function.""" + return AsyncMock() + + async def authorize(self): + """Do normal auth flow unless error is patched.""" + if self.auth_error: + raise self.auth_error + return await super().authorize() + + def set_auth_error(self, error: Exception | None): + """Set an error for authentication.""" + self.auth_error = error + + async def auth_amazon(self): + """Mock the amazon auth.""" + return MOCK_AMAZON_AUTH + + async def auth_snoo(self, id_token): + """Mock the snoo auth.""" + return MOCK_SNOO_AUTH + + async def schedule_reauthorization(self, snoo_expiry: int): + """Mock scheduling reauth.""" + return AsyncMock() + + async def get_devices(self) -> list[SnooDevice]: + """Move getting devices.""" + return [SnooDevice.from_dict(dev) for dev in MOCK_SNOO_DEVICES] + + +@pytest.fixture(name="bypass_api") +def bypass_api() -> MockedSnoo: + """Bypass the Snoo api.""" + api = MockedSnoo("email", "password", AsyncMock()) + with ( + patch("homeassistant.components.snoo.Snoo", return_value=api), + patch("homeassistant.components.snoo.config_flow.Snoo", return_value=api), + ): + yield api diff --git a/tests/components/snoo/const.py b/tests/components/snoo/const.py new file mode 100644 index 00000000000..c5d53780fa1 --- /dev/null +++ b/tests/components/snoo/const.py @@ -0,0 +1,34 @@ +"""Snoo constants for testing.""" + +MOCK_AMAZON_AUTH = { + # This is a JWT with random values. + "AccessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhMWIyYzNkNC1lNWY2" + "LTQ3ODktOTBhYi1jZGVmMDEyMzQ1NjciLCJpc3MiOiJodHRwczovL2NvZ25pdG8taWRwLnVzLXdlc3Qt" + "Mi5hbWF6b25hd3MuY29tL3VzLXdlc3QtMl9FeGFtcGxlVXNlclBvb2xJZCIsImNsaWVudF9pZCI6ImFiY" + "2RlZmdoMTIzNDU2Nzg5MGFiY2RlZmdoMTIiLCJvcmlnaW5fanRpIjoiYjhkOWUwZjEtMmczaC00aTVqLT" + "ZrN2wtOG05bjBvMXAycTNyIiwiZXZlbnRfaWQiOiJmMGcxaDJpMy00ajVrLTZsN20tOG45by0wcDFxMnI" + "zczR0NXUiLCJ0b2tlbl91c2UiOiJhY2Nlc3MiLCJzY29wZSI6ImF3cy5jb2duaXRvLnNpZ25pbi51c2Vy" + "LmFkbWluIiwiYXV0aF90aW1lIjoxNzAwMDAwMDAwLCJleHAiOjE3MDAwMDM2MDAsImlhdCI6MTcwMDAwM" + "DAwMCwianRpIjoidjZ3N3g4eTktMHoxYS0yYjNjLTRkNWUtNmY3ZzhoOWkwajFrIiwidXNlcm5hbWUiOi" + "IxMjNlNDU2Ny1lODliLTEyZDMtYTQ1Ni00MjY2MTQxNzQwMDAifQ.zH5vy5itWot_5-rdJgYoygeKx696" + "Uge46zxXMhdn5RE", + "IdToken": "random_id", + "RefreshToken": "refresh_token", +} + +MOCK_SNOO_AUTH = {"expiresIn": 10800, "snoo": {"token": "random_snoo_token"}} + +MOCK_SNOO_DEVICES = [ + { + "serialNumber": "random_num", + "deviceType": 1, + "firmwareVersion": 1.0, + "babyIds": ["35235-211235-dfasdf-32523"], + "name": "Test Snoo", + "presence": {}, + "presenceIoT": {}, + "awsIoT": {}, + "lastSSID": {}, + "provisionedAt": "random_time", + } +] diff --git a/tests/components/snoo/test_config_flow.py b/tests/components/snoo/test_config_flow.py new file mode 100644 index 00000000000..ffdfb22142d --- /dev/null +++ b/tests/components/snoo/test_config_flow.py @@ -0,0 +1,118 @@ +"""Test the Happiest Baby Snoo config flow.""" + +from unittest.mock import AsyncMock + +import pytest +from python_snoo.exceptions import InvalidSnooAuth, SnooAuthException + +from homeassistant import config_entries +from homeassistant.components.snoo.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from . import create_entry +from .conftest import MockedSnoo + + +async def test_config_flow_success( + hass: HomeAssistant, mock_setup_entry: AsyncMock, bypass_api: MockedSnoo +) -> None: + """Test we create the entry successfully.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "test-username" + assert result["data"] == { + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + } + assert result["result"].unique_id == "123e4567-e89b-12d3-a456-426614174000" + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("exception", "error_msg"), + [ + (InvalidSnooAuth, "invalid_auth"), + (SnooAuthException, "cannot_connect"), + (Exception, "unknown"), + ], +) +async def test_form_auth_issues( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + bypass_api: MockedSnoo, + exception, + error_msg, +) -> None: + """Test we handle invalid auth.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + # Set Authorize to fail. + bypass_api.set_auth_error(exception) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + # Reset auth back to the original + bypass_api.set_auth_error(None) + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": error_msg} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "test-username" + assert result["data"] == { + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_account_already_configured( + hass: HomeAssistant, mock_setup_entry: AsyncMock, bypass_api +) -> None: + """Ensure we abort if the config flow already exists.""" + create_entry(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/snoo/test_init.py b/tests/components/snoo/test_init.py new file mode 100644 index 00000000000..06f420b6518 --- /dev/null +++ b/tests/components/snoo/test_init.py @@ -0,0 +1,14 @@ +"""Test init for Snoo.""" + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from . import async_init_integration +from .conftest import MockedSnoo + + +async def test_async_setup_entry(hass: HomeAssistant, bypass_api: MockedSnoo) -> None: + """Test a successful setup entry.""" + entry = await async_init_integration(hass) + assert len(hass.states.async_all("sensor")) == 2 + assert entry.state == ConfigEntryState.LOADED From beec67a247fbdca4b730624a2b203b02a90d1919 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Mon, 24 Feb 2025 13:52:31 +0100 Subject: [PATCH 1725/3148] Bump zwave-js-server-python to 0.60.1 (#139185) Bump zwave-js-server-python 0.60.1 --- homeassistant/components/zwave_js/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zwave_js/manifest.json b/homeassistant/components/zwave_js/manifest.json index 011776f4556..3178bdf46ad 100644 --- a/homeassistant/components/zwave_js/manifest.json +++ b/homeassistant/components/zwave_js/manifest.json @@ -9,7 +9,7 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["zwave_js_server"], - "requirements": ["pyserial==3.5", "zwave-js-server-python==0.60.0"], + "requirements": ["pyserial==3.5", "zwave-js-server-python==0.60.1"], "usb": [ { "vid": "0658", diff --git a/requirements_all.txt b/requirements_all.txt index 50c4ad93559..738f8d3d918 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3158,7 +3158,7 @@ ziggo-mediabox-xl==1.1.0 zm-py==0.5.4 # homeassistant.components.zwave_js -zwave-js-server-python==0.60.0 +zwave-js-server-python==0.60.1 # homeassistant.components.zwave_me zwave-me-ws==0.4.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a1c713424b4..0c5dfa45469 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2538,7 +2538,7 @@ zeversolar==0.3.2 zha==0.0.49 # homeassistant.components.zwave_js -zwave-js-server-python==0.60.0 +zwave-js-server-python==0.60.1 # homeassistant.components.zwave_me zwave-me-ws==0.4.3 From 0b7a023d2e079dff5cdf04571fa01a24bcd13a31 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 24 Feb 2025 13:56:06 +0100 Subject: [PATCH 1726/3148] Fix description of `cycle` field in `input_select.select_previous` action (#139032) --- homeassistant/components/input_select/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/input_select/strings.json b/homeassistant/components/input_select/strings.json index c46e3740b68..72fd50f7ec7 100644 --- a/homeassistant/components/input_select/strings.json +++ b/homeassistant/components/input_select/strings.json @@ -44,7 +44,7 @@ "fields": { "cycle": { "name": "[%key:component::input_select::services::select_next::fields::cycle::name%]", - "description": "[%key:component::input_select::services::select_next::fields::cycle::description%]" + "description": "If the option should cycle from the first to the last option on the list." } } }, From 37240e811bd2655f77365cc0612b0163ddd08919 Mon Sep 17 00:00:00 2001 From: Antonio Larrosa Date: Mon, 24 Feb 2025 13:57:21 +0100 Subject: [PATCH 1727/3148] Add melcloud standard horizontal vane modes (#136654) Co-authored-by: Joost Lekkerkerker --- homeassistant/components/melcloud/climate.py | 22 ++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/homeassistant/components/melcloud/climate.py b/homeassistant/components/melcloud/climate.py index 03bb4babf1c..9c2ee60b12c 100644 --- a/homeassistant/components/melcloud/climate.py +++ b/homeassistant/components/melcloud/climate.py @@ -152,6 +152,14 @@ class AtaDeviceClimate(MelCloudClimate): self._attr_unique_id = f"{self.api.device.serial}-{self.api.device.mac}" self._attr_device_info = self.api.device_info + async def async_added_to_hass(self) -> None: + """When entity is added to hass.""" + await super().async_added_to_hass() + + # We can only check for vane_horizontal once we fetch the device data from the cloud + if self._device.vane_horizontal: + self._attr_supported_features |= ClimateEntityFeature.SWING_HORIZONTAL_MODE + @property def extra_state_attributes(self) -> dict[str, Any] | None: """Return the optional state attributes with device specific additions.""" @@ -274,15 +282,29 @@ class AtaDeviceClimate(MelCloudClimate): """Return vertical vane position or mode.""" return self._device.vane_vertical + @property + def swing_horizontal_mode(self) -> str | None: + """Return horizontal vane position or mode.""" + return self._device.vane_horizontal + async def async_set_swing_mode(self, swing_mode: str) -> None: """Set vertical vane position or mode.""" await self.async_set_vane_vertical(swing_mode) + async def async_set_swing_horizontal_mode(self, swing_horizontal_mode: str) -> None: + """Set horizontal vane position or mode.""" + await self.async_set_vane_horizontal(swing_horizontal_mode) + @property def swing_modes(self) -> list[str] | None: """Return a list of available vertical vane positions and modes.""" return self._device.vane_vertical_positions + @property + def swing_horizontal_modes(self) -> list[str] | None: + """Return a list of available horizontal vane positions and modes.""" + return self._device.vane_horizontal_positions + async def async_turn_on(self) -> None: """Turn the entity on.""" await self._device.set({"power": True}) From f98720e525b62c7e5efbf5569ef8208a56439760 Mon Sep 17 00:00:00 2001 From: laiho-vogels <144690720+laiho-vogels@users.noreply.github.com> Date: Mon, 24 Feb 2025 13:59:34 +0100 Subject: [PATCH 1728/3148] Change code owner - MotionMount integration (#139187) --- CODEOWNERS | 4 ++-- homeassistant/components/motionmount/manifest.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 3397948d7c8..b16c1e7e1f8 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -967,8 +967,8 @@ build.json @home-assistant/supervisor /tests/components/motionblinds_ble/ @LennP @jerrybboy /homeassistant/components/motioneye/ @dermotduffy /tests/components/motioneye/ @dermotduffy -/homeassistant/components/motionmount/ @RJPoelstra -/tests/components/motionmount/ @RJPoelstra +/homeassistant/components/motionmount/ @laiho-vogels +/tests/components/motionmount/ @laiho-vogels /homeassistant/components/mqtt/ @emontnemery @jbouwh @bdraco /tests/components/mqtt/ @emontnemery @jbouwh @bdraco /homeassistant/components/msteams/ @peroyvind diff --git a/homeassistant/components/motionmount/manifest.json b/homeassistant/components/motionmount/manifest.json index 2665836ffd4..337ce776b33 100644 --- a/homeassistant/components/motionmount/manifest.json +++ b/homeassistant/components/motionmount/manifest.json @@ -1,7 +1,7 @@ { "domain": "motionmount", "name": "Vogel's MotionMount", - "codeowners": ["@RJPoelstra"], + "codeowners": ["@laiho-vogels"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/motionmount", "integration_type": "device", From 5025e311299608800d4461a8cb7055165f14456b Mon Sep 17 00:00:00 2001 From: SteveDiks <126147459+SteveDiks@users.noreply.github.com> Date: Mon, 24 Feb 2025 14:01:40 +0100 Subject: [PATCH 1729/3148] Bump Weheat to 2025.2.22 (#139186) --- homeassistant/components/weheat/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/weheat/manifest.json b/homeassistant/components/weheat/manifest.json index 1d60f66afba..a408303d062 100644 --- a/homeassistant/components/weheat/manifest.json +++ b/homeassistant/components/weheat/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["application_credentials"], "documentation": "https://www.home-assistant.io/integrations/weheat", "iot_class": "cloud_polling", - "requirements": ["weheat==2025.1.15"] + "requirements": ["weheat==2025.2.22"] } diff --git a/requirements_all.txt b/requirements_all.txt index 738f8d3d918..1ce88e0f55d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3055,7 +3055,7 @@ webio-api==0.1.11 webmin-xmlrpc==0.0.2 # homeassistant.components.weheat -weheat==2025.1.15 +weheat==2025.2.22 # homeassistant.components.whirlpool whirlpool-sixth-sense==0.18.12 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0c5dfa45469..c6588b06c41 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2459,7 +2459,7 @@ webio-api==0.1.11 webmin-xmlrpc==0.0.2 # homeassistant.components.weheat -weheat==2025.1.15 +weheat==2025.2.22 # homeassistant.components.whirlpool whirlpool-sixth-sense==0.18.12 From 51a881f3b50ae8df3ed8f5ad21fbf57089e15a31 Mon Sep 17 00:00:00 2001 From: tdfountain <174762217+tdfountain@users.noreply.github.com> Date: Mon, 24 Feb 2025 06:09:43 -0800 Subject: [PATCH 1730/3148] Add ambient temperature and humidity status sensors to NUT (#124181) Co-authored-by: J. Nick Koston --- CODEOWNERS | 4 +- homeassistant/components/nut/diagnostics.py | 4 +- homeassistant/components/nut/icons.json | 6 + homeassistant/components/nut/manifest.json | 2 +- homeassistant/components/nut/sensor.py | 23 + homeassistant/components/nut/strings.json | 2 + tests/components/nut/conftest.py | 5 + .../nut/fixtures/EATON-EPDU-G3.json | 539 ++++++++++++++++++ tests/components/nut/test_init.py | 50 +- tests/components/nut/test_sensor.py | 71 ++- tests/components/nut/util.py | 27 + 11 files changed, 724 insertions(+), 9 deletions(-) create mode 100644 tests/components/nut/conftest.py create mode 100644 tests/components/nut/fixtures/EATON-EPDU-G3.json diff --git a/CODEOWNERS b/CODEOWNERS index b16c1e7e1f8..61b2eb5b557 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1051,8 +1051,8 @@ build.json @home-assistant/supervisor /tests/components/numato/ @clssn /homeassistant/components/number/ @home-assistant/core @Shulyaka /tests/components/number/ @home-assistant/core @Shulyaka -/homeassistant/components/nut/ @bdraco @ollo69 @pestevez -/tests/components/nut/ @bdraco @ollo69 @pestevez +/homeassistant/components/nut/ @bdraco @ollo69 @pestevez @tdfountain +/tests/components/nut/ @bdraco @ollo69 @pestevez @tdfountain /homeassistant/components/nws/ @MatthewFlamm @kamiyo /tests/components/nws/ @MatthewFlamm @kamiyo /homeassistant/components/nyt_games/ @joostlek diff --git a/homeassistant/components/nut/diagnostics.py b/homeassistant/components/nut/diagnostics.py index 532e4ece76b..ec59fa65c22 100644 --- a/homeassistant/components/nut/diagnostics.py +++ b/homeassistant/components/nut/diagnostics.py @@ -39,8 +39,8 @@ async def async_get_config_entry_diagnostics( hass_device = device_registry.async_get_device( identifiers={(DOMAIN, hass_data.unique_id)} ) - if not hass_device: - return data + # Device is always created + assert hass_device is not None data["device"] = { **attr.asdict(hass_device), diff --git a/homeassistant/components/nut/icons.json b/homeassistant/components/nut/icons.json index e0f78d6400b..91df9d10553 100644 --- a/homeassistant/components/nut/icons.json +++ b/homeassistant/components/nut/icons.json @@ -1,6 +1,12 @@ { "entity": { "sensor": { + "ambient_humidity_status": { + "default": "mdi:information-outline" + }, + "ambient_temperature_status": { + "default": "mdi:information-outline" + }, "battery_alarm_threshold": { "default": "mdi:information-outline" }, diff --git a/homeassistant/components/nut/manifest.json b/homeassistant/components/nut/manifest.json index fb6c8561b25..1ee85a84caf 100644 --- a/homeassistant/components/nut/manifest.json +++ b/homeassistant/components/nut/manifest.json @@ -1,7 +1,7 @@ { "domain": "nut", "name": "Network UPS Tools (NUT)", - "codeowners": ["@bdraco", "@ollo69", "@pestevez"], + "codeowners": ["@bdraco", "@ollo69", "@pestevez", "@tdfountain"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/nut", "integration_type": "device", diff --git a/homeassistant/components/nut/sensor.py b/homeassistant/components/nut/sensor.py index 22e0496d0de..2f574ec4842 100644 --- a/homeassistant/components/nut/sensor.py +++ b/homeassistant/components/nut/sensor.py @@ -46,8 +46,17 @@ NUT_DEV_INFO_TO_DEV_INFO: dict[str, str] = { "serial": ATTR_SERIAL_NUMBER, } +AMBIENT_THRESHOLD_STATUS_OPTIONS = [ + "good", + "warning-low", + "critical-low", + "warning-high", + "critical-high", +] + _LOGGER = logging.getLogger(__name__) + SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { "ups.status.display": SensorEntityDescription( key="ups.status.display", @@ -930,6 +939,13 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, ), + "ambient.humidity.status": SensorEntityDescription( + key="ambient.humidity.status", + translation_key="ambient_humidity_status", + device_class=SensorDeviceClass.ENUM, + options=AMBIENT_THRESHOLD_STATUS_OPTIONS, + entity_category=EntityCategory.DIAGNOSTIC, + ), "ambient.temperature": SensorEntityDescription( key="ambient.temperature", translation_key="ambient_temperature", @@ -938,6 +954,13 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, ), + "ambient.temperature.status": SensorEntityDescription( + key="ambient.temperature.status", + translation_key="ambient_temperature_status", + device_class=SensorDeviceClass.ENUM, + options=AMBIENT_THRESHOLD_STATUS_OPTIONS, + entity_category=EntityCategory.DIAGNOSTIC, + ), "watts": SensorEntityDescription( key="watts", translation_key="watts", diff --git a/homeassistant/components/nut/strings.json b/homeassistant/components/nut/strings.json index 83b8d340dc1..b9485a320fb 100644 --- a/homeassistant/components/nut/strings.json +++ b/homeassistant/components/nut/strings.json @@ -80,7 +80,9 @@ "entity": { "sensor": { "ambient_humidity": { "name": "Ambient humidity" }, + "ambient_humidity_status": { "name": "Ambient humidity status" }, "ambient_temperature": { "name": "Ambient temperature" }, + "ambient_temperature_status": { "name": "Ambient temperature status" }, "battery_alarm_threshold": { "name": "Battery alarm threshold" }, "battery_capacity": { "name": "Battery capacity" }, "battery_charge": { "name": "Battery charge" }, diff --git a/tests/components/nut/conftest.py b/tests/components/nut/conftest.py new file mode 100644 index 00000000000..bcf1cb4a99f --- /dev/null +++ b/tests/components/nut/conftest.py @@ -0,0 +1,5 @@ +"""NUT session fixtures.""" + +import pytest + +pytest.register_assert_rewrite("tests.components.nut.util") diff --git a/tests/components/nut/fixtures/EATON-EPDU-G3.json b/tests/components/nut/fixtures/EATON-EPDU-G3.json new file mode 100644 index 00000000000..cd6aeb4fd92 --- /dev/null +++ b/tests/components/nut/fixtures/EATON-EPDU-G3.json @@ -0,0 +1,539 @@ +{ + "ambient.contacts.1.status": "opened", + "ambient.contacts.2.status": "opened", + "ambient.count": "0", + "ambient.humidity": "29.90", + "ambient.humidity.high": "90", + "ambient.humidity.high.critical": "90", + "ambient.humidity.high.warning": "65", + "ambient.humidity.low": "10", + "ambient.humidity.low.critical": "10", + "ambient.humidity.low.warning": "20", + "ambient.humidity.status": "good", + "ambient.present": "yes", + "ambient.temperature": "28.9", + "ambient.temperature.high": "43.30", + "ambient.temperature.high.critical": "43.30", + "ambient.temperature.high.warning": "37.70", + "ambient.temperature.low": "5", + "ambient.temperature.low.critical": "5", + "ambient.temperature.low.warning": "10", + "ambient.temperature.status": "good", + "device.contact": "Contact Name", + "device.count": "1", + "device.description": "ePDU MA 00U-C IN: TYPE 00A 0P OUT: 00xTYPE", + "device.location": "Device Location", + "device.macaddr": "00 00 00 FF FF FF ", + "device.mfr": "EATON", + "device.model": "ePDU MA 00U-C IN: TYPE 00A 0P OUT: 00xTYPE", + "device.part": "EMA000-00", + "device.serial": "A000A00000", + "device.type": "pdu", + "driver.debug": "0", + "driver.flag.allow_killpower": "0", + "driver.name": "snmp-ups", + "driver.parameter.pollinterval": "2", + "driver.parameter.port": "eaton-pdu", + "driver.parameter.synchronous": "auto", + "driver.state": "dumping", + "driver.version": "2.8.2.882-882-g63d90ebcb", + "driver.version.data": "eaton_epdu MIB 0.69", + "driver.version.internal": "1.31", + "input.current": "4.30", + "input.current.high.critical": "16", + "input.current.high.warning": "12.80", + "input.current.low.warning": "0", + "input.current.nominal": "16", + "input.current.status": "good", + "input.feed.color": "0", + "input.feed.desc": "Feed A", + "input.frequency": "60", + "input.frequency.status": "good", + "input.L1.current": "4.30", + "input.L1.current.high.critical": "16", + "input.L1.current.high.warning": "12.80", + "input.L1.current.low.warning": "0", + "input.L1.current.nominal": "16", + "input.L1.current.status": "good", + "input.L1.load": "26", + "input.L1.power": "529", + "input.L1.realpower": "482", + "input.L1.voltage": "122.91", + "input.L1.voltage.high.critical": "140", + "input.L1.voltage.high.warning": "130", + "input.L1.voltage.low.critical": "90", + "input.L1.voltage.low.warning": "95", + "input.L1.voltage.status": "good", + "input.load": "26", + "input.phases": "1", + "input.power": "532", + "input.realpower": "482", + "input.realpower.nominal": "1920", + "input.voltage": "122.91", + "input.voltage.high.critical": "140", + "input.voltage.high.warning": "130", + "input.voltage.low.critical": "90", + "input.voltage.low.warning": "95", + "input.voltage.status": "good", + "outlet.1.current": "0", + "outlet.1.current.high.critical": "16", + "outlet.1.current.high.warning": "12.80", + "outlet.1.current.low.warning": "0", + "outlet.1.current.status": "good", + "outlet.1.delay.shutdown": "120", + "outlet.1.delay.start": "1", + "outlet.1.desc": "Outlet A1", + "outlet.1.groupid": "1", + "outlet.1.id": "1", + "outlet.1.name": "A1", + "outlet.1.power": "0", + "outlet.1.realpower": "0", + "outlet.1.status": "on", + "outlet.1.switchable": "yes", + "outlet.1.timer.shutdown": "-1", + "outlet.1.timer.start": "-1", + "outlet.1.type": "nema520", + "outlet.10.current": "0.26", + "outlet.10.current.high.critical": "16", + "outlet.10.current.high.warning": "12.80", + "outlet.10.current.low.warning": "0", + "outlet.10.current.status": "good", + "outlet.10.delay.shutdown": "120", + "outlet.10.delay.start": "10", + "outlet.10.desc": "Outlet A10", + "outlet.10.groupid": "1", + "outlet.10.id": "10", + "outlet.10.name": "A10", + "outlet.10.power": "32", + "outlet.10.realpower": "15", + "outlet.10.status": "on", + "outlet.10.switchable": "yes", + "outlet.10.timer.shutdown": "-1", + "outlet.10.timer.start": "-1", + "outlet.10.type": "nema520", + "outlet.11.current": "0.24", + "outlet.11.current.high.critical": "16", + "outlet.11.current.high.warning": "12.80", + "outlet.11.current.low.warning": "0", + "outlet.11.current.status": "good", + "outlet.11.delay.shutdown": "120", + "outlet.11.delay.start": "11", + "outlet.11.desc": "Outlet A11", + "outlet.11.groupid": "1", + "outlet.11.id": "11", + "outlet.11.name": "A11", + "outlet.11.power": "29", + "outlet.11.realpower": "22", + "outlet.11.status": "on", + "outlet.11.switchable": "yes", + "outlet.11.timer.shutdown": "-1", + "outlet.11.timer.start": "-1", + "outlet.11.type": "nema520", + "outlet.12.current": "0", + "outlet.12.current.high.critical": "16", + "outlet.12.current.high.warning": "12.80", + "outlet.12.current.low.warning": "0", + "outlet.12.current.status": "good", + "outlet.12.delay.shutdown": "120", + "outlet.12.delay.start": "12", + "outlet.12.desc": "Outlet A12", + "outlet.12.groupid": "1", + "outlet.12.id": "12", + "outlet.12.name": "A12", + "outlet.12.power": "0", + "outlet.12.realpower": "0", + "outlet.12.status": "on", + "outlet.12.switchable": "yes", + "outlet.12.timer.shutdown": "-1", + "outlet.12.timer.start": "-1", + "outlet.12.type": "nema520", + "outlet.13.current": "0.23", + "outlet.13.current.high.critical": "16", + "outlet.13.current.high.warning": "12.80", + "outlet.13.current.low.warning": "0", + "outlet.13.current.status": "good", + "outlet.13.delay.shutdown": "0", + "outlet.13.delay.start": "0", + "outlet.13.desc": "Outlet A13", + "outlet.13.groupid": "1", + "outlet.13.id": "0", + "outlet.13.name": "A13", + "outlet.13.power": "27", + "outlet.13.realpower": "9", + "outlet.13.status": "on", + "outlet.13.switchable": "yes", + "outlet.13.timer.shutdown": "-1", + "outlet.13.timer.start": "-1", + "outlet.13.type": "nema520", + "outlet.14.current": "0.10", + "outlet.14.current.high.critical": "16", + "outlet.14.current.high.warning": "12.80", + "outlet.14.current.low.warning": "0", + "outlet.14.current.status": "good", + "outlet.14.delay.shutdown": "120", + "outlet.14.delay.start": "14", + "outlet.14.desc": "Outlet A14", + "outlet.14.groupid": "1", + "outlet.14.id": "14", + "outlet.14.name": "A14", + "outlet.14.power": "12", + "outlet.14.realpower": "7", + "outlet.14.status": "on", + "outlet.14.switchable": "yes", + "outlet.14.timer.shutdown": "-1", + "outlet.14.timer.start": "-1", + "outlet.14.type": "nema520", + "outlet.15.current": "0.03", + "outlet.15.current.high.critical": "16", + "outlet.15.current.high.warning": "12.80", + "outlet.15.current.low.warning": "0", + "outlet.15.current.status": "good", + "outlet.15.delay.shutdown": "120", + "outlet.15.delay.start": "15", + "outlet.15.desc": "Outlet A15", + "outlet.15.groupid": "1", + "outlet.15.id": "15", + "outlet.15.name": "A15", + "outlet.15.power": "3", + "outlet.15.realpower": "1", + "outlet.15.status": "on", + "outlet.15.switchable": "yes", + "outlet.15.timer.shutdown": "-1", + "outlet.15.timer.start": "-1", + "outlet.15.type": "nema520", + "outlet.16.current": "0.04", + "outlet.16.current.high.critical": "16", + "outlet.16.current.high.warning": "12.80", + "outlet.16.current.low.warning": "0", + "outlet.16.current.status": "good", + "outlet.16.delay.shutdown": "120", + "outlet.16.delay.start": "16", + "outlet.16.desc": "Outlet A16", + "outlet.16.groupid": "1", + "outlet.16.id": "16", + "outlet.16.name": "A16", + "outlet.16.power": "4", + "outlet.16.realpower": "1", + "outlet.16.status": "on", + "outlet.16.switchable": "yes", + "outlet.16.timer.shutdown": "-1", + "outlet.16.timer.start": "-1", + "outlet.16.type": "nema520", + "outlet.17.current": "0.19", + "outlet.17.current.high.critical": "16", + "outlet.17.current.high.warning": "12.80", + "outlet.17.current.low.warning": "0", + "outlet.17.current.status": "good", + "outlet.17.delay.shutdown": "0", + "outlet.17.delay.start": "0", + "outlet.17.desc": "Outlet A17", + "outlet.17.groupid": "1", + "outlet.17.id": "0", + "outlet.17.name": "A17", + "outlet.17.power": "23", + "outlet.17.realpower": "5", + "outlet.17.status": "on", + "outlet.17.switchable": "yes", + "outlet.17.timer.shutdown": "-1", + "outlet.17.timer.start": "-1", + "outlet.17.type": "nema520", + "outlet.18.current": "0.35", + "outlet.18.current.high.critical": "16", + "outlet.18.current.high.warning": "12.80", + "outlet.18.current.low.warning": "0", + "outlet.18.current.status": "good", + "outlet.18.delay.shutdown": "0", + "outlet.18.delay.start": "0", + "outlet.18.desc": "Outlet A18", + "outlet.18.groupid": "1", + "outlet.18.id": "0", + "outlet.18.name": "A18", + "outlet.18.power": "42", + "outlet.18.realpower": "34", + "outlet.18.status": "on", + "outlet.18.switchable": "yes", + "outlet.18.timer.shutdown": "-1", + "outlet.18.timer.start": "-1", + "outlet.18.type": "nema520", + "outlet.19.current": "0.12", + "outlet.19.current.high.critical": "16", + "outlet.19.current.high.warning": "12.80", + "outlet.19.current.low.warning": "0", + "outlet.19.current.status": "good", + "outlet.19.delay.shutdown": "0", + "outlet.19.delay.start": "0", + "outlet.19.desc": "Outlet A19", + "outlet.19.groupid": "1", + "outlet.19.id": "0", + "outlet.19.name": "A19", + "outlet.19.power": "15", + "outlet.19.realpower": "6", + "outlet.19.status": "on", + "outlet.19.switchable": "yes", + "outlet.19.timer.shutdown": "-1", + "outlet.19.timer.start": "-1", + "outlet.19.type": "nema520", + "outlet.2.current": "0.39", + "outlet.2.current.high.critical": "16", + "outlet.2.current.high.warning": "12.80", + "outlet.2.current.low.warning": "0", + "outlet.2.current.status": "good", + "outlet.2.delay.shutdown": "120", + "outlet.2.delay.start": "2", + "outlet.2.desc": "Outlet A2", + "outlet.2.groupid": "1", + "outlet.2.id": "2", + "outlet.2.name": "A2", + "outlet.2.power": "47", + "outlet.2.realpower": "43", + "outlet.2.status": "on", + "outlet.2.switchable": "yes", + "outlet.2.timer.shutdown": "-1", + "outlet.2.timer.start": "-1", + "outlet.2.type": "nema520", + "outlet.20.current": "0", + "outlet.20.current.high.critical": "16", + "outlet.20.current.high.warning": "12.80", + "outlet.20.current.low.warning": "0", + "outlet.20.current.status": "good", + "outlet.20.delay.shutdown": "120", + "outlet.20.delay.start": "20", + "outlet.20.desc": "Outlet A20", + "outlet.20.groupid": "1", + "outlet.20.id": "20", + "outlet.20.name": "A20", + "outlet.20.power": "0", + "outlet.20.realpower": "0", + "outlet.20.status": "on", + "outlet.20.switchable": "yes", + "outlet.20.timer.shutdown": "-1", + "outlet.20.timer.start": "-1", + "outlet.20.type": "nema520", + "outlet.21.current": "0", + "outlet.21.current.high.critical": "16", + "outlet.21.current.high.warning": "12.80", + "outlet.21.current.low.warning": "0", + "outlet.21.current.status": "good", + "outlet.21.delay.shutdown": "120", + "outlet.21.delay.start": "21", + "outlet.21.desc": "Outlet A21", + "outlet.21.groupid": "1", + "outlet.21.id": "21", + "outlet.21.name": "A21", + "outlet.21.power": "0", + "outlet.21.realpower": "0", + "outlet.21.status": "on", + "outlet.21.switchable": "yes", + "outlet.21.timer.shutdown": "-1", + "outlet.21.timer.start": "-1", + "outlet.21.type": "nema520", + "outlet.22.current": "0", + "outlet.22.current.high.critical": "16", + "outlet.22.current.high.warning": "12.80", + "outlet.22.current.low.warning": "0", + "outlet.22.current.status": "good", + "outlet.22.delay.shutdown": "0", + "outlet.22.delay.start": "0", + "outlet.22.desc": "Outlet A22", + "outlet.22.groupid": "1", + "outlet.22.id": "0", + "outlet.22.name": "A22", + "outlet.22.power": "0", + "outlet.22.realpower": "0", + "outlet.22.status": "on", + "outlet.22.switchable": "yes", + "outlet.22.timer.shutdown": "-1", + "outlet.22.timer.start": "-1", + "outlet.22.type": "nema520", + "outlet.23.current": "0.34", + "outlet.23.current.high.critical": "16", + "outlet.23.current.high.warning": "12.80", + "outlet.23.current.low.warning": "0", + "outlet.23.current.status": "good", + "outlet.23.delay.shutdown": "120", + "outlet.23.delay.start": "23", + "outlet.23.desc": "Outlet A23", + "outlet.23.groupid": "1", + "outlet.23.id": "23", + "outlet.23.name": "A23", + "outlet.23.power": "41", + "outlet.23.realpower": "39", + "outlet.23.status": "on", + "outlet.23.switchable": "yes", + "outlet.23.timer.shutdown": "-1", + "outlet.23.timer.start": "-1", + "outlet.23.type": "nema520", + "outlet.24.current": "0.19", + "outlet.24.current.high.critical": "16", + "outlet.24.current.high.warning": "12.80", + "outlet.24.current.low.warning": "0", + "outlet.24.current.status": "good", + "outlet.24.delay.shutdown": "0", + "outlet.24.delay.start": "0", + "outlet.24.desc": "Outlet A24", + "outlet.24.groupid": "1", + "outlet.24.id": "0", + "outlet.24.name": "A24", + "outlet.24.power": "23", + "outlet.24.realpower": "11", + "outlet.24.status": "on", + "outlet.24.switchable": "yes", + "outlet.24.timer.shutdown": "-1", + "outlet.24.timer.start": "-1", + "outlet.24.type": "nema520", + "outlet.3.current": "0.46", + "outlet.3.current.high.critical": "16", + "outlet.3.current.high.warning": "12.80", + "outlet.3.current.low.warning": "0", + "outlet.3.current.status": "good", + "outlet.3.delay.shutdown": "120", + "outlet.3.delay.start": "3", + "outlet.3.desc": "Outlet A3", + "outlet.3.groupid": "1", + "outlet.3.id": "3", + "outlet.3.name": "A3", + "outlet.3.power": "56", + "outlet.3.realpower": "53", + "outlet.3.status": "on", + "outlet.3.switchable": "yes", + "outlet.3.timer.shutdown": "-1", + "outlet.3.timer.start": "-1", + "outlet.3.type": "nema520", + "outlet.4.current": "0.44", + "outlet.4.current.high.critical": "16", + "outlet.4.current.high.warning": "12.80", + "outlet.4.current.low.warning": "0", + "outlet.4.current.status": "good", + "outlet.4.delay.shutdown": "120", + "outlet.4.delay.start": "4", + "outlet.4.desc": "Outlet A4", + "outlet.4.groupid": "1", + "outlet.4.id": "4", + "outlet.4.name": "A4", + "outlet.4.power": "53", + "outlet.4.realpower": "48", + "outlet.4.status": "on", + "outlet.4.switchable": "yes", + "outlet.4.timer.shutdown": "-1", + "outlet.4.timer.start": "-1", + "outlet.4.type": "nema520", + "outlet.5.current": "0.43", + "outlet.5.current.high.critical": "16", + "outlet.5.current.high.warning": "12.80", + "outlet.5.current.low.warning": "0", + "outlet.5.current.status": "good", + "outlet.5.delay.shutdown": "120", + "outlet.5.delay.start": "5", + "outlet.5.desc": "Outlet A5", + "outlet.5.groupid": "1", + "outlet.5.id": "5", + "outlet.5.name": "A5", + "outlet.5.power": "52", + "outlet.5.realpower": "48", + "outlet.5.status": "on", + "outlet.5.switchable": "yes", + "outlet.5.timer.shutdown": "-1", + "outlet.5.timer.start": "-1", + "outlet.5.type": "nema520", + "outlet.6.current": "1.07", + "outlet.6.current.high.critical": "16", + "outlet.6.current.high.warning": "12.80", + "outlet.6.current.low.warning": "0", + "outlet.6.current.status": "good", + "outlet.6.delay.shutdown": "120", + "outlet.6.delay.start": "6", + "outlet.6.desc": "Outlet A6", + "outlet.6.groupid": "1", + "outlet.6.id": "6", + "outlet.6.name": "A6", + "outlet.6.power": "131", + "outlet.6.realpower": "118", + "outlet.6.status": "on", + "outlet.6.switchable": "yes", + "outlet.6.timer.shutdown": "-1", + "outlet.6.timer.start": "-1", + "outlet.6.type": "nema520", + "outlet.7.current": "0", + "outlet.7.current.high.critical": "16", + "outlet.7.current.high.warning": "12.80", + "outlet.7.current.low.warning": "0", + "outlet.7.current.status": "good", + "outlet.7.delay.shutdown": "120", + "outlet.7.delay.start": "7", + "outlet.7.desc": "Outlet A7", + "outlet.7.groupid": "1", + "outlet.7.id": "7", + "outlet.7.name": "A7", + "outlet.7.power": "0", + "outlet.7.realpower": "0", + "outlet.7.status": "on", + "outlet.7.switchable": "yes", + "outlet.7.timer.shutdown": "-1", + "outlet.7.timer.start": "-1", + "outlet.7.type": "nema520", + "outlet.8.current": "0", + "outlet.8.current.high.critical": "16", + "outlet.8.current.high.warning": "12.80", + "outlet.8.current.low.warning": "0", + "outlet.8.current.status": "good", + "outlet.8.delay.shutdown": "120", + "outlet.8.delay.start": "8", + "outlet.8.desc": "Outlet A8", + "outlet.8.groupid": "1", + "outlet.8.id": "8", + "outlet.8.name": "A8", + "outlet.8.power": "0", + "outlet.8.realpower": "0", + "outlet.8.status": "on", + "outlet.8.switchable": "yes", + "outlet.8.timer.shutdown": "-1", + "outlet.8.timer.start": "-1", + "outlet.8.type": "nema520", + "outlet.9.current": "0", + "outlet.9.current.high.critical": "16", + "outlet.9.current.high.warning": "12.80", + "outlet.9.current.low.warning": "0", + "outlet.9.current.status": "good", + "outlet.9.delay.shutdown": "120", + "outlet.9.delay.start": "9", + "outlet.9.desc": "Outlet A9", + "outlet.9.groupid": "1", + "outlet.9.id": "9", + "outlet.9.name": "A9", + "outlet.9.power": "0", + "outlet.9.realpower": "0", + "outlet.9.status": "on", + "outlet.9.switchable": "yes", + "outlet.9.timer.shutdown": "-1", + "outlet.9.timer.start": "-1", + "outlet.9.type": "nema520", + "outlet.count": "24", + "outlet.current": "43.05", + "outlet.desc": "All outlets", + "outlet.frequency": "60", + "outlet.group.1.color": "16051527", + "outlet.group.1.count": "24", + "outlet.group.1.desc": "Section A", + "outlet.group.1.id": "1", + "outlet.group.1.input": "1", + "outlet.group.1.name": "A", + "outlet.group.1.phase": "1", + "outlet.group.1.status": "on", + "outlet.group.1.type": "outlet-section", + "outlet.group.1.voltage": "122.83", + "outlet.group.1.voltage.high.critical": "140", + "outlet.group.1.voltage.high.warning": "130", + "outlet.group.1.voltage.low.critical": "90", + "outlet.group.1.voltage.low.warning": "95", + "outlet.group.1.voltage.status": "good", + "outlet.group.count": "1", + "outlet.id": "0", + "outlet.switchable": "yes", + "outlet.voltage": "122.91", + "ups.firmware": "05.01.0002", + "ups.mfr": "EATON", + "ups.model": "ePDU MA 00U-C IN: TYPE 00A 0P OUT: 00xTYPE", + "ups.serial": "A000A00000", + "ups.status": "", + "ups.type": "pdu" +} diff --git a/tests/components/nut/test_init.py b/tests/components/nut/test_init.py index d5d85daa336..0585696cef2 100644 --- a/tests/components/nut/test_init.py +++ b/tests/components/nut/test_init.py @@ -1,12 +1,19 @@ """Test init of Nut integration.""" +from copy import deepcopy from unittest.mock import patch from aionut import NUTError, NUTLoginError from homeassistant.components.nut.const import DOMAIN from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import CONF_HOST, CONF_PORT, STATE_UNAVAILABLE +from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_PORT, + CONF_USERNAME, + STATE_UNAVAILABLE, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr @@ -147,3 +154,44 @@ async def test_device_location(hass: HomeAssistant) -> None: assert device_entry is not None assert device_entry.suggested_area == mock_device_location + + +async def test_update_options(hass: HomeAssistant) -> None: + """Test update options triggers reload.""" + mock_pynut = _get_mock_nutclient( + list_ups={"ups1": "UPS 1"}, list_vars={"ups.status": "OL"} + ) + + with patch( + "homeassistant.components.nut.AIONUTClient", + return_value=mock_pynut, + ): + mock_config_entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "mock", + CONF_PASSWORD: "somepassword", + CONF_PORT: "mock", + CONF_USERNAME: "someuser", + }, + options={ + "device_options": { + "fake_option": "fake_option_value", + }, + }, + ) + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert mock_config_entry.state is ConfigEntryState.LOADED + + new_options = deepcopy(dict(mock_config_entry.options)) + new_options["device_options"].clear() + hass.config_entries.async_update_entry(mock_config_entry, options=new_options) + await hass.async_block_till_done() + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert mock_config_entry.state is ConfigEntryState.LOADED diff --git a/tests/components/nut/test_sensor.py b/tests/components/nut/test_sensor.py index afe57631910..eb171c39011 100644 --- a/tests/components/nut/test_sensor.py +++ b/tests/components/nut/test_sensor.py @@ -5,17 +5,23 @@ from unittest.mock import patch import pytest from homeassistant.components.nut.const import DOMAIN +from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass from homeassistant.const import ( CONF_HOST, CONF_PORT, CONF_RESOURCES, PERCENTAGE, STATE_UNKNOWN, + UnitOfElectricPotential, ) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from .util import _get_mock_nutclient, async_init_integration +from .util import ( + _get_mock_nutclient, + _test_sensor_and_attributes, + async_init_integration, +) from tests.common import MockConfigEntry @@ -32,7 +38,7 @@ from tests.common import MockConfigEntry "blazer_usb", ], ) -async def test_devices( +async def test_ups_devices( hass: HomeAssistant, entity_registry: er.EntityRegistry, model: str ) -> None: """Test creation of device sensors.""" @@ -67,7 +73,7 @@ async def test_devices( ), ], ) -async def test_devices_with_unique_ids( +async def test_ups_devices_with_unique_ids( hass: HomeAssistant, entity_registry: er.EntityRegistry, model: str, unique_id: str ) -> None: """Test creation of device sensors with unique ids.""" @@ -92,6 +98,65 @@ async def test_devices_with_unique_ids( ) +@pytest.mark.parametrize( + ("model", "unique_id_base"), + [ + ( + "EATON-EPDU-G3", + "EATON_ePDU MA 00U-C IN: TYPE 00A 0P OUT: 00xTYPE_A000A00000_", + ), + ], +) +async def test_pdu_devices_with_unique_ids( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + model: str, + unique_id_base: str, +) -> None: + """Test creation of device sensors with unique ids.""" + + await _test_sensor_and_attributes( + hass, + entity_registry, + model, + unique_id=f"{unique_id_base}input.voltage", + device_id="sensor.ups1_input_voltage", + state_value="122.91", + expected_attributes={ + "device_class": SensorDeviceClass.VOLTAGE, + "state_class": SensorStateClass.MEASUREMENT, + "friendly_name": "Ups1 Input voltage", + "unit_of_measurement": UnitOfElectricPotential.VOLT, + }, + ) + + await _test_sensor_and_attributes( + hass, + entity_registry, + model, + unique_id=f"{unique_id_base}ambient.humidity.status", + device_id="sensor.ups1_ambient_humidity_status", + state_value="good", + expected_attributes={ + "device_class": SensorDeviceClass.ENUM, + "friendly_name": "Ups1 Ambient humidity status", + }, + ) + + await _test_sensor_and_attributes( + hass, + entity_registry, + model, + unique_id=f"{unique_id_base}ambient.temperature.status", + device_id="sensor.ups1_ambient_temperature_status", + state_value="good", + expected_attributes={ + "device_class": SensorDeviceClass.ENUM, + "friendly_name": "Ups1 Ambient temperature status", + }, + ) + + async def test_state_sensors(hass: HomeAssistant) -> None: """Test creation of status display sensors.""" entry = MockConfigEntry( diff --git a/tests/components/nut/util.py b/tests/components/nut/util.py index b6c9cffd390..bd82ffdd6b4 100644 --- a/tests/components/nut/util.py +++ b/tests/components/nut/util.py @@ -6,6 +6,7 @@ from unittest.mock import AsyncMock, MagicMock, patch from homeassistant.components.nut.const import DOMAIN from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from tests.common import MockConfigEntry, load_fixture @@ -79,3 +80,29 @@ async def async_init_integration( await hass.async_block_till_done() return entry + + +async def _test_sensor_and_attributes( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + model: str, + unique_id: str, + device_id: str, + state_value: str, + expected_attributes: dict, +) -> None: + """Test creation of device sensors with unique ids.""" + + await async_init_integration(hass, model) + entry = entity_registry.async_get(device_id) + assert entry + assert entry.unique_id == unique_id + + state = hass.states.get(device_id) + assert state.state == state_value + + # Only test for a subset of attributes in case + # HA changes the implementation and a new one appears + assert all( + state.attributes[key] == attr for key, attr in expected_attributes.items() + ) From 377da5f9547fe2a5c825e7fd28efdbe5a396e993 Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Mon, 24 Feb 2025 17:11:07 +0200 Subject: [PATCH 1731/3148] Update LG webOS TV diagnostics to use tv_info and tv_state dictionaries (#139189) --- .../components/webostv/diagnostics.py | 11 +- .../webostv/snapshots/test_diagnostics.ambr | 101 +++++++++++------- 2 files changed, 66 insertions(+), 46 deletions(-) diff --git a/homeassistant/components/webostv/diagnostics.py b/homeassistant/components/webostv/diagnostics.py index 393a6a066ff..e4ea38064a8 100644 --- a/homeassistant/components/webostv/diagnostics.py +++ b/homeassistant/components/webostv/diagnostics.py @@ -32,15 +32,8 @@ async def async_get_config_entry_diagnostics( client_data = { "is_registered": client.is_registered(), "is_connected": client.is_connected(), - "current_app_id": client.tv_state.current_app_id, - "current_channel": client.tv_state.current_channel, - "apps": client.tv_state.apps, - "inputs": client.tv_state.inputs, - "system_info": client.tv_info.system, - "software_info": client.tv_info.software, - "hello_info": client.tv_info.hello, - "sound_output": client.tv_state.sound_output, - "is_on": client.tv_state.is_on, + "tv_info": client.tv_info.__dict__, + "tv_state": client.tv_state.__dict__, } return async_redact_data( diff --git a/tests/components/webostv/snapshots/test_diagnostics.ambr b/tests/components/webostv/snapshots/test_diagnostics.ambr index 030554b963a..2febee15deb 100644 --- a/tests/components/webostv/snapshots/test_diagnostics.ambr +++ b/tests/components/webostv/snapshots/test_diagnostics.ambr @@ -2,46 +2,73 @@ # name: test_diagnostics dict({ 'client': dict({ - 'apps': dict({ - 'com.webos.app.livetv': dict({ - 'icon': '**REDACTED**', - 'id': 'com.webos.app.livetv', - 'largeIcon': '**REDACTED**', - 'title': 'Live TV', - }), - }), - 'current_app_id': 'com.webos.app.livetv', - 'current_channel': dict({ - 'channelId': 'ch1id', - 'channelName': 'Channel 1', - 'channelNumber': '1', - }), - 'hello_info': dict({ - 'deviceUUID': '**REDACTED**', - }), - 'inputs': dict({ - 'in1': dict({ - 'appId': 'app0', - 'id': 'in1', - 'label': 'Input01', - }), - 'in2': dict({ - 'appId': 'app1', - 'id': 'in2', - 'label': 'Input02', - }), - }), 'is_connected': True, - 'is_on': True, 'is_registered': True, - 'software_info': dict({ - 'major_ver': 'major', - 'minor_ver': 'minor', + 'tv_info': dict({ + 'hello': dict({ + 'deviceUUID': '**REDACTED**', + }), + 'software': dict({ + 'major_ver': 'major', + 'minor_ver': 'minor', + }), + 'system': dict({ + 'modelName': 'MODEL', + 'serialNumber': '1234567890', + }), }), - 'sound_output': 'speaker', - 'system_info': dict({ - 'modelName': 'MODEL', - 'serialNumber': '1234567890', + 'tv_state': dict({ + 'apps': dict({ + 'com.webos.app.livetv': dict({ + 'icon': '**REDACTED**', + 'id': 'com.webos.app.livetv', + 'largeIcon': '**REDACTED**', + 'title': 'Live TV', + }), + }), + 'channel_info': None, + 'channels': list([ + dict({ + 'channelId': 'ch1id', + 'channelName': 'Channel 1', + 'channelNumber': '1', + }), + dict({ + 'channelId': 'ch2id', + 'channelName': 'Channel Name 2', + 'channelNumber': '20', + }), + ]), + 'current_app_id': 'com.webos.app.livetv', + 'current_channel': dict({ + 'channelId': 'ch1id', + 'channelName': 'Channel 1', + 'channelNumber': '1', + }), + 'inputs': dict({ + 'in1': dict({ + 'appId': 'app0', + 'id': 'in1', + 'label': 'Input01', + }), + 'in2': dict({ + 'appId': 'app1', + 'id': 'in2', + 'label': 'Input02', + }), + }), + 'is_on': True, + 'is_screen_on': False, + 'media_state': list([ + dict({ + 'playState': '', + }), + ]), + 'muted': False, + 'power_state': dict({ + }), + 'sound_output': 'speaker', + 'volume': 37, }), }), 'entry': dict({ From 351e594fe4cb6ec1b9f597e89c1b901910414a2a Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 24 Feb 2025 17:14:47 +0100 Subject: [PATCH 1732/3148] Add flag to backup store to track backup wizard completion (#138368) * Add flag to backup store to track backup wizard completion * Add comment * Update hassio tests * Update tests --------- Co-authored-by: Martin Hjelmare --- homeassistant/components/backup/config.py | 8 + homeassistant/components/backup/store.py | 7 +- homeassistant/components/backup/websocket.py | 1 + .../backup/snapshots/test_store.ambr | 212 ++++++++++- .../backup/snapshots/test_websocket.ambr | 345 +++++++++++++++++- tests/components/backup/test_store.py | 75 ++++ tests/components/backup/test_websocket.py | 26 ++ .../hassio/snapshots/test_backup.ambr | 3 + tests/components/hassio/test_backup.py | 2 + 9 files changed, 658 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/backup/config.py b/homeassistant/components/backup/config.py index f34c1b8887d..65f9f4789a6 100644 --- a/homeassistant/components/backup/config.py +++ b/homeassistant/components/backup/config.py @@ -39,6 +39,7 @@ class StoredBackupConfig(TypedDict): """Represent the stored backup config.""" agents: dict[str, StoredAgentConfig] + automatic_backups_configured: bool create_backup: StoredCreateBackupConfig last_attempted_automatic_backup: str | None last_completed_automatic_backup: str | None @@ -51,6 +52,7 @@ class BackupConfigData: """Represent loaded backup config data.""" agents: dict[str, AgentConfig] + automatic_backups_configured: bool # only used by frontend create_backup: CreateBackupConfig last_attempted_automatic_backup: datetime | None = None last_completed_automatic_backup: datetime | None = None @@ -88,6 +90,7 @@ class BackupConfigData: agent_id: AgentConfig(protected=agent_data["protected"]) for agent_id, agent_data in data["agents"].items() }, + automatic_backups_configured=data["automatic_backups_configured"], create_backup=CreateBackupConfig( agent_ids=data["create_backup"]["agent_ids"], include_addons=data["create_backup"]["include_addons"], @@ -127,6 +130,7 @@ class BackupConfigData: agents={ agent_id: agent.to_dict() for agent_id, agent in self.agents.items() }, + automatic_backups_configured=self.automatic_backups_configured, create_backup=self.create_backup.to_dict(), last_attempted_automatic_backup=last_attempted, last_completed_automatic_backup=last_completed, @@ -142,6 +146,7 @@ class BackupConfig: """Initialize backup config.""" self.data = BackupConfigData( agents={}, + automatic_backups_configured=False, create_backup=CreateBackupConfig(), retention=RetentionConfig(), schedule=BackupSchedule(), @@ -159,6 +164,7 @@ class BackupConfig: self, *, agents: dict[str, AgentParametersDict] | UndefinedType = UNDEFINED, + automatic_backups_configured: bool | UndefinedType = UNDEFINED, create_backup: CreateBackupParametersDict | UndefinedType = UNDEFINED, retention: RetentionParametersDict | UndefinedType = UNDEFINED, schedule: ScheduleParametersDict | UndefinedType = UNDEFINED, @@ -172,6 +178,8 @@ class BackupConfig: self.data.agents[agent_id] = replace( self.data.agents[agent_id], **agent_config ) + if automatic_backups_configured is not UNDEFINED: + self.data.automatic_backups_configured = automatic_backups_configured if create_backup is not UNDEFINED: self.data.create_backup = replace(self.data.create_backup, **create_backup) if retention is not UNDEFINED: diff --git a/homeassistant/components/backup/store.py b/homeassistant/components/backup/store.py index 8287080b5a2..883447853e6 100644 --- a/homeassistant/components/backup/store.py +++ b/homeassistant/components/backup/store.py @@ -16,7 +16,7 @@ if TYPE_CHECKING: STORE_DELAY_SAVE = 30 STORAGE_KEY = DOMAIN STORAGE_VERSION = 1 -STORAGE_VERSION_MINOR = 4 +STORAGE_VERSION_MINOR = 5 class StoredBackupData(TypedDict): @@ -67,6 +67,11 @@ class _BackupStore(Store[StoredBackupData]): data["config"]["retention"]["copies"] = None if data["config"]["retention"]["days"] == 0: data["config"]["retention"]["days"] = None + if old_minor_version < 5: + # Version 1.5 adds automatic_backups_configured + data["config"]["automatic_backups_configured"] = ( + data["config"]["create_backup"]["password"] is not None + ) # Note: We allow reading data with major version 2. # Reject if major version is higher than 2. diff --git a/homeassistant/components/backup/websocket.py b/homeassistant/components/backup/websocket.py index b36343c7634..5084f904ec6 100644 --- a/homeassistant/components/backup/websocket.py +++ b/homeassistant/components/backup/websocket.py @@ -352,6 +352,7 @@ async def handle_config_info( { vol.Required("type"): "backup/config/update", vol.Optional("agents"): vol.Schema({str: {"protected": bool}}), + vol.Optional("automatic_backups_configured"): bool, vol.Optional("create_backup"): vol.Schema( { vol.Optional("agent_ids"): vol.All([str], vol.Unique()), diff --git a/tests/components/backup/snapshots/test_store.ambr b/tests/components/backup/snapshots/test_store.ambr index 04f88b84a97..41778322825 100644 --- a/tests/components/backup/snapshots/test_store.ambr +++ b/tests/components/backup/snapshots/test_store.ambr @@ -13,6 +13,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -39,7 +40,7 @@ }), }), 'key': 'backup', - 'minor_version': 4, + 'minor_version': 5, 'version': 1, }) # --- @@ -57,6 +58,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -84,7 +86,7 @@ }), }), 'key': 'backup', - 'minor_version': 4, + 'minor_version': 5, 'version': 1, }) # --- @@ -102,6 +104,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -128,7 +131,7 @@ }), }), 'key': 'backup', - 'minor_version': 4, + 'minor_version': 5, 'version': 1, }) # --- @@ -146,6 +149,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -173,7 +177,7 @@ }), }), 'key': 'backup', - 'minor_version': 4, + 'minor_version': 5, 'version': 1, }) # --- @@ -194,6 +198,7 @@ 'protected': True, }), }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -220,7 +225,7 @@ }), }), 'key': 'backup', - 'minor_version': 4, + 'minor_version': 5, 'version': 1, }) # --- @@ -241,6 +246,7 @@ 'protected': True, }), }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -268,7 +274,201 @@ }), }), 'key': 'backup', - 'minor_version': 4, + 'minor_version': 5, + 'version': 1, + }) +# --- +# name: test_store_migration[store_data3] + dict({ + 'data': dict({ + 'backups': list([ + dict({ + 'backup_id': 'abc123', + 'failed_agent_ids': list([ + 'test.remote', + ]), + }), + ]), + 'config': dict({ + 'agents': dict({ + 'test.remote': dict({ + 'protected': True, + }), + }), + 'automatic_backups_configured': False, + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'state': 'never', + 'time': None, + }), + }), + }), + 'key': 'backup', + 'minor_version': 5, + 'version': 1, + }) +# --- +# name: test_store_migration[store_data3].1 + dict({ + 'data': dict({ + 'backups': list([ + dict({ + 'backup_id': 'abc123', + 'failed_agent_ids': list([ + 'test.remote', + ]), + }), + ]), + 'config': dict({ + 'agents': dict({ + 'test.remote': dict({ + 'protected': True, + }), + }), + 'automatic_backups_configured': False, + 'create_backup': dict({ + 'agent_ids': list([ + 'test-agent', + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'state': 'never', + 'time': None, + }), + }), + }), + 'key': 'backup', + 'minor_version': 5, + 'version': 1, + }) +# --- +# name: test_store_migration[store_data4] + dict({ + 'data': dict({ + 'backups': list([ + dict({ + 'backup_id': 'abc123', + 'failed_agent_ids': list([ + 'test.remote', + ]), + }), + ]), + 'config': dict({ + 'agents': dict({ + 'test.remote': dict({ + 'protected': True, + }), + }), + 'automatic_backups_configured': True, + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': 'hunter2', + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'state': 'never', + 'time': None, + }), + }), + }), + 'key': 'backup', + 'minor_version': 5, + 'version': 1, + }) +# --- +# name: test_store_migration[store_data4].1 + dict({ + 'data': dict({ + 'backups': list([ + dict({ + 'backup_id': 'abc123', + 'failed_agent_ids': list([ + 'test.remote', + ]), + }), + ]), + 'config': dict({ + 'agents': dict({ + 'test.remote': dict({ + 'protected': True, + }), + }), + 'automatic_backups_configured': True, + 'create_backup': dict({ + 'agent_ids': list([ + 'test-agent', + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': 'hunter2', + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'state': 'never', + 'time': None, + }), + }), + }), + 'key': 'backup', + 'minor_version': 5, 'version': 1, }) # --- diff --git a/tests/components/backup/snapshots/test_websocket.ambr b/tests/components/backup/snapshots/test_websocket.ambr index 742fec4c3f3..c100a87e8cc 100644 --- a/tests/components/backup/snapshots/test_websocket.ambr +++ b/tests/components/backup/snapshots/test_websocket.ambr @@ -258,6 +258,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -295,6 +296,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -344,6 +346,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': True, 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -382,6 +385,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -420,6 +424,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -459,6 +464,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -497,6 +503,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -543,6 +550,7 @@ 'protected': False, }), }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -583,6 +591,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': True, 'create_backup': dict({ 'agent_ids': list([ 'hassio.local', @@ -623,6 +632,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': True, 'create_backup': dict({ 'agent_ids': list([ 'hassio.local', @@ -662,6 +672,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -699,6 +710,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -744,6 +756,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': True, 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -782,6 +795,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -820,6 +834,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -859,6 +874,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -897,6 +913,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -943,6 +960,7 @@ 'protected': False, }), }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -983,6 +1001,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': True, 'create_backup': dict({ 'agent_ids': list([ 'backup.local', @@ -1022,6 +1041,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': True, 'create_backup': dict({ 'agent_ids': list([ 'backup.local', @@ -1061,6 +1081,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -1098,6 +1119,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -1137,6 +1159,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -1164,7 +1187,7 @@ }), }), 'key': 'backup', - 'minor_version': 4, + 'minor_version': 5, 'version': 1, }) # --- @@ -1175,6 +1198,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -1212,6 +1236,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -1251,6 +1276,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -1278,7 +1304,7 @@ }), }), 'key': 'backup', - 'minor_version': 4, + 'minor_version': 5, 'version': 1, }) # --- @@ -1289,6 +1315,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -1326,6 +1353,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -1365,6 +1393,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -1392,7 +1421,7 @@ }), }), 'key': 'backup', - 'minor_version': 4, + 'minor_version': 5, 'version': 1, }) # --- @@ -1403,6 +1432,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -1446,6 +1476,7 @@ 'protected': False, }), }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -1490,6 +1521,7 @@ 'protected': False, }), }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -1516,7 +1548,7 @@ }), }), 'key': 'backup', - 'minor_version': 4, + 'minor_version': 5, 'version': 1, }) # --- @@ -1527,6 +1559,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -1570,6 +1603,7 @@ 'protected': False, }), }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -1613,6 +1647,7 @@ 'protected': True, }), }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -1657,6 +1692,7 @@ 'protected': True, }), }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -1683,7 +1719,237 @@ }), }), 'key': 'backup', - 'minor_version': 4, + 'minor_version': 5, + 'version': 1, + }) +# --- +# name: test_config_update[commands14] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + }), + 'automatic_backups_configured': False, + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update[commands14].1 + dict({ + 'id': 3, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + }), + 'automatic_backups_configured': False, + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update[commands14].2 + dict({ + 'data': dict({ + 'backups': list([ + ]), + 'config': dict({ + 'agents': dict({ + }), + 'automatic_backups_configured': False, + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'state': 'never', + 'time': None, + }), + }), + }), + 'key': 'backup', + 'minor_version': 5, + 'version': 1, + }) +# --- +# name: test_config_update[commands15] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + }), + 'automatic_backups_configured': False, + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update[commands15].1 + dict({ + 'id': 3, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + }), + 'automatic_backups_configured': True, + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update[commands15].2 + dict({ + 'data': dict({ + 'backups': list([ + ]), + 'config': dict({ + 'agents': dict({ + }), + 'automatic_backups_configured': True, + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'state': 'never', + 'time': None, + }), + }), + }), + 'key': 'backup', + 'minor_version': 5, 'version': 1, }) # --- @@ -1694,6 +1960,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -1731,6 +1998,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -1770,6 +2038,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -1797,7 +2066,7 @@ }), }), 'key': 'backup', - 'minor_version': 4, + 'minor_version': 5, 'version': 1, }) # --- @@ -1808,6 +2077,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -1845,6 +2115,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -1885,6 +2156,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -1913,7 +2185,7 @@ }), }), 'key': 'backup', - 'minor_version': 4, + 'minor_version': 5, 'version': 1, }) # --- @@ -1924,6 +2196,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -1961,6 +2234,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -2000,6 +2274,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -2027,7 +2302,7 @@ }), }), 'key': 'backup', - 'minor_version': 4, + 'minor_version': 5, 'version': 1, }) # --- @@ -2038,6 +2313,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -2075,6 +2351,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -2116,6 +2393,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -2145,7 +2423,7 @@ }), }), 'key': 'backup', - 'minor_version': 4, + 'minor_version': 5, 'version': 1, }) # --- @@ -2156,6 +2434,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -2193,6 +2472,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -2236,6 +2516,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -2267,7 +2548,7 @@ }), }), 'key': 'backup', - 'minor_version': 4, + 'minor_version': 5, 'version': 1, }) # --- @@ -2278,6 +2559,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -2315,6 +2597,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -2354,6 +2637,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -2381,7 +2665,7 @@ }), }), 'key': 'backup', - 'minor_version': 4, + 'minor_version': 5, 'version': 1, }) # --- @@ -2392,6 +2676,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -2429,6 +2714,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -2468,6 +2754,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -2495,7 +2782,7 @@ }), }), 'key': 'backup', - 'minor_version': 4, + 'minor_version': 5, 'version': 1, }) # --- @@ -2506,6 +2793,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -2543,6 +2831,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -2582,6 +2871,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -2609,7 +2899,7 @@ }), }), 'key': 'backup', - 'minor_version': 4, + 'minor_version': 5, 'version': 1, }) # --- @@ -2620,6 +2910,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -2657,6 +2948,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -2696,6 +2988,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ 'test-agent', @@ -2723,7 +3016,7 @@ }), }), 'key': 'backup', - 'minor_version': 4, + 'minor_version': 5, 'version': 1, }) # --- @@ -2734,6 +3027,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -2771,6 +3065,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -2808,6 +3103,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -2845,6 +3141,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -2882,6 +3179,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -2919,6 +3217,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -2956,6 +3255,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -2993,6 +3293,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -3030,6 +3331,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -3067,6 +3369,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -3104,6 +3407,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -3141,6 +3445,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -3178,6 +3483,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -3215,6 +3521,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -3252,6 +3559,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -3289,6 +3597,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -3326,6 +3635,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -3363,6 +3673,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -3400,6 +3711,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -3437,6 +3749,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -3474,6 +3787,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -3511,6 +3825,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -3548,6 +3863,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -3585,6 +3901,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), diff --git a/tests/components/backup/test_store.py b/tests/components/backup/test_store.py index eff53bda777..0d29bb2006a 100644 --- a/tests/components/backup/test_store.py +++ b/tests/components/backup/test_store.py @@ -99,6 +99,7 @@ def mock_delay_save() -> Generator[None]: ], "config": { "agents": {"test.remote": {"protected": True}}, + "automatic_backups_configured": False, "create_backup": { "agent_ids": [], "include_addons": None, @@ -125,6 +126,80 @@ def mock_delay_save() -> Generator[None]: "key": DOMAIN, "version": 2, }, + { + "data": { + "backups": [ + { + "backup_id": "abc123", + "failed_agent_ids": ["test.remote"], + } + ], + "config": { + "agents": {"test.remote": {"protected": True}}, + "create_backup": { + "agent_ids": [], + "include_addons": None, + "include_all_addons": False, + "include_database": True, + "include_folders": None, + "name": None, + "password": None, + }, + "last_attempted_automatic_backup": None, + "last_completed_automatic_backup": None, + "retention": { + "copies": None, + "days": None, + }, + "schedule": { + "days": [], + "recurrence": "never", + "state": "never", + "time": None, + }, + }, + }, + "key": DOMAIN, + "minor_version": 4, + "version": 1, + }, + { + "data": { + "backups": [ + { + "backup_id": "abc123", + "failed_agent_ids": ["test.remote"], + } + ], + "config": { + "agents": {"test.remote": {"protected": True}}, + "create_backup": { + "agent_ids": [], + "include_addons": None, + "include_all_addons": False, + "include_database": True, + "include_folders": None, + "name": None, + "password": "hunter2", + }, + "last_attempted_automatic_backup": None, + "last_completed_automatic_backup": None, + "retention": { + "copies": None, + "days": None, + }, + "schedule": { + "days": [], + "recurrence": "never", + "state": "never", + "time": None, + }, + }, + }, + "key": DOMAIN, + "minor_version": 4, + "version": 1, + }, ], ) async def test_store_migration( diff --git a/tests/components/backup/test_websocket.py b/tests/components/backup/test_websocket.py index 6d5adb32c01..6605674a679 100644 --- a/tests/components/backup/test_websocket.py +++ b/tests/components/backup/test_websocket.py @@ -55,6 +55,7 @@ DEFAULT_STORAGE_DATA: dict[str, Any] = { "backups": [], "config": { "agents": {}, + "automatic_backups_configured": False, "create_backup": { "agent_ids": [], "include_addons": None, @@ -907,6 +908,7 @@ async def test_agents_info( "backups": [], "config": { "agents": {}, + "automatic_backups_configured": False, "create_backup": { "agent_ids": ["test-agent"], "include_addons": ["test-addon"], @@ -938,6 +940,7 @@ async def test_agents_info( "backups": [], "config": { "agents": {}, + "automatic_backups_configured": True, "create_backup": { "agent_ids": ["test-agent"], "include_addons": None, @@ -969,6 +972,7 @@ async def test_agents_info( "backups": [], "config": { "agents": {}, + "automatic_backups_configured": False, "create_backup": { "agent_ids": ["test-agent"], "include_addons": None, @@ -1000,6 +1004,7 @@ async def test_agents_info( "backups": [], "config": { "agents": {}, + "automatic_backups_configured": False, "create_backup": { "agent_ids": ["test-agent"], "include_addons": None, @@ -1031,6 +1036,7 @@ async def test_agents_info( "backups": [], "config": { "agents": {}, + "automatic_backups_configured": False, "create_backup": { "agent_ids": ["test-agent"], "include_addons": None, @@ -1062,6 +1068,7 @@ async def test_agents_info( "backups": [], "config": { "agents": {}, + "automatic_backups_configured": False, "create_backup": { "agent_ids": ["test-agent"], "include_addons": None, @@ -1096,6 +1103,7 @@ async def test_agents_info( "test-agent1": {"protected": True}, "test-agent2": {"protected": False}, }, + "automatic_backups_configured": False, "create_backup": { "agent_ids": ["test-agent"], "include_addons": None, @@ -1127,6 +1135,7 @@ async def test_agents_info( "backups": [], "config": { "agents": {}, + "automatic_backups_configured": True, "create_backup": { "agent_ids": ["hassio.local", "hassio.share", "test-agent"], "include_addons": None, @@ -1158,6 +1167,7 @@ async def test_agents_info( "backups": [], "config": { "agents": {}, + "automatic_backups_configured": True, "create_backup": { "agent_ids": ["backup.local", "test-agent"], "include_addons": None, @@ -1343,6 +1353,18 @@ async def test_config_load_config_info( }, }, ], + [ + { + "type": "backup/config/update", + "automatic_backups_configured": False, + } + ], + [ + { + "type": "backup/config/update", + "automatic_backups_configured": True, + } + ], ], ) @patch("homeassistant.components.backup.config.random.randint", Mock(return_value=600)) @@ -1774,6 +1796,7 @@ async def test_config_schedule_logic( "backups": [], "config": { "agents": {}, + "automatic_backups_configured": False, "create_backup": { "agent_ids": ["test.test-agent"], "include_addons": [], @@ -2436,6 +2459,7 @@ async def test_config_retention_copies_logic( "backups": [], "config": { "agents": {}, + "automatic_backups_configured": False, "create_backup": { "agent_ids": ["test-agent"], "include_addons": ["test-addon"], @@ -2714,6 +2738,7 @@ async def test_config_retention_copies_logic_manual_backup( "backups": [], "config": { "agents": {}, + "automatic_backups_configured": False, "create_backup": { "agent_ids": ["test-agent"], "include_addons": ["test-addon"], @@ -3161,6 +3186,7 @@ async def test_config_retention_days_logic( "backups": [], "config": { "agents": {}, + "automatic_backups_configured": False, "create_backup": { "agent_ids": ["test-agent"], "include_addons": ["test-addon"], diff --git a/tests/components/hassio/snapshots/test_backup.ambr b/tests/components/hassio/snapshots/test_backup.ambr index a2f33bf9624..725239ee126 100644 --- a/tests/components/hassio/snapshots/test_backup.ambr +++ b/tests/components/hassio/snapshots/test_backup.ambr @@ -6,6 +6,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': False, 'create_backup': dict({ 'agent_ids': list([ ]), @@ -43,6 +44,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': True, 'create_backup': dict({ 'agent_ids': list([ 'test-agent1', @@ -89,6 +91,7 @@ 'config': dict({ 'agents': dict({ }), + 'automatic_backups_configured': True, 'create_backup': dict({ 'agent_ids': list([ 'test-agent1', diff --git a/tests/components/hassio/test_backup.py b/tests/components/hassio/test_backup.py index 6a66d249dd1..c7f400cef5c 100644 --- a/tests/components/hassio/test_backup.py +++ b/tests/components/hassio/test_backup.py @@ -2480,6 +2480,7 @@ async def test_restore_progress_after_restart_unknown_job( "backups": [], "config": { "agents": {}, + "automatic_backups_configured": True, "create_backup": { "agent_ids": ["test-agent1", "hassio.local", "test-agent2"], "include_addons": ["addon1", "addon2"], @@ -2511,6 +2512,7 @@ async def test_restore_progress_after_restart_unknown_job( "backups": [], "config": { "agents": {}, + "automatic_backups_configured": True, "create_backup": { "agent_ids": ["test-agent1", "backup.local", "test-agent2"], "include_addons": ["addon1", "addon2"], From 461039f06a8eddf83203b95200728db737be95ab Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Mon, 24 Feb 2025 17:23:14 +0100 Subject: [PATCH 1733/3148] Add translations for exceptions and data descriptions to pyLoad integration (#138896) --- .../components/pyload/coordinator.py | 8 +++++-- homeassistant/components/pyload/strings.json | 22 ++++++++++++++----- 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/pyload/coordinator.py b/homeassistant/components/pyload/coordinator.py index 937d8d71291..c57dfa7720d 100644 --- a/homeassistant/components/pyload/coordinator.py +++ b/homeassistant/components/pyload/coordinator.py @@ -78,10 +78,14 @@ class PyLoadCoordinator(DataUpdateCoordinator[PyLoadData]): return self.data except CannotConnect as e: raise UpdateFailed( - "Unable to connect and retrieve data from pyLoad API" + translation_domain=DOMAIN, + translation_key="setup_request_exception", ) from e except ParserError as e: - raise UpdateFailed("Unable to parse data from pyLoad API") from e + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="setup_parse_exception", + ) from e async def _async_setup(self) -> None: """Set up the coordinator.""" diff --git a/homeassistant/components/pyload/strings.json b/homeassistant/components/pyload/strings.json index 0fd9b4befcf..ed15a438c28 100644 --- a/homeassistant/components/pyload/strings.json +++ b/homeassistant/components/pyload/strings.json @@ -12,7 +12,11 @@ }, "data_description": { "host": "The hostname or IP address of the device running your pyLoad instance.", - "port": "pyLoad uses port 8000 by default." + "username": "The username used to access the pyLoad instance.", + "password": "The password associated with the pyLoad account.", + "port": "pyLoad uses port 8000 by default.", + "ssl": "If enabled, the connection to the pyLoad instance will use HTTPS.", + "verify_ssl": "If checked, the SSL certificate will be validated to ensure a secure connection." } }, "reconfigure": { @@ -25,8 +29,12 @@ "port": "[%key:common::config_flow::data::port%]" }, "data_description": { - "host": "The hostname or IP address of the device running your pyLoad instance.", - "port": "pyLoad uses port 8000 by default." + "host": "[%key:component::pyload::config::step::user::data_description::host%]", + "verify_ssl": "[%key:component::pyload::config::step::user::data_description::verify_ssl%]", + "username": "[%key:component::pyload::config::step::user::data_description::username%]", + "password": "[%key:component::pyload::config::step::user::data_description::password%]", + "port": "[%key:component::pyload::config::step::user::data_description::port%]", + "ssl": "[%key:component::pyload::config::step::user::data_description::ssl%]" } }, "reauth_confirm": { @@ -34,6 +42,10 @@ "data": { "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "username": "[%key:component::pyload::config::step::user::data_description::username%]", + "password": "[%key:component::pyload::config::step::user::data_description::password%]" } } }, @@ -91,10 +103,10 @@ }, "exceptions": { "setup_request_exception": { - "message": "Unable to connect and retrieve data from pyLoad API, try again later" + "message": "Unable to connect and retrieve data from pyLoad API" }, "setup_parse_exception": { - "message": "Unable to parse data from pyLoad API, try again later" + "message": "Unable to parse data from pyLoad API" }, "setup_authentication_exception": { "message": "Authentication failed for {username}, verify your login credentials" From 2e5f56b70d144b2d19a2e757dbb39cce25eb9216 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Mon, 24 Feb 2025 17:36:20 +0100 Subject: [PATCH 1734/3148] Refactor to-do list order and reordering in Habitica (#138566) --- homeassistant/components/habitica/todo.py | 54 +++++++++++-------- .../fixtures/reorder_dailies_response.json | 15 ++++++ .../fixtures/reorder_todos_response.json | 12 +++++ tests/components/habitica/test_todo.py | 31 +++++++++-- 4 files changed, 84 insertions(+), 28 deletions(-) create mode 100644 tests/components/habitica/fixtures/reorder_dailies_response.json create mode 100644 tests/components/habitica/fixtures/reorder_todos_response.json diff --git a/homeassistant/components/habitica/todo.py b/homeassistant/components/habitica/todo.py index 29b98e90b04..71ba8e60e06 100644 --- a/homeassistant/components/habitica/todo.py +++ b/homeassistant/components/habitica/todo.py @@ -117,20 +117,24 @@ class BaseHabiticaListEntity(HabiticaBase, TodoListEntity): """Move an item in the To-do list.""" if TYPE_CHECKING: assert self.todo_items + tasks_order = ( + self.coordinator.data.user.tasksOrder.todos + if self.entity_description.key is HabiticaTodoList.TODOS + else self.coordinator.data.user.tasksOrder.dailys + ) if previous_uid: - pos = self.todo_items.index( - next(item for item in self.todo_items if item.uid == previous_uid) - ) - if pos < self.todo_items.index( - next(item for item in self.todo_items if item.uid == uid) - ): + pos = tasks_order.index(UUID(previous_uid)) + if pos < tasks_order.index(UUID(uid)): pos += 1 + else: pos = 0 try: - await self.coordinator.habitica.reorder_task(UUID(uid), pos) + tasks_order[:] = ( + await self.coordinator.habitica.reorder_task(UUID(uid), pos) + ).data except TooManyRequestsError as e: raise HomeAssistantError( translation_domain=DOMAIN, @@ -144,20 +148,6 @@ class BaseHabiticaListEntity(HabiticaBase, TodoListEntity): translation_key=f"move_{self.entity_description.key}_item_failed", translation_placeholders={"pos": str(pos)}, ) from e - else: - # move tasks in the coordinator until we have fresh data - tasks = self.coordinator.data.tasks - new_pos = ( - tasks.index( - next(task for task in tasks if task.id == UUID(previous_uid)) - ) - + 1 - if previous_uid - else 0 - ) - old_pos = tasks.index(next(task for task in tasks if task.id == UUID(uid))) - tasks.insert(new_pos, tasks.pop(old_pos)) - await self.coordinator.async_request_refresh() async def async_update_todo_item(self, item: TodoItem) -> None: """Update a Habitica todo.""" @@ -271,7 +261,7 @@ class HabiticaTodosListEntity(BaseHabiticaListEntity): def todo_items(self) -> list[TodoItem]: """Return the todo items.""" - return [ + tasks = [ *( TodoItem( uid=str(task.id), @@ -288,6 +278,15 @@ class HabiticaTodosListEntity(BaseHabiticaListEntity): if task.Type is TaskType.TODO ), ] + return sorted( + tasks, + key=lambda task: ( + float("inf") + if (uid := UUID(task.uid)) + not in (tasks_order := self.coordinator.data.user.tasksOrder.todos) + else tasks_order.index(uid) + ), + ) async def async_create_todo_item(self, item: TodoItem) -> None: """Create a Habitica todo.""" @@ -348,7 +347,7 @@ class HabiticaDailiesListEntity(BaseHabiticaListEntity): if TYPE_CHECKING: assert self.coordinator.data.user.lastCron - return [ + tasks = [ *( TodoItem( uid=str(task.id), @@ -365,3 +364,12 @@ class HabiticaDailiesListEntity(BaseHabiticaListEntity): if task.Type is TaskType.DAILY ) ] + return sorted( + tasks, + key=lambda task: ( + float("inf") + if (uid := UUID(task.uid)) + not in (tasks_order := self.coordinator.data.user.tasksOrder.dailys) + else tasks_order.index(uid) + ), + ) diff --git a/tests/components/habitica/fixtures/reorder_dailies_response.json b/tests/components/habitica/fixtures/reorder_dailies_response.json new file mode 100644 index 00000000000..3ad38ae9c2f --- /dev/null +++ b/tests/components/habitica/fixtures/reorder_dailies_response.json @@ -0,0 +1,15 @@ +{ + "success": true, + "data": [ + "f21fa608-cfc6-4413-9fc7-0eb1b48ca43a", + "2c6d136c-a1c3-4bef-b7c4-fa980784b1e1", + "bc1d1855-b2b8-4663-98ff-62e7b763dfc4", + "e97659e0-2c42-4599-a7bb-00282adc410d", + "564b9ac9-c53d-4638-9e7f-1cd96fe19baa", + "f2c85972-1a19-4426-bc6d-ce3337b9d99f", + "6e53f1f5-a315-4edd-984d-8d762e4a08ef" + ], + "notifications": [], + "userV": 589, + "appVersion": "5.28.6" +} diff --git a/tests/components/habitica/fixtures/reorder_todos_response.json b/tests/components/habitica/fixtures/reorder_todos_response.json new file mode 100644 index 00000000000..ba8118aa1da --- /dev/null +++ b/tests/components/habitica/fixtures/reorder_todos_response.json @@ -0,0 +1,12 @@ +{ + "success": true, + "data": [ + "88de7cd9-af2b-49ce-9afd-bf941d87336b", + "1aa3137e-ef72-4d1f-91ee-41933602f438", + "2f6fcabc-f670-4ec3-ba65-817e8deea490", + "86ea2475-d1b5-4020-bdcc-c188c7996afa" + ], + "notifications": [], + "userV": 589, + "appVersion": "5.28.6" +} diff --git a/tests/components/habitica/test_todo.py b/tests/components/habitica/test_todo.py index 01c033fcf95..3457af78403 100644 --- a/tests/components/habitica/test_todo.py +++ b/tests/components/habitica/test_todo.py @@ -6,7 +6,13 @@ from typing import Any from unittest.mock import AsyncMock, patch from uuid import UUID -from habiticalib import Direction, HabiticaTasksResponse, Task, TaskType +from habiticalib import ( + Direction, + HabiticaTaskOrderResponse, + HabiticaTasksResponse, + Task, + TaskType, +) import pytest from syrupy.assertion import SnapshotAssertion @@ -601,19 +607,23 @@ async def test_delete_completed_todo_items_exception( @pytest.mark.parametrize( - ("entity_id", "uid", "second_pos", "third_pos"), + ("entity_id", "uid", "second_pos", "third_pos", "fixture", "task_type"), [ ( "todo.test_user_to_do_s", "1aa3137e-ef72-4d1f-91ee-41933602f438", "88de7cd9-af2b-49ce-9afd-bf941d87336b", "2f6fcabc-f670-4ec3-ba65-817e8deea490", + "reorder_todos_response.json", + "todos", ), ( "todo.test_user_dailies", "2c6d136c-a1c3-4bef-b7c4-fa980784b1e1", - "564b9ac9-c53d-4638-9e7f-1cd96fe19baa", - "f2c85972-1a19-4426-bc6d-ce3337b9d99f", + "f21fa608-cfc6-4413-9fc7-0eb1b48ca43a", + "bc1d1855-b2b8-4663-98ff-62e7b763dfc4", + "reorder_dailies_response.json", + "dailys", ), ], ids=["todo", "daily"], @@ -627,9 +637,14 @@ async def test_move_todo_item( uid: str, second_pos: str, third_pos: str, + fixture: str, + task_type: str, ) -> None: """Test move todo items.""" - + reorder_response = HabiticaTaskOrderResponse.from_json( + load_fixture(fixture, DOMAIN) + ) + habitica.reorder_task.return_value = reorder_response config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -650,6 +665,7 @@ async def test_move_todo_item( assert resp.get("success") habitica.reorder_task.assert_awaited_once_with(UUID(uid), 1) + habitica.reorder_task.reset_mock() # move down to third position @@ -665,6 +681,7 @@ async def test_move_todo_item( assert resp.get("success") habitica.reorder_task.assert_awaited_once_with(UUID(uid), 2) + habitica.reorder_task.reset_mock() # move to top position @@ -679,6 +696,10 @@ async def test_move_todo_item( assert resp.get("success") habitica.reorder_task.assert_awaited_once_with(UUID(uid), 0) + assert ( + getattr(config_entry.runtime_data.data.user.tasksOrder, task_type) + == reorder_response.data + ) @pytest.mark.parametrize( From ec3f5561dc79331a4acbef20f8a858480a0b587e Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Mon, 24 Feb 2025 18:00:48 +0100 Subject: [PATCH 1735/3148] Add WebDAV backup agent (#137721) * Add WebDAV backup agent * Process code review * Increase timeout for large uploads * Make metadata file based * Update IQS * Grammar * Move to aiowebdav2 * Update helper text * Add decorator to handle backup errors * Bump version * Missed one * Add unauth handling * Apply suggestions from code review Co-authored-by: Josef Zweck * Update homeassistant/components/webdav/__init__.py * Update homeassistant/components/webdav/config_flow.py * Remove timeout Co-authored-by: Josef Zweck * remove unique_id * Add tests * Add missing tests * Bump version * Remove dropbox * Process code review * Bump version to relax pinned dependencies * Process code review * Add translatable exceptions * Process code review * Process code review --------- Co-authored-by: Josef Zweck --- CODEOWNERS | 2 + homeassistant/components/webdav/__init__.py | 70 ++++ homeassistant/components/webdav/backup.py | 273 +++++++++++++++ .../components/webdav/config_flow.py | 90 +++++ homeassistant/components/webdav/const.py | 13 + homeassistant/components/webdav/helpers.py | 38 +++ homeassistant/components/webdav/manifest.json | 12 + .../components/webdav/quality_scale.yaml | 145 ++++++++ homeassistant/components/webdav/strings.json | 41 +++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/webdav/__init__.py | 1 + tests/components/webdav/conftest.py | 80 +++++ tests/components/webdav/const.py | 52 +++ tests/components/webdav/test_backup.py | 323 ++++++++++++++++++ tests/components/webdav/test_config_flow.py | 149 ++++++++ 18 files changed, 1302 insertions(+) create mode 100644 homeassistant/components/webdav/__init__.py create mode 100644 homeassistant/components/webdav/backup.py create mode 100644 homeassistant/components/webdav/config_flow.py create mode 100644 homeassistant/components/webdav/const.py create mode 100644 homeassistant/components/webdav/helpers.py create mode 100644 homeassistant/components/webdav/manifest.json create mode 100644 homeassistant/components/webdav/quality_scale.yaml create mode 100644 homeassistant/components/webdav/strings.json create mode 100644 tests/components/webdav/__init__.py create mode 100644 tests/components/webdav/conftest.py create mode 100644 tests/components/webdav/const.py create mode 100644 tests/components/webdav/test_backup.py create mode 100644 tests/components/webdav/test_config_flow.py diff --git a/CODEOWNERS b/CODEOWNERS index 61b2eb5b557..bb8545c46b7 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1695,6 +1695,8 @@ build.json @home-assistant/supervisor /tests/components/weatherflow_cloud/ @jeeftor /homeassistant/components/weatherkit/ @tjhorner /tests/components/weatherkit/ @tjhorner +/homeassistant/components/webdav/ @jpbede +/tests/components/webdav/ @jpbede /homeassistant/components/webhook/ @home-assistant/core /tests/components/webhook/ @home-assistant/core /homeassistant/components/webmin/ @autinerd diff --git a/homeassistant/components/webdav/__init__.py b/homeassistant/components/webdav/__init__.py new file mode 100644 index 00000000000..952a68d829f --- /dev/null +++ b/homeassistant/components/webdav/__init__.py @@ -0,0 +1,70 @@ +"""The WebDAV integration.""" + +from __future__ import annotations + +import logging + +from aiowebdav2.client import Client +from aiowebdav2.exceptions import UnauthorizedError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME, CONF_VERIFY_SSL +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady + +from .const import CONF_BACKUP_PATH, DATA_BACKUP_AGENT_LISTENERS, DOMAIN +from .helpers import async_create_client, async_ensure_path_exists + +type WebDavConfigEntry = ConfigEntry[Client] + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistant, entry: WebDavConfigEntry) -> bool: + """Set up WebDAV from a config entry.""" + client = async_create_client( + hass=hass, + url=entry.data[CONF_URL], + username=entry.data[CONF_USERNAME], + password=entry.data[CONF_PASSWORD], + verify_ssl=entry.data.get(CONF_VERIFY_SSL, True), + ) + + try: + result = await client.check() + except UnauthorizedError as err: + raise ConfigEntryError( + translation_domain=DOMAIN, + translation_key="invalid_username_password", + ) from err + + # Check if we can connect to the WebDAV server + # and access the root directory + if not result: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="cannot_connect", + ) + + # Ensure the backup directory exists + if not await async_ensure_path_exists( + client, entry.data.get(CONF_BACKUP_PATH, "/") + ): + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="cannot_access_or_create_backup_path", + ) + + entry.runtime_data = client + + def async_notify_backup_listeners() -> None: + for listener in hass.data.get(DATA_BACKUP_AGENT_LISTENERS, []): + listener() + + entry.async_on_unload(entry.async_on_state_change(async_notify_backup_listeners)) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: WebDavConfigEntry) -> bool: + """Unload a WebDAV config entry.""" + return True diff --git a/homeassistant/components/webdav/backup.py b/homeassistant/components/webdav/backup.py new file mode 100644 index 00000000000..2c19ca450e3 --- /dev/null +++ b/homeassistant/components/webdav/backup.py @@ -0,0 +1,273 @@ +"""Support for WebDAV backup.""" + +from __future__ import annotations + +from collections.abc import AsyncIterator, Callable, Coroutine +from functools import wraps +import logging +from typing import Any, Concatenate + +from aiohttp import ClientTimeout +from aiowebdav2 import Property, PropertyRequest +from aiowebdav2.exceptions import UnauthorizedError, WebDavError +from propcache.api import cached_property + +from homeassistant.components.backup import ( + AgentBackup, + BackupAgent, + BackupAgentError, + BackupNotFound, + suggested_filename, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.json import json_dumps +from homeassistant.util.json import json_loads_object + +from . import WebDavConfigEntry +from .const import CONF_BACKUP_PATH, DATA_BACKUP_AGENT_LISTENERS, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +METADATA_VERSION = "1" +BACKUP_TIMEOUT = ClientTimeout(connect=10, total=43200) + + +async def async_get_backup_agents( + hass: HomeAssistant, +) -> list[BackupAgent]: + """Return a list of backup agents.""" + entries: list[WebDavConfigEntry] = hass.config_entries.async_loaded_entries(DOMAIN) + return [WebDavBackupAgent(hass, entry) for entry in entries] + + +@callback +def async_register_backup_agents_listener( + hass: HomeAssistant, + *, + listener: Callable[[], None], + **kwargs: Any, +) -> Callable[[], None]: + """Register a listener to be called when agents are added or removed. + + :return: A function to unregister the listener. + """ + hass.data.setdefault(DATA_BACKUP_AGENT_LISTENERS, []).append(listener) + + @callback + def remove_listener() -> None: + """Remove the listener.""" + hass.data[DATA_BACKUP_AGENT_LISTENERS].remove(listener) + if not hass.data[DATA_BACKUP_AGENT_LISTENERS]: + del hass.data[DATA_BACKUP_AGENT_LISTENERS] + + return remove_listener + + +def handle_backup_errors[_R, **P]( + func: Callable[Concatenate[WebDavBackupAgent, P], Coroutine[Any, Any, _R]], +) -> Callable[Concatenate[WebDavBackupAgent, P], Coroutine[Any, Any, _R]]: + """Handle backup errors.""" + + @wraps(func) + async def wrapper(self: WebDavBackupAgent, *args: P.args, **kwargs: P.kwargs) -> _R: + try: + return await func(self, *args, **kwargs) + except UnauthorizedError as err: + raise BackupAgentError("Authentication error") from err + except WebDavError as err: + _LOGGER.debug("Full error: %s", err, exc_info=True) + raise BackupAgentError( + f"Backup operation failed: {err}", + ) from err + except TimeoutError as err: + _LOGGER.error( + "Error during backup in %s: Timeout", + func.__name__, + ) + raise BackupAgentError("Backup operation timed out") from err + + return wrapper + + +def suggested_filenames(backup: AgentBackup) -> tuple[str, str]: + """Return the suggested filenames for the backup and metadata.""" + base_name = suggested_filename(backup).rsplit(".", 1)[0] + return f"{base_name}.tar", f"{base_name}.metadata.json" + + +class WebDavBackupAgent(BackupAgent): + """Backup agent interface.""" + + domain = DOMAIN + + def __init__(self, hass: HomeAssistant, entry: WebDavConfigEntry) -> None: + """Initialize the WebDAV backup agent.""" + super().__init__() + self._hass = hass + self._entry = entry + self._client = entry.runtime_data + self.name = entry.title + self.unique_id = entry.entry_id + + @cached_property + def _backup_path(self) -> str: + """Return the path to the backup.""" + return self._entry.data.get(CONF_BACKUP_PATH, "") + + @handle_backup_errors + async def async_download_backup( + self, + backup_id: str, + **kwargs: Any, + ) -> AsyncIterator[bytes]: + """Download a backup file. + + :param backup_id: The ID of the backup that was returned in async_list_backups. + :return: An async iterator that yields bytes. + """ + backup = await self._find_backup_by_id(backup_id) + if backup is None: + raise BackupNotFound("Backup not found") + + return await self._client.download_iter( + f"{self._backup_path}/{suggested_filename(backup)}", + timeout=BACKUP_TIMEOUT, + ) + + @handle_backup_errors + async def async_upload_backup( + self, + *, + open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]], + backup: AgentBackup, + **kwargs: Any, + ) -> None: + """Upload a backup. + + :param open_stream: A function returning an async iterator that yields bytes. + :param backup: Metadata about the backup that should be uploaded. + """ + (filename_tar, filename_meta) = suggested_filenames(backup) + + await self._client.upload_iter( + await open_stream(), + f"{self._backup_path}/{filename_tar}", + timeout=BACKUP_TIMEOUT, + ) + + _LOGGER.debug( + "Uploaded backup to %s", + f"{self._backup_path}/{filename_tar}", + ) + + await self._client.upload_iter( + json_dumps(backup.as_dict()), + f"{self._backup_path}/{filename_meta}", + ) + + await self._client.set_property_batch( + f"{self._backup_path}/{filename_meta}", + [ + Property( + namespace="homeassistant", + name="backup_id", + value=backup.backup_id, + ), + Property( + namespace="homeassistant", + name="metadata_version", + value=METADATA_VERSION, + ), + ], + ) + + _LOGGER.debug( + "Uploaded metadata file for %s", + f"{self._backup_path}/{filename_meta}", + ) + + @handle_backup_errors + async def async_delete_backup( + self, + backup_id: str, + **kwargs: Any, + ) -> None: + """Delete a backup file. + + :param backup_id: The ID of the backup that was returned in async_list_backups. + """ + backup = await self._find_backup_by_id(backup_id) + if backup is None: + return + + (filename_tar, filename_meta) = suggested_filenames(backup) + backup_path = f"{self._backup_path}/{filename_tar}" + + await self._client.clean(backup_path) + await self._client.clean(f"{self._backup_path}/{filename_meta}") + + _LOGGER.debug( + "Deleted backup at %s", + backup_path, + ) + + @handle_backup_errors + async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]: + """List backups.""" + metadata_files = await self._list_metadata_files() + return [ + await self._download_metadata(metadata_file) + for metadata_file in metadata_files + ] + + @handle_backup_errors + async def async_get_backup( + self, + backup_id: str, + **kwargs: Any, + ) -> AgentBackup | None: + """Return a backup.""" + return await self._find_backup_by_id(backup_id) + + async def _list_metadata_files(self) -> list[str]: + """List metadata files.""" + files = await self._client.list_with_infos(self._backup_path) + return [ + file["path"] + for file in files + if file["path"].endswith(".json") + and await self._is_current_metadata_version(file["path"]) + ] + + async def _is_current_metadata_version(self, path: str) -> bool: + """Check if is current metadata version.""" + metadata_version = await self._client.get_property( + path, + PropertyRequest( + namespace="homeassistant", + name="metadata_version", + ), + ) + return metadata_version.value == METADATA_VERSION if metadata_version else False + + async def _find_backup_by_id(self, backup_id: str) -> AgentBackup | None: + """Find a backup by its backup ID on remote.""" + metadata_files = await self._list_metadata_files() + for metadata_file in metadata_files: + remote_backup_id = await self._client.get_property( + metadata_file, + PropertyRequest( + namespace="homeassistant", + name="backup_id", + ), + ) + if remote_backup_id and remote_backup_id.value == backup_id: + return await self._download_metadata(metadata_file) + + return None + + async def _download_metadata(self, path: str) -> AgentBackup: + """Download metadata file.""" + iterator = await self._client.download_iter(path) + metadata = await anext(iterator) + return AgentBackup.from_dict(json_loads_object(metadata)) diff --git a/homeassistant/components/webdav/config_flow.py b/homeassistant/components/webdav/config_flow.py new file mode 100644 index 00000000000..f75544d25ad --- /dev/null +++ b/homeassistant/components/webdav/config_flow.py @@ -0,0 +1,90 @@ +"""Config flow for the WebDAV integration.""" + +from __future__ import annotations + +import logging +from typing import Any + +from aiowebdav2.exceptions import UnauthorizedError +import voluptuous as vol +import yarl + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME, CONF_VERIFY_SSL +from homeassistant.helpers.selector import ( + TextSelector, + TextSelectorConfig, + TextSelectorType, +) + +from .const import CONF_BACKUP_PATH, DOMAIN +from .helpers import async_create_client + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_URL): TextSelector( + TextSelectorConfig( + type=TextSelectorType.URL, + ) + ), + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): TextSelector( + TextSelectorConfig( + type=TextSelectorType.PASSWORD, + ) + ), + vol.Optional(CONF_BACKUP_PATH, default="/"): str, + vol.Optional(CONF_VERIFY_SSL, default=True): bool, + } +) + + +class WebDavConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for WebDAV.""" + + 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: + client = async_create_client( + hass=self.hass, + url=user_input[CONF_URL], + username=user_input[CONF_USERNAME], + password=user_input[CONF_PASSWORD], + verify_ssl=user_input.get(CONF_VERIFY_SSL, True), + ) + + # Check if we can connect to the WebDAV server + # .check() already does the most of the error handling and will return True + # if we can access the root directory + try: + result = await client.check() + except UnauthorizedError: + errors["base"] = "invalid_auth" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected error") + errors["base"] = "unknown" + else: + if result: + self._async_abort_entries_match( + { + CONF_URL: user_input[CONF_URL], + CONF_USERNAME: user_input[CONF_USERNAME], + } + ) + + parsed_url = yarl.URL(user_input[CONF_URL]) + return self.async_create_entry( + title=f"{user_input[CONF_USERNAME]}@{parsed_url.host}", + data=user_input, + ) + + errors["base"] = "cannot_connect" + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) diff --git a/homeassistant/components/webdav/const.py b/homeassistant/components/webdav/const.py new file mode 100644 index 00000000000..faf8ce77ca5 --- /dev/null +++ b/homeassistant/components/webdav/const.py @@ -0,0 +1,13 @@ +"""Constants for the WebDAV integration.""" + +from collections.abc import Callable + +from homeassistant.util.hass_dict import HassKey + +DOMAIN = "webdav" + +DATA_BACKUP_AGENT_LISTENERS: HassKey[list[Callable[[], None]]] = HassKey( + f"{DOMAIN}.backup_agent_listeners" +) + +CONF_BACKUP_PATH = "backup_path" diff --git a/homeassistant/components/webdav/helpers.py b/homeassistant/components/webdav/helpers.py new file mode 100644 index 00000000000..9f91ed3bdb3 --- /dev/null +++ b/homeassistant/components/webdav/helpers.py @@ -0,0 +1,38 @@ +"""Helper functions for the WebDAV component.""" + +from aiowebdav2.client import Client, ClientOptions + +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.aiohttp_client import async_get_clientsession + + +@callback +def async_create_client( + *, + hass: HomeAssistant, + url: str, + username: str, + password: str, + verify_ssl: bool = False, +) -> Client: + """Create a WebDAV client.""" + return Client( + url=url, + username=username, + password=password, + options=ClientOptions( + verify_ssl=verify_ssl, + session=async_get_clientsession(hass), + ), + ) + + +async def async_ensure_path_exists(client: Client, path: str) -> bool: + """Ensure that a path exists recursively on the WebDAV server.""" + parts = path.strip("/").split("/") + for i in range(1, len(parts) + 1): + sub_path = "/".join(parts[:i]) + if not await client.check(sub_path) and not await client.mkdir(sub_path): + return False + + return True diff --git a/homeassistant/components/webdav/manifest.json b/homeassistant/components/webdav/manifest.json new file mode 100644 index 00000000000..a1ac779afc8 --- /dev/null +++ b/homeassistant/components/webdav/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "webdav", + "name": "WebDAV", + "codeowners": ["@jpbede"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/webdav", + "integration_type": "service", + "iot_class": "cloud_polling", + "loggers": ["aiowebdav2"], + "quality_scale": "bronze", + "requirements": ["aiowebdav2==0.2.2"] +} diff --git a/homeassistant/components/webdav/quality_scale.yaml b/homeassistant/components/webdav/quality_scale.yaml new file mode 100644 index 00000000000..560626fda7e --- /dev/null +++ b/homeassistant/components/webdav/quality_scale.yaml @@ -0,0 +1,145 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: Integration does not register custom actions. + appropriate-polling: + status: exempt + comment: | + This integration does not poll. + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: | + This integration does not have any custom actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: | + Entities of this integration does not explicitly subscribe to events. + entity-unique-id: + status: exempt + comment: | + This integration does not have entities. + has-entity-name: + status: exempt + comment: | + This integration does not have entities. + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: Integration does not register custom actions. + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: | + No Options flow. + docs-installation-parameters: done + entity-unavailable: + status: exempt + comment: | + This integration does not have entities. + integration-owner: done + log-when-unavailable: + status: exempt + comment: | + This integration does not have entities. + parallel-updates: + status: exempt + comment: | + This integration does not have platforms. + reauthentication-flow: todo + test-coverage: todo + + # Gold + devices: + status: exempt + comment: | + This integration connects to a single service. + diagnostics: + status: exempt + comment: | + There is no data to diagnose. + discovery-update-info: + status: exempt + comment: | + This integration is a cloud service and does not support discovery. + discovery: + status: exempt + comment: | + This integration is a cloud service and does not support discovery. + docs-data-update: + status: exempt + comment: | + This integration does not poll or push. + docs-examples: + status: exempt + comment: | + This integration only serves backup. + docs-known-limitations: + status: done + comment: | + No known limitations. + docs-supported-devices: + status: exempt + comment: | + This integration is a cloud service. + docs-supported-functions: + status: exempt + comment: | + This integration does not have entities. + docs-troubleshooting: + status: exempt + comment: | + No issues known to troubleshoot. + docs-use-cases: todo + dynamic-devices: + status: exempt + comment: | + This integration connects to a single service. + entity-category: + status: exempt + comment: | + This integration does not have entities. + entity-device-class: + status: exempt + comment: | + This integration does not have entities. + entity-disabled-by-default: + status: exempt + comment: | + This integration does not have entities. + entity-translations: + status: exempt + comment: | + This integration does not have entities. + exception-translations: done + icon-translations: + status: exempt + comment: | + This integration does not have entities. + reconfiguration-flow: + status: exempt + comment: | + Nothing to reconfigure. + repair-issues: todo + stale-devices: + status: exempt + comment: | + This integration connects to a single service. + + # Platinum + async-dependency: done + inject-websession: todo + strict-typing: todo diff --git a/homeassistant/components/webdav/strings.json b/homeassistant/components/webdav/strings.json new file mode 100644 index 00000000000..57117cdd9de --- /dev/null +++ b/homeassistant/components/webdav/strings.json @@ -0,0 +1,41 @@ +{ + "config": { + "step": { + "user": { + "data": { + "url": "[%key:common::config_flow::data::url%]", + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]", + "backup_path": "Backup path", + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" + }, + "data_description": { + "url": "The URL of the WebDAV server. Check with your provider for the correct URL.", + "username": "The username for the WebDAV server.", + "password": "The password for the WebDAV server.", + "backup_path": "Define the path where the backups should be located (will be created automatically if it does not exist).", + "verify_ssl": "Whether to verify the SSL certificate of the server. If you are using a self-signed certificate, do not select this option." + } + } + }, + "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_service%]" + } + }, + "exceptions": { + "invalid_username_password": { + "message": "Invalid username or password" + }, + "cannot_connect": { + "message": "Cannot connect to WebDAV server" + }, + "cannot_access_or_create_backup_path": { + "message": "Cannot access or create backup path. Please check the path and permissions." + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index c92235aae47..de581c65297 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -692,6 +692,7 @@ FLOWS = { "weatherflow", "weatherflow_cloud", "weatherkit", + "webdav", "webmin", "webostv", "weheat", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 6f4315c43dc..41083ee8e8c 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -7092,6 +7092,12 @@ } } }, + "webdav": { + "name": "WebDAV", + "integration_type": "service", + "config_flow": true, + "iot_class": "cloud_polling" + }, "webmin": { "name": "Webmin", "integration_type": "device", diff --git a/requirements_all.txt b/requirements_all.txt index 1ce88e0f55d..87dd9bb204e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -421,6 +421,9 @@ aiowaqi==3.1.0 # homeassistant.components.watttime aiowatttime==0.1.1 +# homeassistant.components.webdav +aiowebdav2==0.2.2 + # homeassistant.components.webostv aiowebostv==0.7.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c6588b06c41..f55ea287d37 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -403,6 +403,9 @@ aiowaqi==3.1.0 # homeassistant.components.watttime aiowatttime==0.1.1 +# homeassistant.components.webdav +aiowebdav2==0.2.2 + # homeassistant.components.webostv aiowebostv==0.7.0 diff --git a/tests/components/webdav/__init__.py b/tests/components/webdav/__init__.py new file mode 100644 index 00000000000..33e0222fb34 --- /dev/null +++ b/tests/components/webdav/__init__.py @@ -0,0 +1 @@ +"""Tests for the WebDAV integration.""" diff --git a/tests/components/webdav/conftest.py b/tests/components/webdav/conftest.py new file mode 100644 index 00000000000..ccd3437aaa0 --- /dev/null +++ b/tests/components/webdav/conftest.py @@ -0,0 +1,80 @@ +"""Common fixtures for the WebDAV tests.""" + +from collections.abc import AsyncIterator, Generator +from json import dumps +from unittest.mock import AsyncMock, patch + +from aiowebdav2 import Property, PropertyRequest +import pytest + +from homeassistant.components.webdav.const import DOMAIN +from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME + +from .const import ( + BACKUP_METADATA, + MOCK_GET_PROPERTY_BACKUP_ID, + MOCK_GET_PROPERTY_METADATA_VERSION, + MOCK_LIST_WITH_INFOS, +) + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.webdav.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + title="user@webdav.demo", + domain=DOMAIN, + data={ + CONF_URL: "https://webdav.demo", + CONF_USERNAME: "user", + CONF_PASSWORD: "supersecretpassword", + }, + entry_id="01JKXV07ASC62D620DGYNG2R8H", + ) + + +def _get_property(path: str, request: PropertyRequest) -> Property: + """Return the property of a file.""" + if path.endswith(".json") and request.name == "metadata_version": + return MOCK_GET_PROPERTY_METADATA_VERSION + + return MOCK_GET_PROPERTY_BACKUP_ID + + +async def _download_mock(path: str, timeout=None) -> AsyncIterator[bytes]: + """Mock the download function.""" + if path.endswith(".json"): + yield dumps(BACKUP_METADATA).encode() + + yield b"backup data" + + +@pytest.fixture(name="webdav_client") +def mock_webdav_client() -> Generator[AsyncMock]: + """Mock the aiowebdav client.""" + with ( + patch( + "homeassistant.components.webdav.helpers.Client", + autospec=True, + ) as mock_webdav_client, + ): + mock = mock_webdav_client.return_value + mock.check.return_value = True + mock.mkdir.return_value = True + mock.list_with_infos.return_value = MOCK_LIST_WITH_INFOS + mock.download_iter.side_effect = _download_mock + mock.upload_iter.return_value = None + mock.clean.return_value = None + mock.get_property.side_effect = _get_property + yield mock diff --git a/tests/components/webdav/const.py b/tests/components/webdav/const.py new file mode 100644 index 00000000000..777008b07a5 --- /dev/null +++ b/tests/components/webdav/const.py @@ -0,0 +1,52 @@ +"""Constants for WebDAV tests.""" + +from aiowebdav2 import Property + +BACKUP_METADATA = { + "addons": [], + "backup_id": "23e64aec", + "date": "2025-02-10T17:47:22.727189+01:00", + "database_included": True, + "extra_metadata": {}, + "folders": [], + "homeassistant_included": True, + "homeassistant_version": "2025.2.1", + "name": "Automatic backup 2025.2.1", + "protected": False, + "size": 34519040, +} + +MOCK_LIST_WITH_INFOS = [ + { + "content_type": "application/x-tar", + "created": "2025-02-10T17:47:22Z", + "etag": '"84d7d000-62dcd4ce886b4"', + "isdir": "False", + "modified": "Mon, 10 Feb 2025 17:47:22 GMT", + "name": "None", + "path": "/Automatic_backup_2025.2.1_2025-02-10_18.31_30202686.tar", + "size": "2228736000", + }, + { + "content_type": "application/json", + "created": "2025-02-10T17:47:22Z", + "etag": '"8d0-62dcd4cec050a"', + "isdir": "False", + "modified": "Mon, 10 Feb 2025 17:47:22 GMT", + "name": "None", + "path": "/Automatic_backup_2025.2.1_2025-02-10_18.31_30202686.metadata.json", + "size": "2256", + }, +] + +MOCK_GET_PROPERTY_METADATA_VERSION = Property( + namespace="homeassistant", + name="metadata_version", + value="1", +) + +MOCK_GET_PROPERTY_BACKUP_ID = Property( + namespace="homeassistant", + name="backup_id", + value="23e64aec", +) diff --git a/tests/components/webdav/test_backup.py b/tests/components/webdav/test_backup.py new file mode 100644 index 00000000000..b02fb2e9628 --- /dev/null +++ b/tests/components/webdav/test_backup.py @@ -0,0 +1,323 @@ +"""Test the backups for WebDAV.""" + +from __future__ import annotations + +from collections.abc import AsyncGenerator +from io import StringIO +from unittest.mock import Mock, patch + +from aiowebdav2.exceptions import UnauthorizedError, WebDavError +import pytest + +from homeassistant.components.backup import DOMAIN as BACKUP_DOMAIN, AgentBackup +from homeassistant.components.webdav.backup import async_register_backup_agents_listener +from homeassistant.components.webdav.const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from .const import BACKUP_METADATA, MOCK_LIST_WITH_INFOS + +from tests.common import AsyncMock, MockConfigEntry +from tests.typing import ClientSessionGenerator, WebSocketGenerator + + +@pytest.fixture(autouse=True) +async def setup_backup_integration( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, webdav_client: AsyncMock +) -> AsyncGenerator[None]: + """Set up webdav integration.""" + with ( + patch("homeassistant.components.backup.is_hassio", return_value=False), + patch("homeassistant.components.backup.store.STORE_DELAY_SAVE", 0), + ): + assert await async_setup_component(hass, BACKUP_DOMAIN, {}) + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + yield + + +async def test_agents_info( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_config_entry: MockConfigEntry, +) -> None: + """Test backup agent info.""" + client = await hass_ws_client(hass) + + await client.send_json_auto_id({"type": "backup/agents/info"}) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == { + "agents": [ + {"agent_id": "backup.local", "name": "local"}, + { + "agent_id": f"{DOMAIN}.{mock_config_entry.entry_id}", + "name": mock_config_entry.title, + }, + ], + } + + +async def test_agents_list_backups( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_config_entry: MockConfigEntry, +) -> None: + """Test agent list backups.""" + + client = await hass_ws_client(hass) + await client.send_json_auto_id({"type": "backup/info"}) + response = await client.receive_json() + + assert response["success"] + assert response["result"]["agent_errors"] == {} + assert response["result"]["backups"] == [ + { + "addons": [], + "agents": { + "webdav.01JKXV07ASC62D620DGYNG2R8H": { + "protected": False, + "size": 34519040, + } + }, + "backup_id": "23e64aec", + "date": "2025-02-10T17:47:22.727189+01:00", + "database_included": True, + "extra_metadata": {}, + "folders": [], + "homeassistant_included": True, + "homeassistant_version": "2025.2.1", + "name": "Automatic backup 2025.2.1", + "failed_agent_ids": [], + "with_automatic_settings": None, + } + ] + + +async def test_agents_get_backup( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_config_entry: MockConfigEntry, +) -> None: + """Test agent get backup.""" + + backup_id = BACKUP_METADATA["backup_id"] + client = await hass_ws_client(hass) + await client.send_json_auto_id({"type": "backup/details", "backup_id": backup_id}) + response = await client.receive_json() + + assert response["success"] + assert response["result"]["agent_errors"] == {} + assert response["result"]["backup"] == { + "addons": [], + "agents": { + f"{DOMAIN}.{mock_config_entry.entry_id}": { + "protected": False, + "size": 34519040, + } + }, + "backup_id": "23e64aec", + "date": "2025-02-10T17:47:22.727189+01:00", + "database_included": True, + "extra_metadata": {}, + "folders": [], + "homeassistant_included": True, + "homeassistant_version": "2025.2.1", + "name": "Automatic backup 2025.2.1", + "failed_agent_ids": [], + "with_automatic_settings": None, + } + + +async def test_agents_delete( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + webdav_client: AsyncMock, +) -> None: + """Test agent delete backup.""" + client = await hass_ws_client(hass) + + await client.send_json_auto_id( + { + "type": "backup/delete", + "backup_id": BACKUP_METADATA["backup_id"], + } + ) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == {"agent_errors": {}} + assert webdav_client.clean.call_count == 2 + + +async def test_agents_upload( + hass_client: ClientSessionGenerator, + webdav_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test agent upload backup.""" + client = await hass_client() + test_backup = AgentBackup.from_dict(BACKUP_METADATA) + + with ( + patch( + "homeassistant.components.backup.manager.BackupManager.async_get_backup", + ) as fetch_backup, + patch( + "homeassistant.components.backup.manager.read_backup", + return_value=test_backup, + ), + patch("pathlib.Path.open") as mocked_open, + ): + mocked_open.return_value.read = Mock(side_effect=[b"test", b""]) + fetch_backup.return_value = test_backup + resp = await client.post( + f"/api/backup/upload?agent_id={DOMAIN}.{mock_config_entry.entry_id}", + data={"file": StringIO("test")}, + ) + + assert resp.status == 201 + assert webdav_client.upload_iter.call_count == 2 + assert webdav_client.set_property_batch.call_count == 1 + + +async def test_agents_download( + hass_client: ClientSessionGenerator, + webdav_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test agent download backup.""" + client = await hass_client() + backup_id = BACKUP_METADATA["backup_id"] + + resp = await client.get( + f"/api/backup/download/{backup_id}?agent_id={DOMAIN}.{mock_config_entry.entry_id}" + ) + assert resp.status == 200 + assert await resp.content.read() == b"backup data" + + +async def test_error_on_agents_download( + hass_client: ClientSessionGenerator, + webdav_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test we get not found on a not existing backup on download.""" + client = await hass_client() + backup_id = BACKUP_METADATA["backup_id"] + webdav_client.list_with_infos.side_effect = [MOCK_LIST_WITH_INFOS, []] + + resp = await client.get( + f"/api/backup/download/{backup_id}?agent_id={DOMAIN}.{mock_config_entry.entry_id}" + ) + assert resp.status == 404 + + +@pytest.mark.parametrize( + ("side_effect", "error"), + [ + ( + WebDavError("Unknown path"), + "Backup operation failed: Unknown path", + ), + (TimeoutError(), "Backup operation timed out"), + ], +) +async def test_delete_error( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + webdav_client: AsyncMock, + mock_config_entry: MockConfigEntry, + side_effect: Exception, + error: str, +) -> None: + """Test error during delete.""" + webdav_client.clean.side_effect = side_effect + + client = await hass_ws_client(hass) + + await client.send_json_auto_id( + { + "type": "backup/delete", + "backup_id": BACKUP_METADATA["backup_id"], + } + ) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == { + "agent_errors": {f"{DOMAIN}.{mock_config_entry.entry_id}": error} + } + + +async def test_agents_delete_not_found_does_not_throw( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + webdav_client: AsyncMock, +) -> None: + """Test agent delete backup.""" + webdav_client.list_with_infos.return_value = [] + client = await hass_ws_client(hass) + + await client.send_json_auto_id( + { + "type": "backup/delete", + "backup_id": BACKUP_METADATA["backup_id"], + } + ) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == {"agent_errors": {}} + + +async def test_agents_backup_not_found( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + webdav_client: AsyncMock, +) -> None: + """Test backup not found.""" + webdav_client.list_with_infos.return_value = [] + backup_id = BACKUP_METADATA["backup_id"] + client = await hass_ws_client(hass) + await client.send_json_auto_id({"type": "backup/details", "backup_id": backup_id}) + response = await client.receive_json() + + assert response["success"] + assert response["result"]["backup"] is None + + +async def test_raises_on_403( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + webdav_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test we raise on 403.""" + webdav_client.list_with_infos.side_effect = UnauthorizedError( + "https://webdav.example.com" + ) + backup_id = BACKUP_METADATA["backup_id"] + client = await hass_ws_client(hass) + await client.send_json_auto_id({"type": "backup/details", "backup_id": backup_id}) + response = await client.receive_json() + + assert response["success"] + assert response["result"]["agent_errors"] == { + f"{DOMAIN}.{mock_config_entry.entry_id}": "Authentication error" + } + + +async def test_listeners_get_cleaned_up(hass: HomeAssistant) -> None: + """Test listener gets cleaned up.""" + listener = AsyncMock() + remove_listener = async_register_backup_agents_listener(hass, listener=listener) + + # make sure it's the last listener + hass.data[DATA_BACKUP_AGENT_LISTENERS] = [listener] + remove_listener() + + assert hass.data.get(DATA_BACKUP_AGENT_LISTENERS) is None diff --git a/tests/components/webdav/test_config_flow.py b/tests/components/webdav/test_config_flow.py new file mode 100644 index 00000000000..eb887edb1a1 --- /dev/null +++ b/tests/components/webdav/test_config_flow.py @@ -0,0 +1,149 @@ +"""Test the WebDAV config flow.""" + +from unittest.mock import AsyncMock + +from aiowebdav2.exceptions import UnauthorizedError +import pytest + +from homeassistant import config_entries +from homeassistant.components.webdav.const import CONF_BACKUP_PATH, DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME, CONF_VERIFY_SSL +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_form(hass: HomeAssistant, webdav_client: AsyncMock) -> None: + """Test we get the form and create a entry on success.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_URL: "https://webdav.demo", + CONF_USERNAME: "user", + CONF_PASSWORD: "supersecretpassword", + CONF_BACKUP_PATH: "/backups", + CONF_VERIFY_SSL: False, + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "user@webdav.demo" + assert result["data"] == { + CONF_URL: "https://webdav.demo", + CONF_USERNAME: "user", + CONF_PASSWORD: "supersecretpassword", + CONF_BACKUP_PATH: "/backups", + CONF_VERIFY_SSL: False, + } + assert len(webdav_client.mock_calls) == 1 + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_form_fail(hass: HomeAssistant, webdav_client: AsyncMock) -> None: + """Test to handle exceptions.""" + webdav_client.check.return_value = False + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={ + CONF_URL: "https://webdav.demo", + CONF_USERNAME: "user", + CONF_PASSWORD: "supersecretpassword", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "cannot_connect"} + + # reset and test for success + webdav_client.check.return_value = True + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_URL: "https://webdav.demo", + CONF_USERNAME: "user", + CONF_PASSWORD: "supersecretpassword", + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "user@webdav.demo" + assert "errors" not in result + + +@pytest.mark.parametrize( + ("exception", "expected_error"), + [ + (UnauthorizedError("https://webdav.demo"), "invalid_auth"), + (Exception("Unexpected error"), "unknown"), + ], +) +async def test_form_unauthorized( + hass: HomeAssistant, + webdav_client: AsyncMock, + exception: Exception, + expected_error: str, +) -> None: + """Test to handle unauthorized.""" + webdav_client.check.side_effect = exception + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={ + CONF_URL: "https://webdav.demo", + CONF_USERNAME: "user", + CONF_PASSWORD: "supersecretpassword", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": expected_error} + + # reset and test for success + webdav_client.check.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_URL: "https://webdav.demo", + CONF_USERNAME: "user", + CONF_PASSWORD: "supersecretpassword", + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "user@webdav.demo" + assert "errors" not in result + + +async def test_duplicate_entry( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, webdav_client: AsyncMock +) -> None: + """Test we get the form and create a entry on success.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={ + CONF_URL: "https://webdav.demo", + CONF_USERNAME: "user", + CONF_PASSWORD: "supersecretpassword", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" From 60479369b6266f924c5d7b1ff10b13394cdf5584 Mon Sep 17 00:00:00 2001 From: elmurato <1382097+elmurato@users.noreply.github.com> Date: Mon, 24 Feb 2025 18:02:18 +0100 Subject: [PATCH 1736/3148] Remove name in Minecraft Server config entry (#139113) * Remove CONF_NAME in config entry * Revert config entry version from 4 back to 3 * Add data_description for address in strings.json * Use config entry title as coordinator name * Use constant as mock config entry title --- .../minecraft_server/config_flow.py | 8 +- .../components/minecraft_server/const.py | 2 - .../minecraft_server/coordinator.py | 4 +- .../minecraft_server/diagnostics.py | 4 +- .../minecraft_server/quality_scale.yaml | 4 +- .../components/minecraft_server/strings.json | 10 +- tests/components/minecraft_server/conftest.py | 8 +- .../snapshots/test_binary_sensor.ambr | 16 +-- .../snapshots/test_diagnostics.ambr | 2 - .../snapshots/test_sensor.ambr | 120 +++++++++--------- .../minecraft_server/test_binary_sensor.py | 11 +- .../minecraft_server/test_config_flow.py | 8 +- .../components/minecraft_server/test_init.py | 4 +- .../minecraft_server/test_sensor.py | 40 +++--- 14 files changed, 118 insertions(+), 123 deletions(-) diff --git a/homeassistant/components/minecraft_server/config_flow.py b/homeassistant/components/minecraft_server/config_flow.py index 3ffdc33f3b2..d0f7cf5a8fb 100644 --- a/homeassistant/components/minecraft_server/config_flow.py +++ b/homeassistant/components/minecraft_server/config_flow.py @@ -8,10 +8,10 @@ from typing import Any import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_ADDRESS, CONF_NAME, CONF_TYPE +from homeassistant.const import CONF_ADDRESS, CONF_TYPE from .api import MinecraftServer, MinecraftServerAddressError, MinecraftServerType -from .const import DEFAULT_NAME, DOMAIN +from .const import DOMAIN DEFAULT_ADDRESS = "localhost:25565" @@ -37,7 +37,6 @@ class MinecraftServerConfigFlow(ConfigFlow, domain=DOMAIN): # Prepare config entry data. config_data = { - CONF_NAME: user_input[CONF_NAME], CONF_ADDRESS: address, } @@ -78,9 +77,6 @@ class MinecraftServerConfigFlow(ConfigFlow, domain=DOMAIN): step_id="user", data_schema=vol.Schema( { - vol.Required( - CONF_NAME, default=user_input.get(CONF_NAME, DEFAULT_NAME) - ): str, vol.Required( CONF_ADDRESS, default=user_input.get(CONF_ADDRESS, DEFAULT_ADDRESS), diff --git a/homeassistant/components/minecraft_server/const.py b/homeassistant/components/minecraft_server/const.py index e7a58741696..35a1c0dd5a5 100644 --- a/homeassistant/components/minecraft_server/const.py +++ b/homeassistant/components/minecraft_server/const.py @@ -1,7 +1,5 @@ """Constants for the Minecraft Server integration.""" -DEFAULT_NAME = "Minecraft Server" - DOMAIN = "minecraft_server" KEY_LATENCY = "latency" diff --git a/homeassistant/components/minecraft_server/coordinator.py b/homeassistant/components/minecraft_server/coordinator.py index 2cd1c1a94ab..457b0700535 100644 --- a/homeassistant/components/minecraft_server/coordinator.py +++ b/homeassistant/components/minecraft_server/coordinator.py @@ -6,7 +6,7 @@ from datetime import timedelta import logging from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_ADDRESS, CONF_NAME, CONF_TYPE +from homeassistant.const import CONF_ADDRESS, CONF_TYPE from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -42,7 +42,7 @@ class MinecraftServerCoordinator(DataUpdateCoordinator[MinecraftServerData]): super().__init__( hass=hass, - name=config_entry.data[CONF_NAME], + name=config_entry.title, config_entry=config_entry, logger=_LOGGER, update_interval=SCAN_INTERVAL, diff --git a/homeassistant/components/minecraft_server/diagnostics.py b/homeassistant/components/minecraft_server/diagnostics.py index 61a65f9c2dd..dd94411b969 100644 --- a/homeassistant/components/minecraft_server/diagnostics.py +++ b/homeassistant/components/minecraft_server/diagnostics.py @@ -5,12 +5,12 @@ from dataclasses import asdict from typing import Any from homeassistant.components.diagnostics import async_redact_data -from homeassistant.const import CONF_ADDRESS, CONF_NAME +from homeassistant.const import CONF_ADDRESS from homeassistant.core import HomeAssistant from .coordinator import MinecraftServerConfigEntry -TO_REDACT: Iterable[Any] = {CONF_ADDRESS, CONF_NAME, "players_list"} +TO_REDACT: Iterable[Any] = {CONF_ADDRESS, "players_list"} async def async_get_config_entry_diagnostics( diff --git a/homeassistant/components/minecraft_server/quality_scale.yaml b/homeassistant/components/minecraft_server/quality_scale.yaml index eeda413f2ad..a866969fc33 100644 --- a/homeassistant/components/minecraft_server/quality_scale.yaml +++ b/homeassistant/components/minecraft_server/quality_scale.yaml @@ -6,9 +6,7 @@ rules: appropriate-polling: done brands: done common-modules: done - config-flow: - status: todo - comment: Check removal and replacement of name in config flow with the title (server address). + config-flow: done config-flow-test-coverage: status: todo comment: | diff --git a/homeassistant/components/minecraft_server/strings.json b/homeassistant/components/minecraft_server/strings.json index c084c9e6df0..cb4670dcac4 100644 --- a/homeassistant/components/minecraft_server/strings.json +++ b/homeassistant/components/minecraft_server/strings.json @@ -2,12 +2,14 @@ "config": { "step": { "user": { - "title": "Link your Minecraft Server", - "description": "Set up your Minecraft Server instance to allow monitoring.", "data": { - "name": "[%key:common::config_flow::data::name%]", "address": "Server address" - } + }, + "data_description": { + "address": "The hostname, IP address or SRV record of your Minecraft server, optionally including the port." + }, + "title": "Link your Minecraft Server", + "description": "Set up your Minecraft Server instance to allow monitoring." } }, "abort": { diff --git a/tests/components/minecraft_server/conftest.py b/tests/components/minecraft_server/conftest.py index d34db5114cc..67b8bd17b3a 100644 --- a/tests/components/minecraft_server/conftest.py +++ b/tests/components/minecraft_server/conftest.py @@ -3,8 +3,8 @@ import pytest from homeassistant.components.minecraft_server.api import MinecraftServerType -from homeassistant.components.minecraft_server.const import DEFAULT_NAME, DOMAIN -from homeassistant.const import CONF_ADDRESS, CONF_NAME, CONF_TYPE +from homeassistant.components.minecraft_server.const import DOMAIN +from homeassistant.const import CONF_ADDRESS, CONF_TYPE from .const import TEST_ADDRESS, TEST_CONFIG_ENTRY_ID @@ -18,8 +18,8 @@ def java_mock_config_entry() -> MockConfigEntry: domain=DOMAIN, unique_id=None, entry_id=TEST_CONFIG_ENTRY_ID, + title=TEST_ADDRESS, data={ - CONF_NAME: DEFAULT_NAME, CONF_ADDRESS: TEST_ADDRESS, CONF_TYPE: MinecraftServerType.JAVA_EDITION, }, @@ -34,8 +34,8 @@ def bedrock_mock_config_entry() -> MockConfigEntry: domain=DOMAIN, unique_id=None, entry_id=TEST_CONFIG_ENTRY_ID, + title=TEST_ADDRESS, data={ - CONF_NAME: DEFAULT_NAME, CONF_ADDRESS: TEST_ADDRESS, CONF_TYPE: MinecraftServerType.BEDROCK_EDITION, }, diff --git a/tests/components/minecraft_server/snapshots/test_binary_sensor.ambr b/tests/components/minecraft_server/snapshots/test_binary_sensor.ambr index 2e4bf49089c..c93a87d70d8 100644 --- a/tests/components/minecraft_server/snapshots/test_binary_sensor.ambr +++ b/tests/components/minecraft_server/snapshots/test_binary_sensor.ambr @@ -3,10 +3,10 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'connectivity', - 'friendly_name': 'Minecraft Server Status', + 'friendly_name': 'mc.dummyserver.com:25566 Status', }), 'context': , - 'entity_id': 'binary_sensor.minecraft_server_status', + 'entity_id': 'binary_sensor.mc_dummyserver_com_25566_status', 'last_changed': , 'last_reported': , 'last_updated': , @@ -17,10 +17,10 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'connectivity', - 'friendly_name': 'Minecraft Server Status', + 'friendly_name': 'mc.dummyserver.com:25566 Status', }), 'context': , - 'entity_id': 'binary_sensor.minecraft_server_status', + 'entity_id': 'binary_sensor.mc_dummyserver_com_25566_status', 'last_changed': , 'last_reported': , 'last_updated': , @@ -31,10 +31,10 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'connectivity', - 'friendly_name': 'Minecraft Server Status', + 'friendly_name': 'mc.dummyserver.com:25566 Status', }), 'context': , - 'entity_id': 'binary_sensor.minecraft_server_status', + 'entity_id': 'binary_sensor.mc_dummyserver_com_25566_status', 'last_changed': , 'last_reported': , 'last_updated': , @@ -45,10 +45,10 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'connectivity', - 'friendly_name': 'Minecraft Server Status', + 'friendly_name': 'mc.dummyserver.com:25566 Status', }), 'context': , - 'entity_id': 'binary_sensor.minecraft_server_status', + 'entity_id': 'binary_sensor.mc_dummyserver_com_25566_status', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/minecraft_server/snapshots/test_diagnostics.ambr b/tests/components/minecraft_server/snapshots/test_diagnostics.ambr index 72d79795c6a..b722f4122f3 100644 --- a/tests/components/minecraft_server/snapshots/test_diagnostics.ambr +++ b/tests/components/minecraft_server/snapshots/test_diagnostics.ambr @@ -8,7 +8,6 @@ }), 'config_entry_data': dict({ 'address': '**REDACTED**', - 'name': '**REDACTED**', 'type': 'Bedrock Edition', }), 'config_entry_options': dict({ @@ -36,7 +35,6 @@ }), 'config_entry_data': dict({ 'address': '**REDACTED**', - 'name': '**REDACTED**', 'type': 'Java Edition', }), 'config_entry_options': dict({ diff --git a/tests/components/minecraft_server/snapshots/test_sensor.ambr b/tests/components/minecraft_server/snapshots/test_sensor.ambr index 47d638adf79..d2b044c06f5 100644 --- a/tests/components/minecraft_server/snapshots/test_sensor.ambr +++ b/tests/components/minecraft_server/snapshots/test_sensor.ambr @@ -2,11 +2,11 @@ # name: test_sensor[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Minecraft Server Latency', + 'friendly_name': 'mc.dummyserver.com:25566 Latency', 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.minecraft_server_latency', + 'entity_id': 'sensor.mc_dummyserver_com_25566_latency', 'last_changed': , 'last_reported': , 'last_updated': , @@ -16,11 +16,11 @@ # name: test_sensor[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].1 StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Minecraft Server Players online', + 'friendly_name': 'mc.dummyserver.com:25566 Players online', 'unit_of_measurement': 'players', }), 'context': , - 'entity_id': 'sensor.minecraft_server_players_online', + 'entity_id': 'sensor.mc_dummyserver_com_25566_players_online', 'last_changed': , 'last_reported': , 'last_updated': , @@ -30,11 +30,11 @@ # name: test_sensor[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].2 StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Minecraft Server Players max', + 'friendly_name': 'mc.dummyserver.com:25566 Players max', 'unit_of_measurement': 'players', }), 'context': , - 'entity_id': 'sensor.minecraft_server_players_max', + 'entity_id': 'sensor.mc_dummyserver_com_25566_players_max', 'last_changed': , 'last_reported': , 'last_updated': , @@ -44,10 +44,10 @@ # name: test_sensor[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].3 StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Minecraft Server World message', + 'friendly_name': 'mc.dummyserver.com:25566 World message', }), 'context': , - 'entity_id': 'sensor.minecraft_server_world_message', + 'entity_id': 'sensor.mc_dummyserver_com_25566_world_message', 'last_changed': , 'last_reported': , 'last_updated': , @@ -57,10 +57,10 @@ # name: test_sensor[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].4 StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Minecraft Server Version', + 'friendly_name': 'mc.dummyserver.com:25566 Version', }), 'context': , - 'entity_id': 'sensor.minecraft_server_version', + 'entity_id': 'sensor.mc_dummyserver_com_25566_version', 'last_changed': , 'last_reported': , 'last_updated': , @@ -70,10 +70,10 @@ # name: test_sensor[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].5 StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Minecraft Server Protocol version', + 'friendly_name': 'mc.dummyserver.com:25566 Protocol version', }), 'context': , - 'entity_id': 'sensor.minecraft_server_protocol_version', + 'entity_id': 'sensor.mc_dummyserver_com_25566_protocol_version', 'last_changed': , 'last_reported': , 'last_updated': , @@ -83,10 +83,10 @@ # name: test_sensor[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].6 StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Minecraft Server Map name', + 'friendly_name': 'mc.dummyserver.com:25566 Map name', }), 'context': , - 'entity_id': 'sensor.minecraft_server_map_name', + 'entity_id': 'sensor.mc_dummyserver_com_25566_map_name', 'last_changed': , 'last_reported': , 'last_updated': , @@ -96,10 +96,10 @@ # name: test_sensor[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].7 StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Minecraft Server Game mode', + 'friendly_name': 'mc.dummyserver.com:25566 Game mode', }), 'context': , - 'entity_id': 'sensor.minecraft_server_game_mode', + 'entity_id': 'sensor.mc_dummyserver_com_25566_game_mode', 'last_changed': , 'last_reported': , 'last_updated': , @@ -109,10 +109,10 @@ # name: test_sensor[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].8 StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Minecraft Server Edition', + 'friendly_name': 'mc.dummyserver.com:25566 Edition', }), 'context': , - 'entity_id': 'sensor.minecraft_server_edition', + 'entity_id': 'sensor.mc_dummyserver_com_25566_edition', 'last_changed': , 'last_reported': , 'last_updated': , @@ -122,11 +122,11 @@ # name: test_sensor[java_mock_config_entry-JavaServer-async_lookup-status_response0-entity_ids0] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Minecraft Server Latency', + 'friendly_name': 'mc.dummyserver.com:25566 Latency', 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.minecraft_server_latency', + 'entity_id': 'sensor.mc_dummyserver_com_25566_latency', 'last_changed': , 'last_reported': , 'last_updated': , @@ -136,7 +136,7 @@ # name: test_sensor[java_mock_config_entry-JavaServer-async_lookup-status_response0-entity_ids0].1 StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Minecraft Server Players online', + 'friendly_name': 'mc.dummyserver.com:25566 Players online', 'players_list': list([ 'Player 1', 'Player 2', @@ -145,7 +145,7 @@ 'unit_of_measurement': 'players', }), 'context': , - 'entity_id': 'sensor.minecraft_server_players_online', + 'entity_id': 'sensor.mc_dummyserver_com_25566_players_online', 'last_changed': , 'last_reported': , 'last_updated': , @@ -155,11 +155,11 @@ # name: test_sensor[java_mock_config_entry-JavaServer-async_lookup-status_response0-entity_ids0].2 StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Minecraft Server Players max', + 'friendly_name': 'mc.dummyserver.com:25566 Players max', 'unit_of_measurement': 'players', }), 'context': , - 'entity_id': 'sensor.minecraft_server_players_max', + 'entity_id': 'sensor.mc_dummyserver_com_25566_players_max', 'last_changed': , 'last_reported': , 'last_updated': , @@ -169,10 +169,10 @@ # name: test_sensor[java_mock_config_entry-JavaServer-async_lookup-status_response0-entity_ids0].3 StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Minecraft Server World message', + 'friendly_name': 'mc.dummyserver.com:25566 World message', }), 'context': , - 'entity_id': 'sensor.minecraft_server_world_message', + 'entity_id': 'sensor.mc_dummyserver_com_25566_world_message', 'last_changed': , 'last_reported': , 'last_updated': , @@ -182,10 +182,10 @@ # name: test_sensor[java_mock_config_entry-JavaServer-async_lookup-status_response0-entity_ids0].4 StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Minecraft Server Version', + 'friendly_name': 'mc.dummyserver.com:25566 Version', }), 'context': , - 'entity_id': 'sensor.minecraft_server_version', + 'entity_id': 'sensor.mc_dummyserver_com_25566_version', 'last_changed': , 'last_reported': , 'last_updated': , @@ -195,10 +195,10 @@ # name: test_sensor[java_mock_config_entry-JavaServer-async_lookup-status_response0-entity_ids0].5 StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Minecraft Server Protocol version', + 'friendly_name': 'mc.dummyserver.com:25566 Protocol version', }), 'context': , - 'entity_id': 'sensor.minecraft_server_protocol_version', + 'entity_id': 'sensor.mc_dummyserver_com_25566_protocol_version', 'last_changed': , 'last_reported': , 'last_updated': , @@ -208,11 +208,11 @@ # name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Minecraft Server Latency', + 'friendly_name': 'mc.dummyserver.com:25566 Latency', 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.minecraft_server_latency', + 'entity_id': 'sensor.mc_dummyserver_com_25566_latency', 'last_changed': , 'last_reported': , 'last_updated': , @@ -222,11 +222,11 @@ # name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].1 StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Minecraft Server Players online', + 'friendly_name': 'mc.dummyserver.com:25566 Players online', 'unit_of_measurement': 'players', }), 'context': , - 'entity_id': 'sensor.minecraft_server_players_online', + 'entity_id': 'sensor.mc_dummyserver_com_25566_players_online', 'last_changed': , 'last_reported': , 'last_updated': , @@ -236,11 +236,11 @@ # name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].2 StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Minecraft Server Players max', + 'friendly_name': 'mc.dummyserver.com:25566 Players max', 'unit_of_measurement': 'players', }), 'context': , - 'entity_id': 'sensor.minecraft_server_players_max', + 'entity_id': 'sensor.mc_dummyserver_com_25566_players_max', 'last_changed': , 'last_reported': , 'last_updated': , @@ -250,10 +250,10 @@ # name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].3 StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Minecraft Server World message', + 'friendly_name': 'mc.dummyserver.com:25566 World message', }), 'context': , - 'entity_id': 'sensor.minecraft_server_world_message', + 'entity_id': 'sensor.mc_dummyserver_com_25566_world_message', 'last_changed': , 'last_reported': , 'last_updated': , @@ -263,10 +263,10 @@ # name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].4 StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Minecraft Server Version', + 'friendly_name': 'mc.dummyserver.com:25566 Version', }), 'context': , - 'entity_id': 'sensor.minecraft_server_version', + 'entity_id': 'sensor.mc_dummyserver_com_25566_version', 'last_changed': , 'last_reported': , 'last_updated': , @@ -276,10 +276,10 @@ # name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].5 StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Minecraft Server Protocol version', + 'friendly_name': 'mc.dummyserver.com:25566 Protocol version', }), 'context': , - 'entity_id': 'sensor.minecraft_server_protocol_version', + 'entity_id': 'sensor.mc_dummyserver_com_25566_protocol_version', 'last_changed': , 'last_reported': , 'last_updated': , @@ -289,10 +289,10 @@ # name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].6 StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Minecraft Server Map name', + 'friendly_name': 'mc.dummyserver.com:25566 Map name', }), 'context': , - 'entity_id': 'sensor.minecraft_server_map_name', + 'entity_id': 'sensor.mc_dummyserver_com_25566_map_name', 'last_changed': , 'last_reported': , 'last_updated': , @@ -302,10 +302,10 @@ # name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].7 StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Minecraft Server Game mode', + 'friendly_name': 'mc.dummyserver.com:25566 Game mode', }), 'context': , - 'entity_id': 'sensor.minecraft_server_game_mode', + 'entity_id': 'sensor.mc_dummyserver_com_25566_game_mode', 'last_changed': , 'last_reported': , 'last_updated': , @@ -315,10 +315,10 @@ # name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].8 StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Minecraft Server Edition', + 'friendly_name': 'mc.dummyserver.com:25566 Edition', }), 'context': , - 'entity_id': 'sensor.minecraft_server_edition', + 'entity_id': 'sensor.mc_dummyserver_com_25566_edition', 'last_changed': , 'last_reported': , 'last_updated': , @@ -328,11 +328,11 @@ # name: test_sensor_update[java_mock_config_entry-JavaServer-async_lookup-status_response0-entity_ids0] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Minecraft Server Latency', + 'friendly_name': 'mc.dummyserver.com:25566 Latency', 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.minecraft_server_latency', + 'entity_id': 'sensor.mc_dummyserver_com_25566_latency', 'last_changed': , 'last_reported': , 'last_updated': , @@ -342,7 +342,7 @@ # name: test_sensor_update[java_mock_config_entry-JavaServer-async_lookup-status_response0-entity_ids0].1 StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Minecraft Server Players online', + 'friendly_name': 'mc.dummyserver.com:25566 Players online', 'players_list': list([ 'Player 1', 'Player 2', @@ -351,7 +351,7 @@ 'unit_of_measurement': 'players', }), 'context': , - 'entity_id': 'sensor.minecraft_server_players_online', + 'entity_id': 'sensor.mc_dummyserver_com_25566_players_online', 'last_changed': , 'last_reported': , 'last_updated': , @@ -361,11 +361,11 @@ # name: test_sensor_update[java_mock_config_entry-JavaServer-async_lookup-status_response0-entity_ids0].2 StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Minecraft Server Players max', + 'friendly_name': 'mc.dummyserver.com:25566 Players max', 'unit_of_measurement': 'players', }), 'context': , - 'entity_id': 'sensor.minecraft_server_players_max', + 'entity_id': 'sensor.mc_dummyserver_com_25566_players_max', 'last_changed': , 'last_reported': , 'last_updated': , @@ -375,10 +375,10 @@ # name: test_sensor_update[java_mock_config_entry-JavaServer-async_lookup-status_response0-entity_ids0].3 StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Minecraft Server World message', + 'friendly_name': 'mc.dummyserver.com:25566 World message', }), 'context': , - 'entity_id': 'sensor.minecraft_server_world_message', + 'entity_id': 'sensor.mc_dummyserver_com_25566_world_message', 'last_changed': , 'last_reported': , 'last_updated': , @@ -388,10 +388,10 @@ # name: test_sensor_update[java_mock_config_entry-JavaServer-async_lookup-status_response0-entity_ids0].4 StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Minecraft Server Version', + 'friendly_name': 'mc.dummyserver.com:25566 Version', }), 'context': , - 'entity_id': 'sensor.minecraft_server_version', + 'entity_id': 'sensor.mc_dummyserver_com_25566_version', 'last_changed': , 'last_reported': , 'last_updated': , @@ -401,10 +401,10 @@ # name: test_sensor_update[java_mock_config_entry-JavaServer-async_lookup-status_response0-entity_ids0].5 StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Minecraft Server Protocol version', + 'friendly_name': 'mc.dummyserver.com:25566 Protocol version', }), 'context': , - 'entity_id': 'sensor.minecraft_server_protocol_version', + 'entity_id': 'sensor.mc_dummyserver_com_25566_protocol_version', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/minecraft_server/test_binary_sensor.py b/tests/components/minecraft_server/test_binary_sensor.py index 6321c91d74a..77537a5e8e4 100644 --- a/tests/components/minecraft_server/test_binary_sensor.py +++ b/tests/components/minecraft_server/test_binary_sensor.py @@ -64,7 +64,9 @@ async def test_binary_sensor( ): assert await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() - assert hass.states.get("binary_sensor.minecraft_server_status") == snapshot + assert ( + hass.states.get("binary_sensor.mc_dummyserver_com_25566_status") == snapshot + ) @pytest.mark.parametrize( @@ -113,7 +115,9 @@ async def test_binary_sensor_update( freezer.tick(timedelta(minutes=1)) async_fire_time_changed(hass) await hass.async_block_till_done() - assert hass.states.get("binary_sensor.minecraft_server_status") == snapshot + assert ( + hass.states.get("binary_sensor.mc_dummyserver_com_25566_status") == snapshot + ) @pytest.mark.parametrize( @@ -167,5 +171,6 @@ async def test_binary_sensor_update_failure( async_fire_time_changed(hass) await hass.async_block_till_done() assert ( - hass.states.get("binary_sensor.minecraft_server_status").state == STATE_OFF + hass.states.get("binary_sensor.mc_dummyserver_com_25566_status").state + == STATE_OFF ) diff --git a/tests/components/minecraft_server/test_config_flow.py b/tests/components/minecraft_server/test_config_flow.py index 41817986bcf..00e25028249 100644 --- a/tests/components/minecraft_server/test_config_flow.py +++ b/tests/components/minecraft_server/test_config_flow.py @@ -5,9 +5,9 @@ from unittest.mock import patch from mcstatus import BedrockServer, JavaServer from homeassistant.components.minecraft_server.api import MinecraftServerType -from homeassistant.components.minecraft_server.const import DEFAULT_NAME, DOMAIN +from homeassistant.components.minecraft_server.const import DOMAIN from homeassistant.config_entries import SOURCE_USER -from homeassistant.const import CONF_ADDRESS, CONF_NAME, CONF_TYPE +from homeassistant.const import CONF_ADDRESS, CONF_TYPE from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -22,7 +22,6 @@ from .const import ( from tests.common import MockConfigEntry USER_INPUT = { - CONF_NAME: DEFAULT_NAME, CONF_ADDRESS: TEST_ADDRESS, } @@ -146,7 +145,6 @@ async def test_java_connection(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == USER_INPUT[CONF_ADDRESS] - assert result["data"][CONF_NAME] == USER_INPUT[CONF_NAME] assert result["data"][CONF_ADDRESS] == TEST_ADDRESS assert result["data"][CONF_TYPE] == MinecraftServerType.JAVA_EDITION @@ -169,7 +167,6 @@ async def test_bedrock_connection(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == USER_INPUT[CONF_ADDRESS] - assert result["data"][CONF_NAME] == USER_INPUT[CONF_NAME] assert result["data"][CONF_ADDRESS] == TEST_ADDRESS assert result["data"][CONF_TYPE] == MinecraftServerType.BEDROCK_EDITION @@ -207,6 +204,5 @@ async def test_recovery(hass: HomeAssistant) -> None: ) assert result2["type"] is FlowResultType.CREATE_ENTRY assert result2["title"] == USER_INPUT[CONF_ADDRESS] - assert result2["data"][CONF_NAME] == USER_INPUT[CONF_NAME] assert result2["data"][CONF_ADDRESS] == TEST_ADDRESS assert result2["data"][CONF_TYPE] == MinecraftServerType.BEDROCK_EDITION diff --git a/tests/components/minecraft_server/test_init.py b/tests/components/minecraft_server/test_init.py index 6f7a49a190c..c00c5ec80cd 100644 --- a/tests/components/minecraft_server/test_init.py +++ b/tests/components/minecraft_server/test_init.py @@ -6,7 +6,7 @@ from mcstatus import JavaServer import pytest from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN -from homeassistant.components.minecraft_server.const import DEFAULT_NAME, DOMAIN +from homeassistant.components.minecraft_server.const import DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_ADDRESS, CONF_HOST, CONF_NAME, CONF_PORT @@ -23,6 +23,8 @@ from .const import ( from tests.common import MockConfigEntry +DEFAULT_NAME = "Minecraft Server" + TEST_UNIQUE_ID = f"{TEST_HOST}-{TEST_PORT}" SENSOR_KEYS = [ diff --git a/tests/components/minecraft_server/test_sensor.py b/tests/components/minecraft_server/test_sensor.py index ff62f8ddf36..a4cea239f7a 100644 --- a/tests/components/minecraft_server/test_sensor.py +++ b/tests/components/minecraft_server/test_sensor.py @@ -22,35 +22,35 @@ from .const import ( from tests.common import async_fire_time_changed JAVA_SENSOR_ENTITIES: list[str] = [ - "sensor.minecraft_server_latency", - "sensor.minecraft_server_players_online", - "sensor.minecraft_server_players_max", - "sensor.minecraft_server_world_message", - "sensor.minecraft_server_version", - "sensor.minecraft_server_protocol_version", + "sensor.mc_dummyserver_com_25566_latency", + "sensor.mc_dummyserver_com_25566_players_online", + "sensor.mc_dummyserver_com_25566_players_max", + "sensor.mc_dummyserver_com_25566_world_message", + "sensor.mc_dummyserver_com_25566_version", + "sensor.mc_dummyserver_com_25566_protocol_version", ] JAVA_SENSOR_ENTITIES_DISABLED_BY_DEFAULT: list[str] = [ - "sensor.minecraft_server_players_max", - "sensor.minecraft_server_protocol_version", + "sensor.mc_dummyserver_com_25566_players_max", + "sensor.mc_dummyserver_com_25566_protocol_version", ] BEDROCK_SENSOR_ENTITIES: list[str] = [ - "sensor.minecraft_server_latency", - "sensor.minecraft_server_players_online", - "sensor.minecraft_server_players_max", - "sensor.minecraft_server_world_message", - "sensor.minecraft_server_version", - "sensor.minecraft_server_protocol_version", - "sensor.minecraft_server_map_name", - "sensor.minecraft_server_game_mode", - "sensor.minecraft_server_edition", + "sensor.mc_dummyserver_com_25566_latency", + "sensor.mc_dummyserver_com_25566_players_online", + "sensor.mc_dummyserver_com_25566_players_max", + "sensor.mc_dummyserver_com_25566_world_message", + "sensor.mc_dummyserver_com_25566_version", + "sensor.mc_dummyserver_com_25566_protocol_version", + "sensor.mc_dummyserver_com_25566_map_name", + "sensor.mc_dummyserver_com_25566_game_mode", + "sensor.mc_dummyserver_com_25566_edition", ] BEDROCK_SENSOR_ENTITIES_DISABLED_BY_DEFAULT: list[str] = [ - "sensor.minecraft_server_players_max", - "sensor.minecraft_server_protocol_version", - "sensor.minecraft_server_edition", + "sensor.mc_dummyserver_com_25566_players_max", + "sensor.mc_dummyserver_com_25566_protocol_version", + "sensor.mc_dummyserver_com_25566_edition", ] From 2bab7436d3498aa9ff6536240a4dc832542372b1 Mon Sep 17 00:00:00 2001 From: cdnninja Date: Mon, 24 Feb 2025 10:07:05 -0700 Subject: [PATCH 1737/3148] Add vesync debug mode in library (#134571) * Debug mode pass through * Correct code, shouldn't have been lambda * listener for change * ruff * Update manifest.json * Reflect correct logger title * Ruff fix from merge --- homeassistant/components/vesync/__init__.py | 31 ++++++++++++++++--- homeassistant/components/vesync/const.py | 1 + homeassistant/components/vesync/manifest.json | 2 +- 3 files changed, 29 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/vesync/__init__.py b/homeassistant/components/vesync/__init__.py index f9371d44507..01f88c64bf4 100644 --- a/homeassistant/components/vesync/__init__.py +++ b/homeassistant/components/vesync/__init__.py @@ -5,8 +5,13 @@ import logging from pyvesync import VeSync from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.const import ( + CONF_PASSWORD, + CONF_USERNAME, + EVENT_LOGGING_CHANGED, + Platform, +) +from homeassistant.core import Event, HomeAssistant, ServiceCall, callback from homeassistant.helpers import entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_send @@ -17,6 +22,7 @@ from .const import ( VS_COORDINATOR, VS_DEVICES, VS_DISCOVERY, + VS_LISTENERS, VS_MANAGER, ) from .coordinator import VeSyncDataCoordinator @@ -42,7 +48,13 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b time_zone = str(hass.config.time_zone) - manager = VeSync(username, password, time_zone) + manager = VeSync( + username=username, + password=password, + time_zone=time_zone, + debug=logging.getLogger("pyvesync.vesync").level == logging.DEBUG, + redact=True, + ) login = await hass.async_add_executor_job(manager.login) @@ -62,6 +74,17 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) + @callback + def _async_handle_logging_changed(_event: Event) -> None: + """Handle when the logging level changes.""" + manager.debug = logging.getLogger("pyvesync.vesync").level == logging.DEBUG + + cleanup = hass.bus.async_listen( + EVENT_LOGGING_CHANGED, _async_handle_logging_changed + ) + + hass.data[DOMAIN][VS_LISTENERS] = cleanup + async def async_new_device_discovery(service: ServiceCall) -> None: """Discover if new devices should be added.""" manager = hass.data[DOMAIN][VS_MANAGER] @@ -87,7 +110,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - + hass.data[DOMAIN][VS_LISTENERS]() unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if unload_ok: hass.data.pop(DOMAIN) diff --git a/homeassistant/components/vesync/const.py b/homeassistant/components/vesync/const.py index 2e51b96451c..1273ab914f8 100644 --- a/homeassistant/components/vesync/const.py +++ b/homeassistant/components/vesync/const.py @@ -22,6 +22,7 @@ exceeds the quota of 7700. VS_DEVICES = "devices" VS_COORDINATOR = "coordinator" VS_MANAGER = "manager" +VS_LISTENERS = "listeners" VS_NUMBERS = "numbers" VS_HUMIDIFIER_MODE_AUTO = "auto" diff --git a/homeassistant/components/vesync/manifest.json b/homeassistant/components/vesync/manifest.json index 9e2fbcc1782..571c6ee0036 100644 --- a/homeassistant/components/vesync/manifest.json +++ b/homeassistant/components/vesync/manifest.json @@ -11,6 +11,6 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/vesync", "iot_class": "cloud_polling", - "loggers": ["pyvesync"], + "loggers": ["pyvesync.vesync"], "requirements": ["pyvesync==2.1.18"] } From 79dbc704702fd7ff1489ca16a99dfa48a9596e96 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Mon, 24 Feb 2025 18:09:51 +0100 Subject: [PATCH 1738/3148] Fix return value for DataUpdateCoordinator._async setup (#139181) Fix return value for coodinator async setup --- homeassistant/helpers/update_coordinator.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/helpers/update_coordinator.py b/homeassistant/helpers/update_coordinator.py index be765ff422d..7130264eb0d 100644 --- a/homeassistant/helpers/update_coordinator.py +++ b/homeassistant/helpers/update_coordinator.py @@ -348,8 +348,8 @@ class DataUpdateCoordinator(BaseDataUpdateCoordinatorProtocol, Generic[_DataT]): only once during the first refresh. """ if self.setup_method is None: - return None - return await self.setup_method() + return + await self.setup_method() async def async_refresh(self) -> None: """Refresh data and log errors.""" From 6507955a144c006cb4cc32800ddbfc8c83728a63 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 24 Feb 2025 18:55:13 +0100 Subject: [PATCH 1739/3148] Fix race in WS command recorder/info (#139177) * Fix race in WS command recorder/info * Add comment * Remove unnecessary local import --- .../recorder/basic_websocket_api.py | 33 +++++++++---------- .../components/recorder/test_websocket_api.py | 27 +++++++++------ 2 files changed, 32 insertions(+), 28 deletions(-) diff --git a/homeassistant/components/recorder/basic_websocket_api.py b/homeassistant/components/recorder/basic_websocket_api.py index 9cbc77b30c0..258f6c63a9d 100644 --- a/homeassistant/components/recorder/basic_websocket_api.py +++ b/homeassistant/components/recorder/basic_websocket_api.py @@ -8,6 +8,7 @@ import voluptuous as vol from homeassistant.components import websocket_api from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import recorder as recorder_helper from .util import get_instance @@ -23,27 +24,23 @@ def async_setup(hass: HomeAssistant) -> None: vol.Required("type"): "recorder/info", } ) -@callback -def ws_info( +@websocket_api.async_response +async def ws_info( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] ) -> None: """Return status of the recorder.""" - if instance := get_instance(hass): - backlog = instance.backlog - migration_in_progress = instance.migration_in_progress - migration_is_live = instance.migration_is_live - recording = instance.recording - # We avoid calling is_alive() as it can block waiting - # for the thread state lock which will block the event loop. - is_running = instance.is_running - max_backlog = instance.max_backlog - else: - backlog = None - migration_in_progress = False - migration_is_live = False - recording = False - is_running = False - max_backlog = None + # Wait for db_connected to ensure the recorder instance is created and the + # migration flags are set. + await hass.data[recorder_helper.DATA_RECORDER].db_connected + instance = get_instance(hass) + backlog = instance.backlog + migration_in_progress = instance.migration_in_progress + migration_is_live = instance.migration_is_live + recording = instance.recording + # We avoid calling is_alive() as it can block waiting + # for the thread state lock which will block the event loop. + is_running = instance.is_running + max_backlog = instance.max_backlog recorder_info = { "backlog": backlog, diff --git a/tests/components/recorder/test_websocket_api.py b/tests/components/recorder/test_websocket_api.py index 8cbbb7a711b..8f93264b682 100644 --- a/tests/components/recorder/test_websocket_api.py +++ b/tests/components/recorder/test_websocket_api.py @@ -2608,21 +2608,28 @@ async def test_recorder_info_bad_recorder_config( assert response["result"]["thread_running"] is False -async def test_recorder_info_no_instance( - recorder_mock: Recorder, hass: HomeAssistant, hass_ws_client: WebSocketGenerator +async def test_recorder_info_wait_database_connect( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + async_test_recorder: RecorderInstanceContextManager, ) -> None: - """Test getting recorder when there is no instance.""" + """Test getting recorder info waits for recorder database connection.""" client = await hass_ws_client() - with patch( - "homeassistant.components.recorder.basic_websocket_api.get_instance", - return_value=None, - ): - await client.send_json_auto_id({"type": "recorder/info"}) + recorder_helper.async_initialize_recorder(hass) + await client.send_json_auto_id({"type": "recorder/info"}) + + async with async_test_recorder(hass): response = await client.receive_json() assert response["success"] - assert response["result"]["recording"] is False - assert response["result"]["thread_running"] is False + assert response["result"] == { + "backlog": ANY, + "max_backlog": 65000, + "migration_in_progress": False, + "migration_is_live": False, + "recording": True, + "thread_running": True, + } async def test_recorder_info_migration_queue_exhausted( From b42973040c98eeaccefe23d88a34144cc2b891a3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 24 Feb 2025 13:01:25 -0500 Subject: [PATCH 1740/3148] Bump aiohttp to 3.11.13 (#139197) changelog: https://github.com/aio-libs/aiohttp/compare/v3.11.12...v3.11.13 --- 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 967ce98a705..335a3b1da29 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -6,7 +6,7 @@ aiodns==3.2.0 aiohasupervisor==0.3.0 aiohttp-asyncmdnsresolver==0.1.1 aiohttp-fast-zlib==0.2.3 -aiohttp==3.11.12 +aiohttp==3.11.13 aiohttp_cors==0.7.0 aiousbwatcher==1.1.1 aiozoneinfo==0.2.3 diff --git a/pyproject.toml b/pyproject.toml index b43e4d284ca..1224cc0c70e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,7 @@ dependencies = [ # change behavior based on presence of supervisor. Deprecated with #127228 # Lib can be removed with 2025.11 "aiohasupervisor==0.3.0", - "aiohttp==3.11.12", + "aiohttp==3.11.13", "aiohttp_cors==0.7.0", "aiohttp-fast-zlib==0.2.3", "aiohttp-asyncmdnsresolver==0.1.1", diff --git a/requirements.txt b/requirements.txt index 962cab71a53..1ec004d7f65 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,7 +5,7 @@ # Home Assistant Core aiodns==3.2.0 aiohasupervisor==0.3.0 -aiohttp==3.11.12 +aiohttp==3.11.13 aiohttp_cors==0.7.0 aiohttp-fast-zlib==0.2.3 aiohttp-asyncmdnsresolver==0.1.1 From 1c83dab0a1aa1ee010958a94af5ba7cc00beff3a Mon Sep 17 00:00:00 2001 From: Tristan Date: Tue, 25 Feb 2025 06:29:55 +1100 Subject: [PATCH 1741/3148] Update Linkplay constants for Arylic S10+ and Arylic Up2Stream Amp 2.1 (#138198) --- homeassistant/components/linkplay/utils.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/linkplay/utils.py b/homeassistant/components/linkplay/utils.py index 00bb691362b..7151ed1537a 100644 --- a/homeassistant/components/linkplay/utils.py +++ b/homeassistant/components/linkplay/utils.py @@ -25,10 +25,12 @@ MODELS_ARYLIC_A30: Final[str] = "A30" MODELS_ARYLIC_A50: Final[str] = "A50" MODELS_ARYLIC_A50S: Final[str] = "A50+" MODELS_ARYLIC_UP2STREAM_AMP: Final[str] = "Up2Stream Amp 2.0" +MODELS_ARYLIC_UP2STREAM_AMP_2P1: Final[str] = "Up2Stream Amp 2.1" MODELS_ARYLIC_UP2STREAM_AMP_V3: Final[str] = "Up2Stream Amp v3" MODELS_ARYLIC_UP2STREAM_AMP_V4: Final[str] = "Up2Stream Amp v4" MODELS_ARYLIC_UP2STREAM_PRO: Final[str] = "Up2Stream Pro v1" MODELS_ARYLIC_UP2STREAM_PRO_V3: Final[str] = "Up2Stream Pro v3" +MODELS_ARYLIC_S10P: Final[str] = "Arylic S10+" MODELS_ARYLIC_UP2STREAM_PLATE_AMP: Final[str] = "Up2Stream Plate Amp" MODELS_IEAST_AUDIOCAST_M5: Final[str] = "AudioCast M5" MODELS_WIIM_AMP: Final[str] = "WiiM Amp" @@ -49,9 +51,10 @@ PROJECTID_LOOKUP: Final[dict[str, tuple[str, str]]] = { "UP2STREAM_AMP_V3": (MANUFACTURER_ARYLIC, MODELS_ARYLIC_UP2STREAM_AMP_V3), "UP2STREAM_AMP_V4": (MANUFACTURER_ARYLIC, MODELS_ARYLIC_UP2STREAM_AMP_V4), "UP2STREAM_PRO_V3": (MANUFACTURER_ARYLIC, MODELS_ARYLIC_UP2STREAM_PRO_V3), + "S10P_WIFI": (MANUFACTURER_ARYLIC, MODELS_ARYLIC_S10P), "ARYLIC_V20": (MANUFACTURER_ARYLIC, MODELS_ARYLIC_UP2STREAM_PLATE_AMP), "UP2STREAM_MINI_V3": (MANUFACTURER_ARYLIC, MODELS_GENERIC), - "UP2STREAM_AMP_2P1": (MANUFACTURER_ARYLIC, MODELS_GENERIC), + "UP2STREAM_AMP_2P1": (MANUFACTURER_ARYLIC, MODELS_ARYLIC_UP2STREAM_AMP_2P1), "RP0014_A50C_S": (MANUFACTURER_ARYLIC, MODELS_GENERIC), "ARYLIC_A30": (MANUFACTURER_ARYLIC, MODELS_GENERIC), "ARYLIC_SUBWOOFER": (MANUFACTURER_ARYLIC, MODELS_GENERIC), From 2451e5578a20cbb320e072a44688aaee8f0be44e Mon Sep 17 00:00:00 2001 From: peteS-UK <64092177+peteS-UK@users.noreply.github.com> Date: Mon, 24 Feb 2025 19:39:04 +0000 Subject: [PATCH 1742/3148] Add support for Apps and Radios to Squeezebox Media Browser (#135009) --- .../components/squeezebox/browse_media.py | 179 ++++++++++++++++-- homeassistant/components/squeezebox/const.py | 8 +- .../components/squeezebox/media_player.py | 13 +- tests/components/squeezebox/conftest.py | 28 ++- .../squeezebox/test_media_browser.py | 171 +++++++++++++---- 5 files changed, 334 insertions(+), 65 deletions(-) diff --git a/homeassistant/components/squeezebox/browse_media.py b/homeassistant/components/squeezebox/browse_media.py index c0458067a23..e12d2aa8844 100644 --- a/homeassistant/components/squeezebox/browse_media.py +++ b/homeassistant/components/squeezebox/browse_media.py @@ -3,6 +3,7 @@ from __future__ import annotations import contextlib +from dataclasses import dataclass, field from typing import Any from pysqueezebox import Player @@ -18,6 +19,8 @@ from homeassistant.components.media_player import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.network import is_internal_request +from .const import UNPLAYABLE_TYPES + LIBRARY = [ "Favorites", "Artists", @@ -26,9 +29,11 @@ LIBRARY = [ "Playlists", "Genres", "New Music", + "Apps", + "Radios", ] -MEDIA_TYPE_TO_SQUEEZEBOX = { +MEDIA_TYPE_TO_SQUEEZEBOX: dict[str | MediaType, str] = { "Favorites": "favorites", "Artists": "artists", "Albums": "albums", @@ -41,19 +46,25 @@ MEDIA_TYPE_TO_SQUEEZEBOX = { MediaType.TRACK: "title", MediaType.PLAYLIST: "playlist", MediaType.GENRE: "genre", + "Apps": "apps", + "Radios": "radios", } -SQUEEZEBOX_ID_BY_TYPE = { +SQUEEZEBOX_ID_BY_TYPE: dict[str | MediaType, str] = { MediaType.ALBUM: "album_id", MediaType.ARTIST: "artist_id", MediaType.TRACK: "track_id", MediaType.PLAYLIST: "playlist_id", MediaType.GENRE: "genre_id", "Favorites": "item_id", + MediaType.APPS: "item_id", } CONTENT_TYPE_MEDIA_CLASS: dict[str | MediaType, dict[str, MediaClass | None]] = { "Favorites": {"item": MediaClass.DIRECTORY, "children": MediaClass.TRACK}, + "Apps": {"item": MediaClass.DIRECTORY, "children": MediaClass.APP}, + "Radios": {"item": MediaClass.DIRECTORY, "children": MediaClass.APP}, + "App": {"item": MediaClass.DIRECTORY, "children": MediaClass.TRACK}, "Artists": {"item": MediaClass.DIRECTORY, "children": MediaClass.ARTIST}, "Albums": {"item": MediaClass.DIRECTORY, "children": MediaClass.ALBUM}, "Tracks": {"item": MediaClass.DIRECTORY, "children": MediaClass.TRACK}, @@ -65,9 +76,14 @@ CONTENT_TYPE_MEDIA_CLASS: dict[str | MediaType, dict[str, MediaClass | None]] = MediaType.TRACK: {"item": MediaClass.TRACK, "children": None}, MediaType.GENRE: {"item": MediaClass.GENRE, "children": MediaClass.ARTIST}, MediaType.PLAYLIST: {"item": MediaClass.PLAYLIST, "children": MediaClass.TRACK}, + MediaType.APP: {"item": MediaClass.DIRECTORY, "children": MediaClass.TRACK}, + MediaType.APPS: {"item": MediaClass.DIRECTORY, "children": MediaClass.APP}, } -CONTENT_TYPE_TO_CHILD_TYPE = { +CONTENT_TYPE_TO_CHILD_TYPE: dict[ + str | MediaType, + str | MediaType | None, +] = { MediaType.ALBUM: MediaType.TRACK, MediaType.PLAYLIST: MediaType.PLAYLIST, MediaType.ARTIST: MediaType.ALBUM, @@ -78,15 +94,93 @@ CONTENT_TYPE_TO_CHILD_TYPE = { "Playlists": MediaType.PLAYLIST, "Genres": MediaType.GENRE, "Favorites": None, # can only be determined after inspecting the item + "Apps": MediaClass.APP, + "Radios": MediaClass.APP, + "App": None, # can only be determined after inspecting the item "New Music": MediaType.ALBUM, + MediaType.APPS: MediaType.APP, + MediaType.APP: MediaType.TRACK, } +@dataclass +class BrowseData: + """Class for browser to squeezebox mappings and other browse data.""" + + content_type_to_child_type: dict[ + str | MediaType, + str | MediaType | None, + ] = field(default_factory=dict) + content_type_media_class: dict[str | MediaType, dict[str, MediaClass | None]] = ( + field(default_factory=dict) + ) + squeezebox_id_by_type: dict[str | MediaType, str] = field(default_factory=dict) + media_type_to_squeezebox: dict[str | MediaType, str] = field(default_factory=dict) + known_apps_radios: set[str] = field(default_factory=set) + + def __post_init__(self) -> None: + """Initialise the maps.""" + self.content_type_media_class.update(CONTENT_TYPE_MEDIA_CLASS) + self.content_type_to_child_type.update(CONTENT_TYPE_TO_CHILD_TYPE) + self.squeezebox_id_by_type.update(SQUEEZEBOX_ID_BY_TYPE) + self.media_type_to_squeezebox.update(MEDIA_TYPE_TO_SQUEEZEBOX) + + +@dataclass +class BrowseItemResponse: + """Class for response data for browse item functions.""" + + child_item_type: str | MediaType + child_media_class: dict[str, MediaClass | None] + can_expand: bool + can_play: bool + + +def _add_new_command_to_browse_data( + browse_data: BrowseData, cmd: str | MediaType, type: str +) -> None: + """Add items to maps for new apps or radios.""" + browse_data.media_type_to_squeezebox[cmd] = cmd + browse_data.squeezebox_id_by_type[cmd] = type + browse_data.content_type_media_class[cmd] = { + "item": MediaClass.DIRECTORY, + "children": MediaClass.TRACK, + } + browse_data.content_type_to_child_type[cmd] = MediaType.TRACK + + +def _build_response_apps_radios_category( + browse_data: BrowseData, + cmd: str | MediaType, +) -> BrowseItemResponse: + """Build item for App or radio category.""" + return BrowseItemResponse( + child_item_type=cmd, + child_media_class=browse_data.content_type_media_class[cmd], + can_expand=True, + can_play=False, + ) + + +def _build_response_known_app( + browse_data: BrowseData, search_type: str, item: dict[str, Any] +) -> BrowseItemResponse: + """Build item for app or radio.""" + + return BrowseItemResponse( + child_item_type=search_type, + child_media_class=browse_data.content_type_media_class[search_type], + can_play=bool(item["isaudio"] and item.get("url")), + can_expand=item["hasitems"], + ) + + async def build_item_response( entity: MediaPlayerEntity, player: Player, payload: dict[str, str | None], browse_limit: int, + browse_data: BrowseData, ) -> BrowseMedia: """Create response payload for search described by payload.""" @@ -97,29 +191,30 @@ async def build_item_response( assert ( search_type is not None ) # async_browse_media will not call this function if search_type is None - media_class = CONTENT_TYPE_MEDIA_CLASS[search_type] + media_class = browse_data.content_type_media_class[search_type] children = None if search_id and search_id != search_type: - browse_id = (SQUEEZEBOX_ID_BY_TYPE[search_type], search_id) + browse_id = (browse_data.squeezebox_id_by_type[search_type], search_id) else: browse_id = None result = await player.async_browse( - MEDIA_TYPE_TO_SQUEEZEBOX[search_type], + browse_data.media_type_to_squeezebox[search_type], limit=browse_limit, browse_id=browse_id, ) if result is not None and result.get("items"): - item_type = CONTENT_TYPE_TO_CHILD_TYPE[search_type] + item_type = browse_data.content_type_to_child_type[search_type] children = [] list_playable = [] for item in result["items"]: - item_id = str(item["id"]) + item_id = str(item.get("id", "")) item_thumbnail: str | None = None + if item_type: child_item_type: MediaType | str = item_type child_media_class = CONTENT_TYPE_MEDIA_CLASS[item_type] @@ -144,6 +239,47 @@ async def build_item_response( can_expand = item["hasitems"] can_play = item["isaudio"] and item.get("url") + if search_type in ["Apps", "Radios"]: + # item["cmd"] contains the name of the command to use with the cli for the app + # add the command to the dictionaries + if item["title"] == "Search" or item.get("type") in UNPLAYABLE_TYPES: + # Skip searches in apps as they'd need UI or if the link isn't to audio + continue + app_cmd = "app-" + item["cmd"] + + if app_cmd not in browse_data.known_apps_radios: + browse_data.known_apps_radios.add(app_cmd) + + _add_new_command_to_browse_data(browse_data, app_cmd, "item_id") + + browse_item_response = _build_response_apps_radios_category( + browse_data, app_cmd + ) + + # Temporary variables until remainder of browse calls are restructured + child_item_type = browse_item_response.child_item_type + child_media_class = browse_item_response.child_media_class + can_expand = browse_item_response.can_expand + can_play = browse_item_response.can_play + + elif search_type in browse_data.known_apps_radios: + if ( + item.get("title") in ["Search", None] + or item.get("type") in UNPLAYABLE_TYPES + ): + # Skip searches in apps as they'd need UI + continue + + browse_item_response = _build_response_known_app( + browse_data, search_type, item + ) + + # Temporary variables until remainder of browse calls are restructured + child_item_type = browse_item_response.child_item_type + child_media_class = browse_item_response.child_media_class + can_expand = browse_item_response.can_expand + can_play = browse_item_response.can_play + if artwork_track_id := item.get("artwork_track_id"): if internal_request: item_thumbnail = player.generate_image_url_from_track_id( @@ -153,6 +289,8 @@ async def build_item_response( item_thumbnail = entity.get_browse_image_url( item_type, item_id, artwork_track_id ) + elif search_type in ["Apps", "Radios"]: + item_thumbnail = player.generate_image_url(item["icon"]) else: item_thumbnail = item.get("image_url") # will not be proxied by HA @@ -176,6 +314,7 @@ async def build_item_response( assert media_class["item"] is not None if not search_id: search_id = search_type + return BrowseMedia( title=result.get("title"), media_class=media_class["item"], @@ -188,7 +327,11 @@ async def build_item_response( ) -async def library_payload(hass: HomeAssistant, player: Player) -> BrowseMedia: +async def library_payload( + hass: HomeAssistant, + player: Player, + browse_media: BrowseData, +) -> BrowseMedia: """Create response payload to describe contents of library.""" library_info: dict[str, Any] = { "title": "Music Library", @@ -201,10 +344,10 @@ async def library_payload(hass: HomeAssistant, player: Player) -> BrowseMedia: } for item in LIBRARY: - media_class = CONTENT_TYPE_MEDIA_CLASS[item] + media_class = browse_media.content_type_media_class[item] result = await player.async_browse( - MEDIA_TYPE_TO_SQUEEZEBOX[item], + browse_media.media_type_to_squeezebox[item], limit=1, ) if result is not None and result.get("items") is not None: @@ -215,7 +358,7 @@ async def library_payload(hass: HomeAssistant, player: Player) -> BrowseMedia: media_class=media_class["children"], media_content_id=item, media_content_type=item, - can_play=item != "Favorites", + can_play=item not in ["Favorites", "Apps", "Radios"], can_expand=True, ) ) @@ -242,17 +385,23 @@ async def generate_playlist( player: Player, payload: dict[str, str], browse_limit: int, + browse_media: BrowseData, ) -> list | None: """Generate playlist from browsing payload.""" media_type = payload["search_type"] media_id = payload["search_id"] - if media_type not in SQUEEZEBOX_ID_BY_TYPE: + if media_type not in browse_media.squeezebox_id_by_type: raise BrowseError(f"Media type not supported: {media_type}") - browse_id = (SQUEEZEBOX_ID_BY_TYPE[media_type], media_id) + browse_id = (browse_media.squeezebox_id_by_type[media_type], media_id) + if media_type.startswith("app-"): + category = media_type + else: + category = "titles" + result = await player.async_browse( - "titles", limit=browse_limit, browse_id=browse_id + category, limit=browse_limit, browse_id=browse_id ) if result and "items" in result: items: list = result["items"] diff --git a/homeassistant/components/squeezebox/const.py b/homeassistant/components/squeezebox/const.py index 61ec3cac2fa..5ce95d25632 100644 --- a/homeassistant/components/squeezebox/const.py +++ b/homeassistant/components/squeezebox/const.py @@ -27,7 +27,12 @@ STATUS_QUERY_LIBRARYNAME = "libraryname" STATUS_QUERY_MAC = "mac" STATUS_QUERY_UUID = "uuid" STATUS_QUERY_VERSION = "version" -SQUEEZEBOX_SOURCE_STRINGS = ("source:", "wavin:", "spotify:") +SQUEEZEBOX_SOURCE_STRINGS = ( + "source:", + "wavin:", + "spotify:", + "loop:", +) SIGNAL_PLAYER_DISCOVERED = "squeezebox_player_discovered" SIGNAL_PLAYER_REDISCOVERED = "squeezebox_player_rediscovered" DISCOVERY_INTERVAL = 60 @@ -38,3 +43,4 @@ DEFAULT_BROWSE_LIMIT = 1000 DEFAULT_VOLUME_STEP = 5 ATTR_ANNOUNCE_VOLUME = "announce_volume" ATTR_ANNOUNCE_TIMEOUT = "announce_timeout" +UNPLAYABLE_TYPES = ("text", "actions") diff --git a/homeassistant/components/squeezebox/media_player.py b/homeassistant/components/squeezebox/media_player.py index 48015f86ba0..0cd539b4584 100644 --- a/homeassistant/components/squeezebox/media_player.py +++ b/homeassistant/components/squeezebox/media_player.py @@ -47,6 +47,7 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util.dt import utcnow from .browse_media import ( + BrowseData, build_item_response, generate_playlist, library_payload, @@ -240,6 +241,7 @@ class SqueezeBoxMediaPlayerEntity( model=player.model, manufacturer=_manufacturer, ) + self._browse_data = BrowseData() @callback def _handle_coordinator_update(self) -> None: @@ -530,9 +532,7 @@ class SqueezeBoxMediaPlayerEntity( "search_type": MediaType.PLAYLIST, } playlist = await generate_playlist( - self._player, - payload, - self.browse_limit, + self._player, payload, self.browse_limit, self._browse_data ) except BrowseError: # a list of urls @@ -545,9 +545,7 @@ class SqueezeBoxMediaPlayerEntity( "search_type": media_type, } playlist = await generate_playlist( - self._player, - payload, - self.browse_limit, + self._player, payload, self.browse_limit, self._browse_data ) _LOGGER.debug("Generated playlist: %s", playlist) @@ -646,7 +644,7 @@ class SqueezeBoxMediaPlayerEntity( ) if media_content_type in [None, "library"]: - return await library_payload(self.hass, self._player) + return await library_payload(self.hass, self._player, self._browse_data) if media_content_id and media_source.is_media_source_id(media_content_id): return await media_source.async_browse_media( @@ -663,6 +661,7 @@ class SqueezeBoxMediaPlayerEntity( self._player, payload, self.browse_limit, + self._browse_data, ) async def async_get_browse_image( diff --git a/tests/components/squeezebox/conftest.py b/tests/components/squeezebox/conftest.py index 9224334a716..cb77495e818 100644 --- a/tests/components/squeezebox/conftest.py +++ b/tests/components/squeezebox/conftest.py @@ -142,6 +142,9 @@ async def mock_async_browse( "title": "title", "playlists": "playlist", "playlist": "title", + "apps": "app", + "radios": "app", + "app-fakecommand": "track", } fake_items = [ { @@ -152,6 +155,8 @@ async def mock_async_browse( "item_type": child_types[media_type], "artwork_track_id": "b35bb9e9", "url": "file:///var/lib/squeezeboxserver/music/track_1.mp3", + "cmd": "fakecommand", + "icon": "plugins/Qobuz/html/images/qobuz.png", }, { "title": "Fake Item 2", @@ -161,6 +166,8 @@ async def mock_async_browse( "item_type": child_types[media_type], "image_url": "http://lms.internal:9000/html/images/favorites.png", "url": "file:///var/lib/squeezeboxserver/music/track_2.mp3", + "cmd": "fakecommand", + "icon": "plugins/Qobuz/html/images/qobuz.png", }, { "title": "Fake Item 3", @@ -169,6 +176,19 @@ async def mock_async_browse( "isaudio": True, "album_id": FAKE_VALID_ITEM_ID if media_type == "favorites" else None, "url": "file:///var/lib/squeezeboxserver/music/track_3.mp3", + "cmd": "fakecommand", + "icon": "plugins/Qobuz/html/images/qobuz.png", + }, + { + "title": "Fake Invalid Item 1", + "id": FAKE_VALID_ITEM_ID + "invalid_3", + "hasitems": media_type == "favorites", + "isaudio": True, + "album_id": FAKE_VALID_ITEM_ID if media_type == "favorites" else None, + "url": "file:///var/lib/squeezeboxserver/music/track_3.mp3", + "cmd": "fakecommand", + "icon": "plugins/Qobuz/html/images/qobuz.png", + "type": "text", }, ] @@ -198,7 +218,10 @@ async def mock_async_browse( "items": fake_items, } return None - if media_type in MEDIA_TYPE_TO_SQUEEZEBOX.values(): + if ( + media_type in MEDIA_TYPE_TO_SQUEEZEBOX.values() + or media_type == "app-fakecommand" + ): return { "title": media_type, "items": fake_items, @@ -232,6 +255,9 @@ def mock_pysqueezebox_player(uuid: str) -> MagicMock: mock_player.async_play_announcement = AsyncMock( side_effect=mock_async_play_announcement ) + mock_player.generate_image_url = MagicMock( + return_value="http://lms.internal:9000/html/images/favorites.png" + ) mock_player.name = TEST_PLAYER_NAME mock_player.player_id = uuid mock_player.mode = "stop" diff --git a/tests/components/squeezebox/test_media_browser.py b/tests/components/squeezebox/test_media_browser.py index c03c1b6344d..f00ea1754fc 100644 --- a/tests/components/squeezebox/test_media_browser.py +++ b/tests/components/squeezebox/test_media_browser.py @@ -19,6 +19,8 @@ from homeassistant.components.squeezebox.browse_media import ( from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant +from .conftest import FAKE_VALID_ITEM_ID + from tests.common import MockConfigEntry from tests.typing import WebSocketGenerator @@ -66,56 +68,143 @@ async def test_async_browse_media_root( assert item["title"] == LIBRARY[idx] +@pytest.mark.parametrize( + ("category", "child_count"), + [ + ("Favorites", 4), + ("Artists", 4), + ("Albums", 4), + ("Playlists", 4), + ("Genres", 4), + ("New Music", 4), + ("Apps", 3), + ("Radios", 3), + ], +) async def test_async_browse_media_with_subitems( hass: HomeAssistant, config_entry: MockConfigEntry, hass_ws_client: WebSocketGenerator, + category: str, + child_count: int, ) -> None: """Test each category with subitems.""" - for category in ( - "Favorites", - "Artists", - "Albums", - "Playlists", - "Genres", - "New Music", + with patch( + "homeassistant.components.squeezebox.browse_media.is_internal_request", + return_value=False, ): - with patch( - "homeassistant.components.squeezebox.browse_media.is_internal_request", - return_value=False, - ): - client = await hass_ws_client() - await client.send_json( - { - "id": 1, - "type": "media_player/browse_media", - "entity_id": "media_player.test_player", - "media_content_id": "", - "media_content_type": category, - } - ) - response = await client.receive_json() - assert response["success"] - category_level = response["result"] - assert category_level["title"] == MEDIA_TYPE_TO_SQUEEZEBOX[category] - assert category_level["children"][0]["title"] == "Fake Item 1" + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "media_player/browse_media", + "entity_id": "media_player.test_player", + "media_content_id": "", + "media_content_type": category, + } + ) + response = await client.receive_json() + assert response["success"] + category_level = response["result"] + assert category_level["title"] == MEDIA_TYPE_TO_SQUEEZEBOX[category] + assert category_level["children"][0]["title"] == "Fake Item 1" + assert len(category_level["children"]) == child_count - # Look up a subitem - search_type = category_level["children"][0]["media_content_type"] - search_id = category_level["children"][0]["media_content_id"] - await client.send_json( + # Look up a subitem + search_type = category_level["children"][0]["media_content_type"] + search_id = category_level["children"][0]["media_content_id"] + await client.send_json( + { + "id": 2, + "type": "media_player/browse_media", + "entity_id": "media_player.test_player", + "media_content_id": search_id, + "media_content_type": search_type, + } + ) + response = await client.receive_json() + assert response["success"] + search = response["result"] + assert search["title"] == "Fake Item 1" + + +async def test_async_browse_media_for_apps( + hass: HomeAssistant, + config_entry: MockConfigEntry, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test browsing for app category.""" + with patch( + "homeassistant.components.squeezebox.browse_media.is_internal_request", + return_value=False, + ): + category = "Apps" + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "media_player/browse_media", + "entity_id": "media_player.test_player", + "media_content_id": "", + "media_content_type": category, + } + ) + response = await client.receive_json() + assert response["success"] + + # Look up a subitem + await client.send_json( + { + "id": 2, + "type": "media_player/browse_media", + "entity_id": "media_player.test_player", + "media_content_id": "", + "media_content_type": "app-fakecommand", + } + ) + response = await client.receive_json() + assert response["success"] + search = response["result"] + assert search["children"][0]["title"] == "Fake Item 1" + assert "Fake Invalid Item 1" not in search + + +async def test_generate_playlist_for_app( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test the generate_playlist for app-fakecommand media type.""" + with patch( + "homeassistant.components.squeezebox.browse_media.is_internal_request", + return_value=False, + ): + category = "Apps" + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "media_player/browse_media", + "entity_id": "media_player.test_player", + "media_content_id": "", + "media_content_type": category, + } + ) + response = await client.receive_json() + assert response["success"] + + try: + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_PLAY_MEDIA, { - "id": 2, - "type": "media_player/browse_media", - "entity_id": "media_player.test_player", - "media_content_id": search_id, - "media_content_type": search_type, - } + ATTR_ENTITY_ID: "media_player.test_player", + ATTR_MEDIA_CONTENT_TYPE: "app-fakecommand", + ATTR_MEDIA_CONTENT_ID: FAKE_VALID_ITEM_ID, + }, + blocking=True, ) - response = await client.receive_json() - assert response["success"] - search = response["result"] - assert search["title"] == "Fake Item 1" + except BrowseError: + pytest.fail("generate_playlist fails for app") async def test_async_browse_tracks( @@ -142,7 +231,7 @@ async def test_async_browse_tracks( assert response["success"] tracks = response["result"] assert tracks["title"] == "titles" - assert len(tracks["children"]) == 3 + assert len(tracks["children"]) == 4 async def test_async_browse_error( From dc92e912c2885d69071bf1721c4ea60eef0fc3f2 Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Mon, 24 Feb 2025 20:59:51 +0100 Subject: [PATCH 1743/3148] Add azure_storage as backup agent (#134085) Co-authored-by: Martin Hjelmare --- .strict-typing | 1 + CODEOWNERS | 2 + homeassistant/brands/microsoft.json | 1 + .../components/azure_storage/__init__.py | 82 +++++ .../components/azure_storage/backup.py | 182 ++++++++++ .../components/azure_storage/config_flow.py | 72 ++++ .../components/azure_storage/const.py | 16 + .../components/azure_storage/manifest.json | 12 + .../azure_storage/quality_scale.yaml | 133 ++++++++ .../components/azure_storage/strings.json | 48 +++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + mypy.ini | 10 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/azure_storage/__init__.py | 14 + tests/components/azure_storage/conftest.py | 63 ++++ tests/components/azure_storage/const.py | 36 ++ tests/components/azure_storage/test_backup.py | 317 ++++++++++++++++++ .../azure_storage/test_config_flow.py | 113 +++++++ tests/components/azure_storage/test_init.py | 54 +++ 21 files changed, 1169 insertions(+) create mode 100644 homeassistant/components/azure_storage/__init__.py create mode 100644 homeassistant/components/azure_storage/backup.py create mode 100644 homeassistant/components/azure_storage/config_flow.py create mode 100644 homeassistant/components/azure_storage/const.py create mode 100644 homeassistant/components/azure_storage/manifest.json create mode 100644 homeassistant/components/azure_storage/quality_scale.yaml create mode 100644 homeassistant/components/azure_storage/strings.json create mode 100644 tests/components/azure_storage/__init__.py create mode 100644 tests/components/azure_storage/conftest.py create mode 100644 tests/components/azure_storage/const.py create mode 100644 tests/components/azure_storage/test_backup.py create mode 100644 tests/components/azure_storage/test_config_flow.py create mode 100644 tests/components/azure_storage/test_init.py diff --git a/.strict-typing b/.strict-typing index 95eb2abb4b4..1df49300b1e 100644 --- a/.strict-typing +++ b/.strict-typing @@ -103,6 +103,7 @@ homeassistant.components.auth.* homeassistant.components.automation.* homeassistant.components.awair.* homeassistant.components.axis.* +homeassistant.components.azure_storage.* homeassistant.components.backup.* homeassistant.components.baf.* homeassistant.components.bang_olufsen.* diff --git a/CODEOWNERS b/CODEOWNERS index bb8545c46b7..87f170009f0 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -180,6 +180,8 @@ build.json @home-assistant/supervisor /homeassistant/components/azure_event_hub/ @eavanvalkenburg /tests/components/azure_event_hub/ @eavanvalkenburg /homeassistant/components/azure_service_bus/ @hfurubotten +/homeassistant/components/azure_storage/ @zweckj +/tests/components/azure_storage/ @zweckj /homeassistant/components/backup/ @home-assistant/core /tests/components/backup/ @home-assistant/core /homeassistant/components/baf/ @bdraco @jfroy diff --git a/homeassistant/brands/microsoft.json b/homeassistant/brands/microsoft.json index 0e00c4a7bc3..918f67f06dd 100644 --- a/homeassistant/brands/microsoft.json +++ b/homeassistant/brands/microsoft.json @@ -6,6 +6,7 @@ "azure_devops", "azure_event_hub", "azure_service_bus", + "azure_storage", "microsoft_face_detect", "microsoft_face_identify", "microsoft_face", diff --git a/homeassistant/components/azure_storage/__init__.py b/homeassistant/components/azure_storage/__init__.py new file mode 100644 index 00000000000..873a9ab90ca --- /dev/null +++ b/homeassistant/components/azure_storage/__init__.py @@ -0,0 +1,82 @@ +"""The Azure Storage integration.""" + +from aiohttp import ClientTimeout +from azure.core.exceptions import ( + ClientAuthenticationError, + HttpResponseError, + ResourceNotFoundError, +) +from azure.core.pipeline.transport._aiohttp import ( + AioHttpTransport, +) # need to import from private file, as it is not properly imported in the init +from azure.storage.blob.aio import ContainerClient + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_create_clientsession + +from .const import ( + CONF_ACCOUNT_NAME, + CONF_CONTAINER_NAME, + CONF_STORAGE_ACCOUNT_KEY, + DATA_BACKUP_AGENT_LISTENERS, + DOMAIN, +) + +type AzureStorageConfigEntry = ConfigEntry[ContainerClient] + + +async def async_setup_entry( + hass: HomeAssistant, entry: AzureStorageConfigEntry +) -> bool: + """Set up Azure Storage integration.""" + # set increase aiohttp timeout for long running operations (up/download) + session = async_create_clientsession( + hass, timeout=ClientTimeout(connect=10, total=12 * 60 * 60) + ) + container_client = ContainerClient( + account_url=f"https://{entry.data[CONF_ACCOUNT_NAME]}.blob.core.windows.net/", + container_name=entry.data[CONF_CONTAINER_NAME], + credential=entry.data[CONF_STORAGE_ACCOUNT_KEY], + transport=AioHttpTransport(session=session), + ) + + try: + if not await container_client.exists(): + await container_client.create_container() + except ResourceNotFoundError as err: + raise ConfigEntryError( + translation_domain=DOMAIN, + translation_key="account_not_found", + translation_placeholders={CONF_ACCOUNT_NAME: entry.data[CONF_ACCOUNT_NAME]}, + ) from err + except ClientAuthenticationError as err: + raise ConfigEntryError( + translation_domain=DOMAIN, + translation_key="invalid_auth", + translation_placeholders={CONF_ACCOUNT_NAME: entry.data[CONF_ACCOUNT_NAME]}, + ) from err + except HttpResponseError as err: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="cannot_connect", + translation_placeholders={CONF_ACCOUNT_NAME: entry.data[CONF_ACCOUNT_NAME]}, + ) from err + + entry.runtime_data = container_client + + def _async_notify_backup_listeners() -> None: + for listener in hass.data.get(DATA_BACKUP_AGENT_LISTENERS, []): + listener() + + entry.async_on_unload(entry.async_on_state_change(_async_notify_backup_listeners)) + + return True + + +async def async_unload_entry( + hass: HomeAssistant, entry: AzureStorageConfigEntry +) -> bool: + """Unload an Azure Storage config entry.""" + return True diff --git a/homeassistant/components/azure_storage/backup.py b/homeassistant/components/azure_storage/backup.py new file mode 100644 index 00000000000..6f39295761d --- /dev/null +++ b/homeassistant/components/azure_storage/backup.py @@ -0,0 +1,182 @@ +"""Support for Azure Storage backup.""" + +from __future__ import annotations + +from collections.abc import AsyncIterator, Callable, Coroutine +from functools import wraps +import json +import logging +from typing import Any, Concatenate + +from azure.core.exceptions import HttpResponseError +from azure.storage.blob import BlobProperties + +from homeassistant.components.backup import ( + AgentBackup, + BackupAgent, + BackupAgentError, + BackupNotFound, + suggested_filename, +) +from homeassistant.core import HomeAssistant, callback + +from . import AzureStorageConfigEntry +from .const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN + +_LOGGER = logging.getLogger(__name__) +METADATA_VERSION = "1" + + +async def async_get_backup_agents( + hass: HomeAssistant, +) -> list[BackupAgent]: + """Return a list of backup agents.""" + entries: list[AzureStorageConfigEntry] = hass.config_entries.async_loaded_entries( + DOMAIN + ) + return [AzureStorageBackupAgent(hass, entry) for entry in entries] + + +@callback +def async_register_backup_agents_listener( + hass: HomeAssistant, + *, + listener: Callable[[], None], + **kwargs: Any, +) -> Callable[[], None]: + """Register a listener to be called when agents are added or removed.""" + hass.data.setdefault(DATA_BACKUP_AGENT_LISTENERS, []).append(listener) + + @callback + def remove_listener() -> None: + """Remove the listener.""" + hass.data[DATA_BACKUP_AGENT_LISTENERS].remove(listener) + if not hass.data[DATA_BACKUP_AGENT_LISTENERS]: + hass.data.pop(DATA_BACKUP_AGENT_LISTENERS) + + return remove_listener + + +def handle_backup_errors[_R, **P]( + func: Callable[Concatenate[AzureStorageBackupAgent, P], Coroutine[Any, Any, _R]], +) -> Callable[Concatenate[AzureStorageBackupAgent, P], Coroutine[Any, Any, _R]]: + """Handle backup errors.""" + + @wraps(func) + async def wrapper( + self: AzureStorageBackupAgent, *args: P.args, **kwargs: P.kwargs + ) -> _R: + try: + return await func(self, *args, **kwargs) + except HttpResponseError as err: + _LOGGER.debug( + "Error during backup in %s: Status %s, message %s", + func.__name__, + err.status_code, + err.message, + exc_info=True, + ) + raise BackupAgentError( + f"Error during backup operation in {func.__name__}:" + f" Status {err.status_code}, message: {err.message}" + ) from err + + return wrapper + + +class AzureStorageBackupAgent(BackupAgent): + """Azure storage backup agent.""" + + domain = DOMAIN + + def __init__(self, hass: HomeAssistant, entry: AzureStorageConfigEntry) -> None: + """Initialize the Azure storage backup agent.""" + super().__init__() + self._client = entry.runtime_data + self.name = entry.title + self.unique_id = entry.entry_id + + @handle_backup_errors + async def async_download_backup( + self, + backup_id: str, + **kwargs: Any, + ) -> AsyncIterator[bytes]: + """Download a backup file.""" + blob = await self._find_blob_by_backup_id(backup_id) + if blob is None: + raise BackupNotFound(f"Backup {backup_id} not found") + download_stream = await self._client.download_blob(blob.name) + return download_stream.chunks() + + @handle_backup_errors + async def async_upload_backup( + self, + *, + open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]], + backup: AgentBackup, + **kwargs: Any, + ) -> None: + """Upload a backup.""" + + metadata = { + "metadata_version": METADATA_VERSION, + "backup_id": backup.backup_id, + "backup_metadata": json.dumps(backup.as_dict()), + } + + await self._client.upload_blob( + name=suggested_filename(backup), + metadata=metadata, + data=await open_stream(), + length=backup.size, + ) + + @handle_backup_errors + async def async_delete_backup( + self, + backup_id: str, + **kwargs: Any, + ) -> None: + """Delete a backup file.""" + blob = await self._find_blob_by_backup_id(backup_id) + if blob is None: + return + await self._client.delete_blob(blob.name) + + @handle_backup_errors + async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]: + """List backups.""" + backups: list[AgentBackup] = [] + async for blob in self._client.list_blobs(include="metadata"): + metadata = blob.metadata + + if metadata.get("metadata_version") == METADATA_VERSION: + backups.append( + AgentBackup.from_dict(json.loads(metadata["backup_metadata"])) + ) + + return backups + + @handle_backup_errors + async def async_get_backup( + self, + backup_id: str, + **kwargs: Any, + ) -> AgentBackup | None: + """Return a backup.""" + blob = await self._find_blob_by_backup_id(backup_id) + if blob is None: + return None + + return AgentBackup.from_dict(json.loads(blob.metadata["backup_metadata"])) + + async def _find_blob_by_backup_id(self, backup_id: str) -> BlobProperties | None: + """Find a blob by backup id.""" + async for blob in self._client.list_blobs(include="metadata"): + if ( + backup_id == blob.metadata.get("backup_id", "") + and blob.metadata.get("metadata_version") == METADATA_VERSION + ): + return blob + return None diff --git a/homeassistant/components/azure_storage/config_flow.py b/homeassistant/components/azure_storage/config_flow.py new file mode 100644 index 00000000000..e5b1214fa5b --- /dev/null +++ b/homeassistant/components/azure_storage/config_flow.py @@ -0,0 +1,72 @@ +"""Config flow for Azure Storage integration.""" + +import logging +from typing import Any + +from azure.core.exceptions import ClientAuthenticationError, ResourceNotFoundError +from azure.core.pipeline.transport._aiohttp import ( + AioHttpTransport, +) # need to import from private file, as it is not properly imported in the init +from azure.storage.blob.aio import ContainerClient +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import ( + CONF_ACCOUNT_NAME, + CONF_CONTAINER_NAME, + CONF_STORAGE_ACCOUNT_KEY, + DOMAIN, +) + +_LOGGER = logging.getLogger(__name__) + + +class AzureStorageConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for azure storage.""" + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """User step for Azure Storage.""" + + errors: dict[str, str] = {} + + if user_input is not None: + self._async_abort_entries_match( + {CONF_ACCOUNT_NAME: user_input[CONF_ACCOUNT_NAME]} + ) + container_client = ContainerClient( + account_url=f"https://{user_input[CONF_ACCOUNT_NAME]}.blob.core.windows.net/", + container_name=user_input[CONF_CONTAINER_NAME], + credential=user_input[CONF_STORAGE_ACCOUNT_KEY], + transport=AioHttpTransport(session=async_get_clientsession(self.hass)), + ) + try: + await container_client.exists() + except ResourceNotFoundError: + errors["base"] = "cannot_connect" + except ClientAuthenticationError: + errors[CONF_STORAGE_ACCOUNT_KEY] = "invalid_auth" + except Exception: + _LOGGER.exception("Unknown exception occurred") + errors["base"] = "unknown" + if not errors: + return self.async_create_entry( + title=f"{user_input[CONF_ACCOUNT_NAME]}/{user_input[CONF_CONTAINER_NAME]}", + data=user_input, + ) + + return self.async_show_form( + data_schema=vol.Schema( + { + vol.Required(CONF_ACCOUNT_NAME): str, + vol.Required( + CONF_CONTAINER_NAME, default="home-assistant-backups" + ): str, + vol.Required(CONF_STORAGE_ACCOUNT_KEY): str, + } + ), + errors=errors, + ) diff --git a/homeassistant/components/azure_storage/const.py b/homeassistant/components/azure_storage/const.py new file mode 100644 index 00000000000..efcb338a096 --- /dev/null +++ b/homeassistant/components/azure_storage/const.py @@ -0,0 +1,16 @@ +"""Constants for the Azure Storage integration.""" + +from collections.abc import Callable +from typing import Final + +from homeassistant.util.hass_dict import HassKey + +DOMAIN: Final = "azure_storage" + +CONF_STORAGE_ACCOUNT_KEY: Final = "storage_account_key" +CONF_ACCOUNT_NAME: Final = "account_name" +CONF_CONTAINER_NAME: Final = "container_name" + +DATA_BACKUP_AGENT_LISTENERS: HassKey[list[Callable[[], None]]] = HassKey( + f"{DOMAIN}.backup_agent_listeners" +) diff --git a/homeassistant/components/azure_storage/manifest.json b/homeassistant/components/azure_storage/manifest.json new file mode 100644 index 00000000000..8f2d8aeaca7 --- /dev/null +++ b/homeassistant/components/azure_storage/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "azure_storage", + "name": "Azure Storage", + "codeowners": ["@zweckj"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/azure_storage", + "integration_type": "service", + "iot_class": "cloud_polling", + "loggers": ["azure-storage-blob"], + "quality_scale": "bronze", + "requirements": ["azure-storage-blob==12.24.0"] +} diff --git a/homeassistant/components/azure_storage/quality_scale.yaml b/homeassistant/components/azure_storage/quality_scale.yaml new file mode 100644 index 00000000000..6b6f90de494 --- /dev/null +++ b/homeassistant/components/azure_storage/quality_scale.yaml @@ -0,0 +1,133 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: Integration does not register custom actions. + appropriate-polling: + status: exempt + comment: | + This integration does not poll. + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: | + This integration does not have any custom actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: | + Entities of this integration does not explicitly subscribe to events. + entity-unique-id: + status: exempt + comment: | + This integration does not have entities. + has-entity-name: + status: exempt + comment: | + This integration does not have entities. + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: done + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: | + This integration does not have any configuration parameters. + docs-installation-parameters: done + entity-unavailable: + status: exempt + comment: | + This integration does not have entities. + integration-owner: done + log-when-unavailable: + status: exempt + comment: | + This integration does not have entities. + parallel-updates: + status: exempt + comment: | + This integration does not have platforms. + reauthentication-flow: todo + test-coverage: done + + # Gold + devices: + status: exempt + comment: | + This integration connects to a single service. + diagnostics: + status: exempt + comment: | + There is no data to diagnose. + discovery-update-info: + status: exempt + comment: | + This integration is a cloud service and does not support discovery. + discovery: + status: exempt + comment: | + This integration is a cloud service and does not support discovery. + docs-data-update: + status: exempt + comment: | + This integration does not poll or push. + docs-examples: + status: exempt + comment: | + This integration only serves backup. + docs-known-limitations: done + docs-supported-devices: + status: exempt + comment: | + This integration is a cloud service. + docs-supported-functions: + status: exempt + comment: | + This integration does not have entities. + docs-troubleshooting: done + docs-use-cases: done + dynamic-devices: + status: exempt + comment: | + This integration connects to a single service. + entity-category: + status: exempt + comment: | + This integration does not have entities. + entity-device-class: + status: exempt + comment: | + This integration does not have entities. + entity-disabled-by-default: + status: exempt + comment: | + This integration does not have entities. + entity-translations: + status: exempt + comment: | + This integration does not have entities. + exception-translations: done + icon-translations: + status: exempt + comment: | + This integration does not have entities. + reconfiguration-flow: todo + repair-issues: done + stale-devices: + status: exempt + comment: | + This integration connects to a single service. + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/azure_storage/strings.json b/homeassistant/components/azure_storage/strings.json new file mode 100644 index 00000000000..4bd4cb0dfba --- /dev/null +++ b/homeassistant/components/azure_storage/strings.json @@ -0,0 +1,48 @@ +{ + "config": { + "error": { + "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%]" + }, + "step": { + "user": { + "data": { + "storage_account_key": "Storage account key", + "account_name": "Account name", + "container_name": "Container name" + }, + "data_description": { + "storage_account_key": "Storage account access key used for authorization", + "account_name": "Name of the storage account", + "container_name": "Name of the storage container to be used (will be created if it does not exist)" + }, + "description": "Set up an Azure (Blob) storage account to be used for backups.", + "title": "Add Azure storage account" + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + } + }, + "issues": { + "container_not_found": { + "title": "Storage container not found", + "description": "The storage container {container_name} has not been found in the storage account. Please re-create it manually, then fix this issue." + } + }, + "exceptions": { + "account_not_found": { + "message": "Storage account {account_name} not found" + }, + "cannot_connect": { + "message": "Can not connect to storage account {account_name}" + }, + "invalid_auth": { + "message": "Authentication failed for storage account {account_name}" + }, + "container_not_found": { + "message": "Storage container {container_name} not found" + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index de581c65297..8284f77ef94 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -79,6 +79,7 @@ FLOWS = { "azure_data_explorer", "azure_devops", "azure_event_hub", + "azure_storage", "baf", "balboa", "bang_olufsen", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 41083ee8e8c..01ff9d14d90 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -3800,6 +3800,12 @@ "iot_class": "cloud_push", "name": "Azure Service Bus" }, + "azure_storage": { + "integration_type": "service", + "config_flow": true, + "iot_class": "cloud_polling", + "name": "Azure Storage" + }, "microsoft_face_detect": { "integration_type": "hub", "config_flow": false, diff --git a/mypy.ini b/mypy.ini index a04242dc66d..a6203993c87 100644 --- a/mypy.ini +++ b/mypy.ini @@ -785,6 +785,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.azure_storage.*] +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.backup.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 87dd9bb204e..3b80e4f78a6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -571,6 +571,9 @@ azure-kusto-ingest==4.5.1 # homeassistant.components.azure_service_bus azure-servicebus==7.10.0 +# homeassistant.components.azure_storage +azure-storage-blob==12.24.0 + # homeassistant.components.holiday babel==2.15.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f55ea287d37..4ec3192285d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -517,6 +517,9 @@ azure-kusto-data[aio]==4.5.1 # homeassistant.components.azure_data_explorer azure-kusto-ingest==4.5.1 +# homeassistant.components.azure_storage +azure-storage-blob==12.24.0 + # homeassistant.components.holiday babel==2.15.0 diff --git a/tests/components/azure_storage/__init__.py b/tests/components/azure_storage/__init__.py new file mode 100644 index 00000000000..bfd2e72d979 --- /dev/null +++ b/tests/components/azure_storage/__init__.py @@ -0,0 +1,14 @@ +"""Azure Storage integration tests.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Set up the azure_storage integration for testing.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/azure_storage/conftest.py b/tests/components/azure_storage/conftest.py new file mode 100644 index 00000000000..7c583ac391e --- /dev/null +++ b/tests/components/azure_storage/conftest.py @@ -0,0 +1,63 @@ +"""Fixtures for Azure Storage tests.""" + +from collections.abc import AsyncIterator, Generator +from unittest.mock import AsyncMock, MagicMock, patch + +from azure.storage.blob import BlobProperties +import pytest + +from homeassistant.components.azure_storage.const import DOMAIN + +from .const import BACKUP_METADATA, USER_INPUT + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Mock setting up a config entry.""" + with patch( + "homeassistant.components.azure_storage.async_setup_entry", return_value=True + ) as mock_setup: + yield mock_setup + + +@pytest.fixture(autouse=True) +def mock_client() -> Generator[MagicMock]: + """Mock the Azure Storage client.""" + with ( + patch( + "homeassistant.components.azure_storage.config_flow.ContainerClient", + autospec=True, + ) as container_client, + patch( + "homeassistant.components.azure_storage.ContainerClient", + new=container_client, + ), + ): + client = container_client.return_value + client.exists.return_value = False + + async def async_list_blobs(): + yield BlobProperties(metadata=BACKUP_METADATA) + yield BlobProperties(metadata=BACKUP_METADATA) + + client.list_blobs.return_value = async_list_blobs() + + class MockStream: + async def chunks(self) -> AsyncIterator[bytes]: + yield b"backup data" + + client.download_blob.return_value = MockStream() + + yield client + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + title="account/container1", + domain=DOMAIN, + data=USER_INPUT, + ) diff --git a/tests/components/azure_storage/const.py b/tests/components/azure_storage/const.py new file mode 100644 index 00000000000..4edb754f650 --- /dev/null +++ b/tests/components/azure_storage/const.py @@ -0,0 +1,36 @@ +"""Consts for Azure Storage tests.""" + +from json import dumps + +from homeassistant.components.azure_storage.const import ( + CONF_ACCOUNT_NAME, + CONF_CONTAINER_NAME, + CONF_STORAGE_ACCOUNT_KEY, +) +from homeassistant.components.backup import AgentBackup + +USER_INPUT = { + CONF_ACCOUNT_NAME: "account", + CONF_CONTAINER_NAME: "container1", + CONF_STORAGE_ACCOUNT_KEY: "test", +} + +TEST_BACKUP = AgentBackup( + addons=[], + backup_id="23e64aec", + date="2024-11-22T11:48:48.727189+01:00", + database_included=True, + extra_metadata={}, + folders=[], + homeassistant_included=True, + homeassistant_version="2024.12.0.dev0", + name="Core 2024.12.0.dev0", + protected=False, + size=34519040, +) + +BACKUP_METADATA = { + "metadata_version": "1", + "backup_id": "23e64aec", + "backup_metadata": dumps(TEST_BACKUP.as_dict()), +} diff --git a/tests/components/azure_storage/test_backup.py b/tests/components/azure_storage/test_backup.py new file mode 100644 index 00000000000..4dc1de0a26e --- /dev/null +++ b/tests/components/azure_storage/test_backup.py @@ -0,0 +1,317 @@ +"""Test the backups for OneDrive.""" + +from __future__ import annotations + +from collections.abc import AsyncGenerator +from io import StringIO +from unittest.mock import ANY, Mock, patch + +from azure.core.exceptions import HttpResponseError +from azure.storage.blob import BlobProperties +import pytest + +from homeassistant.components.azure_storage.backup import ( + async_register_backup_agents_listener, +) +from homeassistant.components.azure_storage.const import ( + DATA_BACKUP_AGENT_LISTENERS, + DOMAIN, +) +from homeassistant.components.backup import DOMAIN as BACKUP_DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from . import setup_integration +from .const import BACKUP_METADATA, TEST_BACKUP + +from tests.common import MockConfigEntry +from tests.typing import ClientSessionGenerator, MagicMock, WebSocketGenerator + + +@pytest.fixture(autouse=True) +async def setup_backup_integration( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> AsyncGenerator[None]: + """Set up onedrive integration.""" + with ( + patch("homeassistant.components.backup.is_hassio", return_value=False), + patch("homeassistant.components.backup.store.STORE_DELAY_SAVE", 0), + ): + assert await async_setup_component(hass, BACKUP_DOMAIN, {}) + await setup_integration(hass, mock_config_entry) + + await hass.async_block_till_done() + yield + + +async def test_agents_info( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_config_entry: MockConfigEntry, +) -> None: + """Test backup agent info.""" + client = await hass_ws_client(hass) + + await client.send_json_auto_id({"type": "backup/agents/info"}) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == { + "agents": [ + {"agent_id": "backup.local", "name": "local"}, + { + "agent_id": f"{DOMAIN}.{mock_config_entry.entry_id}", + "name": mock_config_entry.title, + }, + ], + } + + +async def test_agents_list_backups( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_config_entry: MockConfigEntry, +) -> None: + """Test agent list backups.""" + + client = await hass_ws_client(hass) + await client.send_json_auto_id({"type": "backup/info"}) + response = await client.receive_json() + + assert response["success"] + assert response["result"]["agent_errors"] == {} + assert response["result"]["backups"] == [ + { + "addons": [], + "agents": { + f"{DOMAIN}.{mock_config_entry.entry_id}": { + "protected": False, + "size": 34519040, + } + }, + "backup_id": "23e64aec", + "date": "2024-11-22T11:48:48.727189+01:00", + "database_included": True, + "folders": [], + "homeassistant_included": True, + "homeassistant_version": "2024.12.0.dev0", + "name": "Core 2024.12.0.dev0", + "failed_agent_ids": [], + "extra_metadata": {}, + "with_automatic_settings": None, + } + ] + + +async def test_agents_get_backup( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_config_entry: MockConfigEntry, +) -> None: + """Test agent get backup.""" + + backup_id = TEST_BACKUP.backup_id + client = await hass_ws_client(hass) + await client.send_json_auto_id({"type": "backup/details", "backup_id": backup_id}) + response = await client.receive_json() + + assert response["success"] + assert response["result"]["agent_errors"] == {} + assert response["result"]["backup"] == { + "addons": [], + "agents": { + f"{DOMAIN}.{mock_config_entry.entry_id}": { + "protected": False, + "size": 34519040, + } + }, + "backup_id": "23e64aec", + "date": "2024-11-22T11:48:48.727189+01:00", + "database_included": True, + "folders": [], + "homeassistant_included": True, + "homeassistant_version": "2024.12.0.dev0", + "extra_metadata": {}, + "name": "Core 2024.12.0.dev0", + "failed_agent_ids": [], + "with_automatic_settings": None, + } + + +async def test_agents_get_backup_does_not_throw_on_not_found( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test agent get backup does not throw on a backup not found.""" + + client = await hass_ws_client(hass) + await client.send_json_auto_id({"type": "backup/details", "backup_id": "random"}) + response = await client.receive_json() + + assert response["success"] + assert response["result"]["agent_errors"] == {} + assert response["result"]["backup"] is None + + +async def test_agents_delete( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_client: MagicMock, +) -> None: + """Test agent delete backup.""" + client = await hass_ws_client(hass) + + await client.send_json_auto_id( + { + "type": "backup/delete", + "backup_id": TEST_BACKUP.backup_id, + } + ) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == {"agent_errors": {}} + mock_client.delete_blob.assert_called_once() + + +async def test_agents_delete_not_throwing_on_not_found( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_client: MagicMock, +) -> None: + """Test agent delete backup does not throw on a backup not found.""" + client = await hass_ws_client(hass) + + await client.send_json_auto_id( + { + "type": "backup/delete", + "backup_id": "random", + } + ) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == {"agent_errors": {}} + assert mock_client.delete_blob.call_count == 0 + + +async def test_agents_upload( + hass_client: ClientSessionGenerator, + caplog: pytest.LogCaptureFixture, + mock_client: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test agent upload backup.""" + client = await hass_client() + with ( + patch( + "homeassistant.components.backup.manager.BackupManager.async_get_backup", + ) as fetch_backup, + patch( + "homeassistant.components.backup.manager.read_backup", + return_value=TEST_BACKUP, + ), + patch("pathlib.Path.open") as mocked_open, + ): + mocked_open.return_value.read = Mock(side_effect=[b"test", b""]) + fetch_backup.return_value = TEST_BACKUP + resp = await client.post( + f"/api/backup/upload?agent_id={DOMAIN}.{mock_config_entry.entry_id}", + data={"file": StringIO("test")}, + ) + + assert resp.status == 201 + assert f"Uploading backup {TEST_BACKUP.backup_id}" in caplog.text + mock_client.upload_blob.assert_called_once_with( + name="Core_2024.12.0.dev0_2024-11-22_11.48_48727189.tar", + metadata=BACKUP_METADATA, + data=ANY, + length=ANY, + ) + + +async def test_agents_download( + hass_client: ClientSessionGenerator, + mock_client: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test agent download backup.""" + client = await hass_client() + backup_id = BACKUP_METADATA["backup_id"] + + resp = await client.get( + f"/api/backup/download/{backup_id}?agent_id={DOMAIN}.{mock_config_entry.entry_id}" + ) + assert resp.status == 200 + assert await resp.content.read() == b"backup data" + mock_client.download_blob.assert_called_once() + + +async def test_agents_error_on_download_not_found( + hass_client: ClientSessionGenerator, + mock_client: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test agent download backup.""" + + async def async_list_blobs( + metadata: dict[str, str], + ) -> AsyncGenerator[BlobProperties]: + yield BlobProperties(metadata=metadata) + + mock_client.list_blobs.side_effect = [ + async_list_blobs(BACKUP_METADATA), + async_list_blobs({}), + ] + client = await hass_client() + backup_id = BACKUP_METADATA["backup_id"] + + resp = await client.get( + f"/api/backup/download/{backup_id}?agent_id={DOMAIN}.{mock_config_entry.entry_id}" + ) + assert resp.status == 404 + assert mock_client.download_blob.call_count == 0 + + +async def test_error_during_delete( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_client: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the error wrapper.""" + mock_client.delete_blob.side_effect = HttpResponseError("Failed to delete backup") + + client = await hass_ws_client(hass) + + await client.send_json_auto_id( + { + "type": "backup/delete", + "backup_id": BACKUP_METADATA["backup_id"], + } + ) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == { + "agent_errors": { + f"{DOMAIN}.{mock_config_entry.entry_id}": ( + "Error during backup operation in async_delete_backup: " + "Status None, message: Failed to delete backup" + ) + } + } + + +async def test_listeners_get_cleaned_up(hass: HomeAssistant) -> None: + """Test listener gets cleaned up.""" + listener = MagicMock() + remove_listener = async_register_backup_agents_listener(hass, listener=listener) + + hass.data[DATA_BACKUP_AGENT_LISTENERS] = [ + listener + ] # make sure it's the last listener + remove_listener() + + assert hass.data.get(DATA_BACKUP_AGENT_LISTENERS) is None diff --git a/tests/components/azure_storage/test_config_flow.py b/tests/components/azure_storage/test_config_flow.py new file mode 100644 index 00000000000..ed8bbed0718 --- /dev/null +++ b/tests/components/azure_storage/test_config_flow.py @@ -0,0 +1,113 @@ +"""Test the Azure storage config flow.""" + +from unittest.mock import AsyncMock, MagicMock + +from azure.core.exceptions import ClientAuthenticationError, ResourceNotFoundError +import pytest + +from homeassistant.components.azure_storage.const import ( + CONF_ACCOUNT_NAME, + CONF_CONTAINER_NAME, + CONF_STORAGE_ACCOUNT_KEY, + DOMAIN, +) +from homeassistant.config_entries import SOURCE_USER, ConfigFlowResult +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from .const import USER_INPUT + +from tests.common import MockConfigEntry + + +async def __async_start_flow( + hass: HomeAssistant, +) -> ConfigFlowResult: + """Initialize the config flow.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.FORM + + return await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT, + ) + + +async def test_flow( + hass: HomeAssistant, + mock_client: MagicMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test config flow.""" + mock_client.exists.return_value = False + result = await __async_start_flow(hass) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert ( + result["title"] + == f"{USER_INPUT[CONF_ACCOUNT_NAME]}/{USER_INPUT[CONF_CONTAINER_NAME]}" + ) + assert result["data"] == { + CONF_ACCOUNT_NAME: "account", + CONF_CONTAINER_NAME: "container1", + CONF_STORAGE_ACCOUNT_KEY: "test", + } + + +@pytest.mark.parametrize( + ("exception", "errors"), + [ + (ResourceNotFoundError, {"base": "cannot_connect"}), + (ClientAuthenticationError, {CONF_STORAGE_ACCOUNT_KEY: "invalid_auth"}), + (Exception, {"base": "unknown"}), + ], +) +async def test_flow_errors( + hass: HomeAssistant, + mock_client: MagicMock, + mock_setup_entry: AsyncMock, + exception: Exception, + errors: dict[str, str], +) -> None: + """Test config flow errors.""" + mock_client.exists.side_effect = exception + + result = await __async_start_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == errors + + # fix and finish the test + mock_client.exists.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert ( + result["title"] + == f"{USER_INPUT[CONF_ACCOUNT_NAME]}/{USER_INPUT[CONF_CONTAINER_NAME]}" + ) + assert result["data"] == { + CONF_ACCOUNT_NAME: "account", + CONF_CONTAINER_NAME: "container1", + CONF_STORAGE_ACCOUNT_KEY: "test", + } + + +async def test_abort_if_already_configured( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test we abort if the account is already configured.""" + mock_config_entry.add_to_hass(hass) + + result = await __async_start_flow(hass) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/azure_storage/test_init.py b/tests/components/azure_storage/test_init.py new file mode 100644 index 00000000000..ca725134737 --- /dev/null +++ b/tests/components/azure_storage/test_init.py @@ -0,0 +1,54 @@ +"""Test the Azure storage integration.""" + +from unittest.mock import MagicMock + +from azure.core.exceptions import ( + ClientAuthenticationError, + HttpResponseError, + ResourceNotFoundError, +) +import pytest + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from . import setup_integration + +from tests.common import MockConfigEntry + + +async def test_load_unload_config_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test loading and unloading the integration.""" + 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 + + +@pytest.mark.parametrize( + ("exception", "state"), + [ + (ClientAuthenticationError, ConfigEntryState.SETUP_ERROR), + (HttpResponseError, ConfigEntryState.SETUP_RETRY), + (ResourceNotFoundError, ConfigEntryState.SETUP_ERROR), + ], +) +async def test_setup_errors( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_client: MagicMock, + exception: Exception, + state: ConfigEntryState, +) -> None: + """Test various setup errors.""" + mock_client.exists.side_effect = exception() + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is state From a1076300c88ea56833c67e2fc730dc98a3f40ac4 Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Mon, 24 Feb 2025 21:03:21 +0100 Subject: [PATCH 1744/3148] Bump onedrive quality scale to platinum (#137451) --- homeassistant/components/onedrive/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/onedrive/manifest.json b/homeassistant/components/onedrive/manifest.json index 698bc7f5ca4..5ab16402cb8 100644 --- a/homeassistant/components/onedrive/manifest.json +++ b/homeassistant/components/onedrive/manifest.json @@ -8,6 +8,6 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["onedrive_personal_sdk"], - "quality_scale": "bronze", + "quality_scale": "platinum", "requirements": ["onedrive-personal-sdk==0.0.11"] } From 33c9f3cc7d5a40678b76971e7ced738f5f9079a7 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Mon, 24 Feb 2025 21:09:17 +0100 Subject: [PATCH 1745/3148] Bump pyloadapi to v1.4.2 (#139140) --- homeassistant/components/pyload/__init__.py | 2 +- homeassistant/components/pyload/button.py | 2 +- homeassistant/components/pyload/config_flow.py | 3 +-- homeassistant/components/pyload/manifest.json | 2 +- homeassistant/components/pyload/switch.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 7 files changed, 7 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/pyload/__init__.py b/homeassistant/components/pyload/__init__.py index 8251722de50..cf8e922d70e 100644 --- a/homeassistant/components/pyload/__init__.py +++ b/homeassistant/components/pyload/__init__.py @@ -3,7 +3,7 @@ from __future__ import annotations from aiohttp import CookieJar -from pyloadapi.api import PyLoadAPI +from pyloadapi import PyLoadAPI from homeassistant.const import ( CONF_HOST, diff --git a/homeassistant/components/pyload/button.py b/homeassistant/components/pyload/button.py index 6303ced09f0..5ee10a327d1 100644 --- a/homeassistant/components/pyload/button.py +++ b/homeassistant/components/pyload/button.py @@ -7,7 +7,7 @@ from dataclasses import dataclass from enum import StrEnum from typing import Any -from pyloadapi.api import CannotConnect, InvalidAuth, PyLoadAPI +from pyloadapi import CannotConnect, InvalidAuth, PyLoadAPI from homeassistant.components.button import ButtonEntity, ButtonEntityDescription from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/pyload/config_flow.py b/homeassistant/components/pyload/config_flow.py index b9bfc579cfc..bc3bbc6cb34 100644 --- a/homeassistant/components/pyload/config_flow.py +++ b/homeassistant/components/pyload/config_flow.py @@ -7,8 +7,7 @@ import logging from typing import Any from aiohttp import CookieJar -from pyloadapi.api import PyLoadAPI -from pyloadapi.exceptions import CannotConnect, InvalidAuth, ParserError +from pyloadapi import CannotConnect, InvalidAuth, ParserError, PyLoadAPI import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult diff --git a/homeassistant/components/pyload/manifest.json b/homeassistant/components/pyload/manifest.json index 4490057c8e0..134865b9d93 100644 --- a/homeassistant/components/pyload/manifest.json +++ b/homeassistant/components/pyload/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "local_polling", "loggers": ["pyloadapi"], - "requirements": ["PyLoadAPI==1.4.1"] + "requirements": ["PyLoadAPI==1.4.2"] } diff --git a/homeassistant/components/pyload/switch.py b/homeassistant/components/pyload/switch.py index 57160cbf5c1..46a54451b9a 100644 --- a/homeassistant/components/pyload/switch.py +++ b/homeassistant/components/pyload/switch.py @@ -7,7 +7,7 @@ from dataclasses import dataclass from enum import StrEnum from typing import Any -from pyloadapi.api import CannotConnect, InvalidAuth, PyLoadAPI +from pyloadapi import CannotConnect, InvalidAuth, PyLoadAPI from homeassistant.components.switch import ( SwitchDeviceClass, diff --git a/requirements_all.txt b/requirements_all.txt index 3b80e4f78a6..d0e098a6a0b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -57,7 +57,7 @@ PyFlume==0.6.5 PyFronius==0.7.3 # homeassistant.components.pyload -PyLoadAPI==1.4.1 +PyLoadAPI==1.4.2 # homeassistant.components.met_eireann PyMetEireann==2024.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4ec3192285d..10c18f61725 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -54,7 +54,7 @@ PyFlume==0.6.5 PyFronius==0.7.3 # homeassistant.components.pyload -PyLoadAPI==1.4.1 +PyLoadAPI==1.4.2 # homeassistant.components.met_eireann PyMetEireann==2024.11.0 From 72f690d68163d55d0ff624d021a9eecffdf36ab3 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 24 Feb 2025 21:34:41 +0100 Subject: [PATCH 1746/3148] Add missing translations to switchbot (#139212) --- homeassistant/components/switchbot/strings.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/homeassistant/components/switchbot/strings.json b/homeassistant/components/switchbot/strings.json index 9c101204dcb..c9f93cce604 100644 --- a/homeassistant/components/switchbot/strings.json +++ b/homeassistant/components/switchbot/strings.json @@ -70,6 +70,10 @@ "data": { "retry_count": "Retry count", "lock_force_nightlatch": "Force Nightlatch operation mode" + }, + "data_description": { + "retry_count": "How many times to retry sending commands to your SwitchBot devices", + "lock_force_nightlatch": "Force Nightlatch operation mode even if Nightlatch is not detected" } } } From b662d32e44e1ed4ccef75eb8b82cf58797f1166f Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 24 Feb 2025 22:19:18 +0100 Subject: [PATCH 1747/3148] Fix bug in check_translations fixture (#139206) * Fix bug in check_translations fixture * Fix check for ignored translation errors * Fix websocket_api test --- tests/components/conftest.py | 7 +++++-- tests/components/websocket_api/test_commands.py | 4 ++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/tests/components/conftest.py b/tests/components/conftest.py index dd6776a1cad..cf10e2b8dfd 100644 --- a/tests/components/conftest.py +++ b/tests/components/conftest.py @@ -624,7 +624,8 @@ async def _validate_translation( if not translation_required: return - if full_key in translation_errors: + if translation_errors.get(full_key) in {"used", "unused"}: + # This translation key is in the ignore list, mark it as used translation_errors[full_key] = "used" return @@ -864,6 +865,7 @@ async def check_translations( if not isinstance(ignore_translations, list): ignore_translations = [ignore_translations] + # Set all ignored translation keys to "unused" translation_errors = {k: "unused" for k in ignore_translations} translation_coros = set() @@ -945,10 +947,11 @@ async def check_translations( # Run final checks unused_ignore = [k for k, v in translation_errors.items() if v == "unused"] if unused_ignore: + # Some ignored translations were not used pytest.fail( f"Unused ignore translations: {', '.join(unused_ignore)}. " "Please remove them from the ignore_translations fixture." ) for description in translation_errors.values(): - if description not in {"used", "unused"}: + if description != "used": pytest.fail(description) diff --git a/tests/components/websocket_api/test_commands.py b/tests/components/websocket_api/test_commands.py index 2ddb5c628c7..baa939c411b 100644 --- a/tests/components/websocket_api/test_commands.py +++ b/tests/components/websocket_api/test_commands.py @@ -540,6 +540,10 @@ async def test_call_service_schema_validation_error( assert len(calls) == 0 +@pytest.mark.parametrize( + "ignore_translations", + ["component.test.exceptions.custom_error.message"], +) async def test_call_service_error( hass: HomeAssistant, websocket_client: MockHAClientWebSocket ) -> None: From b86bb75e5ec605f07b474506ce86769979ac85ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Mon, 24 Feb 2025 23:25:24 +0100 Subject: [PATCH 1748/3148] Add missing exception translation to Home Connect (#139218) Add missing exception translation --- homeassistant/components/home_connect/__init__.py | 6 +++++- homeassistant/components/home_connect/strings.json | 3 +++ tests/components/home_connect/test_init.py | 4 +--- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/home_connect/__init__.py b/homeassistant/components/home_connect/__init__.py index 51b38bf7cd3..405606c6159 100644 --- a/homeassistant/components/home_connect/__init__.py +++ b/homeassistant/components/home_connect/__init__.py @@ -213,7 +213,11 @@ async def _get_client_and_ha_id( break if entry is None: raise ServiceValidationError( - "Home Connect config entry not found for that device id" + translation_domain=DOMAIN, + translation_key="config_entry_not_found", + translation_placeholders={ + "device_id": device_id, + }, ) ha_id = next( diff --git a/homeassistant/components/home_connect/strings.json b/homeassistant/components/home_connect/strings.json index 977ad1f36f0..5072bb616dd 100644 --- a/homeassistant/components/home_connect/strings.json +++ b/homeassistant/components/home_connect/strings.json @@ -33,6 +33,9 @@ "appliance_not_found": { "message": "Appliance for device ID {device_id} not found" }, + "config_entry_not_found": { + "message": "Config entry for device ID {device_id} not found" + }, "turn_on_light": { "message": "Error turning on {entity_id}: {error}" }, diff --git a/tests/components/home_connect/test_init.py b/tests/components/home_connect/test_init.py index 06498f891db..6e4e428bf6a 100644 --- a/tests/components/home_connect/test_init.py +++ b/tests/components/home_connect/test_init.py @@ -589,9 +589,7 @@ async def test_services_appliance_not_found( ) service_call["service_data"]["device_id"] = device_entry.id - with pytest.raises( - ServiceValidationError, match=r"Home Connect config entry.*not found" - ): + with pytest.raises(ServiceValidationError, match=r"Config entry.*not found"): await hass.services.async_call(**service_call) device_entry = device_registry.async_get_or_create( From 597c0ab9854c29054aa92a10421755917f224ecf Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Tue, 25 Feb 2025 02:05:30 +0100 Subject: [PATCH 1749/3148] Configure trusted publishing for PyPI file upload (#137607) --- .github/workflows/builder.yml | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index 88f6f37d6d6..68581c58d24 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -448,6 +448,9 @@ jobs: environment: ${{ needs.init.outputs.channel }} needs: ["init", "build_base"] runs-on: ubuntu-latest + permissions: + contents: read + id-token: write if: github.repository_owner == 'home-assistant' && needs.init.outputs.publish == 'true' steps: - name: Checkout the repository @@ -473,16 +476,13 @@ jobs: run: | # Remove dist, build, and homeassistant.egg-info # when build locally for testing! - pip install twine build + pip install build python -m build - - name: Upload package - shell: bash - run: | - export TWINE_USERNAME="__token__" - export TWINE_PASSWORD="${{ secrets.TWINE_TOKEN }}" - - twine upload dist/* --skip-existing + - name: Upload package to PyPI + uses: pypa/gh-action-pypi-publish@v1.12.4 + with: + skip-existing: true hassfest-image: name: Build and test hassfest image From c115a7f455b4a5873e8cae767a76bf01789d7394 Mon Sep 17 00:00:00 2001 From: Noah Husby <32528627+noahhusby@users.noreply.github.com> Date: Mon, 24 Feb 2025 20:20:48 -0500 Subject: [PATCH 1750/3148] Bump aiostreammagic to 2.11.0 (#139213) --- homeassistant/components/cambridge_audio/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/cambridge_audio/manifest.json b/homeassistant/components/cambridge_audio/manifest.json index 14a389587d2..88d28e256aa 100644 --- a/homeassistant/components/cambridge_audio/manifest.json +++ b/homeassistant/components/cambridge_audio/manifest.json @@ -8,6 +8,6 @@ "iot_class": "local_push", "loggers": ["aiostreammagic"], "quality_scale": "platinum", - "requirements": ["aiostreammagic==2.10.0"], + "requirements": ["aiostreammagic==2.11.0"], "zeroconf": ["_stream-magic._tcp.local.", "_smoip._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index d0e098a6a0b..f18deb65b35 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -386,7 +386,7 @@ aiosolaredge==0.2.0 aiosteamist==1.0.1 # homeassistant.components.cambridge_audio -aiostreammagic==2.10.0 +aiostreammagic==2.11.0 # homeassistant.components.switcher_kis aioswitcher==6.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 10c18f61725..a449ef121e4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -368,7 +368,7 @@ aiosolaredge==0.2.0 aiosteamist==1.0.1 # homeassistant.components.cambridge_audio -aiostreammagic==2.10.0 +aiostreammagic==2.11.0 # homeassistant.components.switcher_kis aioswitcher==6.0.0 From 54843bb4223804388c2557fad4ad6480487e03a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Tue, 25 Feb 2025 02:21:25 +0100 Subject: [PATCH 1751/3148] Add missing exception translation to Home Connect (#139223) --- homeassistant/components/home_connect/__init__.py | 8 +++++++- homeassistant/components/home_connect/strings.json | 3 +++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/home_connect/__init__.py b/homeassistant/components/home_connect/__init__.py index 405606c6159..3e1bd1da156 100644 --- a/homeassistant/components/home_connect/__init__.py +++ b/homeassistant/components/home_connect/__init__.py @@ -203,7 +203,13 @@ async def _get_client_and_ha_id( device_registry = dr.async_get(hass) device_entry = device_registry.async_get(device_id) if device_entry is None: - raise ServiceValidationError("Device entry not found for device id") + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="device_entry_not_found", + translation_placeholders={ + "device_id": device_id, + }, + ) entry: HomeConnectConfigEntry | None = None for entry_id in device_entry.config_entries: _entry = hass.config_entries.async_get_entry(entry_id) diff --git a/homeassistant/components/home_connect/strings.json b/homeassistant/components/home_connect/strings.json index 5072bb616dd..672ad364365 100644 --- a/homeassistant/components/home_connect/strings.json +++ b/homeassistant/components/home_connect/strings.json @@ -33,6 +33,9 @@ "appliance_not_found": { "message": "Appliance for device ID {device_id} not found" }, + "device_entry_not_found": { + "message": "Device entry for device ID {device_id} not found" + }, "config_entry_not_found": { "message": "Config entry for device ID {device_id} not found" }, From 212c42ca77d987b5f0dee4536e3c04a92915a9b1 Mon Sep 17 00:00:00 2001 From: Dan Raper Date: Tue, 25 Feb 2025 01:25:31 +0000 Subject: [PATCH 1752/3148] Bump ohmepy to 1.3.2 (#139013) --- homeassistant/components/ohme/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ohme/manifest.json b/homeassistant/components/ohme/manifest.json index c1ca2bac62f..fb11fa0dd06 100644 --- a/homeassistant/components/ohme/manifest.json +++ b/homeassistant/components/ohme/manifest.json @@ -7,5 +7,5 @@ "integration_type": "device", "iot_class": "cloud_polling", "quality_scale": "silver", - "requirements": ["ohme==1.3.0"] + "requirements": ["ohme==1.3.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index f18deb65b35..6683ea5909b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1553,7 +1553,7 @@ odp-amsterdam==6.0.2 oemthermostat==1.1.1 # homeassistant.components.ohme -ohme==1.3.0 +ohme==1.3.2 # homeassistant.components.ollama ollama==0.4.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a449ef121e4..26689bfc459 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1301,7 +1301,7 @@ objgraph==3.5.0 odp-amsterdam==6.0.2 # homeassistant.components.ohme -ohme==1.3.0 +ohme==1.3.2 # homeassistant.components.ollama ollama==0.4.7 From 24bb13e0d173beb78aecaa8e1dc67a45ff7107f4 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 25 Feb 2025 09:13:10 +0100 Subject: [PATCH 1753/3148] Fix kitchen_sink statistic issues (#139228) --- .../components/kitchen_sink/__init__.py | 8 +-- .../kitchen_sink/snapshots/test_init.ambr | 52 +++++++++++++++++++ tests/components/kitchen_sink/test_init.py | 20 +++++++ 3 files changed, 76 insertions(+), 4 deletions(-) create mode 100644 tests/components/kitchen_sink/snapshots/test_init.ambr diff --git a/homeassistant/components/kitchen_sink/__init__.py b/homeassistant/components/kitchen_sink/__init__.py index eff1a1ba8b2..de8e521f0e8 100644 --- a/homeassistant/components/kitchen_sink/__init__.py +++ b/homeassistant/components/kitchen_sink/__init__.py @@ -296,7 +296,7 @@ async def _insert_statistics(hass: HomeAssistant) -> None: metadata = { "source": RECORDER_DOMAIN, "name": None, - "statistic_id": "sensor.statistics_issue_1", + "statistic_id": "sensor.statistics_issues_issue_1", "unit_of_measurement": UnitOfVolume.CUBIC_METERS, "has_mean": True, "has_sum": False, @@ -308,7 +308,7 @@ async def _insert_statistics(hass: HomeAssistant) -> None: metadata = { "source": RECORDER_DOMAIN, "name": None, - "statistic_id": "sensor.statistics_issue_2", + "statistic_id": "sensor.statistics_issues_issue_2", "unit_of_measurement": "cats", "has_mean": True, "has_sum": False, @@ -320,7 +320,7 @@ async def _insert_statistics(hass: HomeAssistant) -> None: metadata = { "source": RECORDER_DOMAIN, "name": None, - "statistic_id": "sensor.statistics_issue_3", + "statistic_id": "sensor.statistics_issues_issue_3", "unit_of_measurement": UnitOfVolume.CUBIC_METERS, "has_mean": True, "has_sum": False, @@ -332,7 +332,7 @@ async def _insert_statistics(hass: HomeAssistant) -> None: metadata = { "source": RECORDER_DOMAIN, "name": None, - "statistic_id": "sensor.statistics_issue_4", + "statistic_id": "sensor.statistics_issues_issue_4", "unit_of_measurement": UnitOfVolume.CUBIC_METERS, "has_mean": True, "has_sum": False, diff --git a/tests/components/kitchen_sink/snapshots/test_init.ambr b/tests/components/kitchen_sink/snapshots/test_init.ambr new file mode 100644 index 00000000000..b91131eb2b0 --- /dev/null +++ b/tests/components/kitchen_sink/snapshots/test_init.ambr @@ -0,0 +1,52 @@ +# serializer version: 1 +# name: test_statistics_issues + dict({ + 'sensor.statistics_issues_issue_1': list([ + dict({ + 'data': dict({ + 'metadata_unit': 'm³', + 'state_unit': 'W', + 'statistic_id': 'sensor.statistics_issues_issue_1', + 'supported_unit': 'CCF, L, fl. oz., ft³, gal, mL, m³', + }), + 'type': 'units_changed', + }), + ]), + 'sensor.statistics_issues_issue_2': list([ + dict({ + 'data': dict({ + 'metadata_unit': 'cats', + 'state_unit': 'dogs', + 'statistic_id': 'sensor.statistics_issues_issue_2', + 'supported_unit': 'cats', + }), + 'type': 'units_changed', + }), + ]), + 'sensor.statistics_issues_issue_3': list([ + dict({ + 'data': dict({ + 'statistic_id': 'sensor.statistics_issues_issue_3', + }), + 'type': 'state_class_removed', + }), + dict({ + 'data': dict({ + 'metadata_unit': 'm³', + 'state_unit': 'W', + 'statistic_id': 'sensor.statistics_issues_issue_3', + 'supported_unit': 'CCF, L, fl. oz., ft³, gal, mL, m³', + }), + 'type': 'units_changed', + }), + ]), + 'sensor.statistics_issues_issue_4': list([ + dict({ + 'data': dict({ + 'statistic_id': 'sensor.statistics_issues_issue_4', + }), + 'type': 'no_state', + }), + ]), + }) +# --- diff --git a/tests/components/kitchen_sink/test_init.py b/tests/components/kitchen_sink/test_init.py index 7338c1dca99..50518f89107 100644 --- a/tests/components/kitchen_sink/test_init.py +++ b/tests/components/kitchen_sink/test_init.py @@ -5,6 +5,7 @@ from http import HTTPStatus from unittest.mock import ANY import pytest +from syrupy.assertion import SnapshotAssertion import voluptuous as vol from homeassistant.components.kitchen_sink import DOMAIN @@ -102,6 +103,25 @@ async def test_demo_statistics_growth(hass: HomeAssistant) -> None: assert statistics[statistic_id][0]["sum"] <= (2**20 + 24) +@pytest.mark.usefixtures("recorder_mock", "mock_history") +async def test_statistics_issues( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + snapshot: SnapshotAssertion, +) -> None: + """Test that the kitchen sink sum statistics causes statistics issues.""" + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + await hass.async_block_till_done() + await hass.async_start() + await async_wait_recording_done(hass) + + ws_client = await hass_ws_client(hass) + await ws_client.send_json_auto_id({"type": "recorder/validate_statistics"}) + response = await ws_client.receive_json() + assert response["success"] + assert response["result"] == snapshot + + @pytest.mark.freeze_time("2023-10-21") @pytest.mark.usefixtures("mock_history") async def test_issues_created( From 6342d8334bf6eb94eadd7c2f40b8bb06933744dd Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Tue, 25 Feb 2025 09:18:41 +0100 Subject: [PATCH 1754/3148] Bump aiowebdav2 to 0.3.0 (#139202) --- homeassistant/components/webdav/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/webdav/manifest.json b/homeassistant/components/webdav/manifest.json index a1ac779afc8..75a8d7ddfe2 100644 --- a/homeassistant/components/webdav/manifest.json +++ b/homeassistant/components/webdav/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["aiowebdav2"], "quality_scale": "bronze", - "requirements": ["aiowebdav2==0.2.2"] + "requirements": ["aiowebdav2==0.3.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 6683ea5909b..7d8952bdb9d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -422,7 +422,7 @@ aiowaqi==3.1.0 aiowatttime==0.1.1 # homeassistant.components.webdav -aiowebdav2==0.2.2 +aiowebdav2==0.3.0 # homeassistant.components.webostv aiowebostv==0.7.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 26689bfc459..c1bd76b715b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -404,7 +404,7 @@ aiowaqi==3.1.0 aiowatttime==0.1.1 # homeassistant.components.webdav -aiowebdav2==0.2.2 +aiowebdav2==0.3.0 # homeassistant.components.webostv aiowebostv==0.7.0 From c386abd49dc4bd8decc0e716850361e739fe53cc Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Tue, 25 Feb 2025 09:32:06 +0100 Subject: [PATCH 1755/3148] Bump pylamarzocco to 1.4.7 (#139231) --- homeassistant/components/lamarzocco/binary_sensor.py | 2 +- homeassistant/components/lamarzocco/manifest.json | 2 +- homeassistant/components/lamarzocco/number.py | 3 ++- homeassistant/components/lamarzocco/select.py | 3 ++- homeassistant/components/lamarzocco/sensor.py | 8 +++++--- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 7 files changed, 13 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/lamarzocco/binary_sensor.py b/homeassistant/components/lamarzocco/binary_sensor.py index 39bd5d4b954..a98cddcda9c 100644 --- a/homeassistant/components/lamarzocco/binary_sensor.py +++ b/homeassistant/components/lamarzocco/binary_sensor.py @@ -83,7 +83,7 @@ async def async_setup_entry( ] if ( - coordinator.device.model == MachineModel.LINEA_MINI + coordinator.device.model in (MachineModel.LINEA_MINI, MachineModel.LINEA_MINI_R) and coordinator.device.config.scale ): entities.extend( diff --git a/homeassistant/components/lamarzocco/manifest.json b/homeassistant/components/lamarzocco/manifest.json index afd367b0f6e..eceb2bbf53b 100644 --- a/homeassistant/components/lamarzocco/manifest.json +++ b/homeassistant/components/lamarzocco/manifest.json @@ -37,5 +37,5 @@ "iot_class": "cloud_polling", "loggers": ["pylamarzocco"], "quality_scale": "platinum", - "requirements": ["pylamarzocco==1.4.6"] + "requirements": ["pylamarzocco==1.4.7"] } diff --git a/homeassistant/components/lamarzocco/number.py b/homeassistant/components/lamarzocco/number.py index 3b3d569a6f7..666c57c1866 100644 --- a/homeassistant/components/lamarzocco/number.py +++ b/homeassistant/components/lamarzocco/number.py @@ -220,7 +220,8 @@ SCALE_KEY_ENTITIES: tuple[LaMarzoccoKeyNumberEntityDescription, ...] = ( config.bbw_settings.doses[key] if config.bbw_settings else None ), supported_fn=( - lambda coordinator: coordinator.device.model == MachineModel.LINEA_MINI + lambda coordinator: coordinator.device.model + in (MachineModel.LINEA_MINI, MachineModel.LINEA_MINI_R) and coordinator.device.config.scale is not None ), ), diff --git a/homeassistant/components/lamarzocco/select.py b/homeassistant/components/lamarzocco/select.py index bd6ac1ee04f..d8217cefaff 100644 --- a/homeassistant/components/lamarzocco/select.py +++ b/homeassistant/components/lamarzocco/select.py @@ -88,6 +88,7 @@ ENTITIES: tuple[LaMarzoccoSelectEntityDescription, ...] = ( MachineModel.GS3_AV, MachineModel.LINEA_MICRA, MachineModel.LINEA_MINI, + MachineModel.LINEA_MINI_R, ), ), LaMarzoccoSelectEntityDescription( @@ -138,7 +139,7 @@ async def async_setup_entry( ] if ( - coordinator.device.model == MachineModel.LINEA_MINI + coordinator.device.model in (MachineModel.LINEA_MINI, MachineModel.LINEA_MINI_R) and coordinator.device.config.scale ): entities.extend( diff --git a/homeassistant/components/lamarzocco/sensor.py b/homeassistant/components/lamarzocco/sensor.py index 6287ea91a40..0d4a5e53ebe 100644 --- a/homeassistant/components/lamarzocco/sensor.py +++ b/homeassistant/components/lamarzocco/sensor.py @@ -80,7 +80,7 @@ ENTITIES: tuple[LaMarzoccoSensorEntityDescription, ...] = ( BoilerType.STEAM ].current_temperature, supported_fn=lambda coordinator: coordinator.device.model - != MachineModel.LINEA_MINI, + not in (MachineModel.LINEA_MINI, MachineModel.LINEA_MINI_R), ), ) @@ -125,7 +125,8 @@ SCALE_ENTITIES: tuple[LaMarzoccoSensorEntityDescription, ...] = ( device.config.scale.battery if device.config.scale else 0 ), supported_fn=( - lambda coordinator: coordinator.device.model == MachineModel.LINEA_MINI + lambda coordinator: coordinator.device.model + in (MachineModel.LINEA_MINI, MachineModel.LINEA_MINI_R) ), ), ) @@ -148,7 +149,8 @@ async def async_setup_entry( ] if ( - config_coordinator.device.model == MachineModel.LINEA_MINI + config_coordinator.device.model + in (MachineModel.LINEA_MINI, MachineModel.LINEA_MINI_R) and config_coordinator.device.config.scale ): entities.extend( diff --git a/requirements_all.txt b/requirements_all.txt index 7d8952bdb9d..d239ac021f9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2077,7 +2077,7 @@ pykwb==0.0.8 pylacrosse==0.4 # homeassistant.components.lamarzocco -pylamarzocco==1.4.6 +pylamarzocco==1.4.7 # homeassistant.components.lastfm pylast==5.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c1bd76b715b..b770f80c3f1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1691,7 +1691,7 @@ pykrakenapi==0.1.8 pykulersky==0.5.2 # homeassistant.components.lamarzocco -pylamarzocco==1.4.6 +pylamarzocco==1.4.7 # homeassistant.components.lastfm pylast==5.1.0 From bf190a8a73724e82e0acfb404291c8867256ff13 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 25 Feb 2025 10:19:41 +0100 Subject: [PATCH 1756/3148] Add backup helper (#139199) * Add backup helper * Add hassio to stage 1 * Apply same changes to newly merged `webdav` and `azure_storage` to fix inflight conflict * Address comments, add tests --------- Co-authored-by: J. Nick Koston --- homeassistant/bootstrap.py | 17 ++--- homeassistant/components/backup/__init__.py | 27 +++---- .../components/backup/basic_websocket.py | 38 ++++++++++ homeassistant/components/backup/manager.py | 18 ++--- homeassistant/components/backup/websocket.py | 26 +------ .../components/frontend/manifest.json | 1 - homeassistant/components/hassio/backup.py | 4 +- .../components/onboarding/manifest.json | 1 - homeassistant/components/onboarding/views.py | 4 +- homeassistant/helpers/backup.py | 70 +++++++++++++++++++ script/hassfest/dependencies.py | 4 ++ tests/components/azure_storage/test_backup.py | 2 + tests/components/backup/common.py | 2 + .../backup/snapshots/test_websocket.ambr | 17 +++++ tests/components/backup/test_backup.py | 4 ++ tests/components/backup/test_websocket.py | 25 +++++++ tests/components/cloud/test_backup.py | 4 +- tests/components/google_drive/test_backup.py | 4 +- tests/components/hassio/test_backup.py | 8 +++ tests/components/hassio/test_update.py | 23 +++--- tests/components/hassio/test_websocket_api.py | 23 +++--- tests/components/kitchen_sink/test_backup.py | 4 +- tests/components/onboarding/test_views.py | 6 ++ tests/components/onedrive/test_backup.py | 4 +- tests/components/synology_dsm/test_backup.py | 5 +- tests/components/webdav/test_backup.py | 2 + tests/helpers/test_backup.py | 42 +++++++++++ 27 files changed, 289 insertions(+), 96 deletions(-) create mode 100644 homeassistant/components/backup/basic_websocket.py create mode 100644 homeassistant/helpers/backup.py create mode 100644 tests/helpers/test_backup.py diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 9cfc1c95d8b..e25bfbe358c 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -74,6 +74,7 @@ from .core_config import async_process_ha_core_config from .exceptions import HomeAssistantError from .helpers import ( area_registry, + backup, category_registry, config_validation as cv, device_registry, @@ -163,16 +164,6 @@ FRONTEND_INTEGRATIONS = { # integrations can be removed and database migration status is # visible in frontend "frontend", - # Hassio is an after dependency of backup, after dependencies - # are not promoted from stage 2 to earlier stages, so we need to - # add it here. Hassio needs to be setup before backup, otherwise - # the backup integration will think we are a container/core install - # when using HAOS or Supervised install. - "hassio", - # Backup is an after dependency of frontend, after dependencies - # are not promoted from stage 2 to earlier stages, so we need to - # add it here. - "backup", } # Stage 0 is divided into substages. Each substage has a name, a set of integrations and a timeout. # The substage containing recorder should have no timeout, as it could cancel a database migration. @@ -206,6 +197,8 @@ STAGE_1_INTEGRATIONS = { "mqtt_eventstream", # To provide account link implementations "cloud", + # Ensure supervisor is available + "hassio", } DEFAULT_INTEGRATIONS = { @@ -905,6 +898,10 @@ async def _async_set_up_integrations( if "recorder" in domains_to_setup: recorder.async_initialize_recorder(hass) + # Initialize backup + if "backup" in domains_to_setup: + backup.async_initialize_backup(hass) + stage_0_and_1_domains: list[tuple[str, set[str], int | None]] = [ *( (name, domain_group & domains_to_setup, timeout) diff --git a/homeassistant/components/backup/__init__.py b/homeassistant/components/backup/__init__.py index a5159086945..d9d1c3cc2fe 100644 --- a/homeassistant/components/backup/__init__.py +++ b/homeassistant/components/backup/__init__.py @@ -1,8 +1,8 @@ """The Backup integration.""" -from homeassistant.core import HomeAssistant, ServiceCall, callback -from homeassistant.exceptions import HomeAssistantError +from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.backup import DATA_BACKUP from homeassistant.helpers.hassio import is_hassio from homeassistant.helpers.typing import ConfigType @@ -32,6 +32,7 @@ from .manager import ( IdleEvent, IncorrectPasswordError, ManagerBackup, + ManagerStateEvent, NewBackup, RestoreBackupEvent, RestoreBackupStage, @@ -63,12 +64,12 @@ __all__ = [ "IncorrectPasswordError", "LocalBackupAgent", "ManagerBackup", + "ManagerStateEvent", "NewBackup", "RestoreBackupEvent", "RestoreBackupStage", "RestoreBackupState", "WrittenBackup", - "async_get_manager", "suggested_filename", "suggested_filename_from_name_date", ] @@ -91,7 +92,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: backup_manager = BackupManager(hass, reader_writer) hass.data[DATA_MANAGER] = backup_manager - await backup_manager.async_setup() + try: + await backup_manager.async_setup() + except Exception as err: + hass.data[DATA_BACKUP].manager_ready.set_exception(err) + raise + else: + hass.data[DATA_BACKUP].manager_ready.set_result(None) async_register_websocket_handlers(hass, with_hassio) @@ -122,15 +129,3 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async_register_http_views(hass) return True - - -@callback -def async_get_manager(hass: HomeAssistant) -> BackupManager: - """Get the backup manager instance. - - Raises HomeAssistantError if the backup integration is not available. - """ - if DATA_MANAGER not in hass.data: - raise HomeAssistantError("Backup integration is not available") - - return hass.data[DATA_MANAGER] diff --git a/homeassistant/components/backup/basic_websocket.py b/homeassistant/components/backup/basic_websocket.py new file mode 100644 index 00000000000..614dc23a927 --- /dev/null +++ b/homeassistant/components/backup/basic_websocket.py @@ -0,0 +1,38 @@ +"""Websocket commands for the Backup integration.""" + +from typing import Any + +import voluptuous as vol + +from homeassistant.components import websocket_api +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.backup import async_subscribe_events + +from .const import DATA_MANAGER +from .manager import ManagerStateEvent + + +@callback +def async_register_websocket_handlers(hass: HomeAssistant) -> None: + """Register websocket commands.""" + websocket_api.async_register_command(hass, handle_subscribe_events) + + +@websocket_api.require_admin +@websocket_api.websocket_command({vol.Required("type"): "backup/subscribe_events"}) +@websocket_api.async_response +async def handle_subscribe_events( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Subscribe to backup events.""" + + def on_event(event: ManagerStateEvent) -> None: + connection.send_message(websocket_api.event_message(msg["id"], event)) + + if DATA_MANAGER in hass.data: + manager = hass.data[DATA_MANAGER] + on_event(manager.last_event) + connection.subscriptions[msg["id"]] = async_subscribe_events(hass, on_event) + connection.send_result(msg["id"]) diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index 0f79cd79e0c..3bf31618b24 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -33,6 +33,7 @@ from homeassistant.helpers import ( integration_platform, issue_registry as ir, ) +from homeassistant.helpers.backup import DATA_BACKUP from homeassistant.helpers.json import json_bytes from homeassistant.util import dt as dt_util, json as json_util @@ -332,7 +333,9 @@ class BackupManager: # Latest backup event and backup event subscribers self.last_event: ManagerStateEvent = IdleEvent() self.last_non_idle_event: ManagerStateEvent | None = None - self._backup_event_subscriptions: list[Callable[[ManagerStateEvent], None]] = [] + self._backup_event_subscriptions = hass.data[ + DATA_BACKUP + ].backup_event_subscriptions async def async_setup(self) -> None: """Set up the backup manager.""" @@ -1279,19 +1282,6 @@ class BackupManager: for subscription in self._backup_event_subscriptions: subscription(event) - @callback - def async_subscribe_events( - self, - on_event: Callable[[ManagerStateEvent], None], - ) -> Callable[[], None]: - """Subscribe events.""" - - def remove_subscription() -> None: - self._backup_event_subscriptions.remove(on_event) - - self._backup_event_subscriptions.append(on_event) - return remove_subscription - def _update_issue_backup_failed(self) -> None: """Update issue registry when a backup fails.""" ir.async_create_issue( diff --git a/homeassistant/components/backup/websocket.py b/homeassistant/components/backup/websocket.py index 5084f904ec6..8b5f35287dd 100644 --- a/homeassistant/components/backup/websocket.py +++ b/homeassistant/components/backup/websocket.py @@ -10,11 +10,7 @@ from homeassistant.helpers import config_validation as cv from .config import Day, ScheduleRecurrence from .const import DATA_MANAGER, LOGGER -from .manager import ( - DecryptOnDowloadNotSupported, - IncorrectPasswordError, - ManagerStateEvent, -) +from .manager import DecryptOnDowloadNotSupported, IncorrectPasswordError from .models import BackupNotFound, Folder @@ -34,7 +30,6 @@ def async_register_websocket_handlers(hass: HomeAssistant, with_hassio: bool) -> websocket_api.async_register_command(hass, handle_create_with_automatic_settings) websocket_api.async_register_command(hass, handle_delete) websocket_api.async_register_command(hass, handle_restore) - websocket_api.async_register_command(hass, handle_subscribe_events) websocket_api.async_register_command(hass, handle_config_info) websocket_api.async_register_command(hass, handle_config_update) @@ -401,22 +396,3 @@ def handle_config_update( changes.pop("type") manager.config.update(**changes) connection.send_result(msg["id"]) - - -@websocket_api.require_admin -@websocket_api.websocket_command({vol.Required("type"): "backup/subscribe_events"}) -@websocket_api.async_response -async def handle_subscribe_events( - hass: HomeAssistant, - connection: websocket_api.ActiveConnection, - msg: dict[str, Any], -) -> None: - """Subscribe to backup events.""" - - def on_event(event: ManagerStateEvent) -> None: - connection.send_message(websocket_api.event_message(msg["id"], event)) - - manager = hass.data[DATA_MANAGER] - on_event(manager.last_event) - connection.subscriptions[msg["id"]] = manager.async_subscribe_events(on_event) - connection.send_result(msg["id"]) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 499e1fbddb2..b13b33685d5 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -1,7 +1,6 @@ { "domain": "frontend", "name": "Home Assistant Frontend", - "after_dependencies": ["backup"], "codeowners": ["@home-assistant/frontend"], "dependencies": [ "api", diff --git a/homeassistant/components/hassio/backup.py b/homeassistant/components/hassio/backup.py index e7d169c142c..fe69b9e08e5 100644 --- a/homeassistant/components/hassio/backup.py +++ b/homeassistant/components/hassio/backup.py @@ -45,13 +45,13 @@ from homeassistant.components.backup import ( RestoreBackupStage, RestoreBackupState, WrittenBackup, - async_get_manager as async_get_backup_manager, suggested_filename as suggested_backup_filename, suggested_filename_from_name_date, ) from homeassistant.const import __version__ as HAVERSION from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.backup import async_get_manager as async_get_backup_manager from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.util import dt as dt_util from homeassistant.util.enum import try_parse_enum @@ -751,7 +751,7 @@ async def backup_addon_before_update( async def backup_core_before_update(hass: HomeAssistant) -> None: """Prepare for updating core.""" - backup_manager = async_get_backup_manager(hass) + backup_manager = await async_get_backup_manager(hass) client = get_supervisor_client(hass) try: diff --git a/homeassistant/components/onboarding/manifest.json b/homeassistant/components/onboarding/manifest.json index 3634894cd00..a4cf814eb2a 100644 --- a/homeassistant/components/onboarding/manifest.json +++ b/homeassistant/components/onboarding/manifest.json @@ -1,7 +1,6 @@ { "domain": "onboarding", "name": "Home Assistant Onboarding", - "after_dependencies": ["backup"], "codeowners": ["@home-assistant/core"], "dependencies": ["auth", "http", "person"], "documentation": "https://www.home-assistant.io/integrations/onboarding", diff --git a/homeassistant/components/onboarding/views.py b/homeassistant/components/onboarding/views.py index b392c6b57b0..a590588c009 100644 --- a/homeassistant/components/onboarding/views.py +++ b/homeassistant/components/onboarding/views.py @@ -20,7 +20,6 @@ from homeassistant.components.backup import ( BackupManager, Folder, IncorrectPasswordError, - async_get_manager as async_get_backup_manager, http as backup_http, ) from homeassistant.components.http import KEY_HASS, KEY_HASS_REFRESH_TOKEN_ID @@ -29,6 +28,7 @@ from homeassistant.components.http.view import HomeAssistantView from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import area_registry as ar +from homeassistant.helpers.backup import async_get_manager as async_get_backup_manager from homeassistant.helpers.system_info import async_get_system_info from homeassistant.helpers.translation import async_get_translations from homeassistant.setup import async_setup_component @@ -341,7 +341,7 @@ def with_backup_manager[_ViewT: BackupOnboardingView, **_P]( raise HTTPUnauthorized try: - manager = async_get_backup_manager(request.app[KEY_HASS]) + manager = await async_get_backup_manager(request.app[KEY_HASS]) except HomeAssistantError: return self.json( {"code": "backup_disabled"}, diff --git a/homeassistant/helpers/backup.py b/homeassistant/helpers/backup.py new file mode 100644 index 00000000000..4ab302749a1 --- /dev/null +++ b/homeassistant/helpers/backup.py @@ -0,0 +1,70 @@ +"""Helpers for the backup integration.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Callable +from dataclasses import dataclass, field +from typing import TYPE_CHECKING + +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.util.hass_dict import HassKey + +if TYPE_CHECKING: + from homeassistant.components.backup import BackupManager, ManagerStateEvent + +DATA_BACKUP: HassKey[BackupData] = HassKey("backup_data") +DATA_MANAGER: HassKey[BackupManager] = HassKey("backup") + + +@dataclass(slots=True) +class BackupData: + """Backup data stored in hass.data.""" + + backup_event_subscriptions: list[Callable[[ManagerStateEvent], None]] = field( + default_factory=list + ) + manager_ready: asyncio.Future[None] = field(default_factory=asyncio.Future) + + +@callback +def async_initialize_backup(hass: HomeAssistant) -> None: + """Initialize backup data. + + This creates the BackupData instance stored in hass.data[DATA_BACKUP] and + registers the basic backup websocket API which is used by frontend to subscribe + to backup events. + """ + # pylint: disable-next=import-outside-toplevel + from homeassistant.components.backup import basic_websocket + + hass.data[DATA_BACKUP] = BackupData() + basic_websocket.async_register_websocket_handlers(hass) + + +async def async_get_manager(hass: HomeAssistant) -> BackupManager: + """Get the backup manager instance. + + Raises HomeAssistantError if the backup integration is not available. + """ + if DATA_BACKUP not in hass.data: + raise HomeAssistantError("Backup integration is not available") + + await hass.data[DATA_BACKUP].manager_ready + return hass.data[DATA_MANAGER] + + +@callback +def async_subscribe_events( + hass: HomeAssistant, + on_event: Callable[[ManagerStateEvent], None], +) -> Callable[[], None]: + """Subscribe to backup events.""" + backup_event_subscriptions = hass.data[DATA_BACKUP].backup_event_subscriptions + + def remove_subscription() -> None: + backup_event_subscriptions.remove(on_event) + + backup_event_subscriptions.append(on_event) + return remove_subscription diff --git a/script/hassfest/dependencies.py b/script/hassfest/dependencies.py index d29571eaa83..368c2f762b8 100644 --- a/script/hassfest/dependencies.py +++ b/script/hassfest/dependencies.py @@ -175,6 +175,10 @@ IGNORE_VIOLATIONS = { "logbook", # Temporary needed for migration until 2024.10 ("conversation", "assist_pipeline"), + # The onboarding integration provides a limited backup API used during + # onboarding. The onboarding integration waits for the backup manager + # to be ready before calling any backup functionality. + ("onboarding", "backup"), } diff --git a/tests/components/azure_storage/test_backup.py b/tests/components/azure_storage/test_backup.py index 4dc1de0a26e..7c5912a4981 100644 --- a/tests/components/azure_storage/test_backup.py +++ b/tests/components/azure_storage/test_backup.py @@ -19,6 +19,7 @@ from homeassistant.components.azure_storage.const import ( ) from homeassistant.components.backup import DOMAIN as BACKUP_DOMAIN from homeassistant.core import HomeAssistant +from homeassistant.helpers.backup import async_initialize_backup from homeassistant.setup import async_setup_component from . import setup_integration @@ -38,6 +39,7 @@ async def setup_backup_integration( patch("homeassistant.components.backup.is_hassio", return_value=False), patch("homeassistant.components.backup.store.STORE_DELAY_SAVE", 0), ): + async_initialize_backup(hass) assert await async_setup_component(hass, BACKUP_DOMAIN, {}) await setup_integration(hass, mock_config_entry) diff --git a/tests/components/backup/common.py b/tests/components/backup/common.py index b21698bf365..e41da5c1bad 100644 --- a/tests/components/backup/common.py +++ b/tests/components/backup/common.py @@ -18,6 +18,7 @@ from homeassistant.components.backup import ( ) from homeassistant.components.backup.const import DATA_MANAGER from homeassistant.core import HomeAssistant +from homeassistant.helpers.backup import async_initialize_backup from homeassistant.setup import async_setup_component from tests.common import mock_platform @@ -125,6 +126,7 @@ async def setup_backup_integration( ) -> dict[str, Mock]: """Set up the Backup integration.""" backups = backups or {} + async_initialize_backup(hass) with ( patch("homeassistant.components.backup.is_hassio", return_value=with_hassio), patch( diff --git a/tests/components/backup/snapshots/test_websocket.ambr b/tests/components/backup/snapshots/test_websocket.ambr index c100a87e8cc..17e3ca8b176 100644 --- a/tests/components/backup/snapshots/test_websocket.ambr +++ b/tests/components/backup/snapshots/test_websocket.ambr @@ -5768,3 +5768,20 @@ 'type': 'event', }) # --- +# name: test_subscribe_event_early + dict({ + 'event': dict({ + 'manager_state': 'idle', + }), + 'id': 1, + 'type': 'event', + }) +# --- +# name: test_subscribe_event_early.1 + dict({ + 'id': 1, + 'result': None, + 'success': True, + 'type': 'result', + }) +# --- diff --git a/tests/components/backup/test_backup.py b/tests/components/backup/test_backup.py index 38b61ce65ea..c9d797f4e30 100644 --- a/tests/components/backup/test_backup.py +++ b/tests/components/backup/test_backup.py @@ -14,6 +14,7 @@ from syrupy import SnapshotAssertion from homeassistant.components.backup import DOMAIN, AgentBackup from homeassistant.core import HomeAssistant +from homeassistant.helpers.backup import async_initialize_backup from homeassistant.setup import async_setup_component from .common import ( @@ -63,6 +64,7 @@ async def test_load_backups( side_effect: Exception | None, ) -> None: """Test load backups.""" + async_initialize_backup(hass) assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() client = await hass_ws_client(hass) @@ -82,6 +84,7 @@ async def test_upload( hass_client: ClientSessionGenerator, ) -> None: """Test upload backup.""" + async_initialize_backup(hass) assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() client = await hass_client() @@ -137,6 +140,7 @@ async def test_delete_backup( unlink_path: Path | None, ) -> None: """Test delete backup.""" + async_initialize_backup(hass) assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() client = await hass_ws_client(hass) diff --git a/tests/components/backup/test_websocket.py b/tests/components/backup/test_websocket.py index 6605674a679..9b2241882c4 100644 --- a/tests/components/backup/test_websocket.py +++ b/tests/components/backup/test_websocket.py @@ -27,6 +27,8 @@ from homeassistant.components.backup.manager import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.backup import async_initialize_backup +from homeassistant.setup import async_setup_component from .common import ( LOCAL_AGENT_ID, @@ -3264,6 +3266,29 @@ async def test_subscribe_event( assert await client.receive_json() == snapshot +async def test_subscribe_event_early( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + snapshot: SnapshotAssertion, +) -> None: + """Test subscribe event before backup integration has started.""" + async_initialize_backup(hass) + await setup_backup_integration(hass, with_hassio=False) + + client = await hass_ws_client(hass) + await client.send_json_auto_id({"type": "backup/subscribe_events"}) + assert await client.receive_json() == snapshot + + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + manager = hass.data[DATA_MANAGER] + + manager.async_on_backup_event( + CreateBackupEvent(stage=None, state=CreateBackupState.IN_PROGRESS, reason=None) + ) + assert await client.receive_json() == snapshot + + @pytest.mark.parametrize( ("agent_id", "backup_id", "password"), [ diff --git a/tests/components/cloud/test_backup.py b/tests/components/cloud/test_backup.py index 18793cc00bb..5220d3eccd5 100644 --- a/tests/components/cloud/test_backup.py +++ b/tests/components/cloud/test_backup.py @@ -21,6 +21,7 @@ from homeassistant.components.cloud import DOMAIN from homeassistant.components.cloud.backup import async_register_backup_agents_listener from homeassistant.components.cloud.const import EVENT_CLOUD_EVENT from homeassistant.core import HomeAssistant +from homeassistant.helpers.backup import async_initialize_backup from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.setup import async_setup_component from homeassistant.util.aiohttp import MockStreamReader @@ -44,7 +45,8 @@ async def setup_integration( cloud: MagicMock, cloud_logged_in: None, ) -> AsyncGenerator[None]: - """Set up cloud integration.""" + """Set up cloud and backup integrations.""" + async_initialize_backup(hass) with ( patch("homeassistant.components.backup.is_hassio", return_value=False), patch("homeassistant.components.backup.store.STORE_DELAY_SAVE", 0), diff --git a/tests/components/google_drive/test_backup.py b/tests/components/google_drive/test_backup.py index 70431e2049f..2da397def5b 100644 --- a/tests/components/google_drive/test_backup.py +++ b/tests/components/google_drive/test_backup.py @@ -17,6 +17,7 @@ from homeassistant.components.backup import ( ) from homeassistant.components.google_drive import DOMAIN from homeassistant.core import HomeAssistant +from homeassistant.helpers.backup import async_initialize_backup from homeassistant.setup import async_setup_component from .conftest import CONFIG_ENTRY_TITLE, TEST_AGENT_ID @@ -63,7 +64,8 @@ async def setup_integration( config_entry: MockConfigEntry, mock_api: MagicMock, ) -> None: - """Set up Google Drive integration.""" + """Set up Google Drive and backup integrations.""" + async_initialize_backup(hass) config_entry.add_to_hass(hass) assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}}) mock_api.list_files = AsyncMock( diff --git a/tests/components/hassio/test_backup.py b/tests/components/hassio/test_backup.py index c7f400cef5c..6e4fe4dd428 100644 --- a/tests/components/hassio/test_backup.py +++ b/tests/components/hassio/test_backup.py @@ -44,6 +44,7 @@ from homeassistant.components.backup import ( from homeassistant.components.hassio import DOMAIN from homeassistant.components.hassio.backup import RESTORE_JOB_ID_ENV from homeassistant.core import HomeAssistant +from homeassistant.helpers.backup import async_initialize_backup from homeassistant.setup import async_setup_component from .test_init import MOCK_ENVIRON @@ -320,6 +321,7 @@ async def setup_backup_integration( hass: HomeAssistant, hassio_enabled: None, supervisor_client: AsyncMock ) -> None: """Set up Backup integration.""" + async_initialize_backup(hass) assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}}) await hass.async_block_till_done() @@ -432,6 +434,7 @@ async def test_agent_info( client = await hass_ws_client(hass) supervisor_client.mounts.info.return_value = mounts + async_initialize_backup(hass) assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}}) await client.send_json_auto_id({"type": "backup/agents/info"}) @@ -1287,6 +1290,7 @@ async def test_reader_writer_create_per_agent_encryption( ) supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE supervisor_client.mounts.info.return_value = mounts + async_initialize_backup(hass) assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}}) for command in commands: @@ -2352,6 +2356,7 @@ async def test_restore_progress_after_restart( supervisor_client.jobs.get_job.return_value = get_job_result + async_initialize_backup(hass) with patch.dict(os.environ, MOCK_ENVIRON | {RESTORE_JOB_ID_ENV: TEST_JOB_ID}): assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}}) @@ -2375,6 +2380,7 @@ async def test_restore_progress_after_restart_report_progress( supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE + async_initialize_backup(hass) with patch.dict(os.environ, MOCK_ENVIRON | {RESTORE_JOB_ID_ENV: TEST_JOB_ID}): assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}}) @@ -2457,6 +2463,7 @@ async def test_restore_progress_after_restart_unknown_job( supervisor_client.jobs.get_job.side_effect = SupervisorError + async_initialize_backup(hass) with patch.dict(os.environ, MOCK_ENVIRON | {RESTORE_JOB_ID_ENV: TEST_JOB_ID}): assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}}) @@ -2556,6 +2563,7 @@ async def test_config_load_config_info( hass_storage.update(storage_data) + async_initialize_backup(hass) assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}}) await hass.async_block_till_done() diff --git a/tests/components/hassio/test_update.py b/tests/components/hassio/test_update.py index 83af302e1ce..a3718454538 100644 --- a/tests/components/hassio/test_update.py +++ b/tests/components/hassio/test_update.py @@ -18,6 +18,7 @@ from homeassistant.components.hassio.const import REQUEST_REFRESH_DELAY from homeassistant.const import __version__ as HAVERSION from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.backup import async_initialize_backup from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -235,6 +236,13 @@ async def test_update_addon(hass: HomeAssistant, update_addon: AsyncMock) -> Non update_addon.assert_called_once_with("test", StoreAddonUpdate(backup=False)) +async def setup_backup_integration(hass: HomeAssistant) -> None: + """Set up the backup integration.""" + async_initialize_backup(hass) + assert await async_setup_component(hass, "backup", {}) + await hass.async_block_till_done() + + @pytest.mark.parametrize( ("commands", "default_mount", "expected_kwargs"), [ @@ -318,8 +326,7 @@ async def test_update_addon_with_backup( {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, ) assert result - assert await async_setup_component(hass, "backup", {}) - await hass.async_block_till_done() + await setup_backup_integration(hass) client = await hass_ws_client(hass) for command in commands: @@ -413,8 +420,7 @@ async def test_update_addon_with_backup_removes_old_backups( {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, ) assert result - assert await async_setup_component(hass, "backup", {}) - await hass.async_block_till_done() + await setup_backup_integration(hass) supervisor_client.mounts.info.return_value.default_backup_mount = None with ( @@ -588,8 +594,7 @@ async def test_update_core_with_backup( {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, ) assert result - assert await async_setup_component(hass, "backup", {}) - await hass.async_block_till_done() + await setup_backup_integration(hass) client = await hass_ws_client(hass) for command in commands: @@ -691,8 +696,7 @@ async def test_update_addon_with_backup_and_error( {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, ) assert result - assert await async_setup_component(hass, "backup", {}) - await hass.async_block_till_done() + await setup_backup_integration(hass) supervisor_client.homeassistant.update.return_value = None supervisor_client.mounts.info.return_value.default_backup_mount = None @@ -811,8 +815,7 @@ async def test_update_core_with_backup_and_error( {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, ) assert result - assert await async_setup_component(hass, "backup", {}) - await hass.async_block_till_done() + await setup_backup_integration(hass) supervisor_client.homeassistant.update.return_value = None supervisor_client.mounts.info.return_value.default_backup_mount = None diff --git a/tests/components/hassio/test_websocket_api.py b/tests/components/hassio/test_websocket_api.py index e752b53ae7a..b695cc1794a 100644 --- a/tests/components/hassio/test_websocket_api.py +++ b/tests/components/hassio/test_websocket_api.py @@ -26,6 +26,7 @@ from homeassistant.components.hassio.const import ( ) from homeassistant.const import __version__ as HAVERSION from homeassistant.core import HomeAssistant +from homeassistant.helpers.backup import async_initialize_backup from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.setup import async_setup_component @@ -355,6 +356,13 @@ async def test_update_addon( update_addon.assert_called_once_with("test", StoreAddonUpdate(backup=False)) +async def setup_backup_integration(hass: HomeAssistant) -> None: + """Set up the backup integration.""" + async_initialize_backup(hass) + assert await async_setup_component(hass, "backup", {}) + await hass.async_block_till_done() + + @pytest.mark.parametrize( ("commands", "default_mount", "expected_kwargs"), [ @@ -438,8 +446,7 @@ async def test_update_addon_with_backup( {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, ) assert result - assert await async_setup_component(hass, "backup", {}) - await hass.async_block_till_done() + await setup_backup_integration(hass) client = await hass_ws_client(hass) for command in commands: @@ -533,8 +540,7 @@ async def test_update_addon_with_backup_removes_old_backups( {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, ) assert result - assert await async_setup_component(hass, "backup", {}) - await hass.async_block_till_done() + await setup_backup_integration(hass) client = await hass_ws_client(hass) supervisor_client.mounts.info.return_value.default_backup_mount = None @@ -686,8 +692,7 @@ async def test_update_core_with_backup( {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, ) assert result - assert await async_setup_component(hass, "backup", {}) - await hass.async_block_till_done() + await setup_backup_integration(hass) client = await hass_ws_client(hass) for command in commands: @@ -766,8 +771,7 @@ async def test_update_addon_with_backup_and_error( {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, ) assert result - assert await async_setup_component(hass, "backup", {}) - await hass.async_block_till_done() + await setup_backup_integration(hass) supervisor_client.homeassistant.update.return_value = None supervisor_client.mounts.info.return_value.default_backup_mount = None @@ -834,8 +838,7 @@ async def test_update_core_with_backup_and_error( {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, ) assert result - assert await async_setup_component(hass, "backup", {}) - await hass.async_block_till_done() + await setup_backup_integration(hass) supervisor_client.homeassistant.update.return_value = None supervisor_client.mounts.info.return_value.default_backup_mount = None diff --git a/tests/components/kitchen_sink/test_backup.py b/tests/components/kitchen_sink/test_backup.py index 7c693abcda8..933979ee913 100644 --- a/tests/components/kitchen_sink/test_backup.py +++ b/tests/components/kitchen_sink/test_backup.py @@ -15,6 +15,7 @@ from homeassistant.components.backup import ( from homeassistant.components.kitchen_sink import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.helpers import instance_id +from homeassistant.helpers.backup import async_initialize_backup from homeassistant.setup import async_setup_component from tests.typing import ClientSessionGenerator, WebSocketGenerator @@ -35,7 +36,8 @@ async def backup_only() -> AsyncGenerator[None]: @pytest.fixture(autouse=True) async def setup_integration(hass: HomeAssistant) -> AsyncGenerator[None]: - """Set up Kitchen Sink integration.""" + """Set up Kitchen Sink and backup integrations.""" + async_initialize_backup(hass) with patch("homeassistant.components.backup.is_hassio", return_value=False): assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}}) assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) diff --git a/tests/components/onboarding/test_views.py b/tests/components/onboarding/test_views.py index 08d21a13331..b7189bda6cc 100644 --- a/tests/components/onboarding/test_views.py +++ b/tests/components/onboarding/test_views.py @@ -16,6 +16,7 @@ from homeassistant.components.onboarding import const, views from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import area_registry as ar +from homeassistant.helpers.backup import async_initialize_backup from homeassistant.setup import async_setup_component from . import mock_storage @@ -765,6 +766,7 @@ async def test_onboarding_backup_info( mock_storage(hass_storage, {"done": []}) assert await async_setup_component(hass, "onboarding", {}) + async_initialize_backup(hass) assert await async_setup_component(hass, "backup", {}) await hass.async_block_till_done() @@ -881,6 +883,7 @@ async def test_onboarding_backup_restore( mock_storage(hass_storage, {"done": []}) assert await async_setup_component(hass, "onboarding", {}) + async_initialize_backup(hass) assert await async_setup_component(hass, "backup", {}) await hass.async_block_till_done() @@ -977,6 +980,7 @@ async def test_onboarding_backup_restore_error( mock_storage(hass_storage, {"done": []}) assert await async_setup_component(hass, "onboarding", {}) + async_initialize_backup(hass) assert await async_setup_component(hass, "backup", {}) await hass.async_block_till_done() @@ -1020,6 +1024,7 @@ async def test_onboarding_backup_restore_unexpected_error( mock_storage(hass_storage, {"done": []}) assert await async_setup_component(hass, "onboarding", {}) + async_initialize_backup(hass) assert await async_setup_component(hass, "backup", {}) await hass.async_block_till_done() @@ -1045,6 +1050,7 @@ async def test_onboarding_backup_upload( mock_storage(hass_storage, {"done": []}) assert await async_setup_component(hass, "onboarding", {}) + async_initialize_backup(hass) assert await async_setup_component(hass, "backup", {}) await hass.async_block_till_done() diff --git a/tests/components/onedrive/test_backup.py b/tests/components/onedrive/test_backup.py index c307e5190c1..a81eb03a51c 100644 --- a/tests/components/onedrive/test_backup.py +++ b/tests/components/onedrive/test_backup.py @@ -21,6 +21,7 @@ from homeassistant.components.onedrive.backup import ( from homeassistant.components.onedrive.const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN from homeassistant.config_entries import SOURCE_REAUTH from homeassistant.core import HomeAssistant +from homeassistant.helpers.backup import async_initialize_backup from homeassistant.setup import async_setup_component from . import setup_integration @@ -35,7 +36,8 @@ async def setup_backup_integration( hass: HomeAssistant, mock_config_entry: MockConfigEntry, ) -> AsyncGenerator[None]: - """Set up onedrive integration.""" + """Set up onedrive and backup integrations.""" + async_initialize_backup(hass) with ( patch("homeassistant.components.backup.is_hassio", return_value=False), patch("homeassistant.components.backup.store.STORE_DELAY_SAVE", 0), diff --git a/tests/components/synology_dsm/test_backup.py b/tests/components/synology_dsm/test_backup.py index 8e98f4dffa9..24cfe29f52b 100644 --- a/tests/components/synology_dsm/test_backup.py +++ b/tests/components/synology_dsm/test_backup.py @@ -28,6 +28,7 @@ from homeassistant.const import ( CONF_USERNAME, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers.backup import async_initialize_backup from homeassistant.setup import async_setup_component from homeassistant.util.aiohttp import MockStreamReader @@ -164,7 +165,8 @@ async def setup_dsm_with_filestation( hass: HomeAssistant, mock_dsm_with_filestation: MagicMock, ): - """Mock setup of synology dsm config entry.""" + """Mock setup of synology dsm config entry and backup integration.""" + async_initialize_backup(hass) with ( patch( "homeassistant.components.synology_dsm.common.SynologyDSM", @@ -222,6 +224,7 @@ async def test_agents_not_loaded( ) -> None: """Test backup agent with no loaded config entry.""" with patch("homeassistant.components.backup.is_hassio", return_value=False): + async_initialize_backup(hass) assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}}) assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) await hass.async_block_till_done() diff --git a/tests/components/webdav/test_backup.py b/tests/components/webdav/test_backup.py index b02fb2e9628..2219e92f700 100644 --- a/tests/components/webdav/test_backup.py +++ b/tests/components/webdav/test_backup.py @@ -13,6 +13,7 @@ from homeassistant.components.backup import DOMAIN as BACKUP_DOMAIN, AgentBackup from homeassistant.components.webdav.backup import async_register_backup_agents_listener from homeassistant.components.webdav.const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN from homeassistant.core import HomeAssistant +from homeassistant.helpers.backup import async_initialize_backup from homeassistant.setup import async_setup_component from .const import BACKUP_METADATA, MOCK_LIST_WITH_INFOS @@ -30,6 +31,7 @@ async def setup_backup_integration( patch("homeassistant.components.backup.is_hassio", return_value=False), patch("homeassistant.components.backup.store.STORE_DELAY_SAVE", 0), ): + async_initialize_backup(hass) assert await async_setup_component(hass, BACKUP_DOMAIN, {}) mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) diff --git a/tests/helpers/test_backup.py b/tests/helpers/test_backup.py new file mode 100644 index 00000000000..10ff5cb855f --- /dev/null +++ b/tests/helpers/test_backup.py @@ -0,0 +1,42 @@ +"""The tests for the backup helpers.""" + +import asyncio +from unittest.mock import patch + +import pytest + +from homeassistant.components.backup import DOMAIN as BACKUP_DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import backup as backup_helper +from homeassistant.setup import async_setup_component + + +async def test_async_get_manager(hass: HomeAssistant) -> None: + """Test async_get_manager.""" + backup_helper.async_initialize_backup(hass) + task = asyncio.create_task(backup_helper.async_get_manager(hass)) + assert await async_setup_component(hass, BACKUP_DOMAIN, {}) + manager = await task + assert manager is hass.data[backup_helper.DATA_MANAGER] + + +async def test_async_get_manager_no_backup(hass: HomeAssistant) -> None: + """Test async_get_manager when the backup integration is not enabled.""" + with pytest.raises(HomeAssistantError, match="Backup integration is not available"): + await backup_helper.async_get_manager(hass) + + +async def test_async_get_manager_backup_failed_setup(hass: HomeAssistant) -> None: + """Test test_async_get_manager when the backup integration can't be set up.""" + backup_helper.async_initialize_backup(hass) + + with patch( + "homeassistant.components.backup.manager.BackupManager.async_setup", + side_effect=Exception("Boom!"), + ): + assert not await async_setup_component(hass, BACKUP_DOMAIN, {}) + with ( + pytest.raises(Exception, match="Boom!"), + ): + await backup_helper.async_get_manager(hass) From d197acc0692c7cf115aa74416ba318b6a5817104 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Tue, 25 Feb 2025 11:46:40 +0100 Subject: [PATCH 1757/3148] Reduce requests made by webdav (#139238) * Reduce requests made by webdav * Update homeassistant/components/webdav/backup.py Co-authored-by: Martin Hjelmare --------- Co-authored-by: Martin Hjelmare --- homeassistant/components/webdav/backup.py | 70 +++++++++++++---------- tests/components/webdav/conftest.py | 19 +----- tests/components/webdav/const.py | 49 +++++----------- tests/components/webdav/test_backup.py | 38 ++++++++++-- 4 files changed, 90 insertions(+), 86 deletions(-) diff --git a/homeassistant/components/webdav/backup.py b/homeassistant/components/webdav/backup.py index 2c19ca450e3..a51866fde61 100644 --- a/homeassistant/components/webdav/backup.py +++ b/homeassistant/components/webdav/backup.py @@ -95,6 +95,23 @@ def suggested_filenames(backup: AgentBackup) -> tuple[str, str]: return f"{base_name}.tar", f"{base_name}.metadata.json" +def _is_current_metadata_version(properties: list[Property]) -> bool: + """Check if any property is of the current metadata version.""" + return any( + prop.value == METADATA_VERSION + for prop in properties + if prop.namespace == "homeassistant" and prop.name == "metadata_version" + ) + + +def _backup_id_from_properties(properties: list[Property]) -> str | None: + """Return the backup ID from properties.""" + for prop in properties: + if prop.namespace == "homeassistant" and prop.name == "backup_id": + return prop.value + return None + + class WebDavBackupAgent(BackupAgent): """Backup agent interface.""" @@ -217,7 +234,7 @@ class WebDavBackupAgent(BackupAgent): metadata_files = await self._list_metadata_files() return [ await self._download_metadata(metadata_file) - for metadata_file in metadata_files + for metadata_file in metadata_files.values() ] @handle_backup_errors @@ -229,40 +246,33 @@ class WebDavBackupAgent(BackupAgent): """Return a backup.""" return await self._find_backup_by_id(backup_id) - async def _list_metadata_files(self) -> list[str]: + async def _list_metadata_files(self) -> dict[str, str]: """List metadata files.""" - files = await self._client.list_with_infos(self._backup_path) - return [ - file["path"] - for file in files - if file["path"].endswith(".json") - and await self._is_current_metadata_version(file["path"]) - ] - - async def _is_current_metadata_version(self, path: str) -> bool: - """Check if is current metadata version.""" - metadata_version = await self._client.get_property( - path, - PropertyRequest( - namespace="homeassistant", - name="metadata_version", - ), - ) - return metadata_version.value == METADATA_VERSION if metadata_version else False - - async def _find_backup_by_id(self, backup_id: str) -> AgentBackup | None: - """Find a backup by its backup ID on remote.""" - metadata_files = await self._list_metadata_files() - for metadata_file in metadata_files: - remote_backup_id = await self._client.get_property( - metadata_file, + files = await self._client.list_with_properties( + self._backup_path, + [ + PropertyRequest( + namespace="homeassistant", + name="metadata_version", + ), PropertyRequest( namespace="homeassistant", name="backup_id", ), - ) - if remote_backup_id and remote_backup_id.value == backup_id: - return await self._download_metadata(metadata_file) + ], + ) + return { + backup_id: file_name + for file_name, properties in files.items() + if file_name.endswith(".json") and _is_current_metadata_version(properties) + if (backup_id := _backup_id_from_properties(properties)) + } + + async def _find_backup_by_id(self, backup_id: str) -> AgentBackup | None: + """Find a backup by its backup ID on remote.""" + metadata_files = await self._list_metadata_files() + if metadata_file := metadata_files.get(backup_id): + return await self._download_metadata(metadata_file) return None diff --git a/tests/components/webdav/conftest.py b/tests/components/webdav/conftest.py index ccd3437aaa0..4fdd6fb7870 100644 --- a/tests/components/webdav/conftest.py +++ b/tests/components/webdav/conftest.py @@ -4,18 +4,12 @@ from collections.abc import AsyncIterator, Generator from json import dumps from unittest.mock import AsyncMock, patch -from aiowebdav2 import Property, PropertyRequest import pytest from homeassistant.components.webdav.const import DOMAIN from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME -from .const import ( - BACKUP_METADATA, - MOCK_GET_PROPERTY_BACKUP_ID, - MOCK_GET_PROPERTY_METADATA_VERSION, - MOCK_LIST_WITH_INFOS, -) +from .const import BACKUP_METADATA, MOCK_LIST_WITH_PROPERTIES from tests.common import MockConfigEntry @@ -44,14 +38,6 @@ def mock_config_entry() -> MockConfigEntry: ) -def _get_property(path: str, request: PropertyRequest) -> Property: - """Return the property of a file.""" - if path.endswith(".json") and request.name == "metadata_version": - return MOCK_GET_PROPERTY_METADATA_VERSION - - return MOCK_GET_PROPERTY_BACKUP_ID - - async def _download_mock(path: str, timeout=None) -> AsyncIterator[bytes]: """Mock the download function.""" if path.endswith(".json"): @@ -72,9 +58,8 @@ def mock_webdav_client() -> Generator[AsyncMock]: mock = mock_webdav_client.return_value mock.check.return_value = True mock.mkdir.return_value = True - mock.list_with_infos.return_value = MOCK_LIST_WITH_INFOS + mock.list_with_properties.return_value = MOCK_LIST_WITH_PROPERTIES mock.download_iter.side_effect = _download_mock mock.upload_iter.return_value = None mock.clean.return_value = None - mock.get_property.side_effect = _get_property yield mock diff --git a/tests/components/webdav/const.py b/tests/components/webdav/const.py index 777008b07a5..52cad9a163b 100644 --- a/tests/components/webdav/const.py +++ b/tests/components/webdav/const.py @@ -16,37 +16,18 @@ BACKUP_METADATA = { "size": 34519040, } -MOCK_LIST_WITH_INFOS = [ - { - "content_type": "application/x-tar", - "created": "2025-02-10T17:47:22Z", - "etag": '"84d7d000-62dcd4ce886b4"', - "isdir": "False", - "modified": "Mon, 10 Feb 2025 17:47:22 GMT", - "name": "None", - "path": "/Automatic_backup_2025.2.1_2025-02-10_18.31_30202686.tar", - "size": "2228736000", - }, - { - "content_type": "application/json", - "created": "2025-02-10T17:47:22Z", - "etag": '"8d0-62dcd4cec050a"', - "isdir": "False", - "modified": "Mon, 10 Feb 2025 17:47:22 GMT", - "name": "None", - "path": "/Automatic_backup_2025.2.1_2025-02-10_18.31_30202686.metadata.json", - "size": "2256", - }, -] - -MOCK_GET_PROPERTY_METADATA_VERSION = Property( - namespace="homeassistant", - name="metadata_version", - value="1", -) - -MOCK_GET_PROPERTY_BACKUP_ID = Property( - namespace="homeassistant", - name="backup_id", - value="23e64aec", -) +MOCK_LIST_WITH_PROPERTIES = { + "/Automatic_backup_2025.2.1_2025-02-10_18.31_30202686.tar": [], + "/Automatic_backup_2025.2.1_2025-02-10_18.31_30202686.metadata.json": [ + Property( + namespace="homeassistant", + name="backup_id", + value="23e64aec", + ), + Property( + namespace="homeassistant", + name="metadata_version", + value="1", + ), + ], +} diff --git a/tests/components/webdav/test_backup.py b/tests/components/webdav/test_backup.py index 2219e92f700..c20e73cc786 100644 --- a/tests/components/webdav/test_backup.py +++ b/tests/components/webdav/test_backup.py @@ -6,6 +6,7 @@ from collections.abc import AsyncGenerator from io import StringIO from unittest.mock import Mock, patch +from aiowebdav2 import Property from aiowebdav2.exceptions import UnauthorizedError, WebDavError import pytest @@ -16,7 +17,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.backup import async_initialize_backup from homeassistant.setup import async_setup_component -from .const import BACKUP_METADATA, MOCK_LIST_WITH_INFOS +from .const import BACKUP_METADATA, MOCK_LIST_WITH_PROPERTIES from tests.common import AsyncMock, MockConfigEntry from tests.typing import ClientSessionGenerator, WebSocketGenerator @@ -210,7 +211,7 @@ async def test_error_on_agents_download( """Test we get not found on a not existing backup on download.""" client = await hass_client() backup_id = BACKUP_METADATA["backup_id"] - webdav_client.list_with_infos.side_effect = [MOCK_LIST_WITH_INFOS, []] + webdav_client.list_with_properties.side_effect = [MOCK_LIST_WITH_PROPERTIES, {}] resp = await client.get( f"/api/backup/download/{backup_id}?agent_id={DOMAIN}.{mock_config_entry.entry_id}" @@ -261,7 +262,7 @@ async def test_agents_delete_not_found_does_not_throw( webdav_client: AsyncMock, ) -> None: """Test agent delete backup.""" - webdav_client.list_with_infos.return_value = [] + webdav_client.list_with_properties.return_value = {} client = await hass_ws_client(hass) await client.send_json_auto_id( @@ -282,7 +283,7 @@ async def test_agents_backup_not_found( webdav_client: AsyncMock, ) -> None: """Test backup not found.""" - webdav_client.list_with_infos.return_value = [] + webdav_client.list_with_properties.return_value = [] backup_id = BACKUP_METADATA["backup_id"] client = await hass_ws_client(hass) await client.send_json_auto_id({"type": "backup/details", "backup_id": backup_id}) @@ -299,7 +300,7 @@ async def test_raises_on_403( mock_config_entry: MockConfigEntry, ) -> None: """Test we raise on 403.""" - webdav_client.list_with_infos.side_effect = UnauthorizedError( + webdav_client.list_with_properties.side_effect = UnauthorizedError( "https://webdav.example.com" ) backup_id = BACKUP_METADATA["backup_id"] @@ -323,3 +324,30 @@ async def test_listeners_get_cleaned_up(hass: HomeAssistant) -> None: remove_listener() assert hass.data.get(DATA_BACKUP_AGENT_LISTENERS) is None + + +async def test_metadata_misses_backup_id( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + webdav_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test getting a backup when metadata has backup id property.""" + MOCK_LIST_WITH_PROPERTIES[ + "/Automatic_backup_2025.2.1_2025-02-10_18.31_30202686.metadata.json" + ] = [ + Property( + namespace="homeassistant", + name="metadata_version", + value="1", + ) + ] + webdav_client.list_with_properties.return_value = MOCK_LIST_WITH_PROPERTIES + + backup_id = BACKUP_METADATA["backup_id"] + client = await hass_ws_client(hass) + await client.send_json_auto_id({"type": "backup/details", "backup_id": backup_id}) + response = await client.receive_json() + + assert response["success"] + assert response["result"]["backup"] is None From 661b55d6eb62531389513f93735bbcf922899fa2 Mon Sep 17 00:00:00 2001 From: Markus Adrario Date: Tue, 25 Feb 2025 12:06:24 +0100 Subject: [PATCH 1758/3148] Add Homee valve platform (#139188) --- homeassistant/components/homee/__init__.py | 1 + homeassistant/components/homee/strings.json | 5 + homeassistant/components/homee/valve.py | 81 +++++++++++++ tests/components/homee/fixtures/valve.json | 51 ++++++++ .../homee/snapshots/test_valve.ambr | 51 ++++++++ tests/components/homee/test_sensor.py | 25 ++-- tests/components/homee/test_valve.py | 110 ++++++++++++++++++ 7 files changed, 310 insertions(+), 14 deletions(-) create mode 100644 homeassistant/components/homee/valve.py create mode 100644 tests/components/homee/fixtures/valve.json create mode 100644 tests/components/homee/snapshots/test_valve.ambr create mode 100644 tests/components/homee/test_valve.py diff --git a/homeassistant/components/homee/__init__.py b/homeassistant/components/homee/__init__.py index 0e4959c35ac..c576fa6d23c 100644 --- a/homeassistant/components/homee/__init__.py +++ b/homeassistant/components/homee/__init__.py @@ -20,6 +20,7 @@ PLATFORMS = [ Platform.LIGHT, Platform.SENSOR, Platform.SWITCH, + Platform.VALVE, ] type HomeeConfigEntry = ConfigEntry[Homee] diff --git a/homeassistant/components/homee/strings.json b/homeassistant/components/homee/strings.json index f7e24acff99..a78e12341a3 100644 --- a/homeassistant/components/homee/strings.json +++ b/homeassistant/components/homee/strings.json @@ -205,6 +205,11 @@ "watchdog": { "name": "Watchdog" } + }, + "valve": { + "valve_position": { + "name": "Valve position" + } } }, "exceptions": { diff --git a/homeassistant/components/homee/valve.py b/homeassistant/components/homee/valve.py new file mode 100644 index 00000000000..b54d6334263 --- /dev/null +++ b/homeassistant/components/homee/valve.py @@ -0,0 +1,81 @@ +"""The Homee valve platform.""" + +from pyHomee.const import AttributeType +from pyHomee.model import HomeeAttribute + +from homeassistant.components.valve import ( + ValveDeviceClass, + ValveEntity, + ValveEntityDescription, + ValveEntityFeature, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import HomeeConfigEntry +from .entity import HomeeEntity + +VALVE_DESCRIPTIONS = { + AttributeType.CURRENT_VALVE_POSITION: ValveEntityDescription( + key="valve_position", + device_class=ValveDeviceClass.WATER, + ) +} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: HomeeConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Add the Homee platform for the valve component.""" + + async_add_entities( + HomeeValve(attribute, config_entry, VALVE_DESCRIPTIONS[attribute.type]) + for node in config_entry.runtime_data.nodes + for attribute in node.attributes + if attribute.type in VALVE_DESCRIPTIONS + ) + + +class HomeeValve(HomeeEntity, ValveEntity): + """Representation of a Homee valve.""" + + _attr_reports_position = True + + def __init__( + self, + attribute: HomeeAttribute, + entry: HomeeConfigEntry, + description: ValveEntityDescription, + ) -> None: + """Initialize a Homee valve entity.""" + super().__init__(attribute, entry) + self.entity_description = description + self._attr_translation_key = description.key + + @property + def supported_features(self) -> ValveEntityFeature: + """Return the supported features.""" + if self._attribute.editable: + return ValveEntityFeature.SET_POSITION + return ValveEntityFeature(0) + + @property + def current_valve_position(self) -> int | None: + """Return the current valve position.""" + return int(self._attribute.current_value) + + @property + def is_closing(self) -> bool: + """Return if the valve is closing.""" + return self._attribute.target_value < self._attribute.current_value + + @property + def is_opening(self) -> bool: + """Return if the valve is opening.""" + return self._attribute.target_value > self._attribute.current_value + + async def async_set_valve_position(self, position: int) -> None: + """Move the valve to a specific position.""" + await self.async_set_value(position) diff --git a/tests/components/homee/fixtures/valve.json b/tests/components/homee/fixtures/valve.json new file mode 100644 index 00000000000..2b622cca6b1 --- /dev/null +++ b/tests/components/homee/fixtures/valve.json @@ -0,0 +1,51 @@ +{ + "id": 1, + "name": "Test Valve", + "profile": 3011, + "image": "nodeicon_valve", + "favorite": 0, + "order": 27, + "protocol": 3, + "routing": 0, + "state": 1, + "state_changed": 1736188706, + "added": 1610308228, + "history": 1, + "cube_type": 3, + "note": "", + "services": 7, + "phonetic_name": "", + "owner": 2, + "security": 0, + "attributes": [ + { + "id": 1, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 100, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 0.0, + "unit": "%", + "step_value": 1.0, + "editable": 1, + "type": 18, + "state": 1, + "last_changed": 1711796633, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["step"], + "history": { + "day": 1, + "week": 26, + "month": 6 + } + } + } + ] +} diff --git a/tests/components/homee/snapshots/test_valve.ambr b/tests/components/homee/snapshots/test_valve.ambr new file mode 100644 index 00000000000..c76ecc6e780 --- /dev/null +++ b/tests/components/homee/snapshots/test_valve.ambr @@ -0,0 +1,51 @@ +# serializer version: 1 +# name: test_valve_snapshot[valve.test_valve_valve_position-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'valve', + 'entity_category': None, + 'entity_id': 'valve.test_valve_valve_position', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Valve position', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'valve_position', + 'unique_id': '00055511EECC-1-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_valve_snapshot[valve.test_valve_valve_position-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_position': 0, + 'device_class': 'water', + 'friendly_name': 'Test Valve Valve position', + 'supported_features': , + }), + 'context': , + 'entity_id': 'valve.test_valve_valve_position', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'closed', + }) +# --- diff --git a/tests/components/homee/test_sensor.py b/tests/components/homee/test_sensor.py index 0f66709c532..a2ba991c49b 100644 --- a/tests/components/homee/test_sensor.py +++ b/tests/components/homee/test_sensor.py @@ -1,9 +1,8 @@ """Test homee sensors.""" -from datetime import timedelta -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch -from freezegun.api import FrozenDateTimeFactory +import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.homee.const import ( @@ -12,13 +11,18 @@ from homeassistant.components.homee.const import ( WINDOW_MAP, WINDOW_MAP_REVERSED, ) -from homeassistant.const import LIGHT_LUX +from homeassistant.const import LIGHT_LUX, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from . import async_update_attribute_value, build_mock_node, setup_integration -from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.fixture(autouse=True) +def enable_all_entities(entity_registry_enabled_by_default: None) -> None: + """Make sure all entities are enabled.""" async def test_up_down_values( @@ -110,19 +114,12 @@ async def test_sensor_snapshot( mock_homee: MagicMock, mock_config_entry: MockConfigEntry, entity_registry: er.EntityRegistry, - freezer: FrozenDateTimeFactory, snapshot: SnapshotAssertion, ) -> None: """Test the multisensor snapshot.""" mock_homee.nodes = [build_mock_node("sensors.json")] mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] - await setup_integration(hass, mock_config_entry) - entity_registry.async_update_entity( - "sensor.test_multisensor_node_state", disabled_by=None - ) - await hass.async_block_till_done() - freezer.tick(timedelta(seconds=30)) - async_fire_time_changed(hass) - await hass.async_block_till_done() + with patch("homeassistant.components.homee.PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, mock_config_entry) await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/homee/test_valve.py b/tests/components/homee/test_valve.py new file mode 100644 index 00000000000..166b52cc07b --- /dev/null +++ b/tests/components/homee/test_valve.py @@ -0,0 +1,110 @@ +"""Test Homee valves.""" + +from unittest.mock import MagicMock, patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.valve import ( + DOMAIN as VALVE_DOMAIN, + SERVICE_SET_VALVE_POSITION, + STATE_CLOSED, + STATE_CLOSING, + STATE_OPEN, + STATE_OPENING, + ValveEntityFeature, +) +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import build_mock_node, setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +async def test_valve_set_position( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test set valve position service.""" + mock_homee.nodes = [build_mock_node("valve.json")] + mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + VALVE_DOMAIN, + SERVICE_SET_VALVE_POSITION, + {ATTR_ENTITY_ID: "valve.test_valve_valve_position", "position": 100}, + ) + mock_homee.set_value.assert_called_once_with(1, 1, 100) + + +@pytest.mark.parametrize( + ("current_value", "target_value", "state"), + [ + (0.0, 0.0, STATE_CLOSED), + (0.0, 100.0, STATE_OPENING), + (100.0, 0.0, STATE_CLOSING), + (100.0, 100.0, STATE_OPEN), + ], +) +async def test_opening_closing( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, + current_value: float, + target_value: float, + state: str, +) -> None: + """Test if opening/closing is detected correctly.""" + mock_homee.nodes = [build_mock_node("valve.json")] + mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] + await setup_integration(hass, mock_config_entry) + + valve = mock_homee.nodes[0].attributes[0] + valve.current_value = current_value + valve.target_value = target_value + valve.add_on_changed_listener.call_args_list[0][0][0](valve) + await hass.async_block_till_done() + + assert hass.states.get("valve.test_valve_valve_position").state == state + + +async def test_supported_features( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test supported features.""" + mock_homee.nodes = [build_mock_node("valve.json")] + mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] + await setup_integration(hass, mock_config_entry) + + valve = mock_homee.nodes[0].attributes[0] + attributes = hass.states.get("valve.test_valve_valve_position").attributes + assert attributes["supported_features"] == ValveEntityFeature.SET_POSITION + + valve.editable = 0 + valve.add_on_changed_listener.call_args_list[0][0][0](valve) + await hass.async_block_till_done() + + attributes = hass.states.get("valve.test_valve_valve_position").attributes + assert attributes["supported_features"] == ValveEntityFeature(0) + + +async def test_valve_snapshot( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test the valve snapshots.""" + mock_homee.nodes = [build_mock_node("valve.json")] + mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] + with patch("homeassistant.components.homee.PLATFORMS", [Platform.VALVE]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) From 051cc41d4f27c2a3bb5b422783a7b9d400befb55 Mon Sep 17 00:00:00 2001 From: Andre Lengwenus Date: Tue, 25 Feb 2025 12:35:47 +0100 Subject: [PATCH 1759/3148] Fix units for LCN sensor (#138940) --- homeassistant/components/lcn/sensor.py | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/lcn/sensor.py b/homeassistant/components/lcn/sensor.py index ee87ed2a91b..7783df8679a 100644 --- a/homeassistant/components/lcn/sensor.py +++ b/homeassistant/components/lcn/sensor.py @@ -3,7 +3,6 @@ from collections.abc import Iterable from functools import partial from itertools import chain -from typing import cast import pypck @@ -18,6 +17,11 @@ from homeassistant.const import ( CONF_ENTITIES, CONF_SOURCE, CONF_UNIT_OF_MEASUREMENT, + LIGHT_LUX, + UnitOfElectricCurrent, + UnitOfElectricPotential, + UnitOfSpeed, + UnitOfTemperature, ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -47,6 +51,17 @@ DEVICE_CLASS_MAPPING = { pypck.lcn_defs.VarUnit.AMPERE: SensorDeviceClass.CURRENT, } +UNIT_OF_MEASUREMENT_MAPPING = { + pypck.lcn_defs.VarUnit.CELSIUS: UnitOfTemperature.CELSIUS, + pypck.lcn_defs.VarUnit.KELVIN: UnitOfTemperature.KELVIN, + pypck.lcn_defs.VarUnit.FAHRENHEIT: UnitOfTemperature.FAHRENHEIT, + pypck.lcn_defs.VarUnit.LUX_T: LIGHT_LUX, + pypck.lcn_defs.VarUnit.LUX_I: LIGHT_LUX, + pypck.lcn_defs.VarUnit.METERPERSECOND: UnitOfSpeed.METERS_PER_SECOND, + pypck.lcn_defs.VarUnit.VOLT: UnitOfElectricPotential.VOLT, + pypck.lcn_defs.VarUnit.AMPERE: UnitOfElectricCurrent.AMPERE, +} + def add_lcn_entities( config_entry: ConfigEntry, @@ -103,8 +118,10 @@ class LcnVariableSensor(LcnEntity, SensorEntity): config[CONF_DOMAIN_DATA][CONF_UNIT_OF_MEASUREMENT] ) - self._attr_native_unit_of_measurement = cast(str, self.unit.value) - self._attr_device_class = DEVICE_CLASS_MAPPING.get(self.unit, None) + self._attr_native_unit_of_measurement = UNIT_OF_MEASUREMENT_MAPPING.get( + self.unit + ) + self._attr_device_class = DEVICE_CLASS_MAPPING.get(self.unit) async def async_added_to_hass(self) -> None: """Run when entity about to be added to hass.""" From 48d3dd88a17826bd4ee227efc336515616559731 Mon Sep 17 00:00:00 2001 From: Dan Raper Date: Tue, 25 Feb 2025 11:36:08 +0000 Subject: [PATCH 1760/3148] Add Ohme voltage and slot list sensor (#139203) * Bump ohmepy to 1.3.1 * Bump ohmepy to 1.3.2 * Add voltage and slot list sensor * CI fixes * Change slot list sensor name * Fix snapshot tests --- homeassistant/components/ohme/icons.json | 3 + homeassistant/components/ohme/sensor.py | 15 +++ homeassistant/components/ohme/strings.json | 3 + .../ohme/snapshots/test_sensor.ambr | 99 +++++++++++++++++++ 4 files changed, 120 insertions(+) diff --git a/homeassistant/components/ohme/icons.json b/homeassistant/components/ohme/icons.json index ade48b4f80f..9771b0bf5c2 100644 --- a/homeassistant/components/ohme/icons.json +++ b/homeassistant/components/ohme/icons.json @@ -31,6 +31,9 @@ }, "ct_current": { "default": "mdi:gauge" + }, + "slot_list": { + "default": "mdi:calendar-clock" } }, "switch": { diff --git a/homeassistant/components/ohme/sensor.py b/homeassistant/components/ohme/sensor.py index 1e0572fe858..d0425040b53 100644 --- a/homeassistant/components/ohme/sensor.py +++ b/homeassistant/components/ohme/sensor.py @@ -15,7 +15,9 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import ( PERCENTAGE, + STATE_UNKNOWN, UnitOfElectricCurrent, + UnitOfElectricPotential, UnitOfEnergy, UnitOfPower, ) @@ -66,6 +68,13 @@ SENSOR_CHARGE_SESSION = [ state_class=SensorStateClass.TOTAL_INCREASING, value_fn=lambda client: client.energy, ), + OhmeSensorDescription( + key="voltage", + device_class=SensorDeviceClass.VOLTAGE, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda client: client.power.volts, + ), OhmeSensorDescription( key="battery", translation_key="vehicle_battery", @@ -74,6 +83,12 @@ SENSOR_CHARGE_SESSION = [ suggested_display_precision=0, value_fn=lambda client: client.battery, ), + OhmeSensorDescription( + key="slot_list", + translation_key="slot_list", + value_fn=lambda client: ", ".join(str(x) for x in client.slots) + or STATE_UNKNOWN, + ), ] SENSOR_ADVANCED_SETTINGS = [ diff --git a/homeassistant/components/ohme/strings.json b/homeassistant/components/ohme/strings.json index 46ccfca71fd..387b28565b2 100644 --- a/homeassistant/components/ohme/strings.json +++ b/homeassistant/components/ohme/strings.json @@ -85,6 +85,9 @@ }, "vehicle_battery": { "name": "Vehicle battery" + }, + "slot_list": { + "name": "Charge slots" } }, "switch": { diff --git a/tests/components/ohme/snapshots/test_sensor.ambr b/tests/components/ohme/snapshots/test_sensor.ambr index fc28b3b011c..9cef4bfffd9 100644 --- a/tests/components/ohme/snapshots/test_sensor.ambr +++ b/tests/components/ohme/snapshots/test_sensor.ambr @@ -1,4 +1,51 @@ # serializer version: 1 +# name: test_sensors[sensor.ohme_home_pro_charge_slots-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ohme_home_pro_charge_slots', + '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': 'Charge slots', + 'platform': 'ohme', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'slot_list', + 'unique_id': 'chargerid_slot_list', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.ohme_home_pro_charge_slots-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Ohme Home Pro Charge slots', + }), + 'context': , + 'entity_id': 'sensor.ohme_home_pro_charge_slots', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_sensors[sensor.ohme_home_pro_ct_current-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -327,3 +374,55 @@ 'state': '80', }) # --- +# name: test_sensors[sensor.ohme_home_pro_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ohme_home_pro_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage', + 'platform': 'ohme', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'chargerid_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.ohme_home_pro_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Ohme Home Pro Voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ohme_home_pro_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- From 01fb6841da27c4dbec10b4ecc93aa2787b1b61de Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 25 Feb 2025 12:36:20 +0100 Subject: [PATCH 1761/3148] Initiate source list as instance variable in Volumio (#139243) --- homeassistant/components/volumio/media_player.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/volumio/media_player.py b/homeassistant/components/volumio/media_player.py index 514f1ad9221..773a125d483 100644 --- a/homeassistant/components/volumio/media_player.py +++ b/homeassistant/components/volumio/media_player.py @@ -70,7 +70,6 @@ class Volumio(MediaPlayerEntity): | MediaPlayerEntityFeature.CLEAR_PLAYLIST | MediaPlayerEntityFeature.BROWSE_MEDIA ) - _attr_source_list = [] def __init__(self, volumio, uid, name, info): """Initialize the media player.""" @@ -78,6 +77,7 @@ class Volumio(MediaPlayerEntity): unique_id = uid self._state = {} self.thumbnail_cache = {} + self._attr_source_list = [] self._attr_unique_id = unique_id self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, unique_id)}, From 9e063fd77c3a11d6f7881303a0105fb7972aa912 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 25 Feb 2025 12:36:59 +0100 Subject: [PATCH 1762/3148] `logbook.log` action: Make description of `name` field UI-friendly (#139200) --- homeassistant/components/logbook/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/logbook/strings.json b/homeassistant/components/logbook/strings.json index 27ad49b0e3a..5a38b57a9b7 100644 --- a/homeassistant/components/logbook/strings.json +++ b/homeassistant/components/logbook/strings.json @@ -7,7 +7,7 @@ "fields": { "name": { "name": "[%key:common::config_flow::data::name%]", - "description": "Custom name for an entity, can be referenced using an `entity_id`." + "description": "Custom name for an entity, can be referenced using the 'Entity ID' field." }, "message": { "name": "Message", From cea5cda881cb25300d0bd9e78998af31f519428d Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 25 Feb 2025 12:47:18 +0100 Subject: [PATCH 1763/3148] Treat "Twist Assist" & "Block to Block" as feature names and add descriptions in Z-Wave (#139239) Treat "Twist Assist" & "Block to Block" as feature names and add descriptions - name-case both "Twist Assist" and "Block to Block" so those feature names don't get translated - for proper translation of both features add useful descriptions of what they actually do - fix sentence-casing on "Operation type" --- homeassistant/components/zwave_js/strings.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/zwave_js/strings.json b/homeassistant/components/zwave_js/strings.json index e845cc28707..8f23fee4447 100644 --- a/homeassistant/components/zwave_js/strings.json +++ b/homeassistant/components/zwave_js/strings.json @@ -516,8 +516,8 @@ "name": "Auto relock time" }, "block_to_block": { - "description": "Enable block-to-block functionality.", - "name": "Block to block" + "description": "Whether the lock should run the motor until it hits resistance.", + "name": "Block to Block" }, "hold_and_release_time": { "description": "Duration in seconds the latch stays retracted.", @@ -529,11 +529,11 @@ }, "operation_type": { "description": "The operation type of the lock.", - "name": "Operation Type" + "name": "Operation type" }, "twist_assist": { - "description": "Enable Twist Assist.", - "name": "Twist assist" + "description": "Whether the motor should help in locking and unlocking.", + "name": "Twist Assist" } }, "name": "Set lock configuration" From bc7f5f39818007c02972c300c0df799089b1d62a Mon Sep 17 00:00:00 2001 From: LG-ThinQ-Integration Date: Tue, 25 Feb 2025 20:58:01 +0900 Subject: [PATCH 1764/3148] Add climate's swing mode to LG ThinQ (#137619) Co-authored-by: yunseon.park --- homeassistant/components/lg_thinq/climate.py | 52 +++++++++++++++++++ .../lg_thinq/snapshots/test_climate.ambr | 22 +++++++- 2 files changed, 72 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/lg_thinq/climate.py b/homeassistant/components/lg_thinq/climate.py index ff57709f9a8..063705f5d0d 100644 --- a/homeassistant/components/lg_thinq/climate.py +++ b/homeassistant/components/lg_thinq/climate.py @@ -12,6 +12,8 @@ from thinqconnect.integration import ExtendedProperty from homeassistant.components.climate import ( ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, + SWING_OFF, + SWING_ON, ClimateEntity, ClimateEntityDescription, ClimateEntityFeature, @@ -73,6 +75,13 @@ HVAC_TO_STR: dict[HVACMode, str] = { THINQ_PRESET_MODE: list[str] = ["air_clean", "aroma", "energy_saving"] +STR_TO_SWING = { + "true": SWING_ON, + "false": SWING_OFF, +} + +SWING_TO_STR = {v: k for k, v in STR_TO_SWING.items()} + _LOGGER = logging.getLogger(__name__) @@ -142,6 +151,14 @@ class ThinQClimateEntity(ThinQEntity, ClimateEntity): self._attr_supported_features |= ( ClimateEntityFeature.TARGET_TEMPERATURE_RANGE ) + # Supports swing mode. + if self.data.swing_modes: + self._attr_swing_modes = [SWING_ON, SWING_OFF] + self._attr_supported_features |= ClimateEntityFeature.SWING_MODE + + if self.data.swing_horizontal_modes: + self._attr_swing_horizontal_modes = [SWING_ON, SWING_OFF] + self._attr_supported_features |= ClimateEntityFeature.SWING_HORIZONTAL_MODE def _update_status(self) -> None: """Update status itself.""" @@ -150,6 +167,13 @@ class ThinQClimateEntity(ThinQEntity, ClimateEntity): # Update fan, hvac and preset mode. if self.supported_features & ClimateEntityFeature.FAN_MODE: self._attr_fan_mode = self.data.fan_mode + if self.supported_features & ClimateEntityFeature.SWING_MODE: + self._attr_swing_mode = STR_TO_SWING.get(self.data.swing_mode) + if self.supported_features & ClimateEntityFeature.SWING_HORIZONTAL_MODE: + self._attr_swing_horizontal_mode = STR_TO_SWING.get( + self.data.swing_horizontal_mode + ) + if self.data.is_on: hvac_mode = self._requested_hvac_mode or self.data.hvac_mode if hvac_mode in STR_TO_HVAC: @@ -268,6 +292,34 @@ class ThinQClimateEntity(ThinQEntity, ClimateEntity): self.coordinator.api.async_set_fan_mode(self.property_id, fan_mode) ) + async def async_set_swing_mode(self, swing_mode: str) -> None: + """Set new swing mode.""" + _LOGGER.debug( + "[%s:%s] async_set_swing_mode: %s", + self.coordinator.device_name, + self.property_id, + swing_mode, + ) + await self.async_call_api( + self.coordinator.api.async_set_swing_mode( + self.property_id, SWING_TO_STR.get(swing_mode) + ) + ) + + async def async_set_swing_horizontal_mode(self, swing_horizontal_mode: str) -> None: + """Set new swing horizontal mode.""" + _LOGGER.debug( + "[%s:%s] async_set_swing_horizontal_mode: %s", + self.coordinator.device_name, + self.property_id, + swing_horizontal_mode, + ) + await self.async_call_api( + self.coordinator.api.async_set_swing_horizontal_mode( + self.property_id, SWING_TO_STR.get(swing_horizontal_mode) + ) + ) + def _round_by_step(self, temperature: float) -> float: """Round the value by step.""" if ( diff --git a/tests/components/lg_thinq/snapshots/test_climate.ambr b/tests/components/lg_thinq/snapshots/test_climate.ambr index e2fcc2540f3..db57e824487 100644 --- a/tests/components/lg_thinq/snapshots/test_climate.ambr +++ b/tests/components/lg_thinq/snapshots/test_climate.ambr @@ -20,6 +20,14 @@ 'preset_modes': list([ 'air_clean', ]), + 'swing_horizontal_modes': list([ + 'on', + 'off', + ]), + 'swing_modes': list([ + 'on', + 'off', + ]), 'target_temp_step': 1, }), 'config_entry_id': , @@ -44,7 +52,7 @@ 'original_name': None, 'platform': 'lg_thinq', 'previous_unique_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': , 'unique_id': 'MW2-2E247F93-B570-46A6-B827-920E9E10F966_climate_air_conditioner', 'unit_of_measurement': None, @@ -73,7 +81,17 @@ 'preset_modes': list([ 'air_clean', ]), - 'supported_features': , + 'supported_features': , + 'swing_horizontal_mode': 'off', + 'swing_horizontal_modes': list([ + 'on', + 'off', + ]), + 'swing_mode': 'off', + 'swing_modes': list([ + 'on', + 'off', + ]), 'target_temp_high': None, 'target_temp_low': None, 'target_temp_step': 1, From 694a77fe3c1f7f89510a9ed80b02fe4738a294cb Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 25 Feb 2025 13:24:32 +0100 Subject: [PATCH 1765/3148] Bump aiowithings to 3.1.6 (#139242) --- homeassistant/components/withings/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/withings/manifest.json b/homeassistant/components/withings/manifest.json index 4c78e077d21..232997da054 100644 --- a/homeassistant/components/withings/manifest.json +++ b/homeassistant/components/withings/manifest.json @@ -13,5 +13,5 @@ "documentation": "https://www.home-assistant.io/integrations/withings", "iot_class": "cloud_push", "loggers": ["aiowithings"], - "requirements": ["aiowithings==3.1.5"] + "requirements": ["aiowithings==3.1.6"] } diff --git a/requirements_all.txt b/requirements_all.txt index d239ac021f9..1274cd99deb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -428,7 +428,7 @@ aiowebdav2==0.3.0 aiowebostv==0.7.0 # homeassistant.components.withings -aiowithings==3.1.5 +aiowithings==3.1.6 # homeassistant.components.yandex_transport aioymaps==1.2.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b770f80c3f1..6e3238a5fe7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -410,7 +410,7 @@ aiowebdav2==0.3.0 aiowebostv==0.7.0 # homeassistant.components.withings -aiowithings==3.1.5 +aiowithings==3.1.6 # homeassistant.components.yandex_transport aioymaps==1.2.5 From 2509353221182f1db94a6e25dd25f8b335e13169 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Tue, 25 Feb 2025 13:40:21 +0100 Subject: [PATCH 1766/3148] Add update reward action to Habitica integration (#139157) --- homeassistant/components/habitica/const.py | 5 + homeassistant/components/habitica/icons.json | 7 + homeassistant/components/habitica/services.py | 148 +++++++++- .../components/habitica/services.yaml | 40 +++ .../components/habitica/strings.json | 72 ++++- tests/components/habitica/conftest.py | 7 + .../habitica/fixtures/create_tag.json | 8 + .../components/habitica/fixtures/reward.json | 27 ++ tests/components/habitica/fixtures/tasks.json | 5 +- .../habitica/snapshots/test_sensor.ambr | 4 + .../habitica/snapshots/test_services.ambr | 8 + tests/components/habitica/test_services.py | 267 +++++++++++++++++- 12 files changed, 593 insertions(+), 5 deletions(-) create mode 100644 tests/components/habitica/fixtures/create_tag.json create mode 100644 tests/components/habitica/fixtures/reward.json diff --git a/homeassistant/components/habitica/const.py b/homeassistant/components/habitica/const.py index 5eb616142e5..5e18477d142 100644 --- a/homeassistant/components/habitica/const.py +++ b/homeassistant/components/habitica/const.py @@ -35,6 +35,10 @@ ATTR_TYPE = "type" ATTR_PRIORITY = "priority" ATTR_TAG = "tag" ATTR_KEYWORD = "keyword" +ATTR_REMOVE_TAG = "remove_tag" +ATTR_ALIAS = "alias" +ATTR_PRIORITY = "priority" +ATTR_COST = "cost" SERVICE_CAST_SKILL = "cast_skill" SERVICE_START_QUEST = "start_quest" @@ -50,6 +54,7 @@ SERVICE_SCORE_REWARD = "score_reward" SERVICE_TRANSFORMATION = "transformation" +SERVICE_UPDATE_REWARD = "update_reward" DEVELOPER_ID = "4c4ca53f-c059-4ffa-966e-9d29dd405daf" X_CLIENT = f"{DEVELOPER_ID} - {APPLICATION_NAME} {__version__}" diff --git a/homeassistant/components/habitica/icons.json b/homeassistant/components/habitica/icons.json index 6ae6ebd728b..e119b063aa5 100644 --- a/homeassistant/components/habitica/icons.json +++ b/homeassistant/components/habitica/icons.json @@ -217,6 +217,13 @@ "sections": { "filter": "mdi:calendar-filter" } + }, + "update_reward": { + "service": "mdi:treasure-chest", + "sections": { + "tag_options": "mdi:tag", + "developer_options": "mdi:test-tube" + } } } } diff --git a/homeassistant/components/habitica/services.py b/homeassistant/components/habitica/services.py index 59bcc8cc7cc..16bbeef9073 100644 --- a/homeassistant/components/habitica/services.py +++ b/homeassistant/components/habitica/services.py @@ -4,7 +4,8 @@ from __future__ import annotations from dataclasses import asdict import logging -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, cast +from uuid import UUID from aiohttp import ClientError from habiticalib import ( @@ -13,6 +14,7 @@ from habiticalib import ( NotAuthorizedError, NotFoundError, Skill, + Task, TaskData, TaskPriority, TaskType, @@ -20,6 +22,7 @@ from habiticalib import ( ) import voluptuous as vol +from homeassistant.components.todo import ATTR_DESCRIPTION, ATTR_RENAME from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ATTR_NAME, CONF_NAME from homeassistant.core import ( @@ -34,14 +37,17 @@ from homeassistant.helpers.issue_registry import IssueSeverity, async_create_iss from homeassistant.helpers.selector import ConfigEntrySelector from .const import ( + ATTR_ALIAS, ATTR_ARGS, ATTR_CONFIG_ENTRY, + ATTR_COST, ATTR_DATA, ATTR_DIRECTION, ATTR_ITEM, ATTR_KEYWORD, ATTR_PATH, ATTR_PRIORITY, + ATTR_REMOVE_TAG, ATTR_SKILL, ATTR_TAG, ATTR_TARGET, @@ -61,6 +67,7 @@ from .const import ( SERVICE_SCORE_REWARD, SERVICE_START_QUEST, SERVICE_TRANSFORMATION, + SERVICE_UPDATE_REWARD, ) from .coordinator import HabiticaConfigEntry @@ -104,6 +111,21 @@ SERVICE_TRANSFORMATION_SCHEMA = vol.Schema( } ) +SERVICE_UPDATE_TASK_SCHEMA = vol.Schema( + { + vol.Required(ATTR_CONFIG_ENTRY): ConfigEntrySelector(), + vol.Required(ATTR_TASK): cv.string, + vol.Optional(ATTR_RENAME): cv.string, + vol.Optional(ATTR_DESCRIPTION): cv.string, + vol.Optional(ATTR_TAG): vol.All(cv.ensure_list, [str]), + vol.Optional(ATTR_REMOVE_TAG): vol.All(cv.ensure_list, [str]), + vol.Optional(ATTR_ALIAS): vol.All( + cv.string, cv.matches_regex("^[a-zA-Z0-9-_]*$") + ), + vol.Optional(ATTR_COST): vol.Coerce(float), + } +) + SERVICE_GET_TASKS_SCHEMA = vol.Schema( { vol.Required(ATTR_CONFIG_ENTRY): ConfigEntrySelector({"integration": DOMAIN}), @@ -516,6 +538,130 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 return result + async def update_task(call: ServiceCall) -> ServiceResponse: + """Update task action.""" + entry = get_config_entry(hass, call.data[ATTR_CONFIG_ENTRY]) + coordinator = entry.runtime_data + await coordinator.async_refresh() + + try: + current_task = next( + task + for task in coordinator.data.tasks + if call.data[ATTR_TASK] in (str(task.id), task.alias, task.text) + and task.Type is TaskType.REWARD + ) + except StopIteration as e: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="task_not_found", + translation_placeholders={"task": f"'{call.data[ATTR_TASK]}'"}, + ) from e + + task_id = current_task.id + if TYPE_CHECKING: + assert task_id + data = Task() + + if rename := call.data.get(ATTR_RENAME): + data["text"] = rename + + if (description := call.data.get(ATTR_DESCRIPTION)) is not None: + data["notes"] = description + + tags = cast(list[str], call.data.get(ATTR_TAG)) + remove_tags = cast(list[str], call.data.get(ATTR_REMOVE_TAG)) + + if tags or remove_tags: + update_tags = set(current_task.tags) + user_tags = { + tag.name.lower(): tag.id + for tag in coordinator.data.user.tags + if tag.id and tag.name + } + + if tags: + # Creates new tag if it doesn't exist + async def create_tag(tag_name: str) -> UUID: + tag_id = (await coordinator.habitica.create_tag(tag_name)).data.id + if TYPE_CHECKING: + assert tag_id + return tag_id + + try: + update_tags.update( + { + user_tags.get(tag_name.lower()) + or (await create_tag(tag_name)) + for tag_name in tags + } + ) + except TooManyRequestsError as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="setup_rate_limit_exception", + translation_placeholders={"retry_after": str(e.retry_after)}, + ) from e + except HabiticaException as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="service_call_exception", + translation_placeholders={"reason": str(e.error.message)}, + ) from e + except ClientError as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="service_call_exception", + translation_placeholders={"reason": str(e)}, + ) from e + + if remove_tags: + update_tags.difference_update( + { + user_tags[tag_name.lower()] + for tag_name in remove_tags + if tag_name.lower() in user_tags + } + ) + + data["tags"] = list(update_tags) + + if (alias := call.data.get(ATTR_ALIAS)) is not None: + data["alias"] = alias + + if (cost := call.data.get(ATTR_COST)) is not None: + data["value"] = cost + + try: + response = await coordinator.habitica.update_task(task_id, data) + except TooManyRequestsError as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="setup_rate_limit_exception", + translation_placeholders={"retry_after": str(e.retry_after)}, + ) from e + except HabiticaException as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="service_call_exception", + translation_placeholders={"reason": str(e.error.message)}, + ) from e + except ClientError as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="service_call_exception", + translation_placeholders={"reason": str(e)}, + ) from e + else: + return response.data.to_dict(omit_none=True) + + hass.services.async_register( + DOMAIN, + SERVICE_UPDATE_REWARD, + update_task, + schema=SERVICE_UPDATE_TASK_SCHEMA, + supports_response=SupportsResponse.ONLY, + ) hass.services.async_register( DOMAIN, SERVICE_API_CALL, diff --git a/homeassistant/components/habitica/services.yaml b/homeassistant/components/habitica/services.yaml index f3095518290..b8479c1eeec 100644 --- a/homeassistant/components/habitica/services.yaml +++ b/homeassistant/components/habitica/services.yaml @@ -140,3 +140,43 @@ get_tasks: required: false selector: text: +update_reward: + fields: + config_entry: *config_entry + task: *task + rename: + selector: + text: + description: + required: false + selector: + text: + multiline: true + cost: + required: false + selector: + number: + min: 0 + step: 0.01 + unit_of_measurement: "🪙" + mode: box + tag_options: + collapsed: true + fields: + tag: + required: false + selector: + text: + multiple: true + remove_tag: + required: false + selector: + text: + multiple: true + developer_options: + collapsed: true + fields: + alias: + required: false + selector: + text: diff --git a/homeassistant/components/habitica/strings.json b/homeassistant/components/habitica/strings.json index 396a10e05f9..75558cea078 100644 --- a/homeassistant/components/habitica/strings.json +++ b/homeassistant/components/habitica/strings.json @@ -7,7 +7,23 @@ "unit_tasks": "tasks", "unit_health_points": "HP", "unit_mana_points": "MP", - "unit_experience_points": "XP" + "unit_experience_points": "XP", + "config_entry_description": "Select the Habitica account to update a task.", + "task_description": "The name (or task ID) of the task you want to update.", + "rename_name": "Rename", + "rename_description": "The new title for the Habitica task.", + "description_name": "Update description", + "description_description": "The new description for the Habitica task.", + "tag_name": "Add tags", + "tag_description": "Add tags to the Habitica task. If a tag does not already exist, a new one will be created.", + "remove_tag_name": "Remove tags", + "remove_tag_description": "Remove tags from the Habitica task.", + "alias_name": "Task alias", + "alias_description": "A task alias can be used instead of the name or task ID. Only dashes, underscores, and alphanumeric characters are supported. The task alias must be unique among all your tasks.", + "developer_options_name": "Advanced settings", + "developer_options_description": "Additional features available in developer mode.", + "tag_options_name": "Tags", + "tag_options_description": "Add or remove tags from a task." }, "config": { "abort": { @@ -457,6 +473,12 @@ }, "authentication_failed": { "message": "Authentication failed. It looks like your API token has been reset. Please re-authenticate using your new token" + }, + "frequency_not_weekly": { + "message": "Unable to update task, weekly repeat settings apply only to weekly recurring dailies." + }, + "frequency_not_monthly": { + "message": "Unable to update task, monthly repeat settings apply only to monthly recurring dailies." } }, "issues": { @@ -651,6 +673,54 @@ "description": "Use the optional filters to narrow the returned tasks." } } + }, + "update_reward": { + "name": "Update a reward", + "description": "Updates a specific reward for the selected Habitica character", + "fields": { + "config_entry": { + "name": "[%key:component::habitica::common::config_entry_name%]", + "description": "Select the Habitica account to update a reward." + }, + "task": { + "name": "[%key:component::habitica::common::task_name%]", + "description": "[%key:component::habitica::common::task_description%]" + }, + "rename": { + "name": "[%key:component::habitica::common::rename_name%]", + "description": "[%key:component::habitica::common::rename_description%]" + }, + "description": { + "name": "[%key:component::habitica::common::description_name%]", + "description": "[%key:component::habitica::common::description_description%]" + }, + "tag": { + "name": "[%key:component::habitica::common::tag_name%]", + "description": "[%key:component::habitica::common::tag_description%]" + }, + "remove_tag": { + "name": "[%key:component::habitica::common::remove_tag_name%]", + "description": "[%key:component::habitica::common::remove_tag_description%]" + }, + "alias": { + "name": "[%key:component::habitica::common::alias_name%]", + "description": "[%key:component::habitica::common::alias_description%]" + }, + "cost": { + "name": "Cost", + "description": "Update the cost of a reward." + } + }, + "sections": { + "tag_options": { + "name": "[%key:component::habitica::common::tag_options_name%]", + "description": "[%key:component::habitica::common::tag_options_description%]" + }, + "developer_options": { + "name": "[%key:component::habitica::common::developer_options_name%]", + "description": "[%key:component::habitica::common::developer_options_description%]" + } + } } }, "selector": { diff --git a/tests/components/habitica/conftest.py b/tests/components/habitica/conftest.py index e04fc58ad15..45c33a9ebb6 100644 --- a/tests/components/habitica/conftest.py +++ b/tests/components/habitica/conftest.py @@ -14,6 +14,7 @@ from habiticalib import ( HabiticaResponse, HabiticaScoreResponse, HabiticaSleepResponse, + HabiticaTagResponse, HabiticaTaskOrderResponse, HabiticaTaskResponse, HabiticaTasksResponse, @@ -144,6 +145,12 @@ async def mock_habiticalib() -> Generator[AsyncMock]: load_fixture("anonymized.json", DOMAIN) ) ) + client.update_task.return_value = HabiticaTaskResponse.from_json( + load_fixture("task.json", DOMAIN) + ) + client.create_tag.return_value = HabiticaTagResponse.from_json( + load_fixture("create_tag.json", DOMAIN) + ) client.habitipy.return_value = { "tasks": { "user": { diff --git a/tests/components/habitica/fixtures/create_tag.json b/tests/components/habitica/fixtures/create_tag.json new file mode 100644 index 00000000000..638ec69d84e --- /dev/null +++ b/tests/components/habitica/fixtures/create_tag.json @@ -0,0 +1,8 @@ +{ + "success": true, + "data": { + "name": "Home Assistant", + "id": "8bc0afbf-ab8e-49a4-982d-67a40557ed1a" + }, + "notifications": [] +} diff --git a/tests/components/habitica/fixtures/reward.json b/tests/components/habitica/fixtures/reward.json new file mode 100644 index 00000000000..1c639c4298e --- /dev/null +++ b/tests/components/habitica/fixtures/reward.json @@ -0,0 +1,27 @@ +{ + "success": true, + "data": { + "_id": "5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b", + "type": "reward", + "text": "Belohne Dich selbst", + "notes": "Schaue fern, spiele ein Spiel, gönne Dir einen Leckerbissen, es liegt ganz bei Dir!", + "tags": [], + "value": 10, + "priority": 1, + "attribute": "str", + "challenge": {}, + "group": { + "completedBy": {}, + "assignedUsers": [] + }, + "byHabitica": false, + "reminders": [], + "createdAt": "2024-07-07T17:51:53.266Z", + "updatedAt": "2024-07-07T17:51:53.266Z", + "userId": "5f359083-ef78-4af0-985a-0b2c6d05797c", + "id": "5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b" + }, + "notifications": [], + "userV": 589, + "appVersion": "5.28.6" +} diff --git a/tests/components/habitica/fixtures/tasks.json b/tests/components/habitica/fixtures/tasks.json index cf6e3864675..378652138bc 100644 --- a/tests/components/habitica/fixtures/tasks.json +++ b/tests/components/habitica/fixtures/tasks.json @@ -533,7 +533,10 @@ "type": "reward", "text": "Belohne Dich selbst", "notes": "Schaue fern, spiele ein Spiel, gönne Dir einen Leckerbissen, es liegt ganz bei Dir!", - "tags": [], + "tags": [ + "3450351f-1323-4c7e-9fd2-0cdff25b3ce0", + "b2780f82-b3b5-49a3-a677-48f2c8c7e3bb" + ], "value": 10, "priority": 1, "attribute": "str", diff --git a/tests/components/habitica/snapshots/test_sensor.ambr b/tests/components/habitica/snapshots/test_sensor.ambr index 881326f76d8..1fbc9eca595 100644 --- a/tests/components/habitica/snapshots/test_sensor.ambr +++ b/tests/components/habitica/snapshots/test_sensor.ambr @@ -1271,6 +1271,10 @@ 'th': False, 'w': True, }), + 'tags': list([ + '3450351f-1323-4c7e-9fd2-0cdff25b3ce0', + 'b2780f82-b3b5-49a3-a677-48f2c8c7e3bb', + ]), 'text': 'Belohne Dich selbst', 'type': 'reward', 'value': 10.0, diff --git a/tests/components/habitica/snapshots/test_services.ambr b/tests/components/habitica/snapshots/test_services.ambr index e25ed8db313..79c9e3eab66 100644 --- a/tests/components/habitica/snapshots/test_services.ambr +++ b/tests/components/habitica/snapshots/test_services.ambr @@ -1081,6 +1081,8 @@ 'startDate': None, 'streak': None, 'tags': list([ + '3450351f-1323-4c7e-9fd2-0cdff25b3ce0', + 'b2780f82-b3b5-49a3-a677-48f2c8c7e3bb', ]), 'text': 'Belohne Dich selbst', 'type': 'reward', @@ -3321,6 +3323,8 @@ 'startDate': None, 'streak': None, 'tags': list([ + '3450351f-1323-4c7e-9fd2-0cdff25b3ce0', + 'b2780f82-b3b5-49a3-a677-48f2c8c7e3bb', ]), 'text': 'Belohne Dich selbst', 'type': 'reward', @@ -5580,6 +5584,8 @@ 'startDate': None, 'streak': None, 'tags': list([ + '3450351f-1323-4c7e-9fd2-0cdff25b3ce0', + 'b2780f82-b3b5-49a3-a677-48f2c8c7e3bb', ]), 'text': 'Belohne Dich selbst', 'type': 'reward', @@ -5954,6 +5960,8 @@ 'startDate': None, 'streak': None, 'tags': list([ + '3450351f-1323-4c7e-9fd2-0cdff25b3ce0', + 'b2780f82-b3b5-49a3-a677-48f2c8c7e3bb', ]), 'text': 'Belohne Dich selbst', 'type': 'reward', diff --git a/tests/components/habitica/test_services.py b/tests/components/habitica/test_services.py index 5fca1884bdf..3f7ca14220b 100644 --- a/tests/components/habitica/test_services.py +++ b/tests/components/habitica/test_services.py @@ -6,16 +6,19 @@ from unittest.mock import AsyncMock, patch from uuid import UUID from aiohttp import ClientError -from habiticalib import Direction, Skill +from habiticalib import Direction, HabiticaTaskResponse, Skill, Task import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.habitica.const import ( + ATTR_ALIAS, ATTR_CONFIG_ENTRY, + ATTR_COST, ATTR_DIRECTION, ATTR_ITEM, ATTR_KEYWORD, ATTR_PRIORITY, + ATTR_REMOVE_TAG, ATTR_SKILL, ATTR_TAG, ATTR_TARGET, @@ -33,7 +36,9 @@ from homeassistant.components.habitica.const import ( SERVICE_SCORE_REWARD, SERVICE_START_QUEST, SERVICE_TRANSFORMATION, + SERVICE_UPDATE_REWARD, ) +from homeassistant.components.todo import ATTR_DESCRIPTION, ATTR_RENAME from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError @@ -45,7 +50,7 @@ from .conftest import ( ERROR_TOO_MANY_REQUESTS, ) -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, load_fixture REQUEST_EXCEPTION_MSG = "Unable to connect to Habitica: reason" RATE_LIMIT_EXCEPTION_MSG = "Rate limit exceeded, try again in 5 seconds" @@ -889,3 +894,261 @@ async def test_get_tasks( ) assert response == snapshot + + +@pytest.mark.parametrize( + ("exception", "expected_exception", "exception_msg"), + [ + ( + ERROR_TOO_MANY_REQUESTS, + HomeAssistantError, + RATE_LIMIT_EXCEPTION_MSG, + ), + ( + ERROR_BAD_REQUEST, + HomeAssistantError, + REQUEST_EXCEPTION_MSG, + ), + ( + ClientError, + HomeAssistantError, + "Unable to connect to Habitica: ", + ), + ], +) +@pytest.mark.usefixtures("habitica") +async def test_update_task_exceptions( + hass: HomeAssistant, + config_entry: MockConfigEntry, + habitica: AsyncMock, + exception: Exception, + expected_exception: Exception, + exception_msg: str, +) -> None: + """Test Habitica task action exceptions.""" + task_id = "5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b" + + habitica.update_task.side_effect = exception + with pytest.raises(expected_exception, match=exception_msg): + await hass.services.async_call( + DOMAIN, + SERVICE_UPDATE_REWARD, + service_data={ + ATTR_CONFIG_ENTRY: config_entry.entry_id, + ATTR_TASK: task_id, + }, + return_response=True, + blocking=True, + ) + + +@pytest.mark.usefixtures("habitica") +async def test_task_not_found( + hass: HomeAssistant, + config_entry: MockConfigEntry, + habitica: AsyncMock, +) -> None: + """Test Habitica task not found exceptions.""" + task_id = "7f902bbc-eb3d-4a8f-82cf-4e2025d69af1" + + with pytest.raises( + ServiceValidationError, + match="Unable to complete action, could not find the task '7f902bbc-eb3d-4a8f-82cf-4e2025d69af1'", + ): + await hass.services.async_call( + DOMAIN, + SERVICE_UPDATE_REWARD, + service_data={ + ATTR_CONFIG_ENTRY: config_entry.entry_id, + ATTR_TASK: task_id, + }, + return_response=True, + blocking=True, + ) + + +@pytest.mark.parametrize( + ("service_data", "call_args"), + [ + ( + { + ATTR_COST: 100, + }, + Task(value=100), + ), + ( + { + ATTR_RENAME: "RENAME", + }, + Task(text="RENAME"), + ), + ( + { + ATTR_DESCRIPTION: "DESCRIPTION", + }, + Task(notes="DESCRIPTION"), + ), + ( + { + ATTR_ALIAS: "ALIAS", + }, + Task(alias="ALIAS"), + ), + ], +) +async def test_update_reward( + hass: HomeAssistant, + config_entry: MockConfigEntry, + habitica: AsyncMock, + service_data: dict[str, Any], + call_args: Task, +) -> None: + """Test Habitica update_reward action.""" + task_id = "5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b" + + habitica.update_task.return_value = HabiticaTaskResponse.from_json( + load_fixture("task.json", DOMAIN) + ) + await hass.services.async_call( + DOMAIN, + SERVICE_UPDATE_REWARD, + service_data={ + ATTR_CONFIG_ENTRY: config_entry.entry_id, + ATTR_TASK: task_id, + **service_data, + }, + return_response=True, + blocking=True, + ) + habitica.update_task.assert_awaited_with(UUID(task_id), call_args) + + +async def test_tags( + hass: HomeAssistant, + config_entry: MockConfigEntry, + habitica: AsyncMock, +) -> None: + """Test adding tags to a task.""" + task_id = "5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b" + + await hass.services.async_call( + DOMAIN, + SERVICE_UPDATE_REWARD, + service_data={ + ATTR_CONFIG_ENTRY: config_entry.entry_id, + ATTR_TASK: task_id, + ATTR_TAG: ["Schule"], + }, + return_response=True, + blocking=True, + ) + + call_args = habitica.update_task.call_args[0] + assert call_args[0] == UUID(task_id) + assert set(call_args[1]["tags"]) == { + UUID("2ac458af-0833-4f3f-bf04-98a0c33ef60b"), + UUID("3450351f-1323-4c7e-9fd2-0cdff25b3ce0"), + UUID("b2780f82-b3b5-49a3-a677-48f2c8c7e3bb"), + } + + +async def test_create_new_tag( + hass: HomeAssistant, + config_entry: MockConfigEntry, + habitica: AsyncMock, +) -> None: + """Test adding a non-existent tag and create it as new.""" + task_id = "5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b" + + await hass.services.async_call( + DOMAIN, + SERVICE_UPDATE_REWARD, + service_data={ + ATTR_CONFIG_ENTRY: config_entry.entry_id, + ATTR_TASK: task_id, + ATTR_TAG: ["Home Assistant"], + }, + return_response=True, + blocking=True, + ) + + habitica.create_tag.assert_awaited_with("Home Assistant") + + call_args = habitica.update_task.call_args[0] + assert call_args[0] == UUID(task_id) + assert set(call_args[1]["tags"]) == { + UUID("8bc0afbf-ab8e-49a4-982d-67a40557ed1a"), + UUID("3450351f-1323-4c7e-9fd2-0cdff25b3ce0"), + UUID("b2780f82-b3b5-49a3-a677-48f2c8c7e3bb"), + } + + +@pytest.mark.parametrize( + ("exception", "expected_exception", "exception_msg"), + [ + ( + ERROR_TOO_MANY_REQUESTS, + HomeAssistantError, + RATE_LIMIT_EXCEPTION_MSG, + ), + ( + ERROR_BAD_REQUEST, + HomeAssistantError, + REQUEST_EXCEPTION_MSG, + ), + ( + ClientError, + HomeAssistantError, + "Unable to connect to Habitica: ", + ), + ], +) +async def test_create_new_tag_exception( + hass: HomeAssistant, + config_entry: MockConfigEntry, + habitica: AsyncMock, + exception: Exception, + expected_exception: Exception, + exception_msg: str, +) -> None: + """Test create new tag exception.""" + task_id = "5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b" + + habitica.create_tag.side_effect = exception + with pytest.raises(expected_exception, match=exception_msg): + await hass.services.async_call( + DOMAIN, + SERVICE_UPDATE_REWARD, + service_data={ + ATTR_CONFIG_ENTRY: config_entry.entry_id, + ATTR_TASK: task_id, + ATTR_TAG: ["Home Assistant"], + }, + return_response=True, + blocking=True, + ) + + +async def test_remove_tags( + hass: HomeAssistant, + config_entry: MockConfigEntry, + habitica: AsyncMock, +) -> None: + """Test removing tags from a task.""" + task_id = "5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b" + + await hass.services.async_call( + DOMAIN, + SERVICE_UPDATE_REWARD, + service_data={ + ATTR_CONFIG_ENTRY: config_entry.entry_id, + ATTR_TASK: task_id, + ATTR_REMOVE_TAG: ["Kreativität"], + }, + return_response=True, + blocking=True, + ) + + call_args = habitica.update_task.call_args[0] + assert call_args[0] == UUID(task_id) + assert set(call_args[1]["tags"]) == {UUID("b2780f82-b3b5-49a3-a677-48f2c8c7e3bb")} From befed910da93b30b130f780dd76a74ac40da757d Mon Sep 17 00:00:00 2001 From: cdnninja Date: Tue, 25 Feb 2025 05:48:31 -0700 Subject: [PATCH 1767/3148] Add Re-Auth Flow to vesync (#137398) Co-authored-by: Joost Lekkerkerker --- homeassistant/components/vesync/__init__.py | 4 +- .../components/vesync/config_flow.py | 34 +++++++++++ homeassistant/components/vesync/strings.json | 11 +++- tests/components/vesync/test_config_flow.py | 56 +++++++++++++++++++ tests/components/vesync/test_init.py | 19 ++----- 5 files changed, 107 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/vesync/__init__.py b/homeassistant/components/vesync/__init__.py index 01f88c64bf4..dddf7857545 100644 --- a/homeassistant/components/vesync/__init__.py +++ b/homeassistant/components/vesync/__init__.py @@ -12,6 +12,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import Event, HomeAssistant, ServiceCall, callback +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers import entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_send @@ -59,8 +60,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b login = await hass.async_add_executor_job(manager.login) if not login: - _LOGGER.error("Unable to login to the VeSync server") - return False + raise ConfigEntryAuthFailed hass.data[DOMAIN] = {} hass.data[DOMAIN][VS_MANAGER] = manager diff --git a/homeassistant/components/vesync/config_flow.py b/homeassistant/components/vesync/config_flow.py index 07543440e91..e5537d8fcc9 100644 --- a/homeassistant/components/vesync/config_flow.py +++ b/homeassistant/components/vesync/config_flow.py @@ -1,5 +1,6 @@ """Config flow utilities.""" +from collections.abc import Mapping from typing import Any from pyvesync import VeSync @@ -57,3 +58,36 @@ class VeSyncFlowHandler(ConfigFlow, domain=DOMAIN): title=username, data={CONF_USERNAME: username, CONF_PASSWORD: password}, ) + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Handle re-authentication with vesync.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm re-authentication with vesync.""" + + if user_input: + username = user_input[CONF_USERNAME] + password = user_input[CONF_PASSWORD] + + manager = VeSync(username, password) + login = await self.hass.async_add_executor_job(manager.login) + if login: + return self.async_update_reload_and_abort( + self._get_reauth_entry(), + data_updates={ + CONF_USERNAME: username, + CONF_PASSWORD: password, + }, + ) + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=DATA_SCHEMA, + description_placeholders={"name": "VeSync"}, + errors={"base": "invalid_auth"}, + ) diff --git a/homeassistant/components/vesync/strings.json b/homeassistant/components/vesync/strings.json index 2232b16329b..89f401da92f 100644 --- a/homeassistant/components/vesync/strings.json +++ b/homeassistant/components/vesync/strings.json @@ -7,13 +7,22 @@ "username": "[%key:common::config_flow::data::email%]", "password": "[%key:common::config_flow::data::password%]" } + }, + "reauth_confirm": { + "title": "[%key:common::config_flow::title::reauth%]", + "description": "The vesync integration needs to re-authenticate your account", + "data": { + "username": "[%key:common::config_flow::data::email%]", + "password": "[%key:common::config_flow::data::password%]" + } } }, "error": { "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" }, "abort": { - "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" + "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } }, "entity": { diff --git a/tests/components/vesync/test_config_flow.py b/tests/components/vesync/test_config_flow.py index 22a93e1ba56..38f28e73aed 100644 --- a/tests/components/vesync/test_config_flow.py +++ b/tests/components/vesync/test_config_flow.py @@ -48,3 +48,59 @@ async def test_config_flow_user_input(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.CREATE_ENTRY assert result["data"][CONF_USERNAME] == "user" assert result["data"][CONF_PASSWORD] == "pass" + + +async def test_reauth_flow(hass: HomeAssistant) -> None: + """Test a successful reauth flow.""" + mock_entry = MockConfigEntry( + domain=DOMAIN, + unique_id="test-username", + ) + mock_entry.add_to_hass(hass) + + result = await mock_entry.start_reauth_flow(hass) + + assert result["step_id"] == "reauth_confirm" + assert result["type"] is FlowResultType.FORM + with patch("pyvesync.vesync.VeSync.login", return_value=True): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_USERNAME: "new-username", CONF_PASSWORD: "new-password"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert mock_entry.data == { + CONF_USERNAME: "new-username", + CONF_PASSWORD: "new-password", + } + + +async def test_reauth_flow_invalid_auth(hass: HomeAssistant) -> None: + """Test an authorization error reauth flow.""" + + mock_entry = MockConfigEntry( + domain=DOMAIN, + unique_id="test-username", + ) + mock_entry.add_to_hass(hass) + + result = await mock_entry.start_reauth_flow(hass) + assert result["step_id"] == "reauth_confirm" + assert result["type"] is FlowResultType.FORM + + with patch("pyvesync.vesync.VeSync.login", return_value=False): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_USERNAME: "new-username", CONF_PASSWORD: "new-password"}, + ) + + assert result["type"] is FlowResultType.FORM + with patch("pyvesync.vesync.VeSync.login", return_value=True): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_USERNAME: "new-username", CONF_PASSWORD: "new-password"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" diff --git a/tests/components/vesync/test_init.py b/tests/components/vesync/test_init.py index 011545af2ae..31df2418b3d 100644 --- a/tests/components/vesync/test_init.py +++ b/tests/components/vesync/test_init.py @@ -2,7 +2,6 @@ from unittest.mock import Mock, patch -import pytest from pyvesync import VeSync from homeassistant.components.vesync import SERVICE_UPDATE_DEVS, async_setup_entry @@ -19,25 +18,17 @@ async def test_async_setup_entry__not_login( hass: HomeAssistant, config_entry: ConfigEntry, manager: VeSync, - caplog: pytest.LogCaptureFixture, ) -> None: """Test setup does not create config entry when not logged in.""" manager.login = Mock(return_value=False) - with ( - patch.object(hass.config_entries, "async_forward_entry_setups") as setups_mock, - patch( - "homeassistant.components.vesync.async_generate_device_list" - ) as process_mock, - ): - assert not await async_setup_entry(hass, config_entry) - await hass.async_block_till_done() - assert setups_mock.call_count == 0 - assert process_mock.call_count == 0 + assert not await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() assert manager.login.call_count == 1 - assert DOMAIN not in hass.data - assert "Unable to login to the VeSync server" in caplog.text + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert config_entry.state is ConfigEntryState.SETUP_ERROR + assert not hass.data.get(DOMAIN) async def test_async_setup_entry__no_devices( From d7301c62e2b51dd1911dee7139aa9fced8f3cb10 Mon Sep 17 00:00:00 2001 From: Maikel Punie Date: Tue, 25 Feb 2025 14:02:10 +0100 Subject: [PATCH 1768/3148] Rework the velbus configflow to make it more user-friendly (#135609) --- homeassistant/components/velbus/__init__.py | 38 +++- .../components/velbus/config_flow.py | 110 +++++++--- homeassistant/components/velbus/const.py | 1 + .../components/velbus/quality_scale.yaml | 5 +- homeassistant/components/velbus/strings.json | 26 +++ .../velbus/snapshots/test_diagnostics.ambr | 2 +- tests/components/velbus/test_config_flow.py | 203 +++++++++++------- tests/components/velbus/test_init.py | 32 ++- 8 files changed, 297 insertions(+), 120 deletions(-) diff --git a/homeassistant/components/velbus/__init__.py b/homeassistant/components/velbus/__init__.py index 41b8730eeb0..35c61892964 100644 --- a/homeassistant/components/velbus/__init__.py +++ b/homeassistant/components/velbus/__init__.py @@ -135,15 +135,39 @@ async def async_migrate_entry( hass: HomeAssistant, config_entry: VelbusConfigEntry ) -> bool: """Migrate old entry.""" - _LOGGER.debug("Migrating from version %s", config_entry.version) - cache_path = hass.config.path(STORAGE_DIR, f"velbuscache-{config_entry.entry_id}/") - if config_entry.version == 1: - # This is the config entry migration for adding the new program selection + _LOGGER.error( + "Migrating from version %s.%s", config_entry.version, config_entry.minor_version + ) + + # This is the config entry migration for adding the new program selection + # migrate from 1.x to 2.1 + if config_entry.version < 2: # clean the velbusCache + cache_path = hass.config.path( + STORAGE_DIR, f"velbuscache-{config_entry.entry_id}/" + ) if os.path.isdir(cache_path): await hass.async_add_executor_job(shutil.rmtree, cache_path) - # set the new version - hass.config_entries.async_update_entry(config_entry, version=2) - _LOGGER.debug("Migration to version %s successful", config_entry.version) + # This is the config entry migration for swapping the usb unique id to the serial number + # migrate from 2.1 to 2.2 + if ( + config_entry.version < 3 + and config_entry.minor_version == 1 + and config_entry.unique_id is not None + ): + # not all velbus devices have a unique id, so handle this correctly + parts = config_entry.unique_id.split("_") + # old one should have 4 item + if len(parts) == 4: + hass.config_entries.async_update_entry(config_entry, unique_id=parts[1]) + + # update the config entry + hass.config_entries.async_update_entry(config_entry, version=2, minor_version=2) + + _LOGGER.error( + "Migration to version %s.%s successful", + config_entry.version, + config_entry.minor_version, + ) return True diff --git a/homeassistant/components/velbus/config_flow.py b/homeassistant/components/velbus/config_flow.py index 9e99b2631d4..fc5da92588a 100644 --- a/homeassistant/components/velbus/config_flow.py +++ b/homeassistant/components/velbus/config_flow.py @@ -4,22 +4,23 @@ from __future__ import annotations from typing import Any +import serial.tools.list_ports import velbusaio.controller from velbusaio.exceptions import VelbusConnectionFailed import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_NAME, CONF_PORT +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_PORT from homeassistant.helpers.service_info.usb import UsbServiceInfo -from homeassistant.util import slugify -from .const import DOMAIN +from .const import CONF_TLS, DOMAIN class VelbusConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow.""" VERSION = 2 + MINOR_VERSION = 2 def __init__(self) -> None: """Initialize the velbus config flow.""" @@ -27,14 +28,16 @@ class VelbusConfigFlow(ConfigFlow, domain=DOMAIN): self._device: str = "" self._title: str = "" - def _create_device(self, name: str, prt: str) -> ConfigFlowResult: + def _create_device(self) -> ConfigFlowResult: """Create an entry async.""" - return self.async_create_entry(title=name, data={CONF_PORT: prt}) + return self.async_create_entry( + title=self._title, data={CONF_PORT: self._device} + ) - async def _test_connection(self, prt: str) -> bool: + async def _test_connection(self) -> bool: """Try to connect to the velbus with the port specified.""" try: - controller = velbusaio.controller.Velbus(prt) + controller = velbusaio.controller.Velbus(self._device) await controller.connect() await controller.stop() except VelbusConnectionFailed: @@ -46,43 +49,86 @@ class VelbusConfigFlow(ConfigFlow, domain=DOMAIN): self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Step when user initializes a integration.""" - self._errors = {} + return self.async_show_menu( + step_id="user", menu_options=["network", "usbselect"] + ) + + async def async_step_network( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle network step.""" if user_input is not None: - name = slugify(user_input[CONF_NAME]) - prt = user_input[CONF_PORT] - self._async_abort_entries_match({CONF_PORT: prt}) - if await self._test_connection(prt): - return self._create_device(name, prt) + self._title = "Velbus Network" + if user_input[CONF_TLS]: + self._device = "tls://" + else: + self._device = "" + if user_input[CONF_PASSWORD] != "": + self._device += f"{user_input[CONF_PASSWORD]}@" + self._device += f"{user_input[CONF_HOST]}:{user_input[CONF_PORT]}" + self._async_abort_entries_match({CONF_PORT: self._device}) + if await self._test_connection(): + return self._create_device() + else: + user_input = { + CONF_TLS: True, + CONF_PORT: 27015, + } + + return self.async_show_form( + step_id="network", + data_schema=self.add_suggested_values_to_schema( + vol.Schema( + { + vol.Required(CONF_TLS): bool, + vol.Required(CONF_HOST): str, + vol.Required(CONF_PORT): int, + vol.Optional(CONF_PASSWORD): str, + } + ), + suggested_values=user_input, + ), + errors=self._errors, + ) + + async def async_step_usbselect( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle usb select step.""" + ports = await self.hass.async_add_executor_job(serial.tools.list_ports.comports) + list_of_ports = [ + f"{p}{', s/n: ' + p.serial_number if p.serial_number else ''}" + + (f" - {p.manufacturer}" if p.manufacturer else "") + for p in ports + ] + + if user_input is not None: + self._title = "Velbus USB" + self._device = ports[list_of_ports.index(user_input[CONF_PORT])].device + self._async_abort_entries_match({CONF_PORT: self._device}) + if await self._test_connection(): + return self._create_device() else: user_input = {} - user_input[CONF_NAME] = "" user_input[CONF_PORT] = "" return self.async_show_form( - step_id="user", - data_schema=vol.Schema( - { - vol.Required(CONF_NAME, default=user_input[CONF_NAME]): str, - vol.Required(CONF_PORT, default=user_input[CONF_PORT]): str, - } + step_id="usbselect", + data_schema=self.add_suggested_values_to_schema( + vol.Schema({vol.Required(CONF_PORT): vol.In(list_of_ports)}), + suggested_values=user_input, ), errors=self._errors, ) async def async_step_usb(self, discovery_info: UsbServiceInfo) -> ConfigFlowResult: """Handle USB Discovery.""" - await self.async_set_unique_id( - f"{discovery_info.vid}:{discovery_info.pid}_{discovery_info.serial_number}_{discovery_info.manufacturer}_{discovery_info.description}" - ) - dev_path = discovery_info.device - # check if this device is not already configured - self._async_abort_entries_match({CONF_PORT: dev_path}) - # check if we can make a valid velbus connection - if not await self._test_connection(dev_path): - return self.async_abort(reason="cannot_connect") - # store the data for the config step - self._device = dev_path + await self.async_set_unique_id(discovery_info.serial_number) + self._device = discovery_info.device self._title = "Velbus USB" + self._async_abort_entries_match({CONF_PORT: self._device}) + if not await self._test_connection(): + return self.async_abort(reason="cannot_connect") # call the config step self._set_confirm_only() return await self.async_step_discovery_confirm() @@ -92,7 +138,7 @@ class VelbusConfigFlow(ConfigFlow, domain=DOMAIN): ) -> ConfigFlowResult: """Handle Discovery confirmation.""" if user_input is not None: - return self._create_device(self._title, self._device) + return self._create_device() return self.async_show_form( step_id="discovery_confirm", diff --git a/homeassistant/components/velbus/const.py b/homeassistant/components/velbus/const.py index b40f64e8607..f42e449bdcc 100644 --- a/homeassistant/components/velbus/const.py +++ b/homeassistant/components/velbus/const.py @@ -14,6 +14,7 @@ DOMAIN: Final = "velbus" CONF_CONFIG_ENTRY: Final = "config_entry" CONF_INTERFACE: Final = "interface" CONF_MEMO_TEXT: Final = "memo_text" +CONF_TLS: Final = "tls" SERVICE_SCAN: Final = "scan" SERVICE_SYNC: Final = "sync_clock" diff --git a/homeassistant/components/velbus/quality_scale.yaml b/homeassistant/components/velbus/quality_scale.yaml index 0ad3e3ce485..829f48e6f52 100644 --- a/homeassistant/components/velbus/quality_scale.yaml +++ b/homeassistant/components/velbus/quality_scale.yaml @@ -8,10 +8,7 @@ rules: brands: done common-modules: done config-flow-test-coverage: done - config-flow: - status: todo - comment: | - Dynamically build up the port parameter based on inputs provided by the user, do not fill-in a name parameter, build it up in the config flow + config-flow: done dependency-transparency: done docs-actions: done docs-high-level-description: done diff --git a/homeassistant/components/velbus/strings.json b/homeassistant/components/velbus/strings.json index 69fc3d661e9..895f883678d 100644 --- a/homeassistant/components/velbus/strings.json +++ b/homeassistant/components/velbus/strings.json @@ -7,6 +7,32 @@ "name": "The name for this Velbus connection", "port": "Connection string" } + }, + "network": { + "title": "TCP/IP configuration", + "data": { + "tls": "Use TLS (secure connection)", + "host": "[%key:common::config_flow::data::host%]", + "port": "[%key:common::config_flow::data::port%]", + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "tls": "Enable this if you use a secure connection to your velbus interface, like a Signum.", + "host": "The IP address or hostname of the velbus interface.", + "port": "The port number of the velbus interface.", + "password": "The password of the velbus interface, this is only needed if the interface is password protected." + }, + "description": "TCP/IP configuration, in case you use a Signum, velserv, velbus-tcp or any other velbus to TCP/IP interface." + }, + "usbselect": { + "title": "USB configuration", + "data": { + "port": "[%key:common::config_flow::data::port%]" + }, + "data_description": { + "port": "Select the serial port for your velbus USB interface." + }, + "description": "Select the serial port for your velbus USB interface." } }, "error": { diff --git a/tests/components/velbus/snapshots/test_diagnostics.ambr b/tests/components/velbus/snapshots/test_diagnostics.ambr index c8bff1841e8..a280bf4c9c2 100644 --- a/tests/components/velbus/snapshots/test_diagnostics.ambr +++ b/tests/components/velbus/snapshots/test_diagnostics.ambr @@ -10,7 +10,7 @@ 'discovery_keys': dict({ }), 'domain': 'velbus', - 'minor_version': 1, + 'minor_version': 2, 'options': dict({ }), 'pref_disable_new_entities': False, diff --git a/tests/components/velbus/test_config_flow.py b/tests/components/velbus/test_config_flow.py index 04b6a51043f..ee714624b45 100644 --- a/tests/components/velbus/test_config_flow.py +++ b/tests/components/velbus/test_config_flow.py @@ -7,14 +7,14 @@ import pytest import serial.tools.list_ports from velbusaio.exceptions import VelbusConnectionFailed -from homeassistant.components.velbus.const import DOMAIN +from homeassistant.components.velbus.const import CONF_TLS, DOMAIN from homeassistant.config_entries import SOURCE_USB, SOURCE_USER -from homeassistant.const import CONF_NAME, CONF_PORT, CONF_SOURCE +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_SOURCE from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers.service_info.usb import UsbServiceInfo -from .const import PORT_SERIAL, PORT_TCP +from .const import PORT_SERIAL from tests.common import MockConfigEntry @@ -27,6 +27,8 @@ DISCOVERY_INFO = UsbServiceInfo( manufacturer="Velleman", ) +USB_DEV = "/dev/ttyACME100 - Some serial port, s/n: 1234 - Virtual serial port" + def com_port(): """Mock of a serial port.""" @@ -38,23 +40,15 @@ def com_port(): return port -@pytest.fixture(name="controller") -def mock_controller() -> Generator[MagicMock]: - """Mock a successful velbus controller.""" - with patch( - "homeassistant.components.velbus.config_flow.velbusaio.controller.Velbus", - autospec=True, - ) as controller: - yield controller - - @pytest.fixture(autouse=True) def override_async_setup_entry() -> Generator[AsyncMock]: """Override async_setup_entry.""" - with patch( - "homeassistant.components.velbus.async_setup_entry", return_value=True - ) as mock_setup_entry: - yield mock_setup_entry + with ( + patch( + "homeassistant.components.velbus.async_setup_entry", return_value=True + ) as mock, + ): + yield mock @pytest.fixture(name="controller_connection_failed") @@ -65,73 +59,126 @@ def mock_controller_connection_failed(): @pytest.mark.usefixtures("controller") -async def test_user(hass: HomeAssistant) -> None: - """Test user config.""" - # simple user form +async def test_user_network_succes(hass: HomeAssistant) -> None: + """Test user network config.""" + # inttial menu show result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) assert result assert result.get("flow_id") - assert result.get("type") is FlowResultType.FORM + assert result.get("type") is FlowResultType.MENU assert result.get("step_id") == "user" - - # try with a serial port - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={CONF_NAME: "Velbus Test Serial", CONF_PORT: PORT_SERIAL}, + assert result.get("menu_options") == ["network", "usbselect"] + # select the network option + result = await hass.config_entries.flow.async_configure( + result.get("flow_id"), + {"next_step_id": "network"}, + ) + assert result.get("type") is FlowResultType.FORM + # fill in the network form + result = await hass.config_entries.flow.async_configure( + result.get("flow_id"), + { + CONF_TLS: False, + CONF_HOST: "velbus", + CONF_PORT: 6000, + CONF_PASSWORD: "", + }, ) assert result assert result.get("type") is FlowResultType.CREATE_ENTRY - assert result.get("title") == "velbus_test_serial" + assert result.get("title") == "Velbus Network" + data = result.get("data") + assert data + assert data[CONF_PORT] == "velbus:6000" + + +@pytest.mark.usefixtures("controller") +async def test_user_network_succes_tls(hass: HomeAssistant) -> None: + """Test user network config.""" + # inttial menu show + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result + assert result.get("flow_id") + assert result.get("type") is FlowResultType.MENU + assert result.get("step_id") == "user" + assert result.get("menu_options") == ["network", "usbselect"] + # select the network option + result = await hass.config_entries.flow.async_configure( + result.get("flow_id"), + {"next_step_id": "network"}, + ) + assert result["type"] is FlowResultType.FORM + # fill in the network form + result = await hass.config_entries.flow.async_configure( + result.get("flow_id"), + { + CONF_TLS: True, + CONF_HOST: "velbus", + CONF_PORT: 6000, + CONF_PASSWORD: "password", + }, + ) + assert result + assert result.get("type") is FlowResultType.CREATE_ENTRY + assert result.get("title") == "Velbus Network" + data = result.get("data") + assert data + assert data[CONF_PORT] == "tls://password@velbus:6000" + + +@pytest.mark.usefixtures("controller") +@patch("serial.tools.list_ports.comports", MagicMock(return_value=[com_port()])) +async def test_user_usb_succes(hass: HomeAssistant) -> None: + """Test user usb step.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result.get("flow_id"), + {"next_step_id": "usbselect"}, + ) + assert result["type"] is FlowResultType.FORM + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_PORT: USB_DEV, + }, + ) + assert result + assert result.get("type") is FlowResultType.CREATE_ENTRY + assert result.get("title") == "Velbus USB" data = result.get("data") assert data assert data[CONF_PORT] == PORT_SERIAL - # try with a ip:port combination - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={CONF_NAME: "Velbus Test TCP", CONF_PORT: PORT_TCP}, - ) - assert result - assert result.get("type") is FlowResultType.CREATE_ENTRY - assert result.get("title") == "velbus_test_tcp" - data = result.get("data") - assert data - assert data[CONF_PORT] == PORT_TCP - -@pytest.mark.usefixtures("controller_connection_failed") -async def test_user_fail(hass: HomeAssistant) -> None: - """Test user config.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={CONF_NAME: "Velbus Test Serial", CONF_PORT: PORT_SERIAL}, - ) - assert result - assert result.get("type") is FlowResultType.FORM - assert result.get("errors") == {CONF_PORT: "cannot_connect"} - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={CONF_NAME: "Velbus Test TCP", CONF_PORT: PORT_TCP}, - ) - assert result - assert result.get("type") is FlowResultType.FORM - assert result.get("errors") == {CONF_PORT: "cannot_connect"} - - -@pytest.mark.usefixtures("config_entry") -async def test_abort_if_already_setup(hass: HomeAssistant) -> None: +@pytest.mark.usefixtures("controller") +async def test_network_abort_if_already_setup(hass: HomeAssistant) -> None: """Test we abort if Velbus is already setup.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_PORT: "127.0.0.1:3788"}, + ) + entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={CONF_PORT: PORT_TCP, CONF_NAME: "velbus test"}, + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result.get("flow_id"), + {"next_step_id": "network"}, + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_TLS: False, + CONF_HOST: "127.0.0.1", + CONF_PORT: 3788, + CONF_PASSWORD: "", + }, ) assert result assert result.get("type") is FlowResultType.ABORT @@ -156,7 +203,7 @@ async def test_flow_usb(hass: HomeAssistant) -> None: user_input={}, ) assert result - assert result["result"].unique_id == "0B1B:10CF_1234_Velleman_Velbus VMB1USB" + assert result["result"].unique_id == "1234" assert result.get("type") is FlowResultType.CREATE_ENTRY @@ -167,13 +214,23 @@ async def test_flow_usb_if_already_setup(hass: HomeAssistant) -> None: entry = MockConfigEntry( domain=DOMAIN, data={CONF_PORT: PORT_SERIAL}, - unique_id="0B1B:10CF_1234_Velleman_Velbus VMB1USB", ) entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( - DOMAIN, - context={CONF_SOURCE: SOURCE_USB}, - data=DISCOVERY_INFO, + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result.get("flow_id"), + {"next_step_id": "usbselect"}, + ) + assert result + assert result["type"] is FlowResultType.FORM + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_PORT: USB_DEV, + }, ) assert result assert result.get("type") is FlowResultType.ABORT diff --git a/tests/components/velbus/test_init.py b/tests/components/velbus/test_init.py index 3285099f2a2..2d28ba81cb1 100644 --- a/tests/components/velbus/test_init.py +++ b/tests/components/velbus/test_init.py @@ -16,6 +16,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er from . import init_integration +from .const import PORT_TCP from tests.common import MockConfigEntry @@ -107,16 +108,41 @@ async def test_migrate_config_entry( """Test successful migration of entry data.""" legacy_config = {CONF_NAME: "fake_name", CONF_PORT: "1.2.3.4:5678"} entry = MockConfigEntry(domain=DOMAIN, unique_id="my own id", data=legacy_config) - entry.add_to_hass(hass) - - assert dict(entry.data) == legacy_config assert entry.version == 1 + assert entry.minor_version == 1 + + entry.add_to_hass(hass) # test in case we do not have a cache with patch("os.path.isdir", return_value=True), patch("shutil.rmtree"): await hass.config_entries.async_setup(entry.entry_id) assert dict(entry.data) == legacy_config assert entry.version == 2 + assert entry.minor_version == 2 + + +@pytest.mark.parametrize( + ("unique_id", "expected"), + [("vid:pid_serial_manufacturer_decription", "serial"), (None, None)], +) +async def test_migrate_config_entry_unique_id( + hass: HomeAssistant, + controller: AsyncMock, + unique_id: str, + expected: str, +) -> None: + """Test the migration of unique id.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_PORT: PORT_TCP, CONF_NAME: "velbus home"}, + unique_id=unique_id, + ) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + assert entry.unique_id == expected + assert entry.version == 2 + assert entry.minor_version == 2 async def test_api_call( From 507c0739df39529a4d77a9a44b315e084eba17c8 Mon Sep 17 00:00:00 2001 From: LG-ThinQ-Integration Date: Tue, 25 Feb 2025 22:14:04 +0900 Subject: [PATCH 1769/3148] Add missing ATTR_HVAC_MODE of async_set_temperature to LG ThinQ (#137621) Co-authored-by: yunseon.park --- homeassistant/components/lg_thinq/climate.py | 56 ++++++-------------- 1 file changed, 17 insertions(+), 39 deletions(-) diff --git a/homeassistant/components/lg_thinq/climate.py b/homeassistant/components/lg_thinq/climate.py index 063705f5d0d..73678e209f7 100644 --- a/homeassistant/components/lg_thinq/climate.py +++ b/homeassistant/components/lg_thinq/climate.py @@ -2,7 +2,6 @@ from __future__ import annotations -from dataclasses import dataclass import logging from typing import Any @@ -10,6 +9,7 @@ from thinqconnect import DeviceType from thinqconnect.integration import ExtendedProperty from homeassistant.components.climate import ( + ATTR_HVAC_MODE, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, SWING_OFF, @@ -28,31 +28,19 @@ from . import ThinqConfigEntry from .coordinator import DeviceDataUpdateCoordinator from .entity import ThinQEntity - -@dataclass(frozen=True, kw_only=True) -class ThinQClimateEntityDescription(ClimateEntityDescription): - """Describes ThinQ climate entity.""" - - min_temp: float | None = None - max_temp: float | None = None - step: float | None = None - - -DEVICE_TYPE_CLIMATE_MAP: dict[DeviceType, tuple[ThinQClimateEntityDescription, ...]] = { +DEVICE_TYPE_CLIMATE_MAP: dict[DeviceType, tuple[ClimateEntityDescription, ...]] = { DeviceType.AIR_CONDITIONER: ( - ThinQClimateEntityDescription( + ClimateEntityDescription( key=ExtendedProperty.CLIMATE_AIR_CONDITIONER, name=None, translation_key=ExtendedProperty.CLIMATE_AIR_CONDITIONER, ), ), DeviceType.SYSTEM_BOILER: ( - ThinQClimateEntityDescription( + ClimateEntityDescription( key=ExtendedProperty.CLIMATE_SYSTEM_BOILER, name=None, - min_temp=16, - max_temp=30, - step=1, + translation_key=ExtendedProperty.CLIMATE_SYSTEM_BOILER, ), ), } @@ -65,13 +53,7 @@ STR_TO_HVAC: dict[str, HVACMode] = { "heat": HVACMode.HEAT, } -HVAC_TO_STR: dict[HVACMode, str] = { - HVACMode.AUTO: "auto", - HVACMode.COOL: "cool", - HVACMode.DRY: "air_dry", - HVACMode.FAN_ONLY: "fan", - HVACMode.HEAT: "heat", -} +HVAC_TO_STR = {v: k for k, v in STR_TO_HVAC.items()} THINQ_PRESET_MODE: list[str] = ["air_clean", "aroma", "energy_saving"] @@ -111,12 +93,10 @@ async def async_setup_entry( class ThinQClimateEntity(ThinQEntity, ClimateEntity): """Represent a thinq climate platform.""" - entity_description: ThinQClimateEntityDescription - def __init__( self, coordinator: DeviceDataUpdateCoordinator, - entity_description: ThinQClimateEntityDescription, + entity_description: ClimateEntityDescription, property_id: str, ) -> None: """Initialize a climate entity.""" @@ -190,18 +170,12 @@ class ThinQClimateEntity(ThinQEntity, ClimateEntity): self._attr_current_temperature = self.data.current_temp # Update min, max and step. - if (max_temp := self.entity_description.max_temp) is not None or ( - max_temp := self.data.max - ) is not None: - self._attr_max_temp = max_temp - if (min_temp := self.entity_description.min_temp) is not None or ( - min_temp := self.data.min - ) is not None: - self._attr_min_temp = min_temp - if (step := self.entity_description.step) is not None or ( - step := self.data.step - ) is not None: - self._attr_target_temperature_step = step + if self.data.max is not None: + self._attr_max_temp = self.data.max + if self.data.min is not None: + self._attr_min_temp = self.data.min + + self._attr_target_temperature_step = self.data.step # Update target temperatures. self._attr_target_temperature = self.data.target_temp @@ -342,6 +316,10 @@ class ThinQClimateEntity(ThinQEntity, ClimateEntity): self.property_id, kwargs, ) + if hvac_mode := kwargs.get(ATTR_HVAC_MODE): + await self.async_set_hvac_mode(HVACMode(hvac_mode)) + if hvac_mode == HVACMode.OFF: + return if (temperature := kwargs.get(ATTR_TEMPERATURE)) is not None: if ( From d45fce86a9d9623e1fad38fdd0f941f1f1601607 Mon Sep 17 00:00:00 2001 From: Dan Bishop Date: Tue, 25 Feb 2025 13:18:12 +0000 Subject: [PATCH 1770/3148] Make Radarr units translatable (#139250) Co-authored-by: Joost Lekkerkerker --- homeassistant/components/radarr/sensor.py | 2 -- homeassistant/components/radarr/strings.json | 6 ++++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/radarr/sensor.py b/homeassistant/components/radarr/sensor.py index fa0cb95d549..a6d29ee9d1d 100644 --- a/homeassistant/components/radarr/sensor.py +++ b/homeassistant/components/radarr/sensor.py @@ -81,14 +81,12 @@ SENSOR_TYPES: dict[str, RadarrSensorEntityDescription[Any]] = { "movie": RadarrSensorEntityDescription[int]( key="movies", translation_key="movies", - native_unit_of_measurement="Movies", entity_registry_enabled_default=False, value_fn=lambda data, _: data, ), "queue": RadarrSensorEntityDescription[int]( key="queue", translation_key="queue", - native_unit_of_measurement="Movies", entity_registry_enabled_default=False, state_class=SensorStateClass.TOTAL, value_fn=lambda data, _: data, diff --git a/homeassistant/components/radarr/strings.json b/homeassistant/components/radarr/strings.json index ec1baf6ffd8..cb624aff057 100644 --- a/homeassistant/components/radarr/strings.json +++ b/homeassistant/components/radarr/strings.json @@ -43,10 +43,12 @@ }, "sensor": { "movies": { - "name": "Movies" + "name": "Movies", + "unit_of_measurement": "[%key:component::radarr::entity::sensor::movies::name%]" }, "queue": { - "name": "Queue" + "name": "Queue", + "unit_of_measurement": "[%key:component::radarr::entity::sensor::movies::name%]" }, "start_time": { "name": "Start time" From 664e09790c7354be80710aa9b56716782168e7c3 Mon Sep 17 00:00:00 2001 From: elmurato <1382097+elmurato@users.noreply.github.com> Date: Tue, 25 Feb 2025 14:22:30 +0100 Subject: [PATCH 1771/3148] Improve Minecraft Server config flow tests (#139251) --- .../minecraft_server/quality_scale.yaml | 7 +- .../minecraft_server/test_config_flow.py | 202 ++++++++++-------- 2 files changed, 108 insertions(+), 101 deletions(-) diff --git a/homeassistant/components/minecraft_server/quality_scale.yaml b/homeassistant/components/minecraft_server/quality_scale.yaml index a866969fc33..6cf1fc7772e 100644 --- a/homeassistant/components/minecraft_server/quality_scale.yaml +++ b/homeassistant/components/minecraft_server/quality_scale.yaml @@ -7,12 +7,7 @@ rules: brands: done common-modules: done config-flow: done - config-flow-test-coverage: - status: todo - comment: | - Merge test_show_config_form with full flow test. - Move full flow test to the top of all tests. - All test cases should end in either CREATE_ENTRY or ABORT. + config-flow-test-coverage: done dependency-transparency: done docs-actions: status: exempt diff --git a/tests/components/minecraft_server/test_config_flow.py b/tests/components/minecraft_server/test_config_flow.py index 00e25028249..c57b74c6a65 100644 --- a/tests/components/minecraft_server/test_config_flow.py +++ b/tests/components/minecraft_server/test_config_flow.py @@ -26,8 +26,8 @@ USER_INPUT = { } -async def test_show_config_form(hass: HomeAssistant) -> None: - """Test if initial configuration form is shown.""" +async def test_full_flow_java(hass: HomeAssistant) -> None: + """Test config entry in case of a successful connection to a Java Edition server.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) @@ -35,96 +35,6 @@ async def test_show_config_form(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" - -async def test_service_already_configured( - hass: HomeAssistant, bedrock_mock_config_entry: MockConfigEntry -) -> None: - """Test config flow abort if service is already configured.""" - bedrock_mock_config_entry.add_to_hass(hass) - - with ( - patch( - "homeassistant.components.minecraft_server.api.BedrockServer.lookup", - return_value=BedrockServer(host=TEST_HOST, port=TEST_PORT), - ), - patch( - "homeassistant.components.minecraft_server.api.BedrockServer.async_status", - return_value=TEST_BEDROCK_STATUS_RESPONSE, - ), - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT - ) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" - - -async def test_address_validation_failure(hass: HomeAssistant) -> None: - """Test error in case of a failed connection.""" - with ( - patch( - "homeassistant.components.minecraft_server.api.BedrockServer.lookup", - side_effect=ValueError, - ), - patch( - "homeassistant.components.minecraft_server.api.JavaServer.async_lookup", - side_effect=ValueError, - ), - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT - ) - - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"base": "cannot_connect"} - - -async def test_java_connection_failure(hass: HomeAssistant) -> None: - """Test error in case of a failed connection to a Java Edition server.""" - with ( - patch( - "homeassistant.components.minecraft_server.api.BedrockServer.lookup", - side_effect=ValueError, - ), - patch( - "homeassistant.components.minecraft_server.api.JavaServer.async_lookup", - return_value=JavaServer(host=TEST_HOST, port=TEST_PORT), - ), - patch( - "homeassistant.components.minecraft_server.api.JavaServer.async_status", - side_effect=OSError, - ), - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT - ) - - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"base": "cannot_connect"} - - -async def test_bedrock_connection_failure(hass: HomeAssistant) -> None: - """Test error in case of a failed connection to a Bedrock Edition server.""" - with ( - patch( - "homeassistant.components.minecraft_server.api.BedrockServer.lookup", - return_value=BedrockServer(host=TEST_HOST, port=TEST_PORT), - ), - patch( - "homeassistant.components.minecraft_server.api.BedrockServer.async_status", - side_effect=OSError, - ), - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT - ) - - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"base": "cannot_connect"} - - -async def test_java_connection(hass: HomeAssistant) -> None: - """Test config entry in case of a successful connection to a Java Edition server.""" with ( patch( "homeassistant.components.minecraft_server.api.BedrockServer.lookup", @@ -149,8 +59,15 @@ async def test_java_connection(hass: HomeAssistant) -> None: assert result["data"][CONF_TYPE] == MinecraftServerType.JAVA_EDITION -async def test_bedrock_connection(hass: HomeAssistant) -> None: +async def test_full_flow_bedrock(hass: HomeAssistant) -> None: """Test config entry in case of a successful connection to a Bedrock Edition server.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + with ( patch( "homeassistant.components.minecraft_server.api.BedrockServer.lookup", @@ -171,8 +88,12 @@ async def test_bedrock_connection(hass: HomeAssistant) -> None: assert result["data"][CONF_TYPE] == MinecraftServerType.BEDROCK_EDITION -async def test_recovery(hass: HomeAssistant) -> None: - """Test config flow recovery (successful connection after a failed connection).""" +async def test_service_already_configured_java( + hass: HomeAssistant, java_mock_config_entry: MockConfigEntry +) -> None: + """Test config flow abort if a Java Edition server is already configured.""" + java_mock_config_entry.add_to_hass(hass) + with ( patch( "homeassistant.components.minecraft_server.api.BedrockServer.lookup", @@ -180,8 +101,99 @@ async def test_recovery(hass: HomeAssistant) -> None: ), patch( "homeassistant.components.minecraft_server.api.JavaServer.async_lookup", + return_value=JavaServer(host=TEST_HOST, port=TEST_PORT), + ), + patch( + "homeassistant.components.minecraft_server.api.JavaServer.async_status", + return_value=TEST_JAVA_STATUS_RESPONSE, + ), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_service_already_configured_bedrock( + hass: HomeAssistant, bedrock_mock_config_entry: MockConfigEntry +) -> None: + """Test config flow abort if a Bedrock Edition server is already configured.""" + bedrock_mock_config_entry.add_to_hass(hass) + + with ( + patch( + "homeassistant.components.minecraft_server.api.BedrockServer.lookup", + return_value=BedrockServer(host=TEST_HOST, port=TEST_PORT), + ), + patch( + "homeassistant.components.minecraft_server.api.BedrockServer.async_status", + return_value=TEST_BEDROCK_STATUS_RESPONSE, + ), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_recovery_java(hass: HomeAssistant) -> None: + """Test config flow recovery with a Java Edition server (successful connection after a failed connection).""" + with ( + patch( + "homeassistant.components.minecraft_server.api.BedrockServer.lookup", side_effect=ValueError, ), + patch( + "homeassistant.components.minecraft_server.api.JavaServer.async_lookup", + return_value=JavaServer(host=TEST_HOST, port=TEST_PORT), + ), + patch( + "homeassistant.components.minecraft_server.api.JavaServer.async_status", + side_effect=OSError, + ), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} + + with ( + patch( + "homeassistant.components.minecraft_server.api.BedrockServer.lookup", + side_effect=ValueError, + ), + patch( + "homeassistant.components.minecraft_server.api.JavaServer.async_lookup", + return_value=JavaServer(host=TEST_HOST, port=TEST_PORT), + ), + patch( + "homeassistant.components.minecraft_server.api.JavaServer.async_status", + return_value=TEST_JAVA_STATUS_RESPONSE, + ), + ): + result2 = await hass.config_entries.flow.async_configure( + flow_id=result["flow_id"], user_input=USER_INPUT + ) + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["title"] == USER_INPUT[CONF_ADDRESS] + assert result2["data"][CONF_ADDRESS] == TEST_ADDRESS + assert result2["data"][CONF_TYPE] == MinecraftServerType.JAVA_EDITION + + +async def test_recovery_bedrock(hass: HomeAssistant) -> None: + """Test config flow recovery with a Bedrock Edition server (successful connection after a failed connection).""" + with ( + patch( + "homeassistant.components.minecraft_server.api.BedrockServer.lookup", + return_value=BedrockServer(host=TEST_HOST, port=TEST_PORT), + ), + patch( + "homeassistant.components.minecraft_server.api.BedrockServer.async_status", + side_effect=OSError, + ), ): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT From 7ba94a680dacf007eca5f26e5e356d9202bee543 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Tue, 25 Feb 2025 14:46:43 +0100 Subject: [PATCH 1772/3148] Revert "Bump Stookwijzer to 1.5.7" (#139253) --- homeassistant/components/stookwijzer/__init__.py | 2 ++ homeassistant/components/stookwijzer/config_flow.py | 2 ++ homeassistant/components/stookwijzer/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 7 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/stookwijzer/__init__.py b/homeassistant/components/stookwijzer/__init__.py index cb198749c52..d8b9561bde9 100644 --- a/homeassistant/components/stookwijzer/__init__.py +++ b/homeassistant/components/stookwijzer/__init__.py @@ -9,6 +9,7 @@ from stookwijzer import Stookwijzer from homeassistant.const import CONF_LATITUDE, CONF_LOCATION, CONF_LONGITUDE, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er, issue_registry as ir +from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DOMAIN, LOGGER from .coordinator import StookwijzerConfigEntry, StookwijzerCoordinator @@ -43,6 +44,7 @@ async def async_migrate_entry( if entry.version == 1: latitude, longitude = await Stookwijzer.async_transform_coordinates( + async_get_clientsession(hass), entry.data[CONF_LOCATION][CONF_LATITUDE], entry.data[CONF_LOCATION][CONF_LONGITUDE], ) diff --git a/homeassistant/components/stookwijzer/config_flow.py b/homeassistant/components/stookwijzer/config_flow.py index 124b0f8bfbb..32b4836763f 100644 --- a/homeassistant/components/stookwijzer/config_flow.py +++ b/homeassistant/components/stookwijzer/config_flow.py @@ -9,6 +9,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_LATITUDE, CONF_LOCATION, CONF_LONGITUDE +from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.selector import LocationSelector from .const import DOMAIN @@ -26,6 +27,7 @@ class StookwijzerFlowHandler(ConfigFlow, domain=DOMAIN): errors = {} if user_input is not None: latitude, longitude = await Stookwijzer.async_transform_coordinates( + async_get_clientsession(self.hass), user_input[CONF_LOCATION][CONF_LATITUDE], user_input[CONF_LOCATION][CONF_LONGITUDE], ) diff --git a/homeassistant/components/stookwijzer/manifest.json b/homeassistant/components/stookwijzer/manifest.json index e8f6081b9be..8243b903e8d 100644 --- a/homeassistant/components/stookwijzer/manifest.json +++ b/homeassistant/components/stookwijzer/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/stookwijzer", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["stookwijzer==1.5.7"] + "requirements": ["stookwijzer==1.5.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index 1274cd99deb..e3576e8618b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2811,7 +2811,7 @@ statsd==3.2.1 steamodd==4.21 # homeassistant.components.stookwijzer -stookwijzer==1.5.7 +stookwijzer==1.5.4 # homeassistant.components.streamlabswater streamlabswater==1.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6e3238a5fe7..baefe19b71b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2272,7 +2272,7 @@ statsd==3.2.1 steamodd==4.21 # homeassistant.components.stookwijzer -stookwijzer==1.5.7 +stookwijzer==1.5.4 # homeassistant.components.streamlabswater streamlabswater==1.0.1 From a3bc55f49bcecb2055cff1f29f492abddf8ce37e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Tue, 25 Feb 2025 14:50:12 +0100 Subject: [PATCH 1773/3148] Add parallel updates to Home Connect (#139255) --- homeassistant/components/home_connect/binary_sensor.py | 2 ++ homeassistant/components/home_connect/button.py | 2 ++ homeassistant/components/home_connect/light.py | 2 ++ homeassistant/components/home_connect/number.py | 2 ++ homeassistant/components/home_connect/select.py | 2 ++ homeassistant/components/home_connect/sensor.py | 2 ++ homeassistant/components/home_connect/switch.py | 1 + homeassistant/components/home_connect/time.py | 2 ++ 8 files changed, 15 insertions(+) diff --git a/homeassistant/components/home_connect/binary_sensor.py b/homeassistant/components/home_connect/binary_sensor.py index 57ede4b2ff4..1f82aa71766 100644 --- a/homeassistant/components/home_connect/binary_sensor.py +++ b/homeassistant/components/home_connect/binary_sensor.py @@ -38,6 +38,8 @@ from .coordinator import ( ) from .entity import HomeConnectEntity +PARALLEL_UPDATES = 0 + REFRIGERATION_DOOR_BOOLEAN_MAP = { REFRIGERATION_STATUS_DOOR_CLOSED: False, REFRIGERATION_STATUS_DOOR_OPEN: True, diff --git a/homeassistant/components/home_connect/button.py b/homeassistant/components/home_connect/button.py index 138979409a5..0a5538ec588 100644 --- a/homeassistant/components/home_connect/button.py +++ b/homeassistant/components/home_connect/button.py @@ -18,6 +18,8 @@ from .coordinator import ( from .entity import HomeConnectEntity from .utils import get_dict_from_home_connect_error +PARALLEL_UPDATES = 1 + class HomeConnectCommandButtonEntityDescription(ButtonEntityDescription): """Describes Home Connect button entity.""" diff --git a/homeassistant/components/home_connect/light.py b/homeassistant/components/home_connect/light.py index 9f9016855e9..72c6b9aaa2b 100644 --- a/homeassistant/components/home_connect/light.py +++ b/homeassistant/components/home_connect/light.py @@ -36,6 +36,8 @@ from .utils import get_dict_from_home_connect_error _LOGGER = logging.getLogger(__name__) +PARALLEL_UPDATES = 1 + @dataclass(frozen=True, kw_only=True) class HomeConnectLightEntityDescription(LightEntityDescription): diff --git a/homeassistant/components/home_connect/number.py b/homeassistant/components/home_connect/number.py index 27b4bc7eb6f..404f063946c 100644 --- a/homeassistant/components/home_connect/number.py +++ b/homeassistant/components/home_connect/number.py @@ -30,6 +30,8 @@ from .utils import get_dict_from_home_connect_error _LOGGER = logging.getLogger(__name__) +PARALLEL_UPDATES = 1 + UNIT_MAP = { "seconds": UnitOfTime.SECONDS, "ml": UnitOfVolume.MILLILITERS, diff --git a/homeassistant/components/home_connect/select.py b/homeassistant/components/home_connect/select.py index d5657387358..ef3e2ccbf82 100644 --- a/homeassistant/components/home_connect/select.py +++ b/homeassistant/components/home_connect/select.py @@ -50,6 +50,8 @@ from .coordinator import ( from .entity import HomeConnectEntity, HomeConnectOptionEntity from .utils import bsh_key_to_translation_key, get_dict_from_home_connect_error +PARALLEL_UPDATES = 1 + FUNCTIONAL_LIGHT_COLOR_TEMPERATURE_ENUM = { bsh_key_to_translation_key(option): option for option in ( diff --git a/homeassistant/components/home_connect/sensor.py b/homeassistant/components/home_connect/sensor.py index 88dd017e7d9..be0b621b508 100644 --- a/homeassistant/components/home_connect/sensor.py +++ b/homeassistant/components/home_connect/sensor.py @@ -27,6 +27,8 @@ from .const import ( from .coordinator import HomeConnectApplianceData, HomeConnectConfigEntry from .entity import HomeConnectEntity +PARALLEL_UPDATES = 0 + EVENT_OPTIONS = ["confirmed", "off", "present"] diff --git a/homeassistant/components/home_connect/switch.py b/homeassistant/components/home_connect/switch.py index d5a92eef2a4..6f9aa0e679f 100644 --- a/homeassistant/components/home_connect/switch.py +++ b/homeassistant/components/home_connect/switch.py @@ -42,6 +42,7 @@ from .utils import get_dict_from_home_connect_error _LOGGER = logging.getLogger(__name__) +PARALLEL_UPDATES = 1 SWITCHES = ( SwitchEntityDescription( diff --git a/homeassistant/components/home_connect/time.py b/homeassistant/components/home_connect/time.py index 3d16dd37e21..a1761219d30 100644 --- a/homeassistant/components/home_connect/time.py +++ b/homeassistant/components/home_connect/time.py @@ -23,6 +23,8 @@ from .coordinator import HomeConnectApplianceData, HomeConnectConfigEntry from .entity import HomeConnectEntity from .utils import get_dict_from_home_connect_error +PARALLEL_UPDATES = 1 + TIME_ENTITIES = ( TimeEntityDescription( key=SettingKey.BSH_COMMON_ALARM_CLOCK, From d4dd8fd9020157971084d43a7e534196e5752852 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 25 Feb 2025 14:01:45 +0000 Subject: [PATCH 1774/3148] Bump fnv-hash-fast to 1.2.6 (#139246) --- homeassistant/components/homekit/manifest.json | 2 +- homeassistant/components/recorder/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/homekit/manifest.json b/homeassistant/components/homekit/manifest.json index 63254384666..f9a31489ca4 100644 --- a/homeassistant/components/homekit/manifest.json +++ b/homeassistant/components/homekit/manifest.json @@ -10,7 +10,7 @@ "loggers": ["pyhap"], "requirements": [ "HAP-python==4.9.2", - "fnv-hash-fast==1.2.3", + "fnv-hash-fast==1.2.6", "PyQRCode==1.2.1", "base36==0.1.1" ], diff --git a/homeassistant/components/recorder/manifest.json b/homeassistant/components/recorder/manifest.json index 6f555704670..40513c8ea24 100644 --- a/homeassistant/components/recorder/manifest.json +++ b/homeassistant/components/recorder/manifest.json @@ -8,7 +8,7 @@ "quality_scale": "internal", "requirements": [ "SQLAlchemy==2.0.38", - "fnv-hash-fast==1.2.3", + "fnv-hash-fast==1.2.6", "psutil-home-assistant==0.0.1" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 335a3b1da29..6bcac95366d 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -30,7 +30,7 @@ ciso8601==2.3.2 cronsim==2.6 cryptography==44.0.1 dbus-fast==2.33.0 -fnv-hash-fast==1.2.3 +fnv-hash-fast==1.2.6 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 habluetooth==3.24.0 diff --git a/pyproject.toml b/pyproject.toml index 1224cc0c70e..7a970b405a6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,7 +43,7 @@ dependencies = [ "certifi>=2021.5.30", "ciso8601==2.3.2", "cronsim==2.6", - "fnv-hash-fast==1.2.3", + "fnv-hash-fast==1.2.6", # hass-nabucasa is imported by helpers which don't depend on the cloud # integration "hass-nabucasa==0.92.0", diff --git a/requirements.txt b/requirements.txt index 1ec004d7f65..f002f0d6ecc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -20,7 +20,7 @@ bcrypt==4.2.0 certifi>=2021.5.30 ciso8601==2.3.2 cronsim==2.6 -fnv-hash-fast==1.2.3 +fnv-hash-fast==1.2.6 hass-nabucasa==0.92.0 httpx==0.28.1 home-assistant-bluetooth==1.13.1 diff --git a/requirements_all.txt b/requirements_all.txt index e3576e8618b..dcb11cf69c7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -946,7 +946,7 @@ flux-led==1.1.3 # homeassistant.components.homekit # homeassistant.components.recorder -fnv-hash-fast==1.2.3 +fnv-hash-fast==1.2.6 # homeassistant.components.foobot foobot_async==1.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index baefe19b71b..04c2d8eb789 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -805,7 +805,7 @@ flux-led==1.1.3 # homeassistant.components.homekit # homeassistant.components.recorder -fnv-hash-fast==1.2.3 +fnv-hash-fast==1.2.6 # homeassistant.components.foobot foobot_async==1.0.0 From b8b153b87f801269076300a58d768e885f40242d Mon Sep 17 00:00:00 2001 From: Cameron Ring Date: Tue, 25 Feb 2025 06:07:42 -0800 Subject: [PATCH 1775/3148] Make default dim level configurable in Lutron (#137127) --- .../components/lutron/config_flow.py | 48 ++++++++++++++++++- homeassistant/components/lutron/const.py | 4 ++ homeassistant/components/lutron/light.py | 20 ++++++-- homeassistant/components/lutron/strings.json | 9 ++++ 4 files changed, 76 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/lutron/config_flow.py b/homeassistant/components/lutron/config_flow.py index 6a48e0d4b67..3f55a2b131b 100644 --- a/homeassistant/components/lutron/config_flow.py +++ b/homeassistant/components/lutron/config_flow.py @@ -9,10 +9,21 @@ from urllib.error import HTTPError from pylutron import Lutron import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import ( + ConfigEntry, + ConfigFlow, + ConfigFlowResult, + OptionsFlow, +) from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import callback +from homeassistant.helpers.selector import ( + NumberSelector, + NumberSelectorConfig, + NumberSelectorMode, +) -from .const import DOMAIN +from .const import CONF_DEFAULT_DIMMER_LEVEL, DEFAULT_DIMMER_LEVEL, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -68,3 +79,36 @@ class LutronConfigFlow(ConfigFlow, domain=DOMAIN): ), errors=errors, ) + + @staticmethod + @callback + def async_get_options_flow( + config_entry: ConfigEntry, + ) -> OptionsFlowHandler: + """Get the options flow for this handler.""" + return OptionsFlowHandler() + + +class OptionsFlowHandler(OptionsFlow): + """Handle option flow for lutron.""" + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle options flow.""" + if user_input is not None: + return self.async_create_entry(title="", data=user_input) + + data_schema = vol.Schema( + { + vol.Required( + CONF_DEFAULT_DIMMER_LEVEL, + default=self.config_entry.options.get( + CONF_DEFAULT_DIMMER_LEVEL, DEFAULT_DIMMER_LEVEL + ), + ): NumberSelector( + NumberSelectorConfig(min=1, max=255, mode=NumberSelectorMode.SLIDER) + ) + } + ) + return self.async_show_form(step_id="init", data_schema=data_schema) diff --git a/homeassistant/components/lutron/const.py b/homeassistant/components/lutron/const.py index 3862f7eb1d8..b69e35f38ba 100644 --- a/homeassistant/components/lutron/const.py +++ b/homeassistant/components/lutron/const.py @@ -1,3 +1,7 @@ """Lutron constants.""" DOMAIN = "lutron" + +CONF_DEFAULT_DIMMER_LEVEL = "default_dimmer_level" + +DEFAULT_DIMMER_LEVEL = 255 / 2 diff --git a/homeassistant/components/lutron/light.py b/homeassistant/components/lutron/light.py index 58183fb0a38..a7489e13b7b 100644 --- a/homeassistant/components/lutron/light.py +++ b/homeassistant/components/lutron/light.py @@ -5,7 +5,7 @@ from __future__ import annotations from collections.abc import Mapping from typing import Any -from pylutron import Output +from pylutron import Lutron, LutronEntity, Output from homeassistant.components.light import ( ATTR_BRIGHTNESS, @@ -20,6 +20,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import DOMAIN, LutronData +from .const import CONF_DEFAULT_DIMMER_LEVEL, DEFAULT_DIMMER_LEVEL from .entity import LutronDevice @@ -37,7 +38,7 @@ async def async_setup_entry( async_add_entities( ( - LutronLight(area_name, device, entry_data.client) + LutronLight(area_name, device, entry_data.client, config_entry) for area_name, device in entry_data.lights ), True, @@ -64,6 +65,17 @@ class LutronLight(LutronDevice, LightEntity): _prev_brightness: int | None = None _attr_name = None + def __init__( + self, + area_name: str, + lutron_device: LutronEntity, + controller: Lutron, + config_entry: ConfigEntry, + ) -> None: + """Initialize the device.""" + super().__init__(area_name, lutron_device, controller) + self._config_entry = config_entry + def turn_on(self, **kwargs: Any) -> None: """Turn the light on.""" if flash := kwargs.get(ATTR_FLASH): @@ -72,7 +84,9 @@ class LutronLight(LutronDevice, LightEntity): if ATTR_BRIGHTNESS in kwargs and self._lutron_device.is_dimmable: brightness = kwargs[ATTR_BRIGHTNESS] elif self._prev_brightness == 0: - brightness = 255 / 2 + brightness = self._config_entry.options.get( + CONF_DEFAULT_DIMMER_LEVEL, DEFAULT_DIMMER_LEVEL + ) else: brightness = self._prev_brightness self._prev_brightness = brightness diff --git a/homeassistant/components/lutron/strings.json b/homeassistant/components/lutron/strings.json index b73e0bd15ed..37db509e294 100644 --- a/homeassistant/components/lutron/strings.json +++ b/homeassistant/components/lutron/strings.json @@ -19,6 +19,15 @@ } } }, + "options": { + "step": { + "init": { + "data": { + "default_dimmer_level": "Default light level when first turning on a light from Home Assistant" + } + } + } + }, "entity": { "event": { "button": { From b9dbf07a5e7e00a8e04c3b5c683ad11621f1658b Mon Sep 17 00:00:00 2001 From: elmurato <1382097+elmurato@users.noreply.github.com> Date: Tue, 25 Feb 2025 15:09:58 +0100 Subject: [PATCH 1776/3148] Set PARALLEL_UPDATES in all Minecraft Server platforms (#139259) --- homeassistant/components/minecraft_server/binary_sensor.py | 3 +++ .../components/minecraft_server/quality_scale.yaml | 6 +----- homeassistant/components/minecraft_server/sensor.py | 3 +++ 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/minecraft_server/binary_sensor.py b/homeassistant/components/minecraft_server/binary_sensor.py index 39e12228451..a7279040a6d 100644 --- a/homeassistant/components/minecraft_server/binary_sensor.py +++ b/homeassistant/components/minecraft_server/binary_sensor.py @@ -22,6 +22,9 @@ BINARY_SENSOR_DESCRIPTIONS = [ ), ] +# Coordinator is used to centralize the data updates. +PARALLEL_UPDATES = 0 + async def async_setup_entry( hass: HomeAssistant, diff --git a/homeassistant/components/minecraft_server/quality_scale.yaml b/homeassistant/components/minecraft_server/quality_scale.yaml index 6cf1fc7772e..61a975632bb 100644 --- a/homeassistant/components/minecraft_server/quality_scale.yaml +++ b/homeassistant/components/minecraft_server/quality_scale.yaml @@ -51,11 +51,7 @@ rules: log-when-unavailable: status: done comment: Handled by coordinator. - parallel-updates: - status: todo - comment: | - Although this is handled by the coordinator and no service actions are provided, - PARALLEL_UPDATES should still be set to 0 in binary_sensor and sensor according to the rule. + parallel-updates: done reauthentication-flow: status: exempt comment: No authentication is required for the integration. diff --git a/homeassistant/components/minecraft_server/sensor.py b/homeassistant/components/minecraft_server/sensor.py index 6effa53fbf2..cfc16c7724d 100644 --- a/homeassistant/components/minecraft_server/sensor.py +++ b/homeassistant/components/minecraft_server/sensor.py @@ -30,6 +30,9 @@ KEY_VERSION = "version" UNIT_PLAYERS_MAX = "players" UNIT_PLAYERS_ONLINE = "players" +# Coordinator is used to centralize the data updates. +PARALLEL_UPDATES = 0 + @dataclass(frozen=True, kw_only=True) class MinecraftServerSensorEntityDescription(SensorEntityDescription): From 75660469956aaf7c9039f4aaacfbc0d1e28c2677 Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Tue, 25 Feb 2025 16:10:03 +0200 Subject: [PATCH 1777/3148] Bump aiowebostv to 0.7.1 (#139244) --- homeassistant/components/webostv/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/webostv/manifest.json b/homeassistant/components/webostv/manifest.json index 45c9628539c..06cbca32453 100644 --- a/homeassistant/components/webostv/manifest.json +++ b/homeassistant/components/webostv/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/webostv", "iot_class": "local_push", "loggers": ["aiowebostv"], - "requirements": ["aiowebostv==0.7.0"], + "requirements": ["aiowebostv==0.7.1"], "ssdp": [ { "st": "urn:lge-com:service:webos-second-screen:1" diff --git a/requirements_all.txt b/requirements_all.txt index dcb11cf69c7..f7b9f2425a6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -425,7 +425,7 @@ aiowatttime==0.1.1 aiowebdav2==0.3.0 # homeassistant.components.webostv -aiowebostv==0.7.0 +aiowebostv==0.7.1 # homeassistant.components.withings aiowithings==3.1.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 04c2d8eb789..90ea8c808c4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -407,7 +407,7 @@ aiowatttime==0.1.1 aiowebdav2==0.3.0 # homeassistant.components.webostv -aiowebostv==0.7.0 +aiowebostv==0.7.1 # homeassistant.components.withings aiowithings==3.1.6 From 923ec71bf673582508128bcccb409b91b0453de0 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 25 Feb 2025 15:10:21 +0100 Subject: [PATCH 1778/3148] Consistently capitalize "Velbus" brand name, camel-case "VelServ" (#139257) --- homeassistant/components/velbus/strings.json | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/velbus/strings.json b/homeassistant/components/velbus/strings.json index 895f883678d..a50395af115 100644 --- a/homeassistant/components/velbus/strings.json +++ b/homeassistant/components/velbus/strings.json @@ -17,12 +17,12 @@ "password": "[%key:common::config_flow::data::password%]" }, "data_description": { - "tls": "Enable this if you use a secure connection to your velbus interface, like a Signum.", - "host": "The IP address or hostname of the velbus interface.", - "port": "The port number of the velbus interface.", - "password": "The password of the velbus interface, this is only needed if the interface is password protected." + "tls": "Enable this if you use a secure connection to your Velbus interface, like a Signum.", + "host": "The IP address or hostname of the Velbus interface.", + "port": "The port number of the Velbus interface.", + "password": "The password of the Velbus interface, this is only needed if the interface is password protected." }, - "description": "TCP/IP configuration, in case you use a Signum, velserv, velbus-tcp or any other velbus to TCP/IP interface." + "description": "TCP/IP configuration, in case you use a Signum, VelServ, velbus-tcp or any other Velbus to TCP/IP interface." }, "usbselect": { "title": "USB configuration", @@ -30,9 +30,9 @@ "port": "[%key:common::config_flow::data::port%]" }, "data_description": { - "port": "Select the serial port for your velbus USB interface." + "port": "Select the serial port for your Velbus USB interface." }, - "description": "Select the serial port for your velbus USB interface." + "description": "Select the serial port for your Velbus USB interface." } }, "error": { From 1633700a5811f7fb9219976255cc3e4306a4c637 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 25 Feb 2025 14:25:07 +0000 Subject: [PATCH 1779/3148] Bump cached-ipaddress to 0.9.2 (#139245) --- homeassistant/components/dhcp/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/dhcp/manifest.json b/homeassistant/components/dhcp/manifest.json index 65d43f80abe..5b3a5abd26f 100644 --- a/homeassistant/components/dhcp/manifest.json +++ b/homeassistant/components/dhcp/manifest.json @@ -16,6 +16,6 @@ "requirements": [ "aiodhcpwatcher==1.1.1", "aiodiscover==2.6.1", - "cached-ipaddress==0.8.1" + "cached-ipaddress==0.9.2" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 6bcac95366d..e4f9466a10e 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -24,7 +24,7 @@ bleak==0.22.3 bluetooth-adapters==0.21.4 bluetooth-auto-recovery==1.4.4 bluetooth-data-tools==1.23.4 -cached-ipaddress==0.8.1 +cached-ipaddress==0.9.2 certifi>=2021.5.30 ciso8601==2.3.2 cronsim==2.6 diff --git a/requirements_all.txt b/requirements_all.txt index f7b9f2425a6..5e6841ecf1e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -686,7 +686,7 @@ btsmarthub-devicelist==0.2.3 buienradar==1.0.6 # homeassistant.components.dhcp -cached-ipaddress==0.8.1 +cached-ipaddress==0.9.2 # homeassistant.components.caldav caldav==1.3.9 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 90ea8c808c4..46ce49503be 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -597,7 +597,7 @@ bthome-ble==3.12.4 buienradar==1.0.6 # homeassistant.components.dhcp -cached-ipaddress==0.8.1 +cached-ipaddress==0.9.2 # homeassistant.components.caldav caldav==1.3.9 From 1f93d2cefb3cc2cde56fb7be25cd78aa3aa8f5cb Mon Sep 17 00:00:00 2001 From: Dan Bishop Date: Tue, 25 Feb 2025 14:26:22 +0000 Subject: [PATCH 1780/3148] Make Sonarr component's units translatable (#139254) Co-authored-by: Joost Lekkerkerker --- homeassistant/components/sonarr/sensor.py | 5 ----- homeassistant/components/sonarr/strings.json | 15 ++++++++++----- tests/components/sonarr/test_sensor.py | 10 +++++----- 3 files changed, 15 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/sonarr/sensor.py b/homeassistant/components/sonarr/sensor.py index 6a0293e455c..983ac76d93e 100644 --- a/homeassistant/components/sonarr/sensor.py +++ b/homeassistant/components/sonarr/sensor.py @@ -90,7 +90,6 @@ SENSOR_TYPES: dict[str, SonarrSensorEntityDescription[Any]] = { "commands": SonarrSensorEntityDescription[list[Command]]( key="commands", translation_key="commands", - native_unit_of_measurement="Commands", entity_registry_enabled_default=False, value_fn=len, attributes_fn=lambda data: {c.name: c.status for c in data}, @@ -107,7 +106,6 @@ SENSOR_TYPES: dict[str, SonarrSensorEntityDescription[Any]] = { "queue": SonarrSensorEntityDescription[SonarrQueue]( key="queue", translation_key="queue", - native_unit_of_measurement="Episodes", entity_registry_enabled_default=False, value_fn=lambda data: data.totalRecords, attributes_fn=get_queue_attr, @@ -115,7 +113,6 @@ SENSOR_TYPES: dict[str, SonarrSensorEntityDescription[Any]] = { "series": SonarrSensorEntityDescription[list[SonarrSeries]]( key="series", translation_key="series", - native_unit_of_measurement="Series", entity_registry_enabled_default=False, value_fn=len, attributes_fn=lambda data: { @@ -129,7 +126,6 @@ SENSOR_TYPES: dict[str, SonarrSensorEntityDescription[Any]] = { "upcoming": SonarrSensorEntityDescription[list[SonarrCalendar]]( key="upcoming", translation_key="upcoming", - native_unit_of_measurement="Episodes", value_fn=len, attributes_fn=lambda data: { e.series.title: f"S{e.seasonNumber:02d}E{e.episodeNumber:02d}" for e in data @@ -138,7 +134,6 @@ SENSOR_TYPES: dict[str, SonarrSensorEntityDescription[Any]] = { "wanted": SonarrSensorEntityDescription[SonarrWantedMissing]( key="wanted", translation_key="wanted", - native_unit_of_measurement="Episodes", entity_registry_enabled_default=False, value_fn=lambda data: data.totalRecords, attributes_fn=get_wanted_attr, diff --git a/homeassistant/components/sonarr/strings.json b/homeassistant/components/sonarr/strings.json index 5b17f3283e8..940ec650270 100644 --- a/homeassistant/components/sonarr/strings.json +++ b/homeassistant/components/sonarr/strings.json @@ -37,22 +37,27 @@ "entity": { "sensor": { "commands": { - "name": "Commands" + "name": "Commands", + "unit_of_measurement": "commands" }, "diskspace": { "name": "Disk space" }, "queue": { - "name": "Queue" + "name": "Queue", + "unit_of_measurement": "episodes" }, "series": { - "name": "Shows" + "name": "Shows", + "unit_of_measurement": "series" }, "upcoming": { - "name": "Upcoming" + "name": "Upcoming", + "unit_of_measurement": "[%key:component::sonarr::entity::sensor::queue::unit_of_measurement%]" }, "wanted": { - "name": "Wanted" + "name": "Wanted", + "unit_of_measurement": "[%key:component::sonarr::entity::sensor::queue::unit_of_measurement%]" } } } diff --git a/tests/components/sonarr/test_sensor.py b/tests/components/sonarr/test_sensor.py index 3ccff4c88ba..78f03e8b6de 100644 --- a/tests/components/sonarr/test_sensor.py +++ b/tests/components/sonarr/test_sensor.py @@ -49,7 +49,7 @@ async def test_sensors( state = hass.states.get("sensor.sonarr_commands") assert state - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "Commands" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "commands" assert state.state == "2" state = hass.states.get("sensor.sonarr_disk_space") @@ -60,25 +60,25 @@ async def test_sensors( state = hass.states.get("sensor.sonarr_queue") assert state - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "Episodes" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "episodes" assert state.attributes.get("The Andy Griffith Show S01E01") == "100.00%" assert state.state == "1" state = hass.states.get("sensor.sonarr_shows") assert state - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "Series" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "series" assert state.attributes.get("The Andy Griffith Show") == "0/0 Episodes" assert state.state == "1" state = hass.states.get("sensor.sonarr_upcoming") assert state - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "Episodes" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "episodes" assert state.attributes.get("Bob's Burgers") == "S04E11" assert state.state == "1" state = hass.states.get("sensor.sonarr_wanted") assert state - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "Episodes" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "episodes" assert state.attributes.get("Bob's Burgers S04E11") == "2014-01-26T17:30:00-08:00" assert ( state.attributes.get("The Andy Griffith Show S01E01") From 776501f5e65789d5ff20e154f01542411f01cdff Mon Sep 17 00:00:00 2001 From: fwestenberg <47930023+fwestenberg@users.noreply.github.com> Date: Tue, 25 Feb 2025 15:41:36 +0100 Subject: [PATCH 1781/3148] Bump stookwijzer to 1.5.8 (#139258) --- homeassistant/components/stookwijzer/__init__.py | 2 -- homeassistant/components/stookwijzer/config_flow.py | 2 -- homeassistant/components/stookwijzer/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 3 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/stookwijzer/__init__.py b/homeassistant/components/stookwijzer/__init__.py index d8b9561bde9..cb198749c52 100644 --- a/homeassistant/components/stookwijzer/__init__.py +++ b/homeassistant/components/stookwijzer/__init__.py @@ -9,7 +9,6 @@ from stookwijzer import Stookwijzer from homeassistant.const import CONF_LATITUDE, CONF_LOCATION, CONF_LONGITUDE, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er, issue_registry as ir -from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DOMAIN, LOGGER from .coordinator import StookwijzerConfigEntry, StookwijzerCoordinator @@ -44,7 +43,6 @@ async def async_migrate_entry( if entry.version == 1: latitude, longitude = await Stookwijzer.async_transform_coordinates( - async_get_clientsession(hass), entry.data[CONF_LOCATION][CONF_LATITUDE], entry.data[CONF_LOCATION][CONF_LONGITUDE], ) diff --git a/homeassistant/components/stookwijzer/config_flow.py b/homeassistant/components/stookwijzer/config_flow.py index 32b4836763f..124b0f8bfbb 100644 --- a/homeassistant/components/stookwijzer/config_flow.py +++ b/homeassistant/components/stookwijzer/config_flow.py @@ -9,7 +9,6 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_LATITUDE, CONF_LOCATION, CONF_LONGITUDE -from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.selector import LocationSelector from .const import DOMAIN @@ -27,7 +26,6 @@ class StookwijzerFlowHandler(ConfigFlow, domain=DOMAIN): errors = {} if user_input is not None: latitude, longitude = await Stookwijzer.async_transform_coordinates( - async_get_clientsession(self.hass), user_input[CONF_LOCATION][CONF_LATITUDE], user_input[CONF_LOCATION][CONF_LONGITUDE], ) diff --git a/homeassistant/components/stookwijzer/manifest.json b/homeassistant/components/stookwijzer/manifest.json index 8243b903e8d..86fccf64db1 100644 --- a/homeassistant/components/stookwijzer/manifest.json +++ b/homeassistant/components/stookwijzer/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/stookwijzer", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["stookwijzer==1.5.4"] + "requirements": ["stookwijzer==1.5.8"] } diff --git a/requirements_all.txt b/requirements_all.txt index 5e6841ecf1e..55b4d140321 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2811,7 +2811,7 @@ statsd==3.2.1 steamodd==4.21 # homeassistant.components.stookwijzer -stookwijzer==1.5.4 +stookwijzer==1.5.8 # homeassistant.components.streamlabswater streamlabswater==1.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 46ce49503be..072250cad20 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2272,7 +2272,7 @@ statsd==3.2.1 steamodd==4.21 # homeassistant.components.stookwijzer -stookwijzer==1.5.4 +stookwijzer==1.5.8 # homeassistant.components.streamlabswater streamlabswater==1.0.1 From 2b55f3af3677b2a3c5d98b38f08d8447879fbd37 Mon Sep 17 00:00:00 2001 From: Maikel Punie Date: Tue, 25 Feb 2025 15:42:12 +0100 Subject: [PATCH 1782/3148] Bump Velbus to bronze quality scale (#139256) --- homeassistant/components/velbus/manifest.json | 1 + script/hassfest/quality_scale.py | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/velbus/manifest.json b/homeassistant/components/velbus/manifest.json index 960f127d16e..29504277651 100644 --- a/homeassistant/components/velbus/manifest.json +++ b/homeassistant/components/velbus/manifest.json @@ -13,6 +13,7 @@ "velbus-packet", "velbus-protocol" ], + "quality_scale": "bronze", "requirements": ["velbus-aio==2025.1.1"], "usb": [ { diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index 195dd93e630..d155cc74acb 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -2167,7 +2167,6 @@ INTEGRATIONS_WITHOUT_SCALE = [ "velux", "venstar", "vera", - "velbus", "verisure", "versasense", "version", From 3059d069600cad10676bff47df0e718964e1bc66 Mon Sep 17 00:00:00 2001 From: Markus Adrario Date: Tue, 25 Feb 2025 15:49:12 +0100 Subject: [PATCH 1783/3148] Add Homee number platform (#138962) Co-authored-by: Joostlek --- homeassistant/components/homee/__init__.py | 1 + homeassistant/components/homee/button.py | 2 +- homeassistant/components/homee/cover.py | 22 +- homeassistant/components/homee/entity.py | 6 +- homeassistant/components/homee/light.py | 12 +- homeassistant/components/homee/number.py | 130 +++ homeassistant/components/homee/strings.json | 44 + homeassistant/components/homee/switch.py | 4 +- homeassistant/components/homee/valve.py | 2 +- tests/components/homee/fixtures/numbers.json | 337 ++++++++ .../homee/snapshots/test_number.ambr | 802 ++++++++++++++++++ tests/components/homee/test_number.py | 74 ++ 12 files changed, 1414 insertions(+), 22 deletions(-) create mode 100644 homeassistant/components/homee/number.py create mode 100644 tests/components/homee/fixtures/numbers.json create mode 100644 tests/components/homee/snapshots/test_number.ambr create mode 100644 tests/components/homee/test_number.py diff --git a/homeassistant/components/homee/__init__.py b/homeassistant/components/homee/__init__.py index c576fa6d23c..d7785ad9104 100644 --- a/homeassistant/components/homee/__init__.py +++ b/homeassistant/components/homee/__init__.py @@ -18,6 +18,7 @@ PLATFORMS = [ Platform.BUTTON, Platform.COVER, Platform.LIGHT, + Platform.NUMBER, Platform.SENSOR, Platform.SWITCH, Platform.VALVE, diff --git a/homeassistant/components/homee/button.py b/homeassistant/components/homee/button.py index f39ee3f5a87..af6d769c1dc 100644 --- a/homeassistant/components/homee/button.py +++ b/homeassistant/components/homee/button.py @@ -75,4 +75,4 @@ class HomeeButton(HomeeEntity, ButtonEntity): async def async_press(self) -> None: """Handle the button press.""" - await self.async_set_value(1) + await self.async_set_homee_value(1) diff --git a/homeassistant/components/homee/cover.py b/homeassistant/components/homee/cover.py index a3695f7ade6..6e7e4fd5c55 100644 --- a/homeassistant/components/homee/cover.py +++ b/homeassistant/components/homee/cover.py @@ -205,17 +205,17 @@ class HomeeCover(HomeeNodeEntity, CoverEntity): """Open the cover.""" assert self._open_close_attribute is not None if not self._open_close_attribute.is_reversed: - await self.async_set_value(self._open_close_attribute, 0) + await self.async_set_homee_value(self._open_close_attribute, 0) else: - await self.async_set_value(self._open_close_attribute, 1) + await self.async_set_homee_value(self._open_close_attribute, 1) async def async_close_cover(self, **kwargs: Any) -> None: """Close cover.""" assert self._open_close_attribute is not None if not self._open_close_attribute.is_reversed: - await self.async_set_value(self._open_close_attribute, 1) + await self.async_set_homee_value(self._open_close_attribute, 1) else: - await self.async_set_value(self._open_close_attribute, 0) + await self.async_set_homee_value(self._open_close_attribute, 0) async def async_set_cover_position(self, **kwargs: Any) -> None: """Move the cover to a specific position.""" @@ -230,12 +230,12 @@ class HomeeCover(HomeeNodeEntity, CoverEntity): homee_max = attribute.maximum homee_position = (position / 100) * (homee_max - homee_min) + homee_min - await self.async_set_value(attribute, homee_position) + await self.async_set_homee_value(attribute, homee_position) async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the cover.""" if self._open_close_attribute is not None: - await self.async_set_value(self._open_close_attribute, 2) + await self.async_set_homee_value(self._open_close_attribute, 2) async def async_open_cover_tilt(self, **kwargs: Any) -> None: """Open the cover tilt.""" @@ -245,9 +245,9 @@ class HomeeCover(HomeeNodeEntity, CoverEntity): ) ) is not None: if not slat_attribute.is_reversed: - await self.async_set_value(slat_attribute, 2) + await self.async_set_homee_value(slat_attribute, 2) else: - await self.async_set_value(slat_attribute, 1) + await self.async_set_homee_value(slat_attribute, 1) async def async_close_cover_tilt(self, **kwargs: Any) -> None: """Close the cover tilt.""" @@ -257,9 +257,9 @@ class HomeeCover(HomeeNodeEntity, CoverEntity): ) ) is not None: if not slat_attribute.is_reversed: - await self.async_set_value(slat_attribute, 1) + await self.async_set_homee_value(slat_attribute, 1) else: - await self.async_set_value(slat_attribute, 2) + await self.async_set_homee_value(slat_attribute, 2) async def async_set_cover_tilt_position(self, **kwargs: Any) -> None: """Move the cover tilt to a specific position.""" @@ -276,4 +276,4 @@ class HomeeCover(HomeeNodeEntity, CoverEntity): homee_max = attribute.maximum homee_position = (position / 100) * (homee_max - homee_min) + homee_min - await self.async_set_value(attribute, homee_position) + await self.async_set_homee_value(attribute, homee_position) diff --git a/homeassistant/components/homee/entity.py b/homeassistant/components/homee/entity.py index 5a7f34b1c37..165a655d82b 100644 --- a/homeassistant/components/homee/entity.py +++ b/homeassistant/components/homee/entity.py @@ -54,7 +54,7 @@ class HomeeEntity(Entity): """Return the availability of the underlying node.""" return (self._attribute.state == AttributeState.NORMAL) and self._host_connected - async def async_set_value(self, value: float) -> None: + async def async_set_homee_value(self, value: float) -> None: """Set an attribute value on the homee node.""" homee = self._entry.runtime_data try: @@ -144,7 +144,9 @@ class HomeeNodeEntity(Entity): return None - async def async_set_value(self, attribute: HomeeAttribute, value: float) -> None: + async def async_set_homee_value( + self, attribute: HomeeAttribute, value: float + ) -> None: """Set an attribute value on the homee node.""" homee = self._entry.runtime_data try: diff --git a/homeassistant/components/homee/light.py b/homeassistant/components/homee/light.py index 12d127c0945..b9c4460075a 100644 --- a/homeassistant/components/homee/light.py +++ b/homeassistant/components/homee/light.py @@ -175,24 +175,26 @@ class HomeeLight(HomeeNodeEntity, LightEntity): kwargs[ATTR_BRIGHTNESS], ) ) - await self.async_set_value(self._dimmer_attr, target_value) + await self.async_set_homee_value(self._dimmer_attr, target_value) else: # If no brightness value is given, just turn on. - await self.async_set_value(self._on_off_attr, 1) + await self.async_set_homee_value(self._on_off_attr, 1) if ATTR_COLOR_TEMP_KELVIN in kwargs and self._temp_attr is not None: - await self.async_set_value(self._temp_attr, kwargs[ATTR_COLOR_TEMP_KELVIN]) + await self.async_set_homee_value( + self._temp_attr, kwargs[ATTR_COLOR_TEMP_KELVIN] + ) if ATTR_HS_COLOR in kwargs: color = kwargs[ATTR_HS_COLOR] if self._col_attr is not None: - await self.async_set_value( + await self.async_set_homee_value( self._col_attr, rgb_list_to_decimal(color_hs_to_RGB(*color)), ) async def async_turn_off(self, **kwargs: Any) -> None: """Instruct the light to turn off.""" - await self.async_set_value(self._on_off_attr, 0) + await self.async_set_homee_value(self._on_off_attr, 0) def _get_supported_color_modes(self) -> set[ColorMode]: """Determine the supported color modes from the available attributes.""" diff --git a/homeassistant/components/homee/number.py b/homeassistant/components/homee/number.py new file mode 100644 index 00000000000..3f1f08a6618 --- /dev/null +++ b/homeassistant/components/homee/number.py @@ -0,0 +1,130 @@ +"""The Homee number platform.""" + +from pyHomee.const import AttributeType +from pyHomee.model import HomeeAttribute + +from homeassistant.components.number import ( + NumberDeviceClass, + NumberEntity, + NumberEntityDescription, +) +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import HomeeConfigEntry +from .const import HOMEE_UNIT_TO_HA_UNIT +from .entity import HomeeEntity + +NUMBER_DESCRIPTIONS = { + AttributeType.DOWN_POSITION: NumberEntityDescription( + key="down_position", + entity_category=EntityCategory.CONFIG, + ), + AttributeType.DOWN_SLAT_POSITION: NumberEntityDescription( + key="down_slat_position", + entity_category=EntityCategory.CONFIG, + ), + AttributeType.DOWN_TIME: NumberEntityDescription( + key="down_time", + device_class=NumberDeviceClass.DURATION, + entity_category=EntityCategory.CONFIG, + ), + AttributeType.ENDPOSITION_CONFIGURATION: NumberEntityDescription( + key="endposition_configuration", + entity_category=EntityCategory.CONFIG, + ), + AttributeType.MOTION_ALARM_CANCELATION_DELAY: NumberEntityDescription( + key="motion_alarm_cancelation_delay", + device_class=NumberDeviceClass.DURATION, + entity_category=EntityCategory.CONFIG, + ), + AttributeType.OPEN_WINDOW_DETECTION_SENSIBILITY: NumberEntityDescription( + key="open_window_detection_sensibility", + entity_category=EntityCategory.CONFIG, + ), + AttributeType.POLLING_INTERVAL: NumberEntityDescription( + key="polling_interval", + device_class=NumberDeviceClass.DURATION, + entity_category=EntityCategory.CONFIG, + ), + AttributeType.SHUTTER_SLAT_TIME: NumberEntityDescription( + key="shutter_slat_time", + device_class=NumberDeviceClass.DURATION, + entity_category=EntityCategory.CONFIG, + ), + AttributeType.SLAT_MAX_ANGLE: NumberEntityDescription( + key="slat_max_angle", + entity_category=EntityCategory.CONFIG, + ), + AttributeType.SLAT_MIN_ANGLE: NumberEntityDescription( + key="slat_min_angle", + entity_category=EntityCategory.CONFIG, + ), + AttributeType.SLAT_STEPS: NumberEntityDescription( + key="slat_steps", + entity_category=EntityCategory.CONFIG, + ), + AttributeType.TEMPERATURE_OFFSET: NumberEntityDescription( + key="temperature_offset", + entity_category=EntityCategory.CONFIG, + ), + AttributeType.UP_TIME: NumberEntityDescription( + key="up_time", + device_class=NumberDeviceClass.DURATION, + entity_category=EntityCategory.CONFIG, + ), + AttributeType.WAKE_UP_INTERVAL: NumberEntityDescription( + key="wake_up_interval", + device_class=NumberDeviceClass.DURATION, + entity_category=EntityCategory.CONFIG, + ), +} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: HomeeConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Add the Homee platform for the number component.""" + + async_add_entities( + HomeeNumber(attribute, config_entry, NUMBER_DESCRIPTIONS[attribute.type]) + for node in config_entry.runtime_data.nodes + for attribute in node.attributes + if attribute.type in NUMBER_DESCRIPTIONS and attribute.data != "fixed_value" + ) + + +class HomeeNumber(HomeeEntity, NumberEntity): + """Representation of a Homee number.""" + + def __init__( + self, + attribute: HomeeAttribute, + entry: HomeeConfigEntry, + description: NumberEntityDescription, + ) -> None: + """Initialize a Homee number entity.""" + super().__init__(attribute, entry) + self.entity_description = description + self._attr_translation_key = description.key + self._attr_native_unit_of_measurement = HOMEE_UNIT_TO_HA_UNIT[attribute.unit] + self._attr_native_min_value = attribute.minimum + self._attr_native_max_value = attribute.maximum + self._attr_native_step = attribute.step_value + + @property + def available(self) -> bool: + """Return the availability of the entity.""" + return super().available and self._attribute.editable + + @property + def native_value(self) -> int: + """Return the native value of the number.""" + return int(self._attribute.current_value) + + async def async_set_native_value(self, value: float) -> None: + """Set the selected value.""" + await self.async_set_homee_value(value) diff --git a/homeassistant/components/homee/strings.json b/homeassistant/components/homee/strings.json index a78e12341a3..cf5b90dbe2a 100644 --- a/homeassistant/components/homee/strings.json +++ b/homeassistant/components/homee/strings.json @@ -66,6 +66,50 @@ "name": "Light {instance}" } }, + "number": { + "down_position": { + "name": "Down position" + }, + "down_slat_position": { + "name": "Down slat position" + }, + "down_time": { + "name": "Down-movement duration" + }, + "endposition_configuration": { + "name": "End position" + }, + "motion_alarm_cancelation_delay": { + "name": "Motion alarm delay" + }, + "open_window_detection_sensibility": { + "name": "Window open sensibility" + }, + "polling_interval": { + "name": "Polling interval" + }, + "shutter_slat_time": { + "name": "Slat turn duration" + }, + "slat_max_angle": { + "name": "Maximum slat angle" + }, + "slat_min_angle": { + "name": "Minimum slat angle" + }, + "slat_steps": { + "name": "Slat steps" + }, + "temperature_offset": { + "name": "Temperature offset" + }, + "up_time": { + "name": "Up-movement duration" + }, + "wake_up_interval": { + "name": "Wake-up interval" + } + }, "sensor": { "brightness_instance": { "name": "Illuminance {instance}" diff --git a/homeassistant/components/homee/switch.py b/homeassistant/components/homee/switch.py index e8b87b2b8e0..86c7acdbf11 100644 --- a/homeassistant/components/homee/switch.py +++ b/homeassistant/components/homee/switch.py @@ -120,8 +120,8 @@ class HomeeSwitch(HomeeEntity, SwitchEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Turn the switch on.""" - await self.async_set_value(1) + await self.async_set_homee_value(1) async def async_turn_off(self, **kwargs: Any) -> None: """Turn the switch off.""" - await self.async_set_value(0) + await self.async_set_homee_value(0) diff --git a/homeassistant/components/homee/valve.py b/homeassistant/components/homee/valve.py index b54d6334263..9a4ff446a10 100644 --- a/homeassistant/components/homee/valve.py +++ b/homeassistant/components/homee/valve.py @@ -78,4 +78,4 @@ class HomeeValve(HomeeEntity, ValveEntity): async def async_set_valve_position(self, position: int) -> None: """Move the valve to a specific position.""" - await self.async_set_value(position) + await self.async_set_homee_value(position) diff --git a/tests/components/homee/fixtures/numbers.json b/tests/components/homee/fixtures/numbers.json new file mode 100644 index 00000000000..c8773a89568 --- /dev/null +++ b/tests/components/homee/fixtures/numbers.json @@ -0,0 +1,337 @@ +{ + "id": 1, + "name": "Test Number", + "profile": 2011, + "image": "default", + "favorite": 0, + "order": 1, + "protocol": 3, + "routing": 0, + "state": 1, + "state_changed": 1731020474, + "added": 1680027411, + "history": 1, + "cube_type": 3, + "note": "", + "services": 0, + "phonetic_name": "", + "owner": 2, + "security": 0, + "attributes": [ + { + "id": 2, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 100, + "current_value": 100.0, + "target_value": 100.0, + "last_value": 100.0, + "unit": "%", + "step_value": 0.5, + "editable": 1, + "type": 349, + "state": 1, + "last_changed": 1624446307, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 3, + "node_id": 1, + "instance": 0, + "minimum": -75, + "maximum": 75, + "current_value": 38.0, + "target_value": 38.0, + "last_value": 38.0, + "unit": "°", + "step_value": 1.0, + "editable": 1, + "type": 350, + "state": 1, + "last_changed": 1624446307, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 4, + "node_id": 1, + "instance": 0, + "minimum": 4, + "maximum": 240, + "current_value": 57.0, + "target_value": 57.0, + "last_value": 90.0, + "unit": "s", + "step_value": 1.0, + "editable": 1, + "type": 111, + "state": 1, + "last_changed": 1615396252, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 5, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 130, + "current_value": 129.0, + "target_value": 129.0, + "last_value": 1.0, + "unit": "n/a", + "step_value": 1.0, + "editable": 1, + "type": 325, + "state": 1, + "last_changed": 1672086680, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 6, + "node_id": 1, + "instance": 0, + "minimum": 1, + "maximum": 15300, + "current_value": 10.0, + "target_value": 1.0, + "last_value": 10.0, + "unit": "s", + "step_value": 1.0, + "editable": 0, + "type": 28, + "state": 1, + "last_changed": 1676204559, + "changed_by": 0, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 7, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 3, + "current_value": 3.0, + "target_value": 3.0, + "last_value": 2.0, + "unit": "", + "step_value": 1.0, + "editable": 1, + "type": 261, + "state": 1, + "last_changed": 1666336770, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 8, + "node_id": 1, + "instance": 0, + "minimum": 5, + "maximum": 45, + "current_value": 30.0, + "target_value": 30.0, + "last_value": 0.0, + "unit": "min", + "step_value": 5.0, + "editable": 1, + "type": 88, + "state": 1, + "last_changed": 1672086680, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 9, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 24, + "current_value": 1.6, + "target_value": 1.6, + "last_value": 0.0, + "unit": "s", + "step_value": 0.1, + "editable": 1, + "type": 114, + "state": 1, + "last_changed": 1615396156, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 10, + "node_id": 1, + "instance": 0, + "minimum": -127, + "maximum": 127, + "current_value": 75.0, + "target_value": 75.0, + "last_value": 0.0, + "unit": "°", + "step_value": 1.0, + "editable": 1, + "type": 323, + "state": 1, + "last_changed": 1615396156, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 11, + "node_id": 1, + "instance": 0, + "minimum": -127, + "maximum": 127, + "current_value": -75.0, + "target_value": -75.0, + "last_value": 0.0, + "unit": "°", + "step_value": 1.0, + "editable": 1, + "type": 322, + "state": 1, + "last_changed": 1615396156, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 12, + "node_id": 1, + "instance": 0, + "minimum": 1, + "maximum": 20, + "current_value": 6.0, + "target_value": 6.0, + "last_value": 1.0, + "unit": "n/a", + "step_value": 1.0, + "editable": 1, + "type": 174, + "state": 1, + "last_changed": 1672149083, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 13, + "node_id": 1, + "instance": 0, + "minimum": -5, + "maximum": 128, + "current_value": -3, + "target_value": -3, + "last_value": 128.0, + "unit": "°C", + "step_value": 0.1, + "editable": 1, + "type": 64, + "state": 6, + "last_changed": 1711799534, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 14, + "node_id": 1, + "instance": 0, + "minimum": 4, + "maximum": 240, + "current_value": 57.0, + "target_value": 57.0, + "last_value": 90.0, + "unit": "s", + "step_value": 1.0, + "editable": 1, + "type": 110, + "state": 1, + "last_changed": 1615396246, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 15, + "node_id": 1, + "instance": 0, + "minimum": 30, + "maximum": 7200, + "current_value": 600.0, + "target_value": 600.0, + "last_value": 600.0, + "unit": "min", + "step_value": 30.0, + "editable": 1, + "type": 29, + "state": 1, + "last_changed": 1739333970, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 16, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 240, + "current_value": 12.0, + "target_value": 12.0, + "last_value": 12.0, + "unit": "h", + "step_value": 1.0, + "editable": 0, + "type": 29, + "state": 1, + "last_changed": 1735964135, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "fixed_value", + "name": "" + } + ] +} diff --git a/tests/components/homee/snapshots/test_number.ambr b/tests/components/homee/snapshots/test_number.ambr new file mode 100644 index 00000000000..04b1aefab00 --- /dev/null +++ b/tests/components/homee/snapshots/test_number.ambr @@ -0,0 +1,802 @@ +# serializer version: 1 +# name: test_number_snapshot[number.test_number_down_movement_duration-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 240, + 'min': 4, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_number_down_movement_duration', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Down-movement duration', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'down_time', + 'unique_id': '00055511EECC-1-4', + 'unit_of_measurement': , + }) +# --- +# name: test_number_snapshot[number.test_number_down_movement_duration-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Test Number Down-movement duration', + 'max': 240, + 'min': 4, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.test_number_down_movement_duration', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '57', + }) +# --- +# name: test_number_snapshot[number.test_number_down_position-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 100, + 'min': 0, + 'mode': , + 'step': 0.5, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_number_down_position', + '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': 'Down position', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'down_position', + 'unique_id': '00055511EECC-1-2', + 'unit_of_measurement': '%', + }) +# --- +# name: test_number_snapshot[number.test_number_down_position-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Number Down position', + 'max': 100, + 'min': 0, + 'mode': , + 'step': 0.5, + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'number.test_number_down_position', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- +# name: test_number_snapshot[number.test_number_down_slat_position-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 75, + 'min': -75, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_number_down_slat_position', + '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': 'Down slat position', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'down_slat_position', + 'unique_id': '00055511EECC-1-3', + 'unit_of_measurement': '°', + }) +# --- +# name: test_number_snapshot[number.test_number_down_slat_position-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Number Down slat position', + 'max': 75, + 'min': -75, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': '°', + }), + 'context': , + 'entity_id': 'number.test_number_down_slat_position', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '38', + }) +# --- +# name: test_number_snapshot[number.test_number_end_position-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 130, + 'min': 0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_number_end_position', + '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': 'End position', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'endposition_configuration', + 'unique_id': '00055511EECC-1-5', + 'unit_of_measurement': None, + }) +# --- +# name: test_number_snapshot[number.test_number_end_position-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Number End position', + 'max': 130, + 'min': 0, + 'mode': , + 'step': 1.0, + }), + 'context': , + 'entity_id': 'number.test_number_end_position', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '129', + }) +# --- +# name: test_number_snapshot[number.test_number_maximum_slat_angle-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 127, + 'min': -127, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_number_maximum_slat_angle', + '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': 'Maximum slat angle', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'slat_max_angle', + 'unique_id': '00055511EECC-1-10', + 'unit_of_measurement': '°', + }) +# --- +# name: test_number_snapshot[number.test_number_maximum_slat_angle-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Number Maximum slat angle', + 'max': 127, + 'min': -127, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': '°', + }), + 'context': , + 'entity_id': 'number.test_number_maximum_slat_angle', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '75', + }) +# --- +# name: test_number_snapshot[number.test_number_minimum_slat_angle-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 127, + 'min': -127, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_number_minimum_slat_angle', + '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': 'Minimum slat angle', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'slat_min_angle', + 'unique_id': '00055511EECC-1-11', + 'unit_of_measurement': '°', + }) +# --- +# name: test_number_snapshot[number.test_number_minimum_slat_angle-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Number Minimum slat angle', + 'max': 127, + 'min': -127, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': '°', + }), + 'context': , + 'entity_id': 'number.test_number_minimum_slat_angle', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-75', + }) +# --- +# name: test_number_snapshot[number.test_number_motion_alarm_delay-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 15300, + 'min': 1, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_number_motion_alarm_delay', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Motion alarm delay', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'motion_alarm_cancelation_delay', + 'unique_id': '00055511EECC-1-6', + 'unit_of_measurement': , + }) +# --- +# name: test_number_snapshot[number.test_number_motion_alarm_delay-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Test Number Motion alarm delay', + 'max': 15300, + 'min': 1, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.test_number_motion_alarm_delay', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_number_snapshot[number.test_number_polling_interval-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 45, + 'min': 5, + 'mode': , + 'step': 5.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_number_polling_interval', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Polling interval', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'polling_interval', + 'unique_id': '00055511EECC-1-8', + 'unit_of_measurement': , + }) +# --- +# name: test_number_snapshot[number.test_number_polling_interval-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Test Number Polling interval', + 'max': 45, + 'min': 5, + 'mode': , + 'step': 5.0, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.test_number_polling_interval', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '30', + }) +# --- +# name: test_number_snapshot[number.test_number_slat_steps-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 20, + 'min': 1, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_number_slat_steps', + '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': 'Slat steps', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'slat_steps', + 'unique_id': '00055511EECC-1-12', + 'unit_of_measurement': None, + }) +# --- +# name: test_number_snapshot[number.test_number_slat_steps-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Number Slat steps', + 'max': 20, + 'min': 1, + 'mode': , + 'step': 1.0, + }), + 'context': , + 'entity_id': 'number.test_number_slat_steps', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '6', + }) +# --- +# name: test_number_snapshot[number.test_number_slat_turn_duration-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 24, + 'min': 0, + 'mode': , + 'step': 0.1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_number_slat_turn_duration', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Slat turn duration', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'shutter_slat_time', + 'unique_id': '00055511EECC-1-9', + 'unit_of_measurement': , + }) +# --- +# name: test_number_snapshot[number.test_number_slat_turn_duration-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Test Number Slat turn duration', + 'max': 24, + 'min': 0, + 'mode': , + 'step': 0.1, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.test_number_slat_turn_duration', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1', + }) +# --- +# name: test_number_snapshot[number.test_number_temperature_offset-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 128, + 'min': -5, + 'mode': , + 'step': 0.1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_number_temperature_offset', + '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': 'Temperature offset', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'temperature_offset', + 'unique_id': '00055511EECC-1-13', + 'unit_of_measurement': , + }) +# --- +# name: test_number_snapshot[number.test_number_temperature_offset-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Number Temperature offset', + 'max': 128, + 'min': -5, + 'mode': , + 'step': 0.1, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.test_number_temperature_offset', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_number_snapshot[number.test_number_up_movement_duration-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 240, + 'min': 4, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_number_up_movement_duration', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Up-movement duration', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'up_time', + 'unique_id': '00055511EECC-1-14', + 'unit_of_measurement': , + }) +# --- +# name: test_number_snapshot[number.test_number_up_movement_duration-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Test Number Up-movement duration', + 'max': 240, + 'min': 4, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.test_number_up_movement_duration', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '57', + }) +# --- +# name: test_number_snapshot[number.test_number_wake_up_interval-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 7200, + 'min': 30, + 'mode': , + 'step': 30.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_number_wake_up_interval', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wake-up interval', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wake_up_interval', + 'unique_id': '00055511EECC-1-15', + 'unit_of_measurement': , + }) +# --- +# name: test_number_snapshot[number.test_number_wake_up_interval-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Test Number Wake-up interval', + 'max': 7200, + 'min': 30, + 'mode': , + 'step': 30.0, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.test_number_wake_up_interval', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '600', + }) +# --- +# name: test_number_snapshot[number.test_number_window_open_sensibility-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 3, + 'min': 0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_number_window_open_sensibility', + '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': 'Window open sensibility', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'open_window_detection_sensibility', + 'unique_id': '00055511EECC-1-7', + 'unit_of_measurement': None, + }) +# --- +# name: test_number_snapshot[number.test_number_window_open_sensibility-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Number Window open sensibility', + 'max': 3, + 'min': 0, + 'mode': , + 'step': 1.0, + }), + 'context': , + 'entity_id': 'number.test_number_window_open_sensibility', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3', + }) +# --- diff --git a/tests/components/homee/test_number.py b/tests/components/homee/test_number.py new file mode 100644 index 00000000000..73ca707c2d5 --- /dev/null +++ b/tests/components/homee/test_number.py @@ -0,0 +1,74 @@ +"""Test Homee nmumbers.""" + +from unittest.mock import MagicMock, patch + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.number import ( + ATTR_VALUE, + DOMAIN as NUMBER_DOMAIN, + SERVICE_SET_VALUE, +) +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import build_mock_node, setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +async def test_set_value( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test set_value service.""" + mock_homee.nodes = [build_mock_node("numbers.json")] + mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: "number.test_number_down_position", ATTR_VALUE: 90}, + blocking=True, + ) + number = mock_homee.nodes[0].attributes[0] + mock_homee.set_value.assert_called_once_with(number.node_id, number.id, 90) + + +async def test_set_value_not_editable( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test set_value if attribute is not editable.""" + mock_homee.nodes = [build_mock_node("numbers.json")] + mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: "number.test_number_motion_alarm_delay", ATTR_VALUE: 10000}, + blocking=True, + ) + assert not mock_homee.set_value.called + assert not hass.states.async_available("number.test_number_motion_alarm_delay") + + +async def test_number_snapshot( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test the multisensor snapshot.""" + mock_homee.nodes = [build_mock_node("numbers.json")] + mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] + with patch("homeassistant.components.homee.PLATFORMS", [Platform.NUMBER]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) From e99bf21a36d58da9024960159b99bfc80fe1b861 Mon Sep 17 00:00:00 2001 From: Matrix Date: Tue, 25 Feb 2025 22:51:21 +0800 Subject: [PATCH 1784/3148] Fix yolink lock v2 state update (#138710) --- homeassistant/components/yolink/lock.py | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/yolink/lock.py b/homeassistant/components/yolink/lock.py index 135d0e26d04..5e244dd08f2 100644 --- a/homeassistant/components/yolink/lock.py +++ b/homeassistant/components/yolink/lock.py @@ -51,15 +51,16 @@ class YoLinkLockEntity(YoLinkEntity, LockEntity): def update_entity_state(self, state: dict[str, Any]) -> None: """Update HA Entity State.""" state_value = state.get("state") - if self.coordinator.device.device_type == ATTR_DEVICE_LOCK_V2: - self._attr_is_locked = ( - state_value["lock"] == "locked" if state_value is not None else None - ) - else: - self._attr_is_locked = ( - state_value == "locked" if state_value is not None else None - ) - self.async_write_ha_state() + if state_value is not None: + if self.coordinator.device.device_type == ATTR_DEVICE_LOCK_V2: + self._attr_is_locked = ( + state_value["lock"] == "locked" if state_value is not None else None + ) + else: + self._attr_is_locked = ( + state_value == "locked" if state_value is not None else None + ) + self.async_write_ha_state() async def call_lock_state_change(self, state: str) -> None: """Call setState api to change lock state.""" @@ -69,7 +70,7 @@ class YoLinkLockEntity(YoLinkEntity, LockEntity): ) else: await self.call_device(ClientRequest("setState", {"state": state})) - self._attr_is_locked = state == "lock" + self._attr_is_locked = state in ["locked", "lock"] self.async_write_ha_state() async def async_lock(self, **kwargs: Any) -> None: From f96e31fad851d8ab61f75695ff83ba0ea0f5092f Mon Sep 17 00:00:00 2001 From: elmurato <1382097+elmurato@users.noreply.github.com> Date: Tue, 25 Feb 2025 15:51:43 +0100 Subject: [PATCH 1785/3148] Set Minecraft Server quality scale to silver (#139265) --- homeassistant/components/minecraft_server/manifest.json | 1 + homeassistant/components/minecraft_server/quality_scale.yaml | 2 +- script/hassfest/quality_scale.py | 1 - 3 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/minecraft_server/manifest.json b/homeassistant/components/minecraft_server/manifest.json index d6ade4853c9..be399a3c8dc 100644 --- a/homeassistant/components/minecraft_server/manifest.json +++ b/homeassistant/components/minecraft_server/manifest.json @@ -6,5 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/minecraft_server", "iot_class": "local_polling", "loggers": ["dnspython", "mcstatus"], + "quality_scale": "silver", "requirements": ["mcstatus==11.1.1"] } diff --git a/homeassistant/components/minecraft_server/quality_scale.yaml b/homeassistant/components/minecraft_server/quality_scale.yaml index 61a975632bb..288e58fad39 100644 --- a/homeassistant/components/minecraft_server/quality_scale.yaml +++ b/homeassistant/components/minecraft_server/quality_scale.yaml @@ -14,7 +14,7 @@ rules: comment: Integration doesn't provide any service actions. docs-high-level-description: done docs-installation-instructions: done - docs-removal-instructions: todo + docs-removal-instructions: done entity-event-setup: status: done comment: Handled by coordinator. diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index d155cc74acb..5f90fff81d5 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -1722,7 +1722,6 @@ INTEGRATIONS_WITHOUT_SCALE = [ "mikrotik", "mill", "min_max", - "minecraft_server", "minio", "mjpeg", "moat", From 1fb51ef1891555fa864ef23b7286dc53920c9abe Mon Sep 17 00:00:00 2001 From: Andrew <34544450+10100011@users.noreply.github.com> Date: Tue, 25 Feb 2025 14:54:10 +0000 Subject: [PATCH 1786/3148] Add OpenWeatherMap Minute forecast action (#128799) --- .../components/openweathermap/const.py | 1 + .../components/openweathermap/coordinator.py | 24 ++++ .../components/openweathermap/icons.json | 7 + .../components/openweathermap/services.yaml | 5 + .../components/openweathermap/strings.json | 11 ++ .../components/openweathermap/weather.py | 27 +++- .../snapshots/test_weather.ambr | 25 ++++ .../openweathermap/test_config_flow.py | 30 +++-- .../components/openweathermap/test_weather.py | 121 ++++++++++++++++++ 9 files changed, 240 insertions(+), 11 deletions(-) create mode 100644 homeassistant/components/openweathermap/icons.json create mode 100644 homeassistant/components/openweathermap/services.yaml create mode 100644 tests/components/openweathermap/snapshots/test_weather.ambr create mode 100644 tests/components/openweathermap/test_weather.py diff --git a/homeassistant/components/openweathermap/const.py b/homeassistant/components/openweathermap/const.py index 81a6544c7ce..de317709f5b 100644 --- a/homeassistant/components/openweathermap/const.py +++ b/homeassistant/components/openweathermap/const.py @@ -48,6 +48,7 @@ ATTR_API_WEATHER_CODE = "weather_code" ATTR_API_CLOUD_COVERAGE = "cloud_coverage" ATTR_API_FORECAST = "forecast" ATTR_API_CURRENT = "current" +ATTR_API_MINUTE_FORECAST = "minute_forecast" ATTR_API_HOURLY_FORECAST = "hourly_forecast" ATTR_API_DAILY_FORECAST = "daily_forecast" UPDATE_LISTENER = "update_listener" diff --git a/homeassistant/components/openweathermap/coordinator.py b/homeassistant/components/openweathermap/coordinator.py index 55c1aa469c2..994949b5e03 100644 --- a/homeassistant/components/openweathermap/coordinator.py +++ b/homeassistant/components/openweathermap/coordinator.py @@ -10,6 +10,7 @@ from pyopenweathermap import ( CurrentWeather, DailyWeatherForecast, HourlyWeatherForecast, + MinutelyWeatherForecast, OWMClient, RequestError, WeatherReport, @@ -34,10 +35,14 @@ from .const import ( ATTR_API_CONDITION, ATTR_API_CURRENT, ATTR_API_DAILY_FORECAST, + ATTR_API_DATETIME, ATTR_API_DEW_POINT, ATTR_API_FEELS_LIKE_TEMPERATURE, + ATTR_API_FORECAST, ATTR_API_HOURLY_FORECAST, ATTR_API_HUMIDITY, + ATTR_API_MINUTE_FORECAST, + ATTR_API_PRECIPITATION, ATTR_API_PRECIPITATION_KIND, ATTR_API_PRESSURE, ATTR_API_RAIN, @@ -106,6 +111,11 @@ class WeatherUpdateCoordinator(DataUpdateCoordinator): return { ATTR_API_CURRENT: current_weather, + ATTR_API_MINUTE_FORECAST: ( + self._get_minute_weather_data(weather_report.minutely_forecast) + if weather_report.minutely_forecast is not None + else {} + ), ATTR_API_HOURLY_FORECAST: [ self._get_hourly_forecast_weather_data(item) for item in weather_report.hourly_forecast @@ -116,6 +126,20 @@ class WeatherUpdateCoordinator(DataUpdateCoordinator): ], } + def _get_minute_weather_data( + self, minute_forecast: list[MinutelyWeatherForecast] + ) -> dict: + """Get minute weather data from the forecast.""" + return { + ATTR_API_FORECAST: [ + { + ATTR_API_DATETIME: item.date_time, + ATTR_API_PRECIPITATION: round(item.precipitation, 2), + } + for item in minute_forecast + ] + } + def _get_current_weather_data(self, current_weather: CurrentWeather): return { ATTR_API_CONDITION: self._get_condition(current_weather.condition.id), diff --git a/homeassistant/components/openweathermap/icons.json b/homeassistant/components/openweathermap/icons.json new file mode 100644 index 00000000000..d493b1538ba --- /dev/null +++ b/homeassistant/components/openweathermap/icons.json @@ -0,0 +1,7 @@ +{ + "services": { + "get_minute_forecast": { + "service": "mdi:weather-snowy-rainy" + } + } +} diff --git a/homeassistant/components/openweathermap/services.yaml b/homeassistant/components/openweathermap/services.yaml new file mode 100644 index 00000000000..6bbcf1b23e4 --- /dev/null +++ b/homeassistant/components/openweathermap/services.yaml @@ -0,0 +1,5 @@ +get_minute_forecast: + target: + entity: + domain: weather + integration: openweathermap diff --git a/homeassistant/components/openweathermap/strings.json b/homeassistant/components/openweathermap/strings.json index 46b5feab75c..0692087bc23 100644 --- a/homeassistant/components/openweathermap/strings.json +++ b/homeassistant/components/openweathermap/strings.json @@ -47,5 +47,16 @@ } } } + }, + "services": { + "get_minute_forecast": { + "name": "Get minute forecast", + "description": "Get minute weather forecast." + } + }, + "exceptions": { + "service_minute_forecast_mode": { + "message": "Minute forecast is available only when {name} mode is set to v3.0" + } } } diff --git a/homeassistant/components/openweathermap/weather.py b/homeassistant/components/openweathermap/weather.py index 43e9c0a868a..a6ad163e1c8 100644 --- a/homeassistant/components/openweathermap/weather.py +++ b/homeassistant/components/openweathermap/weather.py @@ -14,7 +14,9 @@ from homeassistant.const import ( UnitOfSpeed, UnitOfTemperature, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant, SupportsResponse, callback +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import entity_platform from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -28,6 +30,7 @@ from .const import ( ATTR_API_FEELS_LIKE_TEMPERATURE, ATTR_API_HOURLY_FORECAST, ATTR_API_HUMIDITY, + ATTR_API_MINUTE_FORECAST, ATTR_API_PRESSURE, ATTR_API_TEMPERATURE, ATTR_API_VISIBILITY_DISTANCE, @@ -44,6 +47,8 @@ from .const import ( ) from .coordinator import WeatherUpdateCoordinator +SERVICE_GET_MINUTE_FORECAST = "get_minute_forecast" + async def async_setup_entry( hass: HomeAssistant, @@ -61,6 +66,14 @@ async def async_setup_entry( async_add_entities([owm_weather], False) + platform = entity_platform.async_get_current_platform() + platform.async_register_entity_service( + name=SERVICE_GET_MINUTE_FORECAST, + schema=None, + func="async_get_minute_forecast", + supports_response=SupportsResponse.ONLY, + ) + class OpenWeatherMapWeather(SingleCoordinatorWeatherEntity[WeatherUpdateCoordinator]): """Implementation of an OpenWeatherMap sensor.""" @@ -91,6 +104,7 @@ class OpenWeatherMapWeather(SingleCoordinatorWeatherEntity[WeatherUpdateCoordina manufacturer=MANUFACTURER, name=DEFAULT_NAME, ) + self.mode = mode if mode in (OWM_MODE_V30, OWM_MODE_V25): self._attr_supported_features = ( @@ -100,6 +114,17 @@ class OpenWeatherMapWeather(SingleCoordinatorWeatherEntity[WeatherUpdateCoordina elif mode == OWM_MODE_FREE_FORECAST: self._attr_supported_features = WeatherEntityFeature.FORECAST_HOURLY + async def async_get_minute_forecast(self) -> dict[str, list[dict]] | dict: + """Return Minute forecast.""" + + if self.mode == OWM_MODE_V30: + return self.coordinator.data[ATTR_API_MINUTE_FORECAST] + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="service_minute_forecast_mode", + translation_placeholders={"name": DEFAULT_NAME}, + ) + @property def condition(self) -> str | None: """Return the current condition.""" diff --git a/tests/components/openweathermap/snapshots/test_weather.ambr b/tests/components/openweathermap/snapshots/test_weather.ambr new file mode 100644 index 00000000000..c89dcb96a9c --- /dev/null +++ b/tests/components/openweathermap/snapshots/test_weather.ambr @@ -0,0 +1,25 @@ +# serializer version: 1 +# name: test_get_minute_forecast[mock_service_response] + dict({ + 'weather.openweathermap': dict({ + 'forecast': list([ + dict({ + 'datetime': 1728672360, + 'precipitation': 0, + }), + dict({ + 'datetime': 1728672420, + 'precipitation': 1.23, + }), + dict({ + 'datetime': 1728672480, + 'precipitation': 4.5, + }), + dict({ + 'datetime': 1728672540, + 'precipitation': 0, + }), + ]), + }), + }) +# --- diff --git a/tests/components/openweathermap/test_config_flow.py b/tests/components/openweathermap/test_config_flow.py index aec34360754..d5e01677dd8 100644 --- a/tests/components/openweathermap/test_config_flow.py +++ b/tests/components/openweathermap/test_config_flow.py @@ -18,7 +18,7 @@ from homeassistant.components.openweathermap.const import ( DEFAULT_LANGUAGE, DEFAULT_OWM_MODE, DOMAIN, - OWM_MODE_V25, + OWM_MODE_V30, ) from homeassistant.config_entries import SOURCE_USER, ConfigEntryState from homeassistant.const import ( @@ -40,13 +40,15 @@ CONFIG = { CONF_LATITUDE: 50, CONF_LONGITUDE: 40, CONF_LANGUAGE: DEFAULT_LANGUAGE, - CONF_MODE: OWM_MODE_V25, + CONF_MODE: OWM_MODE_V30, } VALID_YAML_CONFIG = {CONF_API_KEY: "foo"} -def _create_mocked_owm_factory(is_valid: bool): +def _create_static_weather_report() -> WeatherReport: + """Create a static WeatherReport.""" + current_weather = CurrentWeather( date_time=datetime.fromtimestamp(1714063536, tz=UTC), temperature=6.84, @@ -60,8 +62,8 @@ def _create_mocked_owm_factory(is_valid: bool): wind_speed=9.83, wind_bearing=199, wind_gust=None, - rain={}, - snow={}, + rain={"1h": 1.21}, + snow=None, condition=WeatherCondition( id=803, main="Clouds", @@ -106,13 +108,21 @@ def _create_mocked_owm_factory(is_valid: bool): rain=0, snow=0, ) - minutely_weather_forecast = MinutelyWeatherForecast( - date_time=1728672360, precipitation=2.54 - ) - weather_report = WeatherReport( - current_weather, [minutely_weather_forecast], [], [daily_weather_forecast] + minutely_weather_forecast = [ + MinutelyWeatherForecast(date_time=1728672360, precipitation=0), + MinutelyWeatherForecast(date_time=1728672420, precipitation=1.23), + MinutelyWeatherForecast(date_time=1728672480, precipitation=4.5), + MinutelyWeatherForecast(date_time=1728672540, precipitation=0), + ] + return WeatherReport( + current_weather, minutely_weather_forecast, [], [daily_weather_forecast] ) + +def _create_mocked_owm_factory(is_valid: bool): + """Create a mocked OWM client.""" + + weather_report = _create_static_weather_report() mocked_owm_client = MagicMock() mocked_owm_client.validate_key = AsyncMock(return_value=is_valid) mocked_owm_client.get_weather = AsyncMock(return_value=weather_report) diff --git a/tests/components/openweathermap/test_weather.py b/tests/components/openweathermap/test_weather.py new file mode 100644 index 00000000000..5d3565d6ca9 --- /dev/null +++ b/tests/components/openweathermap/test_weather.py @@ -0,0 +1,121 @@ +"""Test the OpenWeatherMap weather entity.""" + +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.openweathermap.const import ( + DEFAULT_LANGUAGE, + DOMAIN, + OWM_MODE_V25, + OWM_MODE_V30, +) +from homeassistant.components.openweathermap.weather import SERVICE_GET_MINUTE_FORECAST +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ( + CONF_API_KEY, + CONF_LANGUAGE, + CONF_LATITUDE, + CONF_LONGITUDE, + CONF_MODE, + CONF_NAME, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError + +from .test_config_flow import _create_static_weather_report + +from tests.common import AsyncMock, MockConfigEntry, patch + +ENTITY_ID = "weather.openweathermap" +API_KEY = "test_api_key" +LATITUDE = 12.34 +LONGITUDE = 56.78 +NAME = "openweathermap" + +# Define test data for mocked weather report +static_weather_report = _create_static_weather_report() + + +def mock_config_entry(mode: str) -> MockConfigEntry: + """Create a mock OpenWeatherMap config entry.""" + return MockConfigEntry( + domain=DOMAIN, + data={ + CONF_API_KEY: API_KEY, + CONF_LATITUDE: LATITUDE, + CONF_LONGITUDE: LONGITUDE, + CONF_NAME: NAME, + }, + options={CONF_MODE: mode, CONF_LANGUAGE: DEFAULT_LANGUAGE}, + version=5, + ) + + +@pytest.fixture +def mock_config_entry_v25() -> MockConfigEntry: + """Create a mock OpenWeatherMap v2.5 config entry.""" + return mock_config_entry(OWM_MODE_V25) + + +@pytest.fixture +def mock_config_entry_v30() -> MockConfigEntry: + """Create a mock OpenWeatherMap v3.0 config entry.""" + return mock_config_entry(OWM_MODE_V30) + + +async def setup_mock_config_entry( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +): + """Set up the MockConfigEntry and assert it is loaded correctly.""" + mock_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert hass.states.get(ENTITY_ID) + assert mock_config_entry.state is ConfigEntryState.LOADED + + +@patch( + "pyopenweathermap.client.onecall_client.OWMOneCallClient.get_weather", + AsyncMock(return_value=static_weather_report), +) +async def test_get_minute_forecast( + hass: HomeAssistant, + mock_config_entry_v30: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test the get_minute_forecast Service call.""" + await setup_mock_config_entry(hass, mock_config_entry_v30) + + result = await hass.services.async_call( + DOMAIN, + SERVICE_GET_MINUTE_FORECAST, + {"entity_id": ENTITY_ID}, + blocking=True, + return_response=True, + ) + assert result == snapshot(name="mock_service_response") + + +@patch( + "pyopenweathermap.client.onecall_client.OWMOneCallClient.get_weather", + AsyncMock(return_value=static_weather_report), +) +async def test_mode_fail( + hass: HomeAssistant, + mock_config_entry_v25: MockConfigEntry, +) -> None: + """Test that Minute forecasting fails when mode is not v3.0.""" + await setup_mock_config_entry(hass, mock_config_entry_v25) + + # Expect a ServiceValidationError when mode is not OWM_MODE_V30 + with pytest.raises( + ServiceValidationError, + match="Minute forecast is available only when OpenWeatherMap mode is set to v3.0", + ): + await hass.services.async_call( + DOMAIN, + SERVICE_GET_MINUTE_FORECAST, + {"entity_id": ENTITY_ID}, + blocking=True, + return_response=True, + ) From 47e78e9008d6b0b4c880d21376009f31f309c213 Mon Sep 17 00:00:00 2001 From: Renier Moorcroft <66512715+RenierM26@users.noreply.github.com> Date: Tue, 25 Feb 2025 16:55:31 +0200 Subject: [PATCH 1787/3148] Fix Ezviz entity state for cameras that are offline (#136003) --- homeassistant/components/ezviz/camera.py | 5 ----- homeassistant/components/ezviz/entity.py | 10 ++++++++++ homeassistant/components/ezviz/image.py | 6 ++++++ 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/ezviz/camera.py b/homeassistant/components/ezviz/camera.py index 54879fd6a9b..e3d01bef83e 100644 --- a/homeassistant/components/ezviz/camera.py +++ b/homeassistant/components/ezviz/camera.py @@ -141,11 +141,6 @@ class EzvizCamera(EzvizEntity, Camera): if camera_password: self._attr_supported_features = CameraEntityFeature.STREAM - @property - def available(self) -> bool: - """Return True if entity is available.""" - return self.data["status"] != 2 - @property def is_on(self) -> bool: """Return true if on.""" diff --git a/homeassistant/components/ezviz/entity.py b/homeassistant/components/ezviz/entity.py index 44de4a0c9c7..54614e4899a 100644 --- a/homeassistant/components/ezviz/entity.py +++ b/homeassistant/components/ezviz/entity.py @@ -42,6 +42,11 @@ class EzvizEntity(CoordinatorEntity[EzvizDataUpdateCoordinator], Entity): """Return coordinator data for this entity.""" return self.coordinator.data[self._serial] + @property + def available(self) -> bool: + """Return True if entity is available.""" + return self.data["status"] != 2 + class EzvizBaseEntity(Entity): """Generic entity for EZVIZ individual poll entities.""" @@ -72,3 +77,8 @@ class EzvizBaseEntity(Entity): def data(self) -> dict[str, Any]: """Return coordinator data for this entity.""" return self.coordinator.data[self._serial] + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return self.data["status"] != 2 diff --git a/homeassistant/components/ezviz/image.py b/homeassistant/components/ezviz/image.py index f335406a367..ea032a8ec00 100644 --- a/homeassistant/components/ezviz/image.py +++ b/homeassistant/components/ezviz/image.py @@ -4,6 +4,7 @@ from __future__ import annotations import logging +from propcache import cached_property from pyezviz.exceptions import PyEzvizError from pyezviz.utils import decrypt_image @@ -62,6 +63,11 @@ class EzvizLastMotion(EzvizEntity, ImageEntity): else None ) + @cached_property + def available(self) -> bool: + """Entity gets data from ezviz API so always available.""" + return True + async def _async_load_image_from_url(self, url: str) -> Image | None: """Load an image by url.""" if response := await self._fetch_url(url): From 72502c1a151e0268abfb3363d4385f76ac3adc06 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 25 Feb 2025 16:09:15 +0100 Subject: [PATCH 1788/3148] Use proper camel-case for "VeSync", fix sentence-casing in title (#139252) Just a quick follow-up PR to fix these two spelling mistakes. --- homeassistant/components/vesync/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/vesync/strings.json b/homeassistant/components/vesync/strings.json index 89f401da92f..eabb2969580 100644 --- a/homeassistant/components/vesync/strings.json +++ b/homeassistant/components/vesync/strings.json @@ -2,7 +2,7 @@ "config": { "step": { "user": { - "title": "Enter Username and Password", + "title": "Enter username and password", "data": { "username": "[%key:common::config_flow::data::email%]", "password": "[%key:common::config_flow::data::password%]" @@ -10,7 +10,7 @@ }, "reauth_confirm": { "title": "[%key:common::config_flow::title::reauth%]", - "description": "The vesync integration needs to re-authenticate your account", + "description": "The VeSync integration needs to re-authenticate your account", "data": { "username": "[%key:common::config_flow::data::email%]", "password": "[%key:common::config_flow::data::password%]" From f607b95c00b6ee8b0a9dfb7cd1a38251d5d83439 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Tue, 25 Feb 2025 16:27:18 +0100 Subject: [PATCH 1789/3148] Add request made by `rest_command` to debug log (#139266) --- homeassistant/components/rest_command/__init__.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/homeassistant/components/rest_command/__init__.py b/homeassistant/components/rest_command/__init__.py index f4c84bf72b5..c6a4206de4a 100644 --- a/homeassistant/components/rest_command/__init__.py +++ b/homeassistant/components/rest_command/__init__.py @@ -146,6 +146,14 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: if content_type: headers[hdrs.CONTENT_TYPE] = content_type + _LOGGER.debug( + "Calling %s %s with headers: %s and payload: %s", + method, + request_url, + headers, + payload, + ) + try: async with getattr(websession, method)( request_url, From 27f7085b610936b7495844f70b7d268424111d5b Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Tue, 25 Feb 2025 16:27:56 +0100 Subject: [PATCH 1790/3148] Create repair for configured unavailable backup agents (#137382) * Create repair for configured not loaded agents * Rework to repair issue * Extract logic to config function * Update test * Handle empty agend ids config update * Address review comment * Update tests * Address comment --- homeassistant/components/backup/config.py | 51 +++++- homeassistant/components/backup/manager.py | 9 + homeassistant/components/backup/strings.json | 4 + tests/components/backup/test_manager.py | 19 +- tests/components/backup/test_websocket.py | 182 +++++++++++++++++++ 5 files changed, 262 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/backup/config.py b/homeassistant/components/backup/config.py index 65f9f4789a6..f4fa2e8bac6 100644 --- a/homeassistant/components/backup/config.py +++ b/homeassistant/components/backup/config.py @@ -12,16 +12,19 @@ from typing import TYPE_CHECKING, Self, TypedDict from cronsim import CronSim from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.event import async_call_later, async_track_point_in_time from homeassistant.helpers.typing import UNDEFINED, UndefinedType from homeassistant.util import dt as dt_util -from .const import LOGGER +from .const import DOMAIN, LOGGER from .models import BackupManagerError, Folder if TYPE_CHECKING: from .manager import BackupManager, ManagerBackup +AUTOMATIC_BACKUP_AGENTS_UNAVAILABLE_ISSUE_ID = "automatic_backup_agents_unavailable" + CRON_PATTERN_DAILY = "{m} {h} * * *" CRON_PATTERN_WEEKLY = "{m} {h} * * {d}" @@ -151,6 +154,7 @@ class BackupConfig: retention=RetentionConfig(), schedule=BackupSchedule(), ) + self._hass = hass self._manager = manager def load(self, stored_config: StoredBackupConfig) -> None: @@ -182,6 +186,8 @@ class BackupConfig: self.data.automatic_backups_configured = automatic_backups_configured if create_backup is not UNDEFINED: self.data.create_backup = replace(self.data.create_backup, **create_backup) + if "agent_ids" in create_backup: + check_unavailable_agents(self._hass, self._manager) if retention is not UNDEFINED: new_retention = RetentionConfig(**retention) if new_retention != self.data.retention: @@ -562,3 +568,46 @@ async def delete_backups_exceeding_configured_count(manager: BackupManager) -> N await manager.async_delete_filtered_backups( include_filter=_automatic_backups_filter, delete_filter=_delete_filter ) + + +@callback +def check_unavailable_agents(hass: HomeAssistant, manager: BackupManager) -> None: + """Check for unavailable agents.""" + if missing_agent_ids := set(manager.config.data.create_backup.agent_ids) - set( + manager.backup_agents + ): + LOGGER.debug( + "Agents %s are configured for automatic backup but are unavailable", + missing_agent_ids, + ) + + # Remove issues for unavailable agents that are not unavailable anymore. + issue_registry = ir.async_get(hass) + existing_missing_agent_issue_ids = { + issue_id + for domain, issue_id in issue_registry.issues + if domain == DOMAIN + and issue_id.startswith(AUTOMATIC_BACKUP_AGENTS_UNAVAILABLE_ISSUE_ID) + } + current_missing_agent_issue_ids = { + f"{AUTOMATIC_BACKUP_AGENTS_UNAVAILABLE_ISSUE_ID}_{agent_id}": agent_id + for agent_id in missing_agent_ids + } + for issue_id in existing_missing_agent_issue_ids - set( + current_missing_agent_issue_ids + ): + ir.async_delete_issue(hass, DOMAIN, issue_id) + for issue_id, agent_id in current_missing_agent_issue_ids.items(): + ir.async_create_issue( + hass, + DOMAIN, + issue_id, + is_fixable=False, + learn_more_url="homeassistant://config/backup", + severity=ir.IssueSeverity.WARNING, + translation_key="automatic_backup_agents_unavailable", + translation_placeholders={ + "agent_id": agent_id, + "backup_settings": "/config/backup/settings", + }, + ) diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index 3bf31618b24..bd970d7708a 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -32,6 +32,7 @@ from homeassistant.helpers import ( instance_id, integration_platform, issue_registry as ir, + start, ) from homeassistant.helpers.backup import DATA_BACKUP from homeassistant.helpers.json import json_bytes @@ -47,6 +48,7 @@ from .agent import ( from .config import ( BackupConfig, CreateBackupParametersDict, + check_unavailable_agents, delete_backups_exceeding_configured_count, ) from .const import ( @@ -417,6 +419,13 @@ class BackupManager: } ) + @callback + def check_unavailable_agents_after_start(hass: HomeAssistant) -> None: + """Check unavailable agents after start.""" + check_unavailable_agents(hass, self) + + start.async_at_started(self.hass, check_unavailable_agents_after_start) + async def _add_platform( self, hass: HomeAssistant, diff --git a/homeassistant/components/backup/strings.json b/homeassistant/components/backup/strings.json index 32d76ded049..c3047d3a4ac 100644 --- a/homeassistant/components/backup/strings.json +++ b/homeassistant/components/backup/strings.json @@ -1,5 +1,9 @@ { "issues": { + "automatic_backup_agents_unavailable": { + "title": "The backup location {agent_id} is unavailable", + "description": "The backup location `{agent_id}` is unavailable but is still configured for automatic backups.\n\nPlease visit the [automatic backup configuration page]({backup_settings}) to review and update your backup locations. Backups will not be uploaded to selected locations that are unavailable." + }, "automatic_backup_failed_create": { "title": "Automatic backup could not be created", "description": "The automatic backup could not be created. Please check the logs for more information. Another attempt will be made at the next scheduled time if a backup schedule is configured." diff --git a/tests/components/backup/test_manager.py b/tests/components/backup/test_manager.py index b2b7e083a51..3c72929cfe0 100644 --- a/tests/components/backup/test_manager.py +++ b/tests/components/backup/test_manager.py @@ -982,7 +982,15 @@ async def delayed_boom(*args, **kwargs) -> tuple[NewBackup, Any]: None, None, True, - {}, + { + (DOMAIN, "automatic_backup_agents_unavailable_test.unknown"): { + "translation_key": "automatic_backup_agents_unavailable", + "translation_placeholders": { + "agent_id": "test.unknown", + "backup_settings": "/config/backup/settings", + }, + }, + }, ), ( ["test.remote", "test.unknown"], @@ -994,7 +1002,14 @@ async def delayed_boom(*args, **kwargs) -> tuple[NewBackup, Any]: (DOMAIN, "automatic_backup_failed"): { "translation_key": "automatic_backup_failed_upload_agents", "translation_placeholders": {"failed_agents": "test.unknown"}, - } + }, + (DOMAIN, "automatic_backup_agents_unavailable_test.unknown"): { + "translation_key": "automatic_backup_agents_unavailable", + "translation_placeholders": { + "agent_id": "test.unknown", + "backup_settings": "/config/backup/settings", + }, + }, }, ), # Error raised in async_initiate_backup diff --git a/tests/components/backup/test_websocket.py b/tests/components/backup/test_websocket.py index 9b2241882c4..404ba52de4b 100644 --- a/tests/components/backup/test_websocket.py +++ b/tests/components/backup/test_websocket.py @@ -27,6 +27,7 @@ from homeassistant.components.backup.manager import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.backup import async_initialize_backup from homeassistant.setup import async_setup_component @@ -34,7 +35,9 @@ from .common import ( LOCAL_AGENT_ID, TEST_BACKUP_ABC123, TEST_BACKUP_DEF456, + mock_backup_agent, setup_backup_integration, + setup_backup_platform, ) from tests.common import async_fire_time_changed, async_mock_service @@ -3244,6 +3247,185 @@ async def test_config_retention_days_logic( await hass.async_block_till_done() +async def test_configured_agents_unavailable_repair( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + issue_registry: ir.IssueRegistry, + hass_storage: dict[str, Any], +) -> None: + """Test creating and deleting repair issue for configured unavailable agents.""" + issue_id = "automatic_backup_agents_unavailable_test.agent" + ws_client = await hass_ws_client(hass) + hass_storage.update( + { + "backup": { + "data": { + "backups": [], + "config": { + "agents": {}, + "automatic_backups_configured": True, + "create_backup": { + "agent_ids": ["test.agent"], + "include_addons": None, + "include_all_addons": False, + "include_database": False, + "include_folders": None, + "name": None, + "password": None, + }, + "retention": {"copies": None, "days": None}, + "last_attempted_automatic_backup": None, + "last_completed_automatic_backup": None, + "schedule": { + "days": ["mon"], + "recurrence": "custom_days", + "state": "never", + "time": None, + }, + }, + }, + "key": DOMAIN, + "version": store.STORAGE_VERSION, + "minor_version": store.STORAGE_VERSION_MINOR, + }, + } + ) + + await setup_backup_integration(hass) + get_agents_mock = AsyncMock(return_value=[mock_backup_agent("agent")]) + register_listener_mock = Mock() + await setup_backup_platform( + hass, + domain="test", + platform=Mock( + async_get_backup_agents=get_agents_mock, + async_register_backup_agents_listener=register_listener_mock, + ), + ) + await hass.async_block_till_done() + + reload_backup_agents = register_listener_mock.call_args[1]["listener"] + + await ws_client.send_json_auto_id({"type": "backup/agents/info"}) + resp = await ws_client.receive_json() + assert resp["result"]["agents"] == [ + {"agent_id": "backup.local", "name": "local"}, + {"agent_id": "test.agent", "name": "agent"}, + ] + + assert not issue_registry.async_get_issue(domain=DOMAIN, issue_id=issue_id) + + # Reload the agents with no agents returned. + + get_agents_mock.return_value = [] + reload_backup_agents() + await hass.async_block_till_done() + + await ws_client.send_json_auto_id({"type": "backup/agents/info"}) + resp = await ws_client.receive_json() + assert resp["result"]["agents"] == [ + {"agent_id": "backup.local", "name": "local"}, + ] + + assert issue_registry.async_get_issue(domain=DOMAIN, issue_id=issue_id) + + await ws_client.send_json_auto_id({"type": "backup/config/info"}) + result = await ws_client.receive_json() + assert result["result"]["config"]["create_backup"]["agent_ids"] == ["test.agent"] + + # Update the automatic backup configuration removing the unavailable agent. + + await ws_client.send_json_auto_id( + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["backup.local"]}, + } + ) + result = await ws_client.receive_json() + + assert not issue_registry.async_get_issue(domain=DOMAIN, issue_id=issue_id) + + await ws_client.send_json_auto_id({"type": "backup/config/info"}) + result = await ws_client.receive_json() + assert result["result"]["config"]["create_backup"]["agent_ids"] == ["backup.local"] + + # Reload the agents with one agent returned + # but not configured for automatic backups. + + get_agents_mock.return_value = [mock_backup_agent("agent")] + reload_backup_agents() + await hass.async_block_till_done() + + await ws_client.send_json_auto_id({"type": "backup/agents/info"}) + resp = await ws_client.receive_json() + assert resp["result"]["agents"] == [ + {"agent_id": "backup.local", "name": "local"}, + {"agent_id": "test.agent", "name": "agent"}, + ] + + assert not issue_registry.async_get_issue(domain=DOMAIN, issue_id=issue_id) + + await ws_client.send_json_auto_id({"type": "backup/config/info"}) + result = await ws_client.receive_json() + assert result["result"]["config"]["create_backup"]["agent_ids"] == ["backup.local"] + + # Update the automatic backup configuration and configure the test agent. + + await ws_client.send_json_auto_id( + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["backup.local", "test.agent"]}, + } + ) + result = await ws_client.receive_json() + + assert not issue_registry.async_get_issue(domain=DOMAIN, issue_id=issue_id) + + await ws_client.send_json_auto_id({"type": "backup/config/info"}) + result = await ws_client.receive_json() + assert result["result"]["config"]["create_backup"]["agent_ids"] == [ + "backup.local", + "test.agent", + ] + + # Reload the agents with no agents returned again. + + get_agents_mock.return_value = [] + reload_backup_agents() + await hass.async_block_till_done() + + await ws_client.send_json_auto_id({"type": "backup/agents/info"}) + resp = await ws_client.receive_json() + assert resp["result"]["agents"] == [ + {"agent_id": "backup.local", "name": "local"}, + ] + + assert issue_registry.async_get_issue(domain=DOMAIN, issue_id=issue_id) + + await ws_client.send_json_auto_id({"type": "backup/config/info"}) + result = await ws_client.receive_json() + assert result["result"]["config"]["create_backup"]["agent_ids"] == [ + "backup.local", + "test.agent", + ] + + # Update the automatic backup configuration removing all agents. + + await ws_client.send_json_auto_id( + { + "type": "backup/config/update", + "create_backup": {"agent_ids": []}, + } + ) + result = await ws_client.receive_json() + + assert not issue_registry.async_get_issue(domain=DOMAIN, issue_id=issue_id) + + await ws_client.send_json_auto_id({"type": "backup/config/info"}) + result = await ws_client.receive_json() + assert result["result"]["config"]["create_backup"]["agent_ids"] == [] + + async def test_subscribe_event( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, From ca1677cc461666a3ded07a63d091926dcb6e9ee0 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 25 Feb 2025 16:52:58 +0100 Subject: [PATCH 1791/3148] Improve description of `openweathermap.get_minute_forecast` action (#139267) --- homeassistant/components/openweathermap/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/openweathermap/strings.json b/homeassistant/components/openweathermap/strings.json index 0692087bc23..1aa161c87dc 100644 --- a/homeassistant/components/openweathermap/strings.json +++ b/homeassistant/components/openweathermap/strings.json @@ -51,7 +51,7 @@ "services": { "get_minute_forecast": { "name": "Get minute forecast", - "description": "Get minute weather forecast." + "description": "Retrieves a minute-by-minute weather forecast for one hour." } }, "exceptions": { From fcffe5151ddc1ec86dafaca6e4348315b5b241ad Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 25 Feb 2025 17:00:09 +0100 Subject: [PATCH 1792/3148] Use right import in ezviz (#139272) --- homeassistant/components/ezviz/image.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/ezviz/image.py b/homeassistant/components/ezviz/image.py index ea032a8ec00..28ebc7279e6 100644 --- a/homeassistant/components/ezviz/image.py +++ b/homeassistant/components/ezviz/image.py @@ -4,7 +4,7 @@ from __future__ import annotations import logging -from propcache import cached_property +from propcache.api import cached_property from pyezviz.exceptions import PyEzvizError from pyezviz.utils import decrypt_image From 433c2cb43eba4ee8bc46706f49524557c46980c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Peter=20Br=C3=B8ndum?= <34370407+brondum@users.noreply.github.com> Date: Tue, 25 Feb 2025 17:00:35 +0100 Subject: [PATCH 1793/3148] Change touchline dependency to pytouchline_extended (#136362) Co-authored-by: Joost Lekkerkerker --- homeassistant/components/touchline/climate.py | 15 ++++++++------- homeassistant/components/touchline/manifest.json | 2 +- requirements_all.txt | 2 +- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/touchline/climate.py b/homeassistant/components/touchline/climate.py index f7eec7c54f9..86526f4718b 100644 --- a/homeassistant/components/touchline/climate.py +++ b/homeassistant/components/touchline/climate.py @@ -4,7 +4,7 @@ from __future__ import annotations from typing import Any, NamedTuple -from pytouchline import PyTouchline +from pytouchline_extended import PyTouchline import voluptuous as vol from homeassistant.components.climate import ( @@ -53,12 +53,13 @@ def setup_platform( """Set up the Touchline devices.""" host = config[CONF_HOST] - py_touchline = PyTouchline() - number_of_devices = int(py_touchline.get_number_of_devices(host)) - add_entities( - (Touchline(PyTouchline(device_id)) for device_id in range(number_of_devices)), - True, - ) + py_touchline = PyTouchline(url=host) + number_of_devices = int(py_touchline.get_number_of_devices()) + devices = [ + Touchline(PyTouchline(id=device_id, url=host)) + for device_id in range(number_of_devices) + ] + add_entities(devices, True) class Touchline(ClimateEntity): diff --git a/homeassistant/components/touchline/manifest.json b/homeassistant/components/touchline/manifest.json index c003cca97a4..6d25462408b 100644 --- a/homeassistant/components/touchline/manifest.json +++ b/homeassistant/components/touchline/manifest.json @@ -6,5 +6,5 @@ "iot_class": "local_polling", "loggers": ["pytouchline"], "quality_scale": "legacy", - "requirements": ["pytouchline==0.7"] + "requirements": ["pytouchline_extended==0.4.5"] } diff --git a/requirements_all.txt b/requirements_all.txt index 55b4d140321..1b0af492388 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2497,7 +2497,7 @@ pytile==2024.12.0 pytomorrowio==0.3.6 # homeassistant.components.touchline -pytouchline==0.7 +pytouchline_extended==0.4.5 # homeassistant.components.touchline_sl pytouchlinesl==0.3.0 From 9ec9110e1ee02e9d5cf45486388f59cceb02b0a2 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Tue, 25 Feb 2025 17:03:31 +0100 Subject: [PATCH 1794/3148] Rename description field to notes in Habitica action (#139271) --- homeassistant/components/habitica/const.py | 1 + homeassistant/components/habitica/services.py | 9 +++++---- homeassistant/components/habitica/services.yaml | 2 +- homeassistant/components/habitica/strings.json | 10 +++++----- tests/components/habitica/test_services.py | 7 ++++--- 5 files changed, 16 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/habitica/const.py b/homeassistant/components/habitica/const.py index 5e18477d142..353bcbbd39d 100644 --- a/homeassistant/components/habitica/const.py +++ b/homeassistant/components/habitica/const.py @@ -39,6 +39,7 @@ ATTR_REMOVE_TAG = "remove_tag" ATTR_ALIAS = "alias" ATTR_PRIORITY = "priority" ATTR_COST = "cost" +ATTR_NOTES = "notes" SERVICE_CAST_SKILL = "cast_skill" SERVICE_START_QUEST = "start_quest" diff --git a/homeassistant/components/habitica/services.py b/homeassistant/components/habitica/services.py index 16bbeef9073..57005cf2b72 100644 --- a/homeassistant/components/habitica/services.py +++ b/homeassistant/components/habitica/services.py @@ -22,7 +22,7 @@ from habiticalib import ( ) import voluptuous as vol -from homeassistant.components.todo import ATTR_DESCRIPTION, ATTR_RENAME +from homeassistant.components.todo import ATTR_RENAME from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ATTR_NAME, CONF_NAME from homeassistant.core import ( @@ -45,6 +45,7 @@ from .const import ( ATTR_DIRECTION, ATTR_ITEM, ATTR_KEYWORD, + ATTR_NOTES, ATTR_PATH, ATTR_PRIORITY, ATTR_REMOVE_TAG, @@ -116,7 +117,7 @@ SERVICE_UPDATE_TASK_SCHEMA = vol.Schema( vol.Required(ATTR_CONFIG_ENTRY): ConfigEntrySelector(), vol.Required(ATTR_TASK): cv.string, vol.Optional(ATTR_RENAME): cv.string, - vol.Optional(ATTR_DESCRIPTION): cv.string, + vol.Optional(ATTR_NOTES): cv.string, vol.Optional(ATTR_TAG): vol.All(cv.ensure_list, [str]), vol.Optional(ATTR_REMOVE_TAG): vol.All(cv.ensure_list, [str]), vol.Optional(ATTR_ALIAS): vol.All( @@ -566,8 +567,8 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 if rename := call.data.get(ATTR_RENAME): data["text"] = rename - if (description := call.data.get(ATTR_DESCRIPTION)) is not None: - data["notes"] = description + if (notes := call.data.get(ATTR_NOTES)) is not None: + data["notes"] = notes tags = cast(list[str], call.data.get(ATTR_TAG)) remove_tags = cast(list[str], call.data.get(ATTR_REMOVE_TAG)) diff --git a/homeassistant/components/habitica/services.yaml b/homeassistant/components/habitica/services.yaml index b8479c1eeec..7b486690ef5 100644 --- a/homeassistant/components/habitica/services.yaml +++ b/homeassistant/components/habitica/services.yaml @@ -147,7 +147,7 @@ update_reward: rename: selector: text: - description: + notes: required: false selector: text: diff --git a/homeassistant/components/habitica/strings.json b/homeassistant/components/habitica/strings.json index 75558cea078..1bb2fcbd9d7 100644 --- a/homeassistant/components/habitica/strings.json +++ b/homeassistant/components/habitica/strings.json @@ -12,8 +12,8 @@ "task_description": "The name (or task ID) of the task you want to update.", "rename_name": "Rename", "rename_description": "The new title for the Habitica task.", - "description_name": "Update description", - "description_description": "The new description for the Habitica task.", + "notes_name": "Update notes", + "notes_description": "The new notes for the Habitica task.", "tag_name": "Add tags", "tag_description": "Add tags to the Habitica task. If a tag does not already exist, a new one will be created.", "remove_tag_name": "Remove tags", @@ -690,9 +690,9 @@ "name": "[%key:component::habitica::common::rename_name%]", "description": "[%key:component::habitica::common::rename_description%]" }, - "description": { - "name": "[%key:component::habitica::common::description_name%]", - "description": "[%key:component::habitica::common::description_description%]" + "notes": { + "name": "[%key:component::habitica::common::notes_name%]", + "description": "[%key:component::habitica::common::notes_description%]" }, "tag": { "name": "[%key:component::habitica::common::tag_name%]", diff --git a/tests/components/habitica/test_services.py b/tests/components/habitica/test_services.py index 3f7ca14220b..a4442016784 100644 --- a/tests/components/habitica/test_services.py +++ b/tests/components/habitica/test_services.py @@ -17,6 +17,7 @@ from homeassistant.components.habitica.const import ( ATTR_DIRECTION, ATTR_ITEM, ATTR_KEYWORD, + ATTR_NOTES, ATTR_PRIORITY, ATTR_REMOVE_TAG, ATTR_SKILL, @@ -38,7 +39,7 @@ from homeassistant.components.habitica.const import ( SERVICE_TRANSFORMATION, SERVICE_UPDATE_REWARD, ) -from homeassistant.components.todo import ATTR_DESCRIPTION, ATTR_RENAME +from homeassistant.components.todo import ATTR_RENAME from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError @@ -984,9 +985,9 @@ async def test_task_not_found( ), ( { - ATTR_DESCRIPTION: "DESCRIPTION", + ATTR_NOTES: "NOTES", }, - Task(notes="DESCRIPTION"), + Task(notes="NOTES"), ), ( { From f3021b40abc5917740dc4950f7098b9daa58d041 Mon Sep 17 00:00:00 2001 From: Galorhallen <12990764+Galorhallen@users.noreply.github.com> Date: Tue, 25 Feb 2025 17:04:53 +0100 Subject: [PATCH 1795/3148] Add support for effects in Govee lights (#137846) --- .../govee_light_local/coordinator.py | 4 + .../components/govee_light_local/light.py | 62 ++ .../components/govee_light_local/strings.json | 24 + .../components/govee_light_local/conftest.py | 26 +- .../govee_light_local/test_config_flow.py | 60 +- .../govee_light_local/test_light.py | 624 ++++++++++++------ 6 files changed, 558 insertions(+), 242 deletions(-) diff --git a/homeassistant/components/govee_light_local/coordinator.py b/homeassistant/components/govee_light_local/coordinator.py index ecbed0c4f65..530ade1f743 100644 --- a/homeassistant/components/govee_light_local/coordinator.py +++ b/homeassistant/components/govee_light_local/coordinator.py @@ -89,6 +89,10 @@ class GoveeLocalApiCoordinator(DataUpdateCoordinator[list[GoveeDevice]]): """Set light color in kelvin.""" await device.set_temperature(temperature) + async def set_scene(self, device: GoveeController, scene: str) -> None: + """Set light scene.""" + await device.set_scene(scene) + @property def devices(self) -> list[GoveeDevice]: """Return a list of discovered Govee devices.""" diff --git a/homeassistant/components/govee_light_local/light.py b/homeassistant/components/govee_light_local/light.py index 11ca53b53a1..984654477e9 100644 --- a/homeassistant/components/govee_light_local/light.py +++ b/homeassistant/components/govee_light_local/light.py @@ -10,9 +10,11 @@ from govee_local_api import GoveeDevice, GoveeLightFeatures from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_TEMP_KELVIN, + ATTR_EFFECT, ATTR_RGB_COLOR, ColorMode, LightEntity, + LightEntityFeature, filter_supported_color_modes, ) from homeassistant.core import HomeAssistant, callback @@ -25,6 +27,8 @@ from .coordinator import GoveeLocalApiCoordinator, GoveeLocalConfigEntry _LOGGER = logging.getLogger(__name__) +_NONE_SCENE = "none" + async def async_setup_entry( hass: HomeAssistant, @@ -50,10 +54,22 @@ async def async_setup_entry( class GoveeLight(CoordinatorEntity[GoveeLocalApiCoordinator], LightEntity): """Govee Light.""" + _attr_translation_key = "govee_light" _attr_has_entity_name = True _attr_name = None _attr_supported_color_modes: set[ColorMode] _fixed_color_mode: ColorMode | None = None + _attr_effect_list: list[str] | None = None + _attr_effect: str | None = None + _attr_supported_features: LightEntityFeature = LightEntityFeature(0) + _last_color_state: ( + tuple[ + ColorMode | str | None, + int | None, + tuple[int, int, int] | tuple[int | None] | None, + ] + | None + ) = None def __init__( self, @@ -80,6 +96,13 @@ class GoveeLight(CoordinatorEntity[GoveeLocalApiCoordinator], LightEntity): if GoveeLightFeatures.BRIGHTNESS & capabilities.features: color_modes.add(ColorMode.BRIGHTNESS) + if ( + GoveeLightFeatures.SCENES & capabilities.features + and capabilities.scenes + ): + self._attr_supported_features = LightEntityFeature.EFFECT + self._attr_effect_list = [_NONE_SCENE, *capabilities.scenes.keys()] + self._attr_supported_color_modes = filter_supported_color_modes(color_modes) if len(self._attr_supported_color_modes) == 1: # If the light supports only a single color mode, set it now @@ -143,12 +166,27 @@ class GoveeLight(CoordinatorEntity[GoveeLocalApiCoordinator], LightEntity): if ATTR_RGB_COLOR in kwargs: self._attr_color_mode = ColorMode.RGB + self._attr_effect = None + self._last_color_state = None red, green, blue = kwargs[ATTR_RGB_COLOR] await self.coordinator.set_rgb_color(self._device, red, green, blue) elif ATTR_COLOR_TEMP_KELVIN in kwargs: self._attr_color_mode = ColorMode.COLOR_TEMP + self._attr_effect = None + self._last_color_state = None temperature: float = kwargs[ATTR_COLOR_TEMP_KELVIN] await self.coordinator.set_temperature(self._device, int(temperature)) + elif ATTR_EFFECT in kwargs: + effect = kwargs[ATTR_EFFECT] + if effect and self._attr_effect_list and effect in self._attr_effect_list: + if effect == _NONE_SCENE: + self._attr_effect = None + await self._restore_last_color_state() + else: + self._attr_effect = effect + self._save_last_color_state() + await self.coordinator.set_scene(self._device, effect) + self.async_write_ha_state() async def async_turn_off(self, **kwargs: Any) -> None: @@ -159,3 +197,27 @@ class GoveeLight(CoordinatorEntity[GoveeLocalApiCoordinator], LightEntity): @callback def _update_callback(self, device: GoveeDevice) -> None: self.async_write_ha_state() + + def _save_last_color_state(self) -> None: + color_mode = self.color_mode + self._last_color_state = ( + color_mode, + self.brightness, + (self.color_temp_kelvin,) + if color_mode == ColorMode.COLOR_TEMP + else self.rgb_color, + ) + + async def _restore_last_color_state(self) -> None: + if self._last_color_state: + color_mode, brightness, color = self._last_color_state + if color: + if color_mode == ColorMode.RGB: + await self.coordinator.set_rgb_color(self._device, *color) + elif color_mode == ColorMode.COLOR_TEMP: + await self.coordinator.set_temperature(self._device, *color) + if brightness: + await self.coordinator.set_brightness( + self._device, int((float(brightness) / 255.0) * 100.0) + ) + self._last_color_state = None diff --git a/homeassistant/components/govee_light_local/strings.json b/homeassistant/components/govee_light_local/strings.json index ad8f0f41ae7..49f3a2cbeb9 100644 --- a/homeassistant/components/govee_light_local/strings.json +++ b/homeassistant/components/govee_light_local/strings.json @@ -9,5 +9,29 @@ "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]", "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]" } + }, + "entity": { + "light": { + "govee_light": { + "state_attributes": { + "effect": { + "state": { + "none": "None", + "sunrise": "Sunrise", + "sunset": "Sunset", + "movie": "Movie", + "dating": "Dating", + "romantic": "Romantic", + "twinkle": "Twinkle", + "candlelight": "Candlelight", + "snowflake": "Snowflake", + "energetic": "Energetic", + "breathe": "Breathe", + "crossing": "Crossing" + } + } + } + } + } } } diff --git a/tests/components/govee_light_local/conftest.py b/tests/components/govee_light_local/conftest.py index 61a6394bd6a..a8b6955c384 100644 --- a/tests/components/govee_light_local/conftest.py +++ b/tests/components/govee_light_local/conftest.py @@ -4,15 +4,15 @@ from asyncio import Event from collections.abc import Generator from unittest.mock import AsyncMock, MagicMock, patch -from govee_local_api import GoveeLightCapabilities -from govee_local_api.light_capabilities import COMMON_FEATURES +from govee_local_api import GoveeLightCapabilities, GoveeLightFeatures +from govee_local_api.light_capabilities import COMMON_FEATURES, SCENE_CODES import pytest from homeassistant.components.govee_light_local.coordinator import GoveeController @pytest.fixture(name="mock_govee_api") -def fixture_mock_govee_api(): +def fixture_mock_govee_api() -> Generator[AsyncMock]: """Set up Govee Local API fixture.""" mock_api = AsyncMock(spec=GoveeController) mock_api.start = AsyncMock() @@ -21,8 +21,20 @@ def fixture_mock_govee_api(): mock_api.turn_on_off = AsyncMock() mock_api.set_brightness = AsyncMock() mock_api.set_color = AsyncMock() + mock_api.set_scene = AsyncMock() mock_api._async_update_data = AsyncMock() - return mock_api + + with ( + patch( + "homeassistant.components.govee_light_local.coordinator.GoveeController", + return_value=mock_api, + ) as mock_controller, + patch( + "homeassistant.components.govee_light_local.config_flow.GoveeController", + return_value=mock_api, + ), + ): + yield mock_controller.return_value @pytest.fixture(name="mock_setup_entry") @@ -38,3 +50,9 @@ def fixture_mock_setup_entry() -> Generator[AsyncMock]: DEFAULT_CAPABILITIES: GoveeLightCapabilities = GoveeLightCapabilities( features=COMMON_FEATURES, segments=[], scenes={} ) + +SCENE_CAPABILITIES: GoveeLightCapabilities = GoveeLightCapabilities( + features=COMMON_FEATURES | GoveeLightFeatures.SCENES, + segments=[], + scenes=SCENE_CODES, +) diff --git a/tests/components/govee_light_local/test_config_flow.py b/tests/components/govee_light_local/test_config_flow.py index 103159f1a2b..e6e336a70f2 100644 --- a/tests/components/govee_light_local/test_config_flow.py +++ b/tests/components/govee_light_local/test_config_flow.py @@ -32,15 +32,9 @@ async def test_creating_entry_has_no_devices( mock_govee_api.devices = [] - with ( - patch( - "homeassistant.components.govee_light_local.config_flow.GoveeController", - return_value=mock_govee_api, - ), - patch( - "homeassistant.components.govee_light_local.config_flow.DISCOVERY_TIMEOUT", - 0, - ), + with patch( + "homeassistant.components.govee_light_local.config_flow.DISCOVERY_TIMEOUT", + 0, ): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -67,24 +61,20 @@ async def test_creating_entry_has_with_devices( mock_govee_api.devices = _get_devices(mock_govee_api) - with patch( - "homeassistant.components.govee_light_local.config_flow.GoveeController", - return_value=mock_govee_api, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) - # Confirmation form - assert result["type"] is FlowResultType.FORM + # Confirmation form + assert result["type"] is FlowResultType.FORM - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result["type"] is FlowResultType.CREATE_ENTRY + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert result["type"] is FlowResultType.CREATE_ENTRY - await hass.async_block_till_done() + await hass.async_block_till_done() - mock_govee_api.start.assert_awaited_once() - mock_setup_entry.assert_awaited_once() + mock_govee_api.start.assert_awaited_once() + mock_setup_entry.assert_awaited_once() async def test_creating_entry_errno( @@ -99,21 +89,17 @@ async def test_creating_entry_errno( mock_govee_api.start.side_effect = e mock_govee_api.devices = _get_devices(mock_govee_api) - with patch( - "homeassistant.components.govee_light_local.config_flow.GoveeController", - return_value=mock_govee_api, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) - # Confirmation form - assert result["type"] is FlowResultType.FORM + # Confirmation form + assert result["type"] is FlowResultType.FORM - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result["type"] is FlowResultType.ABORT + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert result["type"] is FlowResultType.ABORT - await hass.async_block_till_done() + await hass.async_block_till_done() - assert mock_govee_api.start.call_count == 1 - mock_setup_entry.assert_not_awaited() + assert mock_govee_api.start.call_count == 1 + mock_setup_entry.assert_not_awaited() diff --git a/tests/components/govee_light_local/test_light.py b/tests/components/govee_light_local/test_light.py index 24bdbba9e11..c5dde6a9b9e 100644 --- a/tests/components/govee_light_local/test_light.py +++ b/tests/components/govee_light_local/test_light.py @@ -10,7 +10,7 @@ from homeassistant.components.light import ATTR_SUPPORTED_COLOR_MODES, ColorMode from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant -from .conftest import DEFAULT_CAPABILITIES +from .conftest import DEFAULT_CAPABILITIES, SCENE_CAPABILITIES from tests.common import MockConfigEntry @@ -30,28 +30,24 @@ async def test_light_known_device( ) ] - with patch( - "homeassistant.components.govee_light_local.coordinator.GoveeController", - return_value=mock_govee_api, - ): - entry = MockConfigEntry(domain=DOMAIN) - entry.add_to_hass(hass) + entry = MockConfigEntry(domain=DOMAIN) + entry.add_to_hass(hass) - assert await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() - assert len(hass.states.async_all()) == 1 + assert len(hass.states.async_all()) == 1 - light = hass.states.get("light.H615A") - assert light is not None + light = hass.states.get("light.H615A") + assert light is not None - color_modes = light.attributes[ATTR_SUPPORTED_COLOR_MODES] - assert set(color_modes) == {ColorMode.COLOR_TEMP, ColorMode.RGB} + color_modes = light.attributes[ATTR_SUPPORTED_COLOR_MODES] + assert set(color_modes) == {ColorMode.COLOR_TEMP, ColorMode.RGB} - # Remove - assert await hass.config_entries.async_remove(entry.entry_id) - await hass.async_block_till_done() - assert hass.states.get("light.H615A") is None + # Remove + assert await hass.config_entries.async_remove(entry.entry_id) + await hass.async_block_till_done() + assert hass.states.get("light.H615A") is None async def test_light_unknown_device( @@ -69,26 +65,22 @@ async def test_light_unknown_device( ) ] - with patch( - "homeassistant.components.govee_light_local.coordinator.GoveeController", - return_value=mock_govee_api, - ): - entry = MockConfigEntry(domain=DOMAIN) - entry.add_to_hass(hass) + entry = MockConfigEntry(domain=DOMAIN) + entry.add_to_hass(hass) - assert await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() - assert len(hass.states.async_all()) == 1 + assert len(hass.states.async_all()) == 1 - light = hass.states.get("light.XYZK") - assert light is not None + light = hass.states.get("light.XYZK") + assert light is not None - assert light.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ColorMode.ONOFF] + assert light.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ColorMode.ONOFF] async def test_light_remove(hass: HomeAssistant, mock_govee_api: AsyncMock) -> None: - """Test adding a known device.""" + """Test remove device.""" mock_govee_api.devices = [ GoveeDevice( @@ -100,49 +92,41 @@ async def test_light_remove(hass: HomeAssistant, mock_govee_api: AsyncMock) -> N ) ] - with patch( - "homeassistant.components.govee_light_local.coordinator.GoveeController", - return_value=mock_govee_api, - ): - entry = MockConfigEntry(domain=DOMAIN) - entry.add_to_hass(hass) + entry = MockConfigEntry(domain=DOMAIN) + entry.add_to_hass(hass) - assert await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - assert hass.states.get("light.H615A") is not None + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + assert hass.states.get("light.H615A") is not None - # Remove 1 - assert await hass.config_entries.async_remove(entry.entry_id) - await hass.async_block_till_done() - assert len(hass.states.async_all()) == 0 + # Remove 1 + assert await hass.config_entries.async_remove(entry.entry_id) + await hass.async_block_till_done() + assert len(hass.states.async_all()) == 0 async def test_light_setup_retry( hass: HomeAssistant, mock_govee_api: AsyncMock ) -> None: - """Test adding an unknown device.""" + """Test setup retry.""" mock_govee_api.devices = [] - with patch( - "homeassistant.components.govee_light_local.coordinator.GoveeController", - return_value=mock_govee_api, - ): - entry = MockConfigEntry(domain=DOMAIN) - entry.add_to_hass(hass) + entry = MockConfigEntry(domain=DOMAIN) + entry.add_to_hass(hass) - with patch( - "homeassistant.components.govee_light_local.DISCOVERY_TIMEOUT", - 0, - ): - await hass.config_entries.async_setup(entry.entry_id) - assert entry.state is ConfigEntryState.SETUP_RETRY + with patch( + "homeassistant.components.govee_light_local.DISCOVERY_TIMEOUT", + 0, + ): + await hass.config_entries.async_setup(entry.entry_id) + assert entry.state is ConfigEntryState.SETUP_RETRY async def test_light_setup_retry_eaddrinuse( hass: HomeAssistant, mock_govee_api: AsyncMock ) -> None: - """Test adding an unknown device.""" + """Test retry on address already in use.""" mock_govee_api.start.side_effect = OSError() mock_govee_api.start.side_effect.errno = EADDRINUSE @@ -156,21 +140,17 @@ async def test_light_setup_retry_eaddrinuse( ) ] - with patch( - "homeassistant.components.govee_light_local.coordinator.GoveeController", - return_value=mock_govee_api, - ): - entry = MockConfigEntry(domain=DOMAIN) - entry.add_to_hass(hass) + entry = MockConfigEntry(domain=DOMAIN) + entry.add_to_hass(hass) - await hass.config_entries.async_setup(entry.entry_id) - assert entry.state is ConfigEntryState.SETUP_RETRY + await hass.config_entries.async_setup(entry.entry_id) + assert entry.state is ConfigEntryState.SETUP_RETRY async def test_light_setup_error( hass: HomeAssistant, mock_govee_api: AsyncMock ) -> None: - """Test adding an unknown device.""" + """Test setup error.""" mock_govee_api.start.side_effect = OSError() mock_govee_api.start.side_effect.errno = ENETDOWN @@ -184,19 +164,15 @@ async def test_light_setup_error( ) ] - with patch( - "homeassistant.components.govee_light_local.coordinator.GoveeController", - return_value=mock_govee_api, - ): - entry = MockConfigEntry(domain=DOMAIN) - entry.add_to_hass(hass) + entry = MockConfigEntry(domain=DOMAIN) + entry.add_to_hass(hass) - await hass.config_entries.async_setup(entry.entry_id) - assert entry.state is ConfigEntryState.SETUP_ERROR + await hass.config_entries.async_setup(entry.entry_id) + assert entry.state is ConfigEntryState.SETUP_ERROR async def test_light_on_off(hass: HomeAssistant, mock_govee_api: MagicMock) -> None: - """Test adding a known device.""" + """Test light on and then off.""" mock_govee_api.devices = [ GoveeDevice( @@ -208,48 +184,44 @@ async def test_light_on_off(hass: HomeAssistant, mock_govee_api: MagicMock) -> N ) ] - with patch( - "homeassistant.components.govee_light_local.coordinator.GoveeController", - return_value=mock_govee_api, - ): - entry = MockConfigEntry(domain=DOMAIN) - entry.add_to_hass(hass) + entry = MockConfigEntry(domain=DOMAIN) + entry.add_to_hass(hass) - assert await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() - assert len(hass.states.async_all()) == 1 + assert len(hass.states.async_all()) == 1 - light = hass.states.get("light.H615A") - assert light is not None - assert light.state == "off" + light = hass.states.get("light.H615A") + assert light is not None + assert light.state == "off" - await hass.services.async_call( - "light", - "turn_on", - {"entity_id": light.entity_id}, - blocking=True, - ) - await hass.async_block_till_done() + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": light.entity_id}, + blocking=True, + ) + await hass.async_block_till_done() - light = hass.states.get("light.H615A") - assert light is not None - assert light.state == "on" - mock_govee_api.turn_on_off.assert_awaited_with(mock_govee_api.devices[0], True) + light = hass.states.get("light.H615A") + assert light is not None + assert light.state == "on" + mock_govee_api.turn_on_off.assert_awaited_with(mock_govee_api.devices[0], True) - # Turn off - await hass.services.async_call( - "light", - "turn_off", - {"entity_id": light.entity_id}, - blocking=True, - ) - await hass.async_block_till_done() + # Turn off + await hass.services.async_call( + "light", + "turn_off", + {"entity_id": light.entity_id}, + blocking=True, + ) + await hass.async_block_till_done() - light = hass.states.get("light.H615A") - assert light is not None - assert light.state == "off" - mock_govee_api.turn_on_off.assert_awaited_with(mock_govee_api.devices[0], False) + light = hass.states.get("light.H615A") + assert light is not None + assert light.state == "off" + mock_govee_api.turn_on_off.assert_awaited_with(mock_govee_api.devices[0], False) async def test_light_brightness(hass: HomeAssistant, mock_govee_api: MagicMock) -> None: @@ -264,67 +236,59 @@ async def test_light_brightness(hass: HomeAssistant, mock_govee_api: MagicMock) ) ] - with patch( - "homeassistant.components.govee_light_local.coordinator.GoveeController", - return_value=mock_govee_api, - ): - entry = MockConfigEntry(domain=DOMAIN) - entry.add_to_hass(hass) + entry = MockConfigEntry(domain=DOMAIN) + entry.add_to_hass(hass) - assert await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() - assert len(hass.states.async_all()) == 1 + assert len(hass.states.async_all()) == 1 - light = hass.states.get("light.H615A") - assert light is not None - assert light.state == "off" + light = hass.states.get("light.H615A") + assert light is not None + assert light.state == "off" - await hass.services.async_call( - "light", - "turn_on", - {"entity_id": light.entity_id, "brightness_pct": 50}, - blocking=True, - ) - await hass.async_block_till_done() + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": light.entity_id, "brightness_pct": 50}, + blocking=True, + ) + await hass.async_block_till_done() - light = hass.states.get("light.H615A") - assert light is not None - assert light.state == "on" - mock_govee_api.set_brightness.assert_awaited_with(mock_govee_api.devices[0], 50) - assert light.attributes["brightness"] == 127 + light = hass.states.get("light.H615A") + assert light is not None + assert light.state == "on" + mock_govee_api.set_brightness.assert_awaited_with(mock_govee_api.devices[0], 50) + assert light.attributes["brightness"] == 127 - await hass.services.async_call( - "light", - "turn_on", - {"entity_id": light.entity_id, "brightness": 255}, - blocking=True, - ) - await hass.async_block_till_done() + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": light.entity_id, "brightness": 255}, + blocking=True, + ) + await hass.async_block_till_done() - light = hass.states.get("light.H615A") - assert light is not None - assert light.state == "on" - assert light.attributes["brightness"] == 255 - mock_govee_api.set_brightness.assert_awaited_with( - mock_govee_api.devices[0], 100 - ) + light = hass.states.get("light.H615A") + assert light is not None + assert light.state == "on" + assert light.attributes["brightness"] == 255 + mock_govee_api.set_brightness.assert_awaited_with(mock_govee_api.devices[0], 100) - await hass.services.async_call( - "light", - "turn_on", - {"entity_id": light.entity_id, "brightness": 255}, - blocking=True, - ) - await hass.async_block_till_done() + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": light.entity_id, "brightness": 255}, + blocking=True, + ) + await hass.async_block_till_done() - light = hass.states.get("light.H615A") - assert light is not None - assert light.state == "on" - assert light.attributes["brightness"] == 255 - mock_govee_api.set_brightness.assert_awaited_with( - mock_govee_api.devices[0], 100 - ) + light = hass.states.get("light.H615A") + assert light is not None + assert light.state == "on" + assert light.attributes["brightness"] == 255 + mock_govee_api.set_brightness.assert_awaited_with(mock_govee_api.devices[0], 100) async def test_light_color(hass: HomeAssistant, mock_govee_api: MagicMock) -> None: @@ -339,54 +303,312 @@ async def test_light_color(hass: HomeAssistant, mock_govee_api: MagicMock) -> No ) ] - with patch( - "homeassistant.components.govee_light_local.coordinator.GoveeController", - return_value=mock_govee_api, - ): - entry = MockConfigEntry(domain=DOMAIN) - entry.add_to_hass(hass) + entry = MockConfigEntry(domain=DOMAIN) + entry.add_to_hass(hass) - assert await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() - assert len(hass.states.async_all()) == 1 + assert len(hass.states.async_all()) == 1 - light = hass.states.get("light.H615A") - assert light is not None - assert light.state == "off" + light = hass.states.get("light.H615A") + assert light is not None + assert light.state == "off" - await hass.services.async_call( - "light", - "turn_on", - {"entity_id": light.entity_id, "rgb_color": [100, 255, 50]}, - blocking=True, + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": light.entity_id, "rgb_color": [100, 255, 50]}, + blocking=True, + ) + await hass.async_block_till_done() + + light = hass.states.get("light.H615A") + assert light is not None + assert light.state == "on" + assert light.attributes["rgb_color"] == (100, 255, 50) + assert light.attributes["color_mode"] == ColorMode.RGB + + mock_govee_api.set_color.assert_awaited_with( + mock_govee_api.devices[0], rgb=(100, 255, 50), temperature=None + ) + + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": light.entity_id, "kelvin": 4400}, + blocking=True, + ) + await hass.async_block_till_done() + + light = hass.states.get("light.H615A") + assert light is not None + assert light.state == "on" + assert light.attributes["color_temp_kelvin"] == 4400 + assert light.attributes["color_mode"] == ColorMode.COLOR_TEMP + + mock_govee_api.set_color.assert_awaited_with( + mock_govee_api.devices[0], rgb=None, temperature=4400 + ) + + +async def test_scene_on(hass: HomeAssistant, mock_govee_api: MagicMock) -> None: + """Test turning on scene.""" + + mock_govee_api.devices = [ + GoveeDevice( + controller=mock_govee_api, + ip="192.168.1.100", + fingerprint="asdawdqwdqwd", + sku="H615A", + capabilities=SCENE_CAPABILITIES, ) - await hass.async_block_till_done() + ] - light = hass.states.get("light.H615A") - assert light is not None - assert light.state == "on" - assert light.attributes["rgb_color"] == (100, 255, 50) - assert light.attributes["color_mode"] == ColorMode.RGB + entry = MockConfigEntry(domain=DOMAIN) + entry.add_to_hass(hass) - mock_govee_api.set_color.assert_awaited_with( - mock_govee_api.devices[0], rgb=(100, 255, 50), temperature=None + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.states.async_all()) == 1 + + light = hass.states.get("light.H615A") + assert light is not None + assert light.state == "off" + + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": light.entity_id, "effect": "sunrise"}, + blocking=True, + ) + await hass.async_block_till_done() + + light = hass.states.get("light.H615A") + assert light is not None + assert light.state == "on" + assert light.attributes["effect"] == "sunrise" + mock_govee_api.turn_on_off.assert_awaited_with(mock_govee_api.devices[0], True) + + +async def test_scene_restore_rgb( + hass: HomeAssistant, mock_govee_api: MagicMock +) -> None: + """Test restore rgb color.""" + + mock_govee_api.devices = [ + GoveeDevice( + controller=mock_govee_api, + ip="192.168.1.100", + fingerprint="asdawdqwdqwd", + sku="H615A", + capabilities=SCENE_CAPABILITIES, ) + ] - await hass.services.async_call( - "light", - "turn_on", - {"entity_id": light.entity_id, "kelvin": 4400}, - blocking=True, + entry = MockConfigEntry(domain=DOMAIN) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.states.async_all()) == 1 + + initial_color = (12, 34, 56) + light = hass.states.get("light.H615A") + assert light is not None + assert light.state == "off" + + # Set initial color + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": light.entity_id, "rgb_color": initial_color}, + blocking=True, + ) + await hass.async_block_till_done() + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": light.entity_id, "brightness": 255}, + blocking=True, + ) + await hass.async_block_till_done() + + light = hass.states.get("light.H615A") + assert light is not None + assert light.state == "on" + assert light.attributes["rgb_color"] == initial_color + assert light.attributes["brightness"] == 255 + mock_govee_api.turn_on_off.assert_awaited_with(mock_govee_api.devices[0], True) + + # Activate scene + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": light.entity_id, "effect": "sunrise"}, + blocking=True, + ) + await hass.async_block_till_done() + + light = hass.states.get("light.H615A") + assert light is not None + assert light.state == "on" + assert light.attributes["effect"] == "sunrise" + mock_govee_api.turn_on_off.assert_awaited_with(mock_govee_api.devices[0], True) + + # Deactivate scene + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": light.entity_id, "effect": "none"}, + blocking=True, + ) + await hass.async_block_till_done() + + light = hass.states.get("light.H615A") + assert light is not None + assert light.state == "on" + assert light.attributes["effect"] is None + assert light.attributes["rgb_color"] == initial_color + assert light.attributes["brightness"] == 255 + + +async def test_scene_restore_temperature( + hass: HomeAssistant, mock_govee_api: MagicMock +) -> None: + """Test restore color temperature.""" + + mock_govee_api.devices = [ + GoveeDevice( + controller=mock_govee_api, + ip="192.168.1.100", + fingerprint="asdawdqwdqwd", + sku="H615A", + capabilities=SCENE_CAPABILITIES, ) - await hass.async_block_till_done() + ] - light = hass.states.get("light.H615A") - assert light is not None - assert light.state == "on" - assert light.attributes["color_temp_kelvin"] == 4400 - assert light.attributes["color_mode"] == ColorMode.COLOR_TEMP + entry = MockConfigEntry(domain=DOMAIN) + entry.add_to_hass(hass) - mock_govee_api.set_color.assert_awaited_with( - mock_govee_api.devices[0], rgb=None, temperature=4400 + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.states.async_all()) == 1 + + initial_color = 3456 + light = hass.states.get("light.H615A") + assert light is not None + assert light.state == "off" + + # Set initial color + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": light.entity_id, "color_temp_kelvin": initial_color}, + blocking=True, + ) + await hass.async_block_till_done() + + light = hass.states.get("light.H615A") + assert light is not None + assert light.state == "on" + assert light.attributes["color_temp_kelvin"] == initial_color + mock_govee_api.turn_on_off.assert_awaited_with(mock_govee_api.devices[0], True) + + # Activate scene + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": light.entity_id, "effect": "sunrise"}, + blocking=True, + ) + await hass.async_block_till_done() + + light = hass.states.get("light.H615A") + assert light is not None + assert light.state == "on" + assert light.attributes["effect"] == "sunrise" + mock_govee_api.set_scene.assert_awaited_with(mock_govee_api.devices[0], "sunrise") + + # Deactivate scene + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": light.entity_id, "effect": "none"}, + blocking=True, + ) + await hass.async_block_till_done() + + light = hass.states.get("light.H615A") + assert light is not None + assert light.state == "on" + assert light.attributes["effect"] is None + assert light.attributes["color_temp_kelvin"] == initial_color + + +async def test_scene_none(hass: HomeAssistant, mock_govee_api: MagicMock) -> None: + """Test turn on 'none' scene.""" + + mock_govee_api.devices = [ + GoveeDevice( + controller=mock_govee_api, + ip="192.168.1.100", + fingerprint="asdawdqwdqwd", + sku="H615A", + capabilities=SCENE_CAPABILITIES, ) + ] + + entry = MockConfigEntry(domain=DOMAIN) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.states.async_all()) == 1 + + initial_color = (12, 34, 56) + light = hass.states.get("light.H615A") + assert light is not None + assert light.state == "off" + + # Set initial color + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": light.entity_id, "rgb_color": initial_color}, + blocking=True, + ) + await hass.async_block_till_done() + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": light.entity_id, "brightness": 255}, + blocking=True, + ) + await hass.async_block_till_done() + + light = hass.states.get("light.H615A") + assert light is not None + assert light.state == "on" + assert light.attributes["rgb_color"] == initial_color + assert light.attributes["brightness"] == 255 + mock_govee_api.turn_on_off.assert_awaited_with(mock_govee_api.devices[0], True) + + # Activate scene + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": light.entity_id, "effect": "none"}, + blocking=True, + ) + await hass.async_block_till_done() + + light = hass.states.get("light.H615A") + assert light is not None + assert light.state == "on" + assert light.attributes["effect"] is None + mock_govee_api.set_scene.assert_not_called() From 743cc428299135579dd87ffb2f7c2264c5ff0646 Mon Sep 17 00:00:00 2001 From: tronikos Date: Tue, 25 Feb 2025 08:08:32 -0800 Subject: [PATCH 1796/3148] Add Burbank Water and Power (BWP) virtual integration (#139027) --- .../components/burbank_water_and_power/__init__.py | 1 + .../components/burbank_water_and_power/manifest.json | 6 ++++++ homeassistant/generated/integrations.json | 5 +++++ 3 files changed, 12 insertions(+) create mode 100644 homeassistant/components/burbank_water_and_power/__init__.py create mode 100644 homeassistant/components/burbank_water_and_power/manifest.json diff --git a/homeassistant/components/burbank_water_and_power/__init__.py b/homeassistant/components/burbank_water_and_power/__init__.py new file mode 100644 index 00000000000..2b82c8bd56b --- /dev/null +++ b/homeassistant/components/burbank_water_and_power/__init__.py @@ -0,0 +1 @@ +"""Virtual integration: Burbank Water and Power (BWP).""" diff --git a/homeassistant/components/burbank_water_and_power/manifest.json b/homeassistant/components/burbank_water_and_power/manifest.json new file mode 100644 index 00000000000..7b938d3b98b --- /dev/null +++ b/homeassistant/components/burbank_water_and_power/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "burbank_water_and_power", + "name": "Burbank Water and Power (BWP)", + "integration_type": "virtual", + "supported_by": "opower" +} diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 01ff9d14d90..e3185251114 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -850,6 +850,11 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "burbank_water_and_power": { + "name": "Burbank Water and Power (BWP)", + "integration_type": "virtual", + "supported_by": "opower" + }, "caldav": { "name": "CalDAV", "integration_type": "hub", From 2bba185e4c32939ee4f45fa69f6f80c6b42348e5 Mon Sep 17 00:00:00 2001 From: Paul Traina Date: Tue, 25 Feb 2025 08:09:51 -0800 Subject: [PATCH 1797/3148] Update adext to 0.4.4 (#139151) --- homeassistant/components/alarmdecoder/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/alarmdecoder/manifest.json b/homeassistant/components/alarmdecoder/manifest.json index ae1a2f4684d..c2c12792801 100644 --- a/homeassistant/components/alarmdecoder/manifest.json +++ b/homeassistant/components/alarmdecoder/manifest.json @@ -7,5 +7,5 @@ "integration_type": "device", "iot_class": "local_push", "loggers": ["adext", "alarmdecoder"], - "requirements": ["adext==0.4.3"] + "requirements": ["adext==0.4.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index 1b0af492388..00194d2f15b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -140,7 +140,7 @@ adax==0.4.0 adb-shell[async]==0.4.4 # homeassistant.components.alarmdecoder -adext==0.4.3 +adext==0.4.4 # homeassistant.components.adguard adguardhome==0.7.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 072250cad20..180ed7d43e4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -128,7 +128,7 @@ adax==0.4.0 adb-shell[async]==0.4.4 # homeassistant.components.alarmdecoder -adext==0.4.3 +adext==0.4.4 # homeassistant.components.adguard adguardhome==0.7.0 From 38cc26485a5ec055335b8dedfd2b601c87f6e285 Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Tue, 25 Feb 2025 17:21:05 +0100 Subject: [PATCH 1798/3148] Add sound mode support to Onkyo (#133531) --- homeassistant/components/onkyo/__init__.py | 17 ++- homeassistant/components/onkyo/config_flow.py | 139 +++++++++++++---- homeassistant/components/onkyo/const.py | 126 ++++++++++++++-- .../components/onkyo/media_player.py | 142 ++++++++++++++---- homeassistant/components/onkyo/strings.json | 23 ++- tests/components/onkyo/__init__.py | 6 +- tests/components/onkyo/test_config_flow.py | 128 ++++++++++------ 7 files changed, 447 insertions(+), 134 deletions(-) diff --git a/homeassistant/components/onkyo/__init__.py b/homeassistant/components/onkyo/__init__.py index fd5c0ba634a..2ebe86da561 100644 --- a/homeassistant/components/onkyo/__init__.py +++ b/homeassistant/components/onkyo/__init__.py @@ -1,6 +1,7 @@ """The onkyo component.""" from dataclasses import dataclass +import logging from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, Platform @@ -9,10 +10,18 @@ from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType -from .const import DOMAIN, OPTION_INPUT_SOURCES, InputSource +from .const import ( + DOMAIN, + OPTION_INPUT_SOURCES, + OPTION_LISTENING_MODES, + InputSource, + ListeningMode, +) from .receiver import Receiver, async_interview from .services import DATA_MP_ENTITIES, async_register_services +_LOGGER = logging.getLogger(__name__) + PLATFORMS = [Platform.MEDIA_PLAYER] CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) @@ -24,6 +33,7 @@ class OnkyoData: receiver: Receiver sources: dict[InputSource, str] + sound_modes: dict[ListeningMode, str] type OnkyoConfigEntry = ConfigEntry[OnkyoData] @@ -50,7 +60,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: OnkyoConfigEntry) -> boo sources_store: dict[str, str] = entry.options[OPTION_INPUT_SOURCES] sources = {InputSource(k): v for k, v in sources_store.items()} - entry.runtime_data = OnkyoData(receiver, sources) + sound_modes_store: dict[str, str] = entry.options.get(OPTION_LISTENING_MODES, {}) + sound_modes = {ListeningMode(k): v for k, v in sound_modes_store.items()} + + entry.runtime_data = OnkyoData(receiver, sources, sound_modes) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/onkyo/config_flow.py b/homeassistant/components/onkyo/config_flow.py index 228748d5257..5d941be959a 100644 --- a/homeassistant/components/onkyo/config_flow.py +++ b/homeassistant/components/onkyo/config_flow.py @@ -1,5 +1,6 @@ """Config flow for Onkyo.""" +from collections.abc import Mapping import logging from typing import Any @@ -33,12 +34,14 @@ from .const import ( CONF_SOURCES, DOMAIN, OPTION_INPUT_SOURCES, + OPTION_LISTENING_MODES, OPTION_MAX_VOLUME, OPTION_MAX_VOLUME_DEFAULT, OPTION_VOLUME_RESOLUTION, OPTION_VOLUME_RESOLUTION_DEFAULT, VOLUME_RESOLUTION_ALLOWED, InputSource, + ListeningMode, ) from .receiver import ReceiverInfo, async_discover, async_interview @@ -46,9 +49,14 @@ _LOGGER = logging.getLogger(__name__) CONF_DEVICE = "device" -INPUT_SOURCES_ALL_MEANINGS = [ - input_source.value_meaning for input_source in InputSource -] +INPUT_SOURCES_DEFAULT: dict[str, str] = {} +LISTENING_MODES_DEFAULT: dict[str, str] = {} +INPUT_SOURCES_ALL_MEANINGS = { + input_source.value_meaning: input_source for input_source in InputSource +} +LISTENING_MODES_ALL_MEANINGS = { + listening_mode.value_meaning: listening_mode for listening_mode in ListeningMode +} STEP_MANUAL_SCHEMA = vol.Schema({vol.Required(CONF_HOST): str}) STEP_RECONFIGURE_SCHEMA = vol.Schema( { @@ -59,7 +67,14 @@ STEP_CONFIGURE_SCHEMA = STEP_RECONFIGURE_SCHEMA.extend( { vol.Required(OPTION_INPUT_SOURCES): SelectSelector( SelectSelectorConfig( - options=INPUT_SOURCES_ALL_MEANINGS, + options=list(INPUT_SOURCES_ALL_MEANINGS), + multiple=True, + mode=SelectSelectorMode.DROPDOWN, + ) + ), + vol.Required(OPTION_LISTENING_MODES): SelectSelector( + SelectSelectorConfig( + options=list(LISTENING_MODES_ALL_MEANINGS), multiple=True, mode=SelectSelectorMode.DROPDOWN, ) @@ -238,9 +253,8 @@ class OnkyoConfigFlow(ConfigFlow, domain=DOMAIN): CONF_HOST: self._receiver_info.host, }, options={ + **entry_options, OPTION_VOLUME_RESOLUTION: volume_resolution, - OPTION_MAX_VOLUME: entry_options[OPTION_MAX_VOLUME], - OPTION_INPUT_SOURCES: entry_options[OPTION_INPUT_SOURCES], }, ) @@ -250,12 +264,24 @@ class OnkyoConfigFlow(ConfigFlow, domain=DOMAIN): input_source_meanings: list[str] = user_input[OPTION_INPUT_SOURCES] if not input_source_meanings: errors[OPTION_INPUT_SOURCES] = "empty_input_source_list" - else: + + listening_modes: list[str] = user_input[OPTION_LISTENING_MODES] + if not listening_modes: + errors[OPTION_LISTENING_MODES] = "empty_listening_mode_list" + + if not errors: input_sources_store: dict[str, str] = {} for input_source_meaning in input_source_meanings: - input_source = InputSource.from_meaning(input_source_meaning) + input_source = INPUT_SOURCES_ALL_MEANINGS[input_source_meaning] input_sources_store[input_source.value] = input_source_meaning + listening_modes_store: dict[str, str] = {} + for listening_mode_meaning in listening_modes: + listening_mode = LISTENING_MODES_ALL_MEANINGS[ + listening_mode_meaning + ] + listening_modes_store[listening_mode.value] = listening_mode_meaning + result = self.async_create_entry( title=self._receiver_info.model_name, data={ @@ -265,6 +291,7 @@ class OnkyoConfigFlow(ConfigFlow, domain=DOMAIN): OPTION_VOLUME_RESOLUTION: volume_resolution, OPTION_MAX_VOLUME: OPTION_MAX_VOLUME_DEFAULT, OPTION_INPUT_SOURCES: input_sources_store, + OPTION_LISTENING_MODES: listening_modes_store, }, ) @@ -278,16 +305,13 @@ class OnkyoConfigFlow(ConfigFlow, domain=DOMAIN): if reconfigure_entry is None: suggested_values = { OPTION_VOLUME_RESOLUTION: OPTION_VOLUME_RESOLUTION_DEFAULT, - OPTION_INPUT_SOURCES: [], + OPTION_INPUT_SOURCES: INPUT_SOURCES_DEFAULT, + OPTION_LISTENING_MODES: LISTENING_MODES_DEFAULT, } else: entry_options = reconfigure_entry.options suggested_values = { OPTION_VOLUME_RESOLUTION: entry_options[OPTION_VOLUME_RESOLUTION], - OPTION_INPUT_SOURCES: [ - InputSource(input_source).value_meaning - for input_source in entry_options[OPTION_INPUT_SOURCES] - ], } return self.async_show_form( @@ -356,6 +380,7 @@ class OnkyoConfigFlow(ConfigFlow, domain=DOMAIN): OPTION_VOLUME_RESOLUTION: volume_resolution, OPTION_MAX_VOLUME: max_volume, OPTION_INPUT_SOURCES: sources_store, + OPTION_LISTENING_MODES: LISTENING_MODES_DEFAULT, }, ) @@ -373,7 +398,14 @@ OPTIONS_STEP_INIT_SCHEMA = vol.Schema( ), vol.Required(OPTION_INPUT_SOURCES): SelectSelector( SelectSelectorConfig( - options=INPUT_SOURCES_ALL_MEANINGS, + options=list(INPUT_SOURCES_ALL_MEANINGS), + multiple=True, + mode=SelectSelectorMode.DROPDOWN, + ) + ), + vol.Required(OPTION_LISTENING_MODES): SelectSelector( + SelectSelectorConfig( + options=list(LISTENING_MODES_ALL_MEANINGS), multiple=True, mode=SelectSelectorMode.DROPDOWN, ) @@ -387,6 +419,7 @@ class OnkyoOptionsFlowHandler(OptionsFlow): _data: dict[str, Any] _input_sources: dict[InputSource, str] + _listening_modes: dict[ListeningMode, str] async def async_step_init( self, user_input: dict[str, Any] | None = None @@ -394,20 +427,40 @@ class OnkyoOptionsFlowHandler(OptionsFlow): """Manage the options.""" errors = {} - entry_options = self.config_entry.options + entry_options: Mapping[str, Any] = self.config_entry.options + entry_options = { + OPTION_LISTENING_MODES: LISTENING_MODES_DEFAULT, + **entry_options, + } if user_input is not None: - self._input_sources = {} - for input_source_meaning in user_input[OPTION_INPUT_SOURCES]: - input_source = InputSource.from_meaning(input_source_meaning) - input_source_name = entry_options[OPTION_INPUT_SOURCES].get( - input_source.value, input_source_meaning - ) - self._input_sources[input_source] = input_source_name - - if not self._input_sources: + input_source_meanings: list[str] = user_input[OPTION_INPUT_SOURCES] + if not input_source_meanings: errors[OPTION_INPUT_SOURCES] = "empty_input_source_list" - else: + + listening_mode_meanings: list[str] = user_input[OPTION_LISTENING_MODES] + if not listening_mode_meanings: + errors[OPTION_LISTENING_MODES] = "empty_listening_mode_list" + + if not errors: + self._input_sources = {} + for input_source_meaning in input_source_meanings: + input_source = INPUT_SOURCES_ALL_MEANINGS[input_source_meaning] + input_source_name = entry_options[OPTION_INPUT_SOURCES].get( + input_source.value, input_source_meaning + ) + self._input_sources[input_source] = input_source_name + + self._listening_modes = {} + for listening_mode_meaning in listening_mode_meanings: + listening_mode = LISTENING_MODES_ALL_MEANINGS[ + listening_mode_meaning + ] + listening_mode_name = entry_options[OPTION_LISTENING_MODES].get( + listening_mode.value, listening_mode_meaning + ) + self._listening_modes[listening_mode] = listening_mode_name + self._data = { OPTION_VOLUME_RESOLUTION: entry_options[OPTION_VOLUME_RESOLUTION], OPTION_MAX_VOLUME: user_input[OPTION_MAX_VOLUME], @@ -423,6 +476,10 @@ class OnkyoOptionsFlowHandler(OptionsFlow): InputSource(input_source).value_meaning for input_source in entry_options[OPTION_INPUT_SOURCES] ], + OPTION_LISTENING_MODES: [ + ListeningMode(listening_mode).value_meaning + for listening_mode in entry_options[OPTION_LISTENING_MODES] + ], } return self.async_show_form( @@ -440,28 +497,48 @@ class OnkyoOptionsFlowHandler(OptionsFlow): if user_input is not None: input_sources_store: dict[str, str] = {} for input_source_meaning, input_source_name in user_input[ - "input_sources" + OPTION_INPUT_SOURCES ].items(): - input_source = InputSource.from_meaning(input_source_meaning) + input_source = INPUT_SOURCES_ALL_MEANINGS[input_source_meaning] input_sources_store[input_source.value] = input_source_name + listening_modes_store: dict[str, str] = {} + for listening_mode_meaning, listening_mode_name in user_input[ + OPTION_LISTENING_MODES + ].items(): + listening_mode = LISTENING_MODES_ALL_MEANINGS[listening_mode_meaning] + listening_modes_store[listening_mode.value] = listening_mode_name + return self.async_create_entry( data={ **self._data, OPTION_INPUT_SOURCES: input_sources_store, + OPTION_LISTENING_MODES: listening_modes_store, } ) - schema_dict: dict[Any, Selector] = {} - + input_sources_schema_dict: dict[Any, Selector] = {} for input_source, input_source_name in self._input_sources.items(): - schema_dict[ + input_sources_schema_dict[ vol.Required(input_source.value_meaning, default=input_source_name) ] = TextSelector() + listening_modes_schema_dict: dict[Any, Selector] = {} + for listening_mode, listening_mode_name in self._listening_modes.items(): + listening_modes_schema_dict[ + vol.Required(listening_mode.value_meaning, default=listening_mode_name) + ] = TextSelector() + return self.async_show_form( step_id="names", data_schema=vol.Schema( - {vol.Required("input_sources"): section(vol.Schema(schema_dict))} + { + vol.Required(OPTION_INPUT_SOURCES): section( + vol.Schema(input_sources_schema_dict) + ), + vol.Required(OPTION_LISTENING_MODES): section( + vol.Schema(listening_modes_schema_dict) + ), + } ), ) diff --git a/homeassistant/components/onkyo/const.py b/homeassistant/components/onkyo/const.py index bd4fe98ae7d..fcb1a8a0a9e 100644 --- a/homeassistant/components/onkyo/const.py +++ b/homeassistant/components/onkyo/const.py @@ -2,7 +2,7 @@ from enum import Enum import typing -from typing import ClassVar, Literal, Self +from typing import Literal, Self import pyeiscp @@ -24,7 +24,27 @@ VOLUME_RESOLUTION_ALLOWED: tuple[VolumeResolution, ...] = typing.get_args( OPTION_MAX_VOLUME = "max_volume" OPTION_MAX_VOLUME_DEFAULT = 100.0 + +class EnumWithMeaning(Enum): + """Enum with meaning.""" + + value_meaning: str + + def __new__(cls, value: str) -> Self: + """Create enum.""" + obj = object.__new__(cls) + obj._value_ = value + obj.value_meaning = cls._get_meanings()[value] + + return obj + + @staticmethod + def _get_meanings() -> dict[str, str]: + raise NotImplementedError + + OPTION_INPUT_SOURCES = "input_sources" +OPTION_LISTENING_MODES = "listening_modes" _INPUT_SOURCE_MEANINGS = { "00": "VIDEO1 ··· VCR/DVR ··· STB/DVR", @@ -71,7 +91,7 @@ _INPUT_SOURCE_MEANINGS = { } -class InputSource(Enum): +class InputSource(EnumWithMeaning): """Receiver input source.""" DVR = "00" @@ -116,24 +136,100 @@ class InputSource(Enum): HDMI_7 = "57" MAIN_SOURCE = "80" - __meaning_mapping: ClassVar[dict[str, Self]] = {} # type: ignore[misc] + @staticmethod + def _get_meanings() -> dict[str, str]: + return _INPUT_SOURCE_MEANINGS - value_meaning: str - def __new__(cls, value: str) -> Self: - """Create InputSource enum.""" - obj = object.__new__(cls) - obj._value_ = value - obj.value_meaning = _INPUT_SOURCE_MEANINGS[value] +_LISTENING_MODE_MEANINGS = { + "00": "STEREO", + "01": "DIRECT", + "02": "SURROUND", + "03": "FILM ··· GAME RPG ··· ADVANCED GAME", + "04": "THX", + "05": "ACTION ··· GAME ACTION", + "06": "MUSICAL ··· GAME ROCK ··· ROCK/POP", + "07": "MONO MOVIE", + "08": "ORCHESTRA ··· CLASSICAL", + "09": "UNPLUGGED", + "0A": "STUDIO MIX ··· ENTERTAINMENT SHOW", + "0B": "TV LOGIC ··· DRAMA", + "0C": "ALL CH STEREO ··· EXTENDED STEREO", + "0D": "THEATER DIMENSIONAL ··· FRONT STAGE SURROUND", + "0E": "ENHANCED 7/ENHANCE ··· GAME SPORTS ··· SPORTS", + "0F": "MONO", + "11": "PURE AUDIO ··· PURE DIRECT", + "12": "MULTIPLEX", + "13": "FULL MONO ··· MONO MUSIC", + "14": "DOLBY VIRTUAL/SURROUND ENHANCER", + "15": "DTS SURROUND SENSATION", + "16": "AUDYSSEY DSX", + "17": "DTS VIRTUAL:X", + "1F": "WHOLE HOUSE MODE ··· MULTI ZONE MUSIC", + "23": "STAGE (JAPAN GENRE CONTROL)", + "25": "ACTION (JAPAN GENRE CONTROL)", + "26": "MUSIC (JAPAN GENRE CONTROL)", + "2E": "SPORTS (JAPAN GENRE CONTROL)", + "40": "STRAIGHT DECODE ··· 5.1 CH SURROUND", + "41": "DOLBY EX/DTS ES", + "42": "THX CINEMA", + "43": "THX SURROUND EX", + "44": "THX MUSIC", + "45": "THX GAMES", + "50": "THX U(2)/S(2)/I/S CINEMA", + "51": "THX U(2)/S(2)/I/S MUSIC", + "52": "THX U(2)/S(2)/I/S GAMES", + "80": "DOLBY ATMOS/DOLBY SURROUND ··· PLII/PLIIx MOVIE", + "81": "PLII/PLIIx MUSIC", + "82": "DTS:X/NEURAL:X ··· NEO:6/NEO:X CINEMA", + "83": "NEO:6/NEO:X MUSIC", + "84": "DOLBY SURROUND THX CINEMA ··· PLII/PLIIx THX CINEMA", + "85": "DTS NEURAL:X THX CINEMA ··· NEO:6/NEO:X THX CINEMA", + "86": "PLII/PLIIx GAME", + "87": "NEURAL SURR", + "88": "NEURAL THX/NEURAL SURROUND", + "89": "DOLBY SURROUND THX GAMES ··· PLII/PLIIx THX GAMES", + "8A": "DTS NEURAL:X THX GAMES ··· NEO:6/NEO:X THX GAMES", + "8B": "DOLBY SURROUND THX MUSIC ··· PLII/PLIIx THX MUSIC", + "8C": "DTS NEURAL:X THX MUSIC ··· NEO:6/NEO:X THX MUSIC", + "8D": "NEURAL THX CINEMA", + "8E": "NEURAL THX MUSIC", + "8F": "NEURAL THX GAMES", + "90": "PLIIz HEIGHT", + "91": "NEO:6 CINEMA DTS SURROUND SENSATION", + "92": "NEO:6 MUSIC DTS SURROUND SENSATION", + "93": "NEURAL DIGITAL MUSIC", + "94": "PLIIz HEIGHT + THX CINEMA", + "95": "PLIIz HEIGHT + THX MUSIC", + "96": "PLIIz HEIGHT + THX GAMES", + "97": "PLIIz HEIGHT + THX U2/S2 CINEMA", + "98": "PLIIz HEIGHT + THX U2/S2 MUSIC", + "99": "PLIIz HEIGHT + THX U2/S2 GAMES", + "9A": "NEO:X GAME", + "A0": "PLIIx/PLII Movie + AUDYSSEY DSX", + "A1": "PLIIx/PLII MUSIC + AUDYSSEY DSX", + "A2": "PLIIx/PLII GAME + AUDYSSEY DSX", + "A3": "NEO:6 CINEMA + AUDYSSEY DSX", + "A4": "NEO:6 MUSIC + AUDYSSEY DSX", + "A5": "NEURAL SURROUND + AUDYSSEY DSX", + "A6": "NEURAL DIGITAL MUSIC + AUDYSSEY DSX", + "A7": "DOLBY EX + AUDYSSEY DSX", + "FF": "AUTO SURROUND", +} - cls.__meaning_mapping[obj.value_meaning] = obj - return obj +class ListeningMode(EnumWithMeaning): + """Receiver listening mode.""" - @classmethod - def from_meaning(cls, meaning: str) -> Self: - """Get InputSource enum from its meaning.""" - return cls.__meaning_mapping[meaning] + _ignore_ = "ListeningMode _k _v _meaning" + + ListeningMode = vars() + for _k in _LISTENING_MODE_MEANINGS: + ListeningMode["I" + _k] = _k + + @staticmethod + def _get_meanings() -> dict[str, str]: + return _LISTENING_MODE_MEANINGS ZONES = {"main": "Main", "zone2": "Zone 2", "zone3": "Zone 3", "zone4": "Zone 4"} diff --git a/homeassistant/components/onkyo/media_player.py b/homeassistant/components/onkyo/media_player.py index 711cede15bc..7c91fda5f78 100644 --- a/homeassistant/components/onkyo/media_player.py +++ b/homeassistant/components/onkyo/media_player.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +from enum import Enum from functools import cache import logging from typing import Any, Literal @@ -39,6 +40,7 @@ from .const import ( PYEISCP_COMMANDS, ZONES, InputSource, + ListeningMode, VolumeResolution, ) from .receiver import Receiver, async_discover @@ -63,6 +65,8 @@ CONF_SOURCES_DEFAULT = { "fm": "Radio", } +ISSUE_URL_PLACEHOLDER = "/config/integrations/dashboard/add?domain=onkyo" + PLATFORM_SCHEMA = MEDIA_PLAYER_PLATFORM_SCHEMA.extend( { vol.Optional(CONF_HOST): cv.string, @@ -79,23 +83,23 @@ PLATFORM_SCHEMA = MEDIA_PLAYER_PLATFORM_SCHEMA.extend( } ) -SUPPORT_ONKYO_WO_VOLUME = ( + +SUPPORTED_FEATURES_BASE = ( MediaPlayerEntityFeature.TURN_ON | MediaPlayerEntityFeature.TURN_OFF | MediaPlayerEntityFeature.SELECT_SOURCE | MediaPlayerEntityFeature.PLAY_MEDIA ) -SUPPORT_ONKYO = ( - SUPPORT_ONKYO_WO_VOLUME - | MediaPlayerEntityFeature.VOLUME_SET +SUPPORTED_FEATURES_VOLUME = ( + MediaPlayerEntityFeature.VOLUME_SET | MediaPlayerEntityFeature.VOLUME_MUTE | MediaPlayerEntityFeature.VOLUME_STEP ) -DEFAULT_PLAYABLE_SOURCES = ( - InputSource.from_meaning("FM"), - InputSource.from_meaning("AM"), - InputSource.from_meaning("DAB"), +PLAYABLE_SOURCES = ( + InputSource.FM, + InputSource.AM, + InputSource.DAB, ) ATTR_PRESET = "preset" @@ -118,7 +122,6 @@ AUDIO_INFORMATION_MAPPING = [ "auto_phase_control_phase", "upmix_mode", ] - VIDEO_INFORMATION_MAPPING = [ "video_input_port", "input_resolution", @@ -131,7 +134,6 @@ VIDEO_INFORMATION_MAPPING = [ "picture_mode", "input_hdr", ] -ISSUE_URL_PLACEHOLDER = "/config/integrations/dashboard/add?domain=onkyo" type LibValue = str | tuple[str, ...] @@ -139,7 +141,19 @@ type LibValue = str | tuple[str, ...] def _get_single_lib_value(value: LibValue) -> str: if isinstance(value, str): return value - return value[0] + return value[-1] + + +def _get_lib_mapping[T: Enum](cmds: Any, cls: type[T]) -> dict[T, LibValue]: + result: dict[T, LibValue] = {} + for k, v in cmds["values"].items(): + try: + key = cls(k) + except ValueError: + continue + result[key] = v["name"] + + return result @cache @@ -154,15 +168,7 @@ def _input_source_lib_mappings(zone: str) -> dict[InputSource, LibValue]: case "zone4": cmds = PYEISCP_COMMANDS["zone4"]["SL4"] - result: dict[InputSource, LibValue] = {} - for k, v in cmds["values"].items(): - try: - source = InputSource(k) - except ValueError: - continue - result[source] = v["name"] - - return result + return _get_lib_mapping(cmds, InputSource) @cache @@ -170,6 +176,24 @@ def _rev_input_source_lib_mappings(zone: str) -> dict[LibValue, InputSource]: return {value: key for key, value in _input_source_lib_mappings(zone).items()} +@cache +def _listening_mode_lib_mappings(zone: str) -> dict[ListeningMode, LibValue]: + match zone: + case "main": + cmds = PYEISCP_COMMANDS["main"]["LMD"] + case "zone2": + cmds = PYEISCP_COMMANDS["zone2"]["LMZ"] + case _: + return {} + + return _get_lib_mapping(cmds, ListeningMode) + + +@cache +def _rev_listening_mode_lib_mappings(zone: str) -> dict[LibValue, ListeningMode]: + return {value: key for key, value in _listening_mode_lib_mappings(zone).items()} + + async def async_setup_platform( hass: HomeAssistant, config: ConfigType, @@ -303,6 +327,7 @@ async def async_setup_entry( volume_resolution: VolumeResolution = entry.options[OPTION_VOLUME_RESOLUTION] max_volume: float = entry.options[OPTION_MAX_VOLUME] sources = data.sources + sound_modes = data.sound_modes def connect_callback(receiver: Receiver) -> None: if not receiver.first_connect: @@ -331,6 +356,7 @@ async def async_setup_entry( volume_resolution=volume_resolution, max_volume=max_volume, sources=sources, + sound_modes=sound_modes, ) entities[zone] = zone_entity async_add_entities([zone_entity]) @@ -345,6 +371,7 @@ class OnkyoMediaPlayer(MediaPlayerEntity): _attr_should_poll = False _supports_volume: bool = False + _supports_sound_mode: bool = False _supports_audio_info: bool = False _supports_video_info: bool = False _query_timer: asyncio.TimerHandle | None = None @@ -357,6 +384,7 @@ class OnkyoMediaPlayer(MediaPlayerEntity): volume_resolution: VolumeResolution, max_volume: float, sources: dict[InputSource, str], + sound_modes: dict[ListeningMode, str], ) -> None: """Initialize the Onkyo Receiver.""" self._receiver = receiver @@ -381,7 +409,27 @@ class OnkyoMediaPlayer(MediaPlayerEntity): value: key for key, value in self._source_mapping.items() } + self._sound_mode_lib_mapping = _listening_mode_lib_mappings(zone) + self._rev_sound_mode_lib_mapping = _rev_listening_mode_lib_mappings(zone) + self._sound_mode_mapping = { + key: value + for key, value in sound_modes.items() + if key in self._sound_mode_lib_mapping + } + self._rev_sound_mode_mapping = { + value: key for key, value in self._sound_mode_mapping.items() + } + self._attr_source_list = list(self._rev_source_mapping) + self._attr_sound_mode_list = list(self._rev_sound_mode_mapping) + + self._attr_supported_features = SUPPORTED_FEATURES_BASE + if zone == "main": + self._attr_supported_features |= SUPPORTED_FEATURES_VOLUME + self._supports_volume = True + self._attr_supported_features |= MediaPlayerEntityFeature.SELECT_SOUND_MODE + self._supports_sound_mode = True + self._attr_extra_state_attributes = {} async def async_added_to_hass(self) -> None: @@ -394,13 +442,6 @@ class OnkyoMediaPlayer(MediaPlayerEntity): self._query_timer.cancel() self._query_timer = None - @property - def supported_features(self) -> MediaPlayerEntityFeature: - """Return media player features that are supported.""" - if self._supports_volume: - return SUPPORT_ONKYO - return SUPPORT_ONKYO_WO_VOLUME - @callback def _update_receiver(self, propname: str, value: Any) -> None: """Update a property in the receiver.""" @@ -466,6 +507,24 @@ class OnkyoMediaPlayer(MediaPlayerEntity): "input-selector" if self._zone == "main" else "selector", source_lib_single ) + async def async_select_sound_mode(self, sound_mode: str) -> None: + """Select listening sound mode.""" + if not self.sound_mode_list or sound_mode not in self.sound_mode_list: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_sound_mode", + translation_placeholders={ + "invalid_sound_mode": sound_mode, + "entity_id": self.entity_id, + }, + ) + + sound_mode_lib = self._sound_mode_lib_mapping[ + self._rev_sound_mode_mapping[sound_mode] + ] + sound_mode_lib_single = _get_single_lib_value(sound_mode_lib) + self._update_receiver("listening-mode", sound_mode_lib_single) + async def async_select_output(self, hdmi_output: str) -> None: """Set hdmi-out.""" self._update_receiver("hdmi-output-selector", hdmi_output) @@ -476,7 +535,7 @@ class OnkyoMediaPlayer(MediaPlayerEntity): """Play radio station by preset number.""" if self.source is not None: source = self._rev_source_mapping[self.source] - if media_type.lower() == "radio" and source in DEFAULT_PLAYABLE_SOURCES: + if media_type.lower() == "radio" and source in PLAYABLE_SOURCES: self._update_receiver("preset", media_id) @callback @@ -517,7 +576,9 @@ class OnkyoMediaPlayer(MediaPlayerEntity): self._attr_extra_state_attributes.pop(ATTR_PRESET, None) self._attr_extra_state_attributes.pop(ATTR_VIDEO_OUT, None) elif command in ["volume", "master-volume"] and value != "N/A": - self._supports_volume = True + if not self._supports_volume: + self._attr_supported_features |= SUPPORTED_FEATURES_VOLUME + self._supports_volume = True # AMP_VOL / (VOL_RESOLUTION * (MAX_VOL / 100)) volume_level: float = value / ( self._volume_resolution * self._max_volume / 100 @@ -535,6 +596,14 @@ class OnkyoMediaPlayer(MediaPlayerEntity): self._attr_extra_state_attributes[ATTR_PRESET] = value elif ATTR_PRESET in self._attr_extra_state_attributes: del self._attr_extra_state_attributes[ATTR_PRESET] + elif command == "listening-mode" and value != "N/A": + if not self._supports_sound_mode: + self._attr_supported_features |= ( + MediaPlayerEntityFeature.SELECT_SOUND_MODE + ) + self._supports_sound_mode = True + self._parse_sound_mode(value) + self._query_av_info_delayed() elif command == "audio-information": self._supports_audio_info = True self._parse_audio_information(value) @@ -561,6 +630,21 @@ class OnkyoMediaPlayer(MediaPlayerEntity): ) self._attr_source = source_meaning + @callback + def _parse_sound_mode(self, mode_lib: LibValue) -> None: + sound_mode = self._rev_sound_mode_lib_mapping[mode_lib] + if sound_mode in self._sound_mode_mapping: + self._attr_sound_mode = self._sound_mode_mapping[sound_mode] + return + + sound_mode_meaning = sound_mode.value_meaning + _LOGGER.error( + 'Listening mode "%s" is invalid for entity: %s', + sound_mode_meaning, + self.entity_id, + ) + self._attr_sound_mode = sound_mode_meaning + @callback def _parse_audio_information( self, audio_information: tuple[str] | Literal["N/A"] diff --git a/homeassistant/components/onkyo/strings.json b/homeassistant/components/onkyo/strings.json index b3b14efec44..d8131dd1149 100644 --- a/homeassistant/components/onkyo/strings.json +++ b/homeassistant/components/onkyo/strings.json @@ -27,17 +27,20 @@ "description": "Configure {name}", "data": { "volume_resolution": "Volume resolution", - "input_sources": "[%key:component::onkyo::options::step::init::data::input_sources%]" + "input_sources": "[%key:component::onkyo::options::step::init::data::input_sources%]", + "listening_modes": "[%key:component::onkyo::options::step::init::data::listening_modes%]" }, "data_description": { "volume_resolution": "Number of steps it takes for the receiver to go from the lowest to the highest possible volume.", - "input_sources": "[%key:component::onkyo::options::step::init::data_description::input_sources%]" + "input_sources": "[%key:component::onkyo::options::step::init::data_description::input_sources%]", + "listening_modes": "[%key:component::onkyo::options::step::init::data_description::listening_modes%]" } } }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "empty_input_source_list": "[%key:component::onkyo::options::error::empty_input_source_list%]", + "empty_listening_mode_list": "[%key:component::onkyo::options::error::empty_listening_mode_list%]", "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { @@ -53,11 +56,13 @@ "init": { "data": { "max_volume": "Maximum volume limit (%)", - "input_sources": "Input sources" + "input_sources": "Input sources", + "listening_modes": "Listening modes" }, "data_description": { "max_volume": "Maximum volume limit as a percentage. This will associate Home Assistant's maximum volume to this value on the receiver, i.e., if you set this to 50%, then setting the volume to 100% in Home Assistant will cause the volume on the receiver to be set to 50% of its maximum value.", - "input_sources": "List of input sources supported by the receiver." + "input_sources": "List of input sources supported by the receiver.", + "listening_modes": "List of listening modes supported by the receiver." } }, "names": { @@ -65,12 +70,17 @@ "input_sources": { "name": "Input source names", "description": "Mappings of receiver's input sources to their names." + }, + "listening_modes": { + "name": "Listening mode names", + "description": "Mappings of receiver's listening modes to their names." } } } }, "error": { - "empty_input_source_list": "Input source list cannot be empty" + "empty_input_source_list": "Input source list cannot be empty", + "empty_listening_mode_list": "Listening mode list cannot be empty" } }, "issues": { @@ -84,6 +94,9 @@ } }, "exceptions": { + "invalid_sound_mode": { + "message": "Cannot select sound mode \"{invalid_sound_mode}\" for entity: {entity_id}." + }, "invalid_source": { "message": "Cannot select input source \"{invalid_source}\" for entity: {entity_id}." } diff --git a/tests/components/onkyo/__init__.py b/tests/components/onkyo/__init__.py index 064075d109e..689711888d8 100644 --- a/tests/components/onkyo/__init__.py +++ b/tests/components/onkyo/__init__.py @@ -34,8 +34,9 @@ def create_config_entry_from_info(info: ReceiverInfo) -> MockConfigEntry: data = {CONF_HOST: info.host} options = { "volume_resolution": 80, - "input_sources": {"12": "tv"}, "max_volume": 100, + "input_sources": {"12": "tv"}, + "listening_modes": {"00": "stereo"}, } return MockConfigEntry( @@ -52,8 +53,9 @@ def create_empty_config_entry() -> MockConfigEntry: data = {CONF_HOST: ""} options = { "volume_resolution": 80, - "input_sources": {"12": "tv"}, "max_volume": 100, + "input_sources": {"12": "tv"}, + "listening_modes": {"00": "stereo"}, } return MockConfigEntry( diff --git a/tests/components/onkyo/test_config_flow.py b/tests/components/onkyo/test_config_flow.py index 203cc22cf95..000e74d5308 100644 --- a/tests/components/onkyo/test_config_flow.py +++ b/tests/components/onkyo/test_config_flow.py @@ -11,7 +11,9 @@ from homeassistant.components.onkyo.config_flow import OnkyoConfigFlow from homeassistant.components.onkyo.const import ( DOMAIN, OPTION_INPUT_SOURCES, + OPTION_LISTENING_MODES, OPTION_MAX_VOLUME, + OPTION_MAX_VOLUME_DEFAULT, OPTION_VOLUME_RESOLUTION, ) from homeassistant.config_entries import SOURCE_USER @@ -226,7 +228,11 @@ async def test_ssdp_discovery_success( select_result = await hass.config_entries.flow.async_configure( result["flow_id"], - user_input={"volume_resolution": 200, "input_sources": ["TV"]}, + user_input={ + "volume_resolution": 200, + "input_sources": ["TV"], + "listening_modes": ["THX"], + }, ) assert select_result["type"] is FlowResultType.CREATE_ENTRY @@ -349,34 +355,6 @@ async def test_ssdp_discovery_no_host( assert result["reason"] == "unknown" -async def test_configure_empty_source_list( - hass: HomeAssistant, default_mock_discovery -) -> None: - """Test receiver configuration with no sources set.""" - - init_result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - ) - - form_result = await hass.config_entries.flow.async_configure( - init_result["flow_id"], - {"next_step_id": "manual"}, - ) - - select_result = await hass.config_entries.flow.async_configure( - form_result["flow_id"], - user_input={CONF_HOST: "sample-host-name"}, - ) - - configure_result = await hass.config_entries.flow.async_configure( - select_result["flow_id"], - user_input={"volume_resolution": 200, "input_sources": []}, - ) - - assert configure_result["errors"] == {"input_sources": "empty_input_source_list"} - - async def test_configure_no_resolution( hass: HomeAssistant, default_mock_discovery ) -> None: @@ -404,33 +382,61 @@ async def test_configure_no_resolution( ) -async def test_configure_resolution_set( - hass: HomeAssistant, default_mock_discovery -) -> None: - """Test receiver configure with specified resolution.""" +async def test_configure(hass: HomeAssistant, default_mock_discovery) -> None: + """Test receiver configure.""" - init_result = await hass.config_entries.flow.async_init( + result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, ) - form_result = await hass.config_entries.flow.async_configure( - init_result["flow_id"], + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "manual"}, ) - select_result = await hass.config_entries.flow.async_configure( - form_result["flow_id"], + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_HOST: "sample-host-name"}, ) - configure_result = await hass.config_entries.flow.async_configure( - select_result["flow_id"], - user_input={"volume_resolution": 200, "input_sources": ["TV"]}, + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + OPTION_VOLUME_RESOLUTION: 200, + OPTION_INPUT_SOURCES: [], + OPTION_LISTENING_MODES: ["THX"], + }, ) + assert result["step_id"] == "configure_receiver" + assert result["errors"] == {OPTION_INPUT_SOURCES: "empty_input_source_list"} - assert configure_result["type"] is FlowResultType.CREATE_ENTRY - assert configure_result["options"]["volume_resolution"] == 200 + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + OPTION_VOLUME_RESOLUTION: 200, + OPTION_INPUT_SOURCES: ["TV"], + OPTION_LISTENING_MODES: [], + }, + ) + assert result["step_id"] == "configure_receiver" + assert result["errors"] == {OPTION_LISTENING_MODES: "empty_listening_mode_list"} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + OPTION_VOLUME_RESOLUTION: 200, + OPTION_INPUT_SOURCES: ["TV"], + OPTION_LISTENING_MODES: ["THX"], + }, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["options"] == { + OPTION_VOLUME_RESOLUTION: 200, + OPTION_MAX_VOLUME: OPTION_MAX_VOLUME_DEFAULT, + OPTION_INPUT_SOURCES: {"12": "TV"}, + OPTION_LISTENING_MODES: {"04": "THX"}, + } async def test_configure_invalid_resolution_set( @@ -601,21 +607,26 @@ async def test_import_success( await hass.async_block_till_done() assert import_result["type"] is FlowResultType.CREATE_ENTRY - assert import_result["data"]["host"] == "host 1" - assert import_result["options"]["volume_resolution"] == 80 - assert import_result["options"]["max_volume"] == 100 - assert import_result["options"]["input_sources"] == { - "00": "Auxiliary", - "01": "Video", + assert import_result["data"] == {"host": "host 1"} + assert import_result["options"] == { + "volume_resolution": 80, + "max_volume": 100, + "input_sources": { + "00": "Auxiliary", + "01": "Video", + }, + "listening_modes": {}, } @pytest.mark.parametrize( "ignore_translations", [ - [ # The schema is dynamically created from input sources + [ # The schema is dynamically created from input sources and listening modes "component.onkyo.options.step.names.sections.input_sources.data.TV", "component.onkyo.options.step.names.sections.input_sources.data_description.TV", + "component.onkyo.options.step.names.sections.listening_modes.data.STEREO", + "component.onkyo.options.step.names.sections.listening_modes.data_description.STEREO", ] ], ) @@ -635,6 +646,7 @@ async def test_options_flow(hass: HomeAssistant, config_entry: MockConfigEntry) user_input={ OPTION_MAX_VOLUME: 42, OPTION_INPUT_SOURCES: [], + OPTION_LISTENING_MODES: ["STEREO"], }, ) @@ -647,6 +659,20 @@ async def test_options_flow(hass: HomeAssistant, config_entry: MockConfigEntry) user_input={ OPTION_MAX_VOLUME: 42, OPTION_INPUT_SOURCES: ["TV"], + OPTION_LISTENING_MODES: [], + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "init" + assert result["errors"] == {OPTION_LISTENING_MODES: "empty_listening_mode_list"} + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + OPTION_MAX_VOLUME: 42, + OPTION_INPUT_SOURCES: ["TV"], + OPTION_LISTENING_MODES: ["STEREO"], }, ) @@ -657,6 +683,7 @@ async def test_options_flow(hass: HomeAssistant, config_entry: MockConfigEntry) result["flow_id"], user_input={ OPTION_INPUT_SOURCES: {"TV": "television"}, + OPTION_LISTENING_MODES: {"STEREO": "Duophonia"}, }, ) @@ -665,4 +692,5 @@ async def test_options_flow(hass: HomeAssistant, config_entry: MockConfigEntry) OPTION_VOLUME_RESOLUTION: old_volume_resolution, OPTION_MAX_VOLUME: 42.0, OPTION_INPUT_SOURCES: {"12": "television"}, + OPTION_LISTENING_MODES: {"00": "Duophonia"}, } From 4e904bf5a3f202da06f38a0c3d6843e6d0c1afa7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noah=20Gro=C3=9F?= Date: Tue, 25 Feb 2025 17:21:31 +0100 Subject: [PATCH 1799/3148] Use new python library for picnic component (#139111) --- CODEOWNERS | 4 ++-- homeassistant/components/picnic/__init__.py | 2 +- homeassistant/components/picnic/config_flow.py | 4 ++-- homeassistant/components/picnic/coordinator.py | 4 ++-- homeassistant/components/picnic/manifest.json | 6 +++--- homeassistant/components/picnic/services.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/picnic/test_config_flow.py | 2 +- 9 files changed, 14 insertions(+), 14 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 87f170009f0..1052a58fe88 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1146,8 +1146,8 @@ build.json @home-assistant/supervisor /tests/components/philips_js/ @elupus /homeassistant/components/pi_hole/ @shenxn /tests/components/pi_hole/ @shenxn -/homeassistant/components/picnic/ @corneyl -/tests/components/picnic/ @corneyl +/homeassistant/components/picnic/ @corneyl @codesalatdev +/tests/components/picnic/ @corneyl @codesalatdev /homeassistant/components/ping/ @jpbede /tests/components/ping/ @jpbede /homeassistant/components/plaato/ @JohNan diff --git a/homeassistant/components/picnic/__init__.py b/homeassistant/components/picnic/__init__.py index d2f023af79f..8de407133cd 100644 --- a/homeassistant/components/picnic/__init__.py +++ b/homeassistant/components/picnic/__init__.py @@ -1,6 +1,6 @@ """The Picnic integration.""" -from python_picnic_api import PicnicAPI +from python_picnic_api2 import PicnicAPI from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ACCESS_TOKEN, CONF_COUNTRY_CODE, Platform diff --git a/homeassistant/components/picnic/config_flow.py b/homeassistant/components/picnic/config_flow.py index 4c8281f21de..a60086173a8 100644 --- a/homeassistant/components/picnic/config_flow.py +++ b/homeassistant/components/picnic/config_flow.py @@ -6,8 +6,8 @@ from collections.abc import Mapping import logging from typing import Any -from python_picnic_api import PicnicAPI -from python_picnic_api.session import PicnicAuthError +from python_picnic_api2 import PicnicAPI +from python_picnic_api2.session import PicnicAuthError import requests import voluptuous as vol diff --git a/homeassistant/components/picnic/coordinator.py b/homeassistant/components/picnic/coordinator.py index de686cad37d..9b23157dbf3 100644 --- a/homeassistant/components/picnic/coordinator.py +++ b/homeassistant/components/picnic/coordinator.py @@ -6,8 +6,8 @@ import copy from datetime import timedelta import logging -from python_picnic_api import PicnicAPI -from python_picnic_api.session import PicnicAuthError +from python_picnic_api2 import PicnicAPI +from python_picnic_api2.session import PicnicAuthError from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ACCESS_TOKEN diff --git a/homeassistant/components/picnic/manifest.json b/homeassistant/components/picnic/manifest.json index 947dd0241d2..09f28da39a4 100644 --- a/homeassistant/components/picnic/manifest.json +++ b/homeassistant/components/picnic/manifest.json @@ -1,10 +1,10 @@ { "domain": "picnic", "name": "Picnic", - "codeowners": ["@corneyl"], + "codeowners": ["@corneyl", "@codesalatdev"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/picnic", "iot_class": "cloud_polling", - "loggers": ["python_picnic_api"], - "requirements": ["python-picnic-api==1.1.0"] + "loggers": ["python_picnic_api2"], + "requirements": ["python-picnic-api2==1.2.2"] } diff --git a/homeassistant/components/picnic/services.py b/homeassistant/components/picnic/services.py index bbc775891b7..76d7b8a6c44 100644 --- a/homeassistant/components/picnic/services.py +++ b/homeassistant/components/picnic/services.py @@ -4,7 +4,7 @@ from __future__ import annotations from typing import cast -from python_picnic_api import PicnicAPI +from python_picnic_api2 import PicnicAPI import voluptuous as vol from homeassistant.core import HomeAssistant, ServiceCall diff --git a/requirements_all.txt b/requirements_all.txt index 00194d2f15b..44cd0de4281 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2455,7 +2455,7 @@ python-otbr-api==2.7.0 python-overseerr==0.7.0 # homeassistant.components.picnic -python-picnic-api==1.1.0 +python-picnic-api2==1.2.2 # homeassistant.components.rabbitair python-rabbitair==0.0.8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 180ed7d43e4..b6c384e9944 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1991,7 +1991,7 @@ python-otbr-api==2.7.0 python-overseerr==0.7.0 # homeassistant.components.picnic -python-picnic-api==1.1.0 +python-picnic-api2==1.2.2 # homeassistant.components.rabbitair python-rabbitair==0.0.8 diff --git a/tests/components/picnic/test_config_flow.py b/tests/components/picnic/test_config_flow.py index 8d668b28c16..ba4c36682e1 100644 --- a/tests/components/picnic/test_config_flow.py +++ b/tests/components/picnic/test_config_flow.py @@ -3,7 +3,7 @@ from unittest.mock import patch import pytest -from python_picnic_api.session import PicnicAuthError +from python_picnic_api2.session import PicnicAuthError import requests from homeassistant import config_entries From a910fb879c9760da64f1db6d50787dbda03cab72 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 25 Feb 2025 18:23:32 +0100 Subject: [PATCH 1800/3148] Bump securetar to 2025.2.1 (#139273) --- homeassistant/components/backup/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/backup/manifest.json b/homeassistant/components/backup/manifest.json index 6cbfb834c7f..db0719983b1 100644 --- a/homeassistant/components/backup/manifest.json +++ b/homeassistant/components/backup/manifest.json @@ -8,5 +8,5 @@ "integration_type": "system", "iot_class": "calculated", "quality_scale": "internal", - "requirements": ["cronsim==2.6", "securetar==2025.1.4"] + "requirements": ["cronsim==2.6", "securetar==2025.2.1"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index e4f9466a10e..6a6c1dfc3ed 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -60,7 +60,7 @@ python-slugify==8.0.4 PyTurboJPEG==1.7.5 PyYAML==6.0.2 requests==2.32.3 -securetar==2025.1.4 +securetar==2025.2.1 SQLAlchemy==2.0.38 standard-aifc==3.13.0 standard-telnetlib==3.13.0 diff --git a/pyproject.toml b/pyproject.toml index 7a970b405a6..a7e3917eb90 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -66,7 +66,7 @@ dependencies = [ "python-slugify==8.0.4", "PyYAML==6.0.2", "requests==2.32.3", - "securetar==2025.1.4", + "securetar==2025.2.1", "SQLAlchemy==2.0.38", "standard-aifc==3.13.0", "standard-telnetlib==3.13.0", diff --git a/requirements.txt b/requirements.txt index f002f0d6ecc..b378688106d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -38,7 +38,7 @@ psutil-home-assistant==0.0.1 python-slugify==8.0.4 PyYAML==6.0.2 requests==2.32.3 -securetar==2025.1.4 +securetar==2025.2.1 SQLAlchemy==2.0.38 standard-aifc==3.13.0 standard-telnetlib==3.13.0 diff --git a/requirements_all.txt b/requirements_all.txt index 44cd0de4281..592add8e73e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2690,7 +2690,7 @@ screenlogicpy==0.10.0 scsgate==0.1.0 # homeassistant.components.backup -securetar==2025.1.4 +securetar==2025.2.1 # homeassistant.components.sendgrid sendgrid==6.8.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b6c384e9944..e9510d296fe 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2172,7 +2172,7 @@ sanix==1.0.6 screenlogicpy==0.10.0 # homeassistant.components.backup -securetar==2025.1.4 +securetar==2025.2.1 # homeassistant.components.emulated_kasa # homeassistant.components.sense From a1d1f6ec97c68ecbb544cd40694d827ae429674a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 25 Feb 2025 18:08:53 +0000 Subject: [PATCH 1801/3148] Fix race in async_get_integrations with multiple calls when an integration is not found (#139270) * Fix race in async_get_integrations with multiple calls when an integration is not found * Fix race in async_get_integrations with multiple calls when an integration is not found * Fix race in async_get_integrations with multiple calls when an integration is not found * tweaks * tweaks * tweaks * restore lost comment * tweak test * comment cache * improve test * improve comment --- homeassistant/loader.py | 68 ++++++++++++++++++++++------------------- tests/test_loader.py | 56 +++++++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+), 31 deletions(-) diff --git a/homeassistant/loader.py b/homeassistant/loader.py index 92b588dbe15..008c2b057b2 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -40,7 +40,6 @@ from .generated.ssdp import SSDP from .generated.usb import USB from .generated.zeroconf import HOMEKIT, ZEROCONF from .helpers.json import json_bytes, json_fragment -from .helpers.typing import UNDEFINED from .util.hass_dict import HassKey from .util.json import JSON_DECODE_EXCEPTIONS, json_loads @@ -125,9 +124,9 @@ BLOCKED_CUSTOM_INTEGRATIONS: dict[str, BlockedIntegration] = { DATA_COMPONENTS: HassKey[dict[str, ModuleType | ComponentProtocol]] = HassKey( "components" ) -DATA_INTEGRATIONS: HassKey[dict[str, Integration | asyncio.Future[None]]] = HassKey( - "integrations" -) +DATA_INTEGRATIONS: HassKey[ + dict[str, Integration | asyncio.Future[Integration | IntegrationNotFound]] +] = HassKey("integrations") DATA_MISSING_PLATFORMS: HassKey[dict[str, bool]] = HassKey("missing_platforms") DATA_CUSTOM_COMPONENTS: HassKey[ dict[str, Integration] | asyncio.Future[dict[str, Integration]] @@ -1345,7 +1344,7 @@ def async_get_loaded_integration(hass: HomeAssistant, domain: str) -> Integratio Raises IntegrationNotLoaded if the integration is not loaded. """ cache = hass.data[DATA_INTEGRATIONS] - int_or_fut = cache.get(domain, UNDEFINED) + int_or_fut = cache.get(domain) # Integration is never subclassed, so we can check for type if type(int_or_fut) is Integration: return int_or_fut @@ -1355,7 +1354,7 @@ def async_get_loaded_integration(hass: HomeAssistant, domain: str) -> Integratio async def async_get_integration(hass: HomeAssistant, domain: str) -> Integration: """Get integration.""" cache = hass.data[DATA_INTEGRATIONS] - if type(int_or_fut := cache.get(domain, UNDEFINED)) is Integration: + if type(int_or_fut := cache.get(domain)) is Integration: return int_or_fut integrations_or_excs = await async_get_integrations(hass, [domain]) int_or_exc = integrations_or_excs[domain] @@ -1370,15 +1369,17 @@ async def async_get_integrations( """Get integrations.""" cache = hass.data[DATA_INTEGRATIONS] results: dict[str, Integration | Exception] = {} - needed: dict[str, asyncio.Future[None]] = {} - in_progress: dict[str, asyncio.Future[None]] = {} + needed: dict[str, asyncio.Future[Integration | IntegrationNotFound]] = {} + in_progress: dict[str, asyncio.Future[Integration | IntegrationNotFound]] = {} for domain in domains: - int_or_fut = cache.get(domain, UNDEFINED) + int_or_fut = cache.get(domain) # Integration is never subclassed, so we can check for type if type(int_or_fut) is Integration: results[domain] = int_or_fut - elif int_or_fut is not UNDEFINED: - in_progress[domain] = cast(asyncio.Future[None], int_or_fut) + elif int_or_fut: + if TYPE_CHECKING: + assert isinstance(int_or_fut, asyncio.Future) + in_progress[domain] = int_or_fut elif "." in domain: results[domain] = ValueError(f"Invalid domain {domain}") else: @@ -1386,14 +1387,13 @@ async def async_get_integrations( if in_progress: await asyncio.wait(in_progress.values()) - for domain in in_progress: - # When we have waited and it's UNDEFINED, it doesn't exist - # We don't cache that it doesn't exist, or else people can't fix it - # and then restart, because their config will never be valid. - if (int_or_fut := cache.get(domain, UNDEFINED)) is UNDEFINED: - results[domain] = IntegrationNotFound(domain) - else: - results[domain] = cast(Integration, int_or_fut) + # Here we retrieve the results we waited for + # instead of reading them from the cache since + # reading from the cache will have a race if + # the integration gets removed from the cache + # because it was not found. + for domain, future in in_progress.items(): + results[domain] = future.result() if not needed: return results @@ -1405,7 +1405,7 @@ async def async_get_integrations( for domain, future in needed.items(): if integration := custom.get(domain): results[domain] = cache[domain] = integration - future.set_result(None) + future.set_result(integration) for domain in results: if domain in needed: @@ -1419,18 +1419,24 @@ async def async_get_integrations( _resolve_integrations_from_root, hass, components, needed ) for domain, future in needed.items(): - int_or_exc = integrations.get(domain) - if not int_or_exc: - cache.pop(domain) - results[domain] = IntegrationNotFound(domain) - elif isinstance(int_or_exc, Exception): - cache.pop(domain) - exc = IntegrationNotFound(domain) - exc.__cause__ = int_or_exc - results[domain] = exc + if integration := integrations.get(domain): + results[domain] = cache[domain] = integration + future.set_result(integration) else: - results[domain] = cache[domain] = int_or_exc - future.set_result(None) + # We don't cache that it doesn't exist as configuration + # validation that relies on integrations being loaded + # would be unfixable. For example if a custom integration + # was temporarily removed. + # This allows restoring a missing integration to fix the + # validation error so the config validations checks do not + # block restarting. + del cache[domain] + exc = IntegrationNotFound(domain) + results[domain] = exc + # We don't use set_exception because + # we expect there will be cases where + # the a future exception is never retrieved + future.set_result(exc) return results diff --git a/tests/test_loader.py b/tests/test_loader.py index 4c3c4eb309f..8afe800144c 100644 --- a/tests/test_loader.py +++ b/tests/test_loader.py @@ -2039,3 +2039,59 @@ async def test_manifest_json_fragment_round_trip(hass: HomeAssistant) -> None: json_loads(json_dumps(integration.manifest_json_fragment)) == integration.manifest ) + + +async def test_async_get_integrations_multiple_non_existent( + hass: HomeAssistant, +) -> None: + """Test async_get_integrations with multiple non-existent integrations.""" + integrations = await loader.async_get_integrations(hass, ["does_not_exist"]) + assert isinstance(integrations["does_not_exist"], loader.IntegrationNotFound) + + async def slow_load_failure( + *args: Any, **kwargs: Any + ) -> dict[str, loader.Integration]: + await asyncio.sleep(0.1) + return {} + + with patch.object(hass, "async_add_executor_job", slow_load_failure): + task1 = hass.async_create_task( + loader.async_get_integrations(hass, ["does_not_exist", "does_not_exist2"]) + ) + # Task one should now be waiting for executor job + task2 = hass.async_create_task( + loader.async_get_integrations(hass, ["does_not_exist"]) + ) + # Task two should be waiting for the futures created in task one + task3 = hass.async_create_task( + loader.async_get_integrations(hass, ["does_not_exist2", "does_not_exist"]) + ) + # Task three should be waiting for the futures created in task one + integrations_1 = await task1 + assert isinstance(integrations_1["does_not_exist"], loader.IntegrationNotFound) + assert isinstance(integrations_1["does_not_exist2"], loader.IntegrationNotFound) + integrations_2 = await task2 + assert isinstance(integrations_2["does_not_exist"], loader.IntegrationNotFound) + integrations_3 = await task3 + assert isinstance(integrations_3["does_not_exist2"], loader.IntegrationNotFound) + assert isinstance(integrations_3["does_not_exist"], loader.IntegrationNotFound) + + # Make sure IntegrationNotFound is not cached + # so configuration errors can be fixed as to + # not prevent Home Assistant from being restarted + integration = loader.Integration( + hass, + "custom_components.does_not_exist", + None, + { + "name": "Does not exist", + "domain": "does_not_exist", + }, + ) + with patch.object( + loader, + "_resolve_integrations_from_root", + return_value={"does_not_exist": integration}, + ): + integrations = await loader.async_get_integrations(hass, ["does_not_exist"]) + assert integrations["does_not_exist"] is integration From cd4c79450b7a97c8994f16f8705290bba823e220 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 25 Feb 2025 19:17:11 +0100 Subject: [PATCH 1802/3148] Bump python-overseerr to 0.7.1 (#139263) Co-authored-by: Shay Levy --- homeassistant/components/overseerr/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/overseerr/manifest.json b/homeassistant/components/overseerr/manifest.json index 6258481adcf..3c4321ebb37 100644 --- a/homeassistant/components/overseerr/manifest.json +++ b/homeassistant/components/overseerr/manifest.json @@ -9,5 +9,5 @@ "integration_type": "service", "iot_class": "local_push", "quality_scale": "platinum", - "requirements": ["python-overseerr==0.7.0"] + "requirements": ["python-overseerr==0.7.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 592add8e73e..c318a069597 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2452,7 +2452,7 @@ python-opensky==1.0.1 python-otbr-api==2.7.0 # homeassistant.components.overseerr -python-overseerr==0.7.0 +python-overseerr==0.7.1 # homeassistant.components.picnic python-picnic-api2==1.2.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e9510d296fe..d42434585d1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1988,7 +1988,7 @@ python-opensky==1.0.1 python-otbr-api==2.7.0 # homeassistant.components.overseerr -python-overseerr==0.7.0 +python-overseerr==0.7.1 # homeassistant.components.picnic python-picnic-api2==1.2.2 From 2cd496fdafda5a63fb20464970779d16d677dffd Mon Sep 17 00:00:00 2001 From: G Johansson Date: Tue, 25 Feb 2025 19:36:45 +0100 Subject: [PATCH 1803/3148] Add coordinator to SMHI (#139052) * Add coordinator to SMHI * Remove not needed logging * docstrings --- homeassistant/components/smhi/__init__.py | 13 ++- homeassistant/components/smhi/const.py | 7 ++ homeassistant/components/smhi/coordinator.py | 63 +++++++++++ homeassistant/components/smhi/entity.py | 17 +-- homeassistant/components/smhi/weather.py | 107 +++++++------------ tests/components/smhi/test_weather.py | 100 +++++++++-------- 6 files changed, 176 insertions(+), 131 deletions(-) create mode 100644 homeassistant/components/smhi/coordinator.py diff --git a/homeassistant/components/smhi/__init__.py b/homeassistant/components/smhi/__init__.py index 59b32948879..1869b333071 100644 --- a/homeassistant/components/smhi/__init__.py +++ b/homeassistant/components/smhi/__init__.py @@ -1,6 +1,5 @@ """Support for the Swedish weather institute weather service.""" -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_LATITUDE, CONF_LOCATION, @@ -10,10 +9,12 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant +from .coordinator import SMHIConfigEntry, SMHIDataUpdateCoordinator + PLATFORMS = [Platform.WEATHER] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: SMHIConfigEntry) -> bool: """Set up SMHI forecast as config entry.""" # Setting unique id where missing @@ -21,16 +22,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: unique_id = f"{entry.data[CONF_LOCATION][CONF_LATITUDE]}-{entry.data[CONF_LOCATION][CONF_LONGITUDE]}" hass.config_entries.async_update_entry(entry, unique_id=unique_id) + coordinator = SMHIDataUpdateCoordinator(hass, entry) + await coordinator.async_config_entry_first_refresh() + entry.runtime_data = coordinator + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: SMHIConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) -async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_migrate_entry(hass: HomeAssistant, entry: SMHIConfigEntry) -> bool: """Migrate old entry.""" if entry.version > 3: diff --git a/homeassistant/components/smhi/const.py b/homeassistant/components/smhi/const.py index 11401119227..6cbf928d5e6 100644 --- a/homeassistant/components/smhi/const.py +++ b/homeassistant/components/smhi/const.py @@ -1,5 +1,7 @@ """Constants in smhi component.""" +from datetime import timedelta +import logging from typing import Final from homeassistant.components.weather import DOMAIN as WEATHER_DOMAIN @@ -12,3 +14,8 @@ HOME_LOCATION_NAME = "Home" DEFAULT_NAME = "Weather" ENTITY_ID_SENSOR_FORMAT = WEATHER_DOMAIN + ".smhi_{}" + +LOGGER = logging.getLogger(__package__) + +DEFAULT_SCAN_INTERVAL = timedelta(minutes=31) +TIMEOUT = 10 diff --git a/homeassistant/components/smhi/coordinator.py b/homeassistant/components/smhi/coordinator.py new file mode 100644 index 00000000000..511ba8b38d9 --- /dev/null +++ b/homeassistant/components/smhi/coordinator.py @@ -0,0 +1,63 @@ +"""DataUpdateCoordinator for the SMHI integration.""" + +from __future__ import annotations + +import asyncio +from dataclasses import dataclass + +from pysmhi import SMHIForecast, SmhiForecastException, SMHIPointForecast + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_LATITUDE, CONF_LOCATION, CONF_LONGITUDE +from homeassistant.core import HomeAssistant +from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DEFAULT_SCAN_INTERVAL, DOMAIN, LOGGER, TIMEOUT + +type SMHIConfigEntry = ConfigEntry[SMHIDataUpdateCoordinator] + + +@dataclass +class SMHIForecastData: + """Dataclass for SMHI data.""" + + daily: list[SMHIForecast] + hourly: list[SMHIForecast] + + +class SMHIDataUpdateCoordinator(DataUpdateCoordinator[SMHIForecastData]): + """A SMHI Data Update Coordinator.""" + + config_entry: SMHIConfigEntry + + def __init__(self, hass: HomeAssistant, config_entry: SMHIConfigEntry) -> None: + """Initialize the SMHI coordinator.""" + super().__init__( + hass, + LOGGER, + config_entry=config_entry, + name=DOMAIN, + update_interval=DEFAULT_SCAN_INTERVAL, + ) + self._smhi_api = SMHIPointForecast( + config_entry.data[CONF_LOCATION][CONF_LONGITUDE], + config_entry.data[CONF_LOCATION][CONF_LATITUDE], + session=aiohttp_client.async_get_clientsession(hass), + ) + + async def _async_update_data(self) -> SMHIForecastData: + """Fetch data from SMHI.""" + try: + async with asyncio.timeout(TIMEOUT): + _forecast_daily = await self._smhi_api.async_get_daily_forecast() + _forecast_hourly = await self._smhi_api.async_get_hourly_forecast() + except SmhiForecastException as ex: + raise UpdateFailed( + "Failed to retrieve the forecast from the SMHI API" + ) from ex + + return SMHIForecastData( + daily=_forecast_daily, + hourly=_forecast_hourly, + ) diff --git a/homeassistant/components/smhi/entity.py b/homeassistant/components/smhi/entity.py index 8d650d31945..89dca3360ca 100644 --- a/homeassistant/components/smhi/entity.py +++ b/homeassistant/components/smhi/entity.py @@ -2,16 +2,16 @@ from __future__ import annotations -import aiohttp -from pysmhi import SMHIPointForecast +from abc import abstractmethod from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity import Entity +from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN +from .coordinator import SMHIDataUpdateCoordinator -class SmhiWeatherBaseEntity(Entity): +class SmhiWeatherBaseEntity(CoordinatorEntity[SMHIDataUpdateCoordinator]): """Representation of a base weather entity.""" _attr_attribution = "Swedish weather institute (SMHI)" @@ -22,11 +22,11 @@ class SmhiWeatherBaseEntity(Entity): self, latitude: str, longitude: str, - session: aiohttp.ClientSession, + coordinator: SMHIDataUpdateCoordinator, ) -> None: """Initialize the SMHI base weather entity.""" + super().__init__(coordinator) self._attr_unique_id = f"{latitude}, {longitude}" - self._smhi_api = SMHIPointForecast(longitude, latitude, session=session) self._attr_device_info = DeviceInfo( entry_type=DeviceEntryType.SERVICE, identifiers={(DOMAIN, f"{latitude}, {longitude}")}, @@ -34,3 +34,8 @@ class SmhiWeatherBaseEntity(Entity): model="v2", configuration_url="http://opendata.smhi.se/apidocs/metfcst/parameters.html", ) + self.update_entity_data() + + @abstractmethod + def update_entity_data(self) -> None: + """Refresh the entity data.""" diff --git a/homeassistant/components/smhi/weather.py b/homeassistant/components/smhi/weather.py index b9cac9bdf2e..d2e31990012 100644 --- a/homeassistant/components/smhi/weather.py +++ b/homeassistant/components/smhi/weather.py @@ -2,14 +2,11 @@ from __future__ import annotations -import asyncio from collections.abc import Mapping -from datetime import datetime, timedelta -import logging +from datetime import timedelta from typing import Any, Final -import aiohttp -from pysmhi import SMHIForecast, SmhiForecastException +from pysmhi import SMHIForecast from homeassistant.components.weather import ( ATTR_CONDITION_CLEAR_NIGHT, @@ -39,10 +36,9 @@ from homeassistant.components.weather import ( ATTR_FORECAST_TIME, ATTR_FORECAST_WIND_BEARING, Forecast, - WeatherEntity, + SingleCoordinatorWeatherEntity, WeatherEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_LATITUDE, CONF_LOCATION, @@ -53,17 +49,14 @@ from homeassistant.const import ( UnitOfSpeed, UnitOfTemperature, ) -from homeassistant.core import HomeAssistant -from homeassistant.helpers import aiohttp_client, sun +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import sun from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.event import async_call_later -from homeassistant.util import Throttle from .const import ATTR_SMHI_THUNDER_PROBABILITY, ENTITY_ID_SENSOR_FORMAT +from .coordinator import SMHIConfigEntry from .entity import SmhiWeatherBaseEntity -_LOGGER = logging.getLogger(__name__) - # Used to map condition from API results CONDITION_CLASSES: Final[dict[str, list[int]]] = { ATTR_CONDITION_CLOUDY: [5, 6], @@ -96,25 +89,25 @@ MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=31) async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: SMHIConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add a weather entity from map location.""" location = config_entry.data - session = aiohttp_client.async_get_clientsession(hass) + coordinator = config_entry.runtime_data entity = SmhiWeather( location[CONF_LOCATION][CONF_LATITUDE], location[CONF_LOCATION][CONF_LONGITUDE], - session=session, + coordinator=coordinator, ) entity.entity_id = ENTITY_ID_SENSOR_FORMAT.format(config_entry.title) - async_add_entities([entity], True) + async_add_entities([entity]) -class SmhiWeather(SmhiWeatherBaseEntity, WeatherEntity): +class SmhiWeather(SmhiWeatherBaseEntity, SingleCoordinatorWeatherEntity): """Representation of a weather entity.""" _attr_native_temperature_unit = UnitOfTemperature.CELSIUS @@ -126,61 +119,37 @@ class SmhiWeather(SmhiWeatherBaseEntity, WeatherEntity): WeatherEntityFeature.FORECAST_DAILY | WeatherEntityFeature.FORECAST_HOURLY ) - def __init__( - self, - latitude: str, - longitude: str, - session: aiohttp.ClientSession, - ) -> None: - """Initialize the SMHI weather entity.""" - super().__init__(latitude, longitude, session) - self._forecast_daily: list[SMHIForecast] | None = None - self._forecast_hourly: list[SMHIForecast] | None = None - self._fail_count = 0 + def update_entity_data(self) -> None: + """Refresh the entity data.""" + if daily_data := self.coordinator.data.daily: + self._attr_native_temperature = daily_data[0]["temperature"] + self._attr_humidity = daily_data[0]["humidity"] + self._attr_native_wind_speed = daily_data[0]["wind_speed"] + self._attr_wind_bearing = daily_data[0]["wind_direction"] + self._attr_native_visibility = daily_data[0]["visibility"] + self._attr_native_pressure = daily_data[0]["pressure"] + self._attr_native_wind_gust_speed = daily_data[0]["wind_gust"] + self._attr_cloud_coverage = daily_data[0]["total_cloud"] + self._attr_condition = CONDITION_MAP.get(daily_data[0]["symbol"]) + if self._attr_condition == ATTR_CONDITION_SUNNY and not sun.is_up( + self.coordinator.hass + ): + self._attr_condition = ATTR_CONDITION_CLEAR_NIGHT @property def extra_state_attributes(self) -> Mapping[str, Any] | None: """Return additional attributes.""" - if self._forecast_daily: + if daily_data := self.coordinator.data.daily: return { - ATTR_SMHI_THUNDER_PROBABILITY: self._forecast_daily[0]["thunder"], + ATTR_SMHI_THUNDER_PROBABILITY: daily_data[0]["thunder"], } return None - @Throttle(MIN_TIME_BETWEEN_UPDATES) - async def async_update(self) -> None: - """Refresh the forecast data from SMHI weather API.""" - try: - async with asyncio.timeout(TIMEOUT): - self._forecast_daily = await self._smhi_api.async_get_daily_forecast() - self._forecast_hourly = await self._smhi_api.async_get_hourly_forecast() - self._fail_count = 0 - except (TimeoutError, SmhiForecastException): - _LOGGER.error("Failed to connect to SMHI API, retry in 5 minutes") - self._fail_count += 1 - if self._fail_count < 3: - async_call_later(self.hass, RETRY_TIMEOUT, self.retry_update) - return - - if self._forecast_daily: - self._attr_native_temperature = self._forecast_daily[0]["temperature"] - self._attr_humidity = self._forecast_daily[0]["humidity"] - self._attr_native_wind_speed = self._forecast_daily[0]["wind_speed"] - self._attr_wind_bearing = self._forecast_daily[0]["wind_direction"] - self._attr_native_visibility = self._forecast_daily[0]["visibility"] - self._attr_native_pressure = self._forecast_daily[0]["pressure"] - self._attr_native_wind_gust_speed = self._forecast_daily[0]["wind_gust"] - self._attr_cloud_coverage = self._forecast_daily[0]["total_cloud"] - self._attr_condition = CONDITION_MAP.get(self._forecast_daily[0]["symbol"]) - if self._attr_condition == ATTR_CONDITION_SUNNY and not sun.is_up( - self.hass - ): - self._attr_condition = ATTR_CONDITION_CLEAR_NIGHT - await self.async_update_listeners(("daily", "hourly")) - - async def retry_update(self, _: datetime) -> None: - """Retry refresh weather forecast.""" - await self.async_update(no_throttle=True) + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self.update_entity_data() + super()._handle_coordinator_update() def _get_forecast_data( self, forecast_data: list[SMHIForecast] | None @@ -219,10 +188,10 @@ class SmhiWeather(SmhiWeatherBaseEntity, WeatherEntity): return data - async def async_forecast_daily(self) -> list[Forecast] | None: + def _async_forecast_daily(self) -> list[Forecast] | None: """Service to retrieve the daily forecast.""" - return self._get_forecast_data(self._forecast_daily) + return self._get_forecast_data(self.coordinator.data.daily) - async def async_forecast_hourly(self) -> list[Forecast] | None: + def _async_forecast_hourly(self) -> list[Forecast] | None: """Service to retrieve the hourly forecast.""" - return self._get_forecast_data(self._forecast_hourly) + return self._get_forecast_data(self.coordinator.data.hourly) diff --git a/tests/components/smhi/test_weather.py b/tests/components/smhi/test_weather.py index f47566f2d5c..a09a9689d52 100644 --- a/tests/components/smhi/test_weather.py +++ b/tests/components/smhi/test_weather.py @@ -4,29 +4,27 @@ from datetime import datetime, timedelta from unittest.mock import patch from freezegun import freeze_time +from freezegun.api import FrozenDateTimeFactory from pysmhi import SMHIForecast, SmhiForecastException from pysmhi.const import API_POINT_FORECAST import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components.smhi.const import ATTR_SMHI_THUNDER_PROBABILITY -from homeassistant.components.smhi.weather import CONDITION_CLASSES, RETRY_TIMEOUT +from homeassistant.components.smhi.weather import CONDITION_CLASSES from homeassistant.components.weather import ( ATTR_CONDITION_CLEAR_NIGHT, ATTR_FORECAST_CONDITION, - ATTR_WEATHER_CLOUD_COVERAGE, - ATTR_WEATHER_HUMIDITY, - ATTR_WEATHER_PRESSURE, - ATTR_WEATHER_TEMPERATURE, - ATTR_WEATHER_VISIBILITY, - ATTR_WEATHER_WIND_BEARING, ATTR_WEATHER_WIND_GUST_SPEED, - ATTR_WEATHER_WIND_SPEED, ATTR_WEATHER_WIND_SPEED_UNIT, DOMAIN as WEATHER_DOMAIN, SERVICE_GET_FORECASTS, ) -from homeassistant.const import ATTR_ATTRIBUTION, STATE_UNKNOWN, UnitOfSpeed +from homeassistant.const import ( + ATTR_ATTRIBUTION, + STATE_UNAVAILABLE, + STATE_UNKNOWN, + UnitOfSpeed, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.util import dt as dt_util @@ -104,33 +102,38 @@ async def test_clear_night( assert response == snapshot(name="clear-night_forecast") -async def test_properties_no_data(hass: HomeAssistant) -> None: +async def test_properties_no_data( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + api_response: str, + freezer: FrozenDateTimeFactory, +) -> None: """Test properties when no API data available.""" + uri = API_POINT_FORECAST.format( + TEST_CONFIG["location"]["longitude"], TEST_CONFIG["location"]["latitude"] + ) + aioclient_mock.get(uri, text=api_response) + entry = MockConfigEntry(domain="smhi", title="test", data=TEST_CONFIG, version=3) entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + with patch( - "homeassistant.components.smhi.entity.SMHIPointForecast.async_get_daily_forecast", + "homeassistant.components.smhi.coordinator.SMHIPointForecast.async_get_daily_forecast", side_effect=SmhiForecastException("boom"), ): - await hass.config_entries.async_setup(entry.entry_id) + freezer.tick(timedelta(minutes=35)) + async_fire_time_changed(hass) await hass.async_block_till_done() state = hass.states.get(ENTITY_ID) assert state assert state.name == "test" - assert state.state == STATE_UNKNOWN + assert state.state == STATE_UNAVAILABLE assert state.attributes[ATTR_ATTRIBUTION] == "Swedish weather institute (SMHI)" - assert ATTR_WEATHER_HUMIDITY not in state.attributes - assert ATTR_WEATHER_PRESSURE not in state.attributes - assert ATTR_WEATHER_TEMPERATURE not in state.attributes - assert ATTR_WEATHER_VISIBILITY not in state.attributes - assert ATTR_WEATHER_WIND_SPEED not in state.attributes - assert ATTR_WEATHER_WIND_BEARING not in state.attributes - assert ATTR_WEATHER_CLOUD_COVERAGE not in state.attributes - assert ATTR_SMHI_THUNDER_PROBABILITY not in state.attributes - assert ATTR_WEATHER_WIND_GUST_SPEED not in state.attributes async def test_properties_unknown_symbol(hass: HomeAssistant) -> None: @@ -215,11 +218,11 @@ async def test_properties_unknown_symbol(hass: HomeAssistant) -> None: with ( patch( - "homeassistant.components.smhi.entity.SMHIPointForecast.async_get_daily_forecast", + "homeassistant.components.smhi.coordinator.SMHIPointForecast.async_get_daily_forecast", return_value=testdata, ), patch( - "homeassistant.components.smhi.entity.SMHIPointForecast.async_get_hourly_forecast", + "homeassistant.components.smhi.coordinator.SMHIPointForecast.async_get_hourly_forecast", return_value=None, ), ): @@ -246,55 +249,48 @@ async def test_properties_unknown_symbol(hass: HomeAssistant) -> None: @pytest.mark.parametrize("error", [SmhiForecastException(), TimeoutError()]) async def test_refresh_weather_forecast_retry( - hass: HomeAssistant, error: Exception + hass: HomeAssistant, + error: Exception, + aioclient_mock: AiohttpClientMocker, + api_response: str, + freezer: FrozenDateTimeFactory, ) -> None: """Test the refresh weather forecast function.""" + uri = API_POINT_FORECAST.format( + TEST_CONFIG["location"]["longitude"], TEST_CONFIG["location"]["latitude"] + ) + aioclient_mock.get(uri, text=api_response) + entry = MockConfigEntry(domain="smhi", title="test", data=TEST_CONFIG, version=3) entry.add_to_hass(hass) - now = dt_util.utcnow() + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() with patch( - "homeassistant.components.smhi.entity.SMHIPointForecast.async_get_daily_forecast", + "homeassistant.components.smhi.coordinator.SMHIPointForecast.async_get_daily_forecast", side_effect=error, ) as mock_get_forecast: - await hass.config_entries.async_setup(entry.entry_id) + freezer.tick(timedelta(minutes=35)) + async_fire_time_changed(hass) await hass.async_block_till_done() state = hass.states.get(ENTITY_ID) assert state assert state.name == "test" - assert state.state == STATE_UNKNOWN + assert state.state == STATE_UNAVAILABLE assert mock_get_forecast.call_count == 1 - future = now + timedelta(seconds=RETRY_TIMEOUT + 1) - async_fire_time_changed(hass, future) + freezer.tick(timedelta(minutes=35)) + async_fire_time_changed(hass) await hass.async_block_till_done() state = hass.states.get(ENTITY_ID) assert state - assert state.state == STATE_UNKNOWN + assert state.state == STATE_UNAVAILABLE assert mock_get_forecast.call_count == 2 - future = future + timedelta(seconds=RETRY_TIMEOUT + 1) - async_fire_time_changed(hass, future) - await hass.async_block_till_done() - - state = hass.states.get(ENTITY_ID) - assert state - assert state.state == STATE_UNKNOWN - assert mock_get_forecast.call_count == 3 - - future = future + timedelta(seconds=RETRY_TIMEOUT + 1) - async_fire_time_changed(hass, future) - await hass.async_block_till_done() - - state = hass.states.get(ENTITY_ID) - assert state - assert state.state == STATE_UNKNOWN - # after three failed retries we stop retrying and go back to normal interval - assert mock_get_forecast.call_count == 3 - def test_condition_class() -> None: """Test condition class.""" From 75533463f794b935a28a4a08cb6d9b4dca798677 Mon Sep 17 00:00:00 2001 From: Dan Bishop Date: Tue, 25 Feb 2025 18:41:47 +0000 Subject: [PATCH 1804/3148] Make Radarr unit translation lowercase (#139261) Co-authored-by: Joost Lekkerkerker --- homeassistant/components/radarr/strings.json | 4 ++-- tests/components/radarr/test_sensor.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/radarr/strings.json b/homeassistant/components/radarr/strings.json index cb624aff057..268d7955c1b 100644 --- a/homeassistant/components/radarr/strings.json +++ b/homeassistant/components/radarr/strings.json @@ -44,11 +44,11 @@ "sensor": { "movies": { "name": "Movies", - "unit_of_measurement": "[%key:component::radarr::entity::sensor::movies::name%]" + "unit_of_measurement": "movies" }, "queue": { "name": "Queue", - "unit_of_measurement": "[%key:component::radarr::entity::sensor::movies::name%]" + "unit_of_measurement": "[%key:component::radarr::entity::sensor::movies::unit_of_measurement%]" }, "start_time": { "name": "Start time" diff --git a/tests/components/radarr/test_sensor.py b/tests/components/radarr/test_sensor.py index 9139e13a957..f6b14bffa80 100644 --- a/tests/components/radarr/test_sensor.py +++ b/tests/components/radarr/test_sensor.py @@ -68,13 +68,13 @@ async def test_sensors( assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "GB" state = hass.states.get("sensor.mock_title_movies") assert state.state == "1" - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "Movies" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "movies" state = hass.states.get("sensor.mock_title_start_time") assert state.state == "2020-09-01T23:50:20+00:00" assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TIMESTAMP state = hass.states.get("sensor.mock_title_queue") assert state.state == "2" - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "Movies" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "movies" assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.TOTAL From ef465521460f92286a725969796336f79673f6ac Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Tue, 25 Feb 2025 20:03:14 +0100 Subject: [PATCH 1805/3148] Add common state translation string for charging and discharging (#139074) add common state translation string for charging and discharging --- homeassistant/components/blue_current/strings.json | 2 +- homeassistant/components/bmw_connected_drive/strings.json | 2 +- homeassistant/components/enphase_envoy/strings.json | 4 ++-- homeassistant/components/lektrico/strings.json | 2 +- homeassistant/components/lg_thinq/strings.json | 4 ++-- homeassistant/components/matter/strings.json | 2 +- homeassistant/components/ohme/strings.json | 2 +- homeassistant/components/peblar/strings.json | 2 +- homeassistant/components/reolink/strings.json | 4 ++-- homeassistant/components/roborock/strings.json | 4 ++-- homeassistant/components/tesla_fleet/strings.json | 2 +- homeassistant/components/tesla_wall_connector/strings.json | 2 +- homeassistant/components/teslemetry/strings.json | 2 +- homeassistant/components/tessie/strings.json | 4 ++-- homeassistant/strings.json | 4 +++- 15 files changed, 22 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/blue_current/strings.json b/homeassistant/components/blue_current/strings.json index 0154c794c33..2e48d768a74 100644 --- a/homeassistant/components/blue_current/strings.json +++ b/homeassistant/components/blue_current/strings.json @@ -28,7 +28,7 @@ "name": "Activity", "state": { "available": "Available", - "charging": "Charging", + "charging": "[%key:common::state::charging%]", "unavailable": "Unavailable", "error": "Error", "offline": "Offline" diff --git a/homeassistant/components/bmw_connected_drive/strings.json b/homeassistant/components/bmw_connected_drive/strings.json index edb0d5cfb12..4b16b719d8d 100644 --- a/homeassistant/components/bmw_connected_drive/strings.json +++ b/homeassistant/components/bmw_connected_drive/strings.json @@ -138,7 +138,7 @@ "name": "Charging status", "state": { "default": "Default", - "charging": "Charging", + "charging": "[%key:common::state::charging%]", "error": "Error", "complete": "Complete", "fully_charged": "Fully charged", diff --git a/homeassistant/components/enphase_envoy/strings.json b/homeassistant/components/enphase_envoy/strings.json index 0c1facca1ea..b498c59e0d3 100644 --- a/homeassistant/components/enphase_envoy/strings.json +++ b/homeassistant/components/enphase_envoy/strings.json @@ -360,9 +360,9 @@ "acb_battery_state": { "name": "Battery state", "state": { - "discharging": "Discharging", + "discharging": "[%key:common::state::discharging%]", "idle": "[%key:common::state::idle%]", - "charging": "Charging", + "charging": "[%key:common::state::charging%]", "full": "Full" } }, diff --git a/homeassistant/components/lektrico/strings.json b/homeassistant/components/lektrico/strings.json index e24700c9b09..3b4417c346a 100644 --- a/homeassistant/components/lektrico/strings.json +++ b/homeassistant/components/lektrico/strings.json @@ -86,7 +86,7 @@ "name": "State", "state": { "available": "Available", - "charging": "Charging", + "charging": "[%key:common::state::charging%]", "connected": "Connected", "error": "Error", "locked": "Locked", diff --git a/homeassistant/components/lg_thinq/strings.json b/homeassistant/components/lg_thinq/strings.json index a930860aa35..e1d3779f44b 100644 --- a/homeassistant/components/lg_thinq/strings.json +++ b/homeassistant/components/lg_thinq/strings.json @@ -411,7 +411,7 @@ "cancel": "Cancel", "carbonation": "Carbonation", "change_condition": "Settings Change", - "charging": "Charging", + "charging": "[%key:common::state::charging%]", "charging_complete": "Charging completed", "checking_turbidity": "Detecting soil level", "cleaning": "Cleaning", @@ -498,7 +498,7 @@ "cancel": "[%key:component::lg_thinq::entity::sensor::current_state::state::cancel%]", "carbonation": "[%key:component::lg_thinq::entity::sensor::current_state::state::carbonation%]", "change_condition": "[%key:component::lg_thinq::entity::sensor::current_state::state::change_condition%]", - "charging": "[%key:component::lg_thinq::entity::sensor::current_state::state::charging%]", + "charging": "[%key:common::state::charging%]", "charging_complete": "[%key:component::lg_thinq::entity::sensor::current_state::state::charging_complete%]", "checking_turbidity": "[%key:component::lg_thinq::entity::sensor::current_state::state::checking_turbidity%]", "cleaning": "[%key:component::lg_thinq::entity::sensor::current_state::state::cleaning%]", diff --git a/homeassistant/components/matter/strings.json b/homeassistant/components/matter/strings.json index f299b5cb628..1404d0a9076 100644 --- a/homeassistant/components/matter/strings.json +++ b/homeassistant/components/matter/strings.json @@ -263,7 +263,7 @@ "paused": "[%key:common::state::paused%]", "error": "Error", "seeking_charger": "Seeking charger", - "charging": "Charging", + "charging": "[%key:common::state::charging%]", "docked": "Docked" } }, diff --git a/homeassistant/components/ohme/strings.json b/homeassistant/components/ohme/strings.json index 387b28565b2..4c845daa8f0 100644 --- a/homeassistant/components/ohme/strings.json +++ b/homeassistant/components/ohme/strings.json @@ -74,7 +74,7 @@ "state": { "unplugged": "Unplugged", "plugged_in": "Plugged in", - "charging": "Charging", + "charging": "[%key:common::state::charging%]", "paused": "[%key:common::state::paused%]", "pending_approval": "Pending approval", "finished": "Finished charging" diff --git a/homeassistant/components/peblar/strings.json b/homeassistant/components/peblar/strings.json index 4a1500e54c5..416f1a2c062 100644 --- a/homeassistant/components/peblar/strings.json +++ b/homeassistant/components/peblar/strings.json @@ -107,7 +107,7 @@ "cp_state": { "name": "State", "state": { - "charging": "Charging", + "charging": "[%key:common::state::charging%]", "error": "Error", "fault": "Fault", "invalid": "Invalid", diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index 3da463beddf..335ed92d32e 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -741,8 +741,8 @@ "battery_state": { "name": "Battery state", "state": { - "discharging": "Discharging", - "charging": "Charging", + "discharging": "[%key:common::state::discharging%]", + "charging": "[%key:common::state::charging%]", "chargecomplete": "Charge complete" } }, diff --git a/homeassistant/components/roborock/strings.json b/homeassistant/components/roborock/strings.json index 8968ac020a2..eb058ea74e3 100644 --- a/homeassistant/components/roborock/strings.json +++ b/homeassistant/components/roborock/strings.json @@ -128,7 +128,7 @@ "updating": "[%key:component::roborock::entity::sensor::status::state::updating%]", "washing": "Washing", "ready": "Ready", - "charging": "[%key:component::roborock::entity::sensor::status::state::charging%]", + "charging": "[%key:common::state::charging%]", "mop_washing": "Washing mop", "self_clean_cleaning": "Self clean cleaning", "self_clean_deep_cleaning": "Self clean deep cleaning", @@ -199,7 +199,7 @@ "cleaning": "Cleaning", "returning_home": "Returning home", "manual_mode": "Manual mode", - "charging": "Charging", + "charging": "[%key:common::state::charging%]", "charging_problem": "Charging problem", "paused": "[%key:common::state::paused%]", "spot_cleaning": "Spot cleaning", diff --git a/homeassistant/components/tesla_fleet/strings.json b/homeassistant/components/tesla_fleet/strings.json index 540ea2b7135..331885893fe 100644 --- a/homeassistant/components/tesla_fleet/strings.json +++ b/homeassistant/components/tesla_fleet/strings.json @@ -329,7 +329,7 @@ "name": "Charging", "state": { "starting": "Starting", - "charging": "Charging", + "charging": "[%key:common::state::charging%]", "disconnected": "Disconnected", "stopped": "Stopped", "complete": "Complete", diff --git a/homeassistant/components/tesla_wall_connector/strings.json b/homeassistant/components/tesla_wall_connector/strings.json index 1a03207a012..b356a9f3ebc 100644 --- a/homeassistant/components/tesla_wall_connector/strings.json +++ b/homeassistant/components/tesla_wall_connector/strings.json @@ -42,7 +42,7 @@ "charging_finished": "Charging finished", "waiting_car": "Waiting for car", "charging_reduced": "Charging (reduced)", - "charging": "Charging" + "charging": "[%key:common::state::charging%]" } }, "status_code": { diff --git a/homeassistant/components/teslemetry/strings.json b/homeassistant/components/teslemetry/strings.json index b6b3d17e37c..9dc17fd2ef7 100644 --- a/homeassistant/components/teslemetry/strings.json +++ b/homeassistant/components/teslemetry/strings.json @@ -415,7 +415,7 @@ "name": "Charging", "state": { "starting": "Starting", - "charging": "Charging", + "charging": "[%key:common::state::charging%]", "disconnected": "Disconnected", "stopped": "Stopped", "complete": "Complete", diff --git a/homeassistant/components/tessie/strings.json b/homeassistant/components/tessie/strings.json index ccd17fbf6c8..4f0f5f67ebd 100644 --- a/homeassistant/components/tessie/strings.json +++ b/homeassistant/components/tessie/strings.json @@ -75,7 +75,7 @@ "name": "Charging", "state": { "starting": "Starting", - "charging": "Charging", + "charging": "[%key:common::state::charging%]", "disconnected": "Disconnected", "stopped": "Stopped", "complete": "Complete", @@ -212,7 +212,7 @@ "name": "State", "state": { "booting": "Booting", - "charging": "[%key:component::tessie::entity::sensor::charge_state_charging_state::state::charging%]", + "charging": "[%key:common::state::charging%]", "disconnected": "[%key:common::state::disconnected%]", "connected": "[%key:common::state::connected%]", "scheduled": "Scheduled", diff --git a/homeassistant/strings.json b/homeassistant/strings.json index fca55353aa0..f423c3bf59c 100644 --- a/homeassistant/strings.json +++ b/homeassistant/strings.json @@ -71,7 +71,9 @@ "standby": "Standby", "paused": "Paused", "home": "Home", - "not_home": "Away" + "not_home": "Away", + "charging": "Charging", + "discharging": "Discharging" }, "config_flow": { "title": { From 51c09c2aa4dae532b2f358cf238f6819f3947167 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 25 Feb 2025 20:10:29 +0100 Subject: [PATCH 1806/3148] Add test fixture ignore_translations_for_mock_domains (#139235) * Add test fixture ignore_translations_for_mock_domains * Fix fixture * Avoid unnecessary attempt to get integration * Really fix fixture * Add forgotten parameter * Address review comment --- .../application_credentials/test_init.py | 25 +---- .../components/config/test_config_entries.py | 25 +---- tests/components/conftest.py | 93 ++++++++++++++++--- .../test_config_flow_failures.py | 68 +++++++------- .../test_silabs_multiprotocol_addon.py | 60 +++--------- tests/components/onkyo/test_config_flow.py | 2 +- tests/components/repairs/test_init.py | 30 +----- .../components/repairs/test_websocket_api.py | 68 +++----------- tests/components/sensor/test_recorder.py | 5 +- tests/components/synology_dsm/test_repairs.py | 2 +- .../components/websocket_api/test_commands.py | 9 +- tests/components/workday/test_repairs.py | 2 +- tests/components/zwave_js/test_repairs.py | 2 +- 13 files changed, 164 insertions(+), 227 deletions(-) diff --git a/tests/components/application_credentials/test_init.py b/tests/components/application_credentials/test_init.py index b72d9653c2d..9896e4c9fc0 100644 --- a/tests/components/application_credentials/test_init.py +++ b/tests/components/application_credentials/test_init.py @@ -423,10 +423,7 @@ async def test_import_named_credential( ] -@pytest.mark.parametrize( - "ignore_translations", - ["component.fake_integration.config.abort.missing_credentials"], -) +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["fake_integration"]) async def test_config_flow_no_credentials(hass: HomeAssistant) -> None: """Test config flow base case with no credentials registered.""" result = await hass.config_entries.flow.async_init( @@ -436,10 +433,7 @@ async def test_config_flow_no_credentials(hass: HomeAssistant) -> None: assert result.get("reason") == "missing_credentials" -@pytest.mark.parametrize( - "ignore_translations", - ["component.fake_integration.config.abort.missing_credentials"], -) +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["fake_integration"]) async def test_config_flow_other_domain( hass: HomeAssistant, ws_client: ClientFixture, @@ -567,10 +561,7 @@ async def test_config_flow_multiple_entries( ) -@pytest.mark.parametrize( - "ignore_translations", - ["component.fake_integration.config.abort.missing_credentials"], -) +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["fake_integration"]) async def test_config_flow_create_delete_credential( hass: HomeAssistant, ws_client: ClientFixture, @@ -616,10 +607,7 @@ async def test_config_flow_with_config_credential( assert result["data"].get("auth_implementation") == TEST_DOMAIN -@pytest.mark.parametrize( - "ignore_translations", - ["component.fake_integration.config.abort.missing_configuration"], -) +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["fake_integration"]) @pytest.mark.parametrize("mock_application_credentials_integration", [None]) async def test_import_without_setup(hass: HomeAssistant, config_credential) -> None: """Test import of credentials without setting up the integration.""" @@ -635,10 +623,7 @@ async def test_import_without_setup(hass: HomeAssistant, config_credential) -> N assert result.get("reason") == "missing_configuration" -@pytest.mark.parametrize( - "ignore_translations", - ["component.fake_integration.config.abort.missing_configuration"], -) +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["fake_integration"]) @pytest.mark.parametrize("mock_application_credentials_integration", [None]) async def test_websocket_without_platform( hass: HomeAssistant, ws_client: ClientFixture diff --git a/tests/components/config/test_config_entries.py b/tests/components/config/test_config_entries.py index a31836b598c..739b79e22bd 100644 --- a/tests/components/config/test_config_entries.py +++ b/tests/components/config/test_config_entries.py @@ -400,10 +400,7 @@ async def test_available_flows( ############################ -@pytest.mark.parametrize( - "ignore_translations", - ["component.test.config.error.Should be unique."], -) +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["test"]) async def test_initialize_flow(hass: HomeAssistant, client: TestClient) -> None: """Test we can initialize a flow.""" mock_platform(hass, "test.config_flow", None) @@ -513,10 +510,7 @@ async def test_initialize_flow_unauth( assert resp.status == HTTPStatus.UNAUTHORIZED -@pytest.mark.parametrize( - "ignore_translations", - ["component.test.config.abort.bla"], -) +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["test"]) async def test_abort(hass: HomeAssistant, client: TestClient) -> None: """Test a flow that aborts.""" mock_platform(hass, "test.config_flow", None) @@ -826,10 +820,7 @@ async def test_get_progress_index_unauth( assert response["error"]["code"] == "unauthorized" -@pytest.mark.parametrize( - "ignore_translations", - ["component.test.config.error.Should be unique."], -) +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["test"]) async def test_get_progress_flow(hass: HomeAssistant, client: TestClient) -> None: """Test we can query the API for same result as we get from init a flow.""" mock_platform(hass, "test.config_flow", None) @@ -863,10 +854,7 @@ async def test_get_progress_flow(hass: HomeAssistant, client: TestClient) -> Non assert data == data2 -@pytest.mark.parametrize( - "ignore_translations", - ["component.test.config.error.Should be unique."], -) +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["test"]) async def test_get_progress_flow_unauth( hass: HomeAssistant, client: TestClient, hass_admin_user: MockUser ) -> None: @@ -2870,10 +2858,7 @@ async def test_flow_with_multiple_schema_errors_base( } -@pytest.mark.parametrize( - "ignore_translations", - ["component.test.config.abort.reconfigure_successful"], -) +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["test"]) @pytest.mark.usefixtures("freezer") async def test_supports_reconfigure( hass: HomeAssistant, diff --git a/tests/components/conftest.py b/tests/components/conftest.py index cf10e2b8dfd..6d6d0d4641f 100644 --- a/tests/components/conftest.py +++ b/tests/components/conftest.py @@ -22,6 +22,7 @@ from aiohasupervisor.models import ( import pytest import voluptuous as vol +from homeassistant import components, loader from homeassistant.components import repairs from homeassistant.config_entries import ( DISCOVERY_SOURCES, @@ -605,6 +606,7 @@ def _validate_translation_placeholders( async def _validate_translation( hass: HomeAssistant, translation_errors: dict[str, str], + ignore_translations_for_mock_domains: set[str], category: str, component: str, key: str, @@ -614,7 +616,25 @@ async def _validate_translation( ) -> None: """Raise if translation doesn't exist.""" full_key = f"component.{component}.{category}.{key}" + if component in ignore_translations_for_mock_domains: + try: + integration = await loader.async_get_integration(hass, component) + except loader.IntegrationNotFound: + return + component_paths = components.__path__ + if not any( + Path(f"{component_path}/{component}") == integration.file_path + for component_path in component_paths + ): + return + # If the integration exists, translation errors should be ignored via the + # ignore_missing_translations fixture instead of the + # ignore_translations_for_mock_domains fixture. + translation_errors[full_key] = f"The integration '{component}' exists" + return + translations = await async_get_translations(hass, "en", category, [component]) + if (translation := translations.get(full_key)) is not None: _validate_translation_placeholders( full_key, translation, description_placeholders, translation_errors @@ -625,6 +645,18 @@ async def _validate_translation( return if translation_errors.get(full_key) in {"used", "unused"}: + # If the does not integration exist, translation errors should be ignored + # via the ignore_translations_for_mock_domains fixture instead of the + # ignore_missing_translations fixture. + try: + await loader.async_get_integration(hass, component) + except loader.IntegrationNotFound: + translation_errors[full_key] = ( + f"Translation not found for {component}: `{category}.{key}`. " + f"The integration '{component}' does not exist." + ) + return + # This translation key is in the ignore list, mark it as used translation_errors[full_key] = "used" return @@ -636,11 +668,22 @@ async def _validate_translation( @pytest.fixture -def ignore_translations() -> str | list[str]: - """Ignore specific translations. +def ignore_missing_translations() -> str | list[str]: + """Ignore specific missing translations. - Override or parametrize this fixture with a fixture that returns, - a list of translation that should be ignored. + Override or parametrize this fixture with a fixture that returns + a list of missing translation that should be ignored. + """ + return [] + + +@pytest.fixture +def ignore_translations_for_mock_domains() -> str | list[str]: + """Don't validate translations for specific domains. + + Override or parametrize this fixture with a fixture that returns + a list of domains for which translations should not be validated. + This should only be used when testing mocked integrations. """ return [] @@ -673,6 +716,7 @@ async def _check_step_or_section_translations( translation_prefix: str, description_placeholders: dict[str, str], data_schema: vol.Schema | None, + ignore_translations_for_mock_domains: set[str], ) -> None: # neither title nor description are required # - title defaults to integration name @@ -681,6 +725,7 @@ async def _check_step_or_section_translations( await _validate_translation( hass, translation_errors, + ignore_translations_for_mock_domains, category, integration, f"{translation_prefix}.{header}", @@ -702,6 +747,7 @@ async def _check_step_or_section_translations( f"{translation_prefix}.sections.{data_key}", description_placeholders, data_value.schema, + ignore_translations_for_mock_domains, ) continue iqs_config_flow = _get_integration_quality_scale_rule( @@ -712,6 +758,7 @@ async def _check_step_or_section_translations( await _validate_translation( hass, translation_errors, + ignore_translations_for_mock_domains, category, integration, f"{translation_prefix}.{header}.{data_key}", @@ -725,6 +772,7 @@ async def _check_config_flow_result_translations( flow: FlowHandler, result: FlowResult[FlowContext, str], translation_errors: dict[str, str], + ignore_translations_for_mock_domains: set[str], ) -> None: if result["type"] is FlowResultType.CREATE_ENTRY: # No need to check translations for a completed flow @@ -760,6 +808,7 @@ async def _check_config_flow_result_translations( f"{key_prefix}step.{step_id}", result["description_placeholders"], result["data_schema"], + ignore_translations_for_mock_domains, ) if errors := result.get("errors"): @@ -767,6 +816,7 @@ async def _check_config_flow_result_translations( await _validate_translation( flow.hass, translation_errors, + ignore_translations_for_mock_domains, category, integration, f"{key_prefix}error.{error}", @@ -782,6 +832,7 @@ async def _check_config_flow_result_translations( await _validate_translation( flow.hass, translation_errors, + ignore_translations_for_mock_domains, category, integration, f"{key_prefix}abort.{result['reason']}", @@ -793,6 +844,7 @@ async def _check_create_issue_translations( issue_registry: ir.IssueRegistry, issue: ir.IssueEntry, translation_errors: dict[str, str], + ignore_translations_for_mock_domains: set[str], ) -> None: if issue.translation_key is None: # `translation_key` is only None on dismissed issues @@ -800,6 +852,7 @@ async def _check_create_issue_translations( await _validate_translation( issue_registry.hass, translation_errors, + ignore_translations_for_mock_domains, "issues", issue.domain, f"{issue.translation_key}.title", @@ -810,6 +863,7 @@ async def _check_create_issue_translations( await _validate_translation( issue_registry.hass, translation_errors, + ignore_translations_for_mock_domains, "issues", issue.domain, f"{issue.translation_key}.description", @@ -831,6 +885,7 @@ async def _check_exception_translation( exception: HomeAssistantError, translation_errors: dict[str, str], request: pytest.FixtureRequest, + ignore_translations_for_mock_domains: set[str], ) -> None: if exception.translation_key is None: if ( @@ -844,6 +899,7 @@ async def _check_exception_translation( await _validate_translation( hass, translation_errors, + ignore_translations_for_mock_domains, "exceptions", exception.translation_domain, f"{exception.translation_key}.message", @@ -853,7 +909,9 @@ async def _check_exception_translation( @pytest.fixture(autouse=True) async def check_translations( - ignore_translations: str | list[str], request: pytest.FixtureRequest + ignore_missing_translations: str | list[str], + ignore_translations_for_mock_domains: str | list[str], + request: pytest.FixtureRequest, ) -> AsyncGenerator[None]: """Check that translation requirements are met. @@ -862,11 +920,16 @@ async def check_translations( - issue registry entries - action (service) exceptions """ - if not isinstance(ignore_translations, list): - ignore_translations = [ignore_translations] + if not isinstance(ignore_missing_translations, list): + ignore_missing_translations = [ignore_missing_translations] + + if not isinstance(ignore_translations_for_mock_domains, list): + ignored_domains = {ignore_translations_for_mock_domains} + else: + ignored_domains = set(ignore_translations_for_mock_domains) # Set all ignored translation keys to "unused" - translation_errors = {k: "unused" for k in ignore_translations} + translation_errors = {k: "unused" for k in ignore_missing_translations} translation_coros = set() @@ -881,7 +944,7 @@ async def check_translations( ) -> FlowResult: result = await _original_flow_manager_async_handle_step(self, flow, *args) await _check_config_flow_result_translations( - self, flow, result, translation_errors + self, flow, result, translation_errors, ignored_domains ) return result @@ -892,7 +955,9 @@ async def check_translations( self, domain, issue_id, *args, **kwargs ) translation_coros.add( - _check_create_issue_translations(self, result, translation_errors) + _check_create_issue_translations( + self, result, translation_errors, ignored_domains + ) ) return result @@ -920,7 +985,11 @@ async def check_translations( except HomeAssistantError as err: translation_coros.add( _check_exception_translation( - self._hass, err, translation_errors, request + self._hass, + err, + translation_errors, + request, + ignored_domains, ) ) raise @@ -950,7 +1019,7 @@ async def check_translations( # Some ignored translations were not used pytest.fail( f"Unused ignore translations: {', '.join(unused_ignore)}. " - "Please remove them from the ignore_translations fixture." + "Please remove them from the ignore_missing_translations fixture." ) for description in translation_errors.values(): if description != "used": diff --git a/tests/components/homeassistant_hardware/test_config_flow_failures.py b/tests/components/homeassistant_hardware/test_config_flow_failures.py index 8c2ee4b90ba..fb38704ae61 100644 --- a/tests/components/homeassistant_hardware/test_config_flow_failures.py +++ b/tests/components/homeassistant_hardware/test_config_flow_failures.py @@ -35,8 +35,8 @@ async def fixture_mock_supervisor_client(supervisor_client: AsyncMock): @pytest.mark.parametrize( - "ignore_translations", - ["component.test_firmware_domain.config.abort.unsupported_firmware"], + "ignore_translations_for_mock_domains", + ["test_firmware_domain"], ) @pytest.mark.parametrize( "next_step", @@ -69,8 +69,8 @@ async def test_config_flow_cannot_probe_firmware( @pytest.mark.parametrize( - "ignore_translations", - ["component.test_firmware_domain.config.abort.not_hassio"], + "ignore_translations_for_mock_domains", + ["test_firmware_domain"], ) async def test_config_flow_zigbee_not_hassio_wrong_firmware( hass: HomeAssistant, @@ -98,8 +98,8 @@ async def test_config_flow_zigbee_not_hassio_wrong_firmware( @pytest.mark.parametrize( - "ignore_translations", - ["component.test_firmware_domain.config.abort.addon_already_running"], + "ignore_translations_for_mock_domains", + ["test_firmware_domain"], ) async def test_config_flow_zigbee_flasher_addon_already_running( hass: HomeAssistant, @@ -136,8 +136,8 @@ async def test_config_flow_zigbee_flasher_addon_already_running( @pytest.mark.parametrize( - "ignore_translations", - ["component.test_firmware_domain.config.abort.addon_info_failed"], + "ignore_translations_for_mock_domains", + ["test_firmware_domain"], ) async def test_config_flow_zigbee_flasher_addon_info_fails(hass: HomeAssistant) -> None: """Test failure case when flasher addon cannot be installed.""" @@ -173,8 +173,8 @@ async def test_config_flow_zigbee_flasher_addon_info_fails(hass: HomeAssistant) @pytest.mark.parametrize( - "ignore_translations", - ["component.test_firmware_domain.config.abort.addon_install_failed"], + "ignore_translations_for_mock_domains", + ["test_firmware_domain"], ) async def test_config_flow_zigbee_flasher_addon_install_fails( hass: HomeAssistant, @@ -207,8 +207,8 @@ async def test_config_flow_zigbee_flasher_addon_install_fails( @pytest.mark.parametrize( - "ignore_translations", - ["component.test_firmware_domain.config.abort.addon_set_config_failed"], + "ignore_translations_for_mock_domains", + ["test_firmware_domain"], ) async def test_config_flow_zigbee_flasher_addon_set_config_fails( hass: HomeAssistant, @@ -245,8 +245,8 @@ async def test_config_flow_zigbee_flasher_addon_set_config_fails( @pytest.mark.parametrize( - "ignore_translations", - ["component.test_firmware_domain.config.abort.addon_start_failed"], + "ignore_translations_for_mock_domains", + ["test_firmware_domain"], ) async def test_config_flow_zigbee_flasher_run_fails(hass: HomeAssistant) -> None: """Test failure case when flasher addon fails to run.""" @@ -310,8 +310,8 @@ async def test_config_flow_zigbee_flasher_uninstall_fails(hass: HomeAssistant) - @pytest.mark.parametrize( - "ignore_translations", - ["component.test_firmware_domain.config.abort.unsupported_firmware"], + "ignore_translations_for_mock_domains", + ["test_firmware_domain"], ) async def test_config_flow_zigbee_confirmation_fails(hass: HomeAssistant) -> None: """Test the config flow failing due to Zigbee firmware not being detected.""" @@ -346,8 +346,8 @@ async def test_config_flow_zigbee_confirmation_fails(hass: HomeAssistant) -> Non @pytest.mark.parametrize( - "ignore_translations", - ["component.test_firmware_domain.config.abort.not_hassio_thread"], + "ignore_translations_for_mock_domains", + ["test_firmware_domain"], ) async def test_config_flow_thread_not_hassio(hass: HomeAssistant) -> None: """Test when the stick is used with a non-hassio setup and Thread is selected.""" @@ -373,8 +373,8 @@ async def test_config_flow_thread_not_hassio(hass: HomeAssistant) -> None: @pytest.mark.parametrize( - "ignore_translations", - ["component.test_firmware_domain.config.abort.addon_info_failed"], + "ignore_translations_for_mock_domains", + ["test_firmware_domain"], ) async def test_config_flow_thread_addon_info_fails(hass: HomeAssistant) -> None: """Test failure case when flasher addon cannot be installed.""" @@ -401,8 +401,8 @@ async def test_config_flow_thread_addon_info_fails(hass: HomeAssistant) -> None: @pytest.mark.parametrize( - "ignore_translations", - ["component.test_firmware_domain.config.abort.otbr_addon_already_running"], + "ignore_translations_for_mock_domains", + ["test_firmware_domain"], ) async def test_config_flow_thread_addon_already_running(hass: HomeAssistant) -> None: """Test failure case when the Thread addon is already running.""" @@ -440,8 +440,8 @@ async def test_config_flow_thread_addon_already_running(hass: HomeAssistant) -> @pytest.mark.parametrize( - "ignore_translations", - ["component.test_firmware_domain.config.abort.addon_install_failed"], + "ignore_translations_for_mock_domains", + ["test_firmware_domain"], ) async def test_config_flow_thread_addon_install_fails(hass: HomeAssistant) -> None: """Test failure case when flasher addon cannot be installed.""" @@ -471,8 +471,8 @@ async def test_config_flow_thread_addon_install_fails(hass: HomeAssistant) -> No @pytest.mark.parametrize( - "ignore_translations", - ["component.test_firmware_domain.config.abort.addon_set_config_failed"], + "ignore_translations_for_mock_domains", + ["test_firmware_domain"], ) async def test_config_flow_thread_addon_set_config_fails(hass: HomeAssistant) -> None: """Test failure case when flasher addon cannot be configured.""" @@ -502,8 +502,8 @@ async def test_config_flow_thread_addon_set_config_fails(hass: HomeAssistant) -> @pytest.mark.parametrize( - "ignore_translations", - ["component.test_firmware_domain.config.abort.addon_start_failed"], + "ignore_translations_for_mock_domains", + ["test_firmware_domain"], ) async def test_config_flow_thread_flasher_run_fails(hass: HomeAssistant) -> None: """Test failure case when flasher addon fails to run.""" @@ -567,8 +567,8 @@ async def test_config_flow_thread_flasher_uninstall_fails(hass: HomeAssistant) - @pytest.mark.parametrize( - "ignore_translations", - ["component.test_firmware_domain.config.abort.unsupported_firmware"], + "ignore_translations_for_mock_domains", + ["test_firmware_domain"], ) async def test_config_flow_thread_confirmation_fails(hass: HomeAssistant) -> None: """Test the config flow failing due to OpenThread firmware not being detected.""" @@ -609,8 +609,8 @@ async def test_config_flow_thread_confirmation_fails(hass: HomeAssistant) -> Non @pytest.mark.parametrize( - "ignore_translations", - ["component.test_firmware_domain.options.abort.zha_still_using_stick"], + "ignore_translations_for_mock_domains", + ["test_firmware_domain"], ) async def test_options_flow_zigbee_to_thread_zha_configured( hass: HomeAssistant, @@ -657,8 +657,8 @@ async def test_options_flow_zigbee_to_thread_zha_configured( @pytest.mark.parametrize( - "ignore_translations", - ["component.test_firmware_domain.options.abort.otbr_still_using_stick"], + "ignore_translations_for_mock_domains", + ["test_firmware_domain"], ) async def test_options_flow_thread_to_zigbee_otbr_configured( hass: HomeAssistant, diff --git a/tests/components/homeassistant_hardware/test_silabs_multiprotocol_addon.py b/tests/components/homeassistant_hardware/test_silabs_multiprotocol_addon.py index 22e3e338986..fbba3d42bbe 100644 --- a/tests/components/homeassistant_hardware/test_silabs_multiprotocol_addon.py +++ b/tests/components/homeassistant_hardware/test_silabs_multiprotocol_addon.py @@ -450,10 +450,7 @@ async def test_option_flow_install_multi_pan_addon_zha_other_radio( } -@pytest.mark.parametrize( - "ignore_translations", - ["component.test.options.abort.not_hassio"], -) +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["test"]) async def test_option_flow_non_hassio( hass: HomeAssistant, ) -> None: @@ -766,10 +763,7 @@ async def test_option_flow_addon_installed_same_device_do_not_uninstall_multi_pa assert result["type"] is FlowResultType.CREATE_ENTRY -@pytest.mark.parametrize( - "ignore_translations", - ["component.test.options.abort.addon_already_running"], -) +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["test"]) async def test_option_flow_flasher_already_running_failure( hass: HomeAssistant, addon_info, @@ -881,10 +875,7 @@ async def test_option_flow_addon_installed_same_device_flasher_already_installed assert result["type"] is FlowResultType.CREATE_ENTRY -@pytest.mark.parametrize( - "ignore_translations", - ["component.test.options.abort.addon_install_failed"], -) +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["test"]) async def test_option_flow_flasher_install_failure( hass: HomeAssistant, addon_info, @@ -951,10 +942,7 @@ async def test_option_flow_flasher_install_failure( assert result["reason"] == "addon_install_failed" -@pytest.mark.parametrize( - "ignore_translations", - ["component.test.options.abort.addon_start_failed"], -) +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["test"]) async def test_option_flow_flasher_addon_flash_failure( hass: HomeAssistant, addon_info, @@ -1017,10 +1005,7 @@ async def test_option_flow_flasher_addon_flash_failure( assert result["description_placeholders"]["addon_name"] == "Silicon Labs Flasher" -@pytest.mark.parametrize( - "ignore_translations", - ["component.test.options.abort.zha_migration_failed"], -) +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["test"]) @patch( "homeassistant.components.zha.radio_manager.ZhaMultiPANMigrationHelper.async_initiate_migration", side_effect=Exception("Boom!"), @@ -1082,10 +1067,7 @@ async def test_option_flow_uninstall_migration_initiate_failure( mock_initiate_migration.assert_called_once() -@pytest.mark.parametrize( - "ignore_translations", - ["component.test.options.abort.zha_migration_failed"], -) +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["test"]) @patch( "homeassistant.components.zha.radio_manager.ZhaMultiPANMigrationHelper.async_finish_migration", side_effect=Exception("Boom!"), @@ -1187,10 +1169,7 @@ async def test_option_flow_do_not_install_multi_pan_addon( assert result["type"] is FlowResultType.CREATE_ENTRY -@pytest.mark.parametrize( - "ignore_translations", - ["component.test.options.abort.addon_install_failed"], -) +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["test"]) async def test_option_flow_install_multi_pan_addon_install_fails( hass: HomeAssistant, addon_store_info, @@ -1234,10 +1213,7 @@ async def test_option_flow_install_multi_pan_addon_install_fails( assert result["reason"] == "addon_install_failed" -@pytest.mark.parametrize( - "ignore_translations", - ["component.test.options.abort.addon_start_failed"], -) +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["test"]) async def test_option_flow_install_multi_pan_addon_start_fails( hass: HomeAssistant, addon_store_info, @@ -1299,10 +1275,7 @@ async def test_option_flow_install_multi_pan_addon_start_fails( assert result["reason"] == "addon_start_failed" -@pytest.mark.parametrize( - "ignore_translations", - ["component.test.options.abort.addon_set_config_failed"], -) +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["test"]) async def test_option_flow_install_multi_pan_addon_set_options_fails( hass: HomeAssistant, addon_store_info, @@ -1346,10 +1319,7 @@ async def test_option_flow_install_multi_pan_addon_set_options_fails( assert result["reason"] == "addon_set_config_failed" -@pytest.mark.parametrize( - "ignore_translations", - ["component.test.options.abort.addon_info_failed"], -) +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["test"]) async def test_option_flow_addon_info_fails( hass: HomeAssistant, addon_store_info, @@ -1373,10 +1343,7 @@ async def test_option_flow_addon_info_fails( assert result["reason"] == "addon_info_failed" -@pytest.mark.parametrize( - "ignore_translations", - ["component.test.options.abort.zha_migration_failed"], -) +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["test"]) @patch( "homeassistant.components.zha.radio_manager.ZhaMultiPANMigrationHelper.async_initiate_migration", side_effect=Exception("Boom!"), @@ -1432,10 +1399,7 @@ async def test_option_flow_install_multi_pan_addon_zha_migration_fails_step_1( set_addon_options.assert_not_called() -@pytest.mark.parametrize( - "ignore_translations", - ["component.test.options.abort.zha_migration_failed"], -) +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["test"]) @patch( "homeassistant.components.zha.radio_manager.ZhaMultiPANMigrationHelper.async_finish_migration", side_effect=Exception("Boom!"), diff --git a/tests/components/onkyo/test_config_flow.py b/tests/components/onkyo/test_config_flow.py index 000e74d5308..28186503ead 100644 --- a/tests/components/onkyo/test_config_flow.py +++ b/tests/components/onkyo/test_config_flow.py @@ -620,7 +620,7 @@ async def test_import_success( @pytest.mark.parametrize( - "ignore_translations", + "ignore_missing_translations", [ [ # The schema is dynamically created from input sources and listening modes "component.onkyo.options.step.names.sections.input_sources.data.TV", diff --git a/tests/components/repairs/test_init.py b/tests/components/repairs/test_init.py index e78563503f1..9c4a0dfbd2a 100644 --- a/tests/components/repairs/test_init.py +++ b/tests/components/repairs/test_init.py @@ -21,16 +21,7 @@ from tests.common import mock_platform from tests.typing import WebSocketGenerator -@pytest.mark.parametrize( - "ignore_translations", - [ - [ - "component.test.issues.even_worse.title", - "component.test.issues.even_worse.description", - "component.test.issues.abc_123.title", - ] - ], -) +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["test"]) @pytest.mark.freeze_time("2022-07-19 07:53:05") async def test_create_update_issue( hass: HomeAssistant, hass_ws_client: WebSocketGenerator @@ -170,14 +161,7 @@ async def test_create_issue_invalid_version( assert msg["result"] == {"issues": []} -@pytest.mark.parametrize( - "ignore_translations", - [ - [ - "component.test.issues.abc_123.title", - ] - ], -) +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["test"]) @pytest.mark.freeze_time("2022-07-19 07:53:05") async def test_ignore_issue( hass: HomeAssistant, hass_ws_client: WebSocketGenerator @@ -347,10 +331,7 @@ async def test_ignore_issue( } -@pytest.mark.parametrize( - "ignore_translations", - ["component.fake_integration.issues.abc_123.title"], -) +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["fake_integration"]) @pytest.mark.freeze_time("2022-07-19 07:53:05") async def test_delete_issue( hass: HomeAssistant, @@ -505,10 +486,7 @@ async def test_non_compliant_platform( assert list(hass.data[DOMAIN]["platforms"].keys()) == ["fake_integration"] -@pytest.mark.parametrize( - "ignore_translations", - ["component.fake_integration.issues.abc_123.title"], -) +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["fake_integration"]) @pytest.mark.freeze_time("2022-07-21 08:22:00") async def test_sync_methods( hass: HomeAssistant, diff --git a/tests/components/repairs/test_websocket_api.py b/tests/components/repairs/test_websocket_api.py index 399292fb83f..bbaf70e0a9b 100644 --- a/tests/components/repairs/test_websocket_api.py +++ b/tests/components/repairs/test_websocket_api.py @@ -151,10 +151,7 @@ async def mock_repairs_integration(hass: HomeAssistant) -> None: ) -@pytest.mark.parametrize( - "ignore_translations", - ["component.fake_integration.issues.abc_123.title"], -) +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["fake_integration"]) async def test_dismiss_issue( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -238,10 +235,7 @@ async def test_dismiss_issue( } -@pytest.mark.parametrize( - "ignore_translations", - ["component.fake_integration.issues.abc_123.title"], -) +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["fake_integration"]) async def test_fix_non_existing_issue( hass: HomeAssistant, hass_client: ClientSessionGenerator, @@ -289,19 +283,19 @@ async def test_fix_non_existing_issue( @pytest.mark.parametrize( - ("domain", "step", "description_placeholders", "ignore_translations"), + ( + "domain", + "step", + "description_placeholders", + "ignore_translations_for_mock_domains", + ), [ - ( - "fake_integration", - "custom_step", - None, - ["component.fake_integration.issues.abc_123.title"], - ), + ("fake_integration", "custom_step", None, ["fake_integration"]), ( "fake_integration_default_handler", "confirm", {"abc": "123"}, - ["component.fake_integration_default_handler.issues.abc_123.title"], + ["fake_integration_default_handler"], ), ], ) @@ -398,10 +392,7 @@ async def test_fix_issue_unauth( assert resp.status == HTTPStatus.UNAUTHORIZED -@pytest.mark.parametrize( - "ignore_translations", - ["component.fake_integration.issues.abc_123.title"], -) +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["fake_integration"]) async def test_get_progress_unauth( hass: HomeAssistant, hass_client: ClientSessionGenerator, @@ -433,10 +424,7 @@ async def test_get_progress_unauth( assert resp.status == HTTPStatus.UNAUTHORIZED -@pytest.mark.parametrize( - "ignore_translations", - ["component.fake_integration.issues.abc_123.title"], -) +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["fake_integration"]) async def test_step_unauth( hass: HomeAssistant, hass_client: ClientSessionGenerator, @@ -468,16 +456,7 @@ async def test_step_unauth( assert resp.status == HTTPStatus.UNAUTHORIZED -@pytest.mark.parametrize( - "ignore_translations", - [ - [ - "component.test.issues.even_worse.title", - "component.test.issues.even_worse.description", - "component.test.issues.abc_123.title", - ] - ], -) +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["test"]) @pytest.mark.freeze_time("2022-07-19 07:53:05") async def test_list_issues( hass: HomeAssistant, @@ -569,15 +548,7 @@ async def test_list_issues( } -@pytest.mark.parametrize( - "ignore_translations", - [ - [ - "component.fake_integration.issues.abc_123.title", - "component.fake_integration.issues.abc_123.fix_flow.abort.not_given", - ] - ], -) +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["fake_integration"]) async def test_fix_issue_aborted( hass: HomeAssistant, hass_client: ClientSessionGenerator, @@ -639,16 +610,7 @@ async def test_fix_issue_aborted( assert msg["result"]["issues"][0] == first_issue -@pytest.mark.parametrize( - "ignore_translations", - [ - [ - "component.test.issues.abc_123.title", - "component.test.issues.even_worse.title", - "component.test.issues.even_worse.description", - ] - ], -) +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["test"]) @pytest.mark.freeze_time("2022-07-19 07:53:05") async def test_get_issue_data( hass: HomeAssistant, hass_ws_client: WebSocketGenerator diff --git a/tests/components/sensor/test_recorder.py b/tests/components/sensor/test_recorder.py index 615960defbb..a5b6a07dde5 100644 --- a/tests/components/sensor/test_recorder.py +++ b/tests/components/sensor/test_recorder.py @@ -5449,12 +5449,11 @@ async def test_exclude_attributes(hass: HomeAssistant) -> None: assert ATTR_FRIENDLY_NAME in states[0].attributes +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["test"]) @pytest.mark.parametrize( - "ignore_translations", + "ignore_missing_translations", [ [ - "component.test.issues..title", - "component.test.issues..description", "component.sensor.issues..title", "component.sensor.issues..description", ] diff --git a/tests/components/synology_dsm/test_repairs.py b/tests/components/synology_dsm/test_repairs.py index 0dea980b553..a094928b837 100644 --- a/tests/components/synology_dsm/test_repairs.py +++ b/tests/components/synology_dsm/test_repairs.py @@ -256,7 +256,7 @@ async def test_missing_backup_no_shares( @pytest.mark.parametrize( - "ignore_translations", + "ignore_missing_translations", ["component.synology_dsm.issues.other_issue.title"], ) async def test_other_fixable_issues( diff --git a/tests/components/websocket_api/test_commands.py b/tests/components/websocket_api/test_commands.py index baa939c411b..c0114cde42b 100644 --- a/tests/components/websocket_api/test_commands.py +++ b/tests/components/websocket_api/test_commands.py @@ -540,10 +540,7 @@ async def test_call_service_schema_validation_error( assert len(calls) == 0 -@pytest.mark.parametrize( - "ignore_translations", - ["component.test.exceptions.custom_error.message"], -) +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["test"]) async def test_call_service_error( hass: HomeAssistant, websocket_client: MockHAClientWebSocket ) -> None: @@ -2394,9 +2391,7 @@ async def test_execute_script( ), ], ) -@pytest.mark.parametrize( - "ignore_translations", ["component.test.exceptions.test_error.message"] -) +@pytest.mark.parametrize("ignore_translations_for_mock_domains", ["test"]) async def test_execute_script_err_localization( hass: HomeAssistant, websocket_client: MockHAClientWebSocket, diff --git a/tests/components/workday/test_repairs.py b/tests/components/workday/test_repairs.py index adbae5676e6..09b0149a424 100644 --- a/tests/components/workday/test_repairs.py +++ b/tests/components/workday/test_repairs.py @@ -430,7 +430,7 @@ async def test_bad_date_holiday( @pytest.mark.parametrize( - "ignore_translations", + "ignore_missing_translations", ["component.workday.issues.issue_1.title"], ) async def test_other_fixable_issues( diff --git a/tests/components/zwave_js/test_repairs.py b/tests/components/zwave_js/test_repairs.py index a46320168eb..1d0f74c7269 100644 --- a/tests/components/zwave_js/test_repairs.py +++ b/tests/components/zwave_js/test_repairs.py @@ -180,7 +180,7 @@ async def test_device_config_file_changed_ignore_step( @pytest.mark.parametrize( - "ignore_translations", + "ignore_missing_translations", ["component.zwave_js.issues.invalid_issue.title"], ) async def test_invalid_issue( From 19704cff0418a970be9b8c70319e20183f305d58 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 25 Feb 2025 19:10:54 +0000 Subject: [PATCH 1807/3148] Fix grammar in loader comments (#139276) https://github.com/home-assistant/core/pull/139270#discussion_r1970315129 --- homeassistant/loader.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/loader.py b/homeassistant/loader.py index 008c2b057b2..3bc33f8374c 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -1435,7 +1435,7 @@ async def async_get_integrations( results[domain] = exc # We don't use set_exception because # we expect there will be cases where - # the a future exception is never retrieved + # the future exception is never retrieved future.set_result(exc) return results From 570e11ba5b5bb6a5a37603e5acfa0e019a224e9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Tue, 25 Feb 2025 20:22:30 +0100 Subject: [PATCH 1808/3148] Bump aiohomeconnect to 0.15.0 (#139277) --- homeassistant/components/home_connect/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/home_connect/manifest.json b/homeassistant/components/home_connect/manifest.json index 06325afaed8..28714b31679 100644 --- a/homeassistant/components/home_connect/manifest.json +++ b/homeassistant/components/home_connect/manifest.json @@ -7,6 +7,6 @@ "documentation": "https://www.home-assistant.io/integrations/home_connect", "iot_class": "cloud_push", "loggers": ["aiohomeconnect"], - "requirements": ["aiohomeconnect==0.12.3"], + "requirements": ["aiohomeconnect==0.15.0"], "single_config_entry": true } diff --git a/requirements_all.txt b/requirements_all.txt index c318a069597..c8265568525 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -264,7 +264,7 @@ aioharmony==0.4.1 aiohasupervisor==0.3.0 # homeassistant.components.home_connect -aiohomeconnect==0.12.3 +aiohomeconnect==0.15.0 # homeassistant.components.homekit_controller aiohomekit==3.2.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d42434585d1..bc065805b2e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -249,7 +249,7 @@ aioharmony==0.4.1 aiohasupervisor==0.3.0 # homeassistant.components.home_connect -aiohomeconnect==0.12.3 +aiohomeconnect==0.15.0 # homeassistant.components.homekit_controller aiohomekit==3.2.7 From b8a0cdea124c87c0a9e663f451f2841ea8491026 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Tue, 25 Feb 2025 20:50:42 +0100 Subject: [PATCH 1809/3148] Add current cavity temperature sensor to Home Connect (#139282) --- homeassistant/components/home_connect/sensor.py | 6 ++++++ homeassistant/components/home_connect/strings.json | 3 +++ 2 files changed, 9 insertions(+) diff --git a/homeassistant/components/home_connect/sensor.py b/homeassistant/components/home_connect/sensor.py index be0b621b508..3f85bc3404c 100644 --- a/homeassistant/components/home_connect/sensor.py +++ b/homeassistant/components/home_connect/sensor.py @@ -179,6 +179,12 @@ SENSORS = ( ], translation_key="last_selected_map", ), + HomeConnectSensorEntityDescription( + key=StatusKey.COOKING_OVEN_CURRENT_CAVITY_TEMPERATURE, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + translation_key="current_cavity_temperature", + ), ) EVENT_SENSORS = ( diff --git a/homeassistant/components/home_connect/strings.json b/homeassistant/components/home_connect/strings.json index 672ad364365..4fabd1e1c50 100644 --- a/homeassistant/components/home_connect/strings.json +++ b/homeassistant/components/home_connect/strings.json @@ -1529,6 +1529,9 @@ "map3": "Map 3" } }, + "current_cavity_temperature": { + "name": "Current cavity temperature" + }, "freezer_door_alarm": { "name": "Freezer door alarm", "state": { From df6a5d7459cfe6348c5628857c19ecc99956cced Mon Sep 17 00:00:00 2001 From: Denis Shulyaka Date: Tue, 25 Feb 2025 23:24:38 +0300 Subject: [PATCH 1810/3148] Bump anthropic to 0.47.2 (#139283) --- homeassistant/components/anthropic/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/anthropic/manifest.json b/homeassistant/components/anthropic/manifest.json index b5cbb36c034..797a7299d16 100644 --- a/homeassistant/components/anthropic/manifest.json +++ b/homeassistant/components/anthropic/manifest.json @@ -8,5 +8,5 @@ "documentation": "https://www.home-assistant.io/integrations/anthropic", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["anthropic==0.44.0"] + "requirements": ["anthropic==0.47.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index c8265568525..79015872b6d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -476,7 +476,7 @@ anova-wifi==0.17.0 anthemav==1.4.1 # homeassistant.components.anthropic -anthropic==0.44.0 +anthropic==0.47.2 # homeassistant.components.mcp_server anyio==4.8.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bc065805b2e..479557ba478 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -449,7 +449,7 @@ anova-wifi==0.17.0 anthemav==1.4.1 # homeassistant.components.anthropic -anthropic==0.44.0 +anthropic==0.47.2 # homeassistant.components.mcp_server anyio==4.8.0 From fd47d6578e866de8a8bdb0fc64d652960c8fc3f4 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 25 Feb 2025 21:31:24 +0100 Subject: [PATCH 1811/3148] Adjust recorder validate_statistics handler (#139229) --- homeassistant/components/recorder/websocket_api.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/recorder/websocket_api.py b/homeassistant/components/recorder/websocket_api.py index 03d9e725170..d23ecab3dac 100644 --- a/homeassistant/components/recorder/websocket_api.py +++ b/homeassistant/components/recorder/websocket_api.py @@ -297,13 +297,13 @@ async def ws_list_statistic_ids( async def ws_validate_statistics( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] ) -> None: - """Fetch a list of available statistic_id.""" + """Validate statistics and return issues found.""" instance = get_instance(hass) - statistic_ids = await instance.async_add_executor_job( + validation_issues = await instance.async_add_executor_job( validate_statistics, hass, ) - connection.send_result(msg["id"], statistic_ids) + connection.send_result(msg["id"], validation_issues) @websocket_api.websocket_command( From 03f6508bd89f41eee089634739b0581be5849669 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pierre=20St=C3=A5hl?= Date: Tue, 25 Feb 2025 21:37:01 +0100 Subject: [PATCH 1812/3148] Fix re-connect logic in Apple TV integration (#139289) --- homeassistant/components/apple_tv/__init__.py | 1 - 1 file changed, 1 deletion(-) diff --git a/homeassistant/components/apple_tv/__init__.py b/homeassistant/components/apple_tv/__init__.py index f4417134b37..b911b3cec99 100644 --- a/homeassistant/components/apple_tv/__init__.py +++ b/homeassistant/components/apple_tv/__init__.py @@ -233,7 +233,6 @@ class AppleTVManager(DeviceListener): pass except Exception: _LOGGER.exception("Failed to connect") - await self.disconnect() async def _connect_loop(self) -> None: """Connect loop background task function.""" From fe348e17a3b709660fb5d24a193461fb19519892 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 25 Feb 2025 20:43:06 +0000 Subject: [PATCH 1813/3148] Revert "Bump stookwijzer==1.5.8" (#139287) --- homeassistant/components/stookwijzer/__init__.py | 2 ++ homeassistant/components/stookwijzer/config_flow.py | 2 ++ homeassistant/components/stookwijzer/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 7 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/stookwijzer/__init__.py b/homeassistant/components/stookwijzer/__init__.py index cb198749c52..d8b9561bde9 100644 --- a/homeassistant/components/stookwijzer/__init__.py +++ b/homeassistant/components/stookwijzer/__init__.py @@ -9,6 +9,7 @@ from stookwijzer import Stookwijzer from homeassistant.const import CONF_LATITUDE, CONF_LOCATION, CONF_LONGITUDE, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er, issue_registry as ir +from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DOMAIN, LOGGER from .coordinator import StookwijzerConfigEntry, StookwijzerCoordinator @@ -43,6 +44,7 @@ async def async_migrate_entry( if entry.version == 1: latitude, longitude = await Stookwijzer.async_transform_coordinates( + async_get_clientsession(hass), entry.data[CONF_LOCATION][CONF_LATITUDE], entry.data[CONF_LOCATION][CONF_LONGITUDE], ) diff --git a/homeassistant/components/stookwijzer/config_flow.py b/homeassistant/components/stookwijzer/config_flow.py index 124b0f8bfbb..32b4836763f 100644 --- a/homeassistant/components/stookwijzer/config_flow.py +++ b/homeassistant/components/stookwijzer/config_flow.py @@ -9,6 +9,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_LATITUDE, CONF_LOCATION, CONF_LONGITUDE +from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.selector import LocationSelector from .const import DOMAIN @@ -26,6 +27,7 @@ class StookwijzerFlowHandler(ConfigFlow, domain=DOMAIN): errors = {} if user_input is not None: latitude, longitude = await Stookwijzer.async_transform_coordinates( + async_get_clientsession(self.hass), user_input[CONF_LOCATION][CONF_LATITUDE], user_input[CONF_LOCATION][CONF_LONGITUDE], ) diff --git a/homeassistant/components/stookwijzer/manifest.json b/homeassistant/components/stookwijzer/manifest.json index 86fccf64db1..8243b903e8d 100644 --- a/homeassistant/components/stookwijzer/manifest.json +++ b/homeassistant/components/stookwijzer/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/stookwijzer", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["stookwijzer==1.5.8"] + "requirements": ["stookwijzer==1.5.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index 79015872b6d..7caab6809ba 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2811,7 +2811,7 @@ statsd==3.2.1 steamodd==4.21 # homeassistant.components.stookwijzer -stookwijzer==1.5.8 +stookwijzer==1.5.4 # homeassistant.components.streamlabswater streamlabswater==1.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 479557ba478..3ca116b3c24 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2272,7 +2272,7 @@ statsd==3.2.1 steamodd==4.21 # homeassistant.components.stookwijzer -stookwijzer==1.5.8 +stookwijzer==1.5.4 # homeassistant.components.streamlabswater streamlabswater==1.0.1 From 81db3dea4183918a85d4d264f253aa27f26c9293 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 25 Feb 2025 20:56:39 +0000 Subject: [PATCH 1814/3148] Add option to ESPHome to subscribe to logs (#139073) --- .../components/esphome/config_flow.py | 5 ++ homeassistant/components/esphome/const.py | 1 + homeassistant/components/esphome/manager.py | 39 +++++++++++ homeassistant/components/esphome/strings.json | 3 +- tests/components/esphome/conftest.py | 26 +++++++- tests/components/esphome/test_config_flow.py | 66 ++++++++++++++++--- tests/components/esphome/test_manager.py | 62 ++++++++++++++++- 7 files changed, 189 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/esphome/config_flow.py b/homeassistant/components/esphome/config_flow.py index 695131b19f7..955a93cd2b7 100644 --- a/homeassistant/components/esphome/config_flow.py +++ b/homeassistant/components/esphome/config_flow.py @@ -41,6 +41,7 @@ from .const import ( CONF_ALLOW_SERVICE_CALLS, CONF_DEVICE_NAME, CONF_NOISE_PSK, + CONF_SUBSCRIBE_LOGS, DEFAULT_ALLOW_SERVICE_CALLS, DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS, DOMAIN, @@ -508,6 +509,10 @@ class OptionsFlowHandler(OptionsFlow): CONF_ALLOW_SERVICE_CALLS, DEFAULT_ALLOW_SERVICE_CALLS ), ): bool, + vol.Required( + CONF_SUBSCRIBE_LOGS, + default=self.config_entry.options.get(CONF_SUBSCRIBE_LOGS, False), + ): bool, } ) return self.async_show_form(step_id="init", data_schema=data_schema) diff --git a/homeassistant/components/esphome/const.py b/homeassistant/components/esphome/const.py index 143aaa6342a..aabebad01b6 100644 --- a/homeassistant/components/esphome/const.py +++ b/homeassistant/components/esphome/const.py @@ -5,6 +5,7 @@ from awesomeversion import AwesomeVersion DOMAIN = "esphome" CONF_ALLOW_SERVICE_CALLS = "allow_service_calls" +CONF_SUBSCRIBE_LOGS = "subscribe_logs" CONF_DEVICE_NAME = "device_name" CONF_NOISE_PSK = "noise_psk" diff --git a/homeassistant/components/esphome/manager.py b/homeassistant/components/esphome/manager.py index 5f5ee1241f7..c73268de747 100644 --- a/homeassistant/components/esphome/manager.py +++ b/homeassistant/components/esphome/manager.py @@ -5,6 +5,7 @@ from __future__ import annotations import asyncio from functools import partial import logging +import re from typing import TYPE_CHECKING, Any, NamedTuple from aioesphomeapi import ( @@ -16,6 +17,7 @@ from aioesphomeapi import ( HomeassistantServiceCall, InvalidAuthAPIError, InvalidEncryptionKeyAPIError, + LogLevel, ReconnectLogic, RequiresEncryptionAPIError, UserService, @@ -61,6 +63,7 @@ from .bluetooth import async_connect_scanner from .const import ( CONF_ALLOW_SERVICE_CALLS, CONF_DEVICE_NAME, + CONF_SUBSCRIBE_LOGS, DEFAULT_ALLOW_SERVICE_CALLS, DEFAULT_URL, DOMAIN, @@ -74,8 +77,30 @@ from .domain_data import DomainData # Import config flow so that it's added to the registry from .entry_data import ESPHomeConfigEntry, RuntimeEntryData +if TYPE_CHECKING: + from aioesphomeapi.api_pb2 import ( # type: ignore[attr-defined] + SubscribeLogsResponse, + ) + + _LOGGER = logging.getLogger(__name__) +LOG_LEVEL_TO_LOGGER = { + LogLevel.LOG_LEVEL_NONE: logging.DEBUG, + LogLevel.LOG_LEVEL_ERROR: logging.ERROR, + LogLevel.LOG_LEVEL_WARN: logging.WARNING, + LogLevel.LOG_LEVEL_INFO: logging.INFO, + LogLevel.LOG_LEVEL_CONFIG: logging.INFO, + LogLevel.LOG_LEVEL_DEBUG: logging.DEBUG, + LogLevel.LOG_LEVEL_VERBOSE: logging.DEBUG, + LogLevel.LOG_LEVEL_VERY_VERBOSE: logging.DEBUG, +} +# 7-bit and 8-bit C1 ANSI sequences +# https://stackoverflow.com/questions/14693701/how-can-i-remove-the-ansi-escape-sequences-from-a-string-in-python +ANSI_ESCAPE_78BIT = re.compile( + rb"(?:\x1B[@-Z\\-_]|[\x80-\x9A\x9C-\x9F]|(?:\x1B\[|\x9B)[0-?]*[ -/]*[@-~])" +) + @callback def _async_check_firmware_version( @@ -341,6 +366,18 @@ class ESPHomeManager: # Re-connection logic will trigger after this await self.cli.disconnect() + def _async_on_log(self, msg: SubscribeLogsResponse) -> None: + """Handle a log message from the API.""" + logger_level = LOG_LEVEL_TO_LOGGER.get(msg.level, logging.DEBUG) + if _LOGGER.isEnabledFor(logger_level): + log: bytes = msg.message + _LOGGER.log( + logger_level, + "%s: %s", + self.entry.title, + ANSI_ESCAPE_78BIT.sub(b"", log).decode("utf-8", "backslashreplace"), + ) + async def _on_connnect(self) -> None: """Subscribe to states and list entities on successful API login.""" entry = self.entry @@ -352,6 +389,8 @@ class ESPHomeManager: cli = self.cli stored_device_name = entry.data.get(CONF_DEVICE_NAME) unique_id_is_mac_address = unique_id and ":" in unique_id + if entry.options.get(CONF_SUBSCRIBE_LOGS): + cli.subscribe_logs(self._async_on_log, LogLevel.LOG_LEVEL_VERY_VERBOSE) results = await asyncio.gather( create_eager_task(cli.device_info()), create_eager_task(cli.list_entities_services()), diff --git a/homeassistant/components/esphome/strings.json b/homeassistant/components/esphome/strings.json index 81b58de8df2..1534a49e365 100644 --- a/homeassistant/components/esphome/strings.json +++ b/homeassistant/components/esphome/strings.json @@ -54,7 +54,8 @@ "step": { "init": { "data": { - "allow_service_calls": "Allow the device to perform Home Assistant actions." + "allow_service_calls": "Allow the device to perform Home Assistant actions.", + "subscribe_logs": "Subscribe to logs from the device. When enabled, the device will send logs to Home Assistant and you can view them in the logs panel." } } } diff --git a/tests/components/esphome/conftest.py b/tests/components/esphome/conftest.py index 2b7c127efd3..07f6c6ea697 100644 --- a/tests/components/esphome/conftest.py +++ b/tests/components/esphome/conftest.py @@ -6,7 +6,7 @@ import asyncio from asyncio import Event from collections.abc import AsyncGenerator, Awaitable, Callable, Coroutine from pathlib import Path -from typing import Any +from typing import TYPE_CHECKING, Any from unittest.mock import AsyncMock, MagicMock, Mock, patch from aioesphomeapi import ( @@ -17,6 +17,7 @@ from aioesphomeapi import ( EntityInfo, EntityState, HomeassistantServiceCall, + LogLevel, ReconnectLogic, UserService, VoiceAssistantAnnounceFinished, @@ -42,6 +43,10 @@ from . import DASHBOARD_HOST, DASHBOARD_PORT, DASHBOARD_SLUG from tests.common import MockConfigEntry +if TYPE_CHECKING: + from aioesphomeapi.api_pb2 import SubscribeLogsResponse + + _ONE_SECOND = 16000 * 2 # 16Khz 16-bit @@ -154,6 +159,7 @@ def mock_client(mock_device_info) -> APIClient: mock_client.device_info = AsyncMock(return_value=mock_device_info) mock_client.connect = AsyncMock() mock_client.disconnect = AsyncMock() + mock_client.subscribe_logs = Mock() mock_client.list_entities_services = AsyncMock(return_value=([], [])) mock_client.address = "127.0.0.1" mock_client.api_version = APIVersion(99, 99) @@ -222,6 +228,7 @@ class MockESPHomeDevice: ] | None ) + self.on_log_message: Callable[[SubscribeLogsResponse], None] self.device_info = device_info def set_state_callback(self, state_callback: Callable[[EntityState], None]) -> None: @@ -250,6 +257,16 @@ class MockESPHomeDevice: """Mock disconnecting.""" await self.on_disconnect(expected_disconnect) + def set_on_log_message( + self, on_log_message: Callable[[SubscribeLogsResponse], None] + ) -> None: + """Set the log message callback.""" + self.on_log_message = on_log_message + + def mock_on_log_message(self, log_message: SubscribeLogsResponse) -> None: + """Mock on log message.""" + self.on_log_message(log_message) + def set_on_connect(self, on_connect: Callable[[], None]) -> None: """Set the connect callback.""" self.on_connect = on_connect @@ -413,6 +430,12 @@ async def _mock_generic_device_entry( on_state_sub, on_state_request ) + def _subscribe_logs( + on_log_message: Callable[[SubscribeLogsResponse], None], log_level: LogLevel + ) -> None: + """Subscribe to log messages.""" + mock_device.set_on_log_message(on_log_message) + def _subscribe_voice_assistant( *, handle_start: Callable[ @@ -453,6 +476,7 @@ async def _mock_generic_device_entry( mock_client.subscribe_states = _subscribe_states mock_client.subscribe_service_calls = _subscribe_service_calls mock_client.subscribe_home_assistant_states = _subscribe_home_assistant_states + mock_client.subscribe_logs = _subscribe_logs try_connect_done = Event() diff --git a/tests/components/esphome/test_config_flow.py b/tests/components/esphome/test_config_flow.py index 65dab4c516f..afca6f76b43 100644 --- a/tests/components/esphome/test_config_flow.py +++ b/tests/components/esphome/test_config_flow.py @@ -23,6 +23,7 @@ from homeassistant.components.esphome.const import ( CONF_ALLOW_SERVICE_CALLS, CONF_DEVICE_NAME, CONF_NOISE_PSK, + CONF_SUBSCRIBE_LOGS, DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS, DOMAIN, ) @@ -1295,14 +1296,57 @@ async def test_zeroconf_no_encryption_key_via_dashboard( assert result["step_id"] == "encryption_key" -@pytest.mark.parametrize("option_value", [True, False]) -async def test_option_flow( +async def test_option_flow_allow_service_calls( hass: HomeAssistant, - option_value: bool, mock_client: APIClient, mock_generic_device_entry, ) -> None: - """Test config flow options.""" + """Test config flow options for allow service calls.""" + entry = await mock_generic_device_entry( + mock_client=mock_client, + entity_info=[], + user_service=[], + states=[], + ) + + result = await hass.config_entries.options.async_init(entry.entry_id) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "init" + assert result["data_schema"]({}) == { + CONF_ALLOW_SERVICE_CALLS: DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS, + CONF_SUBSCRIBE_LOGS: False, + } + + result = await hass.config_entries.options.async_init(entry.entry_id) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "init" + assert result["data_schema"]({}) == { + CONF_ALLOW_SERVICE_CALLS: DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS, + CONF_SUBSCRIBE_LOGS: False, + } + with patch( + "homeassistant.components.esphome.async_setup_entry", return_value=True + ) as mock_reload: + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={CONF_ALLOW_SERVICE_CALLS: True, CONF_SUBSCRIBE_LOGS: False}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_ALLOW_SERVICE_CALLS: True, + CONF_SUBSCRIBE_LOGS: False, + } + assert len(mock_reload.mock_calls) == 1 + + +async def test_option_flow_subscribe_logs( + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry, +) -> None: + """Test config flow options with subscribe logs.""" entry = await mock_generic_device_entry( mock_client=mock_client, entity_info=[], @@ -1315,7 +1359,8 @@ async def test_option_flow( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "init" assert result["data_schema"]({}) == { - CONF_ALLOW_SERVICE_CALLS: DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS + CONF_ALLOW_SERVICE_CALLS: DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS, + CONF_SUBSCRIBE_LOGS: False, } with patch( @@ -1323,15 +1368,16 @@ async def test_option_flow( ) as mock_reload: result = await hass.config_entries.options.async_configure( result["flow_id"], - user_input={ - CONF_ALLOW_SERVICE_CALLS: option_value, - }, + user_input={CONF_ALLOW_SERVICE_CALLS: False, CONF_SUBSCRIBE_LOGS: True}, ) await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["data"] == {CONF_ALLOW_SERVICE_CALLS: option_value} - assert len(mock_reload.mock_calls) == int(option_value) + assert result["data"] == { + CONF_ALLOW_SERVICE_CALLS: False, + CONF_SUBSCRIBE_LOGS: True, + } + assert len(mock_reload.mock_calls) == 1 @pytest.mark.usefixtures("mock_zeroconf") diff --git a/tests/components/esphome/test_manager.py b/tests/components/esphome/test_manager.py index 7db1427d975..cf9d4a6f217 100644 --- a/tests/components/esphome/test_manager.py +++ b/tests/components/esphome/test_manager.py @@ -2,7 +2,8 @@ import asyncio from collections.abc import Awaitable, Callable -from unittest.mock import AsyncMock, call +import logging +from unittest.mock import AsyncMock, Mock, call from aioesphomeapi import ( APIClient, @@ -13,6 +14,7 @@ from aioesphomeapi import ( HomeassistantServiceCall, InvalidAuthAPIError, InvalidEncryptionKeyAPIError, + LogLevel, RequiresEncryptionAPIError, UserService, UserServiceArg, @@ -24,6 +26,7 @@ from homeassistant import config_entries from homeassistant.components.esphome.const import ( CONF_ALLOW_SERVICE_CALLS, CONF_DEVICE_NAME, + CONF_SUBSCRIBE_LOGS, DOMAIN, STABLE_BLE_VERSION_STR, ) @@ -44,6 +47,63 @@ from .conftest import MockESPHomeDevice from tests.common import MockConfigEntry, async_capture_events, async_mock_service +async def test_esphome_device_subscribe_logs( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: Callable[ + [APIClient, list[EntityInfo], list[UserService], list[EntityState]], + Awaitable[MockESPHomeDevice], + ], + caplog: pytest.LogCaptureFixture, +) -> None: + """Test configuring a device to subscribe to logs.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "fe80::1", + CONF_PORT: 6053, + CONF_PASSWORD: "", + }, + options={CONF_SUBSCRIBE_LOGS: True}, + ) + entry.add_to_hass(hass) + device: MockESPHomeDevice = await mock_esphome_device( + mock_client=mock_client, + entry=entry, + entity_info=[], + user_service=[], + device_info={}, + states=[], + ) + await hass.async_block_till_done() + caplog.set_level(logging.DEBUG) + device.mock_on_log_message( + Mock(level=LogLevel.LOG_LEVEL_INFO, message=b"test_log_message") + ) + await hass.async_block_till_done() + assert "test_log_message" in caplog.text + + device.mock_on_log_message( + Mock(level=LogLevel.LOG_LEVEL_ERROR, message=b"test_error_log_message") + ) + await hass.async_block_till_done() + assert "test_error_log_message" in caplog.text + + caplog.set_level(logging.ERROR) + device.mock_on_log_message( + Mock(level=LogLevel.LOG_LEVEL_DEBUG, message=b"test_debug_log_message") + ) + await hass.async_block_till_done() + assert "test_debug_log_message" not in caplog.text + + caplog.set_level(logging.DEBUG) + device.mock_on_log_message( + Mock(level=LogLevel.LOG_LEVEL_DEBUG, message=b"test_debug_log_message") + ) + await hass.async_block_till_done() + assert "test_debug_log_message" in caplog.text + + async def test_esphome_device_service_calls_not_allowed( hass: HomeAssistant, mock_client: APIClient, From 3230e741e9325253aac0dd3254fed68b4b8302ef Mon Sep 17 00:00:00 2001 From: G Johansson Date: Tue, 25 Feb 2025 22:49:41 +0100 Subject: [PATCH 1815/3148] Remove not used constants in smhi (#139298) --- homeassistant/components/smhi/weather.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/homeassistant/components/smhi/weather.py b/homeassistant/components/smhi/weather.py index d2e31990012..5faef04e03d 100644 --- a/homeassistant/components/smhi/weather.py +++ b/homeassistant/components/smhi/weather.py @@ -3,7 +3,6 @@ from __future__ import annotations from collections.abc import Mapping -from datetime import timedelta from typing import Any, Final from pysmhi import SMHIForecast @@ -80,12 +79,6 @@ CONDITION_MAP = { for cond_code in cond_codes } -TIMEOUT = 10 -# 5 minutes between retrying connect to API again -RETRY_TIMEOUT = 5 * 60 - -MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=31) - async def async_setup_entry( hass: HomeAssistant, From 7bc0c1b9121ec6eb078e43c99680d045b670655a Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Tue, 25 Feb 2025 22:52:44 +0100 Subject: [PATCH 1816/3148] Bump `aioshelly` to version `13.0.0` (#139294) * Bump aioshelly to version 13.0.0 * MODEL_BLU_GATEWAY_GEN3 -> MODEL_BLU_GATEWAY_G3 --- homeassistant/components/shelly/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/shelly/test_binary_sensor.py | 4 ++-- tests/components/shelly/test_climate.py | 8 ++++---- tests/components/shelly/test_number.py | 8 ++++---- tests/components/shelly/test_sensor.py | 4 ++-- 7 files changed, 15 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/shelly/manifest.json b/homeassistant/components/shelly/manifest.json index c8073d6dbc2..ec08a005995 100644 --- a/homeassistant/components/shelly/manifest.json +++ b/homeassistant/components/shelly/manifest.json @@ -8,7 +8,7 @@ "integration_type": "device", "iot_class": "local_push", "loggers": ["aioshelly"], - "requirements": ["aioshelly==12.4.2"], + "requirements": ["aioshelly==13.0.0"], "zeroconf": [ { "type": "_http._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index 7caab6809ba..4949a9fc4a9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -371,7 +371,7 @@ aioruuvigateway==0.1.0 aiosenz==1.0.0 # homeassistant.components.shelly -aioshelly==12.4.2 +aioshelly==13.0.0 # homeassistant.components.skybell aioskybell==22.7.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3ca116b3c24..17a6f6a6f56 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -353,7 +353,7 @@ aioruuvigateway==0.1.0 aiosenz==1.0.0 # homeassistant.components.shelly -aioshelly==12.4.2 +aioshelly==13.0.0 # homeassistant.components.skybell aioskybell==22.7.0 diff --git a/tests/components/shelly/test_binary_sensor.py b/tests/components/shelly/test_binary_sensor.py index 7f2d07b1ccc..1e7c54320e8 100644 --- a/tests/components/shelly/test_binary_sensor.py +++ b/tests/components/shelly/test_binary_sensor.py @@ -3,7 +3,7 @@ from copy import deepcopy from unittest.mock import Mock -from aioshelly.const import MODEL_BLU_GATEWAY_GEN3, MODEL_MOTION +from aioshelly.const import MODEL_BLU_GATEWAY_G3, MODEL_MOTION from freezegun.api import FrozenDateTimeFactory import pytest from syrupy import SnapshotAssertion @@ -486,7 +486,7 @@ async def test_blu_trv_binary_sensor_entity( snapshot: SnapshotAssertion, ) -> None: """Test BLU TRV binary sensor entity.""" - await init_integration(hass, 3, model=MODEL_BLU_GATEWAY_GEN3) + await init_integration(hass, 3, model=MODEL_BLU_GATEWAY_G3) for entity in ("calibration",): entity_id = f"{BINARY_SENSOR_DOMAIN}.trv_name_{entity}" diff --git a/tests/components/shelly/test_climate.py b/tests/components/shelly/test_climate.py index 5ad298c15a1..040d67cb9c4 100644 --- a/tests/components/shelly/test_climate.py +++ b/tests/components/shelly/test_climate.py @@ -5,7 +5,7 @@ from unittest.mock import AsyncMock, Mock, PropertyMock from aioshelly.const import ( BLU_TRV_IDENTIFIER, - MODEL_BLU_GATEWAY_GEN3, + MODEL_BLU_GATEWAY_G3, MODEL_VALVE, MODEL_WALL_DISPLAY, ) @@ -782,7 +782,7 @@ async def test_blu_trv_climate_set_temperature( entity_id = "climate.trv_name" monkeypatch.delitem(mock_blu_trv.status, "thermostat:0") - await init_integration(hass, 3, model=MODEL_BLU_GATEWAY_GEN3) + await init_integration(hass, 3, model=MODEL_BLU_GATEWAY_G3) assert get_entity_attribute(hass, entity_id, ATTR_TEMPERATURE) == 17.1 @@ -820,7 +820,7 @@ async def test_blu_trv_climate_disabled( entity_id = "climate.trv_name" monkeypatch.delitem(mock_blu_trv.status, "thermostat:0") - await init_integration(hass, 3, model=MODEL_BLU_GATEWAY_GEN3) + await init_integration(hass, 3, model=MODEL_BLU_GATEWAY_G3) assert get_entity_attribute(hass, entity_id, ATTR_TEMPERATURE) == 17.1 @@ -842,7 +842,7 @@ async def test_blu_trv_climate_hvac_action( entity_id = "climate.trv_name" monkeypatch.delitem(mock_blu_trv.status, "thermostat:0") - await init_integration(hass, 3, model=MODEL_BLU_GATEWAY_GEN3) + await init_integration(hass, 3, model=MODEL_BLU_GATEWAY_G3) assert get_entity_attribute(hass, entity_id, ATTR_HVAC_ACTION) == HVACAction.IDLE diff --git a/tests/components/shelly/test_number.py b/tests/components/shelly/test_number.py index b1b65d99ab5..6bddd1eeb23 100644 --- a/tests/components/shelly/test_number.py +++ b/tests/components/shelly/test_number.py @@ -3,7 +3,7 @@ from copy import deepcopy from unittest.mock import AsyncMock, Mock -from aioshelly.const import MODEL_BLU_GATEWAY_GEN3 +from aioshelly.const import MODEL_BLU_GATEWAY_G3 from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError import pytest from syrupy import SnapshotAssertion @@ -405,7 +405,7 @@ async def test_blu_trv_number_entity( # disable automatic temperature control in the device monkeypatch.setitem(mock_blu_trv.config["blutrv:200"], "enable", False) - await init_integration(hass, 3, model=MODEL_BLU_GATEWAY_GEN3) + await init_integration(hass, 3, model=MODEL_BLU_GATEWAY_G3) for entity in ("external_temperature", "valve_position"): entity_id = f"{NUMBER_DOMAIN}.trv_name_{entity}" @@ -421,7 +421,7 @@ async def test_blu_trv_ext_temp_set_value( hass: HomeAssistant, mock_blu_trv: Mock ) -> None: """Test the set value action for BLU TRV External Temperature number entity.""" - await init_integration(hass, 3, model=MODEL_BLU_GATEWAY_GEN3) + await init_integration(hass, 3, model=MODEL_BLU_GATEWAY_G3) entity_id = f"{NUMBER_DOMAIN}.trv_name_external_temperature" @@ -461,7 +461,7 @@ async def test_blu_trv_valve_pos_set_value( # disable automatic temperature control to enable valve position entity monkeypatch.setitem(mock_blu_trv.config["blutrv:200"], "enable", False) - await init_integration(hass, 3, model=MODEL_BLU_GATEWAY_GEN3) + await init_integration(hass, 3, model=MODEL_BLU_GATEWAY_G3) entity_id = f"{NUMBER_DOMAIN}.trv_name_valve_position" diff --git a/tests/components/shelly/test_sensor.py b/tests/components/shelly/test_sensor.py index ef7771e53ba..d0fec65c7de 100644 --- a/tests/components/shelly/test_sensor.py +++ b/tests/components/shelly/test_sensor.py @@ -3,7 +3,7 @@ from copy import deepcopy from unittest.mock import Mock -from aioshelly.const import MODEL_BLU_GATEWAY_GEN3 +from aioshelly.const import MODEL_BLU_GATEWAY_G3 from freezegun.api import FrozenDateTimeFactory import pytest from syrupy import SnapshotAssertion @@ -1416,7 +1416,7 @@ async def test_blu_trv_sensor_entity( snapshot: SnapshotAssertion, ) -> None: """Test BLU TRV sensor entity.""" - await init_integration(hass, 3, model=MODEL_BLU_GATEWAY_GEN3) + await init_integration(hass, 3, model=MODEL_BLU_GATEWAY_G3) for entity in ("battery", "signal_strength", "valve_position"): entity_id = f"{SENSOR_DOMAIN}.trv_name_{entity}" From 622be70fee42215fb67b7ac33998861808c81f1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Tue, 25 Feb 2025 22:02:49 +0000 Subject: [PATCH 1817/3148] Remove timeout from vscode test launch configuration (#139288) --- .vscode/launch.json | 1 - 1 file changed, 1 deletion(-) diff --git a/.vscode/launch.json b/.vscode/launch.json index 15cdb9fb625..459a9e6acc5 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -38,7 +38,6 @@ "module": "pytest", "justMyCode": false, "args": [ - "--timeout=10", "--picked" ], }, From 8644fb188761fbf50d791fb8c3707b16335893c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Tue, 25 Feb 2025 23:05:52 +0100 Subject: [PATCH 1818/3148] Add missing Home Connect context at event listener registration for appliance options (#139292) * Add missing context at event listener registration for appliance options * Add tests --- .../components/home_connect/common.py | 35 ++--- tests/components/home_connect/test_entity.py | 121 ++++++++++++++++++ 2 files changed, 141 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/home_connect/common.py b/homeassistant/components/home_connect/common.py index a9f48eea5ba..f52b59bc213 100644 --- a/homeassistant/components/home_connect/common.py +++ b/homeassistant/components/home_connect/common.py @@ -72,22 +72,27 @@ def _handle_paired_or_connected_appliance( for entity in get_option_entities_for_appliance(entry, appliance) if entity.unique_id not in known_entity_unique_ids ) - changed_options_listener_remove_callback = ( - entry.runtime_data.async_add_listener( - partial( - _create_option_entities, - entry, - appliance, - known_entity_unique_ids, - get_option_entities_for_appliance, - async_add_entities, - ), + for event_key in ( + EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM, + EventKey.BSH_COMMON_ROOT_SELECTED_PROGRAM, + ): + changed_options_listener_remove_callback = ( + entry.runtime_data.async_add_listener( + partial( + _create_option_entities, + entry, + appliance, + known_entity_unique_ids, + get_option_entities_for_appliance, + async_add_entities, + ), + (appliance.info.ha_id, event_key), + ) + ) + entry.async_on_unload(changed_options_listener_remove_callback) + changed_options_listener_remove_callbacks[appliance.info.ha_id].append( + changed_options_listener_remove_callback ) - ) - entry.async_on_unload(changed_options_listener_remove_callback) - changed_options_listener_remove_callbacks[appliance.info.ha_id].append( - changed_options_listener_remove_callback - ) known_entity_unique_ids.update( { cast(str, entity.unique_id): appliance.info.ha_id diff --git a/tests/components/home_connect/test_entity.py b/tests/components/home_connect/test_entity.py index 272fc21ba62..f173cda0b0c 100644 --- a/tests/components/home_connect/test_entity.py +++ b/tests/components/home_connect/test_entity.py @@ -5,6 +5,7 @@ from unittest.mock import AsyncMock, MagicMock from aiohomeconnect.model import ( ArrayOfEvents, + ArrayOfHomeAppliances, ArrayOfPrograms, Event, EventKey, @@ -233,6 +234,126 @@ async def test_program_options_retrieval( assert hass.states.is_state(entity_id, STATE_UNKNOWN) +@pytest.mark.parametrize( + "event_key", + [ + EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM, + EventKey.BSH_COMMON_ROOT_SELECTED_PROGRAM, + ], +) +@pytest.mark.parametrize( + ("appliance_ha_id", "option_key", "option_entity_id"), + [ + ( + "Dishwasher", + OptionKey.DISHCARE_DISHWASHER_HALF_LOAD, + "switch.dishwasher_half_load", + ) + ], + indirect=["appliance_ha_id"], +) +async def test_program_options_retrieval_after_appliance_connection( + event_key: EventKey, + appliance_ha_id: str, + option_key: OptionKey, + option_entity_id: str, + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, +) -> None: + """Test that the options are correctly retrieved at the start and updated on program updates.""" + array_of_home_appliances = client.get_home_appliances.return_value + + async def get_home_appliances_with_options_mock() -> ArrayOfHomeAppliances: + return ArrayOfHomeAppliances( + [ + appliance + for appliance in array_of_home_appliances.homeappliances + if appliance.ha_id != appliance_ha_id + ] + ) + + client.get_home_appliances = AsyncMock( + side_effect=get_home_appliances_with_options_mock + ) + client.get_available_program = AsyncMock( + return_value=ProgramDefinition( + ProgramKey.UNKNOWN, + options=[], + ) + ) + + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + assert not hass.states.get(option_entity_id) + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.CONNECTED, + data=ArrayOfEvents( + [ + Event( + key=EventKey.BSH_COMMON_APPLIANCE_CONNECTED, + raw_key=EventKey.BSH_COMMON_APPLIANCE_CONNECTED.value, + timestamp=0, + level="", + handling="", + value="", + ) + ] + ), + ) + ] + ) + await hass.async_block_till_done() + + assert not hass.states.get(option_entity_id) + + client.get_available_program = AsyncMock( + return_value=ProgramDefinition( + ProgramKey.UNKNOWN, + options=[ + ProgramDefinitionOption( + option_key, + "Boolean", + constraints=ProgramDefinitionConstraints( + default=False, + ), + ), + ], + ) + ) + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.NOTIFY, + data=ArrayOfEvents( + [ + Event( + key=event_key, + raw_key=event_key.value, + timestamp=0, + level="", + handling="", + value=ProgramKey.DISHCARE_DISHWASHER_AUTO_1, + ) + ] + ), + ) + ] + ) + await hass.async_block_till_done() + + assert hass.states.get(option_entity_id) + + @pytest.mark.parametrize( ( "set_active_program_option_side_effect", From 412ceca6f723f2187c583d58cb225f394baa0adf Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Tue, 25 Feb 2025 23:22:02 +0100 Subject: [PATCH 1819/3148] Sort common translation strings (#139300) sort common strings --- homeassistant/strings.json | 238 ++++++++++++++++++------------------- 1 file changed, 119 insertions(+), 119 deletions(-) diff --git a/homeassistant/strings.json b/homeassistant/strings.json index f423c3bf59c..29b7db7a011 100644 --- a/homeassistant/strings.json +++ b/homeassistant/strings.json @@ -1,13 +1,101 @@ { "common": { - "generic": { - "model": "Model", - "ui_managed": "Managed via UI" + "action": { + "close": "Close", + "connect": "Connect", + "disable": "Disable", + "disconnect": "Disconnect", + "enable": "Enable", + "open": "Open", + "pause": "Pause", + "reload": "Reload", + "restart": "Restart", + "start": "Start", + "stop": "Stop", + "toggle": "Toggle", + "turn_off": "Turn off", + "turn_on": "Turn on" + }, + "config_flow": { + "abort": { + "already_configured_account": "Account is already configured", + "already_configured_device": "Device is already configured", + "already_configured_location": "Location is already configured", + "already_configured_service": "Service is already configured", + "already_in_progress": "Configuration flow is already in progress", + "cloud_not_connected": "Not connected to Home Assistant Cloud.", + "no_devices_found": "No devices found on the network", + "oauth2_authorize_url_timeout": "Timeout generating authorize URL.", + "oauth2_error": "Received invalid token data.", + "oauth2_failed": "Error while obtaining access token.", + "oauth2_missing_configuration": "The component is not configured. Please follow the documentation.", + "oauth2_missing_credentials": "The integration requires application credentials.", + "oauth2_no_url_available": "No URL available. For information about this error, [check the help section]({docs_url})", + "oauth2_timeout": "Timeout resolving OAuth token.", + "oauth2_unauthorized": "OAuth authorization error while obtaining access token.", + "oauth2_user_rejected_authorize": "Account linking rejected: {error}", + "reauth_successful": "Re-authentication was successful", + "reconfigure_successful": "Re-configuration was successful", + "single_instance_allowed": "Already configured. Only a single configuration possible.", + "unknown_authorize_url_generation": "Unknown error generating an authorize URL.", + "webhook_not_internet_accessible": "Your Home Assistant instance needs to be accessible from the internet to receive webhook messages." + }, + "create_entry": { + "authenticated": "Successfully authenticated" + }, + "data": { + "access_token": "Access token", + "api_key": "API key", + "api_token": "API token", + "device": "Device", + "elevation": "Elevation", + "email": "Email", + "host": "Host", + "ip": "IP address", + "language": "Language", + "latitude": "Latitude", + "llm_hass_api": "Control Home Assistant", + "location": "Location", + "longitude": "Longitude", + "mode": "Mode", + "name": "Name", + "password": "Password", + "path": "Path", + "pin": "PIN code", + "port": "Port", + "ssl": "Uses an SSL certificate", + "url": "URL", + "usb_path": "USB device path", + "username": "Username", + "verify_ssl": "Verify SSL certificate" + }, + "description": { + "confirm_setup": "Do you want to start setup?" + }, + "error": { + "cannot_connect": "Failed to connect", + "invalid_access_token": "Invalid access token", + "invalid_api_key": "Invalid API key", + "invalid_auth": "Invalid authentication", + "invalid_host": "Invalid hostname or IP address", + "timeout_connect": "Timeout establishing connection", + "unknown": "Unexpected error" + }, + "title": { + "oauth2_pick_implementation": "Pick authentication method", + "reauth": "Authentication expired for {name}", + "via_hassio_addon": "{name} via Home Assistant add-on" + } }, "device_automation": { + "action_type": { + "toggle": "Toggle {entity_name}", + "turn_off": "Turn off {entity_name}", + "turn_on": "Turn on {entity_name}" + }, "condition_type": { - "is_on": "{entity_name} is on", - "is_off": "{entity_name} is off" + "is_off": "{entity_name} is off", + "is_on": "{entity_name} is on" }, "extra_fields": { "above": "Above", @@ -19,30 +107,35 @@ }, "trigger_type": { "changed_states": "{entity_name} turned on or off", - "turned_on": "{entity_name} turned on", - "turned_off": "{entity_name} turned off" - }, - "action_type": { - "toggle": "Toggle {entity_name}", - "turn_on": "Turn on {entity_name}", - "turn_off": "Turn off {entity_name}" + "turned_off": "{entity_name} turned off", + "turned_on": "{entity_name} turned on" } }, - "action": { - "connect": "Connect", - "disconnect": "Disconnect", - "enable": "Enable", - "disable": "Disable", + "generic": { + "model": "Model", + "ui_managed": "Managed via UI" + }, + "state": { + "active": "Active", + "charging": "Charging", + "closed": "Closed", + "connected": "Connected", + "disabled": "Disabled", + "discharging": "Discharging", + "disconnected": "Disconnected", + "enabled": "Enabled", + "home": "Home", + "idle": "Idle", + "locked": "Locked", + "no": "No", + "not_home": "Away", + "off": "Off", + "on": "On", "open": "Open", - "close": "Close", - "reload": "Reload", - "restart": "Restart", - "start": "Start", - "stop": "Stop", - "pause": "Pause", - "turn_on": "Turn on", - "turn_off": "Turn off", - "toggle": "Toggle" + "paused": "Paused", + "standby": "Standby", + "unlocked": "Unlocked", + "yes": "Yes" }, "time": { "monday": "Monday", @@ -52,99 +145,6 @@ "friday": "Friday", "saturday": "Saturday", "sunday": "Sunday" - }, - "state": { - "off": "Off", - "on": "On", - "yes": "Yes", - "no": "No", - "open": "Open", - "closed": "Closed", - "enabled": "Enabled", - "disabled": "Disabled", - "connected": "Connected", - "disconnected": "Disconnected", - "locked": "Locked", - "unlocked": "Unlocked", - "active": "Active", - "idle": "Idle", - "standby": "Standby", - "paused": "Paused", - "home": "Home", - "not_home": "Away", - "charging": "Charging", - "discharging": "Discharging" - }, - "config_flow": { - "title": { - "oauth2_pick_implementation": "Pick authentication method", - "reauth": "Authentication expired for {name}", - "via_hassio_addon": "{name} via Home Assistant add-on" - }, - "description": { - "confirm_setup": "Do you want to start setup?" - }, - "data": { - "device": "Device", - "name": "Name", - "email": "Email", - "username": "Username", - "password": "Password", - "host": "Host", - "ip": "IP address", - "port": "Port", - "url": "URL", - "usb_path": "USB device path", - "access_token": "Access token", - "api_key": "API key", - "api_token": "API token", - "llm_hass_api": "Control Home Assistant", - "ssl": "Uses an SSL certificate", - "verify_ssl": "Verify SSL certificate", - "elevation": "Elevation", - "longitude": "Longitude", - "latitude": "Latitude", - "location": "Location", - "pin": "PIN code", - "mode": "Mode", - "path": "Path", - "language": "Language" - }, - "create_entry": { - "authenticated": "Successfully authenticated" - }, - "error": { - "cannot_connect": "Failed to connect", - "invalid_access_token": "Invalid access token", - "invalid_api_key": "Invalid API key", - "invalid_auth": "Invalid authentication", - "invalid_host": "Invalid hostname or IP address", - "unknown": "Unexpected error", - "timeout_connect": "Timeout establishing connection" - }, - "abort": { - "single_instance_allowed": "Already configured. Only a single configuration possible.", - "already_configured_account": "Account is already configured", - "already_configured_device": "Device is already configured", - "already_configured_location": "Location is already configured", - "already_configured_service": "Service is already configured", - "already_in_progress": "Configuration flow is already in progress", - "no_devices_found": "No devices found on the network", - "webhook_not_internet_accessible": "Your Home Assistant instance needs to be accessible from the internet to receive webhook messages.", - "oauth2_error": "Received invalid token data.", - "oauth2_timeout": "Timeout resolving OAuth token.", - "oauth2_missing_configuration": "The component is not configured. Please follow the documentation.", - "oauth2_missing_credentials": "The integration requires application credentials.", - "oauth2_authorize_url_timeout": "Timeout generating authorize URL.", - "oauth2_no_url_available": "No URL available. For information about this error, [check the help section]({docs_url})", - "oauth2_user_rejected_authorize": "Account linking rejected: {error}", - "oauth2_unauthorized": "OAuth authorization error while obtaining access token.", - "oauth2_failed": "Error while obtaining access token.", - "reauth_successful": "Re-authentication was successful", - "reconfigure_successful": "Re-configuration was successful", - "unknown_authorize_url_generation": "Unknown error generating an authorize URL.", - "cloud_not_connected": "Not connected to Home Assistant Cloud." - } } } } From bd306abace66a43cd2c42c3be7cdfecc7a6962cf Mon Sep 17 00:00:00 2001 From: peteS-UK <64092177+peteS-UK@users.noreply.github.com> Date: Tue, 25 Feb 2025 23:55:53 +0000 Subject: [PATCH 1820/3148] Add album artist media browser category to Squeezebox (#139210) --- homeassistant/components/squeezebox/browse_media.py | 4 ++++ tests/components/squeezebox/conftest.py | 1 + tests/components/squeezebox/test_media_browser.py | 1 + 3 files changed, 6 insertions(+) diff --git a/homeassistant/components/squeezebox/browse_media.py b/homeassistant/components/squeezebox/browse_media.py index e12d2aa8844..6bc1d2380cf 100644 --- a/homeassistant/components/squeezebox/browse_media.py +++ b/homeassistant/components/squeezebox/browse_media.py @@ -29,6 +29,7 @@ LIBRARY = [ "Playlists", "Genres", "New Music", + "Album Artists", "Apps", "Radios", ] @@ -41,6 +42,7 @@ MEDIA_TYPE_TO_SQUEEZEBOX: dict[str | MediaType, str] = { "Playlists": "playlists", "Genres": "genres", "New Music": "new music", + "Album Artists": "album artists", MediaType.ALBUM: "album", MediaType.ARTIST: "artist", MediaType.TRACK: "title", @@ -71,6 +73,7 @@ CONTENT_TYPE_MEDIA_CLASS: dict[str | MediaType, dict[str, MediaClass | None]] = "Playlists": {"item": MediaClass.DIRECTORY, "children": MediaClass.PLAYLIST}, "Genres": {"item": MediaClass.DIRECTORY, "children": MediaClass.GENRE}, "New Music": {"item": MediaClass.DIRECTORY, "children": MediaClass.ALBUM}, + "Album Artists": {"item": MediaClass.DIRECTORY, "children": MediaClass.ARTIST}, MediaType.ALBUM: {"item": MediaClass.ALBUM, "children": MediaClass.TRACK}, MediaType.ARTIST: {"item": MediaClass.ARTIST, "children": MediaClass.ALBUM}, MediaType.TRACK: {"item": MediaClass.TRACK, "children": None}, @@ -98,6 +101,7 @@ CONTENT_TYPE_TO_CHILD_TYPE: dict[ "Radios": MediaClass.APP, "App": None, # can only be determined after inspecting the item "New Music": MediaType.ALBUM, + "Album Artists": MediaType.ARTIST, MediaType.APPS: MediaType.APP, MediaType.APP: MediaType.TRACK, } diff --git a/tests/components/squeezebox/conftest.py b/tests/components/squeezebox/conftest.py index cb77495e818..9ca750808c5 100644 --- a/tests/components/squeezebox/conftest.py +++ b/tests/components/squeezebox/conftest.py @@ -132,6 +132,7 @@ async def mock_async_browse( child_types = { "favorites": "favorites", "new music": "album", + "album artists": "artists", "albums": "album", "album": "track", "genres": "genre", diff --git a/tests/components/squeezebox/test_media_browser.py b/tests/components/squeezebox/test_media_browser.py index f00ea1754fc..7b11ef30a87 100644 --- a/tests/components/squeezebox/test_media_browser.py +++ b/tests/components/squeezebox/test_media_browser.py @@ -77,6 +77,7 @@ async def test_async_browse_media_root( ("Playlists", 4), ("Genres", 4), ("New Music", 4), + ("Album Artists", 4), ("Apps", 3), ("Radios", 3), ], From 3ff04d6d049cf8ff65eddfbf87b7e65b5d8aecfb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 26 Feb 2025 02:14:58 +0000 Subject: [PATCH 1821/3148] Bump aioesphomeapi to 29.2.0 (#139309) --- 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 403da9286ab..b59dd544c49 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -16,7 +16,7 @@ "loggers": ["aioesphomeapi", "noiseprotocol", "bleak_esphome"], "mqtt": ["esphome/discover/#"], "requirements": [ - "aioesphomeapi==29.1.1", + "aioesphomeapi==29.2.0", "esphome-dashboard-api==1.2.3", "bleak-esphome==2.7.1" ], diff --git a/requirements_all.txt b/requirements_all.txt index 4949a9fc4a9..3a7fe746411 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==29.1.1 +aioesphomeapi==29.2.0 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 17a6f6a6f56..f01c344b3c7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -231,7 +231,7 @@ aioelectricitymaps==0.4.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==29.1.1 +aioesphomeapi==29.2.0 # homeassistant.components.flo aioflo==2021.11.0 From b1865de58f99ebe77c9e1d35c6cf72c7fd194e57 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 26 Feb 2025 08:13:25 +0100 Subject: [PATCH 1822/3148] Bump actions/download-artifact from 4.1.8 to 4.1.9 (#139317) Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 4.1.8 to 4.1.9. - [Release notes](https://github.com/actions/download-artifact/releases) - [Commits](https://github.com/actions/download-artifact/compare/v4.1.8...v4.1.9) --- updated-dependencies: - dependency-name: actions/download-artifact dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/builder.yml | 4 ++-- .github/workflows/ci.yaml | 6 +++--- .github/workflows/wheels.yml | 14 +++++++------- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index 68581c58d24..7867e635f51 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -175,7 +175,7 @@ jobs: sed -i "s|pykrakenapi|# pykrakenapi|g" requirements_all.txt - name: Download translations - uses: actions/download-artifact@v4.1.8 + uses: actions/download-artifact@v4.1.9 with: name: translations @@ -462,7 +462,7 @@ jobs: python-version: ${{ env.DEFAULT_PYTHON }} - name: Download translations - uses: actions/download-artifact@v4.1.8 + uses: actions/download-artifact@v4.1.9 with: name: translations diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 2aead92791a..8745ab63470 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -942,7 +942,7 @@ jobs: run: | echo "::add-matcher::.github/workflows/matchers/pytest-slow.json" - name: Download pytest_buckets - uses: actions/download-artifact@v4.1.8 + uses: actions/download-artifact@v4.1.9 with: name: pytest_buckets - name: Compile English translations @@ -1271,7 +1271,7 @@ jobs: - name: Check out code from GitHub uses: actions/checkout@v4.2.2 - name: Download all coverage artifacts - uses: actions/download-artifact@v4.1.8 + uses: actions/download-artifact@v4.1.9 with: pattern: coverage-* - name: Upload coverage to Codecov @@ -1410,7 +1410,7 @@ jobs: - name: Check out code from GitHub uses: actions/checkout@v4.2.2 - name: Download all coverage artifacts - uses: actions/download-artifact@v4.1.8 + uses: actions/download-artifact@v4.1.9 with: pattern: coverage-* - name: Upload coverage to Codecov diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 743ae869ab9..7c02c8d97cd 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -138,17 +138,17 @@ jobs: uses: actions/checkout@v4.2.2 - name: Download env_file - uses: actions/download-artifact@v4.1.8 + uses: actions/download-artifact@v4.1.9 with: name: env_file - name: Download build_constraints - uses: actions/download-artifact@v4.1.8 + uses: actions/download-artifact@v4.1.9 with: name: build_constraints - name: Download requirements_diff - uses: actions/download-artifact@v4.1.8 + uses: actions/download-artifact@v4.1.9 with: name: requirements_diff @@ -187,22 +187,22 @@ jobs: uses: actions/checkout@v4.2.2 - name: Download env_file - uses: actions/download-artifact@v4.1.8 + uses: actions/download-artifact@v4.1.9 with: name: env_file - name: Download build_constraints - uses: actions/download-artifact@v4.1.8 + uses: actions/download-artifact@v4.1.9 with: name: build_constraints - name: Download requirements_diff - uses: actions/download-artifact@v4.1.8 + uses: actions/download-artifact@v4.1.9 with: name: requirements_diff - name: Download requirements_all_wheels - uses: actions/download-artifact@v4.1.8 + uses: actions/download-artifact@v4.1.9 with: name: requirements_all_wheels From 4530fe4bf70bc9ce7b842392bb20c00d01119bcf Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 26 Feb 2025 08:48:25 +0100 Subject: [PATCH 1823/3148] Bump home-assistant/builder from 2024.08.2 to 2025.02.0 (#139316) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/builder.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index 7867e635f51..0ad4c510a55 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -197,7 +197,7 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Build base image - uses: home-assistant/builder@2024.08.2 + uses: home-assistant/builder@2025.02.0 with: args: | $BUILD_ARGS \ @@ -263,7 +263,7 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Build base image - uses: home-assistant/builder@2024.08.2 + uses: home-assistant/builder@2025.02.0 with: args: | $BUILD_ARGS \ From eb26a2124bf4e2ca55dcd635ade83ea4cf00e5d9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 26 Feb 2025 07:58:13 +0000 Subject: [PATCH 1824/3148] Adjust remote ESPHome log subscription level on logging change (#139308) --- homeassistant/components/esphome/manager.py | 53 +++++++++++++++++---- tests/components/esphome/conftest.py | 5 +- tests/components/esphome/test_manager.py | 32 +++++++++++++ 3 files changed, 79 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/esphome/manager.py b/homeassistant/components/esphome/manager.py index c73268de747..e32bb7d6ded 100644 --- a/homeassistant/components/esphome/manager.py +++ b/homeassistant/components/esphome/manager.py @@ -35,6 +35,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import ( + CALLBACK_TYPE, Event, EventStateChangedData, HomeAssistant, @@ -95,6 +96,14 @@ LOG_LEVEL_TO_LOGGER = { LogLevel.LOG_LEVEL_VERBOSE: logging.DEBUG, LogLevel.LOG_LEVEL_VERY_VERBOSE: logging.DEBUG, } +LOGGER_TO_LOG_LEVEL = { + logging.NOTSET: LogLevel.LOG_LEVEL_VERY_VERBOSE, + logging.DEBUG: LogLevel.LOG_LEVEL_VERY_VERBOSE, + logging.INFO: LogLevel.LOG_LEVEL_CONFIG, + logging.WARNING: LogLevel.LOG_LEVEL_WARN, + logging.ERROR: LogLevel.LOG_LEVEL_ERROR, + logging.CRITICAL: LogLevel.LOG_LEVEL_ERROR, +} # 7-bit and 8-bit C1 ANSI sequences # https://stackoverflow.com/questions/14693701/how-can-i-remove-the-ansi-escape-sequences-from-a-string-in-python ANSI_ESCAPE_78BIT = re.compile( @@ -161,6 +170,8 @@ class ESPHomeManager: """Class to manage an ESPHome connection.""" __slots__ = ( + "_cancel_subscribe_logs", + "_log_level", "cli", "device_id", "domain_data", @@ -194,6 +205,8 @@ class ESPHomeManager: self.reconnect_logic: ReconnectLogic | None = None self.zeroconf_instance = zeroconf_instance self.entry_data = entry.runtime_data + self._cancel_subscribe_logs: CALLBACK_TYPE | None = None + self._log_level = LogLevel.LOG_LEVEL_NONE async def on_stop(self, event: Event) -> None: """Cleanup the socket client on HA close.""" @@ -368,15 +381,31 @@ class ESPHomeManager: def _async_on_log(self, msg: SubscribeLogsResponse) -> None: """Handle a log message from the API.""" - logger_level = LOG_LEVEL_TO_LOGGER.get(msg.level, logging.DEBUG) - if _LOGGER.isEnabledFor(logger_level): - log: bytes = msg.message - _LOGGER.log( - logger_level, - "%s: %s", - self.entry.title, - ANSI_ESCAPE_78BIT.sub(b"", log).decode("utf-8", "backslashreplace"), - ) + log: bytes = msg.message + _LOGGER.log( + LOG_LEVEL_TO_LOGGER.get(msg.level, logging.DEBUG), + "%s: %s", + self.entry.title, + ANSI_ESCAPE_78BIT.sub(b"", log).decode("utf-8", "backslashreplace"), + ) + + @callback + def _async_get_equivalent_log_level(self) -> LogLevel: + """Get the equivalent ESPHome log level for the current logger.""" + return LOGGER_TO_LOG_LEVEL.get( + _LOGGER.getEffectiveLevel(), LogLevel.LOG_LEVEL_VERY_VERBOSE + ) + + @callback + def _async_subscribe_logs(self, log_level: LogLevel) -> None: + """Subscribe to logs.""" + if self._cancel_subscribe_logs is not None: + self._cancel_subscribe_logs() + self._cancel_subscribe_logs = None + self._log_level = log_level + self._cancel_subscribe_logs = self.cli.subscribe_logs( + self._async_on_log, self._log_level + ) async def _on_connnect(self) -> None: """Subscribe to states and list entities on successful API login.""" @@ -390,7 +419,7 @@ class ESPHomeManager: stored_device_name = entry.data.get(CONF_DEVICE_NAME) unique_id_is_mac_address = unique_id and ":" in unique_id if entry.options.get(CONF_SUBSCRIBE_LOGS): - cli.subscribe_logs(self._async_on_log, LogLevel.LOG_LEVEL_VERY_VERBOSE) + self._async_subscribe_logs(self._async_get_equivalent_log_level()) results = await asyncio.gather( create_eager_task(cli.device_info()), create_eager_task(cli.list_entities_services()), @@ -542,6 +571,10 @@ class ESPHomeManager: def _async_handle_logging_changed(self, _event: Event) -> None: """Handle when the logging level changes.""" self.cli.set_debug(_LOGGER.isEnabledFor(logging.DEBUG)) + if self.entry.options.get(CONF_SUBSCRIBE_LOGS) and self._log_level != ( + new_log_level := self._async_get_equivalent_log_level() + ): + self._async_subscribe_logs(new_log_level) async def async_start(self) -> None: """Start the esphome connection manager.""" diff --git a/tests/components/esphome/conftest.py b/tests/components/esphome/conftest.py index 07f6c6ea697..dc6195bfe1f 100644 --- a/tests/components/esphome/conftest.py +++ b/tests/components/esphome/conftest.py @@ -230,6 +230,7 @@ class MockESPHomeDevice: ) self.on_log_message: Callable[[SubscribeLogsResponse], None] self.device_info = device_info + self.current_log_level = LogLevel.LOG_LEVEL_NONE def set_state_callback(self, state_callback: Callable[[EntityState], None]) -> None: """Set the state callback.""" @@ -432,9 +433,11 @@ async def _mock_generic_device_entry( def _subscribe_logs( on_log_message: Callable[[SubscribeLogsResponse], None], log_level: LogLevel - ) -> None: + ) -> Callable[[], None]: """Subscribe to log messages.""" mock_device.set_on_log_message(on_log_message) + mock_device.current_log_level = log_level + return lambda: None def _subscribe_voice_assistant( *, diff --git a/tests/components/esphome/test_manager.py b/tests/components/esphome/test_manager.py index cf9d4a6f217..b805b065d5a 100644 --- a/tests/components/esphome/test_manager.py +++ b/tests/components/esphome/test_manager.py @@ -57,6 +57,7 @@ async def test_esphome_device_subscribe_logs( caplog: pytest.LogCaptureFixture, ) -> None: """Test configuring a device to subscribe to logs.""" + assert await async_setup_component(hass, "logger", {"logger": {}}) entry = MockConfigEntry( domain=DOMAIN, data={ @@ -76,6 +77,15 @@ async def test_esphome_device_subscribe_logs( states=[], ) await hass.async_block_till_done() + + await hass.services.async_call( + "logger", + "set_level", + {"homeassistant.components.esphome": "DEBUG"}, + blocking=True, + ) + assert device.current_log_level == LogLevel.LOG_LEVEL_VERY_VERBOSE + caplog.set_level(logging.DEBUG) device.mock_on_log_message( Mock(level=LogLevel.LOG_LEVEL_INFO, message=b"test_log_message") @@ -103,6 +113,28 @@ async def test_esphome_device_subscribe_logs( await hass.async_block_till_done() assert "test_debug_log_message" in caplog.text + await hass.services.async_call( + "logger", + "set_level", + {"homeassistant.components.esphome": "WARNING"}, + blocking=True, + ) + assert device.current_log_level == LogLevel.LOG_LEVEL_WARN + await hass.services.async_call( + "logger", + "set_level", + {"homeassistant.components.esphome": "ERROR"}, + blocking=True, + ) + assert device.current_log_level == LogLevel.LOG_LEVEL_ERROR + await hass.services.async_call( + "logger", + "set_level", + {"homeassistant.components.esphome": "INFO"}, + blocking=True, + ) + assert device.current_log_level == LogLevel.LOG_LEVEL_CONFIG + async def test_esphome_device_service_calls_not_allowed( hass: HomeAssistant, From cab6ec0363824ce78932a7b711ed1d3513d7946a Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 26 Feb 2025 09:02:17 +0100 Subject: [PATCH 1825/3148] Fix homeassistant/expose_entity/list (#138872) Co-authored-by: Paulus Schoutsen --- .../homeassistant/exposed_entities.py | 11 +++--- .../homeassistant/test_exposed_entities.py | 34 ++++++++++++++----- 2 files changed, 33 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/homeassistant/exposed_entities.py b/homeassistant/components/homeassistant/exposed_entities.py index 7bd9f9ab7bc..b7e420dedde 100644 --- a/homeassistant/components/homeassistant/exposed_entities.py +++ b/homeassistant/components/homeassistant/exposed_entities.py @@ -437,18 +437,21 @@ def ws_expose_entity( def ws_list_exposed_entities( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] ) -> None: - """Expose an entity to an assistant.""" + """List entities which are exposed to assistants.""" result: dict[str, Any] = {} exposed_entities = hass.data[DATA_EXPOSED_ENTITIES] entity_registry = er.async_get(hass) for entity_id in chain(exposed_entities.entities, entity_registry.entities): - result[entity_id] = {} + exposed_to = {} entity_settings = async_get_entity_settings(hass, entity_id) for assistant, settings in entity_settings.items(): - if "should_expose" not in settings: + if "should_expose" not in settings or not settings["should_expose"]: continue - result[entity_id][assistant] = settings["should_expose"] + exposed_to[assistant] = True + if not exposed_to: + continue + result[entity_id] = exposed_to connection.send_result(msg["id"], {"exposed_entities": result}) diff --git a/tests/components/homeassistant/test_exposed_entities.py b/tests/components/homeassistant/test_exposed_entities.py index 1f1955c2f82..ec87672e75c 100644 --- a/tests/components/homeassistant/test_exposed_entities.py +++ b/tests/components/homeassistant/test_exposed_entities.py @@ -497,28 +497,48 @@ async def test_list_exposed_entities( entry1 = entity_registry.async_get_or_create("test", "test", "unique1") entry2 = entity_registry.async_get_or_create("test", "test", "unique2") + entity_registry.async_get_or_create("test", "test", "unique3") # Set options for registered entities await ws_client.send_json_auto_id( { "type": "homeassistant/expose_entity", "assistants": ["cloud.alexa", "cloud.google_assistant"], - "entity_ids": [entry1.entity_id, entry2.entity_id], + "entity_ids": [entry1.entity_id], "should_expose": True, } ) response = await ws_client.receive_json() assert response["success"] + await ws_client.send_json_auto_id( + { + "type": "homeassistant/expose_entity", + "assistants": ["cloud.alexa", "cloud.google_assistant"], + "entity_ids": [entry2.entity_id], + "should_expose": False, + } + ) + response = await ws_client.receive_json() + assert response["success"] + # Set options for entities not in the entity registry await ws_client.send_json_auto_id( { "type": "homeassistant/expose_entity", "assistants": ["cloud.alexa", "cloud.google_assistant"], - "entity_ids": [ - "test.test", - "test.test2", - ], + "entity_ids": ["test.test"], + "should_expose": True, + } + ) + response = await ws_client.receive_json() + assert response["success"] + + await ws_client.send_json_auto_id( + { + "type": "homeassistant/expose_entity", + "assistants": ["cloud.alexa", "cloud.google_assistant"], + "entity_ids": ["test.test2"], "should_expose": False, } ) @@ -531,10 +551,8 @@ async def test_list_exposed_entities( assert response["success"] assert response["result"] == { "exposed_entities": { - "test.test": {"cloud.alexa": False, "cloud.google_assistant": False}, - "test.test2": {"cloud.alexa": False, "cloud.google_assistant": False}, + "test.test": {"cloud.alexa": True, "cloud.google_assistant": True}, "test.test_unique1": {"cloud.alexa": True, "cloud.google_assistant": True}, - "test.test_unique2": {"cloud.alexa": True, "cloud.google_assistant": True}, }, } From d15f9edc5709428f79b59daa17a8df9df7d57ee9 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Wed, 26 Feb 2025 11:51:35 +0100 Subject: [PATCH 1826/3148] Bump `accuweather` to version `4.1.0` (#139320) --- homeassistant/components/accuweather/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/accuweather/manifest.json b/homeassistant/components/accuweather/manifest.json index 75f4a265b5f..5a019ef968e 100644 --- a/homeassistant/components/accuweather/manifest.json +++ b/homeassistant/components/accuweather/manifest.json @@ -7,6 +7,6 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["accuweather"], - "requirements": ["accuweather==4.0.0"], + "requirements": ["accuweather==4.1.0"], "single_config_entry": true } diff --git a/requirements_all.txt b/requirements_all.txt index 3a7fe746411..9569e134bc2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -131,7 +131,7 @@ TwitterAPI==2.7.12 WSDiscovery==2.1.2 # homeassistant.components.accuweather -accuweather==4.0.0 +accuweather==4.1.0 # homeassistant.components.adax adax==0.4.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f01c344b3c7..ab22b808f92 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -119,7 +119,7 @@ Tami4EdgeAPI==3.0 WSDiscovery==2.1.2 # homeassistant.components.accuweather -accuweather==4.0.0 +accuweather==4.1.0 # homeassistant.components.adax adax==0.4.0 From 861ba0ee5e61004c900b1a0bc3bc759e216cfd37 Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Wed, 26 Feb 2025 11:52:57 +0100 Subject: [PATCH 1827/3148] Bump ZHA to 0.0.50 (#139318) --- homeassistant/components/zha/manifest.json | 2 +- homeassistant/components/zha/strings.json | 129 +++++++++++++++++++++ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 132 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 54de60b8669..25e4de77a32 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -21,7 +21,7 @@ "zha", "universal_silabs_flasher" ], - "requirements": ["zha==0.0.49"], + "requirements": ["zha==0.0.50"], "usb": [ { "vid": "10C4", diff --git a/homeassistant/components/zha/strings.json b/homeassistant/components/zha/strings.json index 2007adca0da..38f55fb550d 100644 --- a/homeassistant/components/zha/strings.json +++ b/homeassistant/components/zha/strings.json @@ -1044,6 +1044,63 @@ }, "valve_duration": { "name": "Irrigation duration" + }, + "down_movement": { + "name": "Down movement" + }, + "sustain_time": { + "name": "Sustain time" + }, + "up_movement": { + "name": "Up movement" + }, + "large_motion_detection_sensitivity": { + "name": "Motion detection sensitivity" + }, + "large_motion_detection_distance": { + "name": "Motion detection distance" + }, + "medium_motion_detection_distance": { + "name": "Medium motion detection distance" + }, + "medium_motion_detection_sensitivity": { + "name": "Medium motion detection sensitivity" + }, + "small_motion_detection_distance": { + "name": "Small motion detection distance" + }, + "small_motion_detection_sensitivity": { + "name": "Small motion detection sensitivity" + }, + "static_detection_sensitivity": { + "name": "Static detection sensitivity" + }, + "static_detection_distance": { + "name": "Static detection distance" + }, + "motion_detection_sensitivity": { + "name": "Motion detection sensitivity" + }, + "holiday_temperature": { + "name": "Holiday temperature" + }, + "boost_time": { + "name": "Boost time" + }, + "antifrost_temperature": { + "name": "Antifrost temperature" + }, + "eco_temperature": { + "name": "Eco temperature" + }, + "comfort_temperature": { + "name": "Comfort temperature" + }, + "valve_state_auto_shutdown": { + "name": "Valve state auto shutdown" + }, + "shutdown_timer": { + "name": "Shutdown timer" } }, "select": { @@ -1235,6 +1292,33 @@ }, "eco_mode": { "name": "Eco mode" + }, + "mode": { + "name": "Mode" + }, + "reverse": { + "name": "Reverse" + }, + "motion_state": { + "name": "Motion state" + }, + "motion_detection_mode": { + "name": "Motion detection mode" + }, + "screen_orientation": { + "name": "Screen orientation" + }, + "motor_thrust": { + "name": "Motor thrust" + }, + "display_brightness": { + "name": "Display brightness" + }, + "display_orientation": { + "name": "Display orientation" + }, + "hysteresis_mode": { + "name": "Hysteresis mode" } }, "sensor": { @@ -1561,6 +1645,27 @@ }, "error_status": { "name": "Error status" + }, + "brightness_level": { + "name": "Brightness level" + }, + "average_light_intensity_20mins": { + "name": "Average light intensity last 20 min" + }, + "todays_max_light_intensity": { + "name": "Today's max light intensity" + }, + "fault_code": { + "name": "Fault code" + }, + "water_flow": { + "name": "Water flow" + }, + "remaining_watering_time": { + "name": "Remaining watering time" + }, + "last_watering_duration": { + "name": "Last watering duration" } }, "switch": { @@ -1746,6 +1851,30 @@ }, "total_flow_reset_switch": { "name": "Total flow reset switch" + }, + "touch_control": { + "name": "Touch control" + }, + "sound_enabled": { + "name": "Sound enabled" + }, + "invert_relay": { + "name": "Invert relay" + }, + "boost_heating": { + "name": "Boost heating" + }, + "holiday_mode": { + "name": "Holiday mode" + }, + "heating_stop": { + "name": "Heating stop" + }, + "schedule_mode": { + "name": "Schedule mode" + }, + "auto_clean": { + "name": "Auto clean" } } } diff --git a/requirements_all.txt b/requirements_all.txt index 9569e134bc2..c4570f25195 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3152,7 +3152,7 @@ zeroconf==0.145.1 zeversolar==0.3.2 # homeassistant.components.zha -zha==0.0.49 +zha==0.0.50 # homeassistant.components.zhong_hong zhong-hong-hvac==1.0.13 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ab22b808f92..6b30a0c0867 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2541,7 +2541,7 @@ zeroconf==0.145.1 zeversolar==0.3.2 # homeassistant.components.zha -zha==0.0.49 +zha==0.0.50 # homeassistant.components.zwave_js zwave-js-server-python==0.60.1 From 5895245a31a8d60a6fcb2ca93225609ce288184a Mon Sep 17 00:00:00 2001 From: Christophe Gagnier Date: Wed, 26 Feb 2025 05:57:54 -0500 Subject: [PATCH 1828/3148] Bump pytechnove to 2.0.0 (#139314) --- homeassistant/components/technove/manifest.json | 2 +- homeassistant/components/technove/strings.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/technove/snapshots/test_diagnostics.ambr | 2 +- tests/components/technove/snapshots/test_sensor.ambr | 4 ++-- 6 files changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/technove/manifest.json b/homeassistant/components/technove/manifest.json index 722aa4004e1..746c2280aaa 100644 --- a/homeassistant/components/technove/manifest.json +++ b/homeassistant/components/technove/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/technove", "integration_type": "device", "iot_class": "local_polling", - "requirements": ["python-technove==1.3.1"], + "requirements": ["python-technove==2.0.0"], "zeroconf": ["_technove-stations._tcp.local."] } diff --git a/homeassistant/components/technove/strings.json b/homeassistant/components/technove/strings.json index 9976f0b3c59..05260845a03 100644 --- a/homeassistant/components/technove/strings.json +++ b/homeassistant/components/technove/strings.json @@ -70,7 +70,7 @@ "plugged_waiting": "Plugged, waiting", "plugged_charging": "Plugged, charging", "out_of_activation_period": "Out of activation period", - "high_charge_period": "High charge period" + "high_tariff_period": "High tariff period" } } }, diff --git a/requirements_all.txt b/requirements_all.txt index c4570f25195..766addab2b6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2479,7 +2479,7 @@ python-songpal==0.16.2 python-tado==0.18.6 # homeassistant.components.technove -python-technove==1.3.1 +python-technove==2.0.0 # homeassistant.components.telegram_bot python-telegram-bot[socks]==21.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6b30a0c0867..ca35a30f50b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2012,7 +2012,7 @@ python-songpal==0.16.2 python-tado==0.18.6 # homeassistant.components.technove -python-technove==1.3.1 +python-technove==2.0.0 # homeassistant.components.telegram_bot python-telegram-bot[socks]==21.5 diff --git a/tests/components/technove/snapshots/test_diagnostics.ambr b/tests/components/technove/snapshots/test_diagnostics.ambr index 175e8f2022a..e16c51a2e98 100644 --- a/tests/components/technove/snapshots/test_diagnostics.ambr +++ b/tests/components/technove/snapshots/test_diagnostics.ambr @@ -6,7 +6,7 @@ 'current': 23.75, 'energy_session': 12.34, 'energy_total': 1234, - 'high_charge_period_active': False, + 'high_tariff_period_active': False, 'in_sharing_mode': False, 'is_battery_protected': False, 'is_session_active': True, diff --git a/tests/components/technove/snapshots/test_sensor.ambr b/tests/components/technove/snapshots/test_sensor.ambr index dec671b0f34..aaec5667e55 100644 --- a/tests/components/technove/snapshots/test_sensor.ambr +++ b/tests/components/technove/snapshots/test_sensor.ambr @@ -322,7 +322,7 @@ 'plugged_waiting', 'plugged_charging', 'out_of_activation_period', - 'high_charge_period', + 'high_tariff_period', ]), }), 'config_entry_id': , @@ -363,7 +363,7 @@ 'plugged_waiting', 'plugged_charging', 'out_of_activation_period', - 'high_charge_period', + 'high_tariff_period', ]), }), 'context': , From fe396cdf4b0f6e29aa38d2b235485999eb50195d Mon Sep 17 00:00:00 2001 From: Matt Zimmerman Date: Wed, 26 Feb 2025 02:59:13 -0800 Subject: [PATCH 1829/3148] Update python-smarttub dependency to 0.0.39 (#139313) --- homeassistant/components/smarttub/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/smarttub/manifest.json b/homeassistant/components/smarttub/manifest.json index d5102f14437..b8d81db0ea5 100644 --- a/homeassistant/components/smarttub/manifest.json +++ b/homeassistant/components/smarttub/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/smarttub", "iot_class": "cloud_polling", "loggers": ["smarttub"], - "requirements": ["python-smarttub==0.0.38"] + "requirements": ["python-smarttub==0.0.39"] } diff --git a/requirements_all.txt b/requirements_all.txt index 766addab2b6..11d223a21f9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2467,7 +2467,7 @@ python-ripple-api==0.0.3 python-roborock==2.11.1 # homeassistant.components.smarttub -python-smarttub==0.0.38 +python-smarttub==0.0.39 # homeassistant.components.snoo python-snoo==0.6.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ca35a30f50b..3d25b71b2a8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2000,7 +2000,7 @@ python-rabbitair==0.0.8 python-roborock==2.11.1 # homeassistant.components.smarttub -python-smarttub==0.0.38 +python-smarttub==0.0.39 # homeassistant.components.snoo python-snoo==0.6.0 From b82886a3e1b0edc5044d096ea4ff30810f9f8713 Mon Sep 17 00:00:00 2001 From: Denis Shulyaka Date: Wed, 26 Feb 2025 15:25:59 +0300 Subject: [PATCH 1830/3148] Fix anthropic blocking call (#139299) --- homeassistant/components/anthropic/__init__.py | 6 +++++- homeassistant/components/anthropic/config_flow.py | 5 ++++- tests/components/anthropic/test_conversation.py | 1 + 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/anthropic/__init__.py b/homeassistant/components/anthropic/__init__.py index aa6cf509fa1..84c9054b476 100644 --- a/homeassistant/components/anthropic/__init__.py +++ b/homeassistant/components/anthropic/__init__.py @@ -2,6 +2,8 @@ from __future__ import annotations +from functools import partial + import anthropic from homeassistant.config_entries import ConfigEntry @@ -20,7 +22,9 @@ type AnthropicConfigEntry = ConfigEntry[anthropic.AsyncClient] async def async_setup_entry(hass: HomeAssistant, entry: AnthropicConfigEntry) -> bool: """Set up Anthropic from a config entry.""" - client = anthropic.AsyncAnthropic(api_key=entry.data[CONF_API_KEY]) + client = await hass.async_add_executor_job( + partial(anthropic.AsyncAnthropic, api_key=entry.data[CONF_API_KEY]) + ) try: await client.messages.create( model="claude-3-haiku-20240307", diff --git a/homeassistant/components/anthropic/config_flow.py b/homeassistant/components/anthropic/config_flow.py index fa43a3c4bcc..63a70f31fea 100644 --- a/homeassistant/components/anthropic/config_flow.py +++ b/homeassistant/components/anthropic/config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations +from functools import partial import logging from types import MappingProxyType from typing import Any @@ -59,7 +60,9 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None: Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. """ - client = anthropic.AsyncAnthropic(api_key=data[CONF_API_KEY]) + client = await hass.async_add_executor_job( + partial(anthropic.AsyncAnthropic, api_key=data[CONF_API_KEY]) + ) await client.messages.create( model="claude-3-haiku-20240307", max_tokens=1, diff --git a/tests/components/anthropic/test_conversation.py b/tests/components/anthropic/test_conversation.py index bda9ca32b34..a35df281fb6 100644 --- a/tests/components/anthropic/test_conversation.py +++ b/tests/components/anthropic/test_conversation.py @@ -488,6 +488,7 @@ async def test_unknown_hass_api( CONF_LLM_HASS_API: "non-existing", }, ) + await hass.async_block_till_done() result = await conversation.async_converse( hass, "hello", "1234", Context(), agent_id="conversation.claude" From 4dca4a64b522f0ca8d454ddcfa7fe5329ef028ee Mon Sep 17 00:00:00 2001 From: Ben Bridts Date: Wed, 26 Feb 2025 13:26:12 +0100 Subject: [PATCH 1831/3148] Bump pybotvac to 0.0.26 (#139330) --- homeassistant/components/neato/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/neato/manifest.json b/homeassistant/components/neato/manifest.json index e4b471cb5ac..ef7cda52f19 100644 --- a/homeassistant/components/neato/manifest.json +++ b/homeassistant/components/neato/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/neato", "iot_class": "cloud_polling", "loggers": ["pybotvac"], - "requirements": ["pybotvac==0.0.25"] + "requirements": ["pybotvac==0.0.26"] } diff --git a/requirements_all.txt b/requirements_all.txt index 11d223a21f9..da1df50e3a2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1843,7 +1843,7 @@ pyblackbird==0.6 pyblu==2.0.0 # homeassistant.components.neato -pybotvac==0.0.25 +pybotvac==0.0.26 # homeassistant.components.braviatv pybravia==0.3.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3d25b71b2a8..815f42090a5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1520,7 +1520,7 @@ pyblackbird==0.6 pyblu==2.0.0 # homeassistant.components.neato -pybotvac==0.0.25 +pybotvac==0.0.26 # homeassistant.components.braviatv pybravia==0.3.4 From 0f827fbf2238506f15771fa03985f1e3bbf48e79 Mon Sep 17 00:00:00 2001 From: fwestenberg <47930023+fwestenberg@users.noreply.github.com> Date: Wed, 26 Feb 2025 13:31:07 +0100 Subject: [PATCH 1832/3148] Bump stookwijzer==1.6.0 (#139332) --- homeassistant/components/stookwijzer/__init__.py | 6 ++---- homeassistant/components/stookwijzer/config_flow.py | 6 ++---- homeassistant/components/stookwijzer/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/stookwijzer/conftest.py | 2 +- 6 files changed, 8 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/stookwijzer/__init__.py b/homeassistant/components/stookwijzer/__init__.py index d8b9561bde9..a4a00e4d1b8 100644 --- a/homeassistant/components/stookwijzer/__init__.py +++ b/homeassistant/components/stookwijzer/__init__.py @@ -9,7 +9,6 @@ from stookwijzer import Stookwijzer from homeassistant.const import CONF_LATITUDE, CONF_LOCATION, CONF_LONGITUDE, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er, issue_registry as ir -from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DOMAIN, LOGGER from .coordinator import StookwijzerConfigEntry, StookwijzerCoordinator @@ -43,13 +42,12 @@ async def async_migrate_entry( LOGGER.debug("Migrating from version %s", entry.version) if entry.version == 1: - latitude, longitude = await Stookwijzer.async_transform_coordinates( - async_get_clientsession(hass), + longitude, latitude = await Stookwijzer.async_transform_coordinates( entry.data[CONF_LOCATION][CONF_LATITUDE], entry.data[CONF_LOCATION][CONF_LONGITUDE], ) - if not latitude or not longitude: + if not longitude or not latitude: ir.async_create_issue( hass, DOMAIN, diff --git a/homeassistant/components/stookwijzer/config_flow.py b/homeassistant/components/stookwijzer/config_flow.py index 32b4836763f..52283e4842d 100644 --- a/homeassistant/components/stookwijzer/config_flow.py +++ b/homeassistant/components/stookwijzer/config_flow.py @@ -9,7 +9,6 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_LATITUDE, CONF_LOCATION, CONF_LONGITUDE -from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.selector import LocationSelector from .const import DOMAIN @@ -26,12 +25,11 @@ class StookwijzerFlowHandler(ConfigFlow, domain=DOMAIN): """Handle a flow initialized by the user.""" errors = {} if user_input is not None: - latitude, longitude = await Stookwijzer.async_transform_coordinates( - async_get_clientsession(self.hass), + longitude, latitude = await Stookwijzer.async_transform_coordinates( user_input[CONF_LOCATION][CONF_LATITUDE], user_input[CONF_LOCATION][CONF_LONGITUDE], ) - if latitude and longitude: + if longitude and latitude: return self.async_create_entry( title="Stookwijzer", data={CONF_LATITUDE: latitude, CONF_LONGITUDE: longitude}, diff --git a/homeassistant/components/stookwijzer/manifest.json b/homeassistant/components/stookwijzer/manifest.json index 8243b903e8d..9b4cea567be 100644 --- a/homeassistant/components/stookwijzer/manifest.json +++ b/homeassistant/components/stookwijzer/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/stookwijzer", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["stookwijzer==1.5.4"] + "requirements": ["stookwijzer==1.6.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index da1df50e3a2..7a60530b12c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2811,7 +2811,7 @@ statsd==3.2.1 steamodd==4.21 # homeassistant.components.stookwijzer -stookwijzer==1.5.4 +stookwijzer==1.6.0 # homeassistant.components.streamlabswater streamlabswater==1.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 815f42090a5..af549502560 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2272,7 +2272,7 @@ statsd==3.2.1 steamodd==4.21 # homeassistant.components.stookwijzer -stookwijzer==1.5.4 +stookwijzer==1.6.0 # homeassistant.components.streamlabswater streamlabswater==1.0.1 diff --git a/tests/components/stookwijzer/conftest.py b/tests/components/stookwijzer/conftest.py index 3f7303e97f6..95a60e623a3 100644 --- a/tests/components/stookwijzer/conftest.py +++ b/tests/components/stookwijzer/conftest.py @@ -71,8 +71,8 @@ def mock_stookwijzer() -> Generator[MagicMock]: ), ): stookwijzer_mock.async_transform_coordinates.return_value = ( - 200000.123456789, 450000.123456789, + 200000.123456789, ) client = stookwijzer_mock.return_value From ee01aa73b8290d25bc6f70fe28df92bcb8c3d9d6 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 26 Feb 2025 13:44:09 +0100 Subject: [PATCH 1833/3148] Improve error message when failing to create backups (#139262) * Improve error message when failing to create backups * Check for expected error message in tests --- homeassistant/components/backup/manager.py | 17 ++- tests/components/backup/test_manager.py | 120 ++++++++++++++++++++- 2 files changed, 133 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index bd970d7708a..317de85b823 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -1620,7 +1620,13 @@ class CoreBackupReaderWriter(BackupReaderWriter): """Generate backup contents and return the size.""" if not tar_file_path: tar_file_path = self.temp_backup_dir / f"{backup_data['slug']}.tar" - make_backup_dir(tar_file_path.parent) + try: + make_backup_dir(tar_file_path.parent) + except OSError as err: + raise BackupReaderWriterError( + f"Failed to create dir {tar_file_path.parent}: " + f"{err} ({err.__class__.__name__})" + ) from err excludes = EXCLUDE_FROM_BACKUP if not database_included: @@ -1658,7 +1664,14 @@ class CoreBackupReaderWriter(BackupReaderWriter): file_filter=is_excluded_by_filter, arcname="data", ) - return (tar_file_path, tar_file_path.stat().st_size) + try: + stat_result = tar_file_path.stat() + except OSError as err: + raise BackupReaderWriterError( + f"Error getting size of {tar_file_path}: " + f"{err} ({err.__class__.__name__})" + ) from err + return (tar_file_path, stat_result.st_size) async def async_receive_backup( self, diff --git a/tests/components/backup/test_manager.py b/tests/components/backup/test_manager.py index 3c72929cfe0..6e626e63748 100644 --- a/tests/components/backup/test_manager.py +++ b/tests/components/backup/test_manager.py @@ -1311,7 +1311,7 @@ async def test_initiate_backup_with_task_error( (1, None, 1, None, 1, None, 1, OSError("Boom!")), ], ) -async def test_initiate_backup_file_error( +async def test_initiate_backup_file_error_upload_to_agents( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, generate_backup_id: MagicMock, @@ -1325,7 +1325,7 @@ async def test_initiate_backup_file_error( unlink_call_count: int, unlink_exception: Exception | None, ) -> None: - """Test file error during generate backup.""" + """Test file error during generate backup, while uploading to agents.""" agent_ids = ["test.remote"] await setup_backup_integration(hass, remote_agents=["test.remote"]) @@ -1418,6 +1418,122 @@ async def test_initiate_backup_file_error( assert unlink_mock.call_count == unlink_call_count +@pytest.mark.usefixtures("mock_backup_generation") +@pytest.mark.parametrize( + ( + "mkdir_call_count", + "mkdir_exception", + "atomic_contents_add_call_count", + "atomic_contents_add_exception", + "stat_call_count", + "stat_exception", + "error_message", + ), + [ + (1, OSError("Boom!"), 0, None, 0, None, "Failed to create dir"), + (1, None, 1, OSError("Boom!"), 0, None, "Boom!"), + (1, None, 1, None, 1, OSError("Boom!"), "Error getting size"), + ], +) +async def test_initiate_backup_file_error_create_backup( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + generate_backup_id: MagicMock, + path_glob: MagicMock, + caplog: pytest.LogCaptureFixture, + mkdir_call_count: int, + mkdir_exception: Exception | None, + atomic_contents_add_call_count: int, + atomic_contents_add_exception: Exception | None, + stat_call_count: int, + stat_exception: Exception | None, + error_message: str, +) -> None: + """Test file error during generate backup, while creating backup.""" + agent_ids = ["test.remote"] + + await setup_backup_integration(hass, remote_agents=["test.remote"]) + + ws_client = await hass_ws_client(hass) + + path_glob.return_value = [] + + await ws_client.send_json_auto_id({"type": "backup/info"}) + result = await ws_client.receive_json() + + assert result["success"] is True + assert result["result"] == { + "backups": [], + "agent_errors": {}, + "last_attempted_automatic_backup": None, + "last_completed_automatic_backup": None, + "last_non_idle_event": None, + "next_automatic_backup": None, + "next_automatic_backup_additional": False, + "state": "idle", + } + + await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) + + result = await ws_client.receive_json() + assert result["event"] == {"manager_state": BackupManagerState.IDLE} + + result = await ws_client.receive_json() + assert result["success"] is True + + with ( + patch( + "homeassistant.components.backup.manager.atomic_contents_add", + side_effect=atomic_contents_add_exception, + ) as atomic_contents_add_mock, + patch("pathlib.Path.mkdir", side_effect=mkdir_exception) as mkdir_mock, + patch("pathlib.Path.stat", side_effect=stat_exception) as stat_mock, + ): + await ws_client.send_json_auto_id( + {"type": "backup/generate", "agent_ids": agent_ids} + ) + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.CREATE_BACKUP, + "reason": None, + "stage": None, + "state": CreateBackupState.IN_PROGRESS, + } + result = await ws_client.receive_json() + assert result["success"] is True + + backup_id = result["result"]["backup_job_id"] + assert backup_id == generate_backup_id.return_value + + await hass.async_block_till_done() + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.CREATE_BACKUP, + "reason": None, + "stage": CreateBackupStage.HOME_ASSISTANT, + "state": CreateBackupState.IN_PROGRESS, + } + + result = await ws_client.receive_json() + assert result["event"] == { + "manager_state": BackupManagerState.CREATE_BACKUP, + "reason": "upload_failed", + "stage": None, + "state": CreateBackupState.FAILED, + } + + result = await ws_client.receive_json() + assert result["event"] == {"manager_state": BackupManagerState.IDLE} + + assert atomic_contents_add_mock.call_count == atomic_contents_add_call_count + assert mkdir_mock.call_count == mkdir_call_count + assert stat_mock.call_count == stat_call_count + + assert error_message in caplog.text + + def _mock_local_backup_agent(name: str) -> Mock: local_agent = mock_backup_agent(name) # This makes the local_agent pass isinstance checks for LocalBackupAgent From e591157e37407c117cad6909a8b36d23c6fc6582 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Wed, 26 Feb 2025 13:44:43 +0100 Subject: [PATCH 1834/3148] Add translations and icon for Twinkly select entity (#139336) Co-authored-by: Joost Lekkerkerker --- homeassistant/components/twinkly/icons.json | 5 +++++ homeassistant/components/twinkly/select.py | 2 +- homeassistant/components/twinkly/strings.json | 16 ++++++++++++++++ .../twinkly/snapshots/test_select.ambr | 2 +- 4 files changed, 23 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/twinkly/icons.json b/homeassistant/components/twinkly/icons.json index 82c95aebce6..d57d54aa507 100644 --- a/homeassistant/components/twinkly/icons.json +++ b/homeassistant/components/twinkly/icons.json @@ -4,6 +4,11 @@ "light": { "default": "mdi:string-lights" } + }, + "select": { + "mode": { + "default": "mdi:cogs" + } } } } diff --git a/homeassistant/components/twinkly/select.py b/homeassistant/components/twinkly/select.py index 86d9732b8cc..a5283b3f91d 100644 --- a/homeassistant/components/twinkly/select.py +++ b/homeassistant/components/twinkly/select.py @@ -29,7 +29,7 @@ async def async_setup_entry( class TwinklyModeSelect(TwinklyEntity, SelectEntity): """Twinkly Mode Selection.""" - _attr_name = "Mode" + _attr_translation_key = "mode" _attr_options = TWINKLY_MODES def __init__(self, coordinator: TwinklyCoordinator) -> None: diff --git a/homeassistant/components/twinkly/strings.json b/homeassistant/components/twinkly/strings.json index bbc3d67373d..c2e0efef92c 100644 --- a/homeassistant/components/twinkly/strings.json +++ b/homeassistant/components/twinkly/strings.json @@ -20,5 +20,21 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } + }, + "entity": { + "select": { + "mode": { + "name": "Mode", + "state": { + "color": "Color", + "demo": "Demo", + "effect": "Effect", + "movie": "Uploaded effect", + "off": "[%key:common::state::off%]", + "playlist": "Playlist", + "rt": "Real time" + } + } + } } } diff --git a/tests/components/twinkly/snapshots/test_select.ambr b/tests/components/twinkly/snapshots/test_select.ambr index 26edd4b731d..6700aecd1f2 100644 --- a/tests/components/twinkly/snapshots/test_select.ambr +++ b/tests/components/twinkly/snapshots/test_select.ambr @@ -38,7 +38,7 @@ 'platform': 'twinkly', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'mode', 'unique_id': '00:2d:13:3b:aa:bb_mode', 'unit_of_measurement': None, }) From 2bf592d8aa951977d500f3a66ca341ce058a5e2b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 26 Feb 2025 12:55:03 +0000 Subject: [PATCH 1835/3148] Bump recommended ESPHome Bluetooth proxy version to 2025.2.1 (#139196) --- homeassistant/components/esphome/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/esphome/const.py b/homeassistant/components/esphome/const.py index aabebad01b6..eb5f03c4495 100644 --- a/homeassistant/components/esphome/const.py +++ b/homeassistant/components/esphome/const.py @@ -13,7 +13,7 @@ DEFAULT_ALLOW_SERVICE_CALLS = True DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS = False -STABLE_BLE_VERSION_STR = "2023.8.0" +STABLE_BLE_VERSION_STR = "2025.2.1" STABLE_BLE_VERSION = AwesomeVersion(STABLE_BLE_VERSION_STR) PROJECT_URLS = { "esphome.bluetooth-proxy": "https://esphome.github.io/bluetooth-proxies/", From 0c092f80c7ac95bc1bb696da62d380f69320e95e Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 26 Feb 2025 14:09:38 +0100 Subject: [PATCH 1836/3148] Add default_db_url flag to WS command recorder/info (#139333) --- homeassistant/components/recorder/__init__.py | 9 +++-- .../recorder/basic_websocket_api.py | 3 ++ .../components/recorder/test_websocket_api.py | 40 +++++++++++++++++++ 3 files changed, 49 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/recorder/__init__.py b/homeassistant/components/recorder/__init__.py index 5a95ace92cb..7cb71e70f65 100644 --- a/homeassistant/components/recorder/__init__.py +++ b/homeassistant/components/recorder/__init__.py @@ -149,9 +149,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: commit_interval = conf[CONF_COMMIT_INTERVAL] db_max_retries = conf[CONF_DB_MAX_RETRIES] db_retry_wait = conf[CONF_DB_RETRY_WAIT] - db_url = conf.get(CONF_DB_URL) or DEFAULT_URL.format( - hass_config_path=hass.config.path(DEFAULT_DB_FILE) - ) + db_url = conf.get(CONF_DB_URL) or get_default_url(hass) exclude = conf[CONF_EXCLUDE] exclude_event_types: set[EventType[Any] | str] = set( exclude.get(CONF_EVENT_TYPES, []) @@ -200,3 +198,8 @@ async def _async_setup_integration_platform( instance.queue_task(AddRecorderPlatformTask(domain, platform)) await async_process_integration_platforms(hass, DOMAIN, _process_recorder_platform) + + +def get_default_url(hass: HomeAssistant) -> str: + """Return the default URL.""" + return DEFAULT_URL.format(hass_config_path=hass.config.path(DEFAULT_DB_FILE)) diff --git a/homeassistant/components/recorder/basic_websocket_api.py b/homeassistant/components/recorder/basic_websocket_api.py index 258f6c63a9d..ce9aa452fae 100644 --- a/homeassistant/components/recorder/basic_websocket_api.py +++ b/homeassistant/components/recorder/basic_websocket_api.py @@ -10,6 +10,7 @@ from homeassistant.components import websocket_api from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import recorder as recorder_helper +from . import get_default_url from .util import get_instance @@ -34,6 +35,7 @@ async def ws_info( await hass.data[recorder_helper.DATA_RECORDER].db_connected instance = get_instance(hass) backlog = instance.backlog + db_in_default_location = instance.db_url == get_default_url(hass) migration_in_progress = instance.migration_in_progress migration_is_live = instance.migration_is_live recording = instance.recording @@ -44,6 +46,7 @@ async def ws_info( recorder_info = { "backlog": backlog, + "db_in_default_location": db_in_default_location, "max_backlog": max_backlog, "migration_in_progress": migration_in_progress, "migration_is_live": migration_is_live, diff --git a/tests/components/recorder/test_websocket_api.py b/tests/components/recorder/test_websocket_api.py index 8f93264b682..a4e35bc8753 100644 --- a/tests/components/recorder/test_websocket_api.py +++ b/tests/components/recorder/test_websocket_api.py @@ -2562,6 +2562,7 @@ async def test_recorder_info( assert response["success"] assert response["result"] == { "backlog": 0, + "db_in_default_location": False, # We never use the default URL in tests "max_backlog": 65000, "migration_in_progress": False, "migration_is_live": False, @@ -2570,6 +2571,44 @@ async def test_recorder_info( } +@pytest.mark.parametrize( + ("db_url", "db_in_default_location"), + [ + ("sqlite:///{config_dir}/home-assistant_v2.db", True), + ("sqlite:///{config_dir}/custom.db", False), + ("mysql://root:root_password@127.0.0.1:3316/homeassistant-test", False), + ], +) +async def test_recorder_info_default_url( + recorder_mock: Recorder, + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + db_url: str, + db_in_default_location: bool, +) -> None: + """Test getting recorder status.""" + client = await hass_ws_client() + + # Ensure there are no queued events + await async_wait_recording_done(hass) + + with patch.object( + recorder_mock, "db_url", db_url.format(config_dir=hass.config.config_dir) + ): + await client.send_json_auto_id({"type": "recorder/info"}) + response = await client.receive_json() + assert response["success"] + assert response["result"] == { + "backlog": 0, + "db_in_default_location": db_in_default_location, + "max_backlog": 65000, + "migration_in_progress": False, + "migration_is_live": False, + "recording": True, + "thread_running": True, + } + + async def test_recorder_info_no_recorder( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -2624,6 +2663,7 @@ async def test_recorder_info_wait_database_connect( assert response["success"] assert response["result"] == { "backlog": ANY, + "db_in_default_location": False, "max_backlog": 65000, "migration_in_progress": False, "migration_is_live": False, From b676c2f61b1da5c42199c946da257d85cb5779b7 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 26 Feb 2025 14:24:19 +0100 Subject: [PATCH 1837/3148] Improve action descriptions of LIFX integration (#139329) Improve action description of lifx integration - fix sentence-casing on two action names - change "Kelvin" unit name to proper uppercase - reference 'Theme' and 'Palette' fields by their friendly names for matching translations - change paint_theme action description to match HA style --- homeassistant/components/lifx/strings.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/lifx/strings.json b/homeassistant/components/lifx/strings.json index 39102d904d5..c407489d52d 100644 --- a/homeassistant/components/lifx/strings.json +++ b/homeassistant/components/lifx/strings.json @@ -66,7 +66,7 @@ } }, "set_state": { - "name": "Set State", + "name": "Set state", "description": "Sets a color/brightness and possibly turn the light on/off.", "fields": { "infrared": { @@ -209,11 +209,11 @@ }, "palette": { "name": "Palette", - "description": "List of at least 2 and at most 16 colors as hue (0-360), saturation (0-100), brightness (0-100) and kelvin (1500-9000) values to use for this effect. Overrides the theme attribute." + "description": "List of at least 2 and at most 16 colors as hue (0-360), saturation (0-100), brightness (0-100) and Kelvin (1500-9000) values to use for this effect. Overrides the 'Theme' attribute." }, "theme": { "name": "[%key:component::lifx::entity::select::theme::name%]", - "description": "Predefined color theme to use for the effect. Overridden by the palette attribute." + "description": "Predefined color theme to use for the effect. Overridden by the 'Palette' attribute." }, "power_on": { "name": "Power on", @@ -243,7 +243,7 @@ }, "palette": { "name": "Palette", - "description": "List of 1 to 6 colors as hue (0-360), saturation (0-100), brightness (0-100) and kelvin (1500-9000) values to use for this effect." + "description": "List of 1 to 6 colors as hue (0-360), saturation (0-100), brightness (0-100) and Kelvin (1500-9000) values to use for this effect." }, "power_on": { "name": "Power on", @@ -256,16 +256,16 @@ "description": "Stops a running effect." }, "paint_theme": { - "name": "Paint Theme", - "description": "Paint either a provided theme or custom palette across one or more LIFX lights.", + "name": "Paint theme", + "description": "Paints either a provided theme or custom palette across one or more LIFX lights.", "fields": { "palette": { "name": "Palette", - "description": "List of at least 2 and at most 16 colors as hue (0-360), saturation (0-100), brightness (0-100) and kelvin (1500-9000) values to paint across the target lights. Overrides the theme attribute." + "description": "List of at least 2 and at most 16 colors as hue (0-360), saturation (0-100), brightness (0-100) and Kelvin (1500-9000) values to paint across the target lights. Overrides the 'Theme' attribute." }, "theme": { "name": "[%key:component::lifx::entity::select::theme::name%]", - "description": "Predefined color theme to paint. Overridden by the palette attribute." + "description": "Predefined color theme to paint. Overridden by the 'Palette' attribute." }, "transition": { "name": "Transition", From bb9aba2a7dac8b54831781f3db8ccf6e094ea738 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Wed, 26 Feb 2025 14:48:18 +0100 Subject: [PATCH 1838/3148] Bump Music Assistant client to 1.1.1 (#139331) --- .../components/music_assistant/actions.py | 6 +++++- .../components/music_assistant/manifest.json | 2 +- .../components/music_assistant/media_browser.py | 11 +++++++++++ .../components/music_assistant/media_player.py | 4 +++- .../components/music_assistant/schemas.py | 16 ++++++++++------ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 7 files changed, 32 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/music_assistant/actions.py b/homeassistant/components/music_assistant/actions.py index bcd33b7fd6c..bf9a1260362 100644 --- a/homeassistant/components/music_assistant/actions.py +++ b/homeassistant/components/music_assistant/actions.py @@ -48,6 +48,7 @@ from .schemas import ( if TYPE_CHECKING: from music_assistant_client import MusicAssistantClient + from music_assistant_models.media_items import Album, Artist, Playlist, Radio, Track from . import MusicAssistantConfigEntry @@ -173,6 +174,9 @@ async def handle_get_library(call: ServiceCall) -> ServiceResponse: "offset": offset, "order_by": order_by, } + library_result: ( + list[Album] | list[Artist] | list[Track] | list[Radio] | list[Playlist] + ) if media_type == MediaType.ALBUM: library_result = await mass.music.get_library_albums( **base_params, @@ -181,7 +185,7 @@ async def handle_get_library(call: ServiceCall) -> ServiceResponse: elif media_type == MediaType.ARTIST: library_result = await mass.music.get_library_artists( **base_params, - album_artists_only=call.data.get(ATTR_ALBUM_ARTISTS_ONLY), + album_artists_only=bool(call.data.get(ATTR_ALBUM_ARTISTS_ONLY)), ) elif media_type == MediaType.TRACK: library_result = await mass.music.get_library_tracks( diff --git a/homeassistant/components/music_assistant/manifest.json b/homeassistant/components/music_assistant/manifest.json index f5cdcf50673..fb8bb9c3ac2 100644 --- a/homeassistant/components/music_assistant/manifest.json +++ b/homeassistant/components/music_assistant/manifest.json @@ -7,6 +7,6 @@ "documentation": "https://www.home-assistant.io/integrations/music_assistant", "iot_class": "local_push", "loggers": ["music_assistant"], - "requirements": ["music-assistant-client==1.0.8"], + "requirements": ["music-assistant-client==1.1.1"], "zeroconf": ["_mass._tcp.local."] } diff --git a/homeassistant/components/music_assistant/media_browser.py b/homeassistant/components/music_assistant/media_browser.py index e65d6d4a975..a926e2a0595 100644 --- a/homeassistant/components/music_assistant/media_browser.py +++ b/homeassistant/components/music_assistant/media_browser.py @@ -166,6 +166,8 @@ async def build_playlist_items_listing( ) -> BrowseMedia: """Build Playlist items browse listing.""" playlist = await mass.music.get_item_by_uri(identifier) + if TYPE_CHECKING: + assert playlist.uri is not None return BrowseMedia( media_class=MediaClass.PLAYLIST, @@ -219,6 +221,9 @@ async def build_artist_items_listing( artist = await mass.music.get_item_by_uri(identifier) albums = await mass.music.get_artist_albums(artist.item_id, artist.provider) + if TYPE_CHECKING: + assert artist.uri is not None + return BrowseMedia( media_class=MediaType.ARTIST, media_content_id=artist.uri, @@ -267,6 +272,9 @@ async def build_album_items_listing( album = await mass.music.get_item_by_uri(identifier) tracks = await mass.music.get_album_tracks(album.item_id, album.provider) + if TYPE_CHECKING: + assert album.uri is not None + return BrowseMedia( media_class=MediaType.ALBUM, media_content_id=album.uri, @@ -340,6 +348,9 @@ def build_item( title = item.name img_url = mass.get_media_item_image_url(item) + if TYPE_CHECKING: + assert item.uri is not None + return BrowseMedia( media_class=media_class or item.media_type.value, media_content_id=item.uri, diff --git a/homeassistant/components/music_assistant/media_player.py b/homeassistant/components/music_assistant/media_player.py index 5621b5eb562..bbbda095302 100644 --- a/homeassistant/components/music_assistant/media_player.py +++ b/homeassistant/components/music_assistant/media_player.py @@ -20,6 +20,7 @@ from music_assistant_models.enums import ( from music_assistant_models.errors import MediaNotFoundError, MusicAssistantError from music_assistant_models.event import MassEvent from music_assistant_models.media_items import ItemMapping, MediaItemType, Track +from music_assistant_models.player_queue import PlayerQueue import voluptuous as vol from homeassistant.components import media_source @@ -78,7 +79,6 @@ from .schemas import QUEUE_DETAILS_SCHEMA, queue_item_dict_from_mass_item if TYPE_CHECKING: from music_assistant_client import MusicAssistantClient from music_assistant_models.player import Player - from music_assistant_models.player_queue import PlayerQueue SUPPORTED_FEATURES = ( MediaPlayerEntityFeature.PAUSE @@ -473,6 +473,8 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity): album=album, media_type=MediaType(media_type) if media_type else None, ): + if TYPE_CHECKING: + assert item.uri is not None media_uris.append(item.uri) if not media_uris: diff --git a/homeassistant/components/music_assistant/schemas.py b/homeassistant/components/music_assistant/schemas.py index d8c4fe1649d..0954d1573e7 100644 --- a/homeassistant/components/music_assistant/schemas.py +++ b/homeassistant/components/music_assistant/schemas.py @@ -65,20 +65,20 @@ MEDIA_ITEM_SCHEMA = vol.Schema( def media_item_dict_from_mass_item( mass: MusicAssistantClient, - item: MediaItemType | ItemMapping | None, -) -> dict[str, Any] | None: + item: MediaItemType | ItemMapping, +) -> dict[str, Any]: """Parse a Music Assistant MediaItem.""" - if not item: - return None - base = { + base: dict[str, Any] = { ATTR_MEDIA_TYPE: item.media_type, ATTR_URI: item.uri, ATTR_NAME: item.name, ATTR_VERSION: item.version, ATTR_IMAGE: mass.get_media_item_image_url(item), } + artists: list[ItemMapping] | None if artists := getattr(item, "artists", None): base[ATTR_ARTISTS] = [media_item_dict_from_mass_item(mass, x) for x in artists] + album: ItemMapping | None if album := getattr(item, "album", None): base[ATTR_ALBUM] = media_item_dict_from_mass_item(mass, album) return base @@ -151,7 +151,11 @@ def queue_item_dict_from_mass_item( ATTR_QUEUE_ITEM_ID: item.queue_item_id, ATTR_NAME: item.name, ATTR_DURATION: item.duration, - ATTR_MEDIA_ITEM: media_item_dict_from_mass_item(mass, item.media_item), + ATTR_MEDIA_ITEM: ( + media_item_dict_from_mass_item(mass, item.media_item) + if item.media_item + else None + ), } if streamdetails := item.streamdetails: base[ATTR_STREAM_TITLE] = streamdetails.stream_title diff --git a/requirements_all.txt b/requirements_all.txt index 7a60530b12c..40df67dc93f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1447,7 +1447,7 @@ mozart-api==4.1.1.116.4 mullvad-api==1.0.0 # homeassistant.components.music_assistant -music-assistant-client==1.0.8 +music-assistant-client==1.1.1 # homeassistant.components.tts mutagen==1.47.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index af549502560..029b770512e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1219,7 +1219,7 @@ mozart-api==4.1.1.116.4 mullvad-api==1.0.0 # homeassistant.components.music_assistant -music-assistant-client==1.0.8 +music-assistant-client==1.1.1 # homeassistant.components.tts mutagen==1.47.0 From bb120020a8e9bcdd1789275c5bc722dd3e7230ec Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 26 Feb 2025 15:14:04 +0100 Subject: [PATCH 1839/3148] Refactor SmartThings (#137940) --- CODEOWNERS | 2 + .../components/smartthings/__init__.py | 478 +- .../smartthings/application_credentials.py | 64 + .../components/smartthings/binary_sensor.py | 162 +- .../components/smartthings/climate.py | 510 +- .../components/smartthings/config_flow.py | 313 +- homeassistant/components/smartthings/const.py | 64 +- homeassistant/components/smartthings/cover.py | 139 +- .../components/smartthings/entity.py | 107 +- homeassistant/components/smartthings/fan.py | 128 +- homeassistant/components/smartthings/light.py | 159 +- homeassistant/components/smartthings/lock.py | 42 +- .../components/smartthings/manifest.json | 9 +- homeassistant/components/smartthings/scene.py | 21 +- .../components/smartthings/sensor.py | 547 +- .../components/smartthings/smartapp.py | 545 -- .../components/smartthings/strings.json | 50 +- .../components/smartthings/switch.py | 59 +- .../generated/application_credentials.py | 1 + requirements_all.txt | 5 +- requirements_test_all.txt | 5 +- tests/components/smartthings/__init__.py | 76 +- tests/components/smartthings/conftest.py | 462 +- .../aeotec_home_energy_meter_gen5.json | 31 + .../device_status/base_electric_meter.json | 21 + .../device_status/c2c_arlo_pro_3_switch.json | 82 + .../fixtures/device_status/c2c_shade.json | 50 + .../fixtures/device_status/centralite.json | 60 + .../device_status/contact_sensor.json | 66 + .../device_status/da_ac_rac_000001.json | 879 +++ .../device_status/da_ac_rac_01001.json | 731 +++ .../device_status/da_ks_microwave_0101x.json | 600 ++ .../device_status/da_ref_normal_000001.json | 727 +++ .../device_status/da_rvc_normal_000001.json | 274 + .../device_status/da_wm_dw_000001.json | 786 +++ .../device_status/da_wm_wd_000001.json | 719 +++ .../device_status/da_wm_wm_000001.json | 1243 +++++ .../fixtures/device_status/ecobee_sensor.json | 51 + .../device_status/ecobee_thermostat.json | 98 + .../fixtures/device_status/fake_fan.json | 31 + .../ge_in_wall_smart_dimmer.json | 23 + .../hue_color_temperature_bulb.json | 75 + .../device_status/hue_rgbw_color_bulb.json | 94 + .../fixtures/device_status/iphone.json | 12 + .../device_status/multipurpose_sensor.json | 79 + .../sensibo_airconditioner_1.json | 57 + .../fixtures/device_status/smart_plug.json | 43 + .../fixtures/device_status/sonos_player.json | 259 + .../device_status/vd_network_audio_002s.json | 164 + .../fixtures/device_status/vd_stv_2017_k.json | 266 + .../device_status/virtual_thermostat.json | 97 + .../fixtures/device_status/virtual_valve.json | 13 + .../device_status/virtual_water_sensor.json | 28 + .../yale_push_button_deadbolt_lock.json | 110 + .../aeotec_home_energy_meter_gen5.json | 70 + .../fixtures/devices/base_electric_meter.json | 62 + .../devices/c2c_arlo_pro_3_switch.json | 79 + .../fixtures/devices/c2c_shade.json | 59 + .../fixtures/devices/centralite.json | 67 + .../fixtures/devices/contact_sensor.json | 71 + .../fixtures/devices/da_ac_rac_000001.json | 311 ++ .../fixtures/devices/da_ac_rac_01001.json | 264 + .../devices/da_ks_microwave_0101x.json | 176 + .../devices/da_ref_normal_000001.json | 412 ++ .../devices/da_rvc_normal_000001.json | 119 + .../fixtures/devices/da_wm_dw_000001.json | 168 + .../fixtures/devices/da_wm_wd_000001.json | 204 + .../fixtures/devices/da_wm_wm_000001.json | 260 + .../fixtures/devices/ecobee_sensor.json | 64 + .../fixtures/devices/ecobee_thermostat.json | 80 + .../fixtures/devices/fake_fan.json | 50 + .../devices/ge_in_wall_smart_dimmer.json | 65 + .../devices/hue_color_temperature_bulb.json | 73 + .../fixtures/devices/hue_rgbw_color_bulb.json | 81 + .../smartthings/fixtures/devices/iphone.json | 41 + .../fixtures/devices/multipurpose_sensor.json | 78 + .../devices/sensibo_airconditioner_1.json | 64 + .../fixtures/devices/smart_plug.json | 59 + .../fixtures/devices/sonos_player.json | 82 + .../devices/vd_network_audio_002s.json | 109 + .../fixtures/devices/vd_stv_2017_k.json | 148 + .../fixtures/devices/virtual_thermostat.json | 69 + .../fixtures/devices/virtual_valve.json | 49 + .../devices/virtual_water_sensor.json | 53 + .../yale_push_button_deadbolt_lock.json | 67 + .../smartthings/fixtures/locations.json | 9 + .../smartthings/fixtures/scenes.json | 34 + .../snapshots/test_binary_sensor.ambr | 529 ++ .../smartthings/snapshots/test_climate.ambr | 356 ++ .../smartthings/snapshots/test_cover.ambr | 100 + .../smartthings/snapshots/test_fan.ambr | 67 + .../smartthings/snapshots/test_init.ambr | 1024 ++++ .../smartthings/snapshots/test_light.ambr | 267 + .../smartthings/snapshots/test_lock.ambr | 50 + .../smartthings/snapshots/test_scene.ambr | 101 + .../smartthings/snapshots/test_sensor.ambr | 4857 +++++++++++++++++ .../smartthings/snapshots/test_switch.ambr | 471 ++ .../smartthings/test_binary_sensor.py | 158 +- tests/components/smartthings/test_climate.py | 1382 ++--- .../smartthings/test_config_flow.py | 1179 ++-- tests/components/smartthings/test_cover.py | 369 +- tests/components/smartthings/test_fan.py | 521 +- tests/components/smartthings/test_init.py | 571 +- tests/components/smartthings/test_light.py | 561 +- tests/components/smartthings/test_lock.py | 174 +- tests/components/smartthings/test_scene.py | 65 +- tests/components/smartthings/test_sensor.py | 306 +- tests/components/smartthings/test_smartapp.py | 186 - tests/components/smartthings/test_switch.py | 166 +- 109 files changed, 22599 insertions(+), 6175 deletions(-) create mode 100644 homeassistant/components/smartthings/application_credentials.py delete mode 100644 homeassistant/components/smartthings/smartapp.py create mode 100644 tests/components/smartthings/fixtures/device_status/aeotec_home_energy_meter_gen5.json create mode 100644 tests/components/smartthings/fixtures/device_status/base_electric_meter.json create mode 100644 tests/components/smartthings/fixtures/device_status/c2c_arlo_pro_3_switch.json create mode 100644 tests/components/smartthings/fixtures/device_status/c2c_shade.json create mode 100644 tests/components/smartthings/fixtures/device_status/centralite.json create mode 100644 tests/components/smartthings/fixtures/device_status/contact_sensor.json create mode 100644 tests/components/smartthings/fixtures/device_status/da_ac_rac_000001.json create mode 100644 tests/components/smartthings/fixtures/device_status/da_ac_rac_01001.json create mode 100644 tests/components/smartthings/fixtures/device_status/da_ks_microwave_0101x.json create mode 100644 tests/components/smartthings/fixtures/device_status/da_ref_normal_000001.json create mode 100644 tests/components/smartthings/fixtures/device_status/da_rvc_normal_000001.json create mode 100644 tests/components/smartthings/fixtures/device_status/da_wm_dw_000001.json create mode 100644 tests/components/smartthings/fixtures/device_status/da_wm_wd_000001.json create mode 100644 tests/components/smartthings/fixtures/device_status/da_wm_wm_000001.json create mode 100644 tests/components/smartthings/fixtures/device_status/ecobee_sensor.json create mode 100644 tests/components/smartthings/fixtures/device_status/ecobee_thermostat.json create mode 100644 tests/components/smartthings/fixtures/device_status/fake_fan.json create mode 100644 tests/components/smartthings/fixtures/device_status/ge_in_wall_smart_dimmer.json create mode 100644 tests/components/smartthings/fixtures/device_status/hue_color_temperature_bulb.json create mode 100644 tests/components/smartthings/fixtures/device_status/hue_rgbw_color_bulb.json create mode 100644 tests/components/smartthings/fixtures/device_status/iphone.json create mode 100644 tests/components/smartthings/fixtures/device_status/multipurpose_sensor.json create mode 100644 tests/components/smartthings/fixtures/device_status/sensibo_airconditioner_1.json create mode 100644 tests/components/smartthings/fixtures/device_status/smart_plug.json create mode 100644 tests/components/smartthings/fixtures/device_status/sonos_player.json create mode 100644 tests/components/smartthings/fixtures/device_status/vd_network_audio_002s.json create mode 100644 tests/components/smartthings/fixtures/device_status/vd_stv_2017_k.json create mode 100644 tests/components/smartthings/fixtures/device_status/virtual_thermostat.json create mode 100644 tests/components/smartthings/fixtures/device_status/virtual_valve.json create mode 100644 tests/components/smartthings/fixtures/device_status/virtual_water_sensor.json create mode 100644 tests/components/smartthings/fixtures/device_status/yale_push_button_deadbolt_lock.json create mode 100644 tests/components/smartthings/fixtures/devices/aeotec_home_energy_meter_gen5.json create mode 100644 tests/components/smartthings/fixtures/devices/base_electric_meter.json create mode 100644 tests/components/smartthings/fixtures/devices/c2c_arlo_pro_3_switch.json create mode 100644 tests/components/smartthings/fixtures/devices/c2c_shade.json create mode 100644 tests/components/smartthings/fixtures/devices/centralite.json create mode 100644 tests/components/smartthings/fixtures/devices/contact_sensor.json create mode 100644 tests/components/smartthings/fixtures/devices/da_ac_rac_000001.json create mode 100644 tests/components/smartthings/fixtures/devices/da_ac_rac_01001.json create mode 100644 tests/components/smartthings/fixtures/devices/da_ks_microwave_0101x.json create mode 100644 tests/components/smartthings/fixtures/devices/da_ref_normal_000001.json create mode 100644 tests/components/smartthings/fixtures/devices/da_rvc_normal_000001.json create mode 100644 tests/components/smartthings/fixtures/devices/da_wm_dw_000001.json create mode 100644 tests/components/smartthings/fixtures/devices/da_wm_wd_000001.json create mode 100644 tests/components/smartthings/fixtures/devices/da_wm_wm_000001.json create mode 100644 tests/components/smartthings/fixtures/devices/ecobee_sensor.json create mode 100644 tests/components/smartthings/fixtures/devices/ecobee_thermostat.json create mode 100644 tests/components/smartthings/fixtures/devices/fake_fan.json create mode 100644 tests/components/smartthings/fixtures/devices/ge_in_wall_smart_dimmer.json create mode 100644 tests/components/smartthings/fixtures/devices/hue_color_temperature_bulb.json create mode 100644 tests/components/smartthings/fixtures/devices/hue_rgbw_color_bulb.json create mode 100644 tests/components/smartthings/fixtures/devices/iphone.json create mode 100644 tests/components/smartthings/fixtures/devices/multipurpose_sensor.json create mode 100644 tests/components/smartthings/fixtures/devices/sensibo_airconditioner_1.json create mode 100644 tests/components/smartthings/fixtures/devices/smart_plug.json create mode 100644 tests/components/smartthings/fixtures/devices/sonos_player.json create mode 100644 tests/components/smartthings/fixtures/devices/vd_network_audio_002s.json create mode 100644 tests/components/smartthings/fixtures/devices/vd_stv_2017_k.json create mode 100644 tests/components/smartthings/fixtures/devices/virtual_thermostat.json create mode 100644 tests/components/smartthings/fixtures/devices/virtual_valve.json create mode 100644 tests/components/smartthings/fixtures/devices/virtual_water_sensor.json create mode 100644 tests/components/smartthings/fixtures/devices/yale_push_button_deadbolt_lock.json create mode 100644 tests/components/smartthings/fixtures/locations.json create mode 100644 tests/components/smartthings/fixtures/scenes.json create mode 100644 tests/components/smartthings/snapshots/test_binary_sensor.ambr create mode 100644 tests/components/smartthings/snapshots/test_climate.ambr create mode 100644 tests/components/smartthings/snapshots/test_cover.ambr create mode 100644 tests/components/smartthings/snapshots/test_fan.ambr create mode 100644 tests/components/smartthings/snapshots/test_init.ambr create mode 100644 tests/components/smartthings/snapshots/test_light.ambr create mode 100644 tests/components/smartthings/snapshots/test_lock.ambr create mode 100644 tests/components/smartthings/snapshots/test_scene.ambr create mode 100644 tests/components/smartthings/snapshots/test_sensor.ambr create mode 100644 tests/components/smartthings/snapshots/test_switch.ambr delete mode 100644 tests/components/smartthings/test_smartapp.py diff --git a/CODEOWNERS b/CODEOWNERS index 1052a58fe88..3366bfb0885 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1401,6 +1401,8 @@ build.json @home-assistant/supervisor /tests/components/smappee/ @bsmappee /homeassistant/components/smart_meter_texas/ @grahamwetzler /tests/components/smart_meter_texas/ @grahamwetzler +/homeassistant/components/smartthings/ @joostlek +/tests/components/smartthings/ @joostlek /homeassistant/components/smarttub/ @mdz /tests/components/smarttub/ @mdz /homeassistant/components/smarty/ @z0mbieprocess diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py index 2914851ccbf..d580e36e45e 100644 --- a/homeassistant/components/smartthings/__init__.py +++ b/homeassistant/components/smartthings/__init__.py @@ -2,416 +2,144 @@ from __future__ import annotations -import asyncio -from collections.abc import Iterable -from http import HTTPStatus -import importlib +from dataclasses import dataclass import logging +from typing import TYPE_CHECKING -from aiohttp.client_exceptions import ClientConnectionError, ClientResponseError -from pysmartapp.event import EVENT_TYPE_DEVICE -from pysmartthings import APIInvalidGrant, Attribute, Capability, SmartThings +from aiohttp import ClientError +from pysmartthings import ( + Attribute, + Capability, + Device, + Scene, + SmartThings, + SmartThingsAuthenticationFailedError, + Status, +) -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import CONF_ACCESS_TOKEN, CONF_CLIENT_ID, CONF_CLIENT_SECRET +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ( - ConfigEntryAuthFailed, - ConfigEntryError, - ConfigEntryNotReady, -) -from homeassistant.helpers import config_validation as cv +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.dispatcher import async_dispatcher_send -from homeassistant.helpers.event import async_track_time_interval -from homeassistant.helpers.typing import ConfigType -from homeassistant.loader import async_get_loaded_integration -from homeassistant.setup import SetupPhases, async_pause_setup +from homeassistant.helpers.config_entry_oauth2_flow import ( + OAuth2Session, + async_get_config_entry_implementation, +) -from .config_flow import SmartThingsFlowHandler # noqa: F401 -from .const import ( - CONF_APP_ID, - CONF_INSTALLED_APP_ID, - CONF_LOCATION_ID, - CONF_REFRESH_TOKEN, - DATA_BROKERS, - DATA_MANAGER, - DOMAIN, - EVENT_BUTTON, - PLATFORMS, - SIGNAL_SMARTTHINGS_UPDATE, - TOKEN_REFRESH_INTERVAL, -) -from .smartapp import ( - format_unique_id, - setup_smartapp, - setup_smartapp_endpoint, - smartapp_sync_subscriptions, - unload_smartapp_endpoint, - validate_installed_app, - validate_webhook_requirements, -) +from .const import CONF_INSTALLED_APP_ID, CONF_LOCATION_ID, MAIN, OLD_DATA _LOGGER = logging.getLogger(__name__) -CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + +@dataclass +class SmartThingsData: + """Define an object to hold SmartThings data.""" + + devices: dict[str, FullDevice] + scenes: dict[str, Scene] + client: SmartThings -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Initialize the SmartThings platform.""" - await setup_smartapp_endpoint(hass, False) - return True +@dataclass +class FullDevice: + """Define an object to hold device data.""" + + device: Device + status: dict[str, dict[Capability, dict[Attribute, Status]]] -async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Handle migration of a previous version config entry. +type SmartThingsConfigEntry = ConfigEntry[SmartThingsData] - A config entry created under a previous version must go through the - integration setup again so we can properly retrieve the needed data - elements. Force this by removing the entry and triggering a new flow. - """ - # Remove the entry which will invoke the callback to delete the app. - hass.async_create_task(hass.config_entries.async_remove(entry.entry_id)) - # only create new flow if there isn't a pending one for SmartThings. - if not hass.config_entries.flow.async_progress_by_handler(DOMAIN): - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT} - ) - ) - - # Return False because it could not be migrated. - return False +PLATFORMS = [ + Platform.BINARY_SENSOR, + Platform.CLIMATE, + Platform.COVER, + Platform.FAN, + Platform.LIGHT, + Platform.LOCK, + Platform.SCENE, + Platform.SENSOR, + Platform.SWITCH, +] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: SmartThingsConfigEntry) -> bool: """Initialize config entry which represents an installed SmartApp.""" - # For backwards compat - if entry.unique_id is None: - hass.config_entries.async_update_entry( - entry, - unique_id=format_unique_id( - entry.data[CONF_APP_ID], entry.data[CONF_LOCATION_ID] - ), - ) - - if not validate_webhook_requirements(hass): - _LOGGER.warning( - "The 'base_url' of the 'http' integration must be configured and start with" - " 'https://'" - ) - return False - - api = SmartThings(async_get_clientsession(hass), entry.data[CONF_ACCESS_TOKEN]) - - # Ensure platform modules are loaded since the DeviceBroker will - # import them below and we want them to be cached ahead of time - # so the integration does not do blocking I/O in the event loop - # to import the modules. - await async_get_loaded_integration(hass, DOMAIN).async_get_platforms(PLATFORMS) + # The oauth smartthings entry will have a token, older ones are version 3 + # after migration but still require reauthentication + if CONF_TOKEN not in entry.data: + raise ConfigEntryAuthFailed("Config entry missing token") + implementation = await async_get_config_entry_implementation(hass, entry) + session = OAuth2Session(hass, entry, implementation) try: - # See if the app is already setup. This occurs when there are - # installs in multiple SmartThings locations (valid use-case) - manager = hass.data[DOMAIN][DATA_MANAGER] - smart_app = manager.smartapps.get(entry.data[CONF_APP_ID]) - if not smart_app: - # Validate and setup the app. - app = await api.app(entry.data[CONF_APP_ID]) - smart_app = setup_smartapp(hass, app) + await session.async_ensure_token_valid() + except ClientError as err: + raise ConfigEntryNotReady from err - # Validate and retrieve the installed app. - installed_app = await validate_installed_app( - api, entry.data[CONF_INSTALLED_APP_ID] - ) + client = SmartThings(session=async_get_clientsession(hass)) - # Get scenes - scenes = await async_get_entry_scenes(entry, api) + async def _refresh_token() -> str: + await session.async_ensure_token_valid() + token = session.token[CONF_ACCESS_TOKEN] + if TYPE_CHECKING: + assert isinstance(token, str) + return token - # Get SmartApp token to sync subscriptions - token = await api.generate_tokens( - entry.data[CONF_CLIENT_ID], - entry.data[CONF_CLIENT_SECRET], - entry.data[CONF_REFRESH_TOKEN], - ) - hass.config_entries.async_update_entry( - entry, data={**entry.data, CONF_REFRESH_TOKEN: token.refresh_token} - ) + client.refresh_token_function = _refresh_token - # Get devices and their current status - devices = await api.devices(location_ids=[installed_app.location_id]) + device_status: dict[str, FullDevice] = {} + try: + devices = await client.get_devices() + for device in devices: + status = await client.get_device_status(device.device_id) + device_status[device.device_id] = FullDevice(device=device, status=status) + except SmartThingsAuthenticationFailedError as err: + raise ConfigEntryAuthFailed from err - async def retrieve_device_status(device): - try: - await device.status.refresh() - except ClientResponseError: - _LOGGER.debug( - ( - "Unable to update status for device: %s (%s), the device will" - " be excluded" - ), - device.label, - device.device_id, - exc_info=True, - ) - devices.remove(device) + scenes = { + scene.scene_id: scene + for scene in await client.get_scenes(location_id=entry.data[CONF_LOCATION_ID]) + } - await asyncio.gather(*(retrieve_device_status(d) for d in devices.copy())) + entry.runtime_data = SmartThingsData( + devices={ + device_id: device + for device_id, device in device_status.items() + if MAIN in device.status + }, + client=client, + scenes=scenes, + ) - # Sync device subscriptions - await smartapp_sync_subscriptions( - hass, - token.access_token, - installed_app.location_id, - installed_app.installed_app_id, - devices, - ) - - # Setup device broker - with async_pause_setup(hass, SetupPhases.WAIT_IMPORT_PLATFORMS): - # DeviceBroker has a side effect of importing platform - # modules when its created. In the future this should be - # refactored to not do this. - broker = await hass.async_add_import_executor_job( - DeviceBroker, hass, entry, token, smart_app, devices, scenes - ) - broker.connect() - hass.data[DOMAIN][DATA_BROKERS][entry.entry_id] = broker - - except APIInvalidGrant as ex: - raise ConfigEntryAuthFailed from ex - except ClientResponseError as ex: - if ex.status in (HTTPStatus.UNAUTHORIZED, HTTPStatus.FORBIDDEN): - raise ConfigEntryError( - "The access token is no longer valid. Please remove the integration and set up again." - ) from ex - _LOGGER.debug(ex, exc_info=True) - raise ConfigEntryNotReady from ex - except (ClientConnectionError, RuntimeWarning) as ex: - _LOGGER.debug(ex, exc_info=True) - raise ConfigEntryNotReady from ex + entry.async_create_background_task( + hass, + client.subscribe( + entry.data[CONF_LOCATION_ID], entry.data[CONF_TOKEN][CONF_INSTALLED_APP_ID] + ), + "smartthings_webhook", + ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True -async def async_get_entry_scenes(entry: ConfigEntry, api): - """Get the scenes within an integration.""" - try: - return await api.scenes(location_id=entry.data[CONF_LOCATION_ID]) - except ClientResponseError as ex: - if ex.status == HTTPStatus.FORBIDDEN: - _LOGGER.exception( - ( - "Unable to load scenes for configuration entry '%s' because the" - " access token does not have the required access" - ), - entry.title, - ) - else: - raise - return [] - - -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, entry: SmartThingsConfigEntry +) -> bool: """Unload a config entry.""" - broker = hass.data[DOMAIN][DATA_BROKERS].pop(entry.entry_id, None) - if broker: - broker.disconnect() - return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) -async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Perform clean-up when entry is being removed.""" - api = SmartThings(async_get_clientsession(hass), entry.data[CONF_ACCESS_TOKEN]) +async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Handle config entry migration.""" - # Remove the installed_app, which if already removed raises a HTTPStatus.FORBIDDEN error. - installed_app_id = entry.data[CONF_INSTALLED_APP_ID] - try: - await api.delete_installed_app(installed_app_id) - except ClientResponseError as ex: - if ex.status == HTTPStatus.FORBIDDEN: - _LOGGER.debug( - "Installed app %s has already been removed", - installed_app_id, - exc_info=True, - ) - else: - raise - _LOGGER.debug("Removed installed app %s", installed_app_id) - - # Remove the app if not referenced by other entries, which if already - # removed raises a HTTPStatus.FORBIDDEN error. - all_entries = hass.config_entries.async_entries(DOMAIN) - app_id = entry.data[CONF_APP_ID] - app_count = sum(1 for entry in all_entries if entry.data[CONF_APP_ID] == app_id) - if app_count > 1: - _LOGGER.debug( - ( - "App %s was not removed because it is in use by other configuration" - " entries" - ), - app_id, - ) - return - # Remove the app - try: - await api.delete_app(app_id) - except ClientResponseError as ex: - if ex.status == HTTPStatus.FORBIDDEN: - _LOGGER.debug("App %s has already been removed", app_id, exc_info=True) - else: - raise - _LOGGER.debug("Removed app %s", app_id) - - if len(all_entries) == 1: - await unload_smartapp_endpoint(hass) - - -class DeviceBroker: - """Manages an individual SmartThings config entry.""" - - def __init__( - self, - hass: HomeAssistant, - entry: ConfigEntry, - token, - smart_app, - devices: Iterable, - scenes: Iterable, - ) -> None: - """Create a new instance of the DeviceBroker.""" - self._hass = hass - self._entry = entry - self._installed_app_id = entry.data[CONF_INSTALLED_APP_ID] - self._smart_app = smart_app - self._token = token - self._event_disconnect = None - self._regenerate_token_remove = None - self._assignments = self._assign_capabilities(devices) - self.devices = {device.device_id: device for device in devices} - self.scenes = {scene.scene_id: scene for scene in scenes} - - def _assign_capabilities(self, devices: Iterable): - """Assign platforms to capabilities.""" - assignments = {} - for device in devices: - capabilities = device.capabilities.copy() - slots = {} - for platform in PLATFORMS: - platform_module = importlib.import_module( - f".{platform}", self.__module__ - ) - if not hasattr(platform_module, "get_capabilities"): - continue - assigned = platform_module.get_capabilities(capabilities) - if not assigned: - continue - # Draw-down capabilities and set slot assignment - for capability in assigned: - if capability not in capabilities: - continue - capabilities.remove(capability) - slots[capability] = platform - assignments[device.device_id] = slots - return assignments - - def connect(self): - """Connect handlers/listeners for device/lifecycle events.""" - - # Setup interval to regenerate the refresh token on a periodic basis. - # Tokens expire in 30 days and once expired, cannot be recovered. - async def regenerate_refresh_token(now): - """Generate a new refresh token and update the config entry.""" - await self._token.refresh( - self._entry.data[CONF_CLIENT_ID], - self._entry.data[CONF_CLIENT_SECRET], - ) - self._hass.config_entries.async_update_entry( - self._entry, - data={ - **self._entry.data, - CONF_REFRESH_TOKEN: self._token.refresh_token, - }, - ) - _LOGGER.debug( - "Regenerated refresh token for installed app: %s", - self._installed_app_id, - ) - - self._regenerate_token_remove = async_track_time_interval( - self._hass, regenerate_refresh_token, TOKEN_REFRESH_INTERVAL + if entry.version < 3: + # We keep the old data around, so we can use that to clean up the webhook in the future + hass.config_entries.async_update_entry( + entry, version=3, data={OLD_DATA: dict(entry.data)} ) - # Connect handler to incoming device events - self._event_disconnect = self._smart_app.connect_event(self._event_handler) - - def disconnect(self): - """Disconnects handlers/listeners for device/lifecycle events.""" - if self._regenerate_token_remove: - self._regenerate_token_remove() - if self._event_disconnect: - self._event_disconnect() - - def get_assigned(self, device_id: str, platform: str): - """Get the capabilities assigned to the platform.""" - slots = self._assignments.get(device_id, {}) - return [key for key, value in slots.items() if value == platform] - - def any_assigned(self, device_id: str, platform: str): - """Return True if the platform has any assigned capabilities.""" - slots = self._assignments.get(device_id, {}) - return any(value for value in slots.values() if value == platform) - - async def _event_handler(self, req, resp, app): - """Broker for incoming events.""" - # Do not process events received from a different installed app - # under the same parent SmartApp (valid use-scenario) - if req.installed_app_id != self._installed_app_id: - return - - updated_devices = set() - for evt in req.events: - if evt.event_type != EVENT_TYPE_DEVICE: - continue - if not (device := self.devices.get(evt.device_id)): - continue - device.status.apply_attribute_update( - evt.component_id, - evt.capability, - evt.attribute, - evt.value, - data=evt.data, - ) - - # Fire events for buttons - if ( - evt.capability == Capability.button - and evt.attribute == Attribute.button - ): - data = { - "component_id": evt.component_id, - "device_id": evt.device_id, - "location_id": evt.location_id, - "value": evt.value, - "name": device.label, - "data": evt.data, - } - self._hass.bus.async_fire(EVENT_BUTTON, data) - _LOGGER.debug("Fired button event: %s", data) - else: - data = { - "location_id": evt.location_id, - "device_id": evt.device_id, - "component_id": evt.component_id, - "capability": evt.capability, - "attribute": evt.attribute, - "value": evt.value, - "data": evt.data, - } - _LOGGER.debug("Push update received: %s", data) - - updated_devices.add(device.device_id) - - async_dispatcher_send(self._hass, SIGNAL_SMARTTHINGS_UPDATE, updated_devices) + return True diff --git a/homeassistant/components/smartthings/application_credentials.py b/homeassistant/components/smartthings/application_credentials.py new file mode 100644 index 00000000000..1e637c6bd12 --- /dev/null +++ b/homeassistant/components/smartthings/application_credentials.py @@ -0,0 +1,64 @@ +"""Application credentials platform for SmartThings.""" + +from json import JSONDecodeError +import logging +from typing import cast + +from aiohttp import BasicAuth, ClientError + +from homeassistant.components.application_credentials import ( + AuthImplementation, + AuthorizationServer, + ClientCredential, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.config_entry_oauth2_flow import AbstractOAuth2Implementation + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +async def async_get_auth_implementation( + hass: HomeAssistant, auth_domain: str, credential: ClientCredential +) -> AbstractOAuth2Implementation: + """Return auth implementation.""" + return SmartThingsOAuth2Implementation( + hass, + DOMAIN, + credential, + authorization_server=AuthorizationServer( + authorize_url="https://api.smartthings.com/oauth/authorize", + token_url="https://auth-global.api.smartthings.com/oauth/token", + ), + ) + + +class SmartThingsOAuth2Implementation(AuthImplementation): + """Oauth2 implementation that only uses the external url.""" + + async def _token_request(self, data: dict) -> dict: + """Make a token request.""" + session = async_get_clientsession(self.hass) + + resp = await session.post( + self.token_url, + data=data, + auth=BasicAuth(self.client_id, self.client_secret), + ) + if resp.status >= 400: + try: + error_response = await resp.json() + except (ClientError, JSONDecodeError): + error_response = {} + error_code = error_response.get("error", "unknown") + error_description = error_response.get("error_description", "unknown error") + _LOGGER.error( + "Token request for %s failed (%s): %s", + self.domain, + error_code, + error_description, + ) + resp.raise_for_status() + return cast(dict, await resp.json()) diff --git a/homeassistant/components/smartthings/binary_sensor.py b/homeassistant/components/smartthings/binary_sensor.py index 6b511c86677..6afa4edcf17 100644 --- a/homeassistant/components/smartthings/binary_sensor.py +++ b/homeassistant/components/smartthings/binary_sensor.py @@ -2,84 +2,144 @@ from __future__ import annotations -from collections.abc import Sequence +from dataclasses import dataclass -from pysmartthings import Attribute, Capability +from pysmartthings import Attribute, Capability, SmartThings from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, + BinarySensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DATA_BROKERS, DOMAIN +from . import FullDevice, SmartThingsConfigEntry +from .const import MAIN from .entity import SmartThingsEntity -CAPABILITY_TO_ATTRIB = { - Capability.acceleration_sensor: Attribute.acceleration, - Capability.contact_sensor: Attribute.contact, - Capability.filter_status: Attribute.filter_status, - Capability.motion_sensor: Attribute.motion, - Capability.presence_sensor: Attribute.presence, - Capability.sound_sensor: Attribute.sound, - Capability.tamper_alert: Attribute.tamper, - Capability.valve: Attribute.valve, - Capability.water_sensor: Attribute.water, -} -ATTRIB_TO_CLASS = { - Attribute.acceleration: BinarySensorDeviceClass.MOVING, - Attribute.contact: BinarySensorDeviceClass.OPENING, - Attribute.filter_status: BinarySensorDeviceClass.PROBLEM, - Attribute.motion: BinarySensorDeviceClass.MOTION, - Attribute.presence: BinarySensorDeviceClass.PRESENCE, - Attribute.sound: BinarySensorDeviceClass.SOUND, - Attribute.tamper: BinarySensorDeviceClass.PROBLEM, - Attribute.valve: BinarySensorDeviceClass.OPENING, - Attribute.water: BinarySensorDeviceClass.MOISTURE, -} -ATTRIB_TO_ENTTIY_CATEGORY = { - Attribute.tamper: EntityCategory.DIAGNOSTIC, + +@dataclass(frozen=True, kw_only=True) +class SmartThingsBinarySensorEntityDescription(BinarySensorEntityDescription): + """Describe a SmartThings binary sensor entity.""" + + is_on_key: str + + +CAPABILITY_TO_SENSORS: dict[ + Capability, dict[Attribute, SmartThingsBinarySensorEntityDescription] +] = { + Capability.ACCELERATION_SENSOR: { + Attribute.ACCELERATION: SmartThingsBinarySensorEntityDescription( + key=Attribute.ACCELERATION, + device_class=BinarySensorDeviceClass.MOVING, + is_on_key="active", + ) + }, + Capability.CONTACT_SENSOR: { + Attribute.CONTACT: SmartThingsBinarySensorEntityDescription( + key=Attribute.CONTACT, + device_class=BinarySensorDeviceClass.DOOR, + is_on_key="open", + ) + }, + Capability.FILTER_STATUS: { + Attribute.FILTER_STATUS: SmartThingsBinarySensorEntityDescription( + key=Attribute.FILTER_STATUS, + device_class=BinarySensorDeviceClass.PROBLEM, + is_on_key="replace", + ) + }, + Capability.MOTION_SENSOR: { + Attribute.MOTION: SmartThingsBinarySensorEntityDescription( + key=Attribute.MOTION, + device_class=BinarySensorDeviceClass.MOTION, + is_on_key="active", + ) + }, + Capability.PRESENCE_SENSOR: { + Attribute.PRESENCE: SmartThingsBinarySensorEntityDescription( + key=Attribute.PRESENCE, + device_class=BinarySensorDeviceClass.PRESENCE, + is_on_key="present", + ) + }, + Capability.SOUND_SENSOR: { + Attribute.SOUND: SmartThingsBinarySensorEntityDescription( + key=Attribute.SOUND, + device_class=BinarySensorDeviceClass.SOUND, + is_on_key="detected", + ) + }, + Capability.TAMPER_ALERT: { + Attribute.TAMPER: SmartThingsBinarySensorEntityDescription( + key=Attribute.TAMPER, + device_class=BinarySensorDeviceClass.PROBLEM, + is_on_key="detected", + entity_category=EntityCategory.DIAGNOSTIC, + ) + }, + Capability.VALVE: { + Attribute.VALVE: SmartThingsBinarySensorEntityDescription( + key=Attribute.VALVE, + device_class=BinarySensorDeviceClass.OPENING, + is_on_key="open", + ) + }, + Capability.WATER_SENSOR: { + Attribute.WATER: SmartThingsBinarySensorEntityDescription( + key=Attribute.WATER, + device_class=BinarySensorDeviceClass.MOISTURE, + is_on_key="wet", + ) + }, } async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + entry: SmartThingsConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add binary sensors for a config entry.""" - broker = hass.data[DOMAIN][DATA_BROKERS][config_entry.entry_id] - sensors = [] - for device in broker.devices.values(): - for capability in broker.get_assigned(device.device_id, "binary_sensor"): - attrib = CAPABILITY_TO_ATTRIB[capability] - sensors.append(SmartThingsBinarySensor(device, attrib)) - async_add_entities(sensors) - - -def get_capabilities(capabilities: Sequence[str]) -> Sequence[str] | None: - """Return all capabilities supported if minimum required are present.""" - return [ - capability for capability in CAPABILITY_TO_ATTRIB if capability in capabilities - ] + entry_data = entry.runtime_data + async_add_entities( + SmartThingsBinarySensor( + entry_data.client, device, description, capability, attribute + ) + for device in entry_data.devices.values() + for capability, attribute_map in CAPABILITY_TO_SENSORS.items() + if capability in device.status[MAIN] + for attribute, description in attribute_map.items() + ) class SmartThingsBinarySensor(SmartThingsEntity, BinarySensorEntity): """Define a SmartThings Binary Sensor.""" - def __init__(self, device, attribute): + entity_description: SmartThingsBinarySensorEntityDescription + + def __init__( + self, + client: SmartThings, + device: FullDevice, + entity_description: SmartThingsBinarySensorEntityDescription, + capability: Capability, + attribute: Attribute, + ) -> None: """Init the class.""" - super().__init__(device) + super().__init__(client, device, {capability}) self._attribute = attribute - self._attr_name = f"{device.label} {attribute}" - self._attr_unique_id = f"{device.device_id}.{attribute}" - self._attr_device_class = ATTRIB_TO_CLASS[attribute] - self._attr_entity_category = ATTRIB_TO_ENTTIY_CATEGORY.get(attribute) + self.capability = capability + self.entity_description = entity_description + self._attr_name = f"{device.device.label} {attribute}" + self._attr_unique_id = f"{device.device.device_id}.{attribute}" @property - def is_on(self): + def is_on(self) -> bool: """Return true if the binary sensor is on.""" - return self._device.status.is_on(self._attribute) + return ( + self.get_attribute_value(self.capability, self._attribute) + == self.entity_description.is_on_key + ) diff --git a/homeassistant/components/smartthings/climate.py b/homeassistant/components/smartthings/climate.py index 238f8015620..2e05fb2fc4f 100644 --- a/homeassistant/components/smartthings/climate.py +++ b/homeassistant/components/smartthings/climate.py @@ -3,17 +3,15 @@ from __future__ import annotations import asyncio -from collections.abc import Iterable, Sequence import logging from typing import Any -from pysmartthings import Attribute, Capability +from pysmartthings import Attribute, Capability, Command, SmartThings from homeassistant.components.climate import ( ATTR_HVAC_MODE, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, - DOMAIN as CLIMATE_DOMAIN, SWING_BOTH, SWING_HORIZONTAL, SWING_OFF, @@ -23,12 +21,12 @@ from homeassistant.components.climate import ( HVACAction, HVACMode, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DATA_BROKERS, DOMAIN +from . import FullDevice, SmartThingsConfigEntry +from .const import MAIN from .entity import SmartThingsEntity ATTR_OPERATION_STATE = "operation_state" @@ -97,124 +95,106 @@ UNIT_MAP = {"C": UnitOfTemperature.CELSIUS, "F": UnitOfTemperature.FAHRENHEIT} _LOGGER = logging.getLogger(__name__) +AC_CAPABILITIES = [ + Capability.AIR_CONDITIONER_MODE, + Capability.AIR_CONDITIONER_FAN_MODE, + Capability.SWITCH, + Capability.TEMPERATURE_MEASUREMENT, + Capability.THERMOSTAT_COOLING_SETPOINT, +] + +THERMOSTAT_CAPABILITIES = [ + Capability.TEMPERATURE_MEASUREMENT, + Capability.THERMOSTAT_HEATING_SETPOINT, + Capability.THERMOSTAT_MODE, +] + + async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + entry: SmartThingsConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add climate entities for a config entry.""" - ac_capabilities = [ - Capability.air_conditioner_mode, - Capability.air_conditioner_fan_mode, - Capability.switch, - Capability.temperature_measurement, - Capability.thermostat_cooling_setpoint, + entry_data = entry.runtime_data + entities: list[ClimateEntity] = [ + SmartThingsAirConditioner(entry_data.client, device) + for device in entry_data.devices.values() + if all(capability in device.status[MAIN] for capability in AC_CAPABILITIES) ] - - broker = hass.data[DOMAIN][DATA_BROKERS][config_entry.entry_id] - entities: list[ClimateEntity] = [] - for device in broker.devices.values(): - if not broker.any_assigned(device.device_id, CLIMATE_DOMAIN): - continue - if all(capability in device.capabilities for capability in ac_capabilities): - entities.append(SmartThingsAirConditioner(device)) - else: - entities.append(SmartThingsThermostat(device)) - async_add_entities(entities, True) - - -def get_capabilities(capabilities: Sequence[str]) -> Sequence[str] | None: - """Return all capabilities supported if minimum required are present.""" - supported = [ - Capability.air_conditioner_mode, - Capability.demand_response_load_control, - Capability.air_conditioner_fan_mode, - Capability.switch, - Capability.thermostat, - Capability.thermostat_cooling_setpoint, - Capability.thermostat_fan_mode, - Capability.thermostat_heating_setpoint, - Capability.thermostat_mode, - Capability.thermostat_operating_state, - ] - # Can have this legacy/deprecated capability - if Capability.thermostat in capabilities: - return supported - # Or must have all of these thermostat capabilities - thermostat_capabilities = [ - Capability.temperature_measurement, - Capability.thermostat_heating_setpoint, - Capability.thermostat_mode, - ] - if all(capability in capabilities for capability in thermostat_capabilities): - return supported - # Or must have all of these A/C capabilities - ac_capabilities = [ - Capability.air_conditioner_mode, - Capability.air_conditioner_fan_mode, - Capability.switch, - Capability.temperature_measurement, - Capability.thermostat_cooling_setpoint, - ] - if all(capability in capabilities for capability in ac_capabilities): - return supported - return None + entities.extend( + SmartThingsThermostat(entry_data.client, device) + for device in entry_data.devices.values() + if all( + capability in device.status[MAIN] for capability in THERMOSTAT_CAPABILITIES + ) + ) + async_add_entities(entities) class SmartThingsThermostat(SmartThingsEntity, ClimateEntity): """Define a SmartThings climate entities.""" - def __init__(self, device): + def __init__(self, client: SmartThings, device: FullDevice) -> None: """Init the class.""" - super().__init__(device) + super().__init__( + client, + device, + { + Capability.THERMOSTAT_FAN_MODE, + Capability.THERMOSTAT_MODE, + Capability.TEMPERATURE_MEASUREMENT, + Capability.THERMOSTAT_HEATING_SETPOINT, + Capability.THERMOSTAT_OPERATING_STATE, + Capability.THERMOSTAT_COOLING_SETPOINT, + Capability.RELATIVE_HUMIDITY_MEASUREMENT, + }, + ) self._attr_supported_features = self._determine_features() - self._hvac_mode = None - self._hvac_modes = None - def _determine_features(self): + def _determine_features(self) -> ClimateEntityFeature: flags = ( ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE | ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON ) - if self._device.get_capability( - Capability.thermostat_fan_mode, Capability.thermostat + if self.get_attribute_value( + Capability.THERMOSTAT_FAN_MODE, Attribute.THERMOSTAT_FAN_MODE ): flags |= ClimateEntityFeature.FAN_MODE return flags async def async_set_fan_mode(self, fan_mode: str) -> None: """Set new target fan mode.""" - await self._device.set_thermostat_fan_mode(fan_mode, set_status=True) - - # State is set optimistically in the command above, therefore update - # the entity state ahead of receiving the confirming push updates - self.async_schedule_update_ha_state(True) + await self.execute_device_command( + Capability.THERMOSTAT_FAN_MODE, + Command.SET_THERMOSTAT_FAN_MODE, + argument=fan_mode, + ) async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set new target operation mode.""" - mode = STATE_TO_MODE[hvac_mode] - await self._device.set_thermostat_mode(mode, set_status=True) - - # State is set optimistically in the command above, therefore update - # the entity state ahead of receiving the confirming push updates - self.async_schedule_update_ha_state(True) + await self.execute_device_command( + Capability.THERMOSTAT_MODE, + Command.SET_THERMOSTAT_MODE, + argument=STATE_TO_MODE[hvac_mode], + ) async def async_set_temperature(self, **kwargs: Any) -> None: """Set new operation mode and target temperatures.""" + hvac_mode = self.hvac_mode # Operation state if operation_state := kwargs.get(ATTR_HVAC_MODE): - mode = STATE_TO_MODE[operation_state] - await self._device.set_thermostat_mode(mode, set_status=True) - await self.async_update() + await self.async_set_hvac_mode(operation_state) + hvac_mode = operation_state # Heat/cool setpoint heating_setpoint = None cooling_setpoint = None - if self.hvac_mode == HVACMode.HEAT: + if hvac_mode == HVACMode.HEAT: heating_setpoint = kwargs.get(ATTR_TEMPERATURE) - elif self.hvac_mode == HVACMode.COOL: + elif hvac_mode == HVACMode.COOL: cooling_setpoint = kwargs.get(ATTR_TEMPERATURE) else: heating_setpoint = kwargs.get(ATTR_TARGET_TEMP_LOW) @@ -222,135 +202,145 @@ class SmartThingsThermostat(SmartThingsEntity, ClimateEntity): tasks = [] if heating_setpoint is not None: tasks.append( - self._device.set_heating_setpoint( - round(heating_setpoint, 3), set_status=True + self.execute_device_command( + Capability.THERMOSTAT_HEATING_SETPOINT, + Command.SET_HEATING_SETPOINT, + argument=round(heating_setpoint, 3), ) ) if cooling_setpoint is not None: tasks.append( - self._device.set_cooling_setpoint( - round(cooling_setpoint, 3), set_status=True + self.execute_device_command( + Capability.THERMOSTAT_COOLING_SETPOINT, + Command.SET_COOLING_SETPOINT, + argument=round(cooling_setpoint, 3), ) ) await asyncio.gather(*tasks) - # State is set optimistically in the commands above, therefore update - # the entity state ahead of receiving the confirming push updates - self.async_schedule_update_ha_state(True) - - async def async_update(self) -> None: - """Update the attributes of the climate device.""" - thermostat_mode = self._device.status.thermostat_mode - self._hvac_mode = MODE_TO_STATE.get(thermostat_mode) - if self._hvac_mode is None: - _LOGGER.debug( - "Device %s (%s) returned an invalid hvac mode: %s", - self._device.label, - self._device.device_id, - thermostat_mode, - ) - - modes = set() - supported_modes = self._device.status.supported_thermostat_modes - if isinstance(supported_modes, Iterable): - for mode in supported_modes: - if (state := MODE_TO_STATE.get(mode)) is not None: - modes.add(state) - else: - _LOGGER.debug( - ( - "Device %s (%s) returned an invalid supported thermostat" - " mode: %s" - ), - self._device.label, - self._device.device_id, - mode, - ) - else: - _LOGGER.debug( - "Device %s (%s) returned invalid supported thermostat modes: %s", - self._device.label, - self._device.device_id, - supported_modes, - ) - self._hvac_modes = list(modes) - @property - def current_humidity(self): + def current_humidity(self) -> float | None: """Return the current humidity.""" - return self._device.status.humidity + if self.supports_capability(Capability.RELATIVE_HUMIDITY_MEASUREMENT): + return self.get_attribute_value( + Capability.RELATIVE_HUMIDITY_MEASUREMENT, Attribute.HUMIDITY + ) + return None @property - def current_temperature(self): + def current_temperature(self) -> float | None: """Return the current temperature.""" - return self._device.status.temperature + return self.get_attribute_value( + Capability.TEMPERATURE_MEASUREMENT, Attribute.TEMPERATURE + ) @property - def fan_mode(self): + def fan_mode(self) -> str | None: """Return the fan setting.""" - return self._device.status.thermostat_fan_mode + return self.get_attribute_value( + Capability.THERMOSTAT_FAN_MODE, Attribute.THERMOSTAT_FAN_MODE + ) @property - def fan_modes(self): + def fan_modes(self) -> list[str]: """Return the list of available fan modes.""" - return self._device.status.supported_thermostat_fan_modes + return self.get_attribute_value( + Capability.THERMOSTAT_FAN_MODE, Attribute.SUPPORTED_THERMOSTAT_FAN_MODES + ) @property def hvac_action(self) -> HVACAction | None: """Return the current running hvac operation if supported.""" return OPERATING_STATE_TO_ACTION.get( - self._device.status.thermostat_operating_state + self.get_attribute_value( + Capability.THERMOSTAT_OPERATING_STATE, + Attribute.THERMOSTAT_OPERATING_STATE, + ) ) @property - def hvac_mode(self) -> HVACMode: + def hvac_mode(self) -> HVACMode | None: """Return current operation ie. heat, cool, idle.""" - return self._hvac_mode + return MODE_TO_STATE.get( + self.get_attribute_value( + Capability.THERMOSTAT_MODE, Attribute.THERMOSTAT_MODE + ) + ) @property def hvac_modes(self) -> list[HVACMode]: """Return the list of available operation modes.""" - return self._hvac_modes + return [ + state + for mode in self.get_attribute_value( + Capability.THERMOSTAT_MODE, Attribute.SUPPORTED_THERMOSTAT_MODES + ) + if (state := AC_MODE_TO_STATE.get(mode)) is not None + ] @property - def target_temperature(self): + def target_temperature(self) -> float | None: """Return the temperature we try to reach.""" if self.hvac_mode == HVACMode.COOL: - return self._device.status.cooling_setpoint + return self.get_attribute_value( + Capability.THERMOSTAT_COOLING_SETPOINT, Attribute.COOLING_SETPOINT + ) if self.hvac_mode == HVACMode.HEAT: - return self._device.status.heating_setpoint + return self.get_attribute_value( + Capability.THERMOSTAT_HEATING_SETPOINT, Attribute.HEATING_SETPOINT + ) return None @property - def target_temperature_high(self): + def target_temperature_high(self) -> float | None: """Return the highbound target temperature we try to reach.""" if self.hvac_mode == HVACMode.HEAT_COOL: - return self._device.status.cooling_setpoint + return self.get_attribute_value( + Capability.THERMOSTAT_COOLING_SETPOINT, Attribute.COOLING_SETPOINT + ) return None @property def target_temperature_low(self): """Return the lowbound target temperature we try to reach.""" if self.hvac_mode == HVACMode.HEAT_COOL: - return self._device.status.heating_setpoint + return self.get_attribute_value( + Capability.THERMOSTAT_HEATING_SETPOINT, Attribute.HEATING_SETPOINT + ) return None @property - def temperature_unit(self): + def temperature_unit(self) -> str: """Return the unit of measurement.""" - return UNIT_MAP.get(self._device.status.attributes[Attribute.temperature].unit) + unit = self._internal_state[Capability.TEMPERATURE_MEASUREMENT][ + Attribute.TEMPERATURE + ].unit + assert unit + return UNIT_MAP[unit] class SmartThingsAirConditioner(SmartThingsEntity, ClimateEntity): """Define a SmartThings Air Conditioner.""" - _hvac_modes: list[HVACMode] + _attr_preset_mode = None - def __init__(self, device) -> None: + def __init__(self, client: SmartThings, device: FullDevice) -> None: """Init the class.""" - super().__init__(device) - self._hvac_modes = [] - self._attr_preset_mode = None + super().__init__( + client, + device, + { + Capability.AIR_CONDITIONER_MODE, + Capability.SWITCH, + Capability.FAN_OSCILLATION_MODE, + Capability.AIR_CONDITIONER_FAN_MODE, + Capability.THERMOSTAT_COOLING_SETPOINT, + Capability.TEMPERATURE_MEASUREMENT, + Capability.CUSTOM_AIR_CONDITIONER_OPTIONAL_MODE, + Capability.DEMAND_RESPONSE_LOAD_CONTROL, + }, + ) + self._attr_hvac_modes = self._determine_hvac_modes() self._attr_preset_modes = self._determine_preset_modes() self._attr_swing_modes = self._determine_swing_modes() self._attr_supported_features = self._determine_supported_features() @@ -362,7 +352,7 @@ class SmartThingsAirConditioner(SmartThingsEntity, ClimateEntity): | ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON ) - if self._device.get_capability(Capability.fan_oscillation_mode): + if self.supports_capability(Capability.FAN_OSCILLATION_MODE): features |= ClimateEntityFeature.SWING_MODE if (self._attr_preset_modes is not None) and len(self._attr_preset_modes) > 0: features |= ClimateEntityFeature.PRESET_MODE @@ -370,14 +360,11 @@ class SmartThingsAirConditioner(SmartThingsEntity, ClimateEntity): async def async_set_fan_mode(self, fan_mode: str) -> None: """Set new target fan mode.""" - await self._device.set_fan_mode(fan_mode, set_status=True) - - # setting the fan must reset the preset mode (it deactivates the windFree function) - self._attr_preset_mode = None - - # State is set optimistically in the command above, therefore update - # the entity state ahead of receiving the confirming push updates - self.async_write_ha_state() + await self.execute_device_command( + Capability.AIR_CONDITIONER_FAN_MODE, + Command.SET_FAN_MODE, + argument=fan_mode, + ) async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set new target operation mode.""" @@ -386,23 +373,27 @@ class SmartThingsAirConditioner(SmartThingsEntity, ClimateEntity): return tasks = [] # Turn on the device if it's off before setting mode. - if not self._device.status.switch: - tasks.append(self._device.switch_on(set_status=True)) + if self.get_attribute_value(Capability.SWITCH, Attribute.SWITCH) == "off": + tasks.append(self.async_turn_on()) mode = STATE_TO_AC_MODE[hvac_mode] # If new hvac_mode is HVAC_MODE_FAN_ONLY and AirConditioner support "wind" mode the AirConditioner new mode has to be "wind" # The conversion make the mode change working # The conversion is made only for device that wrongly has capability "wind" instead "fan_only" if hvac_mode == HVACMode.FAN_ONLY: - supported_modes = self._device.status.supported_ac_modes - if WIND in supported_modes: + if WIND in self.get_attribute_value( + Capability.AIR_CONDITIONER_MODE, Attribute.SUPPORTED_AC_MODES + ): mode = WIND - tasks.append(self._device.set_air_conditioner_mode(mode, set_status=True)) + tasks.append( + self.execute_device_command( + Capability.AIR_CONDITIONER_MODE, + Command.SET_AIR_CONDITIONER_MODE, + argument=mode, + ) + ) await asyncio.gather(*tasks) - # State is set optimistically in the command above, therefore update - # the entity state ahead of receiving the confirming push updates - self.async_write_ha_state() async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" @@ -410,53 +401,44 @@ class SmartThingsAirConditioner(SmartThingsEntity, ClimateEntity): # operation mode if operation_mode := kwargs.get(ATTR_HVAC_MODE): if operation_mode == HVACMode.OFF: - tasks.append(self._device.switch_off(set_status=True)) + tasks.append(self.async_turn_off()) else: - if not self._device.status.switch: - tasks.append(self._device.switch_on(set_status=True)) + if ( + self.get_attribute_value(Capability.SWITCH, Attribute.SWITCH) + == "off" + ): + tasks.append(self.async_turn_on()) tasks.append(self.async_set_hvac_mode(operation_mode)) # temperature tasks.append( - self._device.set_cooling_setpoint(kwargs[ATTR_TEMPERATURE], set_status=True) + self.execute_device_command( + Capability.THERMOSTAT_COOLING_SETPOINT, + Command.SET_COOLING_SETPOINT, + argument=kwargs[ATTR_TEMPERATURE], + ) ) await asyncio.gather(*tasks) - # State is set optimistically in the command above, therefore update - # the entity state ahead of receiving the confirming push updates - self.async_write_ha_state() async def async_turn_on(self) -> None: """Turn device on.""" - await self._device.switch_on(set_status=True) - # State is set optimistically in the command above, therefore update - # the entity state ahead of receiving the confirming push updates - self.async_write_ha_state() + await self.execute_device_command( + Capability.SWITCH, + Command.ON, + ) async def async_turn_off(self) -> None: """Turn device off.""" - await self._device.switch_off(set_status=True) - # State is set optimistically in the command above, therefore update - # the entity state ahead of receiving the confirming push updates - self.async_write_ha_state() - - async def async_update(self) -> None: - """Update the calculated fields of the AC.""" - modes = {HVACMode.OFF} - for mode in self._device.status.supported_ac_modes: - if (state := AC_MODE_TO_STATE.get(mode)) is not None: - modes.add(state) - else: - _LOGGER.debug( - "Device %s (%s) returned an invalid supported AC mode: %s", - self._device.label, - self._device.device_id, - mode, - ) - self._hvac_modes = list(modes) + await self.execute_device_command( + Capability.SWITCH, + Command.OFF, + ) @property def current_temperature(self) -> float | None: """Return the current temperature.""" - return self._device.status.temperature + return self.get_attribute_value( + Capability.TEMPERATURE_MEASUREMENT, Attribute.TEMPERATURE + ) @property def extra_state_attributes(self) -> dict[str, Any]: @@ -465,100 +447,114 @@ class SmartThingsAirConditioner(SmartThingsEntity, ClimateEntity): Include attributes from the Demand Response Load Control (drlc) and Power Consumption capabilities. """ - attributes = [ - "drlc_status_duration", - "drlc_status_level", - "drlc_status_start", - "drlc_status_override", - ] - state_attributes = {} - for attribute in attributes: - value = getattr(self._device.status, attribute) - if value is not None: - state_attributes[attribute] = value - return state_attributes + drlc_status = self.get_attribute_value( + Capability.DEMAND_RESPONSE_LOAD_CONTROL, + Attribute.DEMAND_RESPONSE_LOAD_CONTROL_STATUS, + ) + return { + "drlc_status_duration": drlc_status["duration"], + "drlc_status_level": drlc_status["drlcLevel"], + "drlc_status_start": drlc_status["start"], + "drlc_status_override": drlc_status["override"], + } @property def fan_mode(self) -> str: """Return the fan setting.""" - return self._device.status.fan_mode + return self.get_attribute_value( + Capability.AIR_CONDITIONER_FAN_MODE, Attribute.FAN_MODE + ) @property def fan_modes(self) -> list[str]: """Return the list of available fan modes.""" - return self._device.status.supported_ac_fan_modes + return self.get_attribute_value( + Capability.AIR_CONDITIONER_FAN_MODE, Attribute.SUPPORTED_AC_FAN_MODES + ) @property def hvac_mode(self) -> HVACMode | None: """Return current operation ie. heat, cool, idle.""" - if not self._device.status.switch: + if self.get_attribute_value(Capability.SWITCH, Attribute.SWITCH) == "off": return HVACMode.OFF - return AC_MODE_TO_STATE.get(self._device.status.air_conditioner_mode) - - @property - def hvac_modes(self) -> list[HVACMode]: - """Return the list of available operation modes.""" - return self._hvac_modes + return AC_MODE_TO_STATE.get( + self.get_attribute_value( + Capability.AIR_CONDITIONER_MODE, Attribute.AIR_CONDITIONER_MODE + ) + ) @property def target_temperature(self) -> float: """Return the temperature we try to reach.""" - return self._device.status.cooling_setpoint + return self.get_attribute_value( + Capability.THERMOSTAT_COOLING_SETPOINT, Attribute.COOLING_SETPOINT + ) @property def temperature_unit(self) -> str: """Return the unit of measurement.""" - return UNIT_MAP[self._device.status.attributes[Attribute.temperature].unit] + unit = self._internal_state[Capability.TEMPERATURE_MEASUREMENT][ + Attribute.TEMPERATURE + ].unit + assert unit + return UNIT_MAP[unit] def _determine_swing_modes(self) -> list[str] | None: """Return the list of available swing modes.""" - supported_swings = None - supported_modes = self._device.status.attributes[ - Attribute.supported_fan_oscillation_modes - ][0] - if supported_modes is not None: - supported_swings = [ - FAN_OSCILLATION_TO_SWING.get(m, SWING_OFF) for m in supported_modes - ] - return supported_swings + if ( + supported_modes := self.get_attribute_value( + Capability.FAN_OSCILLATION_MODE, + Attribute.SUPPORTED_FAN_OSCILLATION_MODES, + ) + ) is None: + return None + return [FAN_OSCILLATION_TO_SWING.get(m, SWING_OFF) for m in supported_modes] async def async_set_swing_mode(self, swing_mode: str) -> None: """Set swing mode.""" - fan_oscillation_mode = SWING_TO_FAN_OSCILLATION[swing_mode] - await self._device.set_fan_oscillation_mode(fan_oscillation_mode) - - # setting the fan must reset the preset mode (it deactivates the windFree function) - self._attr_preset_mode = None - - self.async_schedule_update_ha_state(True) + await self.execute_device_command( + Capability.FAN_OSCILLATION_MODE, + Command.SET_FAN_OSCILLATION_MODE, + argument=SWING_TO_FAN_OSCILLATION[swing_mode], + ) @property def swing_mode(self) -> str: """Return the swing setting.""" return FAN_OSCILLATION_TO_SWING.get( - self._device.status.fan_oscillation_mode, SWING_OFF + self.get_attribute_value( + Capability.FAN_OSCILLATION_MODE, Attribute.FAN_OSCILLATION_MODE + ), + SWING_OFF, ) def _determine_preset_modes(self) -> list[str] | None: """Return a list of available preset modes.""" - supported_modes: list | None = self._device.status.attributes[ - "supportedAcOptionalMode" - ].value - if supported_modes and WINDFREE in supported_modes: - return [WINDFREE] + if self.supports_capability(Capability.CUSTOM_AIR_CONDITIONER_OPTIONAL_MODE): + supported_modes = self.get_attribute_value( + Capability.CUSTOM_AIR_CONDITIONER_OPTIONAL_MODE, + Attribute.SUPPORTED_AC_OPTIONAL_MODE, + ) + if supported_modes and WINDFREE in supported_modes: + return [WINDFREE] return None async def async_set_preset_mode(self, preset_mode: str) -> None: """Set special modes (currently only windFree is supported).""" - result = await self._device.command( - "main", - "custom.airConditionerOptionalMode", - "setAcOptionalMode", - [preset_mode], + await self.execute_device_command( + Capability.CUSTOM_AIR_CONDITIONER_OPTIONAL_MODE, + Command.SET_AC_OPTIONAL_MODE, + argument=preset_mode, ) - if result: - self._device.status.update_attribute_value("acOptionalMode", preset_mode) - self._attr_preset_mode = preset_mode - - self.async_write_ha_state() + def _determine_hvac_modes(self) -> list[HVACMode]: + """Determine the supported HVAC modes.""" + modes = [HVACMode.OFF] + modes.extend( + state + for mode in self.get_attribute_value( + Capability.AIR_CONDITIONER_MODE, Attribute.SUPPORTED_AC_MODES + ) + if (state := AC_MODE_TO_STATE.get(mode)) is not None + ) + return modes diff --git a/homeassistant/components/smartthings/config_flow.py b/homeassistant/components/smartthings/config_flow.py index 7b49854740a..bcd2ddc192b 100644 --- a/homeassistant/components/smartthings/config_flow.py +++ b/homeassistant/components/smartthings/config_flow.py @@ -1,298 +1,83 @@ """Config flow to configure SmartThings.""" from collections.abc import Mapping -from http import HTTPStatus import logging from typing import Any -from aiohttp import ClientResponseError -from pysmartthings import APIResponseError, AppOAuth, SmartThings -from pysmartthings.installedapp import format_install_url -import voluptuous as vol +from pysmartthings import SmartThings -from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_ACCESS_TOKEN, CONF_CLIENT_ID, CONF_CLIENT_SECRET +from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.config_entry_oauth2_flow import AbstractOAuth2FlowHandler -from .const import ( - APP_OAUTH_CLIENT_NAME, - APP_OAUTH_SCOPES, - CONF_APP_ID, - CONF_INSTALLED_APP_ID, - CONF_LOCATION_ID, - CONF_REFRESH_TOKEN, - DOMAIN, - VAL_UID_MATCHER, -) -from .smartapp import ( - create_app, - find_app, - format_unique_id, - get_webhook_url, - setup_smartapp, - setup_smartapp_endpoint, - update_app, - validate_webhook_requirements, -) +from .const import CONF_LOCATION_ID, DOMAIN, OLD_DATA, SCOPES _LOGGER = logging.getLogger(__name__) -class SmartThingsFlowHandler(ConfigFlow, domain=DOMAIN): +class SmartThingsConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN): """Handle configuration of SmartThings integrations.""" - VERSION = 2 + VERSION = 3 + DOMAIN = DOMAIN - api: SmartThings - app_id: str - location_id: str + @property + def logger(self) -> logging.Logger: + """Return logger.""" + return logging.getLogger(__name__) - def __init__(self) -> None: - """Create a new instance of the flow handler.""" - self.access_token: str | None = None - self.oauth_client_secret = None - self.oauth_client_id = None - self.installed_app_id = None - self.refresh_token = None - self.endpoints_initialized = False + @property + def extra_authorize_data(self) -> dict[str, Any]: + """Extra data that needs to be appended to the authorize url.""" + return {"scope": " ".join(SCOPES)} - async def async_step_import(self, import_data: None) -> ConfigFlowResult: - """Occurs when a previously entry setup fails and is re-initiated.""" - return await self.async_step_user(import_data) + async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResult: + """Create an entry for SmartThings.""" + client = SmartThings(session=async_get_clientsession(self.hass)) + client.authenticate(data[CONF_TOKEN][CONF_ACCESS_TOKEN]) + locations = await client.get_locations() + location = locations[0] + # We pick to use the location id as unique id rather than the installed app id + # as the installed app id could change with the right settings in the SmartApp + # or the app used to sign in changed for any reason. + await self.async_set_unique_id(location.location_id) + if self.source != SOURCE_REAUTH: + self._abort_if_unique_id_configured() - async def async_step_user( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Validate and confirm webhook setup.""" - if not self.endpoints_initialized: - self.endpoints_initialized = True - await setup_smartapp_endpoint( - self.hass, len(self._async_current_entries()) == 0 + return self.async_create_entry( + title=location.name, + data={**data, CONF_LOCATION_ID: location.location_id}, ) - webhook_url = get_webhook_url(self.hass) - # Abort if the webhook is invalid - if not validate_webhook_requirements(self.hass): - return self.async_abort( - reason="invalid_webhook_url", - description_placeholders={ - "webhook_url": webhook_url, - "component_url": ( - "https://www.home-assistant.io/integrations/smartthings/" - ), + if (entry := self._get_reauth_entry()) and CONF_TOKEN not in entry.data: + if entry.data[OLD_DATA][CONF_LOCATION_ID] != location.location_id: + return self.async_abort(reason="reauth_location_mismatch") + return self.async_update_reload_and_abort( + self._get_reauth_entry(), + data_updates={ + **data, + CONF_LOCATION_ID: location.location_id, }, + unique_id=location.location_id, ) - - # Show the confirmation - if user_input is None: - return self.async_show_form( - step_id="user", - description_placeholders={"webhook_url": webhook_url}, - ) - - # Show the next screen - return await self.async_step_pat() - - async def async_step_pat( - self, user_input: dict[str, str] | None = None - ) -> ConfigFlowResult: - """Get the Personal Access Token and validate it.""" - errors: dict[str, str] = {} - if user_input is None or CONF_ACCESS_TOKEN not in user_input: - return self._show_step_pat(errors) - - self.access_token = user_input[CONF_ACCESS_TOKEN] - - # Ensure token is a UUID - if not VAL_UID_MATCHER.match(self.access_token): - errors[CONF_ACCESS_TOKEN] = "token_invalid_format" - return self._show_step_pat(errors) - - # Setup end-point - self.api = SmartThings(async_get_clientsession(self.hass), self.access_token) - try: - app = await find_app(self.hass, self.api) - if app: - await app.refresh() # load all attributes - await update_app(self.hass, app) - # Find an existing entry to copy the oauth client - existing = next( - ( - entry - for entry in self._async_current_entries() - if entry.data[CONF_APP_ID] == app.app_id - ), - None, - ) - if existing: - self.oauth_client_id = existing.data[CONF_CLIENT_ID] - self.oauth_client_secret = existing.data[CONF_CLIENT_SECRET] - else: - # Get oauth client id/secret by regenerating it - app_oauth = AppOAuth(app.app_id) - app_oauth.client_name = APP_OAUTH_CLIENT_NAME - app_oauth.scope.extend(APP_OAUTH_SCOPES) - client = await self.api.generate_app_oauth(app_oauth) - self.oauth_client_secret = client.client_secret - self.oauth_client_id = client.client_id - else: - app, client = await create_app(self.hass, self.api) - self.oauth_client_secret = client.client_secret - self.oauth_client_id = client.client_id - setup_smartapp(self.hass, app) - self.app_id = app.app_id - - except APIResponseError as ex: - if ex.is_target_error(): - errors["base"] = "webhook_error" - else: - errors["base"] = "app_setup_error" - _LOGGER.exception( - "API error setting up the SmartApp: %s", ex.raw_error_response - ) - return self._show_step_pat(errors) - except ClientResponseError as ex: - if ex.status == HTTPStatus.UNAUTHORIZED: - errors[CONF_ACCESS_TOKEN] = "token_unauthorized" - _LOGGER.debug( - "Unauthorized error received setting up SmartApp", exc_info=True - ) - elif ex.status == HTTPStatus.FORBIDDEN: - errors[CONF_ACCESS_TOKEN] = "token_forbidden" - _LOGGER.debug( - "Forbidden error received setting up SmartApp", exc_info=True - ) - else: - errors["base"] = "app_setup_error" - _LOGGER.exception("Unexpected error setting up the SmartApp") - return self._show_step_pat(errors) - except Exception: - errors["base"] = "app_setup_error" - _LOGGER.exception("Unexpected error setting up the SmartApp") - return self._show_step_pat(errors) - - return await self.async_step_select_location() - - async def async_step_select_location( - self, user_input: dict[str, str] | None = None - ) -> ConfigFlowResult: - """Ask user to select the location to setup.""" - if user_input is None or CONF_LOCATION_ID not in user_input: - # Get available locations - existing_locations = [ - entry.data[CONF_LOCATION_ID] for entry in self._async_current_entries() - ] - locations = await self.api.locations() - locations_options = { - location.location_id: location.name - for location in locations - if location.location_id not in existing_locations - } - if not locations_options: - return self.async_abort(reason="no_available_locations") - - return self.async_show_form( - step_id="select_location", - data_schema=vol.Schema( - {vol.Required(CONF_LOCATION_ID): vol.In(locations_options)} - ), - ) - - self.location_id = user_input[CONF_LOCATION_ID] - await self.async_set_unique_id(format_unique_id(self.app_id, self.location_id)) - return await self.async_step_authorize() - - async def async_step_authorize( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Wait for the user to authorize the app installation.""" - user_input = {} if user_input is None else user_input - self.installed_app_id = user_input.get(CONF_INSTALLED_APP_ID) - self.refresh_token = user_input.get(CONF_REFRESH_TOKEN) - if self.installed_app_id is None: - # Launch the external setup URL - url = format_install_url(self.app_id, self.location_id) - return self.async_external_step(step_id="authorize", url=url) - - next_step_id = "install" - if self.source == SOURCE_REAUTH: - next_step_id = "update" - return self.async_external_step_done(next_step_id=next_step_id) - - def _show_step_pat(self, errors): - if self.access_token is None: - # Get the token from an existing entry to make it easier to setup multiple locations. - self.access_token = next( - ( - entry.data.get(CONF_ACCESS_TOKEN) - for entry in self._async_current_entries() - ), - None, - ) - - return self.async_show_form( - step_id="pat", - data_schema=vol.Schema( - {vol.Required(CONF_ACCESS_TOKEN, default=self.access_token): str} - ), - errors=errors, - description_placeholders={ - "token_url": "https://account.smartthings.com/tokens", - "component_url": ( - "https://www.home-assistant.io/integrations/smartthings/" - ), - }, + self._abort_if_unique_id_mismatch(reason="reauth_account_mismatch") + return self.async_update_reload_and_abort( + self._get_reauth_entry(), data_updates=data ) async def async_step_reauth( self, entry_data: Mapping[str, Any] ) -> ConfigFlowResult: - """Handle re-authentication of an existing config entry.""" + """Perform reauth upon migration of old entries.""" return await self.async_step_reauth_confirm() async def async_step_reauth_confirm( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Handle re-authentication of an existing config entry.""" + """Confirm reauth dialog.""" if user_input is None: - return self.async_show_form(step_id="reauth_confirm") - self.app_id = self._get_reauth_entry().data[CONF_APP_ID] - self.location_id = self._get_reauth_entry().data[CONF_LOCATION_ID] - self._set_confirm_only() - return await self.async_step_authorize() - - async def async_step_update( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Handle re-authentication of an existing config entry.""" - return await self.async_step_update_confirm() - - async def async_step_update_confirm( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Handle re-authentication of an existing config entry.""" - if user_input is None: - self._set_confirm_only() - return self.async_show_form(step_id="update_confirm") - entry = self._get_reauth_entry() - return self.async_update_reload_and_abort( - entry, data_updates={CONF_REFRESH_TOKEN: self.refresh_token} - ) - - async def async_step_install( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Create a config entry at completion of a flow and authorization of the app.""" - data = { - CONF_ACCESS_TOKEN: self.access_token, - CONF_REFRESH_TOKEN: self.refresh_token, - CONF_CLIENT_ID: self.oauth_client_id, - CONF_CLIENT_SECRET: self.oauth_client_secret, - CONF_LOCATION_ID: self.location_id, - CONF_APP_ID: self.app_id, - CONF_INSTALLED_APP_ID: self.installed_app_id, - } - - location = await self.api.location(data[CONF_LOCATION_ID]) - - return self.async_create_entry(title=location.name, data=data) + return self.async_show_form( + step_id="reauth_confirm", + ) + return await self.async_step_user() diff --git a/homeassistant/components/smartthings/const.py b/homeassistant/components/smartthings/const.py index e50837697e7..c39d225dd09 100644 --- a/homeassistant/components/smartthings/const.py +++ b/homeassistant/components/smartthings/const.py @@ -1,15 +1,23 @@ """Constants used by the SmartThings component and platforms.""" -from datetime import timedelta -import re - -from homeassistant.const import Platform - DOMAIN = "smartthings" -APP_OAUTH_CLIENT_NAME = "Home Assistant" -APP_OAUTH_SCOPES = ["r:devices:*"] -APP_NAME_PREFIX = "homeassistant." +SCOPES = [ + "r:devices:*", + "w:devices:*", + "x:devices:*", + "r:hubs:*", + "r:locations:*", + "w:locations:*", + "x:locations:*", + "r:scenes:*", + "x:scenes:*", + "r:rules:*", + "w:rules:*", + "r:installedapps", + "w:installedapps", + "sse", +] CONF_APP_ID = "app_id" CONF_CLOUDHOOK_URL = "cloudhook_url" @@ -18,41 +26,5 @@ CONF_INSTANCE_ID = "instance_id" CONF_LOCATION_ID = "location_id" CONF_REFRESH_TOKEN = "refresh_token" -DATA_MANAGER = "manager" -DATA_BROKERS = "brokers" -EVENT_BUTTON = "smartthings.button" - -SIGNAL_SMARTTHINGS_UPDATE = "smartthings_update" -SIGNAL_SMARTAPP_PREFIX = "smartthings_smartap_" - -SETTINGS_INSTANCE_ID = "hassInstanceId" - -SUBSCRIPTION_WARNING_LIMIT = 40 - -STORAGE_KEY = DOMAIN -STORAGE_VERSION = 1 - -# Ordered 'specific to least-specific platform' in order for capabilities -# to be drawn-down and represented by the most appropriate platform. -PLATFORMS = [ - Platform.BINARY_SENSOR, - Platform.CLIMATE, - Platform.COVER, - Platform.FAN, - Platform.LIGHT, - Platform.LOCK, - Platform.SCENE, - Platform.SENSOR, - Platform.SWITCH, -] - -IGNORED_CAPABILITIES = [ - "execute", - "healthCheck", - "ocf", -] - -TOKEN_REFRESH_INTERVAL = timedelta(days=14) - -VAL_UID = "^(?:([0-9a-fA-F]{32})|([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}))$" -VAL_UID_MATCHER = re.compile(VAL_UID) +MAIN = "main" +OLD_DATA = "old_data" diff --git a/homeassistant/components/smartthings/cover.py b/homeassistant/components/smartthings/cover.py index daf9b0f38f8..97a7456d132 100644 --- a/homeassistant/components/smartthings/cover.py +++ b/homeassistant/components/smartthings/cover.py @@ -2,25 +2,23 @@ from __future__ import annotations -from collections.abc import Sequence from typing import Any -from pysmartthings import Attribute, Capability +from pysmartthings import Attribute, Capability, Command, SmartThings from homeassistant.components.cover import ( ATTR_POSITION, - DOMAIN as COVER_DOMAIN, CoverDeviceClass, CoverEntity, CoverEntityFeature, CoverState, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_BATTERY_LEVEL from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DATA_BROKERS, DOMAIN +from . import FullDevice, SmartThingsConfigEntry +from .const import MAIN from .entity import SmartThingsEntity VALUE_TO_STATE = { @@ -32,114 +30,99 @@ VALUE_TO_STATE = { "unknown": None, } +CAPABILITIES = (Capability.WINDOW_SHADE, Capability.DOOR_CONTROL) + async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + entry: SmartThingsConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add covers for a config entry.""" - broker = hass.data[DOMAIN][DATA_BROKERS][config_entry.entry_id] + entry_data = entry.runtime_data async_add_entities( - [ - SmartThingsCover(device) - for device in broker.devices.values() - if broker.any_assigned(device.device_id, COVER_DOMAIN) - ], - True, + SmartThingsCover(entry_data.client, device, capability) + for device in entry_data.devices.values() + for capability in device.status[MAIN] + if capability in CAPABILITIES ) -def get_capabilities(capabilities: Sequence[str]) -> Sequence[str] | None: - """Return all capabilities supported if minimum required are present.""" - min_required = [ - Capability.door_control, - Capability.garage_door_control, - Capability.window_shade, - ] - # Must have one of the min_required - if any(capability in capabilities for capability in min_required): - # Return all capabilities supported/consumed - return [ - *min_required, - Capability.battery, - Capability.switch_level, - Capability.window_shade_level, - ] - - return None - - class SmartThingsCover(SmartThingsEntity, CoverEntity): """Define a SmartThings cover.""" - def __init__(self, device): + _state: CoverState | None = None + + def __init__( + self, client: SmartThings, device: FullDevice, capability: Capability + ) -> None: """Initialize the cover class.""" - super().__init__(device) - self._current_cover_position = None - self._state = None + super().__init__( + client, + device, + { + capability, + Capability.BATTERY, + Capability.WINDOW_SHADE_LEVEL, + Capability.SWITCH_LEVEL, + }, + ) + self.capability = capability self._attr_supported_features = ( CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE ) - if ( - Capability.switch_level in device.capabilities - or Capability.window_shade_level in device.capabilities - ): + if self.supports_capability(Capability.WINDOW_SHADE_LEVEL): + self.level_capability = Capability.WINDOW_SHADE_LEVEL + self.level_command = Command.SET_SHADE_LEVEL + else: + self.level_capability = Capability.SWITCH_LEVEL + self.level_command = Command.SET_LEVEL + if self.supports_capability( + Capability.SWITCH_LEVEL + ) or self.supports_capability(Capability.WINDOW_SHADE_LEVEL): self._attr_supported_features |= CoverEntityFeature.SET_POSITION - if Capability.door_control in device.capabilities: + if self.supports_capability(Capability.DOOR_CONTROL): self._attr_device_class = CoverDeviceClass.DOOR - elif Capability.window_shade in device.capabilities: + elif self.supports_capability(Capability.WINDOW_SHADE): self._attr_device_class = CoverDeviceClass.SHADE - elif Capability.garage_door_control in device.capabilities: - self._attr_device_class = CoverDeviceClass.GARAGE async def async_close_cover(self, **kwargs: Any) -> None: """Close cover.""" - # Same command for all 3 supported capabilities - await self._device.close(set_status=True) - # State is set optimistically in the commands above, therefore update - # the entity state ahead of receiving the confirming push updates - self.async_schedule_update_ha_state(True) + await self.execute_device_command(self.capability, Command.CLOSE) async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" - # Same for all capability types - await self._device.open(set_status=True) - # State is set optimistically in the commands above, therefore update - # the entity state ahead of receiving the confirming push updates - self.async_schedule_update_ha_state(True) + await self.execute_device_command(self.capability, Command.OPEN) async def async_set_cover_position(self, **kwargs: Any) -> None: """Move the cover to a specific position.""" - if not self.supported_features & CoverEntityFeature.SET_POSITION: - return - # Do not set_status=True as device will report progress. - if Capability.window_shade_level in self._device.capabilities: - await self._device.set_window_shade_level( - kwargs[ATTR_POSITION], set_status=False - ) - else: - await self._device.set_level(kwargs[ATTR_POSITION], set_status=False) + await self.execute_device_command( + self.level_capability, + self.level_command, + argument=kwargs[ATTR_POSITION], + ) - async def async_update(self) -> None: + def _update_attr(self) -> None: """Update the attrs of the cover.""" - if Capability.door_control in self._device.capabilities: - self._state = VALUE_TO_STATE.get(self._device.status.door) - elif Capability.window_shade in self._device.capabilities: - self._state = VALUE_TO_STATE.get(self._device.status.window_shade) - elif Capability.garage_door_control in self._device.capabilities: - self._state = VALUE_TO_STATE.get(self._device.status.door) + attribute = { + Capability.WINDOW_SHADE: Attribute.WINDOW_SHADE, + Capability.DOOR_CONTROL: Attribute.DOOR, + }[self.capability] + self._state = VALUE_TO_STATE.get( + self.get_attribute_value(self.capability, attribute) + ) - if Capability.window_shade_level in self._device.capabilities: - self._attr_current_cover_position = self._device.status.shade_level - elif Capability.switch_level in self._device.capabilities: - self._attr_current_cover_position = self._device.status.level + if self.supports_capability(Capability.SWITCH_LEVEL): + self._attr_current_cover_position = self.get_attribute_value( + Capability.SWITCH_LEVEL, Attribute.LEVEL + ) self._attr_extra_state_attributes = {} - battery = self._device.status.attributes[Attribute.battery].value - if battery is not None: - self._attr_extra_state_attributes[ATTR_BATTERY_LEVEL] = battery + if self.supports_capability(Capability.BATTERY): + self._attr_extra_state_attributes[ATTR_BATTERY_LEVEL] = ( + self.get_attribute_value(Capability.BATTERY, Attribute.BATTERY) + ) @property def is_opening(self) -> bool: diff --git a/homeassistant/components/smartthings/entity.py b/homeassistant/components/smartthings/entity.py index cc63213d122..f5f1f268801 100644 --- a/homeassistant/components/smartthings/entity.py +++ b/homeassistant/components/smartthings/entity.py @@ -2,13 +2,15 @@ from __future__ import annotations -from pysmartthings.device import DeviceEntity +from typing import Any, cast + +from pysmartthings import Attribute, Capability, Command, DeviceEvent, SmartThings from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity -from .const import DOMAIN, SIGNAL_SMARTTHINGS_UPDATE +from . import FullDevice +from .const import DOMAIN, MAIN class SmartThingsEntity(Entity): @@ -16,35 +18,86 @@ class SmartThingsEntity(Entity): _attr_should_poll = False - def __init__(self, device: DeviceEntity) -> None: + def __init__( + self, client: SmartThings, device: FullDevice, capabilities: set[Capability] + ) -> None: """Initialize the instance.""" - self._device = device - self._dispatcher_remove = None - self._attr_name = device.label - self._attr_unique_id = device.device_id + self.client = client + self.capabilities = capabilities + self._internal_state = { + capability: device.status[MAIN][capability] + for capability in capabilities + if capability in device.status[MAIN] + } + self.device = device + self._attr_name = device.device.label + self._attr_unique_id = device.device.device_id self._attr_device_info = DeviceInfo( configuration_url="https://account.smartthings.com", - identifiers={(DOMAIN, device.device_id)}, - manufacturer=device.status.ocf_manufacturer_name, - model=device.status.ocf_model_number, - name=device.label, - hw_version=device.status.ocf_hardware_version, - sw_version=device.status.ocf_firmware_version, + identifiers={(DOMAIN, device.device.device_id)}, + name=device.device.label, ) + if (ocf := device.status[MAIN].get(Capability.OCF)) is not None: + self._attr_device_info.update( + { + "manufacturer": cast( + str | None, ocf[Attribute.MANUFACTURER_NAME].value + ), + "model": cast(str | None, ocf[Attribute.MODEL_NUMBER].value), + "hw_version": cast( + str | None, ocf[Attribute.HARDWARE_VERSION].value + ), + "sw_version": cast( + str | None, ocf[Attribute.OCF_FIRMWARE_VERSION].value + ), + } + ) - async def async_added_to_hass(self): - """Device added to hass.""" + async def async_added_to_hass(self) -> None: + """Subscribe to updates.""" + await super().async_added_to_hass() + for capability in self._internal_state: + self.async_on_remove( + self.client.add_device_event_listener( + self.device.device.device_id, + MAIN, + capability, + self._update_handler, + ) + ) + self._update_attr() - async def async_update_state(devices): - """Update device state.""" - if self._device.device_id in devices: - await self.async_update_ha_state(True) + def _update_handler(self, event: DeviceEvent) -> None: + self._internal_state[event.capability][event.attribute].value = event.value + self._internal_state[event.capability][event.attribute].data = event.data + self._handle_update() - self._dispatcher_remove = async_dispatcher_connect( - self.hass, SIGNAL_SMARTTHINGS_UPDATE, async_update_state + def supports_capability(self, capability: Capability) -> bool: + """Test if device supports a capability.""" + return capability in self.device.status[MAIN] + + def get_attribute_value(self, capability: Capability, attribute: Attribute) -> Any: + """Get the value of a device attribute.""" + return self._internal_state[capability][attribute].value + + def _update_attr(self) -> None: + """Update the attributes.""" + + def _handle_update(self) -> None: + """Handle updated data from the coordinator.""" + self._update_attr() + self.async_write_ha_state() + + async def execute_device_command( + self, + capability: Capability, + command: Command, + argument: int | str | list[Any] | dict[str, Any] | None = None, + ) -> None: + """Execute a command on the device.""" + kwargs = {} + if argument is not None: + kwargs["argument"] = argument + await self.client.execute_device_command( + self.device.device.device_id, capability, command, MAIN, **kwargs ) - - async def async_will_remove_from_hass(self) -> None: - """Disconnect the device when removed.""" - if self._dispatcher_remove: - self._dispatcher_remove() diff --git a/homeassistant/components/smartthings/fan.py b/homeassistant/components/smartthings/fan.py index 1f26a805dcb..23afb0baeb2 100644 --- a/homeassistant/components/smartthings/fan.py +++ b/homeassistant/components/smartthings/fan.py @@ -2,14 +2,12 @@ from __future__ import annotations -from collections.abc import Sequence import math from typing import Any -from pysmartthings import Capability +from pysmartthings import Attribute, Capability, Command, SmartThings from homeassistant.components.fan import FanEntity, FanEntityFeature -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.percentage import ( @@ -18,7 +16,8 @@ from homeassistant.util.percentage import ( ) from homeassistant.util.scaling import int_states_in_range -from .const import DATA_BROKERS, DOMAIN +from . import FullDevice, SmartThingsConfigEntry +from .const import MAIN from .entity import SmartThingsEntity SPEED_RANGE = (1, 3) # off is not included @@ -26,86 +25,73 @@ SPEED_RANGE = (1, 3) # off is not included async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + entry: SmartThingsConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add fans for a config entry.""" - broker = hass.data[DOMAIN][DATA_BROKERS][config_entry.entry_id] + entry_data = entry.runtime_data async_add_entities( - SmartThingsFan(device) - for device in broker.devices.values() - if broker.any_assigned(device.device_id, "fan") + SmartThingsFan(entry_data.client, device) + for device in entry_data.devices.values() + if Capability.SWITCH in device.status[MAIN] + and any( + capability in device.status[MAIN] + for capability in ( + Capability.FAN_SPEED, + Capability.AIR_CONDITIONER_FAN_MODE, + ) + ) + and Capability.THERMOSTAT_COOLING_SETPOINT not in device.status[MAIN] ) -def get_capabilities(capabilities: Sequence[str]) -> Sequence[str] | None: - """Return all capabilities supported if minimum required are present.""" - - # MUST support switch as we need a way to turn it on and off - if Capability.switch not in capabilities: - return None - - # These are all optional but at least one must be supported - optional = [ - Capability.air_conditioner_fan_mode, - Capability.fan_speed, - ] - - # At least one of the optional capabilities must be supported - # to classify this entity as a fan. - # If they are not then return None and don't setup the platform. - if not any(capability in capabilities for capability in optional): - return None - - supported = [Capability.switch] - - supported.extend( - capability for capability in optional if capability in capabilities - ) - - return supported - - class SmartThingsFan(SmartThingsEntity, FanEntity): """Define a SmartThings Fan.""" _attr_speed_count = int_states_in_range(SPEED_RANGE) - def __init__(self, device): + def __init__(self, client: SmartThings, device: FullDevice) -> None: """Init the class.""" - super().__init__(device) + super().__init__( + client, + device, + { + Capability.SWITCH, + Capability.FAN_SPEED, + Capability.AIR_CONDITIONER_FAN_MODE, + }, + ) self._attr_supported_features = self._determine_features() def _determine_features(self): flags = FanEntityFeature.TURN_OFF | FanEntityFeature.TURN_ON - if self._device.get_capability(Capability.fan_speed): + if self.supports_capability(Capability.FAN_SPEED): flags |= FanEntityFeature.SET_SPEED - if self._device.get_capability(Capability.air_conditioner_fan_mode): + if self.supports_capability(Capability.AIR_CONDITIONER_FAN_MODE): flags |= FanEntityFeature.PRESET_MODE return flags async def async_set_percentage(self, percentage: int) -> None: """Set the speed percentage of the fan.""" - await self._async_set_percentage(percentage) - - async def _async_set_percentage(self, percentage: int | None) -> None: - if percentage is None: - await self._device.switch_on(set_status=True) - elif percentage == 0: - await self._device.switch_off(set_status=True) + if percentage == 0: + await self.execute_device_command(Capability.SWITCH, Command.OFF) else: value = math.ceil(percentage_to_ranged_value(SPEED_RANGE, percentage)) - await self._device.set_fan_speed(value, set_status=True) - # State is set optimistically in the command above, therefore update - # the entity state ahead of receiving the confirming push updates - self.async_write_ha_state() + await self.execute_device_command( + Capability.FAN_SPEED, + Command.SET_FAN_SPEED, + argument=value, + ) async def async_set_preset_mode(self, preset_mode: str) -> None: """Set the preset_mode of the fan.""" - await self._device.set_fan_mode(preset_mode, set_status=True) - self.async_write_ha_state() + await self.execute_device_command( + Capability.AIR_CONDITIONER_FAN_MODE, + Command.SET_FAN_MODE, + argument=preset_mode, + ) async def async_turn_on( self, @@ -114,32 +100,30 @@ class SmartThingsFan(SmartThingsEntity, FanEntity): **kwargs: Any, ) -> None: """Turn the fan on.""" - if FanEntityFeature.SET_SPEED in self._attr_supported_features: - # If speed is set in features then turn the fan on with the speed. - await self._async_set_percentage(percentage) + if ( + FanEntityFeature.SET_SPEED in self._attr_supported_features + and percentage is not None + ): + await self.async_set_percentage(percentage) else: - # If speed is not valid then turn on the fan with the - await self._device.switch_on(set_status=True) - # State is set optimistically in the command above, therefore update - # the entity state ahead of receiving the confirming push updates - self.async_write_ha_state() + await self.execute_device_command(Capability.SWITCH, Command.ON) async def async_turn_off(self, **kwargs: Any) -> None: """Turn the fan off.""" - await self._device.switch_off(set_status=True) - # State is set optimistically in the command above, therefore update - # the entity state ahead of receiving the confirming push updates - self.async_write_ha_state() + await self.execute_device_command(Capability.SWITCH, Command.OFF) @property def is_on(self) -> bool: """Return true if fan is on.""" - return self._device.status.switch + return self.get_attribute_value(Capability.SWITCH, Attribute.SWITCH) @property def percentage(self) -> int | None: """Return the current speed percentage.""" - return ranged_value_to_percentage(SPEED_RANGE, self._device.status.fan_speed) + return ranged_value_to_percentage( + SPEED_RANGE, + self.get_attribute_value(Capability.FAN_SPEED, Attribute.FAN_SPEED), + ) @property def preset_mode(self) -> str | None: @@ -147,7 +131,9 @@ class SmartThingsFan(SmartThingsEntity, FanEntity): Requires FanEntityFeature.PRESET_MODE. """ - return self._device.status.fan_mode + return self.get_attribute_value( + Capability.AIR_CONDITIONER_FAN_MODE, Attribute.FAN_MODE + ) @property def preset_modes(self) -> list[str] | None: @@ -155,4 +141,6 @@ class SmartThingsFan(SmartThingsEntity, FanEntity): Requires FanEntityFeature.PRESET_MODE. """ - return self._device.status.supported_ac_fan_modes + return self.get_attribute_value( + Capability.AIR_CONDITIONER_FAN_MODE, Attribute.SUPPORTED_AC_FAN_MODES + ) diff --git a/homeassistant/components/smartthings/light.py b/homeassistant/components/smartthings/light.py index 2ee369176cb..582f9dd5435 100644 --- a/homeassistant/components/smartthings/light.py +++ b/homeassistant/components/smartthings/light.py @@ -3,10 +3,9 @@ from __future__ import annotations import asyncio -from collections.abc import Sequence from typing import Any -from pysmartthings import Capability +from pysmartthings import Attribute, Capability, Command, SmartThings from homeassistant.components.light import ( ATTR_BRIGHTNESS, @@ -18,54 +17,38 @@ from homeassistant.components.light import ( LightEntityFeature, brightness_supported, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DATA_BROKERS, DOMAIN +from . import FullDevice, SmartThingsConfigEntry +from .const import MAIN from .entity import SmartThingsEntity +CAPABILITIES = ( + Capability.SWITCH_LEVEL, + Capability.COLOR_CONTROL, + Capability.COLOR_TEMPERATURE, +) + async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + entry: SmartThingsConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add lights for a config entry.""" - broker = hass.data[DOMAIN][DATA_BROKERS][config_entry.entry_id] + entry_data = entry.runtime_data async_add_entities( - [ - SmartThingsLight(device) - for device in broker.devices.values() - if broker.any_assigned(device.device_id, "light") - ], - True, + SmartThingsLight(entry_data.client, device) + for device in entry_data.devices.values() + if Capability.SWITCH in device.status[MAIN] + and any(capability in device.status[MAIN] for capability in CAPABILITIES) ) -def get_capabilities(capabilities: Sequence[str]) -> Sequence[str] | None: - """Return all capabilities supported if minimum required are present.""" - supported = [ - Capability.switch, - Capability.switch_level, - Capability.color_control, - Capability.color_temperature, - ] - # Must be able to be turned on/off. - if Capability.switch not in capabilities: - return None - # Must have one of these - light_capabilities = [ - Capability.color_control, - Capability.color_temperature, - Capability.switch_level, - ] - if any(capability in capabilities for capability in light_capabilities): - return supported - return None - - -def convert_scale(value, value_scale, target_scale, round_digits=4): +def convert_scale( + value: float, value_scale: int, target_scale: int, round_digits: int = 4 +) -> float: """Convert a value to a different scale.""" return round(value * target_scale / value_scale, round_digits) @@ -76,46 +59,41 @@ class SmartThingsLight(SmartThingsEntity, LightEntity): _attr_supported_color_modes: set[ColorMode] # SmartThings does not expose this attribute, instead it's - # implemented within each device-type handler. This value is the + # implemented within each device-type handler. This value is the # lowest kelvin found supported across 20+ handlers. _attr_min_color_temp_kelvin = 2000 # 500 mireds # SmartThings does not expose this attribute, instead it's - # implemented within each device-type handler. This value is the + # implemented within each device-type handler. This value is the # highest kelvin found supported across 20+ handlers. _attr_max_color_temp_kelvin = 9000 # 111 mireds - def __init__(self, device): + def __init__(self, client: SmartThings, device: FullDevice) -> None: """Initialize a SmartThingsLight.""" - super().__init__(device) - self._attr_supported_color_modes = self._determine_color_modes() - self._attr_supported_features = self._determine_features() - - def _determine_color_modes(self): - """Get features supported by the device.""" + super().__init__( + client, + device, + { + Capability.COLOR_CONTROL, + Capability.COLOR_TEMPERATURE, + Capability.SWITCH_LEVEL, + Capability.SWITCH, + }, + ) color_modes = set() - # Color Temperature - if Capability.color_temperature in self._device.capabilities: + if self.supports_capability(Capability.COLOR_TEMPERATURE): color_modes.add(ColorMode.COLOR_TEMP) - # Color - if Capability.color_control in self._device.capabilities: + if self.supports_capability(Capability.COLOR_CONTROL): color_modes.add(ColorMode.HS) - # Brightness - if not color_modes and Capability.switch_level in self._device.capabilities: + if not color_modes and self.supports_capability(Capability.SWITCH_LEVEL): color_modes.add(ColorMode.BRIGHTNESS) if not color_modes: color_modes.add(ColorMode.ONOFF) - - return color_modes - - def _determine_features(self) -> LightEntityFeature: - """Get features supported by the device.""" + self._attr_supported_color_modes = color_modes features = LightEntityFeature(0) - # Transition - if Capability.switch_level in self._device.capabilities: + if self.supports_capability(Capability.SWITCH_LEVEL): features |= LightEntityFeature.TRANSITION - - return features + self._attr_supported_features = features async def async_turn_on(self, **kwargs: Any) -> None: """Turn the light on.""" @@ -136,11 +114,10 @@ class SmartThingsLight(SmartThingsEntity, LightEntity): kwargs[ATTR_BRIGHTNESS], kwargs.get(ATTR_TRANSITION, 0) ) else: - await self._device.switch_on(set_status=True) - - # State is set optimistically in the commands above, therefore update - # the entity state ahead of receiving the confirming push updates - self.async_schedule_update_ha_state(True) + await self.execute_device_command( + Capability.SWITCH, + Command.ON, + ) async def async_turn_off(self, **kwargs: Any) -> None: """Turn the light off.""" @@ -148,27 +125,39 @@ class SmartThingsLight(SmartThingsEntity, LightEntity): if ATTR_TRANSITION in kwargs: await self.async_set_level(0, int(kwargs[ATTR_TRANSITION])) else: - await self._device.switch_off(set_status=True) + await self.execute_device_command( + Capability.SWITCH, + Command.OFF, + ) - # State is set optimistically in the commands above, therefore update - # the entity state ahead of receiving the confirming push updates - self.async_schedule_update_ha_state(True) - - async def async_update(self) -> None: + def _update_attr(self) -> None: """Update entity attributes when the device status has changed.""" # Brightness and transition if brightness_supported(self._attr_supported_color_modes): self._attr_brightness = int( - convert_scale(self._device.status.level, 100, 255, 0) + convert_scale( + self.get_attribute_value(Capability.SWITCH_LEVEL, Attribute.LEVEL), + 100, + 255, + 0, + ) ) # Color Temperature if ColorMode.COLOR_TEMP in self._attr_supported_color_modes: - self._attr_color_temp_kelvin = self._device.status.color_temperature + self._attr_color_temp_kelvin = self.get_attribute_value( + Capability.COLOR_TEMPERATURE, Attribute.COLOR_TEMPERATURE + ) # Color if ColorMode.HS in self._attr_supported_color_modes: self._attr_hs_color = ( - convert_scale(self._device.status.hue, 100, 360), - self._device.status.saturation, + convert_scale( + self.get_attribute_value(Capability.COLOR_CONTROL, Attribute.HUE), + 100, + 360, + ), + self.get_attribute_value( + Capability.COLOR_CONTROL, Attribute.SATURATION + ), ) async def async_set_color(self, hs_color): @@ -176,14 +165,22 @@ class SmartThingsLight(SmartThingsEntity, LightEntity): hue = convert_scale(float(hs_color[0]), 360, 100) hue = max(min(hue, 100.0), 0.0) saturation = max(min(float(hs_color[1]), 100.0), 0.0) - await self._device.set_color(hue, saturation, set_status=True) + await self.execute_device_command( + Capability.COLOR_CONTROL, + Command.SET_COLOR, + argument={"hue": hue, "saturation": saturation}, + ) async def async_set_color_temp(self, value: int): """Set the color temperature of the device.""" kelvin = max(min(value, 30000), 1) - await self._device.set_color_temperature(kelvin, set_status=True) + await self.execute_device_command( + Capability.COLOR_TEMPERATURE, + Command.SET_COLOR_TEMPERATURE, + argument=kelvin, + ) - async def async_set_level(self, brightness: int, transition: int): + async def async_set_level(self, brightness: int, transition: int) -> None: """Set the brightness of the light over transition.""" level = int(convert_scale(brightness, 255, 100, 0)) # Due to rounding, set level to 1 (one) so we don't inadvertently @@ -191,7 +188,11 @@ class SmartThingsLight(SmartThingsEntity, LightEntity): level = 1 if level == 0 and brightness > 0 else level level = max(min(level, 100), 0) duration = int(transition) - await self._device.set_level(level, duration, set_status=True) + await self.execute_device_command( + Capability.SWITCH_LEVEL, + Command.SET_LEVEL, + argument=[level, duration], + ) @property def color_mode(self) -> ColorMode: @@ -208,4 +209,4 @@ class SmartThingsLight(SmartThingsEntity, LightEntity): @property def is_on(self) -> bool: """Return true if light is on.""" - return self._device.status.switch + return self.get_attribute_value(Capability.SWITCH, Attribute.SWITCH) == "on" diff --git a/homeassistant/components/smartthings/lock.py b/homeassistant/components/smartthings/lock.py index 468b7c2083a..56274dfe161 100644 --- a/homeassistant/components/smartthings/lock.py +++ b/homeassistant/components/smartthings/lock.py @@ -2,17 +2,16 @@ from __future__ import annotations -from collections.abc import Sequence from typing import Any -from pysmartthings import Attribute, Capability +from pysmartthings import Attribute, Capability, Command from homeassistant.components.lock import LockEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DATA_BROKERS, DOMAIN +from . import SmartThingsConfigEntry +from .const import MAIN from .entity import SmartThingsEntity ST_STATE_LOCKED = "locked" @@ -28,48 +27,47 @@ ST_LOCK_ATTR_MAP = { async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + entry: SmartThingsConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add locks for a config entry.""" - broker = hass.data[DOMAIN][DATA_BROKERS][config_entry.entry_id] + entry_data = entry.runtime_data async_add_entities( - SmartThingsLock(device) - for device in broker.devices.values() - if broker.any_assigned(device.device_id, "lock") + SmartThingsLock(entry_data.client, device, {Capability.LOCK}) + for device in entry_data.devices.values() + if Capability.LOCK in device.status[MAIN] ) -def get_capabilities(capabilities: Sequence[str]) -> Sequence[str] | None: - """Return all capabilities supported if minimum required are present.""" - if Capability.lock in capabilities: - return [Capability.lock] - return None - - class SmartThingsLock(SmartThingsEntity, LockEntity): """Define a SmartThings lock.""" async def async_lock(self, **kwargs: Any) -> None: """Lock the device.""" - await self._device.lock(set_status=True) - self.async_write_ha_state() + await self.execute_device_command( + Capability.LOCK, + Command.LOCK, + ) async def async_unlock(self, **kwargs: Any) -> None: """Unlock the device.""" - await self._device.unlock(set_status=True) - self.async_write_ha_state() + await self.execute_device_command( + Capability.LOCK, + Command.UNLOCK, + ) @property def is_locked(self) -> bool: """Return true if lock is locked.""" - return self._device.status.lock == ST_STATE_LOCKED + return ( + self.get_attribute_value(Capability.LOCK, Attribute.LOCK) == ST_STATE_LOCKED + ) @property def extra_state_attributes(self) -> dict[str, Any]: """Return device specific state attributes.""" state_attrs = {} - status = self._device.status.attributes[Attribute.lock] + status = self._internal_state[Capability.LOCK][Attribute.LOCK] if status.value: state_attrs["lock_state"] = status.value if isinstance(status.data, dict): diff --git a/homeassistant/components/smartthings/manifest.json b/homeassistant/components/smartthings/manifest.json index be313248eaf..b34ab90ca8c 100644 --- a/homeassistant/components/smartthings/manifest.json +++ b/homeassistant/components/smartthings/manifest.json @@ -1,10 +1,9 @@ { "domain": "smartthings", "name": "SmartThings", - "after_dependencies": ["cloud"], - "codeowners": [], + "codeowners": ["@joostlek"], "config_flow": true, - "dependencies": ["webhook"], + "dependencies": ["application_credentials"], "dhcp": [ { "hostname": "st*", @@ -29,6 +28,6 @@ ], "documentation": "https://www.home-assistant.io/integrations/smartthings", "iot_class": "cloud_push", - "loggers": ["httpsig", "pysmartapp", "pysmartthings"], - "requirements": ["pysmartapp==0.3.5", "pysmartthings==0.7.8"] + "loggers": ["pysmartthings"], + "requirements": ["pysmartthings==1.2.0"] } diff --git a/homeassistant/components/smartthings/scene.py b/homeassistant/components/smartthings/scene.py index aa6655b0134..2b387859f22 100644 --- a/homeassistant/components/smartthings/scene.py +++ b/homeassistant/components/smartthings/scene.py @@ -2,39 +2,42 @@ from typing import Any +from pysmartthings import Scene as STScene, SmartThings + from homeassistant.components.scene import Scene -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DATA_BROKERS, DOMAIN +from . import SmartThingsConfigEntry async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + entry: SmartThingsConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: - """Add switches for a config entry.""" - broker = hass.data[DOMAIN][DATA_BROKERS][config_entry.entry_id] - async_add_entities(SmartThingsScene(scene) for scene in broker.scenes.values()) + """Add lights for a config entry.""" + client = entry.runtime_data.client + scenes = entry.runtime_data.scenes + async_add_entities(SmartThingsScene(scene, client) for scene in scenes.values()) class SmartThingsScene(Scene): """Define a SmartThings scene.""" - def __init__(self, scene): + def __init__(self, scene: STScene, client: SmartThings) -> None: """Init the scene class.""" + self.client = client self._scene = scene self._attr_name = scene.name self._attr_unique_id = scene.scene_id async def async_activate(self, **kwargs: Any) -> None: """Activate scene.""" - await self._scene.execute() + await self.client.execute_scene(self._scene.scene_id) @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Get attributes about the state.""" return { "icon": self._scene.icon, diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index 3a283bb806b..b16d332a1ae 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -2,13 +2,12 @@ from __future__ import annotations -from collections.abc import Callable, Mapping, Sequence +from collections.abc import Callable, Mapping from dataclasses import dataclass from datetime import datetime from typing import Any -from pysmartthings import Attribute, Capability -from pysmartthings.device import DeviceEntity, DeviceStatus +from pysmartthings import Attribute, Capability, SmartThings from homeassistant.components.sensor import ( SensorDeviceClass, @@ -16,14 +15,12 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONCENTRATION_PARTS_PER_MILLION, LIGHT_LUX, PERCENTAGE, EntityCategory, UnitOfArea, - UnitOfElectricPotential, UnitOfEnergy, UnitOfMass, UnitOfPower, @@ -34,17 +31,23 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util -from .const import DATA_BROKERS, DOMAIN +from . import FullDevice, SmartThingsConfigEntry +from .const import MAIN from .entity import SmartThingsEntity +THERMOSTAT_CAPABILITIES = { + Capability.TEMPERATURE_MEASUREMENT, + Capability.THERMOSTAT_HEATING_SETPOINT, + Capability.THERMOSTAT_MODE, +} -def power_attributes(status: DeviceStatus) -> dict[str, Any]: + +def power_attributes(status: dict[str, Any]) -> dict[str, Any]: """Return the power attributes.""" state = {} - for attribute in ("power_consumption_start", "power_consumption_end"): - value = getattr(status, attribute) - if value is not None: - state[attribute] = value + for attribute in ("start", "end"): + if (value := status.get(attribute)) is not None: + state[f"power_consumption_{attribute}"] = value return state @@ -53,62 +56,70 @@ class SmartThingsSensorEntityDescription(SensorEntityDescription): """Describe a SmartThings sensor entity.""" value_fn: Callable[[Any], str | float | int | datetime | None] = lambda value: value - extra_state_attributes_fn: Callable[[DeviceStatus], dict[str, Any]] | None = None + extra_state_attributes_fn: Callable[[Any], dict[str, Any]] | None = None unique_id_separator: str = "." + capability_ignore_list: list[set[Capability]] | None = None CAPABILITY_TO_SENSORS: dict[ - str, dict[str, list[SmartThingsSensorEntityDescription]] + Capability, dict[Attribute, list[SmartThingsSensorEntityDescription]] ] = { - Capability.activity_lighting_mode: { - Attribute.lighting_mode: [ + # no fixtures + Capability.ACTIVITY_LIGHTING_MODE: { + Attribute.LIGHTING_MODE: [ SmartThingsSensorEntityDescription( - key=Attribute.lighting_mode, + key=Attribute.LIGHTING_MODE, name="Activity Lighting Mode", entity_category=EntityCategory.DIAGNOSTIC, ) ] }, - Capability.air_conditioner_mode: { - Attribute.air_conditioner_mode: [ + Capability.AIR_CONDITIONER_MODE: { + Attribute.AIR_CONDITIONER_MODE: [ SmartThingsSensorEntityDescription( - key=Attribute.air_conditioner_mode, + key=Attribute.AIR_CONDITIONER_MODE, name="Air Conditioner Mode", entity_category=EntityCategory.DIAGNOSTIC, + capability_ignore_list=[ + { + Capability.TEMPERATURE_MEASUREMENT, + Capability.THERMOSTAT_COOLING_SETPOINT, + } + ], ) ] }, - Capability.air_quality_sensor: { - Attribute.air_quality: [ + Capability.AIR_QUALITY_SENSOR: { + Attribute.AIR_QUALITY: [ SmartThingsSensorEntityDescription( - key=Attribute.air_quality, + key=Attribute.AIR_QUALITY, name="Air Quality", native_unit_of_measurement="CAQI", state_class=SensorStateClass.MEASUREMENT, ) ] }, - Capability.alarm: { - Attribute.alarm: [ + Capability.ALARM: { + Attribute.ALARM: [ SmartThingsSensorEntityDescription( - key=Attribute.alarm, + key=Attribute.ALARM, name="Alarm", ) ] }, - Capability.audio_volume: { - Attribute.volume: [ + Capability.AUDIO_VOLUME: { + Attribute.VOLUME: [ SmartThingsSensorEntityDescription( - key=Attribute.volume, + key=Attribute.VOLUME, name="Volume", native_unit_of_measurement=PERCENTAGE, ) ] }, - Capability.battery: { - Attribute.battery: [ + Capability.BATTERY: { + Attribute.BATTERY: [ SmartThingsSensorEntityDescription( - key=Attribute.battery, + key=Attribute.BATTERY, name="Battery", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, @@ -116,20 +127,22 @@ CAPABILITY_TO_SENSORS: dict[ ) ] }, - Capability.body_mass_index_measurement: { - Attribute.bmi_measurement: [ + # no fixtures + Capability.BODY_MASS_INDEX_MEASUREMENT: { + Attribute.BMI_MEASUREMENT: [ SmartThingsSensorEntityDescription( - key=Attribute.bmi_measurement, + key=Attribute.BMI_MEASUREMENT, name="Body Mass Index", native_unit_of_measurement=f"{UnitOfMass.KILOGRAMS}/{UnitOfArea.SQUARE_METERS}", state_class=SensorStateClass.MEASUREMENT, ) ] }, - Capability.body_weight_measurement: { - Attribute.body_weight_measurement: [ + # no fixtures + Capability.BODY_WEIGHT_MEASUREMENT: { + Attribute.BODY_WEIGHT_MEASUREMENT: [ SmartThingsSensorEntityDescription( - key=Attribute.body_weight_measurement, + key=Attribute.BODY_WEIGHT_MEASUREMENT, name="Body Weight", native_unit_of_measurement=UnitOfMass.KILOGRAMS, device_class=SensorDeviceClass.WEIGHT, @@ -137,10 +150,11 @@ CAPABILITY_TO_SENSORS: dict[ ) ] }, - Capability.carbon_dioxide_measurement: { - Attribute.carbon_dioxide: [ + # no fixtures + Capability.CARBON_DIOXIDE_MEASUREMENT: { + Attribute.CARBON_DIOXIDE: [ SmartThingsSensorEntityDescription( - key=Attribute.carbon_dioxide, + key=Attribute.CARBON_DIOXIDE, name="Carbon Dioxide", native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, device_class=SensorDeviceClass.CO2, @@ -148,18 +162,20 @@ CAPABILITY_TO_SENSORS: dict[ ) ] }, - Capability.carbon_monoxide_detector: { - Attribute.carbon_monoxide: [ + # no fixtures + Capability.CARBON_MONOXIDE_DETECTOR: { + Attribute.CARBON_MONOXIDE: [ SmartThingsSensorEntityDescription( - key=Attribute.carbon_monoxide, + key=Attribute.CARBON_MONOXIDE, name="Carbon Monoxide Detector", ) ] }, - Capability.carbon_monoxide_measurement: { - Attribute.carbon_monoxide_level: [ + # no fixtures + Capability.CARBON_MONOXIDE_MEASUREMENT: { + Attribute.CARBON_MONOXIDE_LEVEL: [ SmartThingsSensorEntityDescription( - key=Attribute.carbon_monoxide_level, + key=Attribute.CARBON_MONOXIDE_LEVEL, name="Carbon Monoxide Level", native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, device_class=SensorDeviceClass.CO, @@ -167,79 +183,80 @@ CAPABILITY_TO_SENSORS: dict[ ) ] }, - Capability.dishwasher_operating_state: { - Attribute.machine_state: [ + Capability.DISHWASHER_OPERATING_STATE: { + Attribute.MACHINE_STATE: [ SmartThingsSensorEntityDescription( - key=Attribute.machine_state, + key=Attribute.MACHINE_STATE, name="Dishwasher Machine State", ) ], - Attribute.dishwasher_job_state: [ + Attribute.DISHWASHER_JOB_STATE: [ SmartThingsSensorEntityDescription( - key=Attribute.dishwasher_job_state, + key=Attribute.DISHWASHER_JOB_STATE, name="Dishwasher Job State", ) ], - Attribute.completion_time: [ + Attribute.COMPLETION_TIME: [ SmartThingsSensorEntityDescription( - key=Attribute.completion_time, + key=Attribute.COMPLETION_TIME, name="Dishwasher Completion Time", device_class=SensorDeviceClass.TIMESTAMP, value_fn=dt_util.parse_datetime, ) ], }, - Capability.dryer_mode: { - Attribute.dryer_mode: [ + # part of the proposed spec, no fixtures + Capability.DRYER_MODE: { + Attribute.DRYER_MODE: [ SmartThingsSensorEntityDescription( - key=Attribute.dryer_mode, + key=Attribute.DRYER_MODE, name="Dryer Mode", entity_category=EntityCategory.DIAGNOSTIC, ) ] }, - Capability.dryer_operating_state: { - Attribute.machine_state: [ + Capability.DRYER_OPERATING_STATE: { + Attribute.MACHINE_STATE: [ SmartThingsSensorEntityDescription( - key=Attribute.machine_state, + key=Attribute.MACHINE_STATE, name="Dryer Machine State", ) ], - Attribute.dryer_job_state: [ + Attribute.DRYER_JOB_STATE: [ SmartThingsSensorEntityDescription( - key=Attribute.dryer_job_state, + key=Attribute.DRYER_JOB_STATE, name="Dryer Job State", ) ], - Attribute.completion_time: [ + Attribute.COMPLETION_TIME: [ SmartThingsSensorEntityDescription( - key=Attribute.completion_time, + key=Attribute.COMPLETION_TIME, name="Dryer Completion Time", device_class=SensorDeviceClass.TIMESTAMP, value_fn=dt_util.parse_datetime, ) ], }, - Capability.dust_sensor: { - Attribute.fine_dust_level: [ + Capability.DUST_SENSOR: { + Attribute.DUST_LEVEL: [ SmartThingsSensorEntityDescription( - key=Attribute.fine_dust_level, - name="Fine Dust Level", - state_class=SensorStateClass.MEASUREMENT, - ) - ], - Attribute.dust_level: [ - SmartThingsSensorEntityDescription( - key=Attribute.dust_level, + key=Attribute.DUST_LEVEL, name="Dust Level", state_class=SensorStateClass.MEASUREMENT, ) ], - }, - Capability.energy_meter: { - Attribute.energy: [ + Attribute.FINE_DUST_LEVEL: [ SmartThingsSensorEntityDescription( - key=Attribute.energy, + key=Attribute.FINE_DUST_LEVEL, + name="Fine Dust Level", + state_class=SensorStateClass.MEASUREMENT, + ) + ], + }, + Capability.ENERGY_METER: { + Attribute.ENERGY: [ + SmartThingsSensorEntityDescription( + key=Attribute.ENERGY, name="Energy Meter", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, @@ -247,10 +264,11 @@ CAPABILITY_TO_SENSORS: dict[ ) ] }, - Capability.equivalent_carbon_dioxide_measurement: { - Attribute.equivalent_carbon_dioxide_measurement: [ + # no fixtures + Capability.EQUIVALENT_CARBON_DIOXIDE_MEASUREMENT: { + Attribute.EQUIVALENT_CARBON_DIOXIDE_MEASUREMENT: [ SmartThingsSensorEntityDescription( - key=Attribute.equivalent_carbon_dioxide_measurement, + key=Attribute.EQUIVALENT_CARBON_DIOXIDE_MEASUREMENT, name="Equivalent Carbon Dioxide Measurement", native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, device_class=SensorDeviceClass.CO2, @@ -258,43 +276,45 @@ CAPABILITY_TO_SENSORS: dict[ ) ] }, - Capability.formaldehyde_measurement: { - Attribute.formaldehyde_level: [ + # no fixtures + Capability.FORMALDEHYDE_MEASUREMENT: { + Attribute.FORMALDEHYDE_LEVEL: [ SmartThingsSensorEntityDescription( - key=Attribute.formaldehyde_level, + key=Attribute.FORMALDEHYDE_LEVEL, name="Formaldehyde Measurement", native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, state_class=SensorStateClass.MEASUREMENT, ) ] }, - Capability.gas_meter: { - Attribute.gas_meter: [ + # no fixtures + Capability.GAS_METER: { + Attribute.GAS_METER: [ SmartThingsSensorEntityDescription( - key=Attribute.gas_meter, + key=Attribute.GAS_METER, name="Gas Meter", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.MEASUREMENT, ) ], - Attribute.gas_meter_calorific: [ + Attribute.GAS_METER_CALORIFIC: [ SmartThingsSensorEntityDescription( - key=Attribute.gas_meter_calorific, + key=Attribute.GAS_METER_CALORIFIC, name="Gas Meter Calorific", ) ], - Attribute.gas_meter_time: [ + Attribute.GAS_METER_TIME: [ SmartThingsSensorEntityDescription( - key=Attribute.gas_meter_time, + key=Attribute.GAS_METER_TIME, name="Gas Meter Time", device_class=SensorDeviceClass.TIMESTAMP, value_fn=dt_util.parse_datetime, ) ], - Attribute.gas_meter_volume: [ + Attribute.GAS_METER_VOLUME: [ SmartThingsSensorEntityDescription( - key=Attribute.gas_meter_volume, + key=Attribute.GAS_METER_VOLUME, name="Gas Meter Volume", native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, device_class=SensorDeviceClass.GAS, @@ -302,114 +322,117 @@ CAPABILITY_TO_SENSORS: dict[ ) ], }, - Capability.illuminance_measurement: { - Attribute.illuminance: [ + # no fixtures + Capability.ILLUMINANCE_MEASUREMENT: { + Attribute.ILLUMINANCE: [ SmartThingsSensorEntityDescription( - key=Attribute.illuminance, + key=Attribute.ILLUMINANCE, name="Illuminance", native_unit_of_measurement=LIGHT_LUX, device_class=SensorDeviceClass.ILLUMINANCE, + state_class=SensorStateClass.MEASUREMENT, ) ] }, - Capability.infrared_level: { - Attribute.infrared_level: [ + # no fixtures + Capability.INFRARED_LEVEL: { + Attribute.INFRARED_LEVEL: [ SmartThingsSensorEntityDescription( - key=Attribute.infrared_level, + key=Attribute.INFRARED_LEVEL, name="Infrared Level", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, ) ] }, - Capability.media_input_source: { - Attribute.input_source: [ + Capability.MEDIA_INPUT_SOURCE: { + Attribute.INPUT_SOURCE: [ SmartThingsSensorEntityDescription( - key=Attribute.input_source, + key=Attribute.INPUT_SOURCE, name="Media Input Source", ) ] }, - Capability.media_playback_repeat: { - Attribute.playback_repeat_mode: [ + # part of the proposed spec, no fixtures + Capability.MEDIA_PLAYBACK_REPEAT: { + Attribute.PLAYBACK_REPEAT_MODE: [ SmartThingsSensorEntityDescription( - key=Attribute.playback_repeat_mode, + key=Attribute.PLAYBACK_REPEAT_MODE, name="Media Playback Repeat", ) ] }, - Capability.media_playback_shuffle: { - Attribute.playback_shuffle: [ + # part of the proposed spec, no fixtures + Capability.MEDIA_PLAYBACK_SHUFFLE: { + Attribute.PLAYBACK_SHUFFLE: [ SmartThingsSensorEntityDescription( - key=Attribute.playback_shuffle, + key=Attribute.PLAYBACK_SHUFFLE, name="Media Playback Shuffle", ) ] }, - Capability.media_playback: { - Attribute.playback_status: [ + Capability.MEDIA_PLAYBACK: { + Attribute.PLAYBACK_STATUS: [ SmartThingsSensorEntityDescription( - key=Attribute.playback_status, + key=Attribute.PLAYBACK_STATUS, name="Media Playback Status", ) ] }, - Capability.odor_sensor: { - Attribute.odor_level: [ + Capability.ODOR_SENSOR: { + Attribute.ODOR_LEVEL: [ SmartThingsSensorEntityDescription( - key=Attribute.odor_level, + key=Attribute.ODOR_LEVEL, name="Odor Sensor", ) ] }, - Capability.oven_mode: { - Attribute.oven_mode: [ + Capability.OVEN_MODE: { + Attribute.OVEN_MODE: [ SmartThingsSensorEntityDescription( - key=Attribute.oven_mode, + key=Attribute.OVEN_MODE, name="Oven Mode", entity_category=EntityCategory.DIAGNOSTIC, ) ] }, - Capability.oven_operating_state: { - Attribute.machine_state: [ + Capability.OVEN_OPERATING_STATE: { + Attribute.MACHINE_STATE: [ SmartThingsSensorEntityDescription( - key=Attribute.machine_state, + key=Attribute.MACHINE_STATE, name="Oven Machine State", ) ], - Attribute.oven_job_state: [ + Attribute.OVEN_JOB_STATE: [ SmartThingsSensorEntityDescription( - key=Attribute.oven_job_state, + key=Attribute.OVEN_JOB_STATE, name="Oven Job State", ) ], - Attribute.completion_time: [ + Attribute.COMPLETION_TIME: [ SmartThingsSensorEntityDescription( - key=Attribute.completion_time, + key=Attribute.COMPLETION_TIME, name="Oven Completion Time", ) ], }, - Capability.oven_setpoint: { - Attribute.oven_setpoint: [ + Capability.OVEN_SETPOINT: { + Attribute.OVEN_SETPOINT: [ SmartThingsSensorEntityDescription( - key=Attribute.oven_setpoint, + key=Attribute.OVEN_SETPOINT, name="Oven Set Point", ) ] }, - Capability.power_consumption_report: { - Attribute.power_consumption: [ + Capability.POWER_CONSUMPTION_REPORT: { + Attribute.POWER_CONSUMPTION: [ SmartThingsSensorEntityDescription( key="energy_meter", name="energy", state_class=SensorStateClass.TOTAL_INCREASING, device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - value_fn=lambda value: ( - val / 1000 if (val := value.get("energy")) is not None else None - ), + value_fn=lambda value: value["energy"] / 1000, ), SmartThingsSensorEntityDescription( key="power_meter", @@ -417,7 +440,7 @@ CAPABILITY_TO_SENSORS: dict[ state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.POWER, native_unit_of_measurement=UnitOfPower.WATT, - value_fn=lambda value: value.get("power"), + value_fn=lambda value: value["power"], extra_state_attributes_fn=power_attributes, ), SmartThingsSensorEntityDescription( @@ -426,11 +449,7 @@ CAPABILITY_TO_SENSORS: dict[ state_class=SensorStateClass.TOTAL_INCREASING, device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - value_fn=lambda value: ( - val / 1000 - if (val := value.get("deltaEnergy")) is not None - else None - ), + value_fn=lambda value: value["deltaEnergy"] / 1000, ), SmartThingsSensorEntityDescription( key="powerEnergy_meter", @@ -438,11 +457,7 @@ CAPABILITY_TO_SENSORS: dict[ state_class=SensorStateClass.TOTAL_INCREASING, device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - value_fn=lambda value: ( - val / 1000 - if (val := value.get("powerEnergy")) is not None - else None - ), + value_fn=lambda value: value["powerEnergy"] / 1000, ), SmartThingsSensorEntityDescription( key="energySaved_meter", @@ -450,18 +465,14 @@ CAPABILITY_TO_SENSORS: dict[ state_class=SensorStateClass.TOTAL_INCREASING, device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - value_fn=lambda value: ( - val / 1000 - if (val := value.get("energySaved")) is not None - else None - ), + value_fn=lambda value: value["energySaved"] / 1000, ), ] }, - Capability.power_meter: { - Attribute.power: [ + Capability.POWER_METER: { + Attribute.POWER: [ SmartThingsSensorEntityDescription( - key=Attribute.power, + key=Attribute.POWER, name="Power Meter", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, @@ -469,72 +480,76 @@ CAPABILITY_TO_SENSORS: dict[ ) ] }, - Capability.power_source: { - Attribute.power_source: [ + # no fixtures + Capability.POWER_SOURCE: { + Attribute.POWER_SOURCE: [ SmartThingsSensorEntityDescription( - key=Attribute.power_source, + key=Attribute.POWER_SOURCE, name="Power Source", entity_category=EntityCategory.DIAGNOSTIC, ) ] }, - Capability.refrigeration_setpoint: { - Attribute.refrigeration_setpoint: [ + # part of the proposed spec + Capability.REFRIGERATION_SETPOINT: { + Attribute.REFRIGERATION_SETPOINT: [ SmartThingsSensorEntityDescription( - key=Attribute.refrigeration_setpoint, + key=Attribute.REFRIGERATION_SETPOINT, name="Refrigeration Setpoint", + device_class=SensorDeviceClass.TEMPERATURE, ) ] }, - Capability.relative_humidity_measurement: { - Attribute.humidity: [ + Capability.RELATIVE_HUMIDITY_MEASUREMENT: { + Attribute.HUMIDITY: [ SmartThingsSensorEntityDescription( - key=Attribute.humidity, - name="Relative Humidity", + key=Attribute.HUMIDITY, + name="Relative Humidity Measurement", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.HUMIDITY, state_class=SensorStateClass.MEASUREMENT, ) ] }, - Capability.robot_cleaner_cleaning_mode: { - Attribute.robot_cleaner_cleaning_mode: [ + Capability.ROBOT_CLEANER_CLEANING_MODE: { + Attribute.ROBOT_CLEANER_CLEANING_MODE: [ SmartThingsSensorEntityDescription( - key=Attribute.robot_cleaner_cleaning_mode, + key=Attribute.ROBOT_CLEANER_CLEANING_MODE, name="Robot Cleaner Cleaning Mode", entity_category=EntityCategory.DIAGNOSTIC, ) - ] + ], }, - Capability.robot_cleaner_movement: { - Attribute.robot_cleaner_movement: [ + Capability.ROBOT_CLEANER_MOVEMENT: { + Attribute.ROBOT_CLEANER_MOVEMENT: [ SmartThingsSensorEntityDescription( - key=Attribute.robot_cleaner_movement, + key=Attribute.ROBOT_CLEANER_MOVEMENT, name="Robot Cleaner Movement", ) ] }, - Capability.robot_cleaner_turbo_mode: { - Attribute.robot_cleaner_turbo_mode: [ + Capability.ROBOT_CLEANER_TURBO_MODE: { + Attribute.ROBOT_CLEANER_TURBO_MODE: [ SmartThingsSensorEntityDescription( - key=Attribute.robot_cleaner_turbo_mode, + key=Attribute.ROBOT_CLEANER_TURBO_MODE, name="Robot Cleaner Turbo Mode", entity_category=EntityCategory.DIAGNOSTIC, ) ] }, - Capability.signal_strength: { - Attribute.lqi: [ + # no fixtures + Capability.SIGNAL_STRENGTH: { + Attribute.LQI: [ SmartThingsSensorEntityDescription( - key=Attribute.lqi, + key=Attribute.LQI, name="LQI Signal Strength", state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, ) ], - Attribute.rssi: [ + Attribute.RSSI: [ SmartThingsSensorEntityDescription( - key=Attribute.rssi, + key=Attribute.RSSI, name="RSSI Signal Strength", device_class=SensorDeviceClass.SIGNAL_STRENGTH, state_class=SensorStateClass.MEASUREMENT, @@ -542,85 +557,99 @@ CAPABILITY_TO_SENSORS: dict[ ) ], }, - Capability.smoke_detector: { - Attribute.smoke: [ + # no fixtures + Capability.SMOKE_DETECTOR: { + Attribute.SMOKE: [ SmartThingsSensorEntityDescription( - key=Attribute.smoke, + key=Attribute.SMOKE, name="Smoke Detector", ) ] }, - Capability.temperature_measurement: { - Attribute.temperature: [ + Capability.TEMPERATURE_MEASUREMENT: { + Attribute.TEMPERATURE: [ SmartThingsSensorEntityDescription( - key=Attribute.temperature, + key=Attribute.TEMPERATURE, name="Temperature Measurement", - native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ) ] }, - Capability.thermostat_cooling_setpoint: { - Attribute.cooling_setpoint: [ + Capability.THERMOSTAT_COOLING_SETPOINT: { + Attribute.COOLING_SETPOINT: [ SmartThingsSensorEntityDescription( - key=Attribute.cooling_setpoint, + key=Attribute.COOLING_SETPOINT, name="Thermostat Cooling Setpoint", - native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, + capability_ignore_list=[ + { + Capability.AIR_CONDITIONER_FAN_MODE, + Capability.TEMPERATURE_MEASUREMENT, + Capability.AIR_CONDITIONER_MODE, + }, + THERMOSTAT_CAPABILITIES, + ], ) ] }, - Capability.thermostat_fan_mode: { - Attribute.thermostat_fan_mode: [ + # no fixtures + Capability.THERMOSTAT_FAN_MODE: { + Attribute.THERMOSTAT_FAN_MODE: [ SmartThingsSensorEntityDescription( - key=Attribute.thermostat_fan_mode, + key=Attribute.THERMOSTAT_FAN_MODE, name="Thermostat Fan Mode", entity_category=EntityCategory.DIAGNOSTIC, + capability_ignore_list=[THERMOSTAT_CAPABILITIES], ) ] }, - Capability.thermostat_heating_setpoint: { - Attribute.heating_setpoint: [ + # no fixtures + Capability.THERMOSTAT_HEATING_SETPOINT: { + Attribute.HEATING_SETPOINT: [ SmartThingsSensorEntityDescription( - key=Attribute.heating_setpoint, + key=Attribute.HEATING_SETPOINT, name="Thermostat Heating Setpoint", - native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, entity_category=EntityCategory.DIAGNOSTIC, + capability_ignore_list=[THERMOSTAT_CAPABILITIES], ) ] }, - Capability.thermostat_mode: { - Attribute.thermostat_mode: [ + # no fixtures + Capability.THERMOSTAT_MODE: { + Attribute.THERMOSTAT_MODE: [ SmartThingsSensorEntityDescription( - key=Attribute.thermostat_mode, + key=Attribute.THERMOSTAT_MODE, name="Thermostat Mode", entity_category=EntityCategory.DIAGNOSTIC, + capability_ignore_list=[THERMOSTAT_CAPABILITIES], ) ] }, - Capability.thermostat_operating_state: { - Attribute.thermostat_operating_state: [ + # no fixtures + Capability.THERMOSTAT_OPERATING_STATE: { + Attribute.THERMOSTAT_OPERATING_STATE: [ SmartThingsSensorEntityDescription( - key=Attribute.thermostat_operating_state, + key=Attribute.THERMOSTAT_OPERATING_STATE, name="Thermostat Operating State", + capability_ignore_list=[THERMOSTAT_CAPABILITIES], ) ] }, - Capability.thermostat_setpoint: { - Attribute.thermostat_setpoint: [ + # deprecated capability + Capability.THERMOSTAT_SETPOINT: { + Attribute.THERMOSTAT_SETPOINT: [ SmartThingsSensorEntityDescription( - key=Attribute.thermostat_setpoint, + key=Attribute.THERMOSTAT_SETPOINT, name="Thermostat Setpoint", - native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, entity_category=EntityCategory.DIAGNOSTIC, ) ] }, - Capability.three_axis: { - Attribute.three_axis: [ + Capability.THREE_AXIS: { + Attribute.THREE_AXIS: [ SmartThingsSensorEntityDescription( key="X Coordinate", name="X Coordinate", @@ -641,75 +670,77 @@ CAPABILITY_TO_SENSORS: dict[ ), ] }, - Capability.tv_channel: { - Attribute.tv_channel: [ + Capability.TV_CHANNEL: { + Attribute.TV_CHANNEL: [ SmartThingsSensorEntityDescription( - key=Attribute.tv_channel, + key=Attribute.TV_CHANNEL, name="Tv Channel", ) ], - Attribute.tv_channel_name: [ + Attribute.TV_CHANNEL_NAME: [ SmartThingsSensorEntityDescription( - key=Attribute.tv_channel_name, + key=Attribute.TV_CHANNEL_NAME, name="Tv Channel Name", ) ], }, - Capability.tvoc_measurement: { - Attribute.tvoc_level: [ + # no fixtures + Capability.TVOC_MEASUREMENT: { + Attribute.TVOC_LEVEL: [ SmartThingsSensorEntityDescription( - key=Attribute.tvoc_level, + key=Attribute.TVOC_LEVEL, name="Tvoc Measurement", native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, state_class=SensorStateClass.MEASUREMENT, ) ] }, - Capability.ultraviolet_index: { - Attribute.ultraviolet_index: [ + # no fixtures + Capability.ULTRAVIOLET_INDEX: { + Attribute.ULTRAVIOLET_INDEX: [ SmartThingsSensorEntityDescription( - key=Attribute.ultraviolet_index, + key=Attribute.ULTRAVIOLET_INDEX, name="Ultraviolet Index", state_class=SensorStateClass.MEASUREMENT, ) ] }, - Capability.voltage_measurement: { - Attribute.voltage: [ + Capability.VOLTAGE_MEASUREMENT: { + Attribute.VOLTAGE: [ SmartThingsSensorEntityDescription( - key=Attribute.voltage, + key=Attribute.VOLTAGE, name="Voltage Measurement", - native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, ) ] }, - Capability.washer_mode: { - Attribute.washer_mode: [ + # part of the proposed spec + Capability.WASHER_MODE: { + Attribute.WASHER_MODE: [ SmartThingsSensorEntityDescription( - key=Attribute.washer_mode, + key=Attribute.WASHER_MODE, name="Washer Mode", entity_category=EntityCategory.DIAGNOSTIC, ) ] }, - Capability.washer_operating_state: { - Attribute.machine_state: [ + Capability.WASHER_OPERATING_STATE: { + Attribute.MACHINE_STATE: [ SmartThingsSensorEntityDescription( - key=Attribute.machine_state, + key=Attribute.MACHINE_STATE, name="Washer Machine State", ) ], - Attribute.washer_job_state: [ + Attribute.WASHER_JOB_STATE: [ SmartThingsSensorEntityDescription( - key=Attribute.washer_job_state, + key=Attribute.WASHER_JOB_STATE, name="Washer Job State", ) ], - Attribute.completion_time: [ + Attribute.COMPLETION_TIME: [ SmartThingsSensorEntityDescription( - key=Attribute.completion_time, + key=Attribute.COMPLETION_TIME, name="Washer Completion Time", device_class=SensorDeviceClass.TIMESTAMP, value_fn=dt_util.parse_datetime, @@ -718,37 +749,37 @@ CAPABILITY_TO_SENSORS: dict[ }, } + UNITS = { "C": UnitOfTemperature.CELSIUS, "F": UnitOfTemperature.FAHRENHEIT, "lux": LIGHT_LUX, - "mG": None, # Three axis sensors never had a unit, so this removes it for now + "mG": None, } async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + entry: SmartThingsConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add sensors for a config entry.""" - broker = hass.data[DOMAIN][DATA_BROKERS][config_entry.entry_id] + entry_data = entry.runtime_data async_add_entities( - SmartThingsSensor(device, attribute, description) - for device in broker.devices.values() - for capability in broker.get_assigned(device.device_id, "sensor") - for attribute, descriptions in CAPABILITY_TO_SENSORS[capability].items() - for description in descriptions + SmartThingsSensor(entry_data.client, device, description, capability, attribute) + for device in entry_data.devices.values() + for capability, attributes in device.status[MAIN].items() + if capability in CAPABILITY_TO_SENSORS + for attribute in attributes + for description in CAPABILITY_TO_SENSORS[capability].get(attribute, []) + if not description.capability_ignore_list + or not any( + all(capability in device.status[MAIN] for capability in capability_list) + for capability_list in description.capability_ignore_list + ) ) -def get_capabilities(capabilities: Sequence[str]) -> Sequence[str] | None: - """Return all capabilities supported if minimum required are present.""" - return [ - capability for capability in CAPABILITY_TO_SENSORS if capability in capabilities - ] - - class SmartThingsSensor(SmartThingsEntity, SensorEntity): """Define a SmartThings Sensor.""" @@ -756,28 +787,30 @@ class SmartThingsSensor(SmartThingsEntity, SensorEntity): def __init__( self, - device: DeviceEntity, - attribute: str, + client: SmartThings, + device: FullDevice, entity_description: SmartThingsSensorEntityDescription, + capability: Capability, + attribute: Attribute, ) -> None: """Init the class.""" - super().__init__(device) + super().__init__(client, device, {capability}) + self._attr_name = f"{device.device.label} {entity_description.name}" + self._attr_unique_id = f"{device.device.device_id}{entity_description.unique_id_separator}{entity_description.key}" self._attribute = attribute - self._attr_name = f"{device.label} {entity_description.name}" - self._attr_unique_id = f"{device.device_id}{entity_description.unique_id_separator}{entity_description.key}" + self.capability = capability self.entity_description = entity_description @property - def native_value(self) -> str | float | int | datetime | None: + def native_value(self) -> str | float | datetime | int | None: """Return the state of the sensor.""" - return self.entity_description.value_fn( - self._device.status.attributes[self._attribute].value - ) + res = self.get_attribute_value(self.capability, self._attribute) + return self.entity_description.value_fn(res) @property - def native_unit_of_measurement(self): + def native_unit_of_measurement(self) -> str | None: """Return the unit this state is expressed in.""" - unit = self._device.status.attributes[self._attribute].unit + unit = self._internal_state[self.capability][self._attribute].unit return ( UNITS.get(unit, unit) if unit @@ -789,6 +822,6 @@ class SmartThingsSensor(SmartThingsEntity, SensorEntity): """Return the state attributes.""" if self.entity_description.extra_state_attributes_fn: return self.entity_description.extra_state_attributes_fn( - self._device.status + self.get_attribute_value(self.capability, self._attribute) ) return None diff --git a/homeassistant/components/smartthings/smartapp.py b/homeassistant/components/smartthings/smartapp.py deleted file mode 100644 index 76b6804075f..00000000000 --- a/homeassistant/components/smartthings/smartapp.py +++ /dev/null @@ -1,545 +0,0 @@ -"""SmartApp functionality to receive cloud-push notifications.""" - -from __future__ import annotations - -import asyncio -import functools -import logging -import secrets -from typing import Any -from urllib.parse import urlparse -from uuid import uuid4 - -from aiohttp import web -from pysmartapp import Dispatcher, SmartAppManager -from pysmartapp.const import SETTINGS_APP_ID -from pysmartthings import ( - APP_TYPE_WEBHOOK, - CAPABILITIES, - CLASSIFICATION_AUTOMATION, - App, - AppEntity, - AppOAuth, - AppSettings, - InstalledAppStatus, - SmartThings, - SourceType, - Subscription, - SubscriptionEntity, -) - -from homeassistant.components import cloud, webhook -from homeassistant.config_entries import ConfigFlowResult -from homeassistant.const import CONF_WEBHOOK_ID -from homeassistant.core import HomeAssistant -from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.dispatcher import ( - async_dispatcher_connect, - async_dispatcher_send, -) -from homeassistant.helpers.network import NoURLAvailableError, get_url -from homeassistant.helpers.storage import Store - -from .const import ( - APP_NAME_PREFIX, - APP_OAUTH_CLIENT_NAME, - APP_OAUTH_SCOPES, - CONF_CLOUDHOOK_URL, - CONF_INSTALLED_APP_ID, - CONF_INSTANCE_ID, - CONF_REFRESH_TOKEN, - DATA_BROKERS, - DATA_MANAGER, - DOMAIN, - IGNORED_CAPABILITIES, - SETTINGS_INSTANCE_ID, - SIGNAL_SMARTAPP_PREFIX, - STORAGE_KEY, - STORAGE_VERSION, - SUBSCRIPTION_WARNING_LIMIT, -) - -_LOGGER = logging.getLogger(__name__) - - -def format_unique_id(app_id: str, location_id: str) -> str: - """Format the unique id for a config entry.""" - return f"{app_id}_{location_id}" - - -async def find_app(hass: HomeAssistant, api: SmartThings) -> AppEntity | None: - """Find an existing SmartApp for this installation of hass.""" - apps = await api.apps() - for app in [app for app in apps if app.app_name.startswith(APP_NAME_PREFIX)]: - # Load settings to compare instance id - settings = await app.settings() - if ( - settings.settings.get(SETTINGS_INSTANCE_ID) - == hass.data[DOMAIN][CONF_INSTANCE_ID] - ): - return app - return None - - -async def validate_installed_app(api, installed_app_id: str): - """Ensure the specified installed SmartApp is valid and functioning. - - Query the API for the installed SmartApp and validate that it is tied to - the specified app_id and is in an authorized state. - """ - installed_app = await api.installed_app(installed_app_id) - if installed_app.installed_app_status != InstalledAppStatus.AUTHORIZED: - raise RuntimeWarning( - f"Installed SmartApp instance '{installed_app.display_name}' " - f"({installed_app.installed_app_id}) is not AUTHORIZED " - f"but instead {installed_app.installed_app_status}" - ) - return installed_app - - -def validate_webhook_requirements(hass: HomeAssistant) -> bool: - """Ensure Home Assistant is setup properly to receive webhooks.""" - if cloud.async_active_subscription(hass): - return True - if hass.data[DOMAIN][CONF_CLOUDHOOK_URL] is not None: - return True - return get_webhook_url(hass).lower().startswith("https://") - - -def get_webhook_url(hass: HomeAssistant) -> str: - """Get the URL of the webhook. - - Return the cloudhook if available, otherwise local webhook. - """ - cloudhook_url = hass.data[DOMAIN][CONF_CLOUDHOOK_URL] - if cloud.async_active_subscription(hass) and cloudhook_url is not None: - return cloudhook_url - return webhook.async_generate_url(hass, hass.data[DOMAIN][CONF_WEBHOOK_ID]) - - -def _get_app_template(hass: HomeAssistant): - try: - endpoint = f"at {get_url(hass, allow_cloud=False, prefer_external=True)}" - except NoURLAvailableError: - endpoint = "" - - cloudhook_url = hass.data[DOMAIN][CONF_CLOUDHOOK_URL] - if cloudhook_url is not None: - endpoint = "via Nabu Casa" - description = f"{hass.config.location_name} {endpoint}" - - return { - "app_name": APP_NAME_PREFIX + str(uuid4()), - "display_name": "Home Assistant", - "description": description, - "webhook_target_url": get_webhook_url(hass), - "app_type": APP_TYPE_WEBHOOK, - "single_instance": True, - "classifications": [CLASSIFICATION_AUTOMATION], - } - - -async def create_app(hass: HomeAssistant, api): - """Create a SmartApp for this instance of hass.""" - # Create app from template attributes - template = _get_app_template(hass) - app = App() - for key, value in template.items(): - setattr(app, key, value) - app, client = await api.create_app(app) - _LOGGER.debug("Created SmartApp '%s' (%s)", app.app_name, app.app_id) - - # Set unique hass id in settings - settings = AppSettings(app.app_id) - settings.settings[SETTINGS_APP_ID] = app.app_id - settings.settings[SETTINGS_INSTANCE_ID] = hass.data[DOMAIN][CONF_INSTANCE_ID] - await api.update_app_settings(settings) - _LOGGER.debug( - "Updated App Settings for SmartApp '%s' (%s)", app.app_name, app.app_id - ) - - # Set oauth scopes - oauth = AppOAuth(app.app_id) - oauth.client_name = APP_OAUTH_CLIENT_NAME - oauth.scope.extend(APP_OAUTH_SCOPES) - await api.update_app_oauth(oauth) - _LOGGER.debug("Updated App OAuth for SmartApp '%s' (%s)", app.app_name, app.app_id) - return app, client - - -async def update_app(hass: HomeAssistant, app): - """Ensure the SmartApp is up-to-date and update if necessary.""" - template = _get_app_template(hass) - template.pop("app_name") # don't update this - update_required = False - for key, value in template.items(): - if getattr(app, key) != value: - update_required = True - setattr(app, key, value) - if update_required: - await app.save() - _LOGGER.debug( - "SmartApp '%s' (%s) updated with latest settings", app.app_name, app.app_id - ) - - -def setup_smartapp(hass, app): - """Configure an individual SmartApp in hass. - - Register the SmartApp with the SmartAppManager so that hass will service - lifecycle events (install, event, etc...). A unique SmartApp is created - for each SmartThings account that is configured in hass. - """ - manager = hass.data[DOMAIN][DATA_MANAGER] - if smartapp := manager.smartapps.get(app.app_id): - # already setup - return smartapp - smartapp = manager.register(app.app_id, app.webhook_public_key) - smartapp.name = app.display_name - smartapp.description = app.description - smartapp.permissions.extend(APP_OAUTH_SCOPES) - return smartapp - - -async def setup_smartapp_endpoint(hass: HomeAssistant, fresh_install: bool): - """Configure the SmartApp webhook in hass. - - SmartApps are an extension point within the SmartThings ecosystem and - is used to receive push updates (i.e. device updates) from the cloud. - """ - if hass.data.get(DOMAIN): - # already setup - if not fresh_install: - return - - # We're doing a fresh install, clean up - await unload_smartapp_endpoint(hass) - - # Get/create config to store a unique id for this hass instance. - store = Store[dict[str, Any]](hass, STORAGE_VERSION, STORAGE_KEY) - - if fresh_install or not (config := await store.async_load()): - # Create config - config = { - CONF_INSTANCE_ID: str(uuid4()), - CONF_WEBHOOK_ID: secrets.token_hex(), - CONF_CLOUDHOOK_URL: None, - } - await store.async_save(config) - - # Register webhook - webhook.async_register( - hass, DOMAIN, "SmartApp", config[CONF_WEBHOOK_ID], smartapp_webhook - ) - - # Create webhook if eligible - cloudhook_url = config.get(CONF_CLOUDHOOK_URL) - if ( - cloudhook_url is None - and cloud.async_active_subscription(hass) - and not hass.config_entries.async_entries(DOMAIN) - ): - cloudhook_url = await cloud.async_create_cloudhook( - hass, config[CONF_WEBHOOK_ID] - ) - config[CONF_CLOUDHOOK_URL] = cloudhook_url - await store.async_save(config) - _LOGGER.debug("Created cloudhook '%s'", cloudhook_url) - - # SmartAppManager uses a dispatcher to invoke callbacks when push events - # occur. Use hass' implementation instead of the built-in one. - dispatcher = Dispatcher( - signal_prefix=SIGNAL_SMARTAPP_PREFIX, - connect=functools.partial(async_dispatcher_connect, hass), - send=functools.partial(async_dispatcher_send, hass), - ) - # Path is used in digital signature validation - path = ( - urlparse(cloudhook_url).path - if cloudhook_url - else webhook.async_generate_path(config[CONF_WEBHOOK_ID]) - ) - manager = SmartAppManager(path, dispatcher=dispatcher) - manager.connect_install(functools.partial(smartapp_install, hass)) - manager.connect_update(functools.partial(smartapp_update, hass)) - manager.connect_uninstall(functools.partial(smartapp_uninstall, hass)) - - hass.data[DOMAIN] = { - DATA_MANAGER: manager, - CONF_INSTANCE_ID: config[CONF_INSTANCE_ID], - DATA_BROKERS: {}, - CONF_WEBHOOK_ID: config[CONF_WEBHOOK_ID], - # Will not be present if not enabled - CONF_CLOUDHOOK_URL: config.get(CONF_CLOUDHOOK_URL), - } - _LOGGER.debug( - "Setup endpoint for %s", - cloudhook_url - if cloudhook_url - else webhook.async_generate_url(hass, config[CONF_WEBHOOK_ID]), - ) - - -async def unload_smartapp_endpoint(hass: HomeAssistant): - """Tear down the component configuration.""" - if DOMAIN not in hass.data: - return - # Remove the cloudhook if it was created - cloudhook_url = hass.data[DOMAIN][CONF_CLOUDHOOK_URL] - if cloudhook_url and cloud.async_is_logged_in(hass): - await cloud.async_delete_cloudhook(hass, hass.data[DOMAIN][CONF_WEBHOOK_ID]) - # Remove cloudhook from storage - store = Store[dict[str, Any]](hass, STORAGE_VERSION, STORAGE_KEY) - await store.async_save( - { - CONF_INSTANCE_ID: hass.data[DOMAIN][CONF_INSTANCE_ID], - CONF_WEBHOOK_ID: hass.data[DOMAIN][CONF_WEBHOOK_ID], - CONF_CLOUDHOOK_URL: None, - } - ) - _LOGGER.debug("Cloudhook '%s' was removed", cloudhook_url) - # Remove the webhook - webhook.async_unregister(hass, hass.data[DOMAIN][CONF_WEBHOOK_ID]) - # Disconnect all brokers - for broker in hass.data[DOMAIN][DATA_BROKERS].values(): - broker.disconnect() - # Remove all handlers from manager - hass.data[DOMAIN][DATA_MANAGER].dispatcher.disconnect_all() - # Remove the component data - hass.data.pop(DOMAIN) - - -async def smartapp_sync_subscriptions( - hass: HomeAssistant, - auth_token: str, - location_id: str, - installed_app_id: str, - devices, -): - """Synchronize subscriptions of an installed up.""" - api = SmartThings(async_get_clientsession(hass), auth_token) - tasks = [] - - async def create_subscription(target: str): - sub = Subscription() - sub.installed_app_id = installed_app_id - sub.location_id = location_id - sub.source_type = SourceType.CAPABILITY - sub.capability = target - try: - await api.create_subscription(sub) - _LOGGER.debug( - "Created subscription for '%s' under app '%s'", target, installed_app_id - ) - except Exception as error: # noqa: BLE001 - _LOGGER.error( - "Failed to create subscription for '%s' under app '%s': %s", - target, - installed_app_id, - error, - ) - - async def delete_subscription(sub: SubscriptionEntity): - try: - await api.delete_subscription(installed_app_id, sub.subscription_id) - _LOGGER.debug( - ( - "Removed subscription for '%s' under app '%s' because it was no" - " longer needed" - ), - sub.capability, - installed_app_id, - ) - except Exception as error: # noqa: BLE001 - _LOGGER.error( - "Failed to remove subscription for '%s' under app '%s': %s", - sub.capability, - installed_app_id, - error, - ) - - # Build set of capabilities and prune unsupported ones - capabilities = set() - for device in devices: - capabilities.update(device.capabilities) - # Remove items not defined in the library - capabilities.intersection_update(CAPABILITIES) - # Remove unused capabilities - capabilities.difference_update(IGNORED_CAPABILITIES) - capability_count = len(capabilities) - if capability_count > SUBSCRIPTION_WARNING_LIMIT: - _LOGGER.warning( - ( - "Some device attributes may not receive push updates and there may be" - " subscription creation failures under app '%s' because %s" - " subscriptions are required but there is a limit of %s per app" - ), - installed_app_id, - capability_count, - SUBSCRIPTION_WARNING_LIMIT, - ) - _LOGGER.debug( - "Synchronizing subscriptions for %s capabilities under app '%s': %s", - capability_count, - installed_app_id, - capabilities, - ) - - # Get current subscriptions and find differences - subscriptions = await api.subscriptions(installed_app_id) - for subscription in subscriptions: - if subscription.capability in capabilities: - capabilities.remove(subscription.capability) - else: - # Delete the subscription - tasks.append(delete_subscription(subscription)) - - # Remaining capabilities need subscriptions created - tasks.extend([create_subscription(c) for c in capabilities]) - - if tasks: - await asyncio.gather(*tasks) - else: - _LOGGER.debug("Subscriptions for app '%s' are up-to-date", installed_app_id) - - -async def _find_and_continue_flow( - hass: HomeAssistant, - app_id: str, - location_id: str, - installed_app_id: str, - refresh_token: str, -): - """Continue a config flow if one is in progress for the specific installed app.""" - unique_id = format_unique_id(app_id, location_id) - flow = next( - ( - flow - for flow in hass.config_entries.flow.async_progress_by_handler(DOMAIN) - if flow["context"].get("unique_id") == unique_id - ), - None, - ) - if flow is not None: - await _continue_flow(hass, app_id, installed_app_id, refresh_token, flow) - - -async def _continue_flow( - hass: HomeAssistant, - app_id: str, - installed_app_id: str, - refresh_token: str, - flow: ConfigFlowResult, -) -> None: - await hass.config_entries.flow.async_configure( - flow["flow_id"], - { - CONF_INSTALLED_APP_ID: installed_app_id, - CONF_REFRESH_TOKEN: refresh_token, - }, - ) - _LOGGER.debug( - "Continued config flow '%s' for SmartApp '%s' under parent app '%s'", - flow["flow_id"], - installed_app_id, - app_id, - ) - - -async def smartapp_install(hass: HomeAssistant, req, resp, app): - """Handle a SmartApp installation and continue the config flow.""" - await _find_and_continue_flow( - hass, app.app_id, req.location_id, req.installed_app_id, req.refresh_token - ) - _LOGGER.debug( - "Installed SmartApp '%s' under parent app '%s'", - req.installed_app_id, - app.app_id, - ) - - -async def smartapp_update(hass: HomeAssistant, req, resp, app): - """Handle a SmartApp update and either update the entry or continue the flow.""" - unique_id = format_unique_id(app.app_id, req.location_id) - flow = next( - ( - flow - for flow in hass.config_entries.flow.async_progress_by_handler(DOMAIN) - if flow["context"].get("unique_id") == unique_id - and flow["step_id"] == "authorize" - ), - None, - ) - if flow is not None: - await _continue_flow( - hass, app.app_id, req.installed_app_id, req.refresh_token, flow - ) - _LOGGER.debug( - "Continued reauth flow '%s' for SmartApp '%s' under parent app '%s'", - flow["flow_id"], - req.installed_app_id, - app.app_id, - ) - return - entry = next( - ( - entry - for entry in hass.config_entries.async_entries(DOMAIN) - if entry.data.get(CONF_INSTALLED_APP_ID) == req.installed_app_id - ), - None, - ) - if entry: - hass.config_entries.async_update_entry( - entry, data={**entry.data, CONF_REFRESH_TOKEN: req.refresh_token} - ) - _LOGGER.debug( - "Updated config entry '%s' for SmartApp '%s' under parent app '%s'", - entry.entry_id, - req.installed_app_id, - app.app_id, - ) - - await _find_and_continue_flow( - hass, app.app_id, req.location_id, req.installed_app_id, req.refresh_token - ) - _LOGGER.debug( - "Updated SmartApp '%s' under parent app '%s'", req.installed_app_id, app.app_id - ) - - -async def smartapp_uninstall(hass: HomeAssistant, req, resp, app): - """Handle when a SmartApp is removed from a location by the user. - - Find and delete the config entry representing the integration. - """ - entry = next( - ( - entry - for entry in hass.config_entries.async_entries(DOMAIN) - if entry.data.get(CONF_INSTALLED_APP_ID) == req.installed_app_id - ), - None, - ) - if entry: - # Add as job not needed because the current coroutine was invoked - # from the dispatcher and is not being awaited. - await hass.config_entries.async_remove(entry.entry_id) - - _LOGGER.debug( - "Uninstalled SmartApp '%s' under parent app '%s'", - req.installed_app_id, - app.app_id, - ) - - -async def smartapp_webhook(hass: HomeAssistant, webhook_id: str, request): - """Handle a smartapp lifecycle event callback from SmartThings. - - Requests from SmartThings are digitally signed and the SmartAppManager - validates the signature for authenticity. - """ - manager = hass.data[DOMAIN][DATA_MANAGER] - data = await request.json() - result = await manager.handle_request(data, request.headers) - return web.json_response(result) diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index 31a552be149..5112d819026 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -1,43 +1,29 @@ { "config": { "step": { - "user": { - "title": "Confirm Callback URL", - "description": "SmartThings will be configured to send push updates to Home Assistant at:\n> {webhook_url}\n\nIf this is not correct, please update your configuration, restart Home Assistant, and try again." + "pick_implementation": { + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" }, - "pat": { - "title": "Enter Personal Access Token", - "description": "Please enter a SmartThings [Personal Access Token]({token_url}) that has been created per the [instructions]({component_url}). This will be used to create the Home Assistant integration within your SmartThings account.\n\n**Please note that all Personal Access Tokens created after 30 December 2024 are only valid for 24 hours, after which the integration will stop working. We are working on a fix.**", - "data": { - "access_token": "[%key:common::config_flow::data::access_token%]" - } - }, - "select_location": { - "title": "Select Location", - "description": "Please select the SmartThings Location you wish to add to Home Assistant. We will then open a new window and ask you to login and authorize installation of the Home Assistant integration into the selected location.", - "data": { "location_id": "[%key:common::config_flow::data::location%]" } - }, - "authorize": { "title": "Authorize Home Assistant" }, "reauth_confirm": { - "title": "Reauthorize Home Assistant", - "description": "You are about to reauthorize Home Assistant with SmartThings. This will require you to log in and authorize the integration again." - }, - "update_confirm": { - "title": "Finish reauthentication", - "description": "You have almost successfully reauthorized Home Assistant with SmartThings. Please press the button down below to finish the process." + "title": "[%key:common::config_flow::title::reauth%]", + "description": "The SmartThings integration needs to re-authenticate your account" } }, - "abort": { - "invalid_webhook_url": "Home Assistant is not configured correctly to receive updates from SmartThings. The webhook URL is invalid:\n> {webhook_url}\n\nPlease update your configuration per the [instructions]({component_url}), restart Home Assistant, and try again.", - "no_available_locations": "There are no available SmartThings Locations to set up in Home Assistant.", - "reauth_successful": "Home Assistant has been successfully reauthorized with SmartThings." - }, "error": { - "token_invalid_format": "The token must be in the UID/GUID format", - "token_unauthorized": "The token is invalid or no longer authorized.", - "token_forbidden": "The token does not have the required OAuth scopes.", - "app_setup_error": "Unable to set up the SmartApp. Please try again.", - "webhook_error": "SmartThings could not validate the webhook URL. Please ensure the webhook URL is reachable from the internet and try again." + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + }, + "abort": { + "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", + "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", + "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", + "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", + "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "reauth_account_mismatch": "Authenticated account does not match the account to be reauthenticated. Please log in with the correct account and pick the right location.", + "reauth_location_mismatch": "Authenticated location does not match the location to be reauthenticated. Please log in with the correct account and pick the right location." } } } diff --git a/homeassistant/components/smartthings/switch.py b/homeassistant/components/smartthings/switch.py index 7a88ca0c422..d8cd9f1f956 100644 --- a/homeassistant/components/smartthings/switch.py +++ b/homeassistant/components/smartthings/switch.py @@ -2,60 +2,67 @@ from __future__ import annotations -from collections.abc import Sequence from typing import Any -from pysmartthings import Capability +from pysmartthings import Attribute, Capability, Command from homeassistant.components.switch import SwitchEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DATA_BROKERS, DOMAIN +from . import SmartThingsConfigEntry +from .const import MAIN from .entity import SmartThingsEntity +CAPABILITIES = ( + Capability.SWITCH_LEVEL, + Capability.COLOR_CONTROL, + Capability.COLOR_TEMPERATURE, + Capability.FAN_SPEED, +) + +AC_CAPABILITIES = ( + Capability.AIR_CONDITIONER_MODE, + Capability.AIR_CONDITIONER_FAN_MODE, + Capability.TEMPERATURE_MEASUREMENT, + Capability.THERMOSTAT_COOLING_SETPOINT, +) + async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + entry: SmartThingsConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add switches for a config entry.""" - broker = hass.data[DOMAIN][DATA_BROKERS][config_entry.entry_id] + entry_data = entry.runtime_data async_add_entities( - SmartThingsSwitch(device) - for device in broker.devices.values() - if broker.any_assigned(device.device_id, "switch") + SmartThingsSwitch(entry_data.client, device, {Capability.SWITCH}) + for device in entry_data.devices.values() + if Capability.SWITCH in device.status[MAIN] + and not any(capability in device.status[MAIN] for capability in CAPABILITIES) + and not all(capability in device.status[MAIN] for capability in AC_CAPABILITIES) ) -def get_capabilities(capabilities: Sequence[str]) -> Sequence[str] | None: - """Return all capabilities supported if minimum required are present.""" - # Must be able to be turned on/off. - if Capability.switch in capabilities: - return [Capability.switch, Capability.energy_meter, Capability.power_meter] - return None - - class SmartThingsSwitch(SmartThingsEntity, SwitchEntity): """Define a SmartThings switch.""" async def async_turn_off(self, **kwargs: Any) -> None: """Turn the switch off.""" - await self._device.switch_off(set_status=True) - # State is set optimistically in the command above, therefore update - # the entity state ahead of receiving the confirming push updates - self.async_write_ha_state() + await self.execute_device_command( + Capability.SWITCH, + Command.OFF, + ) async def async_turn_on(self, **kwargs: Any) -> None: """Turn the switch on.""" - await self._device.switch_on(set_status=True) - # State is set optimistically in the command above, therefore update - # the entity state ahead of receiving the confirming push updates - self.async_write_ha_state() + await self.execute_device_command( + Capability.SWITCH, + Command.ON, + ) @property def is_on(self) -> bool: """Return true if light is on.""" - return self._device.status.switch + return self.get_attribute_value(Capability.SWITCH, Attribute.SWITCH) == "on" diff --git a/homeassistant/generated/application_credentials.py b/homeassistant/generated/application_credentials.py index 08fe28e4df5..b891e807a7f 100644 --- a/homeassistant/generated/application_credentials.py +++ b/homeassistant/generated/application_credentials.py @@ -28,6 +28,7 @@ APPLICATION_CREDENTIALS = [ "onedrive", "point", "senz", + "smartthings", "spotify", "tesla_fleet", "twitch", diff --git a/requirements_all.txt b/requirements_all.txt index 40df67dc93f..54c0a29bee5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2310,10 +2310,7 @@ pysma==0.7.5 pysmappee==0.2.29 # homeassistant.components.smartthings -pysmartapp==0.3.5 - -# homeassistant.components.smartthings -pysmartthings==0.7.8 +pysmartthings==1.2.0 # homeassistant.components.smarty pysmarty2==0.10.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 029b770512e..a3f171fa1a9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1882,10 +1882,7 @@ pysma==0.7.5 pysmappee==0.2.29 # homeassistant.components.smartthings -pysmartapp==0.3.5 - -# homeassistant.components.smartthings -pysmartthings==0.7.8 +pysmartthings==1.2.0 # homeassistant.components.smarty pysmarty2==0.10.2 diff --git a/tests/components/smartthings/__init__.py b/tests/components/smartthings/__init__.py index 5a3e9135963..94a2e7512f2 100644 --- a/tests/components/smartthings/__init__.py +++ b/tests/components/smartthings/__init__.py @@ -1 +1,75 @@ -"""Tests for the SmartThings component.""" +"""Tests for the SmartThings integration.""" + +from typing import Any +from unittest.mock import AsyncMock + +from pysmartthings.models import Attribute, Capability, DeviceEvent +from syrupy import SnapshotAssertion + +from homeassistant.components.smartthings.const import MAIN +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +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) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + +def snapshot_smartthings_entities( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + platform: Platform, +) -> None: + """Snapshot SmartThings entities.""" + entities = hass.states.async_all(platform) + for entity_state in entities: + entity_entry = entity_registry.async_get(entity_state.entity_id) + assert entity_entry == snapshot(name=f"{entity_entry.entity_id}-entry") + assert entity_state == snapshot(name=f"{entity_entry.entity_id}-state") + + +def set_attribute_value( + mock: AsyncMock, + capability: Capability, + attribute: Attribute, + value: Any, + component: str = MAIN, +) -> None: + """Set the value of an attribute.""" + mock.get_device_status.return_value[component][capability][attribute].value = value + + +async def trigger_update( + hass: HomeAssistant, + mock: AsyncMock, + device_id: str, + capability: Capability, + attribute: Attribute, + value: str | float | dict[str, Any] | list[Any] | None, + data: dict[str, Any] | None = None, +) -> None: + """Trigger an update.""" + for call in mock.add_device_event_listener.call_args_list: + if call[0][0] == device_id and call[0][2] == capability: + call[0][3]( + DeviceEvent( + "abc", + "abc", + "abc", + device_id, + MAIN, + capability, + attribute, + value, + data, + ) + ) + await hass.async_block_till_done() diff --git a/tests/components/smartthings/conftest.py b/tests/components/smartthings/conftest.py index 71a36c7885a..b7d0cb61607 100644 --- a/tests/components/smartthings/conftest.py +++ b/tests/components/smartthings/conftest.py @@ -1,358 +1,178 @@ """Test configuration and mocks for the SmartThings component.""" -import secrets -from typing import Any -from unittest.mock import Mock, patch -from uuid import uuid4 +from collections.abc import Generator +import time +from unittest.mock import AsyncMock, patch -from pysmartthings import ( - CLASSIFICATION_AUTOMATION, - AppEntity, - AppOAuthClient, - AppSettings, - DeviceEntity, +from pysmartthings.models import ( + DeviceResponse, DeviceStatus, - InstalledApp, - InstalledAppStatus, - InstalledAppType, - Location, - SceneEntity, - SmartThings, - Subscription, + LocationResponse, + SceneResponse, ) -from pysmartthings.api import Api import pytest -from homeassistant.components import webhook -from homeassistant.components.smartthings import DeviceBroker +from homeassistant.components.application_credentials import ( + ClientCredential, + async_import_client_credential, +) +from homeassistant.components.smartthings import CONF_INSTALLED_APP_ID from homeassistant.components.smartthings.const import ( - APP_NAME_PREFIX, - CONF_APP_ID, - CONF_INSTALLED_APP_ID, - CONF_INSTANCE_ID, CONF_LOCATION_ID, CONF_REFRESH_TOKEN, - DATA_BROKERS, DOMAIN, - SETTINGS_INSTANCE_ID, - STORAGE_KEY, - STORAGE_VERSION, -) -from homeassistant.config_entries import SOURCE_USER, ConfigEntryState -from homeassistant.const import ( - CONF_ACCESS_TOKEN, - CONF_CLIENT_ID, - CONF_CLIENT_SECRET, - CONF_WEBHOOK_ID, + SCOPES, ) +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_CLIENT_ID, CONF_CLIENT_SECRET from homeassistant.core import HomeAssistant -from homeassistant.core_config import async_process_ha_core_config from homeassistant.setup import async_setup_component -from tests.common import MockConfigEntry -from tests.components.light.conftest import mock_light_profiles # noqa: F401 - -COMPONENT_PREFIX = "homeassistant.components.smartthings." +from tests.common import MockConfigEntry, load_fixture -async def setup_platform( - hass: HomeAssistant, platform: str, *, devices=None, scenes=None -): - """Set up the SmartThings platform and prerequisites.""" - hass.config.components.add(DOMAIN) - config_entry = MockConfigEntry( - version=2, - domain=DOMAIN, - title="Test", - data={CONF_INSTALLED_APP_ID: str(uuid4())}, - ) - config_entry.add_to_hass(hass) - broker = DeviceBroker( - hass, config_entry, Mock(), Mock(), devices or [], scenes or [] - ) +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.smartthings.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry - hass.data[DOMAIN] = {DATA_BROKERS: {config_entry.entry_id: broker}} - config_entry.mock_state(hass, ConfigEntryState.LOADED) - await hass.config_entries.async_forward_entry_setups(config_entry, [platform]) - await hass.async_block_till_done() - return config_entry + +@pytest.fixture(name="expires_at") +def mock_expires_at() -> int: + """Fixture to set the oauth token expiration time.""" + return time.time() + 3600 @pytest.fixture(autouse=True) -async def setup_component( - hass: HomeAssistant, config_file: dict[str, str], hass_storage: dict[str, Any] -) -> None: - """Load the SmartThing component.""" - hass_storage[STORAGE_KEY] = {"data": config_file, "version": STORAGE_VERSION} - await async_process_ha_core_config( +async def setup_credentials(hass: HomeAssistant) -> None: + """Fixture to setup credentials.""" + assert await async_setup_component(hass, "application_credentials", {}) + await async_import_client_credential( hass, - {"external_url": "https://test.local"}, - ) - await async_setup_component(hass, "smartthings", {}) - - -def _create_location() -> Mock: - loc = Mock(Location) - loc.name = "Test Location" - loc.location_id = str(uuid4()) - return loc - - -@pytest.fixture(name="location") -def location_fixture() -> Mock: - """Fixture for a single location.""" - return _create_location() - - -@pytest.fixture(name="locations") -def locations_fixture(location: Mock) -> list[Mock]: - """Fixture for 2 locations.""" - return [location, _create_location()] - - -@pytest.fixture(name="app") -async def app_fixture(hass: HomeAssistant, config_file: dict[str, str]) -> Mock: - """Fixture for a single app.""" - app = Mock(AppEntity) - app.app_name = APP_NAME_PREFIX + str(uuid4()) - app.app_id = str(uuid4()) - app.app_type = "WEBHOOK_SMART_APP" - app.classifications = [CLASSIFICATION_AUTOMATION] - app.display_name = "Home Assistant" - app.description = f"{hass.config.location_name} at https://test.local" - app.single_instance = True - app.webhook_target_url = webhook.async_generate_url( - hass, hass.data[DOMAIN][CONF_WEBHOOK_ID] + DOMAIN, + ClientCredential("CLIENT_ID", "CLIENT_SECRET"), + DOMAIN, ) - settings = Mock(AppSettings) - settings.app_id = app.app_id - settings.settings = {SETTINGS_INSTANCE_ID: config_file[CONF_INSTANCE_ID]} - app.settings.return_value = settings - return app - -@pytest.fixture(name="app_oauth_client") -def app_oauth_client_fixture() -> Mock: - """Fixture for a single app's oauth.""" - client = Mock(AppOAuthClient) - client.client_id = str(uuid4()) - client.client_secret = str(uuid4()) - return client - - -@pytest.fixture(name="app_settings") -def app_settings_fixture(app, config_file): - """Fixture for an app settings.""" - settings = Mock(AppSettings) - settings.app_id = app.app_id - settings.settings = {SETTINGS_INSTANCE_ID: config_file[CONF_INSTANCE_ID]} - return settings - - -def _create_installed_app(location_id: str, app_id: str) -> Mock: - item = Mock(InstalledApp) - item.installed_app_id = str(uuid4()) - item.installed_app_status = InstalledAppStatus.AUTHORIZED - item.installed_app_type = InstalledAppType.WEBHOOK_SMART_APP - item.app_id = app_id - item.location_id = location_id - return item - - -@pytest.fixture(name="installed_app") -def installed_app_fixture(location: Mock, app: Mock) -> Mock: - """Fixture for a single installed app.""" - return _create_installed_app(location.location_id, app.app_id) - - -@pytest.fixture(name="installed_apps") -def installed_apps_fixture(installed_app, locations, app): - """Fixture for 2 installed apps.""" - return [installed_app, _create_installed_app(locations[1].location_id, app.app_id)] - - -@pytest.fixture(name="config_file") -def config_file_fixture() -> dict[str, str]: - """Fixture representing the local config file contents.""" - return {CONF_INSTANCE_ID: str(uuid4()), CONF_WEBHOOK_ID: secrets.token_hex()} - - -@pytest.fixture(name="smartthings_mock") -def smartthings_mock_fixture(locations): - """Fixture to mock smartthings API calls.""" - - async def _location(location_id): - return next( - location for location in locations if location.location_id == location_id - ) - - smartthings_mock = Mock(SmartThings) - smartthings_mock.location.side_effect = _location - mock = Mock(return_value=smartthings_mock) +@pytest.fixture +def mock_smartthings() -> Generator[AsyncMock]: + """Mock a SmartThings client.""" with ( - patch(COMPONENT_PREFIX + "SmartThings", new=mock), - patch(COMPONENT_PREFIX + "config_flow.SmartThings", new=mock), - patch(COMPONENT_PREFIX + "smartapp.SmartThings", new=mock), + patch( + "homeassistant.components.smartthings.SmartThings", + autospec=True, + ) as mock_client, + patch( + "homeassistant.components.smartthings.config_flow.SmartThings", + new=mock_client, + ), ): - yield smartthings_mock + client = mock_client.return_value + client.get_scenes.return_value = SceneResponse.from_json( + load_fixture("scenes.json", DOMAIN) + ).items + client.get_locations.return_value = LocationResponse.from_json( + load_fixture("locations.json", DOMAIN) + ).items + yield client -@pytest.fixture(name="device") -def device_fixture(location): - """Fixture representing devices loaded.""" - item = Mock(DeviceEntity) - item.device_id = "743de49f-036f-4e9c-839a-2f89d57607db" - item.name = "GE In-Wall Smart Dimmer" - item.label = "Front Porch Lights" - item.location_id = location.location_id - item.capabilities = [ - "switch", - "switchLevel", - "refresh", - "indicator", - "sensor", - "actuator", - "healthCheck", - "light", +@pytest.fixture( + params=[ + "da_ac_rac_000001", + "da_ac_rac_01001", + "multipurpose_sensor", + "contact_sensor", + "base_electric_meter", + "smart_plug", + "vd_stv_2017_k", + "c2c_arlo_pro_3_switch", + "yale_push_button_deadbolt_lock", + "ge_in_wall_smart_dimmer", + "centralite", + "da_ref_normal_000001", + "vd_network_audio_002s", + "iphone", + "da_wm_dw_000001", + "da_wm_wd_000001", + "da_wm_wm_000001", + "da_rvc_normal_000001", + "da_ks_microwave_0101x", + "hue_color_temperature_bulb", + "hue_rgbw_color_bulb", + "c2c_shade", + "sonos_player", + "aeotec_home_energy_meter_gen5", + "virtual_water_sensor", + "virtual_thermostat", + "virtual_valve", + "sensibo_airconditioner_1", + "ecobee_sensor", + "ecobee_thermostat", + "fake_fan", ] - item.components = {"main": item.capabilities} - item.status = Mock(DeviceStatus) - return item +) +def device_fixture( + mock_smartthings: AsyncMock, request: pytest.FixtureRequest +) -> Generator[str]: + """Return every device.""" + return request.param -@pytest.fixture(name="config_entry") -def config_entry_fixture(installed_app: Mock, location: Mock) -> MockConfigEntry: - """Fixture representing a config entry.""" - data = { - CONF_ACCESS_TOKEN: str(uuid4()), - CONF_INSTALLED_APP_ID: installed_app.installed_app_id, - CONF_APP_ID: installed_app.app_id, - CONF_LOCATION_ID: location.location_id, - CONF_REFRESH_TOKEN: str(uuid4()), - CONF_CLIENT_ID: str(uuid4()), - CONF_CLIENT_SECRET: str(uuid4()), - } +@pytest.fixture +def devices(mock_smartthings: AsyncMock, device_fixture: str) -> Generator[AsyncMock]: + """Return a specific device.""" + mock_smartthings.get_devices.return_value = DeviceResponse.from_json( + load_fixture(f"devices/{device_fixture}.json", DOMAIN) + ).items + mock_smartthings.get_device_status.return_value = DeviceStatus.from_json( + load_fixture(f"device_status/{device_fixture}.json", DOMAIN) + ).components + return mock_smartthings + + +@pytest.fixture +def mock_config_entry(expires_at: int) -> MockConfigEntry: + """Mock a config entry.""" return MockConfigEntry( domain=DOMAIN, - data=data, - title=location.name, - version=2, - source=SOURCE_USER, + title="My home", + unique_id="397678e5-9995-4a39-9d9f-ae6ba310236c", + data={ + "auth_implementation": DOMAIN, + "token": { + "access_token": "mock-access-token", + "refresh_token": "mock-refresh-token", + "expires_at": expires_at, + "scope": " ".join(SCOPES), + "access_tier": 0, + "installed_app_id": "5aaaa925-2be1-4e40-b257-e4ef59083324", + }, + CONF_LOCATION_ID: "397678e5-9995-4a39-9d9f-ae6ba310236c", + CONF_INSTALLED_APP_ID: "123", + }, + version=3, ) -@pytest.fixture(name="subscription_factory") -def subscription_factory_fixture(): - """Fixture for creating mock subscriptions.""" - - def _factory(capability): - sub = Subscription() - sub.capability = capability - return sub - - return _factory - - -@pytest.fixture(name="device_factory") -def device_factory_fixture(): - """Fixture for creating mock devices.""" - api = Mock(Api) - api.post_device_command.return_value = {"results": [{"status": "ACCEPTED"}]} - - def _factory(label, capabilities, status: dict | None = None): - device_data = { - "deviceId": str(uuid4()), - "name": "Device Type Handler Name", - "label": label, - "deviceManufacturerCode": "9135fc86-0929-4436-bf73-5d75f523d9db", - "locationId": "fcd829e9-82f4-45b9-acfd-62fda029af80", - "components": [ - { - "id": "main", - "capabilities": [ - {"id": capability, "version": 1} for capability in capabilities - ], - } - ], - "dth": { - "deviceTypeId": "b678b29d-2726-4e4f-9c3f-7aa05bd08964", - "deviceTypeName": "Switch", - "deviceNetworkType": "ZWAVE", - }, - "type": "DTH", - } - device = DeviceEntity(api, data=device_data) - if status: - for attribute, value in status.items(): - device.status.apply_attribute_update("main", "", attribute, value) - return device - - return _factory - - -@pytest.fixture(name="scene_factory") -def scene_factory_fixture(location): - """Fixture for creating mock devices.""" - - def _factory(name): - scene = Mock(SceneEntity) - scene.scene_id = str(uuid4()) - scene.name = name - scene.icon = None - scene.color = None - scene.location_id = location.location_id - return scene - - return _factory - - -@pytest.fixture(name="scene") -def scene_fixture(scene_factory): - """Fixture for an individual scene.""" - return scene_factory("Test Scene") - - -@pytest.fixture(name="event_factory") -def event_factory_fixture(): - """Fixture for creating mock devices.""" - - def _factory( - device_id, - event_type="DEVICE_EVENT", - capability="", - attribute="Updated", - value="Value", - data=None, - ): - event = Mock() - event.event_type = event_type - event.device_id = device_id - event.component_id = "main" - event.capability = capability - event.attribute = attribute - event.value = value - event.data = data - event.location_id = str(uuid4()) - return event - - return _factory - - -@pytest.fixture(name="event_request_factory") -def event_request_factory_fixture(event_factory): - """Fixture for creating mock smartapp event requests.""" - - def _factory(device_ids=None, events=None): - request = Mock() - request.installed_app_id = uuid4() - if events is None: - events = [] - if device_ids: - events.extend([event_factory(device_id) for device_id in device_ids]) - events.append(event_factory(uuid4())) - events.append(event_factory(device_ids[0], event_type="OTHER")) - request.events = events - return request - - return _factory +@pytest.fixture +def mock_old_config_entry() -> MockConfigEntry: + """Mock the old config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="My home", + unique_id="appid123-2be1-4e40-b257-e4ef59083324_397678e5-9995-4a39-9d9f-ae6ba310236c", + data={ + CONF_ACCESS_TOKEN: "mock-access-token", + CONF_REFRESH_TOKEN: "mock-refresh-token", + CONF_CLIENT_ID: "CLIENT_ID", + CONF_CLIENT_SECRET: "CLIENT_SECRET", + CONF_LOCATION_ID: "397678e5-9995-4a39-9d9f-ae6ba310236c", + CONF_INSTALLED_APP_ID: "123aa123-2be1-4e40-b257-e4ef59083324", + }, + version=2, + ) diff --git a/tests/components/smartthings/fixtures/device_status/aeotec_home_energy_meter_gen5.json b/tests/components/smartthings/fixtures/device_status/aeotec_home_energy_meter_gen5.json new file mode 100644 index 00000000000..95ae6310be8 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/aeotec_home_energy_meter_gen5.json @@ -0,0 +1,31 @@ +{ + "components": { + "main": { + "powerMeter": { + "power": { + "value": 2859.743, + "unit": "W", + "timestamp": "2025-02-10T21:09:08.228Z" + } + }, + "voltageMeasurement": { + "voltage": { + "value": null + } + }, + "powerConsumptionReport": { + "powerConsumption": { + "value": null + } + }, + "energyMeter": { + "energy": { + "value": 19978.536, + "unit": "kWh", + "timestamp": "2025-02-10T21:09:08.357Z" + } + }, + "refresh": {} + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/base_electric_meter.json b/tests/components/smartthings/fixtures/device_status/base_electric_meter.json new file mode 100644 index 00000000000..b4fa67b6f7e --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/base_electric_meter.json @@ -0,0 +1,21 @@ +{ + "components": { + "main": { + "powerMeter": { + "power": { + "value": 938.3, + "unit": "W", + "timestamp": "2025-02-09T17:56:21.748Z" + } + }, + "energyMeter": { + "energy": { + "value": 1930.362, + "unit": "kWh", + "timestamp": "2025-02-09T17:56:21.918Z" + } + }, + "refresh": {} + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/c2c_arlo_pro_3_switch.json b/tests/components/smartthings/fixtures/device_status/c2c_arlo_pro_3_switch.json new file mode 100644 index 00000000000..371a779f83c --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/c2c_arlo_pro_3_switch.json @@ -0,0 +1,82 @@ +{ + "components": { + "main": { + "videoCapture": { + "stream": { + "value": null + }, + "clip": { + "value": null + } + }, + "videoStream": { + "supportedFeatures": { + "value": null + }, + "stream": { + "value": null + } + }, + "healthCheck": { + "checkInterval": { + "value": 60, + "unit": "s", + "data": { + "deviceScheme": "UNTRACKED", + "protocol": "cloud" + }, + "timestamp": "2025-02-03T21:55:57.991Z" + }, + "healthStatus": { + "value": null + }, + "DeviceWatch-Enroll": { + "value": null + }, + "DeviceWatch-DeviceStatus": { + "value": "online", + "data": {}, + "timestamp": "2025-02-08T21:56:09.761Z" + } + }, + "alarm": { + "alarm": { + "value": "off", + "timestamp": "2025-02-08T21:56:09.761Z" + } + }, + "refresh": {}, + "soundSensor": { + "sound": { + "value": "not detected", + "timestamp": "2025-02-08T21:56:09.761Z" + } + }, + "motionSensor": { + "motion": { + "value": "inactive", + "timestamp": "2025-02-08T21:56:09.761Z" + } + }, + "battery": { + "quantity": { + "value": null + }, + "battery": { + "value": 100, + "unit": "%", + "timestamp": "2025-02-08T21:56:10.041Z" + }, + "type": { + "value": null + } + }, + "switch": { + "switch": { + "value": "on", + "timestamp": "2025-02-08T21:56:10.041Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/c2c_shade.json b/tests/components/smartthings/fixtures/device_status/c2c_shade.json new file mode 100644 index 00000000000..cc5bcd84482 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/c2c_shade.json @@ -0,0 +1,50 @@ +{ + "components": { + "main": { + "healthCheck": { + "checkInterval": { + "value": 60, + "unit": "s", + "data": { + "deviceScheme": "UNTRACKED", + "protocol": "cloud" + }, + "timestamp": "2025-02-07T23:01:15.966Z" + }, + "healthStatus": { + "value": null + }, + "DeviceWatch-Enroll": { + "value": null + }, + "DeviceWatch-DeviceStatus": { + "value": "offline", + "data": { + "reason": "DEVICE-OFFLINE" + }, + "timestamp": "2025-02-08T09:04:47.694Z" + } + }, + "switchLevel": { + "levelRange": { + "value": null + }, + "level": { + "value": 100, + "unit": "%", + "timestamp": "2025-02-08T09:04:47.694Z" + } + }, + "refresh": {}, + "windowShade": { + "supportedWindowShadeCommands": { + "value": null + }, + "windowShade": { + "value": "open", + "timestamp": "2025-02-08T09:04:47.694Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/centralite.json b/tests/components/smartthings/fixtures/device_status/centralite.json new file mode 100644 index 00000000000..efdf54d9128 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/centralite.json @@ -0,0 +1,60 @@ +{ + "components": { + "main": { + "powerMeter": { + "power": { + "value": 0.0, + "unit": "W", + "timestamp": "2025-02-09T17:49:15.190Z" + } + }, + "switchLevel": { + "levelRange": { + "value": null + }, + "level": { + "value": 0, + "unit": "%", + "timestamp": "2025-02-09T17:49:15.112Z" + } + }, + "refresh": {}, + "firmwareUpdate": { + "lastUpdateStatusReason": { + "value": null + }, + "availableVersion": { + "value": "16015010", + "timestamp": "2025-01-26T10:19:54.783Z" + }, + "lastUpdateStatus": { + "value": null + }, + "supportedCommands": { + "value": null + }, + "state": { + "value": "normalOperation", + "timestamp": "2025-01-26T10:19:54.788Z" + }, + "updateAvailable": { + "value": false, + "timestamp": "2025-01-26T10:19:54.789Z" + }, + "currentVersion": { + "value": "16015010", + "timestamp": "2025-01-26T10:19:54.775Z" + }, + "lastUpdateTime": { + "value": null + } + }, + "switch": { + "switch": { + "value": "off", + "timestamp": "2025-02-09T17:24:16.864Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/contact_sensor.json b/tests/components/smartthings/fixtures/device_status/contact_sensor.json new file mode 100644 index 00000000000..fa158d41b39 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/contact_sensor.json @@ -0,0 +1,66 @@ +{ + "components": { + "main": { + "contactSensor": { + "contact": { + "value": "closed", + "timestamp": "2025-02-09T17:16:42.674Z" + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": 59.0, + "unit": "F", + "timestamp": "2025-02-09T17:11:44.249Z" + } + }, + "refresh": {}, + "battery": { + "quantity": { + "value": null + }, + "battery": { + "value": 100, + "unit": "%", + "timestamp": "2025-02-09T13:23:50.726Z" + }, + "type": { + "value": null + } + }, + "firmwareUpdate": { + "lastUpdateStatusReason": { + "value": null + }, + "availableVersion": { + "value": "00000103", + "timestamp": "2025-02-09T13:59:19.101Z" + }, + "lastUpdateStatus": { + "value": null + }, + "supportedCommands": { + "value": null + }, + "state": { + "value": "normalOperation", + "timestamp": "2025-02-09T13:59:19.101Z" + }, + "updateAvailable": { + "value": false, + "timestamp": "2025-02-09T13:59:19.102Z" + }, + "currentVersion": { + "value": "00000103", + "timestamp": "2025-02-09T13:59:19.102Z" + }, + "lastUpdateTime": { + "value": null + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/da_ac_rac_000001.json b/tests/components/smartthings/fixtures/device_status/da_ac_rac_000001.json new file mode 100644 index 00000000000..c80fcf9c298 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/da_ac_rac_000001.json @@ -0,0 +1,879 @@ +{ + "components": { + "1": { + "relativeHumidityMeasurement": { + "humidity": { + "value": 0, + "unit": "%", + "timestamp": "2021-04-06T16:43:35.291Z" + } + }, + "custom.airConditionerOdorController": { + "airConditionerOdorControllerProgress": { + "value": null, + "timestamp": "2021-04-08T04:11:38.269Z" + }, + "airConditionerOdorControllerState": { + "value": null, + "timestamp": "2021-04-08T04:11:38.269Z" + } + }, + "custom.thermostatSetpointControl": { + "minimumSetpoint": { + "value": null, + "timestamp": "2021-04-08T04:04:19.901Z" + }, + "maximumSetpoint": { + "value": null, + "timestamp": "2021-04-08T04:04:19.901Z" + } + }, + "airConditionerMode": { + "availableAcModes": { + "value": null + }, + "supportedAcModes": { + "value": null, + "timestamp": "2021-04-08T03:50:50.930Z" + }, + "airConditionerMode": { + "value": null, + "timestamp": "2021-04-08T03:50:50.930Z" + } + }, + "custom.spiMode": { + "spiMode": { + "value": null, + "timestamp": "2021-04-06T16:57:57.686Z" + } + }, + "airQualitySensor": { + "airQuality": { + "value": null, + "unit": "CAQI", + "timestamp": "2021-04-06T16:57:57.602Z" + } + }, + "custom.airConditionerOptionalMode": { + "supportedAcOptionalMode": { + "value": null, + "timestamp": "2021-04-06T16:57:57.659Z" + }, + "acOptionalMode": { + "value": null, + "timestamp": "2021-04-06T16:57:57.659Z" + } + }, + "switch": { + "switch": { + "value": null, + "timestamp": "2021-04-06T16:44:10.518Z" + } + }, + "custom.airConditionerTropicalNightMode": { + "acTropicalNightModeLevel": { + "value": null, + "timestamp": "2021-04-06T16:44:10.498Z" + } + }, + "ocf": { + "st": { + "value": null, + "timestamp": "2021-04-06T16:44:10.472Z" + }, + "mndt": { + "value": null, + "timestamp": "2021-04-06T16:44:10.472Z" + }, + "mnfv": { + "value": null, + "timestamp": "2021-04-06T16:44:10.472Z" + }, + "mnhw": { + "value": null, + "timestamp": "2021-04-06T16:44:10.472Z" + }, + "di": { + "value": null, + "timestamp": "2021-04-06T16:44:10.472Z" + }, + "mnsl": { + "value": null, + "timestamp": "2021-04-06T16:44:10.472Z" + }, + "dmv": { + "value": null, + "timestamp": "2021-04-06T16:44:10.472Z" + }, + "n": { + "value": null, + "timestamp": "2021-04-06T16:44:10.472Z" + }, + "mnmo": { + "value": null, + "timestamp": "2021-04-06T16:44:10.472Z" + }, + "vid": { + "value": null, + "timestamp": "2021-04-06T16:44:10.472Z" + }, + "mnmn": { + "value": null, + "timestamp": "2021-04-06T16:44:10.472Z" + }, + "mnml": { + "value": null, + "timestamp": "2021-04-06T16:44:10.472Z" + }, + "mnpv": { + "value": null, + "timestamp": "2021-04-06T16:44:10.472Z" + }, + "mnos": { + "value": null, + "timestamp": "2021-04-06T16:44:10.472Z" + }, + "pi": { + "value": null, + "timestamp": "2021-04-06T16:44:10.472Z" + }, + "icv": { + "value": null, + "timestamp": "2021-04-06T16:44:10.472Z" + } + }, + "airConditionerFanMode": { + "fanMode": { + "value": null, + "timestamp": "2021-04-06T16:44:10.381Z" + }, + "supportedAcFanModes": { + "value": ["auto", "low", "medium", "high", "turbo"], + "timestamp": "2024-09-10T10:26:28.605Z" + }, + "availableAcFanModes": { + "value": null + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": [ + "remoteControlStatus", + "airQualitySensor", + "dustSensor", + "odorSensor", + "veryFineDustSensor", + "custom.dustFilter", + "custom.deodorFilter", + "custom.deviceReportStateConfiguration", + "audioVolume", + "custom.autoCleaningMode", + "custom.airConditionerTropicalNightMode", + "custom.airConditionerOdorController", + "demandResponseLoadControl", + "relativeHumidityMeasurement" + ], + "timestamp": "2024-09-10T10:26:28.605Z" + } + }, + "fanOscillationMode": { + "supportedFanOscillationModes": { + "value": null, + "timestamp": "2021-04-06T16:44:10.325Z" + }, + "availableFanOscillationModes": { + "value": null + }, + "fanOscillationMode": { + "value": "fixed", + "timestamp": "2025-02-08T00:44:53.247Z" + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": null, + "timestamp": "2021-04-06T16:44:10.373Z" + } + }, + "dustSensor": { + "dustLevel": { + "value": null, + "unit": "\u03bcg/m^3", + "timestamp": "2021-04-06T16:44:10.122Z" + }, + "fineDustLevel": { + "value": null, + "unit": "\u03bcg/m^3", + "timestamp": "2021-04-06T16:44:10.122Z" + } + }, + "custom.deviceReportStateConfiguration": { + "reportStateRealtimePeriod": { + "value": null, + "timestamp": "2021-04-06T16:44:09.800Z" + }, + "reportStateRealtime": { + "value": null, + "timestamp": "2021-04-06T16:44:09.800Z" + }, + "reportStatePeriod": { + "value": null, + "timestamp": "2021-04-06T16:44:09.800Z" + } + }, + "thermostatCoolingSetpoint": { + "coolingSetpointRange": { + "value": null + }, + "coolingSetpoint": { + "value": null, + "timestamp": "2021-04-06T16:43:59.136Z" + } + }, + "demandResponseLoadControl": { + "drlcStatus": { + "value": null, + "timestamp": "2021-04-06T16:43:54.748Z" + } + }, + "audioVolume": { + "volume": { + "value": null, + "unit": "%", + "timestamp": "2021-04-06T16:43:53.541Z" + } + }, + "powerConsumptionReport": { + "powerConsumption": { + "value": null, + "timestamp": "2021-04-06T16:43:53.364Z" + } + }, + "custom.autoCleaningMode": { + "supportedAutoCleaningModes": { + "value": null + }, + "timedCleanDuration": { + "value": null + }, + "operatingState": { + "value": null + }, + "timedCleanDurationRange": { + "value": null + }, + "supportedOperatingStates": { + "value": null + }, + "progress": { + "value": null + }, + "autoCleaningMode": { + "value": null, + "timestamp": "2021-04-06T16:43:53.344Z" + } + }, + "custom.dustFilter": { + "dustFilterUsageStep": { + "value": null, + "timestamp": "2021-04-06T16:43:39.145Z" + }, + "dustFilterUsage": { + "value": null, + "timestamp": "2021-04-06T16:43:39.145Z" + }, + "dustFilterLastResetDate": { + "value": null, + "timestamp": "2021-04-06T16:43:39.145Z" + }, + "dustFilterStatus": { + "value": null, + "timestamp": "2021-04-06T16:43:39.145Z" + }, + "dustFilterCapacity": { + "value": null, + "timestamp": "2021-04-06T16:43:39.145Z" + }, + "dustFilterResetType": { + "value": null, + "timestamp": "2021-04-06T16:43:39.145Z" + } + }, + "odorSensor": { + "odorLevel": { + "value": null, + "timestamp": "2021-04-06T16:43:38.992Z" + } + }, + "remoteControlStatus": { + "remoteControlEnabled": { + "value": null, + "timestamp": "2021-04-06T16:43:39.097Z" + } + }, + "custom.deodorFilter": { + "deodorFilterCapacity": { + "value": null, + "timestamp": "2021-04-06T16:43:39.118Z" + }, + "deodorFilterLastResetDate": { + "value": null, + "timestamp": "2021-04-06T16:43:39.118Z" + }, + "deodorFilterStatus": { + "value": null, + "timestamp": "2021-04-06T16:43:39.118Z" + }, + "deodorFilterResetType": { + "value": null, + "timestamp": "2021-04-06T16:43:39.118Z" + }, + "deodorFilterUsage": { + "value": null, + "timestamp": "2021-04-06T16:43:39.118Z" + }, + "deodorFilterUsageStep": { + "value": null, + "timestamp": "2021-04-06T16:43:39.118Z" + } + }, + "custom.energyType": { + "energyType": { + "value": null, + "timestamp": "2021-04-06T16:43:38.843Z" + }, + "energySavingSupport": { + "value": null + }, + "drMaxDuration": { + "value": null + }, + "energySavingLevel": { + "value": null + }, + "energySavingInfo": { + "value": null + }, + "supportedEnergySavingLevels": { + "value": null + }, + "energySavingOperation": { + "value": null + }, + "notificationTemplateID": { + "value": null + }, + "energySavingOperationSupport": { + "value": null + } + }, + "veryFineDustSensor": { + "veryFineDustLevel": { + "value": null, + "unit": "\u03bcg/m^3", + "timestamp": "2021-04-06T16:43:38.529Z" + } + } + }, + "main": { + "relativeHumidityMeasurement": { + "humidity": { + "value": 60, + "unit": "%", + "timestamp": "2024-12-30T13:10:23.759Z" + } + }, + "custom.airConditionerOdorController": { + "airConditionerOdorControllerProgress": { + "value": null, + "timestamp": "2021-04-06T16:43:37.555Z" + }, + "airConditionerOdorControllerState": { + "value": null, + "timestamp": "2021-04-06T16:43:37.555Z" + } + }, + "custom.thermostatSetpointControl": { + "minimumSetpoint": { + "value": 16, + "unit": "C", + "timestamp": "2025-01-08T06:30:58.307Z" + }, + "maximumSetpoint": { + "value": 30, + "unit": "C", + "timestamp": "2024-09-10T10:26:28.781Z" + } + }, + "airConditionerMode": { + "availableAcModes": { + "value": null + }, + "supportedAcModes": { + "value": ["cool", "dry", "wind", "auto", "heat"], + "timestamp": "2024-09-10T10:26:28.781Z" + }, + "airConditionerMode": { + "value": "heat", + "timestamp": "2025-02-09T09:14:39.642Z" + } + }, + "custom.spiMode": { + "spiMode": { + "value": "off", + "timestamp": "2025-02-09T09:14:39.642Z" + } + }, + "samsungce.dongleSoftwareInstallation": { + "status": { + "value": "completed", + "timestamp": "2021-12-29T01:36:51.289Z" + } + }, + "samsungce.deviceIdentification": { + "micomAssayCode": { + "value": null + }, + "modelName": { + "value": null + }, + "serialNumber": { + "value": null + }, + "serialNumberExtra": { + "value": null + }, + "modelClassificationCode": { + "value": null + }, + "description": { + "value": null + }, + "releaseYear": { + "value": null + }, + "binaryId": { + "value": "ARTIK051_KRAC_18K", + "timestamp": "2025-02-08T00:44:53.855Z" + } + }, + "airQualitySensor": { + "airQuality": { + "value": null, + "unit": "CAQI", + "timestamp": "2021-04-06T16:43:37.208Z" + } + }, + "custom.airConditionerOptionalMode": { + "supportedAcOptionalMode": { + "value": ["off", "windFree"], + "timestamp": "2024-09-10T10:26:28.781Z" + }, + "acOptionalMode": { + "value": "off", + "timestamp": "2025-02-09T09:14:39.642Z" + } + }, + "switch": { + "switch": { + "value": "off", + "timestamp": "2025-02-09T16:37:54.072Z" + } + }, + "custom.airConditionerTropicalNightMode": { + "acTropicalNightModeLevel": { + "value": 0, + "timestamp": "2025-02-09T09:14:39.642Z" + } + }, + "ocf": { + "st": { + "value": null, + "timestamp": "2021-04-06T16:43:35.933Z" + }, + "mndt": { + "value": null, + "timestamp": "2021-04-06T16:43:35.912Z" + }, + "mnfv": { + "value": "0.1.0", + "timestamp": "2024-09-10T10:26:28.552Z" + }, + "mnhw": { + "value": "1.0", + "timestamp": "2024-09-10T10:26:28.552Z" + }, + "di": { + "value": "96a5ef74-5832-a84b-f1f7-ca799957065d", + "timestamp": "2024-09-10T10:26:28.552Z" + }, + "mnsl": { + "value": null, + "timestamp": "2021-04-06T16:43:35.803Z" + }, + "dmv": { + "value": "res.1.1.0,sh.1.1.0", + "timestamp": "2024-09-10T10:26:28.552Z" + }, + "n": { + "value": "[room a/c] Samsung", + "timestamp": "2024-09-10T10:26:28.552Z" + }, + "mnmo": { + "value": "ARTIK051_KRAC_18K|10193441|60010132001111110200000000000000", + "timestamp": "2024-09-10T10:26:28.781Z" + }, + "vid": { + "value": "DA-AC-RAC-000001", + "timestamp": "2024-09-10T10:26:28.552Z" + }, + "mnmn": { + "value": "Samsung Electronics", + "timestamp": "2024-09-10T10:26:28.552Z" + }, + "mnml": { + "value": "http://www.samsung.com", + "timestamp": "2024-09-10T10:26:28.552Z" + }, + "mnpv": { + "value": "0G3MPDCKA00010E", + "timestamp": "2024-09-10T10:26:28.552Z" + }, + "mnos": { + "value": "TizenRT2.0", + "timestamp": "2024-09-10T10:26:28.552Z" + }, + "pi": { + "value": "96a5ef74-5832-a84b-f1f7-ca799957065d", + "timestamp": "2024-09-10T10:26:28.552Z" + }, + "icv": { + "value": "core.1.1.0", + "timestamp": "2024-09-10T10:26:28.552Z" + } + }, + "airConditionerFanMode": { + "fanMode": { + "value": "low", + "timestamp": "2025-02-09T09:14:39.249Z" + }, + "supportedAcFanModes": { + "value": ["auto", "low", "medium", "high", "turbo"], + "timestamp": "2025-02-09T09:14:39.249Z" + }, + "availableAcFanModes": { + "value": null + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": [ + "remoteControlStatus", + "airQualitySensor", + "dustSensor", + "veryFineDustSensor", + "custom.dustFilter", + "custom.deodorFilter", + "custom.deviceReportStateConfiguration", + "samsungce.dongleSoftwareInstallation", + "demandResponseLoadControl", + "custom.airConditionerOdorController" + ], + "timestamp": "2025-02-09T09:14:39.642Z" + } + }, + "samsungce.driverVersion": { + "versionNumber": { + "value": 24070101, + "timestamp": "2024-09-04T06:35:09.557Z" + } + }, + "fanOscillationMode": { + "supportedFanOscillationModes": { + "value": null, + "timestamp": "2021-04-06T16:43:35.782Z" + }, + "availableFanOscillationModes": { + "value": null + }, + "fanOscillationMode": { + "value": "fixed", + "timestamp": "2025-02-09T09:14:39.249Z" + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": 25, + "unit": "C", + "timestamp": "2025-02-09T16:33:29.164Z" + } + }, + "dustSensor": { + "dustLevel": { + "value": null, + "unit": "\u03bcg/m^3", + "timestamp": "2021-04-06T16:43:35.665Z" + }, + "fineDustLevel": { + "value": null, + "unit": "\u03bcg/m^3", + "timestamp": "2021-04-06T16:43:35.665Z" + } + }, + "custom.deviceReportStateConfiguration": { + "reportStateRealtimePeriod": { + "value": null, + "timestamp": "2021-04-06T16:43:35.643Z" + }, + "reportStateRealtime": { + "value": null, + "timestamp": "2021-04-06T16:43:35.643Z" + }, + "reportStatePeriod": { + "value": null, + "timestamp": "2021-04-06T16:43:35.643Z" + } + }, + "thermostatCoolingSetpoint": { + "coolingSetpointRange": { + "value": null + }, + "coolingSetpoint": { + "value": 25, + "unit": "C", + "timestamp": "2025-02-09T09:15:11.608Z" + } + }, + "custom.disabledComponents": { + "disabledComponents": { + "value": ["1"], + "timestamp": "2025-02-09T09:14:39.642Z" + } + }, + "demandResponseLoadControl": { + "drlcStatus": { + "value": { + "drlcType": 1, + "drlcLevel": -1, + "start": "1970-01-01T00:00:00Z", + "duration": 0, + "override": false + }, + "timestamp": "2024-09-10T10:26:28.781Z" + } + }, + "audioVolume": { + "volume": { + "value": 100, + "unit": "%", + "timestamp": "2025-02-09T09:14:39.642Z" + } + }, + "powerConsumptionReport": { + "powerConsumption": { + "value": { + "energy": 2247300, + "deltaEnergy": 400, + "power": 0, + "powerEnergy": 0.0, + "persistedEnergy": 2247300, + "energySaved": 0, + "start": "2025-02-09T15:45:29Z", + "end": "2025-02-09T16:15:33Z" + }, + "timestamp": "2025-02-09T16:15:33.639Z" + } + }, + "custom.autoCleaningMode": { + "supportedAutoCleaningModes": { + "value": null + }, + "timedCleanDuration": { + "value": null + }, + "operatingState": { + "value": null + }, + "timedCleanDurationRange": { + "value": null + }, + "supportedOperatingStates": { + "value": null + }, + "progress": { + "value": null + }, + "autoCleaningMode": { + "value": "off", + "timestamp": "2025-02-09T09:14:39.642Z" + } + }, + "refresh": {}, + "execute": { + "data": { + "value": { + "payload": { + "rt": ["oic.r.temperature"], + "if": ["oic.if.baseline", "oic.if.a"], + "range": [16.0, 30.0], + "units": "C", + "temperature": 22.0 + } + }, + "data": { + "href": "/temperature/desired/0" + }, + "timestamp": "2023-07-19T03:07:43.270Z" + } + }, + "samsungce.selfCheck": { + "result": { + "value": null + }, + "supportedActions": { + "value": ["start"], + "timestamp": "2024-09-04T06:35:09.557Z" + }, + "progress": { + "value": null + }, + "errors": { + "value": [], + "timestamp": "2025-02-08T00:44:53.349Z" + }, + "status": { + "value": "ready", + "timestamp": "2025-02-08T00:44:53.549Z" + } + }, + "custom.dustFilter": { + "dustFilterUsageStep": { + "value": null, + "timestamp": "2021-04-06T16:43:35.527Z" + }, + "dustFilterUsage": { + "value": null, + "timestamp": "2021-04-06T16:43:35.527Z" + }, + "dustFilterLastResetDate": { + "value": null, + "timestamp": "2021-04-06T16:43:35.527Z" + }, + "dustFilterStatus": { + "value": null, + "timestamp": "2021-04-06T16:43:35.527Z" + }, + "dustFilterCapacity": { + "value": null, + "timestamp": "2021-04-06T16:43:35.527Z" + }, + "dustFilterResetType": { + "value": null, + "timestamp": "2021-04-06T16:43:35.527Z" + } + }, + "remoteControlStatus": { + "remoteControlEnabled": { + "value": null, + "timestamp": "2021-04-06T16:43:35.379Z" + } + }, + "custom.deodorFilter": { + "deodorFilterCapacity": { + "value": null, + "timestamp": "2021-04-06T16:43:35.502Z" + }, + "deodorFilterLastResetDate": { + "value": null, + "timestamp": "2021-04-06T16:43:35.502Z" + }, + "deodorFilterStatus": { + "value": null, + "timestamp": "2021-04-06T16:43:35.502Z" + }, + "deodorFilterResetType": { + "value": null, + "timestamp": "2021-04-06T16:43:35.502Z" + }, + "deodorFilterUsage": { + "value": null, + "timestamp": "2021-04-06T16:43:35.502Z" + }, + "deodorFilterUsageStep": { + "value": null, + "timestamp": "2021-04-06T16:43:35.502Z" + } + }, + "custom.energyType": { + "energyType": { + "value": "1.0", + "timestamp": "2024-09-10T10:26:28.781Z" + }, + "energySavingSupport": { + "value": false, + "timestamp": "2021-12-29T07:29:17.526Z" + }, + "drMaxDuration": { + "value": null + }, + "energySavingLevel": { + "value": null + }, + "energySavingInfo": { + "value": null + }, + "supportedEnergySavingLevels": { + "value": null + }, + "energySavingOperation": { + "value": null + }, + "notificationTemplateID": { + "value": null + }, + "energySavingOperationSupport": { + "value": null + } + }, + "samsungce.softwareUpdate": { + "targetModule": { + "value": null + }, + "otnDUID": { + "value": "43CEZFTFFL7Z2", + "timestamp": "2025-02-08T00:44:53.855Z" + }, + "lastUpdatedDate": { + "value": null + }, + "availableModules": { + "value": [], + "timestamp": "2025-02-08T00:44:53.855Z" + }, + "newVersionAvailable": { + "value": false, + "timestamp": "2025-02-08T00:44:53.855Z" + }, + "operatingState": { + "value": null + }, + "progress": { + "value": null + } + }, + "veryFineDustSensor": { + "veryFineDustLevel": { + "value": null, + "unit": "\u03bcg/m^3", + "timestamp": "2021-04-06T16:43:35.363Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/da_ac_rac_01001.json b/tests/components/smartthings/fixtures/device_status/da_ac_rac_01001.json new file mode 100644 index 00000000000..257d553cb9f --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/da_ac_rac_01001.json @@ -0,0 +1,731 @@ +{ + "components": { + "main": { + "samsungce.unavailableCapabilities": { + "unavailableCommands": { + "value": ["custom.spiMode.setSpiMode"], + "timestamp": "2025-02-09T05:44:01.769Z" + } + }, + "relativeHumidityMeasurement": { + "humidity": { + "value": 42, + "unit": "%", + "timestamp": "2025-02-09T17:02:45.042Z" + } + }, + "custom.thermostatSetpointControl": { + "minimumSetpoint": { + "value": 16, + "unit": "C", + "timestamp": "2025-02-09T15:42:13.444Z" + }, + "maximumSetpoint": { + "value": 30, + "unit": "C", + "timestamp": "2025-02-09T15:42:13.444Z" + } + }, + "airConditionerMode": { + "availableAcModes": { + "value": [], + "timestamp": "2025-02-09T14:35:56.800Z" + }, + "supportedAcModes": { + "value": ["auto", "cool", "dry", "wind", "heat"], + "timestamp": "2025-02-09T15:42:13.444Z" + }, + "airConditionerMode": { + "value": "cool", + "timestamp": "2025-02-09T04:52:00.923Z" + } + }, + "custom.spiMode": { + "spiMode": { + "value": null + } + }, + "custom.airConditionerOptionalMode": { + "supportedAcOptionalMode": { + "value": [ + "off", + "sleep", + "quiet", + "smart", + "speed", + "windFree", + "windFreeSleep" + ], + "timestamp": "2025-02-09T15:42:13.444Z" + }, + "acOptionalMode": { + "value": "off", + "timestamp": "2025-02-09T05:44:01.853Z" + } + }, + "samsungce.airConditionerBeep": { + "beep": { + "value": "off", + "timestamp": "2025-02-09T04:52:00.923Z" + } + }, + "ocf": { + "st": { + "value": null + }, + "mndt": { + "value": null + }, + "mnfv": { + "value": "ARA-WW-TP1-22-COMMON_11240702", + "timestamp": "2025-02-09T15:42:12.723Z" + }, + "mnhw": { + "value": "Realtek", + "timestamp": "2025-02-09T15:42:12.723Z" + }, + "di": { + "value": "4ece486b-89db-f06a-d54d-748b676b4d8e", + "timestamp": "2025-02-09T15:42:12.714Z" + }, + "mnsl": { + "value": "http://www.samsung.com", + "timestamp": "2025-02-09T15:42:12.723Z" + }, + "dmv": { + "value": "res.1.1.0,sh.1.1.0", + "timestamp": "2025-02-09T15:42:12.714Z" + }, + "n": { + "value": "Samsung-Room-Air-Conditioner", + "timestamp": "2025-02-09T15:42:12.714Z" + }, + "mnmo": { + "value": "ARA-WW-TP1-22-COMMON|10229641|60010523001511014600083200800000", + "timestamp": "2025-02-09T15:42:13.444Z" + }, + "vid": { + "value": "DA-AC-RAC-01001", + "timestamp": "2025-02-09T15:42:12.723Z" + }, + "mnmn": { + "value": "Samsung Electronics", + "timestamp": "2025-02-09T15:42:12.723Z" + }, + "mnml": { + "value": "http://www.samsung.com", + "timestamp": "2025-02-09T15:42:12.723Z" + }, + "mnpv": { + "value": "DAWIT 2.0", + "timestamp": "2025-02-09T15:42:12.723Z" + }, + "mnos": { + "value": "TizenRT 3.1", + "timestamp": "2025-02-09T15:42:12.723Z" + }, + "pi": { + "value": "4ece486b-89db-f06a-d54d-748b676b4d8e", + "timestamp": "2025-02-09T15:42:12.723Z" + }, + "icv": { + "value": "core.1.1.0", + "timestamp": "2025-02-09T15:42:12.714Z" + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": [ + "custom.deodorFilter", + "custom.electricHepaFilter", + "custom.periodicSensing", + "custom.doNotDisturbMode", + "samsungce.deviceInfoPrivate", + "samsungce.quickControl", + "samsungce.welcomeCooling", + "samsungce.airConditionerBeep", + "samsungce.airConditionerLighting", + "samsungce.individualControlLock", + "samsungce.alwaysOnSensing", + "samsungce.buttonDisplayCondition", + "airQualitySensor", + "dustSensor", + "odorSensor", + "veryFineDustSensor", + "custom.spiMode", + "audioNotification" + ], + "timestamp": "2025-02-09T15:42:13.444Z" + } + }, + "samsungce.driverVersion": { + "versionNumber": { + "value": 24100102, + "timestamp": "2025-01-28T21:31:35.935Z" + } + }, + "sec.diagnosticsInformation": { + "logType": { + "value": ["errCode", "dump"], + "timestamp": "2025-02-09T15:42:13.444Z" + }, + "endpoint": { + "value": "SSM", + "timestamp": "2025-02-09T15:42:13.444Z" + }, + "minVersion": { + "value": "1.0", + "timestamp": "2025-02-09T15:42:13.444Z" + }, + "signinPermission": { + "value": null + }, + "setupId": { + "value": "010", + "timestamp": "2025-02-09T15:42:13.444Z" + }, + "protocolType": { + "value": "wifi_https", + "timestamp": "2025-02-09T15:42:13.444Z" + }, + "tsId": { + "value": "DA01", + "timestamp": "2025-02-09T15:42:13.444Z" + }, + "mnId": { + "value": "0AJT", + "timestamp": "2025-02-09T15:42:13.444Z" + }, + "dumpType": { + "value": "file", + "timestamp": "2025-02-09T15:42:13.444Z" + } + }, + "fanOscillationMode": { + "supportedFanOscillationModes": { + "value": ["fixed", "vertical", "horizontal", "all"], + "timestamp": "2025-02-09T15:42:13.444Z" + }, + "availableFanOscillationModes": { + "value": null + }, + "fanOscillationMode": { + "value": "fixed", + "timestamp": "2025-02-09T15:42:13.444Z" + } + }, + "custom.periodicSensing": { + "automaticExecutionSetting": { + "value": null + }, + "automaticExecutionMode": { + "value": null + }, + "supportedAutomaticExecutionSetting": { + "value": null + }, + "supportedAutomaticExecutionMode": { + "value": null + }, + "periodicSensing": { + "value": null + }, + "periodicSensingInterval": { + "value": null + }, + "lastSensingTime": { + "value": null + }, + "lastSensingLevel": { + "value": null + }, + "periodicSensingStatus": { + "value": null + } + }, + "demandResponseLoadControl": { + "drlcStatus": { + "value": { + "drlcType": 1, + "drlcLevel": 0, + "start": "1970-01-01T00:00:00Z", + "duration": 0, + "override": false + }, + "timestamp": "2025-02-09T15:42:13.444Z" + } + }, + "audioVolume": { + "volume": { + "value": 0, + "unit": "%", + "timestamp": "2025-02-09T04:52:00.923Z" + } + }, + "powerConsumptionReport": { + "powerConsumption": { + "value": { + "energy": 13836, + "deltaEnergy": 0, + "power": 0, + "powerEnergy": 0.0, + "persistedEnergy": 13836, + "energySaved": 0, + "persistedSavedEnergy": 0, + "start": "2025-02-09T16:08:15Z", + "end": "2025-02-09T17:02:44Z" + }, + "timestamp": "2025-02-09T17:02:44.883Z" + } + }, + "custom.autoCleaningMode": { + "supportedAutoCleaningModes": { + "value": null + }, + "timedCleanDuration": { + "value": null + }, + "operatingState": { + "value": null + }, + "timedCleanDurationRange": { + "value": null + }, + "supportedOperatingStates": { + "value": null + }, + "progress": { + "value": null + }, + "autoCleaningMode": { + "value": "on", + "timestamp": "2025-02-09T05:44:02.014Z" + } + }, + "samsungce.individualControlLock": { + "lockState": { + "value": null + } + }, + "audioNotification": {}, + "execute": { + "data": { + "value": null + } + }, + "samsungce.welcomeCooling": { + "latestRequestId": { + "value": null + }, + "operatingState": { + "value": null + } + }, + "sec.wifiConfiguration": { + "autoReconnection": { + "value": true, + "timestamp": "2025-02-09T15:42:13.444Z" + }, + "minVersion": { + "value": "1.0", + "timestamp": "2025-02-09T15:42:13.444Z" + }, + "supportedWiFiFreq": { + "value": ["2.4G"], + "timestamp": "2025-02-09T15:42:13.444Z" + }, + "supportedAuthType": { + "value": ["OPEN", "WEP", "WPA-PSK", "WPA2-PSK", "SAE"], + "timestamp": "2025-02-09T15:42:13.444Z" + }, + "protocolType": { + "value": ["helper_hotspot", "ble_ocf"], + "timestamp": "2025-02-09T15:42:13.444Z" + } + }, + "samsungce.selfCheck": { + "result": { + "value": null + }, + "supportedActions": { + "value": ["start", "cancel"], + "timestamp": "2025-02-09T04:52:00.923Z" + }, + "progress": { + "value": 1, + "unit": "%", + "timestamp": "2025-02-09T04:52:00.923Z" + }, + "errors": { + "value": [], + "timestamp": "2025-02-09T15:42:13.444Z" + }, + "status": { + "value": "ready", + "timestamp": "2025-02-09T04:52:00.923Z" + } + }, + "custom.dustFilter": { + "dustFilterUsageStep": { + "value": 1, + "timestamp": "2025-02-09T12:00:10.310Z" + }, + "dustFilterUsage": { + "value": 12, + "timestamp": "2025-02-09T12:00:10.310Z" + }, + "dustFilterLastResetDate": { + "value": null + }, + "dustFilterStatus": { + "value": "normal", + "timestamp": "2025-02-09T12:00:10.310Z" + }, + "dustFilterCapacity": { + "value": 500, + "unit": "Hour", + "timestamp": "2025-02-09T12:00:10.310Z" + }, + "dustFilterResetType": { + "value": ["replaceable", "washable"], + "timestamp": "2025-02-09T12:00:10.310Z" + } + }, + "custom.energyType": { + "energyType": { + "value": "1.0", + "timestamp": "2025-01-28T21:31:39.517Z" + }, + "energySavingSupport": { + "value": true, + "timestamp": "2025-01-28T21:38:35.560Z" + }, + "drMaxDuration": { + "value": 99999999, + "unit": "min", + "timestamp": "2025-01-28T21:31:37.357Z" + }, + "energySavingLevel": { + "value": null + }, + "energySavingInfo": { + "value": null + }, + "supportedEnergySavingLevels": { + "value": null + }, + "energySavingOperation": { + "value": false, + "timestamp": "2025-02-09T15:42:13.444Z" + }, + "notificationTemplateID": { + "value": null + }, + "energySavingOperationSupport": { + "value": true, + "timestamp": "2025-01-28T21:38:35.731Z" + } + }, + "bypassable": { + "bypassStatus": { + "value": "bypassed", + "timestamp": "2025-01-28T21:31:35.935Z" + } + }, + "samsungce.airQualityHealthConcern": { + "supportedAirQualityHealthConcerns": { + "value": null + }, + "airQualityHealthConcern": { + "value": null + } + }, + "samsungce.softwareUpdate": { + "targetModule": { + "value": {}, + "timestamp": "2025-02-05T20:07:11.459Z" + }, + "otnDUID": { + "value": "U7CB2ZD4QPDUC", + "timestamp": "2025-02-09T15:42:13.444Z" + }, + "lastUpdatedDate": { + "value": null + }, + "availableModules": { + "value": [], + "timestamp": "2025-01-28T21:31:38.089Z" + }, + "newVersionAvailable": { + "value": false, + "timestamp": "2025-02-09T15:42:13.444Z" + }, + "operatingState": { + "value": "none", + "timestamp": "2025-02-05T20:07:11.459Z" + }, + "progress": { + "value": null + } + }, + "veryFineDustSensor": { + "veryFineDustLevel": { + "value": null + } + }, + "custom.veryFineDustFilter": { + "veryFineDustFilterStatus": { + "value": null + }, + "veryFineDustFilterResetType": { + "value": null + }, + "veryFineDustFilterUsage": { + "value": null + }, + "veryFineDustFilterLastResetDate": { + "value": null + }, + "veryFineDustFilterUsageStep": { + "value": null + }, + "veryFineDustFilterCapacity": { + "value": null + } + }, + "samsungce.silentAction": {}, + "custom.airConditionerOdorController": { + "airConditionerOdorControllerProgress": { + "value": 0, + "timestamp": "2025-02-09T04:52:00.923Z" + }, + "airConditionerOdorControllerState": { + "value": "off", + "timestamp": "2025-02-09T04:52:00.923Z" + } + }, + "samsungce.deviceIdentification": { + "micomAssayCode": { + "value": null + }, + "modelName": { + "value": null + }, + "serialNumber": { + "value": null + }, + "serialNumberExtra": { + "value": null + }, + "modelClassificationCode": { + "value": null + }, + "description": { + "value": null + }, + "releaseYear": { + "value": 21, + "timestamp": "2025-01-28T21:31:35.935Z" + }, + "binaryId": { + "value": "ARA-WW-TP1-22-COMMON", + "timestamp": "2025-02-09T15:42:13.444Z" + } + }, + "airQualitySensor": { + "airQuality": { + "value": null + } + }, + "switch": { + "switch": { + "value": "off", + "timestamp": "2025-02-09T15:42:13.444Z" + } + }, + "samsungce.quickControl": { + "version": { + "value": null + } + }, + "custom.airConditionerTropicalNightMode": { + "acTropicalNightModeLevel": { + "value": 6, + "timestamp": "2025-02-09T04:52:00.923Z" + } + }, + "airConditionerFanMode": { + "fanMode": { + "value": "high", + "timestamp": "2025-02-09T14:07:45.816Z" + }, + "supportedAcFanModes": { + "value": ["auto", "low", "medium", "high", "turbo"], + "timestamp": "2025-02-09T15:42:13.444Z" + }, + "availableAcFanModes": { + "value": ["auto", "low", "medium", "high", "turbo"], + "timestamp": "2025-02-09T05:44:01.769Z" + } + }, + "samsungce.dustFilterAlarm": { + "alarmThreshold": { + "value": 500, + "unit": "Hour", + "timestamp": "2025-02-09T12:00:10.310Z" + }, + "supportedAlarmThresholds": { + "value": [180, 300, 500, 700], + "unit": "Hour", + "timestamp": "2025-02-09T15:42:13.444Z" + } + }, + "custom.electricHepaFilter": { + "electricHepaFilterCapacity": { + "value": null + }, + "electricHepaFilterUsageStep": { + "value": null + }, + "electricHepaFilterLastResetDate": { + "value": null + }, + "electricHepaFilterStatus": { + "value": null + }, + "electricHepaFilterUsage": { + "value": null + }, + "electricHepaFilterResetType": { + "value": null + } + }, + "samsungce.airConditionerLighting": { + "supportedLightingLevels": { + "value": ["on", "off"], + "timestamp": "2025-02-09T15:42:13.444Z" + }, + "lighting": { + "value": "on", + "timestamp": "2025-02-09T09:30:03.213Z" + } + }, + "samsungce.buttonDisplayCondition": { + "switch": { + "value": "enabled", + "timestamp": "2025-02-09T05:17:41.282Z" + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": 27, + "unit": "C", + "timestamp": "2025-02-09T16:38:17.028Z" + } + }, + "dustSensor": { + "dustLevel": { + "value": null + }, + "fineDustLevel": { + "value": null + } + }, + "sec.calmConnectionCare": { + "role": { + "value": ["things"], + "timestamp": "2025-02-09T15:42:13.444Z" + }, + "protocols": { + "value": null + }, + "version": { + "value": "1.0", + "timestamp": "2025-02-09T15:42:13.444Z" + } + }, + "custom.deviceReportStateConfiguration": { + "reportStateRealtimePeriod": { + "value": "disabled", + "timestamp": "2025-02-09T05:17:39.792Z" + }, + "reportStateRealtime": { + "value": { + "state": "disabled" + }, + "timestamp": "2025-02-09T15:42:13.444Z" + }, + "reportStatePeriod": { + "value": "enabled", + "timestamp": "2025-02-09T05:17:39.792Z" + } + }, + "thermostatCoolingSetpoint": { + "coolingSetpointRange": { + "value": { + "minimum": 16, + "maximum": 30, + "step": 1 + }, + "unit": "C", + "timestamp": "2025-02-09T05:17:41.533Z" + }, + "coolingSetpoint": { + "value": 23, + "unit": "C", + "timestamp": "2025-02-09T14:07:45.643Z" + } + }, + "samsungce.alwaysOnSensing": { + "origins": { + "value": [], + "timestamp": "2025-02-09T15:42:13.444Z" + }, + "alwaysOn": { + "value": "off", + "timestamp": "2025-02-09T15:42:13.444Z" + } + }, + "refresh": {}, + "odorSensor": { + "odorLevel": { + "value": null + } + }, + "custom.deodorFilter": { + "deodorFilterCapacity": { + "value": null + }, + "deodorFilterLastResetDate": { + "value": null + }, + "deodorFilterStatus": { + "value": null + }, + "deodorFilterResetType": { + "value": null + }, + "deodorFilterUsage": { + "value": null + }, + "deodorFilterUsageStep": { + "value": null + } + }, + "custom.doNotDisturbMode": { + "doNotDisturb": { + "value": null + }, + "startTime": { + "value": null + }, + "endTime": { + "value": null + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/da_ks_microwave_0101x.json b/tests/components/smartthings/fixtures/device_status/da_ks_microwave_0101x.json new file mode 100644 index 00000000000..181b62666c7 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/da_ks_microwave_0101x.json @@ -0,0 +1,600 @@ +{ + "components": { + "main": { + "doorControl": { + "door": { + "value": null + } + }, + "samsungce.kitchenDeviceDefaults": { + "defaultOperationTime": { + "value": 30, + "timestamp": "2022-03-23T15:59:12.609Z" + }, + "defaultOvenMode": { + "value": "MicroWave", + "timestamp": "2025-02-08T21:13:36.289Z" + }, + "defaultOvenSetpoint": { + "value": null + } + }, + "samsungce.deviceIdentification": { + "micomAssayCode": { + "value": null + }, + "modelName": { + "value": null + }, + "serialNumber": { + "value": null + }, + "serialNumberExtra": { + "value": null + }, + "modelClassificationCode": { + "value": null + }, + "description": { + "value": null + }, + "releaseYear": { + "value": null + }, + "binaryId": { + "value": "TP2X_DA-KS-MICROWAVE-0101X", + "timestamp": "2025-02-08T21:13:36.256Z" + } + }, + "switch": { + "switch": { + "value": "off", + "timestamp": "2025-02-09T00:11:12.010Z" + } + }, + "ocf": { + "st": { + "value": null + }, + "mndt": { + "value": null + }, + "mnfv": { + "value": "AKS-WW-TP2-20-MICROWAVE-OTR_40230125", + "timestamp": "2023-07-03T06:44:54.757Z" + }, + "mnhw": { + "value": "MediaTek", + "timestamp": "2022-03-23T15:59:12.742Z" + }, + "di": { + "value": "2bad3237-4886-e699-1b90-4a51a3d55c8a", + "timestamp": "2022-03-23T15:59:12.742Z" + }, + "mnsl": { + "value": "http://www.samsung.com", + "timestamp": "2022-03-23T15:59:12.742Z" + }, + "dmv": { + "value": "res.1.1.0,sh.1.1.0", + "timestamp": "2023-07-03T22:00:58.832Z" + }, + "n": { + "value": "Samsung Microwave", + "timestamp": "2023-07-03T06:44:54.757Z" + }, + "mnmo": { + "value": "TP2X_DA-KS-MICROWAVE-0101X|40436241|50040100011411000200000000000000", + "timestamp": "2023-07-03T06:44:54.757Z" + }, + "vid": { + "value": "DA-KS-MICROWAVE-0101X", + "timestamp": "2022-03-23T15:59:12.742Z" + }, + "mnmn": { + "value": "Samsung Electronics", + "timestamp": "2022-03-23T15:59:12.742Z" + }, + "mnml": { + "value": "http://www.samsung.com", + "timestamp": "2022-03-23T15:59:12.742Z" + }, + "mnpv": { + "value": "DAWIT 3.0", + "timestamp": "2023-07-03T06:44:54.757Z" + }, + "mnos": { + "value": "TizenRT 2.0 + IPv6", + "timestamp": "2023-07-03T06:44:54.757Z" + }, + "pi": { + "value": "2bad3237-4886-e699-1b90-4a51a3d55c8a", + "timestamp": "2022-03-23T15:59:12.742Z" + }, + "icv": { + "value": "core.1.1.0", + "timestamp": "2022-03-23T15:59:12.742Z" + } + }, + "samsungce.kitchenDeviceIdentification": { + "regionCode": { + "value": "US", + "timestamp": "2025-02-08T21:13:36.289Z" + }, + "modelCode": { + "value": "ME8000T-/AA0", + "timestamp": "2025-02-08T21:13:36.289Z" + }, + "fuel": { + "value": null + }, + "type": { + "value": "microwave", + "timestamp": "2022-03-23T15:59:10.971Z" + }, + "representativeComponent": { + "value": null + } + }, + "samsungce.kitchenModeSpecification": { + "specification": { + "value": { + "single": [ + { + "mode": "MicroWave", + "supportedOptions": { + "operationTime": { + "max": "01:40:00" + }, + "powerLevel": { + "default": "100%", + "supportedValues": [ + "0%", + "10%", + "20%", + "30%", + "40%", + "50%", + "60%", + "70%", + "80%", + "90%", + "100%" + ] + } + } + }, + { + "mode": "ConvectionBake", + "supportedOptions": { + "temperature": { + "F": { + "min": 100, + "max": 425, + "default": 350, + "supportedValues": [ + 100, 200, 225, 250, 275, 300, 325, 350, 375, 400, 425 + ] + } + }, + "operationTime": { + "max": "01:40:00" + } + } + }, + { + "mode": "ConvectionRoast", + "supportedOptions": { + "temperature": { + "F": { + "min": 200, + "max": 425, + "default": 325, + "supportedValues": [ + 200, 225, 250, 275, 300, 325, 350, 375, 400, 425 + ] + } + }, + "operationTime": { + "max": "01:40:00" + } + } + }, + { + "mode": "Grill", + "supportedOptions": { + "temperature": { + "F": { + "min": 425, + "max": 425, + "default": 425, + "resolution": 5 + } + }, + "operationTime": { + "max": "01:40:00" + } + } + }, + { + "mode": "SpeedBake", + "supportedOptions": { + "operationTime": { + "max": "01:40:00" + }, + "powerLevel": { + "default": "30%", + "supportedValues": ["10%", "30%", "50%", "70%"] + } + } + }, + { + "mode": "SpeedRoast", + "supportedOptions": { + "operationTime": { + "max": "01:40:00" + }, + "powerLevel": { + "default": "30%", + "supportedValues": ["10%", "30%", "50%", "70%"] + } + } + }, + { + "mode": "KeepWarm", + "supportedOptions": { + "temperature": { + "F": { + "min": 175, + "max": 175, + "default": 175, + "resolution": 5 + } + }, + "operationTime": { + "max": "01:40:00" + } + } + }, + { + "mode": "Autocook", + "supportedOptions": {} + }, + { + "mode": "Cookie", + "supportedOptions": { + "temperature": { + "F": { + "min": 325, + "max": 325, + "default": 325, + "resolution": 5 + } + }, + "operationTime": { + "max": "01:40:00" + } + } + }, + { + "mode": "SteamClean", + "supportedOptions": { + "operationTime": { + "max": "00:06:30" + } + } + } + ] + }, + "timestamp": "2025-02-08T10:21:03.790Z" + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": ["doorControl", "samsungce.hoodFanSpeed"], + "timestamp": "2025-02-08T21:13:36.152Z" + } + }, + "samsungce.driverVersion": { + "versionNumber": { + "value": 22120101, + "timestamp": "2023-07-03T09:36:13.282Z" + } + }, + "sec.diagnosticsInformation": { + "logType": { + "value": ["errCode", "dump"], + "timestamp": "2025-02-08T21:13:36.256Z" + }, + "endpoint": { + "value": "SSM", + "timestamp": "2025-02-08T21:13:36.256Z" + }, + "minVersion": { + "value": "1.0", + "timestamp": "2025-02-08T21:13:36.256Z" + }, + "signinPermission": { + "value": null + }, + "setupId": { + "value": "621", + "timestamp": "2025-02-08T21:13:36.256Z" + }, + "protocolType": { + "value": "wifi_https", + "timestamp": "2025-02-08T21:13:36.256Z" + }, + "tsId": { + "value": null + }, + "mnId": { + "value": "0AJT", + "timestamp": "2025-02-08T21:13:36.256Z" + }, + "dumpType": { + "value": "file", + "timestamp": "2025-02-08T21:13:36.256Z" + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": 1, + "unit": "F", + "timestamp": "2025-02-09T00:11:15.291Z" + } + }, + "samsungce.ovenOperatingState": { + "completionTime": { + "value": "2025-02-08T21:13:36.184Z", + "timestamp": "2025-02-08T21:13:36.188Z" + }, + "operatingState": { + "value": "ready", + "timestamp": "2025-02-08T21:13:36.188Z" + }, + "progress": { + "value": 0, + "timestamp": "2025-02-08T21:13:36.188Z" + }, + "ovenJobState": { + "value": "ready", + "timestamp": "2025-02-08T21:13:36.160Z" + }, + "operationTime": { + "value": "00:00:00", + "timestamp": "2025-02-08T21:13:36.188Z" + } + }, + "ovenMode": { + "supportedOvenModes": { + "value": [ + "Microwave", + "ConvectionBake", + "ConvectionRoast", + "grill", + "Others", + "warming" + ], + "timestamp": "2025-02-08T10:21:03.790Z" + }, + "ovenMode": { + "value": "Others", + "timestamp": "2025-02-08T21:13:36.289Z" + } + }, + "samsungce.ovenMode": { + "supportedOvenModes": { + "value": [ + "MicroWave", + "ConvectionBake", + "ConvectionRoast", + "Grill", + "SpeedBake", + "SpeedRoast", + "KeepWarm", + "Autocook", + "Cookie", + "SteamClean" + ], + "timestamp": "2025-02-08T10:21:03.790Z" + }, + "ovenMode": { + "value": "NoOperation", + "timestamp": "2025-02-08T21:13:36.289Z" + } + }, + "samsungce.kidsLock": { + "lockState": { + "value": "unlocked", + "timestamp": "2025-02-08T21:13:36.152Z" + } + }, + "ovenSetpoint": { + "ovenSetpointRange": { + "value": null + }, + "ovenSetpoint": { + "value": 0, + "timestamp": "2025-02-09T00:01:09.108Z" + } + }, + "refresh": {}, + "samsungce.hoodFanSpeed": { + "settableMaxFanSpeed": { + "value": 3, + "timestamp": "2025-02-09T00:01:06.959Z" + }, + "hoodFanSpeed": { + "value": 0, + "timestamp": "2025-02-09T00:01:07.813Z" + }, + "supportedHoodFanSpeed": { + "value": [0, 1, 2, 3, 4, 5], + "timestamp": "2022-03-23T15:59:12.796Z" + }, + "settableMinFanSpeed": { + "value": 0, + "timestamp": "2025-02-09T00:01:06.959Z" + } + }, + "samsungce.doorState": { + "doorState": { + "value": "closed", + "timestamp": "2025-02-08T21:13:36.227Z" + } + }, + "samsungce.microwavePower": { + "supportedPowerLevels": { + "value": [ + "0%", + "10%", + "20%", + "30%", + "40%", + "50%", + "60%", + "70%", + "80%", + "90%", + "100%" + ], + "timestamp": "2025-02-08T21:13:36.160Z" + }, + "powerLevel": { + "value": "0%", + "timestamp": "2025-02-08T21:13:36.160Z" + } + }, + "execute": { + "data": { + "value": { + "payload": { + "rt": ["x.com.samsung.da.temperatures"], + "if": ["oic.if.baseline", "oic.if.a"], + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "Temperature", + "x.com.samsung.da.desired": "0", + "x.com.samsung.da.current": "1", + "x.com.samsung.da.increment": "5", + "x.com.samsung.da.unit": "Fahrenheit" + } + ] + } + }, + "data": { + "href": "/temperatures/vs/0" + }, + "timestamp": "2023-07-19T05:50:12.609Z" + } + }, + "remoteControlStatus": { + "remoteControlEnabled": { + "value": "false", + "timestamp": "2025-02-08T21:13:36.357Z" + } + }, + "samsungce.definedRecipe": { + "definedRecipe": { + "value": { + "cavityId": "0", + "recipeType": "0", + "categoryId": 0, + "itemId": 0, + "servingSize": 0, + "browingLevel": 0, + "option": 0 + }, + "timestamp": "2025-02-08T21:13:36.160Z" + } + }, + "samsungce.softwareUpdate": { + "targetModule": { + "value": null + }, + "otnDUID": { + "value": "U7CNQWBWSCD7C", + "timestamp": "2025-02-08T21:13:36.256Z" + }, + "lastUpdatedDate": { + "value": null + }, + "availableModules": { + "value": [], + "timestamp": "2025-02-08T21:13:36.213Z" + }, + "newVersionAvailable": { + "value": false, + "timestamp": "2025-02-08T21:13:36.213Z" + }, + "operatingState": { + "value": null + }, + "progress": { + "value": null + } + }, + "ovenOperatingState": { + "completionTime": { + "value": "2025-02-08T21:13:36.184Z", + "timestamp": "2025-02-08T21:13:36.188Z" + }, + "machineState": { + "value": "ready", + "timestamp": "2025-02-08T21:13:36.188Z" + }, + "progress": { + "value": 0, + "unit": "%", + "timestamp": "2025-02-08T21:13:36.188Z" + }, + "supportedMachineStates": { + "value": null + }, + "ovenJobState": { + "value": "ready", + "timestamp": "2025-02-08T21:13:36.160Z" + }, + "operationTime": { + "value": 0, + "timestamp": "2025-02-08T21:13:36.188Z" + } + } + }, + "hood": { + "samsungce.hoodFanSpeed": { + "settableMaxFanSpeed": { + "value": 3, + "timestamp": "2025-02-09T00:01:06.959Z" + }, + "hoodFanSpeed": { + "value": 0, + "timestamp": "2025-02-09T00:01:07.813Z" + }, + "supportedHoodFanSpeed": { + "value": [0, 1, 2, 3, 4, 5], + "timestamp": "2022-03-23T15:59:12.796Z" + }, + "settableMinFanSpeed": { + "value": 0, + "timestamp": "2025-02-09T00:01:06.959Z" + } + }, + "samsungce.lamp": { + "brightnessLevel": { + "value": "off", + "timestamp": "2025-02-08T21:13:36.289Z" + }, + "supportedBrightnessLevel": { + "value": ["off", "low", "high"], + "timestamp": "2025-02-08T21:13:36.289Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/da_ref_normal_000001.json b/tests/components/smartthings/fixtures/device_status/da_ref_normal_000001.json new file mode 100644 index 00000000000..0c5a883b4f9 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/da_ref_normal_000001.json @@ -0,0 +1,727 @@ +{ + "components": { + "pantry-01": { + "samsungce.fridgePantryInfo": { + "name": { + "value": null + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": [], + "timestamp": "2022-02-07T10:47:54.524Z" + } + }, + "samsungce.fridgePantryMode": { + "mode": { + "value": null + }, + "supportedModes": { + "value": null + } + } + }, + "pantry-02": { + "samsungce.fridgePantryInfo": { + "name": { + "value": null + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": [], + "timestamp": "2022-02-07T10:47:54.524Z" + } + }, + "samsungce.fridgePantryMode": { + "mode": { + "value": null + }, + "supportedModes": { + "value": null + } + } + }, + "icemaker": { + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": [], + "timestamp": "2024-10-12T13:55:04.008Z" + } + }, + "switch": { + "switch": { + "value": "off", + "timestamp": "2025-02-09T13:55:01.720Z" + } + } + }, + "onedoor": { + "custom.fridgeMode": { + "fridgeModeValue": { + "value": null + }, + "fridgeMode": { + "value": null + }, + "supportedFridgeModes": { + "value": null + } + }, + "contactSensor": { + "contact": { + "value": null + } + }, + "samsungce.unavailableCapabilities": { + "unavailableCommands": { + "value": [], + "timestamp": "2024-11-08T04:14:59.899Z" + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": ["samsungce.freezerConvertMode", "custom.fridgeMode"], + "timestamp": "2024-11-12T08:23:59.944Z" + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": null + } + }, + "custom.thermostatSetpointControl": { + "minimumSetpoint": { + "value": null + }, + "maximumSetpoint": { + "value": null + } + }, + "samsungce.freezerConvertMode": { + "supportedFreezerConvertModes": { + "value": null + }, + "freezerConvertMode": { + "value": null + } + }, + "thermostatCoolingSetpoint": { + "coolingSetpointRange": { + "value": null + }, + "coolingSetpoint": { + "value": null + } + } + }, + "cooler": { + "custom.fridgeMode": { + "fridgeModeValue": { + "value": null + }, + "fridgeMode": { + "value": null + }, + "supportedFridgeModes": { + "value": null + } + }, + "contactSensor": { + "contact": { + "value": "closed", + "timestamp": "2025-02-09T16:26:21.425Z" + } + }, + "samsungce.unavailableCapabilities": { + "unavailableCommands": { + "value": [], + "timestamp": "2024-11-08T04:14:59.899Z" + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": ["custom.fridgeMode"], + "timestamp": "2024-10-12T13:55:04.008Z" + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": 37, + "unit": "F", + "timestamp": "2025-01-19T21:07:55.764Z" + } + }, + "custom.thermostatSetpointControl": { + "minimumSetpoint": { + "value": 34, + "unit": "F", + "timestamp": "2025-01-19T21:07:55.764Z" + }, + "maximumSetpoint": { + "value": 44, + "unit": "F", + "timestamp": "2025-01-19T21:07:55.764Z" + } + }, + "thermostatCoolingSetpoint": { + "coolingSetpointRange": { + "value": { + "minimum": 34, + "maximum": 44, + "step": 1 + }, + "unit": "F", + "timestamp": "2025-01-19T21:07:55.764Z" + }, + "coolingSetpoint": { + "value": 37, + "unit": "F", + "timestamp": "2025-01-19T21:07:55.764Z" + } + } + }, + "freezer": { + "custom.fridgeMode": { + "fridgeModeValue": { + "value": null + }, + "fridgeMode": { + "value": null + }, + "supportedFridgeModes": { + "value": null + } + }, + "contactSensor": { + "contact": { + "value": "closed", + "timestamp": "2025-02-09T14:48:16.247Z" + } + }, + "samsungce.unavailableCapabilities": { + "unavailableCommands": { + "value": [], + "timestamp": "2024-11-08T04:14:59.899Z" + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": ["custom.fridgeMode", "samsungce.freezerConvertMode"], + "timestamp": "2024-11-08T01:09:17.382Z" + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": 0, + "unit": "F", + "timestamp": "2025-01-23T04:42:18.178Z" + } + }, + "custom.thermostatSetpointControl": { + "minimumSetpoint": { + "value": -8, + "unit": "F", + "timestamp": "2025-01-19T21:07:55.764Z" + }, + "maximumSetpoint": { + "value": 5, + "unit": "F", + "timestamp": "2025-01-19T21:07:55.764Z" + } + }, + "samsungce.freezerConvertMode": { + "supportedFreezerConvertModes": { + "value": null + }, + "freezerConvertMode": { + "value": null + } + }, + "thermostatCoolingSetpoint": { + "coolingSetpointRange": { + "value": { + "minimum": -8, + "maximum": 5, + "step": 1 + }, + "unit": "F", + "timestamp": "2025-01-19T21:07:55.764Z" + }, + "coolingSetpoint": { + "value": 0, + "unit": "F", + "timestamp": "2025-01-19T21:07:55.764Z" + } + } + }, + "main": { + "contactSensor": { + "contact": { + "value": "closed", + "timestamp": "2025-02-09T16:26:21.425Z" + } + }, + "samsungce.dongleSoftwareInstallation": { + "status": { + "value": "completed", + "timestamp": "2022-02-07T10:47:54.524Z" + } + }, + "samsungce.deviceIdentification": { + "micomAssayCode": { + "value": null + }, + "modelName": { + "value": null + }, + "serialNumber": { + "value": null + }, + "serialNumberExtra": { + "value": null + }, + "modelClassificationCode": { + "value": null + }, + "description": { + "value": null + }, + "releaseYear": { + "value": 20, + "timestamp": "2024-11-08T01:09:17.382Z" + }, + "binaryId": { + "value": "TP2X_REF_20K", + "timestamp": "2025-02-09T13:55:01.720Z" + } + }, + "samsungce.quickControl": { + "version": { + "value": null + } + }, + "custom.fridgeMode": { + "fridgeModeValue": { + "value": null + }, + "fridgeMode": { + "value": null + }, + "supportedFridgeModes": { + "value": null + } + }, + "ocf": { + "st": { + "value": null + }, + "mndt": { + "value": null + }, + "mnfv": { + "value": "A-RFWW-TP2-21-COMMON_20220110", + "timestamp": "2024-12-21T22:04:22.037Z" + }, + "mnhw": { + "value": "MediaTek", + "timestamp": "2024-12-21T22:04:22.037Z" + }, + "di": { + "value": "7db87911-7dce-1cf2-7119-b953432a2f09", + "timestamp": "2024-12-21T22:04:22.037Z" + }, + "mnsl": { + "value": "http://www.samsung.com", + "timestamp": "2024-12-21T22:04:22.037Z" + }, + "dmv": { + "value": "res.1.1.0,sh.1.1.0", + "timestamp": "2024-12-21T22:04:22.037Z" + }, + "n": { + "value": "[refrigerator] Samsung", + "timestamp": "2024-12-21T22:04:22.037Z" + }, + "mnmo": { + "value": "TP2X_REF_20K|00115641|0004014D011411200103000020000000", + "timestamp": "2024-12-21T22:04:22.037Z" + }, + "vid": { + "value": "DA-REF-NORMAL-000001", + "timestamp": "2024-12-21T22:04:22.037Z" + }, + "mnmn": { + "value": "Samsung Electronics", + "timestamp": "2024-12-21T22:04:22.037Z" + }, + "mnml": { + "value": "http://www.samsung.com", + "timestamp": "2024-12-21T22:04:22.037Z" + }, + "mnpv": { + "value": "DAWIT 2.0", + "timestamp": "2024-12-21T22:04:22.037Z" + }, + "mnos": { + "value": "TizenRT 1.0 + IPv6", + "timestamp": "2024-12-21T22:04:22.037Z" + }, + "pi": { + "value": "7db87911-7dce-1cf2-7119-b953432a2f09", + "timestamp": "2024-12-21T22:04:22.037Z" + }, + "icv": { + "value": "core.1.1.0", + "timestamp": "2024-12-21T22:04:22.037Z" + } + }, + "samsungce.fridgeVacationMode": { + "vacationMode": { + "value": null + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": [ + "temperatureMeasurement", + "thermostatCoolingSetpoint", + "custom.fridgeMode", + "custom.deodorFilter", + "samsungce.dongleSoftwareInstallation", + "samsungce.quickControl", + "samsungce.deviceInfoPrivate", + "demandResponseLoadControl", + "samsungce.fridgeVacationMode", + "sec.diagnosticsInformation" + ], + "timestamp": "2025-02-09T13:55:01.720Z" + } + }, + "samsungce.driverVersion": { + "versionNumber": { + "value": 24100101, + "timestamp": "2024-11-08T04:14:59.025Z" + } + }, + "sec.diagnosticsInformation": { + "logType": { + "value": null + }, + "endpoint": { + "value": null + }, + "minVersion": { + "value": null + }, + "signinPermission": { + "value": null + }, + "setupId": { + "value": null + }, + "protocolType": { + "value": null + }, + "tsId": { + "value": null + }, + "mnId": { + "value": null + }, + "dumpType": { + "value": null + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": null + } + }, + "custom.deviceReportStateConfiguration": { + "reportStateRealtimePeriod": { + "value": null + }, + "reportStateRealtime": { + "value": { + "state": "disabled" + }, + "timestamp": "2025-01-19T21:07:55.703Z" + }, + "reportStatePeriod": { + "value": "enabled", + "timestamp": "2025-01-19T21:07:55.703Z" + } + }, + "thermostatCoolingSetpoint": { + "coolingSetpointRange": { + "value": null + }, + "coolingSetpoint": { + "value": null + } + }, + "custom.disabledComponents": { + "disabledComponents": { + "value": [ + "icemaker-02", + "pantry-01", + "pantry-02", + "cvroom", + "onedoor" + ], + "timestamp": "2024-11-08T01:09:17.382Z" + } + }, + "demandResponseLoadControl": { + "drlcStatus": { + "value": { + "drlcType": 1, + "drlcLevel": 0, + "duration": 0, + "override": false + }, + "timestamp": "2025-01-19T21:07:55.691Z" + } + }, + "samsungce.sabbathMode": { + "supportedActions": { + "value": ["on", "off"], + "timestamp": "2025-01-19T21:07:55.799Z" + }, + "status": { + "value": "off", + "timestamp": "2025-01-19T21:07:55.799Z" + } + }, + "powerConsumptionReport": { + "powerConsumption": { + "value": { + "energy": 1568087, + "deltaEnergy": 7, + "power": 6, + "powerEnergy": 13.555977778169844, + "persistedEnergy": 0, + "energySaved": 0, + "start": "2025-02-09T17:38:01Z", + "end": "2025-02-09T17:49:00Z" + }, + "timestamp": "2025-02-09T17:49:00.507Z" + } + }, + "refresh": {}, + "execute": { + "data": { + "value": { + "payload": { + "rt": ["x.com.samsung.da.rm.micomdata"], + "if": ["oic.if.baseline", "oic.if.a"], + "x.com.samsung.rm.micomdata": "D0C0022B00000000000DFE15051F5AA54400000000000000000000000000000000000000000000000001F04A00C5E0", + "x.com.samsung.rm.micomdataLength": 94 + } + }, + "data": { + "href": "/rm/micomdata/vs/0" + }, + "timestamp": "2023-07-19T05:25:39.852Z" + } + }, + "refrigeration": { + "defrost": { + "value": "off", + "timestamp": "2025-01-19T21:07:55.772Z" + }, + "rapidCooling": { + "value": "off", + "timestamp": "2025-01-19T21:07:55.725Z" + }, + "rapidFreezing": { + "value": "off", + "timestamp": "2025-01-19T21:07:55.725Z" + } + }, + "custom.deodorFilter": { + "deodorFilterCapacity": { + "value": null + }, + "deodorFilterLastResetDate": { + "value": null + }, + "deodorFilterStatus": { + "value": null + }, + "deodorFilterResetType": { + "value": null + }, + "deodorFilterUsage": { + "value": null + }, + "deodorFilterUsageStep": { + "value": null + } + }, + "samsungce.powerCool": { + "activated": { + "value": false, + "timestamp": "2025-01-19T21:07:55.725Z" + } + }, + "custom.energyType": { + "energyType": { + "value": "2.0", + "timestamp": "2022-02-07T10:47:54.524Z" + }, + "energySavingSupport": { + "value": false, + "timestamp": "2022-02-07T10:47:54.524Z" + }, + "drMaxDuration": { + "value": 1440, + "unit": "min", + "timestamp": "2022-02-07T11:39:47.504Z" + }, + "energySavingLevel": { + "value": null + }, + "energySavingInfo": { + "value": null + }, + "supportedEnergySavingLevels": { + "value": null + }, + "energySavingOperation": { + "value": null + }, + "notificationTemplateID": { + "value": null + }, + "energySavingOperationSupport": { + "value": false, + "timestamp": "2022-02-07T11:39:47.504Z" + } + }, + "samsungce.softwareUpdate": { + "targetModule": { + "value": {}, + "timestamp": "2025-01-19T21:07:55.725Z" + }, + "otnDUID": { + "value": "P7CNQWBWM3XBW", + "timestamp": "2025-01-19T21:07:55.744Z" + }, + "lastUpdatedDate": { + "value": null + }, + "availableModules": { + "value": [], + "timestamp": "2025-01-19T21:07:55.744Z" + }, + "newVersionAvailable": { + "value": false, + "timestamp": "2025-01-19T21:07:55.725Z" + }, + "operatingState": { + "value": null + }, + "progress": { + "value": null + } + }, + "samsungce.powerFreeze": { + "activated": { + "value": false, + "timestamp": "2025-01-19T21:07:55.725Z" + } + }, + "custom.waterFilter": { + "waterFilterUsageStep": { + "value": 1, + "timestamp": "2025-01-19T21:07:55.758Z" + }, + "waterFilterResetType": { + "value": ["replaceable"], + "timestamp": "2025-01-19T21:07:55.758Z" + }, + "waterFilterCapacity": { + "value": null + }, + "waterFilterLastResetDate": { + "value": null + }, + "waterFilterUsage": { + "value": 100, + "timestamp": "2025-02-09T04:02:12.910Z" + }, + "waterFilterStatus": { + "value": "replace", + "timestamp": "2025-02-09T04:02:12.910Z" + } + } + }, + "cvroom": { + "custom.fridgeMode": { + "fridgeModeValue": { + "value": null + }, + "fridgeMode": { + "value": null + }, + "supportedFridgeModes": { + "value": null + } + }, + "contactSensor": { + "contact": { + "value": null + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": ["temperatureMeasurement", "thermostatCoolingSetpoint"], + "timestamp": "2022-02-07T11:39:42.105Z" + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": null + } + }, + "thermostatCoolingSetpoint": { + "coolingSetpointRange": { + "value": null + }, + "coolingSetpoint": { + "value": null + } + } + }, + "icemaker-02": { + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": [], + "timestamp": "2022-02-07T11:39:42.105Z" + } + }, + "switch": { + "switch": { + "value": null + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/da_rvc_normal_000001.json b/tests/components/smartthings/fixtures/device_status/da_rvc_normal_000001.json new file mode 100644 index 00000000000..3bb2011a2b5 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/da_rvc_normal_000001.json @@ -0,0 +1,274 @@ +{ + "components": { + "main": { + "custom.disabledComponents": { + "disabledComponents": { + "value": ["station"], + "timestamp": "2020-11-03T04:43:07.114Z" + } + }, + "powerConsumptionReport": { + "powerConsumption": { + "value": null, + "timestamp": "2020-11-03T04:43:07.092Z" + } + }, + "refresh": {}, + "samsungce.robotCleanerOperatingState": { + "supportedOperatingState": { + "value": [ + "homing", + "error", + "idle", + "charging", + "chargingForRemainingJob", + "paused", + "cleaning" + ], + "timestamp": "2020-11-03T04:43:06.547Z" + }, + "operatingState": { + "value": "idle", + "timestamp": "2023-06-18T15:59:24.580Z" + }, + "cleaningStep": { + "value": null + }, + "homingReason": { + "value": "none", + "timestamp": "2020-11-03T04:43:22.926Z" + }, + "isMapBasedOperationAvailable": { + "value": null + } + }, + "battery": { + "quantity": { + "value": null + }, + "battery": { + "value": 100, + "unit": "%", + "timestamp": "2022-09-09T22:55:13.962Z" + }, + "type": { + "value": null + } + }, + "execute": { + "data": { + "value": { + "payload": { + "rt": ["x.com.samsung.da.alarms"], + "if": ["oic.if.baseline", "oic.if.a"], + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.code": "4", + "x.com.samsung.da.alarmType": "Device", + "x.com.samsung.da.triggeredTime": "2023-06-18T15:59:30", + "x.com.samsung.da.state": "deleted" + } + ] + } + }, + "data": { + "href": "/alarms/vs/0" + }, + "timestamp": "2023-06-18T15:59:28.267Z" + } + }, + "samsungce.deviceIdentification": { + "micomAssayCode": { + "value": null + }, + "modelName": { + "value": null + }, + "serialNumber": { + "value": null + }, + "serialNumberExtra": { + "value": null + }, + "modelClassificationCode": { + "value": null + }, + "description": { + "value": null + }, + "releaseYear": { + "value": null + }, + "binaryId": { + "value": null + } + }, + "switch": { + "switch": { + "value": "off", + "timestamp": "2023-06-18T15:59:27.658Z" + } + }, + "robotCleanerTurboMode": { + "robotCleanerTurboMode": { + "value": "off", + "timestamp": "2022-09-08T02:53:49.826Z" + } + }, + "ocf": { + "st": { + "value": null, + "timestamp": "2020-06-02T23:30:52.793Z" + }, + "mndt": { + "value": null, + "timestamp": "2020-06-03T13:34:18.508Z" + }, + "mnfv": { + "value": "1.0", + "timestamp": "2019-07-07T19:45:19.771Z" + }, + "mnhw": { + "value": "1.0", + "timestamp": "2019-07-07T19:45:19.771Z" + }, + "di": { + "value": "3442dfc6-17c0-a65f-dae0-4c6e01786f44", + "timestamp": "2019-07-07T19:45:19.771Z" + }, + "mnsl": { + "value": null, + "timestamp": "2020-06-03T00:49:53.813Z" + }, + "dmv": { + "value": "res.1.1.0,sh.1.1.0", + "timestamp": "2021-12-23T07:09:40.610Z" + }, + "n": { + "value": "[robot vacuum] Samsung", + "timestamp": "2019-07-07T19:45:19.771Z" + }, + "mnmo": { + "value": "powerbot_7000_17M|50016055|80010404011141000100000000000000", + "timestamp": "2022-09-07T06:42:36.551Z" + }, + "vid": { + "value": "DA-RVC-NORMAL-000001", + "timestamp": "2019-07-07T19:45:19.771Z" + }, + "mnmn": { + "value": "Samsung Electronics", + "timestamp": "2019-07-07T19:45:19.771Z" + }, + "mnml": { + "value": "http://www.samsung.com", + "timestamp": "2019-07-07T19:45:19.771Z" + }, + "mnpv": { + "value": "00", + "timestamp": "2019-07-07T19:45:19.771Z" + }, + "mnos": { + "value": "Tizen(3/0)", + "timestamp": "2019-07-07T19:45:19.771Z" + }, + "pi": { + "value": "3442dfc6-17c0-a65f-dae0-4c6e01786f44", + "timestamp": "2019-07-07T19:45:19.771Z" + }, + "icv": { + "value": "core.1.1.0", + "timestamp": "2019-07-07T19:45:19.771Z" + } + }, + "samsungce.robotCleanerCleaningMode": { + "supportedCleaningMode": { + "value": ["auto", "spot", "manual", "stop"], + "timestamp": "2020-11-03T04:43:06.547Z" + }, + "repeatModeEnabled": { + "value": false, + "timestamp": "2020-12-21T01:32:56.245Z" + }, + "supportRepeatMode": { + "value": true, + "timestamp": "2020-11-03T04:43:06.547Z" + }, + "cleaningMode": { + "value": "stop", + "timestamp": "2022-09-09T21:25:20.601Z" + } + }, + "robotCleanerMovement": { + "robotCleanerMovement": { + "value": "idle", + "timestamp": "2023-06-18T15:59:24.580Z" + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": [ + "samsungce.robotCleanerMapAreaInfo", + "samsungce.robotCleanerMapCleaningInfo", + "samsungce.robotCleanerPatrol", + "samsungce.robotCleanerPetMonitoring", + "samsungce.robotCleanerPetMonitoringReport", + "samsungce.robotCleanerPetCleaningSchedule", + "soundDetection", + "samsungce.soundDetectionSensitivity", + "samsungce.musicPlaylist", + "mediaPlayback", + "mediaTrackControl", + "imageCapture", + "videoCapture", + "audioVolume", + "audioMute", + "audioNotification", + "powerConsumptionReport", + "custom.hepaFilter", + "samsungce.robotCleanerMotorFilter", + "samsungce.robotCleanerRelayCleaning", + "audioTrackAddressing", + "samsungce.robotCleanerWelcome" + ], + "timestamp": "2022-09-08T01:03:48.820Z" + } + }, + "robotCleanerCleaningMode": { + "robotCleanerCleaningMode": { + "value": "stop", + "timestamp": "2022-09-09T21:25:20.601Z" + } + }, + "samsungce.softwareUpdate": { + "targetModule": { + "value": null + }, + "otnDUID": { + "value": null + }, + "lastUpdatedDate": { + "value": null + }, + "availableModules": { + "value": null + }, + "newVersionAvailable": { + "value": null + }, + "operatingState": { + "value": null + }, + "progress": { + "value": null + } + }, + "samsungce.driverVersion": { + "versionNumber": { + "value": 22100101, + "timestamp": "2022-11-01T09:26:07.107Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/da_wm_dw_000001.json b/tests/components/smartthings/fixtures/device_status/da_wm_dw_000001.json new file mode 100644 index 00000000000..5535055f686 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/da_wm_dw_000001.json @@ -0,0 +1,786 @@ +{ + "components": { + "main": { + "samsungce.dishwasherWashingCourse": { + "customCourseCandidates": { + "value": null + }, + "washingCourse": { + "value": "normal", + "timestamp": "2025-02-08T20:21:26.497Z" + }, + "supportedCourses": { + "value": [ + "auto", + "normal", + "heavy", + "delicate", + "express", + "rinseOnly", + "selfClean" + ], + "timestamp": "2025-02-08T18:00:37.194Z" + } + }, + "dishwasherOperatingState": { + "completionTime": { + "value": "2025-02-08T22:49:26Z", + "timestamp": "2025-02-08T20:21:26.452Z" + }, + "machineState": { + "value": "stop", + "timestamp": "2025-02-08T20:21:26.452Z" + }, + "progress": { + "value": null + }, + "supportedMachineStates": { + "value": ["stop", "run", "pause"], + "timestamp": "2024-09-10T10:21:02.853Z" + }, + "dishwasherJobState": { + "value": "unknown", + "timestamp": "2025-02-08T20:21:26.452Z" + } + }, + "samsungce.dishwasherWashingOptions": { + "dryPlus": { + "value": null + }, + "stormWash": { + "value": null + }, + "hotAirDry": { + "value": null + }, + "selectedZone": { + "value": { + "value": "all", + "settable": ["none", "upper", "lower", "all"] + }, + "timestamp": "2022-11-09T00:20:42.461Z" + }, + "speedBooster": { + "value": { + "value": false, + "settable": [false, true] + }, + "timestamp": "2023-11-24T14:46:55.375Z" + }, + "highTempWash": { + "value": { + "value": false, + "settable": [false, true] + }, + "timestamp": "2025-02-08T07:39:54.739Z" + }, + "sanitizingWash": { + "value": null + }, + "heatedDry": { + "value": null + }, + "zoneBooster": { + "value": { + "value": "none", + "settable": ["none", "left", "right", "all"] + }, + "timestamp": "2022-11-20T07:10:27.445Z" + }, + "addRinse": { + "value": null + }, + "supportedList": { + "value": [ + "selectedZone", + "zoneBooster", + "speedBooster", + "sanitize", + "highTempWash" + ], + "timestamp": "2021-06-27T01:19:38.000Z" + }, + "rinsePlus": { + "value": null + }, + "sanitize": { + "value": { + "value": false, + "settable": [false, true] + }, + "timestamp": "2025-01-18T23:49:09.964Z" + }, + "steamSoak": { + "value": null + } + }, + "samsungce.deviceIdentification": { + "micomAssayCode": { + "value": null + }, + "modelName": { + "value": null + }, + "serialNumber": { + "value": null + }, + "serialNumberExtra": { + "value": null + }, + "modelClassificationCode": { + "value": null + }, + "description": { + "value": null + }, + "releaseYear": { + "value": null + }, + "binaryId": { + "value": "DA_DW_A51_20_COMMON", + "timestamp": "2025-02-08T19:29:30.987Z" + } + }, + "custom.dishwasherOperatingProgress": { + "dishwasherOperatingProgress": { + "value": "none", + "timestamp": "2025-02-08T20:21:26.452Z" + } + }, + "switch": { + "switch": { + "value": "off", + "timestamp": "2025-02-08T20:21:26.386Z" + } + }, + "samsungce.quickControl": { + "version": { + "value": null + } + }, + "samsungce.waterConsumptionReport": { + "waterConsumption": { + "value": null + } + }, + "ocf": { + "st": { + "value": null + }, + "mndt": { + "value": null + }, + "mnfv": { + "value": "DA_DW_A51_20_COMMON_30230714", + "timestamp": "2023-11-02T15:58:55.699Z" + }, + "mnhw": { + "value": "ARTIK051", + "timestamp": "2021-06-27T01:19:37.615Z" + }, + "di": { + "value": "f36dc7ce-cac0-0667-dc14-a3704eb5e676", + "timestamp": "2021-06-27T01:19:37.615Z" + }, + "mnsl": { + "value": "http://www.samsung.com", + "timestamp": "2021-06-27T01:19:37.615Z" + }, + "dmv": { + "value": "res.1.1.0,sh.1.1.0", + "timestamp": "2024-07-04T13:53:32.032Z" + }, + "n": { + "value": "[dishwasher] Samsung", + "timestamp": "2021-06-27T01:19:37.615Z" + }, + "mnmo": { + "value": "DA_DW_A51_20_COMMON|30007242|40010201001311000101000000000000", + "timestamp": "2021-06-27T01:19:37.615Z" + }, + "vid": { + "value": "DA-WM-DW-000001", + "timestamp": "2021-06-27T01:19:37.615Z" + }, + "mnmn": { + "value": "Samsung Electronics", + "timestamp": "2021-06-27T01:19:37.615Z" + }, + "mnml": { + "value": "http://www.samsung.com", + "timestamp": "2021-06-27T01:19:37.615Z" + }, + "mnpv": { + "value": "DAWIT 2.0", + "timestamp": "2021-06-27T01:19:37.615Z" + }, + "mnos": { + "value": "TizenRT 1.0 + IPv6", + "timestamp": "2021-06-27T01:19:37.615Z" + }, + "pi": { + "value": "f36dc7ce-cac0-0667-dc14-a3704eb5e676", + "timestamp": "2021-06-27T01:19:37.615Z" + }, + "icv": { + "value": "core.1.1.0", + "timestamp": "2021-06-27T01:19:37.615Z" + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": [ + "samsungce.waterConsumptionReport", + "sec.wifiConfiguration", + "samsungce.quickControl", + "samsungce.deviceInfoPrivate", + "demandResponseLoadControl", + "sec.diagnosticsInformation", + "custom.waterFilter" + ], + "timestamp": "2025-02-08T19:29:32.447Z" + } + }, + "samsungce.driverVersion": { + "versionNumber": { + "value": 24040105, + "timestamp": "2024-07-02T02:56:22.508Z" + } + }, + "sec.diagnosticsInformation": { + "logType": { + "value": null + }, + "endpoint": { + "value": null + }, + "minVersion": { + "value": null + }, + "signinPermission": { + "value": null + }, + "setupId": { + "value": null + }, + "protocolType": { + "value": null + }, + "tsId": { + "value": null + }, + "mnId": { + "value": null + }, + "dumpType": { + "value": null + } + }, + "samsungce.dishwasherOperation": { + "supportedOperatingState": { + "value": ["ready", "running", "paused"], + "timestamp": "2024-09-10T10:21:02.853Z" + }, + "operatingState": { + "value": "ready", + "timestamp": "2025-02-08T20:21:26.452Z" + }, + "reservable": { + "value": false, + "timestamp": "2025-02-08T18:00:37.194Z" + }, + "progressPercentage": { + "value": 1, + "timestamp": "2025-02-08T20:21:26.452Z" + }, + "remainingTimeStr": { + "value": "02:28", + "timestamp": "2025-02-08T20:21:26.452Z" + }, + "operationTime": { + "value": null + }, + "remainingTime": { + "value": 148.0, + "unit": "min", + "timestamp": "2025-02-08T20:21:26.452Z" + }, + "timeLeftToStart": { + "value": 0.0, + "unit": "min", + "timestamp": "2025-02-08T18:00:37.482Z" + } + }, + "samsungce.dishwasherJobState": { + "scheduledJobs": { + "value": [ + { + "jobName": "washing", + "timeInSec": 3600 + }, + { + "jobName": "rinsing", + "timeInSec": 1020 + }, + { + "jobName": "drying", + "timeInSec": 1200 + } + ], + "timestamp": "2025-02-08T20:21:26.928Z" + }, + "dishwasherJobState": { + "value": "none", + "timestamp": "2025-02-08T20:21:26.452Z" + } + }, + "samsungce.kidsLock": { + "lockState": { + "value": "unlocked", + "timestamp": "2025-02-08T18:00:37.450Z" + } + }, + "demandResponseLoadControl": { + "drlcStatus": { + "value": null + } + }, + "powerConsumptionReport": { + "powerConsumption": { + "value": { + "energy": 101600, + "deltaEnergy": 0, + "power": 0, + "powerEnergy": 0.0, + "persistedEnergy": 0, + "energySaved": 0, + "persistedSavedEnergy": 0, + "start": "2025-02-08T20:21:21Z", + "end": "2025-02-08T20:21:26Z" + }, + "timestamp": "2025-02-08T20:21:26.596Z" + } + }, + "refresh": {}, + "samsungce.dishwasherWashingCourseDetails": { + "predefinedCourses": { + "value": [ + { + "courseName": "auto", + "energyUsage": 3, + "waterUsage": 3, + "temperature": { + "min": 50, + "max": 60, + "unit": "C" + }, + "expectedTime": { + "time": 136, + "unit": "min" + }, + "options": { + "highTempWash": { + "default": false, + "settable": [false, true] + }, + "sanitize": { + "default": false, + "settable": [false, true] + }, + "speedBooster": { + "default": false, + "settable": [false, true] + }, + "zoneBooster": { + "default": "none", + "settable": ["none", "left"] + }, + "selectedZone": { + "default": "all", + "settable": ["none", "lower", "all"] + } + } + }, + { + "courseName": "normal", + "energyUsage": 3, + "waterUsage": 4, + "temperature": { + "min": 45, + "max": 62, + "unit": "C" + }, + "expectedTime": { + "time": 148, + "unit": "min" + }, + "options": { + "highTempWash": { + "default": false, + "settable": [false, true] + }, + "sanitize": { + "default": false, + "settable": [false, true] + }, + "speedBooster": { + "default": false, + "settable": [false, true] + }, + "zoneBooster": { + "default": "none", + "settable": ["none", "left"] + }, + "selectedZone": { + "default": "all", + "settable": ["none", "lower", "all"] + } + } + }, + { + "courseName": "heavy", + "energyUsage": 4, + "waterUsage": 5, + "temperature": { + "min": 65, + "max": 65, + "unit": "C" + }, + "expectedTime": { + "time": 155, + "unit": "min" + }, + "options": { + "highTempWash": { + "default": false, + "settable": [false, true] + }, + "sanitize": { + "default": false, + "settable": [false, true] + }, + "speedBooster": { + "default": false, + "settable": [false, true] + }, + "zoneBooster": { + "default": "none", + "settable": ["none", "left"] + }, + "selectedZone": { + "default": "all", + "settable": ["none", "lower", "all"] + } + } + }, + { + "courseName": "delicate", + "energyUsage": 2, + "waterUsage": 3, + "temperature": { + "min": 50, + "max": 50, + "unit": "C" + }, + "expectedTime": { + "time": 112, + "unit": "min" + }, + "options": { + "highTempWash": { + "default": false, + "settable": [] + }, + "sanitize": { + "default": false, + "settable": [] + }, + "speedBooster": { + "default": false, + "settable": [false, true] + }, + "zoneBooster": { + "default": "none", + "settable": [] + }, + "selectedZone": { + "default": "all", + "settable": ["none", "lower", "all"] + } + } + }, + { + "courseName": "express", + "energyUsage": 2, + "waterUsage": 2, + "temperature": { + "min": 52, + "max": 52, + "unit": "C" + }, + "expectedTime": { + "time": 60, + "unit": "min" + }, + "options": { + "highTempWash": { + "default": false, + "settable": [false, true] + }, + "sanitize": { + "default": false, + "settable": [false, true] + }, + "speedBooster": { + "default": false, + "settable": [] + }, + "zoneBooster": { + "default": "none", + "settable": ["none", "left"] + }, + "selectedZone": { + "default": "all", + "settable": ["none", "lower", "all"] + } + } + }, + { + "courseName": "rinseOnly", + "energyUsage": 1, + "waterUsage": 1, + "temperature": { + "min": 40, + "max": 40, + "unit": "C" + }, + "expectedTime": { + "time": 14, + "unit": "min" + }, + "options": { + "highTempWash": { + "default": false, + "settable": [] + }, + "sanitize": { + "default": false, + "settable": [] + }, + "speedBooster": { + "default": false, + "settable": [] + }, + "zoneBooster": { + "default": "none", + "settable": [] + }, + "selectedZone": { + "default": "all", + "settable": ["none", "lower", "all"] + } + } + }, + { + "courseName": "selfClean", + "energyUsage": 5, + "waterUsage": 4, + "temperature": { + "min": 70, + "max": 70, + "unit": "C" + }, + "expectedTime": { + "time": 139, + "unit": "min" + }, + "options": { + "highTempWash": { + "default": false, + "settable": [] + }, + "sanitize": { + "default": false, + "settable": [] + }, + "speedBooster": { + "default": false, + "settable": [] + }, + "zoneBooster": { + "default": "none", + "settable": [] + }, + "selectedZone": { + "default": "all", + "settable": ["none", "all"] + } + } + } + ], + "timestamp": "2025-02-08T18:00:37.194Z" + }, + "waterUsageMax": { + "value": 5, + "timestamp": "2025-02-08T18:00:37.194Z" + }, + "energyUsageMax": { + "value": 5, + "timestamp": "2025-02-08T18:00:37.194Z" + } + }, + "execute": { + "data": { + "value": { + "payload": { + "rt": ["oic.r.operational.state"], + "if": ["oic.if.baseline", "oic.if.a"], + "currentMachineState": "idle", + "machineStates": ["pause", "active", "idle"], + "jobStates": [ + "None", + "Predrain", + "Prewash", + "Wash", + "Rinse", + "Drying", + "Finish" + ], + "currentJobState": "None", + "remainingTime": "02:16:00", + "progressPercentage": "1" + } + }, + "data": { + "href": "/operational/state/0" + }, + "timestamp": "2023-07-19T04:23:15.606Z" + } + }, + "sec.wifiConfiguration": { + "autoReconnection": { + "value": null + }, + "minVersion": { + "value": null + }, + "supportedWiFiFreq": { + "value": null + }, + "supportedAuthType": { + "value": null + }, + "protocolType": { + "value": null + } + }, + "custom.dishwasherOperatingPercentage": { + "dishwasherOperatingPercentage": { + "value": 1, + "timestamp": "2025-02-08T20:21:26.452Z" + } + }, + "remoteControlStatus": { + "remoteControlEnabled": { + "value": "false", + "timestamp": "2025-02-08T18:00:37.555Z" + } + }, + "custom.supportedOptions": { + "course": { + "value": null + }, + "referenceTable": { + "value": null + }, + "supportedCourses": { + "value": ["82", "83", "84", "85", "86", "87", "88"], + "timestamp": "2025-02-08T18:00:37.194Z" + } + }, + "custom.dishwasherDelayStartTime": { + "dishwasherDelayStartTime": { + "value": "00:00:00", + "timestamp": "2025-02-08T18:00:37.482Z" + } + }, + "custom.energyType": { + "energyType": { + "value": "2.0", + "timestamp": "2023-08-25T03:23:06.667Z" + }, + "energySavingSupport": { + "value": true, + "timestamp": "2024-10-01T00:08:09.813Z" + }, + "drMaxDuration": { + "value": null + }, + "energySavingLevel": { + "value": null + }, + "energySavingInfo": { + "value": null + }, + "supportedEnergySavingLevels": { + "value": null + }, + "energySavingOperation": { + "value": null + }, + "notificationTemplateID": { + "value": null + }, + "energySavingOperationSupport": { + "value": null + } + }, + "samsungce.softwareUpdate": { + "targetModule": { + "value": null + }, + "otnDUID": { + "value": "MTCNQWBWIV6TS", + "timestamp": "2025-02-08T18:00:37.538Z" + }, + "lastUpdatedDate": { + "value": null + }, + "availableModules": { + "value": [], + "timestamp": "2022-07-20T03:37:30.706Z" + }, + "newVersionAvailable": { + "value": false, + "timestamp": "2025-02-08T18:00:37.538Z" + }, + "operatingState": { + "value": null + }, + "progress": { + "value": null + } + }, + "custom.waterFilter": { + "waterFilterUsageStep": { + "value": null + }, + "waterFilterResetType": { + "value": null + }, + "waterFilterCapacity": { + "value": null + }, + "waterFilterLastResetDate": { + "value": null + }, + "waterFilterUsage": { + "value": null + }, + "waterFilterStatus": { + "value": null + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/da_wm_wd_000001.json b/tests/components/smartthings/fixtures/device_status/da_wm_wd_000001.json new file mode 100644 index 00000000000..fe43b490387 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/da_wm_wd_000001.json @@ -0,0 +1,719 @@ +{ + "components": { + "hca.main": { + "hca.dryerMode": { + "mode": { + "value": "normal", + "timestamp": "2025-02-08T18:10:11.023Z" + }, + "supportedModes": { + "value": ["normal", "timeDry", "quickDry"], + "timestamp": "2025-02-08T18:10:10.497Z" + } + } + }, + "main": { + "custom.dryerWrinklePrevent": { + "operatingState": { + "value": "ready", + "timestamp": "2025-02-08T18:10:10.497Z" + }, + "dryerWrinklePrevent": { + "value": "off", + "timestamp": "2025-02-08T18:10:10.840Z" + } + }, + "samsungce.dryerDryingTemperature": { + "dryingTemperature": { + "value": "medium", + "timestamp": "2025-02-08T18:10:10.840Z" + }, + "supportedDryingTemperature": { + "value": ["none", "extraLow", "low", "mediumLow", "medium", "high"], + "timestamp": "2025-01-04T22:52:14.884Z" + } + }, + "samsungce.welcomeMessage": { + "welcomeMessage": { + "value": null + } + }, + "samsungce.dongleSoftwareInstallation": { + "status": { + "value": "completed", + "timestamp": "2022-06-14T06:49:02.183Z" + } + }, + "samsungce.dryerCyclePreset": { + "maxNumberOfPresets": { + "value": 10, + "timestamp": "2025-02-08T18:10:10.990Z" + }, + "presets": { + "value": null + } + }, + "samsungce.deviceIdentification": { + "micomAssayCode": { + "value": "20233741", + "timestamp": "2025-02-08T18:10:11.113Z" + }, + "modelName": { + "value": null + }, + "serialNumber": { + "value": null + }, + "serialNumberExtra": { + "value": null + }, + "modelClassificationCode": { + "value": "3000000100111100020B000000000000", + "timestamp": "2025-02-08T18:10:11.113Z" + }, + "description": { + "value": "DA_WM_A51_20_COMMON_DV6300R/DC92-02385A_0090", + "timestamp": "2025-02-08T18:10:11.113Z" + }, + "releaseYear": { + "value": null + }, + "binaryId": { + "value": "DA_WM_A51_20_COMMON", + "timestamp": "2025-02-08T18:10:11.113Z" + } + }, + "switch": { + "switch": { + "value": "off", + "timestamp": "2025-02-08T18:10:10.911Z" + } + }, + "samsungce.quickControl": { + "version": { + "value": null + } + }, + "samsungce.dryerFreezePrevent": { + "operatingState": { + "value": null + } + }, + "ocf": { + "st": { + "value": null + }, + "mndt": { + "value": null + }, + "mnfv": { + "value": "DA_WM_A51_20_COMMON_30230708", + "timestamp": "2025-01-04T22:52:14.222Z" + }, + "mnhw": { + "value": "ARTIK051", + "timestamp": "2025-01-04T22:52:14.222Z" + }, + "di": { + "value": "02f7256e-8353-5bdd-547f-bd5b1647e01b", + "timestamp": "2025-01-04T22:52:14.222Z" + }, + "mnsl": { + "value": "http://www.samsung.com", + "timestamp": "2025-01-04T22:52:14.222Z" + }, + "dmv": { + "value": "res.1.1.0,sh.1.1.0", + "timestamp": "2025-01-04T22:52:14.222Z" + }, + "n": { + "value": "[dryer] Samsung", + "timestamp": "2025-01-04T22:52:14.222Z" + }, + "mnmo": { + "value": "DA_WM_A51_20_COMMON|20233741|3000000100111100020B000000000000", + "timestamp": "2025-01-04T22:52:14.222Z" + }, + "vid": { + "value": "DA-WM-WD-000001", + "timestamp": "2025-01-04T22:52:14.222Z" + }, + "mnmn": { + "value": "Samsung Electronics", + "timestamp": "2025-01-04T22:52:14.222Z" + }, + "mnml": { + "value": "http://www.samsung.com", + "timestamp": "2025-01-04T22:52:14.222Z" + }, + "mnpv": { + "value": "DAWIT 2.0", + "timestamp": "2025-01-04T22:52:14.222Z" + }, + "mnos": { + "value": "TizenRT 1.0 + IPv6", + "timestamp": "2025-01-04T22:52:14.222Z" + }, + "pi": { + "value": "02f7256e-8353-5bdd-547f-bd5b1647e01b", + "timestamp": "2025-01-04T22:52:14.222Z" + }, + "icv": { + "value": "core.1.1.0", + "timestamp": "2025-01-04T22:52:14.222Z" + } + }, + "custom.dryerDryLevel": { + "dryerDryLevel": { + "value": "normal", + "timestamp": "2025-02-08T18:10:10.840Z" + }, + "supportedDryerDryLevel": { + "value": ["none", "damp", "less", "normal", "more", "very"], + "timestamp": "2021-06-01T22:54:28.224Z" + } + }, + "samsungce.dryerAutoCycleLink": { + "dryerAutoCycleLink": { + "value": "on", + "timestamp": "2025-02-08T18:10:11.986Z" + } + }, + "samsungce.dryerCycle": { + "dryerCycle": { + "value": "Table_00_Course_01", + "timestamp": "2025-02-08T18:10:11.023Z" + }, + "supportedCycles": { + "value": [ + { + "cycle": "01", + "supportedOptions": { + "dryingLevel": { + "raw": "D33E", + "default": "normal", + "options": ["damp", "less", "normal", "more", "very"] + }, + "dryingTemperature": { + "raw": "8410", + "default": "medium", + "options": ["medium"] + } + } + }, + { + "cycle": "9C", + "supportedOptions": { + "dryingLevel": { + "raw": "D33E", + "default": "normal", + "options": ["damp", "less", "normal", "more", "very"] + }, + "dryingTemperature": { + "raw": "8520", + "default": "high", + "options": ["high"] + } + } + }, + { + "cycle": "A5", + "supportedOptions": { + "dryingLevel": { + "raw": "D33E", + "default": "normal", + "options": ["damp", "less", "normal", "more", "very"] + }, + "dryingTemperature": { + "raw": "8520", + "default": "high", + "options": ["high"] + } + } + }, + { + "cycle": "9E", + "supportedOptions": { + "dryingLevel": { + "raw": "D33E", + "default": "normal", + "options": ["damp", "less", "normal", "more", "very"] + }, + "dryingTemperature": { + "raw": "8308", + "default": "mediumLow", + "options": ["mediumLow"] + } + } + }, + { + "cycle": "9B", + "supportedOptions": { + "dryingLevel": { + "raw": "D520", + "default": "very", + "options": ["very"] + }, + "dryingTemperature": { + "raw": "8520", + "default": "high", + "options": ["high"] + } + } + }, + { + "cycle": "27", + "supportedOptions": { + "dryingLevel": { + "raw": "D000", + "default": "none", + "options": [] + }, + "dryingTemperature": { + "raw": "8520", + "default": "high", + "options": ["high"] + } + } + }, + { + "cycle": "E5", + "supportedOptions": { + "dryingLevel": { + "raw": "D000", + "default": "none", + "options": [] + }, + "dryingTemperature": { + "raw": "8000", + "default": "none", + "options": [] + } + } + }, + { + "cycle": "A0", + "supportedOptions": { + "dryingLevel": { + "raw": "D000", + "default": "none", + "options": [] + }, + "dryingTemperature": { + "raw": "8000", + "default": "none", + "options": [] + } + } + }, + { + "cycle": "A4", + "supportedOptions": { + "dryingLevel": { + "raw": "D000", + "default": "none", + "options": [] + }, + "dryingTemperature": { + "raw": "853E", + "default": "high", + "options": ["extraLow", "low", "mediumLow", "medium", "high"] + } + } + }, + { + "cycle": "A6", + "supportedOptions": { + "dryingLevel": { + "raw": "D000", + "default": "none", + "options": [] + }, + "dryingTemperature": { + "raw": "8520", + "default": "high", + "options": ["high"] + } + } + }, + { + "cycle": "A3", + "supportedOptions": { + "dryingLevel": { + "raw": "D308", + "default": "normal", + "options": ["normal"] + }, + "dryingTemperature": { + "raw": "8410", + "default": "medium", + "options": ["medium"] + } + } + }, + { + "cycle": "A2", + "supportedOptions": { + "dryingLevel": { + "raw": "D33E", + "default": "normal", + "options": ["damp", "less", "normal", "more", "very"] + }, + "dryingTemperature": { + "raw": "8102", + "default": "extraLow", + "options": ["extraLow"] + } + } + } + ], + "timestamp": "2025-01-04T22:52:14.884Z" + }, + "referenceTable": { + "value": { + "id": "Table_00" + }, + "timestamp": "2025-02-08T18:10:11.023Z" + }, + "specializedFunctionClassification": { + "value": 4, + "timestamp": "2025-02-08T18:10:10.497Z" + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": [ + "samsungce.dryerCyclePreset", + "samsungce.welcomeMessage", + "samsungce.dongleSoftwareInstallation", + "sec.wifiConfiguration", + "samsungce.quickControl", + "samsungce.deviceInfoPrivate", + "demandResponseLoadControl", + "samsungce.dryerFreezePrevent", + "sec.diagnosticsInformation" + ], + "timestamp": "2024-07-05T16:04:06.674Z" + } + }, + "samsungce.driverVersion": { + "versionNumber": { + "value": 24110101, + "timestamp": "2024-12-03T02:59:11.115Z" + } + }, + "sec.diagnosticsInformation": { + "logType": { + "value": null + }, + "endpoint": { + "value": null + }, + "minVersion": { + "value": null + }, + "signinPermission": { + "value": null + }, + "setupId": { + "value": null + }, + "protocolType": { + "value": null + }, + "tsId": { + "value": null + }, + "mnId": { + "value": null + }, + "dumpType": { + "value": null + } + }, + "samsungce.kidsLock": { + "lockState": { + "value": "unlocked", + "timestamp": "2025-02-08T18:10:10.825Z" + } + }, + "demandResponseLoadControl": { + "drlcStatus": { + "value": null + } + }, + "samsungce.detergentOrder": { + "alarmEnabled": { + "value": false, + "timestamp": "2025-02-08T18:10:10.497Z" + }, + "orderThreshold": { + "value": 0, + "unit": "cc", + "timestamp": "2025-02-08T18:10:10.497Z" + } + }, + "powerConsumptionReport": { + "powerConsumption": { + "value": { + "energy": 4495500, + "deltaEnergy": 0, + "power": 0, + "powerEnergy": 0.0, + "persistedEnergy": 0, + "energySaved": 0, + "start": "2025-02-07T04:00:19Z", + "end": "2025-02-08T18:10:11Z" + }, + "timestamp": "2025-02-08T18:10:11.053Z" + } + }, + "dryerOperatingState": { + "completionTime": { + "value": "2025-02-08T19:25:10Z", + "timestamp": "2025-02-08T18:10:10.962Z" + }, + "machineState": { + "value": "stop", + "timestamp": "2025-02-08T18:10:10.962Z" + }, + "supportedMachineStates": { + "value": ["stop", "run", "pause"], + "timestamp": "2025-02-08T18:10:10.962Z" + }, + "dryerJobState": { + "value": "none", + "timestamp": "2025-02-08T18:10:10.962Z" + } + }, + "samsungce.detergentState": { + "remainingAmount": { + "value": 0, + "unit": "cc", + "timestamp": "2025-02-08T18:10:10.497Z" + }, + "dosage": { + "value": 0, + "unit": "cc", + "timestamp": "2025-02-08T18:10:10.497Z" + }, + "initialAmount": { + "value": 0, + "unit": "cc", + "timestamp": "2025-02-08T18:10:10.497Z" + }, + "detergentType": { + "value": "none", + "timestamp": "2021-06-01T22:54:28.372Z" + } + }, + "samsungce.dryerDelayEnd": { + "remainingTime": { + "value": 0, + "unit": "min", + "timestamp": "2025-02-08T18:10:10.962Z" + } + }, + "refresh": {}, + "custom.jobBeginningStatus": { + "jobBeginningStatus": { + "value": null + } + }, + "execute": { + "data": { + "value": { + "payload": { + "rt": ["x.com.samsung.da.information"], + "if": ["oic.if.baseline", "oic.if.a"], + "x.com.samsung.da.modelNum": "DA_WM_A51_20_COMMON|20233741|3000000100111100020B000000000000", + "x.com.samsung.da.description": "DA_WM_A51_20_COMMON_DV6300R/DC92-02385A_0090", + "x.com.samsung.da.serialNum": "FFFFFFFFFFFFFFF", + "x.com.samsung.da.otnDUID": "7XCDM6YAIRCGM", + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "DA_WM_A51_20_COMMON|20233741|3000000100111100020B000000000000", + "x.com.samsung.da.type": "Software", + "x.com.samsung.da.number": "02198A220728(E256)", + "x.com.samsung.da.newVersionAvailable": "0" + }, + { + "x.com.samsung.da.id": "1", + "x.com.samsung.da.description": "DA_WM_A51_20_COMMON", + "x.com.samsung.da.type": "Firmware", + "x.com.samsung.da.number": "18112816,20112625", + "x.com.samsung.da.newVersionAvailable": "0" + } + ] + } + }, + "data": { + "href": "/information/vs/0" + }, + "timestamp": "2023-08-06T22:48:43.192Z" + } + }, + "sec.wifiConfiguration": { + "autoReconnection": { + "value": null + }, + "minVersion": { + "value": null + }, + "supportedWiFiFreq": { + "value": null + }, + "supportedAuthType": { + "value": null + }, + "protocolType": { + "value": null + } + }, + "remoteControlStatus": { + "remoteControlEnabled": { + "value": "false", + "timestamp": "2025-02-08T18:10:10.970Z" + } + }, + "custom.supportedOptions": { + "course": { + "value": null + }, + "referenceTable": { + "value": { + "id": "Table_00" + }, + "timestamp": "2025-02-08T18:10:11.023Z" + }, + "supportedCourses": { + "value": [ + "01", + "9C", + "A5", + "9E", + "9B", + "27", + "E5", + "A0", + "A4", + "A6", + "A3", + "A2" + ], + "timestamp": "2025-02-08T18:10:10.497Z" + } + }, + "custom.energyType": { + "energyType": { + "value": "2.0", + "timestamp": "2022-06-14T06:49:02.183Z" + }, + "energySavingSupport": { + "value": false, + "timestamp": "2022-06-14T06:49:02.721Z" + }, + "drMaxDuration": { + "value": null + }, + "energySavingLevel": { + "value": null + }, + "energySavingInfo": { + "value": null + }, + "supportedEnergySavingLevels": { + "value": null + }, + "energySavingOperation": { + "value": null + }, + "notificationTemplateID": { + "value": null + }, + "energySavingOperationSupport": { + "value": null + } + }, + "samsungce.dryerOperatingState": { + "operatingState": { + "value": "ready", + "timestamp": "2025-02-07T04:00:18.186Z" + }, + "supportedOperatingStates": { + "value": ["ready", "running", "paused"], + "timestamp": "2022-11-01T13:43:26.961Z" + }, + "scheduledJobs": { + "value": [ + { + "jobName": "drying", + "timeInMin": 57 + }, + { + "jobName": "cooling", + "timeInMin": 3 + } + ], + "timestamp": "2025-02-08T18:10:10.497Z" + }, + "progress": { + "value": 1, + "unit": "%", + "timestamp": "2025-02-07T04:00:18.186Z" + }, + "remainingTimeStr": { + "value": "01:15", + "timestamp": "2025-02-07T04:00:18.186Z" + }, + "dryerJobState": { + "value": "none", + "timestamp": "2025-02-07T04:00:18.186Z" + }, + "remainingTime": { + "value": 75, + "unit": "min", + "timestamp": "2025-02-07T04:00:18.186Z" + } + }, + "samsungce.softwareUpdate": { + "targetModule": { + "value": null + }, + "otnDUID": { + "value": "7XCDM6YAIRCGM", + "timestamp": "2025-02-08T18:10:11.113Z" + }, + "lastUpdatedDate": { + "value": null + }, + "availableModules": { + "value": [], + "timestamp": "2024-12-02T00:29:53.432Z" + }, + "newVersionAvailable": { + "value": false, + "timestamp": "2024-12-02T00:29:53.432Z" + }, + "operatingState": { + "value": null + }, + "progress": { + "value": null + } + }, + "samsungce.dryerDryingTime": { + "supportedDryingTime": { + "value": ["0", "20", "30", "40", "50", "60"], + "timestamp": "2021-06-01T22:54:28.224Z" + }, + "dryingTime": { + "value": "0", + "unit": "min", + "timestamp": "2025-02-08T18:10:10.840Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/da_wm_wm_000001.json b/tests/components/smartthings/fixtures/device_status/da_wm_wm_000001.json new file mode 100644 index 00000000000..6a141c9462e --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/da_wm_wm_000001.json @@ -0,0 +1,1243 @@ +{ + "components": { + "hca.main": { + "hca.washerMode": { + "mode": { + "value": "normal", + "timestamp": "2025-02-07T02:29:55.623Z" + }, + "supportedModes": { + "value": ["normal", "quickWash"], + "timestamp": "2025-02-07T02:29:55.152Z" + } + } + }, + "main": { + "samsungce.washerDelayEnd": { + "remainingTime": { + "value": 0, + "unit": "min", + "timestamp": "2025-02-07T02:29:55.546Z" + }, + "minimumReservableTime": { + "value": 45, + "unit": "min", + "timestamp": "2025-02-07T03:09:45.594Z" + } + }, + "samsungce.washerWaterLevel": { + "supportedWaterLevel": { + "value": null + }, + "waterLevel": { + "value": null + } + }, + "samsungce.welcomeMessage": { + "welcomeMessage": { + "value": null + } + }, + "custom.washerWaterTemperature": { + "supportedWasherWaterTemperature": { + "value": ["none", "tapCold", "cold", "warm", "hot", "extraHot"], + "timestamp": "2024-12-25T22:13:27.760Z" + }, + "washerWaterTemperature": { + "value": "warm", + "timestamp": "2025-02-07T02:29:55.691Z" + } + }, + "samsungce.softenerAutoReplenishment": { + "regularSoftenerType": { + "value": null + }, + "regularSoftenerAlarmEnabled": { + "value": null + }, + "regularSoftenerInitialAmount": { + "value": null + }, + "regularSoftenerRemainingAmount": { + "value": null + }, + "regularSoftenerDosage": { + "value": null + }, + "regularSoftenerOrderThreshold": { + "value": null + } + }, + "samsungce.autoDispenseSoftener": { + "remainingAmount": { + "value": null + }, + "amount": { + "value": null + }, + "supportedDensity": { + "value": null + }, + "density": { + "value": null + }, + "supportedAmount": { + "value": null + } + }, + "samsungce.dongleSoftwareInstallation": { + "status": { + "value": "completed", + "timestamp": "2022-06-15T14:11:34.909Z" + } + }, + "samsungce.autoDispenseDetergent": { + "remainingAmount": { + "value": null + }, + "amount": { + "value": null + }, + "supportedDensity": { + "value": null + }, + "density": { + "value": null + }, + "supportedAmount": { + "value": null + }, + "availableTypes": { + "value": null + }, + "type": { + "value": null + }, + "recommendedAmount": { + "value": null + } + }, + "samsungce.deviceIdentification": { + "micomAssayCode": { + "value": "20233741", + "timestamp": "2025-02-07T02:29:55.453Z" + }, + "modelName": { + "value": null + }, + "serialNumber": { + "value": null + }, + "serialNumberExtra": { + "value": null + }, + "modelClassificationCode": { + "value": "2001000100131100022B010000000000", + "timestamp": "2025-02-07T02:29:55.453Z" + }, + "description": { + "value": "DA_WM_TP2_20_COMMON_WF6300R/DC92-02338B_0080", + "timestamp": "2025-02-07T02:29:55.453Z" + }, + "releaseYear": { + "value": null + }, + "binaryId": { + "value": "DA_WM_TP2_20_COMMON", + "timestamp": "2025-02-07T02:29:55.453Z" + } + }, + "samsungce.washerWaterValve": { + "waterValve": { + "value": null + }, + "supportedWaterValve": { + "value": null + } + }, + "washerOperatingState": { + "completionTime": { + "value": "2025-02-07T03:54:45Z", + "timestamp": "2025-02-07T03:09:45.534Z" + }, + "machineState": { + "value": "stop", + "timestamp": "2025-02-07T03:09:45.534Z" + }, + "washerJobState": { + "value": "none", + "timestamp": "2025-02-07T03:09:45.534Z" + }, + "supportedMachineStates": { + "value": ["stop", "run", "pause"], + "timestamp": "2025-02-07T02:29:55.546Z" + } + }, + "switch": { + "switch": { + "value": "off", + "timestamp": "2025-02-07T03:09:45.456Z" + } + }, + "custom.washerAutoSoftener": { + "washerAutoSoftener": { + "value": null + } + }, + "samsungce.washerFreezePrevent": { + "operatingState": { + "value": null + } + }, + "samsungce.quickControl": { + "version": { + "value": null + } + }, + "samsungce.washerCycle": { + "supportedCycles": { + "value": [ + { + "cycle": "01", + "cycleType": "washingOnly", + "supportedOptions": { + "soilLevel": { + "raw": "C33E", + "default": "normal", + "options": [ + "extraLight", + "light", + "normal", + "heavy", + "extraHeavy" + ] + }, + "spinLevel": { + "raw": "A43B", + "default": "high", + "options": [ + "rinseHold", + "noSpin", + "medium", + "high", + "extraHigh" + ] + }, + "rinseCycle": { + "raw": "923F", + "default": "2", + "options": ["0", "1", "2", "3", "4", "5"] + }, + "waterTemperature": { + "raw": "833E", + "default": "warm", + "options": ["tapCold", "cold", "warm", "hot", "extraHot"] + } + } + }, + { + "cycle": "70", + "cycleType": "washingOnly", + "supportedOptions": { + "soilLevel": { + "raw": "C53E", + "default": "extraHeavy", + "options": [ + "extraLight", + "light", + "normal", + "heavy", + "extraHeavy" + ] + }, + "spinLevel": { + "raw": "A53F", + "default": "extraHigh", + "options": [ + "rinseHold", + "noSpin", + "low", + "medium", + "high", + "extraHigh" + ] + }, + "rinseCycle": { + "raw": "923F", + "default": "2", + "options": ["0", "1", "2", "3", "4", "5"] + }, + "waterTemperature": { + "raw": "843E", + "default": "hot", + "options": ["tapCold", "cold", "warm", "hot", "extraHot"] + } + } + }, + { + "cycle": "55", + "cycleType": "washingOnly", + "supportedOptions": { + "soilLevel": { + "raw": "C33E", + "default": "normal", + "options": [ + "extraLight", + "light", + "normal", + "heavy", + "extraHeavy" + ] + }, + "spinLevel": { + "raw": "A43F", + "default": "high", + "options": [ + "rinseHold", + "noSpin", + "low", + "medium", + "high", + "extraHigh" + ] + }, + "rinseCycle": { + "raw": "923F", + "default": "2", + "options": ["0", "1", "2", "3", "4", "5"] + }, + "waterTemperature": { + "raw": "831E", + "default": "warm", + "options": ["tapCold", "cold", "warm", "hot"] + } + } + }, + { + "cycle": "71", + "cycleType": "washingOnly", + "supportedOptions": { + "soilLevel": { + "raw": "C33E", + "default": "normal", + "options": [ + "extraLight", + "light", + "normal", + "heavy", + "extraHeavy" + ] + }, + "spinLevel": { + "raw": "A20F", + "default": "low", + "options": ["rinseHold", "noSpin", "low", "medium"] + }, + "rinseCycle": { + "raw": "923F", + "default": "2", + "options": ["0", "1", "2", "3", "4", "5"] + }, + "waterTemperature": { + "raw": "830E", + "default": "warm", + "options": ["tapCold", "cold", "warm"] + } + } + }, + { + "cycle": "72", + "cycleType": "washingOnly", + "supportedOptions": { + "soilLevel": { + "raw": "C33E", + "default": "normal", + "options": [ + "extraLight", + "light", + "normal", + "heavy", + "extraHeavy" + ] + }, + "spinLevel": { + "raw": "A43F", + "default": "high", + "options": [ + "rinseHold", + "noSpin", + "low", + "medium", + "high", + "extraHigh" + ] + }, + "rinseCycle": { + "raw": "923F", + "default": "2", + "options": ["0", "1", "2", "3", "4", "5"] + }, + "waterTemperature": { + "raw": "8520", + "default": "extraHot", + "options": ["extraHot"] + } + } + }, + { + "cycle": "77", + "cycleType": "washingOnly", + "supportedOptions": { + "soilLevel": { + "raw": "C33E", + "default": "normal", + "options": [ + "extraLight", + "light", + "normal", + "heavy", + "extraHeavy" + ] + }, + "spinLevel": { + "raw": "A21F", + "default": "low", + "options": ["rinseHold", "noSpin", "low", "medium", "high"] + }, + "rinseCycle": { + "raw": "923F", + "default": "2", + "options": ["0", "1", "2", "3", "4", "5"] + }, + "waterTemperature": { + "raw": "830E", + "default": "warm", + "options": ["tapCold", "cold", "warm"] + } + } + }, + { + "cycle": "E5", + "cycleType": "washingOnly", + "supportedOptions": { + "soilLevel": { + "raw": "C53E", + "default": "extraHeavy", + "options": [ + "extraLight", + "light", + "normal", + "heavy", + "extraHeavy" + ] + }, + "spinLevel": { + "raw": "A43F", + "default": "high", + "options": [ + "rinseHold", + "noSpin", + "low", + "medium", + "high", + "extraHigh" + ] + }, + "rinseCycle": { + "raw": "923F", + "default": "2", + "options": ["0", "1", "2", "3", "4", "5"] + }, + "waterTemperature": { + "raw": "831E", + "default": "warm", + "options": ["tapCold", "cold", "warm", "hot"] + } + } + }, + { + "cycle": "57", + "cycleType": "washingOnly", + "supportedOptions": { + "soilLevel": { + "raw": "C000", + "default": "none", + "options": [] + }, + "spinLevel": { + "raw": "A520", + "default": "extraHigh", + "options": ["extraHigh"] + }, + "rinseCycle": { + "raw": "9204", + "default": "2", + "options": ["2"] + }, + "waterTemperature": { + "raw": "8520", + "default": "extraHot", + "options": ["extraHot"] + } + } + }, + { + "cycle": "73", + "cycleType": "washingOnly", + "supportedOptions": { + "soilLevel": { + "raw": "C000", + "default": "none", + "options": [] + }, + "spinLevel": { + "raw": "A43F", + "default": "high", + "options": [ + "rinseHold", + "noSpin", + "low", + "medium", + "high", + "extraHigh" + ] + }, + "rinseCycle": { + "raw": "913F", + "default": "1", + "options": ["0", "1", "2", "3", "4", "5"] + }, + "waterTemperature": { + "raw": "8000", + "default": "none", + "options": [] + } + } + }, + { + "cycle": "74", + "cycleType": "washingOnly", + "supportedOptions": { + "soilLevel": { + "raw": "C33E", + "default": "normal", + "options": [ + "extraLight", + "light", + "normal", + "heavy", + "extraHeavy" + ] + }, + "spinLevel": { + "raw": "A207", + "default": "low", + "options": ["rinseHold", "noSpin", "low"] + }, + "rinseCycle": { + "raw": "923F", + "default": "2", + "options": ["0", "1", "2", "3", "4", "5"] + }, + "waterTemperature": { + "raw": "830E", + "default": "warm", + "options": ["tapCold", "cold", "warm"] + } + } + }, + { + "cycle": "75", + "cycleType": "washingOnly", + "supportedOptions": { + "soilLevel": { + "raw": "C33E", + "default": "normal", + "options": [ + "extraLight", + "light", + "normal", + "heavy", + "extraHeavy" + ] + }, + "spinLevel": { + "raw": "A30F", + "default": "medium", + "options": ["rinseHold", "noSpin", "low", "medium"] + }, + "rinseCycle": { + "raw": "920F", + "default": "2", + "options": ["0", "1", "2", "3"] + }, + "waterTemperature": { + "raw": "810E", + "default": "tapCold", + "options": ["tapCold", "cold", "warm"] + } + } + }, + { + "cycle": "78", + "cycleType": "washingOnly", + "supportedOptions": { + "soilLevel": { + "raw": "C13E", + "default": "extraLight", + "options": [ + "extraLight", + "light", + "normal", + "heavy", + "extraHeavy" + ] + }, + "spinLevel": { + "raw": "A53F", + "default": "extraHigh", + "options": [ + "rinseHold", + "noSpin", + "low", + "medium", + "high", + "extraHigh" + ] + }, + "rinseCycle": { + "raw": "913F", + "default": "1", + "options": ["0", "1", "2", "3", "4", "5"] + }, + "waterTemperature": { + "raw": "831E", + "default": "warm", + "options": ["tapCold", "cold", "warm", "hot"] + } + } + } + ], + "timestamp": "2024-12-25T22:13:27.760Z" + }, + "washerCycle": { + "value": "Table_00_Course_01", + "timestamp": "2025-02-07T02:29:55.623Z" + }, + "referenceTable": { + "value": { + "id": "Table_00" + }, + "timestamp": "2021-06-01T22:52:20.068Z" + }, + "specializedFunctionClassification": { + "value": 4, + "timestamp": "2025-02-07T02:29:55.152Z" + } + }, + "samsungce.waterConsumptionReport": { + "waterConsumption": { + "value": null + } + }, + "ocf": { + "st": { + "value": null + }, + "mndt": { + "value": null + }, + "mnfv": { + "value": "DA_WM_TP2_20_COMMON_30230804", + "timestamp": "2024-12-25T22:13:27.131Z" + }, + "mnhw": { + "value": "MediaTek", + "timestamp": "2024-12-25T22:13:27.131Z" + }, + "di": { + "value": "f984b91d-f250-9d42-3436-33f09a422a47", + "timestamp": "2024-12-25T22:13:27.131Z" + }, + "mnsl": { + "value": "http://www.samsung.com", + "timestamp": "2024-12-25T22:13:27.131Z" + }, + "dmv": { + "value": "res.1.1.0,sh.1.1.0", + "timestamp": "2024-12-25T22:13:27.131Z" + }, + "n": { + "value": "[washer] Samsung", + "timestamp": "2024-12-25T22:13:27.131Z" + }, + "mnmo": { + "value": "DA_WM_TP2_20_COMMON|20233741|2001000100131100022B010000000000", + "timestamp": "2024-12-25T22:13:27.131Z" + }, + "vid": { + "value": "DA-WM-WM-000001", + "timestamp": "2024-12-25T22:13:27.131Z" + }, + "mnmn": { + "value": "Samsung Electronics", + "timestamp": "2024-12-25T22:13:27.131Z" + }, + "mnml": { + "value": "http://www.samsung.com", + "timestamp": "2024-12-25T22:13:27.131Z" + }, + "mnpv": { + "value": "DAWIT 2.0", + "timestamp": "2024-12-25T22:13:27.131Z" + }, + "mnos": { + "value": "TizenRT 2.0 + IPv6", + "timestamp": "2024-12-25T22:13:27.131Z" + }, + "pi": { + "value": "f984b91d-f250-9d42-3436-33f09a422a47", + "timestamp": "2024-12-25T22:13:27.131Z" + }, + "icv": { + "value": "core.1.1.0", + "timestamp": "2024-12-25T22:13:27.131Z" + } + }, + "custom.dryerDryLevel": { + "dryerDryLevel": { + "value": null + }, + "supportedDryerDryLevel": { + "value": null + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": [ + "samsungce.autoDispenseDetergent", + "samsungce.autoDispenseSoftener", + "samsungce.waterConsumptionReport", + "samsungce.washerCyclePreset", + "samsungce.welcomeMessage", + "samsungce.dongleSoftwareInstallation", + "sec.wifiConfiguration", + "samsungce.quickControl", + "samsungce.deviceInfoPrivate", + "samsungce.energyPlanner", + "demandResponseLoadControl", + "samsungce.softenerAutoReplenishment", + "samsungce.softenerOrder", + "samsungce.softenerState", + "samsungce.washerBubbleSoak", + "samsungce.washerFreezePrevent", + "custom.dryerDryLevel", + "samsungce.washerWaterLevel", + "samsungce.washerWaterValve", + "samsungce.washerWashingTime", + "custom.washerAutoDetergent", + "custom.washerAutoSoftener" + ], + "timestamp": "2024-07-01T16:13:35.173Z" + } + }, + "custom.washerRinseCycles": { + "supportedWasherRinseCycles": { + "value": ["0", "1", "2", "3", "4", "5"], + "timestamp": "2024-12-25T22:13:27.760Z" + }, + "washerRinseCycles": { + "value": "2", + "timestamp": "2025-02-07T02:29:55.691Z" + } + }, + "samsungce.driverVersion": { + "versionNumber": { + "value": 24110101, + "timestamp": "2024-12-03T02:14:52.963Z" + } + }, + "sec.diagnosticsInformation": { + "logType": { + "value": ["errCode", "dump"], + "timestamp": "2025-02-07T02:29:55.453Z" + }, + "endpoint": { + "value": "SSM", + "timestamp": "2025-02-07T02:29:55.453Z" + }, + "minVersion": { + "value": "1.0", + "timestamp": "2025-02-07T02:29:55.453Z" + }, + "signinPermission": { + "value": null + }, + "setupId": { + "value": "210", + "timestamp": "2025-02-07T02:29:55.453Z" + }, + "protocolType": { + "value": "wifi_https", + "timestamp": "2025-02-07T02:29:55.453Z" + }, + "tsId": { + "value": null + }, + "mnId": { + "value": "0AJT", + "timestamp": "2025-02-07T02:29:55.453Z" + }, + "dumpType": { + "value": "file", + "timestamp": "2025-02-07T02:29:55.453Z" + } + }, + "samsungce.washerOperatingState": { + "washerJobState": { + "value": "none", + "timestamp": "2025-02-07T03:09:45.534Z" + }, + "operatingState": { + "value": "ready", + "timestamp": "2025-02-07T03:09:45.534Z" + }, + "supportedOperatingStates": { + "value": ["ready", "running", "paused"], + "timestamp": "2022-11-04T14:21:57.546Z" + }, + "scheduledJobs": { + "value": [ + { + "jobName": "wash", + "timeInMin": 23 + }, + { + "jobName": "rinse", + "timeInMin": 10 + }, + { + "jobName": "spin", + "timeInMin": 9 + } + ], + "timestamp": "2025-02-07T02:30:43.851Z" + }, + "scheduledPhases": { + "value": [ + { + "phaseName": "wash", + "timeInMin": 23 + }, + { + "phaseName": "rinse", + "timeInMin": 10 + }, + { + "phaseName": "spin", + "timeInMin": 9 + } + ], + "timestamp": "2025-02-07T02:30:43.851Z" + }, + "progress": { + "value": 1, + "unit": "%", + "timestamp": "2025-02-07T03:09:45.534Z" + }, + "remainingTimeStr": { + "value": "00:45", + "timestamp": "2025-02-07T03:09:45.534Z" + }, + "washerJobPhase": { + "value": "none", + "timestamp": "2025-02-07T03:09:45.534Z" + }, + "operationTime": { + "value": 45, + "unit": "min", + "timestamp": "2025-02-07T03:09:45.594Z" + }, + "remainingTime": { + "value": 45, + "unit": "min", + "timestamp": "2025-02-07T03:09:45.534Z" + } + }, + "samsungce.kidsLock": { + "lockState": { + "value": "unlocked", + "timestamp": "2025-02-07T02:29:55.407Z" + } + }, + "demandResponseLoadControl": { + "drlcStatus": { + "value": null + } + }, + "samsungce.detergentOrder": { + "alarmEnabled": { + "value": false, + "timestamp": "2025-02-07T02:29:55.152Z" + }, + "orderThreshold": { + "value": 0, + "unit": "cc", + "timestamp": "2025-02-07T02:29:55.152Z" + } + }, + "powerConsumptionReport": { + "powerConsumption": { + "value": { + "energy": 352800, + "deltaEnergy": 0, + "power": 0, + "powerEnergy": 0.0, + "persistedEnergy": 0, + "energySaved": 0, + "start": "2025-02-07T03:09:24Z", + "end": "2025-02-07T03:09:45Z" + }, + "timestamp": "2025-02-07T03:09:45.703Z" + } + }, + "samsungce.detergentAutoReplenishment": { + "neutralDetergentType": { + "value": null + }, + "regularDetergentRemainingAmount": { + "value": 0, + "unit": "cc", + "timestamp": "2025-02-07T02:29:55.152Z" + }, + "babyDetergentRemainingAmount": { + "value": null + }, + "neutralDetergentRemainingAmount": { + "value": null + }, + "neutralDetergentAlarmEnabled": { + "value": null + }, + "neutralDetergentOrderThreshold": { + "value": null + }, + "babyDetergentInitialAmount": { + "value": null + }, + "babyDetergentType": { + "value": null + }, + "neutralDetergentInitialAmount": { + "value": null + }, + "regularDetergentDosage": { + "value": 0, + "unit": "cc", + "timestamp": "2025-02-07T02:29:55.152Z" + }, + "babyDetergentDosage": { + "value": null + }, + "regularDetergentOrderThreshold": { + "value": 0, + "unit": "cc", + "timestamp": "2025-02-07T02:29:55.152Z" + }, + "regularDetergentType": { + "value": "none", + "timestamp": "2025-02-07T02:29:55.152Z" + }, + "regularDetergentInitialAmount": { + "value": 0, + "unit": "cc", + "timestamp": "2025-02-07T02:29:55.152Z" + }, + "regularDetergentAlarmEnabled": { + "value": false, + "timestamp": "2025-02-07T02:29:55.152Z" + }, + "neutralDetergentDosage": { + "value": null + }, + "babyDetergentOrderThreshold": { + "value": null + }, + "babyDetergentAlarmEnabled": { + "value": null + } + }, + "samsungce.softenerOrder": { + "alarmEnabled": { + "value": null + }, + "orderThreshold": { + "value": null + } + }, + "custom.washerSoilLevel": { + "supportedWasherSoilLevel": { + "value": [ + "none", + "extraLight", + "light", + "normal", + "heavy", + "extraHeavy" + ], + "timestamp": "2024-12-25T22:13:27.760Z" + }, + "washerSoilLevel": { + "value": "normal", + "timestamp": "2025-02-07T02:29:55.691Z" + } + }, + "samsungce.washerBubbleSoak": { + "status": { + "value": null + } + }, + "samsungce.washerCyclePreset": { + "maxNumberOfPresets": { + "value": 10, + "timestamp": "2025-02-07T02:29:55.805Z" + }, + "presets": { + "value": null + } + }, + "samsungce.detergentState": { + "remainingAmount": { + "value": 0, + "unit": "cc", + "timestamp": "2025-02-07T02:29:55.152Z" + }, + "dosage": { + "value": 0, + "unit": "cc", + "timestamp": "2025-02-07T02:29:55.152Z" + }, + "initialAmount": { + "value": 0, + "unit": "cc", + "timestamp": "2025-02-07T02:29:55.152Z" + }, + "detergentType": { + "value": "none", + "timestamp": "2021-06-01T22:52:19.999Z" + } + }, + "refresh": {}, + "custom.jobBeginningStatus": { + "jobBeginningStatus": { + "value": null + } + }, + "execute": { + "data": { + "value": { + "payload": { + "rt": ["x.com.samsung.da.information"], + "if": ["oic.if.baseline", "oic.if.a"], + "x.com.samsung.da.modelNum": "DA_WM_TP2_20_COMMON|20233741|2001000100131100022B010000000000", + "x.com.samsung.da.description": "DA_WM_TP2_20_COMMON_WF6300R/DC92-02338B_0080", + "x.com.samsung.da.serialNum": "01FW57AR401623N", + "x.com.samsung.da.otnDUID": "U7CNQWBWJM5U4", + "x.com.samsung.da.diagProtocolType": "WIFI_HTTPS", + "x.com.samsung.da.diagLogType": ["errCode", "dump"], + "x.com.samsung.da.diagDumpType": "file", + "x.com.samsung.da.diagEndPoint": "SSM", + "x.com.samsung.da.diagMnid": "0AJT", + "x.com.samsung.da.diagSetupid": "210", + "x.com.samsung.da.diagMinVersion": "1.0", + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "DA_WM_TP2_20_COMMON|20233741|2001000100131100022B010000000000", + "x.com.samsung.da.type": "Software", + "x.com.samsung.da.number": "02674A220725(F541)", + "x.com.samsung.da.newVersionAvailable": "0" + }, + { + "x.com.samsung.da.id": "1", + "x.com.samsung.da.description": "DA_WM_TP2_20_COMMON", + "x.com.samsung.da.type": "Firmware", + "x.com.samsung.da.number": "18112816,20050607", + "x.com.samsung.da.newVersionAvailable": "0" + } + ] + } + }, + "data": { + "href": "/information/vs/0" + }, + "timestamp": "2023-08-06T16:52:15.994Z" + } + }, + "samsungce.softenerState": { + "remainingAmount": { + "value": null + }, + "dosage": { + "value": null + }, + "softenerType": { + "value": null + }, + "initialAmount": { + "value": null + } + }, + "samsungce.energyPlanner": { + "data": { + "value": null + }, + "plan": { + "value": null + } + }, + "sec.wifiConfiguration": { + "autoReconnection": { + "value": null + }, + "minVersion": { + "value": null + }, + "supportedWiFiFreq": { + "value": null + }, + "supportedAuthType": { + "value": null + }, + "protocolType": { + "value": null + } + }, + "remoteControlStatus": { + "remoteControlEnabled": { + "value": "false", + "timestamp": "2025-02-07T02:29:55.634Z" + } + }, + "custom.supportedOptions": { + "course": { + "value": null + }, + "referenceTable": { + "value": { + "id": "Table_00" + }, + "timestamp": "2025-02-07T02:29:55.623Z" + }, + "supportedCourses": { + "value": [ + "01", + "70", + "55", + "71", + "72", + "77", + "E5", + "57", + "73", + "74", + "75", + "78" + ], + "timestamp": "2025-02-07T02:29:55.152Z" + } + }, + "samsungce.washerWashingTime": { + "supportedWashingTimes": { + "value": null + }, + "washingTime": { + "value": null + } + }, + "custom.energyType": { + "energyType": { + "value": "2.0", + "timestamp": "2022-06-15T14:11:34.909Z" + }, + "energySavingSupport": { + "value": false, + "timestamp": "2022-06-15T14:26:38.584Z" + }, + "drMaxDuration": { + "value": 99999999, + "unit": "min", + "timestamp": "2022-06-15T14:11:37.255Z" + }, + "energySavingLevel": { + "value": null + }, + "energySavingInfo": { + "value": null + }, + "supportedEnergySavingLevels": { + "value": null + }, + "energySavingOperation": { + "value": null + }, + "notificationTemplateID": { + "value": null + }, + "energySavingOperationSupport": { + "value": false, + "timestamp": "2022-06-15T14:11:37.255Z" + } + }, + "samsungce.softwareUpdate": { + "targetModule": { + "value": {}, + "timestamp": "2025-02-07T02:29:55.548Z" + }, + "otnDUID": { + "value": "U7CNQWBWJM5U4", + "timestamp": "2025-02-07T02:29:55.453Z" + }, + "lastUpdatedDate": { + "value": null + }, + "availableModules": { + "value": [], + "timestamp": "2024-12-01T23:36:22.798Z" + }, + "newVersionAvailable": { + "value": false, + "timestamp": "2025-02-07T02:29:55.548Z" + }, + "operatingState": { + "value": null + }, + "progress": { + "value": null + } + }, + "custom.washerAutoDetergent": { + "washerAutoDetergent": { + "value": null + } + }, + "custom.washerSpinLevel": { + "washerSpinLevel": { + "value": "high", + "timestamp": "2025-02-07T02:29:55.691Z" + }, + "supportedWasherSpinLevel": { + "value": [ + "rinseHold", + "noSpin", + "low", + "medium", + "high", + "extraHigh" + ], + "timestamp": "2024-12-25T22:13:27.760Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/ecobee_sensor.json b/tests/components/smartthings/fixtures/device_status/ecobee_sensor.json new file mode 100644 index 00000000000..e9d8addfcb3 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/ecobee_sensor.json @@ -0,0 +1,51 @@ +{ + "components": { + "main": { + "presenceSensor": { + "presence": { + "value": "not present", + "timestamp": "2025-02-11T13:58:50.044Z" + } + }, + "healthCheck": { + "checkInterval": { + "value": 60, + "unit": "s", + "data": { + "deviceScheme": "UNTRACKED", + "protocol": "cloud" + }, + "timestamp": "2025-01-16T21:14:07.471Z" + }, + "healthStatus": { + "value": null + }, + "DeviceWatch-Enroll": { + "value": null + }, + "DeviceWatch-DeviceStatus": { + "value": "online", + "data": {}, + "timestamp": "2025-02-11T14:23:22.053Z" + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": 71, + "unit": "F", + "timestamp": "2025-02-11T14:36:16.823Z" + } + }, + "refresh": {}, + "motionSensor": { + "motion": { + "value": "inactive", + "timestamp": "2025-02-11T13:58:50.044Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/ecobee_thermostat.json b/tests/components/smartthings/fixtures/device_status/ecobee_thermostat.json new file mode 100644 index 00000000000..dd4b8717195 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/ecobee_thermostat.json @@ -0,0 +1,98 @@ +{ + "components": { + "main": { + "relativeHumidityMeasurement": { + "humidity": { + "value": 32, + "unit": "%", + "timestamp": "2025-02-11T14:36:17.275Z" + } + }, + "thermostatOperatingState": { + "thermostatOperatingState": { + "value": "heating", + "timestamp": "2025-02-11T13:39:58.286Z" + } + }, + "healthCheck": { + "checkInterval": { + "value": 60, + "unit": "s", + "data": { + "deviceScheme": "UNTRACKED", + "protocol": "cloud" + }, + "timestamp": "2025-01-16T21:14:07.448Z" + }, + "healthStatus": { + "value": null + }, + "DeviceWatch-Enroll": { + "value": null + }, + "DeviceWatch-DeviceStatus": { + "value": "online", + "data": {}, + "timestamp": "2025-02-11T13:39:58.286Z" + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": 71, + "unit": "F", + "timestamp": "2025-02-11T14:23:21.556Z" + } + }, + "thermostatHeatingSetpoint": { + "heatingSetpoint": { + "value": 71, + "unit": "F", + "timestamp": "2025-02-11T13:39:58.286Z" + }, + "heatingSetpointRange": { + "value": null + } + }, + "thermostatFanMode": { + "thermostatFanMode": { + "value": "auto", + "data": { + "supportedThermostatFanModes": ["on", "auto"] + }, + "timestamp": "2025-02-11T13:39:58.286Z" + }, + "supportedThermostatFanModes": { + "value": ["on", "auto"], + "timestamp": "2025-02-11T13:39:58.286Z" + } + }, + "refresh": {}, + "thermostatMode": { + "thermostatMode": { + "value": "heat", + "data": { + "supportedThermostatModes": ["off", "cool", "auxheatonly", "auto"] + }, + "timestamp": "2025-02-11T13:39:58.286Z" + }, + "supportedThermostatModes": { + "value": ["off", "cool", "auxheatonly", "auto"], + "timestamp": "2025-02-11T13:39:58.286Z" + } + }, + "thermostatCoolingSetpoint": { + "coolingSetpointRange": { + "value": null + }, + "coolingSetpoint": { + "value": 73, + "unit": "F", + "timestamp": "2025-02-11T13:39:58.286Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/fake_fan.json b/tests/components/smartthings/fixtures/device_status/fake_fan.json new file mode 100644 index 00000000000..91efb69cee6 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/fake_fan.json @@ -0,0 +1,31 @@ +{ + "components": { + "main": { + "switch": { + "switch": { + "value": "off", + "timestamp": "2025-02-08T23:21:22.908Z" + } + }, + "fanSpeed": { + "fanSpeed": { + "value": 60, + "timestamp": "2025-02-10T21:09:08.357Z" + } + }, + "airConditionerFanMode": { + "fanMode": { + "value": null, + "timestamp": "2021-04-06T16:44:10.381Z" + }, + "supportedAcFanModes": { + "value": ["auto", "low", "medium", "high", "turbo"], + "timestamp": "2024-09-10T10:26:28.605Z" + }, + "availableAcFanModes": { + "value": null + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/ge_in_wall_smart_dimmer.json b/tests/components/smartthings/fixtures/device_status/ge_in_wall_smart_dimmer.json new file mode 100644 index 00000000000..bff74f135be --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/ge_in_wall_smart_dimmer.json @@ -0,0 +1,23 @@ +{ + "components": { + "main": { + "switchLevel": { + "levelRange": { + "value": null + }, + "level": { + "value": 39, + "unit": "%", + "timestamp": "2025-02-07T02:39:25.819Z" + } + }, + "refresh": {}, + "switch": { + "switch": { + "value": "off", + "timestamp": "2025-02-08T23:21:22.908Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/hue_color_temperature_bulb.json b/tests/components/smartthings/fixtures/device_status/hue_color_temperature_bulb.json new file mode 100644 index 00000000000..6bdf7ceb2dd --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/hue_color_temperature_bulb.json @@ -0,0 +1,75 @@ +{ + "components": { + "main": { + "healthCheck": { + "checkInterval": { + "value": 60, + "unit": "s", + "data": { + "deviceScheme": "UNTRACKED", + "protocol": "cloud" + }, + "timestamp": "2023-12-17T18:11:41.671Z" + }, + "healthStatus": { + "value": null + }, + "DeviceWatch-Enroll": { + "value": null + }, + "DeviceWatch-DeviceStatus": { + "value": "online", + "data": {}, + "timestamp": "2025-02-07T15:14:53.823Z" + } + }, + "switchLevel": { + "levelRange": { + "value": { + "minimum": 1, + "maximum": 100 + }, + "unit": "%", + "timestamp": "2025-02-07T15:14:53.823Z" + }, + "level": { + "value": 70, + "unit": "%", + "timestamp": "2025-02-07T21:56:04.127Z" + } + }, + "refresh": {}, + "synthetic.lightingEffectFade": { + "fade": { + "value": null + } + }, + "colorTemperature": { + "colorTemperatureRange": { + "value": { + "minimum": 2000, + "maximum": 6535 + }, + "unit": "K", + "timestamp": "2025-02-07T15:14:53.823Z" + }, + "colorTemperature": { + "value": 3000, + "unit": "K", + "timestamp": "2025-02-07T21:56:04.127Z" + } + }, + "switch": { + "switch": { + "value": "on", + "timestamp": "2025-02-07T21:56:04.127Z" + } + }, + "synthetic.lightingEffectCircadian": { + "circadian": { + "value": null + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/hue_rgbw_color_bulb.json b/tests/components/smartthings/fixtures/device_status/hue_rgbw_color_bulb.json new file mode 100644 index 00000000000..5868472267c --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/hue_rgbw_color_bulb.json @@ -0,0 +1,94 @@ +{ + "components": { + "main": { + "colorControl": { + "saturation": { + "value": 60, + "timestamp": "2025-02-07T15:14:53.812Z" + }, + "color": { + "value": null + }, + "hue": { + "value": 60.8072, + "timestamp": "2025-02-07T15:14:53.812Z" + } + }, + "healthCheck": { + "checkInterval": { + "value": 60, + "unit": "s", + "data": { + "deviceScheme": "UNTRACKED", + "protocol": "cloud" + }, + "timestamp": "2023-12-17T18:11:41.678Z" + }, + "healthStatus": { + "value": null + }, + "DeviceWatch-Enroll": { + "value": null + }, + "DeviceWatch-DeviceStatus": { + "value": "online", + "data": {}, + "timestamp": "2025-02-07T15:14:53.812Z" + } + }, + "switchLevel": { + "levelRange": { + "value": { + "minimum": 1, + "maximum": 100 + }, + "unit": "%", + "timestamp": "2025-02-07T15:14:53.812Z" + }, + "level": { + "value": 70, + "unit": "%", + "timestamp": "2025-02-07T21:56:02.381Z" + } + }, + "refresh": {}, + "synthetic.lightingEffectFade": { + "fade": { + "value": null + } + }, + "samsungim.hueSyncMode": { + "mode": { + "value": "normal", + "timestamp": "2025-02-07T15:14:53.812Z" + } + }, + "switch": { + "switch": { + "value": "off", + "timestamp": "2025-02-08T07:08:19.519Z" + } + }, + "colorTemperature": { + "colorTemperatureRange": { + "value": { + "minimum": 2000, + "maximum": 6535 + }, + "unit": "K", + "timestamp": "2025-02-06T15:14:52.807Z" + }, + "colorTemperature": { + "value": 3000, + "unit": "K", + "timestamp": "2025-02-07T21:56:02.381Z" + } + }, + "synthetic.lightingEffectCircadian": { + "circadian": { + "value": null + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/iphone.json b/tests/components/smartthings/fixtures/device_status/iphone.json new file mode 100644 index 00000000000..618ce440ff0 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/iphone.json @@ -0,0 +1,12 @@ +{ + "components": { + "main": { + "presenceSensor": { + "presence": { + "value": "present", + "timestamp": "2023-09-22T18:12:25.012Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/multipurpose_sensor.json b/tests/components/smartthings/fixtures/device_status/multipurpose_sensor.json new file mode 100644 index 00000000000..e0b37de7e3c --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/multipurpose_sensor.json @@ -0,0 +1,79 @@ +{ + "components": { + "main": { + "contactSensor": { + "contact": { + "value": "closed", + "timestamp": "2025-02-08T14:00:28.332Z" + } + }, + "threeAxis": { + "threeAxis": { + "value": [20, 8, -1042], + "unit": "mG", + "timestamp": "2025-02-09T17:27:36.673Z" + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": 67.0, + "unit": "F", + "timestamp": "2025-02-09T17:56:19.744Z" + } + }, + "refresh": {}, + "battery": { + "quantity": { + "value": null + }, + "battery": { + "value": 50, + "unit": "%", + "timestamp": "2025-02-09T12:24:02.074Z" + }, + "type": { + "value": null + } + }, + "firmwareUpdate": { + "lastUpdateStatusReason": { + "value": null + }, + "availableVersion": { + "value": "0000001B", + "timestamp": "2025-02-09T04:20:25.600Z" + }, + "lastUpdateStatus": { + "value": null + }, + "supportedCommands": { + "value": null + }, + "state": { + "value": "normalOperation", + "timestamp": "2025-02-09T04:20:25.600Z" + }, + "updateAvailable": { + "value": false, + "timestamp": "2025-02-09T04:20:25.601Z" + }, + "currentVersion": { + "value": "0000001B", + "timestamp": "2025-02-09T04:20:25.593Z" + }, + "lastUpdateTime": { + "value": null + } + }, + "accelerationSensor": { + "acceleration": { + "value": "inactive", + "timestamp": "2025-02-09T17:27:46.812Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/sensibo_airconditioner_1.json b/tests/components/smartthings/fixtures/device_status/sensibo_airconditioner_1.json new file mode 100644 index 00000000000..b4263e7eb87 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/sensibo_airconditioner_1.json @@ -0,0 +1,57 @@ +{ + "components": { + "main": { + "healthCheck": { + "checkInterval": { + "value": 60, + "unit": "s", + "data": { + "deviceScheme": "UNTRACKED", + "protocol": "cloud" + }, + "timestamp": "2024-12-04T10:10:02.934Z" + }, + "healthStatus": { + "value": null + }, + "DeviceWatch-Enroll": { + "value": null + }, + "DeviceWatch-DeviceStatus": { + "value": "online", + "data": {}, + "timestamp": "2025-02-09T10:09:47.758Z" + } + }, + "refresh": {}, + "airConditionerMode": { + "availableAcModes": { + "value": null + }, + "supportedAcModes": { + "value": null + }, + "airConditionerMode": { + "value": "cool", + "timestamp": "2025-02-09T10:09:47.758Z" + } + }, + "thermostatCoolingSetpoint": { + "coolingSetpointRange": { + "value": null + }, + "coolingSetpoint": { + "value": 20, + "unit": "C", + "timestamp": "2025-02-09T10:09:47.758Z" + } + }, + "switch": { + "switch": { + "value": "off", + "timestamp": "2025-02-09T10:09:47.758Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/smart_plug.json b/tests/components/smartthings/fixtures/device_status/smart_plug.json new file mode 100644 index 00000000000..f4f591483c6 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/smart_plug.json @@ -0,0 +1,43 @@ +{ + "components": { + "main": { + "refresh": {}, + "firmwareUpdate": { + "lastUpdateStatusReason": { + "value": null + }, + "availableVersion": { + "value": "00102101", + "timestamp": "2025-02-08T19:37:03.624Z" + }, + "lastUpdateStatus": { + "value": null + }, + "supportedCommands": { + "value": null + }, + "state": { + "value": "normalOperation", + "timestamp": "2025-02-08T19:37:03.622Z" + }, + "updateAvailable": { + "value": false, + "timestamp": "2025-02-08T19:37:03.624Z" + }, + "currentVersion": { + "value": "00102101", + "timestamp": "2025-02-08T19:37:03.594Z" + }, + "lastUpdateTime": { + "value": null + } + }, + "switch": { + "switch": { + "value": "on", + "timestamp": "2025-02-09T17:31:12.210Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/sonos_player.json b/tests/components/smartthings/fixtures/device_status/sonos_player.json new file mode 100644 index 00000000000..057b6c62d0d --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/sonos_player.json @@ -0,0 +1,259 @@ +{ + "components": { + "main": { + "mediaPlayback": { + "supportedPlaybackCommands": { + "value": ["play", "pause", "stop"], + "timestamp": "2025-02-02T13:18:40.078Z" + }, + "playbackStatus": { + "value": "playing", + "timestamp": "2025-02-09T19:53:58.330Z" + } + }, + "mediaPresets": { + "presets": { + "value": [ + { + "id": "10", + "imageUrl": "https://www.storytel.com//images/320x320/0000059036.jpg", + "mediaSource": "Storytel", + "name": "Dra \u00e5t skogen Sune!" + }, + { + "id": "22", + "imageUrl": "https://www.storytel.com//images/320x320/0000001894.jpg", + "mediaSource": "Storytel", + "name": "Fy katten Sune" + }, + { + "id": "29", + "imageUrl": "https://www.storytel.com//images/320x320/0000001896.jpg", + "mediaSource": "Storytel", + "name": "Gult \u00e4r fult, Sune" + }, + { + "id": "2", + "imageUrl": "https://static.mytuner.mobi/media/tvos_radios/2l5zg6lhjbab.png", + "mediaSource": "myTuner Radio", + "name": "Kiss" + }, + { + "id": "3", + "imageUrl": "https://www.storytel.com//images/320x320/0000046017.jpg", + "mediaSource": "Storytel", + "name": "L\u00e4skigt Sune!" + }, + { + "id": "16", + "imageUrl": "https://www.storytel.com//images/320x320/0002590598.jpg", + "mediaSource": "Storytel", + "name": "Pluggh\u00e4sten Sune" + }, + { + "id": "14", + "imageUrl": "https://www.storytel.com//images/320x320/0000000070.jpg", + "mediaSource": "Storytel", + "name": "Sagan om Sune" + }, + { + "id": "18", + "imageUrl": "https://www.storytel.com//images/320x320/0000006452.jpg", + "mediaSource": "Storytel", + "name": "Sk\u00e4mtaren Sune" + }, + { + "id": "26", + "imageUrl": "https://www.storytel.com//images/320x320/0000001892.jpg", + "mediaSource": "Storytel", + "name": "Spik och panik, Sune!" + }, + { + "id": "7", + "imageUrl": "https://www.storytel.com//images/320x320/0003119145.jpg", + "mediaSource": "Storytel", + "name": "Sune - T\u00e5gsemestern" + }, + { + "id": "25", + "imageUrl": "https://www.storytel.com//images/320x320/0000000071.jpg", + "mediaSource": "Storytel", + "name": "Sune b\u00f6rjar tv\u00e5an" + }, + { + "id": "9", + "imageUrl": "https://www.storytel.com//images/320x320/0000006448.jpg", + "mediaSource": "Storytel", + "name": "Sune i Grekland" + }, + { + "id": "8", + "imageUrl": "https://www.storytel.com//images/320x320/0002492498.jpg", + "mediaSource": "Storytel", + "name": "Sune i Ullared" + }, + { + "id": "30", + "imageUrl": "https://www.storytel.com//images/320x320/0002072946.jpg", + "mediaSource": "Storytel", + "name": "Sune och familjen Anderssons sjuka jul" + }, + { + "id": "17", + "imageUrl": "https://www.storytel.com//images/320x320/0000000475.jpg", + "mediaSource": "Storytel", + "name": "Sune och klantpappan" + }, + { + "id": "11", + "imageUrl": "https://www.storytel.com//images/320x320/0000042688.jpg", + "mediaSource": "Storytel", + "name": "Sune och Mamma Mysko" + }, + { + "id": "20", + "imageUrl": "https://www.storytel.com//images/320x320/0000000072.jpg", + "mediaSource": "Storytel", + "name": "Sune och syster vampyr" + }, + { + "id": "15", + "imageUrl": "https://www.storytel.com//images/320x320/0000039918.jpg", + "mediaSource": "Storytel", + "name": "Sune slutar f\u00f6rsta klass" + }, + { + "id": "5", + "imageUrl": "https://www.storytel.com//images/320x320/0000017431.jpg", + "mediaSource": "Storytel", + "name": "Sune v\u00e4rsta killen!" + }, + { + "id": "27", + "imageUrl": "https://www.storytel.com//images/320x320/0000068900.jpg", + "mediaSource": "Storytel", + "name": "Sunes halloween" + }, + { + "id": "19", + "imageUrl": "https://www.storytel.com//images/320x320/0000000476.jpg", + "mediaSource": "Storytel", + "name": "Sunes hemligheter" + }, + { + "id": "21", + "imageUrl": "https://www.storytel.com//images/320x320/0002370989.jpg", + "mediaSource": "Storytel", + "name": "Sunes hj\u00e4rnsl\u00e4pp" + }, + { + "id": "24", + "imageUrl": "https://www.storytel.com//images/320x320/0000001889.jpg", + "mediaSource": "Storytel", + "name": "Sunes jul" + }, + { + "id": "28", + "imageUrl": "https://www.storytel.com//images/320x320/0000034437.jpg", + "mediaSource": "Storytel", + "name": "Sunes party" + }, + { + "id": "4", + "imageUrl": "https://www.storytel.com//images/320x320/0000006450.jpg", + "mediaSource": "Storytel", + "name": "Sunes skolresa" + }, + { + "id": "13", + "imageUrl": "https://www.storytel.com//images/320x320/0000000477.jpg", + "mediaSource": "Storytel", + "name": "Sunes sommar" + }, + { + "id": "12", + "imageUrl": "https://www.storytel.com//images/320x320/0000046015.jpg", + "mediaSource": "Storytel", + "name": "Sunes Sommarstuga" + }, + { + "id": "6", + "imageUrl": "https://www.storytel.com//images/320x320/0002099327.jpg", + "mediaSource": "Storytel", + "name": "Supersnuten Sune" + }, + { + "id": "23", + "imageUrl": "https://www.storytel.com//images/320x320/0000563738.jpg", + "mediaSource": "Storytel", + "name": "Zunes stolpskott" + } + ], + "timestamp": "2025-02-02T13:18:48.272Z" + } + }, + "audioVolume": { + "volume": { + "value": 15, + "unit": "%", + "timestamp": "2025-02-09T19:57:37.230Z" + } + }, + "mediaGroup": { + "groupMute": { + "value": "unmuted", + "timestamp": "2025-02-07T01:19:54.911Z" + }, + "groupPrimaryDeviceId": { + "value": "RINCON_38420B9108F601400", + "timestamp": "2025-02-09T19:52:24.000Z" + }, + "groupId": { + "value": "RINCON_38420B9108F601400:3579458382", + "timestamp": "2025-02-09T19:54:06.936Z" + }, + "groupVolume": { + "value": 12, + "unit": "%", + "timestamp": "2025-02-07T01:19:54.911Z" + }, + "groupRole": { + "value": "ungrouped", + "timestamp": "2025-02-09T19:52:23.974Z" + } + }, + "refresh": {}, + "mediaTrackControl": { + "supportedTrackControlCommands": { + "value": ["nextTrack", "previousTrack"], + "timestamp": "2025-02-02T13:18:40.123Z" + } + }, + "audioMute": { + "mute": { + "value": "unmuted", + "timestamp": "2025-02-09T19:57:35.487Z" + } + }, + "audioNotification": {}, + "audioTrackData": { + "totalTime": { + "value": null + }, + "audioTrackData": { + "value": { + "album": "Forever Young", + "albumArtUrl": "http://192.168.1.123:1400/getaa?s=1&u=x-sonos-spotify%3aspotify%253atrack%253a3bg2qahpZmsg5wV2EMPXIk%3fsid%3d9%26flags%3d8232%26sn%3d9", + "artist": "David Guetta", + "mediaSource": "Spotify", + "title": "Forever Young" + }, + "timestamp": "2025-02-09T19:53:55.615Z" + }, + "elapsedTime": { + "value": null + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/vd_network_audio_002s.json b/tests/components/smartthings/fixtures/device_status/vd_network_audio_002s.json new file mode 100644 index 00000000000..a0bcbd742f4 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/vd_network_audio_002s.json @@ -0,0 +1,164 @@ +{ + "components": { + "main": { + "mediaPlayback": { + "supportedPlaybackCommands": { + "value": ["play", "pause", "stop"], + "timestamp": "2025-02-09T15:42:12.923Z" + }, + "playbackStatus": { + "value": "stopped", + "timestamp": "2025-02-09T15:42:12.923Z" + } + }, + "samsungvd.soundFrom": { + "mode": { + "value": 3, + "timestamp": "2025-02-09T15:42:13.215Z" + }, + "detailName": { + "value": "External Device", + "timestamp": "2025-02-09T15:42:13.215Z" + } + }, + "audioVolume": { + "volume": { + "value": 17, + "unit": "%", + "timestamp": "2025-02-09T17:25:51.839Z" + } + }, + "samsungvd.audioGroupInfo": { + "role": { + "value": null + }, + "status": { + "value": null + } + }, + "refresh": {}, + "audioNotification": {}, + "execute": { + "data": { + "value": null + } + }, + "samsungvd.audioInputSource": { + "supportedInputSources": { + "value": ["digital", "HDMI1", "bluetooth", "wifi", "HDMI2"], + "timestamp": "2025-02-09T17:18:44.680Z" + }, + "inputSource": { + "value": "HDMI1", + "timestamp": "2025-02-09T17:18:44.680Z" + } + }, + "switch": { + "switch": { + "value": "on", + "timestamp": "2025-02-09T17:25:51.536Z" + } + }, + "ocf": { + "st": { + "value": "2024-12-10T02:12:44Z", + "timestamp": "2024-12-31T01:03:42.587Z" + }, + "mndt": { + "value": "2023-01-01", + "timestamp": "2024-12-31T01:03:42.587Z" + }, + "mnfv": { + "value": "SAT-iMX8M23WWC-1010.5", + "timestamp": "2024-12-31T01:03:42.587Z" + }, + "mnhw": { + "value": "", + "timestamp": "2024-12-31T01:03:42.587Z" + }, + "di": { + "value": "0d94e5db-8501-2355-eb4f-214163702cac", + "timestamp": "2024-12-31T01:03:42.587Z" + }, + "mnsl": { + "value": "", + "timestamp": "2024-12-31T01:03:42.587Z" + }, + "dmv": { + "value": "res.1.1.0,sh.1.1.0", + "timestamp": "2024-12-31T01:03:42.587Z" + }, + "n": { + "value": "Soundbar Living", + "timestamp": "2024-12-31T01:03:42.587Z" + }, + "mnmo": { + "value": "HW-Q990C", + "timestamp": "2024-12-31T01:03:42.587Z" + }, + "vid": { + "value": "VD-NetworkAudio-002S", + "timestamp": "2024-12-31T01:03:42.587Z" + }, + "mnmn": { + "value": "Samsung Electronics", + "timestamp": "2024-12-31T01:03:42.587Z" + }, + "mnml": { + "value": "", + "timestamp": "2024-12-31T01:03:42.587Z" + }, + "mnpv": { + "value": "7.0", + "timestamp": "2024-12-31T01:03:42.587Z" + }, + "mnos": { + "value": "Tizen", + "timestamp": "2024-12-31T01:03:42.587Z" + }, + "pi": { + "value": "0d94e5db-8501-2355-eb4f-214163702cac", + "timestamp": "2024-12-31T01:03:42.587Z" + }, + "icv": { + "value": "core.1.1.0", + "timestamp": "2024-12-31T01:03:42.587Z" + } + }, + "audioMute": { + "mute": { + "value": "unmuted", + "timestamp": "2025-02-09T17:18:44.787Z" + } + }, + "samsungvd.thingStatus": { + "updatedTime": { + "value": 1739115734, + "timestamp": "2025-02-09T15:42:13.949Z" + }, + "status": { + "value": "Idle", + "timestamp": "2025-02-09T15:42:13.949Z" + } + }, + "audioTrackData": { + "totalTime": { + "value": 0, + "timestamp": "2024-12-31T00:29:29.953Z" + }, + "audioTrackData": { + "value": { + "title": "", + "artist": "", + "album": "" + }, + "timestamp": "2024-12-31T00:29:29.953Z" + }, + "elapsedTime": { + "value": 0, + "timestamp": "2024-12-31T00:29:29.828Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/vd_stv_2017_k.json b/tests/components/smartthings/fixtures/device_status/vd_stv_2017_k.json new file mode 100644 index 00000000000..18496942e2f --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/vd_stv_2017_k.json @@ -0,0 +1,266 @@ +{ + "components": { + "main": { + "mediaPlayback": { + "supportedPlaybackCommands": { + "value": ["play", "pause", "stop", "fastForward", "rewind"], + "timestamp": "2020-05-07T02:58:10.250Z" + }, + "playbackStatus": { + "value": null, + "timestamp": "2020-08-04T21:53:22.108Z" + } + }, + "audioVolume": { + "volume": { + "value": 13, + "unit": "%", + "timestamp": "2021-08-21T19:19:52.832Z" + } + }, + "samsungvd.supportsPowerOnByOcf": { + "supportsPowerOnByOcf": { + "value": null, + "timestamp": "2020-10-29T10:47:20.305Z" + } + }, + "samsungvd.mediaInputSource": { + "supportedInputSourcesMap": { + "value": [ + { + "id": "dtv", + "name": "TV" + }, + { + "id": "HDMI1", + "name": "PlayStation 4" + }, + { + "id": "HDMI4", + "name": "HT-CT370" + }, + { + "id": "HDMI4", + "name": "HT-CT370" + } + ], + "timestamp": "2021-10-16T15:18:11.622Z" + }, + "inputSource": { + "value": "HDMI1", + "timestamp": "2021-08-28T16:29:59.716Z" + } + }, + "mediaInputSource": { + "supportedInputSources": { + "value": ["digitalTv", "HDMI1", "HDMI4", "HDMI4"], + "timestamp": "2021-10-16T15:18:11.622Z" + }, + "inputSource": { + "value": "HDMI1", + "timestamp": "2021-08-28T16:29:59.716Z" + } + }, + "custom.tvsearch": {}, + "samsungvd.ambient": {}, + "refresh": {}, + "custom.error": { + "error": { + "value": null, + "timestamp": "2020-08-04T21:53:22.148Z" + } + }, + "execute": { + "data": { + "value": { + "payload": { + "rt": ["x.com.samsung.tv.deviceinfo"], + "if": ["oic.if.baseline", "oic.if.r"], + "x.com.samsung.country": "USA", + "x.com.samsung.infolinkversion": "T-INFOLINK2017-1008", + "x.com.samsung.modelid": "17_KANTM_UHD", + "x.com.samsung.tv.blemac": "CC:6E:A4:1F:4C:F7", + "x.com.samsung.tv.btmac": "CC:6E:A4:1F:4C:F7", + "x.com.samsung.tv.category": "tv", + "x.com.samsung.tv.countrycode": "US", + "x.com.samsung.tv.duid": "B2NBQRAG357IX", + "x.com.samsung.tv.ethmac": "c0:48:e6:e7:fc:2c", + "x.com.samsung.tv.p2pmac": "ce:6e:a4:1f:4c:f6", + "x.com.samsung.tv.udn": "717fb7ed-b310-4cfe-8954-1cd8211dd689", + "x.com.samsung.tv.wifimac": "cc:6e:a4:1f:4c:f6" + } + }, + "data": { + "href": "/sec/tv/deviceinfo" + }, + "timestamp": "2021-08-30T19:18:12.303Z" + } + }, + "switch": { + "switch": { + "value": "on", + "timestamp": "2021-10-16T15:18:11.317Z" + } + }, + "tvChannel": { + "tvChannel": { + "value": "", + "timestamp": "2020-05-07T02:58:10.479Z" + }, + "tvChannelName": { + "value": "", + "timestamp": "2021-08-21T18:53:06.643Z" + } + }, + "ocf": { + "st": { + "value": "2021-08-21T14:50:34Z", + "timestamp": "2021-08-21T19:19:51.890Z" + }, + "mndt": { + "value": "2017-01-01", + "timestamp": "2021-08-21T19:19:51.890Z" + }, + "mnfv": { + "value": "T-KTMAKUC-1290.3", + "timestamp": "2021-08-21T18:52:57.543Z" + }, + "mnhw": { + "value": "0-0", + "timestamp": "2020-05-07T02:58:10.206Z" + }, + "di": { + "value": "4588d2d9-a8cf-40f4-9a0b-ed5dfbaccda1", + "timestamp": "2020-05-07T02:58:10.206Z" + }, + "mnsl": { + "value": "http://www.samsung.com/sec/tv/overview/", + "timestamp": "2021-08-21T19:19:51.890Z" + }, + "dmv": { + "value": "res.1.1.0,sh.1.1.0", + "timestamp": "2021-08-21T18:52:58.071Z" + }, + "n": { + "value": "[TV] Samsung 8 Series (49)", + "timestamp": "2020-05-07T02:58:10.206Z" + }, + "mnmo": { + "value": "UN49MU8000", + "timestamp": "2020-05-07T02:58:10.206Z" + }, + "vid": { + "value": "VD-STV_2017_K", + "timestamp": "2020-05-07T02:58:10.206Z" + }, + "mnmn": { + "value": "Samsung Electronics", + "timestamp": "2020-05-07T02:58:10.206Z" + }, + "mnml": { + "value": "http://www.samsung.com", + "timestamp": "2020-05-07T02:58:10.206Z" + }, + "mnpv": { + "value": "Tizen 3.0", + "timestamp": "2020-05-07T02:58:10.206Z" + }, + "mnos": { + "value": "4.1.10", + "timestamp": "2020-05-07T02:58:10.206Z" + }, + "pi": { + "value": "4588d2d9-a8cf-40f4-9a0b-ed5dfbaccda1", + "timestamp": "2020-05-07T02:58:10.206Z" + }, + "icv": { + "value": "core.1.1.0", + "timestamp": "2021-08-21T18:52:58.071Z" + } + }, + "custom.picturemode": { + "pictureMode": { + "value": "Dynamic", + "timestamp": "2020-12-23T01:33:37.069Z" + }, + "supportedPictureModes": { + "value": ["Dynamic", "Standard", "Natural", "Movie"], + "timestamp": "2020-05-07T02:58:10.585Z" + }, + "supportedPictureModesMap": { + "value": [ + { + "id": "modeDynamic", + "name": "Dynamic" + }, + { + "id": "modeStandard", + "name": "Standard" + }, + { + "id": "modeNatural", + "name": "Natural" + }, + { + "id": "modeMovie", + "name": "Movie" + } + ], + "timestamp": "2020-12-23T01:33:37.069Z" + } + }, + "samsungvd.ambientContent": { + "supportedAmbientApps": { + "value": [], + "timestamp": "2021-01-17T01:10:11.985Z" + } + }, + "custom.accessibility": {}, + "custom.recording": {}, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": ["samsungvd.ambient", "samsungvd.ambientContent"], + "timestamp": "2021-01-17T01:10:11.985Z" + } + }, + "custom.soundmode": { + "supportedSoundModesMap": { + "value": [ + { + "id": "modeStandard", + "name": "Standard" + } + ], + "timestamp": "2021-08-21T19:19:52.887Z" + }, + "soundMode": { + "value": "Standard", + "timestamp": "2020-12-23T01:33:37.272Z" + }, + "supportedSoundModes": { + "value": ["Standard"], + "timestamp": "2021-08-21T19:19:52.887Z" + } + }, + "audioMute": { + "mute": { + "value": "muted", + "timestamp": "2021-08-21T19:19:52.832Z" + } + }, + "mediaTrackControl": { + "supportedTrackControlCommands": { + "value": null, + "timestamp": "2020-08-04T21:53:22.384Z" + } + }, + "custom.launchapp": {}, + "samsungvd.firmwareVersion": { + "firmwareVersion": { + "value": null, + "timestamp": "2020-10-29T10:47:19.376Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/virtual_thermostat.json b/tests/components/smartthings/fixtures/device_status/virtual_thermostat.json new file mode 100644 index 00000000000..c2c36fa249e --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/virtual_thermostat.json @@ -0,0 +1,97 @@ +{ + "components": { + "main": { + "thermostatOperatingState": { + "thermostatOperatingState": { + "value": "pending cool", + "timestamp": "2025-02-10T22:04:56.341Z" + } + }, + "thermostatHeatingSetpoint": { + "heatingSetpoint": { + "value": 814.7469111058201, + "unit": "F", + "timestamp": "2025-02-10T22:04:56.341Z" + }, + "heatingSetpointRange": { + "value": { + "maximum": 3226.693210895862, + "step": 9234.459191378826, + "minimum": 6214.940743832475 + }, + "unit": "F", + "timestamp": "2025-02-10T22:04:56.341Z" + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": { + "maximum": 1826.722761785079, + "step": 138.2080712609211, + "minimum": 9268.726934158902 + }, + "unit": "F", + "timestamp": "2025-02-10T22:04:56.341Z" + }, + "temperature": { + "value": 8554.194688973037, + "unit": "F", + "timestamp": "2025-02-10T22:04:56.341Z" + } + }, + "thermostatFanMode": { + "thermostatFanMode": { + "value": "followschedule", + "data": {}, + "timestamp": "2025-02-10T22:04:56.341Z" + }, + "supportedThermostatFanModes": { + "value": ["on"], + "timestamp": "2025-02-10T22:04:56.341Z" + } + }, + "thermostatMode": { + "thermostatMode": { + "value": "auxheatonly", + "data": {}, + "timestamp": "2025-02-10T22:04:56.341Z" + }, + "supportedThermostatModes": { + "value": ["rush hour"], + "timestamp": "2025-02-10T22:04:56.341Z" + } + }, + "battery": { + "quantity": { + "value": 51, + "timestamp": "2025-02-10T22:04:56.341Z" + }, + "battery": { + "value": 100, + "unit": "%", + "timestamp": "2025-02-10T22:04:56.341Z" + }, + "type": { + "value": "38140", + "timestamp": "2025-02-10T22:04:56.341Z" + } + }, + "thermostatCoolingSetpoint": { + "coolingSetpointRange": { + "value": { + "maximum": 7288.145606306409, + "step": 7620.031701049315, + "minimum": 4997.721228739137 + }, + "unit": "F", + "timestamp": "2025-02-10T22:04:56.341Z" + }, + "coolingSetpoint": { + "value": 244.33726326608746, + "unit": "F", + "timestamp": "2025-02-10T22:04:56.341Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/virtual_valve.json b/tests/components/smartthings/fixtures/device_status/virtual_valve.json new file mode 100644 index 00000000000..8cb66c72595 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/virtual_valve.json @@ -0,0 +1,13 @@ +{ + "components": { + "main": { + "refresh": {}, + "valve": { + "valve": { + "value": "closed", + "timestamp": "2025-02-11T11:27:02.262Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/virtual_water_sensor.json b/tests/components/smartthings/fixtures/device_status/virtual_water_sensor.json new file mode 100644 index 00000000000..8200bfe81a1 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/virtual_water_sensor.json @@ -0,0 +1,28 @@ +{ + "components": { + "main": { + "waterSensor": { + "water": { + "value": "dry", + "timestamp": "2025-02-10T21:58:18.784Z" + } + }, + "refresh": {}, + "battery": { + "quantity": { + "value": 84, + "timestamp": "2025-02-10T21:58:18.784Z" + }, + "battery": { + "value": 100, + "unit": "%", + "timestamp": "2025-02-10T21:58:18.784Z" + }, + "type": { + "value": "46120", + "timestamp": "2025-02-10T21:58:18.784Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/yale_push_button_deadbolt_lock.json b/tests/components/smartthings/fixtures/device_status/yale_push_button_deadbolt_lock.json new file mode 100644 index 00000000000..0bb1af96f70 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/yale_push_button_deadbolt_lock.json @@ -0,0 +1,110 @@ +{ + "components": { + "main": { + "lock": { + "supportedUnlockDirections": { + "value": null + }, + "supportedLockValues": { + "value": null + }, + "lock": { + "value": "locked", + "data": {}, + "timestamp": "2025-02-09T17:29:56.641Z" + }, + "supportedLockCommands": { + "value": null + } + }, + "refresh": {}, + "battery": { + "quantity": { + "value": null + }, + "battery": { + "value": 86, + "unit": "%", + "timestamp": "2025-02-09T17:18:14.150Z" + }, + "type": { + "value": null + } + }, + "firmwareUpdate": { + "lastUpdateStatusReason": { + "value": null + }, + "availableVersion": { + "value": "00840847", + "timestamp": "2025-02-09T11:48:45.331Z" + }, + "lastUpdateStatus": { + "value": null + }, + "supportedCommands": { + "value": null + }, + "state": { + "value": "normalOperation", + "timestamp": "2025-02-09T11:48:45.331Z" + }, + "updateAvailable": { + "value": false, + "timestamp": "2025-02-09T11:48:45.332Z" + }, + "currentVersion": { + "value": "00840847", + "timestamp": "2025-02-09T11:48:45.328Z" + }, + "lastUpdateTime": { + "value": null + } + }, + "lockCodes": { + "codeLength": { + "value": null, + "timestamp": "2020-08-04T15:29:24.127Z" + }, + "maxCodes": { + "value": 250, + "timestamp": "2023-08-22T01:34:19.751Z" + }, + "maxCodeLength": { + "value": 8, + "timestamp": "2023-08-22T01:34:18.690Z" + }, + "codeChanged": { + "value": "8 unset", + "data": { + "codeName": "Code 8" + }, + "timestamp": "2025-01-06T04:56:31.712Z" + }, + "lock": { + "value": "locked", + "data": { + "method": "manual" + }, + "timestamp": "2023-07-10T23:03:42.305Z" + }, + "minCodeLength": { + "value": 4, + "timestamp": "2023-08-22T01:34:18.781Z" + }, + "codeReport": { + "value": 5, + "timestamp": "2022-08-01T01:36:58.424Z" + }, + "scanCodes": { + "value": "Complete", + "timestamp": "2025-01-06T04:56:31.730Z" + }, + "lockCodes": { + "value": "{\"1\":\"Salim\",\"2\":\"Saima\",\"3\":\"Sarah\",\"4\":\"Aisha\",\"5\":\"Moiz\"}", + "timestamp": "2025-01-06T04:56:28.325Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/devices/aeotec_home_energy_meter_gen5.json b/tests/components/smartthings/fixtures/devices/aeotec_home_energy_meter_gen5.json new file mode 100644 index 00000000000..5ef0e2fd9eb --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/aeotec_home_energy_meter_gen5.json @@ -0,0 +1,70 @@ +{ + "items": [ + { + "deviceId": "f0af21a2-d5a1-437c-b10a-b34a87394b71", + "name": "aeotec-home-energy-meter-gen5", + "label": "Aeotec Energy Monitor", + "manufacturerName": "SmartThingsCommunity", + "presentationId": "3e0921d3-0a66-3d49-b458-752e596838e9", + "deviceManufacturerCode": "0086-0002-005F", + "locationId": "6911ddf5-f0cb-4516-a06a-3a2a6ec22bca", + "ownerId": "93257fc4-6471-2566-b06e-2fe72dd979fa", + "roomId": "cdf080f0-0542-41d7-a606-aff69683e04c", + "components": [ + { + "id": "main", + "label": "Meter", + "capabilities": [ + { + "id": "powerMeter", + "version": 1 + }, + { + "id": "energyMeter", + "version": 1 + }, + { + "id": "voltageMeasurement", + "version": 1 + }, + { + "id": "powerConsumptionReport", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + } + ], + "categories": [ + { + "name": "CurbPowerMeter", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2023-01-12T23:02:44.917Z", + "parentDeviceId": "6a2d07a4-dd77-48bc-9acf-017029aaf099", + "profile": { + "id": "6372c227-93c7-32ef-9be5-aef2221adff1" + }, + "zwave": { + "networkId": "0A", + "driverId": "b98b34ce-1d1d-480c-bb17-41307a90cde0", + "executingLocally": true, + "hubId": "6a2d07a4-dd77-48bc-9acf-017029aaf099", + "networkSecurityLevel": "ZWAVE_S0_LEGACY", + "provisioningState": "PROVISIONED", + "manufacturerId": 134, + "productType": 2, + "productId": 95 + }, + "type": "ZWAVE", + "restrictionTier": 0, + "allowed": [], + "executionContext": "LOCAL" + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/base_electric_meter.json b/tests/components/smartthings/fixtures/devices/base_electric_meter.json new file mode 100644 index 00000000000..9e0c130978c --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/base_electric_meter.json @@ -0,0 +1,62 @@ +{ + "items": [ + { + "deviceId": "68e786a6-7f61-4c3a-9e13-70b803cf782b", + "name": "base-electric-meter", + "label": "Aeon Energy Monitor", + "manufacturerName": "SmartThingsCommunity", + "presentationId": "8e619cd9-c271-3ba0-9015-62bc074bc47f", + "deviceManufacturerCode": "0086-0002-0009", + "locationId": "c4d3b2a1-09f8-765e-4d3c-2b1a09f8e7d6 ", + "ownerId": "d47f2b19-3a6e-4c8d-bf21-9e8a7c5d134e", + "roomId": "94be4a1e-382a-4b7f-a5ef-fdb1a7d9f9e6", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "powerMeter", + "version": 1 + }, + { + "id": "energyMeter", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + } + ], + "categories": [ + { + "name": "CurbPowerMeter", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2023-06-03T16:23:57.284Z", + "parentDeviceId": "074fa784-8be8-4c70-8e22-6f5ed6f81b7e", + "profile": { + "id": "d382796f-8ed5-3088-8735-eb03e962203b" + }, + "zwave": { + "networkId": "2A", + "driverId": "4fb7ec02-2697-4d73-977d-2b1c65c4484f", + "executingLocally": true, + "hubId": "074fa784-8be8-4c70-8e22-6f5ed6f81b7e", + "networkSecurityLevel": "ZWAVE_LEGACY_NON_SECURE", + "provisioningState": "PROVISIONED", + "manufacturerId": 134, + "productType": 2, + "productId": 9 + }, + "type": "ZWAVE", + "restrictionTier": 0, + "allowed": [], + "executionContext": "LOCAL" + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/c2c_arlo_pro_3_switch.json b/tests/components/smartthings/fixtures/devices/c2c_arlo_pro_3_switch.json new file mode 100644 index 00000000000..a9e3bddb2ca --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/c2c_arlo_pro_3_switch.json @@ -0,0 +1,79 @@ +{ + "items": [ + { + "deviceId": "10e06a70-ee7d-4832-85e9-a0a06a7a05bd", + "name": "c2c-arlo-pro-3-switch", + "label": "2nd Floor Hallway", + "manufacturerName": "SmartThings", + "presentationId": "SmartThings-smartthings-c2c_arlo_pro_3", + "deviceManufacturerCode": "Arlo", + "locationId": "c4d3b2a1-09f8-765e-4d3c-2b1a09f8e7d6 ", + "ownerId": "d47f2b19-3a6e-4c8d-bf21-9e8a7c5d134e", + "roomId": "68b45114-9af8-4906-8636-b973a6faa271", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "switch", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "soundSensor", + "version": 1 + }, + { + "id": "healthCheck", + "version": 1 + }, + { + "id": "videoStream", + "version": 1 + }, + { + "id": "motionSensor", + "version": 1 + }, + { + "id": "videoCapture", + "version": 1 + }, + { + "id": "battery", + "version": 1 + }, + { + "id": "alarm", + "version": 1 + } + ], + "categories": [ + { + "name": "Camera", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2024-11-21T21:55:59.340Z", + "profile": { + "id": "89aefc3a-e210-4678-944c-638d47d296f6" + }, + "viper": { + "manufacturerName": "Arlo", + "modelName": "VMC4041PB", + "endpointAppId": "viper_555d6f40-b65a-11ea-8fe0-77cb99571462" + }, + "type": "VIPER", + "restrictionTier": 0, + "allowed": [], + "executionContext": "CLOUD" + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/c2c_shade.json b/tests/components/smartthings/fixtures/devices/c2c_shade.json new file mode 100644 index 00000000000..265eab11ff5 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/c2c_shade.json @@ -0,0 +1,59 @@ +{ + "items": [ + { + "deviceId": "571af102-15db-4030-b76b-245a691f74a5", + "name": "c2c-shade", + "label": "Curtain 1A", + "manufacturerName": "SmartThings", + "presentationId": "SmartThings-smartthings-c2c-shade", + "deviceManufacturerCode": "WonderLabs Company", + "locationId": "88a3a314-f0c8-40b4-bb44-44ba06c9c42f", + "ownerId": "12d4af93-cb68-b108-87f5-625437d7371f", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "windowShade", + "version": 1 + }, + { + "id": "switchLevel", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "healthCheck", + "version": 1 + } + ], + "categories": [ + { + "name": "Blind", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2025-02-07T23:01:15.883Z", + "profile": { + "id": "0ceffb3e-10d3-4123-bb42-2a92c93c6e25" + }, + "viper": { + "manufacturerName": "WonderLabs Company", + "modelName": "WoCurtain3", + "hwVersion": "WoCurtain3-WoCurtain3", + "endpointAppId": "viper_f18eb770-077d-11ea-bb72-9922e3ed0d38" + }, + "type": "VIPER", + "restrictionTier": 0, + "allowed": [], + "executionContext": "CLOUD" + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/centralite.json b/tests/components/smartthings/fixtures/devices/centralite.json new file mode 100644 index 00000000000..68cdbdf4499 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/centralite.json @@ -0,0 +1,67 @@ +{ + "items": [ + { + "deviceId": "d0268a69-abfb-4c92-a646-61cec2e510ad", + "name": "plug-level-power", + "label": "Dimmer Debian", + "manufacturerName": "SmartThingsCommunity", + "presentationId": "bb7c4cfb-6eaf-3efc-823b-06a54fc9ded9", + "deviceManufacturerCode": "CentraLite", + "locationId": "c4d3b2a1-09f8-765e-4d3c-2b1a09f8e7d6 ", + "ownerId": "d47f2b19-3a6e-4c8d-bf21-9e8a7c5d134e", + "roomId": "94be4a1e-382a-4b7f-a5ef-fdb1a7d9f9e6", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "switch", + "version": 1 + }, + { + "id": "switchLevel", + "version": 1 + }, + { + "id": "powerMeter", + "version": 1 + }, + { + "id": "firmwareUpdate", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + } + ], + "categories": [ + { + "name": "SmartPlug", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2024-08-15T22:16:37.926Z", + "parentDeviceId": "074fa784-8be8-4c70-8e22-6f5ed6f81b7e", + "profile": { + "id": "24195ea4-635c-3450-a235-71bc78ab3d1c" + }, + "zigbee": { + "eui": "000D6F0003C04BC9", + "networkId": "F50E", + "driverId": "f2e891c6-00cc-446c-9192-8ebda63d9898", + "executingLocally": true, + "hubId": "074fa784-8be8-4c70-8e22-6f5ed6f81b7e", + "provisioningState": "PROVISIONED" + }, + "type": "ZIGBEE", + "restrictionTier": 0, + "allowed": [], + "executionContext": "LOCAL" + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/contact_sensor.json b/tests/components/smartthings/fixtures/devices/contact_sensor.json new file mode 100644 index 00000000000..a5de2e2cbfe --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/contact_sensor.json @@ -0,0 +1,71 @@ +{ + "items": [ + { + "deviceId": "2d9a892b-1c93-45a5-84cb-0e81889498c6", + "name": "contact-profile", + "label": ".Front Door Open/Closed Sensor", + "manufacturerName": "SmartThingsCommunity", + "presentationId": "a7f2c1d9-89b3-35a4-b217-fc68d9e4e752", + "deviceManufacturerCode": "Visonic", + "locationId": "c4d3b2a1-09f8-765e-4d3c-2b1a09f8e7d6 ", + "ownerId": "d47f2b19-3a6e-4c8d-bf21-9e8a7c5d134e", + "roomId": "68b45114-9af8-4906-8636-b973a6faa271", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "contactSensor", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "battery", + "version": 1 + }, + { + "id": "firmwareUpdate", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + } + ], + "categories": [ + { + "name": "ContactSensor", + "categoryType": "manufacturer" + }, + { + "name": "ContactSensor", + "categoryType": "user" + } + ] + } + ], + "createTime": "2023-09-28T17:38:59.179Z", + "parentDeviceId": "074fa784-8be8-4c70-8e22-6f5ed6f81b7e", + "profile": { + "id": "22aa5a07-ac33-365f-b2f1-5ecef8cdb0eb" + }, + "zigbee": { + "eui": "000D6F000576F604", + "networkId": "5A44", + "driverId": "408981c2-91d4-4dfc-bbfb-84ca0205d993", + "executingLocally": true, + "hubId": "074fa784-8be8-4c70-8e22-6f5ed6f81b7e", + "provisioningState": "PROVISIONED" + }, + "type": "ZIGBEE", + "restrictionTier": 0, + "allowed": [], + "executionContext": "LOCAL" + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/da_ac_rac_000001.json b/tests/components/smartthings/fixtures/devices/da_ac_rac_000001.json new file mode 100644 index 00000000000..ec7f16b090a --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/da_ac_rac_000001.json @@ -0,0 +1,311 @@ +{ + "items": [ + { + "deviceId": "96a5ef74-5832-a84b-f1f7-ca799957065d", + "name": "[room a/c] Samsung", + "label": "AC Office Granit", + "manufacturerName": "Samsung Electronics", + "presentationId": "DA-AC-RAC-000001", + "deviceManufacturerCode": "Samsung Electronics", + "locationId": "58d3fd7c-c512-4da3-b500-ef269382756c", + "ownerId": "f9a28d7c-1ed5-d9e9-a81c-18971ec081db", + "roomId": "85a79db4-9cf2-4f09-a5b2-cd70a5c0cef0", + "deviceTypeName": "Samsung OCF Air Conditioner", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "ocf", + "version": 1 + }, + { + "id": "switch", + "version": 1 + }, + { + "id": "airConditionerMode", + "version": 1 + }, + { + "id": "airConditionerFanMode", + "version": 1 + }, + { + "id": "fanOscillationMode", + "version": 1 + }, + { + "id": "airQualitySensor", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "thermostatCoolingSetpoint", + "version": 1 + }, + { + "id": "relativeHumidityMeasurement", + "version": 1 + }, + { + "id": "dustSensor", + "version": 1 + }, + { + "id": "veryFineDustSensor", + "version": 1 + }, + { + "id": "audioVolume", + "version": 1 + }, + { + "id": "remoteControlStatus", + "version": 1 + }, + { + "id": "powerConsumptionReport", + "version": 1 + }, + { + "id": "demandResponseLoadControl", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "execute", + "version": 1 + }, + { + "id": "custom.spiMode", + "version": 1 + }, + { + "id": "custom.thermostatSetpointControl", + "version": 1 + }, + { + "id": "custom.airConditionerOptionalMode", + "version": 1 + }, + { + "id": "custom.airConditionerTropicalNightMode", + "version": 1 + }, + { + "id": "custom.autoCleaningMode", + "version": 1 + }, + { + "id": "custom.deviceReportStateConfiguration", + "version": 1 + }, + { + "id": "custom.energyType", + "version": 1 + }, + { + "id": "custom.dustFilter", + "version": 1 + }, + { + "id": "custom.airConditionerOdorController", + "version": 1 + }, + { + "id": "custom.deodorFilter", + "version": 1 + }, + { + "id": "custom.disabledComponents", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + }, + { + "id": "samsungce.deviceIdentification", + "version": 1 + }, + { + "id": "samsungce.dongleSoftwareInstallation", + "version": 1 + }, + { + "id": "samsungce.softwareUpdate", + "version": 1 + }, + { + "id": "samsungce.selfCheck", + "version": 1 + }, + { + "id": "samsungce.driverVersion", + "version": 1 + } + ], + "categories": [ + { + "name": "AirConditioner", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "1", + "label": "1", + "capabilities": [ + { + "id": "switch", + "version": 1 + }, + { + "id": "airConditionerMode", + "version": 1 + }, + { + "id": "airConditionerFanMode", + "version": 1 + }, + { + "id": "fanOscillationMode", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "thermostatCoolingSetpoint", + "version": 1 + }, + { + "id": "relativeHumidityMeasurement", + "version": 1 + }, + { + "id": "airQualitySensor", + "version": 1 + }, + { + "id": "dustSensor", + "version": 1 + }, + { + "id": "veryFineDustSensor", + "version": 1 + }, + { + "id": "odorSensor", + "version": 1 + }, + { + "id": "remoteControlStatus", + "version": 1 + }, + { + "id": "audioVolume", + "version": 1 + }, + { + "id": "custom.thermostatSetpointControl", + "version": 1 + }, + { + "id": "custom.autoCleaningMode", + "version": 1 + }, + { + "id": "custom.airConditionerTropicalNightMode", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + }, + { + "id": "ocf", + "version": 1 + }, + { + "id": "powerConsumptionReport", + "version": 1 + }, + { + "id": "demandResponseLoadControl", + "version": 1 + }, + { + "id": "custom.spiMode", + "version": 1 + }, + { + "id": "custom.airConditionerOptionalMode", + "version": 1 + }, + { + "id": "custom.deviceReportStateConfiguration", + "version": 1 + }, + { + "id": "custom.energyType", + "version": 1 + }, + { + "id": "custom.dustFilter", + "version": 1 + }, + { + "id": "custom.airConditionerOdorController", + "version": 1 + }, + { + "id": "custom.deodorFilter", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2021-04-06T16:43:34.753Z", + "profile": { + "id": "60fbc713-8da5-315d-b31a-6d6dcde4be7b" + }, + "ocf": { + "ocfDeviceType": "oic.d.airconditioner", + "name": "[room a/c] Samsung", + "specVersion": "core.1.1.0", + "verticalDomainSpecVersion": "res.1.1.0,sh.1.1.0", + "manufacturerName": "Samsung Electronics", + "modelNumber": "ARTIK051_KRAC_18K|10193441|60010132001111110200000000000000", + "platformVersion": "0G3MPDCKA00010E", + "platformOS": "TizenRT2.0", + "hwVersion": "1.0", + "firmwareVersion": "0.1.0", + "vendorId": "DA-AC-RAC-000001", + "lastSignupTime": "2021-04-06T16:43:27.889445Z", + "transferCandidate": false, + "additionalAuthCodeRequired": false + }, + "type": "OCF", + "restrictionTier": 0, + "allowed": [], + "executionContext": "CLOUD" + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/da_ac_rac_01001.json b/tests/components/smartthings/fixtures/devices/da_ac_rac_01001.json new file mode 100644 index 00000000000..8d9ebde5bcd --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/da_ac_rac_01001.json @@ -0,0 +1,264 @@ +{ + "items": [ + { + "deviceId": "4ece486b-89db-f06a-d54d-748b676b4d8e", + "name": "Samsung-Room-Air-Conditioner", + "label": "Aire Dormitorio Principal", + "manufacturerName": "Samsung Electronics", + "presentationId": "DA-AC-RAC-01001", + "deviceManufacturerCode": "Samsung Electronics", + "locationId": "c4189ac1-208f-461a-8ab6-ea67937b3743", + "ownerId": "85ea07e1-7063-f673-3ba5-125293f297c8", + "roomId": "1f66199a-1773-4d8f-97b7-44c312a62cf7", + "deviceTypeName": "Samsung OCF Air Conditioner", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "ocf", + "version": 1 + }, + { + "id": "switch", + "version": 1 + }, + { + "id": "airConditionerMode", + "version": 1 + }, + { + "id": "airConditionerFanMode", + "version": 1 + }, + { + "id": "bypassable", + "version": 1 + }, + { + "id": "fanOscillationMode", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "thermostatCoolingSetpoint", + "version": 1 + }, + { + "id": "relativeHumidityMeasurement", + "version": 1 + }, + { + "id": "airQualitySensor", + "version": 1 + }, + { + "id": "odorSensor", + "version": 1 + }, + { + "id": "dustSensor", + "version": 1 + }, + { + "id": "veryFineDustSensor", + "version": 1 + }, + { + "id": "audioVolume", + "version": 1 + }, + { + "id": "powerConsumptionReport", + "version": 1 + }, + { + "id": "demandResponseLoadControl", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "execute", + "version": 1 + }, + { + "id": "audioNotification", + "version": 1 + }, + { + "id": "custom.spiMode", + "version": 1 + }, + { + "id": "custom.thermostatSetpointControl", + "version": 1 + }, + { + "id": "custom.airConditionerOptionalMode", + "version": 1 + }, + { + "id": "custom.airConditionerTropicalNightMode", + "version": 1 + }, + { + "id": "custom.autoCleaningMode", + "version": 1 + }, + { + "id": "custom.deviceReportStateConfiguration", + "version": 1 + }, + { + "id": "custom.energyType", + "version": 1 + }, + { + "id": "custom.dustFilter", + "version": 1 + }, + { + "id": "custom.veryFineDustFilter", + "version": 1 + }, + { + "id": "custom.deodorFilter", + "version": 1 + }, + { + "id": "custom.electricHepaFilter", + "version": 1 + }, + { + "id": "custom.doNotDisturbMode", + "version": 1 + }, + { + "id": "custom.periodicSensing", + "version": 1 + }, + { + "id": "custom.airConditionerOdorController", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + }, + { + "id": "samsungce.alwaysOnSensing", + "version": 1 + }, + { + "id": "samsungce.airConditionerBeep", + "version": 1 + }, + { + "id": "samsungce.airConditionerLighting", + "version": 1 + }, + { + "id": "samsungce.airQualityHealthConcern", + "version": 1 + }, + { + "id": "samsungce.buttonDisplayCondition", + "version": 1 + }, + { + "id": "samsungce.deviceIdentification", + "version": 1 + }, + { + "id": "samsungce.driverVersion", + "version": 1 + }, + { + "id": "samsungce.dustFilterAlarm", + "version": 1 + }, + { + "id": "samsungce.individualControlLock", + "version": 1 + }, + { + "id": "samsungce.softwareUpdate", + "version": 1 + }, + { + "id": "samsungce.selfCheck", + "version": 1 + }, + { + "id": "samsungce.silentAction", + "version": 1 + }, + { + "id": "samsungce.quickControl", + "version": 1 + }, + { + "id": "samsungce.welcomeCooling", + "version": 1 + }, + { + "id": "samsungce.unavailableCapabilities", + "version": 1 + }, + { + "id": "sec.diagnosticsInformation", + "version": 1 + }, + { + "id": "sec.wifiConfiguration", + "version": 1 + }, + { + "id": "sec.calmConnectionCare", + "version": 1 + } + ], + "categories": [ + { + "name": "AirConditioner", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2025-01-28T21:31:35.755Z", + "profile": { + "id": "091a55f4-7054-39fa-b23e-b56deb7580f8" + }, + "ocf": { + "ocfDeviceType": "oic.d.airconditioner", + "name": "Samsung-Room-Air-Conditioner", + "specVersion": "core.1.1.0", + "verticalDomainSpecVersion": "1.2.1", + "manufacturerName": "Samsung Electronics", + "modelNumber": "ARA-WW-TP1-22-COMMON|10229641|60010523001511014600083200800000", + "platformVersion": "DAWIT 2.0", + "platformOS": "TizenRT 3.1", + "hwVersion": "Realtek", + "firmwareVersion": "ARA-WW-TP1-22-COMMON_11240702", + "vendorId": "DA-AC-RAC-01001", + "vendorResourceClientServerVersion": "Realtek Release 3.1.240221", + "lastSignupTime": "2025-01-28T21:31:30.090416369Z", + "transferCandidate": false, + "additionalAuthCodeRequired": false + }, + "type": "OCF", + "restrictionTier": 0, + "allowed": [], + "executionContext": "CLOUD" + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/da_ks_microwave_0101x.json b/tests/components/smartthings/fixtures/devices/da_ks_microwave_0101x.json new file mode 100644 index 00000000000..f6599fee461 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/da_ks_microwave_0101x.json @@ -0,0 +1,176 @@ +{ + "items": [ + { + "deviceId": "2bad3237-4886-e699-1b90-4a51a3d55c8a", + "name": "Samsung Microwave", + "label": "Microwave", + "manufacturerName": "Samsung Electronics", + "presentationId": "DA-KS-MICROWAVE-0101X", + "deviceManufacturerCode": "Samsung Electronics", + "locationId": "586e4602-34ab-4a22-993e-5f616b04604f", + "ownerId": "b603d7e8-6066-4e10-8102-afa752a63816", + "roomId": "f4d03391-ab13-4c1d-b4dc-d6ddf86014a2", + "deviceTypeName": "oic.d.microwave", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "ocf", + "version": 1 + }, + { + "id": "execute", + "version": 1 + }, + { + "id": "switch", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "remoteControlStatus", + "version": 1 + }, + { + "id": "ovenSetpoint", + "version": 1 + }, + { + "id": "ovenMode", + "version": 1 + }, + { + "id": "ovenOperatingState", + "version": 1 + }, + { + "id": "doorControl", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "samsungce.deviceIdentification", + "version": 1 + }, + { + "id": "samsungce.driverVersion", + "version": 1 + }, + { + "id": "samsungce.hoodFanSpeed", + "version": 1 + }, + { + "id": "samsungce.definedRecipe", + "version": 1 + }, + { + "id": "samsungce.doorState", + "version": 1 + }, + { + "id": "samsungce.kitchenDeviceIdentification", + "version": 1 + }, + { + "id": "samsungce.kitchenDeviceDefaults", + "version": 1 + }, + { + "id": "samsungce.ovenMode", + "version": 1 + }, + { + "id": "samsungce.ovenOperatingState", + "version": 1 + }, + { + "id": "samsungce.microwavePower", + "version": 1 + }, + { + "id": "samsungce.kitchenModeSpecification", + "version": 1 + }, + { + "id": "samsungce.kidsLock", + "version": 1 + }, + { + "id": "samsungce.softwareUpdate", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + }, + { + "id": "sec.diagnosticsInformation", + "version": 1 + } + ], + "categories": [ + { + "name": "Microwave", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "hood", + "label": "hood", + "capabilities": [ + { + "id": "samsungce.lamp", + "version": 1 + }, + { + "id": "samsungce.hoodFanSpeed", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2022-03-23T15:59:10.704Z", + "profile": { + "id": "e5db3b6f-cad6-3caa-9775-9c9cae20f4a4" + }, + "ocf": { + "ocfDeviceType": "oic.d.microwave", + "name": "Samsung Microwave", + "specVersion": "core.1.1.0", + "verticalDomainSpecVersion": "res.1.1.0,sh.1.1.0", + "manufacturerName": "Samsung Electronics", + "modelNumber": "TP2X_DA-KS-MICROWAVE-0101X|40436241|50040100011411000200000000000000", + "platformVersion": "DAWIT 3.0", + "platformOS": "TizenRT 2.0 + IPv6", + "hwVersion": "MediaTek", + "firmwareVersion": "AKS-WW-TP2-20-MICROWAVE-OTR_40230125", + "vendorId": "DA-KS-MICROWAVE-0101X", + "vendorResourceClientServerVersion": "MediaTek Release 2.220916.2", + "lastSignupTime": "2022-04-17T15:33:11.063457Z", + "transferCandidate": false, + "additionalAuthCodeRequired": false + }, + "type": "OCF", + "restrictionTier": 0, + "allowed": [], + "executionContext": "CLOUD" + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/da_ref_normal_000001.json b/tests/components/smartthings/fixtures/devices/da_ref_normal_000001.json new file mode 100644 index 00000000000..67afc0ad32c --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/da_ref_normal_000001.json @@ -0,0 +1,412 @@ +{ + "items": [ + { + "deviceId": "7db87911-7dce-1cf2-7119-b953432a2f09", + "name": "[refrigerator] Samsung", + "label": "Refrigerator", + "manufacturerName": "Samsung Electronics", + "presentationId": "DA-REF-NORMAL-000001", + "deviceManufacturerCode": "Samsung Electronics", + "locationId": "c4d3b2a1-09f8-765e-4d3c-2b1a09f8e7d6 ", + "ownerId": "d47f2b19-3a6e-4c8d-bf21-9e8a7c5d134e", + "roomId": "3a1f7e7c-4e59-4c29-adb0-0813be691efd", + "deviceTypeName": "Samsung OCF Refrigerator", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "contactSensor", + "version": 1 + }, + { + "id": "execute", + "version": 1 + }, + { + "id": "ocf", + "version": 1 + }, + { + "id": "powerConsumptionReport", + "version": 1 + }, + { + "id": "demandResponseLoadControl", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "refrigeration", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "thermostatCoolingSetpoint", + "version": 1 + }, + { + "id": "custom.deodorFilter", + "version": 1 + }, + { + "id": "custom.deviceReportStateConfiguration", + "version": 1 + }, + { + "id": "custom.energyType", + "version": 1 + }, + { + "id": "custom.fridgeMode", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + }, + { + "id": "custom.disabledComponents", + "version": 1 + }, + { + "id": "custom.waterFilter", + "version": 1 + }, + { + "id": "samsungce.softwareUpdate", + "version": 1 + }, + { + "id": "samsungce.deviceIdentification", + "version": 1 + }, + { + "id": "samsungce.dongleSoftwareInstallation", + "version": 1 + }, + { + "id": "samsungce.driverVersion", + "version": 1 + }, + { + "id": "samsungce.fridgeVacationMode", + "version": 1 + }, + { + "id": "samsungce.powerCool", + "version": 1 + }, + { + "id": "samsungce.powerFreeze", + "version": 1 + }, + { + "id": "samsungce.sabbathMode", + "version": 1 + }, + { + "id": "samsungce.quickControl", + "version": 1 + }, + { + "id": "sec.diagnosticsInformation", + "version": 1 + } + ], + "categories": [ + { + "name": "Refrigerator", + "categoryType": "manufacturer" + }, + { + "name": "Refrigerator", + "categoryType": "user" + } + ] + }, + { + "id": "freezer", + "label": "freezer", + "capabilities": [ + { + "id": "contactSensor", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "thermostatCoolingSetpoint", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + }, + { + "id": "custom.fridgeMode", + "version": 1 + }, + { + "id": "custom.thermostatSetpointControl", + "version": 1 + }, + { + "id": "samsungce.freezerConvertMode", + "version": 1 + }, + { + "id": "samsungce.unavailableCapabilities", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "cooler", + "label": "cooler", + "capabilities": [ + { + "id": "contactSensor", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "thermostatCoolingSetpoint", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + }, + { + "id": "custom.fridgeMode", + "version": 1 + }, + { + "id": "custom.thermostatSetpointControl", + "version": 1 + }, + { + "id": "samsungce.unavailableCapabilities", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "cvroom", + "label": "cvroom", + "capabilities": [ + { + "id": "contactSensor", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "thermostatCoolingSetpoint", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + }, + { + "id": "custom.fridgeMode", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "onedoor", + "label": "onedoor", + "capabilities": [ + { + "id": "contactSensor", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "thermostatCoolingSetpoint", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + }, + { + "id": "custom.fridgeMode", + "version": 1 + }, + { + "id": "custom.thermostatSetpointControl", + "version": 1 + }, + { + "id": "samsungce.freezerConvertMode", + "version": 1 + }, + { + "id": "samsungce.unavailableCapabilities", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "icemaker", + "label": "icemaker", + "capabilities": [ + { + "id": "switch", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "icemaker-02", + "label": "icemaker-02", + "capabilities": [ + { + "id": "switch", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "pantry-01", + "label": "pantry-01", + "capabilities": [ + { + "id": "samsungce.fridgePantryInfo", + "version": 1 + }, + { + "id": "samsungce.fridgePantryMode", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "pantry-02", + "label": "pantry-02", + "capabilities": [ + { + "id": "samsungce.fridgePantryInfo", + "version": 1 + }, + { + "id": "samsungce.fridgePantryMode", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2022-01-08T16:50:43.544Z", + "profile": { + "id": "f2a9af35-5df8-3477-91df-94941d302591" + }, + "ocf": { + "ocfDeviceType": "oic.d.refrigerator", + "name": "[refrigerator] Samsung", + "specVersion": "core.1.1.0", + "verticalDomainSpecVersion": "res.1.1.0,sh.1.1.0", + "manufacturerName": "Samsung Electronics", + "modelNumber": "TP2X_REF_20K|00115641|0004014D011411200103000020000000", + "platformVersion": "DAWIT 2.0", + "platformOS": "TizenRT 1.0 + IPv6", + "hwVersion": "MediaTek", + "firmwareVersion": "A-RFWW-TP2-21-COMMON_20220110", + "vendorId": "DA-REF-NORMAL-000001", + "vendorResourceClientServerVersion": "MediaTek Release 2.210524.1", + "lastSignupTime": "2024-08-06T15:24:29.362093Z", + "transferCandidate": false, + "additionalAuthCodeRequired": false + }, + "type": "OCF", + "restrictionTier": 0, + "allowed": [], + "executionContext": "CLOUD" + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/da_rvc_normal_000001.json b/tests/components/smartthings/fixtures/devices/da_rvc_normal_000001.json new file mode 100644 index 00000000000..b355eedb17a --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/da_rvc_normal_000001.json @@ -0,0 +1,119 @@ +{ + "items": [ + { + "deviceId": "3442dfc6-17c0-a65f-dae0-4c6e01786f44", + "name": "[robot vacuum] Samsung", + "label": "Robot vacuum", + "manufacturerName": "Samsung Electronics", + "presentationId": "DA-RVC-NORMAL-000001", + "deviceManufacturerCode": "Samsung Electronics", + "locationId": "586e4602-34ab-4a22-993e-5f616b04604f", + "ownerId": "b603d7e8-6066-4e10-8102-afa752a63816", + "roomId": "5d425f41-042a-4d9a-92c4-e43150a61bae", + "deviceTypeName": "Samsung OCF Robot Vacuum", + "components": [ + { + "id": "main", + "label": "Robot vacuum", + "capabilities": [ + { + "id": "ocf", + "version": 1 + }, + { + "id": "execute", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "battery", + "version": 1 + }, + { + "id": "switch", + "version": 1 + }, + { + "id": "powerConsumptionReport", + "version": 1 + }, + { + "id": "robotCleanerTurboMode", + "version": 1 + }, + { + "id": "robotCleanerMovement", + "version": 1 + }, + { + "id": "robotCleanerCleaningMode", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + }, + { + "id": "custom.disabledComponents", + "version": 1 + }, + { + "id": "samsungce.deviceIdentification", + "version": 1 + }, + { + "id": "samsungce.softwareUpdate", + "version": 1 + }, + { + "id": "samsungce.robotCleanerCleaningMode", + "version": 1 + }, + { + "id": "samsungce.robotCleanerOperatingState", + "version": 1 + }, + { + "id": "samsungce.driverVersion", + "version": 1 + } + ], + "categories": [ + { + "name": "RobotCleaner", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2018-06-06T23:04:25Z", + "profile": { + "id": "61b1c3cd-61cc-3dde-a4ba-9477d5e559cb" + }, + "ocf": { + "ocfDeviceType": "oic.d.robotcleaner", + "name": "[robot vacuum] Samsung", + "specVersion": "core.1.1.0", + "verticalDomainSpecVersion": "res.1.1.0,sh.1.1.0", + "manufacturerName": "Samsung Electronics", + "modelNumber": "powerbot_7000_17M|50016055|80010404011141000100000000000000", + "platformVersion": "00", + "platformOS": "Tizen(3/0)", + "hwVersion": "1.0", + "firmwareVersion": "1.0", + "vendorId": "DA-RVC-NORMAL-000001", + "lastSignupTime": "2020-11-03T04:43:02.729Z", + "transferCandidate": false, + "additionalAuthCodeRequired": false + }, + "type": "OCF", + "restrictionTier": 0, + "allowed": [], + "executionContext": "CLOUD" + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/da_wm_dw_000001.json b/tests/components/smartthings/fixtures/devices/da_wm_dw_000001.json new file mode 100644 index 00000000000..1c7024e153f --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/da_wm_dw_000001.json @@ -0,0 +1,168 @@ +{ + "items": [ + { + "deviceId": "f36dc7ce-cac0-0667-dc14-a3704eb5e676", + "name": "[dishwasher] Samsung", + "label": "Dishwasher", + "manufacturerName": "Samsung Electronics", + "presentationId": "DA-WM-DW-000001", + "deviceManufacturerCode": "Samsung Electronics", + "locationId": "586e4602-34ab-4a22-993e-5f616b04604f", + "ownerId": "b603d7e8-6066-4e10-8102-afa752a63816", + "roomId": "f4d03391-ab13-4c1d-b4dc-d6ddf86014a2", + "deviceTypeName": "Samsung OCF Dishwasher", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "execute", + "version": 1 + }, + { + "id": "ocf", + "version": 1 + }, + { + "id": "powerConsumptionReport", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "remoteControlStatus", + "version": 1 + }, + { + "id": "switch", + "version": 1 + }, + { + "id": "demandResponseLoadControl", + "version": 1 + }, + { + "id": "dishwasherOperatingState", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + }, + { + "id": "custom.dishwasherOperatingProgress", + "version": 1 + }, + { + "id": "custom.dishwasherOperatingPercentage", + "version": 1 + }, + { + "id": "custom.dishwasherDelayStartTime", + "version": 1 + }, + { + "id": "custom.energyType", + "version": 1 + }, + { + "id": "custom.supportedOptions", + "version": 1 + }, + { + "id": "custom.waterFilter", + "version": 1 + }, + { + "id": "samsungce.deviceIdentification", + "version": 1 + }, + { + "id": "samsungce.dishwasherJobState", + "version": 1 + }, + { + "id": "samsungce.dishwasherWashingCourse", + "version": 1 + }, + { + "id": "samsungce.dishwasherWashingCourseDetails", + "version": 1 + }, + { + "id": "samsungce.dishwasherOperation", + "version": 1 + }, + { + "id": "samsungce.dishwasherWashingOptions", + "version": 1 + }, + { + "id": "samsungce.driverVersion", + "version": 1 + }, + { + "id": "samsungce.softwareUpdate", + "version": 1 + }, + { + "id": "samsungce.kidsLock", + "version": 1 + }, + { + "id": "samsungce.waterConsumptionReport", + "version": 1 + }, + { + "id": "samsungce.quickControl", + "version": 1 + }, + { + "id": "sec.diagnosticsInformation", + "version": 1 + }, + { + "id": "sec.wifiConfiguration", + "version": 1 + } + ], + "categories": [ + { + "name": "Dishwasher", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2021-06-27T01:19:35.408Z", + "profile": { + "id": "0cba797c-40ee-3473-aa01-4ee5b6cb8c67" + }, + "ocf": { + "ocfDeviceType": "oic.d.dishwasher", + "name": "[dishwasher] Samsung", + "specVersion": "core.1.1.0", + "verticalDomainSpecVersion": "res.1.1.0,sh.1.1.0", + "manufacturerName": "Samsung Electronics", + "modelNumber": "DA_DW_A51_20_COMMON|30007242|40010201001311000101000000000000", + "platformVersion": "DAWIT 2.0", + "platformOS": "TizenRT 1.0 + IPv6", + "hwVersion": "ARTIK051", + "firmwareVersion": "DA_DW_A51_20_COMMON_30230714", + "vendorId": "DA-WM-DW-000001", + "vendorResourceClientServerVersion": "ARTIK051 Release 2.210224.1", + "lastSignupTime": "2021-10-16T17:28:59.984202Z", + "transferCandidate": false, + "additionalAuthCodeRequired": false + }, + "type": "OCF", + "restrictionTier": 0, + "allowed": [], + "executionContext": "CLOUD" + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/da_wm_wd_000001.json b/tests/components/smartthings/fixtures/devices/da_wm_wd_000001.json new file mode 100644 index 00000000000..b9a650718e2 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/da_wm_wd_000001.json @@ -0,0 +1,204 @@ +{ + "items": [ + { + "deviceId": "02f7256e-8353-5bdd-547f-bd5b1647e01b", + "name": "[dryer] Samsung", + "label": "Dryer", + "manufacturerName": "Samsung Electronics", + "presentationId": "DA-WM-WD-000001", + "deviceManufacturerCode": "Samsung Electronics", + "locationId": "781d5f1e-c87e-455e-87f7-8e954879e91d", + "ownerId": "b603d7e8-6066-4e10-8102-afa752a63816", + "roomId": "2a8637b2-77ad-475e-b537-7b6f7f97fff6", + "deviceTypeName": "Samsung OCF Dryer", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "ocf", + "version": 1 + }, + { + "id": "execute", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "switch", + "version": 1 + }, + { + "id": "remoteControlStatus", + "version": 1 + }, + { + "id": "dryerOperatingState", + "version": 1 + }, + { + "id": "powerConsumptionReport", + "version": 1 + }, + { + "id": "demandResponseLoadControl", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + }, + { + "id": "custom.dryerDryLevel", + "version": 1 + }, + { + "id": "custom.dryerWrinklePrevent", + "version": 1 + }, + { + "id": "custom.energyType", + "version": 1 + }, + { + "id": "custom.jobBeginningStatus", + "version": 1 + }, + { + "id": "custom.supportedOptions", + "version": 1 + }, + { + "id": "samsungce.softwareUpdate", + "version": 1 + }, + { + "id": "samsungce.detergentOrder", + "version": 1 + }, + { + "id": "samsungce.detergentState", + "version": 1 + }, + { + "id": "samsungce.deviceIdentification", + "version": 1 + }, + { + "id": "samsungce.dongleSoftwareInstallation", + "version": 1 + }, + { + "id": "samsungce.driverVersion", + "version": 1 + }, + { + "id": "samsungce.dryerAutoCycleLink", + "version": 1 + }, + { + "id": "samsungce.dryerCycle", + "version": 1 + }, + { + "id": "samsungce.dryerCyclePreset", + "version": 1 + }, + { + "id": "samsungce.dryerDelayEnd", + "version": 1 + }, + { + "id": "samsungce.dryerDryingTemperature", + "version": 1 + }, + { + "id": "samsungce.dryerDryingTime", + "version": 1 + }, + { + "id": "samsungce.dryerFreezePrevent", + "version": 1 + }, + { + "id": "samsungce.dryerOperatingState", + "version": 1 + }, + { + "id": "samsungce.kidsLock", + "version": 1 + }, + { + "id": "samsungce.welcomeMessage", + "version": 1 + }, + { + "id": "samsungce.quickControl", + "version": 1 + }, + { + "id": "sec.diagnosticsInformation", + "version": 1 + }, + { + "id": "sec.wifiConfiguration", + "version": 1 + } + ], + "categories": [ + { + "name": "Dryer", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "hca.main", + "label": "hca.main", + "capabilities": [ + { + "id": "hca.dryerMode", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2021-06-01T22:54:25.907Z", + "profile": { + "id": "53a1d049-eeda-396c-8324-e33438ef57be" + }, + "ocf": { + "ocfDeviceType": "oic.d.dryer", + "name": "[dryer] Samsung", + "specVersion": "core.1.1.0", + "verticalDomainSpecVersion": "res.1.1.0,sh.1.1.0", + "manufacturerName": "Samsung Electronics", + "modelNumber": "DA_WM_A51_20_COMMON|20233741|3000000100111100020B000000000000", + "platformVersion": "DAWIT 2.0", + "platformOS": "TizenRT 1.0 + IPv6", + "hwVersion": "ARTIK051", + "firmwareVersion": "DA_WM_A51_20_COMMON_30230708", + "vendorId": "DA-WM-WD-000001", + "vendorResourceClientServerVersion": "ARTIK051 Release 2.210224.1", + "lastSignupTime": "2021-06-01T22:54:22.826697Z", + "transferCandidate": false, + "additionalAuthCodeRequired": false + }, + "type": "OCF", + "restrictionTier": 0, + "allowed": [], + "executionContext": "CLOUD" + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/da_wm_wm_000001.json b/tests/components/smartthings/fixtures/devices/da_wm_wm_000001.json new file mode 100644 index 00000000000..852a2afa932 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/da_wm_wm_000001.json @@ -0,0 +1,260 @@ +{ + "items": [ + { + "deviceId": "f984b91d-f250-9d42-3436-33f09a422a47", + "name": "[washer] Samsung", + "label": "Washer", + "manufacturerName": "Samsung Electronics", + "presentationId": "DA-WM-WM-000001", + "deviceManufacturerCode": "Samsung Electronics", + "locationId": "781d5f1e-c87e-455e-87f7-8e954879e91d", + "ownerId": "b603d7e8-6066-4e10-8102-afa752a63816", + "roomId": "2a8637b2-77ad-475e-b537-7b6f7f97fff6", + "deviceTypeName": "Samsung OCF Washer", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "execute", + "version": 1 + }, + { + "id": "ocf", + "version": 1 + }, + { + "id": "powerConsumptionReport", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "remoteControlStatus", + "version": 1 + }, + { + "id": "demandResponseLoadControl", + "version": 1 + }, + { + "id": "switch", + "version": 1 + }, + { + "id": "washerOperatingState", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + }, + { + "id": "custom.dryerDryLevel", + "version": 1 + }, + { + "id": "custom.energyType", + "version": 1 + }, + { + "id": "custom.jobBeginningStatus", + "version": 1 + }, + { + "id": "custom.supportedOptions", + "version": 1 + }, + { + "id": "custom.washerAutoDetergent", + "version": 1 + }, + { + "id": "custom.washerAutoSoftener", + "version": 1 + }, + { + "id": "custom.washerRinseCycles", + "version": 1 + }, + { + "id": "custom.washerSoilLevel", + "version": 1 + }, + { + "id": "custom.washerSpinLevel", + "version": 1 + }, + { + "id": "custom.washerWaterTemperature", + "version": 1 + }, + { + "id": "samsungce.autoDispenseDetergent", + "version": 1 + }, + { + "id": "samsungce.autoDispenseSoftener", + "version": 1 + }, + { + "id": "samsungce.detergentOrder", + "version": 1 + }, + { + "id": "samsungce.detergentState", + "version": 1 + }, + { + "id": "samsungce.deviceIdentification", + "version": 1 + }, + { + "id": "samsungce.dongleSoftwareInstallation", + "version": 1 + }, + { + "id": "samsungce.detergentAutoReplenishment", + "version": 1 + }, + { + "id": "samsungce.softenerAutoReplenishment", + "version": 1 + }, + { + "id": "samsungce.driverVersion", + "version": 1 + }, + { + "id": "samsungce.softwareUpdate", + "version": 1 + }, + { + "id": "samsungce.kidsLock", + "version": 1 + }, + { + "id": "samsungce.softenerOrder", + "version": 1 + }, + { + "id": "samsungce.softenerState", + "version": 1 + }, + { + "id": "samsungce.washerBubbleSoak", + "version": 1 + }, + { + "id": "samsungce.washerCycle", + "version": 1 + }, + { + "id": "samsungce.washerCyclePreset", + "version": 1 + }, + { + "id": "samsungce.washerDelayEnd", + "version": 1 + }, + { + "id": "samsungce.washerFreezePrevent", + "version": 1 + }, + { + "id": "samsungce.washerOperatingState", + "version": 1 + }, + { + "id": "samsungce.washerWashingTime", + "version": 1 + }, + { + "id": "samsungce.washerWaterLevel", + "version": 1 + }, + { + "id": "samsungce.washerWaterValve", + "version": 1 + }, + { + "id": "samsungce.welcomeMessage", + "version": 1 + }, + { + "id": "samsungce.waterConsumptionReport", + "version": 1 + }, + { + "id": "samsungce.quickControl", + "version": 1 + }, + { + "id": "samsungce.energyPlanner", + "version": 1 + }, + { + "id": "sec.diagnosticsInformation", + "version": 1 + }, + { + "id": "sec.wifiConfiguration", + "version": 1 + } + ], + "categories": [ + { + "name": "Washer", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "hca.main", + "label": "hca.main", + "capabilities": [ + { + "id": "hca.washerMode", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2021-06-01T22:52:18.023Z", + "profile": { + "id": "3f221c79-d81c-315f-8e8b-b5742802a1e3" + }, + "ocf": { + "ocfDeviceType": "oic.d.washer", + "name": "[washer] Samsung", + "specVersion": "core.1.1.0", + "verticalDomainSpecVersion": "res.1.1.0,sh.1.1.0", + "manufacturerName": "Samsung Electronics", + "modelNumber": "DA_WM_TP2_20_COMMON|20233741|2001000100131100022B010000000000", + "platformVersion": "DAWIT 2.0", + "platformOS": "TizenRT 2.0 + IPv6", + "hwVersion": "MediaTek", + "firmwareVersion": "DA_WM_TP2_20_COMMON_30230804", + "vendorId": "DA-WM-WM-000001", + "vendorResourceClientServerVersion": "MediaTek Release 2.211214.1", + "lastSignupTime": "2021-06-01T22:52:13.923649Z", + "transferCandidate": false, + "additionalAuthCodeRequired": false + }, + "type": "OCF", + "restrictionTier": 0, + "allowed": [], + "executionContext": "CLOUD" + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/ecobee_sensor.json b/tests/components/smartthings/fixtures/devices/ecobee_sensor.json new file mode 100644 index 00000000000..4c37a17f1a0 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/ecobee_sensor.json @@ -0,0 +1,64 @@ +{ + "items": [ + { + "deviceId": "d5dc3299-c266-41c7-bd08-f540aea54b89", + "name": "ecobee Sensor", + "label": "Child Bedroom", + "manufacturerName": "0A0b", + "presentationId": "ST_635a866e-a3ea-4184-9d60-9c72ea603dfd", + "deviceManufacturerCode": "ecobee", + "locationId": "b6fe1fcb-e82b-4ce8-a5e1-85e96adba06c", + "ownerId": "b473ee01-2b1f-7bb1-c433-3caec75960bc", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "motionSensor", + "version": 1 + }, + { + "id": "presenceSensor", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "healthCheck", + "version": 1 + } + ], + "categories": [ + { + "name": "MotionSensor", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2025-01-16T21:14:07.283Z", + "profile": { + "id": "8ab3ca07-0d07-471b-a276-065e46d7aa8a" + }, + "viper": { + "manufacturerName": "ecobee", + "modelName": "aresSmart-ecobee3_remote_sensor", + "swVersion": "250206213001", + "hwVersion": "250206213001", + "endpointAppId": "viper_92ccdcc0-4184-11eb-b9c5-036180216747" + }, + "type": "VIPER", + "restrictionTier": 0, + "allowed": [], + "executionContext": "CLOUD" + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/ecobee_thermostat.json b/tests/components/smartthings/fixtures/devices/ecobee_thermostat.json new file mode 100644 index 00000000000..9becb0923c2 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/ecobee_thermostat.json @@ -0,0 +1,80 @@ +{ + "items": [ + { + "deviceId": "028469cb-6e89-4f14-8d9a-bfbca5e0fbfc", + "name": "v4 - ecobee Thermostat - Heat and Cool (F)", + "label": "Main Floor", + "manufacturerName": "0A0b", + "presentationId": "ST_5334da38-8076-4b40-9f6c-ac3fccaa5d24", + "deviceManufacturerCode": "ecobee", + "locationId": "b6fe1fcb-e82b-4ce8-a5e1-85e96adba06c", + "ownerId": "b473ee01-2b1f-7bb1-c433-3caec75960bc", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "relativeHumidityMeasurement", + "version": 1 + }, + { + "id": "thermostatHeatingSetpoint", + "version": 1 + }, + { + "id": "thermostatCoolingSetpoint", + "version": 1 + }, + { + "id": "thermostatOperatingState", + "version": 1 + }, + { + "id": "thermostatMode", + "version": 1 + }, + { + "id": "thermostatFanMode", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "healthCheck", + "version": 1 + } + ], + "categories": [ + { + "name": "Thermostat", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2025-01-16T21:14:07.276Z", + "profile": { + "id": "234d537d-d388-497f-b0f4-2e25025119ba" + }, + "viper": { + "manufacturerName": "ecobee", + "modelName": "aresSmart-thermostat", + "swVersion": "250206151734", + "hwVersion": "250206151734", + "endpointAppId": "viper_92ccdcc0-4184-11eb-b9c5-036180216747" + }, + "type": "VIPER", + "restrictionTier": 0, + "allowed": [], + "executionContext": "CLOUD" + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/fake_fan.json b/tests/components/smartthings/fixtures/devices/fake_fan.json new file mode 100644 index 00000000000..7b8e174d420 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/fake_fan.json @@ -0,0 +1,50 @@ +{ + "items": [ + { + "deviceId": "f1af21a2-d5a1-437c-b10a-b34a87394b71", + "name": "fake-fan", + "label": "Fake fan", + "manufacturerName": "Myself", + "presentationId": "3f0921d3-0a66-3d49-b458-752e596838e9", + "deviceManufacturerCode": "0086-0002-005F", + "locationId": "6f11ddf5-f0cb-4516-a06a-3a2a6ec22bca", + "ownerId": "9f257fc4-6471-2566-b06e-2fe72dd979fa", + "roomId": "cdf080f0-0542-41d7-a606-aff69683e04c", + "components": [ + { + "id": "main", + "capabilities": [ + { + "id": "switch", + "version": 1 + }, + { + "id": "fanSpeed", + "version": 1 + }, + { + "id": "airConditionerFanMode", + "version": 1 + } + ], + "categories": [ + { + "name": "Fan", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2023-01-12T23:02:44.917Z", + "parentDeviceId": "6a2dd7a4-dd77-48bc-9acf-017029aaf099", + "profile": { + "id": "6372cd27-93c7-32ef-9be5-aef2221adff1" + }, + "type": "ZWAVE", + "restrictionTier": 0, + "allowed": [], + "executionContext": "LOCAL" + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/ge_in_wall_smart_dimmer.json b/tests/components/smartthings/fixtures/devices/ge_in_wall_smart_dimmer.json new file mode 100644 index 00000000000..910eacec2cc --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/ge_in_wall_smart_dimmer.json @@ -0,0 +1,65 @@ +{ + "items": [ + { + "deviceId": "aaedaf28-2ae0-4c1d-b57e-87f6a420c298", + "name": "GE Dimmer Switch", + "label": "Basement Exit Light", + "manufacturerName": "SmartThingsCommunity", + "presentationId": "31cf01ee-cb49-3d95-ac2d-2afab47f25c7", + "deviceManufacturerCode": "0063-4944-3130", + "locationId": "c4d3b2a1-09f8-765e-4d3c-2b1a09f8e7d6 ", + "roomId": "e73dcd00-6953-431d-ae79-73fd2f2c528e", + "components": [ + { + "id": "main", + "label": "Basement Exit Light", + "capabilities": [ + { + "id": "switch", + "version": 1 + }, + { + "id": "switchLevel", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + } + ], + "categories": [ + { + "name": "Switch", + "categoryType": "manufacturer" + }, + { + "name": "Switch", + "categoryType": "user" + } + ] + } + ], + "createTime": "2020-05-25T18:18:01Z", + "parentDeviceId": "074fa784-8be8-4c70-8e22-6f5ed6f81b7e", + "profile": { + "id": "ec5458c2-c011-3479-a59b-82b42820c2f7" + }, + "zwave": { + "networkId": "14", + "driverId": "2cbf55e3-dbc2-48a2-8be5-4c3ce756b692", + "executingLocally": true, + "hubId": "074fa784-8be8-4c70-8e22-6f5ed6f81b7e", + "networkSecurityLevel": "ZWAVE_LEGACY_NON_SECURE", + "provisioningState": "NONFUNCTIONAL", + "manufacturerId": 99, + "productType": 18756, + "productId": 12592 + }, + "type": "ZWAVE", + "restrictionTier": 0, + "allowed": [], + "executionContext": "LOCAL" + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/hue_color_temperature_bulb.json b/tests/components/smartthings/fixtures/devices/hue_color_temperature_bulb.json new file mode 100644 index 00000000000..7f729001453 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/hue_color_temperature_bulb.json @@ -0,0 +1,73 @@ +{ + "items": [ + { + "deviceId": "440063de-a200-40b5-8a6b-f3399eaa0370", + "name": "hue-color-temperature-bulb", + "label": "Bathroom spot", + "manufacturerName": "0A2r", + "presentationId": "ST_b93bec0e-1a81-4471-83fc-4dddca504acd", + "deviceManufacturerCode": "Signify Netherlands B.V.", + "locationId": "88a3a314-f0c8-40b4-bb44-44ba06c9c42f", + "ownerId": "12d4af93-cb68-b108-87f5-625437d7371f", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "colorTemperature", + "version": 1 + }, + { + "id": "switch", + "version": 1 + }, + { + "id": "switchLevel", + "version": 1 + }, + { + "id": "healthCheck", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "synthetic.lightingEffectCircadian", + "version": 1 + }, + { + "id": "synthetic.lightingEffectFade", + "version": 1 + } + ], + "categories": [ + { + "name": "Light", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2023-12-17T18:11:41.453Z", + "profile": { + "id": "a79e4507-ecaa-3c7e-b660-a3a71f30eafb" + }, + "viper": { + "uniqueIdentifier": "ea409b82a6184ad9b49bd6318692cc1c", + "manufacturerName": "Signify Netherlands B.V.", + "modelName": "Hue ambiance spot", + "swVersion": "1.122.2", + "hwVersion": "LTG002", + "endpointAppId": "viper_71ee45b0-a794-11e9-86b2-fdd6b9f75ce6" + }, + "type": "VIPER", + "restrictionTier": 0, + "allowed": [], + "executionContext": "CLOUD" + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/hue_rgbw_color_bulb.json b/tests/components/smartthings/fixtures/devices/hue_rgbw_color_bulb.json new file mode 100644 index 00000000000..eeca03fec01 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/hue_rgbw_color_bulb.json @@ -0,0 +1,81 @@ +{ + "items": [ + { + "deviceId": "cb958955-b015-498c-9e62-fc0c51abd054", + "name": "hue-rgbw-color-bulb", + "label": "Standing light", + "manufacturerName": "0A2r", + "presentationId": "ST_2733b8dc-4b0f-4593-8e49-2432202abd52", + "deviceManufacturerCode": "Signify Netherlands B.V.", + "locationId": "88a3a314-f0c8-40b4-bb44-44ba06c9c42f", + "ownerId": "12d4af93-cb68-b108-87f5-625437d7371f", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "switch", + "version": 1 + }, + { + "id": "colorControl", + "version": 1 + }, + { + "id": "colorTemperature", + "version": 1 + }, + { + "id": "switchLevel", + "version": 1 + }, + { + "id": "healthCheck", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "samsungim.hueSyncMode", + "version": 1 + }, + { + "id": "synthetic.lightingEffectCircadian", + "version": 1 + }, + { + "id": "synthetic.lightingEffectFade", + "version": 1 + } + ], + "categories": [ + { + "name": "Light", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2023-12-17T18:11:41.454Z", + "profile": { + "id": "71be1b96-c5b5-38f7-a22c-65f5392ce7ed" + }, + "viper": { + "uniqueIdentifier": "f5f891a57b9d45408230b4228bdc2111", + "manufacturerName": "Signify Netherlands B.V.", + "modelName": "Hue color lamp", + "swVersion": "1.122.2", + "hwVersion": "LCA001", + "endpointAppId": "viper_71ee45b0-a794-11e9-86b2-fdd6b9f75ce6" + }, + "type": "VIPER", + "restrictionTier": 0, + "allowed": [], + "executionContext": "CLOUD" + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/iphone.json b/tests/components/smartthings/fixtures/devices/iphone.json new file mode 100644 index 00000000000..3fc26307c90 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/iphone.json @@ -0,0 +1,41 @@ +{ + "items": [ + { + "deviceId": "184c67cc-69e2-44b6-8f73-55c963068ad9", + "name": "iPhone", + "label": "iPhone", + "manufacturerName": "SmartThings", + "presentationId": "SmartThings-smartthings-Mobile_Presence", + "locationId": "c4d3b2a1-09f8-765e-4d3c-2b1a09f8e7d6 ", + "ownerId": "d47f2b19-3a6e-4c8d-bf21-9e8a7c5d134e", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "presenceSensor", + "version": 1 + } + ], + "categories": [ + { + "name": "MobilePresence", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2021-12-02T16:14:24.394Z", + "parentDeviceId": "b8e11599-5297-4574-8e62-885995fcaa20", + "profile": { + "id": "21d0f660-98b4-3f7b-8114-fe62e555628e" + }, + "type": "MOBILE", + "restrictionTier": 0, + "allowed": [], + "executionContext": "CLOUD" + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/multipurpose_sensor.json b/tests/components/smartthings/fixtures/devices/multipurpose_sensor.json new file mode 100644 index 00000000000..3770614a366 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/multipurpose_sensor.json @@ -0,0 +1,78 @@ +{ + "items": [ + { + "deviceId": "7d246592-93db-4d72-a10d-5a51793ece8c", + "name": "Multipurpose Sensor", + "label": "Deck Door", + "manufacturerName": "SmartThingsCommunity", + "presentationId": "c385e2bc-acb8-317b-be2a-6efd1f879720", + "deviceManufacturerCode": "SmartThings", + "locationId": "c4d3b2a1-09f8-765e-4d3c-2b1a09f8e7d6 ", + "roomId": "b277a3c0-b8fe-44de-9133-c1108747810c", + "components": [ + { + "id": "main", + "label": "Deck Door", + "capabilities": [ + { + "id": "contactSensor", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "threeAxis", + "version": 1 + }, + { + "id": "accelerationSensor", + "version": 1 + }, + { + "id": "battery", + "version": 1 + }, + { + "id": "firmwareUpdate", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + } + ], + "categories": [ + { + "name": "MultiFunctionalSensor", + "categoryType": "manufacturer" + }, + { + "name": "Door", + "categoryType": "user" + } + ] + } + ], + "createTime": "2019-02-23T16:53:57Z", + "parentDeviceId": "074fa784-8be8-4c70-8e22-6f5ed6f81b7e", + "profile": { + "id": "4471213f-121b-38fd-b022-51df37ac1d4c" + }, + "zigbee": { + "eui": "24FD5B00010AED6B", + "networkId": "C972", + "driverId": "408981c2-91d4-4dfc-bbfb-84ca0205d993", + "executingLocally": true, + "hubId": "074fa784-8be8-4c70-8e22-6f5ed6f81b7e", + "provisioningState": "PROVISIONED" + }, + "type": "ZIGBEE", + "restrictionTier": 0, + "allowed": [], + "executionContext": "LOCAL" + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/sensibo_airconditioner_1.json b/tests/components/smartthings/fixtures/devices/sensibo_airconditioner_1.json new file mode 100644 index 00000000000..ae6596755a3 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/sensibo_airconditioner_1.json @@ -0,0 +1,64 @@ +{ + "items": [ + { + "deviceId": "bf4b1167-48a3-4af7-9186-0900a678ffa5", + "name": "sensibo-airconditioner-1", + "label": "Office", + "manufacturerName": "0ABU", + "presentationId": "sensibo-airconditioner-1", + "deviceManufacturerCode": "Sensibo", + "locationId": "fe14085e-bacb-4997-bc0c-df08204eaea2", + "ownerId": "49228038-22ca-1c78-d7ab-b774b4569480", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "switch", + "version": 1 + }, + { + "id": "thermostatCoolingSetpoint", + "version": 1 + }, + { + "id": "airConditionerMode", + "version": 1 + }, + { + "id": "healthCheck", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + } + ], + "categories": [ + { + "name": "AirConditioner", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2024-12-04T10:10:02.873Z", + "profile": { + "id": "ddaffb28-8ebb-4bd6-9d6f-57c28dcb434d" + }, + "viper": { + "manufacturerName": "Sensibo", + "modelName": "skyplus", + "swVersion": "SKY40147", + "hwVersion": "SKY40147", + "endpointAppId": "viper_5661d200-806e-11e9-abe0-3b2f83c8954c" + }, + "type": "VIPER", + "restrictionTier": 0, + "allowed": [], + "executionContext": "CLOUD" + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/smart_plug.json b/tests/components/smartthings/fixtures/devices/smart_plug.json new file mode 100644 index 00000000000..24d0fbc6e84 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/smart_plug.json @@ -0,0 +1,59 @@ +{ + "items": [ + { + "deviceId": "550a1c72-65a0-4d55-b97b-75168e055398", + "name": "SYLVANIA SMART+ Smart Plug", + "label": "Arlo Beta Basestation", + "manufacturerName": "SmartThingsCommunity", + "presentationId": "28127039-043b-3df0-adf2-7541403dc4c1", + "deviceManufacturerCode": "LEDVANCE", + "locationId": "c4d3b2a1-09f8-765e-4d3c-2b1a09f8e7d6 ", + "ownerId": "d47f2b19-3a6e-4c8d-bf21-9e8a7c5d134e", + "roomId": "94be4a1e-382a-4b7f-a5ef-fdb1a7d9f9e6", + "components": [ + { + "id": "main", + "label": "Pi Hole", + "capabilities": [ + { + "id": "switch", + "version": 1 + }, + { + "id": "firmwareUpdate", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + } + ], + "categories": [ + { + "name": "Switch", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2018-10-05T12:23:14Z", + "parentDeviceId": "074fa784-8be8-4c70-8e22-6f5ed6f81b7e", + "profile": { + "id": "daeff874-075a-32e3-8b11-bdb99d8e67c7" + }, + "zigbee": { + "eui": "F0D1B80000051E05", + "networkId": "801E", + "driverId": "f2e891c6-00cc-446c-9192-8ebda63d9898", + "executingLocally": true, + "hubId": "074fa784-8be8-4c70-8e22-6f5ed6f81b7e", + "provisioningState": "PROVISIONED" + }, + "type": "ZIGBEE", + "restrictionTier": 0, + "allowed": [], + "executionContext": "LOCAL" + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/sonos_player.json b/tests/components/smartthings/fixtures/devices/sonos_player.json new file mode 100644 index 00000000000..67d1ef24cf9 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/sonos_player.json @@ -0,0 +1,82 @@ +{ + "items": [ + { + "deviceId": "c85fced9-c474-4a47-93c2-037cc7829536", + "name": "sonos-player", + "label": "Elliots Rum", + "manufacturerName": "SmartThingsCommunity", + "presentationId": "ef0a871d-9ed1-377d-8746-0da1dfd50598", + "deviceManufacturerCode": "Sonos", + "locationId": "eed0e167-e793-459b-80cb-a0b02e2b86c2", + "ownerId": "2c69cc36-85ae-c41a-9981-a4ee96cd9137", + "roomId": "105e6d1a-52a4-4797-a235-5a48d7d433c8", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "mediaPlayback", + "version": 1 + }, + { + "id": "mediaGroup", + "version": 1 + }, + { + "id": "mediaPresets", + "version": 1 + }, + { + "id": "mediaTrackControl", + "version": 1 + }, + { + "id": "audioMute", + "version": 1 + }, + { + "id": "audioNotification", + "version": 1 + }, + { + "id": "audioTrackData", + "version": 1 + }, + { + "id": "audioVolume", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + } + ], + "categories": [ + { + "name": "Speaker", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2025-02-02T13:18:28.570Z", + "parentDeviceId": "2f7f7d2b-e683-48ae-86f7-e57df6a0bce2", + "profile": { + "id": "0443d359-3f76-383f-82a4-6fc4a879ef1d" + }, + "lan": { + "networkId": "38420B9108F6", + "driverId": "c21a6c77-872c-474e-be5b-5f6f11a240ef", + "executingLocally": true, + "hubId": "2f7f7d2b-e683-48ae-86f7-e57df6a0bce2", + "provisioningState": "TYPED" + }, + "type": "LAN", + "restrictionTier": 0, + "allowed": [], + "executionContext": "LOCAL" + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/vd_network_audio_002s.json b/tests/components/smartthings/fixtures/devices/vd_network_audio_002s.json new file mode 100644 index 00000000000..7fb07533810 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/vd_network_audio_002s.json @@ -0,0 +1,109 @@ +{ + "items": [ + { + "deviceId": "0d94e5db-8501-2355-eb4f-214163702cac", + "name": "Soundbar", + "label": "Soundbar Living", + "manufacturerName": "Samsung Electronics", + "presentationId": "VD-NetworkAudio-002S", + "deviceManufacturerCode": "Samsung Electronics", + "locationId": "c4189ac1-208f-461a-8ab6-ea67937b3743", + "ownerId": "85ea07e1-7063-f673-3ba5-125293f297c8", + "roomId": "db506ec3-83b1-4125-9c4c-eb597da5db6a", + "deviceTypeName": "Samsung OCF Network Audio Player", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "ocf", + "version": 1 + }, + { + "id": "execute", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "switch", + "version": 1 + }, + { + "id": "audioVolume", + "version": 1 + }, + { + "id": "audioMute", + "version": 1 + }, + { + "id": "audioTrackData", + "version": 1 + }, + { + "id": "samsungvd.audioInputSource", + "version": 1 + }, + { + "id": "mediaPlayback", + "version": 1 + }, + { + "id": "audioNotification", + "version": 1 + }, + { + "id": "samsungvd.soundFrom", + "version": 1 + }, + { + "id": "samsungvd.thingStatus", + "version": 1 + }, + { + "id": "samsungvd.audioGroupInfo", + "version": 1, + "ephemeral": true + } + ], + "categories": [ + { + "name": "NetworkAudio", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2024-10-26T02:58:40.549Z", + "profile": { + "id": "3a714028-20ea-3feb-9891-46092132c737" + }, + "ocf": { + "ocfDeviceType": "oic.d.networkaudio", + "name": "Soundbar Living", + "specVersion": "core.1.1.0", + "verticalDomainSpecVersion": "res.1.1.0,sh.1.1.0", + "manufacturerName": "Samsung Electronics", + "modelNumber": "HW-Q990C", + "platformVersion": "7.0", + "platformOS": "Tizen", + "hwVersion": "", + "firmwareVersion": "SAT-iMX8M23WWC-1010.5", + "vendorId": "VD-NetworkAudio-002S", + "vendorResourceClientServerVersion": "3.2.41", + "lastSignupTime": "2024-10-26T02:58:36.491256384Z", + "transferCandidate": false, + "additionalAuthCodeRequired": false + }, + "type": "OCF", + "restrictionTier": 0, + "allowed": [], + "executionContext": "CLOUD" + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/vd_stv_2017_k.json b/tests/components/smartthings/fixtures/devices/vd_stv_2017_k.json new file mode 100644 index 00000000000..3c22a214495 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/vd_stv_2017_k.json @@ -0,0 +1,148 @@ +{ + "items": [ + { + "deviceId": "4588d2d9-a8cf-40f4-9a0b-ed5dfbaccda1", + "name": "[TV] Samsung 8 Series (49)", + "label": "[TV] Samsung 8 Series (49)", + "manufacturerName": "Samsung Electronics", + "presentationId": "VD-STV_2017_K", + "deviceManufacturerCode": "Samsung Electronics", + "locationId": "c4d3b2a1-09f8-765e-4d3c-2b1a09f8e7d6 ", + "ownerId": "d47f2b19-3a6e-4c8d-bf21-9e8a7c5d134e", + "roomId": "94be4a1e-382a-4b7f-a5ef-fdb1a7d9f9e6", + "deviceTypeName": "Samsung OCF TV", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "ocf", + "version": 1 + }, + { + "id": "switch", + "version": 1 + }, + { + "id": "audioVolume", + "version": 1 + }, + { + "id": "audioMute", + "version": 1 + }, + { + "id": "tvChannel", + "version": 1 + }, + { + "id": "mediaInputSource", + "version": 1 + }, + { + "id": "mediaPlayback", + "version": 1 + }, + { + "id": "mediaTrackControl", + "version": 1 + }, + { + "id": "custom.error", + "version": 1 + }, + { + "id": "custom.picturemode", + "version": 1 + }, + { + "id": "custom.soundmode", + "version": 1 + }, + { + "id": "custom.accessibility", + "version": 1 + }, + { + "id": "custom.launchapp", + "version": 1 + }, + { + "id": "custom.recording", + "version": 1 + }, + { + "id": "custom.tvsearch", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + }, + { + "id": "samsungvd.ambient", + "version": 1 + }, + { + "id": "samsungvd.ambientContent", + "version": 1 + }, + { + "id": "samsungvd.mediaInputSource", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "execute", + "version": 1 + }, + { + "id": "samsungvd.firmwareVersion", + "version": 1 + }, + { + "id": "samsungvd.supportsPowerOnByOcf", + "version": 1 + } + ], + "categories": [ + { + "name": "Television", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2020-05-07T02:58:10Z", + "profile": { + "id": "bac5c673-8eea-3d00-b1d2-283b46539017" + }, + "ocf": { + "ocfDeviceType": "oic.d.tv", + "name": "[TV] Samsung 8 Series (49)", + "specVersion": "core.1.1.0", + "verticalDomainSpecVersion": "res.1.1.0,sh.1.1.0", + "manufacturerName": "Samsung Electronics", + "modelNumber": "UN49MU8000", + "platformVersion": "Tizen 3.0", + "platformOS": "4.1.10", + "hwVersion": "0-0", + "firmwareVersion": "T-KTMAKUC-1290.3", + "vendorId": "VD-STV_2017_K", + "locale": "en_US", + "lastSignupTime": "2021-08-21T18:52:56.748359Z", + "transferCandidate": false, + "additionalAuthCodeRequired": false + }, + "type": "OCF", + "restrictionTier": 0, + "allowed": [], + "executionContext": "CLOUD" + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/virtual_thermostat.json b/tests/components/smartthings/fixtures/devices/virtual_thermostat.json new file mode 100644 index 00000000000..d5bf3b32a0c --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/virtual_thermostat.json @@ -0,0 +1,69 @@ +{ + "items": [ + { + "deviceId": "2894dc93-0f11-49cc-8a81-3a684cebebf6", + "name": "asd", + "label": "asd", + "manufacturerName": "SmartThingsCommunity", + "presentationId": "78906115-bf23-3c43-9cd6-f42ca3d5517a", + "locationId": "88a3a314-f0c8-40b4-bb44-44ba06c9c42f", + "ownerId": "12d4af93-cb68-b108-87f5-625437d7371f", + "roomId": "58826afc-9f38-426a-b868-dc94776286e3", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "thermostatHeatingSetpoint", + "version": 1 + }, + { + "id": "thermostatCoolingSetpoint", + "version": 1 + }, + { + "id": "thermostatOperatingState", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "thermostatMode", + "version": 1 + }, + { + "id": "thermostatFanMode", + "version": 1 + }, + { + "id": "battery", + "version": 1 + } + ], + "categories": [ + { + "name": "Thermostat", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2025-02-10T22:04:56.174Z", + "profile": { + "id": "e921d7f2-5851-363d-89d5-5e83f5ab44c6" + }, + "virtual": { + "name": "asd", + "executingLocally": false + }, + "type": "VIRTUAL", + "restrictionTier": 0, + "allowed": [], + "executionContext": "CLOUD" + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/virtual_valve.json b/tests/components/smartthings/fixtures/devices/virtual_valve.json new file mode 100644 index 00000000000..1988617afad --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/virtual_valve.json @@ -0,0 +1,49 @@ +{ + "items": [ + { + "deviceId": "612ab3c2-3bb0-48f7-b2c0-15b169cb2fc3", + "name": "volvo", + "label": "volvo", + "manufacturerName": "SmartThingsCommunity", + "presentationId": "916408b6-c94e-38b8-9fbf-03c8a48af5c3", + "locationId": "88a3a314-f0c8-40b4-bb44-44ba06c9c42f", + "ownerId": "12d4af93-cb68-b108-87f5-625437d7371f", + "roomId": "58826afc-9f38-426a-b868-dc94776286e3", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "valve", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + } + ], + "categories": [ + { + "name": "WaterValve", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2025-02-11T11:27:02.052Z", + "profile": { + "id": "f8e25992-7f5d-31da-b04d-497012590113" + }, + "virtual": { + "name": "volvo", + "executingLocally": false + }, + "type": "VIRTUAL", + "restrictionTier": 0, + "allowed": [], + "executionContext": "CLOUD" + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/virtual_water_sensor.json b/tests/components/smartthings/fixtures/devices/virtual_water_sensor.json new file mode 100644 index 00000000000..ad3a45a0481 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/virtual_water_sensor.json @@ -0,0 +1,53 @@ +{ + "items": [ + { + "deviceId": "a2a6018b-2663-4727-9d1d-8f56953b5116", + "name": "asd", + "label": "asd", + "manufacturerName": "SmartThingsCommunity", + "presentationId": "838ae989-b832-3610-968c-2940491600f6", + "locationId": "88a3a314-f0c8-40b4-bb44-44ba06c9c42f", + "ownerId": "12d4af93-cb68-b108-87f5-625437d7371f", + "roomId": "58826afc-9f38-426a-b868-dc94776286e3", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "waterSensor", + "version": 1 + }, + { + "id": "battery", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + } + ], + "categories": [ + { + "name": "LeakSensor", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2025-02-10T21:58:18.688Z", + "profile": { + "id": "39230a95-d42d-34d4-a33c-f79573495a30" + }, + "virtual": { + "name": "asd", + "executingLocally": false + }, + "type": "VIRTUAL", + "restrictionTier": 0, + "allowed": [], + "executionContext": "CLOUD" + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/yale_push_button_deadbolt_lock.json b/tests/components/smartthings/fixtures/devices/yale_push_button_deadbolt_lock.json new file mode 100644 index 00000000000..e83a1be7644 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/yale_push_button_deadbolt_lock.json @@ -0,0 +1,67 @@ +{ + "items": [ + { + "deviceId": "a9f587c5-5d8b-4273-8907-e7f609af5158", + "name": "Yale Push Button Deadbolt Lock", + "label": "Basement Door Lock", + "manufacturerName": "SmartThingsCommunity", + "presentationId": "45f9424f-4e20-34b0-abb6-5f26b189acb0", + "deviceManufacturerCode": "Yale", + "locationId": "c4d3b2a1-09f8-765e-4d3c-2b1a09f8e7d6 ", + "ownerId": "d47f2b19-3a6e-4c8d-bf21-9e8a7c5d134e", + "roomId": "94be4a1e-382a-4b7f-a5ef-fdb1a7d9f9e6", + "components": [ + { + "id": "main", + "label": "Basement Door Lock", + "capabilities": [ + { + "id": "lock", + "version": 1 + }, + { + "id": "lockCodes", + "version": 1 + }, + { + "id": "battery", + "version": 1 + }, + { + "id": "firmwareUpdate", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + } + ], + "categories": [ + { + "name": "SmartLock", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2016-11-18T23:01:19Z", + "parentDeviceId": "074fa784-8be8-4c70-8e22-6f5ed6f81b7e", + "profile": { + "id": "51b76691-3c3a-3fce-8c7c-4f9d50e5885a" + }, + "zigbee": { + "eui": "000D6F0002FB6E24", + "networkId": "C771", + "driverId": "ce930ffd-8155-4dca-aaa9-6c4158fc4278", + "executingLocally": true, + "hubId": "074fa784-8be8-4c70-8e22-6f5ed6f81b7e", + "provisioningState": "PROVISIONED" + }, + "type": "ZIGBEE", + "restrictionTier": 0, + "allowed": [], + "executionContext": "LOCAL" + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/locations.json b/tests/components/smartthings/fixtures/locations.json new file mode 100644 index 00000000000..abfa17dc4b7 --- /dev/null +++ b/tests/components/smartthings/fixtures/locations.json @@ -0,0 +1,9 @@ +{ + "items": [ + { + "locationId": "397678e5-9995-4a39-9d9f-ae6ba310236c", + "name": "Home" + } + ], + "_links": null +} diff --git a/tests/components/smartthings/fixtures/scenes.json b/tests/components/smartthings/fixtures/scenes.json new file mode 100644 index 00000000000..aa4f1aaa3d1 --- /dev/null +++ b/tests/components/smartthings/fixtures/scenes.json @@ -0,0 +1,34 @@ +{ + "items": [ + { + "sceneId": "743b0f37-89b8-476c-aedf-eea8ad8cd29d", + "sceneName": "Away", + "sceneIcon": "203", + "sceneColor": null, + "locationId": "88a3a314-f0c8-40b4-bb44-44ba06c9c42f", + "createdBy": "12d4af93-cb68-b108-87f5-625437d7371f", + "createdDate": 1738964737000, + "lastUpdatedDate": 1738964737000, + "lastExecutedDate": null, + "editable": false, + "apiVersion": "20200501" + }, + { + "sceneId": "f3341e8b-9b32-4509-af2e-4f7c952e98ba", + "sceneName": "Home", + "sceneIcon": "204", + "sceneColor": null, + "locationId": "88a3a314-f0c8-40b4-bb44-44ba06c9c42f", + "createdBy": "12d4af93-cb68-b108-87f5-625437d7371f", + "createdDate": 1738964731000, + "lastUpdatedDate": 1738964731000, + "lastExecutedDate": null, + "editable": false, + "apiVersion": "20200501" + } + ], + "_links": { + "next": null, + "previous": null + } +} diff --git a/tests/components/smartthings/snapshots/test_binary_sensor.ambr b/tests/components/smartthings/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000..1317c19edd7 --- /dev/null +++ b/tests/components/smartthings/snapshots/test_binary_sensor.ambr @@ -0,0 +1,529 @@ +# serializer version: 1 +# name: test_all_entities[c2c_arlo_pro_3_switch][binary_sensor.2nd_floor_hallway_motion-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.2nd_floor_hallway_motion', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': '2nd Floor Hallway motion', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '10e06a70-ee7d-4832-85e9-a0a06a7a05bd.motion', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[c2c_arlo_pro_3_switch][binary_sensor.2nd_floor_hallway_motion-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'motion', + 'friendly_name': '2nd Floor Hallway motion', + }), + 'context': , + 'entity_id': 'binary_sensor.2nd_floor_hallway_motion', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[c2c_arlo_pro_3_switch][binary_sensor.2nd_floor_hallway_sound-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.2nd_floor_hallway_sound', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': '2nd Floor Hallway sound', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '10e06a70-ee7d-4832-85e9-a0a06a7a05bd.sound', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[c2c_arlo_pro_3_switch][binary_sensor.2nd_floor_hallway_sound-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'sound', + 'friendly_name': '2nd Floor Hallway sound', + }), + 'context': , + 'entity_id': 'binary_sensor.2nd_floor_hallway_sound', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[contact_sensor][binary_sensor.front_door_open_closed_sensor_contact-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.front_door_open_closed_sensor_contact', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': '.Front Door Open/Closed Sensor contact', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '2d9a892b-1c93-45a5-84cb-0e81889498c6.contact', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[contact_sensor][binary_sensor.front_door_open_closed_sensor_contact-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': '.Front Door Open/Closed Sensor contact', + }), + 'context': , + 'entity_id': 'binary_sensor.front_door_open_closed_sensor_contact', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[da_ref_normal_000001][binary_sensor.refrigerator_contact-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.refrigerator_contact', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Refrigerator contact', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '7db87911-7dce-1cf2-7119-b953432a2f09.contact', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ref_normal_000001][binary_sensor.refrigerator_contact-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Refrigerator contact', + }), + 'context': , + 'entity_id': 'binary_sensor.refrigerator_contact', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[ecobee_sensor][binary_sensor.child_bedroom_motion-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.child_bedroom_motion', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Child Bedroom motion', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'd5dc3299-c266-41c7-bd08-f540aea54b89.motion', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[ecobee_sensor][binary_sensor.child_bedroom_motion-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'motion', + 'friendly_name': 'Child Bedroom motion', + }), + 'context': , + 'entity_id': 'binary_sensor.child_bedroom_motion', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[ecobee_sensor][binary_sensor.child_bedroom_presence-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.child_bedroom_presence', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Child Bedroom presence', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'd5dc3299-c266-41c7-bd08-f540aea54b89.presence', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[ecobee_sensor][binary_sensor.child_bedroom_presence-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'presence', + 'friendly_name': 'Child Bedroom presence', + }), + 'context': , + 'entity_id': 'binary_sensor.child_bedroom_presence', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[iphone][binary_sensor.iphone_presence-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.iphone_presence', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'iPhone presence', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '184c67cc-69e2-44b6-8f73-55c963068ad9.presence', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[iphone][binary_sensor.iphone_presence-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'presence', + 'friendly_name': 'iPhone presence', + }), + 'context': , + 'entity_id': 'binary_sensor.iphone_presence', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[multipurpose_sensor][binary_sensor.deck_door_acceleration-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.deck_door_acceleration', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Deck Door acceleration', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '7d246592-93db-4d72-a10d-5a51793ece8c.acceleration', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[multipurpose_sensor][binary_sensor.deck_door_acceleration-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'moving', + 'friendly_name': 'Deck Door acceleration', + }), + 'context': , + 'entity_id': 'binary_sensor.deck_door_acceleration', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[multipurpose_sensor][binary_sensor.deck_door_contact-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.deck_door_contact', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Deck Door contact', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '7d246592-93db-4d72-a10d-5a51793ece8c.contact', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[multipurpose_sensor][binary_sensor.deck_door_contact-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Deck Door contact', + }), + 'context': , + 'entity_id': 'binary_sensor.deck_door_contact', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[virtual_valve][binary_sensor.volvo_valve-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.volvo_valve', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'volvo valve', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '612ab3c2-3bb0-48f7-b2c0-15b169cb2fc3.valve', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[virtual_valve][binary_sensor.volvo_valve-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'opening', + 'friendly_name': 'volvo valve', + }), + 'context': , + 'entity_id': 'binary_sensor.volvo_valve', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[virtual_water_sensor][binary_sensor.asd_water-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.asd_water', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'asd water', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'a2a6018b-2663-4727-9d1d-8f56953b5116.water', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[virtual_water_sensor][binary_sensor.asd_water-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'moisture', + 'friendly_name': 'asd water', + }), + 'context': , + 'entity_id': 'binary_sensor.asd_water', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/smartthings/snapshots/test_climate.ambr b/tests/components/smartthings/snapshots/test_climate.ambr new file mode 100644 index 00000000000..bd76637cfb7 --- /dev/null +++ b/tests/components/smartthings/snapshots/test_climate.ambr @@ -0,0 +1,356 @@ +# serializer version: 1 +# name: test_all_entities[da_ac_rac_000001][climate.ac_office_granit-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'fan_modes': list([ + 'auto', + 'low', + 'medium', + 'high', + 'turbo', + ]), + 'hvac_modes': list([ + , + , + , + , + , + , + ]), + 'max_temp': 35, + 'min_temp': 7, + 'preset_modes': list([ + 'windFree', + ]), + 'swing_modes': None, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.ac_office_granit', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'AC Office Granit', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ac_rac_000001][climate.ac_office_granit-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 25, + 'drlc_status_duration': 0, + 'drlc_status_level': -1, + 'drlc_status_override': False, + 'drlc_status_start': '1970-01-01T00:00:00Z', + 'fan_mode': 'low', + 'fan_modes': list([ + 'auto', + 'low', + 'medium', + 'high', + 'turbo', + ]), + 'friendly_name': 'AC Office Granit', + 'hvac_modes': list([ + , + , + , + , + , + , + ]), + 'max_temp': 35, + 'min_temp': 7, + 'preset_mode': None, + 'preset_modes': list([ + 'windFree', + ]), + 'supported_features': , + 'swing_mode': 'off', + 'swing_modes': None, + 'temperature': 25, + }), + 'context': , + 'entity_id': 'climate.ac_office_granit', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[da_ac_rac_01001][climate.aire_dormitorio_principal-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'fan_modes': list([ + 'auto', + 'low', + 'medium', + 'high', + 'turbo', + ]), + 'hvac_modes': list([ + , + , + , + , + , + , + ]), + 'max_temp': 35, + 'min_temp': 7, + 'preset_modes': list([ + 'windFree', + ]), + 'swing_modes': list([ + 'off', + 'vertical', + 'horizontal', + 'both', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.aire_dormitorio_principal', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Aire Dormitorio Principal', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ac_rac_01001][climate.aire_dormitorio_principal-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 27, + 'drlc_status_duration': 0, + 'drlc_status_level': 0, + 'drlc_status_override': False, + 'drlc_status_start': '1970-01-01T00:00:00Z', + 'fan_mode': 'high', + 'fan_modes': list([ + 'auto', + 'low', + 'medium', + 'high', + 'turbo', + ]), + 'friendly_name': 'Aire Dormitorio Principal', + 'hvac_modes': list([ + , + , + , + , + , + , + ]), + 'max_temp': 35, + 'min_temp': 7, + 'preset_mode': None, + 'preset_modes': list([ + 'windFree', + ]), + 'supported_features': , + 'swing_mode': 'off', + 'swing_modes': list([ + 'off', + 'vertical', + 'horizontal', + 'both', + ]), + 'temperature': 23, + }), + 'context': , + 'entity_id': 'climate.aire_dormitorio_principal', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[ecobee_thermostat][climate.main_floor-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'fan_modes': list([ + 'on', + 'auto', + ]), + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 35.0, + 'min_temp': 7.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.main_floor', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Main Floor', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '028469cb-6e89-4f14-8d9a-bfbca5e0fbfc', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[ecobee_thermostat][climate.main_floor-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_humidity': 32, + 'current_temperature': 21.7, + 'fan_mode': 'auto', + 'fan_modes': list([ + 'on', + 'auto', + ]), + 'friendly_name': 'Main Floor', + 'hvac_action': , + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 35.0, + 'min_temp': 7.0, + 'supported_features': , + 'target_temp_high': None, + 'target_temp_low': None, + 'temperature': 21.7, + }), + 'context': , + 'entity_id': 'climate.main_floor', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }) +# --- +# name: test_all_entities[virtual_thermostat][climate.asd-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'fan_modes': list([ + 'on', + ]), + 'hvac_modes': list([ + ]), + 'max_temp': 35.0, + 'min_temp': 7.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.asd', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'asd', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '2894dc93-0f11-49cc-8a81-3a684cebebf6', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[virtual_thermostat][climate.asd-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 4734.6, + 'fan_mode': 'followschedule', + 'fan_modes': list([ + 'on', + ]), + 'friendly_name': 'asd', + 'hvac_action': , + 'hvac_modes': list([ + ]), + 'max_temp': 35.0, + 'min_temp': 7.0, + 'supported_features': , + 'target_temp_high': None, + 'target_temp_low': None, + 'temperature': None, + }), + 'context': , + 'entity_id': 'climate.asd', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/smartthings/snapshots/test_cover.ambr b/tests/components/smartthings/snapshots/test_cover.ambr new file mode 100644 index 00000000000..6283e4fef04 --- /dev/null +++ b/tests/components/smartthings/snapshots/test_cover.ambr @@ -0,0 +1,100 @@ +# serializer version: 1 +# name: test_all_entities[c2c_shade][cover.curtain_1a-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.curtain_1a', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Curtain 1A', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '571af102-15db-4030-b76b-245a691f74a5', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[c2c_shade][cover.curtain_1a-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_position': 100, + 'device_class': 'shade', + 'friendly_name': 'Curtain 1A', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.curtain_1a', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'open', + }) +# --- +# name: test_all_entities[da_ks_microwave_0101x][cover.microwave-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.microwave', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Microwave', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_microwave_0101x][cover.microwave-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Microwave', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.microwave', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/smartthings/snapshots/test_fan.ambr b/tests/components/smartthings/snapshots/test_fan.ambr new file mode 100644 index 00000000000..400ceef8390 --- /dev/null +++ b/tests/components/smartthings/snapshots/test_fan.ambr @@ -0,0 +1,67 @@ +# serializer version: 1 +# name: test_all_entities[fake_fan][fan.fake_fan-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'preset_modes': list([ + 'auto', + 'low', + 'medium', + 'high', + 'turbo', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.fake_fan', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Fake fan', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'f1af21a2-d5a1-437c-b10a-b34a87394b71', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[fake_fan][fan.fake_fan-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Fake fan', + 'percentage': 2000, + 'percentage_step': 33.333333333333336, + 'preset_mode': None, + 'preset_modes': list([ + 'auto', + 'low', + 'medium', + 'high', + 'turbo', + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'fan.fake_fan', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/smartthings/snapshots/test_init.ambr b/tests/components/smartthings/snapshots/test_init.ambr new file mode 100644 index 00000000000..546d99a967f --- /dev/null +++ b/tests/components/smartthings/snapshots/test_init.ambr @@ -0,0 +1,1024 @@ +# serializer version: 1 +# name: test_devices[aeotec_home_energy_meter_gen5] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + 'f0af21a2-d5a1-437c-b10a-b34a87394b71', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': None, + 'model': None, + 'model_id': None, + 'name': 'Aeotec Energy Monitor', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[base_electric_meter] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '68e786a6-7f61-4c3a-9e13-70b803cf782b', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': None, + 'model': None, + 'model_id': None, + 'name': 'Aeon Energy Monitor', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[c2c_arlo_pro_3_switch] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '10e06a70-ee7d-4832-85e9-a0a06a7a05bd', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': None, + 'model': None, + 'model_id': None, + 'name': '2nd Floor Hallway', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[c2c_shade] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '571af102-15db-4030-b76b-245a691f74a5', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': None, + 'model': None, + 'model_id': None, + 'name': 'Curtain 1A', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[centralite] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + 'd0268a69-abfb-4c92-a646-61cec2e510ad', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': None, + 'model': None, + 'model_id': None, + 'name': 'Dimmer Debian', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[contact_sensor] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '2d9a892b-1c93-45a5-84cb-0e81889498c6', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': None, + 'model': None, + 'model_id': None, + 'name': '.Front Door Open/Closed Sensor', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[da_ac_rac_000001] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '1.0', + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '96a5ef74-5832-a84b-f1f7-ca799957065d', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Samsung Electronics', + 'model': 'ARTIK051_KRAC_18K|10193441|60010132001111110200000000000000', + 'model_id': None, + 'name': 'AC Office Granit', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '0.1.0', + 'via_device_id': None, + }) +# --- +# name: test_devices[da_ac_rac_01001] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': 'Realtek', + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '4ece486b-89db-f06a-d54d-748b676b4d8e', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Samsung Electronics', + 'model': 'ARA-WW-TP1-22-COMMON|10229641|60010523001511014600083200800000', + 'model_id': None, + 'name': 'Aire Dormitorio Principal', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': 'ARA-WW-TP1-22-COMMON_11240702', + 'via_device_id': None, + }) +# --- +# name: test_devices[da_ks_microwave_0101x] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': 'MediaTek', + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '2bad3237-4886-e699-1b90-4a51a3d55c8a', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Samsung Electronics', + 'model': 'TP2X_DA-KS-MICROWAVE-0101X|40436241|50040100011411000200000000000000', + 'model_id': None, + 'name': 'Microwave', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': 'AKS-WW-TP2-20-MICROWAVE-OTR_40230125', + 'via_device_id': None, + }) +# --- +# name: test_devices[da_ref_normal_000001] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': 'MediaTek', + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '7db87911-7dce-1cf2-7119-b953432a2f09', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Samsung Electronics', + 'model': 'TP2X_REF_20K|00115641|0004014D011411200103000020000000', + 'model_id': None, + 'name': 'Refrigerator', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': 'A-RFWW-TP2-21-COMMON_20220110', + 'via_device_id': None, + }) +# --- +# name: test_devices[da_rvc_normal_000001] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '1.0', + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '3442dfc6-17c0-a65f-dae0-4c6e01786f44', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Samsung Electronics', + 'model': 'powerbot_7000_17M|50016055|80010404011141000100000000000000', + 'model_id': None, + 'name': 'Robot vacuum', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '1.0', + 'via_device_id': None, + }) +# --- +# name: test_devices[da_wm_dw_000001] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': 'ARTIK051', + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + 'f36dc7ce-cac0-0667-dc14-a3704eb5e676', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Samsung Electronics', + 'model': 'DA_DW_A51_20_COMMON|30007242|40010201001311000101000000000000', + 'model_id': None, + 'name': 'Dishwasher', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': 'DA_DW_A51_20_COMMON_30230714', + 'via_device_id': None, + }) +# --- +# name: test_devices[da_wm_wd_000001] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': 'ARTIK051', + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '02f7256e-8353-5bdd-547f-bd5b1647e01b', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Samsung Electronics', + 'model': 'DA_WM_A51_20_COMMON|20233741|3000000100111100020B000000000000', + 'model_id': None, + 'name': 'Dryer', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': 'DA_WM_A51_20_COMMON_30230708', + 'via_device_id': None, + }) +# --- +# name: test_devices[da_wm_wm_000001] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': 'MediaTek', + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + 'f984b91d-f250-9d42-3436-33f09a422a47', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Samsung Electronics', + 'model': 'DA_WM_TP2_20_COMMON|20233741|2001000100131100022B010000000000', + 'model_id': None, + 'name': 'Washer', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': 'DA_WM_TP2_20_COMMON_30230804', + 'via_device_id': None, + }) +# --- +# name: test_devices[ecobee_sensor] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + 'd5dc3299-c266-41c7-bd08-f540aea54b89', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': None, + 'model': None, + 'model_id': None, + 'name': 'Child Bedroom', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[ecobee_thermostat] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '028469cb-6e89-4f14-8d9a-bfbca5e0fbfc', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': None, + 'model': None, + 'model_id': None, + 'name': 'Main Floor', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[fake_fan] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + 'f1af21a2-d5a1-437c-b10a-b34a87394b71', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': None, + 'model': None, + 'model_id': None, + 'name': 'Fake fan', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[ge_in_wall_smart_dimmer] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + 'aaedaf28-2ae0-4c1d-b57e-87f6a420c298', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': None, + 'model': None, + 'model_id': None, + 'name': 'Basement Exit Light', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[hue_color_temperature_bulb] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '440063de-a200-40b5-8a6b-f3399eaa0370', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': None, + 'model': None, + 'model_id': None, + 'name': 'Bathroom spot', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[hue_rgbw_color_bulb] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + 'cb958955-b015-498c-9e62-fc0c51abd054', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': None, + 'model': None, + 'model_id': None, + 'name': 'Standing light', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[iphone] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '184c67cc-69e2-44b6-8f73-55c963068ad9', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': None, + 'model': None, + 'model_id': None, + 'name': 'iPhone', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[multipurpose_sensor] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '7d246592-93db-4d72-a10d-5a51793ece8c', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': None, + 'model': None, + 'model_id': None, + 'name': 'Deck Door', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[sensibo_airconditioner_1] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + 'bf4b1167-48a3-4af7-9186-0900a678ffa5', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': None, + 'model': None, + 'model_id': None, + 'name': 'Office', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[smart_plug] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '550a1c72-65a0-4d55-b97b-75168e055398', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': None, + 'model': None, + 'model_id': None, + 'name': 'Arlo Beta Basestation', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[sonos_player] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + 'c85fced9-c474-4a47-93c2-037cc7829536', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': None, + 'model': None, + 'model_id': None, + 'name': 'Elliots Rum', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[vd_network_audio_002s] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '0d94e5db-8501-2355-eb4f-214163702cac', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Samsung Electronics', + 'model': 'HW-Q990C', + 'model_id': None, + 'name': 'Soundbar Living', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': 'SAT-iMX8M23WWC-1010.5', + 'via_device_id': None, + }) +# --- +# name: test_devices[vd_stv_2017_k] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '0-0', + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '4588d2d9-a8cf-40f4-9a0b-ed5dfbaccda1', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Samsung Electronics', + 'model': 'UN49MU8000', + 'model_id': None, + 'name': '[TV] Samsung 8 Series (49)', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': 'T-KTMAKUC-1290.3', + 'via_device_id': None, + }) +# --- +# name: test_devices[virtual_thermostat] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '2894dc93-0f11-49cc-8a81-3a684cebebf6', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': None, + 'model': None, + 'model_id': None, + 'name': 'asd', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[virtual_valve] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '612ab3c2-3bb0-48f7-b2c0-15b169cb2fc3', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': None, + 'model': None, + 'model_id': None, + 'name': 'volvo', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[virtual_water_sensor] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + 'a2a6018b-2663-4727-9d1d-8f56953b5116', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': None, + 'model': None, + 'model_id': None, + 'name': 'asd', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[yale_push_button_deadbolt_lock] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + 'a9f587c5-5d8b-4273-8907-e7f609af5158', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': None, + 'model': None, + 'model_id': None, + 'name': 'Basement Door Lock', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- diff --git a/tests/components/smartthings/snapshots/test_light.ambr b/tests/components/smartthings/snapshots/test_light.ambr new file mode 100644 index 00000000000..8e7f424f658 --- /dev/null +++ b/tests/components/smartthings/snapshots/test_light.ambr @@ -0,0 +1,267 @@ +# serializer version: 1 +# name: test_all_entities[centralite][light.dimmer_debian-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.dimmer_debian', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Dimmer Debian', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'd0268a69-abfb-4c92-a646-61cec2e510ad', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[centralite][light.dimmer_debian-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': None, + 'color_mode': None, + 'friendly_name': 'Dimmer Debian', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.dimmer_debian', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[ge_in_wall_smart_dimmer][light.basement_exit_light-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.basement_exit_light', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Basement Exit Light', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'aaedaf28-2ae0-4c1d-b57e-87f6a420c298', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[ge_in_wall_smart_dimmer][light.basement_exit_light-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': None, + 'color_mode': None, + 'friendly_name': 'Basement Exit Light', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.basement_exit_light', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[hue_color_temperature_bulb][light.bathroom_spot-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 9000, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 111, + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.bathroom_spot', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Bathroom spot', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '440063de-a200-40b5-8a6b-f3399eaa0370', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[hue_color_temperature_bulb][light.bathroom_spot-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': 178, + 'color_mode': , + 'color_temp': 333, + 'color_temp_kelvin': 3000, + 'friendly_name': 'Bathroom spot', + 'hs_color': tuple( + 27.825, + 56.895, + ), + 'max_color_temp_kelvin': 9000, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 111, + 'rgb_color': tuple( + 255, + 177, + 110, + ), + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + 'xy_color': tuple( + 0.496, + 0.383, + ), + }), + 'context': , + 'entity_id': 'light.bathroom_spot', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[hue_rgbw_color_bulb][light.standing_light-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 9000, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 111, + 'supported_color_modes': list([ + , + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.standing_light', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Standing light', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'cb958955-b015-498c-9e62-fc0c51abd054', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[hue_rgbw_color_bulb][light.standing_light-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': None, + 'color_mode': None, + 'color_temp': None, + 'color_temp_kelvin': None, + 'friendly_name': 'Standing light', + 'hs_color': None, + 'max_color_temp_kelvin': 9000, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 111, + 'rgb_color': None, + 'supported_color_modes': list([ + , + , + ]), + 'supported_features': , + 'xy_color': None, + }), + 'context': , + 'entity_id': 'light.standing_light', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/smartthings/snapshots/test_lock.ambr b/tests/components/smartthings/snapshots/test_lock.ambr new file mode 100644 index 00000000000..94370f8570b --- /dev/null +++ b/tests/components/smartthings/snapshots/test_lock.ambr @@ -0,0 +1,50 @@ +# serializer version: 1 +# name: test_all_entities[yale_push_button_deadbolt_lock][lock.basement_door_lock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'lock', + 'entity_category': None, + 'entity_id': 'lock.basement_door_lock', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Basement Door Lock', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'a9f587c5-5d8b-4273-8907-e7f609af5158', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[yale_push_button_deadbolt_lock][lock.basement_door_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Basement Door Lock', + 'lock_state': 'locked', + 'supported_features': , + }), + 'context': , + 'entity_id': 'lock.basement_door_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'locked', + }) +# --- diff --git a/tests/components/smartthings/snapshots/test_scene.ambr b/tests/components/smartthings/snapshots/test_scene.ambr new file mode 100644 index 00000000000..fd9abc9fcca --- /dev/null +++ b/tests/components/smartthings/snapshots/test_scene.ambr @@ -0,0 +1,101 @@ +# serializer version: 1 +# name: test_all_entities[scene.away-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'scene', + 'entity_category': None, + 'entity_id': 'scene.away', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Away', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '743b0f37-89b8-476c-aedf-eea8ad8cd29d', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[scene.away-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'color': None, + 'friendly_name': 'Away', + 'icon': '203', + 'location_id': '88a3a314-f0c8-40b4-bb44-44ba06c9c42f', + }), + 'context': , + 'entity_id': 'scene.away', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[scene.home-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'scene', + 'entity_category': None, + 'entity_id': 'scene.home', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Home', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'f3341e8b-9b32-4509-af2e-4f7c952e98ba', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[scene.home-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'color': None, + 'friendly_name': 'Home', + 'icon': '204', + 'location_id': '88a3a314-f0c8-40b4-bb44-44ba06c9c42f', + }), + 'context': , + 'entity_id': 'scene.home', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..92928b9606b --- /dev/null +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -0,0 +1,4857 @@ +# serializer version: 1 +# name: test_all_entities[aeotec_home_energy_meter_gen5][sensor.aeotec_energy_monitor_energy_meter-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.aeotec_energy_monitor_energy_meter', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Aeotec Energy Monitor Energy Meter', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'f0af21a2-d5a1-437c-b10a-b34a87394b71.energy', + 'unit_of_measurement': 'kWh', + }) +# --- +# name: test_all_entities[aeotec_home_energy_meter_gen5][sensor.aeotec_energy_monitor_energy_meter-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Aeotec Energy Monitor Energy Meter', + 'state_class': , + 'unit_of_measurement': 'kWh', + }), + 'context': , + 'entity_id': 'sensor.aeotec_energy_monitor_energy_meter', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '19978.536', + }) +# --- +# name: test_all_entities[aeotec_home_energy_meter_gen5][sensor.aeotec_energy_monitor_power_meter-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.aeotec_energy_monitor_power_meter', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Aeotec Energy Monitor Power Meter', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'f0af21a2-d5a1-437c-b10a-b34a87394b71.power', + 'unit_of_measurement': 'W', + }) +# --- +# name: test_all_entities[aeotec_home_energy_meter_gen5][sensor.aeotec_energy_monitor_power_meter-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Aeotec Energy Monitor Power Meter', + 'state_class': , + 'unit_of_measurement': 'W', + }), + 'context': , + 'entity_id': 'sensor.aeotec_energy_monitor_power_meter', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2859.743', + }) +# --- +# name: test_all_entities[aeotec_home_energy_meter_gen5][sensor.aeotec_energy_monitor_voltage_measurement-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.aeotec_energy_monitor_voltage_measurement', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Aeotec Energy Monitor Voltage Measurement', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'f0af21a2-d5a1-437c-b10a-b34a87394b71.voltage', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[aeotec_home_energy_meter_gen5][sensor.aeotec_energy_monitor_voltage_measurement-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Aeotec Energy Monitor Voltage Measurement', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.aeotec_energy_monitor_voltage_measurement', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[base_electric_meter][sensor.aeon_energy_monitor_energy_meter-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.aeon_energy_monitor_energy_meter', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Aeon Energy Monitor Energy Meter', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '68e786a6-7f61-4c3a-9e13-70b803cf782b.energy', + 'unit_of_measurement': 'kWh', + }) +# --- +# name: test_all_entities[base_electric_meter][sensor.aeon_energy_monitor_energy_meter-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Aeon Energy Monitor Energy Meter', + 'state_class': , + 'unit_of_measurement': 'kWh', + }), + 'context': , + 'entity_id': 'sensor.aeon_energy_monitor_energy_meter', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1930.362', + }) +# --- +# name: test_all_entities[base_electric_meter][sensor.aeon_energy_monitor_power_meter-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.aeon_energy_monitor_power_meter', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Aeon Energy Monitor Power Meter', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '68e786a6-7f61-4c3a-9e13-70b803cf782b.power', + 'unit_of_measurement': 'W', + }) +# --- +# name: test_all_entities[base_electric_meter][sensor.aeon_energy_monitor_power_meter-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Aeon Energy Monitor Power Meter', + 'state_class': , + 'unit_of_measurement': 'W', + }), + 'context': , + 'entity_id': 'sensor.aeon_energy_monitor_power_meter', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '938.3', + }) +# --- +# name: test_all_entities[c2c_arlo_pro_3_switch][sensor.2nd_floor_hallway_alarm-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.2nd_floor_hallway_alarm', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': '2nd Floor Hallway Alarm', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '10e06a70-ee7d-4832-85e9-a0a06a7a05bd.alarm', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[c2c_arlo_pro_3_switch][sensor.2nd_floor_hallway_alarm-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '2nd Floor Hallway Alarm', + }), + 'context': , + 'entity_id': 'sensor.2nd_floor_hallway_alarm', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[c2c_arlo_pro_3_switch][sensor.2nd_floor_hallway_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.2nd_floor_hallway_battery', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': '2nd Floor Hallway Battery', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '10e06a70-ee7d-4832-85e9-a0a06a7a05bd.battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[c2c_arlo_pro_3_switch][sensor.2nd_floor_hallway_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': '2nd Floor Hallway Battery', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.2nd_floor_hallway_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- +# name: test_all_entities[centralite][sensor.dimmer_debian_power_meter-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.dimmer_debian_power_meter', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Dimmer Debian Power Meter', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'd0268a69-abfb-4c92-a646-61cec2e510ad.power', + 'unit_of_measurement': 'W', + }) +# --- +# name: test_all_entities[centralite][sensor.dimmer_debian_power_meter-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Dimmer Debian Power Meter', + 'state_class': , + 'unit_of_measurement': 'W', + }), + 'context': , + 'entity_id': 'sensor.dimmer_debian_power_meter', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[contact_sensor][sensor.front_door_open_closed_sensor_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.front_door_open_closed_sensor_battery', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': '.Front Door Open/Closed Sensor Battery', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '2d9a892b-1c93-45a5-84cb-0e81889498c6.battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[contact_sensor][sensor.front_door_open_closed_sensor_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': '.Front Door Open/Closed Sensor Battery', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.front_door_open_closed_sensor_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- +# name: test_all_entities[contact_sensor][sensor.front_door_open_closed_sensor_temperature_measurement-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.front_door_open_closed_sensor_temperature_measurement', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': '.Front Door Open/Closed Sensor Temperature Measurement', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '2d9a892b-1c93-45a5-84cb-0e81889498c6.temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[contact_sensor][sensor.front_door_open_closed_sensor_temperature_measurement-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': '.Front Door Open/Closed Sensor Temperature Measurement', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.front_door_open_closed_sensor_temperature_measurement', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '15.0', + }) +# --- +# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_air_quality-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ac_office_granit_air_quality', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'AC Office Granit Air Quality', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d.airQuality', + 'unit_of_measurement': 'CAQI', + }) +# --- +# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_air_quality-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'AC Office Granit Air Quality', + 'state_class': , + 'unit_of_measurement': 'CAQI', + }), + 'context': , + 'entity_id': 'sensor.ac_office_granit_air_quality', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_deltaenergy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ac_office_granit_deltaenergy', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'AC Office Granit deltaEnergy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d.deltaEnergy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_deltaenergy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'AC Office Granit deltaEnergy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ac_office_granit_deltaenergy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.4', + }) +# --- +# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_dust_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ac_office_granit_dust_level', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'AC Office Granit Dust Level', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d.dustLevel', + 'unit_of_measurement': 'μg/m^3', + }) +# --- +# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_dust_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'AC Office Granit Dust Level', + 'state_class': , + 'unit_of_measurement': 'μg/m^3', + }), + 'context': , + 'entity_id': 'sensor.ac_office_granit_dust_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ac_office_granit_energy', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'AC Office Granit energy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d.energy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'AC Office Granit energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ac_office_granit_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2247.3', + }) +# --- +# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_energysaved-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ac_office_granit_energysaved', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'AC Office Granit energySaved', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d.energySaved_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_energysaved-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'AC Office Granit energySaved', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ac_office_granit_energysaved', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_fine_dust_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ac_office_granit_fine_dust_level', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'AC Office Granit Fine Dust Level', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d.fineDustLevel', + 'unit_of_measurement': 'μg/m^3', + }) +# --- +# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_fine_dust_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'AC Office Granit Fine Dust Level', + 'state_class': , + 'unit_of_measurement': 'μg/m^3', + }), + 'context': , + 'entity_id': 'sensor.ac_office_granit_fine_dust_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ac_office_granit_power', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'AC Office Granit power', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d.power_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'AC Office Granit power', + 'power_consumption_end': '2025-02-09T16:15:33Z', + 'power_consumption_start': '2025-02-09T15:45:29Z', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ac_office_granit_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_powerenergy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ac_office_granit_powerenergy', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'AC Office Granit powerEnergy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d.powerEnergy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_powerenergy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'AC Office Granit powerEnergy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ac_office_granit_powerenergy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_relative_humidity_measurement-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ac_office_granit_relative_humidity_measurement', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'AC Office Granit Relative Humidity Measurement', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d.humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_relative_humidity_measurement-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'AC Office Granit Relative Humidity Measurement', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.ac_office_granit_relative_humidity_measurement', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '60', + }) +# --- +# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_temperature_measurement-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ac_office_granit_temperature_measurement', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'AC Office Granit Temperature Measurement', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d.temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_temperature_measurement-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'AC Office Granit Temperature Measurement', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ac_office_granit_temperature_measurement', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '25', + }) +# --- +# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_volume-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ac_office_granit_volume', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'AC Office Granit Volume', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d.volume', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_volume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'AC Office Granit Volume', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.ac_office_granit_volume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- +# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_air_quality-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.aire_dormitorio_principal_air_quality', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Aire Dormitorio Principal Air Quality', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e.airQuality', + 'unit_of_measurement': 'CAQI', + }) +# --- +# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_air_quality-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Aire Dormitorio Principal Air Quality', + 'state_class': , + 'unit_of_measurement': 'CAQI', + }), + 'context': , + 'entity_id': 'sensor.aire_dormitorio_principal_air_quality', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_deltaenergy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.aire_dormitorio_principal_deltaenergy', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Aire Dormitorio Principal deltaEnergy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e.deltaEnergy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_deltaenergy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Aire Dormitorio Principal deltaEnergy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.aire_dormitorio_principal_deltaenergy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_dust_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.aire_dormitorio_principal_dust_level', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Aire Dormitorio Principal Dust Level', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e.dustLevel', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_dust_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Aire Dormitorio Principal Dust Level', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.aire_dormitorio_principal_dust_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.aire_dormitorio_principal_energy', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Aire Dormitorio Principal energy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e.energy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Aire Dormitorio Principal energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.aire_dormitorio_principal_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '13.836', + }) +# --- +# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_energysaved-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.aire_dormitorio_principal_energysaved', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Aire Dormitorio Principal energySaved', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e.energySaved_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_energysaved-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Aire Dormitorio Principal energySaved', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.aire_dormitorio_principal_energysaved', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_fine_dust_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.aire_dormitorio_principal_fine_dust_level', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Aire Dormitorio Principal Fine Dust Level', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e.fineDustLevel', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_fine_dust_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Aire Dormitorio Principal Fine Dust Level', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.aire_dormitorio_principal_fine_dust_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_odor_sensor-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.aire_dormitorio_principal_odor_sensor', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Aire Dormitorio Principal Odor Sensor', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e.odorLevel', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_odor_sensor-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Aire Dormitorio Principal Odor Sensor', + }), + 'context': , + 'entity_id': 'sensor.aire_dormitorio_principal_odor_sensor', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.aire_dormitorio_principal_power', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Aire Dormitorio Principal power', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e.power_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Aire Dormitorio Principal power', + 'power_consumption_end': '2025-02-09T17:02:44Z', + 'power_consumption_start': '2025-02-09T16:08:15Z', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.aire_dormitorio_principal_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_powerenergy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.aire_dormitorio_principal_powerenergy', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Aire Dormitorio Principal powerEnergy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e.powerEnergy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_powerenergy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Aire Dormitorio Principal powerEnergy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.aire_dormitorio_principal_powerenergy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_relative_humidity_measurement-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.aire_dormitorio_principal_relative_humidity_measurement', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Aire Dormitorio Principal Relative Humidity Measurement', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e.humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_relative_humidity_measurement-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Aire Dormitorio Principal Relative Humidity Measurement', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.aire_dormitorio_principal_relative_humidity_measurement', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '42', + }) +# --- +# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_temperature_measurement-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.aire_dormitorio_principal_temperature_measurement', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Aire Dormitorio Principal Temperature Measurement', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e.temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_temperature_measurement-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Aire Dormitorio Principal Temperature Measurement', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.aire_dormitorio_principal_temperature_measurement', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '27', + }) +# --- +# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_volume-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.aire_dormitorio_principal_volume', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Aire Dormitorio Principal Volume', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e.volume', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_volume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Aire Dormitorio Principal Volume', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.aire_dormitorio_principal_volume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_oven_completion_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.microwave_oven_completion_time', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Microwave Oven Completion Time', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a.completionTime', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_oven_completion_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Microwave Oven Completion Time', + }), + 'context': , + 'entity_id': 'sensor.microwave_oven_completion_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2025-02-08T21:13:36.184Z', + }) +# --- +# name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_oven_job_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.microwave_oven_job_state', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Microwave Oven Job State', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a.ovenJobState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_oven_job_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Microwave Oven Job State', + }), + 'context': , + 'entity_id': 'sensor.microwave_oven_job_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'ready', + }) +# --- +# name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_oven_machine_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.microwave_oven_machine_state', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Microwave Oven Machine State', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a.machineState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_oven_machine_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Microwave Oven Machine State', + }), + 'context': , + 'entity_id': 'sensor.microwave_oven_machine_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'ready', + }) +# --- +# name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_oven_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.microwave_oven_mode', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Microwave Oven Mode', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a.ovenMode', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_oven_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Microwave Oven Mode', + }), + 'context': , + 'entity_id': 'sensor.microwave_oven_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Others', + }) +# --- +# name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_oven_set_point-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.microwave_oven_set_point', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Microwave Oven Set Point', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a.ovenSetpoint', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_oven_set_point-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Microwave Oven Set Point', + }), + 'context': , + 'entity_id': 'sensor.microwave_oven_set_point', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_temperature_measurement-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.microwave_temperature_measurement', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Microwave Temperature Measurement', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a.temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_temperature_measurement-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Microwave Temperature Measurement', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.microwave_temperature_measurement', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-17', + }) +# --- +# name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_deltaenergy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.refrigerator_deltaenergy', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Refrigerator deltaEnergy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '7db87911-7dce-1cf2-7119-b953432a2f09.deltaEnergy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_deltaenergy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Refrigerator deltaEnergy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.refrigerator_deltaenergy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.007', + }) +# --- +# name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.refrigerator_energy', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Refrigerator energy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '7db87911-7dce-1cf2-7119-b953432a2f09.energy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Refrigerator energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.refrigerator_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1568.087', + }) +# --- +# name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_energysaved-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.refrigerator_energysaved', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Refrigerator energySaved', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '7db87911-7dce-1cf2-7119-b953432a2f09.energySaved_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_energysaved-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Refrigerator energySaved', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.refrigerator_energysaved', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.refrigerator_power', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Refrigerator power', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '7db87911-7dce-1cf2-7119-b953432a2f09.power_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Refrigerator power', + 'power_consumption_end': '2025-02-09T17:49:00Z', + 'power_consumption_start': '2025-02-09T17:38:01Z', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.refrigerator_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '6', + }) +# --- +# name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_powerenergy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.refrigerator_powerenergy', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Refrigerator powerEnergy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '7db87911-7dce-1cf2-7119-b953432a2f09.powerEnergy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_powerenergy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Refrigerator powerEnergy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.refrigerator_powerenergy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0135559777781698', + }) +# --- +# name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_temperature_measurement-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.refrigerator_temperature_measurement', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Refrigerator Temperature Measurement', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '7db87911-7dce-1cf2-7119-b953432a2f09.temperature', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_temperature_measurement-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Refrigerator Temperature Measurement', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.refrigerator_temperature_measurement', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_thermostat_cooling_setpoint-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.refrigerator_thermostat_cooling_setpoint', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Refrigerator Thermostat Cooling Setpoint', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '7db87911-7dce-1cf2-7119-b953432a2f09.coolingSetpoint', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_thermostat_cooling_setpoint-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Refrigerator Thermostat Cooling Setpoint', + }), + 'context': , + 'entity_id': 'sensor.refrigerator_thermostat_cooling_setpoint', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[da_rvc_normal_000001][sensor.robot_vacuum_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.robot_vacuum_battery', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Robot vacuum Battery', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '3442dfc6-17c0-a65f-dae0-4c6e01786f44.battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[da_rvc_normal_000001][sensor.robot_vacuum_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Robot vacuum Battery', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.robot_vacuum_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- +# name: test_all_entities[da_rvc_normal_000001][sensor.robot_vacuum_robot_cleaner_cleaning_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.robot_vacuum_robot_cleaner_cleaning_mode', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Robot vacuum Robot Cleaner Cleaning Mode', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '3442dfc6-17c0-a65f-dae0-4c6e01786f44.robotCleanerCleaningMode', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_rvc_normal_000001][sensor.robot_vacuum_robot_cleaner_cleaning_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Robot vacuum Robot Cleaner Cleaning Mode', + }), + 'context': , + 'entity_id': 'sensor.robot_vacuum_robot_cleaner_cleaning_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'stop', + }) +# --- +# name: test_all_entities[da_rvc_normal_000001][sensor.robot_vacuum_robot_cleaner_movement-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.robot_vacuum_robot_cleaner_movement', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Robot vacuum Robot Cleaner Movement', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '3442dfc6-17c0-a65f-dae0-4c6e01786f44.robotCleanerMovement', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_rvc_normal_000001][sensor.robot_vacuum_robot_cleaner_movement-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Robot vacuum Robot Cleaner Movement', + }), + 'context': , + 'entity_id': 'sensor.robot_vacuum_robot_cleaner_movement', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'idle', + }) +# --- +# name: test_all_entities[da_rvc_normal_000001][sensor.robot_vacuum_robot_cleaner_turbo_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.robot_vacuum_robot_cleaner_turbo_mode', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Robot vacuum Robot Cleaner Turbo Mode', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '3442dfc6-17c0-a65f-dae0-4c6e01786f44.robotCleanerTurboMode', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_rvc_normal_000001][sensor.robot_vacuum_robot_cleaner_turbo_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Robot vacuum Robot Cleaner Turbo Mode', + }), + 'context': , + 'entity_id': 'sensor.robot_vacuum_robot_cleaner_turbo_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_deltaenergy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.dishwasher_deltaenergy', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Dishwasher deltaEnergy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676.deltaEnergy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_deltaenergy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Dishwasher deltaEnergy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.dishwasher_deltaenergy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_dishwasher_completion_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.dishwasher_dishwasher_completion_time', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Dishwasher Dishwasher Completion Time', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676.completionTime', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_dishwasher_completion_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Dishwasher Dishwasher Completion Time', + }), + 'context': , + 'entity_id': 'sensor.dishwasher_dishwasher_completion_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2025-02-08T22:49:26+00:00', + }) +# --- +# name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_dishwasher_job_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.dishwasher_dishwasher_job_state', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Dishwasher Dishwasher Job State', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676.dishwasherJobState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_dishwasher_job_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dishwasher Dishwasher Job State', + }), + 'context': , + 'entity_id': 'sensor.dishwasher_dishwasher_job_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_dishwasher_machine_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.dishwasher_dishwasher_machine_state', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Dishwasher Dishwasher Machine State', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676.machineState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_dishwasher_machine_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dishwasher Dishwasher Machine State', + }), + 'context': , + 'entity_id': 'sensor.dishwasher_dishwasher_machine_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'stop', + }) +# --- +# name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.dishwasher_energy', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Dishwasher energy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676.energy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Dishwasher energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.dishwasher_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '101.6', + }) +# --- +# name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_energysaved-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.dishwasher_energysaved', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Dishwasher energySaved', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676.energySaved_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_energysaved-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Dishwasher energySaved', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.dishwasher_energysaved', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.dishwasher_power', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Dishwasher power', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676.power_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Dishwasher power', + 'power_consumption_end': '2025-02-08T20:21:26Z', + 'power_consumption_start': '2025-02-08T20:21:21Z', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.dishwasher_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_powerenergy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.dishwasher_powerenergy', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Dishwasher powerEnergy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676.powerEnergy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_powerenergy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Dishwasher powerEnergy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.dishwasher_powerenergy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_wm_wd_000001][sensor.dryer_deltaenergy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.dryer_deltaenergy', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Dryer deltaEnergy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b.deltaEnergy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_wm_wd_000001][sensor.dryer_deltaenergy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Dryer deltaEnergy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.dryer_deltaenergy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_wm_wd_000001][sensor.dryer_dryer_completion_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.dryer_dryer_completion_time', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Dryer Dryer Completion Time', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b.completionTime', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wd_000001][sensor.dryer_dryer_completion_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Dryer Dryer Completion Time', + }), + 'context': , + 'entity_id': 'sensor.dryer_dryer_completion_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2025-02-08T19:25:10+00:00', + }) +# --- +# name: test_all_entities[da_wm_wd_000001][sensor.dryer_dryer_job_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.dryer_dryer_job_state', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Dryer Dryer Job State', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b.dryerJobState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wd_000001][sensor.dryer_dryer_job_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dryer Dryer Job State', + }), + 'context': , + 'entity_id': 'sensor.dryer_dryer_job_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'none', + }) +# --- +# name: test_all_entities[da_wm_wd_000001][sensor.dryer_dryer_machine_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.dryer_dryer_machine_state', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Dryer Dryer Machine State', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b.machineState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wd_000001][sensor.dryer_dryer_machine_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dryer Dryer Machine State', + }), + 'context': , + 'entity_id': 'sensor.dryer_dryer_machine_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'stop', + }) +# --- +# name: test_all_entities[da_wm_wd_000001][sensor.dryer_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.dryer_energy', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Dryer energy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b.energy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_wm_wd_000001][sensor.dryer_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Dryer energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.dryer_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4495.5', + }) +# --- +# name: test_all_entities[da_wm_wd_000001][sensor.dryer_energysaved-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.dryer_energysaved', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Dryer energySaved', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b.energySaved_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_wm_wd_000001][sensor.dryer_energysaved-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Dryer energySaved', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.dryer_energysaved', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_wm_wd_000001][sensor.dryer_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.dryer_power', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Dryer power', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b.power_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_wm_wd_000001][sensor.dryer_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Dryer power', + 'power_consumption_end': '2025-02-08T18:10:11Z', + 'power_consumption_start': '2025-02-07T04:00:19Z', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.dryer_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_all_entities[da_wm_wd_000001][sensor.dryer_powerenergy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.dryer_powerenergy', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Dryer powerEnergy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b.powerEnergy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_wm_wd_000001][sensor.dryer_powerenergy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Dryer powerEnergy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.dryer_powerenergy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_wm_wm_000001][sensor.washer_deltaenergy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.washer_deltaenergy', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Washer deltaEnergy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47.deltaEnergy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_wm_wm_000001][sensor.washer_deltaenergy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Washer deltaEnergy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.washer_deltaenergy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_wm_wm_000001][sensor.washer_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.washer_energy', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Washer energy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47.energy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_wm_wm_000001][sensor.washer_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Washer energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.washer_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '352.8', + }) +# --- +# name: test_all_entities[da_wm_wm_000001][sensor.washer_energysaved-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.washer_energysaved', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Washer energySaved', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47.energySaved_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_wm_wm_000001][sensor.washer_energysaved-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Washer energySaved', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.washer_energysaved', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_wm_wm_000001][sensor.washer_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.washer_power', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Washer power', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47.power_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_wm_wm_000001][sensor.washer_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Washer power', + 'power_consumption_end': '2025-02-07T03:09:45Z', + 'power_consumption_start': '2025-02-07T03:09:24Z', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.washer_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_all_entities[da_wm_wm_000001][sensor.washer_powerenergy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.washer_powerenergy', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Washer powerEnergy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47.powerEnergy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_wm_wm_000001][sensor.washer_powerenergy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Washer powerEnergy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.washer_powerenergy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_wm_wm_000001][sensor.washer_washer_completion_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.washer_washer_completion_time', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Washer Washer Completion Time', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47.completionTime', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wm_000001][sensor.washer_washer_completion_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Washer Washer Completion Time', + }), + 'context': , + 'entity_id': 'sensor.washer_washer_completion_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2025-02-07T03:54:45+00:00', + }) +# --- +# name: test_all_entities[da_wm_wm_000001][sensor.washer_washer_job_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.washer_washer_job_state', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Washer Washer Job State', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47.washerJobState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wm_000001][sensor.washer_washer_job_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Washer Washer Job State', + }), + 'context': , + 'entity_id': 'sensor.washer_washer_job_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'none', + }) +# --- +# name: test_all_entities[da_wm_wm_000001][sensor.washer_washer_machine_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.washer_washer_machine_state', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Washer Washer Machine State', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47.machineState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wm_000001][sensor.washer_washer_machine_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Washer Washer Machine State', + }), + 'context': , + 'entity_id': 'sensor.washer_washer_machine_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'stop', + }) +# --- +# name: test_all_entities[ecobee_sensor][sensor.child_bedroom_temperature_measurement-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.child_bedroom_temperature_measurement', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Child Bedroom Temperature Measurement', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'd5dc3299-c266-41c7-bd08-f540aea54b89.temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[ecobee_sensor][sensor.child_bedroom_temperature_measurement-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Child Bedroom Temperature Measurement', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.child_bedroom_temperature_measurement', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '22', + }) +# --- +# name: test_all_entities[ecobee_thermostat][sensor.main_floor_relative_humidity_measurement-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.main_floor_relative_humidity_measurement', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Main Floor Relative Humidity Measurement', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '028469cb-6e89-4f14-8d9a-bfbca5e0fbfc.humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[ecobee_thermostat][sensor.main_floor_relative_humidity_measurement-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Main Floor Relative Humidity Measurement', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.main_floor_relative_humidity_measurement', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '32', + }) +# --- +# name: test_all_entities[ecobee_thermostat][sensor.main_floor_temperature_measurement-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.main_floor_temperature_measurement', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Main Floor Temperature Measurement', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '028469cb-6e89-4f14-8d9a-bfbca5e0fbfc.temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[ecobee_thermostat][sensor.main_floor_temperature_measurement-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Main Floor Temperature Measurement', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.main_floor_temperature_measurement', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '22', + }) +# --- +# name: test_all_entities[multipurpose_sensor][sensor.deck_door_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.deck_door_battery', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Deck Door Battery', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '7d246592-93db-4d72-a10d-5a51793ece8c.battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[multipurpose_sensor][sensor.deck_door_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Deck Door Battery', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.deck_door_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50', + }) +# --- +# name: test_all_entities[multipurpose_sensor][sensor.deck_door_temperature_measurement-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.deck_door_temperature_measurement', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Deck Door Temperature Measurement', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '7d246592-93db-4d72-a10d-5a51793ece8c.temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[multipurpose_sensor][sensor.deck_door_temperature_measurement-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Deck Door Temperature Measurement', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.deck_door_temperature_measurement', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '19.4', + }) +# --- +# name: test_all_entities[multipurpose_sensor][sensor.deck_door_x_coordinate-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.deck_door_x_coordinate', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Deck Door X Coordinate', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '7d246592-93db-4d72-a10d-5a51793ece8c X Coordinate', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[multipurpose_sensor][sensor.deck_door_x_coordinate-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Deck Door X Coordinate', + }), + 'context': , + 'entity_id': 'sensor.deck_door_x_coordinate', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '20', + }) +# --- +# name: test_all_entities[multipurpose_sensor][sensor.deck_door_y_coordinate-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.deck_door_y_coordinate', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Deck Door Y Coordinate', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '7d246592-93db-4d72-a10d-5a51793ece8c Y Coordinate', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[multipurpose_sensor][sensor.deck_door_y_coordinate-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Deck Door Y Coordinate', + }), + 'context': , + 'entity_id': 'sensor.deck_door_y_coordinate', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '8', + }) +# --- +# name: test_all_entities[multipurpose_sensor][sensor.deck_door_z_coordinate-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.deck_door_z_coordinate', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Deck Door Z Coordinate', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '7d246592-93db-4d72-a10d-5a51793ece8c Z Coordinate', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[multipurpose_sensor][sensor.deck_door_z_coordinate-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Deck Door Z Coordinate', + }), + 'context': , + 'entity_id': 'sensor.deck_door_z_coordinate', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-1042', + }) +# --- +# name: test_all_entities[sensibo_airconditioner_1][sensor.office_air_conditioner_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.office_air_conditioner_mode', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Office Air Conditioner Mode', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'bf4b1167-48a3-4af7-9186-0900a678ffa5.airConditionerMode', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensibo_airconditioner_1][sensor.office_air_conditioner_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Office Air Conditioner Mode', + }), + 'context': , + 'entity_id': 'sensor.office_air_conditioner_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'cool', + }) +# --- +# name: test_all_entities[sensibo_airconditioner_1][sensor.office_thermostat_cooling_setpoint-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.office_thermostat_cooling_setpoint', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Office Thermostat Cooling Setpoint', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'bf4b1167-48a3-4af7-9186-0900a678ffa5.coolingSetpoint', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensibo_airconditioner_1][sensor.office_thermostat_cooling_setpoint-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Office Thermostat Cooling Setpoint', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.office_thermostat_cooling_setpoint', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '20', + }) +# --- +# name: test_all_entities[sonos_player][sensor.elliots_rum_media_playback_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.elliots_rum_media_playback_status', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Elliots Rum Media Playback Status', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'c85fced9-c474-4a47-93c2-037cc7829536.playbackStatus', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sonos_player][sensor.elliots_rum_media_playback_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Elliots Rum Media Playback Status', + }), + 'context': , + 'entity_id': 'sensor.elliots_rum_media_playback_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'playing', + }) +# --- +# name: test_all_entities[sonos_player][sensor.elliots_rum_volume-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.elliots_rum_volume', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Elliots Rum Volume', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'c85fced9-c474-4a47-93c2-037cc7829536.volume', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[sonos_player][sensor.elliots_rum_volume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Elliots Rum Volume', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.elliots_rum_volume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '15', + }) +# --- +# name: test_all_entities[vd_network_audio_002s][sensor.soundbar_living_media_playback_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.soundbar_living_media_playback_status', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Soundbar Living Media Playback Status', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '0d94e5db-8501-2355-eb4f-214163702cac.playbackStatus', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[vd_network_audio_002s][sensor.soundbar_living_media_playback_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Soundbar Living Media Playback Status', + }), + 'context': , + 'entity_id': 'sensor.soundbar_living_media_playback_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'stopped', + }) +# --- +# name: test_all_entities[vd_network_audio_002s][sensor.soundbar_living_volume-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.soundbar_living_volume', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Soundbar Living Volume', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '0d94e5db-8501-2355-eb4f-214163702cac.volume', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[vd_network_audio_002s][sensor.soundbar_living_volume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Soundbar Living Volume', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.soundbar_living_volume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '17', + }) +# --- +# name: test_all_entities[vd_stv_2017_k][sensor.tv_samsung_8_series_49_media_input_source-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.tv_samsung_8_series_49_media_input_source', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': '[TV] Samsung 8 Series (49) Media Input Source', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '4588d2d9-a8cf-40f4-9a0b-ed5dfbaccda1.inputSource', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[vd_stv_2017_k][sensor.tv_samsung_8_series_49_media_input_source-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '[TV] Samsung 8 Series (49) Media Input Source', + }), + 'context': , + 'entity_id': 'sensor.tv_samsung_8_series_49_media_input_source', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'HDMI1', + }) +# --- +# name: test_all_entities[vd_stv_2017_k][sensor.tv_samsung_8_series_49_media_playback_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.tv_samsung_8_series_49_media_playback_status', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': '[TV] Samsung 8 Series (49) Media Playback Status', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '4588d2d9-a8cf-40f4-9a0b-ed5dfbaccda1.playbackStatus', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[vd_stv_2017_k][sensor.tv_samsung_8_series_49_media_playback_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '[TV] Samsung 8 Series (49) Media Playback Status', + }), + 'context': , + 'entity_id': 'sensor.tv_samsung_8_series_49_media_playback_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[vd_stv_2017_k][sensor.tv_samsung_8_series_49_tv_channel-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.tv_samsung_8_series_49_tv_channel', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': '[TV] Samsung 8 Series (49) Tv Channel', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '4588d2d9-a8cf-40f4-9a0b-ed5dfbaccda1.tvChannel', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[vd_stv_2017_k][sensor.tv_samsung_8_series_49_tv_channel-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '[TV] Samsung 8 Series (49) Tv Channel', + }), + 'context': , + 'entity_id': 'sensor.tv_samsung_8_series_49_tv_channel', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '', + }) +# --- +# name: test_all_entities[vd_stv_2017_k][sensor.tv_samsung_8_series_49_tv_channel_name-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.tv_samsung_8_series_49_tv_channel_name', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': '[TV] Samsung 8 Series (49) Tv Channel Name', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '4588d2d9-a8cf-40f4-9a0b-ed5dfbaccda1.tvChannelName', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[vd_stv_2017_k][sensor.tv_samsung_8_series_49_tv_channel_name-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '[TV] Samsung 8 Series (49) Tv Channel Name', + }), + 'context': , + 'entity_id': 'sensor.tv_samsung_8_series_49_tv_channel_name', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '', + }) +# --- +# name: test_all_entities[vd_stv_2017_k][sensor.tv_samsung_8_series_49_volume-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.tv_samsung_8_series_49_volume', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': '[TV] Samsung 8 Series (49) Volume', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '4588d2d9-a8cf-40f4-9a0b-ed5dfbaccda1.volume', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[vd_stv_2017_k][sensor.tv_samsung_8_series_49_volume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '[TV] Samsung 8 Series (49) Volume', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.tv_samsung_8_series_49_volume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '13', + }) +# --- +# name: test_all_entities[virtual_thermostat][sensor.asd_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.asd_battery', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'asd Battery', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '2894dc93-0f11-49cc-8a81-3a684cebebf6.battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[virtual_thermostat][sensor.asd_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'asd Battery', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.asd_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- +# name: test_all_entities[virtual_thermostat][sensor.asd_temperature_measurement-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.asd_temperature_measurement', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'asd Temperature Measurement', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '2894dc93-0f11-49cc-8a81-3a684cebebf6.temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[virtual_thermostat][sensor.asd_temperature_measurement-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'asd Temperature Measurement', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.asd_temperature_measurement', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4734.552604985020', + }) +# --- +# name: test_all_entities[virtual_water_sensor][sensor.asd_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.asd_battery', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'asd Battery', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'a2a6018b-2663-4727-9d1d-8f56953b5116.battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[virtual_water_sensor][sensor.asd_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'asd Battery', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.asd_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- +# name: test_all_entities[yale_push_button_deadbolt_lock][sensor.basement_door_lock_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.basement_door_lock_battery', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Basement Door Lock Battery', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'a9f587c5-5d8b-4273-8907-e7f609af5158.battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[yale_push_button_deadbolt_lock][sensor.basement_door_lock_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Basement Door Lock Battery', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.basement_door_lock_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '86', + }) +# --- diff --git a/tests/components/smartthings/snapshots/test_switch.ambr b/tests/components/smartthings/snapshots/test_switch.ambr new file mode 100644 index 00000000000..cf3245eed7d --- /dev/null +++ b/tests/components/smartthings/snapshots/test_switch.ambr @@ -0,0 +1,471 @@ +# serializer version: 1 +# name: test_all_entities[c2c_arlo_pro_3_switch][switch.2nd_floor_hallway-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.2nd_floor_hallway', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': '2nd Floor Hallway', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '10e06a70-ee7d-4832-85e9-a0a06a7a05bd', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[c2c_arlo_pro_3_switch][switch.2nd_floor_hallway-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '2nd Floor Hallway', + }), + 'context': , + 'entity_id': 'switch.2nd_floor_hallway', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[da_ks_microwave_0101x][switch.microwave-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.microwave', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Microwave', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_microwave_0101x][switch.microwave-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Microwave', + }), + 'context': , + 'entity_id': 'switch.microwave', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[da_rvc_normal_000001][switch.robot_vacuum-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.robot_vacuum', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Robot vacuum', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '3442dfc6-17c0-a65f-dae0-4c6e01786f44', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_rvc_normal_000001][switch.robot_vacuum-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Robot vacuum', + }), + 'context': , + 'entity_id': 'switch.robot_vacuum', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[da_wm_dw_000001][switch.dishwasher-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.dishwasher', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Dishwasher', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_dw_000001][switch.dishwasher-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dishwasher', + }), + 'context': , + 'entity_id': 'switch.dishwasher', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[da_wm_wd_000001][switch.dryer-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.dryer', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Dryer', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wd_000001][switch.dryer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dryer', + }), + 'context': , + 'entity_id': 'switch.dryer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[da_wm_wm_000001][switch.washer-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.washer', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Washer', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wm_000001][switch.washer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Washer', + }), + 'context': , + 'entity_id': 'switch.washer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[sensibo_airconditioner_1][switch.office-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.office', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Office', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'bf4b1167-48a3-4af7-9186-0900a678ffa5', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensibo_airconditioner_1][switch.office-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Office', + }), + 'context': , + 'entity_id': 'switch.office', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[smart_plug][switch.arlo_beta_basestation-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.arlo_beta_basestation', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Arlo Beta Basestation', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '550a1c72-65a0-4d55-b97b-75168e055398', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[smart_plug][switch.arlo_beta_basestation-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Arlo Beta Basestation', + }), + 'context': , + 'entity_id': 'switch.arlo_beta_basestation', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[vd_network_audio_002s][switch.soundbar_living-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.soundbar_living', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Soundbar Living', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '0d94e5db-8501-2355-eb4f-214163702cac', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[vd_network_audio_002s][switch.soundbar_living-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Soundbar Living', + }), + 'context': , + 'entity_id': 'switch.soundbar_living', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[vd_stv_2017_k][switch.tv_samsung_8_series_49-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.tv_samsung_8_series_49', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': '[TV] Samsung 8 Series (49)', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '4588d2d9-a8cf-40f4-9a0b-ed5dfbaccda1', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[vd_stv_2017_k][switch.tv_samsung_8_series_49-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '[TV] Samsung 8 Series (49)', + }), + 'context': , + 'entity_id': 'switch.tv_samsung_8_series_49', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/smartthings/test_binary_sensor.py b/tests/components/smartthings/test_binary_sensor.py index 52fd5d28aa7..eb473d3be04 100644 --- a/tests/components/smartthings/test_binary_sensor.py +++ b/tests/components/smartthings/test_binary_sensor.py @@ -1,139 +1,53 @@ -"""Test for the SmartThings binary_sensor platform. +"""Test for the SmartThings binary_sensor platform.""" -The only mocking required is of the underlying SmartThings API object so -real HTTP calls are not initiated during testing. -""" +from unittest.mock import AsyncMock -from pysmartthings import ATTRIBUTES, CAPABILITIES, Attribute, Capability +from pysmartthings import Attribute, Capability +import pytest +from syrupy import SnapshotAssertion -from homeassistant.components.binary_sensor import ( - DEVICE_CLASSES, - DOMAIN as BINARY_SENSOR_DOMAIN, -) -from homeassistant.components.smartthings import binary_sensor -from homeassistant.components.smartthings.const import DOMAIN, SIGNAL_SMARTTHINGS_UPDATE -from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import ATTR_FRIENDLY_NAME, STATE_UNAVAILABLE, EntityCategory +from homeassistant.const import STATE_OFF, STATE_ON, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers import entity_registry as er -from .conftest import setup_platform +from . import setup_integration, snapshot_smartthings_entities, trigger_update + +from tests.common import MockConfigEntry -async def test_mapping_integrity() -> None: - """Test ensures the map dicts have proper integrity.""" - # Ensure every CAPABILITY_TO_ATTRIB key is in CAPABILITIES - # Ensure every CAPABILITY_TO_ATTRIB value is in ATTRIB_TO_CLASS keys - for capability, attrib in binary_sensor.CAPABILITY_TO_ATTRIB.items(): - assert capability in CAPABILITIES, capability - assert attrib in ATTRIBUTES, attrib - assert attrib in binary_sensor.ATTRIB_TO_CLASS, attrib - # Ensure every ATTRIB_TO_CLASS value is in DEVICE_CLASSES - for attrib, device_class in binary_sensor.ATTRIB_TO_CLASS.items(): - assert attrib in ATTRIBUTES, attrib - assert device_class in DEVICE_CLASSES, device_class - - -async def test_entity_state(hass: HomeAssistant, device_factory) -> None: - """Tests the state attributes properly match the light types.""" - device = device_factory( - "Motion Sensor 1", [Capability.motion_sensor], {Attribute.motion: "inactive"} - ) - await setup_platform(hass, BINARY_SENSOR_DOMAIN, devices=[device]) - state = hass.states.get("binary_sensor.motion_sensor_1_motion") - assert state.state == "off" - assert state.attributes[ATTR_FRIENDLY_NAME] == f"{device.label} {Attribute.motion}" - - -async def test_entity_and_device_attributes( +async def test_all_entities( hass: HomeAssistant, - device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, entity_registry: er.EntityRegistry, - device_factory, ) -> None: - """Test the attributes of the entity are correct.""" - # Arrange - device = device_factory( - "Motion Sensor 1", - [Capability.motion_sensor], - { - Attribute.motion: "inactive", - Attribute.mnmo: "123", - Attribute.mnmn: "Generic manufacturer", - Attribute.mnhw: "v4.56", - Attribute.mnfv: "v7.89", - }, - ) - # Act - await setup_platform(hass, BINARY_SENSOR_DOMAIN, devices=[device]) - # Assert - entry = entity_registry.async_get("binary_sensor.motion_sensor_1_motion") - assert entry - assert entry.unique_id == f"{device.device_id}.{Attribute.motion}" - entry = device_registry.async_get_device(identifiers={(DOMAIN, device.device_id)}) - assert entry - assert entry.configuration_url == "https://account.smartthings.com" - assert entry.identifiers == {(DOMAIN, device.device_id)} - assert entry.name == device.label - assert entry.model == "123" - assert entry.manufacturer == "Generic manufacturer" - assert entry.hw_version == "v4.56" - assert entry.sw_version == "v7.89" + """Test all entities.""" + await setup_integration(hass, mock_config_entry) - -async def test_update_from_signal(hass: HomeAssistant, device_factory) -> None: - """Test the binary_sensor updates when receiving a signal.""" - # Arrange - device = device_factory( - "Motion Sensor 1", [Capability.motion_sensor], {Attribute.motion: "inactive"} - ) - await setup_platform(hass, BINARY_SENSOR_DOMAIN, devices=[device]) - device.status.apply_attribute_update( - "main", Capability.motion_sensor, Attribute.motion, "active" - ) - # Act - async_dispatcher_send(hass, SIGNAL_SMARTTHINGS_UPDATE, [device.device_id]) - # Assert - await hass.async_block_till_done() - state = hass.states.get("binary_sensor.motion_sensor_1_motion") - assert state is not None - assert state.state == "on" - - -async def test_unload_config_entry(hass: HomeAssistant, device_factory) -> None: - """Test the binary_sensor is removed when the config entry is unloaded.""" - # Arrange - device = device_factory( - "Motion Sensor 1", [Capability.motion_sensor], {Attribute.motion: "inactive"} - ) - config_entry = await setup_platform(hass, BINARY_SENSOR_DOMAIN, devices=[device]) - config_entry.mock_state(hass, ConfigEntryState.LOADED) - # Act - await hass.config_entries.async_forward_entry_unload(config_entry, "binary_sensor") - # Assert - assert ( - hass.states.get("binary_sensor.motion_sensor_1_motion").state - == STATE_UNAVAILABLE + snapshot_smartthings_entities( + hass, entity_registry, snapshot, Platform.BINARY_SENSOR ) -async def test_entity_category( - hass: HomeAssistant, entity_registry: er.EntityRegistry, device_factory +@pytest.mark.parametrize("device_fixture", ["da_ref_normal_000001"]) +async def test_state_update( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, ) -> None: - """Tests the state attributes properly match the light types.""" - device1 = device_factory( - "Motion Sensor 1", [Capability.motion_sensor], {Attribute.motion: "inactive"} - ) - device2 = device_factory( - "Tamper Sensor 2", [Capability.tamper_alert], {Attribute.tamper: "inactive"} - ) - await setup_platform(hass, BINARY_SENSOR_DOMAIN, devices=[device1, device2]) + """Test state update.""" + await setup_integration(hass, mock_config_entry) - entry = entity_registry.async_get("binary_sensor.motion_sensor_1_motion") - assert entry - assert entry.entity_category is None + assert hass.states.get("binary_sensor.refrigerator_contact").state == STATE_OFF - entry = entity_registry.async_get("binary_sensor.tamper_sensor_2_tamper") - assert entry - assert entry.entity_category is EntityCategory.DIAGNOSTIC + await trigger_update( + hass, + devices, + "7db87911-7dce-1cf2-7119-b953432a2f09", + Capability.CONTACT_SENSOR, + Attribute.CONTACT, + "open", + ) + + assert hass.states.get("binary_sensor.refrigerator_contact").state == STATE_ON diff --git a/tests/components/smartthings/test_climate.py b/tests/components/smartthings/test_climate.py index d39ee2d6bed..380c4072860 100644 --- a/tests/components/smartthings/test_climate.py +++ b/tests/components/smartthings/test_climate.py @@ -1,12 +1,11 @@ -"""Test for the SmartThings climate platform. +"""Test for the SmartThings climate platform.""" -The only mocking required is of the underlying SmartThings API object so -real HTTP calls are not initiated during testing. -""" +from typing import Any +from unittest.mock import AsyncMock, call -from pysmartthings import Attribute, Capability -from pysmartthings.device import Status +from pysmartthings import Attribute, Capability, Command, Status import pytest +from syrupy import SnapshotAssertion from homeassistant.components.climate import ( ATTR_CURRENT_HUMIDITY, @@ -26,748 +25,835 @@ from homeassistant.components.climate import ( SERVICE_SET_PRESET_MODE, SERVICE_SET_SWING_MODE, SERVICE_SET_TEMPERATURE, - ClimateEntityFeature, + SWING_HORIZONTAL, + SWING_OFF, HVACAction, HVACMode, ) -from homeassistant.components.smartthings import climate -from homeassistant.components.smartthings.const import DOMAIN +from homeassistant.components.smartthings.const import MAIN from homeassistant.const import ( ATTR_ENTITY_ID, - ATTR_SUPPORTED_FEATURES, ATTR_TEMPERATURE, SERVICE_TURN_OFF, SERVICE_TURN_ON, - STATE_UNKNOWN, + Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers import entity_registry as er -from .conftest import setup_platform +from . import ( + set_attribute_value, + setup_integration, + snapshot_smartthings_entities, + trigger_update, +) + +from tests.common import MockConfigEntry -@pytest.fixture(name="legacy_thermostat") -def legacy_thermostat_fixture(device_factory): - """Fixture returns a legacy thermostat.""" - device = device_factory( - "Legacy Thermostat", - capabilities=[Capability.thermostat], - status={ - Attribute.cooling_setpoint: 74, - Attribute.heating_setpoint: 68, - Attribute.thermostat_fan_mode: "auto", - Attribute.supported_thermostat_fan_modes: ["auto", "on"], - Attribute.thermostat_mode: "auto", - Attribute.supported_thermostat_modes: climate.MODE_TO_STATE.keys(), - Attribute.thermostat_operating_state: "idle", - }, - ) - device.status.attributes[Attribute.temperature] = Status(70, "F", None) - return device - - -@pytest.fixture(name="basic_thermostat") -def basic_thermostat_fixture(device_factory): - """Fixture returns a basic thermostat.""" - device = device_factory( - "Basic Thermostat", - capabilities=[ - Capability.temperature_measurement, - Capability.thermostat_cooling_setpoint, - Capability.thermostat_heating_setpoint, - Capability.thermostat_mode, - ], - status={ - Attribute.cooling_setpoint: 74, - Attribute.heating_setpoint: 68, - Attribute.thermostat_mode: "off", - Attribute.supported_thermostat_modes: ["off", "auto", "heat", "cool"], - }, - ) - device.status.attributes[Attribute.temperature] = Status(70, "F", None) - return device - - -@pytest.fixture(name="minimal_thermostat") -def minimal_thermostat_fixture(device_factory): - """Fixture returns a minimal thermostat without cooling.""" - device = device_factory( - "Minimal Thermostat", - capabilities=[ - Capability.temperature_measurement, - Capability.thermostat_heating_setpoint, - Capability.thermostat_mode, - ], - status={ - Attribute.heating_setpoint: 68, - Attribute.thermostat_mode: "off", - Attribute.supported_thermostat_modes: ["off", "heat"], - }, - ) - device.status.attributes[Attribute.temperature] = Status(70, "F", None) - return device - - -@pytest.fixture(name="thermostat") -def thermostat_fixture(device_factory): - """Fixture returns a fully-featured thermostat.""" - device = device_factory( - "Thermostat", - capabilities=[ - Capability.temperature_measurement, - Capability.relative_humidity_measurement, - Capability.thermostat_cooling_setpoint, - Capability.thermostat_heating_setpoint, - Capability.thermostat_mode, - Capability.thermostat_operating_state, - Capability.thermostat_fan_mode, - ], - status={ - Attribute.cooling_setpoint: 74, - Attribute.heating_setpoint: 68, - Attribute.thermostat_fan_mode: "on", - Attribute.supported_thermostat_fan_modes: ["auto", "on"], - Attribute.thermostat_mode: "heat", - Attribute.supported_thermostat_modes: [ - "auto", - "heat", - "cool", - "off", - "eco", - ], - Attribute.thermostat_operating_state: "idle", - Attribute.humidity: 34, - Attribute.mnmo: "123", - Attribute.mnmn: "Generic manufacturer", - Attribute.mnhw: "v4.56", - Attribute.mnfv: "v7.89", - }, - ) - device.status.attributes[Attribute.temperature] = Status(70, "F", None) - return device - - -@pytest.fixture(name="buggy_thermostat") -def buggy_thermostat_fixture(device_factory): - """Fixture returns a buggy thermostat.""" - device = device_factory( - "Buggy Thermostat", - capabilities=[ - Capability.temperature_measurement, - Capability.thermostat_cooling_setpoint, - Capability.thermostat_heating_setpoint, - Capability.thermostat_mode, - ], - status={ - Attribute.thermostat_mode: "heating", - Attribute.cooling_setpoint: 74, - Attribute.heating_setpoint: 68, - }, - ) - device.status.attributes[Attribute.temperature] = Status(70, "F", None) - return device - - -@pytest.fixture(name="air_conditioner") -def air_conditioner_fixture(device_factory): - """Fixture returns a air conditioner.""" - device = device_factory( - "Air Conditioner", - capabilities=[ - Capability.air_conditioner_mode, - Capability.demand_response_load_control, - Capability.air_conditioner_fan_mode, - Capability.switch, - Capability.temperature_measurement, - Capability.thermostat_cooling_setpoint, - Capability.fan_oscillation_mode, - ], - status={ - Attribute.air_conditioner_mode: "auto", - Attribute.supported_ac_modes: [ - "cool", - "dry", - "wind", - "auto", - "heat", - "fanOnly", - ], - Attribute.drlc_status: { - "duration": 0, - "drlcLevel": -1, - "start": "1970-01-01T00:00:00Z", - "override": False, - }, - Attribute.fan_mode: "medium", - Attribute.supported_ac_fan_modes: [ - "auto", - "low", - "medium", - "high", - "turbo", - ], - Attribute.switch: "on", - Attribute.cooling_setpoint: 23, - "supportedAcOptionalMode": ["windFree"], - Attribute.supported_fan_oscillation_modes: [ - "all", - "horizontal", - "vertical", - "fixed", - ], - Attribute.fan_oscillation_mode: "vertical", - }, - ) - device.status.attributes[Attribute.temperature] = Status(24, "C", None) - return device - - -@pytest.fixture(name="air_conditioner_windfree") -def air_conditioner_windfree_fixture(device_factory): - """Fixture returns a air conditioner.""" - device = device_factory( - "Air Conditioner", - capabilities=[ - Capability.air_conditioner_mode, - Capability.demand_response_load_control, - Capability.air_conditioner_fan_mode, - Capability.switch, - Capability.temperature_measurement, - Capability.thermostat_cooling_setpoint, - Capability.fan_oscillation_mode, - ], - status={ - Attribute.air_conditioner_mode: "auto", - Attribute.supported_ac_modes: [ - "cool", - "dry", - "wind", - "auto", - "heat", - "wind", - ], - Attribute.drlc_status: { - "duration": 0, - "drlcLevel": -1, - "start": "1970-01-01T00:00:00Z", - "override": False, - }, - Attribute.fan_mode: "medium", - Attribute.supported_ac_fan_modes: [ - "auto", - "low", - "medium", - "high", - "turbo", - ], - Attribute.switch: "on", - Attribute.cooling_setpoint: 23, - "supportedAcOptionalMode": ["windFree"], - Attribute.supported_fan_oscillation_modes: [ - "all", - "horizontal", - "vertical", - "fixed", - ], - Attribute.fan_oscillation_mode: "vertical", - }, - ) - device.status.attributes[Attribute.temperature] = Status(24, "C", None) - return device - - -async def test_legacy_thermostat_entity_state( - hass: HomeAssistant, legacy_thermostat +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, ) -> None: - """Tests the state attributes properly match the thermostat type.""" - await setup_platform(hass, CLIMATE_DOMAIN, devices=[legacy_thermostat]) - state = hass.states.get("climate.legacy_thermostat") - assert state.state == HVACMode.HEAT_COOL - assert ( - state.attributes[ATTR_SUPPORTED_FEATURES] - == ClimateEntityFeature.FAN_MODE - | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE - | ClimateEntityFeature.TARGET_TEMPERATURE - | ClimateEntityFeature.TURN_OFF - | ClimateEntityFeature.TURN_ON - ) - assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.IDLE - assert sorted(state.attributes[ATTR_HVAC_MODES]) == [ - HVACMode.AUTO, - HVACMode.COOL, - HVACMode.HEAT, - HVACMode.HEAT_COOL, - HVACMode.OFF, - ] - assert state.attributes[ATTR_FAN_MODE] == "auto" - assert state.attributes[ATTR_FAN_MODES] == ["auto", "on"] - assert state.attributes[ATTR_TARGET_TEMP_LOW] == 20 # celsius - assert state.attributes[ATTR_TARGET_TEMP_HIGH] == 23.3 # celsius - assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 21.1 # celsius + """Test all entities.""" + await setup_integration(hass, mock_config_entry) + + snapshot_smartthings_entities(hass, entity_registry, snapshot, Platform.CLIMATE) -async def test_basic_thermostat_entity_state( - hass: HomeAssistant, basic_thermostat +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) +async def test_ac_set_fan_mode( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, ) -> None: - """Tests the state attributes properly match the thermostat type.""" - await setup_platform(hass, CLIMATE_DOMAIN, devices=[basic_thermostat]) - state = hass.states.get("climate.basic_thermostat") - assert state.state == HVACMode.OFF - assert ( - state.attributes[ATTR_SUPPORTED_FEATURES] - == ClimateEntityFeature.TARGET_TEMPERATURE_RANGE - | ClimateEntityFeature.TARGET_TEMPERATURE - | ClimateEntityFeature.TURN_OFF - | ClimateEntityFeature.TURN_ON - ) - assert ATTR_HVAC_ACTION not in state.attributes - assert sorted(state.attributes[ATTR_HVAC_MODES]) == [ - HVACMode.COOL, - HVACMode.HEAT, - HVACMode.HEAT_COOL, - HVACMode.OFF, - ] - assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 21.1 # celsius + """Test climate set fan mode.""" + await setup_integration(hass, mock_config_entry) - -async def test_minimal_thermostat_entity_state( - hass: HomeAssistant, minimal_thermostat -) -> None: - """Tests the state attributes properly match the thermostat type.""" - await setup_platform(hass, CLIMATE_DOMAIN, devices=[minimal_thermostat]) - state = hass.states.get("climate.minimal_thermostat") - assert state.state == HVACMode.OFF - assert ( - state.attributes[ATTR_SUPPORTED_FEATURES] - == ClimateEntityFeature.TARGET_TEMPERATURE_RANGE - | ClimateEntityFeature.TARGET_TEMPERATURE - | ClimateEntityFeature.TURN_OFF - | ClimateEntityFeature.TURN_ON - ) - assert ATTR_HVAC_ACTION not in state.attributes - assert sorted(state.attributes[ATTR_HVAC_MODES]) == [ - HVACMode.HEAT, - HVACMode.OFF, - ] - assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 21.1 # celsius - - -async def test_thermostat_entity_state(hass: HomeAssistant, thermostat) -> None: - """Tests the state attributes properly match the thermostat type.""" - await setup_platform(hass, CLIMATE_DOMAIN, devices=[thermostat]) - state = hass.states.get("climate.thermostat") - assert state.state == HVACMode.HEAT - assert ( - state.attributes[ATTR_SUPPORTED_FEATURES] - == ClimateEntityFeature.FAN_MODE - | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE - | ClimateEntityFeature.TARGET_TEMPERATURE - | ClimateEntityFeature.TURN_OFF - | ClimateEntityFeature.TURN_ON - ) - assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.IDLE - assert sorted(state.attributes[ATTR_HVAC_MODES]) == [ - HVACMode.AUTO, - HVACMode.COOL, - HVACMode.HEAT, - HVACMode.HEAT_COOL, - HVACMode.OFF, - ] - assert state.attributes[ATTR_FAN_MODE] == "on" - assert state.attributes[ATTR_FAN_MODES] == ["auto", "on"] - assert state.attributes[ATTR_TEMPERATURE] == 20 # celsius - assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 21.1 # celsius - assert state.attributes[ATTR_CURRENT_HUMIDITY] == 34 - - -async def test_buggy_thermostat_entity_state( - hass: HomeAssistant, buggy_thermostat -) -> None: - """Tests the state attributes properly match the thermostat type.""" - await setup_platform(hass, CLIMATE_DOMAIN, devices=[buggy_thermostat]) - state = hass.states.get("climate.buggy_thermostat") - assert state.state == STATE_UNKNOWN - assert ( - state.attributes[ATTR_SUPPORTED_FEATURES] - == ClimateEntityFeature.TARGET_TEMPERATURE_RANGE - | ClimateEntityFeature.TARGET_TEMPERATURE - | ClimateEntityFeature.TURN_OFF - | ClimateEntityFeature.TURN_ON - ) - assert state.state is STATE_UNKNOWN - assert state.attributes[ATTR_TEMPERATURE] is None - assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 21.1 # celsius - assert state.attributes[ATTR_HVAC_MODES] == [] - - -async def test_buggy_thermostat_invalid_mode( - hass: HomeAssistant, buggy_thermostat -) -> None: - """Tests when an invalid operation mode is included.""" - buggy_thermostat.status.update_attribute_value( - Attribute.supported_thermostat_modes, ["heat", "emergency heat", "other"] - ) - await setup_platform(hass, CLIMATE_DOMAIN, devices=[buggy_thermostat]) - state = hass.states.get("climate.buggy_thermostat") - assert state.attributes[ATTR_HVAC_MODES] == [HVACMode.HEAT] - - -async def test_air_conditioner_entity_state( - hass: HomeAssistant, air_conditioner -) -> None: - """Tests when an invalid operation mode is included.""" - await setup_platform(hass, CLIMATE_DOMAIN, devices=[air_conditioner]) - state = hass.states.get("climate.air_conditioner") - assert state.state == HVACMode.HEAT_COOL - assert ( - state.attributes[ATTR_SUPPORTED_FEATURES] - == ClimateEntityFeature.FAN_MODE - | ClimateEntityFeature.TARGET_TEMPERATURE - | ClimateEntityFeature.PRESET_MODE - | ClimateEntityFeature.SWING_MODE - | ClimateEntityFeature.TURN_OFF - | ClimateEntityFeature.TURN_ON - ) - assert sorted(state.attributes[ATTR_HVAC_MODES]) == [ - HVACMode.COOL, - HVACMode.DRY, - HVACMode.FAN_ONLY, - HVACMode.HEAT, - HVACMode.HEAT_COOL, - HVACMode.OFF, - ] - assert state.attributes[ATTR_FAN_MODE] == "medium" - assert sorted(state.attributes[ATTR_FAN_MODES]) == [ - "auto", - "high", - "low", - "medium", - "turbo", - ] - assert state.attributes[ATTR_TEMPERATURE] == 23 - assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 24 - assert state.attributes["drlc_status_duration"] == 0 - assert state.attributes["drlc_status_level"] == -1 - assert state.attributes["drlc_status_start"] == "1970-01-01T00:00:00Z" - assert state.attributes["drlc_status_override"] is False - - -async def test_set_fan_mode(hass: HomeAssistant, thermostat, air_conditioner) -> None: - """Test the fan mode is set successfully.""" - await setup_platform(hass, CLIMATE_DOMAIN, devices=[thermostat, air_conditioner]) - entity_ids = ["climate.thermostat", "climate.air_conditioner"] await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_FAN_MODE, - {ATTR_ENTITY_ID: entity_ids, ATTR_FAN_MODE: "auto"}, + {ATTR_ENTITY_ID: "climate.ac_office_granit", ATTR_FAN_MODE: "auto"}, blocking=True, ) - for entity_id in entity_ids: - state = hass.states.get(entity_id) - assert state.attributes[ATTR_FAN_MODE] == "auto", entity_id + devices.execute_device_command.assert_called_once_with( + "96a5ef74-5832-a84b-f1f7-ca799957065d", + Capability.AIR_CONDITIONER_FAN_MODE, + Command.SET_FAN_MODE, + MAIN, + argument="auto", + ) -async def test_set_hvac_mode(hass: HomeAssistant, thermostat, air_conditioner) -> None: - """Test the hvac mode is set successfully.""" - await setup_platform(hass, CLIMATE_DOMAIN, devices=[thermostat, air_conditioner]) - entity_ids = ["climate.thermostat", "climate.air_conditioner"] +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) +async def test_ac_set_hvac_mode_off( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setting AC HVAC mode to off.""" + await setup_integration(hass, mock_config_entry) + await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, - {ATTR_ENTITY_ID: entity_ids, ATTR_HVAC_MODE: HVACMode.COOL}, + {ATTR_ENTITY_ID: "climate.ac_office_granit", ATTR_HVAC_MODE: HVACMode.OFF}, blocking=True, ) - - for entity_id in entity_ids: - state = hass.states.get(entity_id) - assert state.state == HVACMode.COOL, entity_id - - -async def test_ac_set_hvac_mode_from_off(hass: HomeAssistant, air_conditioner) -> None: - """Test setting HVAC mode when the unit is off.""" - air_conditioner.status.update_attribute_value( - Attribute.air_conditioner_mode, "heat" + devices.execute_device_command.assert_called_once_with( + "96a5ef74-5832-a84b-f1f7-ca799957065d", + Capability.SWITCH, + Command.OFF, + MAIN, ) - air_conditioner.status.update_attribute_value(Attribute.switch, "off") - await setup_platform(hass, CLIMATE_DOMAIN, devices=[air_conditioner]) - state = hass.states.get("climate.air_conditioner") - assert state.state == HVACMode.OFF + + +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) +@pytest.mark.parametrize( + ("hvac_mode", "argument"), + [ + (HVACMode.HEAT_COOL, "auto"), + (HVACMode.COOL, "cool"), + (HVACMode.DRY, "dry"), + (HVACMode.HEAT, "heat"), + (HVACMode.FAN_ONLY, "fanOnly"), + ], +) +async def test_ac_set_hvac_mode( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + hvac_mode: HVACMode, + argument: str, +) -> None: + """Test setting AC HVAC mode.""" + set_attribute_value( + devices, + Capability.AIR_CONDITIONER_MODE, + Attribute.SUPPORTED_AC_MODES, + ["auto", "cool", "dry", "heat", "fanOnly"], + ) + set_attribute_value(devices, Capability.SWITCH, Attribute.SWITCH, "on") + + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: "climate.ac_office_granit", ATTR_HVAC_MODE: hvac_mode}, + blocking=True, + ) + devices.execute_device_command.assert_called_once_with( + "96a5ef74-5832-a84b-f1f7-ca799957065d", + Capability.AIR_CONDITIONER_MODE, + Command.SET_AIR_CONDITIONER_MODE, + MAIN, + argument=argument, + ) + + +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) +async def test_ac_set_hvac_mode_turns_on( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setting AC HVAC mode turns on the device if it is off.""" + + await setup_integration(hass, mock_config_entry) + await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, { - ATTR_ENTITY_ID: "climate.air_conditioner", + ATTR_ENTITY_ID: "climate.ac_office_granit", ATTR_HVAC_MODE: HVACMode.HEAT_COOL, }, blocking=True, ) - state = hass.states.get("climate.air_conditioner") - assert state.state == HVACMode.HEAT_COOL - - -async def test_ac_set_hvac_mode_off(hass: HomeAssistant, air_conditioner) -> None: - """Test the AC HVAC mode can be turned off set successfully.""" - await setup_platform(hass, CLIMATE_DOMAIN, devices=[air_conditioner]) - state = hass.states.get("climate.air_conditioner") - assert state.state != HVACMode.OFF - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_HVAC_MODE, - {ATTR_ENTITY_ID: "climate.air_conditioner", ATTR_HVAC_MODE: HVACMode.OFF}, - blocking=True, - ) - state = hass.states.get("climate.air_conditioner") - assert state.state == HVACMode.OFF + assert devices.execute_device_command.mock_calls == [ + call( + "96a5ef74-5832-a84b-f1f7-ca799957065d", + Capability.SWITCH, + Command.ON, + MAIN, + ), + call( + "96a5ef74-5832-a84b-f1f7-ca799957065d", + Capability.AIR_CONDITIONER_MODE, + Command.SET_AIR_CONDITIONER_MODE, + MAIN, + argument="auto", + ), + ] +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) async def test_ac_set_hvac_mode_wind( - hass: HomeAssistant, air_conditioner_windfree + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, ) -> None: - """Test the AC HVAC mode to fan only as wind mode for supported models.""" - await setup_platform(hass, CLIMATE_DOMAIN, devices=[air_conditioner_windfree]) - state = hass.states.get("climate.air_conditioner") - assert state.state != HVACMode.OFF + """Test setting AC HVAC mode to wind if the device supports it.""" + set_attribute_value( + devices, + Capability.AIR_CONDITIONER_MODE, + Attribute.SUPPORTED_AC_MODES, + ["auto", "cool", "dry", "heat", "wind"], + ) + set_attribute_value(devices, Capability.SWITCH, Attribute.SWITCH, "on") + + await setup_integration(hass, mock_config_entry) + await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, - {ATTR_ENTITY_ID: "climate.air_conditioner", ATTR_HVAC_MODE: HVACMode.FAN_ONLY}, + {ATTR_ENTITY_ID: "climate.ac_office_granit", ATTR_HVAC_MODE: HVACMode.FAN_ONLY}, blocking=True, ) - state = hass.states.get("climate.air_conditioner") - assert state.state == HVACMode.FAN_ONLY + devices.execute_device_command.assert_called_once_with( + "96a5ef74-5832-a84b-f1f7-ca799957065d", + Capability.AIR_CONDITIONER_MODE, + Command.SET_AIR_CONDITIONER_MODE, + MAIN, + argument="wind", + ) -async def test_set_temperature_heat_mode(hass: HomeAssistant, thermostat) -> None: - """Test the temperature is set successfully when in heat mode.""" - thermostat.status.thermostat_mode = "heat" - await setup_platform(hass, CLIMATE_DOMAIN, devices=[thermostat]) +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) +async def test_ac_set_temperature( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setting AC temperature.""" + await setup_integration(hass, mock_config_entry) + await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, - {ATTR_ENTITY_ID: "climate.thermostat", ATTR_TEMPERATURE: 21}, + {ATTR_ENTITY_ID: "climate.ac_office_granit", ATTR_TEMPERATURE: 23}, blocking=True, ) - state = hass.states.get("climate.thermostat") - assert state.state == HVACMode.HEAT - assert state.attributes[ATTR_TEMPERATURE] == 21 - assert thermostat.status.heating_setpoint == 69.8 - - -async def test_set_temperature_cool_mode(hass: HomeAssistant, thermostat) -> None: - """Test the temperature is set successfully when in cool mode.""" - thermostat.status.thermostat_mode = "cool" - await setup_platform(hass, CLIMATE_DOMAIN, devices=[thermostat]) - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_TEMPERATURE, - {ATTR_ENTITY_ID: "climate.thermostat", ATTR_TEMPERATURE: 21}, - blocking=True, + devices.execute_device_command.assert_called_once_with( + "96a5ef74-5832-a84b-f1f7-ca799957065d", + Capability.THERMOSTAT_COOLING_SETPOINT, + Command.SET_COOLING_SETPOINT, + MAIN, + argument=23, ) - state = hass.states.get("climate.thermostat") - assert state.attributes[ATTR_TEMPERATURE] == 21 -async def test_set_temperature(hass: HomeAssistant, thermostat) -> None: - """Test the temperature is set successfully.""" - thermostat.status.thermostat_mode = "auto" - await setup_platform(hass, CLIMATE_DOMAIN, devices=[thermostat]) +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) +async def test_ac_set_temperature_and_hvac_mode_while_off( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setting AC temperature and HVAC mode while off.""" + await setup_integration(hass, mock_config_entry) + await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, { - ATTR_ENTITY_ID: "climate.thermostat", - ATTR_TARGET_TEMP_HIGH: 25.5, - ATTR_TARGET_TEMP_LOW: 22.2, + ATTR_ENTITY_ID: "climate.ac_office_granit", + ATTR_TEMPERATURE: 23, + ATTR_HVAC_MODE: HVACMode.HEAT_COOL, }, blocking=True, ) - state = hass.states.get("climate.thermostat") - assert state.attributes[ATTR_TARGET_TEMP_HIGH] == 25.5 - assert state.attributes[ATTR_TARGET_TEMP_LOW] == 22.2 + assert devices.execute_device_command.mock_calls == [ + call( + "96a5ef74-5832-a84b-f1f7-ca799957065d", + Capability.SWITCH, + Command.ON, + MAIN, + ), + call( + "96a5ef74-5832-a84b-f1f7-ca799957065d", + Capability.THERMOSTAT_COOLING_SETPOINT, + Command.SET_COOLING_SETPOINT, + MAIN, + argument=23.0, + ), + call( + "96a5ef74-5832-a84b-f1f7-ca799957065d", + Capability.SWITCH, + Command.ON, + MAIN, + ), + call( + "96a5ef74-5832-a84b-f1f7-ca799957065d", + Capability.AIR_CONDITIONER_MODE, + Command.SET_AIR_CONDITIONER_MODE, + MAIN, + argument="auto", + ), + ] -async def test_set_temperature_ac(hass: HomeAssistant, air_conditioner) -> None: - """Test the temperature is set successfully.""" - await setup_platform(hass, CLIMATE_DOMAIN, devices=[air_conditioner]) - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_TEMPERATURE, - {ATTR_ENTITY_ID: "climate.air_conditioner", ATTR_TEMPERATURE: 27}, - blocking=True, - ) - state = hass.states.get("climate.air_conditioner") - assert state.attributes[ATTR_TEMPERATURE] == 27 - - -async def test_set_temperature_ac_with_mode( - hass: HomeAssistant, air_conditioner +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) +async def test_ac_set_temperature_and_hvac_mode( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, ) -> None: - """Test the temperature is set successfully.""" - await setup_platform(hass, CLIMATE_DOMAIN, devices=[air_conditioner]) + """Test setting AC temperature and HVAC mode.""" + set_attribute_value(devices, Capability.SWITCH, Attribute.SWITCH, "on") + await setup_integration(hass, mock_config_entry) + await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, { - ATTR_ENTITY_ID: "climate.air_conditioner", - ATTR_TEMPERATURE: 27, - ATTR_HVAC_MODE: HVACMode.COOL, + ATTR_ENTITY_ID: "climate.ac_office_granit", + ATTR_TEMPERATURE: 23, + ATTR_HVAC_MODE: HVACMode.HEAT_COOL, }, blocking=True, ) - state = hass.states.get("climate.air_conditioner") - assert state.attributes[ATTR_TEMPERATURE] == 27 - assert state.state == HVACMode.COOL + assert devices.execute_device_command.mock_calls == [ + call( + "96a5ef74-5832-a84b-f1f7-ca799957065d", + Capability.THERMOSTAT_COOLING_SETPOINT, + Command.SET_COOLING_SETPOINT, + MAIN, + argument=23.0, + ), + call( + "96a5ef74-5832-a84b-f1f7-ca799957065d", + Capability.AIR_CONDITIONER_MODE, + Command.SET_AIR_CONDITIONER_MODE, + MAIN, + argument="auto", + ), + ] -async def test_set_temperature_ac_with_mode_from_off( - hass: HomeAssistant, air_conditioner +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) +async def test_ac_set_temperature_and_hvac_mode_off( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, ) -> None: - """Test the temp and mode is set successfully when the unit is off.""" - air_conditioner.status.update_attribute_value( - Attribute.air_conditioner_mode, "heat" - ) - air_conditioner.status.update_attribute_value(Attribute.switch, "off") - await setup_platform(hass, CLIMATE_DOMAIN, devices=[air_conditioner]) - assert hass.states.get("climate.air_conditioner").state == HVACMode.OFF + """Test setting AC temperature and HVAC mode OFF.""" + set_attribute_value(devices, Capability.SWITCH, Attribute.SWITCH, "on") + await setup_integration(hass, mock_config_entry) + await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, { - ATTR_ENTITY_ID: "climate.air_conditioner", - ATTR_TEMPERATURE: 27, - ATTR_HVAC_MODE: HVACMode.COOL, - }, - blocking=True, - ) - state = hass.states.get("climate.air_conditioner") - assert state.attributes[ATTR_TEMPERATURE] == 27 - assert state.state == HVACMode.COOL - - -async def test_set_temperature_ac_with_mode_to_off( - hass: HomeAssistant, air_conditioner -) -> None: - """Test the temp and mode is set successfully to turn off the unit.""" - await setup_platform(hass, CLIMATE_DOMAIN, devices=[air_conditioner]) - assert hass.states.get("climate.air_conditioner").state != HVACMode.OFF - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_TEMPERATURE, - { - ATTR_ENTITY_ID: "climate.air_conditioner", - ATTR_TEMPERATURE: 27, + ATTR_ENTITY_ID: "climate.ac_office_granit", + ATTR_TEMPERATURE: 23, ATTR_HVAC_MODE: HVACMode.OFF, }, blocking=True, ) - state = hass.states.get("climate.air_conditioner") - assert state.attributes[ATTR_TEMPERATURE] == 27 - assert state.state == HVACMode.OFF + assert devices.execute_device_command.mock_calls == [ + call( + "96a5ef74-5832-a84b-f1f7-ca799957065d", + Capability.SWITCH, + Command.OFF, + MAIN, + ), + call( + "96a5ef74-5832-a84b-f1f7-ca799957065d", + Capability.THERMOSTAT_COOLING_SETPOINT, + Command.SET_COOLING_SETPOINT, + MAIN, + argument=23.0, + ), + ] -async def test_set_temperature_with_mode(hass: HomeAssistant, thermostat) -> None: - """Test the temperature and mode is set successfully.""" - await setup_platform(hass, CLIMATE_DOMAIN, devices=[thermostat]) - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_TEMPERATURE, - { - ATTR_ENTITY_ID: "climate.thermostat", - ATTR_TARGET_TEMP_HIGH: 25.5, - ATTR_TARGET_TEMP_LOW: 22.2, - ATTR_HVAC_MODE: HVACMode.HEAT_COOL, - }, - blocking=True, - ) - state = hass.states.get("climate.thermostat") - assert state.attributes[ATTR_TARGET_TEMP_HIGH] == 25.5 - assert state.attributes[ATTR_TARGET_TEMP_LOW] == 22.2 - assert state.state == HVACMode.HEAT_COOL - - -async def test_set_turn_off(hass: HomeAssistant, air_conditioner) -> None: - """Test the a/c is turned off successfully.""" - await setup_platform(hass, CLIMATE_DOMAIN, devices=[air_conditioner]) - state = hass.states.get("climate.air_conditioner") - assert state.state == HVACMode.HEAT_COOL - await hass.services.async_call( - CLIMATE_DOMAIN, SERVICE_TURN_OFF, {"entity_id": "all"}, blocking=True - ) - state = hass.states.get("climate.air_conditioner") - assert state.state == HVACMode.OFF - - -async def test_set_turn_on(hass: HomeAssistant, air_conditioner) -> None: - """Test the a/c is turned on successfully.""" - air_conditioner.status.update_attribute_value(Attribute.switch, "off") - await setup_platform(hass, CLIMATE_DOMAIN, devices=[air_conditioner]) - state = hass.states.get("climate.air_conditioner") - assert state.state == HVACMode.OFF - await hass.services.async_call( - CLIMATE_DOMAIN, SERVICE_TURN_ON, {"entity_id": "all"}, blocking=True - ) - state = hass.states.get("climate.air_conditioner") - assert state.state == HVACMode.HEAT_COOL - - -async def test_entity_and_device_attributes( +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) +@pytest.mark.parametrize( + ("service", "command"), + [ + (SERVICE_TURN_ON, Command.ON), + (SERVICE_TURN_OFF, Command.OFF), + ], +) +async def test_ac_toggle_power( hass: HomeAssistant, - device_registry: dr.DeviceRegistry, - entity_registry: er.EntityRegistry, - thermostat, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + service: str, + command: Command, ) -> None: - """Test the attributes of the entries are correct.""" - await setup_platform(hass, CLIMATE_DOMAIN, devices=[thermostat]) + """Test toggling AC power.""" + await setup_integration(hass, mock_config_entry) - entry = entity_registry.async_get("climate.thermostat") - assert entry - assert entry.unique_id == thermostat.device_id - - entry = device_registry.async_get_device( - identifiers={(DOMAIN, thermostat.device_id)} - ) - assert entry - assert entry.configuration_url == "https://account.smartthings.com" - assert entry.identifiers == {(DOMAIN, thermostat.device_id)} - assert entry.name == thermostat.label - assert entry.model == "123" - assert entry.manufacturer == "Generic manufacturer" - assert entry.hw_version == "v4.56" - assert entry.sw_version == "v7.89" - - -async def test_set_windfree_off(hass: HomeAssistant, air_conditioner) -> None: - """Test if the windfree preset can be turned on and is turned off when fan mode is set.""" - entity_ids = ["climate.air_conditioner"] - air_conditioner.status.update_attribute_value(Attribute.switch, "on") - await setup_platform(hass, CLIMATE_DOMAIN, devices=[air_conditioner]) await hass.services.async_call( CLIMATE_DOMAIN, - SERVICE_SET_PRESET_MODE, - {ATTR_ENTITY_ID: entity_ids, ATTR_PRESET_MODE: "windFree"}, + service, + {ATTR_ENTITY_ID: "climate.ac_office_granit"}, blocking=True, ) - state = hass.states.get("climate.air_conditioner") - assert state.attributes[ATTR_PRESET_MODE] == "windFree" - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_FAN_MODE, - {ATTR_ENTITY_ID: entity_ids, ATTR_FAN_MODE: "low"}, - blocking=True, + devices.execute_device_command.assert_called_once_with( + "96a5ef74-5832-a84b-f1f7-ca799957065d", + Capability.SWITCH, + command, + MAIN, ) - state = hass.states.get("climate.air_conditioner") - assert not state.attributes[ATTR_PRESET_MODE] -async def test_set_swing_mode(hass: HomeAssistant, air_conditioner) -> None: - """Test the fan swing is set successfully.""" - await setup_platform(hass, CLIMATE_DOMAIN, devices=[air_conditioner]) - entity_ids = ["climate.air_conditioner"] +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) +async def test_ac_set_swing_mode( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test climate set swing mode.""" + set_attribute_value( + devices, + Capability.FAN_OSCILLATION_MODE, + Attribute.SUPPORTED_FAN_OSCILLATION_MODES, + ["fixed"], + ) + await setup_integration(hass, mock_config_entry) + await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_SWING_MODE, - {ATTR_ENTITY_ID: entity_ids, ATTR_SWING_MODE: "vertical"}, + {ATTR_ENTITY_ID: "climate.ac_office_granit", ATTR_SWING_MODE: SWING_OFF}, blocking=True, ) - state = hass.states.get("climate.air_conditioner") - assert state.attributes[ATTR_SWING_MODE] == "vertical" + devices.execute_device_command.assert_called_once_with( + "96a5ef74-5832-a84b-f1f7-ca799957065d", + Capability.FAN_OSCILLATION_MODE, + Command.SET_FAN_OSCILLATION_MODE, + MAIN, + argument="fixed", + ) + + +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) +async def test_ac_set_preset_mode( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test climate set preset mode.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: "climate.ac_office_granit", ATTR_PRESET_MODE: "windFree"}, + blocking=True, + ) + devices.execute_device_command.assert_called_once_with( + "96a5ef74-5832-a84b-f1f7-ca799957065d", + Capability.CUSTOM_AIR_CONDITIONER_OPTIONAL_MODE, + Command.SET_AC_OPTIONAL_MODE, + MAIN, + argument="windFree", + ) + + +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) +async def test_ac_state_update( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test state update.""" + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("climate.ac_office_granit").state == HVACMode.OFF + + await trigger_update( + hass, + devices, + "96a5ef74-5832-a84b-f1f7-ca799957065d", + Capability.SWITCH, + Attribute.SWITCH, + "on", + ) + + assert hass.states.get("climate.ac_office_granit").state == HVACMode.HEAT + + +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) +@pytest.mark.parametrize( + ( + "capability", + "attribute", + "value", + "state_attribute", + "original_value", + "expected_value", + ), + [ + ( + Capability.TEMPERATURE_MEASUREMENT, + Attribute.TEMPERATURE, + 20, + ATTR_CURRENT_TEMPERATURE, + 25, + 20, + ), + ( + Capability.AIR_CONDITIONER_FAN_MODE, + Attribute.FAN_MODE, + "auto", + ATTR_FAN_MODE, + "low", + "auto", + ), + ( + Capability.AIR_CONDITIONER_FAN_MODE, + Attribute.SUPPORTED_AC_FAN_MODES, + ["low", "auto"], + ATTR_FAN_MODES, + ["auto", "low", "medium", "high", "turbo"], + ["low", "auto"], + ), + ( + Capability.THERMOSTAT_COOLING_SETPOINT, + Attribute.COOLING_SETPOINT, + 23, + ATTR_TEMPERATURE, + 25, + 23, + ), + ( + Capability.FAN_OSCILLATION_MODE, + Attribute.FAN_OSCILLATION_MODE, + "horizontal", + ATTR_SWING_MODE, + SWING_OFF, + SWING_HORIZONTAL, + ), + ( + Capability.FAN_OSCILLATION_MODE, + Attribute.FAN_OSCILLATION_MODE, + "direct", + ATTR_SWING_MODE, + SWING_OFF, + SWING_OFF, + ), + ], + ids=[ + ATTR_CURRENT_TEMPERATURE, + ATTR_FAN_MODE, + ATTR_FAN_MODES, + ATTR_TEMPERATURE, + ATTR_SWING_MODE, + f"{ATTR_SWING_MODE}_off", + ], +) +async def test_ac_state_attributes_update( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + capability: Capability, + attribute: Attribute, + value: Any, + state_attribute: str, + original_value: Any, + expected_value: Any, +) -> None: + """Test state attributes update.""" + await setup_integration(hass, mock_config_entry) + + assert ( + hass.states.get("climate.ac_office_granit").attributes[state_attribute] + == original_value + ) + + await trigger_update( + hass, + devices, + "96a5ef74-5832-a84b-f1f7-ca799957065d", + capability, + attribute, + value, + ) + + assert ( + hass.states.get("climate.ac_office_granit").attributes[state_attribute] + == expected_value + ) + + +@pytest.mark.parametrize("device_fixture", ["virtual_thermostat"]) +async def test_thermostat_set_fan_mode( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test thermostat set fan mode.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_FAN_MODE, + {ATTR_ENTITY_ID: "climate.asd", ATTR_FAN_MODE: "on"}, + blocking=True, + ) + devices.execute_device_command.assert_called_once_with( + "2894dc93-0f11-49cc-8a81-3a684cebebf6", + Capability.THERMOSTAT_FAN_MODE, + Command.SET_THERMOSTAT_FAN_MODE, + MAIN, + argument="on", + ) + + +@pytest.mark.parametrize("device_fixture", ["virtual_thermostat"]) +async def test_thermostat_set_hvac_mode( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test thermostat set HVAC mode.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: "climate.asd", ATTR_HVAC_MODE: HVACMode.HEAT_COOL}, + blocking=True, + ) + devices.execute_device_command.assert_called_once_with( + "2894dc93-0f11-49cc-8a81-3a684cebebf6", + Capability.THERMOSTAT_MODE, + Command.SET_THERMOSTAT_MODE, + MAIN, + argument="auto", + ) + + +@pytest.mark.parametrize("device_fixture", ["virtual_thermostat"]) +@pytest.mark.parametrize( + ("state", "data", "calls"), + [ + ( + "auto", + {ATTR_TARGET_TEMP_LOW: 15, ATTR_TARGET_TEMP_HIGH: 23}, + [ + call( + "2894dc93-0f11-49cc-8a81-3a684cebebf6", + Capability.THERMOSTAT_HEATING_SETPOINT, + Command.SET_HEATING_SETPOINT, + MAIN, + argument=59.0, + ), + call( + "2894dc93-0f11-49cc-8a81-3a684cebebf6", + Capability.THERMOSTAT_COOLING_SETPOINT, + Command.SET_COOLING_SETPOINT, + MAIN, + argument=73.4, + ), + ], + ), + ( + "cool", + {ATTR_TEMPERATURE: 15}, + [ + call( + "2894dc93-0f11-49cc-8a81-3a684cebebf6", + Capability.THERMOSTAT_COOLING_SETPOINT, + Command.SET_COOLING_SETPOINT, + MAIN, + argument=59.0, + ) + ], + ), + ( + "heat", + {ATTR_TEMPERATURE: 23}, + [ + call( + "2894dc93-0f11-49cc-8a81-3a684cebebf6", + Capability.THERMOSTAT_HEATING_SETPOINT, + Command.SET_HEATING_SETPOINT, + MAIN, + argument=73.4, + ) + ], + ), + ( + "heat", + {ATTR_TEMPERATURE: 23, ATTR_HVAC_MODE: HVACMode.COOL}, + [ + call( + "2894dc93-0f11-49cc-8a81-3a684cebebf6", + Capability.THERMOSTAT_MODE, + Command.SET_THERMOSTAT_MODE, + MAIN, + argument="cool", + ), + call( + "2894dc93-0f11-49cc-8a81-3a684cebebf6", + Capability.THERMOSTAT_COOLING_SETPOINT, + Command.SET_COOLING_SETPOINT, + MAIN, + argument=73.4, + ), + ], + ), + ], +) +async def test_thermostat_set_temperature( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + state: str, + data: dict[str, Any], + calls: list[call], +) -> None: + """Test thermostat set temperature.""" + set_attribute_value( + devices, Capability.THERMOSTAT_MODE, Attribute.THERMOSTAT_MODE, state + ) + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: "climate.asd"} | data, + blocking=True, + ) + assert devices.execute_device_command.mock_calls == calls + + +@pytest.mark.parametrize("device_fixture", ["virtual_thermostat"]) +async def test_humidity( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test humidity extra state attribute.""" + devices.get_device_status.return_value[MAIN][ + Capability.RELATIVE_HUMIDITY_MEASUREMENT + ] = {Attribute.HUMIDITY: Status(50)} + await setup_integration(hass, mock_config_entry) + + state = hass.states.get("climate.asd") + assert state + assert state.attributes[ATTR_CURRENT_HUMIDITY] == 50 + + +@pytest.mark.parametrize("device_fixture", ["virtual_thermostat"]) +async def test_updating_humidity( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test updating humidity extra state attribute.""" + devices.get_device_status.return_value[MAIN][ + Capability.RELATIVE_HUMIDITY_MEASUREMENT + ] = {Attribute.HUMIDITY: Status(50)} + await setup_integration(hass, mock_config_entry) + + state = hass.states.get("climate.asd") + assert state + assert state.attributes[ATTR_CURRENT_HUMIDITY] == 50 + + await trigger_update( + hass, + devices, + "2894dc93-0f11-49cc-8a81-3a684cebebf6", + Capability.RELATIVE_HUMIDITY_MEASUREMENT, + Attribute.HUMIDITY, + 40, + ) + + assert hass.states.get("climate.asd").attributes[ATTR_CURRENT_HUMIDITY] == 40 + + +@pytest.mark.parametrize("device_fixture", ["virtual_thermostat"]) +@pytest.mark.parametrize( + ( + "capability", + "attribute", + "value", + "state_attribute", + "original_value", + "expected_value", + ), + [ + ( + Capability.TEMPERATURE_MEASUREMENT, + Attribute.TEMPERATURE, + 20, + ATTR_CURRENT_TEMPERATURE, + 4734.6, + -6.7, + ), + ( + Capability.THERMOSTAT_FAN_MODE, + Attribute.THERMOSTAT_FAN_MODE, + "auto", + ATTR_FAN_MODE, + "followschedule", + "auto", + ), + ( + Capability.THERMOSTAT_FAN_MODE, + Attribute.SUPPORTED_THERMOSTAT_FAN_MODES, + ["auto", "circulate"], + ATTR_FAN_MODES, + ["on"], + ["auto", "circulate"], + ), + ( + Capability.THERMOSTAT_OPERATING_STATE, + Attribute.THERMOSTAT_OPERATING_STATE, + "fan only", + ATTR_HVAC_ACTION, + HVACAction.COOLING, + HVACAction.FAN, + ), + ( + Capability.THERMOSTAT_MODE, + Attribute.SUPPORTED_THERMOSTAT_MODES, + ["coolClean", "dryClean"], + ATTR_HVAC_MODES, + [], + [HVACMode.COOL, HVACMode.DRY], + ), + ], + ids=[ + ATTR_CURRENT_TEMPERATURE, + ATTR_FAN_MODE, + ATTR_FAN_MODES, + ATTR_HVAC_ACTION, + ATTR_HVAC_MODES, + ], +) +async def test_thermostat_state_attributes_update( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + capability: Capability, + attribute: Attribute, + value: Any, + state_attribute: str, + original_value: Any, + expected_value: Any, +) -> None: + """Test state attributes update.""" + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("climate.asd").attributes[state_attribute] == original_value + + await trigger_update( + hass, + devices, + "2894dc93-0f11-49cc-8a81-3a684cebebf6", + capability, + attribute, + value, + ) + + assert hass.states.get("climate.asd").attributes[state_attribute] == expected_value diff --git a/tests/components/smartthings/test_config_flow.py b/tests/components/smartthings/test_config_flow.py index 05ddc3a71de..647e0ea5284 100644 --- a/tests/components/smartthings/test_config_flow.py +++ b/tests/components/smartthings/test_config_flow.py @@ -1,813 +1,436 @@ """Tests for the SmartThings config flow module.""" from http import HTTPStatus -from unittest.mock import AsyncMock, Mock, patch -from uuid import uuid4 +from unittest.mock import AsyncMock -from aiohttp import ClientResponseError -from pysmartthings import APIResponseError -from pysmartthings.installedapp import format_install_url +import pytest -from homeassistant import config_entries -from homeassistant.components.smartthings import smartapp +from homeassistant.components.smartthings import OLD_DATA from homeassistant.components.smartthings.const import ( - CONF_APP_ID, CONF_INSTALLED_APP_ID, CONF_LOCATION_ID, CONF_REFRESH_TOKEN, DOMAIN, ) -from homeassistant.const import CONF_ACCESS_TOKEN, CONF_CLIENT_ID, CONF_CLIENT_SECRET +from homeassistant.config_entries import SOURCE_USER, ConfigEntryState +from homeassistant.const import ( + CONF_ACCESS_TOKEN, + CONF_CLIENT_ID, + CONF_CLIENT_SECRET, + CONF_TOKEN, +) from homeassistant.core import HomeAssistant -from homeassistant.core_config import async_process_ha_core_config from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import config_entry_oauth2_flow from tests.common import MockConfigEntry +from tests.test_util.aiohttp import AiohttpClientMocker +from tests.typing import ClientSessionGenerator -async def test_import_shows_user_step(hass: HomeAssistant) -> None: - """Test import source shows the user form.""" - # Webhook confirmation shown - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_IMPORT} - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - assert result["description_placeholders"][ - "webhook_url" - ] == smartapp.get_webhook_url(hass) - - -async def test_entry_created( - hass: HomeAssistant, app, app_oauth_client, location, smartthings_mock +@pytest.mark.usefixtures("current_request_with_host") +async def test_full_flow( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_smartthings: AsyncMock, + mock_setup_entry: AsyncMock, ) -> None: - """Test local webhook, new app, install event creates entry.""" - token = str(uuid4()) - installed_app_id = str(uuid4()) - refresh_token = str(uuid4()) - smartthings_mock.apps.return_value = [] - smartthings_mock.create_app.return_value = (app, app_oauth_client) - smartthings_mock.locations.return_value = [location] - request = Mock() - request.installed_app_id = installed_app_id - request.auth_token = token - request.location_id = location.location_id - request.refresh_token = refresh_token - - # Webhook confirmation shown + """Check a full flow.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - assert result["description_placeholders"][ - "webhook_url" - ] == smartapp.get_webhook_url(hass) - - # Advance to PAT screen - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "pat" - assert "token_url" in result["description_placeholders"] - assert "component_url" in result["description_placeholders"] - - # Enter token and advance to location screen - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_ACCESS_TOKEN: token} - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "select_location" - - # Select location and advance to external auth - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_LOCATION_ID: location.location_id} - ) - assert result["type"] is FlowResultType.EXTERNAL_STEP - assert result["step_id"] == "authorize" - assert result["url"] == format_install_url(app.app_id, location.location_id) - - # Complete external auth and advance to install - await smartapp.smartapp_install(hass, request, None, app) - - # Finish - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["data"]["app_id"] == app.app_id - assert result["data"]["installed_app_id"] == installed_app_id - assert result["data"]["location_id"] == location.location_id - assert result["data"]["access_token"] == token - assert result["data"]["refresh_token"] == request.refresh_token - assert result["data"][CONF_CLIENT_SECRET] == app_oauth_client.client_secret - assert result["data"][CONF_CLIENT_ID] == app_oauth_client.client_id - assert result["title"] == location.name - entry = next( - (entry for entry in hass.config_entries.async_entries(DOMAIN)), - None, - ) - assert entry.unique_id == smartapp.format_unique_id( - app.app_id, location.location_id + DOMAIN, context={"source": SOURCE_USER} ) - -async def test_entry_created_from_update_event( - hass: HomeAssistant, app, app_oauth_client, location, smartthings_mock -) -> None: - """Test local webhook, new app, update event creates entry.""" - token = str(uuid4()) - installed_app_id = str(uuid4()) - refresh_token = str(uuid4()) - smartthings_mock.apps.return_value = [] - smartthings_mock.create_app.return_value = (app, app_oauth_client) - smartthings_mock.locations.return_value = [location] - request = Mock() - request.installed_app_id = installed_app_id - request.auth_token = token - request.location_id = location.location_id - request.refresh_token = refresh_token - - # Webhook confirmation shown - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - assert result["description_placeholders"][ - "webhook_url" - ] == smartapp.get_webhook_url(hass) - - # Advance to PAT screen - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "pat" - assert "token_url" in result["description_placeholders"] - assert "component_url" in result["description_placeholders"] - - # Enter token and advance to location screen - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_ACCESS_TOKEN: token} - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "select_location" - - # Select location and advance to external auth - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_LOCATION_ID: location.location_id} - ) - assert result["type"] is FlowResultType.EXTERNAL_STEP - assert result["step_id"] == "authorize" - assert result["url"] == format_install_url(app.app_id, location.location_id) - - # Complete external auth and advance to install - await smartapp.smartapp_update(hass, request, None, app) - - # Finish - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["data"]["app_id"] == app.app_id - assert result["data"]["installed_app_id"] == installed_app_id - assert result["data"]["location_id"] == location.location_id - assert result["data"]["access_token"] == token - assert result["data"]["refresh_token"] == request.refresh_token - assert result["data"][CONF_CLIENT_SECRET] == app_oauth_client.client_secret - assert result["data"][CONF_CLIENT_ID] == app_oauth_client.client_id - assert result["title"] == location.name - entry = next( - (entry for entry in hass.config_entries.async_entries(DOMAIN)), - None, - ) - assert entry.unique_id == smartapp.format_unique_id( - app.app_id, location.location_id - ) - - -async def test_entry_created_existing_app_new_oauth_client( - hass: HomeAssistant, app, app_oauth_client, location, smartthings_mock -) -> None: - """Test entry is created with an existing app and generation of a new oauth client.""" - token = str(uuid4()) - installed_app_id = str(uuid4()) - refresh_token = str(uuid4()) - smartthings_mock.apps.return_value = [app] - smartthings_mock.generate_app_oauth.return_value = app_oauth_client - smartthings_mock.locations.return_value = [location] - smartthings_mock.create_app = AsyncMock(return_value=(app, app_oauth_client)) - request = Mock() - request.installed_app_id = installed_app_id - request.auth_token = token - request.location_id = location.location_id - request.refresh_token = refresh_token - - # Webhook confirmation shown - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - assert result["description_placeholders"][ - "webhook_url" - ] == smartapp.get_webhook_url(hass) - - # Advance to PAT screen - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "pat" - assert "token_url" in result["description_placeholders"] - assert "component_url" in result["description_placeholders"] - - # Enter token and advance to location screen - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_ACCESS_TOKEN: token} - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "select_location" - - # Select location and advance to external auth - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_LOCATION_ID: location.location_id} - ) - assert result["type"] is FlowResultType.EXTERNAL_STEP - assert result["step_id"] == "authorize" - assert result["url"] == format_install_url(app.app_id, location.location_id) - - # Complete external auth and advance to install - await smartapp.smartapp_install(hass, request, None, app) - - # Finish - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["data"]["app_id"] == app.app_id - assert result["data"]["installed_app_id"] == installed_app_id - assert result["data"]["location_id"] == location.location_id - assert result["data"]["access_token"] == token - assert result["data"]["refresh_token"] == request.refresh_token - assert result["data"][CONF_CLIENT_SECRET] == app_oauth_client.client_secret - assert result["data"][CONF_CLIENT_ID] == app_oauth_client.client_id - assert result["title"] == location.name - entry = next( - (entry for entry in hass.config_entries.async_entries(DOMAIN)), - None, - ) - assert entry.unique_id == smartapp.format_unique_id( - app.app_id, location.location_id - ) - - -async def test_entry_created_existing_app_copies_oauth_client( - hass: HomeAssistant, app, location, smartthings_mock -) -> None: - """Test entry is created with an existing app and copies the oauth client from another entry.""" - token = str(uuid4()) - installed_app_id = str(uuid4()) - refresh_token = str(uuid4()) - oauth_client_id = str(uuid4()) - oauth_client_secret = str(uuid4()) - smartthings_mock.apps.return_value = [app] - smartthings_mock.locations.return_value = [location] - request = Mock() - request.installed_app_id = installed_app_id - request.auth_token = token - request.location_id = location.location_id - request.refresh_token = refresh_token - entry = MockConfigEntry( - domain=DOMAIN, - data={ - CONF_APP_ID: app.app_id, - CONF_CLIENT_ID: oauth_client_id, - CONF_CLIENT_SECRET: oauth_client_secret, - CONF_LOCATION_ID: str(uuid4()), - CONF_INSTALLED_APP_ID: str(uuid4()), - CONF_ACCESS_TOKEN: token, - }, - ) - entry.add_to_hass(hass) - - # Webhook confirmation shown - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - assert result["description_placeholders"][ - "webhook_url" - ] == smartapp.get_webhook_url(hass) - - # Advance to PAT screen - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "pat" - assert "token_url" in result["description_placeholders"] - assert "component_url" in result["description_placeholders"] - # Assert access token is defaulted to an existing entry for convenience. - assert result["data_schema"]({}) == {CONF_ACCESS_TOKEN: token} - - # Enter token and advance to location screen - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_ACCESS_TOKEN: token} - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "select_location" - - # Select location and advance to external auth - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_LOCATION_ID: location.location_id} - ) - assert result["type"] is FlowResultType.EXTERNAL_STEP - assert result["step_id"] == "authorize" - assert result["url"] == format_install_url(app.app_id, location.location_id) - - # Complete external auth and advance to install - await smartapp.smartapp_install(hass, request, None, app) - - # Finish - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["data"]["app_id"] == app.app_id - assert result["data"]["installed_app_id"] == installed_app_id - assert result["data"]["location_id"] == location.location_id - assert result["data"]["access_token"] == token - assert result["data"]["refresh_token"] == request.refresh_token - assert result["data"][CONF_CLIENT_SECRET] == oauth_client_secret - assert result["data"][CONF_CLIENT_ID] == oauth_client_id - assert result["title"] == location.name - entry = next( - ( - entry - for entry in hass.config_entries.async_entries(DOMAIN) - if entry.data[CONF_INSTALLED_APP_ID] == installed_app_id - ), - None, - ) - assert entry.unique_id == smartapp.format_unique_id( - app.app_id, location.location_id - ) - - -async def test_entry_created_with_cloudhook( - hass: HomeAssistant, app, app_oauth_client, location, smartthings_mock -) -> None: - """Test cloud, new app, install event creates entry.""" - hass.config.components.add("cloud") - # Unload the endpoint so we can reload it under the cloud. - await smartapp.unload_smartapp_endpoint(hass) - token = str(uuid4()) - installed_app_id = str(uuid4()) - refresh_token = str(uuid4()) - smartthings_mock.apps.return_value = [] - smartthings_mock.create_app = AsyncMock(return_value=(app, app_oauth_client)) - smartthings_mock.locations = AsyncMock(return_value=[location]) - request = Mock() - request.installed_app_id = installed_app_id - request.auth_token = token - request.location_id = location.location_id - request.refresh_token = refresh_token - - with ( - patch.object( - smartapp.cloud, - "async_active_subscription", - Mock(return_value=True), - ), - patch.object( - smartapp.cloud, - "async_create_cloudhook", - AsyncMock(return_value="http://cloud.test"), - ) as mock_create_cloudhook, - ): - await smartapp.setup_smartapp_endpoint(hass, True) - - # Webhook confirmation shown - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - assert result["description_placeholders"][ - "webhook_url" - ] == smartapp.get_webhook_url(hass) - # One is done by app fixture, one done by new config entry - assert mock_create_cloudhook.call_count == 2 - - # Advance to PAT screen - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "pat" - assert "token_url" in result["description_placeholders"] - assert "component_url" in result["description_placeholders"] - - # Enter token and advance to location screen - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_ACCESS_TOKEN: token} - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "select_location" - - # Select location and advance to external auth - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_LOCATION_ID: location.location_id} - ) - assert result["type"] is FlowResultType.EXTERNAL_STEP - assert result["step_id"] == "authorize" - assert result["url"] == format_install_url(app.app_id, location.location_id) - - # Complete external auth and advance to install - await smartapp.smartapp_install(hass, request, None, app) - - # Finish - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["data"]["app_id"] == app.app_id - assert result["data"]["installed_app_id"] == installed_app_id - assert result["data"]["location_id"] == location.location_id - assert result["data"]["access_token"] == token - assert result["data"]["refresh_token"] == request.refresh_token - assert result["data"][CONF_CLIENT_SECRET] == app_oauth_client.client_secret - assert result["data"][CONF_CLIENT_ID] == app_oauth_client.client_id - assert result["title"] == location.name - entry = next( - (entry for entry in hass.config_entries.async_entries(DOMAIN)), - None, - ) - assert entry.unique_id == smartapp.format_unique_id( - app.app_id, location.location_id - ) - - -async def test_invalid_webhook_aborts(hass: HomeAssistant) -> None: - """Test flow aborts if webhook is invalid.""" - # Webhook confirmation shown - await async_process_ha_core_config( + state = config_entry_oauth2_flow._encode_jwt( hass, - {"external_url": "http://example.local:8123"}, - ) - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "invalid_webhook_url" - assert result["description_placeholders"][ - "webhook_url" - ] == smartapp.get_webhook_url(hass) - assert "component_url" in result["description_placeholders"] - - -async def test_invalid_token_shows_error(hass: HomeAssistant) -> None: - """Test an error is shown for invalid token formats.""" - token = "123456789" - - # Webhook confirmation shown - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - assert result["description_placeholders"][ - "webhook_url" - ] == smartapp.get_webhook_url(hass) - - # Advance to PAT screen - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "pat" - assert "token_url" in result["description_placeholders"] - assert "component_url" in result["description_placeholders"] - - # Enter token - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_ACCESS_TOKEN: token} - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "pat" - assert result["data_schema"]({}) == {CONF_ACCESS_TOKEN: token} - assert result["errors"] == {CONF_ACCESS_TOKEN: "token_invalid_format"} - assert "token_url" in result["description_placeholders"] - assert "component_url" in result["description_placeholders"] - - -async def test_unauthorized_token_shows_error( - hass: HomeAssistant, smartthings_mock -) -> None: - """Test an error is shown for unauthorized token formats.""" - token = str(uuid4()) - request_info = Mock(real_url="http://example.com") - smartthings_mock.apps.side_effect = ClientResponseError( - request_info=request_info, history=None, status=HTTPStatus.UNAUTHORIZED - ) - - # Webhook confirmation shown - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - assert result["description_placeholders"][ - "webhook_url" - ] == smartapp.get_webhook_url(hass) - - # Advance to PAT screen - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "pat" - assert "token_url" in result["description_placeholders"] - assert "component_url" in result["description_placeholders"] - - # Enter token - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_ACCESS_TOKEN: token} - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "pat" - assert result["data_schema"]({}) == {CONF_ACCESS_TOKEN: token} - assert result["errors"] == {CONF_ACCESS_TOKEN: "token_unauthorized"} - assert "token_url" in result["description_placeholders"] - assert "component_url" in result["description_placeholders"] - - -async def test_forbidden_token_shows_error( - hass: HomeAssistant, smartthings_mock -) -> None: - """Test an error is shown for forbidden token formats.""" - token = str(uuid4()) - request_info = Mock(real_url="http://example.com") - smartthings_mock.apps.side_effect = ClientResponseError( - request_info=request_info, history=None, status=HTTPStatus.FORBIDDEN - ) - - # Webhook confirmation shown - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - assert result["description_placeholders"][ - "webhook_url" - ] == smartapp.get_webhook_url(hass) - - # Advance to PAT screen - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "pat" - assert "token_url" in result["description_placeholders"] - assert "component_url" in result["description_placeholders"] - - # Enter token - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_ACCESS_TOKEN: token} - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "pat" - assert result["data_schema"]({}) == {CONF_ACCESS_TOKEN: token} - assert result["errors"] == {CONF_ACCESS_TOKEN: "token_forbidden"} - assert "token_url" in result["description_placeholders"] - assert "component_url" in result["description_placeholders"] - - -async def test_webhook_problem_shows_error( - hass: HomeAssistant, smartthings_mock -) -> None: - """Test an error is shown when there's an problem with the webhook endpoint.""" - token = str(uuid4()) - data = {"error": {}} - request_info = Mock(real_url="http://example.com") - error = APIResponseError( - request_info=request_info, - history=None, - data=data, - status=HTTPStatus.UNPROCESSABLE_ENTITY, - ) - error.is_target_error = Mock(return_value=True) - smartthings_mock.apps.side_effect = error - - # Webhook confirmation shown - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - assert result["description_placeholders"][ - "webhook_url" - ] == smartapp.get_webhook_url(hass) - - # Advance to PAT screen - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "pat" - assert "token_url" in result["description_placeholders"] - assert "component_url" in result["description_placeholders"] - - # Enter token - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_ACCESS_TOKEN: token} - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "pat" - assert result["data_schema"]({}) == {CONF_ACCESS_TOKEN: token} - assert result["errors"] == {"base": "webhook_error"} - assert "token_url" in result["description_placeholders"] - assert "component_url" in result["description_placeholders"] - - -async def test_api_error_shows_error(hass: HomeAssistant, smartthings_mock) -> None: - """Test an error is shown when other API errors occur.""" - token = str(uuid4()) - data = {"error": {}} - request_info = Mock(real_url="http://example.com") - error = APIResponseError( - request_info=request_info, - history=None, - data=data, - status=HTTPStatus.BAD_REQUEST, - ) - smartthings_mock.apps.side_effect = error - - # Webhook confirmation shown - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - assert result["description_placeholders"][ - "webhook_url" - ] == smartapp.get_webhook_url(hass) - - # Advance to PAT screen - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "pat" - assert "token_url" in result["description_placeholders"] - assert "component_url" in result["description_placeholders"] - - # Enter token - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_ACCESS_TOKEN: token} - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "pat" - assert result["data_schema"]({}) == {CONF_ACCESS_TOKEN: token} - assert result["errors"] == {"base": "app_setup_error"} - assert "token_url" in result["description_placeholders"] - assert "component_url" in result["description_placeholders"] - - -async def test_unknown_response_error_shows_error( - hass: HomeAssistant, smartthings_mock -) -> None: - """Test an error is shown when there is an unknown API error.""" - token = str(uuid4()) - request_info = Mock(real_url="http://example.com") - error = ClientResponseError( - request_info=request_info, history=None, status=HTTPStatus.NOT_FOUND - ) - smartthings_mock.apps.side_effect = error - - # Webhook confirmation shown - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - assert result["description_placeholders"][ - "webhook_url" - ] == smartapp.get_webhook_url(hass) - - # Advance to PAT screen - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "pat" - assert "token_url" in result["description_placeholders"] - assert "component_url" in result["description_placeholders"] - - # Enter token - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_ACCESS_TOKEN: token} - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "pat" - assert result["data_schema"]({}) == {CONF_ACCESS_TOKEN: token} - assert result["errors"] == {"base": "app_setup_error"} - assert "token_url" in result["description_placeholders"] - assert "component_url" in result["description_placeholders"] - - -async def test_unknown_error_shows_error(hass: HomeAssistant, smartthings_mock) -> None: - """Test an error is shown when there is an unknown API error.""" - token = str(uuid4()) - smartthings_mock.apps.side_effect = Exception("Unknown error") - - # Webhook confirmation shown - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - assert result["description_placeholders"][ - "webhook_url" - ] == smartapp.get_webhook_url(hass) - - # Advance to PAT screen - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "pat" - assert "token_url" in result["description_placeholders"] - assert "component_url" in result["description_placeholders"] - - # Enter token - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_ACCESS_TOKEN: token} - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "pat" - assert result["data_schema"]({}) == {CONF_ACCESS_TOKEN: token} - assert result["errors"] == {"base": "app_setup_error"} - assert "token_url" in result["description_placeholders"] - assert "component_url" in result["description_placeholders"] - - -async def test_no_available_locations_aborts( - hass: HomeAssistant, app, app_oauth_client, location, smartthings_mock -) -> None: - """Test select location aborts if no available locations.""" - token = str(uuid4()) - smartthings_mock.apps.return_value = [] - smartthings_mock.create_app.return_value = (app, app_oauth_client) - smartthings_mock.locations.return_value = [location] - entry = MockConfigEntry( - domain=DOMAIN, data={CONF_LOCATION_ID: location.location_id} - ) - entry.add_to_hass(hass) - - # Webhook confirmation shown - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - assert result["description_placeholders"][ - "webhook_url" - ] == smartapp.get_webhook_url(hass) - - # Advance to PAT screen - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "pat" - assert "token_url" in result["description_placeholders"] - assert "component_url" in result["description_placeholders"] - - # Enter token and advance to location screen - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_ACCESS_TOKEN: token} - ) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "no_available_locations" - - -async def test_reauth( - hass: HomeAssistant, app, app_oauth_client, location, smartthings_mock -) -> None: - """Test reauth flow.""" - token = str(uuid4()) - installed_app_id = str(uuid4()) - refresh_token = str(uuid4()) - smartthings_mock.apps.return_value = [] - smartthings_mock.create_app.return_value = (app, app_oauth_client) - smartthings_mock.locations.return_value = [location] - request = Mock() - request.installed_app_id = installed_app_id - request.auth_token = token - request.location_id = location.location_id - request.refresh_token = refresh_token - - entry = MockConfigEntry( - domain=DOMAIN, - data={ - CONF_APP_ID: app.app_id, - CONF_CLIENT_ID: app_oauth_client.client_id, - CONF_CLIENT_SECRET: app_oauth_client.client_secret, - CONF_LOCATION_ID: location.location_id, - CONF_INSTALLED_APP_ID: installed_app_id, - CONF_ACCESS_TOKEN: token, - CONF_REFRESH_TOKEN: "abc", + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", }, - unique_id=smartapp.format_unique_id(app.app_id, location.location_id), ) - entry.add_to_hass(hass) - result = await entry.start_reauth_flow(hass) + + assert result["type"] is FlowResultType.EXTERNAL_STEP + assert result["url"] == ( + "https://api.smartthings.com/oauth/authorize" + "?response_type=code&client_id=CLIENT_ID" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}" + "&scope=r:devices:*+w:devices:*+x:devices:*+r:hubs:*+" + "r:locations:*+w:locations:*+x:locations:*+r:scenes:*+" + "x:scenes:*+r:rules:*+w:rules:*+r:installedapps+w:installedapps+sse" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == HTTPStatus.OK + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.clear_requests() + aioclient_mock.post( + "https://auth-global.api.smartthings.com/oauth/token", + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "token_type": "Bearer", + "expires_in": 82806, + "scope": "r:devices:* w:devices:* x:devices:* r:hubs:* " + "r:locations:* w:locations:* x:locations:* " + "r:scenes:* x:scenes:* r:rules:* w:rules:* " + "r:installedapps w:installedapps sse", + "access_tier": 0, + "installed_app_id": "5aaaa925-2be1-4e40-b257-e4ef59083324", + }, + ) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.CREATE_ENTRY + result["data"]["token"].pop("expires_at") + assert result["data"][CONF_TOKEN] == { + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "token_type": "Bearer", + "expires_in": 82806, + "scope": "r:devices:* w:devices:* x:devices:* r:hubs:* " + "r:locations:* w:locations:* x:locations:* " + "r:scenes:* x:scenes:* r:rules:* w:rules:* " + "r:installedapps w:installedapps sse", + "access_tier": 0, + "installed_app_id": "5aaaa925-2be1-4e40-b257-e4ef59083324", + } + assert result["result"].unique_id == "397678e5-9995-4a39-9d9f-ae6ba310236c" + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_duplicate_entry( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_smartthings: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test duplicate entry is not able to set up.""" + mock_config_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + assert result["type"] is FlowResultType.EXTERNAL_STEP + assert result["url"] == ( + "https://api.smartthings.com/oauth/authorize" + "?response_type=code&client_id=CLIENT_ID" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}" + "&scope=r:devices:*+w:devices:*+x:devices:*+r:hubs:*+" + "r:locations:*+w:locations:*+x:locations:*+r:scenes:*+" + "x:scenes:*+r:rules:*+w:rules:*+r:installedapps+w:installedapps+sse" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == HTTPStatus.OK + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.clear_requests() + aioclient_mock.post( + "https://auth-global.api.smartthings.com/oauth/token", + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "token_type": "Bearer", + "expires_in": 82806, + "scope": "r:devices:* w:devices:* x:devices:* r:hubs:* " + "r:locations:* w:locations:* x:locations:* " + "r:scenes:* x:scenes:* r:rules:* w:rules:* " + "r:installedapps w:installedapps sse", + "access_tier": 0, + "installed_app_id": "5aaaa925-2be1-4e40-b257-e4ef59083324", + }, + ) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_reauthentication( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_smartthings: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test SmartThings reauthentication.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM assert result["step_id"] == "reauth_confirm" result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result["type"] is FlowResultType.EXTERNAL_STEP - assert result["step_id"] == "authorize" - assert result["url"] == format_install_url(app.app_id, location.location_id) - await smartapp.smartapp_update(hass, request, None, app) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + client = await hass_client_no_auth() + await client.get(f"/auth/external/callback?code=abcd&state={state}") + + aioclient_mock.post( + "https://auth-global.api.smartthings.com/oauth/token", + json={ + "refresh_token": "new-refresh-token", + "access_token": "new-access-token", + "token_type": "Bearer", + "expires_in": 82806, + "scope": "r:devices:* w:devices:* x:devices:* r:hubs:* " + "r:locations:* w:locations:* x:locations:* " + "r:scenes:* x:scenes:* r:rules:* w:rules:* " + "r:installedapps w:installedapps sse", + "access_tier": 0, + "installed_app_id": "5aaaa925-2be1-4e40-b257-e4ef59083324", + }, + ) result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "update_confirm" - - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reauth_successful" + mock_config_entry.data["token"].pop("expires_at") + assert mock_config_entry.data[CONF_TOKEN] == { + "refresh_token": "new-refresh-token", + "access_token": "new-access-token", + "token_type": "Bearer", + "expires_in": 82806, + "scope": "r:devices:* w:devices:* x:devices:* r:hubs:* " + "r:locations:* w:locations:* x:locations:* " + "r:scenes:* x:scenes:* r:rules:* w:rules:* " + "r:installedapps w:installedapps sse", + "access_tier": 0, + "installed_app_id": "5aaaa925-2be1-4e40-b257-e4ef59083324", + } - assert entry.data[CONF_REFRESH_TOKEN] == refresh_token + +@pytest.mark.usefixtures("current_request_with_host") +async def test_reauth_account_mismatch( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_smartthings: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test SmartThings reauthentication with different account.""" + mock_config_entry.add_to_hass(hass) + + mock_smartthings.get_locations.return_value[ + 0 + ].location_id = "123123123-2be1-4e40-b257-e4ef59083324" + + result = await mock_config_entry.start_reauth_flow(hass) + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + client = await hass_client_no_auth() + await client.get(f"/auth/external/callback?code=abcd&state={state}") + + aioclient_mock.post( + "https://auth-global.api.smartthings.com/oauth/token", + json={ + "refresh_token": "new-refresh-token", + "access_token": "new-access-token", + "token_type": "Bearer", + "expires_in": 82806, + "scope": "r:devices:* w:devices:* x:devices:* r:hubs:* " + "r:locations:* w:locations:* x:locations:* " + "r:scenes:* x:scenes:* r:rules:* w:rules:* " + "r:installedapps w:installedapps sse", + "access_tier": 0, + "installed_app_id": "123123123-2be1-4e40-b257-e4ef59083324", + }, + ) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_account_mismatch" + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_migration( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_smartthings: AsyncMock, + mock_old_config_entry: MockConfigEntry, +) -> None: + """Test SmartThings reauthentication with different account.""" + mock_old_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_old_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_old_config_entry.state is ConfigEntryState.SETUP_ERROR + + result = hass.config_entries.flow.async_progress()[0] + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + client = await hass_client_no_auth() + await client.get(f"/auth/external/callback?code=abcd&state={state}") + + aioclient_mock.post( + "https://auth-global.api.smartthings.com/oauth/token", + json={ + "refresh_token": "new-refresh-token", + "access_token": "new-access-token", + "token_type": "Bearer", + "expires_in": 82806, + "scope": "r:devices:* w:devices:* x:devices:* r:hubs:* " + "r:locations:* w:locations:* x:locations:* " + "r:scenes:* x:scenes:* r:rules:* w:rules:* " + "r:installedapps w:installedapps sse", + "access_tier": 0, + "installed_app_id": "123123123-2be1-4e40-b257-e4ef59083324", + }, + ) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert mock_old_config_entry.state is ConfigEntryState.LOADED + assert len(hass.config_entries.flow.async_progress()) == 0 + mock_old_config_entry.data[CONF_TOKEN].pop("expires_at") + assert mock_old_config_entry.data == { + "auth_implementation": DOMAIN, + "old_data": { + CONF_ACCESS_TOKEN: "mock-access-token", + CONF_REFRESH_TOKEN: "mock-refresh-token", + CONF_CLIENT_ID: "CLIENT_ID", + CONF_CLIENT_SECRET: "CLIENT_SECRET", + CONF_LOCATION_ID: "397678e5-9995-4a39-9d9f-ae6ba310236c", + CONF_INSTALLED_APP_ID: "123aa123-2be1-4e40-b257-e4ef59083324", + }, + CONF_TOKEN: { + "refresh_token": "new-refresh-token", + "access_token": "new-access-token", + "token_type": "Bearer", + "expires_in": 82806, + "scope": "r:devices:* w:devices:* x:devices:* r:hubs:* " + "r:locations:* w:locations:* x:locations:* " + "r:scenes:* x:scenes:* r:rules:* w:rules:* " + "r:installedapps w:installedapps sse", + "access_tier": 0, + "installed_app_id": "123123123-2be1-4e40-b257-e4ef59083324", + }, + CONF_LOCATION_ID: "397678e5-9995-4a39-9d9f-ae6ba310236c", + } + assert mock_old_config_entry.unique_id == "397678e5-9995-4a39-9d9f-ae6ba310236c" + assert mock_old_config_entry.version == 3 + assert mock_old_config_entry.minor_version == 1 + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_migration_wrong_location( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_smartthings: AsyncMock, + mock_old_config_entry: MockConfigEntry, +) -> None: + """Test SmartThings reauthentication with wrong location.""" + mock_old_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_old_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_old_config_entry.state is ConfigEntryState.SETUP_ERROR + + mock_smartthings.get_locations.return_value[ + 0 + ].location_id = "123123123-2be1-4e40-b257-e4ef59083324" + + result = hass.config_entries.flow.async_progress()[0] + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + client = await hass_client_no_auth() + await client.get(f"/auth/external/callback?code=abcd&state={state}") + + aioclient_mock.post( + "https://auth-global.api.smartthings.com/oauth/token", + json={ + "refresh_token": "new-refresh-token", + "access_token": "new-access-token", + "token_type": "Bearer", + "expires_in": 82806, + "scope": "r:devices:* w:devices:* x:devices:* r:hubs:* " + "r:locations:* w:locations:* x:locations:* " + "r:scenes:* x:scenes:* r:rules:* w:rules:* " + "r:installedapps w:installedapps sse", + "access_tier": 0, + "installed_app_id": "123123123-2be1-4e40-b257-e4ef59083324", + }, + ) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_location_mismatch" + assert mock_old_config_entry.state is ConfigEntryState.SETUP_ERROR + assert mock_old_config_entry.data == { + OLD_DATA: { + CONF_ACCESS_TOKEN: "mock-access-token", + CONF_REFRESH_TOKEN: "mock-refresh-token", + CONF_CLIENT_ID: "CLIENT_ID", + CONF_CLIENT_SECRET: "CLIENT_SECRET", + CONF_LOCATION_ID: "397678e5-9995-4a39-9d9f-ae6ba310236c", + CONF_INSTALLED_APP_ID: "123aa123-2be1-4e40-b257-e4ef59083324", + } + } + assert ( + mock_old_config_entry.unique_id + == "appid123-2be1-4e40-b257-e4ef59083324_397678e5-9995-4a39-9d9f-ae6ba310236c" + ) + assert mock_old_config_entry.version == 3 + assert mock_old_config_entry.minor_version == 1 diff --git a/tests/components/smartthings/test_cover.py b/tests/components/smartthings/test_cover.py index 31443c12ab2..37f12b44880 100644 --- a/tests/components/smartthings/test_cover.py +++ b/tests/components/smartthings/test_cover.py @@ -1,249 +1,192 @@ -"""Test for the SmartThings cover platform. +"""Test for the SmartThings cover platform.""" -The only mocking required is of the underlying SmartThings API object so -real HTTP calls are not initiated during testing. -""" +from unittest.mock import AsyncMock -from pysmartthings import Attribute, Capability +from pysmartthings import Attribute, Capability, Command, Status +import pytest +from syrupy import SnapshotAssertion from homeassistant.components.cover import ( ATTR_CURRENT_POSITION, ATTR_POSITION, DOMAIN as COVER_DOMAIN, +) +from homeassistant.components.smartthings.const import MAIN +from homeassistant.const import ( + ATTR_BATTERY_LEVEL, + ATTR_ENTITY_ID, SERVICE_CLOSE_COVER, SERVICE_OPEN_COVER, SERVICE_SET_COVER_POSITION, - CoverState, + STATE_OPEN, + STATE_OPENING, + Platform, ) -from homeassistant.components.smartthings.const import DOMAIN, SIGNAL_SMARTTHINGS_UPDATE -from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import ATTR_BATTERY_LEVEL, ATTR_ENTITY_ID, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers import entity_registry as er -from .conftest import setup_platform +from . import setup_integration, snapshot_smartthings_entities, trigger_update + +from tests.common import MockConfigEntry -async def test_entity_and_device_attributes( +async def test_all_entities( hass: HomeAssistant, - device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, entity_registry: er.EntityRegistry, - device_factory, ) -> None: - """Test the attributes of the entity are correct.""" - # Arrange - device = device_factory( - "Garage", - [Capability.garage_door_control], - { - Attribute.door: "open", - Attribute.mnmo: "123", - Attribute.mnmn: "Generic manufacturer", - Attribute.mnhw: "v4.56", - Attribute.mnfv: "v7.89", - }, + """Test all entities.""" + await setup_integration(hass, mock_config_entry) + + snapshot_smartthings_entities(hass, entity_registry, snapshot, Platform.COVER) + + +@pytest.mark.parametrize("device_fixture", ["c2c_shade"]) +@pytest.mark.parametrize( + ("action", "command"), + [ + (SERVICE_OPEN_COVER, Command.OPEN), + (SERVICE_CLOSE_COVER, Command.CLOSE), + ], +) +async def test_cover_open_close( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + action: str, + command: Command, +) -> None: + """Test cover open and close command.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + COVER_DOMAIN, + action, + {ATTR_ENTITY_ID: "cover.curtain_1a"}, + blocking=True, + ) + devices.execute_device_command.assert_called_once_with( + "571af102-15db-4030-b76b-245a691f74a5", + Capability.WINDOW_SHADE, + command, + MAIN, ) - # Act - await setup_platform(hass, COVER_DOMAIN, devices=[device]) - # Assert - entry = entity_registry.async_get("cover.garage") - assert entry - assert entry.unique_id == device.device_id - - entry = device_registry.async_get_device(identifiers={(DOMAIN, device.device_id)}) - assert entry - assert entry.configuration_url == "https://account.smartthings.com" - assert entry.identifiers == {(DOMAIN, device.device_id)} - assert entry.name == device.label - assert entry.model == "123" - assert entry.manufacturer == "Generic manufacturer" - assert entry.hw_version == "v4.56" - assert entry.sw_version == "v7.89" -async def test_open(hass: HomeAssistant, device_factory) -> None: - """Test the cover opens doors, garages, and shades successfully.""" - # Arrange - devices = { - device_factory("Door", [Capability.door_control], {Attribute.door: "closed"}), - device_factory( - "Garage", [Capability.garage_door_control], {Attribute.door: "closed"} - ), - device_factory( - "Shade", [Capability.window_shade], {Attribute.window_shade: "closed"} - ), +@pytest.mark.parametrize("device_fixture", ["c2c_shade"]) +async def test_cover_set_position( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test cover set position command.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_SET_COVER_POSITION, + {ATTR_ENTITY_ID: "cover.curtain_1a", ATTR_POSITION: 25}, + blocking=True, + ) + devices.execute_device_command.assert_called_once_with( + "571af102-15db-4030-b76b-245a691f74a5", + Capability.SWITCH_LEVEL, + Command.SET_LEVEL, + MAIN, + argument=25, + ) + + +@pytest.mark.parametrize("device_fixture", ["c2c_shade"]) +async def test_cover_battery( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test battery extra state attribute.""" + devices.get_device_status.return_value[MAIN][Capability.BATTERY] = { + Attribute.BATTERY: Status(50) } - await setup_platform(hass, COVER_DOMAIN, devices=devices) - entity_ids = ["cover.door", "cover.garage", "cover.shade"] - # Act - await hass.services.async_call( - COVER_DOMAIN, SERVICE_OPEN_COVER, {ATTR_ENTITY_ID: entity_ids}, blocking=True - ) - # Assert - for entity_id in entity_ids: - state = hass.states.get(entity_id) - assert state is not None - assert state.state == CoverState.OPENING + await setup_integration(hass, mock_config_entry) + + state = hass.states.get("cover.curtain_1a") + assert state + assert state.attributes[ATTR_BATTERY_LEVEL] == 50 -async def test_close(hass: HomeAssistant, device_factory) -> None: - """Test the cover closes doors, garages, and shades successfully.""" - # Arrange - devices = { - device_factory("Door", [Capability.door_control], {Attribute.door: "open"}), - device_factory( - "Garage", [Capability.garage_door_control], {Attribute.door: "open"} - ), - device_factory( - "Shade", [Capability.window_shade], {Attribute.window_shade: "open"} - ), +@pytest.mark.parametrize("device_fixture", ["c2c_shade"]) +async def test_cover_battery_updating( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test battery extra state attribute.""" + devices.get_device_status.return_value[MAIN][Capability.BATTERY] = { + Attribute.BATTERY: Status(50) } - await setup_platform(hass, COVER_DOMAIN, devices=devices) - entity_ids = ["cover.door", "cover.garage", "cover.shade"] - # Act - await hass.services.async_call( - COVER_DOMAIN, SERVICE_CLOSE_COVER, {ATTR_ENTITY_ID: entity_ids}, blocking=True + await setup_integration(hass, mock_config_entry) + + state = hass.states.get("cover.curtain_1a") + assert state + assert state.attributes[ATTR_BATTERY_LEVEL] == 50 + + await trigger_update( + hass, + devices, + "571af102-15db-4030-b76b-245a691f74a5", + Capability.BATTERY, + Attribute.BATTERY, + 49, ) - # Assert - for entity_id in entity_ids: - state = hass.states.get(entity_id) - assert state is not None - assert state.state == CoverState.CLOSING + + state = hass.states.get("cover.curtain_1a") + assert state + assert state.attributes[ATTR_BATTERY_LEVEL] == 49 -async def test_set_cover_position_switch_level( - hass: HomeAssistant, device_factory +@pytest.mark.parametrize("device_fixture", ["c2c_shade"]) +async def test_state_update( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, ) -> None: - """Test the cover sets to the specific position for legacy devices that use Capability.switch_level.""" - # Arrange - device = device_factory( - "Shade", - [Capability.window_shade, Capability.battery, Capability.switch_level], - {Attribute.window_shade: "opening", Attribute.battery: 95, Attribute.level: 10}, - ) - await setup_platform(hass, COVER_DOMAIN, devices=[device]) - # Act - await hass.services.async_call( - COVER_DOMAIN, - SERVICE_SET_COVER_POSITION, - {ATTR_POSITION: 50, "entity_id": "all"}, - blocking=True, + """Test state update.""" + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("cover.curtain_1a").state == STATE_OPEN + + await trigger_update( + hass, + devices, + "571af102-15db-4030-b76b-245a691f74a5", + Capability.WINDOW_SHADE, + Attribute.WINDOW_SHADE, + "opening", ) - state = hass.states.get("cover.shade") - # Result of call does not update state - assert state.state == CoverState.OPENING - assert state.attributes[ATTR_BATTERY_LEVEL] == 95 - assert state.attributes[ATTR_CURRENT_POSITION] == 10 - # Ensure API called - - assert device._api.post_device_command.call_count == 1 + assert hass.states.get("cover.curtain_1a").state == STATE_OPENING -async def test_set_cover_position(hass: HomeAssistant, device_factory) -> None: - """Test the cover sets to the specific position.""" - # Arrange - device = device_factory( - "Shade", - [Capability.window_shade, Capability.battery, Capability.window_shade_level], - { - Attribute.window_shade: "opening", - Attribute.battery: 95, - Attribute.shade_level: 10, - }, - ) - await setup_platform(hass, COVER_DOMAIN, devices=[device]) - # Act - await hass.services.async_call( - COVER_DOMAIN, - SERVICE_SET_COVER_POSITION, - {ATTR_POSITION: 50, "entity_id": "all"}, - blocking=True, - ) - - state = hass.states.get("cover.shade") - # Result of call does not update state - assert state.state == CoverState.OPENING - assert state.attributes[ATTR_BATTERY_LEVEL] == 95 - assert state.attributes[ATTR_CURRENT_POSITION] == 10 - # Ensure API called - - assert device._api.post_device_command.call_count == 1 - - -async def test_set_cover_position_unsupported( - hass: HomeAssistant, device_factory +@pytest.mark.parametrize("device_fixture", ["c2c_shade"]) +async def test_position_update( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, ) -> None: - """Test set position does nothing when not supported by device.""" - # Arrange - device = device_factory( - "Shade", [Capability.window_shade], {Attribute.window_shade: "opening"} - ) - await setup_platform(hass, COVER_DOMAIN, devices=[device]) - # Act - await hass.services.async_call( - COVER_DOMAIN, - SERVICE_SET_COVER_POSITION, - {"entity_id": "all", ATTR_POSITION: 50}, - blocking=True, + """Test position update.""" + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("cover.curtain_1a").attributes[ATTR_CURRENT_POSITION] == 100 + + await trigger_update( + hass, + devices, + "571af102-15db-4030-b76b-245a691f74a5", + Capability.SWITCH_LEVEL, + Attribute.LEVEL, + 50, ) - state = hass.states.get("cover.shade") - assert ATTR_CURRENT_POSITION not in state.attributes - - # Ensure API was not called - - assert device._api.post_device_command.call_count == 0 - - -async def test_update_to_open_from_signal(hass: HomeAssistant, device_factory) -> None: - """Test the cover updates to open when receiving a signal.""" - # Arrange - device = device_factory( - "Garage", [Capability.garage_door_control], {Attribute.door: "opening"} - ) - await setup_platform(hass, COVER_DOMAIN, devices=[device]) - device.status.update_attribute_value(Attribute.door, "open") - assert hass.states.get("cover.garage").state == CoverState.OPENING - # Act - async_dispatcher_send(hass, SIGNAL_SMARTTHINGS_UPDATE, [device.device_id]) - # Assert - await hass.async_block_till_done() - state = hass.states.get("cover.garage") - assert state is not None - assert state.state == CoverState.OPEN - - -async def test_update_to_closed_from_signal( - hass: HomeAssistant, device_factory -) -> None: - """Test the cover updates to closed when receiving a signal.""" - # Arrange - device = device_factory( - "Garage", [Capability.garage_door_control], {Attribute.door: "closing"} - ) - await setup_platform(hass, COVER_DOMAIN, devices=[device]) - device.status.update_attribute_value(Attribute.door, "closed") - assert hass.states.get("cover.garage").state == CoverState.CLOSING - # Act - async_dispatcher_send(hass, SIGNAL_SMARTTHINGS_UPDATE, [device.device_id]) - # Assert - await hass.async_block_till_done() - state = hass.states.get("cover.garage") - assert state is not None - assert state.state == CoverState.CLOSED - - -async def test_unload_config_entry(hass: HomeAssistant, device_factory) -> None: - """Test the lock is removed when the config entry is unloaded.""" - # Arrange - device = device_factory( - "Garage", [Capability.garage_door_control], {Attribute.door: "open"} - ) - config_entry = await setup_platform(hass, COVER_DOMAIN, devices=[device]) - config_entry.mock_state(hass, ConfigEntryState.LOADED) - # Act - await hass.config_entries.async_forward_entry_unload(config_entry, COVER_DOMAIN) - # Assert - assert hass.states.get("cover.garage").state == STATE_UNAVAILABLE + assert hass.states.get("cover.curtain_1a").attributes[ATTR_CURRENT_POSITION] == 50 diff --git a/tests/components/smartthings/test_fan.py b/tests/components/smartthings/test_fan.py index b78c453b402..58287355381 100644 --- a/tests/components/smartthings/test_fan.py +++ b/tests/components/smartthings/test_fan.py @@ -1,433 +1,168 @@ -"""Test for the SmartThings fan platform. +"""Test for the SmartThings fan platform.""" -The only mocking required is of the underlying SmartThings API object so -real HTTP calls are not initiated during testing. -""" +from unittest.mock import AsyncMock -from pysmartthings import Attribute, Capability +from pysmartthings import Capability, Command +import pytest +from syrupy import SnapshotAssertion from homeassistant.components.fan import ( ATTR_PERCENTAGE, ATTR_PRESET_MODE, - ATTR_PRESET_MODES, DOMAIN as FAN_DOMAIN, - FanEntityFeature, + SERVICE_SET_PERCENTAGE, + SERVICE_SET_PRESET_MODE, ) -from homeassistant.components.smartthings.const import DOMAIN, SIGNAL_SMARTTHINGS_UPDATE -from homeassistant.config_entries import ConfigEntryState +from homeassistant.components.smartthings import MAIN from homeassistant.const import ( ATTR_ENTITY_ID, - ATTR_SUPPORTED_FEATURES, - STATE_UNAVAILABLE, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers import entity_registry as er -from .conftest import setup_platform +from . import setup_integration, snapshot_smartthings_entities + +from tests.common import MockConfigEntry -async def test_entity_state(hass: HomeAssistant, device_factory) -> None: - """Tests the state attributes properly match the fan types.""" - device = device_factory( - "Fan 1", - capabilities=[Capability.switch, Capability.fan_speed], - status={Attribute.switch: "on", Attribute.fan_speed: 2}, - ) - await setup_platform(hass, FAN_DOMAIN, devices=[device]) - - # Dimmer 1 - state = hass.states.get("fan.fan_1") - assert state.state == "on" - assert ( - state.attributes[ATTR_SUPPORTED_FEATURES] - == FanEntityFeature.SET_SPEED - | FanEntityFeature.TURN_OFF - | FanEntityFeature.TURN_ON - ) - assert state.attributes[ATTR_PERCENTAGE] == 66 - - -async def test_entity_and_device_attributes( +async def test_all_entities( hass: HomeAssistant, - device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, entity_registry: er.EntityRegistry, - device_factory, ) -> None: - """Test the attributes of the entity are correct.""" - # Arrange - device = device_factory( - "Fan 1", - capabilities=[Capability.switch, Capability.fan_speed], - status={ - Attribute.switch: "on", - Attribute.fan_speed: 2, - Attribute.mnmo: "123", - Attribute.mnmn: "Generic manufacturer", - Attribute.mnhw: "v4.56", - Attribute.mnfv: "v7.89", - }, - ) - # Act - await setup_platform(hass, FAN_DOMAIN, devices=[device]) - # Assert - entry = entity_registry.async_get("fan.fan_1") - assert entry - assert entry.unique_id == device.device_id + """Test all entities.""" + await setup_integration(hass, mock_config_entry) - entry = device_registry.async_get_device(identifiers={(DOMAIN, device.device_id)}) - assert entry - assert entry.configuration_url == "https://account.smartthings.com" - assert entry.identifiers == {(DOMAIN, device.device_id)} - assert entry.name == device.label - assert entry.model == "123" - assert entry.manufacturer == "Generic manufacturer" - assert entry.hw_version == "v4.56" - assert entry.sw_version == "v7.89" + snapshot_smartthings_entities(hass, entity_registry, snapshot, Platform.FAN) -# Setup platform tests with varying capabilities -async def test_setup_mode_capability(hass: HomeAssistant, device_factory) -> None: - """Test setting up a fan with only the mode capability.""" - # Arrange - device = device_factory( - "Fan 1", - capabilities=[Capability.switch, Capability.air_conditioner_fan_mode], - status={ - Attribute.switch: "off", - Attribute.fan_mode: "high", - Attribute.supported_ac_fan_modes: ["high", "low", "medium"], - }, - ) - - await setup_platform(hass, FAN_DOMAIN, devices=[device]) - - # Assert - state = hass.states.get("fan.fan_1") - assert state is not None - assert ( - state.attributes[ATTR_SUPPORTED_FEATURES] - == FanEntityFeature.PRESET_MODE - | FanEntityFeature.TURN_OFF - | FanEntityFeature.TURN_ON - ) - assert state.attributes[ATTR_PRESET_MODE] == "high" - assert state.attributes[ATTR_PRESET_MODES] == ["high", "low", "medium"] - - -async def test_setup_speed_capability(hass: HomeAssistant, device_factory) -> None: - """Test setting up a fan with only the speed capability.""" - # Arrange - device = device_factory( - "Fan 1", - capabilities=[Capability.switch, Capability.fan_speed], - status={ - Attribute.switch: "off", - Attribute.fan_speed: 2, - }, - ) - - await setup_platform(hass, FAN_DOMAIN, devices=[device]) - - # Assert - state = hass.states.get("fan.fan_1") - assert state is not None - assert ( - state.attributes[ATTR_SUPPORTED_FEATURES] - == FanEntityFeature.SET_SPEED - | FanEntityFeature.TURN_OFF - | FanEntityFeature.TURN_ON - ) - assert state.attributes[ATTR_PERCENTAGE] == 66 - - -async def test_setup_both_capabilities(hass: HomeAssistant, device_factory) -> None: - """Test setting up a fan with both the mode and speed capability.""" - # Arrange - device = device_factory( - "Fan 1", - capabilities=[ - Capability.switch, - Capability.fan_speed, - Capability.air_conditioner_fan_mode, - ], - status={ - Attribute.switch: "off", - Attribute.fan_speed: 2, - Attribute.fan_mode: "high", - Attribute.supported_ac_fan_modes: ["high", "low", "medium"], - }, - ) - - await setup_platform(hass, FAN_DOMAIN, devices=[device]) - - # Assert - state = hass.states.get("fan.fan_1") - assert state is not None - assert ( - state.attributes[ATTR_SUPPORTED_FEATURES] - == FanEntityFeature.SET_SPEED - | FanEntityFeature.PRESET_MODE - | FanEntityFeature.TURN_OFF - | FanEntityFeature.TURN_ON - ) - assert state.attributes[ATTR_PERCENTAGE] == 66 - assert state.attributes[ATTR_PRESET_MODE] == "high" - assert state.attributes[ATTR_PRESET_MODES] == ["high", "low", "medium"] - - -# Speed Capability Tests - - -async def test_turn_off_speed_capability(hass: HomeAssistant, device_factory) -> None: - """Test the fan turns of successfully.""" - # Arrange - device = device_factory( - "Fan 1", - capabilities=[Capability.switch, Capability.fan_speed], - status={Attribute.switch: "on", Attribute.fan_speed: 2}, - ) - await setup_platform(hass, FAN_DOMAIN, devices=[device]) - # Act - await hass.services.async_call( - "fan", "turn_off", {"entity_id": "fan.fan_1"}, blocking=True - ) - # Assert - state = hass.states.get("fan.fan_1") - assert state is not None - assert state.state == "off" - - -async def test_turn_on_speed_capability(hass: HomeAssistant, device_factory) -> None: - """Test the fan turns of successfully.""" - # Arrange - device = device_factory( - "Fan 1", - capabilities=[Capability.switch, Capability.fan_speed], - status={Attribute.switch: "off", Attribute.fan_speed: 0}, - ) - await setup_platform(hass, FAN_DOMAIN, devices=[device]) - # Act - await hass.services.async_call( - "fan", "turn_on", {ATTR_ENTITY_ID: "fan.fan_1"}, blocking=True - ) - # Assert - state = hass.states.get("fan.fan_1") - assert state is not None - assert state.state == "on" - - -async def test_turn_on_with_speed_speed_capability( - hass: HomeAssistant, device_factory +@pytest.mark.parametrize("device_fixture", ["fake_fan"]) +@pytest.mark.parametrize( + ("action", "command"), + [ + (SERVICE_TURN_ON, Command.ON), + (SERVICE_TURN_OFF, Command.OFF), + ], +) +async def test_turn_on_off( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + action: str, + command: Command, ) -> None: - """Test the fan turns on to the specified speed.""" - # Arrange - device = device_factory( - "Fan 1", - capabilities=[Capability.switch, Capability.fan_speed], - status={Attribute.switch: "off", Attribute.fan_speed: 0}, - ) - await setup_platform(hass, FAN_DOMAIN, devices=[device]) - # Act + """Test turning on and off.""" + await setup_integration(hass, mock_config_entry) + await hass.services.async_call( - "fan", - "turn_on", - {ATTR_ENTITY_ID: "fan.fan_1", ATTR_PERCENTAGE: 100}, + FAN_DOMAIN, + action, + {ATTR_ENTITY_ID: "fan.fake_fan"}, blocking=True, ) - # Assert - state = hass.states.get("fan.fan_1") - assert state is not None - assert state.state == "on" - assert state.attributes[ATTR_PERCENTAGE] == 100 - - -async def test_turn_off_with_speed_speed_capability( - hass: HomeAssistant, device_factory -) -> None: - """Test the fan turns off with the speed.""" - # Arrange - device = device_factory( - "Fan 1", - capabilities=[Capability.switch, Capability.fan_speed], - status={Attribute.switch: "on", Attribute.fan_speed: 100}, + devices.execute_device_command.assert_called_once_with( + "f1af21a2-d5a1-437c-b10a-b34a87394b71", + Capability.SWITCH, + command, + MAIN, ) - await setup_platform(hass, FAN_DOMAIN, devices=[device]) - # Act + + +@pytest.mark.parametrize("device_fixture", ["fake_fan"]) +async def test_set_percentage( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setting the speed percentage of the fan.""" + await setup_integration(hass, mock_config_entry) + await hass.services.async_call( - "fan", - "set_percentage", - {ATTR_ENTITY_ID: "fan.fan_1", ATTR_PERCENTAGE: 0}, + FAN_DOMAIN, + SERVICE_SET_PERCENTAGE, + {ATTR_ENTITY_ID: "fan.fake_fan", ATTR_PERCENTAGE: 50}, blocking=True, ) - # Assert - state = hass.states.get("fan.fan_1") - assert state is not None - assert state.state == "off" - - -async def test_set_percentage_speed_capability( - hass: HomeAssistant, device_factory -) -> None: - """Test setting to specific fan speed.""" - # Arrange - device = device_factory( - "Fan 1", - capabilities=[Capability.switch, Capability.fan_speed], - status={Attribute.switch: "off", Attribute.fan_speed: 0}, + devices.execute_device_command.assert_called_once_with( + "f1af21a2-d5a1-437c-b10a-b34a87394b71", + Capability.FAN_SPEED, + Command.SET_FAN_SPEED, + MAIN, + argument=2, ) - await setup_platform(hass, FAN_DOMAIN, devices=[device]) - # Act + + +@pytest.mark.parametrize("device_fixture", ["fake_fan"]) +async def test_set_percentage_off( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setting the speed percentage of the fan.""" + await setup_integration(hass, mock_config_entry) + await hass.services.async_call( - "fan", - "set_percentage", - {ATTR_ENTITY_ID: "fan.fan_1", ATTR_PERCENTAGE: 100}, + FAN_DOMAIN, + SERVICE_SET_PERCENTAGE, + {ATTR_ENTITY_ID: "fan.fake_fan", ATTR_PERCENTAGE: 0}, blocking=True, ) - # Assert - state = hass.states.get("fan.fan_1") - assert state is not None - assert state.state == "on" - assert state.attributes[ATTR_PERCENTAGE] == 100 + devices.execute_device_command.assert_called_once_with( + "f1af21a2-d5a1-437c-b10a-b34a87394b71", + Capability.SWITCH, + Command.OFF, + MAIN, + ) -async def test_update_from_signal_speed_capability( - hass: HomeAssistant, device_factory +@pytest.mark.parametrize("device_fixture", ["fake_fan"]) +async def test_set_percentage_on( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, ) -> None: - """Test the fan updates when receiving a signal.""" - # Arrange - device = device_factory( - "Fan 1", - capabilities=[Capability.switch, Capability.fan_speed], - status={Attribute.switch: "off", Attribute.fan_speed: 0}, - ) - await setup_platform(hass, FAN_DOMAIN, devices=[device]) - await device.switch_on(True) - # Act - async_dispatcher_send(hass, SIGNAL_SMARTTHINGS_UPDATE, [device.device_id]) - # Assert - await hass.async_block_till_done() - state = hass.states.get("fan.fan_1") - assert state is not None - assert state.state == "on" + """Test setting the speed percentage of the fan.""" + await setup_integration(hass, mock_config_entry) - -async def test_unload_config_entry(hass: HomeAssistant, device_factory) -> None: - """Test the fan is removed when the config entry is unloaded.""" - # Arrange - device = device_factory( - "Fan 1", - capabilities=[Capability.switch, Capability.fan_speed], - status={Attribute.switch: "off", Attribute.fan_speed: 0}, - ) - config_entry = await setup_platform(hass, FAN_DOMAIN, devices=[device]) - config_entry.mock_state(hass, ConfigEntryState.LOADED) - # Act - await hass.config_entries.async_forward_entry_unload(config_entry, "fan") - # Assert - assert hass.states.get("fan.fan_1").state == STATE_UNAVAILABLE - - -# Preset Mode Tests - - -async def test_turn_off_mode_capability(hass: HomeAssistant, device_factory) -> None: - """Test the fan turns of successfully.""" - # Arrange - device = device_factory( - "Fan 1", - capabilities=[Capability.switch, Capability.air_conditioner_fan_mode], - status={ - Attribute.switch: "on", - Attribute.fan_mode: "high", - Attribute.supported_ac_fan_modes: ["high", "low", "medium"], - }, - ) - await setup_platform(hass, FAN_DOMAIN, devices=[device]) - # Act await hass.services.async_call( - "fan", "turn_off", {"entity_id": "fan.fan_1"}, blocking=True - ) - # Assert - state = hass.states.get("fan.fan_1") - assert state is not None - assert state.state == "off" - assert state.attributes[ATTR_PRESET_MODE] == "high" - - -async def test_turn_on_mode_capability(hass: HomeAssistant, device_factory) -> None: - """Test the fan turns of successfully.""" - # Arrange - device = device_factory( - "Fan 1", - capabilities=[Capability.switch, Capability.air_conditioner_fan_mode], - status={ - Attribute.switch: "off", - Attribute.fan_mode: "high", - Attribute.supported_ac_fan_modes: ["high", "low", "medium"], - }, - ) - await setup_platform(hass, FAN_DOMAIN, devices=[device]) - # Act - await hass.services.async_call( - "fan", "turn_on", {ATTR_ENTITY_ID: "fan.fan_1"}, blocking=True - ) - # Assert - state = hass.states.get("fan.fan_1") - assert state is not None - assert state.state == "on" - assert state.attributes[ATTR_PRESET_MODE] == "high" - - -async def test_update_from_signal_mode_capability( - hass: HomeAssistant, device_factory -) -> None: - """Test the fan updates when receiving a signal.""" - # Arrange - device = device_factory( - "Fan 1", - capabilities=[Capability.switch, Capability.air_conditioner_fan_mode], - status={ - Attribute.switch: "off", - Attribute.fan_mode: "high", - Attribute.supported_ac_fan_modes: ["high", "low", "medium"], - }, - ) - await setup_platform(hass, FAN_DOMAIN, devices=[device]) - await device.switch_on(True) - # Act - async_dispatcher_send(hass, SIGNAL_SMARTTHINGS_UPDATE, [device.device_id]) - # Assert - await hass.async_block_till_done() - state = hass.states.get("fan.fan_1") - assert state is not None - assert state.state == "on" - - -async def test_set_preset_mode_mode_capability( - hass: HomeAssistant, device_factory -) -> None: - """Test setting to specific fan mode.""" - # Arrange - device = device_factory( - "Fan 1", - capabilities=[Capability.switch, Capability.air_conditioner_fan_mode], - status={ - Attribute.switch: "off", - Attribute.fan_mode: "high", - Attribute.supported_ac_fan_modes: ["high", "low", "medium"], - }, - ) - - await setup_platform(hass, FAN_DOMAIN, devices=[device]) - # Act - await hass.services.async_call( - "fan", - "set_preset_mode", - {ATTR_ENTITY_ID: "fan.fan_1", ATTR_PRESET_MODE: "low"}, + FAN_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "fan.fake_fan", ATTR_PERCENTAGE: 50}, blocking=True, ) - # Assert - state = hass.states.get("fan.fan_1") - assert state is not None - assert state.attributes[ATTR_PRESET_MODE] == "low" + devices.execute_device_command.assert_called_once_with( + "f1af21a2-d5a1-437c-b10a-b34a87394b71", + Capability.FAN_SPEED, + Command.SET_FAN_SPEED, + MAIN, + argument=2, + ) + + +@pytest.mark.parametrize("device_fixture", ["fake_fan"]) +async def test_set_preset_mode( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setting the speed percentage of the fan.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: "fan.fake_fan", ATTR_PRESET_MODE: "turbo"}, + blocking=True, + ) + devices.execute_device_command.assert_called_once_with( + "f1af21a2-d5a1-437c-b10a-b34a87394b71", + Capability.AIR_CONDITIONER_FAN_MODE, + Command.SET_FAN_MODE, + MAIN, + argument="turbo", + ) diff --git a/tests/components/smartthings/test_init.py b/tests/components/smartthings/test_init.py index 83372b58228..be88f11903e 100644 --- a/tests/components/smartthings/test_init.py +++ b/tests/components/smartthings/test_init.py @@ -1,568 +1,31 @@ """Tests for the SmartThings component init module.""" -from collections.abc import Callable, Coroutine -from datetime import datetime, timedelta -from http import HTTPStatus -from typing import Any -from unittest.mock import Mock, patch -from uuid import uuid4 +from unittest.mock import AsyncMock -from aiohttp import ClientConnectionError, ClientResponseError -from pysmartthings import InstalledAppStatus, OAuthToken -import pytest +from syrupy import SnapshotAssertion -from homeassistant import config_entries -from homeassistant.components import cloud, smartthings -from homeassistant.components.smartthings.const import ( - CONF_CLOUDHOOK_URL, - CONF_INSTALLED_APP_ID, - CONF_REFRESH_TOKEN, - DATA_BROKERS, - DOMAIN, - EVENT_BUTTON, - PLATFORMS, - SIGNAL_SMARTTHINGS_UPDATE, -) -from homeassistant.config_entries import ConfigEntryState +from homeassistant.components.smartthings.const import DOMAIN from homeassistant.core import HomeAssistant -from homeassistant.core_config import async_process_ha_core_config -from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers import device_registry as dr + +from . import setup_integration from tests.common import MockConfigEntry -async def test_migration_creates_new_flow( - hass: HomeAssistant, smartthings_mock, config_entry -) -> None: - """Test migration deletes app and creates new flow.""" - - config_entry.add_to_hass(hass) - hass.config_entries.async_update_entry(config_entry, version=1) - - await smartthings.async_migrate_entry(hass, config_entry) - await hass.async_block_till_done() - - assert smartthings_mock.delete_installed_app.call_count == 1 - assert smartthings_mock.delete_app.call_count == 1 - assert not hass.config_entries.async_entries(DOMAIN) - flows = hass.config_entries.flow.async_progress() - assert len(flows) == 1 - assert flows[0]["handler"] == "smartthings" - assert flows[0]["context"] == {"source": config_entries.SOURCE_IMPORT} - - -async def test_unrecoverable_api_errors_create_new_flow( - hass: HomeAssistant, config_entry, smartthings_mock -) -> None: - """Test a new config flow is initiated when there are API errors. - - 401 (unauthorized): Occurs when the access token is no longer valid. - 403 (forbidden/not found): Occurs when the app or installed app could - not be retrieved/found (likely deleted?) - """ - - config_entry.add_to_hass(hass) - request_info = Mock(real_url="http://example.com") - smartthings_mock.app.side_effect = ClientResponseError( - request_info=request_info, history=None, status=HTTPStatus.UNAUTHORIZED - ) - - # Assert setup returns false - result = await hass.config_entries.async_setup(config_entry.entry_id) - assert not result - - assert config_entry.state == ConfigEntryState.SETUP_ERROR - - -async def test_recoverable_api_errors_raise_not_ready( - hass: HomeAssistant, config_entry, smartthings_mock -) -> None: - """Test config entry not ready raised for recoverable API errors.""" - config_entry.add_to_hass(hass) - request_info = Mock(real_url="http://example.com") - smartthings_mock.app.side_effect = ClientResponseError( - request_info=request_info, - history=None, - status=HTTPStatus.INTERNAL_SERVER_ERROR, - ) - - with pytest.raises(ConfigEntryNotReady): - await smartthings.async_setup_entry(hass, config_entry) - - -async def test_scenes_api_errors_raise_not_ready( - hass: HomeAssistant, config_entry, app, installed_app, smartthings_mock -) -> None: - """Test if scenes are unauthorized we continue to load platforms.""" - config_entry.add_to_hass(hass) - request_info = Mock(real_url="http://example.com") - smartthings_mock.app.return_value = app - smartthings_mock.installed_app.return_value = installed_app - smartthings_mock.scenes.side_effect = ClientResponseError( - request_info=request_info, - history=None, - status=HTTPStatus.INTERNAL_SERVER_ERROR, - ) - with pytest.raises(ConfigEntryNotReady): - await smartthings.async_setup_entry(hass, config_entry) - - -async def test_connection_errors_raise_not_ready( - hass: HomeAssistant, config_entry, smartthings_mock -) -> None: - """Test config entry not ready raised for connection errors.""" - config_entry.add_to_hass(hass) - smartthings_mock.app.side_effect = ClientConnectionError() - - with pytest.raises(ConfigEntryNotReady): - await smartthings.async_setup_entry(hass, config_entry) - - -async def test_base_url_no_longer_https_does_not_load( - hass: HomeAssistant, config_entry, app, smartthings_mock -) -> None: - """Test base_url no longer valid creates a new flow.""" - await async_process_ha_core_config( - hass, - {"external_url": "http://example.local:8123"}, - ) - config_entry.add_to_hass(hass) - smartthings_mock.app.return_value = app - - # Assert setup returns false - result = await smartthings.async_setup_entry(hass, config_entry) - assert not result - - -async def test_unauthorized_installed_app_raises_not_ready( - hass: HomeAssistant, config_entry, app, installed_app, smartthings_mock -) -> None: - """Test config entry not ready raised when the app isn't authorized.""" - config_entry.add_to_hass(hass) - installed_app.installed_app_status = InstalledAppStatus.PENDING - - smartthings_mock.app.return_value = app - smartthings_mock.installed_app.return_value = installed_app - - with pytest.raises(ConfigEntryNotReady): - await smartthings.async_setup_entry(hass, config_entry) - - -async def test_scenes_unauthorized_loads_platforms( +async def test_devices( hass: HomeAssistant, - config_entry, - app, - installed_app, - device, - smartthings_mock, - subscription_factory, + snapshot: SnapshotAssertion, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, ) -> None: - """Test if scenes are unauthorized we continue to load platforms.""" - config_entry.add_to_hass(hass) - request_info = Mock(real_url="http://example.com") - smartthings_mock.app.return_value = app - smartthings_mock.installed_app.return_value = installed_app - smartthings_mock.devices.return_value = [device] - smartthings_mock.scenes.side_effect = ClientResponseError( - request_info=request_info, history=None, status=HTTPStatus.FORBIDDEN - ) - mock_token = Mock() - mock_token.access_token = str(uuid4()) - mock_token.refresh_token = str(uuid4()) - smartthings_mock.generate_tokens.return_value = mock_token - subscriptions = [ - subscription_factory(capability) for capability in device.capabilities - ] - smartthings_mock.subscriptions.return_value = subscriptions + """Test all entities.""" + await setup_integration(hass, mock_config_entry) - with patch.object( - hass.config_entries, "async_forward_entry_setups" - ) as forward_mock: - assert await hass.config_entries.async_setup(config_entry.entry_id) - # Assert platforms loaded - await hass.async_block_till_done() - forward_mock.assert_called_once_with(config_entry, PLATFORMS) + device_id = devices.get_devices.return_value[0].device_id + device = device_registry.async_get_device({(DOMAIN, device_id)}) -async def test_config_entry_loads_platforms( - hass: HomeAssistant, - config_entry, - app, - installed_app, - device, - smartthings_mock, - subscription_factory, - scene, -) -> None: - """Test config entry loads properly and proxies to platforms.""" - config_entry.add_to_hass(hass) - smartthings_mock.app.return_value = app - smartthings_mock.installed_app.return_value = installed_app - smartthings_mock.devices.return_value = [device] - smartthings_mock.scenes.return_value = [scene] - mock_token = Mock() - mock_token.access_token = str(uuid4()) - mock_token.refresh_token = str(uuid4()) - smartthings_mock.generate_tokens.return_value = mock_token - subscriptions = [ - subscription_factory(capability) for capability in device.capabilities - ] - smartthings_mock.subscriptions.return_value = subscriptions - - with patch.object( - hass.config_entries, "async_forward_entry_setups" - ) as forward_mock: - assert await hass.config_entries.async_setup(config_entry.entry_id) - # Assert platforms loaded - await hass.async_block_till_done() - forward_mock.assert_called_once_with(config_entry, PLATFORMS) - - -async def test_config_entry_loads_unconnected_cloud( - hass: HomeAssistant, - config_entry, - app, - installed_app, - device, - smartthings_mock, - subscription_factory, - scene, -) -> None: - """Test entry loads during startup when cloud isn't connected.""" - config_entry.add_to_hass(hass) - hass.data[DOMAIN][CONF_CLOUDHOOK_URL] = "https://test.cloud" - smartthings_mock.app.return_value = app - smartthings_mock.installed_app.return_value = installed_app - smartthings_mock.devices.return_value = [device] - smartthings_mock.scenes.return_value = [scene] - mock_token = Mock() - mock_token.access_token = str(uuid4()) - mock_token.refresh_token = str(uuid4()) - smartthings_mock.generate_tokens.return_value = mock_token - subscriptions = [ - subscription_factory(capability) for capability in device.capabilities - ] - smartthings_mock.subscriptions.return_value = subscriptions - with patch.object( - hass.config_entries, "async_forward_entry_setups" - ) as forward_mock: - assert await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - forward_mock.assert_called_once_with(config_entry, PLATFORMS) - - -async def test_unload_entry(hass: HomeAssistant, config_entry) -> None: - """Test entries are unloaded correctly.""" - connect_disconnect = Mock() - smart_app = Mock() - smart_app.connect_event.return_value = connect_disconnect - broker = smartthings.DeviceBroker(hass, config_entry, Mock(), smart_app, [], []) - broker.connect() - hass.data[DOMAIN][DATA_BROKERS][config_entry.entry_id] = broker - - with patch.object( - hass.config_entries, "async_forward_entry_unload", return_value=True - ) as forward_mock: - assert await smartthings.async_unload_entry(hass, config_entry) - - assert connect_disconnect.call_count == 1 - assert config_entry.entry_id not in hass.data[DOMAIN][DATA_BROKERS] - # Assert platforms unloaded - await hass.async_block_till_done() - assert forward_mock.call_count == len(PLATFORMS) - - -async def test_remove_entry( - hass: HomeAssistant, config_entry, smartthings_mock -) -> None: - """Test that the installed app and app are removed up.""" - # Act - await smartthings.async_remove_entry(hass, config_entry) - # Assert - assert smartthings_mock.delete_installed_app.call_count == 1 - assert smartthings_mock.delete_app.call_count == 1 - - -async def test_remove_entry_cloudhook( - hass: HomeAssistant, config_entry, smartthings_mock -) -> None: - """Test that the installed app, app, and cloudhook are removed up.""" - hass.config.components.add("cloud") - # Arrange - config_entry.add_to_hass(hass) - hass.data[DOMAIN][CONF_CLOUDHOOK_URL] = "https://test.cloud" - # Act - with ( - patch.object( - cloud, "async_is_logged_in", return_value=True - ) as mock_async_is_logged_in, - patch.object(cloud, "async_delete_cloudhook") as mock_async_delete_cloudhook, - ): - await smartthings.async_remove_entry(hass, config_entry) - # Assert - assert smartthings_mock.delete_installed_app.call_count == 1 - assert smartthings_mock.delete_app.call_count == 1 - assert mock_async_is_logged_in.call_count == 1 - assert mock_async_delete_cloudhook.call_count == 1 - - -async def test_remove_entry_app_in_use( - hass: HomeAssistant, config_entry, smartthings_mock -) -> None: - """Test app is not removed if in use by another config entry.""" - # Arrange - config_entry.add_to_hass(hass) - data = config_entry.data.copy() - data[CONF_INSTALLED_APP_ID] = str(uuid4()) - entry2 = MockConfigEntry(version=2, domain=DOMAIN, data=data) - entry2.add_to_hass(hass) - # Act - await smartthings.async_remove_entry(hass, config_entry) - # Assert - assert smartthings_mock.delete_installed_app.call_count == 1 - assert smartthings_mock.delete_app.call_count == 0 - - -async def test_remove_entry_already_deleted( - hass: HomeAssistant, config_entry, smartthings_mock -) -> None: - """Test handles when the apps have already been removed.""" - request_info = Mock(real_url="http://example.com") - # Arrange - smartthings_mock.delete_installed_app.side_effect = ClientResponseError( - request_info=request_info, history=None, status=HTTPStatus.FORBIDDEN - ) - smartthings_mock.delete_app.side_effect = ClientResponseError( - request_info=request_info, history=None, status=HTTPStatus.FORBIDDEN - ) - # Act - await smartthings.async_remove_entry(hass, config_entry) - # Assert - assert smartthings_mock.delete_installed_app.call_count == 1 - assert smartthings_mock.delete_app.call_count == 1 - - -async def test_remove_entry_installedapp_api_error( - hass: HomeAssistant, config_entry, smartthings_mock -) -> None: - """Test raises exceptions removing the installed app.""" - request_info = Mock(real_url="http://example.com") - # Arrange - smartthings_mock.delete_installed_app.side_effect = ClientResponseError( - request_info=request_info, - history=None, - status=HTTPStatus.INTERNAL_SERVER_ERROR, - ) - # Act - with pytest.raises(ClientResponseError): - await smartthings.async_remove_entry(hass, config_entry) - # Assert - assert smartthings_mock.delete_installed_app.call_count == 1 - assert smartthings_mock.delete_app.call_count == 0 - - -async def test_remove_entry_installedapp_unknown_error( - hass: HomeAssistant, config_entry, smartthings_mock -) -> None: - """Test raises exceptions removing the installed app.""" - # Arrange - smartthings_mock.delete_installed_app.side_effect = ValueError - # Act - with pytest.raises(ValueError): - await smartthings.async_remove_entry(hass, config_entry) - # Assert - assert smartthings_mock.delete_installed_app.call_count == 1 - assert smartthings_mock.delete_app.call_count == 0 - - -async def test_remove_entry_app_api_error( - hass: HomeAssistant, config_entry, smartthings_mock -) -> None: - """Test raises exceptions removing the app.""" - # Arrange - request_info = Mock(real_url="http://example.com") - smartthings_mock.delete_app.side_effect = ClientResponseError( - request_info=request_info, - history=None, - status=HTTPStatus.INTERNAL_SERVER_ERROR, - ) - # Act - with pytest.raises(ClientResponseError): - await smartthings.async_remove_entry(hass, config_entry) - # Assert - assert smartthings_mock.delete_installed_app.call_count == 1 - assert smartthings_mock.delete_app.call_count == 1 - - -async def test_remove_entry_app_unknown_error( - hass: HomeAssistant, config_entry, smartthings_mock -) -> None: - """Test raises exceptions removing the app.""" - # Arrange - smartthings_mock.delete_app.side_effect = ValueError - # Act - with pytest.raises(ValueError): - await smartthings.async_remove_entry(hass, config_entry) - # Assert - assert smartthings_mock.delete_installed_app.call_count == 1 - assert smartthings_mock.delete_app.call_count == 1 - - -async def test_broker_regenerates_token(hass: HomeAssistant, config_entry) -> None: - """Test the device broker regenerates the refresh token.""" - token = Mock(OAuthToken) - token.refresh_token = str(uuid4()) - stored_action = None - config_entry.add_to_hass(hass) - - def async_track_time_interval( - hass: HomeAssistant, - action: Callable[[datetime], Coroutine[Any, Any, None] | None], - interval: timedelta, - ) -> None: - nonlocal stored_action - stored_action = action - - with patch( - "homeassistant.components.smartthings.async_track_time_interval", - new=async_track_time_interval, - ): - broker = smartthings.DeviceBroker(hass, config_entry, token, Mock(), [], []) - broker.connect() - - assert stored_action - await stored_action(None) - assert token.refresh.call_count == 1 - assert config_entry.data[CONF_REFRESH_TOKEN] == token.refresh_token - - -async def test_event_handler_dispatches_updated_devices( - hass: HomeAssistant, - config_entry, - device_factory, - event_request_factory, - event_factory, -) -> None: - """Test the event handler dispatches updated devices.""" - devices = [ - device_factory("Bedroom 1 Switch", ["switch"]), - device_factory("Bathroom 1", ["switch"]), - device_factory("Sensor", ["motionSensor"]), - device_factory("Lock", ["lock"]), - ] - device_ids = [ - devices[0].device_id, - devices[1].device_id, - devices[2].device_id, - devices[3].device_id, - ] - event = event_factory( - devices[3].device_id, - capability="lock", - attribute="lock", - value="locked", - data={"codeId": "1"}, - ) - request = event_request_factory(device_ids=device_ids, events=[event]) - config_entry.add_to_hass(hass) - hass.config_entries.async_update_entry( - config_entry, - data={ - **config_entry.data, - CONF_INSTALLED_APP_ID: request.installed_app_id, - }, - ) - called = False - - def signal(ids): - nonlocal called - called = True - assert device_ids == ids - - async_dispatcher_connect(hass, SIGNAL_SMARTTHINGS_UPDATE, signal) - - broker = smartthings.DeviceBroker(hass, config_entry, Mock(), Mock(), devices, []) - broker.connect() - - await broker._event_handler(request, None, None) - await hass.async_block_till_done() - - assert called - for device in devices: - assert device.status.values["Updated"] == "Value" - assert devices[3].status.attributes["lock"].value == "locked" - assert devices[3].status.attributes["lock"].data == {"codeId": "1"} - - broker.disconnect() - - -async def test_event_handler_ignores_other_installed_app( - hass: HomeAssistant, config_entry, device_factory, event_request_factory -) -> None: - """Test the event handler dispatches updated devices.""" - device = device_factory("Bedroom 1 Switch", ["switch"]) - request = event_request_factory([device.device_id]) - called = False - - def signal(ids): - nonlocal called - called = True - - async_dispatcher_connect(hass, SIGNAL_SMARTTHINGS_UPDATE, signal) - broker = smartthings.DeviceBroker(hass, config_entry, Mock(), Mock(), [device], []) - broker.connect() - - await broker._event_handler(request, None, None) - await hass.async_block_till_done() - - assert not called - - broker.disconnect() - - -async def test_event_handler_fires_button_events( - hass: HomeAssistant, - config_entry, - device_factory, - event_factory, - event_request_factory, -) -> None: - """Test the event handler fires button events.""" - device = device_factory("Button 1", ["button"]) - event = event_factory( - device.device_id, capability="button", attribute="button", value="pushed" - ) - request = event_request_factory(events=[event]) - config_entry.add_to_hass(hass) - hass.config_entries.async_update_entry( - config_entry, - data={ - **config_entry.data, - CONF_INSTALLED_APP_ID: request.installed_app_id, - }, - ) - called = False - - def handler(evt): - nonlocal called - called = True - assert evt.data == { - "component_id": "main", - "device_id": device.device_id, - "location_id": event.location_id, - "value": "pushed", - "name": device.label, - "data": None, - } - - hass.bus.async_listen(EVENT_BUTTON, handler) - broker = smartthings.DeviceBroker(hass, config_entry, Mock(), Mock(), [device], []) - broker.connect() - - await broker._event_handler(request, None, None) - await hass.async_block_till_done() - - assert called - - broker.disconnect() + assert device is not None + assert device == snapshot diff --git a/tests/components/smartthings/test_light.py b/tests/components/smartthings/test_light.py index b46188b5b5f..8d47e90c9f5 100644 --- a/tests/components/smartthings/test_light.py +++ b/tests/components/smartthings/test_light.py @@ -1,342 +1,307 @@ -"""Test for the SmartThings light platform. +"""Test for the SmartThings light platform.""" -The only mocking required is of the underlying SmartThings API object so -real HTTP calls are not initiated during testing. -""" +from typing import Any +from unittest.mock import AsyncMock, call -from pysmartthings import Attribute, Capability +from pysmartthings import Attribute, Capability, Command import pytest +from syrupy import SnapshotAssertion from homeassistant.components.light import ( ATTR_BRIGHTNESS, + ATTR_COLOR_MODE, ATTR_COLOR_TEMP_KELVIN, ATTR_HS_COLOR, - ATTR_SUPPORTED_COLOR_MODES, ATTR_TRANSITION, DOMAIN as LIGHT_DOMAIN, ColorMode, - LightEntityFeature, ) -from homeassistant.components.smartthings.const import DOMAIN, SIGNAL_SMARTTHINGS_UPDATE -from homeassistant.config_entries import ConfigEntryState +from homeassistant.components.smartthings.const import MAIN from homeassistant.const import ( ATTR_ENTITY_ID, - ATTR_SUPPORTED_FEATURES, - STATE_UNAVAILABLE, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_OFF, + STATE_ON, + Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers import entity_registry as er -from .conftest import setup_platform +from . import ( + set_attribute_value, + setup_integration, + snapshot_smartthings_entities, + trigger_update, +) + +from tests.common import MockConfigEntry -@pytest.fixture(name="light_devices") -def light_devices_fixture(device_factory): - """Fixture returns a set of mock light devices.""" - return [ - device_factory( - "Dimmer 1", - capabilities=[Capability.switch, Capability.switch_level], - status={Attribute.switch: "on", Attribute.level: 100}, - ), - device_factory( - "Color Dimmer 1", - capabilities=[ - Capability.switch, - Capability.switch_level, - Capability.color_control, - ], - status={ - Attribute.switch: "off", - Attribute.level: 0, - Attribute.hue: 76.0, - Attribute.saturation: 55.0, - }, - ), - device_factory( - "Color Dimmer 2", - capabilities=[ - Capability.switch, - Capability.switch_level, - Capability.color_control, - Capability.color_temperature, - ], - status={ - Attribute.switch: "on", - Attribute.level: 100, - Attribute.hue: 76.0, - Attribute.saturation: 0.0, - Attribute.color_temperature: 4500, - }, - ), - ] - - -async def test_entity_state(hass: HomeAssistant, light_devices) -> None: - """Tests the state attributes properly match the light types.""" - await setup_platform(hass, LIGHT_DOMAIN, devices=light_devices) - - # Dimmer 1 - state = hass.states.get("light.dimmer_1") - assert state.state == "on" - assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ColorMode.BRIGHTNESS] - assert state.attributes[ATTR_SUPPORTED_FEATURES] == LightEntityFeature.TRANSITION - assert isinstance(state.attributes[ATTR_BRIGHTNESS], int) - assert state.attributes[ATTR_BRIGHTNESS] == 255 - - # Color Dimmer 1 - state = hass.states.get("light.color_dimmer_1") - assert state.state == "off" - assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ColorMode.HS] - assert state.attributes[ATTR_SUPPORTED_FEATURES] == LightEntityFeature.TRANSITION - - # Color Dimmer 2 - state = hass.states.get("light.color_dimmer_2") - assert state.state == "on" - assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ - ColorMode.COLOR_TEMP, - ColorMode.HS, - ] - assert state.attributes[ATTR_SUPPORTED_FEATURES] == LightEntityFeature.TRANSITION - assert state.attributes[ATTR_BRIGHTNESS] == 255 - assert ATTR_HS_COLOR not in state.attributes[ATTR_HS_COLOR] - assert isinstance(state.attributes[ATTR_COLOR_TEMP_KELVIN], int) - assert state.attributes[ATTR_COLOR_TEMP_KELVIN] == 4500 - - -async def test_entity_and_device_attributes( +async def test_all_entities( hass: HomeAssistant, - device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, entity_registry: er.EntityRegistry, - device_factory, ) -> None: - """Test the attributes of the entity are correct.""" - # Arrange - device = device_factory( - "Light 1", - [Capability.switch, Capability.switch_level], - { - Attribute.mnmo: "123", - Attribute.mnmn: "Generic manufacturer", - Attribute.mnhw: "v4.56", - Attribute.mnfv: "v7.89", - }, - ) - # Act - await setup_platform(hass, LIGHT_DOMAIN, devices=[device]) - # Assert - entry = entity_registry.async_get("light.light_1") - assert entry - assert entry.unique_id == device.device_id + """Test all entities.""" + await setup_integration(hass, mock_config_entry) - entry = device_registry.async_get_device(identifiers={(DOMAIN, device.device_id)}) - assert entry - assert entry.configuration_url == "https://account.smartthings.com" - assert entry.identifiers == {(DOMAIN, device.device_id)} - assert entry.name == device.label - assert entry.model == "123" - assert entry.manufacturer == "Generic manufacturer" - assert entry.hw_version == "v4.56" - assert entry.sw_version == "v7.89" + snapshot_smartthings_entities(hass, entity_registry, snapshot, Platform.LIGHT) -async def test_turn_off(hass: HomeAssistant, light_devices) -> None: - """Test the light turns of successfully.""" - # Arrange - await setup_platform(hass, LIGHT_DOMAIN, devices=light_devices) - # Act - await hass.services.async_call( - "light", "turn_off", {"entity_id": "light.color_dimmer_2"}, blocking=True - ) - # This test schedules and update right after the call - await hass.async_block_till_done() - # Assert - state = hass.states.get("light.color_dimmer_2") - assert state is not None - assert state.state == "off" - - -async def test_turn_off_with_transition(hass: HomeAssistant, light_devices) -> None: - """Test the light turns of successfully with transition.""" - # Arrange - await setup_platform(hass, LIGHT_DOMAIN, devices=light_devices) - # Act - await hass.services.async_call( - "light", - "turn_off", - {ATTR_ENTITY_ID: "light.color_dimmer_2", ATTR_TRANSITION: 2}, - blocking=True, - ) - # This test schedules and update right after the call - await hass.async_block_till_done() - # Assert - state = hass.states.get("light.color_dimmer_2") - assert state is not None - assert state.state == "off" - - -async def test_turn_on(hass: HomeAssistant, light_devices) -> None: - """Test the light turns of successfully.""" - # Arrange - await setup_platform(hass, LIGHT_DOMAIN, devices=light_devices) - # Act - await hass.services.async_call( - "light", "turn_on", {ATTR_ENTITY_ID: "light.color_dimmer_1"}, blocking=True - ) - # This test schedules and update right after the call - await hass.async_block_till_done() - # Assert - state = hass.states.get("light.color_dimmer_1") - assert state is not None - assert state.state == "on" - - -async def test_turn_on_with_brightness(hass: HomeAssistant, light_devices) -> None: - """Test the light turns on to the specified brightness.""" - # Arrange - await setup_platform(hass, LIGHT_DOMAIN, devices=light_devices) - # Act - await hass.services.async_call( - "light", - "turn_on", - { - ATTR_ENTITY_ID: "light.color_dimmer_1", - ATTR_BRIGHTNESS: 75, - ATTR_TRANSITION: 2, - }, - blocking=True, - ) - # This test schedules and update right after the call - await hass.async_block_till_done() - # Assert - state = hass.states.get("light.color_dimmer_1") - assert state is not None - assert state.state == "on" - # round-trip rounding error (expected) - assert state.attributes[ATTR_BRIGHTNESS] == 74 - - -async def test_turn_on_with_minimal_brightness( - hass: HomeAssistant, light_devices +@pytest.mark.parametrize("device_fixture", ["hue_rgbw_color_bulb"]) +@pytest.mark.parametrize( + ("data", "calls"), + [ + ( + {}, + [ + call( + "cb958955-b015-498c-9e62-fc0c51abd054", + Capability.SWITCH, + Command.ON, + MAIN, + ) + ], + ), + ( + {ATTR_COLOR_TEMP_KELVIN: 4000}, + [ + call( + "cb958955-b015-498c-9e62-fc0c51abd054", + Capability.COLOR_TEMPERATURE, + Command.SET_COLOR_TEMPERATURE, + MAIN, + argument=4000, + ), + call( + "cb958955-b015-498c-9e62-fc0c51abd054", + Capability.SWITCH, + Command.ON, + MAIN, + ), + ], + ), + ( + {ATTR_HS_COLOR: [350, 90]}, + [ + call( + "cb958955-b015-498c-9e62-fc0c51abd054", + Capability.COLOR_CONTROL, + Command.SET_COLOR, + MAIN, + argument={"hue": 97.2222, "saturation": 90.0}, + ), + call( + "cb958955-b015-498c-9e62-fc0c51abd054", + Capability.SWITCH, + Command.ON, + MAIN, + ), + ], + ), + ( + {ATTR_BRIGHTNESS: 50}, + [ + call( + "cb958955-b015-498c-9e62-fc0c51abd054", + Capability.SWITCH_LEVEL, + Command.SET_LEVEL, + MAIN, + argument=[20, 0], + ) + ], + ), + ( + {ATTR_BRIGHTNESS: 50, ATTR_TRANSITION: 3}, + [ + call( + "cb958955-b015-498c-9e62-fc0c51abd054", + Capability.SWITCH_LEVEL, + Command.SET_LEVEL, + MAIN, + argument=[20, 3], + ) + ], + ), + ], +) +async def test_turn_on_light( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + data: dict[str, Any], + calls: list[call], ) -> None: - """Test lights set to lowest brightness when converted scale would be zero. + """Test light turn on command.""" + await setup_integration(hass, mock_config_entry) - SmartThings light brightness is a percentage (0-100), but Home Assistant uses a - 0-255 scale. This tests if a really low value (1-2) is passed, we don't - set the level to zero, which turns off the lights in SmartThings. - """ - # Arrange - await setup_platform(hass, LIGHT_DOMAIN, devices=light_devices) - # Act await hass.services.async_call( - "light", - "turn_on", - {ATTR_ENTITY_ID: "light.color_dimmer_1", ATTR_BRIGHTNESS: 2}, + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "light.standing_light"} | data, blocking=True, ) - # This test schedules and update right after the call - await hass.async_block_till_done() - # Assert - state = hass.states.get("light.color_dimmer_1") - assert state is not None - assert state.state == "on" - # round-trip rounding error (expected) - assert state.attributes[ATTR_BRIGHTNESS] == 3 + assert devices.execute_device_command.mock_calls == calls -async def test_turn_on_with_color(hass: HomeAssistant, light_devices) -> None: - """Test the light turns on with color.""" - # Arrange - await setup_platform(hass, LIGHT_DOMAIN, devices=light_devices) - # Act +@pytest.mark.parametrize("device_fixture", ["hue_rgbw_color_bulb"]) +@pytest.mark.parametrize( + ("data", "calls"), + [ + ( + {}, + [ + call( + "cb958955-b015-498c-9e62-fc0c51abd054", + Capability.SWITCH, + Command.OFF, + MAIN, + ) + ], + ), + ( + {ATTR_TRANSITION: 3}, + [ + call( + "cb958955-b015-498c-9e62-fc0c51abd054", + Capability.SWITCH_LEVEL, + Command.SET_LEVEL, + MAIN, + argument=[0, 3], + ) + ], + ), + ], +) +async def test_turn_off_light( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + data: dict[str, Any], + calls: list[call], +) -> None: + """Test light turn off command.""" + await setup_integration(hass, mock_config_entry) + await hass.services.async_call( - "light", - "turn_on", - {ATTR_ENTITY_ID: "light.color_dimmer_2", ATTR_HS_COLOR: (180, 50)}, + LIGHT_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "light.standing_light"} | data, blocking=True, ) - # This test schedules and update right after the call - await hass.async_block_till_done() - # Assert - state = hass.states.get("light.color_dimmer_2") - assert state is not None - assert state.state == "on" - assert state.attributes[ATTR_HS_COLOR] == (180, 50) + assert devices.execute_device_command.mock_calls == calls -async def test_turn_on_with_color_temp(hass: HomeAssistant, light_devices) -> None: - """Test the light turns on with color temp.""" - # Arrange - await setup_platform(hass, LIGHT_DOMAIN, devices=light_devices) - # Act - await hass.services.async_call( - "light", - "turn_on", - {ATTR_ENTITY_ID: "light.color_dimmer_2", ATTR_COLOR_TEMP_KELVIN: 3333}, - blocking=True, +@pytest.mark.parametrize("device_fixture", ["hue_rgbw_color_bulb"]) +async def test_state_update( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test state update.""" + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("light.standing_light").state == STATE_OFF + + await trigger_update( + hass, + devices, + "cb958955-b015-498c-9e62-fc0c51abd054", + Capability.SWITCH, + Attribute.SWITCH, + "on", ) - # This test schedules and update right after the call - await hass.async_block_till_done() - # Assert - state = hass.states.get("light.color_dimmer_2") - assert state is not None - assert state.state == "on" - assert state.attributes[ATTR_COLOR_TEMP_KELVIN] == 3333 + + assert hass.states.get("light.standing_light").state == STATE_ON -async def test_update_from_signal(hass: HomeAssistant, device_factory) -> None: - """Test the light updates when receiving a signal.""" - # Arrange - device = device_factory( - "Color Dimmer 2", - capabilities=[ - Capability.switch, - Capability.switch_level, - Capability.color_control, - Capability.color_temperature, - ], - status={ - Attribute.switch: "off", - Attribute.level: 100, - Attribute.hue: 76.0, - Attribute.saturation: 55.0, - Attribute.color_temperature: 4500, - }, +@pytest.mark.parametrize("device_fixture", ["hue_rgbw_color_bulb"]) +async def test_updating_brightness( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test brightness update.""" + set_attribute_value(devices, Capability.SWITCH, Attribute.SWITCH, "on") + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("light.standing_light").attributes[ATTR_BRIGHTNESS] == 178 + + await trigger_update( + hass, + devices, + "cb958955-b015-498c-9e62-fc0c51abd054", + Capability.SWITCH_LEVEL, + Attribute.LEVEL, + 20, ) - await setup_platform(hass, LIGHT_DOMAIN, devices=[device]) - await device.switch_on(True) - # Act - async_dispatcher_send(hass, SIGNAL_SMARTTHINGS_UPDATE, [device.device_id]) - # Assert - await hass.async_block_till_done() - state = hass.states.get("light.color_dimmer_2") - assert state is not None - assert state.state == "on" + + assert hass.states.get("light.standing_light").attributes[ATTR_BRIGHTNESS] == 51 -async def test_unload_config_entry(hass: HomeAssistant, device_factory) -> None: - """Test the light is removed when the config entry is unloaded.""" - # Arrange - device = device_factory( - "Color Dimmer 2", - capabilities=[ - Capability.switch, - Capability.switch_level, - Capability.color_control, - Capability.color_temperature, - ], - status={ - Attribute.switch: "off", - Attribute.level: 100, - Attribute.hue: 76.0, - Attribute.saturation: 55.0, - Attribute.color_temperature: 4500, - }, +@pytest.mark.parametrize("device_fixture", ["hue_rgbw_color_bulb"]) +async def test_updating_hs( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test hue/saturation update.""" + set_attribute_value(devices, Capability.SWITCH, Attribute.SWITCH, "on") + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("light.standing_light").attributes[ATTR_HS_COLOR] == ( + 218.906, + 60, + ) + + await trigger_update( + hass, + devices, + "cb958955-b015-498c-9e62-fc0c51abd054", + Capability.COLOR_CONTROL, + Attribute.HUE, + 20, + ) + + assert hass.states.get("light.standing_light").attributes[ATTR_HS_COLOR] == ( + 72.0, + 60, + ) + + +@pytest.mark.parametrize("device_fixture", ["hue_rgbw_color_bulb"]) +async def test_updating_color_temp( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test color temperature update.""" + set_attribute_value(devices, Capability.SWITCH, Attribute.SWITCH, "on") + set_attribute_value(devices, Capability.COLOR_CONTROL, Attribute.SATURATION, 0) + await setup_integration(hass, mock_config_entry) + + assert ( + hass.states.get("light.standing_light").attributes[ATTR_COLOR_MODE] + is ColorMode.COLOR_TEMP + ) + assert ( + hass.states.get("light.standing_light").attributes[ATTR_COLOR_TEMP_KELVIN] + == 3000 + ) + + await trigger_update( + hass, + devices, + "cb958955-b015-498c-9e62-fc0c51abd054", + Capability.COLOR_TEMPERATURE, + Attribute.COLOR_TEMPERATURE, + 2000, + ) + + assert ( + hass.states.get("light.standing_light").attributes[ATTR_COLOR_TEMP_KELVIN] + == 2000 ) - config_entry = await setup_platform(hass, LIGHT_DOMAIN, devices=[device]) - config_entry.mock_state(hass, ConfigEntryState.LOADED) - # Act - await hass.config_entries.async_forward_entry_unload(config_entry, "light") - # Assert - assert hass.states.get("light.color_dimmer_2").state == STATE_UNAVAILABLE diff --git a/tests/components/smartthings/test_lock.py b/tests/components/smartthings/test_lock.py index 3c2a2651fb9..28191eceb9a 100644 --- a/tests/components/smartthings/test_lock.py +++ b/tests/components/smartthings/test_lock.py @@ -1,129 +1,85 @@ -"""Test for the SmartThings lock platform. +"""Test for the SmartThings lock platform.""" -The only mocking required is of the underlying SmartThings API object so -real HTTP calls are not initiated during testing. -""" +from unittest.mock import AsyncMock -from pysmartthings import Attribute, Capability -from pysmartthings.device import Status +from pysmartthings import Attribute, Capability, Command +import pytest +from syrupy import SnapshotAssertion -from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN -from homeassistant.components.smartthings.const import DOMAIN, SIGNAL_SMARTTHINGS_UPDATE -from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN, LockState +from homeassistant.components.smartthings.const import MAIN +from homeassistant.const import ATTR_ENTITY_ID, SERVICE_LOCK, SERVICE_UNLOCK, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers import entity_registry as er -from .conftest import setup_platform +from . import setup_integration, snapshot_smartthings_entities, trigger_update + +from tests.common import MockConfigEntry -async def test_entity_and_device_attributes( +async def test_all_entities( hass: HomeAssistant, - device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, entity_registry: er.EntityRegistry, - device_factory, ) -> None: - """Test the attributes of the entity are correct.""" - # Arrange - device = device_factory( - "Lock_1", - [Capability.lock], - { - Attribute.lock: "unlocked", - Attribute.mnmo: "123", - Attribute.mnmn: "Generic manufacturer", - Attribute.mnhw: "v4.56", - Attribute.mnfv: "v7.89", - }, - ) - # Act - await setup_platform(hass, LOCK_DOMAIN, devices=[device]) - # Assert - entry = entity_registry.async_get("lock.lock_1") - assert entry - assert entry.unique_id == device.device_id + """Test all entities.""" + await setup_integration(hass, mock_config_entry) - entry = device_registry.async_get_device(identifiers={(DOMAIN, device.device_id)}) - assert entry - assert entry.configuration_url == "https://account.smartthings.com" - assert entry.identifiers == {(DOMAIN, device.device_id)} - assert entry.name == device.label - assert entry.model == "123" - assert entry.manufacturer == "Generic manufacturer" - assert entry.hw_version == "v4.56" - assert entry.sw_version == "v7.89" + snapshot_smartthings_entities(hass, entity_registry, snapshot, Platform.LOCK) -async def test_lock(hass: HomeAssistant, device_factory) -> None: - """Test the lock locks successfully.""" - # Arrange - device = device_factory("Lock_1", [Capability.lock]) - device.status.attributes[Attribute.lock] = Status( - "unlocked", - None, - { - "method": "Manual", - "codeId": None, - "codeName": "Code 1", - "lockName": "Front Door", - "usedCode": "Code 2", - }, - ) - await setup_platform(hass, LOCK_DOMAIN, devices=[device]) - # Act +@pytest.mark.parametrize("device_fixture", ["yale_push_button_deadbolt_lock"]) +@pytest.mark.parametrize( + ("action", "command"), + [ + (SERVICE_LOCK, Command.LOCK), + (SERVICE_UNLOCK, Command.UNLOCK), + ], +) +async def test_lock_unlock( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + action: str, + command: Command, +) -> None: + """Test lock and unlock command.""" + await setup_integration(hass, mock_config_entry) + await hass.services.async_call( - LOCK_DOMAIN, "lock", {"entity_id": "lock.lock_1"}, blocking=True + LOCK_DOMAIN, + action, + {ATTR_ENTITY_ID: "lock.basement_door_lock"}, + blocking=True, ) - # Assert - state = hass.states.get("lock.lock_1") - assert state is not None - assert state.state == "locked" - assert state.attributes["method"] == "Manual" - assert state.attributes["lock_state"] == "locked" - assert state.attributes["code_name"] == "Code 1" - assert state.attributes["used_code"] == "Code 2" - assert state.attributes["lock_name"] == "Front Door" - assert "code_id" not in state.attributes - - -async def test_unlock(hass: HomeAssistant, device_factory) -> None: - """Test the lock unlocks successfully.""" - # Arrange - device = device_factory("Lock_1", [Capability.lock], {Attribute.lock: "locked"}) - await setup_platform(hass, LOCK_DOMAIN, devices=[device]) - # Act - await hass.services.async_call( - LOCK_DOMAIN, "unlock", {"entity_id": "lock.lock_1"}, blocking=True + devices.execute_device_command.assert_called_once_with( + "a9f587c5-5d8b-4273-8907-e7f609af5158", + Capability.LOCK, + command, + MAIN, ) - # Assert - state = hass.states.get("lock.lock_1") - assert state is not None - assert state.state == "unlocked" -async def test_update_from_signal(hass: HomeAssistant, device_factory) -> None: - """Test the lock updates when receiving a signal.""" - # Arrange - device = device_factory("Lock_1", [Capability.lock], {Attribute.lock: "unlocked"}) - await setup_platform(hass, LOCK_DOMAIN, devices=[device]) - await device.lock(True) - # Act - async_dispatcher_send(hass, SIGNAL_SMARTTHINGS_UPDATE, [device.device_id]) - # Assert - await hass.async_block_till_done() - state = hass.states.get("lock.lock_1") - assert state is not None - assert state.state == "locked" +@pytest.mark.parametrize("device_fixture", ["yale_push_button_deadbolt_lock"]) +async def test_state_update( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test state update.""" + await setup_integration(hass, mock_config_entry) + assert hass.states.get("lock.basement_door_lock").state == LockState.LOCKED -async def test_unload_config_entry(hass: HomeAssistant, device_factory) -> None: - """Test the lock is removed when the config entry is unloaded.""" - # Arrange - device = device_factory("Lock_1", [Capability.lock], {Attribute.lock: "locked"}) - config_entry = await setup_platform(hass, LOCK_DOMAIN, devices=[device]) - config_entry.mock_state(hass, ConfigEntryState.LOADED) - # Act - await hass.config_entries.async_forward_entry_unload(config_entry, "lock") - # Assert - assert hass.states.get("lock.lock_1").state == STATE_UNAVAILABLE + await trigger_update( + hass, + devices, + "a9f587c5-5d8b-4273-8907-e7f609af5158", + Capability.LOCK, + Attribute.LOCK, + "open", + ) + + assert hass.states.get("lock.basement_door_lock").state == LockState.UNLOCKED diff --git a/tests/components/smartthings/test_scene.py b/tests/components/smartthings/test_scene.py index a20db1aaae8..7ef287b9e96 100644 --- a/tests/components/smartthings/test_scene.py +++ b/tests/components/smartthings/test_scene.py @@ -1,52 +1,47 @@ -"""Test for the SmartThings scene platform. +"""Test for the SmartThings scene platform.""" -The only mocking required is of the underlying SmartThings API object so -real HTTP calls are not initiated during testing. -""" +from unittest.mock import AsyncMock + +from syrupy import SnapshotAssertion from homeassistant.components.scene import DOMAIN as SCENE_DOMAIN -from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_ON, STATE_UNAVAILABLE +from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_ON, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from .conftest import setup_platform +from . import setup_integration, snapshot_smartthings_entities + +from tests.common import MockConfigEntry -async def test_entity_and_device_attributes( - hass: HomeAssistant, entity_registry: er.EntityRegistry, scene +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_smartthings: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, ) -> None: - """Test the attributes of the entity are correct.""" - # Act - await setup_platform(hass, SCENE_DOMAIN, scenes=[scene]) - # Assert - entry = entity_registry.async_get("scene.test_scene") - assert entry - assert entry.unique_id == scene.scene_id + """Test all entities.""" + await setup_integration(hass, mock_config_entry) + + snapshot_smartthings_entities(hass, entity_registry, snapshot, Platform.SCENE) -async def test_scene_activate(hass: HomeAssistant, scene) -> None: - """Test the scene is activated.""" - await setup_platform(hass, SCENE_DOMAIN, scenes=[scene]) +async def test_activate_scene( + hass: HomeAssistant, + mock_smartthings: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test activating a scene.""" + await setup_integration(hass, mock_config_entry) + await hass.services.async_call( SCENE_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "scene.test_scene"}, + {ATTR_ENTITY_ID: "scene.away"}, blocking=True, ) - state = hass.states.get("scene.test_scene") - assert state.attributes["icon"] == scene.icon - assert state.attributes["color"] == scene.color - assert state.attributes["location_id"] == scene.location_id - assert scene.execute.call_count == 1 - -async def test_unload_config_entry(hass: HomeAssistant, scene) -> None: - """Test the scene is removed when the config entry is unloaded.""" - # Arrange - config_entry = await setup_platform(hass, SCENE_DOMAIN, scenes=[scene]) - config_entry.mock_state(hass, ConfigEntryState.LOADED) - # Act - await hass.config_entries.async_forward_entry_unload(config_entry, SCENE_DOMAIN) - # Assert - assert hass.states.get("scene.test_scene").state == STATE_UNAVAILABLE + mock_smartthings.execute_scene.assert_called_once_with( + "743b0f37-89b8-476c-aedf-eea8ad8cd29d" + ) diff --git a/tests/components/smartthings/test_sensor.py b/tests/components/smartthings/test_sensor.py index a6a48202f1d..7f8464e69aa 100644 --- a/tests/components/smartthings/test_sensor.py +++ b/tests/components/smartthings/test_sensor.py @@ -1,290 +1,56 @@ -"""Test for the SmartThings sensors platform. +"""Test for the SmartThings sensors platform.""" -The only mocking required is of the underlying SmartThings API object so -real HTTP calls are not initiated during testing. -""" +from unittest.mock import AsyncMock from pysmartthings import Attribute, Capability +import pytest +from syrupy import SnapshotAssertion -from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN -from homeassistant.components.smartthings.const import DOMAIN, SIGNAL_SMARTTHINGS_UPDATE -from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import ( - ATTR_FRIENDLY_NAME, - ATTR_UNIT_OF_MEASUREMENT, - PERCENTAGE, - STATE_UNAVAILABLE, - STATE_UNKNOWN, - EntityCategory, -) +from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers import entity_registry as er -from .conftest import setup_platform +from . import setup_integration, snapshot_smartthings_entities, trigger_update + +from tests.common import MockConfigEntry -async def test_entity_state(hass: HomeAssistant, device_factory) -> None: - """Tests the state attributes properly match the sensor types.""" - device = device_factory("Sensor 1", [Capability.battery], {Attribute.battery: 100}) - await setup_platform(hass, SENSOR_DOMAIN, devices=[device]) - state = hass.states.get("sensor.sensor_1_battery") - assert state.state == "100" - assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == PERCENTAGE - assert state.attributes[ATTR_FRIENDLY_NAME] == f"{device.label} Battery" - - -async def test_entity_three_axis_state(hass: HomeAssistant, device_factory) -> None: - """Tests the state attributes properly match the three axis types.""" - device = device_factory( - "Three Axis", [Capability.three_axis], {Attribute.three_axis: [100, 75, 25]} - ) - await setup_platform(hass, SENSOR_DOMAIN, devices=[device]) - state = hass.states.get("sensor.three_axis_x_coordinate") - assert state.state == "100" - assert state.attributes[ATTR_FRIENDLY_NAME] == f"{device.label} X Coordinate" - state = hass.states.get("sensor.three_axis_y_coordinate") - assert state.state == "75" - assert state.attributes[ATTR_FRIENDLY_NAME] == f"{device.label} Y Coordinate" - state = hass.states.get("sensor.three_axis_z_coordinate") - assert state.state == "25" - assert state.attributes[ATTR_FRIENDLY_NAME] == f"{device.label} Z Coordinate" - - -async def test_entity_three_axis_invalid_state( - hass: HomeAssistant, device_factory -) -> None: - """Tests the state attributes properly match the three axis types.""" - device = device_factory( - "Three Axis", - [Capability.three_axis], - {Attribute.three_axis: [None, None, None]}, - ) - await setup_platform(hass, SENSOR_DOMAIN, devices=[device]) - state = hass.states.get("sensor.three_axis_x_coordinate") - assert state.state == STATE_UNKNOWN - state = hass.states.get("sensor.three_axis_y_coordinate") - assert state.state == STATE_UNKNOWN - state = hass.states.get("sensor.three_axis_z_coordinate") - assert state.state == STATE_UNKNOWN - - -async def test_entity_and_device_attributes( +async def test_all_entities( hass: HomeAssistant, - device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, entity_registry: er.EntityRegistry, - device_factory, ) -> None: - """Test the attributes of the entity are correct.""" - # Arrange - device = device_factory( - "Sensor 1", - [Capability.battery], - { - Attribute.battery: 100, - Attribute.mnmo: "123", - Attribute.mnmn: "Generic manufacturer", - Attribute.mnhw: "v4.56", - Attribute.mnfv: "v7.89", - }, - ) - # Act - await setup_platform(hass, SENSOR_DOMAIN, devices=[device]) - # Assert - entry = entity_registry.async_get("sensor.sensor_1_battery") - assert entry - assert entry.unique_id == f"{device.device_id}.{Attribute.battery}" - assert entry.entity_category is EntityCategory.DIAGNOSTIC - entry = device_registry.async_get_device(identifiers={(DOMAIN, device.device_id)}) - assert entry - assert entry.configuration_url == "https://account.smartthings.com" - assert entry.identifiers == {(DOMAIN, device.device_id)} - assert entry.name == device.label - assert entry.model == "123" - assert entry.manufacturer == "Generic manufacturer" - assert entry.hw_version == "v4.56" - assert entry.sw_version == "v7.89" + """Test all entities.""" + await setup_integration(hass, mock_config_entry) + + snapshot_smartthings_entities(hass, entity_registry, snapshot, Platform.SENSOR) -async def test_energy_sensors_for_switch_device( +@pytest.mark.parametrize("device_fixture", ["aeotec_home_energy_meter_gen5"]) +async def test_state_update( hass: HomeAssistant, - device_registry: dr.DeviceRegistry, - entity_registry: er.EntityRegistry, - device_factory, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, ) -> None: - """Test the attributes of the entity are correct.""" - # Arrange - device = device_factory( - "Switch_1", - [Capability.switch, Capability.power_meter, Capability.energy_meter], - { - Attribute.switch: "off", - Attribute.power: 355, - Attribute.energy: 11.422, - Attribute.mnmo: "123", - Attribute.mnmn: "Generic manufacturer", - Attribute.mnhw: "v4.56", - Attribute.mnfv: "v7.89", - }, + """Test state update.""" + await setup_integration(hass, mock_config_entry) + + assert ( + hass.states.get("sensor.aeotec_energy_monitor_energy_meter").state + == "19978.536" ) - # Act - await setup_platform(hass, SENSOR_DOMAIN, devices=[device]) - # Assert - state = hass.states.get("sensor.switch_1_energy_meter") - assert state - assert state.state == "11.422" - entry = entity_registry.async_get("sensor.switch_1_energy_meter") - assert entry - assert entry.unique_id == f"{device.device_id}.{Attribute.energy}" - assert entry.entity_category is None - entry = device_registry.async_get_device(identifiers={(DOMAIN, device.device_id)}) - assert entry - assert entry.configuration_url == "https://account.smartthings.com" - assert entry.identifiers == {(DOMAIN, device.device_id)} - assert entry.name == device.label - assert entry.model == "123" - assert entry.manufacturer == "Generic manufacturer" - assert entry.hw_version == "v4.56" - assert entry.sw_version == "v7.89" - state = hass.states.get("sensor.switch_1_power_meter") - assert state - assert state.state == "355" - entry = entity_registry.async_get("sensor.switch_1_power_meter") - assert entry - assert entry.unique_id == f"{device.device_id}.{Attribute.power}" - assert entry.entity_category is None - entry = device_registry.async_get_device(identifiers={(DOMAIN, device.device_id)}) - assert entry - assert entry.configuration_url == "https://account.smartthings.com" - assert entry.identifiers == {(DOMAIN, device.device_id)} - assert entry.name == device.label - assert entry.model == "123" - assert entry.manufacturer == "Generic manufacturer" - assert entry.hw_version == "v4.56" - assert entry.sw_version == "v7.89" - - -async def test_power_consumption_sensor( - hass: HomeAssistant, - device_registry: dr.DeviceRegistry, - entity_registry: er.EntityRegistry, - device_factory, -) -> None: - """Test the attributes of the entity are correct.""" - # Arrange - device = device_factory( - "refrigerator", - [Capability.power_consumption_report], - { - Attribute.power_consumption: { - "energy": 1412002, - "deltaEnergy": 25, - "power": 109, - "powerEnergy": 24.304498331745464, - "persistedEnergy": 0, - "energySaved": 0, - "start": "2021-07-30T16:45:25Z", - "end": "2021-07-30T16:58:33Z", - }, - Attribute.mnmo: "123", - Attribute.mnmn: "Generic manufacturer", - Attribute.mnhw: "v4.56", - Attribute.mnfv: "v7.89", - }, + await trigger_update( + hass, + devices, + "f0af21a2-d5a1-437c-b10a-b34a87394b71", + Capability.ENERGY_METER, + Attribute.ENERGY, + 20000.0, ) - # Act - await setup_platform(hass, SENSOR_DOMAIN, devices=[device]) - # Assert - state = hass.states.get("sensor.refrigerator_energy") - assert state - assert state.state == "1412.002" - entry = entity_registry.async_get("sensor.refrigerator_energy") - assert entry - assert entry.unique_id == f"{device.device_id}.energy_meter" - entry = device_registry.async_get_device(identifiers={(DOMAIN, device.device_id)}) - assert entry - assert entry.configuration_url == "https://account.smartthings.com" - assert entry.identifiers == {(DOMAIN, device.device_id)} - assert entry.name == device.label - assert entry.model == "123" - assert entry.manufacturer == "Generic manufacturer" - assert entry.hw_version == "v4.56" - assert entry.sw_version == "v7.89" - state = hass.states.get("sensor.refrigerator_power") - assert state - assert state.state == "109" - assert state.attributes["power_consumption_start"] == "2021-07-30T16:45:25Z" - assert state.attributes["power_consumption_end"] == "2021-07-30T16:58:33Z" - entry = entity_registry.async_get("sensor.refrigerator_power") - assert entry - assert entry.unique_id == f"{device.device_id}.power_meter" - entry = device_registry.async_get_device(identifiers={(DOMAIN, device.device_id)}) - assert entry - assert entry.configuration_url == "https://account.smartthings.com" - assert entry.identifiers == {(DOMAIN, device.device_id)} - assert entry.name == device.label - assert entry.model == "123" - assert entry.manufacturer == "Generic manufacturer" - assert entry.hw_version == "v4.56" - assert entry.sw_version == "v7.89" - - device = device_factory( - "vacuum", - [Capability.power_consumption_report], - { - Attribute.power_consumption: {}, - Attribute.mnmo: "123", - Attribute.mnmn: "Generic manufacturer", - Attribute.mnhw: "v4.56", - Attribute.mnfv: "v7.89", - }, + assert ( + hass.states.get("sensor.aeotec_energy_monitor_energy_meter").state == "20000.0" ) - # Act - await setup_platform(hass, SENSOR_DOMAIN, devices=[device]) - # Assert - state = hass.states.get("sensor.vacuum_energy") - assert state - assert state.state == "unknown" - entry = entity_registry.async_get("sensor.vacuum_energy") - assert entry - assert entry.unique_id == f"{device.device_id}.energy_meter" - entry = device_registry.async_get_device(identifiers={(DOMAIN, device.device_id)}) - assert entry - assert entry.configuration_url == "https://account.smartthings.com" - assert entry.identifiers == {(DOMAIN, device.device_id)} - assert entry.name == device.label - assert entry.model == "123" - assert entry.manufacturer == "Generic manufacturer" - assert entry.hw_version == "v4.56" - assert entry.sw_version == "v7.89" - - -async def test_update_from_signal(hass: HomeAssistant, device_factory) -> None: - """Test the binary_sensor updates when receiving a signal.""" - # Arrange - device = device_factory("Sensor 1", [Capability.battery], {Attribute.battery: 100}) - await setup_platform(hass, SENSOR_DOMAIN, devices=[device]) - device.status.apply_attribute_update( - "main", Capability.battery, Attribute.battery, 75 - ) - # Act - async_dispatcher_send(hass, SIGNAL_SMARTTHINGS_UPDATE, [device.device_id]) - # Assert - await hass.async_block_till_done() - state = hass.states.get("sensor.sensor_1_battery") - assert state is not None - assert state.state == "75" - - -async def test_unload_config_entry(hass: HomeAssistant, device_factory) -> None: - """Test the binary_sensor is removed when the config entry is unloaded.""" - # Arrange - device = device_factory("Sensor 1", [Capability.battery], {Attribute.battery: 100}) - config_entry = await setup_platform(hass, SENSOR_DOMAIN, devices=[device]) - config_entry.mock_state(hass, ConfigEntryState.LOADED) - # Act - await hass.config_entries.async_forward_entry_unload(config_entry, "sensor") - # Assert - assert hass.states.get("sensor.sensor_1_battery").state == STATE_UNAVAILABLE diff --git a/tests/components/smartthings/test_smartapp.py b/tests/components/smartthings/test_smartapp.py deleted file mode 100644 index c7861866fad..00000000000 --- a/tests/components/smartthings/test_smartapp.py +++ /dev/null @@ -1,186 +0,0 @@ -"""Tests for the smartapp module.""" - -from unittest.mock import AsyncMock, Mock, patch -from uuid import uuid4 - -from pysmartthings import CAPABILITIES, AppEntity, Capability -import pytest - -from homeassistant.components.smartthings import smartapp -from homeassistant.components.smartthings.const import ( - CONF_REFRESH_TOKEN, - DATA_MANAGER, - DOMAIN, -) -from homeassistant.core import HomeAssistant - -from tests.common import MockConfigEntry - - -async def test_update_app(hass: HomeAssistant, app) -> None: - """Test update_app does not save if app is current.""" - await smartapp.update_app(hass, app) - assert app.save.call_count == 0 - - -async def test_update_app_updated_needed(hass: HomeAssistant, app) -> None: - """Test update_app updates when an app is needed.""" - mock_app = Mock(AppEntity) - mock_app.app_name = "Test" - - await smartapp.update_app(hass, mock_app) - - assert mock_app.save.call_count == 1 - assert mock_app.app_name == "Test" - assert mock_app.display_name == app.display_name - assert mock_app.description == app.description - assert mock_app.webhook_target_url == app.webhook_target_url - assert mock_app.app_type == app.app_type - assert mock_app.single_instance == app.single_instance - assert mock_app.classifications == app.classifications - - -async def test_smartapp_update_saves_token( - hass: HomeAssistant, smartthings_mock, location, device_factory -) -> None: - """Test update saves token.""" - # Arrange - entry = MockConfigEntry( - domain=DOMAIN, data={"installed_app_id": str(uuid4()), "app_id": str(uuid4())} - ) - entry.add_to_hass(hass) - app = Mock() - app.app_id = entry.data["app_id"] - request = Mock() - request.installed_app_id = entry.data["installed_app_id"] - request.auth_token = str(uuid4()) - request.refresh_token = str(uuid4()) - request.location_id = location.location_id - - # Act - await smartapp.smartapp_update(hass, request, None, app) - # Assert - assert entry.data[CONF_REFRESH_TOKEN] == request.refresh_token - - -async def test_smartapp_uninstall(hass: HomeAssistant, config_entry) -> None: - """Test the config entry is unloaded when the app is uninstalled.""" - config_entry.add_to_hass(hass) - app = Mock() - app.app_id = config_entry.data["app_id"] - request = Mock() - request.installed_app_id = config_entry.data["installed_app_id"] - - with patch.object(hass.config_entries, "async_remove") as remove: - await smartapp.smartapp_uninstall(hass, request, None, app) - assert remove.call_count == 1 - - -async def test_smartapp_webhook(hass: HomeAssistant) -> None: - """Test the smartapp webhook calls the manager.""" - manager = Mock() - manager.handle_request = AsyncMock(return_value={}) - hass.data[DOMAIN][DATA_MANAGER] = manager - request = Mock() - request.headers = [] - request.json = AsyncMock(return_value={}) - result = await smartapp.smartapp_webhook(hass, "", request) - - assert result.body == b"{}" - - -async def test_smartapp_sync_subscriptions( - hass: HomeAssistant, smartthings_mock, device_factory, subscription_factory -) -> None: - """Test synchronization adds and removes and ignores unused.""" - smartthings_mock.subscriptions.return_value = [ - subscription_factory(Capability.thermostat), - subscription_factory(Capability.switch), - subscription_factory(Capability.switch_level), - ] - devices = [ - device_factory("", [Capability.battery, "ping"]), - device_factory("", [Capability.switch, Capability.switch_level]), - device_factory("", [Capability.switch, Capability.execute]), - ] - - await smartapp.smartapp_sync_subscriptions( - hass, str(uuid4()), str(uuid4()), str(uuid4()), devices - ) - - assert smartthings_mock.subscriptions.call_count == 1 - assert smartthings_mock.delete_subscription.call_count == 1 - assert smartthings_mock.create_subscription.call_count == 1 - - -async def test_smartapp_sync_subscriptions_up_to_date( - hass: HomeAssistant, smartthings_mock, device_factory, subscription_factory -) -> None: - """Test synchronization does nothing when current.""" - smartthings_mock.subscriptions.return_value = [ - subscription_factory(Capability.battery), - subscription_factory(Capability.switch), - subscription_factory(Capability.switch_level), - ] - devices = [ - device_factory("", [Capability.battery, "ping"]), - device_factory("", [Capability.switch, Capability.switch_level]), - device_factory("", [Capability.switch]), - ] - - await smartapp.smartapp_sync_subscriptions( - hass, str(uuid4()), str(uuid4()), str(uuid4()), devices - ) - - assert smartthings_mock.subscriptions.call_count == 1 - assert smartthings_mock.delete_subscription.call_count == 0 - assert smartthings_mock.create_subscription.call_count == 0 - - -async def test_smartapp_sync_subscriptions_limit_warning( - hass: HomeAssistant, - smartthings_mock, - device_factory, - subscription_factory, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test synchronization over the limit logs a warning.""" - smartthings_mock.subscriptions.return_value = [] - devices = [ - device_factory("", CAPABILITIES), - ] - - await smartapp.smartapp_sync_subscriptions( - hass, str(uuid4()), str(uuid4()), str(uuid4()), devices - ) - - assert ( - "Some device attributes may not receive push updates and there may be " - "subscription creation failures" in caplog.text - ) - - -async def test_smartapp_sync_subscriptions_handles_exceptions( - hass: HomeAssistant, smartthings_mock, device_factory, subscription_factory -) -> None: - """Test synchronization does nothing when current.""" - smartthings_mock.delete_subscription.side_effect = Exception - smartthings_mock.create_subscription.side_effect = Exception - smartthings_mock.subscriptions.return_value = [ - subscription_factory(Capability.battery), - subscription_factory(Capability.switch), - subscription_factory(Capability.switch_level), - ] - devices = [ - device_factory("", [Capability.thermostat, "ping"]), - device_factory("", [Capability.switch, Capability.switch_level]), - device_factory("", [Capability.switch]), - ] - - await smartapp.smartapp_sync_subscriptions( - hass, str(uuid4()), str(uuid4()), str(uuid4()), devices - ) - - assert smartthings_mock.subscriptions.call_count == 1 - assert smartthings_mock.delete_subscription.call_count == 1 - assert smartthings_mock.create_subscription.call_count == 1 diff --git a/tests/components/smartthings/test_switch.py b/tests/components/smartthings/test_switch.py index fadd7600e87..a1e420a8edb 100644 --- a/tests/components/smartthings/test_switch.py +++ b/tests/components/smartthings/test_switch.py @@ -1,115 +1,89 @@ -"""Test for the SmartThings switch platform. +"""Test for the SmartThings switch platform.""" -The only mocking required is of the underlying SmartThings API object so -real HTTP calls are not initiated during testing. -""" +from unittest.mock import AsyncMock -from pysmartthings import Attribute, Capability +from pysmartthings import Attribute, Capability, Command +import pytest +from syrupy import SnapshotAssertion -from homeassistant.components.smartthings.const import DOMAIN, SIGNAL_SMARTTHINGS_UPDATE +from homeassistant.components.smartthings.const import MAIN from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN -from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_OFF, + STATE_ON, + Platform, +) from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers import entity_registry as er -from .conftest import setup_platform +from . import setup_integration, snapshot_smartthings_entities, trigger_update + +from tests.common import MockConfigEntry -async def test_entity_and_device_attributes( +async def test_all_entities( hass: HomeAssistant, - device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, entity_registry: er.EntityRegistry, - device_factory, ) -> None: - """Test the attributes of the entity are correct.""" - # Arrange - device = device_factory( - "Switch_1", - [Capability.switch], - { - Attribute.switch: "on", - Attribute.mnmo: "123", - Attribute.mnmn: "Generic manufacturer", - Attribute.mnhw: "v4.56", - Attribute.mnfv: "v7.89", - }, - ) - # Act - await setup_platform(hass, SWITCH_DOMAIN, devices=[device]) - # Assert - entry = entity_registry.async_get("switch.switch_1") - assert entry - assert entry.unique_id == device.device_id + """Test all entities.""" + await setup_integration(hass, mock_config_entry) - entry = device_registry.async_get_device(identifiers={(DOMAIN, device.device_id)}) - assert entry - assert entry.configuration_url == "https://account.smartthings.com" - assert entry.identifiers == {(DOMAIN, device.device_id)} - assert entry.name == device.label - assert entry.model == "123" - assert entry.manufacturer == "Generic manufacturer" - assert entry.hw_version == "v4.56" - assert entry.sw_version == "v7.89" + snapshot_smartthings_entities(hass, entity_registry, snapshot, Platform.SWITCH) -async def test_turn_off(hass: HomeAssistant, device_factory) -> None: - """Test the switch turns of successfully.""" - # Arrange - device = device_factory("Switch_1", [Capability.switch], {Attribute.switch: "on"}) - await setup_platform(hass, SWITCH_DOMAIN, devices=[device]) - # Act +@pytest.mark.parametrize("device_fixture", ["c2c_arlo_pro_3_switch"]) +@pytest.mark.parametrize( + ("action", "command"), + [ + (SERVICE_TURN_ON, Command.ON), + (SERVICE_TURN_OFF, Command.OFF), + ], +) +async def test_switch_turn_on_off( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + action: str, + command: Command, +) -> None: + """Test switch turn on and off command.""" + await setup_integration(hass, mock_config_entry) + await hass.services.async_call( - "switch", "turn_off", {"entity_id": "switch.switch_1"}, blocking=True + SWITCH_DOMAIN, + action, + {ATTR_ENTITY_ID: "switch.2nd_floor_hallway"}, + blocking=True, ) - # Assert - state = hass.states.get("switch.switch_1") - assert state is not None - assert state.state == "off" - - -async def test_turn_on(hass: HomeAssistant, device_factory) -> None: - """Test the switch turns of successfully.""" - # Arrange - device = device_factory( - "Switch_1", - [Capability.switch, Capability.power_meter, Capability.energy_meter], - {Attribute.switch: "off", Attribute.power: 355, Attribute.energy: 11.422}, + devices.execute_device_command.assert_called_once_with( + "10e06a70-ee7d-4832-85e9-a0a06a7a05bd", Capability.SWITCH, command, MAIN ) - await setup_platform(hass, SWITCH_DOMAIN, devices=[device]) - # Act - await hass.services.async_call( - "switch", "turn_on", {"entity_id": "switch.switch_1"}, blocking=True + + +@pytest.mark.parametrize("device_fixture", ["c2c_arlo_pro_3_switch"]) +async def test_state_update( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test state update.""" + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("switch.2nd_floor_hallway").state == STATE_ON + + await trigger_update( + hass, + devices, + "10e06a70-ee7d-4832-85e9-a0a06a7a05bd", + Capability.SWITCH, + Attribute.SWITCH, + "off", ) - # Assert - state = hass.states.get("switch.switch_1") - assert state is not None - assert state.state == "on" - -async def test_update_from_signal(hass: HomeAssistant, device_factory) -> None: - """Test the switch updates when receiving a signal.""" - # Arrange - device = device_factory("Switch_1", [Capability.switch], {Attribute.switch: "off"}) - await setup_platform(hass, SWITCH_DOMAIN, devices=[device]) - await device.switch_on(True) - # Act - async_dispatcher_send(hass, SIGNAL_SMARTTHINGS_UPDATE, [device.device_id]) - # Assert - await hass.async_block_till_done() - state = hass.states.get("switch.switch_1") - assert state is not None - assert state.state == "on" - - -async def test_unload_config_entry(hass: HomeAssistant, device_factory) -> None: - """Test the switch is removed when the config entry is unloaded.""" - # Arrange - device = device_factory("Switch 1", [Capability.switch], {Attribute.switch: "on"}) - config_entry = await setup_platform(hass, SWITCH_DOMAIN, devices=[device]) - config_entry.mock_state(hass, ConfigEntryState.LOADED) - # Act - await hass.config_entries.async_forward_entry_unload(config_entry, "switch") - # Assert - assert hass.states.get("switch.switch_1").state == STATE_UNAVAILABLE + assert hass.states.get("switch.2nd_floor_hallway").state == STATE_OFF From 7e97ef588b8ea7e12d8356f6a9c55c79669a1691 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 26 Feb 2025 15:27:52 +0100 Subject: [PATCH 1840/3148] Add keys initiate_flow and entry_type to data entry translations (#138882) --- homeassistant/components/kitchen_sink/strings.json | 8 ++++++-- script/hassfest/translations.py | 4 +++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/kitchen_sink/strings.json b/homeassistant/components/kitchen_sink/strings.json index e2fbb99c89f..e0cdf75b707 100644 --- a/homeassistant/components/kitchen_sink/strings.json +++ b/homeassistant/components/kitchen_sink/strings.json @@ -11,7 +11,6 @@ }, "config_subentries": { "entity": { - "title": "Add entity", "step": { "add_sensor": { "description": "Configure the new sensor", @@ -27,7 +26,12 @@ "state": "Initial state" } } - } + }, + "initiate_flow": { + "user": "Add sensor", + "reconfigure": "Reconfigure sensor" + }, + "entry_type": "Sensor" } }, "options": { diff --git a/script/hassfest/translations.py b/script/hassfest/translations.py index 2e5ec3e8ba0..c257f185f51 100644 --- a/script/hassfest/translations.py +++ b/script/hassfest/translations.py @@ -185,6 +185,8 @@ def gen_data_entry_schema( vol.Optional("abort"): {str: translation_value_validator}, vol.Optional("progress"): {str: translation_value_validator}, vol.Optional("create_entry"): {str: translation_value_validator}, + vol.Optional("initiate_flow"): {str: translation_value_validator}, + vol.Optional("entry_type"): translation_value_validator, } if flow_title == REQUIRED: schema[vol.Required("title")] = translation_value_validator @@ -289,7 +291,7 @@ def gen_strings_schema(config: Config, integration: Integration) -> vol.Schema: gen_data_entry_schema( config=config, integration=integration, - flow_title=REQUIRED, + flow_title=REMOVED, require_step_title=False, ), slug_validator=vol.Any("_", cv.slug), From 5324f3e5420a91e308429efaae8498d1e29e31f1 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Wed, 26 Feb 2025 15:44:16 +0100 Subject: [PATCH 1841/3148] Add support for swing horizontal mode for mqtt climate (#139303) * Add support for swing horizontal mode for mqtt climate * Fix import --- .../components/mqtt/abbreviations.py | 6 ++ homeassistant/components/mqtt/climate.py | 57 +++++++++++ tests/components/climate/common.py | 18 +++- tests/components/mqtt/test_climate.py | 96 ++++++++++++++++++- tests/components/mqtt/test_discovery.py | 1 - 5 files changed, 174 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/mqtt/abbreviations.py b/homeassistant/components/mqtt/abbreviations.py index 584b238b3a8..2d73cc5865c 100644 --- a/homeassistant/components/mqtt/abbreviations.py +++ b/homeassistant/components/mqtt/abbreviations.py @@ -218,10 +218,16 @@ ABBREVIATIONS = { "sup_vol": "support_volume_set", "sup_feat": "supported_features", "sup_clrm": "supported_color_modes", + "swing_h_mode_cmd_tpl": "swing_horizontal_mode_command_template", + "swing_h_mode_cmd_t": "swing_horizontal_mode_command_topic", + "swing_h_mode_stat_tpl": "swing_horizontal_mode_state_template", + "swing_h_mode_stat_t": "swing_horizontal_mode_state_topic", + "swing_h_modes": "swing_horizontal_modes", "swing_mode_cmd_tpl": "swing_mode_command_template", "swing_mode_cmd_t": "swing_mode_command_topic", "swing_mode_stat_tpl": "swing_mode_state_template", "swing_mode_stat_t": "swing_mode_state_topic", + "swing_modes": "swing_modes", "temp_cmd_tpl": "temperature_command_template", "temp_cmd_t": "temperature_command_topic", "temp_hi_cmd_tpl": "temperature_high_command_template", diff --git a/homeassistant/components/mqtt/climate.py b/homeassistant/components/mqtt/climate.py index a65eb18e3f1..931a57a71cc 100644 --- a/homeassistant/components/mqtt/climate.py +++ b/homeassistant/components/mqtt/climate.py @@ -113,11 +113,19 @@ CONF_PRESET_MODE_COMMAND_TOPIC = "preset_mode_command_topic" CONF_PRESET_MODE_VALUE_TEMPLATE = "preset_mode_value_template" CONF_PRESET_MODE_COMMAND_TEMPLATE = "preset_mode_command_template" CONF_PRESET_MODES_LIST = "preset_modes" + +CONF_SWING_HORIZONTAL_MODE_COMMAND_TEMPLATE = "swing_horizontal_mode_command_template" +CONF_SWING_HORIZONTAL_MODE_COMMAND_TOPIC = "swing_horizontal_mode_command_topic" +CONF_SWING_HORIZONTAL_MODE_LIST = "swing_horizontal_modes" +CONF_SWING_HORIZONTAL_MODE_STATE_TEMPLATE = "swing_horizontal_mode_state_template" +CONF_SWING_HORIZONTAL_MODE_STATE_TOPIC = "swing_horizontal_mode_state_topic" + CONF_SWING_MODE_COMMAND_TEMPLATE = "swing_mode_command_template" CONF_SWING_MODE_COMMAND_TOPIC = "swing_mode_command_topic" CONF_SWING_MODE_LIST = "swing_modes" CONF_SWING_MODE_STATE_TEMPLATE = "swing_mode_state_template" CONF_SWING_MODE_STATE_TOPIC = "swing_mode_state_topic" + CONF_TEMP_HIGH_COMMAND_TEMPLATE = "temperature_high_command_template" CONF_TEMP_HIGH_COMMAND_TOPIC = "temperature_high_command_topic" CONF_TEMP_HIGH_STATE_TEMPLATE = "temperature_high_state_template" @@ -145,6 +153,8 @@ MQTT_CLIMATE_ATTRIBUTES_BLOCKED = frozenset( climate.ATTR_MIN_TEMP, climate.ATTR_PRESET_MODE, climate.ATTR_PRESET_MODES, + climate.ATTR_SWING_HORIZONTAL_MODE, + climate.ATTR_SWING_HORIZONTAL_MODES, climate.ATTR_SWING_MODE, climate.ATTR_SWING_MODES, climate.ATTR_TARGET_TEMP_HIGH, @@ -162,6 +172,7 @@ VALUE_TEMPLATE_KEYS = ( CONF_MODE_STATE_TEMPLATE, CONF_ACTION_TEMPLATE, CONF_PRESET_MODE_VALUE_TEMPLATE, + CONF_SWING_HORIZONTAL_MODE_STATE_TEMPLATE, CONF_SWING_MODE_STATE_TEMPLATE, CONF_TEMP_HIGH_STATE_TEMPLATE, CONF_TEMP_LOW_STATE_TEMPLATE, @@ -174,6 +185,7 @@ COMMAND_TEMPLATE_KEYS = { CONF_MODE_COMMAND_TEMPLATE, CONF_POWER_COMMAND_TEMPLATE, CONF_PRESET_MODE_COMMAND_TEMPLATE, + CONF_SWING_HORIZONTAL_MODE_COMMAND_TEMPLATE, CONF_SWING_MODE_COMMAND_TEMPLATE, CONF_TEMP_COMMAND_TEMPLATE, CONF_TEMP_HIGH_COMMAND_TEMPLATE, @@ -194,6 +206,8 @@ TOPIC_KEYS = ( CONF_POWER_COMMAND_TOPIC, CONF_PRESET_MODE_COMMAND_TOPIC, CONF_PRESET_MODE_STATE_TOPIC, + CONF_SWING_HORIZONTAL_MODE_COMMAND_TOPIC, + CONF_SWING_HORIZONTAL_MODE_STATE_TOPIC, CONF_SWING_MODE_COMMAND_TOPIC, CONF_SWING_MODE_STATE_TOPIC, CONF_TEMP_COMMAND_TOPIC, @@ -302,6 +316,13 @@ _PLATFORM_SCHEMA_BASE = MQTT_BASE_SCHEMA.extend( vol.Optional(CONF_PRESET_MODE_COMMAND_TEMPLATE): cv.template, vol.Optional(CONF_PRESET_MODE_STATE_TOPIC): valid_subscribe_topic, vol.Optional(CONF_PRESET_MODE_VALUE_TEMPLATE): cv.template, + vol.Optional(CONF_SWING_HORIZONTAL_MODE_COMMAND_TEMPLATE): cv.template, + vol.Optional(CONF_SWING_HORIZONTAL_MODE_COMMAND_TOPIC): valid_publish_topic, + vol.Optional( + CONF_SWING_HORIZONTAL_MODE_LIST, default=[SWING_ON, SWING_OFF] + ): cv.ensure_list, + vol.Optional(CONF_SWING_HORIZONTAL_MODE_STATE_TEMPLATE): cv.template, + vol.Optional(CONF_SWING_HORIZONTAL_MODE_STATE_TOPIC): valid_subscribe_topic, vol.Optional(CONF_SWING_MODE_COMMAND_TEMPLATE): cv.template, vol.Optional(CONF_SWING_MODE_COMMAND_TOPIC): valid_publish_topic, vol.Optional( @@ -515,6 +536,7 @@ class MqttClimate(MqttTemperatureControlEntity, ClimateEntity): _attr_fan_mode: str | None = None _attr_hvac_mode: HVACMode | None = None + _attr_swing_horizontal_mode: str | None = None _attr_swing_mode: str | None = None _default_name = DEFAULT_NAME _entity_id_format = climate.ENTITY_ID_FORMAT @@ -543,6 +565,7 @@ class MqttClimate(MqttTemperatureControlEntity, ClimateEntity): if (precision := config.get(CONF_PRECISION)) is not None: self._attr_precision = precision self._attr_fan_modes = config[CONF_FAN_MODE_LIST] + self._attr_swing_horizontal_modes = config[CONF_SWING_HORIZONTAL_MODE_LIST] self._attr_swing_modes = config[CONF_SWING_MODE_LIST] self._attr_target_temperature_step = config[CONF_TEMP_STEP] @@ -568,6 +591,11 @@ class MqttClimate(MqttTemperatureControlEntity, ClimateEntity): if self._topic[CONF_FAN_MODE_STATE_TOPIC] is None or self._optimistic: self._attr_fan_mode = FAN_LOW + if ( + self._topic[CONF_SWING_HORIZONTAL_MODE_STATE_TOPIC] is None + or self._optimistic + ): + self._attr_swing_horizontal_mode = SWING_OFF if self._topic[CONF_SWING_MODE_STATE_TOPIC] is None or self._optimistic: self._attr_swing_mode = SWING_OFF if self._topic[CONF_MODE_STATE_TOPIC] is None or self._optimistic: @@ -629,6 +657,11 @@ class MqttClimate(MqttTemperatureControlEntity, ClimateEntity): ): support |= ClimateEntityFeature.FAN_MODE + if (self._topic[CONF_SWING_HORIZONTAL_MODE_STATE_TOPIC] is not None) or ( + self._topic[CONF_SWING_HORIZONTAL_MODE_COMMAND_TOPIC] is not None + ): + support |= ClimateEntityFeature.SWING_HORIZONTAL_MODE + if (self._topic[CONF_SWING_MODE_STATE_TOPIC] is not None) or ( self._topic[CONF_SWING_MODE_COMMAND_TOPIC] is not None ): @@ -744,6 +777,16 @@ class MqttClimate(MqttTemperatureControlEntity, ClimateEntity): ), {"_attr_fan_mode"}, ) + self.add_subscription( + CONF_SWING_HORIZONTAL_MODE_STATE_TOPIC, + partial( + self._handle_mode_received, + CONF_SWING_HORIZONTAL_MODE_STATE_TEMPLATE, + "_attr_swing_horizontal_mode", + CONF_SWING_HORIZONTAL_MODE_LIST, + ), + {"_attr_swing_horizontal_mode"}, + ) self.add_subscription( CONF_SWING_MODE_STATE_TOPIC, partial( @@ -782,6 +825,20 @@ class MqttClimate(MqttTemperatureControlEntity, ClimateEntity): self.async_write_ha_state() + async def async_set_swing_horizontal_mode(self, swing_horizontal_mode: str) -> None: + """Set new swing horizontal mode.""" + payload = self._command_templates[CONF_SWING_HORIZONTAL_MODE_COMMAND_TEMPLATE]( + swing_horizontal_mode + ) + await self._publish(CONF_SWING_HORIZONTAL_MODE_COMMAND_TOPIC, payload) + + if ( + self._optimistic + or self._topic[CONF_SWING_HORIZONTAL_MODE_STATE_TOPIC] is None + ): + self._attr_swing_horizontal_mode = swing_horizontal_mode + self.async_write_ha_state() + async def async_set_swing_mode(self, swing_mode: str) -> None: """Set new swing mode.""" payload = self._command_templates[CONF_SWING_MODE_COMMAND_TEMPLATE](swing_mode) diff --git a/tests/components/climate/common.py b/tests/components/climate/common.py index d6aedd23671..8f5834d9180 100644 --- a/tests/components/climate/common.py +++ b/tests/components/climate/common.py @@ -11,6 +11,7 @@ from homeassistant.components.climate import ( ATTR_HUMIDITY, ATTR_HVAC_MODE, ATTR_PRESET_MODE, + ATTR_SWING_HORIZONTAL_MODE, ATTR_SWING_MODE, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, @@ -20,10 +21,11 @@ from homeassistant.components.climate import ( SERVICE_SET_HUMIDITY, SERVICE_SET_HVAC_MODE, SERVICE_SET_PRESET_MODE, + SERVICE_SET_SWING_HORIZONTAL_MODE, SERVICE_SET_SWING_MODE, SERVICE_SET_TEMPERATURE, + HVACMode, ) -from homeassistant.components.climate.const import HVACMode from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_TEMPERATURE, @@ -211,6 +213,20 @@ def set_operation_mode( hass.services.call(DOMAIN, SERVICE_SET_HVAC_MODE, data) +async def async_set_swing_horizontal_mode( + hass: HomeAssistant, swing_horizontal_mode: str, entity_id: str = ENTITY_MATCH_ALL +) -> None: + """Set new target swing horizontal mode.""" + data = {ATTR_SWING_HORIZONTAL_MODE: swing_horizontal_mode} + + if entity_id is not None: + data[ATTR_ENTITY_ID] = entity_id + + await hass.services.async_call( + DOMAIN, SERVICE_SET_SWING_HORIZONTAL_MODE, data, blocking=True + ) + + async def async_set_swing_mode( hass: HomeAssistant, swing_mode: str, entity_id: str = ENTITY_MATCH_ALL ) -> None: diff --git a/tests/components/mqtt/test_climate.py b/tests/components/mqtt/test_climate.py index 5edd73e3f5a..3760b0226f5 100644 --- a/tests/components/mqtt/test_climate.py +++ b/tests/components/mqtt/test_climate.py @@ -15,6 +15,7 @@ from homeassistant.components.climate import ( ATTR_FAN_MODE, ATTR_HUMIDITY, ATTR_HVAC_ACTION, + ATTR_SWING_HORIZONTAL_MODE, ATTR_SWING_MODE, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, @@ -85,6 +86,7 @@ DEFAULT_CONFIG = { "temperature_high_command_topic": "temperature-high-topic", "fan_mode_command_topic": "fan-mode-topic", "swing_mode_command_topic": "swing-mode-topic", + "swing_horizontal_mode_command_topic": "swing-horizontal-mode-topic", "preset_mode_command_topic": "preset-mode-topic", "preset_modes": [ "eco", @@ -111,6 +113,7 @@ async def test_setup_params( assert state.attributes.get("temperature") == 21 assert state.attributes.get("fan_mode") == "low" assert state.attributes.get("swing_mode") == "off" + assert state.attributes.get("swing_horizontal_mode") == "off" assert state.state == "off" assert state.attributes.get("min_temp") == DEFAULT_MIN_TEMP assert state.attributes.get("max_temp") == DEFAULT_MAX_TEMP @@ -123,6 +126,7 @@ async def test_setup_params( | ClimateEntityFeature.TARGET_HUMIDITY | ClimateEntityFeature.FAN_MODE | ClimateEntityFeature.PRESET_MODE + | ClimateEntityFeature.SWING_HORIZONTAL_MODE | ClimateEntityFeature.SWING_MODE | ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON @@ -159,6 +163,7 @@ async def test_supported_features( state = hass.states.get(ENTITY_CLIMATE) support = ( ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.SWING_HORIZONTAL_MODE | ClimateEntityFeature.SWING_MODE | ClimateEntityFeature.FAN_MODE | ClimateEntityFeature.PRESET_MODE @@ -562,12 +567,29 @@ async def test_set_swing_mode_bad_attr( state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("swing_mode") == "off" + assert state.attributes.get("swing_horizontal_mode") == "off" + with pytest.raises(vol.Invalid) as excinfo: + await common.async_set_swing_horizontal_mode(hass, None, ENTITY_CLIMATE) # type:ignore[arg-type] + assert ( + "string value is None for dictionary value @ data['swing_horizontal_mode']" + in str(excinfo.value) + ) + state = hass.states.get(ENTITY_CLIMATE) + assert state.attributes.get("swing_horizontal_mode") == "off" + @pytest.mark.parametrize( "hass_config", [ help_custom_config( - climate.DOMAIN, DEFAULT_CONFIG, ({"swing_mode_state_topic": "swing-state"},) + climate.DOMAIN, + DEFAULT_CONFIG, + ( + { + "swing_mode_state_topic": "swing-state", + "swing_horizontal_mode_state_topic": "swing-horizontal-state", + }, + ), ) ], ) @@ -579,19 +601,32 @@ async def test_set_swing_pessimistic( state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("swing_mode") is None + assert state.attributes.get("swing_horizontal_mode") is None await common.async_set_swing_mode(hass, "on", ENTITY_CLIMATE) state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("swing_mode") is None + await common.async_set_swing_horizontal_mode(hass, "on", ENTITY_CLIMATE) + state = hass.states.get(ENTITY_CLIMATE) + assert state.attributes.get("swing_horizontal_mode") is None + async_fire_mqtt_message(hass, "swing-state", "on") state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("swing_mode") == "on" + async_fire_mqtt_message(hass, "swing-horizontal-state", "on") + state = hass.states.get(ENTITY_CLIMATE) + assert state.attributes.get("swing_horizontal_mode") == "on" + async_fire_mqtt_message(hass, "swing-state", "bogus state") state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("swing_mode") == "on" + async_fire_mqtt_message(hass, "swing-horizontal-state", "bogus state") + state = hass.states.get(ENTITY_CLIMATE) + assert state.attributes.get("swing_horizontal_mode") == "on" + @pytest.mark.parametrize( "hass_config", @@ -599,7 +634,13 @@ async def test_set_swing_pessimistic( help_custom_config( climate.DOMAIN, DEFAULT_CONFIG, - ({"swing_mode_state_topic": "swing-state", "optimistic": True},), + ( + { + "swing_mode_state_topic": "swing-state", + "swing_horizontal_mode_state_topic": "swing-horizontal-state", + "optimistic": True, + }, + ), ) ], ) @@ -611,19 +652,32 @@ async def test_set_swing_optimistic( state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("swing_mode") == "off" + assert state.attributes.get("swing_horizontal_mode") == "off" await common.async_set_swing_mode(hass, "on", ENTITY_CLIMATE) state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("swing_mode") == "on" + await common.async_set_swing_horizontal_mode(hass, "on", ENTITY_CLIMATE) + state = hass.states.get(ENTITY_CLIMATE) + assert state.attributes.get("swing_horizontal_mode") == "on" + async_fire_mqtt_message(hass, "swing-state", "off") state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("swing_mode") == "off" + async_fire_mqtt_message(hass, "swing-horizontal-state", "off") + state = hass.states.get(ENTITY_CLIMATE) + assert state.attributes.get("swing_horizontal_mode") == "off" + async_fire_mqtt_message(hass, "swing-state", "bogus state") state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("swing_mode") == "off" + async_fire_mqtt_message(hass, "swing-horizontal-state", "bogus state") + state = hass.states.get(ENTITY_CLIMATE) + assert state.attributes.get("swing_horizontal_mode") == "off" + @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_set_swing( @@ -638,6 +692,15 @@ async def test_set_swing( mqtt_mock.async_publish.assert_called_once_with("swing-mode-topic", "on", 0, False) state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("swing_mode") == "on" + mqtt_mock.reset_mock() + + assert state.attributes.get("swing_horizontal_mode") == "off" + await common.async_set_swing_horizontal_mode(hass, "on", ENTITY_CLIMATE) + mqtt_mock.async_publish.assert_called_once_with( + "swing-horizontal-mode-topic", "on", 0, False + ) + state = hass.states.get(ENTITY_CLIMATE) + assert state.attributes.get("swing_horizontal_mode") == "on" @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) @@ -1337,6 +1400,7 @@ async def test_get_target_temperature_low_high_with_templates( "temperature_low_command_topic": "temperature-low-topic", "temperature_high_command_topic": "temperature-high-topic", "fan_mode_command_topic": "fan-mode-topic", + "swing_horizontal_mode_command_topic": "swing-horizontal-mode-topic", "swing_mode_command_topic": "swing-mode-topic", "preset_mode_command_topic": "preset-mode-topic", "preset_modes": [ @@ -1359,6 +1423,7 @@ async def test_get_target_temperature_low_high_with_templates( "action_topic": "action", "mode_state_topic": "mode-state", "fan_mode_state_topic": "fan-state", + "swing_horizontal_mode_state_topic": "swing-horizontal-state", "swing_mode_state_topic": "swing-state", "temperature_state_topic": "temperature-state", "target_humidity_state_topic": "humidity-state", @@ -1396,6 +1461,12 @@ async def test_get_with_templates( state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("swing_mode") == "on" + # Swing Horizontal Mode + assert state.attributes.get("swing_horizontal_mode") is None + async_fire_mqtt_message(hass, "swing-horizontal-state", '"on"') + state = hass.states.get(ENTITY_CLIMATE) + assert state.attributes.get("swing_horizontal_mode") == "on" + # Temperature - with valid value assert state.attributes.get("temperature") is None async_fire_mqtt_message(hass, "temperature-state", '"1031"') @@ -1495,6 +1566,7 @@ async def test_get_with_templates( "temperature_low_command_topic": "temperature-low-topic", "temperature_high_command_topic": "temperature-high-topic", "fan_mode_command_topic": "fan-mode-topic", + "swing_horizontal_mode_command_topic": "swing-horizontal-mode-topic", "swing_mode_command_topic": "swing-mode-topic", "preset_mode_command_topic": "preset-mode-topic", "preset_modes": [ @@ -1511,6 +1583,7 @@ async def test_get_with_templates( "power_command_template": "power: {{ value }}", "preset_mode_command_template": "preset_mode: {{ value }}", "mode_command_template": "mode: {{ value }}", + "swing_horizontal_mode_command_template": "swing_horizontal_mode: {{ value }}", "swing_mode_command_template": "swing_mode: {{ value }}", "temperature_command_template": "temp: {{ value }}", "temperature_high_command_template": "temp_hi: {{ value }}", @@ -1580,6 +1653,15 @@ async def test_set_and_templates( state = hass.states.get(ENTITY_CLIMATE) assert state.state == "off" + # Swing Horizontal Mode + await common.async_set_swing_horizontal_mode(hass, "on", ENTITY_CLIMATE) + mqtt_mock.async_publish.assert_called_once_with( + "swing-horizontal-mode-topic", "swing_horizontal_mode: on", 0, False + ) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get(ENTITY_CLIMATE) + assert state.attributes.get("swing_horizontal_mode") == "on" + # Swing Mode await common.async_set_swing_mode(hass, "on", ENTITY_CLIMATE) mqtt_mock.async_publish.assert_called_once_with( @@ -1940,6 +2022,7 @@ async def test_unique_id( ("fan_mode_state_topic", "low", ATTR_FAN_MODE, "low"), ("mode_state_topic", "cool", None, None), ("mode_state_topic", "fan_only", None, None), + ("swing_horizontal_mode_state_topic", "on", ATTR_SWING_HORIZONTAL_MODE, "on"), ("swing_mode_state_topic", "on", ATTR_SWING_MODE, "on"), ("temperature_low_state_topic", "19.1", ATTR_TARGET_TEMP_LOW, 19.1), ("temperature_high_state_topic", "22.9", ATTR_TARGET_TEMP_HIGH, 22.9), @@ -2178,6 +2261,13 @@ async def test_precision_whole( "medium", "fan_mode_command_template", ), + ( + climate.SERVICE_SET_SWING_HORIZONTAL_MODE, + "swing_horizontal_mode_command_topic", + {"swing_horizontal_mode": "on"}, + "on", + "swing_horizontal_mode_command_template", + ), ( climate.SERVICE_SET_SWING_MODE, "swing_mode_command_topic", @@ -2378,6 +2468,7 @@ async def test_unload_entry( "current_temperature_topic": "current-temperature-topic", "preset_mode_state_topic": "preset-mode-state-topic", "preset_modes": ["eco", "away"], + "swing_horizontal_mode_state_topic": "swing-horizontal-mode-state-topic", "swing_mode_state_topic": "swing-mode-state-topic", "target_humidity_state_topic": "target-humidity-state-topic", "temperature_high_state_topic": "temperature-high-state-topic", @@ -2399,6 +2490,7 @@ async def test_unload_entry( ("current-humidity-topic", "45", "46"), ("current-temperature-topic", "18.0", "18.1"), ("preset-mode-state-topic", "eco", "away"), + ("swing-horizontal-mode-state-topic", "on", "off"), ("swing-mode-state-topic", "on", "off"), ("target-humidity-state-topic", "45", "50"), ("temperature-state-topic", "18", "19"), diff --git a/tests/components/mqtt/test_discovery.py b/tests/components/mqtt/test_discovery.py index 982167feee1..47c3a1e1988 100644 --- a/tests/components/mqtt/test_discovery.py +++ b/tests/components/mqtt/test_discovery.py @@ -2380,7 +2380,6 @@ ABBREVIATIONS_WHITE_LIST = [ "CONF_PRECISION", "CONF_QOS", "CONF_SCHEMA", - "CONF_SWING_MODE_LIST", "CONF_TEMP_STEP", # Removed "CONF_WHITE_VALUE", From 2826198d5d0655a6c890afcaa08f70f8e8abe60b Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 26 Feb 2025 15:48:51 +0100 Subject: [PATCH 1842/3148] Add entity translations to SmartThings (#139342) * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * fix * fix * Add AC tests * Add thermostat tests * Add cover tests * Add device tests * Add light tests * Add rest of the tests * Add oauth * Add oauth tests * Add oauth tests * Add oauth tests * Add oauth tests * Bump version * Add rest of the tests * Finalize * Finalize * Finalize * Finalize * Finalize * Finalize * Finalize * Finalize * Finalize * Finalize * Iterate over entities instead * use set * use const * uncomment * fix handler * Fix device info * Fix device info * Fix lib * Fix lib * Fix * Fix * Fix * Fix * Fix * Fix * Fix * Fix * Fix * Add fake fan * Fix * Add entity translations to SmartThings * Fix --- .../components/smartthings/binary_sensor.py | 6 +- .../components/smartthings/climate.py | 3 + homeassistant/components/smartthings/cover.py | 1 + .../components/smartthings/entity.py | 2 +- homeassistant/components/smartthings/fan.py | 1 + homeassistant/components/smartthings/light.py | 1 + homeassistant/components/smartthings/lock.py | 2 + .../components/smartthings/sensor.py | 134 +- .../components/smartthings/strings.json | 183 ++ .../components/smartthings/switch.py | 2 + .../snapshots/test_binary_sensor.ambr | 102 +- .../smartthings/snapshots/test_climate.ambr | 16 +- .../smartthings/snapshots/test_cover.ambr | 8 +- .../smartthings/snapshots/test_fan.ambr | 4 +- .../smartthings/snapshots/test_light.ambr | 16 +- .../smartthings/snapshots/test_lock.ambr | 4 +- .../smartthings/snapshots/test_sensor.ambr | 2320 ++++++++--------- .../smartthings/snapshots/test_switch.ambr | 40 +- .../smartthings/test_binary_sensor.py | 4 +- tests/components/smartthings/test_sensor.py | 9 +- 20 files changed, 1517 insertions(+), 1341 deletions(-) diff --git a/homeassistant/components/smartthings/binary_sensor.py b/homeassistant/components/smartthings/binary_sensor.py index 6afa4edcf17..99cbd3f9353 100644 --- a/homeassistant/components/smartthings/binary_sensor.py +++ b/homeassistant/components/smartthings/binary_sensor.py @@ -33,6 +33,7 @@ CAPABILITY_TO_SENSORS: dict[ Capability.ACCELERATION_SENSOR: { Attribute.ACCELERATION: SmartThingsBinarySensorEntityDescription( key=Attribute.ACCELERATION, + translation_key="acceleration", device_class=BinarySensorDeviceClass.MOVING, is_on_key="active", ) @@ -47,6 +48,7 @@ CAPABILITY_TO_SENSORS: dict[ Capability.FILTER_STATUS: { Attribute.FILTER_STATUS: SmartThingsBinarySensorEntityDescription( key=Attribute.FILTER_STATUS, + translation_key="filter_status", device_class=BinarySensorDeviceClass.PROBLEM, is_on_key="replace", ) @@ -75,7 +77,7 @@ CAPABILITY_TO_SENSORS: dict[ Capability.TAMPER_ALERT: { Attribute.TAMPER: SmartThingsBinarySensorEntityDescription( key=Attribute.TAMPER, - device_class=BinarySensorDeviceClass.PROBLEM, + device_class=BinarySensorDeviceClass.TAMPER, is_on_key="detected", entity_category=EntityCategory.DIAGNOSTIC, ) @@ -83,6 +85,7 @@ CAPABILITY_TO_SENSORS: dict[ Capability.VALVE: { Attribute.VALVE: SmartThingsBinarySensorEntityDescription( key=Attribute.VALVE, + translation_key="valve", device_class=BinarySensorDeviceClass.OPENING, is_on_key="open", ) @@ -133,7 +136,6 @@ class SmartThingsBinarySensor(SmartThingsEntity, BinarySensorEntity): self._attribute = attribute self.capability = capability self.entity_description = entity_description - self._attr_name = f"{device.device.label} {attribute}" self._attr_unique_id = f"{device.device.device_id}.{attribute}" @property diff --git a/homeassistant/components/smartthings/climate.py b/homeassistant/components/smartthings/climate.py index 2e05fb2fc4f..2c3b8f3ac03 100644 --- a/homeassistant/components/smartthings/climate.py +++ b/homeassistant/components/smartthings/climate.py @@ -135,6 +135,8 @@ async def async_setup_entry( class SmartThingsThermostat(SmartThingsEntity, ClimateEntity): """Define a SmartThings climate entities.""" + _attr_name = None + def __init__(self, client: SmartThings, device: FullDevice) -> None: """Init the class.""" super().__init__( @@ -322,6 +324,7 @@ class SmartThingsThermostat(SmartThingsEntity, ClimateEntity): class SmartThingsAirConditioner(SmartThingsEntity, ClimateEntity): """Define a SmartThings Air Conditioner.""" + _attr_name = None _attr_preset_mode = None def __init__(self, client: SmartThings, device: FullDevice) -> None: diff --git a/homeassistant/components/smartthings/cover.py b/homeassistant/components/smartthings/cover.py index 97a7456d132..fd4752b4e28 100644 --- a/homeassistant/components/smartthings/cover.py +++ b/homeassistant/components/smartthings/cover.py @@ -51,6 +51,7 @@ async def async_setup_entry( class SmartThingsCover(SmartThingsEntity, CoverEntity): """Define a SmartThings cover.""" + _attr_name = None _state: CoverState | None = None def __init__( diff --git a/homeassistant/components/smartthings/entity.py b/homeassistant/components/smartthings/entity.py index f5f1f268801..b2e556c6718 100644 --- a/homeassistant/components/smartthings/entity.py +++ b/homeassistant/components/smartthings/entity.py @@ -17,6 +17,7 @@ class SmartThingsEntity(Entity): """Defines a SmartThings entity.""" _attr_should_poll = False + _attr_has_entity_name = True def __init__( self, client: SmartThings, device: FullDevice, capabilities: set[Capability] @@ -30,7 +31,6 @@ class SmartThingsEntity(Entity): if capability in device.status[MAIN] } self.device = device - self._attr_name = device.device.label self._attr_unique_id = device.device.device_id self._attr_device_info = DeviceInfo( configuration_url="https://account.smartthings.com", diff --git a/homeassistant/components/smartthings/fan.py b/homeassistant/components/smartthings/fan.py index 23afb0baeb2..8edf01ec613 100644 --- a/homeassistant/components/smartthings/fan.py +++ b/homeassistant/components/smartthings/fan.py @@ -48,6 +48,7 @@ async def async_setup_entry( class SmartThingsFan(SmartThingsEntity, FanEntity): """Define a SmartThings Fan.""" + _attr_name = None _attr_speed_count = int_states_in_range(SPEED_RANGE) def __init__(self, client: SmartThings, device: FullDevice) -> None: diff --git a/homeassistant/components/smartthings/light.py b/homeassistant/components/smartthings/light.py index 582f9dd5435..54e8ad18a7c 100644 --- a/homeassistant/components/smartthings/light.py +++ b/homeassistant/components/smartthings/light.py @@ -56,6 +56,7 @@ def convert_scale( class SmartThingsLight(SmartThingsEntity, LightEntity): """Define a SmartThings Light.""" + _attr_name = None _attr_supported_color_modes: set[ColorMode] # SmartThings does not expose this attribute, instead it's diff --git a/homeassistant/components/smartthings/lock.py b/homeassistant/components/smartthings/lock.py index 56274dfe161..f56ecd5d565 100644 --- a/homeassistant/components/smartthings/lock.py +++ b/homeassistant/components/smartthings/lock.py @@ -42,6 +42,8 @@ async def async_setup_entry( class SmartThingsLock(SmartThingsEntity, LockEntity): """Define a SmartThings lock.""" + _attr_name = None + async def async_lock(self, **kwargs: Any) -> None: """Lock the device.""" await self.execute_device_command( diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index b16d332a1ae..6685d6be726 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -69,7 +69,7 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.LIGHTING_MODE: [ SmartThingsSensorEntityDescription( key=Attribute.LIGHTING_MODE, - name="Activity Lighting Mode", + translation_key="lighting_mode", entity_category=EntityCategory.DIAGNOSTIC, ) ] @@ -78,7 +78,7 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.AIR_CONDITIONER_MODE: [ SmartThingsSensorEntityDescription( key=Attribute.AIR_CONDITIONER_MODE, - name="Air Conditioner Mode", + translation_key="air_conditioner_mode", entity_category=EntityCategory.DIAGNOSTIC, capability_ignore_list=[ { @@ -93,7 +93,7 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.AIR_QUALITY: [ SmartThingsSensorEntityDescription( key=Attribute.AIR_QUALITY, - name="Air Quality", + translation_key="air_quality", native_unit_of_measurement="CAQI", state_class=SensorStateClass.MEASUREMENT, ) @@ -103,7 +103,7 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.ALARM: [ SmartThingsSensorEntityDescription( key=Attribute.ALARM, - name="Alarm", + translation_key="alarm", ) ] }, @@ -111,7 +111,7 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.VOLUME: [ SmartThingsSensorEntityDescription( key=Attribute.VOLUME, - name="Volume", + translation_key="audio_volume", native_unit_of_measurement=PERCENTAGE, ) ] @@ -120,7 +120,6 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.BATTERY: [ SmartThingsSensorEntityDescription( key=Attribute.BATTERY, - name="Battery", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, entity_category=EntityCategory.DIAGNOSTIC, @@ -132,7 +131,7 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.BMI_MEASUREMENT: [ SmartThingsSensorEntityDescription( key=Attribute.BMI_MEASUREMENT, - name="Body Mass Index", + translation_key="body_mass_index", native_unit_of_measurement=f"{UnitOfMass.KILOGRAMS}/{UnitOfArea.SQUARE_METERS}", state_class=SensorStateClass.MEASUREMENT, ) @@ -143,7 +142,7 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.BODY_WEIGHT_MEASUREMENT: [ SmartThingsSensorEntityDescription( key=Attribute.BODY_WEIGHT_MEASUREMENT, - name="Body Weight", + translation_key="body_weight", native_unit_of_measurement=UnitOfMass.KILOGRAMS, device_class=SensorDeviceClass.WEIGHT, state_class=SensorStateClass.MEASUREMENT, @@ -155,7 +154,6 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.CARBON_DIOXIDE: [ SmartThingsSensorEntityDescription( key=Attribute.CARBON_DIOXIDE, - name="Carbon Dioxide", native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, device_class=SensorDeviceClass.CO2, state_class=SensorStateClass.MEASUREMENT, @@ -167,7 +165,7 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.CARBON_MONOXIDE: [ SmartThingsSensorEntityDescription( key=Attribute.CARBON_MONOXIDE, - name="Carbon Monoxide Detector", + translation_key="carbon_monoxide_detector", ) ] }, @@ -176,7 +174,6 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.CARBON_MONOXIDE_LEVEL: [ SmartThingsSensorEntityDescription( key=Attribute.CARBON_MONOXIDE_LEVEL, - name="Carbon Monoxide Level", native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, device_class=SensorDeviceClass.CO, state_class=SensorStateClass.MEASUREMENT, @@ -187,19 +184,19 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.MACHINE_STATE: [ SmartThingsSensorEntityDescription( key=Attribute.MACHINE_STATE, - name="Dishwasher Machine State", + translation_key="dishwasher_machine_state", ) ], Attribute.DISHWASHER_JOB_STATE: [ SmartThingsSensorEntityDescription( key=Attribute.DISHWASHER_JOB_STATE, - name="Dishwasher Job State", + translation_key="dishwasher_job_state", ) ], Attribute.COMPLETION_TIME: [ SmartThingsSensorEntityDescription( key=Attribute.COMPLETION_TIME, - name="Dishwasher Completion Time", + translation_key="completion_time", device_class=SensorDeviceClass.TIMESTAMP, value_fn=dt_util.parse_datetime, ) @@ -210,7 +207,7 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.DRYER_MODE: [ SmartThingsSensorEntityDescription( key=Attribute.DRYER_MODE, - name="Dryer Mode", + translation_key="dryer_mode", entity_category=EntityCategory.DIAGNOSTIC, ) ] @@ -219,19 +216,19 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.MACHINE_STATE: [ SmartThingsSensorEntityDescription( key=Attribute.MACHINE_STATE, - name="Dryer Machine State", + translation_key="dryer_machine_state", ) ], Attribute.DRYER_JOB_STATE: [ SmartThingsSensorEntityDescription( key=Attribute.DRYER_JOB_STATE, - name="Dryer Job State", + translation_key="dryer_job_state", ) ], Attribute.COMPLETION_TIME: [ SmartThingsSensorEntityDescription( key=Attribute.COMPLETION_TIME, - name="Dryer Completion Time", + translation_key="completion_time", device_class=SensorDeviceClass.TIMESTAMP, value_fn=dt_util.parse_datetime, ) @@ -241,14 +238,14 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.DUST_LEVEL: [ SmartThingsSensorEntityDescription( key=Attribute.DUST_LEVEL, - name="Dust Level", + translation_key="dust_level", state_class=SensorStateClass.MEASUREMENT, ) ], Attribute.FINE_DUST_LEVEL: [ SmartThingsSensorEntityDescription( key=Attribute.FINE_DUST_LEVEL, - name="Fine Dust Level", + translation_key="fine_dust_level", state_class=SensorStateClass.MEASUREMENT, ) ], @@ -257,7 +254,6 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.ENERGY: [ SmartThingsSensorEntityDescription( key=Attribute.ENERGY, - name="Energy Meter", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, @@ -269,7 +265,7 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.EQUIVALENT_CARBON_DIOXIDE_MEASUREMENT: [ SmartThingsSensorEntityDescription( key=Attribute.EQUIVALENT_CARBON_DIOXIDE_MEASUREMENT, - name="Equivalent Carbon Dioxide Measurement", + translation_key="equivalent_carbon_dioxide", native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, device_class=SensorDeviceClass.CO2, state_class=SensorStateClass.MEASUREMENT, @@ -281,7 +277,7 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.FORMALDEHYDE_LEVEL: [ SmartThingsSensorEntityDescription( key=Attribute.FORMALDEHYDE_LEVEL, - name="Formaldehyde Measurement", + translation_key="formaldehyde", native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, state_class=SensorStateClass.MEASUREMENT, ) @@ -292,7 +288,7 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.GAS_METER: [ SmartThingsSensorEntityDescription( key=Attribute.GAS_METER, - name="Gas Meter", + translation_key="gas_meter", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.MEASUREMENT, @@ -301,13 +297,13 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.GAS_METER_CALORIFIC: [ SmartThingsSensorEntityDescription( key=Attribute.GAS_METER_CALORIFIC, - name="Gas Meter Calorific", + translation_key="gas_meter_calorific", ) ], Attribute.GAS_METER_TIME: [ SmartThingsSensorEntityDescription( key=Attribute.GAS_METER_TIME, - name="Gas Meter Time", + translation_key="gas_meter_time", device_class=SensorDeviceClass.TIMESTAMP, value_fn=dt_util.parse_datetime, ) @@ -315,7 +311,6 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.GAS_METER_VOLUME: [ SmartThingsSensorEntityDescription( key=Attribute.GAS_METER_VOLUME, - name="Gas Meter Volume", native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, device_class=SensorDeviceClass.GAS, state_class=SensorStateClass.MEASUREMENT, @@ -327,7 +322,6 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.ILLUMINANCE: [ SmartThingsSensorEntityDescription( key=Attribute.ILLUMINANCE, - name="Illuminance", native_unit_of_measurement=LIGHT_LUX, device_class=SensorDeviceClass.ILLUMINANCE, state_class=SensorStateClass.MEASUREMENT, @@ -339,7 +333,7 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.INFRARED_LEVEL: [ SmartThingsSensorEntityDescription( key=Attribute.INFRARED_LEVEL, - name="Infrared Level", + translation_key="infrared_level", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, ) @@ -349,7 +343,7 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.INPUT_SOURCE: [ SmartThingsSensorEntityDescription( key=Attribute.INPUT_SOURCE, - name="Media Input Source", + translation_key="media_input_source", ) ] }, @@ -358,7 +352,7 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.PLAYBACK_REPEAT_MODE: [ SmartThingsSensorEntityDescription( key=Attribute.PLAYBACK_REPEAT_MODE, - name="Media Playback Repeat", + translation_key="media_playback_repeat", ) ] }, @@ -367,7 +361,7 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.PLAYBACK_SHUFFLE: [ SmartThingsSensorEntityDescription( key=Attribute.PLAYBACK_SHUFFLE, - name="Media Playback Shuffle", + translation_key="media_playback_shuffle", ) ] }, @@ -375,7 +369,7 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.PLAYBACK_STATUS: [ SmartThingsSensorEntityDescription( key=Attribute.PLAYBACK_STATUS, - name="Media Playback Status", + translation_key="media_playback_status", ) ] }, @@ -383,7 +377,7 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.ODOR_LEVEL: [ SmartThingsSensorEntityDescription( key=Attribute.ODOR_LEVEL, - name="Odor Sensor", + translation_key="odor_sensor", ) ] }, @@ -391,7 +385,7 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.OVEN_MODE: [ SmartThingsSensorEntityDescription( key=Attribute.OVEN_MODE, - name="Oven Mode", + translation_key="oven_mode", entity_category=EntityCategory.DIAGNOSTIC, ) ] @@ -400,19 +394,19 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.MACHINE_STATE: [ SmartThingsSensorEntityDescription( key=Attribute.MACHINE_STATE, - name="Oven Machine State", + translation_key="oven_machine_state", ) ], Attribute.OVEN_JOB_STATE: [ SmartThingsSensorEntityDescription( key=Attribute.OVEN_JOB_STATE, - name="Oven Job State", + translation_key="oven_job_state", ) ], Attribute.COMPLETION_TIME: [ SmartThingsSensorEntityDescription( key=Attribute.COMPLETION_TIME, - name="Oven Completion Time", + translation_key="completion_time", ) ], }, @@ -420,7 +414,7 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.OVEN_SETPOINT: [ SmartThingsSensorEntityDescription( key=Attribute.OVEN_SETPOINT, - name="Oven Set Point", + translation_key="oven_setpoint", ) ] }, @@ -428,7 +422,6 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.POWER_CONSUMPTION: [ SmartThingsSensorEntityDescription( key="energy_meter", - name="energy", state_class=SensorStateClass.TOTAL_INCREASING, device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, @@ -436,7 +429,6 @@ CAPABILITY_TO_SENSORS: dict[ ), SmartThingsSensorEntityDescription( key="power_meter", - name="power", state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.POWER, native_unit_of_measurement=UnitOfPower.WATT, @@ -445,7 +437,7 @@ CAPABILITY_TO_SENSORS: dict[ ), SmartThingsSensorEntityDescription( key="deltaEnergy_meter", - name="deltaEnergy", + translation_key="energy_difference", state_class=SensorStateClass.TOTAL_INCREASING, device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, @@ -453,7 +445,7 @@ CAPABILITY_TO_SENSORS: dict[ ), SmartThingsSensorEntityDescription( key="powerEnergy_meter", - name="powerEnergy", + translation_key="power_energy", state_class=SensorStateClass.TOTAL_INCREASING, device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, @@ -461,7 +453,7 @@ CAPABILITY_TO_SENSORS: dict[ ), SmartThingsSensorEntityDescription( key="energySaved_meter", - name="energySaved", + translation_key="energy_saved", state_class=SensorStateClass.TOTAL_INCREASING, device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, @@ -473,7 +465,6 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.POWER: [ SmartThingsSensorEntityDescription( key=Attribute.POWER, - name="Power Meter", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, @@ -485,7 +476,7 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.POWER_SOURCE: [ SmartThingsSensorEntityDescription( key=Attribute.POWER_SOURCE, - name="Power Source", + translation_key="power_source", entity_category=EntityCategory.DIAGNOSTIC, ) ] @@ -495,7 +486,7 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.REFRIGERATION_SETPOINT: [ SmartThingsSensorEntityDescription( key=Attribute.REFRIGERATION_SETPOINT, - name="Refrigeration Setpoint", + translation_key="refrigeration_setpoint", device_class=SensorDeviceClass.TEMPERATURE, ) ] @@ -504,7 +495,6 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.HUMIDITY: [ SmartThingsSensorEntityDescription( key=Attribute.HUMIDITY, - name="Relative Humidity Measurement", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.HUMIDITY, state_class=SensorStateClass.MEASUREMENT, @@ -515,7 +505,7 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.ROBOT_CLEANER_CLEANING_MODE: [ SmartThingsSensorEntityDescription( key=Attribute.ROBOT_CLEANER_CLEANING_MODE, - name="Robot Cleaner Cleaning Mode", + translation_key="robot_cleaner_cleaning_mode", entity_category=EntityCategory.DIAGNOSTIC, ) ], @@ -524,7 +514,7 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.ROBOT_CLEANER_MOVEMENT: [ SmartThingsSensorEntityDescription( key=Attribute.ROBOT_CLEANER_MOVEMENT, - name="Robot Cleaner Movement", + translation_key="robot_cleaner_movement", ) ] }, @@ -532,7 +522,7 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.ROBOT_CLEANER_TURBO_MODE: [ SmartThingsSensorEntityDescription( key=Attribute.ROBOT_CLEANER_TURBO_MODE, - name="Robot Cleaner Turbo Mode", + translation_key="robot_cleaner_turbo_mode", entity_category=EntityCategory.DIAGNOSTIC, ) ] @@ -542,7 +532,7 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.LQI: [ SmartThingsSensorEntityDescription( key=Attribute.LQI, - name="LQI Signal Strength", + translation_key="link_quality", state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, ) @@ -550,7 +540,6 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.RSSI: [ SmartThingsSensorEntityDescription( key=Attribute.RSSI, - name="RSSI Signal Strength", device_class=SensorDeviceClass.SIGNAL_STRENGTH, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -562,7 +551,7 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.SMOKE: [ SmartThingsSensorEntityDescription( key=Attribute.SMOKE, - name="Smoke Detector", + translation_key="smoke_detector", ) ] }, @@ -570,7 +559,6 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.TEMPERATURE: [ SmartThingsSensorEntityDescription( key=Attribute.TEMPERATURE, - name="Temperature Measurement", device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ) @@ -580,7 +568,7 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.COOLING_SETPOINT: [ SmartThingsSensorEntityDescription( key=Attribute.COOLING_SETPOINT, - name="Thermostat Cooling Setpoint", + translation_key="thermostat_cooling_setpoint", device_class=SensorDeviceClass.TEMPERATURE, capability_ignore_list=[ { @@ -598,7 +586,7 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.THERMOSTAT_FAN_MODE: [ SmartThingsSensorEntityDescription( key=Attribute.THERMOSTAT_FAN_MODE, - name="Thermostat Fan Mode", + translation_key="thermostat_fan_mode", entity_category=EntityCategory.DIAGNOSTIC, capability_ignore_list=[THERMOSTAT_CAPABILITIES], ) @@ -609,7 +597,7 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.HEATING_SETPOINT: [ SmartThingsSensorEntityDescription( key=Attribute.HEATING_SETPOINT, - name="Thermostat Heating Setpoint", + translation_key="thermostat_heating_setpoint", device_class=SensorDeviceClass.TEMPERATURE, entity_category=EntityCategory.DIAGNOSTIC, capability_ignore_list=[THERMOSTAT_CAPABILITIES], @@ -621,7 +609,7 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.THERMOSTAT_MODE: [ SmartThingsSensorEntityDescription( key=Attribute.THERMOSTAT_MODE, - name="Thermostat Mode", + translation_key="thermostat_mode", entity_category=EntityCategory.DIAGNOSTIC, capability_ignore_list=[THERMOSTAT_CAPABILITIES], ) @@ -632,7 +620,7 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.THERMOSTAT_OPERATING_STATE: [ SmartThingsSensorEntityDescription( key=Attribute.THERMOSTAT_OPERATING_STATE, - name="Thermostat Operating State", + translation_key="thermostat_operating_state", capability_ignore_list=[THERMOSTAT_CAPABILITIES], ) ] @@ -642,7 +630,7 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.THERMOSTAT_SETPOINT: [ SmartThingsSensorEntityDescription( key=Attribute.THERMOSTAT_SETPOINT, - name="Thermostat Setpoint", + translation_key="thermostat_setpoint", device_class=SensorDeviceClass.TEMPERATURE, entity_category=EntityCategory.DIAGNOSTIC, ) @@ -652,19 +640,19 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.THREE_AXIS: [ SmartThingsSensorEntityDescription( key="X Coordinate", - name="X Coordinate", + translation_key="x_coordinate", unique_id_separator=" ", value_fn=lambda value: value[0], ), SmartThingsSensorEntityDescription( key="Y Coordinate", - name="Y Coordinate", + translation_key="y_coordinate", unique_id_separator=" ", value_fn=lambda value: value[1], ), SmartThingsSensorEntityDescription( key="Z Coordinate", - name="Z Coordinate", + translation_key="z_coordinate", unique_id_separator=" ", value_fn=lambda value: value[2], ), @@ -674,13 +662,13 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.TV_CHANNEL: [ SmartThingsSensorEntityDescription( key=Attribute.TV_CHANNEL, - name="Tv Channel", + translation_key="tv_channel", ) ], Attribute.TV_CHANNEL_NAME: [ SmartThingsSensorEntityDescription( key=Attribute.TV_CHANNEL_NAME, - name="Tv Channel Name", + translation_key="tv_channel_name", ) ], }, @@ -689,7 +677,7 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.TVOC_LEVEL: [ SmartThingsSensorEntityDescription( key=Attribute.TVOC_LEVEL, - name="Tvoc Measurement", + device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS, native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, state_class=SensorStateClass.MEASUREMENT, ) @@ -700,7 +688,7 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.ULTRAVIOLET_INDEX: [ SmartThingsSensorEntityDescription( key=Attribute.ULTRAVIOLET_INDEX, - name="Ultraviolet Index", + translation_key="uv_index", state_class=SensorStateClass.MEASUREMENT, ) ] @@ -709,7 +697,6 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.VOLTAGE: [ SmartThingsSensorEntityDescription( key=Attribute.VOLTAGE, - name="Voltage Measurement", device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, ) @@ -720,7 +707,7 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.WASHER_MODE: [ SmartThingsSensorEntityDescription( key=Attribute.WASHER_MODE, - name="Washer Mode", + translation_key="washer_mode", entity_category=EntityCategory.DIAGNOSTIC, ) ] @@ -729,19 +716,19 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.MACHINE_STATE: [ SmartThingsSensorEntityDescription( key=Attribute.MACHINE_STATE, - name="Washer Machine State", + translation_key="washer_machine_state", ) ], Attribute.WASHER_JOB_STATE: [ SmartThingsSensorEntityDescription( key=Attribute.WASHER_JOB_STATE, - name="Washer Job State", + translation_key="washer_job_state", ) ], Attribute.COMPLETION_TIME: [ SmartThingsSensorEntityDescription( key=Attribute.COMPLETION_TIME, - name="Washer Completion Time", + translation_key="completion_time", device_class=SensorDeviceClass.TIMESTAMP, value_fn=dt_util.parse_datetime, ) @@ -795,7 +782,6 @@ class SmartThingsSensor(SmartThingsEntity, SensorEntity): ) -> None: """Init the class.""" super().__init__(client, device, {capability}) - self._attr_name = f"{device.device.label} {entity_description.name}" self._attr_unique_id = f"{device.device.device_id}{entity_description.unique_id_separator}{entity_description.key}" self._attribute = attribute self.capability = capability diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index 5112d819026..9cfc6176d20 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -25,5 +25,188 @@ "reauth_account_mismatch": "Authenticated account does not match the account to be reauthenticated. Please log in with the correct account and pick the right location.", "reauth_location_mismatch": "Authenticated location does not match the location to be reauthenticated. Please log in with the correct account and pick the right location." } + }, + "entity": { + "binary_sensor": { + "acceleration": { + "name": "Acceleration" + }, + "filter_status": { + "name": "Filter status" + }, + "valve": { + "name": "Valve" + } + }, + "sensor": { + "lighting_mode": { + "name": "Activity lighting mode" + }, + "air_conditioner_mode": { + "name": "Air conditioner mode" + }, + "air_quality": { + "name": "Air quality" + }, + "alarm": { + "name": "Alarm" + }, + "audio_volume": { + "name": "Volume" + }, + "body_mass_index": { + "name": "Body mass index" + }, + "body_weight": { + "name": "Body weight" + }, + "carbon_monoxide_detector": { + "name": "Carbon monoxide detector" + }, + "dishwasher_machine_state": { + "name": "Machine state" + }, + "dishwasher_job_state": { + "name": "Job state" + }, + "completion_time": { + "name": "Completion time" + }, + "dryer_mode": { + "name": "Dryer mode" + }, + "dryer_machine_state": { + "name": "[%key:component::smartthings::entity::sensor::dishwasher_machine_state::name%]" + }, + "dryer_job_state": { + "name": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::name%]" + }, + "dust_level": { + "name": "Dust level" + }, + "fine_dust_level": { + "name": "Fine dust level" + }, + "equivalent_carbon_dioxide": { + "name": "Equivalent carbon dioxide" + }, + "formaldehyde": { + "name": "Formaldehyde" + }, + "gas_meter": { + "name": "Gas meter" + }, + "gas_meter_calorific": { + "name": "Gas meter calorific" + }, + "gas_meter_time": { + "name": "Gas meter time" + }, + "infrared_level": { + "name": "Infrared level" + }, + "media_input_source": { + "name": "Media input source" + }, + "media_playback_repeat": { + "name": "Media playback repeat" + }, + "media_playback_shuffle": { + "name": "Media playback shuffle" + }, + "media_playback_status": { + "name": "Media playback status" + }, + "odor_sensor": { + "name": "Odor sensor" + }, + "oven_mode": { + "name": "Oven mode" + }, + "oven_machine_state": { + "name": "[%key:component::smartthings::entity::sensor::dishwasher_machine_state::name%]" + }, + "oven_job_state": { + "name": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::name%]" + }, + "oven_setpoint": { + "name": "Set point" + }, + "energy_difference": { + "name": "Energy difference" + }, + "power_energy": { + "name": "Power energy" + }, + "energy_saved": { + "name": "Energy saved" + }, + "power_source": { + "name": "Power source" + }, + "refrigeration_setpoint": { + "name": "[%key:component::smartthings::entity::sensor::oven_setpoint::name%]" + }, + "robot_cleaner_cleaning_mode": { + "name": "Cleaning mode" + }, + "robot_cleaner_movement": { + "name": "Movement" + }, + "robot_cleaner_turbo_mode": { + "name": "Turbo mode" + }, + "link_quality": { + "name": "Link quality" + }, + "smoke_detector": { + "name": "Smoke detector" + }, + "thermostat_cooling_setpoint": { + "name": "Cooling set point" + }, + "thermostat_fan_mode": { + "name": "Fan mode" + }, + "thermostat_heating_setpoint": { + "name": "Heating set point" + }, + "thermostat_mode": { + "name": "Mode" + }, + "thermostat_operating_state": { + "name": "Operating state" + }, + "thermostat_setpoint": { + "name": "[%key:component::smartthings::entity::sensor::oven_setpoint::name%]" + }, + "x_coordinate": { + "name": "X coordinate" + }, + "y_coordinate": { + "name": "Y coordinate" + }, + "z_coordinate": { + "name": "Z coordinate" + }, + "tv_channel": { + "name": "TV channel" + }, + "tv_channel_name": { + "name": "TV channel name" + }, + "uv_index": { + "name": "UV index" + }, + "washer_mode": { + "name": "Washer mode" + }, + "washer_machine_state": { + "name": "[%key:component::smartthings::entity::sensor::dishwasher_machine_state::name%]" + }, + "washer_job_state": { + "name": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::name%]" + } + } } } diff --git a/homeassistant/components/smartthings/switch.py b/homeassistant/components/smartthings/switch.py index d8cd9f1f956..380005f1b93 100644 --- a/homeassistant/components/smartthings/switch.py +++ b/homeassistant/components/smartthings/switch.py @@ -48,6 +48,8 @@ async def async_setup_entry( class SmartThingsSwitch(SmartThingsEntity, SwitchEntity): """Define a SmartThings switch.""" + _attr_name = None + async def async_turn_off(self, **kwargs: Any) -> None: """Turn the switch off.""" await self.execute_device_command( diff --git a/tests/components/smartthings/snapshots/test_binary_sensor.ambr b/tests/components/smartthings/snapshots/test_binary_sensor.ambr index 1317c19edd7..27a5e38a123 100644 --- a/tests/components/smartthings/snapshots/test_binary_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_binary_sensor.ambr @@ -13,7 +13,7 @@ 'domain': 'binary_sensor', 'entity_category': None, 'entity_id': 'binary_sensor.2nd_floor_hallway_motion', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -24,7 +24,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': '2nd Floor Hallway motion', + 'original_name': 'Motion', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -37,7 +37,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'motion', - 'friendly_name': '2nd Floor Hallway motion', + 'friendly_name': '2nd Floor Hallway Motion', }), 'context': , 'entity_id': 'binary_sensor.2nd_floor_hallway_motion', @@ -61,7 +61,7 @@ 'domain': 'binary_sensor', 'entity_category': None, 'entity_id': 'binary_sensor.2nd_floor_hallway_sound', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -72,7 +72,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': '2nd Floor Hallway sound', + 'original_name': 'Sound', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -85,7 +85,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'sound', - 'friendly_name': '2nd Floor Hallway sound', + 'friendly_name': '2nd Floor Hallway Sound', }), 'context': , 'entity_id': 'binary_sensor.2nd_floor_hallway_sound', @@ -95,7 +95,7 @@ 'state': 'off', }) # --- -# name: test_all_entities[contact_sensor][binary_sensor.front_door_open_closed_sensor_contact-entry] +# name: test_all_entities[contact_sensor][binary_sensor.front_door_open_closed_sensor_door-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -108,8 +108,8 @@ 'disabled_by': None, 'domain': 'binary_sensor', 'entity_category': None, - 'entity_id': 'binary_sensor.front_door_open_closed_sensor_contact', - 'has_entity_name': False, + 'entity_id': 'binary_sensor.front_door_open_closed_sensor_door', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -120,7 +120,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': '.Front Door Open/Closed Sensor contact', + 'original_name': 'Door', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -129,21 +129,21 @@ 'unit_of_measurement': None, }) # --- -# name: test_all_entities[contact_sensor][binary_sensor.front_door_open_closed_sensor_contact-state] +# name: test_all_entities[contact_sensor][binary_sensor.front_door_open_closed_sensor_door-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'door', - 'friendly_name': '.Front Door Open/Closed Sensor contact', + 'friendly_name': '.Front Door Open/Closed Sensor Door', }), 'context': , - 'entity_id': 'binary_sensor.front_door_open_closed_sensor_contact', + 'entity_id': 'binary_sensor.front_door_open_closed_sensor_door', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'off', }) # --- -# name: test_all_entities[da_ref_normal_000001][binary_sensor.refrigerator_contact-entry] +# name: test_all_entities[da_ref_normal_000001][binary_sensor.refrigerator_door-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -156,8 +156,8 @@ 'disabled_by': None, 'domain': 'binary_sensor', 'entity_category': None, - 'entity_id': 'binary_sensor.refrigerator_contact', - 'has_entity_name': False, + 'entity_id': 'binary_sensor.refrigerator_door', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -168,7 +168,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Refrigerator contact', + 'original_name': 'Door', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -177,14 +177,14 @@ 'unit_of_measurement': None, }) # --- -# name: test_all_entities[da_ref_normal_000001][binary_sensor.refrigerator_contact-state] +# name: test_all_entities[da_ref_normal_000001][binary_sensor.refrigerator_door-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'door', - 'friendly_name': 'Refrigerator contact', + 'friendly_name': 'Refrigerator Door', }), 'context': , - 'entity_id': 'binary_sensor.refrigerator_contact', + 'entity_id': 'binary_sensor.refrigerator_door', 'last_changed': , 'last_reported': , 'last_updated': , @@ -205,7 +205,7 @@ 'domain': 'binary_sensor', 'entity_category': None, 'entity_id': 'binary_sensor.child_bedroom_motion', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -216,7 +216,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Child Bedroom motion', + 'original_name': 'Motion', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -229,7 +229,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'motion', - 'friendly_name': 'Child Bedroom motion', + 'friendly_name': 'Child Bedroom Motion', }), 'context': , 'entity_id': 'binary_sensor.child_bedroom_motion', @@ -253,7 +253,7 @@ 'domain': 'binary_sensor', 'entity_category': None, 'entity_id': 'binary_sensor.child_bedroom_presence', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -264,7 +264,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Child Bedroom presence', + 'original_name': 'Presence', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -277,7 +277,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'presence', - 'friendly_name': 'Child Bedroom presence', + 'friendly_name': 'Child Bedroom Presence', }), 'context': , 'entity_id': 'binary_sensor.child_bedroom_presence', @@ -301,7 +301,7 @@ 'domain': 'binary_sensor', 'entity_category': None, 'entity_id': 'binary_sensor.iphone_presence', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -312,7 +312,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'iPhone presence', + 'original_name': 'Presence', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -325,7 +325,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'presence', - 'friendly_name': 'iPhone presence', + 'friendly_name': 'iPhone Presence', }), 'context': , 'entity_id': 'binary_sensor.iphone_presence', @@ -349,7 +349,7 @@ 'domain': 'binary_sensor', 'entity_category': None, 'entity_id': 'binary_sensor.deck_door_acceleration', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -360,11 +360,11 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Deck Door acceleration', + 'original_name': 'Acceleration', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'acceleration', 'unique_id': '7d246592-93db-4d72-a10d-5a51793ece8c.acceleration', 'unit_of_measurement': None, }) @@ -373,7 +373,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'moving', - 'friendly_name': 'Deck Door acceleration', + 'friendly_name': 'Deck Door Acceleration', }), 'context': , 'entity_id': 'binary_sensor.deck_door_acceleration', @@ -383,7 +383,7 @@ 'state': 'off', }) # --- -# name: test_all_entities[multipurpose_sensor][binary_sensor.deck_door_contact-entry] +# name: test_all_entities[multipurpose_sensor][binary_sensor.deck_door_door-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -396,8 +396,8 @@ 'disabled_by': None, 'domain': 'binary_sensor', 'entity_category': None, - 'entity_id': 'binary_sensor.deck_door_contact', - 'has_entity_name': False, + 'entity_id': 'binary_sensor.deck_door_door', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -408,7 +408,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Deck Door contact', + 'original_name': 'Door', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -417,14 +417,14 @@ 'unit_of_measurement': None, }) # --- -# name: test_all_entities[multipurpose_sensor][binary_sensor.deck_door_contact-state] +# name: test_all_entities[multipurpose_sensor][binary_sensor.deck_door_door-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'door', - 'friendly_name': 'Deck Door contact', + 'friendly_name': 'Deck Door Door', }), 'context': , - 'entity_id': 'binary_sensor.deck_door_contact', + 'entity_id': 'binary_sensor.deck_door_door', 'last_changed': , 'last_reported': , 'last_updated': , @@ -445,7 +445,7 @@ 'domain': 'binary_sensor', 'entity_category': None, 'entity_id': 'binary_sensor.volvo_valve', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -456,11 +456,11 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'volvo valve', + 'original_name': 'Valve', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'valve', 'unique_id': '612ab3c2-3bb0-48f7-b2c0-15b169cb2fc3.valve', 'unit_of_measurement': None, }) @@ -469,7 +469,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'opening', - 'friendly_name': 'volvo valve', + 'friendly_name': 'volvo Valve', }), 'context': , 'entity_id': 'binary_sensor.volvo_valve', @@ -479,7 +479,7 @@ 'state': 'off', }) # --- -# name: test_all_entities[virtual_water_sensor][binary_sensor.asd_water-entry] +# name: test_all_entities[virtual_water_sensor][binary_sensor.asd_moisture-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -492,8 +492,8 @@ 'disabled_by': None, 'domain': 'binary_sensor', 'entity_category': None, - 'entity_id': 'binary_sensor.asd_water', - 'has_entity_name': False, + 'entity_id': 'binary_sensor.asd_moisture', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -504,7 +504,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'asd water', + 'original_name': 'Moisture', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -513,14 +513,14 @@ 'unit_of_measurement': None, }) # --- -# name: test_all_entities[virtual_water_sensor][binary_sensor.asd_water-state] +# name: test_all_entities[virtual_water_sensor][binary_sensor.asd_moisture-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'moisture', - 'friendly_name': 'asd water', + 'friendly_name': 'asd Moisture', }), 'context': , - 'entity_id': 'binary_sensor.asd_water', + 'entity_id': 'binary_sensor.asd_moisture', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/smartthings/snapshots/test_climate.ambr b/tests/components/smartthings/snapshots/test_climate.ambr index bd76637cfb7..ba32776011a 100644 --- a/tests/components/smartthings/snapshots/test_climate.ambr +++ b/tests/components/smartthings/snapshots/test_climate.ambr @@ -35,7 +35,7 @@ 'domain': 'climate', 'entity_category': None, 'entity_id': 'climate.ac_office_granit', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -46,7 +46,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'AC Office Granit', + 'original_name': None, 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': , @@ -140,7 +140,7 @@ 'domain': 'climate', 'entity_category': None, 'entity_id': 'climate.aire_dormitorio_principal', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -151,7 +151,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Aire Dormitorio Principal', + 'original_name': None, 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': , @@ -234,7 +234,7 @@ 'domain': 'climate', 'entity_category': None, 'entity_id': 'climate.main_floor', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -245,7 +245,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Main Floor', + 'original_name': None, 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': , @@ -307,7 +307,7 @@ 'domain': 'climate', 'entity_category': None, 'entity_id': 'climate.asd', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -318,7 +318,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'asd', + 'original_name': None, 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': , diff --git a/tests/components/smartthings/snapshots/test_cover.ambr b/tests/components/smartthings/snapshots/test_cover.ambr index 6283e4fef04..102be416cea 100644 --- a/tests/components/smartthings/snapshots/test_cover.ambr +++ b/tests/components/smartthings/snapshots/test_cover.ambr @@ -13,7 +13,7 @@ 'domain': 'cover', 'entity_category': None, 'entity_id': 'cover.curtain_1a', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -24,7 +24,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Curtain 1A', + 'original_name': None, 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': , @@ -63,7 +63,7 @@ 'domain': 'cover', 'entity_category': None, 'entity_id': 'cover.microwave', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -74,7 +74,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Microwave', + 'original_name': None, 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': , diff --git a/tests/components/smartthings/snapshots/test_fan.ambr b/tests/components/smartthings/snapshots/test_fan.ambr index 400ceef8390..33caffcacc6 100644 --- a/tests/components/smartthings/snapshots/test_fan.ambr +++ b/tests/components/smartthings/snapshots/test_fan.ambr @@ -21,7 +21,7 @@ 'domain': 'fan', 'entity_category': None, 'entity_id': 'fan.fake_fan', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -32,7 +32,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Fake fan', + 'original_name': None, 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': , diff --git a/tests/components/smartthings/snapshots/test_light.ambr b/tests/components/smartthings/snapshots/test_light.ambr index 8e7f424f658..8766811c443 100644 --- a/tests/components/smartthings/snapshots/test_light.ambr +++ b/tests/components/smartthings/snapshots/test_light.ambr @@ -17,7 +17,7 @@ 'domain': 'light', 'entity_category': None, 'entity_id': 'light.dimmer_debian', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -28,7 +28,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Dimmer Debian', + 'original_name': None, 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': , @@ -74,7 +74,7 @@ 'domain': 'light', 'entity_category': None, 'entity_id': 'light.basement_exit_light', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -85,7 +85,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Basement Exit Light', + 'original_name': None, 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': , @@ -135,7 +135,7 @@ 'domain': 'light', 'entity_category': None, 'entity_id': 'light.bathroom_spot', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -146,7 +146,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Bathroom spot', + 'original_name': None, 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': , @@ -216,7 +216,7 @@ 'domain': 'light', 'entity_category': None, 'entity_id': 'light.standing_light', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -227,7 +227,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Standing light', + 'original_name': None, 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': , diff --git a/tests/components/smartthings/snapshots/test_lock.ambr b/tests/components/smartthings/snapshots/test_lock.ambr index 94370f8570b..2cf9688c3dd 100644 --- a/tests/components/smartthings/snapshots/test_lock.ambr +++ b/tests/components/smartthings/snapshots/test_lock.ambr @@ -13,7 +13,7 @@ 'domain': 'lock', 'entity_category': None, 'entity_id': 'lock.basement_door_lock', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -24,7 +24,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Basement Door Lock', + 'original_name': None, 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index 92928b9606b..2fca1a8d108 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_all_entities[aeotec_home_energy_meter_gen5][sensor.aeotec_energy_monitor_energy_meter-entry] +# name: test_all_entities[aeotec_home_energy_meter_gen5][sensor.aeotec_energy_monitor_energy_2-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -14,8 +14,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.aeotec_energy_monitor_energy_meter', - 'has_entity_name': False, + 'entity_id': 'sensor.aeotec_energy_monitor_energy_2', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -26,7 +26,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Aeotec Energy Monitor Energy Meter', + 'original_name': 'Energy', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -35,23 +35,23 @@ 'unit_of_measurement': 'kWh', }) # --- -# name: test_all_entities[aeotec_home_energy_meter_gen5][sensor.aeotec_energy_monitor_energy_meter-state] +# name: test_all_entities[aeotec_home_energy_meter_gen5][sensor.aeotec_energy_monitor_energy_2-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', - 'friendly_name': 'Aeotec Energy Monitor Energy Meter', + 'friendly_name': 'Aeotec Energy Monitor Energy', 'state_class': , 'unit_of_measurement': 'kWh', }), 'context': , - 'entity_id': 'sensor.aeotec_energy_monitor_energy_meter', + 'entity_id': 'sensor.aeotec_energy_monitor_energy_2', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '19978.536', }) # --- -# name: test_all_entities[aeotec_home_energy_meter_gen5][sensor.aeotec_energy_monitor_power_meter-entry] +# name: test_all_entities[aeotec_home_energy_meter_gen5][sensor.aeotec_energy_monitor_power-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -66,8 +66,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.aeotec_energy_monitor_power_meter', - 'has_entity_name': False, + 'entity_id': 'sensor.aeotec_energy_monitor_power', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -78,7 +78,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Aeotec Energy Monitor Power Meter', + 'original_name': 'Power', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -87,23 +87,23 @@ 'unit_of_measurement': 'W', }) # --- -# name: test_all_entities[aeotec_home_energy_meter_gen5][sensor.aeotec_energy_monitor_power_meter-state] +# name: test_all_entities[aeotec_home_energy_meter_gen5][sensor.aeotec_energy_monitor_power-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power', - 'friendly_name': 'Aeotec Energy Monitor Power Meter', + 'friendly_name': 'Aeotec Energy Monitor Power', 'state_class': , 'unit_of_measurement': 'W', }), 'context': , - 'entity_id': 'sensor.aeotec_energy_monitor_power_meter', + 'entity_id': 'sensor.aeotec_energy_monitor_power', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '2859.743', }) # --- -# name: test_all_entities[aeotec_home_energy_meter_gen5][sensor.aeotec_energy_monitor_voltage_measurement-entry] +# name: test_all_entities[aeotec_home_energy_meter_gen5][sensor.aeotec_energy_monitor_voltage-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -118,8 +118,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.aeotec_energy_monitor_voltage_measurement', - 'has_entity_name': False, + 'entity_id': 'sensor.aeotec_energy_monitor_voltage', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -130,7 +130,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Aeotec Energy Monitor Voltage Measurement', + 'original_name': 'Voltage', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -139,22 +139,22 @@ 'unit_of_measurement': None, }) # --- -# name: test_all_entities[aeotec_home_energy_meter_gen5][sensor.aeotec_energy_monitor_voltage_measurement-state] +# name: test_all_entities[aeotec_home_energy_meter_gen5][sensor.aeotec_energy_monitor_voltage-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'voltage', - 'friendly_name': 'Aeotec Energy Monitor Voltage Measurement', + 'friendly_name': 'Aeotec Energy Monitor Voltage', 'state_class': , }), 'context': , - 'entity_id': 'sensor.aeotec_energy_monitor_voltage_measurement', + 'entity_id': 'sensor.aeotec_energy_monitor_voltage', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'unknown', }) # --- -# name: test_all_entities[base_electric_meter][sensor.aeon_energy_monitor_energy_meter-entry] +# name: test_all_entities[base_electric_meter][sensor.aeon_energy_monitor_energy-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -169,8 +169,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.aeon_energy_monitor_energy_meter', - 'has_entity_name': False, + 'entity_id': 'sensor.aeon_energy_monitor_energy', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -181,7 +181,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Aeon Energy Monitor Energy Meter', + 'original_name': 'Energy', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -190,23 +190,23 @@ 'unit_of_measurement': 'kWh', }) # --- -# name: test_all_entities[base_electric_meter][sensor.aeon_energy_monitor_energy_meter-state] +# name: test_all_entities[base_electric_meter][sensor.aeon_energy_monitor_energy-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', - 'friendly_name': 'Aeon Energy Monitor Energy Meter', + 'friendly_name': 'Aeon Energy Monitor Energy', 'state_class': , 'unit_of_measurement': 'kWh', }), 'context': , - 'entity_id': 'sensor.aeon_energy_monitor_energy_meter', + 'entity_id': 'sensor.aeon_energy_monitor_energy', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '1930.362', }) # --- -# name: test_all_entities[base_electric_meter][sensor.aeon_energy_monitor_power_meter-entry] +# name: test_all_entities[base_electric_meter][sensor.aeon_energy_monitor_power-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -221,8 +221,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.aeon_energy_monitor_power_meter', - 'has_entity_name': False, + 'entity_id': 'sensor.aeon_energy_monitor_power', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -233,7 +233,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Aeon Energy Monitor Power Meter', + 'original_name': 'Power', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -242,16 +242,16 @@ 'unit_of_measurement': 'W', }) # --- -# name: test_all_entities[base_electric_meter][sensor.aeon_energy_monitor_power_meter-state] +# name: test_all_entities[base_electric_meter][sensor.aeon_energy_monitor_power-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power', - 'friendly_name': 'Aeon Energy Monitor Power Meter', + 'friendly_name': 'Aeon Energy Monitor Power', 'state_class': , 'unit_of_measurement': 'W', }), 'context': , - 'entity_id': 'sensor.aeon_energy_monitor_power_meter', + 'entity_id': 'sensor.aeon_energy_monitor_power', 'last_changed': , 'last_reported': , 'last_updated': , @@ -272,7 +272,7 @@ 'domain': 'sensor', 'entity_category': None, 'entity_id': 'sensor.2nd_floor_hallway_alarm', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -283,11 +283,11 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': '2nd Floor Hallway Alarm', + 'original_name': 'Alarm', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'alarm', 'unique_id': '10e06a70-ee7d-4832-85e9-a0a06a7a05bd.alarm', 'unit_of_measurement': None, }) @@ -319,7 +319,7 @@ 'domain': 'sensor', 'entity_category': , 'entity_id': 'sensor.2nd_floor_hallway_battery', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -330,7 +330,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': '2nd Floor Hallway Battery', + 'original_name': 'Battery', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -354,7 +354,7 @@ 'state': '100', }) # --- -# name: test_all_entities[centralite][sensor.dimmer_debian_power_meter-entry] +# name: test_all_entities[centralite][sensor.dimmer_debian_power-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -369,8 +369,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.dimmer_debian_power_meter', - 'has_entity_name': False, + 'entity_id': 'sensor.dimmer_debian_power', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -381,7 +381,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Dimmer Debian Power Meter', + 'original_name': 'Power', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -390,16 +390,16 @@ 'unit_of_measurement': 'W', }) # --- -# name: test_all_entities[centralite][sensor.dimmer_debian_power_meter-state] +# name: test_all_entities[centralite][sensor.dimmer_debian_power-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power', - 'friendly_name': 'Dimmer Debian Power Meter', + 'friendly_name': 'Dimmer Debian Power', 'state_class': , 'unit_of_measurement': 'W', }), 'context': , - 'entity_id': 'sensor.dimmer_debian_power_meter', + 'entity_id': 'sensor.dimmer_debian_power', 'last_changed': , 'last_reported': , 'last_updated': , @@ -420,7 +420,7 @@ 'domain': 'sensor', 'entity_category': , 'entity_id': 'sensor.front_door_open_closed_sensor_battery', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -431,7 +431,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': '.Front Door Open/Closed Sensor Battery', + 'original_name': 'Battery', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -455,7 +455,7 @@ 'state': '100', }) # --- -# name: test_all_entities[contact_sensor][sensor.front_door_open_closed_sensor_temperature_measurement-entry] +# name: test_all_entities[contact_sensor][sensor.front_door_open_closed_sensor_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -470,8 +470,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.front_door_open_closed_sensor_temperature_measurement', - 'has_entity_name': False, + 'entity_id': 'sensor.front_door_open_closed_sensor_temperature', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -482,7 +482,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': '.Front Door Open/Closed Sensor Temperature Measurement', + 'original_name': 'Temperature', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -491,16 +491,16 @@ 'unit_of_measurement': , }) # --- -# name: test_all_entities[contact_sensor][sensor.front_door_open_closed_sensor_temperature_measurement-state] +# name: test_all_entities[contact_sensor][sensor.front_door_open_closed_sensor_temperature-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', - 'friendly_name': '.Front Door Open/Closed Sensor Temperature Measurement', + 'friendly_name': '.Front Door Open/Closed Sensor Temperature', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.front_door_open_closed_sensor_temperature_measurement', + 'entity_id': 'sensor.front_door_open_closed_sensor_temperature', 'last_changed': , 'last_reported': , 'last_updated': , @@ -523,7 +523,7 @@ 'domain': 'sensor', 'entity_category': None, 'entity_id': 'sensor.ac_office_granit_air_quality', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -534,11 +534,11 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'AC Office Granit Air Quality', + 'original_name': 'Air quality', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'air_quality', 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d.airQuality', 'unit_of_measurement': 'CAQI', }) @@ -546,7 +546,7 @@ # name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_air_quality-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'AC Office Granit Air Quality', + 'friendly_name': 'AC Office Granit Air quality', 'state_class': , 'unit_of_measurement': 'CAQI', }), @@ -558,58 +558,6 @@ 'state': 'unknown', }) # --- -# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_deltaenergy-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.ac_office_granit_deltaenergy', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'AC Office Granit deltaEnergy', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d.deltaEnergy_meter', - 'unit_of_measurement': , - }) -# --- -# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_deltaenergy-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'energy', - 'friendly_name': 'AC Office Granit deltaEnergy', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.ac_office_granit_deltaenergy', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '0.4', - }) -# --- # name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_dust_level-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -626,7 +574,7 @@ 'domain': 'sensor', 'entity_category': None, 'entity_id': 'sensor.ac_office_granit_dust_level', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -637,11 +585,11 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'AC Office Granit Dust Level', + 'original_name': 'Dust level', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'dust_level', 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d.dustLevel', 'unit_of_measurement': 'μg/m^3', }) @@ -649,7 +597,7 @@ # name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_dust_level-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'AC Office Granit Dust Level', + 'friendly_name': 'AC Office Granit Dust level', 'state_class': , 'unit_of_measurement': 'μg/m^3', }), @@ -677,7 +625,7 @@ 'domain': 'sensor', 'entity_category': None, 'entity_id': 'sensor.ac_office_granit_energy', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -688,7 +636,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'AC Office Granit energy', + 'original_name': 'Energy', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -701,7 +649,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', - 'friendly_name': 'AC Office Granit energy', + 'friendly_name': 'AC Office Granit Energy', 'state_class': , 'unit_of_measurement': , }), @@ -713,7 +661,7 @@ 'state': '2247.3', }) # --- -# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_energysaved-entry] +# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_energy_difference-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -728,8 +676,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.ac_office_granit_energysaved', - 'has_entity_name': False, + 'entity_id': 'sensor.ac_office_granit_energy_difference', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -740,25 +688,77 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'AC Office Granit energySaved', + 'original_name': 'Energy difference', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, - 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d.energySaved_meter', + 'translation_key': 'energy_difference', + 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d.deltaEnergy_meter', 'unit_of_measurement': , }) # --- -# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_energysaved-state] +# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_energy_difference-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', - 'friendly_name': 'AC Office Granit energySaved', + 'friendly_name': 'AC Office Granit Energy difference', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.ac_office_granit_energysaved', + 'entity_id': 'sensor.ac_office_granit_energy_difference', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.4', + }) +# --- +# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_energy_saved-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ac_office_granit_energy_saved', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy saved', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_saved', + 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d.energySaved_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_energy_saved-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'AC Office Granit Energy saved', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ac_office_granit_energy_saved', 'last_changed': , 'last_reported': , 'last_updated': , @@ -781,7 +781,7 @@ 'domain': 'sensor', 'entity_category': None, 'entity_id': 'sensor.ac_office_granit_fine_dust_level', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -792,11 +792,11 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'AC Office Granit Fine Dust Level', + 'original_name': 'Fine dust level', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'fine_dust_level', 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d.fineDustLevel', 'unit_of_measurement': 'μg/m^3', }) @@ -804,7 +804,7 @@ # name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_fine_dust_level-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'AC Office Granit Fine Dust Level', + 'friendly_name': 'AC Office Granit Fine dust level', 'state_class': , 'unit_of_measurement': 'μg/m^3', }), @@ -816,6 +816,58 @@ 'state': 'unknown', }) # --- +# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ac_office_granit_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d.humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'AC Office Granit Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.ac_office_granit_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '60', + }) +# --- # name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_power-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -832,7 +884,7 @@ 'domain': 'sensor', 'entity_category': None, 'entity_id': 'sensor.ac_office_granit_power', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -843,7 +895,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'AC Office Granit power', + 'original_name': 'Power', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -856,7 +908,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power', - 'friendly_name': 'AC Office Granit power', + 'friendly_name': 'AC Office Granit Power', 'power_consumption_end': '2025-02-09T16:15:33Z', 'power_consumption_start': '2025-02-09T15:45:29Z', 'state_class': , @@ -870,7 +922,7 @@ 'state': '0', }) # --- -# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_powerenergy-entry] +# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_power_energy-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -885,8 +937,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.ac_office_granit_powerenergy', - 'has_entity_name': False, + 'entity_id': 'sensor.ac_office_granit_power_energy', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -897,32 +949,32 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'AC Office Granit powerEnergy', + 'original_name': 'Power energy', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'power_energy', 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d.powerEnergy_meter', 'unit_of_measurement': , }) # --- -# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_powerenergy-state] +# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_power_energy-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', - 'friendly_name': 'AC Office Granit powerEnergy', + 'friendly_name': 'AC Office Granit Power energy', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.ac_office_granit_powerenergy', + 'entity_id': 'sensor.ac_office_granit_power_energy', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0.0', }) # --- -# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_relative_humidity_measurement-entry] +# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -937,60 +989,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.ac_office_granit_relative_humidity_measurement', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'AC Office Granit Relative Humidity Measurement', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d.humidity', - 'unit_of_measurement': '%', - }) -# --- -# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_relative_humidity_measurement-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'humidity', - 'friendly_name': 'AC Office Granit Relative Humidity Measurement', - 'state_class': , - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.ac_office_granit_relative_humidity_measurement', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '60', - }) -# --- -# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_temperature_measurement-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.ac_office_granit_temperature_measurement', - 'has_entity_name': False, + 'entity_id': 'sensor.ac_office_granit_temperature', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -1001,7 +1001,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'AC Office Granit Temperature Measurement', + 'original_name': 'Temperature', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -1010,16 +1010,16 @@ 'unit_of_measurement': , }) # --- -# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_temperature_measurement-state] +# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_temperature-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', - 'friendly_name': 'AC Office Granit Temperature Measurement', + 'friendly_name': 'AC Office Granit Temperature', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.ac_office_granit_temperature_measurement', + 'entity_id': 'sensor.ac_office_granit_temperature', 'last_changed': , 'last_reported': , 'last_updated': , @@ -1040,7 +1040,7 @@ 'domain': 'sensor', 'entity_category': None, 'entity_id': 'sensor.ac_office_granit_volume', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -1051,11 +1051,11 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'AC Office Granit Volume', + 'original_name': 'Volume', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'audio_volume', 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d.volume', 'unit_of_measurement': '%', }) @@ -1090,7 +1090,7 @@ 'domain': 'sensor', 'entity_category': None, 'entity_id': 'sensor.aire_dormitorio_principal_air_quality', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -1101,11 +1101,11 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Aire Dormitorio Principal Air Quality', + 'original_name': 'Air quality', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'air_quality', 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e.airQuality', 'unit_of_measurement': 'CAQI', }) @@ -1113,7 +1113,7 @@ # name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_air_quality-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Aire Dormitorio Principal Air Quality', + 'friendly_name': 'Aire Dormitorio Principal Air quality', 'state_class': , 'unit_of_measurement': 'CAQI', }), @@ -1125,58 +1125,6 @@ 'state': 'unknown', }) # --- -# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_deltaenergy-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.aire_dormitorio_principal_deltaenergy', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Aire Dormitorio Principal deltaEnergy', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e.deltaEnergy_meter', - 'unit_of_measurement': , - }) -# --- -# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_deltaenergy-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'energy', - 'friendly_name': 'Aire Dormitorio Principal deltaEnergy', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.aire_dormitorio_principal_deltaenergy', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '0.0', - }) -# --- # name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_dust_level-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1193,7 +1141,7 @@ 'domain': 'sensor', 'entity_category': None, 'entity_id': 'sensor.aire_dormitorio_principal_dust_level', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -1204,11 +1152,11 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Aire Dormitorio Principal Dust Level', + 'original_name': 'Dust level', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'dust_level', 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e.dustLevel', 'unit_of_measurement': None, }) @@ -1216,7 +1164,7 @@ # name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_dust_level-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Aire Dormitorio Principal Dust Level', + 'friendly_name': 'Aire Dormitorio Principal Dust level', 'state_class': , }), 'context': , @@ -1243,7 +1191,7 @@ 'domain': 'sensor', 'entity_category': None, 'entity_id': 'sensor.aire_dormitorio_principal_energy', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -1254,7 +1202,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Aire Dormitorio Principal energy', + 'original_name': 'Energy', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -1267,7 +1215,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', - 'friendly_name': 'Aire Dormitorio Principal energy', + 'friendly_name': 'Aire Dormitorio Principal Energy', 'state_class': , 'unit_of_measurement': , }), @@ -1279,7 +1227,7 @@ 'state': '13.836', }) # --- -# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_energysaved-entry] +# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_energy_difference-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -1294,8 +1242,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.aire_dormitorio_principal_energysaved', - 'has_entity_name': False, + 'entity_id': 'sensor.aire_dormitorio_principal_energy_difference', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -1306,25 +1254,77 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Aire Dormitorio Principal energySaved', + 'original_name': 'Energy difference', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, - 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e.energySaved_meter', + 'translation_key': 'energy_difference', + 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e.deltaEnergy_meter', 'unit_of_measurement': , }) # --- -# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_energysaved-state] +# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_energy_difference-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', - 'friendly_name': 'Aire Dormitorio Principal energySaved', + 'friendly_name': 'Aire Dormitorio Principal Energy difference', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.aire_dormitorio_principal_energysaved', + 'entity_id': 'sensor.aire_dormitorio_principal_energy_difference', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_energy_saved-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.aire_dormitorio_principal_energy_saved', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy saved', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_saved', + 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e.energySaved_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_energy_saved-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Aire Dormitorio Principal Energy saved', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.aire_dormitorio_principal_energy_saved', 'last_changed': , 'last_reported': , 'last_updated': , @@ -1347,7 +1347,7 @@ 'domain': 'sensor', 'entity_category': None, 'entity_id': 'sensor.aire_dormitorio_principal_fine_dust_level', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -1358,11 +1358,11 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Aire Dormitorio Principal Fine Dust Level', + 'original_name': 'Fine dust level', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'fine_dust_level', 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e.fineDustLevel', 'unit_of_measurement': None, }) @@ -1370,7 +1370,7 @@ # name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_fine_dust_level-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Aire Dormitorio Principal Fine Dust Level', + 'friendly_name': 'Aire Dormitorio Principal Fine dust level', 'state_class': , }), 'context': , @@ -1381,6 +1381,58 @@ 'state': 'unknown', }) # --- +# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.aire_dormitorio_principal_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e.humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Aire Dormitorio Principal Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.aire_dormitorio_principal_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '42', + }) +# --- # name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_odor_sensor-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1395,7 +1447,7 @@ 'domain': 'sensor', 'entity_category': None, 'entity_id': 'sensor.aire_dormitorio_principal_odor_sensor', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -1406,11 +1458,11 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Aire Dormitorio Principal Odor Sensor', + 'original_name': 'Odor sensor', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'odor_sensor', 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e.odorLevel', 'unit_of_measurement': None, }) @@ -1418,7 +1470,7 @@ # name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_odor_sensor-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Aire Dormitorio Principal Odor Sensor', + 'friendly_name': 'Aire Dormitorio Principal Odor sensor', }), 'context': , 'entity_id': 'sensor.aire_dormitorio_principal_odor_sensor', @@ -1444,7 +1496,7 @@ 'domain': 'sensor', 'entity_category': None, 'entity_id': 'sensor.aire_dormitorio_principal_power', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -1455,7 +1507,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Aire Dormitorio Principal power', + 'original_name': 'Power', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -1468,7 +1520,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power', - 'friendly_name': 'Aire Dormitorio Principal power', + 'friendly_name': 'Aire Dormitorio Principal Power', 'power_consumption_end': '2025-02-09T17:02:44Z', 'power_consumption_start': '2025-02-09T16:08:15Z', 'state_class': , @@ -1482,7 +1534,7 @@ 'state': '0', }) # --- -# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_powerenergy-entry] +# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_power_energy-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -1497,8 +1549,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.aire_dormitorio_principal_powerenergy', - 'has_entity_name': False, + 'entity_id': 'sensor.aire_dormitorio_principal_power_energy', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -1509,32 +1561,32 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Aire Dormitorio Principal powerEnergy', + 'original_name': 'Power energy', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'power_energy', 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e.powerEnergy_meter', 'unit_of_measurement': , }) # --- -# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_powerenergy-state] +# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_power_energy-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', - 'friendly_name': 'Aire Dormitorio Principal powerEnergy', + 'friendly_name': 'Aire Dormitorio Principal Power energy', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.aire_dormitorio_principal_powerenergy', + 'entity_id': 'sensor.aire_dormitorio_principal_power_energy', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0.0', }) # --- -# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_relative_humidity_measurement-entry] +# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -1549,60 +1601,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.aire_dormitorio_principal_relative_humidity_measurement', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Aire Dormitorio Principal Relative Humidity Measurement', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e.humidity', - 'unit_of_measurement': '%', - }) -# --- -# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_relative_humidity_measurement-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'humidity', - 'friendly_name': 'Aire Dormitorio Principal Relative Humidity Measurement', - 'state_class': , - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.aire_dormitorio_principal_relative_humidity_measurement', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '42', - }) -# --- -# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_temperature_measurement-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.aire_dormitorio_principal_temperature_measurement', - 'has_entity_name': False, + 'entity_id': 'sensor.aire_dormitorio_principal_temperature', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -1613,7 +1613,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Aire Dormitorio Principal Temperature Measurement', + 'original_name': 'Temperature', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -1622,16 +1622,16 @@ 'unit_of_measurement': , }) # --- -# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_temperature_measurement-state] +# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_temperature-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', - 'friendly_name': 'Aire Dormitorio Principal Temperature Measurement', + 'friendly_name': 'Aire Dormitorio Principal Temperature', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.aire_dormitorio_principal_temperature_measurement', + 'entity_id': 'sensor.aire_dormitorio_principal_temperature', 'last_changed': , 'last_reported': , 'last_updated': , @@ -1652,7 +1652,7 @@ 'domain': 'sensor', 'entity_category': None, 'entity_id': 'sensor.aire_dormitorio_principal_volume', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -1663,11 +1663,11 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Aire Dormitorio Principal Volume', + 'original_name': 'Volume', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'audio_volume', 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e.volume', 'unit_of_measurement': '%', }) @@ -1686,7 +1686,7 @@ 'state': '0', }) # --- -# name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_oven_completion_time-entry] +# name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_completion_time-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -1699,8 +1699,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.microwave_oven_completion_time', - 'has_entity_name': False, + 'entity_id': 'sensor.microwave_completion_time', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -1711,29 +1711,29 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Microwave Oven Completion Time', + 'original_name': 'Completion time', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'completion_time', 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a.completionTime', 'unit_of_measurement': None, }) # --- -# name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_oven_completion_time-state] +# name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_completion_time-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Microwave Oven Completion Time', + 'friendly_name': 'Microwave Completion time', }), 'context': , - 'entity_id': 'sensor.microwave_oven_completion_time', + 'entity_id': 'sensor.microwave_completion_time', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '2025-02-08T21:13:36.184Z', }) # --- -# name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_oven_job_state-entry] +# name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_job_state-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -1746,8 +1746,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.microwave_oven_job_state', - 'has_entity_name': False, + 'entity_id': 'sensor.microwave_job_state', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -1758,29 +1758,29 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Microwave Oven Job State', + 'original_name': 'Job state', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'oven_job_state', 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a.ovenJobState', 'unit_of_measurement': None, }) # --- -# name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_oven_job_state-state] +# name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_job_state-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Microwave Oven Job State', + 'friendly_name': 'Microwave Job state', }), 'context': , - 'entity_id': 'sensor.microwave_oven_job_state', + 'entity_id': 'sensor.microwave_job_state', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'ready', }) # --- -# name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_oven_machine_state-entry] +# name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_machine_state-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -1793,8 +1793,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.microwave_oven_machine_state', - 'has_entity_name': False, + 'entity_id': 'sensor.microwave_machine_state', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -1805,22 +1805,22 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Microwave Oven Machine State', + 'original_name': 'Machine state', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'oven_machine_state', 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a.machineState', 'unit_of_measurement': None, }) # --- -# name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_oven_machine_state-state] +# name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_machine_state-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Microwave Oven Machine State', + 'friendly_name': 'Microwave Machine state', }), 'context': , - 'entity_id': 'sensor.microwave_oven_machine_state', + 'entity_id': 'sensor.microwave_machine_state', 'last_changed': , 'last_reported': , 'last_updated': , @@ -1841,7 +1841,7 @@ 'domain': 'sensor', 'entity_category': , 'entity_id': 'sensor.microwave_oven_mode', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -1852,11 +1852,11 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Microwave Oven Mode', + 'original_name': 'Oven mode', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'oven_mode', 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a.ovenMode', 'unit_of_measurement': None, }) @@ -1864,7 +1864,7 @@ # name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_oven_mode-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Microwave Oven Mode', + 'friendly_name': 'Microwave Oven mode', }), 'context': , 'entity_id': 'sensor.microwave_oven_mode', @@ -1874,7 +1874,7 @@ 'state': 'Others', }) # --- -# name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_oven_set_point-entry] +# name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_set_point-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -1887,8 +1887,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.microwave_oven_set_point', - 'has_entity_name': False, + 'entity_id': 'sensor.microwave_set_point', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -1899,29 +1899,29 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Microwave Oven Set Point', + 'original_name': 'Set point', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'oven_setpoint', 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a.ovenSetpoint', 'unit_of_measurement': None, }) # --- -# name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_oven_set_point-state] +# name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_set_point-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Microwave Oven Set Point', + 'friendly_name': 'Microwave Set point', }), 'context': , - 'entity_id': 'sensor.microwave_oven_set_point', + 'entity_id': 'sensor.microwave_set_point', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0', }) # --- -# name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_temperature_measurement-entry] +# name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -1936,8 +1936,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.microwave_temperature_measurement', - 'has_entity_name': False, + 'entity_id': 'sensor.microwave_temperature', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -1948,7 +1948,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Microwave Temperature Measurement', + 'original_name': 'Temperature', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -1957,30 +1957,28 @@ 'unit_of_measurement': , }) # --- -# name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_temperature_measurement-state] +# name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_temperature-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', - 'friendly_name': 'Microwave Temperature Measurement', + 'friendly_name': 'Microwave Temperature', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.microwave_temperature_measurement', + 'entity_id': 'sensor.microwave_temperature', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '-17', }) # --- -# name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_deltaenergy-entry] +# name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_cooling_set_point-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), + 'capabilities': None, 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -1988,8 +1986,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.refrigerator_deltaenergy', - 'has_entity_name': False, + 'entity_id': 'sensor.refrigerator_cooling_set_point', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -1998,31 +1996,29 @@ 'name': None, 'options': dict({ }), - 'original_device_class': , + 'original_device_class': , 'original_icon': None, - 'original_name': 'Refrigerator deltaEnergy', + 'original_name': 'Cooling set point', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, - 'unique_id': '7db87911-7dce-1cf2-7119-b953432a2f09.deltaEnergy_meter', - 'unit_of_measurement': , + 'translation_key': 'thermostat_cooling_setpoint', + 'unique_id': '7db87911-7dce-1cf2-7119-b953432a2f09.coolingSetpoint', + 'unit_of_measurement': None, }) # --- -# name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_deltaenergy-state] +# name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_cooling_set_point-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'energy', - 'friendly_name': 'Refrigerator deltaEnergy', - 'state_class': , - 'unit_of_measurement': , + 'device_class': 'temperature', + 'friendly_name': 'Refrigerator Cooling set point', }), 'context': , - 'entity_id': 'sensor.refrigerator_deltaenergy', + 'entity_id': 'sensor.refrigerator_cooling_set_point', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.007', + 'state': 'unknown', }) # --- # name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_energy-entry] @@ -2041,7 +2037,7 @@ 'domain': 'sensor', 'entity_category': None, 'entity_id': 'sensor.refrigerator_energy', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -2052,7 +2048,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Refrigerator energy', + 'original_name': 'Energy', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -2065,7 +2061,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', - 'friendly_name': 'Refrigerator energy', + 'friendly_name': 'Refrigerator Energy', 'state_class': , 'unit_of_measurement': , }), @@ -2077,7 +2073,7 @@ 'state': '1568.087', }) # --- -# name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_energysaved-entry] +# name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_energy_difference-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -2092,8 +2088,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.refrigerator_energysaved', - 'has_entity_name': False, + 'entity_id': 'sensor.refrigerator_energy_difference', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -2104,25 +2100,77 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Refrigerator energySaved', + 'original_name': 'Energy difference', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, - 'unique_id': '7db87911-7dce-1cf2-7119-b953432a2f09.energySaved_meter', + 'translation_key': 'energy_difference', + 'unique_id': '7db87911-7dce-1cf2-7119-b953432a2f09.deltaEnergy_meter', 'unit_of_measurement': , }) # --- -# name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_energysaved-state] +# name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_energy_difference-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', - 'friendly_name': 'Refrigerator energySaved', + 'friendly_name': 'Refrigerator Energy difference', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.refrigerator_energysaved', + 'entity_id': 'sensor.refrigerator_energy_difference', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.007', + }) +# --- +# name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_energy_saved-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.refrigerator_energy_saved', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy saved', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_saved', + 'unique_id': '7db87911-7dce-1cf2-7119-b953432a2f09.energySaved_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_energy_saved-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Refrigerator Energy saved', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.refrigerator_energy_saved', 'last_changed': , 'last_reported': , 'last_updated': , @@ -2145,7 +2193,7 @@ 'domain': 'sensor', 'entity_category': None, 'entity_id': 'sensor.refrigerator_power', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -2156,7 +2204,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Refrigerator power', + 'original_name': 'Power', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -2169,7 +2217,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power', - 'friendly_name': 'Refrigerator power', + 'friendly_name': 'Refrigerator Power', 'power_consumption_end': '2025-02-09T17:49:00Z', 'power_consumption_start': '2025-02-09T17:38:01Z', 'state_class': , @@ -2183,7 +2231,7 @@ 'state': '6', }) # --- -# name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_powerenergy-entry] +# name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_power_energy-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -2198,8 +2246,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.refrigerator_powerenergy', - 'has_entity_name': False, + 'entity_id': 'sensor.refrigerator_power_energy', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -2210,32 +2258,32 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Refrigerator powerEnergy', + 'original_name': 'Power energy', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'power_energy', 'unique_id': '7db87911-7dce-1cf2-7119-b953432a2f09.powerEnergy_meter', 'unit_of_measurement': , }) # --- -# name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_powerenergy-state] +# name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_power_energy-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', - 'friendly_name': 'Refrigerator powerEnergy', + 'friendly_name': 'Refrigerator Power energy', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.refrigerator_powerenergy', + 'entity_id': 'sensor.refrigerator_power_energy', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0.0135559777781698', }) # --- -# name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_temperature_measurement-entry] +# name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -2250,8 +2298,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.refrigerator_temperature_measurement', - 'has_entity_name': False, + 'entity_id': 'sensor.refrigerator_temperature', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -2262,7 +2310,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Refrigerator Temperature Measurement', + 'original_name': 'Temperature', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -2271,63 +2319,15 @@ 'unit_of_measurement': None, }) # --- -# name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_temperature_measurement-state] +# name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_temperature-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', - 'friendly_name': 'Refrigerator Temperature Measurement', + 'friendly_name': 'Refrigerator Temperature', 'state_class': , }), 'context': , - 'entity_id': 'sensor.refrigerator_temperature_measurement', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_thermostat_cooling_setpoint-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.refrigerator_thermostat_cooling_setpoint', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Refrigerator Thermostat Cooling Setpoint', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '7db87911-7dce-1cf2-7119-b953432a2f09.coolingSetpoint', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_thermostat_cooling_setpoint-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', - 'friendly_name': 'Refrigerator Thermostat Cooling Setpoint', - }), - 'context': , - 'entity_id': 'sensor.refrigerator_thermostat_cooling_setpoint', + 'entity_id': 'sensor.refrigerator_temperature', 'last_changed': , 'last_reported': , 'last_updated': , @@ -2348,7 +2348,7 @@ 'domain': 'sensor', 'entity_category': , 'entity_id': 'sensor.robot_vacuum_battery', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -2359,7 +2359,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Robot vacuum Battery', + 'original_name': 'Battery', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -2383,7 +2383,7 @@ 'state': '100', }) # --- -# name: test_all_entities[da_rvc_normal_000001][sensor.robot_vacuum_robot_cleaner_cleaning_mode-entry] +# name: test_all_entities[da_rvc_normal_000001][sensor.robot_vacuum_cleaning_mode-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -2396,8 +2396,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.robot_vacuum_robot_cleaner_cleaning_mode', - 'has_entity_name': False, + 'entity_id': 'sensor.robot_vacuum_cleaning_mode', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -2408,29 +2408,29 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Robot vacuum Robot Cleaner Cleaning Mode', + 'original_name': 'Cleaning mode', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'robot_cleaner_cleaning_mode', 'unique_id': '3442dfc6-17c0-a65f-dae0-4c6e01786f44.robotCleanerCleaningMode', 'unit_of_measurement': None, }) # --- -# name: test_all_entities[da_rvc_normal_000001][sensor.robot_vacuum_robot_cleaner_cleaning_mode-state] +# name: test_all_entities[da_rvc_normal_000001][sensor.robot_vacuum_cleaning_mode-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Robot vacuum Robot Cleaner Cleaning Mode', + 'friendly_name': 'Robot vacuum Cleaning mode', }), 'context': , - 'entity_id': 'sensor.robot_vacuum_robot_cleaner_cleaning_mode', + 'entity_id': 'sensor.robot_vacuum_cleaning_mode', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'stop', }) # --- -# name: test_all_entities[da_rvc_normal_000001][sensor.robot_vacuum_robot_cleaner_movement-entry] +# name: test_all_entities[da_rvc_normal_000001][sensor.robot_vacuum_movement-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -2443,8 +2443,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.robot_vacuum_robot_cleaner_movement', - 'has_entity_name': False, + 'entity_id': 'sensor.robot_vacuum_movement', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -2455,29 +2455,29 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Robot vacuum Robot Cleaner Movement', + 'original_name': 'Movement', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'robot_cleaner_movement', 'unique_id': '3442dfc6-17c0-a65f-dae0-4c6e01786f44.robotCleanerMovement', 'unit_of_measurement': None, }) # --- -# name: test_all_entities[da_rvc_normal_000001][sensor.robot_vacuum_robot_cleaner_movement-state] +# name: test_all_entities[da_rvc_normal_000001][sensor.robot_vacuum_movement-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Robot vacuum Robot Cleaner Movement', + 'friendly_name': 'Robot vacuum Movement', }), 'context': , - 'entity_id': 'sensor.robot_vacuum_robot_cleaner_movement', + 'entity_id': 'sensor.robot_vacuum_movement', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'idle', }) # --- -# name: test_all_entities[da_rvc_normal_000001][sensor.robot_vacuum_robot_cleaner_turbo_mode-entry] +# name: test_all_entities[da_rvc_normal_000001][sensor.robot_vacuum_turbo_mode-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -2490,8 +2490,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.robot_vacuum_robot_cleaner_turbo_mode', - 'has_entity_name': False, + 'entity_id': 'sensor.robot_vacuum_turbo_mode', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -2502,81 +2502,29 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Robot vacuum Robot Cleaner Turbo Mode', + 'original_name': 'Turbo mode', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'robot_cleaner_turbo_mode', 'unique_id': '3442dfc6-17c0-a65f-dae0-4c6e01786f44.robotCleanerTurboMode', 'unit_of_measurement': None, }) # --- -# name: test_all_entities[da_rvc_normal_000001][sensor.robot_vacuum_robot_cleaner_turbo_mode-state] +# name: test_all_entities[da_rvc_normal_000001][sensor.robot_vacuum_turbo_mode-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Robot vacuum Robot Cleaner Turbo Mode', + 'friendly_name': 'Robot vacuum Turbo mode', }), 'context': , - 'entity_id': 'sensor.robot_vacuum_robot_cleaner_turbo_mode', + 'entity_id': 'sensor.robot_vacuum_turbo_mode', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'off', }) # --- -# name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_deltaenergy-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.dishwasher_deltaenergy', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Dishwasher deltaEnergy', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676.deltaEnergy_meter', - 'unit_of_measurement': , - }) -# --- -# name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_deltaenergy-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'energy', - 'friendly_name': 'Dishwasher deltaEnergy', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.dishwasher_deltaenergy', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '0.0', - }) -# --- -# name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_dishwasher_completion_time-entry] +# name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_completion_time-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -2589,8 +2537,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.dishwasher_dishwasher_completion_time', - 'has_entity_name': False, + 'entity_id': 'sensor.dishwasher_completion_time', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -2601,123 +2549,29 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Dishwasher Dishwasher Completion Time', + 'original_name': 'Completion time', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'completion_time', 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676.completionTime', 'unit_of_measurement': None, }) # --- -# name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_dishwasher_completion_time-state] +# name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_completion_time-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'timestamp', - 'friendly_name': 'Dishwasher Dishwasher Completion Time', + 'friendly_name': 'Dishwasher Completion time', }), 'context': , - 'entity_id': 'sensor.dishwasher_dishwasher_completion_time', + 'entity_id': 'sensor.dishwasher_completion_time', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '2025-02-08T22:49:26+00:00', }) # --- -# name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_dishwasher_job_state-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.dishwasher_dishwasher_job_state', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Dishwasher Dishwasher Job State', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676.dishwasherJobState', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_dishwasher_job_state-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Dishwasher Dishwasher Job State', - }), - 'context': , - 'entity_id': 'sensor.dishwasher_dishwasher_job_state', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_dishwasher_machine_state-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.dishwasher_dishwasher_machine_state', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Dishwasher Dishwasher Machine State', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676.machineState', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_dishwasher_machine_state-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Dishwasher Dishwasher Machine State', - }), - 'context': , - 'entity_id': 'sensor.dishwasher_dishwasher_machine_state', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'stop', - }) -# --- # name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_energy-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2734,7 +2588,7 @@ 'domain': 'sensor', 'entity_category': None, 'entity_id': 'sensor.dishwasher_energy', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -2745,7 +2599,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Dishwasher energy', + 'original_name': 'Energy', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -2758,7 +2612,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', - 'friendly_name': 'Dishwasher energy', + 'friendly_name': 'Dishwasher Energy', 'state_class': , 'unit_of_measurement': , }), @@ -2770,7 +2624,7 @@ 'state': '101.6', }) # --- -# name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_energysaved-entry] +# name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_energy_difference-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -2785,8 +2639,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.dishwasher_energysaved', - 'has_entity_name': False, + 'entity_id': 'sensor.dishwasher_energy_difference', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -2797,31 +2651,177 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Dishwasher energySaved', + 'original_name': 'Energy difference', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676.energySaved_meter', + 'translation_key': 'energy_difference', + 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676.deltaEnergy_meter', 'unit_of_measurement': , }) # --- -# name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_energysaved-state] +# name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_energy_difference-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', - 'friendly_name': 'Dishwasher energySaved', + 'friendly_name': 'Dishwasher Energy difference', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.dishwasher_energysaved', + 'entity_id': 'sensor.dishwasher_energy_difference', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0.0', }) # --- +# name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_energy_saved-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.dishwasher_energy_saved', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy saved', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_saved', + 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676.energySaved_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_energy_saved-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Dishwasher Energy saved', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.dishwasher_energy_saved', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_job_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.dishwasher_job_state', + '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': 'Job state', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'dishwasher_job_state', + 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676.dishwasherJobState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_job_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dishwasher Job state', + }), + 'context': , + 'entity_id': 'sensor.dishwasher_job_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_machine_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.dishwasher_machine_state', + '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': 'Machine state', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'dishwasher_machine_state', + 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676.machineState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_machine_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dishwasher Machine state', + }), + 'context': , + 'entity_id': 'sensor.dishwasher_machine_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'stop', + }) +# --- # name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_power-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2838,7 +2838,7 @@ 'domain': 'sensor', 'entity_category': None, 'entity_id': 'sensor.dishwasher_power', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -2849,7 +2849,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Dishwasher power', + 'original_name': 'Power', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -2862,7 +2862,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power', - 'friendly_name': 'Dishwasher power', + 'friendly_name': 'Dishwasher Power', 'power_consumption_end': '2025-02-08T20:21:26Z', 'power_consumption_start': '2025-02-08T20:21:21Z', 'state_class': , @@ -2876,7 +2876,7 @@ 'state': '0', }) # --- -# name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_powerenergy-entry] +# name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_power_energy-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -2891,8 +2891,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.dishwasher_powerenergy', - 'has_entity_name': False, + 'entity_id': 'sensor.dishwasher_power_energy', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -2903,84 +2903,32 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Dishwasher powerEnergy', + 'original_name': 'Power energy', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'power_energy', 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676.powerEnergy_meter', 'unit_of_measurement': , }) # --- -# name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_powerenergy-state] +# name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_power_energy-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', - 'friendly_name': 'Dishwasher powerEnergy', + 'friendly_name': 'Dishwasher Power energy', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.dishwasher_powerenergy', + 'entity_id': 'sensor.dishwasher_power_energy', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0.0', }) # --- -# name: test_all_entities[da_wm_wd_000001][sensor.dryer_deltaenergy-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.dryer_deltaenergy', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Dryer deltaEnergy', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b.deltaEnergy_meter', - 'unit_of_measurement': , - }) -# --- -# name: test_all_entities[da_wm_wd_000001][sensor.dryer_deltaenergy-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'energy', - 'friendly_name': 'Dryer deltaEnergy', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.dryer_deltaenergy', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '0.0', - }) -# --- -# name: test_all_entities[da_wm_wd_000001][sensor.dryer_dryer_completion_time-entry] +# name: test_all_entities[da_wm_wd_000001][sensor.dryer_completion_time-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -2993,8 +2941,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.dryer_dryer_completion_time', - 'has_entity_name': False, + 'entity_id': 'sensor.dryer_completion_time', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -3005,123 +2953,29 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Dryer Dryer Completion Time', + 'original_name': 'Completion time', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'completion_time', 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b.completionTime', 'unit_of_measurement': None, }) # --- -# name: test_all_entities[da_wm_wd_000001][sensor.dryer_dryer_completion_time-state] +# name: test_all_entities[da_wm_wd_000001][sensor.dryer_completion_time-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'timestamp', - 'friendly_name': 'Dryer Dryer Completion Time', + 'friendly_name': 'Dryer Completion time', }), 'context': , - 'entity_id': 'sensor.dryer_dryer_completion_time', + 'entity_id': 'sensor.dryer_completion_time', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '2025-02-08T19:25:10+00:00', }) # --- -# name: test_all_entities[da_wm_wd_000001][sensor.dryer_dryer_job_state-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.dryer_dryer_job_state', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Dryer Dryer Job State', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b.dryerJobState', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[da_wm_wd_000001][sensor.dryer_dryer_job_state-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Dryer Dryer Job State', - }), - 'context': , - 'entity_id': 'sensor.dryer_dryer_job_state', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'none', - }) -# --- -# name: test_all_entities[da_wm_wd_000001][sensor.dryer_dryer_machine_state-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.dryer_dryer_machine_state', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Dryer Dryer Machine State', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b.machineState', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[da_wm_wd_000001][sensor.dryer_dryer_machine_state-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Dryer Dryer Machine State', - }), - 'context': , - 'entity_id': 'sensor.dryer_dryer_machine_state', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'stop', - }) -# --- # name: test_all_entities[da_wm_wd_000001][sensor.dryer_energy-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -3138,7 +2992,7 @@ 'domain': 'sensor', 'entity_category': None, 'entity_id': 'sensor.dryer_energy', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -3149,7 +3003,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Dryer energy', + 'original_name': 'Energy', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -3162,7 +3016,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', - 'friendly_name': 'Dryer energy', + 'friendly_name': 'Dryer Energy', 'state_class': , 'unit_of_measurement': , }), @@ -3174,7 +3028,7 @@ 'state': '4495.5', }) # --- -# name: test_all_entities[da_wm_wd_000001][sensor.dryer_energysaved-entry] +# name: test_all_entities[da_wm_wd_000001][sensor.dryer_energy_difference-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -3189,8 +3043,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.dryer_energysaved', - 'has_entity_name': False, + 'entity_id': 'sensor.dryer_energy_difference', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -3201,31 +3055,177 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Dryer energySaved', + 'original_name': 'Energy difference', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, - 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b.energySaved_meter', + 'translation_key': 'energy_difference', + 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b.deltaEnergy_meter', 'unit_of_measurement': , }) # --- -# name: test_all_entities[da_wm_wd_000001][sensor.dryer_energysaved-state] +# name: test_all_entities[da_wm_wd_000001][sensor.dryer_energy_difference-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', - 'friendly_name': 'Dryer energySaved', + 'friendly_name': 'Dryer Energy difference', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.dryer_energysaved', + 'entity_id': 'sensor.dryer_energy_difference', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0.0', }) # --- +# name: test_all_entities[da_wm_wd_000001][sensor.dryer_energy_saved-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.dryer_energy_saved', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy saved', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_saved', + 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b.energySaved_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_wm_wd_000001][sensor.dryer_energy_saved-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Dryer Energy saved', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.dryer_energy_saved', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_wm_wd_000001][sensor.dryer_job_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.dryer_job_state', + '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': 'Job state', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'dryer_job_state', + 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b.dryerJobState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wd_000001][sensor.dryer_job_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dryer Job state', + }), + 'context': , + 'entity_id': 'sensor.dryer_job_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'none', + }) +# --- +# name: test_all_entities[da_wm_wd_000001][sensor.dryer_machine_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.dryer_machine_state', + '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': 'Machine state', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'dryer_machine_state', + 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b.machineState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wd_000001][sensor.dryer_machine_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dryer Machine state', + }), + 'context': , + 'entity_id': 'sensor.dryer_machine_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'stop', + }) +# --- # name: test_all_entities[da_wm_wd_000001][sensor.dryer_power-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -3242,7 +3242,7 @@ 'domain': 'sensor', 'entity_category': None, 'entity_id': 'sensor.dryer_power', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -3253,7 +3253,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Dryer power', + 'original_name': 'Power', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -3266,7 +3266,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power', - 'friendly_name': 'Dryer power', + 'friendly_name': 'Dryer Power', 'power_consumption_end': '2025-02-08T18:10:11Z', 'power_consumption_start': '2025-02-07T04:00:19Z', 'state_class': , @@ -3280,7 +3280,7 @@ 'state': '0', }) # --- -# name: test_all_entities[da_wm_wd_000001][sensor.dryer_powerenergy-entry] +# name: test_all_entities[da_wm_wd_000001][sensor.dryer_power_energy-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -3295,8 +3295,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.dryer_powerenergy', - 'has_entity_name': False, + 'entity_id': 'sensor.dryer_power_energy', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -3307,39 +3307,37 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Dryer powerEnergy', + 'original_name': 'Power energy', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'power_energy', 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b.powerEnergy_meter', 'unit_of_measurement': , }) # --- -# name: test_all_entities[da_wm_wd_000001][sensor.dryer_powerenergy-state] +# name: test_all_entities[da_wm_wd_000001][sensor.dryer_power_energy-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', - 'friendly_name': 'Dryer powerEnergy', + 'friendly_name': 'Dryer Power energy', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.dryer_powerenergy', + 'entity_id': 'sensor.dryer_power_energy', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0.0', }) # --- -# name: test_all_entities[da_wm_wm_000001][sensor.washer_deltaenergy-entry] +# name: test_all_entities[da_wm_wm_000001][sensor.washer_completion_time-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), + 'capabilities': None, 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -3347,8 +3345,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.washer_deltaenergy', - 'has_entity_name': False, + 'entity_id': 'sensor.washer_completion_time', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -3357,31 +3355,29 @@ 'name': None, 'options': dict({ }), - 'original_device_class': , + 'original_device_class': , 'original_icon': None, - 'original_name': 'Washer deltaEnergy', + 'original_name': 'Completion time', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47.deltaEnergy_meter', - 'unit_of_measurement': , + 'translation_key': 'completion_time', + 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47.completionTime', + 'unit_of_measurement': None, }) # --- -# name: test_all_entities[da_wm_wm_000001][sensor.washer_deltaenergy-state] +# name: test_all_entities[da_wm_wm_000001][sensor.washer_completion_time-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'energy', - 'friendly_name': 'Washer deltaEnergy', - 'state_class': , - 'unit_of_measurement': , + 'device_class': 'timestamp', + 'friendly_name': 'Washer Completion time', }), 'context': , - 'entity_id': 'sensor.washer_deltaenergy', + 'entity_id': 'sensor.washer_completion_time', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.0', + 'state': '2025-02-07T03:54:45+00:00', }) # --- # name: test_all_entities[da_wm_wm_000001][sensor.washer_energy-entry] @@ -3400,7 +3396,7 @@ 'domain': 'sensor', 'entity_category': None, 'entity_id': 'sensor.washer_energy', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -3411,7 +3407,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Washer energy', + 'original_name': 'Energy', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -3424,7 +3420,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', - 'friendly_name': 'Washer energy', + 'friendly_name': 'Washer Energy', 'state_class': , 'unit_of_measurement': , }), @@ -3436,7 +3432,7 @@ 'state': '352.8', }) # --- -# name: test_all_entities[da_wm_wm_000001][sensor.washer_energysaved-entry] +# name: test_all_entities[da_wm_wm_000001][sensor.washer_energy_difference-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -3451,8 +3447,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.washer_energysaved', - 'has_entity_name': False, + 'entity_id': 'sensor.washer_energy_difference', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -3463,31 +3459,177 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Washer energySaved', + 'original_name': 'Energy difference', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47.energySaved_meter', + 'translation_key': 'energy_difference', + 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47.deltaEnergy_meter', 'unit_of_measurement': , }) # --- -# name: test_all_entities[da_wm_wm_000001][sensor.washer_energysaved-state] +# name: test_all_entities[da_wm_wm_000001][sensor.washer_energy_difference-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', - 'friendly_name': 'Washer energySaved', + 'friendly_name': 'Washer Energy difference', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.washer_energysaved', + 'entity_id': 'sensor.washer_energy_difference', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0.0', }) # --- +# name: test_all_entities[da_wm_wm_000001][sensor.washer_energy_saved-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.washer_energy_saved', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy saved', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_saved', + 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47.energySaved_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_wm_wm_000001][sensor.washer_energy_saved-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Washer Energy saved', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.washer_energy_saved', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_wm_wm_000001][sensor.washer_job_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.washer_job_state', + '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': 'Job state', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'washer_job_state', + 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47.washerJobState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wm_000001][sensor.washer_job_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Washer Job state', + }), + 'context': , + 'entity_id': 'sensor.washer_job_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'none', + }) +# --- +# name: test_all_entities[da_wm_wm_000001][sensor.washer_machine_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.washer_machine_state', + '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': 'Machine state', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'washer_machine_state', + 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47.machineState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wm_000001][sensor.washer_machine_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Washer Machine state', + }), + 'context': , + 'entity_id': 'sensor.washer_machine_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'stop', + }) +# --- # name: test_all_entities[da_wm_wm_000001][sensor.washer_power-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -3504,7 +3646,7 @@ 'domain': 'sensor', 'entity_category': None, 'entity_id': 'sensor.washer_power', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -3515,7 +3657,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Washer power', + 'original_name': 'Power', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -3528,7 +3670,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power', - 'friendly_name': 'Washer power', + 'friendly_name': 'Washer Power', 'power_consumption_end': '2025-02-07T03:09:45Z', 'power_consumption_start': '2025-02-07T03:09:24Z', 'state_class': , @@ -3542,7 +3684,7 @@ 'state': '0', }) # --- -# name: test_all_entities[da_wm_wm_000001][sensor.washer_powerenergy-entry] +# name: test_all_entities[da_wm_wm_000001][sensor.washer_power_energy-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -3557,8 +3699,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.washer_powerenergy', - 'has_entity_name': False, + 'entity_id': 'sensor.washer_power_energy', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -3569,174 +3711,32 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Washer powerEnergy', + 'original_name': 'Power energy', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'power_energy', 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47.powerEnergy_meter', 'unit_of_measurement': , }) # --- -# name: test_all_entities[da_wm_wm_000001][sensor.washer_powerenergy-state] +# name: test_all_entities[da_wm_wm_000001][sensor.washer_power_energy-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', - 'friendly_name': 'Washer powerEnergy', + 'friendly_name': 'Washer Power energy', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.washer_powerenergy', + 'entity_id': 'sensor.washer_power_energy', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0.0', }) # --- -# name: test_all_entities[da_wm_wm_000001][sensor.washer_washer_completion_time-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.washer_washer_completion_time', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Washer Washer Completion Time', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47.completionTime', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[da_wm_wm_000001][sensor.washer_washer_completion_time-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'timestamp', - 'friendly_name': 'Washer Washer Completion Time', - }), - 'context': , - 'entity_id': 'sensor.washer_washer_completion_time', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '2025-02-07T03:54:45+00:00', - }) -# --- -# name: test_all_entities[da_wm_wm_000001][sensor.washer_washer_job_state-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.washer_washer_job_state', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Washer Washer Job State', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47.washerJobState', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[da_wm_wm_000001][sensor.washer_washer_job_state-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Washer Washer Job State', - }), - 'context': , - 'entity_id': 'sensor.washer_washer_job_state', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'none', - }) -# --- -# name: test_all_entities[da_wm_wm_000001][sensor.washer_washer_machine_state-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.washer_washer_machine_state', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Washer Washer Machine State', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47.machineState', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[da_wm_wm_000001][sensor.washer_washer_machine_state-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Washer Washer Machine State', - }), - 'context': , - 'entity_id': 'sensor.washer_washer_machine_state', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'stop', - }) -# --- -# name: test_all_entities[ecobee_sensor][sensor.child_bedroom_temperature_measurement-entry] +# name: test_all_entities[ecobee_sensor][sensor.child_bedroom_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -3751,8 +3751,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.child_bedroom_temperature_measurement', - 'has_entity_name': False, + 'entity_id': 'sensor.child_bedroom_temperature', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -3763,7 +3763,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Child Bedroom Temperature Measurement', + 'original_name': 'Temperature', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -3772,23 +3772,23 @@ 'unit_of_measurement': , }) # --- -# name: test_all_entities[ecobee_sensor][sensor.child_bedroom_temperature_measurement-state] +# name: test_all_entities[ecobee_sensor][sensor.child_bedroom_temperature-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', - 'friendly_name': 'Child Bedroom Temperature Measurement', + 'friendly_name': 'Child Bedroom Temperature', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.child_bedroom_temperature_measurement', + 'entity_id': 'sensor.child_bedroom_temperature', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '22', }) # --- -# name: test_all_entities[ecobee_thermostat][sensor.main_floor_relative_humidity_measurement-entry] +# name: test_all_entities[ecobee_thermostat][sensor.main_floor_humidity-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -3803,8 +3803,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.main_floor_relative_humidity_measurement', - 'has_entity_name': False, + 'entity_id': 'sensor.main_floor_humidity', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -3815,7 +3815,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Main Floor Relative Humidity Measurement', + 'original_name': 'Humidity', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -3824,23 +3824,23 @@ 'unit_of_measurement': '%', }) # --- -# name: test_all_entities[ecobee_thermostat][sensor.main_floor_relative_humidity_measurement-state] +# name: test_all_entities[ecobee_thermostat][sensor.main_floor_humidity-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'humidity', - 'friendly_name': 'Main Floor Relative Humidity Measurement', + 'friendly_name': 'Main Floor Humidity', 'state_class': , 'unit_of_measurement': '%', }), 'context': , - 'entity_id': 'sensor.main_floor_relative_humidity_measurement', + 'entity_id': 'sensor.main_floor_humidity', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '32', }) # --- -# name: test_all_entities[ecobee_thermostat][sensor.main_floor_temperature_measurement-entry] +# name: test_all_entities[ecobee_thermostat][sensor.main_floor_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -3855,8 +3855,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.main_floor_temperature_measurement', - 'has_entity_name': False, + 'entity_id': 'sensor.main_floor_temperature', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -3867,7 +3867,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Main Floor Temperature Measurement', + 'original_name': 'Temperature', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -3876,16 +3876,16 @@ 'unit_of_measurement': , }) # --- -# name: test_all_entities[ecobee_thermostat][sensor.main_floor_temperature_measurement-state] +# name: test_all_entities[ecobee_thermostat][sensor.main_floor_temperature-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', - 'friendly_name': 'Main Floor Temperature Measurement', + 'friendly_name': 'Main Floor Temperature', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.main_floor_temperature_measurement', + 'entity_id': 'sensor.main_floor_temperature', 'last_changed': , 'last_reported': , 'last_updated': , @@ -3906,7 +3906,7 @@ 'domain': 'sensor', 'entity_category': , 'entity_id': 'sensor.deck_door_battery', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -3917,7 +3917,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Deck Door Battery', + 'original_name': 'Battery', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -3941,7 +3941,7 @@ 'state': '50', }) # --- -# name: test_all_entities[multipurpose_sensor][sensor.deck_door_temperature_measurement-entry] +# name: test_all_entities[multipurpose_sensor][sensor.deck_door_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -3956,8 +3956,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.deck_door_temperature_measurement', - 'has_entity_name': False, + 'entity_id': 'sensor.deck_door_temperature', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -3968,7 +3968,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Deck Door Temperature Measurement', + 'original_name': 'Temperature', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -3977,16 +3977,16 @@ 'unit_of_measurement': , }) # --- -# name: test_all_entities[multipurpose_sensor][sensor.deck_door_temperature_measurement-state] +# name: test_all_entities[multipurpose_sensor][sensor.deck_door_temperature-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', - 'friendly_name': 'Deck Door Temperature Measurement', + 'friendly_name': 'Deck Door Temperature', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.deck_door_temperature_measurement', + 'entity_id': 'sensor.deck_door_temperature', 'last_changed': , 'last_reported': , 'last_updated': , @@ -4007,7 +4007,7 @@ 'domain': 'sensor', 'entity_category': None, 'entity_id': 'sensor.deck_door_x_coordinate', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -4018,11 +4018,11 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Deck Door X Coordinate', + 'original_name': 'X coordinate', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'x_coordinate', 'unique_id': '7d246592-93db-4d72-a10d-5a51793ece8c X Coordinate', 'unit_of_measurement': None, }) @@ -4030,7 +4030,7 @@ # name: test_all_entities[multipurpose_sensor][sensor.deck_door_x_coordinate-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Deck Door X Coordinate', + 'friendly_name': 'Deck Door X coordinate', }), 'context': , 'entity_id': 'sensor.deck_door_x_coordinate', @@ -4054,7 +4054,7 @@ 'domain': 'sensor', 'entity_category': None, 'entity_id': 'sensor.deck_door_y_coordinate', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -4065,11 +4065,11 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Deck Door Y Coordinate', + 'original_name': 'Y coordinate', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'y_coordinate', 'unique_id': '7d246592-93db-4d72-a10d-5a51793ece8c Y Coordinate', 'unit_of_measurement': None, }) @@ -4077,7 +4077,7 @@ # name: test_all_entities[multipurpose_sensor][sensor.deck_door_y_coordinate-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Deck Door Y Coordinate', + 'friendly_name': 'Deck Door Y coordinate', }), 'context': , 'entity_id': 'sensor.deck_door_y_coordinate', @@ -4101,7 +4101,7 @@ 'domain': 'sensor', 'entity_category': None, 'entity_id': 'sensor.deck_door_z_coordinate', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -4112,11 +4112,11 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Deck Door Z Coordinate', + 'original_name': 'Z coordinate', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'z_coordinate', 'unique_id': '7d246592-93db-4d72-a10d-5a51793ece8c Z Coordinate', 'unit_of_measurement': None, }) @@ -4124,7 +4124,7 @@ # name: test_all_entities[multipurpose_sensor][sensor.deck_door_z_coordinate-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Deck Door Z Coordinate', + 'friendly_name': 'Deck Door Z coordinate', }), 'context': , 'entity_id': 'sensor.deck_door_z_coordinate', @@ -4148,7 +4148,7 @@ 'domain': 'sensor', 'entity_category': , 'entity_id': 'sensor.office_air_conditioner_mode', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -4159,11 +4159,11 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Office Air Conditioner Mode', + 'original_name': 'Air conditioner mode', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'air_conditioner_mode', 'unique_id': 'bf4b1167-48a3-4af7-9186-0900a678ffa5.airConditionerMode', 'unit_of_measurement': None, }) @@ -4171,7 +4171,7 @@ # name: test_all_entities[sensibo_airconditioner_1][sensor.office_air_conditioner_mode-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Office Air Conditioner Mode', + 'friendly_name': 'Office Air conditioner mode', }), 'context': , 'entity_id': 'sensor.office_air_conditioner_mode', @@ -4181,7 +4181,7 @@ 'state': 'cool', }) # --- -# name: test_all_entities[sensibo_airconditioner_1][sensor.office_thermostat_cooling_setpoint-entry] +# name: test_all_entities[sensibo_airconditioner_1][sensor.office_cooling_set_point-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -4194,8 +4194,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.office_thermostat_cooling_setpoint', - 'has_entity_name': False, + 'entity_id': 'sensor.office_cooling_set_point', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -4206,24 +4206,24 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Office Thermostat Cooling Setpoint', + 'original_name': 'Cooling set point', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'thermostat_cooling_setpoint', 'unique_id': 'bf4b1167-48a3-4af7-9186-0900a678ffa5.coolingSetpoint', 'unit_of_measurement': , }) # --- -# name: test_all_entities[sensibo_airconditioner_1][sensor.office_thermostat_cooling_setpoint-state] +# name: test_all_entities[sensibo_airconditioner_1][sensor.office_cooling_set_point-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', - 'friendly_name': 'Office Thermostat Cooling Setpoint', + 'friendly_name': 'Office Cooling set point', 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.office_thermostat_cooling_setpoint', + 'entity_id': 'sensor.office_cooling_set_point', 'last_changed': , 'last_reported': , 'last_updated': , @@ -4244,7 +4244,7 @@ 'domain': 'sensor', 'entity_category': None, 'entity_id': 'sensor.elliots_rum_media_playback_status', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -4255,11 +4255,11 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Elliots Rum Media Playback Status', + 'original_name': 'Media playback status', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'media_playback_status', 'unique_id': 'c85fced9-c474-4a47-93c2-037cc7829536.playbackStatus', 'unit_of_measurement': None, }) @@ -4267,7 +4267,7 @@ # name: test_all_entities[sonos_player][sensor.elliots_rum_media_playback_status-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Elliots Rum Media Playback Status', + 'friendly_name': 'Elliots Rum Media playback status', }), 'context': , 'entity_id': 'sensor.elliots_rum_media_playback_status', @@ -4291,7 +4291,7 @@ 'domain': 'sensor', 'entity_category': None, 'entity_id': 'sensor.elliots_rum_volume', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -4302,11 +4302,11 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Elliots Rum Volume', + 'original_name': 'Volume', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'audio_volume', 'unique_id': 'c85fced9-c474-4a47-93c2-037cc7829536.volume', 'unit_of_measurement': '%', }) @@ -4339,7 +4339,7 @@ 'domain': 'sensor', 'entity_category': None, 'entity_id': 'sensor.soundbar_living_media_playback_status', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -4350,11 +4350,11 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Soundbar Living Media Playback Status', + 'original_name': 'Media playback status', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'media_playback_status', 'unique_id': '0d94e5db-8501-2355-eb4f-214163702cac.playbackStatus', 'unit_of_measurement': None, }) @@ -4362,7 +4362,7 @@ # name: test_all_entities[vd_network_audio_002s][sensor.soundbar_living_media_playback_status-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Soundbar Living Media Playback Status', + 'friendly_name': 'Soundbar Living Media playback status', }), 'context': , 'entity_id': 'sensor.soundbar_living_media_playback_status', @@ -4386,7 +4386,7 @@ 'domain': 'sensor', 'entity_category': None, 'entity_id': 'sensor.soundbar_living_volume', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -4397,11 +4397,11 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Soundbar Living Volume', + 'original_name': 'Volume', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'audio_volume', 'unique_id': '0d94e5db-8501-2355-eb4f-214163702cac.volume', 'unit_of_measurement': '%', }) @@ -4434,7 +4434,7 @@ 'domain': 'sensor', 'entity_category': None, 'entity_id': 'sensor.tv_samsung_8_series_49_media_input_source', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -4445,11 +4445,11 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': '[TV] Samsung 8 Series (49) Media Input Source', + 'original_name': 'Media input source', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'media_input_source', 'unique_id': '4588d2d9-a8cf-40f4-9a0b-ed5dfbaccda1.inputSource', 'unit_of_measurement': None, }) @@ -4457,7 +4457,7 @@ # name: test_all_entities[vd_stv_2017_k][sensor.tv_samsung_8_series_49_media_input_source-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': '[TV] Samsung 8 Series (49) Media Input Source', + 'friendly_name': '[TV] Samsung 8 Series (49) Media input source', }), 'context': , 'entity_id': 'sensor.tv_samsung_8_series_49_media_input_source', @@ -4481,7 +4481,7 @@ 'domain': 'sensor', 'entity_category': None, 'entity_id': 'sensor.tv_samsung_8_series_49_media_playback_status', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -4492,11 +4492,11 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': '[TV] Samsung 8 Series (49) Media Playback Status', + 'original_name': 'Media playback status', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'media_playback_status', 'unique_id': '4588d2d9-a8cf-40f4-9a0b-ed5dfbaccda1.playbackStatus', 'unit_of_measurement': None, }) @@ -4504,7 +4504,7 @@ # name: test_all_entities[vd_stv_2017_k][sensor.tv_samsung_8_series_49_media_playback_status-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': '[TV] Samsung 8 Series (49) Media Playback Status', + 'friendly_name': '[TV] Samsung 8 Series (49) Media playback status', }), 'context': , 'entity_id': 'sensor.tv_samsung_8_series_49_media_playback_status', @@ -4528,7 +4528,7 @@ 'domain': 'sensor', 'entity_category': None, 'entity_id': 'sensor.tv_samsung_8_series_49_tv_channel', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -4539,11 +4539,11 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': '[TV] Samsung 8 Series (49) Tv Channel', + 'original_name': 'TV channel', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'tv_channel', 'unique_id': '4588d2d9-a8cf-40f4-9a0b-ed5dfbaccda1.tvChannel', 'unit_of_measurement': None, }) @@ -4551,7 +4551,7 @@ # name: test_all_entities[vd_stv_2017_k][sensor.tv_samsung_8_series_49_tv_channel-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': '[TV] Samsung 8 Series (49) Tv Channel', + 'friendly_name': '[TV] Samsung 8 Series (49) TV channel', }), 'context': , 'entity_id': 'sensor.tv_samsung_8_series_49_tv_channel', @@ -4575,7 +4575,7 @@ 'domain': 'sensor', 'entity_category': None, 'entity_id': 'sensor.tv_samsung_8_series_49_tv_channel_name', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -4586,11 +4586,11 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': '[TV] Samsung 8 Series (49) Tv Channel Name', + 'original_name': 'TV channel name', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'tv_channel_name', 'unique_id': '4588d2d9-a8cf-40f4-9a0b-ed5dfbaccda1.tvChannelName', 'unit_of_measurement': None, }) @@ -4598,7 +4598,7 @@ # name: test_all_entities[vd_stv_2017_k][sensor.tv_samsung_8_series_49_tv_channel_name-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': '[TV] Samsung 8 Series (49) Tv Channel Name', + 'friendly_name': '[TV] Samsung 8 Series (49) TV channel name', }), 'context': , 'entity_id': 'sensor.tv_samsung_8_series_49_tv_channel_name', @@ -4622,7 +4622,7 @@ 'domain': 'sensor', 'entity_category': None, 'entity_id': 'sensor.tv_samsung_8_series_49_volume', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -4633,11 +4633,11 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': '[TV] Samsung 8 Series (49) Volume', + 'original_name': 'Volume', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'audio_volume', 'unique_id': '4588d2d9-a8cf-40f4-9a0b-ed5dfbaccda1.volume', 'unit_of_measurement': '%', }) @@ -4670,7 +4670,7 @@ 'domain': 'sensor', 'entity_category': , 'entity_id': 'sensor.asd_battery', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -4681,7 +4681,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'asd Battery', + 'original_name': 'Battery', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -4705,7 +4705,7 @@ 'state': '100', }) # --- -# name: test_all_entities[virtual_thermostat][sensor.asd_temperature_measurement-entry] +# name: test_all_entities[virtual_thermostat][sensor.asd_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -4720,8 +4720,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.asd_temperature_measurement', - 'has_entity_name': False, + 'entity_id': 'sensor.asd_temperature', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -4732,7 +4732,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'asd Temperature Measurement', + 'original_name': 'Temperature', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -4741,16 +4741,16 @@ 'unit_of_measurement': , }) # --- -# name: test_all_entities[virtual_thermostat][sensor.asd_temperature_measurement-state] +# name: test_all_entities[virtual_thermostat][sensor.asd_temperature-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', - 'friendly_name': 'asd Temperature Measurement', + 'friendly_name': 'asd Temperature', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.asd_temperature_measurement', + 'entity_id': 'sensor.asd_temperature', 'last_changed': , 'last_reported': , 'last_updated': , @@ -4771,7 +4771,7 @@ 'domain': 'sensor', 'entity_category': , 'entity_id': 'sensor.asd_battery', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -4782,7 +4782,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'asd Battery', + 'original_name': 'Battery', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -4820,7 +4820,7 @@ 'domain': 'sensor', 'entity_category': , 'entity_id': 'sensor.basement_door_lock_battery', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -4831,7 +4831,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Basement Door Lock Battery', + 'original_name': 'Battery', 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, diff --git a/tests/components/smartthings/snapshots/test_switch.ambr b/tests/components/smartthings/snapshots/test_switch.ambr index cf3245eed7d..d12bd4ea5b6 100644 --- a/tests/components/smartthings/snapshots/test_switch.ambr +++ b/tests/components/smartthings/snapshots/test_switch.ambr @@ -13,7 +13,7 @@ 'domain': 'switch', 'entity_category': None, 'entity_id': 'switch.2nd_floor_hallway', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -24,7 +24,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': '2nd Floor Hallway', + 'original_name': None, 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -60,7 +60,7 @@ 'domain': 'switch', 'entity_category': None, 'entity_id': 'switch.microwave', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -71,7 +71,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Microwave', + 'original_name': None, 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -107,7 +107,7 @@ 'domain': 'switch', 'entity_category': None, 'entity_id': 'switch.robot_vacuum', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -118,7 +118,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Robot vacuum', + 'original_name': None, 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -154,7 +154,7 @@ 'domain': 'switch', 'entity_category': None, 'entity_id': 'switch.dishwasher', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -165,7 +165,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Dishwasher', + 'original_name': None, 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -201,7 +201,7 @@ 'domain': 'switch', 'entity_category': None, 'entity_id': 'switch.dryer', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -212,7 +212,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Dryer', + 'original_name': None, 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -248,7 +248,7 @@ 'domain': 'switch', 'entity_category': None, 'entity_id': 'switch.washer', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -259,7 +259,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Washer', + 'original_name': None, 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -295,7 +295,7 @@ 'domain': 'switch', 'entity_category': None, 'entity_id': 'switch.office', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -306,7 +306,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Office', + 'original_name': None, 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -342,7 +342,7 @@ 'domain': 'switch', 'entity_category': None, 'entity_id': 'switch.arlo_beta_basestation', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -353,7 +353,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Arlo Beta Basestation', + 'original_name': None, 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -389,7 +389,7 @@ 'domain': 'switch', 'entity_category': None, 'entity_id': 'switch.soundbar_living', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -400,7 +400,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Soundbar Living', + 'original_name': None, 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -436,7 +436,7 @@ 'domain': 'switch', 'entity_category': None, 'entity_id': 'switch.tv_samsung_8_series_49', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -447,7 +447,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': '[TV] Samsung 8 Series (49)', + 'original_name': None, 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, diff --git a/tests/components/smartthings/test_binary_sensor.py b/tests/components/smartthings/test_binary_sensor.py index eb473d3be04..f46be2edc89 100644 --- a/tests/components/smartthings/test_binary_sensor.py +++ b/tests/components/smartthings/test_binary_sensor.py @@ -39,7 +39,7 @@ async def test_state_update( """Test state update.""" await setup_integration(hass, mock_config_entry) - assert hass.states.get("binary_sensor.refrigerator_contact").state == STATE_OFF + assert hass.states.get("binary_sensor.refrigerator_door").state == STATE_OFF await trigger_update( hass, @@ -50,4 +50,4 @@ async def test_state_update( "open", ) - assert hass.states.get("binary_sensor.refrigerator_contact").state == STATE_ON + assert hass.states.get("binary_sensor.refrigerator_door").state == STATE_ON diff --git a/tests/components/smartthings/test_sensor.py b/tests/components/smartthings/test_sensor.py index 7f8464e69aa..8b8bb8930f4 100644 --- a/tests/components/smartthings/test_sensor.py +++ b/tests/components/smartthings/test_sensor.py @@ -37,10 +37,7 @@ async def test_state_update( """Test state update.""" await setup_integration(hass, mock_config_entry) - assert ( - hass.states.get("sensor.aeotec_energy_monitor_energy_meter").state - == "19978.536" - ) + assert hass.states.get("sensor.aeotec_energy_monitor_energy_2").state == "19978.536" await trigger_update( hass, @@ -51,6 +48,4 @@ async def test_state_update( 20000.0, ) - assert ( - hass.states.get("sensor.aeotec_energy_monitor_energy_meter").state == "20000.0" - ) + assert hass.states.get("sensor.aeotec_energy_monitor_energy_2").state == "20000.0" From e09b40c2bd7d4a6822dfc9a80eb53bae248e2160 Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Wed, 26 Feb 2025 15:51:16 +0100 Subject: [PATCH 1843/3148] Improve logging for selected options in Onkyo (#139279) Different error for not selected option --- .../components/onkyo/media_player.py | 40 ++++++++++++++----- 1 file changed, 30 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/onkyo/media_player.py b/homeassistant/components/onkyo/media_player.py index 7c91fda5f78..8f9587bc426 100644 --- a/homeassistant/components/onkyo/media_player.py +++ b/homeassistant/components/onkyo/media_player.py @@ -398,6 +398,7 @@ class OnkyoMediaPlayer(MediaPlayerEntity): self._volume_resolution = volume_resolution self._max_volume = max_volume + self._options_sources = sources self._source_lib_mapping = _input_source_lib_mappings(zone) self._rev_source_lib_mapping = _rev_input_source_lib_mappings(zone) self._source_mapping = { @@ -409,6 +410,7 @@ class OnkyoMediaPlayer(MediaPlayerEntity): value: key for key, value in self._source_mapping.items() } + self._options_sound_modes = sound_modes self._sound_mode_lib_mapping = _listening_mode_lib_mappings(zone) self._rev_sound_mode_lib_mapping = _rev_listening_mode_lib_mappings(zone) self._sound_mode_mapping = { @@ -623,11 +625,20 @@ class OnkyoMediaPlayer(MediaPlayerEntity): return source_meaning = source.value_meaning - _LOGGER.error( - 'Input source "%s" is invalid for entity: %s', - source_meaning, - self.entity_id, - ) + + if source not in self._options_sources: + _LOGGER.warning( + 'Input source "%s" for entity: %s is not in the list. Check integration options', + source_meaning, + self.entity_id, + ) + else: + _LOGGER.error( + 'Input source "%s" is invalid for entity: %s', + source_meaning, + self.entity_id, + ) + self._attr_source = source_meaning @callback @@ -638,11 +649,20 @@ class OnkyoMediaPlayer(MediaPlayerEntity): return sound_mode_meaning = sound_mode.value_meaning - _LOGGER.error( - 'Listening mode "%s" is invalid for entity: %s', - sound_mode_meaning, - self.entity_id, - ) + + if sound_mode not in self._options_sound_modes: + _LOGGER.warning( + 'Listening mode "%s" for entity: %s is not in the list. Check integration options', + sound_mode_meaning, + self.entity_id, + ) + else: + _LOGGER.error( + 'Listening mode "%s" is invalid for entity: %s', + sound_mode_meaning, + self.entity_id, + ) + self._attr_sound_mode = sound_mode_meaning @callback From 9be8fd4eac934066f67982931f74d7c4ee451b95 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 26 Feb 2025 15:59:23 +0100 Subject: [PATCH 1844/3148] Change no fixtures comment in SmartThings (#139344) --- .../components/smartthings/sensor.py | 46 +++++++++---------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index 6685d6be726..9c544ea5d73 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -64,7 +64,7 @@ class SmartThingsSensorEntityDescription(SensorEntityDescription): CAPABILITY_TO_SENSORS: dict[ Capability, dict[Attribute, list[SmartThingsSensorEntityDescription]] ] = { - # no fixtures + # Haven't seen at devices yet Capability.ACTIVITY_LIGHTING_MODE: { Attribute.LIGHTING_MODE: [ SmartThingsSensorEntityDescription( @@ -126,7 +126,7 @@ CAPABILITY_TO_SENSORS: dict[ ) ] }, - # no fixtures + # Haven't seen at devices yet Capability.BODY_MASS_INDEX_MEASUREMENT: { Attribute.BMI_MEASUREMENT: [ SmartThingsSensorEntityDescription( @@ -137,7 +137,7 @@ CAPABILITY_TO_SENSORS: dict[ ) ] }, - # no fixtures + # Haven't seen at devices yet Capability.BODY_WEIGHT_MEASUREMENT: { Attribute.BODY_WEIGHT_MEASUREMENT: [ SmartThingsSensorEntityDescription( @@ -149,7 +149,7 @@ CAPABILITY_TO_SENSORS: dict[ ) ] }, - # no fixtures + # Haven't seen at devices yet Capability.CARBON_DIOXIDE_MEASUREMENT: { Attribute.CARBON_DIOXIDE: [ SmartThingsSensorEntityDescription( @@ -160,7 +160,7 @@ CAPABILITY_TO_SENSORS: dict[ ) ] }, - # no fixtures + # Haven't seen at devices yet Capability.CARBON_MONOXIDE_DETECTOR: { Attribute.CARBON_MONOXIDE: [ SmartThingsSensorEntityDescription( @@ -169,7 +169,7 @@ CAPABILITY_TO_SENSORS: dict[ ) ] }, - # no fixtures + # Haven't seen at devices yet Capability.CARBON_MONOXIDE_MEASUREMENT: { Attribute.CARBON_MONOXIDE_LEVEL: [ SmartThingsSensorEntityDescription( @@ -202,7 +202,7 @@ CAPABILITY_TO_SENSORS: dict[ ) ], }, - # part of the proposed spec, no fixtures + # part of the proposed spec, Haven't seen at devices yet Capability.DRYER_MODE: { Attribute.DRYER_MODE: [ SmartThingsSensorEntityDescription( @@ -260,7 +260,7 @@ CAPABILITY_TO_SENSORS: dict[ ) ] }, - # no fixtures + # Haven't seen at devices yet Capability.EQUIVALENT_CARBON_DIOXIDE_MEASUREMENT: { Attribute.EQUIVALENT_CARBON_DIOXIDE_MEASUREMENT: [ SmartThingsSensorEntityDescription( @@ -272,7 +272,7 @@ CAPABILITY_TO_SENSORS: dict[ ) ] }, - # no fixtures + # Haven't seen at devices yet Capability.FORMALDEHYDE_MEASUREMENT: { Attribute.FORMALDEHYDE_LEVEL: [ SmartThingsSensorEntityDescription( @@ -283,7 +283,7 @@ CAPABILITY_TO_SENSORS: dict[ ) ] }, - # no fixtures + # Haven't seen at devices yet Capability.GAS_METER: { Attribute.GAS_METER: [ SmartThingsSensorEntityDescription( @@ -317,7 +317,7 @@ CAPABILITY_TO_SENSORS: dict[ ) ], }, - # no fixtures + # Haven't seen at devices yet Capability.ILLUMINANCE_MEASUREMENT: { Attribute.ILLUMINANCE: [ SmartThingsSensorEntityDescription( @@ -328,7 +328,7 @@ CAPABILITY_TO_SENSORS: dict[ ) ] }, - # no fixtures + # Haven't seen at devices yet Capability.INFRARED_LEVEL: { Attribute.INFRARED_LEVEL: [ SmartThingsSensorEntityDescription( @@ -347,7 +347,7 @@ CAPABILITY_TO_SENSORS: dict[ ) ] }, - # part of the proposed spec, no fixtures + # part of the proposed spec, Haven't seen at devices yet Capability.MEDIA_PLAYBACK_REPEAT: { Attribute.PLAYBACK_REPEAT_MODE: [ SmartThingsSensorEntityDescription( @@ -356,7 +356,7 @@ CAPABILITY_TO_SENSORS: dict[ ) ] }, - # part of the proposed spec, no fixtures + # part of the proposed spec, Haven't seen at devices yet Capability.MEDIA_PLAYBACK_SHUFFLE: { Attribute.PLAYBACK_SHUFFLE: [ SmartThingsSensorEntityDescription( @@ -471,7 +471,7 @@ CAPABILITY_TO_SENSORS: dict[ ) ] }, - # no fixtures + # Haven't seen at devices yet Capability.POWER_SOURCE: { Attribute.POWER_SOURCE: [ SmartThingsSensorEntityDescription( @@ -527,7 +527,7 @@ CAPABILITY_TO_SENSORS: dict[ ) ] }, - # no fixtures + # Haven't seen at devices yet Capability.SIGNAL_STRENGTH: { Attribute.LQI: [ SmartThingsSensorEntityDescription( @@ -546,7 +546,7 @@ CAPABILITY_TO_SENSORS: dict[ ) ], }, - # no fixtures + # Haven't seen at devices yet Capability.SMOKE_DETECTOR: { Attribute.SMOKE: [ SmartThingsSensorEntityDescription( @@ -581,7 +581,7 @@ CAPABILITY_TO_SENSORS: dict[ ) ] }, - # no fixtures + # Haven't seen at devices yet Capability.THERMOSTAT_FAN_MODE: { Attribute.THERMOSTAT_FAN_MODE: [ SmartThingsSensorEntityDescription( @@ -592,7 +592,7 @@ CAPABILITY_TO_SENSORS: dict[ ) ] }, - # no fixtures + # Haven't seen at devices yet Capability.THERMOSTAT_HEATING_SETPOINT: { Attribute.HEATING_SETPOINT: [ SmartThingsSensorEntityDescription( @@ -604,7 +604,7 @@ CAPABILITY_TO_SENSORS: dict[ ) ] }, - # no fixtures + # Haven't seen at devices yet Capability.THERMOSTAT_MODE: { Attribute.THERMOSTAT_MODE: [ SmartThingsSensorEntityDescription( @@ -615,7 +615,7 @@ CAPABILITY_TO_SENSORS: dict[ ) ] }, - # no fixtures + # Haven't seen at devices yet Capability.THERMOSTAT_OPERATING_STATE: { Attribute.THERMOSTAT_OPERATING_STATE: [ SmartThingsSensorEntityDescription( @@ -672,7 +672,7 @@ CAPABILITY_TO_SENSORS: dict[ ) ], }, - # no fixtures + # Haven't seen at devices yet Capability.TVOC_MEASUREMENT: { Attribute.TVOC_LEVEL: [ SmartThingsSensorEntityDescription( @@ -683,7 +683,7 @@ CAPABILITY_TO_SENSORS: dict[ ) ] }, - # no fixtures + # Haven't seen at devices yet Capability.ULTRAVIOLET_INDEX: { Attribute.ULTRAVIOLET_INDEX: [ SmartThingsSensorEntityDescription( From e403bee95b87e138761a51dab9ba2d40ec472508 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 26 Feb 2025 16:05:59 +0100 Subject: [PATCH 1845/3148] Set options for carbon monoxide detector sensor in SmartThings (#139346) --- homeassistant/components/smartthings/sensor.py | 2 ++ homeassistant/components/smartthings/strings.json | 7 ++++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index 9c544ea5d73..da4fa20526e 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -166,6 +166,8 @@ CAPABILITY_TO_SENSORS: dict[ SmartThingsSensorEntityDescription( key=Attribute.CARBON_MONOXIDE, translation_key="carbon_monoxide_detector", + options=["detected", "clear", "tested"], + device_class=SensorDeviceClass.ENUM, ) ] }, diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index 9cfc6176d20..9076aa8b2b5 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -61,7 +61,12 @@ "name": "Body weight" }, "carbon_monoxide_detector": { - "name": "Carbon monoxide detector" + "name": "Carbon monoxide detector", + "state": { + "detected": "Detected", + "clear": "Clear", + "tested": "Tested" + } }, "dishwasher_machine_state": { "name": "Machine state" From fdf69fcd7dea7f708664fba22ded72a8cb313bd9 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Wed, 26 Feb 2025 16:09:20 +0100 Subject: [PATCH 1846/3148] Improve calculating supported features in template light (#139339) --- homeassistant/components/template/light.py | 2 +- tests/components/template/test_light.py | 54 ++++++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/template/light.py b/homeassistant/components/template/light.py index 9391e368e2b..206703ddcce 100644 --- a/homeassistant/components/template/light.py +++ b/homeassistant/components/template/light.py @@ -1013,7 +1013,7 @@ class LightTemplate(TemplateEntity, LightEntity): if render in (None, "None", ""): self._supports_transition = False return - self._attr_supported_features &= LightEntityFeature.EFFECT + self._attr_supported_features &= ~LightEntityFeature.TRANSITION self._supports_transition = bool(render) if self._supports_transition: self._attr_supported_features |= LightEntityFeature.TRANSITION diff --git a/tests/components/template/test_light.py b/tests/components/template/test_light.py index b5ba93a4bd0..a94ec233f81 100644 --- a/tests/components/template/test_light.py +++ b/tests/components/template/test_light.py @@ -1847,6 +1847,60 @@ async def test_supports_transition_template( ) != expected_value +@pytest.mark.parametrize("count", [1]) +async def test_supports_transition_template_updates( + hass: HomeAssistant, count: int +) -> None: + """Test the template for the supports transition dynamically.""" + light_config = { + "test_template_light": { + "value_template": "{{ 1 == 1 }}", + "turn_on": {"service": "light.turn_on", "entity_id": "light.test_state"}, + "turn_off": {"service": "light.turn_off", "entity_id": "light.test_state"}, + "set_temperature": { + "service": "light.turn_on", + "data_template": { + "entity_id": "light.test_state", + "color_temp": "{{color_temp}}", + }, + }, + "set_effect": { + "service": "test.automation", + "data_template": { + "entity_id": "test.test_state", + "effect": "{{effect}}", + }, + }, + "effect_list_template": "{{ ['Disco', 'Police'] }}", + "effect_template": "{{ None }}", + "supports_transition_template": "{{ states('sensor.test') }}", + } + } + await async_setup_light(hass, count, light_config) + state = hass.states.get("light.test_template_light") + assert state is not None + + hass.states.async_set("sensor.test", 0) + await hass.async_block_till_done() + state = hass.states.get("light.test_template_light") + supported_features = state.attributes.get("supported_features") + assert supported_features == LightEntityFeature.EFFECT + + hass.states.async_set("sensor.test", 1) + await hass.async_block_till_done() + state = hass.states.get("light.test_template_light") + supported_features = state.attributes.get("supported_features") + assert ( + supported_features == LightEntityFeature.TRANSITION | LightEntityFeature.EFFECT + ) + + hass.states.async_set("sensor.test", 0) + await hass.async_block_till_done() + state = hass.states.get("light.test_template_light") + supported_features = state.attributes.get("supported_features") + assert supported_features == LightEntityFeature.EFFECT + + @pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( "light_config", From c1898ece8068c8573989c168182de05519917ff6 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 26 Feb 2025 16:13:45 +0100 Subject: [PATCH 1847/3148] Update frontend to 20250226.0 (#139340) Co-authored-by: Robert Resch --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index b13b33685d5..7bd361041e1 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20250221.0"] + "requirements": ["home-assistant-frontend==20250226.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 6a6c1dfc3ed..b248be0eb96 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -37,7 +37,7 @@ habluetooth==3.24.0 hass-nabucasa==0.92.0 hassil==2.2.3 home-assistant-bluetooth==1.13.1 -home-assistant-frontend==20250221.0 +home-assistant-frontend==20250226.0 home-assistant-intents==2025.2.5 httpx==0.28.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 54c0a29bee5..082524036e8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1152,7 +1152,7 @@ hole==0.8.0 holidays==0.67 # homeassistant.components.frontend -home-assistant-frontend==20250221.0 +home-assistant-frontend==20250226.0 # homeassistant.components.conversation home-assistant-intents==2025.2.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a3f171fa1a9..8cac6cc79d0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -981,7 +981,7 @@ hole==0.8.0 holidays==0.67 # homeassistant.components.frontend -home-assistant-frontend==20250221.0 +home-assistant-frontend==20250226.0 # homeassistant.components.conversation home-assistant-intents==2025.2.5 From 3c3c4d2641e2405ca3fa8731992e44e26bbaa7f6 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 26 Feb 2025 16:17:55 +0100 Subject: [PATCH 1848/3148] Use particulate matter device class in SmartThings (#139351) Use particule matter device class in SmartThings --- .../components/smartthings/sensor.py | 7 +- .../components/smartthings/strings.json | 6 - .../smartthings/snapshots/test_sensor.ambr | 410 +++++++++--------- 3 files changed, 213 insertions(+), 210 deletions(-) diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index da4fa20526e..ec4fc94ae80 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -16,6 +16,7 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.const import ( + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONCENTRATION_PARTS_PER_MILLION, LIGHT_LUX, PERCENTAGE, @@ -240,14 +241,16 @@ CAPABILITY_TO_SENSORS: dict[ Attribute.DUST_LEVEL: [ SmartThingsSensorEntityDescription( key=Attribute.DUST_LEVEL, - translation_key="dust_level", + device_class=SensorDeviceClass.PM10, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, ) ], Attribute.FINE_DUST_LEVEL: [ SmartThingsSensorEntityDescription( key=Attribute.FINE_DUST_LEVEL, - translation_key="fine_dust_level", + device_class=SensorDeviceClass.PM25, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, ) ], diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index 9076aa8b2b5..9d7ea5938f5 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -86,12 +86,6 @@ "dryer_job_state": { "name": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::name%]" }, - "dust_level": { - "name": "Dust level" - }, - "fine_dust_level": { - "name": "Fine dust level" - }, "equivalent_carbon_dioxide": { "name": "Equivalent carbon dioxide" }, diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index 2fca1a8d108..8f8f514ef07 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -558,57 +558,6 @@ 'state': 'unknown', }) # --- -# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_dust_level-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.ac_office_granit_dust_level', - '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': 'Dust level', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'dust_level', - 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d.dustLevel', - 'unit_of_measurement': 'μg/m^3', - }) -# --- -# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_dust_level-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'AC Office Granit Dust level', - 'state_class': , - 'unit_of_measurement': 'μg/m^3', - }), - 'context': , - 'entity_id': 'sensor.ac_office_granit_dust_level', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- # name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_energy-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -765,57 +714,6 @@ 'state': '0.0', }) # --- -# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_fine_dust_level-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.ac_office_granit_fine_dust_level', - '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': 'Fine dust level', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'fine_dust_level', - 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d.fineDustLevel', - 'unit_of_measurement': 'μg/m^3', - }) -# --- -# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_fine_dust_level-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'AC Office Granit Fine dust level', - 'state_class': , - 'unit_of_measurement': 'μg/m^3', - }), - 'context': , - 'entity_id': 'sensor.ac_office_granit_fine_dust_level', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- # name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_humidity-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -868,6 +766,110 @@ 'state': '60', }) # --- +# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_pm10-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ac_office_granit_pm10', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'PM10', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d.dustLevel', + 'unit_of_measurement': 'μg/m^3', + }) +# --- +# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_pm10-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pm10', + 'friendly_name': 'AC Office Granit PM10', + 'state_class': , + 'unit_of_measurement': 'μg/m^3', + }), + 'context': , + 'entity_id': 'sensor.ac_office_granit_pm10', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_pm2_5-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ac_office_granit_pm2_5', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'PM2.5', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d.fineDustLevel', + 'unit_of_measurement': 'μg/m^3', + }) +# --- +# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_pm2_5-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pm25', + 'friendly_name': 'AC Office Granit PM2.5', + 'state_class': , + 'unit_of_measurement': 'μg/m^3', + }), + 'context': , + 'entity_id': 'sensor.ac_office_granit_pm2_5', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_power-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1125,56 +1127,6 @@ 'state': 'unknown', }) # --- -# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_dust_level-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.aire_dormitorio_principal_dust_level', - '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': 'Dust level', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'dust_level', - 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e.dustLevel', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_dust_level-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Aire Dormitorio Principal Dust level', - 'state_class': , - }), - 'context': , - 'entity_id': 'sensor.aire_dormitorio_principal_dust_level', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- # name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_energy-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1331,56 +1283,6 @@ 'state': '0.0', }) # --- -# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_fine_dust_level-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.aire_dormitorio_principal_fine_dust_level', - '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': 'Fine dust level', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'fine_dust_level', - 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e.fineDustLevel', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_fine_dust_level-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Aire Dormitorio Principal Fine dust level', - 'state_class': , - }), - 'context': , - 'entity_id': 'sensor.aire_dormitorio_principal_fine_dust_level', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- # name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_humidity-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1480,6 +1382,110 @@ 'state': 'unknown', }) # --- +# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_pm10-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.aire_dormitorio_principal_pm10', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'PM10', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e.dustLevel', + 'unit_of_measurement': 'µg/m³', + }) +# --- +# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_pm10-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pm10', + 'friendly_name': 'Aire Dormitorio Principal PM10', + 'state_class': , + 'unit_of_measurement': 'µg/m³', + }), + 'context': , + 'entity_id': 'sensor.aire_dormitorio_principal_pm10', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_pm2_5-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.aire_dormitorio_principal_pm2_5', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'PM2.5', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e.fineDustLevel', + 'unit_of_measurement': 'µg/m³', + }) +# --- +# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_pm2_5-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pm25', + 'friendly_name': 'Aire Dormitorio Principal PM2.5', + 'state_class': , + 'unit_of_measurement': 'µg/m³', + }), + 'context': , + 'entity_id': 'sensor.aire_dormitorio_principal_pm2_5', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_power-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 9262dec4443ef8ef62464cdd798294b9d40e21dc Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 26 Feb 2025 16:18:14 +0100 Subject: [PATCH 1849/3148] Set options for dishwasher job state sensor in SmartThings (#139349) --- .../components/smartthings/sensor.py | 21 +++++++++++++ .../components/smartthings/strings.json | 14 ++++++++- .../smartthings/snapshots/test_sensor.ambr | 30 +++++++++++++++++-- 3 files changed, 62 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index ec4fc94ae80..feac0b4a09b 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -42,6 +42,13 @@ THERMOSTAT_CAPABILITIES = { Capability.THERMOSTAT_MODE, } +JOB_STATE_MAP = { + "preDrain": "pre_drain", + "preWash": "pre_wash", + "wrinklePrevent": "wrinkle_prevent", + "unknown": None, +} + def power_attributes(status: dict[str, Any]) -> dict[str, Any]: """Return the power attributes.""" @@ -194,6 +201,20 @@ CAPABILITY_TO_SENSORS: dict[ SmartThingsSensorEntityDescription( key=Attribute.DISHWASHER_JOB_STATE, translation_key="dishwasher_job_state", + options=[ + "airwash", + "cooling", + "drying", + "finish", + "pre_drain", + "pre_wash", + "rinse", + "spin", + "wash", + "wrinkle_prevent", + ], + device_class=SensorDeviceClass.ENUM, + value_fn=lambda value: JOB_STATE_MAP.get(value, value), ) ], Attribute.COMPLETION_TIME: [ diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index 9d7ea5938f5..7ee3e57ac64 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -72,7 +72,19 @@ "name": "Machine state" }, "dishwasher_job_state": { - "name": "Job state" + "name": "Job state", + "state": { + "airwash": "Airwash", + "cooling": "Cooling", + "drying": "Drying", + "finish": "Finish", + "pre_drain": "Pre-drain", + "pre_wash": "Pre-wash", + "rinse": "Rinse", + "spin": "Spin", + "wash": "Wash", + "wrinkle_prevent": "Wrinkle prevention" + } }, "completion_time": { "name": "Completion time" diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index 8f8f514ef07..0df93a3a02a 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -2739,7 +2739,20 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'options': list([ + 'airwash', + 'cooling', + 'drying', + 'finish', + 'pre_drain', + 'pre_wash', + 'rinse', + 'spin', + 'wash', + 'wrinkle_prevent', + ]), + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -2757,7 +2770,7 @@ 'name': None, 'options': dict({ }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Job state', 'platform': 'smartthings', @@ -2771,7 +2784,20 @@ # name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_job_state-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'enum', 'friendly_name': 'Dishwasher Job state', + 'options': list([ + 'airwash', + 'cooling', + 'drying', + 'finish', + 'pre_drain', + 'pre_wash', + 'rinse', + 'spin', + 'wash', + 'wrinkle_prevent', + ]), }), 'context': , 'entity_id': 'sensor.dishwasher_job_state', From 37c8764426adb42150c4ec19a36661d43b8ee457 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 26 Feb 2025 16:18:37 +0100 Subject: [PATCH 1850/3148] Set options for dishwasher machine state sensor in SmartThings (#139347) * Set options for dishwasher machine state sensor in SmartThings * Fix --- homeassistant/components/smartthings/sensor.py | 2 ++ .../components/smartthings/strings.json | 7 ++++++- .../smartthings/snapshots/test_sensor.ambr | 16 ++++++++++++++-- 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index feac0b4a09b..fb40632626f 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -195,6 +195,8 @@ CAPABILITY_TO_SENSORS: dict[ SmartThingsSensorEntityDescription( key=Attribute.MACHINE_STATE, translation_key="dishwasher_machine_state", + options=["pause", "run", "stop"], + device_class=SensorDeviceClass.ENUM, ) ], Attribute.DISHWASHER_JOB_STATE: [ diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index 7ee3e57ac64..a577d1267d7 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -69,7 +69,12 @@ } }, "dishwasher_machine_state": { - "name": "Machine state" + "name": "Machine state", + "state": { + "pause": "[%key:common::state::paused%]", + "run": "Running", + "stop": "Stopped" + } }, "dishwasher_job_state": { "name": "Job state", diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index 0df93a3a02a..01156462455 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -2812,7 +2812,13 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'options': list([ + 'pause', + 'run', + 'stop', + ]), + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -2830,7 +2836,7 @@ 'name': None, 'options': dict({ }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Machine state', 'platform': 'smartthings', @@ -2844,7 +2850,13 @@ # name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_machine_state-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'enum', 'friendly_name': 'Dishwasher Machine state', + 'options': list([ + 'pause', + 'run', + 'stop', + ]), }), 'context': , 'entity_id': 'sensor.dishwasher_machine_state', From bd80a7884888d9524ae79c000d0813775a615d6f Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 26 Feb 2025 16:18:59 +0100 Subject: [PATCH 1851/3148] Set options for alarm sensor in SmartThings (#139345) * Set options for alarm sensor in SmartThings * Set options for alarm sensor in SmartThings * Fix --- homeassistant/components/smartthings/sensor.py | 2 ++ .../components/smartthings/strings.json | 8 +++++++- .../smartthings/snapshots/test_sensor.ambr | 18 ++++++++++++++++-- 3 files changed, 25 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index fb40632626f..73cc8c32a09 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -112,6 +112,8 @@ CAPABILITY_TO_SENSORS: dict[ SmartThingsSensorEntityDescription( key=Attribute.ALARM, translation_key="alarm", + options=["both", "strobe", "siren", "off"], + device_class=SensorDeviceClass.ENUM, ) ] }, diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index a577d1267d7..2faf3df682d 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -49,7 +49,13 @@ "name": "Air quality" }, "alarm": { - "name": "Alarm" + "name": "Alarm", + "state": { + "both": "Strobe and siren", + "strobe": "Strobe", + "siren": "Siren", + "off": "[%key:common::state::off%]" + } }, "audio_volume": { "name": "Volume" diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index 01156462455..77d7ddf6643 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -263,7 +263,14 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'options': list([ + 'both', + 'strobe', + 'siren', + 'off', + ]), + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -281,7 +288,7 @@ 'name': None, 'options': dict({ }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Alarm', 'platform': 'smartthings', @@ -295,7 +302,14 @@ # name: test_all_entities[c2c_arlo_pro_3_switch][sensor.2nd_floor_hallway_alarm-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'enum', 'friendly_name': '2nd Floor Hallway Alarm', + 'options': list([ + 'both', + 'strobe', + 'siren', + 'off', + ]), }), 'context': , 'entity_id': 'sensor.2nd_floor_hallway_alarm', From b964bc58bef0671acd205b8d2da12b2e24054a64 Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Wed, 26 Feb 2025 16:19:19 +0100 Subject: [PATCH 1852/3148] Fix variable scopes in scripts (#138883) Co-authored-by: Erik --- homeassistant/helpers/script.py | 103 +++++----- homeassistant/helpers/script_variables.py | 218 ++++++++++++++++++++-- tests/helpers/test_script.py | 146 +++++++++++++++ tests/helpers/test_script_variables.py | 124 +++++++++--- 4 files changed, 504 insertions(+), 87 deletions(-) diff --git a/homeassistant/helpers/script.py b/homeassistant/helpers/script.py index 38bc96b67ef..bf7a4a0971c 100644 --- a/homeassistant/helpers/script.py +++ b/homeassistant/helpers/script.py @@ -12,7 +12,6 @@ from datetime import datetime, timedelta from functools import partial import itertools import logging -from types import MappingProxyType from typing import Any, Literal, TypedDict, cast, overload import async_interrupt @@ -90,7 +89,7 @@ from . import condition, config_validation as cv, service, template from .condition import ConditionCheckerType, trace_condition_function from .dispatcher import async_dispatcher_connect, async_dispatcher_send_internal from .event import async_call_later, async_track_template -from .script_variables import ScriptVariables +from .script_variables import ScriptRunVariables, ScriptVariables from .template import Template from .trace import ( TraceElement, @@ -177,7 +176,7 @@ def _set_result_unless_done(future: asyncio.Future[None]) -> None: future.set_result(None) -def action_trace_append(variables: dict[str, Any], path: str) -> TraceElement: +def action_trace_append(variables: TemplateVarsType, path: str) -> TraceElement: """Append a TraceElement to trace[path].""" trace_element = TraceElement(variables, path) trace_append_element(trace_element, ACTION_TRACE_NODE_MAX_LEN) @@ -189,7 +188,7 @@ async def trace_action( hass: HomeAssistant, script_run: _ScriptRun, stop: asyncio.Future[None], - variables: dict[str, Any], + variables: TemplateVarsType, ) -> AsyncGenerator[TraceElement]: """Trace action execution.""" path = trace_path_get() @@ -411,7 +410,7 @@ class _ScriptRun: self, hass: HomeAssistant, script: Script, - variables: dict[str, Any], + variables: ScriptRunVariables, context: Context | None, log_exceptions: bool, ) -> None: @@ -485,14 +484,16 @@ class _ScriptRun: script_stack.pop() self._finish() - return ScriptRunResult(self._conversation_response, response, self._variables) + return ScriptRunResult( + self._conversation_response, response, self._variables.local_scope + ) async def _async_step(self, log_exceptions: bool) -> None: continue_on_error = self._action.get(CONF_CONTINUE_ON_ERROR, False) with trace_path(str(self._step)): async with trace_action( - self._hass, self, self._stop, self._variables + self._hass, self, self._stop, self._variables.non_parallel_scope ) as trace_element: if self._stop.done(): return @@ -526,7 +527,7 @@ class _ScriptRun: ex, continue_on_error, self._log_exceptions or log_exceptions ) finally: - trace_element.update_variables(self._variables) + trace_element.update_variables(self._variables.non_parallel_scope) def _finish(self) -> None: self._script._runs.remove(self) # noqa: SLF001 @@ -624,11 +625,16 @@ class _ScriptRun: except ScriptStoppedError as ex: raise asyncio.CancelledError from ex - async def _async_run_script(self, script: Script) -> None: + async def _async_run_script( + self, script: Script, *, parallel: bool = False + ) -> None: """Execute a script.""" result = await self._async_run_long_action( self._hass.async_create_task_internal( - script.async_run(self._variables, self._context), eager_start=True + script.async_run( + self._variables.enter_scope(parallel=parallel), self._context + ), + eager_start=True, ) ) if result and result.conversation_response is not UNDEFINED: @@ -647,7 +653,7 @@ class _ScriptRun: """Run a script with a trace path.""" trace_path_stack_cv.set(copy(trace_path_stack_cv.get())) with trace_path([str(idx), "sequence"]): - await self._async_run_script(script) + await self._async_run_script(script, parallel=True) results = await asyncio.gather( *(async_run_with_trace(idx, script) for idx, script in enumerate(scripts)), @@ -760,14 +766,11 @@ class _ScriptRun: with trace_path("else"): await self._async_run_script(if_data["if_else"]) - @async_trace_path("repeat") - async def _async_step_repeat(self) -> None: # noqa: C901 - """Repeat a sequence.""" + async def _async_do_step_repeat(self) -> None: # noqa: C901 + """Repeat a sequence helper.""" description = self._action.get(CONF_ALIAS, "sequence") repeat = self._action[CONF_REPEAT] - saved_repeat_vars = self._variables.get("repeat") - def set_repeat_var( iteration: int, count: int | None = None, item: Any = None ) -> None: @@ -776,7 +779,7 @@ class _ScriptRun: repeat_vars["last"] = iteration == count if item is not None: repeat_vars["item"] = item - self._variables["repeat"] = repeat_vars + self._variables.define_local("repeat", repeat_vars) script = self._script._get_repeat_script(self._step) # noqa: SLF001 warned_too_many_loops = False @@ -927,10 +930,14 @@ class _ScriptRun: # while all the cpu time is consumed. await asyncio.sleep(0) - if saved_repeat_vars: - self._variables["repeat"] = saved_repeat_vars - else: - self._variables.pop("repeat", None) # Not set if count = 0 + @async_trace_path("repeat") + async def _async_step_repeat(self) -> None: + """Repeat a sequence.""" + self._variables = self._variables.enter_scope() + try: + await self._async_do_step_repeat() + finally: + self._variables = self._variables.exit_scope() ### Stop actions ### @@ -959,11 +966,12 @@ class _ScriptRun: ## Variable actions ## async def _async_step_variables(self) -> None: - """Set a variable value.""" - self._step_log("setting variables") - self._variables = self._action[CONF_VARIABLES].async_render( - self._hass, self._variables, render_as_defaults=False - ) + """Define a local variable.""" + self._step_log("defining local variables") + for key, value in ( + self._action[CONF_VARIABLES].async_simple_render(self._variables).items() + ): + self._variables.define_local(key, value) ## External actions ## @@ -1016,7 +1024,7 @@ class _ScriptRun: """Perform the device automation specified in the action.""" self._step_log("device automation") await device_action.async_call_action_from_config( - self._hass, self._action, self._variables, self._context + self._hass, self._action, dict(self._variables), self._context ) async def _async_step_event(self) -> None: @@ -1189,12 +1197,15 @@ class _ScriptRun: self._step_log("wait for trigger", timeout) - variables = {**self._variables} - self._variables["wait"] = { - "remaining": timeout, - "completed": False, - "trigger": None, - } + variables = dict(self._variables) + self._variables.assign_parallel_protected( + "wait", + { + "remaining": timeout, + "completed": False, + "trigger": None, + }, + ) trace_set_result(wait=self._variables["wait"]) if timeout == 0: @@ -1240,7 +1251,9 @@ class _ScriptRun: timeout = self._get_timeout_seconds_from_action() self._step_log("wait template", timeout) - self._variables["wait"] = {"remaining": timeout, "completed": False} + self._variables.assign_parallel_protected( + "wait", {"remaining": timeout, "completed": False} + ) trace_set_result(wait=self._variables["wait"]) wait_template = self._action[CONF_WAIT_TEMPLATE] @@ -1369,7 +1382,7 @@ async def _async_stop_scripts_at_shutdown(hass: HomeAssistant, event: Event) -> ) -type _VarsType = dict[str, Any] | Mapping[str, Any] | MappingProxyType[str, Any] +type _VarsType = dict[str, Any] | Mapping[str, Any] | ScriptRunVariables def _referenced_extract_ids(data: Any, key: str, found: set[str]) -> None: @@ -1407,7 +1420,7 @@ class ScriptRunResult: conversation_response: str | None | UndefinedType service_response: ServiceResponse - variables: dict[str, Any] + variables: Mapping[str, Any] class Script: @@ -1422,7 +1435,6 @@ class Script: *, # Used in "Running " log message change_listener: Callable[[], Any] | None = None, - copy_variables: bool = False, log_exceptions: bool = True, logger: logging.Logger | None = None, max_exceeded: str = DEFAULT_MAX_EXCEEDED, @@ -1476,8 +1488,6 @@ class Script: self._parallel_scripts: dict[int, list[Script]] = {} self._sequence_scripts: dict[int, Script] = {} self.variables = variables - self._variables_dynamic = template.is_complex(variables) - self._copy_variables_on_run = copy_variables @property def change_listener(self) -> Callable[..., Any] | None: @@ -1755,25 +1765,19 @@ class Script: if self.top_level: if self.variables: try: - variables = self.variables.async_render( + run_variables = self.variables.async_render( self._hass, run_variables, ) except exceptions.TemplateError as err: self._log("Error rendering variables: %s", err, level=logging.ERROR) raise - elif run_variables: - variables = dict(run_variables) - else: - variables = {} + variables = ScriptRunVariables.create_top_level(run_variables) variables["context"] = context - elif self._copy_variables_on_run: - # This is not the top level script, variables have been turned to a dict - variables = cast(dict[str, Any], copy(run_variables)) else: - # This is not the top level script, variables have been turned to a dict - variables = cast(dict[str, Any], run_variables) + # This is not the top level script, run_variables is an instance of ScriptRunVariables + variables = cast(ScriptRunVariables, run_variables) # Prevent non-allowed recursive calls which will cause deadlocks when we try to # stop (restart) or wait for (queued) our own script run. @@ -1999,7 +2003,6 @@ class Script: max_runs=self.max_runs, logger=self._logger, top_level=False, - copy_variables=True, ) parallel_script.change_listener = partial( self._chain_change_listener, parallel_script diff --git a/homeassistant/helpers/script_variables.py b/homeassistant/helpers/script_variables.py index 2b4507abd64..54200e094e6 100644 --- a/homeassistant/helpers/script_variables.py +++ b/homeassistant/helpers/script_variables.py @@ -2,8 +2,10 @@ from __future__ import annotations +from collections import ChainMap, UserDict from collections.abc import Mapping -from typing import Any +from dataclasses import dataclass, field +from typing import Any, cast from homeassistant.core import HomeAssistant, callback @@ -24,30 +26,23 @@ class ScriptVariables: hass: HomeAssistant, run_variables: Mapping[str, Any] | None, *, - render_as_defaults: bool = True, limited: bool = False, ) -> dict[str, Any]: """Render script variables. - The run variables are used to compute the static variables. - - If `render_as_defaults` is True, the run variables will not be overridden. - + The run variables are included in the result. + The run variables are used to compute the rendered variable values. + The run variables will not be overridden. + The rendering happens one at a time, with previous results influencing the next. """ if self._has_template is None: self._has_template = template.is_complex(self.variables) if not self._has_template: - if render_as_defaults: - rendered_variables = dict(self.variables) + rendered_variables = dict(self.variables) - if run_variables is not None: - rendered_variables.update(run_variables) - else: - rendered_variables = ( - {} if run_variables is None else dict(run_variables) - ) - rendered_variables.update(self.variables) + if run_variables is not None: + rendered_variables.update(run_variables) return rendered_variables @@ -56,7 +51,7 @@ class ScriptVariables: for key, value in self.variables.items(): # We can skip if we're going to override this key with # run variables anyway - if render_as_defaults and key in rendered_variables: + if key in rendered_variables: continue rendered_variables[key] = template.render_complex( @@ -65,6 +60,197 @@ class ScriptVariables: return rendered_variables + @callback + def async_simple_render(self, run_variables: Mapping[str, Any]) -> dict[str, Any]: + """Render script variables. + + Simply renders the variables, the run variables are not included in the result. + The run variables are used to compute the rendered variable values. + The rendering happens one at a time, with previous results influencing the next. + """ + if self._has_template is None: + self._has_template = template.is_complex(self.variables) + + if not self._has_template: + return self.variables + + run_variables = dict(run_variables) + rendered_variables = {} + + for key, value in self.variables.items(): + rendered_variable = template.render_complex(value, run_variables) + rendered_variables[key] = rendered_variable + run_variables[key] = rendered_variable + + return rendered_variables + def as_dict(self) -> dict[str, Any]: """Return dict version of this class.""" return self.variables + + +@dataclass +class _ParallelData: + """Data used in each parallel sequence.""" + + # `protected` is for variables that need special protection in parallel sequences. + # What this means is that such a variable defined in one parallel sequence will not be + # clobbered by the variable with the same name assigned in another parallel sequence. + # It also means that such a variable will not be visible in the outer scope. + # Currently the only such variable is `wait`. + protected: dict[str, Any] = field(default_factory=dict) + # `outer_scope_writes` is for variables that are written to the outer scope from + # a parallel sequence. This is used for generating correct traces of changed variables + # for each of the parallel sequences, isolating them from one another. + outer_scope_writes: dict[str, Any] = field(default_factory=dict) + + +@dataclass(kw_only=True) +class ScriptRunVariables(UserDict[str, Any]): + """Class to hold script run variables. + + The purpose of this class is to provide proper variable scoping semantics for scripts. + Each instance institutes a new local scope, in which variables can be defined. + Each instance has a reference to the previous instance, except for the top-level instance. + The instances therefore form a chain, in which variable lookup and assignment is performed. + The variables defined lower in the chain naturally override those defined higher up. + """ + + # _previous is the previous ScriptRunVariables in the chain + _previous: ScriptRunVariables | None = None + # _parent is the previous non-empty ScriptRunVariables in the chain + _parent: ScriptRunVariables | None = None + + # _local_data is the store for local variables + _local_data: dict[str, Any] | None = None + # _parallel_data is used for each parallel sequence + _parallel_data: _ParallelData | None = None + + # _non_parallel_scope includes all scopes all the way to the most recent parallel split + _non_parallel_scope: ChainMap[str, Any] + # _full_scope includes all scopes (all the way to the top-level) + _full_scope: ChainMap[str, Any] + + @classmethod + def create_top_level( + cls, + initial_data: Mapping[str, Any] | None = None, + ) -> ScriptRunVariables: + """Create a new top-level ScriptRunVariables.""" + local_data: dict[str, Any] = {} + non_parallel_scope = full_scope = ChainMap(local_data) + self = cls( + _local_data=local_data, + _non_parallel_scope=non_parallel_scope, + _full_scope=full_scope, + ) + if initial_data is not None: + self.update(initial_data) + return self + + def enter_scope(self, *, parallel: bool = False) -> ScriptRunVariables: + """Return a new child scope. + + :param parallel: Whether the new scope starts a parallel sequence. + """ + if self._local_data is not None or self._parallel_data is not None: + parent = self + else: + parent = cast( # top level always has local data, so we can cast safely + ScriptRunVariables, self._parent + ) + + parallel_data: _ParallelData | None + if not parallel: + parallel_data = None + non_parallel_scope = self._non_parallel_scope + full_scope = self._full_scope + else: + parallel_data = _ParallelData() + non_parallel_scope = ChainMap( + parallel_data.protected, parallel_data.outer_scope_writes + ) + full_scope = self._full_scope.new_child(parallel_data.protected) + + return ScriptRunVariables( + _previous=self, + _parent=parent, + _parallel_data=parallel_data, + _non_parallel_scope=non_parallel_scope, + _full_scope=full_scope, + ) + + def exit_scope(self) -> ScriptRunVariables: + """Exit the current scope. + + Does no clean-up, but simply returns the previous scope. + """ + if self._previous is None: + raise ValueError("Cannot exit top-level scope") + return self._previous + + def __delitem__(self, key: str) -> None: + """Delete a variable (disallowed).""" + raise TypeError("Deleting items is not allowed in ScriptRunVariables.") + + def __setitem__(self, key: str, value: Any) -> None: + """Assign value to a variable.""" + self._assign(key, value, parallel_protected=False) + + def assign_parallel_protected(self, key: str, value: Any) -> None: + """Assign value to a variable which is to be protected in parallel sequences.""" + self._assign(key, value, parallel_protected=True) + + def _assign(self, key: str, value: Any, *, parallel_protected: bool) -> None: + """Assign value to a variable. + + Value is always assigned to the variable in the nearest scope, in which it is defined. + If the variable is not defined at all, it is created in the top-level scope. + + :param parallel_protected: Whether variable is to be protected in parallel sequences. + """ + if self._local_data is not None and key in self._local_data: + self._local_data[key] = value + return + + if self._parent is None: + assert self._local_data is not None # top level always has local data + self._local_data[key] = value + return + + if self._parallel_data is not None: + if parallel_protected: + self._parallel_data.protected[key] = value + return + self._parallel_data.protected.pop(key, None) + self._parallel_data.outer_scope_writes[key] = value + + self._parent._assign(key, value, parallel_protected=parallel_protected) # noqa: SLF001 + + def define_local(self, key: str, value: Any) -> None: + """Define a local variable and assign value to it.""" + if self._local_data is None: + self._local_data = {} + self._non_parallel_scope = self._non_parallel_scope.new_child( + self._local_data + ) + self._full_scope = self._full_scope.new_child(self._local_data) + self._local_data[key] = value + + @property + def data(self) -> Mapping[str, Any]: # type: ignore[override] + """Return variables in full scope. + + Defined here for UserDict compatibility. + """ + return self._full_scope + + @property + def non_parallel_scope(self) -> Mapping[str, Any]: + """Return variables in non-parallel scope.""" + return self._non_parallel_scope + + @property + def local_scope(self) -> Mapping[str, Any]: + """Return variables in local scope.""" + return self._local_data if self._local_data is not None else {} diff --git a/tests/helpers/test_script.py b/tests/helpers/test_script.py index f3cbb982ad0..df589a41daa 100644 --- a/tests/helpers/test_script.py +++ b/tests/helpers/test_script.py @@ -452,6 +452,68 @@ async def test_service_response_data_errors( await script_obj.async_run(context=context) +async def test_calling_service_response_data_in_scopes(hass: HomeAssistant) -> None: + """Test response variable is still set after scopes end.""" + expected_var = {"data": "value-12345"} + + def mock_service(call: ServiceCall) -> ServiceResponse: + """Mock service call.""" + if call.return_response: + return expected_var + return None + + hass.services.async_register( + "test", "script", mock_service, supports_response=SupportsResponse.OPTIONAL + ) + + sequence = cv.SCRIPT_SCHEMA( + { + "parallel": [ + { + "alias": "Sequential group", + "sequence": [ + { + "alias": "variables", + "variables": {"state": "off"}, + }, + { + "alias": "service step1", + "action": "test.script", + "response_variable": "my_response", + }, + ], + } + ] + } + ) + + script_obj = script.Script(hass, sequence, "Test Name", "test_domain") + + result = await script_obj.async_run(context=Context()) + + assert result.variables["my_response"] == expected_var + + expected_trace = { + "0": [{"variables": {"my_response": expected_var}}], + "0/parallel/0/sequence/0": [{"variables": {"state": "off"}}], + "0/parallel/0/sequence/1": [ + { + "result": { + "params": { + "domain": "test", + "service": "script", + "service_data": {}, + "target": {}, + }, + "running_script": False, + }, + "variables": {"my_response": expected_var}, + } + ], + } + assert_action_trace(expected_trace) + + async def test_data_template_with_templated_key(hass: HomeAssistant) -> None: """Test the calling of a service with a data_template with a templated key.""" context = Context() @@ -1706,6 +1768,90 @@ async def test_wait_variables_out(hass: HomeAssistant, mode, action_type) -> Non assert float(remaining) == 0.0 +async def test_wait_in_sequence(hass: HomeAssistant) -> None: + """Test wait variable is still set after sequence ends.""" + sequence = cv.SCRIPT_SCHEMA( + [ + { + "alias": "Sequential group", + "sequence": [ + { + "alias": "variables", + "variables": {"state": "off"}, + }, + { + "alias": "wait template", + "wait_template": "{{ state == 'off' }}", + }, + ], + }, + ] + ) + + script_obj = script.Script(hass, sequence, "Test Name", "test_domain") + + result = await script_obj.async_run(context=Context()) + + expected_var = {"completed": True, "remaining": None} + + assert result.variables["wait"] == expected_var + + expected_trace = { + "0": [{"variables": {"wait": expected_var}}], + "0/sequence/0": [{"variables": {"state": "off"}}], + "0/sequence/1": [ + { + "result": {"wait": expected_var}, + "variables": {"wait": expected_var}, + } + ], + } + assert_action_trace(expected_trace) + + +async def test_wait_in_parallel(hass: HomeAssistant) -> None: + """Test wait variable is not set after parallel ends.""" + sequence = cv.SCRIPT_SCHEMA( + { + "parallel": [ + { + "alias": "Sequential group", + "sequence": [ + { + "alias": "variables", + "variables": {"state": "off"}, + }, + { + "alias": "wait template", + "wait_template": "{{ state == 'off' }}", + }, + ], + } + ] + } + ) + + script_obj = script.Script(hass, sequence, "Test Name", "test_domain") + + result = await script_obj.async_run(context=Context()) + + expected_var = {"completed": True, "remaining": None} + + assert "wait" not in result.variables + + expected_trace = { + "0": [{}], + "0/parallel/0/sequence/0": [{"variables": {"state": "off"}}], + "0/parallel/0/sequence/1": [ + { + "result": {"wait": expected_var}, + "variables": {"wait": expected_var}, + } + ], + } + assert_action_trace(expected_trace) + + async def test_wait_for_trigger_bad( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: diff --git a/tests/helpers/test_script_variables.py b/tests/helpers/test_script_variables.py index 3675c857279..974a91674a7 100644 --- a/tests/helpers/test_script_variables.py +++ b/tests/helpers/test_script_variables.py @@ -5,12 +5,13 @@ import pytest from homeassistant.core import HomeAssistant from homeassistant.exceptions import TemplateError from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.script_variables import ScriptRunVariables, ScriptVariables async def test_static_vars() -> None: """Test static vars.""" orig = {"hello": "world"} - var = cv.SCRIPT_VARIABLES_SCHEMA(orig) + var = ScriptVariables(orig) rendered = var.async_render(None, None) assert rendered is not orig assert rendered == orig @@ -20,31 +21,28 @@ async def test_static_vars_run_args() -> None: """Test static vars.""" orig = {"hello": "world"} orig_copy = dict(orig) - var = cv.SCRIPT_VARIABLES_SCHEMA(orig) + var = ScriptVariables(orig) rendered = var.async_render(None, {"hello": "override", "run": "var"}) assert rendered == {"hello": "override", "run": "var"} # Make sure we don't change original vars assert orig == orig_copy -async def test_static_vars_no_default() -> None: +async def test_static_vars_simple() -> None: """Test static vars.""" orig = {"hello": "world"} - var = cv.SCRIPT_VARIABLES_SCHEMA(orig) - rendered = var.async_render(None, None, render_as_defaults=False) - assert rendered is not orig - assert rendered == orig + var = ScriptVariables(orig) + rendered = var.async_simple_render({}) + assert rendered is orig -async def test_static_vars_run_args_no_default() -> None: +async def test_static_vars_run_args_simple() -> None: """Test static vars.""" orig = {"hello": "world"} orig_copy = dict(orig) - var = cv.SCRIPT_VARIABLES_SCHEMA(orig) - rendered = var.async_render( - None, {"hello": "override", "run": "var"}, render_as_defaults=False - ) - assert rendered == {"hello": "world", "run": "var"} + var = ScriptVariables(orig) + rendered = var.async_simple_render({"hello": "override", "run": "var"}) + assert rendered is orig # Make sure we don't change original vars assert orig == orig_copy @@ -78,14 +76,14 @@ async def test_template_vars_run_args(hass: HomeAssistant) -> None: } -async def test_template_vars_no_default(hass: HomeAssistant) -> None: +async def test_template_vars_simple(hass: HomeAssistant) -> None: """Test template vars.""" var = cv.SCRIPT_VARIABLES_SCHEMA({"hello": "{{ 1 + 1 }}"}) - rendered = var.async_render(hass, None, render_as_defaults=False) + rendered = var.async_simple_render({}) assert rendered == {"hello": 2} -async def test_template_vars_run_args_no_default(hass: HomeAssistant) -> None: +async def test_template_vars_run_args_simple(hass: HomeAssistant) -> None: """Test template vars.""" var = cv.SCRIPT_VARIABLES_SCHEMA( { @@ -93,16 +91,13 @@ async def test_template_vars_run_args_no_default(hass: HomeAssistant) -> None: "something_2": "{{ run_var_ex + 1 }}", } ) - rendered = var.async_render( - hass, + rendered = var.async_simple_render( { "run_var_ex": 5, "something_2": 1, - }, - render_as_defaults=False, + } ) assert rendered == { - "run_var_ex": 5, "something": 6, "something_2": 6, } @@ -113,3 +108,90 @@ async def test_template_vars_error(hass: HomeAssistant) -> None: var = cv.SCRIPT_VARIABLES_SCHEMA({"hello": "{{ canont.work }}"}) with pytest.raises(TemplateError): var.async_render(hass, None) + + +async def test_script_vars_exit_top_level() -> None: + """Test exiting top level script run variables.""" + script_vars = ScriptRunVariables.create_top_level() + with pytest.raises(ValueError): + script_vars.exit_scope() + + +async def test_script_vars_delete_var() -> None: + """Test deleting from script run variables.""" + script_vars = ScriptRunVariables.create_top_level({"x": 1, "y": 2}) + with pytest.raises(TypeError): + del script_vars["x"] + with pytest.raises(TypeError): + script_vars.pop("y") + assert script_vars._full_scope == {"x": 1, "y": 2} + + +async def test_script_vars_scopes() -> None: + """Test script run variables scopes.""" + script_vars = ScriptRunVariables.create_top_level() + script_vars["x"] = 1 + script_vars["y"] = 1 + assert script_vars["x"] == 1 + assert script_vars["y"] == 1 + + script_vars_2 = script_vars.enter_scope() + script_vars_2.define_local("x", 2) + assert script_vars_2["x"] == 2 + assert script_vars_2["y"] == 1 + + script_vars_3 = script_vars_2.enter_scope() + script_vars_3["x"] = 3 + script_vars_3["y"] = 3 + assert script_vars_3["x"] == 3 + assert script_vars_3["y"] == 3 + + script_vars_4 = script_vars_3.enter_scope() + assert script_vars_4["x"] == 3 + assert script_vars_4["y"] == 3 + + assert script_vars_4.exit_scope() is script_vars_3 + + assert script_vars_3._full_scope == {"x": 3, "y": 3} + assert script_vars_3.local_scope == {} + + assert script_vars_3.exit_scope() is script_vars_2 + + assert script_vars_2._full_scope == {"x": 3, "y": 3} + assert script_vars_2.local_scope == {"x": 3} + + assert script_vars_2.exit_scope() is script_vars + + assert script_vars._full_scope == {"x": 1, "y": 3} + assert script_vars.local_scope == {"x": 1, "y": 3} + + +async def test_script_vars_parallel() -> None: + """Test script run variables parallel support.""" + script_vars = ScriptRunVariables.create_top_level({"x": 1, "y": 1, "z": 1}) + + script_vars_2a = script_vars.enter_scope(parallel=True) + script_vars_3a = script_vars_2a.enter_scope() + + script_vars_2b = script_vars.enter_scope(parallel=True) + script_vars_3b = script_vars_2b.enter_scope() + + script_vars_3a["x"] = "a" + script_vars_3a.assign_parallel_protected("y", "a") + + script_vars_3b["x"] = "b" + script_vars_3b.assign_parallel_protected("y", "b") + + assert script_vars_3a._full_scope == {"x": "b", "y": "a", "z": 1} + assert script_vars_3a.non_parallel_scope == {"x": "a", "y": "a"} + + assert script_vars_3b._full_scope == {"x": "b", "y": "b", "z": 1} + assert script_vars_3b.non_parallel_scope == {"x": "b", "y": "b"} + + assert script_vars_3a.exit_scope() is script_vars_2a + assert script_vars_2a.exit_scope() is script_vars + assert script_vars_3b.exit_scope() is script_vars_2b + assert script_vars_2b.exit_scope() is script_vars + + assert script_vars._full_scope == {"x": "b", "y": 1, "z": 1} + assert script_vars.local_scope == {"x": "b", "y": 1, "z": 1} From 998757f09ee8bda5749633710d95bc88280b2b5e Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 26 Feb 2025 16:40:34 +0100 Subject: [PATCH 1853/3148] Add translatable states to SmartThings media source input (#139353) Add translatable states to media source input --- .../components/smartthings/sensor.py | 14 +++++++++ .../components/smartthings/strings.json | 29 ++++++++++++++++++- .../smartthings/snapshots/test_sensor.ambr | 20 +++++++++++-- 3 files changed, 59 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index 73cc8c32a09..b77f3245040 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -67,6 +67,7 @@ class SmartThingsSensorEntityDescription(SensorEntityDescription): extra_state_attributes_fn: Callable[[Any], dict[str, Any]] | None = None unique_id_separator: str = "." capability_ignore_list: list[set[Capability]] | None = None + options_attribute: Attribute | None = None CAPABILITY_TO_SENSORS: dict[ @@ -374,6 +375,9 @@ CAPABILITY_TO_SENSORS: dict[ SmartThingsSensorEntityDescription( key=Attribute.INPUT_SOURCE, translation_key="media_input_source", + device_class=SensorDeviceClass.ENUM, + options_attribute=Attribute.SUPPORTED_INPUT_SOURCES, + value_fn=lambda value: value.lower(), ) ] }, @@ -841,3 +845,13 @@ class SmartThingsSensor(SmartThingsEntity, SensorEntity): self.get_attribute_value(self.capability, self._attribute) ) return None + + @property + def options(self) -> list[str] | None: + """Return the options for this sensor.""" + if self.entity_description.options_attribute: + options = self.get_attribute_value( + self.capability, self.entity_description.options_attribute + ) + return [option.lower() for option in options] + return super().options diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index 2faf3df682d..d5989288769 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -128,7 +128,34 @@ "name": "Infrared level" }, "media_input_source": { - "name": "Media input source" + "name": "Media input source", + "state": { + "am": "AM", + "fm": "FM", + "cd": "CD", + "hdmi": "HDMI", + "hdmi1": "HDMI 1", + "hdmi2": "HDMI 2", + "hdmi3": "HDMI 3", + "hdmi4": "HDMI 4", + "hdmi5": "HDMI 5", + "hdmi6": "HDMI 6", + "digitaltv": "Digital TV", + "usb": "USB", + "youtube": "YouTube", + "aux": "AUX", + "bluetooth": "Bluetooth", + "digital": "Digital", + "melon": "Melon", + "wifi": "Wi-Fi", + "network": "Network", + "optical": "Optical", + "coaxial": "Coaxial", + "analog1": "Analog 1", + "analog2": "Analog 2", + "analog3": "Analog 3", + "phono": "Phono" + } }, "media_playback_repeat": { "name": "Media playback repeat" diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index 77d7ddf6643..6046b4381b5 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -4483,7 +4483,14 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'options': list([ + 'digitaltv', + 'hdmi1', + 'hdmi4', + 'hdmi4', + ]), + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -4501,7 +4508,7 @@ 'name': None, 'options': dict({ }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Media input source', 'platform': 'smartthings', @@ -4515,14 +4522,21 @@ # name: test_all_entities[vd_stv_2017_k][sensor.tv_samsung_8_series_49_media_input_source-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'enum', 'friendly_name': '[TV] Samsung 8 Series (49) Media input source', + 'options': list([ + 'digitaltv', + 'hdmi1', + 'hdmi4', + 'hdmi4', + ]), }), 'context': , 'entity_id': 'sensor.tv_samsung_8_series_49_media_input_source', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'HDMI1', + 'state': 'hdmi1', }) # --- # name: test_all_entities[vd_stv_2017_k][sensor.tv_samsung_8_series_49_media_playback_status-entry] From 775a81829bd87560a874ed9e57c6f08ffd49bff0 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 26 Feb 2025 16:49:00 +0100 Subject: [PATCH 1854/3148] Add translatable states to SmartThings media playback (#139354) Add translatable states to media playback --- .../components/smartthings/sensor.py | 14 ++++ .../smartthings/snapshots/test_sensor.ambr | 66 +++++++++++++++++-- 2 files changed, 74 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index b77f3245040..0e4e4a11983 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -49,6 +49,10 @@ JOB_STATE_MAP = { "unknown": None, } +MEDIA_PLAYBACK_STATE_MAP = { + "fast forwarding": "fast_forwarding", +} + def power_attributes(status: dict[str, Any]) -> dict[str, Any]: """Return the power attributes.""" @@ -404,6 +408,16 @@ CAPABILITY_TO_SENSORS: dict[ SmartThingsSensorEntityDescription( key=Attribute.PLAYBACK_STATUS, translation_key="media_playback_status", + options=[ + "paused", + "playing", + "stopped", + "fast_forwarding", + "rewinding", + "buffering", + ], + device_class=SensorDeviceClass.ENUM, + value_fn=lambda value: MEDIA_PLAYBACK_STATE_MAP.get(value, value), ) ] }, diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index 6046b4381b5..84575008c7a 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -4293,7 +4293,16 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'options': list([ + 'paused', + 'playing', + 'stopped', + 'fast_forwarding', + 'rewinding', + 'buffering', + ]), + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -4311,7 +4320,7 @@ 'name': None, 'options': dict({ }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Media playback status', 'platform': 'smartthings', @@ -4325,7 +4334,16 @@ # name: test_all_entities[sonos_player][sensor.elliots_rum_media_playback_status-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'enum', 'friendly_name': 'Elliots Rum Media playback status', + 'options': list([ + 'paused', + 'playing', + 'stopped', + 'fast_forwarding', + 'rewinding', + 'buffering', + ]), }), 'context': , 'entity_id': 'sensor.elliots_rum_media_playback_status', @@ -4388,7 +4406,16 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'options': list([ + 'paused', + 'playing', + 'stopped', + 'fast_forwarding', + 'rewinding', + 'buffering', + ]), + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -4406,7 +4433,7 @@ 'name': None, 'options': dict({ }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Media playback status', 'platform': 'smartthings', @@ -4420,7 +4447,16 @@ # name: test_all_entities[vd_network_audio_002s][sensor.soundbar_living_media_playback_status-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'enum', 'friendly_name': 'Soundbar Living Media playback status', + 'options': list([ + 'paused', + 'playing', + 'stopped', + 'fast_forwarding', + 'rewinding', + 'buffering', + ]), }), 'context': , 'entity_id': 'sensor.soundbar_living_media_playback_status', @@ -4544,7 +4580,16 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'options': list([ + 'paused', + 'playing', + 'stopped', + 'fast_forwarding', + 'rewinding', + 'buffering', + ]), + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -4562,7 +4607,7 @@ 'name': None, 'options': dict({ }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Media playback status', 'platform': 'smartthings', @@ -4576,7 +4621,16 @@ # name: test_all_entities[vd_stv_2017_k][sensor.tv_samsung_8_series_49_media_playback_status-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'enum', 'friendly_name': '[TV] Samsung 8 Series (49) Media playback status', + 'options': list([ + 'paused', + 'playing', + 'stopped', + 'fast_forwarding', + 'rewinding', + 'buffering', + ]), }), 'context': , 'entity_id': 'sensor.tv_samsung_8_series_49_media_playback_status', From fc1190dafd5a020059466fec76afc18bf6a6ed23 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 26 Feb 2025 16:59:20 +0100 Subject: [PATCH 1855/3148] Add translatable states to oven mode in SmartThings (#139356) --- .../components/smartthings/sensor.py | 31 ++++++++++ .../components/smartthings/strings.json | 33 +++++++++- .../smartthings/snapshots/test_sensor.ambr | 62 ++++++++++++++++++- 3 files changed, 122 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index 0e4e4a11983..d4f88964eee 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -53,6 +53,34 @@ MEDIA_PLAYBACK_STATE_MAP = { "fast forwarding": "fast_forwarding", } +OVEN_MODE = { + "Conventional": "conventional", + "Bake": "bake", + "BottomHeat": "bottom_heat", + "ConvectionBake": "convection_bake", + "ConvectionRoast": "convection_roast", + "Broil": "broil", + "ConvectionBroil": "convection_broil", + "SteamCook": "steam_cook", + "SteamBake": "steam_bake", + "SteamRoast": "steam_roast", + "SteamBottomHeatplusConvection": "steam_bottom_heat_plus_convection", + "Microwave": "microwave", + "MWplusGrill": "microwave_plus_grill", + "MWplusConvection": "microwave_plus_convection", + "MWplusHotBlast": "microwave_plus_hot_blast", + "MWplusHotBlast2": "microwave_plus_hot_blast_2", + "SlimMiddle": "slim_middle", + "SlimStrong": "slim_strong", + "SlowCook": "slow_cook", + "Proof": "proof", + "Dehydrate": "dehydrate", + "Others": "others", + "StrongSteam": "strong_steam", + "Descale": "descale", + "Rinse": "rinse", +} + def power_attributes(status: dict[str, Any]) -> dict[str, Any]: """Return the power attributes.""" @@ -435,6 +463,9 @@ CAPABILITY_TO_SENSORS: dict[ key=Attribute.OVEN_MODE, translation_key="oven_mode", entity_category=EntityCategory.DIAGNOSTIC, + options=list(OVEN_MODE.values()), + device_class=SensorDeviceClass.ENUM, + value_fn=lambda value: OVEN_MODE.get(value, value), ) ] }, diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index d5989288769..b88c27fad77 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -170,7 +170,38 @@ "name": "Odor sensor" }, "oven_mode": { - "name": "Oven mode" + "name": "Oven mode", + "state": { + "heating": "Heating", + "grill": "Grill", + "warming": "Warming", + "defrosting": "Defrosting", + "conventional": "Conventional", + "bake": "Bake", + "bottom_heat": "Bottom heat", + "convection_bake": "Convection bake", + "convection_roast": "Convection roast", + "broil": "Broil", + "convection_broil": "Convection broil", + "steam_cook": "Steam cook", + "steam_bake": "Steam bake", + "steam_roast": "Steam roast", + "steam_bottom_heat_plus_convection": "Steam bottom heat plus convection", + "microwave": "Microwave", + "microwave_plus_grill": "Microwave plus grill", + "microwave_plus_convection": "Microwave plus convection", + "microwave_plus_hot_blast": "Microwave plus hot blast", + "microwave_plus_hot_blast_2": "Microwave plus hot blast 2", + "slim_middle": "Slim middle", + "slim_strong": "Slim strong", + "slow_cook": "Slow cook", + "proof": "Proof", + "dehydrate": "Dehydrate", + "others": "Others", + "strong_steam": "Strong steam", + "descale": "Descale", + "rinse": "Rinse" + } }, "oven_machine_state": { "name": "[%key:component::smartthings::entity::sensor::dishwasher_machine_state::name%]" diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index 84575008c7a..41691d26435 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -1852,7 +1852,35 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'options': list([ + 'conventional', + 'bake', + 'bottom_heat', + 'convection_bake', + 'convection_roast', + 'broil', + 'convection_broil', + 'steam_cook', + 'steam_bake', + 'steam_roast', + 'steam_bottom_heat_plus_convection', + 'microwave', + 'microwave_plus_grill', + 'microwave_plus_convection', + 'microwave_plus_hot_blast', + 'microwave_plus_hot_blast_2', + 'slim_middle', + 'slim_strong', + 'slow_cook', + 'proof', + 'dehydrate', + 'others', + 'strong_steam', + 'descale', + 'rinse', + ]), + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -1870,7 +1898,7 @@ 'name': None, 'options': dict({ }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Oven mode', 'platform': 'smartthings', @@ -1884,14 +1912,42 @@ # name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_oven_mode-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'enum', 'friendly_name': 'Microwave Oven mode', + 'options': list([ + 'conventional', + 'bake', + 'bottom_heat', + 'convection_bake', + 'convection_roast', + 'broil', + 'convection_broil', + 'steam_cook', + 'steam_bake', + 'steam_roast', + 'steam_bottom_heat_plus_convection', + 'microwave', + 'microwave_plus_grill', + 'microwave_plus_convection', + 'microwave_plus_hot_blast', + 'microwave_plus_hot_blast_2', + 'slim_middle', + 'slim_strong', + 'slow_cook', + 'proof', + 'dehydrate', + 'others', + 'strong_steam', + 'descale', + 'rinse', + ]), }), 'context': , 'entity_id': 'sensor.microwave_oven_mode', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'Others', + 'state': 'others', }) # --- # name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_set_point-entry] From b777c29bab497a018c4713670cb9eb288b7906e8 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 26 Feb 2025 17:12:27 +0100 Subject: [PATCH 1856/3148] Add translatable states to oven job state in SmartThings (#139361) --- .../components/smartthings/sensor.py | 29 ++++++++++++ .../components/smartthings/strings.json | 21 ++++++++- .../smartthings/snapshots/test_sensor.ambr | 44 ++++++++++++++++++- 3 files changed, 91 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index d4f88964eee..91b9a09fd19 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -49,6 +49,14 @@ JOB_STATE_MAP = { "unknown": None, } +OVEN_JOB_STATE_MAP = { + "scheduledStart": "scheduled_start", + "fastPreheat": "fast_preheat", + "scheduledEnd": "scheduled_end", + "stone_heating": "stone_heating", + "timeHoldPreheat": "time_hold_preheat", +} + MEDIA_PLAYBACK_STATE_MAP = { "fast forwarding": "fast_forwarding", } @@ -480,6 +488,27 @@ CAPABILITY_TO_SENSORS: dict[ SmartThingsSensorEntityDescription( key=Attribute.OVEN_JOB_STATE, translation_key="oven_job_state", + options=[ + "cleaning", + "cooking", + "cooling", + "draining", + "preheat", + "ready", + "rinsing", + "finished", + "scheduled_start", + "warming", + "defrosting", + "sensing", + "searing", + "fast_preheat", + "scheduled_end", + "stone_heating", + "time_hold_preheat", + ], + device_class=SensorDeviceClass.ENUM, + value_fn=lambda value: OVEN_JOB_STATE_MAP.get(value, value), ) ], Attribute.COMPLETION_TIME: [ diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index b88c27fad77..5012cc9efa3 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -207,7 +207,26 @@ "name": "[%key:component::smartthings::entity::sensor::dishwasher_machine_state::name%]" }, "oven_job_state": { - "name": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::name%]" + "name": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::name%]", + "state": { + "cleaning": "Cleaning", + "cooking": "Cooking", + "cooling": "Cooling", + "draining": "Draining", + "preheat": "Preheat", + "ready": "Ready", + "rinsing": "Rinsing", + "finished": "Finished", + "scheduled_start": "Scheduled start", + "warming": "Warming", + "defrosting": "Defrosting", + "sensing": "Sensing", + "searing": "Searing", + "fast_preheat": "Fast preheat", + "scheduled_end": "Scheduled end", + "stone_heating": "Stone heating", + "time_hold_preheat": "Time hold preheat" + } }, "oven_setpoint": { "name": "Set point" diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index 41691d26435..dde39d8b515 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -1758,7 +1758,27 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'options': list([ + 'cleaning', + 'cooking', + 'cooling', + 'draining', + 'preheat', + 'ready', + 'rinsing', + 'finished', + 'scheduled_start', + 'warming', + 'defrosting', + 'sensing', + 'searing', + 'fast_preheat', + 'scheduled_end', + 'stone_heating', + 'time_hold_preheat', + ]), + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -1776,7 +1796,7 @@ 'name': None, 'options': dict({ }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Job state', 'platform': 'smartthings', @@ -1790,7 +1810,27 @@ # name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_job_state-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'enum', 'friendly_name': 'Microwave Job state', + 'options': list([ + 'cleaning', + 'cooking', + 'cooling', + 'draining', + 'preheat', + 'ready', + 'rinsing', + 'finished', + 'scheduled_start', + 'warming', + 'defrosting', + 'sensing', + 'searing', + 'fast_preheat', + 'scheduled_end', + 'stone_heating', + 'time_hold_preheat', + ]), }), 'context': , 'entity_id': 'sensor.microwave_job_state', From 51099ae7d67a3074c552433409148ffca9e16445 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 26 Feb 2025 17:13:02 +0100 Subject: [PATCH 1857/3148] Add translatable states to oven machine state (#139358) --- homeassistant/components/smartthings/sensor.py | 2 ++ .../components/smartthings/strings.json | 7 ++++++- .../smartthings/snapshots/test_sensor.ambr | 16 ++++++++++++++-- 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index 91b9a09fd19..c05dd546623 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -482,6 +482,8 @@ CAPABILITY_TO_SENSORS: dict[ SmartThingsSensorEntityDescription( key=Attribute.MACHINE_STATE, translation_key="oven_machine_state", + options=["ready", "running", "paused"], + device_class=SensorDeviceClass.ENUM, ) ], Attribute.OVEN_JOB_STATE: [ diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index 5012cc9efa3..897d07961bb 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -204,7 +204,12 @@ } }, "oven_machine_state": { - "name": "[%key:component::smartthings::entity::sensor::dishwasher_machine_state::name%]" + "name": "[%key:component::smartthings::entity::sensor::dishwasher_machine_state::name%]", + "state": { + "ready": "Ready", + "running": "[%key:component::smartthings::entity::sensor::dishwasher_machine_state::state::run%]", + "paused": "[%key:common::state::paused%]" + } }, "oven_job_state": { "name": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::name%]", diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index dde39d8b515..1741e3ed2a1 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -1845,7 +1845,13 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'options': list([ + 'ready', + 'running', + 'paused', + ]), + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -1863,7 +1869,7 @@ 'name': None, 'options': dict({ }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Machine state', 'platform': 'smartthings', @@ -1877,7 +1883,13 @@ # name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_machine_state-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'enum', 'friendly_name': 'Microwave Machine state', + 'options': list([ + 'ready', + 'running', + 'paused', + ]), }), 'context': , 'entity_id': 'sensor.microwave_machine_state', From cadee73da869438aa3be3f7c48d0dbefb2d19525 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 26 Feb 2025 17:25:50 +0100 Subject: [PATCH 1858/3148] Add translatable states to robot cleaner movement in SmartThings (#139363) --- .../components/smartthings/sensor.py | 18 +++++++++++ .../components/smartthings/strings.json | 14 ++++++++- .../smartthings/snapshots/test_sensor.ambr | 30 +++++++++++++++++-- 3 files changed, 59 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index c05dd546623..c11ce51ceaa 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -61,6 +61,10 @@ MEDIA_PLAYBACK_STATE_MAP = { "fast forwarding": "fast_forwarding", } +ROBOT_CLEANER_MOVEMENT_MAP = { + "powerOff": "off", +} + OVEN_MODE = { "Conventional": "conventional", "Bake": "bake", @@ -625,6 +629,20 @@ CAPABILITY_TO_SENSORS: dict[ SmartThingsSensorEntityDescription( key=Attribute.ROBOT_CLEANER_MOVEMENT, translation_key="robot_cleaner_movement", + options=[ + "homing", + "idle", + "charging", + "alarm", + "off", + "reserve", + "point", + "after", + "cleaning", + "pause", + ], + device_class=SensorDeviceClass.ENUM, + value_fn=lambda value: ROBOT_CLEANER_MOVEMENT_MAP.get(value, value), ) ] }, diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index 897d07961bb..a5335be616e 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -255,7 +255,19 @@ "name": "Cleaning mode" }, "robot_cleaner_movement": { - "name": "Movement" + "name": "Movement", + "state": { + "homing": "Homing", + "idle": "[%key:common::state::idle%]", + "charging": "[%key:common::state::charging%]", + "alarm": "Alarm", + "off": "[%key:common::state::off%]", + "reserve": "Reserve", + "point": "Point", + "after": "After", + "cleaning": "Cleaning", + "pause": "[%key:common::state::paused%]" + } }, "robot_cleaner_turbo_mode": { "name": "Turbo mode" diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index 1741e3ed2a1..4db096fdb22 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -2563,7 +2563,20 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'options': list([ + 'homing', + 'idle', + 'charging', + 'alarm', + 'off', + 'reserve', + 'point', + 'after', + 'cleaning', + 'pause', + ]), + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -2581,7 +2594,7 @@ 'name': None, 'options': dict({ }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Movement', 'platform': 'smartthings', @@ -2595,7 +2608,20 @@ # name: test_all_entities[da_rvc_normal_000001][sensor.robot_vacuum_movement-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'enum', 'friendly_name': 'Robot vacuum Movement', + 'options': list([ + 'homing', + 'idle', + 'charging', + 'alarm', + 'off', + 'reserve', + 'point', + 'after', + 'cleaning', + 'pause', + ]), }), 'context': , 'entity_id': 'sensor.robot_vacuum_movement', From 5e5fd6a2f2810896d1e63457d6ba2d67c915639f Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 26 Feb 2025 17:33:13 +0100 Subject: [PATCH 1859/3148] Add translatable states to robot cleaner cleaning mode in SmartThings (#139362) * Add translatable states to robot cleaner cleaning mode in SmartThings * Update homeassistant/components/smartthings/strings.json * Update homeassistant/components/smartthings/strings.json --------- Co-authored-by: Josef Zweck --- .../components/smartthings/sensor.py | 2 ++ .../components/smartthings/strings.json | 10 ++++++++- .../smartthings/snapshots/test_sensor.ambr | 22 +++++++++++++++++-- 3 files changed, 31 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index c11ce51ceaa..f5c9fa823f0 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -620,6 +620,8 @@ CAPABILITY_TO_SENSORS: dict[ SmartThingsSensorEntityDescription( key=Attribute.ROBOT_CLEANER_CLEANING_MODE, translation_key="robot_cleaner_cleaning_mode", + options=["auto", "part", "repeat", "manual", "stop", "map"], + device_class=SensorDeviceClass.ENUM, entity_category=EntityCategory.DIAGNOSTIC, ) ], diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index a5335be616e..0fdb705091d 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -252,7 +252,15 @@ "name": "[%key:component::smartthings::entity::sensor::oven_setpoint::name%]" }, "robot_cleaner_cleaning_mode": { - "name": "Cleaning mode" + "name": "Cleaning mode", + "state": { + "auto": "Auto", + "part": "Partial", + "repeat": "Repeat", + "manual": "Manual", + "stop": "[%key:common::action::stop%]", + "map": "Map" + } }, "robot_cleaner_movement": { "name": "Movement", diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index 4db096fdb22..22a67538098 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -2516,7 +2516,16 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'options': list([ + 'auto', + 'part', + 'repeat', + 'manual', + 'stop', + 'map', + ]), + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -2534,7 +2543,7 @@ 'name': None, 'options': dict({ }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Cleaning mode', 'platform': 'smartthings', @@ -2548,7 +2557,16 @@ # name: test_all_entities[da_rvc_normal_000001][sensor.robot_vacuum_cleaning_mode-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'enum', 'friendly_name': 'Robot vacuum Cleaning mode', + 'options': list([ + 'auto', + 'part', + 'repeat', + 'manual', + 'stop', + 'map', + ]), }), 'context': , 'entity_id': 'sensor.robot_vacuum_cleaning_mode', From 92268f894a31b7d1e39009f198d36237a3882a06 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 26 Feb 2025 17:34:29 +0100 Subject: [PATCH 1860/3148] Add translatable states to washer machine state in SmartThings (#139366) --- homeassistant/components/smartthings/sensor.py | 6 +++++- .../components/smartthings/strings.json | 7 ++++++- .../smartthings/snapshots/test_sensor.ambr | 16 ++++++++++++++-- 3 files changed, 25 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index f5c9fa823f0..65c48d5e0fe 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -93,6 +93,8 @@ OVEN_MODE = { "Rinse": "rinse", } +WASHER_OPTIONS = ["pause", "run", "stop"] + def power_attributes(status: dict[str, Any]) -> dict[str, Any]: """Return the power attributes.""" @@ -242,7 +244,7 @@ CAPABILITY_TO_SENSORS: dict[ SmartThingsSensorEntityDescription( key=Attribute.MACHINE_STATE, translation_key="dishwasher_machine_state", - options=["pause", "run", "stop"], + options=WASHER_OPTIONS, device_class=SensorDeviceClass.ENUM, ) ], @@ -847,6 +849,8 @@ CAPABILITY_TO_SENSORS: dict[ SmartThingsSensorEntityDescription( key=Attribute.MACHINE_STATE, translation_key="washer_machine_state", + options=WASHER_OPTIONS, + device_class=SensorDeviceClass.ENUM, ) ], Attribute.WASHER_JOB_STATE: [ diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index 0fdb705091d..6c14d5c2a4d 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -326,7 +326,12 @@ "name": "Washer mode" }, "washer_machine_state": { - "name": "[%key:component::smartthings::entity::sensor::dishwasher_machine_state::name%]" + "name": "[%key:component::smartthings::entity::sensor::dishwasher_machine_state::name%]", + "state": { + "pause": "[%key:common::state::paused%]", + "run": "[%key:component::smartthings::entity::sensor::dishwasher_machine_state::state::run%]", + "stop": "[%key:component::smartthings::entity::sensor::dishwasher_machine_state::state::stop%]" + } }, "washer_job_state": { "name": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::name%]" diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index 22a67538098..87fe69b9640 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -3798,7 +3798,13 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'options': list([ + 'pause', + 'run', + 'stop', + ]), + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -3816,7 +3822,7 @@ 'name': None, 'options': dict({ }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Machine state', 'platform': 'smartthings', @@ -3830,7 +3836,13 @@ # name: test_all_entities[da_wm_wm_000001][sensor.washer_machine_state-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'enum', 'friendly_name': 'Washer Machine state', + 'options': list([ + 'pause', + 'run', + 'stop', + ]), }), 'context': , 'entity_id': 'sensor.washer_machine_state', From 468208502f58fb271885431a4de57d985b66a52a Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 26 Feb 2025 17:52:57 +0100 Subject: [PATCH 1861/3148] Add translatable states to smoke detector in SmartThings (#139365) --- homeassistant/components/smartthings/sensor.py | 2 ++ homeassistant/components/smartthings/strings.json | 7 ++++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index 65c48d5e0fe..c966899f8f9 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -684,6 +684,8 @@ CAPABILITY_TO_SENSORS: dict[ SmartThingsSensorEntityDescription( key=Attribute.SMOKE, translation_key="smoke_detector", + options=["detected", "clear", "tested"], + device_class=SensorDeviceClass.ENUM, ) ] }, diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index 6c14d5c2a4d..fb260d8f689 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -284,7 +284,12 @@ "name": "Link quality" }, "smoke_detector": { - "name": "Smoke detector" + "name": "Smoke detector", + "state": { + "detected": "[%key:component::smartthings::entity::sensor::carbon_monoxide_detector::state::detected%]", + "clear": "[%key:component::smartthings::entity::sensor::carbon_monoxide_detector::state::clear%]", + "tested": "[%key:component::smartthings::entity::sensor::carbon_monoxide_detector::state::tested%]" + } }, "thermostat_cooling_setpoint": { "name": "Cooling set point" From 3eea932b240ea170734fac999df7c11e0c4b82f5 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 26 Feb 2025 17:53:16 +0100 Subject: [PATCH 1862/3148] Add translatable states to robot cleaner turbo mode in SmartThings (#139364) --- homeassistant/components/smartthings/sensor.py | 9 +++++++++ .../components/smartthings/strings.json | 8 +++++++- .../smartthings/snapshots/test_sensor.ambr | 18 ++++++++++++++++-- 3 files changed, 32 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index c966899f8f9..5e07112e677 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -61,6 +61,10 @@ MEDIA_PLAYBACK_STATE_MAP = { "fast forwarding": "fast_forwarding", } +ROBOT_CLEANER_TURBO_MODE_STATE_MAP = { + "extraSilence": "extra_silence", +} + ROBOT_CLEANER_MOVEMENT_MAP = { "powerOff": "off", } @@ -655,6 +659,11 @@ CAPABILITY_TO_SENSORS: dict[ SmartThingsSensorEntityDescription( key=Attribute.ROBOT_CLEANER_TURBO_MODE, translation_key="robot_cleaner_turbo_mode", + options=["on", "off", "silence", "extra_silence"], + device_class=SensorDeviceClass.ENUM, + value_fn=lambda value: ROBOT_CLEANER_TURBO_MODE_STATE_MAP.get( + value, value + ), entity_category=EntityCategory.DIAGNOSTIC, ) ] diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index fb260d8f689..c17e63357ff 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -278,7 +278,13 @@ } }, "robot_cleaner_turbo_mode": { - "name": "Turbo mode" + "name": "Turbo mode", + "state": { + "on": "[%key:common::state::on%]", + "off": "[%key:common::state::off%]", + "silence": "Silent", + "extra_silence": "Extra silent" + } }, "link_quality": { "name": "Link quality" diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index 87fe69b9640..eecd801d062 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -2654,7 +2654,14 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'options': list([ + 'on', + 'off', + 'silence', + 'extra_silence', + ]), + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -2672,7 +2679,7 @@ 'name': None, 'options': dict({ }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Turbo mode', 'platform': 'smartthings', @@ -2686,7 +2693,14 @@ # name: test_all_entities[da_rvc_normal_000001][sensor.robot_vacuum_turbo_mode-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'enum', 'friendly_name': 'Robot vacuum Turbo mode', + 'options': list([ + 'on', + 'off', + 'silence', + 'extra_silence', + ]), }), 'context': , 'entity_id': 'sensor.robot_vacuum_turbo_mode', From 269482845150a4bea36ec9a3d221ccce6a835d4f Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 26 Feb 2025 18:07:56 +0100 Subject: [PATCH 1863/3148] Add translatable states to washer job state in SmartThings (#139368) * Add translatable states to washer job state in SmartThings * fix * Update homeassistant/components/smartthings/sensor.py --- .../components/smartthings/sensor.py | 30 +++++++++++- .../components/smartthings/strings.json | 22 ++++++++- .../smartthings/snapshots/test_sensor.ambr | 46 +++++++++++++++++-- 3 files changed, 91 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index 5e07112e677..e0fded8f801 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -43,6 +43,14 @@ THERMOSTAT_CAPABILITIES = { } JOB_STATE_MAP = { + "airWash": "air_wash", + "airwash": "air_wash", + "aIRinse": "ai_rinse", + "aISpin": "ai_spin", + "aIWash": "ai_wash", + "delayWash": "delay_wash", + "weightSensing": "weight_sensing", + "freezeProtection": "freeze_protection", "preDrain": "pre_drain", "preWash": "pre_wash", "wrinklePrevent": "wrinkle_prevent", @@ -257,7 +265,7 @@ CAPABILITY_TO_SENSORS: dict[ key=Attribute.DISHWASHER_JOB_STATE, translation_key="dishwasher_job_state", options=[ - "airwash", + "air_wash", "cooling", "drying", "finish", @@ -868,6 +876,26 @@ CAPABILITY_TO_SENSORS: dict[ SmartThingsSensorEntityDescription( key=Attribute.WASHER_JOB_STATE, translation_key="washer_job_state", + options=[ + "air_wash", + "ai_rinse", + "ai_spin", + "ai_wash", + "cooling", + "delay_wash", + "drying", + "finish", + "none", + "pre_wash", + "rinse", + "spin", + "wash", + "weight_sensing", + "wrinkle_prevent", + "freeze_protection", + ], + device_class=SensorDeviceClass.ENUM, + value_fn=lambda value: JOB_STATE_MAP.get(value, value), ) ], Attribute.COMPLETION_TIME: [ diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index c17e63357ff..3130c618a2c 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -85,7 +85,7 @@ "dishwasher_job_state": { "name": "Job state", "state": { - "airwash": "Airwash", + "air_wash": "Air wash", "cooling": "Cooling", "drying": "Drying", "finish": "Finish", @@ -345,7 +345,25 @@ } }, "washer_job_state": { - "name": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::name%]" + "name": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::name%]", + "state": { + "air_wash": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::state::air_wash%]", + "ai_rise": "AI rise", + "ai_spin": "AI spin", + "ai_wash": "AI wash", + "cooling": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::state::cooling%]", + "delay_wash": "Delay wash", + "drying": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::state::drying%]", + "finish": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::state::finish%]", + "none": "None", + "pre_wash": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::state::pre_wash%]", + "rinse": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::state::rinse%]", + "spin": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::state::spin%]", + "wash": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::state::wash%]", + "weight_sensing": "Weight sensing", + "wrinkle_prevent": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::state::wrinkle_prevent%]", + "freeze_protection": "Freeze protection" + } } } } diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index eecd801d062..5531e520ec7 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -2921,7 +2921,7 @@ 'area_id': None, 'capabilities': dict({ 'options': list([ - 'airwash', + 'air_wash', 'cooling', 'drying', 'finish', @@ -2967,7 +2967,7 @@ 'device_class': 'enum', 'friendly_name': 'Dishwasher Job state', 'options': list([ - 'airwash', + 'air_wash', 'cooling', 'drying', 'finish', @@ -3765,7 +3765,26 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'options': list([ + 'air_wash', + 'ai_rinse', + 'ai_spin', + 'ai_wash', + 'cooling', + 'delay_wash', + 'drying', + 'finish', + 'none', + 'pre_wash', + 'rinse', + 'spin', + 'wash', + 'weight_sensing', + 'wrinkle_prevent', + 'freeze_protection', + ]), + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -3783,7 +3802,7 @@ 'name': None, 'options': dict({ }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Job state', 'platform': 'smartthings', @@ -3797,7 +3816,26 @@ # name: test_all_entities[da_wm_wm_000001][sensor.washer_job_state-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'enum', 'friendly_name': 'Washer Job state', + 'options': list([ + 'air_wash', + 'ai_rinse', + 'ai_spin', + 'ai_wash', + 'cooling', + 'delay_wash', + 'drying', + 'finish', + 'none', + 'pre_wash', + 'rinse', + 'spin', + 'wash', + 'weight_sensing', + 'wrinkle_prevent', + 'freeze_protection', + ]), }), 'context': , 'entity_id': 'sensor.washer_job_state', From 5be7f491469c7549be1c234e34dcb11dd14b4f7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Wed, 26 Feb 2025 18:11:40 +0100 Subject: [PATCH 1864/3148] Improve Home Connect oven cavity temperature sensor (#139355) * Improve oven cavity temperature translation * Fetch cavity temperature unit * Handle generic Home Connect error * Improve test clarity --- .../components/home_connect/const.py | 9 ++ .../components/home_connect/number.py | 9 +- .../components/home_connect/sensor.py | 30 ++++++- .../components/home_connect/strings.json | 4 +- tests/components/home_connect/test_sensor.py | 83 +++++++++++++++++++ 5 files changed, 124 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/home_connect/const.py b/homeassistant/components/home_connect/const.py index 692a5e91851..66c635f5d95 100644 --- a/homeassistant/components/home_connect/const.py +++ b/homeassistant/components/home_connect/const.py @@ -4,6 +4,8 @@ from typing import cast from aiohomeconnect.model import EventKey, OptionKey, ProgramKey, SettingKey, StatusKey +from homeassistant.const import UnitOfTemperature, UnitOfTime, UnitOfVolume + from .utils import bsh_key_to_translation_key DOMAIN = "home_connect" @@ -21,6 +23,13 @@ APPLIANCES_WITH_PROGRAMS = ( "WasherDryer", ) +UNIT_MAP = { + "seconds": UnitOfTime.SECONDS, + "ml": UnitOfVolume.MILLILITERS, + "°C": UnitOfTemperature.CELSIUS, + "°F": UnitOfTemperature.FAHRENHEIT, +} + BSH_POWER_ON = "BSH.Common.EnumType.PowerState.On" BSH_POWER_OFF = "BSH.Common.EnumType.PowerState.Off" diff --git a/homeassistant/components/home_connect/number.py b/homeassistant/components/home_connect/number.py index 404f063946c..cef35005b32 100644 --- a/homeassistant/components/home_connect/number.py +++ b/homeassistant/components/home_connect/number.py @@ -11,7 +11,6 @@ from homeassistant.components.number import ( NumberEntity, NumberEntityDescription, ) -from homeassistant.const import UnitOfTemperature, UnitOfTime, UnitOfVolume from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -23,6 +22,7 @@ from .const import ( SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID, SVE_TRANSLATION_PLACEHOLDER_KEY, SVE_TRANSLATION_PLACEHOLDER_VALUE, + UNIT_MAP, ) from .coordinator import HomeConnectApplianceData, HomeConnectConfigEntry from .entity import HomeConnectEntity, HomeConnectOptionEntity @@ -32,13 +32,6 @@ _LOGGER = logging.getLogger(__name__) PARALLEL_UPDATES = 1 -UNIT_MAP = { - "seconds": UnitOfTime.SECONDS, - "ml": UnitOfVolume.MILLILITERS, - "°C": UnitOfTemperature.CELSIUS, - "°F": UnitOfTemperature.FAHRENHEIT, -} - NUMBERS = ( NumberEntityDescription( key=SettingKey.REFRIGERATION_FRIDGE_FREEZER_SETPOINT_TEMPERATURE_REFRIGERATOR, diff --git a/homeassistant/components/home_connect/sensor.py b/homeassistant/components/home_connect/sensor.py index 3f85bc3404c..924744ded56 100644 --- a/homeassistant/components/home_connect/sensor.py +++ b/homeassistant/components/home_connect/sensor.py @@ -1,10 +1,12 @@ """Provides a sensor for Home Connect.""" +import contextlib from dataclasses import dataclass from datetime import timedelta from typing import cast from aiohomeconnect.model import EventKey, StatusKey +from aiohomeconnect.model.error import HomeConnectError from homeassistant.components.sensor import ( SensorDeviceClass, @@ -23,6 +25,7 @@ from .const import ( BSH_OPERATION_STATE_FINISHED, BSH_OPERATION_STATE_PAUSE, BSH_OPERATION_STATE_RUN, + UNIT_MAP, ) from .coordinator import HomeConnectApplianceData, HomeConnectConfigEntry from .entity import HomeConnectEntity @@ -40,6 +43,7 @@ class HomeConnectSensorEntityDescription( default_value: str | None = None appliance_types: tuple[str, ...] | None = None + fetch_unit: bool = False BSH_PROGRAM_SENSORS = ( @@ -183,7 +187,8 @@ SENSORS = ( key=StatusKey.COOKING_OVEN_CURRENT_CAVITY_TEMPERATURE, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, - translation_key="current_cavity_temperature", + translation_key="oven_current_cavity_temperature", + fetch_unit=True, ), ) @@ -318,6 +323,29 @@ class HomeConnectSensor(HomeConnectEntity, SensorEntity): case _: self._attr_native_value = status + async def async_added_to_hass(self) -> None: + """When entity is added to hass.""" + await super().async_added_to_hass() + if self.entity_description.fetch_unit: + data = self.appliance.status[cast(StatusKey, self.bsh_key)] + if data.unit: + self._attr_native_unit_of_measurement = UNIT_MAP.get( + data.unit, data.unit + ) + else: + await self.fetch_unit() + + async def fetch_unit(self) -> None: + """Fetch the unit of measurement.""" + with contextlib.suppress(HomeConnectError): + data = await self.coordinator.client.get_status_value( + self.appliance.info.ha_id, status_key=cast(StatusKey, self.bsh_key) + ) + if data.unit: + self._attr_native_unit_of_measurement = UNIT_MAP.get( + data.unit, data.unit + ) + class HomeConnectProgramSensor(HomeConnectSensor): """Sensor class for Home Connect sensors that reports information related to the running program.""" diff --git a/homeassistant/components/home_connect/strings.json b/homeassistant/components/home_connect/strings.json index 4fabd1e1c50..92b59919583 100644 --- a/homeassistant/components/home_connect/strings.json +++ b/homeassistant/components/home_connect/strings.json @@ -1529,8 +1529,8 @@ "map3": "Map 3" } }, - "current_cavity_temperature": { - "name": "Current cavity temperature" + "oven_current_cavity_temperature": { + "name": "Current oven cavity temperature" }, "freezer_door_alarm": { "name": "Freezer door alarm", diff --git a/tests/components/home_connect/test_sensor.py b/tests/components/home_connect/test_sensor.py index 1ec137b95be..31fc9ea6d3f 100644 --- a/tests/components/home_connect/test_sensor.py +++ b/tests/components/home_connect/test_sensor.py @@ -5,6 +5,7 @@ from unittest.mock import AsyncMock, MagicMock from aiohomeconnect.model import ( ArrayOfEvents, + ArrayOfStatus, Event, EventKey, EventMessage, @@ -565,3 +566,85 @@ async def test_sensors_states( ) await hass.async_block_till_done() assert hass.states.is_state(entity_id, expected) + + +@pytest.mark.parametrize( + ( + "appliance_ha_id", + "entity_id", + "status_key", + "unit_get_status", + "unit_get_status_value", + "get_status_value_call_count", + ), + [ + ( + "Oven", + "sensor.oven_current_oven_cavity_temperature", + StatusKey.COOKING_OVEN_CURRENT_CAVITY_TEMPERATURE, + "°C", + None, + 0, + ), + ( + "Oven", + "sensor.oven_current_oven_cavity_temperature", + StatusKey.COOKING_OVEN_CURRENT_CAVITY_TEMPERATURE, + None, + "°C", + 1, + ), + ], + indirect=["appliance_ha_id"], +) +async def test_sensor_unit_fetching( + appliance_ha_id: str, + entity_id: str, + status_key: StatusKey, + unit_get_status: str | None, + unit_get_status_value: str | None, + get_status_value_call_count: int, + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, +) -> None: + """Test that the sensor entities are capable of fetching units.""" + + async def get_status_mock(ha_id: str) -> ArrayOfStatus: + if ha_id != appliance_ha_id: + return ArrayOfStatus([]) + return ArrayOfStatus( + [ + Status( + key=status_key, + raw_key=status_key.value, + value=0, + unit=unit_get_status, + ) + ] + ) + + client.get_status = AsyncMock(side_effect=get_status_mock) + client.get_status_value = AsyncMock( + return_value=Status( + key=status_key, + raw_key=status_key.value, + value=0, + unit=unit_get_status_value, + ) + ) + + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + entity_state = hass.states.get(entity_id) + assert entity_state + assert ( + entity_state.attributes["unit_of_measurement"] == unit_get_status + or unit_get_status_value + ) + + assert client.get_status_value.call_count == get_status_value_call_count From 561b3ae21b2170d80ab70f3ee86bf994dec02e26 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 26 Feb 2025 18:14:59 +0100 Subject: [PATCH 1865/3148] Add translatable states to dryer machine state in Smartthings (#139369) --- homeassistant/components/smartthings/sensor.py | 2 ++ .../components/smartthings/strings.json | 7 ++++++- .../smartthings/snapshots/test_sensor.ambr | 16 ++++++++++++++-- 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index e0fded8f801..8d53b830707 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -304,6 +304,8 @@ CAPABILITY_TO_SENSORS: dict[ SmartThingsSensorEntityDescription( key=Attribute.MACHINE_STATE, translation_key="dryer_machine_state", + options=WASHER_OPTIONS, + device_class=SensorDeviceClass.ENUM, ) ], Attribute.DRYER_JOB_STATE: [ diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index 3130c618a2c..40e14fc1b51 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -104,7 +104,12 @@ "name": "Dryer mode" }, "dryer_machine_state": { - "name": "[%key:component::smartthings::entity::sensor::dishwasher_machine_state::name%]" + "name": "[%key:component::smartthings::entity::sensor::dishwasher_machine_state::name%]", + "state": { + "pause": "[%key:common::state::paused%]", + "run": "[%key:component::smartthings::entity::sensor::dishwasher_machine_state::state::run%]", + "stop": "[%key:component::smartthings::entity::sensor::dishwasher_machine_state::state::stop%]" + } }, "dryer_job_state": { "name": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::name%]" diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index 5531e520ec7..122ced1eb6f 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -3408,7 +3408,13 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'options': list([ + 'pause', + 'run', + 'stop', + ]), + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -3426,7 +3432,7 @@ 'name': None, 'options': dict({ }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Machine state', 'platform': 'smartthings', @@ -3440,7 +3446,13 @@ # name: test_all_entities[da_wm_wd_000001][sensor.dryer_machine_state-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'enum', 'friendly_name': 'Dryer Machine state', + 'options': list([ + 'pause', + 'run', + 'stop', + ]), }), 'context': , 'entity_id': 'sensor.dryer_machine_state', From 25ee2e58a5a34e28a51c358cb8d5affcc9483e56 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 26 Feb 2025 18:15:14 +0100 Subject: [PATCH 1866/3148] Add translatable states to dryer job state in SmartThings (#139370) * Add translatable states to washer job state in SmartThings * Add translatable states to dryer job state in Smartthings * fix * fix --- .../components/smartthings/sensor.py | 23 +++++++++++ .../components/smartthings/strings.json | 19 ++++++++- .../smartthings/snapshots/test_sensor.ambr | 40 ++++++++++++++++++- 3 files changed, 79 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index 8d53b830707..d7aaaaa84c5 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -48,6 +48,10 @@ JOB_STATE_MAP = { "aIRinse": "ai_rinse", "aISpin": "ai_spin", "aIWash": "ai_wash", + "aIDrying": "ai_drying", + "internalCare": "internal_care", + "continuousDehumidifying": "continuous_dehumidifying", + "thawingFrozenInside": "thawing_frozen_inside", "delayWash": "delay_wash", "weightSensing": "weight_sensing", "freezeProtection": "freeze_protection", @@ -312,6 +316,25 @@ CAPABILITY_TO_SENSORS: dict[ SmartThingsSensorEntityDescription( key=Attribute.DRYER_JOB_STATE, translation_key="dryer_job_state", + options=[ + "cooling", + "delay_wash", + "drying", + "finished", + "none", + "refreshing", + "weight_sensing", + "wrinkle_prevent", + "dehumidifying", + "ai_drying", + "sanitizing", + "internal_care", + "freeze_protection", + "continuous_dehumidifying", + "thawing_frozen_inside", + ], + device_class=SensorDeviceClass.ENUM, + value_fn=lambda value: JOB_STATE_MAP.get(value, value), ) ], Attribute.COMPLETION_TIME: [ diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index 40e14fc1b51..9a757b4e9e8 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -112,7 +112,24 @@ } }, "dryer_job_state": { - "name": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::name%]" + "name": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::name%]", + "state": { + "cooling": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::state::cooling%]", + "delay_wash": "[%key:component::smartthings::entity::sensor::washer_job_state::state::delay_wash%]", + "drying": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::state::drying%]", + "finished": "[%key:component::smartthings::entity::sensor::oven_job_state::state::finished%]", + "none": "[%key:component::smartthings::entity::sensor::washer_job_state::state::none%]", + "refreshing": "Refreshing", + "weight_sensing": "[%key:component::smartthings::entity::sensor::washer_job_state::state::weight_sensing%]", + "wrinkle_prevent": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::state::wrinkle_prevent%]", + "dehumidifying": "Dehumidifying", + "ai_drying": "AI drying", + "sanitizing": "Sanitizing", + "internal_care": "Internal care", + "freeze_protection": "Freeze protection", + "continuous_dehumidifying": "Continuous dehumidifying", + "thawing_frozen_inside": "Thawing frozen inside" + } }, "equivalent_carbon_dioxide": { "name": "Equivalent carbon dioxide" diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index 122ced1eb6f..f487ff632a1 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -3361,7 +3361,25 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'options': list([ + 'cooling', + 'delay_wash', + 'drying', + 'finished', + 'none', + 'refreshing', + 'weight_sensing', + 'wrinkle_prevent', + 'dehumidifying', + 'ai_drying', + 'sanitizing', + 'internal_care', + 'freeze_protection', + 'continuous_dehumidifying', + 'thawing_frozen_inside', + ]), + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -3379,7 +3397,7 @@ 'name': None, 'options': dict({ }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Job state', 'platform': 'smartthings', @@ -3393,7 +3411,25 @@ # name: test_all_entities[da_wm_wd_000001][sensor.dryer_job_state-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'enum', 'friendly_name': 'Dryer Job state', + 'options': list([ + 'cooling', + 'delay_wash', + 'drying', + 'finished', + 'none', + 'refreshing', + 'weight_sensing', + 'wrinkle_prevent', + 'dehumidifying', + 'ai_drying', + 'sanitizing', + 'internal_care', + 'freeze_protection', + 'continuous_dehumidifying', + 'thawing_frozen_inside', + ]), }), 'context': , 'entity_id': 'sensor.dryer_job_state', From 3a21c3617377d5581fbf1f9e38eaa66f7f45ad13 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 26 Feb 2025 18:19:28 +0100 Subject: [PATCH 1867/3148] Don't create entities for disabled capabilities in SmartThings (#139343) * Don't create entities for disabled capabilities in SmartThings * Fix * fix * fix --- .../components/smartthings/__init__.py | 28 +- .../smartthings/snapshots/test_cover.ambr | 49 -- .../smartthings/snapshots/test_sensor.ambr | 456 ------------------ 3 files changed, 26 insertions(+), 507 deletions(-) diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py index d580e36e45e..846170552e9 100644 --- a/homeassistant/components/smartthings/__init__.py +++ b/homeassistant/components/smartthings/__init__.py @@ -4,7 +4,7 @@ from __future__ import annotations from dataclasses import dataclass import logging -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, cast from aiohttp import ClientError from pysmartthings import ( @@ -93,7 +93,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: SmartThingsConfigEntry) try: devices = await client.get_devices() for device in devices: - status = await client.get_device_status(device.device_id) + status = process_status(await client.get_device_status(device.device_id)) device_status[device.device_id] = FullDevice(device=device, status=status) except SmartThingsAuthenticationFailedError as err: raise ConfigEntryAuthFailed from err @@ -143,3 +143,27 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) return True + + +def process_status( + status: dict[str, dict[Capability, dict[Attribute, Status]]], +) -> dict[str, dict[Capability, dict[Attribute, Status]]]: + """Remove disabled capabilities from status.""" + if (main_component := status.get("main")) is None or ( + disabled_capabilities_capability := main_component.get( + Capability.CUSTOM_DISABLED_CAPABILITIES + ) + ) is None: + return status + disabled_capabilities = cast( + list[Capability], + disabled_capabilities_capability[Attribute.DISABLED_CAPABILITIES].value, + ) + for capability in disabled_capabilities: + # We still need to make sure the climate entity can work without this capability + if ( + capability in main_component + and capability != Capability.DEMAND_RESPONSE_LOAD_CONTROL + ): + del main_component[capability] + return status diff --git a/tests/components/smartthings/snapshots/test_cover.ambr b/tests/components/smartthings/snapshots/test_cover.ambr index 102be416cea..aa928c09b7a 100644 --- a/tests/components/smartthings/snapshots/test_cover.ambr +++ b/tests/components/smartthings/snapshots/test_cover.ambr @@ -49,52 +49,3 @@ 'state': 'open', }) # --- -# name: test_all_entities[da_ks_microwave_0101x][cover.microwave-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'cover', - 'entity_category': None, - 'entity_id': 'cover.microwave', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': None, - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': , - 'translation_key': None, - 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[da_ks_microwave_0101x][cover.microwave-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'door', - 'friendly_name': 'Microwave', - 'supported_features': , - }), - 'context': , - 'entity_id': 'cover.microwave', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index f487ff632a1..778b05fa183 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -521,57 +521,6 @@ 'state': '15.0', }) # --- -# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_air_quality-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.ac_office_granit_air_quality', - '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': 'Air quality', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'air_quality', - 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d.airQuality', - 'unit_of_measurement': 'CAQI', - }) -# --- -# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_air_quality-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'AC Office Granit Air quality', - 'state_class': , - 'unit_of_measurement': 'CAQI', - }), - 'context': , - 'entity_id': 'sensor.ac_office_granit_air_quality', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- # name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_energy-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -780,110 +729,6 @@ 'state': '60', }) # --- -# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_pm10-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.ac_office_granit_pm10', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'PM10', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d.dustLevel', - 'unit_of_measurement': 'μg/m^3', - }) -# --- -# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_pm10-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'pm10', - 'friendly_name': 'AC Office Granit PM10', - 'state_class': , - 'unit_of_measurement': 'μg/m^3', - }), - 'context': , - 'entity_id': 'sensor.ac_office_granit_pm10', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_pm2_5-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.ac_office_granit_pm2_5', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'PM2.5', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d.fineDustLevel', - 'unit_of_measurement': 'μg/m^3', - }) -# --- -# name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_pm2_5-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'pm25', - 'friendly_name': 'AC Office Granit PM2.5', - 'state_class': , - 'unit_of_measurement': 'μg/m^3', - }), - 'context': , - 'entity_id': 'sensor.ac_office_granit_pm2_5', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- # name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_power-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1090,57 +935,6 @@ 'state': '100', }) # --- -# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_air_quality-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.aire_dormitorio_principal_air_quality', - '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': 'Air quality', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'air_quality', - 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e.airQuality', - 'unit_of_measurement': 'CAQI', - }) -# --- -# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_air_quality-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Aire Dormitorio Principal Air quality', - 'state_class': , - 'unit_of_measurement': 'CAQI', - }), - 'context': , - 'entity_id': 'sensor.aire_dormitorio_principal_air_quality', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- # name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_energy-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1349,157 +1143,6 @@ 'state': '42', }) # --- -# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_odor_sensor-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.aire_dormitorio_principal_odor_sensor', - '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': 'Odor sensor', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'odor_sensor', - 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e.odorLevel', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_odor_sensor-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Aire Dormitorio Principal Odor sensor', - }), - 'context': , - 'entity_id': 'sensor.aire_dormitorio_principal_odor_sensor', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_pm10-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.aire_dormitorio_principal_pm10', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'PM10', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e.dustLevel', - 'unit_of_measurement': 'µg/m³', - }) -# --- -# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_pm10-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'pm10', - 'friendly_name': 'Aire Dormitorio Principal PM10', - 'state_class': , - 'unit_of_measurement': 'µg/m³', - }), - 'context': , - 'entity_id': 'sensor.aire_dormitorio_principal_pm10', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_pm2_5-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.aire_dormitorio_principal_pm2_5', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'PM2.5', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e.fineDustLevel', - 'unit_of_measurement': 'µg/m³', - }) -# --- -# name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_pm2_5-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'pm25', - 'friendly_name': 'Aire Dormitorio Principal PM2.5', - 'state_class': , - 'unit_of_measurement': 'µg/m³', - }), - 'context': , - 'entity_id': 'sensor.aire_dormitorio_principal_pm2_5', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- # name: test_all_entities[da_ac_rac_01001][sensor.aire_dormitorio_principal_power-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2101,54 +1744,6 @@ 'state': '-17', }) # --- -# name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_cooling_set_point-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.refrigerator_cooling_set_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': 'Cooling set point', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'thermostat_cooling_setpoint', - 'unique_id': '7db87911-7dce-1cf2-7119-b953432a2f09.coolingSetpoint', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_cooling_set_point-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', - 'friendly_name': 'Refrigerator Cooling set point', - }), - 'context': , - 'entity_id': 'sensor.refrigerator_cooling_set_point', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- # name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_energy-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2411,57 +2006,6 @@ 'state': '0.0135559777781698', }) # --- -# name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_temperature-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.refrigerator_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': 'Temperature', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '7db87911-7dce-1cf2-7119-b953432a2f09.temperature', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_temperature-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', - 'friendly_name': 'Refrigerator Temperature', - 'state_class': , - }), - 'context': , - 'entity_id': 'sensor.refrigerator_temperature', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- # name: test_all_entities[da_rvc_normal_000001][sensor.robot_vacuum_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 2e972422c29fefe7bda97476eb87b0d931df9b8c Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 26 Feb 2025 18:19:45 +0100 Subject: [PATCH 1868/3148] Fix typo in SmartThing string (#139373) --- homeassistant/components/smartthings/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index 9a757b4e9e8..e5ffbe35e8b 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -370,7 +370,7 @@ "name": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::name%]", "state": { "air_wash": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::state::air_wash%]", - "ai_rise": "AI rise", + "ai_rinse": "AI rinse", "ai_spin": "AI spin", "ai_wash": "AI wash", "cooling": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::state::cooling%]", From 693584ce291b6a5272d381f6e081b38fcc3e7e1f Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 26 Feb 2025 18:23:01 +0100 Subject: [PATCH 1869/3148] Bump version to 2025.3.0b0 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 7775b618795..00a9cf3b25f 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -25,7 +25,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2025 MINOR_VERSION: Final = 3 -PATCH_VERSION: Final = "0.dev0" +PATCH_VERSION: Final = "0b0" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 13, 0) diff --git a/pyproject.toml b/pyproject.toml index a7e3917eb90..e5f5884945a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.3.0.dev0" +version = "2025.3.0b0" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 7f0db3181d13a2b80bfea7f3b3edc1604b180ed1 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 26 Feb 2025 19:54:29 +0100 Subject: [PATCH 1870/3148] Bump version to 2025.4.0 (#139381) --- .github/workflows/ci.yaml | 2 +- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 8745ab63470..6145e985ce3 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -40,7 +40,7 @@ env: CACHE_VERSION: 11 UV_CACHE_VERSION: 1 MYPY_CACHE_VERSION: 9 - HA_SHORT_VERSION: "2025.3" + HA_SHORT_VERSION: "2025.4" DEFAULT_PYTHON: "3.13" ALL_PYTHON_VERSIONS: "['3.13']" # 10.3 is the oldest supported version diff --git a/homeassistant/const.py b/homeassistant/const.py index 7775b618795..b9695c350a7 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -24,7 +24,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2025 -MINOR_VERSION: Final = 3 +MINOR_VERSION: Final = 4 PATCH_VERSION: Final = "0.dev0" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" diff --git a/pyproject.toml b/pyproject.toml index a7e3917eb90..eda2a495726 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.3.0.dev0" +version = "2025.4.0.dev0" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 9dbce6d904e8db6c19d7b440f7cbdbe9ec1ab287 Mon Sep 17 00:00:00 2001 From: fwestenberg <47930023+fwestenberg@users.noreply.github.com> Date: Wed, 26 Feb 2025 20:31:24 +0100 Subject: [PATCH 1871/3148] Bump stookwijzer==1.6.1 (#139380) --- homeassistant/components/stookwijzer/__init__.py | 8 ++++---- homeassistant/components/stookwijzer/config_flow.py | 6 +++--- homeassistant/components/stookwijzer/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/stookwijzer/conftest.py | 8 ++++---- tests/components/stookwijzer/test_config_flow.py | 6 +++--- tests/components/stookwijzer/test_init.py | 6 +++--- 8 files changed, 20 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/stookwijzer/__init__.py b/homeassistant/components/stookwijzer/__init__.py index a4a00e4d1b8..9adfc09de0e 100644 --- a/homeassistant/components/stookwijzer/__init__.py +++ b/homeassistant/components/stookwijzer/__init__.py @@ -42,12 +42,12 @@ async def async_migrate_entry( LOGGER.debug("Migrating from version %s", entry.version) if entry.version == 1: - longitude, latitude = await Stookwijzer.async_transform_coordinates( + xy = await Stookwijzer.async_transform_coordinates( entry.data[CONF_LOCATION][CONF_LATITUDE], entry.data[CONF_LOCATION][CONF_LONGITUDE], ) - if not longitude or not latitude: + if not xy: ir.async_create_issue( hass, DOMAIN, @@ -65,8 +65,8 @@ async def async_migrate_entry( entry, version=2, data={ - CONF_LATITUDE: latitude, - CONF_LONGITUDE: longitude, + CONF_LATITUDE: xy["x"], + CONF_LONGITUDE: xy["y"], }, ) diff --git a/homeassistant/components/stookwijzer/config_flow.py b/homeassistant/components/stookwijzer/config_flow.py index 52283e4842d..ff14bce26e6 100644 --- a/homeassistant/components/stookwijzer/config_flow.py +++ b/homeassistant/components/stookwijzer/config_flow.py @@ -25,14 +25,14 @@ class StookwijzerFlowHandler(ConfigFlow, domain=DOMAIN): """Handle a flow initialized by the user.""" errors = {} if user_input is not None: - longitude, latitude = await Stookwijzer.async_transform_coordinates( + xy = await Stookwijzer.async_transform_coordinates( user_input[CONF_LOCATION][CONF_LATITUDE], user_input[CONF_LOCATION][CONF_LONGITUDE], ) - if longitude and latitude: + if xy: return self.async_create_entry( title="Stookwijzer", - data={CONF_LATITUDE: latitude, CONF_LONGITUDE: longitude}, + data={CONF_LATITUDE: xy["x"], CONF_LONGITUDE: xy["y"]}, ) errors["base"] = "unknown" diff --git a/homeassistant/components/stookwijzer/manifest.json b/homeassistant/components/stookwijzer/manifest.json index 9b4cea567be..dd10f57f485 100644 --- a/homeassistant/components/stookwijzer/manifest.json +++ b/homeassistant/components/stookwijzer/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/stookwijzer", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["stookwijzer==1.6.0"] + "requirements": ["stookwijzer==1.6.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 082524036e8..dcda559d7d3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2808,7 +2808,7 @@ statsd==3.2.1 steamodd==4.21 # homeassistant.components.stookwijzer -stookwijzer==1.6.0 +stookwijzer==1.6.1 # homeassistant.components.streamlabswater streamlabswater==1.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8cac6cc79d0..5ed82bd81b0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2269,7 +2269,7 @@ statsd==3.2.1 steamodd==4.21 # homeassistant.components.stookwijzer -stookwijzer==1.6.0 +stookwijzer==1.6.1 # homeassistant.components.streamlabswater streamlabswater==1.0.1 diff --git a/tests/components/stookwijzer/conftest.py b/tests/components/stookwijzer/conftest.py index 95a60e623a3..40582dc4be3 100644 --- a/tests/components/stookwijzer/conftest.py +++ b/tests/components/stookwijzer/conftest.py @@ -70,10 +70,10 @@ def mock_stookwijzer() -> Generator[MagicMock]: new=stookwijzer_mock, ), ): - stookwijzer_mock.async_transform_coordinates.return_value = ( - 450000.123456789, - 200000.123456789, - ) + stookwijzer_mock.async_transform_coordinates.return_value = { + "x": 450000.123456789, + "y": 200000.123456789, + } client = stookwijzer_mock.return_value client.lki = 2 diff --git a/tests/components/stookwijzer/test_config_flow.py b/tests/components/stookwijzer/test_config_flow.py index 6dddf83c27a..060d2bdc26c 100644 --- a/tests/components/stookwijzer/test_config_flow.py +++ b/tests/components/stookwijzer/test_config_flow.py @@ -32,8 +32,8 @@ async def test_full_user_flow( assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "Stookwijzer" assert result["data"] == { - CONF_LATITUDE: 200000.123456789, - CONF_LONGITUDE: 450000.123456789, + CONF_LATITUDE: 450000.123456789, + CONF_LONGITUDE: 200000.123456789, } assert len(mock_setup_entry.mock_calls) == 1 @@ -47,7 +47,7 @@ async def test_connection_error( ) -> None: """Test user configuration flow while connection fails.""" original_return_value = mock_stookwijzer.async_transform_coordinates.return_value - mock_stookwijzer.async_transform_coordinates.return_value = (None, None) + mock_stookwijzer.async_transform_coordinates.return_value = None result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} diff --git a/tests/components/stookwijzer/test_init.py b/tests/components/stookwijzer/test_init.py index ddefb6be772..4306b9afc26 100644 --- a/tests/components/stookwijzer/test_init.py +++ b/tests/components/stookwijzer/test_init.py @@ -66,8 +66,8 @@ async def test_migrate_entry( assert mock_v1_config_entry.version == 2 assert mock_v1_config_entry.data == { - CONF_LATITUDE: 200000.123456789, - CONF_LONGITUDE: 450000.123456789, + CONF_LATITUDE: 450000.123456789, + CONF_LONGITUDE: 200000.123456789, } @@ -81,7 +81,7 @@ async def test_entry_migration_failure( assert mock_v1_config_entry.version == 1 # Failed getting the transformed coordinates - mock_stookwijzer.async_transform_coordinates.return_value = (None, None) + mock_stookwijzer.async_transform_coordinates.return_value = None mock_v1_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_v1_config_entry.entry_id) From 6d7dad41d9ac70b1991f6e8360d93b5a817deb9a Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 26 Feb 2025 20:31:45 +0100 Subject: [PATCH 1872/3148] Bump hatasmota to 0.10.0 (#139382) --- homeassistant/components/tasmota/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/tasmota/manifest.json b/homeassistant/components/tasmota/manifest.json index 783483c6ffd..2e0d8af2338 100644 --- a/homeassistant/components/tasmota/manifest.json +++ b/homeassistant/components/tasmota/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "loggers": ["hatasmota"], "mqtt": ["tasmota/discovery/#"], - "requirements": ["HATasmota==0.9.2"] + "requirements": ["HATasmota==0.10.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index dcda559d7d3..0fc22d7564b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -19,7 +19,7 @@ DoorBirdPy==3.0.8 HAP-python==4.9.2 # homeassistant.components.tasmota -HATasmota==0.9.2 +HATasmota==0.10.0 # homeassistant.components.mastodon Mastodon.py==1.8.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5ed82bd81b0..cb8d26677e2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -19,7 +19,7 @@ DoorBirdPy==3.0.8 HAP-python==4.9.2 # homeassistant.components.tasmota -HATasmota==0.9.2 +HATasmota==0.10.0 # homeassistant.components.mastodon Mastodon.py==1.8.1 From 42f55bf271ab872754a112d842e7edb54b05de78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Wed, 26 Feb 2025 21:02:00 +0100 Subject: [PATCH 1873/3148] Small improvements to Home Connect strings and icons (#139386) * Small improvements to Home Connect strings and icons * Fix test --- .../components/home_connect/icons.json | 17 +++++++++++++++++ .../components/home_connect/strings.json | 10 +++++----- tests/components/home_connect/test_entity.py | 2 +- 3 files changed, 23 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/home_connect/icons.json b/homeassistant/components/home_connect/icons.json index 651c00328b6..f781db3ab24 100644 --- a/homeassistant/components/home_connect/icons.json +++ b/homeassistant/components/home_connect/icons.json @@ -49,6 +49,23 @@ "default": "mdi:map-marker-remove-variant" } }, + "button": { + "open_door": { + "default": "mdi:door-open" + }, + "partly_open_door": { + "default": "mdi:door-open" + }, + "pause_program": { + "default": "mdi:pause" + }, + "resume_program": { + "default": "mdi:play" + }, + "stop_program": { + "default": "mdi:stop" + } + }, "sensor": { "operation_state": { "default": "mdi:state-machine", diff --git a/homeassistant/components/home_connect/strings.json b/homeassistant/components/home_connect/strings.json index 92b59919583..7b06128dbe6 100644 --- a/homeassistant/components/home_connect/strings.json +++ b/homeassistant/components/home_connect/strings.json @@ -354,7 +354,7 @@ "options": { "consumer_products_coffee_maker_enum_type_flow_rate_normal": "Normal", "consumer_products_coffee_maker_enum_type_flow_rate_intense": "Intense", - "consumer_products_coffee_maker_enum_type_flow_rate_intense_plus": "Intense plus" + "consumer_products_coffee_maker_enum_type_flow_rate_intense_plus": "Intense +" } }, "coffee_milk_ratio": { @@ -410,7 +410,7 @@ "laundry_care_dryer_enum_type_drying_target_iron_dry": "Iron dry", "laundry_care_dryer_enum_type_drying_target_gentle_dry": "Gentle dry", "laundry_care_dryer_enum_type_drying_target_cupboard_dry": "Cupboard dry", - "laundry_care_dryer_enum_type_drying_target_cupboard_dry_plus": "Cupboard dry plus", + "laundry_care_dryer_enum_type_drying_target_cupboard_dry_plus": "Cupboard dry +", "laundry_care_dryer_enum_type_drying_target_extra_dry": "Extra dry" } }, @@ -592,7 +592,7 @@ "description": "Defines if the program sequence is optimized with a special drying cycle to ensure more shine on glasses and plastic items." }, "dishcare_dishwasher_option_vario_speed_plus": { - "name": "Vario speed plus", + "name": "Vario speed +", "description": "Defines if the program run time is reduced by up to 66% with the usual optimum cleaning and drying." }, "dishcare_dishwasher_option_silence_on_demand": { @@ -608,7 +608,7 @@ "description": "Defines if improved drying for glasses and plasticware is enabled." }, "dishcare_dishwasher_option_hygiene_plus": { - "name": "Hygiene plus", + "name": "Hygiene +", "description": "Defines if the cleaning is done with increased temperature. This ensures maximum hygienic cleanliness for regular use." }, "dishcare_dishwasher_option_eco_dry": { @@ -1462,7 +1462,7 @@ "inactive": "Inactive", "ready": "Ready", "delayedstart": "Delayed start", - "run": "Run", + "run": "Running", "pause": "[%key:common::state::paused%]", "actionrequired": "Action required", "finished": "Finished", diff --git a/tests/components/home_connect/test_entity.py b/tests/components/home_connect/test_entity.py index f173cda0b0c..2422cbe547c 100644 --- a/tests/components/home_connect/test_entity.py +++ b/tests/components/home_connect/test_entity.py @@ -85,7 +85,7 @@ def platforms() -> list[str]: [False, True, True], ( OptionKey.DISHCARE_DISHWASHER_HYGIENE_PLUS, - "switch.dishwasher_hygiene_plus", + "switch.dishwasher_hygiene", ), (OptionKey.DISHCARE_DISHWASHER_EXTRA_DRY, "switch.dishwasher_extra_dry"), ) From f3fb7cd8e83de7b7910dddc647f13d73c14cb481 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Wed, 26 Feb 2025 14:14:03 -0600 Subject: [PATCH 1874/3148] Bump intents to 2025.2.26 (#139387) --- homeassistant/components/conversation/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- script/hassfest/docker/Dockerfile | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/conversation/manifest.json b/homeassistant/components/conversation/manifest.json index 2d4a8053d75..c4f1860eed6 100644 --- a/homeassistant/components/conversation/manifest.json +++ b/homeassistant/components/conversation/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/conversation", "integration_type": "system", "quality_scale": "internal", - "requirements": ["hassil==2.2.3", "home-assistant-intents==2025.2.5"] + "requirements": ["hassil==2.2.3", "home-assistant-intents==2025.2.26"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index b248be0eb96..c49580ae47b 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -38,7 +38,7 @@ hass-nabucasa==0.92.0 hassil==2.2.3 home-assistant-bluetooth==1.13.1 home-assistant-frontend==20250226.0 -home-assistant-intents==2025.2.5 +home-assistant-intents==2025.2.26 httpx==0.28.1 ifaddr==0.2.0 Jinja2==3.1.5 diff --git a/requirements_all.txt b/requirements_all.txt index 0fc22d7564b..acac164af8e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1155,7 +1155,7 @@ holidays==0.67 home-assistant-frontend==20250226.0 # homeassistant.components.conversation -home-assistant-intents==2025.2.5 +home-assistant-intents==2025.2.26 # homeassistant.components.homematicip_cloud homematicip==1.1.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index cb8d26677e2..e37faf9f609 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -984,7 +984,7 @@ holidays==0.67 home-assistant-frontend==20250226.0 # homeassistant.components.conversation -home-assistant-intents==2025.2.5 +home-assistant-intents==2025.2.26 # homeassistant.components.homematicip_cloud homematicip==1.1.7 diff --git a/script/hassfest/docker/Dockerfile b/script/hassfest/docker/Dockerfile index b2e4005cf79..1f177643bd5 100644 --- a/script/hassfest/docker/Dockerfile +++ b/script/hassfest/docker/Dockerfile @@ -25,7 +25,7 @@ RUN --mount=from=ghcr.io/astral-sh/uv:0.6.1,source=/uv,target=/bin/uv \ -c /usr/src/homeassistant/homeassistant/package_constraints.txt \ -r /usr/src/homeassistant/requirements.txt \ stdlib-list==0.10.0 pipdeptree==2.25.0 tqdm==4.67.1 ruff==0.9.7 \ - PyTurboJPEG==1.7.5 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 hassil==2.2.3 home-assistant-intents==2025.2.5 mutagen==1.47.0 pymicro-vad==1.0.1 pyspeex-noise==1.0.2 + PyTurboJPEG==1.7.5 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 hassil==2.2.3 home-assistant-intents==2025.2.26 mutagen==1.47.0 pymicro-vad==1.0.1 pyspeex-noise==1.0.2 LABEL "name"="hassfest" LABEL "maintainer"="Home Assistant " From 036eef2b6bc3941d05a53a050089f2e558df9e51 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Wed, 26 Feb 2025 15:22:08 -0500 Subject: [PATCH 1875/3148] Bump ZHA to 0.0.51 (#139383) * Bump ZHA to 0.0.51 * Fix unit tests not accounting for primary entities --- homeassistant/components/zha/entity.py | 4 ++++ homeassistant/components/zha/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../zha/snapshots/test_diagnostics.ambr | 11 +--------- tests/components/zha/test_sensor.py | 21 ++++++++++++------- tests/components/zha/test_websocket_api.py | 7 +++++-- 7 files changed, 26 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/zha/entity.py b/homeassistant/components/zha/entity.py index 499721722fa..e3339661d15 100644 --- a/homeassistant/components/zha/entity.py +++ b/homeassistant/components/zha/entity.py @@ -59,6 +59,10 @@ class ZHAEntity(LogMixin, RestoreEntity, Entity): def name(self) -> str | UndefinedType | None: """Return the name of the entity.""" meta = self.entity_data.entity.info_object + if meta.primary: + self._attr_name = None + return super().name + original_name = super().name if original_name not in (UNDEFINED, None) or meta.fallback_name is None: diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 25e4de77a32..0cc2524469e 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -21,7 +21,7 @@ "zha", "universal_silabs_flasher" ], - "requirements": ["zha==0.0.50"], + "requirements": ["zha==0.0.51"], "usb": [ { "vid": "10C4", diff --git a/requirements_all.txt b/requirements_all.txt index acac164af8e..70b8bf20e41 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3149,7 +3149,7 @@ zeroconf==0.145.1 zeversolar==0.3.2 # homeassistant.components.zha -zha==0.0.50 +zha==0.0.51 # homeassistant.components.zhong_hong zhong-hong-hvac==1.0.13 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e37faf9f609..f86e597f50c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2538,7 +2538,7 @@ zeroconf==0.145.1 zeversolar==0.3.2 # homeassistant.components.zha -zha==0.0.50 +zha==0.0.51 # homeassistant.components.zwave_js zwave-js-server-python==0.60.1 diff --git a/tests/components/zha/snapshots/test_diagnostics.ambr b/tests/components/zha/snapshots/test_diagnostics.ambr index 7a599b00a21..ba8aa9ea245 100644 --- a/tests/components/zha/snapshots/test_diagnostics.ambr +++ b/tests/components/zha/snapshots/test_diagnostics.ambr @@ -179,16 +179,7 @@ }), '0x0010': dict({ 'attribute': "ZCLAttributeDef(id=0x0010, name='cie_addr', type=, zcl_type=, access=, mandatory=True, is_manufacturer_specific=False)", - 'value': list([ - 50, - 79, - 50, - 2, - 0, - 141, - 21, - 0, - ]), + 'value': None, }), '0x0011': dict({ 'attribute': "ZCLAttributeDef(id=0x0011, name='zone_id', type=, zcl_type=, access=, mandatory=True, is_manufacturer_specific=False)", diff --git a/tests/components/zha/test_sensor.py b/tests/components/zha/test_sensor.py index 2d69cf1ff36..88fb9974c1b 100644 --- a/tests/components/zha/test_sensor.py +++ b/tests/components/zha/test_sensor.py @@ -30,6 +30,7 @@ from homeassistant.core import HomeAssistant from .common import send_attributes_report from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE +ENTITY_ID_NO_PREFIX = "sensor.fakemanufacturer_fakemodel" ENTITY_ID_PREFIX = "sensor.fakemanufacturer_fakemodel_{}" @@ -335,7 +336,7 @@ async def async_test_pi_heating_demand( "humidity", async_test_humidity, 1, - None, + {}, None, STATE_UNKNOWN, ), @@ -344,7 +345,7 @@ async def async_test_pi_heating_demand( "temperature", async_test_temperature, 1, - None, + {}, None, STATE_UNKNOWN, ), @@ -353,7 +354,7 @@ async def async_test_pi_heating_demand( "pressure", async_test_pressure, 1, - None, + {}, None, STATE_UNKNOWN, ), @@ -362,7 +363,7 @@ async def async_test_pi_heating_demand( "illuminance", async_test_illuminance, 1, - None, + {}, None, STATE_UNKNOWN, ), @@ -492,7 +493,7 @@ async def async_test_pi_heating_demand( "device_temperature", async_test_device_temperature, 1, - None, + {}, None, STATE_UNKNOWN, ), @@ -501,7 +502,7 @@ async def async_test_pi_heating_demand( "setpoint_change_source", async_test_setpoint_change_source, 10, - None, + {}, None, STATE_UNKNOWN, ), @@ -510,7 +511,7 @@ async def async_test_pi_heating_demand( "pi_heating_demand", async_test_pi_heating_demand, 10, - None, + {}, None, STATE_UNKNOWN, ), @@ -558,7 +559,6 @@ async def test_sensor( gateway.get_or_create_device(zigpy_device) await gateway.async_device_initialized(zigpy_device) await hass.async_block_till_done(wait_background_tasks=True) - entity_id = ENTITY_ID_PREFIX.format(entity_suffix) zigpy_device = zigpy_device_mock( { @@ -570,6 +570,11 @@ async def test_sensor( } ) + if hass.states.get(ENTITY_ID_NO_PREFIX): + entity_id = ENTITY_ID_NO_PREFIX + else: + entity_id = ENTITY_ID_PREFIX.format(entity_suffix) + assert hass.states.get(entity_id).state == initial_sensor_state # test sensor associated logic diff --git a/tests/components/zha/test_websocket_api.py b/tests/components/zha/test_websocket_api.py index f6afee9eb83..ae1ea90d1f9 100644 --- a/tests/components/zha/test_websocket_api.py +++ b/tests/components/zha/test_websocket_api.py @@ -420,8 +420,11 @@ async def test_list_groupable_devices( assert entity_reference[ATTR_NAME] is not None assert entity_reference["entity_id"] is not None - for entity_reference in endpoint["entities"]: - assert entity_reference["original_name"] is not None + if len(endpoint["entities"]) == 1: + assert endpoint["entities"][0]["original_name"] is None + else: + for entity_reference in endpoint["entities"]: + assert entity_reference["original_name"] is not None # Make sure there are no groupable devices when the device is unavailable # Make device unavailable From b505722f3807560cf41939ca2dd37a7fda29997e Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Thu, 27 Feb 2025 10:00:50 +0100 Subject: [PATCH 1876/3148] Bump onedrive to 0.0.12 (#139410) * Bump onedrive to 0.0.12 * Add alternative name --- homeassistant/components/onedrive/manifest.json | 2 +- homeassistant/components/onedrive/sensor.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/onedrive/manifest.json b/homeassistant/components/onedrive/manifest.json index 5ab16402cb8..31a1f2ccb06 100644 --- a/homeassistant/components/onedrive/manifest.json +++ b/homeassistant/components/onedrive/manifest.json @@ -9,5 +9,5 @@ "iot_class": "cloud_polling", "loggers": ["onedrive_personal_sdk"], "quality_scale": "platinum", - "requirements": ["onedrive-personal-sdk==0.0.11"] + "requirements": ["onedrive-personal-sdk==0.0.12"] } diff --git a/homeassistant/components/onedrive/sensor.py b/homeassistant/components/onedrive/sensor.py index 0ca2b166e3f..fa7c0b125fe 100644 --- a/homeassistant/components/onedrive/sensor.py +++ b/homeassistant/components/onedrive/sensor.py @@ -103,7 +103,7 @@ class OneDriveDriveStateSensor( self._attr_unique_id = f"{coordinator.data.id}_{description.key}" self._attr_device_info = DeviceInfo( entry_type=DeviceEntryType.SERVICE, - name=coordinator.data.name, + name=coordinator.data.name or coordinator.config_entry.title, identifiers={(DOMAIN, coordinator.data.id)}, manufacturer="Microsoft", model=f"OneDrive {coordinator.data.drive_type.value.capitalize()}", diff --git a/requirements_all.txt b/requirements_all.txt index 70b8bf20e41..d1186a9d1a7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1565,7 +1565,7 @@ omnilogic==0.4.5 ondilo==0.5.0 # homeassistant.components.onedrive -onedrive-personal-sdk==0.0.11 +onedrive-personal-sdk==0.0.12 # homeassistant.components.onvif onvif-zeep-async==3.2.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f86e597f50c..2ee967f69ac 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1313,7 +1313,7 @@ omnilogic==0.4.5 ondilo==0.5.0 # homeassistant.components.onedrive -onedrive-personal-sdk==0.0.11 +onedrive-personal-sdk==0.0.12 # homeassistant.components.onvif onvif-zeep-async==3.2.5 From 2150a668b0bc0bc0a22e3d7c353f06b70a822424 Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Thu, 27 Feb 2025 10:17:57 +0100 Subject: [PATCH 1877/3148] Add reauthentication to azure_storage (#139411) * Add reauthentication to azure_storage * update docstring --- .../components/azure_storage/__init__.py | 8 ++- .../components/azure_storage/config_flow.py | 70 ++++++++++++++++--- .../azure_storage/quality_scale.yaml | 2 +- .../components/azure_storage/strings.json | 13 +++- .../azure_storage/test_config_flow.py | 61 ++++++++++++++++ 5 files changed, 140 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/azure_storage/__init__.py b/homeassistant/components/azure_storage/__init__.py index 873a9ab90ca..f22e7b70c12 100644 --- a/homeassistant/components/azure_storage/__init__.py +++ b/homeassistant/components/azure_storage/__init__.py @@ -13,7 +13,11 @@ from azure.storage.blob.aio import ContainerClient from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + ConfigEntryError, + ConfigEntryNotReady, +) from homeassistant.helpers.aiohttp_client import async_create_clientsession from .const import ( @@ -52,7 +56,7 @@ async def async_setup_entry( translation_placeholders={CONF_ACCOUNT_NAME: entry.data[CONF_ACCOUNT_NAME]}, ) from err except ClientAuthenticationError as err: - raise ConfigEntryError( + raise ConfigEntryAuthFailed( translation_domain=DOMAIN, translation_key="invalid_auth", translation_placeholders={CONF_ACCOUNT_NAME: entry.data[CONF_ACCOUNT_NAME]}, diff --git a/homeassistant/components/azure_storage/config_flow.py b/homeassistant/components/azure_storage/config_flow.py index e5b1214fa5b..c98576af5d1 100644 --- a/homeassistant/components/azure_storage/config_flow.py +++ b/homeassistant/components/azure_storage/config_flow.py @@ -1,5 +1,6 @@ """Config flow for Azure Storage integration.""" +from collections.abc import Mapping import logging from typing import Any @@ -26,6 +27,26 @@ _LOGGER = logging.getLogger(__name__) class AzureStorageConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for azure storage.""" + def get_account_url(self, account_name: str) -> str: + """Get the account URL.""" + return f"https://{account_name}.blob.core.windows.net/" + + async def validate_config( + self, container_client: ContainerClient + ) -> dict[str, str]: + """Validate the configuration.""" + errors: dict[str, str] = {} + try: + await container_client.exists() + except ResourceNotFoundError: + errors["base"] = "cannot_connect" + except ClientAuthenticationError: + errors[CONF_STORAGE_ACCOUNT_KEY] = "invalid_auth" + except Exception: + _LOGGER.exception("Unknown exception occurred") + errors["base"] = "unknown" + return errors + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -38,20 +59,13 @@ class AzureStorageConfigFlow(ConfigFlow, domain=DOMAIN): {CONF_ACCOUNT_NAME: user_input[CONF_ACCOUNT_NAME]} ) container_client = ContainerClient( - account_url=f"https://{user_input[CONF_ACCOUNT_NAME]}.blob.core.windows.net/", + account_url=self.get_account_url(user_input[CONF_ACCOUNT_NAME]), container_name=user_input[CONF_CONTAINER_NAME], credential=user_input[CONF_STORAGE_ACCOUNT_KEY], transport=AioHttpTransport(session=async_get_clientsession(self.hass)), ) - try: - await container_client.exists() - except ResourceNotFoundError: - errors["base"] = "cannot_connect" - except ClientAuthenticationError: - errors[CONF_STORAGE_ACCOUNT_KEY] = "invalid_auth" - except Exception: - _LOGGER.exception("Unknown exception occurred") - errors["base"] = "unknown" + errors = await self.validate_config(container_client) + if not errors: return self.async_create_entry( title=f"{user_input[CONF_ACCOUNT_NAME]}/{user_input[CONF_CONTAINER_NAME]}", @@ -70,3 +84,39 @@ class AzureStorageConfigFlow(ConfigFlow, domain=DOMAIN): ), errors=errors, ) + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Perform reauth upon an API authentication error.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm reauth dialog.""" + errors: dict[str, str] = {} + reauth_entry = self._get_reauth_entry() + + if user_input is not None: + container_client = ContainerClient( + account_url=self.get_account_url(reauth_entry.data[CONF_ACCOUNT_NAME]), + container_name=reauth_entry.data[CONF_CONTAINER_NAME], + credential=user_input[CONF_STORAGE_ACCOUNT_KEY], + transport=AioHttpTransport(session=async_get_clientsession(self.hass)), + ) + errors = await self.validate_config(container_client) + if not errors: + return self.async_update_reload_and_abort( + reauth_entry, + data={**reauth_entry.data, **user_input}, + ) + return self.async_show_form( + step_id="reauth_confirm", + data_schema=vol.Schema( + { + vol.Required(CONF_STORAGE_ACCOUNT_KEY): str, + } + ), + errors=errors, + ) diff --git a/homeassistant/components/azure_storage/quality_scale.yaml b/homeassistant/components/azure_storage/quality_scale.yaml index 6b6f90de494..5b147dfe0e4 100644 --- a/homeassistant/components/azure_storage/quality_scale.yaml +++ b/homeassistant/components/azure_storage/quality_scale.yaml @@ -57,7 +57,7 @@ rules: status: exempt comment: | This integration does not have platforms. - reauthentication-flow: todo + reauthentication-flow: done test-coverage: done # Gold diff --git a/homeassistant/components/azure_storage/strings.json b/homeassistant/components/azure_storage/strings.json index 4bd4cb0dfba..5d39b54b8db 100644 --- a/homeassistant/components/azure_storage/strings.json +++ b/homeassistant/components/azure_storage/strings.json @@ -19,10 +19,21 @@ }, "description": "Set up an Azure (Blob) storage account to be used for backups.", "title": "Add Azure storage account" + }, + "reauth_confirm": { + "data": { + "storage_account_key": "[%key:component::azure_storage::config::step::user::data::storage_account_key%]" + }, + "data_description": { + "storage_account_key": "[%key:component::azure_storage::config::step::user::data_description::storage_account_key%]" + }, + "description": "Provide a new storage account key.", + "title": "Reauthenticate Azure storage account" } }, "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%]" } }, "issues": { diff --git a/tests/components/azure_storage/test_config_flow.py b/tests/components/azure_storage/test_config_flow.py index ed8bbed0718..d5c0726e94a 100644 --- a/tests/components/azure_storage/test_config_flow.py +++ b/tests/components/azure_storage/test_config_flow.py @@ -15,6 +15,7 @@ from homeassistant.config_entries import SOURCE_USER, ConfigFlowResult from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from . import setup_integration from .const import USER_INPUT from tests.common import MockConfigEntry @@ -111,3 +112,63 @@ async def test_abort_if_already_configured( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" + + +async def test_reauth_flow( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that the reauth flow works.""" + + await setup_integration(hass, mock_config_entry) + + result = await mock_config_entry.start_reauth_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_STORAGE_ACCOUNT_KEY: "new_key"} + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert mock_config_entry.data == { + **USER_INPUT, + CONF_STORAGE_ACCOUNT_KEY: "new_key", + } + + +async def test_reauth_flow_errors( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_client: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that the reauth flow works with an errors.""" + + await setup_integration(hass, mock_config_entry) + + mock_client.exists.side_effect = Exception() + + result = await mock_config_entry.start_reauth_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_STORAGE_ACCOUNT_KEY: "new_key"} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "unknown"} + + # fix the error and finish the flow successfully + mock_client.exists.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_STORAGE_ACCOUNT_KEY: "new_key"} + ) + assert result["reason"] == "reauth_successful" + assert mock_config_entry.data == { + **USER_INPUT, + CONF_STORAGE_ACCOUNT_KEY: "new_key", + } From 63daed0ed6c8765bfc2391b9b26dd17b02340c5c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 27 Feb 2025 10:43:13 +0100 Subject: [PATCH 1878/3148] Bump codecov/codecov-action from 5.3.1 to 5.4.0 (#139408) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 6145e985ce3..97986f26ee3 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1276,7 +1276,7 @@ jobs: pattern: coverage-* - name: Upload coverage to Codecov if: needs.info.outputs.test_full_suite == 'true' - uses: codecov/codecov-action@v5.3.1 + uses: codecov/codecov-action@v5.4.0 with: fail_ci_if_error: true flags: full-suite @@ -1415,7 +1415,7 @@ jobs: pattern: coverage-* - name: Upload coverage to Codecov if: needs.info.outputs.test_full_suite == 'false' - uses: codecov/codecov-action@v5.3.1 + uses: codecov/codecov-action@v5.4.0 with: fail_ci_if_error: true token: ${{ secrets.CODECOV_TOKEN }} From b1a70c86c3fda7dd042f4df32b689fd8db4d5945 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 27 Feb 2025 10:44:13 +0100 Subject: [PATCH 1879/3148] Bump docker/build-push-action from 6.14.0 to 6.15.0 (#139407) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/builder.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index 0ad4c510a55..df5d3eee6ae 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -509,7 +509,7 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Build Docker image - uses: docker/build-push-action@0adf9959216b96bec444f325f1e493d4aa344497 # v6.14.0 + uses: docker/build-push-action@471d1dc4e07e5cdedd4c2171150001c434f0b7a4 # v6.15.0 with: context: . # So action will not pull the repository again file: ./script/hassfest/docker/Dockerfile @@ -522,7 +522,7 @@ jobs: - name: Push Docker image if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true' id: push - uses: docker/build-push-action@0adf9959216b96bec444f325f1e493d4aa344497 # v6.14.0 + uses: docker/build-push-action@471d1dc4e07e5cdedd4c2171150001c434f0b7a4 # v6.15.0 with: context: . # So action will not pull the repository again file: ./script/hassfest/docker/Dockerfile From 8c98cede60fd1ab6e2ceb83f8d9f4ebfd2b639a6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 27 Feb 2025 10:44:50 +0100 Subject: [PATCH 1880/3148] Bump actions/attest-build-provenance from 2.2.0 to 2.2.1 (#139406) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/builder.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index df5d3eee6ae..ed5005584bd 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -531,7 +531,7 @@ jobs: - name: Generate artifact attestation if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true' - uses: actions/attest-build-provenance@520d128f165991a6c774bcb264f323e3d70747f4 # v2.2.0 + uses: actions/attest-build-provenance@f9eaf234fc1c2e333c1eca18177db0f44fa6ba52 # v2.2.1 with: subject-name: ${{ env.HASSFEST_IMAGE_NAME }} subject-digest: ${{ steps.push.outputs.digest }} From df59adf5d1bf37f752e54709f78016abc20f0a57 Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Thu, 27 Feb 2025 11:06:03 +0100 Subject: [PATCH 1881/3148] Add reconfiguration to azure_storage (#139414) * Add reauthentication to azure_storage * Add reconfigure to azure_storage * iqs * update string * ruff --- .../components/azure_storage/config_flow.py | 38 +++++++++++++++++++ .../azure_storage/quality_scale.yaml | 2 +- .../components/azure_storage/strings.json | 15 +++++++- .../azure_storage/test_config_flow.py | 24 ++++++++++++ 4 files changed, 77 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/azure_storage/config_flow.py b/homeassistant/components/azure_storage/config_flow.py index c98576af5d1..2862d290f95 100644 --- a/homeassistant/components/azure_storage/config_flow.py +++ b/homeassistant/components/azure_storage/config_flow.py @@ -120,3 +120,41 @@ class AzureStorageConfigFlow(ConfigFlow, domain=DOMAIN): ), errors=errors, ) + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Reconfigure the entry.""" + errors: dict[str, str] = {} + reconfigure_entry = self._get_reconfigure_entry() + + if user_input is not None: + container_client = ContainerClient( + account_url=self.get_account_url( + reconfigure_entry.data[CONF_ACCOUNT_NAME] + ), + container_name=user_input[CONF_CONTAINER_NAME], + credential=user_input[CONF_STORAGE_ACCOUNT_KEY], + transport=AioHttpTransport(session=async_get_clientsession(self.hass)), + ) + errors = await self.validate_config(container_client) + if not errors: + return self.async_update_reload_and_abort( + reconfigure_entry, + data={**reconfigure_entry.data, **user_input}, + ) + return self.async_show_form( + data_schema=vol.Schema( + { + vol.Required( + CONF_CONTAINER_NAME, + default=reconfigure_entry.data[CONF_CONTAINER_NAME], + ): str, + vol.Required( + CONF_STORAGE_ACCOUNT_KEY, + default=reconfigure_entry.data[CONF_STORAGE_ACCOUNT_KEY], + ): str, + } + ), + errors=errors, + ) diff --git a/homeassistant/components/azure_storage/quality_scale.yaml b/homeassistant/components/azure_storage/quality_scale.yaml index 5b147dfe0e4..6199ba514a3 100644 --- a/homeassistant/components/azure_storage/quality_scale.yaml +++ b/homeassistant/components/azure_storage/quality_scale.yaml @@ -121,7 +121,7 @@ rules: status: exempt comment: | This integration does not have entities. - reconfiguration-flow: todo + reconfiguration-flow: done repair-issues: done stale-devices: status: exempt diff --git a/homeassistant/components/azure_storage/strings.json b/homeassistant/components/azure_storage/strings.json index 5d39b54b8db..e9053f113cc 100644 --- a/homeassistant/components/azure_storage/strings.json +++ b/homeassistant/components/azure_storage/strings.json @@ -29,11 +29,24 @@ }, "description": "Provide a new storage account key.", "title": "Reauthenticate Azure storage account" + }, + "reconfigure": { + "data": { + "container_name": "[%key:component::azure_storage::config::step::user::data::container_name%]", + "storage_account_key": "[%key:component::azure_storage::config::step::user::data::storage_account_key%]" + }, + "data_description": { + "container_name": "[%key:component::azure_storage::config::step::user::data_description::container_name%]", + "storage_account_key": "[%key:component::azure_storage::config::step::user::data_description::storage_account_key%]" + }, + "description": "Change the settings of the Azure storage integration.", + "title": "Reconfigure Azure storage account" } }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", - "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" } }, "issues": { diff --git a/tests/components/azure_storage/test_config_flow.py b/tests/components/azure_storage/test_config_flow.py index d5c0726e94a..67dc44f9f2c 100644 --- a/tests/components/azure_storage/test_config_flow.py +++ b/tests/components/azure_storage/test_config_flow.py @@ -172,3 +172,27 @@ async def test_reauth_flow_errors( **USER_INPUT, CONF_STORAGE_ACCOUNT_KEY: "new_key", } + + +async def test_reconfigure_flow( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that the reconfigure flow works.""" + + mock_config_entry.add_to_hass(hass) + result = await mock_config_entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_CONTAINER_NAME: "new_container"} + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert mock_config_entry.data == { + **USER_INPUT, + CONF_CONTAINER_NAME: "new_container", + } From cc18ec2de8674eb4be213b6ae7dbf4d0681a3622 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Thu, 27 Feb 2025 12:00:14 +0100 Subject: [PATCH 1882/3148] Fix fetch options error for Home connect (#139392) * Handle errors when obtaining options definitions * Don't fetch program options if the program key is unknown * Test to ensure that available program endpoint is not called on unknown program --- .../components/home_connect/coordinator.py | 31 +++++--- .../home_connect/test_coordinator.py | 22 +++++- tests/components/home_connect/test_entity.py | 73 +++++++++++++++++++ 3 files changed, 113 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/home_connect/coordinator.py b/homeassistant/components/home_connect/coordinator.py index 80ae8173d86..d9200b282c9 100644 --- a/homeassistant/components/home_connect/coordinator.py +++ b/homeassistant/components/home_connect/coordinator.py @@ -440,13 +440,27 @@ class HomeConnectCoordinator( self, ha_id: str, program_key: ProgramKey ) -> dict[OptionKey, ProgramDefinitionOption]: """Get options with constraints for appliance.""" - return { - option.key: option - for option in ( - await self.client.get_available_program(ha_id, program_key=program_key) - ).options - or [] - } + if program_key is ProgramKey.UNKNOWN: + return {} + try: + return { + option.key: option + for option in ( + await self.client.get_available_program( + ha_id, program_key=program_key + ) + ).options + or [] + } + except HomeConnectError as error: + _LOGGER.debug( + "Error fetching options for %s: %s", + ha_id, + error + if isinstance(error, HomeConnectApiError) + else type(error).__name__, + ) + return {} async def update_options( self, ha_id: str, event_key: EventKey, program_key: ProgramKey @@ -456,8 +470,7 @@ class HomeConnectCoordinator( events = self.data[ha_id].events options_to_notify = options.copy() options.clear() - if program_key is not ProgramKey.UNKNOWN: - options.update(await self.get_options_definitions(ha_id, program_key)) + options.update(await self.get_options_definitions(ha_id, program_key)) for option in options.values(): option_value = option.constraints.default if option.constraints else None diff --git a/tests/components/home_connect/test_coordinator.py b/tests/components/home_connect/test_coordinator.py index 51f42a98f42..3dd9ffbe7c1 100644 --- a/tests/components/home_connect/test_coordinator.py +++ b/tests/components/home_connect/test_coordinator.py @@ -75,21 +75,35 @@ async def test_coordinator_update_failing_get_appliances( assert config_entry.state == ConfigEntryState.SETUP_RETRY -async def test_coordinator_update_failing_get_settings_status( +@pytest.mark.parametrize( + "mock_method", + [ + "get_settings", + "get_status", + "get_all_programs", + "get_available_commands", + "get_available_program", + ], +) +async def test_coordinator_update_failing( + mock_method: str, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - client_with_exception: MagicMock, + client: MagicMock, ) -> None: """Test that although is not possible to get settings and status, the config entry is loaded. This is for cases where some appliances are reachable and some are not in the same configuration entry. """ - # Get home appliances does pass at client_with_exception.get_home_appliances mock, so no need to mock it again + setattr(client, mock_method, AsyncMock(side_effect=HomeConnectError())) + assert config_entry.state == ConfigEntryState.NOT_LOADED - await integration_setup(client_with_exception) + await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED + getattr(client, mock_method).assert_called() + @pytest.mark.parametrize("appliance_ha_id", ["Dishwasher"], indirect=True) @pytest.mark.parametrize( diff --git a/tests/components/home_connect/test_entity.py b/tests/components/home_connect/test_entity.py index 2422cbe547c..bad02888dbf 100644 --- a/tests/components/home_connect/test_entity.py +++ b/tests/components/home_connect/test_entity.py @@ -23,6 +23,7 @@ from aiohomeconnect.model.error import ( SelectedProgramNotSetError, ) from aiohomeconnect.model.program import ( + EnumerateProgram, ProgramDefinitionConstraints, ProgramDefinitionOption, ) @@ -234,6 +235,78 @@ async def test_program_options_retrieval( assert hass.states.is_state(entity_id, STATE_UNKNOWN) +@pytest.mark.parametrize( + ("array_of_programs_program_arg", "event_key"), + [ + ( + "active", + EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM, + ), + ( + "selected", + EventKey.BSH_COMMON_ROOT_SELECTED_PROGRAM, + ), + ], +) +async def test_no_options_retrieval_on_unknown_program( + array_of_programs_program_arg: str, + event_key: EventKey, + appliance_ha_id: str, + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, +) -> None: + """Test that no options are retrieved when the program is unknown.""" + + async def get_all_programs_with_options_mock(ha_id: str) -> ArrayOfPrograms: + return ArrayOfPrograms( + **( + { + "programs": [ + EnumerateProgram(ProgramKey.UNKNOWN, "unknown program") + ], + array_of_programs_program_arg: Program( + ProgramKey.UNKNOWN, options=[] + ), + } + ) + ) + + client.get_all_programs = AsyncMock(side_effect=get_all_programs_with_options_mock) + + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + assert client.get_available_program.call_count == 0 + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.NOTIFY, + data=ArrayOfEvents( + [ + Event( + key=event_key, + raw_key=event_key.value, + timestamp=0, + level="", + handling="", + value=ProgramKey.UNKNOWN, + ) + ] + ), + ) + ] + ) + await hass.async_block_till_done() + + assert client.get_available_program.call_count == 0 + + @pytest.mark.parametrize( "event_key", [ From 7b14b6af0ed5b6eb920373ec923836e8328f7154 Mon Sep 17 00:00:00 2001 From: LG-ThinQ-Integration Date: Thu, 27 Feb 2025 20:03:44 +0900 Subject: [PATCH 1883/3148] Add water heater entity to LG ThinQ (#138257) Co-authored-by: yunseon.park --- homeassistant/components/lg_thinq/__init__.py | 1 + .../components/lg_thinq/water_heater.py | 201 ++++++++++++++++++ 2 files changed, 202 insertions(+) create mode 100644 homeassistant/components/lg_thinq/water_heater.py diff --git a/homeassistant/components/lg_thinq/__init__.py b/homeassistant/components/lg_thinq/__init__.py index 72d81af4ff0..f83cbadf925 100644 --- a/homeassistant/components/lg_thinq/__init__.py +++ b/homeassistant/components/lg_thinq/__init__.py @@ -47,6 +47,7 @@ PLATFORMS = [ Platform.SENSOR, Platform.SWITCH, Platform.VACUUM, + Platform.WATER_HEATER, ] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/lg_thinq/water_heater.py b/homeassistant/components/lg_thinq/water_heater.py new file mode 100644 index 00000000000..5a5c8d024b6 --- /dev/null +++ b/homeassistant/components/lg_thinq/water_heater.py @@ -0,0 +1,201 @@ +"""Support for waterheater entities.""" + +from __future__ import annotations + +import logging +from typing import Any + +from thinqconnect import DeviceType +from thinqconnect.integration import ExtendedProperty + +from homeassistant.components.water_heater import ( + ATTR_OPERATION_MODE, + STATE_ECO, + STATE_HEAT_PUMP, + STATE_OFF, + STATE_PERFORMANCE, + WaterHeaterEntity, + WaterHeaterEntityDescription, + WaterHeaterEntityFeature, +) +from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import ThinqConfigEntry +from .coordinator import DeviceDataUpdateCoordinator +from .entity import ThinQEntity + +DEVICE_TYPE_WH_MAP: dict[DeviceType, WaterHeaterEntityDescription] = { + DeviceType.WATER_HEATER: WaterHeaterEntityDescription( + key=ExtendedProperty.WATER_HEATER, + name=None, + ), + DeviceType.SYSTEM_BOILER: WaterHeaterEntityDescription( + key=ExtendedProperty.WATER_BOILER, + name=None, + ), +} + +# Mapping between device and HA operation modes +DEVICE_OP_MODE_TO_HA = { + "auto": STATE_ECO, + "heat_pump": STATE_HEAT_PUMP, + "turbo": STATE_PERFORMANCE, + "vacation": STATE_OFF, +} +HA_STATE_TO_DEVICE_OP_MODE = {v: k for k, v in DEVICE_OP_MODE_TO_HA.items()} + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ThinqConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up an entry for water_heater platform.""" + entities: list[ThinQWaterHeaterEntity] = [] + for coordinator in entry.runtime_data.coordinators.values(): + if ( + description := DEVICE_TYPE_WH_MAP.get(coordinator.api.device.device_type) + ) is not None: + if coordinator.api.device.device_type == DeviceType.WATER_HEATER: + entities.append( + ThinQWaterHeaterEntity( + coordinator, description, ExtendedProperty.WATER_HEATER + ) + ) + elif coordinator.api.device.device_type == DeviceType.SYSTEM_BOILER: + entities.append( + ThinQWaterBoilerEntity( + coordinator, description, ExtendedProperty.WATER_BOILER + ) + ) + if entities: + async_add_entities(entities) + + +class ThinQWaterHeaterEntity(ThinQEntity, WaterHeaterEntity): + """Represent a ThinQ water heater entity.""" + + def __init__( + self, + coordinator: DeviceDataUpdateCoordinator, + entity_description: WaterHeaterEntityDescription, + property_id: str, + ) -> None: + """Initialize a water_heater entity.""" + super().__init__(coordinator, entity_description, property_id) + self._attr_supported_features = ( + WaterHeaterEntityFeature.TARGET_TEMPERATURE + | WaterHeaterEntityFeature.OPERATION_MODE + ) + self._attr_temperature_unit = ( + self._get_unit_of_measurement(self.data.unit) or UnitOfTemperature.CELSIUS + ) + if modes := self.data.job_modes: + self._attr_operation_list = [ + DEVICE_OP_MODE_TO_HA.get(mode, mode) for mode in modes + ] + else: + self._attr_operation_list = [STATE_HEAT_PUMP] + + def _update_status(self) -> None: + """Update status itself.""" + super()._update_status() + self._attr_current_temperature = self.data.current_temp + self._attr_target_temperature = self.data.target_temp + + if self.data.max is not None: + self._attr_max_temp = self.data.max + if self.data.min is not None: + self._attr_min_temp = self.data.min + if self.data.step is not None: + self._attr_target_temperature_step = self.data.step + + self._attr_temperature_unit = ( + self._get_unit_of_measurement(self.data.unit) or UnitOfTemperature.CELSIUS + ) + if self.data.is_on: + self._attr_current_operation = ( + DEVICE_OP_MODE_TO_HA.get(job_mode, job_mode) + if (job_mode := self.data.job_mode) is not None + else STATE_HEAT_PUMP + ) + else: + self._attr_current_operation = STATE_OFF + + _LOGGER.debug( + "[%s:%s] update status: c:%s, t:%s, op_mode:%s, op_list:%s, is_on:%s", + self.coordinator.device_name, + self.property_id, + self.current_temperature, + self.target_temperature, + self.current_operation, + self.operation_list, + self.data.is_on, + ) + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new target temperatures.""" + _LOGGER.debug( + "[%s:%s] async_set_temperature: %s", + self.coordinator.device_name, + self.property_id, + kwargs, + ) + if (operation_mode := kwargs.get(ATTR_OPERATION_MODE)) is not None: + await self.async_set_operation_mode(str(operation_mode)) + if operation_mode == STATE_OFF: + return + + if ( + temperature := kwargs.get(ATTR_TEMPERATURE) + ) is not None and temperature != self.target_temperature: + await self.async_call_api( + self.coordinator.api.async_set_target_temperature( + self.property_id, temperature + ) + ) + + async def async_set_operation_mode(self, operation_mode: str) -> None: + """Set new operation mode.""" + mode = HA_STATE_TO_DEVICE_OP_MODE.get(operation_mode, operation_mode) + _LOGGER.debug( + "[%s:%s] async_set_operation_mode: %s", + self.coordinator.device_name, + self.property_id, + mode, + ) + await self.async_call_api( + self.coordinator.api.async_set_job_mode(self.property_id, mode) + ) + + +class ThinQWaterBoilerEntity(ThinQWaterHeaterEntity): + """Represent a ThinQ water boiler entity.""" + + def __init__( + self, + coordinator: DeviceDataUpdateCoordinator, + entity_description: WaterHeaterEntityDescription, + property_id: str, + ) -> None: + """Initialize a water_heater entity.""" + super().__init__(coordinator, entity_description, property_id) + self._attr_supported_features |= WaterHeaterEntityFeature.ON_OFF + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the entity on.""" + _LOGGER.debug( + "[%s:%s] async_turn_on", self.coordinator.device_name, self.property_id + ) + await self.async_call_api(self.coordinator.api.async_turn_on(self.property_id)) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the entity off.""" + _LOGGER.debug( + "[%s:%s] async_turn_off", self.coordinator.device_name, self.property_id + ) + await self.async_call_api(self.coordinator.api.async_turn_off(self.property_id)) From 5b1783e85980a4b4e11ee4285a75a0f3242424b3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 27 Feb 2025 11:41:27 +0000 Subject: [PATCH 1884/3148] Bump habluetooth to 3.24.1 (#139420) --- 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 8eeb4d67109..6c851e603d9 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -21,6 +21,6 @@ "bluetooth-auto-recovery==1.4.4", "bluetooth-data-tools==1.23.4", "dbus-fast==2.33.0", - "habluetooth==3.24.0" + "habluetooth==3.24.1" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index c49580ae47b..012206d2833 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -33,7 +33,7 @@ dbus-fast==2.33.0 fnv-hash-fast==1.2.6 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 -habluetooth==3.24.0 +habluetooth==3.24.1 hass-nabucasa==0.92.0 hassil==2.2.3 home-assistant-bluetooth==1.13.1 diff --git a/requirements_all.txt b/requirements_all.txt index d1186a9d1a7..0fddd6a3f65 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1109,7 +1109,7 @@ ha-philipsjs==3.2.2 habiticalib==0.3.7 # homeassistant.components.bluetooth -habluetooth==3.24.0 +habluetooth==3.24.1 # homeassistant.components.cloud hass-nabucasa==0.92.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2ee967f69ac..ca7aa099d97 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -950,7 +950,7 @@ ha-philipsjs==3.2.2 habiticalib==0.3.7 # homeassistant.components.bluetooth -habluetooth==3.24.0 +habluetooth==3.24.1 # homeassistant.components.cloud hass-nabucasa==0.92.0 From 735b843f5e55fd83cf37d5961bcdf4a9511e1466 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 27 Feb 2025 12:22:43 +0000 Subject: [PATCH 1885/3148] Bump bleak-esphome to 2.8.0 (#139426) --- homeassistant/components/eq3btsmart/manifest.json | 2 +- homeassistant/components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/eq3btsmart/manifest.json b/homeassistant/components/eq3btsmart/manifest.json index 0bc3ae55236..18dcbb5cb65 100644 --- a/homeassistant/components/eq3btsmart/manifest.json +++ b/homeassistant/components/eq3btsmart/manifest.json @@ -22,5 +22,5 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["eq3btsmart"], - "requirements": ["eq3btsmart==1.4.1", "bleak-esphome==2.7.1"] + "requirements": ["eq3btsmart==1.4.1", "bleak-esphome==2.8.0"] } diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index b59dd544c49..d07754d68a0 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -18,7 +18,7 @@ "requirements": [ "aioesphomeapi==29.2.0", "esphome-dashboard-api==1.2.3", - "bleak-esphome==2.7.1" + "bleak-esphome==2.8.0" ], "zeroconf": ["_esphomelib._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 0fddd6a3f65..ce90fe2120e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -603,7 +603,7 @@ bizkaibus==0.1.1 # homeassistant.components.eq3btsmart # homeassistant.components.esphome -bleak-esphome==2.7.1 +bleak-esphome==2.8.0 # homeassistant.components.bluetooth bleak-retry-connector==3.9.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ca7aa099d97..e198c65fb27 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -534,7 +534,7 @@ bimmer-connected[china]==0.17.2 # homeassistant.components.eq3btsmart # homeassistant.components.esphome -bleak-esphome==2.7.1 +bleak-esphome==2.8.0 # homeassistant.components.bluetooth bleak-retry-connector==3.9.0 From 7ae13a4d7245742d383d4e99f73b95c84feaef4b Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 27 Feb 2025 13:25:55 +0100 Subject: [PATCH 1886/3148] Bump pysmartthings to 2.0.0 (#139418) * Bump pysmartthings to 2.0.0 * Fix * Fix * Fix * Fix --- .../components/smartthings/__init__.py | 8 +++--- homeassistant/components/smartthings/cover.py | 2 +- .../components/smartthings/entity.py | 13 +++++++-- .../components/smartthings/manifest.json | 2 +- .../components/smartthings/sensor.py | 28 +++++++++++++------ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/smartthings/__init__.py | 2 +- .../smartthings/snapshots/test_sensor.ambr | 8 +++--- tests/components/smartthings/test_sensor.py | 14 +++++----- 10 files changed, 50 insertions(+), 31 deletions(-) diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py index 846170552e9..4bc9b270360 100644 --- a/homeassistant/components/smartthings/__init__.py +++ b/homeassistant/components/smartthings/__init__.py @@ -46,7 +46,7 @@ class FullDevice: """Define an object to hold device data.""" device: Device - status: dict[str, dict[Capability, dict[Attribute, Status]]] + status: dict[str, dict[Capability | str, dict[Attribute | str, Status]]] type SmartThingsConfigEntry = ConfigEntry[SmartThingsData] @@ -146,8 +146,8 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: def process_status( - status: dict[str, dict[Capability, dict[Attribute, Status]]], -) -> dict[str, dict[Capability, dict[Attribute, Status]]]: + status: dict[str, dict[Capability | str, dict[Attribute | str, Status]]], +) -> dict[str, dict[Capability | str, dict[Attribute | str, Status]]]: """Remove disabled capabilities from status.""" if (main_component := status.get("main")) is None or ( disabled_capabilities_capability := main_component.get( @@ -156,7 +156,7 @@ def process_status( ) is None: return status disabled_capabilities = cast( - list[Capability], + list[Capability | str], disabled_capabilities_capability[Attribute.DISABLED_CAPABILITIES].value, ) for capability in disabled_capabilities: diff --git a/homeassistant/components/smartthings/cover.py b/homeassistant/components/smartthings/cover.py index fd4752b4e28..0b0f03679eb 100644 --- a/homeassistant/components/smartthings/cover.py +++ b/homeassistant/components/smartthings/cover.py @@ -41,7 +41,7 @@ async def async_setup_entry( """Add covers for a config entry.""" entry_data = entry.runtime_data async_add_entities( - SmartThingsCover(entry_data.client, device, capability) + SmartThingsCover(entry_data.client, device, Capability(capability)) for device in entry_data.devices.values() for capability in device.status[MAIN] if capability in CAPABILITIES diff --git a/homeassistant/components/smartthings/entity.py b/homeassistant/components/smartthings/entity.py index b2e556c6718..1383196ce15 100644 --- a/homeassistant/components/smartthings/entity.py +++ b/homeassistant/components/smartthings/entity.py @@ -4,7 +4,14 @@ from __future__ import annotations from typing import Any, cast -from pysmartthings import Attribute, Capability, Command, DeviceEvent, SmartThings +from pysmartthings import ( + Attribute, + Capability, + Command, + DeviceEvent, + SmartThings, + Status, +) from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity @@ -25,7 +32,7 @@ class SmartThingsEntity(Entity): """Initialize the instance.""" self.client = client self.capabilities = capabilities - self._internal_state = { + self._internal_state: dict[Capability | str, dict[Attribute | str, Status]] = { capability: device.status[MAIN][capability] for capability in capabilities if capability in device.status[MAIN] @@ -58,7 +65,7 @@ class SmartThingsEntity(Entity): await super().async_added_to_hass() for capability in self._internal_state: self.async_on_remove( - self.client.add_device_event_listener( + self.client.add_device_capability_event_listener( self.device.device.device_id, MAIN, capability, diff --git a/homeassistant/components/smartthings/manifest.json b/homeassistant/components/smartthings/manifest.json index b34ab90ca8c..c5277241aa4 100644 --- a/homeassistant/components/smartthings/manifest.json +++ b/homeassistant/components/smartthings/manifest.json @@ -29,5 +29,5 @@ "documentation": "https://www.home-assistant.io/integrations/smartthings", "iot_class": "cloud_push", "loggers": ["pysmartthings"], - "requirements": ["pysmartthings==1.2.0"] + "requirements": ["pysmartthings==2.0.0"] } diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index d7aaaaa84c5..bc986894045 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -130,6 +130,7 @@ class SmartThingsSensorEntityDescription(SensorEntityDescription): unique_id_separator: str = "." capability_ignore_list: list[set[Capability]] | None = None options_attribute: Attribute | None = None + except_if_state_none: bool = False CAPABILITY_TO_SENSORS: dict[ @@ -579,6 +580,7 @@ CAPABILITY_TO_SENSORS: dict[ device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, value_fn=lambda value: value["energy"] / 1000, + except_if_state_none=True, ), SmartThingsSensorEntityDescription( key="power_meter", @@ -587,6 +589,7 @@ CAPABILITY_TO_SENSORS: dict[ native_unit_of_measurement=UnitOfPower.WATT, value_fn=lambda value: value["power"], extra_state_attributes_fn=power_attributes, + except_if_state_none=True, ), SmartThingsSensorEntityDescription( key="deltaEnergy_meter", @@ -595,6 +598,7 @@ CAPABILITY_TO_SENSORS: dict[ device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, value_fn=lambda value: value["deltaEnergy"] / 1000, + except_if_state_none=True, ), SmartThingsSensorEntityDescription( key="powerEnergy_meter", @@ -603,6 +607,7 @@ CAPABILITY_TO_SENSORS: dict[ device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, value_fn=lambda value: value["powerEnergy"] / 1000, + except_if_state_none=True, ), SmartThingsSensorEntityDescription( key="energySaved_meter", @@ -611,6 +616,7 @@ CAPABILITY_TO_SENSORS: dict[ device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, value_fn=lambda value: value["energySaved"] / 1000, + except_if_state_none=True, ), ] }, @@ -953,14 +959,20 @@ async def async_setup_entry( async_add_entities( SmartThingsSensor(entry_data.client, device, description, capability, attribute) for device in entry_data.devices.values() - for capability, attributes in device.status[MAIN].items() - if capability in CAPABILITY_TO_SENSORS - for attribute in attributes - for description in CAPABILITY_TO_SENSORS[capability].get(attribute, []) - if not description.capability_ignore_list - or not any( - all(capability in device.status[MAIN] for capability in capability_list) - for capability_list in description.capability_ignore_list + for capability, attributes in CAPABILITY_TO_SENSORS.items() + if capability in device.status[MAIN] + for attribute, descriptions in attributes.items() + for description in descriptions + if ( + not description.capability_ignore_list + or not any( + all(capability in device.status[MAIN] for capability in capability_list) + for capability_list in description.capability_ignore_list + ) + ) + and ( + not description.except_if_state_none + or device.status[MAIN][capability][attribute].value is not None ) ) diff --git a/requirements_all.txt b/requirements_all.txt index ce90fe2120e..a4bc8becc83 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2310,7 +2310,7 @@ pysma==0.7.5 pysmappee==0.2.29 # homeassistant.components.smartthings -pysmartthings==1.2.0 +pysmartthings==2.0.0 # homeassistant.components.smarty pysmarty2==0.10.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e198c65fb27..5612b5547b2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1882,7 +1882,7 @@ pysma==0.7.5 pysmappee==0.2.29 # homeassistant.components.smartthings -pysmartthings==1.2.0 +pysmartthings==2.0.0 # homeassistant.components.smarty pysmarty2==0.10.2 diff --git a/tests/components/smartthings/__init__.py b/tests/components/smartthings/__init__.py index 94a2e7512f2..a5e51c7d434 100644 --- a/tests/components/smartthings/__init__.py +++ b/tests/components/smartthings/__init__.py @@ -57,7 +57,7 @@ async def trigger_update( data: dict[str, Any] | None = None, ) -> None: """Trigger an update.""" - for call in mock.add_device_event_listener.call_args_list: + for call in mock.add_device_capability_event_listener.call_args_list: if call[0][0] == device_id and call[0][2] == capability: call[0][3]( DeviceEvent( diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index 778b05fa183..93a683afe82 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_all_entities[aeotec_home_energy_meter_gen5][sensor.aeotec_energy_monitor_energy_2-entry] +# name: test_all_entities[aeotec_home_energy_meter_gen5][sensor.aeotec_energy_monitor_energy-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -14,7 +14,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.aeotec_energy_monitor_energy_2', + 'entity_id': 'sensor.aeotec_energy_monitor_energy', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -35,7 +35,7 @@ 'unit_of_measurement': 'kWh', }) # --- -# name: test_all_entities[aeotec_home_energy_meter_gen5][sensor.aeotec_energy_monitor_energy_2-state] +# name: test_all_entities[aeotec_home_energy_meter_gen5][sensor.aeotec_energy_monitor_energy-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', @@ -44,7 +44,7 @@ 'unit_of_measurement': 'kWh', }), 'context': , - 'entity_id': 'sensor.aeotec_energy_monitor_energy_2', + 'entity_id': 'sensor.aeotec_energy_monitor_energy', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/smartthings/test_sensor.py b/tests/components/smartthings/test_sensor.py index 8b8bb8930f4..c83950de9e9 100644 --- a/tests/components/smartthings/test_sensor.py +++ b/tests/components/smartthings/test_sensor.py @@ -28,7 +28,7 @@ async def test_all_entities( snapshot_smartthings_entities(hass, entity_registry, snapshot, Platform.SENSOR) -@pytest.mark.parametrize("device_fixture", ["aeotec_home_energy_meter_gen5"]) +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) async def test_state_update( hass: HomeAssistant, devices: AsyncMock, @@ -37,15 +37,15 @@ async def test_state_update( """Test state update.""" await setup_integration(hass, mock_config_entry) - assert hass.states.get("sensor.aeotec_energy_monitor_energy_2").state == "19978.536" + assert hass.states.get("sensor.ac_office_granit_temperature").state == "25" await trigger_update( hass, devices, - "f0af21a2-d5a1-437c-b10a-b34a87394b71", - Capability.ENERGY_METER, - Attribute.ENERGY, - 20000.0, + "96a5ef74-5832-a84b-f1f7-ca799957065d", + Capability.TEMPERATURE_MEASUREMENT, + Attribute.TEMPERATURE, + 20, ) - assert hass.states.get("sensor.aeotec_energy_monitor_energy_2").state == "20000.0" + assert hass.states.get("sensor.ac_office_granit_temperature").state == "20" From 59eb323f8d9314d128b0df9984856888695a6988 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Thu, 27 Feb 2025 14:29:57 +0100 Subject: [PATCH 1887/3148] Bump reolink-aio to 0.12.1 (#139427) --- homeassistant/components/reolink/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json index 37e448aa820..f923efdbbf2 100644 --- a/homeassistant/components/reolink/manifest.json +++ b/homeassistant/components/reolink/manifest.json @@ -19,5 +19,5 @@ "iot_class": "local_push", "loggers": ["reolink_aio"], "quality_scale": "platinum", - "requirements": ["reolink-aio==0.12.0"] + "requirements": ["reolink-aio==0.12.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index a4bc8becc83..78848c01ed3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2618,7 +2618,7 @@ renault-api==0.2.9 renson-endura-delta==1.7.2 # homeassistant.components.reolink -reolink-aio==0.12.0 +reolink-aio==0.12.1 # homeassistant.components.idteck_prox rfk101py==0.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5612b5547b2..aa7f720b8f1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2121,7 +2121,7 @@ renault-api==0.2.9 renson-endura-delta==1.7.2 # homeassistant.components.reolink -reolink-aio==0.12.0 +reolink-aio==0.12.1 # homeassistant.components.rflink rflink==0.0.66 From f111a2c34a8d4e0be6501334bc11f9d873fef5e5 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Thu, 27 Feb 2025 14:30:29 +0100 Subject: [PATCH 1888/3148] Fix Music Assistant media player entity features (#139428) * Fix Music Assistant supported media player features * Update supported features when player config changes * Add tests --- .../music_assistant/media_player.py | 46 +++++-- tests/components/music_assistant/common.py | 39 +++++- .../music_assistant/test_media_player.py | 116 +++++++++++++++++- 3 files changed, 182 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/music_assistant/media_player.py b/homeassistant/components/music_assistant/media_player.py index bbbda095302..c079fd20e91 100644 --- a/homeassistant/components/music_assistant/media_player.py +++ b/homeassistant/components/music_assistant/media_player.py @@ -9,6 +9,7 @@ import functools import os from typing import TYPE_CHECKING, Any, Concatenate +from music_assistant_models.constants import PLAYER_CONTROL_NONE from music_assistant_models.enums import ( EventType, MediaType, @@ -80,19 +81,14 @@ if TYPE_CHECKING: from music_assistant_client import MusicAssistantClient from music_assistant_models.player import Player -SUPPORTED_FEATURES = ( - MediaPlayerEntityFeature.PAUSE - | MediaPlayerEntityFeature.VOLUME_SET - | MediaPlayerEntityFeature.STOP +SUPPORTED_FEATURES_BASE = ( + MediaPlayerEntityFeature.STOP | MediaPlayerEntityFeature.PREVIOUS_TRACK | MediaPlayerEntityFeature.NEXT_TRACK | MediaPlayerEntityFeature.SHUFFLE_SET | MediaPlayerEntityFeature.REPEAT_SET - | MediaPlayerEntityFeature.TURN_ON - | MediaPlayerEntityFeature.TURN_OFF | MediaPlayerEntityFeature.PLAY | MediaPlayerEntityFeature.PLAY_MEDIA - | MediaPlayerEntityFeature.VOLUME_STEP | MediaPlayerEntityFeature.CLEAR_PLAYLIST | MediaPlayerEntityFeature.BROWSE_MEDIA | MediaPlayerEntityFeature.MEDIA_ENQUEUE @@ -212,11 +208,7 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity): """Initialize MediaPlayer entity.""" super().__init__(mass, player_id) self._attr_icon = self.player.icon.replace("mdi-", "mdi:") - self._attr_supported_features = SUPPORTED_FEATURES - if PlayerFeature.SET_MEMBERS in self.player.supported_features: - self._attr_supported_features |= MediaPlayerEntityFeature.GROUPING - if PlayerFeature.VOLUME_MUTE in self.player.supported_features: - self._attr_supported_features |= MediaPlayerEntityFeature.VOLUME_MUTE + self._set_supported_features() self._attr_device_class = MediaPlayerDeviceClass.SPEAKER self._prev_time: float = 0 @@ -241,6 +233,19 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity): ) ) + # we subscribe to the player config changed event to update + # the supported features of the player + async def player_config_changed(event: MassEvent) -> None: + self._set_supported_features() + await self.async_on_update() + self.async_write_ha_state() + + self.async_on_remove( + self.mass.subscribe( + player_config_changed, EventType.PLAYER_CONFIG_UPDATED, self.player_id + ) + ) + @property def active_queue(self) -> PlayerQueue | None: """Return the active queue for this player (if any).""" @@ -682,3 +687,20 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity): if isinstance(queue_option, MediaPlayerEnqueue): queue_option = QUEUE_OPTION_MAP.get(queue_option) return queue_option + + def _set_supported_features(self) -> None: + """Set supported features based on player capabilities.""" + supported_features = SUPPORTED_FEATURES_BASE + if PlayerFeature.SET_MEMBERS in self.player.supported_features: + supported_features |= MediaPlayerEntityFeature.GROUPING + if PlayerFeature.PAUSE in self.player.supported_features: + supported_features |= MediaPlayerEntityFeature.PAUSE + if self.player.mute_control != PLAYER_CONTROL_NONE: + supported_features |= MediaPlayerEntityFeature.VOLUME_MUTE + if self.player.volume_control != PLAYER_CONTROL_NONE: + supported_features |= MediaPlayerEntityFeature.VOLUME_STEP + supported_features |= MediaPlayerEntityFeature.VOLUME_SET + if self.player.power_control != PLAYER_CONTROL_NONE: + supported_features |= MediaPlayerEntityFeature.TURN_ON + supported_features |= MediaPlayerEntityFeature.TURN_OFF + self._attr_supported_features = supported_features diff --git a/tests/components/music_assistant/common.py b/tests/components/music_assistant/common.py index 7c0f9df751a..863d945ccd1 100644 --- a/tests/components/music_assistant/common.py +++ b/tests/components/music_assistant/common.py @@ -2,9 +2,11 @@ from __future__ import annotations +import asyncio from typing import Any from unittest.mock import AsyncMock, MagicMock +from music_assistant_models.api import MassEvent from music_assistant_models.enums import EventType from music_assistant_models.media_items import Album, Artist, Playlist, Radio, Track from music_assistant_models.player import Player @@ -134,15 +136,42 @@ async def trigger_subscription_callback( hass: HomeAssistant, client: MagicMock, event: EventType = EventType.PLAYER_UPDATED, + object_id: str | None = None, data: Any = None, ) -> None: """Trigger a subscription callback.""" # trigger callback on all subscribers - for sub in client.subscribe_events.call_args_list: - callback = sub.kwargs["callback"] - event_filter = sub.kwargs.get("event_filter") - if event_filter in (None, event): - callback(event, data) + for sub in client.subscribe.call_args_list: + cb_func = sub.kwargs.get("cb_func", sub.args[0]) + event_filter = sub.kwargs.get( + "event_filter", sub.args[1] if len(sub.args) > 1 else None + ) + id_filter = sub.kwargs.get( + "id_filter", sub.args[2] if len(sub.args) > 2 else None + ) + if not ( + event_filter is None + or event == event_filter + or (isinstance(event_filter, list) and event in event_filter) + ): + continue + if not ( + id_filter is None + or object_id == id_filter + or (isinstance(id_filter, list) and object_id in id_filter) + ): + continue + + event = MassEvent( + event=event, + object_id=object_id, + data=data, + ) + if asyncio.iscoroutinefunction(cb_func): + await cb_func(event) + else: + cb_func(event) + await hass.async_block_till_done() diff --git a/tests/components/music_assistant/test_media_player.py b/tests/components/music_assistant/test_media_player.py index 25dfcd22c72..44317d4977a 100644 --- a/tests/components/music_assistant/test_media_player.py +++ b/tests/components/music_assistant/test_media_player.py @@ -2,7 +2,13 @@ from unittest.mock import MagicMock, call -from music_assistant_models.enums import MediaType, QueueOption +from music_assistant_models.constants import PLAYER_CONTROL_NONE +from music_assistant_models.enums import ( + EventType, + MediaType, + PlayerFeature, + QueueOption, +) from music_assistant_models.media_items import Track import pytest from syrupy import SnapshotAssertion @@ -20,6 +26,7 @@ from homeassistant.components.media_player import ( SERVICE_CLEAR_PLAYLIST, SERVICE_JOIN, SERVICE_UNJOIN, + MediaPlayerEntityFeature, ) from homeassistant.components.music_assistant.const import DOMAIN as MASS_DOMAIN from homeassistant.components.music_assistant.media_player import ( @@ -59,7 +66,11 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from .common import setup_integration_from_fixtures, snapshot_music_assistant_entities +from .common import ( + setup_integration_from_fixtures, + snapshot_music_assistant_entities, + trigger_subscription_callback, +) from tests.common import AsyncMock @@ -607,3 +618,104 @@ async def test_media_player_get_queue_action( # no call is made, this info comes from the cached queue data assert music_assistant_client.send_command.call_count == 0 assert response == snapshot(exclude=paths(f"{entity_id}.elapsed_time")) + + +async def test_media_player_supported_features( + hass: HomeAssistant, + music_assistant_client: MagicMock, +) -> None: + """Test if media_player entity supported features are cortrectly (re)mapped.""" + await setup_integration_from_fixtures(hass, music_assistant_client) + entity_id = "media_player.test_player_1" + mass_player_id = "00:00:00:00:00:01" + state = hass.states.get(entity_id) + assert state + expected_features = ( + MediaPlayerEntityFeature.STOP + | MediaPlayerEntityFeature.PREVIOUS_TRACK + | MediaPlayerEntityFeature.NEXT_TRACK + | MediaPlayerEntityFeature.SHUFFLE_SET + | MediaPlayerEntityFeature.REPEAT_SET + | MediaPlayerEntityFeature.PLAY + | MediaPlayerEntityFeature.PLAY_MEDIA + | MediaPlayerEntityFeature.VOLUME_STEP + | MediaPlayerEntityFeature.CLEAR_PLAYLIST + | MediaPlayerEntityFeature.BROWSE_MEDIA + | MediaPlayerEntityFeature.MEDIA_ENQUEUE + | MediaPlayerEntityFeature.MEDIA_ANNOUNCE + | MediaPlayerEntityFeature.SEEK + | MediaPlayerEntityFeature.PAUSE + | MediaPlayerEntityFeature.GROUPING + | MediaPlayerEntityFeature.VOLUME_SET + | MediaPlayerEntityFeature.VOLUME_STEP + | MediaPlayerEntityFeature.VOLUME_MUTE + | MediaPlayerEntityFeature.TURN_ON + | MediaPlayerEntityFeature.TURN_OFF + ) + assert state.attributes["supported_features"] == expected_features + # remove power control capability from player, trigger subscription callback + # and check if the supported features got updated + music_assistant_client.players._players[ + mass_player_id + ].power_control = PLAYER_CONTROL_NONE + await trigger_subscription_callback( + hass, music_assistant_client, EventType.PLAYER_CONFIG_UPDATED, mass_player_id + ) + expected_features &= ~MediaPlayerEntityFeature.TURN_ON + expected_features &= ~MediaPlayerEntityFeature.TURN_OFF + state = hass.states.get(entity_id) + assert state + assert state.attributes["supported_features"] == expected_features + + # remove volume control capability from player, trigger subscription callback + # and check if the supported features got updated + music_assistant_client.players._players[ + mass_player_id + ].volume_control = PLAYER_CONTROL_NONE + await trigger_subscription_callback( + hass, music_assistant_client, EventType.PLAYER_CONFIG_UPDATED, mass_player_id + ) + expected_features &= ~MediaPlayerEntityFeature.VOLUME_SET + expected_features &= ~MediaPlayerEntityFeature.VOLUME_STEP + state = hass.states.get(entity_id) + assert state + assert state.attributes["supported_features"] == expected_features + + # remove mute control capability from player, trigger subscription callback + # and check if the supported features got updated + music_assistant_client.players._players[ + mass_player_id + ].mute_control = PLAYER_CONTROL_NONE + await trigger_subscription_callback( + hass, music_assistant_client, EventType.PLAYER_CONFIG_UPDATED, mass_player_id + ) + expected_features &= ~MediaPlayerEntityFeature.VOLUME_MUTE + state = hass.states.get(entity_id) + assert state + assert state.attributes["supported_features"] == expected_features + + # remove pause capability from player, trigger subscription callback + # and check if the supported features got updated + music_assistant_client.players._players[mass_player_id].supported_features.remove( + PlayerFeature.PAUSE + ) + await trigger_subscription_callback( + hass, music_assistant_client, EventType.PLAYER_CONFIG_UPDATED, mass_player_id + ) + expected_features &= ~MediaPlayerEntityFeature.PAUSE + state = hass.states.get(entity_id) + assert state + assert state.attributes["supported_features"] == expected_features + + # remove grouping capability from player, trigger subscription callback + # and check if the supported features got updated + music_assistant_client.players._players[mass_player_id].supported_features.remove( + PlayerFeature.SET_MEMBERS + ) + await trigger_subscription_callback( + hass, music_assistant_client, EventType.PLAYER_CONFIG_UPDATED, mass_player_id + ) + expected_features &= ~MediaPlayerEntityFeature.GROUPING + state = hass.states.get(entity_id) + assert state + assert state.attributes["supported_features"] == expected_features From 0da6b28808c5fce034fb257ad68042ea601f7c71 Mon Sep 17 00:00:00 2001 From: Michael Arthur Date: Fri, 28 Feb 2025 03:02:14 +1300 Subject: [PATCH 1889/3148] Add lawn mower entity id format (#139402) * add missing entity id format * use ENTITY_ID_FORMAT in mqtt lawn mower --- homeassistant/components/lawn_mower/__init__.py | 1 + homeassistant/components/mqtt/lawn_mower.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/lawn_mower/__init__.py b/homeassistant/components/lawn_mower/__init__.py index 0680bfc9d71..f8c3e0cd67d 100644 --- a/homeassistant/components/lawn_mower/__init__.py +++ b/homeassistant/components/lawn_mower/__init__.py @@ -28,6 +28,7 @@ from .const import ( _LOGGER = logging.getLogger(__name__) DATA_COMPONENT: HassKey[EntityComponent[LawnMowerEntity]] = HassKey(DOMAIN) +ENTITY_ID_FORMAT = DOMAIN + ".{}" PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE SCAN_INTERVAL = timedelta(seconds=60) diff --git a/homeassistant/components/mqtt/lawn_mower.py b/homeassistant/components/mqtt/lawn_mower.py index 7727efcf04d..1917c56f209 100644 --- a/homeassistant/components/mqtt/lawn_mower.py +++ b/homeassistant/components/mqtt/lawn_mower.py @@ -10,6 +10,7 @@ import voluptuous as vol from homeassistant.components import lawn_mower from homeassistant.components.lawn_mower import ( + ENTITY_ID_FORMAT, LawnMowerActivity, LawnMowerEntity, LawnMowerEntityFeature, @@ -50,7 +51,6 @@ CONF_START_MOWING_COMMAND_TOPIC = "start_mowing_command_topic" CONF_START_MOWING_COMMAND_TEMPLATE = "start_mowing_command_template" DEFAULT_NAME = "MQTT Lawn Mower" -ENTITY_ID_FORMAT = lawn_mower.DOMAIN + ".{}" MQTT_LAWN_MOWER_ATTRIBUTES_BLOCKED: frozenset[str] = frozenset() From f677b910a6582704f5f8f481abb49bd04c9a353b Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 27 Feb 2025 15:23:25 +0100 Subject: [PATCH 1890/3148] Add diagnostics to SmartThings (#139423) --- .../components/smartthings/diagnostics.py | 50 + tests/components/smartthings/__init__.py | 28 +- .../snapshots/test_diagnostics.ambr | 1163 +++++++++++++++++ .../smartthings/test_diagnostics.py | 44 + 4 files changed, 1272 insertions(+), 13 deletions(-) create mode 100644 homeassistant/components/smartthings/diagnostics.py create mode 100644 tests/components/smartthings/snapshots/test_diagnostics.ambr create mode 100644 tests/components/smartthings/test_diagnostics.py diff --git a/homeassistant/components/smartthings/diagnostics.py b/homeassistant/components/smartthings/diagnostics.py new file mode 100644 index 00000000000..bcf40645d22 --- /dev/null +++ b/homeassistant/components/smartthings/diagnostics.py @@ -0,0 +1,50 @@ +"""Diagnostics support for SmartThings.""" + +from __future__ import annotations + +import asyncio +from dataclasses import asdict +from typing import Any + +from pysmartthings import DeviceEvent + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceEntry + +from . import SmartThingsConfigEntry +from .const import DOMAIN + +EVENT_WAIT_TIME = 5 + + +async def async_get_device_diagnostics( + hass: HomeAssistant, entry: SmartThingsConfigEntry, device: DeviceEntry +) -> dict[str, Any]: + """Return diagnostics for a device entry.""" + device_id = next( + identifier for identifier in device.identifiers if identifier[0] == DOMAIN + )[0] + + events: list[DeviceEvent] = [] + + def register_event(event: DeviceEvent) -> None: + events.append(event) + + client = entry.runtime_data.client + + listener = client.add_device_event_listener(device_id, register_event) + + await asyncio.sleep(EVENT_WAIT_TIME) + + listener() + + device_status = await client.get_device_status(device_id) + + status: dict[str, Any] = {} + for component, capabilities in device_status.items(): + status[component] = {} + for capability, attributes in capabilities.items(): + status[component][capability] = {} + for attribute, value in attributes.items(): + status[component][capability][attribute] = asdict(value) + return {"events": [asdict(event) for event in events], "status": status} diff --git a/tests/components/smartthings/__init__.py b/tests/components/smartthings/__init__.py index a5e51c7d434..6939d3c5dcc 100644 --- a/tests/components/smartthings/__init__.py +++ b/tests/components/smartthings/__init__.py @@ -57,19 +57,21 @@ async def trigger_update( data: dict[str, Any] | None = None, ) -> None: """Trigger an update.""" + event = DeviceEvent( + "abc", + "abc", + "abc", + device_id, + MAIN, + capability, + attribute, + value, + data, + ) + for call in mock.add_device_event_listener.call_args_list: + if call[0][0] == device_id: + call[0][3](event) for call in mock.add_device_capability_event_listener.call_args_list: if call[0][0] == device_id and call[0][2] == capability: - call[0][3]( - DeviceEvent( - "abc", - "abc", - "abc", - device_id, - MAIN, - capability, - attribute, - value, - data, - ) - ) + call[0][3](event) await hass.async_block_till_done() diff --git a/tests/components/smartthings/snapshots/test_diagnostics.ambr b/tests/components/smartthings/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..50f568df5d1 --- /dev/null +++ b/tests/components/smartthings/snapshots/test_diagnostics.ambr @@ -0,0 +1,1163 @@ +# serializer version: 1 +# name: test_device[da_ac_rac_000001] + dict({ + 'events': list([ + ]), + 'status': dict({ + '1': dict({ + 'airConditionerFanMode': dict({ + 'availableAcFanModes': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'fanMode': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:44:10.381000+00:00', + 'unit': None, + 'value': None, + }), + 'supportedAcFanModes': dict({ + 'data': None, + 'timestamp': '2024-09-10T10:26:28.605000+00:00', + 'unit': None, + 'value': list([ + 'auto', + 'low', + 'medium', + 'high', + 'turbo', + ]), + }), + }), + 'airConditionerMode': dict({ + 'airConditionerMode': dict({ + 'data': None, + 'timestamp': '2021-04-08T03:50:50.930000+00:00', + 'unit': None, + 'value': None, + }), + 'availableAcModes': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'supportedAcModes': dict({ + 'data': None, + 'timestamp': '2021-04-08T03:50:50.930000+00:00', + 'unit': None, + 'value': None, + }), + }), + 'airQualitySensor': dict({ + 'airQuality': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:57:57.602000+00:00', + 'unit': 'CAQI', + 'value': None, + }), + }), + 'audioVolume': dict({ + 'volume': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:43:53.541000+00:00', + 'unit': '%', + 'value': None, + }), + }), + 'custom.airConditionerOdorController': dict({ + 'airConditionerOdorControllerProgress': dict({ + 'data': None, + 'timestamp': '2021-04-08T04:11:38.269000+00:00', + 'unit': None, + 'value': None, + }), + 'airConditionerOdorControllerState': dict({ + 'data': None, + 'timestamp': '2021-04-08T04:11:38.269000+00:00', + 'unit': None, + 'value': None, + }), + }), + 'custom.airConditionerOptionalMode': dict({ + 'acOptionalMode': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:57:57.659000+00:00', + 'unit': None, + 'value': None, + }), + 'supportedAcOptionalMode': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:57:57.659000+00:00', + 'unit': None, + 'value': None, + }), + }), + 'custom.airConditionerTropicalNightMode': dict({ + 'acTropicalNightModeLevel': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:44:10.498000+00:00', + 'unit': None, + 'value': None, + }), + }), + 'custom.autoCleaningMode': dict({ + 'autoCleaningMode': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:43:53.344000+00:00', + 'unit': None, + 'value': None, + }), + 'operatingState': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'progress': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'supportedAutoCleaningModes': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'supportedOperatingStates': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'timedCleanDuration': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'timedCleanDurationRange': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + }), + 'custom.deodorFilter': dict({ + 'deodorFilterCapacity': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:43:39.118000+00:00', + 'unit': None, + 'value': None, + }), + 'deodorFilterLastResetDate': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:43:39.118000+00:00', + 'unit': None, + 'value': None, + }), + 'deodorFilterResetType': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:43:39.118000+00:00', + 'unit': None, + 'value': None, + }), + 'deodorFilterStatus': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:43:39.118000+00:00', + 'unit': None, + 'value': None, + }), + 'deodorFilterUsage': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:43:39.118000+00:00', + 'unit': None, + 'value': None, + }), + 'deodorFilterUsageStep': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:43:39.118000+00:00', + 'unit': None, + 'value': None, + }), + }), + 'custom.deviceReportStateConfiguration': dict({ + 'reportStatePeriod': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:44:09.800000+00:00', + 'unit': None, + 'value': None, + }), + 'reportStateRealtime': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:44:09.800000+00:00', + 'unit': None, + 'value': None, + }), + 'reportStateRealtimePeriod': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:44:09.800000+00:00', + 'unit': None, + 'value': None, + }), + }), + 'custom.disabledCapabilities': dict({ + 'disabledCapabilities': dict({ + 'data': None, + 'timestamp': '2024-09-10T10:26:28.605000+00:00', + 'unit': None, + 'value': list([ + 'remoteControlStatus', + 'airQualitySensor', + 'dustSensor', + 'odorSensor', + 'veryFineDustSensor', + 'custom.dustFilter', + 'custom.deodorFilter', + 'custom.deviceReportStateConfiguration', + 'audioVolume', + 'custom.autoCleaningMode', + 'custom.airConditionerTropicalNightMode', + 'custom.airConditionerOdorController', + 'demandResponseLoadControl', + 'relativeHumidityMeasurement', + ]), + }), + }), + 'custom.dustFilter': dict({ + 'dustFilterCapacity': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:43:39.145000+00:00', + 'unit': None, + 'value': None, + }), + 'dustFilterLastResetDate': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:43:39.145000+00:00', + 'unit': None, + 'value': None, + }), + 'dustFilterResetType': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:43:39.145000+00:00', + 'unit': None, + 'value': None, + }), + 'dustFilterStatus': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:43:39.145000+00:00', + 'unit': None, + 'value': None, + }), + 'dustFilterUsage': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:43:39.145000+00:00', + 'unit': None, + 'value': None, + }), + 'dustFilterUsageStep': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:43:39.145000+00:00', + 'unit': None, + 'value': None, + }), + }), + 'custom.energyType': dict({ + 'drMaxDuration': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'energySavingInfo': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'energySavingLevel': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'energySavingOperation': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'energySavingOperationSupport': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'energySavingSupport': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'energyType': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:43:38.843000+00:00', + 'unit': None, + 'value': None, + }), + 'notificationTemplateID': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'supportedEnergySavingLevels': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + }), + 'custom.spiMode': dict({ + 'spiMode': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:57:57.686000+00:00', + 'unit': None, + 'value': None, + }), + }), + 'custom.thermostatSetpointControl': dict({ + 'maximumSetpoint': dict({ + 'data': None, + 'timestamp': '2021-04-08T04:04:19.901000+00:00', + 'unit': None, + 'value': None, + }), + 'minimumSetpoint': dict({ + 'data': None, + 'timestamp': '2021-04-08T04:04:19.901000+00:00', + 'unit': None, + 'value': None, + }), + }), + 'demandResponseLoadControl': dict({ + 'drlcStatus': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:43:54.748000+00:00', + 'unit': None, + 'value': None, + }), + }), + 'dustSensor': dict({ + 'dustLevel': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:44:10.122000+00:00', + 'unit': 'μg/m^3', + 'value': None, + }), + 'fineDustLevel': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:44:10.122000+00:00', + 'unit': 'μg/m^3', + 'value': None, + }), + }), + 'fanOscillationMode': dict({ + 'availableFanOscillationModes': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'fanOscillationMode': dict({ + 'data': None, + 'timestamp': '2025-02-08T00:44:53.247000+00:00', + 'unit': None, + 'value': 'fixed', + }), + 'supportedFanOscillationModes': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:44:10.325000+00:00', + 'unit': None, + 'value': None, + }), + }), + 'ocf': dict({ + 'di': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:44:10.472000+00:00', + 'unit': None, + 'value': None, + }), + 'dmv': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:44:10.472000+00:00', + 'unit': None, + 'value': None, + }), + 'icv': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:44:10.472000+00:00', + 'unit': None, + 'value': None, + }), + 'mndt': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:44:10.472000+00:00', + 'unit': None, + 'value': None, + }), + 'mnfv': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:44:10.472000+00:00', + 'unit': None, + 'value': None, + }), + 'mnhw': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:44:10.472000+00:00', + 'unit': None, + 'value': None, + }), + 'mnml': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:44:10.472000+00:00', + 'unit': None, + 'value': None, + }), + 'mnmn': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:44:10.472000+00:00', + 'unit': None, + 'value': None, + }), + 'mnmo': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:44:10.472000+00:00', + 'unit': None, + 'value': None, + }), + 'mnos': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:44:10.472000+00:00', + 'unit': None, + 'value': None, + }), + 'mnpv': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:44:10.472000+00:00', + 'unit': None, + 'value': None, + }), + 'mnsl': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:44:10.472000+00:00', + 'unit': None, + 'value': None, + }), + 'n': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:44:10.472000+00:00', + 'unit': None, + 'value': None, + }), + 'pi': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:44:10.472000+00:00', + 'unit': None, + 'value': None, + }), + 'st': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:44:10.472000+00:00', + 'unit': None, + 'value': None, + }), + 'vid': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:44:10.472000+00:00', + 'unit': None, + 'value': None, + }), + }), + 'odorSensor': dict({ + 'odorLevel': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:43:38.992000+00:00', + 'unit': None, + 'value': None, + }), + }), + 'powerConsumptionReport': dict({ + 'powerConsumption': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:43:53.364000+00:00', + 'unit': None, + 'value': None, + }), + }), + 'relativeHumidityMeasurement': dict({ + 'humidity': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:43:35.291000+00:00', + 'unit': '%', + 'value': 0, + }), + }), + 'remoteControlStatus': dict({ + 'remoteControlEnabled': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:43:39.097000+00:00', + 'unit': None, + 'value': None, + }), + }), + 'switch': dict({ + 'switch': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:44:10.518000+00:00', + 'unit': None, + 'value': None, + }), + }), + 'temperatureMeasurement': dict({ + 'temperature': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:44:10.373000+00:00', + 'unit': None, + 'value': None, + }), + 'temperatureRange': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + }), + 'thermostatCoolingSetpoint': dict({ + 'coolingSetpoint': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:43:59.136000+00:00', + 'unit': None, + 'value': None, + }), + 'coolingSetpointRange': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + }), + 'veryFineDustSensor': dict({ + 'veryFineDustLevel': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:43:38.529000+00:00', + 'unit': 'μg/m^3', + 'value': None, + }), + }), + }), + 'main': dict({ + 'airConditionerFanMode': dict({ + 'availableAcFanModes': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'fanMode': dict({ + 'data': None, + 'timestamp': '2025-02-09T09:14:39.249000+00:00', + 'unit': None, + 'value': 'low', + }), + 'supportedAcFanModes': dict({ + 'data': None, + 'timestamp': '2025-02-09T09:14:39.249000+00:00', + 'unit': None, + 'value': list([ + 'auto', + 'low', + 'medium', + 'high', + 'turbo', + ]), + }), + }), + 'airConditionerMode': dict({ + 'airConditionerMode': dict({ + 'data': None, + 'timestamp': '2025-02-09T09:14:39.642000+00:00', + 'unit': None, + 'value': 'heat', + }), + 'availableAcModes': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'supportedAcModes': dict({ + 'data': None, + 'timestamp': '2024-09-10T10:26:28.781000+00:00', + 'unit': None, + 'value': list([ + 'cool', + 'dry', + 'wind', + 'auto', + 'heat', + ]), + }), + }), + 'audioVolume': dict({ + 'volume': dict({ + 'data': None, + 'timestamp': '2025-02-09T09:14:39.642000+00:00', + 'unit': '%', + 'value': 100, + }), + }), + 'custom.airConditionerOptionalMode': dict({ + 'acOptionalMode': dict({ + 'data': None, + 'timestamp': '2025-02-09T09:14:39.642000+00:00', + 'unit': None, + 'value': 'off', + }), + 'supportedAcOptionalMode': dict({ + 'data': None, + 'timestamp': '2024-09-10T10:26:28.781000+00:00', + 'unit': None, + 'value': list([ + 'off', + 'windFree', + ]), + }), + }), + 'custom.airConditionerTropicalNightMode': dict({ + 'acTropicalNightModeLevel': dict({ + 'data': None, + 'timestamp': '2025-02-09T09:14:39.642000+00:00', + 'unit': None, + 'value': 0, + }), + }), + 'custom.autoCleaningMode': dict({ + 'autoCleaningMode': dict({ + 'data': None, + 'timestamp': '2025-02-09T09:14:39.642000+00:00', + 'unit': None, + 'value': 'off', + }), + 'operatingState': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'progress': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'supportedAutoCleaningModes': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'supportedOperatingStates': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'timedCleanDuration': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'timedCleanDurationRange': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + }), + 'custom.disabledCapabilities': dict({ + 'disabledCapabilities': dict({ + 'data': None, + 'timestamp': '2025-02-09T09:14:39.642000+00:00', + 'unit': None, + 'value': list([ + 'remoteControlStatus', + 'airQualitySensor', + 'dustSensor', + 'veryFineDustSensor', + 'custom.dustFilter', + 'custom.deodorFilter', + 'custom.deviceReportStateConfiguration', + 'samsungce.dongleSoftwareInstallation', + 'demandResponseLoadControl', + 'custom.airConditionerOdorController', + ]), + }), + }), + 'custom.disabledComponents': dict({ + 'disabledComponents': dict({ + 'data': None, + 'timestamp': '2025-02-09T09:14:39.642000+00:00', + 'unit': None, + 'value': list([ + '1', + ]), + }), + }), + 'custom.energyType': dict({ + 'drMaxDuration': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'energySavingInfo': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'energySavingLevel': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'energySavingOperation': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'energySavingOperationSupport': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'energySavingSupport': dict({ + 'data': None, + 'timestamp': '2021-12-29T07:29:17.526000+00:00', + 'unit': None, + 'value': 'False', + }), + 'energyType': dict({ + 'data': None, + 'timestamp': '2024-09-10T10:26:28.781000+00:00', + 'unit': None, + 'value': '1.0', + }), + 'notificationTemplateID': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'supportedEnergySavingLevels': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + }), + 'custom.spiMode': dict({ + 'spiMode': dict({ + 'data': None, + 'timestamp': '2025-02-09T09:14:39.642000+00:00', + 'unit': None, + 'value': 'off', + }), + }), + 'custom.thermostatSetpointControl': dict({ + 'maximumSetpoint': dict({ + 'data': None, + 'timestamp': '2024-09-10T10:26:28.781000+00:00', + 'unit': 'C', + 'value': 30, + }), + 'minimumSetpoint': dict({ + 'data': None, + 'timestamp': '2025-01-08T06:30:58.307000+00:00', + 'unit': 'C', + 'value': 16, + }), + }), + 'demandResponseLoadControl': dict({ + 'drlcStatus': dict({ + 'data': None, + 'timestamp': '2024-09-10T10:26:28.781000+00:00', + 'unit': None, + 'value': dict({ + 'drlcLevel': -1, + 'drlcType': 1, + 'duration': 0, + 'override': False, + 'start': '1970-01-01T00:00:00Z', + }), + }), + }), + 'execute': dict({ + 'data': dict({ + 'data': dict({ + 'href': '/temperature/desired/0', + }), + 'timestamp': '2023-07-19T03:07:43.270000+00:00', + 'unit': None, + 'value': dict({ + 'payload': dict({ + 'if': list([ + 'oic.if.baseline', + 'oic.if.a', + ]), + 'range': list([ + 16.0, + 30.0, + ]), + 'rt': list([ + 'oic.r.temperature', + ]), + 'temperature': 22.0, + 'units': 'C', + }), + }), + }), + }), + 'fanOscillationMode': dict({ + 'availableFanOscillationModes': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'fanOscillationMode': dict({ + 'data': None, + 'timestamp': '2025-02-09T09:14:39.249000+00:00', + 'unit': None, + 'value': 'fixed', + }), + 'supportedFanOscillationModes': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:43:35.782000+00:00', + 'unit': None, + 'value': None, + }), + }), + 'ocf': dict({ + 'di': dict({ + 'data': None, + 'timestamp': '2024-09-10T10:26:28.552000+00:00', + 'unit': None, + 'value': '96a5ef74-5832-a84b-f1f7-ca799957065d', + }), + 'dmv': dict({ + 'data': None, + 'timestamp': '2024-09-10T10:26:28.552000+00:00', + 'unit': None, + 'value': 'res.1.1.0,sh.1.1.0', + }), + 'icv': dict({ + 'data': None, + 'timestamp': '2024-09-10T10:26:28.552000+00:00', + 'unit': None, + 'value': 'core.1.1.0', + }), + 'mndt': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:43:35.912000+00:00', + 'unit': None, + 'value': None, + }), + 'mnfv': dict({ + 'data': None, + 'timestamp': '2024-09-10T10:26:28.552000+00:00', + 'unit': None, + 'value': '0.1.0', + }), + 'mnhw': dict({ + 'data': None, + 'timestamp': '2024-09-10T10:26:28.552000+00:00', + 'unit': None, + 'value': '1.0', + }), + 'mnml': dict({ + 'data': None, + 'timestamp': '2024-09-10T10:26:28.552000+00:00', + 'unit': None, + 'value': 'http://www.samsung.com', + }), + 'mnmn': dict({ + 'data': None, + 'timestamp': '2024-09-10T10:26:28.552000+00:00', + 'unit': None, + 'value': 'Samsung Electronics', + }), + 'mnmo': dict({ + 'data': None, + 'timestamp': '2024-09-10T10:26:28.781000+00:00', + 'unit': None, + 'value': 'ARTIK051_KRAC_18K|10193441|60010132001111110200000000000000', + }), + 'mnos': dict({ + 'data': None, + 'timestamp': '2024-09-10T10:26:28.552000+00:00', + 'unit': None, + 'value': 'TizenRT2.0', + }), + 'mnpv': dict({ + 'data': None, + 'timestamp': '2024-09-10T10:26:28.552000+00:00', + 'unit': None, + 'value': '0G3MPDCKA00010E', + }), + 'mnsl': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:43:35.803000+00:00', + 'unit': None, + 'value': None, + }), + 'n': dict({ + 'data': None, + 'timestamp': '2024-09-10T10:26:28.552000+00:00', + 'unit': None, + 'value': '[room a/c] Samsung', + }), + 'pi': dict({ + 'data': None, + 'timestamp': '2024-09-10T10:26:28.552000+00:00', + 'unit': None, + 'value': '96a5ef74-5832-a84b-f1f7-ca799957065d', + }), + 'st': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:43:35.933000+00:00', + 'unit': None, + 'value': None, + }), + 'vid': dict({ + 'data': None, + 'timestamp': '2024-09-10T10:26:28.552000+00:00', + 'unit': None, + 'value': 'DA-AC-RAC-000001', + }), + }), + 'powerConsumptionReport': dict({ + 'powerConsumption': dict({ + 'data': None, + 'timestamp': '2025-02-09T16:15:33.639000+00:00', + 'unit': None, + 'value': dict({ + 'deltaEnergy': 400, + 'end': '2025-02-09T16:15:33Z', + 'energy': 2247300, + 'energySaved': 0, + 'persistedEnergy': 2247300, + 'power': 0, + 'powerEnergy': 0.0, + 'start': '2025-02-09T15:45:29Z', + }), + }), + }), + 'refresh': dict({ + }), + 'relativeHumidityMeasurement': dict({ + 'humidity': dict({ + 'data': None, + 'timestamp': '2024-12-30T13:10:23.759000+00:00', + 'unit': '%', + 'value': 60, + }), + }), + 'samsungce.deviceIdentification': dict({ + 'binaryId': dict({ + 'data': None, + 'timestamp': '2025-02-08T00:44:53.855000+00:00', + 'unit': None, + 'value': 'ARTIK051_KRAC_18K', + }), + 'description': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'micomAssayCode': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'modelClassificationCode': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'modelName': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'releaseYear': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'serialNumber': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'serialNumberExtra': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + }), + 'samsungce.driverVersion': dict({ + 'versionNumber': dict({ + 'data': None, + 'timestamp': '2024-09-04T06:35:09.557000+00:00', + 'unit': None, + 'value': 24070101, + }), + }), + 'samsungce.selfCheck': dict({ + 'errors': dict({ + 'data': None, + 'timestamp': '2025-02-08T00:44:53.349000+00:00', + 'unit': None, + 'value': list([ + ]), + }), + 'progress': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'result': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'status': dict({ + 'data': None, + 'timestamp': '2025-02-08T00:44:53.549000+00:00', + 'unit': None, + 'value': 'ready', + }), + 'supportedActions': dict({ + 'data': None, + 'timestamp': '2024-09-04T06:35:09.557000+00:00', + 'unit': None, + 'value': list([ + 'start', + ]), + }), + }), + 'samsungce.softwareUpdate': dict({ + 'availableModules': dict({ + 'data': None, + 'timestamp': '2025-02-08T00:44:53.855000+00:00', + 'unit': None, + 'value': list([ + ]), + }), + 'lastUpdatedDate': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'newVersionAvailable': dict({ + 'data': None, + 'timestamp': '2025-02-08T00:44:53.855000+00:00', + 'unit': None, + 'value': 'False', + }), + 'operatingState': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'otnDUID': dict({ + 'data': None, + 'timestamp': '2025-02-08T00:44:53.855000+00:00', + 'unit': None, + 'value': '43CEZFTFFL7Z2', + }), + 'progress': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'targetModule': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + }), + 'switch': dict({ + 'switch': dict({ + 'data': None, + 'timestamp': '2025-02-09T16:37:54.072000+00:00', + 'unit': None, + 'value': 'off', + }), + }), + 'temperatureMeasurement': dict({ + 'temperature': dict({ + 'data': None, + 'timestamp': '2025-02-09T16:33:29.164000+00:00', + 'unit': 'C', + 'value': 25, + }), + 'temperatureRange': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + }), + 'thermostatCoolingSetpoint': dict({ + 'coolingSetpoint': dict({ + 'data': None, + 'timestamp': '2025-02-09T09:15:11.608000+00:00', + 'unit': 'C', + 'value': 25, + }), + 'coolingSetpointRange': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + }), + }), + }), + }) +# --- diff --git a/tests/components/smartthings/test_diagnostics.py b/tests/components/smartthings/test_diagnostics.py new file mode 100644 index 00000000000..22f1c77cdd1 --- /dev/null +++ b/tests/components/smartthings/test_diagnostics.py @@ -0,0 +1,44 @@ +"""Test SmartThings diagnostics.""" + +from unittest.mock import AsyncMock, patch + +import pytest +from syrupy import SnapshotAssertion +from syrupy.filters import props + +from homeassistant.components.smartthings.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from . import setup_integration + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_device +from tests.typing import ClientSessionGenerator + + +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) +async def test_device( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + device_registry: dr.DeviceRegistry, + devices: AsyncMock, + mock_smartthings: AsyncMock, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test generating diagnostics for a device entry.""" + await setup_integration(hass, mock_config_entry) + + device = device_registry.async_get_device( + identifiers={(DOMAIN, "96a5ef74-5832-a84b-f1f7-ca799957065d")} + ) + + with patch("homeassistant.components.smartthings.diagnostics.EVENT_WAIT_TIME", 0.1): + diag = await get_diagnostics_for_device( + hass, hass_client, mock_config_entry, device + ) + + assert diag == snapshot( + exclude=props("last_changed", "last_reported", "last_updated") + ) From 744a7a0e826e67f820d086218e900ed520ea3215 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 27 Feb 2025 14:51:40 +0000 Subject: [PATCH 1891/3148] Fix conversation agent fallback (#139421) --- .../components/assist_pipeline/pipeline.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/assist_pipeline/pipeline.py b/homeassistant/components/assist_pipeline/pipeline.py index 788a207b83a..75811a0ec36 100644 --- a/homeassistant/components/assist_pipeline/pipeline.py +++ b/homeassistant/components/assist_pipeline/pipeline.py @@ -1103,12 +1103,16 @@ class PipelineRun: ) & conversation.ConversationEntityFeature.CONTROL: intent_filter = _async_local_fallback_intent_filter - # Try local intents first, if preferred. - elif self.pipeline.prefer_local_intents and ( - intent_response := await conversation.async_handle_intents( - self.hass, - user_input, - intent_filter=intent_filter, + # Try local intents + if ( + intent_response is None + and self.pipeline.prefer_local_intents + and ( + intent_response := await conversation.async_handle_intents( + self.hass, + user_input, + intent_filter=intent_filter, + ) ) ): # Local intent matched From df594748cffe38a9fad44326c056c1019dfe6938 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 27 Feb 2025 16:00:24 +0100 Subject: [PATCH 1892/3148] Bump ruff to 0.9.8 (#139434) --- .pre-commit-config.yaml | 2 +- homeassistant/components/zone/__init__.py | 4 ++-- requirements_test_pre_commit.txt | 2 +- script/hassfest/docker/Dockerfile | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5b701b21b9e..37114684c9f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.9.7 + rev: v0.9.8 hooks: - id: ruff args: diff --git a/homeassistant/components/zone/__init__.py b/homeassistant/components/zone/__init__.py index 1c43a79e10e..813425c95f2 100644 --- a/homeassistant/components/zone/__init__.py +++ b/homeassistant/components/zone/__init__.py @@ -363,7 +363,7 @@ class Zone(collection.CollectionEntity): """Return entity instance initialized from storage.""" zone = cls(config) zone.editable = True - zone._generate_attrs() # noqa: SLF001 + zone._generate_attrs() return zone @classmethod @@ -371,7 +371,7 @@ class Zone(collection.CollectionEntity): """Return entity instance initialized from yaml.""" zone = cls(config) zone.editable = False - zone._generate_attrs() # noqa: SLF001 + zone._generate_attrs() return zone @property diff --git a/requirements_test_pre_commit.txt b/requirements_test_pre_commit.txt index 8c9308e739b..c133c4b544a 100644 --- a/requirements_test_pre_commit.txt +++ b/requirements_test_pre_commit.txt @@ -1,5 +1,5 @@ # Automatically generated from .pre-commit-config.yaml by gen_requirements_all.py, do not edit codespell==2.4.1 -ruff==0.9.7 +ruff==0.9.8 yamllint==1.35.1 diff --git a/script/hassfest/docker/Dockerfile b/script/hassfest/docker/Dockerfile index 1f177643bd5..c09d547ba79 100644 --- a/script/hassfest/docker/Dockerfile +++ b/script/hassfest/docker/Dockerfile @@ -24,7 +24,7 @@ RUN --mount=from=ghcr.io/astral-sh/uv:0.6.1,source=/uv,target=/bin/uv \ --no-cache \ -c /usr/src/homeassistant/homeassistant/package_constraints.txt \ -r /usr/src/homeassistant/requirements.txt \ - stdlib-list==0.10.0 pipdeptree==2.25.0 tqdm==4.67.1 ruff==0.9.7 \ + stdlib-list==0.10.0 pipdeptree==2.25.0 tqdm==4.67.1 ruff==0.9.8 \ PyTurboJPEG==1.7.5 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 hassil==2.2.3 home-assistant-intents==2025.2.26 mutagen==1.47.0 pymicro-vad==1.0.1 pyspeex-noise==1.0.2 LABEL "name"="hassfest" From b02eaed6b0f4cbfc4ae341ee2946cb773628b70d Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Thu, 27 Feb 2025 16:42:08 +0100 Subject: [PATCH 1893/3148] Update frontend to 20250227.0 (#139437) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 7bd361041e1..5399b22f075 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20250226.0"] + "requirements": ["home-assistant-frontend==20250227.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 012206d2833..b8e0b417353 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -37,7 +37,7 @@ habluetooth==3.24.1 hass-nabucasa==0.92.0 hassil==2.2.3 home-assistant-bluetooth==1.13.1 -home-assistant-frontend==20250226.0 +home-assistant-frontend==20250227.0 home-assistant-intents==2025.2.26 httpx==0.28.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 78848c01ed3..920bb3ac81c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1152,7 +1152,7 @@ hole==0.8.0 holidays==0.67 # homeassistant.components.frontend -home-assistant-frontend==20250226.0 +home-assistant-frontend==20250227.0 # homeassistant.components.conversation home-assistant-intents==2025.2.26 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index aa7f720b8f1..2ddd495c900 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -981,7 +981,7 @@ hole==0.8.0 holidays==0.67 # homeassistant.components.frontend -home-assistant-frontend==20250226.0 +home-assistant-frontend==20250227.0 # homeassistant.components.conversation home-assistant-intents==2025.2.26 From 0e1602ff7135814b6ba32b6733a83411e0a8626a Mon Sep 17 00:00:00 2001 From: fwestenberg <47930023+fwestenberg@users.noreply.github.com> Date: Wed, 26 Feb 2025 20:31:24 +0100 Subject: [PATCH 1894/3148] Bump stookwijzer==1.6.1 (#139380) --- homeassistant/components/stookwijzer/__init__.py | 8 ++++---- homeassistant/components/stookwijzer/config_flow.py | 6 +++--- homeassistant/components/stookwijzer/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/stookwijzer/conftest.py | 8 ++++---- tests/components/stookwijzer/test_config_flow.py | 6 +++--- tests/components/stookwijzer/test_init.py | 6 +++--- 8 files changed, 20 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/stookwijzer/__init__.py b/homeassistant/components/stookwijzer/__init__.py index a4a00e4d1b8..9adfc09de0e 100644 --- a/homeassistant/components/stookwijzer/__init__.py +++ b/homeassistant/components/stookwijzer/__init__.py @@ -42,12 +42,12 @@ async def async_migrate_entry( LOGGER.debug("Migrating from version %s", entry.version) if entry.version == 1: - longitude, latitude = await Stookwijzer.async_transform_coordinates( + xy = await Stookwijzer.async_transform_coordinates( entry.data[CONF_LOCATION][CONF_LATITUDE], entry.data[CONF_LOCATION][CONF_LONGITUDE], ) - if not longitude or not latitude: + if not xy: ir.async_create_issue( hass, DOMAIN, @@ -65,8 +65,8 @@ async def async_migrate_entry( entry, version=2, data={ - CONF_LATITUDE: latitude, - CONF_LONGITUDE: longitude, + CONF_LATITUDE: xy["x"], + CONF_LONGITUDE: xy["y"], }, ) diff --git a/homeassistant/components/stookwijzer/config_flow.py b/homeassistant/components/stookwijzer/config_flow.py index 52283e4842d..ff14bce26e6 100644 --- a/homeassistant/components/stookwijzer/config_flow.py +++ b/homeassistant/components/stookwijzer/config_flow.py @@ -25,14 +25,14 @@ class StookwijzerFlowHandler(ConfigFlow, domain=DOMAIN): """Handle a flow initialized by the user.""" errors = {} if user_input is not None: - longitude, latitude = await Stookwijzer.async_transform_coordinates( + xy = await Stookwijzer.async_transform_coordinates( user_input[CONF_LOCATION][CONF_LATITUDE], user_input[CONF_LOCATION][CONF_LONGITUDE], ) - if longitude and latitude: + if xy: return self.async_create_entry( title="Stookwijzer", - data={CONF_LATITUDE: latitude, CONF_LONGITUDE: longitude}, + data={CONF_LATITUDE: xy["x"], CONF_LONGITUDE: xy["y"]}, ) errors["base"] = "unknown" diff --git a/homeassistant/components/stookwijzer/manifest.json b/homeassistant/components/stookwijzer/manifest.json index 9b4cea567be..dd10f57f485 100644 --- a/homeassistant/components/stookwijzer/manifest.json +++ b/homeassistant/components/stookwijzer/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/stookwijzer", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["stookwijzer==1.6.0"] + "requirements": ["stookwijzer==1.6.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 082524036e8..dcda559d7d3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2808,7 +2808,7 @@ statsd==3.2.1 steamodd==4.21 # homeassistant.components.stookwijzer -stookwijzer==1.6.0 +stookwijzer==1.6.1 # homeassistant.components.streamlabswater streamlabswater==1.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8cac6cc79d0..5ed82bd81b0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2269,7 +2269,7 @@ statsd==3.2.1 steamodd==4.21 # homeassistant.components.stookwijzer -stookwijzer==1.6.0 +stookwijzer==1.6.1 # homeassistant.components.streamlabswater streamlabswater==1.0.1 diff --git a/tests/components/stookwijzer/conftest.py b/tests/components/stookwijzer/conftest.py index 95a60e623a3..40582dc4be3 100644 --- a/tests/components/stookwijzer/conftest.py +++ b/tests/components/stookwijzer/conftest.py @@ -70,10 +70,10 @@ def mock_stookwijzer() -> Generator[MagicMock]: new=stookwijzer_mock, ), ): - stookwijzer_mock.async_transform_coordinates.return_value = ( - 450000.123456789, - 200000.123456789, - ) + stookwijzer_mock.async_transform_coordinates.return_value = { + "x": 450000.123456789, + "y": 200000.123456789, + } client = stookwijzer_mock.return_value client.lki = 2 diff --git a/tests/components/stookwijzer/test_config_flow.py b/tests/components/stookwijzer/test_config_flow.py index 6dddf83c27a..060d2bdc26c 100644 --- a/tests/components/stookwijzer/test_config_flow.py +++ b/tests/components/stookwijzer/test_config_flow.py @@ -32,8 +32,8 @@ async def test_full_user_flow( assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "Stookwijzer" assert result["data"] == { - CONF_LATITUDE: 200000.123456789, - CONF_LONGITUDE: 450000.123456789, + CONF_LATITUDE: 450000.123456789, + CONF_LONGITUDE: 200000.123456789, } assert len(mock_setup_entry.mock_calls) == 1 @@ -47,7 +47,7 @@ async def test_connection_error( ) -> None: """Test user configuration flow while connection fails.""" original_return_value = mock_stookwijzer.async_transform_coordinates.return_value - mock_stookwijzer.async_transform_coordinates.return_value = (None, None) + mock_stookwijzer.async_transform_coordinates.return_value = None result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} diff --git a/tests/components/stookwijzer/test_init.py b/tests/components/stookwijzer/test_init.py index ddefb6be772..4306b9afc26 100644 --- a/tests/components/stookwijzer/test_init.py +++ b/tests/components/stookwijzer/test_init.py @@ -66,8 +66,8 @@ async def test_migrate_entry( assert mock_v1_config_entry.version == 2 assert mock_v1_config_entry.data == { - CONF_LATITUDE: 200000.123456789, - CONF_LONGITUDE: 450000.123456789, + CONF_LATITUDE: 450000.123456789, + CONF_LONGITUDE: 200000.123456789, } @@ -81,7 +81,7 @@ async def test_entry_migration_failure( assert mock_v1_config_entry.version == 1 # Failed getting the transformed coordinates - mock_stookwijzer.async_transform_coordinates.return_value = (None, None) + mock_stookwijzer.async_transform_coordinates.return_value = None mock_v1_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_v1_config_entry.entry_id) From 3effc2e182d33f693d6ba96d0726c353c79e5d4d Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Wed, 26 Feb 2025 15:22:08 -0500 Subject: [PATCH 1895/3148] Bump ZHA to 0.0.51 (#139383) * Bump ZHA to 0.0.51 * Fix unit tests not accounting for primary entities --- homeassistant/components/zha/entity.py | 4 ++++ homeassistant/components/zha/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../zha/snapshots/test_diagnostics.ambr | 11 +--------- tests/components/zha/test_sensor.py | 21 ++++++++++++------- tests/components/zha/test_websocket_api.py | 7 +++++-- 7 files changed, 26 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/zha/entity.py b/homeassistant/components/zha/entity.py index 499721722fa..e3339661d15 100644 --- a/homeassistant/components/zha/entity.py +++ b/homeassistant/components/zha/entity.py @@ -59,6 +59,10 @@ class ZHAEntity(LogMixin, RestoreEntity, Entity): def name(self) -> str | UndefinedType | None: """Return the name of the entity.""" meta = self.entity_data.entity.info_object + if meta.primary: + self._attr_name = None + return super().name + original_name = super().name if original_name not in (UNDEFINED, None) or meta.fallback_name is None: diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 25e4de77a32..0cc2524469e 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -21,7 +21,7 @@ "zha", "universal_silabs_flasher" ], - "requirements": ["zha==0.0.50"], + "requirements": ["zha==0.0.51"], "usb": [ { "vid": "10C4", diff --git a/requirements_all.txt b/requirements_all.txt index dcda559d7d3..1a7c37a6cd3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3149,7 +3149,7 @@ zeroconf==0.145.1 zeversolar==0.3.2 # homeassistant.components.zha -zha==0.0.50 +zha==0.0.51 # homeassistant.components.zhong_hong zhong-hong-hvac==1.0.13 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5ed82bd81b0..dea6769aa39 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2538,7 +2538,7 @@ zeroconf==0.145.1 zeversolar==0.3.2 # homeassistant.components.zha -zha==0.0.50 +zha==0.0.51 # homeassistant.components.zwave_js zwave-js-server-python==0.60.1 diff --git a/tests/components/zha/snapshots/test_diagnostics.ambr b/tests/components/zha/snapshots/test_diagnostics.ambr index 7a599b00a21..ba8aa9ea245 100644 --- a/tests/components/zha/snapshots/test_diagnostics.ambr +++ b/tests/components/zha/snapshots/test_diagnostics.ambr @@ -179,16 +179,7 @@ }), '0x0010': dict({ 'attribute': "ZCLAttributeDef(id=0x0010, name='cie_addr', type=, zcl_type=, access=, mandatory=True, is_manufacturer_specific=False)", - 'value': list([ - 50, - 79, - 50, - 2, - 0, - 141, - 21, - 0, - ]), + 'value': None, }), '0x0011': dict({ 'attribute': "ZCLAttributeDef(id=0x0011, name='zone_id', type=, zcl_type=, access=, mandatory=True, is_manufacturer_specific=False)", diff --git a/tests/components/zha/test_sensor.py b/tests/components/zha/test_sensor.py index 2d69cf1ff36..88fb9974c1b 100644 --- a/tests/components/zha/test_sensor.py +++ b/tests/components/zha/test_sensor.py @@ -30,6 +30,7 @@ from homeassistant.core import HomeAssistant from .common import send_attributes_report from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE +ENTITY_ID_NO_PREFIX = "sensor.fakemanufacturer_fakemodel" ENTITY_ID_PREFIX = "sensor.fakemanufacturer_fakemodel_{}" @@ -335,7 +336,7 @@ async def async_test_pi_heating_demand( "humidity", async_test_humidity, 1, - None, + {}, None, STATE_UNKNOWN, ), @@ -344,7 +345,7 @@ async def async_test_pi_heating_demand( "temperature", async_test_temperature, 1, - None, + {}, None, STATE_UNKNOWN, ), @@ -353,7 +354,7 @@ async def async_test_pi_heating_demand( "pressure", async_test_pressure, 1, - None, + {}, None, STATE_UNKNOWN, ), @@ -362,7 +363,7 @@ async def async_test_pi_heating_demand( "illuminance", async_test_illuminance, 1, - None, + {}, None, STATE_UNKNOWN, ), @@ -492,7 +493,7 @@ async def async_test_pi_heating_demand( "device_temperature", async_test_device_temperature, 1, - None, + {}, None, STATE_UNKNOWN, ), @@ -501,7 +502,7 @@ async def async_test_pi_heating_demand( "setpoint_change_source", async_test_setpoint_change_source, 10, - None, + {}, None, STATE_UNKNOWN, ), @@ -510,7 +511,7 @@ async def async_test_pi_heating_demand( "pi_heating_demand", async_test_pi_heating_demand, 10, - None, + {}, None, STATE_UNKNOWN, ), @@ -558,7 +559,6 @@ async def test_sensor( gateway.get_or_create_device(zigpy_device) await gateway.async_device_initialized(zigpy_device) await hass.async_block_till_done(wait_background_tasks=True) - entity_id = ENTITY_ID_PREFIX.format(entity_suffix) zigpy_device = zigpy_device_mock( { @@ -570,6 +570,11 @@ async def test_sensor( } ) + if hass.states.get(ENTITY_ID_NO_PREFIX): + entity_id = ENTITY_ID_NO_PREFIX + else: + entity_id = ENTITY_ID_PREFIX.format(entity_suffix) + assert hass.states.get(entity_id).state == initial_sensor_state # test sensor associated logic diff --git a/tests/components/zha/test_websocket_api.py b/tests/components/zha/test_websocket_api.py index f6afee9eb83..ae1ea90d1f9 100644 --- a/tests/components/zha/test_websocket_api.py +++ b/tests/components/zha/test_websocket_api.py @@ -420,8 +420,11 @@ async def test_list_groupable_devices( assert entity_reference[ATTR_NAME] is not None assert entity_reference["entity_id"] is not None - for entity_reference in endpoint["entities"]: - assert entity_reference["original_name"] is not None + if len(endpoint["entities"]) == 1: + assert endpoint["entities"][0]["original_name"] is None + else: + for entity_reference in endpoint["entities"]: + assert entity_reference["original_name"] is not None # Make sure there are no groupable devices when the device is unavailable # Make device unavailable From 585b950a467a65f16f4751aef127f603a39b5576 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Wed, 26 Feb 2025 14:14:03 -0600 Subject: [PATCH 1896/3148] Bump intents to 2025.2.26 (#139387) --- homeassistant/components/conversation/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- script/hassfest/docker/Dockerfile | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/conversation/manifest.json b/homeassistant/components/conversation/manifest.json index 2d4a8053d75..c4f1860eed6 100644 --- a/homeassistant/components/conversation/manifest.json +++ b/homeassistant/components/conversation/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/conversation", "integration_type": "system", "quality_scale": "internal", - "requirements": ["hassil==2.2.3", "home-assistant-intents==2025.2.5"] + "requirements": ["hassil==2.2.3", "home-assistant-intents==2025.2.26"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index b248be0eb96..c49580ae47b 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -38,7 +38,7 @@ hass-nabucasa==0.92.0 hassil==2.2.3 home-assistant-bluetooth==1.13.1 home-assistant-frontend==20250226.0 -home-assistant-intents==2025.2.5 +home-assistant-intents==2025.2.26 httpx==0.28.1 ifaddr==0.2.0 Jinja2==3.1.5 diff --git a/requirements_all.txt b/requirements_all.txt index 1a7c37a6cd3..4580eac890d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1155,7 +1155,7 @@ holidays==0.67 home-assistant-frontend==20250226.0 # homeassistant.components.conversation -home-assistant-intents==2025.2.5 +home-assistant-intents==2025.2.26 # homeassistant.components.homematicip_cloud homematicip==1.1.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index dea6769aa39..10365827696 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -984,7 +984,7 @@ holidays==0.67 home-assistant-frontend==20250226.0 # homeassistant.components.conversation -home-assistant-intents==2025.2.5 +home-assistant-intents==2025.2.26 # homeassistant.components.homematicip_cloud homematicip==1.1.7 diff --git a/script/hassfest/docker/Dockerfile b/script/hassfest/docker/Dockerfile index b2e4005cf79..1f177643bd5 100644 --- a/script/hassfest/docker/Dockerfile +++ b/script/hassfest/docker/Dockerfile @@ -25,7 +25,7 @@ RUN --mount=from=ghcr.io/astral-sh/uv:0.6.1,source=/uv,target=/bin/uv \ -c /usr/src/homeassistant/homeassistant/package_constraints.txt \ -r /usr/src/homeassistant/requirements.txt \ stdlib-list==0.10.0 pipdeptree==2.25.0 tqdm==4.67.1 ruff==0.9.7 \ - PyTurboJPEG==1.7.5 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 hassil==2.2.3 home-assistant-intents==2025.2.5 mutagen==1.47.0 pymicro-vad==1.0.1 pyspeex-noise==1.0.2 + PyTurboJPEG==1.7.5 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 hassil==2.2.3 home-assistant-intents==2025.2.26 mutagen==1.47.0 pymicro-vad==1.0.1 pyspeex-noise==1.0.2 LABEL "name"="hassfest" LABEL "maintainer"="Home Assistant " From fa6d7d5e3c644ace1b2f88624ddbad390bea5b99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Thu, 27 Feb 2025 12:00:14 +0100 Subject: [PATCH 1897/3148] Fix fetch options error for Home connect (#139392) * Handle errors when obtaining options definitions * Don't fetch program options if the program key is unknown * Test to ensure that available program endpoint is not called on unknown program --- .../components/home_connect/coordinator.py | 31 +++++--- .../home_connect/test_coordinator.py | 22 +++++- tests/components/home_connect/test_entity.py | 73 +++++++++++++++++++ 3 files changed, 113 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/home_connect/coordinator.py b/homeassistant/components/home_connect/coordinator.py index 80ae8173d86..d9200b282c9 100644 --- a/homeassistant/components/home_connect/coordinator.py +++ b/homeassistant/components/home_connect/coordinator.py @@ -440,13 +440,27 @@ class HomeConnectCoordinator( self, ha_id: str, program_key: ProgramKey ) -> dict[OptionKey, ProgramDefinitionOption]: """Get options with constraints for appliance.""" - return { - option.key: option - for option in ( - await self.client.get_available_program(ha_id, program_key=program_key) - ).options - or [] - } + if program_key is ProgramKey.UNKNOWN: + return {} + try: + return { + option.key: option + for option in ( + await self.client.get_available_program( + ha_id, program_key=program_key + ) + ).options + or [] + } + except HomeConnectError as error: + _LOGGER.debug( + "Error fetching options for %s: %s", + ha_id, + error + if isinstance(error, HomeConnectApiError) + else type(error).__name__, + ) + return {} async def update_options( self, ha_id: str, event_key: EventKey, program_key: ProgramKey @@ -456,8 +470,7 @@ class HomeConnectCoordinator( events = self.data[ha_id].events options_to_notify = options.copy() options.clear() - if program_key is not ProgramKey.UNKNOWN: - options.update(await self.get_options_definitions(ha_id, program_key)) + options.update(await self.get_options_definitions(ha_id, program_key)) for option in options.values(): option_value = option.constraints.default if option.constraints else None diff --git a/tests/components/home_connect/test_coordinator.py b/tests/components/home_connect/test_coordinator.py index 51f42a98f42..3dd9ffbe7c1 100644 --- a/tests/components/home_connect/test_coordinator.py +++ b/tests/components/home_connect/test_coordinator.py @@ -75,21 +75,35 @@ async def test_coordinator_update_failing_get_appliances( assert config_entry.state == ConfigEntryState.SETUP_RETRY -async def test_coordinator_update_failing_get_settings_status( +@pytest.mark.parametrize( + "mock_method", + [ + "get_settings", + "get_status", + "get_all_programs", + "get_available_commands", + "get_available_program", + ], +) +async def test_coordinator_update_failing( + mock_method: str, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - client_with_exception: MagicMock, + client: MagicMock, ) -> None: """Test that although is not possible to get settings and status, the config entry is loaded. This is for cases where some appliances are reachable and some are not in the same configuration entry. """ - # Get home appliances does pass at client_with_exception.get_home_appliances mock, so no need to mock it again + setattr(client, mock_method, AsyncMock(side_effect=HomeConnectError())) + assert config_entry.state == ConfigEntryState.NOT_LOADED - await integration_setup(client_with_exception) + await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED + getattr(client, mock_method).assert_called() + @pytest.mark.parametrize("appliance_ha_id", ["Dishwasher"], indirect=True) @pytest.mark.parametrize( diff --git a/tests/components/home_connect/test_entity.py b/tests/components/home_connect/test_entity.py index f173cda0b0c..6ac9a2c1d90 100644 --- a/tests/components/home_connect/test_entity.py +++ b/tests/components/home_connect/test_entity.py @@ -23,6 +23,7 @@ from aiohomeconnect.model.error import ( SelectedProgramNotSetError, ) from aiohomeconnect.model.program import ( + EnumerateProgram, ProgramDefinitionConstraints, ProgramDefinitionOption, ) @@ -234,6 +235,78 @@ async def test_program_options_retrieval( assert hass.states.is_state(entity_id, STATE_UNKNOWN) +@pytest.mark.parametrize( + ("array_of_programs_program_arg", "event_key"), + [ + ( + "active", + EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM, + ), + ( + "selected", + EventKey.BSH_COMMON_ROOT_SELECTED_PROGRAM, + ), + ], +) +async def test_no_options_retrieval_on_unknown_program( + array_of_programs_program_arg: str, + event_key: EventKey, + appliance_ha_id: str, + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, +) -> None: + """Test that no options are retrieved when the program is unknown.""" + + async def get_all_programs_with_options_mock(ha_id: str) -> ArrayOfPrograms: + return ArrayOfPrograms( + **( + { + "programs": [ + EnumerateProgram(ProgramKey.UNKNOWN, "unknown program") + ], + array_of_programs_program_arg: Program( + ProgramKey.UNKNOWN, options=[] + ), + } + ) + ) + + client.get_all_programs = AsyncMock(side_effect=get_all_programs_with_options_mock) + + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + assert client.get_available_program.call_count == 0 + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.NOTIFY, + data=ArrayOfEvents( + [ + Event( + key=event_key, + raw_key=event_key.value, + timestamp=0, + level="", + handling="", + value=ProgramKey.UNKNOWN, + ) + ] + ), + ) + ] + ) + await hass.async_block_till_done() + + assert client.get_available_program.call_count == 0 + + @pytest.mark.parametrize( "event_key", [ From 0c084305073b1d0959b71f4d2210e018dd5d1833 Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Thu, 27 Feb 2025 10:00:50 +0100 Subject: [PATCH 1898/3148] Bump onedrive to 0.0.12 (#139410) * Bump onedrive to 0.0.12 * Add alternative name --- homeassistant/components/onedrive/manifest.json | 2 +- homeassistant/components/onedrive/sensor.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/onedrive/manifest.json b/homeassistant/components/onedrive/manifest.json index 5ab16402cb8..31a1f2ccb06 100644 --- a/homeassistant/components/onedrive/manifest.json +++ b/homeassistant/components/onedrive/manifest.json @@ -9,5 +9,5 @@ "iot_class": "cloud_polling", "loggers": ["onedrive_personal_sdk"], "quality_scale": "platinum", - "requirements": ["onedrive-personal-sdk==0.0.11"] + "requirements": ["onedrive-personal-sdk==0.0.12"] } diff --git a/homeassistant/components/onedrive/sensor.py b/homeassistant/components/onedrive/sensor.py index 0ca2b166e3f..fa7c0b125fe 100644 --- a/homeassistant/components/onedrive/sensor.py +++ b/homeassistant/components/onedrive/sensor.py @@ -103,7 +103,7 @@ class OneDriveDriveStateSensor( self._attr_unique_id = f"{coordinator.data.id}_{description.key}" self._attr_device_info = DeviceInfo( entry_type=DeviceEntryType.SERVICE, - name=coordinator.data.name, + name=coordinator.data.name or coordinator.config_entry.title, identifiers={(DOMAIN, coordinator.data.id)}, manufacturer="Microsoft", model=f"OneDrive {coordinator.data.drive_type.value.capitalize()}", diff --git a/requirements_all.txt b/requirements_all.txt index 4580eac890d..577e1cdc578 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1565,7 +1565,7 @@ omnilogic==0.4.5 ondilo==0.5.0 # homeassistant.components.onedrive -onedrive-personal-sdk==0.0.11 +onedrive-personal-sdk==0.0.12 # homeassistant.components.onvif onvif-zeep-async==3.2.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 10365827696..593ff9203cc 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1313,7 +1313,7 @@ omnilogic==0.4.5 ondilo==0.5.0 # homeassistant.components.onedrive -onedrive-personal-sdk==0.0.11 +onedrive-personal-sdk==0.0.12 # homeassistant.components.onvif onvif-zeep-async==3.2.5 From 2cde317d59f8c5fd14daa00be05f294765966803 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 27 Feb 2025 13:25:55 +0100 Subject: [PATCH 1899/3148] Bump pysmartthings to 2.0.0 (#139418) * Bump pysmartthings to 2.0.0 * Fix * Fix * Fix * Fix --- .../components/smartthings/__init__.py | 8 +++--- homeassistant/components/smartthings/cover.py | 2 +- .../components/smartthings/entity.py | 13 +++++++-- .../components/smartthings/manifest.json | 2 +- .../components/smartthings/sensor.py | 28 +++++++++++++------ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/smartthings/__init__.py | 2 +- .../smartthings/snapshots/test_sensor.ambr | 8 +++--- tests/components/smartthings/test_sensor.py | 14 +++++----- 10 files changed, 50 insertions(+), 31 deletions(-) diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py index 846170552e9..4bc9b270360 100644 --- a/homeassistant/components/smartthings/__init__.py +++ b/homeassistant/components/smartthings/__init__.py @@ -46,7 +46,7 @@ class FullDevice: """Define an object to hold device data.""" device: Device - status: dict[str, dict[Capability, dict[Attribute, Status]]] + status: dict[str, dict[Capability | str, dict[Attribute | str, Status]]] type SmartThingsConfigEntry = ConfigEntry[SmartThingsData] @@ -146,8 +146,8 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: def process_status( - status: dict[str, dict[Capability, dict[Attribute, Status]]], -) -> dict[str, dict[Capability, dict[Attribute, Status]]]: + status: dict[str, dict[Capability | str, dict[Attribute | str, Status]]], +) -> dict[str, dict[Capability | str, dict[Attribute | str, Status]]]: """Remove disabled capabilities from status.""" if (main_component := status.get("main")) is None or ( disabled_capabilities_capability := main_component.get( @@ -156,7 +156,7 @@ def process_status( ) is None: return status disabled_capabilities = cast( - list[Capability], + list[Capability | str], disabled_capabilities_capability[Attribute.DISABLED_CAPABILITIES].value, ) for capability in disabled_capabilities: diff --git a/homeassistant/components/smartthings/cover.py b/homeassistant/components/smartthings/cover.py index fd4752b4e28..0b0f03679eb 100644 --- a/homeassistant/components/smartthings/cover.py +++ b/homeassistant/components/smartthings/cover.py @@ -41,7 +41,7 @@ async def async_setup_entry( """Add covers for a config entry.""" entry_data = entry.runtime_data async_add_entities( - SmartThingsCover(entry_data.client, device, capability) + SmartThingsCover(entry_data.client, device, Capability(capability)) for device in entry_data.devices.values() for capability in device.status[MAIN] if capability in CAPABILITIES diff --git a/homeassistant/components/smartthings/entity.py b/homeassistant/components/smartthings/entity.py index b2e556c6718..1383196ce15 100644 --- a/homeassistant/components/smartthings/entity.py +++ b/homeassistant/components/smartthings/entity.py @@ -4,7 +4,14 @@ from __future__ import annotations from typing import Any, cast -from pysmartthings import Attribute, Capability, Command, DeviceEvent, SmartThings +from pysmartthings import ( + Attribute, + Capability, + Command, + DeviceEvent, + SmartThings, + Status, +) from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity @@ -25,7 +32,7 @@ class SmartThingsEntity(Entity): """Initialize the instance.""" self.client = client self.capabilities = capabilities - self._internal_state = { + self._internal_state: dict[Capability | str, dict[Attribute | str, Status]] = { capability: device.status[MAIN][capability] for capability in capabilities if capability in device.status[MAIN] @@ -58,7 +65,7 @@ class SmartThingsEntity(Entity): await super().async_added_to_hass() for capability in self._internal_state: self.async_on_remove( - self.client.add_device_event_listener( + self.client.add_device_capability_event_listener( self.device.device.device_id, MAIN, capability, diff --git a/homeassistant/components/smartthings/manifest.json b/homeassistant/components/smartthings/manifest.json index b34ab90ca8c..c5277241aa4 100644 --- a/homeassistant/components/smartthings/manifest.json +++ b/homeassistant/components/smartthings/manifest.json @@ -29,5 +29,5 @@ "documentation": "https://www.home-assistant.io/integrations/smartthings", "iot_class": "cloud_push", "loggers": ["pysmartthings"], - "requirements": ["pysmartthings==1.2.0"] + "requirements": ["pysmartthings==2.0.0"] } diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index d7aaaaa84c5..bc986894045 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -130,6 +130,7 @@ class SmartThingsSensorEntityDescription(SensorEntityDescription): unique_id_separator: str = "." capability_ignore_list: list[set[Capability]] | None = None options_attribute: Attribute | None = None + except_if_state_none: bool = False CAPABILITY_TO_SENSORS: dict[ @@ -579,6 +580,7 @@ CAPABILITY_TO_SENSORS: dict[ device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, value_fn=lambda value: value["energy"] / 1000, + except_if_state_none=True, ), SmartThingsSensorEntityDescription( key="power_meter", @@ -587,6 +589,7 @@ CAPABILITY_TO_SENSORS: dict[ native_unit_of_measurement=UnitOfPower.WATT, value_fn=lambda value: value["power"], extra_state_attributes_fn=power_attributes, + except_if_state_none=True, ), SmartThingsSensorEntityDescription( key="deltaEnergy_meter", @@ -595,6 +598,7 @@ CAPABILITY_TO_SENSORS: dict[ device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, value_fn=lambda value: value["deltaEnergy"] / 1000, + except_if_state_none=True, ), SmartThingsSensorEntityDescription( key="powerEnergy_meter", @@ -603,6 +607,7 @@ CAPABILITY_TO_SENSORS: dict[ device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, value_fn=lambda value: value["powerEnergy"] / 1000, + except_if_state_none=True, ), SmartThingsSensorEntityDescription( key="energySaved_meter", @@ -611,6 +616,7 @@ CAPABILITY_TO_SENSORS: dict[ device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, value_fn=lambda value: value["energySaved"] / 1000, + except_if_state_none=True, ), ] }, @@ -953,14 +959,20 @@ async def async_setup_entry( async_add_entities( SmartThingsSensor(entry_data.client, device, description, capability, attribute) for device in entry_data.devices.values() - for capability, attributes in device.status[MAIN].items() - if capability in CAPABILITY_TO_SENSORS - for attribute in attributes - for description in CAPABILITY_TO_SENSORS[capability].get(attribute, []) - if not description.capability_ignore_list - or not any( - all(capability in device.status[MAIN] for capability in capability_list) - for capability_list in description.capability_ignore_list + for capability, attributes in CAPABILITY_TO_SENSORS.items() + if capability in device.status[MAIN] + for attribute, descriptions in attributes.items() + for description in descriptions + if ( + not description.capability_ignore_list + or not any( + all(capability in device.status[MAIN] for capability in capability_list) + for capability_list in description.capability_ignore_list + ) + ) + and ( + not description.except_if_state_none + or device.status[MAIN][capability][attribute].value is not None ) ) diff --git a/requirements_all.txt b/requirements_all.txt index 577e1cdc578..d4b57e0a2ac 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2310,7 +2310,7 @@ pysma==0.7.5 pysmappee==0.2.29 # homeassistant.components.smartthings -pysmartthings==1.2.0 +pysmartthings==2.0.0 # homeassistant.components.smarty pysmarty2==0.10.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 593ff9203cc..0940b6ceef9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1882,7 +1882,7 @@ pysma==0.7.5 pysmappee==0.2.29 # homeassistant.components.smartthings -pysmartthings==1.2.0 +pysmartthings==2.0.0 # homeassistant.components.smarty pysmarty2==0.10.2 diff --git a/tests/components/smartthings/__init__.py b/tests/components/smartthings/__init__.py index 94a2e7512f2..a5e51c7d434 100644 --- a/tests/components/smartthings/__init__.py +++ b/tests/components/smartthings/__init__.py @@ -57,7 +57,7 @@ async def trigger_update( data: dict[str, Any] | None = None, ) -> None: """Trigger an update.""" - for call in mock.add_device_event_listener.call_args_list: + for call in mock.add_device_capability_event_listener.call_args_list: if call[0][0] == device_id and call[0][2] == capability: call[0][3]( DeviceEvent( diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index 778b05fa183..93a683afe82 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_all_entities[aeotec_home_energy_meter_gen5][sensor.aeotec_energy_monitor_energy_2-entry] +# name: test_all_entities[aeotec_home_energy_meter_gen5][sensor.aeotec_energy_monitor_energy-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -14,7 +14,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.aeotec_energy_monitor_energy_2', + 'entity_id': 'sensor.aeotec_energy_monitor_energy', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -35,7 +35,7 @@ 'unit_of_measurement': 'kWh', }) # --- -# name: test_all_entities[aeotec_home_energy_meter_gen5][sensor.aeotec_energy_monitor_energy_2-state] +# name: test_all_entities[aeotec_home_energy_meter_gen5][sensor.aeotec_energy_monitor_energy-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', @@ -44,7 +44,7 @@ 'unit_of_measurement': 'kWh', }), 'context': , - 'entity_id': 'sensor.aeotec_energy_monitor_energy_2', + 'entity_id': 'sensor.aeotec_energy_monitor_energy', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/smartthings/test_sensor.py b/tests/components/smartthings/test_sensor.py index 8b8bb8930f4..c83950de9e9 100644 --- a/tests/components/smartthings/test_sensor.py +++ b/tests/components/smartthings/test_sensor.py @@ -28,7 +28,7 @@ async def test_all_entities( snapshot_smartthings_entities(hass, entity_registry, snapshot, Platform.SENSOR) -@pytest.mark.parametrize("device_fixture", ["aeotec_home_energy_meter_gen5"]) +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) async def test_state_update( hass: HomeAssistant, devices: AsyncMock, @@ -37,15 +37,15 @@ async def test_state_update( """Test state update.""" await setup_integration(hass, mock_config_entry) - assert hass.states.get("sensor.aeotec_energy_monitor_energy_2").state == "19978.536" + assert hass.states.get("sensor.ac_office_granit_temperature").state == "25" await trigger_update( hass, devices, - "f0af21a2-d5a1-437c-b10a-b34a87394b71", - Capability.ENERGY_METER, - Attribute.ENERGY, - 20000.0, + "96a5ef74-5832-a84b-f1f7-ca799957065d", + Capability.TEMPERATURE_MEASUREMENT, + Attribute.TEMPERATURE, + 20, ) - assert hass.states.get("sensor.aeotec_energy_monitor_energy_2").state == "20000.0" + assert hass.states.get("sensor.ac_office_granit_temperature").state == "20" From 7732e6878ed1b8fdd50ef07ff012f8393a5e443a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 27 Feb 2025 11:41:27 +0000 Subject: [PATCH 1900/3148] Bump habluetooth to 3.24.1 (#139420) --- 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 8eeb4d67109..6c851e603d9 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -21,6 +21,6 @@ "bluetooth-auto-recovery==1.4.4", "bluetooth-data-tools==1.23.4", "dbus-fast==2.33.0", - "habluetooth==3.24.0" + "habluetooth==3.24.1" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index c49580ae47b..012206d2833 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -33,7 +33,7 @@ dbus-fast==2.33.0 fnv-hash-fast==1.2.6 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 -habluetooth==3.24.0 +habluetooth==3.24.1 hass-nabucasa==0.92.0 hassil==2.2.3 home-assistant-bluetooth==1.13.1 diff --git a/requirements_all.txt b/requirements_all.txt index d4b57e0a2ac..1114642c71f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1109,7 +1109,7 @@ ha-philipsjs==3.2.2 habiticalib==0.3.7 # homeassistant.components.bluetooth -habluetooth==3.24.0 +habluetooth==3.24.1 # homeassistant.components.cloud hass-nabucasa==0.92.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0940b6ceef9..1d94a856ee8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -950,7 +950,7 @@ ha-philipsjs==3.2.2 habiticalib==0.3.7 # homeassistant.components.bluetooth -habluetooth==3.24.0 +habluetooth==3.24.1 # homeassistant.components.cloud hass-nabucasa==0.92.0 From 59d92c75bd7542d62eca243a96791ee103894be8 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 27 Feb 2025 14:51:40 +0000 Subject: [PATCH 1901/3148] Fix conversation agent fallback (#139421) --- .../components/assist_pipeline/pipeline.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/assist_pipeline/pipeline.py b/homeassistant/components/assist_pipeline/pipeline.py index 788a207b83a..75811a0ec36 100644 --- a/homeassistant/components/assist_pipeline/pipeline.py +++ b/homeassistant/components/assist_pipeline/pipeline.py @@ -1103,12 +1103,16 @@ class PipelineRun: ) & conversation.ConversationEntityFeature.CONTROL: intent_filter = _async_local_fallback_intent_filter - # Try local intents first, if preferred. - elif self.pipeline.prefer_local_intents and ( - intent_response := await conversation.async_handle_intents( - self.hass, - user_input, - intent_filter=intent_filter, + # Try local intents + if ( + intent_response is None + and self.pipeline.prefer_local_intents + and ( + intent_response := await conversation.async_handle_intents( + self.hass, + user_input, + intent_filter=intent_filter, + ) ) ): # Local intent matched From 6a1bbdb3a71bb40bd6d689b72da2d8418fe56c15 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 27 Feb 2025 15:23:25 +0100 Subject: [PATCH 1902/3148] Add diagnostics to SmartThings (#139423) --- .../components/smartthings/diagnostics.py | 50 + tests/components/smartthings/__init__.py | 28 +- .../snapshots/test_diagnostics.ambr | 1163 +++++++++++++++++ .../smartthings/test_diagnostics.py | 44 + 4 files changed, 1272 insertions(+), 13 deletions(-) create mode 100644 homeassistant/components/smartthings/diagnostics.py create mode 100644 tests/components/smartthings/snapshots/test_diagnostics.ambr create mode 100644 tests/components/smartthings/test_diagnostics.py diff --git a/homeassistant/components/smartthings/diagnostics.py b/homeassistant/components/smartthings/diagnostics.py new file mode 100644 index 00000000000..bcf40645d22 --- /dev/null +++ b/homeassistant/components/smartthings/diagnostics.py @@ -0,0 +1,50 @@ +"""Diagnostics support for SmartThings.""" + +from __future__ import annotations + +import asyncio +from dataclasses import asdict +from typing import Any + +from pysmartthings import DeviceEvent + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceEntry + +from . import SmartThingsConfigEntry +from .const import DOMAIN + +EVENT_WAIT_TIME = 5 + + +async def async_get_device_diagnostics( + hass: HomeAssistant, entry: SmartThingsConfigEntry, device: DeviceEntry +) -> dict[str, Any]: + """Return diagnostics for a device entry.""" + device_id = next( + identifier for identifier in device.identifiers if identifier[0] == DOMAIN + )[0] + + events: list[DeviceEvent] = [] + + def register_event(event: DeviceEvent) -> None: + events.append(event) + + client = entry.runtime_data.client + + listener = client.add_device_event_listener(device_id, register_event) + + await asyncio.sleep(EVENT_WAIT_TIME) + + listener() + + device_status = await client.get_device_status(device_id) + + status: dict[str, Any] = {} + for component, capabilities in device_status.items(): + status[component] = {} + for capability, attributes in capabilities.items(): + status[component][capability] = {} + for attribute, value in attributes.items(): + status[component][capability][attribute] = asdict(value) + return {"events": [asdict(event) for event in events], "status": status} diff --git a/tests/components/smartthings/__init__.py b/tests/components/smartthings/__init__.py index a5e51c7d434..6939d3c5dcc 100644 --- a/tests/components/smartthings/__init__.py +++ b/tests/components/smartthings/__init__.py @@ -57,19 +57,21 @@ async def trigger_update( data: dict[str, Any] | None = None, ) -> None: """Trigger an update.""" + event = DeviceEvent( + "abc", + "abc", + "abc", + device_id, + MAIN, + capability, + attribute, + value, + data, + ) + for call in mock.add_device_event_listener.call_args_list: + if call[0][0] == device_id: + call[0][3](event) for call in mock.add_device_capability_event_listener.call_args_list: if call[0][0] == device_id and call[0][2] == capability: - call[0][3]( - DeviceEvent( - "abc", - "abc", - "abc", - device_id, - MAIN, - capability, - attribute, - value, - data, - ) - ) + call[0][3](event) await hass.async_block_till_done() diff --git a/tests/components/smartthings/snapshots/test_diagnostics.ambr b/tests/components/smartthings/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..50f568df5d1 --- /dev/null +++ b/tests/components/smartthings/snapshots/test_diagnostics.ambr @@ -0,0 +1,1163 @@ +# serializer version: 1 +# name: test_device[da_ac_rac_000001] + dict({ + 'events': list([ + ]), + 'status': dict({ + '1': dict({ + 'airConditionerFanMode': dict({ + 'availableAcFanModes': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'fanMode': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:44:10.381000+00:00', + 'unit': None, + 'value': None, + }), + 'supportedAcFanModes': dict({ + 'data': None, + 'timestamp': '2024-09-10T10:26:28.605000+00:00', + 'unit': None, + 'value': list([ + 'auto', + 'low', + 'medium', + 'high', + 'turbo', + ]), + }), + }), + 'airConditionerMode': dict({ + 'airConditionerMode': dict({ + 'data': None, + 'timestamp': '2021-04-08T03:50:50.930000+00:00', + 'unit': None, + 'value': None, + }), + 'availableAcModes': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'supportedAcModes': dict({ + 'data': None, + 'timestamp': '2021-04-08T03:50:50.930000+00:00', + 'unit': None, + 'value': None, + }), + }), + 'airQualitySensor': dict({ + 'airQuality': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:57:57.602000+00:00', + 'unit': 'CAQI', + 'value': None, + }), + }), + 'audioVolume': dict({ + 'volume': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:43:53.541000+00:00', + 'unit': '%', + 'value': None, + }), + }), + 'custom.airConditionerOdorController': dict({ + 'airConditionerOdorControllerProgress': dict({ + 'data': None, + 'timestamp': '2021-04-08T04:11:38.269000+00:00', + 'unit': None, + 'value': None, + }), + 'airConditionerOdorControllerState': dict({ + 'data': None, + 'timestamp': '2021-04-08T04:11:38.269000+00:00', + 'unit': None, + 'value': None, + }), + }), + 'custom.airConditionerOptionalMode': dict({ + 'acOptionalMode': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:57:57.659000+00:00', + 'unit': None, + 'value': None, + }), + 'supportedAcOptionalMode': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:57:57.659000+00:00', + 'unit': None, + 'value': None, + }), + }), + 'custom.airConditionerTropicalNightMode': dict({ + 'acTropicalNightModeLevel': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:44:10.498000+00:00', + 'unit': None, + 'value': None, + }), + }), + 'custom.autoCleaningMode': dict({ + 'autoCleaningMode': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:43:53.344000+00:00', + 'unit': None, + 'value': None, + }), + 'operatingState': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'progress': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'supportedAutoCleaningModes': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'supportedOperatingStates': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'timedCleanDuration': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'timedCleanDurationRange': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + }), + 'custom.deodorFilter': dict({ + 'deodorFilterCapacity': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:43:39.118000+00:00', + 'unit': None, + 'value': None, + }), + 'deodorFilterLastResetDate': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:43:39.118000+00:00', + 'unit': None, + 'value': None, + }), + 'deodorFilterResetType': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:43:39.118000+00:00', + 'unit': None, + 'value': None, + }), + 'deodorFilterStatus': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:43:39.118000+00:00', + 'unit': None, + 'value': None, + }), + 'deodorFilterUsage': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:43:39.118000+00:00', + 'unit': None, + 'value': None, + }), + 'deodorFilterUsageStep': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:43:39.118000+00:00', + 'unit': None, + 'value': None, + }), + }), + 'custom.deviceReportStateConfiguration': dict({ + 'reportStatePeriod': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:44:09.800000+00:00', + 'unit': None, + 'value': None, + }), + 'reportStateRealtime': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:44:09.800000+00:00', + 'unit': None, + 'value': None, + }), + 'reportStateRealtimePeriod': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:44:09.800000+00:00', + 'unit': None, + 'value': None, + }), + }), + 'custom.disabledCapabilities': dict({ + 'disabledCapabilities': dict({ + 'data': None, + 'timestamp': '2024-09-10T10:26:28.605000+00:00', + 'unit': None, + 'value': list([ + 'remoteControlStatus', + 'airQualitySensor', + 'dustSensor', + 'odorSensor', + 'veryFineDustSensor', + 'custom.dustFilter', + 'custom.deodorFilter', + 'custom.deviceReportStateConfiguration', + 'audioVolume', + 'custom.autoCleaningMode', + 'custom.airConditionerTropicalNightMode', + 'custom.airConditionerOdorController', + 'demandResponseLoadControl', + 'relativeHumidityMeasurement', + ]), + }), + }), + 'custom.dustFilter': dict({ + 'dustFilterCapacity': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:43:39.145000+00:00', + 'unit': None, + 'value': None, + }), + 'dustFilterLastResetDate': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:43:39.145000+00:00', + 'unit': None, + 'value': None, + }), + 'dustFilterResetType': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:43:39.145000+00:00', + 'unit': None, + 'value': None, + }), + 'dustFilterStatus': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:43:39.145000+00:00', + 'unit': None, + 'value': None, + }), + 'dustFilterUsage': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:43:39.145000+00:00', + 'unit': None, + 'value': None, + }), + 'dustFilterUsageStep': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:43:39.145000+00:00', + 'unit': None, + 'value': None, + }), + }), + 'custom.energyType': dict({ + 'drMaxDuration': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'energySavingInfo': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'energySavingLevel': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'energySavingOperation': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'energySavingOperationSupport': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'energySavingSupport': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'energyType': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:43:38.843000+00:00', + 'unit': None, + 'value': None, + }), + 'notificationTemplateID': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'supportedEnergySavingLevels': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + }), + 'custom.spiMode': dict({ + 'spiMode': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:57:57.686000+00:00', + 'unit': None, + 'value': None, + }), + }), + 'custom.thermostatSetpointControl': dict({ + 'maximumSetpoint': dict({ + 'data': None, + 'timestamp': '2021-04-08T04:04:19.901000+00:00', + 'unit': None, + 'value': None, + }), + 'minimumSetpoint': dict({ + 'data': None, + 'timestamp': '2021-04-08T04:04:19.901000+00:00', + 'unit': None, + 'value': None, + }), + }), + 'demandResponseLoadControl': dict({ + 'drlcStatus': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:43:54.748000+00:00', + 'unit': None, + 'value': None, + }), + }), + 'dustSensor': dict({ + 'dustLevel': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:44:10.122000+00:00', + 'unit': 'μg/m^3', + 'value': None, + }), + 'fineDustLevel': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:44:10.122000+00:00', + 'unit': 'μg/m^3', + 'value': None, + }), + }), + 'fanOscillationMode': dict({ + 'availableFanOscillationModes': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'fanOscillationMode': dict({ + 'data': None, + 'timestamp': '2025-02-08T00:44:53.247000+00:00', + 'unit': None, + 'value': 'fixed', + }), + 'supportedFanOscillationModes': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:44:10.325000+00:00', + 'unit': None, + 'value': None, + }), + }), + 'ocf': dict({ + 'di': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:44:10.472000+00:00', + 'unit': None, + 'value': None, + }), + 'dmv': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:44:10.472000+00:00', + 'unit': None, + 'value': None, + }), + 'icv': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:44:10.472000+00:00', + 'unit': None, + 'value': None, + }), + 'mndt': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:44:10.472000+00:00', + 'unit': None, + 'value': None, + }), + 'mnfv': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:44:10.472000+00:00', + 'unit': None, + 'value': None, + }), + 'mnhw': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:44:10.472000+00:00', + 'unit': None, + 'value': None, + }), + 'mnml': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:44:10.472000+00:00', + 'unit': None, + 'value': None, + }), + 'mnmn': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:44:10.472000+00:00', + 'unit': None, + 'value': None, + }), + 'mnmo': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:44:10.472000+00:00', + 'unit': None, + 'value': None, + }), + 'mnos': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:44:10.472000+00:00', + 'unit': None, + 'value': None, + }), + 'mnpv': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:44:10.472000+00:00', + 'unit': None, + 'value': None, + }), + 'mnsl': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:44:10.472000+00:00', + 'unit': None, + 'value': None, + }), + 'n': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:44:10.472000+00:00', + 'unit': None, + 'value': None, + }), + 'pi': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:44:10.472000+00:00', + 'unit': None, + 'value': None, + }), + 'st': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:44:10.472000+00:00', + 'unit': None, + 'value': None, + }), + 'vid': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:44:10.472000+00:00', + 'unit': None, + 'value': None, + }), + }), + 'odorSensor': dict({ + 'odorLevel': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:43:38.992000+00:00', + 'unit': None, + 'value': None, + }), + }), + 'powerConsumptionReport': dict({ + 'powerConsumption': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:43:53.364000+00:00', + 'unit': None, + 'value': None, + }), + }), + 'relativeHumidityMeasurement': dict({ + 'humidity': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:43:35.291000+00:00', + 'unit': '%', + 'value': 0, + }), + }), + 'remoteControlStatus': dict({ + 'remoteControlEnabled': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:43:39.097000+00:00', + 'unit': None, + 'value': None, + }), + }), + 'switch': dict({ + 'switch': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:44:10.518000+00:00', + 'unit': None, + 'value': None, + }), + }), + 'temperatureMeasurement': dict({ + 'temperature': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:44:10.373000+00:00', + 'unit': None, + 'value': None, + }), + 'temperatureRange': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + }), + 'thermostatCoolingSetpoint': dict({ + 'coolingSetpoint': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:43:59.136000+00:00', + 'unit': None, + 'value': None, + }), + 'coolingSetpointRange': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + }), + 'veryFineDustSensor': dict({ + 'veryFineDustLevel': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:43:38.529000+00:00', + 'unit': 'μg/m^3', + 'value': None, + }), + }), + }), + 'main': dict({ + 'airConditionerFanMode': dict({ + 'availableAcFanModes': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'fanMode': dict({ + 'data': None, + 'timestamp': '2025-02-09T09:14:39.249000+00:00', + 'unit': None, + 'value': 'low', + }), + 'supportedAcFanModes': dict({ + 'data': None, + 'timestamp': '2025-02-09T09:14:39.249000+00:00', + 'unit': None, + 'value': list([ + 'auto', + 'low', + 'medium', + 'high', + 'turbo', + ]), + }), + }), + 'airConditionerMode': dict({ + 'airConditionerMode': dict({ + 'data': None, + 'timestamp': '2025-02-09T09:14:39.642000+00:00', + 'unit': None, + 'value': 'heat', + }), + 'availableAcModes': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'supportedAcModes': dict({ + 'data': None, + 'timestamp': '2024-09-10T10:26:28.781000+00:00', + 'unit': None, + 'value': list([ + 'cool', + 'dry', + 'wind', + 'auto', + 'heat', + ]), + }), + }), + 'audioVolume': dict({ + 'volume': dict({ + 'data': None, + 'timestamp': '2025-02-09T09:14:39.642000+00:00', + 'unit': '%', + 'value': 100, + }), + }), + 'custom.airConditionerOptionalMode': dict({ + 'acOptionalMode': dict({ + 'data': None, + 'timestamp': '2025-02-09T09:14:39.642000+00:00', + 'unit': None, + 'value': 'off', + }), + 'supportedAcOptionalMode': dict({ + 'data': None, + 'timestamp': '2024-09-10T10:26:28.781000+00:00', + 'unit': None, + 'value': list([ + 'off', + 'windFree', + ]), + }), + }), + 'custom.airConditionerTropicalNightMode': dict({ + 'acTropicalNightModeLevel': dict({ + 'data': None, + 'timestamp': '2025-02-09T09:14:39.642000+00:00', + 'unit': None, + 'value': 0, + }), + }), + 'custom.autoCleaningMode': dict({ + 'autoCleaningMode': dict({ + 'data': None, + 'timestamp': '2025-02-09T09:14:39.642000+00:00', + 'unit': None, + 'value': 'off', + }), + 'operatingState': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'progress': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'supportedAutoCleaningModes': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'supportedOperatingStates': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'timedCleanDuration': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'timedCleanDurationRange': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + }), + 'custom.disabledCapabilities': dict({ + 'disabledCapabilities': dict({ + 'data': None, + 'timestamp': '2025-02-09T09:14:39.642000+00:00', + 'unit': None, + 'value': list([ + 'remoteControlStatus', + 'airQualitySensor', + 'dustSensor', + 'veryFineDustSensor', + 'custom.dustFilter', + 'custom.deodorFilter', + 'custom.deviceReportStateConfiguration', + 'samsungce.dongleSoftwareInstallation', + 'demandResponseLoadControl', + 'custom.airConditionerOdorController', + ]), + }), + }), + 'custom.disabledComponents': dict({ + 'disabledComponents': dict({ + 'data': None, + 'timestamp': '2025-02-09T09:14:39.642000+00:00', + 'unit': None, + 'value': list([ + '1', + ]), + }), + }), + 'custom.energyType': dict({ + 'drMaxDuration': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'energySavingInfo': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'energySavingLevel': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'energySavingOperation': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'energySavingOperationSupport': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'energySavingSupport': dict({ + 'data': None, + 'timestamp': '2021-12-29T07:29:17.526000+00:00', + 'unit': None, + 'value': 'False', + }), + 'energyType': dict({ + 'data': None, + 'timestamp': '2024-09-10T10:26:28.781000+00:00', + 'unit': None, + 'value': '1.0', + }), + 'notificationTemplateID': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'supportedEnergySavingLevels': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + }), + 'custom.spiMode': dict({ + 'spiMode': dict({ + 'data': None, + 'timestamp': '2025-02-09T09:14:39.642000+00:00', + 'unit': None, + 'value': 'off', + }), + }), + 'custom.thermostatSetpointControl': dict({ + 'maximumSetpoint': dict({ + 'data': None, + 'timestamp': '2024-09-10T10:26:28.781000+00:00', + 'unit': 'C', + 'value': 30, + }), + 'minimumSetpoint': dict({ + 'data': None, + 'timestamp': '2025-01-08T06:30:58.307000+00:00', + 'unit': 'C', + 'value': 16, + }), + }), + 'demandResponseLoadControl': dict({ + 'drlcStatus': dict({ + 'data': None, + 'timestamp': '2024-09-10T10:26:28.781000+00:00', + 'unit': None, + 'value': dict({ + 'drlcLevel': -1, + 'drlcType': 1, + 'duration': 0, + 'override': False, + 'start': '1970-01-01T00:00:00Z', + }), + }), + }), + 'execute': dict({ + 'data': dict({ + 'data': dict({ + 'href': '/temperature/desired/0', + }), + 'timestamp': '2023-07-19T03:07:43.270000+00:00', + 'unit': None, + 'value': dict({ + 'payload': dict({ + 'if': list([ + 'oic.if.baseline', + 'oic.if.a', + ]), + 'range': list([ + 16.0, + 30.0, + ]), + 'rt': list([ + 'oic.r.temperature', + ]), + 'temperature': 22.0, + 'units': 'C', + }), + }), + }), + }), + 'fanOscillationMode': dict({ + 'availableFanOscillationModes': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'fanOscillationMode': dict({ + 'data': None, + 'timestamp': '2025-02-09T09:14:39.249000+00:00', + 'unit': None, + 'value': 'fixed', + }), + 'supportedFanOscillationModes': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:43:35.782000+00:00', + 'unit': None, + 'value': None, + }), + }), + 'ocf': dict({ + 'di': dict({ + 'data': None, + 'timestamp': '2024-09-10T10:26:28.552000+00:00', + 'unit': None, + 'value': '96a5ef74-5832-a84b-f1f7-ca799957065d', + }), + 'dmv': dict({ + 'data': None, + 'timestamp': '2024-09-10T10:26:28.552000+00:00', + 'unit': None, + 'value': 'res.1.1.0,sh.1.1.0', + }), + 'icv': dict({ + 'data': None, + 'timestamp': '2024-09-10T10:26:28.552000+00:00', + 'unit': None, + 'value': 'core.1.1.0', + }), + 'mndt': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:43:35.912000+00:00', + 'unit': None, + 'value': None, + }), + 'mnfv': dict({ + 'data': None, + 'timestamp': '2024-09-10T10:26:28.552000+00:00', + 'unit': None, + 'value': '0.1.0', + }), + 'mnhw': dict({ + 'data': None, + 'timestamp': '2024-09-10T10:26:28.552000+00:00', + 'unit': None, + 'value': '1.0', + }), + 'mnml': dict({ + 'data': None, + 'timestamp': '2024-09-10T10:26:28.552000+00:00', + 'unit': None, + 'value': 'http://www.samsung.com', + }), + 'mnmn': dict({ + 'data': None, + 'timestamp': '2024-09-10T10:26:28.552000+00:00', + 'unit': None, + 'value': 'Samsung Electronics', + }), + 'mnmo': dict({ + 'data': None, + 'timestamp': '2024-09-10T10:26:28.781000+00:00', + 'unit': None, + 'value': 'ARTIK051_KRAC_18K|10193441|60010132001111110200000000000000', + }), + 'mnos': dict({ + 'data': None, + 'timestamp': '2024-09-10T10:26:28.552000+00:00', + 'unit': None, + 'value': 'TizenRT2.0', + }), + 'mnpv': dict({ + 'data': None, + 'timestamp': '2024-09-10T10:26:28.552000+00:00', + 'unit': None, + 'value': '0G3MPDCKA00010E', + }), + 'mnsl': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:43:35.803000+00:00', + 'unit': None, + 'value': None, + }), + 'n': dict({ + 'data': None, + 'timestamp': '2024-09-10T10:26:28.552000+00:00', + 'unit': None, + 'value': '[room a/c] Samsung', + }), + 'pi': dict({ + 'data': None, + 'timestamp': '2024-09-10T10:26:28.552000+00:00', + 'unit': None, + 'value': '96a5ef74-5832-a84b-f1f7-ca799957065d', + }), + 'st': dict({ + 'data': None, + 'timestamp': '2021-04-06T16:43:35.933000+00:00', + 'unit': None, + 'value': None, + }), + 'vid': dict({ + 'data': None, + 'timestamp': '2024-09-10T10:26:28.552000+00:00', + 'unit': None, + 'value': 'DA-AC-RAC-000001', + }), + }), + 'powerConsumptionReport': dict({ + 'powerConsumption': dict({ + 'data': None, + 'timestamp': '2025-02-09T16:15:33.639000+00:00', + 'unit': None, + 'value': dict({ + 'deltaEnergy': 400, + 'end': '2025-02-09T16:15:33Z', + 'energy': 2247300, + 'energySaved': 0, + 'persistedEnergy': 2247300, + 'power': 0, + 'powerEnergy': 0.0, + 'start': '2025-02-09T15:45:29Z', + }), + }), + }), + 'refresh': dict({ + }), + 'relativeHumidityMeasurement': dict({ + 'humidity': dict({ + 'data': None, + 'timestamp': '2024-12-30T13:10:23.759000+00:00', + 'unit': '%', + 'value': 60, + }), + }), + 'samsungce.deviceIdentification': dict({ + 'binaryId': dict({ + 'data': None, + 'timestamp': '2025-02-08T00:44:53.855000+00:00', + 'unit': None, + 'value': 'ARTIK051_KRAC_18K', + }), + 'description': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'micomAssayCode': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'modelClassificationCode': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'modelName': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'releaseYear': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'serialNumber': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'serialNumberExtra': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + }), + 'samsungce.driverVersion': dict({ + 'versionNumber': dict({ + 'data': None, + 'timestamp': '2024-09-04T06:35:09.557000+00:00', + 'unit': None, + 'value': 24070101, + }), + }), + 'samsungce.selfCheck': dict({ + 'errors': dict({ + 'data': None, + 'timestamp': '2025-02-08T00:44:53.349000+00:00', + 'unit': None, + 'value': list([ + ]), + }), + 'progress': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'result': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'status': dict({ + 'data': None, + 'timestamp': '2025-02-08T00:44:53.549000+00:00', + 'unit': None, + 'value': 'ready', + }), + 'supportedActions': dict({ + 'data': None, + 'timestamp': '2024-09-04T06:35:09.557000+00:00', + 'unit': None, + 'value': list([ + 'start', + ]), + }), + }), + 'samsungce.softwareUpdate': dict({ + 'availableModules': dict({ + 'data': None, + 'timestamp': '2025-02-08T00:44:53.855000+00:00', + 'unit': None, + 'value': list([ + ]), + }), + 'lastUpdatedDate': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'newVersionAvailable': dict({ + 'data': None, + 'timestamp': '2025-02-08T00:44:53.855000+00:00', + 'unit': None, + 'value': 'False', + }), + 'operatingState': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'otnDUID': dict({ + 'data': None, + 'timestamp': '2025-02-08T00:44:53.855000+00:00', + 'unit': None, + 'value': '43CEZFTFFL7Z2', + }), + 'progress': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + 'targetModule': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + }), + 'switch': dict({ + 'switch': dict({ + 'data': None, + 'timestamp': '2025-02-09T16:37:54.072000+00:00', + 'unit': None, + 'value': 'off', + }), + }), + 'temperatureMeasurement': dict({ + 'temperature': dict({ + 'data': None, + 'timestamp': '2025-02-09T16:33:29.164000+00:00', + 'unit': 'C', + 'value': 25, + }), + 'temperatureRange': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + }), + 'thermostatCoolingSetpoint': dict({ + 'coolingSetpoint': dict({ + 'data': None, + 'timestamp': '2025-02-09T09:15:11.608000+00:00', + 'unit': 'C', + 'value': 25, + }), + 'coolingSetpointRange': dict({ + 'data': None, + 'timestamp': None, + 'unit': None, + 'value': None, + }), + }), + }), + }), + }) +# --- diff --git a/tests/components/smartthings/test_diagnostics.py b/tests/components/smartthings/test_diagnostics.py new file mode 100644 index 00000000000..22f1c77cdd1 --- /dev/null +++ b/tests/components/smartthings/test_diagnostics.py @@ -0,0 +1,44 @@ +"""Test SmartThings diagnostics.""" + +from unittest.mock import AsyncMock, patch + +import pytest +from syrupy import SnapshotAssertion +from syrupy.filters import props + +from homeassistant.components.smartthings.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from . import setup_integration + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_device +from tests.typing import ClientSessionGenerator + + +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) +async def test_device( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + device_registry: dr.DeviceRegistry, + devices: AsyncMock, + mock_smartthings: AsyncMock, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test generating diagnostics for a device entry.""" + await setup_integration(hass, mock_config_entry) + + device = device_registry.async_get_device( + identifiers={(DOMAIN, "96a5ef74-5832-a84b-f1f7-ca799957065d")} + ) + + with patch("homeassistant.components.smartthings.diagnostics.EVENT_WAIT_TIME", 0.1): + diag = await get_diagnostics_for_device( + hass, hass_client, mock_config_entry, device + ) + + assert diag == snapshot( + exclude=props("last_changed", "last_reported", "last_updated") + ) From 553abe4a4aad63d3add6569a7bbd302a30557391 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 27 Feb 2025 12:22:43 +0000 Subject: [PATCH 1903/3148] Bump bleak-esphome to 2.8.0 (#139426) --- homeassistant/components/eq3btsmart/manifest.json | 2 +- homeassistant/components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/eq3btsmart/manifest.json b/homeassistant/components/eq3btsmart/manifest.json index 0bc3ae55236..18dcbb5cb65 100644 --- a/homeassistant/components/eq3btsmart/manifest.json +++ b/homeassistant/components/eq3btsmart/manifest.json @@ -22,5 +22,5 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["eq3btsmart"], - "requirements": ["eq3btsmart==1.4.1", "bleak-esphome==2.7.1"] + "requirements": ["eq3btsmart==1.4.1", "bleak-esphome==2.8.0"] } diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index b59dd544c49..d07754d68a0 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -18,7 +18,7 @@ "requirements": [ "aioesphomeapi==29.2.0", "esphome-dashboard-api==1.2.3", - "bleak-esphome==2.7.1" + "bleak-esphome==2.8.0" ], "zeroconf": ["_esphomelib._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 1114642c71f..828c16d3be5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -603,7 +603,7 @@ bizkaibus==0.1.1 # homeassistant.components.eq3btsmart # homeassistant.components.esphome -bleak-esphome==2.7.1 +bleak-esphome==2.8.0 # homeassistant.components.bluetooth bleak-retry-connector==3.9.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1d94a856ee8..40f4d8e6480 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -534,7 +534,7 @@ bimmer-connected[china]==0.17.2 # homeassistant.components.eq3btsmart # homeassistant.components.esphome -bleak-esphome==2.7.1 +bleak-esphome==2.8.0 # homeassistant.components.bluetooth bleak-retry-connector==3.9.0 From 16314711b89054e46685931e3b602a6f1e734b7e Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Thu, 27 Feb 2025 14:29:57 +0100 Subject: [PATCH 1904/3148] Bump reolink-aio to 0.12.1 (#139427) --- homeassistant/components/reolink/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json index 37e448aa820..f923efdbbf2 100644 --- a/homeassistant/components/reolink/manifest.json +++ b/homeassistant/components/reolink/manifest.json @@ -19,5 +19,5 @@ "iot_class": "local_push", "loggers": ["reolink_aio"], "quality_scale": "platinum", - "requirements": ["reolink-aio==0.12.0"] + "requirements": ["reolink-aio==0.12.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 828c16d3be5..05512f945a4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2618,7 +2618,7 @@ renault-api==0.2.9 renson-endura-delta==1.7.2 # homeassistant.components.reolink -reolink-aio==0.12.0 +reolink-aio==0.12.1 # homeassistant.components.idteck_prox rfk101py==0.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 40f4d8e6480..21508fb5a4a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2121,7 +2121,7 @@ renault-api==0.2.9 renson-endura-delta==1.7.2 # homeassistant.components.reolink -reolink-aio==0.12.0 +reolink-aio==0.12.1 # homeassistant.components.rflink rflink==0.0.66 From 381fa65ba03cbc858e5ad299d3e685ac1f32b6b1 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Thu, 27 Feb 2025 14:30:29 +0100 Subject: [PATCH 1905/3148] Fix Music Assistant media player entity features (#139428) * Fix Music Assistant supported media player features * Update supported features when player config changes * Add tests --- .../music_assistant/media_player.py | 46 +++++-- tests/components/music_assistant/common.py | 39 +++++- .../music_assistant/test_media_player.py | 116 +++++++++++++++++- 3 files changed, 182 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/music_assistant/media_player.py b/homeassistant/components/music_assistant/media_player.py index bbbda095302..c079fd20e91 100644 --- a/homeassistant/components/music_assistant/media_player.py +++ b/homeassistant/components/music_assistant/media_player.py @@ -9,6 +9,7 @@ import functools import os from typing import TYPE_CHECKING, Any, Concatenate +from music_assistant_models.constants import PLAYER_CONTROL_NONE from music_assistant_models.enums import ( EventType, MediaType, @@ -80,19 +81,14 @@ if TYPE_CHECKING: from music_assistant_client import MusicAssistantClient from music_assistant_models.player import Player -SUPPORTED_FEATURES = ( - MediaPlayerEntityFeature.PAUSE - | MediaPlayerEntityFeature.VOLUME_SET - | MediaPlayerEntityFeature.STOP +SUPPORTED_FEATURES_BASE = ( + MediaPlayerEntityFeature.STOP | MediaPlayerEntityFeature.PREVIOUS_TRACK | MediaPlayerEntityFeature.NEXT_TRACK | MediaPlayerEntityFeature.SHUFFLE_SET | MediaPlayerEntityFeature.REPEAT_SET - | MediaPlayerEntityFeature.TURN_ON - | MediaPlayerEntityFeature.TURN_OFF | MediaPlayerEntityFeature.PLAY | MediaPlayerEntityFeature.PLAY_MEDIA - | MediaPlayerEntityFeature.VOLUME_STEP | MediaPlayerEntityFeature.CLEAR_PLAYLIST | MediaPlayerEntityFeature.BROWSE_MEDIA | MediaPlayerEntityFeature.MEDIA_ENQUEUE @@ -212,11 +208,7 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity): """Initialize MediaPlayer entity.""" super().__init__(mass, player_id) self._attr_icon = self.player.icon.replace("mdi-", "mdi:") - self._attr_supported_features = SUPPORTED_FEATURES - if PlayerFeature.SET_MEMBERS in self.player.supported_features: - self._attr_supported_features |= MediaPlayerEntityFeature.GROUPING - if PlayerFeature.VOLUME_MUTE in self.player.supported_features: - self._attr_supported_features |= MediaPlayerEntityFeature.VOLUME_MUTE + self._set_supported_features() self._attr_device_class = MediaPlayerDeviceClass.SPEAKER self._prev_time: float = 0 @@ -241,6 +233,19 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity): ) ) + # we subscribe to the player config changed event to update + # the supported features of the player + async def player_config_changed(event: MassEvent) -> None: + self._set_supported_features() + await self.async_on_update() + self.async_write_ha_state() + + self.async_on_remove( + self.mass.subscribe( + player_config_changed, EventType.PLAYER_CONFIG_UPDATED, self.player_id + ) + ) + @property def active_queue(self) -> PlayerQueue | None: """Return the active queue for this player (if any).""" @@ -682,3 +687,20 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity): if isinstance(queue_option, MediaPlayerEnqueue): queue_option = QUEUE_OPTION_MAP.get(queue_option) return queue_option + + def _set_supported_features(self) -> None: + """Set supported features based on player capabilities.""" + supported_features = SUPPORTED_FEATURES_BASE + if PlayerFeature.SET_MEMBERS in self.player.supported_features: + supported_features |= MediaPlayerEntityFeature.GROUPING + if PlayerFeature.PAUSE in self.player.supported_features: + supported_features |= MediaPlayerEntityFeature.PAUSE + if self.player.mute_control != PLAYER_CONTROL_NONE: + supported_features |= MediaPlayerEntityFeature.VOLUME_MUTE + if self.player.volume_control != PLAYER_CONTROL_NONE: + supported_features |= MediaPlayerEntityFeature.VOLUME_STEP + supported_features |= MediaPlayerEntityFeature.VOLUME_SET + if self.player.power_control != PLAYER_CONTROL_NONE: + supported_features |= MediaPlayerEntityFeature.TURN_ON + supported_features |= MediaPlayerEntityFeature.TURN_OFF + self._attr_supported_features = supported_features diff --git a/tests/components/music_assistant/common.py b/tests/components/music_assistant/common.py index 7c0f9df751a..863d945ccd1 100644 --- a/tests/components/music_assistant/common.py +++ b/tests/components/music_assistant/common.py @@ -2,9 +2,11 @@ from __future__ import annotations +import asyncio from typing import Any from unittest.mock import AsyncMock, MagicMock +from music_assistant_models.api import MassEvent from music_assistant_models.enums import EventType from music_assistant_models.media_items import Album, Artist, Playlist, Radio, Track from music_assistant_models.player import Player @@ -134,15 +136,42 @@ async def trigger_subscription_callback( hass: HomeAssistant, client: MagicMock, event: EventType = EventType.PLAYER_UPDATED, + object_id: str | None = None, data: Any = None, ) -> None: """Trigger a subscription callback.""" # trigger callback on all subscribers - for sub in client.subscribe_events.call_args_list: - callback = sub.kwargs["callback"] - event_filter = sub.kwargs.get("event_filter") - if event_filter in (None, event): - callback(event, data) + for sub in client.subscribe.call_args_list: + cb_func = sub.kwargs.get("cb_func", sub.args[0]) + event_filter = sub.kwargs.get( + "event_filter", sub.args[1] if len(sub.args) > 1 else None + ) + id_filter = sub.kwargs.get( + "id_filter", sub.args[2] if len(sub.args) > 2 else None + ) + if not ( + event_filter is None + or event == event_filter + or (isinstance(event_filter, list) and event in event_filter) + ): + continue + if not ( + id_filter is None + or object_id == id_filter + or (isinstance(id_filter, list) and object_id in id_filter) + ): + continue + + event = MassEvent( + event=event, + object_id=object_id, + data=data, + ) + if asyncio.iscoroutinefunction(cb_func): + await cb_func(event) + else: + cb_func(event) + await hass.async_block_till_done() diff --git a/tests/components/music_assistant/test_media_player.py b/tests/components/music_assistant/test_media_player.py index 25dfcd22c72..44317d4977a 100644 --- a/tests/components/music_assistant/test_media_player.py +++ b/tests/components/music_assistant/test_media_player.py @@ -2,7 +2,13 @@ from unittest.mock import MagicMock, call -from music_assistant_models.enums import MediaType, QueueOption +from music_assistant_models.constants import PLAYER_CONTROL_NONE +from music_assistant_models.enums import ( + EventType, + MediaType, + PlayerFeature, + QueueOption, +) from music_assistant_models.media_items import Track import pytest from syrupy import SnapshotAssertion @@ -20,6 +26,7 @@ from homeassistant.components.media_player import ( SERVICE_CLEAR_PLAYLIST, SERVICE_JOIN, SERVICE_UNJOIN, + MediaPlayerEntityFeature, ) from homeassistant.components.music_assistant.const import DOMAIN as MASS_DOMAIN from homeassistant.components.music_assistant.media_player import ( @@ -59,7 +66,11 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from .common import setup_integration_from_fixtures, snapshot_music_assistant_entities +from .common import ( + setup_integration_from_fixtures, + snapshot_music_assistant_entities, + trigger_subscription_callback, +) from tests.common import AsyncMock @@ -607,3 +618,104 @@ async def test_media_player_get_queue_action( # no call is made, this info comes from the cached queue data assert music_assistant_client.send_command.call_count == 0 assert response == snapshot(exclude=paths(f"{entity_id}.elapsed_time")) + + +async def test_media_player_supported_features( + hass: HomeAssistant, + music_assistant_client: MagicMock, +) -> None: + """Test if media_player entity supported features are cortrectly (re)mapped.""" + await setup_integration_from_fixtures(hass, music_assistant_client) + entity_id = "media_player.test_player_1" + mass_player_id = "00:00:00:00:00:01" + state = hass.states.get(entity_id) + assert state + expected_features = ( + MediaPlayerEntityFeature.STOP + | MediaPlayerEntityFeature.PREVIOUS_TRACK + | MediaPlayerEntityFeature.NEXT_TRACK + | MediaPlayerEntityFeature.SHUFFLE_SET + | MediaPlayerEntityFeature.REPEAT_SET + | MediaPlayerEntityFeature.PLAY + | MediaPlayerEntityFeature.PLAY_MEDIA + | MediaPlayerEntityFeature.VOLUME_STEP + | MediaPlayerEntityFeature.CLEAR_PLAYLIST + | MediaPlayerEntityFeature.BROWSE_MEDIA + | MediaPlayerEntityFeature.MEDIA_ENQUEUE + | MediaPlayerEntityFeature.MEDIA_ANNOUNCE + | MediaPlayerEntityFeature.SEEK + | MediaPlayerEntityFeature.PAUSE + | MediaPlayerEntityFeature.GROUPING + | MediaPlayerEntityFeature.VOLUME_SET + | MediaPlayerEntityFeature.VOLUME_STEP + | MediaPlayerEntityFeature.VOLUME_MUTE + | MediaPlayerEntityFeature.TURN_ON + | MediaPlayerEntityFeature.TURN_OFF + ) + assert state.attributes["supported_features"] == expected_features + # remove power control capability from player, trigger subscription callback + # and check if the supported features got updated + music_assistant_client.players._players[ + mass_player_id + ].power_control = PLAYER_CONTROL_NONE + await trigger_subscription_callback( + hass, music_assistant_client, EventType.PLAYER_CONFIG_UPDATED, mass_player_id + ) + expected_features &= ~MediaPlayerEntityFeature.TURN_ON + expected_features &= ~MediaPlayerEntityFeature.TURN_OFF + state = hass.states.get(entity_id) + assert state + assert state.attributes["supported_features"] == expected_features + + # remove volume control capability from player, trigger subscription callback + # and check if the supported features got updated + music_assistant_client.players._players[ + mass_player_id + ].volume_control = PLAYER_CONTROL_NONE + await trigger_subscription_callback( + hass, music_assistant_client, EventType.PLAYER_CONFIG_UPDATED, mass_player_id + ) + expected_features &= ~MediaPlayerEntityFeature.VOLUME_SET + expected_features &= ~MediaPlayerEntityFeature.VOLUME_STEP + state = hass.states.get(entity_id) + assert state + assert state.attributes["supported_features"] == expected_features + + # remove mute control capability from player, trigger subscription callback + # and check if the supported features got updated + music_assistant_client.players._players[ + mass_player_id + ].mute_control = PLAYER_CONTROL_NONE + await trigger_subscription_callback( + hass, music_assistant_client, EventType.PLAYER_CONFIG_UPDATED, mass_player_id + ) + expected_features &= ~MediaPlayerEntityFeature.VOLUME_MUTE + state = hass.states.get(entity_id) + assert state + assert state.attributes["supported_features"] == expected_features + + # remove pause capability from player, trigger subscription callback + # and check if the supported features got updated + music_assistant_client.players._players[mass_player_id].supported_features.remove( + PlayerFeature.PAUSE + ) + await trigger_subscription_callback( + hass, music_assistant_client, EventType.PLAYER_CONFIG_UPDATED, mass_player_id + ) + expected_features &= ~MediaPlayerEntityFeature.PAUSE + state = hass.states.get(entity_id) + assert state + assert state.attributes["supported_features"] == expected_features + + # remove grouping capability from player, trigger subscription callback + # and check if the supported features got updated + music_assistant_client.players._players[mass_player_id].supported_features.remove( + PlayerFeature.SET_MEMBERS + ) + await trigger_subscription_callback( + hass, music_assistant_client, EventType.PLAYER_CONFIG_UPDATED, mass_player_id + ) + expected_features &= ~MediaPlayerEntityFeature.GROUPING + state = hass.states.get(entity_id) + assert state + assert state.attributes["supported_features"] == expected_features From e4200a79a25f78e747fc0f3eacfe431248262de0 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Thu, 27 Feb 2025 16:42:08 +0100 Subject: [PATCH 1906/3148] Update frontend to 20250227.0 (#139437) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 7bd361041e1..5399b22f075 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20250226.0"] + "requirements": ["home-assistant-frontend==20250227.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 012206d2833..b8e0b417353 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -37,7 +37,7 @@ habluetooth==3.24.1 hass-nabucasa==0.92.0 hassil==2.2.3 home-assistant-bluetooth==1.13.1 -home-assistant-frontend==20250226.0 +home-assistant-frontend==20250227.0 home-assistant-intents==2025.2.26 httpx==0.28.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 05512f945a4..a6eb357230d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1152,7 +1152,7 @@ hole==0.8.0 holidays==0.67 # homeassistant.components.frontend -home-assistant-frontend==20250226.0 +home-assistant-frontend==20250227.0 # homeassistant.components.conversation home-assistant-intents==2025.2.26 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 21508fb5a4a..7049fd84d84 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -981,7 +981,7 @@ hole==0.8.0 holidays==0.67 # homeassistant.components.frontend -home-assistant-frontend==20250226.0 +home-assistant-frontend==20250227.0 # homeassistant.components.conversation home-assistant-intents==2025.2.26 From 345ba73777c3284eec592e497abc18c086efe4f1 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Thu, 27 Feb 2025 16:48:00 +0100 Subject: [PATCH 1907/3148] Bump version to 2025.3.0b1 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 00a9cf3b25f..f22037b9e1d 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -25,7 +25,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2025 MINOR_VERSION: Final = 3 -PATCH_VERSION: Final = "0b0" +PATCH_VERSION: Final = "0b1" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 13, 0) diff --git a/pyproject.toml b/pyproject.toml index e5f5884945a..464b236353f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.3.0b0" +version = "2025.3.0b1" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From a339fbaa8291bde5c9aeb6345684b28b50678516 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 27 Feb 2025 16:56:30 +0000 Subject: [PATCH 1908/3148] Bump aioesphomeapi to 29.3.0 (#139441) --- 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 d07754d68a0..fea2aa03c7a 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -16,7 +16,7 @@ "loggers": ["aioesphomeapi", "noiseprotocol", "bleak_esphome"], "mqtt": ["esphome/discover/#"], "requirements": [ - "aioesphomeapi==29.2.0", + "aioesphomeapi==29.3.0", "esphome-dashboard-api==1.2.3", "bleak-esphome==2.8.0" ], diff --git a/requirements_all.txt b/requirements_all.txt index 920bb3ac81c..e7b05e7c455 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==29.2.0 +aioesphomeapi==29.3.0 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2ddd495c900..239b8ac90ed 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -231,7 +231,7 @@ aioelectricitymaps==0.4.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==29.2.0 +aioesphomeapi==29.3.0 # homeassistant.components.flo aioflo==2021.11.0 From 9502dbee56760f42e7129d7166673d40de1e2201 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Thu, 27 Feb 2025 18:39:01 +0100 Subject: [PATCH 1909/3148] Add more diagnostic info to Reolink (#139436) * Add diagnostic info * Bump reolink-aio to 0.12.1 * Add tests --- .../components/reolink/diagnostics.py | 10 +++++++ tests/components/reolink/conftest.py | 6 ++++ .../reolink/snapshots/test_diagnostics.ambr | 29 +++++++++++++++++++ tests/components/reolink/test_diagnostics.py | 2 ++ 4 files changed, 47 insertions(+) diff --git a/homeassistant/components/reolink/diagnostics.py b/homeassistant/components/reolink/diagnostics.py index 693f2ba59a4..1d0e5d919e7 100644 --- a/homeassistant/components/reolink/diagnostics.py +++ b/homeassistant/components/reolink/diagnostics.py @@ -25,6 +25,14 @@ async def async_get_config_entry_diagnostics( IPC_cam[ch]["firmware version"] = api.camera_sw_version(ch) IPC_cam[ch]["encoding main"] = await api.get_encoding(ch) + chimes: dict[int, dict[str, Any]] = {} + for chime in api.chime_list: + chimes[chime.dev_id] = {} + chimes[chime.dev_id]["channel"] = chime.channel + chimes[chime.dev_id]["name"] = chime.name + chimes[chime.dev_id]["online"] = chime.online + chimes[chime.dev_id]["event_types"] = chime.chime_event_types + return { "model": api.model, "hardware version": api.hardware_version, @@ -41,9 +49,11 @@ async def async_get_config_entry_diagnostics( "channels": api.channels, "stream channels": api.stream_channels, "IPC cams": IPC_cam, + "Chimes": chimes, "capabilities": api.capabilities, "cmd list": host.update_cmd, "firmware ch list": host.firmware_ch_list, "api versions": api.checked_api_versions, "abilities": api.abilities, + "BC_abilities": api.baichuan.abilities, } diff --git a/tests/components/reolink/conftest.py b/tests/components/reolink/conftest.py index 2862aa55b4d..5af55b48dda 100644 --- a/tests/components/reolink/conftest.py +++ b/tests/components/reolink/conftest.py @@ -123,6 +123,8 @@ def reolink_connect_class() -> Generator[MagicMock]: "{'host':'TEST_RESPONSE','channel':'TEST_RESPONSE'}" ) + reolink_connect.chime_list = [] + # enums host_mock.whiteled_mode.return_value = 1 host_mock.whiteled_mode_list.return_value = ["off", "auto"] @@ -137,6 +139,10 @@ def reolink_connect_class() -> Generator[MagicMock]: host_mock.baichuan.events_active = False host_mock.baichuan.privacy_mode.return_value = False host_mock.baichuan.subscribe_events.side_effect = ReolinkError("Test error") + host_mock.baichuan.abilities = { + 0: {"chnID": 0, "aitype": 34615}, + "Host": {"pushAlarm": 7}, + } yield host_mock_class diff --git a/tests/components/reolink/snapshots/test_diagnostics.ambr b/tests/components/reolink/snapshots/test_diagnostics.ambr index 71c5397fbd1..f8d5318e9bd 100644 --- a/tests/components/reolink/snapshots/test_diagnostics.ambr +++ b/tests/components/reolink/snapshots/test_diagnostics.ambr @@ -1,6 +1,27 @@ # serializer version: 1 # name: test_entry_diagnostics dict({ + 'BC_abilities': dict({ + '0': dict({ + 'aitype': 34615, + 'chnID': 0, + }), + 'Host': dict({ + 'pushAlarm': 7, + }), + }), + 'Chimes': dict({ + '12345678': dict({ + 'channel': 0, + 'event_types': list([ + 'md', + 'people', + 'visitor', + ]), + 'name': 'Test chime', + 'online': True, + }), + }), 'HTTP(S) port': 1234, 'HTTPS': True, 'IPC cams': dict({ @@ -41,6 +62,10 @@ 0, ]), 'cmd list': dict({ + 'DingDongOpt': dict({ + '0': 2, + 'null': 2, + }), 'GetAiAlarm': dict({ '0': 5, 'null': 5, @@ -81,6 +106,10 @@ '0': 2, 'null': 4, }), + 'GetDingDongCfg': dict({ + '0': 3, + 'null': 3, + }), 'GetEmail': dict({ '0': 1, 'null': 2, diff --git a/tests/components/reolink/test_diagnostics.py b/tests/components/reolink/test_diagnostics.py index 57b474c13ad..d45163d3cf0 100644 --- a/tests/components/reolink/test_diagnostics.py +++ b/tests/components/reolink/test_diagnostics.py @@ -2,6 +2,7 @@ from unittest.mock import MagicMock +from reolink_aio.api import Chime from syrupy.assertion import SnapshotAssertion from homeassistant.core import HomeAssistant @@ -15,6 +16,7 @@ async def test_entry_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, reolink_connect: MagicMock, + test_chime: Chime, config_entry: MockConfigEntry, snapshot: SnapshotAssertion, ) -> None: From ffac52255423fd572246b6906d72aaa872f64f1a Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 27 Feb 2025 18:39:18 +0100 Subject: [PATCH 1910/3148] Fix SmartThings diagnostics (#139447) --- homeassistant/components/smartthings/diagnostics.py | 9 ++++----- tests/components/smartthings/test_diagnostics.py | 5 +++++ 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/smartthings/diagnostics.py b/homeassistant/components/smartthings/diagnostics.py index bcf40645d22..fc34415e419 100644 --- a/homeassistant/components/smartthings/diagnostics.py +++ b/homeassistant/components/smartthings/diagnostics.py @@ -21,25 +21,24 @@ async def async_get_device_diagnostics( hass: HomeAssistant, entry: SmartThingsConfigEntry, device: DeviceEntry ) -> dict[str, Any]: """Return diagnostics for a device entry.""" + client = entry.runtime_data.client device_id = next( identifier for identifier in device.identifiers if identifier[0] == DOMAIN - )[0] + )[1] + + device_status = await client.get_device_status(device_id) events: list[DeviceEvent] = [] def register_event(event: DeviceEvent) -> None: events.append(event) - client = entry.runtime_data.client - listener = client.add_device_event_listener(device_id, register_event) await asyncio.sleep(EVENT_WAIT_TIME) listener() - device_status = await client.get_device_status(device_id) - status: dict[str, Any] = {} for component, capabilities in device_status.items(): status[component] = {} diff --git a/tests/components/smartthings/test_diagnostics.py b/tests/components/smartthings/test_diagnostics.py index 22f1c77cdd1..768be155c86 100644 --- a/tests/components/smartthings/test_diagnostics.py +++ b/tests/components/smartthings/test_diagnostics.py @@ -34,6 +34,8 @@ async def test_device( identifiers={(DOMAIN, "96a5ef74-5832-a84b-f1f7-ca799957065d")} ) + mock_smartthings.get_device_status.reset_mock() + with patch("homeassistant.components.smartthings.diagnostics.EVENT_WAIT_TIME", 0.1): diag = await get_diagnostics_for_device( hass, hass_client, mock_config_entry, device @@ -42,3 +44,6 @@ async def test_device( assert diag == snapshot( exclude=props("last_changed", "last_reported", "last_updated") ) + mock_smartthings.get_device_status.assert_called_once_with( + "96a5ef74-5832-a84b-f1f7-ca799957065d" + ) From df006aeaded7e6ba9eba95966a23c237c8d1ce51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Thu, 27 Feb 2025 19:23:46 +0100 Subject: [PATCH 1911/3148] Bump aiohomeconnect to 0.15.1 (#139445) --- homeassistant/components/home_connect/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/home_connect/manifest.json b/homeassistant/components/home_connect/manifest.json index 28714b31679..2f5ef4d1b37 100644 --- a/homeassistant/components/home_connect/manifest.json +++ b/homeassistant/components/home_connect/manifest.json @@ -7,6 +7,6 @@ "documentation": "https://www.home-assistant.io/integrations/home_connect", "iot_class": "cloud_push", "loggers": ["aiohomeconnect"], - "requirements": ["aiohomeconnect==0.15.0"], + "requirements": ["aiohomeconnect==0.15.1"], "single_config_entry": true } diff --git a/requirements_all.txt b/requirements_all.txt index e7b05e7c455..8cd0e8ea131 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -264,7 +264,7 @@ aioharmony==0.4.1 aiohasupervisor==0.3.0 # homeassistant.components.home_connect -aiohomeconnect==0.15.0 +aiohomeconnect==0.15.1 # homeassistant.components.homekit_controller aiohomekit==3.2.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 239b8ac90ed..f8824b27cb2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -249,7 +249,7 @@ aioharmony==0.4.1 aiohasupervisor==0.3.0 # homeassistant.components.home_connect -aiohomeconnect==0.15.0 +aiohomeconnect==0.15.1 # homeassistant.components.homekit_controller aiohomekit==3.2.7 From 8cc7e7b76fe1611573edb10dcc3fdd63c8fd5ba9 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Thu, 27 Feb 2025 20:07:12 +0100 Subject: [PATCH 1912/3148] Full test coverage for Vodafone Station init (#139451) Full test coverage for Vodafone Station init --- .../components/vodafone_station/test_init.py | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 tests/components/vodafone_station/test_init.py diff --git a/tests/components/vodafone_station/test_init.py b/tests/components/vodafone_station/test_init.py new file mode 100644 index 00000000000..12b3c3dce8f --- /dev/null +++ b/tests/components/vodafone_station/test_init.py @@ -0,0 +1,33 @@ +"""Tests for Vodafone Station init.""" + +from unittest.mock import AsyncMock + +from homeassistant.components.device_tracker import CONF_CONSIDER_HOME +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from . import setup_integration + +from tests.common import MockConfigEntry + + +async def test_reload_config_entry_with_options( + hass: HomeAssistant, + mock_vodafone_station_router: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the the config entry is reloaded with options.""" + await setup_integration(hass, mock_config_entry) + + result = await hass.config_entries.options.async_init(mock_config_entry.entry_id) + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_CONSIDER_HOME: 37, + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_CONSIDER_HOME: 37, + } From 4c00c56afde0da4cdbae0be6b60d843b50890e5c Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 27 Feb 2025 20:30:18 +0100 Subject: [PATCH 1913/3148] Bump pysmartthings to 2.0.1 (#139454) --- homeassistant/components/smartthings/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/smartthings/manifest.json b/homeassistant/components/smartthings/manifest.json index c5277241aa4..1f52cd23ff3 100644 --- a/homeassistant/components/smartthings/manifest.json +++ b/homeassistant/components/smartthings/manifest.json @@ -29,5 +29,5 @@ "documentation": "https://www.home-assistant.io/integrations/smartthings", "iot_class": "cloud_push", "loggers": ["pysmartthings"], - "requirements": ["pysmartthings==2.0.0"] + "requirements": ["pysmartthings==2.0.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 8cd0e8ea131..b4235c7de0a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2310,7 +2310,7 @@ pysma==0.7.5 pysmappee==0.2.29 # homeassistant.components.smartthings -pysmartthings==2.0.0 +pysmartthings==2.0.1 # homeassistant.components.smarty pysmarty2==0.10.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f8824b27cb2..624052bc2e9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1882,7 +1882,7 @@ pysma==0.7.5 pysmappee==0.2.29 # homeassistant.components.smartthings -pysmartthings==2.0.0 +pysmartthings==2.0.1 # homeassistant.components.smarty pysmarty2==0.10.2 From 938855bea3eed1ebc0a099f12be42b4233bd10e8 Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Thu, 27 Feb 2025 20:42:04 +0100 Subject: [PATCH 1914/3148] Improve onedrive migration (#139458) --- homeassistant/components/onedrive/__init__.py | 40 ++++++++++++++----- tests/components/onedrive/test_init.py | 27 +++++++++++-- 2 files changed, 54 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/onedrive/__init__.py b/homeassistant/components/onedrive/__init__.py index 454c782af92..f10b8fe0d91 100644 --- a/homeassistant/components/onedrive/__init__.py +++ b/homeassistant/components/onedrive/__init__.py @@ -41,14 +41,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass: HomeAssistant, entry: OneDriveConfigEntry) -> bool: """Set up OneDrive from a config entry.""" - implementation = await async_get_config_entry_implementation(hass, entry) - session = OAuth2Session(hass, entry, implementation) - - async def get_access_token() -> str: - await session.async_ensure_token_valid() - return cast(str, session.token[CONF_ACCESS_TOKEN]) - - client = OneDriveClient(get_access_token, async_get_clientsession(hass)) + client, get_access_token = await _get_onedrive_client(hass, entry) # get approot, will be created automatically if it does not exist approot = await _handle_item_operation(client.get_approot, "approot") @@ -164,20 +157,47 @@ async def async_migrate_entry(hass: HomeAssistant, entry: OneDriveConfigEntry) - _LOGGER.debug( "Migrating OneDrive config entry from version %s.%s", version, minor_version ) - + client, _ = await _get_onedrive_client(hass, entry) instance_id = await async_get_instance_id(hass) + try: + approot = await client.get_approot() + folder = await client.get_drive_item( + f"{approot.id}:/backups_{instance_id[:8]}:" + ) + except OneDriveException: + _LOGGER.exception("Migration to version 1.2 failed") + return False + hass.config_entries.async_update_entry( entry, data={ **entry.data, - CONF_FOLDER_ID: "id", # will be updated during setup_entry + CONF_FOLDER_ID: folder.id, CONF_FOLDER_NAME: f"backups_{instance_id[:8]}", }, + minor_version=2, ) _LOGGER.debug("Migration to version 1.2 successful") return True +async def _get_onedrive_client( + hass: HomeAssistant, entry: OneDriveConfigEntry +) -> tuple[OneDriveClient, Callable[[], Awaitable[str]]]: + """Get OneDrive client.""" + implementation = await async_get_config_entry_implementation(hass, entry) + session = OAuth2Session(hass, entry, implementation) + + async def get_access_token() -> str: + await session.async_ensure_token_valid() + return cast(str, session.token[CONF_ACCESS_TOKEN]) + + return ( + OneDriveClient(get_access_token, async_get_clientsession(hass)), + get_access_token, + ) + + async def _handle_item_operation( func: Callable[[], Awaitable[Item]], folder: str ) -> Item: diff --git a/tests/components/onedrive/test_init.py b/tests/components/onedrive/test_init.py index c7765e0a7f8..952ca01e1cb 100644 --- a/tests/components/onedrive/test_init.py +++ b/tests/components/onedrive/test_init.py @@ -236,7 +236,6 @@ async def test_data_cap_issues( async def test_1_1_to_1_2_migration( hass: HomeAssistant, - mock_onedrive_client: MagicMock, mock_config_entry: MockConfigEntry, mock_folder: Folder, ) -> None: @@ -251,12 +250,34 @@ async def test_1_1_to_1_2_migration( }, ) + await setup_integration(hass, old_config_entry) + assert old_config_entry.data[CONF_FOLDER_ID] == mock_folder.id + assert old_config_entry.data[CONF_FOLDER_NAME] == mock_folder.name + assert old_config_entry.minor_version == 2 + + +async def test_1_1_to_1_2_migration_failure( + hass: HomeAssistant, + mock_onedrive_client: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test migration from 1.1 to 1.2 failure.""" + old_config_entry = MockConfigEntry( + unique_id="mock_drive_id", + title="John Doe's OneDrive", + domain=DOMAIN, + data={ + "auth_implementation": mock_config_entry.data["auth_implementation"], + "token": mock_config_entry.data["token"], + }, + ) + # will always 404 after migration, because of dummy id mock_onedrive_client.get_drive_item.side_effect = NotFoundError(404, "Not found") await setup_integration(hass, old_config_entry) - assert old_config_entry.data[CONF_FOLDER_ID] == mock_folder.id - assert old_config_entry.data[CONF_FOLDER_NAME] == mock_folder.name + assert old_config_entry.state is ConfigEntryState.MIGRATION_ERROR + assert old_config_entry.minor_version == 1 async def test_migration_guard_against_major_downgrade( From ef7058f70311642e6a117fd4b29fb69293fac858 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 27 Feb 2025 21:47:20 +0100 Subject: [PATCH 1915/3148] Improve descriptions of `lyric.set_hold_time` action and field (#139385) * Fix misleading descriptions on lyric.set_hold_time action While on Honeywell Lyric thermostats the user can set a "Hold Until" time of day, the set_hold_time action does define a time period instead (Example: 01:00:00) Therefore both descriptions are incorrectly using "until" for explaining the purpose of the action itself and the `time_period` field. This commit re-words both and adds some additional context that helps users (and translators) better understand this action and its purpose. In addition the action name is changed to proper sentence-casing. * Replace "time" with "duration" for additional clarity --- homeassistant/components/lyric/strings.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/lyric/strings.json b/homeassistant/components/lyric/strings.json index 83c65359643..bc48a791e70 100644 --- a/homeassistant/components/lyric/strings.json +++ b/homeassistant/components/lyric/strings.json @@ -53,12 +53,12 @@ }, "services": { "set_hold_time": { - "name": "Set Hold Time", - "description": "Sets the time to hold until.", + "name": "Set hold time", + "description": "Sets the time period to keep the temperature and override the schedule.", "fields": { "time_period": { - "name": "Time Period", - "description": "Time to hold until." + "name": "Time period", + "description": "Duration for which to override the schedule." } } } From e11ead410bbd9179ded05ba5e07b6de9919ec0ea Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 27 Feb 2025 20:50:23 +0000 Subject: [PATCH 1916/3148] Add coverage to ensure we do not load base platforms before recorder (#139464) --- tests/test_bootstrap.py | 43 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index 0d7c8614c6f..e89d038f8ce 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -1528,3 +1528,46 @@ def test_should_rollover_is_always_false() -> None: ).shouldRollover(Mock()) is False ) + + +async def test_no_base_platforms_loaded_before_recorder(hass: HomeAssistant) -> None: + """Verify stage 0 not load base platforms before recorder. + + If a stage 0 integration has a base platform in its dependencies and + it loads before the recorder, it may load integrations that expect + the recorder to be loaded. We need to ensure that no stage 0 integration + has a base platform in its dependencies that loads before the recorder. + """ + integrations_before_recorder: set[str] = set() + for _, integrations, _ in bootstrap.STAGE_0_INTEGRATIONS: + integrations_before_recorder |= integrations + if "recorder" in integrations: + break + + integrations_or_execs = await loader.async_get_integrations( + hass, integrations_before_recorder + ) + integrations: list[Integration] = [] + resolve_deps_tasks: list[asyncio.Task[bool]] = [] + for integration in integrations_or_execs.values(): + assert not isinstance(integrations_or_execs, Exception) + integrations.append(integration) + resolve_deps_tasks.append(integration.resolve_dependencies()) + + await asyncio.gather(*resolve_deps_tasks) + base_platform_py_files = {f"{base_platform}.py" for base_platform in BASE_PLATFORMS} + for integration in integrations: + domain_with_base_platforms_deps = BASE_PLATFORMS.intersection( + integration.all_dependencies + ) + assert not domain_with_base_platforms_deps, ( + f"{integration.domain} has base platforms in dependencies: " + f"{domain_with_base_platforms_deps}" + ) + integration_top_level_files = base_platform_py_files.intersection( + integration._top_level_files + ) + assert not integration_top_level_files, ( + f"{integration.domain} has base platform files in top level files: " + f"{integration_top_level_files}" + ) From 0afdd9556f41a33d845ad19ad57a0e329fffe94a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 27 Feb 2025 21:45:13 +0000 Subject: [PATCH 1917/3148] Bump aioesphomeapi to 29.3.1 (#139465) --- homeassistant/components/esphome/diagnostics.py | 4 +--- homeassistant/components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/esphome/conftest.py | 5 ++++- tests/components/esphome/test_diagnostics.py | 1 + 6 files changed, 9 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/esphome/diagnostics.py b/homeassistant/components/esphome/diagnostics.py index 58c9a8fe666..c68bd560791 100644 --- a/homeassistant/components/esphome/diagnostics.py +++ b/homeassistant/components/esphome/diagnostics.py @@ -13,9 +13,7 @@ from . import CONF_NOISE_PSK from .dashboard import async_get_dashboard from .entry_data import ESPHomeConfigEntry -CONF_MAC_ADDRESS = "mac_address" - -REDACT_KEYS = {CONF_NOISE_PSK, CONF_PASSWORD, CONF_MAC_ADDRESS} +REDACT_KEYS = {CONF_NOISE_PSK, CONF_PASSWORD, "mac_address", "bluetooth_mac_address"} async def async_get_config_entry_diagnostics( diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index fea2aa03c7a..b4360077604 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -16,7 +16,7 @@ "loggers": ["aioesphomeapi", "noiseprotocol", "bleak_esphome"], "mqtt": ["esphome/discover/#"], "requirements": [ - "aioesphomeapi==29.3.0", + "aioesphomeapi==29.3.1", "esphome-dashboard-api==1.2.3", "bleak-esphome==2.8.0" ], diff --git a/requirements_all.txt b/requirements_all.txt index b4235c7de0a..a321d9467b6 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==29.3.0 +aioesphomeapi==29.3.1 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 624052bc2e9..38feed9656c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -231,7 +231,7 @@ aioelectricitymaps==0.4.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==29.3.0 +aioesphomeapi==29.3.1 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/tests/components/esphome/conftest.py b/tests/components/esphome/conftest.py index dc6195bfe1f..94f621b8646 100644 --- a/tests/components/esphome/conftest.py +++ b/tests/components/esphome/conftest.py @@ -581,7 +581,10 @@ async def mock_bluetooth_entry( return await _mock_generic_device_entry( hass, mock_client, - {"bluetooth_proxy_feature_flags": bluetooth_proxy_feature_flags}, + { + "bluetooth_mac_address": "AA:BB:CC:DD:EE:FC", + "bluetooth_proxy_feature_flags": bluetooth_proxy_feature_flags, + }, ([], []), [], ) diff --git a/tests/components/esphome/test_diagnostics.py b/tests/components/esphome/test_diagnostics.py index 2b2629324d2..a4b858ed7de 100644 --- a/tests/components/esphome/test_diagnostics.py +++ b/tests/components/esphome/test_diagnostics.py @@ -89,6 +89,7 @@ async def test_diagnostics_with_bluetooth( "storage_data": { "api_version": {"major": 99, "minor": 99}, "device_info": { + "bluetooth_mac_address": "**REDACTED**", "bluetooth_proxy_feature_flags": 63, "compilation_time": "", "esphome_version": "1.0.0", From ef13b35c359f2a6362d14c4b0ce1a25f5f17923d Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 27 Feb 2025 22:50:34 +0100 Subject: [PATCH 1918/3148] Only lowercase SmartThings media input source if we have it (#139468) --- homeassistant/components/smartthings/sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index bc986894045..2d817c182da 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -461,7 +461,7 @@ CAPABILITY_TO_SENSORS: dict[ translation_key="media_input_source", device_class=SensorDeviceClass.ENUM, options_attribute=Attribute.SUPPORTED_INPUT_SOURCES, - value_fn=lambda value: value.lower(), + value_fn=lambda value: value.lower() if value else None, ) ] }, From 6fa93edf2751b4f4f28c2267dc3479681c6f9228 Mon Sep 17 00:00:00 2001 From: rappenze Date: Thu, 27 Feb 2025 23:27:18 +0100 Subject: [PATCH 1919/3148] Bump pyfibaro to 0.8.2 (#139471) --- homeassistant/components/fibaro/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/fibaro/manifest.json b/homeassistant/components/fibaro/manifest.json index d2a1186b05b..cd4d1de838c 100644 --- a/homeassistant/components/fibaro/manifest.json +++ b/homeassistant/components/fibaro/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["pyfibaro"], - "requirements": ["pyfibaro==0.8.0"] + "requirements": ["pyfibaro==0.8.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index a321d9467b6..1c1b33fca80 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1957,7 +1957,7 @@ pyevilgenius==2.0.0 pyezviz==0.2.1.2 # homeassistant.components.fibaro -pyfibaro==0.8.0 +pyfibaro==0.8.2 # homeassistant.components.fido pyfido==2.1.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 38feed9656c..9bd33de07c7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1595,7 +1595,7 @@ pyevilgenius==2.0.0 pyezviz==0.2.1.2 # homeassistant.components.fibaro -pyfibaro==0.8.0 +pyfibaro==0.8.2 # homeassistant.components.fido pyfido==2.1.2 From 4e8186491cf655c8e61c6cf0e955e89d89ce916a Mon Sep 17 00:00:00 2001 From: Ivan Lopez Hernandez Date: Thu, 27 Feb 2025 19:10:42 -0800 Subject: [PATCH 1920/3148] Fix Gemini Schema validation for #139416 (#139478) Fixed Schema validation for issue #139477 --- .../conversation.py | 15 ++++++- .../snapshots/test_conversation.ambr | 2 +- .../test_conversation.py | 42 +++++++++++++++++-- 3 files changed, 53 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/google_generative_ai_conversation/conversation.py b/homeassistant/components/google_generative_ai_conversation/conversation.py index c99c4c07a7d..2c84249dcb3 100644 --- a/homeassistant/components/google_generative_ai_conversation/conversation.py +++ b/homeassistant/components/google_generative_ai_conversation/conversation.py @@ -111,9 +111,20 @@ def _format_schema(schema: dict[str, Any]) -> Schema: continue if key == "any_of": val = [_format_schema(subschema) for subschema in val] - if key == "type": + elif key == "type": val = val.upper() - if key == "items": + elif key == "format": + # Gemini API does not support all formats, see: https://ai.google.dev/api/caching#Schema + # formats that are not supported are ignored + if schema.get("type") == "string" and val not in ("enum", "date-time"): + continue + if schema.get("type") == "number" and val not in ("float", "double"): + continue + if schema.get("type") == "integer" and val not in ("int32", "int64"): + continue + if schema.get("type") not in ("string", "number", "integer"): + continue + elif key == "items": val = _format_schema(val) elif key == "properties": val = {k: _format_schema(v) for k, v in val.items()} diff --git a/tests/components/google_generative_ai_conversation/snapshots/test_conversation.ambr b/tests/components/google_generative_ai_conversation/snapshots/test_conversation.ambr index 7c9bb896bd3..106366fd240 100644 --- a/tests/components/google_generative_ai_conversation/snapshots/test_conversation.ambr +++ b/tests/components/google_generative_ai_conversation/snapshots/test_conversation.ambr @@ -6,7 +6,7 @@ tuple( ), dict({ - 'config': GenerateContentConfig(http_options=None, system_instruction="Current time is 05:00:00. Today's date is 2024-05-24.\nYou are a voice assistant for Home Assistant.\nAnswer questions about the world truthfully.\nAnswer in plain text. Keep it simple and to the point.\nOnly if the user wants to control a device, tell them to expose entities to their voice assistant in Home Assistant.", temperature=1.0, top_p=0.95, top_k=64.0, candidate_count=None, max_output_tokens=150, stop_sequences=None, response_logprobs=None, logprobs=None, presence_penalty=None, frequency_penalty=None, seed=None, response_mime_type=None, response_schema=None, routing_config=None, safety_settings=[SafetySetting(method=None, category=, threshold=), SafetySetting(method=None, category=, threshold=), SafetySetting(method=None, category=, threshold=), SafetySetting(method=None, category=, threshold=)], tools=[Tool(function_declarations=[FunctionDeclaration(response=None, description='Test function', name='test_tool', parameters=Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=None, max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=, description=None, enum=None, format=None, items=None, properties={'param1': Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=None, max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=, description='Test parameters', enum=None, format=None, items=Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=None, max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=, description=None, enum=None, format='lower', items=None, properties=None, required=None), properties=None, required=None), 'param2': Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=[Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=None, max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=, description=None, enum=None, format=None, items=None, properties=None, required=None), Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=None, max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=, description=None, enum=None, format=None, items=None, properties=None, required=None)], max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=None, description=None, enum=None, format=None, items=None, properties=None, required=None), 'param3': Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=None, max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=, description=None, enum=None, format=None, items=None, properties={'json': Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=None, max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=, description=None, enum=None, format=None, items=None, properties=None, required=None)}, required=[])}, required=[]))], retrieval=None, google_search=None, google_search_retrieval=None, code_execution=None)], tool_config=None, labels=None, cached_content=None, response_modalities=None, media_resolution=None, speech_config=None, audio_timestamp=None, automatic_function_calling=AutomaticFunctionCallingConfig(disable=True, maximum_remote_calls=None, ignore_call_history=None), thinking_config=None), + 'config': GenerateContentConfig(http_options=None, system_instruction="Current time is 05:00:00. Today's date is 2024-05-24.\nYou are a voice assistant for Home Assistant.\nAnswer questions about the world truthfully.\nAnswer in plain text. Keep it simple and to the point.\nOnly if the user wants to control a device, tell them to expose entities to their voice assistant in Home Assistant.", temperature=1.0, top_p=0.95, top_k=64.0, candidate_count=None, max_output_tokens=150, stop_sequences=None, response_logprobs=None, logprobs=None, presence_penalty=None, frequency_penalty=None, seed=None, response_mime_type=None, response_schema=None, routing_config=None, safety_settings=[SafetySetting(method=None, category=, threshold=), SafetySetting(method=None, category=, threshold=), SafetySetting(method=None, category=, threshold=), SafetySetting(method=None, category=, threshold=)], tools=[Tool(function_declarations=[FunctionDeclaration(response=None, description='Test function', name='test_tool', parameters=Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=None, max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=, description=None, enum=None, format=None, items=None, properties={'param1': Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=None, max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=, description='Test parameters', enum=None, format=None, items=Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=None, max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=, description=None, enum=None, format=None, items=None, properties=None, required=None), properties=None, required=None), 'param2': Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=[Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=None, max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=, description=None, enum=None, format=None, items=None, properties=None, required=None), Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=None, max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=, description=None, enum=None, format=None, items=None, properties=None, required=None)], max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=None, description=None, enum=None, format=None, items=None, properties=None, required=None), 'param3': Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=None, max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=, description=None, enum=None, format=None, items=None, properties={'json': Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=None, max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=, description=None, enum=None, format=None, items=None, properties=None, required=None)}, required=[])}, required=[]))], retrieval=None, google_search=None, google_search_retrieval=None, code_execution=None)], tool_config=None, labels=None, cached_content=None, response_modalities=None, media_resolution=None, speech_config=None, audio_timestamp=None, automatic_function_calling=AutomaticFunctionCallingConfig(disable=True, maximum_remote_calls=None, ignore_call_history=None), thinking_config=None), 'history': list([ ]), 'model': 'models/gemini-2.0-flash', diff --git a/tests/components/google_generative_ai_conversation/test_conversation.py b/tests/components/google_generative_ai_conversation/test_conversation.py index 229ee0b323e..5e887d3cab7 100644 --- a/tests/components/google_generative_ai_conversation/test_conversation.py +++ b/tests/components/google_generative_ai_conversation/test_conversation.py @@ -493,6 +493,42 @@ async def test_escape_decode() -> None: {"type": "string", "enum": ["a", "b", "c"]}, {"type": "STRING", "enum": ["a", "b", "c"]}, ), + ( + {"type": "string", "format": "enum", "enum": ["a", "b", "c"]}, + {"type": "STRING", "format": "enum", "enum": ["a", "b", "c"]}, + ), + ( + {"type": "string", "format": "date-time"}, + {"type": "STRING", "format": "date-time"}, + ), + ( + {"type": "string", "format": "byte"}, + {"type": "STRING"}, + ), + ( + {"type": "number", "format": "float"}, + {"type": "NUMBER", "format": "float"}, + ), + ( + {"type": "number", "format": "double"}, + {"type": "NUMBER", "format": "double"}, + ), + ( + {"type": "number", "format": "hex"}, + {"type": "NUMBER"}, + ), + ( + {"type": "integer", "format": "int32"}, + {"type": "INTEGER", "format": "int32"}, + ), + ( + {"type": "integer", "format": "int64"}, + {"type": "INTEGER", "format": "int64"}, + ), + ( + {"type": "integer", "format": "int8"}, + {"type": "INTEGER"}, + ), ( {"type": "integer", "enum": [1, 2, 3]}, {"type": "STRING", "enum": ["1", "2", "3"]}, @@ -515,11 +551,11 @@ async def test_escape_decode() -> None: ] }, ), - ({"type": "string", "format": "lower"}, {"format": "lower", "type": "STRING"}), - ({"type": "boolean", "format": "bool"}, {"format": "bool", "type": "BOOLEAN"}), + ({"type": "string", "format": "lower"}, {"type": "STRING"}), + ({"type": "boolean", "format": "bool"}, {"type": "BOOLEAN"}), ( {"type": "number", "format": "percent"}, - {"type": "NUMBER", "format": "percent"}, + {"type": "NUMBER"}, ), ( { From 6953c20a657543c36ceb6bf9778b5e68f92515f3 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 28 Feb 2025 09:15:13 +0100 Subject: [PATCH 1921/3148] Set SmartThings suggested display precision (#139470) --- .../components/smartthings/sensor.py | 5 ++ .../smartthings/snapshots/test_sensor.ambr | 90 +++++++++++++++++++ 2 files changed, 95 insertions(+) diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index 2d817c182da..cd12bf46e25 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -580,6 +580,7 @@ CAPABILITY_TO_SENSORS: dict[ device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, value_fn=lambda value: value["energy"] / 1000, + suggested_display_precision=2, except_if_state_none=True, ), SmartThingsSensorEntityDescription( @@ -589,6 +590,7 @@ CAPABILITY_TO_SENSORS: dict[ native_unit_of_measurement=UnitOfPower.WATT, value_fn=lambda value: value["power"], extra_state_attributes_fn=power_attributes, + suggested_display_precision=2, except_if_state_none=True, ), SmartThingsSensorEntityDescription( @@ -598,6 +600,7 @@ CAPABILITY_TO_SENSORS: dict[ device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, value_fn=lambda value: value["deltaEnergy"] / 1000, + suggested_display_precision=2, except_if_state_none=True, ), SmartThingsSensorEntityDescription( @@ -607,6 +610,7 @@ CAPABILITY_TO_SENSORS: dict[ device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, value_fn=lambda value: value["powerEnergy"] / 1000, + suggested_display_precision=2, except_if_state_none=True, ), SmartThingsSensorEntityDescription( @@ -616,6 +620,7 @@ CAPABILITY_TO_SENSORS: dict[ device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, value_fn=lambda value: value["energySaved"] / 1000, + suggested_display_precision=2, except_if_state_none=True, ), ] diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index 93a683afe82..b67d15bef55 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -545,6 +545,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -597,6 +600,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -649,6 +655,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -753,6 +762,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -807,6 +819,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -959,6 +974,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -1011,6 +1029,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -1063,6 +1084,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -1167,6 +1191,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -1221,6 +1248,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -1768,6 +1798,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -1820,6 +1853,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -1872,6 +1908,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -1924,6 +1963,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -1978,6 +2020,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -2326,6 +2371,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -2378,6 +2426,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -2430,6 +2481,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -2614,6 +2668,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -2668,6 +2725,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -2768,6 +2828,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -2820,6 +2883,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -2872,6 +2938,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -3066,6 +3135,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -3120,6 +3192,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -3220,6 +3295,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -3272,6 +3350,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -3324,6 +3405,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -3520,6 +3604,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -3574,6 +3661,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, From 05df57295193d3f01b40cbfe7fbc80498571bf68 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 28 Feb 2025 10:30:31 +0100 Subject: [PATCH 1922/3148] Bump pysmartthings to 2.1.0 (#139460) --- homeassistant/components/smartthings/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/smartthings/manifest.json b/homeassistant/components/smartthings/manifest.json index 1f52cd23ff3..5dd570f2751 100644 --- a/homeassistant/components/smartthings/manifest.json +++ b/homeassistant/components/smartthings/manifest.json @@ -29,5 +29,5 @@ "documentation": "https://www.home-assistant.io/integrations/smartthings", "iot_class": "cloud_push", "loggers": ["pysmartthings"], - "requirements": ["pysmartthings==2.0.1"] + "requirements": ["pysmartthings==2.1.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 1c1b33fca80..00509109413 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2310,7 +2310,7 @@ pysma==0.7.5 pysmappee==0.2.29 # homeassistant.components.smartthings -pysmartthings==2.0.1 +pysmartthings==2.1.0 # homeassistant.components.smarty pysmarty2==0.10.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9bd33de07c7..609639b0735 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1882,7 +1882,7 @@ pysma==0.7.5 pysmappee==0.2.29 # homeassistant.components.smartthings -pysmartthings==2.0.1 +pysmartthings==2.1.0 # homeassistant.components.smarty pysmarty2==0.10.2 From 9d10e0e054b2e8ebcf88304c24fd5166aed0c5c0 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Fri, 28 Feb 2025 11:18:16 +0100 Subject: [PATCH 1923/3148] Change webdav namespace to absolut URI (#139456) * Change webdav namespace to absolut URI * Add const file --- homeassistant/components/webdav/backup.py | 13 +++++++------ tests/components/webdav/const.py | 4 ++-- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/webdav/backup.py b/homeassistant/components/webdav/backup.py index a51866fde61..f810547022b 100644 --- a/homeassistant/components/webdav/backup.py +++ b/homeassistant/components/webdav/backup.py @@ -30,6 +30,7 @@ _LOGGER = logging.getLogger(__name__) METADATA_VERSION = "1" BACKUP_TIMEOUT = ClientTimeout(connect=10, total=43200) +NAMESPACE = "https://home-assistant.io" async def async_get_backup_agents( @@ -100,14 +101,14 @@ def _is_current_metadata_version(properties: list[Property]) -> bool: return any( prop.value == METADATA_VERSION for prop in properties - if prop.namespace == "homeassistant" and prop.name == "metadata_version" + if prop.namespace == NAMESPACE and prop.name == "metadata_version" ) def _backup_id_from_properties(properties: list[Property]) -> str | None: """Return the backup ID from properties.""" for prop in properties: - if prop.namespace == "homeassistant" and prop.name == "backup_id": + if prop.namespace == NAMESPACE and prop.name == "backup_id": return prop.value return None @@ -186,12 +187,12 @@ class WebDavBackupAgent(BackupAgent): f"{self._backup_path}/{filename_meta}", [ Property( - namespace="homeassistant", + namespace=NAMESPACE, name="backup_id", value=backup.backup_id, ), Property( - namespace="homeassistant", + namespace=NAMESPACE, name="metadata_version", value=METADATA_VERSION, ), @@ -252,11 +253,11 @@ class WebDavBackupAgent(BackupAgent): self._backup_path, [ PropertyRequest( - namespace="homeassistant", + namespace=NAMESPACE, name="metadata_version", ), PropertyRequest( - namespace="homeassistant", + namespace=NAMESPACE, name="backup_id", ), ], diff --git a/tests/components/webdav/const.py b/tests/components/webdav/const.py index 52cad9a163b..8d6b8ad67d7 100644 --- a/tests/components/webdav/const.py +++ b/tests/components/webdav/const.py @@ -20,12 +20,12 @@ MOCK_LIST_WITH_PROPERTIES = { "/Automatic_backup_2025.2.1_2025-02-10_18.31_30202686.tar": [], "/Automatic_backup_2025.2.1_2025-02-10_18.31_30202686.metadata.json": [ Property( - namespace="homeassistant", + namespace="https://home-assistant.io", name="backup_id", value="23e64aec", ), Property( - namespace="homeassistant", + namespace="https://home-assistant.io", name="metadata_version", value="1", ), From 1be98366635c32360736900d607c90194ccbe37c Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 28 Feb 2025 11:44:16 +0100 Subject: [PATCH 1924/3148] Fail recorder.backup.async_pre_backup if Home Assistant is not running (#139491) Fail recorder.backup.async_pre_backup if hass is not running --- homeassistant/components/recorder/backup.py | 4 ++- tests/components/recorder/test_backup.py | 38 ++++++++++++++++++++- 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/recorder/backup.py b/homeassistant/components/recorder/backup.py index d47cbe92bd4..eeebe328007 100644 --- a/homeassistant/components/recorder/backup.py +++ b/homeassistant/components/recorder/backup.py @@ -2,7 +2,7 @@ from logging import getLogger -from homeassistant.core import HomeAssistant +from homeassistant.core import CoreState, HomeAssistant from homeassistant.exceptions import HomeAssistantError from .util import async_migration_in_progress, get_instance @@ -14,6 +14,8 @@ async def async_pre_backup(hass: HomeAssistant) -> None: """Perform operations before a backup starts.""" _LOGGER.info("Backup start notification, locking database for writes") instance = get_instance(hass) + if hass.state is not CoreState.running: + raise HomeAssistantError("Home Assistant is not running") if async_migration_in_progress(hass): raise HomeAssistantError("Database migration in progress") await instance.lock_database() diff --git a/tests/components/recorder/test_backup.py b/tests/components/recorder/test_backup.py index 08fbef01bdd..bed9e88fcbf 100644 --- a/tests/components/recorder/test_backup.py +++ b/tests/components/recorder/test_backup.py @@ -1,12 +1,13 @@ """Test backup platform for the Recorder integration.""" +from contextlib import AbstractContextManager, nullcontext as does_not_raise from unittest.mock import patch import pytest from homeassistant.components.recorder import Recorder from homeassistant.components.recorder.backup import async_post_backup, async_pre_backup -from homeassistant.core import HomeAssistant +from homeassistant.core import CoreState, HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -19,6 +20,41 @@ async def test_async_pre_backup(recorder_mock: Recorder, hass: HomeAssistant) -> assert lock_mock.called +RAISES_HASS_NOT_RUNNING = pytest.raises( + HomeAssistantError, match="Home Assistant is not running" +) + + +@pytest.mark.parametrize( + ("core_state", "expected_result", "lock_calls"), + [ + (CoreState.final_write, RAISES_HASS_NOT_RUNNING, 0), + (CoreState.not_running, RAISES_HASS_NOT_RUNNING, 0), + (CoreState.running, does_not_raise(), 1), + (CoreState.starting, RAISES_HASS_NOT_RUNNING, 0), + (CoreState.stopped, RAISES_HASS_NOT_RUNNING, 0), + (CoreState.stopping, RAISES_HASS_NOT_RUNNING, 0), + ], +) +async def test_async_pre_backup_core_state( + recorder_mock: Recorder, + hass: HomeAssistant, + core_state: CoreState, + expected_result: AbstractContextManager, + lock_calls: int, +) -> None: + """Test pre backup in different core states.""" + hass.set_state(core_state) + with ( # pylint: disable=confusing-with-statement + patch( + "homeassistant.components.recorder.core.Recorder.lock_database" + ) as lock_mock, + expected_result, + ): + await async_pre_backup(hass) + assert len(lock_mock.mock_calls) == lock_calls + + async def test_async_pre_backup_with_timeout( recorder_mock: Recorder, hass: HomeAssistant ) -> None: From 5cf56ec11370c702a25edb03bf3685bef2c6f812 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 28 Feb 2025 11:44:58 +0100 Subject: [PATCH 1925/3148] Adjust recorder backup platform tests (#139492) --- tests/components/recorder/test_backup.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/tests/components/recorder/test_backup.py b/tests/components/recorder/test_backup.py index bed9e88fcbf..a4362b1fa4c 100644 --- a/tests/components/recorder/test_backup.py +++ b/tests/components/recorder/test_backup.py @@ -17,7 +17,7 @@ async def test_async_pre_backup(recorder_mock: Recorder, hass: HomeAssistant) -> "homeassistant.components.recorder.core.Recorder.lock_database" ) as lock_mock: await async_pre_backup(hass) - assert lock_mock.called + assert lock_mock.called RAISES_HASS_NOT_RUNNING = pytest.raises( @@ -75,13 +75,17 @@ async def test_async_pre_backup_with_migration( ) -> None: """Test pre backup with migration.""" with ( + patch( + "homeassistant.components.recorder.core.Recorder.lock_database" + ) as lock_mock, patch( "homeassistant.components.recorder.backup.async_migration_in_progress", return_value=True, ), - pytest.raises(HomeAssistantError), + pytest.raises(HomeAssistantError, match="Database migration in progress"), ): await async_pre_backup(hass) + assert not lock_mock.called async def test_async_post_backup(recorder_mock: Recorder, hass: HomeAssistant) -> None: @@ -90,7 +94,7 @@ async def test_async_post_backup(recorder_mock: Recorder, hass: HomeAssistant) - "homeassistant.components.recorder.core.Recorder.unlock_database" ) as unlock_mock: await async_post_backup(hass) - assert unlock_mock.called + assert unlock_mock.called async def test_async_post_backup_failure( @@ -102,7 +106,9 @@ async def test_async_post_backup_failure( "homeassistant.components.recorder.core.Recorder.unlock_database", return_value=False, ) as unlock_mock, - pytest.raises(HomeAssistantError), + pytest.raises( + HomeAssistantError, match="Could not release database write lock" + ), ): await async_post_backup(hass) assert unlock_mock.called From 12cb349160c5f47f6776e647e275f8b9e5444f31 Mon Sep 17 00:00:00 2001 From: pglab-electronics <89299919+pglab-electronics@users.noreply.github.com> Date: Fri, 28 Feb 2025 12:07:01 +0100 Subject: [PATCH 1926/3148] Add Sensor to PG LAB Integration (#138802) --- .../components/pglab/device_sensor.py | 56 +++++++++ homeassistant/components/pglab/discovery.py | 28 ++++- homeassistant/components/pglab/entity.py | 18 ++- homeassistant/components/pglab/sensor.py | 119 ++++++++++++++++++ homeassistant/components/pglab/strings.json | 11 ++ .../pglab/snapshots/test_sensor.ambr | 95 ++++++++++++++ tests/components/pglab/test_sensor.py | 71 +++++++++++ 7 files changed, 391 insertions(+), 7 deletions(-) create mode 100644 homeassistant/components/pglab/device_sensor.py create mode 100644 homeassistant/components/pglab/sensor.py create mode 100644 tests/components/pglab/snapshots/test_sensor.ambr create mode 100644 tests/components/pglab/test_sensor.py diff --git a/homeassistant/components/pglab/device_sensor.py b/homeassistant/components/pglab/device_sensor.py new file mode 100644 index 00000000000..d202d11d6e7 --- /dev/null +++ b/homeassistant/components/pglab/device_sensor.py @@ -0,0 +1,56 @@ +"""Device Sensor for PG LAB Electronics.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from pypglab.device import Device as PyPGLabDevice +from pypglab.sensor import Sensor as PyPGLabSensors + +from homeassistant.core import callback + +if TYPE_CHECKING: + from .entity import PGLabEntity + + +class PGLabDeviceSensor: + """Keeps PGLab device sensor update.""" + + def __init__(self, pglab_device: PyPGLabDevice) -> None: + """Initialize the device sensor.""" + + # get a reference of PG Lab device internal sensors state + self._sensors: PyPGLabSensors = pglab_device.sensors + + self._ha_sensors: list[PGLabEntity] = [] # list of HA entity sensors + + async def subscribe_topics(self): + """Subscribe to the device sensors topics.""" + self._sensors.set_on_state_callback(self.state_updated) + await self._sensors.subscribe_topics() + + def add_ha_sensor(self, entity: PGLabEntity) -> None: + """Add a new HA sensor to the list.""" + self._ha_sensors.append(entity) + + def remove_ha_sensor(self, entity: PGLabEntity) -> None: + """Remove a HA sensor from the list.""" + self._ha_sensors.remove(entity) + + @callback + def state_updated(self, payload: str) -> None: + """Handle state updates.""" + + # notify all HA sensors that PG LAB device sensor fields have been updated + for s in self._ha_sensors: + s.state_updated(payload) + + @property + def state(self) -> dict: + """Return the device sensors state.""" + return self._sensors.state + + @property + def sensors(self) -> PyPGLabSensors: + """Return the pypglab device sensors.""" + return self._sensors diff --git a/homeassistant/components/pglab/discovery.py b/homeassistant/components/pglab/discovery.py index af6bedc9bf4..fec6f5ce40d 100644 --- a/homeassistant/components/pglab/discovery.py +++ b/homeassistant/components/pglab/discovery.py @@ -28,17 +28,20 @@ from homeassistant.helpers.dispatcher import ( from homeassistant.helpers.entity import Entity from .const import DISCOVERY_TOPIC, DOMAIN, LOGGER +from .device_sensor import PGLabDeviceSensor if TYPE_CHECKING: from . import PGLABConfigEntry # Supported platforms. PLATFORMS = [ + Platform.SENSOR, Platform.SWITCH, ] # Used to create a new component entity. CREATE_NEW_ENTITY = { + Platform.SENSOR: "pglab_create_new_entity_sensor", Platform.SWITCH: "pglab_create_new_entity_switch", } @@ -74,6 +77,7 @@ class DiscoverDeviceInfo: # When the hash string changes the devices entities must be rebuilt. self._hash = pglab_device.hash self._entities: list[tuple[str, str]] = [] + self._sensors = PGLabDeviceSensor(pglab_device) def add_entity(self, entity: Entity) -> None: """Add an entity.""" @@ -93,6 +97,20 @@ class DiscoverDeviceInfo: """Return array of entities available.""" return self._entities + @property + def sensors(self) -> PGLabDeviceSensor: + """Return the PGLab device sensor.""" + return self._sensors + + +async def createDiscoverDeviceInfo(pglab_device: PyPGLabDevice) -> DiscoverDeviceInfo: + """Create a new DiscoverDeviceInfo instance.""" + discovery_info = DiscoverDeviceInfo(pglab_device) + + # Subscribe to sensor state changes. + await discovery_info.sensors.subscribe_topics() + return discovery_info + @dataclass class PGLabDiscovery: @@ -223,7 +241,7 @@ class PGLabDiscovery: self.__clean_discovered_device(hass, pglab_device.id) # Add a new device. - discovery_info = DiscoverDeviceInfo(pglab_device) + discovery_info = await createDiscoverDeviceInfo(pglab_device) self._discovered[pglab_device.id] = discovery_info # Create all new relay entities. @@ -233,6 +251,14 @@ class PGLabDiscovery: hass, CREATE_NEW_ENTITY[Platform.SWITCH], pglab_device, r ) + # Create all new sensor entities. + async_dispatcher_send( + hass, + CREATE_NEW_ENTITY[Platform.SENSOR], + pglab_device, + discovery_info.sensors, + ) + topics = { "discovery_topic": { "topic": f"{self._discovery_topic}/#", diff --git a/homeassistant/components/pglab/entity.py b/homeassistant/components/pglab/entity.py index 1b8975a3bbe..175b4c1eb0f 100644 --- a/homeassistant/components/pglab/entity.py +++ b/homeassistant/components/pglab/entity.py @@ -43,12 +43,20 @@ class PGLabEntity(Entity): connections={(CONNECTION_NETWORK_MAC, device.mac)}, ) - async def async_added_to_hass(self) -> None: - """Update the device discovery info.""" - + async def subscribe_to_update(self): + """Subscribe to the entity updates.""" self._entity.set_on_state_callback(self.state_updated) await self._entity.subscribe_topics() + async def unsubscribe_to_update(self): + """Unsubscribe to the entity updates.""" + await self._entity.unsubscribe_topics() + self._entity.set_on_state_callback(None) + + async def async_added_to_hass(self) -> None: + """Update the device discovery info.""" + + await self.subscribe_to_update() await super().async_added_to_hass() # Inform PGLab discovery instance that a new entity is available. @@ -60,9 +68,7 @@ class PGLabEntity(Entity): """Unsubscribe when removed.""" await super().async_will_remove_from_hass() - - await self._entity.unsubscribe_topics() - self._entity.set_on_state_callback(None) + await self.unsubscribe_to_update() @callback def state_updated(self, payload: str) -> None: diff --git a/homeassistant/components/pglab/sensor.py b/homeassistant/components/pglab/sensor.py new file mode 100644 index 00000000000..f868e7ae101 --- /dev/null +++ b/homeassistant/components/pglab/sensor.py @@ -0,0 +1,119 @@ +"""Sensor for PG LAB Electronics.""" + +from __future__ import annotations + +from datetime import timedelta + +from pypglab.const import SENSOR_REBOOT_TIME, SENSOR_TEMPERATURE, SENSOR_VOLTAGE +from pypglab.device import Device as PyPGLabDevice + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import Platform, UnitOfElectricPotential, UnitOfTemperature +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.util.dt import utcnow + +from . import PGLABConfigEntry +from .device_sensor import PGLabDeviceSensor +from .discovery import PGLabDiscovery +from .entity import PGLabEntity + +PARALLEL_UPDATES = 0 + +SENSOR_INFO: list[SensorEntityDescription] = [ + SensorEntityDescription( + key=SENSOR_TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key=SENSOR_VOLTAGE, + translation_key="mpu_voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key=SENSOR_REBOOT_TIME, + translation_key="runtime", + device_class=SensorDeviceClass.TIMESTAMP, + icon="mdi:progress-clock", + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: PGLABConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up sensor for device.""" + + @callback + def async_discover( + pglab_device: PyPGLabDevice, + pglab_device_sensor: PGLabDeviceSensor, + ) -> None: + """Discover and add a PG LAB Sensor.""" + pglab_discovery = config_entry.runtime_data + for description in SENSOR_INFO: + pglab_sensor = PGLabSensor( + pglab_discovery, pglab_device, pglab_device_sensor, description + ) + async_add_entities([pglab_sensor]) + + # Register the callback to create the sensor entity when discovered. + pglab_discovery = config_entry.runtime_data + await pglab_discovery.register_platform(hass, Platform.SENSOR, async_discover) + + +class PGLabSensor(PGLabEntity, SensorEntity): + """A PGLab sensor.""" + + def __init__( + self, + pglab_discovery: PGLabDiscovery, + pglab_device: PyPGLabDevice, + pglab_device_sensor: PGLabDeviceSensor, + description: SensorEntityDescription, + ) -> None: + """Initialize the Sensor class.""" + + super().__init__( + discovery=pglab_discovery, + device=pglab_device, + entity=pglab_device_sensor.sensors, + ) + + self._type = description.key + self._pglab_device_sensor = pglab_device_sensor + self._attr_unique_id = f"{pglab_device.id}_{description.key}" + self.entity_description = description + + @callback + def state_updated(self, payload: str) -> None: + """Handle state updates.""" + + # get the sensor value from pglab multi fields sensor + value = self._pglab_device_sensor.state[self._type] + + if self.entity_description.device_class == SensorDeviceClass.TIMESTAMP: + self._attr_native_value = utcnow() - timedelta(seconds=value) + else: + self._attr_native_value = value + + super().state_updated(payload) + + async def subscribe_to_update(self): + """Register the HA sensor to be notify when the sensor status is changed.""" + self._pglab_device_sensor.add_ha_sensor(self) + + async def unsubscribe_to_update(self): + """Unregister the HA sensor from sensor tatus updates.""" + self._pglab_device_sensor.remove_ha_sensor(self) diff --git a/homeassistant/components/pglab/strings.json b/homeassistant/components/pglab/strings.json index 8f9021cdcca..4fad408ad98 100644 --- a/homeassistant/components/pglab/strings.json +++ b/homeassistant/components/pglab/strings.json @@ -19,6 +19,17 @@ "relay": { "name": "Relay {relay_id}" } + }, + "sensor": { + "temperature": { + "name": "Temperature" + }, + "runtime": { + "name": "Run time" + }, + "mpu_voltage": { + "name": "MPU voltage" + } } } } diff --git a/tests/components/pglab/snapshots/test_sensor.ambr b/tests/components/pglab/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..f25f459bb70 --- /dev/null +++ b/tests/components/pglab/snapshots/test_sensor.ambr @@ -0,0 +1,95 @@ +# serializer version: 1 +# name: test_sensors[mpu_voltage][initial_sensor_mpu_voltage] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'test MPU voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_mpu_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[mpu_voltage][updated_sensor_mpu_voltage] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'test MPU voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_mpu_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3.31', + }) +# --- +# name: test_sensors[run_time][initial_sensor_run_time] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'test Run time', + 'icon': 'mdi:progress-clock', + }), + 'context': , + 'entity_id': 'sensor.test_run_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[run_time][updated_sensor_run_time] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'test Run time', + 'icon': 'mdi:progress-clock', + }), + 'context': , + 'entity_id': 'sensor.test_run_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2024-02-26T01:04:54+00:00', + }) +# --- +# name: test_sensors[temperature][initial_sensor_temperature] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'test Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[temperature][updated_sensor_temperature] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'test Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '33.4', + }) +# --- diff --git a/tests/components/pglab/test_sensor.py b/tests/components/pglab/test_sensor.py new file mode 100644 index 00000000000..ff20d1452a4 --- /dev/null +++ b/tests/components/pglab/test_sensor.py @@ -0,0 +1,71 @@ +"""The tests for the PG LAB Electronics sensor.""" + +import json + +from freezegun import freeze_time +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.core import HomeAssistant + +from tests.common import async_fire_mqtt_message +from tests.typing import MqttMockHAClient + + +async def send_discovery_message(hass: HomeAssistant) -> None: + """Send mqtt discovery message.""" + + topic = "pglab/discovery/E-Board-DD53AC85/config" + payload = { + "ip": "192.168.1.16", + "mac": "80:34:28:1B:18:5A", + "name": "test", + "hw": "1.0.7", + "fw": "1.0.0", + "type": "E-Board", + "id": "E-Board-DD53AC85", + "manufacturer": "PG LAB Electronics", + "params": {"shutters": 0, "boards": "00000000"}, + } + + async_fire_mqtt_message( + hass, + topic, + json.dumps(payload), + ) + await hass.async_block_till_done() + + +@freeze_time("2024-02-26 01:21:34") +@pytest.mark.parametrize( + "sensor_suffix", + [ + "temperature", + "mpu_voltage", + "run_time", + ], +) +async def test_sensors( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mqtt_mock: MqttMockHAClient, + setup_pglab, + sensor_suffix: str, +) -> None: + """Check if sensors are properly created and updated.""" + + # send the discovery message to make E-BOARD device discoverable + await send_discovery_message(hass) + + # check initial sensors state + state = hass.states.get(f"sensor.test_{sensor_suffix}") + assert state == snapshot(name=f"initial_sensor_{sensor_suffix}") + + # update sensors value via mqtt + update_payload = {"temp": 33.4, "volt": 3.31, "rtime": 1000} + async_fire_mqtt_message(hass, "pglab/test/sensor/value", json.dumps(update_payload)) + await hass.async_block_till_done() + + # check updated sensors state + state = hass.states.get(f"sensor.test_{sensor_suffix}") + assert state == snapshot(name=f"updated_sensor_{sensor_suffix}") From a296c5e9ad301cc56fad055078073bc5bb3386b5 Mon Sep 17 00:00:00 2001 From: Petro31 <35082313+Petro31@users.noreply.github.com> Date: Fri, 28 Feb 2025 06:44:01 -0500 Subject: [PATCH 1927/3148] Add floor_entities function and filter (#136509) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Abílio Costa --- homeassistant/helpers/template.py | 12 ++++++ tests/helpers/test_template.py | 69 +++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+) diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 7866250d658..7dc3097cdb3 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -1525,6 +1525,15 @@ def floor_areas(hass: HomeAssistant, floor_id_or_name: str) -> Iterable[str]: return [entry.id for entry in entries if entry.id] +def floor_entities(hass: HomeAssistant, floor_id_or_name: str) -> Iterable[str]: + """Return entity_ids for a given floor ID or name.""" + return [ + entity_id + for area_id in floor_areas(hass, floor_id_or_name) + for entity_id in area_entities(hass, area_id) + ] + + def areas(hass: HomeAssistant) -> Iterable[str | None]: """Return all areas.""" return list(area_registry.async_get(hass).areas) @@ -3048,6 +3057,9 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): self.globals["floor_areas"] = hassfunction(floor_areas) self.filters["floor_areas"] = self.globals["floor_areas"] + self.globals["floor_entities"] = hassfunction(floor_entities) + self.filters["floor_entities"] = self.globals["floor_entities"] + self.globals["integration_entities"] = hassfunction(integration_entities) self.filters["integration_entities"] = self.globals["integration_entities"] diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index b3a30806cbd..016aedb2f99 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -5881,6 +5881,75 @@ async def test_floor_areas( assert info.rate_limit is None +async def test_floor_entities( + hass: HomeAssistant, + floor_registry: fr.FloorRegistry, + area_registry: ar.AreaRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test floor_entities function.""" + + # Test non existing floor ID + info = render_to_info(hass, "{{ floor_entities('skyring') }}") + assert_result_info(info, []) + assert info.rate_limit is None + + info = render_to_info(hass, "{{ 'skyring' | floor_entities }}") + assert_result_info(info, []) + assert info.rate_limit is None + + # Test wrong value type + info = render_to_info(hass, "{{ floor_entities(42) }}") + assert_result_info(info, []) + assert info.rate_limit is None + + info = render_to_info(hass, "{{ 42 | floor_entities }}") + assert_result_info(info, []) + assert info.rate_limit is None + + floor = floor_registry.async_create("First floor") + area1 = area_registry.async_create("Living room") + area2 = area_registry.async_create("Dining room") + area_registry.async_update(area1.id, floor_id=floor.floor_id) + area_registry.async_update(area2.id, floor_id=floor.floor_id) + + config_entry = MockConfigEntry(domain="light") + config_entry.add_to_hass(hass) + entity_entry = entity_registry.async_get_or_create( + "light", + "hue", + "living_room", + config_entry=config_entry, + ) + entity_registry.async_update_entity(entity_entry.entity_id, area_id=area1.id) + entity_entry = entity_registry.async_get_or_create( + "light", + "hue", + "dining_room", + config_entry=config_entry, + ) + entity_registry.async_update_entity(entity_entry.entity_id, area_id=area2.id) + + # Get entities by floor ID + expected = ["light.hue_living_room", "light.hue_dining_room"] + info = render_to_info(hass, f"{{{{ floor_entities('{floor.floor_id}') }}}}") + assert_result_info(info, expected) + assert info.rate_limit is None + + info = render_to_info(hass, f"{{{{ '{floor.floor_id}' | floor_entities }}}}") + assert_result_info(info, expected) + assert info.rate_limit is None + + # Get entities by floor name + info = render_to_info(hass, f"{{{{ floor_entities('{floor.name}') }}}}") + assert_result_info(info, expected) + assert info.rate_limit is None + + info = render_to_info(hass, f"{{{{ '{floor.name}' | floor_entities }}}}") + assert_result_info(info, expected) + assert info.rate_limit is None + + async def test_labels( hass: HomeAssistant, label_registry: lr.LabelRegistry, From 9a62b0f2457e6e0b95d52f3c83083a666e563322 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 28 Feb 2025 13:05:30 +0100 Subject: [PATCH 1928/3148] Enable ASYNC ruff rules (#139507) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Abílio Costa --- pyproject.toml | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index eda2a495726..5ee20b96bfc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -705,12 +705,7 @@ required-version = ">=0.9.1" [tool.ruff.lint] select = [ "A001", # Variable {name} is shadowing a Python builtin - "ASYNC210", # Async functions should not call blocking HTTP methods - "ASYNC220", # Async functions should not create subprocesses with blocking methods - "ASYNC221", # Async functions should not run processes with blocking methods - "ASYNC222", # Async functions should not wait on processes with blocking methods - "ASYNC230", # Async functions should not open files with blocking methods like open - "ASYNC251", # Async functions should not call time.sleep + "ASYNC", # flake8-async "B002", # Python does not support the unary prefix increment "B005", # Using .strip() with multi-character strings is misleading "B007", # Loop control variable {name} not used within loop body @@ -810,6 +805,8 @@ select = [ ] ignore = [ + "ASYNC109", # Async function definition with a `timeout` parameter Use `asyncio.timeout` instead + "ASYNC110", # Use `asyncio.Event` instead of awaiting `asyncio.sleep` in a `while` loop "D202", # No blank lines allowed after function docstring "D203", # 1 blank line required before class docstring "D213", # Multi-line docstring summary should start at the second line From 62dc0ac485b8b3b98794e767b45d754a5382000a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 28 Feb 2025 13:38:56 +0100 Subject: [PATCH 1929/3148] Bump actions/cache from 4.2.1 to 4.2.2 (#139490) Bumps [actions/cache](https://github.com/actions/cache) from 4.2.1 to 4.2.2. - [Release notes](https://github.com/actions/cache/releases) - [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md) - [Commits](https://github.com/actions/cache/compare/v4.2.1...v4.2.2) --- updated-dependencies: - dependency-name: actions/cache dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yaml | 44 +++++++++++++++++++-------------------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 97986f26ee3..829888f3fe2 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -240,7 +240,7 @@ jobs: check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache@v4.2.1 + uses: actions/cache@v4.2.2 with: path: venv key: >- @@ -256,7 +256,7 @@ jobs: uv pip install "$(cat requirements_test.txt | grep pre-commit)" - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache@v4.2.1 + uses: actions/cache@v4.2.2 with: path: ${{ env.PRE_COMMIT_CACHE }} lookup-only: true @@ -286,7 +286,7 @@ jobs: check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.1 + uses: actions/cache/restore@v4.2.2 with: path: venv fail-on-cache-miss: true @@ -295,7 +295,7 @@ jobs: needs.info.outputs.pre-commit_cache_key }} - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache/restore@v4.2.1 + uses: actions/cache/restore@v4.2.2 with: path: ${{ env.PRE_COMMIT_CACHE }} fail-on-cache-miss: true @@ -326,7 +326,7 @@ jobs: check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.1 + uses: actions/cache/restore@v4.2.2 with: path: venv fail-on-cache-miss: true @@ -335,7 +335,7 @@ jobs: needs.info.outputs.pre-commit_cache_key }} - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache/restore@v4.2.1 + uses: actions/cache/restore@v4.2.2 with: path: ${{ env.PRE_COMMIT_CACHE }} fail-on-cache-miss: true @@ -366,7 +366,7 @@ jobs: check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.1 + uses: actions/cache/restore@v4.2.2 with: path: venv fail-on-cache-miss: true @@ -375,7 +375,7 @@ jobs: needs.info.outputs.pre-commit_cache_key }} - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache/restore@v4.2.1 + uses: actions/cache/restore@v4.2.2 with: path: ${{ env.PRE_COMMIT_CACHE }} fail-on-cache-miss: true @@ -482,7 +482,7 @@ jobs: env.HA_SHORT_VERSION }}-$(date -u '+%Y-%m-%dT%H:%M:%s')" >> $GITHUB_OUTPUT - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache@v4.2.1 + uses: actions/cache@v4.2.2 with: path: venv key: >- @@ -490,7 +490,7 @@ jobs: needs.info.outputs.python_cache_key }} - name: Restore uv wheel cache if: steps.cache-venv.outputs.cache-hit != 'true' - uses: actions/cache@v4.2.1 + uses: actions/cache@v4.2.2 with: path: ${{ env.UV_CACHE_DIR }} key: >- @@ -578,7 +578,7 @@ jobs: check-latest: true - name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.1 + uses: actions/cache/restore@v4.2.2 with: path: venv fail-on-cache-miss: true @@ -611,7 +611,7 @@ jobs: check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.1 + uses: actions/cache/restore@v4.2.2 with: path: venv fail-on-cache-miss: true @@ -649,7 +649,7 @@ jobs: check-latest: true - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.1 + uses: actions/cache/restore@v4.2.2 with: path: venv fail-on-cache-miss: true @@ -692,7 +692,7 @@ jobs: check-latest: true - name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.1 + uses: actions/cache/restore@v4.2.2 with: path: venv fail-on-cache-miss: true @@ -739,7 +739,7 @@ jobs: check-latest: true - name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.1 + uses: actions/cache/restore@v4.2.2 with: path: venv fail-on-cache-miss: true @@ -791,7 +791,7 @@ jobs: env.HA_SHORT_VERSION }}-$(date -u '+%Y-%m-%dT%H:%M:%s')" >> $GITHUB_OUTPUT - name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.1 + uses: actions/cache/restore@v4.2.2 with: path: venv fail-on-cache-miss: true @@ -799,7 +799,7 @@ jobs: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ needs.info.outputs.python_cache_key }} - name: Restore mypy cache - uses: actions/cache@v4.2.1 + uses: actions/cache@v4.2.2 with: path: .mypy_cache key: >- @@ -865,7 +865,7 @@ jobs: check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.1 + uses: actions/cache/restore@v4.2.2 with: path: venv fail-on-cache-miss: true @@ -929,7 +929,7 @@ jobs: check-latest: true - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.1 + uses: actions/cache/restore@v4.2.2 with: path: venv fail-on-cache-miss: true @@ -1051,7 +1051,7 @@ jobs: check-latest: true - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.1 + uses: actions/cache/restore@v4.2.2 with: path: venv fail-on-cache-miss: true @@ -1181,7 +1181,7 @@ jobs: check-latest: true - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.1 + uses: actions/cache/restore@v4.2.2 with: path: venv fail-on-cache-miss: true @@ -1328,7 +1328,7 @@ jobs: check-latest: true - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.1 + uses: actions/cache/restore@v4.2.2 with: path: venv fail-on-cache-miss: true From 0310418efcab37affbc927a0dc509bdd7a6bf792 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 28 Feb 2025 13:54:31 +0100 Subject: [PATCH 1930/3148] Bump dawidd6/action-download-artifact from 8 to 9 (#139488) Bumps [dawidd6/action-download-artifact](https://github.com/dawidd6/action-download-artifact) from 8 to 9. - [Release notes](https://github.com/dawidd6/action-download-artifact/releases) - [Commits](https://github.com/dawidd6/action-download-artifact/compare/v8...v9) --- updated-dependencies: - dependency-name: dawidd6/action-download-artifact dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/builder.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index ed5005584bd..e730f03e1b4 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -94,7 +94,7 @@ jobs: - name: Download nightly wheels of frontend if: needs.init.outputs.channel == 'dev' - uses: dawidd6/action-download-artifact@v8 + uses: dawidd6/action-download-artifact@v9 with: github_token: ${{secrets.GITHUB_TOKEN}} repo: home-assistant/frontend @@ -105,7 +105,7 @@ jobs: - name: Download nightly wheels of intents if: needs.init.outputs.channel == 'dev' - uses: dawidd6/action-download-artifact@v8 + uses: dawidd6/action-download-artifact@v9 with: github_token: ${{secrets.GITHUB_TOKEN}} repo: home-assistant/intents-package From d6f9040bafecbe994c57ddf65dcb9665fc6c27c7 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Fri, 28 Feb 2025 14:14:56 +0100 Subject: [PATCH 1931/3148] Make the Tuya backend library compatible with the newer paho mqtt client. (#139518) * Make the Tuya backend library compatible with the newer paho mqtt client. * Improve classnames and docstrings --- homeassistant/components/tuya/__init__.py | 74 ++++++++++++++++++++++- 1 file changed, 72 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/tuya/__init__.py b/homeassistant/components/tuya/__init__.py index c8a639cd239..32119add5f4 100644 --- a/homeassistant/components/tuya/__init__.py +++ b/homeassistant/components/tuya/__init__.py @@ -3,7 +3,8 @@ from __future__ import annotations import logging -from typing import Any, NamedTuple +from typing import TYPE_CHECKING, Any, NamedTuple +from urllib.parse import urlsplit from tuya_sharing import ( CustomerDevice, @@ -11,6 +12,7 @@ from tuya_sharing import ( SharingDeviceListener, SharingTokenListener, ) +from tuya_sharing.mq import SharingMQ, SharingMQConfig from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback @@ -45,13 +47,81 @@ class HomeAssistantTuyaData(NamedTuple): listener: SharingDeviceListener +if TYPE_CHECKING: + import paho.mqtt.client as mqtt + + +class ManagerCompat(Manager): + """Extended Manager class from the Tuya device sharing SDK. + + The extension ensures compatibility a paho-mqtt client version >= 2.1.0. + It overrides extend refresh_mq method to ensure correct paho.mqtt client calls. + + This code can be removed when a version of tuya-device-sharing with + https://github.com/tuya/tuya-device-sharing-sdk/pull/25 is available. + """ + + def refresh_mq(self): + """Refresh the MQTT connection.""" + if self.mq is not None: + self.mq.stop() + self.mq = None + + home_ids = [home.id for home in self.user_homes] + device = [ + device + for device in self.device_map.values() + if hasattr(device, "id") and getattr(device, "set_up", False) + ] + + sharing_mq = SharingMQCompat(self.customer_api, home_ids, device) + sharing_mq.start() + sharing_mq.add_message_listener(self.on_message) + self.mq = sharing_mq + + +class SharingMQCompat(SharingMQ): + """Extended SharingMQ class from the Tuya device sharing SDK. + + The extension ensures compatibility a paho-mqtt client version >= 2.1.0. + It overrides _start method to ensure correct paho.mqtt client calls. + + This code can be removed when a version of tuya-device-sharing with + https://github.com/tuya/tuya-device-sharing-sdk/pull/25 is available. + """ + + def _start(self, mq_config: SharingMQConfig) -> mqtt.Client: + """Start the MQTT client.""" + # We don't import on the top because some integrations + # should be able to optionally rely on MQTT. + import paho.mqtt.client as mqtt # pylint: disable=import-outside-toplevel + + mqttc = mqtt.Client(client_id=mq_config.client_id) + mqttc.username_pw_set(mq_config.username, mq_config.password) + mqttc.user_data_set({"mqConfig": mq_config}) + mqttc.on_connect = self._on_connect + mqttc.on_message = self._on_message + mqttc.on_subscribe = self._on_subscribe + mqttc.on_log = self._on_log + mqttc.on_disconnect = self._on_disconnect + + url = urlsplit(mq_config.url) + if url.scheme == "ssl": + mqttc.tls_set() + + mqttc.connect(url.hostname, url.port) + + mqttc.loop_start() + return mqttc + + async def async_setup_entry(hass: HomeAssistant, entry: TuyaConfigEntry) -> bool: """Async setup hass config entry.""" if CONF_APP_TYPE in entry.data: raise ConfigEntryAuthFailed("Authentication failed. Please re-authenticate.") token_listener = TokenListener(hass, entry) - manager = Manager( + manager = ManagerCompat( TUYA_CLIENT_ID, entry.data[CONF_USER_CODE], entry.data[CONF_TERMINAL_ID], From b79c6e772af85618ba0c19836c099b68ee48510a Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Fri, 28 Feb 2025 14:17:02 +0100 Subject: [PATCH 1932/3148] Add new mediatypes to Music Assistant integration (#139338) * Bump Music Assistant client to 1.1.0 * Add some casts to help mypy * Add handling of the new media types in Music Assistant * mypy cleanup * lint * update snapshot * Adjust tests --------- Co-authored-by: Franck Nijhof --- .../components/music_assistant/actions.py | 36 +- .../components/music_assistant/const.py | 2 + .../components/music_assistant/schemas.py | 8 + .../components/music_assistant/services.yaml | 7 + .../components/music_assistant/strings.json | 3 + tests/components/music_assistant/common.py | 26 +- .../fixtures/library_audiobooks.json | 489 ++++++++++++++++++ .../fixtures/library_podcasts.json | 309 +++++++++++ .../snapshots/test_actions.ambr | 196 ++++++- .../music_assistant/test_actions.py | 16 +- 10 files changed, 1087 insertions(+), 5 deletions(-) create mode 100644 tests/components/music_assistant/fixtures/library_audiobooks.json create mode 100644 tests/components/music_assistant/fixtures/library_podcasts.json diff --git a/homeassistant/components/music_assistant/actions.py b/homeassistant/components/music_assistant/actions.py index bf9a1260362..031229d1544 100644 --- a/homeassistant/components/music_assistant/actions.py +++ b/homeassistant/components/music_assistant/actions.py @@ -23,6 +23,7 @@ from .const import ( ATTR_ALBUM_TYPE, ATTR_ALBUMS, ATTR_ARTISTS, + ATTR_AUDIOBOOKS, ATTR_CONFIG_ENTRY_ID, ATTR_FAVORITE, ATTR_ITEMS, @@ -32,6 +33,7 @@ from .const import ( ATTR_OFFSET, ATTR_ORDER_BY, ATTR_PLAYLISTS, + ATTR_PODCASTS, ATTR_RADIO, ATTR_SEARCH, ATTR_SEARCH_ALBUM, @@ -48,7 +50,15 @@ from .schemas import ( if TYPE_CHECKING: from music_assistant_client import MusicAssistantClient - from music_assistant_models.media_items import Album, Artist, Playlist, Radio, Track + from music_assistant_models.media_items import ( + Album, + Artist, + Audiobook, + Playlist, + Podcast, + Radio, + Track, + ) from . import MusicAssistantConfigEntry @@ -155,6 +165,14 @@ async def handle_search(call: ServiceCall) -> ServiceResponse: media_item_dict_from_mass_item(mass, item) for item in search_results.radio ], + ATTR_AUDIOBOOKS: [ + media_item_dict_from_mass_item(mass, item) + for item in search_results.audiobooks + ], + ATTR_PODCASTS: [ + media_item_dict_from_mass_item(mass, item) + for item in search_results.podcasts + ], } ) return response @@ -175,7 +193,13 @@ async def handle_get_library(call: ServiceCall) -> ServiceResponse: "order_by": order_by, } library_result: ( - list[Album] | list[Artist] | list[Track] | list[Radio] | list[Playlist] + list[Album] + | list[Artist] + | list[Track] + | list[Radio] + | list[Playlist] + | list[Audiobook] + | list[Podcast] ) if media_type == MediaType.ALBUM: library_result = await mass.music.get_library_albums( @@ -199,6 +223,14 @@ async def handle_get_library(call: ServiceCall) -> ServiceResponse: library_result = await mass.music.get_library_playlists( **base_params, ) + elif media_type == MediaType.AUDIOBOOK: + library_result = await mass.music.get_library_audiobooks( + **base_params, + ) + elif media_type == MediaType.PODCAST: + library_result = await mass.music.get_library_podcasts( + **base_params, + ) else: raise ServiceValidationError(f"Unsupported media type {media_type}") diff --git a/homeassistant/components/music_assistant/const.py b/homeassistant/components/music_assistant/const.py index 1980c495278..d2ee1f75028 100644 --- a/homeassistant/components/music_assistant/const.py +++ b/homeassistant/components/music_assistant/const.py @@ -34,6 +34,8 @@ ATTR_ARTISTS = "artists" ATTR_ALBUMS = "albums" ATTR_TRACKS = "tracks" ATTR_PLAYLISTS = "playlists" +ATTR_AUDIOBOOKS = "audiobooks" +ATTR_PODCASTS = "podcasts" ATTR_RADIO = "radio" ATTR_ITEMS = "items" ATTR_RADIO_MODE = "radio_mode" diff --git a/homeassistant/components/music_assistant/schemas.py b/homeassistant/components/music_assistant/schemas.py index 0954d1573e7..7501d3d2038 100644 --- a/homeassistant/components/music_assistant/schemas.py +++ b/homeassistant/components/music_assistant/schemas.py @@ -15,6 +15,7 @@ from .const import ( ATTR_ALBUM, ATTR_ALBUMS, ATTR_ARTISTS, + ATTR_AUDIOBOOKS, ATTR_BIT_DEPTH, ATTR_CONTENT_TYPE, ATTR_CURRENT_INDEX, @@ -31,6 +32,7 @@ from .const import ( ATTR_OFFSET, ATTR_ORDER_BY, ATTR_PLAYLISTS, + ATTR_PODCASTS, ATTR_PROVIDER, ATTR_QUEUE_ID, ATTR_QUEUE_ITEM_ID, @@ -101,6 +103,12 @@ SEARCH_RESULT_SCHEMA = vol.Schema( vol.Required(ATTR_RADIO): vol.All( cv.ensure_list, [vol.Schema(MEDIA_ITEM_SCHEMA)] ), + vol.Required(ATTR_AUDIOBOOKS): vol.All( + cv.ensure_list, [vol.Schema(MEDIA_ITEM_SCHEMA)] + ), + vol.Required(ATTR_PODCASTS): vol.All( + cv.ensure_list, [vol.Schema(MEDIA_ITEM_SCHEMA)] + ), }, ) diff --git a/homeassistant/components/music_assistant/services.yaml b/homeassistant/components/music_assistant/services.yaml index 73e8e2d7521..a3715ea2580 100644 --- a/homeassistant/components/music_assistant/services.yaml +++ b/homeassistant/components/music_assistant/services.yaml @@ -21,7 +21,10 @@ play_media: options: - artist - album + - audiobook + - folder - playlist + - podcast - track - radio artist: @@ -118,7 +121,9 @@ search: options: - artist - album + - audiobook - playlist + - podcast - track - radio artist: @@ -160,7 +165,9 @@ get_library: options: - artist - album + - audiobook - playlist + - podcast - track - radio favorite: diff --git a/homeassistant/components/music_assistant/strings.json b/homeassistant/components/music_assistant/strings.json index 32b72088518..7338af7cb65 100644 --- a/homeassistant/components/music_assistant/strings.json +++ b/homeassistant/components/music_assistant/strings.json @@ -195,8 +195,11 @@ "options": { "artist": "Artist", "album": "Album", + "audiobook": "Audiobook", + "folder": "Folder", "track": "Track", "playlist": "Playlist", + "podcast": "Podcast", "radio": "Radio" } }, diff --git a/tests/components/music_assistant/common.py b/tests/components/music_assistant/common.py index 863d945ccd1..6d7ef927c6e 100644 --- a/tests/components/music_assistant/common.py +++ b/tests/components/music_assistant/common.py @@ -8,7 +8,15 @@ from unittest.mock import AsyncMock, MagicMock from music_assistant_models.api import MassEvent from music_assistant_models.enums import EventType -from music_assistant_models.media_items import Album, Artist, Playlist, Radio, Track +from music_assistant_models.media_items import ( + Album, + Artist, + Audiobook, + Playlist, + Podcast, + Radio, + Track, +) from music_assistant_models.player import Player from music_assistant_models.player_queue import PlayerQueue from syrupy import SnapshotAssertion @@ -62,6 +70,10 @@ async def setup_integration_from_fixtures( music.get_playlist_tracks = AsyncMock(return_value=library_playlist_tracks) library_radios = create_library_radios_from_fixture() music.get_library_radios = AsyncMock(return_value=library_radios) + library_audiobooks = create_library_audiobooks_from_fixture() + music.get_library_audiobooks = AsyncMock(return_value=library_audiobooks) + library_podcasts = create_library_podcasts_from_fixture() + music.get_library_podcasts = AsyncMock(return_value=library_podcasts) music.get_item_by_uri = AsyncMock() config_entry.add_to_hass(hass) @@ -132,6 +144,18 @@ def create_library_radios_from_fixture() -> list[Radio]: return [Radio.from_dict(radio_data) for radio_data in fixture_data] +def create_library_audiobooks_from_fixture() -> list[Audiobook]: + """Create MA Audiobooks from fixture.""" + fixture_data = load_and_parse_fixture("library_audiobooks") + return [Audiobook.from_dict(radio_data) for radio_data in fixture_data] + + +def create_library_podcasts_from_fixture() -> list[Podcast]: + """Create MA Podcasts from fixture.""" + fixture_data = load_and_parse_fixture("library_podcasts") + return [Podcast.from_dict(radio_data) for radio_data in fixture_data] + + async def trigger_subscription_callback( hass: HomeAssistant, client: MagicMock, diff --git a/tests/components/music_assistant/fixtures/library_audiobooks.json b/tests/components/music_assistant/fixtures/library_audiobooks.json new file mode 100644 index 00000000000..1994ee68e14 --- /dev/null +++ b/tests/components/music_assistant/fixtures/library_audiobooks.json @@ -0,0 +1,489 @@ +{ + "library_audiobooks": [ + { + "item_id": "1", + "provider": "library", + "name": "Test Audiobook", + "version": "", + "sort_name": "test audiobook", + "uri": "library://audiobook/1", + "external_ids": [], + "is_playable": true, + "media_type": "audiobook", + "provider_mappings": [ + { + "item_id": "test-audiobook.mp3", + "provider_domain": "filesystem_smb", + "provider_instance": "filesystem_smb--7Kf8QySu", + "available": true, + "audio_format": { + "content_type": "mp3", + "codec_type": "?", + "sample_rate": 48000, + "bit_depth": 16, + "channels": 1, + "output_format_str": "mp3", + "bit_rate": 90304 + }, + "url": null, + "details": "1738502411" + } + ], + "metadata": { + "description": "Cover (front)", + "review": null, + "explicit": null, + "images": [ + { + "type": "thumb", + "path": "test-audiobook.mp3", + "provider": "filesystem_smb--7Kf8QySu", + "remotely_accessible": false + } + ], + "genres": [], + "mood": null, + "style": null, + "copyright": null, + "lyrics": "", + "label": null, + "links": null, + "performers": null, + "preview": null, + "popularity": null, + "release_date": null, + "languages": null, + "chapters": [], + "last_refresh": null + }, + "favorite": false, + "position": null, + "publisher": null, + "authors": ["TestWriter"], + "narrators": [], + "duration": 9, + "fully_played": true, + "resume_position_ms": 9000 + }, + { + "item_id": "11", + "provider": "library", + "name": "Test Audiobook 0", + "version": "", + "sort_name": "test audiobook 0", + "uri": "library://audiobook/11", + "external_ids": [], + "is_playable": true, + "media_type": "audiobook", + "provider_mappings": [ + { + "item_id": "0", + "provider_domain": "test", + "provider_instance": "test", + "available": true, + "audio_format": { + "content_type": "?", + "codec_type": "?", + "sample_rate": 44100, + "bit_depth": 16, + "channels": 2, + "output_format_str": "?", + "bit_rate": 0 + }, + "url": null, + "details": null + } + ], + "metadata": { + "description": "This is a description for Test Audiobook", + "review": null, + "explicit": null, + "images": [ + { + "type": "thumb", + "path": "logo.png", + "provider": "builtin", + "remotely_accessible": false + } + ], + "genres": null, + "mood": null, + "style": null, + "copyright": null, + "lyrics": null, + "label": null, + "links": null, + "performers": null, + "preview": null, + "popularity": null, + "release_date": null, + "languages": null, + "chapters": [ + { + "position": 1, + "name": "Chapter 1", + "start": 10.0, + "end": 20.0 + }, + { + "position": 2, + "name": "Chapter 2", + "start": 20.0, + "end": 40.0 + }, + { + "position": 2, + "name": "Chapter 3", + "start": 40.0, + "end": null + } + ], + "last_refresh": null + }, + "favorite": false, + "position": null, + "publisher": "Test Publisher", + "authors": ["AudioBook Author"], + "narrators": ["AudioBook Narrator"], + "duration": 60, + "fully_played": null, + "resume_position_ms": null + }, + { + "item_id": "12", + "provider": "library", + "name": "Test Audiobook 1", + "version": "", + "sort_name": "test audiobook 1", + "uri": "library://audiobook/12", + "external_ids": [], + "is_playable": true, + "media_type": "audiobook", + "provider_mappings": [ + { + "item_id": "1", + "provider_domain": "test", + "provider_instance": "test", + "available": true, + "audio_format": { + "content_type": "?", + "codec_type": "?", + "sample_rate": 44100, + "bit_depth": 16, + "channels": 2, + "output_format_str": "?", + "bit_rate": 0 + }, + "url": null, + "details": null + } + ], + "metadata": { + "description": "This is a description for Test Audiobook", + "review": null, + "explicit": null, + "images": [ + { + "type": "thumb", + "path": "logo.png", + "provider": "builtin", + "remotely_accessible": false + } + ], + "genres": null, + "mood": null, + "style": null, + "copyright": null, + "lyrics": null, + "label": null, + "links": null, + "performers": null, + "preview": null, + "popularity": null, + "release_date": null, + "languages": null, + "chapters": [ + { + "position": 1, + "name": "Chapter 1", + "start": 10.0, + "end": 20.0 + }, + { + "position": 2, + "name": "Chapter 2", + "start": 20.0, + "end": 40.0 + }, + { + "position": 2, + "name": "Chapter 3", + "start": 40.0, + "end": null + } + ], + "last_refresh": null + }, + "favorite": false, + "position": null, + "publisher": "Test Publisher", + "authors": ["AudioBook Author"], + "narrators": ["AudioBook Narrator"], + "duration": 60, + "fully_played": null, + "resume_position_ms": null + }, + { + "item_id": "13", + "provider": "library", + "name": "Test Audiobook 2", + "version": "", + "sort_name": "test audiobook 2", + "uri": "library://audiobook/13", + "external_ids": [], + "is_playable": true, + "media_type": "audiobook", + "provider_mappings": [ + { + "item_id": "2", + "provider_domain": "test", + "provider_instance": "test", + "available": true, + "audio_format": { + "content_type": "?", + "codec_type": "?", + "sample_rate": 44100, + "bit_depth": 16, + "channels": 2, + "output_format_str": "?", + "bit_rate": 0 + }, + "url": null, + "details": null + } + ], + "metadata": { + "description": "This is a description for Test Audiobook", + "review": null, + "explicit": null, + "images": [ + { + "type": "thumb", + "path": "logo.png", + "provider": "builtin", + "remotely_accessible": false + } + ], + "genres": null, + "mood": null, + "style": null, + "copyright": null, + "lyrics": null, + "label": null, + "links": null, + "performers": null, + "preview": null, + "popularity": null, + "release_date": null, + "languages": null, + "chapters": [ + { + "position": 1, + "name": "Chapter 1", + "start": 10.0, + "end": 20.0 + }, + { + "position": 2, + "name": "Chapter 2", + "start": 20.0, + "end": 40.0 + }, + { + "position": 2, + "name": "Chapter 3", + "start": 40.0, + "end": null + } + ], + "last_refresh": null + }, + "favorite": false, + "position": null, + "publisher": "Test Publisher", + "authors": ["AudioBook Author"], + "narrators": ["AudioBook Narrator"], + "duration": 60, + "fully_played": null, + "resume_position_ms": null + }, + { + "item_id": "14", + "provider": "library", + "name": "Test Audiobook 3", + "version": "", + "sort_name": "test audiobook 3", + "uri": "library://audiobook/14", + "external_ids": [], + "is_playable": true, + "media_type": "audiobook", + "provider_mappings": [ + { + "item_id": "3", + "provider_domain": "test", + "provider_instance": "test", + "available": true, + "audio_format": { + "content_type": "?", + "codec_type": "?", + "sample_rate": 44100, + "bit_depth": 16, + "channels": 2, + "output_format_str": "?", + "bit_rate": 0 + }, + "url": null, + "details": null + } + ], + "metadata": { + "description": "This is a description for Test Audiobook", + "review": null, + "explicit": null, + "images": [ + { + "type": "thumb", + "path": "logo.png", + "provider": "builtin", + "remotely_accessible": false + } + ], + "genres": null, + "mood": null, + "style": null, + "copyright": null, + "lyrics": null, + "label": null, + "links": null, + "performers": null, + "preview": null, + "popularity": null, + "release_date": null, + "languages": null, + "chapters": [ + { + "position": 1, + "name": "Chapter 1", + "start": 10.0, + "end": 20.0 + }, + { + "position": 2, + "name": "Chapter 2", + "start": 20.0, + "end": 40.0 + }, + { + "position": 2, + "name": "Chapter 3", + "start": 40.0, + "end": null + } + ], + "last_refresh": null + }, + "favorite": false, + "position": null, + "publisher": "Test Publisher", + "authors": ["AudioBook Author"], + "narrators": ["AudioBook Narrator"], + "duration": 60, + "fully_played": null, + "resume_position_ms": null + }, + { + "item_id": "15", + "provider": "library", + "name": "Test Audiobook 4", + "version": "", + "sort_name": "test audiobook 4", + "uri": "library://audiobook/15", + "external_ids": [], + "is_playable": true, + "media_type": "audiobook", + "provider_mappings": [ + { + "item_id": "4", + "provider_domain": "test", + "provider_instance": "test", + "available": true, + "audio_format": { + "content_type": "?", + "codec_type": "?", + "sample_rate": 44100, + "bit_depth": 16, + "channels": 2, + "output_format_str": "?", + "bit_rate": 0 + }, + "url": null, + "details": null + } + ], + "metadata": { + "description": "This is a description for Test Audiobook", + "review": null, + "explicit": null, + "images": [ + { + "type": "thumb", + "path": "logo.png", + "provider": "builtin", + "remotely_accessible": false + } + ], + "genres": null, + "mood": null, + "style": null, + "copyright": null, + "lyrics": null, + "label": null, + "links": null, + "performers": null, + "preview": null, + "popularity": null, + "release_date": null, + "languages": null, + "chapters": [ + { + "position": 1, + "name": "Chapter 1", + "start": 10.0, + "end": 20.0 + }, + { + "position": 2, + "name": "Chapter 2", + "start": 20.0, + "end": 40.0 + }, + { + "position": 2, + "name": "Chapter 3", + "start": 40.0, + "end": null + } + ], + "last_refresh": null + }, + "favorite": false, + "position": null, + "publisher": "Test Publisher", + "authors": ["AudioBook Author"], + "narrators": ["AudioBook Narrator"], + "duration": 60, + "fully_played": null, + "resume_position_ms": null + } + ] +} diff --git a/tests/components/music_assistant/fixtures/library_podcasts.json b/tests/components/music_assistant/fixtures/library_podcasts.json new file mode 100644 index 00000000000..2c6a9c62f65 --- /dev/null +++ b/tests/components/music_assistant/fixtures/library_podcasts.json @@ -0,0 +1,309 @@ +{ + "library_podcasts": [ + { + "item_id": "6", + "provider": "library", + "name": "Test Podcast 0", + "version": "", + "sort_name": "test podcast 0", + "uri": "library://podcast/6", + "external_ids": [], + "is_playable": true, + "media_type": "podcast", + "provider_mappings": [ + { + "item_id": "0", + "provider_domain": "test", + "provider_instance": "test", + "available": true, + "audio_format": { + "content_type": "?", + "codec_type": "?", + "sample_rate": 44100, + "bit_depth": 16, + "channels": 2, + "output_format_str": "?", + "bit_rate": 0 + }, + "url": null, + "details": null + } + ], + "metadata": { + "description": null, + "review": null, + "explicit": null, + "images": [ + { + "type": "thumb", + "path": "logo.png", + "provider": "builtin", + "remotely_accessible": false + } + ], + "genres": null, + "mood": null, + "style": null, + "copyright": null, + "lyrics": null, + "label": null, + "links": null, + "performers": null, + "preview": null, + "popularity": null, + "release_date": null, + "languages": null, + "chapters": null, + "last_refresh": null + }, + "favorite": false, + "position": null, + "publisher": "Test Publisher", + "total_episodes": null + }, + { + "item_id": "7", + "provider": "library", + "name": "Test Podcast 1", + "version": "", + "sort_name": "test podcast 1", + "uri": "library://podcast/7", + "external_ids": [], + "is_playable": true, + "media_type": "podcast", + "provider_mappings": [ + { + "item_id": "1", + "provider_domain": "test", + "provider_instance": "test", + "available": true, + "audio_format": { + "content_type": "?", + "codec_type": "?", + "sample_rate": 44100, + "bit_depth": 16, + "channels": 2, + "output_format_str": "?", + "bit_rate": 0 + }, + "url": null, + "details": null + } + ], + "metadata": { + "description": null, + "review": null, + "explicit": null, + "images": [ + { + "type": "thumb", + "path": "logo.png", + "provider": "builtin", + "remotely_accessible": false + } + ], + "genres": null, + "mood": null, + "style": null, + "copyright": null, + "lyrics": null, + "label": null, + "links": null, + "performers": null, + "preview": null, + "popularity": null, + "release_date": null, + "languages": null, + "chapters": null, + "last_refresh": null + }, + "favorite": false, + "position": null, + "publisher": "Test Publisher", + "total_episodes": null + }, + { + "item_id": "8", + "provider": "library", + "name": "Test Podcast 2", + "version": "", + "sort_name": "test podcast 2", + "uri": "library://podcast/8", + "external_ids": [], + "is_playable": true, + "media_type": "podcast", + "provider_mappings": [ + { + "item_id": "2", + "provider_domain": "test", + "provider_instance": "test", + "available": true, + "audio_format": { + "content_type": "?", + "codec_type": "?", + "sample_rate": 44100, + "bit_depth": 16, + "channels": 2, + "output_format_str": "?", + "bit_rate": 0 + }, + "url": null, + "details": null + } + ], + "metadata": { + "description": null, + "review": null, + "explicit": null, + "images": [ + { + "type": "thumb", + "path": "logo.png", + "provider": "builtin", + "remotely_accessible": false + } + ], + "genres": null, + "mood": null, + "style": null, + "copyright": null, + "lyrics": null, + "label": null, + "links": null, + "performers": null, + "preview": null, + "popularity": null, + "release_date": null, + "languages": null, + "chapters": null, + "last_refresh": null + }, + "favorite": false, + "position": null, + "publisher": "Test Publisher", + "total_episodes": null + }, + { + "item_id": "9", + "provider": "library", + "name": "Test Podcast 3", + "version": "", + "sort_name": "test podcast 3", + "uri": "library://podcast/9", + "external_ids": [], + "is_playable": true, + "media_type": "podcast", + "provider_mappings": [ + { + "item_id": "3", + "provider_domain": "test", + "provider_instance": "test", + "available": true, + "audio_format": { + "content_type": "?", + "codec_type": "?", + "sample_rate": 44100, + "bit_depth": 16, + "channels": 2, + "output_format_str": "?", + "bit_rate": 0 + }, + "url": null, + "details": null + } + ], + "metadata": { + "description": null, + "review": null, + "explicit": null, + "images": [ + { + "type": "thumb", + "path": "logo.png", + "provider": "builtin", + "remotely_accessible": false + } + ], + "genres": null, + "mood": null, + "style": null, + "copyright": null, + "lyrics": null, + "label": null, + "links": null, + "performers": null, + "preview": null, + "popularity": null, + "release_date": null, + "languages": null, + "chapters": null, + "last_refresh": null + }, + "favorite": false, + "position": null, + "publisher": "Test Publisher", + "total_episodes": null + }, + { + "item_id": "10", + "provider": "library", + "name": "Test Podcast 4", + "version": "", + "sort_name": "test podcast 4", + "uri": "library://podcast/10", + "external_ids": [], + "is_playable": true, + "media_type": "podcast", + "provider_mappings": [ + { + "item_id": "4", + "provider_domain": "test", + "provider_instance": "test", + "available": true, + "audio_format": { + "content_type": "?", + "codec_type": "?", + "sample_rate": 44100, + "bit_depth": 16, + "channels": 2, + "output_format_str": "?", + "bit_rate": 0 + }, + "url": null, + "details": null + } + ], + "metadata": { + "description": null, + "review": null, + "explicit": null, + "images": [ + { + "type": "thumb", + "path": "logo.png", + "provider": "builtin", + "remotely_accessible": false + } + ], + "genres": null, + "mood": null, + "style": null, + "copyright": null, + "lyrics": null, + "label": null, + "links": null, + "performers": null, + "preview": null, + "popularity": null, + "release_date": null, + "languages": null, + "chapters": null, + "last_refresh": null + }, + "favorite": false, + "position": null, + "publisher": "Test Publisher", + "total_episodes": null + } + ] +} diff --git a/tests/components/music_assistant/snapshots/test_actions.ambr b/tests/components/music_assistant/snapshots/test_actions.ambr index 6c30ffc512c..32c8776c953 100644 --- a/tests/components/music_assistant/snapshots/test_actions.ambr +++ b/tests/components/music_assistant/snapshots/test_actions.ambr @@ -1,5 +1,195 @@ # serializer version: 1 -# name: test_get_library_action +# name: test_get_library_action[album] + dict({ + 'items': list([ + dict({ + 'artists': list([ + dict({ + 'image': None, + 'media_type': , + 'name': 'A Space Love Adventure', + 'uri': 'library://artist/289', + 'version': '', + }), + ]), + 'image': None, + 'media_type': , + 'name': 'Synth Punk EP', + 'uri': 'library://album/396', + 'version': '', + }), + dict({ + 'artists': list([ + dict({ + 'image': None, + 'media_type': , + 'name': 'Various Artists', + 'uri': 'library://artist/96', + 'version': '', + }), + ]), + 'image': None, + 'media_type': , + 'name': 'Synthwave (The 80S Revival)', + 'uri': 'library://album/95', + 'version': 'The 80S Revival', + }), + ]), + 'limit': 25, + 'media_type': , + 'offset': 0, + 'order_by': 'name', + }) +# --- +# name: test_get_library_action[artist] + dict({ + 'items': list([ + dict({ + 'image': None, + 'media_type': , + 'name': 'W O L F C L U B', + 'uri': 'library://artist/127', + 'version': '', + }), + ]), + 'limit': 25, + 'media_type': , + 'offset': 0, + 'order_by': 'name', + }) +# --- +# name: test_get_library_action[audiobook] + dict({ + 'items': list([ + dict({ + 'image': None, + 'media_type': , + 'name': 'Test Audiobook', + 'uri': 'library://audiobook/1', + 'version': '', + }), + dict({ + 'image': None, + 'media_type': , + 'name': 'Test Audiobook 0', + 'uri': 'library://audiobook/11', + 'version': '', + }), + dict({ + 'image': None, + 'media_type': , + 'name': 'Test Audiobook 1', + 'uri': 'library://audiobook/12', + 'version': '', + }), + dict({ + 'image': None, + 'media_type': , + 'name': 'Test Audiobook 2', + 'uri': 'library://audiobook/13', + 'version': '', + }), + dict({ + 'image': None, + 'media_type': , + 'name': 'Test Audiobook 3', + 'uri': 'library://audiobook/14', + 'version': '', + }), + dict({ + 'image': None, + 'media_type': , + 'name': 'Test Audiobook 4', + 'uri': 'library://audiobook/15', + 'version': '', + }), + ]), + 'limit': 25, + 'media_type': , + 'offset': 0, + 'order_by': 'name', + }) +# --- +# name: test_get_library_action[playlist] + dict({ + 'items': list([ + dict({ + 'image': None, + 'media_type': , + 'name': '1970s Rock Hits', + 'uri': 'library://playlist/40', + 'version': '', + }), + ]), + 'limit': 25, + 'media_type': , + 'offset': 0, + 'order_by': 'name', + }) +# --- +# name: test_get_library_action[podcast] + dict({ + 'items': list([ + dict({ + 'image': None, + 'media_type': , + 'name': 'Test Podcast 0', + 'uri': 'library://podcast/6', + 'version': '', + }), + dict({ + 'image': None, + 'media_type': , + 'name': 'Test Podcast 1', + 'uri': 'library://podcast/7', + 'version': '', + }), + dict({ + 'image': None, + 'media_type': , + 'name': 'Test Podcast 2', + 'uri': 'library://podcast/8', + 'version': '', + }), + dict({ + 'image': None, + 'media_type': , + 'name': 'Test Podcast 3', + 'uri': 'library://podcast/9', + 'version': '', + }), + dict({ + 'image': None, + 'media_type': , + 'name': 'Test Podcast 4', + 'uri': 'library://podcast/10', + 'version': '', + }), + ]), + 'limit': 25, + 'media_type': , + 'offset': 0, + 'order_by': 'name', + }) +# --- +# name: test_get_library_action[radio] + dict({ + 'items': list([ + dict({ + 'image': None, + 'media_type': , + 'name': 'fm4 | ORF | HQ', + 'uri': 'library://radio/1', + 'version': '', + }), + ]), + 'limit': 25, + 'media_type': , + 'offset': 0, + 'order_by': 'name', + }) +# --- +# name: test_get_library_action[track] dict({ 'items': list([ dict({ @@ -192,8 +382,12 @@ ]), 'artists': list([ ]), + 'audiobooks': list([ + ]), 'playlists': list([ ]), + 'podcasts': list([ + ]), 'radio': list([ ]), 'tracks': list([ diff --git a/tests/components/music_assistant/test_actions.py b/tests/components/music_assistant/test_actions.py index 4d3917091c1..ba8b1acdeac 100644 --- a/tests/components/music_assistant/test_actions.py +++ b/tests/components/music_assistant/test_actions.py @@ -3,6 +3,7 @@ from unittest.mock import AsyncMock, MagicMock from music_assistant_models.media_items import SearchResults +import pytest from syrupy import SnapshotAssertion from homeassistant.components.music_assistant.actions import ( @@ -47,9 +48,22 @@ async def test_search_action( assert response == snapshot +@pytest.mark.parametrize( + "media_type", + [ + "artist", + "album", + "track", + "playlist", + "audiobook", + "podcast", + "radio", + ], +) async def test_get_library_action( hass: HomeAssistant, music_assistant_client: MagicMock, + media_type: str, snapshot: SnapshotAssertion, ) -> None: """Test music assistant get_library action.""" @@ -60,7 +74,7 @@ async def test_get_library_action( { ATTR_CONFIG_ENTRY_ID: entry.entry_id, ATTR_FAVORITE: False, - ATTR_MEDIA_TYPE: "track", + ATTR_MEDIA_TYPE: media_type, }, blocking=True, return_response=True, From d157919da2111f8a0fa5efe12a0220028cbe4acd Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 28 Feb 2025 14:19:18 +0100 Subject: [PATCH 1933/3148] Bump actions/attest-build-provenance from 2.2.1 to 2.2.2 (#139489) Bumps [actions/attest-build-provenance](https://github.com/actions/attest-build-provenance) from 2.2.1 to 2.2.2. - [Release notes](https://github.com/actions/attest-build-provenance/releases) - [Changelog](https://github.com/actions/attest-build-provenance/blob/main/RELEASE.md) - [Commits](https://github.com/actions/attest-build-provenance/compare/f9eaf234fc1c2e333c1eca18177db0f44fa6ba52...bd77c077858b8d561b7a36cbe48ef4cc642ca39d) --- updated-dependencies: - dependency-name: actions/attest-build-provenance dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/builder.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index e730f03e1b4..f3bdd0084af 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -531,7 +531,7 @@ jobs: - name: Generate artifact attestation if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true' - uses: actions/attest-build-provenance@f9eaf234fc1c2e333c1eca18177db0f44fa6ba52 # v2.2.1 + uses: actions/attest-build-provenance@bd77c077858b8d561b7a36cbe48ef4cc642ca39d # v2.2.2 with: subject-name: ${{ env.HASSFEST_IMAGE_NAME }} subject-digest: ${{ steps.push.outputs.digest }} From 030a1460de46fd60f014a36723c7773c2c6066fd Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 28 Feb 2025 14:20:39 +0100 Subject: [PATCH 1934/3148] Log a warning when replacing existing config entry with same unique id (#130567) * Log a warning when replacing existing config entry with same unique id * Exclude mobile_app * Ignore custom integrations * Apply suggestions from code review * Apply suggestions from code review * Update config_entries.py * Fix handler * Adjust and add tests * Apply suggestions from code review * Apply suggestions from code review * Update comment * Update config_entries.py * Apply suggestions from code review --- homeassistant/config_entries.py | 17 ++++++++++ tests/test_config_entries.py | 60 +++++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 2639c429e71..98d9e3c760c 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -1628,6 +1628,23 @@ class ConfigEntriesFlowManager( result["handler"], flow.unique_id ) + if existing_entry is not None and flow.handler != "mobile_app": + # This causes the old entry to be removed and replaced, when the flow + # should instead be aborted. + # In case of manual flows, integrations should implement options, reauth, + # reconfigure to allow the user to change settings. + # In case of non user visible flows, the integration should optionally + # update the existing entry before aborting. + # see https://developers.home-assistant.io/blog/2025/01/16/config-flow-unique-id/ + report_usage( + "creates a config entry when another entry with the same unique ID " + "exists", + core_behavior=ReportBehavior.LOG, + core_integration_behavior=ReportBehavior.LOG, + custom_integration_behavior=ReportBehavior.LOG, + integration_domain=flow.handler, + ) + # Unload the entry before setting up the new one. if existing_entry is not None and existing_entry.state.recoverable: await self.config_entries.async_unload(existing_entry.entry_id) diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index 7066417bfee..66aa29d95d1 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -8899,3 +8899,63 @@ async def test_add_description_placeholder_automatically_not_overwrites( result = await hass.config_entries.flow.async_configure(flows[0]["flow_id"], None) assert result["type"] == FlowResultType.FORM assert result["description_placeholders"] == {"name": "Custom title"} + + +@pytest.mark.parametrize( + ("domain", "expected_log"), + [ + ("some_integration", True), + ("mobile_app", False), + ], +) +async def test_create_entry_existing_unique_id( + hass: HomeAssistant, + domain: str, + expected_log: bool, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test to highlight unexpected behavior on create_entry.""" + entry = MockConfigEntry( + title="From config flow", + domain=domain, + entry_id="01J915Q6T9F6G5V0QJX6HBC94T", + data={"host": "any", "port": 123}, + unique_id="mock-unique-id", + ) + entry.add_to_hass(hass) + + assert len(hass.config_entries.async_entries(domain)) == 1 + + mock_setup_entry = AsyncMock(return_value=True) + + mock_integration(hass, MockModule(domain, async_setup_entry=mock_setup_entry)) + mock_platform(hass, f"{domain}.config_flow", None) + + class TestFlow(config_entries.ConfigFlow): + """Test flow.""" + + VERSION = 1 + + async def async_step_user(self, user_input=None): + """Test user step.""" + await self.async_set_unique_id("mock-unique-id") + return self.async_create_entry(title="mock-title", data={}) + + with ( + mock_config_flow(domain, TestFlow), + patch.object(frame, "_REPORTED_INTEGRATIONS", set()), + ): + result = await hass.config_entries.flow.async_init( + domain, context={"source": config_entries.SOURCE_USER} + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.CREATE_ENTRY + + assert len(hass.config_entries.async_entries(domain)) == 1 + + log_text = ( + f"Detected that integration '{domain}' creates a config entry " + "when another entry with the same unique ID exists. Please " + "create a bug report at https:" + ) + assert (log_text in caplog.text) == expected_log From 228a4eb39129ba39efaa328bf6f4a77560f78baf Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 28 Feb 2025 14:25:35 +0100 Subject: [PATCH 1935/3148] Improve error handling in CoreBackupReaderWriter (#139508) --- homeassistant/components/backup/manager.py | 27 +++++++++++-- tests/components/backup/test_manager.py | 46 +++++++++++++++++----- 2 files changed, 61 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index 317de85b823..c8b515e3aee 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -14,6 +14,7 @@ from itertools import chain import json from pathlib import Path, PurePath import shutil +import sys import tarfile import time from typing import IO, TYPE_CHECKING, Any, Protocol, TypedDict, cast @@ -308,6 +309,12 @@ class DecryptOnDowloadNotSupported(BackupManagerError): _message = "On-the-fly decryption is not supported for this backup." +class BackupManagerExceptionGroup(BackupManagerError, ExceptionGroup): + """Raised when multiple exceptions occur.""" + + error_code = "multiple_errors" + + class BackupManager: """Define the format that backup managers can have.""" @@ -1605,10 +1612,24 @@ class CoreBackupReaderWriter(BackupReaderWriter): ) finally: # Inform integrations the backup is done + # If there's an unhandled exception, we keep it so we can rethrow it in case + # the post backup actions also fail. + unhandled_exc = sys.exception() try: - await manager.async_post_backup_actions() - except BackupManagerError as err: - raise BackupReaderWriterError(str(err)) from err + try: + await manager.async_post_backup_actions() + except BackupManagerError as err: + raise BackupReaderWriterError(str(err)) from err + except Exception as err: + if not unhandled_exc: + raise + # If there's an unhandled exception, we wrap both that and the exception + # from the post backup actions in an ExceptionGroup so the caller is + # aware of both exceptions. + raise BackupManagerExceptionGroup( + f"Multiple errors when creating backup: {unhandled_exc}, {err}", + [unhandled_exc, err], + ) from None def _mkdir_and_generate_backup_contents( self, diff --git a/tests/components/backup/test_manager.py b/tests/components/backup/test_manager.py index 6e626e63748..e4762f35327 100644 --- a/tests/components/backup/test_manager.py +++ b/tests/components/backup/test_manager.py @@ -8,6 +8,7 @@ from dataclasses import replace from io import StringIO import json from pathlib import Path +import re import tarfile from typing import Any from unittest.mock import ( @@ -35,6 +36,7 @@ from homeassistant.components.backup.agent import BackupAgentError from homeassistant.components.backup.const import DATA_MANAGER from homeassistant.components.backup.manager import ( BackupManagerError, + BackupManagerExceptionGroup, BackupManagerState, CreateBackupStage, CreateBackupState, @@ -1646,34 +1648,60 @@ async def test_exception_platform_pre(hass: HomeAssistant) -> None: assert str(err.value) == "Error during pre-backup: Test exception" +@pytest.mark.parametrize( + ("unhandled_error", "expected_exception", "expected_msg"), + [ + (None, BackupManagerError, "Error during post-backup: Test exception"), + ( + HomeAssistantError("Boom"), + BackupManagerExceptionGroup, + ( + "Multiple errors when creating backup: Error during pre-backup: Boom, " + "Error during post-backup: Test exception (2 sub-exceptions)" + ), + ), + ( + Exception("Boom"), + BackupManagerExceptionGroup, + ( + "Multiple errors when creating backup: Error during pre-backup: Boom, " + "Error during post-backup: Test exception (2 sub-exceptions)" + ), + ), + ], +) @pytest.mark.usefixtures("mock_backup_generation") -async def test_exception_platform_post(hass: HomeAssistant) -> None: +async def test_exception_platform_post( + hass: HomeAssistant, + unhandled_error: Exception | None, + expected_exception: type[Exception], + expected_msg: str, +) -> None: """Test exception in post step.""" - async def _mock_step(hass: HomeAssistant) -> None: - raise HomeAssistantError("Test exception") - remote_agent = mock_backup_agent("remote") await setup_backup_platform( hass, domain="test", platform=Mock( - async_pre_backup=AsyncMock(), - async_post_backup=_mock_step, + # We let the pre_backup fail to test that unhandled errors are not discarded + # when post backup fails + async_pre_backup=AsyncMock(side_effect=unhandled_error), + async_post_backup=AsyncMock( + side_effect=HomeAssistantError("Test exception") + ), async_get_backup_agents=AsyncMock(return_value=[remote_agent]), ), ) await setup_backup_integration(hass) - with pytest.raises(BackupManagerError) as err: + with pytest.raises(expected_exception, match=re.escape(expected_msg)): await hass.services.async_call( DOMAIN, "create", blocking=True, ) - assert str(err.value) == "Error during post-backup: Test exception" - @pytest.mark.parametrize( ( From ac15d9b3d400fe7b581f52d9e642763e4c70cb0b Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Fri, 28 Feb 2025 23:26:39 +1000 Subject: [PATCH 1936/3148] Fix shift state in Teslemetry (#139505) * Fix shift state * Different fix --- homeassistant/components/teslemetry/sensor.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/teslemetry/sensor.py b/homeassistant/components/teslemetry/sensor.py index 70315e92da0..56c8830d736 100644 --- a/homeassistant/components/teslemetry/sensor.py +++ b/homeassistant/components/teslemetry/sensor.py @@ -8,6 +8,7 @@ from datetime import datetime, timedelta from propcache.api import cached_property from teslemetry_stream import Signal +from teslemetry_stream.const import ShiftState from homeassistant.components.sensor import ( RestoreSensor, @@ -69,7 +70,7 @@ class TeslemetryVehicleSensorEntityDescription(SensorEntityDescription): polling_value_fn: Callable[[StateType], StateType] = lambda x: x polling_available_fn: Callable[[StateType], bool] = lambda x: x is not None streaming_key: Signal | None = None - streaming_value_fn: Callable[[StateType], StateType] = lambda x: x + streaming_value_fn: Callable[[str | int | float], StateType] = lambda x: x streaming_firmware: str = "2024.26" @@ -212,7 +213,7 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryVehicleSensorEntityDescription, ...] = ( polling_available_fn=lambda x: True, polling_value_fn=lambda x: SHIFT_STATES.get(str(x), "p"), streaming_key=Signal.GEAR, - streaming_value_fn=lambda x: SHIFT_STATES.get(str(x)), + streaming_value_fn=lambda x: str(ShiftState.get(x, "P")).lower(), options=list(SHIFT_STATES.values()), device_class=SensorDeviceClass.ENUM, entity_registry_enabled_default=False, From c2a773641778088fe97cb52ce2f072b8fd90eff4 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Fri, 28 Feb 2025 14:30:47 +0100 Subject: [PATCH 1937/3148] Don't split wheels builder anymore (#139522) --- .github/workflows/wheels.yml | 40 ++---------------------------------- 1 file changed, 2 insertions(+), 38 deletions(-) diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 7c02c8d97cd..4b1628c57bb 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -218,15 +218,7 @@ jobs: sed -i "/uv/d" requirements.txt sed -i "/uv/d" requirements_diff.txt - - name: Split requirements all - run: | - # 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). - - split -l $(expr $(expr $(cat requirements_all.txt | wc -l) + 1) / 3) requirements_all_wheels_${{ matrix.arch }}.txt requirements_all.txt - - - name: Build wheels (part 1) + - name: Build wheels uses: home-assistant/wheels@2024.11.0 with: abi: ${{ matrix.abi }} @@ -238,32 +230,4 @@ jobs: skip-binary: aiohttp;charset-normalizer;grpcio;multidict;SQLAlchemy;propcache;protobuf;pymicro-vad;yarl constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" - requirements: "requirements_all.txtaa" - - - name: Build wheels (part 2) - uses: home-assistant/wheels@2024.11.0 - with: - abi: ${{ matrix.abi }} - tag: musllinux_1_2 - arch: ${{ matrix.arch }} - wheels-key: ${{ secrets.WHEELS_KEY }} - env-file: true - apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev;nasm;zlib-ng-dev" - skip-binary: aiohttp;charset-normalizer;grpcio;multidict;SQLAlchemy;propcache;protobuf;pymicro-vad;yarl - constraints: "homeassistant/package_constraints.txt" - requirements-diff: "requirements_diff.txt" - requirements: "requirements_all.txtab" - - - name: Build wheels (part 3) - uses: home-assistant/wheels@2024.11.0 - with: - abi: ${{ matrix.abi }} - tag: musllinux_1_2 - arch: ${{ matrix.arch }} - wheels-key: ${{ secrets.WHEELS_KEY }} - env-file: true - apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev;nasm;zlib-ng-dev" - skip-binary: aiohttp;charset-normalizer;grpcio;multidict;SQLAlchemy;propcache;protobuf;pymicro-vad;yarl - constraints: "homeassistant/package_constraints.txt" - requirements-diff: "requirements_diff.txt" - requirements: "requirements_all.txtac" + requirements: "requirements_all.txt" From 40d2d6df2cbeb0561f9f55104a6d361bb211053b Mon Sep 17 00:00:00 2001 From: Jeef Date: Fri, 28 Feb 2025 06:32:52 -0700 Subject: [PATCH 1938/3148] Bump weatherflow4py to 1.3.1 (#135529) * version bump of dep * update requirements --- homeassistant/components/weatherflow_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/weatherflow_cloud/manifest.json b/homeassistant/components/weatherflow_cloud/manifest.json index 98c98cfbac7..9ffa457a355 100644 --- a/homeassistant/components/weatherflow_cloud/manifest.json +++ b/homeassistant/components/weatherflow_cloud/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/weatherflow_cloud", "iot_class": "cloud_polling", "loggers": ["weatherflow4py"], - "requirements": ["weatherflow4py==1.0.6"] + "requirements": ["weatherflow4py==1.3.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 00509109413..9f70f98ecf0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3046,7 +3046,7 @@ waterfurnace==1.1.0 watergate-local-api==2024.4.1 # homeassistant.components.weatherflow_cloud -weatherflow4py==1.0.6 +weatherflow4py==1.3.1 # homeassistant.components.cisco_webex_teams webexpythonsdk==2.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 609639b0735..5bf3fde31e1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2453,7 +2453,7 @@ watchdog==6.0.0 watergate-local-api==2024.4.1 # homeassistant.components.weatherflow_cloud -weatherflow4py==1.0.6 +weatherflow4py==1.3.1 # homeassistant.components.nasweb webio-api==0.1.11 From 3cd7f502165ec10db0fd2acb01d3fc0f555210e9 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 28 Feb 2025 15:47:51 +0100 Subject: [PATCH 1939/3148] Bump yt-dlp to 2025.02.19 (#139526) --- homeassistant/components/media_extractor/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/media_extractor/manifest.json b/homeassistant/components/media_extractor/manifest.json index f0f8ee03ad0..575c0fa878d 100644 --- a/homeassistant/components/media_extractor/manifest.json +++ b/homeassistant/components/media_extractor/manifest.json @@ -8,6 +8,6 @@ "iot_class": "calculated", "loggers": ["yt_dlp"], "quality_scale": "internal", - "requirements": ["yt-dlp[default]==2025.01.26"], + "requirements": ["yt-dlp[default]==2025.02.19"], "single_config_entry": true } diff --git a/requirements_all.txt b/requirements_all.txt index 9f70f98ecf0..18d94649d0b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3131,7 +3131,7 @@ youless-api==2.2.0 youtubeaio==1.1.5 # homeassistant.components.media_extractor -yt-dlp[default]==2025.01.26 +yt-dlp[default]==2025.02.19 # homeassistant.components.zabbix zabbix-utils==2.0.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5bf3fde31e1..98af884569b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2526,7 +2526,7 @@ youless-api==2.2.0 youtubeaio==1.1.5 # homeassistant.components.media_extractor -yt-dlp[default]==2025.01.26 +yt-dlp[default]==2025.02.19 # homeassistant.components.zamg zamg==0.3.6 From 1b27365c58c3e6607ace6ba5120239f4864752fe Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Fri, 28 Feb 2025 16:00:31 +0100 Subject: [PATCH 1940/3148] Suppress unsupported event 'EVT_USP_RpsPowerDeniedByPsuOverload' by bumping aiounifi to v83 (#139519) Bump aiounifi to v83 --- homeassistant/components/unifi/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/unifi/manifest.json b/homeassistant/components/unifi/manifest.json index f5ad99b72f7..dd255c57c13 100644 --- a/homeassistant/components/unifi/manifest.json +++ b/homeassistant/components/unifi/manifest.json @@ -7,7 +7,7 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["aiounifi"], - "requirements": ["aiounifi==82"], + "requirements": ["aiounifi==83"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/requirements_all.txt b/requirements_all.txt index 18d94649d0b..c8e7bbc806a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -404,7 +404,7 @@ aiotedee==0.2.20 aiotractive==0.6.0 # homeassistant.components.unifi -aiounifi==82 +aiounifi==83 # homeassistant.components.usb aiousbwatcher==1.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 98af884569b..e8e7c4a34f4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -386,7 +386,7 @@ aiotedee==0.2.20 aiotractive==0.6.0 # homeassistant.components.unifi -aiounifi==82 +aiounifi==83 # homeassistant.components.usb aiousbwatcher==1.1.1 From 0f0866cd5281df5d62b3d14fc55241093e0f128a Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Fri, 28 Feb 2025 16:03:47 +0100 Subject: [PATCH 1941/3148] Improve description of `mode` field in `geniushub.set_zone_mode` action (#139513) Improve description of `mode` field in 'geniushub.set_zone_mode' action As the three choices for the `mode` field show up as radio buttons in the UI the description does not need to repeat them. This improves translations by avoiding any over-translation of these values. --- homeassistant/components/geniushub/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/geniushub/strings.json b/homeassistant/components/geniushub/strings.json index 42d53c7fa00..79eee2c9a1b 100644 --- a/homeassistant/components/geniushub/strings.json +++ b/homeassistant/components/geniushub/strings.json @@ -45,7 +45,7 @@ }, "mode": { "name": "[%key:common::config_flow::data::mode%]", - "description": "One of: off, timer or footprint." + "description": "The zone's operating mode." } } }, From 5fa5d08b18f136ed0a2b57a2a3d95826c771233d Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Fri, 28 Feb 2025 16:16:23 +0100 Subject: [PATCH 1942/3148] Bump wheels to 2025.02.0 (#139525) --- .github/workflows/wheels.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 4b1628c57bb..c651ccbe715 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -159,7 +159,7 @@ jobs: sed -i "/uv/d" requirements_diff.txt - name: Build wheels - uses: home-assistant/wheels@2024.11.0 + uses: home-assistant/wheels@2025.02.0 with: abi: ${{ matrix.abi }} tag: musllinux_1_2 @@ -219,7 +219,7 @@ jobs: sed -i "/uv/d" requirements_diff.txt - name: Build wheels - uses: home-assistant/wheels@2024.11.0 + uses: home-assistant/wheels@2025.02.0 with: abi: ${{ matrix.abi }} tag: musllinux_1_2 From 0681652aec080208e0a76f22a9bb2e766332d680 Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Fri, 28 Feb 2025 16:18:57 +0100 Subject: [PATCH 1943/3148] Add diagnostics to onedrive (#139516) * Add diagnostics to onedrive * redact PII * add raw data --- .../components/onedrive/diagnostics.py | 33 +++++++++++++++++++ .../components/onedrive/quality_scale.yaml | 5 +-- .../onedrive/snapshots/test_diagnostics.ambr | 31 +++++++++++++++++ tests/components/onedrive/test_diagnostics.py | 26 +++++++++++++++ 4 files changed, 91 insertions(+), 4 deletions(-) create mode 100644 homeassistant/components/onedrive/diagnostics.py create mode 100644 tests/components/onedrive/snapshots/test_diagnostics.ambr create mode 100644 tests/components/onedrive/test_diagnostics.py diff --git a/homeassistant/components/onedrive/diagnostics.py b/homeassistant/components/onedrive/diagnostics.py new file mode 100644 index 00000000000..0e1ed94e155 --- /dev/null +++ b/homeassistant/components/onedrive/diagnostics.py @@ -0,0 +1,33 @@ +"""Diagnostics support for OneDrive.""" + +from __future__ import annotations + +from dataclasses import asdict +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN +from homeassistant.core import HomeAssistant + +from .coordinator import OneDriveConfigEntry + +TO_REDACT = {"display_name", "email", CONF_ACCESS_TOKEN, CONF_TOKEN} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, + entry: OneDriveConfigEntry, +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + + coordinator = entry.runtime_data.coordinator + + data = { + "drive": asdict(coordinator.data), + "config": { + **entry.data, + **entry.options, + }, + } + + return async_redact_data(data, TO_REDACT) diff --git a/homeassistant/components/onedrive/quality_scale.yaml b/homeassistant/components/onedrive/quality_scale.yaml index dd9e7f26102..023410d89b2 100644 --- a/homeassistant/components/onedrive/quality_scale.yaml +++ b/homeassistant/components/onedrive/quality_scale.yaml @@ -41,10 +41,7 @@ rules: # Gold devices: done - diagnostics: - status: exempt - comment: | - There is no data to diagnose. + diagnostics: done discovery-update-info: status: exempt comment: | diff --git a/tests/components/onedrive/snapshots/test_diagnostics.ambr b/tests/components/onedrive/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..827b9397313 --- /dev/null +++ b/tests/components/onedrive/snapshots/test_diagnostics.ambr @@ -0,0 +1,31 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'config': dict({ + 'auth_implementation': 'onedrive', + 'folder_id': 'my_folder_id', + 'folder_name': 'name', + 'token': '**REDACTED**', + }), + 'drive': dict({ + 'drive_type': 'personal', + 'id': 'mock_drive_id', + 'name': 'My Drive', + 'owner': dict({ + 'application': None, + 'user': dict({ + 'display_name': '**REDACTED**', + 'email': '**REDACTED**', + 'id': 'id', + }), + }), + 'quota': dict({ + 'deleted': 5, + 'remaining': 805306368, + 'state': 'nearing', + 'total': 5368709120, + 'used': 4250000000, + }), + }), + }) +# --- diff --git a/tests/components/onedrive/test_diagnostics.py b/tests/components/onedrive/test_diagnostics.py new file mode 100644 index 00000000000..f82d9925ee6 --- /dev/null +++ b/tests/components/onedrive/test_diagnostics.py @@ -0,0 +1,26 @@ +"""Tests for the diagnostics data provided by the OneDrive integration.""" + +from syrupy import SnapshotAssertion + +from homeassistant.core import HomeAssistant + +from . import setup_integration + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def test_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics.""" + + await setup_integration(hass, mock_config_entry) + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, mock_config_entry) + == snapshot + ) From fca19a3ec139233788670e733684e8612156a91c Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Fri, 28 Feb 2025 09:25:38 -0600 Subject: [PATCH 1944/3148] Move climate intent to homeassistant integration (#139371) * Move climate intent to homeassistant integration * Move get temperature intent to intent integration * Clean up old test --- homeassistant/components/climate/__init__.py | 1 - homeassistant/components/climate/const.py | 1 - homeassistant/components/climate/intent.py | 43 +- homeassistant/components/intent/__init__.py | 44 ++ homeassistant/helpers/intent.py | 1 + homeassistant/helpers/llm.py | 11 +- tests/components/climate/test_intent.py | 330 -------------- tests/components/intent/test_temperature.py | 456 +++++++++++++++++++ 8 files changed, 508 insertions(+), 379 deletions(-) create mode 100644 tests/components/intent/test_temperature.py diff --git a/homeassistant/components/climate/__init__.py b/homeassistant/components/climate/__init__.py index 3ea0f887e76..287a2397121 100644 --- a/homeassistant/components/climate/__init__.py +++ b/homeassistant/components/climate/__init__.py @@ -68,7 +68,6 @@ from .const import ( # noqa: F401 FAN_ON, FAN_TOP, HVAC_MODES, - INTENT_GET_TEMPERATURE, INTENT_SET_TEMPERATURE, PRESET_ACTIVITY, PRESET_AWAY, diff --git a/homeassistant/components/climate/const.py b/homeassistant/components/climate/const.py index d347ccbbb29..ecc0066cd93 100644 --- a/homeassistant/components/climate/const.py +++ b/homeassistant/components/climate/const.py @@ -126,7 +126,6 @@ DEFAULT_MAX_HUMIDITY = 99 DOMAIN = "climate" -INTENT_GET_TEMPERATURE = "HassClimateGetTemperature" INTENT_SET_TEMPERATURE = "HassClimateSetTemperature" SERVICE_SET_AUX_HEAT = "set_aux_heat" diff --git a/homeassistant/components/climate/intent.py b/homeassistant/components/climate/intent.py index 9837a326188..7691a2db0f1 100644 --- a/homeassistant/components/climate/intent.py +++ b/homeassistant/components/climate/intent.py @@ -1,4 +1,4 @@ -"""Intents for the client integration.""" +"""Intents for the climate integration.""" from __future__ import annotations @@ -11,7 +11,6 @@ from homeassistant.helpers import config_validation as cv, intent from . import ( ATTR_TEMPERATURE, DOMAIN, - INTENT_GET_TEMPERATURE, INTENT_SET_TEMPERATURE, SERVICE_SET_TEMPERATURE, ClimateEntityFeature, @@ -20,49 +19,9 @@ from . import ( async def async_setup_intents(hass: HomeAssistant) -> None: """Set up the climate intents.""" - intent.async_register(hass, GetTemperatureIntent()) intent.async_register(hass, SetTemperatureIntent()) -class GetTemperatureIntent(intent.IntentHandler): - """Handle GetTemperature intents.""" - - intent_type = INTENT_GET_TEMPERATURE - description = "Gets the current temperature of a climate device or entity" - slot_schema = { - vol.Optional("area"): intent.non_empty_string, - vol.Optional("name"): intent.non_empty_string, - } - platforms = {DOMAIN} - - async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse: - """Handle the intent.""" - hass = intent_obj.hass - slots = self.async_validate_slots(intent_obj.slots) - - name: str | None = None - if "name" in slots: - name = slots["name"]["value"] - - area: str | None = None - if "area" in slots: - area = slots["area"]["value"] - - match_constraints = intent.MatchTargetsConstraints( - name=name, area_name=area, domains=[DOMAIN], assistant=intent_obj.assistant - ) - match_result = intent.async_match_targets(hass, match_constraints) - if not match_result.is_match: - raise intent.MatchFailedError( - result=match_result, constraints=match_constraints - ) - - response = intent_obj.create_response() - response.response_type = intent.IntentResponseType.QUERY_ANSWER - response.async_set_states(matched_states=match_result.states) - return response - - class SetTemperatureIntent(intent.IntentHandler): """Handle SetTemperature intents.""" diff --git a/homeassistant/components/intent/__init__.py b/homeassistant/components/intent/__init__.py index a1451f8fcca..2f9587e2173 100644 --- a/homeassistant/components/intent/__init__.py +++ b/homeassistant/components/intent/__init__.py @@ -9,6 +9,7 @@ from aiohttp import web import voluptuous as vol from homeassistant.components import http +from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN from homeassistant.components.cover import ( ATTR_POSITION, DOMAIN as COVER_DOMAIN, @@ -140,6 +141,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: intent.async_register(hass, GetCurrentDateIntentHandler()) intent.async_register(hass, GetCurrentTimeIntentHandler()) intent.async_register(hass, RespondIntentHandler()) + intent.async_register(hass, GetTemperatureIntent()) return True @@ -444,6 +446,48 @@ class RespondIntentHandler(intent.IntentHandler): return response +class GetTemperatureIntent(intent.IntentHandler): + """Handle GetTemperature intents.""" + + intent_type = intent.INTENT_GET_TEMPERATURE + description = "Gets the current temperature of a climate device or entity" + slot_schema = { + vol.Optional("area"): intent.non_empty_string, + vol.Optional("name"): intent.non_empty_string, + } + platforms = {CLIMATE_DOMAIN} + + async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse: + """Handle the intent.""" + hass = intent_obj.hass + slots = self.async_validate_slots(intent_obj.slots) + + name: str | None = None + if "name" in slots: + name = slots["name"]["value"] + + area: str | None = None + if "area" in slots: + area = slots["area"]["value"] + + match_constraints = intent.MatchTargetsConstraints( + name=name, + area_name=area, + domains=[CLIMATE_DOMAIN], + assistant=intent_obj.assistant, + ) + match_result = intent.async_match_targets(hass, match_constraints) + if not match_result.is_match: + raise intent.MatchFailedError( + result=match_result, constraints=match_constraints + ) + + response = intent_obj.create_response() + response.response_type = intent.IntentResponseType.QUERY_ANSWER + response.async_set_states(matched_states=match_result.states) + return response + + async def _async_process_intent( hass: HomeAssistant, domain: str, platform: IntentPlatformProtocol ) -> None: diff --git a/homeassistant/helpers/intent.py b/homeassistant/helpers/intent.py index c93545ed414..cecb84d0373 100644 --- a/homeassistant/helpers/intent.py +++ b/homeassistant/helpers/intent.py @@ -59,6 +59,7 @@ INTENT_GET_CURRENT_DATE = "HassGetCurrentDate" INTENT_GET_CURRENT_TIME = "HassGetCurrentTime" INTENT_RESPOND = "HassRespond" INTENT_BROADCAST = "HassBroadcast" +INTENT_GET_TEMPERATURE = "HassClimateGetTemperature" SLOT_SCHEMA = vol.Schema({}, extra=vol.ALLOW_EXTRA) diff --git a/homeassistant/helpers/llm.py b/homeassistant/helpers/llm.py index 2ef785e7f71..4ad2bdd6563 100644 --- a/homeassistant/helpers/llm.py +++ b/homeassistant/helpers/llm.py @@ -19,7 +19,6 @@ from homeassistant.components.calendar import ( DOMAIN as CALENDAR_DOMAIN, SERVICE_GET_EVENTS, ) -from homeassistant.components.climate import INTENT_GET_TEMPERATURE from homeassistant.components.cover import INTENT_CLOSE_COVER, INTENT_OPEN_COVER from homeassistant.components.homeassistant import async_should_expose from homeassistant.components.intent import async_device_supports_timers @@ -285,7 +284,7 @@ class AssistAPI(API): """API exposing Assist API to LLMs.""" IGNORE_INTENTS = { - INTENT_GET_TEMPERATURE, + intent.INTENT_GET_TEMPERATURE, INTENT_GET_WEATHER, INTENT_OPEN_COVER, # deprecated INTENT_CLOSE_COVER, # deprecated @@ -530,9 +529,11 @@ def _get_exposed_entities( info["areas"] = ", ".join(area_names) if attributes := { - attr_name: str(attr_value) - if isinstance(attr_value, (Enum, Decimal, int)) - else attr_value + attr_name: ( + str(attr_value) + if isinstance(attr_value, (Enum, Decimal, int)) + else attr_value + ) for attr_name, attr_value in state.attributes.items() if attr_name in interesting_attributes }: diff --git a/tests/components/climate/test_intent.py b/tests/components/climate/test_intent.py index 65d607e618b..4ce06199eb8 100644 --- a/tests/components/climate/test_intent.py +++ b/tests/components/climate/test_intent.py @@ -14,7 +14,6 @@ from homeassistant.components.climate import ( HVACMode, intent as climate_intent, ) -from homeassistant.components.homeassistant.exposed_entities import async_expose_entity from homeassistant.config_entries import ConfigEntry, ConfigFlow from homeassistant.const import Platform, UnitOfTemperature from homeassistant.core import HomeAssistant @@ -131,335 +130,6 @@ class MockClimateEntityNoSetTemperature(ClimateEntity): _attr_hvac_modes = [HVACMode.OFF, HVACMode.HEAT] -async def test_get_temperature( - hass: HomeAssistant, - area_registry: ar.AreaRegistry, - entity_registry: er.EntityRegistry, -) -> None: - """Test HassClimateGetTemperature intent.""" - assert await async_setup_component(hass, "homeassistant", {}) - await climate_intent.async_setup_intents(hass) - - climate_1 = MockClimateEntity() - climate_1._attr_name = "Climate 1" - climate_1._attr_unique_id = "1234" - climate_1._attr_current_temperature = 10.0 - entity_registry.async_get_or_create( - DOMAIN, "test", "1234", suggested_object_id="climate_1" - ) - - climate_2 = MockClimateEntity() - climate_2._attr_name = "Climate 2" - climate_2._attr_unique_id = "5678" - climate_2._attr_current_temperature = 22.0 - entity_registry.async_get_or_create( - DOMAIN, "test", "5678", suggested_object_id="climate_2" - ) - - await create_mock_platform(hass, [climate_1, climate_2]) - - # Add climate entities to different areas: - # climate_1 => living room - # climate_2 => bedroom - # nothing in office - living_room_area = area_registry.async_create(name="Living Room") - bedroom_area = area_registry.async_create(name="Bedroom") - office_area = area_registry.async_create(name="Office") - - entity_registry.async_update_entity( - climate_1.entity_id, area_id=living_room_area.id - ) - entity_registry.async_update_entity(climate_2.entity_id, area_id=bedroom_area.id) - - # First climate entity will be selected (no area) - response = await intent.async_handle( - hass, - "test", - climate_intent.INTENT_GET_TEMPERATURE, - {}, - assistant=conversation.DOMAIN, - ) - assert response.response_type == intent.IntentResponseType.QUERY_ANSWER - assert response.matched_states - assert response.matched_states[0].entity_id == climate_1.entity_id - state = response.matched_states[0] - assert state.attributes["current_temperature"] == 10.0 - - # Select by area (climate_2) - response = await intent.async_handle( - hass, - "test", - climate_intent.INTENT_GET_TEMPERATURE, - {"area": {"value": bedroom_area.name}}, - assistant=conversation.DOMAIN, - ) - assert response.response_type == intent.IntentResponseType.QUERY_ANSWER - assert len(response.matched_states) == 1 - assert response.matched_states[0].entity_id == climate_2.entity_id - state = response.matched_states[0] - assert state.attributes["current_temperature"] == 22.0 - - # Select by name (climate_2) - response = await intent.async_handle( - hass, - "test", - climate_intent.INTENT_GET_TEMPERATURE, - {"name": {"value": "Climate 2"}}, - assistant=conversation.DOMAIN, - ) - assert response.response_type == intent.IntentResponseType.QUERY_ANSWER - assert len(response.matched_states) == 1 - assert response.matched_states[0].entity_id == climate_2.entity_id - state = response.matched_states[0] - assert state.attributes["current_temperature"] == 22.0 - - # Check area with no climate entities - with pytest.raises(intent.MatchFailedError) as error: - response = await intent.async_handle( - hass, - "test", - climate_intent.INTENT_GET_TEMPERATURE, - {"area": {"value": office_area.name}}, - assistant=conversation.DOMAIN, - ) - - # Exception should contain details of what we tried to match - assert isinstance(error.value, intent.MatchFailedError) - assert error.value.result.no_match_reason == intent.MatchFailedReason.AREA - constraints = error.value.constraints - assert constraints.name is None - assert constraints.area_name == office_area.name - assert constraints.domains and (set(constraints.domains) == {DOMAIN}) - assert constraints.device_classes is None - - # Check wrong name - with pytest.raises(intent.MatchFailedError) as error: - response = await intent.async_handle( - hass, - "test", - climate_intent.INTENT_GET_TEMPERATURE, - {"name": {"value": "Does not exist"}}, - ) - - assert isinstance(error.value, intent.MatchFailedError) - assert error.value.result.no_match_reason == intent.MatchFailedReason.NAME - constraints = error.value.constraints - assert constraints.name == "Does not exist" - assert constraints.area_name is None - assert constraints.domains and (set(constraints.domains) == {DOMAIN}) - assert constraints.device_classes is None - - # Check wrong name with area - with pytest.raises(intent.MatchFailedError) as error: - response = await intent.async_handle( - hass, - "test", - climate_intent.INTENT_GET_TEMPERATURE, - {"name": {"value": "Climate 1"}, "area": {"value": bedroom_area.name}}, - ) - - assert isinstance(error.value, intent.MatchFailedError) - assert error.value.result.no_match_reason == intent.MatchFailedReason.AREA - constraints = error.value.constraints - assert constraints.name == "Climate 1" - assert constraints.area_name == bedroom_area.name - assert constraints.domains and (set(constraints.domains) == {DOMAIN}) - assert constraints.device_classes is None - - -async def test_get_temperature_no_entities( - hass: HomeAssistant, -) -> None: - """Test HassClimateGetTemperature intent with no climate entities.""" - assert await async_setup_component(hass, "homeassistant", {}) - await climate_intent.async_setup_intents(hass) - - await create_mock_platform(hass, []) - - with pytest.raises(intent.MatchFailedError) as err: - await intent.async_handle( - hass, - "test", - climate_intent.INTENT_GET_TEMPERATURE, - {}, - assistant=conversation.DOMAIN, - ) - assert err.value.result.no_match_reason == intent.MatchFailedReason.DOMAIN - - -async def test_not_exposed( - hass: HomeAssistant, - area_registry: ar.AreaRegistry, - entity_registry: er.EntityRegistry, -) -> None: - """Test HassClimateGetTemperature intent when entities aren't exposed.""" - assert await async_setup_component(hass, "homeassistant", {}) - await climate_intent.async_setup_intents(hass) - - climate_1 = MockClimateEntity() - climate_1._attr_name = "Climate 1" - climate_1._attr_unique_id = "1234" - climate_1._attr_current_temperature = 10.0 - entity_registry.async_get_or_create( - DOMAIN, "test", "1234", suggested_object_id="climate_1" - ) - - climate_2 = MockClimateEntity() - climate_2._attr_name = "Climate 2" - climate_2._attr_unique_id = "5678" - climate_2._attr_current_temperature = 22.0 - entity_registry.async_get_or_create( - DOMAIN, "test", "5678", suggested_object_id="climate_2" - ) - - await create_mock_platform(hass, [climate_1, climate_2]) - - # Add climate entities to same area - living_room_area = area_registry.async_create(name="Living Room") - bedroom_area = area_registry.async_create(name="Bedroom") - entity_registry.async_update_entity( - climate_1.entity_id, area_id=living_room_area.id - ) - entity_registry.async_update_entity( - climate_2.entity_id, area_id=living_room_area.id - ) - - # Should fail with empty name - with pytest.raises(intent.InvalidSlotInfo): - await intent.async_handle( - hass, - "test", - climate_intent.INTENT_GET_TEMPERATURE, - {"name": {"value": ""}}, - assistant=conversation.DOMAIN, - ) - - # Should fail with empty area - with pytest.raises(intent.InvalidSlotInfo): - await intent.async_handle( - hass, - "test", - climate_intent.INTENT_GET_TEMPERATURE, - {"area": {"value": ""}}, - assistant=conversation.DOMAIN, - ) - - # Expose second, hide first - async_expose_entity(hass, conversation.DOMAIN, climate_1.entity_id, False) - async_expose_entity(hass, conversation.DOMAIN, climate_2.entity_id, True) - - # Second climate entity is exposed - response = await intent.async_handle( - hass, - "test", - climate_intent.INTENT_GET_TEMPERATURE, - {}, - assistant=conversation.DOMAIN, - ) - assert response.response_type == intent.IntentResponseType.QUERY_ANSWER - assert len(response.matched_states) == 1 - assert response.matched_states[0].entity_id == climate_2.entity_id - - # Using the area should work - response = await intent.async_handle( - hass, - "test", - climate_intent.INTENT_GET_TEMPERATURE, - {"area": {"value": living_room_area.name}}, - assistant=conversation.DOMAIN, - ) - assert response.response_type == intent.IntentResponseType.QUERY_ANSWER - assert len(response.matched_states) == 1 - assert response.matched_states[0].entity_id == climate_2.entity_id - - # Using the name of the exposed entity should work - response = await intent.async_handle( - hass, - "test", - climate_intent.INTENT_GET_TEMPERATURE, - {"name": {"value": climate_2.name}}, - assistant=conversation.DOMAIN, - ) - assert response.response_type == intent.IntentResponseType.QUERY_ANSWER - assert len(response.matched_states) == 1 - assert response.matched_states[0].entity_id == climate_2.entity_id - - # Using the name of the *unexposed* entity should fail - with pytest.raises(intent.MatchFailedError) as err: - await intent.async_handle( - hass, - "test", - climate_intent.INTENT_GET_TEMPERATURE, - {"name": {"value": climate_1.name}}, - assistant=conversation.DOMAIN, - ) - assert err.value.result.no_match_reason == intent.MatchFailedReason.ASSISTANT - - # Expose first, hide second - async_expose_entity(hass, conversation.DOMAIN, climate_1.entity_id, True) - async_expose_entity(hass, conversation.DOMAIN, climate_2.entity_id, False) - - # Second climate entity is exposed - response = await intent.async_handle( - hass, - "test", - climate_intent.INTENT_GET_TEMPERATURE, - {}, - assistant=conversation.DOMAIN, - ) - assert response.response_type == intent.IntentResponseType.QUERY_ANSWER - assert len(response.matched_states) == 1 - assert response.matched_states[0].entity_id == climate_1.entity_id - - # Wrong area name - with pytest.raises(intent.MatchFailedError) as err: - await intent.async_handle( - hass, - "test", - climate_intent.INTENT_GET_TEMPERATURE, - {"area": {"value": bedroom_area.name}}, - assistant=conversation.DOMAIN, - ) - assert err.value.result.no_match_reason == intent.MatchFailedReason.AREA - - # Neither are exposed - async_expose_entity(hass, conversation.DOMAIN, climate_1.entity_id, False) - async_expose_entity(hass, conversation.DOMAIN, climate_2.entity_id, False) - - with pytest.raises(intent.MatchFailedError) as err: - await intent.async_handle( - hass, - "test", - climate_intent.INTENT_GET_TEMPERATURE, - {}, - assistant=conversation.DOMAIN, - ) - assert err.value.result.no_match_reason == intent.MatchFailedReason.ASSISTANT - - # Should fail with area - with pytest.raises(intent.MatchFailedError) as err: - await intent.async_handle( - hass, - "test", - climate_intent.INTENT_GET_TEMPERATURE, - {"area": {"value": living_room_area.name}}, - assistant=conversation.DOMAIN, - ) - assert err.value.result.no_match_reason == intent.MatchFailedReason.ASSISTANT - - # Should fail with both names - for name in (climate_1.name, climate_2.name): - with pytest.raises(intent.MatchFailedError) as err: - await intent.async_handle( - hass, - "test", - climate_intent.INTENT_GET_TEMPERATURE, - {"name": {"value": name}}, - assistant=conversation.DOMAIN, - ) - assert err.value.result.no_match_reason == intent.MatchFailedReason.ASSISTANT - - async def test_set_temperature( hass: HomeAssistant, area_registry: ar.AreaRegistry, diff --git a/tests/components/intent/test_temperature.py b/tests/components/intent/test_temperature.py new file mode 100644 index 00000000000..0279fa44b28 --- /dev/null +++ b/tests/components/intent/test_temperature.py @@ -0,0 +1,456 @@ +"""Test temperature intents.""" + +from collections.abc import Generator +from typing import Any + +import pytest + +from homeassistant.components import conversation +from homeassistant.components.climate import ( + ATTR_TEMPERATURE, + DOMAIN as CLIMATE_DOMAIN, + ClimateEntity, + ClimateEntityFeature, + HVACMode, +) +from homeassistant.components.homeassistant.exposed_entities import async_expose_entity +from homeassistant.config_entries import ConfigEntry, ConfigFlow +from homeassistant.const import Platform, UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.helpers import area_registry as ar, entity_registry as er, intent +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.setup import async_setup_component + +from tests.common import ( + MockConfigEntry, + MockModule, + MockPlatform, + mock_config_flow, + mock_integration, + mock_platform, +) + +TEST_DOMAIN = "test" + + +class MockFlow(ConfigFlow): + """Test flow.""" + + +@pytest.fixture(autouse=True) +def config_flow_fixture(hass: HomeAssistant) -> Generator[None]: + """Mock config flow.""" + mock_platform(hass, f"{TEST_DOMAIN}.config_flow") + + with mock_config_flow(TEST_DOMAIN, MockFlow): + yield + + +@pytest.fixture(autouse=True) +def mock_setup_integration(hass: HomeAssistant) -> None: + """Fixture to set up a mock integration.""" + + async def async_setup_entry_init( + hass: HomeAssistant, config_entry: ConfigEntry + ) -> bool: + """Set up test config entry.""" + await hass.config_entries.async_forward_entry_setups( + config_entry, [CLIMATE_DOMAIN] + ) + return True + + async def async_unload_entry_init( + hass: HomeAssistant, + config_entry: ConfigEntry, + ) -> bool: + await hass.config_entries.async_unload_platforms(config_entry, [Platform.TODO]) + return True + + mock_platform(hass, f"{TEST_DOMAIN}.config_flow") + mock_integration( + hass, + MockModule( + TEST_DOMAIN, + async_setup_entry=async_setup_entry_init, + async_unload_entry=async_unload_entry_init, + ), + ) + + +async def create_mock_platform( + hass: HomeAssistant, + entities: list[ClimateEntity], +) -> MockConfigEntry: + """Create a todo platform with the specified entities.""" + + async def async_setup_entry_platform( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, + ) -> None: + """Set up test event platform via config entry.""" + async_add_entities(entities) + + mock_platform( + hass, + f"{TEST_DOMAIN}.{CLIMATE_DOMAIN}", + MockPlatform(async_setup_entry=async_setup_entry_platform), + ) + + config_entry = MockConfigEntry(domain=TEST_DOMAIN) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + return config_entry + + +class MockClimateEntity(ClimateEntity): + """Mock Climate device to use in tests.""" + + _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_hvac_mode = HVACMode.OFF + _attr_hvac_modes = [HVACMode.OFF, HVACMode.HEAT] + _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set the thermostat temperature.""" + value = kwargs[ATTR_TEMPERATURE] + self._attr_target_temperature = value + + +class MockClimateEntityNoSetTemperature(ClimateEntity): + """Mock Climate device to use in tests.""" + + _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_hvac_mode = HVACMode.OFF + _attr_hvac_modes = [HVACMode.OFF, HVACMode.HEAT] + + +async def test_get_temperature( + hass: HomeAssistant, + area_registry: ar.AreaRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test HassClimateGetTemperature intent.""" + assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component(hass, "intent", {}) + + climate_1 = MockClimateEntity() + climate_1._attr_name = "Climate 1" + climate_1._attr_unique_id = "1234" + climate_1._attr_current_temperature = 10.0 + entity_registry.async_get_or_create( + CLIMATE_DOMAIN, "test", "1234", suggested_object_id="climate_1" + ) + + climate_2 = MockClimateEntity() + climate_2._attr_name = "Climate 2" + climate_2._attr_unique_id = "5678" + climate_2._attr_current_temperature = 22.0 + entity_registry.async_get_or_create( + CLIMATE_DOMAIN, "test", "5678", suggested_object_id="climate_2" + ) + + await create_mock_platform(hass, [climate_1, climate_2]) + + # Add climate entities to different areas: + # climate_1 => living room + # climate_2 => bedroom + # nothing in office + living_room_area = area_registry.async_create(name="Living Room") + bedroom_area = area_registry.async_create(name="Bedroom") + office_area = area_registry.async_create(name="Office") + + entity_registry.async_update_entity( + climate_1.entity_id, area_id=living_room_area.id + ) + entity_registry.async_update_entity(climate_2.entity_id, area_id=bedroom_area.id) + + # First climate entity will be selected (no area) + response = await intent.async_handle( + hass, + "test", + intent.INTENT_GET_TEMPERATURE, + {}, + assistant=conversation.DOMAIN, + ) + assert response.response_type == intent.IntentResponseType.QUERY_ANSWER + assert response.matched_states + assert response.matched_states[0].entity_id == climate_1.entity_id + state = response.matched_states[0] + assert state.attributes["current_temperature"] == 10.0 + + # Select by area (climate_2) + response = await intent.async_handle( + hass, + "test", + intent.INTENT_GET_TEMPERATURE, + {"area": {"value": bedroom_area.name}}, + assistant=conversation.DOMAIN, + ) + assert response.response_type == intent.IntentResponseType.QUERY_ANSWER + assert len(response.matched_states) == 1 + assert response.matched_states[0].entity_id == climate_2.entity_id + state = response.matched_states[0] + assert state.attributes["current_temperature"] == 22.0 + + # Select by name (climate_2) + response = await intent.async_handle( + hass, + "test", + intent.INTENT_GET_TEMPERATURE, + {"name": {"value": "Climate 2"}}, + assistant=conversation.DOMAIN, + ) + assert response.response_type == intent.IntentResponseType.QUERY_ANSWER + assert len(response.matched_states) == 1 + assert response.matched_states[0].entity_id == climate_2.entity_id + state = response.matched_states[0] + assert state.attributes["current_temperature"] == 22.0 + + # Check area with no climate entities + with pytest.raises(intent.MatchFailedError) as error: + response = await intent.async_handle( + hass, + "test", + intent.INTENT_GET_TEMPERATURE, + {"area": {"value": office_area.name}}, + assistant=conversation.DOMAIN, + ) + + # Exception should contain details of what we tried to match + assert isinstance(error.value, intent.MatchFailedError) + assert error.value.result.no_match_reason == intent.MatchFailedReason.AREA + constraints = error.value.constraints + assert constraints.name is None + assert constraints.area_name == office_area.name + assert constraints.domains and (set(constraints.domains) == {CLIMATE_DOMAIN}) + assert constraints.device_classes is None + + # Check wrong name + with pytest.raises(intent.MatchFailedError) as error: + response = await intent.async_handle( + hass, + "test", + intent.INTENT_GET_TEMPERATURE, + {"name": {"value": "Does not exist"}}, + ) + + assert isinstance(error.value, intent.MatchFailedError) + assert error.value.result.no_match_reason == intent.MatchFailedReason.NAME + constraints = error.value.constraints + assert constraints.name == "Does not exist" + assert constraints.area_name is None + assert constraints.domains and (set(constraints.domains) == {CLIMATE_DOMAIN}) + assert constraints.device_classes is None + + # Check wrong name with area + with pytest.raises(intent.MatchFailedError) as error: + response = await intent.async_handle( + hass, + "test", + intent.INTENT_GET_TEMPERATURE, + {"name": {"value": "Climate 1"}, "area": {"value": bedroom_area.name}}, + ) + + assert isinstance(error.value, intent.MatchFailedError) + assert error.value.result.no_match_reason == intent.MatchFailedReason.AREA + constraints = error.value.constraints + assert constraints.name == "Climate 1" + assert constraints.area_name == bedroom_area.name + assert constraints.domains and (set(constraints.domains) == {CLIMATE_DOMAIN}) + assert constraints.device_classes is None + + +async def test_get_temperature_no_entities( + hass: HomeAssistant, +) -> None: + """Test HassClimateGetTemperature intent with no climate entities.""" + assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component(hass, "intent", {}) + + await create_mock_platform(hass, []) + + with pytest.raises(intent.MatchFailedError) as err: + await intent.async_handle( + hass, + "test", + intent.INTENT_GET_TEMPERATURE, + {}, + assistant=conversation.DOMAIN, + ) + assert err.value.result.no_match_reason == intent.MatchFailedReason.DOMAIN + + +async def test_not_exposed( + hass: HomeAssistant, + area_registry: ar.AreaRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test HassClimateGetTemperature intent when entities aren't exposed.""" + assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component(hass, "intent", {}) + + climate_1 = MockClimateEntity() + climate_1._attr_name = "Climate 1" + climate_1._attr_unique_id = "1234" + climate_1._attr_current_temperature = 10.0 + entity_registry.async_get_or_create( + CLIMATE_DOMAIN, "test", "1234", suggested_object_id="climate_1" + ) + + climate_2 = MockClimateEntity() + climate_2._attr_name = "Climate 2" + climate_2._attr_unique_id = "5678" + climate_2._attr_current_temperature = 22.0 + entity_registry.async_get_or_create( + CLIMATE_DOMAIN, "test", "5678", suggested_object_id="climate_2" + ) + + await create_mock_platform(hass, [climate_1, climate_2]) + + # Add climate entities to same area + living_room_area = area_registry.async_create(name="Living Room") + bedroom_area = area_registry.async_create(name="Bedroom") + entity_registry.async_update_entity( + climate_1.entity_id, area_id=living_room_area.id + ) + entity_registry.async_update_entity( + climate_2.entity_id, area_id=living_room_area.id + ) + + # Should fail with empty name + with pytest.raises(intent.InvalidSlotInfo): + await intent.async_handle( + hass, + "test", + intent.INTENT_GET_TEMPERATURE, + {"name": {"value": ""}}, + assistant=conversation.DOMAIN, + ) + + # Should fail with empty area + with pytest.raises(intent.InvalidSlotInfo): + await intent.async_handle( + hass, + "test", + intent.INTENT_GET_TEMPERATURE, + {"area": {"value": ""}}, + assistant=conversation.DOMAIN, + ) + + # Expose second, hide first + async_expose_entity(hass, conversation.DOMAIN, climate_1.entity_id, False) + async_expose_entity(hass, conversation.DOMAIN, climate_2.entity_id, True) + + # Second climate entity is exposed + response = await intent.async_handle( + hass, + "test", + intent.INTENT_GET_TEMPERATURE, + {}, + assistant=conversation.DOMAIN, + ) + assert response.response_type == intent.IntentResponseType.QUERY_ANSWER + assert len(response.matched_states) == 1 + assert response.matched_states[0].entity_id == climate_2.entity_id + + # Using the area should work + response = await intent.async_handle( + hass, + "test", + intent.INTENT_GET_TEMPERATURE, + {"area": {"value": living_room_area.name}}, + assistant=conversation.DOMAIN, + ) + assert response.response_type == intent.IntentResponseType.QUERY_ANSWER + assert len(response.matched_states) == 1 + assert response.matched_states[0].entity_id == climate_2.entity_id + + # Using the name of the exposed entity should work + response = await intent.async_handle( + hass, + "test", + intent.INTENT_GET_TEMPERATURE, + {"name": {"value": climate_2.name}}, + assistant=conversation.DOMAIN, + ) + assert response.response_type == intent.IntentResponseType.QUERY_ANSWER + assert len(response.matched_states) == 1 + assert response.matched_states[0].entity_id == climate_2.entity_id + + # Using the name of the *unexposed* entity should fail + with pytest.raises(intent.MatchFailedError) as err: + await intent.async_handle( + hass, + "test", + intent.INTENT_GET_TEMPERATURE, + {"name": {"value": climate_1.name}}, + assistant=conversation.DOMAIN, + ) + assert err.value.result.no_match_reason == intent.MatchFailedReason.ASSISTANT + + # Expose first, hide second + async_expose_entity(hass, conversation.DOMAIN, climate_1.entity_id, True) + async_expose_entity(hass, conversation.DOMAIN, climate_2.entity_id, False) + + # Second climate entity is exposed + response = await intent.async_handle( + hass, + "test", + intent.INTENT_GET_TEMPERATURE, + {}, + assistant=conversation.DOMAIN, + ) + assert response.response_type == intent.IntentResponseType.QUERY_ANSWER + assert len(response.matched_states) == 1 + assert response.matched_states[0].entity_id == climate_1.entity_id + + # Wrong area name + with pytest.raises(intent.MatchFailedError) as err: + await intent.async_handle( + hass, + "test", + intent.INTENT_GET_TEMPERATURE, + {"area": {"value": bedroom_area.name}}, + assistant=conversation.DOMAIN, + ) + assert err.value.result.no_match_reason == intent.MatchFailedReason.AREA + + # Neither are exposed + async_expose_entity(hass, conversation.DOMAIN, climate_1.entity_id, False) + async_expose_entity(hass, conversation.DOMAIN, climate_2.entity_id, False) + + with pytest.raises(intent.MatchFailedError) as err: + await intent.async_handle( + hass, + "test", + intent.INTENT_GET_TEMPERATURE, + {}, + assistant=conversation.DOMAIN, + ) + assert err.value.result.no_match_reason == intent.MatchFailedReason.ASSISTANT + + # Should fail with area + with pytest.raises(intent.MatchFailedError) as err: + await intent.async_handle( + hass, + "test", + intent.INTENT_GET_TEMPERATURE, + {"area": {"value": living_room_area.name}}, + assistant=conversation.DOMAIN, + ) + assert err.value.result.no_match_reason == intent.MatchFailedReason.ASSISTANT + + # Should fail with both names + for name in (climate_1.name, climate_2.name): + with pytest.raises(intent.MatchFailedError) as err: + await intent.async_handle( + hass, + "test", + intent.INTENT_GET_TEMPERATURE, + {"name": {"value": name}}, + assistant=conversation.DOMAIN, + ) + assert err.value.result.no_match_reason == intent.MatchFailedReason.ASSISTANT From 271d225e5124745e6aaf94355d20ee34507ab1bc Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Fri, 28 Feb 2025 17:05:36 +0100 Subject: [PATCH 1945/3148] Update frontend to 20250228.0 (#139531) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 5399b22f075..d8eb53467f0 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20250227.0"] + "requirements": ["home-assistant-frontend==20250228.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index b8e0b417353..54401a12592 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -37,7 +37,7 @@ habluetooth==3.24.1 hass-nabucasa==0.92.0 hassil==2.2.3 home-assistant-bluetooth==1.13.1 -home-assistant-frontend==20250227.0 +home-assistant-frontend==20250228.0 home-assistant-intents==2025.2.26 httpx==0.28.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index c8e7bbc806a..69024d3dfbf 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1152,7 +1152,7 @@ hole==0.8.0 holidays==0.67 # homeassistant.components.frontend -home-assistant-frontend==20250227.0 +home-assistant-frontend==20250228.0 # homeassistant.components.conversation home-assistant-intents==2025.2.26 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e8e7c4a34f4..9b1edabb9b6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -981,7 +981,7 @@ hole==0.8.0 holidays==0.67 # homeassistant.components.frontend -home-assistant-frontend==20250227.0 +home-assistant-frontend==20250228.0 # homeassistant.components.conversation home-assistant-intents==2025.2.26 From c5e5fe555d929655a3852fae5b52ed6ac024dced Mon Sep 17 00:00:00 2001 From: Jeef Date: Fri, 28 Feb 2025 06:32:52 -0700 Subject: [PATCH 1946/3148] Bump weatherflow4py to 1.3.1 (#135529) * version bump of dep * update requirements --- homeassistant/components/weatherflow_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/weatherflow_cloud/manifest.json b/homeassistant/components/weatherflow_cloud/manifest.json index 98c98cfbac7..9ffa457a355 100644 --- a/homeassistant/components/weatherflow_cloud/manifest.json +++ b/homeassistant/components/weatherflow_cloud/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/weatherflow_cloud", "iot_class": "cloud_polling", "loggers": ["weatherflow4py"], - "requirements": ["weatherflow4py==1.0.6"] + "requirements": ["weatherflow4py==1.3.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index a6eb357230d..0e47f66c965 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3046,7 +3046,7 @@ waterfurnace==1.1.0 watergate-local-api==2024.4.1 # homeassistant.components.weatherflow_cloud -weatherflow4py==1.0.6 +weatherflow4py==1.3.1 # homeassistant.components.cisco_webex_teams webexpythonsdk==2.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7049fd84d84..642494927a8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2453,7 +2453,7 @@ watchdog==6.0.0 watergate-local-api==2024.4.1 # homeassistant.components.weatherflow_cloud -weatherflow4py==1.0.6 +weatherflow4py==1.3.1 # homeassistant.components.nasweb webio-api==0.1.11 From 83c035133854908f7d4c4576a6fe1597fd51de1f Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Fri, 28 Feb 2025 14:17:02 +0100 Subject: [PATCH 1947/3148] Add new mediatypes to Music Assistant integration (#139338) * Bump Music Assistant client to 1.1.0 * Add some casts to help mypy * Add handling of the new media types in Music Assistant * mypy cleanup * lint * update snapshot * Adjust tests --------- Co-authored-by: Franck Nijhof --- .../components/music_assistant/actions.py | 36 +- .../components/music_assistant/const.py | 2 + .../components/music_assistant/schemas.py | 8 + .../components/music_assistant/services.yaml | 7 + .../components/music_assistant/strings.json | 3 + tests/components/music_assistant/common.py | 26 +- .../fixtures/library_audiobooks.json | 489 ++++++++++++++++++ .../fixtures/library_podcasts.json | 309 +++++++++++ .../snapshots/test_actions.ambr | 196 ++++++- .../music_assistant/test_actions.py | 16 +- 10 files changed, 1087 insertions(+), 5 deletions(-) create mode 100644 tests/components/music_assistant/fixtures/library_audiobooks.json create mode 100644 tests/components/music_assistant/fixtures/library_podcasts.json diff --git a/homeassistant/components/music_assistant/actions.py b/homeassistant/components/music_assistant/actions.py index bf9a1260362..031229d1544 100644 --- a/homeassistant/components/music_assistant/actions.py +++ b/homeassistant/components/music_assistant/actions.py @@ -23,6 +23,7 @@ from .const import ( ATTR_ALBUM_TYPE, ATTR_ALBUMS, ATTR_ARTISTS, + ATTR_AUDIOBOOKS, ATTR_CONFIG_ENTRY_ID, ATTR_FAVORITE, ATTR_ITEMS, @@ -32,6 +33,7 @@ from .const import ( ATTR_OFFSET, ATTR_ORDER_BY, ATTR_PLAYLISTS, + ATTR_PODCASTS, ATTR_RADIO, ATTR_SEARCH, ATTR_SEARCH_ALBUM, @@ -48,7 +50,15 @@ from .schemas import ( if TYPE_CHECKING: from music_assistant_client import MusicAssistantClient - from music_assistant_models.media_items import Album, Artist, Playlist, Radio, Track + from music_assistant_models.media_items import ( + Album, + Artist, + Audiobook, + Playlist, + Podcast, + Radio, + Track, + ) from . import MusicAssistantConfigEntry @@ -155,6 +165,14 @@ async def handle_search(call: ServiceCall) -> ServiceResponse: media_item_dict_from_mass_item(mass, item) for item in search_results.radio ], + ATTR_AUDIOBOOKS: [ + media_item_dict_from_mass_item(mass, item) + for item in search_results.audiobooks + ], + ATTR_PODCASTS: [ + media_item_dict_from_mass_item(mass, item) + for item in search_results.podcasts + ], } ) return response @@ -175,7 +193,13 @@ async def handle_get_library(call: ServiceCall) -> ServiceResponse: "order_by": order_by, } library_result: ( - list[Album] | list[Artist] | list[Track] | list[Radio] | list[Playlist] + list[Album] + | list[Artist] + | list[Track] + | list[Radio] + | list[Playlist] + | list[Audiobook] + | list[Podcast] ) if media_type == MediaType.ALBUM: library_result = await mass.music.get_library_albums( @@ -199,6 +223,14 @@ async def handle_get_library(call: ServiceCall) -> ServiceResponse: library_result = await mass.music.get_library_playlists( **base_params, ) + elif media_type == MediaType.AUDIOBOOK: + library_result = await mass.music.get_library_audiobooks( + **base_params, + ) + elif media_type == MediaType.PODCAST: + library_result = await mass.music.get_library_podcasts( + **base_params, + ) else: raise ServiceValidationError(f"Unsupported media type {media_type}") diff --git a/homeassistant/components/music_assistant/const.py b/homeassistant/components/music_assistant/const.py index 1980c495278..d2ee1f75028 100644 --- a/homeassistant/components/music_assistant/const.py +++ b/homeassistant/components/music_assistant/const.py @@ -34,6 +34,8 @@ ATTR_ARTISTS = "artists" ATTR_ALBUMS = "albums" ATTR_TRACKS = "tracks" ATTR_PLAYLISTS = "playlists" +ATTR_AUDIOBOOKS = "audiobooks" +ATTR_PODCASTS = "podcasts" ATTR_RADIO = "radio" ATTR_ITEMS = "items" ATTR_RADIO_MODE = "radio_mode" diff --git a/homeassistant/components/music_assistant/schemas.py b/homeassistant/components/music_assistant/schemas.py index 0954d1573e7..7501d3d2038 100644 --- a/homeassistant/components/music_assistant/schemas.py +++ b/homeassistant/components/music_assistant/schemas.py @@ -15,6 +15,7 @@ from .const import ( ATTR_ALBUM, ATTR_ALBUMS, ATTR_ARTISTS, + ATTR_AUDIOBOOKS, ATTR_BIT_DEPTH, ATTR_CONTENT_TYPE, ATTR_CURRENT_INDEX, @@ -31,6 +32,7 @@ from .const import ( ATTR_OFFSET, ATTR_ORDER_BY, ATTR_PLAYLISTS, + ATTR_PODCASTS, ATTR_PROVIDER, ATTR_QUEUE_ID, ATTR_QUEUE_ITEM_ID, @@ -101,6 +103,12 @@ SEARCH_RESULT_SCHEMA = vol.Schema( vol.Required(ATTR_RADIO): vol.All( cv.ensure_list, [vol.Schema(MEDIA_ITEM_SCHEMA)] ), + vol.Required(ATTR_AUDIOBOOKS): vol.All( + cv.ensure_list, [vol.Schema(MEDIA_ITEM_SCHEMA)] + ), + vol.Required(ATTR_PODCASTS): vol.All( + cv.ensure_list, [vol.Schema(MEDIA_ITEM_SCHEMA)] + ), }, ) diff --git a/homeassistant/components/music_assistant/services.yaml b/homeassistant/components/music_assistant/services.yaml index 73e8e2d7521..a3715ea2580 100644 --- a/homeassistant/components/music_assistant/services.yaml +++ b/homeassistant/components/music_assistant/services.yaml @@ -21,7 +21,10 @@ play_media: options: - artist - album + - audiobook + - folder - playlist + - podcast - track - radio artist: @@ -118,7 +121,9 @@ search: options: - artist - album + - audiobook - playlist + - podcast - track - radio artist: @@ -160,7 +165,9 @@ get_library: options: - artist - album + - audiobook - playlist + - podcast - track - radio favorite: diff --git a/homeassistant/components/music_assistant/strings.json b/homeassistant/components/music_assistant/strings.json index 32b72088518..7338af7cb65 100644 --- a/homeassistant/components/music_assistant/strings.json +++ b/homeassistant/components/music_assistant/strings.json @@ -195,8 +195,11 @@ "options": { "artist": "Artist", "album": "Album", + "audiobook": "Audiobook", + "folder": "Folder", "track": "Track", "playlist": "Playlist", + "podcast": "Podcast", "radio": "Radio" } }, diff --git a/tests/components/music_assistant/common.py b/tests/components/music_assistant/common.py index 863d945ccd1..6d7ef927c6e 100644 --- a/tests/components/music_assistant/common.py +++ b/tests/components/music_assistant/common.py @@ -8,7 +8,15 @@ from unittest.mock import AsyncMock, MagicMock from music_assistant_models.api import MassEvent from music_assistant_models.enums import EventType -from music_assistant_models.media_items import Album, Artist, Playlist, Radio, Track +from music_assistant_models.media_items import ( + Album, + Artist, + Audiobook, + Playlist, + Podcast, + Radio, + Track, +) from music_assistant_models.player import Player from music_assistant_models.player_queue import PlayerQueue from syrupy import SnapshotAssertion @@ -62,6 +70,10 @@ async def setup_integration_from_fixtures( music.get_playlist_tracks = AsyncMock(return_value=library_playlist_tracks) library_radios = create_library_radios_from_fixture() music.get_library_radios = AsyncMock(return_value=library_radios) + library_audiobooks = create_library_audiobooks_from_fixture() + music.get_library_audiobooks = AsyncMock(return_value=library_audiobooks) + library_podcasts = create_library_podcasts_from_fixture() + music.get_library_podcasts = AsyncMock(return_value=library_podcasts) music.get_item_by_uri = AsyncMock() config_entry.add_to_hass(hass) @@ -132,6 +144,18 @@ def create_library_radios_from_fixture() -> list[Radio]: return [Radio.from_dict(radio_data) for radio_data in fixture_data] +def create_library_audiobooks_from_fixture() -> list[Audiobook]: + """Create MA Audiobooks from fixture.""" + fixture_data = load_and_parse_fixture("library_audiobooks") + return [Audiobook.from_dict(radio_data) for radio_data in fixture_data] + + +def create_library_podcasts_from_fixture() -> list[Podcast]: + """Create MA Podcasts from fixture.""" + fixture_data = load_and_parse_fixture("library_podcasts") + return [Podcast.from_dict(radio_data) for radio_data in fixture_data] + + async def trigger_subscription_callback( hass: HomeAssistant, client: MagicMock, diff --git a/tests/components/music_assistant/fixtures/library_audiobooks.json b/tests/components/music_assistant/fixtures/library_audiobooks.json new file mode 100644 index 00000000000..1994ee68e14 --- /dev/null +++ b/tests/components/music_assistant/fixtures/library_audiobooks.json @@ -0,0 +1,489 @@ +{ + "library_audiobooks": [ + { + "item_id": "1", + "provider": "library", + "name": "Test Audiobook", + "version": "", + "sort_name": "test audiobook", + "uri": "library://audiobook/1", + "external_ids": [], + "is_playable": true, + "media_type": "audiobook", + "provider_mappings": [ + { + "item_id": "test-audiobook.mp3", + "provider_domain": "filesystem_smb", + "provider_instance": "filesystem_smb--7Kf8QySu", + "available": true, + "audio_format": { + "content_type": "mp3", + "codec_type": "?", + "sample_rate": 48000, + "bit_depth": 16, + "channels": 1, + "output_format_str": "mp3", + "bit_rate": 90304 + }, + "url": null, + "details": "1738502411" + } + ], + "metadata": { + "description": "Cover (front)", + "review": null, + "explicit": null, + "images": [ + { + "type": "thumb", + "path": "test-audiobook.mp3", + "provider": "filesystem_smb--7Kf8QySu", + "remotely_accessible": false + } + ], + "genres": [], + "mood": null, + "style": null, + "copyright": null, + "lyrics": "", + "label": null, + "links": null, + "performers": null, + "preview": null, + "popularity": null, + "release_date": null, + "languages": null, + "chapters": [], + "last_refresh": null + }, + "favorite": false, + "position": null, + "publisher": null, + "authors": ["TestWriter"], + "narrators": [], + "duration": 9, + "fully_played": true, + "resume_position_ms": 9000 + }, + { + "item_id": "11", + "provider": "library", + "name": "Test Audiobook 0", + "version": "", + "sort_name": "test audiobook 0", + "uri": "library://audiobook/11", + "external_ids": [], + "is_playable": true, + "media_type": "audiobook", + "provider_mappings": [ + { + "item_id": "0", + "provider_domain": "test", + "provider_instance": "test", + "available": true, + "audio_format": { + "content_type": "?", + "codec_type": "?", + "sample_rate": 44100, + "bit_depth": 16, + "channels": 2, + "output_format_str": "?", + "bit_rate": 0 + }, + "url": null, + "details": null + } + ], + "metadata": { + "description": "This is a description for Test Audiobook", + "review": null, + "explicit": null, + "images": [ + { + "type": "thumb", + "path": "logo.png", + "provider": "builtin", + "remotely_accessible": false + } + ], + "genres": null, + "mood": null, + "style": null, + "copyright": null, + "lyrics": null, + "label": null, + "links": null, + "performers": null, + "preview": null, + "popularity": null, + "release_date": null, + "languages": null, + "chapters": [ + { + "position": 1, + "name": "Chapter 1", + "start": 10.0, + "end": 20.0 + }, + { + "position": 2, + "name": "Chapter 2", + "start": 20.0, + "end": 40.0 + }, + { + "position": 2, + "name": "Chapter 3", + "start": 40.0, + "end": null + } + ], + "last_refresh": null + }, + "favorite": false, + "position": null, + "publisher": "Test Publisher", + "authors": ["AudioBook Author"], + "narrators": ["AudioBook Narrator"], + "duration": 60, + "fully_played": null, + "resume_position_ms": null + }, + { + "item_id": "12", + "provider": "library", + "name": "Test Audiobook 1", + "version": "", + "sort_name": "test audiobook 1", + "uri": "library://audiobook/12", + "external_ids": [], + "is_playable": true, + "media_type": "audiobook", + "provider_mappings": [ + { + "item_id": "1", + "provider_domain": "test", + "provider_instance": "test", + "available": true, + "audio_format": { + "content_type": "?", + "codec_type": "?", + "sample_rate": 44100, + "bit_depth": 16, + "channels": 2, + "output_format_str": "?", + "bit_rate": 0 + }, + "url": null, + "details": null + } + ], + "metadata": { + "description": "This is a description for Test Audiobook", + "review": null, + "explicit": null, + "images": [ + { + "type": "thumb", + "path": "logo.png", + "provider": "builtin", + "remotely_accessible": false + } + ], + "genres": null, + "mood": null, + "style": null, + "copyright": null, + "lyrics": null, + "label": null, + "links": null, + "performers": null, + "preview": null, + "popularity": null, + "release_date": null, + "languages": null, + "chapters": [ + { + "position": 1, + "name": "Chapter 1", + "start": 10.0, + "end": 20.0 + }, + { + "position": 2, + "name": "Chapter 2", + "start": 20.0, + "end": 40.0 + }, + { + "position": 2, + "name": "Chapter 3", + "start": 40.0, + "end": null + } + ], + "last_refresh": null + }, + "favorite": false, + "position": null, + "publisher": "Test Publisher", + "authors": ["AudioBook Author"], + "narrators": ["AudioBook Narrator"], + "duration": 60, + "fully_played": null, + "resume_position_ms": null + }, + { + "item_id": "13", + "provider": "library", + "name": "Test Audiobook 2", + "version": "", + "sort_name": "test audiobook 2", + "uri": "library://audiobook/13", + "external_ids": [], + "is_playable": true, + "media_type": "audiobook", + "provider_mappings": [ + { + "item_id": "2", + "provider_domain": "test", + "provider_instance": "test", + "available": true, + "audio_format": { + "content_type": "?", + "codec_type": "?", + "sample_rate": 44100, + "bit_depth": 16, + "channels": 2, + "output_format_str": "?", + "bit_rate": 0 + }, + "url": null, + "details": null + } + ], + "metadata": { + "description": "This is a description for Test Audiobook", + "review": null, + "explicit": null, + "images": [ + { + "type": "thumb", + "path": "logo.png", + "provider": "builtin", + "remotely_accessible": false + } + ], + "genres": null, + "mood": null, + "style": null, + "copyright": null, + "lyrics": null, + "label": null, + "links": null, + "performers": null, + "preview": null, + "popularity": null, + "release_date": null, + "languages": null, + "chapters": [ + { + "position": 1, + "name": "Chapter 1", + "start": 10.0, + "end": 20.0 + }, + { + "position": 2, + "name": "Chapter 2", + "start": 20.0, + "end": 40.0 + }, + { + "position": 2, + "name": "Chapter 3", + "start": 40.0, + "end": null + } + ], + "last_refresh": null + }, + "favorite": false, + "position": null, + "publisher": "Test Publisher", + "authors": ["AudioBook Author"], + "narrators": ["AudioBook Narrator"], + "duration": 60, + "fully_played": null, + "resume_position_ms": null + }, + { + "item_id": "14", + "provider": "library", + "name": "Test Audiobook 3", + "version": "", + "sort_name": "test audiobook 3", + "uri": "library://audiobook/14", + "external_ids": [], + "is_playable": true, + "media_type": "audiobook", + "provider_mappings": [ + { + "item_id": "3", + "provider_domain": "test", + "provider_instance": "test", + "available": true, + "audio_format": { + "content_type": "?", + "codec_type": "?", + "sample_rate": 44100, + "bit_depth": 16, + "channels": 2, + "output_format_str": "?", + "bit_rate": 0 + }, + "url": null, + "details": null + } + ], + "metadata": { + "description": "This is a description for Test Audiobook", + "review": null, + "explicit": null, + "images": [ + { + "type": "thumb", + "path": "logo.png", + "provider": "builtin", + "remotely_accessible": false + } + ], + "genres": null, + "mood": null, + "style": null, + "copyright": null, + "lyrics": null, + "label": null, + "links": null, + "performers": null, + "preview": null, + "popularity": null, + "release_date": null, + "languages": null, + "chapters": [ + { + "position": 1, + "name": "Chapter 1", + "start": 10.0, + "end": 20.0 + }, + { + "position": 2, + "name": "Chapter 2", + "start": 20.0, + "end": 40.0 + }, + { + "position": 2, + "name": "Chapter 3", + "start": 40.0, + "end": null + } + ], + "last_refresh": null + }, + "favorite": false, + "position": null, + "publisher": "Test Publisher", + "authors": ["AudioBook Author"], + "narrators": ["AudioBook Narrator"], + "duration": 60, + "fully_played": null, + "resume_position_ms": null + }, + { + "item_id": "15", + "provider": "library", + "name": "Test Audiobook 4", + "version": "", + "sort_name": "test audiobook 4", + "uri": "library://audiobook/15", + "external_ids": [], + "is_playable": true, + "media_type": "audiobook", + "provider_mappings": [ + { + "item_id": "4", + "provider_domain": "test", + "provider_instance": "test", + "available": true, + "audio_format": { + "content_type": "?", + "codec_type": "?", + "sample_rate": 44100, + "bit_depth": 16, + "channels": 2, + "output_format_str": "?", + "bit_rate": 0 + }, + "url": null, + "details": null + } + ], + "metadata": { + "description": "This is a description for Test Audiobook", + "review": null, + "explicit": null, + "images": [ + { + "type": "thumb", + "path": "logo.png", + "provider": "builtin", + "remotely_accessible": false + } + ], + "genres": null, + "mood": null, + "style": null, + "copyright": null, + "lyrics": null, + "label": null, + "links": null, + "performers": null, + "preview": null, + "popularity": null, + "release_date": null, + "languages": null, + "chapters": [ + { + "position": 1, + "name": "Chapter 1", + "start": 10.0, + "end": 20.0 + }, + { + "position": 2, + "name": "Chapter 2", + "start": 20.0, + "end": 40.0 + }, + { + "position": 2, + "name": "Chapter 3", + "start": 40.0, + "end": null + } + ], + "last_refresh": null + }, + "favorite": false, + "position": null, + "publisher": "Test Publisher", + "authors": ["AudioBook Author"], + "narrators": ["AudioBook Narrator"], + "duration": 60, + "fully_played": null, + "resume_position_ms": null + } + ] +} diff --git a/tests/components/music_assistant/fixtures/library_podcasts.json b/tests/components/music_assistant/fixtures/library_podcasts.json new file mode 100644 index 00000000000..2c6a9c62f65 --- /dev/null +++ b/tests/components/music_assistant/fixtures/library_podcasts.json @@ -0,0 +1,309 @@ +{ + "library_podcasts": [ + { + "item_id": "6", + "provider": "library", + "name": "Test Podcast 0", + "version": "", + "sort_name": "test podcast 0", + "uri": "library://podcast/6", + "external_ids": [], + "is_playable": true, + "media_type": "podcast", + "provider_mappings": [ + { + "item_id": "0", + "provider_domain": "test", + "provider_instance": "test", + "available": true, + "audio_format": { + "content_type": "?", + "codec_type": "?", + "sample_rate": 44100, + "bit_depth": 16, + "channels": 2, + "output_format_str": "?", + "bit_rate": 0 + }, + "url": null, + "details": null + } + ], + "metadata": { + "description": null, + "review": null, + "explicit": null, + "images": [ + { + "type": "thumb", + "path": "logo.png", + "provider": "builtin", + "remotely_accessible": false + } + ], + "genres": null, + "mood": null, + "style": null, + "copyright": null, + "lyrics": null, + "label": null, + "links": null, + "performers": null, + "preview": null, + "popularity": null, + "release_date": null, + "languages": null, + "chapters": null, + "last_refresh": null + }, + "favorite": false, + "position": null, + "publisher": "Test Publisher", + "total_episodes": null + }, + { + "item_id": "7", + "provider": "library", + "name": "Test Podcast 1", + "version": "", + "sort_name": "test podcast 1", + "uri": "library://podcast/7", + "external_ids": [], + "is_playable": true, + "media_type": "podcast", + "provider_mappings": [ + { + "item_id": "1", + "provider_domain": "test", + "provider_instance": "test", + "available": true, + "audio_format": { + "content_type": "?", + "codec_type": "?", + "sample_rate": 44100, + "bit_depth": 16, + "channels": 2, + "output_format_str": "?", + "bit_rate": 0 + }, + "url": null, + "details": null + } + ], + "metadata": { + "description": null, + "review": null, + "explicit": null, + "images": [ + { + "type": "thumb", + "path": "logo.png", + "provider": "builtin", + "remotely_accessible": false + } + ], + "genres": null, + "mood": null, + "style": null, + "copyright": null, + "lyrics": null, + "label": null, + "links": null, + "performers": null, + "preview": null, + "popularity": null, + "release_date": null, + "languages": null, + "chapters": null, + "last_refresh": null + }, + "favorite": false, + "position": null, + "publisher": "Test Publisher", + "total_episodes": null + }, + { + "item_id": "8", + "provider": "library", + "name": "Test Podcast 2", + "version": "", + "sort_name": "test podcast 2", + "uri": "library://podcast/8", + "external_ids": [], + "is_playable": true, + "media_type": "podcast", + "provider_mappings": [ + { + "item_id": "2", + "provider_domain": "test", + "provider_instance": "test", + "available": true, + "audio_format": { + "content_type": "?", + "codec_type": "?", + "sample_rate": 44100, + "bit_depth": 16, + "channels": 2, + "output_format_str": "?", + "bit_rate": 0 + }, + "url": null, + "details": null + } + ], + "metadata": { + "description": null, + "review": null, + "explicit": null, + "images": [ + { + "type": "thumb", + "path": "logo.png", + "provider": "builtin", + "remotely_accessible": false + } + ], + "genres": null, + "mood": null, + "style": null, + "copyright": null, + "lyrics": null, + "label": null, + "links": null, + "performers": null, + "preview": null, + "popularity": null, + "release_date": null, + "languages": null, + "chapters": null, + "last_refresh": null + }, + "favorite": false, + "position": null, + "publisher": "Test Publisher", + "total_episodes": null + }, + { + "item_id": "9", + "provider": "library", + "name": "Test Podcast 3", + "version": "", + "sort_name": "test podcast 3", + "uri": "library://podcast/9", + "external_ids": [], + "is_playable": true, + "media_type": "podcast", + "provider_mappings": [ + { + "item_id": "3", + "provider_domain": "test", + "provider_instance": "test", + "available": true, + "audio_format": { + "content_type": "?", + "codec_type": "?", + "sample_rate": 44100, + "bit_depth": 16, + "channels": 2, + "output_format_str": "?", + "bit_rate": 0 + }, + "url": null, + "details": null + } + ], + "metadata": { + "description": null, + "review": null, + "explicit": null, + "images": [ + { + "type": "thumb", + "path": "logo.png", + "provider": "builtin", + "remotely_accessible": false + } + ], + "genres": null, + "mood": null, + "style": null, + "copyright": null, + "lyrics": null, + "label": null, + "links": null, + "performers": null, + "preview": null, + "popularity": null, + "release_date": null, + "languages": null, + "chapters": null, + "last_refresh": null + }, + "favorite": false, + "position": null, + "publisher": "Test Publisher", + "total_episodes": null + }, + { + "item_id": "10", + "provider": "library", + "name": "Test Podcast 4", + "version": "", + "sort_name": "test podcast 4", + "uri": "library://podcast/10", + "external_ids": [], + "is_playable": true, + "media_type": "podcast", + "provider_mappings": [ + { + "item_id": "4", + "provider_domain": "test", + "provider_instance": "test", + "available": true, + "audio_format": { + "content_type": "?", + "codec_type": "?", + "sample_rate": 44100, + "bit_depth": 16, + "channels": 2, + "output_format_str": "?", + "bit_rate": 0 + }, + "url": null, + "details": null + } + ], + "metadata": { + "description": null, + "review": null, + "explicit": null, + "images": [ + { + "type": "thumb", + "path": "logo.png", + "provider": "builtin", + "remotely_accessible": false + } + ], + "genres": null, + "mood": null, + "style": null, + "copyright": null, + "lyrics": null, + "label": null, + "links": null, + "performers": null, + "preview": null, + "popularity": null, + "release_date": null, + "languages": null, + "chapters": null, + "last_refresh": null + }, + "favorite": false, + "position": null, + "publisher": "Test Publisher", + "total_episodes": null + } + ] +} diff --git a/tests/components/music_assistant/snapshots/test_actions.ambr b/tests/components/music_assistant/snapshots/test_actions.ambr index 6c30ffc512c..32c8776c953 100644 --- a/tests/components/music_assistant/snapshots/test_actions.ambr +++ b/tests/components/music_assistant/snapshots/test_actions.ambr @@ -1,5 +1,195 @@ # serializer version: 1 -# name: test_get_library_action +# name: test_get_library_action[album] + dict({ + 'items': list([ + dict({ + 'artists': list([ + dict({ + 'image': None, + 'media_type': , + 'name': 'A Space Love Adventure', + 'uri': 'library://artist/289', + 'version': '', + }), + ]), + 'image': None, + 'media_type': , + 'name': 'Synth Punk EP', + 'uri': 'library://album/396', + 'version': '', + }), + dict({ + 'artists': list([ + dict({ + 'image': None, + 'media_type': , + 'name': 'Various Artists', + 'uri': 'library://artist/96', + 'version': '', + }), + ]), + 'image': None, + 'media_type': , + 'name': 'Synthwave (The 80S Revival)', + 'uri': 'library://album/95', + 'version': 'The 80S Revival', + }), + ]), + 'limit': 25, + 'media_type': , + 'offset': 0, + 'order_by': 'name', + }) +# --- +# name: test_get_library_action[artist] + dict({ + 'items': list([ + dict({ + 'image': None, + 'media_type': , + 'name': 'W O L F C L U B', + 'uri': 'library://artist/127', + 'version': '', + }), + ]), + 'limit': 25, + 'media_type': , + 'offset': 0, + 'order_by': 'name', + }) +# --- +# name: test_get_library_action[audiobook] + dict({ + 'items': list([ + dict({ + 'image': None, + 'media_type': , + 'name': 'Test Audiobook', + 'uri': 'library://audiobook/1', + 'version': '', + }), + dict({ + 'image': None, + 'media_type': , + 'name': 'Test Audiobook 0', + 'uri': 'library://audiobook/11', + 'version': '', + }), + dict({ + 'image': None, + 'media_type': , + 'name': 'Test Audiobook 1', + 'uri': 'library://audiobook/12', + 'version': '', + }), + dict({ + 'image': None, + 'media_type': , + 'name': 'Test Audiobook 2', + 'uri': 'library://audiobook/13', + 'version': '', + }), + dict({ + 'image': None, + 'media_type': , + 'name': 'Test Audiobook 3', + 'uri': 'library://audiobook/14', + 'version': '', + }), + dict({ + 'image': None, + 'media_type': , + 'name': 'Test Audiobook 4', + 'uri': 'library://audiobook/15', + 'version': '', + }), + ]), + 'limit': 25, + 'media_type': , + 'offset': 0, + 'order_by': 'name', + }) +# --- +# name: test_get_library_action[playlist] + dict({ + 'items': list([ + dict({ + 'image': None, + 'media_type': , + 'name': '1970s Rock Hits', + 'uri': 'library://playlist/40', + 'version': '', + }), + ]), + 'limit': 25, + 'media_type': , + 'offset': 0, + 'order_by': 'name', + }) +# --- +# name: test_get_library_action[podcast] + dict({ + 'items': list([ + dict({ + 'image': None, + 'media_type': , + 'name': 'Test Podcast 0', + 'uri': 'library://podcast/6', + 'version': '', + }), + dict({ + 'image': None, + 'media_type': , + 'name': 'Test Podcast 1', + 'uri': 'library://podcast/7', + 'version': '', + }), + dict({ + 'image': None, + 'media_type': , + 'name': 'Test Podcast 2', + 'uri': 'library://podcast/8', + 'version': '', + }), + dict({ + 'image': None, + 'media_type': , + 'name': 'Test Podcast 3', + 'uri': 'library://podcast/9', + 'version': '', + }), + dict({ + 'image': None, + 'media_type': , + 'name': 'Test Podcast 4', + 'uri': 'library://podcast/10', + 'version': '', + }), + ]), + 'limit': 25, + 'media_type': , + 'offset': 0, + 'order_by': 'name', + }) +# --- +# name: test_get_library_action[radio] + dict({ + 'items': list([ + dict({ + 'image': None, + 'media_type': , + 'name': 'fm4 | ORF | HQ', + 'uri': 'library://radio/1', + 'version': '', + }), + ]), + 'limit': 25, + 'media_type': , + 'offset': 0, + 'order_by': 'name', + }) +# --- +# name: test_get_library_action[track] dict({ 'items': list([ dict({ @@ -192,8 +382,12 @@ ]), 'artists': list([ ]), + 'audiobooks': list([ + ]), 'playlists': list([ ]), + 'podcasts': list([ + ]), 'radio': list([ ]), 'tracks': list([ diff --git a/tests/components/music_assistant/test_actions.py b/tests/components/music_assistant/test_actions.py index 4d3917091c1..ba8b1acdeac 100644 --- a/tests/components/music_assistant/test_actions.py +++ b/tests/components/music_assistant/test_actions.py @@ -3,6 +3,7 @@ from unittest.mock import AsyncMock, MagicMock from music_assistant_models.media_items import SearchResults +import pytest from syrupy import SnapshotAssertion from homeassistant.components.music_assistant.actions import ( @@ -47,9 +48,22 @@ async def test_search_action( assert response == snapshot +@pytest.mark.parametrize( + "media_type", + [ + "artist", + "album", + "track", + "playlist", + "audiobook", + "podcast", + "radio", + ], +) async def test_get_library_action( hass: HomeAssistant, music_assistant_client: MagicMock, + media_type: str, snapshot: SnapshotAssertion, ) -> None: """Test music assistant get_library action.""" @@ -60,7 +74,7 @@ async def test_get_library_action( { ATTR_CONFIG_ENTRY_ID: entry.entry_id, ATTR_FAVORITE: False, - ATTR_MEDIA_TYPE: "track", + ATTR_MEDIA_TYPE: media_type, }, blocking=True, return_response=True, From 0891669aee47e042788860dcafa96effb64aa688 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Fri, 28 Feb 2025 09:25:38 -0600 Subject: [PATCH 1948/3148] Move climate intent to homeassistant integration (#139371) * Move climate intent to homeassistant integration * Move get temperature intent to intent integration * Clean up old test --- homeassistant/components/climate/__init__.py | 1 - homeassistant/components/climate/const.py | 1 - homeassistant/components/climate/intent.py | 43 +- homeassistant/components/intent/__init__.py | 44 ++ homeassistant/helpers/intent.py | 1 + homeassistant/helpers/llm.py | 11 +- tests/components/climate/test_intent.py | 330 -------------- tests/components/intent/test_temperature.py | 456 +++++++++++++++++++ 8 files changed, 508 insertions(+), 379 deletions(-) create mode 100644 tests/components/intent/test_temperature.py diff --git a/homeassistant/components/climate/__init__.py b/homeassistant/components/climate/__init__.py index 3ea0f887e76..287a2397121 100644 --- a/homeassistant/components/climate/__init__.py +++ b/homeassistant/components/climate/__init__.py @@ -68,7 +68,6 @@ from .const import ( # noqa: F401 FAN_ON, FAN_TOP, HVAC_MODES, - INTENT_GET_TEMPERATURE, INTENT_SET_TEMPERATURE, PRESET_ACTIVITY, PRESET_AWAY, diff --git a/homeassistant/components/climate/const.py b/homeassistant/components/climate/const.py index d347ccbbb29..ecc0066cd93 100644 --- a/homeassistant/components/climate/const.py +++ b/homeassistant/components/climate/const.py @@ -126,7 +126,6 @@ DEFAULT_MAX_HUMIDITY = 99 DOMAIN = "climate" -INTENT_GET_TEMPERATURE = "HassClimateGetTemperature" INTENT_SET_TEMPERATURE = "HassClimateSetTemperature" SERVICE_SET_AUX_HEAT = "set_aux_heat" diff --git a/homeassistant/components/climate/intent.py b/homeassistant/components/climate/intent.py index 9837a326188..7691a2db0f1 100644 --- a/homeassistant/components/climate/intent.py +++ b/homeassistant/components/climate/intent.py @@ -1,4 +1,4 @@ -"""Intents for the client integration.""" +"""Intents for the climate integration.""" from __future__ import annotations @@ -11,7 +11,6 @@ from homeassistant.helpers import config_validation as cv, intent from . import ( ATTR_TEMPERATURE, DOMAIN, - INTENT_GET_TEMPERATURE, INTENT_SET_TEMPERATURE, SERVICE_SET_TEMPERATURE, ClimateEntityFeature, @@ -20,49 +19,9 @@ from . import ( async def async_setup_intents(hass: HomeAssistant) -> None: """Set up the climate intents.""" - intent.async_register(hass, GetTemperatureIntent()) intent.async_register(hass, SetTemperatureIntent()) -class GetTemperatureIntent(intent.IntentHandler): - """Handle GetTemperature intents.""" - - intent_type = INTENT_GET_TEMPERATURE - description = "Gets the current temperature of a climate device or entity" - slot_schema = { - vol.Optional("area"): intent.non_empty_string, - vol.Optional("name"): intent.non_empty_string, - } - platforms = {DOMAIN} - - async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse: - """Handle the intent.""" - hass = intent_obj.hass - slots = self.async_validate_slots(intent_obj.slots) - - name: str | None = None - if "name" in slots: - name = slots["name"]["value"] - - area: str | None = None - if "area" in slots: - area = slots["area"]["value"] - - match_constraints = intent.MatchTargetsConstraints( - name=name, area_name=area, domains=[DOMAIN], assistant=intent_obj.assistant - ) - match_result = intent.async_match_targets(hass, match_constraints) - if not match_result.is_match: - raise intent.MatchFailedError( - result=match_result, constraints=match_constraints - ) - - response = intent_obj.create_response() - response.response_type = intent.IntentResponseType.QUERY_ANSWER - response.async_set_states(matched_states=match_result.states) - return response - - class SetTemperatureIntent(intent.IntentHandler): """Handle SetTemperature intents.""" diff --git a/homeassistant/components/intent/__init__.py b/homeassistant/components/intent/__init__.py index a1451f8fcca..2f9587e2173 100644 --- a/homeassistant/components/intent/__init__.py +++ b/homeassistant/components/intent/__init__.py @@ -9,6 +9,7 @@ from aiohttp import web import voluptuous as vol from homeassistant.components import http +from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN from homeassistant.components.cover import ( ATTR_POSITION, DOMAIN as COVER_DOMAIN, @@ -140,6 +141,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: intent.async_register(hass, GetCurrentDateIntentHandler()) intent.async_register(hass, GetCurrentTimeIntentHandler()) intent.async_register(hass, RespondIntentHandler()) + intent.async_register(hass, GetTemperatureIntent()) return True @@ -444,6 +446,48 @@ class RespondIntentHandler(intent.IntentHandler): return response +class GetTemperatureIntent(intent.IntentHandler): + """Handle GetTemperature intents.""" + + intent_type = intent.INTENT_GET_TEMPERATURE + description = "Gets the current temperature of a climate device or entity" + slot_schema = { + vol.Optional("area"): intent.non_empty_string, + vol.Optional("name"): intent.non_empty_string, + } + platforms = {CLIMATE_DOMAIN} + + async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse: + """Handle the intent.""" + hass = intent_obj.hass + slots = self.async_validate_slots(intent_obj.slots) + + name: str | None = None + if "name" in slots: + name = slots["name"]["value"] + + area: str | None = None + if "area" in slots: + area = slots["area"]["value"] + + match_constraints = intent.MatchTargetsConstraints( + name=name, + area_name=area, + domains=[CLIMATE_DOMAIN], + assistant=intent_obj.assistant, + ) + match_result = intent.async_match_targets(hass, match_constraints) + if not match_result.is_match: + raise intent.MatchFailedError( + result=match_result, constraints=match_constraints + ) + + response = intent_obj.create_response() + response.response_type = intent.IntentResponseType.QUERY_ANSWER + response.async_set_states(matched_states=match_result.states) + return response + + async def _async_process_intent( hass: HomeAssistant, domain: str, platform: IntentPlatformProtocol ) -> None: diff --git a/homeassistant/helpers/intent.py b/homeassistant/helpers/intent.py index c93545ed414..cecb84d0373 100644 --- a/homeassistant/helpers/intent.py +++ b/homeassistant/helpers/intent.py @@ -59,6 +59,7 @@ INTENT_GET_CURRENT_DATE = "HassGetCurrentDate" INTENT_GET_CURRENT_TIME = "HassGetCurrentTime" INTENT_RESPOND = "HassRespond" INTENT_BROADCAST = "HassBroadcast" +INTENT_GET_TEMPERATURE = "HassClimateGetTemperature" SLOT_SCHEMA = vol.Schema({}, extra=vol.ALLOW_EXTRA) diff --git a/homeassistant/helpers/llm.py b/homeassistant/helpers/llm.py index 2ef785e7f71..4ad2bdd6563 100644 --- a/homeassistant/helpers/llm.py +++ b/homeassistant/helpers/llm.py @@ -19,7 +19,6 @@ from homeassistant.components.calendar import ( DOMAIN as CALENDAR_DOMAIN, SERVICE_GET_EVENTS, ) -from homeassistant.components.climate import INTENT_GET_TEMPERATURE from homeassistant.components.cover import INTENT_CLOSE_COVER, INTENT_OPEN_COVER from homeassistant.components.homeassistant import async_should_expose from homeassistant.components.intent import async_device_supports_timers @@ -285,7 +284,7 @@ class AssistAPI(API): """API exposing Assist API to LLMs.""" IGNORE_INTENTS = { - INTENT_GET_TEMPERATURE, + intent.INTENT_GET_TEMPERATURE, INTENT_GET_WEATHER, INTENT_OPEN_COVER, # deprecated INTENT_CLOSE_COVER, # deprecated @@ -530,9 +529,11 @@ def _get_exposed_entities( info["areas"] = ", ".join(area_names) if attributes := { - attr_name: str(attr_value) - if isinstance(attr_value, (Enum, Decimal, int)) - else attr_value + attr_name: ( + str(attr_value) + if isinstance(attr_value, (Enum, Decimal, int)) + else attr_value + ) for attr_name, attr_value in state.attributes.items() if attr_name in interesting_attributes }: diff --git a/tests/components/climate/test_intent.py b/tests/components/climate/test_intent.py index 65d607e618b..4ce06199eb8 100644 --- a/tests/components/climate/test_intent.py +++ b/tests/components/climate/test_intent.py @@ -14,7 +14,6 @@ from homeassistant.components.climate import ( HVACMode, intent as climate_intent, ) -from homeassistant.components.homeassistant.exposed_entities import async_expose_entity from homeassistant.config_entries import ConfigEntry, ConfigFlow from homeassistant.const import Platform, UnitOfTemperature from homeassistant.core import HomeAssistant @@ -131,335 +130,6 @@ class MockClimateEntityNoSetTemperature(ClimateEntity): _attr_hvac_modes = [HVACMode.OFF, HVACMode.HEAT] -async def test_get_temperature( - hass: HomeAssistant, - area_registry: ar.AreaRegistry, - entity_registry: er.EntityRegistry, -) -> None: - """Test HassClimateGetTemperature intent.""" - assert await async_setup_component(hass, "homeassistant", {}) - await climate_intent.async_setup_intents(hass) - - climate_1 = MockClimateEntity() - climate_1._attr_name = "Climate 1" - climate_1._attr_unique_id = "1234" - climate_1._attr_current_temperature = 10.0 - entity_registry.async_get_or_create( - DOMAIN, "test", "1234", suggested_object_id="climate_1" - ) - - climate_2 = MockClimateEntity() - climate_2._attr_name = "Climate 2" - climate_2._attr_unique_id = "5678" - climate_2._attr_current_temperature = 22.0 - entity_registry.async_get_or_create( - DOMAIN, "test", "5678", suggested_object_id="climate_2" - ) - - await create_mock_platform(hass, [climate_1, climate_2]) - - # Add climate entities to different areas: - # climate_1 => living room - # climate_2 => bedroom - # nothing in office - living_room_area = area_registry.async_create(name="Living Room") - bedroom_area = area_registry.async_create(name="Bedroom") - office_area = area_registry.async_create(name="Office") - - entity_registry.async_update_entity( - climate_1.entity_id, area_id=living_room_area.id - ) - entity_registry.async_update_entity(climate_2.entity_id, area_id=bedroom_area.id) - - # First climate entity will be selected (no area) - response = await intent.async_handle( - hass, - "test", - climate_intent.INTENT_GET_TEMPERATURE, - {}, - assistant=conversation.DOMAIN, - ) - assert response.response_type == intent.IntentResponseType.QUERY_ANSWER - assert response.matched_states - assert response.matched_states[0].entity_id == climate_1.entity_id - state = response.matched_states[0] - assert state.attributes["current_temperature"] == 10.0 - - # Select by area (climate_2) - response = await intent.async_handle( - hass, - "test", - climate_intent.INTENT_GET_TEMPERATURE, - {"area": {"value": bedroom_area.name}}, - assistant=conversation.DOMAIN, - ) - assert response.response_type == intent.IntentResponseType.QUERY_ANSWER - assert len(response.matched_states) == 1 - assert response.matched_states[0].entity_id == climate_2.entity_id - state = response.matched_states[0] - assert state.attributes["current_temperature"] == 22.0 - - # Select by name (climate_2) - response = await intent.async_handle( - hass, - "test", - climate_intent.INTENT_GET_TEMPERATURE, - {"name": {"value": "Climate 2"}}, - assistant=conversation.DOMAIN, - ) - assert response.response_type == intent.IntentResponseType.QUERY_ANSWER - assert len(response.matched_states) == 1 - assert response.matched_states[0].entity_id == climate_2.entity_id - state = response.matched_states[0] - assert state.attributes["current_temperature"] == 22.0 - - # Check area with no climate entities - with pytest.raises(intent.MatchFailedError) as error: - response = await intent.async_handle( - hass, - "test", - climate_intent.INTENT_GET_TEMPERATURE, - {"area": {"value": office_area.name}}, - assistant=conversation.DOMAIN, - ) - - # Exception should contain details of what we tried to match - assert isinstance(error.value, intent.MatchFailedError) - assert error.value.result.no_match_reason == intent.MatchFailedReason.AREA - constraints = error.value.constraints - assert constraints.name is None - assert constraints.area_name == office_area.name - assert constraints.domains and (set(constraints.domains) == {DOMAIN}) - assert constraints.device_classes is None - - # Check wrong name - with pytest.raises(intent.MatchFailedError) as error: - response = await intent.async_handle( - hass, - "test", - climate_intent.INTENT_GET_TEMPERATURE, - {"name": {"value": "Does not exist"}}, - ) - - assert isinstance(error.value, intent.MatchFailedError) - assert error.value.result.no_match_reason == intent.MatchFailedReason.NAME - constraints = error.value.constraints - assert constraints.name == "Does not exist" - assert constraints.area_name is None - assert constraints.domains and (set(constraints.domains) == {DOMAIN}) - assert constraints.device_classes is None - - # Check wrong name with area - with pytest.raises(intent.MatchFailedError) as error: - response = await intent.async_handle( - hass, - "test", - climate_intent.INTENT_GET_TEMPERATURE, - {"name": {"value": "Climate 1"}, "area": {"value": bedroom_area.name}}, - ) - - assert isinstance(error.value, intent.MatchFailedError) - assert error.value.result.no_match_reason == intent.MatchFailedReason.AREA - constraints = error.value.constraints - assert constraints.name == "Climate 1" - assert constraints.area_name == bedroom_area.name - assert constraints.domains and (set(constraints.domains) == {DOMAIN}) - assert constraints.device_classes is None - - -async def test_get_temperature_no_entities( - hass: HomeAssistant, -) -> None: - """Test HassClimateGetTemperature intent with no climate entities.""" - assert await async_setup_component(hass, "homeassistant", {}) - await climate_intent.async_setup_intents(hass) - - await create_mock_platform(hass, []) - - with pytest.raises(intent.MatchFailedError) as err: - await intent.async_handle( - hass, - "test", - climate_intent.INTENT_GET_TEMPERATURE, - {}, - assistant=conversation.DOMAIN, - ) - assert err.value.result.no_match_reason == intent.MatchFailedReason.DOMAIN - - -async def test_not_exposed( - hass: HomeAssistant, - area_registry: ar.AreaRegistry, - entity_registry: er.EntityRegistry, -) -> None: - """Test HassClimateGetTemperature intent when entities aren't exposed.""" - assert await async_setup_component(hass, "homeassistant", {}) - await climate_intent.async_setup_intents(hass) - - climate_1 = MockClimateEntity() - climate_1._attr_name = "Climate 1" - climate_1._attr_unique_id = "1234" - climate_1._attr_current_temperature = 10.0 - entity_registry.async_get_or_create( - DOMAIN, "test", "1234", suggested_object_id="climate_1" - ) - - climate_2 = MockClimateEntity() - climate_2._attr_name = "Climate 2" - climate_2._attr_unique_id = "5678" - climate_2._attr_current_temperature = 22.0 - entity_registry.async_get_or_create( - DOMAIN, "test", "5678", suggested_object_id="climate_2" - ) - - await create_mock_platform(hass, [climate_1, climate_2]) - - # Add climate entities to same area - living_room_area = area_registry.async_create(name="Living Room") - bedroom_area = area_registry.async_create(name="Bedroom") - entity_registry.async_update_entity( - climate_1.entity_id, area_id=living_room_area.id - ) - entity_registry.async_update_entity( - climate_2.entity_id, area_id=living_room_area.id - ) - - # Should fail with empty name - with pytest.raises(intent.InvalidSlotInfo): - await intent.async_handle( - hass, - "test", - climate_intent.INTENT_GET_TEMPERATURE, - {"name": {"value": ""}}, - assistant=conversation.DOMAIN, - ) - - # Should fail with empty area - with pytest.raises(intent.InvalidSlotInfo): - await intent.async_handle( - hass, - "test", - climate_intent.INTENT_GET_TEMPERATURE, - {"area": {"value": ""}}, - assistant=conversation.DOMAIN, - ) - - # Expose second, hide first - async_expose_entity(hass, conversation.DOMAIN, climate_1.entity_id, False) - async_expose_entity(hass, conversation.DOMAIN, climate_2.entity_id, True) - - # Second climate entity is exposed - response = await intent.async_handle( - hass, - "test", - climate_intent.INTENT_GET_TEMPERATURE, - {}, - assistant=conversation.DOMAIN, - ) - assert response.response_type == intent.IntentResponseType.QUERY_ANSWER - assert len(response.matched_states) == 1 - assert response.matched_states[0].entity_id == climate_2.entity_id - - # Using the area should work - response = await intent.async_handle( - hass, - "test", - climate_intent.INTENT_GET_TEMPERATURE, - {"area": {"value": living_room_area.name}}, - assistant=conversation.DOMAIN, - ) - assert response.response_type == intent.IntentResponseType.QUERY_ANSWER - assert len(response.matched_states) == 1 - assert response.matched_states[0].entity_id == climate_2.entity_id - - # Using the name of the exposed entity should work - response = await intent.async_handle( - hass, - "test", - climate_intent.INTENT_GET_TEMPERATURE, - {"name": {"value": climate_2.name}}, - assistant=conversation.DOMAIN, - ) - assert response.response_type == intent.IntentResponseType.QUERY_ANSWER - assert len(response.matched_states) == 1 - assert response.matched_states[0].entity_id == climate_2.entity_id - - # Using the name of the *unexposed* entity should fail - with pytest.raises(intent.MatchFailedError) as err: - await intent.async_handle( - hass, - "test", - climate_intent.INTENT_GET_TEMPERATURE, - {"name": {"value": climate_1.name}}, - assistant=conversation.DOMAIN, - ) - assert err.value.result.no_match_reason == intent.MatchFailedReason.ASSISTANT - - # Expose first, hide second - async_expose_entity(hass, conversation.DOMAIN, climate_1.entity_id, True) - async_expose_entity(hass, conversation.DOMAIN, climate_2.entity_id, False) - - # Second climate entity is exposed - response = await intent.async_handle( - hass, - "test", - climate_intent.INTENT_GET_TEMPERATURE, - {}, - assistant=conversation.DOMAIN, - ) - assert response.response_type == intent.IntentResponseType.QUERY_ANSWER - assert len(response.matched_states) == 1 - assert response.matched_states[0].entity_id == climate_1.entity_id - - # Wrong area name - with pytest.raises(intent.MatchFailedError) as err: - await intent.async_handle( - hass, - "test", - climate_intent.INTENT_GET_TEMPERATURE, - {"area": {"value": bedroom_area.name}}, - assistant=conversation.DOMAIN, - ) - assert err.value.result.no_match_reason == intent.MatchFailedReason.AREA - - # Neither are exposed - async_expose_entity(hass, conversation.DOMAIN, climate_1.entity_id, False) - async_expose_entity(hass, conversation.DOMAIN, climate_2.entity_id, False) - - with pytest.raises(intent.MatchFailedError) as err: - await intent.async_handle( - hass, - "test", - climate_intent.INTENT_GET_TEMPERATURE, - {}, - assistant=conversation.DOMAIN, - ) - assert err.value.result.no_match_reason == intent.MatchFailedReason.ASSISTANT - - # Should fail with area - with pytest.raises(intent.MatchFailedError) as err: - await intent.async_handle( - hass, - "test", - climate_intent.INTENT_GET_TEMPERATURE, - {"area": {"value": living_room_area.name}}, - assistant=conversation.DOMAIN, - ) - assert err.value.result.no_match_reason == intent.MatchFailedReason.ASSISTANT - - # Should fail with both names - for name in (climate_1.name, climate_2.name): - with pytest.raises(intent.MatchFailedError) as err: - await intent.async_handle( - hass, - "test", - climate_intent.INTENT_GET_TEMPERATURE, - {"name": {"value": name}}, - assistant=conversation.DOMAIN, - ) - assert err.value.result.no_match_reason == intent.MatchFailedReason.ASSISTANT - - async def test_set_temperature( hass: HomeAssistant, area_registry: ar.AreaRegistry, diff --git a/tests/components/intent/test_temperature.py b/tests/components/intent/test_temperature.py new file mode 100644 index 00000000000..0279fa44b28 --- /dev/null +++ b/tests/components/intent/test_temperature.py @@ -0,0 +1,456 @@ +"""Test temperature intents.""" + +from collections.abc import Generator +from typing import Any + +import pytest + +from homeassistant.components import conversation +from homeassistant.components.climate import ( + ATTR_TEMPERATURE, + DOMAIN as CLIMATE_DOMAIN, + ClimateEntity, + ClimateEntityFeature, + HVACMode, +) +from homeassistant.components.homeassistant.exposed_entities import async_expose_entity +from homeassistant.config_entries import ConfigEntry, ConfigFlow +from homeassistant.const import Platform, UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.helpers import area_registry as ar, entity_registry as er, intent +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.setup import async_setup_component + +from tests.common import ( + MockConfigEntry, + MockModule, + MockPlatform, + mock_config_flow, + mock_integration, + mock_platform, +) + +TEST_DOMAIN = "test" + + +class MockFlow(ConfigFlow): + """Test flow.""" + + +@pytest.fixture(autouse=True) +def config_flow_fixture(hass: HomeAssistant) -> Generator[None]: + """Mock config flow.""" + mock_platform(hass, f"{TEST_DOMAIN}.config_flow") + + with mock_config_flow(TEST_DOMAIN, MockFlow): + yield + + +@pytest.fixture(autouse=True) +def mock_setup_integration(hass: HomeAssistant) -> None: + """Fixture to set up a mock integration.""" + + async def async_setup_entry_init( + hass: HomeAssistant, config_entry: ConfigEntry + ) -> bool: + """Set up test config entry.""" + await hass.config_entries.async_forward_entry_setups( + config_entry, [CLIMATE_DOMAIN] + ) + return True + + async def async_unload_entry_init( + hass: HomeAssistant, + config_entry: ConfigEntry, + ) -> bool: + await hass.config_entries.async_unload_platforms(config_entry, [Platform.TODO]) + return True + + mock_platform(hass, f"{TEST_DOMAIN}.config_flow") + mock_integration( + hass, + MockModule( + TEST_DOMAIN, + async_setup_entry=async_setup_entry_init, + async_unload_entry=async_unload_entry_init, + ), + ) + + +async def create_mock_platform( + hass: HomeAssistant, + entities: list[ClimateEntity], +) -> MockConfigEntry: + """Create a todo platform with the specified entities.""" + + async def async_setup_entry_platform( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, + ) -> None: + """Set up test event platform via config entry.""" + async_add_entities(entities) + + mock_platform( + hass, + f"{TEST_DOMAIN}.{CLIMATE_DOMAIN}", + MockPlatform(async_setup_entry=async_setup_entry_platform), + ) + + config_entry = MockConfigEntry(domain=TEST_DOMAIN) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + return config_entry + + +class MockClimateEntity(ClimateEntity): + """Mock Climate device to use in tests.""" + + _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_hvac_mode = HVACMode.OFF + _attr_hvac_modes = [HVACMode.OFF, HVACMode.HEAT] + _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set the thermostat temperature.""" + value = kwargs[ATTR_TEMPERATURE] + self._attr_target_temperature = value + + +class MockClimateEntityNoSetTemperature(ClimateEntity): + """Mock Climate device to use in tests.""" + + _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_hvac_mode = HVACMode.OFF + _attr_hvac_modes = [HVACMode.OFF, HVACMode.HEAT] + + +async def test_get_temperature( + hass: HomeAssistant, + area_registry: ar.AreaRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test HassClimateGetTemperature intent.""" + assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component(hass, "intent", {}) + + climate_1 = MockClimateEntity() + climate_1._attr_name = "Climate 1" + climate_1._attr_unique_id = "1234" + climate_1._attr_current_temperature = 10.0 + entity_registry.async_get_or_create( + CLIMATE_DOMAIN, "test", "1234", suggested_object_id="climate_1" + ) + + climate_2 = MockClimateEntity() + climate_2._attr_name = "Climate 2" + climate_2._attr_unique_id = "5678" + climate_2._attr_current_temperature = 22.0 + entity_registry.async_get_or_create( + CLIMATE_DOMAIN, "test", "5678", suggested_object_id="climate_2" + ) + + await create_mock_platform(hass, [climate_1, climate_2]) + + # Add climate entities to different areas: + # climate_1 => living room + # climate_2 => bedroom + # nothing in office + living_room_area = area_registry.async_create(name="Living Room") + bedroom_area = area_registry.async_create(name="Bedroom") + office_area = area_registry.async_create(name="Office") + + entity_registry.async_update_entity( + climate_1.entity_id, area_id=living_room_area.id + ) + entity_registry.async_update_entity(climate_2.entity_id, area_id=bedroom_area.id) + + # First climate entity will be selected (no area) + response = await intent.async_handle( + hass, + "test", + intent.INTENT_GET_TEMPERATURE, + {}, + assistant=conversation.DOMAIN, + ) + assert response.response_type == intent.IntentResponseType.QUERY_ANSWER + assert response.matched_states + assert response.matched_states[0].entity_id == climate_1.entity_id + state = response.matched_states[0] + assert state.attributes["current_temperature"] == 10.0 + + # Select by area (climate_2) + response = await intent.async_handle( + hass, + "test", + intent.INTENT_GET_TEMPERATURE, + {"area": {"value": bedroom_area.name}}, + assistant=conversation.DOMAIN, + ) + assert response.response_type == intent.IntentResponseType.QUERY_ANSWER + assert len(response.matched_states) == 1 + assert response.matched_states[0].entity_id == climate_2.entity_id + state = response.matched_states[0] + assert state.attributes["current_temperature"] == 22.0 + + # Select by name (climate_2) + response = await intent.async_handle( + hass, + "test", + intent.INTENT_GET_TEMPERATURE, + {"name": {"value": "Climate 2"}}, + assistant=conversation.DOMAIN, + ) + assert response.response_type == intent.IntentResponseType.QUERY_ANSWER + assert len(response.matched_states) == 1 + assert response.matched_states[0].entity_id == climate_2.entity_id + state = response.matched_states[0] + assert state.attributes["current_temperature"] == 22.0 + + # Check area with no climate entities + with pytest.raises(intent.MatchFailedError) as error: + response = await intent.async_handle( + hass, + "test", + intent.INTENT_GET_TEMPERATURE, + {"area": {"value": office_area.name}}, + assistant=conversation.DOMAIN, + ) + + # Exception should contain details of what we tried to match + assert isinstance(error.value, intent.MatchFailedError) + assert error.value.result.no_match_reason == intent.MatchFailedReason.AREA + constraints = error.value.constraints + assert constraints.name is None + assert constraints.area_name == office_area.name + assert constraints.domains and (set(constraints.domains) == {CLIMATE_DOMAIN}) + assert constraints.device_classes is None + + # Check wrong name + with pytest.raises(intent.MatchFailedError) as error: + response = await intent.async_handle( + hass, + "test", + intent.INTENT_GET_TEMPERATURE, + {"name": {"value": "Does not exist"}}, + ) + + assert isinstance(error.value, intent.MatchFailedError) + assert error.value.result.no_match_reason == intent.MatchFailedReason.NAME + constraints = error.value.constraints + assert constraints.name == "Does not exist" + assert constraints.area_name is None + assert constraints.domains and (set(constraints.domains) == {CLIMATE_DOMAIN}) + assert constraints.device_classes is None + + # Check wrong name with area + with pytest.raises(intent.MatchFailedError) as error: + response = await intent.async_handle( + hass, + "test", + intent.INTENT_GET_TEMPERATURE, + {"name": {"value": "Climate 1"}, "area": {"value": bedroom_area.name}}, + ) + + assert isinstance(error.value, intent.MatchFailedError) + assert error.value.result.no_match_reason == intent.MatchFailedReason.AREA + constraints = error.value.constraints + assert constraints.name == "Climate 1" + assert constraints.area_name == bedroom_area.name + assert constraints.domains and (set(constraints.domains) == {CLIMATE_DOMAIN}) + assert constraints.device_classes is None + + +async def test_get_temperature_no_entities( + hass: HomeAssistant, +) -> None: + """Test HassClimateGetTemperature intent with no climate entities.""" + assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component(hass, "intent", {}) + + await create_mock_platform(hass, []) + + with pytest.raises(intent.MatchFailedError) as err: + await intent.async_handle( + hass, + "test", + intent.INTENT_GET_TEMPERATURE, + {}, + assistant=conversation.DOMAIN, + ) + assert err.value.result.no_match_reason == intent.MatchFailedReason.DOMAIN + + +async def test_not_exposed( + hass: HomeAssistant, + area_registry: ar.AreaRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test HassClimateGetTemperature intent when entities aren't exposed.""" + assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component(hass, "intent", {}) + + climate_1 = MockClimateEntity() + climate_1._attr_name = "Climate 1" + climate_1._attr_unique_id = "1234" + climate_1._attr_current_temperature = 10.0 + entity_registry.async_get_or_create( + CLIMATE_DOMAIN, "test", "1234", suggested_object_id="climate_1" + ) + + climate_2 = MockClimateEntity() + climate_2._attr_name = "Climate 2" + climate_2._attr_unique_id = "5678" + climate_2._attr_current_temperature = 22.0 + entity_registry.async_get_or_create( + CLIMATE_DOMAIN, "test", "5678", suggested_object_id="climate_2" + ) + + await create_mock_platform(hass, [climate_1, climate_2]) + + # Add climate entities to same area + living_room_area = area_registry.async_create(name="Living Room") + bedroom_area = area_registry.async_create(name="Bedroom") + entity_registry.async_update_entity( + climate_1.entity_id, area_id=living_room_area.id + ) + entity_registry.async_update_entity( + climate_2.entity_id, area_id=living_room_area.id + ) + + # Should fail with empty name + with pytest.raises(intent.InvalidSlotInfo): + await intent.async_handle( + hass, + "test", + intent.INTENT_GET_TEMPERATURE, + {"name": {"value": ""}}, + assistant=conversation.DOMAIN, + ) + + # Should fail with empty area + with pytest.raises(intent.InvalidSlotInfo): + await intent.async_handle( + hass, + "test", + intent.INTENT_GET_TEMPERATURE, + {"area": {"value": ""}}, + assistant=conversation.DOMAIN, + ) + + # Expose second, hide first + async_expose_entity(hass, conversation.DOMAIN, climate_1.entity_id, False) + async_expose_entity(hass, conversation.DOMAIN, climate_2.entity_id, True) + + # Second climate entity is exposed + response = await intent.async_handle( + hass, + "test", + intent.INTENT_GET_TEMPERATURE, + {}, + assistant=conversation.DOMAIN, + ) + assert response.response_type == intent.IntentResponseType.QUERY_ANSWER + assert len(response.matched_states) == 1 + assert response.matched_states[0].entity_id == climate_2.entity_id + + # Using the area should work + response = await intent.async_handle( + hass, + "test", + intent.INTENT_GET_TEMPERATURE, + {"area": {"value": living_room_area.name}}, + assistant=conversation.DOMAIN, + ) + assert response.response_type == intent.IntentResponseType.QUERY_ANSWER + assert len(response.matched_states) == 1 + assert response.matched_states[0].entity_id == climate_2.entity_id + + # Using the name of the exposed entity should work + response = await intent.async_handle( + hass, + "test", + intent.INTENT_GET_TEMPERATURE, + {"name": {"value": climate_2.name}}, + assistant=conversation.DOMAIN, + ) + assert response.response_type == intent.IntentResponseType.QUERY_ANSWER + assert len(response.matched_states) == 1 + assert response.matched_states[0].entity_id == climate_2.entity_id + + # Using the name of the *unexposed* entity should fail + with pytest.raises(intent.MatchFailedError) as err: + await intent.async_handle( + hass, + "test", + intent.INTENT_GET_TEMPERATURE, + {"name": {"value": climate_1.name}}, + assistant=conversation.DOMAIN, + ) + assert err.value.result.no_match_reason == intent.MatchFailedReason.ASSISTANT + + # Expose first, hide second + async_expose_entity(hass, conversation.DOMAIN, climate_1.entity_id, True) + async_expose_entity(hass, conversation.DOMAIN, climate_2.entity_id, False) + + # Second climate entity is exposed + response = await intent.async_handle( + hass, + "test", + intent.INTENT_GET_TEMPERATURE, + {}, + assistant=conversation.DOMAIN, + ) + assert response.response_type == intent.IntentResponseType.QUERY_ANSWER + assert len(response.matched_states) == 1 + assert response.matched_states[0].entity_id == climate_1.entity_id + + # Wrong area name + with pytest.raises(intent.MatchFailedError) as err: + await intent.async_handle( + hass, + "test", + intent.INTENT_GET_TEMPERATURE, + {"area": {"value": bedroom_area.name}}, + assistant=conversation.DOMAIN, + ) + assert err.value.result.no_match_reason == intent.MatchFailedReason.AREA + + # Neither are exposed + async_expose_entity(hass, conversation.DOMAIN, climate_1.entity_id, False) + async_expose_entity(hass, conversation.DOMAIN, climate_2.entity_id, False) + + with pytest.raises(intent.MatchFailedError) as err: + await intent.async_handle( + hass, + "test", + intent.INTENT_GET_TEMPERATURE, + {}, + assistant=conversation.DOMAIN, + ) + assert err.value.result.no_match_reason == intent.MatchFailedReason.ASSISTANT + + # Should fail with area + with pytest.raises(intent.MatchFailedError) as err: + await intent.async_handle( + hass, + "test", + intent.INTENT_GET_TEMPERATURE, + {"area": {"value": living_room_area.name}}, + assistant=conversation.DOMAIN, + ) + assert err.value.result.no_match_reason == intent.MatchFailedReason.ASSISTANT + + # Should fail with both names + for name in (climate_1.name, climate_2.name): + with pytest.raises(intent.MatchFailedError) as err: + await intent.async_handle( + hass, + "test", + intent.INTENT_GET_TEMPERATURE, + {"name": {"value": name}}, + assistant=conversation.DOMAIN, + ) + assert err.value.result.no_match_reason == intent.MatchFailedReason.ASSISTANT From d8a259044fd07915b029cee986c53b770f82c309 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Thu, 27 Feb 2025 19:23:46 +0100 Subject: [PATCH 1949/3148] Bump aiohomeconnect to 0.15.1 (#139445) --- homeassistant/components/home_connect/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/home_connect/manifest.json b/homeassistant/components/home_connect/manifest.json index 28714b31679..2f5ef4d1b37 100644 --- a/homeassistant/components/home_connect/manifest.json +++ b/homeassistant/components/home_connect/manifest.json @@ -7,6 +7,6 @@ "documentation": "https://www.home-assistant.io/integrations/home_connect", "iot_class": "cloud_push", "loggers": ["aiohomeconnect"], - "requirements": ["aiohomeconnect==0.15.0"], + "requirements": ["aiohomeconnect==0.15.1"], "single_config_entry": true } diff --git a/requirements_all.txt b/requirements_all.txt index 0e47f66c965..f8efdb35022 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -264,7 +264,7 @@ aioharmony==0.4.1 aiohasupervisor==0.3.0 # homeassistant.components.home_connect -aiohomeconnect==0.15.0 +aiohomeconnect==0.15.1 # homeassistant.components.homekit_controller aiohomekit==3.2.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 642494927a8..43fa107bb81 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -249,7 +249,7 @@ aioharmony==0.4.1 aiohasupervisor==0.3.0 # homeassistant.components.home_connect -aiohomeconnect==0.15.0 +aiohomeconnect==0.15.1 # homeassistant.components.homekit_controller aiohomekit==3.2.7 From df4e5a54e36f8a74d3676c3a9fcfa168b18e52c8 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 27 Feb 2025 18:39:18 +0100 Subject: [PATCH 1950/3148] Fix SmartThings diagnostics (#139447) --- homeassistant/components/smartthings/diagnostics.py | 9 ++++----- tests/components/smartthings/test_diagnostics.py | 5 +++++ 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/smartthings/diagnostics.py b/homeassistant/components/smartthings/diagnostics.py index bcf40645d22..fc34415e419 100644 --- a/homeassistant/components/smartthings/diagnostics.py +++ b/homeassistant/components/smartthings/diagnostics.py @@ -21,25 +21,24 @@ async def async_get_device_diagnostics( hass: HomeAssistant, entry: SmartThingsConfigEntry, device: DeviceEntry ) -> dict[str, Any]: """Return diagnostics for a device entry.""" + client = entry.runtime_data.client device_id = next( identifier for identifier in device.identifiers if identifier[0] == DOMAIN - )[0] + )[1] + + device_status = await client.get_device_status(device_id) events: list[DeviceEvent] = [] def register_event(event: DeviceEvent) -> None: events.append(event) - client = entry.runtime_data.client - listener = client.add_device_event_listener(device_id, register_event) await asyncio.sleep(EVENT_WAIT_TIME) listener() - device_status = await client.get_device_status(device_id) - status: dict[str, Any] = {} for component, capabilities in device_status.items(): status[component] = {} diff --git a/tests/components/smartthings/test_diagnostics.py b/tests/components/smartthings/test_diagnostics.py index 22f1c77cdd1..768be155c86 100644 --- a/tests/components/smartthings/test_diagnostics.py +++ b/tests/components/smartthings/test_diagnostics.py @@ -34,6 +34,8 @@ async def test_device( identifiers={(DOMAIN, "96a5ef74-5832-a84b-f1f7-ca799957065d")} ) + mock_smartthings.get_device_status.reset_mock() + with patch("homeassistant.components.smartthings.diagnostics.EVENT_WAIT_TIME", 0.1): diag = await get_diagnostics_for_device( hass, hass_client, mock_config_entry, device @@ -42,3 +44,6 @@ async def test_device( assert diag == snapshot( exclude=props("last_changed", "last_reported", "last_updated") ) + mock_smartthings.get_device_status.assert_called_once_with( + "96a5ef74-5832-a84b-f1f7-ca799957065d" + ) From 46ec3987a87e49b9ecc6d2dd95d6d70a7aac699f Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 27 Feb 2025 20:30:18 +0100 Subject: [PATCH 1951/3148] Bump pysmartthings to 2.0.1 (#139454) --- homeassistant/components/smartthings/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/smartthings/manifest.json b/homeassistant/components/smartthings/manifest.json index c5277241aa4..1f52cd23ff3 100644 --- a/homeassistant/components/smartthings/manifest.json +++ b/homeassistant/components/smartthings/manifest.json @@ -29,5 +29,5 @@ "documentation": "https://www.home-assistant.io/integrations/smartthings", "iot_class": "cloud_push", "loggers": ["pysmartthings"], - "requirements": ["pysmartthings==2.0.0"] + "requirements": ["pysmartthings==2.0.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index f8efdb35022..fcd7285a2a5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2310,7 +2310,7 @@ pysma==0.7.5 pysmappee==0.2.29 # homeassistant.components.smartthings -pysmartthings==2.0.0 +pysmartthings==2.0.1 # homeassistant.components.smarty pysmarty2==0.10.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 43fa107bb81..c4382080448 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1882,7 +1882,7 @@ pysma==0.7.5 pysmappee==0.2.29 # homeassistant.components.smartthings -pysmartthings==2.0.0 +pysmartthings==2.0.1 # homeassistant.components.smarty pysmarty2==0.10.2 From 3985f1c6c893e35e3e8c648b8000cc12ddfabc78 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Fri, 28 Feb 2025 11:18:16 +0100 Subject: [PATCH 1952/3148] Change webdav namespace to absolut URI (#139456) * Change webdav namespace to absolut URI * Add const file --- homeassistant/components/webdav/backup.py | 13 +++++++------ tests/components/webdav/const.py | 4 ++-- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/webdav/backup.py b/homeassistant/components/webdav/backup.py index a51866fde61..f810547022b 100644 --- a/homeassistant/components/webdav/backup.py +++ b/homeassistant/components/webdav/backup.py @@ -30,6 +30,7 @@ _LOGGER = logging.getLogger(__name__) METADATA_VERSION = "1" BACKUP_TIMEOUT = ClientTimeout(connect=10, total=43200) +NAMESPACE = "https://home-assistant.io" async def async_get_backup_agents( @@ -100,14 +101,14 @@ def _is_current_metadata_version(properties: list[Property]) -> bool: return any( prop.value == METADATA_VERSION for prop in properties - if prop.namespace == "homeassistant" and prop.name == "metadata_version" + if prop.namespace == NAMESPACE and prop.name == "metadata_version" ) def _backup_id_from_properties(properties: list[Property]) -> str | None: """Return the backup ID from properties.""" for prop in properties: - if prop.namespace == "homeassistant" and prop.name == "backup_id": + if prop.namespace == NAMESPACE and prop.name == "backup_id": return prop.value return None @@ -186,12 +187,12 @@ class WebDavBackupAgent(BackupAgent): f"{self._backup_path}/{filename_meta}", [ Property( - namespace="homeassistant", + namespace=NAMESPACE, name="backup_id", value=backup.backup_id, ), Property( - namespace="homeassistant", + namespace=NAMESPACE, name="metadata_version", value=METADATA_VERSION, ), @@ -252,11 +253,11 @@ class WebDavBackupAgent(BackupAgent): self._backup_path, [ PropertyRequest( - namespace="homeassistant", + namespace=NAMESPACE, name="metadata_version", ), PropertyRequest( - namespace="homeassistant", + namespace=NAMESPACE, name="backup_id", ), ], diff --git a/tests/components/webdav/const.py b/tests/components/webdav/const.py index 52cad9a163b..8d6b8ad67d7 100644 --- a/tests/components/webdav/const.py +++ b/tests/components/webdav/const.py @@ -20,12 +20,12 @@ MOCK_LIST_WITH_PROPERTIES = { "/Automatic_backup_2025.2.1_2025-02-10_18.31_30202686.tar": [], "/Automatic_backup_2025.2.1_2025-02-10_18.31_30202686.metadata.json": [ Property( - namespace="homeassistant", + namespace="https://home-assistant.io", name="backup_id", value="23e64aec", ), Property( - namespace="homeassistant", + namespace="https://home-assistant.io", name="metadata_version", value="1", ), From b501999a4c06d6986effdc6cbb4091ab11a61116 Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Thu, 27 Feb 2025 20:42:04 +0100 Subject: [PATCH 1953/3148] Improve onedrive migration (#139458) --- homeassistant/components/onedrive/__init__.py | 40 ++++++++++++++----- tests/components/onedrive/test_init.py | 27 +++++++++++-- 2 files changed, 54 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/onedrive/__init__.py b/homeassistant/components/onedrive/__init__.py index 454c782af92..f10b8fe0d91 100644 --- a/homeassistant/components/onedrive/__init__.py +++ b/homeassistant/components/onedrive/__init__.py @@ -41,14 +41,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass: HomeAssistant, entry: OneDriveConfigEntry) -> bool: """Set up OneDrive from a config entry.""" - implementation = await async_get_config_entry_implementation(hass, entry) - session = OAuth2Session(hass, entry, implementation) - - async def get_access_token() -> str: - await session.async_ensure_token_valid() - return cast(str, session.token[CONF_ACCESS_TOKEN]) - - client = OneDriveClient(get_access_token, async_get_clientsession(hass)) + client, get_access_token = await _get_onedrive_client(hass, entry) # get approot, will be created automatically if it does not exist approot = await _handle_item_operation(client.get_approot, "approot") @@ -164,20 +157,47 @@ async def async_migrate_entry(hass: HomeAssistant, entry: OneDriveConfigEntry) - _LOGGER.debug( "Migrating OneDrive config entry from version %s.%s", version, minor_version ) - + client, _ = await _get_onedrive_client(hass, entry) instance_id = await async_get_instance_id(hass) + try: + approot = await client.get_approot() + folder = await client.get_drive_item( + f"{approot.id}:/backups_{instance_id[:8]}:" + ) + except OneDriveException: + _LOGGER.exception("Migration to version 1.2 failed") + return False + hass.config_entries.async_update_entry( entry, data={ **entry.data, - CONF_FOLDER_ID: "id", # will be updated during setup_entry + CONF_FOLDER_ID: folder.id, CONF_FOLDER_NAME: f"backups_{instance_id[:8]}", }, + minor_version=2, ) _LOGGER.debug("Migration to version 1.2 successful") return True +async def _get_onedrive_client( + hass: HomeAssistant, entry: OneDriveConfigEntry +) -> tuple[OneDriveClient, Callable[[], Awaitable[str]]]: + """Get OneDrive client.""" + implementation = await async_get_config_entry_implementation(hass, entry) + session = OAuth2Session(hass, entry, implementation) + + async def get_access_token() -> str: + await session.async_ensure_token_valid() + return cast(str, session.token[CONF_ACCESS_TOKEN]) + + return ( + OneDriveClient(get_access_token, async_get_clientsession(hass)), + get_access_token, + ) + + async def _handle_item_operation( func: Callable[[], Awaitable[Item]], folder: str ) -> Item: diff --git a/tests/components/onedrive/test_init.py b/tests/components/onedrive/test_init.py index c7765e0a7f8..952ca01e1cb 100644 --- a/tests/components/onedrive/test_init.py +++ b/tests/components/onedrive/test_init.py @@ -236,7 +236,6 @@ async def test_data_cap_issues( async def test_1_1_to_1_2_migration( hass: HomeAssistant, - mock_onedrive_client: MagicMock, mock_config_entry: MockConfigEntry, mock_folder: Folder, ) -> None: @@ -251,12 +250,34 @@ async def test_1_1_to_1_2_migration( }, ) + await setup_integration(hass, old_config_entry) + assert old_config_entry.data[CONF_FOLDER_ID] == mock_folder.id + assert old_config_entry.data[CONF_FOLDER_NAME] == mock_folder.name + assert old_config_entry.minor_version == 2 + + +async def test_1_1_to_1_2_migration_failure( + hass: HomeAssistant, + mock_onedrive_client: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test migration from 1.1 to 1.2 failure.""" + old_config_entry = MockConfigEntry( + unique_id="mock_drive_id", + title="John Doe's OneDrive", + domain=DOMAIN, + data={ + "auth_implementation": mock_config_entry.data["auth_implementation"], + "token": mock_config_entry.data["token"], + }, + ) + # will always 404 after migration, because of dummy id mock_onedrive_client.get_drive_item.side_effect = NotFoundError(404, "Not found") await setup_integration(hass, old_config_entry) - assert old_config_entry.data[CONF_FOLDER_ID] == mock_folder.id - assert old_config_entry.data[CONF_FOLDER_NAME] == mock_folder.name + assert old_config_entry.state is ConfigEntryState.MIGRATION_ERROR + assert old_config_entry.minor_version == 1 async def test_migration_guard_against_major_downgrade( From 736ff8828d2fa3e4e67155da4ae34152556a7b5f Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 28 Feb 2025 10:30:31 +0100 Subject: [PATCH 1954/3148] Bump pysmartthings to 2.1.0 (#139460) --- homeassistant/components/smartthings/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/smartthings/manifest.json b/homeassistant/components/smartthings/manifest.json index 1f52cd23ff3..5dd570f2751 100644 --- a/homeassistant/components/smartthings/manifest.json +++ b/homeassistant/components/smartthings/manifest.json @@ -29,5 +29,5 @@ "documentation": "https://www.home-assistant.io/integrations/smartthings", "iot_class": "cloud_push", "loggers": ["pysmartthings"], - "requirements": ["pysmartthings==2.0.1"] + "requirements": ["pysmartthings==2.1.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index fcd7285a2a5..9b38c4dd423 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2310,7 +2310,7 @@ pysma==0.7.5 pysmappee==0.2.29 # homeassistant.components.smartthings -pysmartthings==2.0.1 +pysmartthings==2.1.0 # homeassistant.components.smarty pysmarty2==0.10.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c4382080448..5c05f3e2a7d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1882,7 +1882,7 @@ pysma==0.7.5 pysmappee==0.2.29 # homeassistant.components.smartthings -pysmartthings==2.0.1 +pysmartthings==2.1.0 # homeassistant.components.smarty pysmarty2==0.10.2 From d8bf47c1018984e7959437aa9228cb5b8d1e8a56 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 27 Feb 2025 22:50:34 +0100 Subject: [PATCH 1955/3148] Only lowercase SmartThings media input source if we have it (#139468) --- homeassistant/components/smartthings/sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index bc986894045..2d817c182da 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -461,7 +461,7 @@ CAPABILITY_TO_SENSORS: dict[ translation_key="media_input_source", device_class=SensorDeviceClass.ENUM, options_attribute=Attribute.SUPPORTED_INPUT_SOURCES, - value_fn=lambda value: value.lower(), + value_fn=lambda value: value.lower() if value else None, ) ] }, From c63aaec09e53beb84c7ffbec2c2888dd44ca54a8 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 28 Feb 2025 09:15:13 +0100 Subject: [PATCH 1956/3148] Set SmartThings suggested display precision (#139470) --- .../components/smartthings/sensor.py | 5 ++ .../smartthings/snapshots/test_sensor.ambr | 90 +++++++++++++++++++ 2 files changed, 95 insertions(+) diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index 2d817c182da..cd12bf46e25 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -580,6 +580,7 @@ CAPABILITY_TO_SENSORS: dict[ device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, value_fn=lambda value: value["energy"] / 1000, + suggested_display_precision=2, except_if_state_none=True, ), SmartThingsSensorEntityDescription( @@ -589,6 +590,7 @@ CAPABILITY_TO_SENSORS: dict[ native_unit_of_measurement=UnitOfPower.WATT, value_fn=lambda value: value["power"], extra_state_attributes_fn=power_attributes, + suggested_display_precision=2, except_if_state_none=True, ), SmartThingsSensorEntityDescription( @@ -598,6 +600,7 @@ CAPABILITY_TO_SENSORS: dict[ device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, value_fn=lambda value: value["deltaEnergy"] / 1000, + suggested_display_precision=2, except_if_state_none=True, ), SmartThingsSensorEntityDescription( @@ -607,6 +610,7 @@ CAPABILITY_TO_SENSORS: dict[ device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, value_fn=lambda value: value["powerEnergy"] / 1000, + suggested_display_precision=2, except_if_state_none=True, ), SmartThingsSensorEntityDescription( @@ -616,6 +620,7 @@ CAPABILITY_TO_SENSORS: dict[ device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, value_fn=lambda value: value["energySaved"] / 1000, + suggested_display_precision=2, except_if_state_none=True, ), ] diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index 93a683afe82..b67d15bef55 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -545,6 +545,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -597,6 +600,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -649,6 +655,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -753,6 +762,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -807,6 +819,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -959,6 +974,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -1011,6 +1029,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -1063,6 +1084,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -1167,6 +1191,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -1221,6 +1248,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -1768,6 +1798,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -1820,6 +1853,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -1872,6 +1908,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -1924,6 +1963,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -1978,6 +2020,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -2326,6 +2371,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -2378,6 +2426,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -2430,6 +2481,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -2614,6 +2668,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -2668,6 +2725,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -2768,6 +2828,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -2820,6 +2883,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -2872,6 +2938,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -3066,6 +3135,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -3120,6 +3192,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -3220,6 +3295,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -3272,6 +3350,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -3324,6 +3405,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -3520,6 +3604,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, @@ -3574,6 +3661,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, From 6de878ffe45698c8345083f83d3d2b8b05a041ac Mon Sep 17 00:00:00 2001 From: Ivan Lopez Hernandez Date: Thu, 27 Feb 2025 19:10:42 -0800 Subject: [PATCH 1957/3148] Fix Gemini Schema validation for #139416 (#139478) Fixed Schema validation for issue #139477 --- .../conversation.py | 15 ++++++- .../snapshots/test_conversation.ambr | 2 +- .../test_conversation.py | 42 +++++++++++++++++-- 3 files changed, 53 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/google_generative_ai_conversation/conversation.py b/homeassistant/components/google_generative_ai_conversation/conversation.py index c99c4c07a7d..2c84249dcb3 100644 --- a/homeassistant/components/google_generative_ai_conversation/conversation.py +++ b/homeassistant/components/google_generative_ai_conversation/conversation.py @@ -111,9 +111,20 @@ def _format_schema(schema: dict[str, Any]) -> Schema: continue if key == "any_of": val = [_format_schema(subschema) for subschema in val] - if key == "type": + elif key == "type": val = val.upper() - if key == "items": + elif key == "format": + # Gemini API does not support all formats, see: https://ai.google.dev/api/caching#Schema + # formats that are not supported are ignored + if schema.get("type") == "string" and val not in ("enum", "date-time"): + continue + if schema.get("type") == "number" and val not in ("float", "double"): + continue + if schema.get("type") == "integer" and val not in ("int32", "int64"): + continue + if schema.get("type") not in ("string", "number", "integer"): + continue + elif key == "items": val = _format_schema(val) elif key == "properties": val = {k: _format_schema(v) for k, v in val.items()} diff --git a/tests/components/google_generative_ai_conversation/snapshots/test_conversation.ambr b/tests/components/google_generative_ai_conversation/snapshots/test_conversation.ambr index 7c9bb896bd3..106366fd240 100644 --- a/tests/components/google_generative_ai_conversation/snapshots/test_conversation.ambr +++ b/tests/components/google_generative_ai_conversation/snapshots/test_conversation.ambr @@ -6,7 +6,7 @@ tuple( ), dict({ - 'config': GenerateContentConfig(http_options=None, system_instruction="Current time is 05:00:00. Today's date is 2024-05-24.\nYou are a voice assistant for Home Assistant.\nAnswer questions about the world truthfully.\nAnswer in plain text. Keep it simple and to the point.\nOnly if the user wants to control a device, tell them to expose entities to their voice assistant in Home Assistant.", temperature=1.0, top_p=0.95, top_k=64.0, candidate_count=None, max_output_tokens=150, stop_sequences=None, response_logprobs=None, logprobs=None, presence_penalty=None, frequency_penalty=None, seed=None, response_mime_type=None, response_schema=None, routing_config=None, safety_settings=[SafetySetting(method=None, category=, threshold=), SafetySetting(method=None, category=, threshold=), SafetySetting(method=None, category=, threshold=), SafetySetting(method=None, category=, threshold=)], tools=[Tool(function_declarations=[FunctionDeclaration(response=None, description='Test function', name='test_tool', parameters=Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=None, max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=, description=None, enum=None, format=None, items=None, properties={'param1': Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=None, max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=, description='Test parameters', enum=None, format=None, items=Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=None, max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=, description=None, enum=None, format='lower', items=None, properties=None, required=None), properties=None, required=None), 'param2': Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=[Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=None, max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=, description=None, enum=None, format=None, items=None, properties=None, required=None), Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=None, max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=, description=None, enum=None, format=None, items=None, properties=None, required=None)], max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=None, description=None, enum=None, format=None, items=None, properties=None, required=None), 'param3': Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=None, max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=, description=None, enum=None, format=None, items=None, properties={'json': Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=None, max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=, description=None, enum=None, format=None, items=None, properties=None, required=None)}, required=[])}, required=[]))], retrieval=None, google_search=None, google_search_retrieval=None, code_execution=None)], tool_config=None, labels=None, cached_content=None, response_modalities=None, media_resolution=None, speech_config=None, audio_timestamp=None, automatic_function_calling=AutomaticFunctionCallingConfig(disable=True, maximum_remote_calls=None, ignore_call_history=None), thinking_config=None), + 'config': GenerateContentConfig(http_options=None, system_instruction="Current time is 05:00:00. Today's date is 2024-05-24.\nYou are a voice assistant for Home Assistant.\nAnswer questions about the world truthfully.\nAnswer in plain text. Keep it simple and to the point.\nOnly if the user wants to control a device, tell them to expose entities to their voice assistant in Home Assistant.", temperature=1.0, top_p=0.95, top_k=64.0, candidate_count=None, max_output_tokens=150, stop_sequences=None, response_logprobs=None, logprobs=None, presence_penalty=None, frequency_penalty=None, seed=None, response_mime_type=None, response_schema=None, routing_config=None, safety_settings=[SafetySetting(method=None, category=, threshold=), SafetySetting(method=None, category=, threshold=), SafetySetting(method=None, category=, threshold=), SafetySetting(method=None, category=, threshold=)], tools=[Tool(function_declarations=[FunctionDeclaration(response=None, description='Test function', name='test_tool', parameters=Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=None, max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=, description=None, enum=None, format=None, items=None, properties={'param1': Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=None, max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=, description='Test parameters', enum=None, format=None, items=Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=None, max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=, description=None, enum=None, format=None, items=None, properties=None, required=None), properties=None, required=None), 'param2': Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=[Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=None, max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=, description=None, enum=None, format=None, items=None, properties=None, required=None), Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=None, max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=, description=None, enum=None, format=None, items=None, properties=None, required=None)], max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=None, description=None, enum=None, format=None, items=None, properties=None, required=None), 'param3': Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=None, max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=, description=None, enum=None, format=None, items=None, properties={'json': Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=None, max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=, description=None, enum=None, format=None, items=None, properties=None, required=None)}, required=[])}, required=[]))], retrieval=None, google_search=None, google_search_retrieval=None, code_execution=None)], tool_config=None, labels=None, cached_content=None, response_modalities=None, media_resolution=None, speech_config=None, audio_timestamp=None, automatic_function_calling=AutomaticFunctionCallingConfig(disable=True, maximum_remote_calls=None, ignore_call_history=None), thinking_config=None), 'history': list([ ]), 'model': 'models/gemini-2.0-flash', diff --git a/tests/components/google_generative_ai_conversation/test_conversation.py b/tests/components/google_generative_ai_conversation/test_conversation.py index 229ee0b323e..5e887d3cab7 100644 --- a/tests/components/google_generative_ai_conversation/test_conversation.py +++ b/tests/components/google_generative_ai_conversation/test_conversation.py @@ -493,6 +493,42 @@ async def test_escape_decode() -> None: {"type": "string", "enum": ["a", "b", "c"]}, {"type": "STRING", "enum": ["a", "b", "c"]}, ), + ( + {"type": "string", "format": "enum", "enum": ["a", "b", "c"]}, + {"type": "STRING", "format": "enum", "enum": ["a", "b", "c"]}, + ), + ( + {"type": "string", "format": "date-time"}, + {"type": "STRING", "format": "date-time"}, + ), + ( + {"type": "string", "format": "byte"}, + {"type": "STRING"}, + ), + ( + {"type": "number", "format": "float"}, + {"type": "NUMBER", "format": "float"}, + ), + ( + {"type": "number", "format": "double"}, + {"type": "NUMBER", "format": "double"}, + ), + ( + {"type": "number", "format": "hex"}, + {"type": "NUMBER"}, + ), + ( + {"type": "integer", "format": "int32"}, + {"type": "INTEGER", "format": "int32"}, + ), + ( + {"type": "integer", "format": "int64"}, + {"type": "INTEGER", "format": "int64"}, + ), + ( + {"type": "integer", "format": "int8"}, + {"type": "INTEGER"}, + ), ( {"type": "integer", "enum": [1, 2, 3]}, {"type": "STRING", "enum": ["1", "2", "3"]}, @@ -515,11 +551,11 @@ async def test_escape_decode() -> None: ] }, ), - ({"type": "string", "format": "lower"}, {"format": "lower", "type": "STRING"}), - ({"type": "boolean", "format": "bool"}, {"format": "bool", "type": "BOOLEAN"}), + ({"type": "string", "format": "lower"}, {"type": "STRING"}), + ({"type": "boolean", "format": "bool"}, {"type": "BOOLEAN"}), ( {"type": "number", "format": "percent"}, - {"type": "NUMBER", "format": "percent"}, + {"type": "NUMBER"}, ), ( { From fdb4c0a81f9310aa7361e5ae4de2829fe2bc172c Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 28 Feb 2025 11:44:16 +0100 Subject: [PATCH 1958/3148] Fail recorder.backup.async_pre_backup if Home Assistant is not running (#139491) Fail recorder.backup.async_pre_backup if hass is not running --- homeassistant/components/recorder/backup.py | 4 ++- tests/components/recorder/test_backup.py | 38 ++++++++++++++++++++- 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/recorder/backup.py b/homeassistant/components/recorder/backup.py index d47cbe92bd4..eeebe328007 100644 --- a/homeassistant/components/recorder/backup.py +++ b/homeassistant/components/recorder/backup.py @@ -2,7 +2,7 @@ from logging import getLogger -from homeassistant.core import HomeAssistant +from homeassistant.core import CoreState, HomeAssistant from homeassistant.exceptions import HomeAssistantError from .util import async_migration_in_progress, get_instance @@ -14,6 +14,8 @@ async def async_pre_backup(hass: HomeAssistant) -> None: """Perform operations before a backup starts.""" _LOGGER.info("Backup start notification, locking database for writes") instance = get_instance(hass) + if hass.state is not CoreState.running: + raise HomeAssistantError("Home Assistant is not running") if async_migration_in_progress(hass): raise HomeAssistantError("Database migration in progress") await instance.lock_database() diff --git a/tests/components/recorder/test_backup.py b/tests/components/recorder/test_backup.py index 08fbef01bdd..bed9e88fcbf 100644 --- a/tests/components/recorder/test_backup.py +++ b/tests/components/recorder/test_backup.py @@ -1,12 +1,13 @@ """Test backup platform for the Recorder integration.""" +from contextlib import AbstractContextManager, nullcontext as does_not_raise from unittest.mock import patch import pytest from homeassistant.components.recorder import Recorder from homeassistant.components.recorder.backup import async_post_backup, async_pre_backup -from homeassistant.core import HomeAssistant +from homeassistant.core import CoreState, HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -19,6 +20,41 @@ async def test_async_pre_backup(recorder_mock: Recorder, hass: HomeAssistant) -> assert lock_mock.called +RAISES_HASS_NOT_RUNNING = pytest.raises( + HomeAssistantError, match="Home Assistant is not running" +) + + +@pytest.mark.parametrize( + ("core_state", "expected_result", "lock_calls"), + [ + (CoreState.final_write, RAISES_HASS_NOT_RUNNING, 0), + (CoreState.not_running, RAISES_HASS_NOT_RUNNING, 0), + (CoreState.running, does_not_raise(), 1), + (CoreState.starting, RAISES_HASS_NOT_RUNNING, 0), + (CoreState.stopped, RAISES_HASS_NOT_RUNNING, 0), + (CoreState.stopping, RAISES_HASS_NOT_RUNNING, 0), + ], +) +async def test_async_pre_backup_core_state( + recorder_mock: Recorder, + hass: HomeAssistant, + core_state: CoreState, + expected_result: AbstractContextManager, + lock_calls: int, +) -> None: + """Test pre backup in different core states.""" + hass.set_state(core_state) + with ( # pylint: disable=confusing-with-statement + patch( + "homeassistant.components.recorder.core.Recorder.lock_database" + ) as lock_mock, + expected_result, + ): + await async_pre_backup(hass) + assert len(lock_mock.mock_calls) == lock_calls + + async def test_async_pre_backup_with_timeout( recorder_mock: Recorder, hass: HomeAssistant ) -> None: From 342e04974d16d5613cbc9ee1e817d6fa95aa8f38 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Fri, 28 Feb 2025 23:26:39 +1000 Subject: [PATCH 1959/3148] Fix shift state in Teslemetry (#139505) * Fix shift state * Different fix --- homeassistant/components/teslemetry/sensor.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/teslemetry/sensor.py b/homeassistant/components/teslemetry/sensor.py index 70315e92da0..56c8830d736 100644 --- a/homeassistant/components/teslemetry/sensor.py +++ b/homeassistant/components/teslemetry/sensor.py @@ -8,6 +8,7 @@ from datetime import datetime, timedelta from propcache.api import cached_property from teslemetry_stream import Signal +from teslemetry_stream.const import ShiftState from homeassistant.components.sensor import ( RestoreSensor, @@ -69,7 +70,7 @@ class TeslemetryVehicleSensorEntityDescription(SensorEntityDescription): polling_value_fn: Callable[[StateType], StateType] = lambda x: x polling_available_fn: Callable[[StateType], bool] = lambda x: x is not None streaming_key: Signal | None = None - streaming_value_fn: Callable[[StateType], StateType] = lambda x: x + streaming_value_fn: Callable[[str | int | float], StateType] = lambda x: x streaming_firmware: str = "2024.26" @@ -212,7 +213,7 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryVehicleSensorEntityDescription, ...] = ( polling_available_fn=lambda x: True, polling_value_fn=lambda x: SHIFT_STATES.get(str(x), "p"), streaming_key=Signal.GEAR, - streaming_value_fn=lambda x: SHIFT_STATES.get(str(x)), + streaming_value_fn=lambda x: str(ShiftState.get(x, "P")).lower(), options=list(SHIFT_STATES.values()), device_class=SensorDeviceClass.ENUM, entity_registry_enabled_default=False, From 4300900322bad9c42bff484b41cabc859034ec19 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 28 Feb 2025 14:25:35 +0100 Subject: [PATCH 1960/3148] Improve error handling in CoreBackupReaderWriter (#139508) --- homeassistant/components/backup/manager.py | 27 +++++++++++-- tests/components/backup/test_manager.py | 46 +++++++++++++++++----- 2 files changed, 61 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index 317de85b823..c8b515e3aee 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -14,6 +14,7 @@ from itertools import chain import json from pathlib import Path, PurePath import shutil +import sys import tarfile import time from typing import IO, TYPE_CHECKING, Any, Protocol, TypedDict, cast @@ -308,6 +309,12 @@ class DecryptOnDowloadNotSupported(BackupManagerError): _message = "On-the-fly decryption is not supported for this backup." +class BackupManagerExceptionGroup(BackupManagerError, ExceptionGroup): + """Raised when multiple exceptions occur.""" + + error_code = "multiple_errors" + + class BackupManager: """Define the format that backup managers can have.""" @@ -1605,10 +1612,24 @@ class CoreBackupReaderWriter(BackupReaderWriter): ) finally: # Inform integrations the backup is done + # If there's an unhandled exception, we keep it so we can rethrow it in case + # the post backup actions also fail. + unhandled_exc = sys.exception() try: - await manager.async_post_backup_actions() - except BackupManagerError as err: - raise BackupReaderWriterError(str(err)) from err + try: + await manager.async_post_backup_actions() + except BackupManagerError as err: + raise BackupReaderWriterError(str(err)) from err + except Exception as err: + if not unhandled_exc: + raise + # If there's an unhandled exception, we wrap both that and the exception + # from the post backup actions in an ExceptionGroup so the caller is + # aware of both exceptions. + raise BackupManagerExceptionGroup( + f"Multiple errors when creating backup: {unhandled_exc}, {err}", + [unhandled_exc, err], + ) from None def _mkdir_and_generate_backup_contents( self, diff --git a/tests/components/backup/test_manager.py b/tests/components/backup/test_manager.py index 6e626e63748..e4762f35327 100644 --- a/tests/components/backup/test_manager.py +++ b/tests/components/backup/test_manager.py @@ -8,6 +8,7 @@ from dataclasses import replace from io import StringIO import json from pathlib import Path +import re import tarfile from typing import Any from unittest.mock import ( @@ -35,6 +36,7 @@ from homeassistant.components.backup.agent import BackupAgentError from homeassistant.components.backup.const import DATA_MANAGER from homeassistant.components.backup.manager import ( BackupManagerError, + BackupManagerExceptionGroup, BackupManagerState, CreateBackupStage, CreateBackupState, @@ -1646,34 +1648,60 @@ async def test_exception_platform_pre(hass: HomeAssistant) -> None: assert str(err.value) == "Error during pre-backup: Test exception" +@pytest.mark.parametrize( + ("unhandled_error", "expected_exception", "expected_msg"), + [ + (None, BackupManagerError, "Error during post-backup: Test exception"), + ( + HomeAssistantError("Boom"), + BackupManagerExceptionGroup, + ( + "Multiple errors when creating backup: Error during pre-backup: Boom, " + "Error during post-backup: Test exception (2 sub-exceptions)" + ), + ), + ( + Exception("Boom"), + BackupManagerExceptionGroup, + ( + "Multiple errors when creating backup: Error during pre-backup: Boom, " + "Error during post-backup: Test exception (2 sub-exceptions)" + ), + ), + ], +) @pytest.mark.usefixtures("mock_backup_generation") -async def test_exception_platform_post(hass: HomeAssistant) -> None: +async def test_exception_platform_post( + hass: HomeAssistant, + unhandled_error: Exception | None, + expected_exception: type[Exception], + expected_msg: str, +) -> None: """Test exception in post step.""" - async def _mock_step(hass: HomeAssistant) -> None: - raise HomeAssistantError("Test exception") - remote_agent = mock_backup_agent("remote") await setup_backup_platform( hass, domain="test", platform=Mock( - async_pre_backup=AsyncMock(), - async_post_backup=_mock_step, + # We let the pre_backup fail to test that unhandled errors are not discarded + # when post backup fails + async_pre_backup=AsyncMock(side_effect=unhandled_error), + async_post_backup=AsyncMock( + side_effect=HomeAssistantError("Test exception") + ), async_get_backup_agents=AsyncMock(return_value=[remote_agent]), ), ) await setup_backup_integration(hass) - with pytest.raises(BackupManagerError) as err: + with pytest.raises(expected_exception, match=re.escape(expected_msg)): await hass.services.async_call( DOMAIN, "create", blocking=True, ) - assert str(err.value) == "Error during post-backup: Test exception" - @pytest.mark.parametrize( ( From 9e3e6b3f431e45b14db89c03e038678ec674247f Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Fri, 28 Feb 2025 16:18:57 +0100 Subject: [PATCH 1961/3148] Add diagnostics to onedrive (#139516) * Add diagnostics to onedrive * redact PII * add raw data --- .../components/onedrive/diagnostics.py | 33 +++++++++++++++++++ .../components/onedrive/quality_scale.yaml | 5 +-- .../onedrive/snapshots/test_diagnostics.ambr | 31 +++++++++++++++++ tests/components/onedrive/test_diagnostics.py | 26 +++++++++++++++ 4 files changed, 91 insertions(+), 4 deletions(-) create mode 100644 homeassistant/components/onedrive/diagnostics.py create mode 100644 tests/components/onedrive/snapshots/test_diagnostics.ambr create mode 100644 tests/components/onedrive/test_diagnostics.py diff --git a/homeassistant/components/onedrive/diagnostics.py b/homeassistant/components/onedrive/diagnostics.py new file mode 100644 index 00000000000..0e1ed94e155 --- /dev/null +++ b/homeassistant/components/onedrive/diagnostics.py @@ -0,0 +1,33 @@ +"""Diagnostics support for OneDrive.""" + +from __future__ import annotations + +from dataclasses import asdict +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN +from homeassistant.core import HomeAssistant + +from .coordinator import OneDriveConfigEntry + +TO_REDACT = {"display_name", "email", CONF_ACCESS_TOKEN, CONF_TOKEN} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, + entry: OneDriveConfigEntry, +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + + coordinator = entry.runtime_data.coordinator + + data = { + "drive": asdict(coordinator.data), + "config": { + **entry.data, + **entry.options, + }, + } + + return async_redact_data(data, TO_REDACT) diff --git a/homeassistant/components/onedrive/quality_scale.yaml b/homeassistant/components/onedrive/quality_scale.yaml index dd9e7f26102..023410d89b2 100644 --- a/homeassistant/components/onedrive/quality_scale.yaml +++ b/homeassistant/components/onedrive/quality_scale.yaml @@ -41,10 +41,7 @@ rules: # Gold devices: done - diagnostics: - status: exempt - comment: | - There is no data to diagnose. + diagnostics: done discovery-update-info: status: exempt comment: | diff --git a/tests/components/onedrive/snapshots/test_diagnostics.ambr b/tests/components/onedrive/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..827b9397313 --- /dev/null +++ b/tests/components/onedrive/snapshots/test_diagnostics.ambr @@ -0,0 +1,31 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'config': dict({ + 'auth_implementation': 'onedrive', + 'folder_id': 'my_folder_id', + 'folder_name': 'name', + 'token': '**REDACTED**', + }), + 'drive': dict({ + 'drive_type': 'personal', + 'id': 'mock_drive_id', + 'name': 'My Drive', + 'owner': dict({ + 'application': None, + 'user': dict({ + 'display_name': '**REDACTED**', + 'email': '**REDACTED**', + 'id': 'id', + }), + }), + 'quota': dict({ + 'deleted': 5, + 'remaining': 805306368, + 'state': 'nearing', + 'total': 5368709120, + 'used': 4250000000, + }), + }), + }) +# --- diff --git a/tests/components/onedrive/test_diagnostics.py b/tests/components/onedrive/test_diagnostics.py new file mode 100644 index 00000000000..f82d9925ee6 --- /dev/null +++ b/tests/components/onedrive/test_diagnostics.py @@ -0,0 +1,26 @@ +"""Tests for the diagnostics data provided by the OneDrive integration.""" + +from syrupy import SnapshotAssertion + +from homeassistant.core import HomeAssistant + +from . import setup_integration + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def test_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics.""" + + await setup_integration(hass, mock_config_entry) + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, mock_config_entry) + == snapshot + ) From 94b342f26aea23783dfcba807fb5503142e86372 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Fri, 28 Feb 2025 14:14:56 +0100 Subject: [PATCH 1962/3148] Make the Tuya backend library compatible with the newer paho mqtt client. (#139518) * Make the Tuya backend library compatible with the newer paho mqtt client. * Improve classnames and docstrings --- homeassistant/components/tuya/__init__.py | 74 ++++++++++++++++++++++- 1 file changed, 72 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/tuya/__init__.py b/homeassistant/components/tuya/__init__.py index c8a639cd239..32119add5f4 100644 --- a/homeassistant/components/tuya/__init__.py +++ b/homeassistant/components/tuya/__init__.py @@ -3,7 +3,8 @@ from __future__ import annotations import logging -from typing import Any, NamedTuple +from typing import TYPE_CHECKING, Any, NamedTuple +from urllib.parse import urlsplit from tuya_sharing import ( CustomerDevice, @@ -11,6 +12,7 @@ from tuya_sharing import ( SharingDeviceListener, SharingTokenListener, ) +from tuya_sharing.mq import SharingMQ, SharingMQConfig from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback @@ -45,13 +47,81 @@ class HomeAssistantTuyaData(NamedTuple): listener: SharingDeviceListener +if TYPE_CHECKING: + import paho.mqtt.client as mqtt + + +class ManagerCompat(Manager): + """Extended Manager class from the Tuya device sharing SDK. + + The extension ensures compatibility a paho-mqtt client version >= 2.1.0. + It overrides extend refresh_mq method to ensure correct paho.mqtt client calls. + + This code can be removed when a version of tuya-device-sharing with + https://github.com/tuya/tuya-device-sharing-sdk/pull/25 is available. + """ + + def refresh_mq(self): + """Refresh the MQTT connection.""" + if self.mq is not None: + self.mq.stop() + self.mq = None + + home_ids = [home.id for home in self.user_homes] + device = [ + device + for device in self.device_map.values() + if hasattr(device, "id") and getattr(device, "set_up", False) + ] + + sharing_mq = SharingMQCompat(self.customer_api, home_ids, device) + sharing_mq.start() + sharing_mq.add_message_listener(self.on_message) + self.mq = sharing_mq + + +class SharingMQCompat(SharingMQ): + """Extended SharingMQ class from the Tuya device sharing SDK. + + The extension ensures compatibility a paho-mqtt client version >= 2.1.0. + It overrides _start method to ensure correct paho.mqtt client calls. + + This code can be removed when a version of tuya-device-sharing with + https://github.com/tuya/tuya-device-sharing-sdk/pull/25 is available. + """ + + def _start(self, mq_config: SharingMQConfig) -> mqtt.Client: + """Start the MQTT client.""" + # We don't import on the top because some integrations + # should be able to optionally rely on MQTT. + import paho.mqtt.client as mqtt # pylint: disable=import-outside-toplevel + + mqttc = mqtt.Client(client_id=mq_config.client_id) + mqttc.username_pw_set(mq_config.username, mq_config.password) + mqttc.user_data_set({"mqConfig": mq_config}) + mqttc.on_connect = self._on_connect + mqttc.on_message = self._on_message + mqttc.on_subscribe = self._on_subscribe + mqttc.on_log = self._on_log + mqttc.on_disconnect = self._on_disconnect + + url = urlsplit(mq_config.url) + if url.scheme == "ssl": + mqttc.tls_set() + + mqttc.connect(url.hostname, url.port) + + mqttc.loop_start() + return mqttc + + async def async_setup_entry(hass: HomeAssistant, entry: TuyaConfigEntry) -> bool: """Async setup hass config entry.""" if CONF_APP_TYPE in entry.data: raise ConfigEntryAuthFailed("Authentication failed. Please re-authenticate.") token_listener = TokenListener(hass, entry) - manager = Manager( + manager = ManagerCompat( TUYA_CLIENT_ID, entry.data[CONF_USER_CODE], entry.data[CONF_TERMINAL_ID], From d2e19c829d402303174715782006e633b0b1ce6e Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Fri, 28 Feb 2025 16:00:31 +0100 Subject: [PATCH 1963/3148] Suppress unsupported event 'EVT_USP_RpsPowerDeniedByPsuOverload' by bumping aiounifi to v83 (#139519) Bump aiounifi to v83 --- homeassistant/components/unifi/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/unifi/manifest.json b/homeassistant/components/unifi/manifest.json index f5ad99b72f7..dd255c57c13 100644 --- a/homeassistant/components/unifi/manifest.json +++ b/homeassistant/components/unifi/manifest.json @@ -7,7 +7,7 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["aiounifi"], - "requirements": ["aiounifi==82"], + "requirements": ["aiounifi==83"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/requirements_all.txt b/requirements_all.txt index 9b38c4dd423..79fa1a40d37 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -404,7 +404,7 @@ aiotedee==0.2.20 aiotractive==0.6.0 # homeassistant.components.unifi -aiounifi==82 +aiounifi==83 # homeassistant.components.usb aiousbwatcher==1.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5c05f3e2a7d..40545a50d4c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -386,7 +386,7 @@ aiotedee==0.2.20 aiotractive==0.6.0 # homeassistant.components.unifi -aiounifi==82 +aiounifi==83 # homeassistant.components.usb aiousbwatcher==1.1.1 From a786ff53ff7f087f32cd7fa5c2e7985a68c23721 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Fri, 28 Feb 2025 14:30:47 +0100 Subject: [PATCH 1964/3148] Don't split wheels builder anymore (#139522) --- .github/workflows/wheels.yml | 40 ++---------------------------------- 1 file changed, 2 insertions(+), 38 deletions(-) diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 7c02c8d97cd..4b1628c57bb 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -218,15 +218,7 @@ jobs: sed -i "/uv/d" requirements.txt sed -i "/uv/d" requirements_diff.txt - - name: Split requirements all - run: | - # 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). - - split -l $(expr $(expr $(cat requirements_all.txt | wc -l) + 1) / 3) requirements_all_wheels_${{ matrix.arch }}.txt requirements_all.txt - - - name: Build wheels (part 1) + - name: Build wheels uses: home-assistant/wheels@2024.11.0 with: abi: ${{ matrix.abi }} @@ -238,32 +230,4 @@ jobs: skip-binary: aiohttp;charset-normalizer;grpcio;multidict;SQLAlchemy;propcache;protobuf;pymicro-vad;yarl constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" - requirements: "requirements_all.txtaa" - - - name: Build wheels (part 2) - uses: home-assistant/wheels@2024.11.0 - with: - abi: ${{ matrix.abi }} - tag: musllinux_1_2 - arch: ${{ matrix.arch }} - wheels-key: ${{ secrets.WHEELS_KEY }} - env-file: true - apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev;nasm;zlib-ng-dev" - skip-binary: aiohttp;charset-normalizer;grpcio;multidict;SQLAlchemy;propcache;protobuf;pymicro-vad;yarl - constraints: "homeassistant/package_constraints.txt" - requirements-diff: "requirements_diff.txt" - requirements: "requirements_all.txtab" - - - name: Build wheels (part 3) - uses: home-assistant/wheels@2024.11.0 - with: - abi: ${{ matrix.abi }} - tag: musllinux_1_2 - arch: ${{ matrix.arch }} - wheels-key: ${{ secrets.WHEELS_KEY }} - env-file: true - apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev;nasm;zlib-ng-dev" - skip-binary: aiohttp;charset-normalizer;grpcio;multidict;SQLAlchemy;propcache;protobuf;pymicro-vad;yarl - constraints: "homeassistant/package_constraints.txt" - requirements-diff: "requirements_diff.txt" - requirements: "requirements_all.txtac" + requirements: "requirements_all.txt" From 07128ba06372284df7b69ad0efb591e37f5c6d98 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 28 Feb 2025 15:47:51 +0100 Subject: [PATCH 1965/3148] Bump yt-dlp to 2025.02.19 (#139526) --- homeassistant/components/media_extractor/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/media_extractor/manifest.json b/homeassistant/components/media_extractor/manifest.json index f0f8ee03ad0..575c0fa878d 100644 --- a/homeassistant/components/media_extractor/manifest.json +++ b/homeassistant/components/media_extractor/manifest.json @@ -8,6 +8,6 @@ "iot_class": "calculated", "loggers": ["yt_dlp"], "quality_scale": "internal", - "requirements": ["yt-dlp[default]==2025.01.26"], + "requirements": ["yt-dlp[default]==2025.02.19"], "single_config_entry": true } diff --git a/requirements_all.txt b/requirements_all.txt index 79fa1a40d37..19a5e9ff261 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3131,7 +3131,7 @@ youless-api==2.2.0 youtubeaio==1.1.5 # homeassistant.components.media_extractor -yt-dlp[default]==2025.01.26 +yt-dlp[default]==2025.02.19 # homeassistant.components.zabbix zabbix-utils==2.0.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 40545a50d4c..981c3c129c1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2526,7 +2526,7 @@ youless-api==2.2.0 youtubeaio==1.1.5 # homeassistant.components.media_extractor -yt-dlp[default]==2025.01.26 +yt-dlp[default]==2025.02.19 # homeassistant.components.zamg zamg==0.3.6 From 09c129de4001e4b373ccb337ce9dcbf83cf497d1 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Fri, 28 Feb 2025 17:05:36 +0100 Subject: [PATCH 1966/3148] Update frontend to 20250228.0 (#139531) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 5399b22f075..d8eb53467f0 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20250227.0"] + "requirements": ["home-assistant-frontend==20250228.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index b8e0b417353..54401a12592 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -37,7 +37,7 @@ habluetooth==3.24.1 hass-nabucasa==0.92.0 hassil==2.2.3 home-assistant-bluetooth==1.13.1 -home-assistant-frontend==20250227.0 +home-assistant-frontend==20250228.0 home-assistant-intents==2025.2.26 httpx==0.28.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 19a5e9ff261..7e1f7b23240 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1152,7 +1152,7 @@ hole==0.8.0 holidays==0.67 # homeassistant.components.frontend -home-assistant-frontend==20250227.0 +home-assistant-frontend==20250228.0 # homeassistant.components.conversation home-assistant-intents==2025.2.26 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 981c3c129c1..ce309b4460e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -981,7 +981,7 @@ hole==0.8.0 holidays==0.67 # homeassistant.components.frontend -home-assistant-frontend==20250227.0 +home-assistant-frontend==20250228.0 # homeassistant.components.conversation home-assistant-intents==2025.2.26 From 178d509d56749dec2c92b8f2532e834ffd28746a Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Fri, 28 Feb 2025 17:06:59 +0100 Subject: [PATCH 1967/3148] Bump version to 2025.3.0b2 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index f22037b9e1d..e295e6b3b91 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -25,7 +25,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2025 MINOR_VERSION: Final = 3 -PATCH_VERSION: Final = "0b1" +PATCH_VERSION: Final = "0b2" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 13, 0) diff --git a/pyproject.toml b/pyproject.toml index 464b236353f..439cb650a6f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.3.0b1" +version = "2025.3.0b2" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 2e077cbf12586aef2e75433bb75793e44b82a07a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexey=20ALERT=20Rubash=D1=91ff?= Date: Fri, 28 Feb 2025 19:32:07 +0200 Subject: [PATCH 1968/3148] Bump pyoverkiz to 1.16.1 (#139532) --- homeassistant/components/overkiz/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/overkiz/manifest.json b/homeassistant/components/overkiz/manifest.json index c25accd87f3..14f69291be4 100644 --- a/homeassistant/components/overkiz/manifest.json +++ b/homeassistant/components/overkiz/manifest.json @@ -13,7 +13,7 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["boto3", "botocore", "pyhumps", "pyoverkiz", "s3transfer"], - "requirements": ["pyoverkiz==1.16.0"], + "requirements": ["pyoverkiz==1.16.1"], "zeroconf": [ { "type": "_kizbox._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index 69024d3dfbf..dbaa1bd3b1e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2199,7 +2199,7 @@ pyotgw==2.2.2 pyotp==2.8.0 # homeassistant.components.overkiz -pyoverkiz==1.16.0 +pyoverkiz==1.16.1 # homeassistant.components.onewire pyownet==0.10.0.post1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9b1edabb9b6..693e9002389 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1795,7 +1795,7 @@ pyotgw==2.2.2 pyotp==2.8.0 # homeassistant.components.overkiz -pyoverkiz==1.16.0 +pyoverkiz==1.16.1 # homeassistant.components.onewire pyownet==0.10.0.post1 From e9bb4625d8d0fde99d91e9a8bbb7edc6cd6f5383 Mon Sep 17 00:00:00 2001 From: Nathan Spencer Date: Fri, 28 Feb 2025 10:33:58 -0700 Subject: [PATCH 1969/3148] Set device class for wind direction weatherflow entities (#139397) * Set wind_direction device class in weatherflow * Remove measurement state_class from wind direction entities --- homeassistant/components/weatherflow/sensor.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/weatherflow/sensor.py b/homeassistant/components/weatherflow/sensor.py index 683413236c1..8eee472fe5c 100644 --- a/homeassistant/components/weatherflow/sensor.py +++ b/homeassistant/components/weatherflow/sensor.py @@ -267,16 +267,16 @@ SENSORS: tuple[WeatherFlowSensorEntityDescription, ...] = ( WeatherFlowSensorEntityDescription( key="wind_direction", translation_key="wind_direction", + device_class=SensorDeviceClass.WIND_DIRECTION, native_unit_of_measurement=DEGREE, - state_class=SensorStateClass.MEASUREMENT, event_subscriptions=[EVENT_RAPID_WIND, EVENT_OBSERVATION], raw_data_conv_fn=lambda raw_data: raw_data.magnitude, ), WeatherFlowSensorEntityDescription( key="wind_direction_average", translation_key="wind_direction_average", + device_class=SensorDeviceClass.WIND_DIRECTION, native_unit_of_measurement=DEGREE, - state_class=SensorStateClass.MEASUREMENT, raw_data_conv_fn=lambda raw_data: raw_data.magnitude, ), ) From 49c27ae7bc72ce14069eba1ce0e83f5b07669a7f Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Fri, 28 Feb 2025 12:02:30 -0600 Subject: [PATCH 1970/3148] Check area temperature sensors in get temperature intent (#139221) * Check area temperature sensors in get temperature intent * Fix candidate check * Add new code back in * Remove cruft from climate --- homeassistant/components/intent/__init__.py | 73 ++++++++- homeassistant/helpers/intent.py | 22 ++- tests/components/intent/test_temperature.py | 173 ++++++++++++++++++-- 3 files changed, 247 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/intent/__init__.py b/homeassistant/components/intent/__init__.py index 2f9587e2173..922fa376903 100644 --- a/homeassistant/components/intent/__init__.py +++ b/homeassistant/components/intent/__init__.py @@ -2,13 +2,14 @@ from __future__ import annotations +from collections.abc import Collection import logging from typing import Any, Protocol from aiohttp import web import voluptuous as vol -from homeassistant.components import http +from homeassistant.components import http, sensor from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN from homeassistant.components.cover import ( ATTR_POSITION, @@ -40,7 +41,12 @@ from homeassistant.const import ( SERVICE_TURN_ON, ) from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, State -from homeassistant.helpers import config_validation as cv, integration_platform, intent +from homeassistant.helpers import ( + area_registry as ar, + config_validation as cv, + integration_platform, + intent, +) from homeassistant.helpers.typing import ConfigType from homeassistant.util import dt as dt_util @@ -454,6 +460,9 @@ class GetTemperatureIntent(intent.IntentHandler): slot_schema = { vol.Optional("area"): intent.non_empty_string, vol.Optional("name"): intent.non_empty_string, + vol.Optional("floor"): intent.non_empty_string, + vol.Optional("preferred_area_id"): cv.string, + vol.Optional("preferred_floor_id"): cv.string, } platforms = {CLIMATE_DOMAIN} @@ -470,13 +479,71 @@ class GetTemperatureIntent(intent.IntentHandler): if "area" in slots: area = slots["area"]["value"] + floor_name: str | None = None + if "floor" in slots: + floor_name = slots["floor"]["value"] + + match_preferences = intent.MatchTargetsPreferences( + area_id=slots.get("preferred_area_id", {}).get("value"), + floor_id=slots.get("preferred_floor_id", {}).get("value"), + ) + + if (not name) and (area or match_preferences.area_id): + # Look for temperature sensors assigned to an area + area_registry = ar.async_get(hass) + area_temperature_ids: dict[str, str] = {} + + # Keep candidates that are registered as area temperature sensors + def area_candidate_filter( + candidate: intent.MatchTargetsCandidate, + possible_area_ids: Collection[str], + ) -> bool: + for area_id in possible_area_ids: + temperature_id = area_temperature_ids.get(area_id) + if (temperature_id is None) and ( + area_entry := area_registry.async_get_area(area_id) + ): + temperature_id = area_entry.temperature_entity_id or "" + area_temperature_ids[area_id] = temperature_id + + if candidate.state.entity_id == temperature_id: + return True + + return False + + match_constraints = intent.MatchTargetsConstraints( + area_name=area, + floor_name=floor_name, + domains=[sensor.DOMAIN], + device_classes=[sensor.SensorDeviceClass.TEMPERATURE], + assistant=intent_obj.assistant, + single_target=True, + ) + match_result = intent.async_match_targets( + hass, + match_constraints, + match_preferences, + area_candidate_filter=area_candidate_filter, + ) + if match_result.is_match: + # Found temperature sensor + response = intent_obj.create_response() + response.response_type = intent.IntentResponseType.QUERY_ANSWER + response.async_set_states(matched_states=match_result.states) + return response + + # Look for climate devices match_constraints = intent.MatchTargetsConstraints( name=name, area_name=area, + floor_name=floor_name, domains=[CLIMATE_DOMAIN], assistant=intent_obj.assistant, + single_target=True, + ) + match_result = intent.async_match_targets( + hass, match_constraints, match_preferences ) - match_result = intent.async_match_targets(hass, match_constraints) if not match_result.is_match: raise intent.MatchFailedError( result=match_result, constraints=match_constraints diff --git a/homeassistant/helpers/intent.py b/homeassistant/helpers/intent.py index cecb84d0373..0bb96615d3f 100644 --- a/homeassistant/helpers/intent.py +++ b/homeassistant/helpers/intent.py @@ -507,12 +507,22 @@ def _add_areas( candidate.area = areas.async_get_area(candidate.device.area_id) +def _default_area_candidate_filter( + candidate: MatchTargetsCandidate, possible_area_ids: Collection[str] +) -> bool: + """Keep candidates in the possible areas.""" + return (candidate.area is not None) and (candidate.area.id in possible_area_ids) + + @callback def async_match_targets( # noqa: C901 hass: HomeAssistant, constraints: MatchTargetsConstraints, preferences: MatchTargetsPreferences | None = None, states: list[State] | None = None, + area_candidate_filter: Callable[ + [MatchTargetsCandidate, Collection[str]], bool + ] = _default_area_candidate_filter, ) -> MatchTargetsResult: """Match entities based on constraints in order to handle an intent.""" preferences = preferences or MatchTargetsPreferences() @@ -623,9 +633,7 @@ def async_match_targets( # noqa: C901 } candidates = [ - c - for c in candidates - if (c.area is not None) and (c.area.id in possible_area_ids) + c for c in candidates if area_candidate_filter(c, possible_area_ids) ] if not candidates: return MatchTargetsResult( @@ -649,9 +657,7 @@ def async_match_targets( # noqa: C901 # May be constrained by floors above possible_area_ids.intersection_update(matching_area_ids) candidates = [ - c - for c in candidates - if (c.area is not None) and (c.area.id in possible_area_ids) + c for c in candidates if area_candidate_filter(c, possible_area_ids) ] if not candidates: return MatchTargetsResult( @@ -701,7 +707,7 @@ def async_match_targets( # noqa: C901 group_candidates = [ c for c in group_candidates - if (c.area is not None) and (c.area.id == preferences.area_id) + if area_candidate_filter(c, {preferences.area_id}) ] if len(group_candidates) < 2: # Disambiguated by area @@ -747,7 +753,7 @@ def async_match_targets( # noqa: C901 if preferences.area_id: # Filter by area filtered_candidates = [ - c for c in candidates if c.area and (c.area.id == preferences.area_id) + c for c in candidates if area_candidate_filter(c, {preferences.area_id}) ] if (len(filtered_candidates) > 1) and preferences.floor_id: diff --git a/tests/components/intent/test_temperature.py b/tests/components/intent/test_temperature.py index 0279fa44b28..622e55fe24a 100644 --- a/tests/components/intent/test_temperature.py +++ b/tests/components/intent/test_temperature.py @@ -14,10 +14,16 @@ from homeassistant.components.climate import ( HVACMode, ) from homeassistant.components.homeassistant.exposed_entities import async_expose_entity +from homeassistant.components.sensor import SensorDeviceClass from homeassistant.config_entries import ConfigEntry, ConfigFlow -from homeassistant.const import Platform, UnitOfTemperature +from homeassistant.const import ATTR_DEVICE_CLASS, Platform, UnitOfTemperature from homeassistant.core import HomeAssistant -from homeassistant.helpers import area_registry as ar, entity_registry as er, intent +from homeassistant.helpers import ( + area_registry as ar, + entity_registry as er, + floor_registry as fr, + intent, +) from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.setup import async_setup_component @@ -131,6 +137,7 @@ async def test_get_temperature( hass: HomeAssistant, area_registry: ar.AreaRegistry, entity_registry: er.EntityRegistry, + floor_registry: fr.FloorRegistry, ) -> None: """Test HassClimateGetTemperature intent.""" assert await async_setup_component(hass, "homeassistant", {}) @@ -157,29 +164,133 @@ async def test_get_temperature( # Add climate entities to different areas: # climate_1 => living room # climate_2 => bedroom - # nothing in office + # nothing in bathroom + # nothing in office yet + # nothing in attic yet living_room_area = area_registry.async_create(name="Living Room") bedroom_area = area_registry.async_create(name="Bedroom") office_area = area_registry.async_create(name="Office") + attic_area = area_registry.async_create(name="Attic") + bathroom_area = area_registry.async_create(name="Bathroom") entity_registry.async_update_entity( climate_1.entity_id, area_id=living_room_area.id ) entity_registry.async_update_entity(climate_2.entity_id, area_id=bedroom_area.id) - # First climate entity will be selected (no area) + # Put areas on different floors: + # first floor => living room and office + # 2nd floor => bedroom + # 3rd floor => attic + floor_registry = fr.async_get(hass) + first_floor = floor_registry.async_create("First floor") + living_room_area = area_registry.async_update( + living_room_area.id, floor_id=first_floor.floor_id + ) + office_area = area_registry.async_update( + office_area.id, floor_id=first_floor.floor_id + ) + + second_floor = floor_registry.async_create("Second floor") + bedroom_area = area_registry.async_update( + bedroom_area.id, floor_id=second_floor.floor_id + ) + bathroom_area = area_registry.async_update( + bathroom_area.id, floor_id=second_floor.floor_id + ) + + third_floor = floor_registry.async_create("Third floor") + attic_area = area_registry.async_update( + attic_area.id, floor_id=third_floor.floor_id + ) + + # Add temperature sensors to each area that should *not* be selected + for area in (living_room_area, office_area, bedroom_area, attic_area): + wrong_temperature_entry = entity_registry.async_get_or_create( + "sensor", "test", f"wrong_temperature_{area.id}" + ) + hass.states.async_set( + wrong_temperature_entry.entity_id, + "10.0", + { + ATTR_TEMPERATURE: "Temperature", + ATTR_DEVICE_CLASS: SensorDeviceClass.TEMPERATURE, + }, + ) + entity_registry.async_update_entity( + wrong_temperature_entry.entity_id, area_id=area.id + ) + + # Create temperature sensor and assign them to the office/attic + office_temperature_id = "sensor.office_temperature" + attic_temperature_id = "sensor.attic_temperature" + hass.states.async_set( + office_temperature_id, + "15.5", + { + ATTR_TEMPERATURE: "Temperature", + ATTR_DEVICE_CLASS: SensorDeviceClass.TEMPERATURE, + }, + ) + office_area = area_registry.async_update( + office_area.id, temperature_entity_id=office_temperature_id + ) + + hass.states.async_set( + attic_temperature_id, + "18.1", + { + ATTR_TEMPERATURE: "Temperature", + ATTR_DEVICE_CLASS: SensorDeviceClass.TEMPERATURE, + }, + ) + attic_area = area_registry.async_update( + attic_area.id, temperature_entity_id=attic_temperature_id + ) + + # Multiple climate entities match (error) + with pytest.raises(intent.MatchFailedError) as error: + await intent.async_handle( + hass, + "test", + intent.INTENT_GET_TEMPERATURE, + {}, + assistant=conversation.DOMAIN, + ) + + # Exception should contain details of what we tried to match + assert isinstance(error.value, intent.MatchFailedError) + assert ( + error.value.result.no_match_reason == intent.MatchFailedReason.MULTIPLE_TARGETS + ) + + # Select by area (office_temperature) response = await intent.async_handle( hass, "test", intent.INTENT_GET_TEMPERATURE, - {}, + {"area": {"value": office_area.name}}, assistant=conversation.DOMAIN, ) assert response.response_type == intent.IntentResponseType.QUERY_ANSWER - assert response.matched_states - assert response.matched_states[0].entity_id == climate_1.entity_id + assert len(response.matched_states) == 1 + assert response.matched_states[0].entity_id == office_temperature_id state = response.matched_states[0] - assert state.attributes["current_temperature"] == 10.0 + assert state.state == "15.5" + + # Select by preferred area (attic_temperature) + response = await intent.async_handle( + hass, + "test", + intent.INTENT_GET_TEMPERATURE, + {"preferred_area_id": {"value": attic_area.id}}, + assistant=conversation.DOMAIN, + ) + assert response.response_type == intent.IntentResponseType.QUERY_ANSWER + assert len(response.matched_states) == 1 + assert response.matched_states[0].entity_id == attic_temperature_id + state = response.matched_states[0] + assert state.state == "18.1" # Select by area (climate_2) response = await intent.async_handle( @@ -215,7 +326,7 @@ async def test_get_temperature( hass, "test", intent.INTENT_GET_TEMPERATURE, - {"area": {"value": office_area.name}}, + {"area": {"value": bathroom_area.name}}, assistant=conversation.DOMAIN, ) @@ -224,7 +335,7 @@ async def test_get_temperature( assert error.value.result.no_match_reason == intent.MatchFailedReason.AREA constraints = error.value.constraints assert constraints.name is None - assert constraints.area_name == office_area.name + assert constraints.area_name == bathroom_area.name assert constraints.domains and (set(constraints.domains) == {CLIMATE_DOMAIN}) assert constraints.device_classes is None @@ -262,6 +373,48 @@ async def test_get_temperature( assert constraints.domains and (set(constraints.domains) == {CLIMATE_DOMAIN}) assert constraints.device_classes is None + # Select by floor (climate_1) + response = await intent.async_handle( + hass, + "test", + intent.INTENT_GET_TEMPERATURE, + {"floor": {"value": first_floor.name}}, + assistant=conversation.DOMAIN, + ) + assert response.response_type == intent.IntentResponseType.QUERY_ANSWER + assert len(response.matched_states) == 1 + assert response.matched_states[0].entity_id == climate_1.entity_id + state = response.matched_states[0] + assert state.attributes["current_temperature"] == 10.0 + + # Select by preferred area (climate_2) + response = await intent.async_handle( + hass, + "test", + intent.INTENT_GET_TEMPERATURE, + {"preferred_area_id": {"value": bedroom_area.id}}, + assistant=conversation.DOMAIN, + ) + assert response.response_type == intent.IntentResponseType.QUERY_ANSWER + assert len(response.matched_states) == 1 + assert response.matched_states[0].entity_id == climate_2.entity_id + state = response.matched_states[0] + assert state.attributes["current_temperature"] == 22.0 + + # Select by preferred floor (climate_1) + response = await intent.async_handle( + hass, + "test", + intent.INTENT_GET_TEMPERATURE, + {"preferred_floor_id": {"value": first_floor.floor_id}}, + assistant=conversation.DOMAIN, + ) + assert response.response_type == intent.IntentResponseType.QUERY_ANSWER + assert len(response.matched_states) == 1 + assert response.matched_states[0].entity_id == climate_1.entity_id + state = response.matched_states[0] + assert state.attributes["current_temperature"] == 10.0 + async def test_get_temperature_no_entities( hass: HomeAssistant, From 70bb56e0fc07822b5f48ee80f5fa5b7f8cec56b8 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 28 Feb 2025 18:36:12 +0000 Subject: [PATCH 1971/3148] Text-to-Speech refactor (#139482) * Refactor TTS * More cleanup * Cleanup * Consolidate more * Inline another function * Inline another function * Improve cleanup --- homeassistant/components/tts/__init__.py | 586 ++++++++++-------- homeassistant/components/tts/media_source.py | 13 +- tests/components/elevenlabs/test_tts.py | 2 +- tests/components/google_translate/test_tts.py | 2 +- tests/components/marytts/test_tts.py | 2 +- tests/components/microsoft/test_tts.py | 2 +- tests/components/tts/test_init.py | 2 +- tests/components/tts/test_media_source.py | 6 +- tests/components/voicerss/test_tts.py | 6 +- tests/components/yandextts/test_tts.py | 4 +- 10 files changed, 357 insertions(+), 268 deletions(-) diff --git a/homeassistant/components/tts/__init__.py b/homeassistant/components/tts/__init__.py index 6c7e521f3ef..199d644738b 100644 --- a/homeassistant/components/tts/__init__.py +++ b/homeassistant/components/tts/__init__.py @@ -4,6 +4,7 @@ from __future__ import annotations import asyncio from collections.abc import Mapping +from dataclasses import dataclass from datetime import datetime from functools import partial import hashlib @@ -16,6 +17,7 @@ import re import secrets import subprocess import tempfile +from time import monotonic from typing import Any, Final, TypedDict, final from aiohttp import web @@ -37,11 +39,20 @@ from homeassistant.components.media_player import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_ENTITY_ID, + EVENT_HOMEASSISTANT_STOP, PLATFORM_FORMAT, STATE_UNAVAILABLE, STATE_UNKNOWN, ) -from homeassistant.core import HassJob, HomeAssistant, ServiceCall, callback +from homeassistant.core import ( + CALLBACK_TYPE, + Event, + HassJob, + HassJobType, + HomeAssistant, + ServiceCall, + callback, +) from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_component import EntityComponent @@ -129,9 +140,10 @@ SCHEMA_SERVICE_CLEAR_CACHE = vol.Schema({}) class TTSCache(TypedDict): """Cached TTS file.""" - filename: str + extension: str voice: bytes pending: asyncio.Task | None + last_used: float @callback @@ -192,9 +204,11 @@ async def async_get_media_source_audio( media_source_id: str, ) -> tuple[str, bytes]: """Get TTS audio as extension, data.""" - return await hass.data[DATA_TTS_MANAGER].async_get_tts_audio( - **media_source_id_to_kwargs(media_source_id), + manager = hass.data[DATA_TTS_MANAGER] + cache_key = manager.async_cache_message_in_memory( + **media_source_id_to_kwargs(media_source_id) ) + return await manager.async_get_tts_audio(cache_key) @callback @@ -306,11 +320,11 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # Legacy config options conf = config[DOMAIN][0] if config.get(DOMAIN) else {} - use_cache: bool = conf.get(CONF_CACHE, DEFAULT_CACHE) + use_file_cache: bool = conf.get(CONF_CACHE, DEFAULT_CACHE) cache_dir: str = conf.get(CONF_CACHE_DIR, DEFAULT_CACHE_DIR) - time_memory: int = conf.get(CONF_TIME_MEMORY, DEFAULT_TIME_MEMORY) + memory_cache_maxage: int = conf.get(CONF_TIME_MEMORY, DEFAULT_TIME_MEMORY) - tts = SpeechManager(hass, use_cache, cache_dir, time_memory) + tts = SpeechManager(hass, use_file_cache, cache_dir, memory_cache_maxage) try: await tts.async_init_cache() @@ -383,6 +397,40 @@ CACHED_PROPERTIES_WITH_ATTR_ = { } +@dataclass +class ResultStream: + """Class that will stream the result when available.""" + + # Streaming/conversion properties + url: str + extension: str + content_type: str + + # TTS properties + engine: str + use_file_cache: bool + language: str + options: dict + + _manager: SpeechManager + + @cached_property + def _result_cache_key(self) -> asyncio.Future[str]: + """Get the future that returns the cache key.""" + return asyncio.Future() + + @callback + def async_set_message_cache_key(self, cache_key: str) -> None: + """Set cache key for message to be streamed.""" + self._result_cache_key.set_result(cache_key) + + async def async_get_result(self) -> bytes: + """Get the stream of this result.""" + cache_key = await self._result_cache_key + _extension, data = await self._manager.async_get_tts_audio(cache_key) + return data + + class TextToSpeechEntity(RestoreEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Represent a single TTS engine.""" @@ -521,29 +569,82 @@ def _hash_options(options: dict) -> str: return opts_hash.hexdigest() +class MemcacheCleanup: + """Helper to clean up the stale sessions.""" + + unsub: CALLBACK_TYPE | None = None + + def __init__( + self, hass: HomeAssistant, maxage: float, memcache: dict[str, TTSCache] + ) -> None: + """Initialize the cleanup.""" + self.hass = hass + self.maxage = maxage + self.memcache = memcache + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self._on_hass_stop) + self.cleanup_job = HassJob( + self._cleanup, "chat_session_cleanup", job_type=HassJobType.Callback + ) + + @callback + def schedule(self) -> None: + """Schedule the cleanup.""" + if self.unsub: + return + self.unsub = async_call_later( + self.hass, + self.maxage + 1, + self.cleanup_job, + ) + + @callback + def _on_hass_stop(self, event: Event) -> None: + """Cancel the cleanup on shutdown.""" + if self.unsub: + self.unsub() + self.unsub = None + + @callback + def _cleanup(self, _now: datetime) -> None: + """Clean up and schedule follow-up if necessary.""" + self.unsub = None + memcache = self.memcache + maxage = self.maxage + now = monotonic() + + for cache_key, info in list(memcache.items()): + if info["last_used"] + maxage < now: + _LOGGER.debug("Cleaning up %s", cache_key) + del memcache[cache_key] + + # Still items left, check again in timeout time. + if memcache: + self.schedule() + + class SpeechManager: """Representation of a speech store.""" def __init__( self, hass: HomeAssistant, - use_cache: bool, + use_file_cache: bool, cache_dir: str, - time_memory: int, + memory_cache_maxage: int, ) -> None: """Initialize a speech store.""" self.hass = hass self.providers: dict[str, Provider] = {} - self.use_cache = use_cache + self.use_file_cache = use_file_cache self.cache_dir = cache_dir - self.time_memory = time_memory + self.memory_cache_maxage = memory_cache_maxage self.file_cache: dict[str, str] = {} self.mem_cache: dict[str, TTSCache] = {} - - # filename <-> token - self.filename_to_token: dict[str, str] = {} - self.token_to_filename: dict[str, str] = {} + self.token_to_stream: dict[str, ResultStream] = {} + self.memcache_cleanup = MemcacheCleanup( + hass, memory_cache_maxage, self.mem_cache + ) def _init_cache(self) -> dict[str, str]: """Init cache folder and fetch files.""" @@ -563,18 +664,21 @@ class SpeechManager: async def async_clear_cache(self) -> None: """Read file cache and delete files.""" - self.mem_cache = {} + self.mem_cache.clear() - def remove_files() -> None: + def remove_files(files: list[str]) -> None: """Remove files from filesystem.""" - for filename in self.file_cache.values(): + for filename in files: try: os.remove(os.path.join(self.cache_dir, filename)) except OSError as err: _LOGGER.warning("Can't remove cache file '%s': %s", filename, err) - await self.hass.async_add_executor_job(remove_files) - self.file_cache = {} + task = self.hass.async_add_executor_job( + remove_files, list(self.file_cache.values()) + ) + self.file_cache.clear() + await task @callback def async_register_legacy_engine( @@ -629,107 +733,153 @@ class SpeechManager: return language, merged_options - async def async_get_url_path( + @callback + def async_create_result_stream( self, engine: str, - message: str, - cache: bool | None = None, + message: str | None = None, + use_file_cache: bool | None = None, language: str | None = None, options: dict | None = None, - ) -> str: - """Get URL for play message. - - This method is a coroutine. - """ + ) -> ResultStream: + """Create a streaming URL where the rendered TTS can be retrieved.""" if (engine_instance := get_engine_instance(self.hass, engine)) is None: raise HomeAssistantError(f"Provider {engine} not found") language, options = self.process_options(engine_instance, language, options) - cache_key = self._generate_cache_key(message, language, options, engine) - use_cache = cache if cache is not None else self.use_cache + if use_file_cache is None: + use_file_cache = self.use_file_cache - # Is speech already in memory - if cache_key in self.mem_cache: - filename = self.mem_cache[cache_key]["filename"] - # Is file store in file cache - elif use_cache and cache_key in self.file_cache: - filename = self.file_cache[cache_key] - self.hass.async_create_task(self._async_file_to_mem(cache_key)) - # Load speech from engine into memory - else: - filename = await self._async_get_tts_audio( - engine_instance, cache_key, message, use_cache, language, options - ) + extension = options.get(ATTR_PREFERRED_FORMAT, _DEFAULT_FORMAT) + token = f"{secrets.token_urlsafe(16)}.{extension}" + content, _ = mimetypes.guess_type(token) + result_stream = ResultStream( + url=f"/api/tts_proxy/{token}", + extension=extension, + content_type=content or "audio/mpeg", + use_file_cache=use_file_cache, + engine=engine, + language=language, + options=options, + _manager=self, + ) + self.token_to_stream[token] = result_stream - # Use a randomly generated token instead of exposing the filename - token = self.filename_to_token.get(filename) - if not token: - # Keep extension (.mp3, etc.) - token = secrets.token_urlsafe(16) + os.path.splitext(filename)[1] + if message is None: + return result_stream - # Map token <-> filename - self.filename_to_token[filename] = token - self.token_to_filename[token] = filename + cache_key = self._async_ensure_cached_in_memory( + engine=engine, + engine_instance=engine_instance, + message=message, + use_file_cache=use_file_cache, + language=language, + options=options, + ) + result_stream.async_set_message_cache_key(cache_key) - return f"/api/tts_proxy/{token}" - - async def async_get_tts_audio( - self, - engine: str, - message: str, - cache: bool | None = None, - language: str | None = None, - options: dict | None = None, - ) -> tuple[str, bytes]: - """Fetch TTS audio.""" - if (engine_instance := get_engine_instance(self.hass, engine)) is None: - raise HomeAssistantError(f"Provider {engine} not found") - - language, options = self.process_options(engine_instance, language, options) - cache_key = self._generate_cache_key(message, language, options, engine) - use_cache = cache if cache is not None else self.use_cache - - # If we have the file, load it into memory if necessary - if cache_key not in self.mem_cache: - if use_cache and cache_key in self.file_cache: - await self._async_file_to_mem(cache_key) - else: - await self._async_get_tts_audio( - engine_instance, cache_key, message, use_cache, language, options - ) - - extension = os.path.splitext(self.mem_cache[cache_key]["filename"])[1][1:] - cached = self.mem_cache[cache_key] - if pending := cached.get("pending"): - await pending - cached = self.mem_cache[cache_key] - return extension, cached["voice"] + return result_stream @callback - def _generate_cache_key( + def async_cache_message_in_memory( self, - message: str, - language: str, - options: dict | None, engine: str, + message: str, + use_file_cache: bool | None = None, + language: str | None = None, + options: dict | None = None, ) -> str: - """Generate a cache key for a message.""" + """Make sure a message is cached in memory and returns cache key.""" + if (engine_instance := get_engine_instance(self.hass, engine)) is None: + raise HomeAssistantError(f"Provider {engine} not found") + + language, options = self.process_options(engine_instance, language, options) + if use_file_cache is None: + use_file_cache = self.use_file_cache + + return self._async_ensure_cached_in_memory( + engine=engine, + engine_instance=engine_instance, + message=message, + use_file_cache=use_file_cache, + language=language, + options=options, + ) + + @callback + def _async_ensure_cached_in_memory( + self, + engine: str, + engine_instance: TextToSpeechEntity | Provider, + message: str, + use_file_cache: bool, + language: str, + options: dict, + ) -> str: + """Ensure a message is cached. + + Requires options, language to be processed. + """ options_key = _hash_options(options) if options else "-" msg_hash = hashlib.sha1(bytes(message, "utf-8")).hexdigest() - return KEY_PATTERN.format( + cache_key = KEY_PATTERN.format( msg_hash, language.replace("_", "-"), options_key, engine ).lower() - async def _async_get_tts_audio( + # Is speech already in memory + if cache_key in self.mem_cache: + return cache_key + + if use_file_cache and cache_key in self.file_cache: + coro = self._async_load_file_to_mem(cache_key) + else: + coro = self._async_generate_tts_audio( + engine_instance, cache_key, message, use_file_cache, language, options + ) + + task = self.hass.async_create_task(coro, eager_start=False) + + def handle_error(future: asyncio.Future) -> None: + """Handle error.""" + if not (err := future.exception()): + return + # Truncate message so we don't flood the logs. Cutting off at 32 chars + # but since we add 3 dots to truncated message, we cut off at 35. + trunc_msg = message if len(message) < 35 else f"{message[0:32]}…" + _LOGGER.error("Error generating audio for %s: %s", trunc_msg, err) + self.mem_cache.pop(cache_key, None) + + task.add_done_callback(handle_error) + + self.mem_cache[cache_key] = { + "extension": "", + "voice": b"", + "pending": task, + "last_used": monotonic(), + } + return cache_key + + async def async_get_tts_audio(self, cache_key: str) -> tuple[str, bytes]: + """Fetch TTS audio.""" + cached = self.mem_cache.get(cache_key) + if cached is None: + raise HomeAssistantError("Audio not cached") + if pending := cached.get("pending"): + await pending + cached = self.mem_cache[cache_key] + cached["last_used"] = monotonic() + return cached["extension"], cached["voice"] + + async def _async_generate_tts_audio( self, engine_instance: TextToSpeechEntity | Provider, cache_key: str, message: str, - cache: bool, + cache_to_disk: bool, language: str, options: dict[str, Any], - ) -> str: - """Receive TTS, store for view in cache and return filename. + ) -> None: + """Start loading of the TTS audio. This method is a coroutine. """ @@ -773,96 +923,66 @@ class SpeechManager: if sample_bytes is not None: sample_bytes = int(sample_bytes) - async def get_tts_data() -> str: - """Handle data available.""" - if engine_instance.name is None or engine_instance.name is UNDEFINED: - raise HomeAssistantError("TTS engine name is not set.") + if engine_instance.name is None or engine_instance.name is UNDEFINED: + raise HomeAssistantError("TTS engine name is not set.") - if isinstance(engine_instance, Provider): - extension, data = await engine_instance.async_get_tts_audio( - message, language, options - ) - else: - extension, data = await engine_instance.internal_async_get_tts_audio( - message, language, options - ) - - if data is None or extension is None: - raise HomeAssistantError( - f"No TTS from {engine_instance.name} for '{message}'" - ) - - # Only convert if we have a preferred format different than the - # expected format from the TTS system, or if a specific sample - # rate/format/channel count is requested. - needs_conversion = ( - (final_extension != extension) - or (sample_rate is not None) - or (sample_channels is not None) - or (sample_bytes is not None) + if isinstance(engine_instance, Provider): + extension, data = await engine_instance.async_get_tts_audio( + message, language, options + ) + else: + extension, data = await engine_instance.internal_async_get_tts_audio( + message, language, options ) - if needs_conversion: - data = await async_convert_audio( - self.hass, - extension, - data, - to_extension=final_extension, - to_sample_rate=sample_rate, - to_sample_channels=sample_channels, - to_sample_bytes=sample_bytes, - ) + if data is None or extension is None: + raise HomeAssistantError( + f"No TTS from {engine_instance.name} for '{message}'" + ) - # Create file infos - filename = f"{cache_key}.{final_extension}".lower() + # Only convert if we have a preferred format different than the + # expected format from the TTS system, or if a specific sample + # rate/format/channel count is requested. + needs_conversion = ( + (final_extension != extension) + or (sample_rate is not None) + or (sample_channels is not None) + or (sample_bytes is not None) + ) - # Validate filename - if not _RE_VOICE_FILE.match(filename) and not _RE_LEGACY_VOICE_FILE.match( - filename - ): - raise HomeAssistantError( - f"TTS filename '{filename}' from {engine_instance.name} is invalid!" - ) - - # Save to memory - if final_extension == "mp3": - data = self.write_tags( - filename, data, engine_instance.name, message, language, options - ) - - self._async_store_to_memcache(cache_key, filename, data) - - if cache: - self.hass.async_create_task( - self._async_save_tts_audio(cache_key, filename, data) - ) - - return filename - - audio_task = self.hass.async_create_task(get_tts_data(), eager_start=False) - - def handle_error(_future: asyncio.Future) -> None: - """Handle error.""" - if audio_task.exception(): - self.mem_cache.pop(cache_key, None) - - audio_task.add_done_callback(handle_error) + if needs_conversion: + data = await async_convert_audio( + self.hass, + extension, + data, + to_extension=final_extension, + to_sample_rate=sample_rate, + to_sample_channels=sample_channels, + to_sample_bytes=sample_bytes, + ) + # Create file infos filename = f"{cache_key}.{final_extension}".lower() - self.mem_cache[cache_key] = { - "filename": filename, - "voice": b"", - "pending": audio_task, - } - return filename - async def _async_save_tts_audio( - self, cache_key: str, filename: str, data: bytes - ) -> None: - """Store voice data to file and file_cache. + # Validate filename + if not _RE_VOICE_FILE.match(filename) and not _RE_LEGACY_VOICE_FILE.match( + filename + ): + raise HomeAssistantError( + f"TTS filename '{filename}' from {engine_instance.name} is invalid!" + ) + + # Save to memory + if final_extension == "mp3": + data = self.write_tags( + filename, data, engine_instance.name, message, language, options + ) + + self._async_store_to_memcache(cache_key, final_extension, data) + + if not cache_to_disk: + return - This method is a coroutine. - """ voice_file = os.path.join(self.cache_dir, filename) def save_speech() -> None: @@ -870,13 +990,19 @@ class SpeechManager: with open(voice_file, "wb") as speech: speech.write(data) - try: - await self.hass.async_add_executor_job(save_speech) - self.file_cache[cache_key] = filename - except OSError as err: - _LOGGER.error("Can't write %s: %s", filename, err) + # Don't await, we're going to do this in the background + task = self.hass.async_add_executor_job(save_speech) - async def _async_file_to_mem(self, cache_key: str) -> None: + def write_done(future: asyncio.Future) -> None: + """Write is done task.""" + if err := future.exception(): + _LOGGER.error("Can't write %s: %s", filename, err) + else: + self.file_cache[cache_key] = filename + + task.add_done_callback(write_done) + + async def _async_load_file_to_mem(self, cache_key: str) -> None: """Load voice from file cache into memory. This method is a coroutine. @@ -897,64 +1023,22 @@ class SpeechManager: del self.file_cache[cache_key] raise HomeAssistantError(f"Can't read {voice_file}") from err - self._async_store_to_memcache(cache_key, filename, data) + extension = os.path.splitext(filename)[1][1:] + + self._async_store_to_memcache(cache_key, extension, data) @callback def _async_store_to_memcache( - self, cache_key: str, filename: str, data: bytes + self, cache_key: str, extension: str, data: bytes ) -> None: """Store data to memcache and set timer to remove it.""" self.mem_cache[cache_key] = { - "filename": filename, + "extension": extension, "voice": data, "pending": None, + "last_used": monotonic(), } - - @callback - def async_remove_from_mem(_: datetime) -> None: - """Cleanup memcache.""" - self.mem_cache.pop(cache_key, None) - - async_call_later( - self.hass, - self.time_memory, - HassJob( - async_remove_from_mem, - name="tts remove_from_mem", - cancel_on_shutdown=True, - ), - ) - - async def async_read_tts(self, token: str) -> tuple[str | None, bytes]: - """Read a voice file and return binary. - - This method is a coroutine. - """ - filename = self.token_to_filename.get(token) - if not filename: - raise HomeAssistantError(f"{token} was not recognized!") - - if not (record := _RE_VOICE_FILE.match(filename.lower())) and not ( - record := _RE_LEGACY_VOICE_FILE.match(filename.lower()) - ): - raise HomeAssistantError("Wrong tts file format!") - - cache_key = KEY_PATTERN.format( - record.group(1), record.group(2), record.group(3), record.group(4) - ) - - if cache_key not in self.mem_cache: - if cache_key not in self.file_cache: - raise HomeAssistantError(f"{cache_key} not in cache!") - await self._async_file_to_mem(cache_key) - - cached = self.mem_cache[cache_key] - if pending := cached.get("pending"): - await pending - cached = self.mem_cache[cache_key] - - content, _ = mimetypes.guess_type(filename) - return content, cached["voice"] + self.memcache_cleanup.schedule() @staticmethod def write_tags( @@ -1042,9 +1126,9 @@ class TextToSpeechUrlView(HomeAssistantView): url = "/api/tts_get_url" name = "api:tts:geturl" - def __init__(self, tts: SpeechManager) -> None: + def __init__(self, manager: SpeechManager) -> None: """Initialize a tts view.""" - self.tts = tts + self.manager = manager async def post(self, request: web.Request) -> web.Response: """Generate speech and provide url.""" @@ -1061,45 +1145,53 @@ class TextToSpeechUrlView(HomeAssistantView): engine = data.get("engine_id") or data[ATTR_PLATFORM] message = data[ATTR_MESSAGE] - cache = data.get(ATTR_CACHE) + use_file_cache = data.get(ATTR_CACHE) language = data.get(ATTR_LANGUAGE) options = data.get(ATTR_OPTIONS) try: - path = await self.tts.async_get_url_path( - engine, message, cache=cache, language=language, options=options + stream = self.manager.async_create_result_stream( + engine, + message, + use_file_cache=use_file_cache, + language=language, + options=options, ) except HomeAssistantError as err: _LOGGER.error("Error on init tts: %s", err) return self.json({"error": err}, HTTPStatus.BAD_REQUEST) - base = get_url(self.tts.hass) - url = base + path + base = get_url(self.manager.hass) + url = base + stream.url - return self.json({"url": url, "path": path}) + return self.json({"url": url, "path": stream.url}) class TextToSpeechView(HomeAssistantView): """TTS view to serve a speech audio.""" requires_auth = False - url = "/api/tts_proxy/{filename}" + url = "/api/tts_proxy/{token}" name = "api:tts_speech" - def __init__(self, tts: SpeechManager) -> None: + def __init__(self, manager: SpeechManager) -> None: """Initialize a tts view.""" - self.tts = tts + self.manager = manager - async def get(self, request: web.Request, filename: str) -> web.Response: + async def get(self, request: web.Request, token: str) -> web.Response: """Start a get request.""" - try: - # filename is actually token, but we keep its name for compatibility - content, data = await self.tts.async_read_tts(filename) - except HomeAssistantError as err: - _LOGGER.error("Error on load tts: %s", err) + stream = self.manager.token_to_stream.get(token) + + if stream is None: return web.Response(status=HTTPStatus.NOT_FOUND) - return web.Response(body=data, content_type=content) + try: + data = await stream.async_get_result() + except HomeAssistantError as err: + _LOGGER.error("Error on get tts: %s", err) + return web.Response(status=HTTPStatus.INTERNAL_SERVER_ERROR) + + return web.Response(body=data, content_type=stream.content_type) @websocket_api.websocket_command( diff --git a/homeassistant/components/tts/media_source.py b/homeassistant/components/tts/media_source.py index 4f1fa59f001..aa2cd6e7555 100644 --- a/homeassistant/components/tts/media_source.py +++ b/homeassistant/components/tts/media_source.py @@ -3,7 +3,6 @@ from __future__ import annotations import json -import mimetypes from typing import TypedDict from yarl import URL @@ -73,7 +72,7 @@ class MediaSourceOptions(TypedDict): message: str language: str | None options: dict | None - cache: bool | None + use_file_cache: bool | None @callback @@ -98,10 +97,10 @@ def media_source_id_to_kwargs(media_source_id: str) -> MediaSourceOptions: "message": parsed.query["message"], "language": parsed.query.get("language"), "options": options, - "cache": None, + "use_file_cache": None, } if "cache" in parsed.query: - kwargs["cache"] = parsed.query["cache"] == "true" + kwargs["use_file_cache"] = parsed.query["cache"] == "true" return kwargs @@ -119,7 +118,7 @@ class TTSMediaSource(MediaSource): async def async_resolve_media(self, item: MediaSourceItem) -> PlayMedia: """Resolve media to a url.""" try: - url = await self.hass.data[DATA_TTS_MANAGER].async_get_url_path( + stream = self.hass.data[DATA_TTS_MANAGER].async_create_result_stream( **media_source_id_to_kwargs(item.identifier) ) except Unresolvable: @@ -127,9 +126,7 @@ class TTSMediaSource(MediaSource): except HomeAssistantError as err: raise Unresolvable(str(err)) from err - mime_type = mimetypes.guess_type(url)[0] or "audio/mpeg" - - return PlayMedia(url, mime_type) + return PlayMedia(stream.url, stream.content_type) async def async_browse_media( self, diff --git a/tests/components/elevenlabs/test_tts.py b/tests/components/elevenlabs/test_tts.py index c4234cb38ae..a63672cc85d 100644 --- a/tests/components/elevenlabs/test_tts.py +++ b/tests/components/elevenlabs/test_tts.py @@ -350,7 +350,7 @@ async def test_tts_service_speak_error( assert len(calls) == 1 assert ( await retrieve_media(hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID]) - == HTTPStatus.NOT_FOUND + == HTTPStatus.INTERNAL_SERVER_ERROR ) tts_entity._client.generate.assert_called_once_with( diff --git a/tests/components/google_translate/test_tts.py b/tests/components/google_translate/test_tts.py index 5b691da4bdc..54ad47405a1 100644 --- a/tests/components/google_translate/test_tts.py +++ b/tests/components/google_translate/test_tts.py @@ -475,6 +475,6 @@ async def test_service_say_error( await retrieve_media( hass, hass_client, service_calls[1].data[ATTR_MEDIA_CONTENT_ID] ) - == HTTPStatus.NOT_FOUND + == HTTPStatus.INTERNAL_SERVER_ERROR ) assert len(mock_gtts.mock_calls) == 2 diff --git a/tests/components/marytts/test_tts.py b/tests/components/marytts/test_tts.py index 0ad27cde29b..25231c15a32 100644 --- a/tests/components/marytts/test_tts.py +++ b/tests/components/marytts/test_tts.py @@ -155,7 +155,7 @@ async def test_service_say_http_error( await retrieve_media( hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID] ) - == HTTPStatus.NOT_FOUND + == HTTPStatus.INTERNAL_SERVER_ERROR ) mock_speak.assert_called_once() diff --git a/tests/components/microsoft/test_tts.py b/tests/components/microsoft/test_tts.py index e10ec589113..38f1318a683 100644 --- a/tests/components/microsoft/test_tts.py +++ b/tests/components/microsoft/test_tts.py @@ -366,7 +366,7 @@ async def test_service_say_error( await retrieve_media( hass, hass_client, service_calls[1].data[ATTR_MEDIA_CONTENT_ID] ) - == HTTPStatus.NOT_FOUND + == HTTPStatus.INTERNAL_SERVER_ERROR ) assert len(mock_tts.mock_calls) == 2 diff --git a/tests/components/tts/test_init.py b/tests/components/tts/test_init.py index 4d0767cddf3..86ca2de5791 100644 --- a/tests/components/tts/test_init.py +++ b/tests/components/tts/test_init.py @@ -1197,7 +1197,7 @@ async def test_service_get_tts_error( assert len(calls) == 1 assert ( await retrieve_media(hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID]) - == HTTPStatus.NOT_FOUND + == HTTPStatus.INTERNAL_SERVER_ERROR ) diff --git a/tests/components/tts/test_media_source.py b/tests/components/tts/test_media_source.py index d90923b02ab..9e50cc6b512 100644 --- a/tests/components/tts/test_media_source.py +++ b/tests/components/tts/test_media_source.py @@ -268,7 +268,7 @@ async def test_generate_media_source_id_and_media_source_id_to_kwargs( "message": "hello", "language": "en_US", "options": {"age": 5}, - "cache": True, + "use_file_cache": True, } kwargs = { @@ -284,7 +284,7 @@ async def test_generate_media_source_id_and_media_source_id_to_kwargs( "message": "hello", "language": "en_US", "options": {"age": [5, 6]}, - "cache": True, + "use_file_cache": True, } kwargs = { @@ -300,5 +300,5 @@ async def test_generate_media_source_id_and_media_source_id_to_kwargs( "message": "hello", "language": "en_US", "options": {"age": {"k1": [5, 6], "k2": "v2"}}, - "cache": True, + "use_file_cache": True, } diff --git a/tests/components/voicerss/test_tts.py b/tests/components/voicerss/test_tts.py index 776c0ac153a..e6a30d7fac2 100644 --- a/tests/components/voicerss/test_tts.py +++ b/tests/components/voicerss/test_tts.py @@ -200,7 +200,7 @@ async def test_service_say_error( assert ( await retrieve_media(hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID]) - == HTTPStatus.NOT_FOUND + == HTTPStatus.INTERNAL_SERVER_ERROR ) assert len(aioclient_mock.mock_calls) == 1 assert aioclient_mock.mock_calls[0][2] == FORM_DATA @@ -234,7 +234,7 @@ async def test_service_say_timeout( assert ( await retrieve_media(hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID]) - == HTTPStatus.NOT_FOUND + == HTTPStatus.INTERNAL_SERVER_ERROR ) assert len(aioclient_mock.mock_calls) == 1 assert aioclient_mock.mock_calls[0][2] == FORM_DATA @@ -273,7 +273,7 @@ async def test_service_say_error_msg( assert ( await retrieve_media(hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID]) - == HTTPStatus.NOT_FOUND + == HTTPStatus.INTERNAL_SERVER_ERROR ) assert len(aioclient_mock.mock_calls) == 1 assert aioclient_mock.mock_calls[0][2] == FORM_DATA diff --git a/tests/components/yandextts/test_tts.py b/tests/components/yandextts/test_tts.py index 77878c2be51..098fc025bf3 100644 --- a/tests/components/yandextts/test_tts.py +++ b/tests/components/yandextts/test_tts.py @@ -223,7 +223,7 @@ async def test_service_say_timeout( assert len(calls) == 1 assert ( await retrieve_media(hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID]) - == HTTPStatus.NOT_FOUND + == HTTPStatus.INTERNAL_SERVER_ERROR ) assert len(aioclient_mock.mock_calls) == 1 @@ -269,7 +269,7 @@ async def test_service_say_http_error( assert len(calls) == 1 assert ( await retrieve_media(hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID]) - == HTTPStatus.NOT_FOUND + == HTTPStatus.INTERNAL_SERVER_ERROR ) From bf27ccce17bbaf7bed0378165dec0ef89ad866c2 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Fri, 28 Feb 2025 19:58:26 +0100 Subject: [PATCH 1972/3148] Clarify description of `icloud.update` action (#139535) Currently the description of the `icloud.update` action can be easily misunderstood as just updating the device list or forcing a software update on all devices. This commit changes the description to make clear that it asks for a state update of all devices. --- homeassistant/components/icloud/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/icloud/strings.json b/homeassistant/components/icloud/strings.json index adc96043d66..fc78e8c2ba6 100644 --- a/homeassistant/components/icloud/strings.json +++ b/homeassistant/components/icloud/strings.json @@ -46,7 +46,7 @@ "services": { "update": { "name": "Update", - "description": "Updates iCloud devices.", + "description": "Asks for a state update of all devices linked to an iCloud account.", "fields": { "account": { "name": "Account", From 086c91485ff527cfead19b9c0792e9d3503a22c7 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 28 Feb 2025 20:03:24 +0100 Subject: [PATCH 1973/3148] Set SmartThings delta energy to Total (#139474) --- .../components/smartthings/sensor.py | 2 +- .../smartthings/snapshots/test_sensor.ambr | 24 +++++++++---------- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index cd12bf46e25..0a695876da4 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -596,7 +596,7 @@ CAPABILITY_TO_SENSORS: dict[ SmartThingsSensorEntityDescription( key="deltaEnergy_meter", translation_key="energy_difference", - state_class=SensorStateClass.TOTAL_INCREASING, + state_class=SensorStateClass.TOTAL, device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, value_fn=lambda value: value["deltaEnergy"] / 1000, diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index b67d15bef55..78aa4db62f8 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -582,7 +582,7 @@ }), 'area_id': None, 'capabilities': dict({ - 'state_class': , + 'state_class': , }), 'config_entry_id': , 'config_subentry_id': , @@ -620,7 +620,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'AC Office Granit Energy difference', - 'state_class': , + 'state_class': , 'unit_of_measurement': , }), 'context': , @@ -1011,7 +1011,7 @@ }), 'area_id': None, 'capabilities': dict({ - 'state_class': , + 'state_class': , }), 'config_entry_id': , 'config_subentry_id': , @@ -1049,7 +1049,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Aire Dormitorio Principal Energy difference', - 'state_class': , + 'state_class': , 'unit_of_measurement': , }), 'context': , @@ -1835,7 +1835,7 @@ }), 'area_id': None, 'capabilities': dict({ - 'state_class': , + 'state_class': , }), 'config_entry_id': , 'config_subentry_id': , @@ -1873,7 +1873,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Refrigerator Energy difference', - 'state_class': , + 'state_class': , 'unit_of_measurement': , }), 'context': , @@ -2408,7 +2408,7 @@ }), 'area_id': None, 'capabilities': dict({ - 'state_class': , + 'state_class': , }), 'config_entry_id': , 'config_subentry_id': , @@ -2446,7 +2446,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Dishwasher Energy difference', - 'state_class': , + 'state_class': , 'unit_of_measurement': , }), 'context': , @@ -2865,7 +2865,7 @@ }), 'area_id': None, 'capabilities': dict({ - 'state_class': , + 'state_class': , }), 'config_entry_id': , 'config_subentry_id': , @@ -2903,7 +2903,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Dryer Energy difference', - 'state_class': , + 'state_class': , 'unit_of_measurement': , }), 'context': , @@ -3332,7 +3332,7 @@ }), 'area_id': None, 'capabilities': dict({ - 'state_class': , + 'state_class': , }), 'config_entry_id': , 'config_subentry_id': , @@ -3370,7 +3370,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Washer Energy difference', - 'state_class': , + 'state_class': , 'unit_of_measurement': , }), 'context': , From 90fc6ffdbfec3038b0d022a4692c4a0f9cc0de8c Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 28 Feb 2025 19:15:31 +0000 Subject: [PATCH 1974/3148] Add support for continue conversation in Assist Pipeline (#139480) * Add support for continue conversation in Assist Pipeline * Also forward to ESPHome * Update snapshot * And mobile app --- .../components/assist_pipeline/__init__.py | 2 +- .../components/assist_pipeline/pipeline.py | 99 +++++++++-- .../assist_pipeline/websocket_api.py | 2 +- .../components/conversation/models.py | 2 + .../components/esphome/assist_satellite.py | 5 +- .../snapshots/test_conversation.ambr | 1 + tests/components/assist_pipeline/conftest.py | 15 +- .../assist_pipeline/snapshots/test_init.ambr | 21 ++- .../snapshots/test_websocket.ambr | 7 + tests/components/assist_pipeline/test_init.py | 168 ++++++++++++++++-- tests/components/conversation/__init__.py | 3 +- .../conversation/snapshots/test_chat_log.ambr | 2 + .../snapshots/test_default_agent.ambr | 19 ++ .../conversation/snapshots/test_http.ambr | 12 ++ .../conversation/snapshots/test_init.ambr | 13 ++ .../esphome/test_assist_satellite.py | 29 ++- tests/components/mobile_app/test_webhook.py | 1 + .../ollama/snapshots/test_conversation.ambr | 1 + tests/syrupy.py | 6 +- 19 files changed, 362 insertions(+), 46 deletions(-) diff --git a/homeassistant/components/assist_pipeline/__init__.py b/homeassistant/components/assist_pipeline/__init__.py index 9a32821e3a0..59bd987d90e 100644 --- a/homeassistant/components/assist_pipeline/__init__.py +++ b/homeassistant/components/assist_pipeline/__init__.py @@ -117,7 +117,7 @@ async def async_pipeline_from_audio_stream( """ with chat_session.async_get_chat_session(hass, conversation_id) as session: pipeline_input = PipelineInput( - conversation_id=session.conversation_id, + session=session, device_id=device_id, stt_metadata=stt_metadata, stt_stream=stt_stream, diff --git a/homeassistant/components/assist_pipeline/pipeline.py b/homeassistant/components/assist_pipeline/pipeline.py index 75811a0ec36..038874d1966 100644 --- a/homeassistant/components/assist_pipeline/pipeline.py +++ b/homeassistant/components/assist_pipeline/pipeline.py @@ -96,6 +96,9 @@ ENGINE_LANGUAGE_PAIRS = ( ) KEY_ASSIST_PIPELINE: HassKey[PipelineData] = HassKey(DOMAIN) +KEY_PIPELINE_CONVERSATION_DATA: HassKey[dict[str, PipelineConversationData]] = HassKey( + "pipeline_conversation_data" +) def validate_language(data: dict[str, Any]) -> Any: @@ -590,6 +593,12 @@ class PipelineRun: _device_id: str | None = None """Optional device id set during run start.""" + _conversation_data: PipelineConversationData | None = None + """Data tied to the conversation ID.""" + + _intent_agent_only = False + """If request should only be handled by agent, ignoring sentence triggers and local processing.""" + def __post_init__(self) -> None: """Set language for pipeline.""" self.language = self.pipeline.language or self.hass.config.language @@ -1007,19 +1016,36 @@ class PipelineRun: yield chunk.audio - async def prepare_recognize_intent(self) -> None: + async def prepare_recognize_intent(self, session: chat_session.ChatSession) -> None: """Prepare recognizing an intent.""" - agent_info = conversation.async_get_agent_info( - self.hass, - self.pipeline.conversation_engine or conversation.HOME_ASSISTANT_AGENT, + self._conversation_data = async_get_pipeline_conversation_data( + self.hass, session ) - if agent_info is None: - engine = self.pipeline.conversation_engine or "default" - raise IntentRecognitionError( - code="intent-not-supported", - message=f"Intent recognition engine {engine} is not found", + if self._conversation_data.continue_conversation_agent is not None: + agent_info = conversation.async_get_agent_info( + self.hass, self._conversation_data.continue_conversation_agent ) + self._conversation_data.continue_conversation_agent = None + if agent_info is None: + raise IntentRecognitionError( + code="intent-agent-not-found", + message=f"Intent recognition engine {self._conversation_data.continue_conversation_agent} asked for follow-up but is no longer found", + ) + self._intent_agent_only = True + + else: + agent_info = conversation.async_get_agent_info( + self.hass, + self.pipeline.conversation_engine or conversation.HOME_ASSISTANT_AGENT, + ) + + if agent_info is None: + engine = self.pipeline.conversation_engine or "default" + raise IntentRecognitionError( + code="intent-not-supported", + message=f"Intent recognition engine {engine} is not found", + ) self.intent_agent = agent_info.id @@ -1031,7 +1057,7 @@ class PipelineRun: conversation_extra_system_prompt: str | None, ) -> str: """Run intent recognition portion of pipeline. Returns text to speak.""" - if self.intent_agent is None: + if self.intent_agent is None or self._conversation_data is None: raise RuntimeError("Recognize intent was not prepared") if self.pipeline.conversation_language == MATCH_ALL: @@ -1078,7 +1104,7 @@ class PipelineRun: agent_id = self.intent_agent processed_locally = agent_id == conversation.HOME_ASSISTANT_AGENT intent_response: intent.IntentResponse | None = None - if not processed_locally: + if not processed_locally and not self._intent_agent_only: # Sentence triggers override conversation agent if ( trigger_response_text @@ -1195,6 +1221,9 @@ class PipelineRun: ) ) + if conversation_result.continue_conversation: + self._conversation_data.continue_conversation_agent = agent_id + return speech async def prepare_text_to_speech(self) -> None: @@ -1458,8 +1487,8 @@ class PipelineInput: run: PipelineRun - conversation_id: str - """Identifier for the conversation.""" + session: chat_session.ChatSession + """Session for the conversation.""" stt_metadata: stt.SpeechMetadata | None = None """Metadata of stt input audio. Required when start_stage = stt.""" @@ -1484,7 +1513,9 @@ class PipelineInput: async def execute(self) -> None: """Run pipeline.""" - self.run.start(conversation_id=self.conversation_id, device_id=self.device_id) + self.run.start( + conversation_id=self.session.conversation_id, device_id=self.device_id + ) current_stage: PipelineStage | None = self.run.start_stage stt_audio_buffer: list[EnhancedAudioChunk] = [] stt_processed_stream: AsyncIterable[EnhancedAudioChunk] | None = None @@ -1568,7 +1599,7 @@ class PipelineInput: assert intent_input is not None tts_input = await self.run.recognize_intent( intent_input, - self.conversation_id, + self.session.conversation_id, self.device_id, self.conversation_extra_system_prompt, ) @@ -1652,7 +1683,7 @@ class PipelineInput: <= PIPELINE_STAGE_ORDER.index(PipelineStage.INTENT) <= end_stage_index ): - prepare_tasks.append(self.run.prepare_recognize_intent()) + prepare_tasks.append(self.run.prepare_recognize_intent(self.session)) if ( start_stage_index @@ -1931,7 +1962,7 @@ class PipelineRunDebug: class PipelineStore(Store[SerializedPipelineStorageCollection]): - """Store entity registry data.""" + """Store pipeline data.""" async def _async_migrate_func( self, @@ -2013,3 +2044,37 @@ async def async_run_migrations(hass: HomeAssistant) -> None: for pipeline, attr_updates in updates: await async_update_pipeline(hass, pipeline, **attr_updates) + + +@dataclass +class PipelineConversationData: + """Hold data for the duration of a conversation.""" + + continue_conversation_agent: str | None = None + """The agent that requested the conversation to be continued.""" + + +@callback +def async_get_pipeline_conversation_data( + hass: HomeAssistant, session: chat_session.ChatSession +) -> PipelineConversationData: + """Get the pipeline data for a specific conversation.""" + all_conversation_data = hass.data.get(KEY_PIPELINE_CONVERSATION_DATA) + if all_conversation_data is None: + all_conversation_data = {} + hass.data[KEY_PIPELINE_CONVERSATION_DATA] = all_conversation_data + + data = all_conversation_data.get(session.conversation_id) + + if data is not None: + return data + + @callback + def do_cleanup() -> None: + """Handle cleanup.""" + all_conversation_data.pop(session.conversation_id) + + session.async_on_cleanup(do_cleanup) + + data = all_conversation_data[session.conversation_id] = PipelineConversationData() + return data diff --git a/homeassistant/components/assist_pipeline/websocket_api.py b/homeassistant/components/assist_pipeline/websocket_api.py index d2d54a1b7c3..937b3a0ea45 100644 --- a/homeassistant/components/assist_pipeline/websocket_api.py +++ b/homeassistant/components/assist_pipeline/websocket_api.py @@ -239,7 +239,7 @@ async def websocket_run( with chat_session.async_get_chat_session( hass, msg.get("conversation_id") ) as session: - input_args["conversation_id"] = session.conversation_id + input_args["session"] = session pipeline_input = PipelineInput(**input_args) try: diff --git a/homeassistant/components/conversation/models.py b/homeassistant/components/conversation/models.py index 08a68fa0164..7bdd13afc01 100644 --- a/homeassistant/components/conversation/models.py +++ b/homeassistant/components/conversation/models.py @@ -62,12 +62,14 @@ class ConversationResult: response: intent.IntentResponse conversation_id: str | None = None + continue_conversation: bool = False def as_dict(self) -> dict[str, Any]: """Return result as a dict.""" return { "response": self.response.as_dict(), "conversation_id": self.conversation_id, + "continue_conversation": self.continue_conversation, } diff --git a/homeassistant/components/esphome/assist_satellite.py b/homeassistant/components/esphome/assist_satellite.py index 016b1c3494d..0af74621153 100644 --- a/homeassistant/components/esphome/assist_satellite.py +++ b/homeassistant/components/esphome/assist_satellite.py @@ -284,7 +284,10 @@ class EsphomeAssistSatellite( elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_END: assert event.data is not None data_to_send = { - "conversation_id": event.data["intent_output"]["conversation_id"] or "", + "conversation_id": event.data["intent_output"]["conversation_id"], + "continue_conversation": event.data["intent_output"][ + "continue_conversation" + ], } elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_TTS_START: assert event.data is not None diff --git a/tests/components/anthropic/snapshots/test_conversation.ambr b/tests/components/anthropic/snapshots/test_conversation.ambr index 93f3b03d9af..de414019317 100644 --- a/tests/components/anthropic/snapshots/test_conversation.ambr +++ b/tests/components/anthropic/snapshots/test_conversation.ambr @@ -1,6 +1,7 @@ # serializer version: 1 # name: test_unknown_hass_api dict({ + 'continue_conversation': False, 'conversation_id': '1234', 'response': IntentResponse( card=dict({ diff --git a/tests/components/assist_pipeline/conftest.py b/tests/components/assist_pipeline/conftest.py index 02ec7c04607..a0549f27f05 100644 --- a/tests/components/assist_pipeline/conftest.py +++ b/tests/components/assist_pipeline/conftest.py @@ -5,7 +5,7 @@ from __future__ import annotations from collections.abc import AsyncIterable, Generator from pathlib import Path from typing import Any -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, patch import pytest @@ -24,7 +24,7 @@ from homeassistant.components.assist_pipeline.pipeline import ( from homeassistant.config_entries import ConfigEntry, ConfigFlow from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import chat_session, device_registry as dr from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.setup import async_setup_component @@ -379,3 +379,14 @@ def pipeline_storage(pipeline_data) -> PipelineStorageCollection: def make_10ms_chunk(header: bytes) -> bytes: """Return 10ms of zeros with the given header.""" return header + bytes(BYTES_PER_CHUNK - len(header)) + + +@pytest.fixture +def mock_chat_session(hass: HomeAssistant) -> Generator[chat_session.ChatSession]: + """Mock the ulid of chat sessions.""" + # pylint: disable-next=contextmanager-generator-missing-cleanup + with ( + patch("homeassistant.helpers.chat_session.ulid_now", return_value="mock-ulid"), + chat_session.async_get_chat_session(hass) as session, + ): + yield session diff --git a/tests/components/assist_pipeline/snapshots/test_init.ambr b/tests/components/assist_pipeline/snapshots/test_init.ambr index 11e6bc2339a..f5e5f813db6 100644 --- a/tests/components/assist_pipeline/snapshots/test_init.ambr +++ b/tests/components/assist_pipeline/snapshots/test_init.ambr @@ -45,6 +45,7 @@ dict({ 'data': dict({ 'intent_output': dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -137,6 +138,7 @@ dict({ 'data': dict({ 'intent_output': dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -229,6 +231,7 @@ dict({ 'data': dict({ 'intent_output': dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -345,6 +348,7 @@ dict({ 'data': dict({ 'intent_output': dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -432,7 +436,7 @@ list([ dict({ 'data': dict({ - 'conversation_id': 'mock-conversation-id', + 'conversation_id': 'mock-ulid', 'language': 'en', 'pipeline': , }), @@ -440,7 +444,7 @@ }), dict({ 'data': dict({ - 'conversation_id': 'mock-conversation-id', + 'conversation_id': 'mock-ulid', 'device_id': None, 'engine': 'conversation.home_assistant', 'intent_input': 'test input', @@ -452,6 +456,7 @@ dict({ 'data': dict({ 'intent_output': dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -484,7 +489,7 @@ list([ dict({ 'data': dict({ - 'conversation_id': 'mock-conversation-id', + 'conversation_id': 'mock-ulid', 'language': 'en', 'pipeline': , }), @@ -492,7 +497,7 @@ }), dict({ 'data': dict({ - 'conversation_id': 'mock-conversation-id', + 'conversation_id': 'mock-ulid', 'device_id': None, 'engine': 'conversation.home_assistant', 'intent_input': 'test input', @@ -504,6 +509,7 @@ dict({ 'data': dict({ 'intent_output': dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -536,7 +542,7 @@ list([ dict({ 'data': dict({ - 'conversation_id': 'mock-conversation-id', + 'conversation_id': 'mock-ulid', 'language': 'en', 'pipeline': , }), @@ -544,7 +550,7 @@ }), dict({ 'data': dict({ - 'conversation_id': 'mock-conversation-id', + 'conversation_id': 'mock-ulid', 'device_id': None, 'engine': 'conversation.home_assistant', 'intent_input': 'test input', @@ -556,6 +562,7 @@ dict({ 'data': dict({ 'intent_output': dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -588,7 +595,7 @@ list([ dict({ 'data': dict({ - 'conversation_id': 'mock-conversation-id', + 'conversation_id': 'mock-ulid', 'language': 'en', 'pipeline': , }), diff --git a/tests/components/assist_pipeline/snapshots/test_websocket.ambr b/tests/components/assist_pipeline/snapshots/test_websocket.ambr index f677fa6d8cf..509f2072509 100644 --- a/tests/components/assist_pipeline/snapshots/test_websocket.ambr +++ b/tests/components/assist_pipeline/snapshots/test_websocket.ambr @@ -43,6 +43,7 @@ # name: test_audio_pipeline.4 dict({ 'intent_output': dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -127,6 +128,7 @@ # name: test_audio_pipeline_debug.4 dict({ 'intent_output': dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -223,6 +225,7 @@ # name: test_audio_pipeline_with_enhancements.4 dict({ 'intent_output': dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -329,6 +332,7 @@ # name: test_audio_pipeline_with_wake_word_no_timeout.6 dict({ 'intent_output': dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -596,6 +600,7 @@ # name: test_pipeline_empty_tts_output.2 dict({ 'intent_output': dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -715,6 +720,7 @@ # name: test_text_only_pipeline[extra_msg0].2 dict({ 'intent_output': dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -762,6 +768,7 @@ # name: test_text_only_pipeline[extra_msg1].2 dict({ 'intent_output': dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ diff --git a/tests/components/assist_pipeline/test_init.py b/tests/components/assist_pipeline/test_init.py index 1651950c173..e983e4a96e3 100644 --- a/tests/components/assist_pipeline/test_init.py +++ b/tests/components/assist_pipeline/test_init.py @@ -27,7 +27,7 @@ from homeassistant.components.assist_pipeline.const import ( ) from homeassistant.const import MATCH_ALL from homeassistant.core import Context, HomeAssistant -from homeassistant.helpers import intent +from homeassistant.helpers import chat_session, intent from homeassistant.setup import async_setup_component from .conftest import ( @@ -675,6 +675,7 @@ async def test_wake_word_detection_aborted( mock_wake_word_provider_entity: MockWakeWordEntity, init_components, pipeline_data: assist_pipeline.pipeline.PipelineData, + mock_chat_session: chat_session.ChatSession, snapshot: SnapshotAssertion, ) -> None: """Test creating a pipeline from an audio stream with wake word.""" @@ -693,7 +694,7 @@ async def test_wake_word_detection_aborted( pipeline = assist_pipeline.pipeline.async_get_pipeline(hass, pipeline_id) pipeline_input = assist_pipeline.pipeline.PipelineInput( - conversation_id="mock-conversation-id", + session=mock_chat_session, device_id=None, stt_metadata=stt.SpeechMetadata( language="", @@ -766,6 +767,7 @@ async def test_tts_audio_output( mock_tts_provider: MockTTSProvider, init_components, pipeline_data: assist_pipeline.pipeline.PipelineData, + mock_chat_session: chat_session.ChatSession, snapshot: SnapshotAssertion, ) -> None: """Test using tts_audio_output with wav sets options correctly.""" @@ -780,7 +782,7 @@ async def test_tts_audio_output( pipeline_input = assist_pipeline.pipeline.PipelineInput( tts_input="This is a test.", - conversation_id="mock-conversation-id", + session=mock_chat_session, device_id=None, run=assist_pipeline.pipeline.PipelineRun( hass, @@ -823,6 +825,7 @@ async def test_tts_wav_preferred_format( hass_client: ClientSessionGenerator, mock_tts_provider: MockTTSProvider, init_components, + mock_chat_session: chat_session.ChatSession, pipeline_data: assist_pipeline.pipeline.PipelineData, ) -> None: """Test that preferred format options are given to the TTS system if supported.""" @@ -837,7 +840,7 @@ async def test_tts_wav_preferred_format( pipeline_input = assist_pipeline.pipeline.PipelineInput( tts_input="This is a test.", - conversation_id="mock-conversation-id", + session=mock_chat_session, device_id=None, run=assist_pipeline.pipeline.PipelineRun( hass, @@ -891,6 +894,7 @@ async def test_tts_dict_preferred_format( hass_client: ClientSessionGenerator, mock_tts_provider: MockTTSProvider, init_components, + mock_chat_session: chat_session.ChatSession, pipeline_data: assist_pipeline.pipeline.PipelineData, ) -> None: """Test that preferred format options are given to the TTS system if supported.""" @@ -905,7 +909,7 @@ async def test_tts_dict_preferred_format( pipeline_input = assist_pipeline.pipeline.PipelineInput( tts_input="This is a test.", - conversation_id="mock-conversation-id", + session=mock_chat_session, device_id=None, run=assist_pipeline.pipeline.PipelineRun( hass, @@ -962,6 +966,7 @@ async def test_tts_dict_preferred_format( async def test_sentence_trigger_overrides_conversation_agent( hass: HomeAssistant, init_components, + mock_chat_session: chat_session.ChatSession, pipeline_data: assist_pipeline.pipeline.PipelineData, ) -> None: """Test that sentence triggers are checked before a non-default conversation agent.""" @@ -991,7 +996,7 @@ async def test_sentence_trigger_overrides_conversation_agent( pipeline_input = assist_pipeline.pipeline.PipelineInput( intent_input="test trigger sentence", - conversation_id="mock-conversation-id", + session=mock_chat_session, run=assist_pipeline.pipeline.PipelineRun( hass, context=Context(), @@ -1039,6 +1044,7 @@ async def test_sentence_trigger_overrides_conversation_agent( async def test_prefer_local_intents( hass: HomeAssistant, init_components, + mock_chat_session: chat_session.ChatSession, pipeline_data: assist_pipeline.pipeline.PipelineData, ) -> None: """Test that the default agent is checked first when local intents are preferred.""" @@ -1069,7 +1075,7 @@ async def test_prefer_local_intents( pipeline_input = assist_pipeline.pipeline.PipelineInput( intent_input="I'd like to order a stout please", - conversation_id="mock-conversation-id", + session=mock_chat_session, run=assist_pipeline.pipeline.PipelineRun( hass, context=Context(), @@ -1113,10 +1119,150 @@ async def test_prefer_local_intents( ) +async def test_intent_continue_conversation( + hass: HomeAssistant, + init_components, + mock_chat_session: chat_session.ChatSession, + pipeline_data: assist_pipeline.pipeline.PipelineData, +) -> None: + """Test that a conversation agent flagging continue conversation gets response.""" + events: list[assist_pipeline.PipelineEvent] = [] + + # Fake a test agent and prefer local intents + pipeline_store = pipeline_data.pipeline_store + pipeline_id = pipeline_store.async_get_preferred_item() + pipeline = assist_pipeline.pipeline.async_get_pipeline(hass, pipeline_id) + await assist_pipeline.pipeline.async_update_pipeline( + hass, pipeline, conversation_engine="test-agent" + ) + pipeline = assist_pipeline.pipeline.async_get_pipeline(hass, pipeline_id) + + pipeline_input = assist_pipeline.pipeline.PipelineInput( + intent_input="Set a timer", + session=mock_chat_session, + run=assist_pipeline.pipeline.PipelineRun( + hass, + context=Context(), + pipeline=pipeline, + start_stage=assist_pipeline.PipelineStage.INTENT, + end_stage=assist_pipeline.PipelineStage.INTENT, + event_callback=events.append, + ), + ) + + # Ensure prepare succeeds + with patch( + "homeassistant.components.assist_pipeline.pipeline.conversation.async_get_agent_info", + return_value=conversation.AgentInfo(id="test-agent", name="Test Agent"), + ): + await pipeline_input.validate() + + response = intent.IntentResponse("en") + response.async_set_speech("For how long?") + + with patch( + "homeassistant.components.assist_pipeline.pipeline.conversation.async_converse", + return_value=conversation.ConversationResult( + response=response, + conversation_id=mock_chat_session.conversation_id, + continue_conversation=True, + ), + ) as mock_async_converse: + await pipeline_input.execute() + + mock_async_converse.assert_called() + + results = [ + event.data + for event in events + if event.type + in ( + assist_pipeline.PipelineEventType.INTENT_START, + assist_pipeline.PipelineEventType.INTENT_END, + ) + ] + assert results[1]["intent_output"]["continue_conversation"] is True + + # Change conversation agent to default one and register sentence trigger that should not be called + await assist_pipeline.pipeline.async_update_pipeline( + hass, pipeline, conversation_engine=None + ) + pipeline = assist_pipeline.pipeline.async_get_pipeline(hass, pipeline_id) + assert await async_setup_component( + hass, + "automation", + { + "automation": { + "trigger": { + "platform": "conversation", + "command": ["Hello"], + }, + "action": { + "set_conversation_response": "test trigger response", + }, + } + }, + ) + + # Because we did continue conversation, it should respond to the test agent again. + events.clear() + + pipeline_input = assist_pipeline.pipeline.PipelineInput( + intent_input="Hello", + session=mock_chat_session, + run=assist_pipeline.pipeline.PipelineRun( + hass, + context=Context(), + pipeline=pipeline, + start_stage=assist_pipeline.PipelineStage.INTENT, + end_stage=assist_pipeline.PipelineStage.INTENT, + event_callback=events.append, + ), + ) + + # Ensure prepare succeeds + with patch( + "homeassistant.components.assist_pipeline.pipeline.conversation.async_get_agent_info", + return_value=conversation.AgentInfo(id="test-agent", name="Test Agent"), + ) as mock_prepare: + await pipeline_input.validate() + + # It requested test agent even if that was not default agent. + assert mock_prepare.mock_calls[0][1][1] == "test-agent" + + response = intent.IntentResponse("en") + response.async_set_speech("Timer set for 20 minutes") + + with patch( + "homeassistant.components.assist_pipeline.pipeline.conversation.async_converse", + return_value=conversation.ConversationResult( + response=response, + conversation_id=mock_chat_session.conversation_id, + ), + ) as mock_async_converse: + await pipeline_input.execute() + + mock_async_converse.assert_called() + + # Snapshot will show it was still handled by the test agent and not default agent + results = [ + event.data + for event in events + if event.type + in ( + assist_pipeline.PipelineEventType.INTENT_START, + assist_pipeline.PipelineEventType.INTENT_END, + ) + ] + assert results[0]["engine"] == "test-agent" + assert results[1]["intent_output"]["continue_conversation"] is False + + async def test_stt_language_used_instead_of_conversation_language( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, init_components, + mock_chat_session: chat_session.ChatSession, snapshot: SnapshotAssertion, ) -> None: """Test that the STT language is used first when the conversation language is '*' (all languages).""" @@ -1147,7 +1293,7 @@ async def test_stt_language_used_instead_of_conversation_language( pipeline_input = assist_pipeline.pipeline.PipelineInput( intent_input="test input", - conversation_id="mock-conversation-id", + session=mock_chat_session, run=assist_pipeline.pipeline.PipelineRun( hass, context=Context(), @@ -1192,6 +1338,7 @@ async def test_tts_language_used_instead_of_conversation_language( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, init_components, + mock_chat_session: chat_session.ChatSession, snapshot: SnapshotAssertion, ) -> None: """Test that the TTS language is used after STT when the conversation language is '*' (all languages).""" @@ -1222,7 +1369,7 @@ async def test_tts_language_used_instead_of_conversation_language( pipeline_input = assist_pipeline.pipeline.PipelineInput( intent_input="test input", - conversation_id="mock-conversation-id", + session=mock_chat_session, run=assist_pipeline.pipeline.PipelineRun( hass, context=Context(), @@ -1267,6 +1414,7 @@ async def test_pipeline_language_used_instead_of_conversation_language( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, init_components, + mock_chat_session: chat_session.ChatSession, snapshot: SnapshotAssertion, ) -> None: """Test that the pipeline language is used last when the conversation language is '*' (all languages).""" @@ -1297,7 +1445,7 @@ async def test_pipeline_language_used_instead_of_conversation_language( pipeline_input = assist_pipeline.pipeline.PipelineInput( intent_input="test input", - conversation_id="mock-conversation-id", + session=mock_chat_session, run=assist_pipeline.pipeline.PipelineRun( hass, context=Context(), diff --git a/tests/components/conversation/__init__.py b/tests/components/conversation/__init__.py index 314188dbd82..eeab8b6b9af 100644 --- a/tests/components/conversation/__init__.py +++ b/tests/components/conversation/__init__.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import AsyncGenerator from dataclasses import dataclass, field from typing import Literal from unittest.mock import patch @@ -49,7 +50,7 @@ class MockAgent(conversation.AbstractConversationAgent): @pytest.fixture -async def mock_chat_log(hass: HomeAssistant) -> MockChatLog: +async def mock_chat_log(hass: HomeAssistant) -> AsyncGenerator[MockChatLog]: """Return mock chat logs.""" # pylint: disable-next=contextmanager-generator-missing-cleanup with ( diff --git a/tests/components/conversation/snapshots/test_chat_log.ambr b/tests/components/conversation/snapshots/test_chat_log.ambr index 1ddbf68bb84..ff8ebf724cd 100644 --- a/tests/components/conversation/snapshots/test_chat_log.ambr +++ b/tests/components/conversation/snapshots/test_chat_log.ambr @@ -151,6 +151,7 @@ # --- # name: test_template_error dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -171,6 +172,7 @@ # --- # name: test_unknown_llm_api dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ diff --git a/tests/components/conversation/snapshots/test_default_agent.ambr b/tests/components/conversation/snapshots/test_default_agent.ambr index c2b16ea2912..02e4ef1befe 100644 --- a/tests/components/conversation/snapshots/test_default_agent.ambr +++ b/tests/components/conversation/snapshots/test_default_agent.ambr @@ -1,6 +1,7 @@ # serializer version: 1 # name: test_custom_sentences dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -26,6 +27,7 @@ # --- # name: test_custom_sentences.1 dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -51,6 +53,7 @@ # --- # name: test_custom_sentences_config dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -76,6 +79,7 @@ # --- # name: test_intent_alias_added_removed dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -106,6 +110,7 @@ # --- # name: test_intent_alias_added_removed.1 dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -136,6 +141,7 @@ # --- # name: test_intent_alias_added_removed.2 dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -156,6 +162,7 @@ # --- # name: test_intent_conversion_not_expose_new dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -176,6 +183,7 @@ # --- # name: test_intent_conversion_not_expose_new.1 dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -206,6 +214,7 @@ # --- # name: test_intent_entity_added_removed dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -236,6 +245,7 @@ # --- # name: test_intent_entity_added_removed.1 dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -266,6 +276,7 @@ # --- # name: test_intent_entity_added_removed.2 dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -296,6 +307,7 @@ # --- # name: test_intent_entity_added_removed.3 dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -316,6 +328,7 @@ # --- # name: test_intent_entity_exposed dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -346,6 +359,7 @@ # --- # name: test_intent_entity_fail_if_unexposed dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -366,6 +380,7 @@ # --- # name: test_intent_entity_remove_custom_name dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -386,6 +401,7 @@ # --- # name: test_intent_entity_remove_custom_name.1 dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -416,6 +432,7 @@ # --- # name: test_intent_entity_remove_custom_name.2 dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -436,6 +453,7 @@ # --- # name: test_intent_entity_renamed dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -466,6 +484,7 @@ # --- # name: test_intent_entity_renamed.1 dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ diff --git a/tests/components/conversation/snapshots/test_http.ambr b/tests/components/conversation/snapshots/test_http.ambr index c6ac6c2df9c..849a5b17102 100644 --- a/tests/components/conversation/snapshots/test_http.ambr +++ b/tests/components/conversation/snapshots/test_http.ambr @@ -202,6 +202,7 @@ # --- # name: test_http_api_handle_failure dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -222,6 +223,7 @@ # --- # name: test_http_api_no_match dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -242,6 +244,7 @@ # --- # name: test_http_api_unexpected_failure dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -262,6 +265,7 @@ # --- # name: test_http_processing_intent[None] dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -292,6 +296,7 @@ # --- # name: test_http_processing_intent[conversation.home_assistant] dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -322,6 +327,7 @@ # --- # name: test_http_processing_intent[homeassistant] dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -352,6 +358,7 @@ # --- # name: test_ws_api[payload0] dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -372,6 +379,7 @@ # --- # name: test_ws_api[payload1] dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -392,6 +400,7 @@ # --- # name: test_ws_api[payload2] dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -412,6 +421,7 @@ # --- # name: test_ws_api[payload3] dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -432,6 +442,7 @@ # --- # name: test_ws_api[payload4] dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -452,6 +463,7 @@ # --- # name: test_ws_api[payload5] dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ diff --git a/tests/components/conversation/snapshots/test_init.ambr b/tests/components/conversation/snapshots/test_init.ambr index 911c7043a6d..3d843d4e32a 100644 --- a/tests/components/conversation/snapshots/test_init.ambr +++ b/tests/components/conversation/snapshots/test_init.ambr @@ -1,6 +1,7 @@ # serializer version: 1 # name: test_custom_agent dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -44,6 +45,7 @@ # --- # name: test_turn_on_intent[None-turn kitchen on-None] dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -74,6 +76,7 @@ # --- # name: test_turn_on_intent[None-turn kitchen on-conversation.home_assistant] dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -104,6 +107,7 @@ # --- # name: test_turn_on_intent[None-turn kitchen on-homeassistant] dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -134,6 +138,7 @@ # --- # name: test_turn_on_intent[None-turn on kitchen-None] dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -164,6 +169,7 @@ # --- # name: test_turn_on_intent[None-turn on kitchen-conversation.home_assistant] dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -194,6 +200,7 @@ # --- # name: test_turn_on_intent[None-turn on kitchen-homeassistant] dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -224,6 +231,7 @@ # --- # name: test_turn_on_intent[my_new_conversation-turn kitchen on-None] dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -254,6 +262,7 @@ # --- # name: test_turn_on_intent[my_new_conversation-turn kitchen on-conversation.home_assistant] dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -284,6 +293,7 @@ # --- # name: test_turn_on_intent[my_new_conversation-turn kitchen on-homeassistant] dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -314,6 +324,7 @@ # --- # name: test_turn_on_intent[my_new_conversation-turn on kitchen-None] dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -344,6 +355,7 @@ # --- # name: test_turn_on_intent[my_new_conversation-turn on kitchen-conversation.home_assistant] dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -374,6 +386,7 @@ # --- # name: test_turn_on_intent[my_new_conversation-turn on kitchen-homeassistant] dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ diff --git a/tests/components/esphome/test_assist_satellite.py b/tests/components/esphome/test_assist_satellite.py index 30535236970..56914a0b829 100644 --- a/tests/components/esphome/test_assist_satellite.py +++ b/tests/components/esphome/test_assist_satellite.py @@ -25,7 +25,7 @@ from aioesphomeapi import ( ) import pytest -from homeassistant.components import assist_satellite, tts +from homeassistant.components import assist_satellite, conversation, tts from homeassistant.components.assist_pipeline import PipelineEvent, PipelineEventType from homeassistant.components.assist_satellite import ( AssistSatelliteConfiguration, @@ -285,12 +285,21 @@ async def test_pipeline_api_audio( event_callback( PipelineEvent( type=PipelineEventType.INTENT_END, - data={"intent_output": {"conversation_id": conversation_id}}, + data={ + "intent_output": conversation.ConversationResult( + response=intent_helper.IntentResponse("en"), + conversation_id=conversation_id, + continue_conversation=True, + ).as_dict() + }, ) ) assert mock_client.send_voice_assistant_event.call_args_list[-1].args == ( VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_END, - {"conversation_id": conversation_id}, + { + "conversation_id": conversation_id, + "continue_conversation": True, + }, ) # TTS @@ -484,7 +493,12 @@ async def test_pipeline_udp_audio( event_callback( PipelineEvent( type=PipelineEventType.INTENT_END, - data={"intent_output": {"conversation_id": conversation_id}}, + data={ + "intent_output": conversation.ConversationResult( + response=intent_helper.IntentResponse("en"), + conversation_id=conversation_id, + ).as_dict() + }, ) ) @@ -690,7 +704,12 @@ async def test_pipeline_media_player( event_callback( PipelineEvent( type=PipelineEventType.INTENT_END, - data={"intent_output": {"conversation_id": conversation_id}}, + data={ + "intent_output": conversation.ConversationResult( + response=intent_helper.IntentResponse("en"), + conversation_id=conversation_id, + ).as_dict() + }, ) ) diff --git a/tests/components/mobile_app/test_webhook.py b/tests/components/mobile_app/test_webhook.py index dda5f369ad5..b071caebd16 100644 --- a/tests/components/mobile_app/test_webhook.py +++ b/tests/components/mobile_app/test_webhook.py @@ -1081,6 +1081,7 @@ async def test_webhook_handle_conversation_process( }, }, "conversation_id": None, + "continue_conversation": False, } diff --git a/tests/components/ollama/snapshots/test_conversation.ambr b/tests/components/ollama/snapshots/test_conversation.ambr index 93f3b03d9af..de414019317 100644 --- a/tests/components/ollama/snapshots/test_conversation.ambr +++ b/tests/components/ollama/snapshots/test_conversation.ambr @@ -1,6 +1,7 @@ # serializer version: 1 # name: test_unknown_hass_api dict({ + 'continue_conversation': False, 'conversation_id': '1234', 'response': IntentResponse( card=dict({ diff --git a/tests/syrupy.py b/tests/syrupy.py index 3c8e398f0f8..e028d5839cb 100644 --- a/tests/syrupy.py +++ b/tests/syrupy.py @@ -109,7 +109,11 @@ class HomeAssistantSnapshotSerializer(AmberDataSerializer): serializable_data = cls._serializable_issue_registry_entry(data) elif isinstance(data, dict) and "flow_id" in data and "handler" in data: serializable_data = cls._serializable_flow_result(data) - elif isinstance(data, dict) and set(data) == {"conversation_id", "response"}: + elif isinstance(data, dict) and set(data) == { + "conversation_id", + "response", + "continue_conversation", + }: serializable_data = cls._serializable_conversation_result(data) elif isinstance(data, vol.Schema): serializable_data = voluptuous_serialize.convert(data) From 39bc37d22568cfc3add4c205cb030bd2a3dbd083 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 28 Feb 2025 20:33:25 +0100 Subject: [PATCH 1975/3148] Remove orphan devices on startup in SmartThings (#139541) --- .../components/smartthings/__init__.py | 17 ++++++++++++++- tests/components/smartthings/test_init.py | 21 +++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py index 4bc9b270360..d6de1d3d252 100644 --- a/homeassistant/components/smartthings/__init__.py +++ b/homeassistant/components/smartthings/__init__.py @@ -21,13 +21,14 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.config_entry_oauth2_flow import ( OAuth2Session, async_get_config_entry_implementation, ) -from .const import CONF_INSTALLED_APP_ID, CONF_LOCATION_ID, MAIN, OLD_DATA +from .const import CONF_INSTALLED_APP_ID, CONF_LOCATION_ID, DOMAIN, MAIN, OLD_DATA _LOGGER = logging.getLogger(__name__) @@ -123,6 +124,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: SmartThingsConfigEntry) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + device_registry = dr.async_get(hass) + device_entries = dr.async_entries_for_config_entry(device_registry, entry.entry_id) + for device_entry in device_entries: + device_id = next( + identifier[1] + for identifier in device_entry.identifiers + if identifier[0] == DOMAIN + ) + if device_id in entry.runtime_data.devices: + continue + device_registry.async_update_device( + device_entry.id, remove_config_entry_id=entry.entry_id + ) + return True diff --git a/tests/components/smartthings/test_init.py b/tests/components/smartthings/test_init.py index be88f11903e..372f23eec42 100644 --- a/tests/components/smartthings/test_init.py +++ b/tests/components/smartthings/test_init.py @@ -2,6 +2,7 @@ from unittest.mock import AsyncMock +import pytest from syrupy import SnapshotAssertion from homeassistant.components.smartthings.const import DOMAIN @@ -29,3 +30,23 @@ async def test_devices( assert device is not None assert device == snapshot + + +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) +async def test_removing_stale_devices( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test removing stale devices.""" + mock_config_entry.add_to_hass(hass) + device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={(DOMAIN, "aaa-bbb-ccc")}, + ) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert not device_registry.async_get_device({(DOMAIN, "aaa-bbb-ccc")}) From 455363871f99e041e649c09b313a001134cc9620 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 28 Feb 2025 20:39:49 +0100 Subject: [PATCH 1976/3148] Use last event as color mode in SmartThings (#139473) * Use last event as color mode in SmartThings * Use last event as color mode in SmartThings * Fix --- homeassistant/components/smartthings/light.py | 37 +++--- tests/components/smartthings/test_light.py | 116 +++++++++++++++++- 2 files changed, 135 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/smartthings/light.py b/homeassistant/components/smartthings/light.py index 54e8ad18a7c..aa3a8d35859 100644 --- a/homeassistant/components/smartthings/light.py +++ b/homeassistant/components/smartthings/light.py @@ -3,12 +3,13 @@ from __future__ import annotations import asyncio -from typing import Any +from typing import Any, cast -from pysmartthings import Attribute, Capability, Command, SmartThings +from pysmartthings import Attribute, Capability, Command, DeviceEvent, SmartThings from homeassistant.components.light import ( ATTR_BRIGHTNESS, + ATTR_COLOR_MODE, ATTR_COLOR_TEMP_KELVIN, ATTR_HS_COLOR, ATTR_TRANSITION, @@ -19,6 +20,7 @@ from homeassistant.components.light import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.restore_state import RestoreEntity from . import FullDevice, SmartThingsConfigEntry from .const import MAIN @@ -53,7 +55,7 @@ def convert_scale( return round(value * target_scale / value_scale, round_digits) -class SmartThingsLight(SmartThingsEntity, LightEntity): +class SmartThingsLight(SmartThingsEntity, LightEntity, RestoreEntity): """Define a SmartThings Light.""" _attr_name = None @@ -84,18 +86,28 @@ class SmartThingsLight(SmartThingsEntity, LightEntity): color_modes = set() if self.supports_capability(Capability.COLOR_TEMPERATURE): color_modes.add(ColorMode.COLOR_TEMP) + self._attr_color_mode = ColorMode.COLOR_TEMP if self.supports_capability(Capability.COLOR_CONTROL): color_modes.add(ColorMode.HS) + self._attr_color_mode = ColorMode.HS if not color_modes and self.supports_capability(Capability.SWITCH_LEVEL): color_modes.add(ColorMode.BRIGHTNESS) if not color_modes: color_modes.add(ColorMode.ONOFF) + if len(color_modes) == 1: + self._attr_color_mode = list(color_modes)[0] self._attr_supported_color_modes = color_modes features = LightEntityFeature(0) if self.supports_capability(Capability.SWITCH_LEVEL): features |= LightEntityFeature.TRANSITION self._attr_supported_features = features + async def async_added_to_hass(self) -> None: + """Run when entity about to be added to hass.""" + await super().async_added_to_hass() + if (last_state := await self.async_get_last_extra_data()) is not None: + self._attr_color_mode = last_state.as_dict()[ATTR_COLOR_MODE] + async def async_turn_on(self, **kwargs: Any) -> None: """Turn the light on.""" tasks = [] @@ -195,17 +207,14 @@ class SmartThingsLight(SmartThingsEntity, LightEntity): argument=[level, duration], ) - @property - def color_mode(self) -> ColorMode: - """Return the color mode of the light.""" - if len(self._attr_supported_color_modes) == 1: - # The light supports only a single color mode - return list(self._attr_supported_color_modes)[0] - - # The light supports hs + color temp, determine which one it is - if self._attr_hs_color and self._attr_hs_color[1]: - return ColorMode.HS - return ColorMode.COLOR_TEMP + def _update_handler(self, event: DeviceEvent) -> None: + """Handle device updates.""" + if event.capability in (Capability.COLOR_CONTROL, Capability.COLOR_TEMPERATURE): + self._attr_color_mode = { + Capability.COLOR_CONTROL: ColorMode.HS, + Capability.COLOR_TEMPERATURE: ColorMode.COLOR_TEMP, + }[cast(Capability, event.capability)] + super()._update_handler(event) @property def is_on(self) -> bool: diff --git a/tests/components/smartthings/test_light.py b/tests/components/smartthings/test_light.py index 8d47e90c9f5..56eadde748b 100644 --- a/tests/components/smartthings/test_light.py +++ b/tests/components/smartthings/test_light.py @@ -12,7 +12,12 @@ from homeassistant.components.light import ( ATTR_COLOR_MODE, ATTR_COLOR_TEMP_KELVIN, ATTR_HS_COLOR, + ATTR_MAX_COLOR_TEMP_KELVIN, + ATTR_MIN_COLOR_TEMP_KELVIN, + ATTR_RGB_COLOR, + ATTR_SUPPORTED_COLOR_MODES, ATTR_TRANSITION, + ATTR_XY_COLOR, DOMAIN as LIGHT_DOMAIN, ColorMode, ) @@ -25,7 +30,7 @@ from homeassistant.const import ( STATE_ON, Platform, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, State from homeassistant.helpers import entity_registry as er from . import ( @@ -35,7 +40,7 @@ from . import ( trigger_update, ) -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, mock_restore_cache_with_extra_data async def test_all_entities( @@ -228,6 +233,15 @@ async def test_updating_brightness( set_attribute_value(devices, Capability.SWITCH, Attribute.SWITCH, "on") await setup_integration(hass, mock_config_entry) + await trigger_update( + hass, + devices, + "cb958955-b015-498c-9e62-fc0c51abd054", + Capability.COLOR_CONTROL, + Attribute.HUE, + 40, + ) + assert hass.states.get("light.standing_light").attributes[ATTR_BRIGHTNESS] == 178 await trigger_update( @@ -252,8 +266,17 @@ async def test_updating_hs( set_attribute_value(devices, Capability.SWITCH, Attribute.SWITCH, "on") await setup_integration(hass, mock_config_entry) + await trigger_update( + hass, + devices, + "cb958955-b015-498c-9e62-fc0c51abd054", + Capability.COLOR_CONTROL, + Attribute.HUE, + 40, + ) + assert hass.states.get("light.standing_light").attributes[ATTR_HS_COLOR] == ( - 218.906, + 144.0, 60, ) @@ -280,9 +303,17 @@ async def test_updating_color_temp( ) -> None: """Test color temperature update.""" set_attribute_value(devices, Capability.SWITCH, Attribute.SWITCH, "on") - set_attribute_value(devices, Capability.COLOR_CONTROL, Attribute.SATURATION, 0) await setup_integration(hass, mock_config_entry) + await trigger_update( + hass, + devices, + "cb958955-b015-498c-9e62-fc0c51abd054", + Capability.COLOR_TEMPERATURE, + Attribute.COLOR_TEMPERATURE, + 3000, + ) + assert ( hass.states.get("light.standing_light").attributes[ATTR_COLOR_MODE] is ColorMode.COLOR_TEMP @@ -305,3 +336,80 @@ async def test_updating_color_temp( hass.states.get("light.standing_light").attributes[ATTR_COLOR_TEMP_KELVIN] == 2000 ) + + +@pytest.mark.parametrize("device_fixture", ["hue_rgbw_color_bulb"]) +async def test_color_modes( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test color mode changes.""" + set_attribute_value(devices, Capability.SWITCH, Attribute.SWITCH, "on") + set_attribute_value(devices, Capability.COLOR_CONTROL, Attribute.SATURATION, 50) + await setup_integration(hass, mock_config_entry) + + assert ( + hass.states.get("light.standing_light").attributes[ATTR_COLOR_MODE] + is ColorMode.HS + ) + + await trigger_update( + hass, + devices, + "cb958955-b015-498c-9e62-fc0c51abd054", + Capability.COLOR_TEMPERATURE, + Attribute.COLOR_TEMPERATURE, + 2000, + ) + + assert ( + hass.states.get("light.standing_light").attributes[ATTR_COLOR_MODE] + is ColorMode.COLOR_TEMP + ) + + await trigger_update( + hass, + devices, + "cb958955-b015-498c-9e62-fc0c51abd054", + Capability.COLOR_CONTROL, + Attribute.HUE, + 20, + ) + + assert ( + hass.states.get("light.standing_light").attributes[ATTR_COLOR_MODE] + is ColorMode.HS + ) + + +@pytest.mark.parametrize("device_fixture", ["hue_rgbw_color_bulb"]) +async def test_color_mode_after_startup( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test color mode after startup.""" + set_attribute_value(devices, Capability.SWITCH, Attribute.SWITCH, "on") + + RESTORE_DATA = { + ATTR_BRIGHTNESS: 178, + ATTR_COLOR_MODE: ColorMode.COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN: 3000, + ATTR_HS_COLOR: (144.0, 60), + ATTR_MAX_COLOR_TEMP_KELVIN: 9000, + ATTR_MIN_COLOR_TEMP_KELVIN: 2000, + ATTR_RGB_COLOR: (255, 128, 0), + ATTR_SUPPORTED_COLOR_MODES: [ColorMode.COLOR_TEMP, ColorMode.HS], + ATTR_XY_COLOR: (0.61, 0.35), + } + + mock_restore_cache_with_extra_data( + hass, ((State("light.standing_light", STATE_ON), RESTORE_DATA),) + ) + await setup_integration(hass, mock_config_entry) + + assert ( + hass.states.get("light.standing_light").attributes[ATTR_COLOR_MODE] + is ColorMode.COLOR_TEMP + ) From 1a80934593907875194ad2b0cf291bd890c6330e Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 28 Feb 2025 19:40:13 +0000 Subject: [PATCH 1977/3148] Move TTS entity to own file (#139538) * Move entity to own file * Move entity tests --- homeassistant/components/tts/__init__.py | 161 +---------------------- homeassistant/components/tts/entity.py | 159 ++++++++++++++++++++++ tests/components/tts/test_entity.py | 144 ++++++++++++++++++++ tests/components/tts/test_init.py | 138 +------------------ 4 files changed, 310 insertions(+), 292 deletions(-) create mode 100644 homeassistant/components/tts/entity.py create mode 100644 tests/components/tts/test_entity.py diff --git a/homeassistant/components/tts/__init__.py b/homeassistant/components/tts/__init__.py index 199d644738b..5b2da44eae2 100644 --- a/homeassistant/components/tts/__init__.py +++ b/homeassistant/components/tts/__init__.py @@ -3,10 +3,8 @@ from __future__ import annotations import asyncio -from collections.abc import Mapping from dataclasses import dataclass from datetime import datetime -from functools import partial import hashlib from http import HTTPStatus import io @@ -18,7 +16,7 @@ import secrets import subprocess import tempfile from time import monotonic -from typing import Any, Final, TypedDict, final +from typing import Any, Final, TypedDict from aiohttp import web import mutagen @@ -28,22 +26,8 @@ import voluptuous as vol from homeassistant.components import ffmpeg, websocket_api from homeassistant.components.http import HomeAssistantView -from homeassistant.components.media_player import ( - ATTR_MEDIA_ANNOUNCE, - ATTR_MEDIA_CONTENT_ID, - ATTR_MEDIA_CONTENT_TYPE, - DOMAIN as DOMAIN_MP, - SERVICE_PLAY_MEDIA, - MediaType, -) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - ATTR_ENTITY_ID, - EVENT_HOMEASSISTANT_STOP, - PLATFORM_FORMAT, - STATE_UNAVAILABLE, - STATE_UNKNOWN, -) +from homeassistant.const import EVENT_HOMEASSISTANT_STOP, PLATFORM_FORMAT from homeassistant.core import ( CALLBACK_TYPE, Event, @@ -58,9 +42,8 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.event import async_call_later from homeassistant.helpers.network import get_url -from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import UNDEFINED, ConfigType -from homeassistant.util import dt as dt_util, language as language_util +from homeassistant.util import language as language_util from .const import ( ATTR_CACHE, @@ -78,6 +61,7 @@ from .const import ( DOMAIN, TtsAudioType, ) +from .entity import TextToSpeechEntity from .helper import get_engine_instance from .legacy import PLATFORM_SCHEMA, PLATFORM_SCHEMA_BASE, Provider, async_setup_legacy from .media_source import generate_media_source_id, media_source_id_to_kwargs @@ -95,6 +79,7 @@ __all__ = [ "PLATFORM_SCHEMA_BASE", "Provider", "SampleFormat", + "TextToSpeechEntity", "TtsAudioType", "Voice", "async_default_engine", @@ -389,14 +374,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await hass.data[DATA_COMPONENT].async_unload_entry(entry) -CACHED_PROPERTIES_WITH_ATTR_ = { - "default_language", - "default_options", - "supported_languages", - "supported_options", -} - - @dataclass class ResultStream: """Class that will stream the result when available.""" @@ -431,134 +408,6 @@ class ResultStream: return data -class TextToSpeechEntity(RestoreEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): - """Represent a single TTS engine.""" - - _attr_should_poll = False - __last_tts_loaded: str | None = None - - _attr_default_language: str - _attr_default_options: Mapping[str, Any] | None = None - _attr_supported_languages: list[str] - _attr_supported_options: list[str] | None = None - - @property - @final - def state(self) -> str | None: - """Return the state of the entity.""" - if self.__last_tts_loaded is None: - return None - return self.__last_tts_loaded - - @cached_property - def supported_languages(self) -> list[str]: - """Return a list of supported languages.""" - return self._attr_supported_languages - - @cached_property - def default_language(self) -> str: - """Return the default language.""" - return self._attr_default_language - - @cached_property - def supported_options(self) -> list[str] | None: - """Return a list of supported options like voice, emotions.""" - return self._attr_supported_options - - @cached_property - def default_options(self) -> Mapping[str, Any] | None: - """Return a mapping with the default options.""" - return self._attr_default_options - - @callback - def async_get_supported_voices(self, language: str) -> list[Voice] | None: - """Return a list of supported voices for a language.""" - return None - - async def async_internal_added_to_hass(self) -> None: - """Call when the entity is added to hass.""" - await super().async_internal_added_to_hass() - try: - _ = self.default_language - except AttributeError as err: - raise AttributeError( - "TTS entities must either set the '_attr_default_language' attribute or override the 'default_language' property" - ) from err - try: - _ = self.supported_languages - except AttributeError as err: - raise AttributeError( - "TTS entities must either set the '_attr_supported_languages' attribute or override the 'supported_languages' property" - ) from err - state = await self.async_get_last_state() - if ( - state is not None - and state.state is not None - and state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN) - ): - self.__last_tts_loaded = state.state - - async def async_speak( - self, - media_player_entity_id: list[str], - message: str, - cache: bool, - language: str | None = None, - options: dict | None = None, - ) -> None: - """Speak via a Media Player.""" - await self.hass.services.async_call( - DOMAIN_MP, - SERVICE_PLAY_MEDIA, - { - ATTR_ENTITY_ID: media_player_entity_id, - ATTR_MEDIA_CONTENT_ID: generate_media_source_id( - self.hass, - message=message, - engine=self.entity_id, - language=language, - options=options, - cache=cache, - ), - ATTR_MEDIA_CONTENT_TYPE: MediaType.MUSIC, - ATTR_MEDIA_ANNOUNCE: True, - }, - blocking=True, - context=self._context, - ) - - @final - async def internal_async_get_tts_audio( - self, message: str, language: str, options: dict[str, Any] - ) -> TtsAudioType: - """Process an audio stream to TTS service. - - Only streaming content is allowed! - """ - self.__last_tts_loaded = dt_util.utcnow().isoformat() - self.async_write_ha_state() - return await self.async_get_tts_audio( - message=message, language=language, options=options - ) - - def get_tts_audio( - self, message: str, language: str, options: dict[str, Any] - ) -> TtsAudioType: - """Load tts audio file from the engine.""" - raise NotImplementedError - - async def async_get_tts_audio( - self, message: str, language: str, options: dict[str, Any] - ) -> TtsAudioType: - """Load tts audio file from the engine. - - Return a tuple of file extension and data as bytes. - """ - return await self.hass.async_add_executor_job( - partial(self.get_tts_audio, message, language, options=options) - ) - - def _hash_options(options: dict) -> str: """Hashes an options dictionary.""" opts_hash = hashlib.blake2s(digest_size=5) diff --git a/homeassistant/components/tts/entity.py b/homeassistant/components/tts/entity.py new file mode 100644 index 00000000000..ef65886452d --- /dev/null +++ b/homeassistant/components/tts/entity.py @@ -0,0 +1,159 @@ +"""Entity for Text-to-Speech.""" + +from collections.abc import Mapping +from functools import partial +from typing import Any, final + +from propcache.api import cached_property + +from homeassistant.components.media_player import ( + ATTR_MEDIA_ANNOUNCE, + ATTR_MEDIA_CONTENT_ID, + ATTR_MEDIA_CONTENT_TYPE, + DOMAIN as DOMAIN_MP, + SERVICE_PLAY_MEDIA, + MediaType, +) +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.core import callback +from homeassistant.helpers.restore_state import RestoreEntity +from homeassistant.util import dt as dt_util + +from .const import TtsAudioType +from .media_source import generate_media_source_id +from .models import Voice + +CACHED_PROPERTIES_WITH_ATTR_ = { + "default_language", + "default_options", + "supported_languages", + "supported_options", +} + + +class TextToSpeechEntity(RestoreEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): + """Represent a single TTS engine.""" + + _attr_should_poll = False + __last_tts_loaded: str | None = None + + _attr_default_language: str + _attr_default_options: Mapping[str, Any] | None = None + _attr_supported_languages: list[str] + _attr_supported_options: list[str] | None = None + + @property + @final + def state(self) -> str | None: + """Return the state of the entity.""" + if self.__last_tts_loaded is None: + return None + return self.__last_tts_loaded + + @cached_property + def supported_languages(self) -> list[str]: + """Return a list of supported languages.""" + return self._attr_supported_languages + + @cached_property + def default_language(self) -> str: + """Return the default language.""" + return self._attr_default_language + + @cached_property + def supported_options(self) -> list[str] | None: + """Return a list of supported options like voice, emotions.""" + return self._attr_supported_options + + @cached_property + def default_options(self) -> Mapping[str, Any] | None: + """Return a mapping with the default options.""" + return self._attr_default_options + + @callback + def async_get_supported_voices(self, language: str) -> list[Voice] | None: + """Return a list of supported voices for a language.""" + return None + + async def async_internal_added_to_hass(self) -> None: + """Call when the entity is added to hass.""" + await super().async_internal_added_to_hass() + try: + _ = self.default_language + except AttributeError as err: + raise AttributeError( + "TTS entities must either set the '_attr_default_language' attribute or override the 'default_language' property" + ) from err + try: + _ = self.supported_languages + except AttributeError as err: + raise AttributeError( + "TTS entities must either set the '_attr_supported_languages' attribute or override the 'supported_languages' property" + ) from err + state = await self.async_get_last_state() + if ( + state is not None + and state.state is not None + and state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN) + ): + self.__last_tts_loaded = state.state + + async def async_speak( + self, + media_player_entity_id: list[str], + message: str, + cache: bool, + language: str | None = None, + options: dict | None = None, + ) -> None: + """Speak via a Media Player.""" + await self.hass.services.async_call( + DOMAIN_MP, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: media_player_entity_id, + ATTR_MEDIA_CONTENT_ID: generate_media_source_id( + self.hass, + message=message, + engine=self.entity_id, + language=language, + options=options, + cache=cache, + ), + ATTR_MEDIA_CONTENT_TYPE: MediaType.MUSIC, + ATTR_MEDIA_ANNOUNCE: True, + }, + blocking=True, + context=self._context, + ) + + @final + async def internal_async_get_tts_audio( + self, message: str, language: str, options: dict[str, Any] + ) -> TtsAudioType: + """Process an audio stream to TTS service. + + Only streaming content is allowed! + """ + self.__last_tts_loaded = dt_util.utcnow().isoformat() + self.async_write_ha_state() + return await self.async_get_tts_audio( + message=message, language=language, options=options + ) + + def get_tts_audio( + self, message: str, language: str, options: dict[str, Any] + ) -> TtsAudioType: + """Load tts audio file from the engine.""" + raise NotImplementedError + + async def async_get_tts_audio( + self, message: str, language: str, options: dict[str, Any] + ) -> TtsAudioType: + """Load tts audio file from the engine. + + Return a tuple of file extension and data as bytes. + """ + return await self.hass.async_add_executor_job( + partial(self.get_tts_audio, message, language, options=options) + ) diff --git a/tests/components/tts/test_entity.py b/tests/components/tts/test_entity.py new file mode 100644 index 00000000000..d82ec6a5d2b --- /dev/null +++ b/tests/components/tts/test_entity.py @@ -0,0 +1,144 @@ +"""Tests for the TTS entity.""" + +import pytest + +from homeassistant.components import tts +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant, State + +from .common import ( + DEFAULT_LANG, + SUPPORT_LANGUAGES, + TEST_DOMAIN, + MockTTSEntity, + mock_config_entry_setup, +) + +from tests.common import mock_restore_cache + + +class DefaultEntity(tts.TextToSpeechEntity): + """Test entity.""" + + _attr_supported_languages = SUPPORT_LANGUAGES + _attr_default_language = DEFAULT_LANG + + +async def test_default_entity_attributes() -> None: + """Test default entity attributes.""" + entity = DefaultEntity() + + assert entity.hass is None + assert entity.default_language == DEFAULT_LANG + assert entity.supported_languages == SUPPORT_LANGUAGES + assert entity.supported_options is None + assert entity.default_options is None + assert entity.async_get_supported_voices("test") is None + + +async def test_restore_state( + hass: HomeAssistant, + mock_tts_entity: MockTTSEntity, +) -> None: + """Test we restore state in the integration.""" + entity_id = f"{tts.DOMAIN}.{TEST_DOMAIN}" + timestamp = "2023-01-01T23:59:59+00:00" + mock_restore_cache(hass, (State(entity_id, timestamp),)) + + config_entry = await mock_config_entry_setup(hass, mock_tts_entity) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + state = hass.states.get(entity_id) + assert state + assert state.state == timestamp + + +async def test_tts_entity_subclass_properties( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test for errors when subclasses of the TextToSpeechEntity are missing required properties.""" + + class TestClass1(tts.TextToSpeechEntity): + _attr_default_language = DEFAULT_LANG + _attr_supported_languages = SUPPORT_LANGUAGES + + await mock_config_entry_setup(hass, TestClass1()) + + class TestClass2(tts.TextToSpeechEntity): + @property + def default_language(self) -> str: + return DEFAULT_LANG + + @property + def supported_languages(self) -> list[str]: + return SUPPORT_LANGUAGES + + await mock_config_entry_setup(hass, TestClass2()) + + assert all(record.exc_info is None for record in caplog.records) + + caplog.clear() + + class TestClass3(tts.TextToSpeechEntity): + _attr_default_language = DEFAULT_LANG + + await mock_config_entry_setup(hass, TestClass3()) + + assert ( + "TTS entities must either set the '_attr_supported_languages' attribute or override the 'supported_languages' property" + in [ + str(record.exc_info[1]) + for record in caplog.records + if record.exc_info is not None + ] + ) + caplog.clear() + + class TestClass4(tts.TextToSpeechEntity): + _attr_supported_languages = SUPPORT_LANGUAGES + + await mock_config_entry_setup(hass, TestClass4()) + + assert ( + "TTS entities must either set the '_attr_default_language' attribute or override the 'default_language' property" + in [ + str(record.exc_info[1]) + for record in caplog.records + if record.exc_info is not None + ] + ) + caplog.clear() + + class TestClass5(tts.TextToSpeechEntity): + @property + def default_language(self) -> str: + return DEFAULT_LANG + + await mock_config_entry_setup(hass, TestClass5()) + + assert ( + "TTS entities must either set the '_attr_supported_languages' attribute or override the 'supported_languages' property" + in [ + str(record.exc_info[1]) + for record in caplog.records + if record.exc_info is not None + ] + ) + caplog.clear() + + class TestClass6(tts.TextToSpeechEntity): + @property + def supported_languages(self) -> list[str]: + return SUPPORT_LANGUAGES + + await mock_config_entry_setup(hass, TestClass6()) + + assert ( + "TTS entities must either set the '_attr_default_language' attribute or override the 'default_language' property" + in [ + str(record.exc_info[1]) + for record in caplog.records + if record.exc_info is not None + ] + ) diff --git a/tests/components/tts/test_init.py b/tests/components/tts/test_init.py index 86ca2de5791..8dece920907 100644 --- a/tests/components/tts/test_init.py +++ b/tests/components/tts/test_init.py @@ -20,14 +20,13 @@ from homeassistant.components.media_player import ( ) from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN -from homeassistant.core import HomeAssistant, State +from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util from .common import ( DEFAULT_LANG, - SUPPORT_LANGUAGES, TEST_DOMAIN, MockTTS, MockTTSEntity, @@ -38,37 +37,12 @@ from .common import ( retrieve_media, ) -from tests.common import ( - MockModule, - async_mock_service, - mock_integration, - mock_platform, - mock_restore_cache, -) +from tests.common import MockModule, async_mock_service, mock_integration, mock_platform from tests.typing import ClientSessionGenerator, WebSocketGenerator ORIG_WRITE_TAGS = tts.SpeechManager.write_tags -class DefaultEntity(tts.TextToSpeechEntity): - """Test entity.""" - - _attr_supported_languages = SUPPORT_LANGUAGES - _attr_default_language = DEFAULT_LANG - - -async def test_default_entity_attributes() -> None: - """Test default entity attributes.""" - entity = DefaultEntity() - - assert entity.hass is None - assert entity.default_language == DEFAULT_LANG - assert entity.supported_languages == SUPPORT_LANGUAGES - assert entity.supported_options is None - assert entity.default_options is None - assert entity.async_get_supported_voices("test") is None - - async def test_config_entry_unload( hass: HomeAssistant, hass_client: ClientSessionGenerator, @@ -120,24 +94,6 @@ async def test_config_entry_unload( assert state is None -async def test_restore_state( - hass: HomeAssistant, - mock_tts_entity: MockTTSEntity, -) -> None: - """Test we restore state in the integration.""" - entity_id = f"{tts.DOMAIN}.{TEST_DOMAIN}" - timestamp = "2023-01-01T23:59:59+00:00" - mock_restore_cache(hass, (State(entity_id, timestamp),)) - - config_entry = await mock_config_entry_setup(hass, mock_tts_entity) - await hass.async_block_till_done() - - assert config_entry.state is ConfigEntryState.LOADED - state = hass.states.get(entity_id) - assert state - assert state.state == timestamp - - @pytest.mark.parametrize( "setup", ["mock_setup", "mock_config_entry_setup"], indirect=True ) @@ -1840,96 +1796,6 @@ async def test_async_convert_audio_error(hass: HomeAssistant) -> None: await tts.async_convert_audio(hass, "wav", bytes(0), "mp3") -async def test_ttsentity_subclass_properties( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture -) -> None: - """Test for errors when subclasses of the TextToSpeechEntity are missing required properties.""" - - class TestClass1(tts.TextToSpeechEntity): - _attr_default_language = DEFAULT_LANG - _attr_supported_languages = SUPPORT_LANGUAGES - - await mock_config_entry_setup(hass, TestClass1()) - - class TestClass2(tts.TextToSpeechEntity): - @property - def default_language(self) -> str: - return DEFAULT_LANG - - @property - def supported_languages(self) -> list[str]: - return SUPPORT_LANGUAGES - - await mock_config_entry_setup(hass, TestClass2()) - - assert all(record.exc_info is None for record in caplog.records) - - caplog.clear() - - class TestClass3(tts.TextToSpeechEntity): - _attr_default_language = DEFAULT_LANG - - await mock_config_entry_setup(hass, TestClass3()) - - assert ( - "TTS entities must either set the '_attr_supported_languages' attribute or override the 'supported_languages' property" - in [ - str(record.exc_info[1]) - for record in caplog.records - if record.exc_info is not None - ] - ) - caplog.clear() - - class TestClass4(tts.TextToSpeechEntity): - _attr_supported_languages = SUPPORT_LANGUAGES - - await mock_config_entry_setup(hass, TestClass4()) - - assert ( - "TTS entities must either set the '_attr_default_language' attribute or override the 'default_language' property" - in [ - str(record.exc_info[1]) - for record in caplog.records - if record.exc_info is not None - ] - ) - caplog.clear() - - class TestClass5(tts.TextToSpeechEntity): - @property - def default_language(self) -> str: - return DEFAULT_LANG - - await mock_config_entry_setup(hass, TestClass5()) - - assert ( - "TTS entities must either set the '_attr_supported_languages' attribute or override the 'supported_languages' property" - in [ - str(record.exc_info[1]) - for record in caplog.records - if record.exc_info is not None - ] - ) - caplog.clear() - - class TestClass6(tts.TextToSpeechEntity): - @property - def supported_languages(self) -> list[str]: - return SUPPORT_LANGUAGES - - await mock_config_entry_setup(hass, TestClass6()) - - assert ( - "TTS entities must either set the '_attr_default_language' attribute or override the 'default_language' property" - in [ - str(record.exc_info[1]) - for record in caplog.records - if record.exc_info is not None - ] - ) - - async def test_default_engine_prefer_entity( hass: HomeAssistant, mock_tts_entity: MockTTSEntity, From 437e54511620de7dc5b44a69d7f8682f8e6ae769 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Fri, 28 Feb 2025 20:45:47 +0100 Subject: [PATCH 1978/3148] Rework Comelit tests (#139475) * Rework Comelit tests * allign * restore coverage --- tests/components/comelit/__init__.py | 12 + tests/components/comelit/conftest.py | 104 +++++++ tests/components/comelit/const.py | 38 +-- .../comelit/snapshots/test_diagnostics.ambr | 3 +- tests/components/comelit/test_config_flow.py | 264 +++++++++++------- tests/components/comelit/test_diagnostics.py | 51 +--- 6 files changed, 304 insertions(+), 168 deletions(-) create mode 100644 tests/components/comelit/conftest.py diff --git a/tests/components/comelit/__init__.py b/tests/components/comelit/__init__.py index 916a684de4b..6475f500f01 100644 --- a/tests/components/comelit/__init__.py +++ b/tests/components/comelit/__init__.py @@ -1 +1,13 @@ """Tests for the Comelit SimpleHome integration.""" + +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) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/comelit/conftest.py b/tests/components/comelit/conftest.py new file mode 100644 index 00000000000..d2d450ccb8d --- /dev/null +++ b/tests/components/comelit/conftest.py @@ -0,0 +1,104 @@ +"""Configure tests for Comelit SimpleHome.""" + +import pytest + +from homeassistant.components.comelit.const import ( + BRIDGE, + DOMAIN as COMELIT_DOMAIN, + VEDO, +) +from homeassistant.const import CONF_HOST, CONF_PIN, CONF_PORT, CONF_TYPE + +from .const import ( + BRIDGE_DEVICE_QUERY, + BRIDGE_HOST, + BRIDGE_PIN, + BRIDGE_PORT, + VEDO_DEVICE_QUERY, + VEDO_HOST, + VEDO_PIN, + VEDO_PORT, +) + +from tests.common import AsyncMock, Generator, MockConfigEntry, patch + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.comelit.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_serial_bridge() -> Generator[AsyncMock]: + """Mock a Comelit serial bridge.""" + with ( + patch( + "homeassistant.components.comelit.coordinator.ComeliteSerialBridgeApi", + autospec=True, + ) as mock_comelit_serial_bridge, + patch( + "homeassistant.components.comelit.config_flow.ComeliteSerialBridgeApi", + new=mock_comelit_serial_bridge, + ), + ): + bridge = mock_comelit_serial_bridge.return_value + bridge.get_all_devices.return_value = BRIDGE_DEVICE_QUERY + bridge.host = BRIDGE_HOST + bridge.port = BRIDGE_PORT + bridge.pin = BRIDGE_PIN + yield bridge + + +@pytest.fixture +def mock_serial_bridge_config_entry() -> Generator[MockConfigEntry]: + """Mock a Comelit config entry for Comelit bridge.""" + return MockConfigEntry( + domain=COMELIT_DOMAIN, + data={ + CONF_HOST: BRIDGE_HOST, + CONF_PORT: BRIDGE_PORT, + CONF_PIN: BRIDGE_PIN, + CONF_TYPE: BRIDGE, + }, + ) + + +@pytest.fixture +def mock_vedo() -> Generator[AsyncMock]: + """Mock a Comelit vedo.""" + with ( + patch( + "homeassistant.components.comelit.coordinator.ComelitVedoApi", + autospec=True, + ) as mock_comelit_vedo, + patch( + "homeassistant.components.comelit.config_flow.ComelitVedoApi", + new=mock_comelit_vedo, + ), + ): + vedo = mock_comelit_vedo.return_value + vedo.get_all_areas_and_zones.return_value = VEDO_DEVICE_QUERY + vedo.host = VEDO_HOST + vedo.port = VEDO_PORT + vedo.pin = VEDO_PIN + vedo.type = VEDO + yield vedo + + +@pytest.fixture +def mock_vedo_config_entry() -> Generator[MockConfigEntry]: + """Mock a Comelit config entry for Comelit vedo.""" + return MockConfigEntry( + domain=COMELIT_DOMAIN, + data={ + CONF_HOST: VEDO_HOST, + CONF_PORT: VEDO_PORT, + CONF_PIN: VEDO_PIN, + CONF_TYPE: VEDO, + }, + ) diff --git a/tests/components/comelit/const.py b/tests/components/comelit/const.py index 92fdfebfa1d..3151b83d175 100644 --- a/tests/components/comelit/const.py +++ b/tests/components/comelit/const.py @@ -1,7 +1,10 @@ """Common stuff for Comelit SimpleHome tests.""" -from aiocomelit import ComelitVedoAreaObject, ComelitVedoZoneObject -from aiocomelit.api import ComelitSerialBridgeObject +from aiocomelit import ( + ComelitSerialBridgeObject, + ComelitVedoAreaObject, + ComelitVedoZoneObject, +) from aiocomelit.const import ( CLIMATE, COVER, @@ -9,37 +12,20 @@ from aiocomelit.const import ( LIGHT, OTHER, SCENARIO, - VEDO, WATT, AlarmAreaState, AlarmZoneState, ) -from homeassistant.components.comelit.const import DOMAIN -from homeassistant.const import CONF_DEVICES, CONF_HOST, CONF_PIN, CONF_PORT, CONF_TYPE +BRIDGE_HOST = "fake_bridge_host" +BRIDGE_PORT = 80 +BRIDGE_PIN = 1234 -MOCK_CONFIG = { - DOMAIN: { - CONF_DEVICES: [ - { - CONF_HOST: "fake_host", - CONF_PORT: 80, - CONF_PIN: 1234, - }, - { - CONF_HOST: "fake_vedo_host", - CONF_PORT: 8080, - CONF_PIN: 1234, - CONF_TYPE: VEDO, - }, - ] - } -} +VEDO_HOST = "fake_vedo_host" +VEDO_PORT = 8080 +VEDO_PIN = 5678 -MOCK_USER_BRIDGE_DATA = MOCK_CONFIG[DOMAIN][CONF_DEVICES][0] -MOCK_USER_VEDO_DATA = MOCK_CONFIG[DOMAIN][CONF_DEVICES][1] - -FAKE_PIN = 5678 +FAKE_PIN = 0000 BRIDGE_DEVICE_QUERY = { CLIMATE: {}, diff --git a/tests/components/comelit/snapshots/test_diagnostics.ambr b/tests/components/comelit/snapshots/test_diagnostics.ambr index 877f48a4611..b9891eb3209 100644 --- a/tests/components/comelit/snapshots/test_diagnostics.ambr +++ b/tests/components/comelit/snapshots/test_diagnostics.ambr @@ -57,9 +57,10 @@ }), 'entry': dict({ 'data': dict({ - 'host': 'fake_host', + 'host': 'fake_bridge_host', 'pin': '**REDACTED**', 'port': 80, + 'type': 'Serial bridge', }), 'disabled_by': None, 'discovery_keys': dict({ diff --git a/tests/components/comelit/test_config_flow.py b/tests/components/comelit/test_config_flow.py index eeaea0e41e9..dd1d1fb3836 100644 --- a/tests/components/comelit/test_config_flow.py +++ b/tests/components/comelit/test_config_flow.py @@ -1,59 +1,93 @@ """Tests for Comelit SimpleHome config flow.""" -from typing import Any -from unittest.mock import patch +from unittest.mock import AsyncMock from aiocomelit import CannotAuthenticate, CannotConnect +from aiocomelit.const import BRIDGE, VEDO import pytest from homeassistant.components.comelit.const import DOMAIN from homeassistant.config_entries import SOURCE_USER -from homeassistant.const import CONF_HOST, CONF_PIN, CONF_PORT +from homeassistant.const import CONF_HOST, CONF_PIN, CONF_PORT, CONF_TYPE from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from .const import FAKE_PIN, MOCK_USER_BRIDGE_DATA, MOCK_USER_VEDO_DATA +from .const import ( + BRIDGE_HOST, + BRIDGE_PIN, + BRIDGE_PORT, + FAKE_PIN, + VEDO_HOST, + VEDO_PIN, + VEDO_PORT, +) from tests.common import MockConfigEntry -@pytest.mark.parametrize( - ("class_api", "user_input"), - [ - ("ComeliteSerialBridgeApi", MOCK_USER_BRIDGE_DATA), - ("ComelitVedoApi", MOCK_USER_VEDO_DATA), - ], -) -async def test_full_flow( - hass: HomeAssistant, class_api: str, user_input: dict[str, Any] +async def test_flow_serial_bridge( + hass: HomeAssistant, + mock_serial_bridge: AsyncMock, + mock_serial_bridge_config_entry: MockConfigEntry, ) -> None: """Test starting a flow by user.""" - with ( - patch( - f"aiocomelit.api.{class_api}.login", - ), - patch( - f"aiocomelit.api.{class_api}.logout", - ), - patch("homeassistant.components.comelit.async_setup_entry") as mock_setup_entry, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input=user_input - ) - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["data"][CONF_HOST] == user_input[CONF_HOST] - assert result["data"][CONF_PORT] == user_input[CONF_PORT] - assert result["data"][CONF_PIN] == user_input[CONF_PIN] - assert not result["result"].unique_id - await hass.async_block_till_done() + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" - assert mock_setup_entry.called + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: BRIDGE_HOST, + CONF_PORT: BRIDGE_PORT, + CONF_PIN: BRIDGE_PIN, + }, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_HOST: BRIDGE_HOST, + CONF_PORT: BRIDGE_PORT, + CONF_PIN: BRIDGE_PIN, + CONF_TYPE: BRIDGE, + } + assert not result["result"].unique_id + await hass.async_block_till_done() + + +async def test_flow_vedo( + hass: HomeAssistant, + mock_vedo: AsyncMock, + mock_vedo_config_entry: MockConfigEntry, +) -> None: + """Test starting a flow by user.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: VEDO_HOST, + CONF_PORT: VEDO_PORT, + CONF_PIN: VEDO_PIN, + CONF_TYPE: VEDO, + }, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_HOST: VEDO_HOST, + CONF_PORT: VEDO_PORT, + CONF_PIN: VEDO_PIN, + CONF_TYPE: VEDO, + } + assert not result["result"].unique_id + await hass.async_block_till_done() @pytest.mark.parametrize( @@ -64,7 +98,13 @@ async def test_full_flow( (ConnectionResetError, "unknown"), ], ) -async def test_exception_connection(hass: HomeAssistant, side_effect, error) -> None: +async def test_exception_connection( + hass: HomeAssistant, + mock_vedo: AsyncMock, + mock_vedo_config_entry: MockConfigEntry, + side_effect, + error, +) -> None: """Test starting a flow by user with a connection error.""" result = await hass.config_entries.flow.async_init( @@ -73,59 +113,65 @@ async def test_exception_connection(hass: HomeAssistant, side_effect, error) -> assert result.get("type") is FlowResultType.FORM assert result.get("step_id") == "user" - with ( - patch( - "aiocomelit.api.ComeliteSerialBridgeApi.login", - side_effect=side_effect, - ), - patch( - "aiocomelit.api.ComeliteSerialBridgeApi.logout", - ), - patch( - "homeassistant.components.comelit.async_setup_entry", - ), - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input=MOCK_USER_BRIDGE_DATA - ) + mock_vedo.login.side_effect = side_effect - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - assert result["errors"] is not None - assert result["errors"]["base"] == error + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: VEDO_HOST, + CONF_PORT: VEDO_PORT, + CONF_PIN: VEDO_PIN, + CONF_TYPE: VEDO, + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": error} + + mock_vedo.login.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: VEDO_HOST, + CONF_PORT: VEDO_PORT, + CONF_PIN: VEDO_PIN, + CONF_TYPE: VEDO, + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == VEDO_HOST + assert result["data"] == { + CONF_HOST: VEDO_HOST, + CONF_PORT: VEDO_PORT, + CONF_PIN: VEDO_PIN, + CONF_TYPE: VEDO, + } -async def test_reauth_successful(hass: HomeAssistant) -> None: +async def test_reauth_successful( + hass: HomeAssistant, + mock_vedo: AsyncMock, + mock_vedo_config_entry: MockConfigEntry, +) -> None: """Test starting a reauthentication flow.""" - mock_config = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_BRIDGE_DATA) - mock_config.add_to_hass(hass) - result = await mock_config.start_reauth_flow(hass) + mock_vedo_config_entry.add_to_hass(hass) + result = await mock_vedo_config_entry.start_reauth_flow(hass) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "reauth_confirm" - with ( - patch( - "aiocomelit.api.ComeliteSerialBridgeApi.login", - ), - patch( - "aiocomelit.api.ComeliteSerialBridgeApi.logout", - ), - patch("homeassistant.components.comelit.async_setup_entry"), - patch("requests.get") as mock_request_get, - ): - mock_request_get.return_value.status_code = 200 + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_PIN: FAKE_PIN, + }, + ) - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={ - CONF_PIN: FAKE_PIN, - }, - ) - await hass.async_block_till_done() - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "reauth_successful" + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" @pytest.mark.parametrize( @@ -136,30 +182,40 @@ async def test_reauth_successful(hass: HomeAssistant) -> None: (ConnectionResetError, "unknown"), ], ) -async def test_reauth_not_successful(hass: HomeAssistant, side_effect, error) -> None: +async def test_reauth_not_successful( + hass: HomeAssistant, + mock_vedo: AsyncMock, + mock_vedo_config_entry: MockConfigEntry, + side_effect: Exception, + error: str, +) -> None: """Test starting a reauthentication flow but no connection found.""" - - mock_config = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_BRIDGE_DATA) - mock_config.add_to_hass(hass) - result = await mock_config.start_reauth_flow(hass) + mock_vedo_config_entry.add_to_hass(hass) + result = await mock_vedo_config_entry.start_reauth_flow(hass) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "reauth_confirm" - with ( - patch("aiocomelit.api.ComeliteSerialBridgeApi.login", side_effect=side_effect), - patch( - "aiocomelit.api.ComeliteSerialBridgeApi.logout", - ), - patch("homeassistant.components.comelit.async_setup_entry"), - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={ - CONF_PIN: FAKE_PIN, - }, - ) + mock_vedo.login.side_effect = side_effect + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_PIN: FAKE_PIN, + }, + ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "reauth_confirm" - assert result["errors"] is not None - assert result["errors"]["base"] == error + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {"base": error} + + mock_vedo.login.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_PIN: VEDO_PIN, + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert mock_vedo_config_entry.data[CONF_PIN] == VEDO_PIN diff --git a/tests/components/comelit/test_diagnostics.py b/tests/components/comelit/test_diagnostics.py index 39d75af1152..cabcd0f4cac 100644 --- a/tests/components/comelit/test_diagnostics.py +++ b/tests/components/comelit/test_diagnostics.py @@ -2,21 +2,14 @@ from __future__ import annotations -from unittest.mock import patch +from unittest.mock import AsyncMock from syrupy import SnapshotAssertion from syrupy.filters import props -from homeassistant.components.comelit.const import DOMAIN -from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant -from .const import ( - BRIDGE_DEVICE_QUERY, - MOCK_USER_BRIDGE_DATA, - MOCK_USER_VEDO_DATA, - VEDO_DEVICE_QUERY, -) +from . import setup_integration from tests.common import MockConfigEntry from tests.components.diagnostics import get_diagnostics_for_config_entry @@ -25,25 +18,17 @@ from tests.typing import ClientSessionGenerator async def test_entry_diagnostics_bridge( hass: HomeAssistant, + mock_serial_bridge: AsyncMock, + mock_serial_bridge_config_entry: MockConfigEntry, hass_client: ClientSessionGenerator, snapshot: SnapshotAssertion, ) -> None: """Test Bridge config entry diagnostics.""" - entry = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_BRIDGE_DATA) - entry.add_to_hass(hass) + await setup_integration(hass, mock_serial_bridge_config_entry) - with ( - patch("aiocomelit.api.ComeliteSerialBridgeApi.login"), - patch( - "aiocomelit.api.ComeliteSerialBridgeApi.get_all_devices", - return_value=BRIDGE_DEVICE_QUERY, - ), - ): - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - assert entry.state == ConfigEntryState.LOADED - assert await get_diagnostics_for_config_entry(hass, hass_client, entry) == snapshot( + assert await get_diagnostics_for_config_entry( + hass, hass_client, mock_serial_bridge_config_entry + ) == snapshot( exclude=props( "entry_id", "created_at", @@ -54,25 +39,17 @@ async def test_entry_diagnostics_bridge( async def test_entry_diagnostics_vedo( hass: HomeAssistant, + mock_vedo: AsyncMock, + mock_vedo_config_entry: MockConfigEntry, hass_client: ClientSessionGenerator, snapshot: SnapshotAssertion, ) -> None: """Test Vedo System config entry diagnostics.""" - entry = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_VEDO_DATA) - entry.add_to_hass(hass) + await setup_integration(hass, mock_vedo_config_entry) - with ( - patch("aiocomelit.api.ComelitVedoApi.login"), - patch( - "aiocomelit.api.ComelitVedoApi.get_all_areas_and_zones", - return_value=VEDO_DEVICE_QUERY, - ), - ): - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - assert entry.state == ConfigEntryState.LOADED - assert await get_diagnostics_for_config_entry(hass, hass_client, entry) == snapshot( + assert await get_diagnostics_for_config_entry( + hass, hass_client, mock_vedo_config_entry + ) == snapshot( exclude=props( "entry_id", "created_at", From 6ce48eab45564934a6648b23ca8e8b4348600b5c Mon Sep 17 00:00:00 2001 From: rappenze Date: Fri, 28 Feb 2025 20:47:03 +0100 Subject: [PATCH 1979/3148] Use new pyfibaro library features (#139476) --- homeassistant/components/fibaro/__init__.py | 113 +++++++----------- .../components/fibaro/config_flow.py | 17 +-- tests/components/fibaro/conftest.py | 7 +- tests/components/fibaro/test_config_flow.py | 58 +++------ 4 files changed, 76 insertions(+), 119 deletions(-) diff --git a/homeassistant/components/fibaro/__init__.py b/homeassistant/components/fibaro/__init__.py index 8ede0169482..9a521e27486 100644 --- a/homeassistant/components/fibaro/__init__.py +++ b/homeassistant/components/fibaro/__init__.py @@ -7,21 +7,20 @@ from collections.abc import Callable, Mapping import logging from typing import Any -from pyfibaro.fibaro_client import FibaroClient +from pyfibaro.fibaro_client import ( + FibaroAuthenticationFailed, + FibaroClient, + FibaroConnectFailed, +) from pyfibaro.fibaro_device import DeviceModel -from pyfibaro.fibaro_room import RoomModel +from pyfibaro.fibaro_info import InfoModel from pyfibaro.fibaro_scene import SceneModel from pyfibaro.fibaro_state_resolver import FibaroEvent, FibaroStateResolver -from requests.exceptions import HTTPError from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ( - ConfigEntryAuthFailed, - ConfigEntryNotReady, - HomeAssistantError, -) +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceEntry, DeviceInfo from homeassistant.util import slugify @@ -74,63 +73,31 @@ FIBARO_TYPEMAP = { class FibaroController: """Initiate Fibaro Controller Class.""" - def __init__(self, config: Mapping[str, Any]) -> None: + def __init__( + self, fibaro_client: FibaroClient, info: InfoModel, import_plugins: bool + ) -> None: """Initialize the Fibaro controller.""" - - # The FibaroClient uses the correct API version automatically - self._client = FibaroClient(config[CONF_URL]) - self._client.set_authentication(config[CONF_USERNAME], config[CONF_PASSWORD]) + self._client = fibaro_client + self._fibaro_info = info # Whether to import devices from plugins - self._import_plugins = config[CONF_IMPORT_PLUGINS] - self._room_map: dict[int, RoomModel] # Mapping roomId to room object + self._import_plugins = import_plugins + # Mapping roomId to room object + self._room_map = {room.fibaro_id: room for room in self._client.read_rooms()} self._device_map: dict[int, DeviceModel] # Mapping deviceId to device object self.fibaro_devices: dict[Platform, list[DeviceModel]] = defaultdict( list ) # List of devices by entity platform # All scenes - self._scenes: list[SceneModel] = [] + self._scenes = self._client.read_scenes() self._callbacks: dict[int, list[Any]] = {} # Update value callbacks by deviceId # Event callbacks by device id self._event_callbacks: dict[int, list[Callable[[FibaroEvent], None]]] = {} - self.hub_serial: str # Unique serial number of the hub - self.hub_name: str # The friendly name of the hub - self.hub_model: str - self.hub_software_version: str - self.hub_api_url: str = config[CONF_URL] + # Unique serial number of the hub + self.hub_serial = info.serial_number # Device infos by fibaro device id self._device_infos: dict[int, DeviceInfo] = {} - - def connect(self) -> None: - """Start the communication with the Fibaro controller.""" - - # Return value doesn't need to be checked, - # it is only relevant when connecting without credentials - self._client.connect() - info = self._client.read_info() - self.hub_serial = info.serial_number - self.hub_name = info.hc_name - self.hub_model = info.platform - self.hub_software_version = info.current_version - - self._room_map = {room.fibaro_id: room for room in self._client.read_rooms()} self._read_devices() - self._scenes = self._client.read_scenes() - - def connect_with_error_handling(self) -> None: - """Translate connect errors to easily differentiate auth and connect failures. - - When there is a better error handling in the used library this can be improved. - """ - try: - self.connect() - except HTTPError as http_ex: - if http_ex.response.status_code == 403: - raise FibaroAuthFailed from http_ex - - raise FibaroConnectFailed from http_ex - except Exception as ex: - raise FibaroConnectFailed from ex def enable_state_handler(self) -> None: """Start StateHandler thread for monitoring updates.""" @@ -310,6 +277,14 @@ class FibaroController: """Return list of scenes.""" return self._scenes + def read_fibaro_info(self) -> InfoModel: + """Return the general info about the hub.""" + return self._fibaro_info + + def get_frontend_url(self) -> str: + """Return the url to the Fibaro hub web UI.""" + return self._client.frontend_url() + def _read_devices(self) -> None: """Read and process the device list.""" devices = self._client.read_devices() @@ -375,11 +350,17 @@ class FibaroController: pass +def connect_fibaro_client(data: Mapping[str, Any]) -> tuple[InfoModel, FibaroClient]: + """Connect to the fibaro hub and read some basic data.""" + client = FibaroClient(data[CONF_URL]) + info = client.connect_with_credentials(data[CONF_USERNAME], data[CONF_PASSWORD]) + return (info, client) + + def init_controller(data: Mapping[str, Any]) -> FibaroController: - """Validate the user input allows us to connect to fibaro.""" - controller = FibaroController(data) - controller.connect_with_error_handling() - return controller + """Connect to the fibaro hub and init the controller.""" + info, client = connect_fibaro_client(data) + return FibaroController(client, info, data[CONF_IMPORT_PLUGINS]) async def async_setup_entry(hass: HomeAssistant, entry: FibaroConfigEntry) -> bool: @@ -393,22 +374,24 @@ async def async_setup_entry(hass: HomeAssistant, entry: FibaroConfigEntry) -> bo raise ConfigEntryNotReady( f"Could not connect to controller at {entry.data[CONF_URL]}" ) from connect_ex - except FibaroAuthFailed as auth_ex: + except FibaroAuthenticationFailed as auth_ex: raise ConfigEntryAuthFailed from auth_ex entry.runtime_data = controller # register the hub device info separately as the hub has sometimes no entities + fibaro_info = controller.read_fibaro_info() device_registry = dr.async_get(hass) device_registry.async_get_or_create( config_entry_id=entry.entry_id, identifiers={(DOMAIN, controller.hub_serial)}, serial_number=controller.hub_serial, - manufacturer="Fibaro", - name=controller.hub_name, - model=controller.hub_model, - sw_version=controller.hub_software_version, - configuration_url=controller.hub_api_url.removesuffix("/api/"), + manufacturer=fibaro_info.manufacturer_name, + name=fibaro_info.hc_name, + model=fibaro_info.model_name, + sw_version=fibaro_info.current_version, + configuration_url=controller.get_frontend_url(), + connections={(dr.CONNECTION_NETWORK_MAC, fibaro_info.mac_address)}, ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -443,11 +426,3 @@ async def async_remove_config_entry_device( return False return True - - -class FibaroConnectFailed(HomeAssistantError): - """Error to indicate we cannot connect to fibaro home center.""" - - -class FibaroAuthFailed(HomeAssistantError): - """Error to indicate that authentication failed on fibaro home center.""" diff --git a/homeassistant/components/fibaro/config_flow.py b/homeassistant/components/fibaro/config_flow.py index 0ffd9aaa48f..d941ceab37f 100644 --- a/homeassistant/components/fibaro/config_flow.py +++ b/homeassistant/components/fibaro/config_flow.py @@ -6,6 +6,7 @@ from collections.abc import Mapping import logging from typing import Any +from pyfibaro.fibaro_client import FibaroAuthenticationFailed, FibaroConnectFailed from slugify import slugify import voluptuous as vol @@ -13,7 +14,7 @@ from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_URL, CONF_USERNAME from homeassistant.core import HomeAssistant -from . import FibaroAuthFailed, FibaroConnectFailed, init_controller +from . import connect_fibaro_client from .const import CONF_IMPORT_PLUGINS, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -33,16 +34,16 @@ async def _validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. """ - controller = await hass.async_add_executor_job(init_controller, data) + info, _ = await hass.async_add_executor_job(connect_fibaro_client, data) _LOGGER.debug( "Successfully connected to fibaro home center %s with name %s", - controller.hub_serial, - controller.hub_name, + info.serial_number, + info.hc_name, ) return { - "serial_number": slugify(controller.hub_serial), - "name": controller.hub_name, + "serial_number": slugify(info.serial_number), + "name": info.hc_name, } @@ -75,7 +76,7 @@ class FibaroConfigFlow(ConfigFlow, domain=DOMAIN): info = await _validate_input(self.hass, user_input) except FibaroConnectFailed: errors["base"] = "cannot_connect" - except FibaroAuthFailed: + except FibaroAuthenticationFailed: errors["base"] = "invalid_auth" else: await self.async_set_unique_id(info["serial_number"]) @@ -106,7 +107,7 @@ class FibaroConfigFlow(ConfigFlow, domain=DOMAIN): await _validate_input(self.hass, new_data) except FibaroConnectFailed: errors["base"] = "cannot_connect" - except FibaroAuthFailed: + except FibaroAuthenticationFailed: errors["base"] = "invalid_auth" else: return self.async_update_reload_and_abort( diff --git a/tests/components/fibaro/conftest.py b/tests/components/fibaro/conftest.py index 583c44a41e6..17357e34198 100644 --- a/tests/components/fibaro/conftest.py +++ b/tests/components/fibaro/conftest.py @@ -209,19 +209,22 @@ def mock_fibaro_client() -> Generator[Mock]: info_mock.hc_name = TEST_NAME info_mock.current_version = TEST_VERSION info_mock.platform = TEST_MODEL + info_mock.manufacturer_name = "Fibaro" + info_mock.model_name = "Home Center 2" + info_mock.mac_address = "00:22:4d:b7:13:24" with patch( "homeassistant.components.fibaro.FibaroClient", autospec=True ) as fibaro_client_mock: client = fibaro_client_mock.return_value - client.set_authentication.return_value = None - client.connect.return_value = True + client.connect_with_credentials.return_value = info_mock client.read_info.return_value = info_mock client.read_rooms.return_value = [] client.read_scenes.return_value = [] client.read_devices.return_value = [] client.register_update_handler.return_value = None client.unregister_update_handler.return_value = None + client.frontend_url.return_value = TEST_URL.removesuffix("/api/") yield client diff --git a/tests/components/fibaro/test_config_flow.py b/tests/components/fibaro/test_config_flow.py index 508bb81973d..aee7c2eb903 100644 --- a/tests/components/fibaro/test_config_flow.py +++ b/tests/components/fibaro/test_config_flow.py @@ -2,8 +2,8 @@ from unittest.mock import Mock +from pyfibaro.fibaro_client import FibaroAuthenticationFailed, FibaroConnectFailed import pytest -from requests.exceptions import HTTPError from homeassistant import config_entries from homeassistant.components.fibaro import DOMAIN @@ -23,8 +23,10 @@ pytestmark = pytest.mark.usefixtures("mock_setup_entry", "mock_fibaro_client") async def _recovery_after_failure_works( hass: HomeAssistant, mock_fibaro_client: Mock, result: FlowResult ) -> None: - mock_fibaro_client.connect.side_effect = None - mock_fibaro_client.connect.return_value = True + mock_fibaro_client.connect_with_credentials.side_effect = None + mock_fibaro_client.connect_with_credentials.return_value = ( + mock_fibaro_client.read_info() + ) result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -48,8 +50,10 @@ async def _recovery_after_failure_works( async def _recovery_after_reauth_failure_works( hass: HomeAssistant, mock_fibaro_client: Mock, result: FlowResult ) -> None: - mock_fibaro_client.connect.side_effect = None - mock_fibaro_client.connect.return_value = True + mock_fibaro_client.connect_with_credentials.side_effect = None + mock_fibaro_client.connect_with_credentials.return_value = ( + mock_fibaro_client.read_info() + ) result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -101,7 +105,9 @@ async def test_config_flow_user_initiated_auth_failure( assert result["step_id"] == "user" assert result["errors"] == {} - mock_fibaro_client.connect.side_effect = HTTPError(response=Mock(status_code=403)) + mock_fibaro_client.connect_with_credentials.side_effect = ( + FibaroAuthenticationFailed() + ) result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -119,7 +125,7 @@ async def test_config_flow_user_initiated_auth_failure( await _recovery_after_failure_works(hass, mock_fibaro_client, result) -async def test_config_flow_user_initiated_unknown_failure_1( +async def test_config_flow_user_initiated_connect_failure( hass: HomeAssistant, mock_fibaro_client: Mock ) -> None: """Unknown failure in flow manually initialized by the user.""" @@ -131,37 +137,7 @@ async def test_config_flow_user_initiated_unknown_failure_1( assert result["step_id"] == "user" assert result["errors"] == {} - mock_fibaro_client.connect.side_effect = HTTPError(response=Mock(status_code=500)) - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_URL: TEST_URL, - CONF_USERNAME: TEST_USERNAME, - CONF_PASSWORD: TEST_PASSWORD, - }, - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - assert result["errors"] == {"base": "cannot_connect"} - - await _recovery_after_failure_works(hass, mock_fibaro_client, result) - - -async def test_config_flow_user_initiated_unknown_failure_2( - hass: HomeAssistant, mock_fibaro_client: Mock -) -> None: - """Unknown failure in flow manually initialized by the user.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - assert result["errors"] == {} - - mock_fibaro_client.connect.side_effect = Exception() + mock_fibaro_client.connect_with_credentials.side_effect = FibaroConnectFailed() result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -208,7 +184,7 @@ async def test_reauth_connect_failure( assert result["step_id"] == "reauth_confirm" assert result["errors"] == {} - mock_fibaro_client.connect.side_effect = Exception() + mock_fibaro_client.connect_with_credentials.side_effect = FibaroConnectFailed() result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -233,7 +209,9 @@ async def test_reauth_auth_failure( assert result["step_id"] == "reauth_confirm" assert result["errors"] == {} - mock_fibaro_client.connect.side_effect = HTTPError(response=Mock(status_code=403)) + mock_fibaro_client.connect_with_credentials.side_effect = ( + FibaroAuthenticationFailed() + ) result = await hass.config_entries.flow.async_configure( result["flow_id"], From 5a6ffe19013cac6ce373ea54c12b80b983bd2ae9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 28 Feb 2025 19:49:31 +0000 Subject: [PATCH 1980/3148] Update Bluetooth remote config entries if the MAC is corrected (#139457) * fix ble mac * fixes * fixes * fixes * restore deleted test --- .../components/bluetooth/__init__.py | 19 +++++-- .../components/bluetooth/config_flow.py | 18 +++++-- .../components/bluetooth/test_config_flow.py | 37 ++++++++++++++ tests/components/bluetooth/test_init.py | 49 +++++++++++++++++++ 4 files changed, 117 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/bluetooth/__init__.py b/homeassistant/components/bluetooth/__init__.py index c46ef22803e..7abc929fde5 100644 --- a/homeassistant/components/bluetooth/__init__.py +++ b/homeassistant/components/bluetooth/__init__.py @@ -311,11 +311,24 @@ async def async_update_device( update the device with the new location so they can figure out where the adapter is. """ + address = details[ADAPTER_ADDRESS] + connections = {(dr.CONNECTION_BLUETOOTH, address)} device_registry = dr.async_get(hass) + # We only have one device for the config entry + # so if the address has been corrected, make + # sure the device entry reflects the correct + # address + for device in dr.async_entries_for_config_entry(device_registry, entry.entry_id): + for conn_type, conn_value in device.connections: + if conn_type == dr.CONNECTION_BLUETOOTH and conn_value != address: + device_registry.async_update_device( + device.id, new_connections=connections + ) + break device_entry = device_registry.async_get_or_create( config_entry_id=entry.entry_id, - name=adapter_human_name(adapter, details[ADAPTER_ADDRESS]), - connections={(dr.CONNECTION_BLUETOOTH, details[ADAPTER_ADDRESS])}, + name=adapter_human_name(adapter, address), + connections=connections, manufacturer=details[ADAPTER_MANUFACTURER], model=adapter_model(details), sw_version=details.get(ADAPTER_SW_VERSION), @@ -342,9 +355,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) ) ) + return True address = entry.unique_id assert address is not None - assert source_entry is not None source_domain = entry.data[CONF_SOURCE_DOMAIN] if mac_manufacturer := await get_manufacturer_from_mac(address): manufacturer = f"{mac_manufacturer} ({source_domain})" diff --git a/homeassistant/components/bluetooth/config_flow.py b/homeassistant/components/bluetooth/config_flow.py index e76277306f5..328707bd722 100644 --- a/homeassistant/components/bluetooth/config_flow.py +++ b/homeassistant/components/bluetooth/config_flow.py @@ -186,16 +186,28 @@ class BluetoothConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a flow initialized by an external scanner.""" source = user_input[CONF_SOURCE] await self.async_set_unique_id(source) + source_config_entry_id = user_input[CONF_SOURCE_CONFIG_ENTRY_ID] data = { CONF_SOURCE: source, CONF_SOURCE_MODEL: user_input[CONF_SOURCE_MODEL], CONF_SOURCE_DOMAIN: user_input[CONF_SOURCE_DOMAIN], - CONF_SOURCE_CONFIG_ENTRY_ID: user_input[CONF_SOURCE_CONFIG_ENTRY_ID], + CONF_SOURCE_CONFIG_ENTRY_ID: source_config_entry_id, CONF_SOURCE_DEVICE_ID: user_input[CONF_SOURCE_DEVICE_ID], } self._abort_if_unique_id_configured(updates=data) - manager = get_manager() - scanner = manager.async_scanner_by_source(source) + for entry in self._async_current_entries(include_ignore=False): + # If the mac address needs to be corrected, migrate + # the config entry to the new mac address + if ( + entry.data.get(CONF_SOURCE_CONFIG_ENTRY_ID) == source_config_entry_id + and entry.unique_id != source + ): + self.hass.config_entries.async_update_entry( + entry, unique_id=source, data={**entry.data, **data} + ) + self.hass.config_entries.async_schedule_reload(entry.entry_id) + return self.async_abort(reason="already_configured") + scanner = get_manager().async_scanner_by_source(source) assert scanner is not None return self.async_create_entry(title=scanner.name, data=data) diff --git a/tests/components/bluetooth/test_config_flow.py b/tests/components/bluetooth/test_config_flow.py index f0136396c22..45d177de132 100644 --- a/tests/components/bluetooth/test_config_flow.py +++ b/tests/components/bluetooth/test_config_flow.py @@ -608,3 +608,40 @@ async def test_async_step_integration_discovery_remote_adapter( await hass.async_block_till_done() cancel_scanner() await hass.async_block_till_done() + + +@pytest.mark.usefixtures("enable_bluetooth") +async def test_async_step_integration_discovery_remote_adapter_mac_fix( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + area_registry: ar.AreaRegistry, +) -> None: + """Test remote adapter corrects mac address via integration discovery.""" + entry = MockConfigEntry(domain="test") + entry.add_to_hass(hass) + bluetooth_entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_SOURCE: "AA:BB:CC:DD:EE:FF", + CONF_SOURCE_DOMAIN: "test", + CONF_SOURCE_MODEL: "test", + CONF_SOURCE_CONFIG_ENTRY_ID: entry.entry_id, + CONF_SOURCE_DEVICE_ID: None, + }, + ) + bluetooth_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, + data={ + CONF_SOURCE: "AA:AA:AA:AA:AA:AA", + CONF_SOURCE_DOMAIN: "test", + CONF_SOURCE_MODEL: "test", + CONF_SOURCE_CONFIG_ENTRY_ID: entry.entry_id, + CONF_SOURCE_DEVICE_ID: None, + }, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + assert bluetooth_entry.unique_id == "AA:AA:AA:AA:AA:AA" + assert bluetooth_entry.data[CONF_SOURCE] == "AA:AA:AA:AA:AA:AA" diff --git a/tests/components/bluetooth/test_init.py b/tests/components/bluetooth/test_init.py index 2c8c9e70e7f..de299c58b93 100644 --- a/tests/components/bluetooth/test_init.py +++ b/tests/components/bluetooth/test_init.py @@ -3300,3 +3300,52 @@ async def test_cleanup_orphened_remote_scanner_config_entry( assert not hass.config_entries.async_entry_for_domain_unique_id( "bluetooth", scanner.source ) + + +@pytest.mark.usefixtures("enable_bluetooth") +async def test_fix_incorrect_mac_remote_scanner_config_entry( + hass: HomeAssistant, +) -> None: + """Test the remote scanner config entries can replace a incorrect mac.""" + source_entry = MockConfigEntry(domain="test") + source_entry.add_to_hass(hass) + connector = ( + HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False), + ) + scanner = FakeRemoteScanner("AA:BB:CC:DD:EE:FF", "esp32", connector, True) + assert scanner.source == "AA:BB:CC:DD:EE:FF" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_SOURCE: scanner.source, + CONF_SOURCE_DOMAIN: "test", + CONF_SOURCE_MODEL: "test", + CONF_SOURCE_CONFIG_ENTRY_ID: source_entry.entry_id, + }, + unique_id=scanner.source, + ) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + assert hass.config_entries.async_entry_for_domain_unique_id( + "bluetooth", scanner.source + ) + await hass.config_entries.async_unload(entry.entry_id) + + new_scanner = FakeRemoteScanner("AA:BB:CC:DD:EE:AA", "esp32", connector, True) + assert new_scanner.source == "AA:BB:CC:DD:EE:AA" + hass.config_entries.async_update_entry( + entry, + data={**entry.data, CONF_SOURCE: new_scanner.source}, + unique_id=new_scanner.source, + ) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + assert hass.config_entries.async_entry_for_domain_unique_id( + "bluetooth", new_scanner.source + ) + # Incorrect connection should be removed + assert not hass.config_entries.async_entry_for_domain_unique_id( + "bluetooth", scanner.source + ) From 0f615bbe4f25094c503192ff7e363e2ce8748090 Mon Sep 17 00:00:00 2001 From: Cameron Ring Date: Fri, 28 Feb 2025 11:50:39 -0800 Subject: [PATCH 1981/3148] Add OptionsFlowHandler test for Lutron (#139463) --- tests/components/lutron/test_config_flow.py | 42 ++++++++++++++++++++- 1 file changed, 40 insertions(+), 2 deletions(-) diff --git a/tests/components/lutron/test_config_flow.py b/tests/components/lutron/test_config_flow.py index 47b2a4891cf..df861fafffe 100644 --- a/tests/components/lutron/test_config_flow.py +++ b/tests/components/lutron/test_config_flow.py @@ -6,11 +6,11 @@ from urllib.error import HTTPError import pytest -from homeassistant.components.lutron.const import DOMAIN +from homeassistant.components.lutron.const import CONF_DEFAULT_DIMMER_LEVEL, DOMAIN from homeassistant.config_entries import SOURCE_USER from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant -from homeassistant.data_entry_flow import FlowResultType +from homeassistant.data_entry_flow import FlowResultType, InvalidData from tests.common import MockConfigEntry @@ -146,3 +146,41 @@ MOCK_DATA_IMPORT = { CONF_USERNAME: "lutron", CONF_PASSWORD: "integration", } + + +async def test_options_flow(hass: HomeAssistant) -> None: + """Test options flow.""" + + config_entry = MockConfigEntry( + domain=DOMAIN, + data=MOCK_DATA_STEP, + unique_id="12345678901", + ) + config_entry.add_to_hass(hass) + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "init" + + # Try to set an out of range dimmer level (260) + out_of_range_level = 260 + + # The voluptuous validation will raise an exception before the handler processes it + with pytest.raises(InvalidData): + await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={CONF_DEFAULT_DIMMER_LEVEL: out_of_range_level}, + ) + + # Now try with a valid value + valid_level = 100 + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={CONF_DEFAULT_DIMMER_LEVEL: valid_level}, + ) + + # Verify that the flow finishes successfully with the valid value + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == {CONF_DEFAULT_DIMMER_LEVEL: valid_level} From 32950df0b700a74346a5c43a90675dfe525be9ba Mon Sep 17 00:00:00 2001 From: G Johansson Date: Fri, 28 Feb 2025 20:51:56 +0100 Subject: [PATCH 1982/3148] Specify recorder as after dependency in sql integration (#139037) * Specify recorder as after dependency in sql integration * Remove hassfest exception --------- Co-authored-by: J. Nick Koston --- homeassistant/components/sql/manifest.json | 1 + script/hassfest/dependencies.py | 2 -- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/homeassistant/components/sql/manifest.json b/homeassistant/components/sql/manifest.json index c18b1b9f05f..2b00a5b0d65 100644 --- a/homeassistant/components/sql/manifest.json +++ b/homeassistant/components/sql/manifest.json @@ -1,6 +1,7 @@ { "domain": "sql", "name": "SQL", + "after_dependencies": ["recorder"], "codeowners": ["@gjohansson-ST", "@dougiteixeira"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/sql", diff --git a/script/hassfest/dependencies.py b/script/hassfest/dependencies.py index 368c2f762b8..b22027500dd 100644 --- a/script/hassfest/dependencies.py +++ b/script/hassfest/dependencies.py @@ -153,8 +153,6 @@ ALLOWED_USED_COMPONENTS = { } IGNORE_VIOLATIONS = { - # Has same requirement, gets defaults. - ("sql", "recorder"), # Sharing a base class ("lutron_caseta", "lutron"), ("ffmpeg_noise", "ffmpeg_motion"), From c21234672dc5f1ee169502a49a7a0f94789f2c10 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Fri, 28 Feb 2025 20:56:43 +0100 Subject: [PATCH 1983/3148] Ensure Hue bridge is added first to the device registry (#139438) --- homeassistant/components/hue/v2/device.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/hue/v2/device.py b/homeassistant/components/hue/v2/device.py index 25a027f9ebe..7bb3d28e962 100644 --- a/homeassistant/components/hue/v2/device.py +++ b/homeassistant/components/hue/v2/device.py @@ -94,7 +94,12 @@ async def async_setup_devices(bridge: HueBridge): add_device(hue_resource) # create/update all current devices found in controllers - known_devices = [add_device(hue_device) for hue_device in dev_controller] + # sort the devices to ensure bridges are added first + hue_devices = list(dev_controller) + hue_devices.sort( + key=lambda dev: dev.metadata.archetype != DeviceArchetypes.BRIDGE_V2 + ) + known_devices = [add_device(hue_device) for hue_device in hue_devices] known_devices += [add_device(hue_room) for hue_room in api.groups.room] known_devices += [add_device(hue_zone) for hue_zone in api.groups.zone] From ed06831e9d401d15b85d986bfff31bfdd30cdc90 Mon Sep 17 00:00:00 2001 From: StaleLoafOfBread <45444205+StaleLoafOfBread@users.noreply.github.com> Date: Fri, 28 Feb 2025 14:59:35 -0500 Subject: [PATCH 1984/3148] Fix alert not respecting can_acknowledge setting (#139483) * fix(alert): check can_ack prior to acking * fix(alert): add test for when can_acknowledge=False * fix(alert): warn on can_ack blocking an ack * Raise error when trying to acknowledge alert with can_acknowledge set to False * Rewrite can_ack check as guard Co-authored-by: Franck Nijhof * Make can_ack service error msg human readable because it will show up in the UI * format with ruff * Make pytest aware of service error when acking an unackable alert --------- Co-authored-by: Franck Nijhof --- homeassistant/components/alert/entity.py | 5 ++-- tests/components/alert/test_init.py | 30 ++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/alert/entity.py b/homeassistant/components/alert/entity.py index 629047b15ba..a11b281428f 100644 --- a/homeassistant/components/alert/entity.py +++ b/homeassistant/components/alert/entity.py @@ -14,7 +14,7 @@ from homeassistant.components.notify import ( ) from homeassistant.const import STATE_IDLE, STATE_OFF, STATE_ON from homeassistant.core import Event, EventStateChangedData, HassJob, HomeAssistant -from homeassistant.exceptions import ServiceNotFound +from homeassistant.exceptions import ServiceNotFound, ServiceValidationError from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import ( async_track_point_in_time, @@ -195,7 +195,8 @@ class AlertEntity(Entity): async def async_turn_off(self, **kwargs: Any) -> None: """Async Acknowledge alert.""" - LOGGER.debug("Acknowledged Alert: %s", self._attr_name) + if not self._can_ack: + raise ServiceValidationError("This alert cannot be acknowledged") self._ack = True self.async_write_ha_state() diff --git a/tests/components/alert/test_init.py b/tests/components/alert/test_init.py index 27997a093e5..4407775a582 100644 --- a/tests/components/alert/test_init.py +++ b/tests/components/alert/test_init.py @@ -28,6 +28,7 @@ from homeassistant.const import ( STATE_ON, ) from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.exceptions import ServiceValidationError from homeassistant.setup import async_setup_component from tests.common import MockEntityPlatform, async_mock_service @@ -116,6 +117,35 @@ async def test_silence(hass: HomeAssistant, mock_notifier: list[ServiceCall]) -> assert hass.states.get(ENTITY_ID).state == STATE_ON +async def test_silence_can_acknowledge_false(hass: HomeAssistant) -> None: + """Test that attempting to silence an alert with can_acknowledge=False will not silence.""" + # Create copy of config where can_acknowledge is False + config = deepcopy(TEST_CONFIG) + config[DOMAIN][NAME]["can_acknowledge"] = False + + # Setup the alert component + assert await async_setup_component(hass, DOMAIN, config) + await hass.async_block_till_done() + + # Ensure the alert is currently on + hass.states.async_set(ENTITY_ID, STATE_ON) + await hass.async_block_till_done() + assert hass.states.get(ENTITY_ID).state == STATE_ON + + # Attempt to acknowledge + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + await hass.async_block_till_done() + + # The state should still be ON because can_acknowledge=False + assert hass.states.get(ENTITY_ID).state == STATE_ON + + async def test_reset(hass: HomeAssistant, mock_notifier: list[ServiceCall]) -> None: """Test resetting the alert.""" assert await async_setup_component(hass, DOMAIN, TEST_CONFIG) From 3f48826370431da963c16746455b35cfba731db6 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 28 Feb 2025 21:06:45 +0100 Subject: [PATCH 1985/3148] Bump pysmartthings to 2.2.0 (#139539) --- homeassistant/components/smartthings/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/smartthings/manifest.json b/homeassistant/components/smartthings/manifest.json index 5dd570f2751..0ca6c1f3b26 100644 --- a/homeassistant/components/smartthings/manifest.json +++ b/homeassistant/components/smartthings/manifest.json @@ -29,5 +29,5 @@ "documentation": "https://www.home-assistant.io/integrations/smartthings", "iot_class": "cloud_push", "loggers": ["pysmartthings"], - "requirements": ["pysmartthings==2.1.0"] + "requirements": ["pysmartthings==2.2.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index dbaa1bd3b1e..049b307e399 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2310,7 +2310,7 @@ pysma==0.7.5 pysmappee==0.2.29 # homeassistant.components.smartthings -pysmartthings==2.1.0 +pysmartthings==2.2.0 # homeassistant.components.smarty pysmarty2==0.10.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 693e9002389..dbec7989182 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1882,7 +1882,7 @@ pysma==0.7.5 pysmappee==0.2.29 # homeassistant.components.smartthings -pysmartthings==2.1.0 +pysmartthings==2.2.0 # homeassistant.components.smarty pysmarty2==0.10.2 From 00b7c4f9ef74211e92f2f304312d5f7a39c66b41 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 28 Feb 2025 22:30:57 +0100 Subject: [PATCH 1986/3148] Improve SmartThings OCF device info (#139547) --- homeassistant/components/smartthings/entity.py | 18 ++++++------------ .../smartthings/snapshots/test_init.ambr | 16 ++++++++-------- 2 files changed, 14 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/smartthings/entity.py b/homeassistant/components/smartthings/entity.py index 1383196ce15..0d6ee32b473 100644 --- a/homeassistant/components/smartthings/entity.py +++ b/homeassistant/components/smartthings/entity.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import Any, cast +from typing import Any from pysmartthings import ( Attribute, @@ -44,19 +44,13 @@ class SmartThingsEntity(Entity): identifiers={(DOMAIN, device.device.device_id)}, name=device.device.label, ) - if (ocf := device.status[MAIN].get(Capability.OCF)) is not None: + if (ocf := device.device.ocf) is not None: self._attr_device_info.update( { - "manufacturer": cast( - str | None, ocf[Attribute.MANUFACTURER_NAME].value - ), - "model": cast(str | None, ocf[Attribute.MODEL_NUMBER].value), - "hw_version": cast( - str | None, ocf[Attribute.HARDWARE_VERSION].value - ), - "sw_version": cast( - str | None, ocf[Attribute.OCF_FIRMWARE_VERSION].value - ), + "manufacturer": ocf.manufacturer_name, + "model": ocf.model_number.split("|")[0], + "hw_version": ocf.hardware_version, + "sw_version": ocf.firmware_version, } ) diff --git a/tests/components/smartthings/snapshots/test_init.ambr b/tests/components/smartthings/snapshots/test_init.ambr index 546d99a967f..0b5aeb57c18 100644 --- a/tests/components/smartthings/snapshots/test_init.ambr +++ b/tests/components/smartthings/snapshots/test_init.ambr @@ -219,7 +219,7 @@ 'labels': set({ }), 'manufacturer': 'Samsung Electronics', - 'model': 'ARTIK051_KRAC_18K|10193441|60010132001111110200000000000000', + 'model': 'ARTIK051_KRAC_18K', 'model_id': None, 'name': 'AC Office Granit', 'name_by_user': None, @@ -252,7 +252,7 @@ 'labels': set({ }), 'manufacturer': 'Samsung Electronics', - 'model': 'ARA-WW-TP1-22-COMMON|10229641|60010523001511014600083200800000', + 'model': 'ARA-WW-TP1-22-COMMON', 'model_id': None, 'name': 'Aire Dormitorio Principal', 'name_by_user': None, @@ -285,7 +285,7 @@ 'labels': set({ }), 'manufacturer': 'Samsung Electronics', - 'model': 'TP2X_DA-KS-MICROWAVE-0101X|40436241|50040100011411000200000000000000', + 'model': 'TP2X_DA-KS-MICROWAVE-0101X', 'model_id': None, 'name': 'Microwave', 'name_by_user': None, @@ -318,7 +318,7 @@ 'labels': set({ }), 'manufacturer': 'Samsung Electronics', - 'model': 'TP2X_REF_20K|00115641|0004014D011411200103000020000000', + 'model': 'TP2X_REF_20K', 'model_id': None, 'name': 'Refrigerator', 'name_by_user': None, @@ -351,7 +351,7 @@ 'labels': set({ }), 'manufacturer': 'Samsung Electronics', - 'model': 'powerbot_7000_17M|50016055|80010404011141000100000000000000', + 'model': 'powerbot_7000_17M', 'model_id': None, 'name': 'Robot vacuum', 'name_by_user': None, @@ -384,7 +384,7 @@ 'labels': set({ }), 'manufacturer': 'Samsung Electronics', - 'model': 'DA_DW_A51_20_COMMON|30007242|40010201001311000101000000000000', + 'model': 'DA_DW_A51_20_COMMON', 'model_id': None, 'name': 'Dishwasher', 'name_by_user': None, @@ -417,7 +417,7 @@ 'labels': set({ }), 'manufacturer': 'Samsung Electronics', - 'model': 'DA_WM_A51_20_COMMON|20233741|3000000100111100020B000000000000', + 'model': 'DA_WM_A51_20_COMMON', 'model_id': None, 'name': 'Dryer', 'name_by_user': None, @@ -450,7 +450,7 @@ 'labels': set({ }), 'manufacturer': 'Samsung Electronics', - 'model': 'DA_WM_TP2_20_COMMON|20233741|2001000100131100022B010000000000', + 'model': 'DA_WM_TP2_20_COMMON', 'model_id': None, 'name': 'Washer', 'name_by_user': None, From ac4c379a0ed98484ce88ca8317c8260964396731 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 28 Feb 2025 21:42:33 +0000 Subject: [PATCH 1987/3148] Bump PySwitchBot to 0.56.1 (#139544) changelog: https://github.com/sblibs/pySwitchbot/compare/0.56.0...0.56.1 --- homeassistant/components/switchbot/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/manifest.json b/homeassistant/components/switchbot/manifest.json index 92a1c25d6f5..567a33a8f43 100644 --- a/homeassistant/components/switchbot/manifest.json +++ b/homeassistant/components/switchbot/manifest.json @@ -39,5 +39,5 @@ "documentation": "https://www.home-assistant.io/integrations/switchbot", "iot_class": "local_push", "loggers": ["switchbot"], - "requirements": ["PySwitchbot==0.56.0"] + "requirements": ["PySwitchbot==0.56.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 049b307e399..12fa6c7c7df 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -84,7 +84,7 @@ PyQRCode==1.2.1 PyRMVtransport==0.3.3 # homeassistant.components.switchbot -PySwitchbot==0.56.0 +PySwitchbot==0.56.1 # homeassistant.components.switchmate PySwitchmate==0.5.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index dbec7989182..d11597c908c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -81,7 +81,7 @@ PyQRCode==1.2.1 PyRMVtransport==0.3.3 # homeassistant.components.switchbot -PySwitchbot==0.56.0 +PySwitchbot==0.56.1 # homeassistant.components.syncthru PySyncThru==0.8.0 From 2d6068b8426238a2c39b794d2d874c5fe3176d91 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 28 Feb 2025 22:58:35 +0100 Subject: [PATCH 1988/3148] Create device for the hub in SmartThings (#139545) * Create device for the hub in SmartThings * Create device for the hub in SmartThings * Create device for the hub in SmartThings --- .../components/smartthings/__init__.py | 12 +- .../components/smartthings/entity.py | 5 + .../fixtures/device_status/hub.json | 3 + .../aeotec_home_energy_meter_gen5.json | 1 - .../fixtures/devices/base_electric_meter.json | 1 - .../fixtures/devices/centralite.json | 1 - .../fixtures/devices/contact_sensor.json | 1 - .../fixtures/devices/fake_fan.json | 1 - .../devices/ge_in_wall_smart_dimmer.json | 1 - .../smartthings/fixtures/devices/hub.json | 718 ++++++++++++++++++ .../smartthings/fixtures/devices/iphone.json | 1 - .../fixtures/devices/multipurpose_sensor.json | 1 - .../fixtures/devices/smart_plug.json | 1 - .../fixtures/devices/sonos_player.json | 1 - .../yale_push_button_deadbolt_lock.json | 1 - .../smartthings/snapshots/test_init.ambr | 33 + tests/components/smartthings/test_init.py | 34 +- 17 files changed, 802 insertions(+), 14 deletions(-) create mode 100644 tests/components/smartthings/fixtures/device_status/hub.json create mode 100644 tests/components/smartthings/fixtures/devices/hub.json diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py index d6de1d3d252..2bacd476332 100644 --- a/homeassistant/components/smartthings/__init__.py +++ b/homeassistant/components/smartthings/__init__.py @@ -99,6 +99,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: SmartThingsConfigEntry) except SmartThingsAuthenticationFailedError as err: raise ConfigEntryAuthFailed from err + device_registry = dr.async_get(hass) + for dev in device_status.values(): + for component in dev.device.components: + if component.id == MAIN and Capability.BRIDGE in component.capabilities: + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, dev.device.device_id)}, + name=dev.device.label, + ) scenes = { scene.scene_id: scene for scene in await client.get_scenes(location_id=entry.data[CONF_LOCATION_ID]) @@ -124,7 +133,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: SmartThingsConfigEntry) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - device_registry = dr.async_get(hass) device_entries = dr.async_entries_for_config_entry(device_registry, entry.entry_id) for device_entry in device_entries: device_id = next( @@ -132,7 +140,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: SmartThingsConfigEntry) for identifier in device_entry.identifiers if identifier[0] == DOMAIN ) - if device_id in entry.runtime_data.devices: + if device_id in device_status: continue device_registry.async_update_device( device_entry.id, remove_config_entry_id=entry.entry_id diff --git a/homeassistant/components/smartthings/entity.py b/homeassistant/components/smartthings/entity.py index 0d6ee32b473..790f3672680 100644 --- a/homeassistant/components/smartthings/entity.py +++ b/homeassistant/components/smartthings/entity.py @@ -44,6 +44,11 @@ class SmartThingsEntity(Entity): identifiers={(DOMAIN, device.device.device_id)}, name=device.device.label, ) + if device.device.parent_device_id: + self._attr_device_info["via_device"] = ( + DOMAIN, + device.device.parent_device_id, + ) if (ocf := device.device.ocf) is not None: self._attr_device_info.update( { diff --git a/tests/components/smartthings/fixtures/device_status/hub.json b/tests/components/smartthings/fixtures/device_status/hub.json new file mode 100644 index 00000000000..98ff4c3a8b4 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/hub.json @@ -0,0 +1,3 @@ +{ + "components": {} +} diff --git a/tests/components/smartthings/fixtures/devices/aeotec_home_energy_meter_gen5.json b/tests/components/smartthings/fixtures/devices/aeotec_home_energy_meter_gen5.json index 5ef0e2fd9eb..ab2fe41c678 100644 --- a/tests/components/smartthings/fixtures/devices/aeotec_home_energy_meter_gen5.json +++ b/tests/components/smartthings/fixtures/devices/aeotec_home_energy_meter_gen5.json @@ -45,7 +45,6 @@ } ], "createTime": "2023-01-12T23:02:44.917Z", - "parentDeviceId": "6a2d07a4-dd77-48bc-9acf-017029aaf099", "profile": { "id": "6372c227-93c7-32ef-9be5-aef2221adff1" }, diff --git a/tests/components/smartthings/fixtures/devices/base_electric_meter.json b/tests/components/smartthings/fixtures/devices/base_electric_meter.json index 9e0c130978c..4d00d6f169c 100644 --- a/tests/components/smartthings/fixtures/devices/base_electric_meter.json +++ b/tests/components/smartthings/fixtures/devices/base_electric_meter.json @@ -37,7 +37,6 @@ } ], "createTime": "2023-06-03T16:23:57.284Z", - "parentDeviceId": "074fa784-8be8-4c70-8e22-6f5ed6f81b7e", "profile": { "id": "d382796f-8ed5-3088-8735-eb03e962203b" }, diff --git a/tests/components/smartthings/fixtures/devices/centralite.json b/tests/components/smartthings/fixtures/devices/centralite.json index 68cdbdf4499..dff2be78f70 100644 --- a/tests/components/smartthings/fixtures/devices/centralite.json +++ b/tests/components/smartthings/fixtures/devices/centralite.json @@ -45,7 +45,6 @@ } ], "createTime": "2024-08-15T22:16:37.926Z", - "parentDeviceId": "074fa784-8be8-4c70-8e22-6f5ed6f81b7e", "profile": { "id": "24195ea4-635c-3450-a235-71bc78ab3d1c" }, diff --git a/tests/components/smartthings/fixtures/devices/contact_sensor.json b/tests/components/smartthings/fixtures/devices/contact_sensor.json index a5de2e2cbfe..92fe6a8bbff 100644 --- a/tests/components/smartthings/fixtures/devices/contact_sensor.json +++ b/tests/components/smartthings/fixtures/devices/contact_sensor.json @@ -49,7 +49,6 @@ } ], "createTime": "2023-09-28T17:38:59.179Z", - "parentDeviceId": "074fa784-8be8-4c70-8e22-6f5ed6f81b7e", "profile": { "id": "22aa5a07-ac33-365f-b2f1-5ecef8cdb0eb" }, diff --git a/tests/components/smartthings/fixtures/devices/fake_fan.json b/tests/components/smartthings/fixtures/devices/fake_fan.json index 7b8e174d420..8656e290c8d 100644 --- a/tests/components/smartthings/fixtures/devices/fake_fan.json +++ b/tests/components/smartthings/fixtures/devices/fake_fan.json @@ -36,7 +36,6 @@ } ], "createTime": "2023-01-12T23:02:44.917Z", - "parentDeviceId": "6a2dd7a4-dd77-48bc-9acf-017029aaf099", "profile": { "id": "6372cd27-93c7-32ef-9be5-aef2221adff1" }, diff --git a/tests/components/smartthings/fixtures/devices/ge_in_wall_smart_dimmer.json b/tests/components/smartthings/fixtures/devices/ge_in_wall_smart_dimmer.json index 910eacec2cc..314586300b9 100644 --- a/tests/components/smartthings/fixtures/devices/ge_in_wall_smart_dimmer.json +++ b/tests/components/smartthings/fixtures/devices/ge_in_wall_smart_dimmer.json @@ -40,7 +40,6 @@ } ], "createTime": "2020-05-25T18:18:01Z", - "parentDeviceId": "074fa784-8be8-4c70-8e22-6f5ed6f81b7e", "profile": { "id": "ec5458c2-c011-3479-a59b-82b42820c2f7" }, diff --git a/tests/components/smartthings/fixtures/devices/hub.json b/tests/components/smartthings/fixtures/devices/hub.json new file mode 100644 index 00000000000..4de0823d758 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/hub.json @@ -0,0 +1,718 @@ +{ + "items": [ + { + "deviceId": "074fa784-8be8-4c70-8e22-6f5ed6f81b7e", + "name": "SmartThings v2 Hub", + "label": "Home Hub", + "manufacturerName": "SmartThingsCommunity", + "presentationId": "63f1469e-dc4a-3689-8cc5-69e293c1eb21", + "locationId": "c4d3b2a1-09f8-765e-4d3c-2b1a09f8e7d6 ", + "ownerId": "d47f2b19-3a6e-4c8d-bf21-9e8a7c5d134e", + "roomId": "f7f39cf6-ff3a-4bcb-8d1b-00a3324c016d", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "bridge", + "version": 1 + } + ], + "categories": [ + { + "name": "Hub", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2016-11-13T18:18:07Z", + "childDevices": [ + { + "deviceId": "0781c9d0-92cb-4c7b-bb5b-2f2dbe0c41f3", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "08ee0358-9f40-4afa-b5a0-3a6aba18c267", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "09076422-62cc-4b2d-8beb-b53bc451c704", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "0b5577db-5074-4b70-a2c5-efec286d264d", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "115236ea-59e5-4cd4-bade-d67c409967bc", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "1691801c-ae59-438b-89dc-f2c761fe937d", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "1a987293-0962-4447-99d4-aa82655ffb55", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "2533fdd0-e064-4fa2-b77b-1e17260b58d7", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "265e653b-3c0b-4fa6-8e2a-f6a69c7040f0", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "277e0a96-c8ec-41aa-b4cf-0bac57dc1cee", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "2d9a892b-1c93-45a5-84cb-0e81889498c6", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "374ba6fa-5a08-4ea2-969c-1fa43d86e21f", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "37c0cdda-9158-41ad-9635-4ca32df9fe5b", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "3f82e13c-bd39-4043-bb54-7432a4e47113", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "4339f999-1ad2-46fb-9103-cb628b30a022", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "4a59f635-9f0a-4a6c-a2f0-ffb7ef182a7c", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "4c3469c9-3556-4f19-a2e1-1c0a598341dc", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "4fddedf0-2662-476e-b1fd-aceaec17ad3a", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "550a1c72-65a0-4d55-b97b-75168e055398", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "630cf009-eb3b-409e-a77a-9b298540532f", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "6356b240-c7d8-403c-883e-ae438d432abe", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "68e786a6-7f61-4c3a-9e13-70b803cf782b", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "6a2e5058-36f3-4668-aa43-49a66f8df93d", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "6b5535c7-c039-42ee-9970-8af86c6b0775", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "6c1b7cfa-7429-4f35-9d02-ab1dfd2f1297", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "6ca56087-481f-4e93-9727-fb91049fe396", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "6e3e44b3-d84a-4efc-a97b-b5e0dae28ddc", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "6f4d2e72-7af4-4c96-97ab-d6b6a0d6bc4b", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "7111243f-39d6-4ed0-a277-f040e40a806d", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "7b9d924a-de0c-44f9-ac5c-f15869c59411", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "7bedac4c-5681-4897-a2ef-e9153cb19ba0", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "7d246592-93db-4d72-a10d-5a51793ece8c", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "803cb0d9-addd-4c2d-aaef-d4e20bf88228", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "829da938-6e92-4a93-8923-7c67f9663c03", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "84f1eaf0-592e-459a-a2b3-4fc43e004dae", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "8eacf25f-aa33-4d9e-ba90-0e4ac3ceb8e0", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "8f873071-a9aa-4580-a736-8f5f696e044a", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "91172212-e9ff-4ca6-9626-e7af0361c9ad", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "92138ee5-d3bf-4348-98e8-445dedc319cb", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "971b05df-6ed3-446e-b54f-5092eac01921", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "9a9cb299-5279-4dea-9249-b5c153d22ba1", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "9b479ba0-81e1-4877-87c5-c301a87cbdab", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "9dd17f8f-cf5e-4647-a11c-d8f24cdf9b2a", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "a1e6525c-1e24-403c-b18c-eecb65e22ccf", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "a9d42ef0-f972-44b0-86bc-efd6569a1aef", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "a9f587c5-5d8b-4273-8907-e7f609af5158", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "aaedaf28-2ae0-4c1d-b57e-87f6a420c298", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "b3a84295-ac3c-4fb1-95e4-4a4bbb1b0bce", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "b90c085d-7d1f-4abc-a66d-d5ce3f96be02", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "bafc5147-2e48-498b-97ff-34c93fae7814", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "c1107a0c-fa71-43c5-8ff9-a128ea6c4f20", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "c5209cd2-fcb5-46be-b685-5b05f22dcb2c", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "c5699ff6-af09-4922-901d-bb81b8345bc3", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "cfcd9a21-a943-4519-9972-3c7890cd25b1", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "d0268a69-abfb-4c92-a646-61cec2e510ad", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "d20891e5-59b4-46ce-9184-b7fdf0c7ae4c", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "d48848b9-25b0-4423-8fcf-96a022ac571e", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "ea2aa187-40fd-4140-9742-453e691c4469", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "f27d0b27-24fd-4d8c-b003-d3d7aaba1e70", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "f3c18803-cbec-48e3-8f15-3c31f302d68b", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "f3e184b2-631a-47b2-b583-32ac2fec9e3c", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "f4e0517a-d94f-4bd6-a464-222c8c413a66", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + } + ], + "profile": { + "id": "d77ba2f6-c377-36f5-bb68-15db9d1aa0e1" + }, + "hub": { + "hubEui": "D052A872947A0001", + "firmwareVersion": "000.055.00005", + "hubDrivers": [ + { + "driverVersion": "2025-01-19T15:05:25.835006968", + "driverId": "00425c55-0932-416f-a1ba-78fae98ab614", + "channelId": "c8bb99e1-04a3-426b-9d94-2d260134d624" + }, + { + "driverVersion": "2024-12-17T18:00:36.611958104", + "driverId": "01976eca-e7ff-4d1b-91db-9c980ce668d7", + "channelId": "15ea8adc-8be7-4ea6-8b51-4155f56dc6cf" + }, + { + "driverVersion": "2024-12-17T18:00:48.572636846", + "driverId": "0f206d13-508e-4342-9cbb-937e02489141", + "channelId": "15ea8adc-8be7-4ea6-8b51-4155f56dc6cf" + }, + { + "driverVersion": "2024-12-17T18:00:07.735400483", + "driverId": "2cbf55e3-dbc2-48a2-8be5-4c3ce756b692", + "channelId": "b1373fea-da9b-434b-b674-6694ce5d08cc" + }, + { + "driverVersion": "2024-11-04T22:39:17.976631549", + "driverId": "3fb97b6c-f481-441b-a14e-f270d738764e", + "channelId": "15ea8adc-8be7-4ea6-8b51-4155f56dc6cf" + }, + { + "driverVersion": "2024-10-10T18:17:51.437710641", + "driverId": "408981c2-91d4-4dfc-bbfb-84ca0205d993", + "channelId": "15ea8adc-8be7-4ea6-8b51-4155f56dc6cf" + }, + { + "driverVersion": "2024-10-10T18:17:35.032104982", + "driverId": "4eb5b19a-7bbc-452f-859b-c6d7d857b2da", + "channelId": "15ea8adc-8be7-4ea6-8b51-4155f56dc6cf" + }, + { + "driverVersion": "2023-08-08T18:58:32.479650566", + "driverId": "4fb7ec02-2697-4d73-977d-2b1c65c4484f", + "channelId": "b1373fea-da9b-434b-b674-6694ce5d08cc" + }, + { + "driverVersion": "2024-12-17T18:00:47.743217473", + "driverId": "572a2641-2af8-47e4-bfe5-ad83748fd7a1", + "channelId": "15ea8adc-8be7-4ea6-8b51-4155f56dc6cf" + }, + { + "driverVersion": "2023-07-12T03:33:26.23424277", + "driverId": "5ad2cc83-5503-4040-a98b-b0fc9931b9fe", + "channelId": "479886db-f6f5-41dd-979c-9c5f9366f070" + }, + { + "driverVersion": "2024-09-17T20:08:25.82515546", + "driverId": "5db3363a-d954-412f-93e0-2ee40572658b", + "channelId": "2423da55-101c-4b21-af58-0903656b85ca" + }, + { + "driverVersion": "2024-12-08T10:10:03.832334965", + "driverId": "6342be70-6da0-4535-afc1-ff6378d6c650", + "channelId": "c8bb99e1-04a3-426b-9d94-2d260134d624" + }, + { + "driverVersion": "2022-02-01T21:35:33.624882", + "driverId": "6a90f7a0-e275-4366-bbf2-2e8a502efc5d", + "channelId": "479886db-f6f5-41dd-979c-9c5f9366f070" + }, + { + "driverVersion": "2024-09-28T21:56:32.002090649", + "driverId": "7333473f-722c-465d-9e5d-f3a6ca760489", + "channelId": "f8900c5e-d591-4979-9826-75a867e9e0bd" + }, + { + "driverVersion": "2025-02-03T22:38:47.582952919", + "driverId": "7beb8bc2-8dfa-45c2-8fdb-7373d4597b12", + "channelId": "15ea8adc-8be7-4ea6-8b51-4155f56dc6cf" + }, + { + "driverVersion": "2024-11-15T16:18:24.739596514", + "driverId": "7ca45ba9-7cfe-4547-b752-fe41a0efb848", + "channelId": "c8bb99e1-04a3-426b-9d94-2d260134d624" + }, + { + "driverVersion": "2024-02-06T21:13:39.427465986", + "driverId": "8bf71a5d-677b-4391-93c2-e76471f3d7eb", + "channelId": "15ea8adc-8be7-4ea6-8b51-4155f56dc6cf" + }, + { + "driverVersion": "2024-10-21T19:06:49.949052991", + "driverId": "9050ac53-358c-47a1-a927-9a70f5f28cee", + "channelId": "15ea8adc-8be7-4ea6-8b51-4155f56dc6cf" + }, + { + "driverVersion": "2024-10-10T19:30:29.754256377", + "driverId": "92f39ab3-7b2f-47ee-94a7-ba47c4ee8a47", + "channelId": "b1373fea-da9b-434b-b674-6694ce5d08cc" + }, + { + "driverVersion": "2024-12-17T18:00:21.846431345", + "driverId": "9870bccd-2b3d-4edf-8940-532fcb11e946", + "channelId": "b1373fea-da9b-434b-b674-6694ce5d08cc" + }, + { + "driverVersion": "2024-12-09T21:10:00.424854506", + "driverId": "a6994e70-93de-4a76-8b5d-42971a3427c9", + "channelId": "c8bb99e1-04a3-426b-9d94-2d260134d624" + }, + { + "driverVersion": "2022-01-03T08:19:45.80869", + "driverId": "a89371c4-8765-404b-9de9-e9882cc48bd8", + "channelId": "14bcc056-f80d-416b-9445-467b0db325e3" + }, + { + "driverVersion": "2025-01-11T20:03:43.842469565", + "driverId": "b1504ded-efa4-4ef0-acd5-ae24e7a92e6e", + "channelId": "c8bb99e1-04a3-426b-9d94-2d260134d624" + }, + { + "driverVersion": "2024-12-08T09:45:01.460678797", + "driverId": "bb1b3fd4-dcba-4d55-8d85-58ed7f1979fb", + "channelId": "c8bb99e1-04a3-426b-9d94-2d260134d624" + }, + { + "driverVersion": "2024-11-04T22:39:18.253781754", + "driverId": "c21a6c77-872c-474e-be5b-5f6f11a240ef", + "channelId": "15ea8adc-8be7-4ea6-8b51-4155f56dc6cf" + }, + { + "driverVersion": "2024-01-30T21:36:15.547412569", + "driverId": "c856a3fd-69ee-4478-a224-d7279b6d978f", + "channelId": "15ea8adc-8be7-4ea6-8b51-4155f56dc6cf" + }, + { + "driverVersion": "2025-01-13T18:55:57.509807915", + "driverId": "cd898d81-6c27-4d27-a529-dfadc8caae5a", + "channelId": "15ea8adc-8be7-4ea6-8b51-4155f56dc6cf" + }, + { + "driverVersion": "2024-12-17T18:00:48.892833142", + "driverId": "ce930ffd-8155-4dca-aaa9-6c4158fc4278", + "channelId": "15ea8adc-8be7-4ea6-8b51-4155f56dc6cf" + }, + { + "driverVersion": "2024-10-10T19:30:41.208767469", + "driverId": "d620900d-f7bc-4ab5-a171-6dd159872f7d", + "channelId": "b1373fea-da9b-434b-b674-6694ce5d08cc" + }, + { + "driverVersion": "2024-10-10T19:30:33.46670456", + "driverId": "d6b43c85-1561-446b-9e3e-15e2ad81a952", + "channelId": "b1373fea-da9b-434b-b674-6694ce5d08cc" + }, + { + "driverVersion": "2023-07-11T18:43:49.169154271", + "driverId": "d9c3f8b8-c3c3-4b77-9ddd-01d08102c84b", + "channelId": "15ea8adc-8be7-4ea6-8b51-4155f56dc6cf" + }, + { + "driverVersion": "2024-10-10T18:17:54.195543653", + "driverId": "dbe192cb-f6a1-4369-a843-d1c42e5c91ba", + "channelId": "15ea8adc-8be7-4ea6-8b51-4155f56dc6cf" + }, + { + "driverVersion": "2022-10-02T20:15:49.147522379", + "driverId": "e120daf2-8000-4a9d-93fa-653214ce70d1", + "channelId": "479886db-f6f5-41dd-979c-9c5f9366f070" + }, + { + "driverVersion": "2023-08-15T20:08:28.115440571", + "driverId": "e7947a05-947d-4bb5-92c4-2aafaff6d69c", + "channelId": "b1373fea-da9b-434b-b674-6694ce5d08cc" + }, + { + "driverVersion": "2025-02-05T18:49:13.3338494", + "driverId": "f2e891c6-00cc-446c-9192-8ebda63d9898", + "channelId": "b1373fea-da9b-434b-b674-6694ce5d08cc" + } + ], + "hubData": { + "zwaveStaticDsk": "13740-14339-50623-49310-29679-58685-46457-16097", + "zwaveS2": true, + "hardwareType": "V2_HUB", + "hardwareId": "000D", + "zigbeeFirmware": "5.7.10", + "zigbee3": true, + "zigbeeOta": "ENABLED_WITH_LIGHTS", + "otaEnable": "true", + "zigbeeUnsecureRejoin": true, + "zigbeeRequiresExternalHardware": false, + "threadRequiresExternalHardware": false, + "failoverAvailability": "Unsupported", + "primarySupportAvailability": "Unsupported", + "secondarySupportAvailability": "Unsupported", + "zigbeeAvailability": "Available", + "zwaveAvailability": "Available", + "lanAvailability": "Available", + "matterAvailability": "Available", + "localVirtualDeviceAvailability": "Available", + "childDeviceAvailability": "Unsupported", + "edgeDriversAvailability": "Available", + "hubReplaceAvailability": "Available", + "hubLocalApiAvailability": "Available", + "zigbeeManualFirmwareUpdateSupported": true, + "matterRendezvousHedgeSupported": true, + "matterSoftwareComponentVersion": "1.3-0", + "matterDeviceDiagnosticsAvailability": "Available", + "zigbeeDeviceDiagnosticsAvailability": "Available", + "hedgeTlsCertificate": "", + "zigbeeChannel": "14", + "zigbeePanId": "0EE7", + "zigbeeEui": "D052A872947A0001", + "zigbeeNodeID": "0000", + "zwaveNodeID": "01", + "zwaveHomeID": "CF0F089E", + "zwaveSucID": "01", + "zwaveVersion": "6.10", + "zwaveRegion": "US", + "macAddress": "D0:52:A8:72:91:02", + "localIP": "192.168.168.189", + "zigbeeRadioFunctional": true, + "zwaveRadioFunctional": true, + "zigbeeRadioEnabled": true, + "zwaveRadioEnabled": true, + "zigbeeRadioDetected": true, + "zwaveRadioDetected": true + } + }, + "type": "HUB", + "restrictionTier": 0, + "allowed": [], + "executionContext": "CLOUD" + }, + { + "deviceId": "374ba6fa-5a08-4ea2-969c-1fa43d86e21f", + "name": "Multipurpose Sensor", + "label": "Mail Box", + "manufacturerName": "SmartThingsCommunity", + "presentationId": "c385e2bc-acb8-317b-be2a-6efd1f879720", + "deviceManufacturerCode": "SmartThings", + "locationId": "c4d3b2a1-09f8-765e-4d3c-2b1a09f8e7d6 ", + "ownerId": "d47f2b19-3a6e-4c8d-bf21-9e8a7c5d134e", + "roomId": "f7f39cf6-ff3a-4bcb-8d1b-00a3324c016d", + "components": [ + { + "id": "main", + "label": "Mail Box", + "capabilities": [ + { + "id": "contactSensor", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "threeAxis", + "version": 1 + }, + { + "id": "accelerationSensor", + "version": 1 + }, + { + "id": "battery", + "version": 1 + }, + { + "id": "firmwareUpdate", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + } + ], + "categories": [ + { + "name": "MultiFunctionalSensor", + "categoryType": "manufacturer" + }, + { + "name": "MultiFunctionalSensor", + "categoryType": "user" + } + ] + } + ], + "createTime": "2022-08-16T21:08:09.983Z", + "parentDeviceId": "074fa784-8be8-4c70-8e22-6f5ed6f81b7e", + "profile": { + "id": "4471213f-121b-38fd-b022-51df37ac1d4c" + }, + "zigbee": { + "eui": "24FD5B00010A3A95", + "networkId": "E71B", + "driverId": "408981c2-91d4-4dfc-bbfb-84ca0205d993", + "executingLocally": true, + "hubId": "074fa784-8be8-4c70-8e22-6f5ed6f81b7e", + "provisioningState": "PROVISIONED" + }, + "type": "ZIGBEE", + "restrictionTier": 0, + "allowed": [], + "executionContext": "LOCAL" + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/iphone.json b/tests/components/smartthings/fixtures/devices/iphone.json index 3fc26307c90..1ae79aa06ef 100644 --- a/tests/components/smartthings/fixtures/devices/iphone.json +++ b/tests/components/smartthings/fixtures/devices/iphone.json @@ -27,7 +27,6 @@ } ], "createTime": "2021-12-02T16:14:24.394Z", - "parentDeviceId": "b8e11599-5297-4574-8e62-885995fcaa20", "profile": { "id": "21d0f660-98b4-3f7b-8114-fe62e555628e" }, diff --git a/tests/components/smartthings/fixtures/devices/multipurpose_sensor.json b/tests/components/smartthings/fixtures/devices/multipurpose_sensor.json index 3770614a366..b056ecf007b 100644 --- a/tests/components/smartthings/fixtures/devices/multipurpose_sensor.json +++ b/tests/components/smartthings/fixtures/devices/multipurpose_sensor.json @@ -56,7 +56,6 @@ } ], "createTime": "2019-02-23T16:53:57Z", - "parentDeviceId": "074fa784-8be8-4c70-8e22-6f5ed6f81b7e", "profile": { "id": "4471213f-121b-38fd-b022-51df37ac1d4c" }, diff --git a/tests/components/smartthings/fixtures/devices/smart_plug.json b/tests/components/smartthings/fixtures/devices/smart_plug.json index 24d0fbc6e84..105ae43c3d0 100644 --- a/tests/components/smartthings/fixtures/devices/smart_plug.json +++ b/tests/components/smartthings/fixtures/devices/smart_plug.json @@ -37,7 +37,6 @@ } ], "createTime": "2018-10-05T12:23:14Z", - "parentDeviceId": "074fa784-8be8-4c70-8e22-6f5ed6f81b7e", "profile": { "id": "daeff874-075a-32e3-8b11-bdb99d8e67c7" }, diff --git a/tests/components/smartthings/fixtures/devices/sonos_player.json b/tests/components/smartthings/fixtures/devices/sonos_player.json index 67d1ef24cf9..f7f54a01b49 100644 --- a/tests/components/smartthings/fixtures/devices/sonos_player.json +++ b/tests/components/smartthings/fixtures/devices/sonos_player.json @@ -61,7 +61,6 @@ } ], "createTime": "2025-02-02T13:18:28.570Z", - "parentDeviceId": "2f7f7d2b-e683-48ae-86f7-e57df6a0bce2", "profile": { "id": "0443d359-3f76-383f-82a4-6fc4a879ef1d" }, diff --git a/tests/components/smartthings/fixtures/devices/yale_push_button_deadbolt_lock.json b/tests/components/smartthings/fixtures/devices/yale_push_button_deadbolt_lock.json index e83a1be7644..117aa1344cb 100644 --- a/tests/components/smartthings/fixtures/devices/yale_push_button_deadbolt_lock.json +++ b/tests/components/smartthings/fixtures/devices/yale_push_button_deadbolt_lock.json @@ -45,7 +45,6 @@ } ], "createTime": "2016-11-18T23:01:19Z", - "parentDeviceId": "074fa784-8be8-4c70-8e22-6f5ed6f81b7e", "profile": { "id": "51b76691-3c3a-3fce-8c7c-4f9d50e5885a" }, diff --git a/tests/components/smartthings/snapshots/test_init.ambr b/tests/components/smartthings/snapshots/test_init.ambr index 0b5aeb57c18..f3ed12a9a9a 100644 --- a/tests/components/smartthings/snapshots/test_init.ambr +++ b/tests/components/smartthings/snapshots/test_init.ambr @@ -1022,3 +1022,36 @@ 'via_device_id': None, }) # --- +# name: test_hub_via_device + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '074fa784-8be8-4c70-8e22-6f5ed6f81b7e', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': None, + 'model': None, + 'model_id': None, + 'name': 'Home Hub', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- diff --git a/tests/components/smartthings/test_init.py b/tests/components/smartthings/test_init.py index 372f23eec42..3ffe2c11a42 100644 --- a/tests/components/smartthings/test_init.py +++ b/tests/components/smartthings/test_init.py @@ -2,6 +2,7 @@ from unittest.mock import AsyncMock +from pysmartthings import DeviceResponse, DeviceStatus import pytest from syrupy import SnapshotAssertion @@ -11,7 +12,7 @@ from homeassistant.helpers import device_registry as dr from . import setup_integration -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, load_fixture async def test_devices( @@ -50,3 +51,34 @@ async def test_removing_stale_devices( await hass.async_block_till_done() assert not device_registry.async_get_device({(DOMAIN, "aaa-bbb-ccc")}) + + +async def test_hub_via_device( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, + mock_smartthings: AsyncMock, +) -> None: + """Test hub with child devices.""" + mock_smartthings.get_devices.return_value = DeviceResponse.from_json( + load_fixture("devices/hub.json", DOMAIN) + ).items + mock_smartthings.get_device_status.side_effect = [ + DeviceStatus.from_json( + load_fixture(f"device_status/{fixture}.json", DOMAIN) + ).components + for fixture in ("hub", "multipurpose_sensor") + ] + await setup_integration(hass, mock_config_entry) + + hub_device = device_registry.async_get_device( + {(DOMAIN, "074fa784-8be8-4c70-8e22-6f5ed6f81b7e")} + ) + assert hub_device == snapshot + assert ( + device_registry.async_get_device( + {(DOMAIN, "374ba6fa-5a08-4ea2-969c-1fa43d86e21f")} + ).via_device_id + == hub_device.id + ) From b43a7ff1fe5ea8d4f8099f6011e422ee58510d13 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 28 Feb 2025 22:01:39 +0000 Subject: [PATCH 1989/3148] Stream the TTS result from webview (#139543) --- homeassistant/components/tts/__init__.py | 36 ++++++++++++++++++------ 1 file changed, 27 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/tts/__init__.py b/homeassistant/components/tts/__init__.py index 5b2da44eae2..32c4ba20670 100644 --- a/homeassistant/components/tts/__init__.py +++ b/homeassistant/components/tts/__init__.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +from collections.abc import AsyncGenerator from dataclasses import dataclass from datetime import datetime import hashlib @@ -379,7 +380,7 @@ class ResultStream: """Class that will stream the result when available.""" # Streaming/conversion properties - url: str + token: str extension: str content_type: str @@ -391,6 +392,11 @@ class ResultStream: _manager: SpeechManager + @cached_property + def url(self) -> str: + """Get the URL to stream the result.""" + return f"/api/tts_proxy/{self.token}" + @cached_property def _result_cache_key(self) -> asyncio.Future[str]: """Get the future that returns the cache key.""" @@ -401,11 +407,11 @@ class ResultStream: """Set cache key for message to be streamed.""" self._result_cache_key.set_result(cache_key) - async def async_get_result(self) -> bytes: + async def async_stream_result(self) -> AsyncGenerator[bytes]: """Get the stream of this result.""" cache_key = await self._result_cache_key _extension, data = await self._manager.async_get_tts_audio(cache_key) - return data + yield data def _hash_options(options: dict) -> str: @@ -603,7 +609,7 @@ class SpeechManager: token = f"{secrets.token_urlsafe(16)}.{extension}" content, _ = mimetypes.guess_type(token) result_stream = ResultStream( - url=f"/api/tts_proxy/{token}", + token=token, extension=extension, content_type=content or "audio/mpeg", use_file_cache=use_file_cache, @@ -1027,20 +1033,32 @@ class TextToSpeechView(HomeAssistantView): """Initialize a tts view.""" self.manager = manager - async def get(self, request: web.Request, token: str) -> web.Response: + async def get(self, request: web.Request, token: str) -> web.StreamResponse: """Start a get request.""" stream = self.manager.token_to_stream.get(token) if stream is None: return web.Response(status=HTTPStatus.NOT_FOUND) + response: web.StreamResponse | None = None try: - data = await stream.async_get_result() - except HomeAssistantError as err: - _LOGGER.error("Error on get tts: %s", err) + async for data in stream.async_stream_result(): + if response is None: + response = web.StreamResponse() + response.content_type = stream.content_type + await response.prepare(request) + + await response.write(data) + # pylint: disable=broad-except + except Exception as err: # noqa: BLE001 + _LOGGER.error("Error streaming tts: %s", err) + + # Empty result or exception happened + if response is None: return web.Response(status=HTTPStatus.INTERNAL_SERVER_ERROR) - return web.Response(body=data, content_type=stream.content_type) + await response.write_eof() + return response @websocket_api.websocket_command( From b1ee019e3a99a4ecee17b3edf8bbedfcbd794683 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 28 Feb 2025 23:02:06 +0100 Subject: [PATCH 1990/3148] Bump pysmartthings to 2.3.0 (#139546) --- homeassistant/components/smartthings/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/smartthings/manifest.json b/homeassistant/components/smartthings/manifest.json index 0ca6c1f3b26..9fa6d28fa0a 100644 --- a/homeassistant/components/smartthings/manifest.json +++ b/homeassistant/components/smartthings/manifest.json @@ -29,5 +29,5 @@ "documentation": "https://www.home-assistant.io/integrations/smartthings", "iot_class": "cloud_push", "loggers": ["pysmartthings"], - "requirements": ["pysmartthings==2.2.0"] + "requirements": ["pysmartthings==2.3.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 12fa6c7c7df..bc33b7f17c7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2310,7 +2310,7 @@ pysma==0.7.5 pysmappee==0.2.29 # homeassistant.components.smartthings -pysmartthings==2.2.0 +pysmartthings==2.3.0 # homeassistant.components.smarty pysmarty2==0.10.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d11597c908c..a4620bc21de 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1882,7 +1882,7 @@ pysma==0.7.5 pysmappee==0.2.29 # homeassistant.components.smartthings -pysmartthings==2.2.0 +pysmartthings==2.3.0 # homeassistant.components.smarty pysmarty2==0.10.2 From db05aa17d3675d82fc6fc28bcc442d114beb24ef Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 28 Feb 2025 23:03:57 +0100 Subject: [PATCH 1991/3148] Add SmartThings Viper device info (#139548) --- .../components/smartthings/entity.py | 9 ++++ .../smartthings/snapshots/test_init.ambr | 50 +++++++++---------- 2 files changed, 34 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/smartthings/entity.py b/homeassistant/components/smartthings/entity.py index 790f3672680..0240549740f 100644 --- a/homeassistant/components/smartthings/entity.py +++ b/homeassistant/components/smartthings/entity.py @@ -58,6 +58,15 @@ class SmartThingsEntity(Entity): "sw_version": ocf.firmware_version, } ) + if (viper := device.device.viper) is not None: + self._attr_device_info.update( + { + "manufacturer": viper.manufacturer_name, + "model": viper.model_name, + "hw_version": viper.hardware_version, + "sw_version": viper.software_version, + } + ) async def async_added_to_hass(self) -> None: """Subscribe to updates.""" diff --git a/tests/components/smartthings/snapshots/test_init.ambr b/tests/components/smartthings/snapshots/test_init.ambr index f3ed12a9a9a..7f0e5c17cf2 100644 --- a/tests/components/smartthings/snapshots/test_init.ambr +++ b/tests/components/smartthings/snapshots/test_init.ambr @@ -86,8 +86,8 @@ 'is_new': False, 'labels': set({ }), - 'manufacturer': None, - 'model': None, + 'manufacturer': 'Arlo', + 'model': 'VMC4041PB', 'model_id': None, 'name': '2nd Floor Hallway', 'name_by_user': None, @@ -108,7 +108,7 @@ }), 'disabled_by': None, 'entry_type': None, - 'hw_version': None, + 'hw_version': 'WoCurtain3-WoCurtain3', 'id': , 'identifiers': set({ tuple( @@ -119,8 +119,8 @@ 'is_new': False, 'labels': set({ }), - 'manufacturer': None, - 'model': None, + 'manufacturer': 'WonderLabs Company', + 'model': 'WoCurtain3', 'model_id': None, 'name': 'Curtain 1A', 'name_by_user': None, @@ -471,7 +471,7 @@ }), 'disabled_by': None, 'entry_type': None, - 'hw_version': None, + 'hw_version': '250206213001', 'id': , 'identifiers': set({ tuple( @@ -482,15 +482,15 @@ 'is_new': False, 'labels': set({ }), - 'manufacturer': None, - 'model': None, + 'manufacturer': 'ecobee', + 'model': 'aresSmart-ecobee3_remote_sensor', 'model_id': None, 'name': 'Child Bedroom', 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, 'suggested_area': None, - 'sw_version': None, + 'sw_version': '250206213001', 'via_device_id': None, }) # --- @@ -504,7 +504,7 @@ }), 'disabled_by': None, 'entry_type': None, - 'hw_version': None, + 'hw_version': '250206151734', 'id': , 'identifiers': set({ tuple( @@ -515,15 +515,15 @@ 'is_new': False, 'labels': set({ }), - 'manufacturer': None, - 'model': None, + 'manufacturer': 'ecobee', + 'model': 'aresSmart-thermostat', 'model_id': None, 'name': 'Main Floor', 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, 'suggested_area': None, - 'sw_version': None, + 'sw_version': '250206151734', 'via_device_id': None, }) # --- @@ -603,7 +603,7 @@ }), 'disabled_by': None, 'entry_type': None, - 'hw_version': None, + 'hw_version': 'LTG002', 'id': , 'identifiers': set({ tuple( @@ -614,15 +614,15 @@ 'is_new': False, 'labels': set({ }), - 'manufacturer': None, - 'model': None, + 'manufacturer': 'Signify Netherlands B.V.', + 'model': 'Hue ambiance spot', 'model_id': None, 'name': 'Bathroom spot', 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, 'suggested_area': None, - 'sw_version': None, + 'sw_version': '1.122.2', 'via_device_id': None, }) # --- @@ -636,7 +636,7 @@ }), 'disabled_by': None, 'entry_type': None, - 'hw_version': None, + 'hw_version': 'LCA001', 'id': , 'identifiers': set({ tuple( @@ -647,15 +647,15 @@ 'is_new': False, 'labels': set({ }), - 'manufacturer': None, - 'model': None, + 'manufacturer': 'Signify Netherlands B.V.', + 'model': 'Hue color lamp', 'model_id': None, 'name': 'Standing light', 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, 'suggested_area': None, - 'sw_version': None, + 'sw_version': '1.122.2', 'via_device_id': None, }) # --- @@ -735,7 +735,7 @@ }), 'disabled_by': None, 'entry_type': None, - 'hw_version': None, + 'hw_version': 'SKY40147', 'id': , 'identifiers': set({ tuple( @@ -746,15 +746,15 @@ 'is_new': False, 'labels': set({ }), - 'manufacturer': None, - 'model': None, + 'manufacturer': 'Sensibo', + 'model': 'skyplus', 'model_id': None, 'name': 'Office', 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, 'suggested_area': None, - 'sw_version': None, + 'sw_version': 'SKY40147', 'via_device_id': None, }) # --- From ee1fe2cae45f3b9524f48776182a7f397f4f8973 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 28 Feb 2025 22:17:44 +0000 Subject: [PATCH 1992/3148] Bump bleak-esphome to 2.9.0 (#139467) * Bump bleak-esphome to 2.9.0 changelog: https://github.com/Bluetooth-Devices/bleak-esphome/compare/v2.8.0...v2.9.0 * fixes --- .../components/eq3btsmart/manifest.json | 2 +- homeassistant/components/esphome/__init__.py | 6 +- homeassistant/components/esphome/const.py | 1 + .../components/esphome/diagnostics.py | 8 ++- homeassistant/components/esphome/manager.py | 29 +++++++-- .../components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/esphome/conftest.py | 15 +++++ tests/components/esphome/test_bluetooth.py | 20 +++--- tests/components/esphome/test_diagnostics.py | 7 ++- tests/components/esphome/test_manager.py | 63 +++++++++++++++++++ 12 files changed, 131 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/eq3btsmart/manifest.json b/homeassistant/components/eq3btsmart/manifest.json index 18dcbb5cb65..68781282d66 100644 --- a/homeassistant/components/eq3btsmart/manifest.json +++ b/homeassistant/components/eq3btsmart/manifest.json @@ -22,5 +22,5 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["eq3btsmart"], - "requirements": ["eq3btsmart==1.4.1", "bleak-esphome==2.8.0"] + "requirements": ["eq3btsmart==1.4.1", "bleak-esphome==2.9.0"] } diff --git a/homeassistant/components/esphome/__init__.py b/homeassistant/components/esphome/__init__.py index fee2531fa20..1e1a2763b59 100644 --- a/homeassistant/components/esphome/__init__.py +++ b/homeassistant/components/esphome/__init__.py @@ -16,7 +16,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType -from .const import CONF_NOISE_PSK, DATA_FFMPEG_PROXY, DOMAIN +from .const import CONF_BLUETOOTH_MAC_ADDRESS, CONF_NOISE_PSK, DATA_FFMPEG_PROXY, DOMAIN from .dashboard import async_setup as async_setup_dashboard from .domain_data import DomainData @@ -87,6 +87,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: ESPHomeConfigEntry) -> async def async_remove_entry(hass: HomeAssistant, entry: ESPHomeConfigEntry) -> None: """Remove an esphome config entry.""" - if mac_address := entry.unique_id: - async_remove_scanner(hass, mac_address.upper()) + if bluetooth_mac_address := entry.data.get(CONF_BLUETOOTH_MAC_ADDRESS): + async_remove_scanner(hass, bluetooth_mac_address.upper()) await DomainData.get(hass).get_or_create_store(hass, entry).async_remove() diff --git a/homeassistant/components/esphome/const.py b/homeassistant/components/esphome/const.py index eb5f03c4495..a31f5441dbb 100644 --- a/homeassistant/components/esphome/const.py +++ b/homeassistant/components/esphome/const.py @@ -8,6 +8,7 @@ CONF_ALLOW_SERVICE_CALLS = "allow_service_calls" CONF_SUBSCRIBE_LOGS = "subscribe_logs" CONF_DEVICE_NAME = "device_name" CONF_NOISE_PSK = "noise_psk" +CONF_BLUETOOTH_MAC_ADDRESS = "bluetooth_mac_address" DEFAULT_ALLOW_SERVICE_CALLS = True DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS = False diff --git a/homeassistant/components/esphome/diagnostics.py b/homeassistant/components/esphome/diagnostics.py index c68bd560791..0903e874a15 100644 --- a/homeassistant/components/esphome/diagnostics.py +++ b/homeassistant/components/esphome/diagnostics.py @@ -25,13 +25,17 @@ async def async_get_config_entry_diagnostics( diag["config"] = config_entry.as_dict() entry_data = config_entry.runtime_data + device_info = entry_data.device_info if (storage_data := await entry_data.store.async_load()) is not None: diag["storage_data"] = storage_data if ( - config_entry.unique_id - and (scanner := async_scanner_by_source(hass, config_entry.unique_id.upper())) + device_info + and ( + scanner_mac := device_info.bluetooth_mac_address or device_info.mac_address + ) + and (scanner := async_scanner_by_source(hass, scanner_mac.upper())) and (bluetooth_device := entry_data.bluetooth_device) ): diag["bluetooth"] = { diff --git a/homeassistant/components/esphome/manager.py b/homeassistant/components/esphome/manager.py index e32bb7d6ded..0a47fb66815 100644 --- a/homeassistant/components/esphome/manager.py +++ b/homeassistant/components/esphome/manager.py @@ -63,6 +63,7 @@ from homeassistant.util.async_ import create_eager_task from .bluetooth import async_connect_scanner from .const import ( CONF_ALLOW_SERVICE_CALLS, + CONF_BLUETOOTH_MAC_ADDRESS, CONF_DEVICE_NAME, CONF_SUBSCRIBE_LOGS, DEFAULT_ALLOW_SERVICE_CALLS, @@ -431,6 +432,13 @@ class ESPHomeManager: device_mac = format_mac(device_info.mac_address) mac_address_matches = unique_id == device_mac + if ( + bluetooth_mac_address := device_info.bluetooth_mac_address + ) and entry.data.get(CONF_BLUETOOTH_MAC_ADDRESS) != bluetooth_mac_address: + hass.config_entries.async_update_entry( + entry, + data={**entry.data, CONF_BLUETOOTH_MAC_ADDRESS: bluetooth_mac_address}, + ) # # Migrate config entry to new unique ID if the current # unique id is not a mac address. @@ -498,7 +506,9 @@ class ESPHomeManager: ) ) else: - bluetooth.async_remove_scanner(hass, device_info.mac_address) + bluetooth.async_remove_scanner( + hass, device_info.bluetooth_mac_address or device_info.mac_address + ) if device_info.voice_assistant_feature_flags_compat(api_version) and ( Platform.ASSIST_SATELLITE not in entry_data.loaded_platforms @@ -617,11 +627,22 @@ class ESPHomeManager: ) _setup_services(hass, entry_data, services) - if entry_data.device_info is not None and entry_data.device_info.name: - reconnect_logic.name = entry_data.device_info.name + if (device_info := entry_data.device_info) is not None: + if device_info.name: + reconnect_logic.name = device_info.name + if ( + bluetooth_mac_address := device_info.bluetooth_mac_address + ) and entry.data.get(CONF_BLUETOOTH_MAC_ADDRESS) != bluetooth_mac_address: + hass.config_entries.async_update_entry( + entry, + data={ + **entry.data, + CONF_BLUETOOTH_MAC_ADDRESS: bluetooth_mac_address, + }, + ) if entry.unique_id is None: hass.config_entries.async_update_entry( - entry, unique_id=format_mac(entry_data.device_info.mac_address) + entry, unique_id=format_mac(device_info.mac_address) ) await reconnect_logic.start() diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index b4360077604..b97878d11b5 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -18,7 +18,7 @@ "requirements": [ "aioesphomeapi==29.3.1", "esphome-dashboard-api==1.2.3", - "bleak-esphome==2.8.0" + "bleak-esphome==2.9.0" ], "zeroconf": ["_esphomelib._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index bc33b7f17c7..8c64934cc45 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -603,7 +603,7 @@ bizkaibus==0.1.1 # homeassistant.components.eq3btsmart # homeassistant.components.esphome -bleak-esphome==2.8.0 +bleak-esphome==2.9.0 # homeassistant.components.bluetooth bleak-retry-connector==3.9.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a4620bc21de..0a45a3c4015 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -534,7 +534,7 @@ bimmer-connected[china]==0.17.2 # homeassistant.components.eq3btsmart # homeassistant.components.esphome -bleak-esphome==2.8.0 +bleak-esphome==2.9.0 # homeassistant.components.bluetooth bleak-retry-connector==3.9.0 diff --git a/tests/components/esphome/conftest.py b/tests/components/esphome/conftest.py index 94f621b8646..2786ed8324c 100644 --- a/tests/components/esphome/conftest.py +++ b/tests/components/esphome/conftest.py @@ -30,6 +30,7 @@ from zeroconf import Zeroconf from homeassistant.components.esphome import dashboard from homeassistant.components.esphome.const import ( CONF_ALLOW_SERVICE_CALLS, + CONF_BLUETOOTH_MAC_ADDRESS, CONF_DEVICE_NAME, CONF_NOISE_PSK, DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS, @@ -578,6 +579,19 @@ async def mock_bluetooth_entry( async def _mock_bluetooth_entry( bluetooth_proxy_feature_flags: BluetoothProxyFeature, ) -> MockESPHomeDevice: + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "test.local", + CONF_PORT: 6053, + CONF_PASSWORD: "", + CONF_BLUETOOTH_MAC_ADDRESS: "AA:BB:CC:DD:EE:FC", + }, + options={ + CONF_ALLOW_SERVICE_CALLS: DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS + }, + ) + entry.add_to_hass(hass) return await _mock_generic_device_entry( hass, mock_client, @@ -587,6 +601,7 @@ async def mock_bluetooth_entry( }, ([], []), [], + entry=entry, ) return _mock_bluetooth_entry diff --git a/tests/components/esphome/test_bluetooth.py b/tests/components/esphome/test_bluetooth.py index 19bc5a2e7c7..dd7a8f59fe5 100644 --- a/tests/components/esphome/test_bluetooth.py +++ b/tests/components/esphome/test_bluetooth.py @@ -13,7 +13,7 @@ async def test_bluetooth_connect_with_raw_adv( hass: HomeAssistant, mock_bluetooth_entry_with_raw_adv: MockESPHomeDevice ) -> None: """Test bluetooth connect with raw advertisements.""" - scanner = bluetooth.async_scanner_by_source(hass, "11:22:33:44:55:AA") + scanner = bluetooth.async_scanner_by_source(hass, "AA:BB:CC:DD:EE:FC") assert scanner is not None assert scanner.connectable is True assert scanner.scanning is True @@ -21,11 +21,11 @@ async def test_bluetooth_connect_with_raw_adv( await mock_bluetooth_entry_with_raw_adv.mock_disconnect(True) await hass.async_block_till_done() - scanner = bluetooth.async_scanner_by_source(hass, "11:22:33:44:55:AA") + scanner = bluetooth.async_scanner_by_source(hass, "AA:BB:CC:DD:EE:FC") assert scanner is None await mock_bluetooth_entry_with_raw_adv.mock_connect() await hass.async_block_till_done() - scanner = bluetooth.async_scanner_by_source(hass, "11:22:33:44:55:AA") + scanner = bluetooth.async_scanner_by_source(hass, "AA:BB:CC:DD:EE:FC") assert scanner.scanning is True @@ -33,7 +33,7 @@ async def test_bluetooth_connect_with_legacy_adv( hass: HomeAssistant, mock_bluetooth_entry_with_legacy_adv: MockESPHomeDevice ) -> None: """Test bluetooth connect with legacy advertisements.""" - scanner = bluetooth.async_scanner_by_source(hass, "11:22:33:44:55:AA") + scanner = bluetooth.async_scanner_by_source(hass, "AA:BB:CC:DD:EE:FC") assert scanner is not None assert scanner.connectable is True assert scanner.scanning is True @@ -41,11 +41,11 @@ async def test_bluetooth_connect_with_legacy_adv( await mock_bluetooth_entry_with_legacy_adv.mock_disconnect(True) await hass.async_block_till_done() - scanner = bluetooth.async_scanner_by_source(hass, "11:22:33:44:55:AA") + scanner = bluetooth.async_scanner_by_source(hass, "AA:BB:CC:DD:EE:FC") assert scanner is None await mock_bluetooth_entry_with_legacy_adv.mock_connect() await hass.async_block_till_done() - scanner = bluetooth.async_scanner_by_source(hass, "11:22:33:44:55:AA") + scanner = bluetooth.async_scanner_by_source(hass, "AA:BB:CC:DD:EE:FC") assert scanner.scanning is True @@ -55,10 +55,10 @@ async def test_bluetooth_device_linked_via_device( device_registry: dr.DeviceRegistry, ) -> None: """Test the Bluetooth device is linked to the ESPHome device.""" - scanner = bluetooth.async_scanner_by_source(hass, "11:22:33:44:55:AA") + scanner = bluetooth.async_scanner_by_source(hass, "AA:BB:CC:DD:EE:FC") assert scanner.connectable is True entry = hass.config_entries.async_entry_for_domain_unique_id( - "bluetooth", "11:22:33:44:55:AA" + "bluetooth", "AA:BB:CC:DD:EE:FC" ) assert entry is not None esp_device = device_registry.async_get_device( @@ -71,7 +71,7 @@ async def test_bluetooth_device_linked_via_device( ) assert esp_device is not None device = device_registry.async_get_device( - connections={(dr.CONNECTION_BLUETOOTH, "11:22:33:44:55:AA")} + connections={(dr.CONNECTION_BLUETOOTH, "AA:BB:CC:DD:EE:FC")} ) assert device is not None assert device.via_device_id == esp_device.id @@ -81,7 +81,7 @@ async def test_bluetooth_cleanup_on_remove_entry( hass: HomeAssistant, mock_bluetooth_entry_with_raw_adv: MockESPHomeDevice ) -> None: """Test bluetooth is cleaned up on entry removal.""" - scanner = bluetooth.async_scanner_by_source(hass, "11:22:33:44:55:AA") + scanner = bluetooth.async_scanner_by_source(hass, "AA:BB:CC:DD:EE:FC") assert scanner.connectable is True await hass.config_entries.async_unload( mock_bluetooth_entry_with_raw_adv.entry.entry_id diff --git a/tests/components/esphome/test_diagnostics.py b/tests/components/esphome/test_diagnostics.py index a4b858ed7de..2d64170bc97 100644 --- a/tests/components/esphome/test_diagnostics.py +++ b/tests/components/esphome/test_diagnostics.py @@ -37,7 +37,7 @@ async def test_diagnostics_with_bluetooth( mock_bluetooth_entry_with_raw_adv: MockESPHomeDevice, ) -> None: """Test diagnostics for config entry with Bluetooth.""" - scanner = bluetooth.async_scanner_by_source(hass, "11:22:33:44:55:AA") + scanner = bluetooth.async_scanner_by_source(hass, "AA:BB:CC:DD:EE:FC") assert scanner is not None assert scanner.connectable is True entry = mock_bluetooth_entry_with_raw_adv.entry @@ -55,9 +55,9 @@ async def test_diagnostics_with_bluetooth( "discovered_devices_and_advertisement_data": [], "last_detection": ANY, "monotonic_time": ANY, - "name": "test (11:22:33:44:55:AA)", + "name": "test (AA:BB:CC:DD:EE:FC)", "scanning": True, - "source": "11:22:33:44:55:AA", + "source": "AA:BB:CC:DD:EE:FC", "start_time": ANY, "time_since_last_device_detection": {}, "type": "ESPHomeScanner", @@ -66,6 +66,7 @@ async def test_diagnostics_with_bluetooth( "config": { "created_at": ANY, "data": { + "bluetooth_mac_address": "**REDACTED**", "device_name": "test", "host": "test.local", "password": "", diff --git a/tests/components/esphome/test_manager.py b/tests/components/esphome/test_manager.py index b805b065d5a..ddb1babd8a4 100644 --- a/tests/components/esphome/test_manager.py +++ b/tests/components/esphome/test_manager.py @@ -25,6 +25,7 @@ import pytest from homeassistant import config_entries from homeassistant.components.esphome.const import ( CONF_ALLOW_SERVICE_CALLS, + CONF_BLUETOOTH_MAC_ADDRESS, CONF_DEVICE_NAME, CONF_SUBSCRIBE_LOGS, DOMAIN, @@ -475,6 +476,39 @@ async def test_unique_id_updated_to_mac(hass: HomeAssistant, mock_client) -> Non assert entry.unique_id == "11:22:33:44:55:aa" +@pytest.mark.usefixtures("mock_zeroconf") +async def test_add_missing_bluetooth_mac_address( + hass: HomeAssistant, mock_client +) -> None: + """Test bluetooth mac is added if its missing.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "test.local", CONF_PORT: 6053, CONF_PASSWORD: ""}, + unique_id="mock-config-name", + ) + entry.add_to_hass(hass) + subscribe_done = hass.loop.create_future() + + def async_subscribe_states(*args, **kwargs) -> None: + subscribe_done.set_result(None) + + mock_client.subscribe_states = async_subscribe_states + mock_client.device_info = AsyncMock( + return_value=DeviceInfo( + mac_address="1122334455aa", + bluetooth_mac_address="AA:BB:CC:DD:EE:FF", + ) + ) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + async with asyncio.timeout(1): + await subscribe_done + + assert entry.unique_id == "11:22:33:44:55:aa" + assert entry.data.get(CONF_BLUETOOTH_MAC_ADDRESS) == "AA:BB:CC:DD:EE:FF" + + @pytest.mark.usefixtures("mock_zeroconf") async def test_unique_id_not_updated_if_name_same_and_already_mac( hass: HomeAssistant, mock_client: APIClient @@ -1337,3 +1371,32 @@ async def test_entry_missing_unique_id( await mock_esphome_device(mock_client=mock_client, mock_storage=True) await hass.async_block_till_done() assert entry.unique_id == "11:22:33:44:55:aa" + + +async def test_entry_missing_bluetooth_mac_address( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: Callable[ + [APIClient, list[EntityInfo], list[UserService], list[EntityState]], + Awaitable[MockESPHomeDevice], + ], +) -> None: + """Test the bluetooth_mac_address is added if available.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=None, + data={ + CONF_HOST: "test.local", + CONF_PORT: 6053, + CONF_PASSWORD: "", + }, + options={CONF_ALLOW_SERVICE_CALLS: True}, + ) + entry.add_to_hass(hass) + await mock_esphome_device( + mock_client=mock_client, + mock_storage=True, + device_info={"bluetooth_mac_address": "AA:BB:CC:DD:EE:FC"}, + ) + await hass.async_block_till_done() + assert entry.data[CONF_BLUETOOTH_MAC_ADDRESS] == "AA:BB:CC:DD:EE:FC" From 577b22374a0bb00839c1770278df5b7d96168e78 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 28 Feb 2025 22:25:50 +0000 Subject: [PATCH 1993/3148] Revert polling changes to HomeKit Controller (#139550) This reverts #116200 We changed the polling logic to avoid polling if all chars are marked as watchable to avoid crashing the firmware on a very limited set of devices as it was more in line with what iOS does. In the end, the user ended up replacing the device in #116143 because it turned out to be unreliable in other ways. The vendor has since issued a firmware update that may resolve the problem with all of these devices. In practice it turns out many more devices report that chars are evented and never send events. After a few months of data and reports the trade-off does not seem worth it since users are having to set up manual polling on a wide range of devices. The amount of devices with evented chars that do not actually send state vastly exceeds the number of devices that might crash if they are polled too often so restore the previous behavior fixes #138561 fixes #100331 fixes #124529 fixes #123456 fixes #130763 fixes #124099 fixes #124916 fixes #135434 fixes #125273 fixes #124099 fixes #119617 --- .../homekit_controller/connection.py | 38 ------------------- .../homekit_controller/test_connection.py | 10 ++--- 2 files changed, 5 insertions(+), 43 deletions(-) diff --git a/homeassistant/components/homekit_controller/connection.py b/homeassistant/components/homekit_controller/connection.py index 211aec2c2d5..43cbdec67fa 100644 --- a/homeassistant/components/homekit_controller/connection.py +++ b/homeassistant/components/homekit_controller/connection.py @@ -154,7 +154,6 @@ class HKDevice: self._pending_subscribes: set[tuple[int, int]] = set() self._subscribe_timer: CALLBACK_TYPE | None = None self._load_platforms_lock = asyncio.Lock() - self._full_update_requested: bool = False @property def entity_map(self) -> Accessories: @@ -841,48 +840,11 @@ class HKDevice: async def async_request_update(self, now: datetime | None = None) -> None: """Request an debounced update from the accessory.""" - self._full_update_requested = True await self._debounced_update.async_call() async def async_update(self, now: datetime | None = None) -> None: """Poll state of all entities attached to this bridge/accessory.""" to_poll = self.pollable_characteristics - accessories = self.entity_map.accessories - - if ( - not self._full_update_requested - and len(accessories) == 1 - and self.available - and not (to_poll - self.watchable_characteristics) - and self.pairing.is_available - and await self.pairing.controller.async_reachable( - self.unique_id, timeout=5.0 - ) - ): - # If its a single accessory and all chars are watchable, - # only poll the firmware version to keep the connection alive - # https://github.com/home-assistant/core/issues/123412 - # - # Firmware revision is used here since iOS does this to keep camera - # connections alive, and the goal is to not regress - # https://github.com/home-assistant/core/issues/116143 - # by polling characteristics that are not normally polled frequently - # and may not be tested by the device vendor. - # - _LOGGER.debug( - "Accessory is reachable, limiting poll to firmware version: %s", - self.unique_id, - ) - first_accessory = accessories[0] - accessory_info = first_accessory.services.first( - service_type=ServicesTypes.ACCESSORY_INFORMATION - ) - assert accessory_info is not None - firmware_iid = accessory_info[CharacteristicsTypes.FIRMWARE_REVISION].iid - to_poll = {(first_accessory.aid, firmware_iid)} - - self._full_update_requested = False - if not to_poll: self.async_update_available_state() _LOGGER.debug( diff --git a/tests/components/homekit_controller/test_connection.py b/tests/components/homekit_controller/test_connection.py index 7ea791f9a1e..00c7bb16259 100644 --- a/tests/components/homekit_controller/test_connection.py +++ b/tests/components/homekit_controller/test_connection.py @@ -375,9 +375,9 @@ async def test_poll_firmware_version_only_all_watchable_accessory_mode( state = await helper.poll_and_get_state() assert state.state == STATE_OFF assert mock_get_characteristics.call_count == 2 - # Verify only firmware version is polled - assert mock_get_characteristics.call_args_list[0][0][0] == {(1, 7)} - assert mock_get_characteristics.call_args_list[1][0][0] == {(1, 7)} + # Verify everything is polled + assert mock_get_characteristics.call_args_list[0][0][0] == {(1, 10), (1, 11)} + assert mock_get_characteristics.call_args_list[1][0][0] == {(1, 10), (1, 11)} # Test device goes offline helper.pairing.available = False @@ -429,8 +429,8 @@ async def test_manual_poll_all_chars( ) as mock_get_characteristics: # Initial state is that the light is off await helper.poll_and_get_state() - # Verify only firmware version is polled - assert mock_get_characteristics.call_args_list[0][0][0] == {(1, 7)} + # Verify poll polls all chars + assert len(mock_get_characteristics.call_args_list[0][0][0]) > 1 # Now do a manual poll to ensure all chars are polled mock_get_characteristics.reset_mock() From d6750624ce020bd6a6ba2429cb4ecc3817db2f0a Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 28 Feb 2025 23:32:09 +0100 Subject: [PATCH 1994/3148] Add SmartThings hub connections (#139549) --- homeassistant/components/smartthings/__init__.py | 6 ++++++ tests/components/smartthings/snapshots/test_init.ambr | 8 ++++++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py index 2bacd476332..f3a95e57831 100644 --- a/homeassistant/components/smartthings/__init__.py +++ b/homeassistant/components/smartthings/__init__.py @@ -103,10 +103,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: SmartThingsConfigEntry) for dev in device_status.values(): for component in dev.device.components: if component.id == MAIN and Capability.BRIDGE in component.capabilities: + assert dev.device.hub device_registry.async_get_or_create( config_entry_id=entry.entry_id, identifiers={(DOMAIN, dev.device.device_id)}, + connections={ + (dr.CONNECTION_NETWORK_MAC, dev.device.hub.mac_address) + }, name=dev.device.label, + sw_version=dev.device.hub.firmware_version, + model=dev.device.hub.hardware_type, ) scenes = { scene.scene_id: scene diff --git a/tests/components/smartthings/snapshots/test_init.ambr b/tests/components/smartthings/snapshots/test_init.ambr index 7f0e5c17cf2..18bc802e2bc 100644 --- a/tests/components/smartthings/snapshots/test_init.ambr +++ b/tests/components/smartthings/snapshots/test_init.ambr @@ -1029,6 +1029,10 @@ 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ + tuple( + 'mac', + 'd0:52:a8:72:91:02', + ), }), 'disabled_by': None, 'entry_type': None, @@ -1044,14 +1048,14 @@ 'labels': set({ }), 'manufacturer': None, - 'model': None, + 'model': 'V2_HUB', 'model_id': None, 'name': 'Home Hub', 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, 'suggested_area': None, - 'sw_version': None, + 'sw_version': '000.055.00005', 'via_device_id': None, }) # --- From ebd6daa31d0655b6269c6345e7148f830af9772b Mon Sep 17 00:00:00 2001 From: andylittle Date: Fri, 28 Feb 2025 14:47:40 -0800 Subject: [PATCH 1995/3148] Tuya tyd fix (#135558) Add support for tuya tyd light --- homeassistant/components/tuya/light.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/homeassistant/components/tuya/light.py b/homeassistant/components/tuya/light.py index d94308ebd33..67a94c4e267 100644 --- a/homeassistant/components/tuya/light.py +++ b/homeassistant/components/tuya/light.py @@ -327,6 +327,18 @@ LIGHTS: dict[str, tuple[TuyaLightEntityDescription, ...]] = { brightness_min=DPCode.BRIGHTNESS_MIN_1, ), ), + # Outdoor Flood Light + # Not documented + "tyd": ( + TuyaLightEntityDescription( + key=DPCode.SWITCH_LED, + name=None, + color_mode=DPCode.WORK_MODE, + brightness=DPCode.BRIGHT_VALUE, + color_temp=DPCode.TEMP_VALUE, + color_data=DPCode.COLOUR_DATA, + ), + ), # Solar Light # https://developer.tuya.com/en/docs/iot/tynd?id=Kaof8j02e1t98 "tyndj": ( From 615d79b429ad814d5597567b70b95694d5f4d25f Mon Sep 17 00:00:00 2001 From: LaithBudairi <69572447+LaithBudairi@users.noreply.github.com> Date: Sat, 1 Mar 2025 00:58:39 +0200 Subject: [PATCH 1996/3148] Add missing 'state_class' attribute for Growatt plant sensors (#132145) * Add missing 'state_class' attribute for Growatt plant sensors * Update total.py * Update total.py 'TOTAL_INCREASING' * Update total.py "maximum_output" -> 'TOTAL_INCREASING' * Update homeassistant/components/growatt_server/sensor/total.py --------- Co-authored-by: Franck Nijhof --- homeassistant/components/growatt_server/sensor/total.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/growatt_server/sensor/total.py b/homeassistant/components/growatt_server/sensor/total.py index 8111728d1e9..578745c8610 100644 --- a/homeassistant/components/growatt_server/sensor/total.py +++ b/homeassistant/components/growatt_server/sensor/total.py @@ -26,6 +26,7 @@ TOTAL_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( api_key="todayEnergy", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, ), GrowattSensorEntityDescription( key="total_output_power", @@ -33,6 +34,7 @@ TOTAL_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( api_key="invTodayPpv", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, ), GrowattSensorEntityDescription( key="total_energy_output", From 1dc6a94093e62d8e2a9a5c6301dd421db13398db Mon Sep 17 00:00:00 2001 From: Daniele Ricci Date: Sat, 1 Mar 2025 06:15:28 +0100 Subject: [PATCH 1997/3148] Fix caldav todo list not updating after adding items with Assist (#135980) caldav: fix todo list not updating after adding items with Assist --- homeassistant/components/caldav/todo.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/homeassistant/components/caldav/todo.py b/homeassistant/components/caldav/todo.py index fada4693cf0..73f172dabec 100644 --- a/homeassistant/components/caldav/todo.py +++ b/homeassistant/components/caldav/todo.py @@ -138,6 +138,8 @@ class WebDavTodoListEntity(TodoListEntity): await self.hass.async_add_executor_job( partial(self._calendar.save_todo, **item_data), ) + # refreshing async otherwise it would take too much time + self.hass.async_create_task(self.async_update_ha_state(force_refresh=True)) except (requests.ConnectionError, DAVError) as err: raise HomeAssistantError(f"CalDAV save error: {err}") from err @@ -172,6 +174,8 @@ class WebDavTodoListEntity(TodoListEntity): obj_type="todo", ), ) + # refreshing async otherwise it would take too much time + self.hass.async_create_task(self.async_update_ha_state(force_refresh=True)) except (requests.ConnectionError, DAVError) as err: raise HomeAssistantError(f"CalDAV save error: {err}") from err @@ -195,3 +199,5 @@ class WebDavTodoListEntity(TodoListEntity): await self.hass.async_add_executor_job(item.delete) except (requests.ConnectionError, DAVError) as err: raise HomeAssistantError(f"CalDAV delete error: {err}") from err + # refreshing async otherwise it would take too much time + self.hass.async_create_task(self.async_update_ha_state(force_refresh=True)) From 8e7960fa0ebe4125f3cf03d05029744d4cb04613 Mon Sep 17 00:00:00 2001 From: Juan Grande Date: Sat, 1 Mar 2025 00:10:35 -0800 Subject: [PATCH 1998/3148] Fix bug in derivative sensor when source sensor's state is constant (#139230) Previously, when the source sensor's state remains constant, the derivative sensor repeats its latest value indefinitely. This patch fixes this bug by consuming the state_reported event and updating the sensor's output even when the source sensor doesn't change its state. --- homeassistant/components/derivative/sensor.py | 66 ++++++++--- tests/components/derivative/test_sensor.py | 111 ++++++++++++++++-- 2 files changed, 150 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/derivative/sensor.py b/homeassistant/components/derivative/sensor.py index 90f8a95919d..f6c2b45ef9c 100644 --- a/homeassistant/components/derivative/sensor.py +++ b/homeassistant/components/derivative/sensor.py @@ -24,7 +24,14 @@ from homeassistant.const import ( STATE_UNKNOWN, UnitOfTime, ) -from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback +from homeassistant.core import ( + Event, + EventStateChangedData, + EventStateReportedData, + HomeAssistant, + State, + callback, +) from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.device import async_device_info_to_link_from_entity from homeassistant.helpers.device_registry import DeviceInfo @@ -32,7 +39,10 @@ from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, ) -from homeassistant.helpers.event import async_track_state_change_event +from homeassistant.helpers.event import ( + async_track_state_change_event, + async_track_state_report_event, +) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import ( @@ -200,13 +210,33 @@ class DerivativeSensor(RestoreSensor, SensorEntity): _LOGGER.warning("Could not restore last state: %s", err) @callback - def calc_derivative(event: Event[EventStateChangedData]) -> None: + def on_state_reported(event: Event[EventStateReportedData]) -> None: + """Handle constant sensor state.""" + if self._attr_native_value == Decimal(0): + # If the derivative is zero, and the source sensor hasn't + # changed state, then we know it will still be zero. + return + new_state = event.data["new_state"] + if new_state is not None: + calc_derivative( + new_state, new_state.state, event.data["old_last_reported"] + ) + + @callback + def on_state_changed(event: Event[EventStateChangedData]) -> None: + """Handle changed sensor state.""" + new_state = event.data["new_state"] + old_state = event.data["old_state"] + if new_state is not None and old_state is not None: + calc_derivative(new_state, old_state.state, old_state.last_reported) + + def calc_derivative( + new_state: State, old_value: str, old_last_reported: datetime + ) -> None: """Handle the sensor state changes.""" - if ( - (old_state := event.data["old_state"]) is None - or old_state.state in (STATE_UNKNOWN, STATE_UNAVAILABLE) - or (new_state := event.data["new_state"]) is None - or new_state.state in (STATE_UNKNOWN, STATE_UNAVAILABLE) + if old_value in (STATE_UNKNOWN, STATE_UNAVAILABLE) or new_state.state in ( + STATE_UNKNOWN, + STATE_UNAVAILABLE, ): return @@ -220,15 +250,15 @@ class DerivativeSensor(RestoreSensor, SensorEntity): self._state_list = [ (time_start, time_end, state) for time_start, time_end, state in self._state_list - if (new_state.last_updated - time_end).total_seconds() + if (new_state.last_reported - time_end).total_seconds() < self._time_window ] try: elapsed_time = ( - new_state.last_updated - old_state.last_updated + new_state.last_reported - old_last_reported ).total_seconds() - delta_value = Decimal(new_state.state) - Decimal(old_state.state) + delta_value = Decimal(new_state.state) - Decimal(old_value) new_derivative = ( delta_value / Decimal(elapsed_time) @@ -240,7 +270,7 @@ class DerivativeSensor(RestoreSensor, SensorEntity): _LOGGER.warning("While calculating derivative: %s", err) except DecimalException as err: _LOGGER.warning( - "Invalid state (%s > %s): %s", old_state.state, new_state.state, err + "Invalid state (%s > %s): %s", old_value, new_state.state, err ) except AssertionError as err: _LOGGER.error("Could not calculate derivative: %s", err) @@ -257,7 +287,7 @@ class DerivativeSensor(RestoreSensor, SensorEntity): # add latest derivative to the window list self._state_list.append( - (old_state.last_updated, new_state.last_updated, new_derivative) + (old_last_reported, new_state.last_reported, new_derivative) ) def calculate_weight( @@ -277,13 +307,19 @@ class DerivativeSensor(RestoreSensor, SensorEntity): else: derivative = Decimal("0.00") for start, end, value in self._state_list: - weight = calculate_weight(start, end, new_state.last_updated) + weight = calculate_weight(start, end, new_state.last_reported) derivative = derivative + (value * Decimal(weight)) self._attr_native_value = round(derivative, self._round_digits) self.async_write_ha_state() self.async_on_remove( async_track_state_change_event( - self.hass, self._sensor_source_id, calc_derivative + self.hass, self._sensor_source_id, on_state_changed + ) + ) + + self.async_on_remove( + async_track_state_report_event( + self.hass, self._sensor_source_id, on_state_reported ) ) diff --git a/tests/components/derivative/test_sensor.py b/tests/components/derivative/test_sensor.py index a543de974f1..f8d88066f16 100644 --- a/tests/components/derivative/test_sensor.py +++ b/tests/components/derivative/test_sensor.py @@ -39,7 +39,7 @@ async def test_state(hass: HomeAssistant) -> None: await hass.async_block_till_done() freezer.move_to(dt_util.utcnow() + timedelta(seconds=3600)) - hass.states.async_set(entity_id, 1, {}, force_update=True) + hass.states.async_set(entity_id, 1, {}) await hass.async_block_till_done() state = hass.states.get("sensor.derivative") @@ -51,6 +51,49 @@ async def test_state(hass: HomeAssistant) -> None: assert state.attributes.get("unit_of_measurement") == "kW" +async def test_no_change(hass: HomeAssistant) -> None: + """Test derivative sensor state updated when source sensor doesn't change.""" + config = { + "sensor": { + "platform": "derivative", + "name": "derivative", + "source": "sensor.energy", + "unit": "kW", + "round": 2, + } + } + + assert await async_setup_component(hass, "sensor", config) + + entity_id = config["sensor"]["source"] + base = dt_util.utcnow() + with freeze_time(base) as freezer: + hass.states.async_set(entity_id, 0, {}) + await hass.async_block_till_done() + + freezer.move_to(dt_util.utcnow() + timedelta(seconds=3600)) + hass.states.async_set(entity_id, 1, {}) + await hass.async_block_till_done() + + freezer.move_to(dt_util.utcnow() + timedelta(seconds=3600)) + hass.states.async_set(entity_id, 1, {}) + await hass.async_block_till_done() + + freezer.move_to(dt_util.utcnow() + timedelta(seconds=3600)) + hass.states.async_set(entity_id, 1, {}) + await hass.async_block_till_done() + + state = hass.states.get("sensor.derivative") + assert state is not None + + # Testing a energy sensor at 1 kWh for 1hour = 0kW + assert round(float(state.state), config["sensor"]["round"]) == 0.0 + + assert state.attributes.get("unit_of_measurement") == "kW" + + assert state.last_changed == base + timedelta(seconds=2 * 3600) + + async def _setup_sensor( hass: HomeAssistant, config: dict[str, Any] ) -> tuple[dict[str, Any], str]: @@ -86,7 +129,7 @@ async def setup_tests( with freeze_time(base) as freezer: for time, value in zip(times, values, strict=False): freezer.move_to(base + timedelta(seconds=time)) - hass.states.async_set(entity_id, value, {}, force_update=True) + hass.states.async_set(entity_id, value, {}) await hass.async_block_till_done() state = hass.states.get("sensor.power") @@ -159,6 +202,53 @@ async def test_dataSet6(hass: HomeAssistant) -> None: await setup_tests(hass, {}, times=[0, 60], values=[0, 1 / 60], expected_state=1) +async def test_data_moving_average_with_zeroes(hass: HomeAssistant) -> None: + """Test that zeroes are properly handled within the time window.""" + # We simulate the following situation: + # The temperature rises 1 °C per minute for 10 minutes long. Then, it + # stays constant for another 10 minutes. There is a data point every + # minute and we use a time window of 10 minutes. + # Therefore, we can expect the derivative to peak at 1 after 10 minutes + # and then fall down to 0 in steps of 10%. + + temperature_values = [] + for temperature in range(10): + temperature_values += [temperature] + temperature_values += [10] * 11 + time_window = 600 + times = list(range(0, 1200 + 60, 60)) + + config, entity_id = await _setup_sensor( + hass, + { + "time_window": {"seconds": time_window}, + "unit_time": UnitOfTime.MINUTES, + "round": 1, + }, + ) + + base = dt_util.utcnow() + with freeze_time(base) as freezer: + last_derivative = 0 + for time, value in zip(times, temperature_values, strict=True): + now = base + timedelta(seconds=time) + freezer.move_to(now) + hass.states.async_set(entity_id, value, {}) + await hass.async_block_till_done() + + state = hass.states.get("sensor.power") + derivative = round(float(state.state), config["sensor"]["round"]) + + if time_window == time: + assert derivative == 1.0 + elif time_window < time < time_window * 2: + assert (0.1 - 1e-6) < abs(derivative - last_derivative) < (0.1 + 1e-6) + elif time == time_window * 2: + assert derivative == 0 + + last_derivative = derivative + + async def test_data_moving_average_for_discrete_sensor(hass: HomeAssistant) -> None: """Test derivative sensor state.""" # We simulate the following situation: @@ -188,7 +278,7 @@ async def test_data_moving_average_for_discrete_sensor(hass: HomeAssistant) -> N for time, value in zip(times, temperature_values, strict=False): now = base + timedelta(seconds=time) freezer.move_to(now) - hass.states.async_set(entity_id, value, {}, force_update=True) + hass.states.async_set(entity_id, value, {}) await hass.async_block_till_done() if time_window < time < times[-1] - time_window: @@ -232,7 +322,7 @@ async def test_data_moving_average_for_irregular_times(hass: HomeAssistant) -> N for time, value in zip(times, temperature_values, strict=False): now = base + timedelta(seconds=time) freezer.move_to(now) - hass.states.async_set(entity_id, value, {}, force_update=True) + hass.states.async_set(entity_id, value, {}) await hass.async_block_till_done() if time_window < time and time > times[3]: @@ -270,7 +360,7 @@ async def test_double_signal_after_delay(hass: HomeAssistant) -> None: for time, value in zip(times, temperature_values, strict=False): now = base + timedelta(seconds=time) freezer.move_to(now) - hass.states.async_set(entity_id, value, {}, force_update=True) + hass.states.async_set(entity_id, value, {}) await hass.async_block_till_done() state = hass.states.get("sensor.power") derivative = round(float(state.state), config["sensor"]["round"]) @@ -302,24 +392,22 @@ async def test_prefix(hass: HomeAssistant) -> None: entity_id, 1000, {"unit_of_measurement": UnitOfPower.WATT}, - force_update=True, ) await hass.async_block_till_done() freezer.move_to(dt_util.utcnow() + timedelta(seconds=3600)) hass.states.async_set( entity_id, - 1000, + 2000, {"unit_of_measurement": UnitOfPower.WATT}, - force_update=True, ) await hass.async_block_till_done() state = hass.states.get("sensor.derivative") assert state is not None - # Testing a power sensor at 1000 Watts for 1hour = 0kW/h - assert round(float(state.state), config["sensor"]["round"]) == 0.0 + # Testing a power sensor increasing by 1000 Watts per hour = 1kW/h + assert round(float(state.state), config["sensor"]["round"]) == 1.0 assert state.attributes.get("unit_of_measurement") == f"kW/{UnitOfTime.HOURS}" @@ -345,7 +433,7 @@ async def test_suffix(hass: HomeAssistant) -> None: await hass.async_block_till_done() freezer.move_to(dt_util.utcnow() + timedelta(seconds=3600)) - hass.states.async_set(entity_id, 1000, {}, force_update=True) + hass.states.async_set(entity_id, 1000, {}) await hass.async_block_till_done() state = hass.states.get("sensor.derivative") @@ -375,7 +463,6 @@ async def test_total_increasing_reset(hass: HomeAssistant) -> None: entity_id, value, {ATTR_STATE_CLASS: SensorStateClass.TOTAL_INCREASING}, - force_update=True, ) await hass.async_block_till_done() From a6e2dc485bc2d951c4a37dd00b35ff7a84395127 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 1 Mar 2025 09:44:04 +0000 Subject: [PATCH 1999/3148] Bump orjson to 3.10.15 (#135223) --- 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 54401a12592..1f1cb3c4f4c 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -44,7 +44,7 @@ ifaddr==0.2.0 Jinja2==3.1.5 lru-dict==1.3.0 mutagen==1.47.0 -orjson==3.10.12 +orjson==3.10.15 packaging>=23.1 paho-mqtt==2.1.0 Pillow==11.1.0 diff --git a/pyproject.toml b/pyproject.toml index 5ee20b96bfc..6a75ffa002b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -60,7 +60,7 @@ dependencies = [ "Pillow==11.1.0", "propcache==0.3.0", "pyOpenSSL==25.0.0", - "orjson==3.10.12", + "orjson==3.10.15", "packaging>=23.1", "psutil-home-assistant==0.0.1", "python-slugify==8.0.4", diff --git a/requirements.txt b/requirements.txt index b378688106d..76c5059e29e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -32,7 +32,7 @@ cryptography==44.0.1 Pillow==11.1.0 propcache==0.3.0 pyOpenSSL==25.0.0 -orjson==3.10.12 +orjson==3.10.15 packaging>=23.1 psutil-home-assistant==0.0.1 python-slugify==8.0.4 From 18217a594f8c17aa89e75e657f42fc4828481741 Mon Sep 17 00:00:00 2001 From: Filip Agh Date: Sat, 1 Mar 2025 11:50:24 +0100 Subject: [PATCH 2000/3148] Fix update data for multiple Gree devices (#139469) fix sync date for multiple devices do not use handler for explicit update devices as internal communication lib do not provide which device is updated use ha update loop copy data object to prevent rewrite data from internal lib allow more time to process response before log warning about long wait for response and make log message more clear --- homeassistant/components/gree/const.py | 1 + homeassistant/components/gree/coordinator.py | 14 ++++++++++---- tests/components/gree/test_climate.py | 3 ++- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/gree/const.py b/homeassistant/components/gree/const.py index f926eb1c53e..14236f09fa2 100644 --- a/homeassistant/components/gree/const.py +++ b/homeassistant/components/gree/const.py @@ -20,3 +20,4 @@ MAX_ERRORS = 2 TARGET_TEMPERATURE_STEP = 1 UPDATE_INTERVAL = 60 +MAX_EXPECTED_RESPONSE_TIME_INTERVAL = UPDATE_INTERVAL * 2 diff --git a/homeassistant/components/gree/coordinator.py b/homeassistant/components/gree/coordinator.py index 0d1aa60deaa..c8b4e6cff54 100644 --- a/homeassistant/components/gree/coordinator.py +++ b/homeassistant/components/gree/coordinator.py @@ -2,6 +2,7 @@ from __future__ import annotations +import copy from datetime import datetime, timedelta import logging from typing import Any @@ -24,6 +25,7 @@ from .const import ( DISPATCH_DEVICE_DISCOVERED, DOMAIN, MAX_ERRORS, + MAX_EXPECTED_RESPONSE_TIME_INTERVAL, UPDATE_INTERVAL, ) @@ -48,7 +50,6 @@ class DeviceDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): always_update=False, ) self.device = device - self.device.add_handler(Response.DATA, self.device_state_updated) self.device.add_handler(Response.RESULT, self.device_state_updated) self._error_count: int = 0 @@ -88,7 +89,9 @@ class DeviceDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): # raise update failed if time for more than MAX_ERRORS has passed since last update now = utcnow() elapsed_success = now - self._last_response_time - if self.update_interval and elapsed_success >= self.update_interval: + if self.update_interval and elapsed_success >= timedelta( + seconds=MAX_EXPECTED_RESPONSE_TIME_INTERVAL + ): if not self._last_error_time or ( (now - self.update_interval) >= self._last_error_time ): @@ -96,16 +99,19 @@ class DeviceDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): self._error_count += 1 _LOGGER.warning( - "Device %s is unresponsive for %s seconds", + "Device %s took an unusually long time to respond, %s seconds", self.name, elapsed_success, ) + else: + self._error_count = 0 if self.last_update_success and self._error_count >= MAX_ERRORS: raise UpdateFailed( f"Device {self.name} is unresponsive for too long and now unavailable" ) - return self.device.raw_properties + self._last_response_time = utcnow() + return copy.deepcopy(self.device.raw_properties) async def push_state_update(self): """Send state updates to the physical device.""" diff --git a/tests/components/gree/test_climate.py b/tests/components/gree/test_climate.py index 0cb187f5a60..d7c011a4c25 100644 --- a/tests/components/gree/test_climate.py +++ b/tests/components/gree/test_climate.py @@ -52,6 +52,7 @@ from homeassistant.components.gree.const import ( DISCOVERY_SCAN_INTERVAL, FAN_MEDIUM_HIGH, FAN_MEDIUM_LOW, + MAX_EXPECTED_RESPONSE_TIME_INTERVAL, UPDATE_INTERVAL, ) from homeassistant.const import ( @@ -346,7 +347,7 @@ async def test_unresponsive_device( await async_setup_gree(hass) async def run_update(): - freezer.tick(timedelta(seconds=UPDATE_INTERVAL)) + freezer.tick(timedelta(seconds=MAX_EXPECTED_RESPONSE_TIME_INTERVAL)) async_fire_time_changed(hass) await hass.async_block_till_done() From 66a17bd072094f74383530b8286577b2fbb20187 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 1 Mar 2025 12:06:16 +0100 Subject: [PATCH 2001/3148] Bump pysmartthings to 2.4.0 (#139564) --- homeassistant/components/smartthings/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/smartthings/manifest.json b/homeassistant/components/smartthings/manifest.json index 9fa6d28fa0a..e0cf6739290 100644 --- a/homeassistant/components/smartthings/manifest.json +++ b/homeassistant/components/smartthings/manifest.json @@ -29,5 +29,5 @@ "documentation": "https://www.home-assistant.io/integrations/smartthings", "iot_class": "cloud_push", "loggers": ["pysmartthings"], - "requirements": ["pysmartthings==2.3.0"] + "requirements": ["pysmartthings==2.4.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 8c64934cc45..7df4c3f7b44 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2310,7 +2310,7 @@ pysma==0.7.5 pysmappee==0.2.29 # homeassistant.components.smartthings -pysmartthings==2.3.0 +pysmartthings==2.4.0 # homeassistant.components.smarty pysmarty2==0.10.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0a45a3c4015..48dc3bb48d4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1882,7 +1882,7 @@ pysma==0.7.5 pysmappee==0.2.29 # homeassistant.components.smartthings -pysmartthings==2.3.0 +pysmartthings==2.4.0 # homeassistant.components.smarty pysmarty2==0.10.2 From 2c620f1f6082c60aaf153c6f2380954c5c5a9093 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sat, 1 Mar 2025 12:12:36 +0100 Subject: [PATCH 2002/3148] Improve description of `door` field in `subaru.unlock_specific_door` action (#139558) * Improve description of `door` field in `subaru.unlock_specific_door` action In the UI the `door` field of the `subaru.unlock_specific_door` action presents three radio buttons for the three possible choices 'all', 'driver' and 'tailgate'. Therefore the field description should no longer repeat those options to avoid over-translation that will not match the actual choices. In addition proper sentence-casing is applied to several title keys. * Fix sentence-casing in two title keys --- homeassistant/components/subaru/strings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/subaru/strings.json b/homeassistant/components/subaru/strings.json index 00da729dccd..7525e73f802 100644 --- a/homeassistant/components/subaru/strings.json +++ b/homeassistant/components/subaru/strings.json @@ -2,7 +2,7 @@ "config": { "step": { "user": { - "title": "Subaru Starlink Configuration", + "title": "Subaru Starlink configuration", "description": "Please enter your MySubaru credentials\nNOTE: Initial setup may take up to 30 seconds", "data": { "username": "[%key:common::config_flow::data::username%]", @@ -49,7 +49,7 @@ "options": { "step": { "init": { - "title": "Subaru Starlink Options", + "title": "Subaru Starlink options", "description": "When enabled, vehicle polling will send a remote command to your vehicle every 2 hours to obtain new sensor data. Without vehicle polling, new sensor data is only received when the vehicle automatically pushes data (normally after engine shutdown).", "data": { "update_enabled": "Enable vehicle polling" @@ -106,7 +106,7 @@ "fields": { "door": { "name": "Door", - "description": "One of the following: 'all', 'driver', 'tailgate'." + "description": "Which door(s) to open." } } } From dfe19217371436a45761b08df4622fb3210ab9f8 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sat, 1 Mar 2025 12:12:58 +0100 Subject: [PATCH 2003/3148] Improve description of `media_content_type` in `media_extractor.play_media` action (#139559) * Improve `media_content_type` in `media_extractor.play_media` action In the UI the `media_content_type` field of the `media_extractor.play_media` action already presents a selector with the choices MUSIC, TVSHOW, VIDEO, EPISODE, CHANNEL or PLAYLIST MUSIC. Therefore these can be removed from the field description to avoid any over-translation that will create an unnecessary mismatch in the UI. * Fix casing of `media_extractor.play_media` action name --- homeassistant/components/media_extractor/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/media_extractor/strings.json b/homeassistant/components/media_extractor/strings.json index 125aa08337a..11b5a884e4d 100644 --- a/homeassistant/components/media_extractor/strings.json +++ b/homeassistant/components/media_extractor/strings.json @@ -17,12 +17,12 @@ }, "media_content_type": { "name": "Media content type", - "description": "The type of the content to play. Must be one of MUSIC, TVSHOW, VIDEO, EPISODE, CHANNEL or PLAYLIST MUSIC." + "description": "The type of the content to play." } } }, "extract_media_url": { - "name": "Get Media URL", + "name": "Get media URL", "description": "Extract media URL from a service.", "fields": { "url": { From 042e4d20c5f2fb45c623e1859e0fd67ca68f27c8 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Sat, 1 Mar 2025 12:37:44 +0100 Subject: [PATCH 2004/3148] Bump aiowebdav2 to 0.3.1 (#139567) --- homeassistant/components/webdav/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/webdav/manifest.json b/homeassistant/components/webdav/manifest.json index 75a8d7ddfe2..b4950bc23f3 100644 --- a/homeassistant/components/webdav/manifest.json +++ b/homeassistant/components/webdav/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["aiowebdav2"], "quality_scale": "bronze", - "requirements": ["aiowebdav2==0.3.0"] + "requirements": ["aiowebdav2==0.3.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 7df4c3f7b44..efa3da8d3d3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -422,7 +422,7 @@ aiowaqi==3.1.0 aiowatttime==0.1.1 # homeassistant.components.webdav -aiowebdav2==0.3.0 +aiowebdav2==0.3.1 # homeassistant.components.webostv aiowebostv==0.7.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 48dc3bb48d4..527d9f654dc 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -404,7 +404,7 @@ aiowaqi==3.1.0 aiowatttime==0.1.1 # homeassistant.components.webdav -aiowebdav2==0.3.0 +aiowebdav2==0.3.1 # homeassistant.components.webostv aiowebostv==0.7.1 From fe5cd5c55c89f5a4b1d56a7b4a59743907b10983 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 1 Mar 2025 12:47:58 +0100 Subject: [PATCH 2005/3148] Validate scopes in SmartThings config flow (#139569) --- .../components/smartthings/config_flow.py | 2 + .../components/smartthings/strings.json | 3 +- .../smartthings/test_config_flow.py | 111 ++++++++++++++++++ 3 files changed, 115 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/smartthings/config_flow.py b/homeassistant/components/smartthings/config_flow.py index bcd2ddc192b..b39fe662124 100644 --- a/homeassistant/components/smartthings/config_flow.py +++ b/homeassistant/components/smartthings/config_flow.py @@ -34,6 +34,8 @@ class SmartThingsConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN): async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResult: """Create an entry for SmartThings.""" + if data[CONF_TOKEN]["scope"].split() != SCOPES: + return self.async_abort(reason="missing_scopes") client = SmartThings(session=async_get_clientsession(self.hass)) client.authenticate(data[CONF_TOKEN][CONF_ACCESS_TOKEN]) locations = await client.get_locations() diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index e5ffbe35e8b..9fd417284af 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -23,7 +23,8 @@ "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "reauth_account_mismatch": "Authenticated account does not match the account to be reauthenticated. Please log in with the correct account and pick the right location.", - "reauth_location_mismatch": "Authenticated location does not match the location to be reauthenticated. Please log in with the correct account and pick the right location." + "reauth_location_mismatch": "Authenticated location does not match the location to be reauthenticated. Please log in with the correct account and pick the right location.", + "missing_scopes": "Authentication failed. Please make sure you have granted all required permissions." } }, "entity": { diff --git a/tests/components/smartthings/test_config_flow.py b/tests/components/smartthings/test_config_flow.py index 647e0ea5284..61e2b464920 100644 --- a/tests/components/smartthings/test_config_flow.py +++ b/tests/components/smartthings/test_config_flow.py @@ -101,6 +101,66 @@ async def test_full_flow( assert result["result"].unique_id == "397678e5-9995-4a39-9d9f-ae6ba310236c" +@pytest.mark.usefixtures("current_request_with_host") +async def test_not_enough_scopes( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_smartthings: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test we abort if we don't have enough scopes.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + assert result["type"] is FlowResultType.EXTERNAL_STEP + assert result["url"] == ( + "https://api.smartthings.com/oauth/authorize" + "?response_type=code&client_id=CLIENT_ID" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}" + "&scope=r:devices:*+w:devices:*+x:devices:*+r:hubs:*+" + "r:locations:*+w:locations:*+x:locations:*+r:scenes:*+" + "x:scenes:*+r:rules:*+w:rules:*+r:installedapps+w:installedapps+sse" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == HTTPStatus.OK + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.clear_requests() + aioclient_mock.post( + "https://auth-global.api.smartthings.com/oauth/token", + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "token_type": "Bearer", + "expires_in": 82806, + "scope": "r:devices:* w:devices:* x:devices:* r:hubs:* " + "r:locations:* w:locations:* x:locations:* " + "r:scenes:* x:scenes:* r:rules:* w:rules:* " + "r:installedapps w:installedapps", + "access_tier": 0, + "installed_app_id": "5aaaa925-2be1-4e40-b257-e4ef59083324", + }, + ) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "missing_scopes" + + @pytest.mark.usefixtures("current_request_with_host") async def test_duplicate_entry( hass: HomeAssistant, @@ -227,6 +287,57 @@ async def test_reauthentication( } +@pytest.mark.usefixtures("current_request_with_host") +async def test_reauthentication_wrong_scopes( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_smartthings: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test SmartThings reauthentication with wrong scopes.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reauth_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + client = await hass_client_no_auth() + await client.get(f"/auth/external/callback?code=abcd&state={state}") + + aioclient_mock.post( + "https://auth-global.api.smartthings.com/oauth/token", + json={ + "refresh_token": "new-refresh-token", + "access_token": "new-access-token", + "token_type": "Bearer", + "expires_in": 82806, + "scope": "r:devices:* w:devices:* x:devices:* r:hubs:* " + "r:locations:* w:locations:* x:locations:* " + "r:scenes:* x:scenes:* r:rules:* w:rules:* " + "r:installedapps w:installedapps", + "access_tier": 0, + "installed_app_id": "5aaaa925-2be1-4e40-b257-e4ef59083324", + }, + ) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "missing_scopes" + + @pytest.mark.usefixtures("current_request_with_host") async def test_reauth_account_mismatch( hass: HomeAssistant, From 1852052dffa1175ea80cb4cfb987ee98d2fc4d00 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 1 Mar 2025 13:05:58 +0100 Subject: [PATCH 2006/3148] Add suggested area to SmartThings (#139570) * Add suggested area to SmartThings * Add suggested areas to SmartThings --- .../components/smartthings/__init__.py | 9 ++ .../components/smartthings/binary_sensor.py | 10 +- .../components/smartthings/climate.py | 14 ++- homeassistant/components/smartthings/cover.py | 11 +- .../components/smartthings/entity.py | 9 +- homeassistant/components/smartthings/fan.py | 7 +- homeassistant/components/smartthings/light.py | 7 +- homeassistant/components/smartthings/lock.py | 2 +- .../components/smartthings/sensor.py | 12 ++- .../components/smartthings/switch.py | 4 +- tests/components/smartthings/conftest.py | 4 + .../fixtures/devices/base_electric_meter.json | 2 +- .../devices/c2c_arlo_pro_3_switch.json | 2 +- .../fixtures/devices/centralite.json | 2 +- .../fixtures/devices/contact_sensor.json | 2 +- .../fixtures/devices/da_ac_rac_000001.json | 2 +- .../fixtures/devices/da_ac_rac_01001.json | 2 +- .../devices/da_ks_microwave_0101x.json | 2 +- .../devices/da_ref_normal_000001.json | 2 +- .../devices/da_rvc_normal_000001.json | 2 +- .../fixtures/devices/da_wm_dw_000001.json | 2 +- .../fixtures/devices/da_wm_wd_000001.json | 2 +- .../fixtures/devices/da_wm_wm_000001.json | 2 +- .../fixtures/devices/fake_fan.json | 2 +- .../devices/ge_in_wall_smart_dimmer.json | 2 +- .../smartthings/fixtures/devices/hub.json | 2 +- .../fixtures/devices/multipurpose_sensor.json | 2 +- .../fixtures/devices/smart_plug.json | 2 +- .../fixtures/devices/sonos_player.json | 2 +- .../devices/vd_network_audio_002s.json | 2 +- .../fixtures/devices/vd_stv_2017_k.json | 2 +- .../fixtures/devices/virtual_thermostat.json | 2 +- .../fixtures/devices/virtual_valve.json | 2 +- .../devices/virtual_water_sensor.json | 2 +- .../yale_push_button_deadbolt_lock.json | 2 +- .../smartthings/fixtures/rooms.json | 17 +++ .../smartthings/snapshots/test_init.ambr | 100 +++++++++--------- 37 files changed, 163 insertions(+), 91 deletions(-) create mode 100644 tests/components/smartthings/fixtures/rooms.json diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py index f3a95e57831..b7850bc9333 100644 --- a/homeassistant/components/smartthings/__init__.py +++ b/homeassistant/components/smartthings/__init__.py @@ -39,6 +39,7 @@ class SmartThingsData: devices: dict[str, FullDevice] scenes: dict[str, Scene] + rooms: dict[str, str] client: SmartThings @@ -92,6 +93,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: SmartThingsConfigEntry) device_status: dict[str, FullDevice] = {} try: + rooms = { + room.room_id: room.name + for room in await client.get_rooms(location_id=entry.data[CONF_LOCATION_ID]) + } devices = await client.get_devices() for device in devices: status = process_status(await client.get_device_status(device.device_id)) @@ -113,6 +118,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: SmartThingsConfigEntry) name=dev.device.label, sw_version=dev.device.hub.firmware_version, model=dev.device.hub.hardware_type, + suggested_area=( + rooms.get(dev.device.room_id) if dev.device.room_id else None + ), ) scenes = { scene.scene_id: scene @@ -127,6 +135,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: SmartThingsConfigEntry) }, client=client, scenes=scenes, + rooms=rooms, ) entry.async_create_background_task( diff --git a/homeassistant/components/smartthings/binary_sensor.py b/homeassistant/components/smartthings/binary_sensor.py index 99cbd3f9353..080a90440be 100644 --- a/homeassistant/components/smartthings/binary_sensor.py +++ b/homeassistant/components/smartthings/binary_sensor.py @@ -109,7 +109,12 @@ async def async_setup_entry( entry_data = entry.runtime_data async_add_entities( SmartThingsBinarySensor( - entry_data.client, device, description, capability, attribute + entry_data.client, + device, + description, + entry_data.rooms, + capability, + attribute, ) for device in entry_data.devices.values() for capability, attribute_map in CAPABILITY_TO_SENSORS.items() @@ -128,11 +133,12 @@ class SmartThingsBinarySensor(SmartThingsEntity, BinarySensorEntity): client: SmartThings, device: FullDevice, entity_description: SmartThingsBinarySensorEntityDescription, + rooms: dict[str, str], capability: Capability, attribute: Attribute, ) -> None: """Init the class.""" - super().__init__(client, device, {capability}) + super().__init__(client, device, rooms, {capability}) self._attribute = attribute self.capability = capability self.entity_description = entity_description diff --git a/homeassistant/components/smartthings/climate.py b/homeassistant/components/smartthings/climate.py index 2c3b8f3ac03..bfda5c00d5e 100644 --- a/homeassistant/components/smartthings/climate.py +++ b/homeassistant/components/smartthings/climate.py @@ -118,12 +118,12 @@ async def async_setup_entry( """Add climate entities for a config entry.""" entry_data = entry.runtime_data entities: list[ClimateEntity] = [ - SmartThingsAirConditioner(entry_data.client, device) + SmartThingsAirConditioner(entry_data.client, entry_data.rooms, device) for device in entry_data.devices.values() if all(capability in device.status[MAIN] for capability in AC_CAPABILITIES) ] entities.extend( - SmartThingsThermostat(entry_data.client, device) + SmartThingsThermostat(entry_data.client, entry_data.rooms, device) for device in entry_data.devices.values() if all( capability in device.status[MAIN] for capability in THERMOSTAT_CAPABILITIES @@ -137,11 +137,14 @@ class SmartThingsThermostat(SmartThingsEntity, ClimateEntity): _attr_name = None - def __init__(self, client: SmartThings, device: FullDevice) -> None: + def __init__( + self, client: SmartThings, rooms: dict[str, str], device: FullDevice + ) -> None: """Init the class.""" super().__init__( client, device, + rooms, { Capability.THERMOSTAT_FAN_MODE, Capability.THERMOSTAT_MODE, @@ -327,11 +330,14 @@ class SmartThingsAirConditioner(SmartThingsEntity, ClimateEntity): _attr_name = None _attr_preset_mode = None - def __init__(self, client: SmartThings, device: FullDevice) -> None: + def __init__( + self, client: SmartThings, rooms: dict[str, str], device: FullDevice + ) -> None: """Init the class.""" super().__init__( client, device, + rooms, { Capability.AIR_CONDITIONER_MODE, Capability.SWITCH, diff --git a/homeassistant/components/smartthings/cover.py b/homeassistant/components/smartthings/cover.py index 0b0f03679eb..564de8443b1 100644 --- a/homeassistant/components/smartthings/cover.py +++ b/homeassistant/components/smartthings/cover.py @@ -41,7 +41,9 @@ async def async_setup_entry( """Add covers for a config entry.""" entry_data = entry.runtime_data async_add_entities( - SmartThingsCover(entry_data.client, device, Capability(capability)) + SmartThingsCover( + entry_data.client, device, entry_data.rooms, Capability(capability) + ) for device in entry_data.devices.values() for capability in device.status[MAIN] if capability in CAPABILITIES @@ -55,12 +57,17 @@ class SmartThingsCover(SmartThingsEntity, CoverEntity): _state: CoverState | None = None def __init__( - self, client: SmartThings, device: FullDevice, capability: Capability + self, + client: SmartThings, + device: FullDevice, + rooms: dict[str, str], + capability: Capability, ) -> None: """Initialize the cover class.""" super().__init__( client, device, + rooms, { capability, Capability.BATTERY, diff --git a/homeassistant/components/smartthings/entity.py b/homeassistant/components/smartthings/entity.py index 0240549740f..542401109ad 100644 --- a/homeassistant/components/smartthings/entity.py +++ b/homeassistant/components/smartthings/entity.py @@ -27,7 +27,11 @@ class SmartThingsEntity(Entity): _attr_has_entity_name = True def __init__( - self, client: SmartThings, device: FullDevice, capabilities: set[Capability] + self, + client: SmartThings, + device: FullDevice, + rooms: dict[str, str], + capabilities: set[Capability], ) -> None: """Initialize the instance.""" self.client = client @@ -43,6 +47,9 @@ class SmartThingsEntity(Entity): configuration_url="https://account.smartthings.com", identifiers={(DOMAIN, device.device.device_id)}, name=device.device.label, + suggested_area=( + rooms.get(device.device.room_id) if device.device.room_id else None + ), ) if device.device.parent_device_id: self._attr_device_info["via_device"] = ( diff --git a/homeassistant/components/smartthings/fan.py b/homeassistant/components/smartthings/fan.py index 8edf01ec613..9aa467cbfa8 100644 --- a/homeassistant/components/smartthings/fan.py +++ b/homeassistant/components/smartthings/fan.py @@ -31,7 +31,7 @@ async def async_setup_entry( """Add fans for a config entry.""" entry_data = entry.runtime_data async_add_entities( - SmartThingsFan(entry_data.client, device) + SmartThingsFan(entry_data.client, entry_data.rooms, device) for device in entry_data.devices.values() if Capability.SWITCH in device.status[MAIN] and any( @@ -51,11 +51,14 @@ class SmartThingsFan(SmartThingsEntity, FanEntity): _attr_name = None _attr_speed_count = int_states_in_range(SPEED_RANGE) - def __init__(self, client: SmartThings, device: FullDevice) -> None: + def __init__( + self, client: SmartThings, rooms: dict[str, str], device: FullDevice + ) -> None: """Init the class.""" super().__init__( client, device, + rooms, { Capability.SWITCH, Capability.FAN_SPEED, diff --git a/homeassistant/components/smartthings/light.py b/homeassistant/components/smartthings/light.py index aa3a8d35859..eee333f131f 100644 --- a/homeassistant/components/smartthings/light.py +++ b/homeassistant/components/smartthings/light.py @@ -41,7 +41,7 @@ async def async_setup_entry( """Add lights for a config entry.""" entry_data = entry.runtime_data async_add_entities( - SmartThingsLight(entry_data.client, device) + SmartThingsLight(entry_data.client, entry_data.rooms, device) for device in entry_data.devices.values() if Capability.SWITCH in device.status[MAIN] and any(capability in device.status[MAIN] for capability in CAPABILITIES) @@ -71,11 +71,14 @@ class SmartThingsLight(SmartThingsEntity, LightEntity, RestoreEntity): # highest kelvin found supported across 20+ handlers. _attr_max_color_temp_kelvin = 9000 # 111 mireds - def __init__(self, client: SmartThings, device: FullDevice) -> None: + def __init__( + self, client: SmartThings, rooms: dict[str, str], device: FullDevice + ) -> None: """Initialize a SmartThingsLight.""" super().__init__( client, device, + rooms, { Capability.COLOR_CONTROL, Capability.COLOR_TEMPERATURE, diff --git a/homeassistant/components/smartthings/lock.py b/homeassistant/components/smartthings/lock.py index f56ecd5d565..76a643e417e 100644 --- a/homeassistant/components/smartthings/lock.py +++ b/homeassistant/components/smartthings/lock.py @@ -33,7 +33,7 @@ async def async_setup_entry( """Add locks for a config entry.""" entry_data = entry.runtime_data async_add_entities( - SmartThingsLock(entry_data.client, device, {Capability.LOCK}) + SmartThingsLock(entry_data.client, device, entry_data.rooms, {Capability.LOCK}) for device in entry_data.devices.values() if Capability.LOCK in device.status[MAIN] ) diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index 0a695876da4..ff6e7f252b0 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -962,7 +962,14 @@ async def async_setup_entry( """Add sensors for a config entry.""" entry_data = entry.runtime_data async_add_entities( - SmartThingsSensor(entry_data.client, device, description, capability, attribute) + SmartThingsSensor( + entry_data.client, + device, + description, + entry_data.rooms, + capability, + attribute, + ) for device in entry_data.devices.values() for capability, attributes in CAPABILITY_TO_SENSORS.items() if capability in device.status[MAIN] @@ -992,11 +999,12 @@ class SmartThingsSensor(SmartThingsEntity, SensorEntity): client: SmartThings, device: FullDevice, entity_description: SmartThingsSensorEntityDescription, + rooms: dict[str, str], capability: Capability, attribute: Attribute, ) -> None: """Init the class.""" - super().__init__(client, device, {capability}) + super().__init__(client, device, rooms, {capability}) self._attr_unique_id = f"{device.device.device_id}{entity_description.unique_id_separator}{entity_description.key}" self._attribute = attribute self.capability = capability diff --git a/homeassistant/components/smartthings/switch.py b/homeassistant/components/smartthings/switch.py index 380005f1b93..f470a90bb39 100644 --- a/homeassistant/components/smartthings/switch.py +++ b/homeassistant/components/smartthings/switch.py @@ -37,7 +37,9 @@ async def async_setup_entry( """Add switches for a config entry.""" entry_data = entry.runtime_data async_add_entities( - SmartThingsSwitch(entry_data.client, device, {Capability.SWITCH}) + SmartThingsSwitch( + entry_data.client, device, entry_data.rooms, {Capability.SWITCH} + ) for device in entry_data.devices.values() if Capability.SWITCH in device.status[MAIN] and not any(capability in device.status[MAIN] for capability in CAPABILITIES) diff --git a/tests/components/smartthings/conftest.py b/tests/components/smartthings/conftest.py index b7d0cb61607..a47f32d3a8b 100644 --- a/tests/components/smartthings/conftest.py +++ b/tests/components/smartthings/conftest.py @@ -8,6 +8,7 @@ from pysmartthings.models import ( DeviceResponse, DeviceStatus, LocationResponse, + RoomResponse, SceneResponse, ) import pytest @@ -78,6 +79,9 @@ def mock_smartthings() -> Generator[AsyncMock]: client.get_locations.return_value = LocationResponse.from_json( load_fixture("locations.json", DOMAIN) ).items + client.get_rooms.return_value = RoomResponse.from_json( + load_fixture("rooms.json", DOMAIN) + ).items yield client diff --git a/tests/components/smartthings/fixtures/devices/base_electric_meter.json b/tests/components/smartthings/fixtures/devices/base_electric_meter.json index 4d00d6f169c..a81ca788b29 100644 --- a/tests/components/smartthings/fixtures/devices/base_electric_meter.json +++ b/tests/components/smartthings/fixtures/devices/base_electric_meter.json @@ -9,7 +9,7 @@ "deviceManufacturerCode": "0086-0002-0009", "locationId": "c4d3b2a1-09f8-765e-4d3c-2b1a09f8e7d6 ", "ownerId": "d47f2b19-3a6e-4c8d-bf21-9e8a7c5d134e", - "roomId": "94be4a1e-382a-4b7f-a5ef-fdb1a7d9f9e6", + "roomId": "7715151d-0314-457a-a82c-5ce48900e065", "components": [ { "id": "main", diff --git a/tests/components/smartthings/fixtures/devices/c2c_arlo_pro_3_switch.json b/tests/components/smartthings/fixtures/devices/c2c_arlo_pro_3_switch.json index a9e3bddb2ca..21d4d475e7a 100644 --- a/tests/components/smartthings/fixtures/devices/c2c_arlo_pro_3_switch.json +++ b/tests/components/smartthings/fixtures/devices/c2c_arlo_pro_3_switch.json @@ -9,7 +9,7 @@ "deviceManufacturerCode": "Arlo", "locationId": "c4d3b2a1-09f8-765e-4d3c-2b1a09f8e7d6 ", "ownerId": "d47f2b19-3a6e-4c8d-bf21-9e8a7c5d134e", - "roomId": "68b45114-9af8-4906-8636-b973a6faa271", + "roomId": "7715151d-0314-457a-a82c-5ce48900e065", "components": [ { "id": "main", diff --git a/tests/components/smartthings/fixtures/devices/centralite.json b/tests/components/smartthings/fixtures/devices/centralite.json index dff2be78f70..d94043efbc8 100644 --- a/tests/components/smartthings/fixtures/devices/centralite.json +++ b/tests/components/smartthings/fixtures/devices/centralite.json @@ -9,7 +9,7 @@ "deviceManufacturerCode": "CentraLite", "locationId": "c4d3b2a1-09f8-765e-4d3c-2b1a09f8e7d6 ", "ownerId": "d47f2b19-3a6e-4c8d-bf21-9e8a7c5d134e", - "roomId": "94be4a1e-382a-4b7f-a5ef-fdb1a7d9f9e6", + "roomId": "7715151d-0314-457a-a82c-5ce48900e065", "components": [ { "id": "main", diff --git a/tests/components/smartthings/fixtures/devices/contact_sensor.json b/tests/components/smartthings/fixtures/devices/contact_sensor.json index 92fe6a8bbff..68070abbfc3 100644 --- a/tests/components/smartthings/fixtures/devices/contact_sensor.json +++ b/tests/components/smartthings/fixtures/devices/contact_sensor.json @@ -9,7 +9,7 @@ "deviceManufacturerCode": "Visonic", "locationId": "c4d3b2a1-09f8-765e-4d3c-2b1a09f8e7d6 ", "ownerId": "d47f2b19-3a6e-4c8d-bf21-9e8a7c5d134e", - "roomId": "68b45114-9af8-4906-8636-b973a6faa271", + "roomId": "7715151d-0314-457a-a82c-5ce48900e065", "components": [ { "id": "main", diff --git a/tests/components/smartthings/fixtures/devices/da_ac_rac_000001.json b/tests/components/smartthings/fixtures/devices/da_ac_rac_000001.json index ec7f16b090a..d831e15a86b 100644 --- a/tests/components/smartthings/fixtures/devices/da_ac_rac_000001.json +++ b/tests/components/smartthings/fixtures/devices/da_ac_rac_000001.json @@ -9,7 +9,7 @@ "deviceManufacturerCode": "Samsung Electronics", "locationId": "58d3fd7c-c512-4da3-b500-ef269382756c", "ownerId": "f9a28d7c-1ed5-d9e9-a81c-18971ec081db", - "roomId": "85a79db4-9cf2-4f09-a5b2-cd70a5c0cef0", + "roomId": "7715151d-0314-457a-a82c-5ce48900e065", "deviceTypeName": "Samsung OCF Air Conditioner", "components": [ { diff --git a/tests/components/smartthings/fixtures/devices/da_ac_rac_01001.json b/tests/components/smartthings/fixtures/devices/da_ac_rac_01001.json index 8d9ebde5bcd..db6f8d09673 100644 --- a/tests/components/smartthings/fixtures/devices/da_ac_rac_01001.json +++ b/tests/components/smartthings/fixtures/devices/da_ac_rac_01001.json @@ -9,7 +9,7 @@ "deviceManufacturerCode": "Samsung Electronics", "locationId": "c4189ac1-208f-461a-8ab6-ea67937b3743", "ownerId": "85ea07e1-7063-f673-3ba5-125293f297c8", - "roomId": "1f66199a-1773-4d8f-97b7-44c312a62cf7", + "roomId": "7715151d-0314-457a-a82c-5ce48900e065", "deviceTypeName": "Samsung OCF Air Conditioner", "components": [ { diff --git a/tests/components/smartthings/fixtures/devices/da_ks_microwave_0101x.json b/tests/components/smartthings/fixtures/devices/da_ks_microwave_0101x.json index f6599fee461..f636b069e38 100644 --- a/tests/components/smartthings/fixtures/devices/da_ks_microwave_0101x.json +++ b/tests/components/smartthings/fixtures/devices/da_ks_microwave_0101x.json @@ -9,7 +9,7 @@ "deviceManufacturerCode": "Samsung Electronics", "locationId": "586e4602-34ab-4a22-993e-5f616b04604f", "ownerId": "b603d7e8-6066-4e10-8102-afa752a63816", - "roomId": "f4d03391-ab13-4c1d-b4dc-d6ddf86014a2", + "roomId": "7715151d-0314-457a-a82c-5ce48900e065", "deviceTypeName": "oic.d.microwave", "components": [ { diff --git a/tests/components/smartthings/fixtures/devices/da_ref_normal_000001.json b/tests/components/smartthings/fixtures/devices/da_ref_normal_000001.json index 67afc0ad32c..29372cac23c 100644 --- a/tests/components/smartthings/fixtures/devices/da_ref_normal_000001.json +++ b/tests/components/smartthings/fixtures/devices/da_ref_normal_000001.json @@ -9,7 +9,7 @@ "deviceManufacturerCode": "Samsung Electronics", "locationId": "c4d3b2a1-09f8-765e-4d3c-2b1a09f8e7d6 ", "ownerId": "d47f2b19-3a6e-4c8d-bf21-9e8a7c5d134e", - "roomId": "3a1f7e7c-4e59-4c29-adb0-0813be691efd", + "roomId": "7715151d-0314-457a-a82c-5ce48900e065", "deviceTypeName": "Samsung OCF Refrigerator", "components": [ { diff --git a/tests/components/smartthings/fixtures/devices/da_rvc_normal_000001.json b/tests/components/smartthings/fixtures/devices/da_rvc_normal_000001.json index b355eedb17a..b7f8ab2a42c 100644 --- a/tests/components/smartthings/fixtures/devices/da_rvc_normal_000001.json +++ b/tests/components/smartthings/fixtures/devices/da_rvc_normal_000001.json @@ -9,7 +9,7 @@ "deviceManufacturerCode": "Samsung Electronics", "locationId": "586e4602-34ab-4a22-993e-5f616b04604f", "ownerId": "b603d7e8-6066-4e10-8102-afa752a63816", - "roomId": "5d425f41-042a-4d9a-92c4-e43150a61bae", + "roomId": "7715151d-0314-457a-a82c-5ce48900e065", "deviceTypeName": "Samsung OCF Robot Vacuum", "components": [ { diff --git a/tests/components/smartthings/fixtures/devices/da_wm_dw_000001.json b/tests/components/smartthings/fixtures/devices/da_wm_dw_000001.json index 1c7024e153f..33392081bf5 100644 --- a/tests/components/smartthings/fixtures/devices/da_wm_dw_000001.json +++ b/tests/components/smartthings/fixtures/devices/da_wm_dw_000001.json @@ -9,7 +9,7 @@ "deviceManufacturerCode": "Samsung Electronics", "locationId": "586e4602-34ab-4a22-993e-5f616b04604f", "ownerId": "b603d7e8-6066-4e10-8102-afa752a63816", - "roomId": "f4d03391-ab13-4c1d-b4dc-d6ddf86014a2", + "roomId": "7715151d-0314-457a-a82c-5ce48900e065", "deviceTypeName": "Samsung OCF Dishwasher", "components": [ { diff --git a/tests/components/smartthings/fixtures/devices/da_wm_wd_000001.json b/tests/components/smartthings/fixtures/devices/da_wm_wd_000001.json index b9a650718e2..ef47260a989 100644 --- a/tests/components/smartthings/fixtures/devices/da_wm_wd_000001.json +++ b/tests/components/smartthings/fixtures/devices/da_wm_wd_000001.json @@ -9,7 +9,7 @@ "deviceManufacturerCode": "Samsung Electronics", "locationId": "781d5f1e-c87e-455e-87f7-8e954879e91d", "ownerId": "b603d7e8-6066-4e10-8102-afa752a63816", - "roomId": "2a8637b2-77ad-475e-b537-7b6f7f97fff6", + "roomId": "7715151d-0314-457a-a82c-5ce48900e065", "deviceTypeName": "Samsung OCF Dryer", "components": [ { diff --git a/tests/components/smartthings/fixtures/devices/da_wm_wm_000001.json b/tests/components/smartthings/fixtures/devices/da_wm_wm_000001.json index 852a2afa932..4996eebab96 100644 --- a/tests/components/smartthings/fixtures/devices/da_wm_wm_000001.json +++ b/tests/components/smartthings/fixtures/devices/da_wm_wm_000001.json @@ -9,7 +9,7 @@ "deviceManufacturerCode": "Samsung Electronics", "locationId": "781d5f1e-c87e-455e-87f7-8e954879e91d", "ownerId": "b603d7e8-6066-4e10-8102-afa752a63816", - "roomId": "2a8637b2-77ad-475e-b537-7b6f7f97fff6", + "roomId": "7715151d-0314-457a-a82c-5ce48900e065", "deviceTypeName": "Samsung OCF Washer", "components": [ { diff --git a/tests/components/smartthings/fixtures/devices/fake_fan.json b/tests/components/smartthings/fixtures/devices/fake_fan.json index 8656e290c8d..6a447ae7aff 100644 --- a/tests/components/smartthings/fixtures/devices/fake_fan.json +++ b/tests/components/smartthings/fixtures/devices/fake_fan.json @@ -9,7 +9,7 @@ "deviceManufacturerCode": "0086-0002-005F", "locationId": "6f11ddf5-f0cb-4516-a06a-3a2a6ec22bca", "ownerId": "9f257fc4-6471-2566-b06e-2fe72dd979fa", - "roomId": "cdf080f0-0542-41d7-a606-aff69683e04c", + "roomId": "7715151d-0314-457a-a82c-5ce48900e065", "components": [ { "id": "main", diff --git a/tests/components/smartthings/fixtures/devices/ge_in_wall_smart_dimmer.json b/tests/components/smartthings/fixtures/devices/ge_in_wall_smart_dimmer.json index 314586300b9..646196fa980 100644 --- a/tests/components/smartthings/fixtures/devices/ge_in_wall_smart_dimmer.json +++ b/tests/components/smartthings/fixtures/devices/ge_in_wall_smart_dimmer.json @@ -8,7 +8,7 @@ "presentationId": "31cf01ee-cb49-3d95-ac2d-2afab47f25c7", "deviceManufacturerCode": "0063-4944-3130", "locationId": "c4d3b2a1-09f8-765e-4d3c-2b1a09f8e7d6 ", - "roomId": "e73dcd00-6953-431d-ae79-73fd2f2c528e", + "roomId": "7715151d-0314-457a-a82c-5ce48900e065", "components": [ { "id": "main", diff --git a/tests/components/smartthings/fixtures/devices/hub.json b/tests/components/smartthings/fixtures/devices/hub.json index 4de0823d758..81046859db6 100644 --- a/tests/components/smartthings/fixtures/devices/hub.json +++ b/tests/components/smartthings/fixtures/devices/hub.json @@ -8,7 +8,7 @@ "presentationId": "63f1469e-dc4a-3689-8cc5-69e293c1eb21", "locationId": "c4d3b2a1-09f8-765e-4d3c-2b1a09f8e7d6 ", "ownerId": "d47f2b19-3a6e-4c8d-bf21-9e8a7c5d134e", - "roomId": "f7f39cf6-ff3a-4bcb-8d1b-00a3324c016d", + "roomId": "7715151d-0314-457a-a82c-5ce48900e065", "components": [ { "id": "main", diff --git a/tests/components/smartthings/fixtures/devices/multipurpose_sensor.json b/tests/components/smartthings/fixtures/devices/multipurpose_sensor.json index b056ecf007b..c8088d6473d 100644 --- a/tests/components/smartthings/fixtures/devices/multipurpose_sensor.json +++ b/tests/components/smartthings/fixtures/devices/multipurpose_sensor.json @@ -8,7 +8,7 @@ "presentationId": "c385e2bc-acb8-317b-be2a-6efd1f879720", "deviceManufacturerCode": "SmartThings", "locationId": "c4d3b2a1-09f8-765e-4d3c-2b1a09f8e7d6 ", - "roomId": "b277a3c0-b8fe-44de-9133-c1108747810c", + "roomId": "7715151d-0314-457a-a82c-5ce48900e065", "components": [ { "id": "main", diff --git a/tests/components/smartthings/fixtures/devices/smart_plug.json b/tests/components/smartthings/fixtures/devices/smart_plug.json index 105ae43c3d0..e5ec6c38dad 100644 --- a/tests/components/smartthings/fixtures/devices/smart_plug.json +++ b/tests/components/smartthings/fixtures/devices/smart_plug.json @@ -9,7 +9,7 @@ "deviceManufacturerCode": "LEDVANCE", "locationId": "c4d3b2a1-09f8-765e-4d3c-2b1a09f8e7d6 ", "ownerId": "d47f2b19-3a6e-4c8d-bf21-9e8a7c5d134e", - "roomId": "94be4a1e-382a-4b7f-a5ef-fdb1a7d9f9e6", + "roomId": "7715151d-0314-457a-a82c-5ce48900e065", "components": [ { "id": "main", diff --git a/tests/components/smartthings/fixtures/devices/sonos_player.json b/tests/components/smartthings/fixtures/devices/sonos_player.json index f7f54a01b49..c84caf57475 100644 --- a/tests/components/smartthings/fixtures/devices/sonos_player.json +++ b/tests/components/smartthings/fixtures/devices/sonos_player.json @@ -9,7 +9,7 @@ "deviceManufacturerCode": "Sonos", "locationId": "eed0e167-e793-459b-80cb-a0b02e2b86c2", "ownerId": "2c69cc36-85ae-c41a-9981-a4ee96cd9137", - "roomId": "105e6d1a-52a4-4797-a235-5a48d7d433c8", + "roomId": "7715151d-0314-457a-a82c-5ce48900e065", "components": [ { "id": "main", diff --git a/tests/components/smartthings/fixtures/devices/vd_network_audio_002s.json b/tests/components/smartthings/fixtures/devices/vd_network_audio_002s.json index 7fb07533810..20f4aa71fec 100644 --- a/tests/components/smartthings/fixtures/devices/vd_network_audio_002s.json +++ b/tests/components/smartthings/fixtures/devices/vd_network_audio_002s.json @@ -9,7 +9,7 @@ "deviceManufacturerCode": "Samsung Electronics", "locationId": "c4189ac1-208f-461a-8ab6-ea67937b3743", "ownerId": "85ea07e1-7063-f673-3ba5-125293f297c8", - "roomId": "db506ec3-83b1-4125-9c4c-eb597da5db6a", + "roomId": "7715151d-0314-457a-a82c-5ce48900e065", "deviceTypeName": "Samsung OCF Network Audio Player", "components": [ { diff --git a/tests/components/smartthings/fixtures/devices/vd_stv_2017_k.json b/tests/components/smartthings/fixtures/devices/vd_stv_2017_k.json index 3c22a214495..42630f452d5 100644 --- a/tests/components/smartthings/fixtures/devices/vd_stv_2017_k.json +++ b/tests/components/smartthings/fixtures/devices/vd_stv_2017_k.json @@ -9,7 +9,7 @@ "deviceManufacturerCode": "Samsung Electronics", "locationId": "c4d3b2a1-09f8-765e-4d3c-2b1a09f8e7d6 ", "ownerId": "d47f2b19-3a6e-4c8d-bf21-9e8a7c5d134e", - "roomId": "94be4a1e-382a-4b7f-a5ef-fdb1a7d9f9e6", + "roomId": "7715151d-0314-457a-a82c-5ce48900e065", "deviceTypeName": "Samsung OCF TV", "components": [ { diff --git a/tests/components/smartthings/fixtures/devices/virtual_thermostat.json b/tests/components/smartthings/fixtures/devices/virtual_thermostat.json index d5bf3b32a0c..1b7a55d779d 100644 --- a/tests/components/smartthings/fixtures/devices/virtual_thermostat.json +++ b/tests/components/smartthings/fixtures/devices/virtual_thermostat.json @@ -8,7 +8,7 @@ "presentationId": "78906115-bf23-3c43-9cd6-f42ca3d5517a", "locationId": "88a3a314-f0c8-40b4-bb44-44ba06c9c42f", "ownerId": "12d4af93-cb68-b108-87f5-625437d7371f", - "roomId": "58826afc-9f38-426a-b868-dc94776286e3", + "roomId": "7715151d-0314-457a-a82c-5ce48900e065", "components": [ { "id": "main", diff --git a/tests/components/smartthings/fixtures/devices/virtual_valve.json b/tests/components/smartthings/fixtures/devices/virtual_valve.json index 1988617afad..e46b7846631 100644 --- a/tests/components/smartthings/fixtures/devices/virtual_valve.json +++ b/tests/components/smartthings/fixtures/devices/virtual_valve.json @@ -8,7 +8,7 @@ "presentationId": "916408b6-c94e-38b8-9fbf-03c8a48af5c3", "locationId": "88a3a314-f0c8-40b4-bb44-44ba06c9c42f", "ownerId": "12d4af93-cb68-b108-87f5-625437d7371f", - "roomId": "58826afc-9f38-426a-b868-dc94776286e3", + "roomId": "7715151d-0314-457a-a82c-5ce48900e065", "components": [ { "id": "main", diff --git a/tests/components/smartthings/fixtures/devices/virtual_water_sensor.json b/tests/components/smartthings/fixtures/devices/virtual_water_sensor.json index ad3a45a0481..ffea2664c88 100644 --- a/tests/components/smartthings/fixtures/devices/virtual_water_sensor.json +++ b/tests/components/smartthings/fixtures/devices/virtual_water_sensor.json @@ -8,7 +8,7 @@ "presentationId": "838ae989-b832-3610-968c-2940491600f6", "locationId": "88a3a314-f0c8-40b4-bb44-44ba06c9c42f", "ownerId": "12d4af93-cb68-b108-87f5-625437d7371f", - "roomId": "58826afc-9f38-426a-b868-dc94776286e3", + "roomId": "7715151d-0314-457a-a82c-5ce48900e065", "components": [ { "id": "main", diff --git a/tests/components/smartthings/fixtures/devices/yale_push_button_deadbolt_lock.json b/tests/components/smartthings/fixtures/devices/yale_push_button_deadbolt_lock.json index 117aa1344cb..20f0dd5ca26 100644 --- a/tests/components/smartthings/fixtures/devices/yale_push_button_deadbolt_lock.json +++ b/tests/components/smartthings/fixtures/devices/yale_push_button_deadbolt_lock.json @@ -9,7 +9,7 @@ "deviceManufacturerCode": "Yale", "locationId": "c4d3b2a1-09f8-765e-4d3c-2b1a09f8e7d6 ", "ownerId": "d47f2b19-3a6e-4c8d-bf21-9e8a7c5d134e", - "roomId": "94be4a1e-382a-4b7f-a5ef-fdb1a7d9f9e6", + "roomId": "7715151d-0314-457a-a82c-5ce48900e065", "components": [ { "id": "main", diff --git a/tests/components/smartthings/fixtures/rooms.json b/tests/components/smartthings/fixtures/rooms.json new file mode 100644 index 00000000000..355db9a3423 --- /dev/null +++ b/tests/components/smartthings/fixtures/rooms.json @@ -0,0 +1,17 @@ +{ + "items": [ + { + "roomId": "7715151d-0314-457a-a82c-5ce48900e065", + "locationId": "397678e5-9995-4a39-9d9f-ae6ba310236b", + "name": "Theater", + "backgroundImage": null + }, + { + "roomId": "cdf080f0-0542-41d7-a606-aff69683e04c", + "locationId": "397678e5-9995-4a39-9d9f-ae6ba310236b", + "name": "Toilet", + "backgroundImage": null + } + ], + "_links": null +} diff --git a/tests/components/smartthings/snapshots/test_init.ambr b/tests/components/smartthings/snapshots/test_init.ambr index 18bc802e2bc..fb856ae32d6 100644 --- a/tests/components/smartthings/snapshots/test_init.ambr +++ b/tests/components/smartthings/snapshots/test_init.ambr @@ -1,7 +1,7 @@ # serializer version: 1 # name: test_devices[aeotec_home_energy_meter_gen5] DeviceRegistryEntrySnapshot({ - 'area_id': None, + 'area_id': 'toilet', 'config_entries': , 'config_entries_subentries': , 'configuration_url': 'https://account.smartthings.com', @@ -27,14 +27,14 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, + 'suggested_area': 'Toilet', 'sw_version': None, 'via_device_id': None, }) # --- # name: test_devices[base_electric_meter] DeviceRegistryEntrySnapshot({ - 'area_id': None, + 'area_id': 'theater', 'config_entries': , 'config_entries_subentries': , 'configuration_url': 'https://account.smartthings.com', @@ -60,14 +60,14 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, + 'suggested_area': 'Theater', 'sw_version': None, 'via_device_id': None, }) # --- # name: test_devices[c2c_arlo_pro_3_switch] DeviceRegistryEntrySnapshot({ - 'area_id': None, + 'area_id': 'theater', 'config_entries': , 'config_entries_subentries': , 'configuration_url': 'https://account.smartthings.com', @@ -93,7 +93,7 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, + 'suggested_area': 'Theater', 'sw_version': None, 'via_device_id': None, }) @@ -133,7 +133,7 @@ # --- # name: test_devices[centralite] DeviceRegistryEntrySnapshot({ - 'area_id': None, + 'area_id': 'theater', 'config_entries': , 'config_entries_subentries': , 'configuration_url': 'https://account.smartthings.com', @@ -159,14 +159,14 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, + 'suggested_area': 'Theater', 'sw_version': None, 'via_device_id': None, }) # --- # name: test_devices[contact_sensor] DeviceRegistryEntrySnapshot({ - 'area_id': None, + 'area_id': 'theater', 'config_entries': , 'config_entries_subentries': , 'configuration_url': 'https://account.smartthings.com', @@ -192,14 +192,14 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, + 'suggested_area': 'Theater', 'sw_version': None, 'via_device_id': None, }) # --- # name: test_devices[da_ac_rac_000001] DeviceRegistryEntrySnapshot({ - 'area_id': None, + 'area_id': 'theater', 'config_entries': , 'config_entries_subentries': , 'configuration_url': 'https://account.smartthings.com', @@ -225,14 +225,14 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, + 'suggested_area': 'Theater', 'sw_version': '0.1.0', 'via_device_id': None, }) # --- # name: test_devices[da_ac_rac_01001] DeviceRegistryEntrySnapshot({ - 'area_id': None, + 'area_id': 'theater', 'config_entries': , 'config_entries_subentries': , 'configuration_url': 'https://account.smartthings.com', @@ -258,14 +258,14 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, + 'suggested_area': 'Theater', 'sw_version': 'ARA-WW-TP1-22-COMMON_11240702', 'via_device_id': None, }) # --- # name: test_devices[da_ks_microwave_0101x] DeviceRegistryEntrySnapshot({ - 'area_id': None, + 'area_id': 'theater', 'config_entries': , 'config_entries_subentries': , 'configuration_url': 'https://account.smartthings.com', @@ -291,14 +291,14 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, + 'suggested_area': 'Theater', 'sw_version': 'AKS-WW-TP2-20-MICROWAVE-OTR_40230125', 'via_device_id': None, }) # --- # name: test_devices[da_ref_normal_000001] DeviceRegistryEntrySnapshot({ - 'area_id': None, + 'area_id': 'theater', 'config_entries': , 'config_entries_subentries': , 'configuration_url': 'https://account.smartthings.com', @@ -324,14 +324,14 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, + 'suggested_area': 'Theater', 'sw_version': 'A-RFWW-TP2-21-COMMON_20220110', 'via_device_id': None, }) # --- # name: test_devices[da_rvc_normal_000001] DeviceRegistryEntrySnapshot({ - 'area_id': None, + 'area_id': 'theater', 'config_entries': , 'config_entries_subentries': , 'configuration_url': 'https://account.smartthings.com', @@ -357,14 +357,14 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, + 'suggested_area': 'Theater', 'sw_version': '1.0', 'via_device_id': None, }) # --- # name: test_devices[da_wm_dw_000001] DeviceRegistryEntrySnapshot({ - 'area_id': None, + 'area_id': 'theater', 'config_entries': , 'config_entries_subentries': , 'configuration_url': 'https://account.smartthings.com', @@ -390,14 +390,14 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, + 'suggested_area': 'Theater', 'sw_version': 'DA_DW_A51_20_COMMON_30230714', 'via_device_id': None, }) # --- # name: test_devices[da_wm_wd_000001] DeviceRegistryEntrySnapshot({ - 'area_id': None, + 'area_id': 'theater', 'config_entries': , 'config_entries_subentries': , 'configuration_url': 'https://account.smartthings.com', @@ -423,14 +423,14 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, + 'suggested_area': 'Theater', 'sw_version': 'DA_WM_A51_20_COMMON_30230708', 'via_device_id': None, }) # --- # name: test_devices[da_wm_wm_000001] DeviceRegistryEntrySnapshot({ - 'area_id': None, + 'area_id': 'theater', 'config_entries': , 'config_entries_subentries': , 'configuration_url': 'https://account.smartthings.com', @@ -456,7 +456,7 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, + 'suggested_area': 'Theater', 'sw_version': 'DA_WM_TP2_20_COMMON_30230804', 'via_device_id': None, }) @@ -529,7 +529,7 @@ # --- # name: test_devices[fake_fan] DeviceRegistryEntrySnapshot({ - 'area_id': None, + 'area_id': 'theater', 'config_entries': , 'config_entries_subentries': , 'configuration_url': 'https://account.smartthings.com', @@ -555,14 +555,14 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, + 'suggested_area': 'Theater', 'sw_version': None, 'via_device_id': None, }) # --- # name: test_devices[ge_in_wall_smart_dimmer] DeviceRegistryEntrySnapshot({ - 'area_id': None, + 'area_id': 'theater', 'config_entries': , 'config_entries_subentries': , 'configuration_url': 'https://account.smartthings.com', @@ -588,7 +588,7 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, + 'suggested_area': 'Theater', 'sw_version': None, 'via_device_id': None, }) @@ -694,7 +694,7 @@ # --- # name: test_devices[multipurpose_sensor] DeviceRegistryEntrySnapshot({ - 'area_id': None, + 'area_id': 'theater', 'config_entries': , 'config_entries_subentries': , 'configuration_url': 'https://account.smartthings.com', @@ -720,7 +720,7 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, + 'suggested_area': 'Theater', 'sw_version': None, 'via_device_id': None, }) @@ -760,7 +760,7 @@ # --- # name: test_devices[smart_plug] DeviceRegistryEntrySnapshot({ - 'area_id': None, + 'area_id': 'theater', 'config_entries': , 'config_entries_subentries': , 'configuration_url': 'https://account.smartthings.com', @@ -786,14 +786,14 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, + 'suggested_area': 'Theater', 'sw_version': None, 'via_device_id': None, }) # --- # name: test_devices[sonos_player] DeviceRegistryEntrySnapshot({ - 'area_id': None, + 'area_id': 'theater', 'config_entries': , 'config_entries_subentries': , 'configuration_url': 'https://account.smartthings.com', @@ -819,14 +819,14 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, + 'suggested_area': 'Theater', 'sw_version': None, 'via_device_id': None, }) # --- # name: test_devices[vd_network_audio_002s] DeviceRegistryEntrySnapshot({ - 'area_id': None, + 'area_id': 'theater', 'config_entries': , 'config_entries_subentries': , 'configuration_url': 'https://account.smartthings.com', @@ -852,14 +852,14 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, + 'suggested_area': 'Theater', 'sw_version': 'SAT-iMX8M23WWC-1010.5', 'via_device_id': None, }) # --- # name: test_devices[vd_stv_2017_k] DeviceRegistryEntrySnapshot({ - 'area_id': None, + 'area_id': 'theater', 'config_entries': , 'config_entries_subentries': , 'configuration_url': 'https://account.smartthings.com', @@ -885,14 +885,14 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, + 'suggested_area': 'Theater', 'sw_version': 'T-KTMAKUC-1290.3', 'via_device_id': None, }) # --- # name: test_devices[virtual_thermostat] DeviceRegistryEntrySnapshot({ - 'area_id': None, + 'area_id': 'theater', 'config_entries': , 'config_entries_subentries': , 'configuration_url': 'https://account.smartthings.com', @@ -918,14 +918,14 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, + 'suggested_area': 'Theater', 'sw_version': None, 'via_device_id': None, }) # --- # name: test_devices[virtual_valve] DeviceRegistryEntrySnapshot({ - 'area_id': None, + 'area_id': 'theater', 'config_entries': , 'config_entries_subentries': , 'configuration_url': 'https://account.smartthings.com', @@ -951,14 +951,14 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, + 'suggested_area': 'Theater', 'sw_version': None, 'via_device_id': None, }) # --- # name: test_devices[virtual_water_sensor] DeviceRegistryEntrySnapshot({ - 'area_id': None, + 'area_id': 'theater', 'config_entries': , 'config_entries_subentries': , 'configuration_url': 'https://account.smartthings.com', @@ -984,14 +984,14 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, + 'suggested_area': 'Theater', 'sw_version': None, 'via_device_id': None, }) # --- # name: test_devices[yale_push_button_deadbolt_lock] DeviceRegistryEntrySnapshot({ - 'area_id': None, + 'area_id': 'theater', 'config_entries': , 'config_entries_subentries': , 'configuration_url': 'https://account.smartthings.com', @@ -1017,14 +1017,14 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, + 'suggested_area': 'Theater', 'sw_version': None, 'via_device_id': None, }) # --- # name: test_hub_via_device DeviceRegistryEntrySnapshot({ - 'area_id': None, + 'area_id': 'theater', 'config_entries': , 'config_entries_subentries': , 'configuration_url': None, @@ -1054,7 +1054,7 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, + 'suggested_area': 'Theater', 'sw_version': '000.055.00005', 'via_device_id': None, }) From 3edc7913deec2e16f463968935e4b46f65988273 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Sat, 1 Mar 2025 13:06:10 +0100 Subject: [PATCH 2007/3148] Fix blog post link in comment (#139568) --- homeassistant/config_entries.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 98d9e3c760c..bfea2c29eac 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -1635,7 +1635,7 @@ class ConfigEntriesFlowManager( # reconfigure to allow the user to change settings. # In case of non user visible flows, the integration should optionally # update the existing entry before aborting. - # see https://developers.home-assistant.io/blog/2025/01/16/config-flow-unique-id/ + # see https://developers.home-assistant.io/blog/2025/03/01/config-flow-unique-id/ report_usage( "creates a config entry when another entry with the same unique ID " "exists", From df9590200473a3dad4725c30b012c33acf14f598 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 1 Mar 2025 13:08:28 +0100 Subject: [PATCH 2008/3148] Only determine SmartThings swing modes if we support it (#139571) Only determine swing modes if we support it --- homeassistant/components/smartthings/climate.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/smartthings/climate.py b/homeassistant/components/smartthings/climate.py index bfda5c00d5e..b2f8819601c 100644 --- a/homeassistant/components/smartthings/climate.py +++ b/homeassistant/components/smartthings/climate.py @@ -351,7 +351,8 @@ class SmartThingsAirConditioner(SmartThingsEntity, ClimateEntity): ) self._attr_hvac_modes = self._determine_hvac_modes() self._attr_preset_modes = self._determine_preset_modes() - self._attr_swing_modes = self._determine_swing_modes() + if self.supports_capability(Capability.FAN_OSCILLATION_MODE): + self._attr_swing_modes = self._determine_swing_modes() self._attr_supported_features = self._determine_supported_features() def _determine_supported_features(self) -> ClimateEntityFeature: From 43f48b85620c0f0ab9669a5ed48fe0d3d76937aa Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Sat, 1 Mar 2025 13:23:27 +0100 Subject: [PATCH 2009/3148] Bump azure_storage quality to platinum (#139452) --- homeassistant/components/azure_storage/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/azure_storage/manifest.json b/homeassistant/components/azure_storage/manifest.json index 8f2d8aeaca7..729334f851d 100644 --- a/homeassistant/components/azure_storage/manifest.json +++ b/homeassistant/components/azure_storage/manifest.json @@ -7,6 +7,6 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["azure-storage-blob"], - "quality_scale": "bronze", + "quality_scale": "platinum", "requirements": ["azure-storage-blob==12.24.0"] } From 91eba0855e0da6403b89cd16a9d82b99c9302614 Mon Sep 17 00:00:00 2001 From: Guido Schmitz Date: Sat, 1 Mar 2025 13:29:50 +0100 Subject: [PATCH 2010/3148] Handle IPv6 URLs in devolo Home Network (#139191) * Handle IPv6 URLs in devolo Home Network * Use yarl --- .../components/devolo_home_network/entity.py | 3 +- .../devolo_home_network/conftest.py | 7 ++++ .../snapshots/test_init.ambr | 37 +++++++++++++++++++ .../devolo_home_network/test_init.py | 4 +- 4 files changed, 49 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/devolo_home_network/entity.py b/homeassistant/components/devolo_home_network/entity.py index 93ec1b9a3a2..64d8ff131e8 100644 --- a/homeassistant/components/devolo_home_network/entity.py +++ b/homeassistant/components/devolo_home_network/entity.py @@ -8,6 +8,7 @@ from devolo_plc_api.device_api import ( WifiGuestAccessGet, ) from devolo_plc_api.plcnet_api import DataRate, LogicalNetwork +from yarl import URL from homeassistant.const import ATTR_CONNECTIONS from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo @@ -43,7 +44,7 @@ class DevoloEntity(Entity): self.entry = entry self._attr_device_info = DeviceInfo( - configuration_url=f"http://{self.device.ip}", + configuration_url=URL.build(scheme="http", host=self.device.ip), identifiers={(DOMAIN, str(self.device.serial_number))}, manufacturer="devolo", model=self.device.product, diff --git a/tests/components/devolo_home_network/conftest.py b/tests/components/devolo_home_network/conftest.py index fd03063cd34..2b3fd989754 100644 --- a/tests/components/devolo_home_network/conftest.py +++ b/tests/components/devolo_home_network/conftest.py @@ -27,6 +27,13 @@ def mock_repeater_device(mock_device: MockDevice): return mock_device +@pytest.fixture +def mock_ipv6_device(mock_device: MockDevice): + """Mock connecting to a devolo home network device using IPv6.""" + mock_device.ip = "2001:db8::1" + return mock_device + + @pytest.fixture def mock_nonwifi_device(mock_device: MockDevice): """Mock connecting to a devolo home network device without wifi.""" diff --git a/tests/components/devolo_home_network/snapshots/test_init.ambr b/tests/components/devolo_home_network/snapshots/test_init.ambr index bdc597819a7..5753fd82817 100644 --- a/tests/components/devolo_home_network/snapshots/test_init.ambr +++ b/tests/components/devolo_home_network/snapshots/test_init.ambr @@ -36,6 +36,43 @@ 'via_device_id': None, }) # --- +# name: test_setup_entry[mock_ipv6_device] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'http://[2001:db8::1]', + 'connections': set({ + tuple( + 'mac', + 'aa:bb:cc:dd:ee:ff', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'devolo_home_network', + '1234567890', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'devolo', + 'model': 'dLAN pro 1200+ WiFi ac', + 'model_id': '2730', + 'name': 'Mock Title', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '1234567890', + 'suggested_area': None, + 'sw_version': '5.6.1', + 'via_device_id': None, + }) +# --- # name: test_setup_entry[mock_repeater_device] DeviceRegistryEntrySnapshot({ 'area_id': None, diff --git a/tests/components/devolo_home_network/test_init.py b/tests/components/devolo_home_network/test_init.py index 71823eabe82..56d2c21a5b2 100644 --- a/tests/components/devolo_home_network/test_init.py +++ b/tests/components/devolo_home_network/test_init.py @@ -27,7 +27,9 @@ from .mock import MockDevice from tests.common import MockConfigEntry -@pytest.mark.parametrize("device", ["mock_device", "mock_repeater_device"]) +@pytest.mark.parametrize( + "device", ["mock_device", "mock_repeater_device", "mock_ipv6_device"] +) async def test_setup_entry( hass: HomeAssistant, device: str, From 679b57e450df94711059c968c0b2ff4d0d5f32e3 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Sat, 1 Mar 2025 14:22:14 +0100 Subject: [PATCH 2011/3148] Add strict typing to Vodafone Station (#139573) --- .strict-typing | 1 + .../components/vodafone_station/coordinator.py | 4 ++-- mypy.ini | 10 ++++++++++ 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/.strict-typing b/.strict-typing index 1df49300b1e..4b2a94b2db4 100644 --- a/.strict-typing +++ b/.strict-typing @@ -528,6 +528,7 @@ homeassistant.components.vallox.* homeassistant.components.valve.* homeassistant.components.velbus.* homeassistant.components.vlc_telnet.* +homeassistant.components.vodafone_station.* homeassistant.components.wake_on_lan.* homeassistant.components.wake_word.* homeassistant.components.wallbox.* diff --git a/homeassistant/components/vodafone_station/coordinator.py b/homeassistant/components/vodafone_station/coordinator.py index cd640d10cb6..b7986d06c25 100644 --- a/homeassistant/components/vodafone_station/coordinator.py +++ b/homeassistant/components/vodafone_station/coordinator.py @@ -3,7 +3,7 @@ from dataclasses import dataclass from datetime import datetime, timedelta from json.decoder import JSONDecodeError -from typing import Any +from typing import Any, cast from aiovodafone import VodafoneStationDevice, VodafoneStationSercommApi, exceptions @@ -164,7 +164,7 @@ class VodafoneStationRouter(DataUpdateCoordinator[UpdateCoordinatorDataType]): @property def serial_number(self) -> str: """Device serial number.""" - return self.data.sensors["sys_serial_number"] + return cast(str, self.data.sensors["sys_serial_number"]) @property def device_info(self) -> DeviceInfo: diff --git a/mypy.ini b/mypy.ini index a6203993c87..0792f820965 100644 --- a/mypy.ini +++ b/mypy.ini @@ -5039,6 +5039,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.vodafone_station.*] +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.wake_on_lan.*] check_untyped_defs = true disallow_incomplete_defs = true From c5e0418f7561ac89a800eafb15ee41ab5519bc4a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 1 Mar 2025 16:41:11 +0000 Subject: [PATCH 2012/3148] Bump aiohomekit to 3.2.8 (#139579) changelog: https://github.com/Jc2k/aiohomekit/compare/3.2.7...3.2.8 --- homeassistant/components/homekit_controller/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/homekit_controller/manifest.json b/homeassistant/components/homekit_controller/manifest.json index b7c82b9fd51..98db9a397d3 100644 --- a/homeassistant/components/homekit_controller/manifest.json +++ b/homeassistant/components/homekit_controller/manifest.json @@ -14,6 +14,6 @@ "documentation": "https://www.home-assistant.io/integrations/homekit_controller", "iot_class": "local_push", "loggers": ["aiohomekit", "commentjson"], - "requirements": ["aiohomekit==3.2.7"], + "requirements": ["aiohomekit==3.2.8"], "zeroconf": ["_hap._tcp.local.", "_hap._udp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index efa3da8d3d3..b10d8372466 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -267,7 +267,7 @@ aiohasupervisor==0.3.0 aiohomeconnect==0.15.1 # homeassistant.components.homekit_controller -aiohomekit==3.2.7 +aiohomekit==3.2.8 # homeassistant.components.mcp_server aiohttp_sse==2.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 527d9f654dc..a1120979e69 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -252,7 +252,7 @@ aiohasupervisor==0.3.0 aiohomeconnect==0.15.1 # homeassistant.components.homekit_controller -aiohomekit==3.2.7 +aiohomekit==3.2.8 # homeassistant.components.mcp_server aiohttp_sse==2.2.0 From 2de941bc1125a682fe4fcbcfea6a76f7a71e920a Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sat, 1 Mar 2025 19:35:39 +0100 Subject: [PATCH 2013/3148] Fix - Allow brightness only light MQTT json light to be set up using the `brightness` flag or via `supported_color_modes` (#139585) * Fix - Allow brightness only light MQTT json light to be set up using the `brightness` flag or via `supported_color_modes` * Improve comment --- .../components/mqtt/light/schema_json.py | 4 ++ tests/components/mqtt/test_light_json.py | 71 +++++++++++++++++++ 2 files changed, 75 insertions(+) diff --git a/homeassistant/components/mqtt/light/schema_json.py b/homeassistant/components/mqtt/light/schema_json.py index 14e21e61d48..4473385d550 100644 --- a/homeassistant/components/mqtt/light/schema_json.py +++ b/homeassistant/components/mqtt/light/schema_json.py @@ -217,6 +217,10 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity): self._attr_color_mode = next(iter(self.supported_color_modes)) else: self._attr_color_mode = ColorMode.UNKNOWN + elif config.get(CONF_BRIGHTNESS): + # Brightness is supported and no supported_color_modes are set, + # so set brightness as the supported color mode. + self._attr_supported_color_modes = {ColorMode.BRIGHTNESS} def _update_color(self, values: dict[str, Any]) -> None: color_mode: str = values["color_mode"] diff --git a/tests/components/mqtt/test_light_json.py b/tests/components/mqtt/test_light_json.py index 7ddd04a09a6..bcf9d4bd736 100644 --- a/tests/components/mqtt/test_light_json.py +++ b/tests/components/mqtt/test_light_json.py @@ -361,6 +361,77 @@ async def test_no_color_brightness_color_temp_if_no_topics( assert state.state == STATE_UNKNOWN +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + light.DOMAIN: { + "schema": "json", + "name": "test", + "state_topic": "test_light_rgb", + "command_topic": "test_light_rgb/set", + "supported_color_modes": ["brightness"], + } + } + }, + { + mqtt.DOMAIN: { + light.DOMAIN: { + "schema": "json", + "name": "test", + "state_topic": "test_light_rgb", + "command_topic": "test_light_rgb/set", + "brightness": True, + } + } + }, + ], +) +async def test_brightness_only( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test brightness only light. + + There are two possible configurations for brightness only light: + 1) Set up "brightness" as supported color mode. + 2) Set "brightness" flag to true. + """ + await mqtt_mock_entry() + + state = hass.states.get("light.test") + assert state.state == STATE_UNKNOWN + assert state.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES) == [ + light.ColorMode.BRIGHTNESS + ] + expected_features = ( + light.LightEntityFeature.FLASH | light.LightEntityFeature.TRANSITION + ) + assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == expected_features + assert state.attributes.get("rgb_color") is None + assert state.attributes.get("brightness") is None + assert state.attributes.get("color_temp_kelvin") is None + assert state.attributes.get("effect") is None + assert state.attributes.get("xy_color") is None + assert state.attributes.get("hs_color") is None + + async_fire_mqtt_message(hass, "test_light_rgb", '{"state":"ON", "brightness": 50}') + + state = hass.states.get("light.test") + assert state.state == STATE_ON + assert state.attributes.get("rgb_color") is None + assert state.attributes.get("brightness") == 50 + assert state.attributes.get("color_temp_kelvin") is None + assert state.attributes.get("effect") is None + assert state.attributes.get("xy_color") is None + assert state.attributes.get("hs_color") is None + + async_fire_mqtt_message(hass, "test_light_rgb", '{"state":"OFF"}') + + state = hass.states.get("light.test") + assert state.state == STATE_OFF + + @pytest.mark.parametrize( "hass_config", [ From 9a331de8789fe56382c7ce108d0fa4832a30ef69 Mon Sep 17 00:00:00 2001 From: mvn23 Date: Sat, 1 Mar 2025 19:45:07 +0100 Subject: [PATCH 2014/3148] Remove deprecated import from configuration.yaml from opentherm_gw (#139581) * Remove deprecated import from configuration.yaml in opentherm_gw * Remove tests for removed funcionality from opentherm_gw --- .../components/opentherm_gw/__init__.py | 60 +------------------ .../components/opentherm_gw/strings.json | 6 -- .../opentherm_gw/test_config_flow.py | 24 -------- tests/components/opentherm_gw/test_init.py | 29 +-------- 4 files changed, 2 insertions(+), 117 deletions(-) diff --git a/homeassistant/components/opentherm_gw/__init__.py b/homeassistant/components/opentherm_gw/__init__.py index 8c92c70ab49..f16e9f186be 100644 --- a/homeassistant/components/opentherm_gw/__init__.py +++ b/homeassistant/components/opentherm_gw/__init__.py @@ -10,7 +10,7 @@ from serial import SerialException import voluptuous as vol from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_DATE, ATTR_ID, @@ -21,9 +21,6 @@ from homeassistant.const import ( CONF_ID, CONF_NAME, EVENT_HOMEASSISTANT_STOP, - PRECISION_HALVES, - PRECISION_TENTHS, - PRECISION_WHOLE, Platform, ) from homeassistant.core import HomeAssistant, ServiceCall @@ -32,10 +29,8 @@ from homeassistant.helpers import ( config_validation as cv, device_registry as dr, entity_registry as er, - issue_registry as ir, ) from homeassistant.helpers.dispatcher import async_dispatcher_send -from homeassistant.helpers.typing import ConfigType from .const import ( ATTR_CH_OVRD, @@ -44,9 +39,6 @@ from .const import ( ATTR_LEVEL, ATTR_TRANSP_ARG, ATTR_TRANSP_CMD, - CONF_CLIMATE, - CONF_FLOOR_TEMP, - CONF_PRECISION, CONF_TEMPORARY_OVRD_MODE, CONNECTION_TIMEOUT, DATA_GATEWAYS, @@ -70,29 +62,6 @@ from .const import ( _LOGGER = logging.getLogger(__name__) -# *_SCHEMA required for deprecated import from configuration.yaml, can be removed in 2025.4.0 -CLIMATE_SCHEMA = vol.Schema( - { - vol.Optional(CONF_PRECISION): vol.In( - [PRECISION_TENTHS, PRECISION_HALVES, PRECISION_WHOLE] - ), - vol.Optional(CONF_FLOOR_TEMP, default=False): cv.boolean, - } -) - -CONFIG_SCHEMA = vol.Schema( - { - DOMAIN: cv.schema_with_slug_keys( - { - vol.Required(CONF_DEVICE): cv.string, - vol.Optional(CONF_CLIMATE, default={}): CLIMATE_SCHEMA, - vol.Optional(CONF_NAME): cv.string, - } - ) - }, - extra=vol.ALLOW_EXTRA, -) - PLATFORMS = [ Platform.BINARY_SENSOR, Platform.BUTTON, @@ -164,33 +133,6 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b return True -# Deprecated import from configuration.yaml, can be removed in 2025.4.0 -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the OpenTherm Gateway component.""" - if DOMAIN in config: - ir.async_create_issue( - hass, - DOMAIN, - "deprecated_import_from_configuration_yaml", - breaks_in_ha_version="2025.4.0", - is_fixable=False, - is_persistent=False, - severity=ir.IssueSeverity.WARNING, - translation_key="deprecated_import_from_configuration_yaml", - ) - if not hass.config_entries.async_entries(DOMAIN) and DOMAIN in config: - conf = config[DOMAIN] - for device_id, device_config in conf.items(): - device_config[CONF_ID] = device_id - - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=device_config - ) - ) - return True - - def register_services(hass: HomeAssistant) -> None: """Register services for the component.""" service_reset_schema = vol.Schema( diff --git a/homeassistant/components/opentherm_gw/strings.json b/homeassistant/components/opentherm_gw/strings.json index b49dea4a267..cc57a7d9e0c 100644 --- a/homeassistant/components/opentherm_gw/strings.json +++ b/homeassistant/components/opentherm_gw/strings.json @@ -354,12 +354,6 @@ } } }, - "issues": { - "deprecated_import_from_configuration_yaml": { - "title": "Deprecated configuration", - "description": "Configuration of the OpenTherm Gateway integration through configuration.yaml is deprecated. Your configuration has been migrated to config entries. Please remove any OpenTherm Gateway configuration from your configuration.yaml." - } - }, "options": { "step": { "init": { diff --git a/tests/components/opentherm_gw/test_config_flow.py b/tests/components/opentherm_gw/test_config_flow.py index 57bea4e55dc..99a2dde4acc 100644 --- a/tests/components/opentherm_gw/test_config_flow.py +++ b/tests/components/opentherm_gw/test_config_flow.py @@ -54,30 +54,6 @@ async def test_form_user( assert mock_pyotgw.return_value.disconnect.await_count == 1 -# Deprecated import from configuration.yaml, can be removed in 2025.4.0 -async def test_form_import( - hass: HomeAssistant, - mock_pyotgw: MagicMock, - mock_setup_entry: AsyncMock, -) -> None: - """Test import from existing config.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={CONF_ID: "legacy_gateway", CONF_DEVICE: "/dev/ttyUSB1"}, - ) - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "legacy_gateway" - assert result["data"] == { - CONF_NAME: "legacy_gateway", - CONF_DEVICE: "/dev/ttyUSB1", - CONF_ID: "legacy_gateway", - } - assert mock_pyotgw.return_value.connect.await_count == 1 - assert mock_pyotgw.return_value.disconnect.await_count == 1 - - async def test_form_duplicate_entries( hass: HomeAssistant, mock_pyotgw: MagicMock, diff --git a/tests/components/opentherm_gw/test_init.py b/tests/components/opentherm_gw/test_init.py index 3e85afbf782..4085e25c614 100644 --- a/tests/components/opentherm_gw/test_init.py +++ b/tests/components/opentherm_gw/test_init.py @@ -4,18 +4,13 @@ from unittest.mock import MagicMock from pyotgw.vars import OTGW, OTGW_ABOUT -from homeassistant import setup from homeassistant.components.opentherm_gw.const import ( DOMAIN, OpenThermDeviceIdentifier, ) from homeassistant.const import CONF_ID from homeassistant.core import HomeAssistant -from homeassistant.helpers import ( - device_registry as dr, - entity_registry as er, - issue_registry as ir, -) +from homeassistant.helpers import device_registry as dr, entity_registry as er from .conftest import MOCK_GATEWAY_ID, VERSION_TEST @@ -153,25 +148,3 @@ async def test_climate_entity_migration( updated_entry.unique_id == f"{mock_config_entry.data[CONF_ID]}-{OpenThermDeviceIdentifier.THERMOSTAT}-thermostat_entity" ) - - -# Deprecation test, can be removed in 2025.4.0 -async def test_configuration_yaml_deprecation( - hass: HomeAssistant, - issue_registry: ir.IssueRegistry, - mock_config_entry: MockConfigEntry, - mock_pyotgw: MagicMock, -) -> None: - """Test that existing configuration in configuration.yaml creates an issue.""" - - await setup.async_setup_component( - hass, DOMAIN, {DOMAIN: {"legacy_gateway": {"device": "/dev/null"}}} - ) - - await hass.async_block_till_done() - assert ( - issue_registry.async_get_issue( - DOMAIN, "deprecated_import_from_configuration_yaml" - ) - is not None - ) From 9fe08f292d05e3831a686984db1fc530db37faa5 Mon Sep 17 00:00:00 2001 From: M-A Date: Sat, 1 Mar 2025 13:58:45 -0500 Subject: [PATCH 2015/3148] Bump env_canada to 0.8.0 (#138237) * Bump env_canada to 0.8.0 * Fix requirements*.txt * Grepped more --------- Co-authored-by: Franck Nijhof --- homeassistant/components/environment_canada/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/script/test_gen_requirements_all.py | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/environment_canada/manifest.json b/homeassistant/components/environment_canada/manifest.json index 76534662ff7..fc05e093b33 100644 --- a/homeassistant/components/environment_canada/manifest.json +++ b/homeassistant/components/environment_canada/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/environment_canada", "iot_class": "cloud_polling", "loggers": ["env_canada"], - "requirements": ["env-canada==0.7.2"] + "requirements": ["env-canada==0.8.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index b10d8372466..c052c470bb6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -869,7 +869,7 @@ enocean==0.50 enturclient==0.2.4 # homeassistant.components.environment_canada -env-canada==0.7.2 +env-canada==0.8.0 # homeassistant.components.season ephem==4.1.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a1120979e69..bc5586bf6c7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -738,7 +738,7 @@ energyzero==2.1.1 enocean==0.50 # homeassistant.components.environment_canada -env-canada==0.7.2 +env-canada==0.8.0 # homeassistant.components.season ephem==4.1.6 diff --git a/tests/script/test_gen_requirements_all.py b/tests/script/test_gen_requirements_all.py index 519a5c21855..b667bdd3ddf 100644 --- a/tests/script/test_gen_requirements_all.py +++ b/tests/script/test_gen_requirements_all.py @@ -41,9 +41,9 @@ def test_requirement_override_markers() -> None: ): assert ( gen_requirements_all.process_action_requirement( - "env-canada==0.7.2", "pytest" + "env-canada==0.8.0", "pytest" ) - == "env-canada==0.7.2;python_version<'3.13'" + == "env-canada==0.8.0;python_version<'3.13'" ) assert ( gen_requirements_all.process_action_requirement("other==1.0", "pytest") From ee206938d8062461e005d95d1d7c0eaec67b5272 Mon Sep 17 00:00:00 2001 From: Joris Drenth Date: Sat, 1 Mar 2025 19:59:13 +0100 Subject: [PATCH 2016/3148] Update wallbox to 0.8.0 (#139553) Update Wallbox dependencies --- homeassistant/components/wallbox/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/wallbox/manifest.json b/homeassistant/components/wallbox/manifest.json index 63102646508..d217a018303 100644 --- a/homeassistant/components/wallbox/manifest.json +++ b/homeassistant/components/wallbox/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/wallbox", "iot_class": "cloud_polling", "loggers": ["wallbox"], - "requirements": ["wallbox==0.7.0"] + "requirements": ["wallbox==0.8.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index c052c470bb6..ffbab3d272d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3034,7 +3034,7 @@ vultr==0.1.2 wakeonlan==2.1.0 # homeassistant.components.wallbox -wallbox==0.7.0 +wallbox==0.8.0 # homeassistant.components.folder_watcher watchdog==6.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bc5586bf6c7..2ae0a20caab 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2444,7 +2444,7 @@ vultr==0.1.2 wakeonlan==2.1.0 # homeassistant.components.wallbox -wallbox==0.7.0 +wallbox==0.8.0 # homeassistant.components.folder_watcher watchdog==6.0.0 From d4099ab91732eff26ce56936875cf1a9ec7a0016 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Sat, 1 Mar 2025 20:16:11 +0100 Subject: [PATCH 2017/3148] Bump aiocomelit to 0.11.1 (#139589) --- homeassistant/components/comelit/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/comelit/manifest.json b/homeassistant/components/comelit/manifest.json index 238dede8546..20d481e9a5b 100644 --- a/homeassistant/components/comelit/manifest.json +++ b/homeassistant/components/comelit/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["aiocomelit"], - "requirements": ["aiocomelit==0.10.1"] + "requirements": ["aiocomelit==0.11.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index ffbab3d272d..4c1a15839e7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -213,7 +213,7 @@ aiobafi6==0.9.0 aiobotocore==2.13.1 # homeassistant.components.comelit -aiocomelit==0.10.1 +aiocomelit==0.11.1 # homeassistant.components.dhcp aiodhcpwatcher==1.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2ae0a20caab..a26b276bc02 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -201,7 +201,7 @@ aiobafi6==0.9.0 aiobotocore==2.13.1 # homeassistant.components.comelit -aiocomelit==0.10.1 +aiocomelit==0.11.1 # homeassistant.components.dhcp aiodhcpwatcher==1.1.1 From 4813da33d6841afc27069406c40e32948c664976 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sat, 1 Mar 2025 20:16:32 +0100 Subject: [PATCH 2018/3148] Improve field descriptions of `zha.permit` action (#139584) Make the field descriptions of `source_ieee` and `install_code` UI-friendly by cross-referencing them using their friendly names to allow matching translations. Better explain the alternative of using the `qr_code` field by adding that this contains both the IEEE address and the Install code of the joining device. --- homeassistant/components/zha/strings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zha/strings.json b/homeassistant/components/zha/strings.json index 38f55fb550d..be1642227bd 100644 --- a/homeassistant/components/zha/strings.json +++ b/homeassistant/components/zha/strings.json @@ -274,15 +274,15 @@ }, "source_ieee": { "name": "Source IEEE", - "description": "IEEE address of the joining device (must be used with the install code)." + "description": "IEEE address of the joining device (must be combined with the 'Install code' field)." }, "install_code": { "name": "Install code", - "description": "Install code of the joining device (must be used with the source_ieee)." + "description": "Install code of the joining device (must be combined with the 'Source IEEE' field)." }, "qr_code": { "name": "QR code", - "description": "Value of the QR install code (different between vendors)." + "description": "Provides both the IEEE address and the install code of the joining device (different between vendors)." } } }, From b1a2b89691a3ffc96d9d1899706b3e6c29fee7c2 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Sat, 1 Mar 2025 20:18:52 +0100 Subject: [PATCH 2019/3148] Bump motionblinds to 0.6.26 (#139591) --- homeassistant/components/motion_blinds/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/motion_blinds/manifest.json b/homeassistant/components/motion_blinds/manifest.json index b327c146300..1654d5b5937 100644 --- a/homeassistant/components/motion_blinds/manifest.json +++ b/homeassistant/components/motion_blinds/manifest.json @@ -21,5 +21,5 @@ "documentation": "https://www.home-assistant.io/integrations/motion_blinds", "iot_class": "local_push", "loggers": ["motionblinds"], - "requirements": ["motionblinds==0.6.25"] + "requirements": ["motionblinds==0.6.26"] } diff --git a/requirements_all.txt b/requirements_all.txt index 4c1a15839e7..0554810837f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1432,7 +1432,7 @@ monzopy==1.4.2 mopeka-iot-ble==0.8.0 # homeassistant.components.motion_blinds -motionblinds==0.6.25 +motionblinds==0.6.26 # homeassistant.components.motionblinds_ble motionblindsble==0.1.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a26b276bc02..c86feb62135 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1204,7 +1204,7 @@ monzopy==1.4.2 mopeka-iot-ble==0.8.0 # homeassistant.components.motion_blinds -motionblinds==0.6.25 +motionblinds==0.6.26 # homeassistant.components.motionblinds_ble motionblindsble==0.1.3 From 0c5766184b4b49d72302e31a99578c0a86cb937f Mon Sep 17 00:00:00 2001 From: peteS-UK <64092177+peteS-UK@users.noreply.github.com> Date: Sat, 1 Mar 2025 19:22:34 +0000 Subject: [PATCH 2020/3148] Fix Manufacturer naming for Squeezelite model name for Squeezebox (#139586) Squeezelite Manufacturer Fix --- homeassistant/components/squeezebox/media_player.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/squeezebox/media_player.py b/homeassistant/components/squeezebox/media_player.py index 0cd539b4584..1767d92730a 100644 --- a/homeassistant/components/squeezebox/media_player.py +++ b/homeassistant/components/squeezebox/media_player.py @@ -224,7 +224,7 @@ class SqueezeBoxMediaPlayerEntity( self._previous_media_position = 0 self._attr_unique_id = format_mac(player.player_id) _manufacturer = None - if player.model == "SqueezeLite" or "SqueezePlay" in player.model: + if player.model.startswith("SqueezeLite") or "SqueezePlay" in player.model: _manufacturer = "Ralph Irving" elif ( "Squeezebox" in player.model From 51beb1c0a86881eb9b411e8b1417527da7b39310 Mon Sep 17 00:00:00 2001 From: Trevor Morgan <5444727+clever-trevor@users.noreply.github.com> Date: Sat, 1 Mar 2025 19:26:04 +0000 Subject: [PATCH 2021/3148] Add simplisafe OUTDOOR_ALARM_SECURITY_BELL_BOX device type (#134386) * Update binary_sensor.py to included OUTDOOR_ALARM_SECURITY_BELL_BOX device type Add support for DeviceTypes.OUTDOOR_ALARM_SECURITY_BELL_BOX This is an external siren device in Simplisafe which is not currently discovered with the HA integration * Fixed formatting error --------- Co-authored-by: Franck Nijhof --- homeassistant/components/simplisafe/binary_sensor.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/simplisafe/binary_sensor.py b/homeassistant/components/simplisafe/binary_sensor.py index e1f69ed8113..38a80ddd354 100644 --- a/homeassistant/components/simplisafe/binary_sensor.py +++ b/homeassistant/components/simplisafe/binary_sensor.py @@ -34,6 +34,7 @@ SUPPORTED_BATTERY_SENSOR_TYPES = [ DeviceTypes.PANIC_BUTTON, DeviceTypes.REMOTE, DeviceTypes.SIREN, + DeviceTypes.OUTDOOR_ALARM_SECURITY_BELL_BOX, DeviceTypes.SMOKE, DeviceTypes.SMOKE_AND_CARBON_MONOXIDE, DeviceTypes.TEMPERATURE, @@ -47,6 +48,7 @@ TRIGGERED_SENSOR_TYPES = { DeviceTypes.MOTION: BinarySensorDeviceClass.MOTION, DeviceTypes.MOTION_V2: BinarySensorDeviceClass.MOTION, DeviceTypes.SIREN: BinarySensorDeviceClass.SAFETY, + DeviceTypes.OUTDOOR_ALARM_SECURITY_BELL_BOX: BinarySensorDeviceClass.SAFETY, DeviceTypes.SMOKE: BinarySensorDeviceClass.SMOKE, # Although this sensor can technically apply to both smoke and carbon, we use the # SMOKE device class for simplicity: From b3f14d72c05666d0a450774e603fdaf01d0a22c4 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 1 Mar 2025 20:47:42 +0100 Subject: [PATCH 2022/3148] Don't require not needed scopes in SmartThings (#139576) * Don't require not needed scopes * Don't require not needed scopes --- homeassistant/components/smartthings/const.py | 2 -- .../smartthings/test_config_flow.py | 33 +++++++------------ 2 files changed, 12 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/smartthings/const.py b/homeassistant/components/smartthings/const.py index c39d225dd09..80c4cf90226 100644 --- a/homeassistant/components/smartthings/const.py +++ b/homeassistant/components/smartthings/const.py @@ -14,8 +14,6 @@ SCOPES = [ "x:scenes:*", "r:rules:*", "w:rules:*", - "r:installedapps", - "w:installedapps", "sse", ] diff --git a/tests/components/smartthings/test_config_flow.py b/tests/components/smartthings/test_config_flow.py index 61e2b464920..2fbd686e4d3 100644 --- a/tests/components/smartthings/test_config_flow.py +++ b/tests/components/smartthings/test_config_flow.py @@ -57,7 +57,7 @@ async def test_full_flow( f"&state={state}" "&scope=r:devices:*+w:devices:*+x:devices:*+r:hubs:*+" "r:locations:*+w:locations:*+x:locations:*+r:scenes:*+" - "x:scenes:*+r:rules:*+w:rules:*+r:installedapps+w:installedapps+sse" + "x:scenes:*+r:rules:*+w:rules:*+sse" ) client = await hass_client_no_auth() @@ -75,8 +75,7 @@ async def test_full_flow( "expires_in": 82806, "scope": "r:devices:* w:devices:* x:devices:* r:hubs:* " "r:locations:* w:locations:* x:locations:* " - "r:scenes:* x:scenes:* r:rules:* w:rules:* " - "r:installedapps w:installedapps sse", + "r:scenes:* x:scenes:* r:rules:* w:rules:* sse", "access_tier": 0, "installed_app_id": "5aaaa925-2be1-4e40-b257-e4ef59083324", }, @@ -93,8 +92,7 @@ async def test_full_flow( "expires_in": 82806, "scope": "r:devices:* w:devices:* x:devices:* r:hubs:* " "r:locations:* w:locations:* x:locations:* " - "r:scenes:* x:scenes:* r:rules:* w:rules:* " - "r:installedapps w:installedapps sse", + "r:scenes:* x:scenes:* r:rules:* w:rules:* sse", "access_tier": 0, "installed_app_id": "5aaaa925-2be1-4e40-b257-e4ef59083324", } @@ -130,7 +128,7 @@ async def test_not_enough_scopes( f"&state={state}" "&scope=r:devices:*+w:devices:*+x:devices:*+r:hubs:*+" "r:locations:*+w:locations:*+x:locations:*+r:scenes:*+" - "x:scenes:*+r:rules:*+w:rules:*+r:installedapps+w:installedapps+sse" + "x:scenes:*+r:rules:*+w:rules:*+sse" ) client = await hass_client_no_auth() @@ -192,7 +190,7 @@ async def test_duplicate_entry( f"&state={state}" "&scope=r:devices:*+w:devices:*+x:devices:*+r:hubs:*+" "r:locations:*+w:locations:*+x:locations:*+r:scenes:*+" - "x:scenes:*+r:rules:*+w:rules:*+r:installedapps+w:installedapps+sse" + "x:scenes:*+r:rules:*+w:rules:*+sse" ) client = await hass_client_no_auth() @@ -210,8 +208,7 @@ async def test_duplicate_entry( "expires_in": 82806, "scope": "r:devices:* w:devices:* x:devices:* r:hubs:* " "r:locations:* w:locations:* x:locations:* " - "r:scenes:* x:scenes:* r:rules:* w:rules:* " - "r:installedapps w:installedapps sse", + "r:scenes:* x:scenes:* r:rules:* w:rules:* sse", "access_tier": 0, "installed_app_id": "5aaaa925-2be1-4e40-b257-e4ef59083324", }, @@ -261,8 +258,7 @@ async def test_reauthentication( "expires_in": 82806, "scope": "r:devices:* w:devices:* x:devices:* r:hubs:* " "r:locations:* w:locations:* x:locations:* " - "r:scenes:* x:scenes:* r:rules:* w:rules:* " - "r:installedapps w:installedapps sse", + "r:scenes:* x:scenes:* r:rules:* w:rules:* sse", "access_tier": 0, "installed_app_id": "5aaaa925-2be1-4e40-b257-e4ef59083324", }, @@ -280,8 +276,7 @@ async def test_reauthentication( "expires_in": 82806, "scope": "r:devices:* w:devices:* x:devices:* r:hubs:* " "r:locations:* w:locations:* x:locations:* " - "r:scenes:* x:scenes:* r:rules:* w:rules:* " - "r:installedapps w:installedapps sse", + "r:scenes:* x:scenes:* r:rules:* w:rules:* sse", "access_tier": 0, "installed_app_id": "5aaaa925-2be1-4e40-b257-e4ef59083324", } @@ -377,8 +372,7 @@ async def test_reauth_account_mismatch( "expires_in": 82806, "scope": "r:devices:* w:devices:* x:devices:* r:hubs:* " "r:locations:* w:locations:* x:locations:* " - "r:scenes:* x:scenes:* r:rules:* w:rules:* " - "r:installedapps w:installedapps sse", + "r:scenes:* x:scenes:* r:rules:* w:rules:* sse", "access_tier": 0, "installed_app_id": "123123123-2be1-4e40-b257-e4ef59083324", }, @@ -429,8 +423,7 @@ async def test_migration( "expires_in": 82806, "scope": "r:devices:* w:devices:* x:devices:* r:hubs:* " "r:locations:* w:locations:* x:locations:* " - "r:scenes:* x:scenes:* r:rules:* w:rules:* " - "r:installedapps w:installedapps sse", + "r:scenes:* x:scenes:* r:rules:* w:rules:* sse", "access_tier": 0, "installed_app_id": "123123123-2be1-4e40-b257-e4ef59083324", }, @@ -461,8 +454,7 @@ async def test_migration( "expires_in": 82806, "scope": "r:devices:* w:devices:* x:devices:* r:hubs:* " "r:locations:* w:locations:* x:locations:* " - "r:scenes:* x:scenes:* r:rules:* w:rules:* " - "r:installedapps w:installedapps sse", + "r:scenes:* x:scenes:* r:rules:* w:rules:* sse", "access_tier": 0, "installed_app_id": "123123123-2be1-4e40-b257-e4ef59083324", }, @@ -516,8 +508,7 @@ async def test_migration_wrong_location( "expires_in": 82806, "scope": "r:devices:* w:devices:* x:devices:* r:hubs:* " "r:locations:* w:locations:* x:locations:* " - "r:scenes:* x:scenes:* r:rules:* w:rules:* " - "r:installedapps w:installedapps sse", + "r:scenes:* x:scenes:* r:rules:* w:rules:* sse", "access_tier": 0, "installed_app_id": "123123123-2be1-4e40-b257-e4ef59083324", }, From dd21d48ae48c010b5dbb0468f872ce11c176b723 Mon Sep 17 00:00:00 2001 From: Markus Adrario Date: Sat, 1 Mar 2025 20:53:06 +0100 Subject: [PATCH 2023/3148] Homee: fix watchdog icon (#139577) fix watchdog icon --- homeassistant/components/homee/icons.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/homee/icons.json b/homeassistant/components/homee/icons.json index 07ae598095b..17ac0ecd1f2 100644 --- a/homeassistant/components/homee/icons.json +++ b/homeassistant/components/homee/icons.json @@ -9,7 +9,7 @@ } }, "switch": { - "watchdog_on_off": { + "watchdog": { "default": "mdi:dog" }, "manual_operation": { From 913a4ee9ba38a5cede4b9dd94af47907a11521bb Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sat, 1 Mar 2025 21:14:08 +0100 Subject: [PATCH 2024/3148] Improve certificate handling in MQTT config flow (#137234) * Improve mqtt broker certificate handling in config flow * Expand test cases --- homeassistant/components/mqtt/config_flow.py | 146 ++++++++- homeassistant/components/mqtt/strings.json | 8 +- tests/components/mqtt/test_config_flow.py | 296 +++++++++++++++++-- 3 files changed, 415 insertions(+), 35 deletions(-) diff --git a/homeassistant/components/mqtt/config_flow.py b/homeassistant/components/mqtt/config_flow.py index 22568b0f2b8..ad188c50aa9 100644 --- a/homeassistant/components/mqtt/config_flow.py +++ b/homeassistant/components/mqtt/config_flow.py @@ -5,14 +5,21 @@ from __future__ import annotations import asyncio from collections import OrderedDict from collections.abc import Callable, Mapping +from enum import IntEnum import logging import queue from ssl import PROTOCOL_TLS_CLIENT, SSLContext, SSLError from types import MappingProxyType from typing import TYPE_CHECKING, Any -from cryptography.hazmat.primitives.serialization import load_pem_private_key -from cryptography.x509 import load_pem_x509_certificate +from cryptography.hazmat.primitives.serialization import ( + Encoding, + NoEncryption, + PrivateFormat, + load_der_private_key, + load_pem_private_key, +) +from cryptography.x509 import load_der_x509_certificate, load_pem_x509_certificate import voluptuous as vol from homeassistant.components.file_upload import process_uploaded_file @@ -105,6 +112,8 @@ _LOGGER = logging.getLogger(__name__) ADDON_SETUP_TIMEOUT = 5 ADDON_SETUP_TIMEOUT_ROUNDS = 5 +CONF_CLIENT_KEY_PASSWORD = "client_key_password" + MQTT_TIMEOUT = 5 ADVANCED_OPTIONS = "advanced_options" @@ -165,12 +174,14 @@ BROKER_VERIFICATION_SELECTOR = SelectSelector( # mime configuration from https://pki-tutorial.readthedocs.io/en/latest/mime.html CA_CERT_UPLOAD_SELECTOR = FileSelector( - FileSelectorConfig(accept=".crt,application/x-x509-ca-cert") + FileSelectorConfig(accept=".pem,.crt,.cer,.der,application/x-x509-ca-cert") ) CERT_UPLOAD_SELECTOR = FileSelector( - FileSelectorConfig(accept=".crt,application/x-x509-user-cert") + FileSelectorConfig(accept=".pem,.crt,.cer,.der,application/x-x509-user-cert") +) +KEY_UPLOAD_SELECTOR = FileSelector( + FileSelectorConfig(accept=".pem,.key,.der,.pk8,application/pkcs8") ) -KEY_UPLOAD_SELECTOR = FileSelector(FileSelectorConfig(accept=".key,application/pkcs8")) REAUTH_SCHEMA = vol.Schema( { @@ -710,17 +721,88 @@ class MQTTOptionsFlowHandler(OptionsFlow): ) -async def _get_uploaded_file(hass: HomeAssistant, id: str) -> str: - """Get file content from uploaded file.""" +@callback +def async_is_pem_data(data: bytes) -> bool: + """Return True if data is in PEM format.""" + return ( + b"-----BEGIN CERTIFICATE-----" in data + or b"-----BEGIN PRIVATE KEY-----" in data + or b"-----BEGIN RSA PRIVATE KEY-----" in data + or b"-----BEGIN ENCRYPTED PRIVATE KEY-----" in data + ) - def _proces_uploaded_file() -> str: + +class PEMType(IntEnum): + """Type of PEM data.""" + + CERTIFICATE = 1 + PRIVATE_KEY = 2 + + +@callback +def async_convert_to_pem( + data: bytes, pem_type: PEMType, password: str | None = None +) -> str | None: + """Convert data to PEM format.""" + try: + if async_is_pem_data(data): + if not password: + # Assume unencrypted PEM encoded private key + return data.decode(DEFAULT_ENCODING) + # Return decrypted PEM encoded private key + return ( + load_pem_private_key(data, password=password.encode(DEFAULT_ENCODING)) + .private_bytes( + encoding=Encoding.PEM, + format=PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=NoEncryption(), + ) + .decode(DEFAULT_ENCODING) + ) + # Convert from DER encoding to PEM + if pem_type == PEMType.CERTIFICATE: + return ( + load_der_x509_certificate(data) + .public_bytes( + encoding=Encoding.PEM, + ) + .decode(DEFAULT_ENCODING) + ) + # Assume DER encoded private key + pem_key_data: bytes = load_der_private_key( + data, password.encode(DEFAULT_ENCODING) if password else None + ).private_bytes( + encoding=Encoding.PEM, + format=PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=NoEncryption(), + ) + return pem_key_data.decode("utf-8") + except (TypeError, ValueError, SSLError): + _LOGGER.exception("Error converting %s file data to PEM format", pem_type.name) + return None + + +async def _get_uploaded_file(hass: HomeAssistant, id: str) -> bytes: + """Get file content from uploaded certificate or key file.""" + + def _proces_uploaded_file() -> bytes: with process_uploaded_file(hass, id) as file_path: - return file_path.read_text(encoding=DEFAULT_ENCODING) + return file_path.read_bytes() return await hass.async_add_executor_job(_proces_uploaded_file) -async def async_get_broker_settings( +def _validate_pki_file( + file_id: str | None, pem_data: str | None, errors: dict[str, str], error: str +) -> bool: + """Return False if uploaded file could not be converted to PEM format.""" + if file_id and not pem_data: + errors["base"] = error + return False + return True + + +async def async_get_broker_settings( # noqa: C901 flow: ConfigFlow | OptionsFlow, fields: OrderedDict[Any, Any], entry_config: MappingProxyType[str, Any] | None, @@ -768,6 +850,10 @@ async def async_get_broker_settings( validated_user_input.update(user_input) client_certificate_id: str | None = user_input.get(CONF_CLIENT_CERT) client_key_id: str | None = user_input.get(CONF_CLIENT_KEY) + # We do not store the private key password in the entry data + client_key_password: str | None = validated_user_input.pop( + CONF_CLIENT_KEY_PASSWORD, None + ) if (client_certificate_id and not client_key_id) or ( not client_certificate_id and client_key_id ): @@ -775,7 +861,14 @@ async def async_get_broker_settings( return False certificate_id: str | None = user_input.get(CONF_CERTIFICATE) if certificate_id: - certificate = await _get_uploaded_file(hass, certificate_id) + certificate_data_raw = await _get_uploaded_file(hass, certificate_id) + certificate = async_convert_to_pem( + certificate_data_raw, PEMType.CERTIFICATE + ) + if not _validate_pki_file( + certificate_id, certificate, errors, "bad_certificate" + ): + return False # Return to form for file upload CA cert or client cert and key if ( @@ -797,9 +890,26 @@ async def async_get_broker_settings( return False if client_certificate_id: - client_certificate = await _get_uploaded_file(hass, client_certificate_id) + client_certificate_data = await _get_uploaded_file( + hass, client_certificate_id + ) + client_certificate = async_convert_to_pem( + client_certificate_data, PEMType.CERTIFICATE + ) + if not _validate_pki_file( + client_certificate_id, client_certificate, errors, "bad_client_cert" + ): + return False + if client_key_id: - client_key = await _get_uploaded_file(hass, client_key_id) + client_key_data = await _get_uploaded_file(hass, client_key_id) + client_key = async_convert_to_pem( + client_key_data, PEMType.PRIVATE_KEY, password=client_key_password + ) + if not _validate_pki_file( + client_key_id, client_key, errors, "client_key_error" + ): + return False certificate_data: dict[str, Any] = {} if certificate: @@ -956,6 +1066,14 @@ async def async_get_broker_settings( description={"suggested_value": user_input_basic.get(CONF_CLIENT_KEY)}, ) ] = KEY_UPLOAD_SELECTOR + fields[ + vol.Optional( + CONF_CLIENT_KEY_PASSWORD, + description={ + "suggested_value": user_input_basic.get(CONF_CLIENT_KEY_PASSWORD) + }, + ) + ] = PASSWORD_SELECTOR verification_mode = current_config.get(SET_CA_CERT) or ( "off" if current_ca_certificate is None @@ -1060,7 +1178,7 @@ def check_certicate_chain() -> str | None: with open(private_key, "rb") as client_key_file: load_pem_private_key(client_key_file.read(), password=None) except (TypeError, ValueError): - return "bad_client_key" + return "client_key_error" # Check the certificate chain context = SSLContext(PROTOCOL_TLS_CLIENT) if client_certificate and private_key: diff --git a/homeassistant/components/mqtt/strings.json b/homeassistant/components/mqtt/strings.json index fc316306d56..8805f447d69 100644 --- a/homeassistant/components/mqtt/strings.json +++ b/homeassistant/components/mqtt/strings.json @@ -26,6 +26,7 @@ "client_id": "Client ID (leave empty to randomly generated one)", "client_cert": "Upload client certificate file", "client_key": "Upload private key file", + "client_key_password": "[%key:common::config_flow::data::password%]", "keepalive": "The time between sending keep alive messages", "tls_insecure": "Ignore broker certificate validation", "protocol": "MQTT protocol", @@ -45,6 +46,7 @@ "client_id": "The unique ID to identify the Home Assistant MQTT API as MQTT client. It is recommended to leave this option blank.", "client_cert": "The client certificate to authenticate against your MQTT broker.", "client_key": "The private key file that belongs to your client certificate.", + "client_key_password": "The password for the private key file (if set).", "keepalive": "A value less than 90 seconds is advised.", "tls_insecure": "Option to ignore validation of your MQTT broker's certificate.", "protocol": "The MQTT protocol your broker operates at. For example 3.1.1.", @@ -93,8 +95,8 @@ "bad_will": "Invalid will topic", "bad_discovery_prefix": "Invalid discovery prefix", "bad_certificate": "The CA certificate is invalid", - "bad_client_cert": "Invalid client certificate, ensure a PEM coded file is supplied", - "bad_client_key": "Invalid private key, ensure a PEM coded file is supplied without password", + "bad_client_cert": "Invalid client certificate, ensure a valid file is supplied", + "client_key_error": "Invalid private key file or invalid password supplied", "bad_client_cert_key": "Client certificate and private key are not a valid pair", "bad_ws_headers": "Supply valid HTTP headers as a JSON object", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", @@ -207,7 +209,7 @@ "bad_discovery_prefix": "[%key:component::mqtt::config::error::bad_discovery_prefix%]", "bad_certificate": "[%key:component::mqtt::config::error::bad_certificate%]", "bad_client_cert": "[%key:component::mqtt::config::error::bad_client_cert%]", - "bad_client_key": "[%key:component::mqtt::config::error::bad_client_key%]", + "client_key_error": "[%key:component::mqtt::config::error::client_key_error%]", "bad_client_cert_key": "[%key:component::mqtt::config::error::bad_client_cert_key%]", "bad_ws_headers": "[%key:component::mqtt::config::error::bad_ws_headers%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", diff --git a/tests/components/mqtt/test_config_flow.py b/tests/components/mqtt/test_config_flow.py index de70fd32763..f39e32a0d8b 100644 --- a/tests/components/mqtt/test_config_flow.py +++ b/tests/components/mqtt/test_config_flow.py @@ -40,8 +40,37 @@ ADD_ON_DISCOVERY_INFO = { "protocol": "3.1.1", "ssl": False, } -MOCK_CLIENT_CERT = b"## mock client certificate file ##" -MOCK_CLIENT_KEY = b"## mock key file ##" + +MOCK_CA_CERT = ( + b"-----BEGIN CERTIFICATE-----\n" + b"## mock CA certificate file ##" + b"\n-----END CERTIFICATE-----\n" +) +MOCK_GENERIC_CERT = ( + b"-----BEGIN CERTIFICATE-----\n" + b"## mock generic certificate file ##" + b"\n-----END CERTIFICATE-----\n" +) +MOCK_CA_CERT_DER = b"## mock DER formatted CA certificate file ##\n" +MOCK_CLIENT_CERT = ( + b"-----BEGIN CERTIFICATE-----\n" + b"## mock client certificate file ##" + b"\n-----END CERTIFICATE-----\n" +) +MOCK_CLIENT_CERT_DER = b"## mock DER formatted client certificate file ##\n" +MOCK_CLIENT_KEY = ( + b"-----BEGIN PRIVATE KEY-----\n" + b"## mock client key file ##" + b"\n-----END PRIVATE KEY-----" +) +MOCK_ENCRYPTED_CLIENT_KEY = ( + b"-----BEGIN ENCRYPTED PRIVATE KEY-----\n" + b"## mock client key file ##\n" + b"-----END ENCRYPTED PRIVATE KEY-----" +) +MOCK_CLIENT_KEY_DER = b"## mock DER formatted key file ##\n" +MOCK_ENCRYPTED_CLIENT_KEY_DER = b"## mock DER formatted encrypted key file ##\n" + MOCK_ENTRY_DATA = { mqtt.CONF_BROKER: "test-broker", @@ -102,15 +131,27 @@ def mock_ssl_context() -> Generator[dict[str, MagicMock]]: patch("homeassistant.components.mqtt.config_flow.SSLContext") as mock_context, patch( "homeassistant.components.mqtt.config_flow.load_pem_private_key" - ) as mock_key_check, + ) as mock_pem_key_check, + patch( + "homeassistant.components.mqtt.config_flow.load_der_private_key" + ) as mock_der_key_check, patch( "homeassistant.components.mqtt.config_flow.load_pem_x509_certificate" - ) as mock_cert_check, + ) as mock_pem_cert_check, + patch( + "homeassistant.components.mqtt.config_flow.load_der_x509_certificate" + ) as mock_der_cert_check, ): + mock_pem_key_check().private_bytes.return_value = MOCK_CLIENT_KEY + mock_pem_cert_check().public_bytes.return_value = MOCK_GENERIC_CERT + mock_der_key_check().private_bytes.return_value = MOCK_CLIENT_KEY + mock_der_cert_check().public_bytes.return_value = MOCK_GENERIC_CERT yield { "context": mock_context, - "load_pem_x509_certificate": mock_cert_check, - "load_pem_private_key": mock_key_check, + "load_der_private_key": mock_der_key_check, + "load_der_x509_certificate": mock_der_cert_check, + "load_pem_private_key": mock_pem_key_check, + "load_pem_x509_certificate": mock_pem_cert_check, } @@ -180,9 +221,31 @@ def mock_try_connection_time_out() -> Generator[MagicMock]: yield mock_client() +@pytest.fixture +def mock_ca_cert() -> bytes: + """Mock the CA certificate.""" + return MOCK_CA_CERT + + +@pytest.fixture +def mock_client_cert() -> bytes: + """Mock the client certificate.""" + return MOCK_CLIENT_CERT + + +@pytest.fixture +def mock_client_key() -> bytes: + """Mock the client key.""" + return MOCK_CLIENT_KEY + + @pytest.fixture def mock_process_uploaded_file( - tmp_path: Path, mock_temp_dir: str + tmp_path: Path, + mock_ca_cert: bytes, + mock_client_cert: bytes, + mock_client_key: bytes, + mock_temp_dir: str, ) -> Generator[MagicMock]: """Mock upload certificate files.""" file_id_ca = str(uuid4()) @@ -195,15 +258,15 @@ def mock_process_uploaded_file( ) -> Iterator[Path | None]: if file_id == file_id_ca: with open(tmp_path / "ca.crt", "wb") as cafile: - cafile.write(b"## mock CA certificate file ##") + cafile.write(mock_ca_cert) yield tmp_path / "ca.crt" elif file_id == file_id_cert: with open(tmp_path / "client.crt", "wb") as certfile: - certfile.write(b"## mock client certificate file ##") + certfile.write(mock_client_cert) yield tmp_path / "client.crt" elif file_id == file_id_key: with open(tmp_path / "client.key", "wb") as keyfile: - keyfile.write(b"## mock key file ##") + keyfile.write(mock_client_key) yield tmp_path / "client.key" else: pytest.fail(f"Unexpected file_id: {file_id}") @@ -1024,12 +1087,37 @@ async def test_option_flow( assert yaml_mock.await_count +@pytest.mark.parametrize( + ("mock_ca_cert", "mock_client_cert", "mock_client_key", "client_key_password"), + [ + (MOCK_GENERIC_CERT, MOCK_GENERIC_CERT, MOCK_CLIENT_KEY, ""), + ( + MOCK_GENERIC_CERT, + MOCK_GENERIC_CERT, + MOCK_ENCRYPTED_CLIENT_KEY, + "very*secret", + ), + (MOCK_CA_CERT_DER, MOCK_CLIENT_CERT_DER, MOCK_CLIENT_KEY_DER, ""), + ( + MOCK_CA_CERT_DER, + MOCK_CLIENT_CERT_DER, + MOCK_ENCRYPTED_CLIENT_KEY_DER, + "very*secret", + ), + ], + ids=[ + "pem_certs_private_key_no_password", + "pem_certs_private_key_with_password", + "der_certs_private_key_no_password", + "der_certs_private_key_with_password", + ], +) @pytest.mark.parametrize( "test_error", [ "bad_certificate", "bad_client_cert", - "bad_client_key", + "client_key_error", "bad_client_cert_key", "invalid_inclusion", None, @@ -1042,31 +1130,54 @@ async def test_bad_certificate( mock_ssl_context: dict[str, MagicMock], mock_process_uploaded_file: MagicMock, test_error: str | None, + client_key_password: str, + mock_ca_cert: bytes, ) -> None: """Test bad certificate tests.""" + + def _side_effect_on_client_cert(data: bytes) -> MagicMock: + """Raise on client cert only. + + The function is called twice, once for the CA chain + and once for the client cert. We only want to raise on a client cert. + """ + if data == MOCK_CLIENT_CERT_DER: + raise ValueError + mock_certificate_side_effect = MagicMock() + mock_certificate_side_effect().public_bytes.return_value = MOCK_GENERIC_CERT + return mock_certificate_side_effect + # Mock certificate files file_id = mock_process_uploaded_file.file_id + set_ca_cert = "custom" + set_client_cert = True + tls_insecure = False test_input = { mqtt.CONF_BROKER: "another-broker", CONF_PORT: 2345, mqtt.CONF_CERTIFICATE: file_id[mqtt.CONF_CERTIFICATE], mqtt.CONF_CLIENT_CERT: file_id[mqtt.CONF_CLIENT_CERT], mqtt.CONF_CLIENT_KEY: file_id[mqtt.CONF_CLIENT_KEY], - "set_ca_cert": True, + "client_key_password": client_key_password, + "set_ca_cert": set_ca_cert, "set_client_cert": True, } - set_client_cert = True - set_ca_cert = "custom" - tls_insecure = False if test_error == "bad_certificate": # CA chain is not loading mock_ssl_context["context"]().load_verify_locations.side_effect = SSLError + # Fail on the CA cert if DER encoded + mock_ssl_context["load_der_x509_certificate"].side_effect = ValueError elif test_error == "bad_client_cert": # Client certificate is invalid mock_ssl_context["load_pem_x509_certificate"].side_effect = ValueError - elif test_error == "bad_client_key": + # Fail on the client cert if DER encoded + mock_ssl_context[ + "load_der_x509_certificate" + ].side_effect = _side_effect_on_client_cert + elif test_error == "client_key_error": # Client key file is invalid mock_ssl_context["load_pem_private_key"].side_effect = ValueError + mock_ssl_context["load_der_private_key"].side_effect = ValueError elif test_error == "bad_client_cert_key": # Client key file file and certificate do not pair mock_ssl_context["context"]().load_cert_chain.side_effect = SSLError @@ -2078,8 +2189,8 @@ async def test_setup_with_advanced_settings( CONF_USERNAME: "user", CONF_PASSWORD: "secret", mqtt.CONF_KEEPALIVE: 30, - mqtt.CONF_CLIENT_CERT: "## mock client certificate file ##", - mqtt.CONF_CLIENT_KEY: "## mock key file ##", + mqtt.CONF_CLIENT_CERT: MOCK_CLIENT_CERT.decode(encoding="utf-8"), + mqtt.CONF_CLIENT_KEY: MOCK_CLIENT_KEY.decode(encoding="utf-8"), "tls_insecure": True, mqtt.CONF_TRANSPORT: "websockets", mqtt.CONF_WS_PATH: "/custom_path/", @@ -2091,6 +2202,155 @@ async def test_setup_with_advanced_settings( } +@pytest.mark.usefixtures("mock_ssl_context") +@pytest.mark.parametrize( + ("mock_ca_cert", "mock_client_cert", "mock_client_key", "client_key_password"), + [ + (MOCK_GENERIC_CERT, MOCK_GENERIC_CERT, MOCK_CLIENT_KEY, ""), + ( + MOCK_GENERIC_CERT, + MOCK_GENERIC_CERT, + MOCK_ENCRYPTED_CLIENT_KEY, + "very*secret", + ), + (MOCK_CA_CERT_DER, MOCK_CLIENT_CERT_DER, MOCK_CLIENT_KEY_DER, ""), + ( + MOCK_CA_CERT_DER, + MOCK_CLIENT_CERT_DER, + MOCK_ENCRYPTED_CLIENT_KEY_DER, + "very*secret", + ), + ], + ids=[ + "pem_certs_private_key_no_password", + "pem_certs_private_key_with_password", + "der_certs_private_key_no_password", + "der_certs_private_key_with_password", + ], +) +async def test_setup_with_certificates( + hass: HomeAssistant, + mock_try_connection: MagicMock, + mock_process_uploaded_file: MagicMock, + client_key_password: str, +) -> None: + """Test config flow setup with PEM and DER encoded certificates.""" + file_id = mock_process_uploaded_file.file_id + + config_entry = MockConfigEntry( + domain=mqtt.DOMAIN, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, + ) + config_entry.add_to_hass(hass) + hass.config_entries.async_update_entry( + config_entry, + data={ + mqtt.CONF_BROKER: "test-broker", + CONF_PORT: 1234, + }, + ) + + mock_try_connection.return_value = True + + result = await config_entry.start_reconfigure_flow(hass, show_advanced_options=True) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "broker" + assert result["data_schema"].schema["advanced_options"] + + # first iteration, basic settings + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + mqtt.CONF_BROKER: "test-broker", + CONF_PORT: 2345, + CONF_USERNAME: "user", + CONF_PASSWORD: "secret", + "advanced_options": True, + }, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "broker" + assert "advanced_options" not in result["data_schema"].schema + assert result["data_schema"].schema[CONF_CLIENT_ID] + assert result["data_schema"].schema[mqtt.CONF_KEEPALIVE] + assert result["data_schema"].schema["set_client_cert"] + assert result["data_schema"].schema["set_ca_cert"] + assert result["data_schema"].schema[mqtt.CONF_TLS_INSECURE] + assert result["data_schema"].schema[CONF_PROTOCOL] + assert result["data_schema"].schema[mqtt.CONF_TRANSPORT] + assert mqtt.CONF_CLIENT_CERT not in result["data_schema"].schema + assert mqtt.CONF_CLIENT_KEY not in result["data_schema"].schema + + # second iteration, advanced settings with request for client cert + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + mqtt.CONF_BROKER: "test-broker", + CONF_PORT: 2345, + CONF_USERNAME: "user", + CONF_PASSWORD: "secret", + mqtt.CONF_KEEPALIVE: 30, + "set_ca_cert": "custom", + "set_client_cert": True, + mqtt.CONF_TLS_INSECURE: False, + CONF_PROTOCOL: "3.1.1", + mqtt.CONF_TRANSPORT: "tcp", + }, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "broker" + assert "advanced_options" not in result["data_schema"].schema + assert result["data_schema"].schema[CONF_CLIENT_ID] + assert result["data_schema"].schema[mqtt.CONF_KEEPALIVE] + assert result["data_schema"].schema["set_client_cert"] + assert result["data_schema"].schema["set_ca_cert"] + assert result["data_schema"].schema["client_key_password"] + assert result["data_schema"].schema[mqtt.CONF_TLS_INSECURE] + assert result["data_schema"].schema[CONF_PROTOCOL] + assert result["data_schema"].schema[mqtt.CONF_CERTIFICATE] + assert result["data_schema"].schema[mqtt.CONF_CLIENT_CERT] + assert result["data_schema"].schema[mqtt.CONF_CLIENT_KEY] + assert result["data_schema"].schema[mqtt.CONF_TRANSPORT] + + # third iteration, advanced settings with client cert and key and CA certificate + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + mqtt.CONF_BROKER: "test-broker", + CONF_PORT: 2345, + CONF_USERNAME: "user", + CONF_PASSWORD: "secret", + mqtt.CONF_KEEPALIVE: 30, + "set_ca_cert": "custom", + "set_client_cert": True, + "client_key_password": client_key_password, + mqtt.CONF_CERTIFICATE: file_id[mqtt.CONF_CERTIFICATE], + mqtt.CONF_CLIENT_CERT: file_id[mqtt.CONF_CLIENT_CERT], + mqtt.CONF_CLIENT_KEY: file_id[mqtt.CONF_CLIENT_KEY], + mqtt.CONF_TLS_INSECURE: False, + mqtt.CONF_TRANSPORT: "tcp", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + # Check config entry result + assert config_entry.data == { + mqtt.CONF_BROKER: "test-broker", + CONF_PORT: 2345, + CONF_USERNAME: "user", + CONF_PASSWORD: "secret", + mqtt.CONF_KEEPALIVE: 30, + mqtt.CONF_CLIENT_CERT: MOCK_GENERIC_CERT.decode(encoding="utf-8"), + mqtt.CONF_CLIENT_KEY: MOCK_CLIENT_KEY.decode(encoding="utf-8"), + "tls_insecure": False, + mqtt.CONF_TRANSPORT: "tcp", + mqtt.CONF_CERTIFICATE: MOCK_GENERIC_CERT.decode(encoding="utf-8"), + } + + @pytest.mark.usefixtures("mock_ssl_context", "mock_process_uploaded_file") async def test_change_websockets_transport_to_tcp( hass: HomeAssistant, mock_try_connection: MagicMock From c1686953239e4f18819c45b59aafe1d92d411236 Mon Sep 17 00:00:00 2001 From: peteS-UK <64092177+peteS-UK@users.noreply.github.com> Date: Sat, 1 Mar 2025 20:18:30 +0000 Subject: [PATCH 2025/3148] Clean up squeezebox build_item_response part 1 (#139321) * initial * final * is internal change * test data coverage * Review fixes * final --- .../components/squeezebox/browse_media.py | 153 +++++++++++------- tests/components/squeezebox/conftest.py | 2 +- 2 files changed, 93 insertions(+), 62 deletions(-) diff --git a/homeassistant/components/squeezebox/browse_media.py b/homeassistant/components/squeezebox/browse_media.py index 6bc1d2380cf..82fa55c7b2f 100644 --- a/homeassistant/components/squeezebox/browse_media.py +++ b/homeassistant/components/squeezebox/browse_media.py @@ -138,6 +138,8 @@ class BrowseItemResponse: child_media_class: dict[str, MediaClass | None] can_expand: bool can_play: bool + title: str + id: str def _add_new_command_to_browse_data( @@ -154,11 +156,12 @@ def _add_new_command_to_browse_data( def _build_response_apps_radios_category( - browse_data: BrowseData, - cmd: str | MediaType, + browse_data: BrowseData, cmd: str | MediaType, item: dict[str, Any] ) -> BrowseItemResponse: """Build item for App or radio category.""" return BrowseItemResponse( + id=item.get("id", ""), + title=item["title"], child_item_type=cmd, child_media_class=browse_data.content_type_media_class[cmd], can_expand=True, @@ -172,6 +175,8 @@ def _build_response_known_app( """Build item for app or radio.""" return BrowseItemResponse( + id=item.get("id", ""), + title=item["title"], child_item_type=search_type, child_media_class=browse_data.content_type_media_class[search_type], can_play=bool(item["isaudio"] and item.get("url")), @@ -179,6 +184,61 @@ def _build_response_known_app( ) +def _build_response_favorites(item: dict[str, Any]) -> BrowseItemResponse: + """Build item for Favorites.""" + if "album_id" in item: + return BrowseItemResponse( + id=str(item["album_id"]), + title=item["title"], + child_item_type=MediaType.ALBUM, + child_media_class=CONTENT_TYPE_MEDIA_CLASS[MediaType.ALBUM], + can_expand=True, + can_play=True, + ) + if item["hasitems"] and not item["isaudio"]: + return BrowseItemResponse( + id=item.get("id", ""), + title=item["title"], + child_item_type="Favorites", + child_media_class=CONTENT_TYPE_MEDIA_CLASS["Favorites"], + can_expand=True, + can_play=False, + ) + return BrowseItemResponse( + id=item.get("id", ""), + title=item["title"], + child_item_type="Favorites", + child_media_class=CONTENT_TYPE_MEDIA_CLASS[MediaType.TRACK], + can_expand=item["hasitems"], + can_play=bool(item["isaudio"] and item.get("url")), + ) + + +def _get_item_thumbnail( + item: dict[str, Any], + player: Player, + entity: MediaPlayerEntity, + item_type: str | MediaType | None, + search_type: str, + internal_request: bool, +) -> str | None: + """Construct path to thumbnail image.""" + item_thumbnail: str | None = None + if artwork_track_id := item.get("artwork_track_id"): + if internal_request: + item_thumbnail = player.generate_image_url_from_track_id(artwork_track_id) + elif item_type is not None: + item_thumbnail = entity.get_browse_image_url( + item_type, item.get("id", ""), artwork_track_id + ) + + elif search_type in ["Apps", "Radios"]: + item_thumbnail = player.generate_image_url(item["icon"]) + if item_thumbnail is None: + item_thumbnail = item.get("image_url") # will not be proxied by HA + return item_thumbnail + + async def build_item_response( entity: MediaPlayerEntity, player: Player, @@ -216,34 +276,12 @@ async def build_item_response( children = [] list_playable = [] for item in result["items"]: - item_id = str(item.get("id", "")) item_thumbnail: str | None = None - if item_type: - child_item_type: MediaType | str = item_type - child_media_class = CONTENT_TYPE_MEDIA_CLASS[item_type] - can_expand = child_media_class["children"] is not None - can_play = True - if search_type == "Favorites": - if "album_id" in item: - item_id = str(item["album_id"]) - child_item_type = MediaType.ALBUM - child_media_class = CONTENT_TYPE_MEDIA_CLASS[MediaType.ALBUM] - can_expand = True - can_play = True - elif item["hasitems"] and not item["isaudio"]: - child_item_type = "Favorites" - child_media_class = CONTENT_TYPE_MEDIA_CLASS["Favorites"] - can_expand = True - can_play = False - else: - child_item_type = "Favorites" - child_media_class = CONTENT_TYPE_MEDIA_CLASS[MediaType.TRACK] - can_expand = item["hasitems"] - can_play = item["isaudio"] and item.get("url") + browse_item_response = _build_response_favorites(item) - if search_type in ["Apps", "Radios"]: + elif search_type in ["Apps", "Radios"]: # item["cmd"] contains the name of the command to use with the cli for the app # add the command to the dictionaries if item["title"] == "Search" or item.get("type") in UNPLAYABLE_TYPES: @@ -253,19 +291,12 @@ async def build_item_response( if app_cmd not in browse_data.known_apps_radios: browse_data.known_apps_radios.add(app_cmd) - - _add_new_command_to_browse_data(browse_data, app_cmd, "item_id") + _add_new_command_to_browse_data(browse_data, app_cmd, "item_id") browse_item_response = _build_response_apps_radios_category( - browse_data, app_cmd + browse_data=browse_data, cmd=app_cmd, item=item ) - # Temporary variables until remainder of browse calls are restructured - child_item_type = browse_item_response.child_item_type - child_media_class = browse_item_response.child_media_class - can_expand = browse_item_response.can_expand - can_play = browse_item_response.can_play - elif search_type in browse_data.known_apps_radios: if ( item.get("title") in ["Search", None] @@ -278,39 +309,39 @@ async def build_item_response( browse_data, search_type, item ) - # Temporary variables until remainder of browse calls are restructured - child_item_type = browse_item_response.child_item_type - child_media_class = browse_item_response.child_media_class - can_expand = browse_item_response.can_expand - can_play = browse_item_response.can_play + elif item_type: + browse_item_response = BrowseItemResponse( + id=str(item.get("id", "")), + title=item["title"], + child_item_type=item_type, + child_media_class=CONTENT_TYPE_MEDIA_CLASS[item_type], + can_expand=CONTENT_TYPE_MEDIA_CLASS[item_type]["children"] + is not None, + can_play=True, + ) - if artwork_track_id := item.get("artwork_track_id"): - if internal_request: - item_thumbnail = player.generate_image_url_from_track_id( - artwork_track_id - ) - elif item_type is not None: - item_thumbnail = entity.get_browse_image_url( - item_type, item_id, artwork_track_id - ) - elif search_type in ["Apps", "Radios"]: - item_thumbnail = player.generate_image_url(item["icon"]) - else: - item_thumbnail = item.get("image_url") # will not be proxied by HA + item_thumbnail = _get_item_thumbnail( + item=item, + player=player, + entity=entity, + item_type=item_type, + search_type=search_type, + internal_request=internal_request, + ) - assert child_media_class["item"] is not None + assert browse_item_response.child_media_class["item"] is not None children.append( BrowseMedia( - title=item["title"], - media_class=child_media_class["item"], - media_content_id=item_id, - media_content_type=child_item_type, - can_play=can_play, - can_expand=can_expand, + title=browse_item_response.title, + media_class=browse_item_response.child_media_class["item"], + media_content_id=browse_item_response.id, + media_content_type=browse_item_response.child_item_type, + can_play=browse_item_response.can_play, + can_expand=browse_item_response.can_expand, thumbnail=item_thumbnail, ) ) - list_playable.append(can_play) + list_playable.append(browse_item_response.can_play) if children is None: raise BrowseError(f"Media not found: {search_type} / {search_id}") diff --git a/tests/components/squeezebox/conftest.py b/tests/components/squeezebox/conftest.py index 9ca750808c5..429c3b62087 100644 --- a/tests/components/squeezebox/conftest.py +++ b/tests/components/squeezebox/conftest.py @@ -163,7 +163,7 @@ async def mock_async_browse( "title": "Fake Item 2", "id": FAKE_VALID_ITEM_ID + "_2", "hasitems": media_type == "favorites", - "isaudio": True, + "isaudio": False, "item_type": child_types[media_type], "image_url": "http://lms.internal:9000/html/images/favorites.png", "url": "file:///var/lib/squeezeboxserver/music/track_2.mp3", From 2cce1b024e69186498f2b25d8f63d3a708258b0f Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 1 Mar 2025 15:43:00 -0500 Subject: [PATCH 2026/3148] Migrate Assist Pipeline to use TTS stream (#139542) * Migrate Pipeline to use TTS stream * Fix tests --- .../components/assist_pipeline/pipeline.py | 63 ++++----- homeassistant/components/tts/__init__.py | 35 +++-- .../assist_pipeline/snapshots/test_init.ambr | 24 ++++ .../snapshots/test_websocket.ambr | 90 +++++++++---- tests/components/assist_pipeline/test_init.py | 42 +++--- .../assist_pipeline/test_websocket.py | 121 +++++------------- tests/components/tts/test_init.py | 23 ---- 7 files changed, 196 insertions(+), 202 deletions(-) diff --git a/homeassistant/components/assist_pipeline/pipeline.py b/homeassistant/components/assist_pipeline/pipeline.py index 038874d1966..a028fa638df 100644 --- a/homeassistant/components/assist_pipeline/pipeline.py +++ b/homeassistant/components/assist_pipeline/pipeline.py @@ -19,14 +19,7 @@ import wave import hass_nabucasa import voluptuous as vol -from homeassistant.components import ( - conversation, - media_source, - stt, - tts, - wake_word, - websocket_api, -) +from homeassistant.components import conversation, stt, tts, wake_word, websocket_api from homeassistant.components.tts import ( generate_media_source_id as tts_generate_media_source_id, ) @@ -569,8 +562,7 @@ class PipelineRun: id: str = field(default_factory=ulid_util.ulid_now) stt_provider: stt.SpeechToTextEntity | stt.Provider = field(init=False, repr=False) - tts_engine: str = field(init=False, repr=False) - tts_options: dict | None = field(init=False, default=None) + tts_stream: tts.ResultStream | None = field(init=False, default=None) wake_word_entity_id: str | None = field(init=False, default=None, repr=False) wake_word_entity: wake_word.WakeWordDetectionEntity = field(init=False, repr=False) @@ -648,13 +640,18 @@ class PipelineRun: self._device_id = device_id self._start_debug_recording_thread() - data = { + data: dict[str, Any] = { "pipeline": self.pipeline.id, "language": self.language, "conversation_id": conversation_id, } if self.runner_data is not None: data["runner_data"] = self.runner_data + if self.tts_stream: + data["tts_output"] = { + "url": self.tts_stream.url, + "mime_type": self.tts_stream.content_type, + } self.process_event(PipelineEvent(PipelineEventType.RUN_START, data)) @@ -1246,36 +1243,31 @@ class PipelineRun: tts_options[tts.ATTR_PREFERRED_SAMPLE_BYTES] = SAMPLE_WIDTH try: - options_supported = await tts.async_support_options( - self.hass, - engine, - self.pipeline.tts_language, - tts_options, + self.tts_stream = tts.async_create_stream( + hass=self.hass, + engine=engine, + language=self.pipeline.tts_language, + options=tts_options, ) except HomeAssistantError as err: - raise TextToSpeechError( - code="tts-not-supported", - message=f"Text-to-speech engine '{engine}' not found", - ) from err - if not options_supported: raise TextToSpeechError( code="tts-not-supported", message=( f"Text-to-speech engine {engine} " - f"does not support language {self.pipeline.tts_language} or options {tts_options}" + f"does not support language {self.pipeline.tts_language} or options {tts_options}:" + f" {err}" ), - ) - - self.tts_engine = engine - self.tts_options = tts_options + ) from err async def text_to_speech(self, tts_input: str) -> None: """Run text-to-speech portion of pipeline.""" + assert self.tts_stream is not None + self.process_event( PipelineEvent( PipelineEventType.TTS_START, { - "engine": self.tts_engine, + "engine": self.tts_stream.engine, "language": self.pipeline.tts_language, "voice": self.pipeline.tts_voice, "tts_input": tts_input, @@ -1288,14 +1280,9 @@ class PipelineRun: tts_media_id = tts_generate_media_source_id( self.hass, tts_input, - engine=self.tts_engine, - language=self.pipeline.tts_language, - options=self.tts_options, - ) - tts_media = await media_source.async_resolve_media( - self.hass, - tts_media_id, - None, + engine=self.tts_stream.engine, + language=self.tts_stream.language, + options=self.tts_stream.options, ) except Exception as src_error: _LOGGER.exception("Unexpected error during text-to-speech") @@ -1304,10 +1291,12 @@ class PipelineRun: message="Unexpected error during text-to-speech", ) from src_error - _LOGGER.debug("TTS result %s", tts_media) + self.tts_stream.async_set_message(tts_input) + tts_output = { "media_id": tts_media_id, - **asdict(tts_media), + "url": self.tts_stream.url, + "mime_type": self.tts_stream.content_type, } self.process_event( diff --git a/homeassistant/components/tts/__init__.py b/homeassistant/components/tts/__init__.py index 32c4ba20670..98ce76cafde 100644 --- a/homeassistant/components/tts/__init__.py +++ b/homeassistant/components/tts/__init__.py @@ -79,13 +79,13 @@ __all__ = [ "PLATFORM_SCHEMA", "PLATFORM_SCHEMA_BASE", "Provider", + "ResultStream", "SampleFormat", "TextToSpeechEntity", "TtsAudioType", "Voice", "async_default_engine", "async_get_media_source_audio", - "async_support_options", "generate_media_source_id", ] @@ -167,22 +167,19 @@ def async_resolve_engine(hass: HomeAssistant, engine: str | None) -> str | None: return async_default_engine(hass) -async def async_support_options( +@callback +def async_create_stream( hass: HomeAssistant, engine: str, language: str | None = None, options: dict | None = None, -) -> bool: - """Return if an engine supports options.""" - if (engine_instance := get_engine_instance(hass, engine)) is None: - raise HomeAssistantError(f"Provider {engine} not found") - - try: - hass.data[DATA_TTS_MANAGER].process_options(engine_instance, language, options) - except HomeAssistantError: - return False - - return True +) -> ResultStream: + """Create a streaming URL where the rendered TTS can be retrieved.""" + return hass.data[DATA_TTS_MANAGER].async_create_result_stream( + engine=engine, + language=language, + options=options, + ) async def async_get_media_source_audio( @@ -407,6 +404,18 @@ class ResultStream: """Set cache key for message to be streamed.""" self._result_cache_key.set_result(cache_key) + @callback + def async_set_message(self, message: str) -> None: + """Set message to be generated.""" + cache_key = self._manager.async_cache_message_in_memory( + engine=self.engine, + message=message, + use_file_cache=self.use_file_cache, + language=self.language, + options=self.options, + ) + self._result_cache_key.set_result(cache_key) + async def async_stream_result(self) -> AsyncGenerator[bytes]: """Get the stream of this result.""" cache_key = await self._result_cache_key diff --git a/tests/components/assist_pipeline/snapshots/test_init.ambr b/tests/components/assist_pipeline/snapshots/test_init.ambr index f5e5f813db6..2375d48fcf9 100644 --- a/tests/components/assist_pipeline/snapshots/test_init.ambr +++ b/tests/components/assist_pipeline/snapshots/test_init.ambr @@ -6,6 +6,10 @@ 'conversation_id': 'mock-ulid', 'language': 'en', 'pipeline': , + 'tts_output': dict({ + 'mime_type': 'audio/mpeg', + 'url': '/api/tts_proxy/test_token.mp3', + }), }), 'type': , }), @@ -99,6 +103,10 @@ 'conversation_id': 'mock-ulid', 'language': 'en', 'pipeline': , + 'tts_output': dict({ + 'mime_type': 'audio/mpeg', + 'url': '/api/tts_proxy/test_token.mp3', + }), }), 'type': , }), @@ -192,6 +200,10 @@ 'conversation_id': 'mock-ulid', 'language': 'en', 'pipeline': , + 'tts_output': dict({ + 'mime_type': 'audio/mpeg', + 'url': '/api/tts_proxy/test_token.mp3', + }), }), 'type': , }), @@ -285,6 +297,10 @@ 'conversation_id': 'mock-ulid', 'language': 'en', 'pipeline': , + 'tts_output': dict({ + 'mime_type': 'audio/mpeg', + 'url': '/api/tts_proxy/test_token.mp3', + }), }), 'type': , }), @@ -402,6 +418,10 @@ 'conversation_id': 'mock-ulid', 'language': 'en', 'pipeline': , + 'tts_output': dict({ + 'mime_type': 'audio/mpeg', + 'url': '/api/tts_proxy/mocked-token.mp3', + }), }), 'type': , }), @@ -598,6 +618,10 @@ 'conversation_id': 'mock-ulid', 'language': 'en', 'pipeline': , + 'tts_output': dict({ + 'mime_type': 'audio/mpeg', + 'url': '/api/tts_proxy/mocked-token.mp3', + }), }), 'type': , }), diff --git a/tests/components/assist_pipeline/snapshots/test_websocket.ambr b/tests/components/assist_pipeline/snapshots/test_websocket.ambr index 509f2072509..d937b5396d1 100644 --- a/tests/components/assist_pipeline/snapshots/test_websocket.ambr +++ b/tests/components/assist_pipeline/snapshots/test_websocket.ambr @@ -8,6 +8,10 @@ 'stt_binary_handler_id': 1, 'timeout': 300, }), + 'tts_output': dict({ + 'mime_type': 'audio/mpeg', + 'url': '/api/tts_proxy/test_token.mp3', + }), }) # --- # name: test_audio_pipeline.1 @@ -93,6 +97,10 @@ 'stt_binary_handler_id': 1, 'timeout': 300, }), + 'tts_output': dict({ + 'mime_type': 'audio/mpeg', + 'url': '/api/tts_proxy/test_token.mp3', + }), }) # --- # name: test_audio_pipeline_debug.1 @@ -190,6 +198,10 @@ 'stt_binary_handler_id': 1, 'timeout': 300, }), + 'tts_output': dict({ + 'mime_type': 'audio/mpeg', + 'url': '/api/tts_proxy/test_token.mp3', + }), }) # --- # name: test_audio_pipeline_with_enhancements.1 @@ -275,6 +287,10 @@ 'stt_binary_handler_id': 1, 'timeout': 300, }), + 'tts_output': dict({ + 'mime_type': 'audio/mpeg', + 'url': '/api/tts_proxy/test_token.mp3', + }), }) # --- # name: test_audio_pipeline_with_wake_word_no_timeout.1 @@ -382,6 +398,10 @@ 'stt_binary_handler_id': 1, 'timeout': 300, }), + 'tts_output': dict({ + 'mime_type': 'audio/mpeg', + 'url': '/api/tts_proxy/test_token.mp3', + }), }) # --- # name: test_audio_pipeline_with_wake_word_timeout.1 @@ -585,6 +605,10 @@ 'stt_binary_handler_id': None, 'timeout': 300, }), + 'tts_output': dict({ + 'mime_type': 'audio/mpeg', + 'url': '/api/tts_proxy/mocked-token.mp3', + }), }) # --- # name: test_pipeline_empty_tts_output.1 @@ -634,6 +658,10 @@ 'stt_binary_handler_id': 1, 'timeout': 300, }), + 'tts_output': dict({ + 'mime_type': 'audio/mpeg', + 'url': '/api/tts_proxy/mocked-token.mp3', + }), }) # --- # name: test_stt_cooldown_different_ids.1 @@ -645,6 +673,10 @@ 'stt_binary_handler_id': 1, 'timeout': 300, }), + 'tts_output': dict({ + 'mime_type': 'audio/mpeg', + 'url': '/api/tts_proxy/mocked-token.mp3', + }), }) # --- # name: test_stt_cooldown_same_id @@ -656,6 +688,10 @@ 'stt_binary_handler_id': 1, 'timeout': 300, }), + 'tts_output': dict({ + 'mime_type': 'audio/mpeg', + 'url': '/api/tts_proxy/mocked-token.mp3', + }), }) # --- # name: test_stt_cooldown_same_id.1 @@ -667,6 +703,10 @@ 'stt_binary_handler_id': 1, 'timeout': 300, }), + 'tts_output': dict({ + 'mime_type': 'audio/mpeg', + 'url': '/api/tts_proxy/mocked-token.mp3', + }), }) # --- # name: test_stt_stream_failed @@ -678,6 +718,10 @@ 'stt_binary_handler_id': 1, 'timeout': 300, }), + 'tts_output': dict({ + 'mime_type': 'audio/mpeg', + 'url': '/api/tts_proxy/mocked-token.mp3', + }), }) # --- # name: test_stt_stream_failed.1 @@ -798,28 +842,6 @@ 'message': 'Timeout running pipeline', }) # --- -# name: test_tts_failed - dict({ - 'conversation_id': 'mock-ulid', - 'language': 'en', - 'pipeline': , - 'runner_data': dict({ - 'stt_binary_handler_id': None, - 'timeout': 300, - }), - }) -# --- -# name: test_tts_failed.1 - dict({ - 'engine': 'test', - 'language': 'en-US', - 'tts_input': 'Lights are on.', - 'voice': 'james_earl_jones', - }) -# --- -# name: test_tts_failed.2 - None -# --- # name: test_wake_word_cooldown_different_entities dict({ 'conversation_id': 'mock-ulid', @@ -829,6 +851,10 @@ 'stt_binary_handler_id': 1, 'timeout': 300, }), + 'tts_output': dict({ + 'mime_type': 'audio/mpeg', + 'url': '/api/tts_proxy/mocked-token.mp3', + }), }) # --- # name: test_wake_word_cooldown_different_entities.1 @@ -840,6 +866,10 @@ 'stt_binary_handler_id': 1, 'timeout': 300, }), + 'tts_output': dict({ + 'mime_type': 'audio/mpeg', + 'url': '/api/tts_proxy/mocked-token.mp3', + }), }) # --- # name: test_wake_word_cooldown_different_entities.2 @@ -892,6 +922,10 @@ 'stt_binary_handler_id': 1, 'timeout': 300, }), + 'tts_output': dict({ + 'mime_type': 'audio/mpeg', + 'url': '/api/tts_proxy/mocked-token.mp3', + }), }) # --- # name: test_wake_word_cooldown_different_ids.1 @@ -903,6 +937,10 @@ 'stt_binary_handler_id': 1, 'timeout': 300, }), + 'tts_output': dict({ + 'mime_type': 'audio/mpeg', + 'url': '/api/tts_proxy/mocked-token.mp3', + }), }) # --- # name: test_wake_word_cooldown_different_ids.2 @@ -958,6 +996,10 @@ 'stt_binary_handler_id': 1, 'timeout': 300, }), + 'tts_output': dict({ + 'mime_type': 'audio/mpeg', + 'url': '/api/tts_proxy/mocked-token.mp3', + }), }) # --- # name: test_wake_word_cooldown_same_id.1 @@ -969,6 +1011,10 @@ 'stt_binary_handler_id': 1, 'timeout': 300, }), + 'tts_output': dict({ + 'mime_type': 'audio/mpeg', + 'url': '/api/tts_proxy/mocked-token.mp3', + }), }) # --- # name: test_wake_word_cooldown_same_id.2 diff --git a/tests/components/assist_pipeline/test_init.py b/tests/components/assist_pipeline/test_init.py index e983e4a96e3..0e04d1f0cd2 100644 --- a/tests/components/assist_pipeline/test_init.py +++ b/tests/components/assist_pipeline/test_init.py @@ -43,13 +43,21 @@ from tests.typing import ClientSessionGenerator, WebSocketGenerator @pytest.fixture(autouse=True) -def mock_ulid() -> Generator[Mock]: - """Mock the ulid of chat sessions.""" - with patch("homeassistant.helpers.chat_session.ulid_now") as mock_ulid_now: - mock_ulid_now.return_value = "mock-ulid" +def mock_chat_session_id() -> Generator[Mock]: + """Mock the conversation ID of chat sessions.""" + with patch( + "homeassistant.helpers.chat_session.ulid_now", return_value="mock-ulid" + ) as mock_ulid_now: yield mock_ulid_now +@pytest.fixture(autouse=True) +def mock_tts_token() -> Generator[None]: + """Mock the TTS token for URLs.""" + with patch("secrets.token_urlsafe", return_value="mocked-token"): + yield + + def process_events(events: list[assist_pipeline.PipelineEvent]) -> list[dict]: """Process events to remove dynamic values.""" processed = [] @@ -797,10 +805,16 @@ async def test_tts_audio_output( await pipeline_input.validate() # Verify TTS audio settings - assert pipeline_input.run.tts_options is not None - assert pipeline_input.run.tts_options.get(tts.ATTR_PREFERRED_FORMAT) == "wav" - assert pipeline_input.run.tts_options.get(tts.ATTR_PREFERRED_SAMPLE_RATE) == 16000 - assert pipeline_input.run.tts_options.get(tts.ATTR_PREFERRED_SAMPLE_CHANNELS) == 1 + assert pipeline_input.run.tts_stream.options is not None + assert pipeline_input.run.tts_stream.options.get(tts.ATTR_PREFERRED_FORMAT) == "wav" + assert ( + pipeline_input.run.tts_stream.options.get(tts.ATTR_PREFERRED_SAMPLE_RATE) + == 16000 + ) + assert ( + pipeline_input.run.tts_stream.options.get(tts.ATTR_PREFERRED_SAMPLE_CHANNELS) + == 1 + ) with patch.object(mock_tts_provider, "get_tts_audio") as mock_get_tts_audio: await pipeline_input.execute() @@ -809,9 +823,7 @@ async def test_tts_audio_output( if event.type == assist_pipeline.PipelineEventType.TTS_END: # We must fetch the media URL to trigger the TTS assert event.data - media_id = event.data["tts_output"]["media_id"] - resolved = await media_source.async_resolve_media(hass, media_id, None) - await client.get(resolved.url) + await client.get(event.data["tts_output"]["url"]) # Ensure that no unsupported options were passed in assert mock_get_tts_audio.called @@ -875,9 +887,7 @@ async def test_tts_wav_preferred_format( if event.type == assist_pipeline.PipelineEventType.TTS_END: # We must fetch the media URL to trigger the TTS assert event.data - media_id = event.data["tts_output"]["media_id"] - resolved = await media_source.async_resolve_media(hass, media_id, None) - await client.get(resolved.url) + await client.get(event.data["tts_output"]["url"]) assert mock_get_tts_audio.called options = mock_get_tts_audio.call_args_list[0].kwargs["options"] @@ -949,9 +959,7 @@ async def test_tts_dict_preferred_format( if event.type == assist_pipeline.PipelineEventType.TTS_END: # We must fetch the media URL to trigger the TTS assert event.data - media_id = event.data["tts_output"]["media_id"] - resolved = await media_source.async_resolve_media(hass, media_id, None) - await client.get(resolved.url) + await client.get(event.data["tts_output"]["url"]) assert mock_get_tts_audio.called options = mock_get_tts_audio.call_args_list[0].kwargs["options"] diff --git a/tests/components/assist_pipeline/test_websocket.py b/tests/components/assist_pipeline/test_websocket.py index f856bbe7f61..060c0dce660 100644 --- a/tests/components/assist_pipeline/test_websocket.py +++ b/tests/components/assist_pipeline/test_websocket.py @@ -20,6 +20,8 @@ from homeassistant.components.assist_pipeline.pipeline import ( DeviceAudioQueue, Pipeline, PipelineData, + async_get_pipelines, + async_update_pipeline, ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -38,13 +40,21 @@ from tests.typing import WebSocketGenerator @pytest.fixture(autouse=True) -def mock_ulid() -> Generator[Mock]: - """Mock the ulid of chat sessions.""" - with patch("homeassistant.helpers.chat_session.ulid_now") as mock_ulid_now: - mock_ulid_now.return_value = "mock-ulid" +def mock_chat_session_id() -> Generator[Mock]: + """Mock the conversation ID of chat sessions.""" + with patch( + "homeassistant.helpers.chat_session.ulid_now", return_value="mock-ulid" + ) as mock_ulid_now: yield mock_ulid_now +@pytest.fixture(autouse=True) +def mock_tts_token() -> Generator[None]: + """Mock the TTS token for URLs.""" + with patch("secrets.token_urlsafe", return_value="mocked-token"): + yield + + @pytest.mark.parametrize( "extra_msg", [ @@ -825,74 +835,6 @@ async def test_stt_stream_failed( assert msg["result"] == {"events": events} -async def test_tts_failed( - hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, - init_components, - snapshot: SnapshotAssertion, -) -> None: - """Test pipeline run with text-to-speech error.""" - events = [] - client = await hass_ws_client(hass) - - with patch( - "homeassistant.components.media_source.async_resolve_media", - side_effect=RuntimeError, - ): - await client.send_json_auto_id( - { - "type": "assist_pipeline/run", - "start_stage": "tts", - "end_stage": "tts", - "input": {"text": "Lights are on."}, - } - ) - - # result - msg = await client.receive_json() - assert msg["success"] - - # run start - msg = await client.receive_json() - assert msg["event"]["type"] == "run-start" - msg["event"]["data"]["pipeline"] = ANY - assert msg["event"]["data"] == snapshot - events.append(msg["event"]) - - # tts start - msg = await client.receive_json() - assert msg["event"]["type"] == "tts-start" - assert msg["event"]["data"] == snapshot - events.append(msg["event"]) - - # tts error - msg = await client.receive_json() - assert msg["event"]["type"] == "error" - assert msg["event"]["data"]["code"] == "tts-failed" - events.append(msg["event"]) - - # run end - msg = await client.receive_json() - assert msg["event"]["type"] == "run-end" - assert msg["event"]["data"] == snapshot - events.append(msg["event"]) - - pipeline_data: PipelineData = hass.data[DOMAIN] - pipeline_id = list(pipeline_data.pipeline_debug)[0] - pipeline_run_id = list(pipeline_data.pipeline_debug[pipeline_id])[0] - - await client.send_json_auto_id( - { - "type": "assist_pipeline/pipeline_debug/get", - "pipeline_id": pipeline_id, - "pipeline_run_id": pipeline_run_id, - } - ) - msg = await client.receive_json() - assert msg["success"] - assert msg["result"] == {"events": events} - - async def test_tts_provider_missing( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, @@ -903,23 +845,22 @@ async def test_tts_provider_missing( """Test pipeline run with text-to-speech error.""" client = await hass_ws_client(hass) - with patch( - "homeassistant.components.tts.async_support_options", - side_effect=HomeAssistantError, - ): - await client.send_json_auto_id( - { - "type": "assist_pipeline/run", - "start_stage": "tts", - "end_stage": "tts", - "input": {"text": "Lights are on."}, - } - ) + pipelines = async_get_pipelines(hass) + await async_update_pipeline(hass, pipelines[0], tts_engine="unavailable") - # result - msg = await client.receive_json() - assert not msg["success"] - assert msg["error"]["code"] == "tts-not-supported" + await client.send_json_auto_id( + { + "type": "assist_pipeline/run", + "start_stage": "tts", + "end_stage": "tts", + "input": {"text": "Lights are on."}, + } + ) + + # result + msg = await client.receive_json() + assert not msg["success"] + assert msg["error"]["code"] == "tts-not-supported" async def test_tts_provider_bad_options( @@ -933,8 +874,8 @@ async def test_tts_provider_bad_options( client = await hass_ws_client(hass) with patch( - "homeassistant.components.tts.async_support_options", - return_value=False, + "homeassistant.components.tts.SpeechManager.process_options", + side_effect=HomeAssistantError("Language not supported"), ): await client.send_json_auto_id( { diff --git a/tests/components/tts/test_init.py b/tests/components/tts/test_init.py index 8dece920907..1b9692cc70c 100644 --- a/tests/components/tts/test_init.py +++ b/tests/components/tts/test_init.py @@ -1376,29 +1376,6 @@ def test_resolve_engine(hass: HomeAssistant, setup: str, engine_id: str) -> None assert tts.async_resolve_engine(hass, None) is None -@pytest.mark.parametrize( - ("setup", "engine_id"), - [ - ("mock_setup", "test"), - ("mock_config_entry_setup", "tts.test"), - ], - indirect=["setup"], -) -async def test_support_options(hass: HomeAssistant, setup: str, engine_id: str) -> None: - """Test supporting options.""" - assert await tts.async_support_options(hass, engine_id, "en_US") is True - assert await tts.async_support_options(hass, engine_id, "nl") is False - assert ( - await tts.async_support_options( - hass, engine_id, "en_US", {"invalid_option": "yo"} - ) - is False - ) - - with pytest.raises(HomeAssistantError): - await tts.async_support_options(hass, "non-existing") - - async def test_legacy_fetching_in_async( hass: HomeAssistant, hass_client: ClientSessionGenerator ) -> None: From 3588784f1ef77e50c5d2c044813df2b3e138335f Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Sat, 1 Mar 2025 22:27:31 +0100 Subject: [PATCH 2027/3148] Add create_reward action to Habitica integration (#139304) Add create_reward action to Habitica --- homeassistant/components/habitica/const.py | 1 + homeassistant/components/habitica/icons.json | 6 + homeassistant/components/habitica/services.py | 82 +++++++++----- .../components/habitica/services.yaml | 21 +++- .../components/habitica/strings.json | 42 ++++++- tests/components/habitica/conftest.py | 3 + tests/components/habitica/test_services.py | 103 +++++++++++++++++- 7 files changed, 224 insertions(+), 34 deletions(-) diff --git a/homeassistant/components/habitica/const.py b/homeassistant/components/habitica/const.py index 353bcbbd39d..bd1363ca979 100644 --- a/homeassistant/components/habitica/const.py +++ b/homeassistant/components/habitica/const.py @@ -56,6 +56,7 @@ SERVICE_SCORE_REWARD = "score_reward" SERVICE_TRANSFORMATION = "transformation" SERVICE_UPDATE_REWARD = "update_reward" +SERVICE_CREATE_REWARD = "create_reward" DEVELOPER_ID = "4c4ca53f-c059-4ffa-966e-9d29dd405daf" X_CLIENT = f"{DEVELOPER_ID} - {APPLICATION_NAME} {__version__}" diff --git a/homeassistant/components/habitica/icons.json b/homeassistant/components/habitica/icons.json index e119b063aa5..83df86f3945 100644 --- a/homeassistant/components/habitica/icons.json +++ b/homeassistant/components/habitica/icons.json @@ -224,6 +224,12 @@ "tag_options": "mdi:tag", "developer_options": "mdi:test-tube" } + }, + "create_reward": { + "service": "mdi:treasure-chest-outline", + "sections": { + "developer_options": "mdi:test-tube" + } } } } diff --git a/homeassistant/components/habitica/services.py b/homeassistant/components/habitica/services.py index 57005cf2b72..1abe977681f 100644 --- a/homeassistant/components/habitica/services.py +++ b/homeassistant/components/habitica/services.py @@ -61,6 +61,7 @@ from .const import ( SERVICE_API_CALL, SERVICE_CANCEL_QUEST, SERVICE_CAST_SKILL, + SERVICE_CREATE_REWARD, SERVICE_GET_TASKS, SERVICE_LEAVE_QUEST, SERVICE_REJECT_QUEST, @@ -112,18 +113,29 @@ SERVICE_TRANSFORMATION_SCHEMA = vol.Schema( } ) -SERVICE_UPDATE_TASK_SCHEMA = vol.Schema( +BASE_TASK_SCHEMA = vol.Schema( { vol.Required(ATTR_CONFIG_ENTRY): ConfigEntrySelector(), - vol.Required(ATTR_TASK): cv.string, vol.Optional(ATTR_RENAME): cv.string, vol.Optional(ATTR_NOTES): cv.string, vol.Optional(ATTR_TAG): vol.All(cv.ensure_list, [str]), - vol.Optional(ATTR_REMOVE_TAG): vol.All(cv.ensure_list, [str]), vol.Optional(ATTR_ALIAS): vol.All( cv.string, cv.matches_regex("^[a-zA-Z0-9-_]*$") ), - vol.Optional(ATTR_COST): vol.Coerce(float), + vol.Optional(ATTR_COST): vol.All(vol.Coerce(float), vol.Range(0)), + } +) + +SERVICE_UPDATE_TASK_SCHEMA = BASE_TASK_SCHEMA.extend( + { + vol.Required(ATTR_TASK): cv.string, + vol.Optional(ATTR_REMOVE_TAG): vol.All(cv.ensure_list, [str]), + } +) + +SERVICE_CREATE_TASK_SCHEMA = BASE_TASK_SCHEMA.extend( + { + vol.Required(ATTR_NAME): cv.string, } ) @@ -539,33 +551,36 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 return result - async def update_task(call: ServiceCall) -> ServiceResponse: - """Update task action.""" + async def create_or_update_task(call: ServiceCall) -> ServiceResponse: + """Create or update task action.""" entry = get_config_entry(hass, call.data[ATTR_CONFIG_ENTRY]) coordinator = entry.runtime_data await coordinator.async_refresh() + is_update = call.service == SERVICE_UPDATE_REWARD + current_task = None - try: - current_task = next( - task - for task in coordinator.data.tasks - if call.data[ATTR_TASK] in (str(task.id), task.alias, task.text) - and task.Type is TaskType.REWARD - ) - except StopIteration as e: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="task_not_found", - translation_placeholders={"task": f"'{call.data[ATTR_TASK]}'"}, - ) from e + if is_update: + try: + current_task = next( + task + for task in coordinator.data.tasks + if call.data[ATTR_TASK] in (str(task.id), task.alias, task.text) + and task.Type is TaskType.REWARD + ) + except StopIteration as e: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="task_not_found", + translation_placeholders={"task": f"'{call.data[ATTR_TASK]}'"}, + ) from e - task_id = current_task.id - if TYPE_CHECKING: - assert task_id data = Task() - if rename := call.data.get(ATTR_RENAME): - data["text"] = rename + if not is_update: + data["type"] = TaskType.REWARD + + if (text := call.data.get(ATTR_RENAME)) or (text := call.data.get(ATTR_NAME)): + data["text"] = text if (notes := call.data.get(ATTR_NOTES)) is not None: data["notes"] = notes @@ -574,7 +589,7 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 remove_tags = cast(list[str], call.data.get(ATTR_REMOVE_TAG)) if tags or remove_tags: - update_tags = set(current_task.tags) + update_tags = set(current_task.tags) if current_task else set() user_tags = { tag.name.lower(): tag.id for tag in coordinator.data.user.tags @@ -634,7 +649,13 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 data["value"] = cost try: - response = await coordinator.habitica.update_task(task_id, data) + if is_update: + if TYPE_CHECKING: + assert current_task + assert current_task.id + response = await coordinator.habitica.update_task(current_task.id, data) + else: + response = await coordinator.habitica.create_task(data) except TooManyRequestsError as e: raise HomeAssistantError( translation_domain=DOMAIN, @@ -659,10 +680,17 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 hass.services.async_register( DOMAIN, SERVICE_UPDATE_REWARD, - update_task, + create_or_update_task, schema=SERVICE_UPDATE_TASK_SCHEMA, supports_response=SupportsResponse.ONLY, ) + hass.services.async_register( + DOMAIN, + SERVICE_CREATE_REWARD, + create_or_update_task, + schema=SERVICE_CREATE_TASK_SCHEMA, + supports_response=SupportsResponse.ONLY, + ) hass.services.async_register( DOMAIN, SERVICE_API_CALL, diff --git a/homeassistant/components/habitica/services.yaml b/homeassistant/components/habitica/services.yaml index 7b486690ef5..b92b765e18c 100644 --- a/homeassistant/components/habitica/services.yaml +++ b/homeassistant/components/habitica/services.yaml @@ -147,14 +147,14 @@ update_reward: rename: selector: text: - notes: + notes: ¬es required: false selector: text: multiline: true cost: required: false - selector: + selector: &cost_selector number: min: 0 step: 0.01 @@ -163,7 +163,7 @@ update_reward: tag_options: collapsed: true fields: - tag: + tag: &tag required: false selector: text: @@ -173,10 +173,23 @@ update_reward: selector: text: multiple: true - developer_options: + developer_options: &developer_options collapsed: true fields: alias: required: false selector: text: +create_reward: + fields: + config_entry: *config_entry + name: + required: true + selector: + text: + notes: *notes + cost: + required: true + selector: *cost_selector + tag: *tag + developer_options: *developer_options diff --git a/homeassistant/components/habitica/strings.json b/homeassistant/components/habitica/strings.json index 1bb2fcbd9d7..0658e594d07 100644 --- a/homeassistant/components/habitica/strings.json +++ b/homeassistant/components/habitica/strings.json @@ -23,7 +23,9 @@ "developer_options_name": "Advanced settings", "developer_options_description": "Additional features available in developer mode.", "tag_options_name": "Tags", - "tag_options_description": "Add or remove tags from a task." + "tag_options_description": "Add or remove tags from a task.", + "name_description": "The title for the Habitica task.", + "cost_name": "Cost" }, "config": { "abort": { @@ -707,7 +709,7 @@ "description": "[%key:component::habitica::common::alias_description%]" }, "cost": { - "name": "Cost", + "name": "[%key:component::habitica::common::cost_name%]", "description": "Update the cost of a reward." } }, @@ -721,6 +723,42 @@ "description": "[%key:component::habitica::common::developer_options_description%]" } } + }, + "create_reward": { + "name": "Create reward", + "description": "Adds a new custom reward.", + "fields": { + "config_entry": { + "name": "[%key:component::habitica::common::config_entry_name%]", + "description": "Select the Habitica account to create a reward." + }, + "name": { + "name": "[%key:component::habitica::common::task_name%]", + "description": "[%key:component::habitica::common::name_description%]" + }, + "notes": { + "name": "[%key:component::habitica::common::notes_name%]", + "description": "[%key:component::habitica::common::notes_description%]" + }, + "tag": { + "name": "[%key:component::habitica::common::tag_name%]", + "description": "[%key:component::habitica::common::tag_description%]" + }, + "alias": { + "name": "[%key:component::habitica::common::alias_name%]", + "description": "[%key:component::habitica::common::alias_description%]" + }, + "cost": { + "name": "[%key:component::habitica::common::cost_name%]", + "description": "The cost of the reward." + } + }, + "sections": { + "developer_options": { + "name": "[%key:component::habitica::common::developer_options_name%]", + "description": "[%key:component::habitica::common::developer_options_description%]" + } + } } }, "selector": { diff --git a/tests/components/habitica/conftest.py b/tests/components/habitica/conftest.py index 45c33a9ebb6..efb4f7300bf 100644 --- a/tests/components/habitica/conftest.py +++ b/tests/components/habitica/conftest.py @@ -151,6 +151,9 @@ async def mock_habiticalib() -> Generator[AsyncMock]: client.create_tag.return_value = HabiticaTagResponse.from_json( load_fixture("create_tag.json", DOMAIN) ) + client.create_task.return_value = HabiticaTaskResponse.from_json( + load_fixture("task.json", DOMAIN) + ) client.habitipy.return_value = { "tasks": { "user": { diff --git a/tests/components/habitica/test_services.py b/tests/components/habitica/test_services.py index a4442016784..0b25dc4385e 100644 --- a/tests/components/habitica/test_services.py +++ b/tests/components/habitica/test_services.py @@ -6,7 +6,7 @@ from unittest.mock import AsyncMock, patch from uuid import UUID from aiohttp import ClientError -from habiticalib import Direction, HabiticaTaskResponse, Skill, Task +from habiticalib import Direction, HabiticaTaskResponse, Skill, Task, TaskType import pytest from syrupy.assertion import SnapshotAssertion @@ -30,6 +30,7 @@ from homeassistant.components.habitica.const import ( SERVICE_ACCEPT_QUEST, SERVICE_CANCEL_QUEST, SERVICE_CAST_SKILL, + SERVICE_CREATE_REWARD, SERVICE_GET_TASKS, SERVICE_LEAVE_QUEST, SERVICE_REJECT_QUEST, @@ -41,6 +42,7 @@ from homeassistant.components.habitica.const import ( ) from homeassistant.components.todo import ATTR_RENAME from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ATTR_NAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError @@ -943,6 +945,51 @@ async def test_update_task_exceptions( ) +@pytest.mark.parametrize( + ("exception", "expected_exception", "exception_msg"), + [ + ( + ERROR_TOO_MANY_REQUESTS, + HomeAssistantError, + RATE_LIMIT_EXCEPTION_MSG, + ), + ( + ERROR_BAD_REQUEST, + HomeAssistantError, + REQUEST_EXCEPTION_MSG, + ), + ( + ClientError, + HomeAssistantError, + "Unable to connect to Habitica: ", + ), + ], +) +@pytest.mark.usefixtures("habitica") +async def test_create_task_exceptions( + hass: HomeAssistant, + config_entry: MockConfigEntry, + habitica: AsyncMock, + exception: Exception, + expected_exception: Exception, + exception_msg: str, +) -> None: + """Test Habitica task create action exceptions.""" + + habitica.create_task.side_effect = exception + with pytest.raises(expected_exception, match=exception_msg): + await hass.services.async_call( + DOMAIN, + SERVICE_CREATE_REWARD, + service_data={ + ATTR_CONFIG_ENTRY: config_entry.entry_id, + ATTR_NAME: "TITLE", + }, + return_response=True, + blocking=True, + ) + + @pytest.mark.usefixtures("habitica") async def test_task_not_found( hass: HomeAssistant, @@ -1024,6 +1071,60 @@ async def test_update_reward( habitica.update_task.assert_awaited_with(UUID(task_id), call_args) +@pytest.mark.parametrize( + ("service_data", "call_args"), + [ + ( + { + ATTR_NAME: "TITLE", + ATTR_COST: 100, + }, + Task(type=TaskType.REWARD, text="TITLE", value=100), + ), + ( + { + ATTR_NAME: "TITLE", + }, + Task(type=TaskType.REWARD, text="TITLE"), + ), + ( + { + ATTR_NAME: "TITLE", + ATTR_NOTES: "NOTES", + }, + Task(type=TaskType.REWARD, text="TITLE", notes="NOTES"), + ), + ( + { + ATTR_NAME: "TITLE", + ATTR_ALIAS: "ALIAS", + }, + Task(type=TaskType.REWARD, text="TITLE", alias="ALIAS"), + ), + ], +) +async def test_create_reward( + hass: HomeAssistant, + config_entry: MockConfigEntry, + habitica: AsyncMock, + service_data: dict[str, Any], + call_args: Task, +) -> None: + """Test Habitica create_reward action.""" + + await hass.services.async_call( + DOMAIN, + SERVICE_CREATE_REWARD, + service_data={ + ATTR_CONFIG_ENTRY: config_entry.entry_id, + **service_data, + }, + return_response=True, + blocking=True, + ) + habitica.create_task.assert_awaited_with(call_args) + + async def test_tags( hass: HomeAssistant, config_entry: MockConfigEntry, From 1786bb990376ba69a827e9392631024c021a6198 Mon Sep 17 00:00:00 2001 From: Denis Shulyaka Date: Sun, 2 Mar 2025 00:28:48 +0300 Subject: [PATCH 2028/3148] Use model list to check anthropic API key (#139307) Anthropic model list --- homeassistant/components/anthropic/__init__.py | 11 ++++------- .../components/anthropic/config_flow.py | 7 +------ tests/components/anthropic/conftest.py | 6 ++---- tests/components/anthropic/test_config_flow.py | 4 ++-- .../components/anthropic/test_conversation.py | 18 +++++++++--------- tests/components/anthropic/test_init.py | 5 ++--- 6 files changed, 20 insertions(+), 31 deletions(-) diff --git a/homeassistant/components/anthropic/__init__.py b/homeassistant/components/anthropic/__init__.py index 84c9054b476..a9745d1a6a5 100644 --- a/homeassistant/components/anthropic/__init__.py +++ b/homeassistant/components/anthropic/__init__.py @@ -12,7 +12,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import config_validation as cv -from .const import DOMAIN, LOGGER +from .const import CONF_CHAT_MODEL, DOMAIN, LOGGER, RECOMMENDED_CHAT_MODEL PLATFORMS = (Platform.CONVERSATION,) CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) @@ -26,12 +26,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: AnthropicConfigEntry) -> partial(anthropic.AsyncAnthropic, api_key=entry.data[CONF_API_KEY]) ) try: - await client.messages.create( - model="claude-3-haiku-20240307", - max_tokens=1, - messages=[{"role": "user", "content": "Hi"}], - timeout=10.0, - ) + model_id = entry.options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL) + model = await client.models.retrieve(model_id=model_id, timeout=10.0) + LOGGER.debug("Anthropic model: %s", model.display_name) except anthropic.AuthenticationError as err: LOGGER.error("Invalid API key: %s", err) return False diff --git a/homeassistant/components/anthropic/config_flow.py b/homeassistant/components/anthropic/config_flow.py index 63a70f31fea..5f1f4fdeea7 100644 --- a/homeassistant/components/anthropic/config_flow.py +++ b/homeassistant/components/anthropic/config_flow.py @@ -63,12 +63,7 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None: client = await hass.async_add_executor_job( partial(anthropic.AsyncAnthropic, api_key=data[CONF_API_KEY]) ) - await client.messages.create( - model="claude-3-haiku-20240307", - max_tokens=1, - messages=[{"role": "user", "content": "Hi"}], - timeout=10.0, - ) + await client.models.list(timeout=10.0) class AnthropicConfigFlow(ConfigFlow, domain=DOMAIN): diff --git a/tests/components/anthropic/conftest.py b/tests/components/anthropic/conftest.py index ce6b98c480c..f8ab098cc09 100644 --- a/tests/components/anthropic/conftest.py +++ b/tests/components/anthropic/conftest.py @@ -1,7 +1,7 @@ """Tests helpers.""" from collections.abc import AsyncGenerator -from unittest.mock import AsyncMock, patch +from unittest.mock import patch import pytest @@ -43,9 +43,7 @@ async def mock_init_component( hass: HomeAssistant, mock_config_entry: MockConfigEntry ) -> AsyncGenerator[None]: """Initialize integration.""" - with patch( - "anthropic.resources.messages.AsyncMessages.create", new_callable=AsyncMock - ): + with patch("anthropic.resources.models.AsyncModels.retrieve"): assert await async_setup_component(hass, "anthropic", {}) await hass.async_block_till_done() yield diff --git a/tests/components/anthropic/test_config_flow.py b/tests/components/anthropic/test_config_flow.py index a5a025b00d0..5973d9a3ee8 100644 --- a/tests/components/anthropic/test_config_flow.py +++ b/tests/components/anthropic/test_config_flow.py @@ -49,7 +49,7 @@ async def test_form(hass: HomeAssistant) -> None: with ( patch( - "homeassistant.components.anthropic.config_flow.anthropic.resources.messages.AsyncMessages.create", + "homeassistant.components.anthropic.config_flow.anthropic.resources.models.AsyncModels.list", new_callable=AsyncMock, ), patch( @@ -151,7 +151,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non ) with patch( - "homeassistant.components.anthropic.config_flow.anthropic.resources.messages.AsyncMessages.create", + "homeassistant.components.anthropic.config_flow.anthropic.resources.models.AsyncModels.list", new_callable=AsyncMock, side_effect=side_effect, ): diff --git a/tests/components/anthropic/test_conversation.py b/tests/components/anthropic/test_conversation.py index a35df281fb6..6c8244a59ba 100644 --- a/tests/components/anthropic/test_conversation.py +++ b/tests/components/anthropic/test_conversation.py @@ -127,9 +127,7 @@ async def test_entity( CONF_LLM_HASS_API: "assist", }, ) - with patch( - "anthropic.resources.messages.AsyncMessages.create", new_callable=AsyncMock - ): + with patch("anthropic.resources.models.AsyncModels.retrieve"): await hass.config_entries.async_reload(mock_config_entry.entry_id) state = hass.states.get("conversation.claude") @@ -173,8 +171,11 @@ async def test_template_error( "prompt": "talk like a {% if True %}smarthome{% else %}pirate please.", }, ) - with patch( - "anthropic.resources.messages.AsyncMessages.create", new_callable=AsyncMock + with ( + patch("anthropic.resources.models.AsyncModels.retrieve"), + patch( + "anthropic.resources.messages.AsyncMessages.create", new_callable=AsyncMock + ), ): await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() @@ -205,6 +206,7 @@ async def test_template_variables( }, ) with ( + patch("anthropic.resources.models.AsyncModels.retrieve"), patch( "anthropic.resources.messages.AsyncMessages.create", new_callable=AsyncMock ) as mock_create, @@ -230,8 +232,8 @@ async def test_template_variables( result.response.speech["plain"]["speech"] == "Okay, let me take care of that for you." ) - assert "The user name is Test User." in mock_create.mock_calls[1][2]["system"] - assert "The user id is 12345." in mock_create.mock_calls[1][2]["system"] + assert "The user name is Test User." in mock_create.call_args.kwargs["system"] + assert "The user id is 12345." in mock_create.call_args.kwargs["system"] async def test_conversation_agent( @@ -497,9 +499,7 @@ async def test_unknown_hass_api( assert result == snapshot -@patch("anthropic.resources.messages.AsyncMessages.create", new_callable=AsyncMock) async def test_conversation_id( - mock_create, hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_init_component, diff --git a/tests/components/anthropic/test_init.py b/tests/components/anthropic/test_init.py index ee87bb708d0..305e442f52d 100644 --- a/tests/components/anthropic/test_init.py +++ b/tests/components/anthropic/test_init.py @@ -1,6 +1,6 @@ """Tests for the Anthropic integration.""" -from unittest.mock import AsyncMock, patch +from unittest.mock import patch from anthropic import ( APIConnectionError, @@ -55,8 +55,7 @@ async def test_init_error( ) -> None: """Test initialization errors.""" with patch( - "anthropic.resources.messages.AsyncMessages.create", - new_callable=AsyncMock, + "anthropic.resources.models.AsyncModels.retrieve", side_effect=side_effect, ): assert await async_setup_component(hass, "anthropic", {}) From 35825be12bab4d3805ffef4b4d9ce52922c19cf6 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Sat, 1 Mar 2025 22:36:51 +0100 Subject: [PATCH 2029/3148] =?UTF-8?q?Update=20quality=20scale=20to=20plati?= =?UTF-8?q?num=20=F0=9F=8F=86=EF=B8=8F=20for=20pyLoad=20integration=20(#13?= =?UTF-8?q?8891)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add quality scale file to pyLoad integration * set strict-typing to done * set parallel-updates to done * docs * update docs * flow coverage done * set platinum quality scale --- .strict-typing | 1 + homeassistant/components/pyload/manifest.json | 1 + .../components/pyload/quality_scale.yaml | 82 +++++++++++++++++++ mypy.ini | 10 +++ script/hassfest/quality_scale.py | 2 - 5 files changed, 94 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/pyload/quality_scale.yaml diff --git a/.strict-typing b/.strict-typing index 4b2a94b2db4..8d0d71e85fe 100644 --- a/.strict-typing +++ b/.strict-typing @@ -396,6 +396,7 @@ homeassistant.components.pure_energie.* homeassistant.components.purpleair.* homeassistant.components.pushbullet.* homeassistant.components.pvoutput.* +homeassistant.components.pyload.* homeassistant.components.python_script.* homeassistant.components.qbus.* homeassistant.components.qnap_qsw.* diff --git a/homeassistant/components/pyload/manifest.json b/homeassistant/components/pyload/manifest.json index 134865b9d93..feaa23af7de 100644 --- a/homeassistant/components/pyload/manifest.json +++ b/homeassistant/components/pyload/manifest.json @@ -7,5 +7,6 @@ "integration_type": "service", "iot_class": "local_polling", "loggers": ["pyloadapi"], + "quality_scale": "platinum", "requirements": ["PyLoadAPI==1.4.2"] } diff --git a/homeassistant/components/pyload/quality_scale.yaml b/homeassistant/components/pyload/quality_scale.yaml new file mode 100644 index 00000000000..a9ce552961b --- /dev/null +++ b/homeassistant/components/pyload/quality_scale.yaml @@ -0,0 +1,82 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: The integration registers no actions. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: The integration registers no actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: The integration registers no events. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: The integration registers no actions. + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: Integration has no configuration parameters + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: done + test-coverage: done + + # Gold + devices: done + diagnostics: done + discovery-update-info: + status: exempt + comment: The integration is a web service, there are no discoverable devices. + discovery: + status: exempt + comment: The integration is a web service, there are no discoverable devices. + docs-data-update: done + docs-examples: done + docs-known-limitations: done + docs-supported-devices: + status: exempt + comment: The integration is a web service, there are no devices. + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done + dynamic-devices: + status: exempt + comment: The integration is a web service, there are no devices. + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: done + icon-translations: done + reconfiguration-flow: done + repair-issues: + status: exempt + comment: The integration has no repairs. + stale-devices: + status: exempt + comment: The integration is a web service, there are no devices. + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/mypy.ini b/mypy.ini index 0792f820965..c69401b8605 100644 --- a/mypy.ini +++ b/mypy.ini @@ -3716,6 +3716,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.pyload.*] +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.python_script.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index 5f90fff81d5..1e335eaeb49 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -812,7 +812,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "pushsafer", "pvoutput", "pvpc_hourly_pricing", - "pyload", "qbittorrent", "qingping", "qld_bushfire", @@ -1890,7 +1889,6 @@ INTEGRATIONS_WITHOUT_SCALE = [ "pushsafer", "pvoutput", "pvpc_hourly_pricing", - "pyload", "qbittorrent", "qingping", "qld_bushfire", From 13918f07d8afdeebe49172f4259747f088b9ef03 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Sat, 1 Mar 2025 22:39:19 +0100 Subject: [PATCH 2030/3148] Switch cleanup for Shelly (part 2) (#138922) * Switch cleanup for Shelly (part 2) * apply review comment * Update tests/components/shelly/test_climate.py Co-authored-by: Maciej Bieniek * apply review comments --------- Co-authored-by: Maciej Bieniek --- homeassistant/components/shelly/switch.py | 103 +++++++++----------- homeassistant/components/shelly/utils.py | 11 +++ tests/components/shelly/conftest.py | 9 +- tests/components/shelly/test_climate.py | 1 + tests/components/shelly/test_coordinator.py | 7 ++ tests/components/shelly/test_init.py | 9 ++ tests/components/shelly/test_switch.py | 24 ++++- 7 files changed, 102 insertions(+), 62 deletions(-) diff --git a/homeassistant/components/shelly/switch.py b/homeassistant/components/shelly/switch.py index 41826706945..68708a2cc2b 100644 --- a/homeassistant/components/shelly/switch.py +++ b/homeassistant/components/shelly/switch.py @@ -7,8 +7,9 @@ from dataclasses import dataclass from typing import Any, cast from aioshelly.block_device import Block -from aioshelly.const import MODEL_2, MODEL_25, MODEL_WALL_DISPLAY, RPC_GENERATIONS +from aioshelly.const import MODEL_2, MODEL_25, RPC_GENERATIONS +from homeassistant.components.climate import DOMAIN as CLIMATE_PLATFORM from homeassistant.components.switch import ( DOMAIN as SWITCH_PLATFORM, SwitchEntity, @@ -27,7 +28,6 @@ from .entity import ( RpcEntityDescription, ShellyBlockEntity, ShellyRpcAttributeEntity, - ShellyRpcEntity, ShellySleepingBlockAttributeEntity, async_setup_entry_attribute_entities, async_setup_entry_rpc, @@ -36,12 +36,9 @@ from .utils import ( async_remove_orphaned_entities, async_remove_shelly_entity, get_device_entry_gen, - get_rpc_key_ids, get_virtual_component_ids, is_block_channel_type_light, - is_rpc_channel_type_light, - is_rpc_thermostat_internal_actuator, - is_rpc_thermostat_mode, + is_rpc_exclude_from_relay, ) @@ -67,6 +64,18 @@ class RpcSwitchDescription(RpcEntityDescription, SwitchEntityDescription): method_params_fn: Callable[[int | None, bool], dict] +RPC_RELAY_SWITCHES = { + "switch": RpcSwitchDescription( + key="switch", + sub_key="output", + removal_condition=is_rpc_exclude_from_relay, + is_on=lambda status: bool(status["output"]), + method_on="Switch.Set", + method_off="Switch.Set", + method_params_fn=lambda id, value: {"id": id, "on": value}, + ), +} + RPC_SWITCHES = { "boolean": RpcSwitchDescription( key="boolean", @@ -162,32 +171,10 @@ def async_setup_rpc_entry( """Set up entities for RPC device.""" coordinator = config_entry.runtime_data.rpc assert coordinator - switch_key_ids = get_rpc_key_ids(coordinator.device.status, "switch") - switch_ids = [] - for id_ in switch_key_ids: - if is_rpc_channel_type_light(coordinator.device.config, id_): - continue - - if coordinator.model == MODEL_WALL_DISPLAY: - # There are three configuration scenarios for WallDisplay: - # - relay mode (no thermostat) - # - thermostat mode using the internal relay as an actuator - # - thermostat mode using an external (from another device) relay as - # an actuator - if not is_rpc_thermostat_mode(id_, coordinator.device.status): - # The device is not in thermostat mode, we need to remove a climate - # entity - unique_id = f"{coordinator.mac}-thermostat:{id_}" - async_remove_shelly_entity(hass, "climate", unique_id) - elif is_rpc_thermostat_internal_actuator(coordinator.device.status): - # The internal relay is an actuator, skip this ID so as not to create - # a switch entity - continue - - switch_ids.append(id_) - unique_id = f"{coordinator.mac}-switch:{id_}" - async_remove_shelly_entity(hass, "light", unique_id) + async_setup_entry_rpc( + hass, config_entry, async_add_entities, RPC_RELAY_SWITCHES, RpcRelaySwitch + ) async_setup_entry_rpc( hass, config_entry, async_add_entities, RPC_SWITCHES, RpcSwitch @@ -218,10 +205,16 @@ def async_setup_rpc_entry( "script", ) - if not switch_ids: - return - - async_add_entities(RpcRelaySwitch(coordinator, id_) for id_ in switch_ids) + # if the climate is removed, from the device configuration, we need + # to remove orphaned entities + async_remove_orphaned_entities( + hass, + config_entry.entry_id, + coordinator.mac, + CLIMATE_PLATFORM, + coordinator.device.status, + "thermostat", + ) class BlockSleepingMotionSwitch( @@ -305,28 +298,6 @@ class BlockRelaySwitch(ShellyBlockEntity, SwitchEntity): super()._update_callback() -class RpcRelaySwitch(ShellyRpcEntity, SwitchEntity): - """Entity that controls a relay on RPC based Shelly devices.""" - - def __init__(self, coordinator: ShellyRpcCoordinator, id_: int) -> None: - """Initialize relay switch.""" - super().__init__(coordinator, f"switch:{id_}") - self._id = id_ - - @property - def is_on(self) -> bool: - """If switch is on.""" - return bool(self.status["output"]) - - async def async_turn_on(self, **kwargs: Any) -> None: - """Turn on relay.""" - await self.call_rpc("Switch.Set", {"id": self._id, "on": True}) - - async def async_turn_off(self, **kwargs: Any) -> None: - """Turn off relay.""" - await self.call_rpc("Switch.Set", {"id": self._id, "on": False}) - - class RpcSwitch(ShellyRpcAttributeEntity, SwitchEntity): """Entity that controls a switch on RPC based Shelly devices.""" @@ -351,3 +322,21 @@ class RpcSwitch(ShellyRpcAttributeEntity, SwitchEntity): self.entity_description.method_off, self.entity_description.method_params_fn(self._id, False), ) + + +class RpcRelaySwitch(RpcSwitch): + """Entity that controls a switch on RPC based Shelly devices.""" + + # False to avoid double naming as True is inerithed from base class + _attr_has_entity_name = False + + def __init__( + self, + coordinator: ShellyRpcCoordinator, + key: str, + attribute: str, + description: RpcEntityDescription, + ) -> None: + """Initialize the switch.""" + super().__init__(coordinator, key, attribute, description) + self._attr_unique_id: str = f"{coordinator.mac}-{key}" diff --git a/homeassistant/components/shelly/utils.py b/homeassistant/components/shelly/utils.py index 2e81f745819..d9e86427d0b 100644 --- a/homeassistant/components/shelly/utils.py +++ b/homeassistant/components/shelly/utils.py @@ -627,3 +627,14 @@ async def get_rpc_script_event_types(device: RpcDevice, id: int) -> list[str]: code_response = await device.script_getcode(id) matches = SHELLY_EMIT_EVENT_PATTERN.finditer(code_response["data"]) return sorted([*{str(event_type.group(1)) for event_type in matches}]) + + +def is_rpc_exclude_from_relay( + settings: dict[str, Any], status: dict[str, Any], channel: str +) -> bool: + """Return true if rpc channel should be excludeed from switch platform.""" + ch = int(channel.split(":")[1]) + if is_rpc_thermostat_internal_actuator(status): + return True + + return is_rpc_channel_type_light(settings, ch) diff --git a/tests/components/shelly/conftest.py b/tests/components/shelly/conftest.py index a332d16f95d..0063c5c2697 100644 --- a/tests/components/shelly/conftest.py +++ b/tests/components/shelly/conftest.py @@ -101,6 +101,7 @@ MOCK_BLOCKS = [ "overpower": 0, "power": 53.4, "energy": 1234567.89, + "output": True, }, channel="0", type="relay", @@ -207,7 +208,7 @@ MOCK_CONFIG = { }, "sys": { "ui_data": {}, - "device": {"name": "Test name"}, + "device": {"name": "Test name", "mac": MOCK_MAC}, }, "wifi": {"sta": {"enable": True}, "sta1": {"enable": False}}, "ws": {"enable": False, "server": None}, @@ -312,7 +313,11 @@ MOCK_STATUS_COAP = { MOCK_STATUS_RPC = { - "switch:0": {"output": True}, + "switch:0": { + "id": 0, + "output": True, + "apower": 85.3, + }, "input:0": {"id": 0, "state": None}, "input:1": {"id": 1, "percent": 89, "xpercent": 8.9}, "input:2": { diff --git a/tests/components/shelly/test_climate.py b/tests/components/shelly/test_climate.py index 040d67cb9c4..c78e87ebfce 100644 --- a/tests/components/shelly/test_climate.py +++ b/tests/components/shelly/test_climate.py @@ -751,6 +751,7 @@ async def test_wall_display_thermostat_mode_external_actuator( new_status = deepcopy(mock_rpc_device.status) new_status["sys"]["relay_in_thermostat"] = False + new_status.pop("cover:0") monkeypatch.setattr(mock_rpc_device, "status", new_status) await init_integration(hass, 2, model=MODEL_WALL_DISPLAY) diff --git a/tests/components/shelly/test_coordinator.py b/tests/components/shelly/test_coordinator.py index 8c011e4ad0d..8de434d19d0 100644 --- a/tests/components/shelly/test_coordinator.py +++ b/tests/components/shelly/test_coordinator.py @@ -386,6 +386,8 @@ async def test_rpc_reload_on_cfg_change( monkeypatch: pytest.MonkeyPatch, ) -> None: """Test RPC reload on config change.""" + monkeypatch.delitem(mock_rpc_device.status, "cover:0") + monkeypatch.setitem(mock_rpc_device.status["sys"], "relay_in_thermostat", False) await init_integration(hass, 2) # Generate config change from switch to light @@ -710,6 +712,8 @@ async def test_rpc_reconnect_error( exc: Exception, ) -> None: """Test RPC reconnect error.""" + monkeypatch.delitem(mock_rpc_device.status, "cover:0") + monkeypatch.setitem(mock_rpc_device.status["sys"], "relay_in_thermostat", False) await init_integration(hass, 2) assert get_entity_state(hass, "switch.test_switch_0") == STATE_ON @@ -729,9 +733,12 @@ async def test_rpc_error_running_connected_events( hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_rpc_device: Mock, + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture, ) -> None: """Test RPC error while running connected events.""" + monkeypatch.delitem(mock_rpc_device.status, "cover:0") + monkeypatch.setitem(mock_rpc_device.status["sys"], "relay_in_thermostat", False) with patch( "homeassistant.components.shelly.coordinator.async_ensure_ble_enabled", side_effect=DeviceConnectionError, diff --git a/tests/components/shelly/test_init.py b/tests/components/shelly/test_init.py index b05bce76728..f3ce807b655 100644 --- a/tests/components/shelly/test_init.py +++ b/tests/components/shelly/test_init.py @@ -366,8 +366,11 @@ async def test_entry_unload( entity_id: str, mock_block_device: Mock, mock_rpc_device: Mock, + monkeypatch: pytest.MonkeyPatch, ) -> None: """Test entry unload.""" + monkeypatch.delitem(mock_rpc_device.status, "cover:0") + monkeypatch.setitem(mock_rpc_device.status["sys"], "relay_in_thermostat", False) entry = await init_integration(hass, gen) assert entry.state is ConfigEntryState.LOADED @@ -410,6 +413,9 @@ async def test_entry_unload_not_connected( hass: HomeAssistant, mock_rpc_device: Mock, monkeypatch: pytest.MonkeyPatch ) -> None: """Test entry unload when not connected.""" + monkeypatch.delitem(mock_rpc_device.status, "cover:0") + monkeypatch.setitem(mock_rpc_device.status["sys"], "relay_in_thermostat", False) + with patch( "homeassistant.components.shelly.coordinator.async_stop_scanner" ) as mock_stop_scanner: @@ -435,6 +441,9 @@ async def test_entry_unload_not_connected_but_we_think_we_are( hass: HomeAssistant, mock_rpc_device: Mock, monkeypatch: pytest.MonkeyPatch ) -> None: """Test entry unload when not connected but we think we are still connected.""" + monkeypatch.delitem(mock_rpc_device.status, "cover:0") + monkeypatch.setitem(mock_rpc_device.status["sys"], "relay_in_thermostat", False) + with patch( "homeassistant.components.shelly.coordinator.async_stop_scanner", side_effect=DeviceConnectionError, diff --git a/tests/components/shelly/test_switch.py b/tests/components/shelly/test_switch.py index 5aae9dfffc9..1e5ae9dd88c 100644 --- a/tests/components/shelly/test_switch.py +++ b/tests/components/shelly/test_switch.py @@ -288,6 +288,8 @@ async def test_rpc_device_services( hass: HomeAssistant, mock_rpc_device: Mock, monkeypatch: pytest.MonkeyPatch ) -> None: """Test RPC device turn on/off services.""" + monkeypatch.delitem(mock_rpc_device.status, "cover:0") + monkeypatch.setitem(mock_rpc_device.status["sys"], "relay_in_thermostat", False) await init_integration(hass, 2) await hass.services.async_call( @@ -310,9 +312,14 @@ async def test_rpc_device_services( async def test_rpc_device_unique_ids( - hass: HomeAssistant, mock_rpc_device: Mock, entity_registry: EntityRegistry + hass: HomeAssistant, + mock_rpc_device: Mock, + monkeypatch: pytest.MonkeyPatch, + entity_registry: EntityRegistry, ) -> None: """Test RPC device unique_ids.""" + monkeypatch.delitem(mock_rpc_device.status, "cover:0") + monkeypatch.setitem(mock_rpc_device.status["sys"], "relay_in_thermostat", False) await init_integration(hass, 2) entry = entity_registry.async_get("switch.test_switch_0") @@ -340,6 +347,8 @@ async def test_rpc_set_state_errors( ) -> None: """Test RPC device set state connection/call errors.""" monkeypatch.setattr(mock_rpc_device, "call_rpc", AsyncMock(side_effect=exc)) + monkeypatch.delitem(mock_rpc_device.status, "cover:0") + monkeypatch.setitem(mock_rpc_device.status["sys"], "relay_in_thermostat", False) await init_integration(hass, 2) with pytest.raises(HomeAssistantError): @@ -360,6 +369,8 @@ async def test_rpc_auth_error( "call_rpc", AsyncMock(side_effect=InvalidAuthError), ) + monkeypatch.delitem(mock_rpc_device.status, "cover:0") + monkeypatch.setitem(mock_rpc_device.status["sys"], "relay_in_thermostat", False) entry = await init_integration(hass, 2) assert entry.state is ConfigEntryState.LOADED @@ -409,15 +420,22 @@ async def test_wall_display_relay_mode( monkeypatch: pytest.MonkeyPatch, ) -> None: """Test Wall Display in relay mode.""" - climate_entity_id = "climate.test_name" + climate_entity_id = "climate.test_name_thermostat_0" switch_entity_id = "switch.test_switch_0" + config_entry = await init_integration(hass, 2, model=MODEL_WALL_DISPLAY) + + assert hass.states.get(climate_entity_id) is not None + assert len(hass.states.async_entity_ids(CLIMATE_DOMAIN)) == 1 + new_status = deepcopy(mock_rpc_device.status) new_status["sys"]["relay_in_thermostat"] = False new_status.pop("thermostat:0") + new_status.pop("cover:0") monkeypatch.setattr(mock_rpc_device, "status", new_status) - await init_integration(hass, 2, model=MODEL_WALL_DISPLAY) + await hass.config_entries.async_reload(config_entry.entry_id) + await hass.async_block_till_done() # the climate entity should be removed assert hass.states.get(climate_entity_id) is None From f7927f9da1fd13b996475ac17d7909e6240de334 Mon Sep 17 00:00:00 2001 From: Tatham Oddie Date: Sun, 2 Mar 2025 07:54:48 +1000 Subject: [PATCH 2031/3148] Introduce demo valve (#138187) --- homeassistant/components/demo/__init__.py | 1 + homeassistant/components/demo/valve.py | 89 +++++++++++++++++++++++ tests/components/demo/test_valve.py | 83 +++++++++++++++++++++ 3 files changed, 173 insertions(+) create mode 100644 homeassistant/components/demo/valve.py create mode 100644 tests/components/demo/test_valve.py diff --git a/homeassistant/components/demo/__init__.py b/homeassistant/components/demo/__init__.py index 9314fc211de..dbc65119bfa 100644 --- a/homeassistant/components/demo/__init__.py +++ b/homeassistant/components/demo/__init__.py @@ -48,6 +48,7 @@ COMPONENTS_WITH_CONFIG_ENTRY_DEMO_PLATFORM = [ Platform.TIME, Platform.UPDATE, Platform.VACUUM, + Platform.VALVE, Platform.WATER_HEATER, Platform.WEATHER, ] diff --git a/homeassistant/components/demo/valve.py b/homeassistant/components/demo/valve.py new file mode 100644 index 00000000000..9c6acd45a8a --- /dev/null +++ b/homeassistant/components/demo/valve.py @@ -0,0 +1,89 @@ +"""Demo valve platform that implements valves.""" + +from __future__ import annotations + +import asyncio +from typing import Any + +from homeassistant.components.valve import ValveEntity, ValveEntityFeature, ValveState +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +OPEN_CLOSE_DELAY = 2 # Used to give a realistic open/close experience in frontend + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Demo config entry.""" + async_add_entities( + [ + DemoValve("Front Garden", ValveState.OPEN), + DemoValve("Orchard", ValveState.CLOSED), + ] + ) + + +class DemoValve(ValveEntity): + """Representation of a Demo valve.""" + + _attr_should_poll = False + + def __init__( + self, + name: str, + state: str, + moveable: bool = True, + ) -> None: + """Initialize the valve.""" + self._attr_name = name + if moveable: + self._attr_supported_features = ( + ValveEntityFeature.OPEN | ValveEntityFeature.CLOSE + ) + self._state = state + self._moveable = moveable + + @property + def is_open(self) -> bool: + """Return true if valve is open.""" + return self._state == ValveState.OPEN + + @property + def is_opening(self) -> bool: + """Return true if valve is opening.""" + return self._state == ValveState.OPENING + + @property + def is_closing(self) -> bool: + """Return true if valve is closing.""" + return self._state == ValveState.CLOSING + + @property + def is_closed(self) -> bool: + """Return true if valve is closed.""" + return self._state == ValveState.CLOSED + + @property + def reports_position(self) -> bool: + """Return True if entity reports position, False otherwise.""" + return False + + async def async_open_valve(self, **kwargs: Any) -> None: + """Open the valve.""" + self._state = ValveState.OPENING + self.async_write_ha_state() + await asyncio.sleep(OPEN_CLOSE_DELAY) + self._state = ValveState.OPEN + self.async_write_ha_state() + + async def async_close_valve(self, **kwargs: Any) -> None: + """Close the valve.""" + self._state = ValveState.CLOSING + self.async_write_ha_state() + await asyncio.sleep(OPEN_CLOSE_DELAY) + self._state = ValveState.CLOSED + self.async_write_ha_state() diff --git a/tests/components/demo/test_valve.py b/tests/components/demo/test_valve.py new file mode 100644 index 00000000000..1057065ce70 --- /dev/null +++ b/tests/components/demo/test_valve.py @@ -0,0 +1,83 @@ +"""The tests for the Demo valve platform.""" + +from unittest.mock import patch + +import pytest + +from homeassistant.components.demo import DOMAIN, valve as demo_valve +from homeassistant.components.valve import ( + DOMAIN as VALVE_DOMAIN, + SERVICE_CLOSE_VALVE, + SERVICE_OPEN_VALVE, + ValveState, +) +from homeassistant.const import ATTR_ENTITY_ID, EVENT_STATE_CHANGED, Platform +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import async_capture_events + +FRONT_GARDEN = "valve.front_garden" +ORCHARD = "valve.orchard" + + +@pytest.fixture +async def valve_only() -> None: + """Enable only the valve platform.""" + with patch( + "homeassistant.components.demo.COMPONENTS_WITH_CONFIG_ENTRY_DEMO_PLATFORM", + [Platform.VALVE], + ): + yield + + +@pytest.fixture(autouse=True) +async def setup_comp(hass: HomeAssistant, valve_only: None): + """Set up demo component.""" + assert await async_setup_component( + hass, VALVE_DOMAIN, {VALVE_DOMAIN: {"platform": DOMAIN}} + ) + await hass.async_block_till_done() + + +@patch.object(demo_valve, "OPEN_CLOSE_DELAY", 0) +async def test_closing(hass: HomeAssistant) -> None: + """Test the closing of a valve.""" + state = hass.states.get(FRONT_GARDEN) + assert state.state == ValveState.OPEN + await hass.async_block_till_done() + + state_changes = async_capture_events(hass, EVENT_STATE_CHANGED) + await hass.services.async_call( + VALVE_DOMAIN, + SERVICE_CLOSE_VALVE, + {ATTR_ENTITY_ID: FRONT_GARDEN}, + blocking=False, + ) + await hass.async_block_till_done() + + assert state_changes[0].data["entity_id"] == FRONT_GARDEN + assert state_changes[0].data["new_state"].state == ValveState.CLOSING + + assert state_changes[1].data["entity_id"] == FRONT_GARDEN + assert state_changes[1].data["new_state"].state == ValveState.CLOSED + + +@patch.object(demo_valve, "OPEN_CLOSE_DELAY", 0) +async def test_opening(hass: HomeAssistant) -> None: + """Test the opening of a valve.""" + state = hass.states.get(ORCHARD) + assert state.state == ValveState.CLOSED + await hass.async_block_till_done() + + state_changes = async_capture_events(hass, EVENT_STATE_CHANGED) + await hass.services.async_call( + VALVE_DOMAIN, SERVICE_OPEN_VALVE, {ATTR_ENTITY_ID: ORCHARD}, blocking=False + ) + await hass.async_block_till_done() + + assert state_changes[0].data["entity_id"] == ORCHARD + assert state_changes[0].data["new_state"].state == ValveState.OPENING + + assert state_changes[1].data["entity_id"] == ORCHARD + assert state_changes[1].data["new_state"].state == ValveState.OPEN From a2a11ad02ecee60fdb61ea895747435401313819 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Sat, 1 Mar 2025 22:55:49 +0100 Subject: [PATCH 2032/3148] =?UTF-8?q?Update=20quality=20scale=20to=20plati?= =?UTF-8?q?num=20=F0=9F=8F=86=EF=B8=8F=20for=20IronOS=20integration=20(#13?= =?UTF-8?q?8217)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update status in iron_os quality_scale.yaml --- homeassistant/components/iron_os/manifest.json | 1 + homeassistant/components/iron_os/quality_scale.yaml | 10 +++++++--- script/hassfest/quality_scale.py | 1 - 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/iron_os/manifest.json b/homeassistant/components/iron_os/manifest.json index 462e75c5b6e..c9868791668 100644 --- a/homeassistant/components/iron_os/manifest.json +++ b/homeassistant/components/iron_os/manifest.json @@ -13,5 +13,6 @@ "documentation": "https://www.home-assistant.io/integrations/iron_os", "iot_class": "local_polling", "loggers": ["pynecil"], + "quality_scale": "platinum", "requirements": ["pynecil==4.0.1"] } diff --git a/homeassistant/components/iron_os/quality_scale.yaml b/homeassistant/components/iron_os/quality_scale.yaml index c80b8b5adfe..8f7eb5ff36a 100644 --- a/homeassistant/components/iron_os/quality_scale.yaml +++ b/homeassistant/components/iron_os/quality_scale.yaml @@ -21,8 +21,10 @@ rules: entity-unique-id: done has-entity-name: done runtime-data: done - test-before-configure: todo - test-before-setup: todo + test-before-configure: + status: exempt + comment: Device is set up from a Bluetooth discovery + test-before-setup: done unique-config-entry: done # Silver @@ -70,7 +72,9 @@ rules: repair-issues: status: exempt comment: no repairs/issues - stale-devices: todo + stale-devices: + status: exempt + comment: Stale devices are removed with the config entry as there is only one device per entry # Platinum async-dependency: done diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index 1e335eaeb49..9ddce29a4f3 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -1588,7 +1588,6 @@ INTEGRATIONS_WITHOUT_SCALE = [ "intellifire", "intesishome", "ios", - "iron_os", "iotawatt", "iotty", "iperf3", From 56ddfa9ff80a3c042a27da5541799fafa1980ff5 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Sat, 1 Mar 2025 23:05:55 +0100 Subject: [PATCH 2033/3148] Bump deebot-client to 12.3.1 (#139598) --- homeassistant/components/ecovacs/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ecovacs/manifest.json b/homeassistant/components/ecovacs/manifest.json index b31fa7f347d..6d3dc5c9be6 100644 --- a/homeassistant/components/ecovacs/manifest.json +++ b/homeassistant/components/ecovacs/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/ecovacs", "iot_class": "cloud_push", "loggers": ["sleekxmppfs", "sucks", "deebot_client"], - "requirements": ["py-sucks==0.9.10", "deebot-client==12.2.0"] + "requirements": ["py-sucks==0.9.10", "deebot-client==12.3.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 0554810837f..8e11909a56c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -756,7 +756,7 @@ debugpy==1.8.11 # decora==0.6 # homeassistant.components.ecovacs -deebot-client==12.2.0 +deebot-client==12.3.1 # homeassistant.components.ihc # homeassistant.components.namecheapdns diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c86feb62135..99fdb680f63 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -646,7 +646,7 @@ dbus-fast==2.33.0 debugpy==1.8.11 # homeassistant.components.ecovacs -deebot-client==12.2.0 +deebot-client==12.3.1 # homeassistant.components.ihc # homeassistant.components.namecheapdns From 89b655c192d55920ae755397499fd022d4ffa42d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 1 Mar 2025 16:13:04 -0600 Subject: [PATCH 2034/3148] Fix handling of NaN float values for current humidity in ESPHome (#139600) fixes #131837 --- homeassistant/components/esphome/climate.py | 9 +++++++-- tests/components/esphome/test_climate.py | 4 ++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/esphome/climate.py b/homeassistant/components/esphome/climate.py index 478ce9bae2c..b651f16dfd7 100644 --- a/homeassistant/components/esphome/climate.py +++ b/homeassistant/components/esphome/climate.py @@ -3,6 +3,7 @@ from __future__ import annotations from functools import partial +from math import isfinite from typing import Any, cast from aioesphomeapi import ( @@ -238,9 +239,13 @@ class EsphomeClimateEntity(EsphomeEntity[ClimateInfo, ClimateState], ClimateEnti @esphome_state_property def current_humidity(self) -> int | None: """Return the current humidity.""" - if not self._static_info.supports_current_humidity: + if ( + not self._static_info.supports_current_humidity + or (val := self._state.current_humidity) is None + or not isfinite(val) + ): return None - return round(self._state.current_humidity) + return round(val) @property @esphome_float_state_property diff --git a/tests/components/esphome/test_climate.py b/tests/components/esphome/test_climate.py index 2a5013444dd..03d2f78a5d2 100644 --- a/tests/components/esphome/test_climate.py +++ b/tests/components/esphome/test_climate.py @@ -407,7 +407,7 @@ async def test_climate_entity_with_inf_value( target_temperature=math.inf, fan_mode=ClimateFanMode.AUTO, swing_mode=ClimateSwingMode.BOTH, - current_humidity=20.1, + current_humidity=math.inf, target_humidity=25.7, ) ] @@ -422,7 +422,7 @@ async def test_climate_entity_with_inf_value( assert state is not None assert state.state == HVACMode.AUTO attributes = state.attributes - assert attributes[ATTR_CURRENT_HUMIDITY] == 20 + assert ATTR_CURRENT_HUMIDITY not in attributes assert attributes[ATTR_HUMIDITY] == 26 assert attributes[ATTR_MAX_HUMIDITY] == 30 assert attributes[ATTR_MIN_HUMIDITY] == 10 From cc8ed2c228cca6ca0fdc282075e286bf72c6feec Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Sun, 2 Mar 2025 00:29:42 +0200 Subject: [PATCH 2035/3148] Fix demo valve platform to use AddConfigEntryEntitiesCallback (#139602) --- homeassistant/components/demo/valve.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/demo/valve.py b/homeassistant/components/demo/valve.py index 9c6acd45a8a..03f0123dd96 100644 --- a/homeassistant/components/demo/valve.py +++ b/homeassistant/components/demo/valve.py @@ -8,7 +8,7 @@ from typing import Any from homeassistant.components.valve import ValveEntity, ValveEntityFeature, ValveState from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback OPEN_CLOSE_DELAY = 2 # Used to give a realistic open/close experience in frontend @@ -16,7 +16,7 @@ OPEN_CLOSE_DELAY = 2 # Used to give a realistic open/close experience in fronte async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Demo config entry.""" async_add_entities( From 3e9304253d360af9303f3dbc1b3dac88a53e8776 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Sun, 2 Mar 2025 08:58:15 +1000 Subject: [PATCH 2036/3148] Bump Tesla Fleet API to v0.9.12 (#139565) * bump * Update manifest.json * Fix versions * remove tesla_bluetooth * Remove mistake --- homeassistant/components/tesla_fleet/manifest.json | 2 +- homeassistant/components/teslemetry/manifest.json | 2 +- homeassistant/components/tessie/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/tesla_fleet/manifest.json b/homeassistant/components/tesla_fleet/manifest.json index bb8f6041771..53aff3d0a54 100644 --- a/homeassistant/components/tesla_fleet/manifest.json +++ b/homeassistant/components/tesla_fleet/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/tesla_fleet", "iot_class": "cloud_polling", "loggers": ["tesla-fleet-api"], - "requirements": ["tesla-fleet-api==0.9.10"] + "requirements": ["tesla-fleet-api==0.9.12"] } diff --git a/homeassistant/components/teslemetry/manifest.json b/homeassistant/components/teslemetry/manifest.json index dfe6d7cb3f9..4e9228acd2f 100644 --- a/homeassistant/components/teslemetry/manifest.json +++ b/homeassistant/components/teslemetry/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/teslemetry", "iot_class": "cloud_polling", "loggers": ["tesla-fleet-api"], - "requirements": ["tesla-fleet-api==0.9.10", "teslemetry-stream==0.6.10"] + "requirements": ["tesla-fleet-api==0.9.12", "teslemetry-stream==0.6.10"] } diff --git a/homeassistant/components/tessie/manifest.json b/homeassistant/components/tessie/manifest.json index d777cf5051e..d4ac56883e8 100644 --- a/homeassistant/components/tessie/manifest.json +++ b/homeassistant/components/tessie/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/tessie", "iot_class": "cloud_polling", "loggers": ["tessie", "tesla-fleet-api"], - "requirements": ["tessie-api==0.1.1", "tesla-fleet-api==0.9.10"] + "requirements": ["tessie-api==0.1.1", "tesla-fleet-api==0.9.12"] } diff --git a/requirements_all.txt b/requirements_all.txt index 8e11909a56c..b770799a778 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2872,7 +2872,7 @@ temperusb==1.6.1 # homeassistant.components.tesla_fleet # homeassistant.components.teslemetry # homeassistant.components.tessie -tesla-fleet-api==0.9.10 +tesla-fleet-api==0.9.12 # homeassistant.components.powerwall tesla-powerwall==0.5.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 99fdb680f63..31314c763ba 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2312,7 +2312,7 @@ temperusb==1.6.1 # homeassistant.components.tesla_fleet # homeassistant.components.teslemetry # homeassistant.components.tessie -tesla-fleet-api==0.9.10 +tesla-fleet-api==0.9.12 # homeassistant.components.powerwall tesla-powerwall==0.5.2 From e3eb6051de652875f794e814f0396367898fd3de Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 2 Mar 2025 00:04:13 +0100 Subject: [PATCH 2037/3148] Fix duplicate unique id issue in Sensibo (#139582) * Fix duplicate unique id issue in Sensibo * Fixes * Mods --- .../components/sensibo/binary_sensor.py | 6 ++--- homeassistant/components/sensibo/button.py | 3 ++- homeassistant/components/sensibo/climate.py | 3 ++- .../components/sensibo/coordinator.py | 25 ++++++++++++++----- homeassistant/components/sensibo/number.py | 3 ++- homeassistant/components/sensibo/select.py | 3 ++- homeassistant/components/sensibo/sensor.py | 5 ++-- homeassistant/components/sensibo/switch.py | 3 ++- homeassistant/components/sensibo/update.py | 3 ++- tests/components/sensibo/test_coordinator.py | 4 +++ 10 files changed, 40 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/sensibo/binary_sensor.py b/homeassistant/components/sensibo/binary_sensor.py index 0d6c47ce46c..c7116db7954 100644 --- a/homeassistant/components/sensibo/binary_sensor.py +++ b/homeassistant/components/sensibo/binary_sensor.py @@ -130,9 +130,10 @@ async def async_setup_entry( """Handle additions of devices and sensors.""" entities: list[SensiboMotionSensor | SensiboDeviceSensor] = [] nonlocal added_devices - new_devices, remove_devices, added_devices = coordinator.get_devices( + new_devices, remove_devices, new_added_devices = coordinator.get_devices( added_devices ) + added_devices = new_added_devices if LOGGER.isEnabledFor(logging.DEBUG): LOGGER.debug( @@ -168,8 +169,7 @@ async def async_setup_entry( device_data.model, DEVICE_SENSOR_TYPES ) ) - - async_add_entities(entities) + async_add_entities(entities) entry.async_on_unload(coordinator.async_add_listener(_add_remove_devices)) _add_remove_devices() diff --git a/homeassistant/components/sensibo/button.py b/homeassistant/components/sensibo/button.py index ed0688d6f2c..d36967dae06 100644 --- a/homeassistant/components/sensibo/button.py +++ b/homeassistant/components/sensibo/button.py @@ -46,7 +46,8 @@ async def async_setup_entry( def _add_remove_devices() -> None: """Handle additions of devices and sensors.""" nonlocal added_devices - new_devices, _, added_devices = coordinator.get_devices(added_devices) + new_devices, _, new_added_devices = coordinator.get_devices(added_devices) + added_devices = new_added_devices if new_devices: async_add_entities( diff --git a/homeassistant/components/sensibo/climate.py b/homeassistant/components/sensibo/climate.py index 2190d121248..906c4259ce5 100644 --- a/homeassistant/components/sensibo/climate.py +++ b/homeassistant/components/sensibo/climate.py @@ -149,7 +149,8 @@ async def async_setup_entry( def _add_remove_devices() -> None: """Handle additions of devices and sensors.""" nonlocal added_devices - new_devices, _, added_devices = coordinator.get_devices(added_devices) + new_devices, _, new_added_devices = coordinator.get_devices(added_devices) + added_devices = new_added_devices if new_devices: async_add_entities( diff --git a/homeassistant/components/sensibo/coordinator.py b/homeassistant/components/sensibo/coordinator.py index e19f24295b9..3fa8a6e5dae 100644 --- a/homeassistant/components/sensibo/coordinator.py +++ b/homeassistant/components/sensibo/coordinator.py @@ -56,18 +56,31 @@ class SensiboDataUpdateCoordinator(DataUpdateCoordinator[SensiboData]): ) -> tuple[set[str], set[str], set[str]]: """Addition and removal of devices.""" data = self.data - motion_sensors = { + current_motion_sensors = { sensor_id for device_data in data.parsed.values() if device_data.motion_sensors for sensor_id in device_data.motion_sensors } - devices: set[str] = set(data.parsed) - new_devices: set[str] = motion_sensors | devices - added_devices - remove_devices = added_devices - devices - motion_sensors - added_devices = (added_devices - remove_devices) | new_devices + current_devices: set[str] = set(data.parsed) + LOGGER.debug( + "Current devices: %s, moption sensors: %s", + current_devices, + current_motion_sensors, + ) + new_devices: set[str] = ( + current_motion_sensors | current_devices + ) - added_devices + remove_devices = added_devices - current_devices - current_motion_sensors + new_added_devices = (added_devices - remove_devices) | new_devices - return (new_devices, remove_devices, added_devices) + LOGGER.debug( + "New devices: %s, Removed devices: %s, Added devices: %s", + new_devices, + remove_devices, + new_added_devices, + ) + return (new_devices, remove_devices, new_added_devices) async def _async_update_data(self) -> SensiboData: """Fetch data from Sensibo.""" diff --git a/homeassistant/components/sensibo/number.py b/homeassistant/components/sensibo/number.py index 9d077b308a0..e71ed6f0235 100644 --- a/homeassistant/components/sensibo/number.py +++ b/homeassistant/components/sensibo/number.py @@ -76,7 +76,8 @@ async def async_setup_entry( def _add_remove_devices() -> None: """Handle additions of devices and sensors.""" nonlocal added_devices - new_devices, _, added_devices = coordinator.get_devices(added_devices) + new_devices, _, new_added_devices = coordinator.get_devices(added_devices) + added_devices = new_added_devices if new_devices: async_add_entities( diff --git a/homeassistant/components/sensibo/select.py b/homeassistant/components/sensibo/select.py index 73c0734ef73..5a0546b1aa2 100644 --- a/homeassistant/components/sensibo/select.py +++ b/homeassistant/components/sensibo/select.py @@ -115,7 +115,8 @@ async def async_setup_entry( def _add_remove_devices() -> None: """Handle additions of devices and sensors.""" nonlocal added_devices - new_devices, _, added_devices = coordinator.get_devices(added_devices) + new_devices, _, new_added_devices = coordinator.get_devices(added_devices) + added_devices = new_added_devices if new_devices: async_add_entities( diff --git a/homeassistant/components/sensibo/sensor.py b/homeassistant/components/sensibo/sensor.py index 4174d4b859b..09f095bfaec 100644 --- a/homeassistant/components/sensibo/sensor.py +++ b/homeassistant/components/sensibo/sensor.py @@ -253,9 +253,8 @@ async def async_setup_entry( entities: list[SensiboMotionSensor | SensiboDeviceSensor] = [] nonlocal added_devices - new_devices, remove_devices, added_devices = coordinator.get_devices( - added_devices - ) + new_devices, _, new_added_devices = coordinator.get_devices(added_devices) + added_devices = new_added_devices if new_devices: entities.extend( diff --git a/homeassistant/components/sensibo/switch.py b/homeassistant/components/sensibo/switch.py index 8c140074e57..03e7c12ec2b 100644 --- a/homeassistant/components/sensibo/switch.py +++ b/homeassistant/components/sensibo/switch.py @@ -89,7 +89,8 @@ async def async_setup_entry( def _add_remove_devices() -> None: """Handle additions of devices and sensors.""" nonlocal added_devices - new_devices, _, added_devices = coordinator.get_devices(added_devices) + new_devices, _, new_added_devices = coordinator.get_devices(added_devices) + added_devices = new_added_devices if new_devices: async_add_entities( diff --git a/homeassistant/components/sensibo/update.py b/homeassistant/components/sensibo/update.py index 2103bbbf64a..6f868e5f366 100644 --- a/homeassistant/components/sensibo/update.py +++ b/homeassistant/components/sensibo/update.py @@ -56,7 +56,8 @@ async def async_setup_entry( def _add_remove_devices() -> None: """Handle additions of devices and sensors.""" nonlocal added_devices - new_devices, _, added_devices = coordinator.get_devices(added_devices) + new_devices, _, new_added_devices = coordinator.get_devices(added_devices) + added_devices = new_added_devices if new_devices: async_add_entities( diff --git a/tests/components/sensibo/test_coordinator.py b/tests/components/sensibo/test_coordinator.py index 6cb8e6fe923..2d56fc4c51c 100644 --- a/tests/components/sensibo/test_coordinator.py +++ b/tests/components/sensibo/test_coordinator.py @@ -9,6 +9,7 @@ from unittest.mock import MagicMock from freezegun.api import FrozenDateTimeFactory from pysensibo.exceptions import AuthenticationError, SensiboError from pysensibo.model import SensiboData +import pytest from homeassistant.components.climate import HVACMode from homeassistant.components.sensibo.const import DOMAIN @@ -25,6 +26,7 @@ async def test_coordinator( mock_client: MagicMock, get_data: tuple[SensiboData, dict[str, Any], dict[str, Any]], freezer: FrozenDateTimeFactory, + caplog: pytest.LogCaptureFixture, ) -> None: """Test the Sensibo coordinator with errors.""" config_entry = MockConfigEntry( @@ -87,3 +89,5 @@ async def test_coordinator( mock_data.assert_called_once() state = hass.states.get("climate.hallway") assert state.state == STATE_UNAVAILABLE + + assert "Platform sensibo does not generate unique IDs" not in caplog.text From 55fd5fa86902918c670dbd5e97500b4dfe264c0f Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Sun, 2 Mar 2025 01:12:19 +0200 Subject: [PATCH 2038/3148] Bump aioshelly to 13.1.0 (#139601) Co-authored-by: Franck Nijhof --- homeassistant/components/shelly/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/shelly/manifest.json b/homeassistant/components/shelly/manifest.json index ec08a005995..722fd4c128a 100644 --- a/homeassistant/components/shelly/manifest.json +++ b/homeassistant/components/shelly/manifest.json @@ -8,7 +8,7 @@ "integration_type": "device", "iot_class": "local_push", "loggers": ["aioshelly"], - "requirements": ["aioshelly==13.0.0"], + "requirements": ["aioshelly==13.1.0"], "zeroconf": [ { "type": "_http._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index b770799a778..8a8f0b51613 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -371,7 +371,7 @@ aioruuvigateway==0.1.0 aiosenz==1.0.0 # homeassistant.components.shelly -aioshelly==13.0.0 +aioshelly==13.1.0 # homeassistant.components.skybell aioskybell==22.7.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 31314c763ba..5ead556907a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -353,7 +353,7 @@ aioruuvigateway==0.1.0 aiosenz==1.0.0 # homeassistant.components.shelly -aioshelly==13.0.0 +aioshelly==13.1.0 # homeassistant.components.skybell aioskybell==22.7.0 From 077ff63b38a92fdfeb884dccf7df8957a5c44d56 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 1 Mar 2025 17:51:09 -0600 Subject: [PATCH 2039/3148] Bump inkbird-ble to 0.7.1 (#139603) changelog: https://github.com/Bluetooth-Devices/inkbird-ble/compare/v0.7.0...v0.7.1 --- homeassistant/components/inkbird/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/inkbird/manifest.json b/homeassistant/components/inkbird/manifest.json index 1a251f52582..acc7414edac 100644 --- a/homeassistant/components/inkbird/manifest.json +++ b/homeassistant/components/inkbird/manifest.json @@ -28,5 +28,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/inkbird", "iot_class": "local_push", - "requirements": ["inkbird-ble==0.7.0"] + "requirements": ["inkbird-ble==0.7.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 8a8f0b51613..f2da895114f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1226,7 +1226,7 @@ influxdb-client==1.24.0 influxdb==5.3.1 # homeassistant.components.inkbird -inkbird-ble==0.7.0 +inkbird-ble==0.7.1 # homeassistant.components.insteon insteon-frontend-home-assistant==0.5.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5ead556907a..c328478338d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1040,7 +1040,7 @@ influxdb-client==1.24.0 influxdb==5.3.1 # homeassistant.components.inkbird -inkbird-ble==0.7.0 +inkbird-ble==0.7.1 # homeassistant.components.insteon insteon-frontend-home-assistant==0.5.0 From 4a7fd89abde2da04d596eeb0732b7fb2b4ce233d Mon Sep 17 00:00:00 2001 From: wittypluck Date: Sun, 2 Mar 2025 02:32:55 +0100 Subject: [PATCH 2040/3148] Bump pyopenweathermap to 0.2.2 and remove deprecated API version v2.5 (#139599) * Bump pyopenweathermap * Remove deprecated API mode v2.5 --- .../components/openweathermap/__init__.py | 6 +++--- homeassistant/components/openweathermap/const.py | 2 -- .../components/openweathermap/manifest.json | 2 +- homeassistant/components/openweathermap/weather.py | 3 +-- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/openweathermap/test_weather.py | 14 +++++++------- 7 files changed, 14 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/openweathermap/__init__.py b/homeassistant/components/openweathermap/__init__.py index fa51b91dc6d..40ddf0ff37e 100644 --- a/homeassistant/components/openweathermap/__init__.py +++ b/homeassistant/components/openweathermap/__init__.py @@ -11,7 +11,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_LANGUAGE, CONF_MODE, CONF_NAME from homeassistant.core import HomeAssistant -from .const import CONFIG_FLOW_VERSION, OWM_MODE_V25, PLATFORMS +from .const import CONFIG_FLOW_VERSION, DEFAULT_OWM_MODE, OWM_MODES, PLATFORMS from .coordinator import WeatherUpdateCoordinator from .repairs import async_create_issue, async_delete_issue from .utils import build_data_and_options @@ -39,7 +39,7 @@ async def async_setup_entry( language = entry.options[CONF_LANGUAGE] mode = entry.options[CONF_MODE] - if mode == OWM_MODE_V25: + if mode not in OWM_MODES: async_create_issue(hass, entry.entry_id) else: async_delete_issue(hass, entry.entry_id) @@ -70,7 +70,7 @@ async def async_migrate_entry( _LOGGER.debug("Migrating OpenWeatherMap entry from version %s", version) if version < 5: - combined_data = {**data, **options, CONF_MODE: OWM_MODE_V25} + combined_data = {**data, **options, CONF_MODE: DEFAULT_OWM_MODE} new_data, new_options = build_data_and_options(combined_data) config_entries.async_update_entry( entry, diff --git a/homeassistant/components/openweathermap/const.py b/homeassistant/components/openweathermap/const.py index de317709f5b..fbd2cb1aee2 100644 --- a/homeassistant/components/openweathermap/const.py +++ b/homeassistant/components/openweathermap/const.py @@ -62,10 +62,8 @@ FORECAST_MODE_ONECALL_DAILY = "onecall_daily" OWM_MODE_FREE_CURRENT = "current" OWM_MODE_FREE_FORECAST = "forecast" OWM_MODE_V30 = "v3.0" -OWM_MODE_V25 = "v2.5" OWM_MODES = [ OWM_MODE_V30, - OWM_MODE_V25, OWM_MODE_FREE_CURRENT, OWM_MODE_FREE_FORECAST, ] diff --git a/homeassistant/components/openweathermap/manifest.json b/homeassistant/components/openweathermap/manifest.json index 14313a5a77e..88510aaae8c 100644 --- a/homeassistant/components/openweathermap/manifest.json +++ b/homeassistant/components/openweathermap/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/openweathermap", "iot_class": "cloud_polling", "loggers": ["pyopenweathermap"], - "requirements": ["pyopenweathermap==0.2.1"] + "requirements": ["pyopenweathermap==0.2.2"] } diff --git a/homeassistant/components/openweathermap/weather.py b/homeassistant/components/openweathermap/weather.py index a6ad163e1c8..12d883c871a 100644 --- a/homeassistant/components/openweathermap/weather.py +++ b/homeassistant/components/openweathermap/weather.py @@ -42,7 +42,6 @@ from .const import ( DOMAIN, MANUFACTURER, OWM_MODE_FREE_FORECAST, - OWM_MODE_V25, OWM_MODE_V30, ) from .coordinator import WeatherUpdateCoordinator @@ -106,7 +105,7 @@ class OpenWeatherMapWeather(SingleCoordinatorWeatherEntity[WeatherUpdateCoordina ) self.mode = mode - if mode in (OWM_MODE_V30, OWM_MODE_V25): + if mode == OWM_MODE_V30: self._attr_supported_features = ( WeatherEntityFeature.FORECAST_DAILY | WeatherEntityFeature.FORECAST_HOURLY diff --git a/requirements_all.txt b/requirements_all.txt index f2da895114f..8946b355e03 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2179,7 +2179,7 @@ pyombi==0.1.10 pyopenuv==2023.02.0 # homeassistant.components.openweathermap -pyopenweathermap==0.2.1 +pyopenweathermap==0.2.2 # homeassistant.components.opnsense pyopnsense==0.4.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c328478338d..e0f26ae9e98 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1778,7 +1778,7 @@ pyoctoprintapi==0.1.12 pyopenuv==2023.02.0 # homeassistant.components.openweathermap -pyopenweathermap==0.2.1 +pyopenweathermap==0.2.2 # homeassistant.components.opnsense pyopnsense==0.4.0 diff --git a/tests/components/openweathermap/test_weather.py b/tests/components/openweathermap/test_weather.py index 5d3565d6ca9..e9817e739ac 100644 --- a/tests/components/openweathermap/test_weather.py +++ b/tests/components/openweathermap/test_weather.py @@ -6,7 +6,7 @@ from syrupy import SnapshotAssertion from homeassistant.components.openweathermap.const import ( DEFAULT_LANGUAGE, DOMAIN, - OWM_MODE_V25, + OWM_MODE_FREE_CURRENT, OWM_MODE_V30, ) from homeassistant.components.openweathermap.weather import SERVICE_GET_MINUTE_FORECAST @@ -52,9 +52,9 @@ def mock_config_entry(mode: str) -> MockConfigEntry: @pytest.fixture -def mock_config_entry_v25() -> MockConfigEntry: - """Create a mock OpenWeatherMap v2.5 config entry.""" - return mock_config_entry(OWM_MODE_V25) +def mock_config_entry_free_current() -> MockConfigEntry: + """Create a mock OpenWeatherMap FREE_CURRENT config entry.""" + return mock_config_entry(OWM_MODE_FREE_CURRENT) @pytest.fixture @@ -97,15 +97,15 @@ async def test_get_minute_forecast( @patch( - "pyopenweathermap.client.onecall_client.OWMOneCallClient.get_weather", + "pyopenweathermap.client.free_client.OWMFreeClient.get_weather", AsyncMock(return_value=static_weather_report), ) async def test_mode_fail( hass: HomeAssistant, - mock_config_entry_v25: MockConfigEntry, + mock_config_entry_free_current: MockConfigEntry, ) -> None: """Test that Minute forecasting fails when mode is not v3.0.""" - await setup_mock_config_entry(hass, mock_config_entry_v25) + await setup_mock_config_entry(hass, mock_config_entry_free_current) # Expect a ServiceValidationError when mode is not OWM_MODE_V30 with pytest.raises( From 7293ae5d51b8e4b38d982af05b6004f91099a0c7 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 1 Mar 2025 22:59:14 -0500 Subject: [PATCH 2041/3148] Fix type for ESPHome assist satellite events (#139618) --- homeassistant/components/esphome/assist_satellite.py | 6 +++--- tests/components/esphome/test_assist_satellite.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/esphome/assist_satellite.py b/homeassistant/components/esphome/assist_satellite.py index 0af74621153..fdd16d20d77 100644 --- a/homeassistant/components/esphome/assist_satellite.py +++ b/homeassistant/components/esphome/assist_satellite.py @@ -285,9 +285,9 @@ class EsphomeAssistSatellite( assert event.data is not None data_to_send = { "conversation_id": event.data["intent_output"]["conversation_id"], - "continue_conversation": event.data["intent_output"][ - "continue_conversation" - ], + "continue_conversation": str( + int(event.data["intent_output"]["continue_conversation"]) + ), } elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_TTS_START: assert event.data is not None diff --git a/tests/components/esphome/test_assist_satellite.py b/tests/components/esphome/test_assist_satellite.py index 56914a0b829..3281a760c39 100644 --- a/tests/components/esphome/test_assist_satellite.py +++ b/tests/components/esphome/test_assist_satellite.py @@ -298,7 +298,7 @@ async def test_pipeline_api_audio( VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_END, { "conversation_id": conversation_id, - "continue_conversation": True, + "continue_conversation": "1", }, ) From 220509fd6c55b9f0400539bdb7ffec536504ae04 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sun, 2 Mar 2025 05:00:22 +0100 Subject: [PATCH 2042/3148] Fix body text of imap message not available in custom event data template (#139609) --- homeassistant/components/imap/coordinator.py | 2 +- tests/components/imap/test_init.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/imap/coordinator.py b/homeassistant/components/imap/coordinator.py index 74f7a86c0d6..34d3f43eb69 100644 --- a/homeassistant/components/imap/coordinator.py +++ b/homeassistant/components/imap/coordinator.py @@ -280,7 +280,7 @@ class ImapDataUpdateCoordinator(DataUpdateCoordinator[int | None]): if self.custom_event_template is not None: try: data["custom"] = self.custom_event_template.async_render( - data, parse_result=True + data | {"text": message.text}, parse_result=True ) _LOGGER.debug( "IMAP custom template (%s) for msguid %s (%s) rendered to: %s, initial: %s", diff --git a/tests/components/imap/test_init.py b/tests/components/imap/test_init.py index b86855bd78f..bdd29f7442b 100644 --- a/tests/components/imap/test_init.py +++ b/tests/components/imap/test_init.py @@ -726,9 +726,10 @@ async def test_message_data( [ ("{{ subject }}", "Test subject", None), ('{{ "@example.com" in sender }}', True, None), + ('{{ "body" in text }}', True, None), ("{% bad template }}", None, "Error rendering IMAP custom template"), ], - ids=["subject_test", "sender_filter", "template_error"], + ids=["subject_test", "sender_filter", "body_filter", "template_error"], ) async def test_custom_template( hass: HomeAssistant, From b2c7c5b1aa8e01e660256b0f696fe10464e6c601 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 2 Mar 2025 11:05:25 +0100 Subject: [PATCH 2043/3148] Treat "Core" as name, fix grammar in `reload_core_config` action (#139622) * Treat "Core" as name, fix grammar in `reload_core_config` action Change three occurrences of "core" to "Core" so they are not translated but kept as a name instead. Fix singular/plural mismatch in the field description of the `reload_core_config` action. * Change "us customary" to "US customary" --- homeassistant/components/homeassistant/strings.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/homeassistant/strings.json b/homeassistant/components/homeassistant/strings.json index 590afd697b5..4ca56471452 100644 --- a/homeassistant/components/homeassistant/strings.json +++ b/homeassistant/components/homeassistant/strings.json @@ -12,7 +12,7 @@ }, "imperial_unit_system": { "title": "The imperial unit system is deprecated", - "description": "The imperial unit system is deprecated and your system is currently using us customary. Please update your configuration to use the us customary unit system and reload the core configuration to fix this issue." + "description": "The imperial unit system is deprecated and your system is currently using US customary. Please update your configuration to use the US customary unit system and reload the Core configuration to fix this issue." }, "deprecated_yaml": { "title": "The {integration_title} YAML configuration is being removed", @@ -111,8 +111,8 @@ "description": "Checks the Home Assistant YAML-configuration files for errors. Errors will be shown in the Home Assistant logs." }, "reload_core_config": { - "name": "Reload core configuration", - "description": "Reloads the core configuration from the YAML-configuration." + "name": "Reload Core configuration", + "description": "Reloads the Core configuration from the YAML-configuration." }, "restart": { "name": "[%key:common::action::restart%]", @@ -160,7 +160,7 @@ }, "update_entity": { "name": "Update entity", - "description": "Forces one or more entities to update its data.", + "description": "Forces one or more entities to update their data.", "fields": { "entity_id": { "name": "Entities to update", From e6c946b3f4ae7ee6f34d7faf5736fe099a3bb19a Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 2 Mar 2025 13:15:43 +0100 Subject: [PATCH 2044/3148] Bump pysmartthings to 2.4.1 (#139627) --- homeassistant/components/smartthings/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/smartthings/manifest.json b/homeassistant/components/smartthings/manifest.json index e0cf6739290..7a25dc2ac13 100644 --- a/homeassistant/components/smartthings/manifest.json +++ b/homeassistant/components/smartthings/manifest.json @@ -29,5 +29,5 @@ "documentation": "https://www.home-assistant.io/integrations/smartthings", "iot_class": "cloud_push", "loggers": ["pysmartthings"], - "requirements": ["pysmartthings==2.4.0"] + "requirements": ["pysmartthings==2.4.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 8946b355e03..f98ec465f1b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2310,7 +2310,7 @@ pysma==0.7.5 pysmappee==0.2.29 # homeassistant.components.smartthings -pysmartthings==2.4.0 +pysmartthings==2.4.1 # homeassistant.components.smarty pysmarty2==0.10.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e0f26ae9e98..87e301ae4a8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1882,7 +1882,7 @@ pysma==0.7.5 pysmappee==0.2.29 # homeassistant.components.smartthings -pysmartthings==2.4.0 +pysmartthings==2.4.1 # homeassistant.components.smarty pysmarty2==0.10.2 From b0b5567316f4e063a0bf16ec518f67f465db42d2 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Sun, 2 Mar 2025 14:04:13 +0100 Subject: [PATCH 2045/3148] Add `update_habit` action to Habitica integration (#139311) * Add update_habit action * icons --- homeassistant/components/habitica/const.py | 5 + homeassistant/components/habitica/icons.json | 7 ++ homeassistant/components/habitica/services.py | 48 +++++++- .../components/habitica/services.yaml | 65 +++++++++- .../components/habitica/strings.json | 72 ++++++++++++ tests/components/habitica/test_services.py | 111 +++++++++++++++++- 6 files changed, 299 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/habitica/const.py b/homeassistant/components/habitica/const.py index bd1363ca979..ecaa66378f0 100644 --- a/homeassistant/components/habitica/const.py +++ b/homeassistant/components/habitica/const.py @@ -40,6 +40,10 @@ ATTR_ALIAS = "alias" ATTR_PRIORITY = "priority" ATTR_COST = "cost" ATTR_NOTES = "notes" +ATTR_UP_DOWN = "up_down" +ATTR_FREQUENCY = "frequency" +ATTR_COUNTER_UP = "counter_up" +ATTR_COUNTER_DOWN = "counter_down" SERVICE_CAST_SKILL = "cast_skill" SERVICE_START_QUEST = "start_quest" @@ -57,6 +61,7 @@ SERVICE_TRANSFORMATION = "transformation" SERVICE_UPDATE_REWARD = "update_reward" SERVICE_CREATE_REWARD = "create_reward" +SERVICE_UPDATE_HABIT = "update_habit" DEVELOPER_ID = "4c4ca53f-c059-4ffa-966e-9d29dd405daf" X_CLIENT = f"{DEVELOPER_ID} - {APPLICATION_NAME} {__version__}" diff --git a/homeassistant/components/habitica/icons.json b/homeassistant/components/habitica/icons.json index 83df86f3945..ca4795dd514 100644 --- a/homeassistant/components/habitica/icons.json +++ b/homeassistant/components/habitica/icons.json @@ -230,6 +230,13 @@ "sections": { "developer_options": "mdi:test-tube" } + }, + "update_habit": { + "service": "mdi:contrast-box", + "sections": { + "tag_options": "mdi:tag", + "developer_options": "mdi:test-tube" + } } } } diff --git a/homeassistant/components/habitica/services.py b/homeassistant/components/habitica/services.py index 1abe977681f..3c4a59990a3 100644 --- a/homeassistant/components/habitica/services.py +++ b/homeassistant/components/habitica/services.py @@ -10,6 +10,7 @@ from uuid import UUID from aiohttp import ClientError from habiticalib import ( Direction, + Frequency, HabiticaException, NotAuthorizedError, NotFoundError, @@ -41,8 +42,11 @@ from .const import ( ATTR_ARGS, ATTR_CONFIG_ENTRY, ATTR_COST, + ATTR_COUNTER_DOWN, + ATTR_COUNTER_UP, ATTR_DATA, ATTR_DIRECTION, + ATTR_FREQUENCY, ATTR_ITEM, ATTR_KEYWORD, ATTR_NOTES, @@ -54,6 +58,7 @@ from .const import ( ATTR_TARGET, ATTR_TASK, ATTR_TYPE, + ATTR_UP_DOWN, DOMAIN, EVENT_API_CALL_SUCCESS, SERVICE_ABORT_QUEST, @@ -69,6 +74,7 @@ from .const import ( SERVICE_SCORE_REWARD, SERVICE_START_QUEST, SERVICE_TRANSFORMATION, + SERVICE_UPDATE_HABIT, SERVICE_UPDATE_REWARD, ) from .coordinator import HabiticaConfigEntry @@ -123,6 +129,13 @@ BASE_TASK_SCHEMA = vol.Schema( cv.string, cv.matches_regex("^[a-zA-Z0-9-_]*$") ), vol.Optional(ATTR_COST): vol.All(vol.Coerce(float), vol.Range(0)), + vol.Optional(ATTR_PRIORITY): vol.All( + vol.Upper, vol.In(TaskPriority._member_names_) + ), + vol.Optional(ATTR_UP_DOWN): vol.All(cv.ensure_list, [str]), + vol.Optional(ATTR_COUNTER_UP): vol.All(int, vol.Range(0)), + vol.Optional(ATTR_COUNTER_DOWN): vol.All(int, vol.Range(0)), + vol.Optional(ATTR_FREQUENCY): vol.Coerce(Frequency), } ) @@ -173,6 +186,12 @@ ITEMID_MAP = { "shiny_seed": Skill.SHINY_SEED, } +SERVICE_TASK_TYPE_MAP = { + SERVICE_UPDATE_REWARD: TaskType.REWARD, + SERVICE_CREATE_REWARD: TaskType.REWARD, + SERVICE_UPDATE_HABIT: TaskType.HABIT, +} + def get_config_entry(hass: HomeAssistant, entry_id: str) -> HabiticaConfigEntry: """Return config entry or raise if not found or not loaded.""" @@ -551,12 +570,12 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 return result - async def create_or_update_task(call: ServiceCall) -> ServiceResponse: + async def create_or_update_task(call: ServiceCall) -> ServiceResponse: # noqa: C901 """Create or update task action.""" entry = get_config_entry(hass, call.data[ATTR_CONFIG_ENTRY]) coordinator = entry.runtime_data await coordinator.async_refresh() - is_update = call.service == SERVICE_UPDATE_REWARD + is_update = call.service in (SERVICE_UPDATE_REWARD, SERVICE_UPDATE_HABIT) current_task = None if is_update: @@ -565,7 +584,7 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 task for task in coordinator.data.tasks if call.data[ATTR_TASK] in (str(task.id), task.alias, task.text) - and task.Type is TaskType.REWARD + and task.Type is SERVICE_TASK_TYPE_MAP[call.service] ) except StopIteration as e: raise ServiceValidationError( @@ -648,6 +667,22 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 if (cost := call.data.get(ATTR_COST)) is not None: data["value"] = cost + if priority := call.data.get(ATTR_PRIORITY): + data["priority"] = TaskPriority[priority] + + if frequency := call.data.get(ATTR_FREQUENCY): + data["frequency"] = frequency + + if up_down := call.data.get(ATTR_UP_DOWN): + data["up"] = "up" in up_down + data["down"] = "down" in up_down + + if counter_up := call.data.get(ATTR_COUNTER_UP): + data["counterUp"] = counter_up + + if counter_down := call.data.get(ATTR_COUNTER_DOWN): + data["counterDown"] = counter_down + try: if is_update: if TYPE_CHECKING: @@ -684,6 +719,13 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 schema=SERVICE_UPDATE_TASK_SCHEMA, supports_response=SupportsResponse.ONLY, ) + hass.services.async_register( + DOMAIN, + SERVICE_UPDATE_HABIT, + create_or_update_task, + schema=SERVICE_UPDATE_TASK_SCHEMA, + supports_response=SupportsResponse.ONLY, + ) hass.services.async_register( DOMAIN, SERVICE_CREATE_REWARD, diff --git a/homeassistant/components/habitica/services.yaml b/homeassistant/components/habitica/services.yaml index b92b765e18c..f5a9c2b0032 100644 --- a/homeassistant/components/habitica/services.yaml +++ b/homeassistant/components/habitica/services.yaml @@ -144,7 +144,7 @@ update_reward: fields: config_entry: *config_entry task: *task - rename: + rename: &rename selector: text: notes: ¬es @@ -160,7 +160,7 @@ update_reward: step: 0.01 unit_of_measurement: "🪙" mode: box - tag_options: + tag_options: &tag_options collapsed: true fields: tag: &tag @@ -176,7 +176,7 @@ update_reward: developer_options: &developer_options collapsed: true fields: - alias: + alias: &alias required: false selector: text: @@ -193,3 +193,62 @@ create_reward: selector: *cost_selector tag: *tag developer_options: *developer_options +update_habit: + fields: + config_entry: *config_entry + task: *task + rename: *rename + notes: *notes + up_down: + required: false + selector: + select: + options: + - value: up + label: "➕" + - value: down + label: "➖" + multiple: true + mode: list + priority: + required: false + selector: + select: + options: + - "trivial" + - "easy" + - "medium" + - "hard" + mode: dropdown + translation_key: "priority" + frequency: + required: false + selector: + select: + options: + - "daily" + - "weekly" + - "monthly" + translation_key: "frequency" + mode: dropdown + tag_options: *tag_options + developer_options: + collapsed: true + fields: + counter_up: + required: false + selector: + number: + min: 0 + step: 1 + unit_of_measurement: "➕" + mode: box + counter_down: + required: false + selector: + number: + min: 0 + step: 1 + unit_of_measurement: "➖" + mode: box + alias: *alias diff --git a/homeassistant/components/habitica/strings.json b/homeassistant/components/habitica/strings.json index 0658e594d07..22ea44351da 100644 --- a/homeassistant/components/habitica/strings.json +++ b/homeassistant/components/habitica/strings.json @@ -759,6 +759,70 @@ "description": "[%key:component::habitica::common::developer_options_description%]" } } + }, + "update_habit": { + "name": "Update a habit", + "description": "Updates a specific habit for the selected Habitica character", + "fields": { + "config_entry": { + "name": "[%key:component::habitica::common::config_entry_name%]", + "description": "Select the Habitica account to update a habit." + }, + "task": { + "name": "[%key:component::habitica::common::task_name%]", + "description": "[%key:component::habitica::common::task_description%]" + }, + "rename": { + "name": "[%key:component::habitica::common::rename_name%]", + "description": "[%key:component::habitica::common::rename_description%]" + }, + "notes": { + "name": "[%key:component::habitica::common::notes_name%]", + "description": "[%key:component::habitica::common::notes_description%]" + }, + "tag": { + "name": "[%key:component::habitica::common::tag_name%]", + "description": "[%key:component::habitica::common::tag_description%]" + }, + "remove_tag": { + "name": "[%key:component::habitica::common::remove_tag_name%]", + "description": "[%key:component::habitica::common::remove_tag_description%]" + }, + "alias": { + "name": "[%key:component::habitica::common::alias_name%]", + "description": "[%key:component::habitica::common::alias_description%]" + }, + "priority": { + "name": "Difficulty", + "description": "Update the difficulty of a task." + }, + "frequency": { + "name": "Counter reset", + "description": "Update when a habit's counter resets: daily resets at the start of a new day, weekly after Sunday night, and monthly at the beginning of a new month." + }, + "up_down": { + "name": "Rewards or losses", + "description": "Update if the habit is good and rewarding (positive), bad and penalizing (negative), or both." + }, + "counter_up": { + "name": "Adjust positive counter", + "description": "Update the up counter of a positive habit." + }, + "counter_down": { + "name": "Adjust negative counter", + "description": "Update the down counter of a negative habit." + } + }, + "sections": { + "tag_options": { + "name": "[%key:component::habitica::common::tag_options_name%]", + "description": "[%key:component::habitica::common::tag_options_description%]" + }, + "developer_options": { + "name": "[%key:component::habitica::common::developer_options_name%]", + "description": "[%key:component::habitica::common::developer_options_description%]" + } + } } }, "selector": { @@ -793,6 +857,14 @@ "medium": "Medium", "hard": "Hard" } + }, + "frequency": { + "options": { + "daily": "Daily", + "weekly": "Weekly", + "monthly": "Monthly", + "yearly": "Yearly" + } } } } diff --git a/tests/components/habitica/test_services.py b/tests/components/habitica/test_services.py index 0b25dc4385e..10a8bc0a588 100644 --- a/tests/components/habitica/test_services.py +++ b/tests/components/habitica/test_services.py @@ -6,7 +6,15 @@ from unittest.mock import AsyncMock, patch from uuid import UUID from aiohttp import ClientError -from habiticalib import Direction, HabiticaTaskResponse, Skill, Task, TaskType +from habiticalib import ( + Direction, + Frequency, + HabiticaTaskResponse, + Skill, + Task, + TaskPriority, + TaskType, +) import pytest from syrupy.assertion import SnapshotAssertion @@ -14,7 +22,10 @@ from homeassistant.components.habitica.const import ( ATTR_ALIAS, ATTR_CONFIG_ENTRY, ATTR_COST, + ATTR_COUNTER_DOWN, + ATTR_COUNTER_UP, ATTR_DIRECTION, + ATTR_FREQUENCY, ATTR_ITEM, ATTR_KEYWORD, ATTR_NOTES, @@ -25,6 +36,7 @@ from homeassistant.components.habitica.const import ( ATTR_TARGET, ATTR_TASK, ATTR_TYPE, + ATTR_UP_DOWN, DOMAIN, SERVICE_ABORT_QUEST, SERVICE_ACCEPT_QUEST, @@ -38,6 +50,7 @@ from homeassistant.components.habitica.const import ( SERVICE_SCORE_REWARD, SERVICE_START_QUEST, SERVICE_TRANSFORMATION, + SERVICE_UPDATE_HABIT, SERVICE_UPDATE_REWARD, ) from homeassistant.components.todo import ATTR_RENAME @@ -919,6 +932,13 @@ async def test_get_tasks( ), ], ) +@pytest.mark.parametrize( + ("service", "task_id"), + [ + (SERVICE_UPDATE_REWARD, "5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b"), + (SERVICE_UPDATE_HABIT, "f21fa608-cfc6-4413-9fc7-0eb1b48ca43a"), + ], +) @pytest.mark.usefixtures("habitica") async def test_update_task_exceptions( hass: HomeAssistant, @@ -927,15 +947,16 @@ async def test_update_task_exceptions( exception: Exception, expected_exception: Exception, exception_msg: str, + service: str, + task_id: str, ) -> None: """Test Habitica task action exceptions.""" - task_id = "5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b" habitica.update_task.side_effect = exception with pytest.raises(expected_exception, match=exception_msg): await hass.services.async_call( DOMAIN, - SERVICE_UPDATE_REWARD, + service, service_data={ ATTR_CONFIG_ENTRY: config_entry.entry_id, ATTR_TASK: task_id, @@ -1125,6 +1146,90 @@ async def test_create_reward( habitica.create_task.assert_awaited_with(call_args) +@pytest.mark.parametrize( + ("service_data", "call_args"), + [ + ( + { + ATTR_RENAME: "RENAME", + }, + Task(text="RENAME"), + ), + ( + { + ATTR_NOTES: "NOTES", + }, + Task(notes="NOTES"), + ), + ( + { + ATTR_UP_DOWN: [""], + }, + Task(up=False, down=False), + ), + ( + { + ATTR_UP_DOWN: ["up"], + }, + Task(up=True, down=False), + ), + ( + { + ATTR_UP_DOWN: ["down"], + }, + Task(up=False, down=True), + ), + ( + { + ATTR_PRIORITY: "trivial", + }, + Task(priority=TaskPriority.TRIVIAL), + ), + ( + { + ATTR_FREQUENCY: "daily", + }, + Task(frequency=Frequency.DAILY), + ), + ( + { + ATTR_COUNTER_UP: 1, + ATTR_COUNTER_DOWN: 2, + }, + Task(counterUp=1, counterDown=2), + ), + ( + { + ATTR_ALIAS: "ALIAS", + }, + Task(alias="ALIAS"), + ), + ], +) +async def test_update_habit( + hass: HomeAssistant, + config_entry: MockConfigEntry, + habitica: AsyncMock, + service_data: dict[str, Any], + call_args: Task, +) -> None: + """Test Habitica habit action.""" + task_id = "f21fa608-cfc6-4413-9fc7-0eb1b48ca43a" + + await hass.services.async_call( + DOMAIN, + SERVICE_UPDATE_HABIT, + service_data={ + ATTR_CONFIG_ENTRY: config_entry.entry_id, + ATTR_TASK: task_id, + **service_data, + }, + return_response=True, + blocking=True, + ) + habitica.update_task.assert_awaited_with(UUID(task_id), call_args) + + async def test_tags( hass: HomeAssistant, config_entry: MockConfigEntry, From ee2b53ed0f23919cb8fd994a6b7d6d130ee81d93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexey=20ALERT=20Rubash=D1=91ff?= Date: Sun, 2 Mar 2025 15:10:45 +0200 Subject: [PATCH 2046/3148] Bump pyoverkiz to 1.16.2 (#139623) --- homeassistant/components/overkiz/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/overkiz/manifest.json b/homeassistant/components/overkiz/manifest.json index 14f69291be4..07ec02d76a6 100644 --- a/homeassistant/components/overkiz/manifest.json +++ b/homeassistant/components/overkiz/manifest.json @@ -13,7 +13,7 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["boto3", "botocore", "pyhumps", "pyoverkiz", "s3transfer"], - "requirements": ["pyoverkiz==1.16.1"], + "requirements": ["pyoverkiz==1.16.2"], "zeroconf": [ { "type": "_kizbox._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index f98ec465f1b..696aef8b03b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2199,7 +2199,7 @@ pyotgw==2.2.2 pyotp==2.8.0 # homeassistant.components.overkiz -pyoverkiz==1.16.1 +pyoverkiz==1.16.2 # homeassistant.components.onewire pyownet==0.10.0.post1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 87e301ae4a8..b9509b7fac3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1795,7 +1795,7 @@ pyotgw==2.2.2 pyotp==2.8.0 # homeassistant.components.overkiz -pyoverkiz==1.16.1 +pyoverkiz==1.16.2 # homeassistant.components.onewire pyownet==0.10.0.post1 From 29f680f9120e3f1ccf4c6bbd636d654d50ba85c9 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Sun, 2 Mar 2025 14:12:54 +0100 Subject: [PATCH 2047/3148] Add FrankEver virtual integration (#139629) * Add FranvEver virtual integration * Fix file name --- homeassistant/components/frankever/__init__.py | 1 + homeassistant/components/frankever/manifest.json | 6 ++++++ homeassistant/generated/integrations.json | 5 +++++ 3 files changed, 12 insertions(+) create mode 100644 homeassistant/components/frankever/__init__.py create mode 100644 homeassistant/components/frankever/manifest.json diff --git a/homeassistant/components/frankever/__init__.py b/homeassistant/components/frankever/__init__.py new file mode 100644 index 00000000000..66eeecb1e59 --- /dev/null +++ b/homeassistant/components/frankever/__init__.py @@ -0,0 +1 @@ +"""FrankEver virtual integration.""" diff --git a/homeassistant/components/frankever/manifest.json b/homeassistant/components/frankever/manifest.json new file mode 100644 index 00000000000..37d7be765ef --- /dev/null +++ b/homeassistant/components/frankever/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "frankever", + "name": "FrankEver", + "integration_type": "virtual", + "supported_by": "shelly" +} diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index e3185251114..1db5de7ac69 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -2046,6 +2046,11 @@ "config_flow": false, "iot_class": "cloud_push" }, + "frankever": { + "name": "FrankEver", + "integration_type": "virtual", + "supported_by": "shelly" + }, "free_mobile": { "name": "Free Mobile", "integration_type": "hub", From 3eadfcc01d3aae78e1eb82852ac5452d8eb54e4b Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 2 Mar 2025 14:17:56 +0100 Subject: [PATCH 2048/3148] Still request scopes in SmartThings (#139626) Still request scopes --- homeassistant/components/smartthings/config_flow.py | 4 ++-- homeassistant/components/smartthings/const.py | 6 ++++++ tests/components/smartthings/test_config_flow.py | 9 ++++++--- 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/smartthings/config_flow.py b/homeassistant/components/smartthings/config_flow.py index b39fe662124..0ad1b5553b1 100644 --- a/homeassistant/components/smartthings/config_flow.py +++ b/homeassistant/components/smartthings/config_flow.py @@ -11,7 +11,7 @@ from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.config_entry_oauth2_flow import AbstractOAuth2FlowHandler -from .const import CONF_LOCATION_ID, DOMAIN, OLD_DATA, SCOPES +from .const import CONF_LOCATION_ID, DOMAIN, OLD_DATA, REQUESTED_SCOPES, SCOPES _LOGGER = logging.getLogger(__name__) @@ -30,7 +30,7 @@ class SmartThingsConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN): @property def extra_authorize_data(self) -> dict[str, Any]: """Extra data that needs to be appended to the authorize url.""" - return {"scope": " ".join(SCOPES)} + return {"scope": " ".join(REQUESTED_SCOPES)} async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResult: """Create an entry for SmartThings.""" diff --git a/homeassistant/components/smartthings/const.py b/homeassistant/components/smartthings/const.py index 80c4cf90226..23fd48a4e1e 100644 --- a/homeassistant/components/smartthings/const.py +++ b/homeassistant/components/smartthings/const.py @@ -17,6 +17,12 @@ SCOPES = [ "sse", ] +REQUESTED_SCOPES = [ + *SCOPES, + "r:installedapps", + "w:installedapps", +] + CONF_APP_ID = "app_id" CONF_CLOUDHOOK_URL = "cloudhook_url" CONF_INSTALLED_APP_ID = "installed_app_id" diff --git a/tests/components/smartthings/test_config_flow.py b/tests/components/smartthings/test_config_flow.py index 2fbd686e4d3..858384db0b6 100644 --- a/tests/components/smartthings/test_config_flow.py +++ b/tests/components/smartthings/test_config_flow.py @@ -57,7 +57,8 @@ async def test_full_flow( f"&state={state}" "&scope=r:devices:*+w:devices:*+x:devices:*+r:hubs:*+" "r:locations:*+w:locations:*+x:locations:*+r:scenes:*+" - "x:scenes:*+r:rules:*+w:rules:*+sse" + "x:scenes:*+r:rules:*+w:rules:*+sse+r:installedapps+" + "w:installedapps" ) client = await hass_client_no_auth() @@ -128,7 +129,8 @@ async def test_not_enough_scopes( f"&state={state}" "&scope=r:devices:*+w:devices:*+x:devices:*+r:hubs:*+" "r:locations:*+w:locations:*+x:locations:*+r:scenes:*+" - "x:scenes:*+r:rules:*+w:rules:*+sse" + "x:scenes:*+r:rules:*+w:rules:*+sse+r:installedapps+" + "w:installedapps" ) client = await hass_client_no_auth() @@ -190,7 +192,8 @@ async def test_duplicate_entry( f"&state={state}" "&scope=r:devices:*+w:devices:*+x:devices:*+r:hubs:*+" "r:locations:*+w:locations:*+x:locations:*+r:scenes:*+" - "x:scenes:*+r:rules:*+w:rules:*+sse" + "x:scenes:*+r:rules:*+w:rules:*+sse+r:installedapps+" + "w:installedapps" ) client = await hass_client_no_auth() From d922c723d4ceff3d565110455b5bafc82bf0b4c6 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Sun, 2 Mar 2025 14:19:52 +0100 Subject: [PATCH 2049/3148] Add LinkedGo virtual integration (#139625) --- homeassistant/components/linkedgo/__init__.py | 1 + homeassistant/components/linkedgo/manifest.json | 6 ++++++ homeassistant/generated/integrations.json | 5 +++++ 3 files changed, 12 insertions(+) create mode 100644 homeassistant/components/linkedgo/__init__.py create mode 100644 homeassistant/components/linkedgo/manifest.json diff --git a/homeassistant/components/linkedgo/__init__.py b/homeassistant/components/linkedgo/__init__.py new file mode 100644 index 00000000000..e26fefa6b96 --- /dev/null +++ b/homeassistant/components/linkedgo/__init__.py @@ -0,0 +1 @@ +"""LinkedGo virtual integration.""" diff --git a/homeassistant/components/linkedgo/manifest.json b/homeassistant/components/linkedgo/manifest.json new file mode 100644 index 00000000000..03c650cac08 --- /dev/null +++ b/homeassistant/components/linkedgo/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "linkedgo", + "name": "LinkedGo", + "integration_type": "virtual", + "supported_by": "shelly" +} diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 1db5de7ac69..a92311d31d0 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -3413,6 +3413,11 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "linkedgo": { + "name": "LinkedGo", + "integration_type": "virtual", + "supported_by": "shelly" + }, "linkplay": { "name": "LinkPlay", "integration_type": "hub", From 5b1f3d3e7f99600a04b374101621c96ee4b14c55 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 2 Mar 2025 07:23:40 -0600 Subject: [PATCH 2050/3148] Fix arm vacation mode showing as armed away in elkm1 (#139613) Add native arm vacation mode support to elkm1 Vacation mode is currently implemented as a custom service which will be deprecated in a future PR. Note that the custom service was added long before HA had a native vacation mode which was added in #45980 --- homeassistant/components/elkm1/alarm_control_panel.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/elkm1/alarm_control_panel.py b/homeassistant/components/elkm1/alarm_control_panel.py index 8113a4d99a6..393845f65ff 100644 --- a/homeassistant/components/elkm1/alarm_control_panel.py +++ b/homeassistant/components/elkm1/alarm_control_panel.py @@ -105,6 +105,7 @@ class ElkArea(ElkAttachedEntity, AlarmControlPanelEntity, RestoreEntity): AlarmControlPanelEntityFeature.ARM_HOME | AlarmControlPanelEntityFeature.ARM_AWAY | AlarmControlPanelEntityFeature.ARM_NIGHT + | AlarmControlPanelEntityFeature.ARM_VACATION ) _element: Area @@ -204,7 +205,7 @@ class ElkArea(ElkAttachedEntity, AlarmControlPanelEntity, RestoreEntity): ArmedStatus.ARMED_STAY_INSTANT: AlarmControlPanelState.ARMED_HOME, ArmedStatus.ARMED_TO_NIGHT: AlarmControlPanelState.ARMED_NIGHT, ArmedStatus.ARMED_TO_NIGHT_INSTANT: AlarmControlPanelState.ARMED_NIGHT, - ArmedStatus.ARMED_TO_VACATION: AlarmControlPanelState.ARMED_AWAY, + ArmedStatus.ARMED_TO_VACATION: AlarmControlPanelState.ARMED_VACATION, } if self._element.alarm_state is None: From 0694f9e1648b6025310ffd426ac754dad92bf4a2 Mon Sep 17 00:00:00 2001 From: Maghiel Dijksman Date: Sun, 2 Mar 2025 14:25:19 +0100 Subject: [PATCH 2051/3148] Fix Tuya unsupported Temperature & Humidity Sensors (with or without external probe) (#138542) * add category qxj for th sensor with external probe. partly fixes #136472 * add TEMP_CURRENT_EXTERNAL for th sensor with external probe. fixes #136472 * ruff format * add translation_key temperature_external for TEMP_CURRENT_EXTERNAL --------- Co-authored-by: Franck Nijhof --- .../components/tuya/binary_sensor.py | 3 ++ homeassistant/components/tuya/const.py | 6 +++ homeassistant/components/tuya/sensor.py | 41 +++++++++++++++++++ homeassistant/components/tuya/strings.json | 3 ++ homeassistant/components/tuya/switch.py | 9 ++++ 5 files changed, 62 insertions(+) diff --git a/homeassistant/components/tuya/binary_sensor.py b/homeassistant/components/tuya/binary_sensor.py index 1e13f101110..486dd6e1387 100644 --- a/homeassistant/components/tuya/binary_sensor.py +++ b/homeassistant/components/tuya/binary_sensor.py @@ -291,6 +291,9 @@ BINARY_SENSORS: dict[str, tuple[TuyaBinarySensorEntityDescription, ...]] = { # Temperature and Humidity Sensor # https://developer.tuya.com/en/docs/iot/categorywsdcg?id=Kaiuz3hinij34 "wsdcg": (TAMPER_BINARY_SENSOR,), + # Temperature and Humidity Sensor with External Probe + # New undocumented category qxj, see https://github.com/home-assistant/core/issues/136472 + "qxj": (TAMPER_BINARY_SENSOR,), # Pressure Sensor # https://developer.tuya.com/en/docs/iot/categoryylcg?id=Kaiuz3kc2e4gm "ylcg": ( diff --git a/homeassistant/components/tuya/const.py b/homeassistant/components/tuya/const.py index 08bdef474ef..a40260ed787 100644 --- a/homeassistant/components/tuya/const.py +++ b/homeassistant/components/tuya/const.py @@ -333,6 +333,12 @@ class DPCode(StrEnum): TEMP_CONTROLLER = "temp_controller" TEMP_CURRENT = "temp_current" # Current temperature in °C TEMP_CURRENT_F = "temp_current_f" # Current temperature in °F + TEMP_CURRENT_EXTERNAL = ( + "temp_current_external" # Current external temperature in Celsius + ) + TEMP_CURRENT_EXTERNAL_F = ( + "temp_current_external_f" # Current external temperature in Fahrenheit + ) TEMP_INDOOR = "temp_indoor" # Indoor temperature in °C TEMP_SET = "temp_set" # Set the temperature in °C TEMP_SET_F = "temp_set_f" # Set the temperature in °F diff --git a/homeassistant/components/tuya/sensor.py b/homeassistant/components/tuya/sensor.py index 073202bed94..b1150be306a 100644 --- a/homeassistant/components/tuya/sensor.py +++ b/homeassistant/components/tuya/sensor.py @@ -715,6 +715,47 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { ), *BATTERY_SENSORS, ), + # Temperature and Humidity Sensor with External Probe + # New undocumented category qxj, see https://github.com/home-assistant/core/issues/136472 + "qxj": ( + TuyaSensorEntityDescription( + key=DPCode.VA_TEMPERATURE, + translation_key="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.TEMP_CURRENT, + translation_key="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.TEMP_CURRENT_EXTERNAL, + translation_key="temperature_external", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.VA_HUMIDITY, + translation_key="humidity", + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.HUMIDITY_VALUE, + translation_key="humidity", + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.BRIGHT_VALUE, + translation_key="illuminance", + device_class=SensorDeviceClass.ILLUMINANCE, + state_class=SensorStateClass.MEASUREMENT, + ), + *BATTERY_SENSORS, + ), # Pressure Sensor # https://developer.tuya.com/en/docs/iot/categoryylcg?id=Kaiuz3kc2e4gm "ylcg": ( diff --git a/homeassistant/components/tuya/strings.json b/homeassistant/components/tuya/strings.json index 8ec61cc8aa5..83847d32fb5 100644 --- a/homeassistant/components/tuya/strings.json +++ b/homeassistant/components/tuya/strings.json @@ -469,6 +469,9 @@ "temperature": { "name": "[%key:component::sensor::entity_component::temperature::name%]" }, + "temperature_external": { + "name": "Probe temperature" + }, "humidity": { "name": "[%key:component::sensor::entity_component::humidity::name%]" }, diff --git a/homeassistant/components/tuya/switch.py b/homeassistant/components/tuya/switch.py index 76d8b481a90..4000e8d9b24 100644 --- a/homeassistant/components/tuya/switch.py +++ b/homeassistant/components/tuya/switch.py @@ -612,6 +612,15 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { device_class=SwitchDeviceClass.OUTLET, ), ), + # SIREN: Siren (switch) with Temperature and Humidity Sensor with External Probe + # New undocumented category qxj, see https://github.com/home-assistant/core/issues/136472 + "qxj": ( + SwitchEntityDescription( + key=DPCode.SWITCH, + translation_key="switch", + device_class=SwitchDeviceClass.OUTLET, + ), + ), # Ceiling Light # https://developer.tuya.com/en/docs/iot/ceiling-light?id=Kaiuz03xxfc4r "xdd": ( From b7bedd4b8fa836357f24b25e57b3a12b8d87ac42 Mon Sep 17 00:00:00 2001 From: Martreides <8385298+Martreides@users.noreply.github.com> Date: Sun, 2 Mar 2025 14:32:10 +0100 Subject: [PATCH 2052/3148] Fix Nederlandse Spoorwegen to ignore trains in the past (#138331) * Update NS integration to show first next train instead of just the first. * Handle no first or next trip. * Remove debug statement. * Remove seconds and revert back to minutes. * Make use of dt_util.now(). * Fix issue with next train if no first train. --- .../nederlandse_spoorwegen/sensor.py | 120 +++++++++++------- 1 file changed, 77 insertions(+), 43 deletions(-) diff --git a/homeassistant/components/nederlandse_spoorwegen/sensor.py b/homeassistant/components/nederlandse_spoorwegen/sensor.py index ff3eea9252c..1e7fc54f4f7 100644 --- a/homeassistant/components/nederlandse_spoorwegen/sensor.py +++ b/homeassistant/components/nederlandse_spoorwegen/sensor.py @@ -20,7 +20,7 @@ from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.util import Throttle +from homeassistant.util import Throttle, dt as dt_util _LOGGER = logging.getLogger(__name__) @@ -119,6 +119,8 @@ class NSDepartureSensor(SensorEntity): self._time = time self._state = None self._trips = None + self._first_trip = None + self._next_trip = None @property def name(self): @@ -133,44 +135,44 @@ class NSDepartureSensor(SensorEntity): @property def extra_state_attributes(self): """Return the state attributes.""" - if not self._trips: + if not self._trips or self._first_trip is None: return None - if self._trips[0].trip_parts: - route = [self._trips[0].departure] - route.extend(k.destination for k in self._trips[0].trip_parts) + if self._first_trip.trip_parts: + route = [self._first_trip.departure] + route.extend(k.destination for k in self._first_trip.trip_parts) # Static attributes attributes = { - "going": self._trips[0].going, + "going": self._first_trip.going, "departure_time_planned": None, "departure_time_actual": None, "departure_delay": False, - "departure_platform_planned": self._trips[0].departure_platform_planned, - "departure_platform_actual": self._trips[0].departure_platform_actual, + "departure_platform_planned": self._first_trip.departure_platform_planned, + "departure_platform_actual": self._first_trip.departure_platform_actual, "arrival_time_planned": None, "arrival_time_actual": None, "arrival_delay": False, - "arrival_platform_planned": self._trips[0].arrival_platform_planned, - "arrival_platform_actual": self._trips[0].arrival_platform_actual, + "arrival_platform_planned": self._first_trip.arrival_platform_planned, + "arrival_platform_actual": self._first_trip.arrival_platform_actual, "next": None, - "status": self._trips[0].status.lower(), - "transfers": self._trips[0].nr_transfers, + "status": self._first_trip.status.lower(), + "transfers": self._first_trip.nr_transfers, "route": route, "remarks": None, } # Planned departure attributes - if self._trips[0].departure_time_planned is not None: - attributes["departure_time_planned"] = self._trips[ - 0 - ].departure_time_planned.strftime("%H:%M") + if self._first_trip.departure_time_planned is not None: + attributes["departure_time_planned"] = ( + self._first_trip.departure_time_planned.strftime("%H:%M") + ) # Actual departure attributes - if self._trips[0].departure_time_actual is not None: - attributes["departure_time_actual"] = self._trips[ - 0 - ].departure_time_actual.strftime("%H:%M") + if self._first_trip.departure_time_actual is not None: + attributes["departure_time_actual"] = ( + self._first_trip.departure_time_actual.strftime("%H:%M") + ) # Delay departure attributes if ( @@ -182,16 +184,16 @@ class NSDepartureSensor(SensorEntity): attributes["departure_delay"] = True # Planned arrival attributes - if self._trips[0].arrival_time_planned is not None: - attributes["arrival_time_planned"] = self._trips[ - 0 - ].arrival_time_planned.strftime("%H:%M") + if self._first_trip.arrival_time_planned is not None: + attributes["arrival_time_planned"] = ( + self._first_trip.arrival_time_planned.strftime("%H:%M") + ) # Actual arrival attributes - if self._trips[0].arrival_time_actual is not None: - attributes["arrival_time_actual"] = self._trips[ - 0 - ].arrival_time_actual.strftime("%H:%M") + if self._first_trip.arrival_time_actual is not None: + attributes["arrival_time_actual"] = ( + self._first_trip.arrival_time_actual.strftime("%H:%M") + ) # Delay arrival attributes if ( @@ -202,15 +204,14 @@ class NSDepartureSensor(SensorEntity): attributes["arrival_delay"] = True # Next attributes - if len(self._trips) > 1: - if self._trips[1].departure_time_actual is not None: - attributes["next"] = self._trips[1].departure_time_actual.strftime( - "%H:%M" - ) - elif self._trips[1].departure_time_planned is not None: - attributes["next"] = self._trips[1].departure_time_planned.strftime( - "%H:%M" - ) + if self._next_trip.departure_time_actual is not None: + attributes["next"] = self._next_trip.departure_time_actual.strftime("%H:%M") + elif self._next_trip.departure_time_planned is not None: + attributes["next"] = self._next_trip.departure_time_planned.strftime( + "%H:%M" + ) + else: + attributes["next"] = None return attributes @@ -225,6 +226,7 @@ class NSDepartureSensor(SensorEntity): ): self._state = None self._trips = None + self._first_trip = None return # Set the search parameter to search from a specific trip time @@ -236,19 +238,51 @@ class NSDepartureSensor(SensorEntity): .strftime("%d-%m-%Y %H:%M") ) else: - trip_time = datetime.now().strftime("%d-%m-%Y %H:%M") + trip_time = dt_util.now().strftime("%d-%m-%Y %H:%M") try: self._trips = self._nsapi.get_trips( trip_time, self._departure, self._via, self._heading, True, 0, 2 ) if self._trips: - if self._trips[0].departure_time_actual is None: - planned_time = self._trips[0].departure_time_planned - self._state = planned_time.strftime("%H:%M") + all_times = [] + + # If a train is delayed we can observe this through departure_time_actual. + for trip in self._trips: + if trip.departure_time_actual is None: + all_times.append(trip.departure_time_planned) + else: + all_times.append(trip.departure_time_actual) + + # Remove all trains that already left. + filtered_times = [ + (i, time) + for i, time in enumerate(all_times) + if time > dt_util.now() + ] + + if len(filtered_times) > 0: + sorted_times = sorted(filtered_times, key=lambda x: x[1]) + self._first_trip = self._trips[sorted_times[0][0]] + self._state = sorted_times[0][1].strftime("%H:%M") + + # Filter again to remove trains that leave at the exact same time. + filtered_times = [ + (i, time) + for i, time in enumerate(all_times) + if time > sorted_times[0][1] + ] + + if len(filtered_times) > 0: + sorted_times = sorted(filtered_times, key=lambda x: x[1]) + self._next_trip = self._trips[sorted_times[0][0]] + else: + self._next_trip = None + else: - actual_time = self._trips[0].departure_time_actual - self._state = actual_time.strftime("%H:%M") + self._first_trip = None + self._state = None + except ( requests.exceptions.ConnectionError, requests.exceptions.HTTPError, From 5ac3fe6ee12f7dda3296bb4f6e7db573f6323e79 Mon Sep 17 00:00:00 2001 From: rappenze Date: Sun, 2 Mar 2025 14:38:56 +0100 Subject: [PATCH 2053/3148] Fibaro integration refactorings (#139624) * Fibaro integration refactorings * Fix execute_action * Add test * more tests * Add tests * Fix test * More tests --- homeassistant/components/fibaro/__init__.py | 18 +-- homeassistant/components/fibaro/climate.py | 96 +++++++------ homeassistant/components/fibaro/entity.py | 19 +-- tests/components/fibaro/conftest.py | 54 ++++++- tests/components/fibaro/test_climate.py | 150 +++++++++++++++++++- tests/components/fibaro/test_light.py | 28 +++- 6 files changed, 287 insertions(+), 78 deletions(-) diff --git a/homeassistant/components/fibaro/__init__.py b/homeassistant/components/fibaro/__init__.py index 9a521e27486..33b2598a636 100644 --- a/homeassistant/components/fibaro/__init__.py +++ b/homeassistant/components/fibaro/__init__.py @@ -12,6 +12,7 @@ from pyfibaro.fibaro_client import ( FibaroClient, FibaroConnectFailed, ) +from pyfibaro.fibaro_data_helper import read_rooms from pyfibaro.fibaro_device import DeviceModel from pyfibaro.fibaro_info import InfoModel from pyfibaro.fibaro_scene import SceneModel @@ -83,7 +84,7 @@ class FibaroController: # Whether to import devices from plugins self._import_plugins = import_plugins # Mapping roomId to room object - self._room_map = {room.fibaro_id: room for room in self._client.read_rooms()} + self._room_map = read_rooms(fibaro_client) self._device_map: dict[int, DeviceModel] # Mapping deviceId to device object self.fibaro_devices: dict[Platform, list[DeviceModel]] = defaultdict( list @@ -269,9 +270,7 @@ class FibaroController: def get_room_name(self, room_id: int) -> str | None: """Get the room name by room id.""" - assert self._room_map - room = self._room_map.get(room_id) - return room.name if room else None + return self._room_map.get(room_id) def read_scenes(self) -> list[SceneModel]: """Return list of scenes.""" @@ -294,20 +293,17 @@ class FibaroController: for device in devices: try: device.fibaro_controller = self - if device.room_id == 0: + room_name = self.get_room_name(device.room_id) + if not room_name: room_name = "Unknown" - else: - room_name = self._room_map[device.room_id].name device.room_name = room_name device.friendly_name = f"{room_name} {device.name}" device.ha_id = ( f"{slugify(room_name)}_{slugify(device.name)}_{device.fibaro_id}" ) if device.enabled and (not device.is_plugin or self._import_plugins): - device.mapped_platform = self._map_device_to_platform(device) - else: - device.mapped_platform = None - if (platform := device.mapped_platform) is None: + platform = self._map_device_to_platform(device) + if platform is None: continue device.unique_id_str = f"{slugify(self.hub_serial)}.{device.fibaro_id}" self._create_device_info(device, devices) diff --git a/homeassistant/components/fibaro/climate.py b/homeassistant/components/fibaro/climate.py index d601450a70f..7a8cc3fd2a9 100644 --- a/homeassistant/components/fibaro/climate.py +++ b/homeassistant/components/fibaro/climate.py @@ -129,13 +129,13 @@ class FibaroThermostat(FibaroEntity, ClimateEntity): def __init__(self, fibaro_device: DeviceModel) -> None: """Initialize the Fibaro device.""" super().__init__(fibaro_device) - self._temp_sensor_device: FibaroEntity | None = None - self._target_temp_device: FibaroEntity | None = None - self._op_mode_device: FibaroEntity | None = None - self._fan_mode_device: FibaroEntity | None = None + self._temp_sensor_device: DeviceModel | None = None + self._target_temp_device: DeviceModel | None = None + self._op_mode_device: DeviceModel | None = None + self._fan_mode_device: DeviceModel | None = None self.entity_id = ENTITY_ID_FORMAT.format(self.ha_id) - siblings = fibaro_device.fibaro_controller.get_siblings(fibaro_device) + siblings = self.controller.get_siblings(fibaro_device) _LOGGER.debug("%s siblings: %s", fibaro_device.ha_id, siblings) tempunit = "C" for device in siblings: @@ -147,23 +147,23 @@ class FibaroThermostat(FibaroEntity, ClimateEntity): and (device.value.has_value or device.has_heating_thermostat_setpoint) and device.unit in ("C", "F") ): - self._temp_sensor_device = FibaroEntity(device) + self._temp_sensor_device = device tempunit = device.unit if any( action for action in TARGET_TEMP_ACTIONS if action in device.actions ): - self._target_temp_device = FibaroEntity(device) + self._target_temp_device = device self._attr_supported_features |= ClimateEntityFeature.TARGET_TEMPERATURE if device.has_unit: tempunit = device.unit if any(action for action in OP_MODE_ACTIONS if action in device.actions): - self._op_mode_device = FibaroEntity(device) + self._op_mode_device = device self._attr_supported_features |= ClimateEntityFeature.PRESET_MODE if "setFanMode" in device.actions: - self._fan_mode_device = FibaroEntity(device) + self._fan_mode_device = device self._attr_supported_features |= ClimateEntityFeature.FAN_MODE if tempunit == "F": @@ -172,7 +172,7 @@ class FibaroThermostat(FibaroEntity, ClimateEntity): self._attr_temperature_unit = UnitOfTemperature.CELSIUS if self._fan_mode_device: - fan_modes = self._fan_mode_device.fibaro_device.supported_modes + fan_modes = self._fan_mode_device.supported_modes self._attr_fan_modes = [] for mode in fan_modes: if mode not in FANMODES: @@ -184,7 +184,7 @@ class FibaroThermostat(FibaroEntity, ClimateEntity): if self._op_mode_device: self._attr_preset_modes = [] self._attr_hvac_modes: list[HVACMode] = [] - device = self._op_mode_device.fibaro_device + device = self._op_mode_device if device.has_supported_thermostat_modes: for mode in device.supported_thermostat_modes: try: @@ -222,15 +222,15 @@ class FibaroThermostat(FibaroEntity, ClimateEntity): "- _fan_mode_device %s" ), self.ha_id, - self._temp_sensor_device.ha_id if self._temp_sensor_device else "None", - self._target_temp_device.ha_id if self._target_temp_device else "None", - self._op_mode_device.ha_id if self._op_mode_device else "None", - self._fan_mode_device.ha_id if self._fan_mode_device else "None", + self._temp_sensor_device.fibaro_id if self._temp_sensor_device else "None", + self._target_temp_device.fibaro_id if self._target_temp_device else "None", + self._op_mode_device.fibaro_id if self._op_mode_device else "None", + self._fan_mode_device.fibaro_id if self._fan_mode_device else "None", ) await super().async_added_to_hass() # Register update callback for child devices - siblings = self.fibaro_device.fibaro_controller.get_siblings(self.fibaro_device) + siblings = self.controller.get_siblings(self.fibaro_device) for device in siblings: if device != self.fibaro_device: self.controller.register(device.fibaro_id, self._update_callback) @@ -240,14 +240,14 @@ class FibaroThermostat(FibaroEntity, ClimateEntity): """Return the fan setting.""" if not self._fan_mode_device: return None - mode = self._fan_mode_device.fibaro_device.mode + mode = self._fan_mode_device.mode return FANMODES[mode] def set_fan_mode(self, fan_mode: str) -> None: """Set new target fan mode.""" if not self._fan_mode_device: return - self._fan_mode_device.action("setFanMode", HA_FANMODES[fan_mode]) + self._fan_mode_device.execute_action("setFanMode", [HA_FANMODES[fan_mode]]) @property def fibaro_op_mode(self) -> str | int: @@ -255,7 +255,7 @@ class FibaroThermostat(FibaroEntity, ClimateEntity): if not self._op_mode_device: return HA_OPMODES_HVAC[HVACMode.AUTO] - device = self._op_mode_device.fibaro_device + device = self._op_mode_device if device.has_operating_mode: return device.operating_mode @@ -281,17 +281,17 @@ class FibaroThermostat(FibaroEntity, ClimateEntity): if not self._op_mode_device: return - if "setOperatingMode" in self._op_mode_device.fibaro_device.actions: - self._op_mode_device.action("setOperatingMode", HA_OPMODES_HVAC[hvac_mode]) - elif "setThermostatMode" in self._op_mode_device.fibaro_device.actions: - device = self._op_mode_device.fibaro_device + device = self._op_mode_device + if "setOperatingMode" in device.actions: + device.execute_action("setOperatingMode", [HA_OPMODES_HVAC[hvac_mode]]) + elif "setThermostatMode" in device.actions: if device.has_supported_thermostat_modes: for mode in device.supported_thermostat_modes: if mode.lower() == hvac_mode: - self._op_mode_device.action("setThermostatMode", mode) + device.execute_action("setThermostatMode", [mode]) break - elif "setMode" in self._op_mode_device.fibaro_device.actions: - self._op_mode_device.action("setMode", HA_OPMODES_HVAC[hvac_mode]) + elif "setMode" in device.actions: + device.execute_action("setMode", [HA_OPMODES_HVAC[hvac_mode]]) @property def hvac_action(self) -> HVACAction | None: @@ -299,7 +299,7 @@ class FibaroThermostat(FibaroEntity, ClimateEntity): if not self._op_mode_device: return None - device = self._op_mode_device.fibaro_device + device = self._op_mode_device if device.has_thermostat_operating_state: with suppress(ValueError): return HVACAction(device.thermostat_operating_state.lower()) @@ -315,15 +315,15 @@ class FibaroThermostat(FibaroEntity, ClimateEntity): if not self._op_mode_device: return None - if self._op_mode_device.fibaro_device.has_thermostat_mode: - mode = self._op_mode_device.fibaro_device.thermostat_mode + if self._op_mode_device.has_thermostat_mode: + mode = self._op_mode_device.thermostat_mode if self.preset_modes is not None and mode in self.preset_modes: return mode return None - if self._op_mode_device.fibaro_device.has_operating_mode: - mode = self._op_mode_device.fibaro_device.operating_mode + if self._op_mode_device.has_operating_mode: + mode = self._op_mode_device.operating_mode else: - mode = self._op_mode_device.fibaro_device.mode + mode = self._op_mode_device.mode if mode not in OPMODES_PRESET: return None @@ -334,20 +334,22 @@ class FibaroThermostat(FibaroEntity, ClimateEntity): if self._op_mode_device is None: return - if "setThermostatMode" in self._op_mode_device.fibaro_device.actions: - self._op_mode_device.action("setThermostatMode", preset_mode) - elif "setOperatingMode" in self._op_mode_device.fibaro_device.actions: - self._op_mode_device.action( - "setOperatingMode", HA_OPMODES_PRESET[preset_mode] + if "setThermostatMode" in self._op_mode_device.actions: + self._op_mode_device.execute_action("setThermostatMode", [preset_mode]) + elif "setOperatingMode" in self._op_mode_device.actions: + self._op_mode_device.execute_action( + "setOperatingMode", [HA_OPMODES_PRESET[preset_mode]] + ) + elif "setMode" in self._op_mode_device.actions: + self._op_mode_device.execute_action( + "setMode", [HA_OPMODES_PRESET[preset_mode]] ) - elif "setMode" in self._op_mode_device.fibaro_device.actions: - self._op_mode_device.action("setMode", HA_OPMODES_PRESET[preset_mode]) @property def current_temperature(self) -> float | None: """Return the current temperature.""" if self._temp_sensor_device: - device = self._temp_sensor_device.fibaro_device + device = self._temp_sensor_device if device.has_heating_thermostat_setpoint: return device.heating_thermostat_setpoint return device.value.float_value() @@ -357,7 +359,7 @@ class FibaroThermostat(FibaroEntity, ClimateEntity): def target_temperature(self) -> float | None: """Return the temperature we try to reach.""" if self._target_temp_device: - device = self._target_temp_device.fibaro_device + device = self._target_temp_device if device.has_heating_thermostat_setpoint_future: return device.heating_thermostat_setpoint_future return device.target_level @@ -368,9 +370,11 @@ class FibaroThermostat(FibaroEntity, ClimateEntity): temperature = kwargs.get(ATTR_TEMPERATURE) target = self._target_temp_device if target is not None and temperature is not None: - if "setThermostatSetpoint" in target.fibaro_device.actions: - target.action("setThermostatSetpoint", self.fibaro_op_mode, temperature) - elif "setHeatingThermostatSetpoint" in target.fibaro_device.actions: - target.action("setHeatingThermostatSetpoint", temperature) + if "setThermostatSetpoint" in target.actions: + target.execute_action( + "setThermostatSetpoint", [self.fibaro_op_mode, temperature] + ) + elif "setHeatingThermostatSetpoint" in target.actions: + target.execute_action("setHeatingThermostatSetpoint", [temperature]) else: - target.action("setTargetLevel", temperature) + target.execute_action("setTargetLevel", [temperature]) diff --git a/homeassistant/components/fibaro/entity.py b/homeassistant/components/fibaro/entity.py index 6a8e12136c8..5375b058315 100644 --- a/homeassistant/components/fibaro/entity.py +++ b/homeassistant/components/fibaro/entity.py @@ -11,6 +11,8 @@ from pyfibaro.fibaro_device import DeviceModel from homeassistant.const import ATTR_ARMED, ATTR_BATTERY_LEVEL from homeassistant.helpers.entity import Entity +from . import FibaroController + _LOGGER = logging.getLogger(__name__) @@ -22,7 +24,7 @@ class FibaroEntity(Entity): def __init__(self, fibaro_device: DeviceModel) -> None: """Initialize the device.""" self.fibaro_device = fibaro_device - self.controller = fibaro_device.fibaro_controller + self.controller: FibaroController = fibaro_device.fibaro_controller self.ha_id = fibaro_device.ha_id self._attr_name = fibaro_device.friendly_name self._attr_unique_id = fibaro_device.unique_id_str @@ -54,15 +56,6 @@ class FibaroEntity(Entity): return self.fibaro_device.value_2.int_value() return None - def dont_know_message(self, cmd: str) -> None: - """Make a warning in case we don't know how to perform an action.""" - _LOGGER.warning( - "Not sure how to %s: %s (available actions: %s)", - cmd, - str(self.ha_id), - str(self.fibaro_device.actions), - ) - def set_level(self, level: int) -> None: """Set the level of Fibaro device.""" self.action("setValue", level) @@ -97,11 +90,7 @@ class FibaroEntity(Entity): def action(self, cmd: str, *args: Any) -> None: """Perform an action on the Fibaro HC.""" - if cmd in self.fibaro_device.actions: - self.fibaro_device.execute_action(cmd, args) - _LOGGER.debug("-> %s.%s%s called", str(self.ha_id), str(cmd), str(args)) - else: - self.dont_know_message(cmd) + self.fibaro_device.execute_action(cmd, args) @property def current_binary_state(self) -> bool: diff --git a/tests/components/fibaro/conftest.py b/tests/components/fibaro/conftest.py index 17357e34198..55b7e35132c 100644 --- a/tests/components/fibaro/conftest.py +++ b/tests/components/fibaro/conftest.py @@ -157,12 +157,31 @@ def mock_thermostat() -> Mock: return climate +@pytest.fixture +def mock_thermostat_parent() -> Mock: + """Fixture for a thermostat.""" + climate = Mock() + climate.fibaro_id = 5 + climate.parent_fibaro_id = 0 + climate.name = "Test climate" + climate.room_id = 1 + climate.dead = False + climate.visible = True + climate.enabled = True + climate.type = "com.fibaro.device" + climate.base_type = "com.fibaro.device" + climate.properties = {"manufacturer": ""} + climate.actions = [] + return climate + + @pytest.fixture def mock_thermostat_with_operating_mode() -> Mock: """Fixture for a thermostat.""" climate = Mock() - climate.fibaro_id = 4 - climate.parent_fibaro_id = 0 + climate.fibaro_id = 6 + climate.endpoint_id = 1 + climate.parent_fibaro_id = 5 climate.name = "Test climate" climate.room_id = 1 climate.dead = False @@ -171,20 +190,47 @@ def mock_thermostat_with_operating_mode() -> Mock: climate.type = "com.fibaro.thermostatDanfoss" climate.base_type = "com.fibaro.device" climate.properties = {"manufacturer": ""} - climate.actions = {"setOperationMode": 1} + climate.actions = {"setOperatingMode": 1, "setTargetLevel": 1} climate.supported_features = {} climate.has_supported_operating_modes = True climate.supported_operating_modes = [0, 1, 15] climate.has_operating_mode = True climate.operating_mode = 15 + climate.has_supported_thermostat_modes = False climate.has_thermostat_mode = False + climate.has_unit = True + climate.unit = "C" + climate.has_heating_thermostat_setpoint = False + climate.has_heating_thermostat_setpoint_future = False + climate.target_level = 23 value_mock = Mock() value_mock.has_value = True - value_mock.int_value.return_value = 20 + value_mock.float_value.return_value = 20 climate.value = value_mock return climate +@pytest.fixture +def mock_fan_device() -> Mock: + """Fixture for a fan endpoint of a thermostat device.""" + climate = Mock() + climate.fibaro_id = 7 + climate.endpoint_id = 1 + climate.parent_fibaro_id = 5 + climate.name = "Test fan" + climate.room_id = 1 + climate.dead = False + climate.visible = True + climate.enabled = True + climate.type = "com.fibaro.fan" + climate.base_type = "com.fibaro.device" + climate.properties = {"manufacturer": ""} + climate.actions = {"setFanMode": 1} + climate.supported_modes = [0, 1, 2] + climate.mode = 1 + return climate + + @pytest.fixture def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: """Return the default mocked config entry.""" diff --git a/tests/components/fibaro/test_climate.py b/tests/components/fibaro/test_climate.py index 31022e19a08..339d9d23077 100644 --- a/tests/components/fibaro/test_climate.py +++ b/tests/components/fibaro/test_climate.py @@ -130,5 +130,153 @@ async def test_hvac_mode_with_operation_mode_support( # Act await init_integration(hass, mock_config_entry) # Assert - state = hass.states.get("climate.room_1_test_climate_4") + state = hass.states.get("climate.room_1_test_climate_6") assert state.state == HVACMode.AUTO + + +async def test_set_hvac_mode_with_operation_mode_support( + hass: HomeAssistant, + mock_fibaro_client: Mock, + mock_config_entry: MockConfigEntry, + mock_thermostat_with_operating_mode: Mock, + mock_room: Mock, +) -> None: + """Test that set_hvac_mode() works.""" + + # Arrange + mock_fibaro_client.read_rooms.return_value = [mock_room] + mock_fibaro_client.read_devices.return_value = [mock_thermostat_with_operating_mode] + + with patch("homeassistant.components.fibaro.PLATFORMS", [Platform.CLIMATE]): + # Act + await init_integration(hass, mock_config_entry) + await hass.services.async_call( + "climate", + "set_hvac_mode", + {"entity_id": "climate.room_1_test_climate_6", "hvac_mode": HVACMode.HEAT}, + blocking=True, + ) + + # Assert + mock_thermostat_with_operating_mode.execute_action.assert_called_once() + + +async def test_fan_mode( + hass: HomeAssistant, + mock_fibaro_client: Mock, + mock_config_entry: MockConfigEntry, + mock_thermostat_parent: Mock, + mock_thermostat_with_operating_mode: Mock, + mock_fan_device: Mock, + mock_room: Mock, +) -> None: + """Test that operating mode works.""" + + # Arrange + mock_fibaro_client.read_rooms.return_value = [mock_room] + mock_fibaro_client.read_devices.return_value = [ + mock_thermostat_parent, + mock_thermostat_with_operating_mode, + mock_fan_device, + ] + + with patch("homeassistant.components.fibaro.PLATFORMS", [Platform.CLIMATE]): + # Act + await init_integration(hass, mock_config_entry) + # Assert + state = hass.states.get("climate.room_1_test_climate_6") + assert state.attributes["fan_mode"] == "low" + assert state.attributes["fan_modes"] == ["off", "low", "auto_high"] + + +async def test_set_fan_mode( + hass: HomeAssistant, + mock_fibaro_client: Mock, + mock_config_entry: MockConfigEntry, + mock_thermostat_parent: Mock, + mock_thermostat_with_operating_mode: Mock, + mock_fan_device: Mock, + mock_room: Mock, +) -> None: + """Test that set_fan_mode() works.""" + + # Arrange + mock_fibaro_client.read_rooms.return_value = [mock_room] + mock_fibaro_client.read_devices.return_value = [ + mock_thermostat_parent, + mock_thermostat_with_operating_mode, + mock_fan_device, + ] + + with patch("homeassistant.components.fibaro.PLATFORMS", [Platform.CLIMATE]): + # Act + await init_integration(hass, mock_config_entry) + await hass.services.async_call( + "climate", + "set_fan_mode", + {"entity_id": "climate.room_1_test_climate_6", "fan_mode": "off"}, + blocking=True, + ) + + # Assert + mock_fan_device.execute_action.assert_called_once() + + +async def test_target_temperature( + hass: HomeAssistant, + mock_fibaro_client: Mock, + mock_config_entry: MockConfigEntry, + mock_thermostat_parent: Mock, + mock_thermostat_with_operating_mode: Mock, + mock_fan_device: Mock, + mock_room: Mock, +) -> None: + """Test that operating mode works.""" + + # Arrange + mock_fibaro_client.read_rooms.return_value = [mock_room] + mock_fibaro_client.read_devices.return_value = [ + mock_thermostat_parent, + mock_thermostat_with_operating_mode, + mock_fan_device, + ] + + with patch("homeassistant.components.fibaro.PLATFORMS", [Platform.CLIMATE]): + # Act + await init_integration(hass, mock_config_entry) + # Assert + state = hass.states.get("climate.room_1_test_climate_6") + assert state.attributes["temperature"] == 23 + + +async def test_set_target_temperature( + hass: HomeAssistant, + mock_fibaro_client: Mock, + mock_config_entry: MockConfigEntry, + mock_thermostat_parent: Mock, + mock_thermostat_with_operating_mode: Mock, + mock_fan_device: Mock, + mock_room: Mock, +) -> None: + """Test that set_fan_mode() works.""" + + # Arrange + mock_fibaro_client.read_rooms.return_value = [mock_room] + mock_fibaro_client.read_devices.return_value = [ + mock_thermostat_parent, + mock_thermostat_with_operating_mode, + mock_fan_device, + ] + + with patch("homeassistant.components.fibaro.PLATFORMS", [Platform.CLIMATE]): + # Act + await init_integration(hass, mock_config_entry) + await hass.services.async_call( + "climate", + "set_temperature", + {"entity_id": "climate.room_1_test_climate_6", "temperature": 25.5}, + blocking=True, + ) + + # Assert + mock_thermostat_with_operating_mode.execute_action.assert_called_once() diff --git a/tests/components/fibaro/test_light.py b/tests/components/fibaro/test_light.py index d0a24e009b7..88576e86dc6 100644 --- a/tests/components/fibaro/test_light.py +++ b/tests/components/fibaro/test_light.py @@ -2,7 +2,8 @@ from unittest.mock import Mock, patch -from homeassistant.const import Platform +from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN +from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -55,3 +56,28 @@ async def test_light_brightness( state = hass.states.get("light.room_1_test_light_3") assert state.attributes["brightness"] == 51 assert state.state == "on" + + +async def test_light_turn_off( + hass: HomeAssistant, + mock_fibaro_client: Mock, + mock_config_entry: MockConfigEntry, + mock_light: Mock, + mock_room: Mock, +) -> None: + """Test activate scene is called.""" + # Arrange + mock_fibaro_client.read_rooms.return_value = [mock_room] + mock_fibaro_client.read_devices.return_value = [mock_light] + + with patch("homeassistant.components.fibaro.PLATFORMS", [Platform.LIGHT]): + await init_integration(hass, mock_config_entry) + # Act + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "light.room_1_test_light_3"}, + blocking=True, + ) + # Assert + assert mock_light.execute_action.call_count == 1 From 0c803520a33af3b528756e8b099daeaecc3a957f Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Sun, 2 Mar 2025 14:40:28 +0100 Subject: [PATCH 2054/3148] Motion blind type list (#139590) * Add blind_type_list * fix * styling * fix typing * Bump motionblinds to 0.6.26 --- homeassistant/components/motion_blinds/__init__.py | 14 +++++++++++++- .../components/motion_blinds/config_flow.py | 1 + homeassistant/components/motion_blinds/const.py | 1 + homeassistant/components/motion_blinds/gateway.py | 9 +++++++-- 4 files changed, 22 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/motion_blinds/__init__.py b/homeassistant/components/motion_blinds/__init__.py index df06ffb75fc..2abcc273e23 100644 --- a/homeassistant/components/motion_blinds/__init__.py +++ b/homeassistant/components/motion_blinds/__init__.py @@ -12,6 +12,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from .const import ( + CONF_BLIND_TYPE_LIST, CONF_INTERFACE, CONF_WAIT_FOR_PUSH, DEFAULT_INTERFACE, @@ -39,6 +40,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: key = entry.data[CONF_API_KEY] multicast_interface = entry.data.get(CONF_INTERFACE, DEFAULT_INTERFACE) wait_for_push = entry.options.get(CONF_WAIT_FOR_PUSH, DEFAULT_WAIT_FOR_PUSH) + blind_type_list = entry.data.get(CONF_BLIND_TYPE_LIST) # Create multicast Listener async with setup_lock: @@ -81,7 +83,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # Connect to motion gateway multicast = hass.data[DOMAIN][KEY_MULTICAST_LISTENER] connect_gateway_class = ConnectMotionGateway(hass, multicast) - if not await connect_gateway_class.async_connect_gateway(host, key): + if not await connect_gateway_class.async_connect_gateway( + host, key, blind_type_list + ): raise ConfigEntryNotReady motion_gateway = connect_gateway_class.gateway_device api_lock = asyncio.Lock() @@ -95,6 +99,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass, entry, _LOGGER, coordinator_info ) + # store blind type list for next time + if entry.data.get(CONF_BLIND_TYPE_LIST) != motion_gateway.blind_type_list: + data = { + **entry.data, + CONF_BLIND_TYPE_LIST: motion_gateway.blind_type_list, + } + hass.config_entries.async_update_entry(entry, data=data) + # Fetch initial data so we have data when entities subscribe await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/motion_blinds/config_flow.py b/homeassistant/components/motion_blinds/config_flow.py index d8d1e7c21f1..a7bb34af1e6 100644 --- a/homeassistant/components/motion_blinds/config_flow.py +++ b/homeassistant/components/motion_blinds/config_flow.py @@ -156,6 +156,7 @@ class MotionBlindsFlowHandler(ConfigFlow, domain=DOMAIN): errors: dict[str, str] = {} if user_input is not None: key = user_input[CONF_API_KEY] + assert self._host connect_gateway_class = ConnectMotionGateway(self.hass) if not await connect_gateway_class.async_connect_gateway(self._host, key): diff --git a/homeassistant/components/motion_blinds/const.py b/homeassistant/components/motion_blinds/const.py index 96067d7ceb0..950fa3ab4c7 100644 --- a/homeassistant/components/motion_blinds/const.py +++ b/homeassistant/components/motion_blinds/const.py @@ -8,6 +8,7 @@ DEFAULT_GATEWAY_NAME = "Motionblinds Gateway" PLATFORMS = [Platform.BUTTON, Platform.COVER, Platform.SENSOR] +CONF_BLIND_TYPE_LIST = "blind_type_list" CONF_WAIT_FOR_PUSH = "wait_for_push" CONF_INTERFACE = "interface" DEFAULT_WAIT_FOR_PUSH = False diff --git a/homeassistant/components/motion_blinds/gateway.py b/homeassistant/components/motion_blinds/gateway.py index 44f7caa74b2..9826557919c 100644 --- a/homeassistant/components/motion_blinds/gateway.py +++ b/homeassistant/components/motion_blinds/gateway.py @@ -42,11 +42,16 @@ class ConnectMotionGateway: for blind in self.gateway_device.device_list.values(): blind.Update_from_cache() - async def async_connect_gateway(self, host, key): + async def async_connect_gateway( + self, + host: str, + key: str, + blind_type_list: dict[str, int] | None = None, + ) -> bool: """Connect to the Motion Gateway.""" _LOGGER.debug("Initializing with host %s (key %s)", host, key[:3]) self._gateway_device = MotionGateway( - ip=host, key=key, multicast=self._multicast + ip=host, key=key, multicast=self._multicast, blind_type_list=blind_type_list ) try: # update device info and get the connected sub devices From c9abe760237c44c7a83d0c52fc3fd602809469cd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 2 Mar 2025 08:13:06 -0600 Subject: [PATCH 2055/3148] Use multiple indexed group-by queries to get start time states for MySQL (#138786) * tweaks * mysql * mysql * Update homeassistant/components/recorder/history/modern.py * Update homeassistant/components/recorder/history/modern.py * Update homeassistant/components/recorder/const.py * Update homeassistant/components/recorder/statistics.py * Apply suggestions from code review * mysql * mysql * cover * make sure db is fully init on old schema * fixes * fixes * coverage * coverage * coverage * s/slow_dependant_subquery/slow_dependent_subquery/g * reword * comment that callers are responsible for staying under the limit * comment that callers are responsible for staying under the limit * switch to kwargs * reduce branching complexity * split stats query * preen * split tests * split tests --- homeassistant/components/recorder/const.py | 6 + .../components/recorder/history/modern.py | 149 +++- .../components/recorder/models/database.py | 10 + .../components/recorder/statistics.py | 79 ++- homeassistant/components/recorder/util.py | 29 +- .../history/test_websocket_api_schema_32.py | 6 +- tests/components/recorder/common.py | 13 +- tests/components/recorder/conftest.py | 7 + ...est_filters_with_entityfilter_schema_37.py | 15 +- tests/components/recorder/test_history.py | 27 +- .../recorder/test_history_db_schema_32.py | 5 +- .../recorder/test_history_db_schema_42.py | 5 +- .../recorder/test_purge_v32_schema.py | 4 +- tests/components/recorder/test_statistics.py | 653 +++++++++++++++++- tests/components/recorder/test_util.py | 11 +- 15 files changed, 965 insertions(+), 54 deletions(-) diff --git a/homeassistant/components/recorder/const.py b/homeassistant/components/recorder/const.py index b7ee984558c..36ff63a0496 100644 --- a/homeassistant/components/recorder/const.py +++ b/homeassistant/components/recorder/const.py @@ -30,6 +30,12 @@ CONF_DB_INTEGRITY_CHECK = "db_integrity_check" MAX_QUEUE_BACKLOG_MIN_VALUE = 65000 MIN_AVAILABLE_MEMORY_FOR_QUEUE_BACKLOG = 256 * 1024**2 +# As soon as we have more than 999 ids, split the query as the +# MySQL optimizer handles it poorly and will no longer +# do an index only scan with a group-by +# https://github.com/home-assistant/core/issues/132865#issuecomment-2543160459 +MAX_IDS_FOR_INDEXED_GROUP_BY = 999 + # The maximum number of rows (events) we purge in one delete statement DEFAULT_MAX_BIND_VARS = 4000 diff --git a/homeassistant/components/recorder/history/modern.py b/homeassistant/components/recorder/history/modern.py index 8958913bce6..566e30713f0 100644 --- a/homeassistant/components/recorder/history/modern.py +++ b/homeassistant/components/recorder/history/modern.py @@ -6,11 +6,12 @@ from collections.abc import Callable, Iterable, Iterator from datetime import datetime from itertools import groupby from operator import itemgetter -from typing import Any, cast +from typing import TYPE_CHECKING, Any, cast from sqlalchemy import ( CompoundSelect, Select, + StatementLambdaElement, Subquery, and_, func, @@ -26,8 +27,9 @@ from homeassistant.const import COMPRESSED_STATE_LAST_UPDATED, COMPRESSED_STATE_ from homeassistant.core import HomeAssistant, State, split_entity_id from homeassistant.helpers.recorder import get_instance from homeassistant.util import dt as dt_util +from homeassistant.util.collection import chunked_or_all -from ..const import LAST_REPORTED_SCHEMA_VERSION +from ..const import LAST_REPORTED_SCHEMA_VERSION, MAX_IDS_FOR_INDEXED_GROUP_BY from ..db_schema import ( SHARED_ATTR_OR_LEGACY_ATTRIBUTES, StateAttributes, @@ -149,6 +151,7 @@ def _significant_states_stmt( no_attributes: bool, include_start_time_state: bool, run_start_ts: float | None, + slow_dependent_subquery: bool, ) -> Select | CompoundSelect: """Query the database for significant state changes.""" include_last_changed = not significant_changes_only @@ -187,6 +190,7 @@ def _significant_states_stmt( metadata_ids, no_attributes, include_last_changed, + slow_dependent_subquery, ).subquery(), no_attributes, include_last_changed, @@ -257,7 +261,68 @@ def get_significant_states_with_session( start_time_ts = start_time.timestamp() end_time_ts = datetime_to_timestamp_or_none(end_time) single_metadata_id = metadata_ids[0] if len(metadata_ids) == 1 else None - stmt = lambda_stmt( + rows: list[Row] = [] + if TYPE_CHECKING: + assert instance.database_engine is not None + slow_dependent_subquery = instance.database_engine.optimizer.slow_dependent_subquery + if include_start_time_state and slow_dependent_subquery: + # https://github.com/home-assistant/core/issues/137178 + # If we include the start time state we need to limit the + # number of metadata_ids we query for at a time to avoid + # hitting limits in the MySQL optimizer that prevent + # the start time state query from using an index-only optimization + # to find the start time state. + iter_metadata_ids = chunked_or_all(metadata_ids, MAX_IDS_FOR_INDEXED_GROUP_BY) + else: + iter_metadata_ids = (metadata_ids,) + for metadata_ids_chunk in iter_metadata_ids: + stmt = _generate_significant_states_with_session_stmt( + start_time_ts, + end_time_ts, + single_metadata_id, + metadata_ids_chunk, + metadata_ids_in_significant_domains, + significant_changes_only, + no_attributes, + include_start_time_state, + oldest_ts, + slow_dependent_subquery, + ) + row_chunk = cast( + list[Row], + execute_stmt_lambda_element(session, stmt, None, end_time, orm_rows=False), + ) + if rows: + rows += row_chunk + else: + # If we have no rows yet, we can just assign the chunk + # as this is the common case since its rare that + # we exceed the MAX_IDS_FOR_INDEXED_GROUP_BY limit + rows = row_chunk + return _sorted_states_to_dict( + rows, + start_time_ts if include_start_time_state else None, + entity_ids, + entity_id_to_metadata_id, + minimal_response, + compressed_state_format, + no_attributes=no_attributes, + ) + + +def _generate_significant_states_with_session_stmt( + start_time_ts: float, + end_time_ts: float | None, + single_metadata_id: int | None, + metadata_ids: list[int], + metadata_ids_in_significant_domains: list[int], + significant_changes_only: bool, + no_attributes: bool, + include_start_time_state: bool, + oldest_ts: float | None, + slow_dependent_subquery: bool, +) -> StatementLambdaElement: + return lambda_stmt( lambda: _significant_states_stmt( start_time_ts, end_time_ts, @@ -268,6 +333,7 @@ def get_significant_states_with_session( no_attributes, include_start_time_state, oldest_ts, + slow_dependent_subquery, ), track_on=[ bool(single_metadata_id), @@ -276,17 +342,9 @@ def get_significant_states_with_session( significant_changes_only, no_attributes, include_start_time_state, + slow_dependent_subquery, ], ) - return _sorted_states_to_dict( - execute_stmt_lambda_element(session, stmt, None, end_time, orm_rows=False), - start_time_ts if include_start_time_state else None, - entity_ids, - entity_id_to_metadata_id, - minimal_response, - compressed_state_format, - no_attributes=no_attributes, - ) def get_full_significant_states_with_session( @@ -554,13 +612,14 @@ def get_last_state_changes( ) -def _get_start_time_state_for_entities_stmt( +def _get_start_time_state_for_entities_stmt_dependent_sub_query( epoch_time: float, metadata_ids: list[int], no_attributes: bool, include_last_changed: bool, ) -> Select: """Baked query to get states for specific entities.""" + # Engine has a fast dependent subquery optimizer # This query is the result of significant research in # https://github.com/home-assistant/core/issues/132865 # A reverse index scan with a limit 1 is the fastest way to get the @@ -570,7 +629,9 @@ def _get_start_time_state_for_entities_stmt( # before a specific point in time for all entities. stmt = ( _stmt_and_join_attributes_for_start_state( - no_attributes, include_last_changed, False + no_attributes=no_attributes, + include_last_changed=include_last_changed, + include_last_reported=False, ) .select_from(StatesMeta) .join( @@ -600,6 +661,55 @@ def _get_start_time_state_for_entities_stmt( ) +def _get_start_time_state_for_entities_stmt_group_by( + epoch_time: float, + metadata_ids: list[int], + no_attributes: bool, + include_last_changed: bool, +) -> Select: + """Baked query to get states for specific entities.""" + # Simple group-by for MySQL, must use less + # than 1000 metadata_ids in the IN clause for MySQL + # or it will optimize poorly. Callers are responsible + # for ensuring that the number of metadata_ids is less + # than 1000. + most_recent_states_for_entities_by_date = ( + select( + States.metadata_id.label("max_metadata_id"), + func.max(States.last_updated_ts).label("max_last_updated"), + ) + .filter( + (States.last_updated_ts < epoch_time) & States.metadata_id.in_(metadata_ids) + ) + .group_by(States.metadata_id) + .subquery() + ) + stmt = ( + _stmt_and_join_attributes_for_start_state( + no_attributes=no_attributes, + include_last_changed=include_last_changed, + include_last_reported=False, + ) + .join( + most_recent_states_for_entities_by_date, + and_( + States.metadata_id + == most_recent_states_for_entities_by_date.c.max_metadata_id, + States.last_updated_ts + == most_recent_states_for_entities_by_date.c.max_last_updated, + ), + ) + .filter( + (States.last_updated_ts < epoch_time) & States.metadata_id.in_(metadata_ids) + ) + ) + if no_attributes: + return stmt + return stmt.outerjoin( + StateAttributes, (States.attributes_id == StateAttributes.attributes_id) + ) + + def _get_oldest_possible_ts( hass: HomeAssistant, utc_point_in_time: datetime ) -> float | None: @@ -620,6 +730,7 @@ def _get_start_time_state_stmt( metadata_ids: list[int], no_attributes: bool, include_last_changed: bool, + slow_dependent_subquery: bool, ) -> Select: """Return the states at a specific point in time.""" if single_metadata_id: @@ -634,7 +745,15 @@ def _get_start_time_state_stmt( ) # We have more than one entity to look at so we need to do a query on states # since the last recorder run started. - return _get_start_time_state_for_entities_stmt( + if slow_dependent_subquery: + return _get_start_time_state_for_entities_stmt_group_by( + epoch_time, + metadata_ids, + no_attributes, + include_last_changed, + ) + + return _get_start_time_state_for_entities_stmt_dependent_sub_query( epoch_time, metadata_ids, no_attributes, diff --git a/homeassistant/components/recorder/models/database.py b/homeassistant/components/recorder/models/database.py index b86fd299793..2a4924edab3 100644 --- a/homeassistant/components/recorder/models/database.py +++ b/homeassistant/components/recorder/models/database.py @@ -37,3 +37,13 @@ class DatabaseOptimizer: # https://wiki.postgresql.org/wiki/Loose_indexscan # https://github.com/home-assistant/core/issues/126084 slow_range_in_select: bool + + # MySQL 8.x+ can end up with a file-sort on a dependent subquery + # which makes the query painfully slow. + # https://github.com/home-assistant/core/issues/137178 + # The solution is to use multiple indexed group-by queries instead + # of the subquery as long as the group by does not exceed + # 999 elements since as soon as we hit 1000 elements MySQL + # will no longer use the group_index_range optimization. + # https://github.com/home-assistant/core/issues/132865#issuecomment-2543160459 + slow_dependent_subquery: bool diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index c42a0f77c39..97fe73c54fe 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -28,6 +28,7 @@ from homeassistant.helpers.recorder import DATA_RECORDER from homeassistant.helpers.singleton import singleton from homeassistant.helpers.typing import UNDEFINED, UndefinedType from homeassistant.util import dt as dt_util +from homeassistant.util.collection import chunked_or_all from homeassistant.util.unit_conversion import ( AreaConverter, BaseUnitConverter, @@ -59,6 +60,7 @@ from .const import ( INTEGRATION_PLATFORM_LIST_STATISTIC_IDS, INTEGRATION_PLATFORM_UPDATE_STATISTICS_ISSUES, INTEGRATION_PLATFORM_VALIDATE_STATISTICS, + MAX_IDS_FOR_INDEXED_GROUP_BY, SupportedDialect, ) from .db_schema import ( @@ -1669,6 +1671,7 @@ def _augment_result_with_change( drop_sum = "sum" not in _types prev_sums = {} if tmp := _statistics_at_time( + get_instance(hass), session, {metadata[statistic_id][0] for statistic_id in result}, table, @@ -2027,7 +2030,39 @@ def get_latest_short_term_statistics_with_session( ) -def _generate_statistics_at_time_stmt( +def _generate_statistics_at_time_stmt_group_by( + table: type[StatisticsBase], + metadata_ids: set[int], + start_time_ts: float, + types: set[Literal["last_reset", "max", "mean", "min", "state", "sum"]], +) -> StatementLambdaElement: + """Create the statement for finding the statistics for a given time.""" + # Simple group-by for MySQL, must use less + # than 1000 metadata_ids in the IN clause for MySQL + # or it will optimize poorly. Callers are responsible + # for ensuring that the number of metadata_ids is less + # than 1000. + return _generate_select_columns_for_types_stmt(table, types) + ( + lambda q: q.join( + most_recent_statistic_ids := ( + select( + func.max(table.start_ts).label("max_start_ts"), + table.metadata_id.label("max_metadata_id"), + ) + .filter(table.start_ts < start_time_ts) + .filter(table.metadata_id.in_(metadata_ids)) + .group_by(table.metadata_id) + .subquery() + ), + and_( + table.start_ts == most_recent_statistic_ids.c.max_start_ts, + table.metadata_id == most_recent_statistic_ids.c.max_metadata_id, + ), + ) + ) + + +def _generate_statistics_at_time_stmt_dependent_sub_query( table: type[StatisticsBase], metadata_ids: set[int], start_time_ts: float, @@ -2041,8 +2076,7 @@ def _generate_statistics_at_time_stmt( # databases. Since all databases support this query as a join # condition we can use it as a subquery to get the last start_time_ts # before a specific point in time for all entities. - stmt = _generate_select_columns_for_types_stmt(table, types) - stmt += ( + return _generate_select_columns_for_types_stmt(table, types) + ( lambda q: q.select_from(StatisticsMeta) .join( table, @@ -2064,10 +2098,10 @@ def _generate_statistics_at_time_stmt( ) .where(table.metadata_id.in_(metadata_ids)) ) - return stmt def _statistics_at_time( + instance: Recorder, session: Session, metadata_ids: set[int], table: type[StatisticsBase], @@ -2076,8 +2110,41 @@ def _statistics_at_time( ) -> Sequence[Row] | None: """Return last known statistics, earlier than start_time, for the metadata_ids.""" start_time_ts = start_time.timestamp() - stmt = _generate_statistics_at_time_stmt(table, metadata_ids, start_time_ts, types) - return cast(Sequence[Row], execute_stmt_lambda_element(session, stmt)) + if TYPE_CHECKING: + assert instance.database_engine is not None + if not instance.database_engine.optimizer.slow_dependent_subquery: + stmt = _generate_statistics_at_time_stmt_dependent_sub_query( + table=table, + metadata_ids=metadata_ids, + start_time_ts=start_time_ts, + types=types, + ) + return cast(list[Row], execute_stmt_lambda_element(session, stmt)) + rows: list[Row] = [] + # https://github.com/home-assistant/core/issues/132865 + # If we include the start time state we need to limit the + # number of metadata_ids we query for at a time to avoid + # hitting limits in the MySQL optimizer that prevent + # the start time state query from using an index-only optimization + # to find the start time state. + for metadata_ids_chunk in chunked_or_all( + metadata_ids, MAX_IDS_FOR_INDEXED_GROUP_BY + ): + stmt = _generate_statistics_at_time_stmt_group_by( + table=table, + metadata_ids=metadata_ids_chunk, + start_time_ts=start_time_ts, + types=types, + ) + row_chunk = cast(list[Row], execute_stmt_lambda_element(session, stmt)) + if rows: + rows += row_chunk + else: + # If we have no rows yet, we can just assign the chunk + # as this is the common case since its rare that + # we exceed the MAX_IDS_FOR_INDEXED_GROUP_BY limit + rows = row_chunk + return rows def _build_sum_converted_stats( diff --git a/homeassistant/components/recorder/util.py b/homeassistant/components/recorder/util.py index a686c7c6498..0acaf0aa68f 100644 --- a/homeassistant/components/recorder/util.py +++ b/homeassistant/components/recorder/util.py @@ -464,6 +464,7 @@ def setup_connection_for_dialect( """Execute statements needed for dialect connection.""" version: AwesomeVersion | None = None slow_range_in_select = False + slow_dependent_subquery = False if dialect_name == SupportedDialect.SQLITE: if first_connection: old_isolation = dbapi_connection.isolation_level # type: ignore[attr-defined] @@ -505,9 +506,8 @@ def setup_connection_for_dialect( result = query_on_connection(dbapi_connection, "SELECT VERSION()") version_string = result[0][0] version = _extract_version_from_server_response(version_string) - is_maria_db = "mariadb" in version_string.lower() - if is_maria_db: + if "mariadb" in version_string.lower(): if not version or version < MIN_VERSION_MARIA_DB: _raise_if_version_unsupported( version or version_string, "MariaDB", MIN_VERSION_MARIA_DB @@ -523,19 +523,21 @@ def setup_connection_for_dialect( instance.hass, version, ) - + slow_range_in_select = bool( + not version + or version < MARIADB_WITH_FIXED_IN_QUERIES_105 + or MARIA_DB_106 <= version < MARIADB_WITH_FIXED_IN_QUERIES_106 + or MARIA_DB_107 <= version < MARIADB_WITH_FIXED_IN_QUERIES_107 + or MARIA_DB_108 <= version < MARIADB_WITH_FIXED_IN_QUERIES_108 + ) elif not version or version < MIN_VERSION_MYSQL: _raise_if_version_unsupported( version or version_string, "MySQL", MIN_VERSION_MYSQL ) - - slow_range_in_select = bool( - not version - or version < MARIADB_WITH_FIXED_IN_QUERIES_105 - or MARIA_DB_106 <= version < MARIADB_WITH_FIXED_IN_QUERIES_106 - or MARIA_DB_107 <= version < MARIADB_WITH_FIXED_IN_QUERIES_107 - or MARIA_DB_108 <= version < MARIADB_WITH_FIXED_IN_QUERIES_108 - ) + else: + # MySQL + # https://github.com/home-assistant/core/issues/137178 + slow_dependent_subquery = True # Ensure all times are using UTC to avoid issues with daylight savings execute_on_connection(dbapi_connection, "SET time_zone = '+00:00'") @@ -565,7 +567,10 @@ def setup_connection_for_dialect( return DatabaseEngine( dialect=SupportedDialect(dialect_name), version=version, - optimizer=DatabaseOptimizer(slow_range_in_select=slow_range_in_select), + optimizer=DatabaseOptimizer( + slow_range_in_select=slow_range_in_select, + slow_dependent_subquery=slow_dependent_subquery, + ), max_bind_vars=DEFAULT_MAX_BIND_VARS, ) diff --git a/tests/components/history/test_websocket_api_schema_32.py b/tests/components/history/test_websocket_api_schema_32.py index 7b84c47e81b..c9577e20fcf 100644 --- a/tests/components/history/test_websocket_api_schema_32.py +++ b/tests/components/history/test_websocket_api_schema_32.py @@ -1,5 +1,7 @@ """The tests the History component websocket_api.""" +from collections.abc import Generator + import pytest from homeassistant.components import recorder @@ -17,9 +19,9 @@ from tests.typing import WebSocketGenerator @pytest.fixture(autouse=True) -def db_schema_32(): +def db_schema_32(hass: HomeAssistant) -> Generator[None]: """Fixture to initialize the db with the old schema 32.""" - with old_db_schema("32"): + with old_db_schema(hass, "32"): yield diff --git a/tests/components/recorder/common.py b/tests/components/recorder/common.py index 5e1f02baeed..28eb097f576 100644 --- a/tests/components/recorder/common.py +++ b/tests/components/recorder/common.py @@ -15,7 +15,7 @@ from typing import Any, Literal, cast from unittest.mock import MagicMock, patch, sentinel from freezegun import freeze_time -from sqlalchemy import create_engine +from sqlalchemy import create_engine, event as sqlalchemy_event from sqlalchemy.orm.session import Session from homeassistant import core as ha @@ -414,7 +414,15 @@ def create_engine_test_for_schema_version_postfix( schema_module = get_schema_module_path(schema_version_postfix) importlib.import_module(schema_module) old_db_schema = sys.modules[schema_module] + instance: Recorder | None = None + if "hass" in kwargs: + hass: HomeAssistant = kwargs.pop("hass") + instance = recorder.get_instance(hass) engine = create_engine(*args, **kwargs) + if instance is not None: + instance = recorder.get_instance(hass) + instance.engine = engine + sqlalchemy_event.listen(engine, "connect", instance._setup_recorder_connection) old_db_schema.Base.metadata.create_all(engine) with Session(engine) as session: session.add( @@ -435,7 +443,7 @@ def get_schema_module_path(schema_version_postfix: str) -> str: @contextmanager -def old_db_schema(schema_version_postfix: str) -> Iterator[None]: +def old_db_schema(hass: HomeAssistant, schema_version_postfix: str) -> Iterator[None]: """Fixture to initialize the db with the old schema.""" schema_module = get_schema_module_path(schema_version_postfix) importlib.import_module(schema_module) @@ -455,6 +463,7 @@ def old_db_schema(schema_version_postfix: str) -> Iterator[None]: CREATE_ENGINE_TARGET, new=partial( create_engine_test_for_schema_version_postfix, + hass=hass, schema_version_postfix=schema_version_postfix, ), ), diff --git a/tests/components/recorder/conftest.py b/tests/components/recorder/conftest.py index 9cdf9dbb372..681205126af 100644 --- a/tests/components/recorder/conftest.py +++ b/tests/components/recorder/conftest.py @@ -13,6 +13,7 @@ from sqlalchemy.orm.session import Session from homeassistant.components import recorder from homeassistant.components.recorder import db_schema +from homeassistant.components.recorder.const import MAX_IDS_FOR_INDEXED_GROUP_BY from homeassistant.components.recorder.util import session_scope from homeassistant.core import HomeAssistant @@ -190,3 +191,9 @@ def instrument_migration( instrumented_migration.live_migration_done_stall.set() instrumented_migration.non_live_migration_done_stall.set() yield instrumented_migration + + +@pytest.fixture(params=[1, 2, MAX_IDS_FOR_INDEXED_GROUP_BY]) +def ids_for_start_time_chunk_sizes(request: pytest.FixtureRequest) -> int: + """Fixture to test different chunk sizes for start time query.""" + return request.param diff --git a/tests/components/recorder/test_filters_with_entityfilter_schema_37.py b/tests/components/recorder/test_filters_with_entityfilter_schema_37.py index d3024df4ed6..2e9883aaf53 100644 --- a/tests/components/recorder/test_filters_with_entityfilter_schema_37.py +++ b/tests/components/recorder/test_filters_with_entityfilter_schema_37.py @@ -1,6 +1,6 @@ """The tests for the recorder filter matching the EntityFilter component.""" -from collections.abc import AsyncGenerator +from collections.abc import AsyncGenerator, Generator import json from unittest.mock import patch @@ -32,12 +32,21 @@ from homeassistant.helpers.entityfilter import ( from .common import async_wait_recording_done, old_db_schema +from tests.typing import RecorderInstanceContextManager + + +@pytest.fixture +async def mock_recorder_before_hass( + async_test_recorder: RecorderInstanceContextManager, +) -> None: + """Set up recorder.""" + # This test is for schema 37 and below (32 is new enough to test) @pytest.fixture(autouse=True) -def db_schema_32(): +def db_schema_32(hass: HomeAssistant) -> Generator[None]: """Fixture to initialize the db with the old schema 32.""" - with old_db_schema("32"): + with old_db_schema(hass, "32"): yield diff --git a/tests/components/recorder/test_history.py b/tests/components/recorder/test_history.py index 166451cc971..d6223eb55b3 100644 --- a/tests/components/recorder/test_history.py +++ b/tests/components/recorder/test_history.py @@ -2,10 +2,11 @@ from __future__ import annotations +from collections.abc import Generator from copy import copy from datetime import datetime, timedelta import json -from unittest.mock import sentinel +from unittest.mock import patch, sentinel from freezegun import freeze_time import pytest @@ -36,6 +37,24 @@ from .common import ( from tests.typing import RecorderInstanceContextManager +@pytest.fixture +def multiple_start_time_chunk_sizes( + ids_for_start_time_chunk_sizes: int, +) -> Generator[None]: + """Fixture to test different chunk sizes for start time query. + + Force the recorder to use different chunk sizes for start time query. + + In effect this forces get_significant_states_with_session + to call _generate_significant_states_with_session_stmt multiple times. + """ + with patch( + "homeassistant.components.recorder.history.modern.MAX_IDS_FOR_INDEXED_GROUP_BY", + ids_for_start_time_chunk_sizes, + ): + yield + + @pytest.fixture async def mock_recorder_before_hass( async_test_recorder: RecorderInstanceContextManager, @@ -429,6 +448,7 @@ async def test_ensure_state_can_be_copied( assert_states_equal_without_context(copy(hist[entity_id][1]), hist[entity_id][1]) +@pytest.mark.usefixtures("multiple_start_time_chunk_sizes") async def test_get_significant_states(hass: HomeAssistant) -> None: """Test that only significant states are returned. @@ -443,6 +463,7 @@ async def test_get_significant_states(hass: HomeAssistant) -> None: assert_dict_of_states_equal_without_context_and_last_changed(states, hist) +@pytest.mark.usefixtures("multiple_start_time_chunk_sizes") async def test_get_significant_states_minimal_response( hass: HomeAssistant, ) -> None: @@ -512,6 +533,7 @@ async def test_get_significant_states_minimal_response( ) +@pytest.mark.usefixtures("multiple_start_time_chunk_sizes") @pytest.mark.parametrize("time_zone", ["Europe/Berlin", "US/Hawaii", "UTC"]) async def test_get_significant_states_with_initial( time_zone, hass: HomeAssistant @@ -544,6 +566,7 @@ async def test_get_significant_states_with_initial( assert_dict_of_states_equal_without_context_and_last_changed(states, hist) +@pytest.mark.usefixtures("multiple_start_time_chunk_sizes") async def test_get_significant_states_without_initial( hass: HomeAssistant, ) -> None: @@ -578,6 +601,7 @@ async def test_get_significant_states_without_initial( assert_dict_of_states_equal_without_context_and_last_changed(states, hist) +@pytest.mark.usefixtures("multiple_start_time_chunk_sizes") async def test_get_significant_states_entity_id( hass: HomeAssistant, ) -> None: @@ -596,6 +620,7 @@ async def test_get_significant_states_entity_id( assert_dict_of_states_equal_without_context_and_last_changed(states, hist) +@pytest.mark.usefixtures("multiple_start_time_chunk_sizes") async def test_get_significant_states_multiple_entity_ids( hass: HomeAssistant, ) -> None: diff --git a/tests/components/recorder/test_history_db_schema_32.py b/tests/components/recorder/test_history_db_schema_32.py index 142d2fc87f6..908a67cd635 100644 --- a/tests/components/recorder/test_history_db_schema_32.py +++ b/tests/components/recorder/test_history_db_schema_32.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Generator from copy import copy from datetime import datetime, timedelta import json @@ -50,9 +51,9 @@ def disable_states_meta_manager(): @pytest.fixture(autouse=True) -def db_schema_32(): +def db_schema_32(hass: HomeAssistant) -> Generator[None]: """Fixture to initialize the db with the old schema 32.""" - with old_db_schema("32"): + with old_db_schema(hass, "32"): yield diff --git a/tests/components/recorder/test_history_db_schema_42.py b/tests/components/recorder/test_history_db_schema_42.py index 1523f373ea8..20d0c162d35 100644 --- a/tests/components/recorder/test_history_db_schema_42.py +++ b/tests/components/recorder/test_history_db_schema_42.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Generator from copy import copy from datetime import datetime, timedelta import json @@ -42,9 +43,9 @@ async def mock_recorder_before_hass( @pytest.fixture(autouse=True) -def db_schema_42(): +def db_schema_42(hass: HomeAssistant) -> Generator[None]: """Fixture to initialize the db with the old schema 42.""" - with old_db_schema("42"): + with old_db_schema(hass, "42"): yield diff --git a/tests/components/recorder/test_purge_v32_schema.py b/tests/components/recorder/test_purge_v32_schema.py index 45bef68dabd..0212e4b012e 100644 --- a/tests/components/recorder/test_purge_v32_schema.py +++ b/tests/components/recorder/test_purge_v32_schema.py @@ -58,9 +58,9 @@ async def mock_recorder_before_hass( @pytest.fixture(autouse=True) -def db_schema_32(): +def db_schema_32(hass: HomeAssistant) -> Generator[None]: """Fixture to initialize the db with the old schema 32.""" - with old_db_schema("32"): + with old_db_schema(hass, "32"): yield diff --git a/tests/components/recorder/test_statistics.py b/tests/components/recorder/test_statistics.py index 6e192295c58..ed883c5403e 100644 --- a/tests/components/recorder/test_statistics.py +++ b/tests/components/recorder/test_statistics.py @@ -1,5 +1,6 @@ """The tests for sensor recorder platform.""" +from collections.abc import Generator from datetime import timedelta from typing import Any from unittest.mock import ANY, Mock, patch @@ -18,7 +19,8 @@ from homeassistant.components.recorder.statistics import ( STATISTIC_UNIT_TO_UNIT_CONVERTER, PlatformCompiledStatistics, _generate_max_mean_min_statistic_in_sub_period_stmt, - _generate_statistics_at_time_stmt, + _generate_statistics_at_time_stmt_dependent_sub_query, + _generate_statistics_at_time_stmt_group_by, _generate_statistics_during_period_stmt, async_add_external_statistics, async_import_statistics, @@ -57,6 +59,24 @@ from tests.common import MockPlatform, mock_platform from tests.typing import RecorderInstanceContextManager, WebSocketGenerator +@pytest.fixture +def multiple_start_time_chunk_sizes( + ids_for_start_time_chunk_sizes: int, +) -> Generator[None]: + """Fixture to test different chunk sizes for start time query. + + Force the statistics query to use different chunk sizes for start time query. + + In effect this forces _statistics_at_time + to call _generate_statistics_at_time_stmt_group_by multiple times. + """ + with patch( + "homeassistant.components.recorder.statistics.MAX_IDS_FOR_INDEXED_GROUP_BY", + ids_for_start_time_chunk_sizes, + ): + yield + + @pytest.fixture async def mock_recorder_before_hass( async_test_recorder: RecorderInstanceContextManager, @@ -1113,6 +1133,7 @@ async def test_import_statistics_errors( assert get_metadata(hass, statistic_ids={"sensor.total_energy_import"}) == {} +@pytest.mark.usefixtures("multiple_start_time_chunk_sizes") @pytest.mark.parametrize("timezone", ["America/Regina", "Europe/Vienna", "UTC"]) @pytest.mark.freeze_time("2022-10-01 00:00:00+00:00") async def test_daily_statistics_sum( @@ -1293,6 +1314,215 @@ async def test_daily_statistics_sum( assert stats == {} +@pytest.mark.usefixtures("multiple_start_time_chunk_sizes") +@pytest.mark.parametrize("timezone", ["America/Regina", "Europe/Vienna", "UTC"]) +@pytest.mark.freeze_time("2022-10-01 00:00:00+00:00") +async def test_multiple_daily_statistics_sum( + hass: HomeAssistant, + setup_recorder: None, + caplog: pytest.LogCaptureFixture, + timezone, +) -> None: + """Test daily statistics.""" + await hass.config.async_set_time_zone(timezone) + await async_wait_recording_done(hass) + assert "Compiling statistics for" not in caplog.text + assert "Statistics already compiled" not in caplog.text + + zero = dt_util.utcnow() + period1 = dt_util.as_utc(dt_util.parse_datetime("2022-10-03 00:00:00")) + period2 = dt_util.as_utc(dt_util.parse_datetime("2022-10-03 23:00:00")) + period3 = dt_util.as_utc(dt_util.parse_datetime("2022-10-04 00:00:00")) + period4 = dt_util.as_utc(dt_util.parse_datetime("2022-10-04 23:00:00")) + period5 = dt_util.as_utc(dt_util.parse_datetime("2022-10-05 00:00:00")) + period6 = dt_util.as_utc(dt_util.parse_datetime("2022-10-05 23:00:00")) + + external_statistics = ( + { + "start": period1, + "last_reset": None, + "state": 0, + "sum": 2, + }, + { + "start": period2, + "last_reset": None, + "state": 1, + "sum": 3, + }, + { + "start": period3, + "last_reset": None, + "state": 2, + "sum": 4, + }, + { + "start": period4, + "last_reset": None, + "state": 3, + "sum": 5, + }, + { + "start": period5, + "last_reset": None, + "state": 4, + "sum": 6, + }, + { + "start": period6, + "last_reset": None, + "state": 5, + "sum": 7, + }, + ) + external_metadata1 = { + "has_mean": False, + "has_sum": True, + "name": "Total imported energy 1", + "source": "test", + "statistic_id": "test:total_energy_import2", + "unit_of_measurement": "kWh", + } + external_metadata2 = { + "has_mean": False, + "has_sum": True, + "name": "Total imported energy 2", + "source": "test", + "statistic_id": "test:total_energy_import1", + "unit_of_measurement": "kWh", + } + + async_add_external_statistics(hass, external_metadata1, external_statistics) + async_add_external_statistics(hass, external_metadata2, external_statistics) + + await async_wait_recording_done(hass) + stats = statistics_during_period( + hass, + zero, + period="day", + statistic_ids={"test:total_energy_import1", "test:total_energy_import2"}, + ) + day1_start = dt_util.as_utc(dt_util.parse_datetime("2022-10-03 00:00:00")) + day1_end = dt_util.as_utc(dt_util.parse_datetime("2022-10-04 00:00:00")) + day2_start = dt_util.as_utc(dt_util.parse_datetime("2022-10-04 00:00:00")) + day2_end = dt_util.as_utc(dt_util.parse_datetime("2022-10-05 00:00:00")) + day3_start = dt_util.as_utc(dt_util.parse_datetime("2022-10-05 00:00:00")) + day3_end = dt_util.as_utc(dt_util.parse_datetime("2022-10-06 00:00:00")) + expected_stats_inner = [ + { + "start": day1_start.timestamp(), + "end": day1_end.timestamp(), + "last_reset": None, + "state": 1.0, + "sum": 3.0, + }, + { + "start": day2_start.timestamp(), + "end": day2_end.timestamp(), + "last_reset": None, + "state": 3.0, + "sum": 5.0, + }, + { + "start": day3_start.timestamp(), + "end": day3_end.timestamp(), + "last_reset": None, + "state": 5.0, + "sum": 7.0, + }, + ] + expected_stats = { + "test:total_energy_import1": expected_stats_inner, + "test:total_energy_import2": expected_stats_inner, + } + assert stats == expected_stats + + # Get change + stats = statistics_during_period( + hass, + start_time=period1, + statistic_ids={"test:total_energy_import1", "test:total_energy_import2"}, + period="day", + types={"change"}, + ) + expected_inner = [ + { + "start": day1_start.timestamp(), + "end": day1_end.timestamp(), + "change": 3.0, + }, + { + "start": day2_start.timestamp(), + "end": day2_end.timestamp(), + "change": 2.0, + }, + { + "start": day3_start.timestamp(), + "end": day3_end.timestamp(), + "change": 2.0, + }, + ] + assert stats == { + "test:total_energy_import1": expected_inner, + "test:total_energy_import2": expected_inner, + } + + # Get data with start during the first period + stats = statistics_during_period( + hass, + start_time=period1 + timedelta(hours=1), + statistic_ids={"test:total_energy_import1", "test:total_energy_import2"}, + period="day", + ) + assert stats == expected_stats + + # Get data with end during the third period + stats = statistics_during_period( + hass, + start_time=zero, + end_time=period6 - timedelta(hours=1), + statistic_ids={"test:total_energy_import1", "test:total_energy_import2"}, + period="day", + ) + assert stats == expected_stats + + # Try to get data for entities which do not exist + stats = statistics_during_period( + hass, + start_time=zero, + statistic_ids={ + "not", + "the", + "same", + "test:total_energy_import1", + "test:total_energy_import2", + }, + period="day", + ) + assert stats == expected_stats + + # Use 5minute to ensure table switch works + stats = statistics_during_period( + hass, + start_time=zero, + statistic_ids=[ + "test:total_energy_import1", + "with_other", + "test:total_energy_import2", + ], + period="5minute", + ) + assert stats == {} + + # Ensure future date has not data + future = dt_util.as_utc(dt_util.parse_datetime("2221-11-01 00:00:00")) + stats = statistics_during_period( + hass, start_time=future, end_time=future, period="day" + ) + assert stats == {} + + +@pytest.mark.usefixtures("multiple_start_time_chunk_sizes") @pytest.mark.parametrize("timezone", ["America/Regina", "Europe/Vienna", "UTC"]) @pytest.mark.freeze_time("2022-10-01 00:00:00+00:00") async def test_weekly_statistics_mean( @@ -1428,6 +1658,7 @@ async def test_weekly_statistics_mean( assert stats == {} +@pytest.mark.usefixtures("multiple_start_time_chunk_sizes") @pytest.mark.parametrize("timezone", ["America/Regina", "Europe/Vienna", "UTC"]) @pytest.mark.freeze_time("2022-10-01 00:00:00+00:00") async def test_weekly_statistics_sum( @@ -1608,6 +1839,7 @@ async def test_weekly_statistics_sum( assert stats == {} +@pytest.mark.usefixtures("multiple_start_time_chunk_sizes") @pytest.mark.parametrize("timezone", ["America/Regina", "Europe/Vienna", "UTC"]) @pytest.mark.freeze_time("2021-08-01 00:00:00+00:00") async def test_monthly_statistics_sum( @@ -1914,20 +2146,43 @@ def test_cache_key_for_generate_max_mean_min_statistic_in_sub_period_stmt() -> N assert cache_key_1 != cache_key_3 -def test_cache_key_for_generate_statistics_at_time_stmt() -> None: - """Test cache key for _generate_statistics_at_time_stmt.""" - stmt = _generate_statistics_at_time_stmt(StatisticsShortTerm, {0}, 0.0, set()) +def test_cache_key_for_generate_statistics_at_time_stmt_group_by() -> None: + """Test cache key for _generate_statistics_at_time_stmt_group_by.""" + stmt = _generate_statistics_at_time_stmt_group_by( + StatisticsShortTerm, {0}, 0.0, set() + ) cache_key_1 = stmt._generate_cache_key() - stmt2 = _generate_statistics_at_time_stmt(StatisticsShortTerm, {0}, 0.0, set()) + stmt2 = _generate_statistics_at_time_stmt_group_by( + StatisticsShortTerm, {0}, 0.0, set() + ) cache_key_2 = stmt2._generate_cache_key() assert cache_key_1 == cache_key_2 - stmt3 = _generate_statistics_at_time_stmt( + stmt3 = _generate_statistics_at_time_stmt_group_by( StatisticsShortTerm, {0}, 0.0, {"sum", "mean"} ) cache_key_3 = stmt3._generate_cache_key() assert cache_key_1 != cache_key_3 +def test_cache_key_for_generate_statistics_at_time_stmt_dependent_sub_query() -> None: + """Test cache key for _generate_statistics_at_time_stmt_dependent_sub_query.""" + stmt = _generate_statistics_at_time_stmt_dependent_sub_query( + StatisticsShortTerm, {0}, 0.0, set() + ) + cache_key_1 = stmt._generate_cache_key() + stmt2 = _generate_statistics_at_time_stmt_dependent_sub_query( + StatisticsShortTerm, {0}, 0.0, set() + ) + cache_key_2 = stmt2._generate_cache_key() + assert cache_key_1 == cache_key_2 + stmt3 = _generate_statistics_at_time_stmt_dependent_sub_query( + StatisticsShortTerm, {0}, 0.0, {"sum", "mean"} + ) + cache_key_3 = stmt3._generate_cache_key() + assert cache_key_1 != cache_key_3 + + +@pytest.mark.usefixtures("multiple_start_time_chunk_sizes") @pytest.mark.parametrize("timezone", ["America/Regina", "Europe/Vienna", "UTC"]) @pytest.mark.freeze_time("2022-10-01 00:00:00+00:00") async def test_change( @@ -2263,6 +2518,392 @@ async def test_change( assert stats == {} +@pytest.mark.usefixtures("multiple_start_time_chunk_sizes") +@pytest.mark.parametrize("timezone", ["America/Regina", "Europe/Vienna", "UTC"]) +@pytest.mark.freeze_time("2022-10-01 00:00:00+00:00") +async def test_change_multiple( + hass: HomeAssistant, + setup_recorder: None, + caplog: pytest.LogCaptureFixture, + timezone, +) -> None: + """Test deriving change from sum statistic.""" + await hass.config.async_set_time_zone(timezone) + await async_wait_recording_done(hass) + assert "Compiling statistics for" not in caplog.text + assert "Statistics already compiled" not in caplog.text + + zero = dt_util.utcnow() + period1 = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 00:00:00")) + period2 = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 01:00:00")) + period3 = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 02:00:00")) + period4 = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 03:00:00")) + + external_statistics = ( + { + "start": period1, + "last_reset": None, + "state": 0, + "sum": 2, + }, + { + "start": period2, + "last_reset": None, + "state": 1, + "sum": 3, + }, + { + "start": period3, + "last_reset": None, + "state": 2, + "sum": 5, + }, + { + "start": period4, + "last_reset": None, + "state": 3, + "sum": 8, + }, + ) + external_metadata1 = { + "has_mean": False, + "has_sum": True, + "name": "Total imported energy", + "source": "recorder", + "statistic_id": "sensor.total_energy_import1", + "unit_of_measurement": "kWh", + } + external_metadata2 = { + "has_mean": False, + "has_sum": True, + "name": "Total imported energy", + "source": "recorder", + "statistic_id": "sensor.total_energy_import2", + "unit_of_measurement": "kWh", + } + async_import_statistics(hass, external_metadata1, external_statistics) + async_import_statistics(hass, external_metadata2, external_statistics) + await async_wait_recording_done(hass) + # Get change from far in the past + stats = statistics_during_period( + hass, + zero, + period="hour", + statistic_ids={"sensor.total_energy_import1", "sensor.total_energy_import2"}, + types={"change"}, + ) + hour1_start = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 00:00:00")) + hour1_end = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 01:00:00")) + hour2_start = hour1_end + hour2_end = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 02:00:00")) + hour3_start = hour2_end + hour3_end = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 03:00:00")) + hour4_start = hour3_end + hour4_end = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 04:00:00")) + expected_inner = [ + { + "start": hour1_start.timestamp(), + "end": hour1_end.timestamp(), + "change": 2.0, + }, + { + "start": hour2_start.timestamp(), + "end": hour2_end.timestamp(), + "change": 1.0, + }, + { + "start": hour3_start.timestamp(), + "end": hour3_end.timestamp(), + "change": 2.0, + }, + { + "start": hour4_start.timestamp(), + "end": hour4_end.timestamp(), + "change": 3.0, + }, + ] + expected_stats = { + "sensor.total_energy_import1": expected_inner, + "sensor.total_energy_import2": expected_inner, + } + assert stats == expected_stats + + # Get change + sum from far in the past + stats = statistics_during_period( + hass, + zero, + period="hour", + statistic_ids={"sensor.total_energy_import1", "sensor.total_energy_import2"}, + types={"change", "sum"}, + ) + hour1_start = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 00:00:00")) + hour1_end = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 01:00:00")) + hour2_start = hour1_end + hour2_end = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 02:00:00")) + hour3_start = hour2_end + hour3_end = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 03:00:00")) + hour4_start = hour3_end + hour4_end = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 04:00:00")) + expected_inner = [ + { + "start": hour1_start.timestamp(), + "end": hour1_end.timestamp(), + "change": 2.0, + "sum": 2.0, + }, + { + "start": hour2_start.timestamp(), + "end": hour2_end.timestamp(), + "change": 1.0, + "sum": 3.0, + }, + { + "start": hour3_start.timestamp(), + "end": hour3_end.timestamp(), + "change": 2.0, + "sum": 5.0, + }, + { + "start": hour4_start.timestamp(), + "end": hour4_end.timestamp(), + "change": 3.0, + "sum": 8.0, + }, + ] + expected_stats_change_sum = { + "sensor.total_energy_import1": expected_inner, + "sensor.total_energy_import2": expected_inner, + } + assert stats == expected_stats_change_sum + + # Get change from far in the past with unit conversion + stats = statistics_during_period( + hass, + start_time=hour1_start, + statistic_ids={"sensor.total_energy_import1", "sensor.total_energy_import2"}, + period="hour", + types={"change"}, + units={"energy": "Wh"}, + ) + expected_inner = [ + { + "start": hour1_start.timestamp(), + "end": hour1_end.timestamp(), + "change": 2.0 * 1000, + }, + { + "start": hour2_start.timestamp(), + "end": hour2_end.timestamp(), + "change": 1.0 * 1000, + }, + { + "start": hour3_start.timestamp(), + "end": hour3_end.timestamp(), + "change": 2.0 * 1000, + }, + { + "start": hour4_start.timestamp(), + "end": hour4_end.timestamp(), + "change": 3.0 * 1000, + }, + ] + expected_stats_wh = { + "sensor.total_energy_import1": expected_inner, + "sensor.total_energy_import2": expected_inner, + } + assert stats == expected_stats_wh + + # Get change from far in the past with implicit unit conversion + hass.states.async_set( + "sensor.total_energy_import1", "unknown", {"unit_of_measurement": "MWh"} + ) + hass.states.async_set( + "sensor.total_energy_import2", "unknown", {"unit_of_measurement": "MWh"} + ) + stats = statistics_during_period( + hass, + start_time=hour1_start, + statistic_ids={"sensor.total_energy_import1", "sensor.total_energy_import2"}, + period="hour", + types={"change"}, + ) + expected_inner = [ + { + "start": hour1_start.timestamp(), + "end": hour1_end.timestamp(), + "change": 2.0 / 1000, + }, + { + "start": hour2_start.timestamp(), + "end": hour2_end.timestamp(), + "change": 1.0 / 1000, + }, + { + "start": hour3_start.timestamp(), + "end": hour3_end.timestamp(), + "change": 2.0 / 1000, + }, + { + "start": hour4_start.timestamp(), + "end": hour4_end.timestamp(), + "change": 3.0 / 1000, + }, + ] + expected_stats_mwh = { + "sensor.total_energy_import1": expected_inner, + "sensor.total_energy_import2": expected_inner, + } + assert stats == expected_stats_mwh + hass.states.async_remove("sensor.total_energy_import1") + hass.states.async_remove("sensor.total_energy_import2") + + # Get change from the first recorded hour + stats = statistics_during_period( + hass, + start_time=hour1_start, + statistic_ids={"sensor.total_energy_import1", "sensor.total_energy_import2"}, + period="hour", + types={"change"}, + ) + assert stats == expected_stats + + # Get change from the first recorded hour with unit conversion + stats = statistics_during_period( + hass, + start_time=hour1_start, + statistic_ids={"sensor.total_energy_import1", "sensor.total_energy_import2"}, + period="hour", + types={"change"}, + units={"energy": "Wh"}, + ) + assert stats == expected_stats_wh + + # Get change from the first recorded hour with implicit unit conversion + hass.states.async_set( + "sensor.total_energy_import1", "unknown", {"unit_of_measurement": "MWh"} + ) + hass.states.async_set( + "sensor.total_energy_import2", "unknown", {"unit_of_measurement": "MWh"} + ) + stats = statistics_during_period( + hass, + start_time=hour1_start, + statistic_ids={"sensor.total_energy_import1", "sensor.total_energy_import2"}, + period="hour", + types={"change"}, + ) + assert stats == expected_stats_mwh + hass.states.async_remove("sensor.total_energy_import1") + hass.states.async_remove("sensor.total_energy_import2") + + # Get change from the second recorded hour + stats = statistics_during_period( + hass, + start_time=hour2_start, + statistic_ids={"sensor.total_energy_import1", "sensor.total_energy_import2"}, + period="hour", + types={"change"}, + ) + assert stats == { + "sensor.total_energy_import1": expected_stats["sensor.total_energy_import1"][ + 1:4 + ], + "sensor.total_energy_import2": expected_stats["sensor.total_energy_import2"][ + 1:4 + ], + } + + # Get change from the second recorded hour with unit conversion + stats = statistics_during_period( + hass, + start_time=hour2_start, + statistic_ids={"sensor.total_energy_import1", "sensor.total_energy_import2"}, + period="hour", + types={"change"}, + units={"energy": "Wh"}, + ) + assert stats == { + "sensor.total_energy_import1": expected_stats_wh["sensor.total_energy_import1"][ + 1:4 + ], + "sensor.total_energy_import2": expected_stats_wh["sensor.total_energy_import2"][ + 1:4 + ], + } + + # Get change from the second recorded hour with implicit unit conversion + hass.states.async_set( + "sensor.total_energy_import1", "unknown", {"unit_of_measurement": "MWh"} + ) + hass.states.async_set( + "sensor.total_energy_import2", "unknown", {"unit_of_measurement": "MWh"} + ) + stats = statistics_during_period( + hass, + start_time=hour2_start, + statistic_ids={"sensor.total_energy_import1", "sensor.total_energy_import2"}, + period="hour", + types={"change"}, + ) + assert stats == { + "sensor.total_energy_import1": expected_stats_mwh[ + "sensor.total_energy_import1" + ][1:4], + "sensor.total_energy_import2": expected_stats_mwh[ + "sensor.total_energy_import2" + ][1:4], + } + hass.states.async_remove("sensor.total_energy_import1") + hass.states.async_remove("sensor.total_energy_import2") + + # Get change from the second until the third recorded hour + stats = statistics_during_period( + hass, + start_time=hour2_start, + end_time=hour4_start, + statistic_ids={"sensor.total_energy_import1", "sensor.total_energy_import2"}, + period="hour", + types={"change"}, + ) + assert stats == { + "sensor.total_energy_import1": expected_stats["sensor.total_energy_import1"][ + 1:3 + ], + "sensor.total_energy_import2": expected_stats["sensor.total_energy_import2"][ + 1:3 + ], + } + + # Get change from the fourth recorded hour + stats = statistics_during_period( + hass, + start_time=hour4_start, + statistic_ids={"sensor.total_energy_import1", "sensor.total_energy_import2"}, + period="hour", + types={"change"}, + ) + assert stats == { + "sensor.total_energy_import1": expected_stats["sensor.total_energy_import1"][ + 3:4 + ], + "sensor.total_energy_import2": expected_stats["sensor.total_energy_import2"][ + 3:4 + ], + } + + # Test change with a far future start date + future = dt_util.as_utc(dt_util.parse_datetime("2221-11-01 00:00:00")) + stats = statistics_during_period( + hass, + start_time=future, + statistic_ids={"sensor.total_energy_import1", "sensor.total_energy_import2"}, + period="hour", + types={"change"}, + ) + assert stats == {} + + +@pytest.mark.usefixtures("multiple_start_time_chunk_sizes") @pytest.mark.parametrize("timezone", ["America/Regina", "Europe/Vienna", "UTC"]) @pytest.mark.freeze_time("2022-10-01 00:00:00+00:00") async def test_change_with_none( diff --git a/tests/components/recorder/test_util.py b/tests/components/recorder/test_util.py index c9020762d4b..6c324f4b01a 100644 --- a/tests/components/recorder/test_util.py +++ b/tests/components/recorder/test_util.py @@ -417,7 +417,12 @@ def test_supported_mysql(caplog: pytest.LogCaptureFixture, mysql_version) -> Non dbapi_connection = MagicMock(cursor=_make_cursor_mock) - util.setup_connection_for_dialect(instance_mock, "mysql", dbapi_connection, True) + database_engine = util.setup_connection_for_dialect( + instance_mock, "mysql", dbapi_connection, True + ) + assert database_engine is not None + assert database_engine.optimizer.slow_range_in_select is False + assert database_engine.optimizer.slow_dependent_subquery is True assert "minimum supported version" not in caplog.text @@ -502,6 +507,7 @@ def test_supported_pgsql(caplog: pytest.LogCaptureFixture, pgsql_version) -> Non assert "minimum supported version" not in caplog.text assert database_engine is not None assert database_engine.optimizer.slow_range_in_select is True + assert database_engine.optimizer.slow_dependent_subquery is False @pytest.mark.parametrize( @@ -583,6 +589,7 @@ def test_supported_sqlite(caplog: pytest.LogCaptureFixture, sqlite_version) -> N assert "minimum supported version" not in caplog.text assert database_engine is not None assert database_engine.optimizer.slow_range_in_select is False + assert database_engine.optimizer.slow_dependent_subquery is False @pytest.mark.parametrize( @@ -675,6 +682,7 @@ async def test_issue_for_mariadb_with_MDEV_25020( assert database_engine is not None assert database_engine.optimizer.slow_range_in_select is True + assert database_engine.optimizer.slow_dependent_subquery is False @pytest.mark.parametrize( @@ -731,6 +739,7 @@ async def test_no_issue_for_mariadb_with_MDEV_25020( assert database_engine is not None assert database_engine.optimizer.slow_range_in_select is False + assert database_engine.optimizer.slow_dependent_subquery is False @pytest.mark.skip_on_db_engine(["mysql", "postgresql"]) From 17c16144d15cd77b206668a023a7c3a8cae3553d Mon Sep 17 00:00:00 2001 From: LaithBudairi <69572447+LaithBudairi@users.noreply.github.com> Date: Sat, 1 Mar 2025 00:58:39 +0200 Subject: [PATCH 2056/3148] Add missing 'state_class' attribute for Growatt plant sensors (#132145) * Add missing 'state_class' attribute for Growatt plant sensors * Update total.py * Update total.py 'TOTAL_INCREASING' * Update total.py "maximum_output" -> 'TOTAL_INCREASING' * Update homeassistant/components/growatt_server/sensor/total.py --------- Co-authored-by: Franck Nijhof --- homeassistant/components/growatt_server/sensor/total.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/growatt_server/sensor/total.py b/homeassistant/components/growatt_server/sensor/total.py index 8111728d1e9..578745c8610 100644 --- a/homeassistant/components/growatt_server/sensor/total.py +++ b/homeassistant/components/growatt_server/sensor/total.py @@ -26,6 +26,7 @@ TOTAL_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( api_key="todayEnergy", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, ), GrowattSensorEntityDescription( key="total_output_power", @@ -33,6 +34,7 @@ TOTAL_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = ( api_key="invTodayPpv", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, ), GrowattSensorEntityDescription( key="total_energy_output", From 17116fcd6cfbb10c4455b07c97764e38ad6670bd Mon Sep 17 00:00:00 2001 From: M-A Date: Sat, 1 Mar 2025 13:58:45 -0500 Subject: [PATCH 2057/3148] Bump env_canada to 0.8.0 (#138237) * Bump env_canada to 0.8.0 * Fix requirements*.txt * Grepped more --------- Co-authored-by: Franck Nijhof --- homeassistant/components/environment_canada/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/script/test_gen_requirements_all.py | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/environment_canada/manifest.json b/homeassistant/components/environment_canada/manifest.json index 76534662ff7..fc05e093b33 100644 --- a/homeassistant/components/environment_canada/manifest.json +++ b/homeassistant/components/environment_canada/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/environment_canada", "iot_class": "cloud_polling", "loggers": ["env_canada"], - "requirements": ["env-canada==0.7.2"] + "requirements": ["env-canada==0.8.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 7e1f7b23240..3a4f70ec97e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -869,7 +869,7 @@ enocean==0.50 enturclient==0.2.4 # homeassistant.components.environment_canada -env-canada==0.7.2 +env-canada==0.8.0 # homeassistant.components.season ephem==4.1.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ce309b4460e..458119c43bb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -738,7 +738,7 @@ energyzero==2.1.1 enocean==0.50 # homeassistant.components.environment_canada -env-canada==0.7.2 +env-canada==0.8.0 # homeassistant.components.season ephem==4.1.6 diff --git a/tests/script/test_gen_requirements_all.py b/tests/script/test_gen_requirements_all.py index 519a5c21855..b667bdd3ddf 100644 --- a/tests/script/test_gen_requirements_all.py +++ b/tests/script/test_gen_requirements_all.py @@ -41,9 +41,9 @@ def test_requirement_override_markers() -> None: ): assert ( gen_requirements_all.process_action_requirement( - "env-canada==0.7.2", "pytest" + "env-canada==0.8.0", "pytest" ) - == "env-canada==0.7.2;python_version<'3.13'" + == "env-canada==0.8.0;python_version<'3.13'" ) assert ( gen_requirements_all.process_action_requirement("other==1.0", "pytest") From 2636a4733390d085be09f09ec278e196b6145903 Mon Sep 17 00:00:00 2001 From: Martreides <8385298+Martreides@users.noreply.github.com> Date: Sun, 2 Mar 2025 14:32:10 +0100 Subject: [PATCH 2058/3148] Fix Nederlandse Spoorwegen to ignore trains in the past (#138331) * Update NS integration to show first next train instead of just the first. * Handle no first or next trip. * Remove debug statement. * Remove seconds and revert back to minutes. * Make use of dt_util.now(). * Fix issue with next train if no first train. --- .../nederlandse_spoorwegen/sensor.py | 120 +++++++++++------- 1 file changed, 77 insertions(+), 43 deletions(-) diff --git a/homeassistant/components/nederlandse_spoorwegen/sensor.py b/homeassistant/components/nederlandse_spoorwegen/sensor.py index ff3eea9252c..1e7fc54f4f7 100644 --- a/homeassistant/components/nederlandse_spoorwegen/sensor.py +++ b/homeassistant/components/nederlandse_spoorwegen/sensor.py @@ -20,7 +20,7 @@ from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.util import Throttle +from homeassistant.util import Throttle, dt as dt_util _LOGGER = logging.getLogger(__name__) @@ -119,6 +119,8 @@ class NSDepartureSensor(SensorEntity): self._time = time self._state = None self._trips = None + self._first_trip = None + self._next_trip = None @property def name(self): @@ -133,44 +135,44 @@ class NSDepartureSensor(SensorEntity): @property def extra_state_attributes(self): """Return the state attributes.""" - if not self._trips: + if not self._trips or self._first_trip is None: return None - if self._trips[0].trip_parts: - route = [self._trips[0].departure] - route.extend(k.destination for k in self._trips[0].trip_parts) + if self._first_trip.trip_parts: + route = [self._first_trip.departure] + route.extend(k.destination for k in self._first_trip.trip_parts) # Static attributes attributes = { - "going": self._trips[0].going, + "going": self._first_trip.going, "departure_time_planned": None, "departure_time_actual": None, "departure_delay": False, - "departure_platform_planned": self._trips[0].departure_platform_planned, - "departure_platform_actual": self._trips[0].departure_platform_actual, + "departure_platform_planned": self._first_trip.departure_platform_planned, + "departure_platform_actual": self._first_trip.departure_platform_actual, "arrival_time_planned": None, "arrival_time_actual": None, "arrival_delay": False, - "arrival_platform_planned": self._trips[0].arrival_platform_planned, - "arrival_platform_actual": self._trips[0].arrival_platform_actual, + "arrival_platform_planned": self._first_trip.arrival_platform_planned, + "arrival_platform_actual": self._first_trip.arrival_platform_actual, "next": None, - "status": self._trips[0].status.lower(), - "transfers": self._trips[0].nr_transfers, + "status": self._first_trip.status.lower(), + "transfers": self._first_trip.nr_transfers, "route": route, "remarks": None, } # Planned departure attributes - if self._trips[0].departure_time_planned is not None: - attributes["departure_time_planned"] = self._trips[ - 0 - ].departure_time_planned.strftime("%H:%M") + if self._first_trip.departure_time_planned is not None: + attributes["departure_time_planned"] = ( + self._first_trip.departure_time_planned.strftime("%H:%M") + ) # Actual departure attributes - if self._trips[0].departure_time_actual is not None: - attributes["departure_time_actual"] = self._trips[ - 0 - ].departure_time_actual.strftime("%H:%M") + if self._first_trip.departure_time_actual is not None: + attributes["departure_time_actual"] = ( + self._first_trip.departure_time_actual.strftime("%H:%M") + ) # Delay departure attributes if ( @@ -182,16 +184,16 @@ class NSDepartureSensor(SensorEntity): attributes["departure_delay"] = True # Planned arrival attributes - if self._trips[0].arrival_time_planned is not None: - attributes["arrival_time_planned"] = self._trips[ - 0 - ].arrival_time_planned.strftime("%H:%M") + if self._first_trip.arrival_time_planned is not None: + attributes["arrival_time_planned"] = ( + self._first_trip.arrival_time_planned.strftime("%H:%M") + ) # Actual arrival attributes - if self._trips[0].arrival_time_actual is not None: - attributes["arrival_time_actual"] = self._trips[ - 0 - ].arrival_time_actual.strftime("%H:%M") + if self._first_trip.arrival_time_actual is not None: + attributes["arrival_time_actual"] = ( + self._first_trip.arrival_time_actual.strftime("%H:%M") + ) # Delay arrival attributes if ( @@ -202,15 +204,14 @@ class NSDepartureSensor(SensorEntity): attributes["arrival_delay"] = True # Next attributes - if len(self._trips) > 1: - if self._trips[1].departure_time_actual is not None: - attributes["next"] = self._trips[1].departure_time_actual.strftime( - "%H:%M" - ) - elif self._trips[1].departure_time_planned is not None: - attributes["next"] = self._trips[1].departure_time_planned.strftime( - "%H:%M" - ) + if self._next_trip.departure_time_actual is not None: + attributes["next"] = self._next_trip.departure_time_actual.strftime("%H:%M") + elif self._next_trip.departure_time_planned is not None: + attributes["next"] = self._next_trip.departure_time_planned.strftime( + "%H:%M" + ) + else: + attributes["next"] = None return attributes @@ -225,6 +226,7 @@ class NSDepartureSensor(SensorEntity): ): self._state = None self._trips = None + self._first_trip = None return # Set the search parameter to search from a specific trip time @@ -236,19 +238,51 @@ class NSDepartureSensor(SensorEntity): .strftime("%d-%m-%Y %H:%M") ) else: - trip_time = datetime.now().strftime("%d-%m-%Y %H:%M") + trip_time = dt_util.now().strftime("%d-%m-%Y %H:%M") try: self._trips = self._nsapi.get_trips( trip_time, self._departure, self._via, self._heading, True, 0, 2 ) if self._trips: - if self._trips[0].departure_time_actual is None: - planned_time = self._trips[0].departure_time_planned - self._state = planned_time.strftime("%H:%M") + all_times = [] + + # If a train is delayed we can observe this through departure_time_actual. + for trip in self._trips: + if trip.departure_time_actual is None: + all_times.append(trip.departure_time_planned) + else: + all_times.append(trip.departure_time_actual) + + # Remove all trains that already left. + filtered_times = [ + (i, time) + for i, time in enumerate(all_times) + if time > dt_util.now() + ] + + if len(filtered_times) > 0: + sorted_times = sorted(filtered_times, key=lambda x: x[1]) + self._first_trip = self._trips[sorted_times[0][0]] + self._state = sorted_times[0][1].strftime("%H:%M") + + # Filter again to remove trains that leave at the exact same time. + filtered_times = [ + (i, time) + for i, time in enumerate(all_times) + if time > sorted_times[0][1] + ] + + if len(filtered_times) > 0: + sorted_times = sorted(filtered_times, key=lambda x: x[1]) + self._next_trip = self._trips[sorted_times[0][0]] + else: + self._next_trip = None + else: - actual_time = self._trips[0].departure_time_actual - self._state = actual_time.strftime("%H:%M") + self._first_trip = None + self._state = None + except ( requests.exceptions.ConnectionError, requests.exceptions.HTTPError, From 108b71d33cda7a259f99ba4271b8f6d09bff6b11 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 2 Mar 2025 08:13:06 -0600 Subject: [PATCH 2059/3148] Use multiple indexed group-by queries to get start time states for MySQL (#138786) * tweaks * mysql * mysql * Update homeassistant/components/recorder/history/modern.py * Update homeassistant/components/recorder/history/modern.py * Update homeassistant/components/recorder/const.py * Update homeassistant/components/recorder/statistics.py * Apply suggestions from code review * mysql * mysql * cover * make sure db is fully init on old schema * fixes * fixes * coverage * coverage * coverage * s/slow_dependant_subquery/slow_dependent_subquery/g * reword * comment that callers are responsible for staying under the limit * comment that callers are responsible for staying under the limit * switch to kwargs * reduce branching complexity * split stats query * preen * split tests * split tests --- homeassistant/components/recorder/const.py | 6 + .../components/recorder/history/modern.py | 149 +++- .../components/recorder/models/database.py | 10 + .../components/recorder/statistics.py | 79 ++- homeassistant/components/recorder/util.py | 29 +- .../history/test_websocket_api_schema_32.py | 6 +- tests/components/recorder/common.py | 13 +- tests/components/recorder/conftest.py | 7 + ...est_filters_with_entityfilter_schema_37.py | 15 +- tests/components/recorder/test_history.py | 27 +- .../recorder/test_history_db_schema_32.py | 5 +- .../recorder/test_history_db_schema_42.py | 5 +- .../recorder/test_purge_v32_schema.py | 4 +- tests/components/recorder/test_statistics.py | 653 +++++++++++++++++- tests/components/recorder/test_util.py | 11 +- 15 files changed, 965 insertions(+), 54 deletions(-) diff --git a/homeassistant/components/recorder/const.py b/homeassistant/components/recorder/const.py index b7ee984558c..36ff63a0496 100644 --- a/homeassistant/components/recorder/const.py +++ b/homeassistant/components/recorder/const.py @@ -30,6 +30,12 @@ CONF_DB_INTEGRITY_CHECK = "db_integrity_check" MAX_QUEUE_BACKLOG_MIN_VALUE = 65000 MIN_AVAILABLE_MEMORY_FOR_QUEUE_BACKLOG = 256 * 1024**2 +# As soon as we have more than 999 ids, split the query as the +# MySQL optimizer handles it poorly and will no longer +# do an index only scan with a group-by +# https://github.com/home-assistant/core/issues/132865#issuecomment-2543160459 +MAX_IDS_FOR_INDEXED_GROUP_BY = 999 + # The maximum number of rows (events) we purge in one delete statement DEFAULT_MAX_BIND_VARS = 4000 diff --git a/homeassistant/components/recorder/history/modern.py b/homeassistant/components/recorder/history/modern.py index 8958913bce6..566e30713f0 100644 --- a/homeassistant/components/recorder/history/modern.py +++ b/homeassistant/components/recorder/history/modern.py @@ -6,11 +6,12 @@ from collections.abc import Callable, Iterable, Iterator from datetime import datetime from itertools import groupby from operator import itemgetter -from typing import Any, cast +from typing import TYPE_CHECKING, Any, cast from sqlalchemy import ( CompoundSelect, Select, + StatementLambdaElement, Subquery, and_, func, @@ -26,8 +27,9 @@ from homeassistant.const import COMPRESSED_STATE_LAST_UPDATED, COMPRESSED_STATE_ from homeassistant.core import HomeAssistant, State, split_entity_id from homeassistant.helpers.recorder import get_instance from homeassistant.util import dt as dt_util +from homeassistant.util.collection import chunked_or_all -from ..const import LAST_REPORTED_SCHEMA_VERSION +from ..const import LAST_REPORTED_SCHEMA_VERSION, MAX_IDS_FOR_INDEXED_GROUP_BY from ..db_schema import ( SHARED_ATTR_OR_LEGACY_ATTRIBUTES, StateAttributes, @@ -149,6 +151,7 @@ def _significant_states_stmt( no_attributes: bool, include_start_time_state: bool, run_start_ts: float | None, + slow_dependent_subquery: bool, ) -> Select | CompoundSelect: """Query the database for significant state changes.""" include_last_changed = not significant_changes_only @@ -187,6 +190,7 @@ def _significant_states_stmt( metadata_ids, no_attributes, include_last_changed, + slow_dependent_subquery, ).subquery(), no_attributes, include_last_changed, @@ -257,7 +261,68 @@ def get_significant_states_with_session( start_time_ts = start_time.timestamp() end_time_ts = datetime_to_timestamp_or_none(end_time) single_metadata_id = metadata_ids[0] if len(metadata_ids) == 1 else None - stmt = lambda_stmt( + rows: list[Row] = [] + if TYPE_CHECKING: + assert instance.database_engine is not None + slow_dependent_subquery = instance.database_engine.optimizer.slow_dependent_subquery + if include_start_time_state and slow_dependent_subquery: + # https://github.com/home-assistant/core/issues/137178 + # If we include the start time state we need to limit the + # number of metadata_ids we query for at a time to avoid + # hitting limits in the MySQL optimizer that prevent + # the start time state query from using an index-only optimization + # to find the start time state. + iter_metadata_ids = chunked_or_all(metadata_ids, MAX_IDS_FOR_INDEXED_GROUP_BY) + else: + iter_metadata_ids = (metadata_ids,) + for metadata_ids_chunk in iter_metadata_ids: + stmt = _generate_significant_states_with_session_stmt( + start_time_ts, + end_time_ts, + single_metadata_id, + metadata_ids_chunk, + metadata_ids_in_significant_domains, + significant_changes_only, + no_attributes, + include_start_time_state, + oldest_ts, + slow_dependent_subquery, + ) + row_chunk = cast( + list[Row], + execute_stmt_lambda_element(session, stmt, None, end_time, orm_rows=False), + ) + if rows: + rows += row_chunk + else: + # If we have no rows yet, we can just assign the chunk + # as this is the common case since its rare that + # we exceed the MAX_IDS_FOR_INDEXED_GROUP_BY limit + rows = row_chunk + return _sorted_states_to_dict( + rows, + start_time_ts if include_start_time_state else None, + entity_ids, + entity_id_to_metadata_id, + minimal_response, + compressed_state_format, + no_attributes=no_attributes, + ) + + +def _generate_significant_states_with_session_stmt( + start_time_ts: float, + end_time_ts: float | None, + single_metadata_id: int | None, + metadata_ids: list[int], + metadata_ids_in_significant_domains: list[int], + significant_changes_only: bool, + no_attributes: bool, + include_start_time_state: bool, + oldest_ts: float | None, + slow_dependent_subquery: bool, +) -> StatementLambdaElement: + return lambda_stmt( lambda: _significant_states_stmt( start_time_ts, end_time_ts, @@ -268,6 +333,7 @@ def get_significant_states_with_session( no_attributes, include_start_time_state, oldest_ts, + slow_dependent_subquery, ), track_on=[ bool(single_metadata_id), @@ -276,17 +342,9 @@ def get_significant_states_with_session( significant_changes_only, no_attributes, include_start_time_state, + slow_dependent_subquery, ], ) - return _sorted_states_to_dict( - execute_stmt_lambda_element(session, stmt, None, end_time, orm_rows=False), - start_time_ts if include_start_time_state else None, - entity_ids, - entity_id_to_metadata_id, - minimal_response, - compressed_state_format, - no_attributes=no_attributes, - ) def get_full_significant_states_with_session( @@ -554,13 +612,14 @@ def get_last_state_changes( ) -def _get_start_time_state_for_entities_stmt( +def _get_start_time_state_for_entities_stmt_dependent_sub_query( epoch_time: float, metadata_ids: list[int], no_attributes: bool, include_last_changed: bool, ) -> Select: """Baked query to get states for specific entities.""" + # Engine has a fast dependent subquery optimizer # This query is the result of significant research in # https://github.com/home-assistant/core/issues/132865 # A reverse index scan with a limit 1 is the fastest way to get the @@ -570,7 +629,9 @@ def _get_start_time_state_for_entities_stmt( # before a specific point in time for all entities. stmt = ( _stmt_and_join_attributes_for_start_state( - no_attributes, include_last_changed, False + no_attributes=no_attributes, + include_last_changed=include_last_changed, + include_last_reported=False, ) .select_from(StatesMeta) .join( @@ -600,6 +661,55 @@ def _get_start_time_state_for_entities_stmt( ) +def _get_start_time_state_for_entities_stmt_group_by( + epoch_time: float, + metadata_ids: list[int], + no_attributes: bool, + include_last_changed: bool, +) -> Select: + """Baked query to get states for specific entities.""" + # Simple group-by for MySQL, must use less + # than 1000 metadata_ids in the IN clause for MySQL + # or it will optimize poorly. Callers are responsible + # for ensuring that the number of metadata_ids is less + # than 1000. + most_recent_states_for_entities_by_date = ( + select( + States.metadata_id.label("max_metadata_id"), + func.max(States.last_updated_ts).label("max_last_updated"), + ) + .filter( + (States.last_updated_ts < epoch_time) & States.metadata_id.in_(metadata_ids) + ) + .group_by(States.metadata_id) + .subquery() + ) + stmt = ( + _stmt_and_join_attributes_for_start_state( + no_attributes=no_attributes, + include_last_changed=include_last_changed, + include_last_reported=False, + ) + .join( + most_recent_states_for_entities_by_date, + and_( + States.metadata_id + == most_recent_states_for_entities_by_date.c.max_metadata_id, + States.last_updated_ts + == most_recent_states_for_entities_by_date.c.max_last_updated, + ), + ) + .filter( + (States.last_updated_ts < epoch_time) & States.metadata_id.in_(metadata_ids) + ) + ) + if no_attributes: + return stmt + return stmt.outerjoin( + StateAttributes, (States.attributes_id == StateAttributes.attributes_id) + ) + + def _get_oldest_possible_ts( hass: HomeAssistant, utc_point_in_time: datetime ) -> float | None: @@ -620,6 +730,7 @@ def _get_start_time_state_stmt( metadata_ids: list[int], no_attributes: bool, include_last_changed: bool, + slow_dependent_subquery: bool, ) -> Select: """Return the states at a specific point in time.""" if single_metadata_id: @@ -634,7 +745,15 @@ def _get_start_time_state_stmt( ) # We have more than one entity to look at so we need to do a query on states # since the last recorder run started. - return _get_start_time_state_for_entities_stmt( + if slow_dependent_subquery: + return _get_start_time_state_for_entities_stmt_group_by( + epoch_time, + metadata_ids, + no_attributes, + include_last_changed, + ) + + return _get_start_time_state_for_entities_stmt_dependent_sub_query( epoch_time, metadata_ids, no_attributes, diff --git a/homeassistant/components/recorder/models/database.py b/homeassistant/components/recorder/models/database.py index b86fd299793..2a4924edab3 100644 --- a/homeassistant/components/recorder/models/database.py +++ b/homeassistant/components/recorder/models/database.py @@ -37,3 +37,13 @@ class DatabaseOptimizer: # https://wiki.postgresql.org/wiki/Loose_indexscan # https://github.com/home-assistant/core/issues/126084 slow_range_in_select: bool + + # MySQL 8.x+ can end up with a file-sort on a dependent subquery + # which makes the query painfully slow. + # https://github.com/home-assistant/core/issues/137178 + # The solution is to use multiple indexed group-by queries instead + # of the subquery as long as the group by does not exceed + # 999 elements since as soon as we hit 1000 elements MySQL + # will no longer use the group_index_range optimization. + # https://github.com/home-assistant/core/issues/132865#issuecomment-2543160459 + slow_dependent_subquery: bool diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index c42a0f77c39..97fe73c54fe 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -28,6 +28,7 @@ from homeassistant.helpers.recorder import DATA_RECORDER from homeassistant.helpers.singleton import singleton from homeassistant.helpers.typing import UNDEFINED, UndefinedType from homeassistant.util import dt as dt_util +from homeassistant.util.collection import chunked_or_all from homeassistant.util.unit_conversion import ( AreaConverter, BaseUnitConverter, @@ -59,6 +60,7 @@ from .const import ( INTEGRATION_PLATFORM_LIST_STATISTIC_IDS, INTEGRATION_PLATFORM_UPDATE_STATISTICS_ISSUES, INTEGRATION_PLATFORM_VALIDATE_STATISTICS, + MAX_IDS_FOR_INDEXED_GROUP_BY, SupportedDialect, ) from .db_schema import ( @@ -1669,6 +1671,7 @@ def _augment_result_with_change( drop_sum = "sum" not in _types prev_sums = {} if tmp := _statistics_at_time( + get_instance(hass), session, {metadata[statistic_id][0] for statistic_id in result}, table, @@ -2027,7 +2030,39 @@ def get_latest_short_term_statistics_with_session( ) -def _generate_statistics_at_time_stmt( +def _generate_statistics_at_time_stmt_group_by( + table: type[StatisticsBase], + metadata_ids: set[int], + start_time_ts: float, + types: set[Literal["last_reset", "max", "mean", "min", "state", "sum"]], +) -> StatementLambdaElement: + """Create the statement for finding the statistics for a given time.""" + # Simple group-by for MySQL, must use less + # than 1000 metadata_ids in the IN clause for MySQL + # or it will optimize poorly. Callers are responsible + # for ensuring that the number of metadata_ids is less + # than 1000. + return _generate_select_columns_for_types_stmt(table, types) + ( + lambda q: q.join( + most_recent_statistic_ids := ( + select( + func.max(table.start_ts).label("max_start_ts"), + table.metadata_id.label("max_metadata_id"), + ) + .filter(table.start_ts < start_time_ts) + .filter(table.metadata_id.in_(metadata_ids)) + .group_by(table.metadata_id) + .subquery() + ), + and_( + table.start_ts == most_recent_statistic_ids.c.max_start_ts, + table.metadata_id == most_recent_statistic_ids.c.max_metadata_id, + ), + ) + ) + + +def _generate_statistics_at_time_stmt_dependent_sub_query( table: type[StatisticsBase], metadata_ids: set[int], start_time_ts: float, @@ -2041,8 +2076,7 @@ def _generate_statistics_at_time_stmt( # databases. Since all databases support this query as a join # condition we can use it as a subquery to get the last start_time_ts # before a specific point in time for all entities. - stmt = _generate_select_columns_for_types_stmt(table, types) - stmt += ( + return _generate_select_columns_for_types_stmt(table, types) + ( lambda q: q.select_from(StatisticsMeta) .join( table, @@ -2064,10 +2098,10 @@ def _generate_statistics_at_time_stmt( ) .where(table.metadata_id.in_(metadata_ids)) ) - return stmt def _statistics_at_time( + instance: Recorder, session: Session, metadata_ids: set[int], table: type[StatisticsBase], @@ -2076,8 +2110,41 @@ def _statistics_at_time( ) -> Sequence[Row] | None: """Return last known statistics, earlier than start_time, for the metadata_ids.""" start_time_ts = start_time.timestamp() - stmt = _generate_statistics_at_time_stmt(table, metadata_ids, start_time_ts, types) - return cast(Sequence[Row], execute_stmt_lambda_element(session, stmt)) + if TYPE_CHECKING: + assert instance.database_engine is not None + if not instance.database_engine.optimizer.slow_dependent_subquery: + stmt = _generate_statistics_at_time_stmt_dependent_sub_query( + table=table, + metadata_ids=metadata_ids, + start_time_ts=start_time_ts, + types=types, + ) + return cast(list[Row], execute_stmt_lambda_element(session, stmt)) + rows: list[Row] = [] + # https://github.com/home-assistant/core/issues/132865 + # If we include the start time state we need to limit the + # number of metadata_ids we query for at a time to avoid + # hitting limits in the MySQL optimizer that prevent + # the start time state query from using an index-only optimization + # to find the start time state. + for metadata_ids_chunk in chunked_or_all( + metadata_ids, MAX_IDS_FOR_INDEXED_GROUP_BY + ): + stmt = _generate_statistics_at_time_stmt_group_by( + table=table, + metadata_ids=metadata_ids_chunk, + start_time_ts=start_time_ts, + types=types, + ) + row_chunk = cast(list[Row], execute_stmt_lambda_element(session, stmt)) + if rows: + rows += row_chunk + else: + # If we have no rows yet, we can just assign the chunk + # as this is the common case since its rare that + # we exceed the MAX_IDS_FOR_INDEXED_GROUP_BY limit + rows = row_chunk + return rows def _build_sum_converted_stats( diff --git a/homeassistant/components/recorder/util.py b/homeassistant/components/recorder/util.py index a686c7c6498..0acaf0aa68f 100644 --- a/homeassistant/components/recorder/util.py +++ b/homeassistant/components/recorder/util.py @@ -464,6 +464,7 @@ def setup_connection_for_dialect( """Execute statements needed for dialect connection.""" version: AwesomeVersion | None = None slow_range_in_select = False + slow_dependent_subquery = False if dialect_name == SupportedDialect.SQLITE: if first_connection: old_isolation = dbapi_connection.isolation_level # type: ignore[attr-defined] @@ -505,9 +506,8 @@ def setup_connection_for_dialect( result = query_on_connection(dbapi_connection, "SELECT VERSION()") version_string = result[0][0] version = _extract_version_from_server_response(version_string) - is_maria_db = "mariadb" in version_string.lower() - if is_maria_db: + if "mariadb" in version_string.lower(): if not version or version < MIN_VERSION_MARIA_DB: _raise_if_version_unsupported( version or version_string, "MariaDB", MIN_VERSION_MARIA_DB @@ -523,19 +523,21 @@ def setup_connection_for_dialect( instance.hass, version, ) - + slow_range_in_select = bool( + not version + or version < MARIADB_WITH_FIXED_IN_QUERIES_105 + or MARIA_DB_106 <= version < MARIADB_WITH_FIXED_IN_QUERIES_106 + or MARIA_DB_107 <= version < MARIADB_WITH_FIXED_IN_QUERIES_107 + or MARIA_DB_108 <= version < MARIADB_WITH_FIXED_IN_QUERIES_108 + ) elif not version or version < MIN_VERSION_MYSQL: _raise_if_version_unsupported( version or version_string, "MySQL", MIN_VERSION_MYSQL ) - - slow_range_in_select = bool( - not version - or version < MARIADB_WITH_FIXED_IN_QUERIES_105 - or MARIA_DB_106 <= version < MARIADB_WITH_FIXED_IN_QUERIES_106 - or MARIA_DB_107 <= version < MARIADB_WITH_FIXED_IN_QUERIES_107 - or MARIA_DB_108 <= version < MARIADB_WITH_FIXED_IN_QUERIES_108 - ) + else: + # MySQL + # https://github.com/home-assistant/core/issues/137178 + slow_dependent_subquery = True # Ensure all times are using UTC to avoid issues with daylight savings execute_on_connection(dbapi_connection, "SET time_zone = '+00:00'") @@ -565,7 +567,10 @@ def setup_connection_for_dialect( return DatabaseEngine( dialect=SupportedDialect(dialect_name), version=version, - optimizer=DatabaseOptimizer(slow_range_in_select=slow_range_in_select), + optimizer=DatabaseOptimizer( + slow_range_in_select=slow_range_in_select, + slow_dependent_subquery=slow_dependent_subquery, + ), max_bind_vars=DEFAULT_MAX_BIND_VARS, ) diff --git a/tests/components/history/test_websocket_api_schema_32.py b/tests/components/history/test_websocket_api_schema_32.py index 7b84c47e81b..c9577e20fcf 100644 --- a/tests/components/history/test_websocket_api_schema_32.py +++ b/tests/components/history/test_websocket_api_schema_32.py @@ -1,5 +1,7 @@ """The tests the History component websocket_api.""" +from collections.abc import Generator + import pytest from homeassistant.components import recorder @@ -17,9 +19,9 @@ from tests.typing import WebSocketGenerator @pytest.fixture(autouse=True) -def db_schema_32(): +def db_schema_32(hass: HomeAssistant) -> Generator[None]: """Fixture to initialize the db with the old schema 32.""" - with old_db_schema("32"): + with old_db_schema(hass, "32"): yield diff --git a/tests/components/recorder/common.py b/tests/components/recorder/common.py index 5e1f02baeed..28eb097f576 100644 --- a/tests/components/recorder/common.py +++ b/tests/components/recorder/common.py @@ -15,7 +15,7 @@ from typing import Any, Literal, cast from unittest.mock import MagicMock, patch, sentinel from freezegun import freeze_time -from sqlalchemy import create_engine +from sqlalchemy import create_engine, event as sqlalchemy_event from sqlalchemy.orm.session import Session from homeassistant import core as ha @@ -414,7 +414,15 @@ def create_engine_test_for_schema_version_postfix( schema_module = get_schema_module_path(schema_version_postfix) importlib.import_module(schema_module) old_db_schema = sys.modules[schema_module] + instance: Recorder | None = None + if "hass" in kwargs: + hass: HomeAssistant = kwargs.pop("hass") + instance = recorder.get_instance(hass) engine = create_engine(*args, **kwargs) + if instance is not None: + instance = recorder.get_instance(hass) + instance.engine = engine + sqlalchemy_event.listen(engine, "connect", instance._setup_recorder_connection) old_db_schema.Base.metadata.create_all(engine) with Session(engine) as session: session.add( @@ -435,7 +443,7 @@ def get_schema_module_path(schema_version_postfix: str) -> str: @contextmanager -def old_db_schema(schema_version_postfix: str) -> Iterator[None]: +def old_db_schema(hass: HomeAssistant, schema_version_postfix: str) -> Iterator[None]: """Fixture to initialize the db with the old schema.""" schema_module = get_schema_module_path(schema_version_postfix) importlib.import_module(schema_module) @@ -455,6 +463,7 @@ def old_db_schema(schema_version_postfix: str) -> Iterator[None]: CREATE_ENGINE_TARGET, new=partial( create_engine_test_for_schema_version_postfix, + hass=hass, schema_version_postfix=schema_version_postfix, ), ), diff --git a/tests/components/recorder/conftest.py b/tests/components/recorder/conftest.py index 9cdf9dbb372..681205126af 100644 --- a/tests/components/recorder/conftest.py +++ b/tests/components/recorder/conftest.py @@ -13,6 +13,7 @@ from sqlalchemy.orm.session import Session from homeassistant.components import recorder from homeassistant.components.recorder import db_schema +from homeassistant.components.recorder.const import MAX_IDS_FOR_INDEXED_GROUP_BY from homeassistant.components.recorder.util import session_scope from homeassistant.core import HomeAssistant @@ -190,3 +191,9 @@ def instrument_migration( instrumented_migration.live_migration_done_stall.set() instrumented_migration.non_live_migration_done_stall.set() yield instrumented_migration + + +@pytest.fixture(params=[1, 2, MAX_IDS_FOR_INDEXED_GROUP_BY]) +def ids_for_start_time_chunk_sizes(request: pytest.FixtureRequest) -> int: + """Fixture to test different chunk sizes for start time query.""" + return request.param diff --git a/tests/components/recorder/test_filters_with_entityfilter_schema_37.py b/tests/components/recorder/test_filters_with_entityfilter_schema_37.py index d3024df4ed6..2e9883aaf53 100644 --- a/tests/components/recorder/test_filters_with_entityfilter_schema_37.py +++ b/tests/components/recorder/test_filters_with_entityfilter_schema_37.py @@ -1,6 +1,6 @@ """The tests for the recorder filter matching the EntityFilter component.""" -from collections.abc import AsyncGenerator +from collections.abc import AsyncGenerator, Generator import json from unittest.mock import patch @@ -32,12 +32,21 @@ from homeassistant.helpers.entityfilter import ( from .common import async_wait_recording_done, old_db_schema +from tests.typing import RecorderInstanceContextManager + + +@pytest.fixture +async def mock_recorder_before_hass( + async_test_recorder: RecorderInstanceContextManager, +) -> None: + """Set up recorder.""" + # This test is for schema 37 and below (32 is new enough to test) @pytest.fixture(autouse=True) -def db_schema_32(): +def db_schema_32(hass: HomeAssistant) -> Generator[None]: """Fixture to initialize the db with the old schema 32.""" - with old_db_schema("32"): + with old_db_schema(hass, "32"): yield diff --git a/tests/components/recorder/test_history.py b/tests/components/recorder/test_history.py index 166451cc971..d6223eb55b3 100644 --- a/tests/components/recorder/test_history.py +++ b/tests/components/recorder/test_history.py @@ -2,10 +2,11 @@ from __future__ import annotations +from collections.abc import Generator from copy import copy from datetime import datetime, timedelta import json -from unittest.mock import sentinel +from unittest.mock import patch, sentinel from freezegun import freeze_time import pytest @@ -36,6 +37,24 @@ from .common import ( from tests.typing import RecorderInstanceContextManager +@pytest.fixture +def multiple_start_time_chunk_sizes( + ids_for_start_time_chunk_sizes: int, +) -> Generator[None]: + """Fixture to test different chunk sizes for start time query. + + Force the recorder to use different chunk sizes for start time query. + + In effect this forces get_significant_states_with_session + to call _generate_significant_states_with_session_stmt multiple times. + """ + with patch( + "homeassistant.components.recorder.history.modern.MAX_IDS_FOR_INDEXED_GROUP_BY", + ids_for_start_time_chunk_sizes, + ): + yield + + @pytest.fixture async def mock_recorder_before_hass( async_test_recorder: RecorderInstanceContextManager, @@ -429,6 +448,7 @@ async def test_ensure_state_can_be_copied( assert_states_equal_without_context(copy(hist[entity_id][1]), hist[entity_id][1]) +@pytest.mark.usefixtures("multiple_start_time_chunk_sizes") async def test_get_significant_states(hass: HomeAssistant) -> None: """Test that only significant states are returned. @@ -443,6 +463,7 @@ async def test_get_significant_states(hass: HomeAssistant) -> None: assert_dict_of_states_equal_without_context_and_last_changed(states, hist) +@pytest.mark.usefixtures("multiple_start_time_chunk_sizes") async def test_get_significant_states_minimal_response( hass: HomeAssistant, ) -> None: @@ -512,6 +533,7 @@ async def test_get_significant_states_minimal_response( ) +@pytest.mark.usefixtures("multiple_start_time_chunk_sizes") @pytest.mark.parametrize("time_zone", ["Europe/Berlin", "US/Hawaii", "UTC"]) async def test_get_significant_states_with_initial( time_zone, hass: HomeAssistant @@ -544,6 +566,7 @@ async def test_get_significant_states_with_initial( assert_dict_of_states_equal_without_context_and_last_changed(states, hist) +@pytest.mark.usefixtures("multiple_start_time_chunk_sizes") async def test_get_significant_states_without_initial( hass: HomeAssistant, ) -> None: @@ -578,6 +601,7 @@ async def test_get_significant_states_without_initial( assert_dict_of_states_equal_without_context_and_last_changed(states, hist) +@pytest.mark.usefixtures("multiple_start_time_chunk_sizes") async def test_get_significant_states_entity_id( hass: HomeAssistant, ) -> None: @@ -596,6 +620,7 @@ async def test_get_significant_states_entity_id( assert_dict_of_states_equal_without_context_and_last_changed(states, hist) +@pytest.mark.usefixtures("multiple_start_time_chunk_sizes") async def test_get_significant_states_multiple_entity_ids( hass: HomeAssistant, ) -> None: diff --git a/tests/components/recorder/test_history_db_schema_32.py b/tests/components/recorder/test_history_db_schema_32.py index 142d2fc87f6..908a67cd635 100644 --- a/tests/components/recorder/test_history_db_schema_32.py +++ b/tests/components/recorder/test_history_db_schema_32.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Generator from copy import copy from datetime import datetime, timedelta import json @@ -50,9 +51,9 @@ def disable_states_meta_manager(): @pytest.fixture(autouse=True) -def db_schema_32(): +def db_schema_32(hass: HomeAssistant) -> Generator[None]: """Fixture to initialize the db with the old schema 32.""" - with old_db_schema("32"): + with old_db_schema(hass, "32"): yield diff --git a/tests/components/recorder/test_history_db_schema_42.py b/tests/components/recorder/test_history_db_schema_42.py index 1523f373ea8..20d0c162d35 100644 --- a/tests/components/recorder/test_history_db_schema_42.py +++ b/tests/components/recorder/test_history_db_schema_42.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Generator from copy import copy from datetime import datetime, timedelta import json @@ -42,9 +43,9 @@ async def mock_recorder_before_hass( @pytest.fixture(autouse=True) -def db_schema_42(): +def db_schema_42(hass: HomeAssistant) -> Generator[None]: """Fixture to initialize the db with the old schema 42.""" - with old_db_schema("42"): + with old_db_schema(hass, "42"): yield diff --git a/tests/components/recorder/test_purge_v32_schema.py b/tests/components/recorder/test_purge_v32_schema.py index 45bef68dabd..0212e4b012e 100644 --- a/tests/components/recorder/test_purge_v32_schema.py +++ b/tests/components/recorder/test_purge_v32_schema.py @@ -58,9 +58,9 @@ async def mock_recorder_before_hass( @pytest.fixture(autouse=True) -def db_schema_32(): +def db_schema_32(hass: HomeAssistant) -> Generator[None]: """Fixture to initialize the db with the old schema 32.""" - with old_db_schema("32"): + with old_db_schema(hass, "32"): yield diff --git a/tests/components/recorder/test_statistics.py b/tests/components/recorder/test_statistics.py index 6e192295c58..ed883c5403e 100644 --- a/tests/components/recorder/test_statistics.py +++ b/tests/components/recorder/test_statistics.py @@ -1,5 +1,6 @@ """The tests for sensor recorder platform.""" +from collections.abc import Generator from datetime import timedelta from typing import Any from unittest.mock import ANY, Mock, patch @@ -18,7 +19,8 @@ from homeassistant.components.recorder.statistics import ( STATISTIC_UNIT_TO_UNIT_CONVERTER, PlatformCompiledStatistics, _generate_max_mean_min_statistic_in_sub_period_stmt, - _generate_statistics_at_time_stmt, + _generate_statistics_at_time_stmt_dependent_sub_query, + _generate_statistics_at_time_stmt_group_by, _generate_statistics_during_period_stmt, async_add_external_statistics, async_import_statistics, @@ -57,6 +59,24 @@ from tests.common import MockPlatform, mock_platform from tests.typing import RecorderInstanceContextManager, WebSocketGenerator +@pytest.fixture +def multiple_start_time_chunk_sizes( + ids_for_start_time_chunk_sizes: int, +) -> Generator[None]: + """Fixture to test different chunk sizes for start time query. + + Force the statistics query to use different chunk sizes for start time query. + + In effect this forces _statistics_at_time + to call _generate_statistics_at_time_stmt_group_by multiple times. + """ + with patch( + "homeassistant.components.recorder.statistics.MAX_IDS_FOR_INDEXED_GROUP_BY", + ids_for_start_time_chunk_sizes, + ): + yield + + @pytest.fixture async def mock_recorder_before_hass( async_test_recorder: RecorderInstanceContextManager, @@ -1113,6 +1133,7 @@ async def test_import_statistics_errors( assert get_metadata(hass, statistic_ids={"sensor.total_energy_import"}) == {} +@pytest.mark.usefixtures("multiple_start_time_chunk_sizes") @pytest.mark.parametrize("timezone", ["America/Regina", "Europe/Vienna", "UTC"]) @pytest.mark.freeze_time("2022-10-01 00:00:00+00:00") async def test_daily_statistics_sum( @@ -1293,6 +1314,215 @@ async def test_daily_statistics_sum( assert stats == {} +@pytest.mark.usefixtures("multiple_start_time_chunk_sizes") +@pytest.mark.parametrize("timezone", ["America/Regina", "Europe/Vienna", "UTC"]) +@pytest.mark.freeze_time("2022-10-01 00:00:00+00:00") +async def test_multiple_daily_statistics_sum( + hass: HomeAssistant, + setup_recorder: None, + caplog: pytest.LogCaptureFixture, + timezone, +) -> None: + """Test daily statistics.""" + await hass.config.async_set_time_zone(timezone) + await async_wait_recording_done(hass) + assert "Compiling statistics for" not in caplog.text + assert "Statistics already compiled" not in caplog.text + + zero = dt_util.utcnow() + period1 = dt_util.as_utc(dt_util.parse_datetime("2022-10-03 00:00:00")) + period2 = dt_util.as_utc(dt_util.parse_datetime("2022-10-03 23:00:00")) + period3 = dt_util.as_utc(dt_util.parse_datetime("2022-10-04 00:00:00")) + period4 = dt_util.as_utc(dt_util.parse_datetime("2022-10-04 23:00:00")) + period5 = dt_util.as_utc(dt_util.parse_datetime("2022-10-05 00:00:00")) + period6 = dt_util.as_utc(dt_util.parse_datetime("2022-10-05 23:00:00")) + + external_statistics = ( + { + "start": period1, + "last_reset": None, + "state": 0, + "sum": 2, + }, + { + "start": period2, + "last_reset": None, + "state": 1, + "sum": 3, + }, + { + "start": period3, + "last_reset": None, + "state": 2, + "sum": 4, + }, + { + "start": period4, + "last_reset": None, + "state": 3, + "sum": 5, + }, + { + "start": period5, + "last_reset": None, + "state": 4, + "sum": 6, + }, + { + "start": period6, + "last_reset": None, + "state": 5, + "sum": 7, + }, + ) + external_metadata1 = { + "has_mean": False, + "has_sum": True, + "name": "Total imported energy 1", + "source": "test", + "statistic_id": "test:total_energy_import2", + "unit_of_measurement": "kWh", + } + external_metadata2 = { + "has_mean": False, + "has_sum": True, + "name": "Total imported energy 2", + "source": "test", + "statistic_id": "test:total_energy_import1", + "unit_of_measurement": "kWh", + } + + async_add_external_statistics(hass, external_metadata1, external_statistics) + async_add_external_statistics(hass, external_metadata2, external_statistics) + + await async_wait_recording_done(hass) + stats = statistics_during_period( + hass, + zero, + period="day", + statistic_ids={"test:total_energy_import1", "test:total_energy_import2"}, + ) + day1_start = dt_util.as_utc(dt_util.parse_datetime("2022-10-03 00:00:00")) + day1_end = dt_util.as_utc(dt_util.parse_datetime("2022-10-04 00:00:00")) + day2_start = dt_util.as_utc(dt_util.parse_datetime("2022-10-04 00:00:00")) + day2_end = dt_util.as_utc(dt_util.parse_datetime("2022-10-05 00:00:00")) + day3_start = dt_util.as_utc(dt_util.parse_datetime("2022-10-05 00:00:00")) + day3_end = dt_util.as_utc(dt_util.parse_datetime("2022-10-06 00:00:00")) + expected_stats_inner = [ + { + "start": day1_start.timestamp(), + "end": day1_end.timestamp(), + "last_reset": None, + "state": 1.0, + "sum": 3.0, + }, + { + "start": day2_start.timestamp(), + "end": day2_end.timestamp(), + "last_reset": None, + "state": 3.0, + "sum": 5.0, + }, + { + "start": day3_start.timestamp(), + "end": day3_end.timestamp(), + "last_reset": None, + "state": 5.0, + "sum": 7.0, + }, + ] + expected_stats = { + "test:total_energy_import1": expected_stats_inner, + "test:total_energy_import2": expected_stats_inner, + } + assert stats == expected_stats + + # Get change + stats = statistics_during_period( + hass, + start_time=period1, + statistic_ids={"test:total_energy_import1", "test:total_energy_import2"}, + period="day", + types={"change"}, + ) + expected_inner = [ + { + "start": day1_start.timestamp(), + "end": day1_end.timestamp(), + "change": 3.0, + }, + { + "start": day2_start.timestamp(), + "end": day2_end.timestamp(), + "change": 2.0, + }, + { + "start": day3_start.timestamp(), + "end": day3_end.timestamp(), + "change": 2.0, + }, + ] + assert stats == { + "test:total_energy_import1": expected_inner, + "test:total_energy_import2": expected_inner, + } + + # Get data with start during the first period + stats = statistics_during_period( + hass, + start_time=period1 + timedelta(hours=1), + statistic_ids={"test:total_energy_import1", "test:total_energy_import2"}, + period="day", + ) + assert stats == expected_stats + + # Get data with end during the third period + stats = statistics_during_period( + hass, + start_time=zero, + end_time=period6 - timedelta(hours=1), + statistic_ids={"test:total_energy_import1", "test:total_energy_import2"}, + period="day", + ) + assert stats == expected_stats + + # Try to get data for entities which do not exist + stats = statistics_during_period( + hass, + start_time=zero, + statistic_ids={ + "not", + "the", + "same", + "test:total_energy_import1", + "test:total_energy_import2", + }, + period="day", + ) + assert stats == expected_stats + + # Use 5minute to ensure table switch works + stats = statistics_during_period( + hass, + start_time=zero, + statistic_ids=[ + "test:total_energy_import1", + "with_other", + "test:total_energy_import2", + ], + period="5minute", + ) + assert stats == {} + + # Ensure future date has not data + future = dt_util.as_utc(dt_util.parse_datetime("2221-11-01 00:00:00")) + stats = statistics_during_period( + hass, start_time=future, end_time=future, period="day" + ) + assert stats == {} + + +@pytest.mark.usefixtures("multiple_start_time_chunk_sizes") @pytest.mark.parametrize("timezone", ["America/Regina", "Europe/Vienna", "UTC"]) @pytest.mark.freeze_time("2022-10-01 00:00:00+00:00") async def test_weekly_statistics_mean( @@ -1428,6 +1658,7 @@ async def test_weekly_statistics_mean( assert stats == {} +@pytest.mark.usefixtures("multiple_start_time_chunk_sizes") @pytest.mark.parametrize("timezone", ["America/Regina", "Europe/Vienna", "UTC"]) @pytest.mark.freeze_time("2022-10-01 00:00:00+00:00") async def test_weekly_statistics_sum( @@ -1608,6 +1839,7 @@ async def test_weekly_statistics_sum( assert stats == {} +@pytest.mark.usefixtures("multiple_start_time_chunk_sizes") @pytest.mark.parametrize("timezone", ["America/Regina", "Europe/Vienna", "UTC"]) @pytest.mark.freeze_time("2021-08-01 00:00:00+00:00") async def test_monthly_statistics_sum( @@ -1914,20 +2146,43 @@ def test_cache_key_for_generate_max_mean_min_statistic_in_sub_period_stmt() -> N assert cache_key_1 != cache_key_3 -def test_cache_key_for_generate_statistics_at_time_stmt() -> None: - """Test cache key for _generate_statistics_at_time_stmt.""" - stmt = _generate_statistics_at_time_stmt(StatisticsShortTerm, {0}, 0.0, set()) +def test_cache_key_for_generate_statistics_at_time_stmt_group_by() -> None: + """Test cache key for _generate_statistics_at_time_stmt_group_by.""" + stmt = _generate_statistics_at_time_stmt_group_by( + StatisticsShortTerm, {0}, 0.0, set() + ) cache_key_1 = stmt._generate_cache_key() - stmt2 = _generate_statistics_at_time_stmt(StatisticsShortTerm, {0}, 0.0, set()) + stmt2 = _generate_statistics_at_time_stmt_group_by( + StatisticsShortTerm, {0}, 0.0, set() + ) cache_key_2 = stmt2._generate_cache_key() assert cache_key_1 == cache_key_2 - stmt3 = _generate_statistics_at_time_stmt( + stmt3 = _generate_statistics_at_time_stmt_group_by( StatisticsShortTerm, {0}, 0.0, {"sum", "mean"} ) cache_key_3 = stmt3._generate_cache_key() assert cache_key_1 != cache_key_3 +def test_cache_key_for_generate_statistics_at_time_stmt_dependent_sub_query() -> None: + """Test cache key for _generate_statistics_at_time_stmt_dependent_sub_query.""" + stmt = _generate_statistics_at_time_stmt_dependent_sub_query( + StatisticsShortTerm, {0}, 0.0, set() + ) + cache_key_1 = stmt._generate_cache_key() + stmt2 = _generate_statistics_at_time_stmt_dependent_sub_query( + StatisticsShortTerm, {0}, 0.0, set() + ) + cache_key_2 = stmt2._generate_cache_key() + assert cache_key_1 == cache_key_2 + stmt3 = _generate_statistics_at_time_stmt_dependent_sub_query( + StatisticsShortTerm, {0}, 0.0, {"sum", "mean"} + ) + cache_key_3 = stmt3._generate_cache_key() + assert cache_key_1 != cache_key_3 + + +@pytest.mark.usefixtures("multiple_start_time_chunk_sizes") @pytest.mark.parametrize("timezone", ["America/Regina", "Europe/Vienna", "UTC"]) @pytest.mark.freeze_time("2022-10-01 00:00:00+00:00") async def test_change( @@ -2263,6 +2518,392 @@ async def test_change( assert stats == {} +@pytest.mark.usefixtures("multiple_start_time_chunk_sizes") +@pytest.mark.parametrize("timezone", ["America/Regina", "Europe/Vienna", "UTC"]) +@pytest.mark.freeze_time("2022-10-01 00:00:00+00:00") +async def test_change_multiple( + hass: HomeAssistant, + setup_recorder: None, + caplog: pytest.LogCaptureFixture, + timezone, +) -> None: + """Test deriving change from sum statistic.""" + await hass.config.async_set_time_zone(timezone) + await async_wait_recording_done(hass) + assert "Compiling statistics for" not in caplog.text + assert "Statistics already compiled" not in caplog.text + + zero = dt_util.utcnow() + period1 = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 00:00:00")) + period2 = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 01:00:00")) + period3 = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 02:00:00")) + period4 = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 03:00:00")) + + external_statistics = ( + { + "start": period1, + "last_reset": None, + "state": 0, + "sum": 2, + }, + { + "start": period2, + "last_reset": None, + "state": 1, + "sum": 3, + }, + { + "start": period3, + "last_reset": None, + "state": 2, + "sum": 5, + }, + { + "start": period4, + "last_reset": None, + "state": 3, + "sum": 8, + }, + ) + external_metadata1 = { + "has_mean": False, + "has_sum": True, + "name": "Total imported energy", + "source": "recorder", + "statistic_id": "sensor.total_energy_import1", + "unit_of_measurement": "kWh", + } + external_metadata2 = { + "has_mean": False, + "has_sum": True, + "name": "Total imported energy", + "source": "recorder", + "statistic_id": "sensor.total_energy_import2", + "unit_of_measurement": "kWh", + } + async_import_statistics(hass, external_metadata1, external_statistics) + async_import_statistics(hass, external_metadata2, external_statistics) + await async_wait_recording_done(hass) + # Get change from far in the past + stats = statistics_during_period( + hass, + zero, + period="hour", + statistic_ids={"sensor.total_energy_import1", "sensor.total_energy_import2"}, + types={"change"}, + ) + hour1_start = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 00:00:00")) + hour1_end = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 01:00:00")) + hour2_start = hour1_end + hour2_end = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 02:00:00")) + hour3_start = hour2_end + hour3_end = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 03:00:00")) + hour4_start = hour3_end + hour4_end = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 04:00:00")) + expected_inner = [ + { + "start": hour1_start.timestamp(), + "end": hour1_end.timestamp(), + "change": 2.0, + }, + { + "start": hour2_start.timestamp(), + "end": hour2_end.timestamp(), + "change": 1.0, + }, + { + "start": hour3_start.timestamp(), + "end": hour3_end.timestamp(), + "change": 2.0, + }, + { + "start": hour4_start.timestamp(), + "end": hour4_end.timestamp(), + "change": 3.0, + }, + ] + expected_stats = { + "sensor.total_energy_import1": expected_inner, + "sensor.total_energy_import2": expected_inner, + } + assert stats == expected_stats + + # Get change + sum from far in the past + stats = statistics_during_period( + hass, + zero, + period="hour", + statistic_ids={"sensor.total_energy_import1", "sensor.total_energy_import2"}, + types={"change", "sum"}, + ) + hour1_start = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 00:00:00")) + hour1_end = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 01:00:00")) + hour2_start = hour1_end + hour2_end = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 02:00:00")) + hour3_start = hour2_end + hour3_end = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 03:00:00")) + hour4_start = hour3_end + hour4_end = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 04:00:00")) + expected_inner = [ + { + "start": hour1_start.timestamp(), + "end": hour1_end.timestamp(), + "change": 2.0, + "sum": 2.0, + }, + { + "start": hour2_start.timestamp(), + "end": hour2_end.timestamp(), + "change": 1.0, + "sum": 3.0, + }, + { + "start": hour3_start.timestamp(), + "end": hour3_end.timestamp(), + "change": 2.0, + "sum": 5.0, + }, + { + "start": hour4_start.timestamp(), + "end": hour4_end.timestamp(), + "change": 3.0, + "sum": 8.0, + }, + ] + expected_stats_change_sum = { + "sensor.total_energy_import1": expected_inner, + "sensor.total_energy_import2": expected_inner, + } + assert stats == expected_stats_change_sum + + # Get change from far in the past with unit conversion + stats = statistics_during_period( + hass, + start_time=hour1_start, + statistic_ids={"sensor.total_energy_import1", "sensor.total_energy_import2"}, + period="hour", + types={"change"}, + units={"energy": "Wh"}, + ) + expected_inner = [ + { + "start": hour1_start.timestamp(), + "end": hour1_end.timestamp(), + "change": 2.0 * 1000, + }, + { + "start": hour2_start.timestamp(), + "end": hour2_end.timestamp(), + "change": 1.0 * 1000, + }, + { + "start": hour3_start.timestamp(), + "end": hour3_end.timestamp(), + "change": 2.0 * 1000, + }, + { + "start": hour4_start.timestamp(), + "end": hour4_end.timestamp(), + "change": 3.0 * 1000, + }, + ] + expected_stats_wh = { + "sensor.total_energy_import1": expected_inner, + "sensor.total_energy_import2": expected_inner, + } + assert stats == expected_stats_wh + + # Get change from far in the past with implicit unit conversion + hass.states.async_set( + "sensor.total_energy_import1", "unknown", {"unit_of_measurement": "MWh"} + ) + hass.states.async_set( + "sensor.total_energy_import2", "unknown", {"unit_of_measurement": "MWh"} + ) + stats = statistics_during_period( + hass, + start_time=hour1_start, + statistic_ids={"sensor.total_energy_import1", "sensor.total_energy_import2"}, + period="hour", + types={"change"}, + ) + expected_inner = [ + { + "start": hour1_start.timestamp(), + "end": hour1_end.timestamp(), + "change": 2.0 / 1000, + }, + { + "start": hour2_start.timestamp(), + "end": hour2_end.timestamp(), + "change": 1.0 / 1000, + }, + { + "start": hour3_start.timestamp(), + "end": hour3_end.timestamp(), + "change": 2.0 / 1000, + }, + { + "start": hour4_start.timestamp(), + "end": hour4_end.timestamp(), + "change": 3.0 / 1000, + }, + ] + expected_stats_mwh = { + "sensor.total_energy_import1": expected_inner, + "sensor.total_energy_import2": expected_inner, + } + assert stats == expected_stats_mwh + hass.states.async_remove("sensor.total_energy_import1") + hass.states.async_remove("sensor.total_energy_import2") + + # Get change from the first recorded hour + stats = statistics_during_period( + hass, + start_time=hour1_start, + statistic_ids={"sensor.total_energy_import1", "sensor.total_energy_import2"}, + period="hour", + types={"change"}, + ) + assert stats == expected_stats + + # Get change from the first recorded hour with unit conversion + stats = statistics_during_period( + hass, + start_time=hour1_start, + statistic_ids={"sensor.total_energy_import1", "sensor.total_energy_import2"}, + period="hour", + types={"change"}, + units={"energy": "Wh"}, + ) + assert stats == expected_stats_wh + + # Get change from the first recorded hour with implicit unit conversion + hass.states.async_set( + "sensor.total_energy_import1", "unknown", {"unit_of_measurement": "MWh"} + ) + hass.states.async_set( + "sensor.total_energy_import2", "unknown", {"unit_of_measurement": "MWh"} + ) + stats = statistics_during_period( + hass, + start_time=hour1_start, + statistic_ids={"sensor.total_energy_import1", "sensor.total_energy_import2"}, + period="hour", + types={"change"}, + ) + assert stats == expected_stats_mwh + hass.states.async_remove("sensor.total_energy_import1") + hass.states.async_remove("sensor.total_energy_import2") + + # Get change from the second recorded hour + stats = statistics_during_period( + hass, + start_time=hour2_start, + statistic_ids={"sensor.total_energy_import1", "sensor.total_energy_import2"}, + period="hour", + types={"change"}, + ) + assert stats == { + "sensor.total_energy_import1": expected_stats["sensor.total_energy_import1"][ + 1:4 + ], + "sensor.total_energy_import2": expected_stats["sensor.total_energy_import2"][ + 1:4 + ], + } + + # Get change from the second recorded hour with unit conversion + stats = statistics_during_period( + hass, + start_time=hour2_start, + statistic_ids={"sensor.total_energy_import1", "sensor.total_energy_import2"}, + period="hour", + types={"change"}, + units={"energy": "Wh"}, + ) + assert stats == { + "sensor.total_energy_import1": expected_stats_wh["sensor.total_energy_import1"][ + 1:4 + ], + "sensor.total_energy_import2": expected_stats_wh["sensor.total_energy_import2"][ + 1:4 + ], + } + + # Get change from the second recorded hour with implicit unit conversion + hass.states.async_set( + "sensor.total_energy_import1", "unknown", {"unit_of_measurement": "MWh"} + ) + hass.states.async_set( + "sensor.total_energy_import2", "unknown", {"unit_of_measurement": "MWh"} + ) + stats = statistics_during_period( + hass, + start_time=hour2_start, + statistic_ids={"sensor.total_energy_import1", "sensor.total_energy_import2"}, + period="hour", + types={"change"}, + ) + assert stats == { + "sensor.total_energy_import1": expected_stats_mwh[ + "sensor.total_energy_import1" + ][1:4], + "sensor.total_energy_import2": expected_stats_mwh[ + "sensor.total_energy_import2" + ][1:4], + } + hass.states.async_remove("sensor.total_energy_import1") + hass.states.async_remove("sensor.total_energy_import2") + + # Get change from the second until the third recorded hour + stats = statistics_during_period( + hass, + start_time=hour2_start, + end_time=hour4_start, + statistic_ids={"sensor.total_energy_import1", "sensor.total_energy_import2"}, + period="hour", + types={"change"}, + ) + assert stats == { + "sensor.total_energy_import1": expected_stats["sensor.total_energy_import1"][ + 1:3 + ], + "sensor.total_energy_import2": expected_stats["sensor.total_energy_import2"][ + 1:3 + ], + } + + # Get change from the fourth recorded hour + stats = statistics_during_period( + hass, + start_time=hour4_start, + statistic_ids={"sensor.total_energy_import1", "sensor.total_energy_import2"}, + period="hour", + types={"change"}, + ) + assert stats == { + "sensor.total_energy_import1": expected_stats["sensor.total_energy_import1"][ + 3:4 + ], + "sensor.total_energy_import2": expected_stats["sensor.total_energy_import2"][ + 3:4 + ], + } + + # Test change with a far future start date + future = dt_util.as_utc(dt_util.parse_datetime("2221-11-01 00:00:00")) + stats = statistics_during_period( + hass, + start_time=future, + statistic_ids={"sensor.total_energy_import1", "sensor.total_energy_import2"}, + period="hour", + types={"change"}, + ) + assert stats == {} + + +@pytest.mark.usefixtures("multiple_start_time_chunk_sizes") @pytest.mark.parametrize("timezone", ["America/Regina", "Europe/Vienna", "UTC"]) @pytest.mark.freeze_time("2022-10-01 00:00:00+00:00") async def test_change_with_none( diff --git a/tests/components/recorder/test_util.py b/tests/components/recorder/test_util.py index c9020762d4b..6c324f4b01a 100644 --- a/tests/components/recorder/test_util.py +++ b/tests/components/recorder/test_util.py @@ -417,7 +417,12 @@ def test_supported_mysql(caplog: pytest.LogCaptureFixture, mysql_version) -> Non dbapi_connection = MagicMock(cursor=_make_cursor_mock) - util.setup_connection_for_dialect(instance_mock, "mysql", dbapi_connection, True) + database_engine = util.setup_connection_for_dialect( + instance_mock, "mysql", dbapi_connection, True + ) + assert database_engine is not None + assert database_engine.optimizer.slow_range_in_select is False + assert database_engine.optimizer.slow_dependent_subquery is True assert "minimum supported version" not in caplog.text @@ -502,6 +507,7 @@ def test_supported_pgsql(caplog: pytest.LogCaptureFixture, pgsql_version) -> Non assert "minimum supported version" not in caplog.text assert database_engine is not None assert database_engine.optimizer.slow_range_in_select is True + assert database_engine.optimizer.slow_dependent_subquery is False @pytest.mark.parametrize( @@ -583,6 +589,7 @@ def test_supported_sqlite(caplog: pytest.LogCaptureFixture, sqlite_version) -> N assert "minimum supported version" not in caplog.text assert database_engine is not None assert database_engine.optimizer.slow_range_in_select is False + assert database_engine.optimizer.slow_dependent_subquery is False @pytest.mark.parametrize( @@ -675,6 +682,7 @@ async def test_issue_for_mariadb_with_MDEV_25020( assert database_engine is not None assert database_engine.optimizer.slow_range_in_select is True + assert database_engine.optimizer.slow_dependent_subquery is False @pytest.mark.parametrize( @@ -731,6 +739,7 @@ async def test_no_issue_for_mariadb_with_MDEV_25020( assert database_engine is not None assert database_engine.optimizer.slow_range_in_select is False + assert database_engine.optimizer.slow_dependent_subquery is False @pytest.mark.skip_on_db_engine(["mysql", "postgresql"]) From b4b7142b55c970e7d4f8ab0a01831eb13faf2634 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Fri, 28 Feb 2025 20:51:56 +0100 Subject: [PATCH 2060/3148] Specify recorder as after dependency in sql integration (#139037) * Specify recorder as after dependency in sql integration * Remove hassfest exception --------- Co-authored-by: J. Nick Koston --- homeassistant/components/sql/manifest.json | 1 + script/hassfest/dependencies.py | 2 -- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/homeassistant/components/sql/manifest.json b/homeassistant/components/sql/manifest.json index c18b1b9f05f..2b00a5b0d65 100644 --- a/homeassistant/components/sql/manifest.json +++ b/homeassistant/components/sql/manifest.json @@ -1,6 +1,7 @@ { "domain": "sql", "name": "SQL", + "after_dependencies": ["recorder"], "codeowners": ["@gjohansson-ST", "@dougiteixeira"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/sql", diff --git a/script/hassfest/dependencies.py b/script/hassfest/dependencies.py index 368c2f762b8..b22027500dd 100644 --- a/script/hassfest/dependencies.py +++ b/script/hassfest/dependencies.py @@ -153,8 +153,6 @@ ALLOWED_USED_COMPONENTS = { } IGNORE_VIOLATIONS = { - # Has same requirement, gets defaults. - ("sql", "recorder"), # Sharing a base class ("lutron_caseta", "lutron"), ("ffmpeg_noise", "ffmpeg_motion"), From a0668e5a5bb140b2519cb7c25378107c7d19ec6f Mon Sep 17 00:00:00 2001 From: Guido Schmitz Date: Sat, 1 Mar 2025 13:29:50 +0100 Subject: [PATCH 2061/3148] Handle IPv6 URLs in devolo Home Network (#139191) * Handle IPv6 URLs in devolo Home Network * Use yarl --- .../components/devolo_home_network/entity.py | 3 +- .../devolo_home_network/conftest.py | 7 ++++ .../snapshots/test_init.ambr | 37 +++++++++++++++++++ .../devolo_home_network/test_init.py | 4 +- 4 files changed, 49 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/devolo_home_network/entity.py b/homeassistant/components/devolo_home_network/entity.py index 93ec1b9a3a2..64d8ff131e8 100644 --- a/homeassistant/components/devolo_home_network/entity.py +++ b/homeassistant/components/devolo_home_network/entity.py @@ -8,6 +8,7 @@ from devolo_plc_api.device_api import ( WifiGuestAccessGet, ) from devolo_plc_api.plcnet_api import DataRate, LogicalNetwork +from yarl import URL from homeassistant.const import ATTR_CONNECTIONS from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo @@ -43,7 +44,7 @@ class DevoloEntity(Entity): self.entry = entry self._attr_device_info = DeviceInfo( - configuration_url=f"http://{self.device.ip}", + configuration_url=URL.build(scheme="http", host=self.device.ip), identifiers={(DOMAIN, str(self.device.serial_number))}, manufacturer="devolo", model=self.device.product, diff --git a/tests/components/devolo_home_network/conftest.py b/tests/components/devolo_home_network/conftest.py index fd03063cd34..2b3fd989754 100644 --- a/tests/components/devolo_home_network/conftest.py +++ b/tests/components/devolo_home_network/conftest.py @@ -27,6 +27,13 @@ def mock_repeater_device(mock_device: MockDevice): return mock_device +@pytest.fixture +def mock_ipv6_device(mock_device: MockDevice): + """Mock connecting to a devolo home network device using IPv6.""" + mock_device.ip = "2001:db8::1" + return mock_device + + @pytest.fixture def mock_nonwifi_device(mock_device: MockDevice): """Mock connecting to a devolo home network device without wifi.""" diff --git a/tests/components/devolo_home_network/snapshots/test_init.ambr b/tests/components/devolo_home_network/snapshots/test_init.ambr index bdc597819a7..5753fd82817 100644 --- a/tests/components/devolo_home_network/snapshots/test_init.ambr +++ b/tests/components/devolo_home_network/snapshots/test_init.ambr @@ -36,6 +36,43 @@ 'via_device_id': None, }) # --- +# name: test_setup_entry[mock_ipv6_device] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'http://[2001:db8::1]', + 'connections': set({ + tuple( + 'mac', + 'aa:bb:cc:dd:ee:ff', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'devolo_home_network', + '1234567890', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'devolo', + 'model': 'dLAN pro 1200+ WiFi ac', + 'model_id': '2730', + 'name': 'Mock Title', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '1234567890', + 'suggested_area': None, + 'sw_version': '5.6.1', + 'via_device_id': None, + }) +# --- # name: test_setup_entry[mock_repeater_device] DeviceRegistryEntrySnapshot({ 'area_id': None, diff --git a/tests/components/devolo_home_network/test_init.py b/tests/components/devolo_home_network/test_init.py index 71823eabe82..56d2c21a5b2 100644 --- a/tests/components/devolo_home_network/test_init.py +++ b/tests/components/devolo_home_network/test_init.py @@ -27,7 +27,9 @@ from .mock import MockDevice from tests.common import MockConfigEntry -@pytest.mark.parametrize("device", ["mock_device", "mock_repeater_device"]) +@pytest.mark.parametrize( + "device", ["mock_device", "mock_repeater_device", "mock_ipv6_device"] +) async def test_setup_entry( hass: HomeAssistant, device: str, From 61a3cc37e010b2ee2b4d03fa3059c540a6c7b959 Mon Sep 17 00:00:00 2001 From: Juan Grande Date: Sat, 1 Mar 2025 00:10:35 -0800 Subject: [PATCH 2062/3148] Fix bug in derivative sensor when source sensor's state is constant (#139230) Previously, when the source sensor's state remains constant, the derivative sensor repeats its latest value indefinitely. This patch fixes this bug by consuming the state_reported event and updating the sensor's output even when the source sensor doesn't change its state. --- homeassistant/components/derivative/sensor.py | 66 ++++++++--- tests/components/derivative/test_sensor.py | 111 ++++++++++++++++-- 2 files changed, 150 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/derivative/sensor.py b/homeassistant/components/derivative/sensor.py index 90f8a95919d..f6c2b45ef9c 100644 --- a/homeassistant/components/derivative/sensor.py +++ b/homeassistant/components/derivative/sensor.py @@ -24,7 +24,14 @@ from homeassistant.const import ( STATE_UNKNOWN, UnitOfTime, ) -from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback +from homeassistant.core import ( + Event, + EventStateChangedData, + EventStateReportedData, + HomeAssistant, + State, + callback, +) from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.device import async_device_info_to_link_from_entity from homeassistant.helpers.device_registry import DeviceInfo @@ -32,7 +39,10 @@ from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, ) -from homeassistant.helpers.event import async_track_state_change_event +from homeassistant.helpers.event import ( + async_track_state_change_event, + async_track_state_report_event, +) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import ( @@ -200,13 +210,33 @@ class DerivativeSensor(RestoreSensor, SensorEntity): _LOGGER.warning("Could not restore last state: %s", err) @callback - def calc_derivative(event: Event[EventStateChangedData]) -> None: + def on_state_reported(event: Event[EventStateReportedData]) -> None: + """Handle constant sensor state.""" + if self._attr_native_value == Decimal(0): + # If the derivative is zero, and the source sensor hasn't + # changed state, then we know it will still be zero. + return + new_state = event.data["new_state"] + if new_state is not None: + calc_derivative( + new_state, new_state.state, event.data["old_last_reported"] + ) + + @callback + def on_state_changed(event: Event[EventStateChangedData]) -> None: + """Handle changed sensor state.""" + new_state = event.data["new_state"] + old_state = event.data["old_state"] + if new_state is not None and old_state is not None: + calc_derivative(new_state, old_state.state, old_state.last_reported) + + def calc_derivative( + new_state: State, old_value: str, old_last_reported: datetime + ) -> None: """Handle the sensor state changes.""" - if ( - (old_state := event.data["old_state"]) is None - or old_state.state in (STATE_UNKNOWN, STATE_UNAVAILABLE) - or (new_state := event.data["new_state"]) is None - or new_state.state in (STATE_UNKNOWN, STATE_UNAVAILABLE) + if old_value in (STATE_UNKNOWN, STATE_UNAVAILABLE) or new_state.state in ( + STATE_UNKNOWN, + STATE_UNAVAILABLE, ): return @@ -220,15 +250,15 @@ class DerivativeSensor(RestoreSensor, SensorEntity): self._state_list = [ (time_start, time_end, state) for time_start, time_end, state in self._state_list - if (new_state.last_updated - time_end).total_seconds() + if (new_state.last_reported - time_end).total_seconds() < self._time_window ] try: elapsed_time = ( - new_state.last_updated - old_state.last_updated + new_state.last_reported - old_last_reported ).total_seconds() - delta_value = Decimal(new_state.state) - Decimal(old_state.state) + delta_value = Decimal(new_state.state) - Decimal(old_value) new_derivative = ( delta_value / Decimal(elapsed_time) @@ -240,7 +270,7 @@ class DerivativeSensor(RestoreSensor, SensorEntity): _LOGGER.warning("While calculating derivative: %s", err) except DecimalException as err: _LOGGER.warning( - "Invalid state (%s > %s): %s", old_state.state, new_state.state, err + "Invalid state (%s > %s): %s", old_value, new_state.state, err ) except AssertionError as err: _LOGGER.error("Could not calculate derivative: %s", err) @@ -257,7 +287,7 @@ class DerivativeSensor(RestoreSensor, SensorEntity): # add latest derivative to the window list self._state_list.append( - (old_state.last_updated, new_state.last_updated, new_derivative) + (old_last_reported, new_state.last_reported, new_derivative) ) def calculate_weight( @@ -277,13 +307,19 @@ class DerivativeSensor(RestoreSensor, SensorEntity): else: derivative = Decimal("0.00") for start, end, value in self._state_list: - weight = calculate_weight(start, end, new_state.last_updated) + weight = calculate_weight(start, end, new_state.last_reported) derivative = derivative + (value * Decimal(weight)) self._attr_native_value = round(derivative, self._round_digits) self.async_write_ha_state() self.async_on_remove( async_track_state_change_event( - self.hass, self._sensor_source_id, calc_derivative + self.hass, self._sensor_source_id, on_state_changed + ) + ) + + self.async_on_remove( + async_track_state_report_event( + self.hass, self._sensor_source_id, on_state_reported ) ) diff --git a/tests/components/derivative/test_sensor.py b/tests/components/derivative/test_sensor.py index a543de974f1..f8d88066f16 100644 --- a/tests/components/derivative/test_sensor.py +++ b/tests/components/derivative/test_sensor.py @@ -39,7 +39,7 @@ async def test_state(hass: HomeAssistant) -> None: await hass.async_block_till_done() freezer.move_to(dt_util.utcnow() + timedelta(seconds=3600)) - hass.states.async_set(entity_id, 1, {}, force_update=True) + hass.states.async_set(entity_id, 1, {}) await hass.async_block_till_done() state = hass.states.get("sensor.derivative") @@ -51,6 +51,49 @@ async def test_state(hass: HomeAssistant) -> None: assert state.attributes.get("unit_of_measurement") == "kW" +async def test_no_change(hass: HomeAssistant) -> None: + """Test derivative sensor state updated when source sensor doesn't change.""" + config = { + "sensor": { + "platform": "derivative", + "name": "derivative", + "source": "sensor.energy", + "unit": "kW", + "round": 2, + } + } + + assert await async_setup_component(hass, "sensor", config) + + entity_id = config["sensor"]["source"] + base = dt_util.utcnow() + with freeze_time(base) as freezer: + hass.states.async_set(entity_id, 0, {}) + await hass.async_block_till_done() + + freezer.move_to(dt_util.utcnow() + timedelta(seconds=3600)) + hass.states.async_set(entity_id, 1, {}) + await hass.async_block_till_done() + + freezer.move_to(dt_util.utcnow() + timedelta(seconds=3600)) + hass.states.async_set(entity_id, 1, {}) + await hass.async_block_till_done() + + freezer.move_to(dt_util.utcnow() + timedelta(seconds=3600)) + hass.states.async_set(entity_id, 1, {}) + await hass.async_block_till_done() + + state = hass.states.get("sensor.derivative") + assert state is not None + + # Testing a energy sensor at 1 kWh for 1hour = 0kW + assert round(float(state.state), config["sensor"]["round"]) == 0.0 + + assert state.attributes.get("unit_of_measurement") == "kW" + + assert state.last_changed == base + timedelta(seconds=2 * 3600) + + async def _setup_sensor( hass: HomeAssistant, config: dict[str, Any] ) -> tuple[dict[str, Any], str]: @@ -86,7 +129,7 @@ async def setup_tests( with freeze_time(base) as freezer: for time, value in zip(times, values, strict=False): freezer.move_to(base + timedelta(seconds=time)) - hass.states.async_set(entity_id, value, {}, force_update=True) + hass.states.async_set(entity_id, value, {}) await hass.async_block_till_done() state = hass.states.get("sensor.power") @@ -159,6 +202,53 @@ async def test_dataSet6(hass: HomeAssistant) -> None: await setup_tests(hass, {}, times=[0, 60], values=[0, 1 / 60], expected_state=1) +async def test_data_moving_average_with_zeroes(hass: HomeAssistant) -> None: + """Test that zeroes are properly handled within the time window.""" + # We simulate the following situation: + # The temperature rises 1 °C per minute for 10 minutes long. Then, it + # stays constant for another 10 minutes. There is a data point every + # minute and we use a time window of 10 minutes. + # Therefore, we can expect the derivative to peak at 1 after 10 minutes + # and then fall down to 0 in steps of 10%. + + temperature_values = [] + for temperature in range(10): + temperature_values += [temperature] + temperature_values += [10] * 11 + time_window = 600 + times = list(range(0, 1200 + 60, 60)) + + config, entity_id = await _setup_sensor( + hass, + { + "time_window": {"seconds": time_window}, + "unit_time": UnitOfTime.MINUTES, + "round": 1, + }, + ) + + base = dt_util.utcnow() + with freeze_time(base) as freezer: + last_derivative = 0 + for time, value in zip(times, temperature_values, strict=True): + now = base + timedelta(seconds=time) + freezer.move_to(now) + hass.states.async_set(entity_id, value, {}) + await hass.async_block_till_done() + + state = hass.states.get("sensor.power") + derivative = round(float(state.state), config["sensor"]["round"]) + + if time_window == time: + assert derivative == 1.0 + elif time_window < time < time_window * 2: + assert (0.1 - 1e-6) < abs(derivative - last_derivative) < (0.1 + 1e-6) + elif time == time_window * 2: + assert derivative == 0 + + last_derivative = derivative + + async def test_data_moving_average_for_discrete_sensor(hass: HomeAssistant) -> None: """Test derivative sensor state.""" # We simulate the following situation: @@ -188,7 +278,7 @@ async def test_data_moving_average_for_discrete_sensor(hass: HomeAssistant) -> N for time, value in zip(times, temperature_values, strict=False): now = base + timedelta(seconds=time) freezer.move_to(now) - hass.states.async_set(entity_id, value, {}, force_update=True) + hass.states.async_set(entity_id, value, {}) await hass.async_block_till_done() if time_window < time < times[-1] - time_window: @@ -232,7 +322,7 @@ async def test_data_moving_average_for_irregular_times(hass: HomeAssistant) -> N for time, value in zip(times, temperature_values, strict=False): now = base + timedelta(seconds=time) freezer.move_to(now) - hass.states.async_set(entity_id, value, {}, force_update=True) + hass.states.async_set(entity_id, value, {}) await hass.async_block_till_done() if time_window < time and time > times[3]: @@ -270,7 +360,7 @@ async def test_double_signal_after_delay(hass: HomeAssistant) -> None: for time, value in zip(times, temperature_values, strict=False): now = base + timedelta(seconds=time) freezer.move_to(now) - hass.states.async_set(entity_id, value, {}, force_update=True) + hass.states.async_set(entity_id, value, {}) await hass.async_block_till_done() state = hass.states.get("sensor.power") derivative = round(float(state.state), config["sensor"]["round"]) @@ -302,24 +392,22 @@ async def test_prefix(hass: HomeAssistant) -> None: entity_id, 1000, {"unit_of_measurement": UnitOfPower.WATT}, - force_update=True, ) await hass.async_block_till_done() freezer.move_to(dt_util.utcnow() + timedelta(seconds=3600)) hass.states.async_set( entity_id, - 1000, + 2000, {"unit_of_measurement": UnitOfPower.WATT}, - force_update=True, ) await hass.async_block_till_done() state = hass.states.get("sensor.derivative") assert state is not None - # Testing a power sensor at 1000 Watts for 1hour = 0kW/h - assert round(float(state.state), config["sensor"]["round"]) == 0.0 + # Testing a power sensor increasing by 1000 Watts per hour = 1kW/h + assert round(float(state.state), config["sensor"]["round"]) == 1.0 assert state.attributes.get("unit_of_measurement") == f"kW/{UnitOfTime.HOURS}" @@ -345,7 +433,7 @@ async def test_suffix(hass: HomeAssistant) -> None: await hass.async_block_till_done() freezer.move_to(dt_util.utcnow() + timedelta(seconds=3600)) - hass.states.async_set(entity_id, 1000, {}, force_update=True) + hass.states.async_set(entity_id, 1000, {}) await hass.async_block_till_done() state = hass.states.get("sensor.derivative") @@ -375,7 +463,6 @@ async def test_total_increasing_reset(hass: HomeAssistant) -> None: entity_id, value, {ATTR_STATE_CLASS: SensorStateClass.TOTAL_INCREASING}, - force_update=True, ) await hass.async_block_till_done() From a4e71e20557d836c6cbc899383ef54c7d62fb864 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Fri, 28 Feb 2025 20:56:43 +0100 Subject: [PATCH 2063/3148] Ensure Hue bridge is added first to the device registry (#139438) --- homeassistant/components/hue/v2/device.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/hue/v2/device.py b/homeassistant/components/hue/v2/device.py index 25a027f9ebe..7bb3d28e962 100644 --- a/homeassistant/components/hue/v2/device.py +++ b/homeassistant/components/hue/v2/device.py @@ -94,7 +94,12 @@ async def async_setup_devices(bridge: HueBridge): add_device(hue_resource) # create/update all current devices found in controllers - known_devices = [add_device(hue_device) for hue_device in dev_controller] + # sort the devices to ensure bridges are added first + hue_devices = list(dev_controller) + hue_devices.sort( + key=lambda dev: dev.metadata.archetype != DeviceArchetypes.BRIDGE_V2 + ) + known_devices = [add_device(hue_device) for hue_device in hue_devices] known_devices += [add_device(hue_room) for hue_room in api.groups.room] known_devices += [add_device(hue_zone) for hue_zone in api.groups.zone] From 708f22fe6fdd481dadc1d1c4ba8f15c1228ffde2 Mon Sep 17 00:00:00 2001 From: Filip Agh Date: Sat, 1 Mar 2025 11:50:24 +0100 Subject: [PATCH 2064/3148] Fix update data for multiple Gree devices (#139469) fix sync date for multiple devices do not use handler for explicit update devices as internal communication lib do not provide which device is updated use ha update loop copy data object to prevent rewrite data from internal lib allow more time to process response before log warning about long wait for response and make log message more clear --- homeassistant/components/gree/const.py | 1 + homeassistant/components/gree/coordinator.py | 14 ++++++++++---- tests/components/gree/test_climate.py | 3 ++- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/gree/const.py b/homeassistant/components/gree/const.py index f926eb1c53e..14236f09fa2 100644 --- a/homeassistant/components/gree/const.py +++ b/homeassistant/components/gree/const.py @@ -20,3 +20,4 @@ MAX_ERRORS = 2 TARGET_TEMPERATURE_STEP = 1 UPDATE_INTERVAL = 60 +MAX_EXPECTED_RESPONSE_TIME_INTERVAL = UPDATE_INTERVAL * 2 diff --git a/homeassistant/components/gree/coordinator.py b/homeassistant/components/gree/coordinator.py index 0d1aa60deaa..c8b4e6cff54 100644 --- a/homeassistant/components/gree/coordinator.py +++ b/homeassistant/components/gree/coordinator.py @@ -2,6 +2,7 @@ from __future__ import annotations +import copy from datetime import datetime, timedelta import logging from typing import Any @@ -24,6 +25,7 @@ from .const import ( DISPATCH_DEVICE_DISCOVERED, DOMAIN, MAX_ERRORS, + MAX_EXPECTED_RESPONSE_TIME_INTERVAL, UPDATE_INTERVAL, ) @@ -48,7 +50,6 @@ class DeviceDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): always_update=False, ) self.device = device - self.device.add_handler(Response.DATA, self.device_state_updated) self.device.add_handler(Response.RESULT, self.device_state_updated) self._error_count: int = 0 @@ -88,7 +89,9 @@ class DeviceDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): # raise update failed if time for more than MAX_ERRORS has passed since last update now = utcnow() elapsed_success = now - self._last_response_time - if self.update_interval and elapsed_success >= self.update_interval: + if self.update_interval and elapsed_success >= timedelta( + seconds=MAX_EXPECTED_RESPONSE_TIME_INTERVAL + ): if not self._last_error_time or ( (now - self.update_interval) >= self._last_error_time ): @@ -96,16 +99,19 @@ class DeviceDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): self._error_count += 1 _LOGGER.warning( - "Device %s is unresponsive for %s seconds", + "Device %s took an unusually long time to respond, %s seconds", self.name, elapsed_success, ) + else: + self._error_count = 0 if self.last_update_success and self._error_count >= MAX_ERRORS: raise UpdateFailed( f"Device {self.name} is unresponsive for too long and now unavailable" ) - return self.device.raw_properties + self._last_response_time = utcnow() + return copy.deepcopy(self.device.raw_properties) async def push_state_update(self): """Send state updates to the physical device.""" diff --git a/tests/components/gree/test_climate.py b/tests/components/gree/test_climate.py index 0cb187f5a60..d7c011a4c25 100644 --- a/tests/components/gree/test_climate.py +++ b/tests/components/gree/test_climate.py @@ -52,6 +52,7 @@ from homeassistant.components.gree.const import ( DISCOVERY_SCAN_INTERVAL, FAN_MEDIUM_HIGH, FAN_MEDIUM_LOW, + MAX_EXPECTED_RESPONSE_TIME_INTERVAL, UPDATE_INTERVAL, ) from homeassistant.const import ( @@ -346,7 +347,7 @@ async def test_unresponsive_device( await async_setup_gree(hass) async def run_update(): - freezer.tick(timedelta(seconds=UPDATE_INTERVAL)) + freezer.tick(timedelta(seconds=MAX_EXPECTED_RESPONSE_TIME_INTERVAL)) async_fire_time_changed(hass) await hass.async_block_till_done() From 8a62b882bf72d14a1aca980ac253201b5028c236 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 28 Feb 2025 20:39:49 +0100 Subject: [PATCH 2065/3148] Use last event as color mode in SmartThings (#139473) * Use last event as color mode in SmartThings * Use last event as color mode in SmartThings * Fix --- homeassistant/components/smartthings/light.py | 37 +++--- tests/components/smartthings/test_light.py | 116 +++++++++++++++++- 2 files changed, 135 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/smartthings/light.py b/homeassistant/components/smartthings/light.py index 54e8ad18a7c..aa3a8d35859 100644 --- a/homeassistant/components/smartthings/light.py +++ b/homeassistant/components/smartthings/light.py @@ -3,12 +3,13 @@ from __future__ import annotations import asyncio -from typing import Any +from typing import Any, cast -from pysmartthings import Attribute, Capability, Command, SmartThings +from pysmartthings import Attribute, Capability, Command, DeviceEvent, SmartThings from homeassistant.components.light import ( ATTR_BRIGHTNESS, + ATTR_COLOR_MODE, ATTR_COLOR_TEMP_KELVIN, ATTR_HS_COLOR, ATTR_TRANSITION, @@ -19,6 +20,7 @@ from homeassistant.components.light import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.restore_state import RestoreEntity from . import FullDevice, SmartThingsConfigEntry from .const import MAIN @@ -53,7 +55,7 @@ def convert_scale( return round(value * target_scale / value_scale, round_digits) -class SmartThingsLight(SmartThingsEntity, LightEntity): +class SmartThingsLight(SmartThingsEntity, LightEntity, RestoreEntity): """Define a SmartThings Light.""" _attr_name = None @@ -84,18 +86,28 @@ class SmartThingsLight(SmartThingsEntity, LightEntity): color_modes = set() if self.supports_capability(Capability.COLOR_TEMPERATURE): color_modes.add(ColorMode.COLOR_TEMP) + self._attr_color_mode = ColorMode.COLOR_TEMP if self.supports_capability(Capability.COLOR_CONTROL): color_modes.add(ColorMode.HS) + self._attr_color_mode = ColorMode.HS if not color_modes and self.supports_capability(Capability.SWITCH_LEVEL): color_modes.add(ColorMode.BRIGHTNESS) if not color_modes: color_modes.add(ColorMode.ONOFF) + if len(color_modes) == 1: + self._attr_color_mode = list(color_modes)[0] self._attr_supported_color_modes = color_modes features = LightEntityFeature(0) if self.supports_capability(Capability.SWITCH_LEVEL): features |= LightEntityFeature.TRANSITION self._attr_supported_features = features + async def async_added_to_hass(self) -> None: + """Run when entity about to be added to hass.""" + await super().async_added_to_hass() + if (last_state := await self.async_get_last_extra_data()) is not None: + self._attr_color_mode = last_state.as_dict()[ATTR_COLOR_MODE] + async def async_turn_on(self, **kwargs: Any) -> None: """Turn the light on.""" tasks = [] @@ -195,17 +207,14 @@ class SmartThingsLight(SmartThingsEntity, LightEntity): argument=[level, duration], ) - @property - def color_mode(self) -> ColorMode: - """Return the color mode of the light.""" - if len(self._attr_supported_color_modes) == 1: - # The light supports only a single color mode - return list(self._attr_supported_color_modes)[0] - - # The light supports hs + color temp, determine which one it is - if self._attr_hs_color and self._attr_hs_color[1]: - return ColorMode.HS - return ColorMode.COLOR_TEMP + def _update_handler(self, event: DeviceEvent) -> None: + """Handle device updates.""" + if event.capability in (Capability.COLOR_CONTROL, Capability.COLOR_TEMPERATURE): + self._attr_color_mode = { + Capability.COLOR_CONTROL: ColorMode.HS, + Capability.COLOR_TEMPERATURE: ColorMode.COLOR_TEMP, + }[cast(Capability, event.capability)] + super()._update_handler(event) @property def is_on(self) -> bool: diff --git a/tests/components/smartthings/test_light.py b/tests/components/smartthings/test_light.py index 8d47e90c9f5..56eadde748b 100644 --- a/tests/components/smartthings/test_light.py +++ b/tests/components/smartthings/test_light.py @@ -12,7 +12,12 @@ from homeassistant.components.light import ( ATTR_COLOR_MODE, ATTR_COLOR_TEMP_KELVIN, ATTR_HS_COLOR, + ATTR_MAX_COLOR_TEMP_KELVIN, + ATTR_MIN_COLOR_TEMP_KELVIN, + ATTR_RGB_COLOR, + ATTR_SUPPORTED_COLOR_MODES, ATTR_TRANSITION, + ATTR_XY_COLOR, DOMAIN as LIGHT_DOMAIN, ColorMode, ) @@ -25,7 +30,7 @@ from homeassistant.const import ( STATE_ON, Platform, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, State from homeassistant.helpers import entity_registry as er from . import ( @@ -35,7 +40,7 @@ from . import ( trigger_update, ) -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, mock_restore_cache_with_extra_data async def test_all_entities( @@ -228,6 +233,15 @@ async def test_updating_brightness( set_attribute_value(devices, Capability.SWITCH, Attribute.SWITCH, "on") await setup_integration(hass, mock_config_entry) + await trigger_update( + hass, + devices, + "cb958955-b015-498c-9e62-fc0c51abd054", + Capability.COLOR_CONTROL, + Attribute.HUE, + 40, + ) + assert hass.states.get("light.standing_light").attributes[ATTR_BRIGHTNESS] == 178 await trigger_update( @@ -252,8 +266,17 @@ async def test_updating_hs( set_attribute_value(devices, Capability.SWITCH, Attribute.SWITCH, "on") await setup_integration(hass, mock_config_entry) + await trigger_update( + hass, + devices, + "cb958955-b015-498c-9e62-fc0c51abd054", + Capability.COLOR_CONTROL, + Attribute.HUE, + 40, + ) + assert hass.states.get("light.standing_light").attributes[ATTR_HS_COLOR] == ( - 218.906, + 144.0, 60, ) @@ -280,9 +303,17 @@ async def test_updating_color_temp( ) -> None: """Test color temperature update.""" set_attribute_value(devices, Capability.SWITCH, Attribute.SWITCH, "on") - set_attribute_value(devices, Capability.COLOR_CONTROL, Attribute.SATURATION, 0) await setup_integration(hass, mock_config_entry) + await trigger_update( + hass, + devices, + "cb958955-b015-498c-9e62-fc0c51abd054", + Capability.COLOR_TEMPERATURE, + Attribute.COLOR_TEMPERATURE, + 3000, + ) + assert ( hass.states.get("light.standing_light").attributes[ATTR_COLOR_MODE] is ColorMode.COLOR_TEMP @@ -305,3 +336,80 @@ async def test_updating_color_temp( hass.states.get("light.standing_light").attributes[ATTR_COLOR_TEMP_KELVIN] == 2000 ) + + +@pytest.mark.parametrize("device_fixture", ["hue_rgbw_color_bulb"]) +async def test_color_modes( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test color mode changes.""" + set_attribute_value(devices, Capability.SWITCH, Attribute.SWITCH, "on") + set_attribute_value(devices, Capability.COLOR_CONTROL, Attribute.SATURATION, 50) + await setup_integration(hass, mock_config_entry) + + assert ( + hass.states.get("light.standing_light").attributes[ATTR_COLOR_MODE] + is ColorMode.HS + ) + + await trigger_update( + hass, + devices, + "cb958955-b015-498c-9e62-fc0c51abd054", + Capability.COLOR_TEMPERATURE, + Attribute.COLOR_TEMPERATURE, + 2000, + ) + + assert ( + hass.states.get("light.standing_light").attributes[ATTR_COLOR_MODE] + is ColorMode.COLOR_TEMP + ) + + await trigger_update( + hass, + devices, + "cb958955-b015-498c-9e62-fc0c51abd054", + Capability.COLOR_CONTROL, + Attribute.HUE, + 20, + ) + + assert ( + hass.states.get("light.standing_light").attributes[ATTR_COLOR_MODE] + is ColorMode.HS + ) + + +@pytest.mark.parametrize("device_fixture", ["hue_rgbw_color_bulb"]) +async def test_color_mode_after_startup( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test color mode after startup.""" + set_attribute_value(devices, Capability.SWITCH, Attribute.SWITCH, "on") + + RESTORE_DATA = { + ATTR_BRIGHTNESS: 178, + ATTR_COLOR_MODE: ColorMode.COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN: 3000, + ATTR_HS_COLOR: (144.0, 60), + ATTR_MAX_COLOR_TEMP_KELVIN: 9000, + ATTR_MIN_COLOR_TEMP_KELVIN: 2000, + ATTR_RGB_COLOR: (255, 128, 0), + ATTR_SUPPORTED_COLOR_MODES: [ColorMode.COLOR_TEMP, ColorMode.HS], + ATTR_XY_COLOR: (0.61, 0.35), + } + + mock_restore_cache_with_extra_data( + hass, ((State("light.standing_light", STATE_ON), RESTORE_DATA),) + ) + await setup_integration(hass, mock_config_entry) + + assert ( + hass.states.get("light.standing_light").attributes[ATTR_COLOR_MODE] + is ColorMode.COLOR_TEMP + ) From 22af8af132be3bed372584ea9ae7b06c3c228229 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 28 Feb 2025 20:03:24 +0100 Subject: [PATCH 2066/3148] Set SmartThings delta energy to Total (#139474) --- .../components/smartthings/sensor.py | 2 +- .../smartthings/snapshots/test_sensor.ambr | 24 +++++++++---------- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index cd12bf46e25..0a695876da4 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -596,7 +596,7 @@ CAPABILITY_TO_SENSORS: dict[ SmartThingsSensorEntityDescription( key="deltaEnergy_meter", translation_key="energy_difference", - state_class=SensorStateClass.TOTAL_INCREASING, + state_class=SensorStateClass.TOTAL, device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, value_fn=lambda value: value["deltaEnergy"] / 1000, diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index b67d15bef55..78aa4db62f8 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -582,7 +582,7 @@ }), 'area_id': None, 'capabilities': dict({ - 'state_class': , + 'state_class': , }), 'config_entry_id': , 'config_subentry_id': , @@ -620,7 +620,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'AC Office Granit Energy difference', - 'state_class': , + 'state_class': , 'unit_of_measurement': , }), 'context': , @@ -1011,7 +1011,7 @@ }), 'area_id': None, 'capabilities': dict({ - 'state_class': , + 'state_class': , }), 'config_entry_id': , 'config_subentry_id': , @@ -1049,7 +1049,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Aire Dormitorio Principal Energy difference', - 'state_class': , + 'state_class': , 'unit_of_measurement': , }), 'context': , @@ -1835,7 +1835,7 @@ }), 'area_id': None, 'capabilities': dict({ - 'state_class': , + 'state_class': , }), 'config_entry_id': , 'config_subentry_id': , @@ -1873,7 +1873,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Refrigerator Energy difference', - 'state_class': , + 'state_class': , 'unit_of_measurement': , }), 'context': , @@ -2408,7 +2408,7 @@ }), 'area_id': None, 'capabilities': dict({ - 'state_class': , + 'state_class': , }), 'config_entry_id': , 'config_subentry_id': , @@ -2446,7 +2446,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Dishwasher Energy difference', - 'state_class': , + 'state_class': , 'unit_of_measurement': , }), 'context': , @@ -2865,7 +2865,7 @@ }), 'area_id': None, 'capabilities': dict({ - 'state_class': , + 'state_class': , }), 'config_entry_id': , 'config_subentry_id': , @@ -2903,7 +2903,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Dryer Energy difference', - 'state_class': , + 'state_class': , 'unit_of_measurement': , }), 'context': , @@ -3332,7 +3332,7 @@ }), 'area_id': None, 'capabilities': dict({ - 'state_class': , + 'state_class': , }), 'config_entry_id': , 'config_subentry_id': , @@ -3370,7 +3370,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Washer Energy difference', - 'state_class': , + 'state_class': , 'unit_of_measurement': , }), 'context': , From dce8bca103b843c6e10907df30c0790b7c716c94 Mon Sep 17 00:00:00 2001 From: StaleLoafOfBread <45444205+StaleLoafOfBread@users.noreply.github.com> Date: Fri, 28 Feb 2025 14:59:35 -0500 Subject: [PATCH 2067/3148] Fix alert not respecting can_acknowledge setting (#139483) * fix(alert): check can_ack prior to acking * fix(alert): add test for when can_acknowledge=False * fix(alert): warn on can_ack blocking an ack * Raise error when trying to acknowledge alert with can_acknowledge set to False * Rewrite can_ack check as guard Co-authored-by: Franck Nijhof * Make can_ack service error msg human readable because it will show up in the UI * format with ruff * Make pytest aware of service error when acking an unackable alert --------- Co-authored-by: Franck Nijhof --- homeassistant/components/alert/entity.py | 5 ++-- tests/components/alert/test_init.py | 30 ++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/alert/entity.py b/homeassistant/components/alert/entity.py index 629047b15ba..a11b281428f 100644 --- a/homeassistant/components/alert/entity.py +++ b/homeassistant/components/alert/entity.py @@ -14,7 +14,7 @@ from homeassistant.components.notify import ( ) from homeassistant.const import STATE_IDLE, STATE_OFF, STATE_ON from homeassistant.core import Event, EventStateChangedData, HassJob, HomeAssistant -from homeassistant.exceptions import ServiceNotFound +from homeassistant.exceptions import ServiceNotFound, ServiceValidationError from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import ( async_track_point_in_time, @@ -195,7 +195,8 @@ class AlertEntity(Entity): async def async_turn_off(self, **kwargs: Any) -> None: """Async Acknowledge alert.""" - LOGGER.debug("Acknowledged Alert: %s", self._attr_name) + if not self._can_ack: + raise ServiceValidationError("This alert cannot be acknowledged") self._ack = True self.async_write_ha_state() diff --git a/tests/components/alert/test_init.py b/tests/components/alert/test_init.py index 27997a093e5..4407775a582 100644 --- a/tests/components/alert/test_init.py +++ b/tests/components/alert/test_init.py @@ -28,6 +28,7 @@ from homeassistant.const import ( STATE_ON, ) from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.exceptions import ServiceValidationError from homeassistant.setup import async_setup_component from tests.common import MockEntityPlatform, async_mock_service @@ -116,6 +117,35 @@ async def test_silence(hass: HomeAssistant, mock_notifier: list[ServiceCall]) -> assert hass.states.get(ENTITY_ID).state == STATE_ON +async def test_silence_can_acknowledge_false(hass: HomeAssistant) -> None: + """Test that attempting to silence an alert with can_acknowledge=False will not silence.""" + # Create copy of config where can_acknowledge is False + config = deepcopy(TEST_CONFIG) + config[DOMAIN][NAME]["can_acknowledge"] = False + + # Setup the alert component + assert await async_setup_component(hass, DOMAIN, config) + await hass.async_block_till_done() + + # Ensure the alert is currently on + hass.states.async_set(ENTITY_ID, STATE_ON) + await hass.async_block_till_done() + assert hass.states.get(ENTITY_ID).state == STATE_ON + + # Attempt to acknowledge + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + await hass.async_block_till_done() + + # The state should still be ON because can_acknowledge=False + assert hass.states.get(ENTITY_ID).state == STATE_ON + + async def test_reset(hass: HomeAssistant, mock_notifier: list[ServiceCall]) -> None: """Test resetting the alert.""" assert await async_setup_component(hass, DOMAIN, TEST_CONFIG) From 6f0c62dc9d6e8a51cc4f656ec8bb275403e79eb5 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 28 Feb 2025 21:06:45 +0100 Subject: [PATCH 2068/3148] Bump pysmartthings to 2.2.0 (#139539) --- homeassistant/components/smartthings/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/smartthings/manifest.json b/homeassistant/components/smartthings/manifest.json index 5dd570f2751..0ca6c1f3b26 100644 --- a/homeassistant/components/smartthings/manifest.json +++ b/homeassistant/components/smartthings/manifest.json @@ -29,5 +29,5 @@ "documentation": "https://www.home-assistant.io/integrations/smartthings", "iot_class": "cloud_push", "loggers": ["pysmartthings"], - "requirements": ["pysmartthings==2.1.0"] + "requirements": ["pysmartthings==2.2.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 3a4f70ec97e..550f1d6e650 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2310,7 +2310,7 @@ pysma==0.7.5 pysmappee==0.2.29 # homeassistant.components.smartthings -pysmartthings==2.1.0 +pysmartthings==2.2.0 # homeassistant.components.smarty pysmarty2==0.10.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 458119c43bb..804780d6717 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1882,7 +1882,7 @@ pysma==0.7.5 pysmappee==0.2.29 # homeassistant.components.smartthings -pysmartthings==2.1.0 +pysmartthings==2.2.0 # homeassistant.components.smarty pysmarty2==0.10.2 From f54b3f4de2f1ea23d21f31c75fbe35c4873220c4 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 28 Feb 2025 20:33:25 +0100 Subject: [PATCH 2069/3148] Remove orphan devices on startup in SmartThings (#139541) --- .../components/smartthings/__init__.py | 17 ++++++++++++++- tests/components/smartthings/test_init.py | 21 +++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py index 4bc9b270360..d6de1d3d252 100644 --- a/homeassistant/components/smartthings/__init__.py +++ b/homeassistant/components/smartthings/__init__.py @@ -21,13 +21,14 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.config_entry_oauth2_flow import ( OAuth2Session, async_get_config_entry_implementation, ) -from .const import CONF_INSTALLED_APP_ID, CONF_LOCATION_ID, MAIN, OLD_DATA +from .const import CONF_INSTALLED_APP_ID, CONF_LOCATION_ID, DOMAIN, MAIN, OLD_DATA _LOGGER = logging.getLogger(__name__) @@ -123,6 +124,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: SmartThingsConfigEntry) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + device_registry = dr.async_get(hass) + device_entries = dr.async_entries_for_config_entry(device_registry, entry.entry_id) + for device_entry in device_entries: + device_id = next( + identifier[1] + for identifier in device_entry.identifiers + if identifier[0] == DOMAIN + ) + if device_id in entry.runtime_data.devices: + continue + device_registry.async_update_device( + device_entry.id, remove_config_entry_id=entry.entry_id + ) + return True diff --git a/tests/components/smartthings/test_init.py b/tests/components/smartthings/test_init.py index be88f11903e..372f23eec42 100644 --- a/tests/components/smartthings/test_init.py +++ b/tests/components/smartthings/test_init.py @@ -2,6 +2,7 @@ from unittest.mock import AsyncMock +import pytest from syrupy import SnapshotAssertion from homeassistant.components.smartthings.const import DOMAIN @@ -29,3 +30,23 @@ async def test_devices( assert device is not None assert device == snapshot + + +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) +async def test_removing_stale_devices( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test removing stale devices.""" + mock_config_entry.add_to_hass(hass) + device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={(DOMAIN, "aaa-bbb-ccc")}, + ) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert not device_registry.async_get_device({(DOMAIN, "aaa-bbb-ccc")}) From 5ad156767a4ebc330fa35f3de3ae70d10120d7df Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 28 Feb 2025 21:42:33 +0000 Subject: [PATCH 2070/3148] Bump PySwitchBot to 0.56.1 (#139544) changelog: https://github.com/sblibs/pySwitchbot/compare/0.56.0...0.56.1 --- homeassistant/components/switchbot/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/manifest.json b/homeassistant/components/switchbot/manifest.json index 92a1c25d6f5..567a33a8f43 100644 --- a/homeassistant/components/switchbot/manifest.json +++ b/homeassistant/components/switchbot/manifest.json @@ -39,5 +39,5 @@ "documentation": "https://www.home-assistant.io/integrations/switchbot", "iot_class": "local_push", "loggers": ["switchbot"], - "requirements": ["PySwitchbot==0.56.0"] + "requirements": ["PySwitchbot==0.56.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 550f1d6e650..826e3252b87 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -84,7 +84,7 @@ PyQRCode==1.2.1 PyRMVtransport==0.3.3 # homeassistant.components.switchbot -PySwitchbot==0.56.0 +PySwitchbot==0.56.1 # homeassistant.components.switchmate PySwitchmate==0.5.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 804780d6717..828d1a44244 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -81,7 +81,7 @@ PyQRCode==1.2.1 PyRMVtransport==0.3.3 # homeassistant.components.switchbot -PySwitchbot==0.56.0 +PySwitchbot==0.56.1 # homeassistant.components.syncthru PySyncThru==0.8.0 From 8cc587d3a72d6c497c73b5d68881862e1de1e71f Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 28 Feb 2025 23:02:06 +0100 Subject: [PATCH 2071/3148] Bump pysmartthings to 2.3.0 (#139546) --- homeassistant/components/smartthings/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/smartthings/manifest.json b/homeassistant/components/smartthings/manifest.json index 0ca6c1f3b26..9fa6d28fa0a 100644 --- a/homeassistant/components/smartthings/manifest.json +++ b/homeassistant/components/smartthings/manifest.json @@ -29,5 +29,5 @@ "documentation": "https://www.home-assistant.io/integrations/smartthings", "iot_class": "cloud_push", "loggers": ["pysmartthings"], - "requirements": ["pysmartthings==2.2.0"] + "requirements": ["pysmartthings==2.3.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 826e3252b87..de6aa612528 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2310,7 +2310,7 @@ pysma==0.7.5 pysmappee==0.2.29 # homeassistant.components.smartthings -pysmartthings==2.2.0 +pysmartthings==2.3.0 # homeassistant.components.smarty pysmarty2==0.10.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 828d1a44244..fbb338f7fb7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1882,7 +1882,7 @@ pysma==0.7.5 pysmappee==0.2.29 # homeassistant.components.smartthings -pysmartthings==2.2.0 +pysmartthings==2.3.0 # homeassistant.components.smarty pysmarty2==0.10.2 From c7d89398a0ae27a84bc8d9ad200dc0ded673c492 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 28 Feb 2025 22:30:57 +0100 Subject: [PATCH 2072/3148] Improve SmartThings OCF device info (#139547) --- homeassistant/components/smartthings/entity.py | 18 ++++++------------ .../smartthings/snapshots/test_init.ambr | 16 ++++++++-------- 2 files changed, 14 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/smartthings/entity.py b/homeassistant/components/smartthings/entity.py index 1383196ce15..0d6ee32b473 100644 --- a/homeassistant/components/smartthings/entity.py +++ b/homeassistant/components/smartthings/entity.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import Any, cast +from typing import Any from pysmartthings import ( Attribute, @@ -44,19 +44,13 @@ class SmartThingsEntity(Entity): identifiers={(DOMAIN, device.device.device_id)}, name=device.device.label, ) - if (ocf := device.status[MAIN].get(Capability.OCF)) is not None: + if (ocf := device.device.ocf) is not None: self._attr_device_info.update( { - "manufacturer": cast( - str | None, ocf[Attribute.MANUFACTURER_NAME].value - ), - "model": cast(str | None, ocf[Attribute.MODEL_NUMBER].value), - "hw_version": cast( - str | None, ocf[Attribute.HARDWARE_VERSION].value - ), - "sw_version": cast( - str | None, ocf[Attribute.OCF_FIRMWARE_VERSION].value - ), + "manufacturer": ocf.manufacturer_name, + "model": ocf.model_number.split("|")[0], + "hw_version": ocf.hardware_version, + "sw_version": ocf.firmware_version, } ) diff --git a/tests/components/smartthings/snapshots/test_init.ambr b/tests/components/smartthings/snapshots/test_init.ambr index 546d99a967f..0b5aeb57c18 100644 --- a/tests/components/smartthings/snapshots/test_init.ambr +++ b/tests/components/smartthings/snapshots/test_init.ambr @@ -219,7 +219,7 @@ 'labels': set({ }), 'manufacturer': 'Samsung Electronics', - 'model': 'ARTIK051_KRAC_18K|10193441|60010132001111110200000000000000', + 'model': 'ARTIK051_KRAC_18K', 'model_id': None, 'name': 'AC Office Granit', 'name_by_user': None, @@ -252,7 +252,7 @@ 'labels': set({ }), 'manufacturer': 'Samsung Electronics', - 'model': 'ARA-WW-TP1-22-COMMON|10229641|60010523001511014600083200800000', + 'model': 'ARA-WW-TP1-22-COMMON', 'model_id': None, 'name': 'Aire Dormitorio Principal', 'name_by_user': None, @@ -285,7 +285,7 @@ 'labels': set({ }), 'manufacturer': 'Samsung Electronics', - 'model': 'TP2X_DA-KS-MICROWAVE-0101X|40436241|50040100011411000200000000000000', + 'model': 'TP2X_DA-KS-MICROWAVE-0101X', 'model_id': None, 'name': 'Microwave', 'name_by_user': None, @@ -318,7 +318,7 @@ 'labels': set({ }), 'manufacturer': 'Samsung Electronics', - 'model': 'TP2X_REF_20K|00115641|0004014D011411200103000020000000', + 'model': 'TP2X_REF_20K', 'model_id': None, 'name': 'Refrigerator', 'name_by_user': None, @@ -351,7 +351,7 @@ 'labels': set({ }), 'manufacturer': 'Samsung Electronics', - 'model': 'powerbot_7000_17M|50016055|80010404011141000100000000000000', + 'model': 'powerbot_7000_17M', 'model_id': None, 'name': 'Robot vacuum', 'name_by_user': None, @@ -384,7 +384,7 @@ 'labels': set({ }), 'manufacturer': 'Samsung Electronics', - 'model': 'DA_DW_A51_20_COMMON|30007242|40010201001311000101000000000000', + 'model': 'DA_DW_A51_20_COMMON', 'model_id': None, 'name': 'Dishwasher', 'name_by_user': None, @@ -417,7 +417,7 @@ 'labels': set({ }), 'manufacturer': 'Samsung Electronics', - 'model': 'DA_WM_A51_20_COMMON|20233741|3000000100111100020B000000000000', + 'model': 'DA_WM_A51_20_COMMON', 'model_id': None, 'name': 'Dryer', 'name_by_user': None, @@ -450,7 +450,7 @@ 'labels': set({ }), 'manufacturer': 'Samsung Electronics', - 'model': 'DA_WM_TP2_20_COMMON|20233741|2001000100131100022B010000000000', + 'model': 'DA_WM_TP2_20_COMMON', 'model_id': None, 'name': 'Washer', 'name_by_user': None, From 0323a9c4e6f5ee0bb5cb8b33432d2f5eb739ce9e Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 28 Feb 2025 23:03:57 +0100 Subject: [PATCH 2073/3148] Add SmartThings Viper device info (#139548) --- .../components/smartthings/entity.py | 9 ++++ .../smartthings/snapshots/test_init.ambr | 50 +++++++++---------- 2 files changed, 34 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/smartthings/entity.py b/homeassistant/components/smartthings/entity.py index 0d6ee32b473..f86f3a68f0e 100644 --- a/homeassistant/components/smartthings/entity.py +++ b/homeassistant/components/smartthings/entity.py @@ -53,6 +53,15 @@ class SmartThingsEntity(Entity): "sw_version": ocf.firmware_version, } ) + if (viper := device.device.viper) is not None: + self._attr_device_info.update( + { + "manufacturer": viper.manufacturer_name, + "model": viper.model_name, + "hw_version": viper.hardware_version, + "sw_version": viper.software_version, + } + ) async def async_added_to_hass(self) -> None: """Subscribe to updates.""" diff --git a/tests/components/smartthings/snapshots/test_init.ambr b/tests/components/smartthings/snapshots/test_init.ambr index 0b5aeb57c18..e0d93553121 100644 --- a/tests/components/smartthings/snapshots/test_init.ambr +++ b/tests/components/smartthings/snapshots/test_init.ambr @@ -86,8 +86,8 @@ 'is_new': False, 'labels': set({ }), - 'manufacturer': None, - 'model': None, + 'manufacturer': 'Arlo', + 'model': 'VMC4041PB', 'model_id': None, 'name': '2nd Floor Hallway', 'name_by_user': None, @@ -108,7 +108,7 @@ }), 'disabled_by': None, 'entry_type': None, - 'hw_version': None, + 'hw_version': 'WoCurtain3-WoCurtain3', 'id': , 'identifiers': set({ tuple( @@ -119,8 +119,8 @@ 'is_new': False, 'labels': set({ }), - 'manufacturer': None, - 'model': None, + 'manufacturer': 'WonderLabs Company', + 'model': 'WoCurtain3', 'model_id': None, 'name': 'Curtain 1A', 'name_by_user': None, @@ -471,7 +471,7 @@ }), 'disabled_by': None, 'entry_type': None, - 'hw_version': None, + 'hw_version': '250206213001', 'id': , 'identifiers': set({ tuple( @@ -482,15 +482,15 @@ 'is_new': False, 'labels': set({ }), - 'manufacturer': None, - 'model': None, + 'manufacturer': 'ecobee', + 'model': 'aresSmart-ecobee3_remote_sensor', 'model_id': None, 'name': 'Child Bedroom', 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, 'suggested_area': None, - 'sw_version': None, + 'sw_version': '250206213001', 'via_device_id': None, }) # --- @@ -504,7 +504,7 @@ }), 'disabled_by': None, 'entry_type': None, - 'hw_version': None, + 'hw_version': '250206151734', 'id': , 'identifiers': set({ tuple( @@ -515,15 +515,15 @@ 'is_new': False, 'labels': set({ }), - 'manufacturer': None, - 'model': None, + 'manufacturer': 'ecobee', + 'model': 'aresSmart-thermostat', 'model_id': None, 'name': 'Main Floor', 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, 'suggested_area': None, - 'sw_version': None, + 'sw_version': '250206151734', 'via_device_id': None, }) # --- @@ -603,7 +603,7 @@ }), 'disabled_by': None, 'entry_type': None, - 'hw_version': None, + 'hw_version': 'LTG002', 'id': , 'identifiers': set({ tuple( @@ -614,15 +614,15 @@ 'is_new': False, 'labels': set({ }), - 'manufacturer': None, - 'model': None, + 'manufacturer': 'Signify Netherlands B.V.', + 'model': 'Hue ambiance spot', 'model_id': None, 'name': 'Bathroom spot', 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, 'suggested_area': None, - 'sw_version': None, + 'sw_version': '1.122.2', 'via_device_id': None, }) # --- @@ -636,7 +636,7 @@ }), 'disabled_by': None, 'entry_type': None, - 'hw_version': None, + 'hw_version': 'LCA001', 'id': , 'identifiers': set({ tuple( @@ -647,15 +647,15 @@ 'is_new': False, 'labels': set({ }), - 'manufacturer': None, - 'model': None, + 'manufacturer': 'Signify Netherlands B.V.', + 'model': 'Hue color lamp', 'model_id': None, 'name': 'Standing light', 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, 'suggested_area': None, - 'sw_version': None, + 'sw_version': '1.122.2', 'via_device_id': None, }) # --- @@ -735,7 +735,7 @@ }), 'disabled_by': None, 'entry_type': None, - 'hw_version': None, + 'hw_version': 'SKY40147', 'id': , 'identifiers': set({ tuple( @@ -746,15 +746,15 @@ 'is_new': False, 'labels': set({ }), - 'manufacturer': None, - 'model': None, + 'manufacturer': 'Sensibo', + 'model': 'skyplus', 'model_id': None, 'name': 'Office', 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, 'suggested_area': None, - 'sw_version': None, + 'sw_version': 'SKY40147', 'via_device_id': None, }) # --- From e1ce5b8c69543edfddc0d3bde814e15ac227753c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 28 Feb 2025 22:25:50 +0000 Subject: [PATCH 2074/3148] Revert polling changes to HomeKit Controller (#139550) This reverts #116200 We changed the polling logic to avoid polling if all chars are marked as watchable to avoid crashing the firmware on a very limited set of devices as it was more in line with what iOS does. In the end, the user ended up replacing the device in #116143 because it turned out to be unreliable in other ways. The vendor has since issued a firmware update that may resolve the problem with all of these devices. In practice it turns out many more devices report that chars are evented and never send events. After a few months of data and reports the trade-off does not seem worth it since users are having to set up manual polling on a wide range of devices. The amount of devices with evented chars that do not actually send state vastly exceeds the number of devices that might crash if they are polled too often so restore the previous behavior fixes #138561 fixes #100331 fixes #124529 fixes #123456 fixes #130763 fixes #124099 fixes #124916 fixes #135434 fixes #125273 fixes #124099 fixes #119617 --- .../homekit_controller/connection.py | 38 ------------------- .../homekit_controller/test_connection.py | 10 ++--- 2 files changed, 5 insertions(+), 43 deletions(-) diff --git a/homeassistant/components/homekit_controller/connection.py b/homeassistant/components/homekit_controller/connection.py index 211aec2c2d5..43cbdec67fa 100644 --- a/homeassistant/components/homekit_controller/connection.py +++ b/homeassistant/components/homekit_controller/connection.py @@ -154,7 +154,6 @@ class HKDevice: self._pending_subscribes: set[tuple[int, int]] = set() self._subscribe_timer: CALLBACK_TYPE | None = None self._load_platforms_lock = asyncio.Lock() - self._full_update_requested: bool = False @property def entity_map(self) -> Accessories: @@ -841,48 +840,11 @@ class HKDevice: async def async_request_update(self, now: datetime | None = None) -> None: """Request an debounced update from the accessory.""" - self._full_update_requested = True await self._debounced_update.async_call() async def async_update(self, now: datetime | None = None) -> None: """Poll state of all entities attached to this bridge/accessory.""" to_poll = self.pollable_characteristics - accessories = self.entity_map.accessories - - if ( - not self._full_update_requested - and len(accessories) == 1 - and self.available - and not (to_poll - self.watchable_characteristics) - and self.pairing.is_available - and await self.pairing.controller.async_reachable( - self.unique_id, timeout=5.0 - ) - ): - # If its a single accessory and all chars are watchable, - # only poll the firmware version to keep the connection alive - # https://github.com/home-assistant/core/issues/123412 - # - # Firmware revision is used here since iOS does this to keep camera - # connections alive, and the goal is to not regress - # https://github.com/home-assistant/core/issues/116143 - # by polling characteristics that are not normally polled frequently - # and may not be tested by the device vendor. - # - _LOGGER.debug( - "Accessory is reachable, limiting poll to firmware version: %s", - self.unique_id, - ) - first_accessory = accessories[0] - accessory_info = first_accessory.services.first( - service_type=ServicesTypes.ACCESSORY_INFORMATION - ) - assert accessory_info is not None - firmware_iid = accessory_info[CharacteristicsTypes.FIRMWARE_REVISION].iid - to_poll = {(first_accessory.aid, firmware_iid)} - - self._full_update_requested = False - if not to_poll: self.async_update_available_state() _LOGGER.debug( diff --git a/tests/components/homekit_controller/test_connection.py b/tests/components/homekit_controller/test_connection.py index 7ea791f9a1e..00c7bb16259 100644 --- a/tests/components/homekit_controller/test_connection.py +++ b/tests/components/homekit_controller/test_connection.py @@ -375,9 +375,9 @@ async def test_poll_firmware_version_only_all_watchable_accessory_mode( state = await helper.poll_and_get_state() assert state.state == STATE_OFF assert mock_get_characteristics.call_count == 2 - # Verify only firmware version is polled - assert mock_get_characteristics.call_args_list[0][0][0] == {(1, 7)} - assert mock_get_characteristics.call_args_list[1][0][0] == {(1, 7)} + # Verify everything is polled + assert mock_get_characteristics.call_args_list[0][0][0] == {(1, 10), (1, 11)} + assert mock_get_characteristics.call_args_list[1][0][0] == {(1, 10), (1, 11)} # Test device goes offline helper.pairing.available = False @@ -429,8 +429,8 @@ async def test_manual_poll_all_chars( ) as mock_get_characteristics: # Initial state is that the light is off await helper.poll_and_get_state() - # Verify only firmware version is polled - assert mock_get_characteristics.call_args_list[0][0][0] == {(1, 7)} + # Verify poll polls all chars + assert len(mock_get_characteristics.call_args_list[0][0][0]) > 1 # Now do a manual poll to ensure all chars are polled mock_get_characteristics.reset_mock() From 21277a81d3a973426be3af68ca89c146f563f8f7 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 1 Mar 2025 12:06:16 +0100 Subject: [PATCH 2075/3148] Bump pysmartthings to 2.4.0 (#139564) --- homeassistant/components/smartthings/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/smartthings/manifest.json b/homeassistant/components/smartthings/manifest.json index 9fa6d28fa0a..e0cf6739290 100644 --- a/homeassistant/components/smartthings/manifest.json +++ b/homeassistant/components/smartthings/manifest.json @@ -29,5 +29,5 @@ "documentation": "https://www.home-assistant.io/integrations/smartthings", "iot_class": "cloud_push", "loggers": ["pysmartthings"], - "requirements": ["pysmartthings==2.3.0"] + "requirements": ["pysmartthings==2.4.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index de6aa612528..28395fa3e79 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2310,7 +2310,7 @@ pysma==0.7.5 pysmappee==0.2.29 # homeassistant.components.smartthings -pysmartthings==2.3.0 +pysmartthings==2.4.0 # homeassistant.components.smarty pysmarty2==0.10.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fbb338f7fb7..c33cf54af48 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1882,7 +1882,7 @@ pysma==0.7.5 pysmappee==0.2.29 # homeassistant.components.smartthings -pysmartthings==2.3.0 +pysmartthings==2.4.0 # homeassistant.components.smarty pysmarty2==0.10.2 From f56d65b2ec3428c522eca53b7c8adaf0af649e7d Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Sun, 2 Mar 2025 08:58:15 +1000 Subject: [PATCH 2076/3148] Bump Tesla Fleet API to v0.9.12 (#139565) * bump * Update manifest.json * Fix versions * remove tesla_bluetooth * Remove mistake --- homeassistant/components/tesla_fleet/manifest.json | 2 +- homeassistant/components/teslemetry/manifest.json | 2 +- homeassistant/components/tessie/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/tesla_fleet/manifest.json b/homeassistant/components/tesla_fleet/manifest.json index bb8f6041771..53aff3d0a54 100644 --- a/homeassistant/components/tesla_fleet/manifest.json +++ b/homeassistant/components/tesla_fleet/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/tesla_fleet", "iot_class": "cloud_polling", "loggers": ["tesla-fleet-api"], - "requirements": ["tesla-fleet-api==0.9.10"] + "requirements": ["tesla-fleet-api==0.9.12"] } diff --git a/homeassistant/components/teslemetry/manifest.json b/homeassistant/components/teslemetry/manifest.json index dfe6d7cb3f9..4e9228acd2f 100644 --- a/homeassistant/components/teslemetry/manifest.json +++ b/homeassistant/components/teslemetry/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/teslemetry", "iot_class": "cloud_polling", "loggers": ["tesla-fleet-api"], - "requirements": ["tesla-fleet-api==0.9.10", "teslemetry-stream==0.6.10"] + "requirements": ["tesla-fleet-api==0.9.12", "teslemetry-stream==0.6.10"] } diff --git a/homeassistant/components/tessie/manifest.json b/homeassistant/components/tessie/manifest.json index d777cf5051e..d4ac56883e8 100644 --- a/homeassistant/components/tessie/manifest.json +++ b/homeassistant/components/tessie/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/tessie", "iot_class": "cloud_polling", "loggers": ["tessie", "tesla-fleet-api"], - "requirements": ["tessie-api==0.1.1", "tesla-fleet-api==0.9.10"] + "requirements": ["tessie-api==0.1.1", "tesla-fleet-api==0.9.12"] } diff --git a/requirements_all.txt b/requirements_all.txt index 28395fa3e79..bd1f37d9714 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2872,7 +2872,7 @@ temperusb==1.6.1 # homeassistant.components.tesla_fleet # homeassistant.components.teslemetry # homeassistant.components.tessie -tesla-fleet-api==0.9.10 +tesla-fleet-api==0.9.12 # homeassistant.components.powerwall tesla-powerwall==0.5.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c33cf54af48..258b5b26a27 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2312,7 +2312,7 @@ temperusb==1.6.1 # homeassistant.components.tesla_fleet # homeassistant.components.teslemetry # homeassistant.components.tessie -tesla-fleet-api==0.9.10 +tesla-fleet-api==0.9.12 # homeassistant.components.powerwall tesla-powerwall==0.5.2 From 1530139a6191d16231d2ca479f808e3452e93e32 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Sat, 1 Mar 2025 12:37:44 +0100 Subject: [PATCH 2077/3148] Bump aiowebdav2 to 0.3.1 (#139567) --- homeassistant/components/webdav/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/webdav/manifest.json b/homeassistant/components/webdav/manifest.json index 75a8d7ddfe2..b4950bc23f3 100644 --- a/homeassistant/components/webdav/manifest.json +++ b/homeassistant/components/webdav/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["aiowebdav2"], "quality_scale": "bronze", - "requirements": ["aiowebdav2==0.3.0"] + "requirements": ["aiowebdav2==0.3.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index bd1f37d9714..bfb89dbd8d7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -422,7 +422,7 @@ aiowaqi==3.1.0 aiowatttime==0.1.1 # homeassistant.components.webdav -aiowebdav2==0.3.0 +aiowebdav2==0.3.1 # homeassistant.components.webostv aiowebostv==0.7.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 258b5b26a27..c2c3c99a64c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -404,7 +404,7 @@ aiowaqi==3.1.0 aiowatttime==0.1.1 # homeassistant.components.webdav -aiowebdav2==0.3.0 +aiowebdav2==0.3.1 # homeassistant.components.webostv aiowebostv==0.7.1 From f17274d4179ba7ccd4da39f41826e1c300543bb3 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 1 Mar 2025 12:47:58 +0100 Subject: [PATCH 2078/3148] Validate scopes in SmartThings config flow (#139569) --- .../components/smartthings/config_flow.py | 2 + .../components/smartthings/strings.json | 3 +- .../smartthings/test_config_flow.py | 111 ++++++++++++++++++ 3 files changed, 115 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/smartthings/config_flow.py b/homeassistant/components/smartthings/config_flow.py index bcd2ddc192b..b39fe662124 100644 --- a/homeassistant/components/smartthings/config_flow.py +++ b/homeassistant/components/smartthings/config_flow.py @@ -34,6 +34,8 @@ class SmartThingsConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN): async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResult: """Create an entry for SmartThings.""" + if data[CONF_TOKEN]["scope"].split() != SCOPES: + return self.async_abort(reason="missing_scopes") client = SmartThings(session=async_get_clientsession(self.hass)) client.authenticate(data[CONF_TOKEN][CONF_ACCESS_TOKEN]) locations = await client.get_locations() diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index e5ffbe35e8b..9fd417284af 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -23,7 +23,8 @@ "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "reauth_account_mismatch": "Authenticated account does not match the account to be reauthenticated. Please log in with the correct account and pick the right location.", - "reauth_location_mismatch": "Authenticated location does not match the location to be reauthenticated. Please log in with the correct account and pick the right location." + "reauth_location_mismatch": "Authenticated location does not match the location to be reauthenticated. Please log in with the correct account and pick the right location.", + "missing_scopes": "Authentication failed. Please make sure you have granted all required permissions." } }, "entity": { diff --git a/tests/components/smartthings/test_config_flow.py b/tests/components/smartthings/test_config_flow.py index 647e0ea5284..61e2b464920 100644 --- a/tests/components/smartthings/test_config_flow.py +++ b/tests/components/smartthings/test_config_flow.py @@ -101,6 +101,66 @@ async def test_full_flow( assert result["result"].unique_id == "397678e5-9995-4a39-9d9f-ae6ba310236c" +@pytest.mark.usefixtures("current_request_with_host") +async def test_not_enough_scopes( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_smartthings: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test we abort if we don't have enough scopes.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + assert result["type"] is FlowResultType.EXTERNAL_STEP + assert result["url"] == ( + "https://api.smartthings.com/oauth/authorize" + "?response_type=code&client_id=CLIENT_ID" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}" + "&scope=r:devices:*+w:devices:*+x:devices:*+r:hubs:*+" + "r:locations:*+w:locations:*+x:locations:*+r:scenes:*+" + "x:scenes:*+r:rules:*+w:rules:*+r:installedapps+w:installedapps+sse" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == HTTPStatus.OK + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.clear_requests() + aioclient_mock.post( + "https://auth-global.api.smartthings.com/oauth/token", + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "token_type": "Bearer", + "expires_in": 82806, + "scope": "r:devices:* w:devices:* x:devices:* r:hubs:* " + "r:locations:* w:locations:* x:locations:* " + "r:scenes:* x:scenes:* r:rules:* w:rules:* " + "r:installedapps w:installedapps", + "access_tier": 0, + "installed_app_id": "5aaaa925-2be1-4e40-b257-e4ef59083324", + }, + ) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "missing_scopes" + + @pytest.mark.usefixtures("current_request_with_host") async def test_duplicate_entry( hass: HomeAssistant, @@ -227,6 +287,57 @@ async def test_reauthentication( } +@pytest.mark.usefixtures("current_request_with_host") +async def test_reauthentication_wrong_scopes( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_smartthings: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test SmartThings reauthentication with wrong scopes.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reauth_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + client = await hass_client_no_auth() + await client.get(f"/auth/external/callback?code=abcd&state={state}") + + aioclient_mock.post( + "https://auth-global.api.smartthings.com/oauth/token", + json={ + "refresh_token": "new-refresh-token", + "access_token": "new-access-token", + "token_type": "Bearer", + "expires_in": 82806, + "scope": "r:devices:* w:devices:* x:devices:* r:hubs:* " + "r:locations:* w:locations:* x:locations:* " + "r:scenes:* x:scenes:* r:rules:* w:rules:* " + "r:installedapps w:installedapps", + "access_tier": 0, + "installed_app_id": "5aaaa925-2be1-4e40-b257-e4ef59083324", + }, + ) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "missing_scopes" + + @pytest.mark.usefixtures("current_request_with_host") async def test_reauth_account_mismatch( hass: HomeAssistant, From a718b6ebff7fcee48f719ecc1e89f1476bbfada5 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 1 Mar 2025 13:08:28 +0100 Subject: [PATCH 2079/3148] Only determine SmartThings swing modes if we support it (#139571) Only determine swing modes if we support it --- homeassistant/components/smartthings/climate.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/smartthings/climate.py b/homeassistant/components/smartthings/climate.py index 2c3b8f3ac03..531b431f913 100644 --- a/homeassistant/components/smartthings/climate.py +++ b/homeassistant/components/smartthings/climate.py @@ -345,7 +345,8 @@ class SmartThingsAirConditioner(SmartThingsEntity, ClimateEntity): ) self._attr_hvac_modes = self._determine_hvac_modes() self._attr_preset_modes = self._determine_preset_modes() - self._attr_swing_modes = self._determine_swing_modes() + if self.supports_capability(Capability.FAN_OSCILLATION_MODE): + self._attr_swing_modes = self._determine_swing_modes() self._attr_supported_features = self._determine_supported_features() def _determine_supported_features(self) -> ClimateEntityFeature: From 684c3aac6bffe93d56c58cdeec1d5905c69a82c0 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 1 Mar 2025 20:47:42 +0100 Subject: [PATCH 2080/3148] Don't require not needed scopes in SmartThings (#139576) * Don't require not needed scopes * Don't require not needed scopes --- homeassistant/components/smartthings/const.py | 2 -- .../smartthings/test_config_flow.py | 33 +++++++------------ 2 files changed, 12 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/smartthings/const.py b/homeassistant/components/smartthings/const.py index c39d225dd09..80c4cf90226 100644 --- a/homeassistant/components/smartthings/const.py +++ b/homeassistant/components/smartthings/const.py @@ -14,8 +14,6 @@ SCOPES = [ "x:scenes:*", "r:rules:*", "w:rules:*", - "r:installedapps", - "w:installedapps", "sse", ] diff --git a/tests/components/smartthings/test_config_flow.py b/tests/components/smartthings/test_config_flow.py index 61e2b464920..2fbd686e4d3 100644 --- a/tests/components/smartthings/test_config_flow.py +++ b/tests/components/smartthings/test_config_flow.py @@ -57,7 +57,7 @@ async def test_full_flow( f"&state={state}" "&scope=r:devices:*+w:devices:*+x:devices:*+r:hubs:*+" "r:locations:*+w:locations:*+x:locations:*+r:scenes:*+" - "x:scenes:*+r:rules:*+w:rules:*+r:installedapps+w:installedapps+sse" + "x:scenes:*+r:rules:*+w:rules:*+sse" ) client = await hass_client_no_auth() @@ -75,8 +75,7 @@ async def test_full_flow( "expires_in": 82806, "scope": "r:devices:* w:devices:* x:devices:* r:hubs:* " "r:locations:* w:locations:* x:locations:* " - "r:scenes:* x:scenes:* r:rules:* w:rules:* " - "r:installedapps w:installedapps sse", + "r:scenes:* x:scenes:* r:rules:* w:rules:* sse", "access_tier": 0, "installed_app_id": "5aaaa925-2be1-4e40-b257-e4ef59083324", }, @@ -93,8 +92,7 @@ async def test_full_flow( "expires_in": 82806, "scope": "r:devices:* w:devices:* x:devices:* r:hubs:* " "r:locations:* w:locations:* x:locations:* " - "r:scenes:* x:scenes:* r:rules:* w:rules:* " - "r:installedapps w:installedapps sse", + "r:scenes:* x:scenes:* r:rules:* w:rules:* sse", "access_tier": 0, "installed_app_id": "5aaaa925-2be1-4e40-b257-e4ef59083324", } @@ -130,7 +128,7 @@ async def test_not_enough_scopes( f"&state={state}" "&scope=r:devices:*+w:devices:*+x:devices:*+r:hubs:*+" "r:locations:*+w:locations:*+x:locations:*+r:scenes:*+" - "x:scenes:*+r:rules:*+w:rules:*+r:installedapps+w:installedapps+sse" + "x:scenes:*+r:rules:*+w:rules:*+sse" ) client = await hass_client_no_auth() @@ -192,7 +190,7 @@ async def test_duplicate_entry( f"&state={state}" "&scope=r:devices:*+w:devices:*+x:devices:*+r:hubs:*+" "r:locations:*+w:locations:*+x:locations:*+r:scenes:*+" - "x:scenes:*+r:rules:*+w:rules:*+r:installedapps+w:installedapps+sse" + "x:scenes:*+r:rules:*+w:rules:*+sse" ) client = await hass_client_no_auth() @@ -210,8 +208,7 @@ async def test_duplicate_entry( "expires_in": 82806, "scope": "r:devices:* w:devices:* x:devices:* r:hubs:* " "r:locations:* w:locations:* x:locations:* " - "r:scenes:* x:scenes:* r:rules:* w:rules:* " - "r:installedapps w:installedapps sse", + "r:scenes:* x:scenes:* r:rules:* w:rules:* sse", "access_tier": 0, "installed_app_id": "5aaaa925-2be1-4e40-b257-e4ef59083324", }, @@ -261,8 +258,7 @@ async def test_reauthentication( "expires_in": 82806, "scope": "r:devices:* w:devices:* x:devices:* r:hubs:* " "r:locations:* w:locations:* x:locations:* " - "r:scenes:* x:scenes:* r:rules:* w:rules:* " - "r:installedapps w:installedapps sse", + "r:scenes:* x:scenes:* r:rules:* w:rules:* sse", "access_tier": 0, "installed_app_id": "5aaaa925-2be1-4e40-b257-e4ef59083324", }, @@ -280,8 +276,7 @@ async def test_reauthentication( "expires_in": 82806, "scope": "r:devices:* w:devices:* x:devices:* r:hubs:* " "r:locations:* w:locations:* x:locations:* " - "r:scenes:* x:scenes:* r:rules:* w:rules:* " - "r:installedapps w:installedapps sse", + "r:scenes:* x:scenes:* r:rules:* w:rules:* sse", "access_tier": 0, "installed_app_id": "5aaaa925-2be1-4e40-b257-e4ef59083324", } @@ -377,8 +372,7 @@ async def test_reauth_account_mismatch( "expires_in": 82806, "scope": "r:devices:* w:devices:* x:devices:* r:hubs:* " "r:locations:* w:locations:* x:locations:* " - "r:scenes:* x:scenes:* r:rules:* w:rules:* " - "r:installedapps w:installedapps sse", + "r:scenes:* x:scenes:* r:rules:* w:rules:* sse", "access_tier": 0, "installed_app_id": "123123123-2be1-4e40-b257-e4ef59083324", }, @@ -429,8 +423,7 @@ async def test_migration( "expires_in": 82806, "scope": "r:devices:* w:devices:* x:devices:* r:hubs:* " "r:locations:* w:locations:* x:locations:* " - "r:scenes:* x:scenes:* r:rules:* w:rules:* " - "r:installedapps w:installedapps sse", + "r:scenes:* x:scenes:* r:rules:* w:rules:* sse", "access_tier": 0, "installed_app_id": "123123123-2be1-4e40-b257-e4ef59083324", }, @@ -461,8 +454,7 @@ async def test_migration( "expires_in": 82806, "scope": "r:devices:* w:devices:* x:devices:* r:hubs:* " "r:locations:* w:locations:* x:locations:* " - "r:scenes:* x:scenes:* r:rules:* w:rules:* " - "r:installedapps w:installedapps sse", + "r:scenes:* x:scenes:* r:rules:* w:rules:* sse", "access_tier": 0, "installed_app_id": "123123123-2be1-4e40-b257-e4ef59083324", }, @@ -516,8 +508,7 @@ async def test_migration_wrong_location( "expires_in": 82806, "scope": "r:devices:* w:devices:* x:devices:* r:hubs:* " "r:locations:* w:locations:* x:locations:* " - "r:scenes:* x:scenes:* r:rules:* w:rules:* " - "r:installedapps w:installedapps sse", + "r:scenes:* x:scenes:* r:rules:* w:rules:* sse", "access_tier": 0, "installed_app_id": "123123123-2be1-4e40-b257-e4ef59083324", }, From 74be49d00d8a5251aa7ea9634176e6aee63d1edf Mon Sep 17 00:00:00 2001 From: Markus Adrario Date: Sat, 1 Mar 2025 20:53:06 +0100 Subject: [PATCH 2081/3148] Homee: fix watchdog icon (#139577) fix watchdog icon --- homeassistant/components/homee/icons.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/homee/icons.json b/homeassistant/components/homee/icons.json index 07ae598095b..17ac0ecd1f2 100644 --- a/homeassistant/components/homee/icons.json +++ b/homeassistant/components/homee/icons.json @@ -9,7 +9,7 @@ } }, "switch": { - "watchdog_on_off": { + "watchdog": { "default": "mdi:dog" }, "manual_operation": { From 511e57d0b3e5b2586d3e728a84bd91226ad04dab Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 1 Mar 2025 16:41:11 +0000 Subject: [PATCH 2082/3148] Bump aiohomekit to 3.2.8 (#139579) changelog: https://github.com/Jc2k/aiohomekit/compare/3.2.7...3.2.8 --- homeassistant/components/homekit_controller/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/homekit_controller/manifest.json b/homeassistant/components/homekit_controller/manifest.json index b7c82b9fd51..98db9a397d3 100644 --- a/homeassistant/components/homekit_controller/manifest.json +++ b/homeassistant/components/homekit_controller/manifest.json @@ -14,6 +14,6 @@ "documentation": "https://www.home-assistant.io/integrations/homekit_controller", "iot_class": "local_push", "loggers": ["aiohomekit", "commentjson"], - "requirements": ["aiohomekit==3.2.7"], + "requirements": ["aiohomekit==3.2.8"], "zeroconf": ["_hap._tcp.local.", "_hap._udp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index bfb89dbd8d7..7031a2e6004 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -267,7 +267,7 @@ aiohasupervisor==0.3.0 aiohomeconnect==0.15.1 # homeassistant.components.homekit_controller -aiohomekit==3.2.7 +aiohomekit==3.2.8 # homeassistant.components.mcp_server aiohttp_sse==2.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c2c3c99a64c..934cf43bf3b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -252,7 +252,7 @@ aiohasupervisor==0.3.0 aiohomeconnect==0.15.1 # homeassistant.components.homekit_controller -aiohomekit==3.2.7 +aiohomekit==3.2.8 # homeassistant.components.mcp_server aiohttp_sse==2.2.0 From e766d681b540bba54dcb7e89e348a717b7d08567 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 2 Mar 2025 00:04:13 +0100 Subject: [PATCH 2083/3148] Fix duplicate unique id issue in Sensibo (#139582) * Fix duplicate unique id issue in Sensibo * Fixes * Mods --- .../components/sensibo/binary_sensor.py | 6 ++--- homeassistant/components/sensibo/button.py | 3 ++- homeassistant/components/sensibo/climate.py | 3 ++- .../components/sensibo/coordinator.py | 25 ++++++++++++++----- homeassistant/components/sensibo/number.py | 3 ++- homeassistant/components/sensibo/select.py | 3 ++- homeassistant/components/sensibo/sensor.py | 5 ++-- homeassistant/components/sensibo/switch.py | 3 ++- homeassistant/components/sensibo/update.py | 3 ++- tests/components/sensibo/test_coordinator.py | 4 +++ 10 files changed, 40 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/sensibo/binary_sensor.py b/homeassistant/components/sensibo/binary_sensor.py index 0d6c47ce46c..c7116db7954 100644 --- a/homeassistant/components/sensibo/binary_sensor.py +++ b/homeassistant/components/sensibo/binary_sensor.py @@ -130,9 +130,10 @@ async def async_setup_entry( """Handle additions of devices and sensors.""" entities: list[SensiboMotionSensor | SensiboDeviceSensor] = [] nonlocal added_devices - new_devices, remove_devices, added_devices = coordinator.get_devices( + new_devices, remove_devices, new_added_devices = coordinator.get_devices( added_devices ) + added_devices = new_added_devices if LOGGER.isEnabledFor(logging.DEBUG): LOGGER.debug( @@ -168,8 +169,7 @@ async def async_setup_entry( device_data.model, DEVICE_SENSOR_TYPES ) ) - - async_add_entities(entities) + async_add_entities(entities) entry.async_on_unload(coordinator.async_add_listener(_add_remove_devices)) _add_remove_devices() diff --git a/homeassistant/components/sensibo/button.py b/homeassistant/components/sensibo/button.py index ed0688d6f2c..d36967dae06 100644 --- a/homeassistant/components/sensibo/button.py +++ b/homeassistant/components/sensibo/button.py @@ -46,7 +46,8 @@ async def async_setup_entry( def _add_remove_devices() -> None: """Handle additions of devices and sensors.""" nonlocal added_devices - new_devices, _, added_devices = coordinator.get_devices(added_devices) + new_devices, _, new_added_devices = coordinator.get_devices(added_devices) + added_devices = new_added_devices if new_devices: async_add_entities( diff --git a/homeassistant/components/sensibo/climate.py b/homeassistant/components/sensibo/climate.py index 2190d121248..906c4259ce5 100644 --- a/homeassistant/components/sensibo/climate.py +++ b/homeassistant/components/sensibo/climate.py @@ -149,7 +149,8 @@ async def async_setup_entry( def _add_remove_devices() -> None: """Handle additions of devices and sensors.""" nonlocal added_devices - new_devices, _, added_devices = coordinator.get_devices(added_devices) + new_devices, _, new_added_devices = coordinator.get_devices(added_devices) + added_devices = new_added_devices if new_devices: async_add_entities( diff --git a/homeassistant/components/sensibo/coordinator.py b/homeassistant/components/sensibo/coordinator.py index e19f24295b9..3fa8a6e5dae 100644 --- a/homeassistant/components/sensibo/coordinator.py +++ b/homeassistant/components/sensibo/coordinator.py @@ -56,18 +56,31 @@ class SensiboDataUpdateCoordinator(DataUpdateCoordinator[SensiboData]): ) -> tuple[set[str], set[str], set[str]]: """Addition and removal of devices.""" data = self.data - motion_sensors = { + current_motion_sensors = { sensor_id for device_data in data.parsed.values() if device_data.motion_sensors for sensor_id in device_data.motion_sensors } - devices: set[str] = set(data.parsed) - new_devices: set[str] = motion_sensors | devices - added_devices - remove_devices = added_devices - devices - motion_sensors - added_devices = (added_devices - remove_devices) | new_devices + current_devices: set[str] = set(data.parsed) + LOGGER.debug( + "Current devices: %s, moption sensors: %s", + current_devices, + current_motion_sensors, + ) + new_devices: set[str] = ( + current_motion_sensors | current_devices + ) - added_devices + remove_devices = added_devices - current_devices - current_motion_sensors + new_added_devices = (added_devices - remove_devices) | new_devices - return (new_devices, remove_devices, added_devices) + LOGGER.debug( + "New devices: %s, Removed devices: %s, Added devices: %s", + new_devices, + remove_devices, + new_added_devices, + ) + return (new_devices, remove_devices, new_added_devices) async def _async_update_data(self) -> SensiboData: """Fetch data from Sensibo.""" diff --git a/homeassistant/components/sensibo/number.py b/homeassistant/components/sensibo/number.py index 9d077b308a0..e71ed6f0235 100644 --- a/homeassistant/components/sensibo/number.py +++ b/homeassistant/components/sensibo/number.py @@ -76,7 +76,8 @@ async def async_setup_entry( def _add_remove_devices() -> None: """Handle additions of devices and sensors.""" nonlocal added_devices - new_devices, _, added_devices = coordinator.get_devices(added_devices) + new_devices, _, new_added_devices = coordinator.get_devices(added_devices) + added_devices = new_added_devices if new_devices: async_add_entities( diff --git a/homeassistant/components/sensibo/select.py b/homeassistant/components/sensibo/select.py index 73c0734ef73..5a0546b1aa2 100644 --- a/homeassistant/components/sensibo/select.py +++ b/homeassistant/components/sensibo/select.py @@ -115,7 +115,8 @@ async def async_setup_entry( def _add_remove_devices() -> None: """Handle additions of devices and sensors.""" nonlocal added_devices - new_devices, _, added_devices = coordinator.get_devices(added_devices) + new_devices, _, new_added_devices = coordinator.get_devices(added_devices) + added_devices = new_added_devices if new_devices: async_add_entities( diff --git a/homeassistant/components/sensibo/sensor.py b/homeassistant/components/sensibo/sensor.py index 4174d4b859b..09f095bfaec 100644 --- a/homeassistant/components/sensibo/sensor.py +++ b/homeassistant/components/sensibo/sensor.py @@ -253,9 +253,8 @@ async def async_setup_entry( entities: list[SensiboMotionSensor | SensiboDeviceSensor] = [] nonlocal added_devices - new_devices, remove_devices, added_devices = coordinator.get_devices( - added_devices - ) + new_devices, _, new_added_devices = coordinator.get_devices(added_devices) + added_devices = new_added_devices if new_devices: entities.extend( diff --git a/homeassistant/components/sensibo/switch.py b/homeassistant/components/sensibo/switch.py index 8c140074e57..03e7c12ec2b 100644 --- a/homeassistant/components/sensibo/switch.py +++ b/homeassistant/components/sensibo/switch.py @@ -89,7 +89,8 @@ async def async_setup_entry( def _add_remove_devices() -> None: """Handle additions of devices and sensors.""" nonlocal added_devices - new_devices, _, added_devices = coordinator.get_devices(added_devices) + new_devices, _, new_added_devices = coordinator.get_devices(added_devices) + added_devices = new_added_devices if new_devices: async_add_entities( diff --git a/homeassistant/components/sensibo/update.py b/homeassistant/components/sensibo/update.py index 2103bbbf64a..6f868e5f366 100644 --- a/homeassistant/components/sensibo/update.py +++ b/homeassistant/components/sensibo/update.py @@ -56,7 +56,8 @@ async def async_setup_entry( def _add_remove_devices() -> None: """Handle additions of devices and sensors.""" nonlocal added_devices - new_devices, _, added_devices = coordinator.get_devices(added_devices) + new_devices, _, new_added_devices = coordinator.get_devices(added_devices) + added_devices = new_added_devices if new_devices: async_add_entities( diff --git a/tests/components/sensibo/test_coordinator.py b/tests/components/sensibo/test_coordinator.py index 6cb8e6fe923..2d56fc4c51c 100644 --- a/tests/components/sensibo/test_coordinator.py +++ b/tests/components/sensibo/test_coordinator.py @@ -9,6 +9,7 @@ from unittest.mock import MagicMock from freezegun.api import FrozenDateTimeFactory from pysensibo.exceptions import AuthenticationError, SensiboError from pysensibo.model import SensiboData +import pytest from homeassistant.components.climate import HVACMode from homeassistant.components.sensibo.const import DOMAIN @@ -25,6 +26,7 @@ async def test_coordinator( mock_client: MagicMock, get_data: tuple[SensiboData, dict[str, Any], dict[str, Any]], freezer: FrozenDateTimeFactory, + caplog: pytest.LogCaptureFixture, ) -> None: """Test the Sensibo coordinator with errors.""" config_entry = MockConfigEntry( @@ -87,3 +89,5 @@ async def test_coordinator( mock_data.assert_called_once() state = hass.states.get("climate.hallway") assert state.state == STATE_UNAVAILABLE + + assert "Platform sensibo does not generate unique IDs" not in caplog.text From 9055dff9bd8cfe27d9a404e7e0fac3f69ee40a18 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sat, 1 Mar 2025 20:16:32 +0100 Subject: [PATCH 2084/3148] Improve field descriptions of `zha.permit` action (#139584) Make the field descriptions of `source_ieee` and `install_code` UI-friendly by cross-referencing them using their friendly names to allow matching translations. Better explain the alternative of using the `qr_code` field by adding that this contains both the IEEE address and the Install code of the joining device. --- homeassistant/components/zha/strings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zha/strings.json b/homeassistant/components/zha/strings.json index 38f55fb550d..be1642227bd 100644 --- a/homeassistant/components/zha/strings.json +++ b/homeassistant/components/zha/strings.json @@ -274,15 +274,15 @@ }, "source_ieee": { "name": "Source IEEE", - "description": "IEEE address of the joining device (must be used with the install code)." + "description": "IEEE address of the joining device (must be combined with the 'Install code' field)." }, "install_code": { "name": "Install code", - "description": "Install code of the joining device (must be used with the source_ieee)." + "description": "Install code of the joining device (must be combined with the 'Source IEEE' field)." }, "qr_code": { "name": "QR code", - "description": "Value of the QR install code (different between vendors)." + "description": "Provides both the IEEE address and the install code of the joining device (different between vendors)." } } }, From 8fdff9ca37fb980f5f0f3624e2d2e23506e6a482 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sat, 1 Mar 2025 19:35:39 +0100 Subject: [PATCH 2085/3148] Fix - Allow brightness only light MQTT json light to be set up using the `brightness` flag or via `supported_color_modes` (#139585) * Fix - Allow brightness only light MQTT json light to be set up using the `brightness` flag or via `supported_color_modes` * Improve comment --- .../components/mqtt/light/schema_json.py | 4 ++ tests/components/mqtt/test_light_json.py | 71 +++++++++++++++++++ 2 files changed, 75 insertions(+) diff --git a/homeassistant/components/mqtt/light/schema_json.py b/homeassistant/components/mqtt/light/schema_json.py index 14e21e61d48..4473385d550 100644 --- a/homeassistant/components/mqtt/light/schema_json.py +++ b/homeassistant/components/mqtt/light/schema_json.py @@ -217,6 +217,10 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity): self._attr_color_mode = next(iter(self.supported_color_modes)) else: self._attr_color_mode = ColorMode.UNKNOWN + elif config.get(CONF_BRIGHTNESS): + # Brightness is supported and no supported_color_modes are set, + # so set brightness as the supported color mode. + self._attr_supported_color_modes = {ColorMode.BRIGHTNESS} def _update_color(self, values: dict[str, Any]) -> None: color_mode: str = values["color_mode"] diff --git a/tests/components/mqtt/test_light_json.py b/tests/components/mqtt/test_light_json.py index 7ddd04a09a6..bcf9d4bd736 100644 --- a/tests/components/mqtt/test_light_json.py +++ b/tests/components/mqtt/test_light_json.py @@ -361,6 +361,77 @@ async def test_no_color_brightness_color_temp_if_no_topics( assert state.state == STATE_UNKNOWN +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + light.DOMAIN: { + "schema": "json", + "name": "test", + "state_topic": "test_light_rgb", + "command_topic": "test_light_rgb/set", + "supported_color_modes": ["brightness"], + } + } + }, + { + mqtt.DOMAIN: { + light.DOMAIN: { + "schema": "json", + "name": "test", + "state_topic": "test_light_rgb", + "command_topic": "test_light_rgb/set", + "brightness": True, + } + } + }, + ], +) +async def test_brightness_only( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test brightness only light. + + There are two possible configurations for brightness only light: + 1) Set up "brightness" as supported color mode. + 2) Set "brightness" flag to true. + """ + await mqtt_mock_entry() + + state = hass.states.get("light.test") + assert state.state == STATE_UNKNOWN + assert state.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES) == [ + light.ColorMode.BRIGHTNESS + ] + expected_features = ( + light.LightEntityFeature.FLASH | light.LightEntityFeature.TRANSITION + ) + assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == expected_features + assert state.attributes.get("rgb_color") is None + assert state.attributes.get("brightness") is None + assert state.attributes.get("color_temp_kelvin") is None + assert state.attributes.get("effect") is None + assert state.attributes.get("xy_color") is None + assert state.attributes.get("hs_color") is None + + async_fire_mqtt_message(hass, "test_light_rgb", '{"state":"ON", "brightness": 50}') + + state = hass.states.get("light.test") + assert state.state == STATE_ON + assert state.attributes.get("rgb_color") is None + assert state.attributes.get("brightness") == 50 + assert state.attributes.get("color_temp_kelvin") is None + assert state.attributes.get("effect") is None + assert state.attributes.get("xy_color") is None + assert state.attributes.get("hs_color") is None + + async_fire_mqtt_message(hass, "test_light_rgb", '{"state":"OFF"}') + + state = hass.states.get("light.test") + assert state.state == STATE_OFF + + @pytest.mark.parametrize( "hass_config", [ From 6ff0f67d032f3bb67e3ea408e49ecb91035dd3d5 Mon Sep 17 00:00:00 2001 From: peteS-UK <64092177+peteS-UK@users.noreply.github.com> Date: Sat, 1 Mar 2025 19:22:34 +0000 Subject: [PATCH 2086/3148] Fix Manufacturer naming for Squeezelite model name for Squeezebox (#139586) Squeezelite Manufacturer Fix --- homeassistant/components/squeezebox/media_player.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/squeezebox/media_player.py b/homeassistant/components/squeezebox/media_player.py index 0cd539b4584..1767d92730a 100644 --- a/homeassistant/components/squeezebox/media_player.py +++ b/homeassistant/components/squeezebox/media_player.py @@ -224,7 +224,7 @@ class SqueezeBoxMediaPlayerEntity( self._previous_media_position = 0 self._attr_unique_id = format_mac(player.player_id) _manufacturer = None - if player.model == "SqueezeLite" or "SqueezePlay" in player.model: + if player.model.startswith("SqueezeLite") or "SqueezePlay" in player.model: _manufacturer = "Ralph Irving" elif ( "Squeezebox" in player.model From c257b228f1b12869c3d63b013e97cdca2c31ea9b Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Sat, 1 Mar 2025 23:05:55 +0100 Subject: [PATCH 2087/3148] Bump deebot-client to 12.3.1 (#139598) --- homeassistant/components/ecovacs/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ecovacs/manifest.json b/homeassistant/components/ecovacs/manifest.json index b31fa7f347d..6d3dc5c9be6 100644 --- a/homeassistant/components/ecovacs/manifest.json +++ b/homeassistant/components/ecovacs/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/ecovacs", "iot_class": "cloud_push", "loggers": ["sleekxmppfs", "sucks", "deebot_client"], - "requirements": ["py-sucks==0.9.10", "deebot-client==12.2.0"] + "requirements": ["py-sucks==0.9.10", "deebot-client==12.3.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 7031a2e6004..4ca19351b87 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -756,7 +756,7 @@ debugpy==1.8.11 # decora==0.6 # homeassistant.components.ecovacs -deebot-client==12.2.0 +deebot-client==12.3.1 # homeassistant.components.ihc # homeassistant.components.namecheapdns diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 934cf43bf3b..c16c1e9b249 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -646,7 +646,7 @@ dbus-fast==2.33.0 debugpy==1.8.11 # homeassistant.components.ecovacs -deebot-client==12.2.0 +deebot-client==12.3.1 # homeassistant.components.ihc # homeassistant.components.namecheapdns From 74e8ffa5555e1da765d245979d5b3c8861d04051 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 1 Mar 2025 16:13:04 -0600 Subject: [PATCH 2088/3148] Fix handling of NaN float values for current humidity in ESPHome (#139600) fixes #131837 --- homeassistant/components/esphome/climate.py | 9 +++++++-- tests/components/esphome/test_climate.py | 4 ++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/esphome/climate.py b/homeassistant/components/esphome/climate.py index 478ce9bae2c..b651f16dfd7 100644 --- a/homeassistant/components/esphome/climate.py +++ b/homeassistant/components/esphome/climate.py @@ -3,6 +3,7 @@ from __future__ import annotations from functools import partial +from math import isfinite from typing import Any, cast from aioesphomeapi import ( @@ -238,9 +239,13 @@ class EsphomeClimateEntity(EsphomeEntity[ClimateInfo, ClimateState], ClimateEnti @esphome_state_property def current_humidity(self) -> int | None: """Return the current humidity.""" - if not self._static_info.supports_current_humidity: + if ( + not self._static_info.supports_current_humidity + or (val := self._state.current_humidity) is None + or not isfinite(val) + ): return None - return round(self._state.current_humidity) + return round(val) @property @esphome_float_state_property diff --git a/tests/components/esphome/test_climate.py b/tests/components/esphome/test_climate.py index 2a5013444dd..03d2f78a5d2 100644 --- a/tests/components/esphome/test_climate.py +++ b/tests/components/esphome/test_climate.py @@ -407,7 +407,7 @@ async def test_climate_entity_with_inf_value( target_temperature=math.inf, fan_mode=ClimateFanMode.AUTO, swing_mode=ClimateSwingMode.BOTH, - current_humidity=20.1, + current_humidity=math.inf, target_humidity=25.7, ) ] @@ -422,7 +422,7 @@ async def test_climate_entity_with_inf_value( assert state is not None assert state.state == HVACMode.AUTO attributes = state.attributes - assert attributes[ATTR_CURRENT_HUMIDITY] == 20 + assert ATTR_CURRENT_HUMIDITY not in attributes assert attributes[ATTR_HUMIDITY] == 26 assert attributes[ATTR_MAX_HUMIDITY] == 30 assert attributes[ATTR_MIN_HUMIDITY] == 10 From 4fe4d14f16a0111249bdd3e67dc1987cb6144f61 Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Sun, 2 Mar 2025 01:12:19 +0200 Subject: [PATCH 2089/3148] Bump aioshelly to 13.1.0 (#139601) Co-authored-by: Franck Nijhof --- homeassistant/components/shelly/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/shelly/manifest.json b/homeassistant/components/shelly/manifest.json index ec08a005995..722fd4c128a 100644 --- a/homeassistant/components/shelly/manifest.json +++ b/homeassistant/components/shelly/manifest.json @@ -8,7 +8,7 @@ "integration_type": "device", "iot_class": "local_push", "loggers": ["aioshelly"], - "requirements": ["aioshelly==13.0.0"], + "requirements": ["aioshelly==13.1.0"], "zeroconf": [ { "type": "_http._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index 4ca19351b87..1178fbd1e89 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -371,7 +371,7 @@ aioruuvigateway==0.1.0 aiosenz==1.0.0 # homeassistant.components.shelly -aioshelly==13.0.0 +aioshelly==13.1.0 # homeassistant.components.skybell aioskybell==22.7.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c16c1e9b249..49167e3a800 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -353,7 +353,7 @@ aioruuvigateway==0.1.0 aiosenz==1.0.0 # homeassistant.components.shelly -aioshelly==13.0.0 +aioshelly==13.1.0 # homeassistant.components.skybell aioskybell==22.7.0 From 3690e0395100157fd31a44835d00e02877f7cffc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 1 Mar 2025 17:51:09 -0600 Subject: [PATCH 2090/3148] Bump inkbird-ble to 0.7.1 (#139603) changelog: https://github.com/Bluetooth-Devices/inkbird-ble/compare/v0.7.0...v0.7.1 --- homeassistant/components/inkbird/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/inkbird/manifest.json b/homeassistant/components/inkbird/manifest.json index 1a251f52582..acc7414edac 100644 --- a/homeassistant/components/inkbird/manifest.json +++ b/homeassistant/components/inkbird/manifest.json @@ -28,5 +28,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/inkbird", "iot_class": "local_push", - "requirements": ["inkbird-ble==0.7.0"] + "requirements": ["inkbird-ble==0.7.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 1178fbd1e89..7165cd7362a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1226,7 +1226,7 @@ influxdb-client==1.24.0 influxdb==5.3.1 # homeassistant.components.inkbird -inkbird-ble==0.7.0 +inkbird-ble==0.7.1 # homeassistant.components.insteon insteon-frontend-home-assistant==0.5.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 49167e3a800..dfeafe1a368 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1040,7 +1040,7 @@ influxdb-client==1.24.0 influxdb==5.3.1 # homeassistant.components.inkbird -inkbird-ble==0.7.0 +inkbird-ble==0.7.1 # homeassistant.components.insteon insteon-frontend-home-assistant==0.5.0 From 6abdb28a0396aa39491dd6370aac38042fa8b106 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sun, 2 Mar 2025 05:00:22 +0100 Subject: [PATCH 2091/3148] Fix body text of imap message not available in custom event data template (#139609) --- homeassistant/components/imap/coordinator.py | 2 +- tests/components/imap/test_init.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/imap/coordinator.py b/homeassistant/components/imap/coordinator.py index 74f7a86c0d6..34d3f43eb69 100644 --- a/homeassistant/components/imap/coordinator.py +++ b/homeassistant/components/imap/coordinator.py @@ -280,7 +280,7 @@ class ImapDataUpdateCoordinator(DataUpdateCoordinator[int | None]): if self.custom_event_template is not None: try: data["custom"] = self.custom_event_template.async_render( - data, parse_result=True + data | {"text": message.text}, parse_result=True ) _LOGGER.debug( "IMAP custom template (%s) for msguid %s (%s) rendered to: %s, initial: %s", diff --git a/tests/components/imap/test_init.py b/tests/components/imap/test_init.py index b86855bd78f..bdd29f7442b 100644 --- a/tests/components/imap/test_init.py +++ b/tests/components/imap/test_init.py @@ -726,9 +726,10 @@ async def test_message_data( [ ("{{ subject }}", "Test subject", None), ('{{ "@example.com" in sender }}', True, None), + ('{{ "body" in text }}', True, None), ("{% bad template }}", None, "Error rendering IMAP custom template"), ], - ids=["subject_test", "sender_filter", "template_error"], + ids=["subject_test", "sender_filter", "body_filter", "template_error"], ) async def test_custom_template( hass: HomeAssistant, From 7d9a6ceb6b302018b6b4ff63093d016cac9d9e8a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 2 Mar 2025 07:23:40 -0600 Subject: [PATCH 2092/3148] Fix arm vacation mode showing as armed away in elkm1 (#139613) Add native arm vacation mode support to elkm1 Vacation mode is currently implemented as a custom service which will be deprecated in a future PR. Note that the custom service was added long before HA had a native vacation mode which was added in #45980 --- homeassistant/components/elkm1/alarm_control_panel.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/elkm1/alarm_control_panel.py b/homeassistant/components/elkm1/alarm_control_panel.py index 8113a4d99a6..393845f65ff 100644 --- a/homeassistant/components/elkm1/alarm_control_panel.py +++ b/homeassistant/components/elkm1/alarm_control_panel.py @@ -105,6 +105,7 @@ class ElkArea(ElkAttachedEntity, AlarmControlPanelEntity, RestoreEntity): AlarmControlPanelEntityFeature.ARM_HOME | AlarmControlPanelEntityFeature.ARM_AWAY | AlarmControlPanelEntityFeature.ARM_NIGHT + | AlarmControlPanelEntityFeature.ARM_VACATION ) _element: Area @@ -204,7 +205,7 @@ class ElkArea(ElkAttachedEntity, AlarmControlPanelEntity, RestoreEntity): ArmedStatus.ARMED_STAY_INSTANT: AlarmControlPanelState.ARMED_HOME, ArmedStatus.ARMED_TO_NIGHT: AlarmControlPanelState.ARMED_NIGHT, ArmedStatus.ARMED_TO_NIGHT_INSTANT: AlarmControlPanelState.ARMED_NIGHT, - ArmedStatus.ARMED_TO_VACATION: AlarmControlPanelState.ARMED_AWAY, + ArmedStatus.ARMED_TO_VACATION: AlarmControlPanelState.ARMED_VACATION, } if self._element.alarm_state is None: From 1d0cba1a43921c26dea9309cf897965c921c6371 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 2 Mar 2025 14:17:56 +0100 Subject: [PATCH 2093/3148] Still request scopes in SmartThings (#139626) Still request scopes --- homeassistant/components/smartthings/config_flow.py | 4 ++-- homeassistant/components/smartthings/const.py | 6 ++++++ tests/components/smartthings/test_config_flow.py | 9 ++++++--- 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/smartthings/config_flow.py b/homeassistant/components/smartthings/config_flow.py index b39fe662124..0ad1b5553b1 100644 --- a/homeassistant/components/smartthings/config_flow.py +++ b/homeassistant/components/smartthings/config_flow.py @@ -11,7 +11,7 @@ from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.config_entry_oauth2_flow import AbstractOAuth2FlowHandler -from .const import CONF_LOCATION_ID, DOMAIN, OLD_DATA, SCOPES +from .const import CONF_LOCATION_ID, DOMAIN, OLD_DATA, REQUESTED_SCOPES, SCOPES _LOGGER = logging.getLogger(__name__) @@ -30,7 +30,7 @@ class SmartThingsConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN): @property def extra_authorize_data(self) -> dict[str, Any]: """Extra data that needs to be appended to the authorize url.""" - return {"scope": " ".join(SCOPES)} + return {"scope": " ".join(REQUESTED_SCOPES)} async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResult: """Create an entry for SmartThings.""" diff --git a/homeassistant/components/smartthings/const.py b/homeassistant/components/smartthings/const.py index 80c4cf90226..23fd48a4e1e 100644 --- a/homeassistant/components/smartthings/const.py +++ b/homeassistant/components/smartthings/const.py @@ -17,6 +17,12 @@ SCOPES = [ "sse", ] +REQUESTED_SCOPES = [ + *SCOPES, + "r:installedapps", + "w:installedapps", +] + CONF_APP_ID = "app_id" CONF_CLOUDHOOK_URL = "cloudhook_url" CONF_INSTALLED_APP_ID = "installed_app_id" diff --git a/tests/components/smartthings/test_config_flow.py b/tests/components/smartthings/test_config_flow.py index 2fbd686e4d3..858384db0b6 100644 --- a/tests/components/smartthings/test_config_flow.py +++ b/tests/components/smartthings/test_config_flow.py @@ -57,7 +57,8 @@ async def test_full_flow( f"&state={state}" "&scope=r:devices:*+w:devices:*+x:devices:*+r:hubs:*+" "r:locations:*+w:locations:*+x:locations:*+r:scenes:*+" - "x:scenes:*+r:rules:*+w:rules:*+sse" + "x:scenes:*+r:rules:*+w:rules:*+sse+r:installedapps+" + "w:installedapps" ) client = await hass_client_no_auth() @@ -128,7 +129,8 @@ async def test_not_enough_scopes( f"&state={state}" "&scope=r:devices:*+w:devices:*+x:devices:*+r:hubs:*+" "r:locations:*+w:locations:*+x:locations:*+r:scenes:*+" - "x:scenes:*+r:rules:*+w:rules:*+sse" + "x:scenes:*+r:rules:*+w:rules:*+sse+r:installedapps+" + "w:installedapps" ) client = await hass_client_no_auth() @@ -190,7 +192,8 @@ async def test_duplicate_entry( f"&state={state}" "&scope=r:devices:*+w:devices:*+x:devices:*+r:hubs:*+" "r:locations:*+w:locations:*+x:locations:*+r:scenes:*+" - "x:scenes:*+r:rules:*+w:rules:*+sse" + "x:scenes:*+r:rules:*+w:rules:*+sse+r:installedapps+" + "w:installedapps" ) client = await hass_client_no_auth() From 7e1309d8742a7491f04a4980bae57b1c5362f6fa Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 2 Mar 2025 13:15:43 +0100 Subject: [PATCH 2094/3148] Bump pysmartthings to 2.4.1 (#139627) --- homeassistant/components/smartthings/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/smartthings/manifest.json b/homeassistant/components/smartthings/manifest.json index e0cf6739290..7a25dc2ac13 100644 --- a/homeassistant/components/smartthings/manifest.json +++ b/homeassistant/components/smartthings/manifest.json @@ -29,5 +29,5 @@ "documentation": "https://www.home-assistant.io/integrations/smartthings", "iot_class": "cloud_push", "loggers": ["pysmartthings"], - "requirements": ["pysmartthings==2.4.0"] + "requirements": ["pysmartthings==2.4.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 7165cd7362a..ffb7ead3bdf 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2310,7 +2310,7 @@ pysma==0.7.5 pysmappee==0.2.29 # homeassistant.components.smartthings -pysmartthings==2.4.0 +pysmartthings==2.4.1 # homeassistant.components.smarty pysmarty2==0.10.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index dfeafe1a368..38f78484aad 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1882,7 +1882,7 @@ pysma==0.7.5 pysmappee==0.2.29 # homeassistant.components.smartthings -pysmartthings==2.4.0 +pysmartthings==2.4.1 # homeassistant.components.smarty pysmarty2==0.10.2 From 8382663be4b6d53fccd4de76650570840156795e Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Sun, 2 Mar 2025 16:15:38 +0100 Subject: [PATCH 2095/3148] Bump version to 2025.3.0b3 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index e295e6b3b91..895fcb1b3a6 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -25,7 +25,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2025 MINOR_VERSION: Final = 3 -PATCH_VERSION: Final = "0b2" +PATCH_VERSION: Final = "0b3" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 13, 0) diff --git a/pyproject.toml b/pyproject.toml index 439cb650a6f..710b14869c8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.3.0b2" +version = "2025.3.0b3" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 0a3562aca31cbcdcf934ab0fafeed5862dbe9ffc Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Sun, 2 Mar 2025 16:45:57 +0100 Subject: [PATCH 2096/3148] Add prefix path support to pyLoad integration (#139139) * Add prefix path configuration support * fix typo * formatting * uppercase * changes * redact host --- homeassistant/components/pyload/__init__.py | 37 +++++++++++-- .../components/pyload/config_flow.py | 55 +++++++++++-------- .../components/pyload/diagnostics.py | 13 +++-- homeassistant/components/pyload/strings.json | 22 +++----- tests/components/pyload/conftest.py | 34 +++++++++--- .../pyload/snapshots/test_diagnostics.ambr | 4 +- tests/components/pyload/test_init.py | 20 +++++++ 7 files changed, 126 insertions(+), 59 deletions(-) diff --git a/homeassistant/components/pyload/__init__.py b/homeassistant/components/pyload/__init__.py index cf8e922d70e..ca7bbb0c1dc 100644 --- a/homeassistant/components/pyload/__init__.py +++ b/homeassistant/components/pyload/__init__.py @@ -2,14 +2,18 @@ from __future__ import annotations +import logging + from aiohttp import CookieJar from pyloadapi import PyLoadAPI +from yarl import URL from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_SSL, + CONF_URL, CONF_USERNAME, CONF_VERIFY_SSL, Platform, @@ -19,17 +23,14 @@ from homeassistant.helpers.aiohttp_client import async_create_clientsession from .coordinator import PyLoadConfigEntry, PyLoadCoordinator +_LOGGER = logging.getLogger(__name__) + PLATFORMS: list[Platform] = [Platform.BUTTON, Platform.SENSOR, Platform.SWITCH] async def async_setup_entry(hass: HomeAssistant, entry: PyLoadConfigEntry) -> bool: """Set up pyLoad from a config entry.""" - url = ( - f"{'https' if entry.data[CONF_SSL] else 'http'}://" - f"{entry.data[CONF_HOST]}:{entry.data[CONF_PORT]}/" - ) - session = async_create_clientsession( hass, verify_ssl=entry.data[CONF_VERIFY_SSL], @@ -37,7 +38,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: PyLoadConfigEntry) -> bo ) pyloadapi = PyLoadAPI( session, - api_url=url, + api_url=URL(entry.data[CONF_URL]), username=entry.data[CONF_USERNAME], password=entry.data[CONF_PASSWORD], ) @@ -55,3 +56,27 @@ async def async_setup_entry(hass: HomeAssistant, entry: PyLoadConfigEntry) -> bo async def async_unload_entry(hass: HomeAssistant, entry: PyLoadConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +async def async_migrate_entry(hass: HomeAssistant, entry: PyLoadConfigEntry) -> bool: + """Migrate config entry.""" + _LOGGER.debug( + "Migrating configuration from version %s.%s", entry.version, entry.minor_version + ) + + if entry.version == 1 and entry.minor_version == 0: + url = URL.build( + scheme="https" if entry.data[CONF_SSL] else "http", + host=entry.data[CONF_HOST], + port=entry.data[CONF_PORT], + ).human_repr() + hass.config_entries.async_update_entry( + entry, data={**entry.data, CONF_URL: url}, minor_version=1, version=1 + ) + + _LOGGER.debug( + "Migration to configuration version %s.%s successful", + entry.version, + entry.minor_version, + ) + return True diff --git a/homeassistant/components/pyload/config_flow.py b/homeassistant/components/pyload/config_flow.py index bc3bbc6cb34..50d354d345d 100644 --- a/homeassistant/components/pyload/config_flow.py +++ b/homeassistant/components/pyload/config_flow.py @@ -9,19 +9,17 @@ from typing import Any from aiohttp import CookieJar from pyloadapi import CannotConnect, InvalidAuth, ParserError, PyLoadAPI import voluptuous as vol +from yarl import URL from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import ( - CONF_HOST, CONF_NAME, CONF_PASSWORD, - CONF_PORT, - CONF_SSL, + CONF_URL, CONF_USERNAME, CONF_VERIFY_SSL, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_create_clientsession from homeassistant.helpers.selector import ( TextSelector, @@ -29,15 +27,18 @@ from homeassistant.helpers.selector import ( TextSelectorType, ) -from .const import DEFAULT_NAME, DEFAULT_PORT, DOMAIN +from .const import DEFAULT_NAME, DOMAIN _LOGGER = logging.getLogger(__name__) STEP_USER_DATA_SCHEMA = vol.Schema( { - vol.Required(CONF_HOST): str, - vol.Required(CONF_PORT, default=DEFAULT_PORT): cv.port, - vol.Required(CONF_SSL, default=False): cv.boolean, + vol.Required(CONF_URL): TextSelector( + TextSelectorConfig( + type=TextSelectorType.URL, + autocomplete="url", + ), + ), vol.Required(CONF_VERIFY_SSL, default=True): bool, vol.Required(CONF_USERNAME): TextSelector( TextSelectorConfig( @@ -80,14 +81,9 @@ async def validate_input(hass: HomeAssistant, user_input: dict[str, Any]) -> Non user_input[CONF_VERIFY_SSL], cookie_jar=CookieJar(unsafe=True), ) - - url = ( - f"{'https' if user_input[CONF_SSL] else 'http'}://" - f"{user_input[CONF_HOST]}:{user_input[CONF_PORT]}/" - ) pyload = PyLoadAPI( session, - api_url=url, + api_url=URL(user_input[CONF_URL]), username=user_input[CONF_USERNAME], password=user_input[CONF_PASSWORD], ) @@ -99,6 +95,7 @@ class PyLoadConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for pyLoad.""" VERSION = 1 + MINOR_VERSION = 1 async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -106,9 +103,8 @@ class PyLoadConfigFlow(ConfigFlow, domain=DOMAIN): """Handle the initial step.""" errors: dict[str, str] = {} if user_input is not None: - self._async_abort_entries_match( - {CONF_HOST: user_input[CONF_HOST], CONF_PORT: user_input[CONF_PORT]} - ) + url = URL(user_input[CONF_URL]).human_repr() + self._async_abort_entries_match({CONF_URL: url}) try: await validate_input(self.hass, user_input) except (CannotConnect, ParserError): @@ -120,7 +116,14 @@ class PyLoadConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "unknown" else: title = DEFAULT_NAME - return self.async_create_entry(title=title, data=user_input) + + return self.async_create_entry( + title=title, + data={ + **user_input, + CONF_URL: url, + }, + ) return self.async_show_form( step_id="user", @@ -144,9 +147,8 @@ class PyLoadConfigFlow(ConfigFlow, domain=DOMAIN): reauth_entry = self._get_reauth_entry() if user_input is not None: - new_input = reauth_entry.data | user_input try: - await validate_input(self.hass, new_input) + await validate_input(self.hass, {**reauth_entry.data, **user_input}) except (CannotConnect, ParserError): errors["base"] = "cannot_connect" except InvalidAuth: @@ -155,7 +157,9 @@ class PyLoadConfigFlow(ConfigFlow, domain=DOMAIN): _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: - return self.async_update_reload_and_abort(reauth_entry, data=new_input) + return self.async_update_reload_and_abort( + reauth_entry, data_updates=user_input + ) return self.async_show_form( step_id="reauth_confirm", @@ -191,15 +195,18 @@ class PyLoadConfigFlow(ConfigFlow, domain=DOMAIN): else: return self.async_update_reload_and_abort( reconfig_entry, - data=user_input, + data={ + **user_input, + CONF_URL: URL(user_input[CONF_URL]).human_repr(), + }, reload_even_if_entry_is_unchanged=False, ) - + suggested_values = user_input if user_input else reconfig_entry.data return self.async_show_form( step_id="reconfigure", data_schema=self.add_suggested_values_to_schema( STEP_USER_DATA_SCHEMA, - user_input or reconfig_entry.data, + suggested_values, ), description_placeholders={CONF_NAME: reconfig_entry.data[CONF_USERNAME]}, errors=errors, diff --git a/homeassistant/components/pyload/diagnostics.py b/homeassistant/components/pyload/diagnostics.py index 105a9a953e2..98fab38da1d 100644 --- a/homeassistant/components/pyload/diagnostics.py +++ b/homeassistant/components/pyload/diagnostics.py @@ -5,13 +5,15 @@ from __future__ import annotations from dataclasses import asdict from typing import Any -from homeassistant.components.diagnostics import async_redact_data -from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from yarl import URL + +from homeassistant.components.diagnostics import REDACTED, async_redact_data +from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME from homeassistant.core import HomeAssistant from .coordinator import PyLoadConfigEntry, PyLoadData -TO_REDACT = {CONF_USERNAME, CONF_PASSWORD, CONF_HOST} +TO_REDACT = {CONF_USERNAME, CONF_PASSWORD, CONF_URL} async def async_get_config_entry_diagnostics( @@ -21,6 +23,9 @@ async def async_get_config_entry_diagnostics( pyload_data: PyLoadData = config_entry.runtime_data.data return { - "config_entry_data": async_redact_data(dict(config_entry.data), TO_REDACT), + "config_entry_data": { + **async_redact_data(dict(config_entry.data), TO_REDACT), + CONF_URL: URL(config_entry.data[CONF_URL]).with_host(REDACTED).human_repr(), + }, "pyload_data": asdict(pyload_data), } diff --git a/homeassistant/components/pyload/strings.json b/homeassistant/components/pyload/strings.json index ed15a438c28..9414f7f7bb8 100644 --- a/homeassistant/components/pyload/strings.json +++ b/homeassistant/components/pyload/strings.json @@ -3,38 +3,30 @@ "step": { "user": { "data": { - "host": "[%key:common::config_flow::data::host%]", + "url": "[%key:common::config_flow::data::url%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]", - "ssl": "[%key:common::config_flow::data::ssl%]", - "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]", - "port": "[%key:common::config_flow::data::port%]" + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" }, "data_description": { - "host": "The hostname or IP address of the device running your pyLoad instance.", + "url": "Specify the full URL of your pyLoad web interface, including the protocol (HTTP or HTTPS), hostname or IP address, port (pyLoad uses 8000 by default), and any path prefix if applicable.\nExample: `https://example.com:8000/path`", "username": "The username used to access the pyLoad instance.", "password": "The password associated with the pyLoad account.", - "port": "pyLoad uses port 8000 by default.", - "ssl": "If enabled, the connection to the pyLoad instance will use HTTPS.", "verify_ssl": "If checked, the SSL certificate will be validated to ensure a secure connection." } }, "reconfigure": { "data": { - "host": "[%key:common::config_flow::data::host%]", + "url": "[%key:common::config_flow::data::url%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]", - "ssl": "[%key:common::config_flow::data::ssl%]", - "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]", - "port": "[%key:common::config_flow::data::port%]" + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" }, "data_description": { - "host": "[%key:component::pyload::config::step::user::data_description::host%]", - "verify_ssl": "[%key:component::pyload::config::step::user::data_description::verify_ssl%]", + "url": "[%key:component::pyload::config::step::user::data_description::url%]", "username": "[%key:component::pyload::config::step::user::data_description::username%]", "password": "[%key:component::pyload::config::step::user::data_description::password%]", - "port": "[%key:component::pyload::config::step::user::data_description::port%]", - "ssl": "[%key:component::pyload::config::step::user::data_description::ssl%]" + "verify_ssl": "[%key:component::pyload::config::step::user::data_description::verify_ssl%]" } }, "reauth_confirm": { diff --git a/tests/components/pyload/conftest.py b/tests/components/pyload/conftest.py index 46144771cc1..9b410a5fdd6 100644 --- a/tests/components/pyload/conftest.py +++ b/tests/components/pyload/conftest.py @@ -12,6 +12,7 @@ from homeassistant.const import ( CONF_PASSWORD, CONF_PORT, CONF_SSL, + CONF_URL, CONF_USERNAME, CONF_VERIFY_SSL, ) @@ -19,10 +20,8 @@ from homeassistant.const import ( from tests.common import MockConfigEntry USER_INPUT = { - CONF_HOST: "pyload.local", + CONF_URL: "https://pyload.local:8000/prefix", CONF_PASSWORD: "test-password", - CONF_PORT: 8000, - CONF_SSL: True, CONF_USERNAME: "test-username", CONF_VERIFY_SSL: False, } @@ -33,10 +32,8 @@ REAUTH_INPUT = { } NEW_INPUT = { - CONF_HOST: "pyload.local", + CONF_URL: "https://pyload.local:8000/prefix", CONF_PASSWORD: "new-password", - CONF_PORT: 8000, - CONF_SSL: True, CONF_USERNAME: "new-username", CONF_VERIFY_SSL: False, } @@ -97,5 +94,28 @@ def mock_pyloadapi() -> Generator[MagicMock]: def mock_config_entry() -> MockConfigEntry: """Mock pyLoad configuration entry.""" return MockConfigEntry( - domain=DOMAIN, title=DEFAULT_NAME, data=USER_INPUT, entry_id="XXXXXXXXXXXXXX" + domain=DOMAIN, + title=DEFAULT_NAME, + data=USER_INPUT, + entry_id="XXXXXXXXXXXXXX", + ) + + +@pytest.fixture(name="config_entry_migrate") +def mock_config_entry_migrate() -> MockConfigEntry: + """Mock pyLoad configuration entry for migration.""" + return MockConfigEntry( + domain=DOMAIN, + title=DEFAULT_NAME, + data={ + CONF_HOST: "pyload.local", + CONF_PASSWORD: "test-password", + CONF_PORT: 8000, + CONF_SSL: True, + CONF_USERNAME: "test-username", + CONF_VERIFY_SSL: False, + }, + version=1, + minor_version=0, + entry_id="XXXXXXXXXXXXXX", ) diff --git a/tests/components/pyload/snapshots/test_diagnostics.ambr b/tests/components/pyload/snapshots/test_diagnostics.ambr index e2b51ad184a..81a5d750bc0 100644 --- a/tests/components/pyload/snapshots/test_diagnostics.ambr +++ b/tests/components/pyload/snapshots/test_diagnostics.ambr @@ -2,10 +2,8 @@ # name: test_diagnostics dict({ 'config_entry_data': dict({ - 'host': '**REDACTED**', 'password': '**REDACTED**', - 'port': 8000, - 'ssl': True, + 'url': 'https://**redacted**:8000/prefix', 'username': '**REDACTED**', 'verify_ssl': False, }), diff --git a/tests/components/pyload/test_init.py b/tests/components/pyload/test_init.py index 00b1f0aa3a8..5c85979b9df 100644 --- a/tests/components/pyload/test_init.py +++ b/tests/components/pyload/test_init.py @@ -8,6 +8,7 @@ from pyloadapi.exceptions import CannotConnect, InvalidAuth, ParserError import pytest from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState +from homeassistant.const import CONF_PATH, CONF_URL from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry, async_fire_time_changed @@ -88,3 +89,22 @@ async def test_coordinator_update_invalid_auth( await hass.async_block_till_done() assert any(config_entry.async_get_active_flows(hass, {SOURCE_REAUTH})) + + +@pytest.mark.usefixtures("mock_pyloadapi") +async def test_migration( + hass: HomeAssistant, + config_entry_migrate: MockConfigEntry, +) -> None: + """Test config entry migration.""" + + config_entry_migrate.add_to_hass(hass) + assert config_entry_migrate.data.get(CONF_PATH) is None + + await hass.config_entries.async_setup(config_entry_migrate.entry_id) + await hass.async_block_till_done() + + assert config_entry_migrate.state is ConfigEntryState.LOADED + assert config_entry_migrate.version == 1 + assert config_entry_migrate.minor_version == 1 + assert config_entry_migrate.data[CONF_URL] == "https://pyload.local:8000/" From 8d6178ffa6ddc93b03bd75e1b4cd2b66acdba2b1 Mon Sep 17 00:00:00 2001 From: MarioZG Date: Sun, 2 Mar 2025 15:48:57 +0000 Subject: [PATCH 2097/3148] Add last updated attribute to UK transport train sensor (#139352) added last updated attribute to train sensor Co-authored-by: Franck Nijhof --- homeassistant/components/uk_transport/sensor.py | 6 +++++- tests/components/uk_transport/test_sensor.py | 2 ++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/uk_transport/sensor.py b/homeassistant/components/uk_transport/sensor.py index b06d0e24891..594d46c74ab 100644 --- a/homeassistant/components/uk_transport/sensor.py +++ b/homeassistant/components/uk_transport/sensor.py @@ -32,6 +32,7 @@ ATTR_NEXT_BUSES = "next_buses" ATTR_STATION_CODE = "station_code" ATTR_CALLING_AT = "calling_at" ATTR_NEXT_TRAINS = "next_trains" +ATTR_LAST_UPDATED = "last_updated" CONF_API_APP_KEY = "app_key" CONF_API_APP_ID = "app_id" @@ -199,7 +200,9 @@ class UkTransportLiveBusTimeSensor(UkTransportSensor): def extra_state_attributes(self) -> dict[str, Any] | None: """Return other details about the sensor state.""" if self._data is not None: - attrs = {ATTR_NEXT_BUSES: self._next_buses} + attrs = { + ATTR_NEXT_BUSES: self._next_buses, + } for key in ( ATTR_ATCOCODE, ATTR_LOCALITY, @@ -272,6 +275,7 @@ class UkTransportLiveTrainTimeSensor(UkTransportSensor): attrs = { ATTR_STATION_CODE: self._station_code, ATTR_CALLING_AT: self._calling_at, + ATTR_LAST_UPDATED: self._data[ATTR_REQUEST_TIME], } if self._next_trains: attrs[ATTR_NEXT_TRAINS] = self._next_trains diff --git a/tests/components/uk_transport/test_sensor.py b/tests/components/uk_transport/test_sensor.py index a4a9aea18c8..ba547c5eecc 100644 --- a/tests/components/uk_transport/test_sensor.py +++ b/tests/components/uk_transport/test_sensor.py @@ -8,6 +8,7 @@ import requests_mock from homeassistant.components.uk_transport.sensor import ( ATTR_ATCOCODE, ATTR_CALLING_AT, + ATTR_LAST_UPDATED, ATTR_LOCALITY, ATTR_NEXT_BUSES, ATTR_NEXT_TRAINS, @@ -90,3 +91,4 @@ async def test_train(hass: HomeAssistant) -> None: == "London Waterloo" ) assert train_state.attributes[ATTR_NEXT_TRAINS][0]["estimated"] == "06:13" + assert train_state.attributes[ATTR_LAST_UPDATED] == "2017-07-10T06:10:05+01:00" From 4c8a58f7cc4135ccbb578145615c607fc26fb5ba Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 2 Mar 2025 08:50:35 -0700 Subject: [PATCH 2098/3148] Fix broken link in ESPHome BLE repair (#139639) ESPHome always uses .0 in the URL for the changelog, and we never had a patch version in the stable BLE version field so we need to switch it to .0 for the URL. --- homeassistant/components/esphome/const.py | 4 +++- tests/components/esphome/test_manager.py | 3 ++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/esphome/const.py b/homeassistant/components/esphome/const.py index a31f5441dbb..18d15d0fbbd 100644 --- a/homeassistant/components/esphome/const.py +++ b/homeassistant/components/esphome/const.py @@ -19,6 +19,8 @@ STABLE_BLE_VERSION = AwesomeVersion(STABLE_BLE_VERSION_STR) PROJECT_URLS = { "esphome.bluetooth-proxy": "https://esphome.github.io/bluetooth-proxies/", } -DEFAULT_URL = f"https://esphome.io/changelog/{STABLE_BLE_VERSION_STR}.html" +# ESPHome always uses .0 for the changelog URL +STABLE_BLE_URL_VERSION = f"{STABLE_BLE_VERSION.major}.{STABLE_BLE_VERSION.minor}.0" +DEFAULT_URL = f"https://esphome.io/changelog/{STABLE_BLE_URL_VERSION}.html" DATA_FFMPEG_PROXY = f"{DOMAIN}.ffmpeg_proxy" diff --git a/tests/components/esphome/test_manager.py b/tests/components/esphome/test_manager.py index ddb1babd8a4..905a3f6bdc7 100644 --- a/tests/components/esphome/test_manager.py +++ b/tests/components/esphome/test_manager.py @@ -29,6 +29,7 @@ from homeassistant.components.esphome.const import ( CONF_DEVICE_NAME, CONF_SUBSCRIBE_LOGS, DOMAIN, + STABLE_BLE_URL_VERSION, STABLE_BLE_VERSION_STR, ) from homeassistant.const import ( @@ -366,7 +367,7 @@ async def test_esphome_device_with_old_bluetooth( ) assert ( issue.learn_more_url - == f"https://esphome.io/changelog/{STABLE_BLE_VERSION_STR}.html" + == f"https://esphome.io/changelog/{STABLE_BLE_URL_VERSION}.html" ) From d006d33dc0b940aecbf3bdf4526b1e1d62aaf9b7 Mon Sep 17 00:00:00 2001 From: mvn23 Date: Sun, 2 Mar 2025 16:52:25 +0100 Subject: [PATCH 2099/3148] Remove deprecated device migration from opentherm_gw (#139612) --- .../components/opentherm_gw/__init__.py | 17 ------ tests/components/opentherm_gw/test_init.py | 53 ------------------- 2 files changed, 70 deletions(-) diff --git a/homeassistant/components/opentherm_gw/__init__.py b/homeassistant/components/opentherm_gw/__init__.py index f16e9f186be..8a0a2412c25 100644 --- a/homeassistant/components/opentherm_gw/__init__.py +++ b/homeassistant/components/opentherm_gw/__init__.py @@ -87,23 +87,6 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b gateway = OpenThermGatewayHub(hass, config_entry) hass.data[DATA_OPENTHERM_GW][DATA_GATEWAYS][config_entry.data[CONF_ID]] = gateway - # Migration can be removed in 2025.4.0 - dev_reg = dr.async_get(hass) - if ( - migrate_device := dev_reg.async_get_device( - {(DOMAIN, config_entry.data[CONF_ID])} - ) - ) is not None: - dev_reg.async_update_device( - migrate_device.id, - new_identifiers={ - ( - DOMAIN, - f"{config_entry.data[CONF_ID]}-{OpenThermDeviceIdentifier.GATEWAY}", - ) - }, - ) - # Migration can be removed in 2025.4.0 ent_reg = er.async_get(hass) if ( diff --git a/tests/components/opentherm_gw/test_init.py b/tests/components/opentherm_gw/test_init.py index 4085e25c614..e97e6d87f7c 100644 --- a/tests/components/opentherm_gw/test_init.py +++ b/tests/components/opentherm_gw/test_init.py @@ -71,59 +71,6 @@ async def test_device_registry_update( assert gw_dev.sw_version == VERSION_NEW -# Device migration test can be removed in 2025.4.0 -async def test_device_migration( - hass: HomeAssistant, - device_registry: dr.DeviceRegistry, - mock_config_entry: MockConfigEntry, - mock_pyotgw: MagicMock, -) -> None: - """Test that the device registry is updated correctly.""" - mock_config_entry.add_to_hass(hass) - - device_registry.async_get_or_create( - config_entry_id=mock_config_entry.entry_id, - identifiers={ - (DOMAIN, MOCK_GATEWAY_ID), - }, - name="Mock Gateway", - manufacturer="Schelte Bron", - model="OpenTherm Gateway", - sw_version=VERSION_TEST, - ) - - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - assert ( - device_registry.async_get_device(identifiers={(DOMAIN, MOCK_GATEWAY_ID)}) - is None - ) - - gw_dev = device_registry.async_get_device( - identifiers={(DOMAIN, f"{MOCK_GATEWAY_ID}-{OpenThermDeviceIdentifier.GATEWAY}")} - ) - assert gw_dev is not None - - assert ( - device_registry.async_get_device( - identifiers={ - (DOMAIN, f"{MOCK_GATEWAY_ID}-{OpenThermDeviceIdentifier.BOILER}") - } - ) - is not None - ) - - assert ( - device_registry.async_get_device( - identifiers={ - (DOMAIN, f"{MOCK_GATEWAY_ID}-{OpenThermDeviceIdentifier.THERMOSTAT}") - } - ) - is not None - ) - - # Entity migration test can be removed in 2025.4.0 async def test_climate_entity_migration( hass: HomeAssistant, From de4540c68e3e52f45f2542b59b0b69f97163e826 Mon Sep 17 00:00:00 2001 From: mvn23 Date: Sun, 2 Mar 2025 17:28:11 +0100 Subject: [PATCH 2100/3148] Remove deprecated entity migration from opentherm_gw (#139641) --- .../components/opentherm_gw/__init__.py | 19 +----------- tests/components/opentherm_gw/test_init.py | 29 +------------------ 2 files changed, 2 insertions(+), 46 deletions(-) diff --git a/homeassistant/components/opentherm_gw/__init__.py b/homeassistant/components/opentherm_gw/__init__.py index 8a0a2412c25..87da159872d 100644 --- a/homeassistant/components/opentherm_gw/__init__.py +++ b/homeassistant/components/opentherm_gw/__init__.py @@ -9,7 +9,6 @@ import pyotgw.vars as gw_vars from serial import SerialException import voluptuous as vol -from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_DATE, @@ -25,11 +24,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import ( - config_validation as cv, - device_registry as dr, - entity_registry as er, -) +from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.dispatcher import async_dispatcher_send from .const import ( @@ -87,18 +82,6 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b gateway = OpenThermGatewayHub(hass, config_entry) hass.data[DATA_OPENTHERM_GW][DATA_GATEWAYS][config_entry.data[CONF_ID]] = gateway - # Migration can be removed in 2025.4.0 - ent_reg = er.async_get(hass) - if ( - entity_id := ent_reg.async_get_entity_id( - CLIMATE_DOMAIN, DOMAIN, config_entry.data[CONF_ID] - ) - ) is not None: - ent_reg.async_update_entity( - entity_id, - new_unique_id=f"{config_entry.data[CONF_ID]}-{OpenThermDeviceIdentifier.THERMOSTAT}-thermostat_entity", - ) - config_entry.add_update_listener(options_updated) try: diff --git a/tests/components/opentherm_gw/test_init.py b/tests/components/opentherm_gw/test_init.py index e97e6d87f7c..84629137ce1 100644 --- a/tests/components/opentherm_gw/test_init.py +++ b/tests/components/opentherm_gw/test_init.py @@ -8,9 +8,8 @@ from homeassistant.components.opentherm_gw.const import ( DOMAIN, OpenThermDeviceIdentifier, ) -from homeassistant.const import CONF_ID from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers import device_registry as dr from .conftest import MOCK_GATEWAY_ID, VERSION_TEST @@ -69,29 +68,3 @@ async def test_device_registry_update( ) assert gw_dev is not None assert gw_dev.sw_version == VERSION_NEW - - -# Entity migration test can be removed in 2025.4.0 -async def test_climate_entity_migration( - hass: HomeAssistant, - entity_registry: er.EntityRegistry, - mock_config_entry: MockConfigEntry, - mock_pyotgw: MagicMock, -) -> None: - """Test that the climate entity unique_id gets migrated correctly.""" - mock_config_entry.add_to_hass(hass) - entry = entity_registry.async_get_or_create( - domain="climate", - platform="opentherm_gw", - unique_id=mock_config_entry.data[CONF_ID], - ) - - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - updated_entry = entity_registry.async_get(entry.entity_id) - assert updated_entry is not None - assert ( - updated_entry.unique_id - == f"{mock_config_entry.data[CONF_ID]}-{OpenThermDeviceIdentifier.THERMOSTAT}-thermostat_entity" - ) From 40099547ef6dbd59caf811cafb85df276ba17ccd Mon Sep 17 00:00:00 2001 From: Simon Lamon <32477463+silamon@users.noreply.github.com> Date: Sun, 2 Mar 2025 17:36:37 +0100 Subject: [PATCH 2101/3148] Add typing/async to NMBS (#139002) * Add typing/async to NMBS * Fix tests * Boolean fields * Update homeassistant/components/nmbs/sensor.py Co-authored-by: Jorim Tielemans --------- Co-authored-by: Shay Levy Co-authored-by: Jorim Tielemans --- homeassistant/components/nmbs/__init__.py | 9 +- homeassistant/components/nmbs/config_flow.py | 50 ++++---- homeassistant/components/nmbs/const.py | 8 +- homeassistant/components/nmbs/manifest.json | 2 +- homeassistant/components/nmbs/sensor.py | 126 +++++++++---------- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/nmbs/__init__.py | 19 --- tests/components/nmbs/conftest.py | 5 +- tests/components/nmbs/test_config_flow.py | 4 +- 10 files changed, 101 insertions(+), 126 deletions(-) diff --git a/homeassistant/components/nmbs/__init__.py b/homeassistant/components/nmbs/__init__.py index 7d06baf37b6..4a2783143ca 100644 --- a/homeassistant/components/nmbs/__init__.py +++ b/homeassistant/components/nmbs/__init__.py @@ -8,6 +8,7 @@ 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 homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.typing import ConfigType from .const import DOMAIN @@ -22,13 +23,13 @@ CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the NMBS component.""" - api_client = iRail() + api_client = iRail(session=async_get_clientsession(hass)) hass.data.setdefault(DOMAIN, {}) - station_response = await hass.async_add_executor_job(api_client.get_stations) - if station_response == -1: + station_response = await api_client.get_stations() + if station_response is None: return False - hass.data[DOMAIN] = station_response["station"] + hass.data[DOMAIN] = station_response.stations return True diff --git a/homeassistant/components/nmbs/config_flow.py b/homeassistant/components/nmbs/config_flow.py index e45b2d9adeb..60ab015e22b 100644 --- a/homeassistant/components/nmbs/config_flow.py +++ b/homeassistant/components/nmbs/config_flow.py @@ -3,11 +3,13 @@ from typing import Any from pyrail import iRail +from pyrail.models import StationDetails import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import Platform from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.selector import ( BooleanSelector, SelectOptionDict, @@ -31,17 +33,15 @@ class NMBSConfigFlow(ConfigFlow, domain=DOMAIN): def __init__(self) -> None: """Initialize.""" - self.api_client = iRail() - self.stations: list[dict[str, Any]] = [] + self.stations: list[StationDetails] = [] - async def _fetch_stations(self) -> list[dict[str, Any]]: + async def _fetch_stations(self) -> list[StationDetails]: """Fetch the stations.""" - stations_response = await self.hass.async_add_executor_job( - self.api_client.get_stations - ) - if stations_response == -1: + api_client = iRail(session=async_get_clientsession(self.hass)) + stations_response = await api_client.get_stations() + if stations_response is None: raise CannotConnect("The API is currently unavailable.") - return stations_response["station"] + return stations_response.stations async def _fetch_stations_choices(self) -> list[SelectOptionDict]: """Fetch the stations options.""" @@ -50,7 +50,7 @@ class NMBSConfigFlow(ConfigFlow, domain=DOMAIN): self.stations = await self._fetch_stations() return [ - SelectOptionDict(value=station["id"], label=station["standardname"]) + SelectOptionDict(value=station.id, label=station.standard_name) for station in self.stations ] @@ -72,12 +72,12 @@ class NMBSConfigFlow(ConfigFlow, domain=DOMAIN): [station_from] = [ station for station in self.stations - if station["id"] == user_input[CONF_STATION_FROM] + if station.id == user_input[CONF_STATION_FROM] ] [station_to] = [ station for station in self.stations - if station["id"] == user_input[CONF_STATION_TO] + if station.id == user_input[CONF_STATION_TO] ] vias = "_excl_vias" if user_input.get(CONF_EXCLUDE_VIAS) else "" await self.async_set_unique_id( @@ -85,7 +85,7 @@ class NMBSConfigFlow(ConfigFlow, domain=DOMAIN): ) self._abort_if_unique_id_configured() - config_entry_name = f"Train from {station_from['standardname']} to {station_to['standardname']}" + config_entry_name = f"Train from {station_from.standard_name} to {station_to.standard_name}" return self.async_create_entry( title=config_entry_name, data=user_input, @@ -127,18 +127,18 @@ class NMBSConfigFlow(ConfigFlow, domain=DOMAIN): station_live = None for station in self.stations: if user_input[CONF_STATION_FROM] in ( - station["standardname"], - station["name"], + station.standard_name, + station.name, ): station_from = station if user_input[CONF_STATION_TO] in ( - station["standardname"], - station["name"], + station.standard_name, + station.name, ): station_to = station if CONF_STATION_LIVE in user_input and user_input[CONF_STATION_LIVE] in ( - station["standardname"], - station["name"], + station.standard_name, + station.name, ): station_live = station @@ -148,29 +148,29 @@ class NMBSConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_abort(reason="same_station") # config flow uses id and not the standard name - user_input[CONF_STATION_FROM] = station_from["id"] - user_input[CONF_STATION_TO] = station_to["id"] + user_input[CONF_STATION_FROM] = station_from.id + user_input[CONF_STATION_TO] = station_to.id if station_live: - user_input[CONF_STATION_LIVE] = station_live["id"] + user_input[CONF_STATION_LIVE] = station_live.id entity_registry = er.async_get(self.hass) prefix = "live" vias = "_excl_vias" if user_input.get(CONF_EXCLUDE_VIAS, False) else "" if entity_id := entity_registry.async_get_entity_id( Platform.SENSOR, DOMAIN, - f"{prefix}_{station_live['standardname']}_{station_from['standardname']}_{station_to['standardname']}", + f"{prefix}_{station_live.standard_name}_{station_from.standard_name}_{station_to.standard_name}", ): - new_unique_id = f"{DOMAIN}_{prefix}_{station_live['id']}_{station_from['id']}_{station_to['id']}{vias}" + new_unique_id = f"{DOMAIN}_{prefix}_{station_live.id}_{station_from.id}_{station_to.id}{vias}" entity_registry.async_update_entity( entity_id, new_unique_id=new_unique_id ) if entity_id := entity_registry.async_get_entity_id( Platform.SENSOR, DOMAIN, - f"{prefix}_{station_live['name']}_{station_from['name']}_{station_to['name']}", + f"{prefix}_{station_live.name}_{station_from.name}_{station_to.name}", ): - new_unique_id = f"{DOMAIN}_{prefix}_{station_live['id']}_{station_from['id']}_{station_to['id']}{vias}" + new_unique_id = f"{DOMAIN}_{prefix}_{station_live.id}_{station_from.id}_{station_to.id}{vias}" entity_registry.async_update_entity( entity_id, new_unique_id=new_unique_id ) diff --git a/homeassistant/components/nmbs/const.py b/homeassistant/components/nmbs/const.py index fddb7365501..04c8beb327d 100644 --- a/homeassistant/components/nmbs/const.py +++ b/homeassistant/components/nmbs/const.py @@ -19,11 +19,7 @@ CONF_SHOW_ON_MAP = "show_on_map" def find_station_by_name(hass: HomeAssistant, station_name: str): """Find given station_name in the station list.""" return next( - ( - s - for s in hass.data[DOMAIN] - if station_name in (s["standardname"], s["name"]) - ), + (s for s in hass.data[DOMAIN] if station_name in (s.standard_name, s.name)), None, ) @@ -31,6 +27,6 @@ def find_station_by_name(hass: HomeAssistant, station_name: str): def find_station(hass: HomeAssistant, station_name: str): """Find given station_id in the station list.""" return next( - (s for s in hass.data[DOMAIN] if station_name in s["id"]), + (s for s in hass.data[DOMAIN] if station_name in s.id), None, ) diff --git a/homeassistant/components/nmbs/manifest.json b/homeassistant/components/nmbs/manifest.json index 9016eff11f8..37ff9429a54 100644 --- a/homeassistant/components/nmbs/manifest.json +++ b/homeassistant/components/nmbs/manifest.json @@ -7,5 +7,5 @@ "iot_class": "cloud_polling", "loggers": ["pyrail"], "quality_scale": "legacy", - "requirements": ["pyrail==0.0.3"] + "requirements": ["pyrail==0.4.1"] } diff --git a/homeassistant/components/nmbs/sensor.py b/homeassistant/components/nmbs/sensor.py index c6dea2d0843..822b0236dd0 100644 --- a/homeassistant/components/nmbs/sensor.py +++ b/homeassistant/components/nmbs/sensor.py @@ -2,10 +2,12 @@ from __future__ import annotations +from datetime import datetime import logging from typing import Any from pyrail import iRail +from pyrail.models import ConnectionDetails, LiveboardDeparture, StationDetails import voluptuous as vol from homeassistant.components.sensor import ( @@ -23,6 +25,7 @@ from homeassistant.const import ( ) from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, @@ -44,8 +47,6 @@ from .const import ( # noqa: F401 _LOGGER = logging.getLogger(__name__) -API_FAILURE = -1 - DEFAULT_NAME = "NMBS" DEFAULT_ICON = "mdi:train" @@ -63,12 +64,12 @@ PLATFORM_SCHEMA = SENSOR_PLATFORM_SCHEMA.extend( ) -def get_time_until(departure_time=None): +def get_time_until(departure_time: datetime | None = None): """Calculate the time between now and a train's departure time.""" if departure_time is None: return 0 - delta = dt_util.utc_from_timestamp(int(departure_time)) - dt_util.now() + delta = dt_util.as_utc(departure_time) - dt_util.utcnow() return round(delta.total_seconds() / 60) @@ -77,11 +78,9 @@ def get_delay_in_minutes(delay=0): return round(int(delay) / 60) -def get_ride_duration(departure_time, arrival_time, delay=0): +def get_ride_duration(departure_time: datetime, arrival_time: datetime, delay=0): """Calculate the total travel time in minutes.""" - duration = dt_util.utc_from_timestamp( - int(arrival_time) - ) - dt_util.utc_from_timestamp(int(departure_time)) + duration = arrival_time - departure_time duration_time = int(round(duration.total_seconds() / 60)) return duration_time + get_delay_in_minutes(delay) @@ -157,7 +156,7 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up NMBS sensor entities based on a config entry.""" - api_client = iRail() + api_client = iRail(session=async_get_clientsession(hass)) name = config_entry.data.get(CONF_NAME, None) show_on_map = config_entry.data.get(CONF_SHOW_ON_MAP, False) @@ -189,9 +188,9 @@ class NMBSLiveBoard(SensorEntity): def __init__( self, api_client: iRail, - live_station: dict[str, Any], - station_from: dict[str, Any], - station_to: dict[str, Any], + live_station: StationDetails, + station_from: StationDetails, + station_to: StationDetails, excl_vias: bool, ) -> None: """Initialize the sensor for getting liveboard data.""" @@ -201,7 +200,8 @@ class NMBSLiveBoard(SensorEntity): self._station_to = station_to self._excl_vias = excl_vias - self._attrs: dict[str, Any] | None = {} + self._attrs: LiveboardDeparture | None = None + self._state: str | None = None self.entity_registry_enabled_default = False @@ -209,22 +209,20 @@ class NMBSLiveBoard(SensorEntity): @property def name(self) -> str: """Return the sensor default name.""" - return f"Trains in {self._station['standardname']}" + return f"Trains in {self._station.standard_name}" @property def unique_id(self) -> str: """Return the unique ID.""" - unique_id = ( - f"{self._station['id']}_{self._station_from['id']}_{self._station_to['id']}" - ) + unique_id = f"{self._station.id}_{self._station_from.id}_{self._station_to.id}" vias = "_excl_vias" if self._excl_vias else "" return f"nmbs_live_{unique_id}{vias}" @property def icon(self) -> str: """Return the default icon or an alert icon if delays.""" - if self._attrs and int(self._attrs["delay"]) > 0: + if self._attrs and int(self._attrs.delay) > 0: return DEFAULT_ICON_ALERT return DEFAULT_ICON @@ -240,15 +238,15 @@ class NMBSLiveBoard(SensorEntity): if self._state is None or not self._attrs: return None - delay = get_delay_in_minutes(self._attrs["delay"]) - departure = get_time_until(self._attrs["time"]) + delay = get_delay_in_minutes(self._attrs.delay) + departure = get_time_until(self._attrs.time) attrs = { "departure": f"In {departure} minutes", "departure_minutes": departure, - "extra_train": int(self._attrs["isExtra"]) > 0, - "vehicle_id": self._attrs["vehicle"], - "monitored_station": self._station["standardname"], + "extra_train": self._attrs.is_extra, + "vehicle_id": self._attrs.vehicle, + "monitored_station": self._station.standard_name, } if delay > 0: @@ -257,28 +255,26 @@ class NMBSLiveBoard(SensorEntity): return attrs - def update(self) -> None: + async def async_update(self, **kwargs: Any) -> None: """Set the state equal to the next departure.""" - liveboard = self._api_client.get_liveboard(self._station["id"]) + liveboard = await self._api_client.get_liveboard(self._station.id) - if liveboard == API_FAILURE: + if liveboard is None: _LOGGER.warning("API failed in NMBSLiveBoard") return - if not (departures := liveboard.get("departures")): + if not (departures := liveboard.departures): _LOGGER.warning("API returned invalid departures: %r", liveboard) return _LOGGER.debug("API returned departures: %r", departures) - if departures["number"] == "0": + if len(departures) == 0: # No trains are scheduled return - next_departure = departures["departure"][0] + next_departure = departures[0] self._attrs = next_departure - self._state = ( - f"Track {next_departure['platform']} - {next_departure['station']}" - ) + self._state = f"Track {next_departure.platform} - {next_departure.station}" class NMBSSensor(SensorEntity): @@ -292,8 +288,8 @@ class NMBSSensor(SensorEntity): api_client: iRail, name: str, show_on_map: bool, - station_from: dict[str, Any], - station_to: dict[str, Any], + station_from: StationDetails, + station_to: StationDetails, excl_vias: bool, ) -> None: """Initialize the NMBS connection sensor.""" @@ -304,13 +300,13 @@ class NMBSSensor(SensorEntity): self._station_to = station_to self._excl_vias = excl_vias - self._attrs: dict[str, Any] | None = {} + self._attrs: ConnectionDetails | None = None self._state = None @property def unique_id(self) -> str: """Return the unique ID.""" - unique_id = f"{self._station_from['id']}_{self._station_to['id']}" + unique_id = f"{self._station_from.id}_{self._station_to.id}" vias = "_excl_vias" if self._excl_vias else "" return f"nmbs_connection_{unique_id}{vias}" @@ -319,14 +315,14 @@ class NMBSSensor(SensorEntity): def name(self) -> str: """Return the name of the sensor.""" if self._name is None: - return f"Train from {self._station_from['standardname']} to {self._station_to['standardname']}" + return f"Train from {self._station_from.standard_name} to {self._station_to.standard_name}" return self._name @property def icon(self) -> str: """Return the sensor default icon or an alert icon if any delay.""" if self._attrs: - delay = get_delay_in_minutes(self._attrs["departure"]["delay"]) + delay = get_delay_in_minutes(self._attrs.departure.delay) if delay > 0: return "mdi:alert-octagon" @@ -338,19 +334,19 @@ class NMBSSensor(SensorEntity): if self._state is None or not self._attrs: return None - delay = get_delay_in_minutes(self._attrs["departure"]["delay"]) - departure = get_time_until(self._attrs["departure"]["time"]) - canceled = int(self._attrs["departure"]["canceled"]) + delay = get_delay_in_minutes(self._attrs.departure.delay) + departure = get_time_until(self._attrs.departure.time) + canceled = self._attrs.departure.canceled attrs = { - "destination": self._attrs["departure"]["station"], - "direction": self._attrs["departure"]["direction"]["name"], - "platform_arriving": self._attrs["arrival"]["platform"], - "platform_departing": self._attrs["departure"]["platform"], - "vehicle_id": self._attrs["departure"]["vehicle"], + "destination": self._attrs.departure.station, + "direction": self._attrs.departure.direction.name, + "platform_arriving": self._attrs.arrival.platform, + "platform_departing": self._attrs.departure.platform, + "vehicle_id": self._attrs.departure.vehicle, } - if canceled != 1: + if not canceled: attrs["departure"] = f"In {departure} minutes" attrs["departure_minutes"] = departure attrs["canceled"] = False @@ -364,14 +360,14 @@ class NMBSSensor(SensorEntity): attrs[ATTR_LONGITUDE] = self.station_coordinates[1] if self.is_via_connection and not self._excl_vias: - via = self._attrs["vias"]["via"][0] + via = self._attrs.vias.via[0] - attrs["via"] = via["station"] - attrs["via_arrival_platform"] = via["arrival"]["platform"] - attrs["via_transfer_platform"] = via["departure"]["platform"] + attrs["via"] = via.station + attrs["via_arrival_platform"] = via.arrival.platform + attrs["via_transfer_platform"] = via.departure.platform attrs["via_transfer_time"] = get_delay_in_minutes( - via["timebetween"] - ) + get_delay_in_minutes(via["departure"]["delay"]) + via.timebetween + ) + get_delay_in_minutes(via.departure.delay) if delay > 0: attrs["delay"] = f"{delay} minutes" @@ -390,8 +386,8 @@ class NMBSSensor(SensorEntity): if self._state is None or not self._attrs: return [] - latitude = float(self._attrs["departure"]["stationinfo"]["locationY"]) - longitude = float(self._attrs["departure"]["stationinfo"]["locationX"]) + latitude = float(self._attrs.departure.station_info.latitude) + longitude = float(self._attrs.departure.station_info.longitude) return [latitude, longitude] @property @@ -400,24 +396,24 @@ class NMBSSensor(SensorEntity): if not self._attrs: return False - return "vias" in self._attrs and int(self._attrs["vias"]["number"]) > 0 + return self._attrs.vias is not None and len(self._attrs.vias) > 0 - def update(self) -> None: + async def async_update(self, **kwargs: Any) -> None: """Set the state to the duration of a connection.""" - connections = self._api_client.get_connections( - self._station_from["id"], self._station_to["id"] + connections = await self._api_client.get_connections( + self._station_from.id, self._station_to.id ) - if connections == API_FAILURE: + if connections is None: _LOGGER.warning("API failed in NMBSSensor") return - if not (connection := connections.get("connection")): + if not (connection := connections.connections): _LOGGER.warning("API returned invalid connection: %r", connections) return _LOGGER.debug("API returned connection: %r", connection) - if int(connection[0]["departure"]["left"]) > 0: + if connection[0].departure.left: next_connection = connection[1] else: next_connection = connection[0] @@ -431,9 +427,9 @@ class NMBSSensor(SensorEntity): return duration = get_ride_duration( - next_connection["departure"]["time"], - next_connection["arrival"]["time"], - next_connection["departure"]["delay"], + next_connection.departure.time, + next_connection.arrival.time, + next_connection.departure.delay, ) self._state = duration diff --git a/requirements_all.txt b/requirements_all.txt index 696aef8b03b..5d274a3ba6a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2244,7 +2244,7 @@ pyqvrpro==0.52 pyqwikswitch==0.93 # homeassistant.components.nmbs -pyrail==0.0.3 +pyrail==0.4.1 # homeassistant.components.rainbird pyrainbird==6.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b9509b7fac3..19e143e3975 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1834,7 +1834,7 @@ pyps4-2ndscreen==1.3.1 pyqwikswitch==0.93 # homeassistant.components.nmbs -pyrail==0.0.3 +pyrail==0.4.1 # homeassistant.components.rainbird pyrainbird==6.0.1 diff --git a/tests/components/nmbs/__init__.py b/tests/components/nmbs/__init__.py index 91226950aba..3d284e5bb77 100644 --- a/tests/components/nmbs/__init__.py +++ b/tests/components/nmbs/__init__.py @@ -1,20 +1 @@ """Tests for the NMBS integration.""" - -import json -from typing import Any - -from tests.common import load_fixture - - -def mock_api_unavailable() -> dict[str, Any]: - """Mock for unavailable api.""" - return -1 - - -def mock_station_response() -> dict[str, Any]: - """Mock for valid station response.""" - dummy_stations_response: dict[str, Any] = json.loads( - load_fixture("stations.json", "nmbs") - ) - - return dummy_stations_response diff --git a/tests/components/nmbs/conftest.py b/tests/components/nmbs/conftest.py index 69200fc4c98..a39334ba62c 100644 --- a/tests/components/nmbs/conftest.py +++ b/tests/components/nmbs/conftest.py @@ -3,6 +3,7 @@ from collections.abc import Generator from unittest.mock import AsyncMock, patch +from pyrail.models import StationsApiResponse import pytest from homeassistant.components.nmbs.const import ( @@ -38,8 +39,8 @@ def mock_nmbs_client() -> Generator[AsyncMock]: ), ): client = mock_client.return_value - client.get_stations.return_value = load_json_object_fixture( - "stations.json", DOMAIN + client.get_stations.return_value = StationsApiResponse.from_dict( + load_json_object_fixture("stations.json", DOMAIN) ) yield client diff --git a/tests/components/nmbs/test_config_flow.py b/tests/components/nmbs/test_config_flow.py index ff4c5bdf72a..7e0f087607b 100644 --- a/tests/components/nmbs/test_config_flow.py +++ b/tests/components/nmbs/test_config_flow.py @@ -142,7 +142,7 @@ async def test_unavailable_api( hass: HomeAssistant, mock_nmbs_client: AsyncMock ) -> None: """Test starting a flow by user and api is unavailable.""" - mock_nmbs_client.get_stations.return_value = -1 + mock_nmbs_client.get_stations.return_value = None result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER}, @@ -203,7 +203,7 @@ async def test_unavailable_api_import( hass: HomeAssistant, mock_nmbs_client: AsyncMock ) -> None: """Test starting a flow by import and api is unavailable.""" - mock_nmbs_client.get_stations.return_value = -1 + mock_nmbs_client.get_stations.return_value = None result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_IMPORT}, From 1226354823913b646667d3126b06761f43fc662e Mon Sep 17 00:00:00 2001 From: mvn23 Date: Sun, 2 Mar 2025 17:37:48 +0100 Subject: [PATCH 2102/3148] Finish removing import from configuration.yaml support from opentherm_gw (#139643) --- .../components/opentherm_gw/config_flow.py | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/homeassistant/components/opentherm_gw/config_flow.py b/homeassistant/components/opentherm_gw/config_flow.py index bcbf279f3f7..a100dcb730f 100644 --- a/homeassistant/components/opentherm_gw/config_flow.py +++ b/homeassistant/components/opentherm_gw/config_flow.py @@ -95,19 +95,6 @@ class OpenThermGwConfigFlow(ConfigFlow, domain=DOMAIN): """Handle manual initiation of the config flow.""" return await self.async_step_init(user_input) - # Deprecated import from configuration.yaml, can be removed in 2025.4.0 - async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult: - """Import an OpenTherm Gateway device as a config entry. - - This flow is triggered by `async_setup` for configured devices. - """ - formatted_config = { - CONF_NAME: import_data.get(CONF_NAME, import_data[CONF_ID]), - CONF_DEVICE: import_data[CONF_DEVICE], - CONF_ID: import_data[CONF_ID], - } - return await self.async_step_init(info=formatted_config) - def _show_form(self, errors: dict[str, str] | None = None) -> ConfigFlowResult: """Show the config flow form with possible errors.""" return self.async_show_form( From fca4ef3b1eb75af770fb5d7e01295930886dbd27 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 2 Mar 2025 19:52:37 +0100 Subject: [PATCH 2103/3148] Fix scope comparison in SmartThings (#139652) --- homeassistant/components/smartthings/config_flow.py | 2 +- tests/components/smartthings/test_config_flow.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/smartthings/config_flow.py b/homeassistant/components/smartthings/config_flow.py index 0ad1b5553b1..02b11b190c9 100644 --- a/homeassistant/components/smartthings/config_flow.py +++ b/homeassistant/components/smartthings/config_flow.py @@ -34,7 +34,7 @@ class SmartThingsConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN): async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResult: """Create an entry for SmartThings.""" - if data[CONF_TOKEN]["scope"].split() != SCOPES: + if not set(data[CONF_TOKEN]["scope"].split()) >= set(SCOPES): return self.async_abort(reason="missing_scopes") client = SmartThings(session=async_get_clientsession(self.hass)) client.authenticate(data[CONF_TOKEN][CONF_ACCESS_TOKEN]) diff --git a/tests/components/smartthings/test_config_flow.py b/tests/components/smartthings/test_config_flow.py index 858384db0b6..a16747c1190 100644 --- a/tests/components/smartthings/test_config_flow.py +++ b/tests/components/smartthings/test_config_flow.py @@ -261,7 +261,7 @@ async def test_reauthentication( "expires_in": 82806, "scope": "r:devices:* w:devices:* x:devices:* r:hubs:* " "r:locations:* w:locations:* x:locations:* " - "r:scenes:* x:scenes:* r:rules:* w:rules:* sse", + "r:scenes:* x:scenes:* r:rules:* sse w:rules:*", "access_tier": 0, "installed_app_id": "5aaaa925-2be1-4e40-b257-e4ef59083324", }, @@ -279,7 +279,7 @@ async def test_reauthentication( "expires_in": 82806, "scope": "r:devices:* w:devices:* x:devices:* r:hubs:* " "r:locations:* w:locations:* x:locations:* " - "r:scenes:* x:scenes:* r:rules:* w:rules:* sse", + "r:scenes:* x:scenes:* r:rules:* sse w:rules:*", "access_tier": 0, "installed_app_id": "5aaaa925-2be1-4e40-b257-e4ef59083324", } From 05e23f0fc70bb6ecbd5cbe7252a0650da90b95d9 Mon Sep 17 00:00:00 2001 From: martin12as <86385658+martin12as@users.noreply.github.com> Date: Sun, 2 Mar 2025 16:00:05 -0300 Subject: [PATCH 2104/3148] Add nut commands to turn off/on outlet 1 & 2 (#139044) * Update const.py * Update strings.json * Update homeassistant/components/nut/strings.json Co-authored-by: tdfountain <174762217+tdfountain@users.noreply.github.com> * Update homeassistant/components/nut/strings.json Co-authored-by: tdfountain <174762217+tdfountain@users.noreply.github.com> --------- Co-authored-by: tdfountain <174762217+tdfountain@users.noreply.github.com> --- homeassistant/components/nut/const.py | 8 ++++++++ homeassistant/components/nut/strings.json | 6 +++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/nut/const.py b/homeassistant/components/nut/const.py index 6db40a910a0..924c591e783 100644 --- a/homeassistant/components/nut/const.py +++ b/homeassistant/components/nut/const.py @@ -63,6 +63,10 @@ COMMAND_TEST_FAILURE_STOP = "test.failure.stop" COMMAND_TEST_PANEL_START = "test.panel.start" COMMAND_TEST_PANEL_STOP = "test.panel.stop" COMMAND_TEST_SYSTEM_START = "test.system.start" +COMMAND_OUTLET1_OFF = "outlet.1.load.off" +COMMAND_OUTLET1_ON = "outlet.1.load.on" +COMMAND_OUTLET2_OFF = "outlet.2.load.off" +COMMAND_OUTLET2_ON = "outlet.2.load.on" INTEGRATION_SUPPORTED_COMMANDS = { COMMAND_BEEPER_DISABLE, @@ -91,4 +95,8 @@ INTEGRATION_SUPPORTED_COMMANDS = { COMMAND_TEST_PANEL_START, COMMAND_TEST_PANEL_STOP, COMMAND_TEST_SYSTEM_START, + COMMAND_OUTLET1_OFF, + COMMAND_OUTLET1_ON, + COMMAND_OUTLET2_OFF, + COMMAND_OUTLET2_ON, } diff --git a/homeassistant/components/nut/strings.json b/homeassistant/components/nut/strings.json index b9485a320fb..4242ac9d9b2 100644 --- a/homeassistant/components/nut/strings.json +++ b/homeassistant/components/nut/strings.json @@ -74,7 +74,11 @@ "test_failure_stop": "Stop simulating a power failure", "test_panel_start": "Start testing the UPS panel", "test_panel_stop": "Stop a UPS panel test", - "test_system_start": "Start a system test" + "test_system_start": "Start a system test", + "outlet_1_load_on": "Power outlet 1 on", + "outlet_1_load_off": "Power outlet 1 off", + "outlet_2_load_on": "Power outlet 2 on", + "outlet_2_load_off": "Power outlet 1 off" } }, "entity": { From e63b17cd5832abf43fd57b757016147f69b3c1ad Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 2 Mar 2025 20:04:53 +0100 Subject: [PATCH 2105/3148] Make spelling of "All-Link" consistent in Insteon integration (#139651) "All-Link" is a fixed term in the Insteon integration that should be kept in translations. To clarify that this commit makes all occurrences in the Insteon integration consistent (plus fixing one typo). On the other end the word "database" is sentence-cased as this can be translated, just as "record" etc. Finally the description of the "Load All-Link database" action is made consistent using descriptive third-person singular as all other actions do. --- homeassistant/components/insteon/strings.json | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/insteon/strings.json b/homeassistant/components/insteon/strings.json index 538107dd816..3a15d667ca7 100644 --- a/homeassistant/components/insteon/strings.json +++ b/homeassistant/components/insteon/strings.json @@ -111,7 +111,7 @@ }, "services": { "add_all_link": { - "name": "Add all link", + "name": "Add All-Link", "description": "Tells the Insteon Modem (IM) start All-Linking mode. Once the IM is in All-Linking mode, press the link button on the device to complete All-Linking.", "fields": { "group": { @@ -120,13 +120,13 @@ }, "mode": { "name": "[%key:common::config_flow::data::mode%]", - "description": "Linking mode controller - IM is controller responder - IM is responder." + "description": "Linking mode of the Insteon Modem." } } }, "delete_all_link": { - "name": "Delete all link", - "description": "Tells the Insteon Modem (IM) to remove an All-Link record from the All-Link Database of the IM and a device. Once the IM is set to delete the link, press the link button on the corresponding device to complete the process.", + "name": "Delete All-Link", + "description": "Tells the Insteon Modem (IM) to remove an All-Link record from the All-Link database of the IM and a device. Once the IM is set to delete the link, press the link button on the corresponding device to complete the process.", "fields": { "group": { "name": "Group", @@ -135,8 +135,8 @@ } }, "load_all_link_database": { - "name": "Load all link database", - "description": "Load the All-Link Database for a device. WARNING - Loading a device All-LInk database is very time consuming and inconsistent. This may take a LONG time and may need to be repeated to obtain all records.", + "name": "Load All-Link database", + "description": "Loads the All-Link database for a device. WARNING - Loading a device All-Link database is very time consuming and inconsistent. This may take a LONG time and may need to be repeated to obtain all records.", "fields": { "entity_id": { "name": "Entity", @@ -149,8 +149,8 @@ } }, "print_all_link_database": { - "name": "Print all link database", - "description": "Prints the All-Link Database for a device. Requires that the All-Link Database is loaded into memory.", + "name": "Print All-Link database", + "description": "Prints the All-Link database for a device. Requires that the All-Link database is loaded into memory.", "fields": { "entity_id": { "name": "Entity", @@ -159,8 +159,8 @@ } }, "print_im_all_link_database": { - "name": "Print IM all link database", - "description": "Prints the All-Link Database for the INSTEON Modem (IM)." + "name": "Print IM All-Link database", + "description": "Prints the All-Link database for the INSTEON Modem (IM)." }, "x10_all_units_off": { "name": "X10 all units off", From f76e295204fa1b67bd3c625dc37552e38f8c12fd Mon Sep 17 00:00:00 2001 From: Nathan Spencer Date: Sun, 2 Mar 2025 12:24:27 -0700 Subject: [PATCH 2106/3148] Add fault event to balboa (#138623) * Add fault sensor to balboa * Use an event instead of sensor for faults * Don't set fault initially in conftest * Use event type per fault message code * Set fault to None in conftest --- homeassistant/components/balboa/__init__.py | 2 +- homeassistant/components/balboa/event.py | 91 +++++++++++++++++++ homeassistant/components/balboa/strings.json | 29 ++++++ tests/components/balboa/conftest.py | 2 + .../balboa/snapshots/test_event.ambr | 90 ++++++++++++++++++ tests/components/balboa/test_event.py | 82 +++++++++++++++++ 6 files changed, 295 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/balboa/event.py create mode 100644 tests/components/balboa/snapshots/test_event.ambr create mode 100644 tests/components/balboa/test_event.py diff --git a/homeassistant/components/balboa/__init__.py b/homeassistant/components/balboa/__init__.py index 207826d136e..54ae569bb78 100644 --- a/homeassistant/components/balboa/__init__.py +++ b/homeassistant/components/balboa/__init__.py @@ -21,6 +21,7 @@ _LOGGER = logging.getLogger(__name__) PLATFORMS = [ Platform.BINARY_SENSOR, Platform.CLIMATE, + Platform.EVENT, Platform.FAN, Platform.LIGHT, Platform.SELECT, @@ -28,7 +29,6 @@ PLATFORMS = [ Platform.TIME, ] - KEEP_ALIVE_INTERVAL = timedelta(minutes=1) SYNC_TIME_INTERVAL = timedelta(hours=1) diff --git a/homeassistant/components/balboa/event.py b/homeassistant/components/balboa/event.py new file mode 100644 index 00000000000..57263c34783 --- /dev/null +++ b/homeassistant/components/balboa/event.py @@ -0,0 +1,91 @@ +"""Support for Balboa events.""" + +from __future__ import annotations + +from datetime import datetime, timedelta + +from pybalboa import EVENT_UPDATE, SpaClient + +from homeassistant.components.event import EventEntity +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.event import async_track_time_interval + +from . import BalboaConfigEntry +from .entity import BalboaEntity + +FAULT = "fault" +FAULT_DATE = "fault_date" +REQUEST_FAULT_LOG_INTERVAL = timedelta(minutes=5) + +FAULT_MESSAGE_CODE_MAP: dict[int, str] = { + 15: "sensor_out_of_sync", + 16: "low_flow", + 17: "flow_failed", + 18: "settings_reset", + 19: "priming_mode", + 20: "clock_failed", + 21: "settings_reset", + 22: "memory_failure", + 26: "service_sensor_sync", + 27: "heater_dry", + 28: "heater_may_be_dry", + 29: "water_too_hot", + 30: "heater_too_hot", + 31: "sensor_a_fault", + 32: "sensor_b_fault", + 34: "pump_stuck", + 35: "hot_fault", + 36: "gfci_test_failed", + 37: "standby_mode", +} +FAULT_EVENT_TYPES = sorted(set(FAULT_MESSAGE_CODE_MAP.values())) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: BalboaConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the spa's events.""" + async_add_entities([BalboaEventEntity(entry.runtime_data)]) + + +class BalboaEventEntity(BalboaEntity, EventEntity): + """Representation of a Balboa event entity.""" + + _attr_event_types = FAULT_EVENT_TYPES + _attr_translation_key = FAULT + + def __init__(self, spa: SpaClient) -> None: + """Initialize a Balboa event entity.""" + super().__init__(spa, FAULT) + + @callback + def _async_handle_event(self) -> None: + """Handle the fault event.""" + if not (fault := self._client.fault): + return + fault_date = fault.fault_datetime.isoformat() + if self.state_attributes.get(FAULT_DATE) != fault_date: + self._trigger_event( + FAULT_MESSAGE_CODE_MAP.get(fault.message_code, fault.message), + {FAULT_DATE: fault_date, "code": fault.message_code}, + ) + self.async_write_ha_state() + + async def async_added_to_hass(self) -> None: + """Run when entity about to be added to hass.""" + await super().async_added_to_hass() + self.async_on_remove(self._client.on(EVENT_UPDATE, self._async_handle_event)) + + async def request_fault_log(now: datetime | None = None) -> None: + """Request the most recent fault log.""" + await self._client.request_fault_log() + + await request_fault_log() + self.async_on_remove( + async_track_time_interval( + self.hass, request_fault_log, REQUEST_FAULT_LOG_INTERVAL + ) + ) diff --git a/homeassistant/components/balboa/strings.json b/homeassistant/components/balboa/strings.json index 9779984b182..784ce8533a8 100644 --- a/homeassistant/components/balboa/strings.json +++ b/homeassistant/components/balboa/strings.json @@ -57,6 +57,35 @@ } } }, + "event": { + "fault": { + "name": "Fault", + "state_attributes": { + "event_type": { + "state": { + "sensor_out_of_sync": "Sensors are out of sync", + "low_flow": "The water flow is low", + "flow_failed": "The water flow has failed", + "settings_reset": "The settings have been reset", + "priming_mode": "Priming mode", + "clock_failed": "The clock has failed", + "memory_failure": "Program memory failure", + "service_sensor_sync": "Sensors are out of sync -- call for service", + "heater_dry": "The heater is dry", + "heater_may_be_dry": "The heater may be dry", + "water_too_hot": "The water is too hot", + "heater_too_hot": "The heater is too hot", + "sensor_a_fault": "Sensor A fault", + "sensor_b_fault": "Sensor B fault", + "pump_stuck": "A pump may be stuck on", + "hot_fault": "Hot fault", + "gfci_test_failed": "The GFCI test failed", + "standby_mode": "Standby mode (hold mode)" + } + } + } + } + }, "fan": { "pump": { "name": "Pump {index}" diff --git a/tests/components/balboa/conftest.py b/tests/components/balboa/conftest.py index 90f8fdc3d6e..18639b0c9be 100644 --- a/tests/components/balboa/conftest.py +++ b/tests/components/balboa/conftest.py @@ -68,4 +68,6 @@ def client_fixture() -> Generator[MagicMock]: client.pumps = [] client.temperature_range.state = LowHighRange.LOW + client.fault = None + yield client diff --git a/tests/components/balboa/snapshots/test_event.ambr b/tests/components/balboa/snapshots/test_event.ambr new file mode 100644 index 00000000000..fc8f591a9fc --- /dev/null +++ b/tests/components/balboa/snapshots/test_event.ambr @@ -0,0 +1,90 @@ +# serializer version: 1 +# name: test_events[event.fakespa_fault-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'clock_failed', + 'flow_failed', + 'gfci_test_failed', + 'heater_dry', + 'heater_may_be_dry', + 'heater_too_hot', + 'hot_fault', + 'low_flow', + 'memory_failure', + 'priming_mode', + 'pump_stuck', + 'sensor_a_fault', + 'sensor_b_fault', + 'sensor_out_of_sync', + 'service_sensor_sync', + 'settings_reset', + 'standby_mode', + 'water_too_hot', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.fakespa_fault', + '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': 'Fault', + 'platform': 'balboa', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'fault', + 'unique_id': 'FakeSpa-fault-c0ffee', + 'unit_of_measurement': None, + }) +# --- +# name: test_events[event.fakespa_fault-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'event_type': None, + 'event_types': list([ + 'clock_failed', + 'flow_failed', + 'gfci_test_failed', + 'heater_dry', + 'heater_may_be_dry', + 'heater_too_hot', + 'hot_fault', + 'low_flow', + 'memory_failure', + 'priming_mode', + 'pump_stuck', + 'sensor_a_fault', + 'sensor_b_fault', + 'sensor_out_of_sync', + 'service_sensor_sync', + 'settings_reset', + 'standby_mode', + 'water_too_hot', + ]), + 'friendly_name': 'FakeSpa Fault', + }), + 'context': , + 'entity_id': 'event.fakespa_fault', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/balboa/test_event.py b/tests/components/balboa/test_event.py new file mode 100644 index 00000000000..04f25f6cfa0 --- /dev/null +++ b/tests/components/balboa/test_event.py @@ -0,0 +1,82 @@ +"""Tests of the events of the balboa integration.""" + +from __future__ import annotations + +from datetime import datetime +from unittest.mock import MagicMock, patch + +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.event import ATTR_EVENT_TYPE +from homeassistant.const import STATE_UNKNOWN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import init_integration + +from tests.common import snapshot_platform + +ENTITY_EVENT = "event.fakespa_fault" +FAULT_DATE = "fault_date" + + +async def test_events( + hass: HomeAssistant, + client: MagicMock, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test spa events.""" + with patch("homeassistant.components.balboa.PLATFORMS", [Platform.EVENT]): + entry = await init_integration(hass) + + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) + + +async def test_event(hass: HomeAssistant, client: MagicMock) -> None: + """Test spa fault event.""" + await init_integration(hass) + + # check the state is unknown + state = hass.states.get(ENTITY_EVENT) + assert state.state == STATE_UNKNOWN + + # set a fault + client.fault = MagicMock( + fault_datetime=datetime(2025, 2, 15, 13, 0), message_code=16 + ) + client.emit("") + await hass.async_block_till_done() + + # check new state is what we expect + state = hass.states.get(ENTITY_EVENT) + assert state.attributes[ATTR_EVENT_TYPE] == "low_flow" + assert state.attributes[FAULT_DATE] == "2025-02-15T13:00:00" + assert state.attributes["code"] == 16 + + # set fault to None + client.fault = None + client.emit("") + await hass.async_block_till_done() + + # validate state remains unchanged + state = hass.states.get(ENTITY_EVENT) + assert state.attributes[ATTR_EVENT_TYPE] == "low_flow" + assert state.attributes[FAULT_DATE] == "2025-02-15T13:00:00" + assert state.attributes["code"] == 16 + + # set fault to an unknown one + client.fault = MagicMock( + fault_datetime=datetime(2025, 2, 15, 14, 0), message_code=-1 + ) + # validate a ValueError is raises + with pytest.raises(ValueError): + client.emit("") + await hass.async_block_till_done() + + # validate state remains unchanged + state = hass.states.get(ENTITY_EVENT) + assert state.attributes[ATTR_EVENT_TYPE] == "low_flow" + assert state.attributes[FAULT_DATE] == "2025-02-15T13:00:00" + assert state.attributes["code"] == 16 From 18b0f54a3e5dc8af0e5baab2808f53f8ccf9821f Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 2 Mar 2025 20:49:19 +0100 Subject: [PATCH 2107/3148] Fix typo in `outlet_2_load_off` of NUT integration (#139656) Fix typo in `outlet_2_load_off` Fix small copy & paste error in https://github.com/home-assistant/core/pull/139044 --- homeassistant/components/nut/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/nut/strings.json b/homeassistant/components/nut/strings.json index 4242ac9d9b2..1cd5415b0d6 100644 --- a/homeassistant/components/nut/strings.json +++ b/homeassistant/components/nut/strings.json @@ -78,7 +78,7 @@ "outlet_1_load_on": "Power outlet 1 on", "outlet_1_load_off": "Power outlet 1 off", "outlet_2_load_on": "Power outlet 2 on", - "outlet_2_load_off": "Power outlet 1 off" + "outlet_2_load_off": "Power outlet 2 off" } }, "entity": { From 387bf83ba8427bf8babd60f10201e5f37d6eaae7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 2 Mar 2025 12:53:45 -0700 Subject: [PATCH 2108/3148] Bump aioesphomeapi to 29.3.2 (#139653) changelog: https://github.com/esphome/aioesphomeapi/compare/v29.3.1...v29.3.2 --- 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 b97878d11b5..26c4b21d565 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -16,7 +16,7 @@ "loggers": ["aioesphomeapi", "noiseprotocol", "bleak_esphome"], "mqtt": ["esphome/discover/#"], "requirements": [ - "aioesphomeapi==29.3.1", + "aioesphomeapi==29.3.2", "esphome-dashboard-api==1.2.3", "bleak-esphome==2.9.0" ], diff --git a/requirements_all.txt b/requirements_all.txt index 5d274a3ba6a..45484a6f2d0 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==29.3.1 +aioesphomeapi==29.3.2 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 19e143e3975..d2be4b80bfb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -231,7 +231,7 @@ aioelectricitymaps==0.4.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==29.3.1 +aioesphomeapi==29.3.2 # homeassistant.components.flo aioflo==2021.11.0 From 8536f2b4cbcceb6c4bd4def6d37d26e5181b74fa Mon Sep 17 00:00:00 2001 From: Niklas Neesen Date: Sun, 2 Mar 2025 20:57:13 +0100 Subject: [PATCH 2109/3148] Fix vicare exception for specific ventilation device type (#138343) * fix for exception for specific ventilation device type + tests * fix for exception for specific ventilation device type + tests * New Testset just for fan * update test_sensor.ambr --- homeassistant/components/vicare/fan.py | 10 +- .../fixtures/Vitocal222G_Vitovent300W.json | 3019 +++++++++++++++++ .../components/vicare/snapshots/test_fan.ambr | 126 + tests/components/vicare/test_climate.py | 4 +- tests/components/vicare/test_fan.py | 1 + 5 files changed, 3157 insertions(+), 3 deletions(-) create mode 100644 tests/components/vicare/fixtures/Vitocal222G_Vitovent300W.json diff --git a/homeassistant/components/vicare/fan.py b/homeassistant/components/vicare/fan.py index 26136260a4b..d84b2038dde 100644 --- a/homeassistant/components/vicare/fan.py +++ b/homeassistant/components/vicare/fan.py @@ -196,7 +196,10 @@ class ViCareFan(ViCareEntity, FanEntity): @property def is_on(self) -> bool | None: """Return true if the entity is on.""" - if self._api.getVentilationQuickmode(VentilationQuickmode.STANDBY): + if ( + self._attr_supported_features & FanEntityFeature.TURN_OFF + and self._api.getVentilationQuickmode(VentilationQuickmode.STANDBY) + ): return False return self.percentage is not None and self.percentage > 0 @@ -209,7 +212,10 @@ class ViCareFan(ViCareEntity, FanEntity): @property def icon(self) -> str | None: """Return the icon to use in the frontend.""" - if self._api.getVentilationQuickmode(VentilationQuickmode.STANDBY): + if ( + self._attr_supported_features & FanEntityFeature.TURN_OFF + and self._api.getVentilationQuickmode(VentilationQuickmode.STANDBY) + ): return "mdi:fan-off" if hasattr(self, "_attr_preset_mode"): if self._attr_preset_mode == VentilationMode.VENTILATION: diff --git a/tests/components/vicare/fixtures/Vitocal222G_Vitovent300W.json b/tests/components/vicare/fixtures/Vitocal222G_Vitovent300W.json new file mode 100644 index 00000000000..a733d33a12a --- /dev/null +++ b/tests/components/vicare/fixtures/Vitocal222G_Vitovent300W.json @@ -0,0 +1,3019 @@ +{ + "data": [ + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "device.messages.errors.raw", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "entries": { + "type": "array", + "value": [] + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/device.messages.errors.raw" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "device.serial", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "string", + "value": "################" + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/device.serial" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.boiler.sensors.temperature.commonSupply", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "notConnected" + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.boiler.sensors.temperature.commonSupply" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.boiler.serial", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "string", + "value": "################" + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.boiler.serial" + }, + { + "apiVersion": 1, + "commands": {}, + "deprecated": { + "info": "replaced by heating.bufferCylinder.sensors.temperature.main", + "removalDate": "2024-09-15" + }, + "deviceId": "0", + "feature": "heating.buffer.sensors.temperature.main", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "notConnected" + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.buffer.sensors.temperature.main" + }, + { + "apiVersion": 1, + "commands": {}, + "deprecated": { + "info": "replaced by heating.bufferCylinder.sensors.temperature.top", + "removalDate": "2024-09-15" + }, + "deviceId": "0", + "feature": "heating.buffer.sensors.temperature.top", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "notConnected" + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.buffer.sensors.temperature.top" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.bufferCylinder.sensors.temperature.main", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "notConnected" + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.bufferCylinder.sensors.temperature.main" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.bufferCylinder.sensors.temperature.top", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "notConnected" + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.bufferCylinder.sensors.temperature.top" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "enabled": { + "type": "array", + "value": ["0"] + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.circulation.pump", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "on" + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.circulation.pump" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.circulation.pump", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.circulation.pump" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.circulation.pump", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.circulation.pump" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.frostprotection", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "on" + } + }, + "timestamp": "2025-02-11T20:58:18.395Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.frostprotection" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.frostprotection", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.frostprotection" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.frostprotection", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.frostprotection" + }, + { + "apiVersion": 1, + "commands": { + "setCurve": { + "isExecutable": true, + "name": "setCurve", + "params": { + "shift": { + "constraints": { + "max": 40, + "min": -15, + "stepping": 1 + }, + "required": true, + "type": "number" + }, + "slope": { + "constraints": { + "max": 3.5, + "min": 0, + "stepping": 0.1 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.heating.curve/commands/setCurve" + } + }, + "deviceId": "0", + "feature": "heating.circuits.0.heating.curve", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "shift": { + "type": "number", + "unit": "", + "value": 0 + }, + "slope": { + "type": "number", + "unit": "", + "value": 0.4 + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.heating.curve" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.heating.curve", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.heating.curve" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.heating.curve", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.heating.curve" + }, + { + "apiVersion": 1, + "commands": { + "resetSchedule": { + "isExecutable": false, + "name": "resetSchedule", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.heating.schedule/commands/resetSchedule" + }, + "setSchedule": { + "isExecutable": true, + "name": "setSchedule", + "params": { + "newSchedule": { + "constraints": { + "defaultMode": "standby", + "maxEntries": 8, + "modes": ["reduced", "normal", "fixed"], + "overlapAllowed": true, + "resolution": 10 + }, + "required": true, + "type": "Schedule" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.heating.schedule/commands/setSchedule" + } + }, + "deviceId": "0", + "feature": "heating.circuits.0.heating.schedule", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": true + }, + "entries": { + "type": "Schedule", + "value": { + "fri": [ + { + "end": "24:00", + "mode": "normal", + "position": 0, + "start": "00:00" + } + ], + "mon": [ + { + "end": "24:00", + "mode": "normal", + "position": 0, + "start": "00:00" + } + ], + "sat": [ + { + "end": "24:00", + "mode": "normal", + "position": 0, + "start": "00:00" + } + ], + "sun": [ + { + "end": "24:00", + "mode": "normal", + "position": 0, + "start": "00:00" + } + ], + "thu": [ + { + "end": "24:00", + "mode": "normal", + "position": 0, + "start": "00:00" + } + ], + "tue": [ + { + "end": "24:00", + "mode": "normal", + "position": 0, + "start": "00:00" + } + ], + "wed": [ + { + "end": "24:00", + "mode": "normal", + "position": 0, + "start": "00:00" + } + ] + } + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.heating.schedule" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.heating.schedule", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.heating.schedule" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.heating.schedule", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.heating.schedule" + }, + { + "apiVersion": 1, + "commands": { + "setMode": { + "isExecutable": true, + "name": "setMode", + "params": { + "mode": { + "constraints": { + "enum": ["dhw", "dhwAndHeating", "standby"] + }, + "required": true, + "type": "string" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.modes.active/commands/setMode" + } + }, + "deviceId": "0", + "feature": "heating.circuits.0.operating.modes.active", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "string", + "value": "dhwAndHeating" + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.modes.active" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.modes.active", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.modes.active" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.modes.active", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.modes.active" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.modes.cooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.modes.cooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.modes.cooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.modes.cooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.modes.cooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.modes.cooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.modes.dhw", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.modes.dhw" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.modes.dhw", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.modes.dhw" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.modes.dhw", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.modes.dhw" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.modes.dhwAndHeating", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": true + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.modes.dhwAndHeating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.modes.dhwAndHeating", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.modes.dhwAndHeating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.modes.dhwAndHeating", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.modes.dhwAndHeating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.modes.dhwAndHeatingCooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.modes.dhwAndHeatingCooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.modes.dhwAndHeatingCooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.modes.dhwAndHeatingCooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.modes.dhwAndHeatingCooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.modes.dhwAndHeatingCooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.modes.forcedNormal", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.modes.forcedNormal" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.modes.forcedNormal", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.modes.forcedNormal" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.modes.forcedNormal", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.modes.forcedNormal" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.modes.forcedReduced", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.modes.forcedReduced" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.modes.forcedReduced", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.modes.forcedReduced" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.modes.forcedReduced", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.modes.forcedReduced" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.modes.heating", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.modes.heating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.modes.heating", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.modes.heating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.modes.heating", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.modes.heating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.modes.heatingCooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.modes.heatingCooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.modes.heatingCooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.modes.heatingCooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.modes.heatingCooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.modes.heatingCooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.modes.normalStandby", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.modes.normalStandby" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.modes.normalStandby", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.modes.normalStandby" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.modes.normalStandby", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.modes.normalStandby" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.modes.standby", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.modes.standby" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.modes.standby", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.modes.standby" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.modes.standby", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.modes.standby" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.active", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "string", + "value": "normal" + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.active" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.active", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.active" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.active", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.active" + }, + { + "apiVersion": 1, + "commands": { + "activate": { + "isExecutable": true, + "name": "activate", + "params": { + "temperature": { + "constraints": { + "max": 30, + "min": 10, + "stepping": 1 + }, + "required": false, + "type": "number" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.comfort/commands/activate" + }, + "deactivate": { + "isExecutable": true, + "name": "deactivate", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.comfort/commands/deactivate" + }, + "setTemperature": { + "isExecutable": true, + "name": "setTemperature", + "params": { + "targetTemperature": { + "constraints": { + "max": 30, + "min": 10, + "stepping": 1 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.comfort/commands/setTemperature" + } + }, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.comfort", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + }, + "demand": { + "type": "string", + "value": "unknown" + }, + "temperature": { + "type": "number", + "unit": "celsius", + "value": 20 + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.comfort" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.comfort", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.comfort" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.comfort", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.comfort" + }, + { + "apiVersion": 1, + "commands": { + "activate": { + "isExecutable": true, + "name": "activate", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.eco/commands/activate" + }, + "deactivate": { + "isExecutable": true, + "name": "deactivate", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.eco/commands/deactivate" + } + }, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.eco", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + }, + "temperature": { + "type": "number", + "unit": "celsius", + "value": 22 + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.eco" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.eco", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.eco" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.eco", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.eco" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.fixed", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.fixed" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.fixed", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.fixed" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.fixed", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.fixed" + }, + { + "apiVersion": 1, + "commands": { + "setTemperature": { + "isExecutable": true, + "name": "setTemperature", + "params": { + "targetTemperature": { + "constraints": { + "max": 30, + "min": 10, + "stepping": 1 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.normal/commands/setTemperature" + } + }, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.normal", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": true + }, + "demand": { + "type": "string", + "value": "unknown" + }, + "temperature": { + "type": "number", + "unit": "celsius", + "value": 22 + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.normal" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.normal", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.normal" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.normal", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.normal" + }, + { + "apiVersion": 1, + "commands": { + "setTemperature": { + "isExecutable": true, + "name": "setTemperature", + "params": { + "targetTemperature": { + "constraints": { + "max": 30, + "min": 10, + "stepping": 1 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.reduced/commands/setTemperature" + } + }, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.reduced", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + }, + "demand": { + "type": "string", + "value": "unknown" + }, + "temperature": { + "type": "number", + "unit": "celsius", + "value": 20 + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.reduced" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.reduced", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.reduced" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.reduced", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.reduced" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.standby", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.standby" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.standby", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.standby" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.standby", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.standby" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.sensors.temperature.room", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.sensors.temperature.room" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.sensors.temperature.room", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.sensors.temperature.room" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.sensors.temperature.room", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.sensors.temperature.room" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.sensors.temperature.supply", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 26.3 + } + }, + "timestamp": "2025-02-11T20:49:01.456Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.sensors.temperature.supply" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.sensors.temperature.supply", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.sensors.temperature.supply" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.sensors.temperature.supply", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.sensors.temperature.supply" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.temperature", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "number", + "unit": "celsius", + "value": 33.2 + } + }, + "timestamp": "2025-02-11T19:48:05.380Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.temperature" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.temperature", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.temperature" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.temperature", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.temperature" + }, + { + "apiVersion": 1, + "commands": { + "setLevels": { + "isExecutable": true, + "name": "setLevels", + "params": { + "maxTemperature": { + "constraints": { + "max": 70, + "min": 10, + "stepping": 1 + }, + "required": true, + "type": "number" + }, + "minTemperature": { + "constraints": { + "max": 30, + "min": 1, + "stepping": 1 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.temperature.levels/commands/setLevels" + }, + "setMax": { + "isExecutable": true, + "name": "setMax", + "params": { + "temperature": { + "constraints": { + "max": 70, + "min": 10, + "stepping": 1 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.temperature.levels/commands/setMax" + }, + "setMin": { + "isExecutable": true, + "name": "setMin", + "params": { + "temperature": { + "constraints": { + "max": 30, + "min": 1, + "stepping": 1 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.temperature.levels/commands/setMin" + } + }, + "deviceId": "0", + "feature": "heating.circuits.0.temperature.levels", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "max": { + "type": "number", + "unit": "celsius", + "value": 44 + }, + "min": { + "type": "number", + "unit": "celsius", + "value": 15 + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.temperature.levels" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.temperature.levels", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.temperature.levels" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.temperature.levels", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.temperature.levels" + }, + { + "apiVersion": 1, + "commands": { + "setName": { + "isExecutable": true, + "name": "setName", + "params": { + "name": { + "constraints": { + "maxLength": 20, + "minLength": 1 + }, + "required": true, + "type": "string" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0/commands/setName" + } + }, + "deviceId": "0", + "feature": "heating.circuits.0", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": true + }, + "name": { + "type": "string", + "value": "" + }, + "type": { + "type": "string", + "value": "heatingCircuit" + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.compressors", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "enabled": { + "type": "array", + "value": ["0"] + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.compressors" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.compressors.0.statistics", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "hours": { + "type": "number", + "unit": "hour", + "value": 4332.4 + }, + "starts": { + "type": "number", + "unit": "", + "value": 21314 + } + }, + "timestamp": "2025-02-11T20:34:55.482Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.compressors.0.statistics" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.compressors.1.statistics", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.compressors.1.statistics" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.compressors.0", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + }, + "phase": { + "type": "string", + "value": "off" + } + }, + "timestamp": "2025-02-11T20:45:56.068Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.compressors.0" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.compressors.1", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.compressors.1" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.controller.serial", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "string", + "value": "################" + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.controller.serial" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": true + }, + "status": { + "type": "string", + "value": "on" + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.charging", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-02-11T19:42:36.300Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.charging" + }, + { + "apiVersion": 1, + "commands": { + "activate": { + "isExecutable": true, + "name": "activate", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.oneTimeCharge/commands/activate" + }, + "deactivate": { + "isExecutable": true, + "name": "deactivate", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.oneTimeCharge/commands/deactivate" + } + }, + "deviceId": "0", + "feature": "heating.dhw.oneTimeCharge", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.oneTimeCharge" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.pumps.circulation", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "on" + } + }, + "timestamp": "2025-02-11T19:42:36.300Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.pumps.circulation" + }, + { + "apiVersion": 1, + "commands": { + "resetSchedule": { + "isExecutable": false, + "name": "resetSchedule", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.pumps.circulation.schedule/commands/resetSchedule" + }, + "setSchedule": { + "isExecutable": true, + "name": "setSchedule", + "params": { + "newSchedule": { + "constraints": { + "defaultMode": "off", + "maxEntries": 8, + "modes": ["5/25-cycles", "5/10-cycles", "on"], + "overlapAllowed": true, + "resolution": 10 + }, + "required": true, + "type": "Schedule" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.pumps.circulation.schedule/commands/setSchedule" + } + }, + "deviceId": "0", + "feature": "heating.dhw.pumps.circulation.schedule", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": true + }, + "entries": { + "type": "Schedule", + "value": { + "fri": [ + { + "end": "22:00", + "mode": "on", + "position": 0, + "start": "06:50" + } + ], + "mon": [ + { + "end": "22:00", + "mode": "on", + "position": 0, + "start": "06:50" + } + ], + "sat": [ + { + "end": "22:00", + "mode": "on", + "position": 0, + "start": "07:30" + } + ], + "sun": [ + { + "end": "22:00", + "mode": "on", + "position": 0, + "start": "07:30" + } + ], + "thu": [ + { + "end": "22:00", + "mode": "on", + "position": 0, + "start": "06:50" + } + ], + "tue": [ + { + "end": "22:00", + "mode": "on", + "position": 0, + "start": "06:50" + } + ], + "wed": [ + { + "end": "22:00", + "mode": "on", + "position": 0, + "start": "06:50" + } + ] + } + } + }, + "timestamp": "2025-02-11T17:50:12.565Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.pumps.circulation.schedule" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.pumps.primary", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.pumps.primary" + }, + { + "apiVersion": 1, + "commands": { + "resetSchedule": { + "isExecutable": false, + "name": "resetSchedule", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.schedule/commands/resetSchedule" + }, + "setSchedule": { + "isExecutable": true, + "name": "setSchedule", + "params": { + "newSchedule": { + "constraints": { + "defaultMode": "off", + "maxEntries": 8, + "modes": ["top", "normal", "temp-2"], + "overlapAllowed": true, + "resolution": 10 + }, + "required": true, + "type": "Schedule" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.schedule/commands/setSchedule" + } + }, + "deviceId": "0", + "feature": "heating.dhw.schedule", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": true + }, + "entries": { + "type": "Schedule", + "value": { + "fri": [ + { + "end": "22:00", + "mode": "normal", + "position": 0, + "start": "06:00" + } + ], + "mon": [ + { + "end": "22:00", + "mode": "normal", + "position": 0, + "start": "06:00" + } + ], + "sat": [ + { + "end": "22:00", + "mode": "normal", + "position": 0, + "start": "06:00" + } + ], + "sun": [ + { + "end": "22:00", + "mode": "normal", + "position": 0, + "start": "06:00" + } + ], + "thu": [ + { + "end": "22:00", + "mode": "normal", + "position": 0, + "start": "06:00" + } + ], + "tue": [ + { + "end": "22:00", + "mode": "normal", + "position": 0, + "start": "06:00" + } + ], + "wed": [ + { + "end": "22:00", + "mode": "normal", + "position": 0, + "start": "06:00" + } + ] + } + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.schedule" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.sensors.temperature.dhwCylinder", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 47.9 + } + }, + "timestamp": "2025-02-11T20:39:18.305Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.sensors.temperature.dhwCylinder" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.sensors.temperature.dhwCylinder.bottom", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "notConnected" + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.sensors.temperature.dhwCylinder.bottom" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.sensors.temperature.dhwCylinder.top", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 47.9 + } + }, + "timestamp": "2025-02-11T20:39:18.305Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.sensors.temperature.dhwCylinder.top" + }, + { + "apiVersion": 1, + "commands": {}, + "deprecated": { + "info": "replaced by heating.dhw.sensors.temperature.dhwCylinder", + "removalDate": "2024-09-15" + }, + "deviceId": "0", + "feature": "heating.dhw.sensors.temperature.hotWaterStorage", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 47.9 + } + }, + "timestamp": "2025-02-11T20:39:18.305Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.sensors.temperature.hotWaterStorage" + }, + { + "apiVersion": 1, + "commands": {}, + "deprecated": { + "info": "replaced by heating.dhw.sensors.temperature.dhwCylinder.bottom", + "removalDate": "2024-09-15" + }, + "deviceId": "0", + "feature": "heating.dhw.sensors.temperature.hotWaterStorage.bottom", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "notConnected" + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.sensors.temperature.hotWaterStorage.bottom" + }, + { + "apiVersion": 1, + "commands": {}, + "deprecated": { + "info": "replaced by heating.dhw.sensors.temperature.dhwCylinder.top", + "removalDate": "2024-09-15" + }, + "deviceId": "0", + "feature": "heating.dhw.sensors.temperature.hotWaterStorage.top", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 47.9 + } + }, + "timestamp": "2025-02-11T20:39:18.305Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.sensors.temperature.hotWaterStorage.top" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.sensors.temperature.outlet", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "notConnected" + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.sensors.temperature.outlet" + }, + { + "apiVersion": 1, + "commands": { + "setHysteresis": { + "isExecutable": true, + "name": "setHysteresis", + "params": { + "hysteresis": { + "constraints": { + "max": 10, + "min": 1, + "stepping": 0.5 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.temperature.hysteresis/commands/setHysteresis" + }, + "setHysteresisSwitchOffValue": { + "isExecutable": false, + "name": "setHysteresisSwitchOffValue", + "params": { + "hysteresis": { + "constraints": { + "max": 10, + "min": 1, + "stepping": 0.5 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.temperature.hysteresis/commands/setHysteresisSwitchOffValue" + }, + "setHysteresisSwitchOnValue": { + "isExecutable": true, + "name": "setHysteresisSwitchOnValue", + "params": { + "hysteresis": { + "constraints": { + "max": 10, + "min": 1, + "stepping": 0.5 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.temperature.hysteresis/commands/setHysteresisSwitchOnValue" + } + }, + "deviceId": "0", + "feature": "heating.dhw.temperature.hysteresis", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "switchOffValue": { + "type": "number", + "unit": "kelvin", + "value": 5 + }, + "switchOnValue": { + "type": "number", + "unit": "kelvin", + "value": 5 + }, + "value": { + "type": "number", + "unit": "kelvin", + "value": 5 + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.temperature.hysteresis" + }, + { + "apiVersion": 1, + "commands": { + "setTargetTemperature": { + "isExecutable": true, + "name": "setTargetTemperature", + "params": { + "temperature": { + "constraints": { + "efficientLowerBorder": 10, + "efficientUpperBorder": 60, + "max": 60, + "min": 10, + "stepping": 1 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.temperature.main/commands/setTargetTemperature" + } + }, + "deviceId": "0", + "feature": "heating.dhw.temperature.main", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "number", + "unit": "celsius", + "value": 50 + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.temperature.main" + }, + { + "apiVersion": 1, + "commands": { + "setTargetTemperature": { + "isExecutable": true, + "name": "setTargetTemperature", + "params": { + "temperature": { + "constraints": { + "max": 60, + "min": 10, + "stepping": 1 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.temperature.temp2/commands/setTargetTemperature" + } + }, + "deviceId": "0", + "feature": "heating.dhw.temperature.temp2", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "number", + "unit": "celsius", + "value": 60 + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.temperature.temp2" + }, + { + "apiVersion": 1, + "commands": { + "changeEndDate": { + "isExecutable": false, + "name": "changeEndDate", + "params": { + "end": { + "constraints": { + "regEx": "^[\\d]{4}-[\\d]{2}-[\\d]{2}$", + "sameDayAllowed": false + }, + "required": true, + "type": "string" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.operating.programs.holiday/commands/changeEndDate" + }, + "schedule": { + "isExecutable": true, + "name": "schedule", + "params": { + "end": { + "constraints": { + "regEx": "^[\\d]{4}-[\\d]{2}-[\\d]{2}$", + "sameDayAllowed": false + }, + "required": true, + "type": "string" + }, + "start": { + "constraints": { + "regEx": "^[\\d]{4}-[\\d]{2}-[\\d]{2}$" + }, + "required": true, + "type": "string" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.operating.programs.holiday/commands/schedule" + }, + "unschedule": { + "isExecutable": true, + "name": "unschedule", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.operating.programs.holiday/commands/unschedule" + } + }, + "deviceId": "0", + "feature": "heating.operating.programs.holiday", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + }, + "end": { + "type": "string", + "value": "" + }, + "start": { + "type": "string", + "value": "" + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.operating.programs.holiday" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.primaryCircuit.sensors.temperature.return", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 6.9 + } + }, + "timestamp": "2025-02-11T20:58:31.054Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.primaryCircuit.sensors.temperature.return" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.primaryCircuit.sensors.temperature.supply", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 5.2 + } + }, + "timestamp": "2025-02-11T20:48:38.307Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.primaryCircuit.sensors.temperature.supply" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.secondaryCircuit.sensors.temperature.supply", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 26.9 + } + }, + "timestamp": "2025-02-11T20:46:37.502Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.secondaryCircuit.sensors.temperature.supply" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.sensors.temperature.outside", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 1.9 + } + }, + "timestamp": "2025-02-11T21:00:13.154Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.sensors.temperature.outside" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.sensors.temperature.return", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 26.5 + } + }, + "timestamp": "2025-02-11T20:48:00.474Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.sensors.temperature.return" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.solar", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.solar" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.solar.power.cumulativeProduced", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.solar.power.cumulativeProduced" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.solar.power.production", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.solar.power.production" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.solar.pumps.circuit", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.solar.pumps.circuit" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.solar.sensors.temperature.collector", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.solar.sensors.temperature.collector" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.solar.sensors.temperature.dhw", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.solar.sensors.temperature.dhw" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "ventilation", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": true + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "ventilation.levels.levelFour", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.levels.levelFour" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "ventilation.levels.levelOne", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.levels.levelOne" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "ventilation.levels.levelThree", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.levels.levelThree" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "ventilation.levels.levelTwo", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.levels.levelTwo" + }, + { + "apiVersion": 1, + "commands": { + "setMode": { + "isExecutable": true, + "name": "setMode", + "params": { + "mode": { + "constraints": { + "enum": ["standby", "standard", "ventilation"] + }, + "required": true, + "type": "string" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.operating.modes.active/commands/setMode" + }, + "setModeContinuousSensorOverride": { + "isExecutable": false, + "name": "setModeContinuousSensorOverride", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.operating.modes.active/commands/setModeContinuousSensorOverride" + } + }, + "deviceId": "0", + "feature": "ventilation.operating.modes.active", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "string", + "value": "ventilation" + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.operating.modes.active" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "ventilation.operating.modes.standard", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.operating.modes.standard" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "ventilation.operating.modes.ventilation", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": true + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.operating.modes.ventilation" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "ventilation.operating.programs.active", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "string", + "value": "levelThree" + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.operating.programs.active" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "ventilation.operating.state", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "demand": { + "type": "string", + "value": "ventilation" + }, + "level": { + "type": "string", + "value": "levelThree" + }, + "reason": { + "type": "string", + "value": "schedule" + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.operating.state" + }, + { + "apiVersion": 1, + "commands": { + "activate": { + "isExecutable": true, + "name": "activate", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.quickmodes.comfort/commands/activate" + }, + "deactivate": { + "isExecutable": true, + "name": "deactivate", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.quickmodes.comfort/commands/deactivate" + } + }, + "deviceId": "0", + "feature": "ventilation.quickmodes.comfort", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.quickmodes.comfort" + }, + { + "apiVersion": 1, + "commands": { + "activate": { + "isExecutable": true, + "name": "activate", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.quickmodes.eco/commands/activate" + }, + "deactivate": { + "isExecutable": true, + "name": "deactivate", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.quickmodes.eco/commands/deactivate" + } + }, + "deviceId": "0", + "feature": "ventilation.quickmodes.eco", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.quickmodes.eco" + }, + { + "apiVersion": 1, + "commands": { + "changeEndDate": { + "isExecutable": false, + "name": "changeEndDate", + "params": { + "end": { + "constraints": { + "regEx": "^[\\d]{4}-[\\d]{2}-[\\d]{2}$", + "sameDayAllowed": false + }, + "required": true, + "type": "string" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.quickmodes.holiday/commands/changeEndDate" + }, + "schedule": { + "isExecutable": true, + "name": "schedule", + "params": { + "end": { + "constraints": { + "regEx": "^[\\d]{4}-[\\d]{2}-[\\d]{2}$", + "sameDayAllowed": false + }, + "required": true, + "type": "string" + }, + "start": { + "constraints": { + "regEx": "^[\\d]{4}-[\\d]{2}-[\\d]{2}$" + }, + "required": true, + "type": "string" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.quickmodes.holiday/commands/schedule" + }, + "unschedule": { + "isExecutable": true, + "name": "unschedule", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.quickmodes.holiday/commands/unschedule" + } + }, + "deviceId": "0", + "feature": "ventilation.quickmodes.holiday", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + }, + "end": { + "type": "string", + "value": "" + }, + "start": { + "type": "string", + "value": "" + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.quickmodes.holiday" + }, + { + "apiVersion": 1, + "commands": { + "resetSchedule": { + "isExecutable": false, + "name": "resetSchedule", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.schedule/commands/resetSchedule" + }, + "setSchedule": { + "isExecutable": true, + "name": "setSchedule", + "params": { + "newSchedule": { + "constraints": { + "defaultMode": "levelOne", + "maxEntries": 8, + "modes": ["levelTwo", "levelThree", "levelFour"], + "overlapAllowed": true, + "resolution": 10 + }, + "required": true, + "type": "Schedule" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.schedule/commands/setSchedule" + } + }, + "deviceId": "0", + "feature": "ventilation.schedule", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": true + }, + "entries": { + "type": "Schedule", + "value": { + "fri": [ + { + "end": "24:00", + "mode": "levelThree", + "position": 0, + "start": "00:00" + } + ], + "mon": [ + { + "end": "24:00", + "mode": "levelThree", + "position": 0, + "start": "00:00" + } + ], + "sat": [ + { + "end": "24:00", + "mode": "levelThree", + "position": 0, + "start": "00:00" + } + ], + "sun": [ + { + "end": "24:00", + "mode": "levelThree", + "position": 0, + "start": "00:00" + } + ], + "thu": [ + { + "end": "24:00", + "mode": "levelThree", + "position": 0, + "start": "00:00" + } + ], + "tue": [ + { + "end": "24:00", + "mode": "levelThree", + "position": 0, + "start": "00:00" + } + ], + "wed": [ + { + "end": "24:00", + "mode": "levelThree", + "position": 0, + "start": "00:00" + } + ] + } + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.schedule" + }, + { + "apiVersion": 1, + "commands": { + "setName": { + "isExecutable": true, + "name": "setName", + "params": { + "name": { + "constraints": { + "maxLength": 20, + "minLength": 1 + }, + "required": true, + "type": "string" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.name/commands/setName" + } + }, + "components": [], + "deviceId": "0", + "feature": "heating.circuits.0.name", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "name": { + "type": "string", + "value": "" + } + }, + "timestamp": "2025-01-12T22:36:28.706Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.name" + }, + { + "apiVersion": 1, + "commands": {}, + "components": [], + "deviceId": "0", + "feature": "heating.circuits.1.name", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-02-02T01:29:44.670Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.name" + }, + { + "apiVersion": 1, + "commands": {}, + "components": [], + "deviceId": "0", + "feature": "heating.circuits.2.name", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-02-02T01:29:44.670Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.name" + } + ] +} diff --git a/tests/components/vicare/snapshots/test_fan.ambr b/tests/components/vicare/snapshots/test_fan.ambr index 0bac421e2c7..2c9e815f7bf 100644 --- a/tests/components/vicare/snapshots/test_fan.ambr +++ b/tests/components/vicare/snapshots/test_fan.ambr @@ -1,4 +1,69 @@ # serializer version: 1 +# name: test_all_entities[fan.model0_ventilation-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'preset_modes': list([ + , + , + , + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.model0_ventilation', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:fan', + 'original_name': 'Ventilation', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'ventilation', + 'unique_id': 'gateway0_deviceSerialViAir300F-ventilation', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[fan.model0_ventilation-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'model0 Ventilation', + 'icon': 'mdi:fan', + 'percentage': 0, + 'percentage_step': 25.0, + 'preset_mode': None, + 'preset_modes': list([ + , + , + , + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'fan.model0_ventilation', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_all_entities[fan.model1_ventilation-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -62,3 +127,64 @@ 'state': 'off', }) # --- +# name: test_all_entities[fan.model2_ventilation-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'preset_modes': list([ + , + , + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.model2_ventilation', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:fan', + 'original_name': 'Ventilation', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'ventilation', + 'unique_id': 'gateway2_################-ventilation', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[fan.model2_ventilation-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'model2 Ventilation', + 'icon': 'mdi:fan', + 'preset_mode': None, + 'preset_modes': list([ + , + , + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'fan.model2_ventilation', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/vicare/test_climate.py b/tests/components/vicare/test_climate.py index f48a8988cf0..9299f6567b1 100644 --- a/tests/components/vicare/test_climate.py +++ b/tests/components/vicare/test_climate.py @@ -23,7 +23,9 @@ async def test_all_entities( entity_registry: er.EntityRegistry, ) -> None: """Test all entities.""" - fixtures: list[Fixture] = [Fixture({"type:boiler"}, "vicare/Vitodens300W.json")] + fixtures: list[Fixture] = [ + Fixture({"type:boiler"}, "vicare/Vitodens300W.json"), + ] with ( patch(f"{MODULE}.login", return_value=MockPyViCare(fixtures)), patch(f"{MODULE}.PLATFORMS", [Platform.CLIMATE]), diff --git a/tests/components/vicare/test_fan.py b/tests/components/vicare/test_fan.py index 5683f48f01f..8c42c92fb50 100644 --- a/tests/components/vicare/test_fan.py +++ b/tests/components/vicare/test_fan.py @@ -26,6 +26,7 @@ async def test_all_entities( fixtures: list[Fixture] = [ Fixture({"type:ventilation"}, "vicare/ViAir300F.json"), Fixture({"type:ventilation"}, "vicare/VitoPure.json"), + Fixture({"type:heatpump"}, "vicare/Vitocal222G_Vitovent300W.json"), ] with ( patch(f"{MODULE}.login", return_value=MockPyViCare(fixtures)), From fa40d02a07fc9369a08b0c4503a25b7be2dcddd9 Mon Sep 17 00:00:00 2001 From: karwosts <32912880+karwosts@users.noreply.github.com> Date: Sun, 2 Mar 2025 12:15:37 -0800 Subject: [PATCH 2110/3148] Add model_id filter to device selector (#135646) * Add model_id filter to device selector * Rerun CI --- homeassistant/helpers/selector.py | 3 +++ tests/helpers/test_selector.py | 1 + 2 files changed, 4 insertions(+) diff --git a/homeassistant/helpers/selector.py b/homeassistant/helpers/selector.py index 025b8de8896..dd2fd8a677c 100644 --- a/homeassistant/helpers/selector.py +++ b/homeassistant/helpers/selector.py @@ -164,6 +164,8 @@ DEVICE_FILTER_SELECTOR_CONFIG_SCHEMA = vol.Schema( vol.Optional("manufacturer"): str, # Model of device vol.Optional("model"): str, + # Model ID of device + vol.Optional("model_id"): str, # Device has to contain entities matching this selector vol.Optional("entity"): vol.All( cv.ensure_list, [ENTITY_FILTER_SELECTOR_CONFIG_SCHEMA] @@ -178,6 +180,7 @@ class DeviceFilterSelectorConfig(TypedDict, total=False): integration: str manufacturer: str model: str + model_id: str class ActionSelectorConfig(TypedDict): diff --git a/tests/helpers/test_selector.py b/tests/helpers/test_selector.py index d07bb7458e9..a977a70973d 100644 --- a/tests/helpers/test_selector.py +++ b/tests/helpers/test_selector.py @@ -88,6 +88,7 @@ def _test_selector( ({"integration": "zha"}, ("abc123",), (None,)), ({"manufacturer": "mock-manuf"}, ("abc123",), (None,)), ({"model": "mock-model"}, ("abc123",), (None,)), + ({"model_id": "mock-model_id"}, ("abc123",), (None,)), ({"manufacturer": "mock-manuf", "model": "mock-model"}, ("abc123",), (None,)), ( {"integration": "zha", "manufacturer": "mock-manuf", "model": "mock-model"}, From 14e66ffef4456bbee0b524d923f95dcbe93048fb Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 2 Mar 2025 21:21:47 +0100 Subject: [PATCH 2111/3148] Fetch integration list from next branch for analytics insights (#137250) Fetch integration list from next branch --- homeassistant/components/analytics_insights/config_flow.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/analytics_insights/config_flow.py b/homeassistant/components/analytics_insights/config_flow.py index da77a35f789..b2648f7c13c 100644 --- a/homeassistant/components/analytics_insights/config_flow.py +++ b/homeassistant/components/analytics_insights/config_flow.py @@ -8,7 +8,7 @@ from python_homeassistant_analytics import ( HomeassistantAnalyticsClient, HomeassistantAnalyticsConnectionError, ) -from python_homeassistant_analytics.models import IntegrationType +from python_homeassistant_analytics.models import Environment, IntegrationType import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow @@ -81,7 +81,7 @@ class HomeassistantAnalyticsConfigFlow(ConfigFlow, domain=DOMAIN): ) try: addons = await client.get_addons() - integrations = await client.get_integrations() + integrations = await client.get_integrations(Environment.NEXT) custom_integrations = await client.get_custom_integrations() except HomeassistantAnalyticsConnectionError: LOGGER.exception("Error connecting to Home Assistant analytics") @@ -165,7 +165,7 @@ class HomeassistantAnalyticsOptionsFlowHandler(OptionsFlow): ) try: addons = await client.get_addons() - integrations = await client.get_integrations() + integrations = await client.get_integrations(Environment.NEXT) custom_integrations = await client.get_custom_integrations() except HomeassistantAnalyticsConnectionError: LOGGER.exception("Error connecting to Home Assistant analytics") From 23644a60ac7dd42dfcab85d5de6624b014e20df2 Mon Sep 17 00:00:00 2001 From: Trevor Warwick <49233676+trevorwarwick@users.noreply.github.com> Date: Sun, 2 Mar 2025 20:26:54 +0000 Subject: [PATCH 2112/3148] Improve Linkplay device unavailability detection (#138457) * Dampen reachability changes Retry a few times before declaring player is unavailable * Fix ruff-format complaint Co-authored-by: Simon Lamon <32477463+silamon@users.noreply.github.com> * Fix ruff-format complaint Co-authored-by: Simon Lamon <32477463+silamon@users.noreply.github.com> * Fix ruff-format complaint Co-authored-by: Simon Lamon <32477463+silamon@users.noreply.github.com> * Fix duplicated change Co-authored-by: Simon Lamon <32477463+silamon@users.noreply.github.com> --------- Co-authored-by: Simon Lamon <32477463+silamon@users.noreply.github.com> --- homeassistant/components/linkplay/media_player.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/linkplay/media_player.py b/homeassistant/components/linkplay/media_player.py index 2986db76520..b27616f1e09 100644 --- a/homeassistant/components/linkplay/media_player.py +++ b/homeassistant/components/linkplay/media_player.py @@ -125,6 +125,8 @@ SERVICE_PLAY_PRESET_SCHEMA = cv.make_entity_service_schema( } ) +RETRY_POLL_MAXIMUM = 3 + async def async_setup_entry( hass: HomeAssistant, @@ -156,6 +158,7 @@ class LinkPlayMediaPlayerEntity(LinkPlayBaseEntity, MediaPlayerEntity): super().__init__(bridge) self._attr_unique_id = bridge.device.uuid + self._retry_count = 0 self._attr_source_list = [ SOURCE_MAP[playing_mode] for playing_mode in bridge.device.playmode_support @@ -166,9 +169,12 @@ class LinkPlayMediaPlayerEntity(LinkPlayBaseEntity, MediaPlayerEntity): """Update the state of the media player.""" try: await self._bridge.player.update_status() + self._retry_count = 0 self._update_properties() except LinkPlayRequestException: - self._attr_available = False + self._retry_count += 1 + if self._retry_count >= RETRY_POLL_MAXIMUM: + self._attr_available = False @exception_wrap async def async_select_source(self, source: str) -> None: From c782a6ab63ad4aa84e273af1237e122470fb6944 Mon Sep 17 00:00:00 2001 From: martin12as <86385658+martin12as@users.noreply.github.com> Date: Sun, 2 Mar 2025 17:38:12 -0300 Subject: [PATCH 2113/3148] Improve outlet constant naming for NUT (#139660) * Update const.py Fixed to match string.json * Update const.py --- homeassistant/components/nut/const.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/nut/const.py b/homeassistant/components/nut/const.py index 924c591e783..e67299aa9a3 100644 --- a/homeassistant/components/nut/const.py +++ b/homeassistant/components/nut/const.py @@ -63,10 +63,10 @@ COMMAND_TEST_FAILURE_STOP = "test.failure.stop" COMMAND_TEST_PANEL_START = "test.panel.start" COMMAND_TEST_PANEL_STOP = "test.panel.stop" COMMAND_TEST_SYSTEM_START = "test.system.start" -COMMAND_OUTLET1_OFF = "outlet.1.load.off" -COMMAND_OUTLET1_ON = "outlet.1.load.on" -COMMAND_OUTLET2_OFF = "outlet.2.load.off" -COMMAND_OUTLET2_ON = "outlet.2.load.on" +COMMAND_OUTLET_1_LOAD_OFF = "outlet.1.load.off" +COMMAND_OUTLET_1_LOAD_ON = "outlet.1.load.on" +COMMAND_OUTLET_2_LOAD_OFF = "outlet.2.load.off" +COMMAND_OUTLET_2_LOAD_ON = "outlet.2.load.on" INTEGRATION_SUPPORTED_COMMANDS = { COMMAND_BEEPER_DISABLE, @@ -95,8 +95,8 @@ INTEGRATION_SUPPORTED_COMMANDS = { COMMAND_TEST_PANEL_START, COMMAND_TEST_PANEL_STOP, COMMAND_TEST_SYSTEM_START, - COMMAND_OUTLET1_OFF, - COMMAND_OUTLET1_ON, - COMMAND_OUTLET2_OFF, - COMMAND_OUTLET2_ON, + COMMAND_OUTLET_1_LOAD_OFF, + COMMAND_OUTLET_1_LOAD_ON, + COMMAND_OUTLET_2_LOAD_OFF, + COMMAND_OUTLET_2_LOAD_ON, } From 53bc5ff029631331e041b5f93f0823e98ef9f1f6 Mon Sep 17 00:00:00 2001 From: Michel van de Wetering Date: Sun, 2 Mar 2025 21:41:38 +0100 Subject: [PATCH 2114/3148] Keep entered values in form when connecting to Epson projector fails (#135402) Add suggested values to form --- homeassistant/components/epson/config_flow.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/epson/config_flow.py b/homeassistant/components/epson/config_flow.py index c54bff2eea9..077b9cc31f7 100644 --- a/homeassistant/components/epson/config_flow.py +++ b/homeassistant/components/epson/config_flow.py @@ -72,5 +72,7 @@ class EpsonConfigFlow(ConfigFlow, domain=DOMAIN): if projector: projector.close() return self.async_show_form( - step_id="user", data_schema=DATA_SCHEMA, errors=errors + step_id="user", + data_schema=self.add_suggested_values_to_schema(DATA_SCHEMA, user_input), + errors=errors, ) From 4602c0a1c32339235508d82f4dd90a536a03ae3f Mon Sep 17 00:00:00 2001 From: hydazz <53986978+hydazz@users.noreply.github.com> Date: Mon, 3 Mar 2025 07:59:44 +1100 Subject: [PATCH 2115/3148] Add Night mode and `HVACAction` to Advantage Air (#137475) * add night mode toggle * populate AC's action * set hvac action on zones * update tests * show zones as off if AC is off --------- Co-authored-by: Franck Nijhof --- .../components/advantage_air/climate.py | 37 +++++++++++++++++++ .../components/advantage_air/const.py | 1 + .../components/advantage_air/switch.py | 29 +++++++++++++++ .../advantage_air/snapshots/test_climate.ambr | 1 + 4 files changed, 68 insertions(+) diff --git a/homeassistant/components/advantage_air/climate.py b/homeassistant/components/advantage_air/climate.py index c023d4cf8f3..1d593c5c3c8 100644 --- a/homeassistant/components/advantage_air/climate.py +++ b/homeassistant/components/advantage_air/climate.py @@ -2,6 +2,7 @@ from __future__ import annotations +from decimal import Decimal import logging from typing import Any @@ -14,6 +15,7 @@ from homeassistant.components.climate import ( FAN_MEDIUM, ClimateEntity, ClimateEntityFeature, + HVACAction, HVACMode, ) from homeassistant.const import ATTR_TEMPERATURE, PRECISION_WHOLE, UnitOfTemperature @@ -49,6 +51,14 @@ ADVANTAGE_AIR_MYTEMP_ENABLED = "climateControlModeEnabled" ADVANTAGE_AIR_HEAT_TARGET = "myAutoHeatTargetTemp" ADVANTAGE_AIR_COOL_TARGET = "myAutoCoolTargetTemp" ADVANTAGE_AIR_MYFAN = "autoAA" +ADVANTAGE_AIR_MYAUTO_MODE_SET = "myAutoModeCurrentSetMode" + +HVAC_ACTIONS = { + "cool": HVACAction.COOLING, + "heat": HVACAction.HEATING, + "vent": HVACAction.FAN, + "dry": HVACAction.DRYING, +} HVAC_MODES = [ HVACMode.OFF, @@ -175,6 +185,17 @@ class AdvantageAirAC(AdvantageAirAcEntity, ClimateEntity): return ADVANTAGE_AIR_HVAC_MODES.get(self._ac["mode"]) return HVACMode.OFF + @property + def hvac_action(self) -> HVACAction | None: + """Return the current running HVAC action.""" + if self._ac["state"] == ADVANTAGE_AIR_STATE_OFF: + return HVACAction.OFF + if self._ac["mode"] == "myauto": + return HVAC_ACTIONS.get( + self._ac.get(ADVANTAGE_AIR_MYAUTO_MODE_SET, HVACAction.OFF) + ) + return HVAC_ACTIONS.get(self._ac["mode"]) + @property def fan_mode(self) -> str | None: """Return the current fan modes.""" @@ -273,6 +294,22 @@ class AdvantageAirZone(AdvantageAirZoneEntity, ClimateEntity): return HVACMode.HEAT_COOL return HVACMode.OFF + @property + def hvac_action(self) -> HVACAction | None: + """Return the HVAC action, inheriting from master AC if zone is open but idle if air is <= 5%.""" + if self._ac["state"] == ADVANTAGE_AIR_STATE_OFF: + return HVACAction.OFF + master_action = HVAC_ACTIONS.get(self._ac["mode"], HVACAction.OFF) + if self._ac["mode"] == "myauto": + master_action = HVAC_ACTIONS.get( + str(self._ac.get(ADVANTAGE_AIR_MYAUTO_MODE_SET)), HVACAction.OFF + ) + if self._zone["state"] == ADVANTAGE_AIR_STATE_OPEN: + if self._zone["value"] <= Decimal(5): + return HVACAction.IDLE + return master_action + return HVACAction.OFF + @property def current_temperature(self) -> float | None: """Return the current temperature.""" diff --git a/homeassistant/components/advantage_air/const.py b/homeassistant/components/advantage_air/const.py index 6ae0a0e06d5..103ca57f6ef 100644 --- a/homeassistant/components/advantage_air/const.py +++ b/homeassistant/components/advantage_air/const.py @@ -7,3 +7,4 @@ ADVANTAGE_AIR_STATE_CLOSE = "close" ADVANTAGE_AIR_STATE_ON = "on" ADVANTAGE_AIR_STATE_OFF = "off" ADVANTAGE_AIR_AUTOFAN_ENABLED = "aaAutoFanModeEnabled" +ADVANTAGE_AIR_NIGHT_MODE_ENABLED = "quietNightModeEnabled" diff --git a/homeassistant/components/advantage_air/switch.py b/homeassistant/components/advantage_air/switch.py index 5c4528b44c6..8560c9a9138 100644 --- a/homeassistant/components/advantage_air/switch.py +++ b/homeassistant/components/advantage_air/switch.py @@ -9,6 +9,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import AdvantageAirDataConfigEntry from .const import ( ADVANTAGE_AIR_AUTOFAN_ENABLED, + ADVANTAGE_AIR_NIGHT_MODE_ENABLED, ADVANTAGE_AIR_STATE_OFF, ADVANTAGE_AIR_STATE_ON, ) @@ -32,6 +33,8 @@ async def async_setup_entry( entities.append(AdvantageAirFreshAir(instance, ac_key)) if ADVANTAGE_AIR_AUTOFAN_ENABLED in ac_device["info"]: entities.append(AdvantageAirMyFan(instance, ac_key)) + if ADVANTAGE_AIR_NIGHT_MODE_ENABLED in ac_device["info"]: + entities.append(AdvantageAirNightMode(instance, ac_key)) if things := instance.coordinator.data.get("myThings"): entities.extend( AdvantageAirRelay(instance, thing) @@ -93,6 +96,32 @@ class AdvantageAirMyFan(AdvantageAirAcEntity, SwitchEntity): await self.async_update_ac({ADVANTAGE_AIR_AUTOFAN_ENABLED: False}) +class AdvantageAirNightMode(AdvantageAirAcEntity, SwitchEntity): + """Representation of Advantage 'MySleep$aver' Mode control.""" + + _attr_icon = "mdi:weather-night" + _attr_name = "MySleep$aver" + _attr_device_class = SwitchDeviceClass.SWITCH + + def __init__(self, instance: AdvantageAirData, ac_key: str) -> None: + """Initialize an Advantage Air Night Mode control.""" + super().__init__(instance, ac_key) + self._attr_unique_id += "-nightmode" + + @property + def is_on(self) -> bool: + """Return the Night Mode status.""" + return self._ac[ADVANTAGE_AIR_NIGHT_MODE_ENABLED] + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn Night Mode on.""" + await self.async_update_ac({ADVANTAGE_AIR_NIGHT_MODE_ENABLED: True}) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn Night Mode off.""" + await self.async_update_ac({ADVANTAGE_AIR_NIGHT_MODE_ENABLED: False}) + + class AdvantageAirRelay(AdvantageAirThingEntity, SwitchEntity): """Representation of Advantage Air Thing.""" diff --git a/tests/components/advantage_air/snapshots/test_climate.ambr b/tests/components/advantage_air/snapshots/test_climate.ambr index bd1fb431ae1..b2559b5bdfd 100644 --- a/tests/components/advantage_air/snapshots/test_climate.ambr +++ b/tests/components/advantage_air/snapshots/test_climate.ambr @@ -30,6 +30,7 @@ 'auto', ]), 'friendly_name': 'myauto', + 'hvac_action': , 'hvac_modes': list([ , , From 5ae7109561c76e784801059df6c5b434d4754f23 Mon Sep 17 00:00:00 2001 From: Elias Wernicke Date: Sun, 2 Mar 2025 22:04:25 +0100 Subject: [PATCH 2116/3148] Increase test coverage for todo intent (#135960) * move intent tests to file * add tests for errors --- tests/components/todo/test_init.py | 116 +----------------- tests/components/todo/test_intent.py | 176 +++++++++++++++++++++++++++ 2 files changed, 177 insertions(+), 115 deletions(-) create mode 100644 tests/components/todo/test_intent.py diff --git a/tests/components/todo/test_init.py b/tests/components/todo/test_init.py index 8e8c010f758..11ef3d6f044 100644 --- a/tests/components/todo/test_init.py +++ b/tests/components/todo/test_init.py @@ -7,8 +7,6 @@ import zoneinfo import pytest import voluptuous as vol -from homeassistant.components import conversation -from homeassistant.components.homeassistant.exposed_entities import async_expose_entity from homeassistant.components.todo import ( ATTR_DESCRIPTION, ATTR_DUE_DATE, @@ -22,7 +20,6 @@ from homeassistant.components.todo import ( TodoListEntity, TodoListEntityFeature, TodoServices, - intent as todo_intent, ) from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES @@ -32,10 +29,9 @@ from homeassistant.exceptions import ( ServiceNotSupported, ServiceValidationError, ) -from homeassistant.helpers import intent from homeassistant.setup import async_setup_component -from . import MockTodoListEntity, create_mock_platform +from . import create_mock_platform from tests.typing import WebSocketGenerator @@ -989,116 +985,6 @@ async def test_move_item_unsupported( assert resp.get("error", {}).get("code") == "not_supported" -async def test_add_item_intent( - hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, -) -> None: - """Test adding items to lists using an intent.""" - assert await async_setup_component(hass, "homeassistant", {}) - await todo_intent.async_setup_intents(hass) - - entity1 = MockTodoListEntity() - entity1._attr_name = "List 1" - entity1.entity_id = "todo.list_1" - - entity2 = MockTodoListEntity() - entity2._attr_name = "List 2" - entity2.entity_id = "todo.list_2" - - await create_mock_platform(hass, [entity1, entity2]) - - # Add to first list - response = await intent.async_handle( - hass, - "test", - todo_intent.INTENT_LIST_ADD_ITEM, - {ATTR_ITEM: {"value": " beer "}, "name": {"value": "list 1"}}, - assistant=conversation.DOMAIN, - ) - assert response.response_type == intent.IntentResponseType.ACTION_DONE - assert response.success_results[0].name == "list 1" - assert response.success_results[0].type == intent.IntentResponseTargetType.ENTITY - assert response.success_results[0].id == entity1.entity_id - - assert len(entity1.items) == 1 - assert len(entity2.items) == 0 - assert entity1.items[0].summary == "beer" # summary is trimmed - assert entity1.items[0].status == TodoItemStatus.NEEDS_ACTION - entity1.items.clear() - - # Add to second list - response = await intent.async_handle( - hass, - "test", - todo_intent.INTENT_LIST_ADD_ITEM, - {ATTR_ITEM: {"value": "cheese"}, "name": {"value": "List 2"}}, - assistant=conversation.DOMAIN, - ) - assert response.response_type == intent.IntentResponseType.ACTION_DONE - - assert len(entity1.items) == 0 - assert len(entity2.items) == 1 - assert entity2.items[0].summary == "cheese" - assert entity2.items[0].status == TodoItemStatus.NEEDS_ACTION - - # List name is case insensitive - response = await intent.async_handle( - hass, - "test", - todo_intent.INTENT_LIST_ADD_ITEM, - {ATTR_ITEM: {"value": "wine"}, "name": {"value": "lIST 2"}}, - assistant=conversation.DOMAIN, - ) - assert response.response_type == intent.IntentResponseType.ACTION_DONE - - assert len(entity1.items) == 0 - assert len(entity2.items) == 2 - assert entity2.items[1].summary == "wine" - assert entity2.items[1].status == TodoItemStatus.NEEDS_ACTION - - # Should fail if lists are not exposed - async_expose_entity(hass, conversation.DOMAIN, entity1.entity_id, False) - async_expose_entity(hass, conversation.DOMAIN, entity2.entity_id, False) - with pytest.raises(intent.MatchFailedError) as err: - await intent.async_handle( - hass, - "test", - todo_intent.INTENT_LIST_ADD_ITEM, - {"item": {"value": "cookies"}, "name": {"value": "list 1"}}, - assistant=conversation.DOMAIN, - ) - assert err.value.result.no_match_reason == intent.MatchFailedReason.ASSISTANT - - # Missing list - with pytest.raises(intent.MatchFailedError): - await intent.async_handle( - hass, - "test", - todo_intent.INTENT_LIST_ADD_ITEM, - {"item": {"value": "wine"}, "name": {"value": "This list does not exist"}}, - assistant=conversation.DOMAIN, - ) - - # Fail with empty name/item - with pytest.raises(intent.InvalidSlotInfo): - await intent.async_handle( - hass, - "test", - todo_intent.INTENT_LIST_ADD_ITEM, - {"item": {"value": "wine"}, "name": {"value": ""}}, - assistant=conversation.DOMAIN, - ) - - with pytest.raises(intent.InvalidSlotInfo): - await intent.async_handle( - hass, - "test", - todo_intent.INTENT_LIST_ADD_ITEM, - {"item": {"value": ""}, "name": {"value": "list 1"}}, - assistant=conversation.DOMAIN, - ) - - async def test_remove_completed_items_service( hass: HomeAssistant, test_entity: TodoListEntity, diff --git a/tests/components/todo/test_intent.py b/tests/components/todo/test_intent.py new file mode 100644 index 00000000000..cd074816e7e --- /dev/null +++ b/tests/components/todo/test_intent.py @@ -0,0 +1,176 @@ +"""Tests for the todo intents.""" + +import pytest + +from homeassistant.components import conversation +from homeassistant.components.homeassistant.exposed_entities import async_expose_entity +from homeassistant.components.todo import ( + ATTR_ITEM, + DOMAIN, + TodoItemStatus, + TodoListEntity, + intent as todo_intent, +) +from homeassistant.const import ATTR_NAME +from homeassistant.core import HomeAssistant +from homeassistant.helpers import intent +from homeassistant.setup import async_setup_component + +from . import MockTodoListEntity, create_mock_platform + +from tests.typing import WebSocketGenerator + + +@pytest.fixture(autouse=True) +async def setup_intents(hass: HomeAssistant) -> None: + """Set up the intents.""" + assert await async_setup_component(hass, "homeassistant", {}) + await todo_intent.async_setup_intents(hass) + + +async def test_add_item_intent( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test adding items to lists using an intent.""" + assert await async_setup_component(hass, "homeassistant", {}) + await todo_intent.async_setup_intents(hass) + + entity1 = MockTodoListEntity() + entity1._attr_name = "List 1" + entity1.entity_id = "todo.list_1" + + entity2 = MockTodoListEntity() + entity2._attr_name = "List 2" + entity2.entity_id = "todo.list_2" + + await create_mock_platform(hass, [entity1, entity2]) + + # Add to first list + response = await intent.async_handle( + hass, + "test", + todo_intent.INTENT_LIST_ADD_ITEM, + {ATTR_ITEM: {"value": " beer "}, "name": {"value": "list 1"}}, + assistant=conversation.DOMAIN, + ) + assert response.response_type == intent.IntentResponseType.ACTION_DONE + assert response.success_results[0].name == "list 1" + assert response.success_results[0].type == intent.IntentResponseTargetType.ENTITY + assert response.success_results[0].id == entity1.entity_id + + assert len(entity1.items) == 1 + assert len(entity2.items) == 0 + assert entity1.items[0].summary == "beer" # summary is trimmed + assert entity1.items[0].status == TodoItemStatus.NEEDS_ACTION + entity1.items.clear() + + # Add to second list + response = await intent.async_handle( + hass, + "test", + todo_intent.INTENT_LIST_ADD_ITEM, + {ATTR_ITEM: {"value": "cheese"}, "name": {"value": "List 2"}}, + assistant=conversation.DOMAIN, + ) + assert response.response_type == intent.IntentResponseType.ACTION_DONE + + assert len(entity1.items) == 0 + assert len(entity2.items) == 1 + assert entity2.items[0].summary == "cheese" + assert entity2.items[0].status == TodoItemStatus.NEEDS_ACTION + + # List name is case insensitive + response = await intent.async_handle( + hass, + "test", + todo_intent.INTENT_LIST_ADD_ITEM, + {ATTR_ITEM: {"value": "wine"}, "name": {"value": "lIST 2"}}, + assistant=conversation.DOMAIN, + ) + assert response.response_type == intent.IntentResponseType.ACTION_DONE + + assert len(entity1.items) == 0 + assert len(entity2.items) == 2 + assert entity2.items[1].summary == "wine" + assert entity2.items[1].status == TodoItemStatus.NEEDS_ACTION + + # Should fail if lists are not exposed + async_expose_entity(hass, conversation.DOMAIN, entity1.entity_id, False) + async_expose_entity(hass, conversation.DOMAIN, entity2.entity_id, False) + with pytest.raises(intent.MatchFailedError) as err: + await intent.async_handle( + hass, + "test", + todo_intent.INTENT_LIST_ADD_ITEM, + {"item": {"value": "cookies"}, "name": {"value": "list 1"}}, + assistant=conversation.DOMAIN, + ) + assert err.value.result.no_match_reason == intent.MatchFailedReason.ASSISTANT + + # Missing list + with pytest.raises(intent.MatchFailedError): + await intent.async_handle( + hass, + "test", + todo_intent.INTENT_LIST_ADD_ITEM, + {"item": {"value": "wine"}, "name": {"value": "This list does not exist"}}, + assistant=conversation.DOMAIN, + ) + + # Fail with empty name/item + with pytest.raises(intent.InvalidSlotInfo): + await intent.async_handle( + hass, + "test", + todo_intent.INTENT_LIST_ADD_ITEM, + {"item": {"value": "wine"}, "name": {"value": ""}}, + assistant=conversation.DOMAIN, + ) + + with pytest.raises(intent.InvalidSlotInfo): + await intent.async_handle( + hass, + "test", + todo_intent.INTENT_LIST_ADD_ITEM, + {"item": {"value": ""}, "name": {"value": "list 1"}}, + assistant=conversation.DOMAIN, + ) + + +async def test_add_item_intent_errors( + hass: HomeAssistant, + test_entity: TodoListEntity, +) -> None: + """Test errors with the add item intent.""" + test_entity._attr_name = "List 1" + await create_mock_platform(hass, [test_entity]) + + # Try to add item in list that does not exist + with pytest.raises(intent.MatchFailedError): + await intent.async_handle( + hass, + "test", + todo_intent.INTENT_LIST_ADD_ITEM, + { + ATTR_ITEM: {"value": "wine"}, + ATTR_NAME: {"value": "This list does not exist"}, + }, + assistant=conversation.DOMAIN, + ) + + # Mock the get_entity method to return None + hass.data[DOMAIN].get_entity = lambda entity_id: None + + # Try to add item in a list that exists but get_entity returns None + with pytest.raises(intent.IntentHandleError, match="No to-do list: List 1"): + await intent.async_handle( + hass, + "test", + todo_intent.INTENT_LIST_ADD_ITEM, + { + ATTR_ITEM: {"value": "wine"}, + ATTR_NAME: {"value": "List 1"}, + }, + assistant=conversation.DOMAIN, + ) From 7e4432e321cedeedbfe4d7a45eb40b7078e5f5e9 Mon Sep 17 00:00:00 2001 From: andresb5555 Date: Sun, 2 Mar 2025 23:07:35 +0200 Subject: [PATCH 2117/3148] Do not force logfile to roll over when using TimedRotatingFileHandler (#128301) Do not force log file to roll over when using TimedRotatingFileHandler --- homeassistant/bootstrap.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index e25bfbe358c..cf8e5e1ea09 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -664,11 +664,10 @@ def _create_log_file( err_handler = _RotatingFileHandlerWithoutShouldRollOver( err_log_path, backupCount=1 ) - - try: - err_handler.doRollover() - except OSError as err: - _LOGGER.error("Error rolling over log file: %s", err) + try: + err_handler.doRollover() + except OSError as err: + _LOGGER.error("Error rolling over log file: %s", err) return err_handler From 3c363eb5ce02655778568dc4de1df6658e46e961 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 3 Mar 2025 10:17:13 +0100 Subject: [PATCH 2118/3148] Adjust type hints in update entity (#129387) * Adjust type hints in update entity * Update allowed return type of update_percentage --------- Co-authored-by: Franck Nijhof --- homeassistant/components/update/__init__.py | 6 +++--- pylint/plugins/hass_enforce_type_hints.py | 6 +++++- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/update/__init__.py b/homeassistant/components/update/__init__.py index 0ff8c448197..47cc5aa369b 100644 --- a/homeassistant/components/update/__init__.py +++ b/homeassistant/components/update/__init__.py @@ -226,7 +226,7 @@ class UpdateEntity( _attr_installed_version: str | None = None _attr_device_class: UpdateDeviceClass | None _attr_display_precision: int - _attr_in_progress: bool | int = False + _attr_in_progress: bool = False _attr_latest_version: str | None = None _attr_release_summary: str | None = None _attr_release_url: str | None = None @@ -295,7 +295,7 @@ class UpdateEntity( ) @cached_property - def in_progress(self) -> bool | int | None: + def in_progress(self) -> bool | None: """Update installation progress. Needs UpdateEntityFeature.PROGRESS flag to be set for it to be used. @@ -442,7 +442,7 @@ class UpdateEntity( in_progress = self.in_progress update_percentage = self.update_percentage if in_progress else None if type(in_progress) is not bool and isinstance(in_progress, int): - update_percentage = in_progress + update_percentage = in_progress # type: ignore[unreachable] in_progress = True else: in_progress = self.__in_progress diff --git a/pylint/plugins/hass_enforce_type_hints.py b/pylint/plugins/hass_enforce_type_hints.py index a4590207294..ca7777da959 100644 --- a/pylint/plugins/hass_enforce_type_hints.py +++ b/pylint/plugins/hass_enforce_type_hints.py @@ -2568,7 +2568,7 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { ), TypeHintMatch( function_name="in_progress", - return_type=["bool", "int", None], + return_type=["bool", None], ), TypeHintMatch( function_name="latest_version", @@ -2590,6 +2590,10 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { function_name="title", return_type=["str", None], ), + TypeHintMatch( + function_name="update_percentage", + return_type=["int", "float", None], + ), TypeHintMatch( function_name="install", arg_types={1: "str | None", 2: "bool"}, From 572534b306620627dee300424b028c9d3de33a80 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 3 Mar 2025 10:18:30 +0100 Subject: [PATCH 2119/3148] Fix missing camel-case in one "ElevenLabs" string (#139686) --- homeassistant/components/elevenlabs/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/elevenlabs/strings.json b/homeassistant/components/elevenlabs/strings.json index b346f94a963..8b0205a9e9a 100644 --- a/homeassistant/components/elevenlabs/strings.json +++ b/homeassistant/components/elevenlabs/strings.json @@ -6,7 +6,7 @@ "api_key": "[%key:common::config_flow::data::api_key%]" }, "data_description": { - "api_key": "Your Elevenlabs API key." + "api_key": "Your ElevenLabs API key." } } }, From 5472345f458518b5f561b1446f479fd72a139194 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Mon, 3 Mar 2025 20:45:04 +1000 Subject: [PATCH 2120/3148] Add additional garage door code to Advantage Air (#139687) add Garage door --- homeassistant/components/advantage_air/cover.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/advantage_air/cover.py b/homeassistant/components/advantage_air/cover.py index b5b982597f0..e764d484128 100644 --- a/homeassistant/components/advantage_air/cover.py +++ b/homeassistant/components/advantage_air/cover.py @@ -41,7 +41,7 @@ async def async_setup_entry( entities.append( AdvantageAirThingCover(instance, thing, CoverDeviceClass.BLIND) ) - elif thing["channelDipState"] == 3: # 3 = "Garage door" + elif thing["channelDipState"] in [3, 10]: # 3 & 10 = "Garage door" entities.append( AdvantageAirThingCover(instance, thing, CoverDeviceClass.GARAGE) ) From aee891434f6deee9879659b6b78999b9aa141a0e Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 3 Mar 2025 05:46:40 -0500 Subject: [PATCH 2121/3148] Avoid duplicate chat log content (#139679) --- homeassistant/components/conversation/chat_log.py | 6 +++++- tests/components/conversation/test_chat_log.py | 2 ++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/conversation/chat_log.py b/homeassistant/components/conversation/chat_log.py index 1ee5e9965ab..19482af1983 100644 --- a/homeassistant/components/conversation/chat_log.py +++ b/homeassistant/components/conversation/chat_log.py @@ -49,7 +49,11 @@ def async_get_chat_log( raise RuntimeError( "Cannot attach chat log delta listener unless initial caller" ) - if user_input is not None: + if user_input is not None and ( + (content := chat_log.content[-1]).role != "user" + # MyPy doesn't understand that content is a UserContent here + or content.content != user_input.text # type: ignore[union-attr] + ): chat_log.async_add_user_content(UserContent(content=user_input.text)) yield chat_log diff --git a/tests/components/conversation/test_chat_log.py b/tests/components/conversation/test_chat_log.py index a4dc9b819c1..c0687ebecfb 100644 --- a/tests/components/conversation/test_chat_log.py +++ b/tests/components/conversation/test_chat_log.py @@ -86,7 +86,9 @@ async def test_default_content( with ( chat_session.async_get_chat_session(hass) as session, async_get_chat_log(hass, session, mock_conversation_input) as chat_log, + async_get_chat_log(hass, session, mock_conversation_input) as chat_log2, ): + assert chat_log is chat_log2 assert len(chat_log.content) == 2 assert chat_log.content[0].role == "system" assert chat_log.content[0].content == "" From ee486c269c4fb7ab151a4b219d90329b26be4939 Mon Sep 17 00:00:00 2001 From: cs12ag <70966712+cs12ag@users.noreply.github.com> Date: Mon, 3 Mar 2025 13:06:25 +0000 Subject: [PATCH 2122/3148] Fix unique identifiers where multiple IKEA Tradfri gateways are in use (#136060) * Create unique identifiers where multiple gateways are in use Resolving issue https://github.com/home-assistant/core/issues/134497 * Added migration function to __init__.py Added migration function to execute upon initialisation, to: a) remove the erroneously-added config)_entry added to the device (gateway B gets added as a config_entry to a device associated to gateway A), and b) swap out the non-unique identifiers for genuinely unique identifiers. * Added tests to simulate migration from bad data scenario (i.e. explicitly executing migrate_entity_unique_ids() from __init__.py) * Ammendments suggested in first review * Changes after second review * Rewrite of test_migrate_config_entry_and_identifiers after feedback * Converted migrate function into major version, updated tests * Finalised variable naming convention per feedback, added test to validate config entry migrated to v2 * Hopefully final changes for cosmetic / comment stucture * Further code-coverage in test_migrate_config_entry_and_identifiers() * Minor test corrections * Added test for non-tradfri identifiers --- homeassistant/components/tradfri/__init__.py | 94 ++++++++- .../components/tradfri/config_flow.py | 2 +- homeassistant/components/tradfri/entity.py | 2 +- tests/components/tradfri/__init__.py | 2 + tests/components/tradfri/test_init.py | 186 +++++++++++++++++- 5 files changed, 280 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/tradfri/__init__.py b/homeassistant/components/tradfri/__init__.py index 2073829e021..c3e8938b244 100644 --- a/homeassistant/components/tradfri/__init__.py +++ b/homeassistant/components/tradfri/__init__.py @@ -159,7 +159,7 @@ def remove_stale_devices( device_entries = dr.async_entries_for_config_entry( device_registry, config_entry.entry_id ) - all_device_ids = {device.id for device in devices} + all_device_ids = {str(device.id) for device in devices} for device_entry in device_entries: device_id: str | None = None @@ -176,7 +176,7 @@ def remove_stale_devices( gateway_id = _id break - device_id = _id + device_id = _id.replace(f"{config_entry.data[CONF_GATEWAY_ID]}-", "") break if gateway_id is not None: @@ -190,3 +190,93 @@ def remove_stale_devices( device_registry.async_update_device( device_entry.id, remove_config_entry_id=config_entry.entry_id ) + + +async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Migrate old entry.""" + LOGGER.debug( + "Migrating Tradfri configuration from version %s.%s", + config_entry.version, + config_entry.minor_version, + ) + + if config_entry.version > 1: + # This means the user has downgraded from a future version + return False + + if config_entry.version == 1: + # Migrate to version 2 + migrate_config_entry_and_identifiers(hass, config_entry) + + hass.config_entries.async_update_entry(config_entry, version=2) + + LOGGER.debug( + "Migration to Tradfri configuration version %s.%s successful", + config_entry.version, + config_entry.minor_version, + ) + + return True + + +def migrate_config_entry_and_identifiers( + hass: HomeAssistant, config_entry: ConfigEntry +) -> None: + """Migrate old non-unique identifiers to new unique identifiers.""" + + related_device_flag: bool + device_id: str + + device_reg = dr.async_get(hass) + # Get all devices associated to contextual gateway config_entry + # and loop through list of devices. + for device in dr.async_entries_for_config_entry(device_reg, config_entry.entry_id): + related_device_flag = False + for identifier in device.identifiers: + if identifier[0] != DOMAIN: + continue + + related_device_flag = True + + _id = identifier[1] + + # Identify gateway device. + if _id == config_entry.data[CONF_GATEWAY_ID]: + # Using this to avoid updating gateway's own device registry entry + related_device_flag = False + break + + device_id = str(_id) + break + + # Check that device is related to tradfri domain (and is not the gateway itself) + if not related_device_flag: + continue + + # Loop through list of config_entry_ids for device + config_entry_ids = device.config_entries + for config_entry_id in config_entry_ids: + # Check that the config entry in list is not the device's primary config entry + if config_entry_id == device.primary_config_entry: + continue + + # Check that the 'other' config entry is also a tradfri config entry + other_entry = hass.config_entries.async_get_entry(config_entry_id) + + if other_entry is None or other_entry.domain != DOMAIN: + continue + + # Remove non-primary 'tradfri' config entry from device's config_entry_ids + device_reg.async_update_device( + device.id, remove_config_entry_id=config_entry_id + ) + + if config_entry.data[CONF_GATEWAY_ID] in device_id: + continue + + device_reg.async_update_device( + device.id, + new_identifiers={ + (DOMAIN, f"{config_entry.data[CONF_GATEWAY_ID]}-{device_id}") + }, + ) diff --git a/homeassistant/components/tradfri/config_flow.py b/homeassistant/components/tradfri/config_flow.py index 29d876346a7..9f5b39a9657 100644 --- a/homeassistant/components/tradfri/config_flow.py +++ b/homeassistant/components/tradfri/config_flow.py @@ -35,7 +35,7 @@ class AuthError(Exception): class FlowHandler(ConfigFlow, domain=DOMAIN): """Handle a config flow.""" - VERSION = 1 + VERSION = 2 def __init__(self) -> None: """Initialize flow.""" diff --git a/homeassistant/components/tradfri/entity.py b/homeassistant/components/tradfri/entity.py index b06d0081477..41c20b19de5 100644 --- a/homeassistant/components/tradfri/entity.py +++ b/homeassistant/components/tradfri/entity.py @@ -58,7 +58,7 @@ class TradfriBaseEntity(CoordinatorEntity[TradfriDeviceDataUpdateCoordinator]): info = self._device.device_info self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, self._device_id)}, + identifiers={(DOMAIN, f"{gateway_id}-{self._device_id}")}, manufacturer=info.manufacturer, model=info.model_number, name=self._device.name, diff --git a/tests/components/tradfri/__init__.py b/tests/components/tradfri/__init__.py index 37792ae7e32..f73d887d16c 100644 --- a/tests/components/tradfri/__init__.py +++ b/tests/components/tradfri/__init__.py @@ -1,4 +1,6 @@ """Tests for the tradfri component.""" GATEWAY_ID = "mock-gateway-id" +GATEWAY_ID1 = "mockgatewayid1" +GATEWAY_ID2 = "mockgatewayid2" TRADFRI_PATH = "homeassistant.components.tradfri" diff --git a/tests/components/tradfri/test_init.py b/tests/components/tradfri/test_init.py index 54ce469f3c5..a1a4b8d9627 100644 --- a/tests/components/tradfri/test_init.py +++ b/tests/components/tradfri/test_init.py @@ -2,13 +2,19 @@ from unittest.mock import MagicMock +from pytradfri.const import ATTR_FIRMWARE_VERSION, ATTR_GATEWAY_ID +from pytradfri.gateway import Gateway + from homeassistant.components import tradfri +from homeassistant.components.tradfri.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr +from homeassistant.setup import async_setup_component -from . import GATEWAY_ID +from . import GATEWAY_ID, GATEWAY_ID1, GATEWAY_ID2 +from .common import CommandStore -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, load_json_object_fixture async def test_entry_setup_unload( @@ -66,6 +72,7 @@ async def test_remove_stale_devices( device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, identifiers={(tradfri.DOMAIN, "stale_device_id")}, + name="stale-device", ) device_entries = dr.async_entries_for_config_entry( device_registry, config_entry.entry_id @@ -91,3 +98,178 @@ async def test_remove_stale_devices( assert device_entry.manufacturer == "IKEA of Sweden" assert device_entry.name == "Gateway" assert device_entry.model == "E1526" + + +async def test_migrate_config_entry_and_identifiers( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + command_store: CommandStore, +) -> None: + """Test correction of device registry entries.""" + config_entry1 = MockConfigEntry( + domain=tradfri.DOMAIN, + data={ + tradfri.CONF_HOST: "mock-host1", + tradfri.CONF_IDENTITY: "mock-identity1", + tradfri.CONF_KEY: "mock-key1", + tradfri.CONF_GATEWAY_ID: GATEWAY_ID1, + }, + ) + + gateway1 = mock_gateway_fixture(command_store, GATEWAY_ID1) + command_store.register_device( + gateway1, load_json_object_fixture("bulb_w.json", DOMAIN) + ) + config_entry1.add_to_hass(hass) + + config_entry2 = MockConfigEntry( + domain=tradfri.DOMAIN, + data={ + tradfri.CONF_HOST: "mock-host2", + tradfri.CONF_IDENTITY: "mock-identity2", + tradfri.CONF_KEY: "mock-key2", + tradfri.CONF_GATEWAY_ID: GATEWAY_ID2, + }, + ) + + config_entry2.add_to_hass(hass) + + # Add non-tradfri config entry for use in testing negation logic + config_entry3 = MockConfigEntry( + domain="test_domain", + ) + + config_entry3.add_to_hass(hass) + + # Create gateway device for config entry 1 + gateway1_device = device_registry.async_get_or_create( + config_entry_id=config_entry1.entry_id, + identifiers={(config_entry1.domain, config_entry1.data["gateway_id"])}, + name="Gateway", + ) + + # Create bulb 1 on gateway 1 in Device Registry - this has the old identifiers format + gateway1_bulb1 = device_registry.async_get_or_create( + config_entry_id=config_entry1.entry_id, + identifiers={(tradfri.DOMAIN, 65537)}, + name="bulb1", + ) + + # Update bulb 1 device to have both config entry IDs + # This is to simulate existing data scenario with older version of tradfri component + device_registry.async_update_device( + gateway1_bulb1.id, + add_config_entry_id=config_entry2.entry_id, + ) + + # Create bulb 2 on gateway 1 in Device Registry - this has the new identifiers format + gateway1_bulb2 = device_registry.async_get_or_create( + config_entry_id=config_entry1.entry_id, + identifiers={(tradfri.DOMAIN, f"{GATEWAY_ID1}-65538")}, + name="bulb2", + ) + + # Update bulb 2 device to have an additional config entry from config_entry3 + # This is to simulate scenario whereby a device entry + # is shared by multiple config entries + # and where at least one of those config entries is not the 'tradfri' domain + device_registry.async_update_device( + gateway1_bulb2.id, + add_config_entry_id=config_entry3.entry_id, + merge_identifiers={("test_domain", "config_entry_3-device2")}, + ) + + # Create a device on config entry 3 in Device Registry + config_entry3_device = device_registry.async_get_or_create( + config_entry_id=config_entry3.entry_id, + identifiers={("test_domain", "config_entry_3-device1")}, + name="device", + ) + + # Set up all tradfri config entries. + await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + # Validate that gateway 1 bulb 1 is still the same device entry + # This inherently also validates that the device's identifiers + # have been updated to the new unique format + device_entries = dr.async_entries_for_config_entry( + device_registry, config_entry1.entry_id + ) + assert ( + device_registry.async_get_device( + identifiers={(tradfri.DOMAIN, f"{GATEWAY_ID1}-65537")} + ).id + == gateway1_bulb1.id + ) + + # Validate that gateway 1 bulb 1 only has gateway 1's config ID associated to it + # (Device at index 0 is the gateway) + assert device_entries[1].config_entries == {config_entry1.entry_id} + + # Validate that the gateway 1 device is unchanged + assert device_entries[0].id == gateway1_device.id + assert device_entries[0].identifiers == gateway1_device.identifiers + assert device_entries[0].config_entries == gateway1_device.config_entries + + # Validate that gateway 1 bulb 2 now only exists associated to config entry 3. + # The device will have had its identifiers updated to the new format (for the tradfri + # domain) per migrate_config_entry_and_identifiers(). + # The device will have then been removed from config entry 1 (gateway1) + # due to it not matching a device in the command store. + device_entry = device_registry.async_get_device( + identifiers={(tradfri.DOMAIN, f"{GATEWAY_ID1}-65538")} + ) + + assert device_entry.id == gateway1_bulb2.id + # Assert that the only config entry associated to this device is config entry 3 + assert device_entry.config_entries == {config_entry3.entry_id} + # Assert that that device's other identifiers remain untouched + assert device_entry.identifiers == { + (tradfri.DOMAIN, f"{GATEWAY_ID1}-65538"), + ("test_domain", "config_entry_3-device2"), + } + + # Validate that gateway 2 bulb 1 has been added to device registry and with correct unique identifiers + # (This bulb device exists on gateway 2 because the command_store created above will be executed + # for each gateway being set up.) + device_entries = dr.async_entries_for_config_entry( + device_registry, config_entry2.entry_id + ) + assert len(device_entries) == 2 + assert device_entries[1].identifiers == {(tradfri.DOMAIN, f"{GATEWAY_ID2}-65537")} + + # Validate that gateway 2 bulb 1 only has gateway 2's config ID associated to it + assert device_entries[1].config_entries == {config_entry2.entry_id} + + # Validate that config entry 3 device 1 is still present, + # and has not had its config entries or identifiers changed + # N.B. The gateway1_bulb2 device will qualify in this set + # because the config entry 3 was added to it above + device_entries = dr.async_entries_for_config_entry( + device_registry, config_entry3.entry_id + ) + assert len(device_entries) == 2 + assert device_entries[0].id == config_entry3_device.id + assert device_entries[0].identifiers == {("test_domain", "config_entry_3-device1")} + assert device_entries[0].config_entries == {config_entry3.entry_id} + + # Assert that the tradfri config entries have been migrated to v2 and + # the non-tradfri config entry remains at v1 + assert config_entry1.version == 2 + assert config_entry2.version == 2 + assert config_entry3.version == 1 + + +def mock_gateway_fixture(command_store: CommandStore, gateway_id: str) -> Gateway: + """Mock a Tradfri gateway.""" + gateway = Gateway() + command_store.register_response( + gateway.get_gateway_info(), + {ATTR_GATEWAY_ID: gateway_id, ATTR_FIRMWARE_VERSION: "1.2.1234"}, + ) + command_store.register_response( + gateway.get_devices(), + [], + ) + return gateway From 20e48054cf12c34c14aa7c7b1529b9cba45b501f Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 3 Mar 2025 16:08:39 +0100 Subject: [PATCH 2123/3148] Fix stale docstrings in onboarding tests (#139696) --- tests/components/onboarding/test_views.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/components/onboarding/test_views.py b/tests/components/onboarding/test_views.py index b7189bda6cc..d0a6afa50b5 100644 --- a/tests/components/onboarding/test_views.py +++ b/tests/components/onboarding/test_views.py @@ -762,7 +762,7 @@ async def test_onboarding_backup_info( hass_client: ClientSessionGenerator, snapshot: SnapshotAssertion, ) -> None: - """Test returning installation type during onboarding.""" + """Test backup info.""" mock_storage(hass_storage, {"done": []}) assert await async_setup_component(hass, "onboarding", {}) @@ -879,7 +879,7 @@ async def test_onboarding_backup_restore( params: dict[str, Any], expected_kwargs: dict[str, Any], ) -> None: - """Test returning installation type during onboarding.""" + """Test restore backup.""" mock_storage(hass_storage, {"done": []}) assert await async_setup_component(hass, "onboarding", {}) @@ -976,7 +976,7 @@ async def test_onboarding_backup_restore_error( expected_json: str, restore_calls: int, ) -> None: - """Test returning installation type during onboarding.""" + """Test restore backup fails.""" mock_storage(hass_storage, {"done": []}) assert await async_setup_component(hass, "onboarding", {}) @@ -1020,7 +1020,7 @@ async def test_onboarding_backup_restore_unexpected_error( expected_message: str, restore_calls: int, ) -> None: - """Test returning installation type during onboarding.""" + """Test restore backup fails.""" mock_storage(hass_storage, {"done": []}) assert await async_setup_component(hass, "onboarding", {}) @@ -1046,7 +1046,7 @@ async def test_onboarding_backup_upload( hass_storage: dict[str, Any], hass_client: ClientSessionGenerator, ) -> None: - """Test returning installation type during onboarding.""" + """Test upload backup.""" mock_storage(hass_storage, {"done": []}) assert await async_setup_component(hass, "onboarding", {}) From b17ee78dec603f6ab7a8f51cd81079ea50ae8cd0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Mon, 3 Mar 2025 16:51:04 +0100 Subject: [PATCH 2124/3148] Bump hass-nabucasa from 0.92.0 to 0.94.0 (#139697) --- homeassistant/components/cloud/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/cloud/manifest.json b/homeassistant/components/cloud/manifest.json index 4e99d08afb5..7f448f2f614 100644 --- a/homeassistant/components/cloud/manifest.json +++ b/homeassistant/components/cloud/manifest.json @@ -13,6 +13,6 @@ "integration_type": "system", "iot_class": "cloud_push", "loggers": ["acme", "hass_nabucasa", "snitun"], - "requirements": ["hass-nabucasa==0.92.0"], + "requirements": ["hass-nabucasa==0.94.0"], "single_config_entry": true } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 1f1cb3c4f4c..f6181d214e2 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -34,7 +34,7 @@ fnv-hash-fast==1.2.6 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 habluetooth==3.24.1 -hass-nabucasa==0.92.0 +hass-nabucasa==0.94.0 hassil==2.2.3 home-assistant-bluetooth==1.13.1 home-assistant-frontend==20250228.0 diff --git a/pyproject.toml b/pyproject.toml index 6a75ffa002b..7c60a931c91 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,7 +46,7 @@ dependencies = [ "fnv-hash-fast==1.2.6", # hass-nabucasa is imported by helpers which don't depend on the cloud # integration - "hass-nabucasa==0.92.0", + "hass-nabucasa==0.94.0", # When bumping httpx, please check the version pins of # httpcore, anyio, and h11 in gen_requirements_all "httpx==0.28.1", diff --git a/requirements.txt b/requirements.txt index 76c5059e29e..aef3fdb0f09 100644 --- a/requirements.txt +++ b/requirements.txt @@ -21,7 +21,7 @@ certifi>=2021.5.30 ciso8601==2.3.2 cronsim==2.6 fnv-hash-fast==1.2.6 -hass-nabucasa==0.92.0 +hass-nabucasa==0.94.0 httpx==0.28.1 home-assistant-bluetooth==1.13.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 45484a6f2d0..16b72934e6b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1112,7 +1112,7 @@ habiticalib==0.3.7 habluetooth==3.24.1 # homeassistant.components.cloud -hass-nabucasa==0.92.0 +hass-nabucasa==0.94.0 # homeassistant.components.splunk hass-splunk==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d2be4b80bfb..535aa75c62e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -953,7 +953,7 @@ habiticalib==0.3.7 habluetooth==3.24.1 # homeassistant.components.cloud -hass-nabucasa==0.92.0 +hass-nabucasa==0.94.0 # homeassistant.components.conversation hassil==2.2.3 From aaecb47125099bac51cd18f081a9988ba9f4aa4b Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Mon, 3 Mar 2025 17:57:42 +0100 Subject: [PATCH 2125/3148] Add strict typing to Comelit (#139455) * Add quality scale and strict typing to Comelit * mypy * fix strings * remove quality scale * revert quality scale changes * improve typing * letfover * update typing based on new lib * align to platform * cleanup * apply review comments (part 1) * apply review comment ( part 2) * apply review comments * align * align test data * TypedDict * better casting --- .strict-typing | 1 + .../components/comelit/alarm_control_panel.py | 6 +- .../components/comelit/binary_sensor.py | 9 +- homeassistant/components/comelit/climate.py | 120 +++++++----------- .../components/comelit/coordinator.py | 44 +++++-- .../components/comelit/humidifier.py | 73 ++++------- homeassistant/components/comelit/sensor.py | 19 +-- homeassistant/components/comelit/switch.py | 5 +- mypy.ini | 10 ++ tests/components/comelit/const.py | 6 +- .../comelit/snapshots/test_diagnostics.ambr | 4 +- 11 files changed, 140 insertions(+), 157 deletions(-) diff --git a/.strict-typing b/.strict-typing index 8d0d71e85fe..56d3e299281 100644 --- a/.strict-typing +++ b/.strict-typing @@ -136,6 +136,7 @@ homeassistant.components.clicksend.* homeassistant.components.climate.* homeassistant.components.cloud.* homeassistant.components.co2signal.* +homeassistant.components.comelit.* homeassistant.components.command_line.* homeassistant.components.config.* homeassistant.components.configurator.* diff --git a/homeassistant/components/comelit/alarm_control_panel.py b/homeassistant/components/comelit/alarm_control_panel.py index 6ea4e97f12e..0a01dd957a6 100644 --- a/homeassistant/components/comelit/alarm_control_panel.py +++ b/homeassistant/components/comelit/alarm_control_panel.py @@ -6,7 +6,7 @@ import logging from typing import cast from aiocomelit.api import ComelitVedoAreaObject -from aiocomelit.const import ALARM_AREAS, AlarmAreaState +from aiocomelit.const import AlarmAreaState from homeassistant.components.alarm_control_panel import ( AlarmControlPanelEntity, @@ -56,7 +56,7 @@ async def async_setup_entry( async_add_entities( ComelitAlarmEntity(coordinator, device, config_entry.entry_id) - for device in coordinator.data[ALARM_AREAS].values() + for device in coordinator.data["alarm_areas"].values() ) @@ -92,7 +92,7 @@ class ComelitAlarmEntity(CoordinatorEntity[ComelitVedoSystem], AlarmControlPanel @property def _area(self) -> ComelitVedoAreaObject: """Return area object.""" - return self.coordinator.data[ALARM_AREAS][self._area_index] + return self.coordinator.data["alarm_areas"][self._area_index] @property def available(self) -> bool: diff --git a/homeassistant/components/comelit/binary_sensor.py b/homeassistant/components/comelit/binary_sensor.py index a895f8dc511..c17057d19d1 100644 --- a/homeassistant/components/comelit/binary_sensor.py +++ b/homeassistant/components/comelit/binary_sensor.py @@ -5,7 +5,6 @@ from __future__ import annotations from typing import cast from aiocomelit import ComelitVedoZoneObject -from aiocomelit.const import ALARM_ZONES from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, @@ -29,7 +28,7 @@ async def async_setup_entry( async_add_entities( ComelitVedoBinarySensorEntity(coordinator, device, config_entry.entry_id) - for device in coordinator.data[ALARM_ZONES].values() + for device in coordinator.data["alarm_zones"].values() ) @@ -49,7 +48,7 @@ class ComelitVedoBinarySensorEntity( ) -> None: """Init sensor entity.""" self._api = coordinator.api - self._zone = zone + self._zone_index = zone.index super().__init__(coordinator) # Use config_entry.entry_id as base for unique_id # because no serial number or mac is available @@ -59,4 +58,6 @@ class ComelitVedoBinarySensorEntity( @property def is_on(self) -> bool: """Presence detected.""" - return self.coordinator.data[ALARM_ZONES][self._zone.index].status_api == "0001" + return ( + self.coordinator.data["alarm_zones"][self._zone_index].status_api == "0001" + ) diff --git a/homeassistant/components/comelit/climate.py b/homeassistant/components/comelit/climate.py index 6906c9bf735..3433d1bdf04 100644 --- a/homeassistant/components/comelit/climate.py +++ b/homeassistant/components/comelit/climate.py @@ -3,7 +3,7 @@ from __future__ import annotations from enum import StrEnum -from typing import Any, cast +from typing import Any, TypedDict, cast from aiocomelit import ComelitSerialBridgeObject from aiocomelit.const import CLIMATE @@ -16,7 +16,8 @@ from homeassistant.components.climate import ( UnitOfTemperature, ) from homeassistant.const import ATTR_TEMPERATURE, PRECISION_TENTHS -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -42,22 +43,23 @@ class ClimaComelitCommand(StrEnum): AUTO = "auto" -API_STATUS: dict[str, dict[str, Any]] = { - ClimaComelitMode.OFF: { - "action": "off", - "hvac_mode": HVACMode.OFF, - "hvac_action": HVACAction.OFF, - }, - ClimaComelitMode.LOWER: { - "action": "lower", - "hvac_mode": HVACMode.COOL, - "hvac_action": HVACAction.COOLING, - }, - ClimaComelitMode.UPPER: { - "action": "upper", - "hvac_mode": HVACMode.HEAT, - "hvac_action": HVACAction.HEATING, - }, +class ClimaComelitApiStatus(TypedDict): + """Comelit Clima API status.""" + + hvac_mode: HVACMode + hvac_action: HVACAction + + +API_STATUS: dict[str, ClimaComelitApiStatus] = { + ClimaComelitMode.OFF: ClimaComelitApiStatus( + hvac_mode=HVACMode.OFF, hvac_action=HVACAction.OFF + ), + ClimaComelitMode.LOWER: ClimaComelitApiStatus( + hvac_mode=HVACMode.COOL, hvac_action=HVACAction.COOLING + ), + ClimaComelitMode.UPPER: ClimaComelitApiStatus( + hvac_mode=HVACMode.HEAT, hvac_action=HVACAction.HEATING + ), } MODE_TO_ACTION: dict[HVACMode, ClimaComelitCommand] = { @@ -114,69 +116,41 @@ class ComelitClimateEntity(CoordinatorEntity[ComelitSerialBridge], ClimateEntity self._attr_unique_id = f"{config_entry_entry_id}-{device.index}" self._attr_device_info = coordinator.platform_device_info(device, device.type) - @property - def _clima(self) -> list[Any]: - """Return clima device data.""" + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + device = self.coordinator.data[CLIMATE][self._device.index] + if not isinstance(device.val, list): + raise HomeAssistantError("Invalid clima data") + # CLIMATE has a 2 item tuple: # - first for Clima # - second for Humidifier - return self.coordinator.data[CLIMATE][self._device.index].val[0] + values = device.val[0] - @property - def _api_mode(self) -> str: - """Return device mode.""" - # Values from API: "O", "L", "U" - return self._clima[2] + _active = values[1] + _mode = values[2] # Values from API: "O", "L", "U" + _automatic = values[3] == ClimaComelitMode.AUTO - @property - def _api_active(self) -> bool: - "Return device active/idle." - return self._clima[1] + self._attr_current_temperature = values[0] / 10 - @property - def _api_automatic(self) -> bool: - """Return device in automatic/manual mode.""" - return self._clima[3] == ClimaComelitMode.AUTO + self._attr_hvac_action = None + if _mode == ClimaComelitMode.OFF: + self._attr_hvac_action = HVACAction.OFF + if not _active: + self._attr_hvac_action = HVACAction.IDLE + if _mode in API_STATUS: + self._attr_hvac_action = API_STATUS[_mode]["hvac_action"] - @property - def target_temperature(self) -> float: - """Set target temperature.""" - return self._clima[4] / 10 + self._attr_hvac_mode = None + if _mode == ClimaComelitMode.OFF: + self._attr_hvac_mode = HVACMode.OFF + if _automatic: + self._attr_hvac_mode = HVACMode.AUTO + if _mode in API_STATUS: + self._attr_hvac_mode = API_STATUS[_mode]["hvac_mode"] - @property - def current_temperature(self) -> float: - """Return current temperature.""" - return self._clima[0] / 10 - - @property - def hvac_mode(self) -> HVACMode | None: - """HVAC current mode.""" - - if self._api_mode == ClimaComelitMode.OFF: - return HVACMode.OFF - - if self._api_automatic: - return HVACMode.AUTO - - if self._api_mode in API_STATUS: - return API_STATUS[self._api_mode]["hvac_mode"] - - return None - - @property - def hvac_action(self) -> HVACAction | None: - """HVAC current action.""" - - if self._api_mode == ClimaComelitMode.OFF: - return HVACAction.OFF - - if not self._api_active: - return HVACAction.IDLE - - if self._api_mode in API_STATUS: - return API_STATUS[self._api_mode]["hvac_action"] - - return None + self._attr_target_temperature = values[4] / 10 async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" diff --git a/homeassistant/components/comelit/coordinator.py b/homeassistant/components/comelit/coordinator.py index fcb149b21d6..a569a397c85 100644 --- a/homeassistant/components/comelit/coordinator.py +++ b/homeassistant/components/comelit/coordinator.py @@ -2,7 +2,7 @@ from abc import abstractmethod from datetime import timedelta -from typing import Any +from typing import TypedDict, TypeVar, cast from aiocomelit import ( ComeliteSerialBridgeApi, @@ -13,7 +13,7 @@ from aiocomelit import ( exceptions, ) from aiocomelit.api import ComelitCommonApi -from aiocomelit.const import BRIDGE, VEDO +from aiocomelit.const import ALARM_AREAS, ALARM_ZONES, BRIDGE, VEDO from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -26,7 +26,20 @@ from .const import _LOGGER, DOMAIN type ComelitConfigEntry = ConfigEntry[ComelitBaseCoordinator] -class ComelitBaseCoordinator(DataUpdateCoordinator[dict[str, Any]]): +class AlarmDataObject(TypedDict): + """TypedDict for Alarm data objects.""" + + alarm_areas: dict[int, ComelitVedoAreaObject] + alarm_zones: dict[int, ComelitVedoZoneObject] + + +T = TypeVar( + "T", + bound=dict[str, dict[int, ComelitSerialBridgeObject]] | AlarmDataObject, +) + + +class ComelitBaseCoordinator(DataUpdateCoordinator[T]): """Base coordinator for Comelit Devices.""" _hw_version: str @@ -81,7 +94,7 @@ class ComelitBaseCoordinator(DataUpdateCoordinator[dict[str, Any]]): hw_version=self._hw_version, ) - async def _async_update_data(self) -> dict[str, Any]: + async def _async_update_data(self) -> T: """Update device data.""" _LOGGER.debug("Polling Comelit %s host: %s", self._device, self._host) try: @@ -93,11 +106,13 @@ class ComelitBaseCoordinator(DataUpdateCoordinator[dict[str, Any]]): raise ConfigEntryAuthFailed from err @abstractmethod - async def _async_update_system_data(self) -> dict[str, Any]: + async def _async_update_system_data(self) -> T: """Class method for updating data.""" -class ComelitSerialBridge(ComelitBaseCoordinator): +class ComelitSerialBridge( + ComelitBaseCoordinator[dict[str, dict[int, ComelitSerialBridgeObject]]] +): """Queries Comelit Serial Bridge.""" _hw_version = "20003101" @@ -115,12 +130,14 @@ class ComelitSerialBridge(ComelitBaseCoordinator): self.api = ComeliteSerialBridgeApi(host, port, pin) super().__init__(hass, entry, BRIDGE, host) - async def _async_update_system_data(self) -> dict[str, Any]: + async def _async_update_system_data( + self, + ) -> dict[str, dict[int, ComelitSerialBridgeObject]]: """Specific method for updating data.""" return await self.api.get_all_devices() -class ComelitVedoSystem(ComelitBaseCoordinator): +class ComelitVedoSystem(ComelitBaseCoordinator[AlarmDataObject]): """Queries Comelit VEDO system.""" _hw_version = "VEDO IP" @@ -138,6 +155,13 @@ class ComelitVedoSystem(ComelitBaseCoordinator): self.api = ComelitVedoApi(host, port, pin) super().__init__(hass, entry, VEDO, host) - async def _async_update_system_data(self) -> dict[str, Any]: + async def _async_update_system_data( + self, + ) -> AlarmDataObject: """Specific method for updating data.""" - return await self.api.get_all_areas_and_zones() + data = await self.api.get_all_areas_and_zones() + + return AlarmDataObject( + alarm_areas=cast(dict[int, ComelitVedoAreaObject], data[ALARM_AREAS]), + alarm_zones=cast(dict[int, ComelitVedoZoneObject], data[ALARM_ZONES]), + ) diff --git a/homeassistant/components/comelit/humidifier.py b/homeassistant/components/comelit/humidifier.py index 5daf2297782..da6d44b1bbe 100644 --- a/homeassistant/components/comelit/humidifier.py +++ b/homeassistant/components/comelit/humidifier.py @@ -16,8 +16,8 @@ from homeassistant.components.humidifier import ( HumidifierEntity, HumidifierEntityFeature, ) -from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ServiceValidationError +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -122,61 +122,32 @@ class ComelitHumidifierEntity(CoordinatorEntity[ComelitSerialBridge], Humidifier self._active_action = active_action self._set_command = set_command - @property - def _humidifier(self) -> list[Any]: - """Return humidifier device data.""" + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + device = self.coordinator.data[CLIMATE][self._device.index] + if not isinstance(device.val, list): + raise HomeAssistantError("Invalid clima data") + # CLIMATE has a 2 item tuple: # - first for Clima # - second for Humidifier - return self.coordinator.data[CLIMATE][self._device.index].val[1] + values = device.val[1] - @property - def _api_mode(self) -> str: - """Return device mode.""" - # Values from API: "O", "L", "U" - return self._humidifier[2] + _active = values[1] + _mode = values[2] # Values from API: "O", "L", "U" + _automatic = values[3] == HumidifierComelitMode.AUTO - @property - def _api_active(self) -> bool: - "Return device active/idle." - return self._humidifier[1] + self._attr_action = HumidifierAction.IDLE + if _mode == HumidifierComelitMode.OFF: + self._attr_action = HumidifierAction.OFF + if _active and _mode == self._active_mode: + self._attr_action = self._active_action - @property - def _api_automatic(self) -> bool: - """Return device in automatic/manual mode.""" - return self._humidifier[3] == HumidifierComelitMode.AUTO - - @property - def target_humidity(self) -> float: - """Set target humidity.""" - return self._humidifier[4] / 10 - - @property - def current_humidity(self) -> float: - """Return current humidity.""" - return self._humidifier[0] / 10 - - @property - def is_on(self) -> bool | None: - """Return true is humidifier is on.""" - return self._api_mode == self._active_mode - - @property - def mode(self) -> str | None: - """Return current mode.""" - return MODE_AUTO if self._api_automatic else MODE_NORMAL - - @property - def action(self) -> HumidifierAction | None: - """Return current action.""" - - if self._api_mode == HumidifierComelitMode.OFF: - return HumidifierAction.OFF - - if self._api_active and self._api_mode == self._active_mode: - return self._active_action - - return HumidifierAction.IDLE + self._attr_current_humidity = values[0] / 10 + self._attr_is_on = _mode == self._active_mode + self._attr_mode = MODE_AUTO if _automatic else MODE_NORMAL + self._attr_target_humidity = values[4] / 10 async def async_set_humidity(self, humidity: int) -> None: """Set new target humidity.""" diff --git a/homeassistant/components/comelit/sensor.py b/homeassistant/components/comelit/sensor.py index 9200d99262f..3d57d9dca9c 100644 --- a/homeassistant/components/comelit/sensor.py +++ b/homeassistant/components/comelit/sensor.py @@ -5,7 +5,7 @@ from __future__ import annotations from typing import Final, cast from aiocomelit import ComelitSerialBridgeObject, ComelitVedoZoneObject -from aiocomelit.const import ALARM_ZONES, BRIDGE, OTHER, AlarmZoneState +from aiocomelit.const import BRIDGE, OTHER, AlarmZoneState from homeassistant.components.sensor import ( SensorDeviceClass, @@ -82,7 +82,7 @@ async def async_setup_vedo_entry( coordinator = cast(ComelitVedoSystem, config_entry.runtime_data) entities: list[ComelitVedoSensorEntity] = [] - for device in coordinator.data[ALARM_ZONES].values(): + for device in coordinator.data["alarm_zones"].values(): entities.extend( ComelitVedoSensorEntity( coordinator, device, config_entry.entry_id, sensor_desc @@ -119,9 +119,12 @@ class ComelitBridgeSensorEntity(CoordinatorEntity[ComelitSerialBridge], SensorEn @property def native_value(self) -> StateType: """Sensor value.""" - return getattr( - self.coordinator.data[OTHER][self._device.index], - self.entity_description.key, + return cast( + StateType, + getattr( + self.coordinator.data[OTHER][self._device.index], + self.entity_description.key, + ), ) @@ -139,7 +142,7 @@ class ComelitVedoSensorEntity(CoordinatorEntity[ComelitVedoSystem], SensorEntity ) -> None: """Init sensor entity.""" self._api = coordinator.api - self._zone = zone + self._zone_index = zone.index super().__init__(coordinator) # Use config_entry.entry_id as base for unique_id # because no serial number or mac is available @@ -151,7 +154,7 @@ class ComelitVedoSensorEntity(CoordinatorEntity[ComelitVedoSystem], SensorEntity @property def _zone_object(self) -> ComelitVedoZoneObject: """Zone object.""" - return self.coordinator.data[ALARM_ZONES][self._zone.index] + return self.coordinator.data["alarm_zones"][self._zone_index] @property def available(self) -> bool: @@ -164,4 +167,4 @@ class ComelitVedoSensorEntity(CoordinatorEntity[ComelitVedoSystem], SensorEntity if (status := self._zone_object.human_status) == AlarmZoneState.UNKNOWN: return None - return status.value + return cast(str, status.value) diff --git a/homeassistant/components/comelit/switch.py b/homeassistant/components/comelit/switch.py index e89ee74c1be..f6e5b192c38 100644 --- a/homeassistant/components/comelit/switch.py +++ b/homeassistant/components/comelit/switch.py @@ -77,7 +77,4 @@ class ComelitSwitchEntity(CoordinatorEntity[ComelitSerialBridge], SwitchEntity): @property def is_on(self) -> bool: """Return True if switch is on.""" - return ( - self.coordinator.data[self._device.type][self._device.index].status - == STATE_ON - ) + return self.coordinator.data[OTHER][self._device.index].status == STATE_ON diff --git a/mypy.ini b/mypy.ini index c69401b8605..520fad7d738 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1115,6 +1115,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.comelit.*] +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.command_line.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/tests/components/comelit/const.py b/tests/components/comelit/const.py index 3151b83d175..20324765a0b 100644 --- a/tests/components/comelit/const.py +++ b/tests/components/comelit/const.py @@ -6,6 +6,8 @@ from aiocomelit import ( ComelitVedoZoneObject, ) from aiocomelit.const import ( + ALARM_AREAS, + ALARM_ZONES, CLIMATE, COVER, IRRIGATION, @@ -63,7 +65,7 @@ BRIDGE_DEVICE_QUERY = { } VEDO_DEVICE_QUERY = { - "aree": { + ALARM_AREAS: { 0: ComelitVedoAreaObject( index=0, name="Area0", @@ -80,7 +82,7 @@ VEDO_DEVICE_QUERY = { human_status=AlarmAreaState.UNKNOWN, ) }, - "zone": { + ALARM_ZONES: { 0: ComelitVedoZoneObject( index=0, name="Zone0", diff --git a/tests/components/comelit/snapshots/test_diagnostics.ambr b/tests/components/comelit/snapshots/test_diagnostics.ambr index b9891eb3209..c4544f38f52 100644 --- a/tests/components/comelit/snapshots/test_diagnostics.ambr +++ b/tests/components/comelit/snapshots/test_diagnostics.ambr @@ -86,7 +86,7 @@ 'device_info': dict({ 'devices': list([ dict({ - 'aree': list([ + 'alarm_areas': list([ dict({ '0': dict({ 'alarm': False, @@ -106,7 +106,7 @@ ]), }), dict({ - 'zone': list([ + 'alarm_zones': list([ dict({ '0': dict({ 'human_status': 'rest', From 229407d6852af81aa4fb177bf2f52d08e8673814 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 3 Mar 2025 18:25:18 +0100 Subject: [PATCH 2126/3148] Fix missing sentence-casing in three Fully Kiosk Browser strings (#139705) Fix missing sentence-casing in Fully Kiosk Browser strings --- homeassistant/components/fully_kiosk/strings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/fully_kiosk/strings.json b/homeassistant/components/fully_kiosk/strings.json index a4b466926f0..5841456c034 100644 --- a/homeassistant/components/fully_kiosk/strings.json +++ b/homeassistant/components/fully_kiosk/strings.json @@ -1,6 +1,6 @@ { "common": { - "data_description_password": "The Remote Admin Password from the Fully Kiosk Browser app settings.", + "data_description_password": "The Remote Admin password from the Fully Kiosk Browser app settings.", "data_description_ssl": "Is the Fully Kiosk app configured to require SSL for the connection?", "data_description_verify_ssl": "Should SSL certificartes be verified? This should be off for self-signed certificates." }, @@ -151,7 +151,7 @@ } }, "set_config": { - "name": "Set Configuration", + "name": "Set configuration", "description": "Sets a configuration parameter on Fully Kiosk Browser.", "fields": { "key": { @@ -165,7 +165,7 @@ } }, "start_application": { - "name": "Start Application", + "name": "Start application", "description": "Starts an application on the device running Fully Kiosk Browser.", "fields": { "application": { From 1b15df3075843d37b1ad9b13a29e07e08642f08b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 3 Mar 2025 10:44:49 -0700 Subject: [PATCH 2127/3148] Bump ESPHome stable BLE version to 2025.2.2 (#139704) ensure proxies have https://github.com/esphome/esphome/pull/8328 so they do not reboot themselves if disconnecting takes too long --- homeassistant/components/esphome/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/esphome/const.py b/homeassistant/components/esphome/const.py index 18d15d0fbbd..c7cd7fdcdf0 100644 --- a/homeassistant/components/esphome/const.py +++ b/homeassistant/components/esphome/const.py @@ -14,7 +14,7 @@ DEFAULT_ALLOW_SERVICE_CALLS = True DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS = False -STABLE_BLE_VERSION_STR = "2025.2.1" +STABLE_BLE_VERSION_STR = "2025.2.2" STABLE_BLE_VERSION = AwesomeVersion(STABLE_BLE_VERSION_STR) PROJECT_URLS = { "esphome.bluetooth-proxy": "https://esphome.github.io/bluetooth-proxies/", From 62b6be900fa7341a318ad150d0f3ebb877dbee16 Mon Sep 17 00:00:00 2001 From: Elias Wernicke Date: Mon, 3 Mar 2025 19:16:43 +0100 Subject: [PATCH 2128/3148] Add complete item intent function for todo component (#127806) * add complete item intent * fix error and add tests * fix merge conflict * improve error tests * improve error tests * add response_key * add check for non completed --------- Co-authored-by: Michael Hansen --- homeassistant/components/todo/intent.py | 84 ++++++++++++++++- tests/components/todo/__init__.py | 7 ++ tests/components/todo/test_intent.py | 116 ++++++++++++++++++++++++ 3 files changed, 205 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/todo/intent.py b/homeassistant/components/todo/intent.py index c678408a576..d679a57bf96 100644 --- a/homeassistant/components/todo/intent.py +++ b/homeassistant/components/todo/intent.py @@ -11,11 +11,13 @@ from . import TodoItem, TodoItemStatus, TodoListEntity from .const import DATA_COMPONENT, DOMAIN INTENT_LIST_ADD_ITEM = "HassListAddItem" +INTENT_LIST_COMPLETE_ITEM = "HassListCompleteItem" async def async_setup_intents(hass: HomeAssistant) -> None: """Set up the todo intents.""" intent.async_register(hass, ListAddItemIntent()) + intent.async_register(hass, ListCompleteItemIntent()) class ListAddItemIntent(intent.IntentHandler): @@ -53,14 +55,92 @@ class ListAddItemIntent(intent.IntentHandler): match_result.states[0].entity_id ) if target_list is None: - raise intent.IntentHandleError(f"No to-do list: {list_name}") + raise intent.IntentHandleError( + f"No to-do list: {list_name}", "list_not_found" + ) # Add to list await target_list.async_create_todo_item( TodoItem(summary=item, status=TodoItemStatus.NEEDS_ACTION) ) - response = intent_obj.create_response() + response: intent.IntentResponse = intent_obj.create_response() + response.response_type = intent.IntentResponseType.ACTION_DONE + response.async_set_results( + [ + intent.IntentResponseTarget( + type=intent.IntentResponseTargetType.ENTITY, + name=list_name, + id=match_result.states[0].entity_id, + ) + ] + ) + return response + + +class ListCompleteItemIntent(intent.IntentHandler): + """Handle ListCompleteItem intents.""" + + intent_type = INTENT_LIST_COMPLETE_ITEM + description = "Complete item on a todo list" + slot_schema = { + vol.Required("item"): intent.non_empty_string, + vol.Required("name"): intent.non_empty_string, + } + platforms = {DOMAIN} + + async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse: + """Handle the intent.""" + hass = intent_obj.hass + + slots = self.async_validate_slots(intent_obj.slots) + item = slots["item"]["value"] + list_name = slots["name"]["value"] + + target_list: TodoListEntity | None = None + + # Find matching list + match_constraints = intent.MatchTargetsConstraints( + name=list_name, domains=[DOMAIN], assistant=intent_obj.assistant + ) + match_result = intent.async_match_targets(hass, match_constraints) + if not match_result.is_match: + raise intent.MatchFailedError( + result=match_result, constraints=match_constraints + ) + + target_list = hass.data[DATA_COMPONENT].get_entity( + match_result.states[0].entity_id + ) + if target_list is None: + raise intent.IntentHandleError( + f"No to-do list: {list_name}", "list_not_found" + ) + + # Find item in list + matching_item = None + for todo_item in target_list.todo_items or (): + if ( + item in (todo_item.uid, todo_item.summary) + and todo_item.status == TodoItemStatus.NEEDS_ACTION + ): + matching_item = todo_item + break + if not matching_item or not matching_item.uid: + raise intent.IntentHandleError( + f"Item '{item}' not found on list", "item_not_found" + ) + + # Mark as completed + await target_list.async_update_todo_item( + TodoItem( + uid=matching_item.uid, + summary=matching_item.summary, + status=TodoItemStatus.COMPLETED, + ) + ) + + response: intent.IntentResponse = intent_obj.create_response() response.response_type = intent.IntentResponseType.ACTION_DONE response.async_set_results( [ diff --git a/tests/components/todo/__init__.py b/tests/components/todo/__init__.py index 53772ab144e..239b586d366 100644 --- a/tests/components/todo/__init__.py +++ b/tests/components/todo/__init__.py @@ -34,6 +34,13 @@ class MockTodoListEntity(TodoListEntity): """Delete an item in the To-do list.""" self._attr_todo_items = [item for item in self.items if item.uid not in uids] + async def async_update_todo_item(self, item: TodoItem) -> None: + """Update an item in the To-do list.""" + for idx, existing_item in enumerate(self.items): + if existing_item.uid == item.uid: + self._attr_todo_items[idx] = item + break + async def create_mock_platform( hass: HomeAssistant, diff --git a/tests/components/todo/test_intent.py b/tests/components/todo/test_intent.py index cd074816e7e..3f86347d1b7 100644 --- a/tests/components/todo/test_intent.py +++ b/tests/components/todo/test_intent.py @@ -1,5 +1,7 @@ """Tests for the todo intents.""" +from unittest.mock import patch + import pytest from homeassistant.components import conversation @@ -7,10 +9,12 @@ from homeassistant.components.homeassistant.exposed_entities import async_expose from homeassistant.components.todo import ( ATTR_ITEM, DOMAIN, + TodoItem, TodoItemStatus, TodoListEntity, intent as todo_intent, ) +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ATTR_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers import intent @@ -18,6 +22,7 @@ from homeassistant.setup import async_setup_component from . import MockTodoListEntity, create_mock_platform +from tests.common import async_mock_service from tests.typing import WebSocketGenerator @@ -174,3 +179,114 @@ async def test_add_item_intent_errors( }, assistant=conversation.DOMAIN, ) + + +async def test_complete_item_intent( + hass: HomeAssistant, +) -> None: + """Test the complete item intent.""" + entity1 = MockTodoListEntity( + [ + TodoItem(summary="beer", uid="1", status=TodoItemStatus.NEEDS_ACTION), + TodoItem(summary="wine", uid="2", status=TodoItemStatus.NEEDS_ACTION), + ] + ) + entity1._attr_name = "List 1" + entity1.entity_id = "todo.list_1" + + # Add entities to hass + config_entry = await create_mock_platform(hass, [entity1]) + assert config_entry.state is ConfigEntryState.LOADED + + assert len(entity1.items) == 2 + assert entity1.items[0].status == TodoItemStatus.NEEDS_ACTION + + # Complete item + async_mock_service(hass, DOMAIN, todo_intent.INTENT_LIST_COMPLETE_ITEM) + response = await intent.async_handle( + hass, + DOMAIN, + todo_intent.INTENT_LIST_COMPLETE_ITEM, + {ATTR_ITEM: {"value": "beer"}, ATTR_NAME: {"value": "list 1"}}, + assistant=conversation.DOMAIN, + ) + assert response.response_type == intent.IntentResponseType.ACTION_DONE + + assert len(entity1.items) == 2 + assert entity1.items[0].status == TodoItemStatus.COMPLETED + + +async def test_complete_item_intent_errors( + hass: HomeAssistant, + test_entity: TodoListEntity, +) -> None: + """Test errors with the complete item intent.""" + entity1 = MockTodoListEntity( + [ + TodoItem(summary="beer", uid="1", status=TodoItemStatus.COMPLETED), + ] + ) + entity1._attr_name = "List 1" + entity1.entity_id = "todo.list_1" + + # Add entities to hass + await create_mock_platform(hass, [entity1]) + + # Try to complete item in list that does not exist + with pytest.raises(intent.MatchFailedError): + await intent.async_handle( + hass, + "test", + todo_intent.INTENT_LIST_COMPLETE_ITEM, + { + ATTR_ITEM: {"value": "wine"}, + ATTR_NAME: {"value": "This list does not exist"}, + }, + assistant=conversation.DOMAIN, + ) + + # Try to complete item that does not exist + with pytest.raises(intent.IntentHandleError): + await intent.async_handle( + hass, + "test", + todo_intent.INTENT_LIST_COMPLETE_ITEM, + {ATTR_ITEM: {"value": "bread"}, ATTR_NAME: {"value": "list 1"}}, + assistant=conversation.DOMAIN, + ) + + # Item is already completed + with pytest.raises(intent.IntentHandleError): + await intent.async_handle( + hass, + "test", + todo_intent.INTENT_LIST_COMPLETE_ITEM, + {ATTR_ITEM: {"value": "beer"}, ATTR_NAME: {"value": "list 1"}}, + assistant=conversation.DOMAIN, + ) + + +async def test_complete_item_intent_ha_errors( + hass: HomeAssistant, + test_entity: TodoListEntity, +) -> None: + """Test error handling of HA errors with the complete item intent.""" + test_entity._attr_name = "List 1" + test_entity.entity_id = "todo.list_1" + await create_mock_platform(hass, [test_entity]) + + # Mock the get_entity method to return None + with ( + patch( + "homeassistant.helpers.entity_component.EntityComponent.get_entity", + return_value=None, + ), + pytest.raises(intent.IntentHandleError), + ): + await intent.async_handle( + hass, + DOMAIN, + todo_intent.INTENT_LIST_COMPLETE_ITEM, + {ATTR_ITEM: {"value": "wine"}, ATTR_NAME: {"value": "List 1"}}, + assistant=conversation.DOMAIN, + ) From f248901ea82db566bf31750c438f93cae8a29424 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 3 Mar 2025 19:55:47 +0100 Subject: [PATCH 2129/3148] Grammar fixes in user-facing strings of the LinkPlay integration (#139709) Grammar fixes in user-facing string of the LinkPlay integration Fix spelling of "set up", "media player", "ID" and improve the descriptions of the `play_preset` action. --- homeassistant/components/linkplay/strings.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/linkplay/strings.json b/homeassistant/components/linkplay/strings.json index 31b4649e131..5d68754879c 100644 --- a/homeassistant/components/linkplay/strings.json +++ b/homeassistant/components/linkplay/strings.json @@ -11,7 +11,7 @@ } }, "discovery_confirm": { - "description": "Do you want to setup {name}?" + "description": "Do you want to set up {name}?" } }, "abort": { @@ -26,11 +26,11 @@ "services": { "play_preset": { "name": "Play preset", - "description": "Play the preset number on the device.", + "description": "Plays a preset on the device.", "fields": { "preset_number": { "name": "Preset number", - "description": "The preset number on the device to play." + "description": "The number of the preset to play." } } } @@ -44,7 +44,7 @@ }, "exceptions": { "invalid_grouping_entity": { - "message": "Entity with id {entity_id} can't be added to the LinkPlay multiroom. Is the entity a LinkPlay mediaplayer?" + "message": "Entity with ID {entity_id} can't be added to the LinkPlay multiroom. Is the entity a LinkPlay media player?" } } } From 2c44043e6af150bbcaf1bc03b126b4c210654359 Mon Sep 17 00:00:00 2001 From: Andrew Jackson Date: Mon, 3 Mar 2025 18:57:30 +0000 Subject: [PATCH 2130/3148] Bump mastodon.py to 2.0.1 (#139701) * Bump mastodon to 2.0.1 * Fix mypy --- homeassistant/components/mastodon/manifest.json | 2 +- homeassistant/components/mastodon/notify.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/mastodon/manifest.json b/homeassistant/components/mastodon/manifest.json index 20c506e7766..d7b21ad3a0c 100644 --- a/homeassistant/components/mastodon/manifest.json +++ b/homeassistant/components/mastodon/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["mastodon"], - "requirements": ["Mastodon.py==1.8.1"] + "requirements": ["Mastodon.py==2.0.1"] } diff --git a/homeassistant/components/mastodon/notify.py b/homeassistant/components/mastodon/notify.py index 8e7e9dc1947..8af98ec3ab1 100644 --- a/homeassistant/components/mastodon/notify.py +++ b/homeassistant/components/mastodon/notify.py @@ -52,7 +52,7 @@ async def async_get_service( if discovery_info is None: return None - client: Mastodon = discovery_info.get("client") + client = cast(Mastodon, discovery_info.get("client")) return MastodonNotificationService(hass, client) diff --git a/requirements_all.txt b/requirements_all.txt index 16b72934e6b..a82ae4d16e2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -22,7 +22,7 @@ HAP-python==4.9.2 HATasmota==0.10.0 # homeassistant.components.mastodon -Mastodon.py==1.8.1 +Mastodon.py==2.0.1 # homeassistant.components.doods # homeassistant.components.generic diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 535aa75c62e..b3eff2acd7f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -22,7 +22,7 @@ HAP-python==4.9.2 HATasmota==0.10.0 # homeassistant.components.mastodon -Mastodon.py==1.8.1 +Mastodon.py==2.0.1 # homeassistant.components.doods # homeassistant.components.generic From e47e15125922c9078285b3144fbc0bf963589448 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Mon, 3 Mar 2025 11:02:45 -0800 Subject: [PATCH 2131/3148] Add additional roborock debug logging (#139680) --- homeassistant/components/roborock/__init__.py | 1 + homeassistant/components/roborock/coordinator.py | 1 + 2 files changed, 2 insertions(+) diff --git a/homeassistant/components/roborock/__init__.py b/homeassistant/components/roborock/__init__.py index 1c25d527aa8..955e50cd15b 100644 --- a/homeassistant/components/roborock/__init__.py +++ b/homeassistant/components/roborock/__init__.py @@ -65,6 +65,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: RoborockConfigEntry) -> translation_key="no_user_agreement", ) from err except RoborockException as err: + _LOGGER.debug("Failed to get Roborock home data: %s", err) raise ConfigEntryNotReady( "Failed to get Roborock home data", translation_domain=DOMAIN, diff --git a/homeassistant/components/roborock/coordinator.py b/homeassistant/components/roborock/coordinator.py index b35f62323e8..6690b0ac07e 100644 --- a/homeassistant/components/roborock/coordinator.py +++ b/homeassistant/components/roborock/coordinator.py @@ -179,6 +179,7 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): # Get the rooms for that map id. await self.set_current_map_rooms() except RoborockException as ex: + _LOGGER.debug("Failed to update data: %s", ex) raise UpdateFailed(ex) from ex return self.roborock_device_info.props From dcd2d428940ef1f46cac738ae2b085dff9c64486 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 3 Mar 2025 20:07:07 +0100 Subject: [PATCH 2132/3148] Abort SmartThings flow if default_config is not enabled (#139700) * Abort SmartThings flow if default_config is not enabled * Abort SmartThings flow if default_config is not enabled * Abort SmartThings flow if default_config is not enabled --- .../components/smartthings/config_flow.py | 11 +++ .../components/smartthings/strings.json | 3 +- .../smartthings/test_config_flow.py | 82 +++++++++++++++++-- 3 files changed, 89 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/smartthings/config_flow.py b/homeassistant/components/smartthings/config_flow.py index 02b11b190c9..d2654348527 100644 --- a/homeassistant/components/smartthings/config_flow.py +++ b/homeassistant/components/smartthings/config_flow.py @@ -32,6 +32,17 @@ class SmartThingsConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN): """Extra data that needs to be appended to the authorize url.""" return {"scope": " ".join(REQUESTED_SCOPES)} + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Check we have the cloud integration set up.""" + if "cloud" not in self.hass.config.components: + return self.async_abort( + reason="cloud_not_enabled", + description_placeholders={"default_config": "default_config"}, + ) + return await super().async_step_user(user_input) + async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResult: """Create an entry for SmartThings.""" if not set(data[CONF_TOKEN]["scope"].split()) >= set(SCOPES): diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index 9fd417284af..844ebd12004 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -24,7 +24,8 @@ "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "reauth_account_mismatch": "Authenticated account does not match the account to be reauthenticated. Please log in with the correct account and pick the right location.", "reauth_location_mismatch": "Authenticated location does not match the location to be reauthenticated. Please log in with the correct account and pick the right location.", - "missing_scopes": "Authentication failed. Please make sure you have granted all required permissions." + "missing_scopes": "Authentication failed. Please make sure you have granted all required permissions.", + "cloud_not_enabled": "Please make sure you run Home Assistant with `{default_config}` enabled in your configuration.yaml." } }, "entity": { diff --git a/tests/components/smartthings/test_config_flow.py b/tests/components/smartthings/test_config_flow.py index a16747c1190..7472d7d6b71 100644 --- a/tests/components/smartthings/test_config_flow.py +++ b/tests/components/smartthings/test_config_flow.py @@ -28,7 +28,13 @@ from tests.test_util.aiohttp import AiohttpClientMocker from tests.typing import ClientSessionGenerator -@pytest.mark.usefixtures("current_request_with_host") +@pytest.fixture +def use_cloud(hass: HomeAssistant) -> None: + """Set up the cloud component.""" + hass.config.components.add("cloud") + + +@pytest.mark.usefixtures("current_request_with_host", "use_cloud") async def test_full_flow( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, @@ -100,7 +106,7 @@ async def test_full_flow( assert result["result"].unique_id == "397678e5-9995-4a39-9d9f-ae6ba310236c" -@pytest.mark.usefixtures("current_request_with_host") +@pytest.mark.usefixtures("current_request_with_host", "use_cloud") async def test_not_enough_scopes( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, @@ -161,7 +167,7 @@ async def test_not_enough_scopes( assert result["reason"] == "missing_scopes" -@pytest.mark.usefixtures("current_request_with_host") +@pytest.mark.usefixtures("current_request_with_host", "use_cloud") async def test_duplicate_entry( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, @@ -224,6 +230,23 @@ async def test_duplicate_entry( @pytest.mark.usefixtures("current_request_with_host") +async def test_no_cloud( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_smartthings: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Check we abort when cloud is not enabled.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "cloud_not_enabled" + + +@pytest.mark.usefixtures("current_request_with_host", "use_cloud") async def test_reauthentication( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, @@ -285,7 +308,7 @@ async def test_reauthentication( } -@pytest.mark.usefixtures("current_request_with_host") +@pytest.mark.usefixtures("current_request_with_host", "use_cloud") async def test_reauthentication_wrong_scopes( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, @@ -336,7 +359,7 @@ async def test_reauthentication_wrong_scopes( assert result["reason"] == "missing_scopes" -@pytest.mark.usefixtures("current_request_with_host") +@pytest.mark.usefixtures("current_request_with_host", "use_cloud") async def test_reauth_account_mismatch( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, @@ -388,6 +411,29 @@ async def test_reauth_account_mismatch( @pytest.mark.usefixtures("current_request_with_host") +async def test_reauthentication_no_cloud( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_smartthings: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test SmartThings reauthentication without cloud.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reauth_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "cloud_not_enabled" + + +@pytest.mark.usefixtures("current_request_with_host", "use_cloud") async def test_migration( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, @@ -468,7 +514,7 @@ async def test_migration( assert mock_old_config_entry.minor_version == 1 -@pytest.mark.usefixtures("current_request_with_host") +@pytest.mark.usefixtures("current_request_with_host", "use_cloud") async def test_migration_wrong_location( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, @@ -539,3 +585,27 @@ async def test_migration_wrong_location( ) assert mock_old_config_entry.version == 3 assert mock_old_config_entry.minor_version == 1 + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_migration_no_cloud( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_smartthings: AsyncMock, + mock_old_config_entry: MockConfigEntry, +) -> None: + """Test SmartThings reauthentication with different account.""" + mock_old_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_old_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_old_config_entry.state is ConfigEntryState.SETUP_ERROR + + result = hass.config_entries.flow.async_progress()[0] + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "cloud_not_enabled" From e28e4d210fa92cdc0d85afe29bb6e4ce8cc992b5 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Mon, 3 Mar 2025 20:19:09 +0100 Subject: [PATCH 2133/3148] Bump aiocomelit to 0.11.2 (#139707) --- .../components/comelit/coordinator.py | 29 ++++++------------- .../components/comelit/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/comelit/const.py | 13 ++++----- 5 files changed, 18 insertions(+), 30 deletions(-) diff --git a/homeassistant/components/comelit/coordinator.py b/homeassistant/components/comelit/coordinator.py index a569a397c85..b3be3a47825 100644 --- a/homeassistant/components/comelit/coordinator.py +++ b/homeassistant/components/comelit/coordinator.py @@ -2,18 +2,19 @@ from abc import abstractmethod from datetime import timedelta -from typing import TypedDict, TypeVar, cast +from typing import TypeVar -from aiocomelit import ( +from aiocomelit.api import ( + AlarmDataObject, + ComelitCommonApi, ComeliteSerialBridgeApi, ComelitSerialBridgeObject, ComelitVedoApi, ComelitVedoAreaObject, ComelitVedoZoneObject, - exceptions, ) -from aiocomelit.api import ComelitCommonApi -from aiocomelit.const import ALARM_AREAS, ALARM_ZONES, BRIDGE, VEDO +from aiocomelit.const import BRIDGE, VEDO +from aiocomelit.exceptions import CannotAuthenticate, CannotConnect, CannotRetrieveData from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -26,13 +27,6 @@ from .const import _LOGGER, DOMAIN type ComelitConfigEntry = ConfigEntry[ComelitBaseCoordinator] -class AlarmDataObject(TypedDict): - """TypedDict for Alarm data objects.""" - - alarm_areas: dict[int, ComelitVedoAreaObject] - alarm_zones: dict[int, ComelitVedoZoneObject] - - T = TypeVar( "T", bound=dict[str, dict[int, ComelitSerialBridgeObject]] | AlarmDataObject, @@ -100,9 +94,9 @@ class ComelitBaseCoordinator(DataUpdateCoordinator[T]): try: await self.api.login() return await self._async_update_system_data() - except (exceptions.CannotConnect, exceptions.CannotRetrieveData) as err: + except (CannotConnect, CannotRetrieveData) as err: raise UpdateFailed(repr(err)) from err - except exceptions.CannotAuthenticate as err: + except CannotAuthenticate as err: raise ConfigEntryAuthFailed from err @abstractmethod @@ -159,9 +153,4 @@ class ComelitVedoSystem(ComelitBaseCoordinator[AlarmDataObject]): self, ) -> AlarmDataObject: """Specific method for updating data.""" - data = await self.api.get_all_areas_and_zones() - - return AlarmDataObject( - alarm_areas=cast(dict[int, ComelitVedoAreaObject], data[ALARM_AREAS]), - alarm_zones=cast(dict[int, ComelitVedoZoneObject], data[ALARM_ZONES]), - ) + return await self.api.get_all_areas_and_zones() diff --git a/homeassistant/components/comelit/manifest.json b/homeassistant/components/comelit/manifest.json index 20d481e9a5b..8836af4e8dd 100644 --- a/homeassistant/components/comelit/manifest.json +++ b/homeassistant/components/comelit/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["aiocomelit"], - "requirements": ["aiocomelit==0.11.1"] + "requirements": ["aiocomelit==0.11.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index a82ae4d16e2..cc9761d0137 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -213,7 +213,7 @@ aiobafi6==0.9.0 aiobotocore==2.13.1 # homeassistant.components.comelit -aiocomelit==0.11.1 +aiocomelit==0.11.2 # homeassistant.components.dhcp aiodhcpwatcher==1.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b3eff2acd7f..0c9f5a29b3b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -201,7 +201,7 @@ aiobafi6==0.9.0 aiobotocore==2.13.1 # homeassistant.components.comelit -aiocomelit==0.11.1 +aiocomelit==0.11.2 # homeassistant.components.dhcp aiodhcpwatcher==1.1.1 diff --git a/tests/components/comelit/const.py b/tests/components/comelit/const.py index 20324765a0b..f353ec97628 100644 --- a/tests/components/comelit/const.py +++ b/tests/components/comelit/const.py @@ -1,13 +1,12 @@ """Common stuff for Comelit SimpleHome tests.""" -from aiocomelit import ( +from aiocomelit.api import ( + AlarmDataObject, ComelitSerialBridgeObject, ComelitVedoAreaObject, ComelitVedoZoneObject, ) from aiocomelit.const import ( - ALARM_AREAS, - ALARM_ZONES, CLIMATE, COVER, IRRIGATION, @@ -64,8 +63,8 @@ BRIDGE_DEVICE_QUERY = { SCENARIO: {}, } -VEDO_DEVICE_QUERY = { - ALARM_AREAS: { +VEDO_DEVICE_QUERY = AlarmDataObject( + alarm_areas={ 0: ComelitVedoAreaObject( index=0, name="Area0", @@ -82,7 +81,7 @@ VEDO_DEVICE_QUERY = { human_status=AlarmAreaState.UNKNOWN, ) }, - ALARM_ZONES: { + alarm_zones={ 0: ComelitVedoZoneObject( index=0, name="Zone0", @@ -91,4 +90,4 @@ VEDO_DEVICE_QUERY = { human_status=AlarmZoneState.REST, ) }, -} +) From 890c672f8c83dc2a65714fb54b6fe7aad6e12258 Mon Sep 17 00:00:00 2001 From: StaleLoafOfBread <45444205+StaleLoafOfBread@users.noreply.github.com> Date: Mon, 3 Mar 2025 14:21:05 -0500 Subject: [PATCH 2134/3148] Add charging binary_sensor so front end can render battery icon properly (#139684) * Add charging binary sensor * Add charging binary sensor test --- homeassistant/components/roborock/binary_sensor.py | 10 +++++++++- tests/components/roborock/test_binary_sensor.py | 3 ++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/roborock/binary_sensor.py b/homeassistant/components/roborock/binary_sensor.py index db557f055dc..f2b1564c7b5 100644 --- a/homeassistant/components/roborock/binary_sensor.py +++ b/homeassistant/components/roborock/binary_sensor.py @@ -5,6 +5,7 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass +from roborock.containers import RoborockStateCode from roborock.roborock_typing import DeviceProp from homeassistant.components.binary_sensor import ( @@ -12,7 +13,7 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.const import EntityCategory +from homeassistant.const import ATTR_BATTERY_CHARGING, EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -63,6 +64,13 @@ BINARY_SENSOR_DESCRIPTIONS = [ entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda data: data.status.in_cleaning, ), + RoborockBinarySensorDescription( + key=ATTR_BATTERY_CHARGING, + device_class=BinarySensorDeviceClass.BATTERY_CHARGING, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda data: data.status.state + in (RoborockStateCode.charging, RoborockStateCode.charging_complete), + ), ] diff --git a/tests/components/roborock/test_binary_sensor.py b/tests/components/roborock/test_binary_sensor.py index 0e4b338f469..6a234d735e5 100644 --- a/tests/components/roborock/test_binary_sensor.py +++ b/tests/components/roborock/test_binary_sensor.py @@ -18,7 +18,7 @@ async def test_binary_sensors( hass: HomeAssistant, setup_entry: MockConfigEntry ) -> None: """Test binary sensors and check test values are correctly set.""" - assert len(hass.states.async_all("binary_sensor")) == 8 + assert len(hass.states.async_all("binary_sensor")) == 10 assert hass.states.get("binary_sensor.roborock_s7_maxv_mop_attached").state == "on" assert ( hass.states.get("binary_sensor.roborock_s7_maxv_water_box_attached").state @@ -28,3 +28,4 @@ async def test_binary_sensors( hass.states.get("binary_sensor.roborock_s7_maxv_water_shortage").state == "off" ) assert hass.states.get("binary_sensor.roborock_s7_maxv_cleaning").state == "off" + assert hass.states.get("binary_sensor.roborock_s7_maxv_charging").state == "on" From 9dc04cb0888637f9147bb1c73d6c07e631cc0f1f Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Mon, 3 Mar 2025 11:23:29 -0800 Subject: [PATCH 2135/3148] Improve failure handling and logging for invalid map responses (#139681) --- homeassistant/components/roborock/image.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/roborock/image.py b/homeassistant/components/roborock/image.py index 6d9e87b0556..66088d6453c 100644 --- a/homeassistant/components/roborock/image.py +++ b/homeassistant/components/roborock/image.py @@ -4,6 +4,7 @@ import asyncio from collections.abc import Callable from datetime import datetime import io +import logging from roborock import RoborockCommand from vacuum_map_parser_base.config.color import ColorsPalette @@ -30,6 +31,8 @@ from .const import ( from .coordinator import RoborockConfigEntry, RoborockDataUpdateCoordinator from .entity import RoborockCoordinatedEntityV1 +_LOGGER = logging.getLogger(__name__) + async def async_setup_entry( hass: HomeAssistant, @@ -48,7 +51,11 @@ async def async_setup_entry( ) def parse_image(map_bytes: bytes) -> bytes | None: - parsed_map = parser.parse(map_bytes) + try: + parsed_map = parser.parse(map_bytes) + except (IndexError, ValueError) as err: + _LOGGER.debug("Exception when parsing map contents: %s", err) + return None if parsed_map.image is None: return None img_byte_arr = io.BytesIO() @@ -150,6 +157,7 @@ class RoborockMap(RoborockCoordinatedEntityV1, ImageEntity): not isinstance(response[0], bytes) or (content := self.parser(response[0])) is None ): + _LOGGER.debug("Failed to parse map contents: %s", response[0]) raise HomeAssistantError( translation_domain=DOMAIN, translation_key="map_failure", From 07a93dade20f197c8e32104be804b5d8d93d80e8 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Mon, 3 Mar 2025 20:24:36 +0100 Subject: [PATCH 2136/3148] Add translations for switch state by device class (#139693) --- homeassistant/components/switch/strings.json | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/switch/strings.json b/homeassistant/components/switch/strings.json index 0663384fe2c..b73cf8f849d 100644 --- a/homeassistant/components/switch/strings.json +++ b/homeassistant/components/switch/strings.json @@ -25,10 +25,18 @@ } }, "switch": { - "name": "[%key:component::switch::title%]" + "name": "[%key:component::switch::title%]", + "state": { + "off": "[%key:common::state::off%]", + "on": "[%key:common::state::on%]" + } }, "outlet": { - "name": "Outlet" + "name": "Outlet", + "state": { + "off": "[%key:common::state::off%]", + "on": "[%key:common::state::on%]" + } } }, "services": { From 139072bb590e08d7fa79ed626bf39e42830d7652 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 3 Mar 2025 20:47:38 +0100 Subject: [PATCH 2137/3148] Bump holidays to 0.68 (#139711) --- homeassistant/components/holiday/manifest.json | 2 +- homeassistant/components/workday/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/holiday/manifest.json b/homeassistant/components/holiday/manifest.json index cd5ac1ec1a9..ec47b222370 100644 --- a/homeassistant/components/holiday/manifest.json +++ b/homeassistant/components/holiday/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/holiday", "iot_class": "local_polling", - "requirements": ["holidays==0.67", "babel==2.15.0"] + "requirements": ["holidays==0.68", "babel==2.15.0"] } diff --git a/homeassistant/components/workday/manifest.json b/homeassistant/components/workday/manifest.json index beb828641a4..cc6b0f30002 100644 --- a/homeassistant/components/workday/manifest.json +++ b/homeassistant/components/workday/manifest.json @@ -7,5 +7,5 @@ "iot_class": "local_polling", "loggers": ["holidays"], "quality_scale": "internal", - "requirements": ["holidays==0.67"] + "requirements": ["holidays==0.68"] } diff --git a/requirements_all.txt b/requirements_all.txt index cc9761d0137..404fc67a899 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1149,7 +1149,7 @@ hole==0.8.0 # homeassistant.components.holiday # homeassistant.components.workday -holidays==0.67 +holidays==0.68 # homeassistant.components.frontend home-assistant-frontend==20250228.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0c9f5a29b3b..341adf7362e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -978,7 +978,7 @@ hole==0.8.0 # homeassistant.components.holiday # homeassistant.components.workday -holidays==0.67 +holidays==0.68 # homeassistant.components.frontend home-assistant-frontend==20250228.0 From b6f2d8f30bcf1031542791d64e26ec4a2d76c410 Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Mon, 3 Mar 2025 22:26:16 +0200 Subject: [PATCH 2138/3148] Bump aiowebostv to 0.7.2 (#139712) --- homeassistant/components/webostv/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/webostv/manifest.json b/homeassistant/components/webostv/manifest.json index 06cbca32453..4632bbe8c74 100644 --- a/homeassistant/components/webostv/manifest.json +++ b/homeassistant/components/webostv/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/webostv", "iot_class": "local_push", "loggers": ["aiowebostv"], - "requirements": ["aiowebostv==0.7.1"], + "requirements": ["aiowebostv==0.7.2"], "ssdp": [ { "st": "urn:lge-com:service:webos-second-screen:1" diff --git a/requirements_all.txt b/requirements_all.txt index 404fc67a899..265b72dd9a6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -425,7 +425,7 @@ aiowatttime==0.1.1 aiowebdav2==0.3.1 # homeassistant.components.webostv -aiowebostv==0.7.1 +aiowebostv==0.7.2 # homeassistant.components.withings aiowithings==3.1.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 341adf7362e..e5a293e8dda 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -407,7 +407,7 @@ aiowatttime==0.1.1 aiowebdav2==0.3.1 # homeassistant.components.webostv -aiowebostv==0.7.1 +aiowebostv==0.7.2 # homeassistant.components.withings aiowithings==3.1.6 From 9ea582de26b6f964f3ed4cae585e354cc8c1295f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 3 Mar 2025 14:20:25 -0700 Subject: [PATCH 2139/3148] Bump sense-energy to 0.13.6 (#139714) changes: https://github.com/scottbonline/sense/releases/tag/0.13.6 --- homeassistant/components/emulated_kasa/manifest.json | 2 +- homeassistant/components/sense/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/emulated_kasa/manifest.json b/homeassistant/components/emulated_kasa/manifest.json index 384dd3556a9..d607372136c 100644 --- a/homeassistant/components/emulated_kasa/manifest.json +++ b/homeassistant/components/emulated_kasa/manifest.json @@ -6,5 +6,5 @@ "iot_class": "local_push", "loggers": ["sense_energy"], "quality_scale": "internal", - "requirements": ["sense-energy==0.13.5"] + "requirements": ["sense-energy==0.13.6"] } diff --git a/homeassistant/components/sense/manifest.json b/homeassistant/components/sense/manifest.json index a7cee28f9c9..dda49b661e5 100644 --- a/homeassistant/components/sense/manifest.json +++ b/homeassistant/components/sense/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/sense", "iot_class": "cloud_polling", "loggers": ["sense_energy"], - "requirements": ["sense-energy==0.13.5"] + "requirements": ["sense-energy==0.13.6"] } diff --git a/requirements_all.txt b/requirements_all.txt index 265b72dd9a6..ca4feec308e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2694,7 +2694,7 @@ sendgrid==6.8.2 # homeassistant.components.emulated_kasa # homeassistant.components.sense -sense-energy==0.13.5 +sense-energy==0.13.6 # homeassistant.components.sensirion_ble sensirion-ble==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e5a293e8dda..bd1478f945c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2173,7 +2173,7 @@ securetar==2025.2.1 # homeassistant.components.emulated_kasa # homeassistant.components.sense -sense-energy==0.13.5 +sense-energy==0.13.6 # homeassistant.components.sensirion_ble sensirion-ble==0.1.1 From a7780929416f895ab7f19ccee6e37aef4ab3020b Mon Sep 17 00:00:00 2001 From: Joshua Leaper Date: Tue, 4 Mar 2025 10:05:20 +1030 Subject: [PATCH 2140/3148] Support up to 8 AUX outputs in Ness Alarm (#139718) Support up to 8 AUX outputs --- homeassistant/components/ness_alarm/services.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/ness_alarm/services.yaml b/homeassistant/components/ness_alarm/services.yaml index b02d5e36805..aed1e1836bd 100644 --- a/homeassistant/components/ness_alarm/services.yaml +++ b/homeassistant/components/ness_alarm/services.yaml @@ -7,7 +7,7 @@ aux: selector: number: min: 1 - max: 4 + max: 8 state: default: true selector: From 890d3f4af41a1a133641edd17589bcabae5a33c0 Mon Sep 17 00:00:00 2001 From: Petro31 <35082313+Petro31@users.noreply.github.com> Date: Tue, 4 Mar 2025 01:23:05 -0500 Subject: [PATCH 2141/3148] Add a base class for template entities to inherit from (#139645) * add-abstract-template-entity-base-class * review 1 changes --- CODEOWNERS | 4 +- .../template/alarm_control_panel.py | 90 +++------- homeassistant/components/template/button.py | 12 +- homeassistant/components/template/cover.py | 90 +++++----- homeassistant/components/template/entity.py | 66 ++++++++ homeassistant/components/template/fan.py | 87 +++++----- homeassistant/components/template/light.py | 158 ++++++++++-------- homeassistant/components/template/lock.py | 30 ++-- .../components/template/manifest.json | 2 +- homeassistant/components/template/number.py | 8 +- homeassistant/components/template/select.py | 8 +- homeassistant/components/template/switch.py | 39 ++--- .../components/template/template_entity.py | 53 ++---- homeassistant/components/template/vacuum.py | 116 +++++-------- tests/components/template/test_entity.py | 17 ++ .../template/test_template_entity.py | 2 +- 16 files changed, 384 insertions(+), 398 deletions(-) create mode 100644 homeassistant/components/template/entity.py create mode 100644 tests/components/template/test_entity.py diff --git a/CODEOWNERS b/CODEOWNERS index 3366bfb0885..4e8f78ca873 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1529,8 +1529,8 @@ build.json @home-assistant/supervisor /tests/components/tedee/ @patrickhilker @zweckj /homeassistant/components/tellduslive/ @fredrike /tests/components/tellduslive/ @fredrike -/homeassistant/components/template/ @PhracturedBlue @home-assistant/core -/tests/components/template/ @PhracturedBlue @home-assistant/core +/homeassistant/components/template/ @Petro31 @PhracturedBlue @home-assistant/core +/tests/components/template/ @Petro31 @PhracturedBlue @home-assistant/core /homeassistant/components/tesla_fleet/ @Bre77 /tests/components/tesla_fleet/ @Bre77 /homeassistant/components/tesla_wall_connector/ @einarhauks diff --git a/homeassistant/components/template/alarm_control_panel.py b/homeassistant/components/template/alarm_control_panel.py index 0a468994295..40206a5ccbb 100644 --- a/homeassistant/components/template/alarm_control_panel.py +++ b/homeassistant/components/template/alarm_control_panel.py @@ -36,7 +36,6 @@ from homeassistant.helpers.entity_platform import ( AddEntitiesCallback, ) from homeassistant.helpers.restore_state import RestoreEntity -from homeassistant.helpers.script import Script from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import slugify @@ -199,70 +198,31 @@ class AlarmControlPanelTemplate(TemplateEntity, AlarmControlPanelEntity, Restore name = self._attr_name assert name is not None self._template = config.get(CONF_VALUE_TEMPLATE) - self._disarm_script = None self._attr_code_arm_required: bool = config[CONF_CODE_ARM_REQUIRED] self._attr_code_format = config[CONF_CODE_FORMAT].value - if (disarm_action := config.get(CONF_DISARM_ACTION)) is not None: - self._disarm_script = Script(hass, disarm_action, name, DOMAIN) - self._arm_away_script = None - if (arm_away_action := config.get(CONF_ARM_AWAY_ACTION)) is not None: - self._arm_away_script = Script(hass, arm_away_action, name, DOMAIN) - self._arm_home_script = None - if (arm_home_action := config.get(CONF_ARM_HOME_ACTION)) is not None: - self._arm_home_script = Script(hass, arm_home_action, name, DOMAIN) - self._arm_night_script = None - if (arm_night_action := config.get(CONF_ARM_NIGHT_ACTION)) is not None: - self._arm_night_script = Script(hass, arm_night_action, name, DOMAIN) - self._arm_vacation_script = None - if (arm_vacation_action := config.get(CONF_ARM_VACATION_ACTION)) is not None: - self._arm_vacation_script = Script(hass, arm_vacation_action, name, DOMAIN) - self._arm_custom_bypass_script = None - if ( - arm_custom_bypass_action := config.get(CONF_ARM_CUSTOM_BYPASS_ACTION) - ) is not None: - self._arm_custom_bypass_script = Script( - hass, arm_custom_bypass_action, name, DOMAIN - ) - self._trigger_script = None - if (trigger_action := config.get(CONF_TRIGGER_ACTION)) is not None: - self._trigger_script = Script(hass, trigger_action, name, DOMAIN) + + self._attr_supported_features = AlarmControlPanelEntityFeature(0) + for action_id, supported_feature in ( + (CONF_DISARM_ACTION, 0), + (CONF_ARM_AWAY_ACTION, AlarmControlPanelEntityFeature.ARM_AWAY), + (CONF_ARM_HOME_ACTION, AlarmControlPanelEntityFeature.ARM_HOME), + (CONF_ARM_NIGHT_ACTION, AlarmControlPanelEntityFeature.ARM_NIGHT), + (CONF_ARM_VACATION_ACTION, AlarmControlPanelEntityFeature.ARM_VACATION), + ( + CONF_ARM_CUSTOM_BYPASS_ACTION, + AlarmControlPanelEntityFeature.ARM_CUSTOM_BYPASS, + ), + (CONF_TRIGGER_ACTION, AlarmControlPanelEntityFeature.TRIGGER), + ): + if action_config := config.get(action_id): + self.add_script(action_id, action_config, name, DOMAIN) + self._attr_supported_features |= supported_feature self._state: AlarmControlPanelState | None = None self._attr_device_info = async_device_info_to_link_from_device_id( hass, config.get(CONF_DEVICE_ID), ) - supported_features = AlarmControlPanelEntityFeature(0) - if self._arm_night_script is not None: - supported_features = ( - supported_features | AlarmControlPanelEntityFeature.ARM_NIGHT - ) - - if self._arm_home_script is not None: - supported_features = ( - supported_features | AlarmControlPanelEntityFeature.ARM_HOME - ) - - if self._arm_away_script is not None: - supported_features = ( - supported_features | AlarmControlPanelEntityFeature.ARM_AWAY - ) - - if self._arm_vacation_script is not None: - supported_features = ( - supported_features | AlarmControlPanelEntityFeature.ARM_VACATION - ) - - if self._arm_custom_bypass_script is not None: - supported_features = ( - supported_features | AlarmControlPanelEntityFeature.ARM_CUSTOM_BYPASS - ) - - if self._trigger_script is not None: - supported_features = ( - supported_features | AlarmControlPanelEntityFeature.TRIGGER - ) - self._attr_supported_features = supported_features async def async_added_to_hass(self) -> None: """Restore last state.""" @@ -330,7 +290,7 @@ class AlarmControlPanelTemplate(TemplateEntity, AlarmControlPanelEntity, Restore """Arm the panel to Away.""" await self._async_alarm_arm( AlarmControlPanelState.ARMED_AWAY, - script=self._arm_away_script, + script=self._action_scripts.get(CONF_ARM_AWAY_ACTION), code=code, ) @@ -338,7 +298,7 @@ class AlarmControlPanelTemplate(TemplateEntity, AlarmControlPanelEntity, Restore """Arm the panel to Home.""" await self._async_alarm_arm( AlarmControlPanelState.ARMED_HOME, - script=self._arm_home_script, + script=self._action_scripts.get(CONF_ARM_HOME_ACTION), code=code, ) @@ -346,7 +306,7 @@ class AlarmControlPanelTemplate(TemplateEntity, AlarmControlPanelEntity, Restore """Arm the panel to Night.""" await self._async_alarm_arm( AlarmControlPanelState.ARMED_NIGHT, - script=self._arm_night_script, + script=self._action_scripts.get(CONF_ARM_NIGHT_ACTION), code=code, ) @@ -354,7 +314,7 @@ class AlarmControlPanelTemplate(TemplateEntity, AlarmControlPanelEntity, Restore """Arm the panel to Vacation.""" await self._async_alarm_arm( AlarmControlPanelState.ARMED_VACATION, - script=self._arm_vacation_script, + script=self._action_scripts.get(CONF_ARM_VACATION_ACTION), code=code, ) @@ -362,20 +322,22 @@ class AlarmControlPanelTemplate(TemplateEntity, AlarmControlPanelEntity, Restore """Arm the panel to Custom Bypass.""" await self._async_alarm_arm( AlarmControlPanelState.ARMED_CUSTOM_BYPASS, - script=self._arm_custom_bypass_script, + script=self._action_scripts.get(CONF_ARM_CUSTOM_BYPASS_ACTION), code=code, ) async def async_alarm_disarm(self, code: str | None = None) -> None: """Disarm the panel.""" await self._async_alarm_arm( - AlarmControlPanelState.DISARMED, script=self._disarm_script, code=code + AlarmControlPanelState.DISARMED, + script=self._action_scripts.get(CONF_DISARM_ACTION), + code=code, ) async def async_alarm_trigger(self, code: str | None = None) -> None: """Trigger the panel.""" await self._async_alarm_arm( AlarmControlPanelState.TRIGGERED, - script=self._trigger_script, + script=self._action_scripts.get(CONF_TRIGGER_ACTION), code=code, ) diff --git a/homeassistant/components/template/button.py b/homeassistant/components/template/button.py index f43fc242bba..7a205446585 100644 --- a/homeassistant/components/template/button.py +++ b/homeassistant/components/template/button.py @@ -23,7 +23,6 @@ from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, ) -from homeassistant.helpers.script import Script from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import CONF_PRESS, DOMAIN @@ -121,11 +120,8 @@ class TemplateButtonEntity(TemplateEntity, ButtonEntity): """Initialize the button.""" super().__init__(hass, config=config, unique_id=unique_id) assert self._attr_name is not None - self._command_press = ( - Script(hass, config.get(CONF_PRESS), self._attr_name, DOMAIN) - if config.get(CONF_PRESS, None) is not None - else None - ) + if action := config.get(CONF_PRESS): + self.add_script(CONF_PRESS, action, self._attr_name, DOMAIN) self._attr_device_class = config.get(CONF_DEVICE_CLASS) self._attr_state = None self._attr_device_info = async_device_info_to_link_from_device_id( @@ -135,5 +131,5 @@ class TemplateButtonEntity(TemplateEntity, ButtonEntity): async def async_press(self) -> None: """Press the button.""" - if self._command_press: - await self.async_run_script(self._command_press, context=self._context) + if script := self._action_scripts.get(CONF_PRESS): + await self.async_run_script(script, context=self._context) diff --git a/homeassistant/components/template/cover.py b/homeassistant/components/template/cover.py index 306b4405c6a..ef5e6bc5758 100644 --- a/homeassistant/components/template/cover.py +++ b/homeassistant/components/template/cover.py @@ -3,7 +3,7 @@ from __future__ import annotations import logging -from typing import Any +from typing import TYPE_CHECKING, Any import voluptuous as vol @@ -30,7 +30,6 @@ from homeassistant.exceptions import TemplateError from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.script import Script from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import DOMAIN @@ -103,7 +102,7 @@ PLATFORM_SCHEMA = COVER_PLATFORM_SCHEMA.extend( ) -async def _async_create_entities(hass, config): +async def _async_create_entities(hass: HomeAssistant, config): """Create the Template cover.""" covers = [] @@ -141,11 +140,11 @@ class CoverTemplate(TemplateEntity, CoverEntity): def __init__( self, - hass, + hass: HomeAssistant, object_id, - config, + config: dict[str, Any], unique_id, - ): + ) -> None: """Initialize the Template cover.""" super().__init__( hass, config=config, fallback_name=object_id, unique_id=unique_id @@ -153,45 +152,40 @@ class CoverTemplate(TemplateEntity, CoverEntity): self.entity_id = async_generate_entity_id( ENTITY_ID_FORMAT, object_id, hass=hass ) - friendly_name = self._attr_name + name = self._attr_name + if TYPE_CHECKING: + assert name is not None self._template = config.get(CONF_VALUE_TEMPLATE) self._position_template = config.get(CONF_POSITION_TEMPLATE) self._tilt_template = config.get(CONF_TILT_TEMPLATE) self._attr_device_class = config.get(CONF_DEVICE_CLASS) - self._open_script = None - if (open_action := config.get(OPEN_ACTION)) is not None: - self._open_script = Script(hass, open_action, friendly_name, DOMAIN) - self._close_script = None - if (close_action := config.get(CLOSE_ACTION)) is not None: - self._close_script = Script(hass, close_action, friendly_name, DOMAIN) - self._stop_script = None - if (stop_action := config.get(STOP_ACTION)) is not None: - self._stop_script = Script(hass, stop_action, friendly_name, DOMAIN) - self._position_script = None - if (position_action := config.get(POSITION_ACTION)) is not None: - self._position_script = Script(hass, position_action, friendly_name, DOMAIN) - self._tilt_script = None - if (tilt_action := config.get(TILT_ACTION)) is not None: - self._tilt_script = Script(hass, tilt_action, friendly_name, DOMAIN) + + # The config requires (open and close scripts) or a set position script, + # therefore the base supported features will always include them. + self._attr_supported_features = ( + CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE + ) + for action_id, supported_feature in ( + (OPEN_ACTION, 0), + (CLOSE_ACTION, 0), + (STOP_ACTION, CoverEntityFeature.STOP), + (POSITION_ACTION, CoverEntityFeature.SET_POSITION), + (TILT_ACTION, TILT_FEATURES), + ): + if action_config := config.get(action_id): + self.add_script(action_id, action_config, name, DOMAIN) + self._attr_supported_features |= supported_feature + optimistic = config.get(CONF_OPTIMISTIC) self._optimistic = optimistic or ( optimistic is None and not self._template and not self._position_template ) tilt_optimistic = config.get(CONF_TILT_OPTIMISTIC) self._tilt_optimistic = tilt_optimistic or not self._tilt_template - self._position = None + self._position: int | None = None self._is_opening = False self._is_closing = False - self._tilt_value = None - - supported_features = CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE - if self._stop_script is not None: - supported_features |= CoverEntityFeature.STOP - if self._position_script is not None: - supported_features |= CoverEntityFeature.SET_POSITION - if self._tilt_script is not None: - supported_features |= TILT_FEATURES - self._attr_supported_features = supported_features + self._tilt_value: int | None = None @callback def _async_setup_templates(self) -> None: @@ -317,7 +311,7 @@ class CoverTemplate(TemplateEntity, CoverEntity): None is unknown, 0 is closed, 100 is fully open. """ - if self._position_template or self._position_script: + if self._position_template or self._action_scripts.get(POSITION_ACTION): return self._position return None @@ -331,11 +325,11 @@ class CoverTemplate(TemplateEntity, CoverEntity): async def async_open_cover(self, **kwargs: Any) -> None: """Move the cover up.""" - if self._open_script: - await self.async_run_script(self._open_script, context=self._context) - elif self._position_script: + if (open_script := self._action_scripts.get(OPEN_ACTION)) is not None: + await self.async_run_script(open_script, context=self._context) + elif (position_script := self._action_scripts.get(POSITION_ACTION)) is not None: await self.async_run_script( - self._position_script, + position_script, run_variables={"position": 100}, context=self._context, ) @@ -345,11 +339,11 @@ class CoverTemplate(TemplateEntity, CoverEntity): async def async_close_cover(self, **kwargs: Any) -> None: """Move the cover down.""" - if self._close_script: - await self.async_run_script(self._close_script, context=self._context) - elif self._position_script: + if (close_script := self._action_scripts.get(CLOSE_ACTION)) is not None: + await self.async_run_script(close_script, context=self._context) + elif (position_script := self._action_scripts.get(POSITION_ACTION)) is not None: await self.async_run_script( - self._position_script, + position_script, run_variables={"position": 0}, context=self._context, ) @@ -359,14 +353,14 @@ class CoverTemplate(TemplateEntity, CoverEntity): async def async_stop_cover(self, **kwargs: Any) -> None: """Fire the stop action.""" - if self._stop_script: - await self.async_run_script(self._stop_script, context=self._context) + if (stop_script := self._action_scripts.get(STOP_ACTION)) is not None: + await self.async_run_script(stop_script, context=self._context) async def async_set_cover_position(self, **kwargs: Any) -> None: """Set cover position.""" self._position = kwargs[ATTR_POSITION] await self.async_run_script( - self._position_script, + self._action_scripts[POSITION_ACTION], run_variables={"position": self._position}, context=self._context, ) @@ -377,7 +371,7 @@ class CoverTemplate(TemplateEntity, CoverEntity): """Tilt the cover open.""" self._tilt_value = 100 await self.async_run_script( - self._tilt_script, + self._action_scripts[TILT_ACTION], run_variables={"tilt": self._tilt_value}, context=self._context, ) @@ -388,7 +382,7 @@ class CoverTemplate(TemplateEntity, CoverEntity): """Tilt the cover closed.""" self._tilt_value = 0 await self.async_run_script( - self._tilt_script, + self._action_scripts[TILT_ACTION], run_variables={"tilt": self._tilt_value}, context=self._context, ) @@ -399,7 +393,7 @@ class CoverTemplate(TemplateEntity, CoverEntity): """Move the cover tilt to a specific position.""" self._tilt_value = kwargs[ATTR_TILT_POSITION] await self.async_run_script( - self._tilt_script, + self._action_scripts[TILT_ACTION], run_variables={"tilt": self._tilt_value}, context=self._context, ) diff --git a/homeassistant/components/template/entity.py b/homeassistant/components/template/entity.py new file mode 100644 index 00000000000..dd8623060be --- /dev/null +++ b/homeassistant/components/template/entity.py @@ -0,0 +1,66 @@ +"""Template entity base class.""" + +from collections.abc import Sequence +from typing import Any + +from homeassistant.core import Context, HomeAssistant, callback +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.script import Script, _VarsType +from homeassistant.helpers.template import TemplateStateFromEntityId + + +class AbstractTemplateEntity(Entity): + """Actions linked to a template entity.""" + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize the entity.""" + + self.hass = hass + self._action_scripts: dict[str, Script] = {} + + @property + def referenced_blueprint(self) -> str | None: + """Return referenced blueprint or None.""" + raise NotImplementedError + + @callback + def _render_script_variables(self) -> dict: + """Render configured variables.""" + raise NotImplementedError + + def add_script( + self, + script_id: str, + config: Sequence[dict[str, Any]], + name: str, + domain: str, + ): + """Add an action script.""" + + # Cannot use self.hass because it may be None in child class + # at instantiation. + self._action_scripts[script_id] = Script( + self.hass, + config, + f"{name} {script_id}", + domain, + ) + + async def async_run_script( + self, + script: Script, + *, + run_variables: _VarsType | None = None, + context: Context | None = None, + ) -> None: + """Run an action script.""" + if run_variables is None: + run_variables = {} + await script.async_run( + run_variables={ + "this": TemplateStateFromEntityId(self.hass, self.entity_id), + **self._render_script_variables(), + **run_variables, + }, + context=context, + ) diff --git a/homeassistant/components/template/fan.py b/homeassistant/components/template/fan.py index 6ed525fd45f..2ca05681f7f 100644 --- a/homeassistant/components/template/fan.py +++ b/homeassistant/components/template/fan.py @@ -3,7 +3,7 @@ from __future__ import annotations import logging -from typing import Any +from typing import TYPE_CHECKING, Any import voluptuous as vol @@ -32,7 +32,6 @@ from homeassistant.exceptions import TemplateError from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.script import Script from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import DOMAIN @@ -89,7 +88,7 @@ PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA.extend( ) -async def _async_create_entities(hass, config): +async def _async_create_entities(hass: HomeAssistant, config): """Create the Template Fans.""" fans = [] @@ -127,11 +126,11 @@ class TemplateFan(TemplateEntity, FanEntity): def __init__( self, - hass, + hass: HomeAssistant, object_id, - config, + config: dict[str, Any], unique_id, - ): + ) -> None: """Initialize the fan.""" super().__init__( hass, config=config, fallback_name=object_id, unique_id=unique_id @@ -140,7 +139,9 @@ class TemplateFan(TemplateEntity, FanEntity): self.entity_id = async_generate_entity_id( ENTITY_ID_FORMAT, object_id, hass=hass ) - friendly_name = self._attr_name + name = self._attr_name + if TYPE_CHECKING: + assert name is not None self._template = config.get(CONF_VALUE_TEMPLATE) self._percentage_template = config.get(CONF_PERCENTAGE_TEMPLATE) @@ -148,44 +149,28 @@ class TemplateFan(TemplateEntity, FanEntity): self._oscillating_template = config.get(CONF_OSCILLATING_TEMPLATE) self._direction_template = config.get(CONF_DIRECTION_TEMPLATE) - self._on_script = Script(hass, config[CONF_ON_ACTION], friendly_name, DOMAIN) - self._off_script = Script(hass, config[CONF_OFF_ACTION], friendly_name, DOMAIN) - - self._set_percentage_script = None - if set_percentage_action := config.get(CONF_SET_PERCENTAGE_ACTION): - self._set_percentage_script = Script( - hass, set_percentage_action, friendly_name, DOMAIN - ) - - self._set_preset_mode_script = None - if set_preset_mode_action := config.get(CONF_SET_PRESET_MODE_ACTION): - self._set_preset_mode_script = Script( - hass, set_preset_mode_action, friendly_name, DOMAIN - ) - - self._set_oscillating_script = None - if set_oscillating_action := config.get(CONF_SET_OSCILLATING_ACTION): - self._set_oscillating_script = Script( - hass, set_oscillating_action, friendly_name, DOMAIN - ) - - self._set_direction_script = None - if set_direction_action := config.get(CONF_SET_DIRECTION_ACTION): - self._set_direction_script = Script( - hass, set_direction_action, friendly_name, DOMAIN - ) + for action_id in ( + CONF_ON_ACTION, + CONF_OFF_ACTION, + CONF_SET_PERCENTAGE_ACTION, + CONF_SET_PRESET_MODE_ACTION, + CONF_SET_OSCILLATING_ACTION, + CONF_SET_DIRECTION_ACTION, + ): + if action_config := config.get(action_id): + self.add_script(action_id, action_config, name, DOMAIN) self._state: bool | None = False - self._percentage = None - self._preset_mode = None - self._oscillating = None - self._direction = None + self._percentage: int | None = None + self._preset_mode: str | None = None + self._oscillating: bool | None = None + self._direction: str | None = None # Number of valid speeds self._speed_count = config.get(CONF_SPEED_COUNT) # List of valid preset modes - self._preset_modes = config.get(CONF_PRESET_MODES) + self._preset_modes: list[str] | None = config.get(CONF_PRESET_MODES) if self._percentage_template: self._attr_supported_features |= FanEntityFeature.SET_SPEED @@ -207,7 +192,7 @@ class TemplateFan(TemplateEntity, FanEntity): return self._speed_count or 100 @property - def preset_modes(self) -> list[str]: + def preset_modes(self) -> list[str] | None: """Get the list of available preset modes.""" return self._preset_modes @@ -244,7 +229,7 @@ class TemplateFan(TemplateEntity, FanEntity): ) -> None: """Turn on the fan.""" await self.async_run_script( - self._on_script, + self._action_scripts[CONF_ON_ACTION], run_variables={ ATTR_PERCENTAGE: percentage, ATTR_PRESET_MODE: preset_mode, @@ -263,7 +248,9 @@ class TemplateFan(TemplateEntity, FanEntity): async def async_turn_off(self, **kwargs: Any) -> None: """Turn off the fan.""" - await self.async_run_script(self._off_script, context=self._context) + await self.async_run_script( + self._action_scripts[CONF_OFF_ACTION], context=self._context + ) if self._template is None: self._state = False @@ -273,9 +260,9 @@ class TemplateFan(TemplateEntity, FanEntity): """Set the percentage speed of the fan.""" self._percentage = percentage - if self._set_percentage_script: + if (script := self._action_scripts.get(CONF_SET_PERCENTAGE_ACTION)) is not None: await self.async_run_script( - self._set_percentage_script, + script, run_variables={ATTR_PERCENTAGE: self._percentage}, context=self._context, ) @@ -288,9 +275,11 @@ class TemplateFan(TemplateEntity, FanEntity): """Set the preset_mode of the fan.""" self._preset_mode = preset_mode - if self._set_preset_mode_script: + if ( + script := self._action_scripts.get(CONF_SET_PRESET_MODE_ACTION) + ) is not None: await self.async_run_script( - self._set_preset_mode_script, + script, run_variables={ATTR_PRESET_MODE: self._preset_mode}, context=self._context, ) @@ -301,25 +290,25 @@ class TemplateFan(TemplateEntity, FanEntity): async def async_oscillate(self, oscillating: bool) -> None: """Set oscillation of the fan.""" - if self._set_oscillating_script is None: + if (script := self._action_scripts.get(CONF_SET_OSCILLATING_ACTION)) is None: return self._oscillating = oscillating await self.async_run_script( - self._set_oscillating_script, + script, run_variables={ATTR_OSCILLATING: self.oscillating}, context=self._context, ) async def async_set_direction(self, direction: str) -> None: """Set the direction of the fan.""" - if self._set_direction_script is None: + if (script := self._action_scripts.get(CONF_SET_DIRECTION_ACTION)) is None: return if direction in _VALID_DIRECTIONS: self._direction = direction await self.async_run_script( - self._set_direction_script, + script, run_variables={ATTR_DIRECTION: direction}, context=self._context, ) diff --git a/homeassistant/components/template/light.py b/homeassistant/components/template/light.py index 206703ddcce..3369bf3ce0f 100644 --- a/homeassistant/components/template/light.py +++ b/homeassistant/components/template/light.py @@ -3,7 +3,7 @@ from __future__ import annotations import logging -from typing import Any +from typing import TYPE_CHECKING, Any import voluptuous as vol @@ -39,7 +39,6 @@ from homeassistant.exceptions import TemplateError from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.script import Script from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import color as color_util @@ -127,7 +126,7 @@ PLATFORM_SCHEMA = vol.All( ) -async def _async_create_entities(hass, config): +async def _async_create_entities(hass: HomeAssistant, config): """Create the Template Lights.""" lights = [] @@ -164,11 +163,11 @@ class LightTemplate(TemplateEntity, LightEntity): def __init__( self, - hass, + hass: HomeAssistant, object_id, - config, + config: dict[str, Any], unique_id, - ): + ) -> None: """Initialize the light.""" super().__init__( hass, config=config, fallback_name=object_id, unique_id=unique_id @@ -176,52 +175,31 @@ class LightTemplate(TemplateEntity, LightEntity): self.entity_id = async_generate_entity_id( ENTITY_ID_FORMAT, object_id, hass=hass ) - friendly_name = self._attr_name + name = self._attr_name + if TYPE_CHECKING: + assert name is not None + self._template = config.get(CONF_VALUE_TEMPLATE) - self._on_script = Script(hass, config[CONF_ON_ACTION], friendly_name, DOMAIN) - self._off_script = Script(hass, config[CONF_OFF_ACTION], friendly_name, DOMAIN) - self._level_script = None - if (level_action := config.get(CONF_LEVEL_ACTION)) is not None: - self._level_script = Script(hass, level_action, friendly_name, DOMAIN) self._level_template = config.get(CONF_LEVEL_TEMPLATE) - self._temperature_script = None - if (temperature_action := config.get(CONF_TEMPERATURE_ACTION)) is not None: - self._temperature_script = Script( - hass, temperature_action, friendly_name, DOMAIN - ) self._temperature_template = config.get(CONF_TEMPERATURE_TEMPLATE) - self._color_script = None - if (color_action := config.get(CONF_COLOR_ACTION)) is not None: - self._color_script = Script(hass, color_action, friendly_name, DOMAIN) self._color_template = config.get(CONF_COLOR_TEMPLATE) - self._hs_script = None - if (hs_action := config.get(CONF_HS_ACTION)) is not None: - self._hs_script = Script(hass, hs_action, friendly_name, DOMAIN) self._hs_template = config.get(CONF_HS_TEMPLATE) - self._rgb_script = None - if (rgb_action := config.get(CONF_RGB_ACTION)) is not None: - self._rgb_script = Script(hass, rgb_action, friendly_name, DOMAIN) self._rgb_template = config.get(CONF_RGB_TEMPLATE) - self._rgbw_script = None - if (rgbw_action := config.get(CONF_RGBW_ACTION)) is not None: - self._rgbw_script = Script(hass, rgbw_action, friendly_name, DOMAIN) self._rgbw_template = config.get(CONF_RGBW_TEMPLATE) - self._rgbww_script = None - if (rgbww_action := config.get(CONF_RGBWW_ACTION)) is not None: - self._rgbww_script = Script(hass, rgbww_action, friendly_name, DOMAIN) self._rgbww_template = config.get(CONF_RGBWW_TEMPLATE) - self._effect_script = None - if (effect_action := config.get(CONF_EFFECT_ACTION)) is not None: - self._effect_script = Script(hass, effect_action, friendly_name, DOMAIN) self._effect_list_template = config.get(CONF_EFFECT_LIST_TEMPLATE) self._effect_template = config.get(CONF_EFFECT_TEMPLATE) self._max_mireds_template = config.get(CONF_MAX_MIREDS_TEMPLATE) self._min_mireds_template = config.get(CONF_MIN_MIREDS_TEMPLATE) self._supports_transition_template = config.get(CONF_SUPPORTS_TRANSITION) + for action_id in (CONF_ON_ACTION, CONF_OFF_ACTION, CONF_EFFECT_ACTION): + if action_config := config.get(action_id): + self.add_script(action_id, action_config, name, DOMAIN) + self._state = False self._brightness = None - self._temperature = None + self._temperature: int | None = None self._hs_color = None self._rgb_color = None self._rgbw_color = None @@ -235,21 +213,18 @@ class LightTemplate(TemplateEntity, LightEntity): self._supported_color_modes = None color_modes = {ColorMode.ONOFF} - if self._level_script is not None: - color_modes.add(ColorMode.BRIGHTNESS) - if self._temperature_script is not None: - color_modes.add(ColorMode.COLOR_TEMP) - if self._hs_script is not None: - color_modes.add(ColorMode.HS) - if self._color_script is not None: - color_modes.add(ColorMode.HS) - if self._rgb_script is not None: - color_modes.add(ColorMode.RGB) - if self._rgbw_script is not None: - color_modes.add(ColorMode.RGBW) - if self._rgbww_script is not None: - color_modes.add(ColorMode.RGBWW) - + for action_id, color_mode in ( + (CONF_TEMPERATURE_ACTION, ColorMode.COLOR_TEMP), + (CONF_LEVEL_ACTION, ColorMode.BRIGHTNESS), + (CONF_COLOR_ACTION, ColorMode.HS), + (CONF_HS_ACTION, ColorMode.HS), + (CONF_RGB_ACTION, ColorMode.RGB), + (CONF_RGBW_ACTION, ColorMode.RGBW), + (CONF_RGBWW_ACTION, ColorMode.RGBWW), + ): + if (action_config := config.get(action_id)) is not None: + self.add_script(action_id, action_config, name, DOMAIN) + color_modes.add(color_mode) self._supported_color_modes = filter_supported_color_modes(color_modes) if len(self._supported_color_modes) > 1: self._color_mode = ColorMode.UNKNOWN @@ -257,7 +232,7 @@ class LightTemplate(TemplateEntity, LightEntity): self._color_mode = next(iter(self._supported_color_modes)) self._attr_supported_features = LightEntityFeature(0) - if self._effect_script is not None: + if self._action_scripts.get(CONF_EFFECT_ACTION) is not None: self._attr_supported_features |= LightEntityFeature.EFFECT if self._supports_transition is True: self._attr_supported_features |= LightEntityFeature.TRANSITION @@ -321,12 +296,12 @@ class LightTemplate(TemplateEntity, LightEntity): return self._effect_list @property - def color_mode(self): + def color_mode(self) -> ColorMode | None: """Return current color mode.""" return self._color_mode @property - def supported_color_modes(self): + def supported_color_modes(self) -> set[ColorMode] | None: """Flag supported color modes.""" return self._supported_color_modes @@ -555,17 +530,28 @@ class LightTemplate(TemplateEntity, LightEntity): if ATTR_TRANSITION in kwargs and self._supports_transition is True: common_params["transition"] = kwargs[ATTR_TRANSITION] - if ATTR_COLOR_TEMP_KELVIN in kwargs and self._temperature_script: + if ( + ATTR_COLOR_TEMP_KELVIN in kwargs + and ( + temperature_script := self._action_scripts.get(CONF_TEMPERATURE_ACTION) + ) + is not None + ): common_params["color_temp"] = color_util.color_temperature_kelvin_to_mired( kwargs[ATTR_COLOR_TEMP_KELVIN] ) await self.async_run_script( - self._temperature_script, + temperature_script, run_variables=common_params, context=self._context, ) - elif ATTR_EFFECT in kwargs and self._effect_script: + elif ( + ATTR_EFFECT in kwargs + and (effect_script := self._action_scripts.get(CONF_EFFECT_ACTION)) + is not None + ): + assert self._effect_list is not None effect = kwargs[ATTR_EFFECT] if effect not in self._effect_list: _LOGGER.error( @@ -579,27 +565,38 @@ class LightTemplate(TemplateEntity, LightEntity): common_params["effect"] = effect await self.async_run_script( - self._effect_script, run_variables=common_params, context=self._context + effect_script, run_variables=common_params, context=self._context ) - elif ATTR_HS_COLOR in kwargs and self._color_script: + elif ( + ATTR_HS_COLOR in kwargs + and (color_script := self._action_scripts.get(CONF_COLOR_ACTION)) + is not None + ): hs_value = kwargs[ATTR_HS_COLOR] common_params["hs"] = hs_value common_params["h"] = int(hs_value[0]) common_params["s"] = int(hs_value[1]) await self.async_run_script( - self._color_script, run_variables=common_params, context=self._context + color_script, run_variables=common_params, context=self._context ) - elif ATTR_HS_COLOR in kwargs and self._hs_script: + elif ( + ATTR_HS_COLOR in kwargs + and (hs_script := self._action_scripts.get(CONF_HS_ACTION)) is not None + ): hs_value = kwargs[ATTR_HS_COLOR] common_params["hs"] = hs_value common_params["h"] = int(hs_value[0]) common_params["s"] = int(hs_value[1]) await self.async_run_script( - self._hs_script, run_variables=common_params, context=self._context + hs_script, run_variables=common_params, context=self._context ) - elif ATTR_RGBWW_COLOR in kwargs and self._rgbww_script: + elif ( + ATTR_RGBWW_COLOR in kwargs + and (rgbww_script := self._action_scripts.get(CONF_RGBWW_ACTION)) + is not None + ): rgbww_value = kwargs[ATTR_RGBWW_COLOR] common_params["rgbww"] = rgbww_value common_params["rgb"] = ( @@ -614,9 +611,12 @@ class LightTemplate(TemplateEntity, LightEntity): common_params["ww"] = int(rgbww_value[4]) await self.async_run_script( - self._rgbww_script, run_variables=common_params, context=self._context + rgbww_script, run_variables=common_params, context=self._context ) - elif ATTR_RGBW_COLOR in kwargs and self._rgbw_script: + elif ( + ATTR_RGBW_COLOR in kwargs + and (rgbw_script := self._action_scripts.get(CONF_RGBW_ACTION)) is not None + ): rgbw_value = kwargs[ATTR_RGBW_COLOR] common_params["rgbw"] = rgbw_value common_params["rgb"] = ( @@ -630,9 +630,12 @@ class LightTemplate(TemplateEntity, LightEntity): common_params["w"] = int(rgbw_value[3]) await self.async_run_script( - self._rgbw_script, run_variables=common_params, context=self._context + rgbw_script, run_variables=common_params, context=self._context ) - elif ATTR_RGB_COLOR in kwargs and self._rgb_script: + elif ( + ATTR_RGB_COLOR in kwargs + and (rgb_script := self._action_scripts.get(CONF_RGB_ACTION)) is not None + ): rgb_value = kwargs[ATTR_RGB_COLOR] common_params["rgb"] = rgb_value common_params["r"] = int(rgb_value[0]) @@ -640,15 +643,21 @@ class LightTemplate(TemplateEntity, LightEntity): common_params["b"] = int(rgb_value[2]) await self.async_run_script( - self._rgb_script, run_variables=common_params, context=self._context + rgb_script, run_variables=common_params, context=self._context ) - elif ATTR_BRIGHTNESS in kwargs and self._level_script: + elif ( + ATTR_BRIGHTNESS in kwargs + and (level_script := self._action_scripts.get(CONF_LEVEL_ACTION)) + is not None + ): await self.async_run_script( - self._level_script, run_variables=common_params, context=self._context + level_script, run_variables=common_params, context=self._context ) else: await self.async_run_script( - self._on_script, run_variables=common_params, context=self._context + self._action_scripts[CONF_ON_ACTION], + run_variables=common_params, + context=self._context, ) if optimistic_set: @@ -656,14 +665,15 @@ class LightTemplate(TemplateEntity, LightEntity): async def async_turn_off(self, **kwargs: Any) -> None: """Turn the light off.""" + off_script = self._action_scripts[CONF_OFF_ACTION] if ATTR_TRANSITION in kwargs and self._supports_transition is True: await self.async_run_script( - self._off_script, + off_script, run_variables={"transition": kwargs[ATTR_TRANSITION]}, context=self._context, ) else: - await self.async_run_script(self._off_script, context=self._context) + await self.async_run_script(off_script, context=self._context) if self._template is None: self._state = False self.async_write_ha_state() @@ -1013,7 +1023,7 @@ class LightTemplate(TemplateEntity, LightEntity): if render in (None, "None", ""): self._supports_transition = False return - self._attr_supported_features &= ~LightEntityFeature.TRANSITION + self._attr_supported_features &= LightEntityFeature.EFFECT self._supports_transition = bool(render) if self._supports_transition: self._attr_supported_features |= LightEntityFeature.TRANSITION diff --git a/homeassistant/components/template/lock.py b/homeassistant/components/template/lock.py index 0804f92e46d..b19cadff26c 100644 --- a/homeassistant/components/template/lock.py +++ b/homeassistant/components/template/lock.py @@ -23,7 +23,6 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ServiceValidationError, TemplateError from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.script import Script from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import DOMAIN @@ -90,13 +89,18 @@ class TemplateLock(TemplateEntity, LockEntity): ) self._state: LockState | None = None name = self._attr_name - assert name + if TYPE_CHECKING: + assert name is not None + self._state_template = config.get(CONF_VALUE_TEMPLATE) - self._command_lock = Script(hass, config[CONF_LOCK], name, DOMAIN) - self._command_unlock = Script(hass, config[CONF_UNLOCK], name, DOMAIN) - if CONF_OPEN in config: - self._command_open = Script(hass, config[CONF_OPEN], name, DOMAIN) - self._attr_supported_features |= LockEntityFeature.OPEN + for action_id, supported_feature in ( + (CONF_LOCK, 0), + (CONF_UNLOCK, 0), + (CONF_OPEN, LockEntityFeature.OPEN), + ): + if action_config := config.get(action_id): + self.add_script(action_id, action_config, name, DOMAIN) + self._attr_supported_features |= supported_feature self._code_format_template = config.get(CONF_CODE_FORMAT_TEMPLATE) self._code_format: str | None = None self._code_format_template_error: TemplateError | None = None @@ -210,7 +214,9 @@ class TemplateLock(TemplateEntity, LockEntity): tpl_vars = {ATTR_CODE: kwargs.get(ATTR_CODE) if kwargs else None} await self.async_run_script( - self._command_lock, run_variables=tpl_vars, context=self._context + self._action_scripts[CONF_LOCK], + run_variables=tpl_vars, + context=self._context, ) async def async_unlock(self, **kwargs: Any) -> None: @@ -226,7 +232,9 @@ class TemplateLock(TemplateEntity, LockEntity): tpl_vars = {ATTR_CODE: kwargs.get(ATTR_CODE) if kwargs else None} await self.async_run_script( - self._command_unlock, run_variables=tpl_vars, context=self._context + self._action_scripts[CONF_UNLOCK], + run_variables=tpl_vars, + context=self._context, ) async def async_open(self, **kwargs: Any) -> None: @@ -242,7 +250,9 @@ class TemplateLock(TemplateEntity, LockEntity): tpl_vars = {ATTR_CODE: kwargs.get(ATTR_CODE) if kwargs else None} await self.async_run_script( - self._command_open, run_variables=tpl_vars, context=self._context + self._action_scripts[CONF_OPEN], + run_variables=tpl_vars, + context=self._context, ) def _raise_template_error_if_available(self): diff --git a/homeassistant/components/template/manifest.json b/homeassistant/components/template/manifest.json index f1225f74f06..32bfd8ce02e 100644 --- a/homeassistant/components/template/manifest.json +++ b/homeassistant/components/template/manifest.json @@ -2,7 +2,7 @@ "domain": "template", "name": "Template", "after_dependencies": ["group"], - "codeowners": ["@PhracturedBlue", "@home-assistant/core"], + "codeowners": ["@Petro31", "@PhracturedBlue", "@home-assistant/core"], "config_flow": true, "dependencies": ["blueprint"], "documentation": "https://www.home-assistant.io/integrations/template", diff --git a/homeassistant/components/template/number.py b/homeassistant/components/template/number.py index 661dbb45dc1..6661afc619c 100644 --- a/homeassistant/components/template/number.py +++ b/homeassistant/components/template/number.py @@ -157,9 +157,7 @@ class TemplateNumber(TemplateEntity, NumberEntity): super().__init__(hass, config=config, unique_id=unique_id) assert self._attr_name is not None self._value_template = config[CONF_STATE] - self._command_set_value = Script( - hass, config[CONF_SET_VALUE], self._attr_name, DOMAIN - ) + self.add_script(CONF_SET_VALUE, config[CONF_SET_VALUE], self._attr_name, DOMAIN) self._step_template = config[CONF_STEP] self._min_value_template = config[CONF_MIN] @@ -210,9 +208,9 @@ class TemplateNumber(TemplateEntity, NumberEntity): if self._optimistic: self._attr_native_value = value self.async_write_ha_state() - if self._command_set_value: + if (set_value := self._action_scripts.get(CONF_SET_VALUE)) is not None: await self.async_run_script( - self._command_set_value, + set_value, run_variables={ATTR_VALUE: value}, context=self._context, ) diff --git a/homeassistant/components/template/select.py b/homeassistant/components/template/select.py index a42ee3d0612..d3b879a695d 100644 --- a/homeassistant/components/template/select.py +++ b/homeassistant/components/template/select.py @@ -143,8 +143,8 @@ class TemplateSelect(TemplateEntity, SelectEntity): assert self._attr_name is not None self._value_template = config[CONF_STATE] if (selection_option := config.get(CONF_SELECT_OPTION)) is not None: - self._command_select_option = Script( - hass, selection_option, self._attr_name, DOMAIN + self.add_script( + CONF_SELECT_OPTION, selection_option, self._attr_name, DOMAIN ) self._options_template = config[ATTR_OPTIONS] self._attr_assumed_state = self._optimistic = config.get(CONF_OPTIMISTIC, False) @@ -177,9 +177,9 @@ class TemplateSelect(TemplateEntity, SelectEntity): if self._optimistic: self._attr_current_option = option self.async_write_ha_state() - if self._command_select_option: + if (select_option := self._action_scripts.get(CONF_SELECT_OPTION)) is not None: await self.async_run_script( - self._command_select_option, + select_option, run_variables={ATTR_OPTION: option}, context=self._context, ) diff --git a/homeassistant/components/template/switch.py b/homeassistant/components/template/switch.py index 756866cfd44..148648a7a3c 100644 --- a/homeassistant/components/template/switch.py +++ b/homeassistant/components/template/switch.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import Any +from typing import TYPE_CHECKING, Any import voluptuous as vol @@ -33,7 +33,6 @@ from homeassistant.helpers.entity_platform import ( AddEntitiesCallback, ) from homeassistant.helpers.restore_state import RestoreEntity -from homeassistant.helpers.script import Script from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import CONF_TURN_OFF, CONF_TURN_ON, DOMAIN @@ -74,7 +73,7 @@ SWITCH_CONFIG_SCHEMA = vol.Schema( ) -async def _async_create_entities(hass, config): +async def _async_create_entities(hass: HomeAssistant, config: ConfigType): """Create the Template switches.""" switches = [] @@ -134,11 +133,11 @@ class SwitchTemplate(TemplateEntity, SwitchEntity, RestoreEntity): def __init__( self, - hass, + hass: HomeAssistant, object_id, - config, + config: ConfigType, unique_id, - ): + ) -> None: """Initialize the Template switch.""" super().__init__( hass, config=config, fallback_name=object_id, unique_id=unique_id @@ -147,18 +146,16 @@ class SwitchTemplate(TemplateEntity, SwitchEntity, RestoreEntity): self.entity_id = async_generate_entity_id( ENTITY_ID_FORMAT, object_id, hass=hass ) - friendly_name = self._attr_name + name = self._attr_name + if TYPE_CHECKING: + assert name is not None self._template = config.get(CONF_VALUE_TEMPLATE) - self._on_script = ( - Script(hass, config.get(CONF_TURN_ON), friendly_name, DOMAIN) - if config.get(CONF_TURN_ON) is not None - else None - ) - self._off_script = ( - Script(hass, config.get(CONF_TURN_OFF), friendly_name, DOMAIN) - if config.get(CONF_TURN_OFF) is not None - else None - ) + + if on_action := config.get(CONF_TURN_ON): + self.add_script(CONF_TURN_ON, on_action, name, DOMAIN) + if off_action := config.get(CONF_TURN_OFF): + self.add_script(CONF_TURN_OFF, off_action, name, DOMAIN) + self._state: bool | None = False self._attr_assumed_state = self._template is None self._attr_device_info = async_device_info_to_link_from_device_id( @@ -209,16 +206,16 @@ class SwitchTemplate(TemplateEntity, SwitchEntity, RestoreEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Fire the on action.""" - if self._on_script: - await self.async_run_script(self._on_script, context=self._context) + if (on_script := self._action_scripts.get(CONF_TURN_ON)) is not None: + await self.async_run_script(on_script, context=self._context) if self._template is None: self._state = True self.async_write_ha_state() async def async_turn_off(self, **kwargs: Any) -> None: """Fire the off action.""" - if self._off_script: - await self.async_run_script(self._off_script, context=self._context) + if (off_script := self._action_scripts.get(CONF_TURN_OFF)) is not None: + await self.async_run_script(off_script, context=self._context) if self._template is None: self._state = False self.async_write_ha_state() diff --git a/homeassistant/components/template/template_entity.py b/homeassistant/components/template/template_entity.py index 8f9edca5976..93ba1fa7471 100644 --- a/homeassistant/components/template/template_entity.py +++ b/homeassistant/components/template/template_entity.py @@ -24,7 +24,6 @@ from homeassistant.const import ( ) from homeassistant.core import ( CALLBACK_TYPE, - Context, Event, EventStateChangedData, HomeAssistant, @@ -41,7 +40,7 @@ from homeassistant.helpers.event import ( TrackTemplateResultInfo, async_track_template_result, ) -from homeassistant.helpers.script import Script, _VarsType +from homeassistant.helpers.script_variables import ScriptVariables from homeassistant.helpers.start import async_at_start from homeassistant.helpers.template import ( Template, @@ -61,6 +60,7 @@ from .const import ( CONF_AVAILABILITY_TEMPLATE, CONF_PICTURE, ) +from .entity import AbstractTemplateEntity _LOGGER = logging.getLogger(__name__) @@ -248,7 +248,7 @@ class _TemplateAttribute: return -class TemplateEntity(Entity): # pylint: disable=hass-enforce-class-module +class TemplateEntity(AbstractTemplateEntity): # pylint: disable=hass-enforce-class-module """Entity that uses templates to calculate attributes.""" _attr_available = True @@ -268,6 +268,7 @@ class TemplateEntity(Entity): # pylint: disable=hass-enforce-class-module unique_id: str | None = None, ) -> None: """Template Entity.""" + super().__init__(hass) self._template_attrs: dict[Template, list[_TemplateAttribute]] = {} self._template_result_info: TrackTemplateResultInfo | None = None self._attr_extra_state_attributes = {} @@ -285,6 +286,7 @@ class TemplateEntity(Entity): # pylint: disable=hass-enforce-class-module ] | None ) = None + self._run_variables: ScriptVariables | dict if config is None: self._attribute_templates = attribute_templates self._availability_template = availability_template @@ -339,18 +341,6 @@ class TemplateEntity(Entity): # pylint: disable=hass-enforce-class-module variables=variables, parse_result=False ) - @callback - def _render_variables(self) -> dict: - if isinstance(self._run_variables, dict): - return self._run_variables - - return self._run_variables.async_render( - self.hass, - { - "this": TemplateStateFromEntityId(self.hass, self.entity_id), - }, - ) - @callback def _update_available(self, result: str | TemplateError) -> None: if isinstance(result, TemplateError): @@ -387,6 +377,18 @@ class TemplateEntity(Entity): # pylint: disable=hass-enforce-class-module return None return cast(str, self._blueprint_inputs[CONF_USE_BLUEPRINT][CONF_PATH]) + def _render_script_variables(self) -> dict[str, Any]: + """Render configured variables.""" + if isinstance(self._run_variables, dict): + return self._run_variables + + return self._run_variables.async_render( + self.hass, + { + "this": TemplateStateFromEntityId(self.hass, self.entity_id), + }, + ) + def add_template_attribute( self, attribute: str, @@ -488,7 +490,7 @@ class TemplateEntity(Entity): # pylint: disable=hass-enforce-class-module variables = { "this": TemplateStateFromEntityId(self.hass, self.entity_id), - **self._render_variables(), + **self._render_script_variables(), } for template, attributes in self._template_attrs.items(): @@ -581,22 +583,3 @@ class TemplateEntity(Entity): # pylint: disable=hass-enforce-class-module """Call for forced update.""" assert self._template_result_info self._template_result_info.async_refresh() - - async def async_run_script( - self, - script: Script, - *, - run_variables: _VarsType | None = None, - context: Context | None = None, - ) -> None: - """Run an action script.""" - if run_variables is None: - run_variables = {} - await script.async_run( - run_variables={ - "this": TemplateStateFromEntityId(self.hass, self.entity_id), - **self._render_variables(), - **run_variables, - }, - context=context, - ) diff --git a/homeassistant/components/template/vacuum.py b/homeassistant/components/template/vacuum.py index b977f4e659a..ba7c330dad2 100644 --- a/homeassistant/components/template/vacuum.py +++ b/homeassistant/components/template/vacuum.py @@ -3,7 +3,7 @@ from __future__ import annotations import logging -from typing import Any +from typing import TYPE_CHECKING, Any import voluptuous as vol @@ -33,7 +33,6 @@ from homeassistant.exceptions import TemplateError from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.script import Script from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import DOMAIN @@ -90,7 +89,7 @@ PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA.extend( ) -async def _async_create_entities(hass, config): +async def _async_create_entities(hass: HomeAssistant, config: ConfigType): """Create the Template Vacuums.""" vacuums = [] @@ -127,11 +126,11 @@ class TemplateVacuum(TemplateEntity, StateVacuumEntity): def __init__( self, - hass, + hass: HomeAssistant, object_id, - config, + config: ConfigType, unique_id, - ): + ) -> None: """Initialize the vacuum.""" super().__init__( hass, config=config, fallback_name=object_id, unique_id=unique_id @@ -139,7 +138,9 @@ class TemplateVacuum(TemplateEntity, StateVacuumEntity): self.entity_id = async_generate_entity_id( ENTITY_ID_FORMAT, object_id, hass=hass ) - friendly_name = self._attr_name + name = self._attr_name + if TYPE_CHECKING: + assert name is not None self._template = config.get(CONF_VALUE_TEMPLATE) self._battery_level_template = config.get(CONF_BATTERY_LEVEL_TEMPLATE) @@ -148,43 +149,18 @@ class TemplateVacuum(TemplateEntity, StateVacuumEntity): VacuumEntityFeature.START | VacuumEntityFeature.STATE ) - self._start_script = Script(hass, config[SERVICE_START], friendly_name, DOMAIN) - - self._pause_script = None - if pause_action := config.get(SERVICE_PAUSE): - self._pause_script = Script(hass, pause_action, friendly_name, DOMAIN) - self._attr_supported_features |= VacuumEntityFeature.PAUSE - - self._stop_script = None - if stop_action := config.get(SERVICE_STOP): - self._stop_script = Script(hass, stop_action, friendly_name, DOMAIN) - self._attr_supported_features |= VacuumEntityFeature.STOP - - self._return_to_base_script = None - if return_to_base_action := config.get(SERVICE_RETURN_TO_BASE): - self._return_to_base_script = Script( - hass, return_to_base_action, friendly_name, DOMAIN - ) - self._attr_supported_features |= VacuumEntityFeature.RETURN_HOME - - self._clean_spot_script = None - if clean_spot_action := config.get(SERVICE_CLEAN_SPOT): - self._clean_spot_script = Script( - hass, clean_spot_action, friendly_name, DOMAIN - ) - self._attr_supported_features |= VacuumEntityFeature.CLEAN_SPOT - - self._locate_script = None - if locate_action := config.get(SERVICE_LOCATE): - self._locate_script = Script(hass, locate_action, friendly_name, DOMAIN) - self._attr_supported_features |= VacuumEntityFeature.LOCATE - - self._set_fan_speed_script = None - if set_fan_speed_action := config.get(SERVICE_SET_FAN_SPEED): - self._set_fan_speed_script = Script( - hass, set_fan_speed_action, friendly_name, DOMAIN - ) - self._attr_supported_features |= VacuumEntityFeature.FAN_SPEED + for action_id, supported_feature in ( + (SERVICE_START, 0), + (SERVICE_PAUSE, VacuumEntityFeature.PAUSE), + (SERVICE_STOP, VacuumEntityFeature.STOP), + (SERVICE_RETURN_TO_BASE, VacuumEntityFeature.RETURN_HOME), + (SERVICE_CLEAN_SPOT, VacuumEntityFeature.CLEAN_SPOT), + (SERVICE_LOCATE, VacuumEntityFeature.LOCATE), + (SERVICE_SET_FAN_SPEED, VacuumEntityFeature.FAN_SPEED), + ): + if action_config := config.get(action_id): + self.add_script(action_id, action_config, name, DOMAIN) + self._attr_supported_features |= supported_feature self._state = None self._battery_level = None @@ -203,62 +179,50 @@ class TemplateVacuum(TemplateEntity, StateVacuumEntity): async def async_start(self) -> None: """Start or resume the cleaning task.""" - await self.async_run_script(self._start_script, context=self._context) + await self.async_run_script( + self._action_scripts[SERVICE_START], context=self._context + ) async def async_pause(self) -> None: """Pause the cleaning task.""" - if self._pause_script is None: - return - - await self.async_run_script(self._pause_script, context=self._context) + if (script := self._action_scripts.get(SERVICE_PAUSE)) is not None: + await self.async_run_script(script, context=self._context) async def async_stop(self, **kwargs: Any) -> None: """Stop the cleaning task.""" - if self._stop_script is None: - return - - await self.async_run_script(self._stop_script, context=self._context) + if (script := self._action_scripts.get(SERVICE_STOP)) is not None: + await self.async_run_script(script, context=self._context) async def async_return_to_base(self, **kwargs: Any) -> None: """Set the vacuum cleaner to return to the dock.""" - if self._return_to_base_script is None: - return - - await self.async_run_script(self._return_to_base_script, context=self._context) + if (script := self._action_scripts.get(SERVICE_RETURN_TO_BASE)) is not None: + await self.async_run_script(script, context=self._context) async def async_clean_spot(self, **kwargs: Any) -> None: """Perform a spot clean-up.""" - if self._clean_spot_script is None: - return - - await self.async_run_script(self._clean_spot_script, context=self._context) + if (script := self._action_scripts.get(SERVICE_CLEAN_SPOT)) is not None: + await self.async_run_script(script, context=self._context) async def async_locate(self, **kwargs: Any) -> None: """Locate the vacuum cleaner.""" - if self._locate_script is None: - return - - await self.async_run_script(self._locate_script, context=self._context) + if (script := self._action_scripts.get(SERVICE_LOCATE)) is not None: + await self.async_run_script(script, context=self._context) async def async_set_fan_speed(self, fan_speed: str, **kwargs: Any) -> None: """Set fan speed.""" - if self._set_fan_speed_script is None: - return - - if fan_speed in self._attr_fan_speed_list: - self._attr_fan_speed = fan_speed - await self.async_run_script( - self._set_fan_speed_script, - run_variables={ATTR_FAN_SPEED: fan_speed}, - context=self._context, - ) - else: + if fan_speed not in self._attr_fan_speed_list: _LOGGER.error( "Received invalid fan speed: %s for entity %s. Expected: %s", fan_speed, self.entity_id, self._attr_fan_speed_list, ) + return + + if (script := self._action_scripts.get(SERVICE_SET_FAN_SPEED)) is not None: + await self.async_run_script( + script, run_variables={ATTR_FAN_SPEED: fan_speed}, context=self._context + ) @callback def _async_setup_templates(self) -> None: diff --git a/tests/components/template/test_entity.py b/tests/components/template/test_entity.py new file mode 100644 index 00000000000..67a85839982 --- /dev/null +++ b/tests/components/template/test_entity.py @@ -0,0 +1,17 @@ +"""Test abstract template entity.""" + +import pytest + +from homeassistant.components.template import entity as abstract_entity +from homeassistant.core import HomeAssistant + + +async def test_template_entity_not_implemented(hass: HomeAssistant) -> None: + """Test abstract template entity raises not implemented error.""" + + entity = abstract_entity.AbstractTemplateEntity(None) + with pytest.raises(NotImplementedError): + _ = entity.referenced_blueprint + + with pytest.raises(NotImplementedError): + entity._render_script_variables() diff --git a/tests/components/template/test_template_entity.py b/tests/components/template/test_template_entity.py index c09a09750fe..d66fc2710c9 100644 --- a/tests/components/template/test_template_entity.py +++ b/tests/components/template/test_template_entity.py @@ -9,7 +9,7 @@ from homeassistant.helpers import template async def test_template_entity_requires_hass_set(hass: HomeAssistant) -> None: """Test template entity requires hass to be set before accepting templates.""" - entity = template_entity.TemplateEntity(hass) + entity = template_entity.TemplateEntity(None) with pytest.raises(ValueError, match="^hass cannot be None"): entity.add_template_attribute("_hello", template.Template("Hello")) From cd0a983850519dc2dee30dd5f4290b71b36ead6a Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Tue, 4 Mar 2025 00:28:10 -0800 Subject: [PATCH 2142/3148] Bump google-nest-sdm to 7.1.4 (#139728) --- 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 a0d8bc06640..d9383533300 100644 --- a/homeassistant/components/nest/manifest.json +++ b/homeassistant/components/nest/manifest.json @@ -19,5 +19,5 @@ "documentation": "https://www.home-assistant.io/integrations/nest", "iot_class": "cloud_push", "loggers": ["google_nest_sdm"], - "requirements": ["google-nest-sdm==7.1.3"] + "requirements": ["google-nest-sdm==7.1.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index ca4feec308e..ab96cadb5ab 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1042,7 +1042,7 @@ google-cloud-texttospeech==2.17.2 google-genai==1.1.0 # homeassistant.components.nest -google-nest-sdm==7.1.3 +google-nest-sdm==7.1.4 # homeassistant.components.google_photos google-photos-library-api==0.12.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bd1478f945c..438e296b8d7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -892,7 +892,7 @@ google-cloud-texttospeech==2.17.2 google-genai==1.1.0 # homeassistant.components.nest -google-nest-sdm==7.1.3 +google-nest-sdm==7.1.4 # homeassistant.components.google_photos google-photos-library-api==0.12.1 From c6a9472fdb14a42987913b3a68cc393c289412bc Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Tue, 4 Mar 2025 00:46:56 -0800 Subject: [PATCH 2143/3148] Add nest translation string for `already_in_progress` (#139727) --- homeassistant/components/nest/strings.json | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/nest/strings.json b/homeassistant/components/nest/strings.json index 23da524ab7e..54f543aa845 100644 --- a/homeassistant/components/nest/strings.json +++ b/homeassistant/components/nest/strings.json @@ -58,6 +58,7 @@ }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", "missing_credentials": "[%key:common::config_flow::abort::oauth2_missing_credentials%]", "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", From d87c963db54ea90925496d502886cce49a96715a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Tue, 4 Mar 2025 08:52:29 +0000 Subject: [PATCH 2144/3148] Prevent zero interval in Calendar get_events service (#139378) * Prevent zero interval in Calendar get_events service * Fix holiday calendar tests * Remove redundant entity_id * Use translation for exception * Replace check with voluptuous validator * Revert strings.xml --- homeassistant/components/calendar/__init__.py | 23 ++++++++ tests/components/calendar/test_init.py | 53 ++++++++++++++++++- tests/components/holiday/test_calendar.py | 12 ++--- 3 files changed, 81 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/calendar/__init__.py b/homeassistant/components/calendar/__init__.py index 40d6952fa64..96bf717c3ac 100644 --- a/homeassistant/components/calendar/__init__.py +++ b/homeassistant/components/calendar/__init__.py @@ -153,6 +153,27 @@ def _has_min_duration( return validate +def _has_positive_interval( + start_key: str, end_key: str, duration_key: str +) -> Callable[[dict[str, Any]], dict[str, Any]]: + """Verify that the time span between start and end is greater than zero.""" + + def validate(obj: dict[str, Any]) -> dict[str, Any]: + if (duration := obj.get(duration_key)) is not None: + if duration <= datetime.timedelta(seconds=0): + raise vol.Invalid(f"Expected positive duration ({duration})") + return obj + + if (start := obj.get(start_key)) and (end := obj.get(end_key)): + if start >= end: + raise vol.Invalid( + f"Expected end time to be after start time ({start}, {end})" + ) + return obj + + return validate + + def _has_same_type(*keys: Any) -> Callable[[dict[str, Any]], dict[str, Any]]: """Verify that all values are of the same type.""" @@ -281,6 +302,7 @@ SERVICE_GET_EVENTS_SCHEMA: Final = vol.All( ), } ), + _has_positive_interval(EVENT_START_DATETIME, EVENT_END_DATETIME, EVENT_DURATION), ) @@ -870,6 +892,7 @@ async def async_get_events_service( end = start + service_call.data[EVENT_DURATION] else: end = service_call.data[EVENT_END_DATETIME] + calendar_event_list = await calendar.async_get_events( calendar.hass, dt_util.as_local(start), dt_util.as_local(end) ) diff --git a/tests/components/calendar/test_init.py b/tests/components/calendar/test_init.py index 2d712f408c2..6de0a7ef936 100644 --- a/tests/components/calendar/test_init.py +++ b/tests/components/calendar/test_init.py @@ -5,6 +5,7 @@ from __future__ import annotations from collections.abc import Generator from datetime import timedelta from http import HTTPStatus +import re from typing import Any from freezegun import freeze_time @@ -448,7 +449,7 @@ async def test_list_events_service( service: str, expected: dict[str, Any], ) -> None: - """Test listing events from the service call using exlplicit start and end time. + """Test listing events from the service call using explicit start and end time. This test uses a fixed date/time so that it can deterministically test the string output values. @@ -553,3 +554,53 @@ async def test_list_events_missing_fields(hass: HomeAssistant) -> None: blocking=True, return_response=True, ) + + +@pytest.mark.parametrize( + "frozen_time", ["2023-06-22 10:30:00+00:00"], ids=["frozen_time"] +) +@pytest.mark.parametrize( + ("service_data", "error_msg"), + [ + ( + { + "start_date_time": "2023-06-22T04:30:00-06:00", + "end_date_time": "2023-06-22T04:30:00-06:00", + }, + "Expected end time to be after start time (2023-06-22 04:30:00-06:00, 2023-06-22 04:30:00-06:00)", + ), + ( + { + "start_date_time": "2023-06-22T04:30:00", + "end_date_time": "2023-06-22T04:30:00", + }, + "Expected end time to be after start time (2023-06-22 04:30:00, 2023-06-22 04:30:00)", + ), + ( + {"start_date_time": "2023-06-22", "end_date_time": "2023-06-22"}, + "Expected end time to be after start time (2023-06-22 00:00:00, 2023-06-22 00:00:00)", + ), + ( + {"start_date_time": "2023-06-22 10:00:00", "duration": "0"}, + "Expected positive duration (0:00:00)", + ), + ], +) +async def test_list_events_service_same_dates( + hass: HomeAssistant, + service_data: dict[str, str], + error_msg: str, +) -> None: + """Test listing events from the service call using the same start and end time.""" + + with pytest.raises(vol.error.MultipleInvalid, match=re.escape(error_msg)): + await hass.services.async_call( + DOMAIN, + SERVICE_GET_EVENTS, + service_data={ + "entity_id": "calendar.calendar_1", + **service_data, + }, + blocking=True, + return_response=True, + ) diff --git a/tests/components/holiday/test_calendar.py b/tests/components/holiday/test_calendar.py index db58b7b1f73..6733d38442b 100644 --- a/tests/components/holiday/test_calendar.py +++ b/tests/components/holiday/test_calendar.py @@ -49,7 +49,7 @@ async def test_holiday_calendar_entity( SERVICE_GET_EVENTS, { "entity_id": "calendar.united_states_ak", - "end_date_time": dt_util.now(), + "end_date_time": dt_util.now() + timedelta(hours=1), }, blocking=True, return_response=True, @@ -135,7 +135,7 @@ async def test_default_language( SERVICE_GET_EVENTS, { "entity_id": "calendar.france_bl", - "end_date_time": dt_util.now(), + "end_date_time": dt_util.now() + timedelta(hours=1), }, blocking=True, return_response=True, @@ -164,7 +164,7 @@ async def test_default_language( SERVICE_GET_EVENTS, { "entity_id": "calendar.france_bl", - "end_date_time": dt_util.now(), + "end_date_time": dt_util.now() + timedelta(hours=1), }, blocking=True, return_response=True, @@ -211,7 +211,7 @@ async def test_no_language( SERVICE_GET_EVENTS, { "entity_id": "calendar.albania", - "end_date_time": dt_util.now(), + "end_date_time": dt_util.now() + timedelta(hours=1), }, blocking=True, return_response=True, @@ -308,7 +308,7 @@ async def test_language_not_exist( SERVICE_GET_EVENTS, { "entity_id": "calendar.norge", - "end_date_time": dt_util.now(), + "end_date_time": dt_util.now() + timedelta(hours=1), }, blocking=True, return_response=True, @@ -336,7 +336,7 @@ async def test_language_not_exist( SERVICE_GET_EVENTS, { "entity_id": "calendar.norge", - "end_date_time": dt_util.now(), + "end_date_time": dt_util.now() + timedelta(hours=1), }, blocking=True, return_response=True, From 9f780a5308472e4c5371ca22cef761543d57a3b3 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Tue, 4 Mar 2025 09:56:42 +0100 Subject: [PATCH 2145/3148] Fix ability to remove orphan device in Music Assistant integration (#139431) * Fix ability to remove orphan device in Music Assistant integration * Add test * Remove orphaned device entries at startup as well * adjust mocked client --- .../components/music_assistant/__init__.py | 44 +++++++++++- tests/components/music_assistant/conftest.py | 16 +++++ tests/components/music_assistant/test_init.py | 70 +++++++++++++++++++ 3 files changed, 128 insertions(+), 2 deletions(-) create mode 100644 tests/components/music_assistant/test_init.py diff --git a/homeassistant/components/music_assistant/__init__.py b/homeassistant/components/music_assistant/__init__.py index e569bb93a42..a2d2dae9e3f 100644 --- a/homeassistant/components/music_assistant/__init__.py +++ b/homeassistant/components/music_assistant/__init__.py @@ -9,7 +9,7 @@ from typing import TYPE_CHECKING from music_assistant_client import MusicAssistantClient from music_assistant_client.exceptions import CannotConnect, InvalidServerVersion from music_assistant_models.enums import EventType -from music_assistant_models.errors import MusicAssistantError +from music_assistant_models.errors import ActionUnavailable, MusicAssistantError from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import CONF_URL, EVENT_HOMEASSISTANT_STOP, Platform @@ -23,7 +23,7 @@ from homeassistant.helpers.issue_registry import ( async_delete_issue, ) -from .actions import register_actions +from .actions import get_music_assistant_client, register_actions from .const import DOMAIN, LOGGER if TYPE_CHECKING: @@ -137,6 +137,18 @@ async def async_setup_entry( mass.subscribe(handle_player_removed, EventType.PLAYER_REMOVED) ) + # check if any playerconfigs have been removed while we were disconnected + all_player_configs = await mass.config.get_player_configs() + player_ids = {player.player_id for player in all_player_configs} + dev_reg = dr.async_get(hass) + dev_entries = dr.async_entries_for_config_entry(dev_reg, entry.entry_id) + for device in dev_entries: + for identifier in device.identifiers: + if identifier[0] == DOMAIN and identifier[1] not in player_ids: + dev_reg.async_update_device( + device.id, remove_config_entry_id=entry.entry_id + ) + return True @@ -174,3 +186,31 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await mass_entry_data.mass.disconnect() return unload_ok + + +async def async_remove_config_entry_device( + hass: HomeAssistant, config_entry: ConfigEntry, device_entry: dr.DeviceEntry +) -> bool: + """Remove a config entry from a device.""" + player_id = next( + ( + identifier[1] + for identifier in device_entry.identifiers + if identifier[0] == DOMAIN + ), + None, + ) + if player_id is None: + # this should not be possible at all, but guard it anyways + return False + mass = get_music_assistant_client(hass, config_entry.entry_id) + if mass.players.get(player_id) is None: + # player is already removed on the server, this is an orphaned device + return True + # try to remove the player from the server + try: + await mass.config.remove_player_config(player_id) + except ActionUnavailable: + return False + else: + return True diff --git a/tests/components/music_assistant/conftest.py b/tests/components/music_assistant/conftest.py index 2df43defe62..2b397891d6f 100644 --- a/tests/components/music_assistant/conftest.py +++ b/tests/components/music_assistant/conftest.py @@ -8,6 +8,7 @@ from music_assistant_client.music import Music from music_assistant_client.player_queues import PlayerQueues from music_assistant_client.players import Players from music_assistant_models.api import ServerInfoMessage +from music_assistant_models.config_entries import PlayerConfig import pytest from homeassistant.components.music_assistant.config_flow import CONF_URL @@ -68,6 +69,21 @@ async def music_assistant_client_fixture() -> AsyncGenerator[MagicMock]: client.music = Music(client) client.server_url = client.server_info.base_url client.get_media_item_image_url = MagicMock(return_value=None) + client.config = MagicMock() + + async def get_player_configs() -> list[PlayerConfig]: + """Mock get player configs.""" + # simply return a mock config for each player + return [ + PlayerConfig( + values={}, + provider=player.provider, + player_id=player.player_id, + ) + for player in client.players + ] + + client.config.get_player_configs = get_player_configs yield client diff --git a/tests/components/music_assistant/test_init.py b/tests/components/music_assistant/test_init.py new file mode 100644 index 00000000000..4cfefb50bd2 --- /dev/null +++ b/tests/components/music_assistant/test_init.py @@ -0,0 +1,70 @@ +"""Test the Music Assistant integration init.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock + +from music_assistant_models.errors import ActionUnavailable + +from homeassistant.components.music_assistant.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.setup import async_setup_component + +from .common import setup_integration_from_fixtures + +from tests.typing import WebSocketGenerator + + +async def test_remove_config_entry_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + music_assistant_client: MagicMock, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test device removal.""" + assert await async_setup_component(hass, "config", {}) + await setup_integration_from_fixtures(hass, music_assistant_client) + await hass.async_block_till_done() + config_entry = hass.config_entries.async_entries(DOMAIN)[0] + client = await hass_ws_client(hass) + + # test if the removal should be denied if the device is still in use + device_entry = dr.async_entries_for_config_entry( + device_registry, config_entry.entry_id + )[0] + entity_id = "media_player.test_player_1" + assert device_entry + assert entity_registry.async_get(entity_id) + assert hass.states.get(entity_id) + music_assistant_client.config.remove_player_config = AsyncMock( + side_effect=ActionUnavailable + ) + response = await client.remove_device(device_entry.id, config_entry.entry_id) + assert music_assistant_client.config.remove_player_config.call_count == 1 + assert response["success"] is False + + # test if the removal should be allowed if the device is not in use + music_assistant_client.config.remove_player_config = AsyncMock() + response = await client.remove_device(device_entry.id, config_entry.entry_id) + assert response["success"] is True + await hass.async_block_till_done() + assert not device_registry.async_get(device_entry.id) + assert not entity_registry.async_get(entity_id) + assert not hass.states.get(entity_id) + + # test if the removal succeeds if its no longer provided by the server + mass_player_id = "00:00:00:00:00:02" + music_assistant_client.players._players.pop(mass_player_id) + device_entry = dr.async_entries_for_config_entry( + device_registry, config_entry.entry_id + )[0] + entity_id = "media_player.my_super_test_player_2" + assert device_entry + assert entity_registry.async_get(entity_id) + assert hass.states.get(entity_id) + music_assistant_client.config.remove_player_config = AsyncMock() + response = await client.remove_device(device_entry.id, config_entry.entry_id) + assert music_assistant_client.config.remove_player_config.call_count == 0 + assert response["success"] is True From 13001faf514b32bdbf9cf38e8b7b83049018d866 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 4 Mar 2025 09:57:38 +0100 Subject: [PATCH 2146/3148] Improve strings in `openai_conversation.generate_image` action (#139736) Use descriptive wording, fix sentence-casing. --- homeassistant/components/openai_conversation/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/openai_conversation/strings.json b/homeassistant/components/openai_conversation/strings.json index b8768f8abbe..aba4fdc3d40 100644 --- a/homeassistant/components/openai_conversation/strings.json +++ b/homeassistant/components/openai_conversation/strings.json @@ -48,10 +48,10 @@ "services": { "generate_image": { "name": "Generate image", - "description": "Turn a prompt into an image", + "description": "Turns a prompt into an image", "fields": { "config_entry": { - "name": "Config Entry", + "name": "Config entry", "description": "The config entry to use for this action" }, "prompt": { From 973fee9fe15f5e9b5b9e67912fe59758e9478f60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Tue, 4 Mar 2025 11:07:44 +0100 Subject: [PATCH 2147/3148] Delete refresh after a non-breaking error at event stream at Home Connect (#139740) * Delete refresh after non-breaking error And improve how many time does it take to retry to open stream * Update tests --- .../components/home_connect/coordinator.py | 14 +++++------ .../home_connect/test_coordinator.py | 24 ++++--------------- 2 files changed, 10 insertions(+), 28 deletions(-) diff --git a/homeassistant/components/home_connect/coordinator.py b/homeassistant/components/home_connect/coordinator.py index d9200b282c9..4d275854e30 100644 --- a/homeassistant/components/home_connect/coordinator.py +++ b/homeassistant/components/home_connect/coordinator.py @@ -47,8 +47,6 @@ _LOGGER = logging.getLogger(__name__) type HomeConnectConfigEntry = ConfigEntry[HomeConnectCoordinator] -EVENT_STREAM_RECONNECT_DELAY = 30 - @dataclass(frozen=True, kw_only=True) class HomeConnectApplianceData: @@ -157,9 +155,11 @@ class HomeConnectCoordinator( async def _event_listener(self) -> None: """Match event with listener for event type.""" + retry_time = 10 while True: try: async for event_message in self.client.stream_all_events(): + retry_time = 10 event_message_ha_id = event_message.ha_id match event_message.type: case EventType.STATUS: @@ -256,20 +256,18 @@ class HomeConnectCoordinator( except (EventStreamInterruptedError, HomeConnectRequestError) as error: _LOGGER.debug( "Non-breaking error (%s) while listening for events," - " continuing in 30 seconds", + " continuing in %s seconds", type(error).__name__, + retry_time, ) - await asyncio.sleep(EVENT_STREAM_RECONNECT_DELAY) + await asyncio.sleep(retry_time) + retry_time = min(retry_time * 2, 3600) except HomeConnectApiError as error: _LOGGER.error("Error while listening for events: %s", error) self.hass.config_entries.async_schedule_reload( self.config_entry.entry_id ) break - # if there was a non-breaking error, we continue listening - # but we need to refresh the data to get the possible changes - # that happened while the event stream was interrupted - await self.async_refresh() @callback def _call_event_listener(self, event_message: EventMessage) -> None: diff --git a/tests/components/home_connect/test_coordinator.py b/tests/components/home_connect/test_coordinator.py index 3dd9ffbe7c1..ac27b848a36 100644 --- a/tests/components/home_connect/test_coordinator.py +++ b/tests/components/home_connect/test_coordinator.py @@ -1,6 +1,7 @@ """Test for Home Connect coordinator.""" from collections.abc import Awaitable, Callable +from datetime import timedelta from typing import Any from unittest.mock import AsyncMock, MagicMock, patch @@ -12,8 +13,6 @@ from aiohomeconnect.model import ( EventKey, EventMessage, EventType, - Status, - StatusKey, ) from aiohomeconnect.model.error import ( EventStreamInterruptedError, @@ -24,7 +23,6 @@ from aiohomeconnect.model.error import ( import pytest from homeassistant.components.home_connect.const import ( - BSH_DOOR_STATE_LOCKED, BSH_DOOR_STATE_OPEN, BSH_EVENT_PRESENT_STATE_PRESENT, BSH_POWER_OFF, @@ -38,8 +36,9 @@ from homeassistant.core import ( callback, ) from homeassistant.helpers import entity_registry as er +from homeassistant.util import dt as dt_util -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed @pytest.fixture @@ -286,9 +285,6 @@ async def test_event_listener_error( ( "entity_id", "initial_state", - "status_key", - "status_value", - "after_refresh_expected_state", "event_key", "event_value", "after_event_expected_state", @@ -297,24 +293,15 @@ async def test_event_listener_error( ( "sensor.washer_door", "closed", - StatusKey.BSH_COMMON_DOOR_STATE, - BSH_DOOR_STATE_LOCKED, - "locked", EventKey.BSH_COMMON_STATUS_DOOR_STATE, BSH_DOOR_STATE_OPEN, "open", ), ], ) -@patch( - "homeassistant.components.home_connect.coordinator.EVENT_STREAM_RECONNECT_DELAY", 0 -) async def test_event_listener_resilience( entity_id: str, initial_state: str, - status_key: StatusKey, - status_value: Any, - after_refresh_expected_state: str, event_key: EventKey, event_value: Any, after_event_expected_state: str, @@ -345,16 +332,13 @@ async def test_event_listener_resilience( assert hass.states.is_state(entity_id, initial_state) - client.get_status.return_value = ArrayOfStatus( - [Status(key=status_key, raw_key=status_key.value, value=status_value)], - ) await hass.async_block_till_done() future.set_exception(exception) await hass.async_block_till_done() + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=30)) await hass.async_block_till_done() assert client.stream_all_events.call_count == 2 - assert hass.states.is_state(entity_id, after_refresh_expected_state) await client.add_events( [ From 4f36bbdfe6cc22d046c959347c6a3a0ccaafb4e4 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 4 Mar 2025 11:33:27 +0100 Subject: [PATCH 2148/3148] Fix regression in template flag introduced by #139645 (#139742) --- homeassistant/components/template/light.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/template/light.py b/homeassistant/components/template/light.py index 3369bf3ce0f..c7188f380bc 100644 --- a/homeassistant/components/template/light.py +++ b/homeassistant/components/template/light.py @@ -1023,7 +1023,7 @@ class LightTemplate(TemplateEntity, LightEntity): if render in (None, "None", ""): self._supports_transition = False return - self._attr_supported_features &= LightEntityFeature.EFFECT + self._attr_supported_features &= ~LightEntityFeature.TRANSITION self._supports_transition = bool(render) if self._supports_transition: self._attr_supported_features |= LightEntityFeature.TRANSITION From 32f59bfd256527e0af573fec015bbe365a8a6709 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 4 Mar 2025 11:39:35 +0100 Subject: [PATCH 2149/3148] Remove unused constant from recorder (#139741) --- homeassistant/components/recorder/core.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/homeassistant/components/recorder/core.py b/homeassistant/components/recorder/core.py index eaf72b74cdc..62afa0e7b04 100644 --- a/homeassistant/components/recorder/core.py +++ b/homeassistant/components/recorder/core.py @@ -123,8 +123,6 @@ from .util import ( _LOGGER = logging.getLogger(__name__) -DEFAULT_URL = "sqlite:///{hass_config_path}" - # Controls how often we clean up # States and Events objects EXPIRE_AFTER_COMMITS = 120 From 23dac3933f881df404e6a30522a2671af4f313fb Mon Sep 17 00:00:00 2001 From: Markus Adrario Date: Tue, 4 Mar 2025 11:40:36 +0100 Subject: [PATCH 2150/3148] Fix Homee brightness sensors reporting in percent (#139409) * fix brigtness sensor having percent as unit. * add test for percent-brightness-sensor * remove valve position and update tests * Removed test, because covered by Snapshots * fix review comments * move device calss to init. * fix test * fix review comments * add battery sensor back to test fixture * fix --- homeassistant/components/homee/icons.json | 6 ++ homeassistant/components/homee/sensor.py | 16 ++++++ homeassistant/components/homee/strings.json | 3 + tests/components/homee/fixtures/sensors.json | 23 +++++++- .../homee/snapshots/test_sensor.ambr | 55 ++++++++++++++++++- tests/components/homee/test_sensor.py | 28 +--------- 6 files changed, 103 insertions(+), 28 deletions(-) diff --git a/homeassistant/components/homee/icons.json b/homeassistant/components/homee/icons.json index 17ac0ecd1f2..b4ad8871568 100644 --- a/homeassistant/components/homee/icons.json +++ b/homeassistant/components/homee/icons.json @@ -1,6 +1,12 @@ { "entity": { "sensor": { + "brightness": { + "default": "mdi:brightness-5" + }, + "brightness_instance": { + "default": "mdi:brightness-5" + }, "link_quality": { "default": "mdi:signal" }, diff --git a/homeassistant/components/homee/sensor.py b/homeassistant/components/homee/sensor.py index 86733aae778..410f87f2168 100644 --- a/homeassistant/components/homee/sensor.py +++ b/homeassistant/components/homee/sensor.py @@ -40,10 +40,22 @@ def get_window_value(attribute: HomeeAttribute) -> str | None: return vals.get(attribute.current_value) +def get_brightness_device_class( + attribute: HomeeAttribute, device_class: SensorDeviceClass | None +) -> SensorDeviceClass | None: + """Return the device class for a brightness sensor.""" + if attribute.unit == "%": + return None + return device_class + + @dataclass(frozen=True, kw_only=True) class HomeeSensorEntityDescription(SensorEntityDescription): """A class that describes Homee sensor entities.""" + device_class_fn: Callable[ + [HomeeAttribute, SensorDeviceClass | None], SensorDeviceClass | None + ] = lambda attribute, device_class: device_class value_fn: Callable[[HomeeAttribute], str | float | None] = ( lambda value: value.current_value ) @@ -67,6 +79,7 @@ SENSOR_DESCRIPTIONS: dict[AttributeType, HomeeSensorEntityDescription] = { AttributeType.BRIGHTNESS: HomeeSensorEntityDescription( key="brightness", device_class=SensorDeviceClass.ILLUMINANCE, + device_class_fn=get_brightness_device_class, state_class=SensorStateClass.MEASUREMENT, value_fn=( lambda attribute: attribute.current_value * 1000 @@ -303,6 +316,9 @@ class HomeeSensor(HomeeEntity, SensorEntity): if attribute.instance > 0: self._attr_translation_key = f"{self._attr_translation_key}_instance" self._attr_translation_placeholders = {"instance": str(attribute.instance)} + self._attr_device_class = description.device_class_fn( + attribute, description.device_class + ) @property def native_value(self) -> float | str | None: diff --git a/homeassistant/components/homee/strings.json b/homeassistant/components/homee/strings.json index cf5b90dbe2a..94f85824280 100644 --- a/homeassistant/components/homee/strings.json +++ b/homeassistant/components/homee/strings.json @@ -111,6 +111,9 @@ } }, "sensor": { + "brightness": { + "name": "Illuminance" + }, "brightness_instance": { "name": "Illuminance {instance}" }, diff --git a/tests/components/homee/fixtures/sensors.json b/tests/components/homee/fixtures/sensors.json index f4a7f462218..bcc36a85ee7 100644 --- a/tests/components/homee/fixtures/sensors.json +++ b/tests/components/homee/fixtures/sensors.json @@ -81,6 +81,27 @@ "data": "", "name": "" }, + { + "id": 34, + "node_id": 1, + "instance": 2, + "minimum": 0, + "maximum": 100, + "current_value": 100.0, + "target_value": 100.0, + "last_value": 100.0, + "unit": "%", + "step_value": 1.0, + "editable": 0, + "type": 8, + "state": 1, + "last_changed": 1709982926, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, { "id": 4, "node_id": 1, @@ -93,7 +114,7 @@ "unit": "%", "step_value": 1.0, "editable": 0, - "type": 8, + "type": 11, "state": 1, "last_changed": 1709982926, "changed_by": 1, diff --git a/tests/components/homee/snapshots/test_sensor.ambr b/tests/components/homee/snapshots/test_sensor.ambr index 3101723232e..b35943630d5 100644 --- a/tests/components/homee/snapshots/test_sensor.ambr +++ b/tests/components/homee/snapshots/test_sensor.ambr @@ -82,8 +82,8 @@ 'platform': 'homee', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'battery', - 'unique_id': '00055511EECC-1-4', + 'translation_key': 'battery_instance', + 'unique_id': '00055511EECC-1-34', 'unit_of_measurement': '%', }) # --- @@ -518,6 +518,57 @@ 'state': '51.0', }) # --- +# name: test_sensor_snapshot[sensor.test_multisensor_illuminance-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_multisensor_illuminance', + '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': 'Illuminance', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'brightness', + 'unique_id': '00055511EECC-1-4', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_illuminance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test MultiSensor Illuminance', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.test_multisensor_illuminance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100.0', + }) +# --- # name: test_sensor_snapshot[sensor.test_multisensor_illuminance_1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/homee/test_sensor.py b/tests/components/homee/test_sensor.py index a2ba991c49b..bbdad4c4469 100644 --- a/tests/components/homee/test_sensor.py +++ b/tests/components/homee/test_sensor.py @@ -11,7 +11,7 @@ from homeassistant.components.homee.const import ( WINDOW_MAP, WINDOW_MAP_REVERSED, ) -from homeassistant.const import LIGHT_LUX, Platform +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -37,7 +37,7 @@ async def test_up_down_values( assert hass.states.get("sensor.test_multisensor_state").state == OPEN_CLOSE_MAP[0] - attribute = mock_homee.nodes[0].attributes[27] + attribute = mock_homee.nodes[0].attributes[28] for i in range(1, 5): await async_update_attribute_value(hass, attribute, i) assert ( @@ -69,7 +69,7 @@ async def test_window_position( == WINDOW_MAP[0] ) - attribute = mock_homee.nodes[0].attributes[32] + attribute = mock_homee.nodes[0].attributes[33] for i in range(1, 3): await async_update_attribute_value(hass, attribute, i) assert ( @@ -87,28 +87,6 @@ async def test_window_position( ) -async def test_brightness_sensor( - hass: HomeAssistant, - mock_homee: MagicMock, - mock_config_entry: MockConfigEntry, -) -> None: - """Test brightness sensor's lx & klx units and naming of multi-instance sensors.""" - mock_homee.nodes = [build_mock_node("sensors.json")] - mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] - await setup_integration(hass, mock_config_entry) - - sensor_state = hass.states.get("sensor.test_multisensor_illuminance_1") - assert sensor_state.state == "175.0" - assert sensor_state.attributes["unit_of_measurement"] == LIGHT_LUX - assert sensor_state.attributes["friendly_name"] == "Test MultiSensor Illuminance 1" - - # Sensor with Homee unit klx - sensor_state = hass.states.get("sensor.test_multisensor_illuminance_2") - assert sensor_state.state == "7000.0" - assert sensor_state.attributes["unit_of_measurement"] == LIGHT_LUX - assert sensor_state.attributes["friendly_name"] == "Test MultiSensor Illuminance 2" - - async def test_sensor_snapshot( hass: HomeAssistant, mock_homee: MagicMock, From 50cec420ef736f43cb056589fff0cc160f169eff Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Tue, 4 Mar 2025 11:43:41 +0100 Subject: [PATCH 2151/3148] Upload test results to codecov (#138512) * Upload test results to codecov * Upload tests results in single job --- .github/workflows/ci.yaml | 53 +++++++++++++++++++++++++++++++++++++++ .gitignore | 1 + 2 files changed, 54 insertions(+) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 829888f3fe2..f0b117ab54a 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -962,6 +962,7 @@ jobs: if [[ "${{ needs.info.outputs.skip_coverage }}" != "true" ]]; then cov_params+=(--cov="homeassistant") cov_params+=(--cov-report=xml) + cov_params+=(--junitxml=junit.xml -o junit_family=legacy) fi echo "Test group ${{ matrix.group }}: $(sed -n "${{ matrix.group }},1p" pytest_buckets.txt)" @@ -992,6 +993,12 @@ jobs: name: coverage-${{ matrix.python-version }}-${{ matrix.group }} path: coverage.xml overwrite: true + - name: Upload test results artifact + if: needs.info.outputs.skip_coverage != 'true' + uses: actions/upload-artifact@v4.6.0 + with: + name: test-results-full-${{ matrix.python-version }}-${{ matrix.group }} + path: junit.xml - name: Remove pytest_buckets run: rm pytest_buckets.txt - name: Check dirty @@ -1088,6 +1095,7 @@ jobs: cov_params+=(--cov="homeassistant.components.recorder") cov_params+=(--cov-report=xml) cov_params+=(--cov-report=term-missing) + cov_params+=(--junitxml=junit.xml -o junit_family=legacy) fi python3 -b -X dev -m pytest \ @@ -1122,6 +1130,13 @@ jobs: steps.pytest-partial.outputs.mariadb }} path: coverage.xml overwrite: true + - name: Upload test results artifact + if: needs.info.outputs.skip_coverage != 'true' + uses: actions/upload-artifact@v4.6.0 + with: + name: test-results-mariadb-${{ matrix.python-version }}-${{ + steps.pytest-partial.outputs.mariadb }} + path: junit.xml - name: Check dirty run: | ./script/check_dirty @@ -1218,6 +1233,7 @@ jobs: cov_params+=(--cov="homeassistant.components.recorder") cov_params+=(--cov-report=xml) cov_params+=(--cov-report=term-missing) + cov_params+=(--junitxml=junit.xml -o junit_family=legacy) fi python3 -b -X dev -m pytest \ @@ -1253,6 +1269,13 @@ jobs: steps.pytest-partial.outputs.postgresql }} path: coverage.xml overwrite: true + - name: Upload test results artifact + if: needs.info.outputs.skip_coverage != 'true' + uses: actions/upload-artifact@v4.6.0 + with: + name: test-results-postgres-${{ matrix.python-version }}-${{ + steps.pytest-partial.outputs.postgresql }} + path: junit.xml - name: Check dirty run: | ./script/check_dirty @@ -1365,6 +1388,7 @@ jobs: cov_params+=(--cov="homeassistant.components.${{ matrix.group }}") cov_params+=(--cov-report=xml) cov_params+=(--cov-report=term-missing) + cov_params+=(--junitxml=junit.xml -o junit_family=legacy) fi python3 -b -X dev -m pytest \ @@ -1394,6 +1418,12 @@ jobs: name: coverage-${{ matrix.python-version }}-${{ matrix.group }} path: coverage.xml overwrite: true + - name: Upload test results artifact + if: needs.info.outputs.skip_coverage != 'true' + uses: actions/upload-artifact@v4.6.0 + with: + name: test-results-partial-${{ matrix.python-version }}-${{ matrix.group }} + path: junit.xml - name: Check dirty run: | ./script/check_dirty @@ -1419,3 +1449,26 @@ jobs: with: fail_ci_if_error: true token: ${{ secrets.CODECOV_TOKEN }} + + upload-test-results: + name: Upload test results to Codecov + if: needs.info.outputs.skip_coverage != 'true' && !cancelled() + runs-on: ubuntu-24.04 + needs: + - info + - pytest-partial + - pytest-full + - pytest-postgres + - pytest-mariadb + timeout-minutes: 10 + steps: + - name: Download all coverage artifacts + uses: actions/download-artifact@v4.1.8 + with: + pattern: test-results-* + - name: Upload test results to Codecov + uses: codecov/test-results-action@v1 + with: + fail_ci_if_error: true + verbose: true + token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.gitignore b/.gitignore index 241255253c5..5aa51c9d762 100644 --- a/.gitignore +++ b/.gitignore @@ -69,6 +69,7 @@ test-reports/ test-results.xml test-output.xml pytest-*.txt +junit.xml # Translations *.mo From c0dc83cbc01de3416265402fb90139cf9582036c Mon Sep 17 00:00:00 2001 From: cs12ag <70966712+cs12ag@users.noreply.github.com> Date: Mon, 3 Mar 2025 13:06:25 +0000 Subject: [PATCH 2152/3148] Fix unique identifiers where multiple IKEA Tradfri gateways are in use (#136060) * Create unique identifiers where multiple gateways are in use Resolving issue https://github.com/home-assistant/core/issues/134497 * Added migration function to __init__.py Added migration function to execute upon initialisation, to: a) remove the erroneously-added config)_entry added to the device (gateway B gets added as a config_entry to a device associated to gateway A), and b) swap out the non-unique identifiers for genuinely unique identifiers. * Added tests to simulate migration from bad data scenario (i.e. explicitly executing migrate_entity_unique_ids() from __init__.py) * Ammendments suggested in first review * Changes after second review * Rewrite of test_migrate_config_entry_and_identifiers after feedback * Converted migrate function into major version, updated tests * Finalised variable naming convention per feedback, added test to validate config entry migrated to v2 * Hopefully final changes for cosmetic / comment stucture * Further code-coverage in test_migrate_config_entry_and_identifiers() * Minor test corrections * Added test for non-tradfri identifiers --- homeassistant/components/tradfri/__init__.py | 94 ++++++++- .../components/tradfri/config_flow.py | 2 +- homeassistant/components/tradfri/entity.py | 2 +- tests/components/tradfri/__init__.py | 2 + tests/components/tradfri/test_init.py | 186 +++++++++++++++++- 5 files changed, 280 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/tradfri/__init__.py b/homeassistant/components/tradfri/__init__.py index 2073829e021..c3e8938b244 100644 --- a/homeassistant/components/tradfri/__init__.py +++ b/homeassistant/components/tradfri/__init__.py @@ -159,7 +159,7 @@ def remove_stale_devices( device_entries = dr.async_entries_for_config_entry( device_registry, config_entry.entry_id ) - all_device_ids = {device.id for device in devices} + all_device_ids = {str(device.id) for device in devices} for device_entry in device_entries: device_id: str | None = None @@ -176,7 +176,7 @@ def remove_stale_devices( gateway_id = _id break - device_id = _id + device_id = _id.replace(f"{config_entry.data[CONF_GATEWAY_ID]}-", "") break if gateway_id is not None: @@ -190,3 +190,93 @@ def remove_stale_devices( device_registry.async_update_device( device_entry.id, remove_config_entry_id=config_entry.entry_id ) + + +async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Migrate old entry.""" + LOGGER.debug( + "Migrating Tradfri configuration from version %s.%s", + config_entry.version, + config_entry.minor_version, + ) + + if config_entry.version > 1: + # This means the user has downgraded from a future version + return False + + if config_entry.version == 1: + # Migrate to version 2 + migrate_config_entry_and_identifiers(hass, config_entry) + + hass.config_entries.async_update_entry(config_entry, version=2) + + LOGGER.debug( + "Migration to Tradfri configuration version %s.%s successful", + config_entry.version, + config_entry.minor_version, + ) + + return True + + +def migrate_config_entry_and_identifiers( + hass: HomeAssistant, config_entry: ConfigEntry +) -> None: + """Migrate old non-unique identifiers to new unique identifiers.""" + + related_device_flag: bool + device_id: str + + device_reg = dr.async_get(hass) + # Get all devices associated to contextual gateway config_entry + # and loop through list of devices. + for device in dr.async_entries_for_config_entry(device_reg, config_entry.entry_id): + related_device_flag = False + for identifier in device.identifiers: + if identifier[0] != DOMAIN: + continue + + related_device_flag = True + + _id = identifier[1] + + # Identify gateway device. + if _id == config_entry.data[CONF_GATEWAY_ID]: + # Using this to avoid updating gateway's own device registry entry + related_device_flag = False + break + + device_id = str(_id) + break + + # Check that device is related to tradfri domain (and is not the gateway itself) + if not related_device_flag: + continue + + # Loop through list of config_entry_ids for device + config_entry_ids = device.config_entries + for config_entry_id in config_entry_ids: + # Check that the config entry in list is not the device's primary config entry + if config_entry_id == device.primary_config_entry: + continue + + # Check that the 'other' config entry is also a tradfri config entry + other_entry = hass.config_entries.async_get_entry(config_entry_id) + + if other_entry is None or other_entry.domain != DOMAIN: + continue + + # Remove non-primary 'tradfri' config entry from device's config_entry_ids + device_reg.async_update_device( + device.id, remove_config_entry_id=config_entry_id + ) + + if config_entry.data[CONF_GATEWAY_ID] in device_id: + continue + + device_reg.async_update_device( + device.id, + new_identifiers={ + (DOMAIN, f"{config_entry.data[CONF_GATEWAY_ID]}-{device_id}") + }, + ) diff --git a/homeassistant/components/tradfri/config_flow.py b/homeassistant/components/tradfri/config_flow.py index 29d876346a7..9f5b39a9657 100644 --- a/homeassistant/components/tradfri/config_flow.py +++ b/homeassistant/components/tradfri/config_flow.py @@ -35,7 +35,7 @@ class AuthError(Exception): class FlowHandler(ConfigFlow, domain=DOMAIN): """Handle a config flow.""" - VERSION = 1 + VERSION = 2 def __init__(self) -> None: """Initialize flow.""" diff --git a/homeassistant/components/tradfri/entity.py b/homeassistant/components/tradfri/entity.py index b06d0081477..41c20b19de5 100644 --- a/homeassistant/components/tradfri/entity.py +++ b/homeassistant/components/tradfri/entity.py @@ -58,7 +58,7 @@ class TradfriBaseEntity(CoordinatorEntity[TradfriDeviceDataUpdateCoordinator]): info = self._device.device_info self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, self._device_id)}, + identifiers={(DOMAIN, f"{gateway_id}-{self._device_id}")}, manufacturer=info.manufacturer, model=info.model_number, name=self._device.name, diff --git a/tests/components/tradfri/__init__.py b/tests/components/tradfri/__init__.py index 37792ae7e32..f73d887d16c 100644 --- a/tests/components/tradfri/__init__.py +++ b/tests/components/tradfri/__init__.py @@ -1,4 +1,6 @@ """Tests for the tradfri component.""" GATEWAY_ID = "mock-gateway-id" +GATEWAY_ID1 = "mockgatewayid1" +GATEWAY_ID2 = "mockgatewayid2" TRADFRI_PATH = "homeassistant.components.tradfri" diff --git a/tests/components/tradfri/test_init.py b/tests/components/tradfri/test_init.py index 54ce469f3c5..a1a4b8d9627 100644 --- a/tests/components/tradfri/test_init.py +++ b/tests/components/tradfri/test_init.py @@ -2,13 +2,19 @@ from unittest.mock import MagicMock +from pytradfri.const import ATTR_FIRMWARE_VERSION, ATTR_GATEWAY_ID +from pytradfri.gateway import Gateway + from homeassistant.components import tradfri +from homeassistant.components.tradfri.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr +from homeassistant.setup import async_setup_component -from . import GATEWAY_ID +from . import GATEWAY_ID, GATEWAY_ID1, GATEWAY_ID2 +from .common import CommandStore -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, load_json_object_fixture async def test_entry_setup_unload( @@ -66,6 +72,7 @@ async def test_remove_stale_devices( device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, identifiers={(tradfri.DOMAIN, "stale_device_id")}, + name="stale-device", ) device_entries = dr.async_entries_for_config_entry( device_registry, config_entry.entry_id @@ -91,3 +98,178 @@ async def test_remove_stale_devices( assert device_entry.manufacturer == "IKEA of Sweden" assert device_entry.name == "Gateway" assert device_entry.model == "E1526" + + +async def test_migrate_config_entry_and_identifiers( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + command_store: CommandStore, +) -> None: + """Test correction of device registry entries.""" + config_entry1 = MockConfigEntry( + domain=tradfri.DOMAIN, + data={ + tradfri.CONF_HOST: "mock-host1", + tradfri.CONF_IDENTITY: "mock-identity1", + tradfri.CONF_KEY: "mock-key1", + tradfri.CONF_GATEWAY_ID: GATEWAY_ID1, + }, + ) + + gateway1 = mock_gateway_fixture(command_store, GATEWAY_ID1) + command_store.register_device( + gateway1, load_json_object_fixture("bulb_w.json", DOMAIN) + ) + config_entry1.add_to_hass(hass) + + config_entry2 = MockConfigEntry( + domain=tradfri.DOMAIN, + data={ + tradfri.CONF_HOST: "mock-host2", + tradfri.CONF_IDENTITY: "mock-identity2", + tradfri.CONF_KEY: "mock-key2", + tradfri.CONF_GATEWAY_ID: GATEWAY_ID2, + }, + ) + + config_entry2.add_to_hass(hass) + + # Add non-tradfri config entry for use in testing negation logic + config_entry3 = MockConfigEntry( + domain="test_domain", + ) + + config_entry3.add_to_hass(hass) + + # Create gateway device for config entry 1 + gateway1_device = device_registry.async_get_or_create( + config_entry_id=config_entry1.entry_id, + identifiers={(config_entry1.domain, config_entry1.data["gateway_id"])}, + name="Gateway", + ) + + # Create bulb 1 on gateway 1 in Device Registry - this has the old identifiers format + gateway1_bulb1 = device_registry.async_get_or_create( + config_entry_id=config_entry1.entry_id, + identifiers={(tradfri.DOMAIN, 65537)}, + name="bulb1", + ) + + # Update bulb 1 device to have both config entry IDs + # This is to simulate existing data scenario with older version of tradfri component + device_registry.async_update_device( + gateway1_bulb1.id, + add_config_entry_id=config_entry2.entry_id, + ) + + # Create bulb 2 on gateway 1 in Device Registry - this has the new identifiers format + gateway1_bulb2 = device_registry.async_get_or_create( + config_entry_id=config_entry1.entry_id, + identifiers={(tradfri.DOMAIN, f"{GATEWAY_ID1}-65538")}, + name="bulb2", + ) + + # Update bulb 2 device to have an additional config entry from config_entry3 + # This is to simulate scenario whereby a device entry + # is shared by multiple config entries + # and where at least one of those config entries is not the 'tradfri' domain + device_registry.async_update_device( + gateway1_bulb2.id, + add_config_entry_id=config_entry3.entry_id, + merge_identifiers={("test_domain", "config_entry_3-device2")}, + ) + + # Create a device on config entry 3 in Device Registry + config_entry3_device = device_registry.async_get_or_create( + config_entry_id=config_entry3.entry_id, + identifiers={("test_domain", "config_entry_3-device1")}, + name="device", + ) + + # Set up all tradfri config entries. + await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + # Validate that gateway 1 bulb 1 is still the same device entry + # This inherently also validates that the device's identifiers + # have been updated to the new unique format + device_entries = dr.async_entries_for_config_entry( + device_registry, config_entry1.entry_id + ) + assert ( + device_registry.async_get_device( + identifiers={(tradfri.DOMAIN, f"{GATEWAY_ID1}-65537")} + ).id + == gateway1_bulb1.id + ) + + # Validate that gateway 1 bulb 1 only has gateway 1's config ID associated to it + # (Device at index 0 is the gateway) + assert device_entries[1].config_entries == {config_entry1.entry_id} + + # Validate that the gateway 1 device is unchanged + assert device_entries[0].id == gateway1_device.id + assert device_entries[0].identifiers == gateway1_device.identifiers + assert device_entries[0].config_entries == gateway1_device.config_entries + + # Validate that gateway 1 bulb 2 now only exists associated to config entry 3. + # The device will have had its identifiers updated to the new format (for the tradfri + # domain) per migrate_config_entry_and_identifiers(). + # The device will have then been removed from config entry 1 (gateway1) + # due to it not matching a device in the command store. + device_entry = device_registry.async_get_device( + identifiers={(tradfri.DOMAIN, f"{GATEWAY_ID1}-65538")} + ) + + assert device_entry.id == gateway1_bulb2.id + # Assert that the only config entry associated to this device is config entry 3 + assert device_entry.config_entries == {config_entry3.entry_id} + # Assert that that device's other identifiers remain untouched + assert device_entry.identifiers == { + (tradfri.DOMAIN, f"{GATEWAY_ID1}-65538"), + ("test_domain", "config_entry_3-device2"), + } + + # Validate that gateway 2 bulb 1 has been added to device registry and with correct unique identifiers + # (This bulb device exists on gateway 2 because the command_store created above will be executed + # for each gateway being set up.) + device_entries = dr.async_entries_for_config_entry( + device_registry, config_entry2.entry_id + ) + assert len(device_entries) == 2 + assert device_entries[1].identifiers == {(tradfri.DOMAIN, f"{GATEWAY_ID2}-65537")} + + # Validate that gateway 2 bulb 1 only has gateway 2's config ID associated to it + assert device_entries[1].config_entries == {config_entry2.entry_id} + + # Validate that config entry 3 device 1 is still present, + # and has not had its config entries or identifiers changed + # N.B. The gateway1_bulb2 device will qualify in this set + # because the config entry 3 was added to it above + device_entries = dr.async_entries_for_config_entry( + device_registry, config_entry3.entry_id + ) + assert len(device_entries) == 2 + assert device_entries[0].id == config_entry3_device.id + assert device_entries[0].identifiers == {("test_domain", "config_entry_3-device1")} + assert device_entries[0].config_entries == {config_entry3.entry_id} + + # Assert that the tradfri config entries have been migrated to v2 and + # the non-tradfri config entry remains at v1 + assert config_entry1.version == 2 + assert config_entry2.version == 2 + assert config_entry3.version == 1 + + +def mock_gateway_fixture(command_store: CommandStore, gateway_id: str) -> Gateway: + """Mock a Tradfri gateway.""" + gateway = Gateway() + command_store.register_response( + gateway.get_gateway_info(), + {ATTR_GATEWAY_ID: gateway_id, ATTR_FIRMWARE_VERSION: "1.2.1234"}, + ) + command_store.register_response( + gateway.get_devices(), + [], + ) + return gateway From 50aefc365335d03ef2451823cef4bacdc3f3d7fd Mon Sep 17 00:00:00 2001 From: Niklas Neesen Date: Sun, 2 Mar 2025 20:57:13 +0100 Subject: [PATCH 2153/3148] Fix vicare exception for specific ventilation device type (#138343) * fix for exception for specific ventilation device type + tests * fix for exception for specific ventilation device type + tests * New Testset just for fan * update test_sensor.ambr --- homeassistant/components/vicare/fan.py | 10 +- .../fixtures/Vitocal222G_Vitovent300W.json | 3019 +++++++++++++++++ .../components/vicare/snapshots/test_fan.ambr | 126 + tests/components/vicare/test_climate.py | 4 +- tests/components/vicare/test_fan.py | 1 + 5 files changed, 3157 insertions(+), 3 deletions(-) create mode 100644 tests/components/vicare/fixtures/Vitocal222G_Vitovent300W.json diff --git a/homeassistant/components/vicare/fan.py b/homeassistant/components/vicare/fan.py index 26136260a4b..d84b2038dde 100644 --- a/homeassistant/components/vicare/fan.py +++ b/homeassistant/components/vicare/fan.py @@ -196,7 +196,10 @@ class ViCareFan(ViCareEntity, FanEntity): @property def is_on(self) -> bool | None: """Return true if the entity is on.""" - if self._api.getVentilationQuickmode(VentilationQuickmode.STANDBY): + if ( + self._attr_supported_features & FanEntityFeature.TURN_OFF + and self._api.getVentilationQuickmode(VentilationQuickmode.STANDBY) + ): return False return self.percentage is not None and self.percentage > 0 @@ -209,7 +212,10 @@ class ViCareFan(ViCareEntity, FanEntity): @property def icon(self) -> str | None: """Return the icon to use in the frontend.""" - if self._api.getVentilationQuickmode(VentilationQuickmode.STANDBY): + if ( + self._attr_supported_features & FanEntityFeature.TURN_OFF + and self._api.getVentilationQuickmode(VentilationQuickmode.STANDBY) + ): return "mdi:fan-off" if hasattr(self, "_attr_preset_mode"): if self._attr_preset_mode == VentilationMode.VENTILATION: diff --git a/tests/components/vicare/fixtures/Vitocal222G_Vitovent300W.json b/tests/components/vicare/fixtures/Vitocal222G_Vitovent300W.json new file mode 100644 index 00000000000..a733d33a12a --- /dev/null +++ b/tests/components/vicare/fixtures/Vitocal222G_Vitovent300W.json @@ -0,0 +1,3019 @@ +{ + "data": [ + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "device.messages.errors.raw", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "entries": { + "type": "array", + "value": [] + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/device.messages.errors.raw" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "device.serial", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "string", + "value": "################" + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/device.serial" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.boiler.sensors.temperature.commonSupply", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "notConnected" + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.boiler.sensors.temperature.commonSupply" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.boiler.serial", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "string", + "value": "################" + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.boiler.serial" + }, + { + "apiVersion": 1, + "commands": {}, + "deprecated": { + "info": "replaced by heating.bufferCylinder.sensors.temperature.main", + "removalDate": "2024-09-15" + }, + "deviceId": "0", + "feature": "heating.buffer.sensors.temperature.main", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "notConnected" + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.buffer.sensors.temperature.main" + }, + { + "apiVersion": 1, + "commands": {}, + "deprecated": { + "info": "replaced by heating.bufferCylinder.sensors.temperature.top", + "removalDate": "2024-09-15" + }, + "deviceId": "0", + "feature": "heating.buffer.sensors.temperature.top", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "notConnected" + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.buffer.sensors.temperature.top" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.bufferCylinder.sensors.temperature.main", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "notConnected" + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.bufferCylinder.sensors.temperature.main" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.bufferCylinder.sensors.temperature.top", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "notConnected" + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.bufferCylinder.sensors.temperature.top" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "enabled": { + "type": "array", + "value": ["0"] + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.circulation.pump", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "on" + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.circulation.pump" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.circulation.pump", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.circulation.pump" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.circulation.pump", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.circulation.pump" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.frostprotection", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "on" + } + }, + "timestamp": "2025-02-11T20:58:18.395Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.frostprotection" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.frostprotection", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.frostprotection" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.frostprotection", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.frostprotection" + }, + { + "apiVersion": 1, + "commands": { + "setCurve": { + "isExecutable": true, + "name": "setCurve", + "params": { + "shift": { + "constraints": { + "max": 40, + "min": -15, + "stepping": 1 + }, + "required": true, + "type": "number" + }, + "slope": { + "constraints": { + "max": 3.5, + "min": 0, + "stepping": 0.1 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.heating.curve/commands/setCurve" + } + }, + "deviceId": "0", + "feature": "heating.circuits.0.heating.curve", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "shift": { + "type": "number", + "unit": "", + "value": 0 + }, + "slope": { + "type": "number", + "unit": "", + "value": 0.4 + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.heating.curve" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.heating.curve", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.heating.curve" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.heating.curve", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.heating.curve" + }, + { + "apiVersion": 1, + "commands": { + "resetSchedule": { + "isExecutable": false, + "name": "resetSchedule", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.heating.schedule/commands/resetSchedule" + }, + "setSchedule": { + "isExecutable": true, + "name": "setSchedule", + "params": { + "newSchedule": { + "constraints": { + "defaultMode": "standby", + "maxEntries": 8, + "modes": ["reduced", "normal", "fixed"], + "overlapAllowed": true, + "resolution": 10 + }, + "required": true, + "type": "Schedule" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.heating.schedule/commands/setSchedule" + } + }, + "deviceId": "0", + "feature": "heating.circuits.0.heating.schedule", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": true + }, + "entries": { + "type": "Schedule", + "value": { + "fri": [ + { + "end": "24:00", + "mode": "normal", + "position": 0, + "start": "00:00" + } + ], + "mon": [ + { + "end": "24:00", + "mode": "normal", + "position": 0, + "start": "00:00" + } + ], + "sat": [ + { + "end": "24:00", + "mode": "normal", + "position": 0, + "start": "00:00" + } + ], + "sun": [ + { + "end": "24:00", + "mode": "normal", + "position": 0, + "start": "00:00" + } + ], + "thu": [ + { + "end": "24:00", + "mode": "normal", + "position": 0, + "start": "00:00" + } + ], + "tue": [ + { + "end": "24:00", + "mode": "normal", + "position": 0, + "start": "00:00" + } + ], + "wed": [ + { + "end": "24:00", + "mode": "normal", + "position": 0, + "start": "00:00" + } + ] + } + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.heating.schedule" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.heating.schedule", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.heating.schedule" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.heating.schedule", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.heating.schedule" + }, + { + "apiVersion": 1, + "commands": { + "setMode": { + "isExecutable": true, + "name": "setMode", + "params": { + "mode": { + "constraints": { + "enum": ["dhw", "dhwAndHeating", "standby"] + }, + "required": true, + "type": "string" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.modes.active/commands/setMode" + } + }, + "deviceId": "0", + "feature": "heating.circuits.0.operating.modes.active", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "string", + "value": "dhwAndHeating" + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.modes.active" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.modes.active", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.modes.active" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.modes.active", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.modes.active" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.modes.cooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.modes.cooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.modes.cooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.modes.cooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.modes.cooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.modes.cooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.modes.dhw", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.modes.dhw" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.modes.dhw", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.modes.dhw" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.modes.dhw", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.modes.dhw" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.modes.dhwAndHeating", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": true + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.modes.dhwAndHeating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.modes.dhwAndHeating", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.modes.dhwAndHeating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.modes.dhwAndHeating", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.modes.dhwAndHeating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.modes.dhwAndHeatingCooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.modes.dhwAndHeatingCooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.modes.dhwAndHeatingCooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.modes.dhwAndHeatingCooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.modes.dhwAndHeatingCooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.modes.dhwAndHeatingCooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.modes.forcedNormal", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.modes.forcedNormal" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.modes.forcedNormal", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.modes.forcedNormal" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.modes.forcedNormal", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.modes.forcedNormal" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.modes.forcedReduced", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.modes.forcedReduced" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.modes.forcedReduced", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.modes.forcedReduced" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.modes.forcedReduced", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.modes.forcedReduced" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.modes.heating", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.modes.heating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.modes.heating", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.modes.heating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.modes.heating", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.modes.heating" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.modes.heatingCooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.modes.heatingCooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.modes.heatingCooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.modes.heatingCooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.modes.heatingCooling", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.modes.heatingCooling" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.modes.normalStandby", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.modes.normalStandby" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.modes.normalStandby", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.modes.normalStandby" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.modes.normalStandby", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.modes.normalStandby" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.modes.standby", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.modes.standby" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.modes.standby", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.modes.standby" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.modes.standby", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.modes.standby" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.active", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "string", + "value": "normal" + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.active" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.active", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.active" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.active", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.active" + }, + { + "apiVersion": 1, + "commands": { + "activate": { + "isExecutable": true, + "name": "activate", + "params": { + "temperature": { + "constraints": { + "max": 30, + "min": 10, + "stepping": 1 + }, + "required": false, + "type": "number" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.comfort/commands/activate" + }, + "deactivate": { + "isExecutable": true, + "name": "deactivate", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.comfort/commands/deactivate" + }, + "setTemperature": { + "isExecutable": true, + "name": "setTemperature", + "params": { + "targetTemperature": { + "constraints": { + "max": 30, + "min": 10, + "stepping": 1 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.comfort/commands/setTemperature" + } + }, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.comfort", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + }, + "demand": { + "type": "string", + "value": "unknown" + }, + "temperature": { + "type": "number", + "unit": "celsius", + "value": 20 + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.comfort" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.comfort", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.comfort" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.comfort", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.comfort" + }, + { + "apiVersion": 1, + "commands": { + "activate": { + "isExecutable": true, + "name": "activate", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.eco/commands/activate" + }, + "deactivate": { + "isExecutable": true, + "name": "deactivate", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.eco/commands/deactivate" + } + }, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.eco", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + }, + "temperature": { + "type": "number", + "unit": "celsius", + "value": 22 + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.eco" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.eco", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.eco" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.eco", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.eco" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.fixed", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.fixed" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.fixed", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.fixed" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.fixed", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.fixed" + }, + { + "apiVersion": 1, + "commands": { + "setTemperature": { + "isExecutable": true, + "name": "setTemperature", + "params": { + "targetTemperature": { + "constraints": { + "max": 30, + "min": 10, + "stepping": 1 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.normal/commands/setTemperature" + } + }, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.normal", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": true + }, + "demand": { + "type": "string", + "value": "unknown" + }, + "temperature": { + "type": "number", + "unit": "celsius", + "value": 22 + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.normal" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.normal", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.normal" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.normal", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.normal" + }, + { + "apiVersion": 1, + "commands": { + "setTemperature": { + "isExecutable": true, + "name": "setTemperature", + "params": { + "targetTemperature": { + "constraints": { + "max": 30, + "min": 10, + "stepping": 1 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.reduced/commands/setTemperature" + } + }, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.reduced", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + }, + "demand": { + "type": "string", + "value": "unknown" + }, + "temperature": { + "type": "number", + "unit": "celsius", + "value": 20 + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.reduced" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.reduced", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.reduced" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.reduced", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.reduced" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.operating.programs.standby", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.standby" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.operating.programs.standby", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.standby" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.operating.programs.standby", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.standby" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.sensors.temperature.room", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.sensors.temperature.room" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.sensors.temperature.room", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.sensors.temperature.room" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.sensors.temperature.room", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.sensors.temperature.room" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.sensors.temperature.supply", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 26.3 + } + }, + "timestamp": "2025-02-11T20:49:01.456Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.sensors.temperature.supply" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.sensors.temperature.supply", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.sensors.temperature.supply" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.sensors.temperature.supply", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.sensors.temperature.supply" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.0.temperature", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "number", + "unit": "celsius", + "value": 33.2 + } + }, + "timestamp": "2025-02-11T19:48:05.380Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.temperature" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.temperature", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.temperature" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.temperature", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.temperature" + }, + { + "apiVersion": 1, + "commands": { + "setLevels": { + "isExecutable": true, + "name": "setLevels", + "params": { + "maxTemperature": { + "constraints": { + "max": 70, + "min": 10, + "stepping": 1 + }, + "required": true, + "type": "number" + }, + "minTemperature": { + "constraints": { + "max": 30, + "min": 1, + "stepping": 1 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.temperature.levels/commands/setLevels" + }, + "setMax": { + "isExecutable": true, + "name": "setMax", + "params": { + "temperature": { + "constraints": { + "max": 70, + "min": 10, + "stepping": 1 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.temperature.levels/commands/setMax" + }, + "setMin": { + "isExecutable": true, + "name": "setMin", + "params": { + "temperature": { + "constraints": { + "max": 30, + "min": 1, + "stepping": 1 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.temperature.levels/commands/setMin" + } + }, + "deviceId": "0", + "feature": "heating.circuits.0.temperature.levels", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "max": { + "type": "number", + "unit": "celsius", + "value": 44 + }, + "min": { + "type": "number", + "unit": "celsius", + "value": 15 + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.temperature.levels" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1.temperature.levels", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.temperature.levels" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2.temperature.levels", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.temperature.levels" + }, + { + "apiVersion": 1, + "commands": { + "setName": { + "isExecutable": true, + "name": "setName", + "params": { + "name": { + "constraints": { + "maxLength": 20, + "minLength": 1 + }, + "required": true, + "type": "string" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0/commands/setName" + } + }, + "deviceId": "0", + "feature": "heating.circuits.0", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": true + }, + "name": { + "type": "string", + "value": "" + }, + "type": { + "type": "string", + "value": "heatingCircuit" + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.1", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.circuits.2", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.compressors", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "enabled": { + "type": "array", + "value": ["0"] + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.compressors" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.compressors.0.statistics", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "hours": { + "type": "number", + "unit": "hour", + "value": 4332.4 + }, + "starts": { + "type": "number", + "unit": "", + "value": 21314 + } + }, + "timestamp": "2025-02-11T20:34:55.482Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.compressors.0.statistics" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.compressors.1.statistics", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.compressors.1.statistics" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.compressors.0", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + }, + "phase": { + "type": "string", + "value": "off" + } + }, + "timestamp": "2025-02-11T20:45:56.068Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.compressors.0" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.compressors.1", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.compressors.1" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.controller.serial", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "string", + "value": "################" + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.controller.serial" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": true + }, + "status": { + "type": "string", + "value": "on" + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.charging", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-02-11T19:42:36.300Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.charging" + }, + { + "apiVersion": 1, + "commands": { + "activate": { + "isExecutable": true, + "name": "activate", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.oneTimeCharge/commands/activate" + }, + "deactivate": { + "isExecutable": true, + "name": "deactivate", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.oneTimeCharge/commands/deactivate" + } + }, + "deviceId": "0", + "feature": "heating.dhw.oneTimeCharge", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.oneTimeCharge" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.pumps.circulation", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "on" + } + }, + "timestamp": "2025-02-11T19:42:36.300Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.pumps.circulation" + }, + { + "apiVersion": 1, + "commands": { + "resetSchedule": { + "isExecutable": false, + "name": "resetSchedule", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.pumps.circulation.schedule/commands/resetSchedule" + }, + "setSchedule": { + "isExecutable": true, + "name": "setSchedule", + "params": { + "newSchedule": { + "constraints": { + "defaultMode": "off", + "maxEntries": 8, + "modes": ["5/25-cycles", "5/10-cycles", "on"], + "overlapAllowed": true, + "resolution": 10 + }, + "required": true, + "type": "Schedule" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.pumps.circulation.schedule/commands/setSchedule" + } + }, + "deviceId": "0", + "feature": "heating.dhw.pumps.circulation.schedule", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": true + }, + "entries": { + "type": "Schedule", + "value": { + "fri": [ + { + "end": "22:00", + "mode": "on", + "position": 0, + "start": "06:50" + } + ], + "mon": [ + { + "end": "22:00", + "mode": "on", + "position": 0, + "start": "06:50" + } + ], + "sat": [ + { + "end": "22:00", + "mode": "on", + "position": 0, + "start": "07:30" + } + ], + "sun": [ + { + "end": "22:00", + "mode": "on", + "position": 0, + "start": "07:30" + } + ], + "thu": [ + { + "end": "22:00", + "mode": "on", + "position": 0, + "start": "06:50" + } + ], + "tue": [ + { + "end": "22:00", + "mode": "on", + "position": 0, + "start": "06:50" + } + ], + "wed": [ + { + "end": "22:00", + "mode": "on", + "position": 0, + "start": "06:50" + } + ] + } + } + }, + "timestamp": "2025-02-11T17:50:12.565Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.pumps.circulation.schedule" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.pumps.primary", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.pumps.primary" + }, + { + "apiVersion": 1, + "commands": { + "resetSchedule": { + "isExecutable": false, + "name": "resetSchedule", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.schedule/commands/resetSchedule" + }, + "setSchedule": { + "isExecutable": true, + "name": "setSchedule", + "params": { + "newSchedule": { + "constraints": { + "defaultMode": "off", + "maxEntries": 8, + "modes": ["top", "normal", "temp-2"], + "overlapAllowed": true, + "resolution": 10 + }, + "required": true, + "type": "Schedule" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.schedule/commands/setSchedule" + } + }, + "deviceId": "0", + "feature": "heating.dhw.schedule", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": true + }, + "entries": { + "type": "Schedule", + "value": { + "fri": [ + { + "end": "22:00", + "mode": "normal", + "position": 0, + "start": "06:00" + } + ], + "mon": [ + { + "end": "22:00", + "mode": "normal", + "position": 0, + "start": "06:00" + } + ], + "sat": [ + { + "end": "22:00", + "mode": "normal", + "position": 0, + "start": "06:00" + } + ], + "sun": [ + { + "end": "22:00", + "mode": "normal", + "position": 0, + "start": "06:00" + } + ], + "thu": [ + { + "end": "22:00", + "mode": "normal", + "position": 0, + "start": "06:00" + } + ], + "tue": [ + { + "end": "22:00", + "mode": "normal", + "position": 0, + "start": "06:00" + } + ], + "wed": [ + { + "end": "22:00", + "mode": "normal", + "position": 0, + "start": "06:00" + } + ] + } + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.schedule" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.sensors.temperature.dhwCylinder", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 47.9 + } + }, + "timestamp": "2025-02-11T20:39:18.305Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.sensors.temperature.dhwCylinder" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.sensors.temperature.dhwCylinder.bottom", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "notConnected" + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.sensors.temperature.dhwCylinder.bottom" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.sensors.temperature.dhwCylinder.top", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 47.9 + } + }, + "timestamp": "2025-02-11T20:39:18.305Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.sensors.temperature.dhwCylinder.top" + }, + { + "apiVersion": 1, + "commands": {}, + "deprecated": { + "info": "replaced by heating.dhw.sensors.temperature.dhwCylinder", + "removalDate": "2024-09-15" + }, + "deviceId": "0", + "feature": "heating.dhw.sensors.temperature.hotWaterStorage", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 47.9 + } + }, + "timestamp": "2025-02-11T20:39:18.305Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.sensors.temperature.hotWaterStorage" + }, + { + "apiVersion": 1, + "commands": {}, + "deprecated": { + "info": "replaced by heating.dhw.sensors.temperature.dhwCylinder.bottom", + "removalDate": "2024-09-15" + }, + "deviceId": "0", + "feature": "heating.dhw.sensors.temperature.hotWaterStorage.bottom", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "notConnected" + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.sensors.temperature.hotWaterStorage.bottom" + }, + { + "apiVersion": 1, + "commands": {}, + "deprecated": { + "info": "replaced by heating.dhw.sensors.temperature.dhwCylinder.top", + "removalDate": "2024-09-15" + }, + "deviceId": "0", + "feature": "heating.dhw.sensors.temperature.hotWaterStorage.top", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 47.9 + } + }, + "timestamp": "2025-02-11T20:39:18.305Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.sensors.temperature.hotWaterStorage.top" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.dhw.sensors.temperature.outlet", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "notConnected" + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.sensors.temperature.outlet" + }, + { + "apiVersion": 1, + "commands": { + "setHysteresis": { + "isExecutable": true, + "name": "setHysteresis", + "params": { + "hysteresis": { + "constraints": { + "max": 10, + "min": 1, + "stepping": 0.5 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.temperature.hysteresis/commands/setHysteresis" + }, + "setHysteresisSwitchOffValue": { + "isExecutable": false, + "name": "setHysteresisSwitchOffValue", + "params": { + "hysteresis": { + "constraints": { + "max": 10, + "min": 1, + "stepping": 0.5 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.temperature.hysteresis/commands/setHysteresisSwitchOffValue" + }, + "setHysteresisSwitchOnValue": { + "isExecutable": true, + "name": "setHysteresisSwitchOnValue", + "params": { + "hysteresis": { + "constraints": { + "max": 10, + "min": 1, + "stepping": 0.5 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.temperature.hysteresis/commands/setHysteresisSwitchOnValue" + } + }, + "deviceId": "0", + "feature": "heating.dhw.temperature.hysteresis", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "switchOffValue": { + "type": "number", + "unit": "kelvin", + "value": 5 + }, + "switchOnValue": { + "type": "number", + "unit": "kelvin", + "value": 5 + }, + "value": { + "type": "number", + "unit": "kelvin", + "value": 5 + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.temperature.hysteresis" + }, + { + "apiVersion": 1, + "commands": { + "setTargetTemperature": { + "isExecutable": true, + "name": "setTargetTemperature", + "params": { + "temperature": { + "constraints": { + "efficientLowerBorder": 10, + "efficientUpperBorder": 60, + "max": 60, + "min": 10, + "stepping": 1 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.temperature.main/commands/setTargetTemperature" + } + }, + "deviceId": "0", + "feature": "heating.dhw.temperature.main", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "number", + "unit": "celsius", + "value": 50 + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.temperature.main" + }, + { + "apiVersion": 1, + "commands": { + "setTargetTemperature": { + "isExecutable": true, + "name": "setTargetTemperature", + "params": { + "temperature": { + "constraints": { + "max": 60, + "min": 10, + "stepping": 1 + }, + "required": true, + "type": "number" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.temperature.temp2/commands/setTargetTemperature" + } + }, + "deviceId": "0", + "feature": "heating.dhw.temperature.temp2", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "number", + "unit": "celsius", + "value": 60 + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.dhw.temperature.temp2" + }, + { + "apiVersion": 1, + "commands": { + "changeEndDate": { + "isExecutable": false, + "name": "changeEndDate", + "params": { + "end": { + "constraints": { + "regEx": "^[\\d]{4}-[\\d]{2}-[\\d]{2}$", + "sameDayAllowed": false + }, + "required": true, + "type": "string" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.operating.programs.holiday/commands/changeEndDate" + }, + "schedule": { + "isExecutable": true, + "name": "schedule", + "params": { + "end": { + "constraints": { + "regEx": "^[\\d]{4}-[\\d]{2}-[\\d]{2}$", + "sameDayAllowed": false + }, + "required": true, + "type": "string" + }, + "start": { + "constraints": { + "regEx": "^[\\d]{4}-[\\d]{2}-[\\d]{2}$" + }, + "required": true, + "type": "string" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.operating.programs.holiday/commands/schedule" + }, + "unschedule": { + "isExecutable": true, + "name": "unschedule", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.operating.programs.holiday/commands/unschedule" + } + }, + "deviceId": "0", + "feature": "heating.operating.programs.holiday", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + }, + "end": { + "type": "string", + "value": "" + }, + "start": { + "type": "string", + "value": "" + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.operating.programs.holiday" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.primaryCircuit.sensors.temperature.return", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 6.9 + } + }, + "timestamp": "2025-02-11T20:58:31.054Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.primaryCircuit.sensors.temperature.return" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.primaryCircuit.sensors.temperature.supply", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 5.2 + } + }, + "timestamp": "2025-02-11T20:48:38.307Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.primaryCircuit.sensors.temperature.supply" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.secondaryCircuit.sensors.temperature.supply", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 26.9 + } + }, + "timestamp": "2025-02-11T20:46:37.502Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.secondaryCircuit.sensors.temperature.supply" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.sensors.temperature.outside", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 1.9 + } + }, + "timestamp": "2025-02-11T21:00:13.154Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.sensors.temperature.outside" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.sensors.temperature.return", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 26.5 + } + }, + "timestamp": "2025-02-11T20:48:00.474Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.sensors.temperature.return" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.solar", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.solar" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.solar.power.cumulativeProduced", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.solar.power.cumulativeProduced" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.solar.power.production", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.solar.power.production" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.solar.pumps.circuit", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.solar.pumps.circuit" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.solar.sensors.temperature.collector", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.solar.sensors.temperature.collector" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.solar.sensors.temperature.dhw", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.solar.sensors.temperature.dhw" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "ventilation", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": true + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "ventilation.levels.levelFour", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.levels.levelFour" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "ventilation.levels.levelOne", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.levels.levelOne" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "ventilation.levels.levelThree", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.levels.levelThree" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "ventilation.levels.levelTwo", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.levels.levelTwo" + }, + { + "apiVersion": 1, + "commands": { + "setMode": { + "isExecutable": true, + "name": "setMode", + "params": { + "mode": { + "constraints": { + "enum": ["standby", "standard", "ventilation"] + }, + "required": true, + "type": "string" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.operating.modes.active/commands/setMode" + }, + "setModeContinuousSensorOverride": { + "isExecutable": false, + "name": "setModeContinuousSensorOverride", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.operating.modes.active/commands/setModeContinuousSensorOverride" + } + }, + "deviceId": "0", + "feature": "ventilation.operating.modes.active", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "string", + "value": "ventilation" + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.operating.modes.active" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "ventilation.operating.modes.standard", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.operating.modes.standard" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "ventilation.operating.modes.ventilation", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": true + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.operating.modes.ventilation" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "ventilation.operating.programs.active", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "string", + "value": "levelThree" + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.operating.programs.active" + }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "ventilation.operating.state", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "demand": { + "type": "string", + "value": "ventilation" + }, + "level": { + "type": "string", + "value": "levelThree" + }, + "reason": { + "type": "string", + "value": "schedule" + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.operating.state" + }, + { + "apiVersion": 1, + "commands": { + "activate": { + "isExecutable": true, + "name": "activate", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.quickmodes.comfort/commands/activate" + }, + "deactivate": { + "isExecutable": true, + "name": "deactivate", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.quickmodes.comfort/commands/deactivate" + } + }, + "deviceId": "0", + "feature": "ventilation.quickmodes.comfort", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.quickmodes.comfort" + }, + { + "apiVersion": 1, + "commands": { + "activate": { + "isExecutable": true, + "name": "activate", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.quickmodes.eco/commands/activate" + }, + "deactivate": { + "isExecutable": true, + "name": "deactivate", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.quickmodes.eco/commands/deactivate" + } + }, + "deviceId": "0", + "feature": "ventilation.quickmodes.eco", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.quickmodes.eco" + }, + { + "apiVersion": 1, + "commands": { + "changeEndDate": { + "isExecutable": false, + "name": "changeEndDate", + "params": { + "end": { + "constraints": { + "regEx": "^[\\d]{4}-[\\d]{2}-[\\d]{2}$", + "sameDayAllowed": false + }, + "required": true, + "type": "string" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.quickmodes.holiday/commands/changeEndDate" + }, + "schedule": { + "isExecutable": true, + "name": "schedule", + "params": { + "end": { + "constraints": { + "regEx": "^[\\d]{4}-[\\d]{2}-[\\d]{2}$", + "sameDayAllowed": false + }, + "required": true, + "type": "string" + }, + "start": { + "constraints": { + "regEx": "^[\\d]{4}-[\\d]{2}-[\\d]{2}$" + }, + "required": true, + "type": "string" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.quickmodes.holiday/commands/schedule" + }, + "unschedule": { + "isExecutable": true, + "name": "unschedule", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.quickmodes.holiday/commands/unschedule" + } + }, + "deviceId": "0", + "feature": "ventilation.quickmodes.holiday", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": false + }, + "end": { + "type": "string", + "value": "" + }, + "start": { + "type": "string", + "value": "" + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.quickmodes.holiday" + }, + { + "apiVersion": 1, + "commands": { + "resetSchedule": { + "isExecutable": false, + "name": "resetSchedule", + "params": {}, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.schedule/commands/resetSchedule" + }, + "setSchedule": { + "isExecutable": true, + "name": "setSchedule", + "params": { + "newSchedule": { + "constraints": { + "defaultMode": "levelOne", + "maxEntries": 8, + "modes": ["levelTwo", "levelThree", "levelFour"], + "overlapAllowed": true, + "resolution": 10 + }, + "required": true, + "type": "Schedule" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.schedule/commands/setSchedule" + } + }, + "deviceId": "0", + "feature": "ventilation.schedule", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "active": { + "type": "boolean", + "value": true + }, + "entries": { + "type": "Schedule", + "value": { + "fri": [ + { + "end": "24:00", + "mode": "levelThree", + "position": 0, + "start": "00:00" + } + ], + "mon": [ + { + "end": "24:00", + "mode": "levelThree", + "position": 0, + "start": "00:00" + } + ], + "sat": [ + { + "end": "24:00", + "mode": "levelThree", + "position": 0, + "start": "00:00" + } + ], + "sun": [ + { + "end": "24:00", + "mode": "levelThree", + "position": 0, + "start": "00:00" + } + ], + "thu": [ + { + "end": "24:00", + "mode": "levelThree", + "position": 0, + "start": "00:00" + } + ], + "tue": [ + { + "end": "24:00", + "mode": "levelThree", + "position": 0, + "start": "00:00" + } + ], + "wed": [ + { + "end": "24:00", + "mode": "levelThree", + "position": 0, + "start": "00:00" + } + ] + } + } + }, + "timestamp": "2025-02-10T14:01:48.216Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/ventilation.schedule" + }, + { + "apiVersion": 1, + "commands": { + "setName": { + "isExecutable": true, + "name": "setName", + "params": { + "name": { + "constraints": { + "maxLength": 20, + "minLength": 1 + }, + "required": true, + "type": "string" + } + }, + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.name/commands/setName" + } + }, + "components": [], + "deviceId": "0", + "feature": "heating.circuits.0.name", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "name": { + "type": "string", + "value": "" + } + }, + "timestamp": "2025-01-12T22:36:28.706Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.0.name" + }, + { + "apiVersion": 1, + "commands": {}, + "components": [], + "deviceId": "0", + "feature": "heating.circuits.1.name", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-02-02T01:29:44.670Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.1.name" + }, + { + "apiVersion": 1, + "commands": {}, + "components": [], + "deviceId": "0", + "feature": "heating.circuits.2.name", + "gatewayId": "################", + "isEnabled": false, + "isReady": true, + "properties": {}, + "timestamp": "2024-02-02T01:29:44.670Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/heating.circuits.2.name" + } + ] +} diff --git a/tests/components/vicare/snapshots/test_fan.ambr b/tests/components/vicare/snapshots/test_fan.ambr index 0bac421e2c7..2c9e815f7bf 100644 --- a/tests/components/vicare/snapshots/test_fan.ambr +++ b/tests/components/vicare/snapshots/test_fan.ambr @@ -1,4 +1,69 @@ # serializer version: 1 +# name: test_all_entities[fan.model0_ventilation-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'preset_modes': list([ + , + , + , + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.model0_ventilation', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:fan', + 'original_name': 'Ventilation', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'ventilation', + 'unique_id': 'gateway0_deviceSerialViAir300F-ventilation', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[fan.model0_ventilation-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'model0 Ventilation', + 'icon': 'mdi:fan', + 'percentage': 0, + 'percentage_step': 25.0, + 'preset_mode': None, + 'preset_modes': list([ + , + , + , + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'fan.model0_ventilation', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_all_entities[fan.model1_ventilation-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -62,3 +127,64 @@ 'state': 'off', }) # --- +# name: test_all_entities[fan.model2_ventilation-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'preset_modes': list([ + , + , + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.model2_ventilation', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:fan', + 'original_name': 'Ventilation', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'ventilation', + 'unique_id': 'gateway2_################-ventilation', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[fan.model2_ventilation-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'model2 Ventilation', + 'icon': 'mdi:fan', + 'preset_mode': None, + 'preset_modes': list([ + , + , + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'fan.model2_ventilation', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/vicare/test_climate.py b/tests/components/vicare/test_climate.py index f48a8988cf0..9299f6567b1 100644 --- a/tests/components/vicare/test_climate.py +++ b/tests/components/vicare/test_climate.py @@ -23,7 +23,9 @@ async def test_all_entities( entity_registry: er.EntityRegistry, ) -> None: """Test all entities.""" - fixtures: list[Fixture] = [Fixture({"type:boiler"}, "vicare/Vitodens300W.json")] + fixtures: list[Fixture] = [ + Fixture({"type:boiler"}, "vicare/Vitodens300W.json"), + ] with ( patch(f"{MODULE}.login", return_value=MockPyViCare(fixtures)), patch(f"{MODULE}.PLATFORMS", [Platform.CLIMATE]), diff --git a/tests/components/vicare/test_fan.py b/tests/components/vicare/test_fan.py index 5683f48f01f..8c42c92fb50 100644 --- a/tests/components/vicare/test_fan.py +++ b/tests/components/vicare/test_fan.py @@ -26,6 +26,7 @@ async def test_all_entities( fixtures: list[Fixture] = [ Fixture({"type:ventilation"}, "vicare/ViAir300F.json"), Fixture({"type:ventilation"}, "vicare/VitoPure.json"), + Fixture({"type:heatpump"}, "vicare/Vitocal222G_Vitovent300W.json"), ] with ( patch(f"{MODULE}.login", return_value=MockPyViCare(fixtures)), From 0940fc78069d7657ab8dc858c985f666ffb5ecf8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Tue, 4 Mar 2025 08:52:29 +0000 Subject: [PATCH 2154/3148] Prevent zero interval in Calendar get_events service (#139378) * Prevent zero interval in Calendar get_events service * Fix holiday calendar tests * Remove redundant entity_id * Use translation for exception * Replace check with voluptuous validator * Revert strings.xml --- homeassistant/components/calendar/__init__.py | 23 ++++++++ tests/components/calendar/test_init.py | 53 ++++++++++++++++++- tests/components/holiday/test_calendar.py | 12 ++--- 3 files changed, 81 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/calendar/__init__.py b/homeassistant/components/calendar/__init__.py index 40d6952fa64..96bf717c3ac 100644 --- a/homeassistant/components/calendar/__init__.py +++ b/homeassistant/components/calendar/__init__.py @@ -153,6 +153,27 @@ def _has_min_duration( return validate +def _has_positive_interval( + start_key: str, end_key: str, duration_key: str +) -> Callable[[dict[str, Any]], dict[str, Any]]: + """Verify that the time span between start and end is greater than zero.""" + + def validate(obj: dict[str, Any]) -> dict[str, Any]: + if (duration := obj.get(duration_key)) is not None: + if duration <= datetime.timedelta(seconds=0): + raise vol.Invalid(f"Expected positive duration ({duration})") + return obj + + if (start := obj.get(start_key)) and (end := obj.get(end_key)): + if start >= end: + raise vol.Invalid( + f"Expected end time to be after start time ({start}, {end})" + ) + return obj + + return validate + + def _has_same_type(*keys: Any) -> Callable[[dict[str, Any]], dict[str, Any]]: """Verify that all values are of the same type.""" @@ -281,6 +302,7 @@ SERVICE_GET_EVENTS_SCHEMA: Final = vol.All( ), } ), + _has_positive_interval(EVENT_START_DATETIME, EVENT_END_DATETIME, EVENT_DURATION), ) @@ -870,6 +892,7 @@ async def async_get_events_service( end = start + service_call.data[EVENT_DURATION] else: end = service_call.data[EVENT_END_DATETIME] + calendar_event_list = await calendar.async_get_events( calendar.hass, dt_util.as_local(start), dt_util.as_local(end) ) diff --git a/tests/components/calendar/test_init.py b/tests/components/calendar/test_init.py index 2d712f408c2..6de0a7ef936 100644 --- a/tests/components/calendar/test_init.py +++ b/tests/components/calendar/test_init.py @@ -5,6 +5,7 @@ from __future__ import annotations from collections.abc import Generator from datetime import timedelta from http import HTTPStatus +import re from typing import Any from freezegun import freeze_time @@ -448,7 +449,7 @@ async def test_list_events_service( service: str, expected: dict[str, Any], ) -> None: - """Test listing events from the service call using exlplicit start and end time. + """Test listing events from the service call using explicit start and end time. This test uses a fixed date/time so that it can deterministically test the string output values. @@ -553,3 +554,53 @@ async def test_list_events_missing_fields(hass: HomeAssistant) -> None: blocking=True, return_response=True, ) + + +@pytest.mark.parametrize( + "frozen_time", ["2023-06-22 10:30:00+00:00"], ids=["frozen_time"] +) +@pytest.mark.parametrize( + ("service_data", "error_msg"), + [ + ( + { + "start_date_time": "2023-06-22T04:30:00-06:00", + "end_date_time": "2023-06-22T04:30:00-06:00", + }, + "Expected end time to be after start time (2023-06-22 04:30:00-06:00, 2023-06-22 04:30:00-06:00)", + ), + ( + { + "start_date_time": "2023-06-22T04:30:00", + "end_date_time": "2023-06-22T04:30:00", + }, + "Expected end time to be after start time (2023-06-22 04:30:00, 2023-06-22 04:30:00)", + ), + ( + {"start_date_time": "2023-06-22", "end_date_time": "2023-06-22"}, + "Expected end time to be after start time (2023-06-22 00:00:00, 2023-06-22 00:00:00)", + ), + ( + {"start_date_time": "2023-06-22 10:00:00", "duration": "0"}, + "Expected positive duration (0:00:00)", + ), + ], +) +async def test_list_events_service_same_dates( + hass: HomeAssistant, + service_data: dict[str, str], + error_msg: str, +) -> None: + """Test listing events from the service call using the same start and end time.""" + + with pytest.raises(vol.error.MultipleInvalid, match=re.escape(error_msg)): + await hass.services.async_call( + DOMAIN, + SERVICE_GET_EVENTS, + service_data={ + "entity_id": "calendar.calendar_1", + **service_data, + }, + blocking=True, + return_response=True, + ) diff --git a/tests/components/holiday/test_calendar.py b/tests/components/holiday/test_calendar.py index db58b7b1f73..6733d38442b 100644 --- a/tests/components/holiday/test_calendar.py +++ b/tests/components/holiday/test_calendar.py @@ -49,7 +49,7 @@ async def test_holiday_calendar_entity( SERVICE_GET_EVENTS, { "entity_id": "calendar.united_states_ak", - "end_date_time": dt_util.now(), + "end_date_time": dt_util.now() + timedelta(hours=1), }, blocking=True, return_response=True, @@ -135,7 +135,7 @@ async def test_default_language( SERVICE_GET_EVENTS, { "entity_id": "calendar.france_bl", - "end_date_time": dt_util.now(), + "end_date_time": dt_util.now() + timedelta(hours=1), }, blocking=True, return_response=True, @@ -164,7 +164,7 @@ async def test_default_language( SERVICE_GET_EVENTS, { "entity_id": "calendar.france_bl", - "end_date_time": dt_util.now(), + "end_date_time": dt_util.now() + timedelta(hours=1), }, blocking=True, return_response=True, @@ -211,7 +211,7 @@ async def test_no_language( SERVICE_GET_EVENTS, { "entity_id": "calendar.albania", - "end_date_time": dt_util.now(), + "end_date_time": dt_util.now() + timedelta(hours=1), }, blocking=True, return_response=True, @@ -308,7 +308,7 @@ async def test_language_not_exist( SERVICE_GET_EVENTS, { "entity_id": "calendar.norge", - "end_date_time": dt_util.now(), + "end_date_time": dt_util.now() + timedelta(hours=1), }, blocking=True, return_response=True, @@ -336,7 +336,7 @@ async def test_language_not_exist( SERVICE_GET_EVENTS, { "entity_id": "calendar.norge", - "end_date_time": dt_util.now(), + "end_date_time": dt_util.now() + timedelta(hours=1), }, blocking=True, return_response=True, From b816625028df4b2b17d3816fd095d617341252a8 Mon Sep 17 00:00:00 2001 From: Markus Adrario Date: Tue, 4 Mar 2025 11:40:36 +0100 Subject: [PATCH 2155/3148] Fix Homee brightness sensors reporting in percent (#139409) * fix brigtness sensor having percent as unit. * add test for percent-brightness-sensor * remove valve position and update tests * Removed test, because covered by Snapshots * fix review comments * move device calss to init. * fix test * fix review comments * add battery sensor back to test fixture * fix --- homeassistant/components/homee/icons.json | 6 ++ homeassistant/components/homee/sensor.py | 16 ++++++ homeassistant/components/homee/strings.json | 3 + tests/components/homee/fixtures/sensors.json | 23 +++++++- .../homee/snapshots/test_sensor.ambr | 55 ++++++++++++++++++- tests/components/homee/test_sensor.py | 28 +--------- 6 files changed, 103 insertions(+), 28 deletions(-) diff --git a/homeassistant/components/homee/icons.json b/homeassistant/components/homee/icons.json index 17ac0ecd1f2..b4ad8871568 100644 --- a/homeassistant/components/homee/icons.json +++ b/homeassistant/components/homee/icons.json @@ -1,6 +1,12 @@ { "entity": { "sensor": { + "brightness": { + "default": "mdi:brightness-5" + }, + "brightness_instance": { + "default": "mdi:brightness-5" + }, "link_quality": { "default": "mdi:signal" }, diff --git a/homeassistant/components/homee/sensor.py b/homeassistant/components/homee/sensor.py index 86733aae778..410f87f2168 100644 --- a/homeassistant/components/homee/sensor.py +++ b/homeassistant/components/homee/sensor.py @@ -40,10 +40,22 @@ def get_window_value(attribute: HomeeAttribute) -> str | None: return vals.get(attribute.current_value) +def get_brightness_device_class( + attribute: HomeeAttribute, device_class: SensorDeviceClass | None +) -> SensorDeviceClass | None: + """Return the device class for a brightness sensor.""" + if attribute.unit == "%": + return None + return device_class + + @dataclass(frozen=True, kw_only=True) class HomeeSensorEntityDescription(SensorEntityDescription): """A class that describes Homee sensor entities.""" + device_class_fn: Callable[ + [HomeeAttribute, SensorDeviceClass | None], SensorDeviceClass | None + ] = lambda attribute, device_class: device_class value_fn: Callable[[HomeeAttribute], str | float | None] = ( lambda value: value.current_value ) @@ -67,6 +79,7 @@ SENSOR_DESCRIPTIONS: dict[AttributeType, HomeeSensorEntityDescription] = { AttributeType.BRIGHTNESS: HomeeSensorEntityDescription( key="brightness", device_class=SensorDeviceClass.ILLUMINANCE, + device_class_fn=get_brightness_device_class, state_class=SensorStateClass.MEASUREMENT, value_fn=( lambda attribute: attribute.current_value * 1000 @@ -303,6 +316,9 @@ class HomeeSensor(HomeeEntity, SensorEntity): if attribute.instance > 0: self._attr_translation_key = f"{self._attr_translation_key}_instance" self._attr_translation_placeholders = {"instance": str(attribute.instance)} + self._attr_device_class = description.device_class_fn( + attribute, description.device_class + ) @property def native_value(self) -> float | str | None: diff --git a/homeassistant/components/homee/strings.json b/homeassistant/components/homee/strings.json index cf5b90dbe2a..94f85824280 100644 --- a/homeassistant/components/homee/strings.json +++ b/homeassistant/components/homee/strings.json @@ -111,6 +111,9 @@ } }, "sensor": { + "brightness": { + "name": "Illuminance" + }, "brightness_instance": { "name": "Illuminance {instance}" }, diff --git a/tests/components/homee/fixtures/sensors.json b/tests/components/homee/fixtures/sensors.json index f4a7f462218..bcc36a85ee7 100644 --- a/tests/components/homee/fixtures/sensors.json +++ b/tests/components/homee/fixtures/sensors.json @@ -81,6 +81,27 @@ "data": "", "name": "" }, + { + "id": 34, + "node_id": 1, + "instance": 2, + "minimum": 0, + "maximum": 100, + "current_value": 100.0, + "target_value": 100.0, + "last_value": 100.0, + "unit": "%", + "step_value": 1.0, + "editable": 0, + "type": 8, + "state": 1, + "last_changed": 1709982926, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, { "id": 4, "node_id": 1, @@ -93,7 +114,7 @@ "unit": "%", "step_value": 1.0, "editable": 0, - "type": 8, + "type": 11, "state": 1, "last_changed": 1709982926, "changed_by": 1, diff --git a/tests/components/homee/snapshots/test_sensor.ambr b/tests/components/homee/snapshots/test_sensor.ambr index 3101723232e..b35943630d5 100644 --- a/tests/components/homee/snapshots/test_sensor.ambr +++ b/tests/components/homee/snapshots/test_sensor.ambr @@ -82,8 +82,8 @@ 'platform': 'homee', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'battery', - 'unique_id': '00055511EECC-1-4', + 'translation_key': 'battery_instance', + 'unique_id': '00055511EECC-1-34', 'unit_of_measurement': '%', }) # --- @@ -518,6 +518,57 @@ 'state': '51.0', }) # --- +# name: test_sensor_snapshot[sensor.test_multisensor_illuminance-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_multisensor_illuminance', + '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': 'Illuminance', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'brightness', + 'unique_id': '00055511EECC-1-4', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_illuminance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test MultiSensor Illuminance', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.test_multisensor_illuminance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100.0', + }) +# --- # name: test_sensor_snapshot[sensor.test_multisensor_illuminance_1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/homee/test_sensor.py b/tests/components/homee/test_sensor.py index a2ba991c49b..bbdad4c4469 100644 --- a/tests/components/homee/test_sensor.py +++ b/tests/components/homee/test_sensor.py @@ -11,7 +11,7 @@ from homeassistant.components.homee.const import ( WINDOW_MAP, WINDOW_MAP_REVERSED, ) -from homeassistant.const import LIGHT_LUX, Platform +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -37,7 +37,7 @@ async def test_up_down_values( assert hass.states.get("sensor.test_multisensor_state").state == OPEN_CLOSE_MAP[0] - attribute = mock_homee.nodes[0].attributes[27] + attribute = mock_homee.nodes[0].attributes[28] for i in range(1, 5): await async_update_attribute_value(hass, attribute, i) assert ( @@ -69,7 +69,7 @@ async def test_window_position( == WINDOW_MAP[0] ) - attribute = mock_homee.nodes[0].attributes[32] + attribute = mock_homee.nodes[0].attributes[33] for i in range(1, 3): await async_update_attribute_value(hass, attribute, i) assert ( @@ -87,28 +87,6 @@ async def test_window_position( ) -async def test_brightness_sensor( - hass: HomeAssistant, - mock_homee: MagicMock, - mock_config_entry: MockConfigEntry, -) -> None: - """Test brightness sensor's lx & klx units and naming of multi-instance sensors.""" - mock_homee.nodes = [build_mock_node("sensors.json")] - mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] - await setup_integration(hass, mock_config_entry) - - sensor_state = hass.states.get("sensor.test_multisensor_illuminance_1") - assert sensor_state.state == "175.0" - assert sensor_state.attributes["unit_of_measurement"] == LIGHT_LUX - assert sensor_state.attributes["friendly_name"] == "Test MultiSensor Illuminance 1" - - # Sensor with Homee unit klx - sensor_state = hass.states.get("sensor.test_multisensor_illuminance_2") - assert sensor_state.state == "7000.0" - assert sensor_state.attributes["unit_of_measurement"] == LIGHT_LUX - assert sensor_state.attributes["friendly_name"] == "Test MultiSensor Illuminance 2" - - async def test_sensor_snapshot( hass: HomeAssistant, mock_homee: MagicMock, From 46bcb307f6a33ed636622d76ed6023058e7ee755 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Tue, 4 Mar 2025 09:56:42 +0100 Subject: [PATCH 2156/3148] Fix ability to remove orphan device in Music Assistant integration (#139431) * Fix ability to remove orphan device in Music Assistant integration * Add test * Remove orphaned device entries at startup as well * adjust mocked client --- .../components/music_assistant/__init__.py | 44 +++++++++++- tests/components/music_assistant/conftest.py | 16 +++++ tests/components/music_assistant/test_init.py | 70 +++++++++++++++++++ 3 files changed, 128 insertions(+), 2 deletions(-) create mode 100644 tests/components/music_assistant/test_init.py diff --git a/homeassistant/components/music_assistant/__init__.py b/homeassistant/components/music_assistant/__init__.py index e569bb93a42..a2d2dae9e3f 100644 --- a/homeassistant/components/music_assistant/__init__.py +++ b/homeassistant/components/music_assistant/__init__.py @@ -9,7 +9,7 @@ from typing import TYPE_CHECKING from music_assistant_client import MusicAssistantClient from music_assistant_client.exceptions import CannotConnect, InvalidServerVersion from music_assistant_models.enums import EventType -from music_assistant_models.errors import MusicAssistantError +from music_assistant_models.errors import ActionUnavailable, MusicAssistantError from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import CONF_URL, EVENT_HOMEASSISTANT_STOP, Platform @@ -23,7 +23,7 @@ from homeassistant.helpers.issue_registry import ( async_delete_issue, ) -from .actions import register_actions +from .actions import get_music_assistant_client, register_actions from .const import DOMAIN, LOGGER if TYPE_CHECKING: @@ -137,6 +137,18 @@ async def async_setup_entry( mass.subscribe(handle_player_removed, EventType.PLAYER_REMOVED) ) + # check if any playerconfigs have been removed while we were disconnected + all_player_configs = await mass.config.get_player_configs() + player_ids = {player.player_id for player in all_player_configs} + dev_reg = dr.async_get(hass) + dev_entries = dr.async_entries_for_config_entry(dev_reg, entry.entry_id) + for device in dev_entries: + for identifier in device.identifiers: + if identifier[0] == DOMAIN and identifier[1] not in player_ids: + dev_reg.async_update_device( + device.id, remove_config_entry_id=entry.entry_id + ) + return True @@ -174,3 +186,31 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await mass_entry_data.mass.disconnect() return unload_ok + + +async def async_remove_config_entry_device( + hass: HomeAssistant, config_entry: ConfigEntry, device_entry: dr.DeviceEntry +) -> bool: + """Remove a config entry from a device.""" + player_id = next( + ( + identifier[1] + for identifier in device_entry.identifiers + if identifier[0] == DOMAIN + ), + None, + ) + if player_id is None: + # this should not be possible at all, but guard it anyways + return False + mass = get_music_assistant_client(hass, config_entry.entry_id) + if mass.players.get(player_id) is None: + # player is already removed on the server, this is an orphaned device + return True + # try to remove the player from the server + try: + await mass.config.remove_player_config(player_id) + except ActionUnavailable: + return False + else: + return True diff --git a/tests/components/music_assistant/conftest.py b/tests/components/music_assistant/conftest.py index 2df43defe62..2b397891d6f 100644 --- a/tests/components/music_assistant/conftest.py +++ b/tests/components/music_assistant/conftest.py @@ -8,6 +8,7 @@ from music_assistant_client.music import Music from music_assistant_client.player_queues import PlayerQueues from music_assistant_client.players import Players from music_assistant_models.api import ServerInfoMessage +from music_assistant_models.config_entries import PlayerConfig import pytest from homeassistant.components.music_assistant.config_flow import CONF_URL @@ -68,6 +69,21 @@ async def music_assistant_client_fixture() -> AsyncGenerator[MagicMock]: client.music = Music(client) client.server_url = client.server_info.base_url client.get_media_item_image_url = MagicMock(return_value=None) + client.config = MagicMock() + + async def get_player_configs() -> list[PlayerConfig]: + """Mock get player configs.""" + # simply return a mock config for each player + return [ + PlayerConfig( + values={}, + provider=player.provider, + player_id=player.player_id, + ) + for player in client.players + ] + + client.config.get_player_configs = get_player_configs yield client diff --git a/tests/components/music_assistant/test_init.py b/tests/components/music_assistant/test_init.py new file mode 100644 index 00000000000..4cfefb50bd2 --- /dev/null +++ b/tests/components/music_assistant/test_init.py @@ -0,0 +1,70 @@ +"""Test the Music Assistant integration init.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock + +from music_assistant_models.errors import ActionUnavailable + +from homeassistant.components.music_assistant.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.setup import async_setup_component + +from .common import setup_integration_from_fixtures + +from tests.typing import WebSocketGenerator + + +async def test_remove_config_entry_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + music_assistant_client: MagicMock, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test device removal.""" + assert await async_setup_component(hass, "config", {}) + await setup_integration_from_fixtures(hass, music_assistant_client) + await hass.async_block_till_done() + config_entry = hass.config_entries.async_entries(DOMAIN)[0] + client = await hass_ws_client(hass) + + # test if the removal should be denied if the device is still in use + device_entry = dr.async_entries_for_config_entry( + device_registry, config_entry.entry_id + )[0] + entity_id = "media_player.test_player_1" + assert device_entry + assert entity_registry.async_get(entity_id) + assert hass.states.get(entity_id) + music_assistant_client.config.remove_player_config = AsyncMock( + side_effect=ActionUnavailable + ) + response = await client.remove_device(device_entry.id, config_entry.entry_id) + assert music_assistant_client.config.remove_player_config.call_count == 1 + assert response["success"] is False + + # test if the removal should be allowed if the device is not in use + music_assistant_client.config.remove_player_config = AsyncMock() + response = await client.remove_device(device_entry.id, config_entry.entry_id) + assert response["success"] is True + await hass.async_block_till_done() + assert not device_registry.async_get(device_entry.id) + assert not entity_registry.async_get(entity_id) + assert not hass.states.get(entity_id) + + # test if the removal succeeds if its no longer provided by the server + mass_player_id = "00:00:00:00:00:02" + music_assistant_client.players._players.pop(mass_player_id) + device_entry = dr.async_entries_for_config_entry( + device_registry, config_entry.entry_id + )[0] + entity_id = "media_player.my_super_test_player_2" + assert device_entry + assert entity_registry.async_get(entity_id) + assert hass.states.get(entity_id) + music_assistant_client.config.remove_player_config = AsyncMock() + response = await client.remove_device(device_entry.id, config_entry.entry_id) + assert music_assistant_client.config.remove_player_config.call_count == 0 + assert response["success"] is True From ad04b5361518f1afd980e1d4f2ba1d271e90f88a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 2 Mar 2025 08:50:35 -0700 Subject: [PATCH 2157/3148] Fix broken link in ESPHome BLE repair (#139639) ESPHome always uses .0 in the URL for the changelog, and we never had a patch version in the stable BLE version field so we need to switch it to .0 for the URL. --- homeassistant/components/esphome/const.py | 4 +++- tests/components/esphome/test_manager.py | 3 ++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/esphome/const.py b/homeassistant/components/esphome/const.py index eb5f03c4495..20ff1cd27de 100644 --- a/homeassistant/components/esphome/const.py +++ b/homeassistant/components/esphome/const.py @@ -18,6 +18,8 @@ STABLE_BLE_VERSION = AwesomeVersion(STABLE_BLE_VERSION_STR) PROJECT_URLS = { "esphome.bluetooth-proxy": "https://esphome.github.io/bluetooth-proxies/", } -DEFAULT_URL = f"https://esphome.io/changelog/{STABLE_BLE_VERSION_STR}.html" +# ESPHome always uses .0 for the changelog URL +STABLE_BLE_URL_VERSION = f"{STABLE_BLE_VERSION.major}.{STABLE_BLE_VERSION.minor}.0" +DEFAULT_URL = f"https://esphome.io/changelog/{STABLE_BLE_URL_VERSION}.html" DATA_FFMPEG_PROXY = f"{DOMAIN}.ffmpeg_proxy" diff --git a/tests/components/esphome/test_manager.py b/tests/components/esphome/test_manager.py index b805b065d5a..79653d3bb66 100644 --- a/tests/components/esphome/test_manager.py +++ b/tests/components/esphome/test_manager.py @@ -28,6 +28,7 @@ from homeassistant.components.esphome.const import ( CONF_DEVICE_NAME, CONF_SUBSCRIBE_LOGS, DOMAIN, + STABLE_BLE_URL_VERSION, STABLE_BLE_VERSION_STR, ) from homeassistant.const import ( @@ -365,7 +366,7 @@ async def test_esphome_device_with_old_bluetooth( ) assert ( issue.learn_more_url - == f"https://esphome.io/changelog/{STABLE_BLE_VERSION_STR}.html" + == f"https://esphome.io/changelog/{STABLE_BLE_URL_VERSION}.html" ) From 03cb177e7c31a6f64963dfd660013e9367281a35 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 2 Mar 2025 19:52:37 +0100 Subject: [PATCH 2158/3148] Fix scope comparison in SmartThings (#139652) --- homeassistant/components/smartthings/config_flow.py | 2 +- tests/components/smartthings/test_config_flow.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/smartthings/config_flow.py b/homeassistant/components/smartthings/config_flow.py index 0ad1b5553b1..02b11b190c9 100644 --- a/homeassistant/components/smartthings/config_flow.py +++ b/homeassistant/components/smartthings/config_flow.py @@ -34,7 +34,7 @@ class SmartThingsConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN): async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResult: """Create an entry for SmartThings.""" - if data[CONF_TOKEN]["scope"].split() != SCOPES: + if not set(data[CONF_TOKEN]["scope"].split()) >= set(SCOPES): return self.async_abort(reason="missing_scopes") client = SmartThings(session=async_get_clientsession(self.hass)) client.authenticate(data[CONF_TOKEN][CONF_ACCESS_TOKEN]) diff --git a/tests/components/smartthings/test_config_flow.py b/tests/components/smartthings/test_config_flow.py index 858384db0b6..a16747c1190 100644 --- a/tests/components/smartthings/test_config_flow.py +++ b/tests/components/smartthings/test_config_flow.py @@ -261,7 +261,7 @@ async def test_reauthentication( "expires_in": 82806, "scope": "r:devices:* w:devices:* x:devices:* r:hubs:* " "r:locations:* w:locations:* x:locations:* " - "r:scenes:* x:scenes:* r:rules:* w:rules:* sse", + "r:scenes:* x:scenes:* r:rules:* sse w:rules:*", "access_tier": 0, "installed_app_id": "5aaaa925-2be1-4e40-b257-e4ef59083324", }, @@ -279,7 +279,7 @@ async def test_reauthentication( "expires_in": 82806, "scope": "r:devices:* w:devices:* x:devices:* r:hubs:* " "r:locations:* w:locations:* x:locations:* " - "r:scenes:* x:scenes:* r:rules:* w:rules:* sse", + "r:scenes:* x:scenes:* r:rules:* sse w:rules:*", "access_tier": 0, "installed_app_id": "5aaaa925-2be1-4e40-b257-e4ef59083324", } From dca77e8232dd1f65c7124e7d0fb180b65c47e2ef Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 3 Mar 2025 05:46:40 -0500 Subject: [PATCH 2159/3148] Avoid duplicate chat log content (#139679) --- homeassistant/components/conversation/chat_log.py | 6 +++++- tests/components/conversation/test_chat_log.py | 2 ++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/conversation/chat_log.py b/homeassistant/components/conversation/chat_log.py index 1ee5e9965ab..19482af1983 100644 --- a/homeassistant/components/conversation/chat_log.py +++ b/homeassistant/components/conversation/chat_log.py @@ -49,7 +49,11 @@ def async_get_chat_log( raise RuntimeError( "Cannot attach chat log delta listener unless initial caller" ) - if user_input is not None: + if user_input is not None and ( + (content := chat_log.content[-1]).role != "user" + # MyPy doesn't understand that content is a UserContent here + or content.content != user_input.text # type: ignore[union-attr] + ): chat_log.async_add_user_content(UserContent(content=user_input.text)) yield chat_log diff --git a/tests/components/conversation/test_chat_log.py b/tests/components/conversation/test_chat_log.py index a4dc9b819c1..c0687ebecfb 100644 --- a/tests/components/conversation/test_chat_log.py +++ b/tests/components/conversation/test_chat_log.py @@ -86,7 +86,9 @@ async def test_default_content( with ( chat_session.async_get_chat_session(hass) as session, async_get_chat_log(hass, session, mock_conversation_input) as chat_log, + async_get_chat_log(hass, session, mock_conversation_input) as chat_log2, ): + assert chat_log is chat_log2 assert len(chat_log.content) == 2 assert chat_log.content[0].role == "system" assert chat_log.content[0].content == "" From 73cc1f51cac79a75292927883fa40199ad5714c0 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Mon, 3 Mar 2025 11:02:45 -0800 Subject: [PATCH 2160/3148] Add additional roborock debug logging (#139680) --- homeassistant/components/roborock/__init__.py | 1 + homeassistant/components/roborock/coordinator.py | 1 + 2 files changed, 2 insertions(+) diff --git a/homeassistant/components/roborock/__init__.py b/homeassistant/components/roborock/__init__.py index 1c25d527aa8..955e50cd15b 100644 --- a/homeassistant/components/roborock/__init__.py +++ b/homeassistant/components/roborock/__init__.py @@ -65,6 +65,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: RoborockConfigEntry) -> translation_key="no_user_agreement", ) from err except RoborockException as err: + _LOGGER.debug("Failed to get Roborock home data: %s", err) raise ConfigEntryNotReady( "Failed to get Roborock home data", translation_domain=DOMAIN, diff --git a/homeassistant/components/roborock/coordinator.py b/homeassistant/components/roborock/coordinator.py index b35f62323e8..6690b0ac07e 100644 --- a/homeassistant/components/roborock/coordinator.py +++ b/homeassistant/components/roborock/coordinator.py @@ -179,6 +179,7 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): # Get the rooms for that map id. await self.set_current_map_rooms() except RoborockException as ex: + _LOGGER.debug("Failed to update data: %s", ex) raise UpdateFailed(ex) from ex return self.roborock_device_info.props From 2c9b8b68353efc12846d788b1b8a763ec753e43d Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Mon, 3 Mar 2025 11:23:29 -0800 Subject: [PATCH 2161/3148] Improve failure handling and logging for invalid map responses (#139681) --- homeassistant/components/roborock/image.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/roborock/image.py b/homeassistant/components/roborock/image.py index 6d9e87b0556..66088d6453c 100644 --- a/homeassistant/components/roborock/image.py +++ b/homeassistant/components/roborock/image.py @@ -4,6 +4,7 @@ import asyncio from collections.abc import Callable from datetime import datetime import io +import logging from roborock import RoborockCommand from vacuum_map_parser_base.config.color import ColorsPalette @@ -30,6 +31,8 @@ from .const import ( from .coordinator import RoborockConfigEntry, RoborockDataUpdateCoordinator from .entity import RoborockCoordinatedEntityV1 +_LOGGER = logging.getLogger(__name__) + async def async_setup_entry( hass: HomeAssistant, @@ -48,7 +51,11 @@ async def async_setup_entry( ) def parse_image(map_bytes: bytes) -> bytes | None: - parsed_map = parser.parse(map_bytes) + try: + parsed_map = parser.parse(map_bytes) + except (IndexError, ValueError) as err: + _LOGGER.debug("Exception when parsing map contents: %s", err) + return None if parsed_map.image is None: return None img_byte_arr = io.BytesIO() @@ -150,6 +157,7 @@ class RoborockMap(RoborockCoordinatedEntityV1, ImageEntity): not isinstance(response[0], bytes) or (content := self.parser(response[0])) is None ): + _LOGGER.debug("Failed to parse map contents: %s", response[0]) raise HomeAssistantError( translation_domain=DOMAIN, translation_key="map_failure", From b890d3e15af707ef5bbd071fada115ada9e59451 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 3 Mar 2025 20:07:07 +0100 Subject: [PATCH 2162/3148] Abort SmartThings flow if default_config is not enabled (#139700) * Abort SmartThings flow if default_config is not enabled * Abort SmartThings flow if default_config is not enabled * Abort SmartThings flow if default_config is not enabled --- .../components/smartthings/config_flow.py | 11 +++ .../components/smartthings/strings.json | 3 +- .../smartthings/test_config_flow.py | 82 +++++++++++++++++-- 3 files changed, 89 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/smartthings/config_flow.py b/homeassistant/components/smartthings/config_flow.py index 02b11b190c9..d2654348527 100644 --- a/homeassistant/components/smartthings/config_flow.py +++ b/homeassistant/components/smartthings/config_flow.py @@ -32,6 +32,17 @@ class SmartThingsConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN): """Extra data that needs to be appended to the authorize url.""" return {"scope": " ".join(REQUESTED_SCOPES)} + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Check we have the cloud integration set up.""" + if "cloud" not in self.hass.config.components: + return self.async_abort( + reason="cloud_not_enabled", + description_placeholders={"default_config": "default_config"}, + ) + return await super().async_step_user(user_input) + async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResult: """Create an entry for SmartThings.""" if not set(data[CONF_TOKEN]["scope"].split()) >= set(SCOPES): diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index 9fd417284af..844ebd12004 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -24,7 +24,8 @@ "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "reauth_account_mismatch": "Authenticated account does not match the account to be reauthenticated. Please log in with the correct account and pick the right location.", "reauth_location_mismatch": "Authenticated location does not match the location to be reauthenticated. Please log in with the correct account and pick the right location.", - "missing_scopes": "Authentication failed. Please make sure you have granted all required permissions." + "missing_scopes": "Authentication failed. Please make sure you have granted all required permissions.", + "cloud_not_enabled": "Please make sure you run Home Assistant with `{default_config}` enabled in your configuration.yaml." } }, "entity": { diff --git a/tests/components/smartthings/test_config_flow.py b/tests/components/smartthings/test_config_flow.py index a16747c1190..7472d7d6b71 100644 --- a/tests/components/smartthings/test_config_flow.py +++ b/tests/components/smartthings/test_config_flow.py @@ -28,7 +28,13 @@ from tests.test_util.aiohttp import AiohttpClientMocker from tests.typing import ClientSessionGenerator -@pytest.mark.usefixtures("current_request_with_host") +@pytest.fixture +def use_cloud(hass: HomeAssistant) -> None: + """Set up the cloud component.""" + hass.config.components.add("cloud") + + +@pytest.mark.usefixtures("current_request_with_host", "use_cloud") async def test_full_flow( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, @@ -100,7 +106,7 @@ async def test_full_flow( assert result["result"].unique_id == "397678e5-9995-4a39-9d9f-ae6ba310236c" -@pytest.mark.usefixtures("current_request_with_host") +@pytest.mark.usefixtures("current_request_with_host", "use_cloud") async def test_not_enough_scopes( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, @@ -161,7 +167,7 @@ async def test_not_enough_scopes( assert result["reason"] == "missing_scopes" -@pytest.mark.usefixtures("current_request_with_host") +@pytest.mark.usefixtures("current_request_with_host", "use_cloud") async def test_duplicate_entry( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, @@ -224,6 +230,23 @@ async def test_duplicate_entry( @pytest.mark.usefixtures("current_request_with_host") +async def test_no_cloud( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_smartthings: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Check we abort when cloud is not enabled.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "cloud_not_enabled" + + +@pytest.mark.usefixtures("current_request_with_host", "use_cloud") async def test_reauthentication( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, @@ -285,7 +308,7 @@ async def test_reauthentication( } -@pytest.mark.usefixtures("current_request_with_host") +@pytest.mark.usefixtures("current_request_with_host", "use_cloud") async def test_reauthentication_wrong_scopes( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, @@ -336,7 +359,7 @@ async def test_reauthentication_wrong_scopes( assert result["reason"] == "missing_scopes" -@pytest.mark.usefixtures("current_request_with_host") +@pytest.mark.usefixtures("current_request_with_host", "use_cloud") async def test_reauth_account_mismatch( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, @@ -388,6 +411,29 @@ async def test_reauth_account_mismatch( @pytest.mark.usefixtures("current_request_with_host") +async def test_reauthentication_no_cloud( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_smartthings: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test SmartThings reauthentication without cloud.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reauth_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "cloud_not_enabled" + + +@pytest.mark.usefixtures("current_request_with_host", "use_cloud") async def test_migration( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, @@ -468,7 +514,7 @@ async def test_migration( assert mock_old_config_entry.minor_version == 1 -@pytest.mark.usefixtures("current_request_with_host") +@pytest.mark.usefixtures("current_request_with_host", "use_cloud") async def test_migration_wrong_location( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, @@ -539,3 +585,27 @@ async def test_migration_wrong_location( ) assert mock_old_config_entry.version == 3 assert mock_old_config_entry.minor_version == 1 + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_migration_no_cloud( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_smartthings: AsyncMock, + mock_old_config_entry: MockConfigEntry, +) -> None: + """Test SmartThings reauthentication with different account.""" + mock_old_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_old_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_old_config_entry.state is ConfigEntryState.SETUP_ERROR + + result = hass.config_entries.flow.async_progress()[0] + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "cloud_not_enabled" From c58cbfd6f42f49c2b38a3032902004d73e01cb1d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 3 Mar 2025 10:44:49 -0700 Subject: [PATCH 2163/3148] Bump ESPHome stable BLE version to 2025.2.2 (#139704) ensure proxies have https://github.com/esphome/esphome/pull/8328 so they do not reboot themselves if disconnecting takes too long --- homeassistant/components/esphome/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/esphome/const.py b/homeassistant/components/esphome/const.py index 20ff1cd27de..1a3be4c34ae 100644 --- a/homeassistant/components/esphome/const.py +++ b/homeassistant/components/esphome/const.py @@ -13,7 +13,7 @@ DEFAULT_ALLOW_SERVICE_CALLS = True DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS = False -STABLE_BLE_VERSION_STR = "2025.2.1" +STABLE_BLE_VERSION_STR = "2025.2.2" STABLE_BLE_VERSION = AwesomeVersion(STABLE_BLE_VERSION_STR) PROJECT_URLS = { "esphome.bluetooth-proxy": "https://esphome.github.io/bluetooth-proxies/", From 304c13261a77f9c96506826025c55065ba3c0eab Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 3 Mar 2025 20:47:38 +0100 Subject: [PATCH 2164/3148] Bump holidays to 0.68 (#139711) --- homeassistant/components/holiday/manifest.json | 2 +- homeassistant/components/workday/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/holiday/manifest.json b/homeassistant/components/holiday/manifest.json index cd5ac1ec1a9..ec47b222370 100644 --- a/homeassistant/components/holiday/manifest.json +++ b/homeassistant/components/holiday/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/holiday", "iot_class": "local_polling", - "requirements": ["holidays==0.67", "babel==2.15.0"] + "requirements": ["holidays==0.68", "babel==2.15.0"] } diff --git a/homeassistant/components/workday/manifest.json b/homeassistant/components/workday/manifest.json index beb828641a4..cc6b0f30002 100644 --- a/homeassistant/components/workday/manifest.json +++ b/homeassistant/components/workday/manifest.json @@ -7,5 +7,5 @@ "iot_class": "local_polling", "loggers": ["holidays"], "quality_scale": "internal", - "requirements": ["holidays==0.67"] + "requirements": ["holidays==0.68"] } diff --git a/requirements_all.txt b/requirements_all.txt index ffb7ead3bdf..693d398bfa0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1149,7 +1149,7 @@ hole==0.8.0 # homeassistant.components.holiday # homeassistant.components.workday -holidays==0.67 +holidays==0.68 # homeassistant.components.frontend home-assistant-frontend==20250228.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 38f78484aad..4c76efa2227 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -978,7 +978,7 @@ hole==0.8.0 # homeassistant.components.holiday # homeassistant.components.workday -holidays==0.67 +holidays==0.68 # homeassistant.components.frontend home-assistant-frontend==20250228.0 From f1d332da5a843bf3741c5d85eff1b1b471acdd23 Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Mon, 3 Mar 2025 22:26:16 +0200 Subject: [PATCH 2165/3148] Bump aiowebostv to 0.7.2 (#139712) --- homeassistant/components/webostv/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/webostv/manifest.json b/homeassistant/components/webostv/manifest.json index 06cbca32453..4632bbe8c74 100644 --- a/homeassistant/components/webostv/manifest.json +++ b/homeassistant/components/webostv/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/webostv", "iot_class": "local_push", "loggers": ["aiowebostv"], - "requirements": ["aiowebostv==0.7.1"], + "requirements": ["aiowebostv==0.7.2"], "ssdp": [ { "st": "urn:lge-com:service:webos-second-screen:1" diff --git a/requirements_all.txt b/requirements_all.txt index 693d398bfa0..daebc1fc772 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -425,7 +425,7 @@ aiowatttime==0.1.1 aiowebdav2==0.3.1 # homeassistant.components.webostv -aiowebostv==0.7.1 +aiowebostv==0.7.2 # homeassistant.components.withings aiowithings==3.1.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4c76efa2227..9bcc852cae8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -407,7 +407,7 @@ aiowatttime==0.1.1 aiowebdav2==0.3.1 # homeassistant.components.webostv -aiowebostv==0.7.1 +aiowebostv==0.7.2 # homeassistant.components.withings aiowithings==3.1.6 From 1bdc33d52d90e69fe3762fb862d897b745f5588f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 3 Mar 2025 14:20:25 -0700 Subject: [PATCH 2166/3148] Bump sense-energy to 0.13.6 (#139714) changes: https://github.com/scottbonline/sense/releases/tag/0.13.6 --- homeassistant/components/emulated_kasa/manifest.json | 2 +- homeassistant/components/sense/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/emulated_kasa/manifest.json b/homeassistant/components/emulated_kasa/manifest.json index 384dd3556a9..d607372136c 100644 --- a/homeassistant/components/emulated_kasa/manifest.json +++ b/homeassistant/components/emulated_kasa/manifest.json @@ -6,5 +6,5 @@ "iot_class": "local_push", "loggers": ["sense_energy"], "quality_scale": "internal", - "requirements": ["sense-energy==0.13.5"] + "requirements": ["sense-energy==0.13.6"] } diff --git a/homeassistant/components/sense/manifest.json b/homeassistant/components/sense/manifest.json index a7cee28f9c9..dda49b661e5 100644 --- a/homeassistant/components/sense/manifest.json +++ b/homeassistant/components/sense/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/sense", "iot_class": "cloud_polling", "loggers": ["sense_energy"], - "requirements": ["sense-energy==0.13.5"] + "requirements": ["sense-energy==0.13.6"] } diff --git a/requirements_all.txt b/requirements_all.txt index daebc1fc772..fea719a795b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2694,7 +2694,7 @@ sendgrid==6.8.2 # homeassistant.components.emulated_kasa # homeassistant.components.sense -sense-energy==0.13.5 +sense-energy==0.13.6 # homeassistant.components.sensirion_ble sensirion-ble==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9bcc852cae8..664da571369 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2173,7 +2173,7 @@ securetar==2025.2.1 # homeassistant.components.emulated_kasa # homeassistant.components.sense -sense-energy==0.13.5 +sense-energy==0.13.6 # homeassistant.components.sensirion_ble sensirion-ble==0.1.1 From a0dde2a7d663b41f1d369a0b682441c3f14368fc Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Tue, 4 Mar 2025 00:46:56 -0800 Subject: [PATCH 2167/3148] Add nest translation string for `already_in_progress` (#139727) --- homeassistant/components/nest/strings.json | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/nest/strings.json b/homeassistant/components/nest/strings.json index 23da524ab7e..54f543aa845 100644 --- a/homeassistant/components/nest/strings.json +++ b/homeassistant/components/nest/strings.json @@ -58,6 +58,7 @@ }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", "missing_credentials": "[%key:common::config_flow::abort::oauth2_missing_credentials%]", "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", From 5b3d798ecab9c5c946f920a4ca6f43927af3c788 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Tue, 4 Mar 2025 00:28:10 -0800 Subject: [PATCH 2168/3148] Bump google-nest-sdm to 7.1.4 (#139728) --- 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 a0d8bc06640..d9383533300 100644 --- a/homeassistant/components/nest/manifest.json +++ b/homeassistant/components/nest/manifest.json @@ -19,5 +19,5 @@ "documentation": "https://www.home-assistant.io/integrations/nest", "iot_class": "cloud_push", "loggers": ["google_nest_sdm"], - "requirements": ["google-nest-sdm==7.1.3"] + "requirements": ["google-nest-sdm==7.1.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index fea719a795b..0530135ed07 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1042,7 +1042,7 @@ google-cloud-texttospeech==2.17.2 google-genai==1.1.0 # homeassistant.components.nest -google-nest-sdm==7.1.3 +google-nest-sdm==7.1.4 # homeassistant.components.google_photos google-photos-library-api==0.12.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 664da571369..976d7030a90 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -892,7 +892,7 @@ google-cloud-texttospeech==2.17.2 google-genai==1.1.0 # homeassistant.components.nest -google-nest-sdm==7.1.3 +google-nest-sdm==7.1.4 # homeassistant.components.google_photos google-photos-library-api==0.12.1 From db63d9fcbf1f61d48366d0aa951645d11cb705dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Tue, 4 Mar 2025 11:07:44 +0100 Subject: [PATCH 2169/3148] Delete refresh after a non-breaking error at event stream at Home Connect (#139740) * Delete refresh after non-breaking error And improve how many time does it take to retry to open stream * Update tests --- .../components/home_connect/coordinator.py | 14 +++++------ .../home_connect/test_coordinator.py | 24 ++++--------------- 2 files changed, 10 insertions(+), 28 deletions(-) diff --git a/homeassistant/components/home_connect/coordinator.py b/homeassistant/components/home_connect/coordinator.py index d9200b282c9..4d275854e30 100644 --- a/homeassistant/components/home_connect/coordinator.py +++ b/homeassistant/components/home_connect/coordinator.py @@ -47,8 +47,6 @@ _LOGGER = logging.getLogger(__name__) type HomeConnectConfigEntry = ConfigEntry[HomeConnectCoordinator] -EVENT_STREAM_RECONNECT_DELAY = 30 - @dataclass(frozen=True, kw_only=True) class HomeConnectApplianceData: @@ -157,9 +155,11 @@ class HomeConnectCoordinator( async def _event_listener(self) -> None: """Match event with listener for event type.""" + retry_time = 10 while True: try: async for event_message in self.client.stream_all_events(): + retry_time = 10 event_message_ha_id = event_message.ha_id match event_message.type: case EventType.STATUS: @@ -256,20 +256,18 @@ class HomeConnectCoordinator( except (EventStreamInterruptedError, HomeConnectRequestError) as error: _LOGGER.debug( "Non-breaking error (%s) while listening for events," - " continuing in 30 seconds", + " continuing in %s seconds", type(error).__name__, + retry_time, ) - await asyncio.sleep(EVENT_STREAM_RECONNECT_DELAY) + await asyncio.sleep(retry_time) + retry_time = min(retry_time * 2, 3600) except HomeConnectApiError as error: _LOGGER.error("Error while listening for events: %s", error) self.hass.config_entries.async_schedule_reload( self.config_entry.entry_id ) break - # if there was a non-breaking error, we continue listening - # but we need to refresh the data to get the possible changes - # that happened while the event stream was interrupted - await self.async_refresh() @callback def _call_event_listener(self, event_message: EventMessage) -> None: diff --git a/tests/components/home_connect/test_coordinator.py b/tests/components/home_connect/test_coordinator.py index 3dd9ffbe7c1..ac27b848a36 100644 --- a/tests/components/home_connect/test_coordinator.py +++ b/tests/components/home_connect/test_coordinator.py @@ -1,6 +1,7 @@ """Test for Home Connect coordinator.""" from collections.abc import Awaitable, Callable +from datetime import timedelta from typing import Any from unittest.mock import AsyncMock, MagicMock, patch @@ -12,8 +13,6 @@ from aiohomeconnect.model import ( EventKey, EventMessage, EventType, - Status, - StatusKey, ) from aiohomeconnect.model.error import ( EventStreamInterruptedError, @@ -24,7 +23,6 @@ from aiohomeconnect.model.error import ( import pytest from homeassistant.components.home_connect.const import ( - BSH_DOOR_STATE_LOCKED, BSH_DOOR_STATE_OPEN, BSH_EVENT_PRESENT_STATE_PRESENT, BSH_POWER_OFF, @@ -38,8 +36,9 @@ from homeassistant.core import ( callback, ) from homeassistant.helpers import entity_registry as er +from homeassistant.util import dt as dt_util -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed @pytest.fixture @@ -286,9 +285,6 @@ async def test_event_listener_error( ( "entity_id", "initial_state", - "status_key", - "status_value", - "after_refresh_expected_state", "event_key", "event_value", "after_event_expected_state", @@ -297,24 +293,15 @@ async def test_event_listener_error( ( "sensor.washer_door", "closed", - StatusKey.BSH_COMMON_DOOR_STATE, - BSH_DOOR_STATE_LOCKED, - "locked", EventKey.BSH_COMMON_STATUS_DOOR_STATE, BSH_DOOR_STATE_OPEN, "open", ), ], ) -@patch( - "homeassistant.components.home_connect.coordinator.EVENT_STREAM_RECONNECT_DELAY", 0 -) async def test_event_listener_resilience( entity_id: str, initial_state: str, - status_key: StatusKey, - status_value: Any, - after_refresh_expected_state: str, event_key: EventKey, event_value: Any, after_event_expected_state: str, @@ -345,16 +332,13 @@ async def test_event_listener_resilience( assert hass.states.is_state(entity_id, initial_state) - client.get_status.return_value = ArrayOfStatus( - [Status(key=status_key, raw_key=status_key.value, value=status_value)], - ) await hass.async_block_till_done() future.set_exception(exception) await hass.async_block_till_done() + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=30)) await hass.async_block_till_done() assert client.stream_all_events.call_count == 2 - assert hass.states.is_state(entity_id, after_refresh_expected_state) await client.add_events( [ From 6a5a66e2f9ca7675283c6ae867ba233a26babea1 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 4 Mar 2025 10:46:11 +0000 Subject: [PATCH 2170/3148] Bump version to 2025.3.0b4 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 895fcb1b3a6..2a3b2c082ae 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -25,7 +25,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2025 MINOR_VERSION: Final = 3 -PATCH_VERSION: Final = "0b3" +PATCH_VERSION: Final = "0b4" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 13, 0) diff --git a/pyproject.toml b/pyproject.toml index 710b14869c8..06b5a433574 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.3.0b3" +version = "2025.3.0b4" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From d38e046494cb4a0272fee5102dea2de844a5ffc1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 4 Mar 2025 00:49:44 -1000 Subject: [PATCH 2171/3148] Bump bleak-esphome to 2.10.2 (#139731) * Bump bleak-esphome to 2.10.0 changelog: https://github.com/Bluetooth-Devices/bleak-esphome/compare/v2.9.0...v2.10.0 * again for wheel fix * disable name check since its a binary now --- homeassistant/components/eq3btsmart/manifest.json | 2 +- homeassistant/components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/esphome/bluetooth/test_client.py | 4 +++- 5 files changed, 7 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/eq3btsmart/manifest.json b/homeassistant/components/eq3btsmart/manifest.json index 68781282d66..f106868679b 100644 --- a/homeassistant/components/eq3btsmart/manifest.json +++ b/homeassistant/components/eq3btsmart/manifest.json @@ -22,5 +22,5 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["eq3btsmart"], - "requirements": ["eq3btsmart==1.4.1", "bleak-esphome==2.9.0"] + "requirements": ["eq3btsmart==1.4.1", "bleak-esphome==2.10.2"] } diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 26c4b21d565..d9ac746924f 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -18,7 +18,7 @@ "requirements": [ "aioesphomeapi==29.3.2", "esphome-dashboard-api==1.2.3", - "bleak-esphome==2.9.0" + "bleak-esphome==2.10.2" ], "zeroconf": ["_esphomelib._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index ab96cadb5ab..9f90db99999 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -603,7 +603,7 @@ bizkaibus==0.1.1 # homeassistant.components.eq3btsmart # homeassistant.components.esphome -bleak-esphome==2.9.0 +bleak-esphome==2.10.2 # homeassistant.components.bluetooth bleak-retry-connector==3.9.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 438e296b8d7..3913acfcedb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -534,7 +534,7 @@ bimmer-connected[china]==0.17.2 # homeassistant.components.eq3btsmart # homeassistant.components.esphome -bleak-esphome==2.9.0 +bleak-esphome==2.10.2 # homeassistant.components.bluetooth bleak-retry-connector==3.9.0 diff --git a/tests/components/esphome/bluetooth/test_client.py b/tests/components/esphome/bluetooth/test_client.py index 77d315f096d..554f1725f4b 100644 --- a/tests/components/esphome/bluetooth/test_client.py +++ b/tests/components/esphome/bluetooth/test_client.py @@ -6,7 +6,9 @@ from aioesphomeapi import APIClient, APIVersion, BluetoothProxyFeature, DeviceIn from bleak.exc import BleakError from bleak_esphome.backend.client import ESPHomeClient, ESPHomeClientData from bleak_esphome.backend.device import ESPHomeBluetoothDevice -from bleak_esphome.backend.scanner import ESPHomeScanner +from bleak_esphome.backend.scanner import ( # pylint: disable=no-name-in-module + ESPHomeScanner, +) import pytest from homeassistant.components.bluetooth import HaBluetoothConnector From d5ba55d2fcdd9a0f332416c3e3666f1c8f59bda2 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Tue, 4 Mar 2025 13:27:51 +0100 Subject: [PATCH 2172/3148] Disable test results upload on forks (#139749) Disable test result uploads on forks --- .github/workflows/ci.yaml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index f0b117ab54a..8a999d21b2e 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1452,7 +1452,9 @@ jobs: upload-test-results: name: Upload test results to Codecov - if: needs.info.outputs.skip_coverage != 'true' && !cancelled() + # codecov/test-results-action currently doesn't support tokenless uploads + # therefore we can't run it on forks + if: github.repository_owner == 'home-assistant' && needs.info.outputs.skip_coverage != 'true' && !cancelled() runs-on: ubuntu-24.04 needs: - info From 8a97c2bfca1c7787ca19b67c8d77a3b9a12647d3 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 4 Mar 2025 08:02:58 -0500 Subject: [PATCH 2173/3148] VoIP block non-TTS announcements (#139658) * VoIP block non-TTS announcements * Migrate VoIP to use pipeline token --- homeassistant/components/voip/assist_satellite.py | 7 +++++++ homeassistant/components/voip/strings.json | 5 +++++ tests/components/voip/test_voip.py | 14 ++++++++++++++ 3 files changed, 26 insertions(+) diff --git a/homeassistant/components/voip/assist_satellite.py b/homeassistant/components/voip/assist_satellite.py index a0aeaaf38d3..6d18d8254f2 100644 --- a/homeassistant/components/voip/assist_satellite.py +++ b/homeassistant/components/voip/assist_satellite.py @@ -28,6 +28,7 @@ from homeassistant.components.assist_satellite import ( from homeassistant.components.network import async_get_source_ip from homeassistant.config_entries import ConfigEntry from homeassistant.core import Context, HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( @@ -193,6 +194,12 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol Optionally run a voice pipeline after the announcement has finished. """ + if announcement.media_id_source != "tts": + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="non_tts_announcement", + ) + self._announcement_future = asyncio.Future() self._run_pipeline_after_announce = run_pipeline_after diff --git a/homeassistant/components/voip/strings.json b/homeassistant/components/voip/strings.json index 96c902bf39a..4f37ad1d6f7 100644 --- a/homeassistant/components/voip/strings.json +++ b/homeassistant/components/voip/strings.json @@ -58,5 +58,10 @@ } } } + }, + "exceptions": { + "non_tts_announcement": { + "message": "VoIP does not currently support non-TTS announcements" + } } } diff --git a/tests/components/voip/test_voip.py b/tests/components/voip/test_voip.py index 3e3e5337417..d971591c79a 100644 --- a/tests/components/voip/test_voip.py +++ b/tests/components/voip/test_voip.py @@ -22,6 +22,7 @@ from homeassistant.components.voip.devices import VoIPDevice, VoIPDevices from homeassistant.components.voip.voip import PreRecordMessageProtocol, make_protocol from homeassistant.const import STATE_OFF, STATE_ON, Platform from homeassistant.core import Context, HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_component import EntityComponent from homeassistant.setup import async_setup_component @@ -862,6 +863,19 @@ async def test_announce( & assist_satellite.AssistSatelliteEntityFeature.ANNOUNCE ) + with pytest.raises(HomeAssistantError) as err: + await hass.services.async_call( + "assist_satellite", + "announce", + service_data={"media_id": "http://example.com"}, + blocking=True, + target={ + "entity_id": satellite.entity_id, + }, + ) + assert err.value.translation_domain == "voip" + assert err.value.translation_key == "non_tts_announcement" + announcement = assist_satellite.AssistSatelliteAnnouncement( message="test announcement", media_id=_MEDIA_ID, From e69b4f389f49e11d5d4e4e99b74f3f409802508e Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Tue, 4 Mar 2025 14:07:27 +0100 Subject: [PATCH 2174/3148] Simplify lint-only job config [ci] (#139748) --- .github/workflows/ci.yaml | 38 ++++++++++++++++++-------------------- 1 file changed, 18 insertions(+), 20 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 8a999d21b2e..d0a214814ee 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -89,6 +89,7 @@ jobs: test_groups: ${{ steps.info.outputs.test_groups }} tests_glob: ${{ steps.info.outputs.tests_glob }} tests: ${{ steps.info.outputs.tests }} + lint_only: ${{ steps.info.outputs.lint_only }} skip_coverage: ${{ steps.info.outputs.skip_coverage }} runs-on: ubuntu-24.04 steps: @@ -142,6 +143,7 @@ jobs: test_group_count=10 tests="[]" tests_glob="" + lint_only="" skip_coverage="" if [[ "${{ steps.integrations.outputs.changes }}" != "[]" ]]; @@ -192,6 +194,15 @@ jobs: test_full_suite="true" fi + if [[ "${{ github.event.inputs.lint-only }}" == "true" ]] \ + || [[ "${{ github.event.inputs.pylint-only }}" == "true" ]] \ + || [[ "${{ github.event.inputs.mypy-only }}" == "true" ]] \ + || [[ "${{ github.event.inputs.audit-licenses-only }}" == "true" ]]; + then + lint_only="true" + skip_coverage="true" + fi + if [[ "${{ github.event.inputs.skip-coverage }}" == "true" ]] \ || [[ "${{ contains(github.event.pull_request.labels.*.name, 'ci-skip-coverage') }}" == "true" ]]; then @@ -217,6 +228,8 @@ jobs: echo "tests=${tests}" >> $GITHUB_OUTPUT echo "tests_glob: ${tests_glob}" echo "tests_glob=${tests_glob}" >> $GITHUB_OUTPUT + echo "lint_only": ${lint_only} + echo "lint_only=${lint_only}" >> $GITHUB_OUTPUT echo "skip_coverage: ${skip_coverage}" echo "skip_coverage=${skip_coverage}" >> $GITHUB_OUTPUT @@ -830,10 +843,7 @@ jobs: runs-on: ubuntu-24.04 if: | (github.event_name != 'push' || github.event.repository.full_name == 'home-assistant/core') - && github.event.inputs.lint-only != 'true' - && github.event.inputs.pylint-only != 'true' - && github.event.inputs.mypy-only != 'true' - && github.event.inputs.audit-licenses-only != 'true' + && needs.info.outputs.lint_only != 'true' && needs.info.outputs.test_full_suite == 'true' needs: - info @@ -887,10 +897,7 @@ jobs: runs-on: ubuntu-24.04 if: | (github.event_name != 'push' || github.event.repository.full_name == 'home-assistant/core') - && github.event.inputs.lint-only != 'true' - && github.event.inputs.pylint-only != 'true' - && github.event.inputs.mypy-only != 'true' - && github.event.inputs.audit-licenses-only != 'true' + && needs.info.outputs.lint_only != 'true' && needs.info.outputs.test_full_suite == 'true' needs: - info @@ -1017,10 +1024,7 @@ jobs: options: --health-cmd="mysqladmin ping -uroot -ppassword" --health-interval=5s --health-timeout=2s --health-retries=3 if: | (github.event_name != 'push' || github.event.repository.full_name == 'home-assistant/core') - && github.event.inputs.lint-only != 'true' - && github.event.inputs.pylint-only != 'true' - && github.event.inputs.mypy-only != 'true' - && github.event.inputs.audit-licenses-only != 'true' + && needs.info.outputs.lint_only != 'true' && needs.info.outputs.mariadb_groups != '[]' needs: - info @@ -1153,10 +1157,7 @@ jobs: options: --health-cmd="pg_isready -hlocalhost -Upostgres" --health-interval=5s --health-timeout=2s --health-retries=3 if: | (github.event_name != 'push' || github.event.repository.full_name == 'home-assistant/core') - && github.event.inputs.lint-only != 'true' - && github.event.inputs.pylint-only != 'true' - && github.event.inputs.mypy-only != 'true' - && github.event.inputs.audit-licenses-only != 'true' + && needs.info.outputs.lint_only != 'true' && needs.info.outputs.postgresql_groups != '[]' needs: - info @@ -1309,10 +1310,7 @@ jobs: runs-on: ubuntu-24.04 if: | (github.event_name != 'push' || github.event.repository.full_name == 'home-assistant/core') - && github.event.inputs.lint-only != 'true' - && github.event.inputs.pylint-only != 'true' - && github.event.inputs.mypy-only != 'true' - && github.event.inputs.audit-licenses-only != 'true' + && needs.info.outputs.lint_only != 'true' && needs.info.outputs.tests_glob && needs.info.outputs.test_full_suite == 'false' needs: From d9690507a48241b84f077dffa4bcf412ec860770 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 4 Mar 2025 15:08:14 +0100 Subject: [PATCH 2175/3148] Add Apollo Automation virtual integration (#139751) Co-authored-by: Robert Resch --- homeassistant/components/apollo_automation/__init__.py | 1 + homeassistant/components/apollo_automation/manifest.json | 6 ++++++ homeassistant/generated/integrations.json | 5 +++++ 3 files changed, 12 insertions(+) create mode 100644 homeassistant/components/apollo_automation/__init__.py create mode 100644 homeassistant/components/apollo_automation/manifest.json diff --git a/homeassistant/components/apollo_automation/__init__.py b/homeassistant/components/apollo_automation/__init__.py new file mode 100644 index 00000000000..7815b17818f --- /dev/null +++ b/homeassistant/components/apollo_automation/__init__.py @@ -0,0 +1 @@ +"""Virtual integration: Apollo Automation.""" diff --git a/homeassistant/components/apollo_automation/manifest.json b/homeassistant/components/apollo_automation/manifest.json new file mode 100644 index 00000000000..8e4c58f3f3d --- /dev/null +++ b/homeassistant/components/apollo_automation/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "apollo_automation", + "name": "Apollo Automation", + "integration_type": "virtual", + "supported_by": "esphome" +} diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index a92311d31d0..916087075cc 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -345,6 +345,11 @@ "config_flow": true, "iot_class": "local_polling" }, + "apollo_automation": { + "name": "Apollo Automation", + "integration_type": "virtual", + "supported_by": "esphome" + }, "appalachianpower": { "name": "Appalachian Power", "integration_type": "virtual", From 74ea553b636e9968828f7272cdde8f37c355f66f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Tue, 4 Mar 2025 15:17:05 +0100 Subject: [PATCH 2176/3148] Bump aiohomeconnect to 0.16.2 (#139750) --- homeassistant/components/home_connect/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/home_connect/manifest.json b/homeassistant/components/home_connect/manifest.json index 2f5ef4d1b37..5293e8bf468 100644 --- a/homeassistant/components/home_connect/manifest.json +++ b/homeassistant/components/home_connect/manifest.json @@ -7,6 +7,6 @@ "documentation": "https://www.home-assistant.io/integrations/home_connect", "iot_class": "cloud_push", "loggers": ["aiohomeconnect"], - "requirements": ["aiohomeconnect==0.15.1"], + "requirements": ["aiohomeconnect==0.16.2"], "single_config_entry": true } diff --git a/requirements_all.txt b/requirements_all.txt index 9f90db99999..a56afd43961 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -264,7 +264,7 @@ aioharmony==0.4.1 aiohasupervisor==0.3.0 # homeassistant.components.home_connect -aiohomeconnect==0.15.1 +aiohomeconnect==0.16.2 # homeassistant.components.homekit_controller aiohomekit==3.2.8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3913acfcedb..40311657000 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -249,7 +249,7 @@ aioharmony==0.4.1 aiohasupervisor==0.3.0 # homeassistant.components.home_connect -aiohomeconnect==0.15.1 +aiohomeconnect==0.16.2 # homeassistant.components.homekit_controller aiohomekit==3.2.8 From 7fb949dff7a29ec80a465e38e8a2ec90a914d8db Mon Sep 17 00:00:00 2001 From: Anthony Hou Date: Tue, 4 Mar 2025 22:25:47 +0800 Subject: [PATCH 2177/3148] Fix incorrect weather state returned by HKO (#139757) * Fix incorrect weather state * Clean up unused import --------- Co-authored-by: Franck Nijhof Co-authored-by: Franck Nijhof --- homeassistant/components/hko/coordinator.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/homeassistant/components/hko/coordinator.py b/homeassistant/components/hko/coordinator.py index 5845e8831fe..aede960e702 100644 --- a/homeassistant/components/hko/coordinator.py +++ b/homeassistant/components/hko/coordinator.py @@ -11,7 +11,6 @@ from hko import HKO, HKOError from homeassistant.components.weather import ( ATTR_CONDITION_CLOUDY, ATTR_CONDITION_FOG, - ATTR_CONDITION_HAIL, ATTR_CONDITION_LIGHTNING_RAINY, ATTR_CONDITION_PARTLYCLOUDY, ATTR_CONDITION_POURING, @@ -145,7 +144,7 @@ class HKOUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Return the condition corresponding to the weather info.""" info = info.lower() if WEATHER_INFO_RAIN in info: - return ATTR_CONDITION_HAIL + return ATTR_CONDITION_RAINY if WEATHER_INFO_SNOW in info and WEATHER_INFO_RAIN in info: return ATTR_CONDITION_SNOWY_RAINY if WEATHER_INFO_SNOW in info: From c51a2317e1f05ed9ff935a009651381987bc9a73 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 4 Mar 2025 09:48:10 -0500 Subject: [PATCH 2178/3148] Add timer support to VoIP (#139763) --- .../components/voip/assist_satellite.py | 34 +++++++++- homeassistant/components/voip/manifest.json | 2 +- .../components/voip/test_assist_satellite.py | 62 +++++++++++++++++++ 3 files changed, 96 insertions(+), 2 deletions(-) create mode 100644 tests/components/voip/test_assist_satellite.py diff --git a/homeassistant/components/voip/assist_satellite.py b/homeassistant/components/voip/assist_satellite.py index 6d18d8254f2..2c0a3b9641a 100644 --- a/homeassistant/components/voip/assist_satellite.py +++ b/homeassistant/components/voip/assist_satellite.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +from datetime import timedelta from enum import IntFlag from functools import partial import io @@ -16,7 +17,7 @@ import wave from voip_utils import SIP_PORT, RtpDatagramProtocol from voip_utils.sip import SipDatagramProtocol, SipEndpoint, get_sip_endpoint -from homeassistant.components import tts +from homeassistant.components import intent, tts from homeassistant.components.assist_pipeline import PipelineEvent, PipelineEventType from homeassistant.components.assist_satellite import ( AssistSatelliteAnnouncement, @@ -25,6 +26,7 @@ from homeassistant.components.assist_satellite import ( AssistSatelliteEntityDescription, AssistSatelliteEntityFeature, ) +from homeassistant.components.intent import TimerEventType, TimerInfo from homeassistant.components.network import async_get_source_ip from homeassistant.config_entries import ConfigEntry from homeassistant.core import Context, HomeAssistant, callback @@ -161,6 +163,13 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol await super().async_added_to_hass() self.voip_device.protocol = self + assert self.device_entry is not None + self.async_on_remove( + intent.async_register_timer_handler( + self.hass, self.device_entry.id, self.async_handle_timer_event + ) + ) + async def async_will_remove_from_hass(self) -> None: """Run when entity will be removed from hass.""" await super().async_will_remove_from_hass() @@ -174,6 +183,29 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol """Get the current satellite configuration.""" raise NotImplementedError + @callback + def async_handle_timer_event( + self, + event_type: TimerEventType, + timer_info: TimerInfo, + ) -> None: + """Handle timer event.""" + if event_type != TimerEventType.FINISHED: + return + + if timer_info.name: + message = f"{timer_info.name} finished" + else: + message = f"{timedelta(seconds=timer_info.created_seconds)} timer finished" + + async def announce_message(): + announcement = await self._resolve_announcement_media_id(message, None) + await self.async_announce(announcement) + + self.config_entry.async_create_background_task( + self.hass, announce_message(), "voip_announce_timer" + ) + async def async_set_configuration( self, config: AssistSatelliteConfiguration ) -> None: diff --git a/homeassistant/components/voip/manifest.json b/homeassistant/components/voip/manifest.json index e3b2861dbe5..1e4c249c720 100644 --- a/homeassistant/components/voip/manifest.json +++ b/homeassistant/components/voip/manifest.json @@ -3,7 +3,7 @@ "name": "Voice over IP", "codeowners": ["@balloob", "@synesthesiam"], "config_flow": true, - "dependencies": ["assist_pipeline", "assist_satellite", "network"], + "dependencies": ["assist_pipeline", "assist_satellite", "intent", "network"], "documentation": "https://www.home-assistant.io/integrations/voip", "iot_class": "local_push", "quality_scale": "internal", diff --git a/tests/components/voip/test_assist_satellite.py b/tests/components/voip/test_assist_satellite.py new file mode 100644 index 00000000000..f3e2611631e --- /dev/null +++ b/tests/components/voip/test_assist_satellite.py @@ -0,0 +1,62 @@ +"""Test the Assist Satellite platform.""" + +from unittest.mock import patch + +import pytest + +from homeassistant.components.voip.devices import VoIPDevice +from homeassistant.core import HomeAssistant +from homeassistant.helpers import intent as intent_helper + + +@pytest.mark.parametrize( + ("intent_args", "message"), + [ + ( + {}, + "0:02:00 timer finished", + ), + ( + {"name": {"value": "pizza"}}, + "pizza finished", + ), + ], +) +async def test_timer_events( + hass: HomeAssistant, voip_device: VoIPDevice, intent_args: dict, message: str +) -> None: + """Test for timer events.""" + + await intent_helper.async_handle( + hass, + "test", + intent_helper.INTENT_START_TIMER, + { + "minutes": {"value": 2}, + } + | intent_args, + device_id=voip_device.device_id, + ) + + with ( + patch( + "homeassistant.components.voip.assist_satellite.VoipAssistSatellite._resolve_announcement_media_id", + ) as mock_resolve, + patch( + "homeassistant.components.voip.assist_satellite.VoipAssistSatellite.async_announce", + ) as mock_announce, + ): + await intent_helper.async_handle( + hass, + "test", + intent_helper.INTENT_DECREASE_TIMER, + { + "minutes": {"value": 2}, + }, + device_id=voip_device.device_id, + ) + await hass.async_block_till_done(wait_background_tasks=True) + + assert len(mock_resolve.mock_calls) == 1 + assert len(mock_announce.mock_calls) == 1 + assert mock_resolve.mock_calls[0][1][0] == message From e55757dc820849ce8548e6ca7738899615ceaa06 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 4 Mar 2025 15:56:12 +0100 Subject: [PATCH 2179/3148] Simplify error handling in BackupAgent when a backup is not found (#139754) Simplify error handling in BackupAgent when backup is not found --- homeassistant/components/backup/agent.py | 9 ++++++- homeassistant/components/backup/backup.py | 11 +++----- homeassistant/components/backup/http.py | 11 +++++--- homeassistant/components/backup/manager.py | 29 +++++++++++++++++++--- tests/components/backup/common.py | 15 +++++++---- 5 files changed, 56 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/backup/agent.py b/homeassistant/components/backup/agent.py index 9530f386c7b..0a2531900ae 100644 --- a/homeassistant/components/backup/agent.py +++ b/homeassistant/components/backup/agent.py @@ -41,6 +41,8 @@ class BackupAgent(abc.ABC): ) -> AsyncIterator[bytes]: """Download a backup file. + Raises BackupNotFound if the backup does not exist. + :param backup_id: The ID of the backup that was returned in async_list_backups. :return: An async iterator that yields bytes. """ @@ -67,6 +69,8 @@ class BackupAgent(abc.ABC): ) -> None: """Delete a backup file. + Raises BackupNotFound if the backup does not exist. + :param backup_id: The ID of the backup that was returned in async_list_backups. """ @@ -80,7 +84,10 @@ class BackupAgent(abc.ABC): backup_id: str, **kwargs: Any, ) -> AgentBackup | None: - """Return a backup.""" + """Return a backup. + + Raises BackupNotFound if the backup does not exist. + """ class LocalBackupAgent(BackupAgent): diff --git a/homeassistant/components/backup/backup.py b/homeassistant/components/backup/backup.py index c3a46a6ab1f..de2cfecb1a5 100644 --- a/homeassistant/components/backup/backup.py +++ b/homeassistant/components/backup/backup.py @@ -88,13 +88,13 @@ class CoreLocalBackupAgent(LocalBackupAgent): self, backup_id: str, **kwargs: Any, - ) -> AgentBackup | None: + ) -> AgentBackup: """Return a backup.""" if not self._loaded_backups: await self._load_backups() if backup_id not in self._backups: - return None + raise BackupNotFound(f"Backup {backup_id} not found") backup, backup_path = self._backups[backup_id] if not await self._hass.async_add_executor_job(backup_path.exists): @@ -107,7 +107,7 @@ class CoreLocalBackupAgent(LocalBackupAgent): backup_path, ) self._backups.pop(backup_id) - return None + raise BackupNotFound(f"Backup {backup_id} not found") return backup @@ -130,10 +130,7 @@ class CoreLocalBackupAgent(LocalBackupAgent): if not self._loaded_backups: await self._load_backups() - try: - backup_path = self.get_backup_path(backup_id) - except BackupNotFound: - return + backup_path = self.get_backup_path(backup_id) await self._hass.async_add_executor_job(backup_path.unlink, True) LOGGER.debug("Deleted backup located at %s", backup_path) self._backups.pop(backup_id) diff --git a/homeassistant/components/backup/http.py b/homeassistant/components/backup/http.py index 58f44d4a449..20ad613933b 100644 --- a/homeassistant/components/backup/http.py +++ b/homeassistant/components/backup/http.py @@ -59,10 +59,13 @@ class DownloadBackupView(HomeAssistantView): if agent_id not in manager.backup_agents: return Response(status=HTTPStatus.BAD_REQUEST) agent = manager.backup_agents[agent_id] - backup = await agent.async_get_backup(backup_id) + try: + backup = await agent.async_get_backup(backup_id) + except BackupNotFound: + return Response(status=HTTPStatus.NOT_FOUND) - # We don't need to check if the path exists, aiohttp.FileResponse will handle - # that + # Check for None to be backwards compatible with the old BackupAgent API, + # this can be removed in HA Core 2025.10 if backup is None: return Response(status=HTTPStatus.NOT_FOUND) @@ -92,6 +95,8 @@ class DownloadBackupView(HomeAssistantView): ) -> StreamResponse | FileResponse | Response: if agent_id in manager.local_backup_agents: local_agent = manager.local_backup_agents[agent_id] + # We don't need to check if the path exists, aiohttp.FileResponse will + # handle that path = local_agent.get_backup_path(backup_id) return FileResponse(path=path.as_posix(), headers=headers) diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index c8b515e3aee..4f3ea8b296c 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -64,6 +64,7 @@ from .models import ( AgentBackup, BackupError, BackupManagerError, + BackupNotFound, BackupReaderWriterError, BaseBackup, Folder, @@ -648,6 +649,8 @@ class BackupManager: ) for idx, result in enumerate(get_backup_results): agent_id = agent_ids[idx] + if isinstance(result, BackupNotFound): + continue if isinstance(result, BackupAgentError): agent_errors[agent_id] = result continue @@ -659,6 +662,8 @@ class BackupManager: continue if isinstance(result, BaseException): raise result # unexpected error + # Check for None to be backwards compatible with the old BackupAgent API, + # this can be removed in HA Core 2025.10 if not result: continue if backup is None: @@ -723,6 +728,8 @@ class BackupManager: ) for idx, result in enumerate(delete_backup_results): agent_id = agent_ids[idx] + if isinstance(result, BackupNotFound): + continue if isinstance(result, BackupAgentError): agent_errors[agent_id] = result continue @@ -832,7 +839,7 @@ class BackupManager: agent_errors = { backup_id: error for backup_id, error in zip(backup_ids, delete_results, strict=True) - if error + if error and not isinstance(error, BackupNotFound) } if agent_errors: LOGGER.error( @@ -1264,7 +1271,15 @@ class BackupManager: ) -> None: """Initiate restoring a backup.""" agent = self.backup_agents[agent_id] - if not await agent.async_get_backup(backup_id): + try: + backup = await agent.async_get_backup(backup_id) + except BackupNotFound as err: + raise BackupManagerError( + f"Backup {backup_id} not found in agent {agent_id}" + ) from err + # Check for None to be backwards compatible with the old BackupAgent API, + # this can be removed in HA Core 2025.10 + if not backup: raise BackupManagerError( f"Backup {backup_id} not found in agent {agent_id}" ) @@ -1352,7 +1367,15 @@ class BackupManager: agent = self.backup_agents[agent_id] except KeyError as err: raise BackupManagerError(f"Invalid agent selected: {agent_id}") from err - if not await agent.async_get_backup(backup_id): + try: + backup = await agent.async_get_backup(backup_id) + except BackupNotFound as err: + raise BackupManagerError( + f"Backup {backup_id} not found in agent {agent_id}" + ) from err + # Check for None to be backwards compatible with the old BackupAgent API, + # this can be removed in HA Core 2025.10 + if not backup: raise BackupManagerError( f"Backup {backup_id} not found in agent {agent_id}" ) diff --git a/tests/components/backup/common.py b/tests/components/backup/common.py index e41da5c1bad..e6e4b2f8a50 100644 --- a/tests/components/backup/common.py +++ b/tests/components/backup/common.py @@ -67,15 +67,20 @@ async def aiter_from_iter(iterable: Iterable) -> AsyncIterator: def mock_backup_agent(name: str, backups: list[AgentBackup] | None = None) -> Mock: """Create a mock backup agent.""" + async def delete_backup(backup_id: str, **kwargs: Any) -> None: + """Mock delete.""" + get_backup(backup_id) + async def download_backup(backup_id: str, **kwargs: Any) -> AsyncIterator[bytes]: """Mock download.""" - if not await get_backup(backup_id): - raise BackupNotFound return aiter_from_iter((backups_data.get(backup_id, b"backup data"),)) - async def get_backup(backup_id: str, **kwargs: Any) -> AgentBackup | None: + async def get_backup(backup_id: str, **kwargs: Any) -> AgentBackup: """Get a backup.""" - return next((b for b in backups if b.backup_id == backup_id), None) + backup = next((b for b in backups if b.backup_id == backup_id), None) + if backup is None: + raise BackupNotFound + return backup async def upload_backup( *, @@ -99,7 +104,7 @@ def mock_backup_agent(name: str, backups: list[AgentBackup] | None = None) -> Mo mock_agent.unique_id = name type(mock_agent).agent_id = BackupAgent.agent_id mock_agent.async_delete_backup = AsyncMock( - spec_set=[BackupAgent.async_delete_backup] + side_effect=delete_backup, spec_set=[BackupAgent.async_delete_backup] ) mock_agent.async_download_backup = AsyncMock( side_effect=download_backup, spec_set=[BackupAgent.async_download_backup] From 0eb087ba3f5d99f0181896bf23107d0350102234 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 4 Mar 2025 15:59:38 +0100 Subject: [PATCH 2180/3148] Bump pysmartthings to 2.5.0 (#139758) * Bump pysmartthings to 2.5.0 * Bump pysmartthings to 2.5.0 --- homeassistant/components/smartthings/__init__.py | 8 +++++--- homeassistant/components/smartthings/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 8 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py index b7850bc9333..969df42bed9 100644 --- a/homeassistant/components/smartthings/__init__.py +++ b/homeassistant/components/smartthings/__init__.py @@ -112,9 +112,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: SmartThingsConfigEntry) device_registry.async_get_or_create( config_entry_id=entry.entry_id, identifiers={(DOMAIN, dev.device.device_id)}, - connections={ - (dr.CONNECTION_NETWORK_MAC, dev.device.hub.mac_address) - }, + connections=( + {(dr.CONNECTION_NETWORK_MAC, dev.device.hub.mac_address)} + if dev.device.hub.mac_address + else set() + ), name=dev.device.label, sw_version=dev.device.hub.firmware_version, model=dev.device.hub.hardware_type, diff --git a/homeassistant/components/smartthings/manifest.json b/homeassistant/components/smartthings/manifest.json index 7a25dc2ac13..22926e70ba0 100644 --- a/homeassistant/components/smartthings/manifest.json +++ b/homeassistant/components/smartthings/manifest.json @@ -29,5 +29,5 @@ "documentation": "https://www.home-assistant.io/integrations/smartthings", "iot_class": "cloud_push", "loggers": ["pysmartthings"], - "requirements": ["pysmartthings==2.4.1"] + "requirements": ["pysmartthings==2.5.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index a56afd43961..e6e91eabbe3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2310,7 +2310,7 @@ pysma==0.7.5 pysmappee==0.2.29 # homeassistant.components.smartthings -pysmartthings==2.4.1 +pysmartthings==2.5.0 # homeassistant.components.smarty pysmarty2==0.10.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 40311657000..3f0115f78f4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1882,7 +1882,7 @@ pysma==0.7.5 pysmappee==0.2.29 # homeassistant.components.smartthings -pysmartthings==2.4.1 +pysmartthings==2.5.0 # homeassistant.components.smarty pysmarty2==0.10.2 From ec100e5a6cefc4a0fedf8a1fae7df311fc137a0c Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 4 Mar 2025 16:10:33 +0100 Subject: [PATCH 2181/3148] Align azure_storage with changes in BackupAgent (#139765) --- homeassistant/components/azure_storage/backup.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/azure_storage/backup.py b/homeassistant/components/azure_storage/backup.py index 6f39295761d..4d897126d3d 100644 --- a/homeassistant/components/azure_storage/backup.py +++ b/homeassistant/components/azure_storage/backup.py @@ -141,7 +141,7 @@ class AzureStorageBackupAgent(BackupAgent): """Delete a backup file.""" blob = await self._find_blob_by_backup_id(backup_id) if blob is None: - return + raise BackupNotFound(f"Backup {backup_id} not found") await self._client.delete_blob(blob.name) @handle_backup_errors @@ -163,11 +163,11 @@ class AzureStorageBackupAgent(BackupAgent): self, backup_id: str, **kwargs: Any, - ) -> AgentBackup | None: + ) -> AgentBackup: """Return a backup.""" blob = await self._find_blob_by_backup_id(backup_id) if blob is None: - return None + raise BackupNotFound(f"Backup {backup_id} not found") return AgentBackup.from_dict(json.loads(blob.metadata["backup_metadata"])) From e3a90831bf150c5d27646df7fd7be593273b5aa4 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 4 Mar 2025 16:32:47 +0100 Subject: [PATCH 2182/3148] Align onedrive with changes in BackupAgent (#139769) --- homeassistant/components/onedrive/backup.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/onedrive/backup.py b/homeassistant/components/onedrive/backup.py index 9c7371bee4b..41a244506ea 100644 --- a/homeassistant/components/onedrive/backup.py +++ b/homeassistant/components/onedrive/backup.py @@ -138,7 +138,7 @@ class OneDriveBackupAgent(BackupAgent): """Download a backup file.""" backups = await self._list_cached_backups() if backup_id not in backups: - raise BackupNotFound("Backup not found") + raise BackupNotFound(f"Backup {backup_id} not found") stream = await self._client.download_drive_item( backups[backup_id].backup_file_id, timeout=TIMEOUT @@ -201,7 +201,7 @@ class OneDriveBackupAgent(BackupAgent): """Delete a backup file.""" backups = await self._list_cached_backups() if backup_id not in backups: - return + raise BackupNotFound(f"Backup {backup_id} not found") backup = backups[backup_id] @@ -221,12 +221,12 @@ class OneDriveBackupAgent(BackupAgent): ] @handle_backup_errors - async def async_get_backup( - self, backup_id: str, **kwargs: Any - ) -> AgentBackup | None: + async def async_get_backup(self, backup_id: str, **kwargs: Any) -> AgentBackup: """Return a backup.""" backups = await self._list_cached_backups() - return backups[backup_id].backup if backup_id in backups else None + if backup_id not in backups: + raise BackupNotFound(f"Backup {backup_id} not found") + return backups[backup_id].backup async def _list_cached_backups(self) -> dict[str, OneDriveBackup]: """List backups with a cache.""" From 0ebdb1c2a8d06011aa8e559ae78f599c07a17968 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 4 Mar 2025 16:38:03 +0100 Subject: [PATCH 2183/3148] Align kitchen_sink with changes in BackupAgent (#139768) --- homeassistant/components/kitchen_sink/backup.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/kitchen_sink/backup.py b/homeassistant/components/kitchen_sink/backup.py index 44ac0456105..46b204845ad 100644 --- a/homeassistant/components/kitchen_sink/backup.py +++ b/homeassistant/components/kitchen_sink/backup.py @@ -7,7 +7,13 @@ from collections.abc import AsyncIterator, Callable, Coroutine import logging from typing import Any -from homeassistant.components.backup import AddonInfo, AgentBackup, BackupAgent, Folder +from homeassistant.components.backup import ( + AddonInfo, + AgentBackup, + BackupAgent, + BackupNotFound, + Folder, +) from homeassistant.core import HomeAssistant, callback from . import DATA_BACKUP_AGENT_LISTENERS, DOMAIN @@ -110,9 +116,9 @@ class KitchenSinkBackupAgent(BackupAgent): self, backup_id: str, **kwargs: Any, - ) -> AgentBackup | None: + ) -> AgentBackup: """Return a backup.""" for backup in self._uploads: if backup.backup_id == backup_id: return backup - return None + raise BackupNotFound(f"Backup {backup_id} not found") From 46ac44c248c39bad401ef415a3f5c2a1be4720fe Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 4 Mar 2025 17:44:26 +0100 Subject: [PATCH 2184/3148] Align webdav with changes in BackupAgent (#139771) --- homeassistant/components/webdav/backup.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/webdav/backup.py b/homeassistant/components/webdav/backup.py index f810547022b..a5cf2c56182 100644 --- a/homeassistant/components/webdav/backup.py +++ b/homeassistant/components/webdav/backup.py @@ -144,8 +144,6 @@ class WebDavBackupAgent(BackupAgent): :return: An async iterator that yields bytes. """ backup = await self._find_backup_by_id(backup_id) - if backup is None: - raise BackupNotFound("Backup not found") return await self._client.download_iter( f"{self._backup_path}/{suggested_filename(backup)}", @@ -215,8 +213,6 @@ class WebDavBackupAgent(BackupAgent): :param backup_id: The ID of the backup that was returned in async_list_backups. """ backup = await self._find_backup_by_id(backup_id) - if backup is None: - return (filename_tar, filename_meta) = suggested_filenames(backup) backup_path = f"{self._backup_path}/{filename_tar}" @@ -243,7 +239,7 @@ class WebDavBackupAgent(BackupAgent): self, backup_id: str, **kwargs: Any, - ) -> AgentBackup | None: + ) -> AgentBackup: """Return a backup.""" return await self._find_backup_by_id(backup_id) @@ -269,13 +265,13 @@ class WebDavBackupAgent(BackupAgent): if (backup_id := _backup_id_from_properties(properties)) } - async def _find_backup_by_id(self, backup_id: str) -> AgentBackup | None: + async def _find_backup_by_id(self, backup_id: str) -> AgentBackup: """Find a backup by its backup ID on remote.""" metadata_files = await self._list_metadata_files() if metadata_file := metadata_files.get(backup_id): return await self._download_metadata(metadata_file) - return None + raise BackupNotFound(f"Backup {backup_id} not found") async def _download_metadata(self, path: str) -> AgentBackup: """Download metadata file.""" From 95fbba1d746d91099e7d3249a0d7c93a93abca48 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 4 Mar 2025 17:46:13 +0100 Subject: [PATCH 2185/3148] Align cloud with changes in BackupAgent (#139766) --- homeassistant/components/cloud/backup.py | 27 +++++++++++------------- 1 file changed, 12 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/cloud/backup.py b/homeassistant/components/cloud/backup.py index b31fe16fbe9..b83c4725663 100644 --- a/homeassistant/components/cloud/backup.py +++ b/homeassistant/components/cloud/backup.py @@ -18,7 +18,12 @@ from hass_nabucasa.cloud_api import ( ) from hass_nabucasa.files import FilesError, StorageType, calculate_b64md5 -from homeassistant.components.backup import AgentBackup, BackupAgent, BackupAgentError +from homeassistant.components.backup import ( + AgentBackup, + BackupAgent, + BackupAgentError, + BackupNotFound, +) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.aiohttp_client import ChunkAsyncStreamIterator from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -90,9 +95,7 @@ class CloudBackupAgent(BackupAgent): :param backup_id: The ID of the backup that was returned in async_list_backups. :return: An async iterator that yields bytes. """ - if not (backup := await self._async_get_backup(backup_id)): - raise BackupAgentError("Backup not found") - + backup = await self._async_get_backup(backup_id) try: content = await self._cloud.files.download( storage_type=StorageType.BACKUP, @@ -171,9 +174,7 @@ class CloudBackupAgent(BackupAgent): :param backup_id: The ID of the backup that was returned in async_list_backups. """ - if not (backup := await self._async_get_backup(backup_id)): - return - + backup = await self._async_get_backup(backup_id) try: await async_files_delete_file( self._cloud, @@ -204,16 +205,12 @@ class CloudBackupAgent(BackupAgent): self, backup_id: str, **kwargs: Any, - ) -> AgentBackup | None: + ) -> AgentBackup: """Return a backup.""" - if not (backup := await self._async_get_backup(backup_id)): - return None + backup = await self._async_get_backup(backup_id) return AgentBackup.from_dict(backup["Metadata"]) - async def _async_get_backup( - self, - backup_id: str, - ) -> FilesHandlerListEntry | None: + async def _async_get_backup(self, backup_id: str) -> FilesHandlerListEntry: """Return a backup.""" backups = await self._async_list_backups() @@ -221,4 +218,4 @@ class CloudBackupAgent(BackupAgent): if backup["Metadata"]["backup_id"] == backup_id: return backup - return None + raise BackupNotFound(f"Backup {backup_id} not found") From e1127fc78c3c9b91be65e2be64ad28605eb6a7d9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 4 Mar 2025 07:01:40 -1000 Subject: [PATCH 2186/3148] Bump nexia to 2.1.1 (#139772) changelog: https://github.com/bdraco/nexia/compare/2.0.9...2.1.1 fixes #133368 --- homeassistant/components/nexia/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/nexia/manifest.json b/homeassistant/components/nexia/manifest.json index 6a439f869c9..8a9cda14646 100644 --- a/homeassistant/components/nexia/manifest.json +++ b/homeassistant/components/nexia/manifest.json @@ -12,5 +12,5 @@ "documentation": "https://www.home-assistant.io/integrations/nexia", "iot_class": "cloud_polling", "loggers": ["nexia"], - "requirements": ["nexia==2.0.9"] + "requirements": ["nexia==2.1.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index e6e91eabbe3..1664f8c5299 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1483,7 +1483,7 @@ nettigo-air-monitor==4.0.0 neurio==0.3.1 # homeassistant.components.nexia -nexia==2.0.9 +nexia==2.1.1 # homeassistant.components.nextcloud nextcloudmonitor==1.5.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3f0115f78f4..299dfbb107e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1246,7 +1246,7 @@ netmap==0.7.0.2 nettigo-air-monitor==4.0.0 # homeassistant.components.nexia -nexia==2.0.9 +nexia==2.1.1 # homeassistant.components.nextcloud nextcloudmonitor==1.5.1 From e86fc88631fdb531ccc15ca2c25cebfcce127178 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 4 Mar 2025 18:20:55 +0100 Subject: [PATCH 2187/3148] Minor improvement of hassio backup tests (#139775) --- tests/components/hassio/test_backup.py | 264 ++++++++++++++++--------- 1 file changed, 168 insertions(+), 96 deletions(-) diff --git a/tests/components/hassio/test_backup.py b/tests/components/hassio/test_backup.py index 6e4fe4dd428..07a68b158d3 100644 --- a/tests/components/hassio/test_backup.py +++ b/tests/components/hassio/test_backup.py @@ -6,6 +6,7 @@ from collections.abc import ( Callable, Coroutine, Generator, + Iterable, ) from dataclasses import replace from datetime import datetime @@ -38,6 +39,7 @@ from homeassistant.components.backup import ( AgentBackup, BackupAgent, BackupAgentPlatformProtocol, + BackupNotFound, Folder, store as backup_store, ) @@ -326,43 +328,70 @@ async def setup_backup_integration( await hass.async_block_till_done() -class BackupAgentTest(BackupAgent): - """Test backup agent.""" +async def aiter_from_iter(iterable: Iterable) -> AsyncIterator: + """Convert an iterable to an async iterator.""" + for i in iterable: + yield i - def __init__(self, name: str, domain: str = "test") -> None: - """Initialize the backup agent.""" - self.domain = domain - self.name = name - self.unique_id = name - async def async_download_backup( - self, backup_id: str, **kwargs: Any - ) -> AsyncIterator[bytes]: - """Download a backup file.""" - return AsyncMock(spec_set=["__aiter__"]) +def mock_backup_agent( + name: str, domain: str = "test", backups: list[AgentBackup] | None = None +) -> Mock: + """Create a mock backup agent.""" - async def async_upload_backup( - self, + async def delete_backup(backup_id: str, **kwargs: Any) -> None: + """Mock delete.""" + get_backup(backup_id) + + async def download_backup(backup_id: str, **kwargs: Any) -> AsyncIterator[bytes]: + """Mock download.""" + return aiter_from_iter((backups_data.get(backup_id, b"backup data"),)) + + async def get_backup(backup_id: str, **kwargs: Any) -> AgentBackup: + """Get a backup.""" + backup = next((b for b in backups if b.backup_id == backup_id), None) + if backup is None: + raise BackupNotFound + return backup + + async def upload_backup( *, open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]], backup: AgentBackup, **kwargs: Any, ) -> None: """Upload a backup.""" - await open_stream() + backups.append(backup) + backup_stream = await open_stream() + backup_data = bytearray() + async for chunk in backup_stream: + backup_data += chunk + backups_data[backup.backup_id] = backup_data - async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]: - """List backups.""" - return [] - - async def async_get_backup( - self, backup_id: str, **kwargs: Any - ) -> AgentBackup | None: - """Return a backup.""" - return None - - async def async_delete_backup(self, backup_id: str, **kwargs: Any) -> None: - """Delete a backup file.""" + backups = backups or [] + backups_data: dict[str, bytes] = {} + mock_agent = Mock(spec=BackupAgent) + mock_agent.domain = domain + mock_agent.name = name + mock_agent.unique_id = name + type(mock_agent).agent_id = BackupAgent.agent_id + mock_agent.async_delete_backup = AsyncMock( + side_effect=delete_backup, spec_set=[BackupAgent.async_delete_backup] + ) + mock_agent.async_download_backup = AsyncMock( + side_effect=download_backup, spec_set=[BackupAgent.async_download_backup] + ) + mock_agent.async_get_backup = AsyncMock( + side_effect=get_backup, spec_set=[BackupAgent.async_get_backup] + ) + mock_agent.async_list_backups = AsyncMock( + return_value=backups, spec_set=[BackupAgent.async_list_backups] + ) + mock_agent.async_upload_backup = AsyncMock( + side_effect=upload_backup, + spec_set=[BackupAgent.async_upload_backup], + ) + return mock_agent async def _setup_backup_platform( @@ -383,7 +412,7 @@ async def _setup_backup_platform( [ ( MountsInfo(default_backup_mount=None, mounts=[]), - [BackupAgentTest("local", DOMAIN)], + [mock_backup_agent("local", DOMAIN)], ), ( MountsInfo( @@ -401,7 +430,7 @@ async def _setup_backup_platform( ) ], ), - [BackupAgentTest("local", DOMAIN), BackupAgentTest("test", DOMAIN)], + [mock_backup_agent("local", DOMAIN), mock_backup_agent("test", DOMAIN)], ), ( MountsInfo( @@ -419,7 +448,7 @@ async def _setup_backup_platform( ) ], ), - [BackupAgentTest("local", DOMAIN)], + [mock_backup_agent("local", DOMAIN)], ), ], ) @@ -576,40 +605,13 @@ async def test_agent_upload( ) -> None: """Test agent upload backup.""" client = await hass_client() - backup_id = "test-backup" supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS - test_backup = AgentBackup( - addons=[AddonInfo(name="Test", slug="test", version="1.0.0")], - backup_id=backup_id, - database_included=True, - date="1970-01-01T00:00:00.000Z", - extra_metadata={}, - folders=[Folder.MEDIA, Folder.SHARE], - homeassistant_included=True, - homeassistant_version="2024.12.0", - name="Test", - protected=False, - size=0, - ) supervisor_client.backups.reload.assert_not_called() - with ( - patch("pathlib.Path.mkdir"), - patch("pathlib.Path.open"), - patch( - "homeassistant.components.backup.manager.BackupManager.async_get_backup", - ) as fetch_backup, - patch( - "homeassistant.components.backup.manager.read_backup", - return_value=test_backup, - ), - patch("shutil.copy"), - ): - fetch_backup.return_value = test_backup - resp = await client.post( - "/api/backup/upload?agent_id=hassio.local", - data={"file": StringIO("test")}, - ) + resp = await client.post( + "/api/backup/upload?agent_id=hassio.local", + data={"file": StringIO("test")}, + ) assert resp.status == 201 supervisor_client.backups.reload.assert_not_called() @@ -1551,7 +1553,7 @@ async def test_reader_writer_create_download_remove_error( method_mock = getattr(supervisor_client.backups, method) method_mock.side_effect = exception - remote_agent = BackupAgentTest("remote") + remote_agent = mock_backup_agent("remote") await _setup_backup_platform( hass, domain="test", @@ -1636,7 +1638,7 @@ async def test_reader_writer_create_info_error( supervisor_client.backups.backup_info.side_effect = exception supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE - remote_agent = BackupAgentTest("remote") + remote_agent = mock_backup_agent("remote") await _setup_backup_platform( hass, domain="test", @@ -1713,7 +1715,7 @@ async def test_reader_writer_create_remote_backup( supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS_5 supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE - remote_agent = BackupAgentTest("remote") + remote_agent = mock_backup_agent("remote") await _setup_backup_platform( hass, domain="test", @@ -1861,24 +1863,10 @@ async def test_agent_receive_remote_backup( ) -> None: """Test receiving a backup which will be uploaded to a remote agent.""" client = await hass_client() - backup_id = "test-backup" supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS_5 supervisor_client.backups.upload_backup.return_value = "test_slug" - test_backup = AgentBackup( - addons=[AddonInfo(name="Test", slug="test", version="1.0.0")], - backup_id=backup_id, - database_included=True, - date="1970-01-01T00:00:00.000Z", - extra_metadata={}, - folders=[Folder.MEDIA, Folder.SHARE], - homeassistant_included=True, - homeassistant_version="2024.12.0", - name="Test", - protected=False, - size=0.0, - ) - remote_agent = BackupAgentTest("remote") + remote_agent = mock_backup_agent("remote") await _setup_backup_platform( hass, domain="test", @@ -1889,23 +1877,10 @@ async def test_agent_receive_remote_backup( ) supervisor_client.backups.reload.assert_not_called() - with ( - patch("pathlib.Path.mkdir"), - patch("pathlib.Path.open"), - patch( - "homeassistant.components.backup.manager.BackupManager.async_get_backup", - ) as fetch_backup, - patch( - "homeassistant.components.backup.manager.read_backup", - return_value=test_backup, - ), - patch("shutil.copy"), - ): - fetch_backup.return_value = test_backup - resp = await client.post( - "/api/backup/upload?agent_id=test.remote", - data={"file": StringIO("test")}, - ) + resp = await client.post( + "/api/backup/upload?agent_id=test.remote", + data={"file": StringIO("test")}, + ) assert resp.status == 201 @@ -1996,6 +1971,103 @@ async def test_reader_writer_restore( assert response["result"] is None +@pytest.mark.usefixtures("hassio_client", "setup_backup_integration") +async def test_reader_writer_restore_remote_backup( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + supervisor_client: AsyncMock, +) -> None: + """Test restoring a backup from a remote agent.""" + client = await hass_ws_client(hass) + supervisor_client.backups.partial_restore.return_value.job_id = UUID(TEST_JOB_ID) + supervisor_client.backups.list.return_value = [TEST_BACKUP_5] + supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS_5 + supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE + + backup_id = "abc123" + test_backup = AgentBackup( + addons=[AddonInfo(name="Test", slug="test", version="1.0.0")], + backup_id=backup_id, + database_included=True, + date="1970-01-01T00:00:00.000Z", + extra_metadata={}, + folders=[Folder.MEDIA, Folder.SHARE], + homeassistant_included=True, + homeassistant_version="2024.12.0", + name="Test", + protected=False, + size=0.0, + ) + remote_agent = mock_backup_agent("remote", backups=[test_backup]) + await _setup_backup_platform( + hass, + domain="test", + platform=Mock( + async_get_backup_agents=AsyncMock(return_value=[remote_agent]), + spec_set=BackupAgentPlatformProtocol, + ), + ) + + await client.send_json_auto_id({"type": "backup/subscribe_events"}) + response = await client.receive_json() + assert response["event"] == { + "manager_state": "idle", + } + response = await client.receive_json() + assert response["success"] + + await client.send_json_auto_id( + {"type": "backup/restore", "agent_id": "test.remote", "backup_id": backup_id} + ) + response = await client.receive_json() + assert response["event"] == { + "manager_state": "restore_backup", + "reason": None, + "stage": None, + "state": "in_progress", + } + + remote_agent.async_download_backup.assert_called_once_with(backup_id) + assert len(remote_agent.async_get_backup.mock_calls) == 2 + for call in remote_agent.async_get_backup.mock_calls: + assert call.args[0] == backup_id + supervisor_client.backups.partial_restore.assert_called_once_with( + backup_id, + supervisor_backups.PartialRestoreOptions( + addons=None, + background=True, + folders=None, + homeassistant=True, + location=LOCATION_CLOUD_BACKUP, + password=None, + ), + ) + + await client.send_json_auto_id( + { + "type": "supervisor/event", + "data": {"event": "job", "data": {"done": True, "uuid": TEST_JOB_ID}}, + } + ) + response = await client.receive_json() + assert response["success"] + + response = await client.receive_json() + assert response["event"] == { + "manager_state": "restore_backup", + "reason": None, + "stage": None, + "state": "completed", + } + + response = await client.receive_json() + assert response["event"] == {"manager_state": "idle"} + + response = await client.receive_json() + assert response["success"] + assert response["result"] is None + + @pytest.mark.usefixtures("hassio_client", "setup_backup_integration") async def test_reader_writer_restore_report_progress( hass: HomeAssistant, From c0d882e3059e9e4918504a78100f5318e214b1c6 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Tue, 4 Mar 2025 19:19:38 +0100 Subject: [PATCH 2188/3148] Upload test result artifacts always (#139776) Upload test results artificats always --- .github/workflows/ci.yaml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index d0a214814ee..cf7b80540a1 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1001,7 +1001,7 @@ jobs: path: coverage.xml overwrite: true - name: Upload test results artifact - if: needs.info.outputs.skip_coverage != 'true' + if: needs.info.outputs.skip_coverage != 'true' && !cancelled() uses: actions/upload-artifact@v4.6.0 with: name: test-results-full-${{ matrix.python-version }}-${{ matrix.group }} @@ -1135,7 +1135,7 @@ jobs: path: coverage.xml overwrite: true - name: Upload test results artifact - if: needs.info.outputs.skip_coverage != 'true' + if: needs.info.outputs.skip_coverage != 'true' && !cancelled() uses: actions/upload-artifact@v4.6.0 with: name: test-results-mariadb-${{ matrix.python-version }}-${{ @@ -1271,7 +1271,7 @@ jobs: path: coverage.xml overwrite: true - name: Upload test results artifact - if: needs.info.outputs.skip_coverage != 'true' + if: needs.info.outputs.skip_coverage != 'true' && !cancelled() uses: actions/upload-artifact@v4.6.0 with: name: test-results-postgres-${{ matrix.python-version }}-${{ @@ -1417,7 +1417,7 @@ jobs: path: coverage.xml overwrite: true - name: Upload test results artifact - if: needs.info.outputs.skip_coverage != 'true' + if: needs.info.outputs.skip_coverage != 'true' && !cancelled() uses: actions/upload-artifact@v4.6.0 with: name: test-results-partial-${{ matrix.python-version }}-${{ matrix.group }} From 344cfedd6ff88152668462758d69f24791d56134 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 4 Mar 2025 19:22:18 +0100 Subject: [PATCH 2189/3148] Align synology_dsm with changes in BackupAgent (#139770) --- homeassistant/components/synology_dsm/backup.py | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/synology_dsm/backup.py b/homeassistant/components/synology_dsm/backup.py index 670c4c9bef0..c4b44542059 100644 --- a/homeassistant/components/synology_dsm/backup.py +++ b/homeassistant/components/synology_dsm/backup.py @@ -120,8 +120,7 @@ class SynologyDSMBackupAgent(BackupAgent): :param backup_id: The ID of the backup that was returned in async_list_backups. :return: A tuple of tar_filename and meta_filename """ - if await self.async_get_backup(backup_id) is None: - raise BackupNotFound + await self.async_get_backup(backup_id) base_name = self.backup_base_names[backup_id] return (f"{base_name}.tar", f"{base_name}_meta.json") @@ -195,13 +194,7 @@ class SynologyDSMBackupAgent(BackupAgent): :param backup_id: The ID of the backup that was returned in async_list_backups. """ - try: - (filename_tar, filename_meta) = await self._async_backup_filenames( - backup_id - ) - except BackupAgentError: - # backup meta data could not be found, so we can't delete the backup - return + (filename_tar, filename_meta) = await self._async_backup_filenames(backup_id) for filename in (filename_tar, filename_meta): try: @@ -269,7 +262,9 @@ class SynologyDSMBackupAgent(BackupAgent): self, backup_id: str, **kwargs: Any, - ) -> AgentBackup | None: + ) -> AgentBackup: """Return a backup.""" backups = await self._async_list_backups() - return backups.get(backup_id) + if backup_id not in backups: + raise BackupNotFound(f"Backup {backup_id} not found") + return backups[backup_id] From e8099fd3b2f19106fe8fd53da3c44a7062fdc14e Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Tue, 4 Mar 2025 19:26:20 +0100 Subject: [PATCH 2190/3148] Fix home connect available (#139760) * Fix home connect available * Extend and clarify test * Do not change connected state on stream interrupted --- .../components/home_connect/coordinator.py | 13 +- .../components/home_connect/entity.py | 16 ++- tests/components/home_connect/__init__.py | 18 +++ tests/components/home_connect/conftest.py | 21 +-- .../home_connect/test_coordinator.py | 132 +++++++++++++++++- 5 files changed, 177 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/home_connect/coordinator.py b/homeassistant/components/home_connect/coordinator.py index 4d275854e30..7898fb7be12 100644 --- a/homeassistant/components/home_connect/coordinator.py +++ b/homeassistant/components/home_connect/coordinator.py @@ -98,6 +98,7 @@ class HomeConnectCoordinator( CALLBACK_TYPE, tuple[CALLBACK_TYPE, tuple[EventKey, ...]] ] = {} self.device_registry = dr.async_get(self.hass) + self.data = {} @cached_property def context_listeners(self) -> dict[tuple[str, EventKey], list[CALLBACK_TYPE]]: @@ -161,6 +162,14 @@ class HomeConnectCoordinator( async for event_message in self.client.stream_all_events(): retry_time = 10 event_message_ha_id = event_message.ha_id + if ( + event_message_ha_id in self.data + and not self.data[event_message_ha_id].info.connected + ): + self.data[event_message_ha_id].info.connected = True + self._call_all_event_listeners_for_appliance( + event_message_ha_id + ) match event_message.type: case EventType.STATUS: statuses = self.data[event_message_ha_id].status @@ -295,6 +304,8 @@ class HomeConnectCoordinator( translation_placeholders=get_dict_from_home_connect_error(error), ) from error except HomeConnectError as error: + for appliance_data in self.data.values(): + appliance_data.info.connected = False raise UpdateFailed( translation_domain=DOMAIN, translation_key="fetch_api_error", @@ -303,7 +314,7 @@ class HomeConnectCoordinator( return { appliance.ha_id: await self._get_appliance_data( - appliance, self.data.get(appliance.ha_id) if self.data else None + appliance, self.data.get(appliance.ha_id) ) for appliance in appliances.homeappliances } diff --git a/homeassistant/components/home_connect/entity.py b/homeassistant/components/home_connect/entity.py index 52eaaecace7..b55ff374f34 100644 --- a/homeassistant/components/home_connect/entity.py +++ b/homeassistant/components/home_connect/entity.py @@ -8,6 +8,7 @@ from typing import cast from aiohomeconnect.model import EventKey, OptionKey from aiohomeconnect.model.error import ActiveProgramNotSetError, HomeConnectError +from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceInfo @@ -51,8 +52,10 @@ class HomeConnectEntity(CoordinatorEntity[HomeConnectCoordinator]): def _handle_coordinator_update(self) -> None: """Handle updated data from the coordinator.""" self.update_native_value() + available = self._attr_available = self.appliance.info.connected self.async_write_ha_state() - _LOGGER.debug("Updated %s, new state: %s", self.entity_id, self.state) + state = STATE_UNAVAILABLE if not available else self.state + _LOGGER.debug("Updated %s, new state: %s", self.entity_id, state) @property def bsh_key(self) -> str: @@ -61,10 +64,13 @@ class HomeConnectEntity(CoordinatorEntity[HomeConnectCoordinator]): @property def available(self) -> bool: - """Return True if entity is available.""" - return ( - self.appliance.info.connected and self._attr_available and super().available - ) + """Return True if entity is available. + + Do not use self.last_update_success for available state + as event updates should take precedence over the coordinator + refresh. + """ + return self._attr_available class HomeConnectOptionEntity(HomeConnectEntity): diff --git a/tests/components/home_connect/__init__.py b/tests/components/home_connect/__init__.py index 2b61501c59a..47a438fd218 100644 --- a/tests/components/home_connect/__init__.py +++ b/tests/components/home_connect/__init__.py @@ -1 +1,19 @@ """Tests for the Home Connect integration.""" + +from typing import Any + +from aiohomeconnect.model import ArrayOfHomeAppliances, ArrayOfStatus + +from tests.common import load_json_object_fixture + +MOCK_APPLIANCES = ArrayOfHomeAppliances.from_dict( + load_json_object_fixture("home_connect/appliances.json")["data"] # type: ignore[arg-type] +) +MOCK_PROGRAMS: dict[str, Any] = load_json_object_fixture("home_connect/programs.json") +MOCK_SETTINGS: dict[str, Any] = load_json_object_fixture("home_connect/settings.json") +MOCK_STATUS = ArrayOfStatus.from_dict( + load_json_object_fixture("home_connect/status.json")["data"] # type: ignore[arg-type] +) +MOCK_AVAILABLE_COMMANDS: dict[str, Any] = load_json_object_fixture( + "home_connect/available_commands.json" +) diff --git a/tests/components/home_connect/conftest.py b/tests/components/home_connect/conftest.py index 49cbc89ba41..396fe8c5665 100644 --- a/tests/components/home_connect/conftest.py +++ b/tests/components/home_connect/conftest.py @@ -11,11 +11,9 @@ from aiohomeconnect.client import Client as HomeConnectClient from aiohomeconnect.model import ( ArrayOfCommands, ArrayOfEvents, - ArrayOfHomeAppliances, ArrayOfOptions, ArrayOfPrograms, ArrayOfSettings, - ArrayOfStatus, Event, EventKey, EventMessage, @@ -41,20 +39,15 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from tests.common import MockConfigEntry, load_json_object_fixture - -MOCK_APPLIANCES = ArrayOfHomeAppliances.from_dict( - load_json_object_fixture("home_connect/appliances.json")["data"] -) -MOCK_PROGRAMS: dict[str, Any] = load_json_object_fixture("home_connect/programs.json") -MOCK_SETTINGS: dict[str, Any] = load_json_object_fixture("home_connect/settings.json") -MOCK_STATUS = ArrayOfStatus.from_dict( - load_json_object_fixture("home_connect/status.json")["data"] -) -MOCK_AVAILABLE_COMMANDS: dict[str, Any] = load_json_object_fixture( - "home_connect/available_commands.json" +from . import ( + MOCK_APPLIANCES, + MOCK_AVAILABLE_COMMANDS, + MOCK_PROGRAMS, + MOCK_SETTINGS, + MOCK_STATUS, ) +from tests.common import MockConfigEntry CLIENT_ID = "1234" CLIENT_SECRET = "5678" diff --git a/tests/components/home_connect/test_coordinator.py b/tests/components/home_connect/test_coordinator.py index ac27b848a36..1a49d2bb2a0 100644 --- a/tests/components/home_connect/test_coordinator.py +++ b/tests/components/home_connect/test_coordinator.py @@ -1,6 +1,7 @@ """Test for Home Connect coordinator.""" from collections.abc import Awaitable, Callable +import copy from datetime import timedelta from typing import Any from unittest.mock import AsyncMock, MagicMock, patch @@ -20,6 +21,7 @@ from aiohomeconnect.model.error import ( HomeConnectError, HomeConnectRequestError, ) +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components.home_connect.const import ( @@ -36,8 +38,11 @@ from homeassistant.core import ( callback, ) from homeassistant.helpers import entity_registry as er +from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util +from . import MOCK_APPLIANCES + from tests.common import MockConfigEntry, async_fire_time_changed @@ -74,6 +79,123 @@ async def test_coordinator_update_failing_get_appliances( assert config_entry.state == ConfigEntryState.SETUP_RETRY +@pytest.mark.usefixtures("setup_credentials") +@pytest.mark.parametrize("platforms", [("binary_sensor",)]) +@pytest.mark.parametrize("appliance_ha_id", ["Washer"], indirect=True) +async def test_coordinator_failure_refresh_and_stream( + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + client: MagicMock, + freezer: FrozenDateTimeFactory, + appliance_ha_id: str, +) -> None: + """Test entity available state via coordinator refresh and event stream.""" + entity_id_1 = "binary_sensor.washer_remote_control" + entity_id_2 = "binary_sensor.washer_remote_start" + await async_setup_component(hass, "homeassistant", {}) + await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + state = hass.states.get(entity_id_1) + assert state + assert state.state != "unavailable" + state = hass.states.get(entity_id_2) + assert state + assert state.state != "unavailable" + + client.get_home_appliances.side_effect = HomeConnectError() + + # Force a coordinator refresh. + await hass.services.async_call( + "homeassistant", "update_entity", {"entity_id": entity_id_1}, blocking=True + ) + await hass.async_block_till_done() + + state = hass.states.get(entity_id_1) + assert state + assert state.state == "unavailable" + state = hass.states.get(entity_id_2) + assert state + assert state.state == "unavailable" + + # Test that the entity becomes available again after a successful update. + + client.get_home_appliances.side_effect = None + client.get_home_appliances.return_value = copy.deepcopy(MOCK_APPLIANCES) + + # Move time forward to pass the debounce time. + freezer.tick(timedelta(hours=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # Force a coordinator refresh. + await hass.services.async_call( + "homeassistant", "update_entity", {"entity_id": entity_id_1}, blocking=True + ) + await hass.async_block_till_done() + + state = hass.states.get(entity_id_1) + assert state + assert state.state != "unavailable" + state = hass.states.get(entity_id_2) + assert state + assert state.state != "unavailable" + + # Test that the event stream makes the entity go available too. + + # First make the entity unavailable. + client.get_home_appliances.side_effect = HomeConnectError() + + # Move time forward to pass the debounce time + freezer.tick(timedelta(hours=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # Force a coordinator refresh + await hass.services.async_call( + "homeassistant", "update_entity", {"entity_id": entity_id_1}, blocking=True + ) + await hass.async_block_till_done() + + state = hass.states.get(entity_id_1) + assert state + assert state.state == "unavailable" + state = hass.states.get(entity_id_2) + assert state + assert state.state == "unavailable" + + # Now make the entity available again. + client.get_home_appliances.side_effect = None + client.get_home_appliances.return_value = copy.deepcopy(MOCK_APPLIANCES) + + # One event should make all entities for this appliance available again. + event_message = EventMessage( + appliance_ha_id, + EventType.STATUS, + ArrayOfEvents( + [ + Event( + key=EventKey.BSH_COMMON_STATUS_REMOTE_CONTROL_ACTIVE, + raw_key=EventKey.BSH_COMMON_STATUS_REMOTE_CONTROL_ACTIVE.value, + timestamp=0, + level="", + handling="", + value=False, + ) + ], + ), + ) + await client.add_events([event_message]) + await hass.async_block_till_done() + + state = hass.states.get(entity_id_1) + assert state + assert state.state != "unavailable" + state = hass.states.get(entity_id_2) + assert state + assert state.state != "unavailable" + + @pytest.mark.parametrize( "mock_method", [ @@ -330,11 +452,13 @@ async def test_event_listener_resilience( assert config_entry.state == ConfigEntryState.LOADED assert len(config_entry._background_tasks) == 1 - assert hass.states.is_state(entity_id, initial_state) + state = hass.states.get(entity_id) + assert state + assert state.state == initial_state - await hass.async_block_till_done() future.set_exception(exception) await hass.async_block_till_done() + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=30)) await hass.async_block_till_done() @@ -362,4 +486,6 @@ async def test_event_listener_resilience( ) await hass.async_block_till_done() - assert hass.states.is_state(entity_id, after_event_expected_state) + state = hass.states.get(entity_id) + assert state + assert state.state == after_event_expected_state From be3d678f23bdea5db1286d9ce92401ff4d6ab026 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 4 Mar 2025 20:20:49 +0100 Subject: [PATCH 2191/3148] Align hassio with changes in BackupAgent (#139780) --- homeassistant/components/hassio/backup.py | 36 +++++++++++++---------- 1 file changed, 20 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/hassio/backup.py b/homeassistant/components/hassio/backup.py index fe69b9e08e5..20f1ec82a7a 100644 --- a/homeassistant/components/hassio/backup.py +++ b/homeassistant/components/hassio/backup.py @@ -4,6 +4,7 @@ from __future__ import annotations import asyncio from collections.abc import AsyncIterator, Callable, Coroutine, Mapping +from contextlib import suppress import logging import os from pathlib import Path, PurePath @@ -173,7 +174,7 @@ class SupervisorBackupAgent(BackupAgent): ), ) except SupervisorNotFoundError as err: - raise BackupNotFound from err + raise BackupNotFound(f"Backup {backup_id} not found") from err async def async_upload_backup( self, @@ -186,13 +187,14 @@ class SupervisorBackupAgent(BackupAgent): The upload will be skipped if the backup already exists in the agent's location. """ - if await self.async_get_backup(backup.backup_id): - _LOGGER.debug( - "Backup %s already exists in location %s", - backup.backup_id, - self.location, - ) - return + with suppress(BackupNotFound): + if await self.async_get_backup(backup.backup_id): + _LOGGER.debug( + "Backup %s already exists in location %s", + backup.backup_id, + self.location, + ) + return stream = await open_stream() upload_options = supervisor_backups.UploadBackupOptions( location={self.location}, @@ -218,14 +220,14 @@ class SupervisorBackupAgent(BackupAgent): self, backup_id: str, **kwargs: Any, - ) -> AgentBackup | None: + ) -> AgentBackup: """Return a backup.""" try: details = await self._client.backups.backup_info(backup_id) - except SupervisorNotFoundError: - return None + except SupervisorNotFoundError as err: + raise BackupNotFound(f"Backup {backup_id} not found") from err if self.location not in details.location_attributes: - return None + raise BackupNotFound(f"Backup {backup_id} not found") return _backup_details_to_agent_backup(details, self.location) async def async_delete_backup(self, backup_id: str, **kwargs: Any) -> None: @@ -237,8 +239,8 @@ class SupervisorBackupAgent(BackupAgent): location={self.location} ), ) - except SupervisorNotFoundError: - _LOGGER.debug("Backup %s does not exist", backup_id) + except SupervisorNotFoundError as err: + raise BackupNotFound(f"Backup {backup_id} not found") from err class SupervisorBackupReaderWriter(BackupReaderWriter): @@ -492,10 +494,12 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): ) -> None: """Restore a backup.""" manager = self._hass.data[DATA_MANAGER] - # The backup manager has already checked that the backup exists so we don't need to - # check that here. + # The backup manager has already checked that the backup exists so we don't + # need to catch BackupNotFound here. backup = await manager.backup_agents[agent_id].async_get_backup(backup_id) if ( + # Check for None to be backwards compatible with the old BackupAgent API, + # this can be removed in HA Core 2025.10 backup and restore_homeassistant and restore_database != backup.database_included From 7359013db0923054a8502995076a678450e650ac Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 4 Mar 2025 20:24:36 +0100 Subject: [PATCH 2192/3148] Move ForkedDaapdUpdater setup to __init__ module (#139733) * Move ForkedDaapdUpdater setup to __init__ module * Adjust tests * One more --- .../components/forked_daapd/__init__.py | 18 ++++++++++++++- .../components/forked_daapd/coordinator.py | 5 ++++ .../components/forked_daapd/media_player.py | 23 ++++++------------- .../forked_daapd/test_browse_media.py | 8 +++---- .../forked_daapd/test_config_flow.py | 19 ++++++++------- .../forked_daapd/test_media_player.py | 4 ++-- 6 files changed, 44 insertions(+), 33 deletions(-) diff --git a/homeassistant/components/forked_daapd/__init__.py b/homeassistant/components/forked_daapd/__init__.py index 2172e60ba38..16fd96ee365 100644 --- a/homeassistant/components/forked_daapd/__init__.py +++ b/homeassistant/components/forked_daapd/__init__.py @@ -1,16 +1,32 @@ """The forked_daapd component.""" +from pyforked_daapd import ForkedDaapdAPI + from homeassistant.config_entries import ConfigEntry -from homeassistant.const import Platform +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DOMAIN, HASS_DATA_UPDATER_KEY +from .coordinator import ForkedDaapdUpdater PLATFORMS = [Platform.MEDIA_PLAYER] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up forked-daapd from a config entry by forwarding to platform.""" + host: str = entry.data[CONF_HOST] + port: int = entry.data[CONF_PORT] + password: str = entry.data[CONF_PASSWORD] + forked_daapd_api = ForkedDaapdAPI( + async_get_clientsession(hass), host, port, password + ) + forked_daapd_updater = ForkedDaapdUpdater(hass, forked_daapd_api, entry.entry_id) + if not hass.data.get(DOMAIN): + hass.data[DOMAIN] = {entry.entry_id: {}} + hass.data.setdefault(DOMAIN, {}).setdefault(entry.entry_id, {})[ + HASS_DATA_UPDATER_KEY + ] = forked_daapd_updater await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/forked_daapd/coordinator.py b/homeassistant/components/forked_daapd/coordinator.py index 2db0a75c429..246ad1caa7d 100644 --- a/homeassistant/components/forked_daapd/coordinator.py +++ b/homeassistant/components/forked_daapd/coordinator.py @@ -39,6 +39,11 @@ class ForkedDaapdUpdater: self._all_output_ids: set[str] = set() self._entry_id = entry_id + @property + def api(self) -> ForkedDaapdAPI: + """Return the API object.""" + return self._api + async def async_init(self) -> None: """Perform async portion of class initialization.""" if not (server_config := await self._api.get_request("config")): diff --git a/homeassistant/components/forked_daapd/media_player.py b/homeassistant/components/forked_daapd/media_player.py index 8cbf33460aa..90a04dbc177 100644 --- a/homeassistant/components/forked_daapd/media_player.py +++ b/homeassistant/components/forked_daapd/media_player.py @@ -7,7 +7,6 @@ from collections import defaultdict import logging from typing import Any -from pyforked_daapd import ForkedDaapdAPI from pylibrespot_java import LibrespotJavaAPI from homeassistant.components import media_source @@ -29,7 +28,7 @@ from homeassistant.components.spotify import ( spotify_uri_from_media_browser_url, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT +from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.dispatcher import ( @@ -85,12 +84,12 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up forked-daapd from a config entry.""" + forked_daapd_updater: ForkedDaapdUpdater = hass.data[DOMAIN][config_entry.entry_id][ + HASS_DATA_UPDATER_KEY + ] + host: str = config_entry.data[CONF_HOST] - port: int = config_entry.data[CONF_PORT] - password: str = config_entry.data[CONF_PASSWORD] - forked_daapd_api = ForkedDaapdAPI( - async_get_clientsession(hass), host, port, password - ) + forked_daapd_api = forked_daapd_updater.api forked_daapd_master = ForkedDaapdMaster( clientsession=async_get_clientsession(hass), api=forked_daapd_api, @@ -111,16 +110,8 @@ async def async_setup_entry( ) config_entry.async_on_unload(config_entry.add_update_listener(update_listener)) - if not hass.data.get(DOMAIN): - hass.data[DOMAIN] = {config_entry.entry_id: {}} - async_add_entities([forked_daapd_master], False) - forked_daapd_updater = ForkedDaapdUpdater( - hass, forked_daapd_api, config_entry.entry_id - ) - hass.data[DOMAIN][config_entry.entry_id][HASS_DATA_UPDATER_KEY] = ( - forked_daapd_updater - ) + await forked_daapd_updater.async_init() diff --git a/tests/components/forked_daapd/test_browse_media.py b/tests/components/forked_daapd/test_browse_media.py index cbd278128ae..88b29c2bbba 100644 --- a/tests/components/forked_daapd/test_browse_media.py +++ b/tests/components/forked_daapd/test_browse_media.py @@ -34,7 +34,7 @@ async def test_async_browse_media( await hass.async_block_till_done() with patch( - "homeassistant.components.forked_daapd.media_player.ForkedDaapdAPI", + "homeassistant.components.forked_daapd.ForkedDaapdAPI", autospec=True, ) as mock_api: mock_api.return_value.get_request.return_value = {"websocket_port": 2} @@ -214,7 +214,7 @@ async def test_async_browse_media_not_found( await hass.async_block_till_done() with patch( - "homeassistant.components.forked_daapd.media_player.ForkedDaapdAPI", + "homeassistant.components.forked_daapd.ForkedDaapdAPI", autospec=True, ) as mock_api: mock_api.return_value.get_request.return_value = {"websocket_port": 2} @@ -375,7 +375,7 @@ async def test_async_browse_image( """Test browse media images.""" with patch( - "homeassistant.components.forked_daapd.media_player.ForkedDaapdAPI", + "homeassistant.components.forked_daapd.ForkedDaapdAPI", autospec=True, ) as mock_api: mock_api.return_value.get_request.return_value = {"websocket_port": 2} @@ -430,7 +430,7 @@ async def test_async_browse_image_missing( """Test browse media images with no image available.""" with patch( - "homeassistant.components.forked_daapd.media_player.ForkedDaapdAPI", + "homeassistant.components.forked_daapd.ForkedDaapdAPI", autospec=True, ) as mock_api: mock_api.return_value.get_request.return_value = {"websocket_port": 2} diff --git a/tests/components/forked_daapd/test_config_flow.py b/tests/components/forked_daapd/test_config_flow.py index 8bf5de31da2..ba1f0e6c227 100644 --- a/tests/components/forked_daapd/test_config_flow.py +++ b/tests/components/forked_daapd/test_config_flow.py @@ -1,7 +1,7 @@ """The config flow tests for the forked_daapd media player platform.""" from ipaddress import ip_address -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock, patch import pytest @@ -12,12 +12,10 @@ from homeassistant.components.forked_daapd.const import ( CONF_TTS_VOLUME, DOMAIN, ) -from homeassistant.components.forked_daapd.media_player import async_setup_entry -from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF +from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF, ConfigEntryState from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from tests.common import MockConfigEntry @@ -75,7 +73,7 @@ async def test_config_flow(hass: HomeAssistant, config_entry: MockConfigEntry) - new=AsyncMock(), ) as mock_test_connection, patch( - "homeassistant.components.forked_daapd.media_player.ForkedDaapdAPI.get_request", + "homeassistant.components.forked_daapd.ForkedDaapdAPI.get_request", autospec=True, ) as mock_get_request, ): @@ -232,7 +230,7 @@ async def test_options_flow(hass: HomeAssistant, config_entry: MockConfigEntry) """Test config flow options.""" with patch( - "homeassistant.components.forked_daapd.media_player.ForkedDaapdAPI.get_request", + "homeassistant.components.forked_daapd.ForkedDaapdAPI.get_request", autospec=True, ) as mock_get_request: mock_get_request.return_value = SAMPLE_CONFIG @@ -256,17 +254,18 @@ async def test_options_flow(hass: HomeAssistant, config_entry: MockConfigEntry) async def test_async_setup_entry_not_ready( - hass: HomeAssistant, config_entry: MockConfigEntry + hass: HomeAssistant, config_entry: MockConfigEntry, caplog: pytest.LogCaptureFixture ) -> None: """Test that a PlatformNotReady exception is thrown during platform setup.""" with patch( - "homeassistant.components.forked_daapd.media_player.ForkedDaapdAPI", + "homeassistant.components.forked_daapd.ForkedDaapdAPI", autospec=True, ) as mock_api: mock_api.return_value.get_request.return_value = None config_entry.add_to_hass(hass) - with pytest.raises(PlatformNotReady): - await async_setup_entry(hass, config_entry, MagicMock()) + await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() mock_api.return_value.get_request.assert_called_once() + assert "Platform forked_daapd not ready yet" in caplog.text + assert config_entry.state is ConfigEntryState.LOADED diff --git a/tests/components/forked_daapd/test_media_player.py b/tests/components/forked_daapd/test_media_player.py index 6d7d267eb63..8f0105d48d7 100644 --- a/tests/components/forked_daapd/test_media_player.py +++ b/tests/components/forked_daapd/test_media_player.py @@ -313,7 +313,7 @@ async def mock_api_object_fixture( return get_request_return_values[update_type] with patch( - "homeassistant.components.forked_daapd.media_player.ForkedDaapdAPI", + "homeassistant.components.forked_daapd.ForkedDaapdAPI", autospec=True, ) as mock_api: mock_api.return_value.get_request.side_effect = get_request_side_effect @@ -808,7 +808,7 @@ async def test_invalid_websocket_port( ) -> None: """Test invalid websocket port on async_init.""" with patch( - "homeassistant.components.forked_daapd.media_player.ForkedDaapdAPI", + "homeassistant.components.forked_daapd.ForkedDaapdAPI", autospec=True, ) as mock_api: mock_api.return_value.get_request.return_value = SAMPLE_CONFIG_NO_WEBSOCKET From 3b9bb9678408da7c5d6b0f22c276ef9742637a58 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 4 Mar 2025 20:45:10 +0100 Subject: [PATCH 2193/3148] Align google_drive with changes in BackupAgent (#139767) --- homeassistant/components/google_drive/backup.py | 15 +++++++++++---- tests/components/google_drive/test_backup.py | 4 ++-- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/google_drive/backup.py b/homeassistant/components/google_drive/backup.py index 73e5902f8f5..a4b7fc956ce 100644 --- a/homeassistant/components/google_drive/backup.py +++ b/homeassistant/components/google_drive/backup.py @@ -8,7 +8,12 @@ from typing import Any from google_drive_api.exceptions import GoogleDriveApiError -from homeassistant.components.backup import AgentBackup, BackupAgent, BackupAgentError +from homeassistant.components.backup import ( + AgentBackup, + BackupAgent, + BackupAgentError, + BackupNotFound, +) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import ChunkAsyncStreamIterator @@ -93,13 +98,13 @@ class GoogleDriveBackupAgent(BackupAgent): self, backup_id: str, **kwargs: Any, - ) -> AgentBackup | None: + ) -> AgentBackup: """Return a backup.""" backups = await self.async_list_backups() for backup in backups: if backup.backup_id == backup_id: return backup - return None + raise BackupNotFound(f"Backup {backup_id} not found") async def async_download_backup( self, @@ -120,7 +125,7 @@ class GoogleDriveBackupAgent(BackupAgent): return ChunkAsyncStreamIterator(stream) except (GoogleDriveApiError, HomeAssistantError, TimeoutError) as err: raise BackupAgentError(f"Failed to download backup: {err}") from err - raise BackupAgentError("Backup not found") + raise BackupNotFound(f"Backup {backup_id} not found") async def async_delete_backup( self, @@ -138,5 +143,7 @@ class GoogleDriveBackupAgent(BackupAgent): _LOGGER.debug("Deleting file_id: %s", file_id) await self._client.async_delete(file_id) _LOGGER.debug("Deleted backup_id: %s", backup_id) + return except (GoogleDriveApiError, HomeAssistantError, TimeoutError) as err: raise BackupAgentError(f"Failed to delete backup: {err}") from err + raise BackupNotFound(f"Backup {backup_id} not found") diff --git a/tests/components/google_drive/test_backup.py b/tests/components/google_drive/test_backup.py index 2da397def5b..9cf86a280bd 100644 --- a/tests/components/google_drive/test_backup.py +++ b/tests/components/google_drive/test_backup.py @@ -247,9 +247,9 @@ async def test_agents_download_file_not_found( resp = await client.get( f"/api/backup/download/{TEST_AGENT_BACKUP.backup_id}?agent_id={TEST_AGENT_ID}" ) - assert resp.status == 500 + assert resp.status == 404 content = await resp.content.read() - assert "Backup not found" in content.decode() + assert content == b"" async def test_agents_download_metadata_not_found( From 1456d9d800624b961eacb6c55f113bba63030de5 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 4 Mar 2025 21:00:51 +0100 Subject: [PATCH 2194/3148] Capitalize "Suez Water" and "ID" in user-facing strings (#139782) --- homeassistant/components/suez_water/strings.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/suez_water/strings.json b/homeassistant/components/suez_water/strings.json index be2d4849e76..a8632fcb24a 100644 --- a/homeassistant/components/suez_water/strings.json +++ b/homeassistant/components/suez_water/strings.json @@ -5,21 +5,21 @@ "data": { "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]", - "counter_id": "Meter id" + "counter_id": "Meter ID" }, "data_description": { "username": "Enter your login associated with your {tout_sur_mon_eau} account", "password": "Enter your password associated with your {tout_sur_mon_eau} account", - "counter_id": "Enter your meter id (ex: 12345678). Should be found automatically during setup, if not see integration documentation for more information" + "counter_id": "Enter your meter ID (ex: 12345678). Should be found automatically during setup, if not see integration documentation for more information" }, - "description": "Connect your suez water {tout_sur_mon_eau} account to retrieve your water consumption" + "description": "Connect your Suez Water {tout_sur_mon_eau} account to retrieve your water consumption" } }, "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%]", - "counter_not_found": "Could not find meter id automatically" + "counter_not_found": "Could not find meter ID automatically" }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" From c129f27c95753750987dd1c0a844d01228e68edf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Tue, 4 Mar 2025 15:17:05 +0100 Subject: [PATCH 2195/3148] Bump aiohomeconnect to 0.16.2 (#139750) --- homeassistant/components/home_connect/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/home_connect/manifest.json b/homeassistant/components/home_connect/manifest.json index 2f5ef4d1b37..5293e8bf468 100644 --- a/homeassistant/components/home_connect/manifest.json +++ b/homeassistant/components/home_connect/manifest.json @@ -7,6 +7,6 @@ "documentation": "https://www.home-assistant.io/integrations/home_connect", "iot_class": "cloud_push", "loggers": ["aiohomeconnect"], - "requirements": ["aiohomeconnect==0.15.1"], + "requirements": ["aiohomeconnect==0.16.2"], "single_config_entry": true } diff --git a/requirements_all.txt b/requirements_all.txt index 0530135ed07..4eae0cb7588 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -264,7 +264,7 @@ aioharmony==0.4.1 aiohasupervisor==0.3.0 # homeassistant.components.home_connect -aiohomeconnect==0.15.1 +aiohomeconnect==0.16.2 # homeassistant.components.homekit_controller aiohomekit==3.2.8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 976d7030a90..029beb55cc3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -249,7 +249,7 @@ aioharmony==0.4.1 aiohasupervisor==0.3.0 # homeassistant.components.home_connect -aiohomeconnect==0.15.1 +aiohomeconnect==0.16.2 # homeassistant.components.homekit_controller aiohomekit==3.2.8 From 185949cc185642fb37268687d66847d8793d2724 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 4 Mar 2025 15:08:14 +0100 Subject: [PATCH 2196/3148] Add Apollo Automation virtual integration (#139751) Co-authored-by: Robert Resch --- homeassistant/components/apollo_automation/__init__.py | 1 + homeassistant/components/apollo_automation/manifest.json | 6 ++++++ homeassistant/generated/integrations.json | 5 +++++ 3 files changed, 12 insertions(+) create mode 100644 homeassistant/components/apollo_automation/__init__.py create mode 100644 homeassistant/components/apollo_automation/manifest.json diff --git a/homeassistant/components/apollo_automation/__init__.py b/homeassistant/components/apollo_automation/__init__.py new file mode 100644 index 00000000000..7815b17818f --- /dev/null +++ b/homeassistant/components/apollo_automation/__init__.py @@ -0,0 +1 @@ +"""Virtual integration: Apollo Automation.""" diff --git a/homeassistant/components/apollo_automation/manifest.json b/homeassistant/components/apollo_automation/manifest.json new file mode 100644 index 00000000000..8e4c58f3f3d --- /dev/null +++ b/homeassistant/components/apollo_automation/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "apollo_automation", + "name": "Apollo Automation", + "integration_type": "virtual", + "supported_by": "esphome" +} diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index e3185251114..e8fd68e2e24 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -345,6 +345,11 @@ "config_flow": true, "iot_class": "local_polling" }, + "apollo_automation": { + "name": "Apollo Automation", + "integration_type": "virtual", + "supported_by": "esphome" + }, "appalachianpower": { "name": "Appalachian Power", "integration_type": "virtual", From a195a9107bacd374b55a8d307cf4aed1be0e2dd8 Mon Sep 17 00:00:00 2001 From: Anthony Hou Date: Tue, 4 Mar 2025 22:25:47 +0800 Subject: [PATCH 2197/3148] Fix incorrect weather state returned by HKO (#139757) * Fix incorrect weather state * Clean up unused import --------- Co-authored-by: Franck Nijhof Co-authored-by: Franck Nijhof --- homeassistant/components/hko/coordinator.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/homeassistant/components/hko/coordinator.py b/homeassistant/components/hko/coordinator.py index 5845e8831fe..aede960e702 100644 --- a/homeassistant/components/hko/coordinator.py +++ b/homeassistant/components/hko/coordinator.py @@ -11,7 +11,6 @@ from hko import HKO, HKOError from homeassistant.components.weather import ( ATTR_CONDITION_CLOUDY, ATTR_CONDITION_FOG, - ATTR_CONDITION_HAIL, ATTR_CONDITION_LIGHTNING_RAINY, ATTR_CONDITION_PARTLYCLOUDY, ATTR_CONDITION_POURING, @@ -145,7 +144,7 @@ class HKOUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Return the condition corresponding to the weather info.""" info = info.lower() if WEATHER_INFO_RAIN in info: - return ATTR_CONDITION_HAIL + return ATTR_CONDITION_RAINY if WEATHER_INFO_SNOW in info and WEATHER_INFO_RAIN in info: return ATTR_CONDITION_SNOWY_RAINY if WEATHER_INFO_SNOW in info: From e73b08b269d1a69c6ef4cede9f235a0df8decd19 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 4 Mar 2025 15:59:38 +0100 Subject: [PATCH 2198/3148] Bump pysmartthings to 2.5.0 (#139758) * Bump pysmartthings to 2.5.0 * Bump pysmartthings to 2.5.0 --- homeassistant/components/smartthings/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/smartthings/manifest.json b/homeassistant/components/smartthings/manifest.json index 7a25dc2ac13..22926e70ba0 100644 --- a/homeassistant/components/smartthings/manifest.json +++ b/homeassistant/components/smartthings/manifest.json @@ -29,5 +29,5 @@ "documentation": "https://www.home-assistant.io/integrations/smartthings", "iot_class": "cloud_push", "loggers": ["pysmartthings"], - "requirements": ["pysmartthings==2.4.1"] + "requirements": ["pysmartthings==2.5.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 4eae0cb7588..ac4d06187ab 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2310,7 +2310,7 @@ pysma==0.7.5 pysmappee==0.2.29 # homeassistant.components.smartthings -pysmartthings==2.4.1 +pysmartthings==2.5.0 # homeassistant.components.smarty pysmarty2==0.10.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 029beb55cc3..10c637eb92b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1882,7 +1882,7 @@ pysma==0.7.5 pysmappee==0.2.29 # homeassistant.components.smartthings -pysmartthings==2.4.1 +pysmartthings==2.5.0 # homeassistant.components.smarty pysmarty2==0.10.2 From 47033e587bc843cabba3f44a060e6e22a477cf66 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Tue, 4 Mar 2025 19:26:20 +0100 Subject: [PATCH 2199/3148] Fix home connect available (#139760) * Fix home connect available * Extend and clarify test * Do not change connected state on stream interrupted --- .../components/home_connect/coordinator.py | 13 +- .../components/home_connect/entity.py | 16 ++- tests/components/home_connect/__init__.py | 18 +++ tests/components/home_connect/conftest.py | 21 +-- .../home_connect/test_coordinator.py | 132 +++++++++++++++++- 5 files changed, 177 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/home_connect/coordinator.py b/homeassistant/components/home_connect/coordinator.py index 4d275854e30..7898fb7be12 100644 --- a/homeassistant/components/home_connect/coordinator.py +++ b/homeassistant/components/home_connect/coordinator.py @@ -98,6 +98,7 @@ class HomeConnectCoordinator( CALLBACK_TYPE, tuple[CALLBACK_TYPE, tuple[EventKey, ...]] ] = {} self.device_registry = dr.async_get(self.hass) + self.data = {} @cached_property def context_listeners(self) -> dict[tuple[str, EventKey], list[CALLBACK_TYPE]]: @@ -161,6 +162,14 @@ class HomeConnectCoordinator( async for event_message in self.client.stream_all_events(): retry_time = 10 event_message_ha_id = event_message.ha_id + if ( + event_message_ha_id in self.data + and not self.data[event_message_ha_id].info.connected + ): + self.data[event_message_ha_id].info.connected = True + self._call_all_event_listeners_for_appliance( + event_message_ha_id + ) match event_message.type: case EventType.STATUS: statuses = self.data[event_message_ha_id].status @@ -295,6 +304,8 @@ class HomeConnectCoordinator( translation_placeholders=get_dict_from_home_connect_error(error), ) from error except HomeConnectError as error: + for appliance_data in self.data.values(): + appliance_data.info.connected = False raise UpdateFailed( translation_domain=DOMAIN, translation_key="fetch_api_error", @@ -303,7 +314,7 @@ class HomeConnectCoordinator( return { appliance.ha_id: await self._get_appliance_data( - appliance, self.data.get(appliance.ha_id) if self.data else None + appliance, self.data.get(appliance.ha_id) ) for appliance in appliances.homeappliances } diff --git a/homeassistant/components/home_connect/entity.py b/homeassistant/components/home_connect/entity.py index 52eaaecace7..b55ff374f34 100644 --- a/homeassistant/components/home_connect/entity.py +++ b/homeassistant/components/home_connect/entity.py @@ -8,6 +8,7 @@ from typing import cast from aiohomeconnect.model import EventKey, OptionKey from aiohomeconnect.model.error import ActiveProgramNotSetError, HomeConnectError +from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceInfo @@ -51,8 +52,10 @@ class HomeConnectEntity(CoordinatorEntity[HomeConnectCoordinator]): def _handle_coordinator_update(self) -> None: """Handle updated data from the coordinator.""" self.update_native_value() + available = self._attr_available = self.appliance.info.connected self.async_write_ha_state() - _LOGGER.debug("Updated %s, new state: %s", self.entity_id, self.state) + state = STATE_UNAVAILABLE if not available else self.state + _LOGGER.debug("Updated %s, new state: %s", self.entity_id, state) @property def bsh_key(self) -> str: @@ -61,10 +64,13 @@ class HomeConnectEntity(CoordinatorEntity[HomeConnectCoordinator]): @property def available(self) -> bool: - """Return True if entity is available.""" - return ( - self.appliance.info.connected and self._attr_available and super().available - ) + """Return True if entity is available. + + Do not use self.last_update_success for available state + as event updates should take precedence over the coordinator + refresh. + """ + return self._attr_available class HomeConnectOptionEntity(HomeConnectEntity): diff --git a/tests/components/home_connect/__init__.py b/tests/components/home_connect/__init__.py index 2b61501c59a..47a438fd218 100644 --- a/tests/components/home_connect/__init__.py +++ b/tests/components/home_connect/__init__.py @@ -1 +1,19 @@ """Tests for the Home Connect integration.""" + +from typing import Any + +from aiohomeconnect.model import ArrayOfHomeAppliances, ArrayOfStatus + +from tests.common import load_json_object_fixture + +MOCK_APPLIANCES = ArrayOfHomeAppliances.from_dict( + load_json_object_fixture("home_connect/appliances.json")["data"] # type: ignore[arg-type] +) +MOCK_PROGRAMS: dict[str, Any] = load_json_object_fixture("home_connect/programs.json") +MOCK_SETTINGS: dict[str, Any] = load_json_object_fixture("home_connect/settings.json") +MOCK_STATUS = ArrayOfStatus.from_dict( + load_json_object_fixture("home_connect/status.json")["data"] # type: ignore[arg-type] +) +MOCK_AVAILABLE_COMMANDS: dict[str, Any] = load_json_object_fixture( + "home_connect/available_commands.json" +) diff --git a/tests/components/home_connect/conftest.py b/tests/components/home_connect/conftest.py index 49cbc89ba41..396fe8c5665 100644 --- a/tests/components/home_connect/conftest.py +++ b/tests/components/home_connect/conftest.py @@ -11,11 +11,9 @@ from aiohomeconnect.client import Client as HomeConnectClient from aiohomeconnect.model import ( ArrayOfCommands, ArrayOfEvents, - ArrayOfHomeAppliances, ArrayOfOptions, ArrayOfPrograms, ArrayOfSettings, - ArrayOfStatus, Event, EventKey, EventMessage, @@ -41,20 +39,15 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from tests.common import MockConfigEntry, load_json_object_fixture - -MOCK_APPLIANCES = ArrayOfHomeAppliances.from_dict( - load_json_object_fixture("home_connect/appliances.json")["data"] -) -MOCK_PROGRAMS: dict[str, Any] = load_json_object_fixture("home_connect/programs.json") -MOCK_SETTINGS: dict[str, Any] = load_json_object_fixture("home_connect/settings.json") -MOCK_STATUS = ArrayOfStatus.from_dict( - load_json_object_fixture("home_connect/status.json")["data"] -) -MOCK_AVAILABLE_COMMANDS: dict[str, Any] = load_json_object_fixture( - "home_connect/available_commands.json" +from . import ( + MOCK_APPLIANCES, + MOCK_AVAILABLE_COMMANDS, + MOCK_PROGRAMS, + MOCK_SETTINGS, + MOCK_STATUS, ) +from tests.common import MockConfigEntry CLIENT_ID = "1234" CLIENT_SECRET = "5678" diff --git a/tests/components/home_connect/test_coordinator.py b/tests/components/home_connect/test_coordinator.py index ac27b848a36..1a49d2bb2a0 100644 --- a/tests/components/home_connect/test_coordinator.py +++ b/tests/components/home_connect/test_coordinator.py @@ -1,6 +1,7 @@ """Test for Home Connect coordinator.""" from collections.abc import Awaitable, Callable +import copy from datetime import timedelta from typing import Any from unittest.mock import AsyncMock, MagicMock, patch @@ -20,6 +21,7 @@ from aiohomeconnect.model.error import ( HomeConnectError, HomeConnectRequestError, ) +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components.home_connect.const import ( @@ -36,8 +38,11 @@ from homeassistant.core import ( callback, ) from homeassistant.helpers import entity_registry as er +from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util +from . import MOCK_APPLIANCES + from tests.common import MockConfigEntry, async_fire_time_changed @@ -74,6 +79,123 @@ async def test_coordinator_update_failing_get_appliances( assert config_entry.state == ConfigEntryState.SETUP_RETRY +@pytest.mark.usefixtures("setup_credentials") +@pytest.mark.parametrize("platforms", [("binary_sensor",)]) +@pytest.mark.parametrize("appliance_ha_id", ["Washer"], indirect=True) +async def test_coordinator_failure_refresh_and_stream( + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + client: MagicMock, + freezer: FrozenDateTimeFactory, + appliance_ha_id: str, +) -> None: + """Test entity available state via coordinator refresh and event stream.""" + entity_id_1 = "binary_sensor.washer_remote_control" + entity_id_2 = "binary_sensor.washer_remote_start" + await async_setup_component(hass, "homeassistant", {}) + await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + state = hass.states.get(entity_id_1) + assert state + assert state.state != "unavailable" + state = hass.states.get(entity_id_2) + assert state + assert state.state != "unavailable" + + client.get_home_appliances.side_effect = HomeConnectError() + + # Force a coordinator refresh. + await hass.services.async_call( + "homeassistant", "update_entity", {"entity_id": entity_id_1}, blocking=True + ) + await hass.async_block_till_done() + + state = hass.states.get(entity_id_1) + assert state + assert state.state == "unavailable" + state = hass.states.get(entity_id_2) + assert state + assert state.state == "unavailable" + + # Test that the entity becomes available again after a successful update. + + client.get_home_appliances.side_effect = None + client.get_home_appliances.return_value = copy.deepcopy(MOCK_APPLIANCES) + + # Move time forward to pass the debounce time. + freezer.tick(timedelta(hours=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # Force a coordinator refresh. + await hass.services.async_call( + "homeassistant", "update_entity", {"entity_id": entity_id_1}, blocking=True + ) + await hass.async_block_till_done() + + state = hass.states.get(entity_id_1) + assert state + assert state.state != "unavailable" + state = hass.states.get(entity_id_2) + assert state + assert state.state != "unavailable" + + # Test that the event stream makes the entity go available too. + + # First make the entity unavailable. + client.get_home_appliances.side_effect = HomeConnectError() + + # Move time forward to pass the debounce time + freezer.tick(timedelta(hours=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # Force a coordinator refresh + await hass.services.async_call( + "homeassistant", "update_entity", {"entity_id": entity_id_1}, blocking=True + ) + await hass.async_block_till_done() + + state = hass.states.get(entity_id_1) + assert state + assert state.state == "unavailable" + state = hass.states.get(entity_id_2) + assert state + assert state.state == "unavailable" + + # Now make the entity available again. + client.get_home_appliances.side_effect = None + client.get_home_appliances.return_value = copy.deepcopy(MOCK_APPLIANCES) + + # One event should make all entities for this appliance available again. + event_message = EventMessage( + appliance_ha_id, + EventType.STATUS, + ArrayOfEvents( + [ + Event( + key=EventKey.BSH_COMMON_STATUS_REMOTE_CONTROL_ACTIVE, + raw_key=EventKey.BSH_COMMON_STATUS_REMOTE_CONTROL_ACTIVE.value, + timestamp=0, + level="", + handling="", + value=False, + ) + ], + ), + ) + await client.add_events([event_message]) + await hass.async_block_till_done() + + state = hass.states.get(entity_id_1) + assert state + assert state.state != "unavailable" + state = hass.states.get(entity_id_2) + assert state + assert state.state != "unavailable" + + @pytest.mark.parametrize( "mock_method", [ @@ -330,11 +452,13 @@ async def test_event_listener_resilience( assert config_entry.state == ConfigEntryState.LOADED assert len(config_entry._background_tasks) == 1 - assert hass.states.is_state(entity_id, initial_state) + state = hass.states.get(entity_id) + assert state + assert state.state == initial_state - await hass.async_block_till_done() future.set_exception(exception) await hass.async_block_till_done() + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=30)) await hass.async_block_till_done() @@ -362,4 +486,6 @@ async def test_event_listener_resilience( ) await hass.async_block_till_done() - assert hass.states.is_state(entity_id, after_event_expected_state) + state = hass.states.get(entity_id) + assert state + assert state.state == after_event_expected_state From 7d82375f8185187c3ca5f8890c21c513c1f82c36 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 4 Mar 2025 07:01:40 -1000 Subject: [PATCH 2200/3148] Bump nexia to 2.1.1 (#139772) changelog: https://github.com/bdraco/nexia/compare/2.0.9...2.1.1 fixes #133368 --- homeassistant/components/nexia/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/nexia/manifest.json b/homeassistant/components/nexia/manifest.json index 6a439f869c9..8a9cda14646 100644 --- a/homeassistant/components/nexia/manifest.json +++ b/homeassistant/components/nexia/manifest.json @@ -12,5 +12,5 @@ "documentation": "https://www.home-assistant.io/integrations/nexia", "iot_class": "cloud_polling", "loggers": ["nexia"], - "requirements": ["nexia==2.0.9"] + "requirements": ["nexia==2.1.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index ac4d06187ab..ee2708cdcd3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1483,7 +1483,7 @@ nettigo-air-monitor==4.0.0 neurio==0.3.1 # homeassistant.components.nexia -nexia==2.0.9 +nexia==2.1.1 # homeassistant.components.nextcloud nextcloudmonitor==1.5.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 10c637eb92b..0cfcd581b84 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1246,7 +1246,7 @@ netmap==0.7.0.2 nettigo-air-monitor==4.0.0 # homeassistant.components.nexia -nexia==2.0.9 +nexia==2.1.1 # homeassistant.components.nextcloud nextcloudmonitor==1.5.1 From 01e8ca6495eb46e1df5c31f7ecf45823d8cf4e6f Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 4 Mar 2025 20:25:14 +0000 Subject: [PATCH 2201/3148] Bump version to 2025.3.0b5 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 2a3b2c082ae..0e7a9d0427d 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -25,7 +25,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2025 MINOR_VERSION: Final = 3 -PATCH_VERSION: Final = "0b4" +PATCH_VERSION: Final = "0b5" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 13, 0) diff --git a/pyproject.toml b/pyproject.toml index 06b5a433574..41506b3de71 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.3.0b4" +version = "2025.3.0b5" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 50ba93042be79abcd0c927abcfe095f1b1e67729 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Tue, 4 Mar 2025 22:43:49 +0100 Subject: [PATCH 2202/3148] Add create_habit action to Habitica integration (#139673) --- homeassistant/components/habitica/const.py | 1 + homeassistant/components/habitica/icons.json | 6 ++ homeassistant/components/habitica/services.py | 11 ++- .../components/habitica/services.yaml | 18 +++- .../components/habitica/strings.json | 70 ++++++++++++--- tests/components/habitica/test_services.py | 90 ++++++++++++++++++- 6 files changed, 180 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/habitica/const.py b/homeassistant/components/habitica/const.py index ecaa66378f0..049f2beb370 100644 --- a/homeassistant/components/habitica/const.py +++ b/homeassistant/components/habitica/const.py @@ -62,6 +62,7 @@ SERVICE_TRANSFORMATION = "transformation" SERVICE_UPDATE_REWARD = "update_reward" SERVICE_CREATE_REWARD = "create_reward" SERVICE_UPDATE_HABIT = "update_habit" +SERVICE_CREATE_HABIT = "create_habit" DEVELOPER_ID = "4c4ca53f-c059-4ffa-966e-9d29dd405daf" X_CLIENT = f"{DEVELOPER_ID} - {APPLICATION_NAME} {__version__}" diff --git a/homeassistant/components/habitica/icons.json b/homeassistant/components/habitica/icons.json index ca4795dd514..af4a20acab6 100644 --- a/homeassistant/components/habitica/icons.json +++ b/homeassistant/components/habitica/icons.json @@ -237,6 +237,12 @@ "tag_options": "mdi:tag", "developer_options": "mdi:test-tube" } + }, + "create_habit": { + "service": "mdi:contrast-box", + "sections": { + "developer_options": "mdi:test-tube" + } } } } diff --git a/homeassistant/components/habitica/services.py b/homeassistant/components/habitica/services.py index 3c4a59990a3..78f3002c89d 100644 --- a/homeassistant/components/habitica/services.py +++ b/homeassistant/components/habitica/services.py @@ -66,6 +66,7 @@ from .const import ( SERVICE_API_CALL, SERVICE_CANCEL_QUEST, SERVICE_CAST_SKILL, + SERVICE_CREATE_HABIT, SERVICE_CREATE_REWARD, SERVICE_GET_TASKS, SERVICE_LEAVE_QUEST, @@ -190,6 +191,7 @@ SERVICE_TASK_TYPE_MAP = { SERVICE_UPDATE_REWARD: TaskType.REWARD, SERVICE_CREATE_REWARD: TaskType.REWARD, SERVICE_UPDATE_HABIT: TaskType.HABIT, + SERVICE_CREATE_HABIT: TaskType.HABIT, } @@ -596,7 +598,7 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 data = Task() if not is_update: - data["type"] = TaskType.REWARD + data["type"] = SERVICE_TASK_TYPE_MAP[call.service] if (text := call.data.get(ATTR_RENAME)) or (text := call.data.get(ATTR_NAME)): data["text"] = text @@ -733,6 +735,13 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 schema=SERVICE_CREATE_TASK_SCHEMA, supports_response=SupportsResponse.ONLY, ) + hass.services.async_register( + DOMAIN, + SERVICE_CREATE_HABIT, + create_or_update_task, + schema=SERVICE_CREATE_TASK_SCHEMA, + supports_response=SupportsResponse.ONLY, + ) hass.services.async_register( DOMAIN, SERVICE_API_CALL, diff --git a/homeassistant/components/habitica/services.yaml b/homeassistant/components/habitica/services.yaml index f5a9c2b0032..ed3ae4516e5 100644 --- a/homeassistant/components/habitica/services.yaml +++ b/homeassistant/components/habitica/services.yaml @@ -183,7 +183,7 @@ update_reward: create_reward: fields: config_entry: *config_entry - name: + name: &name required: true selector: text: @@ -199,7 +199,7 @@ update_habit: task: *task rename: *rename notes: *notes - up_down: + up_down: &up_down required: false selector: select: @@ -210,7 +210,7 @@ update_habit: label: "➖" multiple: true mode: list - priority: + priority: &priority required: false selector: select: @@ -221,7 +221,7 @@ update_habit: - "hard" mode: dropdown translation_key: "priority" - frequency: + frequency: &frequency required: false selector: select: @@ -252,3 +252,13 @@ update_habit: unit_of_measurement: "➖" mode: box alias: *alias +create_habit: + fields: + config_entry: *config_entry + name: *name + notes: *notes + up_down: *up_down + priority: *priority + frequency: *frequency + tag: *tag + developer_options: *developer_options diff --git a/homeassistant/components/habitica/strings.json b/homeassistant/components/habitica/strings.json index 22ea44351da..1f9424eafe1 100644 --- a/homeassistant/components/habitica/strings.json +++ b/homeassistant/components/habitica/strings.json @@ -11,9 +11,9 @@ "config_entry_description": "Select the Habitica account to update a task.", "task_description": "The name (or task ID) of the task you want to update.", "rename_name": "Rename", - "rename_description": "The new title for the Habitica task.", - "notes_name": "Update notes", - "notes_description": "The new notes for the Habitica task.", + "rename_description": "The title for the Habitica task.", + "notes_name": "Notes", + "notes_description": "The notes for the Habitica task.", "tag_name": "Add tags", "tag_description": "Add tags to the Habitica task. If a tag does not already exist, a new one will be created.", "remove_tag_name": "Remove tags", @@ -25,7 +25,13 @@ "tag_options_name": "Tags", "tag_options_description": "Add or remove tags from a task.", "name_description": "The title for the Habitica task.", - "cost_name": "Cost" + "cost_name": "Cost", + "difficulty_name": "Difficulty", + "difficulty_description": "The difficulty of the task.", + "frequency_name": "Counter reset", + "frequency_description": "The frequency at which the habit's counter resets: daily at the start of a new day, weekly after Sunday night, or monthly at the beginning of a new month.", + "up_down_name": "Rewards or losses", + "up_down_description": "Whether the habit is good and rewarding (positive), bad and penalizing (negative), or both." }, "config": { "abort": { @@ -793,16 +799,16 @@ "description": "[%key:component::habitica::common::alias_description%]" }, "priority": { - "name": "Difficulty", - "description": "Update the difficulty of a task." + "name": "[%key:component::habitica::common::difficulty_name%]", + "description": "[%key:component::habitica::common::difficulty_description%]" }, "frequency": { - "name": "Counter reset", - "description": "Update when a habit's counter resets: daily resets at the start of a new day, weekly after Sunday night, and monthly at the beginning of a new month." + "name": "[%key:component::habitica::common::frequency_name%]", + "description": "[%key:component::habitica::common::frequency_description%]" }, "up_down": { - "name": "Rewards or losses", - "description": "Update if the habit is good and rewarding (positive), bad and penalizing (negative), or both." + "name": "[%key:component::habitica::common::up_down_name%]", + "description": "[%key:component::habitica::common::up_down_description%]" }, "counter_up": { "name": "Adjust positive counter", @@ -823,6 +829,50 @@ "description": "[%key:component::habitica::common::developer_options_description%]" } } + }, + "create_habit": { + "name": "Create habit", + "description": "Adds a new habit.", + "fields": { + "config_entry": { + "name": "[%key:component::habitica::common::config_entry_name%]", + "description": "Select the Habitica account to create a habit." + }, + "name": { + "name": "[%key:component::habitica::common::task_name%]", + "description": "[%key:component::habitica::common::name_description%]" + }, + "notes": { + "name": "[%key:component::habitica::common::notes_name%]", + "description": "[%key:component::habitica::common::notes_description%]" + }, + "tag": { + "name": "[%key:component::habitica::common::tag_name%]", + "description": "[%key:component::habitica::common::tag_description%]" + }, + "alias": { + "name": "[%key:component::habitica::common::alias_name%]", + "description": "[%key:component::habitica::common::alias_description%]" + }, + "priority": { + "name": "[%key:component::habitica::common::difficulty_name%]", + "description": "[%key:component::habitica::common::difficulty_description%]" + }, + "frequency": { + "name": "[%key:component::habitica::common::frequency_name%]", + "description": "[%key:component::habitica::common::frequency_description%]" + }, + "up_down": { + "name": "[%key:component::habitica::common::up_down_name%]", + "description": "[%key:component::habitica::common::up_down_description%]" + } + }, + "sections": { + "developer_options": { + "name": "[%key:component::habitica::common::developer_options_name%]", + "description": "[%key:component::habitica::common::developer_options_description%]" + } + } } }, "selector": { diff --git a/tests/components/habitica/test_services.py b/tests/components/habitica/test_services.py index 10a8bc0a588..00ad7e6b2e9 100644 --- a/tests/components/habitica/test_services.py +++ b/tests/components/habitica/test_services.py @@ -42,6 +42,7 @@ from homeassistant.components.habitica.const import ( SERVICE_ACCEPT_QUEST, SERVICE_CANCEL_QUEST, SERVICE_CAST_SKILL, + SERVICE_CREATE_HABIT, SERVICE_CREATE_REWARD, SERVICE_GET_TASKS, SERVICE_LEAVE_QUEST, @@ -986,6 +987,10 @@ async def test_update_task_exceptions( ), ], ) +@pytest.mark.parametrize( + "service", + [SERVICE_CREATE_REWARD, SERVICE_CREATE_HABIT], +) @pytest.mark.usefixtures("habitica") async def test_create_task_exceptions( hass: HomeAssistant, @@ -994,6 +999,7 @@ async def test_create_task_exceptions( exception: Exception, expected_exception: Exception, exception_msg: str, + service: str, ) -> None: """Test Habitica task create action exceptions.""" @@ -1001,7 +1007,7 @@ async def test_create_task_exceptions( with pytest.raises(expected_exception, match=exception_msg): await hass.services.async_call( DOMAIN, - SERVICE_CREATE_REWARD, + service, service_data={ ATTR_CONFIG_ENTRY: config_entry.entry_id, ATTR_NAME: "TITLE", @@ -1230,6 +1236,88 @@ async def test_update_habit( habitica.update_task.assert_awaited_with(UUID(task_id), call_args) +@pytest.mark.parametrize( + ("service_data", "call_args"), + [ + ( + { + ATTR_NAME: "TITLE", + }, + Task(type=TaskType.HABIT, text="TITLE"), + ), + ( + { + ATTR_NAME: "TITLE", + ATTR_NOTES: "NOTES", + }, + Task(type=TaskType.HABIT, text="TITLE", notes="NOTES"), + ), + ( + { + ATTR_NAME: "TITLE", + ATTR_UP_DOWN: [""], + }, + Task(type=TaskType.HABIT, text="TITLE", up=False, down=False), + ), + ( + { + ATTR_NAME: "TITLE", + ATTR_UP_DOWN: ["up"], + }, + Task(type=TaskType.HABIT, text="TITLE", up=True, down=False), + ), + ( + { + ATTR_NAME: "TITLE", + ATTR_UP_DOWN: ["down"], + }, + Task(type=TaskType.HABIT, text="TITLE", up=False, down=True), + ), + ( + { + ATTR_NAME: "TITLE", + ATTR_PRIORITY: "trivial", + }, + Task(type=TaskType.HABIT, text="TITLE", priority=TaskPriority.TRIVIAL), + ), + ( + { + ATTR_NAME: "TITLE", + ATTR_FREQUENCY: "daily", + }, + Task(type=TaskType.HABIT, text="TITLE", frequency=Frequency.DAILY), + ), + ( + { + ATTR_NAME: "TITLE", + ATTR_ALIAS: "ALIAS", + }, + Task(type=TaskType.HABIT, text="TITLE", alias="ALIAS"), + ), + ], +) +async def test_create_habit( + hass: HomeAssistant, + config_entry: MockConfigEntry, + habitica: AsyncMock, + service_data: dict[str, Any], + call_args: Task, +) -> None: + """Test Habitica create_habit action.""" + + await hass.services.async_call( + DOMAIN, + SERVICE_CREATE_HABIT, + service_data={ + ATTR_CONFIG_ENTRY: config_entry.entry_id, + **service_data, + }, + return_response=True, + blocking=True, + ) + habitica.create_task.assert_awaited_with(call_args) + + async def test_tags( hass: HomeAssistant, config_entry: MockConfigEntry, From c671862d3f678a3eadb60b32c78ae7a67a518bc3 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Wed, 5 Mar 2025 00:45:58 +0100 Subject: [PATCH 2203/3148] Improve Home Connect appliances test fixture (#139787) Improve Home Connect appliances fixture --- tests/components/home_connect/__init__.py | 5 +- tests/components/home_connect/conftest.py | 211 ++++++++------- .../home_connect/fixtures/appliances.json | 240 +++++++++--------- .../home_connect/test_coordinator.py | 36 ++- 4 files changed, 267 insertions(+), 225 deletions(-) diff --git a/tests/components/home_connect/__init__.py b/tests/components/home_connect/__init__.py index 47a438fd218..8c256cb23f3 100644 --- a/tests/components/home_connect/__init__.py +++ b/tests/components/home_connect/__init__.py @@ -2,13 +2,10 @@ from typing import Any -from aiohomeconnect.model import ArrayOfHomeAppliances, ArrayOfStatus +from aiohomeconnect.model import ArrayOfStatus from tests.common import load_json_object_fixture -MOCK_APPLIANCES = ArrayOfHomeAppliances.from_dict( - load_json_object_fixture("home_connect/appliances.json")["data"] # type: ignore[arg-type] -) MOCK_PROGRAMS: dict[str, Any] = load_json_object_fixture("home_connect/programs.json") MOCK_SETTINGS: dict[str, Any] = load_json_object_fixture("home_connect/settings.json") MOCK_STATUS = ArrayOfStatus.from_dict( diff --git a/tests/components/home_connect/conftest.py b/tests/components/home_connect/conftest.py index 396fe8c5665..c0caf2b2bdd 100644 --- a/tests/components/home_connect/conftest.py +++ b/tests/components/home_connect/conftest.py @@ -11,6 +11,7 @@ from aiohomeconnect.client import Client as HomeConnectClient from aiohomeconnect.model import ( ArrayOfCommands, ArrayOfEvents, + ArrayOfHomeAppliances, ArrayOfOptions, ArrayOfPrograms, ArrayOfSettings, @@ -39,15 +40,9 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from . import ( - MOCK_APPLIANCES, - MOCK_AVAILABLE_COMMANDS, - MOCK_PROGRAMS, - MOCK_SETTINGS, - MOCK_STATUS, -) +from . import MOCK_AVAILABLE_COMMANDS, MOCK_PROGRAMS, MOCK_SETTINGS, MOCK_STATUS -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, load_fixture CLIENT_ID = "1234" CLIENT_SECRET = "5678" @@ -148,14 +143,6 @@ async def mock_integration_setup( return run -def _get_specific_appliance_side_effect(ha_id: str) -> HomeAppliance: - """Get specific appliance side effect.""" - for appliance in copy.deepcopy(MOCK_APPLIANCES).homeappliances: - if appliance.ha_id == ha_id: - return appliance - raise HomeConnectApiError("error.key", "error description") - - def _get_set_program_side_effect( event_queue: asyncio.Queue[list[EventMessage]], event_key: EventKey ): @@ -271,68 +258,12 @@ def _get_set_program_options_side_effect( return set_program_options_side_effect -async def _get_all_programs_side_effect(ha_id: str) -> ArrayOfPrograms: - """Get all programs.""" - appliance_type = next( - appliance - for appliance in MOCK_APPLIANCES.homeappliances - if appliance.ha_id == ha_id - ).type - if appliance_type not in MOCK_PROGRAMS: - raise HomeConnectApiError("error.key", "error description") - - return ArrayOfPrograms( - [ - EnumerateProgram.from_dict(program) - for program in MOCK_PROGRAMS[appliance_type]["data"]["programs"] - ], - Program.from_dict(MOCK_PROGRAMS[appliance_type]["data"]["programs"][0]), - Program.from_dict(MOCK_PROGRAMS[appliance_type]["data"]["programs"][0]), - ) - - -async def _get_settings_side_effect(ha_id: str) -> ArrayOfSettings: - """Get settings.""" - return ArrayOfSettings.from_dict( - MOCK_SETTINGS.get( - next( - appliance - for appliance in MOCK_APPLIANCES.homeappliances - if appliance.ha_id == ha_id - ).type, - {}, - ).get("data", {"settings": []}) - ) - - -async def _get_setting_side_effect(ha_id: str, setting_key: SettingKey): - """Get setting.""" - for appliance in MOCK_APPLIANCES.homeappliances: - if appliance.ha_id == ha_id: - settings = MOCK_SETTINGS.get( - next( - appliance - for appliance in MOCK_APPLIANCES.homeappliances - if appliance.ha_id == ha_id - ).type, - {}, - ).get("data", {"settings": []}) - for setting_dict in cast(list[dict], settings["settings"]): - if setting_dict["key"] == setting_key: - return GetSetting.from_dict(setting_dict) - raise HomeConnectApiError("error.key", "error description") - - -async def _get_available_commands_side_effect(ha_id: str) -> ArrayOfCommands: - """Get available commands.""" - for appliance in MOCK_APPLIANCES.homeappliances: - if appliance.ha_id == ha_id and appliance.type in MOCK_AVAILABLE_COMMANDS: - return ArrayOfCommands.from_dict(MOCK_AVAILABLE_COMMANDS[appliance.type]) - raise HomeConnectApiError("error.key", "error description") - - @pytest.fixture(name="client") -def mock_client(request: pytest.FixtureRequest) -> MagicMock: +def mock_client( + appliances: list[HomeAppliance], + appliance: HomeAppliance | None, + request: pytest.FixtureRequest, +) -> MagicMock: """Fixture to mock Client from HomeConnect.""" mock = MagicMock( @@ -369,17 +300,78 @@ def mock_client(request: pytest.FixtureRequest) -> MagicMock: ] ) + appliances = [appliance] if appliance else appliances + async def stream_all_events() -> AsyncGenerator[EventMessage]: """Mock stream_all_events.""" while True: for event in await event_queue.get(): yield event - mock.get_home_appliances = AsyncMock(return_value=copy.deepcopy(MOCK_APPLIANCES)) + mock.get_home_appliances = AsyncMock(return_value=ArrayOfHomeAppliances(appliances)) + + def _get_specific_appliance_side_effect(ha_id: str) -> HomeAppliance: + """Get specific appliance side effect.""" + for appliance_ in appliances: + if appliance_.ha_id == ha_id: + return appliance_ + raise HomeConnectApiError("error.key", "error description") + mock.get_specific_appliance = AsyncMock( side_effect=_get_specific_appliance_side_effect ) mock.stream_all_events = stream_all_events + + async def _get_all_programs_side_effect(ha_id: str) -> ArrayOfPrograms: + """Get all programs.""" + appliance_type = next( + appliance for appliance in appliances if appliance.ha_id == ha_id + ).type + if appliance_type not in MOCK_PROGRAMS: + raise HomeConnectApiError("error.key", "error description") + + return ArrayOfPrograms( + [ + EnumerateProgram.from_dict(program) + for program in MOCK_PROGRAMS[appliance_type]["data"]["programs"] + ], + Program.from_dict(MOCK_PROGRAMS[appliance_type]["data"]["programs"][0]), + Program.from_dict(MOCK_PROGRAMS[appliance_type]["data"]["programs"][0]), + ) + + async def _get_settings_side_effect(ha_id: str) -> ArrayOfSettings: + """Get settings.""" + return ArrayOfSettings.from_dict( + MOCK_SETTINGS.get( + next( + appliance for appliance in appliances if appliance.ha_id == ha_id + ).type, + {}, + ).get("data", {"settings": []}) + ) + + async def _get_setting_side_effect(ha_id: str, setting_key: SettingKey): + """Get setting.""" + for appliance_ in appliances: + if appliance_.ha_id == ha_id: + settings = MOCK_SETTINGS.get( + appliance_.type, + {}, + ).get("data", {"settings": []}) + for setting_dict in cast(list[dict], settings["settings"]): + if setting_dict["key"] == setting_key: + return GetSetting.from_dict(setting_dict) + raise HomeConnectApiError("error.key", "error description") + + async def _get_available_commands_side_effect(ha_id: str) -> ArrayOfCommands: + """Get available commands.""" + for appliance_ in appliances: + if appliance_.ha_id == ha_id and appliance_.type in MOCK_AVAILABLE_COMMANDS: + return ArrayOfCommands.from_dict( + MOCK_AVAILABLE_COMMANDS[appliance_.type] + ) + raise HomeConnectApiError("error.key", "error description") + mock.start_program = AsyncMock( side_effect=_get_set_program_side_effect( event_queue, EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM @@ -431,7 +423,11 @@ def mock_client(request: pytest.FixtureRequest) -> MagicMock: @pytest.fixture(name="client_with_exception") -def mock_client_with_exception(request: pytest.FixtureRequest) -> MagicMock: +def mock_client_with_exception( + appliances: list[HomeAppliance], + appliance: HomeAppliance | None, + request: pytest.FixtureRequest, +) -> MagicMock: """Fixture to mock Client from HomeConnect that raise exceptions.""" mock = MagicMock( autospec=HomeConnectClient, @@ -449,7 +445,8 @@ def mock_client_with_exception(request: pytest.FixtureRequest) -> MagicMock: for event in await event_queue.get(): yield event - mock.get_home_appliances = AsyncMock(return_value=copy.deepcopy(MOCK_APPLIANCES)) + appliances = [appliance] if appliance else appliances + mock.get_home_appliances = AsyncMock(return_value=ArrayOfHomeAppliances(appliances)) mock.stream_all_events = stream_all_events mock.start_program = AsyncMock(side_effect=exception) @@ -477,12 +474,52 @@ def mock_client_with_exception(request: pytest.FixtureRequest) -> MagicMock: @pytest.fixture(name="appliance_ha_id") -def mock_appliance_ha_id(request: pytest.FixtureRequest) -> str: - """Fixture to mock Appliance.""" - app = "Washer" +def mock_appliance_ha_id( + appliances: list[HomeAppliance], request: pytest.FixtureRequest +) -> str: + """Fixture to get the ha_id of an appliance.""" + appliance_type = "Washer" if hasattr(request, "param") and request.param: - app = request.param - for appliance in MOCK_APPLIANCES.homeappliances: - if appliance.type == app: + appliance_type = request.param + for appliance in appliances: + if appliance.type == appliance_type: return appliance.ha_id - raise ValueError(f"Appliance {app} not found") + raise ValueError(f"Appliance {appliance_type} not found") + + +@pytest.fixture(name="appliances") +def mock_appliances( + appliances_data: str, request: pytest.FixtureRequest +) -> list[HomeAppliance]: + """Fixture to mock the returned appliances.""" + appliances = ArrayOfHomeAppliances.from_json(appliances_data).homeappliances + appliance_types = {appliance.type for appliance in appliances} + if hasattr(request, "param") and request.param: + appliance_types = request.param + return [appliance for appliance in appliances if appliance.type in appliance_types] + + +@pytest.fixture(name="appliance") +def mock_appliance( + appliances_data: str, request: pytest.FixtureRequest +) -> HomeAppliance | None: + """Fixture to mock a single specific appliance to return.""" + appliance_type = None + if hasattr(request, "param") and request.param: + appliance_type = request.param + return next( + ( + appliance + for appliance in ArrayOfHomeAppliances.from_json( + appliances_data + ).homeappliances + if appliance.type == appliance_type + ), + None, + ) + + +@pytest.fixture(name="appliances_data") +def appliances_data_fixture() -> str: + """Fixture to return a the string for an array of appliances.""" + return load_fixture("appliances.json", integration=DOMAIN) diff --git a/tests/components/home_connect/fixtures/appliances.json b/tests/components/home_connect/fixtures/appliances.json index ada18b3482c..081dd44764f 100644 --- a/tests/components/home_connect/fixtures/appliances.json +++ b/tests/components/home_connect/fixtures/appliances.json @@ -1,123 +1,121 @@ { - "data": { - "homeappliances": [ - { - "name": "FridgeFreezer", - "brand": "SIEMENS", - "vib": "HCS05FRF1", - "connected": true, - "type": "FridgeFreezer", - "enumber": "HCS05FRF1/03", - "haId": "SIEMENS-HCS05FRF1-304F4F9E541D" - }, - { - "name": "Dishwasher", - "brand": "SIEMENS", - "vib": "HCS02DWH1", - "connected": true, - "type": "Dishwasher", - "enumber": "HCS02DWH1/03", - "haId": "SIEMENS-HCS02DWH1-6BE58C26DCC1" - }, - { - "name": "Oven", - "brand": "BOSCH", - "vib": "HCS01OVN1", - "connected": true, - "type": "Oven", - "enumber": "HCS01OVN1/03", - "haId": "BOSCH-HCS01OVN1-43E0065FE245" - }, - { - "name": "Washer", - "brand": "SIEMENS", - "vib": "HCS03WCH1", - "connected": true, - "type": "Washer", - "enumber": "HCS03WCH1/03", - "haId": "SIEMENS-HCS03WCH1-7BC6383CF794" - }, - { - "name": "Dryer", - "brand": "BOSCH", - "vib": "HCS04DYR1", - "connected": true, - "type": "Dryer", - "enumber": "HCS04DYR1/03", - "haId": "BOSCH-HCS04DYR1-831694AE3C5A" - }, - { - "name": "CoffeeMaker", - "brand": "BOSCH", - "vib": "HCS06COM1", - "connected": true, - "type": "CoffeeMaker", - "enumber": "HCS06COM1/03", - "haId": "BOSCH-HCS06COM1-D70390681C2C" - }, - { - "name": "WasherDryer", - "brand": "BOSCH", - "vib": "HCS000001", - "connected": true, - "type": "WasherDryer", - "enumber": "HCS000000/01", - "haId": "BOSCH-HCS000000-D00000000001" - }, - { - "name": "Refrigerator", - "brand": "BOSCH", - "vib": "HCS000002", - "connected": true, - "type": "Refrigerator", - "enumber": "HCS000000/02", - "haId": "BOSCH-HCS000000-D00000000002" - }, - { - "name": "Freezer", - "brand": "BOSCH", - "vib": "HCS000003", - "connected": true, - "type": "Freezer", - "enumber": "HCS000000/03", - "haId": "BOSCH-HCS000000-D00000000003" - }, - { - "name": "Hood", - "brand": "BOSCH", - "vib": "HCS000004", - "connected": true, - "type": "Hood", - "enumber": "HCS000000/04", - "haId": "BOSCH-HCS000000-D00000000004" - }, - { - "name": "Hob", - "brand": "BOSCH", - "vib": "HCS000005", - "connected": true, - "type": "Hob", - "enumber": "HCS000000/05", - "haId": "BOSCH-HCS000000-D00000000005" - }, - { - "name": "CookProcessor", - "brand": "BOSCH", - "vib": "HCS000006", - "connected": true, - "type": "CookProcessor", - "enumber": "HCS000000/06", - "haId": "BOSCH-HCS000000-D00000000006" - }, - { - "name": "DNE", - "brand": "BOSCH", - "vib": "HCS000000", - "connected": true, - "type": "DNE", - "enumber": "HCS000000/00", - "haId": "BOSCH-000000000-000000000000" - } - ] - } + "homeappliances": [ + { + "name": "FridgeFreezer", + "brand": "SIEMENS", + "vib": "HCS05FRF1", + "connected": true, + "type": "FridgeFreezer", + "enumber": "HCS05FRF1/03", + "haId": "SIEMENS-HCS05FRF1-304F4F9E541D" + }, + { + "name": "Dishwasher", + "brand": "SIEMENS", + "vib": "HCS02DWH1", + "connected": true, + "type": "Dishwasher", + "enumber": "HCS02DWH1/03", + "haId": "SIEMENS-HCS02DWH1-6BE58C26DCC1" + }, + { + "name": "Oven", + "brand": "BOSCH", + "vib": "HCS01OVN1", + "connected": true, + "type": "Oven", + "enumber": "HCS01OVN1/03", + "haId": "BOSCH-HCS01OVN1-43E0065FE245" + }, + { + "name": "Washer", + "brand": "SIEMENS", + "vib": "HCS03WCH1", + "connected": true, + "type": "Washer", + "enumber": "HCS03WCH1/03", + "haId": "SIEMENS-HCS03WCH1-7BC6383CF794" + }, + { + "name": "Dryer", + "brand": "BOSCH", + "vib": "HCS04DYR1", + "connected": true, + "type": "Dryer", + "enumber": "HCS04DYR1/03", + "haId": "BOSCH-HCS04DYR1-831694AE3C5A" + }, + { + "name": "CoffeeMaker", + "brand": "BOSCH", + "vib": "HCS06COM1", + "connected": true, + "type": "CoffeeMaker", + "enumber": "HCS06COM1/03", + "haId": "BOSCH-HCS06COM1-D70390681C2C" + }, + { + "name": "WasherDryer", + "brand": "BOSCH", + "vib": "HCS000001", + "connected": true, + "type": "WasherDryer", + "enumber": "HCS000000/01", + "haId": "BOSCH-HCS000000-D00000000001" + }, + { + "name": "Refrigerator", + "brand": "BOSCH", + "vib": "HCS000002", + "connected": true, + "type": "Refrigerator", + "enumber": "HCS000000/02", + "haId": "BOSCH-HCS000000-D00000000002" + }, + { + "name": "Freezer", + "brand": "BOSCH", + "vib": "HCS000003", + "connected": true, + "type": "Freezer", + "enumber": "HCS000000/03", + "haId": "BOSCH-HCS000000-D00000000003" + }, + { + "name": "Hood", + "brand": "BOSCH", + "vib": "HCS000004", + "connected": true, + "type": "Hood", + "enumber": "HCS000000/04", + "haId": "BOSCH-HCS000000-D00000000004" + }, + { + "name": "Hob", + "brand": "BOSCH", + "vib": "HCS000005", + "connected": true, + "type": "Hob", + "enumber": "HCS000000/05", + "haId": "BOSCH-HCS000000-D00000000005" + }, + { + "name": "CookProcessor", + "brand": "BOSCH", + "vib": "HCS000006", + "connected": true, + "type": "CookProcessor", + "enumber": "HCS000000/06", + "haId": "BOSCH-HCS000000-D00000000006" + }, + { + "name": "DNE", + "brand": "BOSCH", + "vib": "HCS000000", + "connected": true, + "type": "DNE", + "enumber": "HCS000000/00", + "haId": "BOSCH-000000000-000000000000" + } + ] } diff --git a/tests/components/home_connect/test_coordinator.py b/tests/components/home_connect/test_coordinator.py index 1a49d2bb2a0..1e584335fcd 100644 --- a/tests/components/home_connect/test_coordinator.py +++ b/tests/components/home_connect/test_coordinator.py @@ -1,19 +1,20 @@ """Test for Home Connect coordinator.""" from collections.abc import Awaitable, Callable -import copy from datetime import timedelta -from typing import Any +from typing import Any, cast from unittest.mock import AsyncMock, MagicMock, patch from aiohomeconnect.model import ( ArrayOfEvents, + ArrayOfHomeAppliances, ArrayOfSettings, ArrayOfStatus, Event, EventKey, EventMessage, EventType, + HomeAppliance, ) from aiohomeconnect.model.error import ( EventStreamInterruptedError, @@ -41,8 +42,6 @@ from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util -from . import MOCK_APPLIANCES - from tests.common import MockConfigEntry, async_fire_time_changed @@ -81,16 +80,21 @@ async def test_coordinator_update_failing_get_appliances( @pytest.mark.usefixtures("setup_credentials") @pytest.mark.parametrize("platforms", [("binary_sensor",)]) -@pytest.mark.parametrize("appliance_ha_id", ["Washer"], indirect=True) +@pytest.mark.parametrize("appliance", ["Washer"], indirect=True) async def test_coordinator_failure_refresh_and_stream( hass: HomeAssistant, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], client: MagicMock, freezer: FrozenDateTimeFactory, - appliance_ha_id: str, + appliance: HomeAppliance, ) -> None: """Test entity available state via coordinator refresh and event stream.""" + appliance_data = ( + cast(str, appliance.to_json()) + .replace("ha_id", "haId") + .replace("e_number", "enumber") + ) entity_id_1 = "binary_sensor.washer_remote_control" entity_id_2 = "binary_sensor.washer_remote_start" await async_setup_component(hass, "homeassistant", {}) @@ -121,7 +125,9 @@ async def test_coordinator_failure_refresh_and_stream( # Test that the entity becomes available again after a successful update. client.get_home_appliances.side_effect = None - client.get_home_appliances.return_value = copy.deepcopy(MOCK_APPLIANCES) + client.get_home_appliances.return_value = ArrayOfHomeAppliances( + [HomeAppliance.from_json(appliance_data)] + ) # Move time forward to pass the debounce time. freezer.tick(timedelta(hours=1)) @@ -166,11 +172,13 @@ async def test_coordinator_failure_refresh_and_stream( # Now make the entity available again. client.get_home_appliances.side_effect = None - client.get_home_appliances.return_value = copy.deepcopy(MOCK_APPLIANCES) + client.get_home_appliances.return_value = ArrayOfHomeAppliances( + [HomeAppliance.from_json(appliance_data)] + ) # One event should make all entities for this appliance available again. event_message = EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.STATUS, ArrayOfEvents( [ @@ -399,6 +407,9 @@ async def test_event_listener_error( assert not config_entry._background_tasks +@pytest.mark.usefixtures("setup_credentials") +@pytest.mark.parametrize("platforms", [("sensor",)]) +@pytest.mark.parametrize("appliance", ["Washer"], indirect=True) @pytest.mark.parametrize( "exception", [HomeConnectRequestError(), EventStreamInterruptedError()], @@ -429,11 +440,10 @@ async def test_event_listener_resilience( after_event_expected_state: str, exception: HomeConnectError, hass: HomeAssistant, + appliance: HomeAppliance, + client: MagicMock, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, - appliance_ha_id: str, ) -> None: """Test that the event listener is resilient to interruptions.""" future = hass.loop.create_future() @@ -467,7 +477,7 @@ async def test_event_listener_resilience( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.STATUS, ArrayOfEvents( [ From 3ee5262a8d5f6601d862a1a1aa3731d6c2c40b30 Mon Sep 17 00:00:00 2001 From: peteS-UK <64092177+peteS-UK@users.noreply.github.com> Date: Tue, 4 Mar 2025 23:48:13 +0000 Subject: [PATCH 2204/3148] Clean up squeezebox build_item_response part 2 (#139595) --- .../components/squeezebox/browse_media.py | 103 +++++++----------- 1 file changed, 38 insertions(+), 65 deletions(-) diff --git a/homeassistant/components/squeezebox/browse_media.py b/homeassistant/components/squeezebox/browse_media.py index 82fa55c7b2f..633f004993f 100644 --- a/homeassistant/components/squeezebox/browse_media.py +++ b/homeassistant/components/squeezebox/browse_media.py @@ -62,7 +62,7 @@ SQUEEZEBOX_ID_BY_TYPE: dict[str | MediaType, str] = { MediaType.APPS: "item_id", } -CONTENT_TYPE_MEDIA_CLASS: dict[str | MediaType, dict[str, MediaClass | None]] = { +CONTENT_TYPE_MEDIA_CLASS: dict[str | MediaType, dict[str, MediaClass | str]] = { "Favorites": {"item": MediaClass.DIRECTORY, "children": MediaClass.TRACK}, "Apps": {"item": MediaClass.DIRECTORY, "children": MediaClass.APP}, "Radios": {"item": MediaClass.DIRECTORY, "children": MediaClass.APP}, @@ -76,7 +76,7 @@ CONTENT_TYPE_MEDIA_CLASS: dict[str | MediaType, dict[str, MediaClass | None]] = "Album Artists": {"item": MediaClass.DIRECTORY, "children": MediaClass.ARTIST}, MediaType.ALBUM: {"item": MediaClass.ALBUM, "children": MediaClass.TRACK}, MediaType.ARTIST: {"item": MediaClass.ARTIST, "children": MediaClass.ALBUM}, - MediaType.TRACK: {"item": MediaClass.TRACK, "children": None}, + MediaType.TRACK: {"item": MediaClass.TRACK, "children": ""}, MediaType.GENRE: {"item": MediaClass.GENRE, "children": MediaClass.ARTIST}, MediaType.PLAYLIST: {"item": MediaClass.PLAYLIST, "children": MediaClass.TRACK}, MediaType.APP: {"item": MediaClass.DIRECTORY, "children": MediaClass.TRACK}, @@ -115,7 +115,7 @@ class BrowseData: str | MediaType, str | MediaType | None, ] = field(default_factory=dict) - content_type_media_class: dict[str | MediaType, dict[str, MediaClass | None]] = ( + content_type_media_class: dict[str | MediaType, dict[str, MediaClass | str]] = ( field(default_factory=dict) ) squeezebox_id_by_type: dict[str | MediaType, str] = field(default_factory=dict) @@ -130,18 +130,6 @@ class BrowseData: self.media_type_to_squeezebox.update(MEDIA_TYPE_TO_SQUEEZEBOX) -@dataclass -class BrowseItemResponse: - """Class for response data for browse item functions.""" - - child_item_type: str | MediaType - child_media_class: dict[str, MediaClass | None] - can_expand: bool - can_play: bool - title: str - id: str - - def _add_new_command_to_browse_data( browse_data: BrowseData, cmd: str | MediaType, type: str ) -> None: @@ -157,13 +145,13 @@ def _add_new_command_to_browse_data( def _build_response_apps_radios_category( browse_data: BrowseData, cmd: str | MediaType, item: dict[str, Any] -) -> BrowseItemResponse: +) -> BrowseMedia: """Build item for App or radio category.""" - return BrowseItemResponse( - id=item.get("id", ""), + return BrowseMedia( + media_content_id=item.get("id", ""), title=item["title"], - child_item_type=cmd, - child_media_class=browse_data.content_type_media_class[cmd], + media_content_type=cmd, + media_class=browse_data.content_type_media_class[cmd]["item"], can_expand=True, can_play=False, ) @@ -171,44 +159,44 @@ def _build_response_apps_radios_category( def _build_response_known_app( browse_data: BrowseData, search_type: str, item: dict[str, Any] -) -> BrowseItemResponse: +) -> BrowseMedia: """Build item for app or radio.""" - return BrowseItemResponse( - id=item.get("id", ""), + return BrowseMedia( + media_content_id=item.get("id", ""), title=item["title"], - child_item_type=search_type, - child_media_class=browse_data.content_type_media_class[search_type], + media_content_type=search_type, + media_class=browse_data.content_type_media_class[search_type]["item"], can_play=bool(item["isaudio"] and item.get("url")), can_expand=item["hasitems"], ) -def _build_response_favorites(item: dict[str, Any]) -> BrowseItemResponse: +def _build_response_favorites(item: dict[str, Any]) -> BrowseMedia: """Build item for Favorites.""" if "album_id" in item: - return BrowseItemResponse( - id=str(item["album_id"]), + return BrowseMedia( + media_content_id=str(item["album_id"]), title=item["title"], - child_item_type=MediaType.ALBUM, - child_media_class=CONTENT_TYPE_MEDIA_CLASS[MediaType.ALBUM], + media_content_type=MediaType.ALBUM, + media_class=CONTENT_TYPE_MEDIA_CLASS[MediaType.ALBUM]["item"], can_expand=True, can_play=True, ) if item["hasitems"] and not item["isaudio"]: - return BrowseItemResponse( - id=item.get("id", ""), + return BrowseMedia( + media_content_id=item.get("id", ""), title=item["title"], - child_item_type="Favorites", - child_media_class=CONTENT_TYPE_MEDIA_CLASS["Favorites"], + media_content_type="Favorites", + media_class=CONTENT_TYPE_MEDIA_CLASS["Favorites"]["item"], can_expand=True, can_play=False, ) - return BrowseItemResponse( - id=item.get("id", ""), + return BrowseMedia( + media_content_id=item.get("id", ""), title=item["title"], - child_item_type="Favorites", - child_media_class=CONTENT_TYPE_MEDIA_CLASS[MediaType.TRACK], + media_content_type="Favorites", + media_class=CONTENT_TYPE_MEDIA_CLASS[MediaType.TRACK]["item"], can_expand=item["hasitems"], can_play=bool(item["isaudio"] and item.get("url")), ) @@ -274,12 +262,9 @@ async def build_item_response( item_type = browse_data.content_type_to_child_type[search_type] children = [] - list_playable = [] for item in result["items"]: - item_thumbnail: str | None = None - if search_type == "Favorites": - browse_item_response = _build_response_favorites(item) + child_media = _build_response_favorites(item) elif search_type in ["Apps", "Radios"]: # item["cmd"] contains the name of the command to use with the cli for the app @@ -293,7 +278,7 @@ async def build_item_response( browse_data.known_apps_radios.add(app_cmd) _add_new_command_to_browse_data(browse_data, app_cmd, "item_id") - browse_item_response = _build_response_apps_radios_category( + child_media = _build_response_apps_radios_category( browse_data=browse_data, cmd=app_cmd, item=item ) @@ -305,22 +290,22 @@ async def build_item_response( # Skip searches in apps as they'd need UI continue - browse_item_response = _build_response_known_app( - browse_data, search_type, item - ) + child_media = _build_response_known_app(browse_data, search_type, item) elif item_type: - browse_item_response = BrowseItemResponse( - id=str(item.get("id", "")), + child_media = BrowseMedia( + media_content_id=str(item.get("id", "")), title=item["title"], - child_item_type=item_type, - child_media_class=CONTENT_TYPE_MEDIA_CLASS[item_type], + media_content_type=item_type, + media_class=CONTENT_TYPE_MEDIA_CLASS[item_type]["item"], can_expand=CONTENT_TYPE_MEDIA_CLASS[item_type]["children"] is not None, can_play=True, ) - item_thumbnail = _get_item_thumbnail( + assert child_media.media_class is not None + + child_media.thumbnail = _get_item_thumbnail( item=item, player=player, entity=entity, @@ -329,19 +314,7 @@ async def build_item_response( internal_request=internal_request, ) - assert browse_item_response.child_media_class["item"] is not None - children.append( - BrowseMedia( - title=browse_item_response.title, - media_class=browse_item_response.child_media_class["item"], - media_content_id=browse_item_response.id, - media_content_type=browse_item_response.child_item_type, - can_play=browse_item_response.can_play, - can_expand=browse_item_response.can_expand, - thumbnail=item_thumbnail, - ) - ) - list_playable.append(browse_item_response.can_play) + children.append(child_media) if children is None: raise BrowseError(f"Media not found: {search_type} / {search_id}") @@ -356,7 +329,7 @@ async def build_item_response( children_media_class=media_class["children"], media_content_id=search_id, media_content_type=search_type, - can_play=any(list_playable), + can_play=any(child.can_play for child in children), children=children, can_expand=True, ) From 366c5c3f108fca380da3734e3ede7bd7d343d0d7 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Wed, 5 Mar 2025 01:03:38 +0100 Subject: [PATCH 2205/3148] Improve unique_id tests for Shelly block devices (#139778) * Improve unique_id tests for Shelly block devices * type test --------- Co-authored-by: J. Nick Koston --- tests/components/shelly/test_switch.py | 32 ++++++++++++++++++++++---- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/tests/components/shelly/test_switch.py b/tests/components/shelly/test_switch.py index 1e5ae9dd88c..0425f883ad6 100644 --- a/tests/components/shelly/test_switch.py +++ b/tests/components/shelly/test_switch.py @@ -3,7 +3,7 @@ from copy import deepcopy from unittest.mock import AsyncMock, Mock -from aioshelly.const import MODEL_GAS +from aioshelly.const import MODEL_1PM, MODEL_GAS, MODEL_MOTION from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError, RpcCallError import pytest @@ -177,15 +177,37 @@ async def test_block_restored_motion_switch_no_last_state( assert get_entity_state(hass, entity_id) == STATE_ON +@pytest.mark.parametrize( + ("model", "sleep", "entity", "unique_id"), + [ + (MODEL_1PM, 0, "switch.test_name_channel_1", "123456789ABC-relay_0"), + ( + MODEL_MOTION, + 1000, + "switch.test_name_motion_detection", + "123456789ABC-sensor_0-motionActive", + ), + ], +) async def test_block_device_unique_ids( - hass: HomeAssistant, entity_registry: EntityRegistry, mock_block_device: Mock + hass: HomeAssistant, + entity_registry: EntityRegistry, + mock_block_device: Mock, + model: str, + sleep: int, + entity: str, + unique_id: str, ) -> None: """Test block device unique_ids.""" - await init_integration(hass, 1) + await init_integration(hass, 1, model=model, sleep_period=sleep) - entry = entity_registry.async_get("switch.test_name_channel_1") + if sleep: + mock_block_device.mock_online() + await hass.async_block_till_done(wait_background_tasks=True) + + entry = entity_registry.async_get(entity) assert entry - assert entry.unique_id == "123456789ABC-relay_0" + assert entry.unique_id == unique_id async def test_block_set_state_connection_error( From 9bc806ab21078e0decd5ba316ca25305b63c3d62 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 4 Mar 2025 14:57:03 -1000 Subject: [PATCH 2206/3148] Bump nexia to 2.2.1 (#139786) * Bump nexia to 2.2.0 changelog: https://github.com/bdraco/nexia/compare/2.1.1...2.2.0 * Apply suggestions from code review --- homeassistant/components/nexia/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/nexia/manifest.json b/homeassistant/components/nexia/manifest.json index 8a9cda14646..337378a283c 100644 --- a/homeassistant/components/nexia/manifest.json +++ b/homeassistant/components/nexia/manifest.json @@ -12,5 +12,5 @@ "documentation": "https://www.home-assistant.io/integrations/nexia", "iot_class": "cloud_polling", "loggers": ["nexia"], - "requirements": ["nexia==2.1.1"] + "requirements": ["nexia==2.2.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 1664f8c5299..ab01706915e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1483,7 +1483,7 @@ nettigo-air-monitor==4.0.0 neurio==0.3.1 # homeassistant.components.nexia -nexia==2.1.1 +nexia==2.2.1 # homeassistant.components.nextcloud nextcloudmonitor==1.5.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 299dfbb107e..910c14c7b6a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1246,7 +1246,7 @@ netmap==0.7.0.2 nettigo-air-monitor==4.0.0 # homeassistant.components.nexia -nexia==2.1.1 +nexia==2.2.1 # homeassistant.components.nextcloud nextcloudmonitor==1.5.1 From 0143a71e9705cba6f845f12458668a5ab8cc48bc Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Wed, 5 Mar 2025 04:45:23 +0200 Subject: [PATCH 2207/3148] Bump aiowebostv to 0.7.3 (#139788) --- homeassistant/components/webostv/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/webostv/manifest.json b/homeassistant/components/webostv/manifest.json index 4632bbe8c74..8ac470ae922 100644 --- a/homeassistant/components/webostv/manifest.json +++ b/homeassistant/components/webostv/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/webostv", "iot_class": "local_push", "loggers": ["aiowebostv"], - "requirements": ["aiowebostv==0.7.2"], + "requirements": ["aiowebostv==0.7.3"], "ssdp": [ { "st": "urn:lge-com:service:webos-second-screen:1" diff --git a/requirements_all.txt b/requirements_all.txt index ab01706915e..cca1a8f3f37 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -425,7 +425,7 @@ aiowatttime==0.1.1 aiowebdav2==0.3.1 # homeassistant.components.webostv -aiowebostv==0.7.2 +aiowebostv==0.7.3 # homeassistant.components.withings aiowithings==3.1.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 910c14c7b6a..5fd2b2d3da7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -407,7 +407,7 @@ aiowatttime==0.1.1 aiowebdav2==0.3.1 # homeassistant.components.webostv -aiowebostv==0.7.2 +aiowebostv==0.7.3 # homeassistant.components.withings aiowithings==3.1.6 From 49b2f8fd7ff351cdb797c05ec9563fc124a48b45 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 4 Mar 2025 16:57:27 -1000 Subject: [PATCH 2208/3148] Bump bluetooth-data-tools to 1.25.0 (#139802) changelog: https://github.com/Bluetooth-Devices/bluetooth-data-tools/compare/v1.23.4...v1.25.0 --- homeassistant/components/bluetooth/manifest.json | 2 +- homeassistant/components/ld2410_ble/manifest.json | 2 +- homeassistant/components/led_ble/manifest.json | 2 +- homeassistant/components/private_ble_device/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index 6c851e603d9..d293d450e25 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -19,7 +19,7 @@ "bleak-retry-connector==3.9.0", "bluetooth-adapters==0.21.4", "bluetooth-auto-recovery==1.4.4", - "bluetooth-data-tools==1.23.4", + "bluetooth-data-tools==1.25.0", "dbus-fast==2.33.0", "habluetooth==3.24.1" ] diff --git a/homeassistant/components/ld2410_ble/manifest.json b/homeassistant/components/ld2410_ble/manifest.json index 36d0150642e..c92bcb3294f 100644 --- a/homeassistant/components/ld2410_ble/manifest.json +++ b/homeassistant/components/ld2410_ble/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/ld2410_ble", "integration_type": "device", "iot_class": "local_push", - "requirements": ["bluetooth-data-tools==1.23.4", "ld2410-ble==0.1.1"] + "requirements": ["bluetooth-data-tools==1.25.0", "ld2410-ble==0.1.1"] } diff --git a/homeassistant/components/led_ble/manifest.json b/homeassistant/components/led_ble/manifest.json index 309399e6958..8f624a3c225 100644 --- a/homeassistant/components/led_ble/manifest.json +++ b/homeassistant/components/led_ble/manifest.json @@ -35,5 +35,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/led_ble", "iot_class": "local_polling", - "requirements": ["bluetooth-data-tools==1.23.4", "led-ble==1.1.6"] + "requirements": ["bluetooth-data-tools==1.25.0", "led-ble==1.1.6"] } diff --git a/homeassistant/components/private_ble_device/manifest.json b/homeassistant/components/private_ble_device/manifest.json index 445affbcd57..98a9f757585 100644 --- a/homeassistant/components/private_ble_device/manifest.json +++ b/homeassistant/components/private_ble_device/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/private_ble_device", "iot_class": "local_push", - "requirements": ["bluetooth-data-tools==1.23.4"] + "requirements": ["bluetooth-data-tools==1.25.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index f6181d214e2..8956f565993 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -23,7 +23,7 @@ bleak-retry-connector==3.9.0 bleak==0.22.3 bluetooth-adapters==0.21.4 bluetooth-auto-recovery==1.4.4 -bluetooth-data-tools==1.23.4 +bluetooth-data-tools==1.25.0 cached-ipaddress==0.9.2 certifi>=2021.5.30 ciso8601==2.3.2 diff --git a/requirements_all.txt b/requirements_all.txt index cca1a8f3f37..9562378660a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -640,7 +640,7 @@ bluetooth-auto-recovery==1.4.4 # homeassistant.components.ld2410_ble # homeassistant.components.led_ble # homeassistant.components.private_ble_device -bluetooth-data-tools==1.23.4 +bluetooth-data-tools==1.25.0 # homeassistant.components.bond bond-async==0.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5fd2b2d3da7..5066c08cd6e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -564,7 +564,7 @@ bluetooth-auto-recovery==1.4.4 # homeassistant.components.ld2410_ble # homeassistant.components.led_ble # homeassistant.components.private_ble_device -bluetooth-data-tools==1.23.4 +bluetooth-data-tools==1.25.0 # homeassistant.components.bond bond-async==0.2.1 From 3eb7302fde659ce10acfe29706789cbe7119d855 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 4 Mar 2025 16:57:43 -1000 Subject: [PATCH 2209/3148] Bump fnv-hash-fast to 1.4.0 (#139801) changelog: https://github.com/Bluetooth-Devices/fnv-hash-fast/compare/v1.2.6...v1.4.0 --- homeassistant/components/homekit/manifest.json | 2 +- homeassistant/components/recorder/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/homekit/manifest.json b/homeassistant/components/homekit/manifest.json index f9a31489ca4..4ae2e43dfb2 100644 --- a/homeassistant/components/homekit/manifest.json +++ b/homeassistant/components/homekit/manifest.json @@ -10,7 +10,7 @@ "loggers": ["pyhap"], "requirements": [ "HAP-python==4.9.2", - "fnv-hash-fast==1.2.6", + "fnv-hash-fast==1.4.0", "PyQRCode==1.2.1", "base36==0.1.1" ], diff --git a/homeassistant/components/recorder/manifest.json b/homeassistant/components/recorder/manifest.json index 40513c8ea24..3ba36ab86c0 100644 --- a/homeassistant/components/recorder/manifest.json +++ b/homeassistant/components/recorder/manifest.json @@ -8,7 +8,7 @@ "quality_scale": "internal", "requirements": [ "SQLAlchemy==2.0.38", - "fnv-hash-fast==1.2.6", + "fnv-hash-fast==1.4.0", "psutil-home-assistant==0.0.1" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 8956f565993..d3f49baff73 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -30,7 +30,7 @@ ciso8601==2.3.2 cronsim==2.6 cryptography==44.0.1 dbus-fast==2.33.0 -fnv-hash-fast==1.2.6 +fnv-hash-fast==1.4.0 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 habluetooth==3.24.1 diff --git a/pyproject.toml b/pyproject.toml index 7c60a931c91..55577b7769c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,7 +43,7 @@ dependencies = [ "certifi>=2021.5.30", "ciso8601==2.3.2", "cronsim==2.6", - "fnv-hash-fast==1.2.6", + "fnv-hash-fast==1.4.0", # hass-nabucasa is imported by helpers which don't depend on the cloud # integration "hass-nabucasa==0.94.0", diff --git a/requirements.txt b/requirements.txt index aef3fdb0f09..ed794e79fe9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -20,7 +20,7 @@ bcrypt==4.2.0 certifi>=2021.5.30 ciso8601==2.3.2 cronsim==2.6 -fnv-hash-fast==1.2.6 +fnv-hash-fast==1.4.0 hass-nabucasa==0.94.0 httpx==0.28.1 home-assistant-bluetooth==1.13.1 diff --git a/requirements_all.txt b/requirements_all.txt index 9562378660a..592c53c8ad1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -946,7 +946,7 @@ flux-led==1.1.3 # homeassistant.components.homekit # homeassistant.components.recorder -fnv-hash-fast==1.2.6 +fnv-hash-fast==1.4.0 # homeassistant.components.foobot foobot_async==1.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5066c08cd6e..ce1bfb91cf0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -805,7 +805,7 @@ flux-led==1.1.3 # homeassistant.components.homekit # homeassistant.components.recorder -fnv-hash-fast==1.2.6 +fnv-hash-fast==1.4.0 # homeassistant.components.foobot foobot_async==1.0.0 From e51d9bd6f45014265080fd9d296d1b9dba4e6768 Mon Sep 17 00:00:00 2001 From: Petro31 <35082313+Petro31@users.noreply.github.com> Date: Tue, 4 Mar 2025 21:58:41 -0500 Subject: [PATCH 2210/3148] Remove redundant is not None checks in Template integration (#139790) Remove redundant is not None checks --- homeassistant/components/template/cover.py | 10 ++-- homeassistant/components/template/entity.py | 2 - homeassistant/components/template/fan.py | 6 +-- homeassistant/components/template/light.py | 51 ++++++++------------- homeassistant/components/template/number.py | 2 +- homeassistant/components/template/select.py | 8 ++-- homeassistant/components/template/switch.py | 4 +- homeassistant/components/template/vacuum.py | 12 ++--- 8 files changed, 37 insertions(+), 58 deletions(-) diff --git a/homeassistant/components/template/cover.py b/homeassistant/components/template/cover.py index ef5e6bc5758..7a8e347ee8f 100644 --- a/homeassistant/components/template/cover.py +++ b/homeassistant/components/template/cover.py @@ -325,9 +325,9 @@ class CoverTemplate(TemplateEntity, CoverEntity): async def async_open_cover(self, **kwargs: Any) -> None: """Move the cover up.""" - if (open_script := self._action_scripts.get(OPEN_ACTION)) is not None: + if open_script := self._action_scripts.get(OPEN_ACTION): await self.async_run_script(open_script, context=self._context) - elif (position_script := self._action_scripts.get(POSITION_ACTION)) is not None: + elif position_script := self._action_scripts.get(POSITION_ACTION): await self.async_run_script( position_script, run_variables={"position": 100}, @@ -339,9 +339,9 @@ class CoverTemplate(TemplateEntity, CoverEntity): async def async_close_cover(self, **kwargs: Any) -> None: """Move the cover down.""" - if (close_script := self._action_scripts.get(CLOSE_ACTION)) is not None: + if close_script := self._action_scripts.get(CLOSE_ACTION): await self.async_run_script(close_script, context=self._context) - elif (position_script := self._action_scripts.get(POSITION_ACTION)) is not None: + elif position_script := self._action_scripts.get(POSITION_ACTION): await self.async_run_script( position_script, run_variables={"position": 0}, @@ -353,7 +353,7 @@ class CoverTemplate(TemplateEntity, CoverEntity): async def async_stop_cover(self, **kwargs: Any) -> None: """Fire the stop action.""" - if (stop_script := self._action_scripts.get(STOP_ACTION)) is not None: + if stop_script := self._action_scripts.get(STOP_ACTION): await self.async_run_script(stop_script, context=self._context) async def async_set_cover_position(self, **kwargs: Any) -> None: diff --git a/homeassistant/components/template/entity.py b/homeassistant/components/template/entity.py index dd8623060be..3617d9acdee 100644 --- a/homeassistant/components/template/entity.py +++ b/homeassistant/components/template/entity.py @@ -37,8 +37,6 @@ class AbstractTemplateEntity(Entity): ): """Add an action script.""" - # Cannot use self.hass because it may be None in child class - # at instantiation. self._action_scripts[script_id] = Script( self.hass, config, diff --git a/homeassistant/components/template/fan.py b/homeassistant/components/template/fan.py index 2ca05681f7f..6e0f9fe5e0c 100644 --- a/homeassistant/components/template/fan.py +++ b/homeassistant/components/template/fan.py @@ -260,7 +260,7 @@ class TemplateFan(TemplateEntity, FanEntity): """Set the percentage speed of the fan.""" self._percentage = percentage - if (script := self._action_scripts.get(CONF_SET_PERCENTAGE_ACTION)) is not None: + if script := self._action_scripts.get(CONF_SET_PERCENTAGE_ACTION): await self.async_run_script( script, run_variables={ATTR_PERCENTAGE: self._percentage}, @@ -275,9 +275,7 @@ class TemplateFan(TemplateEntity, FanEntity): """Set the preset_mode of the fan.""" self._preset_mode = preset_mode - if ( - script := self._action_scripts.get(CONF_SET_PRESET_MODE_ACTION) - ) is not None: + if script := self._action_scripts.get(CONF_SET_PRESET_MODE_ACTION): await self.async_run_script( script, run_variables={ATTR_PRESET_MODE: self._preset_mode}, diff --git a/homeassistant/components/template/light.py b/homeassistant/components/template/light.py index c7188f380bc..352f571078a 100644 --- a/homeassistant/components/template/light.py +++ b/homeassistant/components/template/light.py @@ -222,7 +222,7 @@ class LightTemplate(TemplateEntity, LightEntity): (CONF_RGBW_ACTION, ColorMode.RGBW), (CONF_RGBWW_ACTION, ColorMode.RGBWW), ): - if (action_config := config.get(action_id)) is not None: + if action_config := config.get(action_id): self.add_script(action_id, action_config, name, DOMAIN) color_modes.add(color_mode) self._supported_color_modes = filter_supported_color_modes(color_modes) @@ -232,7 +232,7 @@ class LightTemplate(TemplateEntity, LightEntity): self._color_mode = next(iter(self._supported_color_modes)) self._attr_supported_features = LightEntityFeature(0) - if self._action_scripts.get(CONF_EFFECT_ACTION) is not None: + if self._action_scripts.get(CONF_EFFECT_ACTION): self._attr_supported_features |= LightEntityFeature.EFFECT if self._supports_transition is True: self._attr_supported_features |= LightEntityFeature.TRANSITION @@ -530,12 +530,8 @@ class LightTemplate(TemplateEntity, LightEntity): if ATTR_TRANSITION in kwargs and self._supports_transition is True: common_params["transition"] = kwargs[ATTR_TRANSITION] - if ( - ATTR_COLOR_TEMP_KELVIN in kwargs - and ( - temperature_script := self._action_scripts.get(CONF_TEMPERATURE_ACTION) - ) - is not None + if ATTR_COLOR_TEMP_KELVIN in kwargs and ( + temperature_script := self._action_scripts.get(CONF_TEMPERATURE_ACTION) ): common_params["color_temp"] = color_util.color_temperature_kelvin_to_mired( kwargs[ATTR_COLOR_TEMP_KELVIN] @@ -546,10 +542,8 @@ class LightTemplate(TemplateEntity, LightEntity): run_variables=common_params, context=self._context, ) - elif ( - ATTR_EFFECT in kwargs - and (effect_script := self._action_scripts.get(CONF_EFFECT_ACTION)) - is not None + elif ATTR_EFFECT in kwargs and ( + effect_script := self._action_scripts.get(CONF_EFFECT_ACTION) ): assert self._effect_list is not None effect = kwargs[ATTR_EFFECT] @@ -567,10 +561,8 @@ class LightTemplate(TemplateEntity, LightEntity): await self.async_run_script( effect_script, run_variables=common_params, context=self._context ) - elif ( - ATTR_HS_COLOR in kwargs - and (color_script := self._action_scripts.get(CONF_COLOR_ACTION)) - is not None + elif ATTR_HS_COLOR in kwargs and ( + color_script := self._action_scripts.get(CONF_COLOR_ACTION) ): hs_value = kwargs[ATTR_HS_COLOR] common_params["hs"] = hs_value @@ -580,9 +572,8 @@ class LightTemplate(TemplateEntity, LightEntity): await self.async_run_script( color_script, run_variables=common_params, context=self._context ) - elif ( - ATTR_HS_COLOR in kwargs - and (hs_script := self._action_scripts.get(CONF_HS_ACTION)) is not None + elif ATTR_HS_COLOR in kwargs and ( + hs_script := self._action_scripts.get(CONF_HS_ACTION) ): hs_value = kwargs[ATTR_HS_COLOR] common_params["hs"] = hs_value @@ -592,10 +583,8 @@ class LightTemplate(TemplateEntity, LightEntity): await self.async_run_script( hs_script, run_variables=common_params, context=self._context ) - elif ( - ATTR_RGBWW_COLOR in kwargs - and (rgbww_script := self._action_scripts.get(CONF_RGBWW_ACTION)) - is not None + elif ATTR_RGBWW_COLOR in kwargs and ( + rgbww_script := self._action_scripts.get(CONF_RGBWW_ACTION) ): rgbww_value = kwargs[ATTR_RGBWW_COLOR] common_params["rgbww"] = rgbww_value @@ -613,9 +602,8 @@ class LightTemplate(TemplateEntity, LightEntity): await self.async_run_script( rgbww_script, run_variables=common_params, context=self._context ) - elif ( - ATTR_RGBW_COLOR in kwargs - and (rgbw_script := self._action_scripts.get(CONF_RGBW_ACTION)) is not None + elif ATTR_RGBW_COLOR in kwargs and ( + rgbw_script := self._action_scripts.get(CONF_RGBW_ACTION) ): rgbw_value = kwargs[ATTR_RGBW_COLOR] common_params["rgbw"] = rgbw_value @@ -632,9 +620,8 @@ class LightTemplate(TemplateEntity, LightEntity): await self.async_run_script( rgbw_script, run_variables=common_params, context=self._context ) - elif ( - ATTR_RGB_COLOR in kwargs - and (rgb_script := self._action_scripts.get(CONF_RGB_ACTION)) is not None + elif ATTR_RGB_COLOR in kwargs and ( + rgb_script := self._action_scripts.get(CONF_RGB_ACTION) ): rgb_value = kwargs[ATTR_RGB_COLOR] common_params["rgb"] = rgb_value @@ -645,10 +632,8 @@ class LightTemplate(TemplateEntity, LightEntity): await self.async_run_script( rgb_script, run_variables=common_params, context=self._context ) - elif ( - ATTR_BRIGHTNESS in kwargs - and (level_script := self._action_scripts.get(CONF_LEVEL_ACTION)) - is not None + elif ATTR_BRIGHTNESS in kwargs and ( + level_script := self._action_scripts.get(CONF_LEVEL_ACTION) ): await self.async_run_script( level_script, run_variables=common_params, context=self._context diff --git a/homeassistant/components/template/number.py b/homeassistant/components/template/number.py index 6661afc619c..e3654661158 100644 --- a/homeassistant/components/template/number.py +++ b/homeassistant/components/template/number.py @@ -208,7 +208,7 @@ class TemplateNumber(TemplateEntity, NumberEntity): if self._optimistic: self._attr_native_value = value self.async_write_ha_state() - if (set_value := self._action_scripts.get(CONF_SET_VALUE)) is not None: + if set_value := self._action_scripts.get(CONF_SET_VALUE): await self.async_run_script( set_value, run_variables={ATTR_VALUE: value}, diff --git a/homeassistant/components/template/select.py b/homeassistant/components/template/select.py index d3b879a695d..1e7cb781eb0 100644 --- a/homeassistant/components/template/select.py +++ b/homeassistant/components/template/select.py @@ -142,10 +142,8 @@ class TemplateSelect(TemplateEntity, SelectEntity): super().__init__(hass, config=config, unique_id=unique_id) assert self._attr_name is not None self._value_template = config[CONF_STATE] - if (selection_option := config.get(CONF_SELECT_OPTION)) is not None: - self.add_script( - CONF_SELECT_OPTION, selection_option, self._attr_name, DOMAIN - ) + if select_option := config.get(CONF_SELECT_OPTION): + self.add_script(CONF_SELECT_OPTION, select_option, self._attr_name, DOMAIN) self._options_template = config[ATTR_OPTIONS] self._attr_assumed_state = self._optimistic = config.get(CONF_OPTIMISTIC, False) self._attr_options = [] @@ -177,7 +175,7 @@ class TemplateSelect(TemplateEntity, SelectEntity): if self._optimistic: self._attr_current_option = option self.async_write_ha_state() - if (select_option := self._action_scripts.get(CONF_SELECT_OPTION)) is not None: + if select_option := self._action_scripts.get(CONF_SELECT_OPTION): await self.async_run_script( select_option, run_variables={ATTR_OPTION: option}, diff --git a/homeassistant/components/template/switch.py b/homeassistant/components/template/switch.py index 148648a7a3c..feaabc3b17c 100644 --- a/homeassistant/components/template/switch.py +++ b/homeassistant/components/template/switch.py @@ -206,7 +206,7 @@ class SwitchTemplate(TemplateEntity, SwitchEntity, RestoreEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Fire the on action.""" - if (on_script := self._action_scripts.get(CONF_TURN_ON)) is not None: + if on_script := self._action_scripts.get(CONF_TURN_ON): await self.async_run_script(on_script, context=self._context) if self._template is None: self._state = True @@ -214,7 +214,7 @@ class SwitchTemplate(TemplateEntity, SwitchEntity, RestoreEntity): async def async_turn_off(self, **kwargs: Any) -> None: """Fire the off action.""" - if (off_script := self._action_scripts.get(CONF_TURN_OFF)) is not None: + if off_script := self._action_scripts.get(CONF_TURN_OFF): await self.async_run_script(off_script, context=self._context) if self._template is None: self._state = False diff --git a/homeassistant/components/template/vacuum.py b/homeassistant/components/template/vacuum.py index ba7c330dad2..c4d41b52f31 100644 --- a/homeassistant/components/template/vacuum.py +++ b/homeassistant/components/template/vacuum.py @@ -185,27 +185,27 @@ class TemplateVacuum(TemplateEntity, StateVacuumEntity): async def async_pause(self) -> None: """Pause the cleaning task.""" - if (script := self._action_scripts.get(SERVICE_PAUSE)) is not None: + if script := self._action_scripts.get(SERVICE_PAUSE): await self.async_run_script(script, context=self._context) async def async_stop(self, **kwargs: Any) -> None: """Stop the cleaning task.""" - if (script := self._action_scripts.get(SERVICE_STOP)) is not None: + if script := self._action_scripts.get(SERVICE_STOP): await self.async_run_script(script, context=self._context) async def async_return_to_base(self, **kwargs: Any) -> None: """Set the vacuum cleaner to return to the dock.""" - if (script := self._action_scripts.get(SERVICE_RETURN_TO_BASE)) is not None: + if script := self._action_scripts.get(SERVICE_RETURN_TO_BASE): await self.async_run_script(script, context=self._context) async def async_clean_spot(self, **kwargs: Any) -> None: """Perform a spot clean-up.""" - if (script := self._action_scripts.get(SERVICE_CLEAN_SPOT)) is not None: + if script := self._action_scripts.get(SERVICE_CLEAN_SPOT): await self.async_run_script(script, context=self._context) async def async_locate(self, **kwargs: Any) -> None: """Locate the vacuum cleaner.""" - if (script := self._action_scripts.get(SERVICE_LOCATE)) is not None: + if script := self._action_scripts.get(SERVICE_LOCATE): await self.async_run_script(script, context=self._context) async def async_set_fan_speed(self, fan_speed: str, **kwargs: Any) -> None: @@ -219,7 +219,7 @@ class TemplateVacuum(TemplateEntity, StateVacuumEntity): ) return - if (script := self._action_scripts.get(SERVICE_SET_FAN_SPEED)) is not None: + if script := self._action_scripts.get(SERVICE_SET_FAN_SPEED): await self.async_run_script( script, run_variables={ATTR_FAN_SPEED: fan_speed}, context=self._context ) From 24188ffb3186d353ebbbf2e6854901e5a7d469d2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 4 Mar 2025 17:10:07 -1000 Subject: [PATCH 2211/3148] Bump zeroconf to 0.146.0 (#139804) changelog: https://github.com/python-zeroconf/python-zeroconf/compare/0.145.1...0.146.0 --- homeassistant/components/zeroconf/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/zeroconf/manifest.json b/homeassistant/components/zeroconf/manifest.json index 8abaa4a838e..a7fbfdfeada 100644 --- a/homeassistant/components/zeroconf/manifest.json +++ b/homeassistant/components/zeroconf/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "loggers": ["zeroconf"], "quality_scale": "internal", - "requirements": ["zeroconf==0.145.1"] + "requirements": ["zeroconf==0.146.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index d3f49baff73..1dd33524110 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -73,7 +73,7 @@ voluptuous-serialize==2.6.0 voluptuous==0.15.2 webrtc-models==0.3.0 yarl==1.18.3 -zeroconf==0.145.1 +zeroconf==0.146.0 # Constrain pycryptodome to avoid vulnerability # see https://github.com/home-assistant/core/pull/16238 diff --git a/pyproject.toml b/pyproject.toml index 55577b7769c..2c61c000d4a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -82,7 +82,7 @@ dependencies = [ "voluptuous-openapi==0.0.6", "yarl==1.18.3", "webrtc-models==0.3.0", - "zeroconf==0.145.1" + "zeroconf==0.146.0" ] [project.urls] diff --git a/requirements.txt b/requirements.txt index ed794e79fe9..aa47afe95ce 100644 --- a/requirements.txt +++ b/requirements.txt @@ -51,4 +51,4 @@ voluptuous-serialize==2.6.0 voluptuous-openapi==0.0.6 yarl==1.18.3 webrtc-models==0.3.0 -zeroconf==0.145.1 +zeroconf==0.146.0 diff --git a/requirements_all.txt b/requirements_all.txt index 592c53c8ad1..5bb49c10204 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3143,7 +3143,7 @@ zamg==0.3.6 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.145.1 +zeroconf==0.146.0 # homeassistant.components.zeversolar zeversolar==0.3.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ce1bfb91cf0..74c8ad94875 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2532,7 +2532,7 @@ yt-dlp[default]==2025.02.19 zamg==0.3.6 # homeassistant.components.zeroconf -zeroconf==0.145.1 +zeroconf==0.146.0 # homeassistant.components.zeversolar zeversolar==0.3.2 From 27fd0a88f4b419bdc7c4574ae5189a3d94da0401 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 4 Mar 2025 17:12:45 -1000 Subject: [PATCH 2212/3148] Bump bleak-esphome to 2.11.0 (#139803) changelog: https://github.com/Bluetooth-Devices/bleak-esphome/compare/v2.10.2...v2.11.0 --- homeassistant/components/eq3btsmart/manifest.json | 2 +- homeassistant/components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/eq3btsmart/manifest.json b/homeassistant/components/eq3btsmart/manifest.json index f106868679b..4b65852d205 100644 --- a/homeassistant/components/eq3btsmart/manifest.json +++ b/homeassistant/components/eq3btsmart/manifest.json @@ -22,5 +22,5 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["eq3btsmart"], - "requirements": ["eq3btsmart==1.4.1", "bleak-esphome==2.10.2"] + "requirements": ["eq3btsmart==1.4.1", "bleak-esphome==2.11.0"] } diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index d9ac746924f..a159c5a2a53 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -18,7 +18,7 @@ "requirements": [ "aioesphomeapi==29.3.2", "esphome-dashboard-api==1.2.3", - "bleak-esphome==2.10.2" + "bleak-esphome==2.11.0" ], "zeroconf": ["_esphomelib._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 5bb49c10204..98088180cba 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -603,7 +603,7 @@ bizkaibus==0.1.1 # homeassistant.components.eq3btsmart # homeassistant.components.esphome -bleak-esphome==2.10.2 +bleak-esphome==2.11.0 # homeassistant.components.bluetooth bleak-retry-connector==3.9.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 74c8ad94875..7e762e7413d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -534,7 +534,7 @@ bimmer-connected[china]==0.17.2 # homeassistant.components.eq3btsmart # homeassistant.components.esphome -bleak-esphome==2.10.2 +bleak-esphome==2.11.0 # homeassistant.components.bluetooth bleak-retry-connector==3.9.0 From d5d9bc1df66063df826b17a458bc3223326ab1f5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 4 Mar 2025 17:25:11 -1000 Subject: [PATCH 2213/3148] Bump ulid-transform to 1.3.0 (#139808) changelog: https://github.com/Bluetooth-Devices/ulid-transform/compare/v1.2.1...v1.3.0 --- 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 1dd33524110..d7997e9e54d 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -65,7 +65,7 @@ SQLAlchemy==2.0.38 standard-aifc==3.13.0 standard-telnetlib==3.13.0 typing-extensions>=4.12.2,<5.0 -ulid-transform==1.2.1 +ulid-transform==1.3.0 urllib3>=1.26.5,<2 uv==0.6.1 voluptuous-openapi==0.0.6 diff --git a/pyproject.toml b/pyproject.toml index 2c61c000d4a..b11c2403d69 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -71,7 +71,7 @@ dependencies = [ "standard-aifc==3.13.0", "standard-telnetlib==3.13.0", "typing-extensions>=4.12.2,<5.0", - "ulid-transform==1.2.1", + "ulid-transform==1.3.0", # Constrain urllib3 to ensure we deal with CVE-2020-26137 and CVE-2021-33503 # Temporary setting an upper bound, to prevent compat issues with urllib3>=2 # https://github.com/home-assistant/core/issues/97248 diff --git a/requirements.txt b/requirements.txt index aa47afe95ce..6d138a6060d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -43,7 +43,7 @@ SQLAlchemy==2.0.38 standard-aifc==3.13.0 standard-telnetlib==3.13.0 typing-extensions>=4.12.2,<5.0 -ulid-transform==1.2.1 +ulid-transform==1.3.0 urllib3>=1.26.5,<2 uv==0.6.1 voluptuous==0.15.2 From e60a284354b376cedbc4cc5abb005fa20de7c0a6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 4 Mar 2025 17:25:43 -1000 Subject: [PATCH 2214/3148] Bump aioesphomeapi to 29.4.0 (#139806) changelog: https://github.com/esphome/aioesphomeapi/compare/v29.3.2...v29.4.0 --- 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 a159c5a2a53..aa0f6f3752b 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -16,7 +16,7 @@ "loggers": ["aioesphomeapi", "noiseprotocol", "bleak_esphome"], "mqtt": ["esphome/discover/#"], "requirements": [ - "aioesphomeapi==29.3.2", + "aioesphomeapi==29.4.0", "esphome-dashboard-api==1.2.3", "bleak-esphome==2.11.0" ], diff --git a/requirements_all.txt b/requirements_all.txt index 98088180cba..c3a085d2a7a 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==29.3.2 +aioesphomeapi==29.4.0 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7e762e7413d..680005447ad 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -231,7 +231,7 @@ aioelectricitymaps==0.4.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==29.3.2 +aioesphomeapi==29.4.0 # homeassistant.components.flo aioflo==2021.11.0 From 782f50452299bd2a125e6074c5876cce0b104896 Mon Sep 17 00:00:00 2001 From: tdfountain <174762217+tdfountain@users.noreply.github.com> Date: Tue, 4 Mar 2025 19:26:43 -0800 Subject: [PATCH 2215/3148] Add common PDU sensors to NUT (#139669) * Add common PDU sensors and alphabetize sensors list * Back out code quality improvements * Change voltage and current status to diagnostic and disabled by default --- homeassistant/components/nut/icons.json | 15 +++++++++++ homeassistant/components/nut/sensor.py | 33 +++++++++++++++++++++++ homeassistant/components/nut/strings.json | 5 ++++ 3 files changed, 53 insertions(+) diff --git a/homeassistant/components/nut/icons.json b/homeassistant/components/nut/icons.json index 91df9d10553..261d28d712f 100644 --- a/homeassistant/components/nut/icons.json +++ b/homeassistant/components/nut/icons.json @@ -43,18 +43,33 @@ "input_bypass_phases": { "default": "mdi:information-outline" }, + "input_current_status": { + "default": "mdi:information-outline" + }, "input_frequency_status": { "default": "mdi:information-outline" }, + "input_load": { + "default": "mdi:gauge" + }, "input_phases": { "default": "mdi:information-outline" }, + "input_power": { + "default": "mdi:gauge" + }, "input_sensitivity": { "default": "mdi:information-outline" }, "input_transfer_reason": { "default": "mdi:information-outline" }, + "input_voltage_status": { + "default": "mdi:information-outline" + }, + "outlet_voltage": { + "default": "mdi:gauge" + }, "output_l1_power_percent": { "default": "mdi:gauge" }, diff --git a/homeassistant/components/nut/sensor.py b/homeassistant/components/nut/sensor.py index 2f574ec4842..bb74ea617f5 100644 --- a/homeassistant/components/nut/sensor.py +++ b/homeassistant/components/nut/sensor.py @@ -463,6 +463,12 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), + "input.voltage.status": SensorEntityDescription( + key="input.voltage.status", + translation_key="input_voltage_status", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), "input.L1-N.voltage": SensorEntityDescription( key="input.L1-N.voltage", translation_key="input_l1_n_voltage", @@ -671,6 +677,12 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), + "input.current.status": SensorEntityDescription( + key="input.current.status", + translation_key="input_current_status", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), "input.L1.current": SensorEntityDescription( key="input.L1.current", translation_key="input_l1_current", @@ -698,12 +710,26 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), + "input.load": SensorEntityDescription( + key="input.load", + translation_key="input_load", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + ), "input.phases": SensorEntityDescription( key="input.phases", translation_key="input_phases", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), + "input.power": SensorEntityDescription( + key="input.power", + translation_key="input_power", + device_class=SensorDeviceClass.APPARENT_POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), "input.realpower": SensorEntityDescription( key="input.realpower", translation_key="input_realpower", @@ -740,6 +766,13 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), + "outlet.voltage": SensorEntityDescription( + key="outlet.voltage", + translation_key="outlet_voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + ), "output.power.nominal": SensorEntityDescription( key="output.power.nominal", translation_key="output_power_nominal", diff --git a/homeassistant/components/nut/strings.json b/homeassistant/components/nut/strings.json index 1cd5415b0d6..08971732bc6 100644 --- a/homeassistant/components/nut/strings.json +++ b/homeassistant/components/nut/strings.json @@ -130,6 +130,7 @@ "name": "Input bypass L3 real power" }, "input_current": { "name": "Input current" }, + "input_current_status": { "name": "Input current status" }, "input_l1_current": { "name": "Input L1 current" }, "input_l2_current": { "name": "Input L2 current" }, "input_l3_current": { "name": "Input L3 current" }, @@ -140,19 +141,23 @@ "input_l2_frequency": { "name": "Input L2 line frequency" }, "input_l3_frequency": { "name": "Input L3 line frequency" }, "input_phases": { "name": "Input phases" }, + "input_power": { "name": "Input power" }, "input_realpower": { "name": "Input real power" }, "input_l1_realpower": { "name": "Input L1 real power" }, "input_l2_realpower": { "name": "Input L2 real power" }, "input_l3_realpower": { "name": "Input L3 real power" }, + "input_load": { "name": "Input load" }, "input_sensitivity": { "name": "Input power sensitivity" }, "input_transfer_high": { "name": "High voltage transfer" }, "input_transfer_low": { "name": "Low voltage transfer" }, "input_transfer_reason": { "name": "Voltage transfer reason" }, "input_voltage": { "name": "Input voltage" }, "input_voltage_nominal": { "name": "Nominal input voltage" }, + "input_voltage_status": { "name": "Input voltage status" }, "input_l1_n_voltage": { "name": "Input L1 voltage" }, "input_l2_n_voltage": { "name": "Input L2 voltage" }, "input_l3_n_voltage": { "name": "Input L3 voltage" }, + "outlet_voltage": { "name": "Outlet voltage" }, "output_current": { "name": "Output current" }, "output_current_nominal": { "name": "Nominal output current" }, "output_l1_current": { "name": "Output L1 current" }, From 457a7216ff877f185faee5175d350fe5a2fddb73 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 4 Mar 2025 17:51:31 -1000 Subject: [PATCH 2216/3148] Bump dbus-fast to 2.35.1 (#139809) --- 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 d293d450e25..177f0d67a03 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -20,7 +20,7 @@ "bluetooth-adapters==0.21.4", "bluetooth-auto-recovery==1.4.4", "bluetooth-data-tools==1.25.0", - "dbus-fast==2.33.0", + "dbus-fast==2.35.1", "habluetooth==3.24.1" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index d7997e9e54d..59b9b6f14af 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -29,7 +29,7 @@ certifi>=2021.5.30 ciso8601==2.3.2 cronsim==2.6 cryptography==44.0.1 -dbus-fast==2.33.0 +dbus-fast==2.35.1 fnv-hash-fast==1.4.0 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 diff --git a/requirements_all.txt b/requirements_all.txt index c3a085d2a7a..ae055360fff 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -744,7 +744,7 @@ datadog==0.15.0 datapoint==0.9.9 # homeassistant.components.bluetooth -dbus-fast==2.33.0 +dbus-fast==2.35.1 # homeassistant.components.debugpy debugpy==1.8.11 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 680005447ad..17dd1c7009d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -640,7 +640,7 @@ datadog==0.15.0 datapoint==0.9.9 # homeassistant.components.bluetooth -dbus-fast==2.33.0 +dbus-fast==2.35.1 # homeassistant.components.debugpy debugpy==1.8.11 From f0ad0e6eae2b66f667724e114a0d312cc5a32691 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 4 Mar 2025 17:51:46 -1000 Subject: [PATCH 2217/3148] Bump cached-ipaddress to 0.10.0 (#139807) --- homeassistant/components/dhcp/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/dhcp/manifest.json b/homeassistant/components/dhcp/manifest.json index 5b3a5abd26f..64fd2ff38c6 100644 --- a/homeassistant/components/dhcp/manifest.json +++ b/homeassistant/components/dhcp/manifest.json @@ -16,6 +16,6 @@ "requirements": [ "aiodhcpwatcher==1.1.1", "aiodiscover==2.6.1", - "cached-ipaddress==0.9.2" + "cached-ipaddress==0.10.0" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 59b9b6f14af..c00117efc66 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -24,7 +24,7 @@ bleak==0.22.3 bluetooth-adapters==0.21.4 bluetooth-auto-recovery==1.4.4 bluetooth-data-tools==1.25.0 -cached-ipaddress==0.9.2 +cached-ipaddress==0.10.0 certifi>=2021.5.30 ciso8601==2.3.2 cronsim==2.6 diff --git a/requirements_all.txt b/requirements_all.txt index ae055360fff..b43ccf31d37 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -686,7 +686,7 @@ btsmarthub-devicelist==0.2.3 buienradar==1.0.6 # homeassistant.components.dhcp -cached-ipaddress==0.9.2 +cached-ipaddress==0.10.0 # homeassistant.components.caldav caldav==1.3.9 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 17dd1c7009d..dfd485ef5af 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -597,7 +597,7 @@ bthome-ble==3.12.4 buienradar==1.0.6 # homeassistant.components.dhcp -cached-ipaddress==0.9.2 +cached-ipaddress==0.10.0 # homeassistant.components.caldav caldav==1.3.9 From d1995086ccce3e2c62ce5bc2d9727036f99dd78e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 4 Mar 2025 19:15:00 -1000 Subject: [PATCH 2218/3148] Bump habluetooth to 3.25.0 (#139811) changelog: https://github.com/Bluetooth-Devices/habluetooth/compare/v3.24.1...v3.25.0 --- 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 177f0d67a03..81a2aae990a 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -21,6 +21,6 @@ "bluetooth-auto-recovery==1.4.4", "bluetooth-data-tools==1.25.0", "dbus-fast==2.35.1", - "habluetooth==3.24.1" + "habluetooth==3.25.0" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index c00117efc66..b399a1a24ba 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -33,7 +33,7 @@ dbus-fast==2.35.1 fnv-hash-fast==1.4.0 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 -habluetooth==3.24.1 +habluetooth==3.25.0 hass-nabucasa==0.94.0 hassil==2.2.3 home-assistant-bluetooth==1.13.1 diff --git a/requirements_all.txt b/requirements_all.txt index b43ccf31d37..ad6799e066c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1109,7 +1109,7 @@ ha-philipsjs==3.2.2 habiticalib==0.3.7 # homeassistant.components.bluetooth -habluetooth==3.24.1 +habluetooth==3.25.0 # homeassistant.components.cloud hass-nabucasa==0.94.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index dfd485ef5af..0082cc31539 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -950,7 +950,7 @@ ha-philipsjs==3.2.2 habiticalib==0.3.7 # homeassistant.components.bluetooth -habluetooth==3.24.1 +habluetooth==3.25.0 # homeassistant.components.cloud hass-nabucasa==0.94.0 From 0d329bd83df47c3a2ce57688ef419c373ed8499b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 5 Mar 2025 07:49:18 +0100 Subject: [PATCH 2219/3148] Bump actions/upload-artifact from 4.6.0 to 4.6.1 (#139813) Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4.6.0 to 4.6.1. - [Release notes](https://github.com/actions/upload-artifact/releases) - [Commits](https://github.com/actions/upload-artifact/compare/v4.6.0...v4.6.1) --- updated-dependencies: - dependency-name: actions/upload-artifact dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yaml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index cf7b80540a1..4172d796da0 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1002,7 +1002,7 @@ jobs: overwrite: true - name: Upload test results artifact if: needs.info.outputs.skip_coverage != 'true' && !cancelled() - uses: actions/upload-artifact@v4.6.0 + uses: actions/upload-artifact@v4.6.1 with: name: test-results-full-${{ matrix.python-version }}-${{ matrix.group }} path: junit.xml @@ -1136,7 +1136,7 @@ jobs: overwrite: true - name: Upload test results artifact if: needs.info.outputs.skip_coverage != 'true' && !cancelled() - uses: actions/upload-artifact@v4.6.0 + uses: actions/upload-artifact@v4.6.1 with: name: test-results-mariadb-${{ matrix.python-version }}-${{ steps.pytest-partial.outputs.mariadb }} @@ -1272,7 +1272,7 @@ jobs: overwrite: true - name: Upload test results artifact if: needs.info.outputs.skip_coverage != 'true' && !cancelled() - uses: actions/upload-artifact@v4.6.0 + uses: actions/upload-artifact@v4.6.1 with: name: test-results-postgres-${{ matrix.python-version }}-${{ steps.pytest-partial.outputs.postgresql }} @@ -1418,7 +1418,7 @@ jobs: overwrite: true - name: Upload test results artifact if: needs.info.outputs.skip_coverage != 'true' && !cancelled() - uses: actions/upload-artifact@v4.6.0 + uses: actions/upload-artifact@v4.6.1 with: name: test-results-partial-${{ matrix.python-version }}-${{ matrix.group }} path: junit.xml From 1c045ab2228b5ba75b5faad75fcbc7b99bc7d2bc Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 5 Mar 2025 08:16:42 +0100 Subject: [PATCH 2220/3148] Bump actions/download-artifact from 4.1.8 to 4.1.9 (#139814) Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 4.1.8 to 4.1.9. - [Release notes](https://github.com/actions/download-artifact/releases) - [Commits](https://github.com/actions/download-artifact/compare/v4.1.8...v4.1.9) --- updated-dependencies: - dependency-name: actions/download-artifact dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 4172d796da0..07cbc13594c 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1463,7 +1463,7 @@ jobs: timeout-minutes: 10 steps: - name: Download all coverage artifacts - uses: actions/download-artifact@v4.1.8 + uses: actions/download-artifact@v4.1.9 with: pattern: test-results-* - name: Upload test results to Codecov From 1fb02944b740d241ea2ae49211a5e9ae47401527 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Wed, 5 Mar 2025 09:04:55 +0100 Subject: [PATCH 2221/3148] Drop BETA postfix from Matter integration's title (#139816) Drop BETA postfix from Matter title Now that the whole Matter stack of Home Assistant is officially certified, we can drop the beta flag. --- homeassistant/components/matter/manifest.json | 2 +- homeassistant/generated/integrations.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/matter/manifest.json b/homeassistant/components/matter/manifest.json index 669fa1af8c4..48f0bfa2e67 100644 --- a/homeassistant/components/matter/manifest.json +++ b/homeassistant/components/matter/manifest.json @@ -1,6 +1,6 @@ { "domain": "matter", - "name": "Matter (BETA)", + "name": "Matter", "after_dependencies": ["hassio"], "codeowners": ["@home-assistant/matter"], "config_flow": true, diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 916087075cc..eee1d22dcb0 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -3650,7 +3650,7 @@ "iot_class": "cloud_push" }, "matter": { - "name": "Matter (BETA)", + "name": "Matter", "integration_type": "hub", "config_flow": true, "iot_class": "local_push" From 13dfd27b7ee581dbe24294eec02486268b2de435 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Wed, 5 Mar 2025 09:07:45 +0100 Subject: [PATCH 2222/3148] Clean Home Connect error handling (#139817) --- .../components/home_connect/coordinator.py | 18 +++++------------- homeassistant/components/home_connect/utils.py | 8 ++------ 2 files changed, 7 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/home_connect/coordinator.py b/homeassistant/components/home_connect/coordinator.py index 7898fb7be12..dfac68084d1 100644 --- a/homeassistant/components/home_connect/coordinator.py +++ b/homeassistant/components/home_connect/coordinator.py @@ -266,7 +266,7 @@ class HomeConnectCoordinator( _LOGGER.debug( "Non-breaking error (%s) while listening for events," " continuing in %s seconds", - type(error).__name__, + error, retry_time, ) await asyncio.sleep(retry_time) @@ -343,9 +343,7 @@ class HomeConnectCoordinator( _LOGGER.debug( "Error fetching settings for %s: %s", appliance.ha_id, - error - if isinstance(error, HomeConnectApiError) - else type(error).__name__, + error, ) settings = {} try: @@ -357,9 +355,7 @@ class HomeConnectCoordinator( _LOGGER.debug( "Error fetching status for %s: %s", appliance.ha_id, - error - if isinstance(error, HomeConnectApiError) - else type(error).__name__, + error, ) status = {} @@ -373,9 +369,7 @@ class HomeConnectCoordinator( _LOGGER.debug( "Error fetching programs for %s: %s", appliance.ha_id, - error - if isinstance(error, HomeConnectApiError) - else type(error).__name__, + error, ) else: programs.extend(all_programs.programs) @@ -465,9 +459,7 @@ class HomeConnectCoordinator( _LOGGER.debug( "Error fetching options for %s: %s", ha_id, - error - if isinstance(error, HomeConnectApiError) - else type(error).__name__, + error, ) return {} diff --git a/homeassistant/components/home_connect/utils.py b/homeassistant/components/home_connect/utils.py index 108465072e1..ee5febb3cf7 100644 --- a/homeassistant/components/home_connect/utils.py +++ b/homeassistant/components/home_connect/utils.py @@ -2,7 +2,7 @@ import re -from aiohomeconnect.model.error import HomeConnectApiError, HomeConnectError +from aiohomeconnect.model.error import HomeConnectError RE_CAMEL_CASE = re.compile(r"(? dict[str, str]: """Return a translation string from a Home Connect error.""" - return { - "error": str(err) - if isinstance(err, HomeConnectApiError) - else type(err).__name__ - } + return {"error": str(err)} def bsh_key_to_translation_key(bsh_key: str) -> str: From 36412a034d10ce53f8a2986ffd41c1854dfb1e3c Mon Sep 17 00:00:00 2001 From: Dan Raper Date: Wed, 5 Mar 2025 08:27:10 +0000 Subject: [PATCH 2223/3148] Bump ohmepy to 1.4.0 (#139791) --- homeassistant/components/ohme/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ohme/manifest.json b/homeassistant/components/ohme/manifest.json index fb11fa0dd06..f31af213387 100644 --- a/homeassistant/components/ohme/manifest.json +++ b/homeassistant/components/ohme/manifest.json @@ -7,5 +7,5 @@ "integration_type": "device", "iot_class": "cloud_polling", "quality_scale": "silver", - "requirements": ["ohme==1.3.2"] + "requirements": ["ohme==1.4.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index ad6799e066c..e50533e7c0a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1553,7 +1553,7 @@ odp-amsterdam==6.0.2 oemthermostat==1.1.1 # homeassistant.components.ohme -ohme==1.3.2 +ohme==1.4.0 # homeassistant.components.ollama ollama==0.4.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0082cc31539..d89ce212743 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1301,7 +1301,7 @@ objgraph==3.5.0 odp-amsterdam==6.0.2 # homeassistant.components.ohme -ohme==1.3.2 +ohme==1.4.0 # homeassistant.components.ollama ollama==0.4.7 From bba889975ab7e2c116922edc3b77fddb64d87b80 Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Wed, 5 Mar 2025 04:45:23 +0200 Subject: [PATCH 2224/3148] Bump aiowebostv to 0.7.3 (#139788) --- homeassistant/components/webostv/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/webostv/manifest.json b/homeassistant/components/webostv/manifest.json index 4632bbe8c74..8ac470ae922 100644 --- a/homeassistant/components/webostv/manifest.json +++ b/homeassistant/components/webostv/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/webostv", "iot_class": "local_push", "loggers": ["aiowebostv"], - "requirements": ["aiowebostv==0.7.2"], + "requirements": ["aiowebostv==0.7.3"], "ssdp": [ { "st": "urn:lge-com:service:webos-second-screen:1" diff --git a/requirements_all.txt b/requirements_all.txt index ee2708cdcd3..0c4c22edb09 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -425,7 +425,7 @@ aiowatttime==0.1.1 aiowebdav2==0.3.1 # homeassistant.components.webostv -aiowebostv==0.7.2 +aiowebostv==0.7.3 # homeassistant.components.withings aiowithings==3.1.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0cfcd581b84..8500ba955c0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -407,7 +407,7 @@ aiowatttime==0.1.1 aiowebdav2==0.3.1 # homeassistant.components.webostv -aiowebostv==0.7.2 +aiowebostv==0.7.3 # homeassistant.components.withings aiowithings==3.1.6 From 08722432977439dc275d79876e6d2245cdd01507 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Wed, 5 Mar 2025 09:04:55 +0100 Subject: [PATCH 2225/3148] Drop BETA postfix from Matter integration's title (#139816) Drop BETA postfix from Matter title Now that the whole Matter stack of Home Assistant is officially certified, we can drop the beta flag. --- homeassistant/components/matter/manifest.json | 2 +- homeassistant/generated/integrations.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/matter/manifest.json b/homeassistant/components/matter/manifest.json index 669fa1af8c4..48f0bfa2e67 100644 --- a/homeassistant/components/matter/manifest.json +++ b/homeassistant/components/matter/manifest.json @@ -1,6 +1,6 @@ { "domain": "matter", - "name": "Matter (BETA)", + "name": "Matter", "after_dependencies": ["hassio"], "codeowners": ["@home-assistant/matter"], "config_flow": true, diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index e8fd68e2e24..1f5a4d9d279 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -3640,7 +3640,7 @@ "iot_class": "cloud_push" }, "matter": { - "name": "Matter (BETA)", + "name": "Matter", "integration_type": "hub", "config_flow": true, "iot_class": "local_push" From 2a11c413c7678a8e4e6c8c7abe3f8deb14727e78 Mon Sep 17 00:00:00 2001 From: SteveDiks <126147459+SteveDiks@users.noreply.github.com> Date: Wed, 5 Mar 2025 10:11:59 +0100 Subject: [PATCH 2226/3148] Split the energy and data retrieval in WeHeat (#139211) * Split the energy and data logs * Make sure that pump_info name is set to device name, bump weheat * Adding config entry * Fixed circular import * parallelisation of awaits * Update homeassistant/components/weheat/binary_sensor.py Co-authored-by: Joost Lekkerkerker * Fix undefined weheatdata --------- Co-authored-by: Joost Lekkerkerker --- homeassistant/components/weheat/__init__.py | 41 +++++- .../components/weheat/binary_sensor.py | 19 +-- homeassistant/components/weheat/const.py | 3 +- .../components/weheat/coordinator.py | 118 ++++++++++++++---- homeassistant/components/weheat/entity.py | 17 ++- homeassistant/components/weheat/manifest.json | 2 +- homeassistant/components/weheat/sensor.py | 98 ++++++++++----- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 9 files changed, 223 insertions(+), 79 deletions(-) diff --git a/homeassistant/components/weheat/__init__.py b/homeassistant/components/weheat/__init__.py index b67c3540dc5..15935f3e418 100644 --- a/homeassistant/components/weheat/__init__.py +++ b/homeassistant/components/weheat/__init__.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio from http import HTTPStatus import aiohttp @@ -18,7 +19,13 @@ from homeassistant.helpers.config_entry_oauth2_flow import ( ) from .const import API_URL, LOGGER -from .coordinator import WeheatConfigEntry, WeheatDataUpdateCoordinator +from .coordinator import ( + HeatPumpInfo, + WeheatConfigEntry, + WeheatData, + WeheatDataUpdateCoordinator, + WeheatEnergyUpdateCoordinator, +) PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR] @@ -52,14 +59,38 @@ async def async_setup_entry(hass: HomeAssistant, entry: WeheatConfigEntry) -> bo except UnauthorizedException as error: raise ConfigEntryAuthFailed from error + nr_of_pumps = len(discovered_heat_pumps) + for pump_info in discovered_heat_pumps: LOGGER.debug("Adding %s", pump_info) - # for each pump, add a coordinator - new_coordinator = WeheatDataUpdateCoordinator(hass, entry, session, pump_info) + # for each pump, add the coordinators - await new_coordinator.async_config_entry_first_refresh() + new_heat_pump = HeatPumpInfo(pump_info) + new_data_coordinator = WeheatDataUpdateCoordinator( + hass, entry, session, pump_info, nr_of_pumps + ) + new_energy_coordinator = WeheatEnergyUpdateCoordinator( + hass, entry, session, pump_info + ) - entry.runtime_data.append(new_coordinator) + entry.runtime_data.append( + WeheatData( + heat_pump_info=new_heat_pump, + data_coordinator=new_data_coordinator, + energy_coordinator=new_energy_coordinator, + ) + ) + + await asyncio.gather( + *[ + data.data_coordinator.async_config_entry_first_refresh() + for data in entry.runtime_data + ], + *[ + data.energy_coordinator.async_config_entry_first_refresh() + for data in entry.runtime_data + ], + ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/weheat/binary_sensor.py b/homeassistant/components/weheat/binary_sensor.py index 6a4a03a1e48..5e4c91fde60 100644 --- a/homeassistant/components/weheat/binary_sensor.py +++ b/homeassistant/components/weheat/binary_sensor.py @@ -14,7 +14,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType -from .coordinator import WeheatConfigEntry, WeheatDataUpdateCoordinator +from .coordinator import HeatPumpInfo, WeheatConfigEntry, WeheatDataUpdateCoordinator from .entity import WeheatEntity # Coordinator is used to centralize the data updates @@ -68,10 +68,14 @@ async def async_setup_entry( ) -> None: """Set up the sensors for weheat heat pump.""" entities = [ - WeheatHeatPumpBinarySensor(coordinator, entity_description) + WeheatHeatPumpBinarySensor( + weheatdata.heat_pump_info, + weheatdata.data_coordinator, + entity_description, + ) + for weheatdata in entry.runtime_data for entity_description in BINARY_SENSORS - for coordinator in entry.runtime_data - if entity_description.value_fn(coordinator.data) is not None + if entity_description.value_fn(weheatdata.data_coordinator.data) is not None ] async_add_entities(entities) @@ -80,20 +84,21 @@ async def async_setup_entry( class WeheatHeatPumpBinarySensor(WeheatEntity, BinarySensorEntity): """Defines a Weheat heat pump binary sensor.""" + heat_pump_info: HeatPumpInfo coordinator: WeheatDataUpdateCoordinator entity_description: WeHeatBinarySensorEntityDescription def __init__( self, + heat_pump_info: HeatPumpInfo, coordinator: WeheatDataUpdateCoordinator, entity_description: WeHeatBinarySensorEntityDescription, ) -> None: """Pass coordinator to CoordinatorEntity.""" - super().__init__(coordinator) - + super().__init__(heat_pump_info, coordinator) self.entity_description = entity_description - self._attr_unique_id = f"{coordinator.heatpump_id}_{entity_description.key}" + self._attr_unique_id = f"{heat_pump_info.heatpump_id}_{entity_description.key}" @property def is_on(self) -> bool | None: diff --git a/homeassistant/components/weheat/const.py b/homeassistant/components/weheat/const.py index e33fd983572..ee9b77281e6 100644 --- a/homeassistant/components/weheat/const.py +++ b/homeassistant/components/weheat/const.py @@ -17,7 +17,8 @@ API_URL = "https://api.weheat.nl" OAUTH2_SCOPES = ["openid", "offline_access"] -UPDATE_INTERVAL = 30 +LOG_UPDATE_INTERVAL = 120 +ENERGY_UPDATE_INTERVAL = 1800 LOGGER: Logger = getLogger(__package__) diff --git a/homeassistant/components/weheat/coordinator.py b/homeassistant/components/weheat/coordinator.py index d7e53258e9b..30ca61d0387 100644 --- a/homeassistant/components/weheat/coordinator.py +++ b/homeassistant/components/weheat/coordinator.py @@ -1,5 +1,6 @@ """Define a custom coordinator for the Weheat heatpump integration.""" +from dataclasses import dataclass from datetime import timedelta from weheat.abstractions.discovery import HeatPumpDiscovery @@ -10,6 +11,7 @@ from weheat.exceptions import ( ForbiddenException, NotFoundException, ServiceException, + TooManyRequestsException, UnauthorizedException, ) @@ -21,7 +23,9 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.config_entry_oauth2_flow import OAuth2Session from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import API_URL, DOMAIN, LOGGER, UPDATE_INTERVAL +from .const import API_URL, DOMAIN, ENERGY_UPDATE_INTERVAL, LOG_UPDATE_INTERVAL, LOGGER + +type WeheatConfigEntry = ConfigEntry[list[WeheatData]] EXCEPTIONS = ( ServiceException, @@ -29,9 +33,43 @@ EXCEPTIONS = ( ForbiddenException, BadRequestException, ApiException, + TooManyRequestsException, ) -type WeheatConfigEntry = ConfigEntry[list[WeheatDataUpdateCoordinator]] + +class HeatPumpInfo(HeatPumpDiscovery.HeatPumpInfo): + """Heat pump info with additional properties.""" + + def __init__(self, pump_info: HeatPumpDiscovery.HeatPumpInfo) -> None: + """Initialize the HeatPump object with the provided pump information. + + Args: + pump_info (HeatPumpDiscovery.HeatPumpInfo): An object containing the heat pump's discovery information, including: + - uuid (str): Unique identifier for the heat pump. + - uuid (str): Unique identifier for the heat pump. + - device_name (str): Name of the heat pump device. + - model (str): Model of the heat pump. + - sn (str): Serial number of the heat pump. + - has_dhw (bool): Indicates if the heat pump has domestic hot water functionality. + + """ + super().__init__( + pump_info.uuid, + pump_info.device_name, + pump_info.model, + pump_info.sn, + pump_info.has_dhw, + ) + + @property + def readable_name(self) -> str | None: + """Return the readable name of the heat pump.""" + return self.device_name if self.device_name else self.model + + @property + def heatpump_id(self) -> str: + """Return the heat pump id.""" + return self.uuid class WeheatDataUpdateCoordinator(DataUpdateCoordinator[HeatPump]): @@ -45,45 +83,28 @@ class WeheatDataUpdateCoordinator(DataUpdateCoordinator[HeatPump]): config_entry: WeheatConfigEntry, session: OAuth2Session, heat_pump: HeatPumpDiscovery.HeatPumpInfo, + nr_of_heat_pumps: int, ) -> None: """Initialize the data coordinator.""" super().__init__( hass, - logger=LOGGER, config_entry=config_entry, + logger=LOGGER, name=DOMAIN, - update_interval=timedelta(seconds=UPDATE_INTERVAL), + update_interval=timedelta(seconds=LOG_UPDATE_INTERVAL * nr_of_heat_pumps), ) - self.heat_pump_info = heat_pump self._heat_pump_data = HeatPump( API_URL, heat_pump.uuid, async_get_clientsession(hass) ) self.session = session - @property - def heatpump_id(self) -> str: - """Return the heat pump id.""" - return self.heat_pump_info.uuid - - @property - def readable_name(self) -> str | None: - """Return the readable name of the heat pump.""" - if self.heat_pump_info.name: - return self.heat_pump_info.name - return self.heat_pump_info.model - - @property - def model(self) -> str: - """Return the model of the heat pump.""" - return self.heat_pump_info.model - async def _async_update_data(self) -> HeatPump: """Fetch data from the API.""" await self.session.async_ensure_token_valid() try: - await self._heat_pump_data.async_get_status( + await self._heat_pump_data.async_get_logs( self.session.token[CONF_ACCESS_TOKEN] ) except UnauthorizedException as error: @@ -92,3 +113,54 @@ class WeheatDataUpdateCoordinator(DataUpdateCoordinator[HeatPump]): raise UpdateFailed(error) from error return self._heat_pump_data + + +class WeheatEnergyUpdateCoordinator(DataUpdateCoordinator[HeatPump]): + """A custom Energy coordinator for the Weheat heatpump integration.""" + + config_entry: WeheatConfigEntry + + def __init__( + self, + hass: HomeAssistant, + config_entry: WeheatConfigEntry, + session: OAuth2Session, + heat_pump: HeatPumpDiscovery.HeatPumpInfo, + ) -> None: + """Initialize the data coordinator.""" + super().__init__( + hass, + config_entry=config_entry, + logger=LOGGER, + name=DOMAIN, + update_interval=timedelta(seconds=ENERGY_UPDATE_INTERVAL), + ) + self._heat_pump_data = HeatPump( + API_URL, heat_pump.uuid, async_get_clientsession(hass) + ) + + self.session = session + + async def _async_update_data(self) -> HeatPump: + """Fetch data from the API.""" + await self.session.async_ensure_token_valid() + + try: + await self._heat_pump_data.async_get_energy( + self.session.token[CONF_ACCESS_TOKEN] + ) + except UnauthorizedException as error: + raise ConfigEntryAuthFailed from error + except EXCEPTIONS as error: + raise UpdateFailed(error) from error + + return self._heat_pump_data + + +@dataclass +class WeheatData: + """Data for the Weheat integration.""" + + heat_pump_info: HeatPumpInfo + data_coordinator: WeheatDataUpdateCoordinator + energy_coordinator: WeheatEnergyUpdateCoordinator diff --git a/homeassistant/components/weheat/entity.py b/homeassistant/components/weheat/entity.py index 079db596e19..7a12b2edcfa 100644 --- a/homeassistant/components/weheat/entity.py +++ b/homeassistant/components/weheat/entity.py @@ -3,25 +3,30 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity +from . import HeatPumpInfo from .const import DOMAIN, MANUFACTURER -from .coordinator import WeheatDataUpdateCoordinator +from .coordinator import WeheatDataUpdateCoordinator, WeheatEnergyUpdateCoordinator -class WeheatEntity(CoordinatorEntity[WeheatDataUpdateCoordinator]): +class WeheatEntity[ + _WeheatEntityT: WeheatDataUpdateCoordinator | WeheatEnergyUpdateCoordinator +](CoordinatorEntity[_WeheatEntityT]): """Defines a base Weheat entity.""" _attr_has_entity_name = True def __init__( self, - coordinator: WeheatDataUpdateCoordinator, + heat_pump_info: HeatPumpInfo, + coordinator: _WeheatEntityT, ) -> None: """Initialize the Weheat entity.""" super().__init__(coordinator) + self.heat_pump_info = heat_pump_info self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, coordinator.heatpump_id)}, - name=coordinator.readable_name, + identifiers={(DOMAIN, heat_pump_info.heatpump_id)}, + name=heat_pump_info.readable_name, manufacturer=MANUFACTURER, - model=coordinator.model, + model=heat_pump_info.model, ) diff --git a/homeassistant/components/weheat/manifest.json b/homeassistant/components/weheat/manifest.json index a408303d062..7297c601213 100644 --- a/homeassistant/components/weheat/manifest.json +++ b/homeassistant/components/weheat/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["application_credentials"], "documentation": "https://www.home-assistant.io/integrations/weheat", "iot_class": "cloud_polling", - "requirements": ["weheat==2025.2.22"] + "requirements": ["weheat==2025.2.26"] } diff --git a/homeassistant/components/weheat/sensor.py b/homeassistant/components/weheat/sensor.py index 615bfd30d18..d3b758e41eb 100644 --- a/homeassistant/components/weheat/sensor.py +++ b/homeassistant/components/weheat/sensor.py @@ -27,7 +27,12 @@ from .const import ( DISPLAY_PRECISION_WATER_TEMP, DISPLAY_PRECISION_WATTS, ) -from .coordinator import WeheatConfigEntry, WeheatDataUpdateCoordinator +from .coordinator import ( + HeatPumpInfo, + WeheatConfigEntry, + WeheatDataUpdateCoordinator, + WeheatEnergyUpdateCoordinator, +) from .entity import WeheatEntity # Coordinator is used to centralize the data updates @@ -142,22 +147,6 @@ SENSORS = [ else None ), ), - WeHeatSensorEntityDescription( - translation_key="electricity_used", - key="electricity_used", - native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - device_class=SensorDeviceClass.ENERGY, - state_class=SensorStateClass.TOTAL_INCREASING, - value_fn=lambda status: status.energy_total, - ), - WeHeatSensorEntityDescription( - translation_key="energy_output", - key="energy_output", - native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - device_class=SensorDeviceClass.ENERGY, - state_class=SensorStateClass.TOTAL_INCREASING, - value_fn=lambda status: status.energy_output, - ), WeHeatSensorEntityDescription( translation_key="compressor_rpm", key="compressor_rpm", @@ -174,7 +163,6 @@ SENSORS = [ ), ] - DHW_SENSORS = [ WeHeatSensorEntityDescription( translation_key="dhw_top_temperature", @@ -196,6 +184,25 @@ DHW_SENSORS = [ ), ] +ENERGY_SENSORS = [ + WeHeatSensorEntityDescription( + translation_key="electricity_used", + key="electricity_used", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda status: status.energy_total, + ), + WeHeatSensorEntityDescription( + translation_key="energy_output", + key="energy_output", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda status: status.energy_output, + ), +] + async def async_setup_entry( hass: HomeAssistant, @@ -203,17 +210,39 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the sensors for weheat heat pump.""" - entities = [ - WeheatHeatPumpSensor(coordinator, entity_description) - for entity_description in SENSORS - for coordinator in entry.runtime_data - ] - entities.extend( - WeheatHeatPumpSensor(coordinator, entity_description) - for entity_description in DHW_SENSORS - for coordinator in entry.runtime_data - if coordinator.heat_pump_info.has_dhw - ) + + entities: list[WeheatHeatPumpSensor] = [] + for weheatdata in entry.runtime_data: + entities.extend( + WeheatHeatPumpSensor( + weheatdata.heat_pump_info, + weheatdata.data_coordinator, + entity_description, + ) + for entity_description in SENSORS + if entity_description.value_fn(weheatdata.data_coordinator.data) is not None + ) + if weheatdata.heat_pump_info.has_dhw: + entities.extend( + WeheatHeatPumpSensor( + weheatdata.heat_pump_info, + weheatdata.data_coordinator, + entity_description, + ) + for entity_description in DHW_SENSORS + if entity_description.value_fn(weheatdata.data_coordinator.data) + is not None + ) + entities.extend( + WeheatHeatPumpSensor( + weheatdata.heat_pump_info, + weheatdata.energy_coordinator, + entity_description, + ) + for entity_description in ENERGY_SENSORS + if entity_description.value_fn(weheatdata.energy_coordinator.data) + is not None + ) async_add_entities(entities) @@ -221,20 +250,21 @@ async def async_setup_entry( class WeheatHeatPumpSensor(WeheatEntity, SensorEntity): """Defines a Weheat heat pump sensor.""" - coordinator: WeheatDataUpdateCoordinator + heat_pump_info: HeatPumpInfo + coordinator: WeheatDataUpdateCoordinator | WeheatEnergyUpdateCoordinator entity_description: WeHeatSensorEntityDescription def __init__( self, - coordinator: WeheatDataUpdateCoordinator, + heat_pump_info: HeatPumpInfo, + coordinator: WeheatDataUpdateCoordinator | WeheatEnergyUpdateCoordinator, entity_description: WeHeatSensorEntityDescription, ) -> None: """Pass coordinator to CoordinatorEntity.""" - super().__init__(coordinator) - + super().__init__(heat_pump_info, coordinator) self.entity_description = entity_description - self._attr_unique_id = f"{coordinator.heatpump_id}_{entity_description.key}" + self._attr_unique_id = f"{heat_pump_info.heatpump_id}_{entity_description.key}" @property def native_value(self) -> StateType: diff --git a/requirements_all.txt b/requirements_all.txt index e50533e7c0a..a12305a317b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3058,7 +3058,7 @@ webio-api==0.1.11 webmin-xmlrpc==0.0.2 # homeassistant.components.weheat -weheat==2025.2.22 +weheat==2025.2.26 # homeassistant.components.whirlpool whirlpool-sixth-sense==0.18.12 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d89ce212743..da65fc8ec24 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2462,7 +2462,7 @@ webio-api==0.1.11 webmin-xmlrpc==0.0.2 # homeassistant.components.weheat -weheat==2025.2.22 +weheat==2025.2.26 # homeassistant.components.whirlpool whirlpool-sixth-sense==0.18.12 From b41fc932c594e04a86b58faac70679d465f66f4e Mon Sep 17 00:00:00 2001 From: SteveDiks <126147459+SteveDiks@users.noreply.github.com> Date: Wed, 5 Mar 2025 10:11:59 +0100 Subject: [PATCH 2227/3148] Split the energy and data retrieval in WeHeat (#139211) * Split the energy and data logs * Make sure that pump_info name is set to device name, bump weheat * Adding config entry * Fixed circular import * parallelisation of awaits * Update homeassistant/components/weheat/binary_sensor.py Co-authored-by: Joost Lekkerkerker * Fix undefined weheatdata --------- Co-authored-by: Joost Lekkerkerker --- homeassistant/components/weheat/__init__.py | 41 +++++- .../components/weheat/binary_sensor.py | 19 +-- homeassistant/components/weheat/const.py | 3 +- .../components/weheat/coordinator.py | 118 ++++++++++++++---- homeassistant/components/weheat/entity.py | 17 ++- homeassistant/components/weheat/manifest.json | 2 +- homeassistant/components/weheat/sensor.py | 98 ++++++++++----- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 9 files changed, 223 insertions(+), 79 deletions(-) diff --git a/homeassistant/components/weheat/__init__.py b/homeassistant/components/weheat/__init__.py index b67c3540dc5..15935f3e418 100644 --- a/homeassistant/components/weheat/__init__.py +++ b/homeassistant/components/weheat/__init__.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio from http import HTTPStatus import aiohttp @@ -18,7 +19,13 @@ from homeassistant.helpers.config_entry_oauth2_flow import ( ) from .const import API_URL, LOGGER -from .coordinator import WeheatConfigEntry, WeheatDataUpdateCoordinator +from .coordinator import ( + HeatPumpInfo, + WeheatConfigEntry, + WeheatData, + WeheatDataUpdateCoordinator, + WeheatEnergyUpdateCoordinator, +) PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR] @@ -52,14 +59,38 @@ async def async_setup_entry(hass: HomeAssistant, entry: WeheatConfigEntry) -> bo except UnauthorizedException as error: raise ConfigEntryAuthFailed from error + nr_of_pumps = len(discovered_heat_pumps) + for pump_info in discovered_heat_pumps: LOGGER.debug("Adding %s", pump_info) - # for each pump, add a coordinator - new_coordinator = WeheatDataUpdateCoordinator(hass, entry, session, pump_info) + # for each pump, add the coordinators - await new_coordinator.async_config_entry_first_refresh() + new_heat_pump = HeatPumpInfo(pump_info) + new_data_coordinator = WeheatDataUpdateCoordinator( + hass, entry, session, pump_info, nr_of_pumps + ) + new_energy_coordinator = WeheatEnergyUpdateCoordinator( + hass, entry, session, pump_info + ) - entry.runtime_data.append(new_coordinator) + entry.runtime_data.append( + WeheatData( + heat_pump_info=new_heat_pump, + data_coordinator=new_data_coordinator, + energy_coordinator=new_energy_coordinator, + ) + ) + + await asyncio.gather( + *[ + data.data_coordinator.async_config_entry_first_refresh() + for data in entry.runtime_data + ], + *[ + data.energy_coordinator.async_config_entry_first_refresh() + for data in entry.runtime_data + ], + ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/weheat/binary_sensor.py b/homeassistant/components/weheat/binary_sensor.py index 6a4a03a1e48..5e4c91fde60 100644 --- a/homeassistant/components/weheat/binary_sensor.py +++ b/homeassistant/components/weheat/binary_sensor.py @@ -14,7 +14,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType -from .coordinator import WeheatConfigEntry, WeheatDataUpdateCoordinator +from .coordinator import HeatPumpInfo, WeheatConfigEntry, WeheatDataUpdateCoordinator from .entity import WeheatEntity # Coordinator is used to centralize the data updates @@ -68,10 +68,14 @@ async def async_setup_entry( ) -> None: """Set up the sensors for weheat heat pump.""" entities = [ - WeheatHeatPumpBinarySensor(coordinator, entity_description) + WeheatHeatPumpBinarySensor( + weheatdata.heat_pump_info, + weheatdata.data_coordinator, + entity_description, + ) + for weheatdata in entry.runtime_data for entity_description in BINARY_SENSORS - for coordinator in entry.runtime_data - if entity_description.value_fn(coordinator.data) is not None + if entity_description.value_fn(weheatdata.data_coordinator.data) is not None ] async_add_entities(entities) @@ -80,20 +84,21 @@ async def async_setup_entry( class WeheatHeatPumpBinarySensor(WeheatEntity, BinarySensorEntity): """Defines a Weheat heat pump binary sensor.""" + heat_pump_info: HeatPumpInfo coordinator: WeheatDataUpdateCoordinator entity_description: WeHeatBinarySensorEntityDescription def __init__( self, + heat_pump_info: HeatPumpInfo, coordinator: WeheatDataUpdateCoordinator, entity_description: WeHeatBinarySensorEntityDescription, ) -> None: """Pass coordinator to CoordinatorEntity.""" - super().__init__(coordinator) - + super().__init__(heat_pump_info, coordinator) self.entity_description = entity_description - self._attr_unique_id = f"{coordinator.heatpump_id}_{entity_description.key}" + self._attr_unique_id = f"{heat_pump_info.heatpump_id}_{entity_description.key}" @property def is_on(self) -> bool | None: diff --git a/homeassistant/components/weheat/const.py b/homeassistant/components/weheat/const.py index e33fd983572..ee9b77281e6 100644 --- a/homeassistant/components/weheat/const.py +++ b/homeassistant/components/weheat/const.py @@ -17,7 +17,8 @@ API_URL = "https://api.weheat.nl" OAUTH2_SCOPES = ["openid", "offline_access"] -UPDATE_INTERVAL = 30 +LOG_UPDATE_INTERVAL = 120 +ENERGY_UPDATE_INTERVAL = 1800 LOGGER: Logger = getLogger(__package__) diff --git a/homeassistant/components/weheat/coordinator.py b/homeassistant/components/weheat/coordinator.py index d7e53258e9b..30ca61d0387 100644 --- a/homeassistant/components/weheat/coordinator.py +++ b/homeassistant/components/weheat/coordinator.py @@ -1,5 +1,6 @@ """Define a custom coordinator for the Weheat heatpump integration.""" +from dataclasses import dataclass from datetime import timedelta from weheat.abstractions.discovery import HeatPumpDiscovery @@ -10,6 +11,7 @@ from weheat.exceptions import ( ForbiddenException, NotFoundException, ServiceException, + TooManyRequestsException, UnauthorizedException, ) @@ -21,7 +23,9 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.config_entry_oauth2_flow import OAuth2Session from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import API_URL, DOMAIN, LOGGER, UPDATE_INTERVAL +from .const import API_URL, DOMAIN, ENERGY_UPDATE_INTERVAL, LOG_UPDATE_INTERVAL, LOGGER + +type WeheatConfigEntry = ConfigEntry[list[WeheatData]] EXCEPTIONS = ( ServiceException, @@ -29,9 +33,43 @@ EXCEPTIONS = ( ForbiddenException, BadRequestException, ApiException, + TooManyRequestsException, ) -type WeheatConfigEntry = ConfigEntry[list[WeheatDataUpdateCoordinator]] + +class HeatPumpInfo(HeatPumpDiscovery.HeatPumpInfo): + """Heat pump info with additional properties.""" + + def __init__(self, pump_info: HeatPumpDiscovery.HeatPumpInfo) -> None: + """Initialize the HeatPump object with the provided pump information. + + Args: + pump_info (HeatPumpDiscovery.HeatPumpInfo): An object containing the heat pump's discovery information, including: + - uuid (str): Unique identifier for the heat pump. + - uuid (str): Unique identifier for the heat pump. + - device_name (str): Name of the heat pump device. + - model (str): Model of the heat pump. + - sn (str): Serial number of the heat pump. + - has_dhw (bool): Indicates if the heat pump has domestic hot water functionality. + + """ + super().__init__( + pump_info.uuid, + pump_info.device_name, + pump_info.model, + pump_info.sn, + pump_info.has_dhw, + ) + + @property + def readable_name(self) -> str | None: + """Return the readable name of the heat pump.""" + return self.device_name if self.device_name else self.model + + @property + def heatpump_id(self) -> str: + """Return the heat pump id.""" + return self.uuid class WeheatDataUpdateCoordinator(DataUpdateCoordinator[HeatPump]): @@ -45,45 +83,28 @@ class WeheatDataUpdateCoordinator(DataUpdateCoordinator[HeatPump]): config_entry: WeheatConfigEntry, session: OAuth2Session, heat_pump: HeatPumpDiscovery.HeatPumpInfo, + nr_of_heat_pumps: int, ) -> None: """Initialize the data coordinator.""" super().__init__( hass, - logger=LOGGER, config_entry=config_entry, + logger=LOGGER, name=DOMAIN, - update_interval=timedelta(seconds=UPDATE_INTERVAL), + update_interval=timedelta(seconds=LOG_UPDATE_INTERVAL * nr_of_heat_pumps), ) - self.heat_pump_info = heat_pump self._heat_pump_data = HeatPump( API_URL, heat_pump.uuid, async_get_clientsession(hass) ) self.session = session - @property - def heatpump_id(self) -> str: - """Return the heat pump id.""" - return self.heat_pump_info.uuid - - @property - def readable_name(self) -> str | None: - """Return the readable name of the heat pump.""" - if self.heat_pump_info.name: - return self.heat_pump_info.name - return self.heat_pump_info.model - - @property - def model(self) -> str: - """Return the model of the heat pump.""" - return self.heat_pump_info.model - async def _async_update_data(self) -> HeatPump: """Fetch data from the API.""" await self.session.async_ensure_token_valid() try: - await self._heat_pump_data.async_get_status( + await self._heat_pump_data.async_get_logs( self.session.token[CONF_ACCESS_TOKEN] ) except UnauthorizedException as error: @@ -92,3 +113,54 @@ class WeheatDataUpdateCoordinator(DataUpdateCoordinator[HeatPump]): raise UpdateFailed(error) from error return self._heat_pump_data + + +class WeheatEnergyUpdateCoordinator(DataUpdateCoordinator[HeatPump]): + """A custom Energy coordinator for the Weheat heatpump integration.""" + + config_entry: WeheatConfigEntry + + def __init__( + self, + hass: HomeAssistant, + config_entry: WeheatConfigEntry, + session: OAuth2Session, + heat_pump: HeatPumpDiscovery.HeatPumpInfo, + ) -> None: + """Initialize the data coordinator.""" + super().__init__( + hass, + config_entry=config_entry, + logger=LOGGER, + name=DOMAIN, + update_interval=timedelta(seconds=ENERGY_UPDATE_INTERVAL), + ) + self._heat_pump_data = HeatPump( + API_URL, heat_pump.uuid, async_get_clientsession(hass) + ) + + self.session = session + + async def _async_update_data(self) -> HeatPump: + """Fetch data from the API.""" + await self.session.async_ensure_token_valid() + + try: + await self._heat_pump_data.async_get_energy( + self.session.token[CONF_ACCESS_TOKEN] + ) + except UnauthorizedException as error: + raise ConfigEntryAuthFailed from error + except EXCEPTIONS as error: + raise UpdateFailed(error) from error + + return self._heat_pump_data + + +@dataclass +class WeheatData: + """Data for the Weheat integration.""" + + heat_pump_info: HeatPumpInfo + data_coordinator: WeheatDataUpdateCoordinator + energy_coordinator: WeheatEnergyUpdateCoordinator diff --git a/homeassistant/components/weheat/entity.py b/homeassistant/components/weheat/entity.py index 079db596e19..7a12b2edcfa 100644 --- a/homeassistant/components/weheat/entity.py +++ b/homeassistant/components/weheat/entity.py @@ -3,25 +3,30 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity +from . import HeatPumpInfo from .const import DOMAIN, MANUFACTURER -from .coordinator import WeheatDataUpdateCoordinator +from .coordinator import WeheatDataUpdateCoordinator, WeheatEnergyUpdateCoordinator -class WeheatEntity(CoordinatorEntity[WeheatDataUpdateCoordinator]): +class WeheatEntity[ + _WeheatEntityT: WeheatDataUpdateCoordinator | WeheatEnergyUpdateCoordinator +](CoordinatorEntity[_WeheatEntityT]): """Defines a base Weheat entity.""" _attr_has_entity_name = True def __init__( self, - coordinator: WeheatDataUpdateCoordinator, + heat_pump_info: HeatPumpInfo, + coordinator: _WeheatEntityT, ) -> None: """Initialize the Weheat entity.""" super().__init__(coordinator) + self.heat_pump_info = heat_pump_info self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, coordinator.heatpump_id)}, - name=coordinator.readable_name, + identifiers={(DOMAIN, heat_pump_info.heatpump_id)}, + name=heat_pump_info.readable_name, manufacturer=MANUFACTURER, - model=coordinator.model, + model=heat_pump_info.model, ) diff --git a/homeassistant/components/weheat/manifest.json b/homeassistant/components/weheat/manifest.json index a408303d062..7297c601213 100644 --- a/homeassistant/components/weheat/manifest.json +++ b/homeassistant/components/weheat/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["application_credentials"], "documentation": "https://www.home-assistant.io/integrations/weheat", "iot_class": "cloud_polling", - "requirements": ["weheat==2025.2.22"] + "requirements": ["weheat==2025.2.26"] } diff --git a/homeassistant/components/weheat/sensor.py b/homeassistant/components/weheat/sensor.py index 615bfd30d18..d3b758e41eb 100644 --- a/homeassistant/components/weheat/sensor.py +++ b/homeassistant/components/weheat/sensor.py @@ -27,7 +27,12 @@ from .const import ( DISPLAY_PRECISION_WATER_TEMP, DISPLAY_PRECISION_WATTS, ) -from .coordinator import WeheatConfigEntry, WeheatDataUpdateCoordinator +from .coordinator import ( + HeatPumpInfo, + WeheatConfigEntry, + WeheatDataUpdateCoordinator, + WeheatEnergyUpdateCoordinator, +) from .entity import WeheatEntity # Coordinator is used to centralize the data updates @@ -142,22 +147,6 @@ SENSORS = [ else None ), ), - WeHeatSensorEntityDescription( - translation_key="electricity_used", - key="electricity_used", - native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - device_class=SensorDeviceClass.ENERGY, - state_class=SensorStateClass.TOTAL_INCREASING, - value_fn=lambda status: status.energy_total, - ), - WeHeatSensorEntityDescription( - translation_key="energy_output", - key="energy_output", - native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - device_class=SensorDeviceClass.ENERGY, - state_class=SensorStateClass.TOTAL_INCREASING, - value_fn=lambda status: status.energy_output, - ), WeHeatSensorEntityDescription( translation_key="compressor_rpm", key="compressor_rpm", @@ -174,7 +163,6 @@ SENSORS = [ ), ] - DHW_SENSORS = [ WeHeatSensorEntityDescription( translation_key="dhw_top_temperature", @@ -196,6 +184,25 @@ DHW_SENSORS = [ ), ] +ENERGY_SENSORS = [ + WeHeatSensorEntityDescription( + translation_key="electricity_used", + key="electricity_used", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda status: status.energy_total, + ), + WeHeatSensorEntityDescription( + translation_key="energy_output", + key="energy_output", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda status: status.energy_output, + ), +] + async def async_setup_entry( hass: HomeAssistant, @@ -203,17 +210,39 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the sensors for weheat heat pump.""" - entities = [ - WeheatHeatPumpSensor(coordinator, entity_description) - for entity_description in SENSORS - for coordinator in entry.runtime_data - ] - entities.extend( - WeheatHeatPumpSensor(coordinator, entity_description) - for entity_description in DHW_SENSORS - for coordinator in entry.runtime_data - if coordinator.heat_pump_info.has_dhw - ) + + entities: list[WeheatHeatPumpSensor] = [] + for weheatdata in entry.runtime_data: + entities.extend( + WeheatHeatPumpSensor( + weheatdata.heat_pump_info, + weheatdata.data_coordinator, + entity_description, + ) + for entity_description in SENSORS + if entity_description.value_fn(weheatdata.data_coordinator.data) is not None + ) + if weheatdata.heat_pump_info.has_dhw: + entities.extend( + WeheatHeatPumpSensor( + weheatdata.heat_pump_info, + weheatdata.data_coordinator, + entity_description, + ) + for entity_description in DHW_SENSORS + if entity_description.value_fn(weheatdata.data_coordinator.data) + is not None + ) + entities.extend( + WeheatHeatPumpSensor( + weheatdata.heat_pump_info, + weheatdata.energy_coordinator, + entity_description, + ) + for entity_description in ENERGY_SENSORS + if entity_description.value_fn(weheatdata.energy_coordinator.data) + is not None + ) async_add_entities(entities) @@ -221,20 +250,21 @@ async def async_setup_entry( class WeheatHeatPumpSensor(WeheatEntity, SensorEntity): """Defines a Weheat heat pump sensor.""" - coordinator: WeheatDataUpdateCoordinator + heat_pump_info: HeatPumpInfo + coordinator: WeheatDataUpdateCoordinator | WeheatEnergyUpdateCoordinator entity_description: WeHeatSensorEntityDescription def __init__( self, - coordinator: WeheatDataUpdateCoordinator, + heat_pump_info: HeatPumpInfo, + coordinator: WeheatDataUpdateCoordinator | WeheatEnergyUpdateCoordinator, entity_description: WeHeatSensorEntityDescription, ) -> None: """Pass coordinator to CoordinatorEntity.""" - super().__init__(coordinator) - + super().__init__(heat_pump_info, coordinator) self.entity_description = entity_description - self._attr_unique_id = f"{coordinator.heatpump_id}_{entity_description.key}" + self._attr_unique_id = f"{heat_pump_info.heatpump_id}_{entity_description.key}" @property def native_value(self) -> StateType: diff --git a/requirements_all.txt b/requirements_all.txt index 0c4c22edb09..10dda4e324a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3058,7 +3058,7 @@ webio-api==0.1.11 webmin-xmlrpc==0.0.2 # homeassistant.components.weheat -weheat==2025.2.22 +weheat==2025.2.26 # homeassistant.components.whirlpool whirlpool-sixth-sense==0.18.12 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8500ba955c0..866d850c5d5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2462,7 +2462,7 @@ webio-api==0.1.11 webmin-xmlrpc==0.0.2 # homeassistant.components.weheat -weheat==2025.2.22 +weheat==2025.2.26 # homeassistant.components.whirlpool whirlpool-sixth-sense==0.18.12 From 7001f8daaf4e26e6722f527ecee77d71efdcea72 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 5 Mar 2025 09:39:26 +0000 Subject: [PATCH 2228/3148] Bump version to 2025.3.0b6 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 0e7a9d0427d..79c831a3033 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -25,7 +25,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2025 MINOR_VERSION: Final = 3 -PATCH_VERSION: Final = "0b5" +PATCH_VERSION: Final = "0b6" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 13, 0) diff --git a/pyproject.toml b/pyproject.toml index 41506b3de71..a5c1c55fa3c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.3.0b5" +version = "2025.3.0b6" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 09561aeb397042b1c48aa7b3a3a2633afe2b4591 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 5 Mar 2025 10:43:29 +0100 Subject: [PATCH 2229/3148] Improve frame helper tests (#139821) --- tests/helpers/test_frame.py | 32 ++++++++++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/tests/helpers/test_frame.py b/tests/helpers/test_frame.py index fb98111fd42..d86693dcf9b 100644 --- a/tests/helpers/test_frame.py +++ b/tests/helpers/test_frame.py @@ -449,11 +449,18 @@ async def test_report( @pytest.mark.parametrize( - ("behavior", "integration_domain", "source", "logs_again"), + ( + "behavior", + "integration_domain", + "integration_frame_path", + "source", + "logs_again", + ), [ pytest.param( "core_behavior", None, + "homeassistant", "code that", True, id="core", @@ -461,6 +468,7 @@ async def test_report( pytest.param( "core_behavior", "unknown_integration", + "homeassistant", "code that", True, id="unknown integration", @@ -468,6 +476,7 @@ async def test_report( pytest.param( "core_integration_behavior", "sensor", + "homeassistant", "that integration 'sensor'", False, id="core integration", @@ -475,13 +484,32 @@ async def test_report( pytest.param( "custom_integration_behavior", "test_package", + "homeassistant", "that custom integration 'test_package'", False, id="custom integration", ), + # Assert integration found in stack frame has priority over integration_domain + pytest.param( + "core_integration_behavior", + "sensor", + "homeassistant/components/hue", + "that integration 'hue'", + False, + id="core integration", + ), + # Assert integration found in stack frame has priority over integration_domain + pytest.param( + "custom_integration_behavior", + "test_package", + "custom_components/hue", + "that custom integration 'hue'", + False, + id="custom integration", + ), ], ) -@pytest.mark.usefixtures("enable_custom_integrations") +@pytest.mark.usefixtures("enable_custom_integrations", "mock_integration_frame") async def test_report_integration_domain( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, From 5f88354cb329836c03ba6ed0460462e1ce47d04b Mon Sep 17 00:00:00 2001 From: Dan Raper Date: Wed, 5 Mar 2025 09:59:47 +0000 Subject: [PATCH 2230/3148] Add vehicle select to Ohme (#139795) * Add vehicle select to Ohme * mypy fixes * Update homeassistant/components/ohme/select.py Co-authored-by: Josef Zweck --------- Co-authored-by: Josef Zweck --- homeassistant/components/ohme/icons.json | 3 + homeassistant/components/ohme/select.py | 30 +++++++++- homeassistant/components/ohme/strings.json | 3 + tests/components/ohme/conftest.py | 2 + .../ohme/snapshots/test_select.ambr | 56 +++++++++++++++++++ 5 files changed, 91 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ohme/icons.json b/homeassistant/components/ohme/icons.json index 9771b0bf5c2..0e4d58a5294 100644 --- a/homeassistant/components/ohme/icons.json +++ b/homeassistant/components/ohme/icons.json @@ -16,6 +16,9 @@ "select": { "charge_mode": { "default": "mdi:play-box" + }, + "vehicle": { + "default": "mdi:car" } }, "sensor": { diff --git a/homeassistant/components/ohme/select.py b/homeassistant/components/ohme/select.py index 17cc7c67e9a..f065afeb176 100644 --- a/homeassistant/components/ohme/select.py +++ b/homeassistant/components/ohme/select.py @@ -25,10 +25,12 @@ class OhmeSelectDescription(OhmeEntityDescription, SelectEntityDescription): """Class to describe an Ohme select entity.""" select_fn: Callable[[OhmeApiClient, Any], Awaitable[None]] + options: list[str] | None = None + options_fn: Callable[[OhmeApiClient], list[str]] | None = None current_option_fn: Callable[[OhmeApiClient], str | None] -SELECT_DESCRIPTION: Final[OhmeSelectDescription] = OhmeSelectDescription( +MODE_SELECT_DESCRIPTION: Final[OhmeSelectDescription] = OhmeSelectDescription( key="charge_mode", translation_key="charge_mode", select_fn=lambda client, mode: client.async_set_mode(mode), @@ -37,6 +39,14 @@ SELECT_DESCRIPTION: Final[OhmeSelectDescription] = OhmeSelectDescription( available_fn=lambda client: client.mode is not None, ) +VEHICLE_SELECT_DESCRIPTION: Final[OhmeSelectDescription] = OhmeSelectDescription( + key="vehicle", + translation_key="vehicle", + select_fn=lambda client, selection: client.async_set_vehicle(selection), + options_fn=lambda client: client.vehicles, + current_option_fn=lambda client: client.current_vehicle or None, +) + async def async_setup_entry( hass: HomeAssistant, @@ -44,9 +54,15 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Ohme selects.""" - coordinator = config_entry.runtime_data.charge_session_coordinator + charge_sessions_coordinator = config_entry.runtime_data.charge_session_coordinator + device_info_coordinator = config_entry.runtime_data.device_info_coordinator - async_add_entities([OhmeSelect(coordinator, SELECT_DESCRIPTION)]) + async_add_entities( + [ + OhmeSelect(charge_sessions_coordinator, MODE_SELECT_DESCRIPTION), + OhmeSelect(device_info_coordinator, VEHICLE_SELECT_DESCRIPTION), + ] + ) class OhmeSelect(OhmeEntity, SelectEntity): @@ -64,6 +80,14 @@ class OhmeSelect(OhmeEntity, SelectEntity): ) from e await self.coordinator.async_request_refresh() + @property + def options(self) -> list[str]: + """Return a set of selectable options.""" + if self.entity_description.options_fn: + return self.entity_description.options_fn(self.coordinator.client) + assert self.entity_description.options + return self.entity_description.options + @property def current_option(self) -> str | None: """Return the current selected option.""" diff --git a/homeassistant/components/ohme/strings.json b/homeassistant/components/ohme/strings.json index 4c845daa8f0..187e825c159 100644 --- a/homeassistant/components/ohme/strings.json +++ b/homeassistant/components/ohme/strings.json @@ -66,6 +66,9 @@ "max_charge": "Max charge", "paused": "Paused" } + }, + "vehicle": { + "name": "Vehicle" } }, "sensor": { diff --git a/tests/components/ohme/conftest.py b/tests/components/ohme/conftest.py index 01cc668ae32..d05e34d1ed2 100644 --- a/tests/components/ohme/conftest.py +++ b/tests/components/ohme/conftest.py @@ -66,4 +66,6 @@ def mock_client(): "model": "Home Pro", "sw_version": "v2.65", } + client.vehicles = ["Nissan Leaf", "Tesla Model 3"] + client.current_vehicle = "Nissan Leaf" yield client diff --git a/tests/components/ohme/snapshots/test_select.ambr b/tests/components/ohme/snapshots/test_select.ambr index 8eec0556889..063a9616588 100644 --- a/tests/components/ohme/snapshots/test_select.ambr +++ b/tests/components/ohme/snapshots/test_select.ambr @@ -57,3 +57,59 @@ 'state': 'unknown', }) # --- +# name: test_selects[select.ohme_home_pro_vehicle-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'Nissan Leaf', + 'Tesla Model 3', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.ohme_home_pro_vehicle', + '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': 'Vehicle', + 'platform': 'ohme', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'vehicle', + 'unique_id': 'chargerid_vehicle', + 'unit_of_measurement': None, + }) +# --- +# name: test_selects[select.ohme_home_pro_vehicle-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Ohme Home Pro Vehicle', + 'options': list([ + 'Nissan Leaf', + 'Tesla Model 3', + ]), + }), + 'context': , + 'entity_id': 'select.ohme_home_pro_vehicle', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Nissan Leaf', + }) +# --- From 7fe75a959fbe0aa4a053d5c9c888ceb9221f8c0d Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 5 Mar 2025 11:54:58 +0100 Subject: [PATCH 2231/3148] Update frontend to 20250305.0 (#139829) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index d8eb53467f0..e661439cff2 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20250228.0"] + "requirements": ["home-assistant-frontend==20250305.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index b399a1a24ba..1df15df867f 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -37,7 +37,7 @@ habluetooth==3.25.0 hass-nabucasa==0.94.0 hassil==2.2.3 home-assistant-bluetooth==1.13.1 -home-assistant-frontend==20250228.0 +home-assistant-frontend==20250305.0 home-assistant-intents==2025.2.26 httpx==0.28.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index a12305a317b..535f3eace46 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1152,7 +1152,7 @@ hole==0.8.0 holidays==0.68 # homeassistant.components.frontend -home-assistant-frontend==20250228.0 +home-assistant-frontend==20250305.0 # homeassistant.components.conversation home-assistant-intents==2025.2.26 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index da65fc8ec24..e27a33ce02f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -981,7 +981,7 @@ hole==0.8.0 holidays==0.68 # homeassistant.components.frontend -home-assistant-frontend==20250228.0 +home-assistant-frontend==20250305.0 # homeassistant.components.conversation home-assistant-intents==2025.2.26 From 2c2fd76270e353cc14c4eed0062c49d8f7afc3b1 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 5 Mar 2025 11:54:58 +0100 Subject: [PATCH 2232/3148] Update frontend to 20250305.0 (#139829) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index d8eb53467f0..e661439cff2 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20250228.0"] + "requirements": ["home-assistant-frontend==20250305.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 54401a12592..790180691c0 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -37,7 +37,7 @@ habluetooth==3.24.1 hass-nabucasa==0.92.0 hassil==2.2.3 home-assistant-bluetooth==1.13.1 -home-assistant-frontend==20250228.0 +home-assistant-frontend==20250305.0 home-assistant-intents==2025.2.26 httpx==0.28.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 10dda4e324a..f972f4adb57 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1152,7 +1152,7 @@ hole==0.8.0 holidays==0.68 # homeassistant.components.frontend -home-assistant-frontend==20250228.0 +home-assistant-frontend==20250305.0 # homeassistant.components.conversation home-assistant-intents==2025.2.26 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 866d850c5d5..1e6c7814426 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -981,7 +981,7 @@ hole==0.8.0 holidays==0.68 # homeassistant.components.frontend -home-assistant-frontend==20250228.0 +home-assistant-frontend==20250305.0 # homeassistant.components.conversation home-assistant-intents==2025.2.26 From 5043e2ad108f78c6a5cdce7d4a9d541d5440718e Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 5 Mar 2025 11:01:06 +0000 Subject: [PATCH 2233/3148] Bump version to 2025.3.0b7 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 79c831a3033..b861e9e7170 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -25,7 +25,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2025 MINOR_VERSION: Final = 3 -PATCH_VERSION: Final = "0b6" +PATCH_VERSION: Final = "0b7" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 13, 0) diff --git a/pyproject.toml b/pyproject.toml index a5c1c55fa3c..38a144806a3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.3.0b6" +version = "2025.3.0b7" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From df2248bb8286bd29face578a00ac31ffafae18ef Mon Sep 17 00:00:00 2001 From: LG-ThinQ-Integration Date: Wed, 5 Mar 2025 20:13:11 +0900 Subject: [PATCH 2234/3148] Get temperature data appropriate for hass.config.unit in LG ThinQ (#137626) * Get temperature data appropriate for hass.config.unit * Modify temperature_unit for init * Modify unit's map * Fix ruff error --------- Co-authored-by: yunseon.park --- homeassistant/components/lg_thinq/climate.py | 9 ++++- homeassistant/components/lg_thinq/const.py | 9 +++++ .../components/lg_thinq/coordinator.py | 40 ++++++++++++++++++- homeassistant/components/lg_thinq/entity.py | 10 +---- .../lg_thinq/snapshots/test_climate.ambr | 16 ++++---- tests/components/lg_thinq/test_climate.py | 3 +- 6 files changed, 67 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/lg_thinq/climate.py b/homeassistant/components/lg_thinq/climate.py index 73678e209f7..98a86a8d355 100644 --- a/homeassistant/components/lg_thinq/climate.py +++ b/homeassistant/components/lg_thinq/climate.py @@ -110,7 +110,9 @@ class ThinQClimateEntity(ThinQEntity, ClimateEntity): self._attr_hvac_modes = [HVACMode.OFF] self._attr_hvac_mode = HVACMode.OFF self._attr_preset_modes = [] - self._attr_temperature_unit = UnitOfTemperature.CELSIUS + self._attr_temperature_unit = ( + self._get_unit_of_measurement(self.data.unit) or UnitOfTemperature.CELSIUS + ) self._requested_hvac_mode: str | None = None # Set up HVAC modes. @@ -182,6 +184,11 @@ class ThinQClimateEntity(ThinQEntity, ClimateEntity): self._attr_target_temperature_high = self.data.target_temp_high self._attr_target_temperature_low = self.data.target_temp_low + # Update unit. + self._attr_temperature_unit = ( + self._get_unit_of_measurement(self.data.unit) or UnitOfTemperature.CELSIUS + ) + _LOGGER.debug( "[%s:%s] update status: c:%s, t:%s, l:%s, h:%s, hvac:%s, unit:%s, step:%s", self.coordinator.device_name, diff --git a/homeassistant/components/lg_thinq/const.py b/homeassistant/components/lg_thinq/const.py index a65dee715db..20c6455241a 100644 --- a/homeassistant/components/lg_thinq/const.py +++ b/homeassistant/components/lg_thinq/const.py @@ -3,6 +3,8 @@ from datetime import timedelta from typing import Final +from homeassistant.const import UnitOfTemperature + # Config flow DOMAIN = "lg_thinq" COMPANY = "LGE" @@ -18,3 +20,10 @@ MQTT_SUBSCRIPTION_INTERVAL: Final = timedelta(days=1) # MQTT: Message types DEVICE_PUSH_MESSAGE: Final = "DEVICE_PUSH" DEVICE_STATUS_MESSAGE: Final = "DEVICE_STATUS" + +# Unit conversion map +DEVICE_UNIT_TO_HA: dict[str, str] = { + "F": UnitOfTemperature.FAHRENHEIT, + "C": UnitOfTemperature.CELSIUS, +} +REVERSE_DEVICE_UNIT_TO_HA = {v: k for k, v in DEVICE_UNIT_TO_HA.items()} diff --git a/homeassistant/components/lg_thinq/coordinator.py b/homeassistant/components/lg_thinq/coordinator.py index d6991d15297..513cd27a7b2 100644 --- a/homeassistant/components/lg_thinq/coordinator.py +++ b/homeassistant/components/lg_thinq/coordinator.py @@ -2,19 +2,21 @@ from __future__ import annotations +from collections.abc import Mapping import logging from typing import TYPE_CHECKING, Any from thinqconnect import ThinQAPIException from thinqconnect.integration import HABridge -from homeassistant.core import HomeAssistant +from homeassistant.const import EVENT_CORE_CONFIG_UPDATE +from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed if TYPE_CHECKING: from . import ThinqConfigEntry -from .const import DOMAIN +from .const import DOMAIN, REVERSE_DEVICE_UNIT_TO_HA _LOGGER = logging.getLogger(__name__) @@ -54,6 +56,40 @@ class DeviceDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): f"{self.device_id}_{self.sub_id}" if self.sub_id else self.device_id ) + # Set your preferred temperature unit. This will allow us to retrieve + # temperature values from the API in a converted value corresponding to + # preferred unit. + self._update_preferred_temperature_unit() + + # Add a callback to handle core config update. + self.unit_system: str | None = None + self.hass.bus.async_listen( + event_type=EVENT_CORE_CONFIG_UPDATE, + listener=self._handle_update_config, + event_filter=self.async_config_update_filter, + ) + + async def _handle_update_config(self, _: Event) -> None: + """Handle update core config.""" + self._update_preferred_temperature_unit() + + await self.async_refresh() + + @callback + def async_config_update_filter(self, event_data: Mapping[str, Any]) -> bool: + """Filter out unwanted events.""" + if (unit_system := event_data.get("unit_system")) != self.unit_system: + self.unit_system = unit_system + return True + + return False + + def _update_preferred_temperature_unit(self) -> None: + """Update preferred temperature unit.""" + self.api.set_preferred_temperature_unit( + REVERSE_DEVICE_UNIT_TO_HA.get(self.hass.config.units.temperature_unit) + ) + async def _async_update_data(self) -> dict[str, Any]: """Request to the server to update the status from full response data.""" try: diff --git a/homeassistant/components/lg_thinq/entity.py b/homeassistant/components/lg_thinq/entity.py index 7856506559b..61d8199f321 100644 --- a/homeassistant/components/lg_thinq/entity.py +++ b/homeassistant/components/lg_thinq/entity.py @@ -10,25 +10,19 @@ from thinqconnect import ThinQAPIException from thinqconnect.devices.const import Location from thinqconnect.integration import PropertyState -from homeassistant.const import UnitOfTemperature from homeassistant.core import callback from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import device_registry as dr from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import COMPANY, DOMAIN +from .const import COMPANY, DEVICE_UNIT_TO_HA, DOMAIN from .coordinator import DeviceDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) EMPTY_STATE = PropertyState() -UNIT_CONVERSION_MAP: dict[str, str] = { - "F": UnitOfTemperature.FAHRENHEIT, - "C": UnitOfTemperature.CELSIUS, -} - class ThinQEntity(CoordinatorEntity[DeviceDataUpdateCoordinator]): """The base implementation of all lg thinq entities.""" @@ -75,7 +69,7 @@ class ThinQEntity(CoordinatorEntity[DeviceDataUpdateCoordinator]): if unit is None: return None - return UNIT_CONVERSION_MAP.get(unit) + return DEVICE_UNIT_TO_HA.get(unit) def _update_status(self) -> None: """Update status itself. diff --git a/tests/components/lg_thinq/snapshots/test_climate.ambr b/tests/components/lg_thinq/snapshots/test_climate.ambr index db57e824487..111d49a2ef3 100644 --- a/tests/components/lg_thinq/snapshots/test_climate.ambr +++ b/tests/components/lg_thinq/snapshots/test_climate.ambr @@ -15,8 +15,8 @@ , , ]), - 'max_temp': 30, - 'min_temp': 18, + 'max_temp': 86, + 'min_temp': 64, 'preset_modes': list([ 'air_clean', ]), @@ -28,7 +28,7 @@ 'on', 'off', ]), - 'target_temp_step': 1, + 'target_temp_step': 2, }), 'config_entry_id': , 'config_subentry_id': , @@ -62,7 +62,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'current_humidity': 40, - 'current_temperature': 25, + 'current_temperature': 77, 'fan_mode': 'mid', 'fan_modes': list([ 'low', @@ -75,8 +75,8 @@ , , ]), - 'max_temp': 30, - 'min_temp': 18, + 'max_temp': 86, + 'min_temp': 64, 'preset_mode': None, 'preset_modes': list([ 'air_clean', @@ -94,8 +94,8 @@ ]), 'target_temp_high': None, 'target_temp_low': None, - 'target_temp_step': 1, - 'temperature': 19, + 'target_temp_step': 2, + 'temperature': 66, }), 'context': , 'entity_id': 'climate.test_air_conditioner', diff --git a/tests/components/lg_thinq/test_climate.py b/tests/components/lg_thinq/test_climate.py index 24ed3ad230d..4ac2fa55a21 100644 --- a/tests/components/lg_thinq/test_climate.py +++ b/tests/components/lg_thinq/test_climate.py @@ -5,7 +5,7 @@ from unittest.mock import AsyncMock, patch import pytest from syrupy import SnapshotAssertion -from homeassistant.const import Platform +from homeassistant.const import Platform, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -23,6 +23,7 @@ async def test_all_entities( entity_registry: er.EntityRegistry, ) -> None: """Test all entities.""" + hass.config.units.temperature_unit = UnitOfTemperature.FAHRENHEIT with patch("homeassistant.components.lg_thinq.PLATFORMS", [Platform.CLIMATE]): await setup_integration(hass, mock_config_entry) From 1c1a950c05db4b2b506cff6b00b7cefce2c2e8df Mon Sep 17 00:00:00 2001 From: tdfountain <174762217+tdfountain@users.noreply.github.com> Date: Wed, 5 Mar 2025 04:12:56 -0800 Subject: [PATCH 2235/3148] Add conditional support for ambient sensors in NUT (#139675) * Conditionally remove ambient sensors if not present * Create ambient sensors list and use list comprehension * Update homeassistant/components/nut/sensor.py Co-authored-by: J. Nick Koston --------- Co-authored-by: J. Nick Koston --- homeassistant/components/nut/sensor.py | 11 + .../EATON-EPDU-G3-AMBIENT-NOT-PRESENT.json | 539 ++++++++++++++++++ tests/components/nut/test_sensor.py | 32 ++ 3 files changed, 582 insertions(+) create mode 100644 tests/components/nut/fixtures/EATON-EPDU-G3-AMBIENT-NOT-PRESENT.json diff --git a/homeassistant/components/nut/sensor.py b/homeassistant/components/nut/sensor.py index bb74ea617f5..1484f11dac7 100644 --- a/homeassistant/components/nut/sensor.py +++ b/homeassistant/components/nut/sensor.py @@ -46,6 +46,13 @@ NUT_DEV_INFO_TO_DEV_INFO: dict[str, str] = { "serial": ATTR_SERIAL_NUMBER, } +AMBIENT_PRESENT = "ambient.present" +AMBIENT_SENSORS = { + "ambient.humidity", + "ambient.humidity.status", + "ambient.temperature", + "ambient.temperature.status", +} AMBIENT_THRESHOLD_STATUS_OPTIONS = [ "good", "warning-low", @@ -1035,6 +1042,10 @@ async def async_setup_entry( if KEY_STATUS in resources: resources.append(KEY_STATUS_DISPLAY) + # If device reports ambient sensors are not present, then remove + if status.get(AMBIENT_PRESENT) == "no": + resources = [item for item in resources if item not in AMBIENT_SENSORS] + async_add_entities( NUTSensor( coordinator, diff --git a/tests/components/nut/fixtures/EATON-EPDU-G3-AMBIENT-NOT-PRESENT.json b/tests/components/nut/fixtures/EATON-EPDU-G3-AMBIENT-NOT-PRESENT.json new file mode 100644 index 00000000000..96394e618c9 --- /dev/null +++ b/tests/components/nut/fixtures/EATON-EPDU-G3-AMBIENT-NOT-PRESENT.json @@ -0,0 +1,539 @@ +{ + "ambient.contacts.1.status": "opened", + "ambient.contacts.2.status": "opened", + "ambient.count": "0", + "ambient.humidity": "29.90", + "ambient.humidity.high": "90", + "ambient.humidity.high.critical": "90", + "ambient.humidity.high.warning": "65", + "ambient.humidity.low": "10", + "ambient.humidity.low.critical": "10", + "ambient.humidity.low.warning": "20", + "ambient.humidity.status": "good", + "ambient.present": "no", + "ambient.temperature": "28.9", + "ambient.temperature.high": "43.30", + "ambient.temperature.high.critical": "43.30", + "ambient.temperature.high.warning": "37.70", + "ambient.temperature.low": "5", + "ambient.temperature.low.critical": "5", + "ambient.temperature.low.warning": "10", + "ambient.temperature.status": "good", + "device.contact": "Contact Name", + "device.count": "1", + "device.description": "ePDU MA 00U-C IN: TYPE 00A 0P OUT: 00xTYPE", + "device.location": "Device Location", + "device.macaddr": "00 00 00 FF FF FF ", + "device.mfr": "EATON", + "device.model": "ePDU MA 00U-C IN: TYPE 00A 0P OUT: 00xTYPE", + "device.part": "EMA000-00", + "device.serial": "A000A00000", + "device.type": "pdu", + "driver.debug": "0", + "driver.flag.allow_killpower": "0", + "driver.name": "snmp-ups", + "driver.parameter.pollinterval": "2", + "driver.parameter.port": "eaton-pdu", + "driver.parameter.synchronous": "auto", + "driver.state": "dumping", + "driver.version": "2.8.2.882-882-g63d90ebcb", + "driver.version.data": "eaton_epdu MIB 0.69", + "driver.version.internal": "1.31", + "input.current": "4.30", + "input.current.high.critical": "16", + "input.current.high.warning": "12.80", + "input.current.low.warning": "0", + "input.current.nominal": "16", + "input.current.status": "good", + "input.feed.color": "0", + "input.feed.desc": "Feed A", + "input.frequency": "60", + "input.frequency.status": "good", + "input.L1.current": "4.30", + "input.L1.current.high.critical": "16", + "input.L1.current.high.warning": "12.80", + "input.L1.current.low.warning": "0", + "input.L1.current.nominal": "16", + "input.L1.current.status": "good", + "input.L1.load": "26", + "input.L1.power": "529", + "input.L1.realpower": "482", + "input.L1.voltage": "122.91", + "input.L1.voltage.high.critical": "140", + "input.L1.voltage.high.warning": "130", + "input.L1.voltage.low.critical": "90", + "input.L1.voltage.low.warning": "95", + "input.L1.voltage.status": "good", + "input.load": "26", + "input.phases": "1", + "input.power": "532", + "input.realpower": "482", + "input.realpower.nominal": "1920", + "input.voltage": "122.91", + "input.voltage.high.critical": "140", + "input.voltage.high.warning": "130", + "input.voltage.low.critical": "90", + "input.voltage.low.warning": "95", + "input.voltage.status": "good", + "outlet.1.current": "0", + "outlet.1.current.high.critical": "16", + "outlet.1.current.high.warning": "12.80", + "outlet.1.current.low.warning": "0", + "outlet.1.current.status": "good", + "outlet.1.delay.shutdown": "120", + "outlet.1.delay.start": "1", + "outlet.1.desc": "Outlet A1", + "outlet.1.groupid": "1", + "outlet.1.id": "1", + "outlet.1.name": "A1", + "outlet.1.power": "0", + "outlet.1.realpower": "0", + "outlet.1.status": "on", + "outlet.1.switchable": "yes", + "outlet.1.timer.shutdown": "-1", + "outlet.1.timer.start": "-1", + "outlet.1.type": "nema520", + "outlet.10.current": "0.26", + "outlet.10.current.high.critical": "16", + "outlet.10.current.high.warning": "12.80", + "outlet.10.current.low.warning": "0", + "outlet.10.current.status": "good", + "outlet.10.delay.shutdown": "120", + "outlet.10.delay.start": "10", + "outlet.10.desc": "Outlet A10", + "outlet.10.groupid": "1", + "outlet.10.id": "10", + "outlet.10.name": "A10", + "outlet.10.power": "32", + "outlet.10.realpower": "15", + "outlet.10.status": "on", + "outlet.10.switchable": "yes", + "outlet.10.timer.shutdown": "-1", + "outlet.10.timer.start": "-1", + "outlet.10.type": "nema520", + "outlet.11.current": "0.24", + "outlet.11.current.high.critical": "16", + "outlet.11.current.high.warning": "12.80", + "outlet.11.current.low.warning": "0", + "outlet.11.current.status": "good", + "outlet.11.delay.shutdown": "120", + "outlet.11.delay.start": "11", + "outlet.11.desc": "Outlet A11", + "outlet.11.groupid": "1", + "outlet.11.id": "11", + "outlet.11.name": "A11", + "outlet.11.power": "29", + "outlet.11.realpower": "22", + "outlet.11.status": "on", + "outlet.11.switchable": "yes", + "outlet.11.timer.shutdown": "-1", + "outlet.11.timer.start": "-1", + "outlet.11.type": "nema520", + "outlet.12.current": "0", + "outlet.12.current.high.critical": "16", + "outlet.12.current.high.warning": "12.80", + "outlet.12.current.low.warning": "0", + "outlet.12.current.status": "good", + "outlet.12.delay.shutdown": "120", + "outlet.12.delay.start": "12", + "outlet.12.desc": "Outlet A12", + "outlet.12.groupid": "1", + "outlet.12.id": "12", + "outlet.12.name": "A12", + "outlet.12.power": "0", + "outlet.12.realpower": "0", + "outlet.12.status": "on", + "outlet.12.switchable": "yes", + "outlet.12.timer.shutdown": "-1", + "outlet.12.timer.start": "-1", + "outlet.12.type": "nema520", + "outlet.13.current": "0.23", + "outlet.13.current.high.critical": "16", + "outlet.13.current.high.warning": "12.80", + "outlet.13.current.low.warning": "0", + "outlet.13.current.status": "good", + "outlet.13.delay.shutdown": "0", + "outlet.13.delay.start": "0", + "outlet.13.desc": "Outlet A13", + "outlet.13.groupid": "1", + "outlet.13.id": "0", + "outlet.13.name": "A13", + "outlet.13.power": "27", + "outlet.13.realpower": "9", + "outlet.13.status": "on", + "outlet.13.switchable": "yes", + "outlet.13.timer.shutdown": "-1", + "outlet.13.timer.start": "-1", + "outlet.13.type": "nema520", + "outlet.14.current": "0.10", + "outlet.14.current.high.critical": "16", + "outlet.14.current.high.warning": "12.80", + "outlet.14.current.low.warning": "0", + "outlet.14.current.status": "good", + "outlet.14.delay.shutdown": "120", + "outlet.14.delay.start": "14", + "outlet.14.desc": "Outlet A14", + "outlet.14.groupid": "1", + "outlet.14.id": "14", + "outlet.14.name": "A14", + "outlet.14.power": "12", + "outlet.14.realpower": "7", + "outlet.14.status": "on", + "outlet.14.switchable": "yes", + "outlet.14.timer.shutdown": "-1", + "outlet.14.timer.start": "-1", + "outlet.14.type": "nema520", + "outlet.15.current": "0.03", + "outlet.15.current.high.critical": "16", + "outlet.15.current.high.warning": "12.80", + "outlet.15.current.low.warning": "0", + "outlet.15.current.status": "good", + "outlet.15.delay.shutdown": "120", + "outlet.15.delay.start": "15", + "outlet.15.desc": "Outlet A15", + "outlet.15.groupid": "1", + "outlet.15.id": "15", + "outlet.15.name": "A15", + "outlet.15.power": "3", + "outlet.15.realpower": "1", + "outlet.15.status": "on", + "outlet.15.switchable": "yes", + "outlet.15.timer.shutdown": "-1", + "outlet.15.timer.start": "-1", + "outlet.15.type": "nema520", + "outlet.16.current": "0.04", + "outlet.16.current.high.critical": "16", + "outlet.16.current.high.warning": "12.80", + "outlet.16.current.low.warning": "0", + "outlet.16.current.status": "good", + "outlet.16.delay.shutdown": "120", + "outlet.16.delay.start": "16", + "outlet.16.desc": "Outlet A16", + "outlet.16.groupid": "1", + "outlet.16.id": "16", + "outlet.16.name": "A16", + "outlet.16.power": "4", + "outlet.16.realpower": "1", + "outlet.16.status": "on", + "outlet.16.switchable": "yes", + "outlet.16.timer.shutdown": "-1", + "outlet.16.timer.start": "-1", + "outlet.16.type": "nema520", + "outlet.17.current": "0.19", + "outlet.17.current.high.critical": "16", + "outlet.17.current.high.warning": "12.80", + "outlet.17.current.low.warning": "0", + "outlet.17.current.status": "good", + "outlet.17.delay.shutdown": "0", + "outlet.17.delay.start": "0", + "outlet.17.desc": "Outlet A17", + "outlet.17.groupid": "1", + "outlet.17.id": "0", + "outlet.17.name": "A17", + "outlet.17.power": "23", + "outlet.17.realpower": "5", + "outlet.17.status": "on", + "outlet.17.switchable": "yes", + "outlet.17.timer.shutdown": "-1", + "outlet.17.timer.start": "-1", + "outlet.17.type": "nema520", + "outlet.18.current": "0.35", + "outlet.18.current.high.critical": "16", + "outlet.18.current.high.warning": "12.80", + "outlet.18.current.low.warning": "0", + "outlet.18.current.status": "good", + "outlet.18.delay.shutdown": "0", + "outlet.18.delay.start": "0", + "outlet.18.desc": "Outlet A18", + "outlet.18.groupid": "1", + "outlet.18.id": "0", + "outlet.18.name": "A18", + "outlet.18.power": "42", + "outlet.18.realpower": "34", + "outlet.18.status": "on", + "outlet.18.switchable": "yes", + "outlet.18.timer.shutdown": "-1", + "outlet.18.timer.start": "-1", + "outlet.18.type": "nema520", + "outlet.19.current": "0.12", + "outlet.19.current.high.critical": "16", + "outlet.19.current.high.warning": "12.80", + "outlet.19.current.low.warning": "0", + "outlet.19.current.status": "good", + "outlet.19.delay.shutdown": "0", + "outlet.19.delay.start": "0", + "outlet.19.desc": "Outlet A19", + "outlet.19.groupid": "1", + "outlet.19.id": "0", + "outlet.19.name": "A19", + "outlet.19.power": "15", + "outlet.19.realpower": "6", + "outlet.19.status": "on", + "outlet.19.switchable": "yes", + "outlet.19.timer.shutdown": "-1", + "outlet.19.timer.start": "-1", + "outlet.19.type": "nema520", + "outlet.2.current": "0.39", + "outlet.2.current.high.critical": "16", + "outlet.2.current.high.warning": "12.80", + "outlet.2.current.low.warning": "0", + "outlet.2.current.status": "good", + "outlet.2.delay.shutdown": "120", + "outlet.2.delay.start": "2", + "outlet.2.desc": "Outlet A2", + "outlet.2.groupid": "1", + "outlet.2.id": "2", + "outlet.2.name": "A2", + "outlet.2.power": "47", + "outlet.2.realpower": "43", + "outlet.2.status": "on", + "outlet.2.switchable": "yes", + "outlet.2.timer.shutdown": "-1", + "outlet.2.timer.start": "-1", + "outlet.2.type": "nema520", + "outlet.20.current": "0", + "outlet.20.current.high.critical": "16", + "outlet.20.current.high.warning": "12.80", + "outlet.20.current.low.warning": "0", + "outlet.20.current.status": "good", + "outlet.20.delay.shutdown": "120", + "outlet.20.delay.start": "20", + "outlet.20.desc": "Outlet A20", + "outlet.20.groupid": "1", + "outlet.20.id": "20", + "outlet.20.name": "A20", + "outlet.20.power": "0", + "outlet.20.realpower": "0", + "outlet.20.status": "on", + "outlet.20.switchable": "yes", + "outlet.20.timer.shutdown": "-1", + "outlet.20.timer.start": "-1", + "outlet.20.type": "nema520", + "outlet.21.current": "0", + "outlet.21.current.high.critical": "16", + "outlet.21.current.high.warning": "12.80", + "outlet.21.current.low.warning": "0", + "outlet.21.current.status": "good", + "outlet.21.delay.shutdown": "120", + "outlet.21.delay.start": "21", + "outlet.21.desc": "Outlet A21", + "outlet.21.groupid": "1", + "outlet.21.id": "21", + "outlet.21.name": "A21", + "outlet.21.power": "0", + "outlet.21.realpower": "0", + "outlet.21.status": "on", + "outlet.21.switchable": "yes", + "outlet.21.timer.shutdown": "-1", + "outlet.21.timer.start": "-1", + "outlet.21.type": "nema520", + "outlet.22.current": "0", + "outlet.22.current.high.critical": "16", + "outlet.22.current.high.warning": "12.80", + "outlet.22.current.low.warning": "0", + "outlet.22.current.status": "good", + "outlet.22.delay.shutdown": "0", + "outlet.22.delay.start": "0", + "outlet.22.desc": "Outlet A22", + "outlet.22.groupid": "1", + "outlet.22.id": "0", + "outlet.22.name": "A22", + "outlet.22.power": "0", + "outlet.22.realpower": "0", + "outlet.22.status": "on", + "outlet.22.switchable": "yes", + "outlet.22.timer.shutdown": "-1", + "outlet.22.timer.start": "-1", + "outlet.22.type": "nema520", + "outlet.23.current": "0.34", + "outlet.23.current.high.critical": "16", + "outlet.23.current.high.warning": "12.80", + "outlet.23.current.low.warning": "0", + "outlet.23.current.status": "good", + "outlet.23.delay.shutdown": "120", + "outlet.23.delay.start": "23", + "outlet.23.desc": "Outlet A23", + "outlet.23.groupid": "1", + "outlet.23.id": "23", + "outlet.23.name": "A23", + "outlet.23.power": "41", + "outlet.23.realpower": "39", + "outlet.23.status": "on", + "outlet.23.switchable": "yes", + "outlet.23.timer.shutdown": "-1", + "outlet.23.timer.start": "-1", + "outlet.23.type": "nema520", + "outlet.24.current": "0.19", + "outlet.24.current.high.critical": "16", + "outlet.24.current.high.warning": "12.80", + "outlet.24.current.low.warning": "0", + "outlet.24.current.status": "good", + "outlet.24.delay.shutdown": "0", + "outlet.24.delay.start": "0", + "outlet.24.desc": "Outlet A24", + "outlet.24.groupid": "1", + "outlet.24.id": "0", + "outlet.24.name": "A24", + "outlet.24.power": "23", + "outlet.24.realpower": "11", + "outlet.24.status": "on", + "outlet.24.switchable": "yes", + "outlet.24.timer.shutdown": "-1", + "outlet.24.timer.start": "-1", + "outlet.24.type": "nema520", + "outlet.3.current": "0.46", + "outlet.3.current.high.critical": "16", + "outlet.3.current.high.warning": "12.80", + "outlet.3.current.low.warning": "0", + "outlet.3.current.status": "good", + "outlet.3.delay.shutdown": "120", + "outlet.3.delay.start": "3", + "outlet.3.desc": "Outlet A3", + "outlet.3.groupid": "1", + "outlet.3.id": "3", + "outlet.3.name": "A3", + "outlet.3.power": "56", + "outlet.3.realpower": "53", + "outlet.3.status": "on", + "outlet.3.switchable": "yes", + "outlet.3.timer.shutdown": "-1", + "outlet.3.timer.start": "-1", + "outlet.3.type": "nema520", + "outlet.4.current": "0.44", + "outlet.4.current.high.critical": "16", + "outlet.4.current.high.warning": "12.80", + "outlet.4.current.low.warning": "0", + "outlet.4.current.status": "good", + "outlet.4.delay.shutdown": "120", + "outlet.4.delay.start": "4", + "outlet.4.desc": "Outlet A4", + "outlet.4.groupid": "1", + "outlet.4.id": "4", + "outlet.4.name": "A4", + "outlet.4.power": "53", + "outlet.4.realpower": "48", + "outlet.4.status": "on", + "outlet.4.switchable": "yes", + "outlet.4.timer.shutdown": "-1", + "outlet.4.timer.start": "-1", + "outlet.4.type": "nema520", + "outlet.5.current": "0.43", + "outlet.5.current.high.critical": "16", + "outlet.5.current.high.warning": "12.80", + "outlet.5.current.low.warning": "0", + "outlet.5.current.status": "good", + "outlet.5.delay.shutdown": "120", + "outlet.5.delay.start": "5", + "outlet.5.desc": "Outlet A5", + "outlet.5.groupid": "1", + "outlet.5.id": "5", + "outlet.5.name": "A5", + "outlet.5.power": "52", + "outlet.5.realpower": "48", + "outlet.5.status": "on", + "outlet.5.switchable": "yes", + "outlet.5.timer.shutdown": "-1", + "outlet.5.timer.start": "-1", + "outlet.5.type": "nema520", + "outlet.6.current": "1.07", + "outlet.6.current.high.critical": "16", + "outlet.6.current.high.warning": "12.80", + "outlet.6.current.low.warning": "0", + "outlet.6.current.status": "good", + "outlet.6.delay.shutdown": "120", + "outlet.6.delay.start": "6", + "outlet.6.desc": "Outlet A6", + "outlet.6.groupid": "1", + "outlet.6.id": "6", + "outlet.6.name": "A6", + "outlet.6.power": "131", + "outlet.6.realpower": "118", + "outlet.6.status": "on", + "outlet.6.switchable": "yes", + "outlet.6.timer.shutdown": "-1", + "outlet.6.timer.start": "-1", + "outlet.6.type": "nema520", + "outlet.7.current": "0", + "outlet.7.current.high.critical": "16", + "outlet.7.current.high.warning": "12.80", + "outlet.7.current.low.warning": "0", + "outlet.7.current.status": "good", + "outlet.7.delay.shutdown": "120", + "outlet.7.delay.start": "7", + "outlet.7.desc": "Outlet A7", + "outlet.7.groupid": "1", + "outlet.7.id": "7", + "outlet.7.name": "A7", + "outlet.7.power": "0", + "outlet.7.realpower": "0", + "outlet.7.status": "on", + "outlet.7.switchable": "yes", + "outlet.7.timer.shutdown": "-1", + "outlet.7.timer.start": "-1", + "outlet.7.type": "nema520", + "outlet.8.current": "0", + "outlet.8.current.high.critical": "16", + "outlet.8.current.high.warning": "12.80", + "outlet.8.current.low.warning": "0", + "outlet.8.current.status": "good", + "outlet.8.delay.shutdown": "120", + "outlet.8.delay.start": "8", + "outlet.8.desc": "Outlet A8", + "outlet.8.groupid": "1", + "outlet.8.id": "8", + "outlet.8.name": "A8", + "outlet.8.power": "0", + "outlet.8.realpower": "0", + "outlet.8.status": "on", + "outlet.8.switchable": "yes", + "outlet.8.timer.shutdown": "-1", + "outlet.8.timer.start": "-1", + "outlet.8.type": "nema520", + "outlet.9.current": "0", + "outlet.9.current.high.critical": "16", + "outlet.9.current.high.warning": "12.80", + "outlet.9.current.low.warning": "0", + "outlet.9.current.status": "good", + "outlet.9.delay.shutdown": "120", + "outlet.9.delay.start": "9", + "outlet.9.desc": "Outlet A9", + "outlet.9.groupid": "1", + "outlet.9.id": "9", + "outlet.9.name": "A9", + "outlet.9.power": "0", + "outlet.9.realpower": "0", + "outlet.9.status": "on", + "outlet.9.switchable": "yes", + "outlet.9.timer.shutdown": "-1", + "outlet.9.timer.start": "-1", + "outlet.9.type": "nema520", + "outlet.count": "24", + "outlet.current": "43.05", + "outlet.desc": "All outlets", + "outlet.frequency": "60", + "outlet.group.1.color": "16051527", + "outlet.group.1.count": "24", + "outlet.group.1.desc": "Section A", + "outlet.group.1.id": "1", + "outlet.group.1.input": "1", + "outlet.group.1.name": "A", + "outlet.group.1.phase": "1", + "outlet.group.1.status": "on", + "outlet.group.1.type": "outlet-section", + "outlet.group.1.voltage": "122.83", + "outlet.group.1.voltage.high.critical": "140", + "outlet.group.1.voltage.high.warning": "130", + "outlet.group.1.voltage.low.critical": "90", + "outlet.group.1.voltage.low.warning": "95", + "outlet.group.1.voltage.status": "good", + "outlet.group.count": "1", + "outlet.id": "0", + "outlet.switchable": "yes", + "outlet.voltage": "122.91", + "ups.firmware": "05.01.0002", + "ups.mfr": "EATON", + "ups.model": "ePDU MA 00U-C IN: TYPE 00A 0P OUT: 00xTYPE", + "ups.serial": "A000A00000", + "ups.status": "", + "ups.type": "pdu" +} diff --git a/tests/components/nut/test_sensor.py b/tests/components/nut/test_sensor.py index eb171c39011..6483d581070 100644 --- a/tests/components/nut/test_sensor.py +++ b/tests/components/nut/test_sensor.py @@ -241,3 +241,35 @@ async def test_stale_options( state = hass.states.get("sensor.ups1_battery_charge") assert state.state == "10" + + +@pytest.mark.parametrize( + ("model", "unique_id_base"), + [ + ( + "EATON-EPDU-G3-AMBIENT-NOT-PRESENT", + "EATON_ePDU MA 00U-C IN: TYPE 00A 0P OUT: 00xTYPE_A000A00000_", + ), + ], +) +async def test_pdu_devices_ambient_not_present( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + model: str, + unique_id_base: str, +) -> None: + """Test that ambient sensors not created.""" + + await async_init_integration(hass, model) + + entry = entity_registry.async_get("sensor.ups1_ambient_humidity") + assert not entry + + entry = entity_registry.async_get("sensor.ups1_ambient_humidity_status") + assert not entry + + entry = entity_registry.async_get("sensor.ups1_ambient_temperature") + assert not entry + + entry = entity_registry.async_get("sensor.ups1_ambient_temperature_status") + assert not entry From f0bba1d6d4d29ea34d86a5f86d46adfb75645c2f Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Wed, 5 Mar 2025 13:52:29 +0100 Subject: [PATCH 2236/3148] Fix disable test results uploads properly (#139827) * Fix disable test results uploads properly * use dedicated variable * fix pushes --- .github/workflows/ci.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 07cbc13594c..f8f14f2a126 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1452,7 +1452,7 @@ jobs: name: Upload test results to Codecov # codecov/test-results-action currently doesn't support tokenless uploads # therefore we can't run it on forks - if: github.repository_owner == 'home-assistant' && needs.info.outputs.skip_coverage != 'true' && !cancelled() + if: ${{ (github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork) && needs.info.outputs.skip_coverage != 'true' && !cancelled() }} runs-on: ubuntu-24.04 needs: - info From c0e5a549b6b83d6aa1ae17cd3feefce06596e54f Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Wed, 5 Mar 2025 05:36:20 -0800 Subject: [PATCH 2237/3148] Revert "Add scene support to roborock (#137203)" (#139840) This reverts commit 379bf106754dffd5c6c8cd8035a33597976cd866. --- homeassistant/components/roborock/__init__.py | 24 +--- homeassistant/components/roborock/const.py | 1 - .../components/roborock/coordinator.py | 49 +------- homeassistant/components/roborock/scene.py | 64 ---------- tests/components/roborock/conftest.py | 23 +--- tests/components/roborock/mock_data.py | 17 --- tests/components/roborock/test_scene.py | 112 ------------------ 7 files changed, 12 insertions(+), 278 deletions(-) delete mode 100644 homeassistant/components/roborock/scene.py delete mode 100644 tests/components/roborock/test_scene.py diff --git a/homeassistant/components/roborock/__init__.py b/homeassistant/components/roborock/__init__.py index 955e50cd15b..c382a56cde7 100644 --- a/homeassistant/components/roborock/__init__.py +++ b/homeassistant/components/roborock/__init__.py @@ -83,13 +83,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: RoborockConfigEntry) -> # Get a Coordinator if the device is available or if we have connected to the device before coordinators = await asyncio.gather( *build_setup_functions( - hass, - entry, - device_map, - user_data, - product_info, - home_data.rooms, - api_client, + hass, entry, device_map, user_data, product_info, home_data.rooms ), return_exceptions=True, ) @@ -141,7 +135,6 @@ def build_setup_functions( user_data: UserData, product_info: dict[str, HomeDataProduct], home_data_rooms: list[HomeDataRoom], - api_client: RoborockApiClient, ) -> list[ Coroutine[ Any, @@ -158,7 +151,6 @@ def build_setup_functions( device, product_info[device.product_id], home_data_rooms, - api_client, ) for device in device_map.values() ] @@ -171,12 +163,11 @@ async def setup_device( device: HomeDataDevice, product_info: HomeDataProduct, home_data_rooms: list[HomeDataRoom], - api_client: RoborockApiClient, ) -> RoborockDataUpdateCoordinator | RoborockDataUpdateCoordinatorA01 | None: """Set up a coordinator for a given device.""" if device.pv == "1.0": return await setup_device_v1( - hass, entry, user_data, device, product_info, home_data_rooms, api_client + hass, entry, user_data, device, product_info, home_data_rooms ) if device.pv == "A01": return await setup_device_a01(hass, entry, user_data, device, product_info) @@ -196,7 +187,6 @@ async def setup_device_v1( device: HomeDataDevice, product_info: HomeDataProduct, home_data_rooms: list[HomeDataRoom], - api_client: RoborockApiClient, ) -> RoborockDataUpdateCoordinator | None: """Set up a device Coordinator.""" mqtt_client = await hass.async_add_executor_job( @@ -218,15 +208,7 @@ async def setup_device_v1( await mqtt_client.async_release() raise coordinator = RoborockDataUpdateCoordinator( - hass, - entry, - device, - networking, - product_info, - mqtt_client, - home_data_rooms, - api_client, - user_data, + hass, entry, device, networking, product_info, mqtt_client, home_data_rooms ) try: await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/roborock/const.py b/homeassistant/components/roborock/const.py index fe9091a3ea7..cc8d34fbadc 100644 --- a/homeassistant/components/roborock/const.py +++ b/homeassistant/components/roborock/const.py @@ -36,7 +36,6 @@ PLATFORMS = [ Platform.BUTTON, Platform.IMAGE, Platform.NUMBER, - Platform.SCENE, Platform.SELECT, Platform.SENSOR, Platform.SWITCH, diff --git a/homeassistant/components/roborock/coordinator.py b/homeassistant/components/roborock/coordinator.py index 6690b0ac07e..806651c9ac5 100644 --- a/homeassistant/components/roborock/coordinator.py +++ b/homeassistant/components/roborock/coordinator.py @@ -10,26 +10,17 @@ import logging from propcache.api import cached_property from roborock import HomeDataRoom from roborock.code_mappings import RoborockCategory -from roborock.containers import ( - DeviceData, - HomeDataDevice, - HomeDataProduct, - HomeDataScene, - NetworkInfo, - UserData, -) +from roborock.containers import DeviceData, HomeDataDevice, HomeDataProduct, NetworkInfo from roborock.exceptions import RoborockException from roborock.roborock_message import RoborockDyadDataProtocol, RoborockZeoProtocol from roborock.roborock_typing import DeviceProp from roborock.version_1_apis.roborock_local_client_v1 import RoborockLocalClientV1 from roborock.version_1_apis.roborock_mqtt_client_v1 import RoborockMqttClientV1 from roborock.version_a01_apis import RoborockClientA01 -from roborock.web_api import RoborockApiClient from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_CONNECTIONS from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.typing import StateType @@ -76,8 +67,6 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): product_info: HomeDataProduct, cloud_api: RoborockMqttClientV1, home_data_rooms: list[HomeDataRoom], - api_client: RoborockApiClient, - user_data: UserData, ) -> None: """Initialize.""" super().__init__( @@ -100,7 +89,7 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): self.cloud_api = cloud_api self.device_info = DeviceInfo( name=self.roborock_device_info.device.name, - identifiers={(DOMAIN, self.duid)}, + identifiers={(DOMAIN, self.roborock_device_info.device.duid)}, manufacturer="Roborock", model=self.roborock_device_info.product.model, model_id=self.roborock_device_info.product.model, @@ -114,10 +103,8 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): self.maps: dict[int, RoborockMapInfo] = {} self._home_data_rooms = {str(room.id): room.name for room in home_data_rooms} self.map_storage = RoborockMapStorage( - hass, self.config_entry.entry_id, self.duid_slug + hass, self.config_entry.entry_id, slugify(self.duid) ) - self._user_data = user_data - self._api_client = api_client async def _async_setup(self) -> None: """Set up the coordinator.""" @@ -147,7 +134,7 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): except RoborockException: _LOGGER.warning( "Using the cloud API for device %s. This is not recommended as it can lead to rate limiting. We recommend making your vacuum accessible by your Home Assistant instance", - self.duid, + self.roborock_device_info.device.duid, ) await self.api.async_disconnect() # We use the cloud api if the local api fails to connect. @@ -207,34 +194,6 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): for room in room_mapping or () } - async def get_scenes(self) -> list[HomeDataScene]: - """Get scenes.""" - try: - return await self._api_client.get_scenes(self._user_data, self.duid) - except RoborockException as err: - _LOGGER.error("Failed to get scenes %s", err) - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="command_failed", - translation_placeholders={ - "command": "get_scenes", - }, - ) from err - - async def execute_scene(self, scene_id: int) -> None: - """Execute scene.""" - try: - await self._api_client.execute_scene(self._user_data, scene_id) - except RoborockException as err: - _LOGGER.error("Failed to execute scene %s %s", scene_id, err) - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="command_failed", - translation_placeholders={ - "command": "execute_scene", - }, - ) from err - @cached_property def duid(self) -> str: """Get the unique id of the device as specified by Roborock.""" diff --git a/homeassistant/components/roborock/scene.py b/homeassistant/components/roborock/scene.py deleted file mode 100644 index ff418a2810c..00000000000 --- a/homeassistant/components/roborock/scene.py +++ /dev/null @@ -1,64 +0,0 @@ -"""Support for Roborock scene.""" - -from __future__ import annotations - -import asyncio -from typing import Any - -from homeassistant.components.scene import Scene as SceneEntity -from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import EntityDescription -from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback - -from . import RoborockConfigEntry -from .coordinator import RoborockDataUpdateCoordinator -from .entity import RoborockEntity - - -async def async_setup_entry( - hass: HomeAssistant, - config_entry: RoborockConfigEntry, - async_add_entities: AddConfigEntryEntitiesCallback, -) -> None: - """Set up scene platform.""" - scene_lists = await asyncio.gather( - *[coordinator.get_scenes() for coordinator in config_entry.runtime_data.v1], - ) - async_add_entities( - RoborockSceneEntity( - coordinator, - EntityDescription( - key=str(scene.id), - name=scene.name, - ), - ) - for coordinator, scenes in zip( - config_entry.runtime_data.v1, scene_lists, strict=True - ) - for scene in scenes - ) - - -class RoborockSceneEntity(RoborockEntity, SceneEntity): - """A class to define Roborock scene entities.""" - - entity_description: EntityDescription - - def __init__( - self, - coordinator: RoborockDataUpdateCoordinator, - entity_description: EntityDescription, - ) -> None: - """Create a scene entity.""" - super().__init__( - f"{entity_description.key}_{coordinator.duid_slug}", - coordinator.device_info, - coordinator.api, - ) - self._scene_id = int(entity_description.key) - self._coordinator = coordinator - self.entity_description = entity_description - - async def async_activate(self, **kwargs: Any) -> None: - """Activate the scene.""" - await self._coordinator.execute_scene(self._scene_id) diff --git a/tests/components/roborock/conftest.py b/tests/components/roborock/conftest.py index 9b3a6633c62..43e5148c9a8 100644 --- a/tests/components/roborock/conftest.py +++ b/tests/components/roborock/conftest.py @@ -30,7 +30,6 @@ from .mock_data import ( MULTI_MAP_LIST, NETWORK_INFO, PROP, - SCENES, USER_DATA, USER_EMAIL, ) @@ -68,24 +67,8 @@ class A01Mock(RoborockMqttClientA01): return {prot: self.protocol_responses[prot] for prot in dyad_data_protocols} -@pytest.fixture(name="bypass_api_client_fixture") -def bypass_api_client_fixture() -> None: - """Skip calls to the API client.""" - with ( - patch( - "homeassistant.components.roborock.RoborockApiClient.get_home_data_v2", - return_value=HOME_DATA, - ), - patch( - "homeassistant.components.roborock.RoborockApiClient.get_scenes", - return_value=SCENES, - ), - ): - yield - - @pytest.fixture(name="bypass_api_fixture") -def bypass_api_fixture(bypass_api_client_fixture: Any) -> None: +def bypass_api_fixture() -> None: """Skip calls to the API.""" with ( patch("homeassistant.components.roborock.RoborockMqttClientV1.async_connect"), @@ -93,6 +76,10 @@ def bypass_api_fixture(bypass_api_client_fixture: Any) -> None: patch( "homeassistant.components.roborock.coordinator.RoborockMqttClientV1._send_command" ), + patch( + "homeassistant.components.roborock.RoborockApiClient.get_home_data_v2", + return_value=HOME_DATA, + ), patch( "homeassistant.components.roborock.RoborockMqttClientV1.get_networking", return_value=NETWORK_INFO, diff --git a/tests/components/roborock/mock_data.py b/tests/components/roborock/mock_data.py index 59c54892687..6e3fb229aa9 100644 --- a/tests/components/roborock/mock_data.py +++ b/tests/components/roborock/mock_data.py @@ -9,7 +9,6 @@ from roborock.containers import ( Consumable, DnDTimer, HomeData, - HomeDataScene, MultiMapsList, NetworkInfo, S7Status, @@ -1151,19 +1150,3 @@ MAP_DATA = MapData(0, 0) MAP_DATA.image = ImageData( 100, 10, 10, 10, 10, ImageConfig(), Image.new("RGB", (1, 1)), lambda p: p ) - - -SCENES = [ - HomeDataScene.from_dict( - { - "name": "sc1", - "id": 12, - }, - ), - HomeDataScene.from_dict( - { - "name": "sc2", - "id": 24, - }, - ), -] diff --git a/tests/components/roborock/test_scene.py b/tests/components/roborock/test_scene.py deleted file mode 100644 index 15707784feb..00000000000 --- a/tests/components/roborock/test_scene.py +++ /dev/null @@ -1,112 +0,0 @@ -"""Test Roborock Scene platform.""" - -from unittest.mock import ANY, patch - -import pytest -from roborock import RoborockException - -from homeassistant.const import SERVICE_TURN_ON, Platform -from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError - -from tests.common import MockConfigEntry - - -@pytest.fixture -def bypass_api_client_get_scenes_fixture(bypass_api_fixture) -> None: - """Fixture to raise when getting scenes.""" - with ( - patch( - "homeassistant.components.roborock.RoborockApiClient.get_scenes", - side_effect=RoborockException(), - ), - ): - yield - - -@pytest.mark.parametrize( - ("entity_id"), - [ - ("scene.roborock_s7_maxv_sc1"), - ("scene.roborock_s7_maxv_sc2"), - ], -) -@pytest.mark.usefixtures("entity_registry_enabled_by_default") -async def test_get_scenes_failure( - hass: HomeAssistant, - bypass_api_client_get_scenes_fixture, - setup_entry: MockConfigEntry, - entity_id: str, -) -> None: - """Test that if scene retrieval fails, no entity is being created.""" - # Ensure that the entity does not exist - assert hass.states.get(entity_id) is None - - -@pytest.fixture -def platforms() -> list[Platform]: - """Fixture to set platforms used in the test.""" - return [Platform.SCENE] - - -@pytest.mark.parametrize( - ("entity_id", "scene_id"), - [ - ("scene.roborock_s7_maxv_sc1", 12), - ("scene.roborock_s7_maxv_sc2", 24), - ], -) -@pytest.mark.freeze_time("2023-10-30 08:50:00") -@pytest.mark.usefixtures("entity_registry_enabled_by_default") -async def test_execute_success( - hass: HomeAssistant, - bypass_api_fixture, - setup_entry: MockConfigEntry, - entity_id: str, - scene_id: int, -) -> None: - """Test activating the scene entities.""" - with patch( - "homeassistant.components.roborock.RoborockApiClient.execute_scene" - ) as mock_execute_scene: - await hass.services.async_call( - "scene", - SERVICE_TURN_ON, - blocking=True, - target={"entity_id": entity_id}, - ) - mock_execute_scene.assert_called_once_with(ANY, scene_id) - assert hass.states.get(entity_id).state == "2023-10-30T08:50:00+00:00" - - -@pytest.mark.parametrize( - ("entity_id", "scene_id"), - [ - ("scene.roborock_s7_maxv_sc1", 12), - ], -) -@pytest.mark.freeze_time("2023-10-30 08:50:00") -@pytest.mark.usefixtures("entity_registry_enabled_by_default") -async def test_execute_failure( - hass: HomeAssistant, - bypass_api_fixture, - setup_entry: MockConfigEntry, - entity_id: str, - scene_id: int, -) -> None: - """Test failure while activating the scene entity.""" - with ( - patch( - "homeassistant.components.roborock.RoborockApiClient.execute_scene", - side_effect=RoborockException, - ) as mock_execute_scene, - pytest.raises(HomeAssistantError, match="Error while calling execute_scene"), - ): - await hass.services.async_call( - "scene", - SERVICE_TURN_ON, - blocking=True, - target={"entity_id": entity_id}, - ) - mock_execute_scene.assert_called_once_with(ANY, scene_id) - assert hass.states.get(entity_id).state == "2023-10-30T08:50:00+00:00" From 61e0b938aeb4d3c252c7940a55b94786a7a275b7 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Wed, 5 Mar 2025 14:56:30 +0100 Subject: [PATCH 2238/3148] Convert Shelly block switches to EntityDescription (#106985) --- homeassistant/components/shelly/switch.py | 91 ++++++++++------------- homeassistant/components/shelly/utils.py | 14 +++- 2 files changed, 52 insertions(+), 53 deletions(-) diff --git a/homeassistant/components/shelly/switch.py b/homeassistant/components/shelly/switch.py index 68708a2cc2b..ce9e4f065fb 100644 --- a/homeassistant/components/shelly/switch.py +++ b/homeassistant/components/shelly/switch.py @@ -7,7 +7,7 @@ from dataclasses import dataclass from typing import Any, cast from aioshelly.block_device import Block -from aioshelly.const import MODEL_2, MODEL_25, RPC_GENERATIONS +from aioshelly.const import RPC_GENERATIONS from homeassistant.components.climate import DOMAIN as CLIMATE_PLATFORM from homeassistant.components.switch import ( @@ -21,12 +21,11 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_registry import RegistryEntry from homeassistant.helpers.restore_state import RestoreEntity -from .const import CONF_SLEEP_PERIOD, MOTION_MODELS from .coordinator import ShellyBlockCoordinator, ShellyConfigEntry, ShellyRpcCoordinator from .entity import ( BlockEntityDescription, RpcEntityDescription, - ShellyBlockEntity, + ShellyBlockAttributeEntity, ShellyRpcAttributeEntity, ShellySleepingBlockAttributeEntity, async_setup_entry_attribute_entities, @@ -34,10 +33,9 @@ from .entity import ( ) from .utils import ( async_remove_orphaned_entities, - async_remove_shelly_entity, get_device_entry_gen, get_virtual_component_ids, - is_block_channel_type_light, + is_block_exclude_from_relay, is_rpc_exclude_from_relay, ) @@ -47,11 +45,20 @@ class BlockSwitchDescription(BlockEntityDescription, SwitchEntityDescription): """Class to describe a BLOCK switch.""" -MOTION_SWITCH = BlockSwitchDescription( - key="sensor|motionActive", - name="Motion detection", - entity_category=EntityCategory.CONFIG, -) +BLOCK_RELAY_SWITCHES = { + ("relay", "output"): BlockSwitchDescription( + key="relay|output", + removal_condition=is_block_exclude_from_relay, + ) +} + +BLOCK_SLEEPING_MOTION_SWITCH = { + ("sensor", "motionActive"): BlockSwitchDescription( + key="sensor|motionActive", + name="Motion detection", + entity_category=EntityCategory.CONFIG, + ) +} @dataclass(frozen=True, kw_only=True) @@ -120,46 +127,17 @@ def async_setup_block_entry( coordinator = config_entry.runtime_data.block assert coordinator - # Add Shelly Motion as a switch - if coordinator.model in MOTION_MODELS: - async_setup_entry_attribute_entities( - hass, - config_entry, - async_add_entities, - {("sensor", "motionActive"): MOTION_SWITCH}, - BlockSleepingMotionSwitch, - ) - return + async_setup_entry_attribute_entities( + hass, config_entry, async_add_entities, BLOCK_RELAY_SWITCHES, BlockRelaySwitch + ) - if config_entry.data[CONF_SLEEP_PERIOD]: - return - - # In roller mode the relay blocks exist but do not contain required info - if ( - coordinator.model in [MODEL_2, MODEL_25] - and coordinator.device.settings["mode"] != "relay" - ): - return - - relay_blocks = [] - assert coordinator.device.blocks - for block in coordinator.device.blocks: - if block.type != "relay" or ( - block.channel is not None - and is_block_channel_type_light( - coordinator.device.settings, int(block.channel) - ) - ): - continue - - relay_blocks.append(block) - unique_id = f"{coordinator.mac}-{block.type}_{block.channel}" - async_remove_shelly_entity(hass, "light", unique_id) - - if not relay_blocks: - return - - async_add_entities(BlockRelaySwitch(coordinator, block) for block in relay_blocks) + async_setup_entry_attribute_entities( + hass, + config_entry, + async_add_entities, + BLOCK_SLEEPING_MOTION_SWITCH, + BlockSleepingMotionSwitch, + ) @callback @@ -265,13 +243,22 @@ class BlockSleepingMotionSwitch( self.last_state = last_state -class BlockRelaySwitch(ShellyBlockEntity, SwitchEntity): +class BlockRelaySwitch(ShellyBlockAttributeEntity, SwitchEntity): """Entity that controls a relay on Block based Shelly devices.""" - def __init__(self, coordinator: ShellyBlockCoordinator, block: Block) -> None: + entity_description: BlockSwitchDescription + + def __init__( + self, + coordinator: ShellyBlockCoordinator, + block: Block, + attribute: str, + description: BlockSwitchDescription, + ) -> None: """Initialize relay switch.""" - super().__init__(coordinator, block) + super().__init__(coordinator, block, attribute, description) self.control_result: dict[str, Any] | None = None + self._attr_unique_id: str = f"{coordinator.mac}-{block.description}" @property def is_on(self) -> bool: diff --git a/homeassistant/components/shelly/utils.py b/homeassistant/components/shelly/utils.py index d9e86427d0b..b478e416c50 100644 --- a/homeassistant/components/shelly/utils.py +++ b/homeassistant/components/shelly/utils.py @@ -6,7 +6,7 @@ from collections.abc import Iterable from datetime import datetime, timedelta from ipaddress import IPv4Address, IPv6Address, ip_address from types import MappingProxyType -from typing import Any, cast +from typing import TYPE_CHECKING, Any, cast from aiohttp.web import Request, WebSocketResponse from aioshelly.block_device import COAP, Block, BlockDevice @@ -175,6 +175,18 @@ def is_block_momentary_input( return button_type in momentary_types +def is_block_exclude_from_relay(settings: dict[str, Any], block: Block) -> bool: + """Return true if block should be excluded from switch platform.""" + + if settings.get("mode") == "roller": + return True + + if TYPE_CHECKING: + assert block.channel is not None + + return is_block_channel_type_light(settings, int(block.channel)) + + def get_device_uptime(uptime: float, last_uptime: datetime | None) -> datetime: """Return device uptime string, tolerate up to 5 seconds deviation.""" delta_uptime = utcnow() - timedelta(seconds=uptime) From c69cec28fe6a5c56358b4252c8e19d816b334cb3 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Wed, 5 Mar 2025 15:04:56 +0100 Subject: [PATCH 2239/3148] Bump `gios` to version 6.0.0 (#139832) * Fix the code * Fix tests * Bump version * Use https for configuration URL --- homeassistant/components/gios/__init__.py | 11 ++++++++++- homeassistant/components/gios/config_flow.py | 2 +- homeassistant/components/gios/const.py | 2 +- homeassistant/components/gios/coordinator.py | 6 ++---- homeassistant/components/gios/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 7 files changed, 17 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/gios/__init__.py b/homeassistant/components/gios/__init__.py index c76efbcf361..f756980f5d0 100644 --- a/homeassistant/components/gios/__init__.py +++ b/homeassistant/components/gios/__init__.py @@ -4,9 +4,14 @@ from __future__ import annotations import logging +from aiohttp.client_exceptions import ClientConnectorError +from gios import Gios +from gios.exceptions import GiosError + from homeassistant.components.air_quality import DOMAIN as AIR_QUALITY_PLATFORM from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -36,8 +41,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: GiosConfigEntry) -> bool device_registry.async_update_device(device_entry.id, new_identifiers={new_ids}) websession = async_get_clientsession(hass) + try: + gios = await Gios.create(websession, station_id) + except (GiosError, ConnectionError, ClientConnectorError) as err: + raise ConfigEntryNotReady from err - coordinator = GiosDataUpdateCoordinator(hass, entry, websession, station_id) + coordinator = GiosDataUpdateCoordinator(hass, entry, gios) await coordinator.async_config_entry_first_refresh() entry.runtime_data = GiosData(coordinator) diff --git a/homeassistant/components/gios/config_flow.py b/homeassistant/components/gios/config_flow.py index a089aeab820..ecd0baee6f9 100644 --- a/homeassistant/components/gios/config_flow.py +++ b/homeassistant/components/gios/config_flow.py @@ -37,7 +37,7 @@ class GiosFlowHandler(ConfigFlow, domain=DOMAIN): websession = async_get_clientsession(self.hass) async with asyncio.timeout(API_TIMEOUT): - gios = Gios(user_input[CONF_STATION_ID], websession) + gios = await Gios.create(websession, user_input[CONF_STATION_ID]) await gios.async_update() assert gios.station_name is not None diff --git a/homeassistant/components/gios/const.py b/homeassistant/components/gios/const.py index a8490511ab8..2294e89c961 100644 --- a/homeassistant/components/gios/const.py +++ b/homeassistant/components/gios/const.py @@ -13,7 +13,7 @@ SCAN_INTERVAL: Final = timedelta(minutes=30) DOMAIN: Final = "gios" MANUFACTURER: Final = "Główny Inspektorat Ochrony Środowiska" -URL = "http://powietrze.gios.gov.pl/pjp/current/station_details/info/{station_id}" +URL = "https://powietrze.gios.gov.pl/pjp/current/station_details/info/{station_id}" API_TIMEOUT: Final = 30 diff --git a/homeassistant/components/gios/coordinator.py b/homeassistant/components/gios/coordinator.py index be4b41ca6ee..95f3b8af797 100644 --- a/homeassistant/components/gios/coordinator.py +++ b/homeassistant/components/gios/coordinator.py @@ -6,7 +6,6 @@ import asyncio from dataclasses import dataclass import logging -from aiohttp import ClientSession from aiohttp.client_exceptions import ClientConnectorError from gios import Gios from gios.exceptions import GiosError @@ -39,11 +38,10 @@ class GiosDataUpdateCoordinator(DataUpdateCoordinator[GiosSensors]): self, hass: HomeAssistant, config_entry: GiosConfigEntry, - session: ClientSession, - station_id: int, + gios: Gios, ) -> None: """Class to manage fetching GIOS data API.""" - self.gios = Gios(station_id, session) + self.gios = gios super().__init__( hass, diff --git a/homeassistant/components/gios/manifest.json b/homeassistant/components/gios/manifest.json index 3d2e719fab6..8deb2eee414 100644 --- a/homeassistant/components/gios/manifest.json +++ b/homeassistant/components/gios/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["dacite", "gios"], - "requirements": ["gios==5.0.0"] + "requirements": ["gios==6.0.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 535f3eace46..7a600a83c07 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1008,7 +1008,7 @@ georss-qld-bushfire-alert-client==0.8 getmac==0.9.5 # homeassistant.components.gios -gios==5.0.0 +gios==6.0.0 # homeassistant.components.gitter gitterpy==0.1.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e27a33ce02f..fa66d8b0552 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -861,7 +861,7 @@ georss-qld-bushfire-alert-client==0.8 getmac==0.9.5 # homeassistant.components.gios -gios==5.0.0 +gios==6.0.0 # homeassistant.components.glances glances-api==0.8.0 From 1552aec416d3b3d72b77664932249db3b88aff2f Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 5 Mar 2025 16:13:09 +0100 Subject: [PATCH 2240/3148] Improve frame helper tests (#139843) --- tests/helpers/snapshots/test_frame.ambr | 120 ++++++++++++++ tests/helpers/test_frame.py | 209 +++++++++++++++++++----- 2 files changed, 286 insertions(+), 43 deletions(-) create mode 100644 tests/helpers/snapshots/test_frame.ambr diff --git a/tests/helpers/snapshots/test_frame.ambr b/tests/helpers/snapshots/test_frame.ambr new file mode 100644 index 00000000000..f3fbd54cf45 --- /dev/null +++ b/tests/helpers/snapshots/test_frame.ambr @@ -0,0 +1,120 @@ +# serializer version: 1 +# name: test_report[core default] + list([ + ]) +# --- +# name: test_report[core integration default] + list([ + "Detected that integration 'test_core_integration' test_report_string at homeassistant/components/test_core_integration/light.py, line 23: self.light.is_on. Please create a bug report at https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue+label%3A%22integration%3A+test_core_integration%22", + ]) +# --- +# name: test_report[custom integration default] + list([ + "Detected that custom integration 'test_custom_integration' test_report_string at custom_components/test_custom_integration/light.py, line 23: self.light.is_on. Please create a bug report at https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue+label%3A%22integration%3A+test_custom_integration%22", + ]) +# --- +# name: test_report[disable error_if_core] + list([ + 'Detected code that test_report_string. Please report this issue', + ]) +# --- +# name: test_report[error_if_integration with core integration] + list([ + "Detected that integration 'test_integration_frame' test_report_string at homeassistant/components/test_integration_frame/light.py, line 23: self.light.is_on. Please create a bug report at https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue+label%3A%22integration%3A+test_integration_frame%22", + ]) +# --- +# name: test_report[error_if_integration with custom integration] + list([ + "Detected that custom integration 'test_integration_frame' test_report_string at custom_components/test_integration_frame/light.py, line 23: self.light.is_on. Please create a bug report at https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue+label%3A%22integration%3A+test_integration_frame%22", + ]) +# --- +# name: test_report[log_custom_component_only with core integration] + list([ + ]) +# --- +# name: test_report[log_custom_component_only with custom integration] + list([ + "Detected that custom integration 'test_integration_frame' test_report_string at custom_components/test_integration_frame/light.py, line 23: self.light.is_on. Please create a bug report at https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue+label%3A%22integration%3A+test_integration_frame%22", + ]) +# --- +# name: test_report_usage[core default] + list([ + ]) +# --- +# name: test_report_usage[core integration default] + list([ + "Detected that integration 'test_core_integration' test_report_string at homeassistant/components/test_core_integration/light.py, line 23: self.light.is_on. Please create a bug report at https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue+label%3A%22integration%3A+test_core_integration%22", + ]) +# --- +# name: test_report_usage[core_behavior ignore] + list([ + ]) +# --- +# name: test_report_usage[core_behavior log] + list([ + 'Detected code that test_report_string. Please report this issue', + ]) +# --- +# name: test_report_usage[core_integration_behavior error] + list([ + "Detected that integration 'test_integration_frame' test_report_string at homeassistant/components/test_integration_frame/light.py, line 23: self.light.is_on. Please create a bug report at https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue+label%3A%22integration%3A+test_integration_frame%22", + ]) +# --- +# name: test_report_usage[core_integration_behavior ignore] + list([ + ]) +# --- +# name: test_report_usage[custom integration default] + list([ + "Detected that custom integration 'test_custom_integration' test_report_string at custom_components/test_custom_integration/light.py, line 23: self.light.is_on. Please create a bug report at https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue+label%3A%22integration%3A+test_custom_integration%22", + ]) +# --- +# name: test_report_usage[custom integration error] + list([ + "Detected that custom integration 'test_custom_integration' test_report_string at custom_components/test_custom_integration/light.py, line 23: self.light.is_on. Please create a bug report at https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue+label%3A%22integration%3A+test_custom_integration%22", + ]) +# --- +# name: test_report_usage[custom integration ignore] + list([ + ]) +# --- +# name: test_report_usage_find_issue_tracker[core integration] + list([ + "Detected that integration 'test_core_integration' test_report_string at homeassistant/components/test_core_integration/light.py, line 23: self.light.is_on. Please create a bug report at https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue+label%3A%22integration%3A+test_core_integration%22", + ]) +# --- +# name: test_report_usage_find_issue_tracker[core] + list([ + 'Detected code that test_report_string. Please report this issue', + ]) +# --- +# name: test_report_usage_find_issue_tracker[custom integration] + list([ + "Detected that custom integration 'test_custom_integration' test_report_string at custom_components/test_custom_integration/light.py, line 23: self.light.is_on. Please create a bug report at https://blablabla.com", + ]) +# --- +# name: test_report_usage_find_issue_tracker[unknown custom integration] + list([ + "Detected that custom integration 'unknown_custom_integration' test_report_string at custom_components/unknown_custom_integration/light.py, line 23: self.light.is_on. Please create a bug report at https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue+label%3A%22integration%3A+unknown_custom_integration%22", + ]) +# --- +# name: test_report_usage_find_issue_tracker_other_thread[core integration] + list([ + "Detected that integration 'test_core_integration' test_report_string at homeassistant/components/test_core_integration/light.py, line 23: self.light.is_on. Please create a bug report at https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue+label%3A%22integration%3A+test_core_integration%22", + ]) +# --- +# name: test_report_usage_find_issue_tracker_other_thread[core] + list([ + 'Detected code that test_report_string. Please report this issue', + ]) +# --- +# name: test_report_usage_find_issue_tracker_other_thread[custom integration] + list([ + "Detected that custom integration 'test_custom_integration' test_report_string at custom_components/test_custom_integration/light.py, line 23: self.light.is_on. Please create a bug report at https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue+label%3A%22integration%3A+test_custom_integration%22", + ]) +# --- +# name: test_report_usage_find_issue_tracker_other_thread[unknown custom integration] + list([ + "Detected that custom integration 'unknown_custom_integration' test_report_string at custom_components/unknown_custom_integration/light.py, line 23: self.light.is_on. Please create a bug report at https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue+label%3A%22integration%3A+unknown_custom_integration%22", + ]) +# --- diff --git a/tests/helpers/test_frame.py b/tests/helpers/test_frame.py index d86693dcf9b..22209380dfe 100644 --- a/tests/helpers/test_frame.py +++ b/tests/helpers/test_frame.py @@ -1,15 +1,17 @@ """Test the frame helper.""" +from contextlib import AbstractContextManager, nullcontext as does_not_raise from typing import Any from unittest.mock import ANY, Mock, patch import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.core import HomeAssistant from homeassistant.helpers import frame from homeassistant.loader import async_get_integration -from tests.common import extract_stack_to_frame +from tests.common import MockModule, extract_stack_to_frame, mock_integration async def test_extract_frame_integration( @@ -159,68 +161,68 @@ async def test_get_integration_logger_no_integration( @pytest.mark.parametrize( - ("integration_frame_path", "keywords", "expected_error", "expected_log"), + ("integration_frame_path", "keywords", "expected_result", "expected_log"), [ pytest.param( "homeassistant/test_core", {}, - True, + pytest.raises(RuntimeError, match="test_report_string"), 0, id="core default", ), pytest.param( "homeassistant/components/test_core_integration", {}, - False, + does_not_raise(), 1, id="core integration default", ), pytest.param( "custom_components/test_custom_integration", {}, - False, + does_not_raise(), 1, id="custom integration default", ), pytest.param( "custom_components/test_custom_integration", {"custom_integration_behavior": frame.ReportBehavior.IGNORE}, - False, + does_not_raise(), 0, id="custom integration ignore", ), pytest.param( "custom_components/test_custom_integration", {"custom_integration_behavior": frame.ReportBehavior.ERROR}, - True, + pytest.raises(RuntimeError, match="test_report_string"), 1, id="custom integration error", ), pytest.param( "homeassistant/components/test_integration_frame", {"core_integration_behavior": frame.ReportBehavior.IGNORE}, - False, + does_not_raise(), 0, id="core_integration_behavior ignore", ), pytest.param( "homeassistant/components/test_integration_frame", {"core_integration_behavior": frame.ReportBehavior.ERROR}, - True, + pytest.raises(RuntimeError, match="test_report_string"), 1, id="core_integration_behavior error", ), pytest.param( "homeassistant/test_integration_frame", {"core_behavior": frame.ReportBehavior.IGNORE}, - False, + does_not_raise(), 0, id="core_behavior ignore", ), pytest.param( "homeassistant/test_integration_frame", {"core_behavior": frame.ReportBehavior.LOG}, - False, + does_not_raise(), 1, id="core_behavior log", ), @@ -229,24 +231,142 @@ async def test_get_integration_logger_no_integration( @pytest.mark.usefixtures("mock_integration_frame") async def test_report_usage( caplog: pytest.LogCaptureFixture, + snapshot: SnapshotAssertion, keywords: dict[str, Any], - expected_error: bool, + expected_result: AbstractContextManager, expected_log: int, ) -> None: - """Test report.""" + """Test report_usage. + + Note: This test doesn't set up mock integrations, so it will not + find the correct issue tracker URL, and we don't check for that. + """ what = "test_report_string" - errored = False - try: - with patch.object(frame, "_REPORTED_INTEGRATIONS", set()): - frame.report_usage(what, **keywords) - except RuntimeError: - errored = True - - assert errored == expected_error + with patch.object(frame, "_REPORTED_INTEGRATIONS", set()), expected_result: + frame.report_usage(what, **keywords) assert caplog.text.count(what) == expected_log + reports = [ + rec.message for rec in caplog.records if rec.message.startswith("Detected") + ] + assert reports == snapshot + + +@pytest.mark.parametrize( + "integration_frame_path", + [ + pytest.param( + "homeassistant/test_core", + id="core", + ), + pytest.param( + "homeassistant/components/test_core_integration", + id="core integration", + ), + pytest.param( + "custom_components/test_custom_integration", + id="custom integration", + ), + pytest.param( + "custom_components/unknown_custom_integration", + id="unknown custom integration", + ), + ], +) +@pytest.mark.usefixtures("mock_integration_frame") +async def test_report_usage_find_issue_tracker( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + snapshot: SnapshotAssertion, +) -> None: + """Test report_usage finds the correct issue tracker. + + Note: The issue tracker is found by loader.async_suggest_report_issue, this + test is a sanity check to ensure async_suggest_report_issue is given the + right parameters. + """ + + what = "test_report_string" + mock_integration(hass, MockModule("test_core_integration")) + mock_integration( + hass, + MockModule( + "test_custom_integration", + partial_manifest={"issue_tracker": "https://blablabla.com"}, + ), + built_in=False, + ) + + with patch.object(frame, "_REPORTED_INTEGRATIONS", set()): + frame.report_usage(what, core_behavior=frame.ReportBehavior.LOG) + + assert caplog.text.count(what) == 1 + reports = [ + rec.message for rec in caplog.records if rec.message.startswith("Detected") + ] + assert reports == snapshot + + +@pytest.mark.parametrize( + "integration_frame_path", + [ + pytest.param( + "homeassistant/test_core", + id="core", + ), + pytest.param( + "homeassistant/components/test_core_integration", + id="core integration", + ), + pytest.param( + "custom_components/test_custom_integration", + id="custom integration", + ), + pytest.param( + "custom_components/unknown_custom_integration", + id="unknown custom integration", + ), + ], +) +@pytest.mark.usefixtures("mock_integration_frame") +async def test_report_usage_find_issue_tracker_other_thread( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + snapshot: SnapshotAssertion, +) -> None: + """Test report_usage finds the correct issue tracker. + + In this test, we run the report_usage in a separate thread. + + Note: The issue tracker is found by loader.async_suggest_report_issue, this + test is a sanity check to ensure async_suggest_report_issue is given the + right parameters. + """ + + what = "test_report_string" + mock_integration(hass, MockModule("test_core_integration")) + mock_integration( + hass, + MockModule( + "test_custom_integration", + partial_manifest={"issue_tracker": "https://blablabla.com"}, + ), + built_in=False, + ) + + def sync_job() -> None: + with patch.object(frame, "_REPORTED_INTEGRATIONS", set()): + frame.report_usage(what, core_behavior=frame.ReportBehavior.LOG) + + await hass.async_add_executor_job(sync_job) + + assert caplog.text.count(what) == 1 + reports = [ + rec.message for rec in caplog.records if rec.message.startswith("Detected") + ] + assert reports == snapshot @patch.object(frame, "_REPORTED_INTEGRATIONS", set()) @@ -365,61 +485,61 @@ async def test_report_error_if_integration( @pytest.mark.parametrize( - ("integration_frame_path", "keywords", "expected_error", "expected_log"), + ("integration_frame_path", "keywords", "expected_result", "expected_log"), [ pytest.param( "homeassistant/test_core", {}, - True, + pytest.raises(RuntimeError, match="test_report_string"), 0, id="core default", ), pytest.param( "homeassistant/components/test_core_integration", {}, - False, + does_not_raise(), 1, id="core integration default", ), pytest.param( "custom_components/test_custom_integration", {}, - False, + does_not_raise(), 1, id="custom integration default", ), pytest.param( "custom_components/test_integration_frame", {"log_custom_component_only": True}, - False, + does_not_raise(), 1, id="log_custom_component_only with custom integration", ), pytest.param( "homeassistant/components/test_integration_frame", {"log_custom_component_only": True}, - False, + does_not_raise(), 0, id="log_custom_component_only with core integration", ), pytest.param( "homeassistant/test_integration_frame", {"error_if_core": False}, - False, + does_not_raise(), 1, id="disable error_if_core", ), pytest.param( "custom_components/test_integration_frame", {"error_if_integration": True}, - True, + pytest.raises(RuntimeError, match="test_report_string"), 1, id="error_if_integration with custom integration", ), pytest.param( "homeassistant/components/test_integration_frame", {"error_if_integration": True}, - True, + pytest.raises(RuntimeError, match="test_report_string"), 1, id="error_if_integration with core integration", ), @@ -428,24 +548,27 @@ async def test_report_error_if_integration( @pytest.mark.usefixtures("mock_integration_frame") async def test_report( caplog: pytest.LogCaptureFixture, + snapshot: SnapshotAssertion, keywords: dict[str, Any], - expected_error: bool, + expected_result: AbstractContextManager, expected_log: int, ) -> None: - """Test report.""" + """Test report. + + Note: This test doesn't set up mock integrations, so it will not + find the correct issue tracker URL, and we don't check for that. + """ what = "test_report_string" - errored = False - try: - with patch.object(frame, "_REPORTED_INTEGRATIONS", set()): - frame.report(what, **keywords) - except RuntimeError: - errored = True - - assert errored == expected_error + with patch.object(frame, "_REPORTED_INTEGRATIONS", set()), expected_result: + frame.report(what, **keywords) assert caplog.text.count(what) == expected_log + reports = [ + rec.message for rec in caplog.records if rec.message.startswith("Detected") + ] + assert reports == snapshot @pytest.mark.parametrize( @@ -496,7 +619,7 @@ async def test_report( "homeassistant/components/hue", "that integration 'hue'", False, - id="core integration", + id="core integration stack mismatch", ), # Assert integration found in stack frame has priority over integration_domain pytest.param( @@ -505,7 +628,7 @@ async def test_report( "custom_components/hue", "that custom integration 'hue'", False, - id="custom integration", + id="custom integration stack mismatch", ), ], ) @@ -518,7 +641,7 @@ async def test_report_integration_domain( source: str, logs_again: bool, ) -> None: - """Test report.""" + """Test report_usage when integration_domain is specified.""" await async_get_integration(hass, "sensor") await async_get_integration(hass, "test_package") From fffb414ba920b3cf18408646e4f99bfc86d2f752 Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Wed, 5 Mar 2025 16:19:15 +0100 Subject: [PATCH 2241/3148] Bump onedrive-personal-sdk to 0.0.13 (#139846) --- homeassistant/components/onedrive/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/onedrive/manifest.json b/homeassistant/components/onedrive/manifest.json index 31a1f2ccb06..c3d98200b03 100644 --- a/homeassistant/components/onedrive/manifest.json +++ b/homeassistant/components/onedrive/manifest.json @@ -9,5 +9,5 @@ "iot_class": "cloud_polling", "loggers": ["onedrive_personal_sdk"], "quality_scale": "platinum", - "requirements": ["onedrive-personal-sdk==0.0.12"] + "requirements": ["onedrive-personal-sdk==0.0.13"] } diff --git a/requirements_all.txt b/requirements_all.txt index 7a600a83c07..5d8a5b79acc 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1565,7 +1565,7 @@ omnilogic==0.4.5 ondilo==0.5.0 # homeassistant.components.onedrive -onedrive-personal-sdk==0.0.12 +onedrive-personal-sdk==0.0.13 # homeassistant.components.onvif onvif-zeep-async==3.2.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fa66d8b0552..cc8c6b2208a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1313,7 +1313,7 @@ omnilogic==0.4.5 ondilo==0.5.0 # homeassistant.components.onedrive -onedrive-personal-sdk==0.0.12 +onedrive-personal-sdk==0.0.13 # homeassistant.components.onvif onvif-zeep-async==3.2.5 From e7d371cddc398f392bbd7a54df3d3249c06a54df Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 5 Mar 2025 17:04:41 +0100 Subject: [PATCH 2242/3148] Bump aioecowitt to 2025.3.1 (#139841) * Bump aioecowitt to 2025.3.1 * Bump aioecowitt to 2025.3.1 --- homeassistant/components/ecowitt/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- script/licenses.py | 1 - 4 files changed, 3 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/ecowitt/manifest.json b/homeassistant/components/ecowitt/manifest.json index 175960ab57d..3ce66f48f95 100644 --- a/homeassistant/components/ecowitt/manifest.json +++ b/homeassistant/components/ecowitt/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["webhook"], "documentation": "https://www.home-assistant.io/integrations/ecowitt", "iot_class": "local_push", - "requirements": ["aioecowitt==2024.2.1"] + "requirements": ["aioecowitt==2025.3.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 5d8a5b79acc..efd9ab91eff 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -234,7 +234,7 @@ aioeafm==0.1.2 aioeagle==1.1.0 # homeassistant.components.ecowitt -aioecowitt==2024.2.1 +aioecowitt==2025.3.1 # homeassistant.components.co2signal aioelectricitymaps==0.4.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index cc8c6b2208a..a92e0b8134e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -222,7 +222,7 @@ aioeafm==0.1.2 aioeagle==1.1.0 # homeassistant.components.ecowitt -aioecowitt==2024.2.1 +aioecowitt==2025.3.1 # homeassistant.components.co2signal aioelectricitymaps==0.4.0 diff --git a/script/licenses.py b/script/licenses.py index aa15a58f3bd..448e9dd2a67 100644 --- a/script/licenses.py +++ b/script/licenses.py @@ -180,7 +180,6 @@ EXCEPTIONS = { "PyMicroBot", # https://github.com/spycle/pyMicroBot/pull/3 "PySwitchmate", # https://github.com/Danielhiversen/pySwitchmate/pull/16 "PyXiaomiGateway", # https://github.com/Danielhiversen/PyXiaomiGateway/pull/201 - "aioecowitt", # https://github.com/home-assistant-libs/aioecowitt/pull/180 "chacha20poly1305", # LGPL "commentjson", # https://github.com/vaidik/commentjson/pull/55 "crownstone-cloud", # https://github.com/crownstone/crownstone-lib-python-cloud/pull/5 From 0f3409bd094f09ab441472d2acd5b3e2e2dae885 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 5 Mar 2025 17:07:43 +0100 Subject: [PATCH 2243/3148] Fix stale test name in vacuum (#139853) --- tests/components/vacuum/test_init.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/components/vacuum/test_init.py b/tests/components/vacuum/test_init.py index 8ae054b5646..5735d557288 100644 --- a/tests/components/vacuum/test_init.py +++ b/tests/components/vacuum/test_init.py @@ -428,7 +428,7 @@ async def test_vacuum_log_deprecated_state_warning_using_attr_state_attr( @pytest.mark.usefixtures("mock_as_custom_component") @patch.object(frame, "_REPORTED_INTEGRATIONS", set()) -async def test_alarm_control_panel_deprecated_state_does_not_break_state( +async def test_vacuum_deprecated_state_does_not_break_state( hass: HomeAssistant, config_flow_fixture: None, caplog: pytest.LogCaptureFixture, From b225a7f37012e5507189f11d2438441e1abd51a3 Mon Sep 17 00:00:00 2001 From: jb101010-2 <168106462+jb101010-2@users.noreply.github.com> Date: Wed, 5 Mar 2025 18:12:34 +0100 Subject: [PATCH 2244/3148] Bump pysuezV2 to 2.0.4 (#139824) --- homeassistant/components/suez_water/coordinator.py | 4 ++-- homeassistant/components/suez_water/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/suez_water/coordinator.py b/homeassistant/components/suez_water/coordinator.py index 38f94b8937e..10d4d3cdbcb 100644 --- a/homeassistant/components/suez_water/coordinator.py +++ b/homeassistant/components/suez_water/coordinator.py @@ -20,8 +20,8 @@ class SuezWaterAggregatedAttributes: this_month_consumption: dict[str, float] previous_month_consumption: dict[str, float] - last_year_overall: dict[str, float] - this_year_overall: dict[str, float] + last_year_overall: int + this_year_overall: int history: dict[str, float] highest_monthly_consumption: float diff --git a/homeassistant/components/suez_water/manifest.json b/homeassistant/components/suez_water/manifest.json index 5d317ea5ba3..f09d2e22633 100644 --- a/homeassistant/components/suez_water/manifest.json +++ b/homeassistant/components/suez_water/manifest.json @@ -7,5 +7,5 @@ "iot_class": "cloud_polling", "loggers": ["pysuez", "regex"], "quality_scale": "bronze", - "requirements": ["pysuezV2==2.0.3"] + "requirements": ["pysuezV2==2.0.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index efd9ab91eff..19a74b01d90 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2346,7 +2346,7 @@ pysqueezebox==0.12.0 pystiebeleltron==0.0.1.dev2 # homeassistant.components.suez_water -pysuezV2==2.0.3 +pysuezV2==2.0.4 # homeassistant.components.switchbee pyswitchbee==1.8.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a92e0b8134e..60d767c2199 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1915,7 +1915,7 @@ pyspeex-noise==1.0.2 pysqueezebox==0.12.0 # homeassistant.components.suez_water -pysuezV2==2.0.3 +pysuezV2==2.0.4 # homeassistant.components.switchbee pyswitchbee==1.8.3 From cfe102f274404045da4bdff26ce2308e97a5793a Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Wed, 5 Mar 2025 11:20:48 -0600 Subject: [PATCH 2245/3148] Bump intents to 2025.3.5 (#139851) Co-authored-by: Franck Nijhof --- homeassistant/components/conversation/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- script/hassfest/docker/Dockerfile | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/conversation/manifest.json b/homeassistant/components/conversation/manifest.json index c4f1860eed6..ea950ace323 100644 --- a/homeassistant/components/conversation/manifest.json +++ b/homeassistant/components/conversation/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/conversation", "integration_type": "system", "quality_scale": "internal", - "requirements": ["hassil==2.2.3", "home-assistant-intents==2025.2.26"] + "requirements": ["hassil==2.2.3", "home-assistant-intents==2025.3.5"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 1df15df867f..4a2d4219b50 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -38,7 +38,7 @@ hass-nabucasa==0.94.0 hassil==2.2.3 home-assistant-bluetooth==1.13.1 home-assistant-frontend==20250305.0 -home-assistant-intents==2025.2.26 +home-assistant-intents==2025.3.5 httpx==0.28.1 ifaddr==0.2.0 Jinja2==3.1.5 diff --git a/requirements_all.txt b/requirements_all.txt index 19a74b01d90..1d8947d861b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1155,7 +1155,7 @@ holidays==0.68 home-assistant-frontend==20250305.0 # homeassistant.components.conversation -home-assistant-intents==2025.2.26 +home-assistant-intents==2025.3.5 # homeassistant.components.homematicip_cloud homematicip==1.1.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 60d767c2199..b47e238e1f2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -984,7 +984,7 @@ holidays==0.68 home-assistant-frontend==20250305.0 # homeassistant.components.conversation -home-assistant-intents==2025.2.26 +home-assistant-intents==2025.3.5 # homeassistant.components.homematicip_cloud homematicip==1.1.7 diff --git a/script/hassfest/docker/Dockerfile b/script/hassfest/docker/Dockerfile index c09d547ba79..9d0bbeefd74 100644 --- a/script/hassfest/docker/Dockerfile +++ b/script/hassfest/docker/Dockerfile @@ -25,7 +25,7 @@ RUN --mount=from=ghcr.io/astral-sh/uv:0.6.1,source=/uv,target=/bin/uv \ -c /usr/src/homeassistant/homeassistant/package_constraints.txt \ -r /usr/src/homeassistant/requirements.txt \ stdlib-list==0.10.0 pipdeptree==2.25.0 tqdm==4.67.1 ruff==0.9.8 \ - PyTurboJPEG==1.7.5 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 hassil==2.2.3 home-assistant-intents==2025.2.26 mutagen==1.47.0 pymicro-vad==1.0.1 pyspeex-noise==1.0.2 + PyTurboJPEG==1.7.5 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 hassil==2.2.3 home-assistant-intents==2025.3.5 mutagen==1.47.0 pymicro-vad==1.0.1 pyspeex-noise==1.0.2 LABEL "name"="hassfest" LABEL "maintainer"="Home Assistant " From 2812c8a9930309ab2b957f6e8047cb7ef229c117 Mon Sep 17 00:00:00 2001 From: LG-ThinQ-Integration Date: Wed, 5 Mar 2025 20:13:11 +0900 Subject: [PATCH 2246/3148] Get temperature data appropriate for hass.config.unit in LG ThinQ (#137626) * Get temperature data appropriate for hass.config.unit * Modify temperature_unit for init * Modify unit's map * Fix ruff error --------- Co-authored-by: yunseon.park --- homeassistant/components/lg_thinq/climate.py | 9 ++++- homeassistant/components/lg_thinq/const.py | 9 +++++ .../components/lg_thinq/coordinator.py | 40 ++++++++++++++++++- homeassistant/components/lg_thinq/entity.py | 10 +---- .../lg_thinq/snapshots/test_climate.ambr | 16 ++++---- tests/components/lg_thinq/test_climate.py | 3 +- 6 files changed, 67 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/lg_thinq/climate.py b/homeassistant/components/lg_thinq/climate.py index 73678e209f7..98a86a8d355 100644 --- a/homeassistant/components/lg_thinq/climate.py +++ b/homeassistant/components/lg_thinq/climate.py @@ -110,7 +110,9 @@ class ThinQClimateEntity(ThinQEntity, ClimateEntity): self._attr_hvac_modes = [HVACMode.OFF] self._attr_hvac_mode = HVACMode.OFF self._attr_preset_modes = [] - self._attr_temperature_unit = UnitOfTemperature.CELSIUS + self._attr_temperature_unit = ( + self._get_unit_of_measurement(self.data.unit) or UnitOfTemperature.CELSIUS + ) self._requested_hvac_mode: str | None = None # Set up HVAC modes. @@ -182,6 +184,11 @@ class ThinQClimateEntity(ThinQEntity, ClimateEntity): self._attr_target_temperature_high = self.data.target_temp_high self._attr_target_temperature_low = self.data.target_temp_low + # Update unit. + self._attr_temperature_unit = ( + self._get_unit_of_measurement(self.data.unit) or UnitOfTemperature.CELSIUS + ) + _LOGGER.debug( "[%s:%s] update status: c:%s, t:%s, l:%s, h:%s, hvac:%s, unit:%s, step:%s", self.coordinator.device_name, diff --git a/homeassistant/components/lg_thinq/const.py b/homeassistant/components/lg_thinq/const.py index a65dee715db..20c6455241a 100644 --- a/homeassistant/components/lg_thinq/const.py +++ b/homeassistant/components/lg_thinq/const.py @@ -3,6 +3,8 @@ from datetime import timedelta from typing import Final +from homeassistant.const import UnitOfTemperature + # Config flow DOMAIN = "lg_thinq" COMPANY = "LGE" @@ -18,3 +20,10 @@ MQTT_SUBSCRIPTION_INTERVAL: Final = timedelta(days=1) # MQTT: Message types DEVICE_PUSH_MESSAGE: Final = "DEVICE_PUSH" DEVICE_STATUS_MESSAGE: Final = "DEVICE_STATUS" + +# Unit conversion map +DEVICE_UNIT_TO_HA: dict[str, str] = { + "F": UnitOfTemperature.FAHRENHEIT, + "C": UnitOfTemperature.CELSIUS, +} +REVERSE_DEVICE_UNIT_TO_HA = {v: k for k, v in DEVICE_UNIT_TO_HA.items()} diff --git a/homeassistant/components/lg_thinq/coordinator.py b/homeassistant/components/lg_thinq/coordinator.py index d6991d15297..513cd27a7b2 100644 --- a/homeassistant/components/lg_thinq/coordinator.py +++ b/homeassistant/components/lg_thinq/coordinator.py @@ -2,19 +2,21 @@ from __future__ import annotations +from collections.abc import Mapping import logging from typing import TYPE_CHECKING, Any from thinqconnect import ThinQAPIException from thinqconnect.integration import HABridge -from homeassistant.core import HomeAssistant +from homeassistant.const import EVENT_CORE_CONFIG_UPDATE +from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed if TYPE_CHECKING: from . import ThinqConfigEntry -from .const import DOMAIN +from .const import DOMAIN, REVERSE_DEVICE_UNIT_TO_HA _LOGGER = logging.getLogger(__name__) @@ -54,6 +56,40 @@ class DeviceDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): f"{self.device_id}_{self.sub_id}" if self.sub_id else self.device_id ) + # Set your preferred temperature unit. This will allow us to retrieve + # temperature values from the API in a converted value corresponding to + # preferred unit. + self._update_preferred_temperature_unit() + + # Add a callback to handle core config update. + self.unit_system: str | None = None + self.hass.bus.async_listen( + event_type=EVENT_CORE_CONFIG_UPDATE, + listener=self._handle_update_config, + event_filter=self.async_config_update_filter, + ) + + async def _handle_update_config(self, _: Event) -> None: + """Handle update core config.""" + self._update_preferred_temperature_unit() + + await self.async_refresh() + + @callback + def async_config_update_filter(self, event_data: Mapping[str, Any]) -> bool: + """Filter out unwanted events.""" + if (unit_system := event_data.get("unit_system")) != self.unit_system: + self.unit_system = unit_system + return True + + return False + + def _update_preferred_temperature_unit(self) -> None: + """Update preferred temperature unit.""" + self.api.set_preferred_temperature_unit( + REVERSE_DEVICE_UNIT_TO_HA.get(self.hass.config.units.temperature_unit) + ) + async def _async_update_data(self) -> dict[str, Any]: """Request to the server to update the status from full response data.""" try: diff --git a/homeassistant/components/lg_thinq/entity.py b/homeassistant/components/lg_thinq/entity.py index 7856506559b..61d8199f321 100644 --- a/homeassistant/components/lg_thinq/entity.py +++ b/homeassistant/components/lg_thinq/entity.py @@ -10,25 +10,19 @@ from thinqconnect import ThinQAPIException from thinqconnect.devices.const import Location from thinqconnect.integration import PropertyState -from homeassistant.const import UnitOfTemperature from homeassistant.core import callback from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import device_registry as dr from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import COMPANY, DOMAIN +from .const import COMPANY, DEVICE_UNIT_TO_HA, DOMAIN from .coordinator import DeviceDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) EMPTY_STATE = PropertyState() -UNIT_CONVERSION_MAP: dict[str, str] = { - "F": UnitOfTemperature.FAHRENHEIT, - "C": UnitOfTemperature.CELSIUS, -} - class ThinQEntity(CoordinatorEntity[DeviceDataUpdateCoordinator]): """The base implementation of all lg thinq entities.""" @@ -75,7 +69,7 @@ class ThinQEntity(CoordinatorEntity[DeviceDataUpdateCoordinator]): if unit is None: return None - return UNIT_CONVERSION_MAP.get(unit) + return DEVICE_UNIT_TO_HA.get(unit) def _update_status(self) -> None: """Update status itself. diff --git a/tests/components/lg_thinq/snapshots/test_climate.ambr b/tests/components/lg_thinq/snapshots/test_climate.ambr index db57e824487..111d49a2ef3 100644 --- a/tests/components/lg_thinq/snapshots/test_climate.ambr +++ b/tests/components/lg_thinq/snapshots/test_climate.ambr @@ -15,8 +15,8 @@ , , ]), - 'max_temp': 30, - 'min_temp': 18, + 'max_temp': 86, + 'min_temp': 64, 'preset_modes': list([ 'air_clean', ]), @@ -28,7 +28,7 @@ 'on', 'off', ]), - 'target_temp_step': 1, + 'target_temp_step': 2, }), 'config_entry_id': , 'config_subentry_id': , @@ -62,7 +62,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'current_humidity': 40, - 'current_temperature': 25, + 'current_temperature': 77, 'fan_mode': 'mid', 'fan_modes': list([ 'low', @@ -75,8 +75,8 @@ , , ]), - 'max_temp': 30, - 'min_temp': 18, + 'max_temp': 86, + 'min_temp': 64, 'preset_mode': None, 'preset_modes': list([ 'air_clean', @@ -94,8 +94,8 @@ ]), 'target_temp_high': None, 'target_temp_low': None, - 'target_temp_step': 1, - 'temperature': 19, + 'target_temp_step': 2, + 'temperature': 66, }), 'context': , 'entity_id': 'climate.test_air_conditioner', diff --git a/tests/components/lg_thinq/test_climate.py b/tests/components/lg_thinq/test_climate.py index 24ed3ad230d..4ac2fa55a21 100644 --- a/tests/components/lg_thinq/test_climate.py +++ b/tests/components/lg_thinq/test_climate.py @@ -5,7 +5,7 @@ from unittest.mock import AsyncMock, patch import pytest from syrupy import SnapshotAssertion -from homeassistant.const import Platform +from homeassistant.const import Platform, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -23,6 +23,7 @@ async def test_all_entities( entity_registry: er.EntityRegistry, ) -> None: """Test all entities.""" + hass.config.units.temperature_unit = UnitOfTemperature.FAHRENHEIT with patch("homeassistant.components.lg_thinq.PLATFORMS", [Platform.CLIMATE]): await setup_integration(hass, mock_config_entry) From 1484e46317726218336acfc43e7354eaed7da29c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 4 Mar 2025 14:57:03 -1000 Subject: [PATCH 2247/3148] Bump nexia to 2.2.1 (#139786) * Bump nexia to 2.2.0 changelog: https://github.com/bdraco/nexia/compare/2.1.1...2.2.0 * Apply suggestions from code review --- homeassistant/components/nexia/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/nexia/manifest.json b/homeassistant/components/nexia/manifest.json index 8a9cda14646..337378a283c 100644 --- a/homeassistant/components/nexia/manifest.json +++ b/homeassistant/components/nexia/manifest.json @@ -12,5 +12,5 @@ "documentation": "https://www.home-assistant.io/integrations/nexia", "iot_class": "cloud_polling", "loggers": ["nexia"], - "requirements": ["nexia==2.1.1"] + "requirements": ["nexia==2.2.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index f972f4adb57..19d93b4927b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1483,7 +1483,7 @@ nettigo-air-monitor==4.0.0 neurio==0.3.1 # homeassistant.components.nexia -nexia==2.1.1 +nexia==2.2.1 # homeassistant.components.nextcloud nextcloudmonitor==1.5.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1e6c7814426..30754158426 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1246,7 +1246,7 @@ netmap==0.7.0.2 nettigo-air-monitor==4.0.0 # homeassistant.components.nexia -nexia==2.1.1 +nexia==2.2.1 # homeassistant.components.nextcloud nextcloudmonitor==1.5.1 From 3f94b7a61c9514f39fc0bf99608d7b7ec14dac19 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Wed, 5 Mar 2025 05:36:20 -0800 Subject: [PATCH 2248/3148] Revert "Add scene support to roborock (#137203)" (#139840) This reverts commit 379bf106754dffd5c6c8cd8035a33597976cd866. --- homeassistant/components/roborock/__init__.py | 24 +--- homeassistant/components/roborock/const.py | 1 - .../components/roborock/coordinator.py | 49 +------- homeassistant/components/roborock/scene.py | 64 ---------- tests/components/roborock/conftest.py | 23 +--- tests/components/roborock/mock_data.py | 17 --- tests/components/roborock/test_scene.py | 112 ------------------ 7 files changed, 12 insertions(+), 278 deletions(-) delete mode 100644 homeassistant/components/roborock/scene.py delete mode 100644 tests/components/roborock/test_scene.py diff --git a/homeassistant/components/roborock/__init__.py b/homeassistant/components/roborock/__init__.py index 955e50cd15b..c382a56cde7 100644 --- a/homeassistant/components/roborock/__init__.py +++ b/homeassistant/components/roborock/__init__.py @@ -83,13 +83,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: RoborockConfigEntry) -> # Get a Coordinator if the device is available or if we have connected to the device before coordinators = await asyncio.gather( *build_setup_functions( - hass, - entry, - device_map, - user_data, - product_info, - home_data.rooms, - api_client, + hass, entry, device_map, user_data, product_info, home_data.rooms ), return_exceptions=True, ) @@ -141,7 +135,6 @@ def build_setup_functions( user_data: UserData, product_info: dict[str, HomeDataProduct], home_data_rooms: list[HomeDataRoom], - api_client: RoborockApiClient, ) -> list[ Coroutine[ Any, @@ -158,7 +151,6 @@ def build_setup_functions( device, product_info[device.product_id], home_data_rooms, - api_client, ) for device in device_map.values() ] @@ -171,12 +163,11 @@ async def setup_device( device: HomeDataDevice, product_info: HomeDataProduct, home_data_rooms: list[HomeDataRoom], - api_client: RoborockApiClient, ) -> RoborockDataUpdateCoordinator | RoborockDataUpdateCoordinatorA01 | None: """Set up a coordinator for a given device.""" if device.pv == "1.0": return await setup_device_v1( - hass, entry, user_data, device, product_info, home_data_rooms, api_client + hass, entry, user_data, device, product_info, home_data_rooms ) if device.pv == "A01": return await setup_device_a01(hass, entry, user_data, device, product_info) @@ -196,7 +187,6 @@ async def setup_device_v1( device: HomeDataDevice, product_info: HomeDataProduct, home_data_rooms: list[HomeDataRoom], - api_client: RoborockApiClient, ) -> RoborockDataUpdateCoordinator | None: """Set up a device Coordinator.""" mqtt_client = await hass.async_add_executor_job( @@ -218,15 +208,7 @@ async def setup_device_v1( await mqtt_client.async_release() raise coordinator = RoborockDataUpdateCoordinator( - hass, - entry, - device, - networking, - product_info, - mqtt_client, - home_data_rooms, - api_client, - user_data, + hass, entry, device, networking, product_info, mqtt_client, home_data_rooms ) try: await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/roborock/const.py b/homeassistant/components/roborock/const.py index fe9091a3ea7..cc8d34fbadc 100644 --- a/homeassistant/components/roborock/const.py +++ b/homeassistant/components/roborock/const.py @@ -36,7 +36,6 @@ PLATFORMS = [ Platform.BUTTON, Platform.IMAGE, Platform.NUMBER, - Platform.SCENE, Platform.SELECT, Platform.SENSOR, Platform.SWITCH, diff --git a/homeassistant/components/roborock/coordinator.py b/homeassistant/components/roborock/coordinator.py index 6690b0ac07e..806651c9ac5 100644 --- a/homeassistant/components/roborock/coordinator.py +++ b/homeassistant/components/roborock/coordinator.py @@ -10,26 +10,17 @@ import logging from propcache.api import cached_property from roborock import HomeDataRoom from roborock.code_mappings import RoborockCategory -from roborock.containers import ( - DeviceData, - HomeDataDevice, - HomeDataProduct, - HomeDataScene, - NetworkInfo, - UserData, -) +from roborock.containers import DeviceData, HomeDataDevice, HomeDataProduct, NetworkInfo from roborock.exceptions import RoborockException from roborock.roborock_message import RoborockDyadDataProtocol, RoborockZeoProtocol from roborock.roborock_typing import DeviceProp from roborock.version_1_apis.roborock_local_client_v1 import RoborockLocalClientV1 from roborock.version_1_apis.roborock_mqtt_client_v1 import RoborockMqttClientV1 from roborock.version_a01_apis import RoborockClientA01 -from roborock.web_api import RoborockApiClient from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_CONNECTIONS from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.typing import StateType @@ -76,8 +67,6 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): product_info: HomeDataProduct, cloud_api: RoborockMqttClientV1, home_data_rooms: list[HomeDataRoom], - api_client: RoborockApiClient, - user_data: UserData, ) -> None: """Initialize.""" super().__init__( @@ -100,7 +89,7 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): self.cloud_api = cloud_api self.device_info = DeviceInfo( name=self.roborock_device_info.device.name, - identifiers={(DOMAIN, self.duid)}, + identifiers={(DOMAIN, self.roborock_device_info.device.duid)}, manufacturer="Roborock", model=self.roborock_device_info.product.model, model_id=self.roborock_device_info.product.model, @@ -114,10 +103,8 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): self.maps: dict[int, RoborockMapInfo] = {} self._home_data_rooms = {str(room.id): room.name for room in home_data_rooms} self.map_storage = RoborockMapStorage( - hass, self.config_entry.entry_id, self.duid_slug + hass, self.config_entry.entry_id, slugify(self.duid) ) - self._user_data = user_data - self._api_client = api_client async def _async_setup(self) -> None: """Set up the coordinator.""" @@ -147,7 +134,7 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): except RoborockException: _LOGGER.warning( "Using the cloud API for device %s. This is not recommended as it can lead to rate limiting. We recommend making your vacuum accessible by your Home Assistant instance", - self.duid, + self.roborock_device_info.device.duid, ) await self.api.async_disconnect() # We use the cloud api if the local api fails to connect. @@ -207,34 +194,6 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): for room in room_mapping or () } - async def get_scenes(self) -> list[HomeDataScene]: - """Get scenes.""" - try: - return await self._api_client.get_scenes(self._user_data, self.duid) - except RoborockException as err: - _LOGGER.error("Failed to get scenes %s", err) - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="command_failed", - translation_placeholders={ - "command": "get_scenes", - }, - ) from err - - async def execute_scene(self, scene_id: int) -> None: - """Execute scene.""" - try: - await self._api_client.execute_scene(self._user_data, scene_id) - except RoborockException as err: - _LOGGER.error("Failed to execute scene %s %s", scene_id, err) - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="command_failed", - translation_placeholders={ - "command": "execute_scene", - }, - ) from err - @cached_property def duid(self) -> str: """Get the unique id of the device as specified by Roborock.""" diff --git a/homeassistant/components/roborock/scene.py b/homeassistant/components/roborock/scene.py deleted file mode 100644 index ff418a2810c..00000000000 --- a/homeassistant/components/roborock/scene.py +++ /dev/null @@ -1,64 +0,0 @@ -"""Support for Roborock scene.""" - -from __future__ import annotations - -import asyncio -from typing import Any - -from homeassistant.components.scene import Scene as SceneEntity -from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import EntityDescription -from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback - -from . import RoborockConfigEntry -from .coordinator import RoborockDataUpdateCoordinator -from .entity import RoborockEntity - - -async def async_setup_entry( - hass: HomeAssistant, - config_entry: RoborockConfigEntry, - async_add_entities: AddConfigEntryEntitiesCallback, -) -> None: - """Set up scene platform.""" - scene_lists = await asyncio.gather( - *[coordinator.get_scenes() for coordinator in config_entry.runtime_data.v1], - ) - async_add_entities( - RoborockSceneEntity( - coordinator, - EntityDescription( - key=str(scene.id), - name=scene.name, - ), - ) - for coordinator, scenes in zip( - config_entry.runtime_data.v1, scene_lists, strict=True - ) - for scene in scenes - ) - - -class RoborockSceneEntity(RoborockEntity, SceneEntity): - """A class to define Roborock scene entities.""" - - entity_description: EntityDescription - - def __init__( - self, - coordinator: RoborockDataUpdateCoordinator, - entity_description: EntityDescription, - ) -> None: - """Create a scene entity.""" - super().__init__( - f"{entity_description.key}_{coordinator.duid_slug}", - coordinator.device_info, - coordinator.api, - ) - self._scene_id = int(entity_description.key) - self._coordinator = coordinator - self.entity_description = entity_description - - async def async_activate(self, **kwargs: Any) -> None: - """Activate the scene.""" - await self._coordinator.execute_scene(self._scene_id) diff --git a/tests/components/roborock/conftest.py b/tests/components/roborock/conftest.py index 9b3a6633c62..43e5148c9a8 100644 --- a/tests/components/roborock/conftest.py +++ b/tests/components/roborock/conftest.py @@ -30,7 +30,6 @@ from .mock_data import ( MULTI_MAP_LIST, NETWORK_INFO, PROP, - SCENES, USER_DATA, USER_EMAIL, ) @@ -68,24 +67,8 @@ class A01Mock(RoborockMqttClientA01): return {prot: self.protocol_responses[prot] for prot in dyad_data_protocols} -@pytest.fixture(name="bypass_api_client_fixture") -def bypass_api_client_fixture() -> None: - """Skip calls to the API client.""" - with ( - patch( - "homeassistant.components.roborock.RoborockApiClient.get_home_data_v2", - return_value=HOME_DATA, - ), - patch( - "homeassistant.components.roborock.RoborockApiClient.get_scenes", - return_value=SCENES, - ), - ): - yield - - @pytest.fixture(name="bypass_api_fixture") -def bypass_api_fixture(bypass_api_client_fixture: Any) -> None: +def bypass_api_fixture() -> None: """Skip calls to the API.""" with ( patch("homeassistant.components.roborock.RoborockMqttClientV1.async_connect"), @@ -93,6 +76,10 @@ def bypass_api_fixture(bypass_api_client_fixture: Any) -> None: patch( "homeassistant.components.roborock.coordinator.RoborockMqttClientV1._send_command" ), + patch( + "homeassistant.components.roborock.RoborockApiClient.get_home_data_v2", + return_value=HOME_DATA, + ), patch( "homeassistant.components.roborock.RoborockMqttClientV1.get_networking", return_value=NETWORK_INFO, diff --git a/tests/components/roborock/mock_data.py b/tests/components/roborock/mock_data.py index 59c54892687..6e3fb229aa9 100644 --- a/tests/components/roborock/mock_data.py +++ b/tests/components/roborock/mock_data.py @@ -9,7 +9,6 @@ from roborock.containers import ( Consumable, DnDTimer, HomeData, - HomeDataScene, MultiMapsList, NetworkInfo, S7Status, @@ -1151,19 +1150,3 @@ MAP_DATA = MapData(0, 0) MAP_DATA.image = ImageData( 100, 10, 10, 10, 10, ImageConfig(), Image.new("RGB", (1, 1)), lambda p: p ) - - -SCENES = [ - HomeDataScene.from_dict( - { - "name": "sc1", - "id": 12, - }, - ), - HomeDataScene.from_dict( - { - "name": "sc2", - "id": 24, - }, - ), -] diff --git a/tests/components/roborock/test_scene.py b/tests/components/roborock/test_scene.py deleted file mode 100644 index 15707784feb..00000000000 --- a/tests/components/roborock/test_scene.py +++ /dev/null @@ -1,112 +0,0 @@ -"""Test Roborock Scene platform.""" - -from unittest.mock import ANY, patch - -import pytest -from roborock import RoborockException - -from homeassistant.const import SERVICE_TURN_ON, Platform -from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError - -from tests.common import MockConfigEntry - - -@pytest.fixture -def bypass_api_client_get_scenes_fixture(bypass_api_fixture) -> None: - """Fixture to raise when getting scenes.""" - with ( - patch( - "homeassistant.components.roborock.RoborockApiClient.get_scenes", - side_effect=RoborockException(), - ), - ): - yield - - -@pytest.mark.parametrize( - ("entity_id"), - [ - ("scene.roborock_s7_maxv_sc1"), - ("scene.roborock_s7_maxv_sc2"), - ], -) -@pytest.mark.usefixtures("entity_registry_enabled_by_default") -async def test_get_scenes_failure( - hass: HomeAssistant, - bypass_api_client_get_scenes_fixture, - setup_entry: MockConfigEntry, - entity_id: str, -) -> None: - """Test that if scene retrieval fails, no entity is being created.""" - # Ensure that the entity does not exist - assert hass.states.get(entity_id) is None - - -@pytest.fixture -def platforms() -> list[Platform]: - """Fixture to set platforms used in the test.""" - return [Platform.SCENE] - - -@pytest.mark.parametrize( - ("entity_id", "scene_id"), - [ - ("scene.roborock_s7_maxv_sc1", 12), - ("scene.roborock_s7_maxv_sc2", 24), - ], -) -@pytest.mark.freeze_time("2023-10-30 08:50:00") -@pytest.mark.usefixtures("entity_registry_enabled_by_default") -async def test_execute_success( - hass: HomeAssistant, - bypass_api_fixture, - setup_entry: MockConfigEntry, - entity_id: str, - scene_id: int, -) -> None: - """Test activating the scene entities.""" - with patch( - "homeassistant.components.roborock.RoborockApiClient.execute_scene" - ) as mock_execute_scene: - await hass.services.async_call( - "scene", - SERVICE_TURN_ON, - blocking=True, - target={"entity_id": entity_id}, - ) - mock_execute_scene.assert_called_once_with(ANY, scene_id) - assert hass.states.get(entity_id).state == "2023-10-30T08:50:00+00:00" - - -@pytest.mark.parametrize( - ("entity_id", "scene_id"), - [ - ("scene.roborock_s7_maxv_sc1", 12), - ], -) -@pytest.mark.freeze_time("2023-10-30 08:50:00") -@pytest.mark.usefixtures("entity_registry_enabled_by_default") -async def test_execute_failure( - hass: HomeAssistant, - bypass_api_fixture, - setup_entry: MockConfigEntry, - entity_id: str, - scene_id: int, -) -> None: - """Test failure while activating the scene entity.""" - with ( - patch( - "homeassistant.components.roborock.RoborockApiClient.execute_scene", - side_effect=RoborockException, - ) as mock_execute_scene, - pytest.raises(HomeAssistantError, match="Error while calling execute_scene"), - ): - await hass.services.async_call( - "scene", - SERVICE_TURN_ON, - blocking=True, - target={"entity_id": entity_id}, - ) - mock_execute_scene.assert_called_once_with(ANY, scene_id) - assert hass.states.get(entity_id).state == "2023-10-30T08:50:00+00:00" From 8056b0df2b6939ec2b47f182569ef15f6d0d60a6 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 5 Mar 2025 17:04:41 +0100 Subject: [PATCH 2249/3148] Bump aioecowitt to 2025.3.1 (#139841) * Bump aioecowitt to 2025.3.1 * Bump aioecowitt to 2025.3.1 --- homeassistant/components/ecowitt/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- script/licenses.py | 1 - 4 files changed, 3 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/ecowitt/manifest.json b/homeassistant/components/ecowitt/manifest.json index 175960ab57d..3ce66f48f95 100644 --- a/homeassistant/components/ecowitt/manifest.json +++ b/homeassistant/components/ecowitt/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["webhook"], "documentation": "https://www.home-assistant.io/integrations/ecowitt", "iot_class": "local_push", - "requirements": ["aioecowitt==2024.2.1"] + "requirements": ["aioecowitt==2025.3.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 19d93b4927b..7172befba9c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -234,7 +234,7 @@ aioeafm==0.1.2 aioeagle==1.1.0 # homeassistant.components.ecowitt -aioecowitt==2024.2.1 +aioecowitt==2025.3.1 # homeassistant.components.co2signal aioelectricitymaps==0.4.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 30754158426..0b99fa05ccf 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -222,7 +222,7 @@ aioeafm==0.1.2 aioeagle==1.1.0 # homeassistant.components.ecowitt -aioecowitt==2024.2.1 +aioecowitt==2025.3.1 # homeassistant.components.co2signal aioelectricitymaps==0.4.0 diff --git a/script/licenses.py b/script/licenses.py index aa15a58f3bd..448e9dd2a67 100644 --- a/script/licenses.py +++ b/script/licenses.py @@ -180,7 +180,6 @@ EXCEPTIONS = { "PyMicroBot", # https://github.com/spycle/pyMicroBot/pull/3 "PySwitchmate", # https://github.com/Danielhiversen/pySwitchmate/pull/16 "PyXiaomiGateway", # https://github.com/Danielhiversen/PyXiaomiGateway/pull/201 - "aioecowitt", # https://github.com/home-assistant-libs/aioecowitt/pull/180 "chacha20poly1305", # LGPL "commentjson", # https://github.com/vaidik/commentjson/pull/55 "crownstone-cloud", # https://github.com/crownstone/crownstone-lib-python-cloud/pull/5 From 6c080ee650d4ef19ccef227b964ba453be6ec1d9 Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Wed, 5 Mar 2025 16:19:15 +0100 Subject: [PATCH 2250/3148] Bump onedrive-personal-sdk to 0.0.13 (#139846) --- homeassistant/components/onedrive/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/onedrive/manifest.json b/homeassistant/components/onedrive/manifest.json index 31a1f2ccb06..c3d98200b03 100644 --- a/homeassistant/components/onedrive/manifest.json +++ b/homeassistant/components/onedrive/manifest.json @@ -9,5 +9,5 @@ "iot_class": "cloud_polling", "loggers": ["onedrive_personal_sdk"], "quality_scale": "platinum", - "requirements": ["onedrive-personal-sdk==0.0.12"] + "requirements": ["onedrive-personal-sdk==0.0.13"] } diff --git a/requirements_all.txt b/requirements_all.txt index 7172befba9c..58e717d79c6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1565,7 +1565,7 @@ omnilogic==0.4.5 ondilo==0.5.0 # homeassistant.components.onedrive -onedrive-personal-sdk==0.0.12 +onedrive-personal-sdk==0.0.13 # homeassistant.components.onvif onvif-zeep-async==3.2.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0b99fa05ccf..73d5d27503a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1313,7 +1313,7 @@ omnilogic==0.4.5 ondilo==0.5.0 # homeassistant.components.onedrive -onedrive-personal-sdk==0.0.12 +onedrive-personal-sdk==0.0.13 # homeassistant.components.onvif onvif-zeep-async==3.2.5 From b88eab8ba35daeb899e11aa3240c7f3290bcd98c Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Wed, 5 Mar 2025 11:20:48 -0600 Subject: [PATCH 2251/3148] Bump intents to 2025.3.5 (#139851) Co-authored-by: Franck Nijhof --- homeassistant/components/conversation/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- script/hassfest/docker/Dockerfile | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/conversation/manifest.json b/homeassistant/components/conversation/manifest.json index c4f1860eed6..ea950ace323 100644 --- a/homeassistant/components/conversation/manifest.json +++ b/homeassistant/components/conversation/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/conversation", "integration_type": "system", "quality_scale": "internal", - "requirements": ["hassil==2.2.3", "home-assistant-intents==2025.2.26"] + "requirements": ["hassil==2.2.3", "home-assistant-intents==2025.3.5"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 790180691c0..f74bc88bc56 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -38,7 +38,7 @@ hass-nabucasa==0.92.0 hassil==2.2.3 home-assistant-bluetooth==1.13.1 home-assistant-frontend==20250305.0 -home-assistant-intents==2025.2.26 +home-assistant-intents==2025.3.5 httpx==0.28.1 ifaddr==0.2.0 Jinja2==3.1.5 diff --git a/requirements_all.txt b/requirements_all.txt index 58e717d79c6..c0cea94142b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1155,7 +1155,7 @@ holidays==0.68 home-assistant-frontend==20250305.0 # homeassistant.components.conversation -home-assistant-intents==2025.2.26 +home-assistant-intents==2025.3.5 # homeassistant.components.homematicip_cloud homematicip==1.1.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 73d5d27503a..82e49f43bda 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -984,7 +984,7 @@ holidays==0.68 home-assistant-frontend==20250305.0 # homeassistant.components.conversation -home-assistant-intents==2025.2.26 +home-assistant-intents==2025.3.5 # homeassistant.components.homematicip_cloud homematicip==1.1.7 diff --git a/script/hassfest/docker/Dockerfile b/script/hassfest/docker/Dockerfile index 1f177643bd5..37de7857915 100644 --- a/script/hassfest/docker/Dockerfile +++ b/script/hassfest/docker/Dockerfile @@ -25,7 +25,7 @@ RUN --mount=from=ghcr.io/astral-sh/uv:0.6.1,source=/uv,target=/bin/uv \ -c /usr/src/homeassistant/homeassistant/package_constraints.txt \ -r /usr/src/homeassistant/requirements.txt \ stdlib-list==0.10.0 pipdeptree==2.25.0 tqdm==4.67.1 ruff==0.9.7 \ - PyTurboJPEG==1.7.5 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 hassil==2.2.3 home-assistant-intents==2025.2.26 mutagen==1.47.0 pymicro-vad==1.0.1 pyspeex-noise==1.0.2 + PyTurboJPEG==1.7.5 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 hassil==2.2.3 home-assistant-intents==2025.3.5 mutagen==1.47.0 pymicro-vad==1.0.1 pyspeex-noise==1.0.2 LABEL "name"="hassfest" LABEL "maintainer"="Home Assistant " From 51162320cbf65f7d9e75fda940d81aa4da9c963c Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 5 Mar 2025 17:25:33 +0000 Subject: [PATCH 2252/3148] Bump version to 2025.3.0b8 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index b861e9e7170..da281567f85 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -25,7 +25,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2025 MINOR_VERSION: Final = 3 -PATCH_VERSION: Final = "0b7" +PATCH_VERSION: Final = "0b8" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 13, 0) diff --git a/pyproject.toml b/pyproject.toml index 38a144806a3..86e700a46ce 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.3.0b7" +version = "2025.3.0b8" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From ed088aa72fad267f0a68f9a6db862a1244c1841a Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 5 Mar 2025 17:39:36 +0000 Subject: [PATCH 2253/3148] Bump version to 2025.3.0 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index da281567f85..da2c3268642 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -25,7 +25,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2025 MINOR_VERSION: Final = 3 -PATCH_VERSION: Final = "0b8" +PATCH_VERSION: Final = "0" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 13, 0) diff --git a/pyproject.toml b/pyproject.toml index 86e700a46ce..3f80f7c8ead 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.3.0b8" +version = "2025.3.0" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 1f24e5aec4821da7aac9cd276fb0d0c41371f01e Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 5 Mar 2025 18:41:21 +0100 Subject: [PATCH 2254/3148] Fix no disabled capabilities in SmartThings (#139860) Fix no disabled capabilities --- homeassistant/components/smartthings/__init__.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py index 969df42bed9..9e2178196d5 100644 --- a/homeassistant/components/smartthings/__init__.py +++ b/homeassistant/components/smartthings/__init__.py @@ -199,11 +199,12 @@ def process_status( list[Capability | str], disabled_capabilities_capability[Attribute.DISABLED_CAPABILITIES].value, ) - for capability in disabled_capabilities: - # We still need to make sure the climate entity can work without this capability - if ( - capability in main_component - and capability != Capability.DEMAND_RESPONSE_LOAD_CONTROL - ): - del main_component[capability] + if disabled_capabilities is not None: + for capability in disabled_capabilities: + # We still need to make sure the climate entity can work without this capability + if ( + capability in main_component + and capability != Capability.DEMAND_RESPONSE_LOAD_CONTROL + ): + del main_component[capability] return status From 98e317dd5560e1168f0db5b0b1ec6331ef903bad Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 5 Mar 2025 18:41:21 +0100 Subject: [PATCH 2255/3148] Fix no disabled capabilities in SmartThings (#139860) Fix no disabled capabilities --- homeassistant/components/smartthings/__init__.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py index d6de1d3d252..f7f3d628c20 100644 --- a/homeassistant/components/smartthings/__init__.py +++ b/homeassistant/components/smartthings/__init__.py @@ -174,11 +174,12 @@ def process_status( list[Capability | str], disabled_capabilities_capability[Attribute.DISABLED_CAPABILITIES].value, ) - for capability in disabled_capabilities: - # We still need to make sure the climate entity can work without this capability - if ( - capability in main_component - and capability != Capability.DEMAND_RESPONSE_LOAD_CONTROL - ): - del main_component[capability] + if disabled_capabilities is not None: + for capability in disabled_capabilities: + # We still need to make sure the climate entity can work without this capability + if ( + capability in main_component + and capability != Capability.DEMAND_RESPONSE_LOAD_CONTROL + ): + del main_component[capability] return status From cfaf18f942191745c7ef731c5e57aad07f322df8 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 5 Mar 2025 18:42:34 +0100 Subject: [PATCH 2256/3148] Improve the mock_integration_frame test fixture (#139850) * Improve the mock_integration_frame test fixture * Update test --- tests/conftest.py | 4 ++++ tests/helpers/snapshots/test_frame.ambr | 16 ++++++++-------- tests/helpers/test_frame.py | 2 +- tests/test_config_entries.py | 3 ++- 4 files changed, 15 insertions(+), 10 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 2f7330ebf22..dc834633774 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -16,6 +16,7 @@ import reprlib from shutil import rmtree import sqlite3 import ssl +import sys import threading from typing import TYPE_CHECKING, Any, cast from unittest.mock import AsyncMock, MagicMock, Mock, _patch, patch @@ -1889,12 +1890,15 @@ def mock_integration_frame(integration_frame_path: str) -> Generator[Mock]: Defaults to calling from `hue` core integration, and can be parametrized with `integration_frame_path`. """ + correct_filename = f"/home/paulus/{integration_frame_path}/light.py" + correct_module_name = f"{integration_frame_path.replace('/', '.')}.light" correct_frame = Mock( filename=f"/home/paulus/{integration_frame_path}/light.py", lineno="23", line="self.light.is_on", ) with ( + patch.dict(sys.modules, {correct_module_name: Mock(__file__=correct_filename)}), patch( "homeassistant.helpers.frame.linecache.getline", return_value=correct_frame.line, diff --git a/tests/helpers/snapshots/test_frame.ambr b/tests/helpers/snapshots/test_frame.ambr index f3fbd54cf45..996fd33ada4 100644 --- a/tests/helpers/snapshots/test_frame.ambr +++ b/tests/helpers/snapshots/test_frame.ambr @@ -10,7 +10,7 @@ # --- # name: test_report[custom integration default] list([ - "Detected that custom integration 'test_custom_integration' test_report_string at custom_components/test_custom_integration/light.py, line 23: self.light.is_on. Please create a bug report at https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue+label%3A%22integration%3A+test_custom_integration%22", + "Detected that custom integration 'test_custom_integration' test_report_string at custom_components/test_custom_integration/light.py, line 23: self.light.is_on. Please report it to the author of the 'test_custom_integration' custom integration", ]) # --- # name: test_report[disable error_if_core] @@ -25,7 +25,7 @@ # --- # name: test_report[error_if_integration with custom integration] list([ - "Detected that custom integration 'test_integration_frame' test_report_string at custom_components/test_integration_frame/light.py, line 23: self.light.is_on. Please create a bug report at https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue+label%3A%22integration%3A+test_integration_frame%22", + "Detected that custom integration 'test_integration_frame' test_report_string at custom_components/test_integration_frame/light.py, line 23: self.light.is_on. Please report it to the author of the 'test_integration_frame' custom integration", ]) # --- # name: test_report[log_custom_component_only with core integration] @@ -34,7 +34,7 @@ # --- # name: test_report[log_custom_component_only with custom integration] list([ - "Detected that custom integration 'test_integration_frame' test_report_string at custom_components/test_integration_frame/light.py, line 23: self.light.is_on. Please create a bug report at https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue+label%3A%22integration%3A+test_integration_frame%22", + "Detected that custom integration 'test_integration_frame' test_report_string at custom_components/test_integration_frame/light.py, line 23: self.light.is_on. Please report it to the author of the 'test_integration_frame' custom integration", ]) # --- # name: test_report_usage[core default] @@ -66,12 +66,12 @@ # --- # name: test_report_usage[custom integration default] list([ - "Detected that custom integration 'test_custom_integration' test_report_string at custom_components/test_custom_integration/light.py, line 23: self.light.is_on. Please create a bug report at https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue+label%3A%22integration%3A+test_custom_integration%22", + "Detected that custom integration 'test_custom_integration' test_report_string at custom_components/test_custom_integration/light.py, line 23: self.light.is_on. Please report it to the author of the 'test_custom_integration' custom integration", ]) # --- # name: test_report_usage[custom integration error] list([ - "Detected that custom integration 'test_custom_integration' test_report_string at custom_components/test_custom_integration/light.py, line 23: self.light.is_on. Please create a bug report at https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue+label%3A%22integration%3A+test_custom_integration%22", + "Detected that custom integration 'test_custom_integration' test_report_string at custom_components/test_custom_integration/light.py, line 23: self.light.is_on. Please report it to the author of the 'test_custom_integration' custom integration", ]) # --- # name: test_report_usage[custom integration ignore] @@ -95,7 +95,7 @@ # --- # name: test_report_usage_find_issue_tracker[unknown custom integration] list([ - "Detected that custom integration 'unknown_custom_integration' test_report_string at custom_components/unknown_custom_integration/light.py, line 23: self.light.is_on. Please create a bug report at https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue+label%3A%22integration%3A+unknown_custom_integration%22", + "Detected that custom integration 'unknown_custom_integration' test_report_string at custom_components/unknown_custom_integration/light.py, line 23: self.light.is_on. Please report it to the author of the 'unknown_custom_integration' custom integration", ]) # --- # name: test_report_usage_find_issue_tracker_other_thread[core integration] @@ -110,11 +110,11 @@ # --- # name: test_report_usage_find_issue_tracker_other_thread[custom integration] list([ - "Detected that custom integration 'test_custom_integration' test_report_string at custom_components/test_custom_integration/light.py, line 23: self.light.is_on. Please create a bug report at https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue+label%3A%22integration%3A+test_custom_integration%22", + "Detected that custom integration 'test_custom_integration' test_report_string at custom_components/test_custom_integration/light.py, line 23: self.light.is_on. Please report it to the author of the 'test_custom_integration' custom integration", ]) # --- # name: test_report_usage_find_issue_tracker_other_thread[unknown custom integration] list([ - "Detected that custom integration 'unknown_custom_integration' test_report_string at custom_components/unknown_custom_integration/light.py, line 23: self.light.is_on. Please create a bug report at https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue+label%3A%22integration%3A+unknown_custom_integration%22", + "Detected that custom integration 'unknown_custom_integration' test_report_string at custom_components/unknown_custom_integration/light.py, line 23: self.light.is_on. Please report it to the author of the 'unknown_custom_integration' custom integration", ]) # --- diff --git a/tests/helpers/test_frame.py b/tests/helpers/test_frame.py index 22209380dfe..6d53088d9df 100644 --- a/tests/helpers/test_frame.py +++ b/tests/helpers/test_frame.py @@ -23,7 +23,7 @@ async def test_extract_frame_integration( custom_integration=False, frame=mock_integration_frame, integration="hue", - module=None, + module="homeassistant.components.hue.light", relative_filename="homeassistant/components/hue/light.py", ) diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index 66aa29d95d1..857c5952df9 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -8849,7 +8849,8 @@ async def test_options_flow_deprecated_config_entry_setter( "config_entry explicitly, which is deprecated at " "custom_components/my_integration/light.py, line 23: " "self.light.is_on. This will stop working in Home Assistant 2025.12, please " - "create a bug report at " in caplog.text + "report it to the author of the 'my_integration' custom integration" + in caplog.text ) From cc5c8bf5e3bc5d6fca9598a7324d003e0c49c74e Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 5 Mar 2025 19:37:34 +0100 Subject: [PATCH 2257/3148] Make helpers.frame.report_usage work when called from any thread (#139836) * Make helpers.frame.report_usage work when called from any thread * Address review comments, update tests * Add test * Update test * Update recorder test * Update tests --- homeassistant/bootstrap.py | 4 +- homeassistant/helpers/frame.py | 64 +++++++++++++++++-- tests/components/history_stats/test_sensor.py | 1 + tests/components/recorder/test_pool.py | 17 ++++- tests/conftest.py | 3 + tests/helpers/snapshots/test_frame.ambr | 2 +- tests/helpers/test_condition.py | 2 + tests/helpers/test_config_validation.py | 4 ++ tests/helpers/test_frame.py | 27 +++++--- tests/helpers/test_selector.py | 1 + tests/helpers/test_template.py | 2 + tests/test_config_entries.py | 4 +- 12 files changed, 113 insertions(+), 18 deletions(-) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index cf8e5e1ea09..734439842b2 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -81,6 +81,7 @@ from .helpers import ( entity, entity_registry, floor_registry, + frame, issue_registry, label_registry, recorder, @@ -441,9 +442,10 @@ async def async_load_base_functionality(hass: core.HomeAssistant) -> None: if DATA_REGISTRIES_LOADED in hass.data: return hass.data[DATA_REGISTRIES_LOADED] = None - translation.async_setup(hass) entity.async_setup(hass) + frame.async_setup(hass) template.async_setup(hass) + translation.async_setup(hass) await asyncio.gather( create_eager_task(get_internal_store_manager(hass).async_initialize()), create_eager_task(area_registry.async_load(hass)), diff --git a/homeassistant/helpers/frame.py b/homeassistant/helpers/frame.py index f33f8407e47..3416c8d49f6 100644 --- a/homeassistant/helpers/frame.py +++ b/homeassistant/helpers/frame.py @@ -10,18 +10,20 @@ import functools import linecache import logging import sys +import threading from types import FrameType from typing import Any, cast from propcache.api import cached_property -from homeassistant.core import HomeAssistant, async_get_hass_or_none +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.loader import ( Integration, async_get_issue_integration, async_suggest_report_issue, ) +from homeassistant.util.async_ import run_callback_threadsafe _LOGGER = logging.getLogger(__name__) @@ -29,6 +31,21 @@ _LOGGER = logging.getLogger(__name__) _REPORTED_INTEGRATIONS: set[str] = set() +class _Hass: + """Container which makes a HomeAssistant instance available to frame helper.""" + + hass: HomeAssistant | None = None + + +_hass = _Hass() + + +@callback +def async_setup(hass: HomeAssistant) -> None: + """Set up the frame helper.""" + _hass.hass = hass + + @dataclass(kw_only=True) class IntegrationFrame: """Integration frame container.""" @@ -204,14 +221,49 @@ def report_usage( :param integration_domain: fallback for identifying the integration if the frame is not found """ + if (hass := _hass.hass) is None: + raise RuntimeError("Frame helper not set up") + _report_usage_partial = functools.partial( + _report_usage, + hass, + what, + breaks_in_ha_version=breaks_in_ha_version, + core_behavior=core_behavior, + core_integration_behavior=core_integration_behavior, + custom_integration_behavior=custom_integration_behavior, + exclude_integrations=exclude_integrations, + integration_domain=integration_domain, + level=level, + ) + if hass.loop_thread_id != threading.get_ident(): + future = run_callback_threadsafe(hass.loop, _report_usage_partial) + future.result() + return + _report_usage_partial() + + +def _report_usage( + hass: HomeAssistant, + what: str, + *, + breaks_in_ha_version: str | None, + core_behavior: ReportBehavior, + core_integration_behavior: ReportBehavior, + custom_integration_behavior: ReportBehavior, + exclude_integrations: set[str] | None, + integration_domain: str | None, + level: int, +) -> None: + """Report incorrect code usage. + + Must be called from the event loop. + """ try: integration_frame = get_integration_frame( exclude_integrations=exclude_integrations ) except MissingIntegrationFrame as err: - if integration := async_get_issue_integration( - hass := async_get_hass_or_none(), integration_domain - ): + if integration := async_get_issue_integration(hass, integration_domain): _report_integration_domain( hass, what, @@ -240,6 +292,7 @@ def report_usage( if integration_behavior is not ReportBehavior.IGNORE: _report_integration_frame( + hass, what, breaks_in_ha_version, integration_frame, @@ -299,6 +352,7 @@ def _report_integration_domain( def _report_integration_frame( + hass: HomeAssistant, what: str, breaks_in_ha_version: str | None, integration_frame: IntegrationFrame, @@ -316,7 +370,7 @@ def _report_integration_frame( _REPORTED_INTEGRATIONS.add(key) report_issue = async_suggest_report_issue( - async_get_hass_or_none(), + hass, integration_domain=integration_frame.integration, module=integration_frame.module, ) diff --git a/tests/components/history_stats/test_sensor.py b/tests/components/history_stats/test_sensor.py index 721e540b04d..e2dba1b9355 100644 --- a/tests/components/history_stats/test_sensor.py +++ b/tests/components/history_stats/test_sensor.py @@ -122,6 +122,7 @@ async def test_setup_multiple_states( }, ], ) +@pytest.mark.usefixtures("hass") def test_setup_invalid_config(config) -> None: """Test the history statistics sensor setup with invalid config.""" diff --git a/tests/components/recorder/test_pool.py b/tests/components/recorder/test_pool.py index 3cca095399b..e391161c1ec 100644 --- a/tests/components/recorder/test_pool.py +++ b/tests/components/recorder/test_pool.py @@ -1,5 +1,6 @@ """Test pool.""" +import asyncio import threading import pytest @@ -8,6 +9,7 @@ from sqlalchemy.orm import sessionmaker from homeassistant.components.recorder.const import DB_WORKER_PREFIX from homeassistant.components.recorder.pool import RecorderPool +from homeassistant.core import HomeAssistant async def test_recorder_pool_called_from_event_loop() -> None: @@ -22,7 +24,9 @@ async def test_recorder_pool_called_from_event_loop() -> None: sessionmaker(bind=engine)().connection() -def test_recorder_pool(caplog: pytest.LogCaptureFixture) -> None: +async def test_recorder_pool( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: """Test RecorderPool gives the same connection in the creating thread.""" recorder_and_worker_thread_ids: set[int] = set() engine = create_engine( @@ -35,6 +39,8 @@ def test_recorder_pool(caplog: pytest.LogCaptureFixture) -> None: connections = [] add_thread = False + event = asyncio.Event() + def _get_connection_twice(): if add_thread: recorder_and_worker_thread_ids.add(threading.get_ident()) @@ -48,33 +54,42 @@ def test_recorder_pool(caplog: pytest.LogCaptureFixture) -> None: session = get_session() connections.append(session.connection().connection.driver_connection) session.close() + hass.loop.call_soon_threadsafe(event.set) caplog.clear() + event.clear() new_thread = threading.Thread(target=_get_connection_twice) new_thread.start() + await event.wait() new_thread.join() assert "accesses the database without the database executor" in caplog.text assert connections[0] != connections[1] add_thread = True caplog.clear() + event.clear() new_thread = threading.Thread(target=_get_connection_twice, name=DB_WORKER_PREFIX) new_thread.start() + await event.wait() new_thread.join() assert "accesses the database without the database executor" not in caplog.text assert connections[2] == connections[3] caplog.clear() + event.clear() new_thread = threading.Thread(target=_get_connection_twice, name="Recorder") new_thread.start() + await event.wait() new_thread.join() assert "accesses the database without the database executor" not in caplog.text assert connections[4] == connections[5] shutdown = True caplog.clear() + event.clear() new_thread = threading.Thread(target=_get_connection_twice, name=DB_WORKER_PREFIX) new_thread.start() + await event.wait() new_thread.join() assert "accesses the database without the database executor" not in caplog.text assert connections[6] != connections[7] diff --git a/tests/conftest.py b/tests/conftest.py index dc834633774..e3313813112 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -84,6 +84,7 @@ from homeassistant.helpers import ( device_registry as dr, entity_registry as er, floor_registry as fr, + frame, issue_registry as ir, label_registry as lr, recorder as recorder_helper, @@ -433,6 +434,7 @@ def reset_hass_threading_local_object() -> Generator[None]: """Reset the _Hass threading.local object for every test case.""" yield ha._hass.__dict__.clear() + frame.async_setup(None) @pytest.fixture(autouse=True, scope="session") @@ -599,6 +601,7 @@ async def hass( async with async_test_home_assistant(loop, load_registries) as hass: orig_exception_handler = loop.get_exception_handler() loop.set_exception_handler(exc_handle) + frame.async_setup(hass) yield hass diff --git a/tests/helpers/snapshots/test_frame.ambr b/tests/helpers/snapshots/test_frame.ambr index 996fd33ada4..abdaff6c1b7 100644 --- a/tests/helpers/snapshots/test_frame.ambr +++ b/tests/helpers/snapshots/test_frame.ambr @@ -110,7 +110,7 @@ # --- # name: test_report_usage_find_issue_tracker_other_thread[custom integration] list([ - "Detected that custom integration 'test_custom_integration' test_report_string at custom_components/test_custom_integration/light.py, line 23: self.light.is_on. Please report it to the author of the 'test_custom_integration' custom integration", + "Detected that custom integration 'test_custom_integration' test_report_string at custom_components/test_custom_integration/light.py, line 23: self.light.is_on. Please create a bug report at https://blablabla.com", ]) # --- # name: test_report_usage_find_issue_tracker_other_thread[unknown custom integration] diff --git a/tests/helpers/test_condition.py b/tests/helpers/test_condition.py index b8c8c8a18c8..aac64f6139a 100644 --- a/tests/helpers/test_condition.py +++ b/tests/helpers/test_condition.py @@ -2080,6 +2080,7 @@ async def test_multiple_zones(hass: HomeAssistant) -> None: assert not test(hass) +@pytest.mark.usefixtures("hass") async def test_extract_entities() -> None: """Test extracting entities.""" assert condition.async_extract_entities( @@ -2153,6 +2154,7 @@ async def test_extract_entities() -> None: } +@pytest.mark.usefixtures("hass") async def test_extract_devices() -> None: """Test extracting devices.""" assert condition.async_extract_devices( diff --git a/tests/helpers/test_config_validation.py b/tests/helpers/test_config_validation.py index 7202cef6f5f..c72295493e8 100644 --- a/tests/helpers/test_config_validation.py +++ b/tests/helpers/test_config_validation.py @@ -773,6 +773,7 @@ async def test_dynamic_template_no_hass(hass: HomeAssistant) -> None: await hass.async_add_executor_job(schema, value) +@pytest.mark.usefixtures("hass") def test_template_complex() -> None: """Test template_complex validator.""" schema = vol.Schema(cv.template_complex) @@ -1414,6 +1415,7 @@ def test_key_value_schemas() -> None: schema({"mode": mode, "data": data}) +@pytest.mark.usefixtures("hass") def test_key_value_schemas_with_default() -> None: """Test key value schemas.""" schema = vol.Schema( @@ -1492,6 +1494,7 @@ def test_key_value_schemas_with_default() -> None: ), ], ) +@pytest.mark.usefixtures("hass") def test_script(caplog: pytest.LogCaptureFixture, config: dict, error: str) -> None: """Test script validation is user friendly.""" with pytest.raises(vol.Invalid, match=error): @@ -1570,6 +1573,7 @@ def test_language() -> None: assert schema(value) +@pytest.mark.usefixtures("hass") def test_positive_time_period_template() -> None: """Test positive time period template validation.""" schema = vol.Schema(cv.positive_time_period_template) diff --git a/tests/helpers/test_frame.py b/tests/helpers/test_frame.py index 6d53088d9df..9bec7cce996 100644 --- a/tests/helpers/test_frame.py +++ b/tests/helpers/test_frame.py @@ -36,8 +36,8 @@ async def test_get_integration_logger( assert logger.name == "homeassistant.components.hue" -@pytest.mark.usefixtures("enable_custom_integrations") -async def test_extract_frame_resolve_module(hass: HomeAssistant) -> None: +@pytest.mark.usefixtures("enable_custom_integrations", "hass") +async def test_extract_frame_resolve_module() -> None: """Test extracting the current frame from integration context.""" # pylint: disable-next=import-outside-toplevel from custom_components.test_integration_frame import call_get_integration_frame @@ -53,8 +53,8 @@ async def test_extract_frame_resolve_module(hass: HomeAssistant) -> None: ) -@pytest.mark.usefixtures("enable_custom_integrations") -async def test_get_integration_logger_resolve_module(hass: HomeAssistant) -> None: +@pytest.mark.usefixtures("enable_custom_integrations", "hass") +async def test_get_integration_logger_resolve_module() -> None: """Test getting the logger from integration context.""" # pylint: disable-next=import-outside-toplevel from custom_components.test_integration_frame import call_get_integration_logger @@ -228,7 +228,7 @@ async def test_get_integration_logger_no_integration( ), ], ) -@pytest.mark.usefixtures("mock_integration_frame") +@pytest.mark.usefixtures("hass", "mock_integration_frame") async def test_report_usage( caplog: pytest.LogCaptureFixture, snapshot: SnapshotAssertion, @@ -254,6 +254,13 @@ async def test_report_usage( assert reports == snapshot +async def test_report_usage_no_hass() -> None: + """Test report_usage when frame helper is not set up.""" + + with pytest.raises(RuntimeError, match="Frame helper not set up"): + frame.report_usage("blablabla") + + @pytest.mark.parametrize( "integration_frame_path", [ @@ -370,8 +377,9 @@ async def test_report_usage_find_issue_tracker_other_thread( @patch.object(frame, "_REPORTED_INTEGRATIONS", set()) +@pytest.mark.usefixtures("hass", "mock_integration_frame") async def test_prevent_flooding( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture, mock_integration_frame: Mock + caplog: pytest.LogCaptureFixture, mock_integration_frame: Mock ) -> None: """Test to ensure a report is only written once to the log.""" @@ -401,8 +409,9 @@ async def test_prevent_flooding( @patch.object(frame, "_REPORTED_INTEGRATIONS", set()) +@pytest.mark.usefixtures("hass", "mock_integration_frame") async def test_breaks_in_ha_version( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture, mock_integration_frame: Mock + caplog: pytest.LogCaptureFixture, mock_integration_frame: Mock ) -> None: """Test to ensure a report is only written once to the log.""" @@ -422,6 +431,7 @@ async def test_breaks_in_ha_version( assert expected_message in caplog.text +@pytest.mark.usefixtures("hass") async def test_report_missing_integration_frame( caplog: pytest.LogCaptureFixture, ) -> None: @@ -445,6 +455,7 @@ async def test_report_missing_integration_frame( @pytest.mark.parametrize("run_count", [1, 2]) # Run this twice to make sure the flood check does not # kick in when error_if_integration=True +@pytest.mark.usefixtures("hass") async def test_report_error_if_integration( caplog: pytest.LogCaptureFixture, run_count: int ) -> None: @@ -545,7 +556,7 @@ async def test_report_error_if_integration( ), ], ) -@pytest.mark.usefixtures("mock_integration_frame") +@pytest.mark.usefixtures("hass", "mock_integration_frame") async def test_report( caplog: pytest.LogCaptureFixture, snapshot: SnapshotAssertion, diff --git a/tests/helpers/test_selector.py b/tests/helpers/test_selector.py index a977a70973d..3ddbecaf48d 100644 --- a/tests/helpers/test_selector.py +++ b/tests/helpers/test_selector.py @@ -980,6 +980,7 @@ def test_datetime_selector_schema(schema, valid_selections, invalid_selections) ("schema", "valid_selections", "invalid_selections"), [({}, ("abc123", "{{ now() }}"), (None, "{{ incomplete }", "{% if True %}Hi!"))], ) +@pytest.mark.usefixtures("hass") def test_template_selector_schema(schema, valid_selections, invalid_selections) -> None: """Test template selector.""" _test_selector("template", schema, valid_selections, invalid_selections) diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index 016aedb2f99..8c890bfd53d 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -149,6 +149,7 @@ async def test_template_render_info_collision(hass: HomeAssistant) -> None: template_obj.async_render_to_info() +@pytest.mark.usefixtures("hass") def test_template_equality() -> None: """Test template comparison and hashing.""" template_one = template.Template("{{ template_one }}") @@ -5166,6 +5167,7 @@ def test_iif(hass: HomeAssistant) -> None: assert tpl.async_render() == "no" +@pytest.mark.usefixtures("hass") async def test_cache_garbage_collection() -> None: """Test caching a template.""" template_string = ( diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index 857c5952df9..190453afe06 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -5995,7 +5995,7 @@ async def test_async_wait_component_startup(hass: HomeAssistant) -> None: "integration_frame_path", ["homeassistant/components/my_integration", "homeassistant.core"], ) -@pytest.mark.usefixtures("mock_integration_frame") +@pytest.mark.usefixtures("hass", "mock_integration_frame") async def test_options_flow_with_config_entry_core() -> None: """Test that OptionsFlowWithConfigEntry cannot be used in core.""" entry = MockConfigEntry( @@ -6009,7 +6009,7 @@ async def test_options_flow_with_config_entry_core() -> None: @pytest.mark.parametrize("integration_frame_path", ["custom_components/my_integration"]) -@pytest.mark.usefixtures("mock_integration_frame") +@pytest.mark.usefixtures("hass", "mock_integration_frame") @patch.object(frame, "_REPORTED_INTEGRATIONS", set()) async def test_options_flow_with_config_entry(caplog: pytest.LogCaptureFixture) -> None: """Test that OptionsFlowWithConfigEntry doesn't mutate entry options.""" From cc308237260b52fee4d1bd870c1d4e90d2ac49f4 Mon Sep 17 00:00:00 2001 From: pglab-electronics <89299919+pglab-electronics@users.noreply.github.com> Date: Wed, 5 Mar 2025 20:33:59 +0100 Subject: [PATCH 2258/3148] Reimplement PGLab sensor to use a coordinator (#139789) * Reimplement PGLab sensor to use a coordinator * fix spelling mistake on coordinator name * rename createDiscoverDeviceInfo function in snake_case * adding suffix pglab_ to PGLabBaseEntity/PGLabEntity constructor parameters * Fix docs of PGLabEntity::async_added_to_hass * make coordinator able to return the sensor native value * renaming PGLABConfigEntry in PGLabConfigEntry to be consistent with the integration naming * renamed entry function arguments to config_entry to be less confusing * pass config_entry to constructor of base class of PGLabSensorsCoordinator * set the return value type of get_sensor_value * store coordinator as regular instance attribute * Avoid to access directly entity from discovery module * Rearrange get_sensor_value return types --- homeassistant/components/pglab/__init__.py | 18 ++-- homeassistant/components/pglab/coordinator.py | 78 ++++++++++++++ .../components/pglab/device_sensor.py | 56 ---------- homeassistant/components/pglab/discovery.py | 58 +++++----- homeassistant/components/pglab/entity.py | 100 ++++++++++++------ homeassistant/components/pglab/sensor.py | 70 ++++++------ homeassistant/components/pglab/switch.py | 10 +- .../pglab/snapshots/test_sensor.ambr | 6 +- 8 files changed, 228 insertions(+), 168 deletions(-) create mode 100644 homeassistant/components/pglab/coordinator.py delete mode 100644 homeassistant/components/pglab/device_sensor.py diff --git a/homeassistant/components/pglab/__init__.py b/homeassistant/components/pglab/__init__.py index 7307ac2f801..8bce7be26e8 100644 --- a/homeassistant/components/pglab/__init__.py +++ b/homeassistant/components/pglab/__init__.py @@ -23,12 +23,14 @@ from homeassistant.helpers import config_validation as cv from .const import DOMAIN, LOGGER from .discovery import PGLabDiscovery -type PGLABConfigEntry = ConfigEntry[PGLabDiscovery] +type PGLabConfigEntry = ConfigEntry[PGLabDiscovery] CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) -async def async_setup_entry(hass: HomeAssistant, entry: PGLABConfigEntry) -> bool: +async def async_setup_entry( + hass: HomeAssistant, config_entry: PGLabConfigEntry +) -> bool: """Set up PG LAB Electronics integration from a config entry.""" async def mqtt_publish(topic: str, payload: str, qos: int, retain: bool) -> None: @@ -67,19 +69,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: PGLABConfigEntry) -> boo pglab_mqtt = PyPGLabMqttClient(mqtt_publish, mqtt_subscribe, mqtt_unsubscribe) # Setup PGLab device discovery. - entry.runtime_data = PGLabDiscovery() + config_entry.runtime_data = PGLabDiscovery() # Start to discovery PG Lab devices. - await entry.runtime_data.start(hass, pglab_mqtt, entry) + await config_entry.runtime_data.start(hass, pglab_mqtt, config_entry) return True -async def async_unload_entry(hass: HomeAssistant, entry: PGLABConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, config_entry: PGLabConfigEntry +) -> bool: """Unload a config entry.""" # Stop PGLab device discovery. - pglab_discovery = entry.runtime_data - await pglab_discovery.stop(hass, entry) + pglab_discovery = config_entry.runtime_data + await pglab_discovery.stop(hass, config_entry) return True diff --git a/homeassistant/components/pglab/coordinator.py b/homeassistant/components/pglab/coordinator.py new file mode 100644 index 00000000000..53c5dbc3b58 --- /dev/null +++ b/homeassistant/components/pglab/coordinator.py @@ -0,0 +1,78 @@ +"""Coordinator for PG LAB Electronics.""" + +from __future__ import annotations + +from datetime import datetime, timedelta +from typing import TYPE_CHECKING, Any + +from pypglab.const import SENSOR_REBOOT_TIME, SENSOR_TEMPERATURE, SENSOR_VOLTAGE +from pypglab.device import Device as PyPGLabDevice +from pypglab.sensor import Sensor as PyPGLabSensors + +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from homeassistant.util.dt import utcnow + +from .const import DOMAIN, LOGGER + +if TYPE_CHECKING: + from . import PGLabConfigEntry + + +class PGLabSensorsCoordinator(DataUpdateCoordinator[dict[str, Any]]): + """Class to update Sensor Entities when receiving new data.""" + + def __init__( + self, + hass: HomeAssistant, + config_entry: PGLabConfigEntry, + pglab_device: PyPGLabDevice, + ) -> None: + """Initialize.""" + + # get a reference of PG Lab device internal sensors state + self._sensors: PyPGLabSensors = pglab_device.sensors + + super().__init__( + hass, + LOGGER, + config_entry=config_entry, + name=DOMAIN, + ) + + @callback + def _new_sensors_data(self, payload: str) -> None: + """Handle new sensor data.""" + + # notify all listeners that new sensor values are available + self.async_set_updated_data(self._sensors.state) + + async def subscribe_topics(self) -> None: + """Subscribe the sensors state to be notifty from MQTT update messages.""" + + # subscribe to the pypglab sensors to receive updates from the mqtt broker + # when a new sensor values are available + await self._sensors.subscribe_topics() + + # set the callback to be called when a new sensor values are available + self._sensors.set_on_state_callback(self._new_sensors_data) + + def get_sensor_value(self, sensor_key: str) -> float | datetime | None: + """Return the value of a sensor.""" + + if self.data: + value = self.data[sensor_key] + + if (sensor_key == SENSOR_REBOOT_TIME) and value: + # convert the reboot time to a datetime object + return utcnow() - timedelta(seconds=value) + + if (sensor_key == SENSOR_TEMPERATURE) and value: + # convert the temperature value to a float + return float(value) + + if (sensor_key == SENSOR_VOLTAGE) and value: + # convert the voltage value to a float + return float(value) + + return None diff --git a/homeassistant/components/pglab/device_sensor.py b/homeassistant/components/pglab/device_sensor.py deleted file mode 100644 index d202d11d6e7..00000000000 --- a/homeassistant/components/pglab/device_sensor.py +++ /dev/null @@ -1,56 +0,0 @@ -"""Device Sensor for PG LAB Electronics.""" - -from __future__ import annotations - -from typing import TYPE_CHECKING - -from pypglab.device import Device as PyPGLabDevice -from pypglab.sensor import Sensor as PyPGLabSensors - -from homeassistant.core import callback - -if TYPE_CHECKING: - from .entity import PGLabEntity - - -class PGLabDeviceSensor: - """Keeps PGLab device sensor update.""" - - def __init__(self, pglab_device: PyPGLabDevice) -> None: - """Initialize the device sensor.""" - - # get a reference of PG Lab device internal sensors state - self._sensors: PyPGLabSensors = pglab_device.sensors - - self._ha_sensors: list[PGLabEntity] = [] # list of HA entity sensors - - async def subscribe_topics(self): - """Subscribe to the device sensors topics.""" - self._sensors.set_on_state_callback(self.state_updated) - await self._sensors.subscribe_topics() - - def add_ha_sensor(self, entity: PGLabEntity) -> None: - """Add a new HA sensor to the list.""" - self._ha_sensors.append(entity) - - def remove_ha_sensor(self, entity: PGLabEntity) -> None: - """Remove a HA sensor from the list.""" - self._ha_sensors.remove(entity) - - @callback - def state_updated(self, payload: str) -> None: - """Handle state updates.""" - - # notify all HA sensors that PG LAB device sensor fields have been updated - for s in self._ha_sensors: - s.state_updated(payload) - - @property - def state(self) -> dict: - """Return the device sensors state.""" - return self._sensors.state - - @property - def sensors(self) -> PyPGLabSensors: - """Return the pypglab device sensors.""" - return self._sensors diff --git a/homeassistant/components/pglab/discovery.py b/homeassistant/components/pglab/discovery.py index fec6f5ce40d..e34f80a2e2d 100644 --- a/homeassistant/components/pglab/discovery.py +++ b/homeassistant/components/pglab/discovery.py @@ -25,13 +25,12 @@ from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, ) -from homeassistant.helpers.entity import Entity from .const import DISCOVERY_TOPIC, DOMAIN, LOGGER -from .device_sensor import PGLabDeviceSensor +from .coordinator import PGLabSensorsCoordinator if TYPE_CHECKING: - from . import PGLABConfigEntry + from . import PGLabConfigEntry # Supported platforms. PLATFORMS = [ @@ -69,7 +68,12 @@ def get_device_id_from_discovery_topic(topic: str) -> str | None: class DiscoverDeviceInfo: """Keeps information of the PGLab discovered device.""" - def __init__(self, pglab_device: PyPGLabDevice) -> None: + def __init__( + self, + hass: HomeAssistant, + config_entry: PGLabConfigEntry, + pglab_device: PyPGLabDevice, + ) -> None: """Initialize the device discovery info.""" # Hash string represents the devices actual configuration, @@ -77,15 +81,15 @@ class DiscoverDeviceInfo: # When the hash string changes the devices entities must be rebuilt. self._hash = pglab_device.hash self._entities: list[tuple[str, str]] = [] - self._sensors = PGLabDeviceSensor(pglab_device) + self.coordinator = PGLabSensorsCoordinator(hass, config_entry, pglab_device) - def add_entity(self, entity: Entity) -> None: + def add_entity(self, platform_domain: str, entity_unique_id: str | None) -> None: """Add an entity.""" # PGLabEntity always have unique IDs if TYPE_CHECKING: - assert entity.unique_id is not None - self._entities.append((entity.platform.domain, entity.unique_id)) + assert entity_unique_id is not None + self._entities.append((platform_domain, entity_unique_id)) @property def hash(self) -> int: @@ -97,18 +101,15 @@ class DiscoverDeviceInfo: """Return array of entities available.""" return self._entities - @property - def sensors(self) -> PGLabDeviceSensor: - """Return the PGLab device sensor.""" - return self._sensors - -async def createDiscoverDeviceInfo(pglab_device: PyPGLabDevice) -> DiscoverDeviceInfo: +async def create_discover_device_info( + hass: HomeAssistant, config_entry: PGLabConfigEntry, pglab_device: PyPGLabDevice +) -> DiscoverDeviceInfo: """Create a new DiscoverDeviceInfo instance.""" - discovery_info = DiscoverDeviceInfo(pglab_device) + discovery_info = DiscoverDeviceInfo(hass, config_entry, pglab_device) # Subscribe to sensor state changes. - await discovery_info.sensors.subscribe_topics() + await discovery_info.coordinator.subscribe_topics() return discovery_info @@ -184,7 +185,10 @@ class PGLabDiscovery: del self._discovered[device_id] async def start( - self, hass: HomeAssistant, mqtt: PyPGLabMqttClient, entry: PGLABConfigEntry + self, + hass: HomeAssistant, + mqtt: PyPGLabMqttClient, + config_entry: PGLabConfigEntry, ) -> None: """Start discovering a PGLab devices.""" @@ -210,7 +214,7 @@ class PGLabDiscovery: # Create a new device. device_registry = dr.async_get(hass) device_registry.async_get_or_create( - config_entry_id=entry.entry_id, + config_entry_id=config_entry.entry_id, configuration_url=f"http://{pglab_device.ip}/", connections={(CONNECTION_NETWORK_MAC, pglab_device.mac)}, identifiers={(DOMAIN, pglab_device.id)}, @@ -241,7 +245,9 @@ class PGLabDiscovery: self.__clean_discovered_device(hass, pglab_device.id) # Add a new device. - discovery_info = await createDiscoverDeviceInfo(pglab_device) + discovery_info = await create_discover_device_info( + hass, config_entry, pglab_device + ) self._discovered[pglab_device.id] = discovery_info # Create all new relay entities. @@ -256,7 +262,7 @@ class PGLabDiscovery: hass, CREATE_NEW_ENTITY[Platform.SENSOR], pglab_device, - discovery_info.sensors, + discovery_info.coordinator, ) topics = { @@ -267,7 +273,7 @@ class PGLabDiscovery: } # Forward setup all HA supported platforms. - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) self._mqtt_client = mqtt self._substate = async_prepare_subscribe_topics(hass, self._substate, topics) @@ -282,9 +288,9 @@ class PGLabDiscovery: ) self._disconnect_platform.append(disconnect_callback) - async def stop(self, hass: HomeAssistant, entry: PGLABConfigEntry) -> None: + async def stop(self, hass: HomeAssistant, config_entry: PGLabConfigEntry) -> None: """Stop to discovery PG LAB devices.""" - await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) # Disconnect all registered platforms. for disconnect_callback in self._disconnect_platform: @@ -292,7 +298,9 @@ class PGLabDiscovery: async_unsubscribe_topics(hass, self._substate) - async def add_entity(self, entity: Entity, device_id: str): + async def add_entity( + self, platform_domain: str, entity_unique_id: str | None, device_id: str + ): """Save a new PG LAB device entity.""" # Be sure that the device is been discovered. @@ -300,4 +308,4 @@ class PGLabDiscovery: raise PGLabDiscoveryError("Unknown device, device_id not discovered") discovery_info = self._discovered[device_id] - discovery_info.add_entity(entity) + discovery_info.add_entity(platform_domain, entity_unique_id) diff --git a/homeassistant/components/pglab/entity.py b/homeassistant/components/pglab/entity.py index 175b4c1eb0f..59a4e28de89 100644 --- a/homeassistant/components/pglab/entity.py +++ b/homeassistant/components/pglab/entity.py @@ -8,69 +8,105 @@ from pypglab.entity import Entity as PyPGLabEntity from homeassistant.core import callback from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo from homeassistant.helpers.entity import Entity +from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN +from .coordinator import PGLabSensorsCoordinator from .discovery import PGLabDiscovery -class PGLabEntity(Entity): - """Representation of a PGLab entity in Home Assistant.""" +class PGLabBaseEntity(Entity): + """Base class of a PGLab entity in Home Assistant.""" _attr_has_entity_name = True def __init__( self, - discovery: PGLabDiscovery, - device: PyPGLabDevice, - entity: PyPGLabEntity, + pglab_discovery: PGLabDiscovery, + pglab_device: PyPGLabDevice, ) -> None: """Initialize the class.""" - self._id = entity.id - self._device_id = device.id - self._entity = entity - self._discovery = discovery + self._device_id = pglab_device.id + self._discovery = pglab_discovery # Information about the device that is partially visible in the UI. self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, device.id)}, - name=device.name, - sw_version=device.firmware_version, - hw_version=device.hardware_version, - model=device.type, - manufacturer=device.manufactor, - configuration_url=f"http://{device.ip}/", - connections={(CONNECTION_NETWORK_MAC, device.mac)}, + identifiers={(DOMAIN, pglab_device.id)}, + name=pglab_device.name, + sw_version=pglab_device.firmware_version, + hw_version=pglab_device.hardware_version, + model=pglab_device.type, + manufacturer=pglab_device.manufactor, + configuration_url=f"http://{pglab_device.ip}/", + connections={(CONNECTION_NETWORK_MAC, pglab_device.mac)}, ) - async def subscribe_to_update(self): - """Subscribe to the entity updates.""" - self._entity.set_on_state_callback(self.state_updated) - await self._entity.subscribe_topics() - - async def unsubscribe_to_update(self): - """Unsubscribe to the entity updates.""" - await self._entity.unsubscribe_topics() - self._entity.set_on_state_callback(None) - async def async_added_to_hass(self) -> None: """Update the device discovery info.""" - await self.subscribe_to_update() - await super().async_added_to_hass() - # Inform PGLab discovery instance that a new entity is available. # This is important to know in case the device needs to be reconfigured # and the entity can be potentially destroyed. - await self._discovery.add_entity(self, self._device_id) + await self._discovery.add_entity( + self.platform.domain, + self.unique_id, + self._device_id, + ) + + # propagate the async_added_to_hass to the super class + await super().async_added_to_hass() + + +class PGLabEntity(PGLabBaseEntity): + """Representation of a PGLab entity in Home Assistant.""" + + def __init__( + self, + pglab_discovery: PGLabDiscovery, + pglab_device: PyPGLabDevice, + pglab_entity: PyPGLabEntity, + ) -> None: + """Initialize the class.""" + + super().__init__(pglab_discovery, pglab_device) + + self._id = pglab_entity.id + self._entity: PyPGLabEntity = pglab_entity + + async def async_added_to_hass(self) -> None: + """Subscribe pypglab entity to be updated from mqtt when pypglab entity internal state change.""" + + # set the callback to be called when pypglab entity state is changed + self._entity.set_on_state_callback(self.state_updated) + + # subscribe to the pypglab entity to receive updates from the mqtt broker + await self._entity.subscribe_topics() + await super().async_added_to_hass() async def async_will_remove_from_hass(self) -> None: """Unsubscribe when removed.""" await super().async_will_remove_from_hass() - await self.unsubscribe_to_update() + await self._entity.unsubscribe_topics() + self._entity.set_on_state_callback(None) @callback def state_updated(self, payload: str) -> None: """Handle state updates.""" self.async_write_ha_state() + + +class PGLabSensorEntity(PGLabBaseEntity, CoordinatorEntity[PGLabSensorsCoordinator]): + """Representation of a PGLab sensor entity in Home Assistant.""" + + def __init__( + self, + pglab_discovery: PGLabDiscovery, + pglab_device: PyPGLabDevice, + pglab_coordinator: PGLabSensorsCoordinator, + ) -> None: + """Initialize the class.""" + + PGLabBaseEntity.__init__(self, pglab_discovery, pglab_device) + CoordinatorEntity.__init__(self, pglab_coordinator) diff --git a/homeassistant/components/pglab/sensor.py b/homeassistant/components/pglab/sensor.py index f868e7ae101..ce19ec3a21a 100644 --- a/homeassistant/components/pglab/sensor.py +++ b/homeassistant/components/pglab/sensor.py @@ -2,8 +2,6 @@ from __future__ import annotations -from datetime import timedelta - from pypglab.const import SENSOR_REBOOT_TIME, SENSOR_TEMPERATURE, SENSOR_VOLTAGE from pypglab.device import Device as PyPGLabDevice @@ -16,12 +14,11 @@ from homeassistant.components.sensor import ( from homeassistant.const import Platform, UnitOfElectricPotential, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.util.dt import utcnow -from . import PGLABConfigEntry -from .device_sensor import PGLabDeviceSensor +from . import PGLabConfigEntry +from .coordinator import PGLabSensorsCoordinator from .discovery import PGLabDiscovery -from .entity import PGLabEntity +from .entity import PGLabSensorEntity PARALLEL_UPDATES = 0 @@ -50,7 +47,7 @@ SENSOR_INFO: list[SensorEntityDescription] = [ async def async_setup_entry( hass: HomeAssistant, - config_entry: PGLABConfigEntry, + config_entry: PGLabConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up sensor for device.""" @@ -58,62 +55,55 @@ async def async_setup_entry( @callback def async_discover( pglab_device: PyPGLabDevice, - pglab_device_sensor: PGLabDeviceSensor, + pglab_coordinator: PGLabSensorsCoordinator, ) -> None: """Discover and add a PG LAB Sensor.""" pglab_discovery = config_entry.runtime_data - for description in SENSOR_INFO: - pglab_sensor = PGLabSensor( - pglab_discovery, pglab_device, pglab_device_sensor, description + + sensors: list[PGLabSensor] = [ + PGLabSensor( + description, + pglab_discovery, + pglab_device, + pglab_coordinator, ) - async_add_entities([pglab_sensor]) + for description in SENSOR_INFO + ] + + async_add_entities(sensors) # Register the callback to create the sensor entity when discovered. pglab_discovery = config_entry.runtime_data await pglab_discovery.register_platform(hass, Platform.SENSOR, async_discover) -class PGLabSensor(PGLabEntity, SensorEntity): +class PGLabSensor(PGLabSensorEntity, SensorEntity): """A PGLab sensor.""" def __init__( self, + description: SensorEntityDescription, pglab_discovery: PGLabDiscovery, pglab_device: PyPGLabDevice, - pglab_device_sensor: PGLabDeviceSensor, - description: SensorEntityDescription, + pglab_coordinator: PGLabSensorsCoordinator, ) -> None: """Initialize the Sensor class.""" - super().__init__( - discovery=pglab_discovery, - device=pglab_device, - entity=pglab_device_sensor.sensors, - ) + super().__init__(pglab_discovery, pglab_device, pglab_coordinator) - self._type = description.key - self._pglab_device_sensor = pglab_device_sensor self._attr_unique_id = f"{pglab_device.id}_{description.key}" self.entity_description = description @callback - def state_updated(self, payload: str) -> None: - """Handle state updates.""" + def _handle_coordinator_update(self) -> None: + """Update attributes when the coordinator updates.""" - # get the sensor value from pglab multi fields sensor - value = self._pglab_device_sensor.state[self._type] + self._attr_native_value = self.coordinator.get_sensor_value( + self.entity_description.key + ) + super()._handle_coordinator_update() - if self.entity_description.device_class == SensorDeviceClass.TIMESTAMP: - self._attr_native_value = utcnow() - timedelta(seconds=value) - else: - self._attr_native_value = value - - super().state_updated(payload) - - async def subscribe_to_update(self): - """Register the HA sensor to be notify when the sensor status is changed.""" - self._pglab_device_sensor.add_ha_sensor(self) - - async def unsubscribe_to_update(self): - """Unregister the HA sensor from sensor tatus updates.""" - self._pglab_device_sensor.remove_ha_sensor(self) + @property + def available(self) -> bool: + """Return PG LAB sensor availability.""" + return super().available and self.native_value is not None diff --git a/homeassistant/components/pglab/switch.py b/homeassistant/components/pglab/switch.py index 554b5cf80ca..76b177e84c4 100644 --- a/homeassistant/components/pglab/switch.py +++ b/homeassistant/components/pglab/switch.py @@ -12,7 +12,7 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import PGLABConfigEntry +from . import PGLabConfigEntry from .discovery import PGLabDiscovery from .entity import PGLabEntity @@ -21,7 +21,7 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, - config_entry: PGLABConfigEntry, + config_entry: PGLabConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up switches for device.""" @@ -52,9 +52,9 @@ class PGLabSwitch(PGLabEntity, SwitchEntity): """Initialize the Switch class.""" super().__init__( - discovery=pglab_discovery, - device=pglab_device, - entity=pglab_relay, + pglab_discovery, + pglab_device, + pglab_relay, ) self._attr_unique_id = f"{pglab_device.id}_relay{pglab_relay.id}" diff --git a/tests/components/pglab/snapshots/test_sensor.ambr b/tests/components/pglab/snapshots/test_sensor.ambr index f25f459bb70..71889b65183 100644 --- a/tests/components/pglab/snapshots/test_sensor.ambr +++ b/tests/components/pglab/snapshots/test_sensor.ambr @@ -12,7 +12,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': 'unavailable', }) # --- # name: test_sensors[mpu_voltage][updated_sensor_mpu_voltage] @@ -43,7 +43,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': 'unavailable', }) # --- # name: test_sensors[run_time][updated_sensor_run_time] @@ -74,7 +74,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': 'unavailable', }) # --- # name: test_sensors[temperature][updated_sensor_temperature] From f8e3f2a94fcff3e27350909649997fc40ea5d230 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 6 Mar 2025 00:00:12 +0100 Subject: [PATCH 2259/3148] Improve descriptions in overseerr.get_requests action (#139781) Make the action description consistent with HA style adding a bit more info from the online docs. Fix spelling of "ID" --- homeassistant/components/overseerr/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/overseerr/strings.json b/homeassistant/components/overseerr/strings.json index 14650fd5c25..ce8b9fe9fec 100644 --- a/homeassistant/components/overseerr/strings.json +++ b/homeassistant/components/overseerr/strings.json @@ -90,7 +90,7 @@ "services": { "get_requests": { "name": "Get requests", - "description": "Get media requests from Overseerr.", + "description": "Retrieves a list of media requests from Overseerr.", "fields": { "config_entry_id": { "name": "Overseerr instance", @@ -106,7 +106,7 @@ }, "requested_by": { "name": "Requested by", - "description": "Filter the requests by the user id that requested them." + "description": "Filter the requests by the user ID that requested them." } } } From 8e357831644ca31080a12f82c48aba1fde8264f6 Mon Sep 17 00:00:00 2001 From: Ivan Lopez Hernandez Date: Wed, 5 Mar 2025 18:34:11 -0800 Subject: [PATCH 2260/3148] Trim the Schema allowed keys to match the Public Gemini API docs. (#139876) * Trim the Schema allowed types to match the Public API docs, not the SDK types as those do not match * Testing --- .../conversation.py | 30 +++------ .../snapshots/test_conversation.ambr | 2 +- .../test_conversation.py | 64 ++++++++++++++----- 3 files changed, 58 insertions(+), 38 deletions(-) diff --git a/homeassistant/components/google_generative_ai_conversation/conversation.py b/homeassistant/components/google_generative_ai_conversation/conversation.py index 2c84249dcb3..168e867d857 100644 --- a/homeassistant/components/google_generative_ai_conversation/conversation.py +++ b/homeassistant/components/google_generative_ai_conversation/conversation.py @@ -64,28 +64,18 @@ async def async_setup_entry( SUPPORTED_SCHEMA_KEYS = { - "min_items", - "example", - "property_ordering", - "pattern", - "minimum", - "default", - "any_of", - "max_length", - "title", - "min_properties", - "min_length", - "max_items", - "maximum", - "nullable", - "max_properties", + # Gemini API does not support all of the OpenAPI schema + # SoT: https://ai.google.dev/api/caching#Schema "type", - "description", - "enum", "format", - "items", + "description", + "nullable", + "enum", + "max_items", + "min_items", "properties", "required", + "items", } @@ -109,9 +99,7 @@ def _format_schema(schema: dict[str, Any]) -> Schema: key = _camel_to_snake(key) if key not in SUPPORTED_SCHEMA_KEYS: continue - if key == "any_of": - val = [_format_schema(subschema) for subschema in val] - elif key == "type": + if key == "type": val = val.upper() elif key == "format": # Gemini API does not support all formats, see: https://ai.google.dev/api/caching#Schema diff --git a/tests/components/google_generative_ai_conversation/snapshots/test_conversation.ambr b/tests/components/google_generative_ai_conversation/snapshots/test_conversation.ambr index 106366fd240..c840f7da324 100644 --- a/tests/components/google_generative_ai_conversation/snapshots/test_conversation.ambr +++ b/tests/components/google_generative_ai_conversation/snapshots/test_conversation.ambr @@ -6,7 +6,7 @@ tuple( ), dict({ - 'config': GenerateContentConfig(http_options=None, system_instruction="Current time is 05:00:00. Today's date is 2024-05-24.\nYou are a voice assistant for Home Assistant.\nAnswer questions about the world truthfully.\nAnswer in plain text. Keep it simple and to the point.\nOnly if the user wants to control a device, tell them to expose entities to their voice assistant in Home Assistant.", temperature=1.0, top_p=0.95, top_k=64.0, candidate_count=None, max_output_tokens=150, stop_sequences=None, response_logprobs=None, logprobs=None, presence_penalty=None, frequency_penalty=None, seed=None, response_mime_type=None, response_schema=None, routing_config=None, safety_settings=[SafetySetting(method=None, category=, threshold=), SafetySetting(method=None, category=, threshold=), SafetySetting(method=None, category=, threshold=), SafetySetting(method=None, category=, threshold=)], tools=[Tool(function_declarations=[FunctionDeclaration(response=None, description='Test function', name='test_tool', parameters=Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=None, max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=, description=None, enum=None, format=None, items=None, properties={'param1': Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=None, max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=, description='Test parameters', enum=None, format=None, items=Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=None, max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=, description=None, enum=None, format=None, items=None, properties=None, required=None), properties=None, required=None), 'param2': Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=[Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=None, max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=, description=None, enum=None, format=None, items=None, properties=None, required=None), Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=None, max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=, description=None, enum=None, format=None, items=None, properties=None, required=None)], max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=None, description=None, enum=None, format=None, items=None, properties=None, required=None), 'param3': Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=None, max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=, description=None, enum=None, format=None, items=None, properties={'json': Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=None, max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=, description=None, enum=None, format=None, items=None, properties=None, required=None)}, required=[])}, required=[]))], retrieval=None, google_search=None, google_search_retrieval=None, code_execution=None)], tool_config=None, labels=None, cached_content=None, response_modalities=None, media_resolution=None, speech_config=None, audio_timestamp=None, automatic_function_calling=AutomaticFunctionCallingConfig(disable=True, maximum_remote_calls=None, ignore_call_history=None), thinking_config=None), + 'config': GenerateContentConfig(http_options=None, system_instruction="Current time is 05:00:00. Today's date is 2024-05-24.\nYou are a voice assistant for Home Assistant.\nAnswer questions about the world truthfully.\nAnswer in plain text. Keep it simple and to the point.\nOnly if the user wants to control a device, tell them to expose entities to their voice assistant in Home Assistant.", temperature=1.0, top_p=0.95, top_k=64.0, candidate_count=None, max_output_tokens=150, stop_sequences=None, response_logprobs=None, logprobs=None, presence_penalty=None, frequency_penalty=None, seed=None, response_mime_type=None, response_schema=None, routing_config=None, safety_settings=[SafetySetting(method=None, category=, threshold=), SafetySetting(method=None, category=, threshold=), SafetySetting(method=None, category=, threshold=), SafetySetting(method=None, category=, threshold=)], tools=[Tool(function_declarations=[FunctionDeclaration(response=None, description='Test function', name='test_tool', parameters=Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=None, max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=, description=None, enum=None, format=None, items=None, properties={'param1': Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=None, max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=, description='Test parameters', enum=None, format=None, items=Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=None, max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=, description=None, enum=None, format=None, items=None, properties=None, required=None), properties=None, required=None), 'param2': Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=None, max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=None, description=None, enum=None, format=None, items=None, properties=None, required=None), 'param3': Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=None, max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=, description=None, enum=None, format=None, items=None, properties={'json': Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=None, max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=, description=None, enum=None, format=None, items=None, properties=None, required=None)}, required=[])}, required=[]))], retrieval=None, google_search=None, google_search_retrieval=None, code_execution=None)], tool_config=None, labels=None, cached_content=None, response_modalities=None, media_resolution=None, speech_config=None, audio_timestamp=None, automatic_function_calling=AutomaticFunctionCallingConfig(disable=True, maximum_remote_calls=None, ignore_call_history=None), thinking_config=None), 'history': list([ ]), 'model': 'models/gemini-2.0-flash', diff --git a/tests/components/google_generative_ai_conversation/test_conversation.py b/tests/components/google_generative_ai_conversation/test_conversation.py index 5e887d3cab7..64f71c18bf2 100644 --- a/tests/components/google_generative_ai_conversation/test_conversation.py +++ b/tests/components/google_generative_ai_conversation/test_conversation.py @@ -493,6 +493,26 @@ async def test_escape_decode() -> None: {"type": "string", "enum": ["a", "b", "c"]}, {"type": "STRING", "enum": ["a", "b", "c"]}, ), + ( + {"type": "string", "default": "default"}, + {"type": "STRING"}, + ), + ( + {"type": "string", "pattern": "default"}, + {"type": "STRING"}, + ), + ( + {"type": "string", "maxLength": 10}, + {"type": "STRING"}, + ), + ( + {"type": "string", "minLength": 10}, + {"type": "STRING"}, + ), + ( + {"type": "string", "title": "title"}, + {"type": "STRING"}, + ), ( {"type": "string", "format": "enum", "enum": ["a", "b", "c"]}, {"type": "STRING", "format": "enum", "enum": ["a", "b", "c"]}, @@ -517,6 +537,10 @@ async def test_escape_decode() -> None: {"type": "number", "format": "hex"}, {"type": "NUMBER"}, ), + ( + {"type": "number", "minimum": 1}, + {"type": "NUMBER"}, + ), ( {"type": "integer", "format": "int32"}, {"type": "INTEGER", "format": "int32"}, @@ -535,21 +559,7 @@ async def test_escape_decode() -> None: ), ( {"anyOf": [{"type": "integer"}, {"type": "number"}]}, - {"any_of": [{"type": "INTEGER"}, {"type": "NUMBER"}]}, - ), - ( - { - "any_of": [ - {"any_of": [{"type": "integer"}, {"type": "number"}]}, - {"any_of": [{"type": "integer"}, {"type": "number"}]}, - ] - }, - { - "any_of": [ - {"any_of": [{"type": "INTEGER"}, {"type": "NUMBER"}]}, - {"any_of": [{"type": "INTEGER"}, {"type": "NUMBER"}]}, - ] - }, + {}, ), ({"type": "string", "format": "lower"}, {"type": "STRING"}), ({"type": "boolean", "format": "bool"}, {"type": "BOOLEAN"}), @@ -570,7 +580,15 @@ async def test_escape_decode() -> None: }, ), ( - {"type": "object", "additionalProperties": True}, + {"type": "object", "additionalProperties": True, "minProperties": 1}, + { + "type": "OBJECT", + "properties": {"json": {"type": "STRING"}}, + "required": [], + }, + ), + ( + {"type": "object", "additionalProperties": True, "maxProperties": 1}, { "type": "OBJECT", "properties": {"json": {"type": "STRING"}}, @@ -581,6 +599,20 @@ async def test_escape_decode() -> None: {"type": "array", "items": {"type": "string"}}, {"type": "ARRAY", "items": {"type": "STRING"}}, ), + ( + { + "type": "array", + "items": {"type": "string"}, + "minItems": 1, + "maxItems": 2, + }, + { + "type": "ARRAY", + "items": {"type": "STRING"}, + "min_items": 1, + "max_items": 2, + }, + ), ], ) async def test_format_schema(openapi, genai_schema) -> None: From a5002018e0b9ec148649af5d0f1092b5c972de7d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 5 Mar 2025 20:38:23 -1000 Subject: [PATCH 2261/3148] Bump dbus-fast to 2.37.0 (#139877) --- 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 81a2aae990a..f097eb3a3cf 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -20,7 +20,7 @@ "bluetooth-adapters==0.21.4", "bluetooth-auto-recovery==1.4.4", "bluetooth-data-tools==1.25.0", - "dbus-fast==2.35.1", + "dbus-fast==2.37.0", "habluetooth==3.25.0" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 4a2d4219b50..e7f1cf096a4 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -29,7 +29,7 @@ certifi>=2021.5.30 ciso8601==2.3.2 cronsim==2.6 cryptography==44.0.1 -dbus-fast==2.35.1 +dbus-fast==2.37.0 fnv-hash-fast==1.4.0 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 diff --git a/requirements_all.txt b/requirements_all.txt index 1d8947d861b..b94467cbcae 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -744,7 +744,7 @@ datadog==0.15.0 datapoint==0.9.9 # homeassistant.components.bluetooth -dbus-fast==2.35.1 +dbus-fast==2.37.0 # homeassistant.components.debugpy debugpy==1.8.11 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b47e238e1f2..9170291121f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -640,7 +640,7 @@ datadog==0.15.0 datapoint==0.9.9 # homeassistant.components.bluetooth -dbus-fast==2.35.1 +dbus-fast==2.37.0 # homeassistant.components.debugpy debugpy==1.8.11 From 48865e00b6c35b4fa985322521e38665cfbea1fe Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Thu, 6 Mar 2025 07:42:09 +0100 Subject: [PATCH 2262/3148] Bump pynecil to v4.1.0 (#139881) --- homeassistant/components/iron_os/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/iron_os/manifest.json b/homeassistant/components/iron_os/manifest.json index c9868791668..58cbdaa3bc6 100644 --- a/homeassistant/components/iron_os/manifest.json +++ b/homeassistant/components/iron_os/manifest.json @@ -14,5 +14,5 @@ "iot_class": "local_polling", "loggers": ["pynecil"], "quality_scale": "platinum", - "requirements": ["pynecil==4.0.1"] + "requirements": ["pynecil==4.1.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index b94467cbcae..40f8f53a20a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2140,7 +2140,7 @@ pymsteams==0.1.12 pymysensors==0.24.0 # homeassistant.components.iron_os -pynecil==4.0.1 +pynecil==4.1.0 # homeassistant.components.netgear pynetgear==0.10.10 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9170291121f..a0134a18edd 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1745,7 +1745,7 @@ pymonoprice==0.4 pymysensors==0.24.0 # homeassistant.components.iron_os -pynecil==4.0.1 +pynecil==4.1.0 # homeassistant.components.netgear pynetgear==0.10.10 From aec6868af174c142fe95d95a3316a5abf6d9c5b3 Mon Sep 17 00:00:00 2001 From: Petro31 <35082313+Petro31@users.noreply.github.com> Date: Thu, 6 Mar 2025 02:00:11 -0500 Subject: [PATCH 2263/3148] Add abstract class to trigger based template entities (#139650) * add abstract class to trigger based template entities * updates after merge of parent PR * add comments * add tests --- homeassistant/components/template/config.py | 8 ++- .../components/template/coordinator.py | 27 +++++++- homeassistant/components/template/helpers.py | 7 ++- homeassistant/components/template/number.py | 18 +++--- homeassistant/components/template/select.py | 23 ++++--- .../components/template/trigger_entity.py | 16 ++++- tests/components/template/test_blueprint.py | 62 ++++++++++++++++++- tests/components/template/test_number.py | 8 ++- tests/components/template/test_select.py | 26 +++++++- .../template/test_trigger_entity.py | 13 ++++ .../template/test_event_sensor.yaml | 27 ++++++++ 11 files changed, 202 insertions(+), 33 deletions(-) create mode 100644 tests/components/template/test_trigger_entity.py create mode 100644 tests/testing_config/blueprints/template/test_event_sensor.yaml diff --git a/homeassistant/components/template/config.py b/homeassistant/components/template/config.py index e0c5514def9..9c92ed2b334 100644 --- a/homeassistant/components/template/config.py +++ b/homeassistant/components/template/config.py @@ -122,9 +122,15 @@ async def _async_resolve_blueprints( raise vol.Invalid("more than one platform defined per blueprint") if len(platforms) == 1: platform = platforms.pop() - for prop in (CONF_NAME, CONF_UNIQUE_ID, CONF_VARIABLES): + for prop in (CONF_NAME, CONF_UNIQUE_ID): if prop in config: config[platform][prop] = config.pop(prop) + # For regular template entities, CONF_VARIABLES should be removed because they just + # house input results for template entities. For Trigger based template entities + # CONF_VARIABLES should not be removed because the variables are always + # executed between the trigger and action. + if CONF_TRIGGER not in config and CONF_VARIABLES in config: + config[platform][CONF_VARIABLES] = config.pop(CONF_VARIABLES) raw_config = dict(config) template_config = TemplateConfig(CONFIG_SECTION_SCHEMA(config)) diff --git a/homeassistant/components/template/coordinator.py b/homeassistant/components/template/coordinator.py index 4d8fe78f2b5..c11e9b6101b 100644 --- a/homeassistant/components/template/coordinator.py +++ b/homeassistant/components/template/coordinator.py @@ -2,12 +2,14 @@ from collections.abc import Callable, Mapping import logging -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, cast -from homeassistant.const import EVENT_HOMEASSISTANT_START +from homeassistant.components.blueprint import CONF_USE_BLUEPRINT +from homeassistant.const import CONF_PATH, CONF_VARIABLES, EVENT_HOMEASSISTANT_START from homeassistant.core import Context, CoreState, Event, HomeAssistant, callback from homeassistant.helpers import condition, discovery, trigger as trigger_helper from homeassistant.helpers.script import Script +from homeassistant.helpers.script_variables import ScriptVariables from homeassistant.helpers.trace import trace_get from homeassistant.helpers.typing import ConfigType, TemplateVarsType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -22,7 +24,7 @@ class TriggerUpdateCoordinator(DataUpdateCoordinator): REMOVE_TRIGGER = object() - def __init__(self, hass: HomeAssistant, config: dict[str, Any]) -> None: + def __init__(self, hass: HomeAssistant, config: ConfigType) -> None: """Instantiate trigger data.""" super().__init__( hass, _LOGGER, config_entry=None, name="Trigger Update Coordinator" @@ -32,6 +34,18 @@ class TriggerUpdateCoordinator(DataUpdateCoordinator): self._unsub_start: Callable[[], None] | None = None self._unsub_trigger: Callable[[], None] | None = None self._script: Script | None = None + self._run_variables: ScriptVariables | None = None + self._blueprint_inputs: dict | None = None + if config is not None: + self._run_variables = config.get(CONF_VARIABLES) + self._blueprint_inputs = getattr(config, "raw_blueprint_inputs", None) + + @property + def referenced_blueprint(self) -> str | None: + """Return referenced blueprint or None.""" + if self._blueprint_inputs is None: + return None + return cast(str, self._blueprint_inputs[CONF_USE_BLUEPRINT][CONF_PATH]) @property def unique_id(self) -> str | None: @@ -104,6 +118,10 @@ class TriggerUpdateCoordinator(DataUpdateCoordinator): async def _handle_triggered_with_script( self, run_variables: TemplateVarsType, context: Context | None = None ) -> None: + # Render run variables after the trigger, before checking conditions. + if self._run_variables: + run_variables = self._run_variables.async_render(self.hass, run_variables) + if not self._check_condition(run_variables): return # Create a context referring to the trigger context. @@ -119,6 +137,9 @@ class TriggerUpdateCoordinator(DataUpdateCoordinator): async def _handle_triggered( self, run_variables: TemplateVarsType, context: Context | None = None ) -> None: + if self._run_variables: + run_variables = self._run_variables.async_render(self.hass, run_variables) + if not self._check_condition(run_variables): return self._execute_update(run_variables, context) diff --git a/homeassistant/components/template/helpers.py b/homeassistant/components/template/helpers.py index b320f2128cd..d74a4a4ed00 100644 --- a/homeassistant/components/template/helpers.py +++ b/homeassistant/components/template/helpers.py @@ -9,7 +9,7 @@ from homeassistant.helpers.entity_platform import async_get_platforms from homeassistant.helpers.singleton import singleton from .const import DOMAIN, TEMPLATE_BLUEPRINT_SCHEMA -from .template_entity import TemplateEntity +from .entity import AbstractTemplateEntity DATA_BLUEPRINTS = "template_blueprints" @@ -23,7 +23,7 @@ def templates_with_blueprint(hass: HomeAssistant, blueprint_path: str) -> list[s entity_id for platform in async_get_platforms(hass, DOMAIN) for entity_id, template_entity in platform.entities.items() - if isinstance(template_entity, TemplateEntity) + if isinstance(template_entity, AbstractTemplateEntity) and template_entity.referenced_blueprint == blueprint_path ] @@ -33,7 +33,8 @@ def blueprint_in_template(hass: HomeAssistant, entity_id: str) -> str | None: """Return the blueprint the template entity is based on or None.""" for platform in async_get_platforms(hass, DOMAIN): if isinstance( - (template_entity := platform.entities.get(entity_id)), TemplateEntity + (template_entity := platform.entities.get(entity_id)), + AbstractTemplateEntity, ): return template_entity.referenced_blueprint return None diff --git a/homeassistant/components/template/number.py b/homeassistant/components/template/number.py index e3654661158..3ecf1db565a 100644 --- a/homeassistant/components/template/number.py +++ b/homeassistant/components/template/number.py @@ -31,7 +31,6 @@ from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, ) -from homeassistant.helpers.script import Script from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import TriggerUpdateCoordinator @@ -236,12 +235,8 @@ class TriggerNumberEntity(TriggerEntity, NumberEntity): """Initialize the entity.""" super().__init__(hass, coordinator, config) - self._command_set_value = Script( - hass, - config[CONF_SET_VALUE], - self._rendered.get(CONF_NAME, DEFAULT_NAME), - DOMAIN, - ) + name = self._rendered.get(CONF_NAME, DEFAULT_NAME) + self.add_script(CONF_SET_VALUE, config[CONF_SET_VALUE], name, DOMAIN) self._attr_native_unit_of_measurement = config.get(CONF_UNIT_OF_MEASUREMENT) @@ -276,6 +271,9 @@ class TriggerNumberEntity(TriggerEntity, NumberEntity): if self._config[CONF_OPTIMISTIC]: self._attr_native_value = value self.async_write_ha_state() - await self._command_set_value.async_run( - {ATTR_VALUE: value}, context=self._context - ) + if set_value := self._action_scripts.get(CONF_SET_VALUE): + await self.async_run_script( + set_value, + run_variables={ATTR_VALUE: value}, + context=self._context, + ) diff --git a/homeassistant/components/template/select.py b/homeassistant/components/template/select.py index 1e7cb781eb0..eb60a3dbfe4 100644 --- a/homeassistant/components/template/select.py +++ b/homeassistant/components/template/select.py @@ -28,7 +28,6 @@ from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, ) -from homeassistant.helpers.script import Script from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import TriggerUpdateCoordinator @@ -198,12 +197,13 @@ class TriggerSelectEntity(TriggerEntity, SelectEntity): ) -> None: """Initialize the entity.""" super().__init__(hass, coordinator, config) - self._command_select_option = Script( - hass, - config[CONF_SELECT_OPTION], - self._rendered.get(CONF_NAME, DEFAULT_NAME), - DOMAIN, - ) + if select_option := config.get(CONF_SELECT_OPTION): + self.add_script( + CONF_SELECT_OPTION, + select_option, + self._rendered.get(CONF_NAME, DEFAULT_NAME), + DOMAIN, + ) @property def current_option(self) -> str | None: @@ -220,6 +220,9 @@ class TriggerSelectEntity(TriggerEntity, SelectEntity): if self._config[CONF_OPTIMISTIC]: self._attr_current_option = option self.async_write_ha_state() - await self._command_select_option.async_run( - {ATTR_OPTION: option}, context=self._context - ) + if select_option := self._action_scripts.get(CONF_SELECT_OPTION): + await self.async_run_script( + select_option, + run_variables={ATTR_OPTION: option}, + context=self._context, + ) diff --git a/homeassistant/components/template/trigger_entity.py b/homeassistant/components/template/trigger_entity.py index 5130f332d5b..87c93b6143b 100644 --- a/homeassistant/components/template/trigger_entity.py +++ b/homeassistant/components/template/trigger_entity.py @@ -8,10 +8,13 @@ from homeassistant.helpers.trigger_template_entity import TriggerBaseEntity from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import TriggerUpdateCoordinator +from .entity import AbstractTemplateEntity class TriggerEntity( # pylint: disable=hass-enforce-class-module - TriggerBaseEntity, CoordinatorEntity[TriggerUpdateCoordinator] + TriggerBaseEntity, + CoordinatorEntity[TriggerUpdateCoordinator], + AbstractTemplateEntity, ): """Template entity based on trigger data.""" @@ -24,6 +27,7 @@ class TriggerEntity( # pylint: disable=hass-enforce-class-module """Initialize the entity.""" CoordinatorEntity.__init__(self, coordinator) TriggerBaseEntity.__init__(self, hass, config) + AbstractTemplateEntity.__init__(self, hass) async def async_added_to_hass(self) -> None: """Handle being added to Home Assistant.""" @@ -38,6 +42,16 @@ class TriggerEntity( # pylint: disable=hass-enforce-class-module else: self._unique_id = unique_id + @property + def referenced_blueprint(self) -> str | None: + """Return referenced blueprint or None.""" + return self.coordinator.referenced_blueprint + + @callback + def _render_script_variables(self) -> dict: + """Render configured variables.""" + return self.coordinator.data["run_variables"] + @callback def _process_data(self) -> None: """Process new data.""" diff --git a/tests/components/template/test_blueprint.py b/tests/components/template/test_blueprint.py index dd008a27822..66630ecf739 100644 --- a/tests/components/template/test_blueprint.py +++ b/tests/components/template/test_blueprint.py @@ -16,10 +16,10 @@ from homeassistant.components.blueprint import ( DomainBlueprints, ) from homeassistant.components.template import DOMAIN, SERVICE_RELOAD -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import Context, HomeAssistant, callback from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component -from homeassistant.util import yaml as yaml_util +from homeassistant.util import dt as dt_util, yaml as yaml_util from tests.common import async_mock_service @@ -212,6 +212,61 @@ async def test_reload_template_when_blueprint_changes(hass: HomeAssistant) -> No assert not_inverted.state == "on" +async def test_trigger_event_sensor( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Test event sensor blueprint.""" + blueprint = "test_event_sensor.yaml" + assert await async_setup_component( + hass, + "template", + { + "template": [ + { + "use_blueprint": { + "path": blueprint, + "input": { + "event_type": "my_custom_event", + "event_data": {"foo": "bar"}, + }, + }, + "name": "My Custom Event", + }, + ] + }, + ) + + context = Context() + now = dt_util.utcnow() + with patch("homeassistant.util.dt.now", return_value=now): + hass.bus.async_fire( + "my_custom_event", {"foo": "bar", "beer": 2}, context=context + ) + await hass.async_block_till_done() + + date_state = hass.states.get("sensor.my_custom_event") + assert date_state is not None + assert date_state.state == now.isoformat(timespec="seconds") + data = date_state.attributes.get("data") + assert data is not None + assert data != "" + assert data.get("foo") == "bar" + assert data.get("beer") == 2 + + inverted_foo_template = template.helpers.blueprint_in_template( + hass, "sensor.my_custom_event" + ) + assert inverted_foo_template == blueprint + + inverted_binary_sensor_blueprint_entity_ids = ( + template.helpers.templates_with_blueprint(hass, blueprint) + ) + assert len(inverted_binary_sensor_blueprint_entity_ids) == 1 + + with pytest.raises(BlueprintInUse): + await template.async_get_blueprints(hass).async_remove_blueprint(blueprint) + + async def test_domain_blueprint(hass: HomeAssistant) -> None: """Test DomainBlueprint services.""" reload_handler_calls = async_mock_service(hass, DOMAIN, SERVICE_RELOAD) @@ -262,7 +317,8 @@ async def test_invalid_blueprint( ) assert "more than one platform defined per blueprint" in caplog.text - assert await template.async_get_blueprints(hass).async_get_blueprints() == {} + blueprints = await template.async_get_blueprints(hass).async_get_blueprints() + assert "invalid.yaml" not in blueprints async def test_no_blueprint(hass: HomeAssistant) -> None: diff --git a/tests/components/template/test_number.py b/tests/components/template/test_number.py index ec96245b4d0..f73a943e752 100644 --- a/tests/components/template/test_number.py +++ b/tests/components/template/test_number.py @@ -330,7 +330,10 @@ async def test_trigger_number(hass: HomeAssistant) -> None: "max": "{{ trigger.event.data.max_beers }}", "step": "{{ trigger.event.data.step }}", "unit_of_measurement": "beer", - "set_value": {"event": "test_number_event"}, + "set_value": { + "event": "test_number_event", + "event_data": {"entity_id": "{{ this.entity_id }}"}, + }, "optimistic": True, }, ], @@ -379,6 +382,9 @@ async def test_trigger_number(hass: HomeAssistant) -> None: ) assert len(events) == 1 assert events[0].event_type == "test_number_event" + entity_id = events[0].data.get("entity_id") + assert entity_id is not None + assert entity_id == "number.hello_name" def _verify( diff --git a/tests/components/template/test_select.py b/tests/components/template/test_select.py index 5b4723a3034..59ab45aeb36 100644 --- a/tests/components/template/test_select.py +++ b/tests/components/template/test_select.py @@ -264,6 +264,7 @@ async def test_templates_with_entities( async def test_trigger_select(hass: HomeAssistant) -> None: """Test trigger based template select.""" events = async_capture_events(hass, "test_number_event") + action_events = async_capture_events(hass, "action_event") assert await setup.async_setup_component( hass, "template", @@ -274,13 +275,23 @@ async def test_trigger_select(hass: HomeAssistant) -> None: { "unique_id": "listening-test-event", "trigger": {"platform": "event", "event_type": "test_event"}, + "variables": {"beer": "{{ trigger.event.data.beer }}"}, + "action": [ + {"event": "action_event", "event_data": {"beer": "{{ beer }}"}} + ], "select": [ { "name": "Hello Name", "unique_id": "hello_name-id", "state": "{{ trigger.event.data.beer }}", "options": "{{ trigger.event.data.beers }}", - "select_option": {"event": "test_number_event"}, + "select_option": { + "event": "test_number_event", + "event_data": { + "entity_id": "{{ this.entity_id }}", + "beer": "{{ beer }}", + }, + }, "optimistic": True, }, ], @@ -308,6 +319,12 @@ async def test_trigger_select(hass: HomeAssistant) -> None: assert state.state == "duff" assert state.attributes["options"] == ["duff", "alamo"] + assert len(action_events) == 1 + assert action_events[0].event_type == "action_event" + beer = action_events[0].data.get("beer") + assert beer is not None + assert beer == "duff" + await hass.services.async_call( SELECT_DOMAIN, SELECT_SERVICE_SELECT_OPTION, @@ -316,6 +333,13 @@ async def test_trigger_select(hass: HomeAssistant) -> None: ) assert len(events) == 1 assert events[0].event_type == "test_number_event" + entity_id = events[0].data.get("entity_id") + assert entity_id is not None + assert entity_id == "select.hello_name" + + beer = events[0].data.get("beer") + assert beer is not None + assert beer == "duff" def _verify( diff --git a/tests/components/template/test_trigger_entity.py b/tests/components/template/test_trigger_entity.py new file mode 100644 index 00000000000..99aa2d65df9 --- /dev/null +++ b/tests/components/template/test_trigger_entity.py @@ -0,0 +1,13 @@ +"""Test trigger template entity.""" + +from homeassistant.components.template import trigger_entity +from homeassistant.components.template.coordinator import TriggerUpdateCoordinator +from homeassistant.core import HomeAssistant + + +async def test_reference_blueprints_is_none(hass: HomeAssistant) -> None: + """Test template entity requires hass to be set before accepting templates.""" + coordinator = TriggerUpdateCoordinator(hass, {}) + entity = trigger_entity.TriggerEntity(hass, coordinator, {}) + + assert entity.referenced_blueprint is None diff --git a/tests/testing_config/blueprints/template/test_event_sensor.yaml b/tests/testing_config/blueprints/template/test_event_sensor.yaml new file mode 100644 index 00000000000..8b615eb90ba --- /dev/null +++ b/tests/testing_config/blueprints/template/test_event_sensor.yaml @@ -0,0 +1,27 @@ +blueprint: + name: Create Sensor from Event + description: Creates a timestamp sensor from an event + domain: template + source_url: https://github.com/home-assistant/core/blob/dev/homeassistant/components/template/blueprints/event_sensor.yaml + input: + event_type: + name: Name of the event_type + description: The event_type for the event trigger + selector: + text: + event_data: + name: The data for the event + description: The event_data for the event trigger + selector: + object: +trigger: + - trigger: event + event_type: !input event_type + event_data: !input event_data +variables: + event_data: "{{ trigger.event.data }}" +sensor: + state: "{{ now() }}" + device_class: timestamp + attributes: + data: "{{ event_data }}" From b280874dc022af653bf96f6e9678fe445506e234 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 5 Mar 2025 22:54:48 -1000 Subject: [PATCH 2264/3148] Small cleanups for HomeKit (#139889) * Small cleanups for HomeKit - Add some missing typing - Break out some duplicate code * Small cleanups for HomeKit - Add some missing typing - Break out some duplicate code --- homeassistant/components/homekit/__init__.py | 97 ++++++++++---------- 1 file changed, 46 insertions(+), 51 deletions(-) diff --git a/homeassistant/components/homekit/__init__.py b/homeassistant/components/homekit/__init__.py index 97fb17d7db5..9bd5711832c 100644 --- a/homeassistant/components/homekit/__init__.py +++ b/homeassistant/components/homekit/__init__.py @@ -221,6 +221,34 @@ UNPAIR_SERVICE_SCHEMA = vol.All( ) +@callback +def _async_update_entries_from_yaml( + hass: HomeAssistant, config: ConfigType, start_import_flow: bool +) -> None: + current_entries = hass.config_entries.async_entries(DOMAIN) + entries_by_name, entries_by_port = _async_get_imported_entries_indices( + current_entries + ) + hk_config: list[dict[str, Any]] = config[DOMAIN] + + for index, conf in enumerate(hk_config): + if _async_update_config_entry_from_yaml( + hass, entries_by_name, entries_by_port, conf + ): + continue + + if start_import_flow: + conf[CONF_ENTRY_INDEX] = index + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=conf, + ), + eager_start=True, + ) + + def _async_all_homekit_instances(hass: HomeAssistant) -> list[HomeKit]: """All active HomeKit instances.""" hk_data: HomeKitEntryData | None @@ -258,31 +286,10 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: await hass.async_add_executor_job(get_loader) _async_register_events_and_services(hass) - if DOMAIN not in config: return True - current_entries = hass.config_entries.async_entries(DOMAIN) - entries_by_name, entries_by_port = _async_get_imported_entries_indices( - current_entries - ) - - for index, conf in enumerate(config[DOMAIN]): - if _async_update_config_entry_from_yaml( - hass, entries_by_name, entries_by_port, conf - ): - continue - - conf[CONF_ENTRY_INDEX] = index - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=conf, - ), - eager_start=True, - ) - + _async_update_entries_from_yaml(hass, config, start_import_flow=True) return True @@ -326,13 +333,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: HomeKitConfigEntry) -> b conf = entry.data options = entry.options - name = conf[CONF_NAME] - port = conf[CONF_PORT] - _LOGGER.debug("Begin setup HomeKit for %s", name) - + name: str = conf[CONF_NAME] + port: int = conf[CONF_PORT] # ip_address and advertise_ip are yaml only - ip_address = conf.get(CONF_IP_ADDRESS, _DEFAULT_BIND) - advertise_ips: list[str] = conf.get( + ip_address: str | list[str] | None = conf.get(CONF_IP_ADDRESS, _DEFAULT_BIND) + advertise_ips: list[str] + advertise_ips = conf.get( CONF_ADVERTISE_IP ) or await network.async_get_announce_addresses(hass) @@ -344,13 +350,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: HomeKitConfigEntry) -> b # with users who have not migrated yet we do not do exclude # these entities by default as we cannot migrate automatically # since it requires a re-pairing. - exclude_accessory_mode = conf.get( + exclude_accessory_mode: bool = conf.get( CONF_EXCLUDE_ACCESSORY_MODE, DEFAULT_EXCLUDE_ACCESSORY_MODE ) - homekit_mode = options.get(CONF_HOMEKIT_MODE, DEFAULT_HOMEKIT_MODE) - entity_config = options.get(CONF_ENTITY_CONFIG, {}).copy() - entity_filter = FILTER_SCHEMA(options.get(CONF_FILTER, {})) - devices = options.get(CONF_DEVICES, []) + homekit_mode: str = options.get(CONF_HOMEKIT_MODE, DEFAULT_HOMEKIT_MODE) + entity_config: dict[str, Any] = options.get(CONF_ENTITY_CONFIG, {}).copy() + entity_filter: EntityFilter = FILTER_SCHEMA(options.get(CONF_FILTER, {})) + devices: list[str] = options.get(CONF_DEVICES, []) homekit = HomeKit( hass, @@ -500,26 +506,15 @@ def _async_register_events_and_services(hass: HomeAssistant) -> None: async def _handle_homekit_reload(service: ServiceCall) -> None: """Handle start HomeKit service call.""" config = await async_integration_yaml_config(hass, DOMAIN) - if not config or DOMAIN not in config: return - - current_entries = hass.config_entries.async_entries(DOMAIN) - entries_by_name, entries_by_port = _async_get_imported_entries_indices( - current_entries - ) - - for conf in config[DOMAIN]: - _async_update_config_entry_from_yaml( - hass, entries_by_name, entries_by_port, conf + _async_update_entries_from_yaml(hass, config, start_import_flow=False) + await asyncio.gather( + *( + create_eager_task(hass.config_entries.async_reload(entry.entry_id)) + for entry in hass.config_entries.async_entries(DOMAIN) ) - - reload_tasks = [ - create_eager_task(hass.config_entries.async_reload(entry.entry_id)) - for entry in current_entries - ] - - await asyncio.gather(*reload_tasks) + ) async_register_admin_service( hass, @@ -537,7 +532,7 @@ class HomeKit: hass: HomeAssistant, name: str, port: int, - ip_address: str | None, + ip_address: list[str] | str | None, entity_filter: EntityFilter, exclude_accessory_mode: bool, entity_config: dict[str, Any], From 46f4bc34342ebcd1294a1a7ecde3b9367ba95966 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 6 Mar 2025 10:02:16 +0100 Subject: [PATCH 2265/3148] Bump actions/attest-build-provenance from 2.2.2 to 2.2.3 (#139896) Bumps [actions/attest-build-provenance](https://github.com/actions/attest-build-provenance) from 2.2.2 to 2.2.3. - [Release notes](https://github.com/actions/attest-build-provenance/releases) - [Changelog](https://github.com/actions/attest-build-provenance/blob/main/RELEASE.md) - [Commits](https://github.com/actions/attest-build-provenance/compare/bd77c077858b8d561b7a36cbe48ef4cc642ca39d...c074443f1aee8d4aeeae555aebba3282517141b2) --- updated-dependencies: - dependency-name: actions/attest-build-provenance dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/builder.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index f3bdd0084af..346f90fbe4f 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -531,7 +531,7 @@ jobs: - name: Generate artifact attestation if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true' - uses: actions/attest-build-provenance@bd77c077858b8d561b7a36cbe48ef4cc642ca39d # v2.2.2 + uses: actions/attest-build-provenance@c074443f1aee8d4aeeae555aebba3282517141b2 # v2.2.3 with: subject-name: ${{ env.HASSFEST_IMAGE_NAME }} subject-digest: ${{ steps.push.outputs.digest }} From b44c26d324c334e24336caa08884cbd11be7ee40 Mon Sep 17 00:00:00 2001 From: Avi Miller Date: Thu, 6 Mar 2025 21:10:49 +1100 Subject: [PATCH 2266/3148] Bump aiolifx to 1.1.4 to enable new LIFX product support (#139897) Signed-off-by: Avi Miller --- homeassistant/components/lifx/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/lifx/manifest.json b/homeassistant/components/lifx/manifest.json index 8d460c25322..18b9457ebf4 100644 --- a/homeassistant/components/lifx/manifest.json +++ b/homeassistant/components/lifx/manifest.json @@ -51,7 +51,7 @@ "iot_class": "local_polling", "loggers": ["aiolifx", "aiolifx_effects", "bitstring"], "requirements": [ - "aiolifx==1.1.2", + "aiolifx==1.1.4", "aiolifx-effects==0.3.2", "aiolifx-themes==0.6.4" ] diff --git a/requirements_all.txt b/requirements_all.txt index 40f8f53a20a..3d2c38c5756 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -291,7 +291,7 @@ aiolifx-effects==0.3.2 aiolifx-themes==0.6.4 # homeassistant.components.lifx -aiolifx==1.1.2 +aiolifx==1.1.4 # homeassistant.components.lookin aiolookin==1.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a0134a18edd..8c5c3f4885d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -273,7 +273,7 @@ aiolifx-effects==0.3.2 aiolifx-themes==0.6.4 # homeassistant.components.lifx -aiolifx==1.1.2 +aiolifx==1.1.4 # homeassistant.components.lookin aiolookin==1.0.0 From 4f255439ebce57e1d430b7898a2c2bb3c625e900 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 6 Mar 2025 11:11:22 +0100 Subject: [PATCH 2267/3148] Fix sentence-casing in `music_assistant.get_library` action (#139901) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - make the casing of several words consistent - make the action's description consistent with HA style using "Retrieves …" instead of "Get …" --- homeassistant/components/music_assistant/strings.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/music_assistant/strings.json b/homeassistant/components/music_assistant/strings.json index 7338af7cb65..371ecdc3a86 100644 --- a/homeassistant/components/music_assistant/strings.json +++ b/homeassistant/components/music_assistant/strings.json @@ -139,8 +139,8 @@ } }, "get_library": { - "name": "Get Library items", - "description": "Get items from a Music Assistant library.", + "name": "Get library items", + "description": "Retrieves items from a Music Assistant library.", "fields": { "config_entry_id": { "name": "[%key:component::music_assistant::services::search::fields::config_entry_id::name%]", @@ -167,7 +167,7 @@ "description": "Offset to start the list from." }, "order_by": { - "name": "Order By", + "name": "Order by", "description": "Sort the list by this field." }, "album_type": { @@ -176,7 +176,7 @@ }, "album_artists_only": { "name": "Enable album artists filter (only for artist library)", - "description": "Only return Album Artists when listing the Artists library items." + "description": "Only return album artists when listing the artists library items." } } } From f2b07ea886cdde3fd8879d510f86967fb9c9a953 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Thu, 6 Mar 2025 11:23:10 +0100 Subject: [PATCH 2268/3148] Add support for IronOS v2.23 (#139903) Add support for IronOS 2.23 --- .../components/iron_os/coordinator.py | 6 ++ homeassistant/components/iron_os/icons.json | 17 ++++- homeassistant/components/iron_os/number.py | 25 +++++++- homeassistant/components/iron_os/select.py | 37 ++++++++++- homeassistant/components/iron_os/strings.json | 23 ++++++- tests/components/iron_os/conftest.py | 5 +- .../iron_os/snapshots/test_diagnostics.ambr | 2 +- .../iron_os/snapshots/test_number.ambr | 57 +++++++++++++++++ .../iron_os/snapshots/test_select.ambr | 62 +++++++++++++++++++ .../iron_os/snapshots/test_update.ambr | 2 +- tests/components/iron_os/test_init.py | 36 ++++++++++- tests/components/iron_os/test_number.py | 6 ++ tests/components/iron_os/test_select.py | 6 ++ 13 files changed, 273 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/iron_os/coordinator.py b/homeassistant/components/iron_os/coordinator.py index fc89ecea43c..84c9b895766 100644 --- a/homeassistant/components/iron_os/coordinator.py +++ b/homeassistant/components/iron_os/coordinator.py @@ -8,6 +8,7 @@ from enum import Enum import logging from typing import cast +from awesomeversion import AwesomeVersion from pynecil import ( CharSetting, CommunicationError, @@ -34,6 +35,8 @@ SCAN_INTERVAL = timedelta(seconds=5) SCAN_INTERVAL_GITHUB = timedelta(hours=3) SCAN_INTERVAL_SETTINGS = timedelta(seconds=60) +V223 = AwesomeVersion("v2.23") + @dataclass class IronOSCoordinators: @@ -72,6 +75,7 @@ class IronOSBaseCoordinator[_DataT](DataUpdateCoordinator[_DataT]): ), ) self.device = device + self.v223_features = False async def _async_setup(self) -> None: """Set up the coordinator.""" @@ -81,6 +85,8 @@ class IronOSBaseCoordinator[_DataT](DataUpdateCoordinator[_DataT]): except CommunicationError as e: raise UpdateFailed("Cannot connect to device") from e + self.v223_features = AwesomeVersion(self.device_info.build) >= V223 + class IronOSLiveDataCoordinator(IronOSBaseCoordinator[LiveDataResponse]): """IronOS coordinator.""" diff --git a/homeassistant/components/iron_os/icons.json b/homeassistant/components/iron_os/icons.json index 6410c561b9d..695b9d16849 100644 --- a/homeassistant/components/iron_os/icons.json +++ b/homeassistant/components/iron_os/icons.json @@ -73,6 +73,9 @@ }, "power_limit": { "default": "mdi:flash-alert" + }, + "hall_effect_sleep_time": { + "default": "mdi:timer-sand" } }, "select": { @@ -105,6 +108,9 @@ }, "usb_pd_mode": { "default": "mdi:meter-electric-outline" + }, + "tip_type": { + "default": "mdi:pencil-outline" } }, "sensor": { @@ -154,7 +160,16 @@ "soldering": "mdi:soldering-iron", "sleeping": "mdi:sleep", "settings": "mdi:menu-open", - "debug": "mdi:bug-play" + "debug": "mdi:bug-play", + "soldering_profile": "mdi:chart-box-outline", + "temperature_adjust": "mdi:thermostat-box", + "usb_pd_debug": "mdi:bug-play", + "thermal_runaway": "mdi:fire-alert", + "startup_logo": "mdi:dots-circle", + "cjc_calibration": "mdi:tune-vertical", + "startup_warnings": "mdi:alert", + "initialisation_done": "mdi:check-circle", + "hibernating": "mdi:sleep" } }, "estimated_power": { diff --git a/homeassistant/components/iron_os/number.py b/homeassistant/components/iron_os/number.py index b8bb3c7d999..6ad5947cb6f 100644 --- a/homeassistant/components/iron_os/number.py +++ b/homeassistant/components/iron_os/number.py @@ -65,6 +65,7 @@ class PinecilNumber(StrEnum): VOLTAGE_DIV = "voltage_div" TEMP_INCREMENT_SHORT = "temp_increment_short" TEMP_INCREMENT_LONG = "temp_increment_long" + HALL_EFFECT_SLEEP_TIME = "hall_effect_sleep_time" def multiply(value: float | None, multiplier: float) -> float | None: @@ -323,6 +324,23 @@ PINECIL_NUMBER_DESCRIPTIONS: tuple[IronOSNumberEntityDescription, ...] = ( ), ) +PINECIL_NUMBER_DESCRIPTIONS_V223: tuple[IronOSNumberEntityDescription, ...] = ( + IronOSNumberEntityDescription( + key=PinecilNumber.HALL_EFFECT_SLEEP_TIME, + translation_key=PinecilNumber.HALL_EFFECT_SLEEP_TIME, + value_fn=(lambda _, settings: settings.get("hall_sleep_time")), + characteristic=CharSetting.HALL_SLEEP_TIME, + raw_value_fn=lambda value: value, + mode=NumberMode.BOX, + native_min_value=0, + native_max_value=60, + native_step=5, + entity_category=EntityCategory.CONFIG, + native_unit_of_measurement=UnitOfTime.SECONDS, + entity_registry_enabled_default=False, + ), +) + async def async_setup_entry( hass: HomeAssistant, @@ -331,10 +349,13 @@ async def async_setup_entry( ) -> None: """Set up number entities from a config entry.""" coordinators = entry.runtime_data + descriptions = PINECIL_NUMBER_DESCRIPTIONS + + if coordinators.live_data.v223_features: + descriptions += PINECIL_NUMBER_DESCRIPTIONS_V223 async_add_entities( - IronOSNumberEntity(coordinators, description) - for description in PINECIL_NUMBER_DESCRIPTIONS + IronOSNumberEntity(coordinators, description) for description in descriptions ) diff --git a/homeassistant/components/iron_os/select.py b/homeassistant/components/iron_os/select.py index a005bf29af2..32652829531 100644 --- a/homeassistant/components/iron_os/select.py +++ b/homeassistant/components/iron_os/select.py @@ -17,6 +17,7 @@ from pynecil import ( ScrollSpeed, SettingsDataResponse, TempUnit, + TipType, USBPDMode, ) @@ -53,6 +54,7 @@ class PinecilSelect(StrEnum): LOCKING_MODE = "locking_mode" LOGO_DURATION = "logo_duration" USB_PD_MODE = "usb_pd_mode" + TIP_TYPE = "tip_type" def enum_to_str(enum: Enum | None) -> str | None: @@ -138,6 +140,8 @@ PINECIL_SELECT_DESCRIPTIONS: tuple[IronOSSelectEntityDescription, ...] = ( entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, ), +) +PINECIL_SELECT_DESCRIPTIONS_V222: tuple[IronOSSelectEntityDescription, ...] = ( IronOSSelectEntityDescription( key=PinecilSelect.USB_PD_MODE, translation_key=PinecilSelect.USB_PD_MODE, @@ -149,6 +153,27 @@ PINECIL_SELECT_DESCRIPTIONS: tuple[IronOSSelectEntityDescription, ...] = ( entity_registry_enabled_default=False, ), ) +PINECIL_SELECT_DESCRIPTIONS_V223: tuple[IronOSSelectEntityDescription, ...] = ( + IronOSSelectEntityDescription( + key=PinecilSelect.USB_PD_MODE, + translation_key=PinecilSelect.USB_PD_MODE, + characteristic=CharSetting.USB_PD_MODE, + value_fn=lambda x: enum_to_str(x.get("usb_pd_mode")), + raw_value_fn=lambda value: USBPDMode[value.upper()], + options=[x.name.lower() for x in USBPDMode], + entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, + ), + IronOSSelectEntityDescription( + key=PinecilSelect.TIP_TYPE, + translation_key=PinecilSelect.TIP_TYPE, + characteristic=CharSetting.TIP_TYPE, + value_fn=lambda x: enum_to_str(x.get("tip_type")), + raw_value_fn=lambda value: TipType[value.upper()], + options=[x.name.lower() for x in TipType], + entity_category=EntityCategory.CONFIG, + ), +) async def async_setup_entry( @@ -157,11 +182,17 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up select entities from a config entry.""" - coordinator = entry.runtime_data + coordinators = entry.runtime_data + descriptions = PINECIL_SELECT_DESCRIPTIONS + + descriptions += ( + PINECIL_SELECT_DESCRIPTIONS_V223 + if coordinators.live_data.v223_features + else PINECIL_SELECT_DESCRIPTIONS_V222 + ) async_add_entities( - IronOSSelectEntity(coordinator, description) - for description in PINECIL_SELECT_DESCRIPTIONS + IronOSSelectEntity(coordinators, description) for description in descriptions ) diff --git a/homeassistant/components/iron_os/strings.json b/homeassistant/components/iron_os/strings.json index 60168699427..ddae9a3020f 100644 --- a/homeassistant/components/iron_os/strings.json +++ b/homeassistant/components/iron_os/strings.json @@ -94,6 +94,9 @@ }, "temp_increment_long": { "name": "Long-press temperature step" + }, + "hall_effect_sleep_time": { + "name": "Hall sensor sleep timeout" } }, "select": { @@ -173,6 +176,15 @@ "off": "[%key:common::state::off%]", "on": "[%key:common::state::on%]" } + }, + "tip_type": { + "name": "Soldering tip type", + "state": { + "auto": "Auto sense", + "ts100_long": "TS100 long/Hakko T12 tip", + "pine_short": "Pinecil short tip", + "pts200": "PTS200 short tip" + } } }, "sensor": { @@ -223,7 +235,16 @@ "sleeping": "Sleeping", "settings": "Settings", "debug": "Debug", - "boost": "Boost" + "boost": "Boost", + "soldering_profile": "Soldering profile", + "temperature_adjust": "Temperature adjust", + "usb_pd_debug": "USB PD debug", + "thermal_runaway": "Thermal runaway", + "startup_logo": "Booting", + "cjc_calibration": "CJC calibration", + "startup_warnings": "Startup warnings", + "initialisation_done": "Initialisation done", + "hibernating": "Hibernating" } }, "estimated_power": { diff --git a/tests/components/iron_os/conftest.py b/tests/components/iron_os/conftest.py index 63c7d129987..bf8c756ebee 100644 --- a/tests/components/iron_os/conftest.py +++ b/tests/components/iron_os/conftest.py @@ -20,6 +20,7 @@ from pynecil import ( ScrollSpeed, SettingsDataResponse, TempUnit, + TipType, ) import pytest @@ -164,7 +165,7 @@ def mock_pynecil() -> Generator[AsyncMock]: client = mock_client.return_value client.get_device_info.return_value = DeviceInfoResponse( - build="v2.22", + build="v2.23", device_id="c0ffeeC0", address="c0:ff:ee:c0:ff:ee", device_sn="0000c0ffeec0ffee", @@ -205,6 +206,8 @@ def mock_pynecil() -> Generator[AsyncMock]: display_invert=True, calibrate_cjc=True, usb_pd_mode=True, + hall_sleep_time=5, + tip_type=TipType.PINE_SHORT, ) client.get_live_data.return_value = LiveDataResponse( live_temp=298, diff --git a/tests/components/iron_os/snapshots/test_diagnostics.ambr b/tests/components/iron_os/snapshots/test_diagnostics.ambr index f8db1262254..49cb3878b87 100644 --- a/tests/components/iron_os/snapshots/test_diagnostics.ambr +++ b/tests/components/iron_os/snapshots/test_diagnostics.ambr @@ -6,7 +6,7 @@ }), 'device_info': dict({ '__type': "", - 'repr': "DeviceInfoResponse(build='v2.22', device_id='c0ffeeC0', address='c0:ff:ee:c0:ff:ee', device_sn='0000c0ffeec0ffee', name='Pinecil-C0FFEEE', is_synced=False)", + 'repr': "DeviceInfoResponse(build='v2.23', device_id='c0ffeeC0', address='c0:ff:ee:c0:ff:ee', device_sn='0000c0ffeec0ffee', name='Pinecil-C0FFEEE', is_synced=False)", }), 'live_data': dict({ '__type': "", diff --git a/tests/components/iron_os/snapshots/test_number.ambr b/tests/components/iron_os/snapshots/test_number.ambr index 62fcd120201..b2ec7a70a92 100644 --- a/tests/components/iron_os/snapshots/test_number.ambr +++ b/tests/components/iron_os/snapshots/test_number.ambr @@ -226,6 +226,63 @@ 'state': '7', }) # --- +# name: test_state[number.pinecil_hall_sensor_sleep_timeout-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 60, + 'min': 0, + 'mode': , + 'step': 5, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.pinecil_hall_sensor_sleep_timeout', + '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': 'Hall sensor sleep timeout', + 'platform': 'iron_os', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'c0:ff:ee:c0:ff:ee_hall_effect_sleep_time', + 'unit_of_measurement': , + }) +# --- +# name: test_state[number.pinecil_hall_sensor_sleep_timeout-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Pinecil Hall sensor sleep timeout', + 'max': 60, + 'min': 0, + 'mode': , + 'step': 5, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.pinecil_hall_sensor_sleep_timeout', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5', + }) +# --- # name: test_state[number.pinecil_keep_awake_pulse_delay-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/iron_os/snapshots/test_select.ambr b/tests/components/iron_os/snapshots/test_select.ambr index 10aacc838df..540cab234a5 100644 --- a/tests/components/iron_os/snapshots/test_select.ambr +++ b/tests/components/iron_os/snapshots/test_select.ambr @@ -250,6 +250,7 @@ 'options': list([ 'off', 'on', + 'safe', ]), }), 'config_entry_id': , @@ -287,6 +288,7 @@ 'options': list([ 'off', 'on', + 'safe', ]), }), 'context': , @@ -415,6 +417,66 @@ 'state': 'fast', }) # --- +# name: test_state[select.pinecil_soldering_tip_type-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'auto', + 'ts100_long', + 'pine_short', + 'pts200', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.pinecil_soldering_tip_type', + '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': 'Soldering tip type', + 'platform': 'iron_os', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'c0:ff:ee:c0:ff:ee_tip_type', + 'unit_of_measurement': None, + }) +# --- +# name: test_state[select.pinecil_soldering_tip_type-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Pinecil Soldering tip type', + 'options': list([ + 'auto', + 'ts100_long', + 'pine_short', + 'pts200', + ]), + }), + 'context': , + 'entity_id': 'select.pinecil_soldering_tip_type', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'pine_short', + }) +# --- # name: test_state[select.pinecil_start_up_behavior-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/iron_os/snapshots/test_update.ambr b/tests/components/iron_os/snapshots/test_update.ambr index f2db3246158..fcd7196a70c 100644 --- a/tests/components/iron_os/snapshots/test_update.ambr +++ b/tests/components/iron_os/snapshots/test_update.ambr @@ -45,7 +45,7 @@ 'entity_picture': 'https://brands.home-assistant.io/_/iron_os/icon.png', 'friendly_name': 'Pinecil Firmware', 'in_progress': False, - 'installed_version': 'v2.22', + 'installed_version': 'v2.23', 'latest_version': 'v2.22', 'release_summary': None, 'release_url': 'https://github.com/Ralim/IronOS/releases/tag/v2.22', diff --git a/tests/components/iron_os/test_init.py b/tests/components/iron_os/test_init.py index 4749e1b6199..d1c596f4de5 100644 --- a/tests/components/iron_os/test_init.py +++ b/tests/components/iron_os/test_init.py @@ -4,13 +4,15 @@ from datetime import timedelta from unittest.mock import AsyncMock from freezegun.api import FrozenDateTimeFactory -from pynecil import CommunicationError +from pynecil import CommunicationError, DeviceInfoResponse import pytest from homeassistant.config_entries import ConfigEntryState from homeassistant.const import STATE_UNKNOWN from homeassistant.core import HomeAssistant +from .conftest import DEFAULT_NAME + from tests.common import MockConfigEntry, async_fire_time_changed @@ -89,3 +91,35 @@ async def test_settings_exception( assert (state := hass.states.get("number.pinecil_boost_temperature")) assert state.state == STATE_UNKNOWN + + +@pytest.mark.usefixtures( + "entity_registry_enabled_by_default", "mock_pynecil", "ble_device" +) +async def test_v223_entities_not_loaded( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_pynecil: AsyncMock, +) -> None: + """Test the new entities in IronOS v2.23 are not loaded on smaller versions.""" + + mock_pynecil.get_device_info.return_value = DeviceInfoResponse( + build="v2.22", + device_id="c0ffeeC0", + address="c0:ff:ee:c0:ff:ee", + device_sn="0000c0ffeec0ffee", + name=DEFAULT_NAME, + ) + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + assert hass.states.get("number.pinecil_hall_sensor_sleep_timeout") is None + assert hass.states.get("select.pinecil_soldering_tip_type") is None + assert ( + state := hass.states.get("select.pinecil_power_delivery_3_1_epr") + ) is not None + + assert len(state.attributes["options"]) == 2 diff --git a/tests/components/iron_os/test_number.py b/tests/components/iron_os/test_number.py index bdec922a88c..9a4ba53f338 100644 --- a/tests/components/iron_os/test_number.py +++ b/tests/components/iron_os/test_number.py @@ -138,6 +138,12 @@ async def test_state( ("number.pinecil_sleep_temperature", CharSetting.SLEEP_TEMP, 150, 150), ("number.pinecil_sleep_timeout", CharSetting.SLEEP_TIMEOUT, 5, 5), ("number.pinecil_voltage_divider", CharSetting.VOLTAGE_DIV, 600, 600), + ( + "number.pinecil_hall_sensor_sleep_timeout", + CharSetting.HALL_SLEEP_TIME, + 60, + 60, + ), ], ) @pytest.mark.usefixtures("entity_registry_enabled_by_default", "ble_device") diff --git a/tests/components/iron_os/test_select.py b/tests/components/iron_os/test_select.py index 8cc848dd4cb..5590bfc2ba6 100644 --- a/tests/components/iron_os/test_select.py +++ b/tests/components/iron_os/test_select.py @@ -16,6 +16,7 @@ from pynecil import ( ScreenOrientationMode, ScrollSpeed, TempUnit, + TipType, USBPDMode, ) import pytest @@ -111,6 +112,11 @@ async def test_state( "on", (CharSetting.USB_PD_MODE, USBPDMode.ON), ), + ( + "select.pinecil_soldering_tip_type", + "auto", + (CharSetting.TIP_TYPE, TipType.AUTO), + ), ], ) @pytest.mark.usefixtures("entity_registry_enabled_by_default", "ble_device") From 8bfffcbd296bfac2545884da7c312e6d563fdae0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 6 Mar 2025 00:24:56 -1000 Subject: [PATCH 2269/3148] Bump thermobeacon-ble to 0.8.1 (#139919) changelog: https://github.com/Bluetooth-Devices/thermobeacon-ble/compare/v0.8.0...v0.8.1 fixes #139917 --- homeassistant/components/thermobeacon/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/thermobeacon/manifest.json b/homeassistant/components/thermobeacon/manifest.json index e060cbd91bf..b231137d335 100644 --- a/homeassistant/components/thermobeacon/manifest.json +++ b/homeassistant/components/thermobeacon/manifest.json @@ -54,5 +54,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/thermobeacon", "iot_class": "local_push", - "requirements": ["thermobeacon-ble==0.8.0"] + "requirements": ["thermobeacon-ble==0.8.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 3d2c38c5756..cd2f8700256 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2890,7 +2890,7 @@ tessie-api==0.1.1 # tf-models-official==2.5.0 # homeassistant.components.thermobeacon -thermobeacon-ble==0.8.0 +thermobeacon-ble==0.8.1 # homeassistant.components.thermopro thermopro-ble==0.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8c5c3f4885d..6c522060143 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2327,7 +2327,7 @@ teslemetry-stream==0.6.10 tessie-api==0.1.1 # homeassistant.components.thermobeacon -thermobeacon-ble==0.8.0 +thermobeacon-ble==0.8.1 # homeassistant.components.thermopro thermopro-ble==0.11.0 From 83dd1af6d2bfc5d662c4ede364e328fd26c485be Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 6 Mar 2025 11:25:22 +0100 Subject: [PATCH 2270/3148] Drop report method from frame helper (#139920) * Drop report method from frame helper * Adjust test_prevent_flooding * Adjust test_report_missing_integration_frame * Adjust test_report_error_if_integration * Remove test_report --- homeassistant/helpers/frame.py | 49 ++---------- tests/helpers/snapshots/test_frame.ambr | 38 ---------- tests/helpers/test_frame.py | 99 ++----------------------- 3 files changed, 13 insertions(+), 173 deletions(-) diff --git a/homeassistant/helpers/frame.py b/homeassistant/helpers/frame.py index 3416c8d49f6..acdadb95788 100644 --- a/homeassistant/helpers/frame.py +++ b/homeassistant/helpers/frame.py @@ -150,44 +150,6 @@ class MissingIntegrationFrame(HomeAssistantError): """Raised when no integration is found in the frame.""" -def report( - what: str, - *, - exclude_integrations: set[str] | None = None, - error_if_core: bool = True, - error_if_integration: bool = False, - level: int = logging.WARNING, - log_custom_component_only: bool = False, -) -> None: - """Report incorrect usage. - - If error_if_core is True, raise instead of log if an integration is not found - when unwinding the stack frame. - If error_if_integration is True, raise instead of log if an integration is found - when unwinding the stack frame. - """ - core_behavior = ReportBehavior.ERROR if error_if_core else ReportBehavior.LOG - core_integration_behavior = ( - ReportBehavior.ERROR if error_if_integration else ReportBehavior.LOG - ) - custom_integration_behavior = core_integration_behavior - - if log_custom_component_only: - if core_behavior is ReportBehavior.LOG: - core_behavior = ReportBehavior.IGNORE - if core_integration_behavior is ReportBehavior.LOG: - core_integration_behavior = ReportBehavior.IGNORE - - report_usage( - what, - core_behavior=core_behavior, - core_integration_behavior=core_integration_behavior, - custom_integration_behavior=custom_integration_behavior, - exclude_integrations=exclude_integrations, - level=level, - ) - - class ReportBehavior(enum.Enum): """Enum for behavior on code usage.""" @@ -406,25 +368,26 @@ def warn_use[_CallableT: Callable](func: _CallableT, what: str) -> _CallableT: @functools.wraps(func) async def report_use(*args: Any, **kwargs: Any) -> None: - report(what) + report_usage(what) else: @functools.wraps(func) def report_use(*args: Any, **kwargs: Any) -> None: - report(what) + report_usage(what) return cast(_CallableT, report_use) def report_non_thread_safe_operation(what: str) -> None: """Report a non-thread safe operation.""" - report( + report_usage( f"calls {what} from a thread other than the event loop, " "which may cause Home Assistant to crash or data to corrupt. " "For more information, see " "https://developers.home-assistant.io/docs/asyncio_thread_safety/" f"#{what.replace('.', '')}", - error_if_core=True, - error_if_integration=True, + core_behavior=ReportBehavior.ERROR, + core_integration_behavior=ReportBehavior.ERROR, + custom_integration_behavior=ReportBehavior.ERROR, ) diff --git a/tests/helpers/snapshots/test_frame.ambr b/tests/helpers/snapshots/test_frame.ambr index abdaff6c1b7..e74a4b2947a 100644 --- a/tests/helpers/snapshots/test_frame.ambr +++ b/tests/helpers/snapshots/test_frame.ambr @@ -1,42 +1,4 @@ # serializer version: 1 -# name: test_report[core default] - list([ - ]) -# --- -# name: test_report[core integration default] - list([ - "Detected that integration 'test_core_integration' test_report_string at homeassistant/components/test_core_integration/light.py, line 23: self.light.is_on. Please create a bug report at https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue+label%3A%22integration%3A+test_core_integration%22", - ]) -# --- -# name: test_report[custom integration default] - list([ - "Detected that custom integration 'test_custom_integration' test_report_string at custom_components/test_custom_integration/light.py, line 23: self.light.is_on. Please report it to the author of the 'test_custom_integration' custom integration", - ]) -# --- -# name: test_report[disable error_if_core] - list([ - 'Detected code that test_report_string. Please report this issue', - ]) -# --- -# name: test_report[error_if_integration with core integration] - list([ - "Detected that integration 'test_integration_frame' test_report_string at homeassistant/components/test_integration_frame/light.py, line 23: self.light.is_on. Please create a bug report at https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue+label%3A%22integration%3A+test_integration_frame%22", - ]) -# --- -# name: test_report[error_if_integration with custom integration] - list([ - "Detected that custom integration 'test_integration_frame' test_report_string at custom_components/test_integration_frame/light.py, line 23: self.light.is_on. Please report it to the author of the 'test_integration_frame' custom integration", - ]) -# --- -# name: test_report[log_custom_component_only with core integration] - list([ - ]) -# --- -# name: test_report[log_custom_component_only with custom integration] - list([ - "Detected that custom integration 'test_integration_frame' test_report_string at custom_components/test_integration_frame/light.py, line 23: self.light.is_on. Please report it to the author of the 'test_integration_frame' custom integration", - ]) -# --- # name: test_report_usage[core default] list([ ]) diff --git a/tests/helpers/test_frame.py b/tests/helpers/test_frame.py index 9bec7cce996..6127761d69b 100644 --- a/tests/helpers/test_frame.py +++ b/tests/helpers/test_frame.py @@ -395,14 +395,14 @@ async def test_prevent_flooding( f"q=is%3Aopen+is%3Aissue+label%3A%22integration%3A+{integration}%22" ) - frame.report(what, error_if_core=False) + frame.report_usage(what, core_behavior=frame.ReportBehavior.LOG) assert expected_message in caplog.text assert key in frame._REPORTED_INTEGRATIONS assert len(frame._REPORTED_INTEGRATIONS) == 1 caplog.clear() - frame.report(what, error_if_core=False) + frame.report_usage(what, core_behavior=frame.ReportBehavior.LOG) assert expected_message not in caplog.text assert key in frame._REPORTED_INTEGRATIONS assert len(frame._REPORTED_INTEGRATIONS) == 1 @@ -442,13 +442,13 @@ async def test_report_missing_integration_frame( "homeassistant.helpers.frame.get_integration_frame", side_effect=frame.MissingIntegrationFrame, ): - frame.report(what, error_if_core=False) + frame.report_usage(what, core_behavior=frame.ReportBehavior.LOG) assert what in caplog.text assert caplog.text.count(what) == 1 caplog.clear() - frame.report(what, error_if_core=False, log_custom_component_only=True) + frame.report_usage(what, core_behavior=frame.ReportBehavior.IGNORE) assert caplog.text == "" @@ -492,94 +492,9 @@ async def test_report_error_if_integration( ), ), ): - frame.report("did a bad thing", error_if_integration=True) - - -@pytest.mark.parametrize( - ("integration_frame_path", "keywords", "expected_result", "expected_log"), - [ - pytest.param( - "homeassistant/test_core", - {}, - pytest.raises(RuntimeError, match="test_report_string"), - 0, - id="core default", - ), - pytest.param( - "homeassistant/components/test_core_integration", - {}, - does_not_raise(), - 1, - id="core integration default", - ), - pytest.param( - "custom_components/test_custom_integration", - {}, - does_not_raise(), - 1, - id="custom integration default", - ), - pytest.param( - "custom_components/test_integration_frame", - {"log_custom_component_only": True}, - does_not_raise(), - 1, - id="log_custom_component_only with custom integration", - ), - pytest.param( - "homeassistant/components/test_integration_frame", - {"log_custom_component_only": True}, - does_not_raise(), - 0, - id="log_custom_component_only with core integration", - ), - pytest.param( - "homeassistant/test_integration_frame", - {"error_if_core": False}, - does_not_raise(), - 1, - id="disable error_if_core", - ), - pytest.param( - "custom_components/test_integration_frame", - {"error_if_integration": True}, - pytest.raises(RuntimeError, match="test_report_string"), - 1, - id="error_if_integration with custom integration", - ), - pytest.param( - "homeassistant/components/test_integration_frame", - {"error_if_integration": True}, - pytest.raises(RuntimeError, match="test_report_string"), - 1, - id="error_if_integration with core integration", - ), - ], -) -@pytest.mark.usefixtures("hass", "mock_integration_frame") -async def test_report( - caplog: pytest.LogCaptureFixture, - snapshot: SnapshotAssertion, - keywords: dict[str, Any], - expected_result: AbstractContextManager, - expected_log: int, -) -> None: - """Test report. - - Note: This test doesn't set up mock integrations, so it will not - find the correct issue tracker URL, and we don't check for that. - """ - - what = "test_report_string" - - with patch.object(frame, "_REPORTED_INTEGRATIONS", set()), expected_result: - frame.report(what, **keywords) - - assert caplog.text.count(what) == expected_log - reports = [ - rec.message for rec in caplog.records if rec.message.startswith("Detected") - ] - assert reports == snapshot + frame.report_usage( + "did a bad thing", core_integration_behavior=frame.ReportBehavior.ERROR + ) @pytest.mark.parametrize( From 095b04caf9f12e6aab8e30764dd692cf8e36f97b Mon Sep 17 00:00:00 2001 From: Markus Adrario Date: Thu, 6 Mar 2025 12:20:22 +0100 Subject: [PATCH 2271/3148] Homee parallel updates (#139926) * set parallel updates to 0 * add platforms --- homeassistant/components/homee/button.py | 2 ++ homeassistant/components/homee/cover.py | 2 ++ homeassistant/components/homee/light.py | 2 ++ homeassistant/components/homee/number.py | 2 ++ homeassistant/components/homee/quality_scale.yaml | 2 +- homeassistant/components/homee/sensor.py | 2 ++ homeassistant/components/homee/switch.py | 2 ++ homeassistant/components/homee/valve.py | 2 ++ 8 files changed, 15 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/homee/button.py b/homeassistant/components/homee/button.py index af6d769c1dc..33a8b5f23c8 100644 --- a/homeassistant/components/homee/button.py +++ b/homeassistant/components/homee/button.py @@ -15,6 +15,8 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import HomeeConfigEntry from .entity import HomeeEntity +PARALLEL_UPDATES = 0 + BUTTON_DESCRIPTIONS: dict[AttributeType, ButtonEntityDescription] = { AttributeType.AUTOMATIC_MODE_IMPULSE: ButtonEntityDescription(key="automatic_mode"), AttributeType.BRIEFLY_OPEN_IMPULSE: ButtonEntityDescription(key="briefly_open"), diff --git a/homeassistant/components/homee/cover.py b/homeassistant/components/homee/cover.py index 6e7e4fd5c55..79a9b00ffba 100644 --- a/homeassistant/components/homee/cover.py +++ b/homeassistant/components/homee/cover.py @@ -21,6 +21,8 @@ from .entity import HomeeNodeEntity _LOGGER = logging.getLogger(__name__) +PARALLEL_UPDATES = 0 + OPEN_CLOSE_ATTRIBUTES = [ AttributeType.OPEN_CLOSE, AttributeType.SLAT_ROTATION_IMPULSE, diff --git a/homeassistant/components/homee/light.py b/homeassistant/components/homee/light.py index b9c4460075a..9c66764760e 100644 --- a/homeassistant/components/homee/light.py +++ b/homeassistant/components/homee/light.py @@ -32,6 +32,8 @@ LIGHT_ATTRIBUTES = [ AttributeType.DIMMING_LEVEL, ] +PARALLEL_UPDATES = 0 + def is_light_node(node: HomeeNode) -> bool: """Determine if a node is controllable as a homee light based on its profile and attributes.""" diff --git a/homeassistant/components/homee/number.py b/homeassistant/components/homee/number.py index 3f1f08a6618..5f76b826fcf 100644 --- a/homeassistant/components/homee/number.py +++ b/homeassistant/components/homee/number.py @@ -16,6 +16,8 @@ from . import HomeeConfigEntry from .const import HOMEE_UNIT_TO_HA_UNIT from .entity import HomeeEntity +PARALLEL_UPDATES = 0 + NUMBER_DESCRIPTIONS = { AttributeType.DOWN_POSITION: NumberEntityDescription( key="down_position", diff --git a/homeassistant/components/homee/quality_scale.yaml b/homeassistant/components/homee/quality_scale.yaml index ff99d177018..906218cf823 100644 --- a/homeassistant/components/homee/quality_scale.yaml +++ b/homeassistant/components/homee/quality_scale.yaml @@ -35,7 +35,7 @@ rules: entity-unavailable: done integration-owner: done log-when-unavailable: done - parallel-updates: todo + parallel-updates: done reauthentication-flow: todo test-coverage: todo diff --git a/homeassistant/components/homee/sensor.py b/homeassistant/components/homee/sensor.py index 410f87f2168..e65b73b4a67 100644 --- a/homeassistant/components/homee/sensor.py +++ b/homeassistant/components/homee/sensor.py @@ -27,6 +27,8 @@ from .const import ( from .entity import HomeeEntity, HomeeNodeEntity from .helpers import get_name_for_enum +PARALLEL_UPDATES = 0 + def get_open_close_value(attribute: HomeeAttribute) -> str | None: """Return the open/close value.""" diff --git a/homeassistant/components/homee/switch.py b/homeassistant/components/homee/switch.py index 86c7acdbf11..041b96963f1 100644 --- a/homeassistant/components/homee/switch.py +++ b/homeassistant/components/homee/switch.py @@ -20,6 +20,8 @@ from . import HomeeConfigEntry from .const import CLIMATE_PROFILES, LIGHT_PROFILES from .entity import HomeeEntity +PARALLEL_UPDATES = 0 + def get_device_class( attribute: HomeeAttribute, config_entry: HomeeConfigEntry diff --git a/homeassistant/components/homee/valve.py b/homeassistant/components/homee/valve.py index 9a4ff446a10..995716d7ef8 100644 --- a/homeassistant/components/homee/valve.py +++ b/homeassistant/components/homee/valve.py @@ -15,6 +15,8 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import HomeeConfigEntry from .entity import HomeeEntity +PARALLEL_UPDATES = 0 + VALVE_DESCRIPTIONS = { AttributeType.CURRENT_VALVE_POSITION: ValveEntityDescription( key="valve_position", From 052eed6bb361fd97d300e4093210dd4986fa8921 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 6 Mar 2025 12:20:53 +0100 Subject: [PATCH 2272/3148] Deduplicate climate modes in SmartThings (#139930) * Deduplicate climate modes in SmartThings * Deduplicate climate modes in SmartThings --- homeassistant/components/smartthings/climate.py | 1 + .../smartthings/fixtures/device_status/da_ac_rac_01001.json | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/smartthings/climate.py b/homeassistant/components/smartthings/climate.py index b2f8819601c..c2b44fc41f9 100644 --- a/homeassistant/components/smartthings/climate.py +++ b/homeassistant/components/smartthings/climate.py @@ -566,5 +566,6 @@ class SmartThingsAirConditioner(SmartThingsEntity, ClimateEntity): Capability.AIR_CONDITIONER_MODE, Attribute.SUPPORTED_AC_MODES ) if (state := AC_MODE_TO_STATE.get(mode)) is not None + if state not in modes ) return modes diff --git a/tests/components/smartthings/fixtures/device_status/da_ac_rac_01001.json b/tests/components/smartthings/fixtures/device_status/da_ac_rac_01001.json index 257d553cb9f..e8e71c53ace 100644 --- a/tests/components/smartthings/fixtures/device_status/da_ac_rac_01001.json +++ b/tests/components/smartthings/fixtures/device_status/da_ac_rac_01001.json @@ -32,7 +32,7 @@ "timestamp": "2025-02-09T14:35:56.800Z" }, "supportedAcModes": { - "value": ["auto", "cool", "dry", "wind", "heat"], + "value": ["auto", "cool", "dry", "wind", "heat", "dryClean"], "timestamp": "2025-02-09T15:42:13.444Z" }, "airConditionerMode": { From e06af94a1a4ac2a5bfa91be7d2faaa44d6b23a99 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 6 Mar 2025 12:22:36 +0100 Subject: [PATCH 2273/3148] Improve description of `tibber.get_prices` action (#139863) Replace with the description from the online docs which add the information that a price level is included. This also makes it consistent with the standard descriptive style in Home Assistant. --- homeassistant/components/tibber/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/tibber/strings.json b/homeassistant/components/tibber/strings.json index 05b98b97995..ec2c005d4e3 100644 --- a/homeassistant/components/tibber/strings.json +++ b/homeassistant/components/tibber/strings.json @@ -87,7 +87,7 @@ "services": { "get_prices": { "name": "Get energy prices", - "description": "Get hourly energy prices from Tibber", + "description": "Fetches hourly energy prices including price level.", "fields": { "start": { "name": "Start", From 6455daf092e178f038c94434ef55354c0e4d81a1 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Thu, 6 Mar 2025 12:30:42 +0100 Subject: [PATCH 2274/3148] Set Ondilo ICO diagnostic sensors (#139934) --- homeassistant/components/ondilo_ico/sensor.py | 3 +++ tests/components/ondilo_ico/snapshots/test_sensor.ambr | 8 ++++---- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/ondilo_ico/sensor.py b/homeassistant/components/ondilo_ico/sensor.py index ddc4a94853f..da5ccae11a5 100644 --- a/homeassistant/components/ondilo_ico/sensor.py +++ b/homeassistant/components/ondilo_ico/sensor.py @@ -12,6 +12,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONCENTRATION_PARTS_PER_MILLION, PERCENTAGE, + EntityCategory, UnitOfElectricPotential, UnitOfTemperature, ) @@ -56,12 +57,14 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( key="battery", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, + entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="rssi", translation_key="rssi", native_unit_of_measurement=PERCENTAGE, + entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( diff --git a/tests/components/ondilo_ico/snapshots/test_sensor.ambr b/tests/components/ondilo_ico/snapshots/test_sensor.ambr index 84a2d3da4cb..7df2bfc22ce 100644 --- a/tests/components/ondilo_ico/snapshots/test_sensor.ambr +++ b/tests/components/ondilo_ico/snapshots/test_sensor.ambr @@ -13,7 +13,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.pool_1_battery', 'has_entity_name': True, 'hidden_by': None, @@ -167,7 +167,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.pool_1_rssi', 'has_entity_name': True, 'hidden_by': None, @@ -372,7 +372,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.pool_2_battery', 'has_entity_name': True, 'hidden_by': None, @@ -526,7 +526,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.pool_2_rssi', 'has_entity_name': True, 'hidden_by': None, From 47919fe7e97a8bca005277941967102ee9664e88 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Thu, 6 Mar 2025 12:56:46 +0100 Subject: [PATCH 2275/3148] Simplify lint-only config (2) [ci] (#139933) --- .github/workflows/ci.yaml | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index f8f14f2a126..9ef851009f6 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -197,7 +197,9 @@ jobs: if [[ "${{ github.event.inputs.lint-only }}" == "true" ]] \ || [[ "${{ github.event.inputs.pylint-only }}" == "true" ]] \ || [[ "${{ github.event.inputs.mypy-only }}" == "true" ]] \ - || [[ "${{ github.event.inputs.audit-licenses-only }}" == "true" ]]; + || [[ "${{ github.event.inputs.audit-licenses-only }}" == "true" ]] \ + || [[ "${{ github.event_name }}" == "push" \ + && "${{ github.event.repository.full_name }}" != "home-assistant/core" ]]; then lint_only="true" skip_coverage="true" @@ -842,8 +844,7 @@ jobs: prepare-pytest-full: runs-on: ubuntu-24.04 if: | - (github.event_name != 'push' || github.event.repository.full_name == 'home-assistant/core') - && needs.info.outputs.lint_only != 'true' + needs.info.outputs.lint_only != 'true' && needs.info.outputs.test_full_suite == 'true' needs: - info @@ -896,8 +897,7 @@ jobs: pytest-full: runs-on: ubuntu-24.04 if: | - (github.event_name != 'push' || github.event.repository.full_name == 'home-assistant/core') - && needs.info.outputs.lint_only != 'true' + needs.info.outputs.lint_only != 'true' && needs.info.outputs.test_full_suite == 'true' needs: - info @@ -1023,8 +1023,7 @@ jobs: MYSQL_ROOT_PASSWORD: password options: --health-cmd="mysqladmin ping -uroot -ppassword" --health-interval=5s --health-timeout=2s --health-retries=3 if: | - (github.event_name != 'push' || github.event.repository.full_name == 'home-assistant/core') - && needs.info.outputs.lint_only != 'true' + needs.info.outputs.lint_only != 'true' && needs.info.outputs.mariadb_groups != '[]' needs: - info @@ -1156,8 +1155,7 @@ jobs: POSTGRES_PASSWORD: password options: --health-cmd="pg_isready -hlocalhost -Upostgres" --health-interval=5s --health-timeout=2s --health-retries=3 if: | - (github.event_name != 'push' || github.event.repository.full_name == 'home-assistant/core') - && needs.info.outputs.lint_only != 'true' + needs.info.outputs.lint_only != 'true' && needs.info.outputs.postgresql_groups != '[]' needs: - info @@ -1309,8 +1307,7 @@ jobs: pytest-partial: runs-on: ubuntu-24.04 if: | - (github.event_name != 'push' || github.event.repository.full_name == 'home-assistant/core') - && needs.info.outputs.lint_only != 'true' + needs.info.outputs.lint_only != 'true' && needs.info.outputs.tests_glob && needs.info.outputs.test_full_suite == 'false' needs: From c51e644203a0c93a791bc0d342d14a56728078fc Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 6 Mar 2025 13:16:50 +0100 Subject: [PATCH 2276/3148] Prioritize integration_domain passed to helper.frame.report_usage (#139819) * Prioritize integration_domain passed to helper.frame.report_usage * Update tests * Update tests * Improve docstring * Rename according to suggestion --- homeassistant/helpers/frame.py | 60 ++++++++++++------- .../alarm_control_panel/test_init.py | 22 +++---- tests/components/vacuum/test_init.py | 3 + tests/helpers/test_frame.py | 8 +-- 4 files changed, 58 insertions(+), 35 deletions(-) diff --git a/homeassistant/helpers/frame.py b/homeassistant/helpers/frame.py index acdadb95788..ca7b097d90d 100644 --- a/homeassistant/helpers/frame.py +++ b/homeassistant/helpers/frame.py @@ -180,8 +180,8 @@ def report_usage( breaking version :param exclude_integrations: skip specified integration when reviewing the stack. If no integration is found, the core behavior will be applied - :param integration_domain: fallback for identifying the integration if the - frame is not found + :param integration_domain: domain of the integration causing the issue. If None, the + stack frame will be searched to identify the integration causing the issue. """ if (hass := _hass.hass) is None: raise RuntimeError("Frame helper not set up") @@ -220,13 +220,9 @@ def _report_usage( Must be called from the event loop. """ - try: - integration_frame = get_integration_frame( - exclude_integrations=exclude_integrations - ) - except MissingIntegrationFrame as err: + if integration_domain: if integration := async_get_issue_integration(hass, integration_domain): - _report_integration_domain( + _report_usage_integration_domain( hass, what, breaks_in_ha_version, @@ -236,16 +232,15 @@ def _report_usage( level, ) return - msg = f"Detected code that {what}. Please report this issue" - if core_behavior is ReportBehavior.ERROR: - raise RuntimeError(msg) from err - if core_behavior is ReportBehavior.LOG: - if breaks_in_ha_version: - msg = ( - f"Detected code that {what}. This will stop working in Home " - f"Assistant {breaks_in_ha_version}, please report this issue" - ) - _LOGGER.warning(msg, stack_info=True) + _report_usage_no_integration(what, core_behavior, breaks_in_ha_version, None) + return + + try: + integration_frame = get_integration_frame( + exclude_integrations=exclude_integrations + ) + except MissingIntegrationFrame as err: + _report_usage_no_integration(what, core_behavior, breaks_in_ha_version, err) return integration_behavior = core_integration_behavior @@ -253,7 +248,7 @@ def _report_usage( integration_behavior = custom_integration_behavior if integration_behavior is not ReportBehavior.IGNORE: - _report_integration_frame( + _report_usage_integration_frame( hass, what, breaks_in_ha_version, @@ -263,7 +258,7 @@ def _report_usage( ) -def _report_integration_domain( +def _report_usage_integration_domain( hass: HomeAssistant | None, what: str, breaks_in_ha_version: str | None, @@ -313,7 +308,7 @@ def _report_integration_domain( ) -def _report_integration_frame( +def _report_usage_integration_frame( hass: HomeAssistant, what: str, breaks_in_ha_version: str | None, @@ -362,6 +357,29 @@ def _report_integration_frame( ) +def _report_usage_no_integration( + what: str, + core_behavior: ReportBehavior, + breaks_in_ha_version: str | None, + err: MissingIntegrationFrame | None, +) -> None: + """Report incorrect usage without an integration. + + This could happen because the offending call happened outside of an integration, + or because the integration could not be identified. + """ + msg = f"Detected code that {what}. Please report this issue" + if core_behavior is ReportBehavior.ERROR: + raise RuntimeError(msg) from err + if core_behavior is ReportBehavior.LOG: + if breaks_in_ha_version: + msg = ( + f"Detected code that {what}. This will stop working in Home " + f"Assistant {breaks_in_ha_version}, please report this issue" + ) + _LOGGER.warning(msg, stack_info=True) + + def warn_use[_CallableT: Callable](func: _CallableT, what: str) -> _CallableT: """Mock a function to warn when it was about to be used.""" if asyncio.iscoroutinefunction(func): diff --git a/tests/components/alarm_control_panel/test_init.py b/tests/components/alarm_control_panel/test_init.py index 168d7ecc269..747a9d1a358 100644 --- a/tests/components/alarm_control_panel/test_init.py +++ b/tests/components/alarm_control_panel/test_init.py @@ -292,13 +292,13 @@ async def test_alarm_control_panel_log_deprecated_state_warning_using_state_prop assert state is not None assert ( - "Detected that custom integration 'alarm_control_panel' is setting state" - " directly. Entity None (.MockLegacyAlarmControlPanel'>) should implement" " the 'alarm_state' property and return its state using the AlarmControlPanelState" - " enum at test_init.py, line 123: yield. This will stop working in Home Assistant" - " 2025.11, please create a bug report at" in caplog.text + " enum. This will stop working in Home Assistant 2025.11, please report it to" + " the author of the 'test' custom integration" in caplog.text ) @@ -345,6 +345,7 @@ async def test_alarm_control_panel_log_deprecated_state_warning_using_attr_state async_setup_entry=help_async_setup_entry_init, async_unload_entry=help_async_unload_entry, ), + built_in=False, ) setup_test_component_platform( hass, ALARM_CONTROL_PANEL_DOMAIN, [entity], from_config_entry=True @@ -355,7 +356,7 @@ async def test_alarm_control_panel_log_deprecated_state_warning_using_attr_state assert state is not None assert ( - "Detected that custom integration 'alarm_control_panel' is setting state directly." + "Detected that custom integration 'test' is setting state directly." not in caplog.text ) @@ -364,14 +365,14 @@ async def test_alarm_control_panel_log_deprecated_state_warning_using_attr_state ) assert ( - "Detected that custom integration 'alarm_control_panel' is setting state directly." + "Detected that custom integration 'test' is setting state directly." " Entity alarm_control_panel.test_alarm_control_panel" " (.MockLegacyAlarmControlPanel'>) should implement the 'alarm_state' property" - " and return its state using the AlarmControlPanelState enum at test_init.py, line 123:" - " yield. This will stop working in Home Assistant 2025.11," - " please create a bug report at" in caplog.text + " and return its state using the AlarmControlPanelState enum. " + "This will stop working in Home Assistant 2025.11, please report " + "it to the author of the 'test' custom integration" in caplog.text ) caplog.clear() await help_test_async_alarm_control_panel_service( @@ -379,7 +380,7 @@ async def test_alarm_control_panel_log_deprecated_state_warning_using_attr_state ) # Test we only log once assert ( - "Detected that custom integration 'alarm_control_panel' is setting state directly." + "Detected that custom integration 'test' is setting state directly." not in caplog.text ) @@ -428,6 +429,7 @@ async def test_alarm_control_panel_deprecated_state_does_not_break_state( async_setup_entry=help_async_setup_entry_init, async_unload_entry=help_async_unload_entry, ), + built_in=False, ) setup_test_component_platform( hass, ALARM_CONTROL_PANEL_DOMAIN, [entity], from_config_entry=True diff --git a/tests/components/vacuum/test_init.py b/tests/components/vacuum/test_init.py index 5735d557288..717a69470b3 100644 --- a/tests/components/vacuum/test_init.py +++ b/tests/components/vacuum/test_init.py @@ -356,6 +356,7 @@ async def test_vacuum_log_deprecated_state_warning_using_state_prop( async_setup_entry=help_async_setup_entry_init, async_unload_entry=help_async_unload_entry, ), + built_in=False, ) setup_test_component_platform(hass, VACUUM_DOMAIN, [entity], from_config_entry=True) assert await hass.config_entries.async_setup(config_entry.entry_id) @@ -399,6 +400,7 @@ async def test_vacuum_log_deprecated_state_warning_using_attr_state_attr( async_setup_entry=help_async_setup_entry_init, async_unload_entry=help_async_unload_entry, ), + built_in=False, ) setup_test_component_platform(hass, VACUUM_DOMAIN, [entity], from_config_entry=True) assert await hass.config_entries.async_setup(config_entry.entry_id) @@ -463,6 +465,7 @@ async def test_vacuum_deprecated_state_does_not_break_state( async_setup_entry=help_async_setup_entry_init, async_unload_entry=help_async_unload_entry, ), + built_in=False, ) setup_test_component_platform(hass, VACUUM_DOMAIN, [entity], from_config_entry=True) assert await hass.config_entries.async_setup(config_entry.entry_id) diff --git a/tests/helpers/test_frame.py b/tests/helpers/test_frame.py index 6127761d69b..6a509ffae5c 100644 --- a/tests/helpers/test_frame.py +++ b/tests/helpers/test_frame.py @@ -538,21 +538,21 @@ async def test_report_error_if_integration( False, id="custom integration", ), - # Assert integration found in stack frame has priority over integration_domain + # Assert integration_domain has priority over integration found in stack frame pytest.param( "core_integration_behavior", "sensor", "homeassistant/components/hue", - "that integration 'hue'", + "that integration 'sensor'", False, id="core integration stack mismatch", ), - # Assert integration found in stack frame has priority over integration_domain + # Assert integration_domain has priority over integration found in stack frame pytest.param( "custom_integration_behavior", "test_package", "custom_components/hue", - "that custom integration 'hue'", + "that custom integration 'test_package'", False, id="custom integration stack mismatch", ), From edc763b7d29d7d84796fd0811ecc8addbabddf65 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 6 Mar 2025 13:22:49 +0100 Subject: [PATCH 2277/3148] Bump pysmartthings to 2.6.1 (#139936) * Bump pysmartthings to 2.6.1 * Bump pysmartthings to 2.6.1 --- homeassistant/components/smartthings/entity.py | 4 +++- homeassistant/components/smartthings/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../fixtures/devices/da_ac_rac_000001.json | 14 +++----------- .../smartthings/snapshots/test_init.ambr | 6 +++--- 6 files changed, 12 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/smartthings/entity.py b/homeassistant/components/smartthings/entity.py index 542401109ad..c2637174a5c 100644 --- a/homeassistant/components/smartthings/entity.py +++ b/homeassistant/components/smartthings/entity.py @@ -60,7 +60,9 @@ class SmartThingsEntity(Entity): self._attr_device_info.update( { "manufacturer": ocf.manufacturer_name, - "model": ocf.model_number.split("|")[0], + "model": ( + (ocf.model_number.split("|")[0]) if ocf.model_number else None + ), "hw_version": ocf.hardware_version, "sw_version": ocf.firmware_version, } diff --git a/homeassistant/components/smartthings/manifest.json b/homeassistant/components/smartthings/manifest.json index 22926e70ba0..9efa8b81186 100644 --- a/homeassistant/components/smartthings/manifest.json +++ b/homeassistant/components/smartthings/manifest.json @@ -29,5 +29,5 @@ "documentation": "https://www.home-assistant.io/integrations/smartthings", "iot_class": "cloud_push", "loggers": ["pysmartthings"], - "requirements": ["pysmartthings==2.5.0"] + "requirements": ["pysmartthings==2.6.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index cd2f8700256..7f8bede248b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2310,7 +2310,7 @@ pysma==0.7.5 pysmappee==0.2.29 # homeassistant.components.smartthings -pysmartthings==2.5.0 +pysmartthings==2.6.1 # homeassistant.components.smarty pysmarty2==0.10.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6c522060143..6a04b6d4337 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1882,7 +1882,7 @@ pysma==0.7.5 pysmappee==0.2.29 # homeassistant.components.smartthings -pysmartthings==2.5.0 +pysmartthings==2.6.1 # homeassistant.components.smarty pysmarty2==0.10.2 diff --git a/tests/components/smartthings/fixtures/devices/da_ac_rac_000001.json b/tests/components/smartthings/fixtures/devices/da_ac_rac_000001.json index d831e15a86b..cc4e13784bf 100644 --- a/tests/components/smartthings/fixtures/devices/da_ac_rac_000001.json +++ b/tests/components/smartthings/fixtures/devices/da_ac_rac_000001.json @@ -286,18 +286,10 @@ "id": "60fbc713-8da5-315d-b31a-6d6dcde4be7b" }, "ocf": { - "ocfDeviceType": "oic.d.airconditioner", - "name": "[room a/c] Samsung", - "specVersion": "core.1.1.0", - "verticalDomainSpecVersion": "res.1.1.0,sh.1.1.0", + "ocfDeviceType": "x.com.st.d.sensor.light", "manufacturerName": "Samsung Electronics", - "modelNumber": "ARTIK051_KRAC_18K|10193441|60010132001111110200000000000000", - "platformVersion": "0G3MPDCKA00010E", - "platformOS": "TizenRT2.0", - "hwVersion": "1.0", - "firmwareVersion": "0.1.0", - "vendorId": "DA-AC-RAC-000001", - "lastSignupTime": "2021-04-06T16:43:27.889445Z", + "vendorId": "VD-Sensor.Light-2023", + "lastSignupTime": "2025-01-08T02:32:04.631093137Z", "transferCandidate": false, "additionalAuthCodeRequired": false }, diff --git a/tests/components/smartthings/snapshots/test_init.ambr b/tests/components/smartthings/snapshots/test_init.ambr index fb856ae32d6..12745ea8f2c 100644 --- a/tests/components/smartthings/snapshots/test_init.ambr +++ b/tests/components/smartthings/snapshots/test_init.ambr @@ -207,7 +207,7 @@ }), 'disabled_by': None, 'entry_type': None, - 'hw_version': '1.0', + 'hw_version': None, 'id': , 'identifiers': set({ tuple( @@ -219,14 +219,14 @@ 'labels': set({ }), 'manufacturer': 'Samsung Electronics', - 'model': 'ARTIK051_KRAC_18K', + 'model': None, 'model_id': None, 'name': 'AC Office Granit', 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, 'suggested_area': 'Theater', - 'sw_version': '0.1.0', + 'sw_version': None, 'via_device_id': None, }) # --- From 5d8e03c1242750387a500110f507c999443dd633 Mon Sep 17 00:00:00 2001 From: marc7s <34547876+marc7s@users.noreply.github.com> Date: Thu, 6 Mar 2025 13:29:30 +0100 Subject: [PATCH 2278/3148] Update geocachingapi to v0.3.0 (#139878) Bump Geocaching API version Co-authored-by: Franck Nijhof --- homeassistant/components/geocaching/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/geocaching/manifest.json b/homeassistant/components/geocaching/manifest.json index 127519ca5d0..4617bd1c57b 100644 --- a/homeassistant/components/geocaching/manifest.json +++ b/homeassistant/components/geocaching/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["application_credentials"], "documentation": "https://www.home-assistant.io/integrations/geocaching", "iot_class": "cloud_polling", - "requirements": ["geocachingapi==0.2.1"] + "requirements": ["geocachingapi==0.3.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 7f8bede248b..443a40c64b3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -986,7 +986,7 @@ gcal-sync==7.0.0 geniushub-client==0.7.1 # homeassistant.components.geocaching -geocachingapi==0.2.1 +geocachingapi==0.3.0 # homeassistant.components.aprs geopy==2.3.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6a04b6d4337..770e19df3f1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -839,7 +839,7 @@ gcal-sync==7.0.0 geniushub-client==0.7.1 # homeassistant.components.geocaching -geocachingapi==0.2.1 +geocachingapi==0.3.0 # homeassistant.components.aprs geopy==2.3.0 From 5d7b60e4c82d23abf00fde81cbf32d6589f6f068 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Thu, 6 Mar 2025 13:30:02 +0100 Subject: [PATCH 2279/3148] Bump aiowebdav2 to 0.4.0 (#139938) --- homeassistant/components/webdav/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/webdav/manifest.json b/homeassistant/components/webdav/manifest.json index b4950bc23f3..3f465ceed4a 100644 --- a/homeassistant/components/webdav/manifest.json +++ b/homeassistant/components/webdav/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["aiowebdav2"], "quality_scale": "bronze", - "requirements": ["aiowebdav2==0.3.1"] + "requirements": ["aiowebdav2==0.4.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 443a40c64b3..e9fcd5f577b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -422,7 +422,7 @@ aiowaqi==3.1.0 aiowatttime==0.1.1 # homeassistant.components.webdav -aiowebdav2==0.3.1 +aiowebdav2==0.4.0 # homeassistant.components.webostv aiowebostv==0.7.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 770e19df3f1..b0ab760f963 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -404,7 +404,7 @@ aiowaqi==3.1.0 aiowatttime==0.1.1 # homeassistant.components.webdav -aiowebdav2==0.3.1 +aiowebdav2==0.4.0 # homeassistant.components.webostv aiowebostv==0.7.3 From 485da61d3c5f46d143e94610dbcf96484fe9c4fc Mon Sep 17 00:00:00 2001 From: Ishima Date: Thu, 6 Mar 2025 13:42:23 +0100 Subject: [PATCH 2280/3148] Check support for demand load control in SmartThings AC (#139616) * Check support for demand load control in SmartThings AC * Fix --------- Co-authored-by: Joostlek --- .../components/smartthings/climate.py | 5 +- tests/components/smartthings/conftest.py | 1 + .../device_status/da_ac_rac_100001.json | 167 ++++++++++++++ .../fixtures/devices/da_ac_rac_100001.json | 112 ++++++++++ .../smartthings/snapshots/test_climate.ambr | 84 +++++++ .../smartthings/snapshots/test_init.ambr | 33 +++ .../smartthings/snapshots/test_sensor.ambr | 207 ++++++++++++++++++ 7 files changed, 608 insertions(+), 1 deletion(-) create mode 100644 tests/components/smartthings/fixtures/device_status/da_ac_rac_100001.json create mode 100644 tests/components/smartthings/fixtures/devices/da_ac_rac_100001.json diff --git a/homeassistant/components/smartthings/climate.py b/homeassistant/components/smartthings/climate.py index c2b44fc41f9..b19d65db867 100644 --- a/homeassistant/components/smartthings/climate.py +++ b/homeassistant/components/smartthings/climate.py @@ -451,12 +451,15 @@ class SmartThingsAirConditioner(SmartThingsEntity, ClimateEntity): ) @property - def extra_state_attributes(self) -> dict[str, Any]: + def extra_state_attributes(self) -> dict[str, Any] | None: """Return device specific state attributes. Include attributes from the Demand Response Load Control (drlc) and Power Consumption capabilities. """ + if not self.supports_capability(Capability.DEMAND_RESPONSE_LOAD_CONTROL): + return None + drlc_status = self.get_attribute_value( Capability.DEMAND_RESPONSE_LOAD_CONTROL, Attribute.DEMAND_RESPONSE_LOAD_CONTROL_STATUS, diff --git a/tests/components/smartthings/conftest.py b/tests/components/smartthings/conftest.py index a47f32d3a8b..c9a74862187 100644 --- a/tests/components/smartthings/conftest.py +++ b/tests/components/smartthings/conftest.py @@ -88,6 +88,7 @@ def mock_smartthings() -> Generator[AsyncMock]: @pytest.fixture( params=[ "da_ac_rac_000001", + "da_ac_rac_100001", "da_ac_rac_01001", "multipurpose_sensor", "contact_sensor", diff --git a/tests/components/smartthings/fixtures/device_status/da_ac_rac_100001.json b/tests/components/smartthings/fixtures/device_status/da_ac_rac_100001.json new file mode 100644 index 00000000000..305624e5b3b --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/da_ac_rac_100001.json @@ -0,0 +1,167 @@ +{ + "components": { + "main": { + "refresh": {}, + "custom.thermostatSetpointControl": { + "minimumSetpoint": { + "value": 16, + "timestamp": "2024-11-25T22:17:38.251Z" + }, + "maximumSetpoint": { + "value": 30, + "timestamp": "2024-11-25T22:17:38.251Z" + } + }, + "airConditionerMode": { + "availableAcModes": { + "value": null + }, + "supportedAcModes": { + "value": ["cool", "dry", "wind", "auto"], + "timestamp": "2025-03-02T10:16:19.519Z" + }, + "airConditionerMode": { + "value": "cool", + "timestamp": "2025-03-02T10:16:19.519Z" + } + }, + "execute": { + "data": { + "value": null + } + }, + "airQualitySensor": { + "airQuality": { + "value": null + } + }, + "switch": { + "switch": { + "value": "off", + "timestamp": "2025-03-02T06:54:52.852Z" + } + }, + "ocf": { + "st": { + "value": null + }, + "mndt": { + "value": null + }, + "mnfv": { + "value": null + }, + "mnhw": { + "value": null + }, + "di": { + "value": "F8042E25-0E53-0000-0000-000000000000", + "timestamp": "2025-02-28T21:15:28.920Z" + }, + "mnsl": { + "value": null + }, + "dmv": { + "value": "res.1.1.0,sh.1.1.0", + "timestamp": "2025-02-28T21:15:28.920Z" + }, + "n": { + "value": "Room A/C", + "timestamp": "2025-02-28T21:15:28.920Z" + }, + "mnmo": { + "value": "TP6X_RAC_15K", + "timestamp": "2025-02-28T21:15:28.920Z" + }, + "vid": { + "value": "DA-AC-RAC-100001", + "timestamp": "2025-02-28T21:15:28.920Z" + }, + "mnmn": { + "value": "Samsung Electronics", + "timestamp": "2025-02-28T21:15:28.920Z" + }, + "mnml": { + "value": null + }, + "mnpv": { + "value": null + }, + "mnos": { + "value": null + }, + "pi": { + "value": "shp", + "timestamp": "2025-02-28T21:15:28.920Z" + }, + "icv": { + "value": "core.1.1.0", + "timestamp": "2025-02-28T21:15:28.920Z" + } + }, + "odorSensor": { + "odorLevel": { + "value": null + } + }, + "airConditionerFanMode": { + "fanMode": { + "value": "auto", + "timestamp": "2025-02-28T21:15:28.941Z" + }, + "supportedAcFanModes": { + "value": ["auto", "low", "medium", "high", "turbo"], + "timestamp": "2025-02-28T21:15:28.941Z" + }, + "availableAcFanModes": { + "value": null + } + }, + "samsungce.driverState": { + "driverState": { + "value": null + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": ["odorSensor"], + "timestamp": "2024-11-25T22:17:38.251Z" + } + }, + "samsungce.driverVersion": { + "versionNumber": { + "value": 22090101, + "timestamp": "2024-11-25T22:17:38.251Z" + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": 27, + "unit": "C", + "timestamp": "2025-03-02T08:28:39.409Z" + } + }, + "dustSensor": { + "dustLevel": { + "value": null + }, + "fineDustLevel": { + "value": null + } + }, + "thermostatCoolingSetpoint": { + "coolingSetpointRange": { + "value": null + }, + "coolingSetpoint": { + "value": 18, + "unit": "C", + "timestamp": "2025-03-02T06:54:23.887Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/devices/da_ac_rac_100001.json b/tests/components/smartthings/fixtures/devices/da_ac_rac_100001.json new file mode 100644 index 00000000000..3938ffc9d9b --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/da_ac_rac_100001.json @@ -0,0 +1,112 @@ +{ + "items": [ + { + "deviceId": "F8042E25-0E53-0000-0000-000000000000", + "name": "Room A/C", + "label": "Corridor A/C", + "manufacturerName": "Samsung Electronics", + "presentationId": "DA-AC-RAC-100001", + "deviceManufacturerCode": "Samsung Electronics", + "locationId": "5df0730b-38ed-43e4-b291-ec14feb3224c", + "ownerId": "63b9c79b-90fe-5262-9a6a-5e24db90915e", + "roomId": "7715151d-0314-457a-a82c-5ce48900e065", + "deviceTypeName": "Samsung OCF Air Conditioner", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "ocf", + "version": 1 + }, + { + "id": "switch", + "version": 1 + }, + { + "id": "airConditionerMode", + "version": 1 + }, + { + "id": "airConditionerFanMode", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "thermostatCoolingSetpoint", + "version": 1 + }, + { + "id": "airQualitySensor", + "version": 1 + }, + { + "id": "dustSensor", + "version": 1 + }, + { + "id": "odorSensor", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "execute", + "version": 1 + }, + { + "id": "samsungce.driverVersion", + "version": 1 + }, + { + "id": "samsungce.driverState", + "version": 1 + }, + { + "id": "custom.thermostatSetpointControl", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + } + ], + "categories": [ + { + "name": "AirConditioner", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2024-11-25T22:17:38.129Z", + "profile": { + "id": "9e3e03b1-7f8c-3ea2-8568-6902b79b99dd" + }, + "ocf": { + "ocfDeviceType": "oic.d.airconditioner", + "name": "Room A/C", + "specVersion": "core.1.1.0", + "verticalDomainSpecVersion": "res.1.1.0,sh.1.1.0", + "manufacturerName": "Samsung Electronics", + "modelNumber": "TP6X_RAC_15K", + "vendorId": "DA-AC-RAC-100001", + "lastSignupTime": "2024-11-25T22:17:37.928118320Z", + "transferCandidate": false, + "additionalAuthCodeRequired": false + }, + "type": "OCF", + "restrictionTier": 0, + "allowed": [], + "executionContext": "CLOUD", + "relationships": [] + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/snapshots/test_climate.ambr b/tests/components/smartthings/snapshots/test_climate.ambr index ba32776011a..08ddacf45c6 100644 --- a/tests/components/smartthings/snapshots/test_climate.ambr +++ b/tests/components/smartthings/snapshots/test_climate.ambr @@ -209,6 +209,90 @@ 'state': 'off', }) # --- +# name: test_all_entities[da_ac_rac_100001][climate.corridor_a_c-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'fan_modes': list([ + 'auto', + 'low', + 'medium', + 'high', + 'turbo', + ]), + 'hvac_modes': list([ + , + , + , + , + , + ]), + 'max_temp': 35, + 'min_temp': 7, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.corridor_a_c', + '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': None, + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'F8042E25-0E53-0000-0000-000000000000', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ac_rac_100001][climate.corridor_a_c-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 27, + 'fan_mode': 'auto', + 'fan_modes': list([ + 'auto', + 'low', + 'medium', + 'high', + 'turbo', + ]), + 'friendly_name': 'Corridor A/C', + 'hvac_modes': list([ + , + , + , + , + , + ]), + 'max_temp': 35, + 'min_temp': 7, + 'supported_features': , + 'temperature': 18, + }), + 'context': , + 'entity_id': 'climate.corridor_a_c', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_all_entities[ecobee_thermostat][climate.main_floor-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/smartthings/snapshots/test_init.ambr b/tests/components/smartthings/snapshots/test_init.ambr index 12745ea8f2c..ad7764848b4 100644 --- a/tests/components/smartthings/snapshots/test_init.ambr +++ b/tests/components/smartthings/snapshots/test_init.ambr @@ -263,6 +263,39 @@ 'via_device_id': None, }) # --- +# name: test_devices[da_ac_rac_100001] + DeviceRegistryEntrySnapshot({ + 'area_id': 'theater', + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + 'F8042E25-0E53-0000-0000-000000000000', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Samsung Electronics', + 'model': 'TP6X_RAC_15K', + 'model_id': None, + 'name': 'Corridor A/C', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': 'Theater', + 'sw_version': None, + 'via_device_id': None, + }) +# --- # name: test_devices[da_ks_microwave_0101x] DeviceRegistryEntrySnapshot({ 'area_id': 'theater', diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index 78aa4db62f8..ba2a21fe86b 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -1379,6 +1379,213 @@ 'state': '0', }) # --- +# name: test_all_entities[da_ac_rac_100001][sensor.corridor_a_c_air_quality-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.corridor_a_c_air_quality', + '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': 'Air quality', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'air_quality', + 'unique_id': 'F8042E25-0E53-0000-0000-000000000000.airQuality', + 'unit_of_measurement': 'CAQI', + }) +# --- +# name: test_all_entities[da_ac_rac_100001][sensor.corridor_a_c_air_quality-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Corridor A/C Air quality', + 'state_class': , + 'unit_of_measurement': 'CAQI', + }), + 'context': , + 'entity_id': 'sensor.corridor_a_c_air_quality', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[da_ac_rac_100001][sensor.corridor_a_c_pm10-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.corridor_a_c_pm10', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'PM10', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'F8042E25-0E53-0000-0000-000000000000.dustLevel', + 'unit_of_measurement': 'µg/m³', + }) +# --- +# name: test_all_entities[da_ac_rac_100001][sensor.corridor_a_c_pm10-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pm10', + 'friendly_name': 'Corridor A/C PM10', + 'state_class': , + 'unit_of_measurement': 'µg/m³', + }), + 'context': , + 'entity_id': 'sensor.corridor_a_c_pm10', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[da_ac_rac_100001][sensor.corridor_a_c_pm2_5-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.corridor_a_c_pm2_5', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'PM2.5', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'F8042E25-0E53-0000-0000-000000000000.fineDustLevel', + 'unit_of_measurement': 'µg/m³', + }) +# --- +# name: test_all_entities[da_ac_rac_100001][sensor.corridor_a_c_pm2_5-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pm25', + 'friendly_name': 'Corridor A/C PM2.5', + 'state_class': , + 'unit_of_measurement': 'µg/m³', + }), + 'context': , + 'entity_id': 'sensor.corridor_a_c_pm2_5', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[da_ac_rac_100001][sensor.corridor_a_c_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.corridor_a_c_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': 'Temperature', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'F8042E25-0E53-0000-0000-000000000000.temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ac_rac_100001][sensor.corridor_a_c_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Corridor A/C Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.corridor_a_c_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '27', + }) +# --- # name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_completion_time-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 1a4a3a0f08f915593e511ce4c0c28f04e33b9174 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 6 Mar 2025 13:42:35 +0100 Subject: [PATCH 2281/3148] Use runtime_data in forked_daapd (#138284) * Use runtime_data in forked_daapd * Adjust --- .../components/forked_daapd/__init__.py | 25 ++++++------------- .../components/forked_daapd/config_flow.py | 10 +++----- .../components/forked_daapd/const.py | 3 +-- .../components/forked_daapd/coordinator.py | 3 +++ .../components/forked_daapd/media_player.py | 13 +++------- 5 files changed, 19 insertions(+), 35 deletions(-) diff --git a/homeassistant/components/forked_daapd/__init__.py b/homeassistant/components/forked_daapd/__init__.py index 16fd96ee365..844a6a3eff9 100644 --- a/homeassistant/components/forked_daapd/__init__.py +++ b/homeassistant/components/forked_daapd/__init__.py @@ -2,18 +2,16 @@ from pyforked_daapd import ForkedDaapdAPI -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import DOMAIN, HASS_DATA_UPDATER_KEY -from .coordinator import ForkedDaapdUpdater +from .coordinator import ForkedDaapdConfigEntry, ForkedDaapdUpdater PLATFORMS = [Platform.MEDIA_PLAYER] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: ForkedDaapdConfigEntry) -> bool: """Set up forked-daapd from a config entry by forwarding to platform.""" host: str = entry.data[CONF_HOST] port: int = entry.data[CONF_PORT] @@ -22,24 +20,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async_get_clientsession(hass), host, port, password ) forked_daapd_updater = ForkedDaapdUpdater(hass, forked_daapd_api, entry.entry_id) - if not hass.data.get(DOMAIN): - hass.data[DOMAIN] = {entry.entry_id: {}} - hass.data.setdefault(DOMAIN, {}).setdefault(entry.entry_id, {})[ - HASS_DATA_UPDATER_KEY - ] = forked_daapd_updater + entry.runtime_data = forked_daapd_updater await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, entry: ForkedDaapdConfigEntry +) -> bool: """Remove forked-daapd component.""" status = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if status and hass.data.get(DOMAIN) and hass.data[DOMAIN].get(entry.entry_id): - if websocket_handler := hass.data[DOMAIN][entry.entry_id][ - HASS_DATA_UPDATER_KEY - ].websocket_handler: + if status: + if websocket_handler := entry.runtime_data.websocket_handler: websocket_handler.cancel() - del hass.data[DOMAIN][entry.entry_id] - if not hass.data[DOMAIN]: - del hass.data[DOMAIN] return status diff --git a/homeassistant/components/forked_daapd/config_flow.py b/homeassistant/components/forked_daapd/config_flow.py index b2b2d498f60..890976c7503 100644 --- a/homeassistant/components/forked_daapd/config_flow.py +++ b/homeassistant/components/forked_daapd/config_flow.py @@ -7,12 +7,7 @@ from typing import Any from pyforked_daapd import ForkedDaapdAPI import voluptuous as vol -from homeassistant.config_entries import ( - ConfigEntry, - ConfigFlow, - ConfigFlowResult, - OptionsFlow, -) +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_PORT from homeassistant.core import callback from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -28,6 +23,7 @@ from .const import ( DEFAULT_TTS_VOLUME, DOMAIN, ) +from .coordinator import ForkedDaapdConfigEntry _LOGGER = logging.getLogger(__name__) @@ -115,7 +111,7 @@ class ForkedDaapdFlowHandler(ConfigFlow, domain=DOMAIN): @staticmethod @callback def async_get_options_flow( - config_entry: ConfigEntry, + config_entry: ForkedDaapdConfigEntry, ) -> ForkedDaapdOptionsFlowHandler: """Return options flow handler.""" return ForkedDaapdOptionsFlowHandler() diff --git a/homeassistant/components/forked_daapd/const.py b/homeassistant/components/forked_daapd/const.py index dd7ed1bdf16..effd4c9454c 100644 --- a/homeassistant/components/forked_daapd/const.py +++ b/homeassistant/components/forked_daapd/const.py @@ -30,9 +30,8 @@ DEFAULT_SERVER_NAME = "My Server" DEFAULT_TTS_PAUSE_TIME = 1.2 DEFAULT_TTS_VOLUME = 0.8 DEFAULT_UNMUTE_VOLUME = 0.6 -DOMAIN = "forked_daapd" # key for hass.data +DOMAIN = "forked_daapd" FD_NAME = "OwnTone" -HASS_DATA_UPDATER_KEY = "UPDATER" KNOWN_PIPES = {"librespot-java"} PIPE_FUNCTION_MAP = { "librespot-java": { diff --git a/homeassistant/components/forked_daapd/coordinator.py b/homeassistant/components/forked_daapd/coordinator.py index 246ad1caa7d..0ba339be505 100644 --- a/homeassistant/components/forked_daapd/coordinator.py +++ b/homeassistant/components/forked_daapd/coordinator.py @@ -9,6 +9,7 @@ from typing import Any from pyforked_daapd import ForkedDaapdAPI +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers.dispatcher import async_dispatcher_send @@ -22,6 +23,8 @@ from .const import ( SIGNAL_UPDATE_QUEUE, ) +type ForkedDaapdConfigEntry = ConfigEntry[ForkedDaapdUpdater] + _LOGGER = logging.getLogger(__name__) WS_NOTIFY_EVENT_TYPES = ["player", "outputs", "volume", "options", "queue", "database"] diff --git a/homeassistant/components/forked_daapd/media_player.py b/homeassistant/components/forked_daapd/media_player.py index 90a04dbc177..fd5390195a6 100644 --- a/homeassistant/components/forked_daapd/media_player.py +++ b/homeassistant/components/forked_daapd/media_player.py @@ -27,7 +27,6 @@ from homeassistant.components.spotify import ( resolve_spotify_media_type, spotify_uri_from_media_browser_url, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -54,9 +53,7 @@ from .const import ( DEFAULT_TTS_PAUSE_TIME, DEFAULT_TTS_VOLUME, DEFAULT_UNMUTE_VOLUME, - DOMAIN, FD_NAME, - HASS_DATA_UPDATER_KEY, KNOWN_PIPES, PIPE_FUNCTION_MAP, SIGNAL_ADD_ZONES, @@ -73,20 +70,18 @@ from .const import ( SUPPORTED_FEATURES_ZONE, TTS_TIMEOUT, ) -from .coordinator import ForkedDaapdUpdater +from .coordinator import ForkedDaapdConfigEntry _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: ForkedDaapdConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up forked-daapd from a config entry.""" - forked_daapd_updater: ForkedDaapdUpdater = hass.data[DOMAIN][config_entry.entry_id][ - HASS_DATA_UPDATER_KEY - ] + forked_daapd_updater = config_entry.runtime_data host: str = config_entry.data[CONF_HOST] forked_daapd_api = forked_daapd_updater.api @@ -115,7 +110,7 @@ async def async_setup_entry( await forked_daapd_updater.async_init() -async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def update_listener(hass: HomeAssistant, entry: ForkedDaapdConfigEntry) -> None: """Handle options update.""" async_dispatcher_send( hass, SIGNAL_CONFIG_OPTIONS_UPDATE.format(entry.entry_id), entry.options From 377e0a64d16ca7c3c8efe1b376a94290f2d5745d Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 6 Mar 2025 13:57:13 +0100 Subject: [PATCH 2282/3148] Reset helpers.frame._REPORTED_INTEGRATIONS in between tests (#139924) * Reset helpers.frame._REPORTED_INTEGRATIONS in between tests * Rename * Apply suggestions from code review Co-authored-by: Erik Montnemery --------- Co-authored-by: Erik Montnemery --- tests/components/alarm_control_panel/test_init.py | 7 +------ tests/components/light/test_init.py | 2 -- tests/components/media_source/test_init.py | 1 - tests/components/vacuum/test_init.py | 5 ----- tests/conftest.py | 9 +++++++-- tests/helpers/test_aiohttp_client.py | 2 -- tests/helpers/test_frame.py | 2 -- tests/helpers/test_httpx_client.py | 2 -- tests/helpers/test_update_coordinator.py | 3 +-- tests/test_config_entries.py | 3 --- tests/test_loader.py | 3 --- 11 files changed, 9 insertions(+), 30 deletions(-) diff --git a/tests/components/alarm_control_panel/test_init.py b/tests/components/alarm_control_panel/test_init.py index 747a9d1a358..01d103d01aa 100644 --- a/tests/components/alarm_control_panel/test_init.py +++ b/tests/components/alarm_control_panel/test_init.py @@ -1,7 +1,6 @@ """Test for the alarm control panel const module.""" from typing import Any -from unittest.mock import patch import pytest @@ -23,7 +22,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError -from homeassistant.helpers import entity_registry as er, frame +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.typing import UNDEFINED, UndefinedType from . import help_async_setup_entry_init, help_async_unload_entry @@ -222,7 +221,6 @@ async def test_alarm_control_panel_with_default_code( mock_alarm_control_panel_entity.calls_disarm.assert_called_with("1234") -@patch.object(frame, "_REPORTED_INTEGRATIONS", set()) async def test_alarm_control_panel_not_log_deprecated_state_warning( hass: HomeAssistant, mock_alarm_control_panel_entity: MockAlarmControlPanel, @@ -238,7 +236,6 @@ async def test_alarm_control_panel_not_log_deprecated_state_warning( @pytest.mark.usefixtures("mock_as_custom_component") -@patch.object(frame, "_REPORTED_INTEGRATIONS", set()) async def test_alarm_control_panel_log_deprecated_state_warning_using_state_prop( hass: HomeAssistant, code_format: CodeFormat | None, @@ -303,7 +300,6 @@ async def test_alarm_control_panel_log_deprecated_state_warning_using_state_prop @pytest.mark.usefixtures("mock_as_custom_component") -@patch.object(frame, "_REPORTED_INTEGRATIONS", set()) async def test_alarm_control_panel_log_deprecated_state_warning_using_attr_state_attr( hass: HomeAssistant, code_format: CodeFormat | None, @@ -386,7 +382,6 @@ async def test_alarm_control_panel_log_deprecated_state_warning_using_attr_state @pytest.mark.usefixtures("mock_as_custom_component") -@patch.object(frame, "_REPORTED_INTEGRATIONS", set()) async def test_alarm_control_panel_deprecated_state_does_not_break_state( hass: HomeAssistant, code_format: CodeFormat | None, diff --git a/tests/components/light/test_init.py b/tests/components/light/test_init.py index 5bc17ea3e24..29604ce7595 100644 --- a/tests/components/light/test_init.py +++ b/tests/components/light/test_init.py @@ -21,7 +21,6 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, Unauthorized -from homeassistant.helpers import frame from homeassistant.setup import async_setup_component from homeassistant.util import color as color_util @@ -2846,7 +2845,6 @@ def test_report_invalid_color_modes( ], ids=["with_kelvin", "with_mired_values", "with_mired_defaults"], ) -@patch.object(frame, "_REPORTED_INTEGRATIONS", set()) def test_missing_kelvin_property_warnings( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, diff --git a/tests/components/media_source/test_init.py b/tests/components/media_source/test_init.py index c37e418020b..2c2952068ee 100644 --- a/tests/components/media_source/test_init.py +++ b/tests/components/media_source/test_init.py @@ -114,7 +114,6 @@ async def test_async_resolve_media(hass: HomeAssistant) -> None: assert media.mime_type == "audio/mpeg" -@patch("homeassistant.helpers.frame._REPORTED_INTEGRATIONS", set()) async def test_async_resolve_media_no_entity( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: diff --git a/tests/components/vacuum/test_init.py b/tests/components/vacuum/test_init.py index 717a69470b3..967b9672805 100644 --- a/tests/components/vacuum/test_init.py +++ b/tests/components/vacuum/test_init.py @@ -5,7 +5,6 @@ from __future__ import annotations from enum import Enum from types import ModuleType from typing import Any -from unittest.mock import patch import pytest @@ -25,7 +24,6 @@ from homeassistant.components.vacuum import ( VacuumEntityFeature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import frame from . import MockVacuum, help_async_setup_entry_init, help_async_unload_entry from .common import async_start @@ -326,7 +324,6 @@ async def test_vacuum_not_log_deprecated_state_warning( @pytest.mark.usefixtures("mock_as_custom_component") -@patch.object(frame, "_REPORTED_INTEGRATIONS", set()) async def test_vacuum_log_deprecated_state_warning_using_state_prop( hass: HomeAssistant, config_flow_fixture: None, @@ -371,7 +368,6 @@ async def test_vacuum_log_deprecated_state_warning_using_state_prop( @pytest.mark.usefixtures("mock_as_custom_component") -@patch.object(frame, "_REPORTED_INTEGRATIONS", set()) async def test_vacuum_log_deprecated_state_warning_using_attr_state_attr( hass: HomeAssistant, config_flow_fixture: None, @@ -429,7 +425,6 @@ async def test_vacuum_log_deprecated_state_warning_using_attr_state_attr( @pytest.mark.usefixtures("mock_as_custom_component") -@patch.object(frame, "_REPORTED_INTEGRATIONS", set()) async def test_vacuum_deprecated_state_does_not_break_state( hass: HomeAssistant, config_flow_fixture: None, diff --git a/tests/conftest.py b/tests/conftest.py index e3313813112..7725189aa53 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -430,11 +430,16 @@ def verify_cleanup( @pytest.fixture(autouse=True) -def reset_hass_threading_local_object() -> Generator[None]: - """Reset the _Hass threading.local object for every test case.""" +def reset_globals() -> Generator[None]: + """Reset global objects for every test case.""" yield + + # Reset the _Hass threading.local object ha._hass.__dict__.clear() + + # Reset the frame helper globals frame.async_setup(None) + frame._REPORTED_INTEGRATIONS.clear() @pytest.fixture(autouse=True, scope="session") diff --git a/tests/helpers/test_aiohttp_client.py b/tests/helpers/test_aiohttp_client.py index 13cb25bc516..6d2a7e7a8bb 100644 --- a/tests/helpers/test_aiohttp_client.py +++ b/tests/helpers/test_aiohttp_client.py @@ -249,7 +249,6 @@ async def test_get_clientsession_patched_close(hass: HomeAssistant) -> None: assert mock_close.call_count == 0 -@patch("homeassistant.helpers.frame._REPORTED_INTEGRATIONS", set()) async def test_warning_close_session_integration( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: @@ -292,7 +291,6 @@ async def test_warning_close_session_integration( ) in caplog.text -@patch("homeassistant.helpers.frame._REPORTED_INTEGRATIONS", set()) async def test_warning_close_session_custom( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: diff --git a/tests/helpers/test_frame.py b/tests/helpers/test_frame.py index 6a509ffae5c..e99db76dcbc 100644 --- a/tests/helpers/test_frame.py +++ b/tests/helpers/test_frame.py @@ -376,7 +376,6 @@ async def test_report_usage_find_issue_tracker_other_thread( assert reports == snapshot -@patch.object(frame, "_REPORTED_INTEGRATIONS", set()) @pytest.mark.usefixtures("hass", "mock_integration_frame") async def test_prevent_flooding( caplog: pytest.LogCaptureFixture, mock_integration_frame: Mock @@ -408,7 +407,6 @@ async def test_prevent_flooding( assert len(frame._REPORTED_INTEGRATIONS) == 1 -@patch.object(frame, "_REPORTED_INTEGRATIONS", set()) @pytest.mark.usefixtures("hass", "mock_integration_frame") async def test_breaks_in_ha_version( caplog: pytest.LogCaptureFixture, mock_integration_frame: Mock diff --git a/tests/helpers/test_httpx_client.py b/tests/helpers/test_httpx_client.py index 4b9f2fa2bf6..c3b9c1f9de8 100644 --- a/tests/helpers/test_httpx_client.py +++ b/tests/helpers/test_httpx_client.py @@ -100,7 +100,6 @@ async def test_get_async_client_context_manager(hass: HomeAssistant) -> None: assert mock_aclose.call_count == 0 -@patch("homeassistant.helpers.frame._REPORTED_INTEGRATIONS", set()) async def test_warning_close_session_integration( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: @@ -144,7 +143,6 @@ async def test_warning_close_session_integration( ) in caplog.text -@patch("homeassistant.helpers.frame._REPORTED_INTEGRATIONS", set()) async def test_warning_close_session_custom( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: diff --git a/tests/helpers/test_update_coordinator.py b/tests/helpers/test_update_coordinator.py index 3ad5754dada..5fd9f9e39fd 100644 --- a/tests/helpers/test_update_coordinator.py +++ b/tests/helpers/test_update_coordinator.py @@ -19,7 +19,7 @@ from homeassistant.exceptions import ( ConfigEntryError, ConfigEntryNotReady, ) -from homeassistant.helpers import frame, update_coordinator +from homeassistant.helpers import update_coordinator from homeassistant.util.dt import utcnow from tests.common import MockConfigEntry, async_fire_time_changed @@ -638,7 +638,6 @@ async def test_async_config_entry_first_refresh_invalid_state( @pytest.mark.usefixtures("mock_integration_frame") -@patch.object(frame, "_REPORTED_INTEGRATIONS", set()) async def test_async_config_entry_first_refresh_invalid_state_in_integration( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index 190453afe06..d19c3b38650 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -5763,7 +5763,6 @@ async def test_reauth_reconfigure_missing_entry( @pytest.mark.usefixtures("mock_integration_frame") -@patch.object(frame, "_REPORTED_INTEGRATIONS", set()) @pytest.mark.parametrize( "source", [config_entries.SOURCE_REAUTH, config_entries.SOURCE_RECONFIGURE] ) @@ -6010,7 +6009,6 @@ async def test_options_flow_with_config_entry_core() -> None: @pytest.mark.parametrize("integration_frame_path", ["custom_components/my_integration"]) @pytest.mark.usefixtures("hass", "mock_integration_frame") -@patch.object(frame, "_REPORTED_INTEGRATIONS", set()) async def test_options_flow_with_config_entry(caplog: pytest.LogCaptureFixture) -> None: """Test that OptionsFlowWithConfigEntry doesn't mutate entry options.""" entry = MockConfigEntry( @@ -8789,7 +8787,6 @@ async def test_options_flow_config_entry( @pytest.mark.parametrize("integration_frame_path", ["custom_components/my_integration"]) @pytest.mark.usefixtures("mock_integration_frame") -@patch.object(frame, "_REPORTED_INTEGRATIONS", set()) async def test_options_flow_deprecated_config_entry_setter( hass: HomeAssistant, manager: config_entries.ConfigEntries, diff --git a/tests/test_loader.py b/tests/test_loader.py index 8afe800144c..e4c1982781c 100644 --- a/tests/test_loader.py +++ b/tests/test_loader.py @@ -15,7 +15,6 @@ from homeassistant import loader from homeassistant.components import http, hue from homeassistant.components.hue import light as hue_light from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import frame from homeassistant.helpers.json import json_dumps from homeassistant.util.json import json_loads @@ -1314,7 +1313,6 @@ async def test_config_folder_not_in_path() -> None: ], ) @pytest.mark.usefixtures("mock_integration_frame") -@patch.object(frame, "_REPORTED_INTEGRATIONS", set()) async def test_hass_components_use_reported( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, @@ -2010,7 +2008,6 @@ async def test_has_services(hass: HomeAssistant) -> None: ], ) @pytest.mark.usefixtures("mock_integration_frame") -@patch.object(frame, "_REPORTED_INTEGRATIONS", set()) async def test_hass_helpers_use_reported( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, From 88f18fdfdc1de40fd8f81eda1952cd974432e511 Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Thu, 6 Mar 2025 15:20:08 +0100 Subject: [PATCH 2283/3148] Improve loader dependency tests (#139916) --- tests/test_loader.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tests/test_loader.py b/tests/test_loader.py index e4c1982781c..548091a3503 100644 --- a/tests/test_loader.py +++ b/tests/test_loader.py @@ -55,12 +55,35 @@ async def test_circular_component_dependencies(hass: HomeAssistant) -> None: with pytest.raises(loader.CircularDependency): await loader._async_component_dependencies(hass, mod_4) + # Create a circular after_dependency without a hard dependency + mock_integration( + hass, MockModule("mod1", partial_manifest={"after_dependencies": ["mod4"]}) + ) + mod_4 = mock_integration( + hass, MockModule("mod4", partial_manifest={"after_dependencies": ["mod2"]}) + ) + # this currently doesn't raise, but it should. Will be improved in a follow-up. + await loader._async_component_dependencies(hass, mod_4) + async def test_nonexistent_component_dependencies(hass: HomeAssistant) -> None: """Test if we can detect nonexistent dependencies of components.""" mod_1 = mock_integration(hass, MockModule("mod1", dependencies=["nonexistent"])) with pytest.raises(loader.IntegrationNotFound): await loader._async_component_dependencies(hass, mod_1) + mod_2 = mock_integration(hass, MockModule("mod2", dependencies=["mod1"])) + + assert not await mod_2.resolve_dependencies() + assert mod_2.all_dependencies_resolved + with pytest.raises(RuntimeError): + mod_2.all_dependencies # noqa: B018 + + # this currently is not resolved, because intermediate results are not cached + # this will be improved in a follow-up + assert not mod_1.all_dependencies_resolved + assert not await mod_1.resolve_dependencies() + with pytest.raises(RuntimeError): + mod_1.all_dependencies # noqa: B018 def test_component_loader(hass: HomeAssistant) -> None: From 6ba45a32c08a12e6063aaa1840ec3c3d0f174d97 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 6 Mar 2025 17:25:34 +0100 Subject: [PATCH 2284/3148] Update typing of `BackupAgent.async_get_backup` (#139923) * Update typing of BackupAgent.async_get_backup * Remove manual reset of frame helper --- homeassistant/components/backup/agent.py | 2 +- homeassistant/components/backup/http.py | 8 +- homeassistant/components/backup/manager.py | 16 ++++ .../backup/snapshots/test_websocket.ambr | 34 ++++++++ tests/components/backup/test_http.py | 20 +++++ tests/components/backup/test_websocket.py | 84 +++++++++++++++++++ 6 files changed, 162 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/backup/agent.py b/homeassistant/components/backup/agent.py index 0a2531900ae..8093ac88338 100644 --- a/homeassistant/components/backup/agent.py +++ b/homeassistant/components/backup/agent.py @@ -83,7 +83,7 @@ class BackupAgent(abc.ABC): self, backup_id: str, **kwargs: Any, - ) -> AgentBackup | None: + ) -> AgentBackup: """Return a backup. Raises BackupNotFound if the backup does not exist. diff --git a/homeassistant/components/backup/http.py b/homeassistant/components/backup/http.py index 20ad613933b..8f241e6363d 100644 --- a/homeassistant/components/backup/http.py +++ b/homeassistant/components/backup/http.py @@ -15,6 +15,7 @@ from multidict import istr from homeassistant.components.http import KEY_HASS, HomeAssistantView, require_admin from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import frame from homeassistant.util import slugify from . import util @@ -66,7 +67,12 @@ class DownloadBackupView(HomeAssistantView): # Check for None to be backwards compatible with the old BackupAgent API, # this can be removed in HA Core 2025.10 - if backup is None: + if not backup: + frame.report_usage( + "returns None from BackupAgent.async_get_backup", + breaks_in_ha_version="2025.10", + integration_domain=agent_id.partition(".")[0], + ) return Response(status=HTTPStatus.NOT_FOUND) headers = { diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index 4f3ea8b296c..bfaa5c5a48e 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -30,6 +30,7 @@ from homeassistant.backup_restore import ( from homeassistant.const import __version__ as HAVERSION from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import ( + frame, instance_id, integration_platform, issue_registry as ir, @@ -665,6 +666,11 @@ class BackupManager: # Check for None to be backwards compatible with the old BackupAgent API, # this can be removed in HA Core 2025.10 if not result: + frame.report_usage( + "returns None from BackupAgent.async_get_backup", + breaks_in_ha_version="2025.10", + integration_domain=agent_id.partition(".")[0], + ) continue if backup is None: if known_backup := self.known_backups.get(backup_id): @@ -1280,6 +1286,11 @@ class BackupManager: # Check for None to be backwards compatible with the old BackupAgent API, # this can be removed in HA Core 2025.10 if not backup: + frame.report_usage( + "returns None from BackupAgent.async_get_backup", + breaks_in_ha_version="2025.10", + integration_domain=agent_id.partition(".")[0], + ) raise BackupManagerError( f"Backup {backup_id} not found in agent {agent_id}" ) @@ -1376,6 +1387,11 @@ class BackupManager: # Check for None to be backwards compatible with the old BackupAgent API, # this can be removed in HA Core 2025.10 if not backup: + frame.report_usage( + "returns None from BackupAgent.async_get_backup", + breaks_in_ha_version="2025.10", + integration_domain=agent_id.partition(".")[0], + ) raise BackupManagerError( f"Backup {backup_id} not found in agent {agent_id}" ) diff --git a/tests/components/backup/snapshots/test_websocket.ambr b/tests/components/backup/snapshots/test_websocket.ambr index 17e3ca8b176..6ecb508d9e9 100644 --- a/tests/components/backup/snapshots/test_websocket.ambr +++ b/tests/components/backup/snapshots/test_websocket.ambr @@ -229,6 +229,17 @@ 'type': 'result', }) # --- +# name: test_can_decrypt_on_download_get_backup_returns_none + dict({ + 'error': dict({ + 'code': 'home_assistant_error', + 'message': 'Backup abc123 not found in agent test.remote', + }), + 'id': 1, + 'success': False, + 'type': 'result', + }) +# --- # name: test_can_decrypt_on_download_with_agent_error[BackupAgentError] dict({ 'error': dict({ @@ -4930,6 +4941,18 @@ 'type': 'result', }) # --- +# name: test_details_get_backup_returns_none + dict({ + 'id': 1, + 'result': dict({ + 'agent_errors': dict({ + }), + 'backup': None, + }), + 'success': True, + 'type': 'result', + }) +# --- # name: test_details_with_errors[BackupAgentUnreachableError] dict({ 'id': 1, @@ -5728,6 +5751,17 @@ # name: test_restore_remote_agent[remote_agents1-backups1].1 1 # --- +# name: test_restore_remote_agent_get_backup_returns_none + dict({ + 'error': dict({ + 'code': 'home_assistant_error', + 'message': 'Backup abc123 not found in agent test.remote', + }), + 'id': 1, + 'success': False, + 'type': 'result', + }) +# --- # name: test_restore_wrong_password dict({ 'error': dict({ diff --git a/tests/components/backup/test_http.py b/tests/components/backup/test_http.py index a03217beac2..92bf454095e 100644 --- a/tests/components/backup/test_http.py +++ b/tests/components/backup/test_http.py @@ -234,6 +234,26 @@ async def test_downloading_backup_not_found( assert resp.status == 404 +async def test_downloading_backup_not_found_get_backup_returns_none( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test downloading a backup file that does not exist.""" + mock_agents = await setup_backup_integration(hass, remote_agents=["test.test"]) + mock_agents["test.test"].async_get_backup.return_value = None + mock_agents["test.test"].async_get_backup.side_effect = None + + client = await hass_client() + + resp = await client.get("/api/backup/download/abc123?agent_id=test.test") + assert resp.status == 404 + assert ( + "Detected that integration 'test' returns None from BackupAgent.async_get_backup." + in caplog.text + ) + + async def test_downloading_as_non_admin( hass: HomeAssistant, hass_client: ClientSessionGenerator, diff --git a/tests/components/backup/test_websocket.py b/tests/components/backup/test_websocket.py index 404ba52de4b..d89e68f4ed8 100644 --- a/tests/components/backup/test_websocket.py +++ b/tests/components/backup/test_websocket.py @@ -234,6 +234,31 @@ async def test_details_with_errors( assert await client.receive_json() == snapshot +async def test_details_get_backup_returns_none( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + caplog: pytest.LogCaptureFixture, + snapshot: SnapshotAssertion, +) -> None: + """Test getting backup info when the agent returns None from get_backup.""" + mock_agents = await setup_backup_integration(hass, remote_agents=["test.remote"]) + mock_agents["test.remote"].async_get_backup.return_value = None + mock_agents["test.remote"].async_get_backup.side_effect = None + + client = await hass_ws_client(hass) + await hass.async_block_till_done() + + with patch("pathlib.Path.exists", return_value=True): + await client.send_json_auto_id( + {"type": "backup/details", "backup_id": "abc123"} + ) + assert await client.receive_json() == snapshot + assert ( + "Detected that integration 'test' returns None from BackupAgent.async_get_backup." + in caplog.text + ) + + @pytest.mark.parametrize( ("remote_agents", "backups"), [ @@ -724,6 +749,36 @@ async def test_restore_remote_agent( assert len(restart_calls) == snapshot +async def test_restore_remote_agent_get_backup_returns_none( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + caplog: pytest.LogCaptureFixture, + snapshot: SnapshotAssertion, +) -> None: + """Test calling the restore command when the agent returns None from get_backup.""" + mock_agents = await setup_backup_integration(hass, remote_agents=["test.remote"]) + mock_agents["test.remote"].async_get_backup.return_value = None + mock_agents["test.remote"].async_get_backup.side_effect = None + restart_calls = async_mock_service(hass, "homeassistant", "restart") + + client = await hass_ws_client(hass) + await hass.async_block_till_done() + + await client.send_json_auto_id( + { + "type": "backup/restore", + "backup_id": "abc123", + "agent_id": "test.remote", + } + ) + assert await client.receive_json() == snapshot + assert len(restart_calls) == 0 + assert ( + "Detected that integration 'test' returns None from BackupAgent.async_get_backup." + in caplog.text + ) + + async def test_restore_wrong_password( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, @@ -3543,3 +3598,32 @@ async def test_can_decrypt_on_download_with_agent_error( } ) assert await client.receive_json() == snapshot + + +@pytest.mark.usefixtures("mock_backups") +async def test_can_decrypt_on_download_get_backup_returns_none( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + caplog: pytest.LogCaptureFixture, + snapshot: SnapshotAssertion, +) -> None: + """Test can decrypt on download when the agent returns None from get_backup.""" + + mock_agents = await setup_backup_integration(hass, remote_agents=["test.remote"]) + mock_agents["test.remote"].async_get_backup.return_value = None + mock_agents["test.remote"].async_get_backup.side_effect = None + + client = await hass_ws_client(hass) + await client.send_json_auto_id( + { + "type": "backup/can_decrypt_on_download", + "backup_id": TEST_BACKUP_ABC123.backup_id, + "agent_id": "test.remote", + "password": "hunter2", + } + ) + assert await client.receive_json() == snapshot + assert ( + "Detected that integration 'test' returns None from BackupAgent.async_get_backup." + in caplog.text + ) From 9549b1488ef494d4dcc3d0aa721bb22197ed1497 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 6 Mar 2025 17:52:05 +0100 Subject: [PATCH 2285/3148] Fix SmartThings dust sensor UoM (#139977) --- homeassistant/components/smartthings/sensor.py | 1 + .../fixtures/device_status/da_ac_rac_100001.json | 8 ++++++-- tests/components/smartthings/snapshots/test_sensor.ambr | 4 ++-- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index ff6e7f252b0..22fdf3084c8 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -951,6 +951,7 @@ UNITS = { "F": UnitOfTemperature.FAHRENHEIT, "lux": LIGHT_LUX, "mG": None, + "μg/m^3": CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, } diff --git a/tests/components/smartthings/fixtures/device_status/da_ac_rac_100001.json b/tests/components/smartthings/fixtures/device_status/da_ac_rac_100001.json index 305624e5b3b..5c062d904bb 100644 --- a/tests/components/smartthings/fixtures/device_status/da_ac_rac_100001.json +++ b/tests/components/smartthings/fixtures/device_status/da_ac_rac_100001.json @@ -146,10 +146,14 @@ }, "dustSensor": { "dustLevel": { - "value": null + "value": 46, + "unit": "\u03bcg/m^3", + "timestamp": "2025-03-06T16:01:49.656000+00:00" }, "fineDustLevel": { - "value": null + "value": 10, + "unit": "\u03bcg/m^3", + "timestamp": "2025-03-06T16:01:49.656000+00:00" } }, "thermostatCoolingSetpoint": { diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index ba2a21fe86b..fa9af0f2812 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -1479,7 +1479,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '46', }) # --- # name: test_all_entities[da_ac_rac_100001][sensor.corridor_a_c_pm2_5-entry] @@ -1531,7 +1531,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '10', }) # --- # name: test_all_entities[da_ac_rac_100001][sensor.corridor_a_c_temperature-entry] From 93dfbb41664613592886ec2a4ee68e34c5a79059 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Thu, 6 Mar 2025 17:52:45 +0100 Subject: [PATCH 2286/3148] Update frontend to 20250306.0 (#139965) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index e661439cff2..b210fdb6661 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20250305.0"] + "requirements": ["home-assistant-frontend==20250306.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index e7f1cf096a4..3513ddfdb82 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -37,7 +37,7 @@ habluetooth==3.25.0 hass-nabucasa==0.94.0 hassil==2.2.3 home-assistant-bluetooth==1.13.1 -home-assistant-frontend==20250305.0 +home-assistant-frontend==20250306.0 home-assistant-intents==2025.3.5 httpx==0.28.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index e9fcd5f577b..50fecf0e76b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1152,7 +1152,7 @@ hole==0.8.0 holidays==0.68 # homeassistant.components.frontend -home-assistant-frontend==20250305.0 +home-assistant-frontend==20250306.0 # homeassistant.components.conversation home-assistant-intents==2025.3.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b0ab760f963..2920503ecbe 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -981,7 +981,7 @@ hole==0.8.0 holidays==0.68 # homeassistant.components.frontend -home-assistant-frontend==20250305.0 +home-assistant-frontend==20250306.0 # homeassistant.components.conversation home-assistant-intents==2025.3.5 From df1563daaf6d0e5d95b88f693076bbcbdc2f57a0 Mon Sep 17 00:00:00 2001 From: Regev Brody Date: Thu, 6 Mar 2025 19:18:37 +0200 Subject: [PATCH 2287/3148] Add Roborock buttons for starting routines (#139845) --- homeassistant/components/roborock/__init__.py | 24 ++++- homeassistant/components/roborock/button.py | 62 ++++++++++-- .../components/roborock/coordinator.py | 49 +++++++++- tests/components/roborock/conftest.py | 23 ++++- tests/components/roborock/mock_data.py | 17 ++++ tests/components/roborock/test_button.py | 97 ++++++++++++++++++- 6 files changed, 252 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/roborock/__init__.py b/homeassistant/components/roborock/__init__.py index c382a56cde7..955e50cd15b 100644 --- a/homeassistant/components/roborock/__init__.py +++ b/homeassistant/components/roborock/__init__.py @@ -83,7 +83,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: RoborockConfigEntry) -> # Get a Coordinator if the device is available or if we have connected to the device before coordinators = await asyncio.gather( *build_setup_functions( - hass, entry, device_map, user_data, product_info, home_data.rooms + hass, + entry, + device_map, + user_data, + product_info, + home_data.rooms, + api_client, ), return_exceptions=True, ) @@ -135,6 +141,7 @@ def build_setup_functions( user_data: UserData, product_info: dict[str, HomeDataProduct], home_data_rooms: list[HomeDataRoom], + api_client: RoborockApiClient, ) -> list[ Coroutine[ Any, @@ -151,6 +158,7 @@ def build_setup_functions( device, product_info[device.product_id], home_data_rooms, + api_client, ) for device in device_map.values() ] @@ -163,11 +171,12 @@ async def setup_device( device: HomeDataDevice, product_info: HomeDataProduct, home_data_rooms: list[HomeDataRoom], + api_client: RoborockApiClient, ) -> RoborockDataUpdateCoordinator | RoborockDataUpdateCoordinatorA01 | None: """Set up a coordinator for a given device.""" if device.pv == "1.0": return await setup_device_v1( - hass, entry, user_data, device, product_info, home_data_rooms + hass, entry, user_data, device, product_info, home_data_rooms, api_client ) if device.pv == "A01": return await setup_device_a01(hass, entry, user_data, device, product_info) @@ -187,6 +196,7 @@ async def setup_device_v1( device: HomeDataDevice, product_info: HomeDataProduct, home_data_rooms: list[HomeDataRoom], + api_client: RoborockApiClient, ) -> RoborockDataUpdateCoordinator | None: """Set up a device Coordinator.""" mqtt_client = await hass.async_add_executor_job( @@ -208,7 +218,15 @@ async def setup_device_v1( await mqtt_client.async_release() raise coordinator = RoborockDataUpdateCoordinator( - hass, entry, device, networking, product_info, mqtt_client, home_data_rooms + hass, + entry, + device, + networking, + product_info, + mqtt_client, + home_data_rooms, + api_client, + user_data, ) try: await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/roborock/button.py b/homeassistant/components/roborock/button.py index 33e9502aca1..f0f0d7beea2 100644 --- a/homeassistant/components/roborock/button.py +++ b/homeassistant/components/roborock/button.py @@ -2,7 +2,10 @@ from __future__ import annotations +import asyncio from dataclasses import dataclass +import itertools +from typing import Any from roborock.roborock_typing import RoborockCommand @@ -12,7 +15,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import RoborockConfigEntry, RoborockDataUpdateCoordinator -from .entity import RoborockEntityV1 +from .entity import RoborockEntity, RoborockEntityV1 @dataclass(frozen=True, kw_only=True) @@ -65,14 +68,34 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Roborock button platform.""" + routines_lists = await asyncio.gather( + *[coordinator.get_routines() for coordinator in config_entry.runtime_data.v1], + ) async_add_entities( - RoborockButtonEntity( - coordinator, - description, + itertools.chain( + ( + RoborockButtonEntity( + coordinator, + description, + ) + for coordinator in config_entry.runtime_data.v1 + for description in CONSUMABLE_BUTTON_DESCRIPTIONS + if isinstance(coordinator, RoborockDataUpdateCoordinator) + ), + ( + RoborockRoutineButtonEntity( + coordinator, + ButtonEntityDescription( + key=str(routine.id), + name=routine.name, + ), + ) + for coordinator, routines in zip( + config_entry.runtime_data.v1, routines_lists, strict=True + ) + for routine in routines + ), ) - for coordinator in config_entry.runtime_data.v1 - for description in CONSUMABLE_BUTTON_DESCRIPTIONS - if isinstance(coordinator, RoborockDataUpdateCoordinator) ) @@ -97,3 +120,28 @@ class RoborockButtonEntity(RoborockEntityV1, ButtonEntity): async def async_press(self) -> None: """Press the button.""" await self.send(self.entity_description.command, self.entity_description.param) + + +class RoborockRoutineButtonEntity(RoborockEntity, ButtonEntity): + """A class to define Roborock routines button entities.""" + + entity_description: ButtonEntityDescription + + def __init__( + self, + coordinator: RoborockDataUpdateCoordinator, + entity_description: ButtonEntityDescription, + ) -> None: + """Create a button entity.""" + super().__init__( + f"{entity_description.key}_{coordinator.duid_slug}", + coordinator.device_info, + coordinator.api, + ) + self._routine_id = int(entity_description.key) + self._coordinator = coordinator + self.entity_description = entity_description + + async def async_press(self, **kwargs: Any) -> None: + """Press the button.""" + await self._coordinator.execute_routines(self._routine_id) diff --git a/homeassistant/components/roborock/coordinator.py b/homeassistant/components/roborock/coordinator.py index 806651c9ac5..1ab23fc927a 100644 --- a/homeassistant/components/roborock/coordinator.py +++ b/homeassistant/components/roborock/coordinator.py @@ -10,17 +10,26 @@ import logging from propcache.api import cached_property from roborock import HomeDataRoom from roborock.code_mappings import RoborockCategory -from roborock.containers import DeviceData, HomeDataDevice, HomeDataProduct, NetworkInfo +from roborock.containers import ( + DeviceData, + HomeDataDevice, + HomeDataProduct, + HomeDataScene, + NetworkInfo, + UserData, +) from roborock.exceptions import RoborockException from roborock.roborock_message import RoborockDyadDataProtocol, RoborockZeoProtocol from roborock.roborock_typing import DeviceProp from roborock.version_1_apis.roborock_local_client_v1 import RoborockLocalClientV1 from roborock.version_1_apis.roborock_mqtt_client_v1 import RoborockMqttClientV1 from roborock.version_a01_apis import RoborockClientA01 +from roborock.web_api import RoborockApiClient from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_CONNECTIONS from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.typing import StateType @@ -67,6 +76,8 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): product_info: HomeDataProduct, cloud_api: RoborockMqttClientV1, home_data_rooms: list[HomeDataRoom], + api_client: RoborockApiClient, + user_data: UserData, ) -> None: """Initialize.""" super().__init__( @@ -89,7 +100,7 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): self.cloud_api = cloud_api self.device_info = DeviceInfo( name=self.roborock_device_info.device.name, - identifiers={(DOMAIN, self.roborock_device_info.device.duid)}, + identifiers={(DOMAIN, self.duid)}, manufacturer="Roborock", model=self.roborock_device_info.product.model, model_id=self.roborock_device_info.product.model, @@ -103,8 +114,10 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): self.maps: dict[int, RoborockMapInfo] = {} self._home_data_rooms = {str(room.id): room.name for room in home_data_rooms} self.map_storage = RoborockMapStorage( - hass, self.config_entry.entry_id, slugify(self.duid) + hass, self.config_entry.entry_id, self.duid_slug ) + self._user_data = user_data + self._api_client = api_client async def _async_setup(self) -> None: """Set up the coordinator.""" @@ -134,7 +147,7 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): except RoborockException: _LOGGER.warning( "Using the cloud API for device %s. This is not recommended as it can lead to rate limiting. We recommend making your vacuum accessible by your Home Assistant instance", - self.roborock_device_info.device.duid, + self.duid, ) await self.api.async_disconnect() # We use the cloud api if the local api fails to connect. @@ -194,6 +207,34 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): for room in room_mapping or () } + async def get_routines(self) -> list[HomeDataScene]: + """Get routines.""" + try: + return await self._api_client.get_scenes(self._user_data, self.duid) + except RoborockException as err: + _LOGGER.error("Failed to get routines %s", err) + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="command_failed", + translation_placeholders={ + "command": "get_scenes", + }, + ) from err + + async def execute_routines(self, routine_id: int) -> None: + """Execute routines.""" + try: + await self._api_client.execute_scene(self._user_data, routine_id) + except RoborockException as err: + _LOGGER.error("Failed to execute routines %s %s", routine_id, err) + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="command_failed", + translation_placeholders={ + "command": "execute_scene", + }, + ) from err + @cached_property def duid(self) -> str: """Get the unique id of the device as specified by Roborock.""" diff --git a/tests/components/roborock/conftest.py b/tests/components/roborock/conftest.py index 43e5148c9a8..9b3a6633c62 100644 --- a/tests/components/roborock/conftest.py +++ b/tests/components/roborock/conftest.py @@ -30,6 +30,7 @@ from .mock_data import ( MULTI_MAP_LIST, NETWORK_INFO, PROP, + SCENES, USER_DATA, USER_EMAIL, ) @@ -67,8 +68,24 @@ class A01Mock(RoborockMqttClientA01): return {prot: self.protocol_responses[prot] for prot in dyad_data_protocols} +@pytest.fixture(name="bypass_api_client_fixture") +def bypass_api_client_fixture() -> None: + """Skip calls to the API client.""" + with ( + patch( + "homeassistant.components.roborock.RoborockApiClient.get_home_data_v2", + return_value=HOME_DATA, + ), + patch( + "homeassistant.components.roborock.RoborockApiClient.get_scenes", + return_value=SCENES, + ), + ): + yield + + @pytest.fixture(name="bypass_api_fixture") -def bypass_api_fixture() -> None: +def bypass_api_fixture(bypass_api_client_fixture: Any) -> None: """Skip calls to the API.""" with ( patch("homeassistant.components.roborock.RoborockMqttClientV1.async_connect"), @@ -76,10 +93,6 @@ def bypass_api_fixture() -> None: patch( "homeassistant.components.roborock.coordinator.RoborockMqttClientV1._send_command" ), - patch( - "homeassistant.components.roborock.RoborockApiClient.get_home_data_v2", - return_value=HOME_DATA, - ), patch( "homeassistant.components.roborock.RoborockMqttClientV1.get_networking", return_value=NETWORK_INFO, diff --git a/tests/components/roborock/mock_data.py b/tests/components/roborock/mock_data.py index 6e3fb229aa9..59c54892687 100644 --- a/tests/components/roborock/mock_data.py +++ b/tests/components/roborock/mock_data.py @@ -9,6 +9,7 @@ from roborock.containers import ( Consumable, DnDTimer, HomeData, + HomeDataScene, MultiMapsList, NetworkInfo, S7Status, @@ -1150,3 +1151,19 @@ MAP_DATA = MapData(0, 0) MAP_DATA.image = ImageData( 100, 10, 10, 10, 10, ImageConfig(), Image.new("RGB", (1, 1)), lambda p: p ) + + +SCENES = [ + HomeDataScene.from_dict( + { + "name": "sc1", + "id": 12, + }, + ), + HomeDataScene.from_dict( + { + "name": "sc2", + "id": 24, + }, + ), +] diff --git a/tests/components/roborock/test_button.py b/tests/components/roborock/test_button.py index 0a7efe83513..77c5d4d7cb0 100644 --- a/tests/components/roborock/test_button.py +++ b/tests/components/roborock/test_button.py @@ -1,9 +1,10 @@ """Test Roborock Button platform.""" -from unittest.mock import patch +from unittest.mock import ANY, patch import pytest import roborock +from roborock import RoborockException from homeassistant.components.button import SERVICE_PRESS from homeassistant.const import Platform @@ -13,6 +14,18 @@ from homeassistant.exceptions import HomeAssistantError from tests.common import MockConfigEntry +@pytest.fixture +def bypass_api_client_get_scenes_fixture(bypass_api_fixture) -> None: + """Fixture to raise when getting scenes.""" + with ( + patch( + "homeassistant.components.roborock.RoborockApiClient.get_scenes", + side_effect=RoborockException(), + ), + ): + yield + + @pytest.fixture def platforms() -> list[Platform]: """Fixture to set platforms used in the test.""" @@ -84,3 +97,85 @@ async def test_update_failure( ) assert mock_send_message.assert_called_once assert hass.states.get(entity_id).state == "2023-10-30T08:50:00+00:00" + + +@pytest.mark.parametrize( + ("entity_id"), + [ + ("button.roborock_s7_maxv_sc1"), + ("button.roborock_s7_maxv_sc2"), + ], +) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_get_button_routines_failure( + hass: HomeAssistant, + bypass_api_client_get_scenes_fixture, + setup_entry: MockConfigEntry, + entity_id: str, +) -> None: + """Test that if routine retrieval fails, no entity is being created.""" + # Ensure that the entity does not exist + assert hass.states.get(entity_id) is None + + +@pytest.mark.parametrize( + ("entity_id", "routine_id"), + [ + ("button.roborock_s7_maxv_sc1", 12), + ("button.roborock_s7_maxv_sc2", 24), + ], +) +@pytest.mark.freeze_time("2023-10-30 08:50:00") +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_press_routine_button_success( + hass: HomeAssistant, + bypass_api_fixture, + setup_entry: MockConfigEntry, + entity_id: str, + routine_id: int, +) -> None: + """Test pressing the button entities.""" + with patch( + "homeassistant.components.roborock.RoborockApiClient.execute_scene" + ) as mock_execute_scene: + await hass.services.async_call( + "button", + SERVICE_PRESS, + blocking=True, + target={"entity_id": entity_id}, + ) + mock_execute_scene.assert_called_once_with(ANY, routine_id) + assert hass.states.get(entity_id).state == "2023-10-30T08:50:00+00:00" + + +@pytest.mark.parametrize( + ("entity_id", "routine_id"), + [ + ("button.roborock_s7_maxv_sc1", 12), + ], +) +@pytest.mark.freeze_time("2023-10-30 08:50:00") +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_press_routine_button_failure( + hass: HomeAssistant, + bypass_api_fixture, + setup_entry: MockConfigEntry, + entity_id: str, + routine_id: int, +) -> None: + """Test failure while pressing the button entity.""" + with ( + patch( + "homeassistant.components.roborock.RoborockApiClient.execute_scene", + side_effect=RoborockException, + ) as mock_execute_scene, + pytest.raises(HomeAssistantError, match="Error while calling execute_scene"), + ): + await hass.services.async_call( + "button", + SERVICE_PRESS, + blocking=True, + target={"entity_id": entity_id}, + ) + mock_execute_scene.assert_called_once_with(ANY, routine_id) + assert hass.states.get(entity_id).state == "2023-10-30T08:50:00+00:00" From 59073d47a1b241c63a3ff5f9d30a1655220d7cb5 Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Thu, 6 Mar 2025 12:44:13 -0500 Subject: [PATCH 2288/3148] Bump to python-snoo 0.6.1 (#139954) --- homeassistant/components/snoo/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/snoo/manifest.json b/homeassistant/components/snoo/manifest.json index 3dca8cfe7dd..c9306e58413 100644 --- a/homeassistant/components/snoo/manifest.json +++ b/homeassistant/components/snoo/manifest.json @@ -7,5 +7,5 @@ "iot_class": "cloud_push", "loggers": ["snoo"], "quality_scale": "bronze", - "requirements": ["python-snoo==0.6.0"] + "requirements": ["python-snoo==0.6.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 50fecf0e76b..3e280a4b560 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2467,7 +2467,7 @@ python-roborock==2.11.1 python-smarttub==0.0.39 # homeassistant.components.snoo -python-snoo==0.6.0 +python-snoo==0.6.1 # homeassistant.components.songpal python-songpal==0.16.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2920503ecbe..c7c83a3eb43 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2000,7 +2000,7 @@ python-roborock==2.11.1 python-smarttub==0.0.39 # homeassistant.components.snoo -python-snoo==0.6.0 +python-snoo==0.6.1 # homeassistant.components.songpal python-songpal==0.16.2 From f38a32477ec4424a71be85c6c838d22e35dafd50 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 6 Mar 2025 18:47:37 +0100 Subject: [PATCH 2289/3148] Fix SmartThings fan (#139962) --- homeassistant/components/smartthings/fan.py | 6 +- tests/components/smartthings/conftest.py | 1 + .../device_status/generic_fan_3_speed.json | 19 ++++++ .../fixtures/devices/generic_fan_3_speed.json | 63 +++++++++++++++++++ .../smartthings/snapshots/test_fan.ambr | 56 ++++++++++++++++- .../smartthings/snapshots/test_init.ambr | 33 ++++++++++ 6 files changed, 176 insertions(+), 2 deletions(-) create mode 100644 tests/components/smartthings/fixtures/device_status/generic_fan_3_speed.json create mode 100644 tests/components/smartthings/fixtures/devices/generic_fan_3_speed.json diff --git a/homeassistant/components/smartthings/fan.py b/homeassistant/components/smartthings/fan.py index 9aa467cbfa8..ef3d9702ce2 100644 --- a/homeassistant/components/smartthings/fan.py +++ b/homeassistant/components/smartthings/fan.py @@ -119,7 +119,7 @@ class SmartThingsFan(SmartThingsEntity, FanEntity): @property def is_on(self) -> bool: """Return true if fan is on.""" - return self.get_attribute_value(Capability.SWITCH, Attribute.SWITCH) + return self.get_attribute_value(Capability.SWITCH, Attribute.SWITCH) == "on" @property def percentage(self) -> int | None: @@ -135,6 +135,8 @@ class SmartThingsFan(SmartThingsEntity, FanEntity): Requires FanEntityFeature.PRESET_MODE. """ + if not self.supports_capability(Capability.AIR_CONDITIONER_FAN_MODE): + return None return self.get_attribute_value( Capability.AIR_CONDITIONER_FAN_MODE, Attribute.FAN_MODE ) @@ -145,6 +147,8 @@ class SmartThingsFan(SmartThingsEntity, FanEntity): Requires FanEntityFeature.PRESET_MODE. """ + if not self.supports_capability(Capability.AIR_CONDITIONER_FAN_MODE): + return None return self.get_attribute_value( Capability.AIR_CONDITIONER_FAN_MODE, Attribute.SUPPORTED_AC_FAN_MODES ) diff --git a/tests/components/smartthings/conftest.py b/tests/components/smartthings/conftest.py index c9a74862187..c78b4bc05de 100644 --- a/tests/components/smartthings/conftest.py +++ b/tests/components/smartthings/conftest.py @@ -119,6 +119,7 @@ def mock_smartthings() -> Generator[AsyncMock]: "ecobee_sensor", "ecobee_thermostat", "fake_fan", + "generic_fan_3_speed", ] ) def device_fixture( diff --git a/tests/components/smartthings/fixtures/device_status/generic_fan_3_speed.json b/tests/components/smartthings/fixtures/device_status/generic_fan_3_speed.json new file mode 100644 index 00000000000..9335bd8e042 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/generic_fan_3_speed.json @@ -0,0 +1,19 @@ +{ + "components": { + "main": { + "refresh": {}, + "fanSpeed": { + "fanSpeed": { + "value": 0, + "timestamp": "2025-03-06T11:47:32.683Z" + } + }, + "switch": { + "switch": { + "value": "off", + "timestamp": "2025-03-06T11:47:32.697Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/devices/generic_fan_3_speed.json b/tests/components/smartthings/fixtures/devices/generic_fan_3_speed.json new file mode 100644 index 00000000000..db218189c68 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/generic_fan_3_speed.json @@ -0,0 +1,63 @@ +{ + "items": [ + { + "deviceId": "6d95a8b7-4ee3-429a-a13a-00ec9354170c", + "name": "GE In-Wall Smart Dimmer", + "label": "Bedroom Fan", + "manufacturerName": "SmartThingsEdge", + "presentationId": "generic-fan-3-speed", + "deviceManufacturerCode": "0063-4944-3131", + "locationId": "f1313f27-6732-481d-a2a9-c7bbf900f867", + "ownerId": "e5216062-ac82-79b8-20db-ea65fa3d3fdd", + "roomId": "5f77f7cf-ece8-485e-a409-98f7b128a41a", + "components": [ + { + "id": "main", + "label": "Bedroom Fan", + "capabilities": [ + { + "id": "fanSpeed", + "version": 1 + }, + { + "id": "switch", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + } + ], + "categories": [ + { + "name": "Fan", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2018-01-12T22:12:15Z", + "parentDeviceId": "4ceb9b86-2f0d-4e98-ba4e-3fbe705f7805", + "profile": { + "id": "9bd81754-fc81-3ed1-86c2-d1094d6cbf6d" + }, + "zwave": { + "networkId": "02", + "driverId": "e7947a05-947d-4bb5-92c4-2aafaff6d69c", + "executingLocally": true, + "hubId": "4ceb9b86-2f0d-4e98-ba4e-3fbe705f7805", + "networkSecurityLevel": "ZWAVE_LEGACY_NON_SECURE", + "provisioningState": "PROVISIONED", + "manufacturerId": 99, + "productType": 18756, + "productId": 12593 + }, + "type": "ZWAVE", + "restrictionTier": 0, + "allowed": null, + "executionContext": "LOCAL", + "relationships": [] + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/snapshots/test_fan.ambr b/tests/components/smartthings/snapshots/test_fan.ambr index 33caffcacc6..40ab7b12267 100644 --- a/tests/components/smartthings/snapshots/test_fan.ambr +++ b/tests/components/smartthings/snapshots/test_fan.ambr @@ -62,6 +62,60 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'on', + 'state': 'off', + }) +# --- +# name: test_all_entities[generic_fan_3_speed][fan.bedroom_fan-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'preset_modes': None, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.bedroom_fan', + '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': None, + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '6d95a8b7-4ee3-429a-a13a-00ec9354170c', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[generic_fan_3_speed][fan.bedroom_fan-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Bedroom Fan', + 'percentage': 0, + 'percentage_step': 33.333333333333336, + 'preset_mode': None, + 'preset_modes': None, + 'supported_features': , + }), + 'context': , + 'entity_id': 'fan.bedroom_fan', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', }) # --- diff --git a/tests/components/smartthings/snapshots/test_init.ambr b/tests/components/smartthings/snapshots/test_init.ambr index ad7764848b4..2c09d0addaf 100644 --- a/tests/components/smartthings/snapshots/test_init.ambr +++ b/tests/components/smartthings/snapshots/test_init.ambr @@ -626,6 +626,39 @@ 'via_device_id': None, }) # --- +# name: test_devices[generic_fan_3_speed] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '6d95a8b7-4ee3-429a-a13a-00ec9354170c', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': None, + 'model': None, + 'model_id': None, + 'name': 'Bedroom Fan', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- # name: test_devices[hue_color_temperature_bulb] DeviceRegistryEntrySnapshot({ 'area_id': None, From 4bafdf5e4b4c9e7226420b2c8e021dfa2ebe7cb8 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 6 Mar 2025 18:48:39 +0100 Subject: [PATCH 2290/3148] Add config entry level diagnostics to SmartThings (#139939) * Add config entry level diagnostics to SmartThings * Add config entry level diagnostics to SmartThings * Add config entry level diagnostics to SmartThings --- .../components/smartthings/diagnostics.py | 25 +- .../snapshots/test_diagnostics.ambr | 2561 ++++++++++------- .../smartthings/test_diagnostics.py | 39 +- 3 files changed, 1513 insertions(+), 1112 deletions(-) diff --git a/homeassistant/components/smartthings/diagnostics.py b/homeassistant/components/smartthings/diagnostics.py index fc34415e419..dbc5d4e8224 100644 --- a/homeassistant/components/smartthings/diagnostics.py +++ b/homeassistant/components/smartthings/diagnostics.py @@ -17,6 +17,15 @@ from .const import DOMAIN EVENT_WAIT_TIME = 5 +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, + entry: SmartThingsConfigEntry, +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + client = entry.runtime_data.client + return await client.get_raw_devices() + + async def async_get_device_diagnostics( hass: HomeAssistant, entry: SmartThingsConfigEntry, device: DeviceEntry ) -> dict[str, Any]: @@ -26,7 +35,8 @@ async def async_get_device_diagnostics( identifier for identifier in device.identifiers if identifier[0] == DOMAIN )[1] - device_status = await client.get_device_status(device_id) + device_status = await client.get_raw_device_status(device_id) + device_info = await client.get_raw_device(device_id) events: list[DeviceEvent] = [] @@ -39,11 +49,8 @@ async def async_get_device_diagnostics( listener() - status: dict[str, Any] = {} - for component, capabilities in device_status.items(): - status[component] = {} - for capability, attributes in capabilities.items(): - status[component][capability] = {} - for attribute, value in attributes.items(): - status[component][capability][attribute] = asdict(value) - return {"events": [asdict(event) for event in events], "status": status} + return { + "events": [asdict(event) for event in events], + "status": device_status, + "info": device_info, + } diff --git a/tests/components/smartthings/snapshots/test_diagnostics.ambr b/tests/components/smartthings/snapshots/test_diagnostics.ambr index 50f568df5d1..7610c8839ba 100644 --- a/tests/components/smartthings/snapshots/test_diagnostics.ambr +++ b/tests/components/smartthings/snapshots/test_diagnostics.ambr @@ -1,1160 +1,1525 @@ # serializer version: 1 -# name: test_device[da_ac_rac_000001] +# name: test_config_entry_diagnostics[da_ac_rac_000001] + dict({ + '_links': dict({ + }), + 'items': list([ + dict({ + 'allowed': list([ + ]), + 'components': list([ + dict({ + 'capabilities': list([ + dict({ + 'id': 'ocf', + 'version': 1, + }), + dict({ + 'id': 'switch', + 'version': 1, + }), + dict({ + 'id': 'airConditionerMode', + 'version': 1, + }), + dict({ + 'id': 'airConditionerFanMode', + 'version': 1, + }), + dict({ + 'id': 'fanOscillationMode', + 'version': 1, + }), + dict({ + 'id': 'airQualitySensor', + 'version': 1, + }), + dict({ + 'id': 'temperatureMeasurement', + 'version': 1, + }), + dict({ + 'id': 'thermostatCoolingSetpoint', + 'version': 1, + }), + dict({ + 'id': 'relativeHumidityMeasurement', + 'version': 1, + }), + dict({ + 'id': 'dustSensor', + 'version': 1, + }), + dict({ + 'id': 'veryFineDustSensor', + 'version': 1, + }), + dict({ + 'id': 'audioVolume', + 'version': 1, + }), + dict({ + 'id': 'remoteControlStatus', + 'version': 1, + }), + dict({ + 'id': 'powerConsumptionReport', + 'version': 1, + }), + dict({ + 'id': 'demandResponseLoadControl', + 'version': 1, + }), + dict({ + 'id': 'refresh', + 'version': 1, + }), + dict({ + 'id': 'execute', + 'version': 1, + }), + dict({ + 'id': 'custom.spiMode', + 'version': 1, + }), + dict({ + 'id': 'custom.thermostatSetpointControl', + 'version': 1, + }), + dict({ + 'id': 'custom.airConditionerOptionalMode', + 'version': 1, + }), + dict({ + 'id': 'custom.airConditionerTropicalNightMode', + 'version': 1, + }), + dict({ + 'id': 'custom.autoCleaningMode', + 'version': 1, + }), + dict({ + 'id': 'custom.deviceReportStateConfiguration', + 'version': 1, + }), + dict({ + 'id': 'custom.energyType', + 'version': 1, + }), + dict({ + 'id': 'custom.dustFilter', + 'version': 1, + }), + dict({ + 'id': 'custom.airConditionerOdorController', + 'version': 1, + }), + dict({ + 'id': 'custom.deodorFilter', + 'version': 1, + }), + dict({ + 'id': 'custom.disabledComponents', + 'version': 1, + }), + dict({ + 'id': 'custom.disabledCapabilities', + 'version': 1, + }), + dict({ + 'id': 'samsungce.deviceIdentification', + 'version': 1, + }), + dict({ + 'id': 'samsungce.dongleSoftwareInstallation', + 'version': 1, + }), + dict({ + 'id': 'samsungce.softwareUpdate', + 'version': 1, + }), + dict({ + 'id': 'samsungce.selfCheck', + 'version': 1, + }), + dict({ + 'id': 'samsungce.driverVersion', + 'version': 1, + }), + ]), + 'categories': list([ + dict({ + 'categoryType': 'manufacturer', + 'name': 'AirConditioner', + }), + ]), + 'id': 'main', + 'label': 'main', + }), + dict({ + 'capabilities': list([ + dict({ + 'id': 'switch', + 'version': 1, + }), + dict({ + 'id': 'airConditionerMode', + 'version': 1, + }), + dict({ + 'id': 'airConditionerFanMode', + 'version': 1, + }), + dict({ + 'id': 'fanOscillationMode', + 'version': 1, + }), + dict({ + 'id': 'temperatureMeasurement', + 'version': 1, + }), + dict({ + 'id': 'thermostatCoolingSetpoint', + 'version': 1, + }), + dict({ + 'id': 'relativeHumidityMeasurement', + 'version': 1, + }), + dict({ + 'id': 'airQualitySensor', + 'version': 1, + }), + dict({ + 'id': 'dustSensor', + 'version': 1, + }), + dict({ + 'id': 'veryFineDustSensor', + 'version': 1, + }), + dict({ + 'id': 'odorSensor', + 'version': 1, + }), + dict({ + 'id': 'remoteControlStatus', + 'version': 1, + }), + dict({ + 'id': 'audioVolume', + 'version': 1, + }), + dict({ + 'id': 'custom.thermostatSetpointControl', + 'version': 1, + }), + dict({ + 'id': 'custom.autoCleaningMode', + 'version': 1, + }), + dict({ + 'id': 'custom.airConditionerTropicalNightMode', + 'version': 1, + }), + dict({ + 'id': 'custom.disabledCapabilities', + 'version': 1, + }), + dict({ + 'id': 'ocf', + 'version': 1, + }), + dict({ + 'id': 'powerConsumptionReport', + 'version': 1, + }), + dict({ + 'id': 'demandResponseLoadControl', + 'version': 1, + }), + dict({ + 'id': 'custom.spiMode', + 'version': 1, + }), + dict({ + 'id': 'custom.airConditionerOptionalMode', + 'version': 1, + }), + dict({ + 'id': 'custom.deviceReportStateConfiguration', + 'version': 1, + }), + dict({ + 'id': 'custom.energyType', + 'version': 1, + }), + dict({ + 'id': 'custom.dustFilter', + 'version': 1, + }), + dict({ + 'id': 'custom.airConditionerOdorController', + 'version': 1, + }), + dict({ + 'id': 'custom.deodorFilter', + 'version': 1, + }), + ]), + 'categories': list([ + dict({ + 'categoryType': 'manufacturer', + 'name': 'Other', + }), + ]), + 'id': '1', + 'label': '1', + }), + ]), + 'createTime': '2021-04-06T16:43:34.753Z', + 'deviceId': '96a5ef74-5832-a84b-f1f7-ca799957065d', + 'deviceManufacturerCode': 'Samsung Electronics', + 'deviceTypeName': 'Samsung OCF Air Conditioner', + 'executionContext': 'CLOUD', + 'label': 'AC Office Granit', + 'locationId': '58d3fd7c-c512-4da3-b500-ef269382756c', + 'manufacturerName': 'Samsung Electronics', + 'name': '[room a/c] Samsung', + 'ocf': dict({ + 'additionalAuthCodeRequired': False, + 'lastSignupTime': '2025-01-08T02:32:04.631093137Z', + 'manufacturerName': 'Samsung Electronics', + 'ocfDeviceType': 'x.com.st.d.sensor.light', + 'transferCandidate': False, + 'vendorId': 'VD-Sensor.Light-2023', + }), + 'ownerId': 'f9a28d7c-1ed5-d9e9-a81c-18971ec081db', + 'presentationId': 'DA-AC-RAC-000001', + 'profile': dict({ + 'id': '60fbc713-8da5-315d-b31a-6d6dcde4be7b', + }), + 'restrictionTier': 0, + 'roomId': '7715151d-0314-457a-a82c-5ce48900e065', + 'type': 'OCF', + }), + ]), + }) +# --- +# name: test_device_diagnostics[da_ac_rac_000001] dict({ 'events': list([ ]), + 'info': dict({ + 'allowed': list([ + ]), + 'components': list([ + dict({ + 'capabilities': list([ + dict({ + 'id': 'ocf', + 'version': 1, + }), + dict({ + 'id': 'switch', + 'version': 1, + }), + dict({ + 'id': 'airConditionerMode', + 'version': 1, + }), + dict({ + 'id': 'airConditionerFanMode', + 'version': 1, + }), + dict({ + 'id': 'fanOscillationMode', + 'version': 1, + }), + dict({ + 'id': 'airQualitySensor', + 'version': 1, + }), + dict({ + 'id': 'temperatureMeasurement', + 'version': 1, + }), + dict({ + 'id': 'thermostatCoolingSetpoint', + 'version': 1, + }), + dict({ + 'id': 'relativeHumidityMeasurement', + 'version': 1, + }), + dict({ + 'id': 'dustSensor', + 'version': 1, + }), + dict({ + 'id': 'veryFineDustSensor', + 'version': 1, + }), + dict({ + 'id': 'audioVolume', + 'version': 1, + }), + dict({ + 'id': 'remoteControlStatus', + 'version': 1, + }), + dict({ + 'id': 'powerConsumptionReport', + 'version': 1, + }), + dict({ + 'id': 'demandResponseLoadControl', + 'version': 1, + }), + dict({ + 'id': 'refresh', + 'version': 1, + }), + dict({ + 'id': 'execute', + 'version': 1, + }), + dict({ + 'id': 'custom.spiMode', + 'version': 1, + }), + dict({ + 'id': 'custom.thermostatSetpointControl', + 'version': 1, + }), + dict({ + 'id': 'custom.airConditionerOptionalMode', + 'version': 1, + }), + dict({ + 'id': 'custom.airConditionerTropicalNightMode', + 'version': 1, + }), + dict({ + 'id': 'custom.autoCleaningMode', + 'version': 1, + }), + dict({ + 'id': 'custom.deviceReportStateConfiguration', + 'version': 1, + }), + dict({ + 'id': 'custom.energyType', + 'version': 1, + }), + dict({ + 'id': 'custom.dustFilter', + 'version': 1, + }), + dict({ + 'id': 'custom.airConditionerOdorController', + 'version': 1, + }), + dict({ + 'id': 'custom.deodorFilter', + 'version': 1, + }), + dict({ + 'id': 'custom.disabledComponents', + 'version': 1, + }), + dict({ + 'id': 'custom.disabledCapabilities', + 'version': 1, + }), + dict({ + 'id': 'samsungce.deviceIdentification', + 'version': 1, + }), + dict({ + 'id': 'samsungce.dongleSoftwareInstallation', + 'version': 1, + }), + dict({ + 'id': 'samsungce.softwareUpdate', + 'version': 1, + }), + dict({ + 'id': 'samsungce.selfCheck', + 'version': 1, + }), + dict({ + 'id': 'samsungce.driverVersion', + 'version': 1, + }), + ]), + 'categories': list([ + dict({ + 'categoryType': 'manufacturer', + 'name': 'AirConditioner', + }), + ]), + 'id': 'main', + 'label': 'main', + }), + dict({ + 'capabilities': list([ + dict({ + 'id': 'switch', + 'version': 1, + }), + dict({ + 'id': 'airConditionerMode', + 'version': 1, + }), + dict({ + 'id': 'airConditionerFanMode', + 'version': 1, + }), + dict({ + 'id': 'fanOscillationMode', + 'version': 1, + }), + dict({ + 'id': 'temperatureMeasurement', + 'version': 1, + }), + dict({ + 'id': 'thermostatCoolingSetpoint', + 'version': 1, + }), + dict({ + 'id': 'relativeHumidityMeasurement', + 'version': 1, + }), + dict({ + 'id': 'airQualitySensor', + 'version': 1, + }), + dict({ + 'id': 'dustSensor', + 'version': 1, + }), + dict({ + 'id': 'veryFineDustSensor', + 'version': 1, + }), + dict({ + 'id': 'odorSensor', + 'version': 1, + }), + dict({ + 'id': 'remoteControlStatus', + 'version': 1, + }), + dict({ + 'id': 'audioVolume', + 'version': 1, + }), + dict({ + 'id': 'custom.thermostatSetpointControl', + 'version': 1, + }), + dict({ + 'id': 'custom.autoCleaningMode', + 'version': 1, + }), + dict({ + 'id': 'custom.airConditionerTropicalNightMode', + 'version': 1, + }), + dict({ + 'id': 'custom.disabledCapabilities', + 'version': 1, + }), + dict({ + 'id': 'ocf', + 'version': 1, + }), + dict({ + 'id': 'powerConsumptionReport', + 'version': 1, + }), + dict({ + 'id': 'demandResponseLoadControl', + 'version': 1, + }), + dict({ + 'id': 'custom.spiMode', + 'version': 1, + }), + dict({ + 'id': 'custom.airConditionerOptionalMode', + 'version': 1, + }), + dict({ + 'id': 'custom.deviceReportStateConfiguration', + 'version': 1, + }), + dict({ + 'id': 'custom.energyType', + 'version': 1, + }), + dict({ + 'id': 'custom.dustFilter', + 'version': 1, + }), + dict({ + 'id': 'custom.airConditionerOdorController', + 'version': 1, + }), + dict({ + 'id': 'custom.deodorFilter', + 'version': 1, + }), + ]), + 'categories': list([ + dict({ + 'categoryType': 'manufacturer', + 'name': 'Other', + }), + ]), + 'id': '1', + 'label': '1', + }), + ]), + 'createTime': '2021-04-06T16:43:34.753Z', + 'deviceId': '96a5ef74-5832-a84b-f1f7-ca799957065d', + 'deviceManufacturerCode': 'Samsung Electronics', + 'deviceTypeName': 'Samsung OCF Air Conditioner', + 'executionContext': 'CLOUD', + 'label': 'AC Office Granit', + 'locationId': '58d3fd7c-c512-4da3-b500-ef269382756c', + 'manufacturerName': 'Samsung Electronics', + 'name': '[room a/c] Samsung', + 'ocf': dict({ + 'additionalAuthCodeRequired': False, + 'lastSignupTime': '2025-01-08T02:32:04.631093137Z', + 'manufacturerName': 'Samsung Electronics', + 'ocfDeviceType': 'x.com.st.d.sensor.light', + 'transferCandidate': False, + 'vendorId': 'VD-Sensor.Light-2023', + }), + 'ownerId': 'f9a28d7c-1ed5-d9e9-a81c-18971ec081db', + 'presentationId': 'DA-AC-RAC-000001', + 'profile': dict({ + 'id': '60fbc713-8da5-315d-b31a-6d6dcde4be7b', + }), + 'restrictionTier': 0, + 'roomId': '7715151d-0314-457a-a82c-5ce48900e065', + 'type': 'OCF', + }), 'status': dict({ - '1': dict({ - 'airConditionerFanMode': dict({ - 'availableAcFanModes': dict({ - 'data': None, - 'timestamp': None, - 'unit': None, - 'value': None, + 'components': dict({ + '1': dict({ + 'airConditionerFanMode': dict({ + 'availableAcFanModes': dict({ + 'value': None, + }), + 'fanMode': dict({ + 'timestamp': '2021-04-06T16:44:10.381Z', + 'value': None, + }), + 'supportedAcFanModes': dict({ + 'timestamp': '2024-09-10T10:26:28.605Z', + 'value': list([ + 'auto', + 'low', + 'medium', + 'high', + 'turbo', + ]), + }), }), - 'fanMode': dict({ - 'data': None, - 'timestamp': '2021-04-06T16:44:10.381000+00:00', - 'unit': None, - 'value': None, - }), - 'supportedAcFanModes': dict({ - 'data': None, - 'timestamp': '2024-09-10T10:26:28.605000+00:00', - 'unit': None, - 'value': list([ - 'auto', - 'low', - 'medium', - 'high', - 'turbo', - ]), - }), - }), - 'airConditionerMode': dict({ 'airConditionerMode': dict({ - 'data': None, - 'timestamp': '2021-04-08T03:50:50.930000+00:00', - 'unit': None, - 'value': None, + 'airConditionerMode': dict({ + 'timestamp': '2021-04-08T03:50:50.930Z', + 'value': None, + }), + 'availableAcModes': dict({ + 'value': None, + }), + 'supportedAcModes': dict({ + 'timestamp': '2021-04-08T03:50:50.930Z', + 'value': None, + }), }), - 'availableAcModes': dict({ - 'data': None, - 'timestamp': None, - 'unit': None, - 'value': None, + 'airQualitySensor': dict({ + 'airQuality': dict({ + 'timestamp': '2021-04-06T16:57:57.602Z', + 'unit': 'CAQI', + 'value': None, + }), }), - 'supportedAcModes': dict({ - 'data': None, - 'timestamp': '2021-04-08T03:50:50.930000+00:00', - 'unit': None, - 'value': None, + 'audioVolume': dict({ + 'volume': dict({ + 'timestamp': '2021-04-06T16:43:53.541Z', + 'unit': '%', + 'value': None, + }), }), - }), - 'airQualitySensor': dict({ - 'airQuality': dict({ - 'data': None, - 'timestamp': '2021-04-06T16:57:57.602000+00:00', - 'unit': 'CAQI', - 'value': None, + 'custom.airConditionerOdorController': dict({ + 'airConditionerOdorControllerProgress': dict({ + 'timestamp': '2021-04-08T04:11:38.269Z', + 'value': None, + }), + 'airConditionerOdorControllerState': dict({ + 'timestamp': '2021-04-08T04:11:38.269Z', + 'value': None, + }), }), - }), - 'audioVolume': dict({ - 'volume': dict({ - 'data': None, - 'timestamp': '2021-04-06T16:43:53.541000+00:00', - 'unit': '%', - 'value': None, + 'custom.airConditionerOptionalMode': dict({ + 'acOptionalMode': dict({ + 'timestamp': '2021-04-06T16:57:57.659Z', + 'value': None, + }), + 'supportedAcOptionalMode': dict({ + 'timestamp': '2021-04-06T16:57:57.659Z', + 'value': None, + }), }), - }), - 'custom.airConditionerOdorController': dict({ - 'airConditionerOdorControllerProgress': dict({ - 'data': None, - 'timestamp': '2021-04-08T04:11:38.269000+00:00', - 'unit': None, - 'value': None, + 'custom.airConditionerTropicalNightMode': dict({ + 'acTropicalNightModeLevel': dict({ + 'timestamp': '2021-04-06T16:44:10.498Z', + 'value': None, + }), }), - 'airConditionerOdorControllerState': dict({ - 'data': None, - 'timestamp': '2021-04-08T04:11:38.269000+00:00', - 'unit': None, - 'value': None, + 'custom.autoCleaningMode': dict({ + 'autoCleaningMode': dict({ + 'timestamp': '2021-04-06T16:43:53.344Z', + 'value': None, + }), + 'operatingState': dict({ + 'value': None, + }), + 'progress': dict({ + 'value': None, + }), + 'supportedAutoCleaningModes': dict({ + 'value': None, + }), + 'supportedOperatingStates': dict({ + 'value': None, + }), + 'timedCleanDuration': dict({ + 'value': None, + }), + 'timedCleanDurationRange': dict({ + 'value': None, + }), }), - }), - 'custom.airConditionerOptionalMode': dict({ - 'acOptionalMode': dict({ - 'data': None, - 'timestamp': '2021-04-06T16:57:57.659000+00:00', - 'unit': None, - 'value': None, + 'custom.deodorFilter': dict({ + 'deodorFilterCapacity': dict({ + 'timestamp': '2021-04-06T16:43:39.118Z', + 'value': None, + }), + 'deodorFilterLastResetDate': dict({ + 'timestamp': '2021-04-06T16:43:39.118Z', + 'value': None, + }), + 'deodorFilterResetType': dict({ + 'timestamp': '2021-04-06T16:43:39.118Z', + 'value': None, + }), + 'deodorFilterStatus': dict({ + 'timestamp': '2021-04-06T16:43:39.118Z', + 'value': None, + }), + 'deodorFilterUsage': dict({ + 'timestamp': '2021-04-06T16:43:39.118Z', + 'value': None, + }), + 'deodorFilterUsageStep': dict({ + 'timestamp': '2021-04-06T16:43:39.118Z', + 'value': None, + }), }), - 'supportedAcOptionalMode': dict({ - 'data': None, - 'timestamp': '2021-04-06T16:57:57.659000+00:00', - 'unit': None, - 'value': None, + 'custom.deviceReportStateConfiguration': dict({ + 'reportStatePeriod': dict({ + 'timestamp': '2021-04-06T16:44:09.800Z', + 'value': None, + }), + 'reportStateRealtime': dict({ + 'timestamp': '2021-04-06T16:44:09.800Z', + 'value': None, + }), + 'reportStateRealtimePeriod': dict({ + 'timestamp': '2021-04-06T16:44:09.800Z', + 'value': None, + }), }), - }), - 'custom.airConditionerTropicalNightMode': dict({ - 'acTropicalNightModeLevel': dict({ - 'data': None, - 'timestamp': '2021-04-06T16:44:10.498000+00:00', - 'unit': None, - 'value': None, + 'custom.disabledCapabilities': dict({ + 'disabledCapabilities': dict({ + 'timestamp': '2024-09-10T10:26:28.605Z', + 'value': list([ + 'remoteControlStatus', + 'airQualitySensor', + 'dustSensor', + 'odorSensor', + 'veryFineDustSensor', + 'custom.dustFilter', + 'custom.deodorFilter', + 'custom.deviceReportStateConfiguration', + 'audioVolume', + 'custom.autoCleaningMode', + 'custom.airConditionerTropicalNightMode', + 'custom.airConditionerOdorController', + 'demandResponseLoadControl', + 'relativeHumidityMeasurement', + ]), + }), }), - }), - 'custom.autoCleaningMode': dict({ - 'autoCleaningMode': dict({ - 'data': None, - 'timestamp': '2021-04-06T16:43:53.344000+00:00', - 'unit': None, - 'value': None, + 'custom.dustFilter': dict({ + 'dustFilterCapacity': dict({ + 'timestamp': '2021-04-06T16:43:39.145Z', + 'value': None, + }), + 'dustFilterLastResetDate': dict({ + 'timestamp': '2021-04-06T16:43:39.145Z', + 'value': None, + }), + 'dustFilterResetType': dict({ + 'timestamp': '2021-04-06T16:43:39.145Z', + 'value': None, + }), + 'dustFilterStatus': dict({ + 'timestamp': '2021-04-06T16:43:39.145Z', + 'value': None, + }), + 'dustFilterUsage': dict({ + 'timestamp': '2021-04-06T16:43:39.145Z', + 'value': None, + }), + 'dustFilterUsageStep': dict({ + 'timestamp': '2021-04-06T16:43:39.145Z', + 'value': None, + }), }), - 'operatingState': dict({ - 'data': None, - 'timestamp': None, - 'unit': None, - 'value': None, + 'custom.energyType': dict({ + 'drMaxDuration': dict({ + 'value': None, + }), + 'energySavingInfo': dict({ + 'value': None, + }), + 'energySavingLevel': dict({ + 'value': None, + }), + 'energySavingOperation': dict({ + 'value': None, + }), + 'energySavingOperationSupport': dict({ + 'value': None, + }), + 'energySavingSupport': dict({ + 'value': None, + }), + 'energyType': dict({ + 'timestamp': '2021-04-06T16:43:38.843Z', + 'value': None, + }), + 'notificationTemplateID': dict({ + 'value': None, + }), + 'supportedEnergySavingLevels': dict({ + 'value': None, + }), }), - 'progress': dict({ - 'data': None, - 'timestamp': None, - 'unit': None, - 'value': None, + 'custom.spiMode': dict({ + 'spiMode': dict({ + 'timestamp': '2021-04-06T16:57:57.686Z', + 'value': None, + }), }), - 'supportedAutoCleaningModes': dict({ - 'data': None, - 'timestamp': None, - 'unit': None, - 'value': None, + 'custom.thermostatSetpointControl': dict({ + 'maximumSetpoint': dict({ + 'timestamp': '2021-04-08T04:04:19.901Z', + 'value': None, + }), + 'minimumSetpoint': dict({ + 'timestamp': '2021-04-08T04:04:19.901Z', + 'value': None, + }), }), - 'supportedOperatingStates': dict({ - 'data': None, - 'timestamp': None, - 'unit': None, - 'value': None, + 'demandResponseLoadControl': dict({ + 'drlcStatus': dict({ + 'timestamp': '2021-04-06T16:43:54.748Z', + 'value': None, + }), }), - 'timedCleanDuration': dict({ - 'data': None, - 'timestamp': None, - 'unit': None, - 'value': None, - }), - 'timedCleanDurationRange': dict({ - 'data': None, - 'timestamp': None, - 'unit': None, - 'value': None, - }), - }), - 'custom.deodorFilter': dict({ - 'deodorFilterCapacity': dict({ - 'data': None, - 'timestamp': '2021-04-06T16:43:39.118000+00:00', - 'unit': None, - 'value': None, - }), - 'deodorFilterLastResetDate': dict({ - 'data': None, - 'timestamp': '2021-04-06T16:43:39.118000+00:00', - 'unit': None, - 'value': None, - }), - 'deodorFilterResetType': dict({ - 'data': None, - 'timestamp': '2021-04-06T16:43:39.118000+00:00', - 'unit': None, - 'value': None, - }), - 'deodorFilterStatus': dict({ - 'data': None, - 'timestamp': '2021-04-06T16:43:39.118000+00:00', - 'unit': None, - 'value': None, - }), - 'deodorFilterUsage': dict({ - 'data': None, - 'timestamp': '2021-04-06T16:43:39.118000+00:00', - 'unit': None, - 'value': None, - }), - 'deodorFilterUsageStep': dict({ - 'data': None, - 'timestamp': '2021-04-06T16:43:39.118000+00:00', - 'unit': None, - 'value': None, - }), - }), - 'custom.deviceReportStateConfiguration': dict({ - 'reportStatePeriod': dict({ - 'data': None, - 'timestamp': '2021-04-06T16:44:09.800000+00:00', - 'unit': None, - 'value': None, - }), - 'reportStateRealtime': dict({ - 'data': None, - 'timestamp': '2021-04-06T16:44:09.800000+00:00', - 'unit': None, - 'value': None, - }), - 'reportStateRealtimePeriod': dict({ - 'data': None, - 'timestamp': '2021-04-06T16:44:09.800000+00:00', - 'unit': None, - 'value': None, - }), - }), - 'custom.disabledCapabilities': dict({ - 'disabledCapabilities': dict({ - 'data': None, - 'timestamp': '2024-09-10T10:26:28.605000+00:00', - 'unit': None, - 'value': list([ - 'remoteControlStatus', - 'airQualitySensor', - 'dustSensor', - 'odorSensor', - 'veryFineDustSensor', - 'custom.dustFilter', - 'custom.deodorFilter', - 'custom.deviceReportStateConfiguration', - 'audioVolume', - 'custom.autoCleaningMode', - 'custom.airConditionerTropicalNightMode', - 'custom.airConditionerOdorController', - 'demandResponseLoadControl', - 'relativeHumidityMeasurement', - ]), - }), - }), - 'custom.dustFilter': dict({ - 'dustFilterCapacity': dict({ - 'data': None, - 'timestamp': '2021-04-06T16:43:39.145000+00:00', - 'unit': None, - 'value': None, - }), - 'dustFilterLastResetDate': dict({ - 'data': None, - 'timestamp': '2021-04-06T16:43:39.145000+00:00', - 'unit': None, - 'value': None, - }), - 'dustFilterResetType': dict({ - 'data': None, - 'timestamp': '2021-04-06T16:43:39.145000+00:00', - 'unit': None, - 'value': None, - }), - 'dustFilterStatus': dict({ - 'data': None, - 'timestamp': '2021-04-06T16:43:39.145000+00:00', - 'unit': None, - 'value': None, - }), - 'dustFilterUsage': dict({ - 'data': None, - 'timestamp': '2021-04-06T16:43:39.145000+00:00', - 'unit': None, - 'value': None, - }), - 'dustFilterUsageStep': dict({ - 'data': None, - 'timestamp': '2021-04-06T16:43:39.145000+00:00', - 'unit': None, - 'value': None, - }), - }), - 'custom.energyType': dict({ - 'drMaxDuration': dict({ - 'data': None, - 'timestamp': None, - 'unit': None, - 'value': None, - }), - 'energySavingInfo': dict({ - 'data': None, - 'timestamp': None, - 'unit': None, - 'value': None, - }), - 'energySavingLevel': dict({ - 'data': None, - 'timestamp': None, - 'unit': None, - 'value': None, - }), - 'energySavingOperation': dict({ - 'data': None, - 'timestamp': None, - 'unit': None, - 'value': None, - }), - 'energySavingOperationSupport': dict({ - 'data': None, - 'timestamp': None, - 'unit': None, - 'value': None, - }), - 'energySavingSupport': dict({ - 'data': None, - 'timestamp': None, - 'unit': None, - 'value': None, - }), - 'energyType': dict({ - 'data': None, - 'timestamp': '2021-04-06T16:43:38.843000+00:00', - 'unit': None, - 'value': None, - }), - 'notificationTemplateID': dict({ - 'data': None, - 'timestamp': None, - 'unit': None, - 'value': None, - }), - 'supportedEnergySavingLevels': dict({ - 'data': None, - 'timestamp': None, - 'unit': None, - 'value': None, - }), - }), - 'custom.spiMode': dict({ - 'spiMode': dict({ - 'data': None, - 'timestamp': '2021-04-06T16:57:57.686000+00:00', - 'unit': None, - 'value': None, - }), - }), - 'custom.thermostatSetpointControl': dict({ - 'maximumSetpoint': dict({ - 'data': None, - 'timestamp': '2021-04-08T04:04:19.901000+00:00', - 'unit': None, - 'value': None, - }), - 'minimumSetpoint': dict({ - 'data': None, - 'timestamp': '2021-04-08T04:04:19.901000+00:00', - 'unit': None, - 'value': None, - }), - }), - 'demandResponseLoadControl': dict({ - 'drlcStatus': dict({ - 'data': None, - 'timestamp': '2021-04-06T16:43:54.748000+00:00', - 'unit': None, - 'value': None, - }), - }), - 'dustSensor': dict({ - 'dustLevel': dict({ - 'data': None, - 'timestamp': '2021-04-06T16:44:10.122000+00:00', - 'unit': 'μg/m^3', - 'value': None, - }), - 'fineDustLevel': dict({ - 'data': None, - 'timestamp': '2021-04-06T16:44:10.122000+00:00', - 'unit': 'μg/m^3', - 'value': None, - }), - }), - 'fanOscillationMode': dict({ - 'availableFanOscillationModes': dict({ - 'data': None, - 'timestamp': None, - 'unit': None, - 'value': None, + 'dustSensor': dict({ + 'dustLevel': dict({ + 'timestamp': '2021-04-06T16:44:10.122Z', + 'unit': 'μg/m^3', + 'value': None, + }), + 'fineDustLevel': dict({ + 'timestamp': '2021-04-06T16:44:10.122Z', + 'unit': 'μg/m^3', + 'value': None, + }), }), 'fanOscillationMode': dict({ - 'data': None, - 'timestamp': '2025-02-08T00:44:53.247000+00:00', - 'unit': None, - 'value': 'fixed', + 'availableFanOscillationModes': dict({ + 'value': None, + }), + 'fanOscillationMode': dict({ + 'timestamp': '2025-02-08T00:44:53.247Z', + 'value': 'fixed', + }), + 'supportedFanOscillationModes': dict({ + 'timestamp': '2021-04-06T16:44:10.325Z', + 'value': None, + }), }), - 'supportedFanOscillationModes': dict({ - 'data': None, - 'timestamp': '2021-04-06T16:44:10.325000+00:00', - 'unit': None, - 'value': None, + 'ocf': dict({ + 'di': dict({ + 'timestamp': '2021-04-06T16:44:10.472Z', + 'value': None, + }), + 'dmv': dict({ + 'timestamp': '2021-04-06T16:44:10.472Z', + 'value': None, + }), + 'icv': dict({ + 'timestamp': '2021-04-06T16:44:10.472Z', + 'value': None, + }), + 'mndt': dict({ + 'timestamp': '2021-04-06T16:44:10.472Z', + 'value': None, + }), + 'mnfv': dict({ + 'timestamp': '2021-04-06T16:44:10.472Z', + 'value': None, + }), + 'mnhw': dict({ + 'timestamp': '2021-04-06T16:44:10.472Z', + 'value': None, + }), + 'mnml': dict({ + 'timestamp': '2021-04-06T16:44:10.472Z', + 'value': None, + }), + 'mnmn': dict({ + 'timestamp': '2021-04-06T16:44:10.472Z', + 'value': None, + }), + 'mnmo': dict({ + 'timestamp': '2021-04-06T16:44:10.472Z', + 'value': None, + }), + 'mnos': dict({ + 'timestamp': '2021-04-06T16:44:10.472Z', + 'value': None, + }), + 'mnpv': dict({ + 'timestamp': '2021-04-06T16:44:10.472Z', + 'value': None, + }), + 'mnsl': dict({ + 'timestamp': '2021-04-06T16:44:10.472Z', + 'value': None, + }), + 'n': dict({ + 'timestamp': '2021-04-06T16:44:10.472Z', + 'value': None, + }), + 'pi': dict({ + 'timestamp': '2021-04-06T16:44:10.472Z', + 'value': None, + }), + 'st': dict({ + 'timestamp': '2021-04-06T16:44:10.472Z', + 'value': None, + }), + 'vid': dict({ + 'timestamp': '2021-04-06T16:44:10.472Z', + 'value': None, + }), }), - }), - 'ocf': dict({ - 'di': dict({ - 'data': None, - 'timestamp': '2021-04-06T16:44:10.472000+00:00', - 'unit': None, - 'value': None, + 'odorSensor': dict({ + 'odorLevel': dict({ + 'timestamp': '2021-04-06T16:43:38.992Z', + 'value': None, + }), }), - 'dmv': dict({ - 'data': None, - 'timestamp': '2021-04-06T16:44:10.472000+00:00', - 'unit': None, - 'value': None, + 'powerConsumptionReport': dict({ + 'powerConsumption': dict({ + 'timestamp': '2021-04-06T16:43:53.364Z', + 'value': None, + }), }), - 'icv': dict({ - 'data': None, - 'timestamp': '2021-04-06T16:44:10.472000+00:00', - 'unit': None, - 'value': None, + 'relativeHumidityMeasurement': dict({ + 'humidity': dict({ + 'timestamp': '2021-04-06T16:43:35.291Z', + 'unit': '%', + 'value': 0, + }), }), - 'mndt': dict({ - 'data': None, - 'timestamp': '2021-04-06T16:44:10.472000+00:00', - 'unit': None, - 'value': None, + 'remoteControlStatus': dict({ + 'remoteControlEnabled': dict({ + 'timestamp': '2021-04-06T16:43:39.097Z', + 'value': None, + }), }), - 'mnfv': dict({ - 'data': None, - 'timestamp': '2021-04-06T16:44:10.472000+00:00', - 'unit': None, - 'value': None, - }), - 'mnhw': dict({ - 'data': None, - 'timestamp': '2021-04-06T16:44:10.472000+00:00', - 'unit': None, - 'value': None, - }), - 'mnml': dict({ - 'data': None, - 'timestamp': '2021-04-06T16:44:10.472000+00:00', - 'unit': None, - 'value': None, - }), - 'mnmn': dict({ - 'data': None, - 'timestamp': '2021-04-06T16:44:10.472000+00:00', - 'unit': None, - 'value': None, - }), - 'mnmo': dict({ - 'data': None, - 'timestamp': '2021-04-06T16:44:10.472000+00:00', - 'unit': None, - 'value': None, - }), - 'mnos': dict({ - 'data': None, - 'timestamp': '2021-04-06T16:44:10.472000+00:00', - 'unit': None, - 'value': None, - }), - 'mnpv': dict({ - 'data': None, - 'timestamp': '2021-04-06T16:44:10.472000+00:00', - 'unit': None, - 'value': None, - }), - 'mnsl': dict({ - 'data': None, - 'timestamp': '2021-04-06T16:44:10.472000+00:00', - 'unit': None, - 'value': None, - }), - 'n': dict({ - 'data': None, - 'timestamp': '2021-04-06T16:44:10.472000+00:00', - 'unit': None, - 'value': None, - }), - 'pi': dict({ - 'data': None, - 'timestamp': '2021-04-06T16:44:10.472000+00:00', - 'unit': None, - 'value': None, - }), - 'st': dict({ - 'data': None, - 'timestamp': '2021-04-06T16:44:10.472000+00:00', - 'unit': None, - 'value': None, - }), - 'vid': dict({ - 'data': None, - 'timestamp': '2021-04-06T16:44:10.472000+00:00', - 'unit': None, - 'value': None, - }), - }), - 'odorSensor': dict({ - 'odorLevel': dict({ - 'data': None, - 'timestamp': '2021-04-06T16:43:38.992000+00:00', - 'unit': None, - 'value': None, - }), - }), - 'powerConsumptionReport': dict({ - 'powerConsumption': dict({ - 'data': None, - 'timestamp': '2021-04-06T16:43:53.364000+00:00', - 'unit': None, - 'value': None, - }), - }), - 'relativeHumidityMeasurement': dict({ - 'humidity': dict({ - 'data': None, - 'timestamp': '2021-04-06T16:43:35.291000+00:00', - 'unit': '%', - 'value': 0, - }), - }), - 'remoteControlStatus': dict({ - 'remoteControlEnabled': dict({ - 'data': None, - 'timestamp': '2021-04-06T16:43:39.097000+00:00', - 'unit': None, - 'value': None, - }), - }), - 'switch': dict({ 'switch': dict({ - 'data': None, - 'timestamp': '2021-04-06T16:44:10.518000+00:00', - 'unit': None, - 'value': None, + 'switch': dict({ + 'timestamp': '2021-04-06T16:44:10.518Z', + 'value': None, + }), + }), + 'temperatureMeasurement': dict({ + 'temperature': dict({ + 'timestamp': '2021-04-06T16:44:10.373Z', + 'value': None, + }), + 'temperatureRange': dict({ + 'value': None, + }), + }), + 'thermostatCoolingSetpoint': dict({ + 'coolingSetpoint': dict({ + 'timestamp': '2021-04-06T16:43:59.136Z', + 'value': None, + }), + 'coolingSetpointRange': dict({ + 'value': None, + }), + }), + 'veryFineDustSensor': dict({ + 'veryFineDustLevel': dict({ + 'timestamp': '2021-04-06T16:43:38.529Z', + 'unit': 'μg/m^3', + 'value': None, + }), }), }), - 'temperatureMeasurement': dict({ - 'temperature': dict({ - 'data': None, - 'timestamp': '2021-04-06T16:44:10.373000+00:00', - 'unit': None, - 'value': None, + 'main': dict({ + 'airConditionerFanMode': dict({ + 'availableAcFanModes': dict({ + 'value': None, + }), + 'fanMode': dict({ + 'timestamp': '2025-02-09T09:14:39.249Z', + 'value': 'low', + }), + 'supportedAcFanModes': dict({ + 'timestamp': '2025-02-09T09:14:39.249Z', + 'value': list([ + 'auto', + 'low', + 'medium', + 'high', + 'turbo', + ]), + }), }), - 'temperatureRange': dict({ - 'data': None, - 'timestamp': None, - 'unit': None, - 'value': None, - }), - }), - 'thermostatCoolingSetpoint': dict({ - 'coolingSetpoint': dict({ - 'data': None, - 'timestamp': '2021-04-06T16:43:59.136000+00:00', - 'unit': None, - 'value': None, - }), - 'coolingSetpointRange': dict({ - 'data': None, - 'timestamp': None, - 'unit': None, - 'value': None, - }), - }), - 'veryFineDustSensor': dict({ - 'veryFineDustLevel': dict({ - 'data': None, - 'timestamp': '2021-04-06T16:43:38.529000+00:00', - 'unit': 'μg/m^3', - 'value': None, - }), - }), - }), - 'main': dict({ - 'airConditionerFanMode': dict({ - 'availableAcFanModes': dict({ - 'data': None, - 'timestamp': None, - 'unit': None, - 'value': None, - }), - 'fanMode': dict({ - 'data': None, - 'timestamp': '2025-02-09T09:14:39.249000+00:00', - 'unit': None, - 'value': 'low', - }), - 'supportedAcFanModes': dict({ - 'data': None, - 'timestamp': '2025-02-09T09:14:39.249000+00:00', - 'unit': None, - 'value': list([ - 'auto', - 'low', - 'medium', - 'high', - 'turbo', - ]), - }), - }), - 'airConditionerMode': dict({ 'airConditionerMode': dict({ - 'data': None, - 'timestamp': '2025-02-09T09:14:39.642000+00:00', - 'unit': None, - 'value': 'heat', - }), - 'availableAcModes': dict({ - 'data': None, - 'timestamp': None, - 'unit': None, - 'value': None, - }), - 'supportedAcModes': dict({ - 'data': None, - 'timestamp': '2024-09-10T10:26:28.781000+00:00', - 'unit': None, - 'value': list([ - 'cool', - 'dry', - 'wind', - 'auto', - 'heat', - ]), - }), - }), - 'audioVolume': dict({ - 'volume': dict({ - 'data': None, - 'timestamp': '2025-02-09T09:14:39.642000+00:00', - 'unit': '%', - 'value': 100, - }), - }), - 'custom.airConditionerOptionalMode': dict({ - 'acOptionalMode': dict({ - 'data': None, - 'timestamp': '2025-02-09T09:14:39.642000+00:00', - 'unit': None, - 'value': 'off', - }), - 'supportedAcOptionalMode': dict({ - 'data': None, - 'timestamp': '2024-09-10T10:26:28.781000+00:00', - 'unit': None, - 'value': list([ - 'off', - 'windFree', - ]), - }), - }), - 'custom.airConditionerTropicalNightMode': dict({ - 'acTropicalNightModeLevel': dict({ - 'data': None, - 'timestamp': '2025-02-09T09:14:39.642000+00:00', - 'unit': None, - 'value': 0, - }), - }), - 'custom.autoCleaningMode': dict({ - 'autoCleaningMode': dict({ - 'data': None, - 'timestamp': '2025-02-09T09:14:39.642000+00:00', - 'unit': None, - 'value': 'off', - }), - 'operatingState': dict({ - 'data': None, - 'timestamp': None, - 'unit': None, - 'value': None, - }), - 'progress': dict({ - 'data': None, - 'timestamp': None, - 'unit': None, - 'value': None, - }), - 'supportedAutoCleaningModes': dict({ - 'data': None, - 'timestamp': None, - 'unit': None, - 'value': None, - }), - 'supportedOperatingStates': dict({ - 'data': None, - 'timestamp': None, - 'unit': None, - 'value': None, - }), - 'timedCleanDuration': dict({ - 'data': None, - 'timestamp': None, - 'unit': None, - 'value': None, - }), - 'timedCleanDurationRange': dict({ - 'data': None, - 'timestamp': None, - 'unit': None, - 'value': None, - }), - }), - 'custom.disabledCapabilities': dict({ - 'disabledCapabilities': dict({ - 'data': None, - 'timestamp': '2025-02-09T09:14:39.642000+00:00', - 'unit': None, - 'value': list([ - 'remoteControlStatus', - 'airQualitySensor', - 'dustSensor', - 'veryFineDustSensor', - 'custom.dustFilter', - 'custom.deodorFilter', - 'custom.deviceReportStateConfiguration', - 'samsungce.dongleSoftwareInstallation', - 'demandResponseLoadControl', - 'custom.airConditionerOdorController', - ]), - }), - }), - 'custom.disabledComponents': dict({ - 'disabledComponents': dict({ - 'data': None, - 'timestamp': '2025-02-09T09:14:39.642000+00:00', - 'unit': None, - 'value': list([ - '1', - ]), - }), - }), - 'custom.energyType': dict({ - 'drMaxDuration': dict({ - 'data': None, - 'timestamp': None, - 'unit': None, - 'value': None, - }), - 'energySavingInfo': dict({ - 'data': None, - 'timestamp': None, - 'unit': None, - 'value': None, - }), - 'energySavingLevel': dict({ - 'data': None, - 'timestamp': None, - 'unit': None, - 'value': None, - }), - 'energySavingOperation': dict({ - 'data': None, - 'timestamp': None, - 'unit': None, - 'value': None, - }), - 'energySavingOperationSupport': dict({ - 'data': None, - 'timestamp': None, - 'unit': None, - 'value': None, - }), - 'energySavingSupport': dict({ - 'data': None, - 'timestamp': '2021-12-29T07:29:17.526000+00:00', - 'unit': None, - 'value': 'False', - }), - 'energyType': dict({ - 'data': None, - 'timestamp': '2024-09-10T10:26:28.781000+00:00', - 'unit': None, - 'value': '1.0', - }), - 'notificationTemplateID': dict({ - 'data': None, - 'timestamp': None, - 'unit': None, - 'value': None, - }), - 'supportedEnergySavingLevels': dict({ - 'data': None, - 'timestamp': None, - 'unit': None, - 'value': None, - }), - }), - 'custom.spiMode': dict({ - 'spiMode': dict({ - 'data': None, - 'timestamp': '2025-02-09T09:14:39.642000+00:00', - 'unit': None, - 'value': 'off', - }), - }), - 'custom.thermostatSetpointControl': dict({ - 'maximumSetpoint': dict({ - 'data': None, - 'timestamp': '2024-09-10T10:26:28.781000+00:00', - 'unit': 'C', - 'value': 30, - }), - 'minimumSetpoint': dict({ - 'data': None, - 'timestamp': '2025-01-08T06:30:58.307000+00:00', - 'unit': 'C', - 'value': 16, - }), - }), - 'demandResponseLoadControl': dict({ - 'drlcStatus': dict({ - 'data': None, - 'timestamp': '2024-09-10T10:26:28.781000+00:00', - 'unit': None, - 'value': dict({ - 'drlcLevel': -1, - 'drlcType': 1, - 'duration': 0, - 'override': False, - 'start': '1970-01-01T00:00:00Z', + 'airConditionerMode': dict({ + 'timestamp': '2025-02-09T09:14:39.642Z', + 'value': 'heat', + }), + 'availableAcModes': dict({ + 'value': None, + }), + 'supportedAcModes': dict({ + 'timestamp': '2024-09-10T10:26:28.781Z', + 'value': list([ + 'cool', + 'dry', + 'wind', + 'auto', + 'heat', + ]), }), }), - }), - 'execute': dict({ - 'data': dict({ - 'data': dict({ - 'href': '/temperature/desired/0', + 'airQualitySensor': dict({ + 'airQuality': dict({ + 'timestamp': '2021-04-06T16:43:37.208Z', + 'unit': 'CAQI', + 'value': None, }), - 'timestamp': '2023-07-19T03:07:43.270000+00:00', - 'unit': None, - 'value': dict({ - 'payload': dict({ - 'if': list([ - 'oic.if.baseline', - 'oic.if.a', - ]), - 'range': list([ - 16.0, - 30.0, - ]), - 'rt': list([ - 'oic.r.temperature', - ]), - 'temperature': 22.0, - 'units': 'C', + }), + 'audioVolume': dict({ + 'volume': dict({ + 'timestamp': '2025-02-09T09:14:39.642Z', + 'unit': '%', + 'value': 100, + }), + }), + 'custom.airConditionerOdorController': dict({ + 'airConditionerOdorControllerProgress': dict({ + 'timestamp': '2021-04-06T16:43:37.555Z', + 'value': None, + }), + 'airConditionerOdorControllerState': dict({ + 'timestamp': '2021-04-06T16:43:37.555Z', + 'value': None, + }), + }), + 'custom.airConditionerOptionalMode': dict({ + 'acOptionalMode': dict({ + 'timestamp': '2025-02-09T09:14:39.642Z', + 'value': 'off', + }), + 'supportedAcOptionalMode': dict({ + 'timestamp': '2024-09-10T10:26:28.781Z', + 'value': list([ + 'off', + 'windFree', + ]), + }), + }), + 'custom.airConditionerTropicalNightMode': dict({ + 'acTropicalNightModeLevel': dict({ + 'timestamp': '2025-02-09T09:14:39.642Z', + 'value': 0, + }), + }), + 'custom.autoCleaningMode': dict({ + 'autoCleaningMode': dict({ + 'timestamp': '2025-02-09T09:14:39.642Z', + 'value': 'off', + }), + 'operatingState': dict({ + 'value': None, + }), + 'progress': dict({ + 'value': None, + }), + 'supportedAutoCleaningModes': dict({ + 'value': None, + }), + 'supportedOperatingStates': dict({ + 'value': None, + }), + 'timedCleanDuration': dict({ + 'value': None, + }), + 'timedCleanDurationRange': dict({ + 'value': None, + }), + }), + 'custom.deodorFilter': dict({ + 'deodorFilterCapacity': dict({ + 'timestamp': '2021-04-06T16:43:35.502Z', + 'value': None, + }), + 'deodorFilterLastResetDate': dict({ + 'timestamp': '2021-04-06T16:43:35.502Z', + 'value': None, + }), + 'deodorFilterResetType': dict({ + 'timestamp': '2021-04-06T16:43:35.502Z', + 'value': None, + }), + 'deodorFilterStatus': dict({ + 'timestamp': '2021-04-06T16:43:35.502Z', + 'value': None, + }), + 'deodorFilterUsage': dict({ + 'timestamp': '2021-04-06T16:43:35.502Z', + 'value': None, + }), + 'deodorFilterUsageStep': dict({ + 'timestamp': '2021-04-06T16:43:35.502Z', + 'value': None, + }), + }), + 'custom.deviceReportStateConfiguration': dict({ + 'reportStatePeriod': dict({ + 'timestamp': '2021-04-06T16:43:35.643Z', + 'value': None, + }), + 'reportStateRealtime': dict({ + 'timestamp': '2021-04-06T16:43:35.643Z', + 'value': None, + }), + 'reportStateRealtimePeriod': dict({ + 'timestamp': '2021-04-06T16:43:35.643Z', + 'value': None, + }), + }), + 'custom.disabledCapabilities': dict({ + 'disabledCapabilities': dict({ + 'timestamp': '2025-02-09T09:14:39.642Z', + 'value': list([ + 'remoteControlStatus', + 'airQualitySensor', + 'dustSensor', + 'veryFineDustSensor', + 'custom.dustFilter', + 'custom.deodorFilter', + 'custom.deviceReportStateConfiguration', + 'samsungce.dongleSoftwareInstallation', + 'demandResponseLoadControl', + 'custom.airConditionerOdorController', + ]), + }), + }), + 'custom.disabledComponents': dict({ + 'disabledComponents': dict({ + 'timestamp': '2025-02-09T09:14:39.642Z', + 'value': list([ + '1', + ]), + }), + }), + 'custom.dustFilter': dict({ + 'dustFilterCapacity': dict({ + 'timestamp': '2021-04-06T16:43:35.527Z', + 'value': None, + }), + 'dustFilterLastResetDate': dict({ + 'timestamp': '2021-04-06T16:43:35.527Z', + 'value': None, + }), + 'dustFilterResetType': dict({ + 'timestamp': '2021-04-06T16:43:35.527Z', + 'value': None, + }), + 'dustFilterStatus': dict({ + 'timestamp': '2021-04-06T16:43:35.527Z', + 'value': None, + }), + 'dustFilterUsage': dict({ + 'timestamp': '2021-04-06T16:43:35.527Z', + 'value': None, + }), + 'dustFilterUsageStep': dict({ + 'timestamp': '2021-04-06T16:43:35.527Z', + 'value': None, + }), + }), + 'custom.energyType': dict({ + 'drMaxDuration': dict({ + 'value': None, + }), + 'energySavingInfo': dict({ + 'value': None, + }), + 'energySavingLevel': dict({ + 'value': None, + }), + 'energySavingOperation': dict({ + 'value': None, + }), + 'energySavingOperationSupport': dict({ + 'value': None, + }), + 'energySavingSupport': dict({ + 'timestamp': '2021-12-29T07:29:17.526Z', + 'value': False, + }), + 'energyType': dict({ + 'timestamp': '2024-09-10T10:26:28.781Z', + 'value': '1.0', + }), + 'notificationTemplateID': dict({ + 'value': None, + }), + 'supportedEnergySavingLevels': dict({ + 'value': None, + }), + }), + 'custom.spiMode': dict({ + 'spiMode': dict({ + 'timestamp': '2025-02-09T09:14:39.642Z', + 'value': 'off', + }), + }), + 'custom.thermostatSetpointControl': dict({ + 'maximumSetpoint': dict({ + 'timestamp': '2024-09-10T10:26:28.781Z', + 'unit': 'C', + 'value': 30, + }), + 'minimumSetpoint': dict({ + 'timestamp': '2025-01-08T06:30:58.307Z', + 'unit': 'C', + 'value': 16, + }), + }), + 'demandResponseLoadControl': dict({ + 'drlcStatus': dict({ + 'timestamp': '2024-09-10T10:26:28.781Z', + 'value': dict({ + 'drlcLevel': -1, + 'drlcType': 1, + 'duration': 0, + 'override': False, + 'start': '1970-01-01T00:00:00Z', }), }), }), - }), - 'fanOscillationMode': dict({ - 'availableFanOscillationModes': dict({ - 'data': None, - 'timestamp': None, - 'unit': None, - 'value': None, - }), - 'fanOscillationMode': dict({ - 'data': None, - 'timestamp': '2025-02-09T09:14:39.249000+00:00', - 'unit': None, - 'value': 'fixed', - }), - 'supportedFanOscillationModes': dict({ - 'data': None, - 'timestamp': '2021-04-06T16:43:35.782000+00:00', - 'unit': None, - 'value': None, - }), - }), - 'ocf': dict({ - 'di': dict({ - 'data': None, - 'timestamp': '2024-09-10T10:26:28.552000+00:00', - 'unit': None, - 'value': '96a5ef74-5832-a84b-f1f7-ca799957065d', - }), - 'dmv': dict({ - 'data': None, - 'timestamp': '2024-09-10T10:26:28.552000+00:00', - 'unit': None, - 'value': 'res.1.1.0,sh.1.1.0', - }), - 'icv': dict({ - 'data': None, - 'timestamp': '2024-09-10T10:26:28.552000+00:00', - 'unit': None, - 'value': 'core.1.1.0', - }), - 'mndt': dict({ - 'data': None, - 'timestamp': '2021-04-06T16:43:35.912000+00:00', - 'unit': None, - 'value': None, - }), - 'mnfv': dict({ - 'data': None, - 'timestamp': '2024-09-10T10:26:28.552000+00:00', - 'unit': None, - 'value': '0.1.0', - }), - 'mnhw': dict({ - 'data': None, - 'timestamp': '2024-09-10T10:26:28.552000+00:00', - 'unit': None, - 'value': '1.0', - }), - 'mnml': dict({ - 'data': None, - 'timestamp': '2024-09-10T10:26:28.552000+00:00', - 'unit': None, - 'value': 'http://www.samsung.com', - }), - 'mnmn': dict({ - 'data': None, - 'timestamp': '2024-09-10T10:26:28.552000+00:00', - 'unit': None, - 'value': 'Samsung Electronics', - }), - 'mnmo': dict({ - 'data': None, - 'timestamp': '2024-09-10T10:26:28.781000+00:00', - 'unit': None, - 'value': 'ARTIK051_KRAC_18K|10193441|60010132001111110200000000000000', - }), - 'mnos': dict({ - 'data': None, - 'timestamp': '2024-09-10T10:26:28.552000+00:00', - 'unit': None, - 'value': 'TizenRT2.0', - }), - 'mnpv': dict({ - 'data': None, - 'timestamp': '2024-09-10T10:26:28.552000+00:00', - 'unit': None, - 'value': '0G3MPDCKA00010E', - }), - 'mnsl': dict({ - 'data': None, - 'timestamp': '2021-04-06T16:43:35.803000+00:00', - 'unit': None, - 'value': None, - }), - 'n': dict({ - 'data': None, - 'timestamp': '2024-09-10T10:26:28.552000+00:00', - 'unit': None, - 'value': '[room a/c] Samsung', - }), - 'pi': dict({ - 'data': None, - 'timestamp': '2024-09-10T10:26:28.552000+00:00', - 'unit': None, - 'value': '96a5ef74-5832-a84b-f1f7-ca799957065d', - }), - 'st': dict({ - 'data': None, - 'timestamp': '2021-04-06T16:43:35.933000+00:00', - 'unit': None, - 'value': None, - }), - 'vid': dict({ - 'data': None, - 'timestamp': '2024-09-10T10:26:28.552000+00:00', - 'unit': None, - 'value': 'DA-AC-RAC-000001', - }), - }), - 'powerConsumptionReport': dict({ - 'powerConsumption': dict({ - 'data': None, - 'timestamp': '2025-02-09T16:15:33.639000+00:00', - 'unit': None, - 'value': dict({ - 'deltaEnergy': 400, - 'end': '2025-02-09T16:15:33Z', - 'energy': 2247300, - 'energySaved': 0, - 'persistedEnergy': 2247300, - 'power': 0, - 'powerEnergy': 0.0, - 'start': '2025-02-09T15:45:29Z', + 'dustSensor': dict({ + 'dustLevel': dict({ + 'timestamp': '2021-04-06T16:43:35.665Z', + 'unit': 'μg/m^3', + 'value': None, + }), + 'fineDustLevel': dict({ + 'timestamp': '2021-04-06T16:43:35.665Z', + 'unit': 'μg/m^3', + 'value': None, }), }), - }), - 'refresh': dict({ - }), - 'relativeHumidityMeasurement': dict({ - 'humidity': dict({ - 'data': None, - 'timestamp': '2024-12-30T13:10:23.759000+00:00', - 'unit': '%', - 'value': 60, + 'execute': dict({ + 'data': dict({ + 'data': dict({ + 'href': '/temperature/desired/0', + }), + 'timestamp': '2023-07-19T03:07:43.270Z', + 'value': dict({ + 'payload': dict({ + 'if': list([ + 'oic.if.baseline', + 'oic.if.a', + ]), + 'range': list([ + 16.0, + 30.0, + ]), + 'rt': list([ + 'oic.r.temperature', + ]), + 'temperature': 22.0, + 'units': 'C', + }), + }), + }), }), - }), - 'samsungce.deviceIdentification': dict({ - 'binaryId': dict({ - 'data': None, - 'timestamp': '2025-02-08T00:44:53.855000+00:00', - 'unit': None, - 'value': 'ARTIK051_KRAC_18K', + 'fanOscillationMode': dict({ + 'availableFanOscillationModes': dict({ + 'value': None, + }), + 'fanOscillationMode': dict({ + 'timestamp': '2025-02-09T09:14:39.249Z', + 'value': 'fixed', + }), + 'supportedFanOscillationModes': dict({ + 'timestamp': '2021-04-06T16:43:35.782Z', + 'value': None, + }), }), - 'description': dict({ - 'data': None, - 'timestamp': None, - 'unit': None, - 'value': None, + 'ocf': dict({ + 'di': dict({ + 'timestamp': '2024-09-10T10:26:28.552Z', + 'value': '96a5ef74-5832-a84b-f1f7-ca799957065d', + }), + 'dmv': dict({ + 'timestamp': '2024-09-10T10:26:28.552Z', + 'value': 'res.1.1.0,sh.1.1.0', + }), + 'icv': dict({ + 'timestamp': '2024-09-10T10:26:28.552Z', + 'value': 'core.1.1.0', + }), + 'mndt': dict({ + 'timestamp': '2021-04-06T16:43:35.912Z', + 'value': None, + }), + 'mnfv': dict({ + 'timestamp': '2024-09-10T10:26:28.552Z', + 'value': '0.1.0', + }), + 'mnhw': dict({ + 'timestamp': '2024-09-10T10:26:28.552Z', + 'value': '1.0', + }), + 'mnml': dict({ + 'timestamp': '2024-09-10T10:26:28.552Z', + 'value': 'http://www.samsung.com', + }), + 'mnmn': dict({ + 'timestamp': '2024-09-10T10:26:28.552Z', + 'value': 'Samsung Electronics', + }), + 'mnmo': dict({ + 'timestamp': '2024-09-10T10:26:28.781Z', + 'value': 'ARTIK051_KRAC_18K|10193441|60010132001111110200000000000000', + }), + 'mnos': dict({ + 'timestamp': '2024-09-10T10:26:28.552Z', + 'value': 'TizenRT2.0', + }), + 'mnpv': dict({ + 'timestamp': '2024-09-10T10:26:28.552Z', + 'value': '0G3MPDCKA00010E', + }), + 'mnsl': dict({ + 'timestamp': '2021-04-06T16:43:35.803Z', + 'value': None, + }), + 'n': dict({ + 'timestamp': '2024-09-10T10:26:28.552Z', + 'value': '[room a/c] Samsung', + }), + 'pi': dict({ + 'timestamp': '2024-09-10T10:26:28.552Z', + 'value': '96a5ef74-5832-a84b-f1f7-ca799957065d', + }), + 'st': dict({ + 'timestamp': '2021-04-06T16:43:35.933Z', + 'value': None, + }), + 'vid': dict({ + 'timestamp': '2024-09-10T10:26:28.552Z', + 'value': 'DA-AC-RAC-000001', + }), }), - 'micomAssayCode': dict({ - 'data': None, - 'timestamp': None, - 'unit': None, - 'value': None, + 'powerConsumptionReport': dict({ + 'powerConsumption': dict({ + 'timestamp': '2025-02-09T16:15:33.639Z', + 'value': dict({ + 'deltaEnergy': 400, + 'end': '2025-02-09T16:15:33Z', + 'energy': 2247300, + 'energySaved': 0, + 'persistedEnergy': 2247300, + 'power': 0, + 'powerEnergy': 0.0, + 'start': '2025-02-09T15:45:29Z', + }), + }), }), - 'modelClassificationCode': dict({ - 'data': None, - 'timestamp': None, - 'unit': None, - 'value': None, + 'refresh': dict({ }), - 'modelName': dict({ - 'data': None, - 'timestamp': None, - 'unit': None, - 'value': None, + 'relativeHumidityMeasurement': dict({ + 'humidity': dict({ + 'timestamp': '2024-12-30T13:10:23.759Z', + 'unit': '%', + 'value': 60, + }), }), - 'releaseYear': dict({ - 'data': None, - 'timestamp': None, - 'unit': None, - 'value': None, + 'remoteControlStatus': dict({ + 'remoteControlEnabled': dict({ + 'timestamp': '2021-04-06T16:43:35.379Z', + 'value': None, + }), }), - 'serialNumber': dict({ - 'data': None, - 'timestamp': None, - 'unit': None, - 'value': None, + 'samsungce.deviceIdentification': dict({ + 'binaryId': dict({ + 'timestamp': '2025-02-08T00:44:53.855Z', + 'value': 'ARTIK051_KRAC_18K', + }), + 'description': dict({ + 'value': None, + }), + 'micomAssayCode': dict({ + 'value': None, + }), + 'modelClassificationCode': dict({ + 'value': None, + }), + 'modelName': dict({ + 'value': None, + }), + 'releaseYear': dict({ + 'value': None, + }), + 'serialNumber': dict({ + 'value': None, + }), + 'serialNumberExtra': dict({ + 'value': None, + }), }), - 'serialNumberExtra': dict({ - 'data': None, - 'timestamp': None, - 'unit': None, - 'value': None, + 'samsungce.dongleSoftwareInstallation': dict({ + 'status': dict({ + 'timestamp': '2021-12-29T01:36:51.289Z', + 'value': 'completed', + }), }), - }), - 'samsungce.driverVersion': dict({ - 'versionNumber': dict({ - 'data': None, - 'timestamp': '2024-09-04T06:35:09.557000+00:00', - 'unit': None, - 'value': 24070101, + 'samsungce.driverVersion': dict({ + 'versionNumber': dict({ + 'timestamp': '2024-09-04T06:35:09.557Z', + 'value': 24070101, + }), }), - }), - 'samsungce.selfCheck': dict({ - 'errors': dict({ - 'data': None, - 'timestamp': '2025-02-08T00:44:53.349000+00:00', - 'unit': None, - 'value': list([ - ]), + 'samsungce.selfCheck': dict({ + 'errors': dict({ + 'timestamp': '2025-02-08T00:44:53.349Z', + 'value': list([ + ]), + }), + 'progress': dict({ + 'value': None, + }), + 'result': dict({ + 'value': None, + }), + 'status': dict({ + 'timestamp': '2025-02-08T00:44:53.549Z', + 'value': 'ready', + }), + 'supportedActions': dict({ + 'timestamp': '2024-09-04T06:35:09.557Z', + 'value': list([ + 'start', + ]), + }), }), - 'progress': dict({ - 'data': None, - 'timestamp': None, - 'unit': None, - 'value': None, + 'samsungce.softwareUpdate': dict({ + 'availableModules': dict({ + 'timestamp': '2025-02-08T00:44:53.855Z', + 'value': list([ + ]), + }), + 'lastUpdatedDate': dict({ + 'value': None, + }), + 'newVersionAvailable': dict({ + 'timestamp': '2025-02-08T00:44:53.855Z', + 'value': False, + }), + 'operatingState': dict({ + 'value': None, + }), + 'otnDUID': dict({ + 'timestamp': '2025-02-08T00:44:53.855Z', + 'value': '43CEZFTFFL7Z2', + }), + 'progress': dict({ + 'value': None, + }), + 'targetModule': dict({ + 'value': None, + }), }), - 'result': dict({ - 'data': None, - 'timestamp': None, - 'unit': None, - 'value': None, - }), - 'status': dict({ - 'data': None, - 'timestamp': '2025-02-08T00:44:53.549000+00:00', - 'unit': None, - 'value': 'ready', - }), - 'supportedActions': dict({ - 'data': None, - 'timestamp': '2024-09-04T06:35:09.557000+00:00', - 'unit': None, - 'value': list([ - 'start', - ]), - }), - }), - 'samsungce.softwareUpdate': dict({ - 'availableModules': dict({ - 'data': None, - 'timestamp': '2025-02-08T00:44:53.855000+00:00', - 'unit': None, - 'value': list([ - ]), - }), - 'lastUpdatedDate': dict({ - 'data': None, - 'timestamp': None, - 'unit': None, - 'value': None, - }), - 'newVersionAvailable': dict({ - 'data': None, - 'timestamp': '2025-02-08T00:44:53.855000+00:00', - 'unit': None, - 'value': 'False', - }), - 'operatingState': dict({ - 'data': None, - 'timestamp': None, - 'unit': None, - 'value': None, - }), - 'otnDUID': dict({ - 'data': None, - 'timestamp': '2025-02-08T00:44:53.855000+00:00', - 'unit': None, - 'value': '43CEZFTFFL7Z2', - }), - 'progress': dict({ - 'data': None, - 'timestamp': None, - 'unit': None, - 'value': None, - }), - 'targetModule': dict({ - 'data': None, - 'timestamp': None, - 'unit': None, - 'value': None, - }), - }), - 'switch': dict({ 'switch': dict({ - 'data': None, - 'timestamp': '2025-02-09T16:37:54.072000+00:00', - 'unit': None, - 'value': 'off', + 'switch': dict({ + 'timestamp': '2025-02-09T16:37:54.072Z', + 'value': 'off', + }), }), - }), - 'temperatureMeasurement': dict({ - 'temperature': dict({ - 'data': None, - 'timestamp': '2025-02-09T16:33:29.164000+00:00', - 'unit': 'C', - 'value': 25, + 'temperatureMeasurement': dict({ + 'temperature': dict({ + 'timestamp': '2025-02-09T16:33:29.164Z', + 'unit': 'C', + 'value': 25, + }), + 'temperatureRange': dict({ + 'value': None, + }), }), - 'temperatureRange': dict({ - 'data': None, - 'timestamp': None, - 'unit': None, - 'value': None, + 'thermostatCoolingSetpoint': dict({ + 'coolingSetpoint': dict({ + 'timestamp': '2025-02-09T09:15:11.608Z', + 'unit': 'C', + 'value': 25, + }), + 'coolingSetpointRange': dict({ + 'value': None, + }), }), - }), - 'thermostatCoolingSetpoint': dict({ - 'coolingSetpoint': dict({ - 'data': None, - 'timestamp': '2025-02-09T09:15:11.608000+00:00', - 'unit': 'C', - 'value': 25, - }), - 'coolingSetpointRange': dict({ - 'data': None, - 'timestamp': None, - 'unit': None, - 'value': None, + 'veryFineDustSensor': dict({ + 'veryFineDustLevel': dict({ + 'timestamp': '2021-04-06T16:43:35.363Z', + 'unit': 'μg/m^3', + 'value': None, + }), }), }), }), diff --git a/tests/components/smartthings/test_diagnostics.py b/tests/components/smartthings/test_diagnostics.py index 768be155c86..f486c19de14 100644 --- a/tests/components/smartthings/test_diagnostics.py +++ b/tests/components/smartthings/test_diagnostics.py @@ -12,13 +12,36 @@ from homeassistant.helpers import device_registry as dr from . import setup_integration -from tests.common import MockConfigEntry -from tests.components.diagnostics import get_diagnostics_for_device +from tests.common import MockConfigEntry, load_json_object_fixture +from tests.components.diagnostics import ( + get_diagnostics_for_config_entry, + get_diagnostics_for_device, +) from tests.typing import ClientSessionGenerator @pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) -async def test_device( +async def test_config_entry_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + devices: AsyncMock, + mock_smartthings: AsyncMock, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test generating diagnostics for a device entry.""" + mock_smartthings.get_raw_devices.return_value = load_json_object_fixture( + "devices/da_ac_rac_000001.json", DOMAIN + ) + await setup_integration(hass, mock_config_entry) + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, mock_config_entry) + == snapshot + ) + + +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) +async def test_device_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, device_registry: dr.DeviceRegistry, @@ -28,13 +51,19 @@ async def test_device( snapshot: SnapshotAssertion, ) -> None: """Test generating diagnostics for a device entry.""" + mock_smartthings.get_raw_device_status.return_value = load_json_object_fixture( + "device_status/da_ac_rac_000001.json", DOMAIN + ) + mock_smartthings.get_raw_device.return_value = load_json_object_fixture( + "devices/da_ac_rac_000001.json", DOMAIN + )["items"][0] await setup_integration(hass, mock_config_entry) device = device_registry.async_get_device( identifiers={(DOMAIN, "96a5ef74-5832-a84b-f1f7-ca799957065d")} ) - mock_smartthings.get_device_status.reset_mock() + mock_smartthings.get_raw_device_status.reset_mock() with patch("homeassistant.components.smartthings.diagnostics.EVENT_WAIT_TIME", 0.1): diag = await get_diagnostics_for_device( @@ -44,6 +73,6 @@ async def test_device( assert diag == snapshot( exclude=props("last_changed", "last_reported", "last_updated") ) - mock_smartthings.get_device_status.assert_called_once_with( + mock_smartthings.get_raw_device_status.assert_called_once_with( "96a5ef74-5832-a84b-f1f7-ca799957065d" ) From 4ff2309a90b9936b8c7f75ceb1259dfac6e32fda Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Thu, 6 Mar 2025 18:50:47 +0100 Subject: [PATCH 2291/3148] Use mysensors config entry async_on_unload (#139978) * Use config entry on unload in mysensors * Test mysensors config entry load and unload * Fix docstring --- .../components/mysensors/__init__.py | 8 --- .../components/mysensors/binary_sensor.py | 5 +- homeassistant/components/mysensors/climate.py | 5 +- homeassistant/components/mysensors/const.py | 1 - homeassistant/components/mysensors/cover.py | 5 +- .../components/mysensors/device_tracker.py | 5 +- homeassistant/components/mysensors/gateway.py | 5 +- homeassistant/components/mysensors/helpers.py | 13 ----- homeassistant/components/mysensors/light.py | 5 +- homeassistant/components/mysensors/remote.py | 5 +- homeassistant/components/mysensors/sensor.py | 9 +--- homeassistant/components/mysensors/switch.py | 5 +- homeassistant/components/mysensors/text.py | 5 +- tests/components/mysensors/conftest.py | 2 +- tests/components/mysensors/test_init.py | 49 +++++++++++++++++++ 15 files changed, 61 insertions(+), 66 deletions(-) diff --git a/homeassistant/components/mysensors/__init__.py b/homeassistant/components/mysensors/__init__.py index 19dcce78446..e2aca8b9f01 100644 --- a/homeassistant/components/mysensors/__init__.py +++ b/homeassistant/components/mysensors/__init__.py @@ -17,7 +17,6 @@ from .const import ( DOMAIN, MYSENSORS_DISCOVERED_NODES, MYSENSORS_GATEWAYS, - MYSENSORS_ON_UNLOAD, PLATFORMS, DevId, DiscoveryInfo, @@ -62,13 +61,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if not unload_ok: return False - key = MYSENSORS_ON_UNLOAD.format(entry.entry_id) - if key in hass.data[DOMAIN]: - for fnct in hass.data[DOMAIN][key]: - fnct() - - hass.data[DOMAIN].pop(key) - del hass.data[DOMAIN][MYSENSORS_GATEWAYS][entry.entry_id] hass.data[DOMAIN].pop(MYSENSORS_DISCOVERED_NODES.format(entry.entry_id), None) diff --git a/homeassistant/components/mysensors/binary_sensor.py b/homeassistant/components/mysensors/binary_sensor.py index d42b2194315..e950f083b5b 100644 --- a/homeassistant/components/mysensors/binary_sensor.py +++ b/homeassistant/components/mysensors/binary_sensor.py @@ -20,7 +20,6 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import setup_mysensors_platform from .const import MYSENSORS_DISCOVERY, DiscoveryInfo from .entity import MySensorsChildEntity -from .helpers import on_unload @dataclass(frozen=True) @@ -86,9 +85,7 @@ async def async_setup_entry( async_add_entities=async_add_entities, ) - on_unload( - hass, - config_entry.entry_id, + config_entry.async_on_unload( async_dispatcher_connect( hass, MYSENSORS_DISCOVERY.format(config_entry.entry_id, Platform.BINARY_SENSOR), diff --git a/homeassistant/components/mysensors/climate.py b/homeassistant/components/mysensors/climate.py index d1504f3afab..d1b697a3458 100644 --- a/homeassistant/components/mysensors/climate.py +++ b/homeassistant/components/mysensors/climate.py @@ -21,7 +21,6 @@ from homeassistant.util.unit_system import METRIC_SYSTEM from . import setup_mysensors_platform from .const import MYSENSORS_DISCOVERY, DiscoveryInfo from .entity import MySensorsChildEntity -from .helpers import on_unload DICT_HA_TO_MYS = { HVACMode.AUTO: "AutoChangeOver", @@ -57,9 +56,7 @@ async def async_setup_entry( async_add_entities=async_add_entities, ) - on_unload( - hass, - config_entry.entry_id, + config_entry.async_on_unload( async_dispatcher_connect( hass, MYSENSORS_DISCOVERY.format(config_entry.entry_id, Platform.CLIMATE), diff --git a/homeassistant/components/mysensors/const.py b/homeassistant/components/mysensors/const.py index a65b46616d3..a87b78b549e 100644 --- a/homeassistant/components/mysensors/const.py +++ b/homeassistant/components/mysensors/const.py @@ -34,7 +34,6 @@ CHILD_CALLBACK: str = "mysensors_child_callback_{}_{}_{}_{}" NODE_CALLBACK: str = "mysensors_node_callback_{}_{}" MYSENSORS_DISCOVERY: str = "mysensors_discovery_{}_{}" MYSENSORS_NODE_DISCOVERY: str = "mysensors_node_discovery" -MYSENSORS_ON_UNLOAD: str = "mysensors_on_unload_{}" TYPE: Final = "type" UPDATE_DELAY: float = 0.1 diff --git a/homeassistant/components/mysensors/cover.py b/homeassistant/components/mysensors/cover.py index 14e6ff6dc15..2ac0367d1fc 100644 --- a/homeassistant/components/mysensors/cover.py +++ b/homeassistant/components/mysensors/cover.py @@ -15,7 +15,6 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import setup_mysensors_platform from .const import MYSENSORS_DISCOVERY, DiscoveryInfo from .entity import MySensorsChildEntity -from .helpers import on_unload @unique @@ -45,9 +44,7 @@ async def async_setup_entry( async_add_entities=async_add_entities, ) - on_unload( - hass, - config_entry.entry_id, + config_entry.async_on_unload( async_dispatcher_connect( hass, MYSENSORS_DISCOVERY.format(config_entry.entry_id, Platform.COVER), diff --git a/homeassistant/components/mysensors/device_tracker.py b/homeassistant/components/mysensors/device_tracker.py index 56d8b2f5923..e6368b0b81d 100644 --- a/homeassistant/components/mysensors/device_tracker.py +++ b/homeassistant/components/mysensors/device_tracker.py @@ -12,7 +12,6 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import setup_mysensors_platform from .const import MYSENSORS_DISCOVERY, DiscoveryInfo from .entity import MySensorsChildEntity -from .helpers import on_unload async def async_setup_entry( @@ -33,9 +32,7 @@ async def async_setup_entry( async_add_entities=async_add_entities, ) - on_unload( - hass, - config_entry.entry_id, + config_entry.async_on_unload( async_dispatcher_connect( hass, MYSENSORS_DISCOVERY.format(config_entry.entry_id, Platform.DEVICE_TRACKER), diff --git a/homeassistant/components/mysensors/gateway.py b/homeassistant/components/mysensors/gateway.py index bdc83f30b21..91453ea3306 100644 --- a/homeassistant/components/mysensors/gateway.py +++ b/homeassistant/components/mysensors/gateway.py @@ -47,7 +47,6 @@ from .handler import HANDLERS from .helpers import ( discover_mysensors_node, discover_mysensors_platform, - on_unload, validate_child, validate_node, ) @@ -293,9 +292,7 @@ async def _gw_start( """Stop the gateway.""" await gw_stop(hass, entry, gateway) - on_unload( - hass, - entry.entry_id, + entry.async_on_unload( hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, stop_this_gw), ) diff --git a/homeassistant/components/mysensors/helpers.py b/homeassistant/components/mysensors/helpers.py index c96ad6cea8e..9ed41dfe4e9 100644 --- a/homeassistant/components/mysensors/helpers.py +++ b/homeassistant/components/mysensors/helpers.py @@ -27,7 +27,6 @@ from .const import ( MYSENSORS_DISCOVERED_NODES, MYSENSORS_DISCOVERY, MYSENSORS_NODE_DISCOVERY, - MYSENSORS_ON_UNLOAD, TYPE_TO_PLATFORMS, DevId, GatewayId, @@ -41,18 +40,6 @@ SCHEMAS: Registry[ ] = Registry() -@callback -def on_unload(hass: HomeAssistant, gateway_id: GatewayId, fnct: Callable) -> None: - """Register a callback to be called when entry is unloaded. - - This function is used by platforms to cleanup after themselves. - """ - key = MYSENSORS_ON_UNLOAD.format(gateway_id) - if key not in hass.data[DOMAIN]: - hass.data[DOMAIN][key] = [] - hass.data[DOMAIN][key].append(fnct) - - @callback def discover_mysensors_platform( hass: HomeAssistant, gateway_id: GatewayId, platform: str, new_devices: list[DevId] diff --git a/homeassistant/components/mysensors/light.py b/homeassistant/components/mysensors/light.py index 9e4054ca3d0..4fa9eaa8ea6 100644 --- a/homeassistant/components/mysensors/light.py +++ b/homeassistant/components/mysensors/light.py @@ -21,7 +21,6 @@ from homeassistant.util.color import rgb_hex_to_rgb_list from . import setup_mysensors_platform from .const import MYSENSORS_DISCOVERY, DiscoveryInfo, SensorType from .entity import MySensorsChildEntity -from .helpers import on_unload async def async_setup_entry( @@ -46,9 +45,7 @@ async def async_setup_entry( async_add_entities=async_add_entities, ) - on_unload( - hass, - config_entry.entry_id, + config_entry.async_on_unload( async_dispatcher_connect( hass, MYSENSORS_DISCOVERY.format(config_entry.entry_id, Platform.LIGHT), diff --git a/homeassistant/components/mysensors/remote.py b/homeassistant/components/mysensors/remote.py index ada801f92ab..ccb67f78eba 100644 --- a/homeassistant/components/mysensors/remote.py +++ b/homeassistant/components/mysensors/remote.py @@ -19,7 +19,6 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import setup_mysensors_platform from .const import MYSENSORS_DISCOVERY, DiscoveryInfo from .entity import MySensorsChildEntity -from .helpers import on_unload async def async_setup_entry( @@ -40,9 +39,7 @@ async def async_setup_entry( async_add_entities=async_add_entities, ) - on_unload( - hass, - config_entry.entry_id, + config_entry.async_on_unload( async_dispatcher_connect( hass, MYSENSORS_DISCOVERY.format(config_entry.entry_id, Platform.REMOTE), diff --git a/homeassistant/components/mysensors/sensor.py b/homeassistant/components/mysensors/sensor.py index 759cf7b010f..3a7101e6b39 100644 --- a/homeassistant/components/mysensors/sensor.py +++ b/homeassistant/components/mysensors/sensor.py @@ -50,7 +50,6 @@ from .const import ( NodeDiscoveryInfo, ) from .entity import MySensorNodeEntity, MySensorsChildEntity -from .helpers import on_unload SENSORS: dict[str, SensorEntityDescription] = { "V_TEMP": SensorEntityDescription( @@ -233,9 +232,7 @@ async def async_setup_entry( gateway: BaseAsyncGateway = hass.data[DOMAIN][MYSENSORS_GATEWAYS][gateway_id] async_add_entities([MyBatterySensor(gateway_id, gateway, node_id)]) - on_unload( - hass, - config_entry.entry_id, + config_entry.async_on_unload( async_dispatcher_connect( hass, MYSENSORS_DISCOVERY.format(config_entry.entry_id, Platform.SENSOR), @@ -243,9 +240,7 @@ async def async_setup_entry( ), ) - on_unload( - hass, - config_entry.entry_id, + config_entry.async_on_unload( async_dispatcher_connect( hass, MYSENSORS_NODE_DISCOVERY, diff --git a/homeassistant/components/mysensors/switch.py b/homeassistant/components/mysensors/switch.py index 52207c21f77..499124919b5 100644 --- a/homeassistant/components/mysensors/switch.py +++ b/homeassistant/components/mysensors/switch.py @@ -14,7 +14,6 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import setup_mysensors_platform from .const import MYSENSORS_DISCOVERY, DiscoveryInfo, SensorType from .entity import MySensorsChildEntity -from .helpers import on_unload async def async_setup_entry( @@ -48,9 +47,7 @@ async def async_setup_entry( async_add_entities=async_add_entities, ) - on_unload( - hass, - config_entry.entry_id, + config_entry.async_on_unload( async_dispatcher_connect( hass, MYSENSORS_DISCOVERY.format(config_entry.entry_id, Platform.SWITCH), diff --git a/homeassistant/components/mysensors/text.py b/homeassistant/components/mysensors/text.py index 8eff7a255e7..9fdd9da5345 100644 --- a/homeassistant/components/mysensors/text.py +++ b/homeassistant/components/mysensors/text.py @@ -12,7 +12,6 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import setup_mysensors_platform from .const import MYSENSORS_DISCOVERY, DiscoveryInfo from .entity import MySensorsChildEntity -from .helpers import on_unload async def async_setup_entry( @@ -33,9 +32,7 @@ async def async_setup_entry( async_add_entities=async_add_entities, ) - on_unload( - hass, - config_entry.entry_id, + config_entry.async_on_unload( async_dispatcher_connect( hass, MYSENSORS_DISCOVERY.format(config_entry.entry_id, Platform.TEXT), diff --git a/tests/components/mysensors/conftest.py b/tests/components/mysensors/conftest.py index 1d407815db0..b14a3f9c529 100644 --- a/tests/components/mysensors/conftest.py +++ b/tests/components/mysensors/conftest.py @@ -53,7 +53,7 @@ def gateway_nodes_fixture() -> dict[int, Sensor]: async def serial_transport_fixture( gateway_nodes: dict[int, Sensor], is_serial_port: MagicMock, -) -> AsyncGenerator[dict[int, Sensor]]: +) -> AsyncGenerator[MagicMock]: """Mock a serial transport.""" with ( patch( diff --git a/tests/components/mysensors/test_init.py b/tests/components/mysensors/test_init.py index 7f6ea76d3e1..108f2d7e592 100644 --- a/tests/components/mysensors/test_init.py +++ b/tests/components/mysensors/test_init.py @@ -2,10 +2,15 @@ from __future__ import annotations +from collections.abc import Callable +from unittest.mock import MagicMock + from mysensors import BaseSyncGateway from mysensors.sensor import Sensor from homeassistant.components.mysensors import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component @@ -14,6 +19,50 @@ from tests.common import MockConfigEntry from tests.typing import WebSocketGenerator +async def test_load_unload( + hass: HomeAssistant, + door_sensor: Sensor, + transport: MagicMock, + integration: MockConfigEntry, + receive_message: Callable[[str], None], +) -> None: + """Test loading and unloading the MySensors config entry.""" + config_entry = integration + + assert config_entry.state == ConfigEntryState.LOADED + + entity_id = "binary_sensor.door_sensor_1_1" + state = hass.states.get(entity_id) + + assert state + assert state.state != STATE_UNAVAILABLE + + receive_message("1;1;1;0;16;1\n") + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + + assert state + assert state.state != STATE_UNAVAILABLE + + assert await hass.config_entries.async_unload(config_entry.entry_id) + + assert transport.return_value.disconnect.call_count == 1 + + state = hass.states.get(entity_id) + + assert state + assert state.state == STATE_UNAVAILABLE + + receive_message("1;1;1;0;16;1\n") + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + + assert state + assert state.state == STATE_UNAVAILABLE + + async def test_remove_config_entry_device( hass: HomeAssistant, device_registry: dr.DeviceRegistry, From 99e1a7a676b2fc14f9f8a8db64bee2840fae4646 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 6 Mar 2025 18:52:46 +0100 Subject: [PATCH 2292/3148] Check if the unit of measurement is valid before creating the entity (#139932) --- homeassistant/components/mqtt/sensor.py | 15 ++++++++++++++ tests/components/mqtt/test_sensor.py | 26 +++++++++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/homeassistant/components/mqtt/sensor.py b/homeassistant/components/mqtt/sensor.py index 3e8a4fef0fa..432431c96d9 100644 --- a/homeassistant/components/mqtt/sensor.py +++ b/homeassistant/components/mqtt/sensor.py @@ -11,6 +11,7 @@ import voluptuous as vol from homeassistant.components import sensor from homeassistant.components.sensor import ( CONF_STATE_CLASS, + DEVICE_CLASS_UNITS, DEVICE_CLASSES_SCHEMA, ENTITY_ID_FORMAT, STATE_CLASSES_SCHEMA, @@ -107,6 +108,20 @@ def validate_sensor_state_and_device_class_config(config: ConfigType) -> ConfigT f"got `{CONF_DEVICE_CLASS}` '{device_class}'" ) + if (device_class := config.get(CONF_DEVICE_CLASS)) is None or ( + unit_of_measurement := config.get(CONF_UNIT_OF_MEASUREMENT) + ) is None: + return config + + if ( + device_class in DEVICE_CLASS_UNITS + and unit_of_measurement not in DEVICE_CLASS_UNITS[device_class] + ): + raise vol.Invalid( + f"The unit of measurement `{unit_of_measurement}` is not valid " + f"together with device class `{device_class}`" + ) + return config diff --git a/tests/components/mqtt/test_sensor.py b/tests/components/mqtt/test_sensor.py index 9226b03a7d2..f40082d84be 100644 --- a/tests/components/mqtt/test_sensor.py +++ b/tests/components/mqtt/test_sensor.py @@ -870,6 +870,32 @@ async def test_invalid_device_class( assert "expected SensorDeviceClass or one of" in caplog.text +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + sensor.DOMAIN: { + "name": "test", + "state_topic": "test-topic", + "device_class": "energy", + "unit_of_measurement": "ppm", + } + } + } + ], +) +async def test_invalid_unit_of_measurement( + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture +) -> None: + """Test device_class with invalid unit of measurement.""" + assert await mqtt_mock_entry() + assert ( + "The unit of measurement `ppm` is not valid together with device class `energy`" + in caplog.text + ) + + @pytest.mark.parametrize( "hass_config", [ From eaad8ec49d034b1cd5a89b6fd41b179b70c33bbb Mon Sep 17 00:00:00 2001 From: Markus Adrario Date: Thu, 6 Mar 2025 19:56:17 +0100 Subject: [PATCH 2293/3148] Add Homee select platform (#139534) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * homee select initial * Finish select tests * Add motor rotation * fix snapshot after translation compilation * string improvement * last fixes * fix review comments * remove restore last known state * readd wind monitoring state * fix strings * remove problematic selects * remove motor rotation from strings * fix review comments * Update tests/components/homee/test_select.py Co-authored-by: Abílio Costa * add PARALLEL_UPDATES * parallel updates for select, not light. --------- Co-authored-by: Robert Resch Co-authored-by: Abílio Costa --- homeassistant/components/homee/__init__.py | 1 + homeassistant/components/homee/select.py | 63 +++++++++++ homeassistant/components/homee/strings.json | 10 ++ tests/components/homee/fixtures/selects.json | 43 +++++++ .../homee/snapshots/test_select.ambr | 59 ++++++++++ tests/components/homee/test_select.py | 106 ++++++++++++++++++ 6 files changed, 282 insertions(+) create mode 100644 homeassistant/components/homee/select.py create mode 100644 tests/components/homee/fixtures/selects.json create mode 100644 tests/components/homee/snapshots/test_select.ambr create mode 100644 tests/components/homee/test_select.py diff --git a/homeassistant/components/homee/__init__.py b/homeassistant/components/homee/__init__.py index d7785ad9104..92773dae656 100644 --- a/homeassistant/components/homee/__init__.py +++ b/homeassistant/components/homee/__init__.py @@ -19,6 +19,7 @@ PLATFORMS = [ Platform.COVER, Platform.LIGHT, Platform.NUMBER, + Platform.SELECT, Platform.SENSOR, Platform.SWITCH, Platform.VALVE, diff --git a/homeassistant/components/homee/select.py b/homeassistant/components/homee/select.py new file mode 100644 index 00000000000..70c7972bbda --- /dev/null +++ b/homeassistant/components/homee/select.py @@ -0,0 +1,63 @@ +"""The Homee select platform.""" + +from pyHomee.const import AttributeType +from pyHomee.model import HomeeAttribute + +from homeassistant.components.select import SelectEntity, SelectEntityDescription +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import HomeeConfigEntry +from .entity import HomeeEntity + +PARALLEL_UPDATES = 0 + +SELECT_DESCRIPTIONS: dict[AttributeType, SelectEntityDescription] = { + AttributeType.REPEATER_MODE: SelectEntityDescription( + key="repeater_mode", + options=["off", "level1", "level2"], + entity_category=EntityCategory.CONFIG, + ), +} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: HomeeConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Add the Homee platform for the select component.""" + + async_add_entities( + HomeeSelect(attribute, config_entry, SELECT_DESCRIPTIONS[attribute.type]) + for node in config_entry.runtime_data.nodes + for attribute in node.attributes + if attribute.type in SELECT_DESCRIPTIONS and attribute.editable + ) + + +class HomeeSelect(HomeeEntity, SelectEntity): + """Representation of a Homee select entity.""" + + def __init__( + self, + attribute: HomeeAttribute, + entry: HomeeConfigEntry, + description: SelectEntityDescription, + ) -> None: + """Initialize a Homee select entity.""" + super().__init__(attribute, entry) + self.entity_description = description + assert description.options is not None + self._attr_options = description.options + self._attr_translation_key = description.key + + @property + def current_option(self) -> str: + """Return the current selected option.""" + return self.options[int(self._attribute.current_value)] + + async def async_select_option(self, option: str) -> None: + """Change the selected option.""" + await self.async_set_homee_value(self.options.index(option)) diff --git a/homeassistant/components/homee/strings.json b/homeassistant/components/homee/strings.json index 94f85824280..8b61cc6d28c 100644 --- a/homeassistant/components/homee/strings.json +++ b/homeassistant/components/homee/strings.json @@ -110,6 +110,16 @@ "name": "Wake-up interval" } }, + "select": { + "repeater_mode": { + "name": "Repeater mode", + "state": { + "off": "[%key:common::state::off%]", + "level1": "Level 1", + "level2": "Level 2" + } + } + }, "sensor": { "brightness": { "name": "Illuminance" diff --git a/tests/components/homee/fixtures/selects.json b/tests/components/homee/fixtures/selects.json new file mode 100644 index 00000000000..27adcf07298 --- /dev/null +++ b/tests/components/homee/fixtures/selects.json @@ -0,0 +1,43 @@ +{ + "id": 1, + "name": "Test Select", + "profile": 33, + "image": "nodeicon_dimmablebulb", + "favorite": 0, + "order": 27, + "protocol": 3, + "routing": 0, + "state": 1, + "state_changed": 1736188706, + "added": 1610308228, + "history": 1, + "cube_type": 3, + "note": "", + "services": 7, + "phonetic_name": "", + "owner": 2, + "security": 0, + "attributes": [ + { + "id": 1, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 2, + "current_value": 1.0, + "target_value": 1.0, + "last_value": 1.0, + "unit": "n/a", + "step_value": 1.0, + "editable": 1, + "type": 226, + "state": 1, + "last_changed": 1680027880, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + } + ] +} diff --git a/tests/components/homee/snapshots/test_select.ambr b/tests/components/homee/snapshots/test_select.ambr new file mode 100644 index 00000000000..9fa831230c2 --- /dev/null +++ b/tests/components/homee/snapshots/test_select.ambr @@ -0,0 +1,59 @@ +# serializer version: 1 +# name: test_select_snapshot[select.test_select_repeater_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'off', + 'level1', + 'level2', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.test_select_repeater_mode', + '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': 'Repeater mode', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'repeater_mode', + 'unique_id': '00055511EECC-1-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_select_snapshot[select.test_select_repeater_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Select Repeater mode', + 'options': list([ + 'off', + 'level1', + 'level2', + ]), + }), + 'context': , + 'entity_id': 'select.test_select_repeater_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'level1', + }) +# --- diff --git a/tests/components/homee/test_select.py b/tests/components/homee/test_select.py new file mode 100644 index 00000000000..c0dec2234d6 --- /dev/null +++ b/tests/components/homee/test_select.py @@ -0,0 +1,106 @@ +"""Test homee selects.""" + +from unittest.mock import MagicMock, patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.select import ( + DOMAIN as SELECT_DOMAIN, + SERVICE_SELECT_FIRST, + SERVICE_SELECT_LAST, + SERVICE_SELECT_NEXT, + SERVICE_SELECT_OPTION, + SERVICE_SELECT_PREVIOUS, +) +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import entity_registry as er + +from . import build_mock_node, setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +async def setup_select( + hass: HomeAssistant, mock_homee: MagicMock, mock_config_entry: MockConfigEntry +) -> None: + """Setups the integration for select tests.""" + mock_homee.nodes = [build_mock_node("selects.json")] + mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] + await setup_integration(hass, mock_config_entry) + + +@pytest.mark.parametrize( + ("service", "extra_options", "expected"), + [ + (SERVICE_SELECT_FIRST, {}, 0), + (SERVICE_SELECT_LAST, {}, 2), + (SERVICE_SELECT_NEXT, {}, 2), + (SERVICE_SELECT_PREVIOUS, {}, 0), + ( + SERVICE_SELECT_OPTION, + { + "option": "level2", + }, + 2, + ), + ], +) +async def test_select_services( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, + service: str, + extra_options: dict[str, str], + expected: int, +) -> None: + """Test the select services.""" + await setup_select(hass, mock_homee, mock_config_entry) + + OPTIONS = {ATTR_ENTITY_ID: "select.test_select_repeater_mode"} + OPTIONS.update(extra_options) + + await hass.services.async_call( + SELECT_DOMAIN, + service, + OPTIONS, + blocking=True, + ) + + mock_homee.set_value.assert_called_once_with(1, 1, expected) + + +async def test_select_option_service_error( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the select_option service called with invalid option.""" + await setup_select(hass, mock_homee, mock_config_entry) + + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: "select.test_select_repeater_mode", + "option": "invalid", + }, + blocking=True, + ) + + +async def test_select_snapshot( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test the select entity snapshot.""" + with patch("homeassistant.components.homee.PLATFORMS", [Platform.SELECT]): + await setup_select(hass, mock_homee, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) From eb49e596f9da5dea739ed9ea9425afd5f6b759a8 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Thu, 6 Mar 2025 11:00:40 -0800 Subject: [PATCH 2294/3148] Add a roborock quality_scale.yaml (#139849) * Add a roborock quality_scale.yaml * Update wording in polling * Update event listening * Update quality scale based on feedback --- .../components/roborock/quality_scale.yaml | 102 ++++++++++++++++++ script/hassfest/quality_scale.py | 1 - 2 files changed, 102 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/roborock/quality_scale.yaml diff --git a/homeassistant/components/roborock/quality_scale.yaml b/homeassistant/components/roborock/quality_scale.yaml new file mode 100644 index 00000000000..845d77d0fbe --- /dev/null +++ b/homeassistant/components/roborock/quality_scale.yaml @@ -0,0 +1,102 @@ +rules: + # Bronze + action-setup: done + appropriate-polling: + status: todo + comment: | + The device currently polls every 30 seconds, which is a bit high when idle. + We should consider dynamic polling intervals (e.g. when cleaning) and + separate cloud vs local intervals. + brands: done + common-modules: done + config-flow: + status: todo + comment: Not all fields have a data_description. + config-flow-test-coverage: done + dependency-transparency: done + docs-actions: + status: todo + comment: | + The documentation for `roborock.get_maps` should be updated so it is next + to the other actions rather than only an example. All actions should be + updated to use the simple table format. + docs-high-level-description: done + docs-installation-instructions: todo + docs-removal-instructions: todo + entity-event-setup: done + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: + status: done + comment: The config flow verifies credentials and the cloud APIs. + test-before-setup: done + unique-config-entry: done + # Silver + action-exceptions: todo + config-entry-unloading: done + docs-configuration-parameters: todo + docs-installation-parameters: todo + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: todo + reauthentication-flow: todo + test-coverage: done + # Gold + devices: done + diagnostics: done + discovery: + status: todo + comment: Determine if these devices can support discovery + discovery-update-info: + status: exempt + comment: Devices do not support discovery. + docs-data-update: + status: todo + comment: | + The docs talk about device communication works (cloud vs local), but does + not yet describe data flow (e.g. polling). We should move into a separate + section. + docs-examples: todo + docs-known-limitations: + status: todo + comment: Documentation does not describe known limitations like rate limiting + docs-supported-devices: todo + docs-supported-functions: + status: todo + comment: Mostly complete, but some documentation is outdated (e.g. maps/images) + docs-troubleshooting: + status: todo + comment: | + There are good troubleshooting steps, however we should update the "cloud vs local" + and rate limiting documentation with more information. + docs-use-cases: + status: todo + comment: | + The docs describe controlling the vacuum, though does not describe more + interesting potential integrations with the homoe assistant ecosystem. + dynamic-devices: todo + entity-category: done + entity-device-class: done + entity-disabled-by-default: + status: exempt + comment: There are no noisy entities. + entity-translations: done + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + repair-issues: + status: todo + comment: The Cloud vs Local API warning should probably be a repair issue. + stale-devices: + status: todo + comment: | + The integration does not yet handle stale devices. The roborock app does + support deleting devices and this is a gap #132590 + # Platinum + async-dependency: todo + inject-websession: + status: todo + comment: Web API uses aiohttp but does not yet inject web session. + strict-typing: todo diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index 9ddce29a4f3..65e9d4ed9cc 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -855,7 +855,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "risco", "rituals_perfume_genie", "rmvtransport", - "roborock", "rocketchat", "roku", "romy", From e78139edf13aee5a1c4cd4ec957da4d0d3cc5a7a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 6 Mar 2025 10:10:07 -1000 Subject: [PATCH 2295/3148] Bump nexia to 2.2.2 (#139986) changelog: https://github.com/bdraco/nexia/compare/2.2.1...2.2.2 --- homeassistant/components/nexia/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/nexia/manifest.json b/homeassistant/components/nexia/manifest.json index 337378a283c..09b79d37c55 100644 --- a/homeassistant/components/nexia/manifest.json +++ b/homeassistant/components/nexia/manifest.json @@ -12,5 +12,5 @@ "documentation": "https://www.home-assistant.io/integrations/nexia", "iot_class": "cloud_polling", "loggers": ["nexia"], - "requirements": ["nexia==2.2.1"] + "requirements": ["nexia==2.2.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 3e280a4b560..49a3982c1b6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1483,7 +1483,7 @@ nettigo-air-monitor==4.0.0 neurio==0.3.1 # homeassistant.components.nexia -nexia==2.2.1 +nexia==2.2.2 # homeassistant.components.nextcloud nextcloudmonitor==1.5.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c7c83a3eb43..e146810b836 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1246,7 +1246,7 @@ netmap==0.7.0.2 nettigo-air-monitor==4.0.0 # homeassistant.components.nexia -nexia==2.2.1 +nexia==2.2.2 # homeassistant.components.nextcloud nextcloudmonitor==1.5.1 From 2aa584ce39901e68339da802c631d1a89532a0c1 Mon Sep 17 00:00:00 2001 From: Ivan Lopez Hernandez Date: Thu, 6 Mar 2025 13:17:33 -0800 Subject: [PATCH 2296/3148] Correctly retrieve only loaded Google Generative AI config_entries (#139999) * Correctly retrieve only loaded config_entries * Ruff --- .../__init__.py | 6 +-- .../snapshots/test_init.ambr | 15 ++++++ .../test_init.py | 49 +++++++++++++++++++ 3 files changed, 67 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/google_generative_ai_conversation/__init__.py b/homeassistant/components/google_generative_ai_conversation/__init__.py index 33e361d1433..6b10565e0b5 100644 --- a/homeassistant/components/google_generative_ai_conversation/__init__.py +++ b/homeassistant/components/google_generative_ai_conversation/__init__.py @@ -65,9 +65,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: prompt_parts = [call.data[CONF_PROMPT]] - config_entry: GoogleGenerativeAIConfigEntry = hass.config_entries.async_entries( - DOMAIN - )[0] + config_entry: GoogleGenerativeAIConfigEntry = ( + hass.config_entries.async_loaded_entries(DOMAIN)[0] + ) client = config_entry.runtime_data diff --git a/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr b/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr index 8e6231cbffd..ce882adf6e6 100644 --- a/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr +++ b/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr @@ -31,3 +31,18 @@ ), ]) # --- +# name: test_load_entry_with_unloaded_entries + list([ + tuple( + '', + tuple( + ), + dict({ + 'contents': list([ + 'Write an opening speech for a Home Assistant release party', + ]), + 'model': 'models/gemini-2.0-flash', + }), + ), + ]) +# --- diff --git a/tests/components/google_generative_ai_conversation/test_init.py b/tests/components/google_generative_ai_conversation/test_init.py index 0dad485812e..25533ffd46e 100644 --- a/tests/components/google_generative_ai_conversation/test_init.py +++ b/tests/components/google_generative_ai_conversation/test_init.py @@ -224,3 +224,52 @@ async def test_config_entry_error( await hass.async_block_till_done() assert mock_config_entry.state == state assert any(mock_config_entry.async_get_active_flows(hass, {"reauth"})) == reauth + + +@pytest.mark.usefixtures("mock_init_component") +async def test_load_entry_with_unloaded_entries( + hass: HomeAssistant, snapshot: SnapshotAssertion +) -> None: + """Test loading an entry with unloaded entries.""" + config_entries = hass.config_entries.async_entries( + "google_generative_ai_conversation" + ) + runtime_data = config_entries[0].runtime_data + await hass.config_entries.async_unload(config_entries[0].entry_id) + + entry = MockConfigEntry( + domain="google_generative_ai_conversation", + title="Google Generative AI Conversation", + data={ + "api_key": "bla", + }, + state=ConfigEntryState.LOADED, + ) + entry.runtime_data = runtime_data + entry.add_to_hass(hass) + + stubbed_generated_content = ( + "I'm thrilled to welcome you all to the release " + "party for the latest version of Home Assistant!" + ) + + with patch( + "google.genai.models.AsyncModels.generate_content", + return_value=Mock( + text=stubbed_generated_content, + prompt_feedback=None, + candidates=[Mock()], + ), + ) as mock_generate: + response = await hass.services.async_call( + "google_generative_ai_conversation", + "generate_content", + {"prompt": "Write an opening speech for a Home Assistant release party"}, + blocking=True, + return_response=True, + ) + + assert response == { + "text": stubbed_generated_content, + } + assert [tuple(mock_call) for mock_call in mock_generate.mock_calls] == snapshot From fd1044dcba44c4f35762ec66ba6e1db2b94853d1 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Thu, 6 Mar 2025 23:06:47 +0100 Subject: [PATCH 2297/3148] Bump aiowebdav2 to 0.4.1 (#139988) --- homeassistant/components/webdav/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/webdav/manifest.json b/homeassistant/components/webdav/manifest.json index 3f465ceed4a..fd3c749781e 100644 --- a/homeassistant/components/webdav/manifest.json +++ b/homeassistant/components/webdav/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["aiowebdav2"], "quality_scale": "bronze", - "requirements": ["aiowebdav2==0.4.0"] + "requirements": ["aiowebdav2==0.4.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 49a3982c1b6..a36eeed26cc 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -422,7 +422,7 @@ aiowaqi==3.1.0 aiowatttime==0.1.1 # homeassistant.components.webdav -aiowebdav2==0.4.0 +aiowebdav2==0.4.1 # homeassistant.components.webostv aiowebostv==0.7.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e146810b836..ef235d89ecc 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -404,7 +404,7 @@ aiowaqi==3.1.0 aiowatttime==0.1.1 # homeassistant.components.webdav -aiowebdav2==0.4.0 +aiowebdav2==0.4.1 # homeassistant.components.webostv aiowebostv==0.7.3 From 3dd1fadc7ddff4d5f3723ebbc8507fbf11d375a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Fri, 7 Mar 2025 01:50:06 +0100 Subject: [PATCH 2298/3148] Check operation state on Home Connect program sensor update (#140011) Check operation state on program sensor update --- .../components/home_connect/sensor.py | 7 ++ tests/components/home_connect/test_sensor.py | 82 ++++++++++++++++++- 2 files changed, 86 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/home_connect/sensor.py b/homeassistant/components/home_connect/sensor.py index 924744ded56..c12e1b7b6e4 100644 --- a/homeassistant/components/home_connect/sensor.py +++ b/homeassistant/components/home_connect/sensor.py @@ -386,6 +386,13 @@ class HomeConnectProgramSensor(HomeConnectSensor): def update_native_value(self) -> None: """Update the program sensor's status.""" + self.program_running = ( + status := self.appliance.status.get(StatusKey.BSH_COMMON_OPERATION_STATE) + ) is not None and status.value in [ + BSH_OPERATION_STATE_RUN, + BSH_OPERATION_STATE_PAUSE, + BSH_OPERATION_STATE_FINISHED, + ] event = self.appliance.events.get(cast(EventKey, self.bsh_key)) if event: self._update_native_value(event.value) diff --git a/tests/components/home_connect/test_sensor.py b/tests/components/home_connect/test_sensor.py index 31fc9ea6d3f..04f5e056aa5 100644 --- a/tests/components/home_connect/test_sensor.py +++ b/tests/components/home_connect/test_sensor.py @@ -27,7 +27,7 @@ from homeassistant.components.home_connect.const import ( DOMAIN, ) from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import STATE_UNAVAILABLE, Platform +from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er @@ -302,7 +302,7 @@ ENTITY_ID_STATES = { ) ), ) -async def test_event_sensors( +async def test_program_sensors( client: MagicMock, appliance_ha_id: str, states: tuple, @@ -313,7 +313,7 @@ async def test_event_sensors( integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, ) -> None: - """Test sequence for sensors that are only available after an event happens.""" + """Test sequence for sensors that expose information about a program.""" entity_ids = ENTITY_ID_STATES.keys() time_to_freeze = "2021-01-09 12:00:00+00:00" @@ -358,6 +358,82 @@ async def test_event_sensors( assert hass.states.is_state(entity_id, state) +@pytest.mark.parametrize("appliance_ha_id", [TEST_HC_APP], indirect=True) +@pytest.mark.parametrize( + ("initial_operation_state", "initial_state", "event_order", "entity_states"), + [ + ( + "BSH.Common.EnumType.OperationState.Ready", + STATE_UNAVAILABLE, + (EventType.STATUS, EventType.EVENT), + (STATE_UNKNOWN, "60"), + ), + ( + "BSH.Common.EnumType.OperationState.Run", + STATE_UNKNOWN, + (EventType.EVENT, EventType.STATUS), + ("60", "60"), + ), + ], +) +async def test_program_sensor_edge_case( + initial_operation_state: str, + initial_state: str, + event_order: tuple[EventType, EventType], + entity_states: tuple[str, str], + appliance_ha_id: str, + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, +) -> None: + """Test edge case for the program related entities.""" + entity_id = "sensor.dishwasher_program_progress" + client.get_status = AsyncMock( + return_value=ArrayOfStatus( + [ + Status( + StatusKey.BSH_COMMON_OPERATION_STATE, + StatusKey.BSH_COMMON_OPERATION_STATE.value, + initial_operation_state, + ) + ] + ) + ) + + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + assert hass.states.is_state(entity_id, initial_state) + + for event_type, state in zip(event_order, entity_states, strict=True): + await client.add_events( + [ + EventMessage( + appliance_ha_id, + event_type, + ArrayOfEvents( + [ + Event( + key=event_key, + raw_key=event_key.value, + timestamp=0, + level="", + handling="", + value=value, + ) + ], + ), + ) + for event_key, value in EVENT_PROG_RUN[event_type].items() + ] + ) + await hass.async_block_till_done() + assert hass.states.is_state(entity_id, state) + + # Program sequence for SensorDeviceClass.TIMESTAMP edge cases. PROGRAM_SEQUENCE_EDGE_CASE = [ EVENT_PROG_DELAYED_START, From d47481a30eadde901a90c196cd9b60c09a187541 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 6 Mar 2025 22:52:29 -0500 Subject: [PATCH 2299/3148] Track when an LLM expects to continue a conversation (#139810) * Track when an LLM expects to continue a conversation * Strip content * Address comments --- .../components/anthropic/conversation.py | 4 ++- .../components/conversation/chat_log.py | 19 +++++++++++++ .../conversation.py | 4 ++- .../components/ollama/conversation.py | 4 ++- .../openai_conversation/conversation.py | 4 ++- .../components/conversation/test_chat_log.py | 28 +++++++++++++++++++ 6 files changed, 59 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/anthropic/conversation.py b/homeassistant/components/anthropic/conversation.py index 5511119d377..8d3ba5085ee 100644 --- a/homeassistant/components/anthropic/conversation.py +++ b/homeassistant/components/anthropic/conversation.py @@ -305,7 +305,9 @@ class AnthropicConversationEntity( intent_response = intent.IntentResponse(language=user_input.language) intent_response.async_set_speech(response_content.content or "") return conversation.ConversationResult( - response=intent_response, conversation_id=chat_log.conversation_id + response=intent_response, + conversation_id=chat_log.conversation_id, + continue_conversation=chat_log.continue_conversation, ) async def _async_entry_update_listener( diff --git a/homeassistant/components/conversation/chat_log.py b/homeassistant/components/conversation/chat_log.py index 19482af1983..355f423dbb6 100644 --- a/homeassistant/components/conversation/chat_log.py +++ b/homeassistant/components/conversation/chat_log.py @@ -183,6 +183,25 @@ class ChatLog: llm_api: llm.APIInstance | None = None delta_listener: Callable[[ChatLog, dict], None] | None = None + @property + def continue_conversation(self) -> bool: + """Return whether the conversation should continue.""" + if not self.content: + return False + + last_msg = self.content[-1] + + return ( + last_msg.role == "assistant" + and last_msg.content is not None # type: ignore[union-attr] + and last_msg.content.strip().endswith( # type: ignore[union-attr] + ( + "?", + ";", # Greek question mark + ) + ) + ) + @property def unresponded_tool_results(self) -> bool: """Return if there are unresponded tool results.""" diff --git a/homeassistant/components/google_generative_ai_conversation/conversation.py b/homeassistant/components/google_generative_ai_conversation/conversation.py index 168e867d857..b43558c6768 100644 --- a/homeassistant/components/google_generative_ai_conversation/conversation.py +++ b/homeassistant/components/google_generative_ai_conversation/conversation.py @@ -459,7 +459,9 @@ class GoogleGenerativeAIConversationEntity( " ".join([part.text.strip() for part in response_parts if part.text]) ) return conversation.ConversationResult( - response=response, conversation_id=chat_log.conversation_id + response=response, + conversation_id=chat_log.conversation_id, + continue_conversation=chat_log.continue_conversation, ) async def _async_entry_update_listener( diff --git a/homeassistant/components/ollama/conversation.py b/homeassistant/components/ollama/conversation.py index 90e81544f66..85daf742035 100644 --- a/homeassistant/components/ollama/conversation.py +++ b/homeassistant/components/ollama/conversation.py @@ -292,7 +292,9 @@ class OllamaConversationEntity( ) intent_response.async_set_speech(chat_log.content[-1].content or "") return conversation.ConversationResult( - response=intent_response, conversation_id=chat_log.conversation_id + response=intent_response, + conversation_id=chat_log.conversation_id, + continue_conversation=chat_log.continue_conversation, ) def _trim_history(self, message_history: MessageHistory, max_messages: int) -> None: diff --git a/homeassistant/components/openai_conversation/conversation.py b/homeassistant/components/openai_conversation/conversation.py index cc09ec77c0e..37be41947f7 100644 --- a/homeassistant/components/openai_conversation/conversation.py +++ b/homeassistant/components/openai_conversation/conversation.py @@ -310,7 +310,9 @@ class OpenAIConversationEntity( assert type(chat_log.content[-1]) is conversation.AssistantContent intent_response.async_set_speech(chat_log.content[-1].content or "") return conversation.ConversationResult( - response=intent_response, conversation_id=chat_log.conversation_id + response=intent_response, + conversation_id=chat_log.conversation_id, + continue_conversation=chat_log.continue_conversation, ) async def _async_entry_update_listener( diff --git a/tests/components/conversation/test_chat_log.py b/tests/components/conversation/test_chat_log.py index c0687ebecfb..97094740af0 100644 --- a/tests/components/conversation/test_chat_log.py +++ b/tests/components/conversation/test_chat_log.py @@ -14,6 +14,7 @@ from homeassistant.components.conversation import ( ConversationInput, ConverseError, ToolResultContent, + UserContent, async_get_chat_log, ) from homeassistant.components.conversation.chat_log import DATA_CHAT_LOGS @@ -643,3 +644,30 @@ async def test_chat_log_reuse( assert len(chat_log.content) == 2 assert chat_log.content[1].role == "user" assert chat_log.content[1].content == mock_conversation_input.text + + +async def test_chat_log_continue_conversation( + hass: HomeAssistant, + mock_conversation_input: ConversationInput, +) -> None: + """Test continue conversation.""" + with ( + chat_session.async_get_chat_session(hass) as session, + async_get_chat_log(hass, session) as chat_log, + ): + assert chat_log.continue_conversation is False + chat_log.async_add_user_content(UserContent(mock_conversation_input.text)) + assert chat_log.continue_conversation is False + chat_log.async_add_assistant_content_without_tools( + AssistantContent( + agent_id="mock-agent-id", + content="Hey? ", + ) + ) + chat_log.async_add_assistant_content_without_tools( + AssistantContent( + agent_id="mock-agent-id", + content="Ποιο είναι το αγαπημένο σου χρώμα στα ελληνικά;", + ) + ) + assert chat_log.continue_conversation is True From 9682d3b313996f648293905ea09d6456edb8420d Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Fri, 7 Mar 2025 07:50:34 +0100 Subject: [PATCH 2300/3148] Bump aiohomeconnect to 0.16.3 (#140014) --- homeassistant/components/home_connect/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/home_connect/manifest.json b/homeassistant/components/home_connect/manifest.json index 5293e8bf468..62892e7c85b 100644 --- a/homeassistant/components/home_connect/manifest.json +++ b/homeassistant/components/home_connect/manifest.json @@ -7,6 +7,6 @@ "documentation": "https://www.home-assistant.io/integrations/home_connect", "iot_class": "cloud_push", "loggers": ["aiohomeconnect"], - "requirements": ["aiohomeconnect==0.16.2"], + "requirements": ["aiohomeconnect==0.16.3"], "single_config_entry": true } diff --git a/requirements_all.txt b/requirements_all.txt index a36eeed26cc..bee5afef4cb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -264,7 +264,7 @@ aioharmony==0.4.1 aiohasupervisor==0.3.0 # homeassistant.components.home_connect -aiohomeconnect==0.16.2 +aiohomeconnect==0.16.3 # homeassistant.components.homekit_controller aiohomekit==3.2.8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ef235d89ecc..bcfc539e41b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -249,7 +249,7 @@ aioharmony==0.4.1 aiohasupervisor==0.3.0 # homeassistant.components.home_connect -aiohomeconnect==0.16.2 +aiohomeconnect==0.16.3 # homeassistant.components.homekit_controller aiohomekit==3.2.8 From 6be8370eb372ef0fdb9152fa39044fec5657da95 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Fri, 7 Mar 2025 17:45:25 +1000 Subject: [PATCH 2301/3148] Fix powerwall 0% in Tessie and Tesla Fleet (#140017) Fix powerwall zero --- homeassistant/components/tesla_fleet/sensor.py | 1 + homeassistant/components/tessie/sensor.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/tesla_fleet/sensor.py b/homeassistant/components/tesla_fleet/sensor.py index 64ecc35469b..bdd5ce2c001 100644 --- a/homeassistant/components/tesla_fleet/sensor.py +++ b/homeassistant/components/tesla_fleet/sensor.py @@ -466,6 +466,7 @@ async def async_setup_entry( for energysite in entry.runtime_data.energysites for description in ENERGY_LIVE_DESCRIPTIONS if description.key in energysite.live_coordinator.data + or description.key == "percentage_charged" ), ( # Add energy site history TeslaFleetEnergyHistorySensorEntity(energysite, description) diff --git a/homeassistant/components/tessie/sensor.py b/homeassistant/components/tessie/sensor.py index 4f62e1b1855..1c26ad633f3 100644 --- a/homeassistant/components/tessie/sensor.py +++ b/homeassistant/components/tessie/sensor.py @@ -397,6 +397,7 @@ async def async_setup_entry( for energysite in entry.runtime_data.energysites for description in ENERGY_LIVE_DESCRIPTIONS if description.key in energysite.live_coordinator.data + or description.key == "percentage_charged" ), ( # Add wall connectors TessieWallConnectorSensorEntity(energysite, din, description) @@ -449,7 +450,6 @@ class TessieEnergyLiveSensorEntity(TessieEnergyEntity, SensorEntity): def _async_update_attrs(self) -> None: """Update the attributes of the sensor.""" - self._attr_available = self._value is not None self._attr_native_value = self.entity_description.value_fn(self._value) From 452fbbe61cd5542361eaa2463f2e2624352eb23b Mon Sep 17 00:00:00 2001 From: David Bonnes Date: Fri, 7 Mar 2025 09:12:21 +0000 Subject: [PATCH 2302/3148] Fix regression to evohome debug logging (#140000) * fix regression in debug logging * lint --- homeassistant/components/evohome/coordinator.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/evohome/coordinator.py b/homeassistant/components/evohome/coordinator.py index 7b197f1b643..3264af6b2fd 100644 --- a/homeassistant/components/evohome/coordinator.py +++ b/homeassistant/components/evohome/coordinator.py @@ -11,6 +11,7 @@ from typing import Any import evohomeasync as ec1 import evohomeasync2 as ec2 from evohomeasync2.const import ( + SZ_DHW, SZ_GATEWAY_ID, SZ_GATEWAY_INFO, SZ_GATEWAYS, @@ -19,8 +20,9 @@ from evohomeasync2.const import ( SZ_TEMPERATURE_CONTROL_SYSTEMS, SZ_TIME_ZONE, SZ_USE_DAYLIGHT_SAVE_SWITCHING, + SZ_ZONES, ) -from evohomeasync2.schemas.typedefs import EvoLocStatusResponseT +from evohomeasync2.schemas.typedefs import EvoLocStatusResponseT, EvoTcsConfigResponseT from homeassistant.const import CONF_SCAN_INTERVAL from homeassistant.core import HomeAssistant @@ -113,17 +115,19 @@ class EvoDataUpdateCoordinator(DataUpdateCoordinator): SZ_USE_DAYLIGHT_SAVE_SWITCHING ], } + tcs_info: EvoTcsConfigResponseT = self.tcs.config # type: ignore[assignment] + tcs_info[SZ_ZONES] = [zone.config for zone in self.tcs.zones] + if self.tcs.hotwater: + tcs_info[SZ_DHW] = self.tcs.hotwater.config gwy_info = { SZ_GATEWAY_ID: self.loc.gateways[0].id, - SZ_TEMPERATURE_CONTROL_SYSTEMS: [ - self.loc.gateways[0].systems[0].config - ], + SZ_TEMPERATURE_CONTROL_SYSTEMS: [tcs_info], } config = { SZ_LOCATION_INFO: loc_info, SZ_GATEWAYS: [{SZ_GATEWAY_INFO: gwy_info}], } - self.logger.debug("Config = %s", config) + self.logger.debug("Config = %s", [config]) async def call_client_api( self, From 8780bc99eb06c34db9d6bad0ad0c36a91a2f9f4f Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Fri, 7 Mar 2025 10:44:17 +0100 Subject: [PATCH 2303/3148] Set content length when uploading files to WebDAV (#139950) --- homeassistant/components/webdav/backup.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/webdav/backup.py b/homeassistant/components/webdav/backup.py index a5cf2c56182..321ed98bfa8 100644 --- a/homeassistant/components/webdav/backup.py +++ b/homeassistant/components/webdav/backup.py @@ -169,6 +169,7 @@ class WebDavBackupAgent(BackupAgent): await open_stream(), f"{self._backup_path}/{filename_tar}", timeout=BACKUP_TIMEOUT, + content_length=backup.size, ) _LOGGER.debug( From 2985f08054815df125c0405028eb0ba095d69eff Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 7 Mar 2025 00:56:00 -1000 Subject: [PATCH 2304/3148] Bump dbus-fast to 2.39.3 (#140015) * Bump dbus-fast to 2.39.2 changelog: https://github.com/Bluetooth-Devices/dbus-fast/compare/v2.37.0...v2.39.2 * bump again for more fixes --- 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 f097eb3a3cf..ec617b82a04 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -20,7 +20,7 @@ "bluetooth-adapters==0.21.4", "bluetooth-auto-recovery==1.4.4", "bluetooth-data-tools==1.25.0", - "dbus-fast==2.37.0", + "dbus-fast==2.39.3", "habluetooth==3.25.0" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 3513ddfdb82..988c2934cd8 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -29,7 +29,7 @@ certifi>=2021.5.30 ciso8601==2.3.2 cronsim==2.6 cryptography==44.0.1 -dbus-fast==2.37.0 +dbus-fast==2.39.3 fnv-hash-fast==1.4.0 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 diff --git a/requirements_all.txt b/requirements_all.txt index bee5afef4cb..a4d5f6c6914 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -744,7 +744,7 @@ datadog==0.15.0 datapoint==0.9.9 # homeassistant.components.bluetooth -dbus-fast==2.37.0 +dbus-fast==2.39.3 # homeassistant.components.debugpy debugpy==1.8.11 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bcfc539e41b..2d34851cecc 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -640,7 +640,7 @@ datadog==0.15.0 datapoint==0.9.9 # homeassistant.components.bluetooth -dbus-fast==2.37.0 +dbus-fast==2.39.3 # homeassistant.components.debugpy debugpy==1.8.11 From 73ef2409216fdc7d0bb6f5e786bf281485a580d7 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 7 Mar 2025 12:55:32 +0100 Subject: [PATCH 2305/3148] Fix SmartThings disabling working capabilities (#140039) --- .../components/smartthings/__init__.py | 18 +- tests/components/smartthings/conftest.py | 1 + .../device_status/da_wm_wm_000001_1.json | 1416 +++++++++++++++++ .../fixtures/devices/da_wm_wm_000001_1.json | 261 +++ .../smartthings/snapshots/test_init.ambr | 33 + .../smartthings/snapshots/test_sensor.ambr | 469 ++++++ .../smartthings/snapshots/test_switch.ambr | 47 + 7 files changed, 2241 insertions(+), 4 deletions(-) create mode 100644 tests/components/smartthings/fixtures/device_status/da_wm_wm_000001_1.json create mode 100644 tests/components/smartthings/fixtures/devices/da_wm_wm_000001_1.json diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py index 9e2178196d5..cf17e6a110b 100644 --- a/homeassistant/components/smartthings/__init__.py +++ b/homeassistant/components/smartthings/__init__.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass import logging from typing import TYPE_CHECKING, cast @@ -185,6 +186,16 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True +DATA_VALIDATION: dict[ + Capability | str, Callable[[dict[Attribute | str, Status]], bool] +] = { + Capability.WASHER_OPERATING_STATE: ( + lambda status: status[Attribute.SUPPORTED_MACHINE_STATES].value is not None + ), + Capability.DEMAND_RESPONSE_LOAD_CONTROL: lambda _: True, +} + + def process_status( status: dict[str, dict[Capability | str, dict[Attribute | str, Status]]], ) -> dict[str, dict[Capability | str, dict[Attribute | str, Status]]]: @@ -201,10 +212,9 @@ def process_status( ) if disabled_capabilities is not None: for capability in disabled_capabilities: - # We still need to make sure the climate entity can work without this capability - if ( - capability in main_component - and capability != Capability.DEMAND_RESPONSE_LOAD_CONTROL + if capability in main_component and ( + capability not in DATA_VALIDATION + or not DATA_VALIDATION[capability](main_component[capability]) ): del main_component[capability] return status diff --git a/tests/components/smartthings/conftest.py b/tests/components/smartthings/conftest.py index c78b4bc05de..b6e6339af97 100644 --- a/tests/components/smartthings/conftest.py +++ b/tests/components/smartthings/conftest.py @@ -105,6 +105,7 @@ def mock_smartthings() -> Generator[AsyncMock]: "da_wm_dw_000001", "da_wm_wd_000001", "da_wm_wm_000001", + "da_wm_wm_000001_1", "da_rvc_normal_000001", "da_ks_microwave_0101x", "hue_color_temperature_bulb", diff --git a/tests/components/smartthings/fixtures/device_status/da_wm_wm_000001_1.json b/tests/components/smartthings/fixtures/device_status/da_wm_wm_000001_1.json new file mode 100644 index 00000000000..157e5496625 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/da_wm_wm_000001_1.json @@ -0,0 +1,1416 @@ +{ + "components": { + "hca.main": { + "hca.washerMode": { + "mode": { + "value": "mix", + "timestamp": "2025-03-07T06:06:08.905Z" + }, + "supportedModes": { + "value": ["normal", "quickWash", "mix", "eco"], + "timestamp": "2025-03-07T06:06:08.613Z" + } + } + }, + "main": { + "samsungce.washerDelayEnd": { + "remainingTime": { + "value": 0, + "unit": "min", + "timestamp": "2025-03-07T06:06:08.806Z" + }, + "minimumReservableTime": { + "value": 49, + "unit": "min", + "timestamp": "2025-03-07T06:06:08.613Z" + } + }, + "samsungce.washerWaterLevel": { + "supportedWaterLevel": { + "value": null, + "timestamp": "2021-03-31T22:35:35.010Z" + }, + "waterLevel": { + "value": null, + "timestamp": "2021-04-17T09:56:20.618Z" + } + }, + "samsungce.welcomeMessage": { + "welcomeMessage": { + "value": null, + "timestamp": "2021-04-01T23:43:08.541Z" + } + }, + "custom.washerWaterTemperature": { + "supportedWasherWaterTemperature": { + "value": ["none", "cold", "20", "30", "40", "60", "90"], + "timestamp": "2024-12-09T22:01:38.311Z" + }, + "washerWaterTemperature": { + "value": "40", + "timestamp": "2025-03-07T06:06:08.901Z" + } + }, + "samsungce.softenerAutoReplenishment": { + "regularSoftenerType": { + "value": null + }, + "regularSoftenerAlarmEnabled": { + "value": null + }, + "regularSoftenerInitialAmount": { + "value": null + }, + "regularSoftenerRemainingAmount": { + "value": null + }, + "regularSoftenerDosage": { + "value": null + }, + "regularSoftenerOrderThreshold": { + "value": null + } + }, + "samsungce.autoDispenseSoftener": { + "remainingAmount": { + "value": null, + "timestamp": "2021-01-29T10:38:25.844Z" + }, + "amount": { + "value": null, + "timestamp": "2020-12-28T07:28:49.408Z" + }, + "supportedDensity": { + "value": null, + "timestamp": "2020-12-28T07:28:49.408Z" + }, + "density": { + "value": null, + "timestamp": "2020-12-28T07:28:49.408Z" + }, + "supportedAmount": { + "value": null, + "timestamp": "2020-12-28T07:28:49.408Z" + } + }, + "samsungce.dongleSoftwareInstallation": { + "status": { + "value": "completed", + "timestamp": "2022-06-14T22:23:10.096Z" + } + }, + "samsungce.autoDispenseDetergent": { + "remainingAmount": { + "value": null, + "timestamp": "2021-01-26T01:49:50.635Z" + }, + "amount": { + "value": null, + "timestamp": "2020-12-28T07:15:24.539Z" + }, + "supportedDensity": { + "value": null, + "timestamp": "2020-12-28T07:15:24.539Z" + }, + "density": { + "value": null, + "timestamp": "2020-12-28T07:15:24.539Z" + }, + "supportedAmount": { + "value": null, + "timestamp": "2020-12-28T07:15:24.539Z" + }, + "availableTypes": { + "value": null + }, + "type": { + "value": null + }, + "recommendedAmount": { + "value": null + } + }, + "samsungce.deviceIdentification": { + "micomAssayCode": { + "value": "20224941", + "timestamp": "2025-03-07T06:06:08.719Z" + }, + "modelName": { + "value": null, + "timestamp": "2021-04-01T23:32:40.512Z" + }, + "serialNumber": { + "value": null, + "timestamp": "2021-04-01T23:32:38.884Z" + }, + "serialNumberExtra": { + "value": null, + "timestamp": "2021-04-01T23:32:36.541Z" + }, + "modelClassificationCode": { + "value": "20010102011211030203000000000000", + "timestamp": "2025-03-07T06:06:08.719Z" + }, + "description": { + "value": "DA_WM_A51_20_COMMON_\u0018WD7800N/DC92-02249A_08CC", + "timestamp": "2025-03-07T06:06:08.719Z" + }, + "releaseYear": { + "value": null + }, + "binaryId": { + "value": "DA_WM_A51_20_COMMON", + "timestamp": "2025-03-07T06:06:08.719Z" + } + }, + "samsungce.washerWaterValve": { + "waterValve": { + "value": null, + "timestamp": "2021-04-01T23:43:07.144Z" + }, + "supportedWaterValve": { + "value": null, + "timestamp": "2021-03-31T22:35:34.371Z" + } + }, + "washerOperatingState": { + "completionTime": { + "value": "2025-03-07T07:01:12Z", + "timestamp": "2025-03-07T06:12:12.191Z" + }, + "machineState": { + "value": "run", + "timestamp": "2025-03-07T06:12:12.191Z" + }, + "washerJobState": { + "value": "wash", + "timestamp": "2025-03-07T06:12:37.974Z" + }, + "supportedMachineStates": { + "value": ["stop", "run", "pause"], + "timestamp": "2025-03-07T06:06:08.806Z" + } + }, + "switch": { + "switch": { + "value": "on", + "timestamp": "2025-03-07T06:06:08.856Z" + } + }, + "custom.washerAutoSoftener": { + "washerAutoSoftener": { + "value": null, + "timestamp": "2020-08-07T21:22:34.172Z" + } + }, + "samsungce.washerFreezePrevent": { + "operatingState": { + "value": null + } + }, + "samsungce.quickControl": { + "version": { + "value": null + } + }, + "samsungce.washerCycle": { + "supportedCycles": { + "value": [ + { + "cycle": "D0", + "cycleType": "allInOne", + "supportedOptions": { + "bubbleSoak": { + "raw": "35F0", + "default": "off", + "options": ["on", "off"] + }, + "dryingLevel": { + "raw": "B01F", + "default": "none", + "options": [ + "none", + "cupboard", + "30", + "60", + "90", + "210", + "240", + "270" + ] + }, + "spinLevel": { + "raw": "A67F", + "default": "1400", + "options": [ + "rinseHold", + "noSpin", + "400", + "800", + "1000", + "1200", + "1400" + ] + }, + "rinseCycle": { + "raw": "923F", + "default": "2", + "options": ["0", "1", "2", "3", "4", "5"] + }, + "waterTemperature": { + "raw": "847E", + "default": "40", + "options": ["cold", "20", "30", "40", "60", "90"] + } + } + }, + { + "cycle": "DC", + "cycleType": "allInOne", + "supportedOptions": { + "bubbleSoak": { + "raw": "3500", + "default": "off", + "options": [] + }, + "dryingLevel": { + "raw": "B01F", + "default": "none", + "options": [ + "none", + "cupboard", + "30", + "60", + "90", + "210", + "240", + "270" + ] + }, + "spinLevel": { + "raw": "A33F", + "default": "800", + "options": [ + "rinseHold", + "noSpin", + "400", + "800", + "1000", + "1200" + ] + }, + "rinseCycle": { + "raw": "933F", + "default": "3", + "options": ["0", "1", "2", "3", "4", "5"] + }, + "waterTemperature": { + "raw": "811E", + "default": "cold", + "options": ["cold", "20", "30", "40"] + } + } + }, + { + "cycle": "E3", + "cycleType": "allInOne", + "supportedOptions": { + "bubbleSoak": { + "raw": "35F0", + "default": "off", + "options": ["on", "off"] + }, + "dryingLevel": { + "raw": "B01F", + "default": "none", + "options": [ + "none", + "cupboard", + "30", + "60", + "90", + "210", + "240", + "270" + ] + }, + "spinLevel": { + "raw": "A67F", + "default": "1400", + "options": [ + "rinseHold", + "noSpin", + "400", + "800", + "1000", + "1200", + "1400" + ] + }, + "rinseCycle": { + "raw": "923F", + "default": "2", + "options": ["0", "1", "2", "3", "4", "5"] + }, + "waterTemperature": { + "raw": "841E", + "default": "40", + "options": ["cold", "20", "30", "40"] + } + } + }, + { + "cycle": "E4", + "cycleType": "allInOne", + "supportedOptions": { + "bubbleSoak": { + "raw": "35F0", + "default": "off", + "options": ["on", "off"] + }, + "dryingLevel": { + "raw": "B01F", + "default": "none", + "options": [ + "none", + "cupboard", + "30", + "60", + "90", + "210", + "240", + "270" + ] + }, + "spinLevel": { + "raw": "A67F", + "default": "1400", + "options": [ + "rinseHold", + "noSpin", + "400", + "800", + "1000", + "1200", + "1400" + ] + }, + "rinseCycle": { + "raw": "943F", + "default": "4", + "options": ["0", "1", "2", "3", "4", "5"] + }, + "waterTemperature": { + "raw": "8560", + "default": "60", + "options": ["60", "90"] + } + } + }, + { + "cycle": "50", + "cycleType": "dryingOnly", + "supportedOptions": { + "bubbleSoak": { + "raw": "3500", + "default": "off", + "options": [] + }, + "dryingLevel": { + "raw": "B11E", + "default": "cupboard", + "options": ["cupboard", "30", "60", "90", "210", "240", "270"] + }, + "spinLevel": { + "raw": "A000", + "default": "rinseHold", + "options": [] + }, + "rinseCycle": { + "raw": "9000", + "default": "0", + "options": [] + }, + "waterTemperature": { + "raw": "8000", + "default": "none", + "options": [] + } + } + }, + { + "cycle": "51", + "cycleType": "dryingOnly", + "supportedOptions": { + "bubbleSoak": { + "raw": "3500", + "default": "off", + "options": [] + }, + "dryingLevel": { + "raw": "B11E", + "default": "cupboard", + "options": ["cupboard", "30", "60", "90", "210", "240", "270"] + }, + "spinLevel": { + "raw": "A000", + "default": "rinseHold", + "options": [] + }, + "rinseCycle": { + "raw": "9000", + "default": "0", + "options": [] + }, + "waterTemperature": { + "raw": "8000", + "default": "none", + "options": [] + } + } + }, + { + "cycle": "CA", + "cycleType": "dryingOnly", + "supportedOptions": { + "bubbleSoak": { + "raw": "3500", + "default": "off", + "options": [] + }, + "dryingLevel": { + "raw": "B000", + "default": "none", + "options": ["210", "240", "270"] + }, + "spinLevel": { + "raw": "A000", + "default": "rinseHold", + "options": [] + }, + "rinseCycle": { + "raw": "9000", + "default": "0", + "options": [] + }, + "waterTemperature": { + "raw": "8000", + "default": "none", + "options": [] + } + } + }, + { + "cycle": "E7", + "cycleType": "allInOne", + "supportedOptions": { + "bubbleSoak": { + "raw": "35F0", + "default": "off", + "options": ["on", "off"] + }, + "dryingLevel": { + "raw": "B100", + "default": "cupboard", + "options": ["210", "240", "270"] + }, + "spinLevel": { + "raw": "A640", + "default": "1400", + "options": ["1400"] + }, + "rinseCycle": { + "raw": "923F", + "default": "2", + "options": ["0", "1", "2", "3", "4", "5"] + }, + "waterTemperature": { + "raw": "841E", + "default": "40", + "options": ["cold", "20", "30", "40"] + } + } + }, + { + "cycle": "C7", + "cycleType": "allInOne", + "supportedOptions": { + "bubbleSoak": { + "raw": "3500", + "default": "off", + "options": [] + }, + "dryingLevel": { + "raw": "B000", + "default": "none", + "options": ["210", "240", "270"] + }, + "spinLevel": { + "raw": "A520", + "default": "1200", + "options": ["1200"] + }, + "rinseCycle": { + "raw": "9204", + "default": "2", + "options": ["2"] + }, + "waterTemperature": { + "raw": "8520", + "default": "60", + "options": ["60"] + } + } + }, + { + "cycle": "D8", + "cycleType": "allInOne", + "supportedOptions": { + "bubbleSoak": { + "raw": "3500", + "default": "off", + "options": [] + }, + "dryingLevel": { + "raw": "B000", + "default": "none", + "options": ["210", "240", "270"] + }, + "spinLevel": { + "raw": "A30F", + "default": "800", + "options": ["rinseHold", "noSpin", "400", "800"] + }, + "rinseCycle": { + "raw": "920F", + "default": "2", + "options": ["0", "1", "2", "3"] + }, + "waterTemperature": { + "raw": "841E", + "default": "40", + "options": ["cold", "20", "30", "40"] + } + } + }, + { + "cycle": "D4", + "cycleType": "allInOne", + "supportedOptions": { + "bubbleSoak": { + "raw": "3500", + "default": "off", + "options": [] + }, + "dryingLevel": { + "raw": "B01F", + "default": "none", + "options": [ + "none", + "cupboard", + "30", + "60", + "90", + "210", + "240", + "270" + ] + }, + "spinLevel": { + "raw": "A67F", + "default": "1400", + "options": [ + "rinseHold", + "noSpin", + "400", + "800", + "1000", + "1200", + "1400" + ] + }, + "rinseCycle": { + "raw": "913F", + "default": "1", + "options": ["0", "1", "2", "3", "4", "5"] + }, + "waterTemperature": { + "raw": "8000", + "default": "none", + "options": [] + } + } + }, + { + "cycle": "D3", + "cycleType": "allInOne", + "supportedOptions": { + "bubbleSoak": { + "raw": "3500", + "default": "off", + "options": [] + }, + "dryingLevel": { + "raw": "B01F", + "default": "none", + "options": [ + "none", + "cupboard", + "30", + "60", + "90", + "210", + "240", + "270" + ] + }, + "spinLevel": { + "raw": "A30F", + "default": "800", + "options": ["rinseHold", "noSpin", "400", "800"] + }, + "rinseCycle": { + "raw": "920F", + "default": "2", + "options": ["0", "1", "2", "3"] + }, + "waterTemperature": { + "raw": "831E", + "default": "30", + "options": ["cold", "20", "30", "40"] + } + } + }, + { + "cycle": "DA", + "cycleType": "allInOne", + "supportedOptions": { + "bubbleSoak": { + "raw": "3500", + "default": "off", + "options": [] + }, + "dryingLevel": { + "raw": "B01F", + "default": "none", + "options": [ + "none", + "cupboard", + "30", + "60", + "90", + "210", + "240", + "270" + ] + }, + "spinLevel": { + "raw": "A53F", + "default": "1200", + "options": [ + "rinseHold", + "noSpin", + "400", + "800", + "1000", + "1200" + ] + }, + "rinseCycle": { + "raw": "923F", + "default": "2", + "options": ["0", "1", "2", "3", "4", "5"] + }, + "waterTemperature": { + "raw": "8102", + "default": "cold", + "options": ["cold"] + } + } + }, + { + "cycle": "D2", + "cycleType": "allInOne", + "supportedOptions": { + "bubbleSoak": { + "raw": "35F0", + "default": "off", + "options": ["on", "off"] + }, + "dryingLevel": { + "raw": "B01F", + "default": "none", + "options": [ + "none", + "cupboard", + "30", + "60", + "90", + "210", + "240", + "270" + ] + }, + "spinLevel": { + "raw": "A67F", + "default": "1400", + "options": [ + "rinseHold", + "noSpin", + "400", + "800", + "1000", + "1200", + "1400" + ] + }, + "rinseCycle": { + "raw": "923F", + "default": "2", + "options": ["0", "1", "2", "3", "4", "5"] + }, + "waterTemperature": { + "raw": "843E", + "default": "40", + "options": ["cold", "20", "30", "40", "60"] + } + } + } + ], + "timestamp": "2024-12-09T22:01:38.311Z" + }, + "washerCycle": { + "value": "Table_00_Course_E3", + "timestamp": "2025-03-07T06:06:08.905Z" + }, + "referenceTable": { + "value": { + "id": "Table_00" + }, + "timestamp": "2021-12-01T23:55:08.740Z" + }, + "specializedFunctionClassification": { + "value": 7, + "timestamp": "2025-03-07T06:06:08.613Z" + } + }, + "samsungce.waterConsumptionReport": { + "waterConsumption": { + "value": null, + "timestamp": "2021-03-31T22:35:33.802Z" + } + }, + "ocf": { + "st": { + "value": null, + "timestamp": "2020-08-11T22:47:36.523Z" + }, + "mndt": { + "value": null, + "timestamp": "2020-08-11T22:47:41.693Z" + }, + "mnfv": { + "value": "DA_WM_A51_20_COMMON_30230708", + "timestamp": "2024-12-09T22:01:37.735Z" + }, + "mnhw": { + "value": "ARTIK051", + "timestamp": "2024-12-09T22:01:37.735Z" + }, + "di": { + "value": "63803fae-cbed-f356-a063-2cf148ae3ca7", + "timestamp": "2024-12-09T22:01:37.735Z" + }, + "mnsl": { + "value": "http://www.samsung.com", + "timestamp": "2024-12-09T22:01:37.735Z" + }, + "dmv": { + "value": "1.2.1", + "timestamp": "2024-12-27T04:48:02.896Z" + }, + "n": { + "value": "[washer] Samsung", + "timestamp": "2024-12-09T22:01:37.735Z" + }, + "mnmo": { + "value": "DA_WM_A51_20_COMMON|20224941|20010102011211030203000000000000", + "timestamp": "2024-12-09T22:01:37.735Z" + }, + "vid": { + "value": "DA-WM-WM-000001", + "timestamp": "2024-12-09T22:01:37.735Z" + }, + "mnmn": { + "value": "Samsung Electronics", + "timestamp": "2024-12-09T22:01:37.735Z" + }, + "mnml": { + "value": "http://www.samsung.com", + "timestamp": "2024-12-09T22:01:37.735Z" + }, + "mnpv": { + "value": "DAWIT 2.0", + "timestamp": "2024-12-09T22:01:37.735Z" + }, + "mnos": { + "value": "TizenRT 1.0 + IPv6", + "timestamp": "2024-12-09T22:01:37.735Z" + }, + "pi": { + "value": "63803fae-cbed-f356-a063-2cf148ae3ca7", + "timestamp": "2024-12-09T22:01:37.735Z" + }, + "icv": { + "value": "core.1.1.0", + "timestamp": "2024-12-27T04:48:02.896Z" + } + }, + "custom.dryerDryLevel": { + "dryerDryLevel": { + "value": "none", + "timestamp": "2025-03-07T06:06:08.901Z" + }, + "supportedDryerDryLevel": { + "value": [ + "none", + "cupboard", + "30", + "60", + "90", + "120", + "150", + "180", + "210", + "240", + "270" + ], + "timestamp": "2024-12-09T22:01:38.311Z" + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": [ + "samsungce.washerDelayEnd", + "washerOperatingState", + "samsungce.autoDispenseDetergent", + "samsungce.autoDispenseSoftener", + "samsungce.waterConsumptionReport", + "samsungce.washerCyclePreset", + "samsungce.welcomeMessage", + "samsungce.dongleSoftwareInstallation", + "sec.wifiConfiguration", + "samsungce.quickControl", + "samsungce.deviceInfoPrivate", + "samsungce.energyPlanner", + "demandResponseLoadControl", + "samsungce.softenerAutoReplenishment", + "samsungce.softenerOrder", + "samsungce.softenerState", + "samsungce.washerFreezePrevent", + "custom.washerSoilLevel", + "samsungce.washerWaterLevel", + "samsungce.washerWaterValve", + "samsungce.washerWashingTime", + "custom.washerAutoDetergent", + "custom.washerAutoSoftener", + "sec.diagnosticsInformation" + ], + "timestamp": "2024-07-03T08:44:32.524Z" + } + }, + "custom.washerRinseCycles": { + "supportedWasherRinseCycles": { + "value": ["0", "1", "2", "3", "4", "5"], + "timestamp": "2024-12-09T22:01:38.311Z" + }, + "washerRinseCycles": { + "value": "2", + "timestamp": "2025-03-07T06:06:08.901Z" + } + }, + "samsungce.driverVersion": { + "versionNumber": { + "value": 24110101, + "timestamp": "2024-12-03T02:08:44.235Z" + } + }, + "sec.diagnosticsInformation": { + "logType": { + "value": null + }, + "endpoint": { + "value": null + }, + "minVersion": { + "value": null + }, + "signinPermission": { + "value": null + }, + "setupId": { + "value": null + }, + "protocolType": { + "value": null + }, + "tsId": { + "value": null + }, + "mnId": { + "value": null + }, + "dumpType": { + "value": null + } + }, + "samsungce.washerOperatingState": { + "washerJobState": { + "value": "wash", + "timestamp": "2025-03-07T06:12:37.974Z" + }, + "operatingState": { + "value": "running", + "timestamp": "2025-03-07T06:12:12.191Z" + }, + "supportedOperatingStates": { + "value": ["ready", "running", "paused"], + "timestamp": "2022-11-02T21:35:52.935Z" + }, + "scheduledJobs": { + "value": [ + { + "jobName": "wash", + "timeInMin": 21 + }, + { + "jobName": "rinse", + "timeInMin": 16 + }, + { + "jobName": "spin", + "timeInMin": 11 + } + ], + "timestamp": "2025-03-07T06:06:08.613Z" + }, + "scheduledPhases": { + "value": [ + { + "phaseName": "wash", + "timeInMin": 21 + }, + { + "phaseName": "rinse", + "timeInMin": 16 + }, + { + "phaseName": "spin", + "timeInMin": 11 + } + ], + "timestamp": "2025-03-07T06:06:08.613Z" + }, + "progress": { + "value": 36, + "unit": "%", + "timestamp": "2025-03-07T06:30:10.639Z" + }, + "remainingTimeStr": { + "value": "00:31", + "timestamp": "2025-03-07T06:30:10.639Z" + }, + "washerJobPhase": { + "value": "wash", + "timestamp": "2025-03-07T06:12:37.974Z" + }, + "operationTime": { + "value": 49, + "unit": "min", + "timestamp": "2025-03-06T02:24:50.104Z" + }, + "remainingTime": { + "value": 31, + "unit": "min", + "timestamp": "2025-03-07T06:30:10.639Z" + } + }, + "samsungce.kidsLock": { + "lockState": { + "value": "unlocked", + "timestamp": "2025-03-07T06:06:08.688Z" + } + }, + "demandResponseLoadControl": { + "drlcStatus": { + "value": null + } + }, + "samsungce.detergentOrder": { + "alarmEnabled": { + "value": false, + "timestamp": "2025-03-07T06:06:08.613Z" + }, + "orderThreshold": { + "value": 0, + "unit": "cc", + "timestamp": "2025-03-07T06:06:08.613Z" + } + }, + "powerConsumptionReport": { + "powerConsumption": { + "value": { + "energy": 1323600, + "deltaEnergy": 100, + "power": 0, + "powerEnergy": 0.0, + "persistedEnergy": 0, + "energySaved": 0, + "start": "2025-03-07T06:21:09Z", + "end": "2025-03-07T06:23:21Z" + }, + "timestamp": "2025-03-07T06:23:21.062Z" + } + }, + "samsungce.detergentAutoReplenishment": { + "neutralDetergentType": { + "value": null + }, + "regularDetergentRemainingAmount": { + "value": 0, + "unit": "cc", + "timestamp": "2025-03-07T06:06:08.613Z" + }, + "babyDetergentRemainingAmount": { + "value": null + }, + "neutralDetergentRemainingAmount": { + "value": null + }, + "neutralDetergentAlarmEnabled": { + "value": null + }, + "neutralDetergentOrderThreshold": { + "value": null + }, + "babyDetergentInitialAmount": { + "value": null + }, + "babyDetergentType": { + "value": null + }, + "neutralDetergentInitialAmount": { + "value": null + }, + "regularDetergentDosage": { + "value": 0, + "unit": "cc", + "timestamp": "2025-03-07T06:06:08.613Z" + }, + "babyDetergentDosage": { + "value": null + }, + "regularDetergentOrderThreshold": { + "value": 0, + "unit": "cc", + "timestamp": "2025-03-07T06:06:08.613Z" + }, + "regularDetergentType": { + "value": "none", + "timestamp": "2025-03-07T06:06:08.613Z" + }, + "regularDetergentInitialAmount": { + "value": 0, + "unit": "cc", + "timestamp": "2025-03-07T06:06:08.613Z" + }, + "regularDetergentAlarmEnabled": { + "value": false, + "timestamp": "2025-03-07T06:06:08.613Z" + }, + "neutralDetergentDosage": { + "value": null + }, + "babyDetergentOrderThreshold": { + "value": null + }, + "babyDetergentAlarmEnabled": { + "value": null + } + }, + "samsungce.softenerOrder": { + "alarmEnabled": { + "value": null, + "timestamp": "2020-12-28T11:12:47.109Z" + }, + "orderThreshold": { + "value": null, + "unit": "cc", + "timestamp": "2020-12-28T11:12:47.109Z" + } + }, + "custom.washerSoilLevel": { + "supportedWasherSoilLevel": { + "value": null, + "timestamp": "2020-08-11T22:49:08.023Z" + }, + "washerSoilLevel": { + "value": null, + "timestamp": "2020-08-11T22:49:08.023Z" + } + }, + "samsungce.washerBubbleSoak": { + "status": { + "value": "off", + "timestamp": "2025-03-07T06:06:08.613Z" + } + }, + "samsungce.washerCyclePreset": { + "maxNumberOfPresets": { + "value": 10, + "timestamp": "2025-03-07T06:06:08.957Z" + }, + "presets": { + "value": null, + "timestamp": "2021-03-31T08:11:41.657Z" + } + }, + "samsungce.detergentState": { + "remainingAmount": { + "value": 0, + "unit": "cc", + "timestamp": "2025-03-07T06:06:08.613Z" + }, + "dosage": { + "value": 0, + "unit": "cc", + "timestamp": "2025-03-07T06:06:08.613Z" + }, + "initialAmount": { + "value": 0, + "unit": "cc", + "timestamp": "2025-03-07T06:06:08.613Z" + }, + "detergentType": { + "value": "none", + "timestamp": "2021-03-31T22:35:33.949Z" + } + }, + "refresh": {}, + "custom.jobBeginningStatus": { + "jobBeginningStatus": { + "value": null, + "timestamp": "2020-08-11T22:48:26.262Z" + } + }, + "execute": { + "data": { + "value": { + "payload": { + "rt": ["x.com.samsung.da.information"], + "if": ["oic.if.baseline", "oic.if.a"], + "x.com.samsung.da.modelNum": "DA_WM_A51_20_COMMON|20224941|20010102011211030203000000000000", + "x.com.samsung.da.description": "DA_WM_A51_20_COMMON_\u0018WD7800N/DC92-02249A_08CC", + "x.com.samsung.da.serialNum": "0TE65ADMC00093F", + "x.com.samsung.da.otnDUID": "EXCEZFTFQ53G2", + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "DA_WM_A51_20_COMMON|20224941|20010102011211030203000000000000", + "x.com.samsung.da.type": "Software", + "x.com.samsung.da.number": "02198A220728(E256)", + "x.com.samsung.da.newVersionAvailable": "0" + }, + { + "x.com.samsung.da.id": "1", + "x.com.samsung.da.description": "DA_WM_A51_20_COMMON", + "x.com.samsung.da.type": "Firmware", + "x.com.samsung.da.number": "18072525,18090310", + "x.com.samsung.da.newVersionAvailable": "0" + } + ] + } + }, + "data": { + "href": "/information/vs/0" + }, + "timestamp": "2023-08-06T02:14:23.034Z" + } + }, + "samsungce.softenerState": { + "remainingAmount": { + "value": null, + "unit": "cc", + "timestamp": "2020-12-28T07:11:13.285Z" + }, + "dosage": { + "value": null, + "unit": "cc", + "timestamp": "2020-12-28T01:14:27.011Z" + }, + "softenerType": { + "value": null, + "timestamp": "2020-11-19T21:57:19.712Z" + }, + "initialAmount": { + "value": null, + "unit": "cc", + "timestamp": "2020-12-28T00:45:40.863Z" + } + }, + "samsungce.energyPlanner": { + "data": { + "value": null + }, + "plan": { + "value": null + } + }, + "sec.wifiConfiguration": { + "autoReconnection": { + "value": null + }, + "minVersion": { + "value": null + }, + "supportedWiFiFreq": { + "value": null + }, + "supportedAuthType": { + "value": null + }, + "protocolType": { + "value": null + } + }, + "remoteControlStatus": { + "remoteControlEnabled": { + "value": "true", + "timestamp": "2025-03-07T06:06:08.819Z" + } + }, + "custom.supportedOptions": { + "course": { + "value": null + }, + "referenceTable": { + "value": { + "id": "Table_00" + }, + "timestamp": "2025-03-07T06:06:08.905Z" + }, + "supportedCourses": { + "value": [ + "D0", + "DC", + "E3", + "E4", + "50", + "51", + "CA", + "E7", + "C7", + "D8", + "D4", + "D3", + "DA", + "D2" + ], + "timestamp": "2025-03-07T06:06:08.613Z" + } + }, + "samsungce.washerWashingTime": { + "supportedWashingTimes": { + "value": null, + "timestamp": "2021-03-31T08:10:28.542Z" + }, + "washingTime": { + "value": null, + "unit": "min", + "timestamp": "2021-03-31T08:10:28.542Z" + } + }, + "custom.energyType": { + "energyType": { + "value": "2.0", + "timestamp": "2022-06-14T22:23:10.096Z" + }, + "energySavingSupport": { + "value": false, + "timestamp": "2022-06-14T22:38:10.576Z" + }, + "drMaxDuration": { + "value": null + }, + "energySavingLevel": { + "value": null + }, + "energySavingInfo": { + "value": null + }, + "supportedEnergySavingLevels": { + "value": null + }, + "energySavingOperation": { + "value": null + }, + "notificationTemplateID": { + "value": null + }, + "energySavingOperationSupport": { + "value": null + } + }, + "samsungce.softwareUpdate": { + "targetModule": { + "value": null + }, + "otnDUID": { + "value": "EXCEZFTFQ53G2", + "timestamp": "2025-03-07T06:06:08.719Z" + }, + "lastUpdatedDate": { + "value": null + }, + "availableModules": { + "value": [], + "timestamp": "2024-12-01T10:37:29.975Z" + }, + "newVersionAvailable": { + "value": false, + "timestamp": "2024-12-01T10:37:29.975Z" + }, + "operatingState": { + "value": null + }, + "progress": { + "value": null + } + }, + "custom.washerAutoDetergent": { + "washerAutoDetergent": { + "value": null, + "timestamp": "2020-08-11T22:47:34.372Z" + } + }, + "custom.washerSpinLevel": { + "washerSpinLevel": { + "value": "1400", + "timestamp": "2025-03-07T06:06:08.901Z" + }, + "supportedWasherSpinLevel": { + "value": [ + "rinseHold", + "noSpin", + "400", + "800", + "1000", + "1200", + "1400" + ], + "timestamp": "2024-12-09T22:01:38.311Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/devices/da_wm_wm_000001_1.json b/tests/components/smartthings/fixtures/devices/da_wm_wm_000001_1.json new file mode 100644 index 00000000000..bb1831d6f03 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/da_wm_wm_000001_1.json @@ -0,0 +1,261 @@ +{ + "items": [ + { + "deviceId": "63803fae-cbed-f356-a063-2cf148ae3ca7", + "name": "[washer] Samsung", + "label": "Washing Machine", + "manufacturerName": "Samsung Electronics", + "presentationId": "DA-WM-WM-000001", + "deviceManufacturerCode": "Samsung Electronics", + "locationId": "ca23214d-d9ae-41e5-9d26-f1a604c864d8", + "ownerId": "9b53a4ba-4422-b04d-f436-33c0490e7c37", + "roomId": "e226f1ae-1112-4794-bd3a-0beddf811645", + "deviceTypeName": "Samsung OCF Washer", + "components": [ + { + "id": "main", + "label": "Washing Machine", + "capabilities": [ + { + "id": "execute", + "version": 1 + }, + { + "id": "ocf", + "version": 1 + }, + { + "id": "powerConsumptionReport", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "remoteControlStatus", + "version": 1 + }, + { + "id": "demandResponseLoadControl", + "version": 1 + }, + { + "id": "switch", + "version": 1 + }, + { + "id": "washerOperatingState", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + }, + { + "id": "custom.dryerDryLevel", + "version": 1 + }, + { + "id": "custom.energyType", + "version": 1 + }, + { + "id": "custom.jobBeginningStatus", + "version": 1 + }, + { + "id": "custom.supportedOptions", + "version": 1 + }, + { + "id": "custom.washerAutoDetergent", + "version": 1 + }, + { + "id": "custom.washerAutoSoftener", + "version": 1 + }, + { + "id": "custom.washerRinseCycles", + "version": 1 + }, + { + "id": "custom.washerSoilLevel", + "version": 1 + }, + { + "id": "custom.washerSpinLevel", + "version": 1 + }, + { + "id": "custom.washerWaterTemperature", + "version": 1 + }, + { + "id": "samsungce.autoDispenseDetergent", + "version": 1 + }, + { + "id": "samsungce.autoDispenseSoftener", + "version": 1 + }, + { + "id": "samsungce.detergentOrder", + "version": 1 + }, + { + "id": "samsungce.detergentState", + "version": 1 + }, + { + "id": "samsungce.deviceIdentification", + "version": 1 + }, + { + "id": "samsungce.dongleSoftwareInstallation", + "version": 1 + }, + { + "id": "samsungce.detergentAutoReplenishment", + "version": 1 + }, + { + "id": "samsungce.softenerAutoReplenishment", + "version": 1 + }, + { + "id": "samsungce.driverVersion", + "version": 1 + }, + { + "id": "samsungce.softwareUpdate", + "version": 1 + }, + { + "id": "samsungce.kidsLock", + "version": 1 + }, + { + "id": "samsungce.softenerOrder", + "version": 1 + }, + { + "id": "samsungce.softenerState", + "version": 1 + }, + { + "id": "samsungce.washerBubbleSoak", + "version": 1 + }, + { + "id": "samsungce.washerCycle", + "version": 1 + }, + { + "id": "samsungce.washerCyclePreset", + "version": 1 + }, + { + "id": "samsungce.washerDelayEnd", + "version": 1 + }, + { + "id": "samsungce.washerFreezePrevent", + "version": 1 + }, + { + "id": "samsungce.washerOperatingState", + "version": 1 + }, + { + "id": "samsungce.washerWashingTime", + "version": 1 + }, + { + "id": "samsungce.washerWaterLevel", + "version": 1 + }, + { + "id": "samsungce.washerWaterValve", + "version": 1 + }, + { + "id": "samsungce.welcomeMessage", + "version": 1 + }, + { + "id": "samsungce.waterConsumptionReport", + "version": 1 + }, + { + "id": "samsungce.quickControl", + "version": 1 + }, + { + "id": "samsungce.energyPlanner", + "version": 1 + }, + { + "id": "sec.diagnosticsInformation", + "version": 1 + }, + { + "id": "sec.wifiConfiguration", + "version": 1 + } + ], + "categories": [ + { + "name": "Washer", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "hca.main", + "label": "hca.main", + "capabilities": [ + { + "id": "hca.washerMode", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2020-03-04T03:03:19Z", + "profile": { + "id": "3f221c79-d81c-315f-8e8b-b5742802a1e3" + }, + "ocf": { + "ocfDeviceType": "oic.d.washer", + "name": "[washer] Samsung", + "specVersion": "core.1.1.0", + "verticalDomainSpecVersion": "1.2.1", + "manufacturerName": "Samsung Electronics", + "modelNumber": "DA_WM_A51_20_COMMON|20224941|20010102011211030203000000000000", + "platformVersion": "DAWIT 2.0", + "platformOS": "TizenRT 1.0 + IPv6", + "hwVersion": "ARTIK051", + "firmwareVersion": "DA_WM_A51_20_COMMON_30230708", + "vendorId": "DA-WM-WM-000001", + "vendorResourceClientServerVersion": "ARTIK051 Release 2.210224.1", + "lastSignupTime": "2024-12-27T04:47:59.763899737Z", + "transferCandidate": false, + "additionalAuthCodeRequired": false + }, + "type": "OCF", + "restrictionTier": 0, + "allowed": null, + "executionContext": "CLOUD", + "relationships": [] + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/snapshots/test_init.ambr b/tests/components/smartthings/snapshots/test_init.ambr index 2c09d0addaf..7ec2ce79c5b 100644 --- a/tests/components/smartthings/snapshots/test_init.ambr +++ b/tests/components/smartthings/snapshots/test_init.ambr @@ -494,6 +494,39 @@ 'via_device_id': None, }) # --- +# name: test_devices[da_wm_wm_000001_1] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': 'ARTIK051', + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '63803fae-cbed-f356-a063-2cf148ae3ca7', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Samsung Electronics', + 'model': 'DA_WM_A51_20_COMMON', + 'model_id': None, + 'name': 'Washing Machine', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': 'DA_WM_A51_20_COMMON_30230708', + 'via_device_id': None, + }) +# --- # name: test_devices[ecobee_sensor] DeviceRegistryEntrySnapshot({ 'area_id': None, diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index fa9af0f2812..72364d59277 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -3899,6 +3899,475 @@ 'state': '0.0', }) # --- +# name: test_all_entities[da_wm_wm_000001_1][sensor.washing_machine_completion_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.washing_machine_completion_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Completion time', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'completion_time', + 'unique_id': '63803fae-cbed-f356-a063-2cf148ae3ca7.completionTime', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wm_000001_1][sensor.washing_machine_completion_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Washing Machine Completion time', + }), + 'context': , + 'entity_id': 'sensor.washing_machine_completion_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2025-03-07T07:01:12+00:00', + }) +# --- +# name: test_all_entities[da_wm_wm_000001_1][sensor.washing_machine_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.washing_machine_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '63803fae-cbed-f356-a063-2cf148ae3ca7.energy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_wm_wm_000001_1][sensor.washing_machine_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Washing Machine Energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.washing_machine_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1323.6', + }) +# --- +# name: test_all_entities[da_wm_wm_000001_1][sensor.washing_machine_energy_difference-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.washing_machine_energy_difference', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy difference', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_difference', + 'unique_id': '63803fae-cbed-f356-a063-2cf148ae3ca7.deltaEnergy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_wm_wm_000001_1][sensor.washing_machine_energy_difference-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Washing Machine Energy difference', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.washing_machine_energy_difference', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.1', + }) +# --- +# name: test_all_entities[da_wm_wm_000001_1][sensor.washing_machine_energy_saved-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.washing_machine_energy_saved', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy saved', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_saved', + 'unique_id': '63803fae-cbed-f356-a063-2cf148ae3ca7.energySaved_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_wm_wm_000001_1][sensor.washing_machine_energy_saved-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Washing Machine Energy saved', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.washing_machine_energy_saved', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_wm_wm_000001_1][sensor.washing_machine_job_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'air_wash', + 'ai_rinse', + 'ai_spin', + 'ai_wash', + 'cooling', + 'delay_wash', + 'drying', + 'finish', + 'none', + 'pre_wash', + 'rinse', + 'spin', + 'wash', + 'weight_sensing', + 'wrinkle_prevent', + 'freeze_protection', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.washing_machine_job_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Job state', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'washer_job_state', + 'unique_id': '63803fae-cbed-f356-a063-2cf148ae3ca7.washerJobState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wm_000001_1][sensor.washing_machine_job_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Washing Machine Job state', + 'options': list([ + 'air_wash', + 'ai_rinse', + 'ai_spin', + 'ai_wash', + 'cooling', + 'delay_wash', + 'drying', + 'finish', + 'none', + 'pre_wash', + 'rinse', + 'spin', + 'wash', + 'weight_sensing', + 'wrinkle_prevent', + 'freeze_protection', + ]), + }), + 'context': , + 'entity_id': 'sensor.washing_machine_job_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'wash', + }) +# --- +# name: test_all_entities[da_wm_wm_000001_1][sensor.washing_machine_machine_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'pause', + 'run', + 'stop', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.washing_machine_machine_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Machine state', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'washer_machine_state', + 'unique_id': '63803fae-cbed-f356-a063-2cf148ae3ca7.machineState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wm_000001_1][sensor.washing_machine_machine_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Washing Machine Machine state', + 'options': list([ + 'pause', + 'run', + 'stop', + ]), + }), + 'context': , + 'entity_id': 'sensor.washing_machine_machine_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'run', + }) +# --- +# name: test_all_entities[da_wm_wm_000001_1][sensor.washing_machine_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.washing_machine_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '63803fae-cbed-f356-a063-2cf148ae3ca7.power_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_wm_wm_000001_1][sensor.washing_machine_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Washing Machine Power', + 'power_consumption_end': '2025-03-07T06:23:21Z', + 'power_consumption_start': '2025-03-07T06:21:09Z', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.washing_machine_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_all_entities[da_wm_wm_000001_1][sensor.washing_machine_power_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.washing_machine_power_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power energy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_energy', + 'unique_id': '63803fae-cbed-f356-a063-2cf148ae3ca7.powerEnergy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_wm_wm_000001_1][sensor.washing_machine_power_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Washing Machine Power energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.washing_machine_power_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- # name: test_all_entities[ecobee_sensor][sensor.child_bedroom_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/smartthings/snapshots/test_switch.ambr b/tests/components/smartthings/snapshots/test_switch.ambr index d12bd4ea5b6..00177b3b603 100644 --- a/tests/components/smartthings/snapshots/test_switch.ambr +++ b/tests/components/smartthings/snapshots/test_switch.ambr @@ -281,6 +281,53 @@ 'state': 'off', }) # --- +# name: test_all_entities[da_wm_wm_000001_1][switch.washing_machine-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.washing_machine', + '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': None, + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '63803fae-cbed-f356-a063-2cf148ae3ca7', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wm_000001_1][switch.washing_machine-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Washing Machine', + }), + 'context': , + 'entity_id': 'switch.washing_machine', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_all_entities[sensibo_airconditioner_1][switch.office-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 9a90e1e410f83733d0f9ea79d61ff314741ca16a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 7 Mar 2025 02:01:31 -1000 Subject: [PATCH 2306/3148] Bump ulid-transform to 1.4.0 (#140037) changelog: https://github.com/Bluetooth-Devices/ulid-transform/compare/v1.3.0...v1.4.0 --- 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 988c2934cd8..0727beae8ed 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -65,7 +65,7 @@ SQLAlchemy==2.0.38 standard-aifc==3.13.0 standard-telnetlib==3.13.0 typing-extensions>=4.12.2,<5.0 -ulid-transform==1.3.0 +ulid-transform==1.4.0 urllib3>=1.26.5,<2 uv==0.6.1 voluptuous-openapi==0.0.6 diff --git a/pyproject.toml b/pyproject.toml index b11c2403d69..3affa95a082 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -71,7 +71,7 @@ dependencies = [ "standard-aifc==3.13.0", "standard-telnetlib==3.13.0", "typing-extensions>=4.12.2,<5.0", - "ulid-transform==1.3.0", + "ulid-transform==1.4.0", # Constrain urllib3 to ensure we deal with CVE-2020-26137 and CVE-2021-33503 # Temporary setting an upper bound, to prevent compat issues with urllib3>=2 # https://github.com/home-assistant/core/issues/97248 diff --git a/requirements.txt b/requirements.txt index 6d138a6060d..9bf94749ac9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -43,7 +43,7 @@ SQLAlchemy==2.0.38 standard-aifc==3.13.0 standard-telnetlib==3.13.0 typing-extensions>=4.12.2,<5.0 -ulid-transform==1.3.0 +ulid-transform==1.4.0 urllib3>=1.26.5,<2 uv==0.6.1 voluptuous==0.15.2 From c834944ee7f1b3c566c1d82a78111f8ced512c71 Mon Sep 17 00:00:00 2001 From: David Bonnes Date: Fri, 7 Mar 2025 12:04:04 +0000 Subject: [PATCH 2307/3148] Fix evohome to gracefully handle null schedules (#140036) * extend tests to catch null schedules * add fixture with null schedule * remove null schedules for now * fic the typing for _schedule attr (is list, not dict) * add valid schedule to fixture * update ssetpoints only if there is a schedule * snapshot to match last change * refactor: dont update switchpoints if no schedule * add in warnings for null schedules * add fixture for DHW without schedule --- .../components/evohome/coordinator.py | 12 +- homeassistant/components/evohome/entity.py | 10 +- tests/components/evohome/conftest.py | 12 +- tests/components/evohome/const.py | 3 +- .../fixtures/botched/schedule_3933910.json | 3 + .../fixtures/h139906/schedule_3454854.json | 3 + .../fixtures/h139906/schedule_3454855.json | 143 +++++++++++++ .../fixtures/h139906/status_2727366.json | 52 +++++ .../fixtures/h139906/user_locations.json | 125 ++++++++++++ .../evohome/snapshots/test_climate.ambr | 188 ++++++++++++++++++ .../evohome/snapshots/test_init.ambr | 3 + .../evohome/snapshots/test_water_heater.ambr | 10 + tests/components/evohome/test_water_heater.py | 2 +- 13 files changed, 553 insertions(+), 13 deletions(-) create mode 100644 tests/components/evohome/fixtures/botched/schedule_3933910.json create mode 100644 tests/components/evohome/fixtures/h139906/schedule_3454854.json create mode 100644 tests/components/evohome/fixtures/h139906/schedule_3454855.json create mode 100644 tests/components/evohome/fixtures/h139906/status_2727366.json create mode 100644 tests/components/evohome/fixtures/h139906/user_locations.json diff --git a/homeassistant/components/evohome/coordinator.py b/homeassistant/components/evohome/coordinator.py index 3264af6b2fd..33af90089a4 100644 --- a/homeassistant/components/evohome/coordinator.py +++ b/homeassistant/components/evohome/coordinator.py @@ -207,10 +207,18 @@ class EvoDataUpdateCoordinator(DataUpdateCoordinator): async def _update_v2_schedules(self) -> None: for zone in self.tcs.zones: - await zone.get_schedule() + try: + await zone.get_schedule() + except ec2.InvalidScheduleError as err: + self.logger.warning( + "Zone '%s' has an invalid/missing schedule: %r", zone.name, err + ) if dhw := self.tcs.hotwater: - await dhw.get_schedule() + try: + await dhw.get_schedule() + except ec2.InvalidScheduleError as err: + self.logger.warning("DHW has an invalid/missing schedule: %r", err) async def _async_update_data(self) -> EvoLocStatusResponseT: # type: ignore[override] """Fetch the latest state of an entire TCC Location. diff --git a/homeassistant/components/evohome/entity.py b/homeassistant/components/evohome/entity.py index 11215dd47b6..2f93f0fb143 100644 --- a/homeassistant/components/evohome/entity.py +++ b/homeassistant/components/evohome/entity.py @@ -6,6 +6,7 @@ import logging from typing import Any import evohomeasync2 as evo +from evohomeasync2.schemas.typedefs import DayOfWeekDhwT from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -102,7 +103,7 @@ class EvoChild(EvoEntity): self._evo_tcs = evo_device.tcs - self._schedule: dict[str, Any] | None = None + self._schedule: list[DayOfWeekDhwT] | None = None self._setpoints: dict[str, Any] = {} @property @@ -123,6 +124,9 @@ class EvoChild(EvoEntity): Only Zones & DHW controllers (but not the TCS) can have schedules. """ + if not self._schedule: + return self._setpoints + this_sp_dtm, this_sp_val = self._evo_device.this_switchpoint next_sp_dtm, next_sp_val = self._evo_device.next_switchpoint @@ -152,10 +156,10 @@ class EvoChild(EvoEntity): self._evo_device, err, ) - self._schedule = {} + self._schedule = [] return else: - self._schedule = schedule or {} # mypy hint + self._schedule = schedule # type: ignore[assignment] _LOGGER.debug("Schedule['%s'] = %s", self.name, schedule) diff --git a/tests/components/evohome/conftest.py b/tests/components/evohome/conftest.py index 5f60bc418e3..313982e3f97 100644 --- a/tests/components/evohome/conftest.py +++ b/tests/components/evohome/conftest.py @@ -48,18 +48,18 @@ def location_status_fixture(install: str, loc_id: str | None = None) -> JsonObje return load_json_object_fixture(f"{install}/status_{loc_id}.json", DOMAIN) -def dhw_schedule_fixture(install: str) -> JsonObjectType: +def dhw_schedule_fixture(install: str, dhw_id: str | None = None) -> JsonObjectType: """Load JSON for the schedule of a domesticHotWater zone.""" try: - return load_json_object_fixture(f"{install}/schedule_dhw.json", DOMAIN) + return load_json_object_fixture(f"{install}/schedule_{dhw_id}.json", DOMAIN) except FileNotFoundError: return load_json_object_fixture("default/schedule_dhw.json", DOMAIN) -def zone_schedule_fixture(install: str) -> JsonObjectType: +def zone_schedule_fixture(install: str, zon_id: str | None = None) -> JsonObjectType: """Load JSON for the schedule of a temperatureZone zone.""" try: - return load_json_object_fixture(f"{install}/schedule_zone.json", DOMAIN) + return load_json_object_fixture(f"{install}/schedule_{zon_id}.json", DOMAIN) except FileNotFoundError: return load_json_object_fixture("default/schedule_zone.json", DOMAIN) @@ -120,9 +120,9 @@ def mock_make_request(install: str) -> Callable: elif "schedule" in url: if url.startswith("domesticHotWater"): # /v2/domesticHotWater/{id}/schedule - return dhw_schedule_fixture(install) + return dhw_schedule_fixture(install, url[16:23]) if url.startswith("temperatureZone"): # /v2/temperatureZone/{id}/schedule - return zone_schedule_fixture(install) + return zone_schedule_fixture(install, url[16:23]) pytest.fail(f"Unexpected request: {HTTPMethod.GET} {url}") diff --git a/tests/components/evohome/const.py b/tests/components/evohome/const.py index c3dc92c3fbc..dceb2f60a06 100644 --- a/tests/components/evohome/const.py +++ b/tests/components/evohome/const.py @@ -15,8 +15,9 @@ TEST_INSTALLS: Final = ( "default", # evohome: multi-zone, with DHW "h032585", # VisionProWifi: no preset modes for TCS, zoneId=systemId "h099625", # RoundThermostat + "h139906", # zone with null schedule "sys_004", # RoundModulation ) # "botched", # as default: but with activeFaults, ghost zones & unknown types -TEST_INSTALLS_WITH_DHW: Final = ("default",) +TEST_INSTALLS_WITH_DHW: Final = ("default", "botched") diff --git a/tests/components/evohome/fixtures/botched/schedule_3933910.json b/tests/components/evohome/fixtures/botched/schedule_3933910.json new file mode 100644 index 00000000000..0e5a9308d5b --- /dev/null +++ b/tests/components/evohome/fixtures/botched/schedule_3933910.json @@ -0,0 +1,3 @@ +{ + "dailySchedules": [] +} diff --git a/tests/components/evohome/fixtures/h139906/schedule_3454854.json b/tests/components/evohome/fixtures/h139906/schedule_3454854.json new file mode 100644 index 00000000000..0e5a9308d5b --- /dev/null +++ b/tests/components/evohome/fixtures/h139906/schedule_3454854.json @@ -0,0 +1,3 @@ +{ + "dailySchedules": [] +} diff --git a/tests/components/evohome/fixtures/h139906/schedule_3454855.json b/tests/components/evohome/fixtures/h139906/schedule_3454855.json new file mode 100644 index 00000000000..12f8a6cb390 --- /dev/null +++ b/tests/components/evohome/fixtures/h139906/schedule_3454855.json @@ -0,0 +1,143 @@ +{ + "dailySchedules": [ + { + "dayOfWeek": "Monday", + "switchpoints": [ + { + "heatSetpoint": 22.0, + "timeOfDay": "05:30:00" + }, + { + "heatSetpoint": 20.0, + "timeOfDay": "08:00:00" + }, + { + "heatSetpoint": 22.5, + "timeOfDay": "16:00:00" + }, + { + "heatSetpoint": 15.0, + "timeOfDay": "23:00:00" + } + ] + }, + { + "dayOfWeek": "Tuesday", + "switchpoints": [ + { + "heatSetpoint": 22.0, + "timeOfDay": "05:30:00" + }, + { + "heatSetpoint": 20.0, + "timeOfDay": "08:00:00" + }, + { + "heatSetpoint": 22.5, + "timeOfDay": "16:00:00" + }, + { + "heatSetpoint": 15.0, + "timeOfDay": "23:00:00" + } + ] + }, + { + "dayOfWeek": "Wednesday", + "switchpoints": [ + { + "heatSetpoint": 22.0, + "timeOfDay": "05:30:00" + }, + { + "heatSetpoint": 20.0, + "timeOfDay": "08:00:00" + }, + { + "heatSetpoint": 22.5, + "timeOfDay": "12:00:00" + }, + { + "heatSetpoint": 15.0, + "timeOfDay": "23:00:00" + } + ] + }, + { + "dayOfWeek": "Thursday", + "switchpoints": [ + { + "heatSetpoint": 22.0, + "timeOfDay": "05:30:00" + }, + { + "heatSetpoint": 20.0, + "timeOfDay": "08:00:00" + }, + { + "heatSetpoint": 22.5, + "timeOfDay": "16:00:00" + }, + { + "heatSetpoint": 15.0, + "timeOfDay": "23:00:00" + } + ] + }, + { + "dayOfWeek": "Friday", + "switchpoints": [ + { + "heatSetpoint": 22.0, + "timeOfDay": "05:30:00" + }, + { + "heatSetpoint": 20.0, + "timeOfDay": "08:00:00" + }, + { + "heatSetpoint": 22.5, + "timeOfDay": "16:00:00" + }, + { + "heatSetpoint": 15.0, + "timeOfDay": "23:00:00" + } + ] + }, + { + "dayOfWeek": "Saturday", + "switchpoints": [ + { + "heatSetpoint": 22.0, + "timeOfDay": "07:00:00" + }, + { + "heatSetpoint": 22.5, + "timeOfDay": "16:00:00" + }, + { + "heatSetpoint": 15.0, + "timeOfDay": "23:00:00" + } + ] + }, + { + "dayOfWeek": "Sunday", + "switchpoints": [ + { + "heatSetpoint": 22.0, + "timeOfDay": "07:30:00" + }, + { + "heatSetpoint": 22.5, + "timeOfDay": "16:00:00" + }, + { + "heatSetpoint": 15.0, + "timeOfDay": "23:00:00" + } + ] + } + ] +} diff --git a/tests/components/evohome/fixtures/h139906/status_2727366.json b/tests/components/evohome/fixtures/h139906/status_2727366.json new file mode 100644 index 00000000000..2c123b796bd --- /dev/null +++ b/tests/components/evohome/fixtures/h139906/status_2727366.json @@ -0,0 +1,52 @@ +{ + "locationId": "2727366", + "gateways": [ + { + "gatewayId": "2513794", + "temperatureControlSystems": [ + { + "systemId": "3454856", + "zones": [ + { + "zoneId": "3454854", + "temperatureStatus": { + "temperature": 22.0, + "isAvailable": true + }, + "activeFaults": [ + { + "faultType": "TempZoneSensorCommunicationLost", + "since": "2025-02-06T11:20:29" + } + ], + "setpointStatus": { + "targetHeatTemperature": 5.0, + "setpointMode": "FollowSchedule" + }, + "name": "Thermostat" + }, + { + "zoneId": "3454855", + "temperatureStatus": { + "temperature": 22.0, + "isAvailable": true + }, + "activeFaults": [], + "setpointStatus": { + "targetHeatTemperature": 20.0, + "setpointMode": "FollowSchedule" + }, + "name": "Thermostat 2" + } + ], + "activeFaults": [], + "systemModeStatus": { + "mode": "Auto", + "isPermanent": true + } + } + ], + "activeFaults": [] + } + ] +} diff --git a/tests/components/evohome/fixtures/h139906/user_locations.json b/tests/components/evohome/fixtures/h139906/user_locations.json new file mode 100644 index 00000000000..14db65a5e0d --- /dev/null +++ b/tests/components/evohome/fixtures/h139906/user_locations.json @@ -0,0 +1,125 @@ +[ + { + "locationInfo": { + "locationId": "2727366", + "name": "Vr**********", + "streetAddress": "********** *", + "city": "*********", + "country": "Netherlands", + "postcode": "******", + "locationType": "Residential", + "useDaylightSaveSwitching": true, + "timeZone": { + "timeZoneId": "WEuropeStandardTime", + "displayName": "(UTC+01:00) Amsterdam, Berlijn, Bern, Rome, Stockholm, Wenen", + "offsetMinutes": 60, + "currentOffsetMinutes": 60, + "supportsDaylightSaving": true + }, + "locationOwner": { + "userId": "2276512", + "username": "nobody@nowhere.com", + "firstname": "Gl***", + "lastname": "de*****" + } + }, + "gateways": [ + { + "gatewayInfo": { + "gatewayId": "2513794", + "mac": "************", + "crc": "****", + "isWiFi": false + }, + "temperatureControlSystems": [ + { + "systemId": "3454856", + "modelType": "EvoTouch", + "zones": [ + { + "zoneId": "3454854", + "modelType": "HeatingZone", + "setpointCapabilities": { + "maxHeatSetpoint": 35.0, + "minHeatSetpoint": 5.0, + "valueResolution": 0.5, + "canControlHeat": true, + "canControlCool": false, + "allowedSetpointModes": [ + "PermanentOverride", + "FollowSchedule", + "TemporaryOverride" + ], + "maxDuration": "1.00:00:00", + "timingResolution": "00:10:00" + }, + "scheduleCapabilities": { + "maxSwitchpointsPerDay": 6, + "minSwitchpointsPerDay": 1, + "timingResolution": "00:10:00", + "setpointValueResolution": 0.5 + }, + "name": "Thermostat", + "zoneType": "ZoneTemperatureControl" + }, + { + "zoneId": "3454855", + "modelType": "RoundWireless", + "setpointCapabilities": { + "maxHeatSetpoint": 35.0, + "minHeatSetpoint": 5.0, + "valueResolution": 0.5, + "canControlHeat": true, + "canControlCool": false, + "allowedSetpointModes": [ + "PermanentOverride", + "FollowSchedule", + "TemporaryOverride" + ], + "maxDuration": "1.00:00:00", + "timingResolution": "00:10:00" + }, + "scheduleCapabilities": { + "maxSwitchpointsPerDay": 6, + "minSwitchpointsPerDay": 0, + "timingResolution": "00:10:00", + "setpointValueResolution": 0.5 + }, + "name": "Thermostat 2", + "zoneType": "Thermostat" + } + ], + "allowedSystemModes": [ + { + "systemMode": "Auto", + "canBePermanent": true, + "canBeTemporary": false + }, + { + "systemMode": "AutoWithEco", + "canBePermanent": true, + "canBeTemporary": true, + "maxDuration": "1.00:00:00", + "timingResolution": "01:00:00", + "timingMode": "Duration" + }, + { + "systemMode": "Away", + "canBePermanent": true, + "canBeTemporary": true, + "maxDuration": "99.00:00:00", + "timingResolution": "1.00:00:00", + "timingMode": "Period" + }, + { + "systemMode": "HeatingOff", + "canBePermanent": true, + "canBeTemporary": false + } + ] + } + ] + } + ] + } +] diff --git a/tests/components/evohome/snapshots/test_climate.ambr b/tests/components/evohome/snapshots/test_climate.ambr index 23a15e3f64f..5a6a6bff863 100644 --- a/tests/components/evohome/snapshots/test_climate.ambr +++ b/tests/components/evohome/snapshots/test_climate.ambr @@ -29,6 +29,16 @@ ), ]) # --- +# name: test_ctl_set_hvac_mode[h139906] + list([ + tuple( + , + ), + tuple( + , + ), + ]) +# --- # name: test_ctl_set_hvac_mode[minimal] list([ tuple( @@ -70,6 +80,13 @@ ), ]) # --- +# name: test_ctl_turn_off[h139906] + list([ + tuple( + , + ), + ]) +# --- # name: test_ctl_turn_off[minimal] list([ tuple( @@ -105,6 +122,13 @@ ), ]) # --- +# name: test_ctl_turn_on[h139906] + list([ + tuple( + , + ), + ]) +# --- # name: test_ctl_turn_on[minimal] list([ tuple( @@ -1118,6 +1142,136 @@ 'state': 'heat', }) # --- +# name: test_setup_platform[h139906][climate.thermostat-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 22.0, + 'friendly_name': 'Thermostat', + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 35.0, + 'min_temp': 5.0, + 'preset_mode': 'none', + 'preset_modes': list([ + 'none', + 'temporary', + 'permanent', + ]), + 'status': dict({ + 'activeFaults': tuple( + dict({ + 'fault_type': 'TempZoneSensorCommunicationLost', + 'since': '2025-02-06T11:20:29+01:00', + }), + ), + 'setpoint_status': dict({ + 'setpoint_mode': 'FollowSchedule', + 'target_heat_temperature': 5.0, + }), + 'setpoints': dict({ + }), + 'temperature_status': dict({ + 'is_available': True, + 'temperature': 22.0, + }), + 'zone_id': '3454854', + }), + 'supported_features': , + 'temperature': 5.0, + }), + 'context': , + 'entity_id': 'climate.thermostat', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_setup_platform[h139906][climate.thermostat_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 22.0, + 'friendly_name': 'Thermostat 2', + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 35.0, + 'min_temp': 5.0, + 'preset_mode': 'none', + 'preset_modes': list([ + 'none', + 'temporary', + 'permanent', + ]), + 'status': dict({ + 'activeFaults': tuple( + ), + 'setpoint_status': dict({ + 'setpoint_mode': 'FollowSchedule', + 'target_heat_temperature': 20.0, + }), + 'setpoints': dict({ + 'next_sp_from': HAFakeDatetime(2024, 7, 10, 23, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/Berlin')), + 'next_sp_temp': 15.0, + 'this_sp_from': HAFakeDatetime(2024, 7, 10, 12, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/Berlin')), + 'this_sp_temp': 22.5, + }), + 'temperature_status': dict({ + 'is_available': True, + 'temperature': 22.0, + }), + 'zone_id': '3454855', + }), + 'supported_features': , + 'temperature': 20.0, + }), + 'context': , + 'entity_id': 'climate.thermostat_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }) +# --- +# name: test_setup_platform[h139906][climate.vr-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 22.0, + 'friendly_name': 'Vr**********', + 'hvac_modes': list([ + , + , + ]), + 'icon': 'mdi:thermostat', + 'max_temp': 35, + 'min_temp': 7, + 'preset_mode': None, + 'preset_modes': list([ + 'eco', + 'away', + ]), + 'status': dict({ + 'activeSystemFaults': tuple( + ), + 'system_id': '3454856', + 'system_mode_status': dict({ + 'is_permanent': True, + 'mode': 'Auto', + }), + }), + 'supported_features': , + }), + 'context': , + 'entity_id': 'climate.vr', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }) +# --- # name: test_setup_platform[minimal][climate.main_room-state] StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -1312,6 +1466,13 @@ ), ]) # --- +# name: test_zone_set_hvac_mode[h139906] + list([ + tuple( + 5.0, + ), + ]) +# --- # name: test_zone_set_hvac_mode[minimal] list([ tuple( @@ -1365,6 +1526,19 @@ }), ]) # --- +# name: test_zone_set_preset_mode[h139906] + list([ + tuple( + 5.0, + ), + tuple( + 5.0, + ), + dict({ + 'until': None, + }), + ]) +# --- # name: test_zone_set_preset_mode[minimal] list([ tuple( @@ -1412,6 +1586,13 @@ }), ]) # --- +# name: test_zone_set_temperature[h139906] + list([ + dict({ + 'until': None, + }), + ]) +# --- # name: test_zone_set_temperature[minimal] list([ dict({ @@ -1447,6 +1628,13 @@ ), ]) # --- +# name: test_zone_turn_off[h139906] + list([ + tuple( + 5.0, + ), + ]) +# --- # name: test_zone_turn_off[minimal] list([ tuple( diff --git a/tests/components/evohome/snapshots/test_init.ambr b/tests/components/evohome/snapshots/test_init.ambr index d2e91e3c43d..d6174a53356 100644 --- a/tests/components/evohome/snapshots/test_init.ambr +++ b/tests/components/evohome/snapshots/test_init.ambr @@ -11,6 +11,9 @@ # name: test_setup[h099625] dict_keys(['refresh_system', 'set_system_mode', 'clear_zone_override', 'set_zone_override']) # --- +# name: test_setup[h139906] + dict_keys(['refresh_system', 'set_system_mode', 'clear_zone_override', 'set_zone_override']) +# --- # name: test_setup[minimal] dict_keys(['refresh_system', 'reset_system', 'set_system_mode', 'clear_zone_override', 'set_zone_override']) # --- diff --git a/tests/components/evohome/snapshots/test_water_heater.ambr b/tests/components/evohome/snapshots/test_water_heater.ambr index 771e2c20cba..7b1bc44550a 100644 --- a/tests/components/evohome/snapshots/test_water_heater.ambr +++ b/tests/components/evohome/snapshots/test_water_heater.ambr @@ -1,4 +1,14 @@ # serializer version: 1 +# name: test_set_operation_mode[botched] + list([ + dict({ + 'until': HAFakeDatetime(2024, 7, 10, 12, 0, tzinfo=datetime.timezone.utc), + }), + dict({ + 'until': HAFakeDatetime(2024, 7, 10, 12, 0, tzinfo=datetime.timezone.utc), + }), + ]) +# --- # name: test_set_operation_mode[default] list([ dict({ diff --git a/tests/components/evohome/test_water_heater.py b/tests/components/evohome/test_water_heater.py index a201ff63d1e..ca9a5ba6af8 100644 --- a/tests/components/evohome/test_water_heater.py +++ b/tests/components/evohome/test_water_heater.py @@ -33,7 +33,7 @@ from .const import TEST_INSTALLS_WITH_DHW DHW_ENTITY_ID = "water_heater.domestic_hot_water" -@pytest.mark.parametrize("install", [*TEST_INSTALLS_WITH_DHW, "botched"]) +@pytest.mark.parametrize("install", TEST_INSTALLS_WITH_DHW) async def test_setup_platform( hass: HomeAssistant, config: dict[str, str], From 2401d8900aaab1611ce6bb3af9b3ea43c8c81468 Mon Sep 17 00:00:00 2001 From: hahn-th <15319212+hahn-th@users.noreply.github.com> Date: Fri, 7 Mar 2025 13:11:45 +0100 Subject: [PATCH 2308/3148] Add description for HomematicIP HCU1 in homematicip_cloud setup config flow (#140025) add description for hcu1 --- homeassistant/components/homematicip_cloud/strings.json | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/homematicip_cloud/strings.json b/homeassistant/components/homematicip_cloud/strings.json index 37deace7ebf..228ebc7500e 100644 --- a/homeassistant/components/homematicip_cloud/strings.json +++ b/homeassistant/components/homematicip_cloud/strings.json @@ -3,6 +3,7 @@ "step": { "init": { "title": "Pick Homematic IP access point", + "description": "If you are about to register a **Homematic IP HCU1**, please press the button on top of the device before you continue.\n\nThe registration process must be completed within 5 minutes.", "data": { "hapid": "Access point ID (SGTIN)", "pin": "[%key:common::config_flow::data::pin%]", From 82d5304b457d5c32d89ad0648b7c394c87bfc641 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Fri, 7 Mar 2025 12:13:35 +0000 Subject: [PATCH 2309/3148] Update whirlpool-sixth-sense to 0.19.1 (#139987) --- .../components/whirlpool/__init__.py | 16 +--- homeassistant/components/whirlpool/climate.py | 45 +++------- .../components/whirlpool/diagnostics.py | 23 +++-- .../components/whirlpool/manifest.json | 2 +- homeassistant/components/whirlpool/sensor.py | 69 +++++---------- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/whirlpool/__init__.py | 1 - tests/components/whirlpool/conftest.py | 58 ++++++------- tests/components/whirlpool/const.py | 6 ++ .../whirlpool/snapshots/test_diagnostics.ambr | 34 +++++--- tests/components/whirlpool/test_climate.py | 12 ++- .../components/whirlpool/test_diagnostics.py | 5 -- tests/components/whirlpool/test_init.py | 13 +-- tests/components/whirlpool/test_sensor.py | 84 +++++++------------ 15 files changed, 137 insertions(+), 235 deletions(-) create mode 100644 tests/components/whirlpool/const.py diff --git a/homeassistant/components/whirlpool/__init__.py b/homeassistant/components/whirlpool/__init__.py index 6231324bb0d..cb073779379 100644 --- a/homeassistant/components/whirlpool/__init__.py +++ b/homeassistant/components/whirlpool/__init__.py @@ -1,6 +1,5 @@ """The Whirlpool Appliances integration.""" -from dataclasses import dataclass import logging from aiohttp import ClientError @@ -20,7 +19,7 @@ _LOGGER = logging.getLogger(__name__) PLATFORMS = [Platform.CLIMATE, Platform.SENSOR] -type WhirlpoolConfigEntry = ConfigEntry[WhirlpoolData] +type WhirlpoolConfigEntry = ConfigEntry[AppliancesManager] async def async_setup_entry(hass: HomeAssistant, entry: WhirlpoolConfigEntry) -> bool: @@ -52,8 +51,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: WhirlpoolConfigEntry) -> if not await appliances_manager.fetch_appliances(): _LOGGER.error("Cannot fetch appliances") return False + await appliances_manager.connect() - entry.runtime_data = WhirlpoolData(appliances_manager, auth, backend_selector) + entry.runtime_data = appliances_manager await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True @@ -61,13 +61,5 @@ async def async_setup_entry(hass: HomeAssistant, entry: WhirlpoolConfigEntry) -> async def async_unload_entry(hass: HomeAssistant, entry: WhirlpoolConfigEntry) -> bool: """Unload a config entry.""" + await entry.runtime_data.disconnect() return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - - -@dataclass -class WhirlpoolData: - """Whirlpool integaration shared data.""" - - appliances_manager: AppliancesManager - auth: Auth - backend_selector: BackendSelector diff --git a/homeassistant/components/whirlpool/climate.py b/homeassistant/components/whirlpool/climate.py index 6baf738e54e..84a2c0d52ca 100644 --- a/homeassistant/components/whirlpool/climate.py +++ b/homeassistant/components/whirlpool/climate.py @@ -5,10 +5,7 @@ from __future__ import annotations import logging from typing import Any -from aiohttp import ClientSession from whirlpool.aircon import Aircon, FanSpeed as AirconFanSpeed, Mode as AirconMode -from whirlpool.auth import Auth -from whirlpool.backendselector import BackendSelector from homeassistant.components.climate import ( ENTITY_ID_FORMAT, @@ -25,7 +22,6 @@ from homeassistant.components.climate import ( ) from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant -from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import generate_entity_id from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -73,19 +69,8 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up entry.""" - whirlpool_data = config_entry.runtime_data - - aircons = [ - AirConEntity( - hass, - ac_data["SAID"], - ac_data["NAME"], - whirlpool_data.backend_selector, - whirlpool_data.auth, - async_get_clientsession(hass), - ) - for ac_data in whirlpool_data.appliances_manager.aircons - ] + appliances_manager = config_entry.runtime_data + aircons = [AirConEntity(hass, aircon) for aircon in appliances_manager.aircons] async_add_entities(aircons, True) @@ -110,36 +95,26 @@ class AirConEntity(ClimateEntity): _attr_target_temperature_step = SUPPORTED_TARGET_TEMPERATURE_STEP _attr_temperature_unit = UnitOfTemperature.CELSIUS - def __init__( - self, - hass: HomeAssistant, - said: str, - name: str | None, - backend_selector: BackendSelector, - auth: Auth, - session: ClientSession, - ) -> None: + def __init__(self, hass: HomeAssistant, aircon: Aircon) -> None: """Initialize the entity.""" - self._aircon = Aircon(backend_selector, auth, said, session) - self.entity_id = generate_entity_id(ENTITY_ID_FORMAT, said, hass=hass) - self._attr_unique_id = said + self._aircon = aircon + self.entity_id = generate_entity_id(ENTITY_ID_FORMAT, aircon.said, hass=hass) + self._attr_unique_id = aircon.said self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, said)}, - name=name if name is not None else said, + identifiers={(DOMAIN, aircon.said)}, + name=aircon.name if aircon.name is not None else aircon.said, manufacturer="Whirlpool", model="Sixth Sense", ) async def async_added_to_hass(self) -> None: - """Connect aircon to the cloud.""" + """Register updates callback.""" self._aircon.register_attr_callback(self.async_write_ha_state) - await self._aircon.connect() async def async_will_remove_from_hass(self) -> None: - """Close Whrilpool Appliance sockets before removing.""" + """Unregister updates callback.""" self._aircon.unregister_attr_callback(self.async_write_ha_state) - await self._aircon.disconnect() @property def available(self) -> bool: diff --git a/homeassistant/components/whirlpool/diagnostics.py b/homeassistant/components/whirlpool/diagnostics.py index 87d6ea827e2..09338396de4 100644 --- a/homeassistant/components/whirlpool/diagnostics.py +++ b/homeassistant/components/whirlpool/diagnostics.py @@ -4,6 +4,8 @@ from __future__ import annotations from typing import Any +from whirlpool.appliance import Appliance + from homeassistant.components.diagnostics import async_redact_data from homeassistant.core import HomeAssistant @@ -26,18 +28,25 @@ async def async_get_config_entry_diagnostics( ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - whirlpool = config_entry.runtime_data + def get_appliance_diagnostics(appliance: Appliance) -> dict[str, Any]: + return { + "data_model": appliance.appliance_info.data_model, + "category": appliance.appliance_info.category, + "model_number": appliance.appliance_info.model_number, + } + + appliances_manager = config_entry.runtime_data diagnostics_data = { - "Washer_dryers": { - wd["NAME"]: dict(wd.items()) - for wd in whirlpool.appliances_manager.washer_dryers + "washer_dryers": { + wd.name: get_appliance_diagnostics(wd) + for wd in appliances_manager.washer_dryers }, "aircons": { - ac["NAME"]: dict(ac.items()) for ac in whirlpool.appliances_manager.aircons + ac.name: get_appliance_diagnostics(ac) for ac in appliances_manager.aircons }, "ovens": { - oven["NAME"]: dict(oven.items()) - for oven in whirlpool.appliances_manager.ovens + oven.name: get_appliance_diagnostics(oven) + for oven in appliances_manager.ovens }, } diff --git a/homeassistant/components/whirlpool/manifest.json b/homeassistant/components/whirlpool/manifest.json index 67901eea482..ace2e31791d 100644 --- a/homeassistant/components/whirlpool/manifest.json +++ b/homeassistant/components/whirlpool/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "cloud_push", "loggers": ["whirlpool"], - "requirements": ["whirlpool-sixth-sense==0.18.12"] + "requirements": ["whirlpool-sixth-sense==0.19.1"] } diff --git a/homeassistant/components/whirlpool/sensor.py b/homeassistant/components/whirlpool/sensor.py index f4811feb2c9..d0d13a128e2 100644 --- a/homeassistant/components/whirlpool/sensor.py +++ b/homeassistant/components/whirlpool/sensor.py @@ -16,7 +16,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType @@ -134,37 +133,16 @@ async def async_setup_entry( config_entry: WhirlpoolConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: - """Config flow entry for Whrilpool Laundry.""" + """Config flow entry for Whirlpool sensors.""" entities: list = [] - whirlpool_data = config_entry.runtime_data - for appliance in whirlpool_data.appliances_manager.washer_dryers: - _wd = WasherDryer( - whirlpool_data.backend_selector, - whirlpool_data.auth, - appliance["SAID"], - async_get_clientsession(hass), - ) - await _wd.connect() - + appliances_manager = config_entry.runtime_data + for washer_dryer in appliances_manager.washer_dryers: entities.extend( - [ - WasherDryerClass( - appliance["SAID"], - appliance["NAME"], - description, - _wd, - ) - for description in SENSORS - ] + [WasherDryerClass(washer_dryer, description) for description in SENSORS] ) entities.extend( [ - WasherDryerTimeClass( - appliance["SAID"], - appliance["NAME"], - description, - _wd, - ) + WasherDryerTimeClass(washer_dryer, description) for description in SENSOR_TIMER ] ) @@ -178,34 +156,30 @@ class WasherDryerClass(SensorEntity): _attr_has_entity_name = True def __init__( - self, - said: str, - name: str, - description: WhirlpoolSensorEntityDescription, - washdry: WasherDryer, + self, washer_dryer: WasherDryer, description: WhirlpoolSensorEntityDescription ) -> None: """Initialize the washer sensor.""" - self._wd: WasherDryer = washdry + self._wd: WasherDryer = washer_dryer - if name == "dryer": + if washer_dryer.name == "dryer": self._attr_icon = ICON_D else: self._attr_icon = ICON_W self.entity_description: WhirlpoolSensorEntityDescription = description self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, said)}, - name=name.capitalize(), + identifiers={(DOMAIN, washer_dryer.said)}, + name=washer_dryer.name.capitalize(), manufacturer="Whirlpool", ) - self._attr_unique_id = f"{said}-{description.key}" + self._attr_unique_id = f"{washer_dryer.said}-{description.key}" async def async_added_to_hass(self) -> None: - """Connect washer/dryer to the cloud.""" + """Register updates callback.""" self._wd.register_attr_callback(self.async_write_ha_state) async def async_will_remove_from_hass(self) -> None: - """Close Whirlpool Appliance sockets before removing.""" + """Unregister updates callback.""" self._wd.unregister_attr_callback(self.async_write_ha_state) @property @@ -226,16 +200,12 @@ class WasherDryerTimeClass(RestoreSensor): _attr_has_entity_name = True def __init__( - self, - said: str, - name: str, - description: SensorEntityDescription, - washdry: WasherDryer, + self, washer_dryer: WasherDryer, description: SensorEntityDescription ) -> None: """Initialize the washer sensor.""" - self._wd: WasherDryer = washdry + self._wd: WasherDryer = washer_dryer - if name == "dryer": + if washer_dryer.name == "dryer": self._attr_icon = ICON_D else: self._attr_icon = ICON_W @@ -243,11 +213,11 @@ class WasherDryerTimeClass(RestoreSensor): self.entity_description: SensorEntityDescription = description self._running: bool | None = None self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, said)}, - name=name.capitalize(), + identifiers={(DOMAIN, washer_dryer.said)}, + name=washer_dryer.name.capitalize(), manufacturer="Whirlpool", ) - self._attr_unique_id = f"{said}-{description.key}" + self._attr_unique_id = f"{washer_dryer.said}-{description.key}" async def async_added_to_hass(self) -> None: """Connect washer/dryer to the cloud.""" @@ -259,7 +229,6 @@ class WasherDryerTimeClass(RestoreSensor): async def async_will_remove_from_hass(self) -> None: """Close Whrilpool Appliance sockets before removing.""" self._wd.unregister_attr_callback(self.update_from_latest_data) - await self._wd.disconnect() @property def available(self) -> bool: diff --git a/requirements_all.txt b/requirements_all.txt index a4d5f6c6914..22cbf6bf7cd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3061,7 +3061,7 @@ webmin-xmlrpc==0.0.2 weheat==2025.2.26 # homeassistant.components.whirlpool -whirlpool-sixth-sense==0.18.12 +whirlpool-sixth-sense==0.19.1 # homeassistant.components.whois whois==0.9.27 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2d34851cecc..0e5dd85decd 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2465,7 +2465,7 @@ webmin-xmlrpc==0.0.2 weheat==2025.2.26 # homeassistant.components.whirlpool -whirlpool-sixth-sense==0.18.12 +whirlpool-sixth-sense==0.19.1 # homeassistant.components.whois whois==0.9.27 diff --git a/tests/components/whirlpool/__init__.py b/tests/components/whirlpool/__init__.py index ca00975941a..97d9b4d61d5 100644 --- a/tests/components/whirlpool/__init__.py +++ b/tests/components/whirlpool/__init__.py @@ -31,5 +31,4 @@ async def init_integration_with_entry( entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - return entry diff --git a/tests/components/whirlpool/conftest.py b/tests/components/whirlpool/conftest.py index c302922fe25..93881d3735a 100644 --- a/tests/components/whirlpool/conftest.py +++ b/tests/components/whirlpool/conftest.py @@ -8,10 +8,7 @@ import whirlpool import whirlpool.aircon from whirlpool.backendselector import Brand, Region -MOCK_SAID1 = "said1" -MOCK_SAID2 = "said2" -MOCK_SAID3 = "said3" -MOCK_SAID4 = "said4" +from .const import MOCK_SAID1, MOCK_SAID2, MOCK_SAID3, MOCK_SAID4 @pytest.fixture( @@ -36,7 +33,7 @@ def fixture_brand(request: pytest.FixtureRequest) -> tuple[str, Brand]: return request.param -@pytest.fixture(name="mock_auth_api") +@pytest.fixture(name="mock_auth_api", autouse=True) def fixture_mock_auth_api(): """Set up Auth fixture.""" with ( @@ -50,8 +47,10 @@ def fixture_mock_auth_api(): yield mock_auth -@pytest.fixture(name="mock_appliances_manager_api") -def fixture_mock_appliances_manager_api(): +@pytest.fixture(name="mock_appliances_manager_api", autouse=True) +def fixture_mock_appliances_manager_api( + mock_aircon1_api, mock_aircon2_api, mock_sensor1_api, mock_sensor2_api +): """Set up AppliancesManager fixture.""" with ( mock.patch( @@ -63,28 +62,15 @@ def fixture_mock_appliances_manager_api(): ), ): mock_appliances_manager.return_value.fetch_appliances = AsyncMock() + mock_appliances_manager.return_value.connect = AsyncMock() + mock_appliances_manager.return_value.disconnect = AsyncMock() mock_appliances_manager.return_value.aircons = [ - {"SAID": MOCK_SAID1, "NAME": "TestZone"}, - {"SAID": MOCK_SAID2, "NAME": "TestZone"}, + mock_aircon1_api, + mock_aircon2_api, ] mock_appliances_manager.return_value.washer_dryers = [ - {"SAID": MOCK_SAID3, "NAME": "washer"}, - {"SAID": MOCK_SAID4, "NAME": "dryer"}, - ] - yield mock_appliances_manager - - -@pytest.fixture(name="mock_appliances_manager_laundry_api") -def fixture_mock_appliances_manager_laundry_api(): - """Set up AppliancesManager fixture.""" - with mock.patch( - "homeassistant.components.whirlpool.AppliancesManager" - ) as mock_appliances_manager: - mock_appliances_manager.return_value.fetch_appliances = AsyncMock() - mock_appliances_manager.return_value.aircons = None - mock_appliances_manager.return_value.washer_dryers = [ - {"SAID": MOCK_SAID3, "NAME": "washer"}, - {"SAID": MOCK_SAID4, "NAME": "dryer"}, + mock_sensor1_api, + mock_sensor2_api, ] yield mock_appliances_manager @@ -107,9 +93,11 @@ def fixture_mock_backend_selector_api(): def get_aircon_mock(said): """Get a mock of an air conditioner.""" mock_aircon = mock.Mock(said=said) - mock_aircon.connect = AsyncMock() - mock_aircon.disconnect = AsyncMock() + mock_aircon.name = f"Aircon {said}" mock_aircon.register_attr_callback = MagicMock() + mock_aircon.appliance_info.data_model = "aircon_model" + mock_aircon.appliance_info.category = "aircon" + mock_aircon.appliance_info.model_number = "12345" mock_aircon.get_online.return_value = True mock_aircon.get_power_on.return_value = True mock_aircon.get_mode.return_value = whirlpool.aircon.Mode.Cool @@ -132,13 +120,13 @@ def get_aircon_mock(said): @pytest.fixture(name="mock_aircon1_api", autouse=False) -def fixture_mock_aircon1_api(mock_auth_api, mock_appliances_manager_api): +def fixture_mock_aircon1_api(): """Set up air conditioner API fixture.""" return get_aircon_mock(MOCK_SAID1) @pytest.fixture(name="mock_aircon2_api", autouse=False) -def fixture_mock_aircon2_api(mock_auth_api, mock_appliances_manager_api): +def fixture_mock_aircon2_api(): """Set up air conditioner API fixture.""" return get_aircon_mock(MOCK_SAID2) @@ -168,9 +156,11 @@ def side_effect_function(*args, **kwargs): def get_sensor_mock(said): """Get a mock of a sensor.""" mock_sensor = mock.Mock(said=said) - mock_sensor.connect = AsyncMock() - mock_sensor.disconnect = AsyncMock() + mock_sensor.name = f"WasherDryer {said}" mock_sensor.register_attr_callback = MagicMock() + mock_sensor.appliance_info.data_model = "washer_dryer_model" + mock_sensor.appliance_info.category = "washer_dryer" + mock_sensor.appliance_info.model_number = "12345" mock_sensor.get_online.return_value = True mock_sensor.get_machine_state.return_value = ( whirlpool.washerdryer.MachineState.Standby @@ -187,13 +177,13 @@ def get_sensor_mock(said): @pytest.fixture(name="mock_sensor1_api", autouse=False) -def fixture_mock_sensor1_api(mock_auth_api, mock_appliances_manager_laundry_api): +def fixture_mock_sensor1_api(): """Set up sensor API fixture.""" return get_sensor_mock(MOCK_SAID3) @pytest.fixture(name="mock_sensor2_api", autouse=False) -def fixture_mock_sensor2_api(mock_auth_api, mock_appliances_manager_laundry_api): +def fixture_mock_sensor2_api(): """Set up sensor API fixture.""" return get_sensor_mock(MOCK_SAID4) diff --git a/tests/components/whirlpool/const.py b/tests/components/whirlpool/const.py new file mode 100644 index 00000000000..04ea5c0645c --- /dev/null +++ b/tests/components/whirlpool/const.py @@ -0,0 +1,6 @@ +"""Constants for the Whirlpool Sixth Sense integration tests.""" + +MOCK_SAID1 = "said1" +MOCK_SAID2 = "said2" +MOCK_SAID3 = "said3" +MOCK_SAID4 = "said4" diff --git a/tests/components/whirlpool/snapshots/test_diagnostics.ambr b/tests/components/whirlpool/snapshots/test_diagnostics.ambr index ee8abe04bf1..7ffae8bc808 100644 --- a/tests/components/whirlpool/snapshots/test_diagnostics.ambr +++ b/tests/components/whirlpool/snapshots/test_diagnostics.ambr @@ -2,24 +2,32 @@ # name: test_entry_diagnostics dict({ 'appliances': dict({ - 'Washer_dryers': dict({ - 'dryer': dict({ - 'NAME': 'dryer', - 'SAID': '**REDACTED**', - }), - 'washer': dict({ - 'NAME': 'washer', - 'SAID': '**REDACTED**', - }), - }), 'aircons': dict({ - 'TestZone': dict({ - 'NAME': 'TestZone', - 'SAID': '**REDACTED**', + 'Aircon said1': dict({ + 'category': 'aircon', + 'data_model': 'aircon_model', + 'model_number': '12345', + }), + 'Aircon said2': dict({ + 'category': 'aircon', + 'data_model': 'aircon_model', + 'model_number': '12345', }), }), 'ovens': dict({ }), + 'washer_dryers': dict({ + 'WasherDryer said3': dict({ + 'category': 'washer_dryer', + 'data_model': 'washer_dryer_model', + 'model_number': '12345', + }), + 'WasherDryer said4': dict({ + 'category': 'washer_dryer', + 'data_model': 'washer_dryer_model', + 'model_number': '12345', + }), + }), }), 'config_entry': dict({ 'data': dict({ diff --git a/tests/components/whirlpool/test_climate.py b/tests/components/whirlpool/test_climate.py index cdae28f4432..0586d654f7f 100644 --- a/tests/components/whirlpool/test_climate.py +++ b/tests/components/whirlpool/test_climate.py @@ -68,6 +68,7 @@ async def test_no_appliances( ) -> None: """Test the setup of the climate entities when there are no appliances available.""" mock_appliances_manager_api.return_value.aircons = [] + mock_appliances_manager_api.return_value.washer_dryers = [] await init_integration(hass) assert len(hass.states.async_all()) == 0 @@ -75,16 +76,15 @@ async def test_no_appliances( async def test_static_attributes( hass: HomeAssistant, entity_registry: er.EntityRegistry, - mock_aircon1_api: MagicMock, - mock_aircon_api_instances: MagicMock, ) -> None: """Test static climate attributes.""" await init_integration(hass) - for entity_id in ("climate.said1", "climate.said2"): + for said in ("said1", "said2"): + entity_id = f"climate.{said}" entry = entity_registry.async_get(entity_id) assert entry - assert entry.unique_id == entity_id.split(".")[1] + assert entry.unique_id == said state = hass.states.get(entity_id) assert state is not None @@ -92,7 +92,7 @@ async def test_static_attributes( assert state.state == HVACMode.COOL attributes = state.attributes - assert attributes[ATTR_FRIENDLY_NAME] == "TestZone" + assert attributes[ATTR_FRIENDLY_NAME] == f"Aircon {said}" assert ( attributes[ATTR_SUPPORTED_FEATURES] @@ -123,7 +123,6 @@ async def test_static_attributes( async def test_dynamic_attributes( hass: HomeAssistant, - mock_aircon_api_instances: MagicMock, mock_aircon1_api: MagicMock, mock_aircon2_api: MagicMock, ) -> None: @@ -212,7 +211,6 @@ async def test_dynamic_attributes( async def test_service_calls( hass: HomeAssistant, - mock_aircon_api_instances: MagicMock, mock_aircon1_api: MagicMock, mock_aircon2_api: MagicMock, ) -> None: diff --git a/tests/components/whirlpool/test_diagnostics.py b/tests/components/whirlpool/test_diagnostics.py index 2a0b2e6fd18..192339156e1 100644 --- a/tests/components/whirlpool/test_diagnostics.py +++ b/tests/components/whirlpool/test_diagnostics.py @@ -1,7 +1,5 @@ """Test Blink diagnostics.""" -from unittest.mock import MagicMock - from syrupy import SnapshotAssertion from syrupy.filters import props @@ -19,9 +17,6 @@ async def test_entry_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, snapshot: SnapshotAssertion, - mock_appliances_manager_api: MagicMock, - mock_aircon1_api: MagicMock, - mock_aircon_api_instances: MagicMock, ) -> None: """Test config entry diagnostics.""" diff --git a/tests/components/whirlpool/test_init.py b/tests/components/whirlpool/test_init.py index 8f082ff6294..5f04bf84b9e 100644 --- a/tests/components/whirlpool/test_init.py +++ b/tests/components/whirlpool/test_init.py @@ -21,7 +21,6 @@ async def test_setup( mock_backend_selector_api: MagicMock, region, brand, - mock_aircon_api_instances: MagicMock, ) -> None: """Test setup.""" entry = await init_integration(hass, region[0], brand[0]) @@ -33,7 +32,6 @@ async def test_setup( async def test_setup_region_fallback( hass: HomeAssistant, mock_backend_selector_api: MagicMock, - mock_aircon_api_instances: MagicMock, ) -> None: """Test setup when no region is available on the ConfigEntry. @@ -57,7 +55,6 @@ async def test_setup_brand_fallback( hass: HomeAssistant, region, mock_backend_selector_api: MagicMock, - mock_aircon_api_instances: MagicMock, ) -> None: """Test setup when no brand is available on the ConfigEntry. @@ -81,7 +78,6 @@ async def test_setup_brand_fallback( async def test_setup_http_exception( hass: HomeAssistant, mock_auth_api: MagicMock, - mock_aircon_api_instances: MagicMock, ) -> None: """Test setup with an http exception.""" mock_auth_api.return_value.do_auth = AsyncMock( @@ -95,7 +91,6 @@ async def test_setup_http_exception( async def test_setup_auth_failed( hass: HomeAssistant, mock_auth_api: MagicMock, - mock_aircon_api_instances: MagicMock, ) -> None: """Test setup with failed auth.""" mock_auth_api.return_value.do_auth = AsyncMock() @@ -108,7 +103,6 @@ async def test_setup_auth_failed( async def test_setup_auth_account_locked( hass: HomeAssistant, mock_auth_api: MagicMock, - mock_aircon_api_instances: MagicMock, ) -> None: """Test setup with failed auth due to account being locked.""" mock_auth_api.return_value.do_auth.side_effect = AccountLockedError @@ -120,7 +114,6 @@ async def test_setup_auth_account_locked( async def test_setup_fetch_appliances_failed( hass: HomeAssistant, mock_appliances_manager_api: MagicMock, - mock_aircon_api_instances: MagicMock, ) -> None: """Test setup with failed fetch_appliances.""" mock_appliances_manager_api.return_value.fetch_appliances.return_value = False @@ -129,11 +122,7 @@ async def test_setup_fetch_appliances_failed( assert entry.state is ConfigEntryState.SETUP_ERROR -async def test_unload_entry( - hass: HomeAssistant, - mock_aircon_api_instances: MagicMock, - mock_sensor_api_instances: MagicMock, -) -> None: +async def test_unload_entry(hass: HomeAssistant) -> None: """Test successful unload of entry.""" entry = await init_integration(hass) assert len(hass.config_entries.async_entries(DOMAIN)) == 1 diff --git a/tests/components/whirlpool/test_sensor.py b/tests/components/whirlpool/test_sensor.py index 548025e29bd..95fca331707 100644 --- a/tests/components/whirlpool/test_sensor.py +++ b/tests/components/whirlpool/test_sensor.py @@ -12,14 +12,13 @@ from homeassistant.helpers import entity_registry as er from homeassistant.util.dt import as_timestamp, utc_from_timestamp, utcnow from . import init_integration +from .const import MOCK_SAID3, MOCK_SAID4 from tests.common import async_fire_time_changed, mock_restore_cache_with_extra_data async def update_sensor_state( - hass: HomeAssistant, - entity_id: str, - mock_sensor_api_instance: MagicMock, + hass: HomeAssistant, entity_id: str, mock_sensor_api_instance: MagicMock ) -> State: """Simulate an update trigger from the API.""" @@ -46,10 +45,7 @@ def side_effect_function_open_door(*args, **kwargs): async def test_dryer_sensor_values( - hass: HomeAssistant, - mock_sensor_api_instances: MagicMock, - mock_sensor2_api: MagicMock, - entity_registry: er.EntityRegistry, + hass: HomeAssistant, mock_sensor2_api: MagicMock, entity_registry: er.EntityRegistry ) -> None: """Test the sensor value callbacks.""" hass.set_state(CoreState.not_running) @@ -58,14 +54,11 @@ async def test_dryer_sensor_values( hass, ( ( - State( - "sensor.washer_end_time", - "1", - ), + State(f"sensor.washerdryer_{MOCK_SAID3}_end_time", "1"), {"native_value": thetimestamp, "native_unit_of_measurement": None}, ), ( - State("sensor.dryer_end_time", "1"), + State(f"sensor.washerdryer_{MOCK_SAID4}_end_time", "1"), {"native_value": thetimestamp, "native_unit_of_measurement": None}, ), ), @@ -73,7 +66,7 @@ async def test_dryer_sensor_values( await init_integration(hass) - entity_id = "sensor.dryer_state" + entity_id = f"sensor.washerdryer_{MOCK_SAID4}_state" mock_instance = mock_sensor2_api entry = entity_registry.async_get(entity_id) assert entry @@ -83,7 +76,7 @@ async def test_dryer_sensor_values( state = await update_sensor_state(hass, entity_id, mock_instance) assert state is not None - state_id = f"{entity_id.split('_', maxsplit=1)[0]}_end_time" + state_id = f"sensor.washerdryer_{MOCK_SAID3}_end_time" state = hass.states.get(state_id) assert state.state == thetimestamp.isoformat() @@ -110,10 +103,7 @@ async def test_dryer_sensor_values( async def test_washer_sensor_values( - hass: HomeAssistant, - mock_sensor_api_instances: MagicMock, - mock_sensor1_api: MagicMock, - entity_registry: er.EntityRegistry, + hass: HomeAssistant, mock_sensor1_api: MagicMock, entity_registry: er.EntityRegistry ) -> None: """Test the sensor value callbacks.""" hass.set_state(CoreState.not_running) @@ -122,14 +112,11 @@ async def test_washer_sensor_values( hass, ( ( - State( - "sensor.washer_end_time", - "1", - ), + State(f"sensor.washerdryer_{MOCK_SAID3}_end_time", "1"), {"native_value": thetimestamp, "native_unit_of_measurement": None}, ), ( - State("sensor.dryer_end_time", "1"), + State(f"sensor.washerdryer_{MOCK_SAID4}_end_time", "1"), {"native_value": thetimestamp, "native_unit_of_measurement": None}, ), ), @@ -143,7 +130,7 @@ async def test_washer_sensor_values( ) await hass.async_block_till_done() - entity_id = "sensor.washer_state" + entity_id = f"sensor.washerdryer_{MOCK_SAID3}_state" mock_instance = mock_sensor1_api entry = entity_registry.async_get(entity_id) assert entry @@ -153,11 +140,11 @@ async def test_washer_sensor_values( state = await update_sensor_state(hass, entity_id, mock_instance) assert state is not None - state_id = f"{entity_id.split('_', maxsplit=1)[0]}_end_time" + state_id = f"sensor.washerdryer_{MOCK_SAID3}_end_time" state = hass.states.get(state_id) assert state.state == thetimestamp.isoformat() - state_id = f"{entity_id.split('_', maxsplit=1)[0]}_detergent_level" + state_id = f"sensor.washerdryer_{MOCK_SAID3}_detergent_level" entry = entity_registry.async_get(state_id) assert entry assert entry.disabled @@ -277,10 +264,7 @@ async def test_washer_sensor_values( assert state.state == "door_open" -async def test_restore_state( - hass: HomeAssistant, - mock_sensor_api_instances: MagicMock, -) -> None: +async def test_restore_state(hass: HomeAssistant) -> None: """Test sensor restore state.""" # Home assistant is not running yet hass.set_state(CoreState.not_running) @@ -289,14 +273,11 @@ async def test_restore_state( hass, ( ( - State( - "sensor.washer_end_time", - "1", - ), + State(f"sensor.washerdryer_{MOCK_SAID3}_end_time", "1"), {"native_value": thetimestamp, "native_unit_of_measurement": None}, ), ( - State("sensor.dryer_end_time", "1"), + State(f"sensor.washerdryer_{MOCK_SAID4}_end_time", "1"), {"native_value": thetimestamp, "native_unit_of_measurement": None}, ), ), @@ -305,20 +286,18 @@ async def test_restore_state( # create and add entry await init_integration(hass) # restore from cache - state = hass.states.get("sensor.washer_end_time") + state = hass.states.get(f"sensor.washerdryer_{MOCK_SAID3}_end_time") assert state.state == thetimestamp.isoformat() - state = hass.states.get("sensor.dryer_end_time") + state = hass.states.get(f"sensor.washerdryer_{MOCK_SAID4}_end_time") assert state.state == thetimestamp.isoformat() async def test_no_restore_state( - hass: HomeAssistant, - mock_sensor_api_instances: MagicMock, - mock_sensor1_api: MagicMock, + hass: HomeAssistant, mock_sensor1_api: MagicMock ) -> None: """Test sensor restore state with no restore.""" # create and add entry - entity_id = "sensor.washer_end_time" + entity_id = f"sensor.washerdryer_{MOCK_SAID3}_end_time" await init_integration(hass) # restore from cache state = hass.states.get(entity_id) @@ -330,11 +309,7 @@ async def test_no_restore_state( @pytest.mark.freeze_time("2022-11-30 00:00:00") -async def test_callback( - hass: HomeAssistant, - mock_sensor_api_instances: MagicMock, - mock_sensor1_api: MagicMock, -) -> None: +async def test_callback(hass: HomeAssistant, mock_sensor1_api: MagicMock) -> None: """Test callback timestamp callback function.""" hass.set_state(CoreState.not_running) thetimestamp: datetime = datetime(2022, 11, 29, 00, 00, 00, 00, UTC) @@ -342,14 +317,11 @@ async def test_callback( hass, ( ( - State( - "sensor.washer_end_time", - "1", - ), + State(f"sensor.washerdryer_{MOCK_SAID3}_end_time", "1"), {"native_value": thetimestamp, "native_unit_of_measurement": None}, ), ( - State("sensor.dryer_end_time", "1"), + State(f"sensor.washerdryer_{MOCK_SAID4}_end_time", "1"), {"native_value": thetimestamp, "native_unit_of_measurement": None}, ), ), @@ -358,12 +330,12 @@ async def test_callback( # create and add entry await init_integration(hass) # restore from cache - state = hass.states.get("sensor.washer_end_time") + state = hass.states.get(f"sensor.washerdryer_{MOCK_SAID3}_end_time") assert state.state == thetimestamp.isoformat() callback = mock_sensor1_api.register_attr_callback.call_args_list[1][0][0] callback() - state = hass.states.get("sensor.washer_end_time") + state = hass.states.get(f"sensor.washerdryer_{MOCK_SAID3}_end_time") assert state.state == thetimestamp.isoformat() mock_sensor1_api.get_machine_state.return_value = MachineState.RunningMainCycle mock_sensor1_api.get_attribute.side_effect = None @@ -371,19 +343,19 @@ async def test_callback( callback() # Test new timestamp when machine starts a cycle. - state = hass.states.get("sensor.washer_end_time") + state = hass.states.get(f"sensor.washerdryer_{MOCK_SAID3}_end_time") time = state.state assert state.state != thetimestamp.isoformat() # Test no timestamp change for < 60 seconds time change. mock_sensor1_api.get_attribute.return_value = "65" callback() - state = hass.states.get("sensor.washer_end_time") + state = hass.states.get(f"sensor.washerdryer_{MOCK_SAID3}_end_time") assert state.state == time # Test timestamp change for > 60 seconds. mock_sensor1_api.get_attribute.return_value = "125" callback() - state = hass.states.get("sensor.washer_end_time") + state = hass.states.get(f"sensor.washerdryer_{MOCK_SAID3}_end_time") newtime = utc_from_timestamp(as_timestamp(time) + 65) assert state.state == newtime.isoformat() From 935890e4e046eba0e7f52dceea2ceea9328e044b Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Fri, 7 Mar 2025 22:28:21 +1000 Subject: [PATCH 2310/3148] Fix shift state default in Teslemetry and Tessie (#140018) * Fix again * Fix Tessie * Update snap --- homeassistant/components/teslemetry/sensor.py | 12 ++++++------ homeassistant/components/tessie/sensor.py | 2 +- tests/components/tessie/snapshots/test_sensor.ambr | 2 +- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/teslemetry/sensor.py b/homeassistant/components/teslemetry/sensor.py index 56c8830d736..f1859ad39de 100644 --- a/homeassistant/components/teslemetry/sensor.py +++ b/homeassistant/components/teslemetry/sensor.py @@ -68,7 +68,7 @@ class TeslemetryVehicleSensorEntityDescription(SensorEntityDescription): polling: bool = False polling_value_fn: Callable[[StateType], StateType] = lambda x: x - polling_available_fn: Callable[[StateType], bool] = lambda x: x is not None + nullable: bool = False streaming_key: Signal | None = None streaming_value_fn: Callable[[str | int | float], StateType] = lambda x: x streaming_firmware: str = "2024.26" @@ -210,7 +210,7 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryVehicleSensorEntityDescription, ...] = ( TeslemetryVehicleSensorEntityDescription( key="drive_state_shift_state", polling=True, - polling_available_fn=lambda x: True, + nullable=True, polling_value_fn=lambda x: SHIFT_STATES.get(str(x), "p"), streaming_key=Signal.GEAR, streaming_value_fn=lambda x: str(ShiftState.get(x, "P")).lower(), @@ -622,10 +622,10 @@ class TeslemetryStreamSensorEntity(TeslemetryVehicleStreamEntity, RestoreSensor) def _async_value_from_stream(self, value) -> None: """Update the value of the entity.""" - if value is None: - self._attr_native_value = None - else: + if self.entity_description.nullable or value is not None: self._attr_native_value = self.entity_description.streaming_value_fn(value) + else: + self._attr_native_value = None class TeslemetryVehicleSensorEntity(TeslemetryVehicleEntity, SensorEntity): @@ -644,7 +644,7 @@ class TeslemetryVehicleSensorEntity(TeslemetryVehicleEntity, SensorEntity): def _async_update_attrs(self) -> None: """Update the attributes of the sensor.""" - if self.entity_description.polling_available_fn(self._value): + if self.entity_description.nullable or self._value is not None: self._attr_available = True self._attr_native_value = self.entity_description.polling_value_fn( self._value diff --git a/homeassistant/components/tessie/sensor.py b/homeassistant/components/tessie/sensor.py index 1c26ad633f3..e5b476057fa 100644 --- a/homeassistant/components/tessie/sensor.py +++ b/homeassistant/components/tessie/sensor.py @@ -148,7 +148,7 @@ DESCRIPTIONS: tuple[TessieSensorEntityDescription, ...] = ( key="drive_state_shift_state", options=["p", "d", "r", "n"], device_class=SensorDeviceClass.ENUM, - value_fn=lambda x: x.lower() if isinstance(x, str) else x, + value_fn=lambda x: x.lower() if isinstance(x, str) else "p", ), TessieSensorEntityDescription( key="vehicle_state_odometer", diff --git a/tests/components/tessie/snapshots/test_sensor.ambr b/tests/components/tessie/snapshots/test_sensor.ambr index 5465f89d808..b40cf204bca 100644 --- a/tests/components/tessie/snapshots/test_sensor.ambr +++ b/tests/components/tessie/snapshots/test_sensor.ambr @@ -1614,7 +1614,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': 'p', }) # --- # name: test_sensors[sensor.test_speed-entry] From 11348959ca7f4ed4c1a6ff8915e9ebce2316746e Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Fri, 7 Mar 2025 13:39:48 +0100 Subject: [PATCH 2311/3148] Make descriptions of `keymitt_ble.calibrate` action UI-friendly (#139866) * Make descriptions of `keymitt_ble.calibrate` action UI-friendly Update the action and field descriptions to better work within the graphical UI (selector / units shown) and for translations. * Change to "press or release" to cover the 'Invert' mode --- homeassistant/components/keymitt_ble/strings.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/keymitt_ble/strings.json b/homeassistant/components/keymitt_ble/strings.json index 2a1f428603e..5e7e895d222 100644 --- a/homeassistant/components/keymitt_ble/strings.json +++ b/homeassistant/components/keymitt_ble/strings.json @@ -34,7 +34,7 @@ "services": { "calibrate": { "name": "Calibrate", - "description": "Calibration - Set depth, press & hold duration, and operation mode. Warning - this will send a push command to the device.", + "description": "Sets the depth, press or release duration, and operation mode. Warning - this will send a push command to the device.", "fields": { "entity_id": { "name": "Entity", @@ -42,15 +42,15 @@ }, "depth": { "name": "Depth", - "description": "Depth in percent." + "description": "How far to extend the push arm." }, "duration": { "name": "Duration", - "description": "Duration in seconds." + "description": "How long to press or release." }, "mode": { "name": "[%key:common::config_flow::data::mode%]", - "description": "Normal | invert | toggle." + "description": "The operation mode of the arm." } } } From 354cd90c92ea9b16fc38d5a303cc3ffe9b8962db Mon Sep 17 00:00:00 2001 From: peteS-UK <64092177+peteS-UK@users.noreply.github.com> Date: Fri, 7 Mar 2025 12:53:24 +0000 Subject: [PATCH 2312/3148] Fix Unit of Measurement for Squeezebox duration sensor entity on LMS service (#139861) UOM Fix --- homeassistant/components/squeezebox/sensor.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/squeezebox/sensor.py b/homeassistant/components/squeezebox/sensor.py index c0a7a37d539..9d9490208ea 100644 --- a/homeassistant/components/squeezebox/sensor.py +++ b/homeassistant/components/squeezebox/sensor.py @@ -43,6 +43,7 @@ SENSORS: tuple[SensorEntityDescription, ...] = ( state_class=SensorStateClass.TOTAL, device_class=SensorDeviceClass.DURATION, native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.HOURS, ), SensorEntityDescription( key=STATUS_SENSOR_INFO_TOTAL_GENRES, From eadff2938f7c8f1180f75ae4fcee470a9d812d33 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 7 Mar 2025 14:26:43 +0100 Subject: [PATCH 2313/3148] Bump pysmartthings to 2.7.0 (#140047) --- homeassistant/components/smartthings/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/smartthings/manifest.json b/homeassistant/components/smartthings/manifest.json index 9efa8b81186..2a4e79bff58 100644 --- a/homeassistant/components/smartthings/manifest.json +++ b/homeassistant/components/smartthings/manifest.json @@ -29,5 +29,5 @@ "documentation": "https://www.home-assistant.io/integrations/smartthings", "iot_class": "cloud_push", "loggers": ["pysmartthings"], - "requirements": ["pysmartthings==2.6.1"] + "requirements": ["pysmartthings==2.7.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 22cbf6bf7cd..b142643b29e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2310,7 +2310,7 @@ pysma==0.7.5 pysmappee==0.2.29 # homeassistant.components.smartthings -pysmartthings==2.6.1 +pysmartthings==2.7.0 # homeassistant.components.smarty pysmarty2==0.10.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0e5dd85decd..6e2bfc7c19f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1882,7 +1882,7 @@ pysma==0.7.5 pysmappee==0.2.29 # homeassistant.components.smartthings -pysmartthings==2.6.1 +pysmartthings==2.7.0 # homeassistant.components.smarty pysmarty2==0.10.2 From 62e45e393d5415a54155fe54f57daa42a40810a5 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 7 Mar 2025 14:56:31 +0100 Subject: [PATCH 2314/3148] Fix SmartThings thermostat climate check (#140046) * Fix SmartThings thermostat climate check * Add tests --- .../components/smartthings/climate.py | 4 +- tests/components/smartthings/conftest.py | 1 + .../heatit_ztrm3_thermostat.json | 60 +++++++ .../devices/heatit_ztrm3_thermostat.json | 79 +++++++++ .../smartthings/snapshots/test_climate.ambr | 64 +++++++ .../smartthings/snapshots/test_init.ambr | 33 ++++ .../smartthings/snapshots/test_sensor.ambr | 156 ++++++++++++++++++ 7 files changed, 394 insertions(+), 3 deletions(-) create mode 100644 tests/components/smartthings/fixtures/device_status/heatit_ztrm3_thermostat.json create mode 100644 tests/components/smartthings/fixtures/devices/heatit_ztrm3_thermostat.json diff --git a/homeassistant/components/smartthings/climate.py b/homeassistant/components/smartthings/climate.py index b19d65db867..cafd831c5bd 100644 --- a/homeassistant/components/smartthings/climate.py +++ b/homeassistant/components/smartthings/climate.py @@ -164,9 +164,7 @@ class SmartThingsThermostat(SmartThingsEntity, ClimateEntity): | ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON ) - if self.get_attribute_value( - Capability.THERMOSTAT_FAN_MODE, Attribute.THERMOSTAT_FAN_MODE - ): + if self.supports_capability(Capability.THERMOSTAT_FAN_MODE): flags |= ClimateEntityFeature.FAN_MODE return flags diff --git a/tests/components/smartthings/conftest.py b/tests/components/smartthings/conftest.py index b6e6339af97..730f683fa14 100644 --- a/tests/components/smartthings/conftest.py +++ b/tests/components/smartthings/conftest.py @@ -121,6 +121,7 @@ def mock_smartthings() -> Generator[AsyncMock]: "ecobee_thermostat", "fake_fan", "generic_fan_3_speed", + "heatit_ztrm3_thermostat", ] ) def device_fixture( diff --git a/tests/components/smartthings/fixtures/device_status/heatit_ztrm3_thermostat.json b/tests/components/smartthings/fixtures/device_status/heatit_ztrm3_thermostat.json new file mode 100644 index 00000000000..c49cc55d2cb --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/heatit_ztrm3_thermostat.json @@ -0,0 +1,60 @@ +{ + "components": { + "main": { + "powerMeter": { + "power": { + "value": 368.17, + "unit": "W", + "timestamp": "2025-03-07T12:52:08.997Z" + } + }, + "thermostatOperatingState": { + "thermostatOperatingState": { + "value": "heating", + "timestamp": "2025-03-07T12:49:53.638Z" + } + }, + "energyMeter": { + "energy": { + "value": 2339.5, + "unit": "kWh", + "timestamp": "2025-03-07T12:26:37.133Z" + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": 19.0, + "unit": "C", + "timestamp": "2025-03-07T12:52:39.210Z" + } + }, + "thermostatHeatingSetpoint": { + "heatingSetpoint": { + "value": 19.0, + "unit": "C", + "timestamp": "2025-03-06T21:38:22.856Z" + }, + "heatingSetpointRange": { + "value": null + } + }, + "refresh": {}, + "thermostatMode": { + "thermostatMode": { + "value": "heat", + "data": { + "supportedThermostatModes": ["off", "heat"] + }, + "timestamp": "2025-03-06T21:38:23.046Z" + }, + "supportedThermostatModes": { + "value": ["off", "heat"], + "timestamp": "2023-09-22T15:41:01.268Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/devices/heatit_ztrm3_thermostat.json b/tests/components/smartthings/fixtures/devices/heatit_ztrm3_thermostat.json new file mode 100644 index 00000000000..e8928f6b3a8 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/heatit_ztrm3_thermostat.json @@ -0,0 +1,79 @@ +{ + "items": [ + { + "deviceId": "69a271f6-6537-4982-8cd9-979866872692", + "name": "heatit-ztrm3-thermostat", + "label": "Hall thermostat", + "manufacturerName": "SmartThingsCommunity", + "presentationId": "8c5c0adc-73d6-33db-a1bd-67d746ab0e00", + "deviceManufacturerCode": "019B-0003-0203", + "locationId": "6cf6637b-9bc5-4e52-bc99-7497e322fb0d", + "ownerId": "7b68139b-d068-45d8-bf27-961320350024", + "roomId": "746b4d54-8026-44f1-b50f-8833dafdeea3", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "thermostatHeatingSetpoint", + "version": 1 + }, + { + "id": "thermostatOperatingState", + "version": 1 + }, + { + "id": "thermostatMode", + "version": 1 + }, + { + "id": "powerMeter", + "version": 1 + }, + { + "id": "energyMeter", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + } + ], + "categories": [ + { + "name": "Thermostat", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2023-09-22T15:40:58.942Z", + "parentDeviceId": "d04f5ba0-1430-4826-9aa4-fba4efb57c24", + "profile": { + "id": "2677e0e8-9241-3163-815e-6b1d6743f280" + }, + "zwave": { + "networkId": "28", + "driverId": "28198799-de20-4cfd-a9f3-67860a0877d5", + "executingLocally": true, + "hubId": "d04f5ba0-1430-4826-9aa4-fba4efb57c24", + "networkSecurityLevel": "ZWAVE_S2_AUTHENTICATED", + "provisioningState": "PROVISIONED", + "manufacturerId": 411, + "productType": 3, + "productId": 515 + }, + "type": "ZWAVE", + "restrictionTier": 0, + "allowed": null, + "executionContext": "LOCAL", + "relationships": [] + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/snapshots/test_climate.ambr b/tests/components/smartthings/snapshots/test_climate.ambr index 08ddacf45c6..c85c7af19a6 100644 --- a/tests/components/smartthings/snapshots/test_climate.ambr +++ b/tests/components/smartthings/snapshots/test_climate.ambr @@ -369,6 +369,70 @@ 'state': 'heat', }) # --- +# name: test_all_entities[heatit_ztrm3_thermostat][climate.hall_thermostat-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + ]), + 'max_temp': 35, + 'min_temp': 7, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.hall_thermostat', + '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': None, + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '69a271f6-6537-4982-8cd9-979866872692', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[heatit_ztrm3_thermostat][climate.hall_thermostat-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 19.0, + 'friendly_name': 'Hall thermostat', + 'hvac_action': , + 'hvac_modes': list([ + , + ]), + 'max_temp': 35, + 'min_temp': 7, + 'supported_features': , + 'target_temp_high': None, + 'target_temp_low': None, + 'temperature': 19.0, + }), + 'context': , + 'entity_id': 'climate.hall_thermostat', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }) +# --- # name: test_all_entities[virtual_thermostat][climate.asd-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/smartthings/snapshots/test_init.ambr b/tests/components/smartthings/snapshots/test_init.ambr index 7ec2ce79c5b..1918f19911a 100644 --- a/tests/components/smartthings/snapshots/test_init.ambr +++ b/tests/components/smartthings/snapshots/test_init.ambr @@ -692,6 +692,39 @@ 'via_device_id': None, }) # --- +# name: test_devices[heatit_ztrm3_thermostat] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '69a271f6-6537-4982-8cd9-979866872692', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': None, + 'model': None, + 'model_id': None, + 'name': 'Hall thermostat', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- # name: test_devices[hue_color_temperature_bulb] DeviceRegistryEntrySnapshot({ 'area_id': None, diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index 72364d59277..017689f13fd 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -4524,6 +4524,162 @@ 'state': '22', }) # --- +# name: test_all_entities[heatit_ztrm3_thermostat][sensor.hall_thermostat_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.hall_thermostat_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '69a271f6-6537-4982-8cd9-979866872692.energy', + 'unit_of_measurement': 'kWh', + }) +# --- +# name: test_all_entities[heatit_ztrm3_thermostat][sensor.hall_thermostat_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Hall thermostat Energy', + 'state_class': , + 'unit_of_measurement': 'kWh', + }), + 'context': , + 'entity_id': 'sensor.hall_thermostat_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2339.5', + }) +# --- +# name: test_all_entities[heatit_ztrm3_thermostat][sensor.hall_thermostat_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.hall_thermostat_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '69a271f6-6537-4982-8cd9-979866872692.power', + 'unit_of_measurement': 'W', + }) +# --- +# name: test_all_entities[heatit_ztrm3_thermostat][sensor.hall_thermostat_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Hall thermostat Power', + 'state_class': , + 'unit_of_measurement': 'W', + }), + 'context': , + 'entity_id': 'sensor.hall_thermostat_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '368.17', + }) +# --- +# name: test_all_entities[heatit_ztrm3_thermostat][sensor.hall_thermostat_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.hall_thermostat_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': 'Temperature', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '69a271f6-6537-4982-8cd9-979866872692.temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[heatit_ztrm3_thermostat][sensor.hall_thermostat_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Hall thermostat Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.hall_thermostat_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '19.0', + }) +# --- # name: test_all_entities[multipurpose_sensor][sensor.deck_door_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 0aa09a2d51fd206c78e91c6362f0683d46c7eed5 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 7 Mar 2025 15:04:46 +0100 Subject: [PATCH 2315/3148] Only keep valid powerConsumptionReports in SmartThings (#140049) * power consumption report * Only keep valid powerConsumptionReports in SmartThings --- .../components/smartthings/__init__.py | 55 ++++++++++++++----- .../components/smartthings/sensor.py | 10 ---- .../device_status/c2c_arlo_pro_3_switch.json | 9 +++ 3 files changed, 50 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py index cf17e6a110b..535a409bc8d 100644 --- a/homeassistant/components/smartthings/__init__.py +++ b/homeassistant/components/smartthings/__init__.py @@ -186,7 +186,7 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -DATA_VALIDATION: dict[ +KEEP_CAPABILITY_QUIRK: dict[ Capability | str, Callable[[dict[Attribute | str, Status]], bool] ] = { Capability.WASHER_OPERATING_STATE: ( @@ -195,26 +195,53 @@ DATA_VALIDATION: dict[ Capability.DEMAND_RESPONSE_LOAD_CONTROL: lambda _: True, } +POWER_CONSUMPTION_FIELDS = { + "energy", + "power", + "deltaEnergy", + "powerEnergy", + "energySaved", +} + +CAPABILITY_VALIDATION: dict[ + Capability | str, Callable[[dict[Attribute | str, Status]], bool] +] = { + Capability.POWER_CONSUMPTION_REPORT: ( + lambda status: ( + (power_consumption := status[Attribute.POWER_CONSUMPTION].value) is not None + and all( + field in cast(dict, power_consumption) + for field in POWER_CONSUMPTION_FIELDS + ) + ) + ) +} + def process_status( status: dict[str, dict[Capability | str, dict[Attribute | str, Status]]], ) -> dict[str, dict[Capability | str, dict[Attribute | str, Status]]]: """Remove disabled capabilities from status.""" - if (main_component := status.get("main")) is None or ( + if (main_component := status.get(MAIN)) is None: + return status + if ( disabled_capabilities_capability := main_component.get( Capability.CUSTOM_DISABLED_CAPABILITIES ) - ) is None: - return status - disabled_capabilities = cast( - list[Capability | str], - disabled_capabilities_capability[Attribute.DISABLED_CAPABILITIES].value, - ) - if disabled_capabilities is not None: - for capability in disabled_capabilities: - if capability in main_component and ( - capability not in DATA_VALIDATION - or not DATA_VALIDATION[capability](main_component[capability]) - ): + ) is not None: + disabled_capabilities = cast( + list[Capability | str], + disabled_capabilities_capability[Attribute.DISABLED_CAPABILITIES].value, + ) + if disabled_capabilities is not None: + for capability in disabled_capabilities: + if capability in main_component and ( + capability not in KEEP_CAPABILITY_QUIRK + or not KEEP_CAPABILITY_QUIRK[capability](main_component[capability]) + ): + del main_component[capability] + for capability in list(main_component): + if capability in CAPABILITY_VALIDATION: + if not CAPABILITY_VALIDATION[capability](main_component[capability]): del main_component[capability] return status diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index 22fdf3084c8..9ef8cb55c92 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -130,7 +130,6 @@ class SmartThingsSensorEntityDescription(SensorEntityDescription): unique_id_separator: str = "." capability_ignore_list: list[set[Capability]] | None = None options_attribute: Attribute | None = None - except_if_state_none: bool = False CAPABILITY_TO_SENSORS: dict[ @@ -581,7 +580,6 @@ CAPABILITY_TO_SENSORS: dict[ native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, value_fn=lambda value: value["energy"] / 1000, suggested_display_precision=2, - except_if_state_none=True, ), SmartThingsSensorEntityDescription( key="power_meter", @@ -591,7 +589,6 @@ CAPABILITY_TO_SENSORS: dict[ value_fn=lambda value: value["power"], extra_state_attributes_fn=power_attributes, suggested_display_precision=2, - except_if_state_none=True, ), SmartThingsSensorEntityDescription( key="deltaEnergy_meter", @@ -601,7 +598,6 @@ CAPABILITY_TO_SENSORS: dict[ native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, value_fn=lambda value: value["deltaEnergy"] / 1000, suggested_display_precision=2, - except_if_state_none=True, ), SmartThingsSensorEntityDescription( key="powerEnergy_meter", @@ -611,7 +607,6 @@ CAPABILITY_TO_SENSORS: dict[ native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, value_fn=lambda value: value["powerEnergy"] / 1000, suggested_display_precision=2, - except_if_state_none=True, ), SmartThingsSensorEntityDescription( key="energySaved_meter", @@ -621,7 +616,6 @@ CAPABILITY_TO_SENSORS: dict[ native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, value_fn=lambda value: value["energySaved"] / 1000, suggested_display_precision=2, - except_if_state_none=True, ), ] }, @@ -983,10 +977,6 @@ async def async_setup_entry( for capability_list in description.capability_ignore_list ) ) - and ( - not description.except_if_state_none - or device.status[MAIN][capability][attribute].value is not None - ) ) diff --git a/tests/components/smartthings/fixtures/device_status/c2c_arlo_pro_3_switch.json b/tests/components/smartthings/fixtures/device_status/c2c_arlo_pro_3_switch.json index 371a779f83c..a3d2cabe837 100644 --- a/tests/components/smartthings/fixtures/device_status/c2c_arlo_pro_3_switch.json +++ b/tests/components/smartthings/fixtures/device_status/c2c_arlo_pro_3_switch.json @@ -58,6 +58,15 @@ "timestamp": "2025-02-08T21:56:09.761Z" } }, + "powerConsumptionReport": { + "powerConsumption": { + "value": { + "start": "2025-02-09T15:45:29Z", + "end": "2025-02-09T16:15:33Z" + }, + "timestamp": "2025-02-09T16:15:33.639Z" + } + }, "battery": { "quantity": { "value": null From edd2d4c349440d2c557259c060ad301fd537f159 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Fri, 7 Mar 2025 15:25:37 +0100 Subject: [PATCH 2316/3148] Improve strings of `swiss_public_transport.fetch_connections` action (#139911) Improve strings of `swiss_public.transport.fetch_connections` action - use sentence-casing in action name - capitalize the integration name in action description - remove "from [1-15]" from `limit` description as this is handled by the UI --- .../components/swiss_public_transport/strings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/swiss_public_transport/strings.json b/homeassistant/components/swiss_public_transport/strings.json index 1cdbd527467..f1b28f5ed14 100644 --- a/homeassistant/components/swiss_public_transport/strings.json +++ b/homeassistant/components/swiss_public_transport/strings.json @@ -83,8 +83,8 @@ }, "services": { "fetch_connections": { - "name": "Fetch Connections", - "description": "Fetch a list of connections from the swiss public transport.", + "name": "Fetch connections", + "description": "Fetches a list of connections from Swiss public transport.", "fields": { "config_entry_id": { "name": "Instance", @@ -92,7 +92,7 @@ }, "limit": { "name": "Limit", - "description": "Number of connections to fetch from [1-15]" + "description": "Number of connections to fetch." } } } From 27964e16c12d997b0281fdce4d1a7239f9d01c2c Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Fri, 7 Mar 2025 15:26:40 +0100 Subject: [PATCH 2317/3148] Clean up ondilo ico oauth2 (#139927) --- .../components/ondilo_ico/__init__.py | 30 +++++++++++------ homeassistant/components/ondilo_ico/api.py | 3 -- .../ondilo_ico/application_credentials.py | 14 ++++++++ .../components/ondilo_ico/config_flow.py | 18 ++++++----- homeassistant/components/ondilo_ico/const.py | 4 +-- .../components/ondilo_ico/manifest.json | 2 +- .../components/ondilo_ico/oauth_impl.py | 32 ------------------- .../generated/application_credentials.py | 1 + tests/components/ondilo_ico/conftest.py | 3 +- .../components/ondilo_ico/test_config_flow.py | 24 ++++---------- tests/components/ondilo_ico/test_sensor.py | 4 ++- 11 files changed, 59 insertions(+), 76 deletions(-) create mode 100644 homeassistant/components/ondilo_ico/application_credentials.py delete mode 100644 homeassistant/components/ondilo_ico/oauth_impl.py diff --git a/homeassistant/components/ondilo_ico/__init__.py b/homeassistant/components/ondilo_ico/__init__.py index ddcd7ab8831..93aadb5b6ea 100644 --- a/homeassistant/components/ondilo_ico/__init__.py +++ b/homeassistant/components/ondilo_ico/__init__.py @@ -1,27 +1,37 @@ """The Ondilo ICO integration.""" +from homeassistant.components.application_credentials import ( + ClientCredential, + async_import_client_credential, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_entry_oauth2_flow +from homeassistant.helpers import config_entry_oauth2_flow, config_validation as cv +from homeassistant.helpers.typing import ConfigType from .api import OndiloClient -from .config_flow import OndiloIcoOAuth2FlowHandler -from .const import DOMAIN +from .const import DOMAIN, OAUTH2_CLIENT_ID, OAUTH2_CLIENT_SECRET from .coordinator import OndiloIcoPoolsCoordinator -from .oauth_impl import OndiloOauth2Implementation +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) PLATFORMS = [Platform.SENSOR] +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the Ondilo ICO integration.""" + # Import the default client credential. + await async_import_client_credential( + hass, + DOMAIN, + ClientCredential(OAUTH2_CLIENT_ID, OAUTH2_CLIENT_SECRET, name="Ondilo ICO"), + ) + + return True + + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Ondilo ICO from a config entry.""" - - OndiloIcoOAuth2FlowHandler.async_register_implementation( - hass, - OndiloOauth2Implementation(hass), - ) - implementation = ( await config_entry_oauth2_flow.async_get_config_entry_implementation( hass, entry diff --git a/homeassistant/components/ondilo_ico/api.py b/homeassistant/components/ondilo_ico/api.py index f6ab0baa576..696acf1b2d6 100644 --- a/homeassistant/components/ondilo_ico/api.py +++ b/homeassistant/components/ondilo_ico/api.py @@ -1,15 +1,12 @@ """API for Ondilo ICO bound to Home Assistant OAuth.""" from asyncio import run_coroutine_threadsafe -import logging from ondilo import Ondilo from homeassistant import config_entries, core from homeassistant.helpers import config_entry_oauth2_flow -_LOGGER = logging.getLogger(__name__) - class OndiloClient(Ondilo): """Provide Ondilo ICO authentication tied to an OAuth2 based config entry.""" diff --git a/homeassistant/components/ondilo_ico/application_credentials.py b/homeassistant/components/ondilo_ico/application_credentials.py new file mode 100644 index 00000000000..5481a88bc1b --- /dev/null +++ b/homeassistant/components/ondilo_ico/application_credentials.py @@ -0,0 +1,14 @@ +"""Application credentials platform for Ondilo ICO.""" + +from homeassistant.components.application_credentials import AuthorizationServer +from homeassistant.core import HomeAssistant + +from .const import OAUTH2_AUTHORIZE, OAUTH2_TOKEN + + +async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer: + """Return authorization server.""" + return AuthorizationServer( + authorize_url=OAUTH2_AUTHORIZE, + token_url=OAUTH2_TOKEN, + ) diff --git a/homeassistant/components/ondilo_ico/config_flow.py b/homeassistant/components/ondilo_ico/config_flow.py index fe0b89e7258..6839d2089bf 100644 --- a/homeassistant/components/ondilo_ico/config_flow.py +++ b/homeassistant/components/ondilo_ico/config_flow.py @@ -3,11 +3,14 @@ import logging from typing import Any +from homeassistant.components.application_credentials import ( + ClientCredential, + async_import_client_credential, +) from homeassistant.config_entries import ConfigFlowResult from homeassistant.helpers.config_entry_oauth2_flow import AbstractOAuth2FlowHandler -from .const import DOMAIN -from .oauth_impl import OndiloOauth2Implementation +from .const import DOMAIN, OAUTH2_CLIENT_ID, OAUTH2_CLIENT_SECRET class OndiloIcoOAuth2FlowHandler(AbstractOAuth2FlowHandler, domain=DOMAIN): @@ -18,14 +21,13 @@ class OndiloIcoOAuth2FlowHandler(AbstractOAuth2FlowHandler, domain=DOMAIN): async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Handle a flow initialized by the user.""" - await self.async_set_unique_id(DOMAIN) - - self.async_register_implementation( + """Handle a flow start.""" + # Import the default client credential. + await async_import_client_credential( self.hass, - OndiloOauth2Implementation(self.hass), + DOMAIN, + ClientCredential(OAUTH2_CLIENT_ID, OAUTH2_CLIENT_SECRET, name="Ondilo ICO"), ) - return await super().async_step_user(user_input) @property diff --git a/homeassistant/components/ondilo_ico/const.py b/homeassistant/components/ondilo_ico/const.py index 3c947776857..8dec6072556 100644 --- a/homeassistant/components/ondilo_ico/const.py +++ b/homeassistant/components/ondilo_ico/const.py @@ -4,5 +4,5 @@ DOMAIN = "ondilo_ico" OAUTH2_AUTHORIZE = "https://interop.ondilo.com/oauth2/authorize" OAUTH2_TOKEN = "https://interop.ondilo.com/oauth2/token" -OAUTH2_CLIENTID = "customer_api" -OAUTH2_CLIENTSECRET = "" +OAUTH2_CLIENT_ID = "customer_api" +OAUTH2_CLIENT_SECRET = "" diff --git a/homeassistant/components/ondilo_ico/manifest.json b/homeassistant/components/ondilo_ico/manifest.json index 84862a89fbb..3553797b9cd 100644 --- a/homeassistant/components/ondilo_ico/manifest.json +++ b/homeassistant/components/ondilo_ico/manifest.json @@ -3,7 +3,7 @@ "name": "Ondilo ICO", "codeowners": ["@JeromeHXP"], "config_flow": true, - "dependencies": ["auth"], + "dependencies": ["application_credentials"], "documentation": "https://www.home-assistant.io/integrations/ondilo_ico", "integration_type": "hub", "iot_class": "cloud_polling", diff --git a/homeassistant/components/ondilo_ico/oauth_impl.py b/homeassistant/components/ondilo_ico/oauth_impl.py deleted file mode 100644 index e1c6e6fdb90..00000000000 --- a/homeassistant/components/ondilo_ico/oauth_impl.py +++ /dev/null @@ -1,32 +0,0 @@ -"""Local implementation of OAuth2 specific to Ondilo to hard code client id and secret and return a proper name.""" - -from homeassistant.core import HomeAssistant -from homeassistant.helpers.config_entry_oauth2_flow import LocalOAuth2Implementation - -from .const import ( - DOMAIN, - OAUTH2_AUTHORIZE, - OAUTH2_CLIENTID, - OAUTH2_CLIENTSECRET, - OAUTH2_TOKEN, -) - - -class OndiloOauth2Implementation(LocalOAuth2Implementation): - """Local implementation of OAuth2 specific to Ondilo to hard code client id and secret and return a proper name.""" - - def __init__(self, hass: HomeAssistant) -> None: - """Just init default class with default values.""" - super().__init__( - hass, - DOMAIN, - OAUTH2_CLIENTID, - OAUTH2_CLIENTSECRET, - OAUTH2_AUTHORIZE, - OAUTH2_TOKEN, - ) - - @property - def name(self) -> str: - """Name of the implementation.""" - return "Ondilo" diff --git a/homeassistant/generated/application_credentials.py b/homeassistant/generated/application_credentials.py index b891e807a7f..68c6de405e6 100644 --- a/homeassistant/generated/application_credentials.py +++ b/homeassistant/generated/application_credentials.py @@ -25,6 +25,7 @@ APPLICATION_CREDENTIALS = [ "neato", "nest", "netatmo", + "ondilo_ico", "onedrive", "point", "senz", diff --git a/tests/components/ondilo_ico/conftest.py b/tests/components/ondilo_ico/conftest.py index d35e5ac0003..891f60eb549 100644 --- a/tests/components/ondilo_ico/conftest.py +++ b/tests/components/ondilo_ico/conftest.py @@ -7,6 +7,7 @@ from unittest.mock import MagicMock, patch import pytest from homeassistant.components.ondilo_ico.const import DOMAIN +from homeassistant.util.json import JsonArrayType from tests.common import ( MockConfigEntry, @@ -71,7 +72,7 @@ def ico_details2() -> dict[str, Any]: @pytest.fixture(scope="package") -def last_measures() -> list[dict[str, Any]]: +def last_measures() -> JsonArrayType: """Pool measurements.""" return load_json_array_fixture("last_measures.json", DOMAIN) diff --git a/tests/components/ondilo_ico/test_config_flow.py b/tests/components/ondilo_ico/test_config_flow.py index deab2a8e0b9..19407cecb9d 100644 --- a/tests/components/ondilo_ico/test_config_flow.py +++ b/tests/components/ondilo_ico/test_config_flow.py @@ -4,15 +4,13 @@ from unittest.mock import patch import pytest -from homeassistant import config_entries, setup +from homeassistant import config_entries from homeassistant.components.ondilo_ico.const import ( DOMAIN, OAUTH2_AUTHORIZE, - OAUTH2_CLIENTID, - OAUTH2_CLIENTSECRET, + OAUTH2_CLIENT_ID as CLIENT_ID, OAUTH2_TOKEN, ) -from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import config_entry_oauth2_flow @@ -21,13 +19,12 @@ from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker from tests.typing import ClientSessionGenerator -CLIENT_ID = OAUTH2_CLIENTID -CLIENT_SECRET = OAUTH2_CLIENTSECRET - -async def test_abort_if_existing_entry(hass: HomeAssistant) -> None: +async def test_abort_if_existing_entry( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: """Check flow abort when an entry already exist.""" - MockConfigEntry(domain=DOMAIN).add_to_hass(hass) + config_entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -43,15 +40,6 @@ async def test_full_flow( aioclient_mock: AiohttpClientMocker, ) -> None: """Check full flow.""" - assert await setup.async_setup_component( - hass, - DOMAIN, - { - DOMAIN: {CONF_CLIENT_ID: CLIENT_ID, CONF_CLIENT_SECRET: CLIENT_SECRET}, - "http": {"base_url": "https://example.com"}, - }, - ) - result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) diff --git a/tests/components/ondilo_ico/test_sensor.py b/tests/components/ondilo_ico/test_sensor.py index 0043d22f6c0..c944353724e 100644 --- a/tests/components/ondilo_ico/test_sensor.py +++ b/tests/components/ondilo_ico/test_sensor.py @@ -45,7 +45,9 @@ async def test_no_ico_for_one_pool( # Only the second pool is created assert len(hass.states.async_all()) == 7 assert hass.states.get("sensor.pool_1_temperature") is None - assert hass.states.get("sensor.pool_2_rssi").state == next( + state = hass.states.get("sensor.pool_2_rssi") + assert state is not None + assert state.state == next( str(item["value"]) for item in last_measures if item["data_type"] == "rssi" ) From cd2ce5e11b403a172fb52f33a5a51894fcc8e709 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Fri, 7 Mar 2025 15:44:58 +0100 Subject: [PATCH 2318/3148] Bump py-synologydsm-api to 2.7.1 (#140052) bump py-synologydsm-api to 2.7.1 --- homeassistant/components/synology_dsm/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/synology_dsm/manifest.json b/homeassistant/components/synology_dsm/manifest.json index dc5634e7a84..3804de7f3f1 100644 --- a/homeassistant/components/synology_dsm/manifest.json +++ b/homeassistant/components/synology_dsm/manifest.json @@ -7,7 +7,7 @@ "documentation": "https://www.home-assistant.io/integrations/synology_dsm", "iot_class": "local_polling", "loggers": ["synology_dsm"], - "requirements": ["py-synologydsm-api==2.7.0"], + "requirements": ["py-synologydsm-api==2.7.1"], "ssdp": [ { "manufacturer": "Synology", diff --git a/requirements_all.txt b/requirements_all.txt index b142643b29e..a132c4b89ed 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1755,7 +1755,7 @@ py-schluter==0.1.7 py-sucks==0.9.10 # homeassistant.components.synology_dsm -py-synologydsm-api==2.7.0 +py-synologydsm-api==2.7.1 # homeassistant.components.atome pyAtome==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6e2bfc7c19f..ce6a7ce1d25 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1453,7 +1453,7 @@ py-nightscout==1.2.2 py-sucks==0.9.10 # homeassistant.components.synology_dsm -py-synologydsm-api==2.7.0 +py-synologydsm-api==2.7.1 # homeassistant.components.hdmi_cec pyCEC==0.5.2 From e51154ae69b590417f78d7ec3caa25cf076ffa3c Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 7 Mar 2025 15:46:00 +0100 Subject: [PATCH 2319/3148] Restore SmartThings button event (#140044) * Restore SmartThings button event * Fix --- .../components/smartthings/__init__.py | 32 +++++++++++- homeassistant/components/smartthings/const.py | 2 + tests/components/smartthings/__init__.py | 2 + .../fixtures/device_status/button.json | 21 ++++++++ .../smartthings/fixtures/devices/button.json | 49 +++++++++++++++++++ .../smartthings/snapshots/test_init.ambr | 3 ++ tests/components/smartthings/test_init.py | 36 ++++++++++++-- 7 files changed, 141 insertions(+), 4 deletions(-) create mode 100644 tests/components/smartthings/fixtures/device_status/button.json create mode 100644 tests/components/smartthings/fixtures/devices/button.json diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py index 535a409bc8d..3e0e66e890f 100644 --- a/homeassistant/components/smartthings/__init__.py +++ b/homeassistant/components/smartthings/__init__.py @@ -12,6 +12,7 @@ from pysmartthings import ( Attribute, Capability, Device, + DeviceEvent, Scene, SmartThings, SmartThingsAuthenticationFailedError, @@ -29,7 +30,14 @@ from homeassistant.helpers.config_entry_oauth2_flow import ( async_get_config_entry_implementation, ) -from .const import CONF_INSTALLED_APP_ID, CONF_LOCATION_ID, DOMAIN, MAIN, OLD_DATA +from .const import ( + CONF_INSTALLED_APP_ID, + CONF_LOCATION_ID, + DOMAIN, + EVENT_BUTTON, + MAIN, + OLD_DATA, +) _LOGGER = logging.getLogger(__name__) @@ -141,6 +149,28 @@ async def async_setup_entry(hass: HomeAssistant, entry: SmartThingsConfigEntry) rooms=rooms, ) + def handle_button_press(event: DeviceEvent) -> None: + """Handle a button press.""" + if ( + event.capability is Capability.BUTTON + and event.attribute is Attribute.BUTTON + ): + hass.bus.async_fire( + EVENT_BUTTON, + { + "component_id": event.component_id, + "device_id": event.device_id, + "location_id": event.location_id, + "value": event.value, + "name": entry.runtime_data.devices[event.device_id].device.label, + "data": event.data, + }, + ) + + entry.async_on_unload( + client.add_unspecified_device_event_listener(handle_button_press) + ) + entry.async_create_background_task( hass, client.subscribe( diff --git a/homeassistant/components/smartthings/const.py b/homeassistant/components/smartthings/const.py index 23fd48a4e1e..a6d028aed06 100644 --- a/homeassistant/components/smartthings/const.py +++ b/homeassistant/components/smartthings/const.py @@ -32,3 +32,5 @@ CONF_REFRESH_TOKEN = "refresh_token" MAIN = "main" OLD_DATA = "old_data" + +EVENT_BUTTON = "smartthings.button" diff --git a/tests/components/smartthings/__init__.py b/tests/components/smartthings/__init__.py index 6939d3c5dcc..e87d1a8bcdf 100644 --- a/tests/components/smartthings/__init__.py +++ b/tests/components/smartthings/__init__.py @@ -68,6 +68,8 @@ async def trigger_update( value, data, ) + for call in mock.add_unspecified_device_event_listener.call_args_list: + call[0][0](event) for call in mock.add_device_event_listener.call_args_list: if call[0][0] == device_id: call[0][3](event) diff --git a/tests/components/smartthings/fixtures/device_status/button.json b/tests/components/smartthings/fixtures/device_status/button.json new file mode 100644 index 00000000000..93e320bcb7b --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/button.json @@ -0,0 +1,21 @@ +{ + "components": { + "main": { + "button": { + "button": { + "value": "pushed", + "timestamp": "2025-03-07T12:20:43.363Z" + }, + "numberOfButtons": { + "value": 1, + "timestamp": "2025-03-07T12:20:43.363Z" + }, + "supportedButtonValues": { + "value": ["pushed", "held", "pushed_2x"], + "timestamp": "2025-03-07T12:20:43.363Z" + } + }, + "refresh": {} + } + } +} diff --git a/tests/components/smartthings/fixtures/devices/button.json b/tests/components/smartthings/fixtures/devices/button.json new file mode 100644 index 00000000000..ba993ca6aa7 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/button.json @@ -0,0 +1,49 @@ +{ + "items": [ + { + "deviceId": "c4bdd19f-85d1-4d58-8f9c-e75ac3cf113b", + "name": "button", + "label": "button", + "manufacturerName": "SmartThingsCommunity", + "presentationId": "238c483a-10e8-359b-b032-1be2b2fcdee7", + "locationId": "88a3a314-f0c8-40b4-bb44-44ba06c9c42f", + "ownerId": "12d4af93-cb68-b108-87f5-625437d7371f", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "button", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2025-03-07T12:20:43.273Z", + "profile": { + "id": "b045d731-4d01-35bc-8018-b3da711d8904" + }, + "virtual": { + "name": "button", + "executingLocally": false + }, + "type": "VIRTUAL", + "restrictionTier": 0, + "allowed": null, + "executionContext": "CLOUD", + "relationships": [] + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/snapshots/test_init.ambr b/tests/components/smartthings/snapshots/test_init.ambr index 1918f19911a..5beaf907b70 100644 --- a/tests/components/smartthings/snapshots/test_init.ambr +++ b/tests/components/smartthings/snapshots/test_init.ambr @@ -1,4 +1,7 @@ # serializer version: 1 +# name: test_button_event[button] + +# --- # name: test_devices[aeotec_home_energy_meter_gen5] DeviceRegistryEntrySnapshot({ 'area_id': 'toilet', diff --git a/tests/components/smartthings/test_init.py b/tests/components/smartthings/test_init.py index 3ffe2c11a42..e3d865fc5c8 100644 --- a/tests/components/smartthings/test_init.py +++ b/tests/components/smartthings/test_init.py @@ -2,15 +2,16 @@ from unittest.mock import AsyncMock -from pysmartthings import DeviceResponse, DeviceStatus +from pysmartthings import Attribute, Capability, DeviceResponse, DeviceStatus import pytest from syrupy import SnapshotAssertion +from homeassistant.components.smartthings import EVENT_BUTTON from homeassistant.components.smartthings.const import DOMAIN -from homeassistant.core import HomeAssistant +from homeassistant.core import Event, HomeAssistant from homeassistant.helpers import device_registry as dr -from . import setup_integration +from . import setup_integration, trigger_update from tests.common import MockConfigEntry, load_fixture @@ -33,6 +34,35 @@ async def test_devices( assert device == snapshot +@pytest.mark.parametrize("device_fixture", ["button"]) +async def test_button_event( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test button event.""" + await setup_integration(hass, mock_config_entry) + events = [] + + def capture_event(event: Event) -> None: + events.append(event) + + hass.bus.async_listen_once(EVENT_BUTTON, capture_event) + + await trigger_update( + hass, + devices, + "c4bdd19f-85d1-4d58-8f9c-e75ac3cf113b", + Capability.BUTTON, + Attribute.BUTTON, + "pushed", + ) + + assert len(events) == 1 + assert events[0] == snapshot + + @pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) async def test_removing_stale_devices( hass: HomeAssistant, From 35c1bb1ec5a11e4decaab5c6c23bab71ab670315 Mon Sep 17 00:00:00 2001 From: Ishima Date: Thu, 6 Mar 2025 13:42:23 +0100 Subject: [PATCH 2320/3148] Check support for demand load control in SmartThings AC (#139616) * Check support for demand load control in SmartThings AC * Fix --------- Co-authored-by: Joostlek --- .../components/smartthings/climate.py | 5 +- tests/components/smartthings/conftest.py | 1 + .../device_status/da_ac_rac_100001.json | 167 ++++++++++++++ .../fixtures/devices/da_ac_rac_100001.json | 112 ++++++++++ .../smartthings/snapshots/test_climate.ambr | 84 +++++++ .../smartthings/snapshots/test_init.ambr | 33 +++ .../smartthings/snapshots/test_sensor.ambr | 207 ++++++++++++++++++ 7 files changed, 608 insertions(+), 1 deletion(-) create mode 100644 tests/components/smartthings/fixtures/device_status/da_ac_rac_100001.json create mode 100644 tests/components/smartthings/fixtures/devices/da_ac_rac_100001.json diff --git a/homeassistant/components/smartthings/climate.py b/homeassistant/components/smartthings/climate.py index 531b431f913..ac2883df7ff 100644 --- a/homeassistant/components/smartthings/climate.py +++ b/homeassistant/components/smartthings/climate.py @@ -445,12 +445,15 @@ class SmartThingsAirConditioner(SmartThingsEntity, ClimateEntity): ) @property - def extra_state_attributes(self) -> dict[str, Any]: + def extra_state_attributes(self) -> dict[str, Any] | None: """Return device specific state attributes. Include attributes from the Demand Response Load Control (drlc) and Power Consumption capabilities. """ + if not self.supports_capability(Capability.DEMAND_RESPONSE_LOAD_CONTROL): + return None + drlc_status = self.get_attribute_value( Capability.DEMAND_RESPONSE_LOAD_CONTROL, Attribute.DEMAND_RESPONSE_LOAD_CONTROL_STATUS, diff --git a/tests/components/smartthings/conftest.py b/tests/components/smartthings/conftest.py index b7d0cb61607..4144cf8bcbc 100644 --- a/tests/components/smartthings/conftest.py +++ b/tests/components/smartthings/conftest.py @@ -84,6 +84,7 @@ def mock_smartthings() -> Generator[AsyncMock]: @pytest.fixture( params=[ "da_ac_rac_000001", + "da_ac_rac_100001", "da_ac_rac_01001", "multipurpose_sensor", "contact_sensor", diff --git a/tests/components/smartthings/fixtures/device_status/da_ac_rac_100001.json b/tests/components/smartthings/fixtures/device_status/da_ac_rac_100001.json new file mode 100644 index 00000000000..305624e5b3b --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/da_ac_rac_100001.json @@ -0,0 +1,167 @@ +{ + "components": { + "main": { + "refresh": {}, + "custom.thermostatSetpointControl": { + "minimumSetpoint": { + "value": 16, + "timestamp": "2024-11-25T22:17:38.251Z" + }, + "maximumSetpoint": { + "value": 30, + "timestamp": "2024-11-25T22:17:38.251Z" + } + }, + "airConditionerMode": { + "availableAcModes": { + "value": null + }, + "supportedAcModes": { + "value": ["cool", "dry", "wind", "auto"], + "timestamp": "2025-03-02T10:16:19.519Z" + }, + "airConditionerMode": { + "value": "cool", + "timestamp": "2025-03-02T10:16:19.519Z" + } + }, + "execute": { + "data": { + "value": null + } + }, + "airQualitySensor": { + "airQuality": { + "value": null + } + }, + "switch": { + "switch": { + "value": "off", + "timestamp": "2025-03-02T06:54:52.852Z" + } + }, + "ocf": { + "st": { + "value": null + }, + "mndt": { + "value": null + }, + "mnfv": { + "value": null + }, + "mnhw": { + "value": null + }, + "di": { + "value": "F8042E25-0E53-0000-0000-000000000000", + "timestamp": "2025-02-28T21:15:28.920Z" + }, + "mnsl": { + "value": null + }, + "dmv": { + "value": "res.1.1.0,sh.1.1.0", + "timestamp": "2025-02-28T21:15:28.920Z" + }, + "n": { + "value": "Room A/C", + "timestamp": "2025-02-28T21:15:28.920Z" + }, + "mnmo": { + "value": "TP6X_RAC_15K", + "timestamp": "2025-02-28T21:15:28.920Z" + }, + "vid": { + "value": "DA-AC-RAC-100001", + "timestamp": "2025-02-28T21:15:28.920Z" + }, + "mnmn": { + "value": "Samsung Electronics", + "timestamp": "2025-02-28T21:15:28.920Z" + }, + "mnml": { + "value": null + }, + "mnpv": { + "value": null + }, + "mnos": { + "value": null + }, + "pi": { + "value": "shp", + "timestamp": "2025-02-28T21:15:28.920Z" + }, + "icv": { + "value": "core.1.1.0", + "timestamp": "2025-02-28T21:15:28.920Z" + } + }, + "odorSensor": { + "odorLevel": { + "value": null + } + }, + "airConditionerFanMode": { + "fanMode": { + "value": "auto", + "timestamp": "2025-02-28T21:15:28.941Z" + }, + "supportedAcFanModes": { + "value": ["auto", "low", "medium", "high", "turbo"], + "timestamp": "2025-02-28T21:15:28.941Z" + }, + "availableAcFanModes": { + "value": null + } + }, + "samsungce.driverState": { + "driverState": { + "value": null + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": ["odorSensor"], + "timestamp": "2024-11-25T22:17:38.251Z" + } + }, + "samsungce.driverVersion": { + "versionNumber": { + "value": 22090101, + "timestamp": "2024-11-25T22:17:38.251Z" + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": 27, + "unit": "C", + "timestamp": "2025-03-02T08:28:39.409Z" + } + }, + "dustSensor": { + "dustLevel": { + "value": null + }, + "fineDustLevel": { + "value": null + } + }, + "thermostatCoolingSetpoint": { + "coolingSetpointRange": { + "value": null + }, + "coolingSetpoint": { + "value": 18, + "unit": "C", + "timestamp": "2025-03-02T06:54:23.887Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/devices/da_ac_rac_100001.json b/tests/components/smartthings/fixtures/devices/da_ac_rac_100001.json new file mode 100644 index 00000000000..3938ffc9d9b --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/da_ac_rac_100001.json @@ -0,0 +1,112 @@ +{ + "items": [ + { + "deviceId": "F8042E25-0E53-0000-0000-000000000000", + "name": "Room A/C", + "label": "Corridor A/C", + "manufacturerName": "Samsung Electronics", + "presentationId": "DA-AC-RAC-100001", + "deviceManufacturerCode": "Samsung Electronics", + "locationId": "5df0730b-38ed-43e4-b291-ec14feb3224c", + "ownerId": "63b9c79b-90fe-5262-9a6a-5e24db90915e", + "roomId": "7715151d-0314-457a-a82c-5ce48900e065", + "deviceTypeName": "Samsung OCF Air Conditioner", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "ocf", + "version": 1 + }, + { + "id": "switch", + "version": 1 + }, + { + "id": "airConditionerMode", + "version": 1 + }, + { + "id": "airConditionerFanMode", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "thermostatCoolingSetpoint", + "version": 1 + }, + { + "id": "airQualitySensor", + "version": 1 + }, + { + "id": "dustSensor", + "version": 1 + }, + { + "id": "odorSensor", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "execute", + "version": 1 + }, + { + "id": "samsungce.driverVersion", + "version": 1 + }, + { + "id": "samsungce.driverState", + "version": 1 + }, + { + "id": "custom.thermostatSetpointControl", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + } + ], + "categories": [ + { + "name": "AirConditioner", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2024-11-25T22:17:38.129Z", + "profile": { + "id": "9e3e03b1-7f8c-3ea2-8568-6902b79b99dd" + }, + "ocf": { + "ocfDeviceType": "oic.d.airconditioner", + "name": "Room A/C", + "specVersion": "core.1.1.0", + "verticalDomainSpecVersion": "res.1.1.0,sh.1.1.0", + "manufacturerName": "Samsung Electronics", + "modelNumber": "TP6X_RAC_15K", + "vendorId": "DA-AC-RAC-100001", + "lastSignupTime": "2024-11-25T22:17:37.928118320Z", + "transferCandidate": false, + "additionalAuthCodeRequired": false + }, + "type": "OCF", + "restrictionTier": 0, + "allowed": [], + "executionContext": "CLOUD", + "relationships": [] + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/snapshots/test_climate.ambr b/tests/components/smartthings/snapshots/test_climate.ambr index ba32776011a..08ddacf45c6 100644 --- a/tests/components/smartthings/snapshots/test_climate.ambr +++ b/tests/components/smartthings/snapshots/test_climate.ambr @@ -209,6 +209,90 @@ 'state': 'off', }) # --- +# name: test_all_entities[da_ac_rac_100001][climate.corridor_a_c-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'fan_modes': list([ + 'auto', + 'low', + 'medium', + 'high', + 'turbo', + ]), + 'hvac_modes': list([ + , + , + , + , + , + ]), + 'max_temp': 35, + 'min_temp': 7, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.corridor_a_c', + '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': None, + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'F8042E25-0E53-0000-0000-000000000000', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ac_rac_100001][climate.corridor_a_c-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 27, + 'fan_mode': 'auto', + 'fan_modes': list([ + 'auto', + 'low', + 'medium', + 'high', + 'turbo', + ]), + 'friendly_name': 'Corridor A/C', + 'hvac_modes': list([ + , + , + , + , + , + ]), + 'max_temp': 35, + 'min_temp': 7, + 'supported_features': , + 'temperature': 18, + }), + 'context': , + 'entity_id': 'climate.corridor_a_c', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_all_entities[ecobee_thermostat][climate.main_floor-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/smartthings/snapshots/test_init.ambr b/tests/components/smartthings/snapshots/test_init.ambr index e0d93553121..ed4c39cf320 100644 --- a/tests/components/smartthings/snapshots/test_init.ambr +++ b/tests/components/smartthings/snapshots/test_init.ambr @@ -263,6 +263,39 @@ 'via_device_id': None, }) # --- +# name: test_devices[da_ac_rac_100001] + DeviceRegistryEntrySnapshot({ + 'area_id': 'theater', + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + 'F8042E25-0E53-0000-0000-000000000000', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Samsung Electronics', + 'model': 'TP6X_RAC_15K', + 'model_id': None, + 'name': 'Corridor A/C', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': 'Theater', + 'sw_version': None, + 'via_device_id': None, + }) +# --- # name: test_devices[da_ks_microwave_0101x] DeviceRegistryEntrySnapshot({ 'area_id': None, diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index 78aa4db62f8..ba2a21fe86b 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -1379,6 +1379,213 @@ 'state': '0', }) # --- +# name: test_all_entities[da_ac_rac_100001][sensor.corridor_a_c_air_quality-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.corridor_a_c_air_quality', + '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': 'Air quality', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'air_quality', + 'unique_id': 'F8042E25-0E53-0000-0000-000000000000.airQuality', + 'unit_of_measurement': 'CAQI', + }) +# --- +# name: test_all_entities[da_ac_rac_100001][sensor.corridor_a_c_air_quality-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Corridor A/C Air quality', + 'state_class': , + 'unit_of_measurement': 'CAQI', + }), + 'context': , + 'entity_id': 'sensor.corridor_a_c_air_quality', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[da_ac_rac_100001][sensor.corridor_a_c_pm10-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.corridor_a_c_pm10', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'PM10', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'F8042E25-0E53-0000-0000-000000000000.dustLevel', + 'unit_of_measurement': 'µg/m³', + }) +# --- +# name: test_all_entities[da_ac_rac_100001][sensor.corridor_a_c_pm10-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pm10', + 'friendly_name': 'Corridor A/C PM10', + 'state_class': , + 'unit_of_measurement': 'µg/m³', + }), + 'context': , + 'entity_id': 'sensor.corridor_a_c_pm10', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[da_ac_rac_100001][sensor.corridor_a_c_pm2_5-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.corridor_a_c_pm2_5', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'PM2.5', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'F8042E25-0E53-0000-0000-000000000000.fineDustLevel', + 'unit_of_measurement': 'µg/m³', + }) +# --- +# name: test_all_entities[da_ac_rac_100001][sensor.corridor_a_c_pm2_5-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pm25', + 'friendly_name': 'Corridor A/C PM2.5', + 'state_class': , + 'unit_of_measurement': 'µg/m³', + }), + 'context': , + 'entity_id': 'sensor.corridor_a_c_pm2_5', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[da_ac_rac_100001][sensor.corridor_a_c_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.corridor_a_c_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': 'Temperature', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'F8042E25-0E53-0000-0000-000000000000.temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ac_rac_100001][sensor.corridor_a_c_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Corridor A/C Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.corridor_a_c_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '27', + }) +# --- # name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_completion_time-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 3af6b5cb4ca78c4c055427868aa5dddf29edb1be Mon Sep 17 00:00:00 2001 From: peteS-UK <64092177+peteS-UK@users.noreply.github.com> Date: Fri, 7 Mar 2025 12:53:24 +0000 Subject: [PATCH 2321/3148] Fix Unit of Measurement for Squeezebox duration sensor entity on LMS service (#139861) UOM Fix --- homeassistant/components/squeezebox/sensor.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/squeezebox/sensor.py b/homeassistant/components/squeezebox/sensor.py index c0a7a37d539..9d9490208ea 100644 --- a/homeassistant/components/squeezebox/sensor.py +++ b/homeassistant/components/squeezebox/sensor.py @@ -43,6 +43,7 @@ SENSORS: tuple[SensorEntityDescription, ...] = ( state_class=SensorStateClass.TOTAL, device_class=SensorDeviceClass.DURATION, native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.HOURS, ), SensorEntityDescription( key=STATUS_SENSOR_INFO_TOTAL_GENRES, From 02706c116d6caeb668c950f0380bb324dc2702ac Mon Sep 17 00:00:00 2001 From: Ivan Lopez Hernandez Date: Wed, 5 Mar 2025 18:34:11 -0800 Subject: [PATCH 2322/3148] Trim the Schema allowed keys to match the Public Gemini API docs. (#139876) * Trim the Schema allowed types to match the Public API docs, not the SDK types as those do not match * Testing --- .../conversation.py | 30 +++------ .../snapshots/test_conversation.ambr | 2 +- .../test_conversation.py | 64 ++++++++++++++----- 3 files changed, 58 insertions(+), 38 deletions(-) diff --git a/homeassistant/components/google_generative_ai_conversation/conversation.py b/homeassistant/components/google_generative_ai_conversation/conversation.py index 2c84249dcb3..168e867d857 100644 --- a/homeassistant/components/google_generative_ai_conversation/conversation.py +++ b/homeassistant/components/google_generative_ai_conversation/conversation.py @@ -64,28 +64,18 @@ async def async_setup_entry( SUPPORTED_SCHEMA_KEYS = { - "min_items", - "example", - "property_ordering", - "pattern", - "minimum", - "default", - "any_of", - "max_length", - "title", - "min_properties", - "min_length", - "max_items", - "maximum", - "nullable", - "max_properties", + # Gemini API does not support all of the OpenAPI schema + # SoT: https://ai.google.dev/api/caching#Schema "type", - "description", - "enum", "format", - "items", + "description", + "nullable", + "enum", + "max_items", + "min_items", "properties", "required", + "items", } @@ -109,9 +99,7 @@ def _format_schema(schema: dict[str, Any]) -> Schema: key = _camel_to_snake(key) if key not in SUPPORTED_SCHEMA_KEYS: continue - if key == "any_of": - val = [_format_schema(subschema) for subschema in val] - elif key == "type": + if key == "type": val = val.upper() elif key == "format": # Gemini API does not support all formats, see: https://ai.google.dev/api/caching#Schema diff --git a/tests/components/google_generative_ai_conversation/snapshots/test_conversation.ambr b/tests/components/google_generative_ai_conversation/snapshots/test_conversation.ambr index 106366fd240..c840f7da324 100644 --- a/tests/components/google_generative_ai_conversation/snapshots/test_conversation.ambr +++ b/tests/components/google_generative_ai_conversation/snapshots/test_conversation.ambr @@ -6,7 +6,7 @@ tuple( ), dict({ - 'config': GenerateContentConfig(http_options=None, system_instruction="Current time is 05:00:00. Today's date is 2024-05-24.\nYou are a voice assistant for Home Assistant.\nAnswer questions about the world truthfully.\nAnswer in plain text. Keep it simple and to the point.\nOnly if the user wants to control a device, tell them to expose entities to their voice assistant in Home Assistant.", temperature=1.0, top_p=0.95, top_k=64.0, candidate_count=None, max_output_tokens=150, stop_sequences=None, response_logprobs=None, logprobs=None, presence_penalty=None, frequency_penalty=None, seed=None, response_mime_type=None, response_schema=None, routing_config=None, safety_settings=[SafetySetting(method=None, category=, threshold=), SafetySetting(method=None, category=, threshold=), SafetySetting(method=None, category=, threshold=), SafetySetting(method=None, category=, threshold=)], tools=[Tool(function_declarations=[FunctionDeclaration(response=None, description='Test function', name='test_tool', parameters=Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=None, max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=, description=None, enum=None, format=None, items=None, properties={'param1': Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=None, max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=, description='Test parameters', enum=None, format=None, items=Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=None, max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=, description=None, enum=None, format=None, items=None, properties=None, required=None), properties=None, required=None), 'param2': Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=[Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=None, max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=, description=None, enum=None, format=None, items=None, properties=None, required=None), Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=None, max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=, description=None, enum=None, format=None, items=None, properties=None, required=None)], max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=None, description=None, enum=None, format=None, items=None, properties=None, required=None), 'param3': Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=None, max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=, description=None, enum=None, format=None, items=None, properties={'json': Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=None, max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=, description=None, enum=None, format=None, items=None, properties=None, required=None)}, required=[])}, required=[]))], retrieval=None, google_search=None, google_search_retrieval=None, code_execution=None)], tool_config=None, labels=None, cached_content=None, response_modalities=None, media_resolution=None, speech_config=None, audio_timestamp=None, automatic_function_calling=AutomaticFunctionCallingConfig(disable=True, maximum_remote_calls=None, ignore_call_history=None), thinking_config=None), + 'config': GenerateContentConfig(http_options=None, system_instruction="Current time is 05:00:00. Today's date is 2024-05-24.\nYou are a voice assistant for Home Assistant.\nAnswer questions about the world truthfully.\nAnswer in plain text. Keep it simple and to the point.\nOnly if the user wants to control a device, tell them to expose entities to their voice assistant in Home Assistant.", temperature=1.0, top_p=0.95, top_k=64.0, candidate_count=None, max_output_tokens=150, stop_sequences=None, response_logprobs=None, logprobs=None, presence_penalty=None, frequency_penalty=None, seed=None, response_mime_type=None, response_schema=None, routing_config=None, safety_settings=[SafetySetting(method=None, category=, threshold=), SafetySetting(method=None, category=, threshold=), SafetySetting(method=None, category=, threshold=), SafetySetting(method=None, category=, threshold=)], tools=[Tool(function_declarations=[FunctionDeclaration(response=None, description='Test function', name='test_tool', parameters=Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=None, max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=, description=None, enum=None, format=None, items=None, properties={'param1': Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=None, max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=, description='Test parameters', enum=None, format=None, items=Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=None, max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=, description=None, enum=None, format=None, items=None, properties=None, required=None), properties=None, required=None), 'param2': Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=None, max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=None, description=None, enum=None, format=None, items=None, properties=None, required=None), 'param3': Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=None, max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=, description=None, enum=None, format=None, items=None, properties={'json': Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=None, max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=, description=None, enum=None, format=None, items=None, properties=None, required=None)}, required=[])}, required=[]))], retrieval=None, google_search=None, google_search_retrieval=None, code_execution=None)], tool_config=None, labels=None, cached_content=None, response_modalities=None, media_resolution=None, speech_config=None, audio_timestamp=None, automatic_function_calling=AutomaticFunctionCallingConfig(disable=True, maximum_remote_calls=None, ignore_call_history=None), thinking_config=None), 'history': list([ ]), 'model': 'models/gemini-2.0-flash', diff --git a/tests/components/google_generative_ai_conversation/test_conversation.py b/tests/components/google_generative_ai_conversation/test_conversation.py index 5e887d3cab7..64f71c18bf2 100644 --- a/tests/components/google_generative_ai_conversation/test_conversation.py +++ b/tests/components/google_generative_ai_conversation/test_conversation.py @@ -493,6 +493,26 @@ async def test_escape_decode() -> None: {"type": "string", "enum": ["a", "b", "c"]}, {"type": "STRING", "enum": ["a", "b", "c"]}, ), + ( + {"type": "string", "default": "default"}, + {"type": "STRING"}, + ), + ( + {"type": "string", "pattern": "default"}, + {"type": "STRING"}, + ), + ( + {"type": "string", "maxLength": 10}, + {"type": "STRING"}, + ), + ( + {"type": "string", "minLength": 10}, + {"type": "STRING"}, + ), + ( + {"type": "string", "title": "title"}, + {"type": "STRING"}, + ), ( {"type": "string", "format": "enum", "enum": ["a", "b", "c"]}, {"type": "STRING", "format": "enum", "enum": ["a", "b", "c"]}, @@ -517,6 +537,10 @@ async def test_escape_decode() -> None: {"type": "number", "format": "hex"}, {"type": "NUMBER"}, ), + ( + {"type": "number", "minimum": 1}, + {"type": "NUMBER"}, + ), ( {"type": "integer", "format": "int32"}, {"type": "INTEGER", "format": "int32"}, @@ -535,21 +559,7 @@ async def test_escape_decode() -> None: ), ( {"anyOf": [{"type": "integer"}, {"type": "number"}]}, - {"any_of": [{"type": "INTEGER"}, {"type": "NUMBER"}]}, - ), - ( - { - "any_of": [ - {"any_of": [{"type": "integer"}, {"type": "number"}]}, - {"any_of": [{"type": "integer"}, {"type": "number"}]}, - ] - }, - { - "any_of": [ - {"any_of": [{"type": "INTEGER"}, {"type": "NUMBER"}]}, - {"any_of": [{"type": "INTEGER"}, {"type": "NUMBER"}]}, - ] - }, + {}, ), ({"type": "string", "format": "lower"}, {"type": "STRING"}), ({"type": "boolean", "format": "bool"}, {"type": "BOOLEAN"}), @@ -570,7 +580,15 @@ async def test_escape_decode() -> None: }, ), ( - {"type": "object", "additionalProperties": True}, + {"type": "object", "additionalProperties": True, "minProperties": 1}, + { + "type": "OBJECT", + "properties": {"json": {"type": "STRING"}}, + "required": [], + }, + ), + ( + {"type": "object", "additionalProperties": True, "maxProperties": 1}, { "type": "OBJECT", "properties": {"json": {"type": "STRING"}}, @@ -581,6 +599,20 @@ async def test_escape_decode() -> None: {"type": "array", "items": {"type": "string"}}, {"type": "ARRAY", "items": {"type": "STRING"}}, ), + ( + { + "type": "array", + "items": {"type": "string"}, + "minItems": 1, + "maxItems": 2, + }, + { + "type": "ARRAY", + "items": {"type": "STRING"}, + "min_items": 1, + "max_items": 2, + }, + ), ], ) async def test_format_schema(openapi, genai_schema) -> None: From e909417a3ffe0d9198a5f974fc93c74115c3a0a1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 6 Mar 2025 00:24:56 -1000 Subject: [PATCH 2323/3148] Bump thermobeacon-ble to 0.8.1 (#139919) changelog: https://github.com/Bluetooth-Devices/thermobeacon-ble/compare/v0.8.0...v0.8.1 fixes #139917 --- homeassistant/components/thermobeacon/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/thermobeacon/manifest.json b/homeassistant/components/thermobeacon/manifest.json index e060cbd91bf..b231137d335 100644 --- a/homeassistant/components/thermobeacon/manifest.json +++ b/homeassistant/components/thermobeacon/manifest.json @@ -54,5 +54,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/thermobeacon", "iot_class": "local_push", - "requirements": ["thermobeacon-ble==0.8.0"] + "requirements": ["thermobeacon-ble==0.8.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index c0cea94142b..54d46d9aa2e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2890,7 +2890,7 @@ tessie-api==0.1.1 # tf-models-official==2.5.0 # homeassistant.components.thermobeacon -thermobeacon-ble==0.8.0 +thermobeacon-ble==0.8.1 # homeassistant.components.thermopro thermopro-ble==0.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 82e49f43bda..f6cc0b356c9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2327,7 +2327,7 @@ teslemetry-stream==0.6.10 tessie-api==0.1.1 # homeassistant.components.thermobeacon -thermobeacon-ble==0.8.0 +thermobeacon-ble==0.8.1 # homeassistant.components.thermopro thermopro-ble==0.11.0 From 1304194f097c433e00d7629568cbcb3098ea3ea7 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 6 Mar 2025 12:20:53 +0100 Subject: [PATCH 2324/3148] Deduplicate climate modes in SmartThings (#139930) * Deduplicate climate modes in SmartThings * Deduplicate climate modes in SmartThings --- homeassistant/components/smartthings/climate.py | 1 + .../smartthings/fixtures/device_status/da_ac_rac_01001.json | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/smartthings/climate.py b/homeassistant/components/smartthings/climate.py index ac2883df7ff..9dc0fbb9f08 100644 --- a/homeassistant/components/smartthings/climate.py +++ b/homeassistant/components/smartthings/climate.py @@ -563,5 +563,6 @@ class SmartThingsAirConditioner(SmartThingsEntity, ClimateEntity): Capability.AIR_CONDITIONER_MODE, Attribute.SUPPORTED_AC_MODES ) if (state := AC_MODE_TO_STATE.get(mode)) is not None + if state not in modes ) return modes diff --git a/tests/components/smartthings/fixtures/device_status/da_ac_rac_01001.json b/tests/components/smartthings/fixtures/device_status/da_ac_rac_01001.json index 257d553cb9f..e8e71c53ace 100644 --- a/tests/components/smartthings/fixtures/device_status/da_ac_rac_01001.json +++ b/tests/components/smartthings/fixtures/device_status/da_ac_rac_01001.json @@ -32,7 +32,7 @@ "timestamp": "2025-02-09T14:35:56.800Z" }, "supportedAcModes": { - "value": ["auto", "cool", "dry", "wind", "heat"], + "value": ["auto", "cool", "dry", "wind", "heat", "dryClean"], "timestamp": "2025-02-09T15:42:13.444Z" }, "airConditionerMode": { From af9bbd058503f65428804185a3fcc3d43d8460e4 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 6 Mar 2025 18:52:46 +0100 Subject: [PATCH 2325/3148] Check if the unit of measurement is valid before creating the entity (#139932) --- homeassistant/components/mqtt/sensor.py | 15 ++++++++++++++ tests/components/mqtt/test_sensor.py | 26 +++++++++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/homeassistant/components/mqtt/sensor.py b/homeassistant/components/mqtt/sensor.py index 3e8a4fef0fa..432431c96d9 100644 --- a/homeassistant/components/mqtt/sensor.py +++ b/homeassistant/components/mqtt/sensor.py @@ -11,6 +11,7 @@ import voluptuous as vol from homeassistant.components import sensor from homeassistant.components.sensor import ( CONF_STATE_CLASS, + DEVICE_CLASS_UNITS, DEVICE_CLASSES_SCHEMA, ENTITY_ID_FORMAT, STATE_CLASSES_SCHEMA, @@ -107,6 +108,20 @@ def validate_sensor_state_and_device_class_config(config: ConfigType) -> ConfigT f"got `{CONF_DEVICE_CLASS}` '{device_class}'" ) + if (device_class := config.get(CONF_DEVICE_CLASS)) is None or ( + unit_of_measurement := config.get(CONF_UNIT_OF_MEASUREMENT) + ) is None: + return config + + if ( + device_class in DEVICE_CLASS_UNITS + and unit_of_measurement not in DEVICE_CLASS_UNITS[device_class] + ): + raise vol.Invalid( + f"The unit of measurement `{unit_of_measurement}` is not valid " + f"together with device class `{device_class}`" + ) + return config diff --git a/tests/components/mqtt/test_sensor.py b/tests/components/mqtt/test_sensor.py index 9226b03a7d2..f40082d84be 100644 --- a/tests/components/mqtt/test_sensor.py +++ b/tests/components/mqtt/test_sensor.py @@ -870,6 +870,32 @@ async def test_invalid_device_class( assert "expected SensorDeviceClass or one of" in caplog.text +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + sensor.DOMAIN: { + "name": "test", + "state_topic": "test-topic", + "device_class": "energy", + "unit_of_measurement": "ppm", + } + } + } + ], +) +async def test_invalid_unit_of_measurement( + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture +) -> None: + """Test device_class with invalid unit of measurement.""" + assert await mqtt_mock_entry() + assert ( + "The unit of measurement `ppm` is not valid together with device class `energy`" + in caplog.text + ) + + @pytest.mark.parametrize( "hass_config", [ From a279e23fb5740300a586189406a306efa5a00867 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 6 Mar 2025 13:22:49 +0100 Subject: [PATCH 2326/3148] Bump pysmartthings to 2.6.1 (#139936) * Bump pysmartthings to 2.6.1 * Bump pysmartthings to 2.6.1 --- homeassistant/components/smartthings/entity.py | 4 +++- homeassistant/components/smartthings/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../fixtures/devices/da_ac_rac_000001.json | 14 +++----------- .../smartthings/snapshots/test_init.ambr | 10 +++++----- 6 files changed, 14 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/smartthings/entity.py b/homeassistant/components/smartthings/entity.py index f86f3a68f0e..5a2ce560f75 100644 --- a/homeassistant/components/smartthings/entity.py +++ b/homeassistant/components/smartthings/entity.py @@ -48,7 +48,9 @@ class SmartThingsEntity(Entity): self._attr_device_info.update( { "manufacturer": ocf.manufacturer_name, - "model": ocf.model_number.split("|")[0], + "model": ( + (ocf.model_number.split("|")[0]) if ocf.model_number else None + ), "hw_version": ocf.hardware_version, "sw_version": ocf.firmware_version, } diff --git a/homeassistant/components/smartthings/manifest.json b/homeassistant/components/smartthings/manifest.json index 22926e70ba0..9efa8b81186 100644 --- a/homeassistant/components/smartthings/manifest.json +++ b/homeassistant/components/smartthings/manifest.json @@ -29,5 +29,5 @@ "documentation": "https://www.home-assistant.io/integrations/smartthings", "iot_class": "cloud_push", "loggers": ["pysmartthings"], - "requirements": ["pysmartthings==2.5.0"] + "requirements": ["pysmartthings==2.6.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 54d46d9aa2e..90b98c6e71e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2310,7 +2310,7 @@ pysma==0.7.5 pysmappee==0.2.29 # homeassistant.components.smartthings -pysmartthings==2.5.0 +pysmartthings==2.6.1 # homeassistant.components.smarty pysmarty2==0.10.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f6cc0b356c9..2906ff81b9e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1882,7 +1882,7 @@ pysma==0.7.5 pysmappee==0.2.29 # homeassistant.components.smartthings -pysmartthings==2.5.0 +pysmartthings==2.6.1 # homeassistant.components.smarty pysmarty2==0.10.2 diff --git a/tests/components/smartthings/fixtures/devices/da_ac_rac_000001.json b/tests/components/smartthings/fixtures/devices/da_ac_rac_000001.json index ec7f16b090a..4f6faeddb09 100644 --- a/tests/components/smartthings/fixtures/devices/da_ac_rac_000001.json +++ b/tests/components/smartthings/fixtures/devices/da_ac_rac_000001.json @@ -286,18 +286,10 @@ "id": "60fbc713-8da5-315d-b31a-6d6dcde4be7b" }, "ocf": { - "ocfDeviceType": "oic.d.airconditioner", - "name": "[room a/c] Samsung", - "specVersion": "core.1.1.0", - "verticalDomainSpecVersion": "res.1.1.0,sh.1.1.0", + "ocfDeviceType": "x.com.st.d.sensor.light", "manufacturerName": "Samsung Electronics", - "modelNumber": "ARTIK051_KRAC_18K|10193441|60010132001111110200000000000000", - "platformVersion": "0G3MPDCKA00010E", - "platformOS": "TizenRT2.0", - "hwVersion": "1.0", - "firmwareVersion": "0.1.0", - "vendorId": "DA-AC-RAC-000001", - "lastSignupTime": "2021-04-06T16:43:27.889445Z", + "vendorId": "VD-Sensor.Light-2023", + "lastSignupTime": "2025-01-08T02:32:04.631093137Z", "transferCandidate": false, "additionalAuthCodeRequired": false }, diff --git a/tests/components/smartthings/snapshots/test_init.ambr b/tests/components/smartthings/snapshots/test_init.ambr index ed4c39cf320..3fb4f6e6bd3 100644 --- a/tests/components/smartthings/snapshots/test_init.ambr +++ b/tests/components/smartthings/snapshots/test_init.ambr @@ -207,7 +207,7 @@ }), 'disabled_by': None, 'entry_type': None, - 'hw_version': '1.0', + 'hw_version': None, 'id': , 'identifiers': set({ tuple( @@ -219,14 +219,14 @@ 'labels': set({ }), 'manufacturer': 'Samsung Electronics', - 'model': 'ARTIK051_KRAC_18K', + 'model': None, 'model_id': None, 'name': 'AC Office Granit', 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, 'suggested_area': None, - 'sw_version': '0.1.0', + 'sw_version': None, 'via_device_id': None, }) # --- @@ -265,7 +265,7 @@ # --- # name: test_devices[da_ac_rac_100001] DeviceRegistryEntrySnapshot({ - 'area_id': 'theater', + 'area_id': None, 'config_entries': , 'config_entries_subentries': , 'configuration_url': 'https://account.smartthings.com', @@ -291,7 +291,7 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': 'Theater', + 'suggested_area': None, 'sw_version': None, 'via_device_id': None, }) From 844adfc59078ef47116b6a949809d56c51797639 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Thu, 6 Mar 2025 13:30:02 +0100 Subject: [PATCH 2327/3148] Bump aiowebdav2 to 0.4.0 (#139938) --- homeassistant/components/webdav/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/webdav/manifest.json b/homeassistant/components/webdav/manifest.json index b4950bc23f3..3f465ceed4a 100644 --- a/homeassistant/components/webdav/manifest.json +++ b/homeassistant/components/webdav/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["aiowebdav2"], "quality_scale": "bronze", - "requirements": ["aiowebdav2==0.3.1"] + "requirements": ["aiowebdav2==0.4.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 90b98c6e71e..592dc394655 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -422,7 +422,7 @@ aiowaqi==3.1.0 aiowatttime==0.1.1 # homeassistant.components.webdav -aiowebdav2==0.3.1 +aiowebdav2==0.4.0 # homeassistant.components.webostv aiowebostv==0.7.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2906ff81b9e..e58596173bc 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -404,7 +404,7 @@ aiowaqi==3.1.0 aiowatttime==0.1.1 # homeassistant.components.webdav -aiowebdav2==0.3.1 +aiowebdav2==0.4.0 # homeassistant.components.webostv aiowebostv==0.7.3 From 3a8c8accfe27fa3135d1de698e8ad655d9d7200a Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 6 Mar 2025 18:48:39 +0100 Subject: [PATCH 2328/3148] Add config entry level diagnostics to SmartThings (#139939) * Add config entry level diagnostics to SmartThings * Add config entry level diagnostics to SmartThings * Add config entry level diagnostics to SmartThings --- .../components/smartthings/diagnostics.py | 25 +- .../snapshots/test_diagnostics.ambr | 2561 ++++++++++------- .../smartthings/test_diagnostics.py | 39 +- 3 files changed, 1513 insertions(+), 1112 deletions(-) diff --git a/homeassistant/components/smartthings/diagnostics.py b/homeassistant/components/smartthings/diagnostics.py index fc34415e419..dbc5d4e8224 100644 --- a/homeassistant/components/smartthings/diagnostics.py +++ b/homeassistant/components/smartthings/diagnostics.py @@ -17,6 +17,15 @@ from .const import DOMAIN EVENT_WAIT_TIME = 5 +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, + entry: SmartThingsConfigEntry, +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + client = entry.runtime_data.client + return await client.get_raw_devices() + + async def async_get_device_diagnostics( hass: HomeAssistant, entry: SmartThingsConfigEntry, device: DeviceEntry ) -> dict[str, Any]: @@ -26,7 +35,8 @@ async def async_get_device_diagnostics( identifier for identifier in device.identifiers if identifier[0] == DOMAIN )[1] - device_status = await client.get_device_status(device_id) + device_status = await client.get_raw_device_status(device_id) + device_info = await client.get_raw_device(device_id) events: list[DeviceEvent] = [] @@ -39,11 +49,8 @@ async def async_get_device_diagnostics( listener() - status: dict[str, Any] = {} - for component, capabilities in device_status.items(): - status[component] = {} - for capability, attributes in capabilities.items(): - status[component][capability] = {} - for attribute, value in attributes.items(): - status[component][capability][attribute] = asdict(value) - return {"events": [asdict(event) for event in events], "status": status} + return { + "events": [asdict(event) for event in events], + "status": device_status, + "info": device_info, + } diff --git a/tests/components/smartthings/snapshots/test_diagnostics.ambr b/tests/components/smartthings/snapshots/test_diagnostics.ambr index 50f568df5d1..7610c8839ba 100644 --- a/tests/components/smartthings/snapshots/test_diagnostics.ambr +++ b/tests/components/smartthings/snapshots/test_diagnostics.ambr @@ -1,1160 +1,1525 @@ # serializer version: 1 -# name: test_device[da_ac_rac_000001] +# name: test_config_entry_diagnostics[da_ac_rac_000001] + dict({ + '_links': dict({ + }), + 'items': list([ + dict({ + 'allowed': list([ + ]), + 'components': list([ + dict({ + 'capabilities': list([ + dict({ + 'id': 'ocf', + 'version': 1, + }), + dict({ + 'id': 'switch', + 'version': 1, + }), + dict({ + 'id': 'airConditionerMode', + 'version': 1, + }), + dict({ + 'id': 'airConditionerFanMode', + 'version': 1, + }), + dict({ + 'id': 'fanOscillationMode', + 'version': 1, + }), + dict({ + 'id': 'airQualitySensor', + 'version': 1, + }), + dict({ + 'id': 'temperatureMeasurement', + 'version': 1, + }), + dict({ + 'id': 'thermostatCoolingSetpoint', + 'version': 1, + }), + dict({ + 'id': 'relativeHumidityMeasurement', + 'version': 1, + }), + dict({ + 'id': 'dustSensor', + 'version': 1, + }), + dict({ + 'id': 'veryFineDustSensor', + 'version': 1, + }), + dict({ + 'id': 'audioVolume', + 'version': 1, + }), + dict({ + 'id': 'remoteControlStatus', + 'version': 1, + }), + dict({ + 'id': 'powerConsumptionReport', + 'version': 1, + }), + dict({ + 'id': 'demandResponseLoadControl', + 'version': 1, + }), + dict({ + 'id': 'refresh', + 'version': 1, + }), + dict({ + 'id': 'execute', + 'version': 1, + }), + dict({ + 'id': 'custom.spiMode', + 'version': 1, + }), + dict({ + 'id': 'custom.thermostatSetpointControl', + 'version': 1, + }), + dict({ + 'id': 'custom.airConditionerOptionalMode', + 'version': 1, + }), + dict({ + 'id': 'custom.airConditionerTropicalNightMode', + 'version': 1, + }), + dict({ + 'id': 'custom.autoCleaningMode', + 'version': 1, + }), + dict({ + 'id': 'custom.deviceReportStateConfiguration', + 'version': 1, + }), + dict({ + 'id': 'custom.energyType', + 'version': 1, + }), + dict({ + 'id': 'custom.dustFilter', + 'version': 1, + }), + dict({ + 'id': 'custom.airConditionerOdorController', + 'version': 1, + }), + dict({ + 'id': 'custom.deodorFilter', + 'version': 1, + }), + dict({ + 'id': 'custom.disabledComponents', + 'version': 1, + }), + dict({ + 'id': 'custom.disabledCapabilities', + 'version': 1, + }), + dict({ + 'id': 'samsungce.deviceIdentification', + 'version': 1, + }), + dict({ + 'id': 'samsungce.dongleSoftwareInstallation', + 'version': 1, + }), + dict({ + 'id': 'samsungce.softwareUpdate', + 'version': 1, + }), + dict({ + 'id': 'samsungce.selfCheck', + 'version': 1, + }), + dict({ + 'id': 'samsungce.driverVersion', + 'version': 1, + }), + ]), + 'categories': list([ + dict({ + 'categoryType': 'manufacturer', + 'name': 'AirConditioner', + }), + ]), + 'id': 'main', + 'label': 'main', + }), + dict({ + 'capabilities': list([ + dict({ + 'id': 'switch', + 'version': 1, + }), + dict({ + 'id': 'airConditionerMode', + 'version': 1, + }), + dict({ + 'id': 'airConditionerFanMode', + 'version': 1, + }), + dict({ + 'id': 'fanOscillationMode', + 'version': 1, + }), + dict({ + 'id': 'temperatureMeasurement', + 'version': 1, + }), + dict({ + 'id': 'thermostatCoolingSetpoint', + 'version': 1, + }), + dict({ + 'id': 'relativeHumidityMeasurement', + 'version': 1, + }), + dict({ + 'id': 'airQualitySensor', + 'version': 1, + }), + dict({ + 'id': 'dustSensor', + 'version': 1, + }), + dict({ + 'id': 'veryFineDustSensor', + 'version': 1, + }), + dict({ + 'id': 'odorSensor', + 'version': 1, + }), + dict({ + 'id': 'remoteControlStatus', + 'version': 1, + }), + dict({ + 'id': 'audioVolume', + 'version': 1, + }), + dict({ + 'id': 'custom.thermostatSetpointControl', + 'version': 1, + }), + dict({ + 'id': 'custom.autoCleaningMode', + 'version': 1, + }), + dict({ + 'id': 'custom.airConditionerTropicalNightMode', + 'version': 1, + }), + dict({ + 'id': 'custom.disabledCapabilities', + 'version': 1, + }), + dict({ + 'id': 'ocf', + 'version': 1, + }), + dict({ + 'id': 'powerConsumptionReport', + 'version': 1, + }), + dict({ + 'id': 'demandResponseLoadControl', + 'version': 1, + }), + dict({ + 'id': 'custom.spiMode', + 'version': 1, + }), + dict({ + 'id': 'custom.airConditionerOptionalMode', + 'version': 1, + }), + dict({ + 'id': 'custom.deviceReportStateConfiguration', + 'version': 1, + }), + dict({ + 'id': 'custom.energyType', + 'version': 1, + }), + dict({ + 'id': 'custom.dustFilter', + 'version': 1, + }), + dict({ + 'id': 'custom.airConditionerOdorController', + 'version': 1, + }), + dict({ + 'id': 'custom.deodorFilter', + 'version': 1, + }), + ]), + 'categories': list([ + dict({ + 'categoryType': 'manufacturer', + 'name': 'Other', + }), + ]), + 'id': '1', + 'label': '1', + }), + ]), + 'createTime': '2021-04-06T16:43:34.753Z', + 'deviceId': '96a5ef74-5832-a84b-f1f7-ca799957065d', + 'deviceManufacturerCode': 'Samsung Electronics', + 'deviceTypeName': 'Samsung OCF Air Conditioner', + 'executionContext': 'CLOUD', + 'label': 'AC Office Granit', + 'locationId': '58d3fd7c-c512-4da3-b500-ef269382756c', + 'manufacturerName': 'Samsung Electronics', + 'name': '[room a/c] Samsung', + 'ocf': dict({ + 'additionalAuthCodeRequired': False, + 'lastSignupTime': '2025-01-08T02:32:04.631093137Z', + 'manufacturerName': 'Samsung Electronics', + 'ocfDeviceType': 'x.com.st.d.sensor.light', + 'transferCandidate': False, + 'vendorId': 'VD-Sensor.Light-2023', + }), + 'ownerId': 'f9a28d7c-1ed5-d9e9-a81c-18971ec081db', + 'presentationId': 'DA-AC-RAC-000001', + 'profile': dict({ + 'id': '60fbc713-8da5-315d-b31a-6d6dcde4be7b', + }), + 'restrictionTier': 0, + 'roomId': '7715151d-0314-457a-a82c-5ce48900e065', + 'type': 'OCF', + }), + ]), + }) +# --- +# name: test_device_diagnostics[da_ac_rac_000001] dict({ 'events': list([ ]), + 'info': dict({ + 'allowed': list([ + ]), + 'components': list([ + dict({ + 'capabilities': list([ + dict({ + 'id': 'ocf', + 'version': 1, + }), + dict({ + 'id': 'switch', + 'version': 1, + }), + dict({ + 'id': 'airConditionerMode', + 'version': 1, + }), + dict({ + 'id': 'airConditionerFanMode', + 'version': 1, + }), + dict({ + 'id': 'fanOscillationMode', + 'version': 1, + }), + dict({ + 'id': 'airQualitySensor', + 'version': 1, + }), + dict({ + 'id': 'temperatureMeasurement', + 'version': 1, + }), + dict({ + 'id': 'thermostatCoolingSetpoint', + 'version': 1, + }), + dict({ + 'id': 'relativeHumidityMeasurement', + 'version': 1, + }), + dict({ + 'id': 'dustSensor', + 'version': 1, + }), + dict({ + 'id': 'veryFineDustSensor', + 'version': 1, + }), + dict({ + 'id': 'audioVolume', + 'version': 1, + }), + dict({ + 'id': 'remoteControlStatus', + 'version': 1, + }), + dict({ + 'id': 'powerConsumptionReport', + 'version': 1, + }), + dict({ + 'id': 'demandResponseLoadControl', + 'version': 1, + }), + dict({ + 'id': 'refresh', + 'version': 1, + }), + dict({ + 'id': 'execute', + 'version': 1, + }), + dict({ + 'id': 'custom.spiMode', + 'version': 1, + }), + dict({ + 'id': 'custom.thermostatSetpointControl', + 'version': 1, + }), + dict({ + 'id': 'custom.airConditionerOptionalMode', + 'version': 1, + }), + dict({ + 'id': 'custom.airConditionerTropicalNightMode', + 'version': 1, + }), + dict({ + 'id': 'custom.autoCleaningMode', + 'version': 1, + }), + dict({ + 'id': 'custom.deviceReportStateConfiguration', + 'version': 1, + }), + dict({ + 'id': 'custom.energyType', + 'version': 1, + }), + dict({ + 'id': 'custom.dustFilter', + 'version': 1, + }), + dict({ + 'id': 'custom.airConditionerOdorController', + 'version': 1, + }), + dict({ + 'id': 'custom.deodorFilter', + 'version': 1, + }), + dict({ + 'id': 'custom.disabledComponents', + 'version': 1, + }), + dict({ + 'id': 'custom.disabledCapabilities', + 'version': 1, + }), + dict({ + 'id': 'samsungce.deviceIdentification', + 'version': 1, + }), + dict({ + 'id': 'samsungce.dongleSoftwareInstallation', + 'version': 1, + }), + dict({ + 'id': 'samsungce.softwareUpdate', + 'version': 1, + }), + dict({ + 'id': 'samsungce.selfCheck', + 'version': 1, + }), + dict({ + 'id': 'samsungce.driverVersion', + 'version': 1, + }), + ]), + 'categories': list([ + dict({ + 'categoryType': 'manufacturer', + 'name': 'AirConditioner', + }), + ]), + 'id': 'main', + 'label': 'main', + }), + dict({ + 'capabilities': list([ + dict({ + 'id': 'switch', + 'version': 1, + }), + dict({ + 'id': 'airConditionerMode', + 'version': 1, + }), + dict({ + 'id': 'airConditionerFanMode', + 'version': 1, + }), + dict({ + 'id': 'fanOscillationMode', + 'version': 1, + }), + dict({ + 'id': 'temperatureMeasurement', + 'version': 1, + }), + dict({ + 'id': 'thermostatCoolingSetpoint', + 'version': 1, + }), + dict({ + 'id': 'relativeHumidityMeasurement', + 'version': 1, + }), + dict({ + 'id': 'airQualitySensor', + 'version': 1, + }), + dict({ + 'id': 'dustSensor', + 'version': 1, + }), + dict({ + 'id': 'veryFineDustSensor', + 'version': 1, + }), + dict({ + 'id': 'odorSensor', + 'version': 1, + }), + dict({ + 'id': 'remoteControlStatus', + 'version': 1, + }), + dict({ + 'id': 'audioVolume', + 'version': 1, + }), + dict({ + 'id': 'custom.thermostatSetpointControl', + 'version': 1, + }), + dict({ + 'id': 'custom.autoCleaningMode', + 'version': 1, + }), + dict({ + 'id': 'custom.airConditionerTropicalNightMode', + 'version': 1, + }), + dict({ + 'id': 'custom.disabledCapabilities', + 'version': 1, + }), + dict({ + 'id': 'ocf', + 'version': 1, + }), + dict({ + 'id': 'powerConsumptionReport', + 'version': 1, + }), + dict({ + 'id': 'demandResponseLoadControl', + 'version': 1, + }), + dict({ + 'id': 'custom.spiMode', + 'version': 1, + }), + dict({ + 'id': 'custom.airConditionerOptionalMode', + 'version': 1, + }), + dict({ + 'id': 'custom.deviceReportStateConfiguration', + 'version': 1, + }), + dict({ + 'id': 'custom.energyType', + 'version': 1, + }), + dict({ + 'id': 'custom.dustFilter', + 'version': 1, + }), + dict({ + 'id': 'custom.airConditionerOdorController', + 'version': 1, + }), + dict({ + 'id': 'custom.deodorFilter', + 'version': 1, + }), + ]), + 'categories': list([ + dict({ + 'categoryType': 'manufacturer', + 'name': 'Other', + }), + ]), + 'id': '1', + 'label': '1', + }), + ]), + 'createTime': '2021-04-06T16:43:34.753Z', + 'deviceId': '96a5ef74-5832-a84b-f1f7-ca799957065d', + 'deviceManufacturerCode': 'Samsung Electronics', + 'deviceTypeName': 'Samsung OCF Air Conditioner', + 'executionContext': 'CLOUD', + 'label': 'AC Office Granit', + 'locationId': '58d3fd7c-c512-4da3-b500-ef269382756c', + 'manufacturerName': 'Samsung Electronics', + 'name': '[room a/c] Samsung', + 'ocf': dict({ + 'additionalAuthCodeRequired': False, + 'lastSignupTime': '2025-01-08T02:32:04.631093137Z', + 'manufacturerName': 'Samsung Electronics', + 'ocfDeviceType': 'x.com.st.d.sensor.light', + 'transferCandidate': False, + 'vendorId': 'VD-Sensor.Light-2023', + }), + 'ownerId': 'f9a28d7c-1ed5-d9e9-a81c-18971ec081db', + 'presentationId': 'DA-AC-RAC-000001', + 'profile': dict({ + 'id': '60fbc713-8da5-315d-b31a-6d6dcde4be7b', + }), + 'restrictionTier': 0, + 'roomId': '7715151d-0314-457a-a82c-5ce48900e065', + 'type': 'OCF', + }), 'status': dict({ - '1': dict({ - 'airConditionerFanMode': dict({ - 'availableAcFanModes': dict({ - 'data': None, - 'timestamp': None, - 'unit': None, - 'value': None, + 'components': dict({ + '1': dict({ + 'airConditionerFanMode': dict({ + 'availableAcFanModes': dict({ + 'value': None, + }), + 'fanMode': dict({ + 'timestamp': '2021-04-06T16:44:10.381Z', + 'value': None, + }), + 'supportedAcFanModes': dict({ + 'timestamp': '2024-09-10T10:26:28.605Z', + 'value': list([ + 'auto', + 'low', + 'medium', + 'high', + 'turbo', + ]), + }), }), - 'fanMode': dict({ - 'data': None, - 'timestamp': '2021-04-06T16:44:10.381000+00:00', - 'unit': None, - 'value': None, - }), - 'supportedAcFanModes': dict({ - 'data': None, - 'timestamp': '2024-09-10T10:26:28.605000+00:00', - 'unit': None, - 'value': list([ - 'auto', - 'low', - 'medium', - 'high', - 'turbo', - ]), - }), - }), - 'airConditionerMode': dict({ 'airConditionerMode': dict({ - 'data': None, - 'timestamp': '2021-04-08T03:50:50.930000+00:00', - 'unit': None, - 'value': None, + 'airConditionerMode': dict({ + 'timestamp': '2021-04-08T03:50:50.930Z', + 'value': None, + }), + 'availableAcModes': dict({ + 'value': None, + }), + 'supportedAcModes': dict({ + 'timestamp': '2021-04-08T03:50:50.930Z', + 'value': None, + }), }), - 'availableAcModes': dict({ - 'data': None, - 'timestamp': None, - 'unit': None, - 'value': None, + 'airQualitySensor': dict({ + 'airQuality': dict({ + 'timestamp': '2021-04-06T16:57:57.602Z', + 'unit': 'CAQI', + 'value': None, + }), }), - 'supportedAcModes': dict({ - 'data': None, - 'timestamp': '2021-04-08T03:50:50.930000+00:00', - 'unit': None, - 'value': None, + 'audioVolume': dict({ + 'volume': dict({ + 'timestamp': '2021-04-06T16:43:53.541Z', + 'unit': '%', + 'value': None, + }), }), - }), - 'airQualitySensor': dict({ - 'airQuality': dict({ - 'data': None, - 'timestamp': '2021-04-06T16:57:57.602000+00:00', - 'unit': 'CAQI', - 'value': None, + 'custom.airConditionerOdorController': dict({ + 'airConditionerOdorControllerProgress': dict({ + 'timestamp': '2021-04-08T04:11:38.269Z', + 'value': None, + }), + 'airConditionerOdorControllerState': dict({ + 'timestamp': '2021-04-08T04:11:38.269Z', + 'value': None, + }), }), - }), - 'audioVolume': dict({ - 'volume': dict({ - 'data': None, - 'timestamp': '2021-04-06T16:43:53.541000+00:00', - 'unit': '%', - 'value': None, + 'custom.airConditionerOptionalMode': dict({ + 'acOptionalMode': dict({ + 'timestamp': '2021-04-06T16:57:57.659Z', + 'value': None, + }), + 'supportedAcOptionalMode': dict({ + 'timestamp': '2021-04-06T16:57:57.659Z', + 'value': None, + }), }), - }), - 'custom.airConditionerOdorController': dict({ - 'airConditionerOdorControllerProgress': dict({ - 'data': None, - 'timestamp': '2021-04-08T04:11:38.269000+00:00', - 'unit': None, - 'value': None, + 'custom.airConditionerTropicalNightMode': dict({ + 'acTropicalNightModeLevel': dict({ + 'timestamp': '2021-04-06T16:44:10.498Z', + 'value': None, + }), }), - 'airConditionerOdorControllerState': dict({ - 'data': None, - 'timestamp': '2021-04-08T04:11:38.269000+00:00', - 'unit': None, - 'value': None, + 'custom.autoCleaningMode': dict({ + 'autoCleaningMode': dict({ + 'timestamp': '2021-04-06T16:43:53.344Z', + 'value': None, + }), + 'operatingState': dict({ + 'value': None, + }), + 'progress': dict({ + 'value': None, + }), + 'supportedAutoCleaningModes': dict({ + 'value': None, + }), + 'supportedOperatingStates': dict({ + 'value': None, + }), + 'timedCleanDuration': dict({ + 'value': None, + }), + 'timedCleanDurationRange': dict({ + 'value': None, + }), }), - }), - 'custom.airConditionerOptionalMode': dict({ - 'acOptionalMode': dict({ - 'data': None, - 'timestamp': '2021-04-06T16:57:57.659000+00:00', - 'unit': None, - 'value': None, + 'custom.deodorFilter': dict({ + 'deodorFilterCapacity': dict({ + 'timestamp': '2021-04-06T16:43:39.118Z', + 'value': None, + }), + 'deodorFilterLastResetDate': dict({ + 'timestamp': '2021-04-06T16:43:39.118Z', + 'value': None, + }), + 'deodorFilterResetType': dict({ + 'timestamp': '2021-04-06T16:43:39.118Z', + 'value': None, + }), + 'deodorFilterStatus': dict({ + 'timestamp': '2021-04-06T16:43:39.118Z', + 'value': None, + }), + 'deodorFilterUsage': dict({ + 'timestamp': '2021-04-06T16:43:39.118Z', + 'value': None, + }), + 'deodorFilterUsageStep': dict({ + 'timestamp': '2021-04-06T16:43:39.118Z', + 'value': None, + }), }), - 'supportedAcOptionalMode': dict({ - 'data': None, - 'timestamp': '2021-04-06T16:57:57.659000+00:00', - 'unit': None, - 'value': None, + 'custom.deviceReportStateConfiguration': dict({ + 'reportStatePeriod': dict({ + 'timestamp': '2021-04-06T16:44:09.800Z', + 'value': None, + }), + 'reportStateRealtime': dict({ + 'timestamp': '2021-04-06T16:44:09.800Z', + 'value': None, + }), + 'reportStateRealtimePeriod': dict({ + 'timestamp': '2021-04-06T16:44:09.800Z', + 'value': None, + }), }), - }), - 'custom.airConditionerTropicalNightMode': dict({ - 'acTropicalNightModeLevel': dict({ - 'data': None, - 'timestamp': '2021-04-06T16:44:10.498000+00:00', - 'unit': None, - 'value': None, + 'custom.disabledCapabilities': dict({ + 'disabledCapabilities': dict({ + 'timestamp': '2024-09-10T10:26:28.605Z', + 'value': list([ + 'remoteControlStatus', + 'airQualitySensor', + 'dustSensor', + 'odorSensor', + 'veryFineDustSensor', + 'custom.dustFilter', + 'custom.deodorFilter', + 'custom.deviceReportStateConfiguration', + 'audioVolume', + 'custom.autoCleaningMode', + 'custom.airConditionerTropicalNightMode', + 'custom.airConditionerOdorController', + 'demandResponseLoadControl', + 'relativeHumidityMeasurement', + ]), + }), }), - }), - 'custom.autoCleaningMode': dict({ - 'autoCleaningMode': dict({ - 'data': None, - 'timestamp': '2021-04-06T16:43:53.344000+00:00', - 'unit': None, - 'value': None, + 'custom.dustFilter': dict({ + 'dustFilterCapacity': dict({ + 'timestamp': '2021-04-06T16:43:39.145Z', + 'value': None, + }), + 'dustFilterLastResetDate': dict({ + 'timestamp': '2021-04-06T16:43:39.145Z', + 'value': None, + }), + 'dustFilterResetType': dict({ + 'timestamp': '2021-04-06T16:43:39.145Z', + 'value': None, + }), + 'dustFilterStatus': dict({ + 'timestamp': '2021-04-06T16:43:39.145Z', + 'value': None, + }), + 'dustFilterUsage': dict({ + 'timestamp': '2021-04-06T16:43:39.145Z', + 'value': None, + }), + 'dustFilterUsageStep': dict({ + 'timestamp': '2021-04-06T16:43:39.145Z', + 'value': None, + }), }), - 'operatingState': dict({ - 'data': None, - 'timestamp': None, - 'unit': None, - 'value': None, + 'custom.energyType': dict({ + 'drMaxDuration': dict({ + 'value': None, + }), + 'energySavingInfo': dict({ + 'value': None, + }), + 'energySavingLevel': dict({ + 'value': None, + }), + 'energySavingOperation': dict({ + 'value': None, + }), + 'energySavingOperationSupport': dict({ + 'value': None, + }), + 'energySavingSupport': dict({ + 'value': None, + }), + 'energyType': dict({ + 'timestamp': '2021-04-06T16:43:38.843Z', + 'value': None, + }), + 'notificationTemplateID': dict({ + 'value': None, + }), + 'supportedEnergySavingLevels': dict({ + 'value': None, + }), }), - 'progress': dict({ - 'data': None, - 'timestamp': None, - 'unit': None, - 'value': None, + 'custom.spiMode': dict({ + 'spiMode': dict({ + 'timestamp': '2021-04-06T16:57:57.686Z', + 'value': None, + }), }), - 'supportedAutoCleaningModes': dict({ - 'data': None, - 'timestamp': None, - 'unit': None, - 'value': None, + 'custom.thermostatSetpointControl': dict({ + 'maximumSetpoint': dict({ + 'timestamp': '2021-04-08T04:04:19.901Z', + 'value': None, + }), + 'minimumSetpoint': dict({ + 'timestamp': '2021-04-08T04:04:19.901Z', + 'value': None, + }), }), - 'supportedOperatingStates': dict({ - 'data': None, - 'timestamp': None, - 'unit': None, - 'value': None, + 'demandResponseLoadControl': dict({ + 'drlcStatus': dict({ + 'timestamp': '2021-04-06T16:43:54.748Z', + 'value': None, + }), }), - 'timedCleanDuration': dict({ - 'data': None, - 'timestamp': None, - 'unit': None, - 'value': None, - }), - 'timedCleanDurationRange': dict({ - 'data': None, - 'timestamp': None, - 'unit': None, - 'value': None, - }), - }), - 'custom.deodorFilter': dict({ - 'deodorFilterCapacity': dict({ - 'data': None, - 'timestamp': '2021-04-06T16:43:39.118000+00:00', - 'unit': None, - 'value': None, - }), - 'deodorFilterLastResetDate': dict({ - 'data': None, - 'timestamp': '2021-04-06T16:43:39.118000+00:00', - 'unit': None, - 'value': None, - }), - 'deodorFilterResetType': dict({ - 'data': None, - 'timestamp': '2021-04-06T16:43:39.118000+00:00', - 'unit': None, - 'value': None, - }), - 'deodorFilterStatus': dict({ - 'data': None, - 'timestamp': '2021-04-06T16:43:39.118000+00:00', - 'unit': None, - 'value': None, - }), - 'deodorFilterUsage': dict({ - 'data': None, - 'timestamp': '2021-04-06T16:43:39.118000+00:00', - 'unit': None, - 'value': None, - }), - 'deodorFilterUsageStep': dict({ - 'data': None, - 'timestamp': '2021-04-06T16:43:39.118000+00:00', - 'unit': None, - 'value': None, - }), - }), - 'custom.deviceReportStateConfiguration': dict({ - 'reportStatePeriod': dict({ - 'data': None, - 'timestamp': '2021-04-06T16:44:09.800000+00:00', - 'unit': None, - 'value': None, - }), - 'reportStateRealtime': dict({ - 'data': None, - 'timestamp': '2021-04-06T16:44:09.800000+00:00', - 'unit': None, - 'value': None, - }), - 'reportStateRealtimePeriod': dict({ - 'data': None, - 'timestamp': '2021-04-06T16:44:09.800000+00:00', - 'unit': None, - 'value': None, - }), - }), - 'custom.disabledCapabilities': dict({ - 'disabledCapabilities': dict({ - 'data': None, - 'timestamp': '2024-09-10T10:26:28.605000+00:00', - 'unit': None, - 'value': list([ - 'remoteControlStatus', - 'airQualitySensor', - 'dustSensor', - 'odorSensor', - 'veryFineDustSensor', - 'custom.dustFilter', - 'custom.deodorFilter', - 'custom.deviceReportStateConfiguration', - 'audioVolume', - 'custom.autoCleaningMode', - 'custom.airConditionerTropicalNightMode', - 'custom.airConditionerOdorController', - 'demandResponseLoadControl', - 'relativeHumidityMeasurement', - ]), - }), - }), - 'custom.dustFilter': dict({ - 'dustFilterCapacity': dict({ - 'data': None, - 'timestamp': '2021-04-06T16:43:39.145000+00:00', - 'unit': None, - 'value': None, - }), - 'dustFilterLastResetDate': dict({ - 'data': None, - 'timestamp': '2021-04-06T16:43:39.145000+00:00', - 'unit': None, - 'value': None, - }), - 'dustFilterResetType': dict({ - 'data': None, - 'timestamp': '2021-04-06T16:43:39.145000+00:00', - 'unit': None, - 'value': None, - }), - 'dustFilterStatus': dict({ - 'data': None, - 'timestamp': '2021-04-06T16:43:39.145000+00:00', - 'unit': None, - 'value': None, - }), - 'dustFilterUsage': dict({ - 'data': None, - 'timestamp': '2021-04-06T16:43:39.145000+00:00', - 'unit': None, - 'value': None, - }), - 'dustFilterUsageStep': dict({ - 'data': None, - 'timestamp': '2021-04-06T16:43:39.145000+00:00', - 'unit': None, - 'value': None, - }), - }), - 'custom.energyType': dict({ - 'drMaxDuration': dict({ - 'data': None, - 'timestamp': None, - 'unit': None, - 'value': None, - }), - 'energySavingInfo': dict({ - 'data': None, - 'timestamp': None, - 'unit': None, - 'value': None, - }), - 'energySavingLevel': dict({ - 'data': None, - 'timestamp': None, - 'unit': None, - 'value': None, - }), - 'energySavingOperation': dict({ - 'data': None, - 'timestamp': None, - 'unit': None, - 'value': None, - }), - 'energySavingOperationSupport': dict({ - 'data': None, - 'timestamp': None, - 'unit': None, - 'value': None, - }), - 'energySavingSupport': dict({ - 'data': None, - 'timestamp': None, - 'unit': None, - 'value': None, - }), - 'energyType': dict({ - 'data': None, - 'timestamp': '2021-04-06T16:43:38.843000+00:00', - 'unit': None, - 'value': None, - }), - 'notificationTemplateID': dict({ - 'data': None, - 'timestamp': None, - 'unit': None, - 'value': None, - }), - 'supportedEnergySavingLevels': dict({ - 'data': None, - 'timestamp': None, - 'unit': None, - 'value': None, - }), - }), - 'custom.spiMode': dict({ - 'spiMode': dict({ - 'data': None, - 'timestamp': '2021-04-06T16:57:57.686000+00:00', - 'unit': None, - 'value': None, - }), - }), - 'custom.thermostatSetpointControl': dict({ - 'maximumSetpoint': dict({ - 'data': None, - 'timestamp': '2021-04-08T04:04:19.901000+00:00', - 'unit': None, - 'value': None, - }), - 'minimumSetpoint': dict({ - 'data': None, - 'timestamp': '2021-04-08T04:04:19.901000+00:00', - 'unit': None, - 'value': None, - }), - }), - 'demandResponseLoadControl': dict({ - 'drlcStatus': dict({ - 'data': None, - 'timestamp': '2021-04-06T16:43:54.748000+00:00', - 'unit': None, - 'value': None, - }), - }), - 'dustSensor': dict({ - 'dustLevel': dict({ - 'data': None, - 'timestamp': '2021-04-06T16:44:10.122000+00:00', - 'unit': 'μg/m^3', - 'value': None, - }), - 'fineDustLevel': dict({ - 'data': None, - 'timestamp': '2021-04-06T16:44:10.122000+00:00', - 'unit': 'μg/m^3', - 'value': None, - }), - }), - 'fanOscillationMode': dict({ - 'availableFanOscillationModes': dict({ - 'data': None, - 'timestamp': None, - 'unit': None, - 'value': None, + 'dustSensor': dict({ + 'dustLevel': dict({ + 'timestamp': '2021-04-06T16:44:10.122Z', + 'unit': 'μg/m^3', + 'value': None, + }), + 'fineDustLevel': dict({ + 'timestamp': '2021-04-06T16:44:10.122Z', + 'unit': 'μg/m^3', + 'value': None, + }), }), 'fanOscillationMode': dict({ - 'data': None, - 'timestamp': '2025-02-08T00:44:53.247000+00:00', - 'unit': None, - 'value': 'fixed', + 'availableFanOscillationModes': dict({ + 'value': None, + }), + 'fanOscillationMode': dict({ + 'timestamp': '2025-02-08T00:44:53.247Z', + 'value': 'fixed', + }), + 'supportedFanOscillationModes': dict({ + 'timestamp': '2021-04-06T16:44:10.325Z', + 'value': None, + }), }), - 'supportedFanOscillationModes': dict({ - 'data': None, - 'timestamp': '2021-04-06T16:44:10.325000+00:00', - 'unit': None, - 'value': None, + 'ocf': dict({ + 'di': dict({ + 'timestamp': '2021-04-06T16:44:10.472Z', + 'value': None, + }), + 'dmv': dict({ + 'timestamp': '2021-04-06T16:44:10.472Z', + 'value': None, + }), + 'icv': dict({ + 'timestamp': '2021-04-06T16:44:10.472Z', + 'value': None, + }), + 'mndt': dict({ + 'timestamp': '2021-04-06T16:44:10.472Z', + 'value': None, + }), + 'mnfv': dict({ + 'timestamp': '2021-04-06T16:44:10.472Z', + 'value': None, + }), + 'mnhw': dict({ + 'timestamp': '2021-04-06T16:44:10.472Z', + 'value': None, + }), + 'mnml': dict({ + 'timestamp': '2021-04-06T16:44:10.472Z', + 'value': None, + }), + 'mnmn': dict({ + 'timestamp': '2021-04-06T16:44:10.472Z', + 'value': None, + }), + 'mnmo': dict({ + 'timestamp': '2021-04-06T16:44:10.472Z', + 'value': None, + }), + 'mnos': dict({ + 'timestamp': '2021-04-06T16:44:10.472Z', + 'value': None, + }), + 'mnpv': dict({ + 'timestamp': '2021-04-06T16:44:10.472Z', + 'value': None, + }), + 'mnsl': dict({ + 'timestamp': '2021-04-06T16:44:10.472Z', + 'value': None, + }), + 'n': dict({ + 'timestamp': '2021-04-06T16:44:10.472Z', + 'value': None, + }), + 'pi': dict({ + 'timestamp': '2021-04-06T16:44:10.472Z', + 'value': None, + }), + 'st': dict({ + 'timestamp': '2021-04-06T16:44:10.472Z', + 'value': None, + }), + 'vid': dict({ + 'timestamp': '2021-04-06T16:44:10.472Z', + 'value': None, + }), }), - }), - 'ocf': dict({ - 'di': dict({ - 'data': None, - 'timestamp': '2021-04-06T16:44:10.472000+00:00', - 'unit': None, - 'value': None, + 'odorSensor': dict({ + 'odorLevel': dict({ + 'timestamp': '2021-04-06T16:43:38.992Z', + 'value': None, + }), }), - 'dmv': dict({ - 'data': None, - 'timestamp': '2021-04-06T16:44:10.472000+00:00', - 'unit': None, - 'value': None, + 'powerConsumptionReport': dict({ + 'powerConsumption': dict({ + 'timestamp': '2021-04-06T16:43:53.364Z', + 'value': None, + }), }), - 'icv': dict({ - 'data': None, - 'timestamp': '2021-04-06T16:44:10.472000+00:00', - 'unit': None, - 'value': None, + 'relativeHumidityMeasurement': dict({ + 'humidity': dict({ + 'timestamp': '2021-04-06T16:43:35.291Z', + 'unit': '%', + 'value': 0, + }), }), - 'mndt': dict({ - 'data': None, - 'timestamp': '2021-04-06T16:44:10.472000+00:00', - 'unit': None, - 'value': None, + 'remoteControlStatus': dict({ + 'remoteControlEnabled': dict({ + 'timestamp': '2021-04-06T16:43:39.097Z', + 'value': None, + }), }), - 'mnfv': dict({ - 'data': None, - 'timestamp': '2021-04-06T16:44:10.472000+00:00', - 'unit': None, - 'value': None, - }), - 'mnhw': dict({ - 'data': None, - 'timestamp': '2021-04-06T16:44:10.472000+00:00', - 'unit': None, - 'value': None, - }), - 'mnml': dict({ - 'data': None, - 'timestamp': '2021-04-06T16:44:10.472000+00:00', - 'unit': None, - 'value': None, - }), - 'mnmn': dict({ - 'data': None, - 'timestamp': '2021-04-06T16:44:10.472000+00:00', - 'unit': None, - 'value': None, - }), - 'mnmo': dict({ - 'data': None, - 'timestamp': '2021-04-06T16:44:10.472000+00:00', - 'unit': None, - 'value': None, - }), - 'mnos': dict({ - 'data': None, - 'timestamp': '2021-04-06T16:44:10.472000+00:00', - 'unit': None, - 'value': None, - }), - 'mnpv': dict({ - 'data': None, - 'timestamp': '2021-04-06T16:44:10.472000+00:00', - 'unit': None, - 'value': None, - }), - 'mnsl': dict({ - 'data': None, - 'timestamp': '2021-04-06T16:44:10.472000+00:00', - 'unit': None, - 'value': None, - }), - 'n': dict({ - 'data': None, - 'timestamp': '2021-04-06T16:44:10.472000+00:00', - 'unit': None, - 'value': None, - }), - 'pi': dict({ - 'data': None, - 'timestamp': '2021-04-06T16:44:10.472000+00:00', - 'unit': None, - 'value': None, - }), - 'st': dict({ - 'data': None, - 'timestamp': '2021-04-06T16:44:10.472000+00:00', - 'unit': None, - 'value': None, - }), - 'vid': dict({ - 'data': None, - 'timestamp': '2021-04-06T16:44:10.472000+00:00', - 'unit': None, - 'value': None, - }), - }), - 'odorSensor': dict({ - 'odorLevel': dict({ - 'data': None, - 'timestamp': '2021-04-06T16:43:38.992000+00:00', - 'unit': None, - 'value': None, - }), - }), - 'powerConsumptionReport': dict({ - 'powerConsumption': dict({ - 'data': None, - 'timestamp': '2021-04-06T16:43:53.364000+00:00', - 'unit': None, - 'value': None, - }), - }), - 'relativeHumidityMeasurement': dict({ - 'humidity': dict({ - 'data': None, - 'timestamp': '2021-04-06T16:43:35.291000+00:00', - 'unit': '%', - 'value': 0, - }), - }), - 'remoteControlStatus': dict({ - 'remoteControlEnabled': dict({ - 'data': None, - 'timestamp': '2021-04-06T16:43:39.097000+00:00', - 'unit': None, - 'value': None, - }), - }), - 'switch': dict({ 'switch': dict({ - 'data': None, - 'timestamp': '2021-04-06T16:44:10.518000+00:00', - 'unit': None, - 'value': None, + 'switch': dict({ + 'timestamp': '2021-04-06T16:44:10.518Z', + 'value': None, + }), + }), + 'temperatureMeasurement': dict({ + 'temperature': dict({ + 'timestamp': '2021-04-06T16:44:10.373Z', + 'value': None, + }), + 'temperatureRange': dict({ + 'value': None, + }), + }), + 'thermostatCoolingSetpoint': dict({ + 'coolingSetpoint': dict({ + 'timestamp': '2021-04-06T16:43:59.136Z', + 'value': None, + }), + 'coolingSetpointRange': dict({ + 'value': None, + }), + }), + 'veryFineDustSensor': dict({ + 'veryFineDustLevel': dict({ + 'timestamp': '2021-04-06T16:43:38.529Z', + 'unit': 'μg/m^3', + 'value': None, + }), }), }), - 'temperatureMeasurement': dict({ - 'temperature': dict({ - 'data': None, - 'timestamp': '2021-04-06T16:44:10.373000+00:00', - 'unit': None, - 'value': None, + 'main': dict({ + 'airConditionerFanMode': dict({ + 'availableAcFanModes': dict({ + 'value': None, + }), + 'fanMode': dict({ + 'timestamp': '2025-02-09T09:14:39.249Z', + 'value': 'low', + }), + 'supportedAcFanModes': dict({ + 'timestamp': '2025-02-09T09:14:39.249Z', + 'value': list([ + 'auto', + 'low', + 'medium', + 'high', + 'turbo', + ]), + }), }), - 'temperatureRange': dict({ - 'data': None, - 'timestamp': None, - 'unit': None, - 'value': None, - }), - }), - 'thermostatCoolingSetpoint': dict({ - 'coolingSetpoint': dict({ - 'data': None, - 'timestamp': '2021-04-06T16:43:59.136000+00:00', - 'unit': None, - 'value': None, - }), - 'coolingSetpointRange': dict({ - 'data': None, - 'timestamp': None, - 'unit': None, - 'value': None, - }), - }), - 'veryFineDustSensor': dict({ - 'veryFineDustLevel': dict({ - 'data': None, - 'timestamp': '2021-04-06T16:43:38.529000+00:00', - 'unit': 'μg/m^3', - 'value': None, - }), - }), - }), - 'main': dict({ - 'airConditionerFanMode': dict({ - 'availableAcFanModes': dict({ - 'data': None, - 'timestamp': None, - 'unit': None, - 'value': None, - }), - 'fanMode': dict({ - 'data': None, - 'timestamp': '2025-02-09T09:14:39.249000+00:00', - 'unit': None, - 'value': 'low', - }), - 'supportedAcFanModes': dict({ - 'data': None, - 'timestamp': '2025-02-09T09:14:39.249000+00:00', - 'unit': None, - 'value': list([ - 'auto', - 'low', - 'medium', - 'high', - 'turbo', - ]), - }), - }), - 'airConditionerMode': dict({ 'airConditionerMode': dict({ - 'data': None, - 'timestamp': '2025-02-09T09:14:39.642000+00:00', - 'unit': None, - 'value': 'heat', - }), - 'availableAcModes': dict({ - 'data': None, - 'timestamp': None, - 'unit': None, - 'value': None, - }), - 'supportedAcModes': dict({ - 'data': None, - 'timestamp': '2024-09-10T10:26:28.781000+00:00', - 'unit': None, - 'value': list([ - 'cool', - 'dry', - 'wind', - 'auto', - 'heat', - ]), - }), - }), - 'audioVolume': dict({ - 'volume': dict({ - 'data': None, - 'timestamp': '2025-02-09T09:14:39.642000+00:00', - 'unit': '%', - 'value': 100, - }), - }), - 'custom.airConditionerOptionalMode': dict({ - 'acOptionalMode': dict({ - 'data': None, - 'timestamp': '2025-02-09T09:14:39.642000+00:00', - 'unit': None, - 'value': 'off', - }), - 'supportedAcOptionalMode': dict({ - 'data': None, - 'timestamp': '2024-09-10T10:26:28.781000+00:00', - 'unit': None, - 'value': list([ - 'off', - 'windFree', - ]), - }), - }), - 'custom.airConditionerTropicalNightMode': dict({ - 'acTropicalNightModeLevel': dict({ - 'data': None, - 'timestamp': '2025-02-09T09:14:39.642000+00:00', - 'unit': None, - 'value': 0, - }), - }), - 'custom.autoCleaningMode': dict({ - 'autoCleaningMode': dict({ - 'data': None, - 'timestamp': '2025-02-09T09:14:39.642000+00:00', - 'unit': None, - 'value': 'off', - }), - 'operatingState': dict({ - 'data': None, - 'timestamp': None, - 'unit': None, - 'value': None, - }), - 'progress': dict({ - 'data': None, - 'timestamp': None, - 'unit': None, - 'value': None, - }), - 'supportedAutoCleaningModes': dict({ - 'data': None, - 'timestamp': None, - 'unit': None, - 'value': None, - }), - 'supportedOperatingStates': dict({ - 'data': None, - 'timestamp': None, - 'unit': None, - 'value': None, - }), - 'timedCleanDuration': dict({ - 'data': None, - 'timestamp': None, - 'unit': None, - 'value': None, - }), - 'timedCleanDurationRange': dict({ - 'data': None, - 'timestamp': None, - 'unit': None, - 'value': None, - }), - }), - 'custom.disabledCapabilities': dict({ - 'disabledCapabilities': dict({ - 'data': None, - 'timestamp': '2025-02-09T09:14:39.642000+00:00', - 'unit': None, - 'value': list([ - 'remoteControlStatus', - 'airQualitySensor', - 'dustSensor', - 'veryFineDustSensor', - 'custom.dustFilter', - 'custom.deodorFilter', - 'custom.deviceReportStateConfiguration', - 'samsungce.dongleSoftwareInstallation', - 'demandResponseLoadControl', - 'custom.airConditionerOdorController', - ]), - }), - }), - 'custom.disabledComponents': dict({ - 'disabledComponents': dict({ - 'data': None, - 'timestamp': '2025-02-09T09:14:39.642000+00:00', - 'unit': None, - 'value': list([ - '1', - ]), - }), - }), - 'custom.energyType': dict({ - 'drMaxDuration': dict({ - 'data': None, - 'timestamp': None, - 'unit': None, - 'value': None, - }), - 'energySavingInfo': dict({ - 'data': None, - 'timestamp': None, - 'unit': None, - 'value': None, - }), - 'energySavingLevel': dict({ - 'data': None, - 'timestamp': None, - 'unit': None, - 'value': None, - }), - 'energySavingOperation': dict({ - 'data': None, - 'timestamp': None, - 'unit': None, - 'value': None, - }), - 'energySavingOperationSupport': dict({ - 'data': None, - 'timestamp': None, - 'unit': None, - 'value': None, - }), - 'energySavingSupport': dict({ - 'data': None, - 'timestamp': '2021-12-29T07:29:17.526000+00:00', - 'unit': None, - 'value': 'False', - }), - 'energyType': dict({ - 'data': None, - 'timestamp': '2024-09-10T10:26:28.781000+00:00', - 'unit': None, - 'value': '1.0', - }), - 'notificationTemplateID': dict({ - 'data': None, - 'timestamp': None, - 'unit': None, - 'value': None, - }), - 'supportedEnergySavingLevels': dict({ - 'data': None, - 'timestamp': None, - 'unit': None, - 'value': None, - }), - }), - 'custom.spiMode': dict({ - 'spiMode': dict({ - 'data': None, - 'timestamp': '2025-02-09T09:14:39.642000+00:00', - 'unit': None, - 'value': 'off', - }), - }), - 'custom.thermostatSetpointControl': dict({ - 'maximumSetpoint': dict({ - 'data': None, - 'timestamp': '2024-09-10T10:26:28.781000+00:00', - 'unit': 'C', - 'value': 30, - }), - 'minimumSetpoint': dict({ - 'data': None, - 'timestamp': '2025-01-08T06:30:58.307000+00:00', - 'unit': 'C', - 'value': 16, - }), - }), - 'demandResponseLoadControl': dict({ - 'drlcStatus': dict({ - 'data': None, - 'timestamp': '2024-09-10T10:26:28.781000+00:00', - 'unit': None, - 'value': dict({ - 'drlcLevel': -1, - 'drlcType': 1, - 'duration': 0, - 'override': False, - 'start': '1970-01-01T00:00:00Z', + 'airConditionerMode': dict({ + 'timestamp': '2025-02-09T09:14:39.642Z', + 'value': 'heat', + }), + 'availableAcModes': dict({ + 'value': None, + }), + 'supportedAcModes': dict({ + 'timestamp': '2024-09-10T10:26:28.781Z', + 'value': list([ + 'cool', + 'dry', + 'wind', + 'auto', + 'heat', + ]), }), }), - }), - 'execute': dict({ - 'data': dict({ - 'data': dict({ - 'href': '/temperature/desired/0', + 'airQualitySensor': dict({ + 'airQuality': dict({ + 'timestamp': '2021-04-06T16:43:37.208Z', + 'unit': 'CAQI', + 'value': None, }), - 'timestamp': '2023-07-19T03:07:43.270000+00:00', - 'unit': None, - 'value': dict({ - 'payload': dict({ - 'if': list([ - 'oic.if.baseline', - 'oic.if.a', - ]), - 'range': list([ - 16.0, - 30.0, - ]), - 'rt': list([ - 'oic.r.temperature', - ]), - 'temperature': 22.0, - 'units': 'C', + }), + 'audioVolume': dict({ + 'volume': dict({ + 'timestamp': '2025-02-09T09:14:39.642Z', + 'unit': '%', + 'value': 100, + }), + }), + 'custom.airConditionerOdorController': dict({ + 'airConditionerOdorControllerProgress': dict({ + 'timestamp': '2021-04-06T16:43:37.555Z', + 'value': None, + }), + 'airConditionerOdorControllerState': dict({ + 'timestamp': '2021-04-06T16:43:37.555Z', + 'value': None, + }), + }), + 'custom.airConditionerOptionalMode': dict({ + 'acOptionalMode': dict({ + 'timestamp': '2025-02-09T09:14:39.642Z', + 'value': 'off', + }), + 'supportedAcOptionalMode': dict({ + 'timestamp': '2024-09-10T10:26:28.781Z', + 'value': list([ + 'off', + 'windFree', + ]), + }), + }), + 'custom.airConditionerTropicalNightMode': dict({ + 'acTropicalNightModeLevel': dict({ + 'timestamp': '2025-02-09T09:14:39.642Z', + 'value': 0, + }), + }), + 'custom.autoCleaningMode': dict({ + 'autoCleaningMode': dict({ + 'timestamp': '2025-02-09T09:14:39.642Z', + 'value': 'off', + }), + 'operatingState': dict({ + 'value': None, + }), + 'progress': dict({ + 'value': None, + }), + 'supportedAutoCleaningModes': dict({ + 'value': None, + }), + 'supportedOperatingStates': dict({ + 'value': None, + }), + 'timedCleanDuration': dict({ + 'value': None, + }), + 'timedCleanDurationRange': dict({ + 'value': None, + }), + }), + 'custom.deodorFilter': dict({ + 'deodorFilterCapacity': dict({ + 'timestamp': '2021-04-06T16:43:35.502Z', + 'value': None, + }), + 'deodorFilterLastResetDate': dict({ + 'timestamp': '2021-04-06T16:43:35.502Z', + 'value': None, + }), + 'deodorFilterResetType': dict({ + 'timestamp': '2021-04-06T16:43:35.502Z', + 'value': None, + }), + 'deodorFilterStatus': dict({ + 'timestamp': '2021-04-06T16:43:35.502Z', + 'value': None, + }), + 'deodorFilterUsage': dict({ + 'timestamp': '2021-04-06T16:43:35.502Z', + 'value': None, + }), + 'deodorFilterUsageStep': dict({ + 'timestamp': '2021-04-06T16:43:35.502Z', + 'value': None, + }), + }), + 'custom.deviceReportStateConfiguration': dict({ + 'reportStatePeriod': dict({ + 'timestamp': '2021-04-06T16:43:35.643Z', + 'value': None, + }), + 'reportStateRealtime': dict({ + 'timestamp': '2021-04-06T16:43:35.643Z', + 'value': None, + }), + 'reportStateRealtimePeriod': dict({ + 'timestamp': '2021-04-06T16:43:35.643Z', + 'value': None, + }), + }), + 'custom.disabledCapabilities': dict({ + 'disabledCapabilities': dict({ + 'timestamp': '2025-02-09T09:14:39.642Z', + 'value': list([ + 'remoteControlStatus', + 'airQualitySensor', + 'dustSensor', + 'veryFineDustSensor', + 'custom.dustFilter', + 'custom.deodorFilter', + 'custom.deviceReportStateConfiguration', + 'samsungce.dongleSoftwareInstallation', + 'demandResponseLoadControl', + 'custom.airConditionerOdorController', + ]), + }), + }), + 'custom.disabledComponents': dict({ + 'disabledComponents': dict({ + 'timestamp': '2025-02-09T09:14:39.642Z', + 'value': list([ + '1', + ]), + }), + }), + 'custom.dustFilter': dict({ + 'dustFilterCapacity': dict({ + 'timestamp': '2021-04-06T16:43:35.527Z', + 'value': None, + }), + 'dustFilterLastResetDate': dict({ + 'timestamp': '2021-04-06T16:43:35.527Z', + 'value': None, + }), + 'dustFilterResetType': dict({ + 'timestamp': '2021-04-06T16:43:35.527Z', + 'value': None, + }), + 'dustFilterStatus': dict({ + 'timestamp': '2021-04-06T16:43:35.527Z', + 'value': None, + }), + 'dustFilterUsage': dict({ + 'timestamp': '2021-04-06T16:43:35.527Z', + 'value': None, + }), + 'dustFilterUsageStep': dict({ + 'timestamp': '2021-04-06T16:43:35.527Z', + 'value': None, + }), + }), + 'custom.energyType': dict({ + 'drMaxDuration': dict({ + 'value': None, + }), + 'energySavingInfo': dict({ + 'value': None, + }), + 'energySavingLevel': dict({ + 'value': None, + }), + 'energySavingOperation': dict({ + 'value': None, + }), + 'energySavingOperationSupport': dict({ + 'value': None, + }), + 'energySavingSupport': dict({ + 'timestamp': '2021-12-29T07:29:17.526Z', + 'value': False, + }), + 'energyType': dict({ + 'timestamp': '2024-09-10T10:26:28.781Z', + 'value': '1.0', + }), + 'notificationTemplateID': dict({ + 'value': None, + }), + 'supportedEnergySavingLevels': dict({ + 'value': None, + }), + }), + 'custom.spiMode': dict({ + 'spiMode': dict({ + 'timestamp': '2025-02-09T09:14:39.642Z', + 'value': 'off', + }), + }), + 'custom.thermostatSetpointControl': dict({ + 'maximumSetpoint': dict({ + 'timestamp': '2024-09-10T10:26:28.781Z', + 'unit': 'C', + 'value': 30, + }), + 'minimumSetpoint': dict({ + 'timestamp': '2025-01-08T06:30:58.307Z', + 'unit': 'C', + 'value': 16, + }), + }), + 'demandResponseLoadControl': dict({ + 'drlcStatus': dict({ + 'timestamp': '2024-09-10T10:26:28.781Z', + 'value': dict({ + 'drlcLevel': -1, + 'drlcType': 1, + 'duration': 0, + 'override': False, + 'start': '1970-01-01T00:00:00Z', }), }), }), - }), - 'fanOscillationMode': dict({ - 'availableFanOscillationModes': dict({ - 'data': None, - 'timestamp': None, - 'unit': None, - 'value': None, - }), - 'fanOscillationMode': dict({ - 'data': None, - 'timestamp': '2025-02-09T09:14:39.249000+00:00', - 'unit': None, - 'value': 'fixed', - }), - 'supportedFanOscillationModes': dict({ - 'data': None, - 'timestamp': '2021-04-06T16:43:35.782000+00:00', - 'unit': None, - 'value': None, - }), - }), - 'ocf': dict({ - 'di': dict({ - 'data': None, - 'timestamp': '2024-09-10T10:26:28.552000+00:00', - 'unit': None, - 'value': '96a5ef74-5832-a84b-f1f7-ca799957065d', - }), - 'dmv': dict({ - 'data': None, - 'timestamp': '2024-09-10T10:26:28.552000+00:00', - 'unit': None, - 'value': 'res.1.1.0,sh.1.1.0', - }), - 'icv': dict({ - 'data': None, - 'timestamp': '2024-09-10T10:26:28.552000+00:00', - 'unit': None, - 'value': 'core.1.1.0', - }), - 'mndt': dict({ - 'data': None, - 'timestamp': '2021-04-06T16:43:35.912000+00:00', - 'unit': None, - 'value': None, - }), - 'mnfv': dict({ - 'data': None, - 'timestamp': '2024-09-10T10:26:28.552000+00:00', - 'unit': None, - 'value': '0.1.0', - }), - 'mnhw': dict({ - 'data': None, - 'timestamp': '2024-09-10T10:26:28.552000+00:00', - 'unit': None, - 'value': '1.0', - }), - 'mnml': dict({ - 'data': None, - 'timestamp': '2024-09-10T10:26:28.552000+00:00', - 'unit': None, - 'value': 'http://www.samsung.com', - }), - 'mnmn': dict({ - 'data': None, - 'timestamp': '2024-09-10T10:26:28.552000+00:00', - 'unit': None, - 'value': 'Samsung Electronics', - }), - 'mnmo': dict({ - 'data': None, - 'timestamp': '2024-09-10T10:26:28.781000+00:00', - 'unit': None, - 'value': 'ARTIK051_KRAC_18K|10193441|60010132001111110200000000000000', - }), - 'mnos': dict({ - 'data': None, - 'timestamp': '2024-09-10T10:26:28.552000+00:00', - 'unit': None, - 'value': 'TizenRT2.0', - }), - 'mnpv': dict({ - 'data': None, - 'timestamp': '2024-09-10T10:26:28.552000+00:00', - 'unit': None, - 'value': '0G3MPDCKA00010E', - }), - 'mnsl': dict({ - 'data': None, - 'timestamp': '2021-04-06T16:43:35.803000+00:00', - 'unit': None, - 'value': None, - }), - 'n': dict({ - 'data': None, - 'timestamp': '2024-09-10T10:26:28.552000+00:00', - 'unit': None, - 'value': '[room a/c] Samsung', - }), - 'pi': dict({ - 'data': None, - 'timestamp': '2024-09-10T10:26:28.552000+00:00', - 'unit': None, - 'value': '96a5ef74-5832-a84b-f1f7-ca799957065d', - }), - 'st': dict({ - 'data': None, - 'timestamp': '2021-04-06T16:43:35.933000+00:00', - 'unit': None, - 'value': None, - }), - 'vid': dict({ - 'data': None, - 'timestamp': '2024-09-10T10:26:28.552000+00:00', - 'unit': None, - 'value': 'DA-AC-RAC-000001', - }), - }), - 'powerConsumptionReport': dict({ - 'powerConsumption': dict({ - 'data': None, - 'timestamp': '2025-02-09T16:15:33.639000+00:00', - 'unit': None, - 'value': dict({ - 'deltaEnergy': 400, - 'end': '2025-02-09T16:15:33Z', - 'energy': 2247300, - 'energySaved': 0, - 'persistedEnergy': 2247300, - 'power': 0, - 'powerEnergy': 0.0, - 'start': '2025-02-09T15:45:29Z', + 'dustSensor': dict({ + 'dustLevel': dict({ + 'timestamp': '2021-04-06T16:43:35.665Z', + 'unit': 'μg/m^3', + 'value': None, + }), + 'fineDustLevel': dict({ + 'timestamp': '2021-04-06T16:43:35.665Z', + 'unit': 'μg/m^3', + 'value': None, }), }), - }), - 'refresh': dict({ - }), - 'relativeHumidityMeasurement': dict({ - 'humidity': dict({ - 'data': None, - 'timestamp': '2024-12-30T13:10:23.759000+00:00', - 'unit': '%', - 'value': 60, + 'execute': dict({ + 'data': dict({ + 'data': dict({ + 'href': '/temperature/desired/0', + }), + 'timestamp': '2023-07-19T03:07:43.270Z', + 'value': dict({ + 'payload': dict({ + 'if': list([ + 'oic.if.baseline', + 'oic.if.a', + ]), + 'range': list([ + 16.0, + 30.0, + ]), + 'rt': list([ + 'oic.r.temperature', + ]), + 'temperature': 22.0, + 'units': 'C', + }), + }), + }), }), - }), - 'samsungce.deviceIdentification': dict({ - 'binaryId': dict({ - 'data': None, - 'timestamp': '2025-02-08T00:44:53.855000+00:00', - 'unit': None, - 'value': 'ARTIK051_KRAC_18K', + 'fanOscillationMode': dict({ + 'availableFanOscillationModes': dict({ + 'value': None, + }), + 'fanOscillationMode': dict({ + 'timestamp': '2025-02-09T09:14:39.249Z', + 'value': 'fixed', + }), + 'supportedFanOscillationModes': dict({ + 'timestamp': '2021-04-06T16:43:35.782Z', + 'value': None, + }), }), - 'description': dict({ - 'data': None, - 'timestamp': None, - 'unit': None, - 'value': None, + 'ocf': dict({ + 'di': dict({ + 'timestamp': '2024-09-10T10:26:28.552Z', + 'value': '96a5ef74-5832-a84b-f1f7-ca799957065d', + }), + 'dmv': dict({ + 'timestamp': '2024-09-10T10:26:28.552Z', + 'value': 'res.1.1.0,sh.1.1.0', + }), + 'icv': dict({ + 'timestamp': '2024-09-10T10:26:28.552Z', + 'value': 'core.1.1.0', + }), + 'mndt': dict({ + 'timestamp': '2021-04-06T16:43:35.912Z', + 'value': None, + }), + 'mnfv': dict({ + 'timestamp': '2024-09-10T10:26:28.552Z', + 'value': '0.1.0', + }), + 'mnhw': dict({ + 'timestamp': '2024-09-10T10:26:28.552Z', + 'value': '1.0', + }), + 'mnml': dict({ + 'timestamp': '2024-09-10T10:26:28.552Z', + 'value': 'http://www.samsung.com', + }), + 'mnmn': dict({ + 'timestamp': '2024-09-10T10:26:28.552Z', + 'value': 'Samsung Electronics', + }), + 'mnmo': dict({ + 'timestamp': '2024-09-10T10:26:28.781Z', + 'value': 'ARTIK051_KRAC_18K|10193441|60010132001111110200000000000000', + }), + 'mnos': dict({ + 'timestamp': '2024-09-10T10:26:28.552Z', + 'value': 'TizenRT2.0', + }), + 'mnpv': dict({ + 'timestamp': '2024-09-10T10:26:28.552Z', + 'value': '0G3MPDCKA00010E', + }), + 'mnsl': dict({ + 'timestamp': '2021-04-06T16:43:35.803Z', + 'value': None, + }), + 'n': dict({ + 'timestamp': '2024-09-10T10:26:28.552Z', + 'value': '[room a/c] Samsung', + }), + 'pi': dict({ + 'timestamp': '2024-09-10T10:26:28.552Z', + 'value': '96a5ef74-5832-a84b-f1f7-ca799957065d', + }), + 'st': dict({ + 'timestamp': '2021-04-06T16:43:35.933Z', + 'value': None, + }), + 'vid': dict({ + 'timestamp': '2024-09-10T10:26:28.552Z', + 'value': 'DA-AC-RAC-000001', + }), }), - 'micomAssayCode': dict({ - 'data': None, - 'timestamp': None, - 'unit': None, - 'value': None, + 'powerConsumptionReport': dict({ + 'powerConsumption': dict({ + 'timestamp': '2025-02-09T16:15:33.639Z', + 'value': dict({ + 'deltaEnergy': 400, + 'end': '2025-02-09T16:15:33Z', + 'energy': 2247300, + 'energySaved': 0, + 'persistedEnergy': 2247300, + 'power': 0, + 'powerEnergy': 0.0, + 'start': '2025-02-09T15:45:29Z', + }), + }), }), - 'modelClassificationCode': dict({ - 'data': None, - 'timestamp': None, - 'unit': None, - 'value': None, + 'refresh': dict({ }), - 'modelName': dict({ - 'data': None, - 'timestamp': None, - 'unit': None, - 'value': None, + 'relativeHumidityMeasurement': dict({ + 'humidity': dict({ + 'timestamp': '2024-12-30T13:10:23.759Z', + 'unit': '%', + 'value': 60, + }), }), - 'releaseYear': dict({ - 'data': None, - 'timestamp': None, - 'unit': None, - 'value': None, + 'remoteControlStatus': dict({ + 'remoteControlEnabled': dict({ + 'timestamp': '2021-04-06T16:43:35.379Z', + 'value': None, + }), }), - 'serialNumber': dict({ - 'data': None, - 'timestamp': None, - 'unit': None, - 'value': None, + 'samsungce.deviceIdentification': dict({ + 'binaryId': dict({ + 'timestamp': '2025-02-08T00:44:53.855Z', + 'value': 'ARTIK051_KRAC_18K', + }), + 'description': dict({ + 'value': None, + }), + 'micomAssayCode': dict({ + 'value': None, + }), + 'modelClassificationCode': dict({ + 'value': None, + }), + 'modelName': dict({ + 'value': None, + }), + 'releaseYear': dict({ + 'value': None, + }), + 'serialNumber': dict({ + 'value': None, + }), + 'serialNumberExtra': dict({ + 'value': None, + }), }), - 'serialNumberExtra': dict({ - 'data': None, - 'timestamp': None, - 'unit': None, - 'value': None, + 'samsungce.dongleSoftwareInstallation': dict({ + 'status': dict({ + 'timestamp': '2021-12-29T01:36:51.289Z', + 'value': 'completed', + }), }), - }), - 'samsungce.driverVersion': dict({ - 'versionNumber': dict({ - 'data': None, - 'timestamp': '2024-09-04T06:35:09.557000+00:00', - 'unit': None, - 'value': 24070101, + 'samsungce.driverVersion': dict({ + 'versionNumber': dict({ + 'timestamp': '2024-09-04T06:35:09.557Z', + 'value': 24070101, + }), }), - }), - 'samsungce.selfCheck': dict({ - 'errors': dict({ - 'data': None, - 'timestamp': '2025-02-08T00:44:53.349000+00:00', - 'unit': None, - 'value': list([ - ]), + 'samsungce.selfCheck': dict({ + 'errors': dict({ + 'timestamp': '2025-02-08T00:44:53.349Z', + 'value': list([ + ]), + }), + 'progress': dict({ + 'value': None, + }), + 'result': dict({ + 'value': None, + }), + 'status': dict({ + 'timestamp': '2025-02-08T00:44:53.549Z', + 'value': 'ready', + }), + 'supportedActions': dict({ + 'timestamp': '2024-09-04T06:35:09.557Z', + 'value': list([ + 'start', + ]), + }), }), - 'progress': dict({ - 'data': None, - 'timestamp': None, - 'unit': None, - 'value': None, + 'samsungce.softwareUpdate': dict({ + 'availableModules': dict({ + 'timestamp': '2025-02-08T00:44:53.855Z', + 'value': list([ + ]), + }), + 'lastUpdatedDate': dict({ + 'value': None, + }), + 'newVersionAvailable': dict({ + 'timestamp': '2025-02-08T00:44:53.855Z', + 'value': False, + }), + 'operatingState': dict({ + 'value': None, + }), + 'otnDUID': dict({ + 'timestamp': '2025-02-08T00:44:53.855Z', + 'value': '43CEZFTFFL7Z2', + }), + 'progress': dict({ + 'value': None, + }), + 'targetModule': dict({ + 'value': None, + }), }), - 'result': dict({ - 'data': None, - 'timestamp': None, - 'unit': None, - 'value': None, - }), - 'status': dict({ - 'data': None, - 'timestamp': '2025-02-08T00:44:53.549000+00:00', - 'unit': None, - 'value': 'ready', - }), - 'supportedActions': dict({ - 'data': None, - 'timestamp': '2024-09-04T06:35:09.557000+00:00', - 'unit': None, - 'value': list([ - 'start', - ]), - }), - }), - 'samsungce.softwareUpdate': dict({ - 'availableModules': dict({ - 'data': None, - 'timestamp': '2025-02-08T00:44:53.855000+00:00', - 'unit': None, - 'value': list([ - ]), - }), - 'lastUpdatedDate': dict({ - 'data': None, - 'timestamp': None, - 'unit': None, - 'value': None, - }), - 'newVersionAvailable': dict({ - 'data': None, - 'timestamp': '2025-02-08T00:44:53.855000+00:00', - 'unit': None, - 'value': 'False', - }), - 'operatingState': dict({ - 'data': None, - 'timestamp': None, - 'unit': None, - 'value': None, - }), - 'otnDUID': dict({ - 'data': None, - 'timestamp': '2025-02-08T00:44:53.855000+00:00', - 'unit': None, - 'value': '43CEZFTFFL7Z2', - }), - 'progress': dict({ - 'data': None, - 'timestamp': None, - 'unit': None, - 'value': None, - }), - 'targetModule': dict({ - 'data': None, - 'timestamp': None, - 'unit': None, - 'value': None, - }), - }), - 'switch': dict({ 'switch': dict({ - 'data': None, - 'timestamp': '2025-02-09T16:37:54.072000+00:00', - 'unit': None, - 'value': 'off', + 'switch': dict({ + 'timestamp': '2025-02-09T16:37:54.072Z', + 'value': 'off', + }), }), - }), - 'temperatureMeasurement': dict({ - 'temperature': dict({ - 'data': None, - 'timestamp': '2025-02-09T16:33:29.164000+00:00', - 'unit': 'C', - 'value': 25, + 'temperatureMeasurement': dict({ + 'temperature': dict({ + 'timestamp': '2025-02-09T16:33:29.164Z', + 'unit': 'C', + 'value': 25, + }), + 'temperatureRange': dict({ + 'value': None, + }), }), - 'temperatureRange': dict({ - 'data': None, - 'timestamp': None, - 'unit': None, - 'value': None, + 'thermostatCoolingSetpoint': dict({ + 'coolingSetpoint': dict({ + 'timestamp': '2025-02-09T09:15:11.608Z', + 'unit': 'C', + 'value': 25, + }), + 'coolingSetpointRange': dict({ + 'value': None, + }), }), - }), - 'thermostatCoolingSetpoint': dict({ - 'coolingSetpoint': dict({ - 'data': None, - 'timestamp': '2025-02-09T09:15:11.608000+00:00', - 'unit': 'C', - 'value': 25, - }), - 'coolingSetpointRange': dict({ - 'data': None, - 'timestamp': None, - 'unit': None, - 'value': None, + 'veryFineDustSensor': dict({ + 'veryFineDustLevel': dict({ + 'timestamp': '2021-04-06T16:43:35.363Z', + 'unit': 'μg/m^3', + 'value': None, + }), }), }), }), diff --git a/tests/components/smartthings/test_diagnostics.py b/tests/components/smartthings/test_diagnostics.py index 768be155c86..f486c19de14 100644 --- a/tests/components/smartthings/test_diagnostics.py +++ b/tests/components/smartthings/test_diagnostics.py @@ -12,13 +12,36 @@ from homeassistant.helpers import device_registry as dr from . import setup_integration -from tests.common import MockConfigEntry -from tests.components.diagnostics import get_diagnostics_for_device +from tests.common import MockConfigEntry, load_json_object_fixture +from tests.components.diagnostics import ( + get_diagnostics_for_config_entry, + get_diagnostics_for_device, +) from tests.typing import ClientSessionGenerator @pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) -async def test_device( +async def test_config_entry_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + devices: AsyncMock, + mock_smartthings: AsyncMock, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test generating diagnostics for a device entry.""" + mock_smartthings.get_raw_devices.return_value = load_json_object_fixture( + "devices/da_ac_rac_000001.json", DOMAIN + ) + await setup_integration(hass, mock_config_entry) + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, mock_config_entry) + == snapshot + ) + + +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) +async def test_device_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, device_registry: dr.DeviceRegistry, @@ -28,13 +51,19 @@ async def test_device( snapshot: SnapshotAssertion, ) -> None: """Test generating diagnostics for a device entry.""" + mock_smartthings.get_raw_device_status.return_value = load_json_object_fixture( + "device_status/da_ac_rac_000001.json", DOMAIN + ) + mock_smartthings.get_raw_device.return_value = load_json_object_fixture( + "devices/da_ac_rac_000001.json", DOMAIN + )["items"][0] await setup_integration(hass, mock_config_entry) device = device_registry.async_get_device( identifiers={(DOMAIN, "96a5ef74-5832-a84b-f1f7-ca799957065d")} ) - mock_smartthings.get_device_status.reset_mock() + mock_smartthings.get_raw_device_status.reset_mock() with patch("homeassistant.components.smartthings.diagnostics.EVENT_WAIT_TIME", 0.1): diag = await get_diagnostics_for_device( @@ -44,6 +73,6 @@ async def test_device( assert diag == snapshot( exclude=props("last_changed", "last_reported", "last_updated") ) - mock_smartthings.get_device_status.assert_called_once_with( + mock_smartthings.get_raw_device_status.assert_called_once_with( "96a5ef74-5832-a84b-f1f7-ca799957065d" ) From b4794b202951a001d40f4e4cff4a76edbb465a0a Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Fri, 7 Mar 2025 10:44:17 +0100 Subject: [PATCH 2329/3148] Set content length when uploading files to WebDAV (#139950) --- homeassistant/components/webdav/backup.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/webdav/backup.py b/homeassistant/components/webdav/backup.py index f810547022b..11d0a459852 100644 --- a/homeassistant/components/webdav/backup.py +++ b/homeassistant/components/webdav/backup.py @@ -171,6 +171,7 @@ class WebDavBackupAgent(BackupAgent): await open_stream(), f"{self._backup_path}/{filename_tar}", timeout=BACKUP_TIMEOUT, + content_length=backup.size, ) _LOGGER.debug( From fb4c50b5dc916388d07845d6097541d2d230e7f3 Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Thu, 6 Mar 2025 12:44:13 -0500 Subject: [PATCH 2330/3148] Bump to python-snoo 0.6.1 (#139954) --- homeassistant/components/snoo/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/snoo/manifest.json b/homeassistant/components/snoo/manifest.json index 3dca8cfe7dd..c9306e58413 100644 --- a/homeassistant/components/snoo/manifest.json +++ b/homeassistant/components/snoo/manifest.json @@ -7,5 +7,5 @@ "iot_class": "cloud_push", "loggers": ["snoo"], "quality_scale": "bronze", - "requirements": ["python-snoo==0.6.0"] + "requirements": ["python-snoo==0.6.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 592dc394655..dd6bd24fe2e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2467,7 +2467,7 @@ python-roborock==2.11.1 python-smarttub==0.0.39 # homeassistant.components.snoo -python-snoo==0.6.0 +python-snoo==0.6.1 # homeassistant.components.songpal python-songpal==0.16.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e58596173bc..337338d9716 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2000,7 +2000,7 @@ python-roborock==2.11.1 python-smarttub==0.0.39 # homeassistant.components.snoo -python-snoo==0.6.0 +python-snoo==0.6.1 # homeassistant.components.songpal python-songpal==0.16.2 From 714962bd7a7da2217d7ca235d5e1519572d03d00 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 6 Mar 2025 18:47:37 +0100 Subject: [PATCH 2331/3148] Fix SmartThings fan (#139962) --- homeassistant/components/smartthings/fan.py | 6 +- tests/components/smartthings/conftest.py | 1 + .../device_status/generic_fan_3_speed.json | 19 ++++++ .../fixtures/devices/generic_fan_3_speed.json | 63 +++++++++++++++++++ .../smartthings/snapshots/test_fan.ambr | 56 ++++++++++++++++- .../smartthings/snapshots/test_init.ambr | 33 ++++++++++ 6 files changed, 176 insertions(+), 2 deletions(-) create mode 100644 tests/components/smartthings/fixtures/device_status/generic_fan_3_speed.json create mode 100644 tests/components/smartthings/fixtures/devices/generic_fan_3_speed.json diff --git a/homeassistant/components/smartthings/fan.py b/homeassistant/components/smartthings/fan.py index 8edf01ec613..1c4cb4edc4a 100644 --- a/homeassistant/components/smartthings/fan.py +++ b/homeassistant/components/smartthings/fan.py @@ -116,7 +116,7 @@ class SmartThingsFan(SmartThingsEntity, FanEntity): @property def is_on(self) -> bool: """Return true if fan is on.""" - return self.get_attribute_value(Capability.SWITCH, Attribute.SWITCH) + return self.get_attribute_value(Capability.SWITCH, Attribute.SWITCH) == "on" @property def percentage(self) -> int | None: @@ -132,6 +132,8 @@ class SmartThingsFan(SmartThingsEntity, FanEntity): Requires FanEntityFeature.PRESET_MODE. """ + if not self.supports_capability(Capability.AIR_CONDITIONER_FAN_MODE): + return None return self.get_attribute_value( Capability.AIR_CONDITIONER_FAN_MODE, Attribute.FAN_MODE ) @@ -142,6 +144,8 @@ class SmartThingsFan(SmartThingsEntity, FanEntity): Requires FanEntityFeature.PRESET_MODE. """ + if not self.supports_capability(Capability.AIR_CONDITIONER_FAN_MODE): + return None return self.get_attribute_value( Capability.AIR_CONDITIONER_FAN_MODE, Attribute.SUPPORTED_AC_FAN_MODES ) diff --git a/tests/components/smartthings/conftest.py b/tests/components/smartthings/conftest.py index 4144cf8bcbc..b5fc7fe47cf 100644 --- a/tests/components/smartthings/conftest.py +++ b/tests/components/smartthings/conftest.py @@ -115,6 +115,7 @@ def mock_smartthings() -> Generator[AsyncMock]: "ecobee_sensor", "ecobee_thermostat", "fake_fan", + "generic_fan_3_speed", ] ) def device_fixture( diff --git a/tests/components/smartthings/fixtures/device_status/generic_fan_3_speed.json b/tests/components/smartthings/fixtures/device_status/generic_fan_3_speed.json new file mode 100644 index 00000000000..9335bd8e042 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/generic_fan_3_speed.json @@ -0,0 +1,19 @@ +{ + "components": { + "main": { + "refresh": {}, + "fanSpeed": { + "fanSpeed": { + "value": 0, + "timestamp": "2025-03-06T11:47:32.683Z" + } + }, + "switch": { + "switch": { + "value": "off", + "timestamp": "2025-03-06T11:47:32.697Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/devices/generic_fan_3_speed.json b/tests/components/smartthings/fixtures/devices/generic_fan_3_speed.json new file mode 100644 index 00000000000..db218189c68 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/generic_fan_3_speed.json @@ -0,0 +1,63 @@ +{ + "items": [ + { + "deviceId": "6d95a8b7-4ee3-429a-a13a-00ec9354170c", + "name": "GE In-Wall Smart Dimmer", + "label": "Bedroom Fan", + "manufacturerName": "SmartThingsEdge", + "presentationId": "generic-fan-3-speed", + "deviceManufacturerCode": "0063-4944-3131", + "locationId": "f1313f27-6732-481d-a2a9-c7bbf900f867", + "ownerId": "e5216062-ac82-79b8-20db-ea65fa3d3fdd", + "roomId": "5f77f7cf-ece8-485e-a409-98f7b128a41a", + "components": [ + { + "id": "main", + "label": "Bedroom Fan", + "capabilities": [ + { + "id": "fanSpeed", + "version": 1 + }, + { + "id": "switch", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + } + ], + "categories": [ + { + "name": "Fan", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2018-01-12T22:12:15Z", + "parentDeviceId": "4ceb9b86-2f0d-4e98-ba4e-3fbe705f7805", + "profile": { + "id": "9bd81754-fc81-3ed1-86c2-d1094d6cbf6d" + }, + "zwave": { + "networkId": "02", + "driverId": "e7947a05-947d-4bb5-92c4-2aafaff6d69c", + "executingLocally": true, + "hubId": "4ceb9b86-2f0d-4e98-ba4e-3fbe705f7805", + "networkSecurityLevel": "ZWAVE_LEGACY_NON_SECURE", + "provisioningState": "PROVISIONED", + "manufacturerId": 99, + "productType": 18756, + "productId": 12593 + }, + "type": "ZWAVE", + "restrictionTier": 0, + "allowed": null, + "executionContext": "LOCAL", + "relationships": [] + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/snapshots/test_fan.ambr b/tests/components/smartthings/snapshots/test_fan.ambr index 33caffcacc6..40ab7b12267 100644 --- a/tests/components/smartthings/snapshots/test_fan.ambr +++ b/tests/components/smartthings/snapshots/test_fan.ambr @@ -62,6 +62,60 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'on', + 'state': 'off', + }) +# --- +# name: test_all_entities[generic_fan_3_speed][fan.bedroom_fan-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'preset_modes': None, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.bedroom_fan', + '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': None, + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '6d95a8b7-4ee3-429a-a13a-00ec9354170c', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[generic_fan_3_speed][fan.bedroom_fan-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Bedroom Fan', + 'percentage': 0, + 'percentage_step': 33.333333333333336, + 'preset_mode': None, + 'preset_modes': None, + 'supported_features': , + }), + 'context': , + 'entity_id': 'fan.bedroom_fan', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', }) # --- diff --git a/tests/components/smartthings/snapshots/test_init.ambr b/tests/components/smartthings/snapshots/test_init.ambr index 3fb4f6e6bd3..1554c2a7080 100644 --- a/tests/components/smartthings/snapshots/test_init.ambr +++ b/tests/components/smartthings/snapshots/test_init.ambr @@ -626,6 +626,39 @@ 'via_device_id': None, }) # --- +# name: test_devices[generic_fan_3_speed] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '6d95a8b7-4ee3-429a-a13a-00ec9354170c', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': None, + 'model': None, + 'model_id': None, + 'name': 'Bedroom Fan', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- # name: test_devices[hue_color_temperature_bulb] DeviceRegistryEntrySnapshot({ 'area_id': None, From 352aa88e793ab9ab4690475bed48903669954892 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Thu, 6 Mar 2025 17:52:45 +0100 Subject: [PATCH 2332/3148] Update frontend to 20250306.0 (#139965) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index e661439cff2..b210fdb6661 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20250305.0"] + "requirements": ["home-assistant-frontend==20250306.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index f74bc88bc56..cda2665dcf3 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -37,7 +37,7 @@ habluetooth==3.24.1 hass-nabucasa==0.92.0 hassil==2.2.3 home-assistant-bluetooth==1.13.1 -home-assistant-frontend==20250305.0 +home-assistant-frontend==20250306.0 home-assistant-intents==2025.3.5 httpx==0.28.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index dd6bd24fe2e..d241dd54b62 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1152,7 +1152,7 @@ hole==0.8.0 holidays==0.68 # homeassistant.components.frontend -home-assistant-frontend==20250305.0 +home-assistant-frontend==20250306.0 # homeassistant.components.conversation home-assistant-intents==2025.3.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 337338d9716..273084c15e9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -981,7 +981,7 @@ hole==0.8.0 holidays==0.68 # homeassistant.components.frontend -home-assistant-frontend==20250305.0 +home-assistant-frontend==20250306.0 # homeassistant.components.conversation home-assistant-intents==2025.3.5 From 89756394c9a607d21f7bd12f4cda8d418278cf46 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 6 Mar 2025 17:52:05 +0100 Subject: [PATCH 2333/3148] Fix SmartThings dust sensor UoM (#139977) --- homeassistant/components/smartthings/sensor.py | 1 + .../fixtures/device_status/da_ac_rac_100001.json | 8 ++++++-- tests/components/smartthings/snapshots/test_sensor.ambr | 4 ++-- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index 0a695876da4..56d96bc4ce0 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -951,6 +951,7 @@ UNITS = { "F": UnitOfTemperature.FAHRENHEIT, "lux": LIGHT_LUX, "mG": None, + "μg/m^3": CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, } diff --git a/tests/components/smartthings/fixtures/device_status/da_ac_rac_100001.json b/tests/components/smartthings/fixtures/device_status/da_ac_rac_100001.json index 305624e5b3b..5c062d904bb 100644 --- a/tests/components/smartthings/fixtures/device_status/da_ac_rac_100001.json +++ b/tests/components/smartthings/fixtures/device_status/da_ac_rac_100001.json @@ -146,10 +146,14 @@ }, "dustSensor": { "dustLevel": { - "value": null + "value": 46, + "unit": "\u03bcg/m^3", + "timestamp": "2025-03-06T16:01:49.656000+00:00" }, "fineDustLevel": { - "value": null + "value": 10, + "unit": "\u03bcg/m^3", + "timestamp": "2025-03-06T16:01:49.656000+00:00" } }, "thermostatCoolingSetpoint": { diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index ba2a21fe86b..fa9af0f2812 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -1479,7 +1479,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '46', }) # --- # name: test_all_entities[da_ac_rac_100001][sensor.corridor_a_c_pm2_5-entry] @@ -1531,7 +1531,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '10', }) # --- # name: test_all_entities[da_ac_rac_100001][sensor.corridor_a_c_temperature-entry] From c2c5274aacc7b0aeb9d0a3d9c382f96fc148815a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 6 Mar 2025 10:10:07 -1000 Subject: [PATCH 2334/3148] Bump nexia to 2.2.2 (#139986) changelog: https://github.com/bdraco/nexia/compare/2.2.1...2.2.2 --- homeassistant/components/nexia/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/nexia/manifest.json b/homeassistant/components/nexia/manifest.json index 337378a283c..09b79d37c55 100644 --- a/homeassistant/components/nexia/manifest.json +++ b/homeassistant/components/nexia/manifest.json @@ -12,5 +12,5 @@ "documentation": "https://www.home-assistant.io/integrations/nexia", "iot_class": "cloud_polling", "loggers": ["nexia"], - "requirements": ["nexia==2.2.1"] + "requirements": ["nexia==2.2.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index d241dd54b62..d2ba6d4197b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1483,7 +1483,7 @@ nettigo-air-monitor==4.0.0 neurio==0.3.1 # homeassistant.components.nexia -nexia==2.2.1 +nexia==2.2.2 # homeassistant.components.nextcloud nextcloudmonitor==1.5.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 273084c15e9..941dfefebca 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1246,7 +1246,7 @@ netmap==0.7.0.2 nettigo-air-monitor==4.0.0 # homeassistant.components.nexia -nexia==2.2.1 +nexia==2.2.2 # homeassistant.components.nextcloud nextcloudmonitor==1.5.1 From 5d9d93d3a179bd332d64c0a5d74b667f685a191f Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Thu, 6 Mar 2025 23:06:47 +0100 Subject: [PATCH 2335/3148] Bump aiowebdav2 to 0.4.1 (#139988) --- homeassistant/components/webdav/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/webdav/manifest.json b/homeassistant/components/webdav/manifest.json index 3f465ceed4a..fd3c749781e 100644 --- a/homeassistant/components/webdav/manifest.json +++ b/homeassistant/components/webdav/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["aiowebdav2"], "quality_scale": "bronze", - "requirements": ["aiowebdav2==0.4.0"] + "requirements": ["aiowebdav2==0.4.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index d2ba6d4197b..beceeaf0226 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -422,7 +422,7 @@ aiowaqi==3.1.0 aiowatttime==0.1.1 # homeassistant.components.webdav -aiowebdav2==0.4.0 +aiowebdav2==0.4.1 # homeassistant.components.webostv aiowebostv==0.7.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 941dfefebca..1ff96bf4e4f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -404,7 +404,7 @@ aiowaqi==3.1.0 aiowatttime==0.1.1 # homeassistant.components.webdav -aiowebdav2==0.4.0 +aiowebdav2==0.4.1 # homeassistant.components.webostv aiowebostv==0.7.3 From ccbaf76e44937afada5df874109b91af67027b8c Mon Sep 17 00:00:00 2001 From: Ivan Lopez Hernandez Date: Thu, 6 Mar 2025 13:17:33 -0800 Subject: [PATCH 2336/3148] Correctly retrieve only loaded Google Generative AI config_entries (#139999) * Correctly retrieve only loaded config_entries * Ruff --- .../__init__.py | 6 +-- .../snapshots/test_init.ambr | 15 ++++++ .../test_init.py | 49 +++++++++++++++++++ 3 files changed, 67 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/google_generative_ai_conversation/__init__.py b/homeassistant/components/google_generative_ai_conversation/__init__.py index 33e361d1433..6b10565e0b5 100644 --- a/homeassistant/components/google_generative_ai_conversation/__init__.py +++ b/homeassistant/components/google_generative_ai_conversation/__init__.py @@ -65,9 +65,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: prompt_parts = [call.data[CONF_PROMPT]] - config_entry: GoogleGenerativeAIConfigEntry = hass.config_entries.async_entries( - DOMAIN - )[0] + config_entry: GoogleGenerativeAIConfigEntry = ( + hass.config_entries.async_loaded_entries(DOMAIN)[0] + ) client = config_entry.runtime_data diff --git a/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr b/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr index 8e6231cbffd..ce882adf6e6 100644 --- a/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr +++ b/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr @@ -31,3 +31,18 @@ ), ]) # --- +# name: test_load_entry_with_unloaded_entries + list([ + tuple( + '', + tuple( + ), + dict({ + 'contents': list([ + 'Write an opening speech for a Home Assistant release party', + ]), + 'model': 'models/gemini-2.0-flash', + }), + ), + ]) +# --- diff --git a/tests/components/google_generative_ai_conversation/test_init.py b/tests/components/google_generative_ai_conversation/test_init.py index 0dad485812e..25533ffd46e 100644 --- a/tests/components/google_generative_ai_conversation/test_init.py +++ b/tests/components/google_generative_ai_conversation/test_init.py @@ -224,3 +224,52 @@ async def test_config_entry_error( await hass.async_block_till_done() assert mock_config_entry.state == state assert any(mock_config_entry.async_get_active_flows(hass, {"reauth"})) == reauth + + +@pytest.mark.usefixtures("mock_init_component") +async def test_load_entry_with_unloaded_entries( + hass: HomeAssistant, snapshot: SnapshotAssertion +) -> None: + """Test loading an entry with unloaded entries.""" + config_entries = hass.config_entries.async_entries( + "google_generative_ai_conversation" + ) + runtime_data = config_entries[0].runtime_data + await hass.config_entries.async_unload(config_entries[0].entry_id) + + entry = MockConfigEntry( + domain="google_generative_ai_conversation", + title="Google Generative AI Conversation", + data={ + "api_key": "bla", + }, + state=ConfigEntryState.LOADED, + ) + entry.runtime_data = runtime_data + entry.add_to_hass(hass) + + stubbed_generated_content = ( + "I'm thrilled to welcome you all to the release " + "party for the latest version of Home Assistant!" + ) + + with patch( + "google.genai.models.AsyncModels.generate_content", + return_value=Mock( + text=stubbed_generated_content, + prompt_feedback=None, + candidates=[Mock()], + ), + ) as mock_generate: + response = await hass.services.async_call( + "google_generative_ai_conversation", + "generate_content", + {"prompt": "Write an opening speech for a Home Assistant release party"}, + blocking=True, + return_response=True, + ) + + assert response == { + "text": stubbed_generated_content, + } + assert [tuple(mock_call) for mock_call in mock_generate.mock_calls] == snapshot From 113cd4bfccaab379f38db7803182692a2ed53495 Mon Sep 17 00:00:00 2001 From: David Bonnes Date: Fri, 7 Mar 2025 09:12:21 +0000 Subject: [PATCH 2337/3148] Fix regression to evohome debug logging (#140000) * fix regression in debug logging * lint --- homeassistant/components/evohome/coordinator.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/evohome/coordinator.py b/homeassistant/components/evohome/coordinator.py index 7b197f1b643..3264af6b2fd 100644 --- a/homeassistant/components/evohome/coordinator.py +++ b/homeassistant/components/evohome/coordinator.py @@ -11,6 +11,7 @@ from typing import Any import evohomeasync as ec1 import evohomeasync2 as ec2 from evohomeasync2.const import ( + SZ_DHW, SZ_GATEWAY_ID, SZ_GATEWAY_INFO, SZ_GATEWAYS, @@ -19,8 +20,9 @@ from evohomeasync2.const import ( SZ_TEMPERATURE_CONTROL_SYSTEMS, SZ_TIME_ZONE, SZ_USE_DAYLIGHT_SAVE_SWITCHING, + SZ_ZONES, ) -from evohomeasync2.schemas.typedefs import EvoLocStatusResponseT +from evohomeasync2.schemas.typedefs import EvoLocStatusResponseT, EvoTcsConfigResponseT from homeassistant.const import CONF_SCAN_INTERVAL from homeassistant.core import HomeAssistant @@ -113,17 +115,19 @@ class EvoDataUpdateCoordinator(DataUpdateCoordinator): SZ_USE_DAYLIGHT_SAVE_SWITCHING ], } + tcs_info: EvoTcsConfigResponseT = self.tcs.config # type: ignore[assignment] + tcs_info[SZ_ZONES] = [zone.config for zone in self.tcs.zones] + if self.tcs.hotwater: + tcs_info[SZ_DHW] = self.tcs.hotwater.config gwy_info = { SZ_GATEWAY_ID: self.loc.gateways[0].id, - SZ_TEMPERATURE_CONTROL_SYSTEMS: [ - self.loc.gateways[0].systems[0].config - ], + SZ_TEMPERATURE_CONTROL_SYSTEMS: [tcs_info], } config = { SZ_LOCATION_INFO: loc_info, SZ_GATEWAYS: [{SZ_GATEWAY_INFO: gwy_info}], } - self.logger.debug("Config = %s", config) + self.logger.debug("Config = %s", [config]) async def call_client_api( self, From efa98539faa573bfee228a39a20f52a765d7dc14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Fri, 7 Mar 2025 01:50:06 +0100 Subject: [PATCH 2338/3148] Check operation state on Home Connect program sensor update (#140011) Check operation state on program sensor update --- .../components/home_connect/sensor.py | 7 ++ tests/components/home_connect/test_sensor.py | 82 ++++++++++++++++++- 2 files changed, 86 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/home_connect/sensor.py b/homeassistant/components/home_connect/sensor.py index 924744ded56..c12e1b7b6e4 100644 --- a/homeassistant/components/home_connect/sensor.py +++ b/homeassistant/components/home_connect/sensor.py @@ -386,6 +386,13 @@ class HomeConnectProgramSensor(HomeConnectSensor): def update_native_value(self) -> None: """Update the program sensor's status.""" + self.program_running = ( + status := self.appliance.status.get(StatusKey.BSH_COMMON_OPERATION_STATE) + ) is not None and status.value in [ + BSH_OPERATION_STATE_RUN, + BSH_OPERATION_STATE_PAUSE, + BSH_OPERATION_STATE_FINISHED, + ] event = self.appliance.events.get(cast(EventKey, self.bsh_key)) if event: self._update_native_value(event.value) diff --git a/tests/components/home_connect/test_sensor.py b/tests/components/home_connect/test_sensor.py index 31fc9ea6d3f..04f5e056aa5 100644 --- a/tests/components/home_connect/test_sensor.py +++ b/tests/components/home_connect/test_sensor.py @@ -27,7 +27,7 @@ from homeassistant.components.home_connect.const import ( DOMAIN, ) from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import STATE_UNAVAILABLE, Platform +from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er @@ -302,7 +302,7 @@ ENTITY_ID_STATES = { ) ), ) -async def test_event_sensors( +async def test_program_sensors( client: MagicMock, appliance_ha_id: str, states: tuple, @@ -313,7 +313,7 @@ async def test_event_sensors( integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, ) -> None: - """Test sequence for sensors that are only available after an event happens.""" + """Test sequence for sensors that expose information about a program.""" entity_ids = ENTITY_ID_STATES.keys() time_to_freeze = "2021-01-09 12:00:00+00:00" @@ -358,6 +358,82 @@ async def test_event_sensors( assert hass.states.is_state(entity_id, state) +@pytest.mark.parametrize("appliance_ha_id", [TEST_HC_APP], indirect=True) +@pytest.mark.parametrize( + ("initial_operation_state", "initial_state", "event_order", "entity_states"), + [ + ( + "BSH.Common.EnumType.OperationState.Ready", + STATE_UNAVAILABLE, + (EventType.STATUS, EventType.EVENT), + (STATE_UNKNOWN, "60"), + ), + ( + "BSH.Common.EnumType.OperationState.Run", + STATE_UNKNOWN, + (EventType.EVENT, EventType.STATUS), + ("60", "60"), + ), + ], +) +async def test_program_sensor_edge_case( + initial_operation_state: str, + initial_state: str, + event_order: tuple[EventType, EventType], + entity_states: tuple[str, str], + appliance_ha_id: str, + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, +) -> None: + """Test edge case for the program related entities.""" + entity_id = "sensor.dishwasher_program_progress" + client.get_status = AsyncMock( + return_value=ArrayOfStatus( + [ + Status( + StatusKey.BSH_COMMON_OPERATION_STATE, + StatusKey.BSH_COMMON_OPERATION_STATE.value, + initial_operation_state, + ) + ] + ) + ) + + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + assert hass.states.is_state(entity_id, initial_state) + + for event_type, state in zip(event_order, entity_states, strict=True): + await client.add_events( + [ + EventMessage( + appliance_ha_id, + event_type, + ArrayOfEvents( + [ + Event( + key=event_key, + raw_key=event_key.value, + timestamp=0, + level="", + handling="", + value=value, + ) + ], + ), + ) + for event_key, value in EVENT_PROG_RUN[event_type].items() + ] + ) + await hass.async_block_till_done() + assert hass.states.is_state(entity_id, state) + + # Program sequence for SensorDeviceClass.TIMESTAMP edge cases. PROGRAM_SEQUENCE_EDGE_CASE = [ EVENT_PROG_DELAYED_START, From 9f94ee280a75e79c42caf1f0438c975840976e41 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Fri, 7 Mar 2025 07:50:34 +0100 Subject: [PATCH 2339/3148] Bump aiohomeconnect to 0.16.3 (#140014) --- homeassistant/components/home_connect/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/home_connect/manifest.json b/homeassistant/components/home_connect/manifest.json index 5293e8bf468..62892e7c85b 100644 --- a/homeassistant/components/home_connect/manifest.json +++ b/homeassistant/components/home_connect/manifest.json @@ -7,6 +7,6 @@ "documentation": "https://www.home-assistant.io/integrations/home_connect", "iot_class": "cloud_push", "loggers": ["aiohomeconnect"], - "requirements": ["aiohomeconnect==0.16.2"], + "requirements": ["aiohomeconnect==0.16.3"], "single_config_entry": true } diff --git a/requirements_all.txt b/requirements_all.txt index beceeaf0226..f2546da0871 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -264,7 +264,7 @@ aioharmony==0.4.1 aiohasupervisor==0.3.0 # homeassistant.components.home_connect -aiohomeconnect==0.16.2 +aiohomeconnect==0.16.3 # homeassistant.components.homekit_controller aiohomekit==3.2.8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1ff96bf4e4f..59c9c213a98 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -249,7 +249,7 @@ aioharmony==0.4.1 aiohasupervisor==0.3.0 # homeassistant.components.home_connect -aiohomeconnect==0.16.2 +aiohomeconnect==0.16.3 # homeassistant.components.homekit_controller aiohomekit==3.2.8 From 5e26d98bdf851f4af913fee269849c9ee8e0fe3d Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Fri, 7 Mar 2025 17:45:25 +1000 Subject: [PATCH 2340/3148] Fix powerwall 0% in Tessie and Tesla Fleet (#140017) Fix powerwall zero --- homeassistant/components/tesla_fleet/sensor.py | 1 + homeassistant/components/tessie/sensor.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/tesla_fleet/sensor.py b/homeassistant/components/tesla_fleet/sensor.py index 64ecc35469b..bdd5ce2c001 100644 --- a/homeassistant/components/tesla_fleet/sensor.py +++ b/homeassistant/components/tesla_fleet/sensor.py @@ -466,6 +466,7 @@ async def async_setup_entry( for energysite in entry.runtime_data.energysites for description in ENERGY_LIVE_DESCRIPTIONS if description.key in energysite.live_coordinator.data + or description.key == "percentage_charged" ), ( # Add energy site history TeslaFleetEnergyHistorySensorEntity(energysite, description) diff --git a/homeassistant/components/tessie/sensor.py b/homeassistant/components/tessie/sensor.py index 4f62e1b1855..1c26ad633f3 100644 --- a/homeassistant/components/tessie/sensor.py +++ b/homeassistant/components/tessie/sensor.py @@ -397,6 +397,7 @@ async def async_setup_entry( for energysite in entry.runtime_data.energysites for description in ENERGY_LIVE_DESCRIPTIONS if description.key in energysite.live_coordinator.data + or description.key == "percentage_charged" ), ( # Add wall connectors TessieWallConnectorSensorEntity(energysite, din, description) @@ -449,7 +450,6 @@ class TessieEnergyLiveSensorEntity(TessieEnergyEntity, SensorEntity): def _async_update_attrs(self) -> None: """Update the attributes of the sensor.""" - self._attr_available = self._value is not None self._attr_native_value = self.entity_description.value_fn(self._value) From b15b680cfe3dff2d9526841e927e7f42336be78d Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Fri, 7 Mar 2025 22:28:21 +1000 Subject: [PATCH 2341/3148] Fix shift state default in Teslemetry and Tessie (#140018) * Fix again * Fix Tessie * Update snap --- homeassistant/components/teslemetry/sensor.py | 12 ++++++------ homeassistant/components/tessie/sensor.py | 2 +- tests/components/tessie/snapshots/test_sensor.ambr | 2 +- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/teslemetry/sensor.py b/homeassistant/components/teslemetry/sensor.py index 56c8830d736..f1859ad39de 100644 --- a/homeassistant/components/teslemetry/sensor.py +++ b/homeassistant/components/teslemetry/sensor.py @@ -68,7 +68,7 @@ class TeslemetryVehicleSensorEntityDescription(SensorEntityDescription): polling: bool = False polling_value_fn: Callable[[StateType], StateType] = lambda x: x - polling_available_fn: Callable[[StateType], bool] = lambda x: x is not None + nullable: bool = False streaming_key: Signal | None = None streaming_value_fn: Callable[[str | int | float], StateType] = lambda x: x streaming_firmware: str = "2024.26" @@ -210,7 +210,7 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryVehicleSensorEntityDescription, ...] = ( TeslemetryVehicleSensorEntityDescription( key="drive_state_shift_state", polling=True, - polling_available_fn=lambda x: True, + nullable=True, polling_value_fn=lambda x: SHIFT_STATES.get(str(x), "p"), streaming_key=Signal.GEAR, streaming_value_fn=lambda x: str(ShiftState.get(x, "P")).lower(), @@ -622,10 +622,10 @@ class TeslemetryStreamSensorEntity(TeslemetryVehicleStreamEntity, RestoreSensor) def _async_value_from_stream(self, value) -> None: """Update the value of the entity.""" - if value is None: - self._attr_native_value = None - else: + if self.entity_description.nullable or value is not None: self._attr_native_value = self.entity_description.streaming_value_fn(value) + else: + self._attr_native_value = None class TeslemetryVehicleSensorEntity(TeslemetryVehicleEntity, SensorEntity): @@ -644,7 +644,7 @@ class TeslemetryVehicleSensorEntity(TeslemetryVehicleEntity, SensorEntity): def _async_update_attrs(self) -> None: """Update the attributes of the sensor.""" - if self.entity_description.polling_available_fn(self._value): + if self.entity_description.nullable or self._value is not None: self._attr_available = True self._attr_native_value = self.entity_description.polling_value_fn( self._value diff --git a/homeassistant/components/tessie/sensor.py b/homeassistant/components/tessie/sensor.py index 1c26ad633f3..e5b476057fa 100644 --- a/homeassistant/components/tessie/sensor.py +++ b/homeassistant/components/tessie/sensor.py @@ -148,7 +148,7 @@ DESCRIPTIONS: tuple[TessieSensorEntityDescription, ...] = ( key="drive_state_shift_state", options=["p", "d", "r", "n"], device_class=SensorDeviceClass.ENUM, - value_fn=lambda x: x.lower() if isinstance(x, str) else x, + value_fn=lambda x: x.lower() if isinstance(x, str) else "p", ), TessieSensorEntityDescription( key="vehicle_state_odometer", diff --git a/tests/components/tessie/snapshots/test_sensor.ambr b/tests/components/tessie/snapshots/test_sensor.ambr index 5465f89d808..b40cf204bca 100644 --- a/tests/components/tessie/snapshots/test_sensor.ambr +++ b/tests/components/tessie/snapshots/test_sensor.ambr @@ -1614,7 +1614,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': 'p', }) # --- # name: test_sensors[sensor.test_speed-entry] From e7ea0e435ed51ee8fe4a8a2775f6f37225fb0021 Mon Sep 17 00:00:00 2001 From: hahn-th <15319212+hahn-th@users.noreply.github.com> Date: Fri, 7 Mar 2025 13:11:45 +0100 Subject: [PATCH 2342/3148] Add description for HomematicIP HCU1 in homematicip_cloud setup config flow (#140025) add description for hcu1 --- homeassistant/components/homematicip_cloud/strings.json | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/homematicip_cloud/strings.json b/homeassistant/components/homematicip_cloud/strings.json index 37deace7ebf..228ebc7500e 100644 --- a/homeassistant/components/homematicip_cloud/strings.json +++ b/homeassistant/components/homematicip_cloud/strings.json @@ -3,6 +3,7 @@ "step": { "init": { "title": "Pick Homematic IP access point", + "description": "If you are about to register a **Homematic IP HCU1**, please press the button on top of the device before you continue.\n\nThe registration process must be completed within 5 minutes.", "data": { "hapid": "Access point ID (SGTIN)", "pin": "[%key:common::config_flow::data::pin%]", From 8bcd135f3d76a41d81e5f4889a54c481677ed027 Mon Sep 17 00:00:00 2001 From: David Bonnes Date: Fri, 7 Mar 2025 12:04:04 +0000 Subject: [PATCH 2343/3148] Fix evohome to gracefully handle null schedules (#140036) * extend tests to catch null schedules * add fixture with null schedule * remove null schedules for now * fic the typing for _schedule attr (is list, not dict) * add valid schedule to fixture * update ssetpoints only if there is a schedule * snapshot to match last change * refactor: dont update switchpoints if no schedule * add in warnings for null schedules * add fixture for DHW without schedule --- .../components/evohome/coordinator.py | 12 +- homeassistant/components/evohome/entity.py | 10 +- tests/components/evohome/conftest.py | 12 +- tests/components/evohome/const.py | 3 +- .../fixtures/botched/schedule_3933910.json | 3 + .../fixtures/h139906/schedule_3454854.json | 3 + .../fixtures/h139906/schedule_3454855.json | 143 +++++++++++++ .../fixtures/h139906/status_2727366.json | 52 +++++ .../fixtures/h139906/user_locations.json | 125 ++++++++++++ .../evohome/snapshots/test_climate.ambr | 188 ++++++++++++++++++ .../evohome/snapshots/test_init.ambr | 3 + .../evohome/snapshots/test_water_heater.ambr | 10 + tests/components/evohome/test_water_heater.py | 2 +- 13 files changed, 553 insertions(+), 13 deletions(-) create mode 100644 tests/components/evohome/fixtures/botched/schedule_3933910.json create mode 100644 tests/components/evohome/fixtures/h139906/schedule_3454854.json create mode 100644 tests/components/evohome/fixtures/h139906/schedule_3454855.json create mode 100644 tests/components/evohome/fixtures/h139906/status_2727366.json create mode 100644 tests/components/evohome/fixtures/h139906/user_locations.json diff --git a/homeassistant/components/evohome/coordinator.py b/homeassistant/components/evohome/coordinator.py index 3264af6b2fd..33af90089a4 100644 --- a/homeassistant/components/evohome/coordinator.py +++ b/homeassistant/components/evohome/coordinator.py @@ -207,10 +207,18 @@ class EvoDataUpdateCoordinator(DataUpdateCoordinator): async def _update_v2_schedules(self) -> None: for zone in self.tcs.zones: - await zone.get_schedule() + try: + await zone.get_schedule() + except ec2.InvalidScheduleError as err: + self.logger.warning( + "Zone '%s' has an invalid/missing schedule: %r", zone.name, err + ) if dhw := self.tcs.hotwater: - await dhw.get_schedule() + try: + await dhw.get_schedule() + except ec2.InvalidScheduleError as err: + self.logger.warning("DHW has an invalid/missing schedule: %r", err) async def _async_update_data(self) -> EvoLocStatusResponseT: # type: ignore[override] """Fetch the latest state of an entire TCC Location. diff --git a/homeassistant/components/evohome/entity.py b/homeassistant/components/evohome/entity.py index 11215dd47b6..2f93f0fb143 100644 --- a/homeassistant/components/evohome/entity.py +++ b/homeassistant/components/evohome/entity.py @@ -6,6 +6,7 @@ import logging from typing import Any import evohomeasync2 as evo +from evohomeasync2.schemas.typedefs import DayOfWeekDhwT from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -102,7 +103,7 @@ class EvoChild(EvoEntity): self._evo_tcs = evo_device.tcs - self._schedule: dict[str, Any] | None = None + self._schedule: list[DayOfWeekDhwT] | None = None self._setpoints: dict[str, Any] = {} @property @@ -123,6 +124,9 @@ class EvoChild(EvoEntity): Only Zones & DHW controllers (but not the TCS) can have schedules. """ + if not self._schedule: + return self._setpoints + this_sp_dtm, this_sp_val = self._evo_device.this_switchpoint next_sp_dtm, next_sp_val = self._evo_device.next_switchpoint @@ -152,10 +156,10 @@ class EvoChild(EvoEntity): self._evo_device, err, ) - self._schedule = {} + self._schedule = [] return else: - self._schedule = schedule or {} # mypy hint + self._schedule = schedule # type: ignore[assignment] _LOGGER.debug("Schedule['%s'] = %s", self.name, schedule) diff --git a/tests/components/evohome/conftest.py b/tests/components/evohome/conftest.py index 5f60bc418e3..313982e3f97 100644 --- a/tests/components/evohome/conftest.py +++ b/tests/components/evohome/conftest.py @@ -48,18 +48,18 @@ def location_status_fixture(install: str, loc_id: str | None = None) -> JsonObje return load_json_object_fixture(f"{install}/status_{loc_id}.json", DOMAIN) -def dhw_schedule_fixture(install: str) -> JsonObjectType: +def dhw_schedule_fixture(install: str, dhw_id: str | None = None) -> JsonObjectType: """Load JSON for the schedule of a domesticHotWater zone.""" try: - return load_json_object_fixture(f"{install}/schedule_dhw.json", DOMAIN) + return load_json_object_fixture(f"{install}/schedule_{dhw_id}.json", DOMAIN) except FileNotFoundError: return load_json_object_fixture("default/schedule_dhw.json", DOMAIN) -def zone_schedule_fixture(install: str) -> JsonObjectType: +def zone_schedule_fixture(install: str, zon_id: str | None = None) -> JsonObjectType: """Load JSON for the schedule of a temperatureZone zone.""" try: - return load_json_object_fixture(f"{install}/schedule_zone.json", DOMAIN) + return load_json_object_fixture(f"{install}/schedule_{zon_id}.json", DOMAIN) except FileNotFoundError: return load_json_object_fixture("default/schedule_zone.json", DOMAIN) @@ -120,9 +120,9 @@ def mock_make_request(install: str) -> Callable: elif "schedule" in url: if url.startswith("domesticHotWater"): # /v2/domesticHotWater/{id}/schedule - return dhw_schedule_fixture(install) + return dhw_schedule_fixture(install, url[16:23]) if url.startswith("temperatureZone"): # /v2/temperatureZone/{id}/schedule - return zone_schedule_fixture(install) + return zone_schedule_fixture(install, url[16:23]) pytest.fail(f"Unexpected request: {HTTPMethod.GET} {url}") diff --git a/tests/components/evohome/const.py b/tests/components/evohome/const.py index c3dc92c3fbc..dceb2f60a06 100644 --- a/tests/components/evohome/const.py +++ b/tests/components/evohome/const.py @@ -15,8 +15,9 @@ TEST_INSTALLS: Final = ( "default", # evohome: multi-zone, with DHW "h032585", # VisionProWifi: no preset modes for TCS, zoneId=systemId "h099625", # RoundThermostat + "h139906", # zone with null schedule "sys_004", # RoundModulation ) # "botched", # as default: but with activeFaults, ghost zones & unknown types -TEST_INSTALLS_WITH_DHW: Final = ("default",) +TEST_INSTALLS_WITH_DHW: Final = ("default", "botched") diff --git a/tests/components/evohome/fixtures/botched/schedule_3933910.json b/tests/components/evohome/fixtures/botched/schedule_3933910.json new file mode 100644 index 00000000000..0e5a9308d5b --- /dev/null +++ b/tests/components/evohome/fixtures/botched/schedule_3933910.json @@ -0,0 +1,3 @@ +{ + "dailySchedules": [] +} diff --git a/tests/components/evohome/fixtures/h139906/schedule_3454854.json b/tests/components/evohome/fixtures/h139906/schedule_3454854.json new file mode 100644 index 00000000000..0e5a9308d5b --- /dev/null +++ b/tests/components/evohome/fixtures/h139906/schedule_3454854.json @@ -0,0 +1,3 @@ +{ + "dailySchedules": [] +} diff --git a/tests/components/evohome/fixtures/h139906/schedule_3454855.json b/tests/components/evohome/fixtures/h139906/schedule_3454855.json new file mode 100644 index 00000000000..12f8a6cb390 --- /dev/null +++ b/tests/components/evohome/fixtures/h139906/schedule_3454855.json @@ -0,0 +1,143 @@ +{ + "dailySchedules": [ + { + "dayOfWeek": "Monday", + "switchpoints": [ + { + "heatSetpoint": 22.0, + "timeOfDay": "05:30:00" + }, + { + "heatSetpoint": 20.0, + "timeOfDay": "08:00:00" + }, + { + "heatSetpoint": 22.5, + "timeOfDay": "16:00:00" + }, + { + "heatSetpoint": 15.0, + "timeOfDay": "23:00:00" + } + ] + }, + { + "dayOfWeek": "Tuesday", + "switchpoints": [ + { + "heatSetpoint": 22.0, + "timeOfDay": "05:30:00" + }, + { + "heatSetpoint": 20.0, + "timeOfDay": "08:00:00" + }, + { + "heatSetpoint": 22.5, + "timeOfDay": "16:00:00" + }, + { + "heatSetpoint": 15.0, + "timeOfDay": "23:00:00" + } + ] + }, + { + "dayOfWeek": "Wednesday", + "switchpoints": [ + { + "heatSetpoint": 22.0, + "timeOfDay": "05:30:00" + }, + { + "heatSetpoint": 20.0, + "timeOfDay": "08:00:00" + }, + { + "heatSetpoint": 22.5, + "timeOfDay": "12:00:00" + }, + { + "heatSetpoint": 15.0, + "timeOfDay": "23:00:00" + } + ] + }, + { + "dayOfWeek": "Thursday", + "switchpoints": [ + { + "heatSetpoint": 22.0, + "timeOfDay": "05:30:00" + }, + { + "heatSetpoint": 20.0, + "timeOfDay": "08:00:00" + }, + { + "heatSetpoint": 22.5, + "timeOfDay": "16:00:00" + }, + { + "heatSetpoint": 15.0, + "timeOfDay": "23:00:00" + } + ] + }, + { + "dayOfWeek": "Friday", + "switchpoints": [ + { + "heatSetpoint": 22.0, + "timeOfDay": "05:30:00" + }, + { + "heatSetpoint": 20.0, + "timeOfDay": "08:00:00" + }, + { + "heatSetpoint": 22.5, + "timeOfDay": "16:00:00" + }, + { + "heatSetpoint": 15.0, + "timeOfDay": "23:00:00" + } + ] + }, + { + "dayOfWeek": "Saturday", + "switchpoints": [ + { + "heatSetpoint": 22.0, + "timeOfDay": "07:00:00" + }, + { + "heatSetpoint": 22.5, + "timeOfDay": "16:00:00" + }, + { + "heatSetpoint": 15.0, + "timeOfDay": "23:00:00" + } + ] + }, + { + "dayOfWeek": "Sunday", + "switchpoints": [ + { + "heatSetpoint": 22.0, + "timeOfDay": "07:30:00" + }, + { + "heatSetpoint": 22.5, + "timeOfDay": "16:00:00" + }, + { + "heatSetpoint": 15.0, + "timeOfDay": "23:00:00" + } + ] + } + ] +} diff --git a/tests/components/evohome/fixtures/h139906/status_2727366.json b/tests/components/evohome/fixtures/h139906/status_2727366.json new file mode 100644 index 00000000000..2c123b796bd --- /dev/null +++ b/tests/components/evohome/fixtures/h139906/status_2727366.json @@ -0,0 +1,52 @@ +{ + "locationId": "2727366", + "gateways": [ + { + "gatewayId": "2513794", + "temperatureControlSystems": [ + { + "systemId": "3454856", + "zones": [ + { + "zoneId": "3454854", + "temperatureStatus": { + "temperature": 22.0, + "isAvailable": true + }, + "activeFaults": [ + { + "faultType": "TempZoneSensorCommunicationLost", + "since": "2025-02-06T11:20:29" + } + ], + "setpointStatus": { + "targetHeatTemperature": 5.0, + "setpointMode": "FollowSchedule" + }, + "name": "Thermostat" + }, + { + "zoneId": "3454855", + "temperatureStatus": { + "temperature": 22.0, + "isAvailable": true + }, + "activeFaults": [], + "setpointStatus": { + "targetHeatTemperature": 20.0, + "setpointMode": "FollowSchedule" + }, + "name": "Thermostat 2" + } + ], + "activeFaults": [], + "systemModeStatus": { + "mode": "Auto", + "isPermanent": true + } + } + ], + "activeFaults": [] + } + ] +} diff --git a/tests/components/evohome/fixtures/h139906/user_locations.json b/tests/components/evohome/fixtures/h139906/user_locations.json new file mode 100644 index 00000000000..14db65a5e0d --- /dev/null +++ b/tests/components/evohome/fixtures/h139906/user_locations.json @@ -0,0 +1,125 @@ +[ + { + "locationInfo": { + "locationId": "2727366", + "name": "Vr**********", + "streetAddress": "********** *", + "city": "*********", + "country": "Netherlands", + "postcode": "******", + "locationType": "Residential", + "useDaylightSaveSwitching": true, + "timeZone": { + "timeZoneId": "WEuropeStandardTime", + "displayName": "(UTC+01:00) Amsterdam, Berlijn, Bern, Rome, Stockholm, Wenen", + "offsetMinutes": 60, + "currentOffsetMinutes": 60, + "supportsDaylightSaving": true + }, + "locationOwner": { + "userId": "2276512", + "username": "nobody@nowhere.com", + "firstname": "Gl***", + "lastname": "de*****" + } + }, + "gateways": [ + { + "gatewayInfo": { + "gatewayId": "2513794", + "mac": "************", + "crc": "****", + "isWiFi": false + }, + "temperatureControlSystems": [ + { + "systemId": "3454856", + "modelType": "EvoTouch", + "zones": [ + { + "zoneId": "3454854", + "modelType": "HeatingZone", + "setpointCapabilities": { + "maxHeatSetpoint": 35.0, + "minHeatSetpoint": 5.0, + "valueResolution": 0.5, + "canControlHeat": true, + "canControlCool": false, + "allowedSetpointModes": [ + "PermanentOverride", + "FollowSchedule", + "TemporaryOverride" + ], + "maxDuration": "1.00:00:00", + "timingResolution": "00:10:00" + }, + "scheduleCapabilities": { + "maxSwitchpointsPerDay": 6, + "minSwitchpointsPerDay": 1, + "timingResolution": "00:10:00", + "setpointValueResolution": 0.5 + }, + "name": "Thermostat", + "zoneType": "ZoneTemperatureControl" + }, + { + "zoneId": "3454855", + "modelType": "RoundWireless", + "setpointCapabilities": { + "maxHeatSetpoint": 35.0, + "minHeatSetpoint": 5.0, + "valueResolution": 0.5, + "canControlHeat": true, + "canControlCool": false, + "allowedSetpointModes": [ + "PermanentOverride", + "FollowSchedule", + "TemporaryOverride" + ], + "maxDuration": "1.00:00:00", + "timingResolution": "00:10:00" + }, + "scheduleCapabilities": { + "maxSwitchpointsPerDay": 6, + "minSwitchpointsPerDay": 0, + "timingResolution": "00:10:00", + "setpointValueResolution": 0.5 + }, + "name": "Thermostat 2", + "zoneType": "Thermostat" + } + ], + "allowedSystemModes": [ + { + "systemMode": "Auto", + "canBePermanent": true, + "canBeTemporary": false + }, + { + "systemMode": "AutoWithEco", + "canBePermanent": true, + "canBeTemporary": true, + "maxDuration": "1.00:00:00", + "timingResolution": "01:00:00", + "timingMode": "Duration" + }, + { + "systemMode": "Away", + "canBePermanent": true, + "canBeTemporary": true, + "maxDuration": "99.00:00:00", + "timingResolution": "1.00:00:00", + "timingMode": "Period" + }, + { + "systemMode": "HeatingOff", + "canBePermanent": true, + "canBeTemporary": false + } + ] + } + ] + } + ] + } +] diff --git a/tests/components/evohome/snapshots/test_climate.ambr b/tests/components/evohome/snapshots/test_climate.ambr index 23a15e3f64f..5a6a6bff863 100644 --- a/tests/components/evohome/snapshots/test_climate.ambr +++ b/tests/components/evohome/snapshots/test_climate.ambr @@ -29,6 +29,16 @@ ), ]) # --- +# name: test_ctl_set_hvac_mode[h139906] + list([ + tuple( + , + ), + tuple( + , + ), + ]) +# --- # name: test_ctl_set_hvac_mode[minimal] list([ tuple( @@ -70,6 +80,13 @@ ), ]) # --- +# name: test_ctl_turn_off[h139906] + list([ + tuple( + , + ), + ]) +# --- # name: test_ctl_turn_off[minimal] list([ tuple( @@ -105,6 +122,13 @@ ), ]) # --- +# name: test_ctl_turn_on[h139906] + list([ + tuple( + , + ), + ]) +# --- # name: test_ctl_turn_on[minimal] list([ tuple( @@ -1118,6 +1142,136 @@ 'state': 'heat', }) # --- +# name: test_setup_platform[h139906][climate.thermostat-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 22.0, + 'friendly_name': 'Thermostat', + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 35.0, + 'min_temp': 5.0, + 'preset_mode': 'none', + 'preset_modes': list([ + 'none', + 'temporary', + 'permanent', + ]), + 'status': dict({ + 'activeFaults': tuple( + dict({ + 'fault_type': 'TempZoneSensorCommunicationLost', + 'since': '2025-02-06T11:20:29+01:00', + }), + ), + 'setpoint_status': dict({ + 'setpoint_mode': 'FollowSchedule', + 'target_heat_temperature': 5.0, + }), + 'setpoints': dict({ + }), + 'temperature_status': dict({ + 'is_available': True, + 'temperature': 22.0, + }), + 'zone_id': '3454854', + }), + 'supported_features': , + 'temperature': 5.0, + }), + 'context': , + 'entity_id': 'climate.thermostat', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_setup_platform[h139906][climate.thermostat_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 22.0, + 'friendly_name': 'Thermostat 2', + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 35.0, + 'min_temp': 5.0, + 'preset_mode': 'none', + 'preset_modes': list([ + 'none', + 'temporary', + 'permanent', + ]), + 'status': dict({ + 'activeFaults': tuple( + ), + 'setpoint_status': dict({ + 'setpoint_mode': 'FollowSchedule', + 'target_heat_temperature': 20.0, + }), + 'setpoints': dict({ + 'next_sp_from': HAFakeDatetime(2024, 7, 10, 23, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/Berlin')), + 'next_sp_temp': 15.0, + 'this_sp_from': HAFakeDatetime(2024, 7, 10, 12, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/Berlin')), + 'this_sp_temp': 22.5, + }), + 'temperature_status': dict({ + 'is_available': True, + 'temperature': 22.0, + }), + 'zone_id': '3454855', + }), + 'supported_features': , + 'temperature': 20.0, + }), + 'context': , + 'entity_id': 'climate.thermostat_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }) +# --- +# name: test_setup_platform[h139906][climate.vr-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 22.0, + 'friendly_name': 'Vr**********', + 'hvac_modes': list([ + , + , + ]), + 'icon': 'mdi:thermostat', + 'max_temp': 35, + 'min_temp': 7, + 'preset_mode': None, + 'preset_modes': list([ + 'eco', + 'away', + ]), + 'status': dict({ + 'activeSystemFaults': tuple( + ), + 'system_id': '3454856', + 'system_mode_status': dict({ + 'is_permanent': True, + 'mode': 'Auto', + }), + }), + 'supported_features': , + }), + 'context': , + 'entity_id': 'climate.vr', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }) +# --- # name: test_setup_platform[minimal][climate.main_room-state] StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -1312,6 +1466,13 @@ ), ]) # --- +# name: test_zone_set_hvac_mode[h139906] + list([ + tuple( + 5.0, + ), + ]) +# --- # name: test_zone_set_hvac_mode[minimal] list([ tuple( @@ -1365,6 +1526,19 @@ }), ]) # --- +# name: test_zone_set_preset_mode[h139906] + list([ + tuple( + 5.0, + ), + tuple( + 5.0, + ), + dict({ + 'until': None, + }), + ]) +# --- # name: test_zone_set_preset_mode[minimal] list([ tuple( @@ -1412,6 +1586,13 @@ }), ]) # --- +# name: test_zone_set_temperature[h139906] + list([ + dict({ + 'until': None, + }), + ]) +# --- # name: test_zone_set_temperature[minimal] list([ dict({ @@ -1447,6 +1628,13 @@ ), ]) # --- +# name: test_zone_turn_off[h139906] + list([ + tuple( + 5.0, + ), + ]) +# --- # name: test_zone_turn_off[minimal] list([ tuple( diff --git a/tests/components/evohome/snapshots/test_init.ambr b/tests/components/evohome/snapshots/test_init.ambr index d2e91e3c43d..d6174a53356 100644 --- a/tests/components/evohome/snapshots/test_init.ambr +++ b/tests/components/evohome/snapshots/test_init.ambr @@ -11,6 +11,9 @@ # name: test_setup[h099625] dict_keys(['refresh_system', 'set_system_mode', 'clear_zone_override', 'set_zone_override']) # --- +# name: test_setup[h139906] + dict_keys(['refresh_system', 'set_system_mode', 'clear_zone_override', 'set_zone_override']) +# --- # name: test_setup[minimal] dict_keys(['refresh_system', 'reset_system', 'set_system_mode', 'clear_zone_override', 'set_zone_override']) # --- diff --git a/tests/components/evohome/snapshots/test_water_heater.ambr b/tests/components/evohome/snapshots/test_water_heater.ambr index 771e2c20cba..7b1bc44550a 100644 --- a/tests/components/evohome/snapshots/test_water_heater.ambr +++ b/tests/components/evohome/snapshots/test_water_heater.ambr @@ -1,4 +1,14 @@ # serializer version: 1 +# name: test_set_operation_mode[botched] + list([ + dict({ + 'until': HAFakeDatetime(2024, 7, 10, 12, 0, tzinfo=datetime.timezone.utc), + }), + dict({ + 'until': HAFakeDatetime(2024, 7, 10, 12, 0, tzinfo=datetime.timezone.utc), + }), + ]) +# --- # name: test_set_operation_mode[default] list([ dict({ diff --git a/tests/components/evohome/test_water_heater.py b/tests/components/evohome/test_water_heater.py index a201ff63d1e..ca9a5ba6af8 100644 --- a/tests/components/evohome/test_water_heater.py +++ b/tests/components/evohome/test_water_heater.py @@ -33,7 +33,7 @@ from .const import TEST_INSTALLS_WITH_DHW DHW_ENTITY_ID = "water_heater.domestic_hot_water" -@pytest.mark.parametrize("install", [*TEST_INSTALLS_WITH_DHW, "botched"]) +@pytest.mark.parametrize("install", TEST_INSTALLS_WITH_DHW) async def test_setup_platform( hass: HomeAssistant, config: dict[str, str], From 208406123ed6f56588c8310e0a07225cdf875e11 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 7 Mar 2025 12:55:32 +0100 Subject: [PATCH 2344/3148] Fix SmartThings disabling working capabilities (#140039) --- .../components/smartthings/__init__.py | 18 +- tests/components/smartthings/conftest.py | 1 + .../device_status/da_wm_wm_000001_1.json | 1416 +++++++++++++++++ .../fixtures/devices/da_wm_wm_000001_1.json | 261 +++ .../smartthings/snapshots/test_init.ambr | 33 + .../smartthings/snapshots/test_sensor.ambr | 469 ++++++ .../smartthings/snapshots/test_switch.ambr | 47 + 7 files changed, 2241 insertions(+), 4 deletions(-) create mode 100644 tests/components/smartthings/fixtures/device_status/da_wm_wm_000001_1.json create mode 100644 tests/components/smartthings/fixtures/devices/da_wm_wm_000001_1.json diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py index f7f3d628c20..b2861976dc7 100644 --- a/homeassistant/components/smartthings/__init__.py +++ b/homeassistant/components/smartthings/__init__.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass import logging from typing import TYPE_CHECKING, cast @@ -160,6 +161,16 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True +DATA_VALIDATION: dict[ + Capability | str, Callable[[dict[Attribute | str, Status]], bool] +] = { + Capability.WASHER_OPERATING_STATE: ( + lambda status: status[Attribute.SUPPORTED_MACHINE_STATES].value is not None + ), + Capability.DEMAND_RESPONSE_LOAD_CONTROL: lambda _: True, +} + + def process_status( status: dict[str, dict[Capability | str, dict[Attribute | str, Status]]], ) -> dict[str, dict[Capability | str, dict[Attribute | str, Status]]]: @@ -176,10 +187,9 @@ def process_status( ) if disabled_capabilities is not None: for capability in disabled_capabilities: - # We still need to make sure the climate entity can work without this capability - if ( - capability in main_component - and capability != Capability.DEMAND_RESPONSE_LOAD_CONTROL + if capability in main_component and ( + capability not in DATA_VALIDATION + or not DATA_VALIDATION[capability](main_component[capability]) ): del main_component[capability] return status diff --git a/tests/components/smartthings/conftest.py b/tests/components/smartthings/conftest.py index b5fc7fe47cf..c50b89623e5 100644 --- a/tests/components/smartthings/conftest.py +++ b/tests/components/smartthings/conftest.py @@ -101,6 +101,7 @@ def mock_smartthings() -> Generator[AsyncMock]: "da_wm_dw_000001", "da_wm_wd_000001", "da_wm_wm_000001", + "da_wm_wm_000001_1", "da_rvc_normal_000001", "da_ks_microwave_0101x", "hue_color_temperature_bulb", diff --git a/tests/components/smartthings/fixtures/device_status/da_wm_wm_000001_1.json b/tests/components/smartthings/fixtures/device_status/da_wm_wm_000001_1.json new file mode 100644 index 00000000000..157e5496625 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/da_wm_wm_000001_1.json @@ -0,0 +1,1416 @@ +{ + "components": { + "hca.main": { + "hca.washerMode": { + "mode": { + "value": "mix", + "timestamp": "2025-03-07T06:06:08.905Z" + }, + "supportedModes": { + "value": ["normal", "quickWash", "mix", "eco"], + "timestamp": "2025-03-07T06:06:08.613Z" + } + } + }, + "main": { + "samsungce.washerDelayEnd": { + "remainingTime": { + "value": 0, + "unit": "min", + "timestamp": "2025-03-07T06:06:08.806Z" + }, + "minimumReservableTime": { + "value": 49, + "unit": "min", + "timestamp": "2025-03-07T06:06:08.613Z" + } + }, + "samsungce.washerWaterLevel": { + "supportedWaterLevel": { + "value": null, + "timestamp": "2021-03-31T22:35:35.010Z" + }, + "waterLevel": { + "value": null, + "timestamp": "2021-04-17T09:56:20.618Z" + } + }, + "samsungce.welcomeMessage": { + "welcomeMessage": { + "value": null, + "timestamp": "2021-04-01T23:43:08.541Z" + } + }, + "custom.washerWaterTemperature": { + "supportedWasherWaterTemperature": { + "value": ["none", "cold", "20", "30", "40", "60", "90"], + "timestamp": "2024-12-09T22:01:38.311Z" + }, + "washerWaterTemperature": { + "value": "40", + "timestamp": "2025-03-07T06:06:08.901Z" + } + }, + "samsungce.softenerAutoReplenishment": { + "regularSoftenerType": { + "value": null + }, + "regularSoftenerAlarmEnabled": { + "value": null + }, + "regularSoftenerInitialAmount": { + "value": null + }, + "regularSoftenerRemainingAmount": { + "value": null + }, + "regularSoftenerDosage": { + "value": null + }, + "regularSoftenerOrderThreshold": { + "value": null + } + }, + "samsungce.autoDispenseSoftener": { + "remainingAmount": { + "value": null, + "timestamp": "2021-01-29T10:38:25.844Z" + }, + "amount": { + "value": null, + "timestamp": "2020-12-28T07:28:49.408Z" + }, + "supportedDensity": { + "value": null, + "timestamp": "2020-12-28T07:28:49.408Z" + }, + "density": { + "value": null, + "timestamp": "2020-12-28T07:28:49.408Z" + }, + "supportedAmount": { + "value": null, + "timestamp": "2020-12-28T07:28:49.408Z" + } + }, + "samsungce.dongleSoftwareInstallation": { + "status": { + "value": "completed", + "timestamp": "2022-06-14T22:23:10.096Z" + } + }, + "samsungce.autoDispenseDetergent": { + "remainingAmount": { + "value": null, + "timestamp": "2021-01-26T01:49:50.635Z" + }, + "amount": { + "value": null, + "timestamp": "2020-12-28T07:15:24.539Z" + }, + "supportedDensity": { + "value": null, + "timestamp": "2020-12-28T07:15:24.539Z" + }, + "density": { + "value": null, + "timestamp": "2020-12-28T07:15:24.539Z" + }, + "supportedAmount": { + "value": null, + "timestamp": "2020-12-28T07:15:24.539Z" + }, + "availableTypes": { + "value": null + }, + "type": { + "value": null + }, + "recommendedAmount": { + "value": null + } + }, + "samsungce.deviceIdentification": { + "micomAssayCode": { + "value": "20224941", + "timestamp": "2025-03-07T06:06:08.719Z" + }, + "modelName": { + "value": null, + "timestamp": "2021-04-01T23:32:40.512Z" + }, + "serialNumber": { + "value": null, + "timestamp": "2021-04-01T23:32:38.884Z" + }, + "serialNumberExtra": { + "value": null, + "timestamp": "2021-04-01T23:32:36.541Z" + }, + "modelClassificationCode": { + "value": "20010102011211030203000000000000", + "timestamp": "2025-03-07T06:06:08.719Z" + }, + "description": { + "value": "DA_WM_A51_20_COMMON_\u0018WD7800N/DC92-02249A_08CC", + "timestamp": "2025-03-07T06:06:08.719Z" + }, + "releaseYear": { + "value": null + }, + "binaryId": { + "value": "DA_WM_A51_20_COMMON", + "timestamp": "2025-03-07T06:06:08.719Z" + } + }, + "samsungce.washerWaterValve": { + "waterValve": { + "value": null, + "timestamp": "2021-04-01T23:43:07.144Z" + }, + "supportedWaterValve": { + "value": null, + "timestamp": "2021-03-31T22:35:34.371Z" + } + }, + "washerOperatingState": { + "completionTime": { + "value": "2025-03-07T07:01:12Z", + "timestamp": "2025-03-07T06:12:12.191Z" + }, + "machineState": { + "value": "run", + "timestamp": "2025-03-07T06:12:12.191Z" + }, + "washerJobState": { + "value": "wash", + "timestamp": "2025-03-07T06:12:37.974Z" + }, + "supportedMachineStates": { + "value": ["stop", "run", "pause"], + "timestamp": "2025-03-07T06:06:08.806Z" + } + }, + "switch": { + "switch": { + "value": "on", + "timestamp": "2025-03-07T06:06:08.856Z" + } + }, + "custom.washerAutoSoftener": { + "washerAutoSoftener": { + "value": null, + "timestamp": "2020-08-07T21:22:34.172Z" + } + }, + "samsungce.washerFreezePrevent": { + "operatingState": { + "value": null + } + }, + "samsungce.quickControl": { + "version": { + "value": null + } + }, + "samsungce.washerCycle": { + "supportedCycles": { + "value": [ + { + "cycle": "D0", + "cycleType": "allInOne", + "supportedOptions": { + "bubbleSoak": { + "raw": "35F0", + "default": "off", + "options": ["on", "off"] + }, + "dryingLevel": { + "raw": "B01F", + "default": "none", + "options": [ + "none", + "cupboard", + "30", + "60", + "90", + "210", + "240", + "270" + ] + }, + "spinLevel": { + "raw": "A67F", + "default": "1400", + "options": [ + "rinseHold", + "noSpin", + "400", + "800", + "1000", + "1200", + "1400" + ] + }, + "rinseCycle": { + "raw": "923F", + "default": "2", + "options": ["0", "1", "2", "3", "4", "5"] + }, + "waterTemperature": { + "raw": "847E", + "default": "40", + "options": ["cold", "20", "30", "40", "60", "90"] + } + } + }, + { + "cycle": "DC", + "cycleType": "allInOne", + "supportedOptions": { + "bubbleSoak": { + "raw": "3500", + "default": "off", + "options": [] + }, + "dryingLevel": { + "raw": "B01F", + "default": "none", + "options": [ + "none", + "cupboard", + "30", + "60", + "90", + "210", + "240", + "270" + ] + }, + "spinLevel": { + "raw": "A33F", + "default": "800", + "options": [ + "rinseHold", + "noSpin", + "400", + "800", + "1000", + "1200" + ] + }, + "rinseCycle": { + "raw": "933F", + "default": "3", + "options": ["0", "1", "2", "3", "4", "5"] + }, + "waterTemperature": { + "raw": "811E", + "default": "cold", + "options": ["cold", "20", "30", "40"] + } + } + }, + { + "cycle": "E3", + "cycleType": "allInOne", + "supportedOptions": { + "bubbleSoak": { + "raw": "35F0", + "default": "off", + "options": ["on", "off"] + }, + "dryingLevel": { + "raw": "B01F", + "default": "none", + "options": [ + "none", + "cupboard", + "30", + "60", + "90", + "210", + "240", + "270" + ] + }, + "spinLevel": { + "raw": "A67F", + "default": "1400", + "options": [ + "rinseHold", + "noSpin", + "400", + "800", + "1000", + "1200", + "1400" + ] + }, + "rinseCycle": { + "raw": "923F", + "default": "2", + "options": ["0", "1", "2", "3", "4", "5"] + }, + "waterTemperature": { + "raw": "841E", + "default": "40", + "options": ["cold", "20", "30", "40"] + } + } + }, + { + "cycle": "E4", + "cycleType": "allInOne", + "supportedOptions": { + "bubbleSoak": { + "raw": "35F0", + "default": "off", + "options": ["on", "off"] + }, + "dryingLevel": { + "raw": "B01F", + "default": "none", + "options": [ + "none", + "cupboard", + "30", + "60", + "90", + "210", + "240", + "270" + ] + }, + "spinLevel": { + "raw": "A67F", + "default": "1400", + "options": [ + "rinseHold", + "noSpin", + "400", + "800", + "1000", + "1200", + "1400" + ] + }, + "rinseCycle": { + "raw": "943F", + "default": "4", + "options": ["0", "1", "2", "3", "4", "5"] + }, + "waterTemperature": { + "raw": "8560", + "default": "60", + "options": ["60", "90"] + } + } + }, + { + "cycle": "50", + "cycleType": "dryingOnly", + "supportedOptions": { + "bubbleSoak": { + "raw": "3500", + "default": "off", + "options": [] + }, + "dryingLevel": { + "raw": "B11E", + "default": "cupboard", + "options": ["cupboard", "30", "60", "90", "210", "240", "270"] + }, + "spinLevel": { + "raw": "A000", + "default": "rinseHold", + "options": [] + }, + "rinseCycle": { + "raw": "9000", + "default": "0", + "options": [] + }, + "waterTemperature": { + "raw": "8000", + "default": "none", + "options": [] + } + } + }, + { + "cycle": "51", + "cycleType": "dryingOnly", + "supportedOptions": { + "bubbleSoak": { + "raw": "3500", + "default": "off", + "options": [] + }, + "dryingLevel": { + "raw": "B11E", + "default": "cupboard", + "options": ["cupboard", "30", "60", "90", "210", "240", "270"] + }, + "spinLevel": { + "raw": "A000", + "default": "rinseHold", + "options": [] + }, + "rinseCycle": { + "raw": "9000", + "default": "0", + "options": [] + }, + "waterTemperature": { + "raw": "8000", + "default": "none", + "options": [] + } + } + }, + { + "cycle": "CA", + "cycleType": "dryingOnly", + "supportedOptions": { + "bubbleSoak": { + "raw": "3500", + "default": "off", + "options": [] + }, + "dryingLevel": { + "raw": "B000", + "default": "none", + "options": ["210", "240", "270"] + }, + "spinLevel": { + "raw": "A000", + "default": "rinseHold", + "options": [] + }, + "rinseCycle": { + "raw": "9000", + "default": "0", + "options": [] + }, + "waterTemperature": { + "raw": "8000", + "default": "none", + "options": [] + } + } + }, + { + "cycle": "E7", + "cycleType": "allInOne", + "supportedOptions": { + "bubbleSoak": { + "raw": "35F0", + "default": "off", + "options": ["on", "off"] + }, + "dryingLevel": { + "raw": "B100", + "default": "cupboard", + "options": ["210", "240", "270"] + }, + "spinLevel": { + "raw": "A640", + "default": "1400", + "options": ["1400"] + }, + "rinseCycle": { + "raw": "923F", + "default": "2", + "options": ["0", "1", "2", "3", "4", "5"] + }, + "waterTemperature": { + "raw": "841E", + "default": "40", + "options": ["cold", "20", "30", "40"] + } + } + }, + { + "cycle": "C7", + "cycleType": "allInOne", + "supportedOptions": { + "bubbleSoak": { + "raw": "3500", + "default": "off", + "options": [] + }, + "dryingLevel": { + "raw": "B000", + "default": "none", + "options": ["210", "240", "270"] + }, + "spinLevel": { + "raw": "A520", + "default": "1200", + "options": ["1200"] + }, + "rinseCycle": { + "raw": "9204", + "default": "2", + "options": ["2"] + }, + "waterTemperature": { + "raw": "8520", + "default": "60", + "options": ["60"] + } + } + }, + { + "cycle": "D8", + "cycleType": "allInOne", + "supportedOptions": { + "bubbleSoak": { + "raw": "3500", + "default": "off", + "options": [] + }, + "dryingLevel": { + "raw": "B000", + "default": "none", + "options": ["210", "240", "270"] + }, + "spinLevel": { + "raw": "A30F", + "default": "800", + "options": ["rinseHold", "noSpin", "400", "800"] + }, + "rinseCycle": { + "raw": "920F", + "default": "2", + "options": ["0", "1", "2", "3"] + }, + "waterTemperature": { + "raw": "841E", + "default": "40", + "options": ["cold", "20", "30", "40"] + } + } + }, + { + "cycle": "D4", + "cycleType": "allInOne", + "supportedOptions": { + "bubbleSoak": { + "raw": "3500", + "default": "off", + "options": [] + }, + "dryingLevel": { + "raw": "B01F", + "default": "none", + "options": [ + "none", + "cupboard", + "30", + "60", + "90", + "210", + "240", + "270" + ] + }, + "spinLevel": { + "raw": "A67F", + "default": "1400", + "options": [ + "rinseHold", + "noSpin", + "400", + "800", + "1000", + "1200", + "1400" + ] + }, + "rinseCycle": { + "raw": "913F", + "default": "1", + "options": ["0", "1", "2", "3", "4", "5"] + }, + "waterTemperature": { + "raw": "8000", + "default": "none", + "options": [] + } + } + }, + { + "cycle": "D3", + "cycleType": "allInOne", + "supportedOptions": { + "bubbleSoak": { + "raw": "3500", + "default": "off", + "options": [] + }, + "dryingLevel": { + "raw": "B01F", + "default": "none", + "options": [ + "none", + "cupboard", + "30", + "60", + "90", + "210", + "240", + "270" + ] + }, + "spinLevel": { + "raw": "A30F", + "default": "800", + "options": ["rinseHold", "noSpin", "400", "800"] + }, + "rinseCycle": { + "raw": "920F", + "default": "2", + "options": ["0", "1", "2", "3"] + }, + "waterTemperature": { + "raw": "831E", + "default": "30", + "options": ["cold", "20", "30", "40"] + } + } + }, + { + "cycle": "DA", + "cycleType": "allInOne", + "supportedOptions": { + "bubbleSoak": { + "raw": "3500", + "default": "off", + "options": [] + }, + "dryingLevel": { + "raw": "B01F", + "default": "none", + "options": [ + "none", + "cupboard", + "30", + "60", + "90", + "210", + "240", + "270" + ] + }, + "spinLevel": { + "raw": "A53F", + "default": "1200", + "options": [ + "rinseHold", + "noSpin", + "400", + "800", + "1000", + "1200" + ] + }, + "rinseCycle": { + "raw": "923F", + "default": "2", + "options": ["0", "1", "2", "3", "4", "5"] + }, + "waterTemperature": { + "raw": "8102", + "default": "cold", + "options": ["cold"] + } + } + }, + { + "cycle": "D2", + "cycleType": "allInOne", + "supportedOptions": { + "bubbleSoak": { + "raw": "35F0", + "default": "off", + "options": ["on", "off"] + }, + "dryingLevel": { + "raw": "B01F", + "default": "none", + "options": [ + "none", + "cupboard", + "30", + "60", + "90", + "210", + "240", + "270" + ] + }, + "spinLevel": { + "raw": "A67F", + "default": "1400", + "options": [ + "rinseHold", + "noSpin", + "400", + "800", + "1000", + "1200", + "1400" + ] + }, + "rinseCycle": { + "raw": "923F", + "default": "2", + "options": ["0", "1", "2", "3", "4", "5"] + }, + "waterTemperature": { + "raw": "843E", + "default": "40", + "options": ["cold", "20", "30", "40", "60"] + } + } + } + ], + "timestamp": "2024-12-09T22:01:38.311Z" + }, + "washerCycle": { + "value": "Table_00_Course_E3", + "timestamp": "2025-03-07T06:06:08.905Z" + }, + "referenceTable": { + "value": { + "id": "Table_00" + }, + "timestamp": "2021-12-01T23:55:08.740Z" + }, + "specializedFunctionClassification": { + "value": 7, + "timestamp": "2025-03-07T06:06:08.613Z" + } + }, + "samsungce.waterConsumptionReport": { + "waterConsumption": { + "value": null, + "timestamp": "2021-03-31T22:35:33.802Z" + } + }, + "ocf": { + "st": { + "value": null, + "timestamp": "2020-08-11T22:47:36.523Z" + }, + "mndt": { + "value": null, + "timestamp": "2020-08-11T22:47:41.693Z" + }, + "mnfv": { + "value": "DA_WM_A51_20_COMMON_30230708", + "timestamp": "2024-12-09T22:01:37.735Z" + }, + "mnhw": { + "value": "ARTIK051", + "timestamp": "2024-12-09T22:01:37.735Z" + }, + "di": { + "value": "63803fae-cbed-f356-a063-2cf148ae3ca7", + "timestamp": "2024-12-09T22:01:37.735Z" + }, + "mnsl": { + "value": "http://www.samsung.com", + "timestamp": "2024-12-09T22:01:37.735Z" + }, + "dmv": { + "value": "1.2.1", + "timestamp": "2024-12-27T04:48:02.896Z" + }, + "n": { + "value": "[washer] Samsung", + "timestamp": "2024-12-09T22:01:37.735Z" + }, + "mnmo": { + "value": "DA_WM_A51_20_COMMON|20224941|20010102011211030203000000000000", + "timestamp": "2024-12-09T22:01:37.735Z" + }, + "vid": { + "value": "DA-WM-WM-000001", + "timestamp": "2024-12-09T22:01:37.735Z" + }, + "mnmn": { + "value": "Samsung Electronics", + "timestamp": "2024-12-09T22:01:37.735Z" + }, + "mnml": { + "value": "http://www.samsung.com", + "timestamp": "2024-12-09T22:01:37.735Z" + }, + "mnpv": { + "value": "DAWIT 2.0", + "timestamp": "2024-12-09T22:01:37.735Z" + }, + "mnos": { + "value": "TizenRT 1.0 + IPv6", + "timestamp": "2024-12-09T22:01:37.735Z" + }, + "pi": { + "value": "63803fae-cbed-f356-a063-2cf148ae3ca7", + "timestamp": "2024-12-09T22:01:37.735Z" + }, + "icv": { + "value": "core.1.1.0", + "timestamp": "2024-12-27T04:48:02.896Z" + } + }, + "custom.dryerDryLevel": { + "dryerDryLevel": { + "value": "none", + "timestamp": "2025-03-07T06:06:08.901Z" + }, + "supportedDryerDryLevel": { + "value": [ + "none", + "cupboard", + "30", + "60", + "90", + "120", + "150", + "180", + "210", + "240", + "270" + ], + "timestamp": "2024-12-09T22:01:38.311Z" + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": [ + "samsungce.washerDelayEnd", + "washerOperatingState", + "samsungce.autoDispenseDetergent", + "samsungce.autoDispenseSoftener", + "samsungce.waterConsumptionReport", + "samsungce.washerCyclePreset", + "samsungce.welcomeMessage", + "samsungce.dongleSoftwareInstallation", + "sec.wifiConfiguration", + "samsungce.quickControl", + "samsungce.deviceInfoPrivate", + "samsungce.energyPlanner", + "demandResponseLoadControl", + "samsungce.softenerAutoReplenishment", + "samsungce.softenerOrder", + "samsungce.softenerState", + "samsungce.washerFreezePrevent", + "custom.washerSoilLevel", + "samsungce.washerWaterLevel", + "samsungce.washerWaterValve", + "samsungce.washerWashingTime", + "custom.washerAutoDetergent", + "custom.washerAutoSoftener", + "sec.diagnosticsInformation" + ], + "timestamp": "2024-07-03T08:44:32.524Z" + } + }, + "custom.washerRinseCycles": { + "supportedWasherRinseCycles": { + "value": ["0", "1", "2", "3", "4", "5"], + "timestamp": "2024-12-09T22:01:38.311Z" + }, + "washerRinseCycles": { + "value": "2", + "timestamp": "2025-03-07T06:06:08.901Z" + } + }, + "samsungce.driverVersion": { + "versionNumber": { + "value": 24110101, + "timestamp": "2024-12-03T02:08:44.235Z" + } + }, + "sec.diagnosticsInformation": { + "logType": { + "value": null + }, + "endpoint": { + "value": null + }, + "minVersion": { + "value": null + }, + "signinPermission": { + "value": null + }, + "setupId": { + "value": null + }, + "protocolType": { + "value": null + }, + "tsId": { + "value": null + }, + "mnId": { + "value": null + }, + "dumpType": { + "value": null + } + }, + "samsungce.washerOperatingState": { + "washerJobState": { + "value": "wash", + "timestamp": "2025-03-07T06:12:37.974Z" + }, + "operatingState": { + "value": "running", + "timestamp": "2025-03-07T06:12:12.191Z" + }, + "supportedOperatingStates": { + "value": ["ready", "running", "paused"], + "timestamp": "2022-11-02T21:35:52.935Z" + }, + "scheduledJobs": { + "value": [ + { + "jobName": "wash", + "timeInMin": 21 + }, + { + "jobName": "rinse", + "timeInMin": 16 + }, + { + "jobName": "spin", + "timeInMin": 11 + } + ], + "timestamp": "2025-03-07T06:06:08.613Z" + }, + "scheduledPhases": { + "value": [ + { + "phaseName": "wash", + "timeInMin": 21 + }, + { + "phaseName": "rinse", + "timeInMin": 16 + }, + { + "phaseName": "spin", + "timeInMin": 11 + } + ], + "timestamp": "2025-03-07T06:06:08.613Z" + }, + "progress": { + "value": 36, + "unit": "%", + "timestamp": "2025-03-07T06:30:10.639Z" + }, + "remainingTimeStr": { + "value": "00:31", + "timestamp": "2025-03-07T06:30:10.639Z" + }, + "washerJobPhase": { + "value": "wash", + "timestamp": "2025-03-07T06:12:37.974Z" + }, + "operationTime": { + "value": 49, + "unit": "min", + "timestamp": "2025-03-06T02:24:50.104Z" + }, + "remainingTime": { + "value": 31, + "unit": "min", + "timestamp": "2025-03-07T06:30:10.639Z" + } + }, + "samsungce.kidsLock": { + "lockState": { + "value": "unlocked", + "timestamp": "2025-03-07T06:06:08.688Z" + } + }, + "demandResponseLoadControl": { + "drlcStatus": { + "value": null + } + }, + "samsungce.detergentOrder": { + "alarmEnabled": { + "value": false, + "timestamp": "2025-03-07T06:06:08.613Z" + }, + "orderThreshold": { + "value": 0, + "unit": "cc", + "timestamp": "2025-03-07T06:06:08.613Z" + } + }, + "powerConsumptionReport": { + "powerConsumption": { + "value": { + "energy": 1323600, + "deltaEnergy": 100, + "power": 0, + "powerEnergy": 0.0, + "persistedEnergy": 0, + "energySaved": 0, + "start": "2025-03-07T06:21:09Z", + "end": "2025-03-07T06:23:21Z" + }, + "timestamp": "2025-03-07T06:23:21.062Z" + } + }, + "samsungce.detergentAutoReplenishment": { + "neutralDetergentType": { + "value": null + }, + "regularDetergentRemainingAmount": { + "value": 0, + "unit": "cc", + "timestamp": "2025-03-07T06:06:08.613Z" + }, + "babyDetergentRemainingAmount": { + "value": null + }, + "neutralDetergentRemainingAmount": { + "value": null + }, + "neutralDetergentAlarmEnabled": { + "value": null + }, + "neutralDetergentOrderThreshold": { + "value": null + }, + "babyDetergentInitialAmount": { + "value": null + }, + "babyDetergentType": { + "value": null + }, + "neutralDetergentInitialAmount": { + "value": null + }, + "regularDetergentDosage": { + "value": 0, + "unit": "cc", + "timestamp": "2025-03-07T06:06:08.613Z" + }, + "babyDetergentDosage": { + "value": null + }, + "regularDetergentOrderThreshold": { + "value": 0, + "unit": "cc", + "timestamp": "2025-03-07T06:06:08.613Z" + }, + "regularDetergentType": { + "value": "none", + "timestamp": "2025-03-07T06:06:08.613Z" + }, + "regularDetergentInitialAmount": { + "value": 0, + "unit": "cc", + "timestamp": "2025-03-07T06:06:08.613Z" + }, + "regularDetergentAlarmEnabled": { + "value": false, + "timestamp": "2025-03-07T06:06:08.613Z" + }, + "neutralDetergentDosage": { + "value": null + }, + "babyDetergentOrderThreshold": { + "value": null + }, + "babyDetergentAlarmEnabled": { + "value": null + } + }, + "samsungce.softenerOrder": { + "alarmEnabled": { + "value": null, + "timestamp": "2020-12-28T11:12:47.109Z" + }, + "orderThreshold": { + "value": null, + "unit": "cc", + "timestamp": "2020-12-28T11:12:47.109Z" + } + }, + "custom.washerSoilLevel": { + "supportedWasherSoilLevel": { + "value": null, + "timestamp": "2020-08-11T22:49:08.023Z" + }, + "washerSoilLevel": { + "value": null, + "timestamp": "2020-08-11T22:49:08.023Z" + } + }, + "samsungce.washerBubbleSoak": { + "status": { + "value": "off", + "timestamp": "2025-03-07T06:06:08.613Z" + } + }, + "samsungce.washerCyclePreset": { + "maxNumberOfPresets": { + "value": 10, + "timestamp": "2025-03-07T06:06:08.957Z" + }, + "presets": { + "value": null, + "timestamp": "2021-03-31T08:11:41.657Z" + } + }, + "samsungce.detergentState": { + "remainingAmount": { + "value": 0, + "unit": "cc", + "timestamp": "2025-03-07T06:06:08.613Z" + }, + "dosage": { + "value": 0, + "unit": "cc", + "timestamp": "2025-03-07T06:06:08.613Z" + }, + "initialAmount": { + "value": 0, + "unit": "cc", + "timestamp": "2025-03-07T06:06:08.613Z" + }, + "detergentType": { + "value": "none", + "timestamp": "2021-03-31T22:35:33.949Z" + } + }, + "refresh": {}, + "custom.jobBeginningStatus": { + "jobBeginningStatus": { + "value": null, + "timestamp": "2020-08-11T22:48:26.262Z" + } + }, + "execute": { + "data": { + "value": { + "payload": { + "rt": ["x.com.samsung.da.information"], + "if": ["oic.if.baseline", "oic.if.a"], + "x.com.samsung.da.modelNum": "DA_WM_A51_20_COMMON|20224941|20010102011211030203000000000000", + "x.com.samsung.da.description": "DA_WM_A51_20_COMMON_\u0018WD7800N/DC92-02249A_08CC", + "x.com.samsung.da.serialNum": "0TE65ADMC00093F", + "x.com.samsung.da.otnDUID": "EXCEZFTFQ53G2", + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "DA_WM_A51_20_COMMON|20224941|20010102011211030203000000000000", + "x.com.samsung.da.type": "Software", + "x.com.samsung.da.number": "02198A220728(E256)", + "x.com.samsung.da.newVersionAvailable": "0" + }, + { + "x.com.samsung.da.id": "1", + "x.com.samsung.da.description": "DA_WM_A51_20_COMMON", + "x.com.samsung.da.type": "Firmware", + "x.com.samsung.da.number": "18072525,18090310", + "x.com.samsung.da.newVersionAvailable": "0" + } + ] + } + }, + "data": { + "href": "/information/vs/0" + }, + "timestamp": "2023-08-06T02:14:23.034Z" + } + }, + "samsungce.softenerState": { + "remainingAmount": { + "value": null, + "unit": "cc", + "timestamp": "2020-12-28T07:11:13.285Z" + }, + "dosage": { + "value": null, + "unit": "cc", + "timestamp": "2020-12-28T01:14:27.011Z" + }, + "softenerType": { + "value": null, + "timestamp": "2020-11-19T21:57:19.712Z" + }, + "initialAmount": { + "value": null, + "unit": "cc", + "timestamp": "2020-12-28T00:45:40.863Z" + } + }, + "samsungce.energyPlanner": { + "data": { + "value": null + }, + "plan": { + "value": null + } + }, + "sec.wifiConfiguration": { + "autoReconnection": { + "value": null + }, + "minVersion": { + "value": null + }, + "supportedWiFiFreq": { + "value": null + }, + "supportedAuthType": { + "value": null + }, + "protocolType": { + "value": null + } + }, + "remoteControlStatus": { + "remoteControlEnabled": { + "value": "true", + "timestamp": "2025-03-07T06:06:08.819Z" + } + }, + "custom.supportedOptions": { + "course": { + "value": null + }, + "referenceTable": { + "value": { + "id": "Table_00" + }, + "timestamp": "2025-03-07T06:06:08.905Z" + }, + "supportedCourses": { + "value": [ + "D0", + "DC", + "E3", + "E4", + "50", + "51", + "CA", + "E7", + "C7", + "D8", + "D4", + "D3", + "DA", + "D2" + ], + "timestamp": "2025-03-07T06:06:08.613Z" + } + }, + "samsungce.washerWashingTime": { + "supportedWashingTimes": { + "value": null, + "timestamp": "2021-03-31T08:10:28.542Z" + }, + "washingTime": { + "value": null, + "unit": "min", + "timestamp": "2021-03-31T08:10:28.542Z" + } + }, + "custom.energyType": { + "energyType": { + "value": "2.0", + "timestamp": "2022-06-14T22:23:10.096Z" + }, + "energySavingSupport": { + "value": false, + "timestamp": "2022-06-14T22:38:10.576Z" + }, + "drMaxDuration": { + "value": null + }, + "energySavingLevel": { + "value": null + }, + "energySavingInfo": { + "value": null + }, + "supportedEnergySavingLevels": { + "value": null + }, + "energySavingOperation": { + "value": null + }, + "notificationTemplateID": { + "value": null + }, + "energySavingOperationSupport": { + "value": null + } + }, + "samsungce.softwareUpdate": { + "targetModule": { + "value": null + }, + "otnDUID": { + "value": "EXCEZFTFQ53G2", + "timestamp": "2025-03-07T06:06:08.719Z" + }, + "lastUpdatedDate": { + "value": null + }, + "availableModules": { + "value": [], + "timestamp": "2024-12-01T10:37:29.975Z" + }, + "newVersionAvailable": { + "value": false, + "timestamp": "2024-12-01T10:37:29.975Z" + }, + "operatingState": { + "value": null + }, + "progress": { + "value": null + } + }, + "custom.washerAutoDetergent": { + "washerAutoDetergent": { + "value": null, + "timestamp": "2020-08-11T22:47:34.372Z" + } + }, + "custom.washerSpinLevel": { + "washerSpinLevel": { + "value": "1400", + "timestamp": "2025-03-07T06:06:08.901Z" + }, + "supportedWasherSpinLevel": { + "value": [ + "rinseHold", + "noSpin", + "400", + "800", + "1000", + "1200", + "1400" + ], + "timestamp": "2024-12-09T22:01:38.311Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/devices/da_wm_wm_000001_1.json b/tests/components/smartthings/fixtures/devices/da_wm_wm_000001_1.json new file mode 100644 index 00000000000..bb1831d6f03 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/da_wm_wm_000001_1.json @@ -0,0 +1,261 @@ +{ + "items": [ + { + "deviceId": "63803fae-cbed-f356-a063-2cf148ae3ca7", + "name": "[washer] Samsung", + "label": "Washing Machine", + "manufacturerName": "Samsung Electronics", + "presentationId": "DA-WM-WM-000001", + "deviceManufacturerCode": "Samsung Electronics", + "locationId": "ca23214d-d9ae-41e5-9d26-f1a604c864d8", + "ownerId": "9b53a4ba-4422-b04d-f436-33c0490e7c37", + "roomId": "e226f1ae-1112-4794-bd3a-0beddf811645", + "deviceTypeName": "Samsung OCF Washer", + "components": [ + { + "id": "main", + "label": "Washing Machine", + "capabilities": [ + { + "id": "execute", + "version": 1 + }, + { + "id": "ocf", + "version": 1 + }, + { + "id": "powerConsumptionReport", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "remoteControlStatus", + "version": 1 + }, + { + "id": "demandResponseLoadControl", + "version": 1 + }, + { + "id": "switch", + "version": 1 + }, + { + "id": "washerOperatingState", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + }, + { + "id": "custom.dryerDryLevel", + "version": 1 + }, + { + "id": "custom.energyType", + "version": 1 + }, + { + "id": "custom.jobBeginningStatus", + "version": 1 + }, + { + "id": "custom.supportedOptions", + "version": 1 + }, + { + "id": "custom.washerAutoDetergent", + "version": 1 + }, + { + "id": "custom.washerAutoSoftener", + "version": 1 + }, + { + "id": "custom.washerRinseCycles", + "version": 1 + }, + { + "id": "custom.washerSoilLevel", + "version": 1 + }, + { + "id": "custom.washerSpinLevel", + "version": 1 + }, + { + "id": "custom.washerWaterTemperature", + "version": 1 + }, + { + "id": "samsungce.autoDispenseDetergent", + "version": 1 + }, + { + "id": "samsungce.autoDispenseSoftener", + "version": 1 + }, + { + "id": "samsungce.detergentOrder", + "version": 1 + }, + { + "id": "samsungce.detergentState", + "version": 1 + }, + { + "id": "samsungce.deviceIdentification", + "version": 1 + }, + { + "id": "samsungce.dongleSoftwareInstallation", + "version": 1 + }, + { + "id": "samsungce.detergentAutoReplenishment", + "version": 1 + }, + { + "id": "samsungce.softenerAutoReplenishment", + "version": 1 + }, + { + "id": "samsungce.driverVersion", + "version": 1 + }, + { + "id": "samsungce.softwareUpdate", + "version": 1 + }, + { + "id": "samsungce.kidsLock", + "version": 1 + }, + { + "id": "samsungce.softenerOrder", + "version": 1 + }, + { + "id": "samsungce.softenerState", + "version": 1 + }, + { + "id": "samsungce.washerBubbleSoak", + "version": 1 + }, + { + "id": "samsungce.washerCycle", + "version": 1 + }, + { + "id": "samsungce.washerCyclePreset", + "version": 1 + }, + { + "id": "samsungce.washerDelayEnd", + "version": 1 + }, + { + "id": "samsungce.washerFreezePrevent", + "version": 1 + }, + { + "id": "samsungce.washerOperatingState", + "version": 1 + }, + { + "id": "samsungce.washerWashingTime", + "version": 1 + }, + { + "id": "samsungce.washerWaterLevel", + "version": 1 + }, + { + "id": "samsungce.washerWaterValve", + "version": 1 + }, + { + "id": "samsungce.welcomeMessage", + "version": 1 + }, + { + "id": "samsungce.waterConsumptionReport", + "version": 1 + }, + { + "id": "samsungce.quickControl", + "version": 1 + }, + { + "id": "samsungce.energyPlanner", + "version": 1 + }, + { + "id": "sec.diagnosticsInformation", + "version": 1 + }, + { + "id": "sec.wifiConfiguration", + "version": 1 + } + ], + "categories": [ + { + "name": "Washer", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "hca.main", + "label": "hca.main", + "capabilities": [ + { + "id": "hca.washerMode", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2020-03-04T03:03:19Z", + "profile": { + "id": "3f221c79-d81c-315f-8e8b-b5742802a1e3" + }, + "ocf": { + "ocfDeviceType": "oic.d.washer", + "name": "[washer] Samsung", + "specVersion": "core.1.1.0", + "verticalDomainSpecVersion": "1.2.1", + "manufacturerName": "Samsung Electronics", + "modelNumber": "DA_WM_A51_20_COMMON|20224941|20010102011211030203000000000000", + "platformVersion": "DAWIT 2.0", + "platformOS": "TizenRT 1.0 + IPv6", + "hwVersion": "ARTIK051", + "firmwareVersion": "DA_WM_A51_20_COMMON_30230708", + "vendorId": "DA-WM-WM-000001", + "vendorResourceClientServerVersion": "ARTIK051 Release 2.210224.1", + "lastSignupTime": "2024-12-27T04:47:59.763899737Z", + "transferCandidate": false, + "additionalAuthCodeRequired": false + }, + "type": "OCF", + "restrictionTier": 0, + "allowed": null, + "executionContext": "CLOUD", + "relationships": [] + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/snapshots/test_init.ambr b/tests/components/smartthings/snapshots/test_init.ambr index 1554c2a7080..f000933340a 100644 --- a/tests/components/smartthings/snapshots/test_init.ambr +++ b/tests/components/smartthings/snapshots/test_init.ambr @@ -494,6 +494,39 @@ 'via_device_id': None, }) # --- +# name: test_devices[da_wm_wm_000001_1] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': 'ARTIK051', + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '63803fae-cbed-f356-a063-2cf148ae3ca7', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Samsung Electronics', + 'model': 'DA_WM_A51_20_COMMON', + 'model_id': None, + 'name': 'Washing Machine', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': 'DA_WM_A51_20_COMMON_30230708', + 'via_device_id': None, + }) +# --- # name: test_devices[ecobee_sensor] DeviceRegistryEntrySnapshot({ 'area_id': None, diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index fa9af0f2812..72364d59277 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -3899,6 +3899,475 @@ 'state': '0.0', }) # --- +# name: test_all_entities[da_wm_wm_000001_1][sensor.washing_machine_completion_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.washing_machine_completion_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Completion time', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'completion_time', + 'unique_id': '63803fae-cbed-f356-a063-2cf148ae3ca7.completionTime', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wm_000001_1][sensor.washing_machine_completion_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Washing Machine Completion time', + }), + 'context': , + 'entity_id': 'sensor.washing_machine_completion_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2025-03-07T07:01:12+00:00', + }) +# --- +# name: test_all_entities[da_wm_wm_000001_1][sensor.washing_machine_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.washing_machine_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '63803fae-cbed-f356-a063-2cf148ae3ca7.energy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_wm_wm_000001_1][sensor.washing_machine_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Washing Machine Energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.washing_machine_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1323.6', + }) +# --- +# name: test_all_entities[da_wm_wm_000001_1][sensor.washing_machine_energy_difference-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.washing_machine_energy_difference', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy difference', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_difference', + 'unique_id': '63803fae-cbed-f356-a063-2cf148ae3ca7.deltaEnergy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_wm_wm_000001_1][sensor.washing_machine_energy_difference-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Washing Machine Energy difference', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.washing_machine_energy_difference', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.1', + }) +# --- +# name: test_all_entities[da_wm_wm_000001_1][sensor.washing_machine_energy_saved-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.washing_machine_energy_saved', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy saved', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_saved', + 'unique_id': '63803fae-cbed-f356-a063-2cf148ae3ca7.energySaved_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_wm_wm_000001_1][sensor.washing_machine_energy_saved-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Washing Machine Energy saved', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.washing_machine_energy_saved', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_wm_wm_000001_1][sensor.washing_machine_job_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'air_wash', + 'ai_rinse', + 'ai_spin', + 'ai_wash', + 'cooling', + 'delay_wash', + 'drying', + 'finish', + 'none', + 'pre_wash', + 'rinse', + 'spin', + 'wash', + 'weight_sensing', + 'wrinkle_prevent', + 'freeze_protection', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.washing_machine_job_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Job state', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'washer_job_state', + 'unique_id': '63803fae-cbed-f356-a063-2cf148ae3ca7.washerJobState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wm_000001_1][sensor.washing_machine_job_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Washing Machine Job state', + 'options': list([ + 'air_wash', + 'ai_rinse', + 'ai_spin', + 'ai_wash', + 'cooling', + 'delay_wash', + 'drying', + 'finish', + 'none', + 'pre_wash', + 'rinse', + 'spin', + 'wash', + 'weight_sensing', + 'wrinkle_prevent', + 'freeze_protection', + ]), + }), + 'context': , + 'entity_id': 'sensor.washing_machine_job_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'wash', + }) +# --- +# name: test_all_entities[da_wm_wm_000001_1][sensor.washing_machine_machine_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'pause', + 'run', + 'stop', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.washing_machine_machine_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Machine state', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'washer_machine_state', + 'unique_id': '63803fae-cbed-f356-a063-2cf148ae3ca7.machineState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wm_000001_1][sensor.washing_machine_machine_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Washing Machine Machine state', + 'options': list([ + 'pause', + 'run', + 'stop', + ]), + }), + 'context': , + 'entity_id': 'sensor.washing_machine_machine_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'run', + }) +# --- +# name: test_all_entities[da_wm_wm_000001_1][sensor.washing_machine_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.washing_machine_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '63803fae-cbed-f356-a063-2cf148ae3ca7.power_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_wm_wm_000001_1][sensor.washing_machine_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Washing Machine Power', + 'power_consumption_end': '2025-03-07T06:23:21Z', + 'power_consumption_start': '2025-03-07T06:21:09Z', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.washing_machine_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_all_entities[da_wm_wm_000001_1][sensor.washing_machine_power_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.washing_machine_power_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power energy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_energy', + 'unique_id': '63803fae-cbed-f356-a063-2cf148ae3ca7.powerEnergy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_wm_wm_000001_1][sensor.washing_machine_power_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Washing Machine Power energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.washing_machine_power_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- # name: test_all_entities[ecobee_sensor][sensor.child_bedroom_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/smartthings/snapshots/test_switch.ambr b/tests/components/smartthings/snapshots/test_switch.ambr index d12bd4ea5b6..00177b3b603 100644 --- a/tests/components/smartthings/snapshots/test_switch.ambr +++ b/tests/components/smartthings/snapshots/test_switch.ambr @@ -281,6 +281,53 @@ 'state': 'off', }) # --- +# name: test_all_entities[da_wm_wm_000001_1][switch.washing_machine-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.washing_machine', + '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': None, + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '63803fae-cbed-f356-a063-2cf148ae3ca7', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wm_000001_1][switch.washing_machine-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Washing Machine', + }), + 'context': , + 'entity_id': 'switch.washing_machine', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_all_entities[sensibo_airconditioner_1][switch.office-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From e74fe69d65f9021cce6a8601c8c37ee680d15272 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 7 Mar 2025 14:56:31 +0100 Subject: [PATCH 2345/3148] Fix SmartThings thermostat climate check (#140046) * Fix SmartThings thermostat climate check * Add tests --- .../components/smartthings/climate.py | 4 +- tests/components/smartthings/conftest.py | 1 + .../heatit_ztrm3_thermostat.json | 60 +++++++ .../devices/heatit_ztrm3_thermostat.json | 79 +++++++++ .../smartthings/snapshots/test_climate.ambr | 64 +++++++ .../smartthings/snapshots/test_init.ambr | 33 ++++ .../smartthings/snapshots/test_sensor.ambr | 156 ++++++++++++++++++ 7 files changed, 394 insertions(+), 3 deletions(-) create mode 100644 tests/components/smartthings/fixtures/device_status/heatit_ztrm3_thermostat.json create mode 100644 tests/components/smartthings/fixtures/devices/heatit_ztrm3_thermostat.json diff --git a/homeassistant/components/smartthings/climate.py b/homeassistant/components/smartthings/climate.py index 9dc0fbb9f08..b634321fe43 100644 --- a/homeassistant/components/smartthings/climate.py +++ b/homeassistant/components/smartthings/climate.py @@ -161,9 +161,7 @@ class SmartThingsThermostat(SmartThingsEntity, ClimateEntity): | ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON ) - if self.get_attribute_value( - Capability.THERMOSTAT_FAN_MODE, Attribute.THERMOSTAT_FAN_MODE - ): + if self.supports_capability(Capability.THERMOSTAT_FAN_MODE): flags |= ClimateEntityFeature.FAN_MODE return flags diff --git a/tests/components/smartthings/conftest.py b/tests/components/smartthings/conftest.py index c50b89623e5..d60099e8e76 100644 --- a/tests/components/smartthings/conftest.py +++ b/tests/components/smartthings/conftest.py @@ -117,6 +117,7 @@ def mock_smartthings() -> Generator[AsyncMock]: "ecobee_thermostat", "fake_fan", "generic_fan_3_speed", + "heatit_ztrm3_thermostat", ] ) def device_fixture( diff --git a/tests/components/smartthings/fixtures/device_status/heatit_ztrm3_thermostat.json b/tests/components/smartthings/fixtures/device_status/heatit_ztrm3_thermostat.json new file mode 100644 index 00000000000..c49cc55d2cb --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/heatit_ztrm3_thermostat.json @@ -0,0 +1,60 @@ +{ + "components": { + "main": { + "powerMeter": { + "power": { + "value": 368.17, + "unit": "W", + "timestamp": "2025-03-07T12:52:08.997Z" + } + }, + "thermostatOperatingState": { + "thermostatOperatingState": { + "value": "heating", + "timestamp": "2025-03-07T12:49:53.638Z" + } + }, + "energyMeter": { + "energy": { + "value": 2339.5, + "unit": "kWh", + "timestamp": "2025-03-07T12:26:37.133Z" + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": 19.0, + "unit": "C", + "timestamp": "2025-03-07T12:52:39.210Z" + } + }, + "thermostatHeatingSetpoint": { + "heatingSetpoint": { + "value": 19.0, + "unit": "C", + "timestamp": "2025-03-06T21:38:22.856Z" + }, + "heatingSetpointRange": { + "value": null + } + }, + "refresh": {}, + "thermostatMode": { + "thermostatMode": { + "value": "heat", + "data": { + "supportedThermostatModes": ["off", "heat"] + }, + "timestamp": "2025-03-06T21:38:23.046Z" + }, + "supportedThermostatModes": { + "value": ["off", "heat"], + "timestamp": "2023-09-22T15:41:01.268Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/devices/heatit_ztrm3_thermostat.json b/tests/components/smartthings/fixtures/devices/heatit_ztrm3_thermostat.json new file mode 100644 index 00000000000..e8928f6b3a8 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/heatit_ztrm3_thermostat.json @@ -0,0 +1,79 @@ +{ + "items": [ + { + "deviceId": "69a271f6-6537-4982-8cd9-979866872692", + "name": "heatit-ztrm3-thermostat", + "label": "Hall thermostat", + "manufacturerName": "SmartThingsCommunity", + "presentationId": "8c5c0adc-73d6-33db-a1bd-67d746ab0e00", + "deviceManufacturerCode": "019B-0003-0203", + "locationId": "6cf6637b-9bc5-4e52-bc99-7497e322fb0d", + "ownerId": "7b68139b-d068-45d8-bf27-961320350024", + "roomId": "746b4d54-8026-44f1-b50f-8833dafdeea3", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "thermostatHeatingSetpoint", + "version": 1 + }, + { + "id": "thermostatOperatingState", + "version": 1 + }, + { + "id": "thermostatMode", + "version": 1 + }, + { + "id": "powerMeter", + "version": 1 + }, + { + "id": "energyMeter", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + } + ], + "categories": [ + { + "name": "Thermostat", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2023-09-22T15:40:58.942Z", + "parentDeviceId": "d04f5ba0-1430-4826-9aa4-fba4efb57c24", + "profile": { + "id": "2677e0e8-9241-3163-815e-6b1d6743f280" + }, + "zwave": { + "networkId": "28", + "driverId": "28198799-de20-4cfd-a9f3-67860a0877d5", + "executingLocally": true, + "hubId": "d04f5ba0-1430-4826-9aa4-fba4efb57c24", + "networkSecurityLevel": "ZWAVE_S2_AUTHENTICATED", + "provisioningState": "PROVISIONED", + "manufacturerId": 411, + "productType": 3, + "productId": 515 + }, + "type": "ZWAVE", + "restrictionTier": 0, + "allowed": null, + "executionContext": "LOCAL", + "relationships": [] + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/snapshots/test_climate.ambr b/tests/components/smartthings/snapshots/test_climate.ambr index 08ddacf45c6..c85c7af19a6 100644 --- a/tests/components/smartthings/snapshots/test_climate.ambr +++ b/tests/components/smartthings/snapshots/test_climate.ambr @@ -369,6 +369,70 @@ 'state': 'heat', }) # --- +# name: test_all_entities[heatit_ztrm3_thermostat][climate.hall_thermostat-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + ]), + 'max_temp': 35, + 'min_temp': 7, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.hall_thermostat', + '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': None, + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '69a271f6-6537-4982-8cd9-979866872692', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[heatit_ztrm3_thermostat][climate.hall_thermostat-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 19.0, + 'friendly_name': 'Hall thermostat', + 'hvac_action': , + 'hvac_modes': list([ + , + ]), + 'max_temp': 35, + 'min_temp': 7, + 'supported_features': , + 'target_temp_high': None, + 'target_temp_low': None, + 'temperature': 19.0, + }), + 'context': , + 'entity_id': 'climate.hall_thermostat', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }) +# --- # name: test_all_entities[virtual_thermostat][climate.asd-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/smartthings/snapshots/test_init.ambr b/tests/components/smartthings/snapshots/test_init.ambr index f000933340a..f0c9313871b 100644 --- a/tests/components/smartthings/snapshots/test_init.ambr +++ b/tests/components/smartthings/snapshots/test_init.ambr @@ -692,6 +692,39 @@ 'via_device_id': None, }) # --- +# name: test_devices[heatit_ztrm3_thermostat] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '69a271f6-6537-4982-8cd9-979866872692', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': None, + 'model': None, + 'model_id': None, + 'name': 'Hall thermostat', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- # name: test_devices[hue_color_temperature_bulb] DeviceRegistryEntrySnapshot({ 'area_id': None, diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index 72364d59277..017689f13fd 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -4524,6 +4524,162 @@ 'state': '22', }) # --- +# name: test_all_entities[heatit_ztrm3_thermostat][sensor.hall_thermostat_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.hall_thermostat_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '69a271f6-6537-4982-8cd9-979866872692.energy', + 'unit_of_measurement': 'kWh', + }) +# --- +# name: test_all_entities[heatit_ztrm3_thermostat][sensor.hall_thermostat_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Hall thermostat Energy', + 'state_class': , + 'unit_of_measurement': 'kWh', + }), + 'context': , + 'entity_id': 'sensor.hall_thermostat_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2339.5', + }) +# --- +# name: test_all_entities[heatit_ztrm3_thermostat][sensor.hall_thermostat_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.hall_thermostat_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '69a271f6-6537-4982-8cd9-979866872692.power', + 'unit_of_measurement': 'W', + }) +# --- +# name: test_all_entities[heatit_ztrm3_thermostat][sensor.hall_thermostat_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Hall thermostat Power', + 'state_class': , + 'unit_of_measurement': 'W', + }), + 'context': , + 'entity_id': 'sensor.hall_thermostat_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '368.17', + }) +# --- +# name: test_all_entities[heatit_ztrm3_thermostat][sensor.hall_thermostat_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.hall_thermostat_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': 'Temperature', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '69a271f6-6537-4982-8cd9-979866872692.temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[heatit_ztrm3_thermostat][sensor.hall_thermostat_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Hall thermostat Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.hall_thermostat_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '19.0', + }) +# --- # name: test_all_entities[multipurpose_sensor][sensor.deck_door_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From d6eb61e9eca1303c39c4b3cf741bd2d687ec6381 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 7 Mar 2025 14:26:43 +0100 Subject: [PATCH 2346/3148] Bump pysmartthings to 2.7.0 (#140047) --- homeassistant/components/smartthings/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/smartthings/manifest.json b/homeassistant/components/smartthings/manifest.json index 9efa8b81186..2a4e79bff58 100644 --- a/homeassistant/components/smartthings/manifest.json +++ b/homeassistant/components/smartthings/manifest.json @@ -29,5 +29,5 @@ "documentation": "https://www.home-assistant.io/integrations/smartthings", "iot_class": "cloud_push", "loggers": ["pysmartthings"], - "requirements": ["pysmartthings==2.6.1"] + "requirements": ["pysmartthings==2.7.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index f2546da0871..0aa9ade3e37 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2310,7 +2310,7 @@ pysma==0.7.5 pysmappee==0.2.29 # homeassistant.components.smartthings -pysmartthings==2.6.1 +pysmartthings==2.7.0 # homeassistant.components.smarty pysmarty2==0.10.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 59c9c213a98..eb5ac5f97fb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1882,7 +1882,7 @@ pysma==0.7.5 pysmappee==0.2.29 # homeassistant.components.smartthings -pysmartthings==2.6.1 +pysmartthings==2.7.0 # homeassistant.components.smarty pysmarty2==0.10.2 From be32e3fe8fb129a05b2d9df2113f9c7198c40838 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 7 Mar 2025 15:04:46 +0100 Subject: [PATCH 2347/3148] Only keep valid powerConsumptionReports in SmartThings (#140049) * power consumption report * Only keep valid powerConsumptionReports in SmartThings --- .../components/smartthings/__init__.py | 55 ++++++++++++++----- .../components/smartthings/sensor.py | 10 ---- .../device_status/c2c_arlo_pro_3_switch.json | 9 +++ 3 files changed, 50 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py index b2861976dc7..e26a9293c41 100644 --- a/homeassistant/components/smartthings/__init__.py +++ b/homeassistant/components/smartthings/__init__.py @@ -161,7 +161,7 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -DATA_VALIDATION: dict[ +KEEP_CAPABILITY_QUIRK: dict[ Capability | str, Callable[[dict[Attribute | str, Status]], bool] ] = { Capability.WASHER_OPERATING_STATE: ( @@ -170,26 +170,53 @@ DATA_VALIDATION: dict[ Capability.DEMAND_RESPONSE_LOAD_CONTROL: lambda _: True, } +POWER_CONSUMPTION_FIELDS = { + "energy", + "power", + "deltaEnergy", + "powerEnergy", + "energySaved", +} + +CAPABILITY_VALIDATION: dict[ + Capability | str, Callable[[dict[Attribute | str, Status]], bool] +] = { + Capability.POWER_CONSUMPTION_REPORT: ( + lambda status: ( + (power_consumption := status[Attribute.POWER_CONSUMPTION].value) is not None + and all( + field in cast(dict, power_consumption) + for field in POWER_CONSUMPTION_FIELDS + ) + ) + ) +} + def process_status( status: dict[str, dict[Capability | str, dict[Attribute | str, Status]]], ) -> dict[str, dict[Capability | str, dict[Attribute | str, Status]]]: """Remove disabled capabilities from status.""" - if (main_component := status.get("main")) is None or ( + if (main_component := status.get(MAIN)) is None: + return status + if ( disabled_capabilities_capability := main_component.get( Capability.CUSTOM_DISABLED_CAPABILITIES ) - ) is None: - return status - disabled_capabilities = cast( - list[Capability | str], - disabled_capabilities_capability[Attribute.DISABLED_CAPABILITIES].value, - ) - if disabled_capabilities is not None: - for capability in disabled_capabilities: - if capability in main_component and ( - capability not in DATA_VALIDATION - or not DATA_VALIDATION[capability](main_component[capability]) - ): + ) is not None: + disabled_capabilities = cast( + list[Capability | str], + disabled_capabilities_capability[Attribute.DISABLED_CAPABILITIES].value, + ) + if disabled_capabilities is not None: + for capability in disabled_capabilities: + if capability in main_component and ( + capability not in KEEP_CAPABILITY_QUIRK + or not KEEP_CAPABILITY_QUIRK[capability](main_component[capability]) + ): + del main_component[capability] + for capability in list(main_component): + if capability in CAPABILITY_VALIDATION: + if not CAPABILITY_VALIDATION[capability](main_component[capability]): del main_component[capability] return status diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index 56d96bc4ce0..a0b39917c71 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -130,7 +130,6 @@ class SmartThingsSensorEntityDescription(SensorEntityDescription): unique_id_separator: str = "." capability_ignore_list: list[set[Capability]] | None = None options_attribute: Attribute | None = None - except_if_state_none: bool = False CAPABILITY_TO_SENSORS: dict[ @@ -581,7 +580,6 @@ CAPABILITY_TO_SENSORS: dict[ native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, value_fn=lambda value: value["energy"] / 1000, suggested_display_precision=2, - except_if_state_none=True, ), SmartThingsSensorEntityDescription( key="power_meter", @@ -591,7 +589,6 @@ CAPABILITY_TO_SENSORS: dict[ value_fn=lambda value: value["power"], extra_state_attributes_fn=power_attributes, suggested_display_precision=2, - except_if_state_none=True, ), SmartThingsSensorEntityDescription( key="deltaEnergy_meter", @@ -601,7 +598,6 @@ CAPABILITY_TO_SENSORS: dict[ native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, value_fn=lambda value: value["deltaEnergy"] / 1000, suggested_display_precision=2, - except_if_state_none=True, ), SmartThingsSensorEntityDescription( key="powerEnergy_meter", @@ -611,7 +607,6 @@ CAPABILITY_TO_SENSORS: dict[ native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, value_fn=lambda value: value["powerEnergy"] / 1000, suggested_display_precision=2, - except_if_state_none=True, ), SmartThingsSensorEntityDescription( key="energySaved_meter", @@ -621,7 +616,6 @@ CAPABILITY_TO_SENSORS: dict[ native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, value_fn=lambda value: value["energySaved"] / 1000, suggested_display_precision=2, - except_if_state_none=True, ), ] }, @@ -976,10 +970,6 @@ async def async_setup_entry( for capability_list in description.capability_ignore_list ) ) - and ( - not description.except_if_state_none - or device.status[MAIN][capability][attribute].value is not None - ) ) diff --git a/tests/components/smartthings/fixtures/device_status/c2c_arlo_pro_3_switch.json b/tests/components/smartthings/fixtures/device_status/c2c_arlo_pro_3_switch.json index 371a779f83c..a3d2cabe837 100644 --- a/tests/components/smartthings/fixtures/device_status/c2c_arlo_pro_3_switch.json +++ b/tests/components/smartthings/fixtures/device_status/c2c_arlo_pro_3_switch.json @@ -58,6 +58,15 @@ "timestamp": "2025-02-08T21:56:09.761Z" } }, + "powerConsumptionReport": { + "powerConsumption": { + "value": { + "start": "2025-02-09T15:45:29Z", + "end": "2025-02-09T16:15:33Z" + }, + "timestamp": "2025-02-09T16:15:33.639Z" + } + }, "battery": { "quantity": { "value": null From 991de6f1d02e2c8d11c3256ecc0fbe8b133a85a4 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Fri, 7 Mar 2025 15:44:58 +0100 Subject: [PATCH 2348/3148] Bump py-synologydsm-api to 2.7.1 (#140052) bump py-synologydsm-api to 2.7.1 --- homeassistant/components/synology_dsm/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/synology_dsm/manifest.json b/homeassistant/components/synology_dsm/manifest.json index dc5634e7a84..3804de7f3f1 100644 --- a/homeassistant/components/synology_dsm/manifest.json +++ b/homeassistant/components/synology_dsm/manifest.json @@ -7,7 +7,7 @@ "documentation": "https://www.home-assistant.io/integrations/synology_dsm", "iot_class": "local_polling", "loggers": ["synology_dsm"], - "requirements": ["py-synologydsm-api==2.7.0"], + "requirements": ["py-synologydsm-api==2.7.1"], "ssdp": [ { "manufacturer": "Synology", diff --git a/requirements_all.txt b/requirements_all.txt index 0aa9ade3e37..0d1a593aebb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1755,7 +1755,7 @@ py-schluter==0.1.7 py-sucks==0.9.10 # homeassistant.components.synology_dsm -py-synologydsm-api==2.7.0 +py-synologydsm-api==2.7.1 # homeassistant.components.atome pyAtome==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index eb5ac5f97fb..b42d31188bf 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1453,7 +1453,7 @@ py-nightscout==1.2.2 py-sucks==0.9.10 # homeassistant.components.synology_dsm -py-synologydsm-api==2.7.0 +py-synologydsm-api==2.7.1 # homeassistant.components.hdmi_cec pyCEC==0.5.2 From 7e452521c88055a27fc4f594b0bc02142ae07b1f Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 7 Mar 2025 15:46:00 +0100 Subject: [PATCH 2349/3148] Restore SmartThings button event (#140044) * Restore SmartThings button event * Fix --- .../components/smartthings/__init__.py | 32 +++++++++++- homeassistant/components/smartthings/const.py | 2 + tests/components/smartthings/__init__.py | 2 + .../fixtures/device_status/button.json | 21 ++++++++ .../smartthings/fixtures/devices/button.json | 49 +++++++++++++++++++ .../snapshots/test_diagnostics.ambr | 4 +- .../smartthings/snapshots/test_init.ambr | 3 ++ tests/components/smartthings/test_init.py | 35 ++++++++++++- 8 files changed, 143 insertions(+), 5 deletions(-) create mode 100644 tests/components/smartthings/fixtures/device_status/button.json create mode 100644 tests/components/smartthings/fixtures/devices/button.json diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py index e26a9293c41..3169a249189 100644 --- a/homeassistant/components/smartthings/__init__.py +++ b/homeassistant/components/smartthings/__init__.py @@ -12,6 +12,7 @@ from pysmartthings import ( Attribute, Capability, Device, + DeviceEvent, Scene, SmartThings, SmartThingsAuthenticationFailedError, @@ -29,7 +30,14 @@ from homeassistant.helpers.config_entry_oauth2_flow import ( async_get_config_entry_implementation, ) -from .const import CONF_INSTALLED_APP_ID, CONF_LOCATION_ID, DOMAIN, MAIN, OLD_DATA +from .const import ( + CONF_INSTALLED_APP_ID, + CONF_LOCATION_ID, + DOMAIN, + EVENT_BUTTON, + MAIN, + OLD_DATA, +) _LOGGER = logging.getLogger(__name__) @@ -115,6 +123,28 @@ async def async_setup_entry(hass: HomeAssistant, entry: SmartThingsConfigEntry) scenes=scenes, ) + def handle_button_press(event: DeviceEvent) -> None: + """Handle a button press.""" + if ( + event.capability is Capability.BUTTON + and event.attribute is Attribute.BUTTON + ): + hass.bus.async_fire( + EVENT_BUTTON, + { + "component_id": event.component_id, + "device_id": event.device_id, + "location_id": event.location_id, + "value": event.value, + "name": entry.runtime_data.devices[event.device_id].device.label, + "data": event.data, + }, + ) + + entry.async_on_unload( + client.add_unspecified_device_event_listener(handle_button_press) + ) + entry.async_create_background_task( hass, client.subscribe( diff --git a/homeassistant/components/smartthings/const.py b/homeassistant/components/smartthings/const.py index 23fd48a4e1e..a6d028aed06 100644 --- a/homeassistant/components/smartthings/const.py +++ b/homeassistant/components/smartthings/const.py @@ -32,3 +32,5 @@ CONF_REFRESH_TOKEN = "refresh_token" MAIN = "main" OLD_DATA = "old_data" + +EVENT_BUTTON = "smartthings.button" diff --git a/tests/components/smartthings/__init__.py b/tests/components/smartthings/__init__.py index 6939d3c5dcc..e87d1a8bcdf 100644 --- a/tests/components/smartthings/__init__.py +++ b/tests/components/smartthings/__init__.py @@ -68,6 +68,8 @@ async def trigger_update( value, data, ) + for call in mock.add_unspecified_device_event_listener.call_args_list: + call[0][0](event) for call in mock.add_device_event_listener.call_args_list: if call[0][0] == device_id: call[0][3](event) diff --git a/tests/components/smartthings/fixtures/device_status/button.json b/tests/components/smartthings/fixtures/device_status/button.json new file mode 100644 index 00000000000..93e320bcb7b --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/button.json @@ -0,0 +1,21 @@ +{ + "components": { + "main": { + "button": { + "button": { + "value": "pushed", + "timestamp": "2025-03-07T12:20:43.363Z" + }, + "numberOfButtons": { + "value": 1, + "timestamp": "2025-03-07T12:20:43.363Z" + }, + "supportedButtonValues": { + "value": ["pushed", "held", "pushed_2x"], + "timestamp": "2025-03-07T12:20:43.363Z" + } + }, + "refresh": {} + } + } +} diff --git a/tests/components/smartthings/fixtures/devices/button.json b/tests/components/smartthings/fixtures/devices/button.json new file mode 100644 index 00000000000..ba993ca6aa7 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/button.json @@ -0,0 +1,49 @@ +{ + "items": [ + { + "deviceId": "c4bdd19f-85d1-4d58-8f9c-e75ac3cf113b", + "name": "button", + "label": "button", + "manufacturerName": "SmartThingsCommunity", + "presentationId": "238c483a-10e8-359b-b032-1be2b2fcdee7", + "locationId": "88a3a314-f0c8-40b4-bb44-44ba06c9c42f", + "ownerId": "12d4af93-cb68-b108-87f5-625437d7371f", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "button", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2025-03-07T12:20:43.273Z", + "profile": { + "id": "b045d731-4d01-35bc-8018-b3da711d8904" + }, + "virtual": { + "name": "button", + "executingLocally": false + }, + "type": "VIRTUAL", + "restrictionTier": 0, + "allowed": null, + "executionContext": "CLOUD", + "relationships": [] + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/snapshots/test_diagnostics.ambr b/tests/components/smartthings/snapshots/test_diagnostics.ambr index 7610c8839ba..489b79bc904 100644 --- a/tests/components/smartthings/snapshots/test_diagnostics.ambr +++ b/tests/components/smartthings/snapshots/test_diagnostics.ambr @@ -300,7 +300,7 @@ 'id': '60fbc713-8da5-315d-b31a-6d6dcde4be7b', }), 'restrictionTier': 0, - 'roomId': '7715151d-0314-457a-a82c-5ce48900e065', + 'roomId': '85a79db4-9cf2-4f09-a5b2-cd70a5c0cef0', 'type': 'OCF', }), ]), @@ -606,7 +606,7 @@ 'id': '60fbc713-8da5-315d-b31a-6d6dcde4be7b', }), 'restrictionTier': 0, - 'roomId': '7715151d-0314-457a-a82c-5ce48900e065', + 'roomId': '85a79db4-9cf2-4f09-a5b2-cd70a5c0cef0', 'type': 'OCF', }), 'status': dict({ diff --git a/tests/components/smartthings/snapshots/test_init.ambr b/tests/components/smartthings/snapshots/test_init.ambr index f0c9313871b..e25abf918cd 100644 --- a/tests/components/smartthings/snapshots/test_init.ambr +++ b/tests/components/smartthings/snapshots/test_init.ambr @@ -1,4 +1,7 @@ # serializer version: 1 +# name: test_button_event[button] + +# --- # name: test_devices[aeotec_home_energy_meter_gen5] DeviceRegistryEntrySnapshot({ 'area_id': None, diff --git a/tests/components/smartthings/test_init.py b/tests/components/smartthings/test_init.py index 372f23eec42..2158282e9e6 100644 --- a/tests/components/smartthings/test_init.py +++ b/tests/components/smartthings/test_init.py @@ -2,14 +2,16 @@ from unittest.mock import AsyncMock +from pysmartthings import Attribute, Capability import pytest from syrupy import SnapshotAssertion +from homeassistant.components.smartthings import EVENT_BUTTON from homeassistant.components.smartthings.const import DOMAIN -from homeassistant.core import HomeAssistant +from homeassistant.core import Event, HomeAssistant from homeassistant.helpers import device_registry as dr -from . import setup_integration +from . import setup_integration, trigger_update from tests.common import MockConfigEntry @@ -32,6 +34,35 @@ async def test_devices( assert device == snapshot +@pytest.mark.parametrize("device_fixture", ["button"]) +async def test_button_event( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test button event.""" + await setup_integration(hass, mock_config_entry) + events = [] + + def capture_event(event: Event) -> None: + events.append(event) + + hass.bus.async_listen_once(EVENT_BUTTON, capture_event) + + await trigger_update( + hass, + devices, + "c4bdd19f-85d1-4d58-8f9c-e75ac3cf113b", + Capability.BUTTON, + Attribute.BUTTON, + "pushed", + ) + + assert len(events) == 1 + assert events[0] == snapshot + + @pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) async def test_removing_stale_devices( hass: HomeAssistant, From 9f95383201262b505e8ce740ac668d0c03b0a50c Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 7 Mar 2025 17:03:29 +0000 Subject: [PATCH 2350/3148] Bump version to 2025.3.1 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index da2c3268642..35d00103074 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -25,7 +25,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2025 MINOR_VERSION: Final = 3 -PATCH_VERSION: Final = "0" +PATCH_VERSION: Final = "1" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 13, 0) diff --git a/pyproject.toml b/pyproject.toml index 3f80f7c8ead..12aec7e8f39 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.3.0" +version = "2025.3.1" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From fe34e6beee0baff9b512a8ab6feb0dcf4ac955e2 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Fri, 7 Mar 2025 18:16:55 +0100 Subject: [PATCH 2351/3148] Improve user-facing strings of Bang & Olufsen integration (#140062) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fix typo "Setup …" -> "Set up …" - fix the wrong capitalization of "… all Connected …" - change all action descriptions to match Home Assistant style - reword descriptions of `beolink_expand` and `beolink_unexpand` action using different verbs to better explain them --- homeassistant/components/bang_olufsen/strings.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/bang_olufsen/strings.json b/homeassistant/components/bang_olufsen/strings.json index 57ab828f9fb..278e9b6d47c 100644 --- a/homeassistant/components/bang_olufsen/strings.json +++ b/homeassistant/components/bang_olufsen/strings.json @@ -29,7 +29,7 @@ "description": "Manually configure your Bang & Olufsen device." }, "zeroconf_confirm": { - "title": "Setup Bang & Olufsen device", + "title": "Set up Bang & Olufsen device", "description": "Confirm the configuration of the {model}-{serial_number} @ {host}." } } @@ -197,11 +197,11 @@ "services": { "beolink_allstandby": { "name": "Beolink all standby", - "description": "Set all Connected Beolink devices to standby." + "description": "Sets all connected Beolink devices to standby." }, "beolink_expand": { "name": "Beolink expand", - "description": "Expand current Beolink experience.", + "description": "Adds devices to the current Beolink experience.", "fields": { "all_discovered": { "name": "All discovered", @@ -221,7 +221,7 @@ }, "beolink_join": { "name": "Beolink join", - "description": "Join a Beolink experience.", + "description": "Joins a Beolink experience.", "fields": { "beolink_jid": { "name": "Beolink JID", @@ -241,11 +241,11 @@ }, "beolink_leave": { "name": "Beolink leave", - "description": "Leave a Beolink experience." + "description": "Leaves a Beolink experience." }, "beolink_unexpand": { "name": "Beolink unexpand", - "description": "Unexpand from current Beolink experience.", + "description": "Removes devices from the current Beolink experience.", "fields": { "beolink_jids": { "name": "Beolink JIDs", From 3ccb7d80f3d4e636e023fc808ecccb39d45a12f6 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Fri, 7 Mar 2025 19:40:17 +0100 Subject: [PATCH 2352/3148] Add `update_todo` action to Habitica (#139799) * update_todo action * fix strings --- homeassistant/components/habitica/const.py | 9 + homeassistant/components/habitica/icons.json | 10 + homeassistant/components/habitica/services.py | 117 +++++++++-- .../components/habitica/services.yaml | 66 ++++++ .../components/habitica/strings.json | 130 +++++++++++- tests/components/habitica/conftest.py | 13 +- tests/components/habitica/fixtures/tasks.json | 13 +- .../habitica/snapshots/test_services.ambr | 50 +++++ tests/components/habitica/test_services.py | 193 +++++++++++++++++- 9 files changed, 573 insertions(+), 28 deletions(-) diff --git a/homeassistant/components/habitica/const.py b/homeassistant/components/habitica/const.py index 049f2beb370..c33edc0161d 100644 --- a/homeassistant/components/habitica/const.py +++ b/homeassistant/components/habitica/const.py @@ -44,6 +44,14 @@ ATTR_UP_DOWN = "up_down" ATTR_FREQUENCY = "frequency" ATTR_COUNTER_UP = "counter_up" ATTR_COUNTER_DOWN = "counter_down" +ATTR_ADD_CHECKLIST_ITEM = "add_checklist_item" +ATTR_REMOVE_CHECKLIST_ITEM = "remove_checklist_item" +ATTR_SCORE_CHECKLIST_ITEM = "score_checklist_item" +ATTR_UNSCORE_CHECKLIST_ITEM = "unscore_checklist_item" +ATTR_REMINDER = "reminder" +ATTR_REMOVE_REMINDER = "remove_reminder" +ATTR_CLEAR_REMINDER = "clear_reminder" +ATTR_CLEAR_DATE = "clear_date" SERVICE_CAST_SKILL = "cast_skill" SERVICE_START_QUEST = "start_quest" @@ -63,6 +71,7 @@ SERVICE_UPDATE_REWARD = "update_reward" SERVICE_CREATE_REWARD = "create_reward" SERVICE_UPDATE_HABIT = "update_habit" SERVICE_CREATE_HABIT = "create_habit" +SERVICE_UPDATE_TODO = "update_todo" DEVELOPER_ID = "4c4ca53f-c059-4ffa-966e-9d29dd405daf" X_CLIENT = f"{DEVELOPER_ID} - {APPLICATION_NAME} {__version__}" diff --git a/homeassistant/components/habitica/icons.json b/homeassistant/components/habitica/icons.json index af4a20acab6..f4f045523d4 100644 --- a/homeassistant/components/habitica/icons.json +++ b/homeassistant/components/habitica/icons.json @@ -243,6 +243,16 @@ "sections": { "developer_options": "mdi:test-tube" } + }, + "update_todo": { + "service": "mdi:pencil-box-outline", + "sections": { + "checklist_options": "mdi:format-list-checks", + "tag_options": "mdi:tag", + "developer_options": "mdi:test-tube", + "duedate_options": "mdi:calendar-blank", + "reminder_options": "mdi:reminder" + } } } } diff --git a/homeassistant/components/habitica/services.py b/homeassistant/components/habitica/services.py index 78f3002c89d..f1e92d863ca 100644 --- a/homeassistant/components/habitica/services.py +++ b/homeassistant/components/habitica/services.py @@ -3,17 +3,20 @@ from __future__ import annotations from dataclasses import asdict +from datetime import datetime, time import logging from typing import TYPE_CHECKING, Any, cast -from uuid import UUID +from uuid import UUID, uuid4 from aiohttp import ClientError from habiticalib import ( + Checklist, Direction, Frequency, HabiticaException, NotAuthorizedError, NotFoundError, + Reminders, Skill, Task, TaskData, @@ -25,7 +28,7 @@ import voluptuous as vol from homeassistant.components.todo import ATTR_RENAME from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import ATTR_NAME, CONF_NAME +from homeassistant.const import ATTR_DATE, ATTR_NAME, CONF_NAME from homeassistant.core import ( HomeAssistant, ServiceCall, @@ -38,8 +41,11 @@ from homeassistant.helpers.issue_registry import IssueSeverity, async_create_iss from homeassistant.helpers.selector import ConfigEntrySelector from .const import ( + ATTR_ADD_CHECKLIST_ITEM, ATTR_ALIAS, ATTR_ARGS, + ATTR_CLEAR_DATE, + ATTR_CLEAR_REMINDER, ATTR_CONFIG_ENTRY, ATTR_COST, ATTR_COUNTER_DOWN, @@ -52,12 +58,17 @@ from .const import ( ATTR_NOTES, ATTR_PATH, ATTR_PRIORITY, + ATTR_REMINDER, + ATTR_REMOVE_CHECKLIST_ITEM, + ATTR_REMOVE_REMINDER, ATTR_REMOVE_TAG, + ATTR_SCORE_CHECKLIST_ITEM, ATTR_SKILL, ATTR_TAG, ATTR_TARGET, ATTR_TASK, ATTR_TYPE, + ATTR_UNSCORE_CHECKLIST_ITEM, ATTR_UP_DOWN, DOMAIN, EVENT_API_CALL_SUCCESS, @@ -77,6 +88,7 @@ from .const import ( SERVICE_TRANSFORMATION, SERVICE_UPDATE_HABIT, SERVICE_UPDATE_REWARD, + SERVICE_UPDATE_TODO, ) from .coordinator import HabiticaConfigEntry @@ -137,6 +149,15 @@ BASE_TASK_SCHEMA = vol.Schema( vol.Optional(ATTR_COUNTER_UP): vol.All(int, vol.Range(0)), vol.Optional(ATTR_COUNTER_DOWN): vol.All(int, vol.Range(0)), vol.Optional(ATTR_FREQUENCY): vol.Coerce(Frequency), + vol.Optional(ATTR_DATE): cv.date, + vol.Optional(ATTR_CLEAR_DATE): cv.boolean, + vol.Optional(ATTR_REMINDER): vol.All(cv.ensure_list, [cv.datetime]), + vol.Optional(ATTR_REMOVE_REMINDER): vol.All(cv.ensure_list, [cv.datetime]), + vol.Optional(ATTR_CLEAR_REMINDER): cv.boolean, + vol.Optional(ATTR_ADD_CHECKLIST_ITEM): vol.All(cv.ensure_list, [str]), + vol.Optional(ATTR_REMOVE_CHECKLIST_ITEM): vol.All(cv.ensure_list, [str]), + vol.Optional(ATTR_SCORE_CHECKLIST_ITEM): vol.All(cv.ensure_list, [str]), + vol.Optional(ATTR_UNSCORE_CHECKLIST_ITEM): vol.All(cv.ensure_list, [str]), } ) @@ -192,6 +213,7 @@ SERVICE_TASK_TYPE_MAP = { SERVICE_CREATE_REWARD: TaskType.REWARD, SERVICE_UPDATE_HABIT: TaskType.HABIT, SERVICE_CREATE_HABIT: TaskType.HABIT, + SERVICE_UPDATE_TODO: TaskType.TODO, } @@ -577,7 +599,11 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 entry = get_config_entry(hass, call.data[ATTR_CONFIG_ENTRY]) coordinator = entry.runtime_data await coordinator.async_refresh() - is_update = call.service in (SERVICE_UPDATE_REWARD, SERVICE_UPDATE_HABIT) + is_update = call.service in ( + SERVICE_UPDATE_HABIT, + SERVICE_UPDATE_REWARD, + SERVICE_UPDATE_TODO, + ) current_task = None if is_update: @@ -685,6 +711,69 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 if counter_down := call.data.get(ATTR_COUNTER_DOWN): data["counterDown"] = counter_down + if due_date := call.data.get(ATTR_DATE): + data["date"] = datetime.combine(due_date, time()) + + if call.data.get(ATTR_CLEAR_DATE): + data["date"] = None + + checklist = current_task.checklist if current_task else [] + + if add_checklist_item := call.data.get(ATTR_ADD_CHECKLIST_ITEM): + checklist.extend( + Checklist(completed=False, id=uuid4(), text=item) + for item in add_checklist_item + if not any(i.text == item for i in checklist) + ) + if remove_checklist_item := call.data.get(ATTR_REMOVE_CHECKLIST_ITEM): + checklist = [ + item for item in checklist if item.text not in remove_checklist_item + ] + + if score_checklist_item := call.data.get(ATTR_SCORE_CHECKLIST_ITEM): + for item in checklist: + if item.text in score_checklist_item: + item.completed = True + + if unscore_checklist_item := call.data.get(ATTR_UNSCORE_CHECKLIST_ITEM): + for item in checklist: + if item.text in unscore_checklist_item: + item.completed = False + if ( + add_checklist_item + or remove_checklist_item + or score_checklist_item + or unscore_checklist_item + ): + data["checklist"] = checklist + + reminders = current_task.reminders if current_task else [] + + if add_reminders := call.data.get(ATTR_REMINDER): + existing_reminder_datetimes = { + r.time.replace(tzinfo=None) for r in reminders + } + + reminders.extend( + Reminders(id=uuid4(), time=r) + for r in add_reminders + if r not in existing_reminder_datetimes + ) + + if remove_reminder := call.data.get(ATTR_REMOVE_REMINDER): + reminders = list( + filter( + lambda r: r.time.replace(tzinfo=None) not in remove_reminder, + reminders, + ) + ) + + if clear_reminders := call.data.get(ATTR_CLEAR_REMINDER): + reminders = [] + + if add_reminders or remove_reminder or clear_reminders: + data["reminders"] = reminders + try: if is_update: if TYPE_CHECKING: @@ -714,20 +803,14 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 else: return response.data.to_dict(omit_none=True) - hass.services.async_register( - DOMAIN, - SERVICE_UPDATE_REWARD, - create_or_update_task, - schema=SERVICE_UPDATE_TASK_SCHEMA, - supports_response=SupportsResponse.ONLY, - ) - hass.services.async_register( - DOMAIN, - SERVICE_UPDATE_HABIT, - create_or_update_task, - schema=SERVICE_UPDATE_TASK_SCHEMA, - supports_response=SupportsResponse.ONLY, - ) + for service in (SERVICE_UPDATE_TODO, SERVICE_UPDATE_REWARD, SERVICE_UPDATE_HABIT): + hass.services.async_register( + DOMAIN, + service, + create_or_update_task, + schema=SERVICE_UPDATE_TASK_SCHEMA, + supports_response=SupportsResponse.ONLY, + ) hass.services.async_register( DOMAIN, SERVICE_CREATE_REWARD, diff --git a/homeassistant/components/habitica/services.yaml b/homeassistant/components/habitica/services.yaml index ed3ae4516e5..2464b39529b 100644 --- a/homeassistant/components/habitica/services.yaml +++ b/homeassistant/components/habitica/services.yaml @@ -262,3 +262,69 @@ create_habit: frequency: *frequency tag: *tag developer_options: *developer_options +update_todo: + fields: + config_entry: *config_entry + task: *task + rename: *rename + notes: *notes + checklist_options: + collapsed: true + fields: + add_checklist_item: + required: false + selector: + text: + multiple: true + remove_checklist_item: + required: false + selector: + text: + multiple: true + score_checklist_item: + required: false + selector: + text: + multiple: true + unscore_checklist_item: + required: false + selector: + text: + multiple: true + priority: *priority + duedate_options: + collapsed: true + fields: + date: + required: false + selector: + date: + clear_date: + required: false + selector: + constant: + value: true + label: "🗑️" + reminder_options: + collapsed: true + fields: + reminder: + required: false + selector: + text: + type: datetime-local + multiple: true + remove_reminder: + required: false + selector: + text: + type: datetime-local + multiple: true + clear_reminder: + required: false + selector: + constant: + value: true + label: "🗑️" + tag_options: *tag_options + developer_options: *developer_options diff --git a/homeassistant/components/habitica/strings.json b/homeassistant/components/habitica/strings.json index 1f9424eafe1..d77bbd6f2be 100644 --- a/homeassistant/components/habitica/strings.json +++ b/homeassistant/components/habitica/strings.json @@ -26,12 +26,30 @@ "tag_options_description": "Add or remove tags from a task.", "name_description": "The title for the Habitica task.", "cost_name": "Cost", - "difficulty_name": "Difficulty", - "difficulty_description": "The difficulty of the task.", + "priority_name": "Difficulty", + "priority_description": "The difficulty of the task.", "frequency_name": "Counter reset", "frequency_description": "The frequency at which the habit's counter resets: daily at the start of a new day, weekly after Sunday night, or monthly at the beginning of a new month.", "up_down_name": "Rewards or losses", - "up_down_description": "Whether the habit is good and rewarding (positive), bad and penalizing (negative), or both." + "up_down_description": "Whether the habit is good and rewarding (positive), bad and penalizing (negative), or both.", + "add_checklist_item_name": "Add checklist items", + "add_checklist_item_description": "The items to add to a task's checklist.", + "remove_checklist_item_name": "Delete items", + "remove_checklist_item_description": "Remove items from a task's checklist.", + "score_checklist_item_name": "Complete items", + "score_checklist_item_description": "Mark items from a task's checklist as completed.", + "unscore_checklist_item_name": "Uncomplete items", + "unscore_checklist_item_description": "Undo completion of items of a task's checklist.", + "checklist_options_name": "Checklist", + "checklist_options_description": "Add, remove, or update status of an item on a task's checklist.", + "reminder_name": "Add reminders", + "reminder_description": "Add reminders to a Habitica task.", + "remove_reminder_name": "Remove reminders", + "remove_reminder_description": "Remove specific reminders from a Habitica task.", + "clear_reminder_name": "Clear all reminders", + "clear_reminder_description": "Remove all reminders from a Habitica task.", + "reminder_options_name": "Reminders", + "reminder_options_description": "Add, remove or clear reminders of a Habitica task." }, "config": { "abort": { @@ -659,7 +677,7 @@ "description": "Filter tasks by type." }, "priority": { - "name": "Difficulty", + "name": "[%key:component::habitica::common::priority_name%]", "description": "Filter tasks by difficulty." }, "task": { @@ -799,8 +817,8 @@ "description": "[%key:component::habitica::common::alias_description%]" }, "priority": { - "name": "[%key:component::habitica::common::difficulty_name%]", - "description": "[%key:component::habitica::common::difficulty_description%]" + "name": "[%key:component::habitica::common::priority_name%]", + "description": "[%key:component::habitica::common::priority_description%]" }, "frequency": { "name": "[%key:component::habitica::common::frequency_name%]", @@ -855,8 +873,8 @@ "description": "[%key:component::habitica::common::alias_description%]" }, "priority": { - "name": "[%key:component::habitica::common::difficulty_name%]", - "description": "[%key:component::habitica::common::difficulty_description%]" + "name": "[%key:component::habitica::common::priority_name%]", + "description": "[%key:component::habitica::common::priority_description%]" }, "frequency": { "name": "[%key:component::habitica::common::frequency_name%]", @@ -873,6 +891,102 @@ "description": "[%key:component::habitica::common::developer_options_description%]" } } + }, + "update_todo": { + "name": "Update a to-do", + "description": "Updates a specific to-do for a selected Habitica character", + "fields": { + "config_entry": { + "name": "[%key:component::habitica::common::config_entry_name%]", + "description": "[%key:component::habitica::common::config_entry_description%]" + }, + "task": { + "name": "[%key:component::habitica::common::task_name%]", + "description": "The name (or task ID) of the to-do you want to update." + }, + "rename": { + "name": "[%key:component::habitica::common::rename_name%]", + "description": "[%key:component::habitica::common::rename_description%]" + }, + "notes": { + "name": "[%key:component::habitica::common::notes_name%]", + "description": "[%key:component::habitica::common::notes_description%]" + }, + "tag": { + "name": "[%key:component::habitica::common::tag_name%]", + "description": "[%key:component::habitica::common::tag_description%]" + }, + "remove_tag": { + "name": "[%key:component::habitica::common::remove_tag_name%]", + "description": "[%key:component::habitica::common::remove_tag_description%]" + }, + "alias": { + "name": "[%key:component::habitica::common::alias_name%]", + "description": "[%key:component::habitica::common::alias_description%]" + }, + "priority": { + "name": "[%key:component::habitica::common::priority_name%]", + "description": "[%key:component::habitica::common::priority_description%]" + }, + "date": { + "name": "Due date", + "description": "The to-do's due date." + }, + "clear_date": { + "name": "Clear due date", + "description": "Remove the due date from the to-do." + }, + "reminder": { + "name": "[%key:component::habitica::common::reminder_name%]", + "description": "[%key:component::habitica::common::reminder_description%]" + }, + "remove_reminder": { + "name": "[%key:component::habitica::common::remove_reminder_name%]", + "description": "[%key:component::habitica::common::remove_reminder_description%]" + }, + "clear_reminder": { + "name": "[%key:component::habitica::common::clear_reminder_name%]", + "description": "[%key:component::habitica::common::clear_reminder_description%]" + }, + "add_checklist_item": { + "name": "[%key:component::habitica::common::add_checklist_item_name%]", + "description": "[%key:component::habitica::common::add_checklist_item_description%]" + }, + "remove_checklist_item": { + "name": "[%key:component::habitica::common::remove_checklist_item_name%]", + "description": "[%key:component::habitica::common::remove_checklist_item_description%]" + }, + "score_checklist_item": { + "name": "[%key:component::habitica::common::score_checklist_item_name%]", + "description": "[%key:component::habitica::common::score_checklist_item_description%]" + }, + "unscore_checklist_item": { + "name": "[%key:component::habitica::common::unscore_checklist_item_name%]", + "description": "[%key:component::habitica::common::unscore_checklist_item_description%]" + } + }, + "sections": { + "checklist_options": { + "name": "[%key:component::habitica::common::checklist_options_name%]", + "description": "[%key:component::habitica::common::checklist_options_description%]" + }, + "duedate_options": { + "name": "Due date", + "description": "Set, update or remove due dates of a to-do." + }, + "reminder_options": { + "name": "[%key:component::habitica::common::reminder_options_name%]", + "description": "[%key:component::habitica::common::reminder_options_description%]" + }, + "tag_options": { + "name": "[%key:component::habitica::common::tag_options_name%]", + "description": "[%key:component::habitica::common::tag_options_description%]" + }, + "developer_options": { + "name": "[%key:component::habitica::common::developer_options_name%]", + "description": "[%key:component::habitica::common::developer_options_description%]" + } + } } }, "selector": { diff --git a/tests/components/habitica/conftest.py b/tests/components/habitica/conftest.py index efb4f7300bf..4ef14699e0b 100644 --- a/tests/components/habitica/conftest.py +++ b/tests/components/habitica/conftest.py @@ -1,7 +1,8 @@ """Tests for the habitica component.""" from collections.abc import Generator -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, MagicMock, patch +from uuid import UUID from habiticalib import ( BadRequestError, @@ -176,3 +177,13 @@ def mock_setup_entry() -> Generator[AsyncMock]: "homeassistant.components.habitica.async_setup_entry", return_value=True ) as mock_setup_entry: yield mock_setup_entry + + +@pytest.fixture +def mock_uuid4() -> Generator[MagicMock]: + """Mock uuid4.""" + with patch( + "homeassistant.components.habitica.services.uuid4", autospec=True + ) as mock_uuid4: + mock_uuid4.return_value = UUID("12345678-1234-5678-1234-567812345678") + yield mock_uuid4 diff --git a/tests/components/habitica/fixtures/tasks.json b/tests/components/habitica/fixtures/tasks.json index 378652138bc..3dff57bdd51 100644 --- a/tests/components/habitica/fixtures/tasks.json +++ b/tests/components/habitica/fixtures/tasks.json @@ -425,7 +425,18 @@ "date": "2024-09-27T22:17:00.000Z", "completed": false, "collapseChecklist": false, - "checklist": [], + "checklist": [ + { + "completed": false, + "id": "fccc26f2-1e2b-4bf8-9dd0-a405be261036", + "text": "Checklist-item1" + }, + { + "completed": true, + "id": "5a897af4-ea94-456a-a2bd-f336bcd79509", + "text": "Checklist-item2" + } + ], "type": "todo", "text": "Buch zu Ende lesen", "notes": "Das Buch, das du angefangen hast, bis zum Wochenende fertig lesen.", diff --git a/tests/components/habitica/snapshots/test_services.ambr b/tests/components/habitica/snapshots/test_services.ambr index 79c9e3eab66..af0ec76f3a4 100644 --- a/tests/components/habitica/snapshots/test_services.ambr +++ b/tests/components/habitica/snapshots/test_services.ambr @@ -736,6 +736,16 @@ 'winner': None, }), 'checklist': list([ + dict({ + 'completed': False, + 'id': 'fccc26f2-1e2b-4bf8-9dd0-a405be261036', + 'text': 'Checklist-item1', + }), + dict({ + 'completed': True, + 'id': '5a897af4-ea94-456a-a2bd-f336bcd79509', + 'text': 'Checklist-item2', + }), ]), 'collapseChecklist': False, 'completed': False, @@ -1834,6 +1844,16 @@ 'winner': None, }), 'checklist': list([ + dict({ + 'completed': False, + 'id': 'fccc26f2-1e2b-4bf8-9dd0-a405be261036', + 'text': 'Checklist-item1', + }), + dict({ + 'completed': True, + 'id': '5a897af4-ea94-456a-a2bd-f336bcd79509', + 'text': 'Checklist-item2', + }), ]), 'collapseChecklist': False, 'completed': False, @@ -2978,6 +2998,16 @@ 'winner': None, }), 'checklist': list([ + dict({ + 'completed': False, + 'id': 'fccc26f2-1e2b-4bf8-9dd0-a405be261036', + 'text': 'Checklist-item1', + }), + dict({ + 'completed': True, + 'id': '5a897af4-ea94-456a-a2bd-f336bcd79509', + 'text': 'Checklist-item2', + }), ]), 'collapseChecklist': False, 'completed': False, @@ -5615,6 +5645,16 @@ 'winner': None, }), 'checklist': list([ + dict({ + 'completed': False, + 'id': 'fccc26f2-1e2b-4bf8-9dd0-a405be261036', + 'text': 'Checklist-item1', + }), + dict({ + 'completed': True, + 'id': '5a897af4-ea94-456a-a2bd-f336bcd79509', + 'text': 'Checklist-item2', + }), ]), 'collapseChecklist': False, 'completed': False, @@ -6137,6 +6177,16 @@ 'winner': None, }), 'checklist': list([ + dict({ + 'completed': False, + 'id': 'fccc26f2-1e2b-4bf8-9dd0-a405be261036', + 'text': 'Checklist-item1', + }), + dict({ + 'completed': True, + 'id': '5a897af4-ea94-456a-a2bd-f336bcd79509', + 'text': 'Checklist-item2', + }), ]), 'collapseChecklist': False, 'completed': False, diff --git a/tests/components/habitica/test_services.py b/tests/components/habitica/test_services.py index 00ad7e6b2e9..3fd477f6858 100644 --- a/tests/components/habitica/test_services.py +++ b/tests/components/habitica/test_services.py @@ -1,15 +1,18 @@ """Test Habitica actions.""" from collections.abc import Generator +from datetime import datetime from typing import Any from unittest.mock import AsyncMock, patch from uuid import UUID from aiohttp import ClientError from habiticalib import ( + Checklist, Direction, Frequency, HabiticaTaskResponse, + Reminders, Skill, Task, TaskPriority, @@ -19,7 +22,10 @@ import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.habitica.const import ( + ATTR_ADD_CHECKLIST_ITEM, ATTR_ALIAS, + ATTR_CLEAR_DATE, + ATTR_CLEAR_REMINDER, ATTR_CONFIG_ENTRY, ATTR_COST, ATTR_COUNTER_DOWN, @@ -30,12 +36,17 @@ from homeassistant.components.habitica.const import ( ATTR_KEYWORD, ATTR_NOTES, ATTR_PRIORITY, + ATTR_REMINDER, + ATTR_REMOVE_CHECKLIST_ITEM, + ATTR_REMOVE_REMINDER, ATTR_REMOVE_TAG, + ATTR_SCORE_CHECKLIST_ITEM, ATTR_SKILL, ATTR_TAG, ATTR_TARGET, ATTR_TASK, ATTR_TYPE, + ATTR_UNSCORE_CHECKLIST_ITEM, ATTR_UP_DOWN, DOMAIN, SERVICE_ABORT_QUEST, @@ -53,10 +64,11 @@ from homeassistant.components.habitica.const import ( SERVICE_TRANSFORMATION, SERVICE_UPDATE_HABIT, SERVICE_UPDATE_REWARD, + SERVICE_UPDATE_TODO, ) from homeassistant.components.todo import ATTR_RENAME from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import ATTR_NAME +from homeassistant.const import ATTR_DATE, ATTR_NAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError @@ -938,6 +950,7 @@ async def test_get_tasks( [ (SERVICE_UPDATE_REWARD, "5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b"), (SERVICE_UPDATE_HABIT, "f21fa608-cfc6-4413-9fc7-0eb1b48ca43a"), + (SERVICE_UPDATE_TODO, "88de7cd9-af2b-49ce-9afd-bf941d87336b"), ], ) @pytest.mark.usefixtures("habitica") @@ -1318,6 +1331,184 @@ async def test_create_habit( habitica.create_task.assert_awaited_with(call_args) +@pytest.mark.parametrize( + ("service_data", "call_args"), + [ + ( + { + ATTR_RENAME: "RENAME", + }, + Task(text="RENAME"), + ), + ( + { + ATTR_NOTES: "NOTES", + }, + Task(notes="NOTES"), + ), + ( + { + ATTR_ADD_CHECKLIST_ITEM: "Checklist-item", + }, + Task( + { + "checklist": [ + Checklist( + id=UUID("fccc26f2-1e2b-4bf8-9dd0-a405be261036"), + text="Checklist-item1", + completed=False, + ), + Checklist( + id=UUID("5a897af4-ea94-456a-a2bd-f336bcd79509"), + text="Checklist-item2", + completed=True, + ), + Checklist( + id=UUID("12345678-1234-5678-1234-567812345678"), + text="Checklist-item", + completed=False, + ), + ] + } + ), + ), + ( + { + ATTR_REMOVE_CHECKLIST_ITEM: "Checklist-item1", + }, + Task( + { + "checklist": [ + Checklist( + id=UUID("5a897af4-ea94-456a-a2bd-f336bcd79509"), + text="Checklist-item2", + completed=True, + ), + ] + } + ), + ), + ( + { + ATTR_SCORE_CHECKLIST_ITEM: "Checklist-item1", + }, + Task( + { + "checklist": [ + Checklist( + id=UUID("fccc26f2-1e2b-4bf8-9dd0-a405be261036"), + text="Checklist-item1", + completed=True, + ), + Checklist( + id=UUID("5a897af4-ea94-456a-a2bd-f336bcd79509"), + text="Checklist-item2", + completed=True, + ), + ] + } + ), + ), + ( + { + ATTR_UNSCORE_CHECKLIST_ITEM: "Checklist-item2", + }, + Task( + { + "checklist": [ + Checklist( + id=UUID("fccc26f2-1e2b-4bf8-9dd0-a405be261036"), + text="Checklist-item1", + completed=False, + ), + Checklist( + id=UUID("5a897af4-ea94-456a-a2bd-f336bcd79509"), + text="Checklist-item2", + completed=False, + ), + ] + } + ), + ), + ( + { + ATTR_PRIORITY: "trivial", + }, + Task(priority=TaskPriority.TRIVIAL), + ), + ( + { + ATTR_DATE: "2025-03-05", + }, + Task(date=datetime(2025, 3, 5)), + ), + ( + { + ATTR_CLEAR_DATE: True, + }, + Task(date=None), + ), + ( + { + ATTR_REMINDER: ["2025-02-25T00:00"], + }, + Task( + { + "reminders": [ + Reminders( + id=UUID("12345678-1234-5678-1234-567812345678"), + time=datetime(2025, 2, 25, 0, 0), + startDate=None, + ) + ] + } + ), + ), + ( + { + ATTR_REMOVE_REMINDER: ["2025-02-25T00:00"], + }, + Task({"reminders": []}), + ), + ( + { + ATTR_CLEAR_REMINDER: True, + }, + Task({"reminders": []}), + ), + ( + { + ATTR_ALIAS: "ALIAS", + }, + Task(alias="ALIAS"), + ), + ], +) +@pytest.mark.usefixtures("mock_uuid4") +async def test_update_todo( + hass: HomeAssistant, + config_entry: MockConfigEntry, + habitica: AsyncMock, + service_data: dict[str, Any], + call_args: Task, +) -> None: + """Test Habitica update todo action.""" + task_id = "88de7cd9-af2b-49ce-9afd-bf941d87336b" + + await hass.services.async_call( + DOMAIN, + SERVICE_UPDATE_TODO, + service_data={ + ATTR_CONFIG_ENTRY: config_entry.entry_id, + ATTR_TASK: task_id, + **service_data, + }, + return_response=True, + blocking=True, + ) + habitica.update_task.assert_awaited_with(UUID(task_id), call_args) + + async def test_tags( hass: HomeAssistant, config_entry: MockConfigEntry, From 3b03a37f3bd78b9d8771bbf1c94a0e4497ea3c9e Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Fri, 7 Mar 2025 20:05:13 +0100 Subject: [PATCH 2353/3148] Add file upload service to OneDrive (#139092) * Add file upload service to OneDrive * fix * Add test * docstring * docstring * Fix capitalization in description text. --- homeassistant/components/onedrive/__init__.py | 11 +- homeassistant/components/onedrive/icons.json | 5 + .../components/onedrive/quality_scale.yaml | 9 +- homeassistant/components/onedrive/services.py | 131 ++++++++ .../components/onedrive/services.yaml | 15 + .../components/onedrive/strings.json | 40 +++ tests/components/onedrive/test_services.py | 280 ++++++++++++++++++ 7 files changed, 483 insertions(+), 8 deletions(-) create mode 100644 homeassistant/components/onedrive/services.py create mode 100644 homeassistant/components/onedrive/services.yaml create mode 100644 tests/components/onedrive/test_services.py diff --git a/homeassistant/components/onedrive/__init__.py b/homeassistant/components/onedrive/__init__.py index f10b8fe0d91..17dead653f0 100644 --- a/homeassistant/components/onedrive/__init__.py +++ b/homeassistant/components/onedrive/__init__.py @@ -19,12 +19,14 @@ from onedrive_personal_sdk.models.items import Item, ItemUpdate from homeassistant.const import CONF_ACCESS_TOKEN, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.config_entry_oauth2_flow import ( OAuth2Session, async_get_config_entry_implementation, ) from homeassistant.helpers.instance_id import async_get as async_get_instance_id +from homeassistant.helpers.typing import ConfigType from .const import CONF_FOLDER_ID, CONF_FOLDER_NAME, DATA_BACKUP_AGENT_LISTENERS, DOMAIN from .coordinator import ( @@ -32,13 +34,20 @@ from .coordinator import ( OneDriveRuntimeData, OneDriveUpdateCoordinator, ) +from .services import async_register_services +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) PLATFORMS = [Platform.SENSOR] - _LOGGER = logging.getLogger(__name__) +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the OneDrive integration.""" + async_register_services(hass) + return True + + async def async_setup_entry(hass: HomeAssistant, entry: OneDriveConfigEntry) -> bool: """Set up OneDrive from a config entry.""" client, get_access_token = await _get_onedrive_client(hass, entry) diff --git a/homeassistant/components/onedrive/icons.json b/homeassistant/components/onedrive/icons.json index b693f69934e..2ac4921439c 100644 --- a/homeassistant/components/onedrive/icons.json +++ b/homeassistant/components/onedrive/icons.json @@ -20,5 +20,10 @@ } } } + }, + "services": { + "upload": { + "service": "mdi:cloud-upload" + } } } diff --git a/homeassistant/components/onedrive/quality_scale.yaml b/homeassistant/components/onedrive/quality_scale.yaml index 023410d89b2..1632c2670e0 100644 --- a/homeassistant/components/onedrive/quality_scale.yaml +++ b/homeassistant/components/onedrive/quality_scale.yaml @@ -1,18 +1,13 @@ rules: # Bronze - action-setup: - status: exempt - comment: Integration does not register custom actions. + action-setup: done appropriate-polling: done brands: done common-modules: done config-flow-test-coverage: done config-flow: done dependency-transparency: done - docs-actions: - status: exempt - comment: | - This integration does not have any custom actions. + docs-actions: done docs-high-level-description: done docs-installation-instructions: done docs-removal-instructions: done diff --git a/homeassistant/components/onedrive/services.py b/homeassistant/components/onedrive/services.py new file mode 100644 index 00000000000..1f1afe1507c --- /dev/null +++ b/homeassistant/components/onedrive/services.py @@ -0,0 +1,131 @@ +"""OneDrive services.""" + +from __future__ import annotations + +import asyncio +from dataclasses import asdict +from pathlib import Path +from typing import cast + +from onedrive_personal_sdk.exceptions import OneDriveException +import voluptuous as vol + +from homeassistant.const import CONF_FILENAME +from homeassistant.core import ( + HomeAssistant, + ServiceCall, + ServiceResponse, + SupportsResponse, +) +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.helpers import config_validation as cv + +from .const import DOMAIN +from .coordinator import OneDriveConfigEntry + +CONF_CONFIG_ENTRY_ID = "config_entry_id" +CONF_DESTINATION_FOLDER = "destination_folder" + +UPLOAD_SERVICE = "upload" +UPLOAD_SERVICE_SCHEMA = vol.Schema( + { + vol.Required(CONF_CONFIG_ENTRY_ID): cv.string, + vol.Required(CONF_FILENAME): vol.All(cv.ensure_list, [cv.string]), + vol.Required(CONF_DESTINATION_FOLDER): cv.string, + } +) +CONTENT_SIZE_LIMIT = 250 * 1024 * 1024 + + +def _read_file_contents( + hass: HomeAssistant, filenames: list[str] +) -> list[tuple[str, bytes]]: + """Return the mime types and file contents for each file.""" + results = [] + for filename in filenames: + if not hass.config.is_allowed_path(filename): + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="no_access_to_path", + translation_placeholders={"filename": filename}, + ) + filename_path = Path(filename) + if not filename_path.exists(): + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="filename_does_not_exist", + translation_placeholders={"filename": filename}, + ) + if filename_path.stat().st_size > CONTENT_SIZE_LIMIT: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="file_too_large", + translation_placeholders={ + "filename": filename, + "size": str(filename_path.stat().st_size), + "limit": str(CONTENT_SIZE_LIMIT), + }, + ) + results.append((filename_path.name, filename_path.read_bytes())) + return results + + +def async_register_services(hass: HomeAssistant) -> None: + """Register OneDrive services.""" + + async def async_handle_upload(call: ServiceCall) -> ServiceResponse: + """Generate content from text and optionally images.""" + config_entry: OneDriveConfigEntry | None = hass.config_entries.async_get_entry( + call.data[CONF_CONFIG_ENTRY_ID] + ) + if not config_entry: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="integration_not_found", + translation_placeholders={"target": DOMAIN}, + ) + client = config_entry.runtime_data.client + upload_tasks = [] + file_results = await hass.async_add_executor_job( + _read_file_contents, hass, call.data[CONF_FILENAME] + ) + + # make sure the destination folder exists + try: + folder_id = (await client.get_approot()).id + for folder in ( + cast(str, call.data[CONF_DESTINATION_FOLDER]).strip("/").split("/") + ): + folder_id = (await client.create_folder(folder_id, folder)).id + except OneDriveException as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="create_folder_error", + translation_placeholders={"message": str(err)}, + ) from err + + upload_tasks = [ + client.upload_file(folder_id, file_name, content) + for file_name, content in file_results + ] + try: + upload_results = await asyncio.gather(*upload_tasks) + except OneDriveException as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="upload_error", + translation_placeholders={"message": str(err)}, + ) from err + + if call.return_response: + return {"files": [asdict(item_result) for item_result in upload_results]} + return None + + if not hass.services.has_service(DOMAIN, UPLOAD_SERVICE): + hass.services.async_register( + DOMAIN, + UPLOAD_SERVICE, + async_handle_upload, + schema=UPLOAD_SERVICE_SCHEMA, + supports_response=SupportsResponse.OPTIONAL, + ) diff --git a/homeassistant/components/onedrive/services.yaml b/homeassistant/components/onedrive/services.yaml new file mode 100644 index 00000000000..0cf0faf6b60 --- /dev/null +++ b/homeassistant/components/onedrive/services.yaml @@ -0,0 +1,15 @@ +upload: + fields: + config_entry_id: + required: true + selector: + config_entry: + integration: onedrive + filename: + required: false + selector: + object: + destination_folder: + required: true + selector: + text: diff --git a/homeassistant/components/onedrive/strings.json b/homeassistant/components/onedrive/strings.json index 37e19eb68ca..90fa4efc3ec 100644 --- a/homeassistant/components/onedrive/strings.json +++ b/homeassistant/components/onedrive/strings.json @@ -90,6 +90,24 @@ }, "update_failed": { "message": "Failed to update drive state" + }, + "integration_not_found": { + "message": "Integration \"{target}\" not found in registry." + }, + "no_access_to_path": { + "message": "Cannot read {filename}, no access to path; `allowlist_external_dirs` may need to be adjusted in `configuration.yaml`" + }, + "filename_does_not_exist": { + "message": "`{filename}` does not exist" + }, + "file_too_large": { + "message": "`{filename}` is too large ({size} > {limit})" + }, + "upload_error": { + "message": "Failed to upload content: {message}" + }, + "create_folder_error": { + "message": "Failed to create folder: {message}" } }, "entity": { @@ -113,5 +131,27 @@ } } } + }, + "services": { + "upload": { + "name": "Upload file", + "description": "Uploads files to OneDrive.", + "fields": { + "config_entry_id": { + "name": "Config entry ID", + "description": "The config entry representing the OneDrive you want to upload to." + }, + "filename": { + "name": "Filename", + "description": "Path to the file to upload.", + "example": "/config/www/image.jpg" + }, + "destination_folder": { + "name": "Destination folder", + "description": "Folder inside the Home Assistant app folder (Apps/Home Assistant) you want to upload the file to. Will be created if it does not exist.", + "example": "photos/snapshots" + } + } + } } } diff --git a/tests/components/onedrive/test_services.py b/tests/components/onedrive/test_services.py new file mode 100644 index 00000000000..31d2d932cd0 --- /dev/null +++ b/tests/components/onedrive/test_services.py @@ -0,0 +1,280 @@ +"""Tests for OneDrive services.""" + +from collections.abc import Generator +from dataclasses import dataclass +import re +from typing import Any, cast +from unittest.mock import MagicMock, Mock, patch + +from onedrive_personal_sdk.exceptions import OneDriveException +import pytest + +from homeassistant.components.onedrive.const import DOMAIN +from homeassistant.components.onedrive.services import ( + CONF_CONFIG_ENTRY_ID, + CONF_DESTINATION_FOLDER, + UPLOAD_SERVICE, +) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_FILENAME +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError + +from . import setup_integration + +from tests.common import MockConfigEntry + +TEST_FILENAME = "doorbell_snapshot.jpg" +DESINATION_FOLDER = "TestFolder" + + +@dataclass +class MockUploadFile: + """Dataclass used to configure the test with a fake file behavior.""" + + content: bytes = b"image bytes" + exists: bool = True + is_allowed_path: bool = True + size: int | None = None + + +@pytest.fixture(name="upload_file") +def upload_file_fixture() -> MockUploadFile: + """Fixture to set up test configuration with a fake file.""" + return MockUploadFile() + + +@pytest.fixture(autouse=True) +def mock_upload_file( + hass: HomeAssistant, upload_file: MockUploadFile +) -> Generator[None]: + """Fixture that mocks out the file calls using the FakeFile fixture.""" + with ( + patch( + "homeassistant.components.onedrive.services.Path.read_bytes", + return_value=upload_file.content, + ), + patch( + "homeassistant.components.onedrive.services.Path.exists", + return_value=upload_file.exists, + ), + patch.object( + hass.config, "is_allowed_path", return_value=upload_file.is_allowed_path + ), + patch("pathlib.Path.stat") as mock_stat, + ): + mock_stat.return_value = Mock() + mock_stat.return_value.st_size = ( + upload_file.size if upload_file.size else len(upload_file.content) + ) + yield + + +async def test_upload_service( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test service call to upload content.""" + await setup_integration(hass, mock_config_entry) + + assert hass.services.has_service(DOMAIN, "upload") + + response = await hass.services.async_call( + DOMAIN, + UPLOAD_SERVICE, + { + CONF_CONFIG_ENTRY_ID: mock_config_entry.entry_id, + CONF_FILENAME: TEST_FILENAME, + CONF_DESTINATION_FOLDER: DESINATION_FOLDER, + }, + blocking=True, + return_response=True, + ) + + assert response + assert response["files"] + assert cast(list[dict[str, Any]], response["files"])[0]["id"] == "id" + + +async def test_upload_service_no_response( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test service call to upload content without response.""" + await setup_integration(hass, mock_config_entry) + + assert hass.services.has_service(DOMAIN, "upload") + + response = await hass.services.async_call( + DOMAIN, + UPLOAD_SERVICE, + { + CONF_CONFIG_ENTRY_ID: mock_config_entry.entry_id, + CONF_FILENAME: TEST_FILENAME, + CONF_DESTINATION_FOLDER: DESINATION_FOLDER, + }, + blocking=True, + ) + + assert response is None + + +async def test_upload_service_config_entry_not_found( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test upload service call with a config entry that does not exist.""" + await setup_integration(hass, mock_config_entry) + with pytest.raises(HomeAssistantError, match="not found in registry"): + await hass.services.async_call( + DOMAIN, + UPLOAD_SERVICE, + { + CONF_CONFIG_ENTRY_ID: "invalid-config-entry-id", + CONF_FILENAME: TEST_FILENAME, + CONF_DESTINATION_FOLDER: DESINATION_FOLDER, + }, + blocking=True, + return_response=True, + ) + + +async def test_config_entry_not_loaded( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test upload service call with a config entry that is not loaded.""" + await setup_integration(hass, mock_config_entry) + 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 + + with pytest.raises(HomeAssistantError, match="not found in registry"): + await hass.services.async_call( + DOMAIN, + UPLOAD_SERVICE, + { + CONF_CONFIG_ENTRY_ID: mock_config_entry.unique_id, + CONF_FILENAME: TEST_FILENAME, + CONF_DESTINATION_FOLDER: DESINATION_FOLDER, + }, + blocking=True, + return_response=True, + ) + + +@pytest.mark.parametrize("upload_file", [MockUploadFile(is_allowed_path=False)]) +async def test_path_is_not_allowed( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test upload service call with a filename path that is not allowed.""" + await setup_integration(hass, mock_config_entry) + with ( + pytest.raises(HomeAssistantError, match="no access to path"), + ): + await hass.services.async_call( + DOMAIN, + UPLOAD_SERVICE, + { + CONF_CONFIG_ENTRY_ID: mock_config_entry.entry_id, + CONF_FILENAME: TEST_FILENAME, + CONF_DESTINATION_FOLDER: DESINATION_FOLDER, + }, + blocking=True, + return_response=True, + ) + + +@pytest.mark.parametrize("upload_file", [MockUploadFile(exists=False)]) +async def test_filename_does_not_exist( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test upload service call with a filename path that does not exist.""" + await setup_integration(hass, mock_config_entry) + with pytest.raises(HomeAssistantError, match="does not exist"): + await hass.services.async_call( + DOMAIN, + UPLOAD_SERVICE, + { + CONF_CONFIG_ENTRY_ID: mock_config_entry.entry_id, + CONF_FILENAME: TEST_FILENAME, + CONF_DESTINATION_FOLDER: DESINATION_FOLDER, + }, + blocking=True, + return_response=True, + ) + + +async def test_upload_service_fails_upload( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_onedrive_client: MagicMock, +) -> None: + """Test service call to upload content.""" + await setup_integration(hass, mock_config_entry) + mock_onedrive_client.upload_file.side_effect = OneDriveException("error") + + with pytest.raises(HomeAssistantError, match="Failed to upload"): + await hass.services.async_call( + DOMAIN, + UPLOAD_SERVICE, + { + CONF_CONFIG_ENTRY_ID: mock_config_entry.entry_id, + CONF_FILENAME: TEST_FILENAME, + CONF_DESTINATION_FOLDER: DESINATION_FOLDER, + }, + blocking=True, + return_response=True, + ) + + +@pytest.mark.parametrize("upload_file", [MockUploadFile(size=260 * 1024 * 1024)]) +async def test_upload_size_limit( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test upload service call with a filename path that does not exist.""" + await setup_integration(hass, mock_config_entry) + with pytest.raises( + HomeAssistantError, + match=re.escape(f"`{TEST_FILENAME}` is too large (272629760 > 262144000)"), + ): + await hass.services.async_call( + DOMAIN, + UPLOAD_SERVICE, + { + CONF_CONFIG_ENTRY_ID: mock_config_entry.entry_id, + CONF_FILENAME: TEST_FILENAME, + CONF_DESTINATION_FOLDER: DESINATION_FOLDER, + }, + blocking=True, + return_response=True, + ) + + +async def test_create_album_failed( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_onedrive_client: MagicMock, +) -> None: + """Test service call when folder creation fails.""" + await setup_integration(hass, mock_config_entry) + assert hass.services.has_service(DOMAIN, "upload") + + mock_onedrive_client.create_folder.side_effect = OneDriveException() + + with pytest.raises(HomeAssistantError, match="Failed to create folder"): + await hass.services.async_call( + DOMAIN, + UPLOAD_SERVICE, + { + CONF_CONFIG_ENTRY_ID: mock_config_entry.entry_id, + CONF_FILENAME: TEST_FILENAME, + CONF_DESTINATION_FOLDER: DESINATION_FOLDER, + }, + blocking=True, + return_response=True, + ) From ccb0be9df43b048ac7a2c690a779339888a46138 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 7 Mar 2025 20:27:01 +0100 Subject: [PATCH 2354/3148] Update debugpy to 1.8.13 (#140067) --- homeassistant/components/debugpy/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/debugpy/manifest.json b/homeassistant/components/debugpy/manifest.json index 078af8c67a5..21211d334df 100644 --- a/homeassistant/components/debugpy/manifest.json +++ b/homeassistant/components/debugpy/manifest.json @@ -6,5 +6,5 @@ "integration_type": "service", "iot_class": "local_push", "quality_scale": "internal", - "requirements": ["debugpy==1.8.11"] + "requirements": ["debugpy==1.8.13"] } diff --git a/requirements_all.txt b/requirements_all.txt index a132c4b89ed..da9dbcc410d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -747,7 +747,7 @@ datapoint==0.9.9 dbus-fast==2.39.3 # homeassistant.components.debugpy -debugpy==1.8.11 +debugpy==1.8.13 # homeassistant.components.decora_wifi # decora-wifi==1.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ce6a7ce1d25..cd58ae0c25a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -643,7 +643,7 @@ datapoint==0.9.9 dbus-fast==2.39.3 # homeassistant.components.debugpy -debugpy==1.8.11 +debugpy==1.8.13 # homeassistant.components.ecovacs deebot-client==12.3.1 From 52838d8b8420be3dfb73d9eeb4853f7017be08dc Mon Sep 17 00:00:00 2001 From: Keilin Bickar Date: Fri, 7 Mar 2025 14:29:11 -0500 Subject: [PATCH 2355/3148] Bump sense-energy lib to 0.13.7 (#140068) --- homeassistant/components/emulated_kasa/manifest.json | 2 +- homeassistant/components/sense/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/emulated_kasa/manifest.json b/homeassistant/components/emulated_kasa/manifest.json index d607372136c..fc54fb50064 100644 --- a/homeassistant/components/emulated_kasa/manifest.json +++ b/homeassistant/components/emulated_kasa/manifest.json @@ -6,5 +6,5 @@ "iot_class": "local_push", "loggers": ["sense_energy"], "quality_scale": "internal", - "requirements": ["sense-energy==0.13.6"] + "requirements": ["sense-energy==0.13.7"] } diff --git a/homeassistant/components/sense/manifest.json b/homeassistant/components/sense/manifest.json index dda49b661e5..0a21dbf4cc3 100644 --- a/homeassistant/components/sense/manifest.json +++ b/homeassistant/components/sense/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/sense", "iot_class": "cloud_polling", "loggers": ["sense_energy"], - "requirements": ["sense-energy==0.13.6"] + "requirements": ["sense-energy==0.13.7"] } diff --git a/requirements_all.txt b/requirements_all.txt index da9dbcc410d..47de76d6913 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2694,7 +2694,7 @@ sendgrid==6.8.2 # homeassistant.components.emulated_kasa # homeassistant.components.sense -sense-energy==0.13.6 +sense-energy==0.13.7 # homeassistant.components.sensirion_ble sensirion-ble==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index cd58ae0c25a..ef52bf7cc5e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2173,7 +2173,7 @@ securetar==2025.2.1 # homeassistant.components.emulated_kasa # homeassistant.components.sense -sense-energy==0.13.6 +sense-energy==0.13.7 # homeassistant.components.sensirion_ble sensirion-ble==0.1.1 From 9b14faa43dfa4bc9f3f7efc48cf31f432963163d Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 7 Mar 2025 20:35:36 +0100 Subject: [PATCH 2356/3148] Update jinja to 3.1.6 (#140069) --- 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 0727beae8ed..bce7a2ddcdd 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -41,7 +41,7 @@ home-assistant-frontend==20250306.0 home-assistant-intents==2025.3.5 httpx==0.28.1 ifaddr==0.2.0 -Jinja2==3.1.5 +Jinja2==3.1.6 lru-dict==1.3.0 mutagen==1.47.0 orjson==3.10.15 diff --git a/pyproject.toml b/pyproject.toml index 3affa95a082..09c14cbde69 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,7 +52,7 @@ dependencies = [ "httpx==0.28.1", "home-assistant-bluetooth==1.13.1", "ifaddr==0.2.0", - "Jinja2==3.1.5", + "Jinja2==3.1.6", "lru-dict==1.3.0", "PyJWT==2.10.1", # PyJWT has loose dependency. We want the latest one. diff --git a/requirements.txt b/requirements.txt index 9bf94749ac9..6ae428d5420 100644 --- a/requirements.txt +++ b/requirements.txt @@ -25,7 +25,7 @@ hass-nabucasa==0.94.0 httpx==0.28.1 home-assistant-bluetooth==1.13.1 ifaddr==0.2.0 -Jinja2==3.1.5 +Jinja2==3.1.6 lru-dict==1.3.0 PyJWT==2.10.1 cryptography==44.0.1 From aa556d8678d6f82b9e9a14901c6a2b96dd615443 Mon Sep 17 00:00:00 2001 From: Evan Farrell Date: Fri, 7 Mar 2025 16:15:22 -0500 Subject: [PATCH 2357/3148] Bump govee_ble to 0.43.1 (#139862) Bump govee_ble to 0.43.0 --- homeassistant/components/govee_ble/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/govee_ble/manifest.json b/homeassistant/components/govee_ble/manifest.json index 1c61ae31010..b06dab243af 100644 --- a/homeassistant/components/govee_ble/manifest.json +++ b/homeassistant/components/govee_ble/manifest.json @@ -135,5 +135,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/govee_ble", "iot_class": "local_push", - "requirements": ["govee-ble==0.43.0"] + "requirements": ["govee-ble==0.43.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 47de76d6913..523ca5f81f1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1058,7 +1058,7 @@ goslide-api==0.7.0 gotailwind==0.3.0 # homeassistant.components.govee_ble -govee-ble==0.43.0 +govee-ble==0.43.1 # homeassistant.components.govee_light_local govee-local-api==2.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ef52bf7cc5e..81c92865ac7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -908,7 +908,7 @@ goslide-api==0.7.0 gotailwind==0.3.0 # homeassistant.components.govee_ble -govee-ble==0.43.0 +govee-ble==0.43.1 # homeassistant.components.govee_light_local govee-local-api==2.0.1 From 99b5adaef1b24ae2bd874d5bdd53d13ed6700763 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Fri, 7 Mar 2025 23:04:49 +0100 Subject: [PATCH 2358/3148] Cleanup add_to_hass method in Shelly tests (part 1) (#140075) --- tests/components/shelly/__init__.py | 12 ++++--- .../components/shelly/test_device_trigger.py | 5 ++- tests/components/shelly/test_init.py | 36 ++++--------------- 3 files changed, 15 insertions(+), 38 deletions(-) diff --git a/tests/components/shelly/__init__.py b/tests/components/shelly/__init__.py index 7a20560e25f..5cba8e5e3b8 100644 --- a/tests/components/shelly/__init__.py +++ b/tests/components/shelly/__init__.py @@ -40,13 +40,15 @@ async def init_integration( sleep_period=0, options: dict[str, Any] | None = None, skip_setup: bool = False, + data: dict[str, Any] | None = None, ) -> MockConfigEntry: """Set up the Shelly integration in Home Assistant.""" - data = { - CONF_HOST: "192.168.1.37", - CONF_SLEEP_PERIOD: sleep_period, - "model": model, - } + if data is None: + data = { + CONF_HOST: "192.168.1.37", + CONF_SLEEP_PERIOD: sleep_period, + "model": model, + } if gen is not None: data[CONF_GEN] = gen diff --git a/tests/components/shelly/test_device_trigger.py b/tests/components/shelly/test_device_trigger.py index fb68393304b..89045208d20 100644 --- a/tests/components/shelly/test_device_trigger.py +++ b/tests/components/shelly/test_device_trigger.py @@ -25,7 +25,7 @@ from homeassistant.setup import async_setup_component from . import init_integration -from tests.common import MockConfigEntry, async_get_device_automations +from tests.common import async_get_device_automations @pytest.mark.parametrize( @@ -162,8 +162,7 @@ async def test_get_triggers_for_invalid_device_id( ) -> None: """Test error raised for invalid shelly device_id.""" await init_integration(hass, 1) - config_entry = MockConfigEntry(domain=DOMAIN, data={}) - config_entry.add_to_hass(hass) + config_entry = await init_integration(hass, 1, data={}, skip_setup=True) invalid_device = device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, diff --git a/tests/components/shelly/test_init.py b/tests/components/shelly/test_init.py index f3ce807b655..036da1bfd64 100644 --- a/tests/components/shelly/test_init.py +++ b/tests/components/shelly/test_init.py @@ -27,16 +27,10 @@ from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.const import CONF_HOST, CONF_PORT, STATE_ON, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers import issue_registry as ir -from homeassistant.helpers.device_registry import ( - CONNECTION_NETWORK_MAC, - DeviceRegistry, - format_mac, -) +from homeassistant.helpers.device_registry import DeviceRegistry from homeassistant.setup import async_setup_component -from . import MOCK_MAC, init_integration, mutate_rpc_device_status - -from tests.common import MockConfigEntry +from . import init_integration, mutate_rpc_device_status async def test_custom_coap_port( @@ -121,12 +115,6 @@ async def test_shared_device_mac( caplog: pytest.LogCaptureFixture, ) -> None: """Test first time shared device with another domain.""" - config_entry = MockConfigEntry(domain="test", data={}, unique_id="some_id") - config_entry.add_to_hass(hass) - device_registry.async_get_or_create( - config_entry_id=config_entry.entry_id, - connections={(CONNECTION_NETWORK_MAC, format_mac(MOCK_MAC))}, - ) await init_integration(hass, gen, sleep_period=1000) assert "will resume when device is online" in caplog.text @@ -135,12 +123,7 @@ async def test_setup_entry_not_shelly( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: """Test not Shelly entry.""" - entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) - entry.add_to_hass(hass) - - assert await hass.config_entries.async_setup(entry.entry_id) is False - await hass.async_block_till_done() - + await init_integration(hass, 1, data={}) assert "probably comes from a custom integration" in caplog.text @@ -247,12 +230,7 @@ async def test_sleeping_block_device_online( caplog: pytest.LogCaptureFixture, ) -> None: """Test sleeping block device online.""" - config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id="shelly") - config_entry.add_to_hass(hass) - device_registry.async_get_or_create( - config_entry_id=config_entry.entry_id, - connections={(CONNECTION_NETWORK_MAC, format_mac(MOCK_MAC))}, - ) + await init_integration(hass, 1, data={}) monkeypatch.setitem( mock_block_device.settings, @@ -498,8 +476,7 @@ async def test_entry_missing_port(hass: HomeAssistant) -> None: "model": MODEL_PLUS_2PM, CONF_GEN: 2, } - entry = MockConfigEntry(domain=DOMAIN, data=data, unique_id=MOCK_MAC) - entry.add_to_hass(hass) + entry = await init_integration(hass, 2, data=data, skip_setup=True) with ( patch("homeassistant.components.shelly.RpcDevice.initialize"), patch( @@ -523,8 +500,7 @@ async def test_rpc_entry_custom_port(hass: HomeAssistant) -> None: CONF_GEN: 2, CONF_PORT: 8001, } - entry = MockConfigEntry(domain=DOMAIN, data=data, unique_id=MOCK_MAC) - entry.add_to_hass(hass) + entry = await init_integration(hass, 2, data=data, skip_setup=True) with ( patch("homeassistant.components.shelly.RpcDevice.initialize"), patch( From 293d455cba56bf92d40dbe9d7265db25048447e3 Mon Sep 17 00:00:00 2001 From: Petro31 <35082313+Petro31@users.noreply.github.com> Date: Fri, 7 Mar 2025 18:09:04 -0500 Subject: [PATCH 2359/3148] Add check for invalid options with specific platforms (#140082) --- homeassistant/components/template/config.py | 91 +++++++++++++-------- tests/components/template/test_config.py | 50 +++++++++++ 2 files changed, 107 insertions(+), 34 deletions(-) create mode 100644 tests/components/template/test_config.py diff --git a/homeassistant/components/template/config.py b/homeassistant/components/template/config.py index 9c92ed2b334..9963731c784 100644 --- a/homeassistant/components/template/config.py +++ b/homeassistant/components/template/config.py @@ -1,5 +1,6 @@ """Template config validator.""" +from collections.abc import Callable from contextlib import suppress import logging @@ -52,41 +53,63 @@ from .helpers import async_get_blueprints PACKAGE_MERGE_HINT = "list" + +def ensure_domains_do_not_have_trigger_or_action(*keys: str) -> Callable[[dict], dict]: + """Validate that config does not contain trigger and action.""" + domains = set(keys) + + def validate(obj: dict): + options = set(obj.keys()) + if found_domains := domains.intersection(options): + invalid = {CONF_TRIGGER, CONF_ACTION} + if found_invalid := invalid.intersection(set(obj.keys())): + raise vol.Invalid( + f"Unsupported option(s) found for domain {found_domains.pop()}, please remove ({', '.join(found_invalid)}) from your configuration", + ) + + return obj + + return validate + + CONFIG_SECTION_SCHEMA = vol.Schema( - { - vol.Optional(CONF_UNIQUE_ID): cv.string, - vol.Optional(CONF_TRIGGER): cv.TRIGGER_SCHEMA, - vol.Optional(CONF_CONDITION): cv.CONDITIONS_SCHEMA, - vol.Optional(CONF_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_VARIABLES): cv.SCRIPT_VARIABLES_SCHEMA, - vol.Optional(NUMBER_DOMAIN): vol.All( - cv.ensure_list, [number_platform.NUMBER_SCHEMA] - ), - vol.Optional(SENSOR_DOMAIN): vol.All( - cv.ensure_list, [sensor_platform.SENSOR_SCHEMA] - ), - vol.Optional(CONF_SENSORS): cv.schema_with_slug_keys( - sensor_platform.LEGACY_SENSOR_SCHEMA - ), - vol.Optional(BINARY_SENSOR_DOMAIN): vol.All( - cv.ensure_list, [binary_sensor_platform.BINARY_SENSOR_SCHEMA] - ), - vol.Optional(CONF_BINARY_SENSORS): cv.schema_with_slug_keys( - binary_sensor_platform.LEGACY_BINARY_SENSOR_SCHEMA - ), - vol.Optional(SELECT_DOMAIN): vol.All( - cv.ensure_list, [select_platform.SELECT_SCHEMA] - ), - vol.Optional(BUTTON_DOMAIN): vol.All( - cv.ensure_list, [button_platform.BUTTON_SCHEMA] - ), - vol.Optional(IMAGE_DOMAIN): vol.All( - cv.ensure_list, [image_platform.IMAGE_SCHEMA] - ), - vol.Optional(WEATHER_DOMAIN): vol.All( - cv.ensure_list, [weather_platform.WEATHER_SCHEMA] - ), - }, + vol.All( + { + vol.Optional(CONF_UNIQUE_ID): cv.string, + vol.Optional(CONF_TRIGGER): cv.TRIGGER_SCHEMA, + vol.Optional(CONF_CONDITION): cv.CONDITIONS_SCHEMA, + vol.Optional(CONF_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_VARIABLES): cv.SCRIPT_VARIABLES_SCHEMA, + vol.Optional(NUMBER_DOMAIN): vol.All( + cv.ensure_list, [number_platform.NUMBER_SCHEMA] + ), + vol.Optional(SENSOR_DOMAIN): vol.All( + cv.ensure_list, [sensor_platform.SENSOR_SCHEMA] + ), + vol.Optional(CONF_SENSORS): cv.schema_with_slug_keys( + sensor_platform.LEGACY_SENSOR_SCHEMA + ), + vol.Optional(BINARY_SENSOR_DOMAIN): vol.All( + cv.ensure_list, [binary_sensor_platform.BINARY_SENSOR_SCHEMA] + ), + vol.Optional(CONF_BINARY_SENSORS): cv.schema_with_slug_keys( + binary_sensor_platform.LEGACY_BINARY_SENSOR_SCHEMA + ), + vol.Optional(SELECT_DOMAIN): vol.All( + cv.ensure_list, [select_platform.SELECT_SCHEMA] + ), + vol.Optional(BUTTON_DOMAIN): vol.All( + cv.ensure_list, [button_platform.BUTTON_SCHEMA] + ), + vol.Optional(IMAGE_DOMAIN): vol.All( + cv.ensure_list, [image_platform.IMAGE_SCHEMA] + ), + vol.Optional(WEATHER_DOMAIN): vol.All( + cv.ensure_list, [weather_platform.WEATHER_SCHEMA] + ), + }, + ensure_domains_do_not_have_trigger_or_action(BUTTON_DOMAIN), + ) ) TEMPLATE_BLUEPRINT_INSTANCE_SCHEMA = vol.Schema( diff --git a/tests/components/template/test_config.py b/tests/components/template/test_config.py new file mode 100644 index 00000000000..b14ff0efa5a --- /dev/null +++ b/tests/components/template/test_config.py @@ -0,0 +1,50 @@ +"""Test Template config.""" + +from __future__ import annotations + +import pytest +import voluptuous as vol + +from homeassistant.components.template.config import CONFIG_SECTION_SCHEMA +from homeassistant.core import HomeAssistant + + +@pytest.mark.parametrize( + "config", + [ + { + "trigger": {"trigger": "event", "event_type": "my_event"}, + "button": { + "press": { + "service": "test.automation", + "data_template": {"caller": "{{ this.entity_id }}"}, + }, + "device_class": "restart", + "unique_id": "test", + "name": "test", + "icon": "mdi:test", + }, + }, + { + "trigger": {"trigger": "event", "event_type": "my_event"}, + "action": { + "service": "test.automation", + "data_template": {"caller": "{{ this.entity_id }}"}, + }, + "button": { + "press": { + "service": "test.automation", + "data_template": {"caller": "{{ this.entity_id }}"}, + }, + "device_class": "restart", + "unique_id": "test", + "name": "test", + "icon": "mdi:test", + }, + }, + ], +) +async def test_invalid_schema(hass: HomeAssistant, config: dict) -> None: + """Test invalid config schemas.""" + with pytest.raises(vol.Invalid): + CONFIG_SECTION_SCHEMA(config) From b7094c12f7dab7a32c3bcc0e7a5f6728c8de84c0 Mon Sep 17 00:00:00 2001 From: David Bonnes Date: Fri, 7 Mar 2025 23:17:29 +0000 Subject: [PATCH 2360/3148] Update evohome-async to 1.0.3 (#140083) bump client to 1.0.3 --- homeassistant/components/evohome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/evohome/manifest.json b/homeassistant/components/evohome/manifest.json index 823ad7be5df..700872ef92b 100644 --- a/homeassistant/components/evohome/manifest.json +++ b/homeassistant/components/evohome/manifest.json @@ -6,5 +6,5 @@ "iot_class": "cloud_polling", "loggers": ["evohome", "evohomeasync", "evohomeasync2"], "quality_scale": "legacy", - "requirements": ["evohome-async==1.0.2"] + "requirements": ["evohome-async==1.0.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index 523ca5f81f1..ad1b20370bb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -899,7 +899,7 @@ eufylife-ble-client==0.1.8 # evdev==1.6.1 # homeassistant.components.evohome -evohome-async==1.0.2 +evohome-async==1.0.3 # homeassistant.components.bryant_evolution evolutionhttp==0.0.18 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 81c92865ac7..2ee8cf43829 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -765,7 +765,7 @@ eternalegypt==0.0.16 eufylife-ble-client==0.1.8 # homeassistant.components.evohome -evohome-async==1.0.2 +evohome-async==1.0.3 # homeassistant.components.bryant_evolution evolutionhttp==0.0.18 From d4f205c3669f7a96741331f12ae51760ec3471fe Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 8 Mar 2025 02:36:17 +0100 Subject: [PATCH 2361/3148] Add template function: shuffle (#140077) --- homeassistant/helpers/template.py | 28 ++++++++++++++++- tests/helpers/test_template.py | 52 +++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 1 deletion(-) diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 7dc3097cdb3..ab115203e66 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -6,7 +6,7 @@ from ast import literal_eval import asyncio import base64 import collections.abc -from collections.abc import Callable, Generator, Iterable +from collections.abc import Callable, Generator, Iterable, MutableSequence from contextlib import AbstractContextManager from contextvars import ContextVar from copy import deepcopy @@ -2736,6 +2736,30 @@ def iif( return if_false +def shuffle(*args: Any, seed: Any = None) -> MutableSequence[Any]: + """Shuffle a list, either with a seed or without.""" + if not args: + raise TypeError("shuffle expected at least 1 argument, got 0") + + # If first argument is iterable and more than 1 argument provided + # but not a named seed, then use 2nd argument as seed. + if isinstance(args[0], Iterable): + items = list(args[0]) + if len(args) > 1 and seed is None: + seed = args[1] + elif len(args) == 1: + raise TypeError(f"'{type(args[0]).__name__}' object is not iterable") + else: + items = list(args) + + if seed: + r = random.Random(seed) + r.shuffle(items) + else: + random.shuffle(items) + return items + + class TemplateContextManager(AbstractContextManager): """Context manager to store template being parsed or rendered in a ContextVar.""" @@ -2936,6 +2960,7 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): self.filters["bool"] = forgiving_boolean self.filters["version"] = version self.filters["contains"] = contains + self.filters["shuffle"] = shuffle self.globals["log"] = logarithm self.globals["sin"] = sine self.globals["cos"] = cosine @@ -2973,6 +2998,7 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): self.globals["bool"] = forgiving_boolean self.globals["version"] = version self.globals["zip"] = zip + self.globals["shuffle"] = shuffle self.tests["is_number"] = is_number self.tests["list"] = _is_list self.tests["set"] = _is_set diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index 8c890bfd53d..28391d97a3c 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -15,6 +15,7 @@ from unittest.mock import patch from freezegun import freeze_time import orjson import pytest +from pytest_unordered import unordered from syrupy import SnapshotAssertion import voluptuous as vol @@ -6672,3 +6673,54 @@ async def test_merge_response_not_mutate_original_object( tpl = template.Template(_template, hass) assert tpl.async_render() + + +def test_shuffle(hass: HomeAssistant) -> None: + """Test the shuffle function and filter.""" + assert list( + template.Template("{{ [1, 2, 3] | shuffle }}", hass).async_render() + ) == unordered([1, 2, 3]) + + assert list( + template.Template("{{ shuffle([1, 2, 3]) }}", hass).async_render() + ) == unordered([1, 2, 3]) + + assert list( + template.Template("{{ shuffle(1, 2, 3) }}", hass).async_render() + ) == unordered([1, 2, 3]) + + assert list(template.Template("{{ shuffle([]) }}", hass).async_render()) == [] + + assert list(template.Template("{{ [] | shuffle }}", hass).async_render()) == [] + + # Testing using seed + assert list( + template.Template("{{ shuffle([1, 2, 3], 'seed') }}", hass).async_render() + ) == [2, 3, 1] + + assert list( + template.Template( + "{{ shuffle([1, 2, 3], seed='seed') }}", + hass, + ).async_render() + ) == [2, 3, 1] + + assert list( + template.Template( + "{{ [1, 2, 3] | shuffle('seed') }}", + hass, + ).async_render() + ) == [2, 3, 1] + + assert list( + template.Template( + "{{ [1, 2, 3] | shuffle(seed='seed') }}", + hass, + ).async_render() + ) == [2, 3, 1] + + with pytest.raises(TemplateError): + template.Template("{{ 1 | shuffle }}", hass).async_render() + + with pytest.raises(TemplateError): + template.Template("{{ shuffle() }}", hass).async_render() From 02e90024666362ad3b869c71205e17dff93f282a Mon Sep 17 00:00:00 2001 From: Greg Dowling Date: Sat, 8 Mar 2025 01:59:04 +0000 Subject: [PATCH 2362/3148] Set media type correctly in the roon integration- so the media card correctly displays the artist. (#139871) Set media type correctly - so media card display works properly. --- homeassistant/components/roon/media_player.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/homeassistant/components/roon/media_player.py b/homeassistant/components/roon/media_player.py index 0460e2cfc6e..4a87601a24f 100644 --- a/homeassistant/components/roon/media_player.py +++ b/homeassistant/components/roon/media_player.py @@ -329,6 +329,11 @@ class RoonDevice(MediaPlayerEntity): """Album artist of current playing media (Music track only).""" return self.media_artist + @property + def media_content_type(self) -> str: + """Return the media type.""" + return MediaType.MUSIC + @property def supports_standby(self): """Return power state of source controls.""" From e2c050ed4033433bd6529acf7ad4e5f455865730 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 7 Mar 2025 16:14:22 -1000 Subject: [PATCH 2363/3148] Cache sensor precision calculation (#140019) --- homeassistant/components/sensor/__init__.py | 17 +++--------- homeassistant/util/unit_conversion.py | 10 +++++++ tests/util/test_unit_conversion.py | 29 ++++++++++++++++++--- 3 files changed, 39 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index 89f39d4fb8c..e3ee566a855 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -675,22 +675,13 @@ class SensorEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): ): # Deduce the precision by finding the decimal point, if any value_s = str(value) - precision = ( - len(value_s) - value_s.index(".") - 1 if "." in value_s else 0 - ) - # Scale the precision when converting to a larger unit # For example 1.1 Wh should be rendered as 0.0011 kWh, not 0.0 kWh - ratio_log = max( - 0, - log10( - converter.get_unit_ratio( - native_unit_of_measurement, unit_of_measurement - ) - ), + precision = ( + len(value_s) - value_s.index(".") - 1 if "." in value_s else 0 + ) + converter.get_unit_floored_log_ratio( + native_unit_of_measurement, unit_of_measurement ) - precision = precision + floor(ratio_log) - value = f"{converted_numerical_value:z.{precision}f}" else: value = converted_numerical_value diff --git a/homeassistant/util/unit_conversion.py b/homeassistant/util/unit_conversion.py index 67258c9cd09..f2619c5dd61 100644 --- a/homeassistant/util/unit_conversion.py +++ b/homeassistant/util/unit_conversion.py @@ -4,6 +4,7 @@ from __future__ import annotations from collections.abc import Callable from functools import lru_cache +from math import floor, log10 from homeassistant.const import ( CONCENTRATION_PARTS_PER_BILLION, @@ -144,6 +145,15 @@ class BaseUnitConverter: from_ratio, to_ratio = cls._get_from_to_ratio(from_unit, to_unit) return from_ratio / to_ratio + @classmethod + @lru_cache + def get_unit_floored_log_ratio( + cls, from_unit: str | None, to_unit: str | None + ) -> float: + """Get floored base10 log ratio between units of measurement.""" + from_ratio, to_ratio = cls._get_from_to_ratio(from_unit, to_unit) + return floor(max(0, log10(from_ratio / to_ratio))) + @classmethod @lru_cache def _are_unit_inverses(cls, from_unit: str | None, to_unit: str | None) -> bool: diff --git a/tests/util/test_unit_conversion.py b/tests/util/test_unit_conversion.py index aeea4ad9a5a..3f55ceef242 100644 --- a/tests/util/test_unit_conversion.py +++ b/tests/util/test_unit_conversion.py @@ -902,8 +902,8 @@ def test_convert_nonnumeric_value( ("converter", "from_unit", "to_unit", "expected"), [ # Process all items in _GET_UNIT_RATIO - (converter, item[0], item[1], item[2]) - for converter, item in _GET_UNIT_RATIO.items() + (converter, from_unit, to_unit, expected) + for converter, (from_unit, to_unit, expected) in _GET_UNIT_RATIO.items() ], ) def test_get_unit_ratio( @@ -915,13 +915,34 @@ def test_get_unit_ratio( assert converter.get_unit_ratio(to_unit, from_unit) == pytest.approx(1 / ratio) +@pytest.mark.parametrize( + ("converter", "from_unit", "to_unit", "expected"), + [ + # Process all items in _GET_UNIT_RATIO + (converter, from_unit, to_unit, expected) + for converter, (from_unit, to_unit, expected) in _GET_UNIT_RATIO.items() + ], +) +def get_unit_floored_log_ratio( + converter: type[BaseUnitConverter], from_unit: str, to_unit: str, expected: float +) -> None: + """Test floored log unit ratio. + + Should not use pytest.approx since we are checking these + values are exact. + """ + ratio = converter.get_unit_floored_log_ratio(from_unit, to_unit) + assert ratio == expected + assert converter.get_unit_floored_log_ratio(to_unit, from_unit) == 1 / ratio + + @pytest.mark.parametrize( ("converter", "value", "from_unit", "expected", "to_unit"), [ # Process all items in _CONVERTED_VALUE - (converter, list_item[0], list_item[1], list_item[2], list_item[3]) + (converter, value, from_unit, expected, to_unit) for converter, item in _CONVERTED_VALUE.items() - for list_item in item + for value, from_unit, expected, to_unit in item ], ) def test_unit_conversion( From deea19db51d0a2e5a4c4ad37bf8220b92e0fe89d Mon Sep 17 00:00:00 2001 From: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> Date: Fri, 7 Mar 2025 22:31:32 -0600 Subject: [PATCH 2364/3148] Fix HEOS discovery error when previously ignored (#140091) Abort ignored discovery --- homeassistant/components/heos/config_flow.py | 13 ++++++++--- tests/components/heos/test_config_flow.py | 23 +++++++++++++++++++- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/heos/config_flow.py b/homeassistant/components/heos/config_flow.py index a2f9671c94b..f1cd11f0914 100644 --- a/homeassistant/components/heos/config_flow.py +++ b/homeassistant/components/heos/config_flow.py @@ -14,7 +14,12 @@ from pyheos import ( ) import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow +from homeassistant.config_entries import ( + SOURCE_IGNORE, + ConfigFlow, + ConfigFlowResult, + OptionsFlow, +) from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import callback from homeassistant.helpers import selector @@ -141,8 +146,10 @@ class HeosFlowHandler(ConfigFlow, domain=DOMAIN): hostname = urlparse(discovery_info.ssdp_location).hostname assert hostname is not None - # Abort early when discovered host is part of the current system - if entry and hostname in _get_current_hosts(entry): + # Abort early when discovery is ignored or host is part of the current system + if entry and ( + entry.source == SOURCE_IGNORE or hostname in _get_current_hosts(entry) + ): return self.async_abort(reason="single_instance_allowed") # Connect to discovered host and get system information diff --git a/tests/components/heos/test_config_flow.py b/tests/components/heos/test_config_flow.py index 396c3743663..69df3734690 100644 --- a/tests/components/heos/test_config_flow.py +++ b/tests/components/heos/test_config_flow.py @@ -14,7 +14,12 @@ from pyheos import ( import pytest from homeassistant.components.heos.const import DOMAIN -from homeassistant.config_entries import SOURCE_SSDP, SOURCE_USER, ConfigEntryState +from homeassistant.config_entries import ( + SOURCE_IGNORE, + SOURCE_SSDP, + SOURCE_USER, + ConfigEntryState, +) from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -160,6 +165,22 @@ async def test_discovery_aborts_same_system( assert config_entry.data[CONF_HOST] == "127.0.0.1" +async def test_discovery_ignored_aborts( + hass: HomeAssistant, + discovery_data: SsdpServiceInfo, +) -> None: + """Test discovery aborts when ignored.""" + MockConfigEntry(domain=DOMAIN, unique_id=DOMAIN, source=SOURCE_IGNORE).add_to_hass( + hass + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_SSDP}, data=discovery_data + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "single_instance_allowed" + + async def test_discovery_fails_to_connect_aborts( hass: HomeAssistant, discovery_data: SsdpServiceInfo, controller: MockHeos ) -> None: From 3a2b446e332e2628850e0e9d2a25e50c0f0f34dd Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Sat, 8 Mar 2025 06:48:09 +0100 Subject: [PATCH 2365/3148] Update pyfronius to 0.7.7 and adjust quality scale to platinum (#140084) --- homeassistant/components/fronius/manifest.json | 4 ++-- homeassistant/components/fronius/quality_scale.yaml | 5 +---- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 5 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/fronius/manifest.json b/homeassistant/components/fronius/manifest.json index 94d0f90b0bd..661d808ad23 100644 --- a/homeassistant/components/fronius/manifest.json +++ b/homeassistant/components/fronius/manifest.json @@ -11,6 +11,6 @@ "documentation": "https://www.home-assistant.io/integrations/fronius", "iot_class": "local_polling", "loggers": ["pyfronius"], - "quality_scale": "gold", - "requirements": ["PyFronius==0.7.3"] + "quality_scale": "platinum", + "requirements": ["PyFronius==0.7.7"] } diff --git a/homeassistant/components/fronius/quality_scale.yaml b/homeassistant/components/fronius/quality_scale.yaml index 2c4b892475b..522b8ab571f 100644 --- a/homeassistant/components/fronius/quality_scale.yaml +++ b/homeassistant/components/fronius/quality_scale.yaml @@ -83,7 +83,4 @@ rules: # Platinum async-dependency: done inject-websession: done - strict-typing: - status: todo - comment: | - The pyfronius library isn't strictly typed and doesn't export type information. + strict-typing: done diff --git a/requirements_all.txt b/requirements_all.txt index ad1b20370bb..73e0ea497fc 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -54,7 +54,7 @@ PyFlick==1.1.3 PyFlume==0.6.5 # homeassistant.components.fronius -PyFronius==0.7.3 +PyFronius==0.7.7 # homeassistant.components.pyload PyLoadAPI==1.4.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2ee8cf43829..49a783a5c69 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -51,7 +51,7 @@ PyFlick==1.1.3 PyFlume==0.6.5 # homeassistant.components.fronius -PyFronius==0.7.3 +PyFronius==0.7.7 # homeassistant.components.pyload PyLoadAPI==1.4.2 From f399ffae72d150008e53630a6ea18a4304b68b30 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 8 Mar 2025 10:57:25 +0100 Subject: [PATCH 2366/3148] Map prewash job state in SmartThings (#140097) --- homeassistant/components/smartthings/sensor.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index 9ef8cb55c92..da0e752fb5b 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -57,6 +57,7 @@ JOB_STATE_MAP = { "freezeProtection": "freeze_protection", "preDrain": "pre_drain", "preWash": "pre_wash", + "prewash": "pre_wash", "wrinklePrevent": "wrinkle_prevent", "unknown": None, } From ea33925afcf1dbf353df303c8690b2c703438fc3 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 8 Mar 2025 11:22:09 +0100 Subject: [PATCH 2367/3148] Check support for thermostat operating state in SmartThings (#140103) --- .../components/smartthings/climate.py | 2 + tests/components/smartthings/conftest.py | 1 + .../bosch_radiator_thermostat_ii.json | 89 +++++++++++++++ .../devices/bosch_radiator_thermostat_ii.json | 102 ++++++++++++++++++ .../smartthings/snapshots/test_climate.ambr | 63 +++++++++++ .../smartthings/snapshots/test_init.ambr | 33 ++++++ .../smartthings/snapshots/test_sensor.ambr | 101 +++++++++++++++++ 7 files changed, 391 insertions(+) create mode 100644 tests/components/smartthings/fixtures/device_status/bosch_radiator_thermostat_ii.json create mode 100644 tests/components/smartthings/fixtures/devices/bosch_radiator_thermostat_ii.json diff --git a/homeassistant/components/smartthings/climate.py b/homeassistant/components/smartthings/climate.py index cafd831c5bd..14e26e23dc1 100644 --- a/homeassistant/components/smartthings/climate.py +++ b/homeassistant/components/smartthings/climate.py @@ -254,6 +254,8 @@ class SmartThingsThermostat(SmartThingsEntity, ClimateEntity): @property def hvac_action(self) -> HVACAction | None: """Return the current running hvac operation if supported.""" + if not self.supports_capability(Capability.THERMOSTAT_OPERATING_STATE): + return None return OPERATING_STATE_TO_ACTION.get( self.get_attribute_value( Capability.THERMOSTAT_OPERATING_STATE, diff --git a/tests/components/smartthings/conftest.py b/tests/components/smartthings/conftest.py index 730f683fa14..a659e69a2cc 100644 --- a/tests/components/smartthings/conftest.py +++ b/tests/components/smartthings/conftest.py @@ -122,6 +122,7 @@ def mock_smartthings() -> Generator[AsyncMock]: "fake_fan", "generic_fan_3_speed", "heatit_ztrm3_thermostat", + "bosch_radiator_thermostat_ii", ] ) def device_fixture( diff --git a/tests/components/smartthings/fixtures/device_status/bosch_radiator_thermostat_ii.json b/tests/components/smartthings/fixtures/device_status/bosch_radiator_thermostat_ii.json new file mode 100644 index 00000000000..6248eb05e93 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/bosch_radiator_thermostat_ii.json @@ -0,0 +1,89 @@ +{ + "components": { + "main": { + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": 23.9, + "unit": "C", + "timestamp": "2025-03-07T19:55:13.328Z" + } + }, + "thermostatHeatingSetpoint": { + "heatingSetpoint": { + "value": 22.0, + "unit": "C", + "timestamp": "2025-03-05T03:05:26.510Z" + }, + "heatingSetpointRange": { + "value": { + "minimum": 5.0, + "maximum": 40.0, + "step": 0.1 + }, + "unit": "C", + "timestamp": "2025-03-05T03:05:26.510Z" + } + }, + "refresh": {}, + "thermostatMode": { + "thermostatMode": { + "value": "heat", + "data": { + "supportedThermostatModes": ["off", "heat"] + }, + "timestamp": "2025-03-05T03:05:26.489Z" + }, + "supportedThermostatModes": { + "value": ["off", "heat"], + "timestamp": "2025-03-05T03:05:26.509Z" + } + }, + "battery": { + "quantity": { + "value": null + }, + "battery": { + "value": 94, + "unit": "%", + "timestamp": "2025-03-07T20:47:27.362Z" + }, + "type": { + "value": null + } + }, + "firmwareUpdate": { + "lastUpdateStatusReason": { + "value": null + }, + "availableVersion": { + "value": "2.00.09 (20009)", + "timestamp": "2024-11-29T19:55:02.005Z" + }, + "lastUpdateStatus": { + "value": null + }, + "supportedCommands": { + "value": null + }, + "state": { + "value": "normalOperation", + "timestamp": "2024-11-29T19:55:02.009Z" + }, + "updateAvailable": { + "value": false, + "timestamp": "2024-11-29T19:55:02.004Z" + }, + "currentVersion": { + "value": "2.00.09 (20009)", + "timestamp": "2024-11-29T19:55:02.037Z" + }, + "lastUpdateTime": { + "value": null + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/devices/bosch_radiator_thermostat_ii.json b/tests/components/smartthings/fixtures/devices/bosch_radiator_thermostat_ii.json new file mode 100644 index 00000000000..7a2e2d338cd --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/bosch_radiator_thermostat_ii.json @@ -0,0 +1,102 @@ +{ + "items": [ + { + "deviceId": "286ba274-4093-4bcb-849c-a1a3efe7b1e5", + "name": "thermostat", + "label": "Radiator Thermostat II [+M] Wohnzimmer", + "manufacturerName": "SmartThingsCommunity", + "presentationId": "2a1c9915-f61b-3f3a-a02b-703b8cccf3d6", + "deviceManufacturerCode": "BOSCH", + "locationId": "0b6618a6-c3ab-4b6e-968d-59cc8c2761bc", + "ownerId": "8a20b799-9d87-ecdc-39de-c93c6e4d3ea1", + "roomId": "11374ab5-9b4e-416b-91d1-745bbf9b6db4", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "thermostatMode", + "version": 1 + }, + { + "id": "thermostatHeatingSetpoint", + "version": 1 + }, + { + "id": "battery", + "version": 1 + }, + { + "id": "firmwareUpdate", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + } + ], + "categories": [ + { + "name": "Thermostat", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2024-11-29T19:55:00.910Z", + "parentDeviceId": "61bd280e-71c4-44fb-9b6e-53fdf14718a2", + "profile": { + "id": "4da5d086-111e-3084-a039-616974326833" + }, + "matter": { + "driverId": "5f3c42eb-5704-4c95-9705-c51c1a6764bf", + "hubId": "61bd280e-71c4-44fb-9b6e-53fdf14718a2", + "provisioningState": "PROVISIONED", + "networkId": "8EF2CF7A212285B2-46C6B9F266A4521A", + "executingLocally": true, + "uniqueId": "8475B3FEFF6748D4", + "vendorId": 4617, + "productId": 12306, + "serialNumber": "D44867FFFEB37584", + "listeningType": "SLEEPY", + "supportedNetworkInterfaces": ["THREAD"], + "version": { + "hardware": 18, + "hardwareLabel": "1.2.0", + "software": 20009, + "softwareLabel": "2.00.09" + }, + "endpoints": [ + { + "endpointId": 0, + "deviceTypes": [ + { + "deviceTypeId": 22 + } + ] + }, + { + "endpointId": 1, + "deviceTypes": [ + { + "deviceTypeId": 769 + } + ] + } + ], + "syncDrivers": true + }, + "type": "MATTER", + "restrictionTier": 0, + "allowed": null, + "executionContext": "LOCAL", + "relationships": [] + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/snapshots/test_climate.ambr b/tests/components/smartthings/snapshots/test_climate.ambr index c85c7af19a6..4d3fd15aeb9 100644 --- a/tests/components/smartthings/snapshots/test_climate.ambr +++ b/tests/components/smartthings/snapshots/test_climate.ambr @@ -1,4 +1,67 @@ # serializer version: 1 +# name: test_all_entities[bosch_radiator_thermostat_ii][climate.radiator_thermostat_ii_m_wohnzimmer-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + ]), + 'max_temp': 35, + 'min_temp': 7, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.radiator_thermostat_ii_m_wohnzimmer', + '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': None, + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '286ba274-4093-4bcb-849c-a1a3efe7b1e5', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[bosch_radiator_thermostat_ii][climate.radiator_thermostat_ii_m_wohnzimmer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 23.9, + 'friendly_name': 'Radiator Thermostat II [+M] Wohnzimmer', + 'hvac_modes': list([ + , + ]), + 'max_temp': 35, + 'min_temp': 7, + 'supported_features': , + 'target_temp_high': None, + 'target_temp_low': None, + 'temperature': 22.0, + }), + 'context': , + 'entity_id': 'climate.radiator_thermostat_ii_m_wohnzimmer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }) +# --- # name: test_all_entities[da_ac_rac_000001][climate.ac_office_granit-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/smartthings/snapshots/test_init.ambr b/tests/components/smartthings/snapshots/test_init.ambr index 5beaf907b70..acee145955c 100644 --- a/tests/components/smartthings/snapshots/test_init.ambr +++ b/tests/components/smartthings/snapshots/test_init.ambr @@ -68,6 +68,39 @@ 'via_device_id': None, }) # --- +# name: test_devices[bosch_radiator_thermostat_ii] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '286ba274-4093-4bcb-849c-a1a3efe7b1e5', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': None, + 'model': None, + 'model_id': None, + 'name': 'Radiator Thermostat II [+M] Wohnzimmer', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- # name: test_devices[c2c_arlo_pro_3_switch] DeviceRegistryEntrySnapshot({ 'area_id': 'theater', diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index 017689f13fd..cb282e24b27 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -258,6 +258,107 @@ 'state': '938.3', }) # --- +# name: test_all_entities[bosch_radiator_thermostat_ii][sensor.radiator_thermostat_ii_m_wohnzimmer_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.radiator_thermostat_ii_m_wohnzimmer_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': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '286ba274-4093-4bcb-849c-a1a3efe7b1e5.battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[bosch_radiator_thermostat_ii][sensor.radiator_thermostat_ii_m_wohnzimmer_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Radiator Thermostat II [+M] Wohnzimmer Battery', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.radiator_thermostat_ii_m_wohnzimmer_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '94', + }) +# --- +# name: test_all_entities[bosch_radiator_thermostat_ii][sensor.radiator_thermostat_ii_m_wohnzimmer_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.radiator_thermostat_ii_m_wohnzimmer_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': 'Temperature', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '286ba274-4093-4bcb-849c-a1a3efe7b1e5.temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[bosch_radiator_thermostat_ii][sensor.radiator_thermostat_ii_m_wohnzimmer_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Radiator Thermostat II [+M] Wohnzimmer Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.radiator_thermostat_ii_m_wohnzimmer_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '23.9', + }) +# --- # name: test_all_entities[c2c_arlo_pro_3_switch][sensor.2nd_floor_hallway_alarm-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From f209d75f2c0ef4ed35a6b016164c7c0ab04d5e5f Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 8 Mar 2025 11:27:26 +0100 Subject: [PATCH 2368/3148] Support null supported Thermostat modes in SmartThings (#140101) --- .../components/smartthings/climate.py | 10 +- tests/components/smartthings/conftest.py | 1 + .../device_status/generic_ef00_v1.json | 76 +++++++++ .../fixtures/devices/generic_ef00_v1.json | 95 +++++++++++ .../smartthings/snapshots/test_climate.ambr | 61 +++++++ .../smartthings/snapshots/test_init.ambr | 33 ++++ .../smartthings/snapshots/test_sensor.ambr | 154 ++++++++++++++++++ .../smartthings/snapshots/test_switch.ambr | 47 ++++++ 8 files changed, 474 insertions(+), 3 deletions(-) create mode 100644 tests/components/smartthings/fixtures/device_status/generic_ef00_v1.json create mode 100644 tests/components/smartthings/fixtures/devices/generic_ef00_v1.json diff --git a/homeassistant/components/smartthings/climate.py b/homeassistant/components/smartthings/climate.py index 14e26e23dc1..650b0c5540a 100644 --- a/homeassistant/components/smartthings/climate.py +++ b/homeassistant/components/smartthings/climate.py @@ -275,11 +275,15 @@ class SmartThingsThermostat(SmartThingsEntity, ClimateEntity): @property def hvac_modes(self) -> list[HVACMode]: """Return the list of available operation modes.""" - return [ - state - for mode in self.get_attribute_value( + if ( + supported_thermostat_modes := self.get_attribute_value( Capability.THERMOSTAT_MODE, Attribute.SUPPORTED_THERMOSTAT_MODES ) + ) is None: + return [] + return [ + state + for mode in supported_thermostat_modes if (state := AC_MODE_TO_STATE.get(mode)) is not None ] diff --git a/tests/components/smartthings/conftest.py b/tests/components/smartthings/conftest.py index a659e69a2cc..d3b91d058a9 100644 --- a/tests/components/smartthings/conftest.py +++ b/tests/components/smartthings/conftest.py @@ -122,6 +122,7 @@ def mock_smartthings() -> Generator[AsyncMock]: "fake_fan", "generic_fan_3_speed", "heatit_ztrm3_thermostat", + "generic_ef00_v1", "bosch_radiator_thermostat_ii", ] ) diff --git a/tests/components/smartthings/fixtures/device_status/generic_ef00_v1.json b/tests/components/smartthings/fixtures/device_status/generic_ef00_v1.json new file mode 100644 index 00000000000..cbfdf0d9092 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/generic_ef00_v1.json @@ -0,0 +1,76 @@ +{ + "components": { + "main02": { + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": 200.0, + "unit": "C", + "timestamp": "2024-12-02T20:18:52.095Z" + } + } + }, + "main": { + "thermostatOperatingState": { + "thermostatOperatingState": { + "value": null + } + }, + "signalStrength": { + "rssi": { + "value": -84, + "unit": "dBm", + "timestamp": "2025-03-07T20:53:55.346Z" + }, + "lqi": { + "value": 255, + "timestamp": "2025-03-07T20:53:55.387Z" + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": 21.0, + "unit": "C", + "timestamp": "2025-03-07T16:58:23.773Z" + } + }, + "thermostatHeatingSetpoint": { + "heatingSetpoint": { + "value": 23.0, + "unit": "C", + "timestamp": "2025-02-10T17:48:38.299Z" + }, + "heatingSetpointRange": { + "value": null + } + }, + "refresh": {}, + "valleyboard16460.debug": { + "value": { + "value": "\n \n \n \n \n \n \n \n
Actual_TZE200_rxntag7i
Expected_TZE200_4hbx5cvx
Profilenormal-thermostat-v3
ModeSimilarity
PreferencesModified
Exposes EF00Yes
Default DPNo
", + "timestamp": "2025-03-05T03:04:54.025Z" + } + }, + "thermostatMode": { + "thermostatMode": { + "value": "heat", + "data": {}, + "timestamp": "2024-12-30T08:22:19.273Z" + }, + "supportedThermostatModes": { + "value": null + } + }, + "switch": { + "switch": { + "value": null + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/devices/generic_ef00_v1.json b/tests/components/smartthings/fixtures/devices/generic_ef00_v1.json new file mode 100644 index 00000000000..96937769b41 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/generic_ef00_v1.json @@ -0,0 +1,95 @@ +{ + "items": [ + { + "deviceId": "656569c2-7976-4232-a789-34b4d1176c3a", + "name": "generic-ef00-v1", + "label": "Thermostat K\u00fcche", + "manufacturerName": "SmartThingsCommunity", + "presentationId": "be641577-f796-315b-af6f-b3ad14dd7a58", + "deviceManufacturerCode": "_TZE200_rxntag7i", + "locationId": "0b6618a6-c3ab-4b6e-968d-59cc8c2761bc", + "ownerId": "8a20b799-9d87-ecdc-39de-c93c6e4d3ea1", + "roomId": "eeb2f9d2-19cc-4dad-9f23-28ec807de97e", + "components": [ + { + "id": "main", + "label": "Thermostat", + "capabilities": [ + { + "id": "switch", + "version": 1 + }, + { + "id": "thermostatMode", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "thermostatHeatingSetpoint", + "version": 1 + }, + { + "id": "thermostatOperatingState", + "version": 1 + }, + { + "id": "signalStrength", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "valleyboard16460.debug", + "version": 1 + } + ], + "categories": [ + { + "name": "Thermostat", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "main02", + "label": "Floor", + "capabilities": [ + { + "id": "temperatureMeasurement", + "version": 1 + } + ], + "categories": [ + { + "name": "Thermostat", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2024-12-02T15:58:01.598Z", + "profile": { + "id": "3ad2e1e3-8867-332c-85b5-b291602c324f" + }, + "zigbee": { + "eui": "A4C1388B31017B5F", + "networkId": "162F", + "driverId": "585328e6-ac85-4ac5-bce4-286efd0ab980", + "executingLocally": true, + "hubId": "61bd280e-71c4-44fb-9b6e-53fdf14718a2", + "provisioningState": "DRIVER_SWITCH" + }, + "type": "ZIGBEE", + "restrictionTier": 0, + "allowed": null, + "executionContext": "LOCAL", + "relationships": [] + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/snapshots/test_climate.ambr b/tests/components/smartthings/snapshots/test_climate.ambr index 4d3fd15aeb9..6b512f93d39 100644 --- a/tests/components/smartthings/snapshots/test_climate.ambr +++ b/tests/components/smartthings/snapshots/test_climate.ambr @@ -432,6 +432,67 @@ 'state': 'heat', }) # --- +# name: test_all_entities[generic_ef00_v1][climate.thermostat_kuche-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + ]), + 'max_temp': 35, + 'min_temp': 7, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.thermostat_kuche', + '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': None, + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '656569c2-7976-4232-a789-34b4d1176c3a', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[generic_ef00_v1][climate.thermostat_kuche-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 21.0, + 'friendly_name': 'Thermostat Küche', + 'hvac_modes': list([ + ]), + 'max_temp': 35, + 'min_temp': 7, + 'supported_features': , + 'target_temp_high': None, + 'target_temp_low': None, + 'temperature': 23.0, + }), + 'context': , + 'entity_id': 'climate.thermostat_kuche', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }) +# --- # name: test_all_entities[heatit_ztrm3_thermostat][climate.hall_thermostat-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/smartthings/snapshots/test_init.ambr b/tests/components/smartthings/snapshots/test_init.ambr index acee145955c..f660e04eb48 100644 --- a/tests/components/smartthings/snapshots/test_init.ambr +++ b/tests/components/smartthings/snapshots/test_init.ambr @@ -695,6 +695,39 @@ 'via_device_id': None, }) # --- +# name: test_devices[generic_ef00_v1] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '656569c2-7976-4232-a789-34b4d1176c3a', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': None, + 'model': None, + 'model_id': None, + 'name': 'Thermostat Küche', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- # name: test_devices[generic_fan_3_speed] DeviceRegistryEntrySnapshot({ 'area_id': None, diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index cb282e24b27..84092d3f9e3 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -4625,6 +4625,160 @@ 'state': '22', }) # --- +# name: test_all_entities[generic_ef00_v1][sensor.thermostat_kuche_link_quality-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.thermostat_kuche_link_quality', + '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': 'Link quality', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'link_quality', + 'unique_id': '656569c2-7976-4232-a789-34b4d1176c3a.lqi', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[generic_ef00_v1][sensor.thermostat_kuche_link_quality-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Thermostat Küche Link quality', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.thermostat_kuche_link_quality', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '255', + }) +# --- +# name: test_all_entities[generic_ef00_v1][sensor.thermostat_kuche_signal_strength-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.thermostat_kuche_signal_strength', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Signal strength', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '656569c2-7976-4232-a789-34b4d1176c3a.rssi', + 'unit_of_measurement': 'dBm', + }) +# --- +# name: test_all_entities[generic_ef00_v1][sensor.thermostat_kuche_signal_strength-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'signal_strength', + 'friendly_name': 'Thermostat Küche Signal strength', + 'state_class': , + 'unit_of_measurement': 'dBm', + }), + 'context': , + 'entity_id': 'sensor.thermostat_kuche_signal_strength', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-84', + }) +# --- +# name: test_all_entities[generic_ef00_v1][sensor.thermostat_kuche_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.thermostat_kuche_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': 'Temperature', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '656569c2-7976-4232-a789-34b4d1176c3a.temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[generic_ef00_v1][sensor.thermostat_kuche_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Thermostat Küche Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.thermostat_kuche_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '21.0', + }) +# --- # name: test_all_entities[heatit_ztrm3_thermostat][sensor.hall_thermostat_energy-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/smartthings/snapshots/test_switch.ambr b/tests/components/smartthings/snapshots/test_switch.ambr index 00177b3b603..81b73874a6a 100644 --- a/tests/components/smartthings/snapshots/test_switch.ambr +++ b/tests/components/smartthings/snapshots/test_switch.ambr @@ -328,6 +328,53 @@ 'state': 'on', }) # --- +# name: test_all_entities[generic_ef00_v1][switch.thermostat_kuche-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.thermostat_kuche', + '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': None, + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '656569c2-7976-4232-a789-34b4d1176c3a', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[generic_ef00_v1][switch.thermostat_kuche-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Thermostat Küche', + }), + 'context': , + 'entity_id': 'switch.thermostat_kuche', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_all_entities[sensibo_airconditioner_1][switch.office-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 2c68be3f7eefc0d85204e7553d2ae3a64f48ba16 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 8 Mar 2025 12:02:41 +0100 Subject: [PATCH 2369/3148] Update pytest to 8.3.5 (#140102) --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 0a7a3bb18e5..d9789fab081 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -29,7 +29,7 @@ pytest-timeout==2.3.1 pytest-unordered==0.6.1 pytest-picked==0.5.1 pytest-xdist==3.6.1 -pytest==8.3.4 +pytest==8.3.5 requests-mock==1.12.1 respx==0.22.0 syrupy==4.8.1 From 817597b07a5c75a6d756b0a3f47b79e9b0d91f92 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 8 Mar 2025 12:10:56 +0100 Subject: [PATCH 2370/3148] Update ruff to 0.9.10 (#140105) --- .pre-commit-config.yaml | 2 +- requirements_test_pre_commit.txt | 2 +- script/hassfest/docker/Dockerfile | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 37114684c9f..cf6fe7030e9 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.9.8 + rev: v0.9.10 hooks: - id: ruff args: diff --git a/requirements_test_pre_commit.txt b/requirements_test_pre_commit.txt index c133c4b544a..1cf9ef3fcf5 100644 --- a/requirements_test_pre_commit.txt +++ b/requirements_test_pre_commit.txt @@ -1,5 +1,5 @@ # Automatically generated from .pre-commit-config.yaml by gen_requirements_all.py, do not edit codespell==2.4.1 -ruff==0.9.8 +ruff==0.9.10 yamllint==1.35.1 diff --git a/script/hassfest/docker/Dockerfile b/script/hassfest/docker/Dockerfile index 9d0bbeefd74..104939c3808 100644 --- a/script/hassfest/docker/Dockerfile +++ b/script/hassfest/docker/Dockerfile @@ -24,7 +24,7 @@ RUN --mount=from=ghcr.io/astral-sh/uv:0.6.1,source=/uv,target=/bin/uv \ --no-cache \ -c /usr/src/homeassistant/homeassistant/package_constraints.txt \ -r /usr/src/homeassistant/requirements.txt \ - stdlib-list==0.10.0 pipdeptree==2.25.0 tqdm==4.67.1 ruff==0.9.8 \ + stdlib-list==0.10.0 pipdeptree==2.25.0 tqdm==4.67.1 ruff==0.9.10 \ PyTurboJPEG==1.7.5 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 hassil==2.2.3 home-assistant-intents==2025.3.5 mutagen==1.47.0 pymicro-vad==1.0.1 pyspeex-noise==1.0.2 LABEL "name"="hassfest" From 1aed112c2c82f44d8e443b7c909e6d1c9a8a2039 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 8 Mar 2025 12:11:45 +0100 Subject: [PATCH 2371/3148] Update coverage to 7.6.12 (#140104) --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index d9789fab081..d5dee887214 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -8,7 +8,7 @@ -c homeassistant/package_constraints.txt -r requirements_test_pre_commit.txt astroid==3.3.8 -coverage==7.6.10 +coverage==7.6.12 freezegun==1.5.1 license-expression==30.4.1 mock-open==1.4.0 From 7507a9c24ee50c00b2ebbcecd68841a2a8d9146b Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Sat, 8 Mar 2025 12:50:32 +0100 Subject: [PATCH 2372/3148] Bump `accuweather` to version 4.2.0 (#140106) Bump accuweather to version 4.2.0 --- homeassistant/components/accuweather/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/accuweather/manifest.json b/homeassistant/components/accuweather/manifest.json index 5a019ef968e..810557519eb 100644 --- a/homeassistant/components/accuweather/manifest.json +++ b/homeassistant/components/accuweather/manifest.json @@ -7,6 +7,6 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["accuweather"], - "requirements": ["accuweather==4.1.0"], + "requirements": ["accuweather==4.2.0"], "single_config_entry": true } diff --git a/requirements_all.txt b/requirements_all.txt index 73e0ea497fc..aeda2ed360b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -131,7 +131,7 @@ TwitterAPI==2.7.12 WSDiscovery==2.1.2 # homeassistant.components.accuweather -accuweather==4.1.0 +accuweather==4.2.0 # homeassistant.components.adax adax==0.4.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 49a783a5c69..b955e4a134d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -119,7 +119,7 @@ Tami4EdgeAPI==3.0 WSDiscovery==2.1.2 # homeassistant.components.accuweather -accuweather==4.1.0 +accuweather==4.2.0 # homeassistant.components.adax adax==0.4.0 From 81e6b935292db2fdf6efa4bf308309c10d5b4993 Mon Sep 17 00:00:00 2001 From: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> Date: Sat, 8 Mar 2025 07:57:44 -0600 Subject: [PATCH 2373/3148] Fix HEOS user initiated setup when discovery is waiting confirmation (#140119) --- homeassistant/components/heos/config_flow.py | 2 +- tests/components/heos/test_config_flow.py | 29 ++++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/heos/config_flow.py b/homeassistant/components/heos/config_flow.py index f1cd11f0914..e2d3e2522dc 100644 --- a/homeassistant/components/heos/config_flow.py +++ b/homeassistant/components/heos/config_flow.py @@ -205,7 +205,7 @@ class HeosFlowHandler(ConfigFlow, domain=DOMAIN): self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Obtain host and validate connection.""" - await self.async_set_unique_id(DOMAIN) + await self.async_set_unique_id(DOMAIN, raise_on_progress=False) self._abort_if_unique_id_configured(error="single_instance_allowed") # Try connecting to host if provided errors: dict[str, str] = {} diff --git a/tests/components/heos/test_config_flow.py b/tests/components/heos/test_config_flow.py index 69df3734690..69d9aa3a38e 100644 --- a/tests/components/heos/test_config_flow.py +++ b/tests/components/heos/test_config_flow.py @@ -88,6 +88,35 @@ async def test_create_entry_when_host_valid( assert controller.disconnect.call_count == 1 +async def test_manual_setup_with_discovery_in_progress( + hass: HomeAssistant, + discovery_data: SsdpServiceInfo, + controller: MockHeos, + system: HeosSystem, +) -> None: + """Test user can manually set up when discovery is in progress.""" + # Single discovered, selects preferred host, shows confirm + controller.get_system_info.return_value = system + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_SSDP}, data=discovery_data + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "confirm_discovery" + + # Setup manually + user_result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert user_result["type"] is FlowResultType.FORM + user_result = await hass.config_entries.flow.async_configure( + user_result["flow_id"], user_input={CONF_HOST: "127.0.0.1"} + ) + assert user_result["type"] is FlowResultType.CREATE_ENTRY + + # Discovery flow is removed + assert not hass.config_entries.flow.async_progress_by_handler(DOMAIN) + + async def test_discovery( hass: HomeAssistant, discovery_data: SsdpServiceInfo, From 105d9d59702cad3005a2eece845b5bcdb38c5574 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 8 Mar 2025 14:59:10 +0100 Subject: [PATCH 2374/3148] Handle None options in SmartThings (#140110) * Handle None options in SmartThings * Handle None options in SmartThings --- .../components/smartthings/sensor.py | 9 +- tests/components/smartthings/conftest.py | 1 + .../device_status/im_speaker_ai_0001.json | 222 +++++++++++++++ .../fixtures/devices/im_speaker_ai_0001.json | 136 ++++++++++ .../smartthings/snapshots/test_init.ambr | 33 +++ .../smartthings/snapshots/test_sensor.ambr | 255 ++++++++++++++++++ 6 files changed, 653 insertions(+), 3 deletions(-) create mode 100644 tests/components/smartthings/fixtures/device_status/im_speaker_ai_0001.json create mode 100644 tests/components/smartthings/fixtures/devices/im_speaker_ai_0001.json diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index da0e752fb5b..2164e432edc 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -1031,8 +1031,11 @@ class SmartThingsSensor(SmartThingsEntity, SensorEntity): def options(self) -> list[str] | None: """Return the options for this sensor.""" if self.entity_description.options_attribute: - options = self.get_attribute_value( - self.capability, self.entity_description.options_attribute - ) + if ( + options := self.get_attribute_value( + self.capability, self.entity_description.options_attribute + ) + ) is None: + return [] return [option.lower() for option in options] return super().options diff --git a/tests/components/smartthings/conftest.py b/tests/components/smartthings/conftest.py index d3b91d058a9..7f27d3eecc4 100644 --- a/tests/components/smartthings/conftest.py +++ b/tests/components/smartthings/conftest.py @@ -124,6 +124,7 @@ def mock_smartthings() -> Generator[AsyncMock]: "heatit_ztrm3_thermostat", "generic_ef00_v1", "bosch_radiator_thermostat_ii", + "im_speaker_ai_0001", ] ) def device_fixture( diff --git a/tests/components/smartthings/fixtures/device_status/im_speaker_ai_0001.json b/tests/components/smartthings/fixtures/device_status/im_speaker_ai_0001.json new file mode 100644 index 00000000000..4b23ca7086f --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/im_speaker_ai_0001.json @@ -0,0 +1,222 @@ +{ + "components": { + "main": { + "mediaPlayback": { + "supportedPlaybackCommands": { + "value": ["play", "pause", "stop"], + "timestamp": "2025-03-08T12:06:24.496Z" + }, + "playbackStatus": { + "value": "stopped", + "timestamp": "2025-03-08T12:06:24.496Z" + } + }, + "audioVolume": { + "volume": { + "value": 52, + "unit": "%", + "timestamp": "2025-03-08T12:08:00.153Z" + } + }, + "mediaInputSource": { + "supportedInputSources": { + "value": null + }, + "inputSource": { + "value": null + } + }, + "audioTrackAddressing": {}, + "samsungim.networkAudioGroupInfo": { + "groupName": { + "value": "", + "timestamp": "2025-03-08T12:06:24.628Z" + }, + "role": { + "value": "", + "timestamp": "2025-03-08T12:06:24.628Z" + }, + "channel": { + "value": "", + "timestamp": "2025-03-08T12:06:24.628Z" + }, + "stereoType": { + "value": "A", + "timestamp": "2025-03-08T12:06:24.628Z" + }, + "masterDi": { + "value": "", + "timestamp": "2025-03-08T12:06:24.628Z" + }, + "acmMode": { + "value": "", + "timestamp": "2025-03-08T12:06:24.628Z" + }, + "status": { + "value": "single", + "timestamp": "2025-03-08T12:06:24.628Z" + }, + "masterName": { + "value": "", + "timestamp": "2025-03-08T12:06:24.628Z" + } + }, + "refresh": {}, + "audioNotification": {}, + "execute": { + "data": { + "value": null + } + }, + "samsungim.networkAudioMode": { + "mode": { + "value": "wifi", + "timestamp": "2025-03-08T12:06:24.573Z" + } + }, + "mediaPlaybackRepeat": { + "playbackRepeatMode": { + "value": "off", + "timestamp": "2025-03-08T12:06:24.519Z" + } + }, + "musicPlayer": { + "trackDescription": { + "value": null + }, + "level": { + "value": null + }, + "mute": { + "value": null + }, + "trackData": { + "value": null + }, + "status": { + "value": null + } + }, + "ocf": { + "st": { + "value": null + }, + "mndt": { + "value": null + }, + "mnfv": { + "value": "V310XXU1AWK1", + "timestamp": "2025-03-08T12:06:18.931Z" + }, + "mnhw": { + "value": "1.0", + "timestamp": "2025-03-08T12:06:18.931Z" + }, + "di": { + "value": "c9276e43-fe3c-88c3-1dcc-2eb79e292b8c", + "timestamp": "2025-03-08T12:06:18.931Z" + }, + "mnsl": { + "value": null + }, + "dmv": { + "value": "IoTivity1.2.1", + "timestamp": "2025-03-08T12:06:18.942Z" + }, + "n": { + "value": "Galaxy Home Mini (MQVL)", + "timestamp": "2025-03-08T12:06:18.931Z" + }, + "mnmo": { + "value": "SM-V310", + "timestamp": "2025-03-08T12:06:18.931Z" + }, + "vid": { + "value": "IM-SPEAKER-AI-0001", + "timestamp": "2025-03-08T12:06:18.931Z" + }, + "mnmn": { + "value": "Samsung Electronics", + "timestamp": "2025-03-08T12:06:18.931Z" + }, + "mnml": { + "value": null + }, + "mnpv": { + "value": "4.0.0.1", + "timestamp": "2025-03-08T12:06:18.931Z" + }, + "mnos": { + "value": "Tizen", + "timestamp": "2025-03-08T12:06:18.931Z" + }, + "pi": { + "value": "c9276e43-fe3c-88c3-1dcc-2eb79e292b8c", + "timestamp": "2025-03-08T12:06:18.931Z" + }, + "icv": { + "value": "core0.0.1", + "timestamp": "2025-03-08T12:06:18.942Z" + } + }, + "samsungim.announcement": { + "enableState": { + "value": null + }, + "supportedCategories": { + "value": null + }, + "supportedTypes": { + "value": null + }, + "supportedMimes": { + "value": null + } + }, + "samsungim.bixbyContent": { + "supportedModes": { + "value": ["news", "weather", "music", "search_all"], + "timestamp": "2025-03-08T12:06:24.817Z" + } + }, + "mediaPlaybackShuffle": { + "playbackShuffle": { + "value": "disabled", + "timestamp": "2025-03-08T12:06:24.592Z" + } + }, + "audioMute": { + "mute": { + "value": "unmuted", + "timestamp": "2025-03-08T12:06:24.478Z" + } + }, + "mediaTrackControl": { + "supportedTrackControlCommands": { + "value": null + } + }, + "speechSynthesis": {}, + "samsungim.networkAudioTrackData": { + "appName": { + "value": null + }, + "source": { + "value": "wifi", + "timestamp": "2025-03-08T12:06:24.540Z" + } + }, + "audioTrackData": { + "totalTime": { + "value": null + }, + "audioTrackData": { + "value": null + }, + "elapsedTime": { + "value": null + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/devices/im_speaker_ai_0001.json b/tests/components/smartthings/fixtures/devices/im_speaker_ai_0001.json new file mode 100644 index 00000000000..81fb1b07ff2 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/im_speaker_ai_0001.json @@ -0,0 +1,136 @@ +{ + "items": [ + { + "deviceId": "c9276e43-fe3c-88c3-1dcc-2eb79e292b8c", + "name": "Galaxy Home Mini (MQVL)", + "label": "Galaxy Home Mini", + "manufacturerName": "Samsung Electronics", + "presentationId": "IM-SPEAKER-AI-0001", + "deviceManufacturerCode": "Samsung Electronics", + "locationId": "33db9e71-abe9-43a0-acd3-3f0927bbe5b7", + "ownerId": "9a1ee192-04ba-46ca-9c3d-196d8dbcf807", + "roomId": "445c006d-1796-4dd6-8308-1c3cd045e8ff", + "deviceTypeName": "Samsung OCF Network Audio Player", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "execute", + "version": 1 + }, + { + "id": "ocf", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "audioMute", + "version": 1 + }, + { + "id": "audioVolume", + "version": 1 + }, + { + "id": "mediaInputSource", + "version": 1 + }, + { + "id": "mediaPlaybackRepeat", + "version": 1 + }, + { + "id": "mediaPlaybackShuffle", + "version": 1 + }, + { + "id": "mediaPlayback", + "version": 1 + }, + { + "id": "mediaTrackControl", + "version": 1 + }, + { + "id": "audioTrackAddressing", + "version": 1 + }, + { + "id": "audioTrackData", + "version": 1 + }, + { + "id": "musicPlayer", + "version": 1 + }, + { + "id": "audioNotification", + "version": 1 + }, + { + "id": "speechSynthesis", + "version": 1 + }, + { + "id": "samsungim.bixbyContent", + "version": 1 + }, + { + "id": "samsungim.announcement", + "version": 1 + }, + { + "id": "samsungim.networkAudioMode", + "version": 1 + }, + { + "id": "samsungim.networkAudioGroupInfo", + "version": 1 + }, + { + "id": "samsungim.networkAudioTrackData", + "version": 1 + } + ], + "categories": [ + { + "name": "NetworkAudio", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2025-03-08T12:06:18.865Z", + "profile": { + "id": "09df8e36-e94f-339c-9086-9639505e1fb2" + }, + "ocf": { + "ocfDeviceType": "oic.d.networkaudio", + "name": "Galaxy Home Mini (MQVL)", + "specVersion": "core0.0.1", + "verticalDomainSpecVersion": "IoTivity1.2.1", + "manufacturerName": "Samsung Electronics", + "modelNumber": "SM-V310", + "platformVersion": "4.0.0.1", + "platformOS": "Tizen", + "hwVersion": "1.0", + "firmwareVersion": "V310XXU1AWK1", + "vendorId": "IM-SPEAKER-AI-0001", + "lastSignupTime": "2025-03-08T12:06:16.386696652Z", + "transferCandidate": false, + "additionalAuthCodeRequired": false + }, + "type": "OCF", + "restrictionTier": 0, + "allowed": null, + "executionContext": "CLOUD", + "relationships": [] + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/snapshots/test_init.ambr b/tests/components/smartthings/snapshots/test_init.ambr index f660e04eb48..7c2589590c5 100644 --- a/tests/components/smartthings/snapshots/test_init.ambr +++ b/tests/components/smartthings/snapshots/test_init.ambr @@ -860,6 +860,39 @@ 'via_device_id': None, }) # --- +# name: test_devices[im_speaker_ai_0001] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '1.0', + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + 'c9276e43-fe3c-88c3-1dcc-2eb79e292b8c', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Samsung Electronics', + 'model': 'SM-V310', + 'model_id': None, + 'name': 'Galaxy Home Mini', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': 'V310XXU1AWK1', + 'via_device_id': None, + }) +# --- # name: test_devices[iphone] DeviceRegistryEntrySnapshot({ 'area_id': None, diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index 84092d3f9e3..5909fec2707 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -4935,6 +4935,261 @@ 'state': '19.0', }) # --- +# name: test_all_entities[im_speaker_ai_0001][sensor.galaxy_home_mini_media_input_source-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.galaxy_home_mini_media_input_source', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Media input source', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'media_input_source', + 'unique_id': 'c9276e43-fe3c-88c3-1dcc-2eb79e292b8c.inputSource', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[im_speaker_ai_0001][sensor.galaxy_home_mini_media_input_source-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Galaxy Home Mini Media input source', + }), + 'context': , + 'entity_id': 'sensor.galaxy_home_mini_media_input_source', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[im_speaker_ai_0001][sensor.galaxy_home_mini_media_playback_repeat-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.galaxy_home_mini_media_playback_repeat', + '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': 'Media playback repeat', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'media_playback_repeat', + 'unique_id': 'c9276e43-fe3c-88c3-1dcc-2eb79e292b8c.playbackRepeatMode', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[im_speaker_ai_0001][sensor.galaxy_home_mini_media_playback_repeat-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Galaxy Home Mini Media playback repeat', + }), + 'context': , + 'entity_id': 'sensor.galaxy_home_mini_media_playback_repeat', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[im_speaker_ai_0001][sensor.galaxy_home_mini_media_playback_shuffle-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.galaxy_home_mini_media_playback_shuffle', + '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': 'Media playback shuffle', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'media_playback_shuffle', + 'unique_id': 'c9276e43-fe3c-88c3-1dcc-2eb79e292b8c.playbackShuffle', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[im_speaker_ai_0001][sensor.galaxy_home_mini_media_playback_shuffle-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Galaxy Home Mini Media playback shuffle', + }), + 'context': , + 'entity_id': 'sensor.galaxy_home_mini_media_playback_shuffle', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'disabled', + }) +# --- +# name: test_all_entities[im_speaker_ai_0001][sensor.galaxy_home_mini_media_playback_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'paused', + 'playing', + 'stopped', + 'fast_forwarding', + 'rewinding', + 'buffering', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.galaxy_home_mini_media_playback_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Media playback status', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'media_playback_status', + 'unique_id': 'c9276e43-fe3c-88c3-1dcc-2eb79e292b8c.playbackStatus', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[im_speaker_ai_0001][sensor.galaxy_home_mini_media_playback_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Galaxy Home Mini Media playback status', + 'options': list([ + 'paused', + 'playing', + 'stopped', + 'fast_forwarding', + 'rewinding', + 'buffering', + ]), + }), + 'context': , + 'entity_id': 'sensor.galaxy_home_mini_media_playback_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'stopped', + }) +# --- +# name: test_all_entities[im_speaker_ai_0001][sensor.galaxy_home_mini_volume-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.galaxy_home_mini_volume', + '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': 'Volume', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'audio_volume', + 'unique_id': 'c9276e43-fe3c-88c3-1dcc-2eb79e292b8c.volume', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[im_speaker_ai_0001][sensor.galaxy_home_mini_volume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Galaxy Home Mini Volume', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.galaxy_home_mini_volume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '52', + }) +# --- # name: test_all_entities[multipurpose_sensor][sensor.deck_door_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 244b666dee3604eaa851089c6a0469818f8c0fe3 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 8 Mar 2025 14:59:29 +0100 Subject: [PATCH 2375/3148] Add Dependency Review action (#140108) --- .github/workflows/ci.yaml | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 9ef851009f6..3f970ce5874 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -638,6 +638,25 @@ jobs: . venv/bin/activate python -m script.gen_requirements_all validate + dependency-review: + name: Dependency review + runs-on: ubuntu-24.04 + needs: + - info + - base + if: | + github.event.inputs.pylint-only != 'true' + && github.event.inputs.mypy-only != 'true' + && needs.info.outputs.requirements == 'true' + && github.event_name == 'pull_request' + steps: + - name: Check out code from GitHub + uses: actions/checkout@v4.2.2 + - name: Dependency review + uses: actions/dependency-review-action@v4.5.0 + with: + license-check: false # We use our own license audit checks + audit-licenses: name: Audit licenses runs-on: ubuntu-24.04 From 6754bf2466e27840c3f7862ef14723bc1d57efe7 Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Sat, 8 Mar 2025 13:04:40 -0500 Subject: [PATCH 2376/3148] Send Roborock commands via cloud api when needed (#138496) * Send via cloud api when needed * Extract logic to helper function * change to class method --- homeassistant/components/roborock/entity.py | 29 ++++++++++++++++----- tests/components/roborock/test_vacuum.py | 24 +++++++++++++++++ 2 files changed, 47 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/roborock/entity.py b/homeassistant/components/roborock/entity.py index 4a16ada5967..d417ac17159 100644 --- a/homeassistant/components/roborock/entity.py +++ b/homeassistant/components/roborock/entity.py @@ -8,7 +8,11 @@ from roborock.containers import Consumable, Status from roborock.exceptions import RoborockException from roborock.roborock_message import RoborockDataProtocol from roborock.roborock_typing import RoborockCommand -from roborock.version_1_apis.roborock_client_v1 import AttributeCache, RoborockClientV1 +from roborock.version_1_apis.roborock_client_v1 import ( + CLOUD_REQUIRED, + AttributeCache, + RoborockClientV1, +) from roborock.version_1_apis.roborock_mqtt_client_v1 import RoborockMqttClientV1 from roborock.version_a01_apis import RoborockClientA01 @@ -53,14 +57,16 @@ class RoborockEntityV1(RoborockEntity): """Get an item from the api cache.""" return self._api.cache[attribute] - async def send( - self, + @classmethod + async def _send_command( + cls, command: RoborockCommand | str, + api: RoborockClientV1, params: dict[str, Any] | list[Any] | int | None = None, ) -> dict: - """Send a command to a vacuum cleaner.""" + """Send a Roborock command with params to a given api.""" try: - response: dict = await self._api.send_command(command, params) + response: dict = await api.send_command(command, params) except RoborockException as err: if isinstance(command, RoborockCommand): command_name = command.name @@ -75,6 +81,14 @@ class RoborockEntityV1(RoborockEntity): ) from err return response + async def send( + self, + command: RoborockCommand | str, + params: dict[str, Any] | list[Any] | int | None = None, + ) -> dict: + """Send a command to a vacuum cleaner.""" + return await self._send_command(command, self._api, params) + @property def api(self) -> RoborockClientV1: """Returns the api.""" @@ -152,7 +166,10 @@ class RoborockCoordinatedEntityV1( params: dict[str, Any] | list[Any] | int | None = None, ) -> dict: """Overloads normal send command but refreshes coordinator.""" - res = await super().send(command, params) + if command in CLOUD_REQUIRED: + res = await self._send_command(command, self.coordinator.cloud_api, params) + else: + res = await self._send_command(command, self._api, params) await self.coordinator.async_refresh() return res diff --git a/tests/components/roborock/test_vacuum.py b/tests/components/roborock/test_vacuum.py index d9d4340ec83..15fdeb4767c 100644 --- a/tests/components/roborock/test_vacuum.py +++ b/tests/components/roborock/test_vacuum.py @@ -117,6 +117,30 @@ async def test_commands( assert mock_send_command.call_args[0][1] == called_params +async def test_cloud_command( + hass: HomeAssistant, + bypass_api_fixture, + setup_entry: MockConfigEntry, +) -> None: + """Test sending commands to the vacuum.""" + + vacuum = hass.states.get(ENTITY_ID) + assert vacuum + + data = {ATTR_ENTITY_ID: ENTITY_ID, "command": "get_map_v1"} + with patch( + "homeassistant.components.roborock.coordinator.RoborockMqttClientV1.send_command" + ) as mock_send_command: + await hass.services.async_call( + Platform.VACUUM, + SERVICE_SEND_COMMAND, + data, + blocking=True, + ) + assert mock_send_command.call_count == 1 + assert mock_send_command.call_args[0][0] == RoborockCommand.GET_MAP_V1 + + @pytest.mark.parametrize( ("in_cleaning_int", "expected_command"), [ From 2d22a60b8fc4211e138b5d210c6350e8e30296cc Mon Sep 17 00:00:00 2001 From: John Hillery <34005807+jrhillery@users.noreply.github.com> Date: Sat, 8 Mar 2025 13:22:26 -0500 Subject: [PATCH 2377/3148] Label emergency heat switch (#139872) * Add label to emergency heat switch * Use sentence case names Co-authored-by: Franck Nijhof --------- Co-authored-by: Franck Nijhof --- homeassistant/components/nexia/strings.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/nexia/strings.json b/homeassistant/components/nexia/strings.json index d88ce0b898d..05d86d3a495 100644 --- a/homeassistant/components/nexia/strings.json +++ b/homeassistant/components/nexia/strings.json @@ -58,6 +58,9 @@ "switch": { "hold": { "name": "Hold" + }, + "emergency_heat": { + "name": "Emergency heat" } } }, From b910bc78026f79a04a292516f95d580130434653 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 8 Mar 2025 19:58:10 +0100 Subject: [PATCH 2378/3148] Set device class for Oven Completion time in SmartThings (#140139) --- homeassistant/components/smartthings/sensor.py | 2 ++ tests/components/smartthings/snapshots/test_sensor.ambr | 5 +++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index 2164e432edc..5a2fdcf3854 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -561,6 +561,8 @@ CAPABILITY_TO_SENSORS: dict[ SmartThingsSensorEntityDescription( key=Attribute.COMPLETION_TIME, translation_key="completion_time", + device_class=SensorDeviceClass.TIMESTAMP, + value_fn=dt_util.parse_datetime, ) ], }, diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index 5909fec2707..b939547ca32 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -1710,7 +1710,7 @@ 'name': None, 'options': dict({ }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Completion time', 'platform': 'smartthings', @@ -1724,6 +1724,7 @@ # name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_completion_time-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', 'friendly_name': 'Microwave Completion time', }), 'context': , @@ -1731,7 +1732,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '2025-02-08T21:13:36.184Z', + 'state': '2025-02-08T21:13:36+00:00', }) # --- # name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_job_state-entry] From 06fd6442b6a7df1f0e076852c68b6077126f6287 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Sat, 8 Mar 2025 20:03:25 +0100 Subject: [PATCH 2379/3148] Use the set language for condition sensors in Accuweather integration (#140107) * Use the set language for condition sensors * Update strings * Update test snapshots * Add missing string --- homeassistant/components/accuweather/const.py | 14 +++- .../components/accuweather/coordinator.py | 4 +- .../components/accuweather/sensor.py | 28 +++++--- .../components/accuweather/strings.json | 72 +++++++++---------- .../accuweather/snapshots/test_sensor.ambr | 40 +++-------- 5 files changed, 80 insertions(+), 78 deletions(-) diff --git a/homeassistant/components/accuweather/const.py b/homeassistant/components/accuweather/const.py index 1bbf5a36187..f09b9771ab6 100644 --- a/homeassistant/components/accuweather/const.py +++ b/homeassistant/components/accuweather/const.py @@ -24,7 +24,7 @@ from homeassistant.components.weather import ( API_METRIC: Final = "Metric" ATTRIBUTION: Final = "Data provided by AccuWeather" -ATTR_CATEGORY: Final = "Category" +ATTR_CATEGORY_VALUE = "CategoryValue" ATTR_DIRECTION: Final = "Direction" ATTR_ENGLISH: Final = "English" ATTR_LEVEL: Final = "level" @@ -55,5 +55,17 @@ CONDITION_MAP = { for cond_ha, cond_codes in CONDITION_CLASSES.items() for cond_code in cond_codes } +AIR_QUALITY_CATEGORY_MAP = { + 1: "good", + 2: "moderate", + 3: "unhealthy", + 4: "hazardous", +} +POLLEN_CATEGORY_MAP = { + 1: "low", + 2: "moderate", + 3: "high", + 4: "very high", +} 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 index 40ff3ad2c87..67e3e2ad76e 100644 --- a/homeassistant/components/accuweather/coordinator.py +++ b/homeassistant/components/accuweather/coordinator.py @@ -117,7 +117,9 @@ class AccuWeatherDailyForecastDataUpdateCoordinator( """Update data via library.""" try: async with timeout(10): - result = await self.accuweather.async_get_daily_forecast() + result = await self.accuweather.async_get_daily_forecast( + language=self.hass.config.language + ) except EXCEPTIONS as error: raise UpdateFailed(error) from error diff --git a/homeassistant/components/accuweather/sensor.py b/homeassistant/components/accuweather/sensor.py index f14584cf08c..415df402d55 100644 --- a/homeassistant/components/accuweather/sensor.py +++ b/homeassistant/components/accuweather/sensor.py @@ -29,8 +29,9 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ( + AIR_QUALITY_CATEGORY_MAP, API_METRIC, - ATTR_CATEGORY, + ATTR_CATEGORY_VALUE, ATTR_DIRECTION, ATTR_ENGLISH, ATTR_LEVEL, @@ -38,6 +39,7 @@ from .const import ( ATTR_VALUE, ATTRIBUTION, MAX_FORECAST_DAYS, + POLLEN_CATEGORY_MAP, ) from .coordinator import ( AccuWeatherConfigEntry, @@ -59,9 +61,9 @@ class AccuWeatherSensorDescription(SensorEntityDescription): FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( AccuWeatherSensorDescription( key="AirQuality", - value_fn=lambda data: cast(str, data[ATTR_CATEGORY]), + value_fn=lambda data: AIR_QUALITY_CATEGORY_MAP[data[ATTR_CATEGORY_VALUE]], device_class=SensorDeviceClass.ENUM, - options=["good", "hazardous", "high", "low", "moderate", "unhealthy"], + options=list(AIR_QUALITY_CATEGORY_MAP.values()), translation_key="air_quality", ), AccuWeatherSensorDescription( @@ -83,7 +85,9 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( entity_registry_enabled_default=False, native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, value_fn=lambda data: cast(int, data[ATTR_VALUE]), - attr_fn=lambda data: {ATTR_LEVEL: data[ATTR_CATEGORY]}, + attr_fn=lambda data: { + ATTR_LEVEL: POLLEN_CATEGORY_MAP[data[ATTR_CATEGORY_VALUE]] + }, translation_key="grass_pollen", ), AccuWeatherSensorDescription( @@ -107,7 +111,9 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( entity_registry_enabled_default=False, native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, value_fn=lambda data: cast(int, data[ATTR_VALUE]), - attr_fn=lambda data: {ATTR_LEVEL: data[ATTR_CATEGORY]}, + attr_fn=lambda data: { + ATTR_LEVEL: POLLEN_CATEGORY_MAP[data[ATTR_CATEGORY_VALUE]] + }, translation_key="mold_pollen", ), AccuWeatherSensorDescription( @@ -115,7 +121,9 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, entity_registry_enabled_default=False, value_fn=lambda data: cast(int, data[ATTR_VALUE]), - attr_fn=lambda data: {ATTR_LEVEL: data[ATTR_CATEGORY]}, + attr_fn=lambda data: { + ATTR_LEVEL: POLLEN_CATEGORY_MAP[data[ATTR_CATEGORY_VALUE]] + }, translation_key="ragweed_pollen", ), AccuWeatherSensorDescription( @@ -181,14 +189,18 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, entity_registry_enabled_default=False, value_fn=lambda data: cast(int, data[ATTR_VALUE]), - attr_fn=lambda data: {ATTR_LEVEL: data[ATTR_CATEGORY]}, + attr_fn=lambda data: { + ATTR_LEVEL: POLLEN_CATEGORY_MAP[data[ATTR_CATEGORY_VALUE]] + }, translation_key="tree_pollen", ), AccuWeatherSensorDescription( key="UVIndex", native_unit_of_measurement=UV_INDEX, value_fn=lambda data: cast(int, data[ATTR_VALUE]), - attr_fn=lambda data: {ATTR_LEVEL: data[ATTR_CATEGORY]}, + attr_fn=lambda data: { + ATTR_LEVEL: POLLEN_CATEGORY_MAP[data[ATTR_CATEGORY_VALUE]] + }, translation_key="uv_index_forecast", ), AccuWeatherSensorDescription( diff --git a/homeassistant/components/accuweather/strings.json b/homeassistant/components/accuweather/strings.json index d0250a382e9..e5190b7a8da 100644 --- a/homeassistant/components/accuweather/strings.json +++ b/homeassistant/components/accuweather/strings.json @@ -26,10 +26,18 @@ "state": { "good": "Good", "hazardous": "Hazardous", - "high": "High", - "low": "Low", "moderate": "Moderate", "unhealthy": "Unhealthy" + }, + "state_attributes": { + "options": { + "state": { + "good": "[%key:component::accuweather::entity::sensor::air_quality::state::good%]", + "hazardous": "[%key:component::accuweather::entity::sensor::air_quality::state::hazardous%]", + "moderate": "[%key:component::accuweather::entity::sensor::air_quality::state::moderate%]", + "unhealthy": "[%key:component::accuweather::entity::sensor::air_quality::state::unhealthy%]" + } + } } }, "apparent_temperature": { @@ -62,12 +70,10 @@ "level": { "name": "Level", "state": { - "good": "[%key:component::accuweather::entity::sensor::air_quality::state::good%]", - "hazardous": "[%key:component::accuweather::entity::sensor::air_quality::state::hazardous%]", - "high": "[%key:component::accuweather::entity::sensor::air_quality::state::high%]", - "low": "[%key:component::accuweather::entity::sensor::air_quality::state::low%]", - "moderate": "[%key:component::accuweather::entity::sensor::air_quality::state::moderate%]", - "unhealthy": "[%key:component::accuweather::entity::sensor::air_quality::state::unhealthy%]" + "high": "High", + "low": "Low", + "moderate": "Moderate", + "very_high": "Very high" } } } @@ -81,12 +87,10 @@ "level": { "name": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::name%]", "state": { - "good": "[%key:component::accuweather::entity::sensor::air_quality::state::good%]", - "hazardous": "[%key:component::accuweather::entity::sensor::air_quality::state::hazardous%]", - "high": "[%key:component::accuweather::entity::sensor::air_quality::state::high%]", - "low": "[%key:component::accuweather::entity::sensor::air_quality::state::low%]", - "moderate": "[%key:component::accuweather::entity::sensor::air_quality::state::moderate%]", - "unhealthy": "[%key:component::accuweather::entity::sensor::air_quality::state::unhealthy%]" + "high": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::high%]", + "low": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::low%]", + "moderate": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::moderate%]", + "very_high": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::very_high%]" } } } @@ -108,12 +112,10 @@ "level": { "name": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::name%]", "state": { - "good": "[%key:component::accuweather::entity::sensor::air_quality::state::good%]", - "hazardous": "[%key:component::accuweather::entity::sensor::air_quality::state::hazardous%]", - "high": "[%key:component::accuweather::entity::sensor::air_quality::state::high%]", - "low": "[%key:component::accuweather::entity::sensor::air_quality::state::low%]", - "moderate": "[%key:component::accuweather::entity::sensor::air_quality::state::moderate%]", - "unhealthy": "[%key:component::accuweather::entity::sensor::air_quality::state::unhealthy%]" + "high": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::high%]", + "low": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::low%]", + "moderate": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::moderate%]", + "very_high": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::very_high%]" } } } @@ -154,12 +156,10 @@ "level": { "name": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::name%]", "state": { - "good": "[%key:component::accuweather::entity::sensor::air_quality::state::good%]", - "hazardous": "[%key:component::accuweather::entity::sensor::air_quality::state::hazardous%]", - "high": "[%key:component::accuweather::entity::sensor::air_quality::state::high%]", - "low": "[%key:component::accuweather::entity::sensor::air_quality::state::low%]", - "moderate": "[%key:component::accuweather::entity::sensor::air_quality::state::moderate%]", - "unhealthy": "[%key:component::accuweather::entity::sensor::air_quality::state::unhealthy%]" + "high": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::high%]", + "low": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::low%]", + "moderate": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::moderate%]", + "very_high": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::very_high%]" } } } @@ -170,12 +170,10 @@ "level": { "name": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::name%]", "state": { - "good": "[%key:component::accuweather::entity::sensor::air_quality::state::good%]", - "hazardous": "[%key:component::accuweather::entity::sensor::air_quality::state::hazardous%]", - "high": "[%key:component::accuweather::entity::sensor::air_quality::state::high%]", - "low": "[%key:component::accuweather::entity::sensor::air_quality::state::low%]", - "moderate": "[%key:component::accuweather::entity::sensor::air_quality::state::moderate%]", - "unhealthy": "[%key:component::accuweather::entity::sensor::air_quality::state::unhealthy%]" + "high": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::high%]", + "low": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::low%]", + "moderate": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::moderate%]", + "very_high": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::very_high%]" } } } @@ -186,12 +184,10 @@ "level": { "name": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::name%]", "state": { - "good": "[%key:component::accuweather::entity::sensor::air_quality::state::good%]", - "hazardous": "[%key:component::accuweather::entity::sensor::air_quality::state::hazardous%]", - "high": "[%key:component::accuweather::entity::sensor::air_quality::state::high%]", - "low": "[%key:component::accuweather::entity::sensor::air_quality::state::low%]", - "moderate": "[%key:component::accuweather::entity::sensor::air_quality::state::moderate%]", - "unhealthy": "[%key:component::accuweather::entity::sensor::air_quality::state::unhealthy%]" + "high": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::high%]", + "low": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::low%]", + "moderate": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::moderate%]", + "very_high": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::very_high%]" } } } diff --git a/tests/components/accuweather/snapshots/test_sensor.ambr b/tests/components/accuweather/snapshots/test_sensor.ambr index 257d29ae844..3176f0a88bd 100644 --- a/tests/components/accuweather/snapshots/test_sensor.ambr +++ b/tests/components/accuweather/snapshots/test_sensor.ambr @@ -7,11 +7,9 @@ 'capabilities': dict({ 'options': list([ 'good', - 'hazardous', - 'high', - 'low', 'moderate', 'unhealthy', + 'hazardous', ]), }), 'config_entry_id': , @@ -50,11 +48,9 @@ 'friendly_name': 'Home Air quality day 0', 'options': list([ 'good', - 'hazardous', - 'high', - 'low', 'moderate', 'unhealthy', + 'hazardous', ]), }), 'context': , @@ -73,11 +69,9 @@ 'capabilities': dict({ 'options': list([ 'good', - 'hazardous', - 'high', - 'low', 'moderate', 'unhealthy', + 'hazardous', ]), }), 'config_entry_id': , @@ -116,11 +110,9 @@ 'friendly_name': 'Home Air quality day 1', 'options': list([ 'good', - 'hazardous', - 'high', - 'low', 'moderate', 'unhealthy', + 'hazardous', ]), }), 'context': , @@ -139,11 +131,9 @@ 'capabilities': dict({ 'options': list([ 'good', - 'hazardous', - 'high', - 'low', 'moderate', 'unhealthy', + 'hazardous', ]), }), 'config_entry_id': , @@ -182,11 +172,9 @@ 'friendly_name': 'Home Air quality day 2', 'options': list([ 'good', - 'hazardous', - 'high', - 'low', 'moderate', 'unhealthy', + 'hazardous', ]), }), 'context': , @@ -205,11 +193,9 @@ 'capabilities': dict({ 'options': list([ 'good', - 'hazardous', - 'high', - 'low', 'moderate', 'unhealthy', + 'hazardous', ]), }), 'config_entry_id': , @@ -248,11 +234,9 @@ 'friendly_name': 'Home Air quality day 3', 'options': list([ 'good', - 'hazardous', - 'high', - 'low', 'moderate', 'unhealthy', + 'hazardous', ]), }), 'context': , @@ -271,11 +255,9 @@ 'capabilities': dict({ 'options': list([ 'good', - 'hazardous', - 'high', - 'low', 'moderate', 'unhealthy', + 'hazardous', ]), }), 'config_entry_id': , @@ -314,11 +296,9 @@ 'friendly_name': 'Home Air quality day 4', 'options': list([ 'good', - 'hazardous', - 'high', - 'low', 'moderate', 'unhealthy', + 'hazardous', ]), }), 'context': , From d94bdb7ecd7048db31de504db7d2839560e273ed Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sat, 8 Mar 2025 20:15:56 +0100 Subject: [PATCH 2380/3148] Fix MQTT JSON light not reporting color temp status if color is not supported (#140113) --- .../components/mqtt/light/schema_json.py | 3 +- tests/components/mqtt/test_light_json.py | 59 +++++++++++++++++++ 2 files changed, 60 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/mqtt/light/schema_json.py b/homeassistant/components/mqtt/light/schema_json.py index 4473385d550..d18da9e917a 100644 --- a/homeassistant/components/mqtt/light/schema_json.py +++ b/homeassistant/components/mqtt/light/schema_json.py @@ -31,7 +31,6 @@ from homeassistant.components.light import ( LightEntity, LightEntityFeature, brightness_supported, - color_supported, valid_supported_color_modes, ) from homeassistant.const import ( @@ -293,7 +292,7 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity): elif values["state"] is None: self._attr_is_on = None - if color_supported(self.supported_color_modes) and "color_mode" in values: + if "color_mode" in values: self._update_color(values) if brightness_supported(self.supported_color_modes): diff --git a/tests/components/mqtt/test_light_json.py b/tests/components/mqtt/test_light_json.py index bcf9d4bd736..67d382826ae 100644 --- a/tests/components/mqtt/test_light_json.py +++ b/tests/components/mqtt/test_light_json.py @@ -432,6 +432,65 @@ async def test_brightness_only( assert state.state == STATE_OFF +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + light.DOMAIN: { + "schema": "json", + "name": "test", + "state_topic": "test_light_rgb", + "command_topic": "test_light_rgb/set", + "supported_color_modes": ["color_temp"], + } + } + }, + ], +) +async def test_color_temp_only( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test a light that only support color_temp as supported color mode.""" + await mqtt_mock_entry() + + state = hass.states.get("light.test") + assert state.state == STATE_UNKNOWN + assert state.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES) == [ + light.ColorMode.COLOR_TEMP + ] + expected_features = ( + light.LightEntityFeature.FLASH | light.LightEntityFeature.TRANSITION + ) + assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == expected_features + assert state.attributes.get("rgb_color") is None + assert state.attributes.get("brightness") is None + assert state.attributes.get("color_temp_kelvin") is None + assert state.attributes.get("effect") is None + assert state.attributes.get("xy_color") is None + assert state.attributes.get("hs_color") is None + + async_fire_mqtt_message( + hass, + "test_light_rgb", + '{"state":"ON", "color_mode": "color_temp", "color_temp": 250, "brightness": 50}', + ) + + state = hass.states.get("light.test") + assert state.state == STATE_ON + assert state.attributes.get("rgb_color") == (255, 206, 166) + assert state.attributes.get("brightness") == 50 + assert state.attributes.get("color_temp_kelvin") == 4000 + assert state.attributes.get("effect") is None + assert state.attributes.get("xy_color") == (0.42, 0.365) + assert state.attributes.get("hs_color") == (26.812, 34.87) + + async_fire_mqtt_message(hass, "test_light_rgb", '{"state":"OFF"}') + + state = hass.states.get("light.test") + assert state.state == STATE_OFF + + @pytest.mark.parametrize( "hass_config", [ From e54febdc1e4effe9d609939ba48040cece5e552f Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 8 Mar 2025 20:16:21 +0100 Subject: [PATCH 2381/3148] Add template function: typeof (#140081) --- homeassistant/helpers/template.py | 7 +++++++ tests/helpers/test_template.py | 27 +++++++++++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index ab115203e66..e5a155a5c36 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -2760,6 +2760,11 @@ def shuffle(*args: Any, seed: Any = None) -> MutableSequence[Any]: return items +def typeof(value: Any) -> Any: + """Return the type of value passed to debug types.""" + return value.__class__.__name__ + + class TemplateContextManager(AbstractContextManager): """Context manager to store template being parsed or rendered in a ContextVar.""" @@ -2961,6 +2966,7 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): self.filters["version"] = version self.filters["contains"] = contains self.filters["shuffle"] = shuffle + self.filters["typeof"] = typeof self.globals["log"] = logarithm self.globals["sin"] = sine self.globals["cos"] = cosine @@ -2999,6 +3005,7 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): self.globals["version"] = version self.globals["zip"] = zip self.globals["shuffle"] = shuffle + self.globals["typeof"] = typeof self.tests["is_number"] = is_number self.tests["list"] = _is_list self.tests["set"] = _is_set diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index 28391d97a3c..5ae821bce24 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -6724,3 +6724,30 @@ def test_shuffle(hass: HomeAssistant) -> None: with pytest.raises(TemplateError): template.Template("{{ shuffle() }}", hass).async_render() + + +def test_typeof(hass: HomeAssistant) -> None: + """Test the typeof debug filter/function.""" + assert template.Template("{{ True | typeof }}", hass).async_render() == "bool" + assert template.Template("{{ typeof(True) }}", hass).async_render() == "bool" + + assert template.Template("{{ [1, 2, 3] | typeof }}", hass).async_render() == "list" + assert template.Template("{{ typeof([1, 2, 3]) }}", hass).async_render() == "list" + + assert template.Template("{{ 1 | typeof }}", hass).async_render() == "int" + assert template.Template("{{ typeof(1) }}", hass).async_render() == "int" + + assert template.Template("{{ 1.1 | typeof }}", hass).async_render() == "float" + assert template.Template("{{ typeof(1.1) }}", hass).async_render() == "float" + + assert template.Template("{{ None | typeof }}", hass).async_render() == "NoneType" + assert template.Template("{{ typeof(None) }}", hass).async_render() == "NoneType" + + assert ( + template.Template("{{ 'Home Assistant' | typeof }}", hass).async_render() + == "str" + ) + assert ( + template.Template("{{ typeof('Home Assistant') }}", hass).async_render() + == "str" + ) From e0cff8de84f3157d803f0c11d37b98b59d5a79f2 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sat, 8 Mar 2025 21:21:24 +0100 Subject: [PATCH 2382/3148] Fix typo "an problem" in `nmbs` integration (#140151) --- homeassistant/components/nmbs/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/nmbs/strings.json b/homeassistant/components/nmbs/strings.json index 3e7aa8d05bd..ac11026577a 100644 --- a/homeassistant/components/nmbs/strings.json +++ b/homeassistant/components/nmbs/strings.json @@ -29,7 +29,7 @@ "issues": { "deprecated_yaml_import_issue_station_not_found": { "title": "The {integration_title} YAML configuration import failed", - "description": "Configuring {integration_title} using YAML is being removed but there was an problem importing your YAML configuration.\n\nThe used station \"{station_name}\" could not be found. Fix it or remove the {integration_title} YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually." + "description": "Configuring {integration_title} using YAML is being removed but there was a problem importing your YAML configuration.\n\nThe used station \"{station_name}\" could not be found. Fix it or remove the {integration_title} YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually." } } } From 9bbf4fe8c1cfdfd57d1f36f4f7a51169d748fa2b Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sat, 8 Mar 2025 21:21:46 +0100 Subject: [PATCH 2383/3148] Make spelling of "MELCloud" consistent, fix typo "an connection" (#140150) --- homeassistant/components/melcloud/strings.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/melcloud/strings.json b/homeassistant/components/melcloud/strings.json index 19ef0b76aad..8c168295e88 100644 --- a/homeassistant/components/melcloud/strings.json +++ b/homeassistant/components/melcloud/strings.json @@ -11,20 +11,20 @@ }, "reauth_confirm": { "title": "[%key:common::config_flow::title::reauth%]", - "description": "The Melcloud integration needs to re-authenticate your connection details", + "description": "The MELCloud integration needs to re-authenticate your connection details", "data": { "username": "[%key:common::config_flow::data::email%]", "password": "[%key:common::config_flow::data::password%]" } }, "reconfigure": { - "title": "Reconfigure your MelCloud", + "title": "Reconfigure your MELCloud", "description": "Reconfigure the entry to obtain a new token, for your account: `{username}`.", "data": { "password": "[%key:common::config_flow::data::password%]" }, "data_description": { - "password": "Enter the (new) password for MelCloud." + "password": "Enter the (new) password for MELCloud." } } }, @@ -70,7 +70,7 @@ }, "deprecated_yaml_import_issue_cannot_connect": { "title": "The MELCloud YAML configuration import failed", - "description": "Configuring MELCloud using YAML is being removed but there was an connection error importing your YAML configuration.\n\nEnsure connection to MELCloud works and restart Home Assistant to try again or remove the MELCloud YAML configuration from your configuration.yaml file and continue to [set up the integration](/config/integrations/dashboard/add?domain=melcoud) manually." + "description": "Configuring MELCloud using YAML is being removed but there was a connection error importing your YAML configuration.\n\nEnsure connection to MELCloud works and restart Home Assistant to try again or remove the MELCloud YAML configuration from your configuration.yaml file and continue to [set up the integration](/config/integrations/dashboard/add?domain=melcoud) manually." } }, "entity": { From 726bd5b01208d25fc1dc011a078aabf611bcaed5 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sat, 8 Mar 2025 21:22:06 +0100 Subject: [PATCH 2384/3148] Fix typo "an connection" in `aftership` integration (#140148) --- homeassistant/components/aftership/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/aftership/strings.json b/homeassistant/components/aftership/strings.json index ace8eb6d2d3..c3817a0cd24 100644 --- a/homeassistant/components/aftership/strings.json +++ b/homeassistant/components/aftership/strings.json @@ -51,7 +51,7 @@ "issues": { "deprecated_yaml_import_issue_cannot_connect": { "title": "The {integration_title} YAML configuration import failed", - "description": "Configuring {integration_title} using YAML is being removed but there was an connection error importing your YAML configuration.\n\nEnsure connection to {integration_title} works and restart Home Assistant to try again or remove the {integration_title} YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually." + "description": "Configuring {integration_title} using YAML is being removed but there was a connection error importing your YAML configuration.\n\nEnsure connection to {integration_title} works and restart Home Assistant to try again or remove the {integration_title} YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually." } } } From 40f92bac93751375fa753260d1317b42e1a2272d Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sat, 8 Mar 2025 21:22:30 +0100 Subject: [PATCH 2385/3148] Fix typo "an comma" in `doorbird` integration (#140146) --- homeassistant/components/doorbird/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/doorbird/strings.json b/homeassistant/components/doorbird/strings.json index 090ba4f161f..ad43e8c1c1c 100644 --- a/homeassistant/components/doorbird/strings.json +++ b/homeassistant/components/doorbird/strings.json @@ -6,7 +6,7 @@ "events": "Comma separated list of events." }, "data_description": { - "events": "Add an comma separated event name for each event you wish to track. After entering them here, use the DoorBird app to assign them to a specific event.\n\nExample: somebody_pressed_the_button, motion" + "events": "Add a comma separated event name for each event you wish to track. After entering them here, use the DoorBird app to assign them to a specific event.\n\nExample: somebody_pressed_the_button, motion" } } } From b0b28bd98ad6d7a480eab6ef00f96a240b8ea516 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sat, 8 Mar 2025 21:23:27 +0100 Subject: [PATCH 2386/3148] Replace typo "an code" with "alarm code" in `elkm1` integration (#140143) The use of "alarm code" matches the online docs, too. --- homeassistant/components/elkm1/strings.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/elkm1/strings.json b/homeassistant/components/elkm1/strings.json index f184483646d..b50c1817838 100644 --- a/homeassistant/components/elkm1/strings.json +++ b/homeassistant/components/elkm1/strings.json @@ -53,7 +53,7 @@ "fields": { "code": { "name": "Code", - "description": "An code to authorize the bypass of the alarm control panel." + "description": "Alarm code to authorize the bypass of the alarm control panel." } } }, @@ -63,7 +63,7 @@ "fields": { "code": { "name": "Code", - "description": "An code to authorize the bypass clear of the alarm control panel." + "description": "Alarm code to authorize the bypass clear of the alarm control panel." } } }, @@ -73,7 +73,7 @@ "fields": { "code": { "name": "Code", - "description": "An code to arm the alarm control panel." + "description": "Alarm code to arm the alarm control panel." } } }, @@ -181,7 +181,7 @@ "fields": { "code": { "name": "Code", - "description": "An code to authorize the bypass of the zone." + "description": "Alarm code to authorize the bypass of the zone." } } }, From be67f320b5fe6e7a6174aca46d49bf296bd4be38 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sat, 8 Mar 2025 21:23:44 +0100 Subject: [PATCH 2387/3148] Fix typos in `homeassistant_hardware` strings (#140154) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - "addon" -> "add-on" - "internet" -> "Internet" - "an Thread border router" -> "a …" --- .../components/homeassistant_hardware/strings.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/homeassistant_hardware/strings.json b/homeassistant/components/homeassistant_hardware/strings.json index de328a54bb7..5456f418c75 100644 --- a/homeassistant/components/homeassistant_hardware/strings.json +++ b/homeassistant/components/homeassistant_hardware/strings.json @@ -40,7 +40,7 @@ }, "otbr_failed": { "title": "Failed to setup OpenThread Border Router", - "description": "The OpenThread Border Router add-on installation was unsuccessful. Ensure no other software is trying to communicate with the {model}, you have access to the internet and can install other add-ons, and try again. Check the Supervisor logs if the problem persists." + "description": "The OpenThread Border Router add-on installation was unsuccessful. Ensure no other software is trying to communicate with the {model}, you have access to the Internet and can install other add-ons, and try again. Check the Supervisor logs if the problem persists." }, "confirm_otbr": { "title": "OpenThread Border Router setup complete", @@ -48,16 +48,16 @@ } }, "abort": { - "not_hassio_thread": "The OpenThread Border Router addon can only be installed with Home Assistant OS. If you would like to use the {model} as an Thread border router, please flash the firmware manually using the [web flasher]({docs_web_flasher_url}) and set up OpenThread Border Router to communicate with it.", + "not_hassio_thread": "The OpenThread Border Router add-on can only be installed with Home Assistant OS. If you would like to use the {model} as a Thread border router, please flash the firmware manually using the [web flasher]({docs_web_flasher_url}) and set up OpenThread Border Router to communicate with it.", "otbr_addon_already_running": "The OpenThread Border Router add-on is already running, it cannot be installed again.", "zha_still_using_stick": "This {model} is in use by the Zigbee Home Automation integration. Please migrate your Zigbee network to another adapter or delete the integration and try again.", "otbr_still_using_stick": "This {model} is in use by the OpenThread Border Router add-on. If you use the Thread network, make sure you have alternative border routers. Uninstall the add-on and try again.", - "unsupported_firmware": "The radio firmware on your {model} could not be determined. Make sure that no other integration or addon is currently trying to communicate with the device. If you are running Home Assistant OS in a virtual machine or in Docker, please make sure that permissions are set correctly for the device." + "unsupported_firmware": "The radio firmware on your {model} could not be determined. Make sure that no other integration or add-on is currently trying to communicate with the device. If you are running Home Assistant OS in a virtual machine or in Docker, please make sure that permissions are set correctly for the device." }, "progress": { - "install_zigbee_flasher_addon": "The Silicon Labs Flasher addon is installed, this may take a few minutes.", + "install_zigbee_flasher_addon": "The Silicon Labs Flasher add-on is installed, this may take a few minutes.", "run_zigbee_flasher_addon": "Please wait while Zigbee firmware is installed to your {model}, this will take a few minutes. Do not make any changes to your hardware or software until this finishes.", - "uninstall_zigbee_flasher_addon": "The Silicon Labs Flasher addon is being removed." + "uninstall_zigbee_flasher_addon": "The Silicon Labs Flasher add-on is being removed." } } }, From 62c025fd1268f78f8f3ca972f5ddf51dc2a73228 Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Sat, 8 Mar 2025 21:46:15 +0100 Subject: [PATCH 2388/3148] Use HAs configured timezone for KNX expose time (#140158) * Use HAs configured timezone for KNX expose time * use `hass.config.async_set_time_zone` in tests --- homeassistant/components/knx/expose.py | 3 ++- tests/components/knx/test_expose.py | 7 ++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/knx/expose.py b/homeassistant/components/knx/expose.py index 6585b848d8a..461e6f25879 100644 --- a/homeassistant/components/knx/expose.py +++ b/homeassistant/components/knx/expose.py @@ -30,6 +30,7 @@ from homeassistant.exceptions import TemplateError from homeassistant.helpers.event import async_track_state_change_event from homeassistant.helpers.template import Template from homeassistant.helpers.typing import ConfigType, StateType +from homeassistant.util import dt as dt_util from .const import CONF_RESPOND_TO_READ, KNX_ADDRESS from .schema import ExposeSchema @@ -217,7 +218,7 @@ class KNXExposeTime: self.device = xknx_device_cls( self.xknx, name=expose_type.capitalize(), - localtime=True, + localtime=dt_util.get_default_time_zone(), group_address=config[KNX_ADDRESS], ) diff --git a/tests/components/knx/test_expose.py b/tests/components/knx/test_expose.py index 0fd790a3e33..f7a3f4e94f2 100644 --- a/tests/components/knx/test_expose.py +++ b/tests/components/knx/test_expose.py @@ -348,19 +348,20 @@ async def test_expose_conversion_exception( ) -@freeze_time("2022-1-7 9:13:14") +@freeze_time("2022-1-7 9:13:14") # UTC -> +1h = Vienna in winter (9 -> 0xA) @pytest.mark.parametrize( ("time_type", "raw"), [ - ("time", (0xA9, 0x0D, 0x0E)), # localtime includes day of week + ("time", (0xAA, 0x0D, 0x0E)), # localtime includes day of week ("date", (0x07, 0x01, 0x16)), - ("datetime", (0x7A, 0x1, 0x7, 0xA9, 0xD, 0xE, 0x20, 0xC0)), + ("datetime", (0x7A, 0x1, 0x7, 0xAA, 0xD, 0xE, 0x20, 0xC0)), ], ) async def test_expose_with_date( hass: HomeAssistant, knx: KNXTestKit, time_type: str, raw: tuple[int, ...] ) -> None: """Test an expose with a date.""" + await hass.config.async_set_time_zone("Europe/Vienna") await knx.setup_integration( { CONF_KNX_EXPOSE: { From 9aa8a786a5148125258a5c071272e5365ef9a7f4 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 8 Mar 2025 22:14:27 +0100 Subject: [PATCH 2389/3148] Add template function: flatten (#140157) --- homeassistant/helpers/template.py | 21 ++++++++++++++++++ tests/helpers/test_template.py | 37 +++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index e5a155a5c36..357fe15f3be 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -2765,6 +2765,25 @@ def typeof(value: Any) -> Any: return value.__class__.__name__ +def flatten(value: Iterable[Any], levels: int | None = None) -> list[Any]: + """Flattens list of lists.""" + if not isinstance(value, Iterable) or isinstance(value, str): + raise TypeError(f"flatten expected a list, got {type(value).__name__}") + + flattened: list[Any] = [] + for item in value: + if isinstance(item, Iterable) and not isinstance(item, str): + if levels is None: + flattened.extend(flatten(item)) + elif levels >= 1: + flattened.extend(flatten(item, levels=(levels - 1))) + else: + flattened.append(item) + else: + flattened.append(item) + return flattened + + class TemplateContextManager(AbstractContextManager): """Context manager to store template being parsed or rendered in a ContextVar.""" @@ -2967,6 +2986,7 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): self.filters["contains"] = contains self.filters["shuffle"] = shuffle self.filters["typeof"] = typeof + self.filters["flatten"] = flatten self.globals["log"] = logarithm self.globals["sin"] = sine self.globals["cos"] = cosine @@ -3006,6 +3026,7 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): self.globals["zip"] = zip self.globals["shuffle"] = shuffle self.globals["typeof"] = typeof + self.globals["flatten"] = flatten self.tests["is_number"] = is_number self.tests["list"] = _is_list self.tests["set"] = _is_set diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index 5ae821bce24..f9154b23bad 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -6751,3 +6751,40 @@ def test_typeof(hass: HomeAssistant) -> None: template.Template("{{ typeof('Home Assistant') }}", hass).async_render() == "str" ) + + +def test_flatten(hass: HomeAssistant) -> None: + """Test the flatten function and filter.""" + assert template.Template( + "{{ flatten([1, [2, [3]], 4, [5 , 6]]) }}", hass + ).async_render() == [1, 2, 3, 4, 5, 6] + + assert template.Template( + "{{ [1, [2, [3]], 4, [5 , 6]] | flatten }}", hass + ).async_render() == [1, 2, 3, 4, 5, 6] + + assert template.Template( + "{{ flatten([1, [2, [3]], 4, [5 , 6]], 1) }}", hass + ).async_render() == [1, 2, [3], 4, 5, 6] + + assert template.Template( + "{{ flatten([1, [2, [3]], 4, [5 , 6]], levels=1) }}", hass + ).async_render() == [1, 2, [3], 4, 5, 6] + + assert template.Template( + "{{ [1, [2, [3]], 4, [5 , 6]] | flatten(1) }}", hass + ).async_render() == [1, 2, [3], 4, 5, 6] + + assert template.Template( + "{{ [1, [2, [3]], 4, [5 , 6]] | flatten(levels=1) }}", hass + ).async_render() == [1, 2, [3], 4, 5, 6] + + assert template.Template("{{ flatten([]) }}", hass).async_render() == [] + + assert template.Template("{{ [] | flatten }}", hass).async_render() == [] + + with pytest.raises(TemplateError): + template.Template("{{ 'string' | flatten }}", hass).async_render() + + with pytest.raises(TemplateError): + template.Template("{{ flatten() }}", hass).async_render() From 0d3011f0fbb004cdb038ac31ea0fc0ada744ce09 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sat, 8 Mar 2025 23:04:05 +0100 Subject: [PATCH 2390/3148] Revert "Check if the unit of measurement is valid before creating the entity" (#140155) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Revert "Check if the unit of measurement is valid before creating the entity …" This reverts commit 99e1a7a676b2fc14f9f8a8db64bee2840fae4646. --- homeassistant/components/mqtt/sensor.py | 15 -------------- tests/components/mqtt/test_sensor.py | 26 ------------------------- 2 files changed, 41 deletions(-) diff --git a/homeassistant/components/mqtt/sensor.py b/homeassistant/components/mqtt/sensor.py index 432431c96d9..3e8a4fef0fa 100644 --- a/homeassistant/components/mqtt/sensor.py +++ b/homeassistant/components/mqtt/sensor.py @@ -11,7 +11,6 @@ import voluptuous as vol from homeassistant.components import sensor from homeassistant.components.sensor import ( CONF_STATE_CLASS, - DEVICE_CLASS_UNITS, DEVICE_CLASSES_SCHEMA, ENTITY_ID_FORMAT, STATE_CLASSES_SCHEMA, @@ -108,20 +107,6 @@ def validate_sensor_state_and_device_class_config(config: ConfigType) -> ConfigT f"got `{CONF_DEVICE_CLASS}` '{device_class}'" ) - if (device_class := config.get(CONF_DEVICE_CLASS)) is None or ( - unit_of_measurement := config.get(CONF_UNIT_OF_MEASUREMENT) - ) is None: - return config - - if ( - device_class in DEVICE_CLASS_UNITS - and unit_of_measurement not in DEVICE_CLASS_UNITS[device_class] - ): - raise vol.Invalid( - f"The unit of measurement `{unit_of_measurement}` is not valid " - f"together with device class `{device_class}`" - ) - return config diff --git a/tests/components/mqtt/test_sensor.py b/tests/components/mqtt/test_sensor.py index f40082d84be..9226b03a7d2 100644 --- a/tests/components/mqtt/test_sensor.py +++ b/tests/components/mqtt/test_sensor.py @@ -870,32 +870,6 @@ async def test_invalid_device_class( assert "expected SensorDeviceClass or one of" in caplog.text -@pytest.mark.parametrize( - "hass_config", - [ - { - mqtt.DOMAIN: { - sensor.DOMAIN: { - "name": "test", - "state_topic": "test-topic", - "device_class": "energy", - "unit_of_measurement": "ppm", - } - } - } - ], -) -async def test_invalid_unit_of_measurement( - mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture -) -> None: - """Test device_class with invalid unit of measurement.""" - assert await mqtt_mock_entry() - assert ( - "The unit of measurement `ppm` is not valid together with device class `energy`" - in caplog.text - ) - - @pytest.mark.parametrize( "hass_config", [ From ffcc0496f1e7b7a9c673517797513fa5c738e7ec Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 8 Mar 2025 12:52:51 -1000 Subject: [PATCH 2391/3148] Bump aioesphomeapi to 29.4.1 (#140165) changelog: https://github.com/esphome/aioesphomeapi/compare/v29.4.0...v29.4.1 --- 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 aa0f6f3752b..25d9e407044 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -16,7 +16,7 @@ "loggers": ["aioesphomeapi", "noiseprotocol", "bleak_esphome"], "mqtt": ["esphome/discover/#"], "requirements": [ - "aioesphomeapi==29.4.0", + "aioesphomeapi==29.4.1", "esphome-dashboard-api==1.2.3", "bleak-esphome==2.11.0" ], diff --git a/requirements_all.txt b/requirements_all.txt index aeda2ed360b..675bb5f66f5 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==29.4.0 +aioesphomeapi==29.4.1 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b955e4a134d..f00c318ce25 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -231,7 +231,7 @@ aioelectricitymaps==0.4.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==29.4.0 +aioesphomeapi==29.4.1 # homeassistant.components.flo aioflo==2021.11.0 From f0c5e00cc1a2b7b5d890c86ad2806ae13d7b9863 Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Sun, 9 Mar 2025 04:23:24 +0100 Subject: [PATCH 2392/3148] Fix conversation trigger with variables (#140066) --- homeassistant/helpers/trigger.py | 12 ++++++------ tests/components/conversation/test_trigger.py | 1 + 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/homeassistant/helpers/trigger.py b/homeassistant/helpers/trigger.py index 67e9010df79..a27c85a5c58 100644 --- a/homeassistant/helpers/trigger.py +++ b/homeassistant/helpers/trigger.py @@ -265,18 +265,18 @@ def _trigger_action_wrapper( while isinstance(check_func, functools.partial): check_func = check_func.func - wrapper_func: Callable[..., None] | Callable[..., Coroutine[Any, Any, None]] + wrapper_func: Callable[..., Any] | Callable[..., Coroutine[Any, Any, Any]] if asyncio.iscoroutinefunction(check_func): - async_action = cast(Callable[..., Coroutine[Any, Any, None]], action) + async_action = cast(Callable[..., Coroutine[Any, Any, Any]], action) @functools.wraps(async_action) async def async_with_vars( run_variables: dict[str, Any], context: Context | None = None - ) -> None: + ) -> Any: """Wrap action with extra vars.""" trigger_variables = conf[CONF_VARIABLES] run_variables.update(trigger_variables.async_render(hass, run_variables)) - await action(run_variables, context) + return await action(run_variables, context) wrapper_func = async_with_vars @@ -285,11 +285,11 @@ def _trigger_action_wrapper( @functools.wraps(action) async def with_vars( run_variables: dict[str, Any], context: Context | None = None - ) -> None: + ) -> Any: """Wrap action with extra vars.""" trigger_variables = conf[CONF_VARIABLES] run_variables.update(trigger_variables.async_render(hass, run_variables)) - action(run_variables, context) + return action(run_variables, context) if is_callback(check_func): with_vars = callback(with_vars) diff --git a/tests/components/conversation/test_trigger.py b/tests/components/conversation/test_trigger.py index 3aa8ae2939f..a01f4cd8112 100644 --- a/tests/components/conversation/test_trigger.py +++ b/tests/components/conversation/test_trigger.py @@ -104,6 +104,7 @@ async def test_response(hass: HomeAssistant) -> None: "trigger": { "platform": "conversation", "command": ["Open the pod bay door Hal"], + "variables": {"name": "Dr. David Bowman"}, }, "action": { "set_conversation_response": response, From 6675b497bd8680b8b1d54fd9b117af8ce288f0b8 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sat, 8 Mar 2025 19:28:35 -0800 Subject: [PATCH 2393/3148] Improve LLM tool descriptions for brightness and volume percentage (#138685) * Improve tool descriptions for brightness and volume percentage * Address lint errors * Update intent.py to revert of a light * Create explicit types to make intent slots more future proof * Remove comments about slot type --------- Co-authored-by: Franck Nijhof --- homeassistant/components/light/intent.py | 18 ++-- .../components/media_player/intent.py | 13 ++- homeassistant/helpers/intent.py | 84 ++++++++++++------- 3 files changed, 74 insertions(+), 41 deletions(-) diff --git a/homeassistant/components/light/intent.py b/homeassistant/components/light/intent.py index 83f2ee58b5e..250e1f5b2c1 100644 --- a/homeassistant/components/light/intent.py +++ b/homeassistant/components/light/intent.py @@ -28,13 +28,21 @@ async def async_setup_intents(hass: HomeAssistant) -> None: DOMAIN, SERVICE_TURN_ON, optional_slots={ - ("color", ATTR_RGB_COLOR): color_util.color_name_to_rgb, - ("temperature", ATTR_COLOR_TEMP_KELVIN): cv.positive_int, - ("brightness", ATTR_BRIGHTNESS_PCT): vol.All( - vol.Coerce(int), vol.Range(0, 100) + "color": intent.IntentSlotInfo( + service_data_name=ATTR_RGB_COLOR, + value_schema=color_util.color_name_to_rgb, + ), + "temperature": intent.IntentSlotInfo( + service_data_name=ATTR_COLOR_TEMP_KELVIN, + value_schema=cv.positive_int, + ), + "brightness": intent.IntentSlotInfo( + service_data_name=ATTR_BRIGHTNESS_PCT, + description="The brightness percentage of the light between 0 and 100, where 0 is off and 100 is fully lit", + value_schema=vol.All(vol.Coerce(int), vol.Range(0, 100)), ), }, - description="Sets the brightness or color of a light", + description="Sets the brightness percentage or color of a light", platforms={DOMAIN}, ), ) diff --git a/homeassistant/components/media_player/intent.py b/homeassistant/components/media_player/intent.py index edfab2a668f..af37c0d68bb 100644 --- a/homeassistant/components/media_player/intent.py +++ b/homeassistant/components/media_player/intent.py @@ -96,11 +96,16 @@ async def async_setup_intents(hass: HomeAssistant) -> None: required_states={MediaPlayerState.PLAYING}, required_features=MediaPlayerEntityFeature.VOLUME_SET, required_slots={ - ATTR_MEDIA_VOLUME_LEVEL: vol.All( - vol.Coerce(int), vol.Range(min=0, max=100), lambda val: val / 100 - ) + ATTR_MEDIA_VOLUME_LEVEL: intent.IntentSlotInfo( + description="The volume percentage of the media player", + value_schema=vol.All( + vol.Coerce(int), + vol.Range(min=0, max=100), + lambda val: val / 100, + ), + ), }, - description="Sets the volume of a media player", + description="Sets the volume percentage of a media player", platforms={DOMAIN}, device_classes={MediaPlayerDeviceClass}, ), diff --git a/homeassistant/helpers/intent.py b/homeassistant/helpers/intent.py index 0bb96615d3f..75572194bb8 100644 --- a/homeassistant/helpers/intent.py +++ b/homeassistant/helpers/intent.py @@ -38,7 +38,7 @@ from .typing import VolSchemaType _LOGGER = logging.getLogger(__name__) type _SlotsType = dict[str, Any] type _IntentSlotsType = dict[ - str | tuple[str, str], VolSchemaType | Callable[[Any], Any] + str | tuple[str, str], IntentSlotInfo | VolSchemaType | Callable[[Any], Any] ] INTENT_TURN_OFF = "HassTurnOff" @@ -874,6 +874,34 @@ def non_empty_string(value: Any) -> str: return value_str +@dataclass(kw_only=True) +class IntentSlotInfo: + """Details about how intent slots are processed and validated.""" + + service_data_name: str | None = None + """Optional name of the service data input to map to this slot.""" + + description: str | None = None + """Human readable description of the slot.""" + + value_schema: VolSchemaType | Callable[[Any], Any] = vol.Any + """Validator for the slot.""" + + +def _convert_slot_info( + key: str | tuple[str, str], + value: IntentSlotInfo | VolSchemaType | Callable[[Any], Any], +) -> tuple[str, IntentSlotInfo]: + """Create an IntentSlotInfo from the various supported input arguments.""" + if isinstance(value, IntentSlotInfo): + if not isinstance(key, str): + raise TypeError("Tuple key and IntentSlotDescription value not supported") + return key, value + if isinstance(key, tuple): + return key[0], IntentSlotInfo(service_data_name=key[1], value_schema=value) + return key, IntentSlotInfo(value_schema=value) + + class DynamicServiceIntentHandler(IntentHandler): """Service Intent handler registration (dynamic). @@ -907,23 +935,14 @@ class DynamicServiceIntentHandler(IntentHandler): self.platforms = platforms self.device_classes = device_classes - self.required_slots: _IntentSlotsType = {} - if required_slots: - for key, value_schema in required_slots.items(): - if isinstance(key, str): - # Slot name/service data key - key = (key, key) - - self.required_slots[key] = value_schema - - self.optional_slots: _IntentSlotsType = {} - if optional_slots: - for key, value_schema in optional_slots.items(): - if isinstance(key, str): - # Slot name/service data key - key = (key, key) - - self.optional_slots[key] = value_schema + self.required_slots: dict[str, IntentSlotInfo] = dict( + _convert_slot_info(key, value) + for key, value in (required_slots or {}).items() + ) + self.optional_slots: dict[str, IntentSlotInfo] = dict( + _convert_slot_info(key, value) + for key, value in (optional_slots or {}).items() + ) @cached_property def slot_schema(self) -> dict: @@ -964,16 +983,20 @@ class DynamicServiceIntentHandler(IntentHandler): if self.required_slots: slot_schema.update( { - vol.Required(key[0]): validator - for key, validator in self.required_slots.items() + vol.Required( + key, description=slot_info.description + ): slot_info.value_schema + for key, slot_info in self.required_slots.items() } ) if self.optional_slots: slot_schema.update( { - vol.Optional(key[0]): validator - for key, validator in self.optional_slots.items() + vol.Optional( + key, description=slot_info.description + ): slot_info.value_schema + for key, slot_info in self.optional_slots.items() } ) @@ -1156,18 +1179,15 @@ class DynamicServiceIntentHandler(IntentHandler): service_data: dict[str, Any] = {ATTR_ENTITY_ID: state.entity_id} if self.required_slots: - service_data.update( - { - key[1]: intent_obj.slots[key[0]]["value"] - for key in self.required_slots - } - ) + for key, slot_info in self.required_slots.items(): + service_data[slot_info.service_data_name or key] = intent_obj.slots[ + key + ]["value"] if self.optional_slots: - for key in self.optional_slots: - value = intent_obj.slots.get(key[0]) - if value: - service_data[key[1]] = value["value"] + for key, slot_info in self.optional_slots.items(): + if value := intent_obj.slots.get(key): + service_data[slot_info.service_data_name or key] = value["value"] await self._run_then_background( hass.async_create_task_internal( From aa2a1fc5ef748b827eff6ef2f5797248a348332e Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Sun, 9 Mar 2025 04:42:57 +0100 Subject: [PATCH 2394/3148] Fix not available source in Onkyo (#140175) --- homeassistant/components/onkyo/media_player.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/onkyo/media_player.py b/homeassistant/components/onkyo/media_player.py index 8f9587bc426..f7fe83c57a3 100644 --- a/homeassistant/components/onkyo/media_player.py +++ b/homeassistant/components/onkyo/media_player.py @@ -588,7 +588,7 @@ class OnkyoMediaPlayer(MediaPlayerEntity): self._attr_volume_level = min(1, volume_level) elif command in ["muting", "audio-muting"]: self._attr_is_volume_muted = bool(value == "on") - elif command in ["selector", "input-selector"]: + elif command in ["selector", "input-selector"] and value != "N/A": self._parse_source(value) self._query_av_info_delayed() elif command == "hdmi-output-selector": From 60db3555771117eeaef81204e6366185cdf7c8bf Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 8 Mar 2025 20:13:09 -1000 Subject: [PATCH 2395/3148] Bump aioshelly to 13.2.0 (#140178) Adds support for getting the Bluetooth MAC changelog: https://github.com/home-assistant-libs/aioshelly/compare/13.1.0...13.2.0 --- homeassistant/components/shelly/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/shelly/manifest.json b/homeassistant/components/shelly/manifest.json index 722fd4c128a..c8ac5520b13 100644 --- a/homeassistant/components/shelly/manifest.json +++ b/homeassistant/components/shelly/manifest.json @@ -8,7 +8,7 @@ "integration_type": "device", "iot_class": "local_push", "loggers": ["aioshelly"], - "requirements": ["aioshelly==13.1.0"], + "requirements": ["aioshelly==13.2.0"], "zeroconf": [ { "type": "_http._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index 675bb5f66f5..950d4aa12cb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -371,7 +371,7 @@ aioruuvigateway==0.1.0 aiosenz==1.0.0 # homeassistant.components.shelly -aioshelly==13.1.0 +aioshelly==13.2.0 # homeassistant.components.skybell aioskybell==22.7.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f00c318ce25..503d33e8a8b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -353,7 +353,7 @@ aioruuvigateway==0.1.0 aiosenz==1.0.0 # homeassistant.components.shelly -aioshelly==13.1.0 +aioshelly==13.2.0 # homeassistant.components.skybell aioskybell==22.7.0 From 4e7dd92a3d8b83bfeffb6ab0ee01e7340d73400f Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Sun, 9 Mar 2025 12:27:02 +0100 Subject: [PATCH 2396/3148] Add Ogemray virtual integration (#140185) --- homeassistant/components/ogemray/__init__.py | 1 + homeassistant/components/ogemray/manifest.json | 6 ++++++ homeassistant/generated/integrations.json | 5 +++++ 3 files changed, 12 insertions(+) create mode 100644 homeassistant/components/ogemray/__init__.py create mode 100644 homeassistant/components/ogemray/manifest.json diff --git a/homeassistant/components/ogemray/__init__.py b/homeassistant/components/ogemray/__init__.py new file mode 100644 index 00000000000..94e19234a6b --- /dev/null +++ b/homeassistant/components/ogemray/__init__.py @@ -0,0 +1 @@ +"""Ogemray virtual integration.""" diff --git a/homeassistant/components/ogemray/manifest.json b/homeassistant/components/ogemray/manifest.json new file mode 100644 index 00000000000..6a8eb315c7a --- /dev/null +++ b/homeassistant/components/ogemray/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "ogemray", + "name": "Ogemray", + "integration_type": "virtual", + "supported_by": "shelly" +} diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index eee1d22dcb0..b916526aaf3 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -4426,6 +4426,11 @@ "config_flow": false, "iot_class": "local_polling" }, + "ogemray": { + "name": "Ogemray", + "integration_type": "virtual", + "supported_by": "shelly" + }, "ohmconnect": { "name": "OhmConnect", "integration_type": "hub", From d9d47f7203569e105c71492aaa2b5b69b76b787b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 9 Mar 2025 01:28:56 -1000 Subject: [PATCH 2397/3148] Migrate shelly Bluetooth scanner to use correct MAC address (#140180) --- homeassistant/components/shelly/__init__.py | 5 +++-- .../components/shelly/bluetooth/__init__.py | 4 +--- homeassistant/components/shelly/coordinator.py | 12 +++++++++++- homeassistant/components/shelly/diagnostics.py | 4 +--- tests/components/shelly/test_diagnostics.py | 6 +++--- tests/components/shelly/test_init.py | 7 +++++-- 6 files changed, 24 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/shelly/__init__.py b/homeassistant/components/shelly/__init__.py index 5ca58ec7d01..55b75b3face 100644 --- a/homeassistant/components/shelly/__init__.py +++ b/homeassistant/components/shelly/__init__.py @@ -12,7 +12,7 @@ from aioshelly.exceptions import ( InvalidAuthError, MacAddressMismatchError, ) -from aioshelly.rpc_device import RpcDevice +from aioshelly.rpc_device import RpcDevice, bluetooth_mac_from_primary_mac import voluptuous as vol from homeassistant.components.bluetooth import async_remove_scanner @@ -339,4 +339,5 @@ async def async_remove_entry(hass: HomeAssistant, entry: ShellyConfigEntry) -> N if get_device_entry_gen(entry) in RPC_GENERATIONS and ( mac_address := entry.unique_id ): - async_remove_scanner(hass, mac_address) + source = dr.format_mac(bluetooth_mac_from_primary_mac(mac_address)).upper() + async_remove_scanner(hass, source) diff --git a/homeassistant/components/shelly/bluetooth/__init__.py b/homeassistant/components/shelly/bluetooth/__init__.py index d7eb020d671..cad1b9f044d 100644 --- a/homeassistant/components/shelly/bluetooth/__init__.py +++ b/homeassistant/components/shelly/bluetooth/__init__.py @@ -9,7 +9,6 @@ from aioshelly.ble.const import BLE_SCAN_RESULT_EVENT, BLE_SCAN_RESULT_VERSION from homeassistant.components.bluetooth import async_register_scanner from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback as hass_callback -from homeassistant.helpers.device_registry import format_mac from ..const import BLEScannerMode @@ -26,8 +25,7 @@ async def async_connect_scanner( """Connect scanner.""" device = coordinator.device entry = coordinator.config_entry - source = format_mac(coordinator.mac).upper() - scanner = create_scanner(source, entry.title) + scanner = create_scanner(coordinator.bluetooth_source, entry.title) unload_callbacks = [ async_register_scanner( hass, diff --git a/homeassistant/components/shelly/coordinator.py b/homeassistant/components/shelly/coordinator.py index 7b4da241043..bebf8efbdd7 100644 --- a/homeassistant/components/shelly/coordinator.py +++ b/homeassistant/components/shelly/coordinator.py @@ -18,6 +18,7 @@ from aioshelly.exceptions import ( RpcCallError, ) from aioshelly.rpc_device import RpcDevice, RpcUpdateType +from aioshelly.rpc_device.utils import bluetooth_mac_from_primary_mac from propcache.api import cached_property from homeassistant.components.bluetooth import async_remove_scanner @@ -496,6 +497,15 @@ class ShellyRpcCoordinator(ShellyCoordinatorBase[RpcDevice]): self._connect_task: asyncio.Task | None = None entry.async_on_unload(entry.add_update_listener(self._async_update_listener)) + @cached_property + def bluetooth_source(self) -> str: + """Return the Bluetooth source address. + + This is the Bluetooth MAC address of the device that is used + for the Bluetooth scanner. + """ + return format_mac(bluetooth_mac_from_primary_mac(self.mac)).upper() + async def async_device_online(self, source: str) -> None: """Handle device going online.""" if not self.sleep_period: @@ -706,7 +716,7 @@ class ShellyRpcCoordinator(ShellyCoordinatorBase[RpcDevice]): ) if ble_scanner_mode == BLEScannerMode.DISABLED and self.connected: await async_stop_scanner(self.device) - async_remove_scanner(self.hass, format_mac(self.mac).upper()) + async_remove_scanner(self.hass, self.bluetooth_source) return if await async_ensure_ble_enabled(self.device): # BLE enable required a reboot, don't bother connecting diff --git a/homeassistant/components/shelly/diagnostics.py b/homeassistant/components/shelly/diagnostics.py index a5fe1f5b6c0..d56a2884e17 100644 --- a/homeassistant/components/shelly/diagnostics.py +++ b/homeassistant/components/shelly/diagnostics.py @@ -8,7 +8,6 @@ from homeassistant.components.bluetooth import async_scanner_by_source from homeassistant.components.diagnostics import async_redact_data from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import format_mac from .coordinator import ShellyConfigEntry from .utils import get_rpc_ws_url @@ -86,8 +85,7 @@ async def async_get_config_entry_diagnostics( if k in ["sys", "wifi"] } - source = format_mac(rpc_coordinator.mac).upper() - if scanner := async_scanner_by_source(hass, source): + if scanner := async_scanner_by_source(hass, rpc_coordinator.bluetooth_source): bluetooth = { "scanner": await scanner.async_diagnostics(), } diff --git a/tests/components/shelly/test_diagnostics.py b/tests/components/shelly/test_diagnostics.py index c0f78d48d9b..3826631c580 100644 --- a/tests/components/shelly/test_diagnostics.py +++ b/tests/components/shelly/test_diagnostics.py @@ -134,17 +134,17 @@ async def test_rpc_config_entry_diagnostics( -62, [], ], - "details": {"source": "12:34:56:78:9A:BC"}, + "details": {"source": "12:34:56:78:9A:BE"}, "name": None, "rssi": -62, } ], "last_detection": ANY, "monotonic_time": ANY, - "name": "Mock Title (12:34:56:78:9A:BC)", + "name": "Mock Title (12:34:56:78:9A:BE)", "scanning": True, "start_time": ANY, - "source": "12:34:56:78:9A:BC", + "source": "12:34:56:78:9A:BE", "time_since_last_device_detection": {"AA:BB:CC:DD:EE:FF": ANY}, "type": "ShellyBLEScanner", } diff --git a/tests/components/shelly/test_init.py b/tests/components/shelly/test_init.py index 036da1bfd64..c9e4ce253e4 100644 --- a/tests/components/shelly/test_init.py +++ b/tests/components/shelly/test_init.py @@ -11,6 +11,7 @@ from aioshelly.exceptions import ( InvalidAuthError, MacAddressMismatchError, ) +from aioshelly.rpc_device.utils import bluetooth_mac_from_primary_mac import pytest from homeassistant.components.shelly.const import ( @@ -27,7 +28,7 @@ from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.const import CONF_HOST, CONF_PORT, STATE_ON, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers import issue_registry as ir -from homeassistant.helpers.device_registry import DeviceRegistry +from homeassistant.helpers.device_registry import DeviceRegistry, format_mac from homeassistant.setup import async_setup_component from . import init_integration, mutate_rpc_device_status @@ -545,4 +546,6 @@ async def test_bluetooth_cleanup_on_remove_entry( await hass.config_entries.async_remove(entry.entry_id) await hass.async_block_till_done() - remove_mock.assert_called_once_with(hass, entry.unique_id.upper()) + remove_mock.assert_called_once_with( + hass, format_mac(bluetooth_mac_from_primary_mac(entry.unique_id)).upper() + ) From 03aff0d6625c40ca8eb3c079c803c18aa21b17d8 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Sun, 9 Mar 2025 13:07:20 +0100 Subject: [PATCH 2398/3148] Use CONF_* const in Shelly tests (#140189) --- tests/components/shelly/__init__.py | 4 +- tests/components/shelly/test_config_flow.py | 237 ++++++++++++-------- tests/components/shelly/test_coordinator.py | 4 +- tests/components/shelly/test_init.py | 18 +- 4 files changed, 163 insertions(+), 100 deletions(-) diff --git a/tests/components/shelly/__init__.py b/tests/components/shelly/__init__.py index 5cba8e5e3b8..ddece280d8a 100644 --- a/tests/components/shelly/__init__.py +++ b/tests/components/shelly/__init__.py @@ -18,7 +18,7 @@ from homeassistant.components.shelly.const import ( RPC_SENSORS_POLLING_INTERVAL, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST +from homeassistant.const import CONF_HOST, CONF_MODEL from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.helpers.device_registry import ( @@ -47,7 +47,7 @@ async def init_integration( data = { CONF_HOST: "192.168.1.37", CONF_SLEEP_PERIOD: sleep_period, - "model": model, + CONF_MODEL: model, } if gen is not None: data[CONF_GEN] = gen diff --git a/tests/components/shelly/test_config_flow.py b/tests/components/shelly/test_config_flow.py index 50b8b552268..0b2d355cfd8 100644 --- a/tests/components/shelly/test_config_flow.py +++ b/tests/components/shelly/test_config_flow.py @@ -18,10 +18,19 @@ from homeassistant import config_entries from homeassistant.components.shelly import MacAddressMismatchError, config_flow from homeassistant.components.shelly.const import ( CONF_BLE_SCANNER_MODE, + CONF_GEN, + CONF_SLEEP_PERIOD, DOMAIN, BLEScannerMode, ) from homeassistant.components.shelly.coordinator import ENTRY_RELOAD_COOLDOWN +from homeassistant.const import ( + CONF_HOST, + CONF_MODEL, + CONF_PASSWORD, + CONF_PORT, + CONF_USERNAME, +) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers.service_info.zeroconf import ( @@ -100,18 +109,18 @@ async def test_form( ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - {"host": "1.1.1.1", "port": port}, + {CONF_HOST: "1.1.1.1", CONF_PORT: port}, ) await hass.async_block_till_done() assert result2["type"] is FlowResultType.CREATE_ENTRY assert result2["title"] == "Test name" assert result2["data"] == { - "host": "1.1.1.1", - "port": port, - "model": model, - "sleep_period": 0, - "gen": gen, + CONF_HOST: "1.1.1.1", + CONF_PORT: port, + CONF_MODEL: model, + CONF_SLEEP_PERIOD: 0, + CONF_GEN: gen, } assert len(mock_setup.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 @@ -163,18 +172,18 @@ async def test_user_flow_overrides_existing_discovery( assert result["errors"] == {} result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - {"host": "1.1.1.1", "port": 80}, + {CONF_HOST: "1.1.1.1", CONF_PORT: 80}, ) await hass.async_block_till_done() assert result2["type"] is FlowResultType.CREATE_ENTRY assert result2["title"] == "Test name" assert result2["data"] == { - "host": "1.1.1.1", - "port": 80, - "model": MODEL_PLUS_2PM, - "sleep_period": 0, - "gen": 2, + CONF_HOST: "1.1.1.1", + CONF_PORT: 80, + CONF_MODEL: MODEL_PLUS_2PM, + CONF_SLEEP_PERIOD: 0, + CONF_GEN: 2, } assert result2["context"]["unique_id"] == "AABBCCDDEEFF" assert len(mock_setup.mock_calls) == 1 @@ -220,19 +229,19 @@ async def test_form_gen1_custom_port( ( 1, MODEL_1, - {"username": "test user", "password": "test1 password"}, + {CONF_USERNAME: "test user", CONF_PASSWORD: "test1 password"}, "test user", ), ( 2, MODEL_PLUS_2PM, - {"password": "test2 password"}, + {CONF_PASSWORD: "test2 password"}, "admin", ), ( 3, MODEL_PLUS_2PM, - {"password": "test2 password"}, + {CONF_PASSWORD: "test2 password"}, "admin", ), ], @@ -259,7 +268,7 @@ async def test_form_auth( ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - {"host": "1.1.1.1"}, + {CONF_HOST: "1.1.1.1"}, ) assert result2["type"] is FlowResultType.FORM @@ -282,13 +291,13 @@ async def test_form_auth( assert result3["type"] is FlowResultType.CREATE_ENTRY assert result3["title"] == "Test name" assert result3["data"] == { - "host": "1.1.1.1", - "port": DEFAULT_HTTP_PORT, - "model": model, - "sleep_period": 0, - "gen": gen, - "username": username, - "password": user_input["password"], + CONF_HOST: "1.1.1.1", + CONF_PORT: DEFAULT_HTTP_PORT, + CONF_MODEL: model, + CONF_SLEEP_PERIOD: 0, + CONF_GEN: gen, + CONF_USERNAME: username, + CONF_PASSWORD: user_input[CONF_PASSWORD], } assert len(mock_setup.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 @@ -312,7 +321,7 @@ async def test_form_errors_get_info( with patch("homeassistant.components.shelly.config_flow.get_info", side_effect=exc): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - {"host": "1.1.1.1"}, + {CONF_HOST: "1.1.1.1"}, ) assert result2["type"] is FlowResultType.FORM @@ -333,7 +342,7 @@ async def test_form_missing_model_key( ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - {"host": "1.1.1.1"}, + {CONF_HOST: "1.1.1.1"}, ) assert result2["type"] is FlowResultType.FORM @@ -356,7 +365,7 @@ async def test_form_missing_model_key_auth_enabled( ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - {"host": "1.1.1.1"}, + {CONF_HOST: "1.1.1.1"}, ) assert result2["type"] is FlowResultType.FORM @@ -364,7 +373,7 @@ async def test_form_missing_model_key_auth_enabled( monkeypatch.setattr(mock_rpc_device, "shelly", {"gen": 2}) result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], {"password": "1234"} + result2["flow_id"], {CONF_PASSWORD: "1234"} ) assert result3["type"] is FlowResultType.FORM assert result3["errors"] == {"base": "firmware_not_fully_provisioned"} @@ -424,7 +433,7 @@ async def test_form_errors_test_connection( ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - {"host": "1.1.1.1"}, + {CONF_HOST: "1.1.1.1"}, ) assert result2["type"] is FlowResultType.FORM @@ -435,7 +444,7 @@ async def test_form_already_configured(hass: HomeAssistant) -> None: """Test we get the form.""" entry = MockConfigEntry( - domain="shelly", unique_id="test-mac", data={"host": "0.0.0.0"} + domain="shelly", unique_id="test-mac", data={CONF_HOST: "0.0.0.0"} ) entry.add_to_hass(hass) @@ -449,14 +458,14 @@ async def test_form_already_configured(hass: HomeAssistant) -> None: ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - {"host": "1.1.1.1"}, + {CONF_HOST: "1.1.1.1"}, ) assert result2["type"] is FlowResultType.ABORT assert result2["reason"] == "already_configured" # Test config entry got updated with latest IP - assert entry.data["host"] == "1.1.1.1" + assert entry.data[CONF_HOST] == "1.1.1.1" async def test_user_setup_ignored_device( @@ -467,7 +476,7 @@ async def test_user_setup_ignored_device( entry = MockConfigEntry( domain="shelly", unique_id="test-mac", - data={"host": "0.0.0.0"}, + data={CONF_HOST: "0.0.0.0"}, source=config_entries.SOURCE_IGNORE, ) entry.add_to_hass(hass) @@ -491,13 +500,13 @@ async def test_user_setup_ignored_device( ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - {"host": "1.1.1.1"}, + {CONF_HOST: "1.1.1.1"}, ) assert result2["type"] is FlowResultType.CREATE_ENTRY # Test config entry got updated with latest IP - assert entry.data["host"] == "1.1.1.1" + assert entry.data[CONF_HOST] == "1.1.1.1" assert len(mock_setup.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 @@ -525,7 +534,7 @@ async def test_form_auth_errors_test_connection_gen1( ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - {"host": "1.1.1.1"}, + {CONF_HOST: "1.1.1.1"}, ) with patch( @@ -534,7 +543,7 @@ async def test_form_auth_errors_test_connection_gen1( ): result3 = await hass.config_entries.flow.async_configure( result2["flow_id"], - {"username": "test username", "password": "test password"}, + {CONF_USERNAME: "test username", CONF_PASSWORD: "test password"}, ) assert result3["type"] is FlowResultType.FORM assert result3["errors"] == {"base": base_error} @@ -563,7 +572,7 @@ async def test_form_auth_errors_test_connection_gen2( ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - {"host": "1.1.1.1"}, + {CONF_HOST: "1.1.1.1"}, ) with patch( @@ -571,7 +580,7 @@ async def test_form_auth_errors_test_connection_gen2( new=AsyncMock(side_effect=exc), ): result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], {"password": "test password"} + result2["flow_id"], {CONF_PASSWORD: "test password"} ) assert result3["type"] is FlowResultType.FORM assert result3["errors"] == {"base": base_error} @@ -642,10 +651,10 @@ async def test_zeroconf( assert result2["type"] is FlowResultType.CREATE_ENTRY assert result2["title"] == "Test name" assert result2["data"] == { - "host": "1.1.1.1", - "model": model, - "sleep_period": 0, - "gen": gen, + CONF_HOST: "1.1.1.1", + CONF_MODEL: model, + CONF_SLEEP_PERIOD: 0, + CONF_GEN: gen, } assert len(mock_setup.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 @@ -700,10 +709,10 @@ async def test_zeroconf_sleeping_device( assert result2["type"] is FlowResultType.CREATE_ENTRY assert result2["title"] == "Test name" assert result2["data"] == { - "host": "1.1.1.1", - "model": MODEL_1, - "sleep_period": 600, - "gen": 1, + CONF_HOST: "1.1.1.1", + CONF_MODEL: MODEL_1, + CONF_SLEEP_PERIOD: 600, + CONF_GEN: 1, } assert len(mock_setup.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 @@ -739,7 +748,7 @@ async def test_zeroconf_already_configured(hass: HomeAssistant) -> None: """Test we get the form.""" entry = MockConfigEntry( - domain="shelly", unique_id="test-mac", data={"host": "0.0.0.0"} + domain="shelly", unique_id="test-mac", data={CONF_HOST: "0.0.0.0"} ) entry.add_to_hass(hass) @@ -756,7 +765,7 @@ async def test_zeroconf_already_configured(hass: HomeAssistant) -> None: assert result["reason"] == "already_configured" # Test config entry got updated with latest IP - assert entry.data["host"] == "1.1.1.1" + assert entry.data[CONF_HOST] == "1.1.1.1" async def test_zeroconf_ignored(hass: HomeAssistant) -> None: @@ -787,7 +796,7 @@ async def test_zeroconf_with_wifi_ap_ip(hass: HomeAssistant) -> None: """Test we ignore the Wi-FI AP IP.""" entry = MockConfigEntry( - domain="shelly", unique_id="test-mac", data={"host": "2.2.2.2"} + domain="shelly", unique_id="test-mac", data={CONF_HOST: "2.2.2.2"} ) entry.add_to_hass(hass) @@ -806,7 +815,7 @@ async def test_zeroconf_with_wifi_ap_ip(hass: HomeAssistant) -> None: assert result["reason"] == "already_configured" # Test config entry was not updated with the wifi ap ip - assert entry.data["host"] == "2.2.2.2" + assert entry.data[CONF_HOST] == "2.2.2.2" async def test_zeroconf_cannot_connect(hass: HomeAssistant) -> None: @@ -852,20 +861,20 @@ async def test_zeroconf_require_auth( ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - {"username": "test username", "password": "test password"}, + {CONF_USERNAME: "test username", CONF_PASSWORD: "test password"}, ) await hass.async_block_till_done() assert result2["type"] is FlowResultType.CREATE_ENTRY assert result2["title"] == "Test name" assert result2["data"] == { - "host": "1.1.1.1", - "port": DEFAULT_HTTP_PORT, - "model": MODEL_1, - "sleep_period": 0, - "gen": 1, - "username": "test username", - "password": "test password", + CONF_HOST: "1.1.1.1", + CONF_PORT: DEFAULT_HTTP_PORT, + CONF_MODEL: MODEL_1, + CONF_SLEEP_PERIOD: 0, + CONF_GEN: 1, + CONF_USERNAME: "test username", + CONF_PASSWORD: "test password", } assert len(mock_setup.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 @@ -874,9 +883,9 @@ async def test_zeroconf_require_auth( @pytest.mark.parametrize( ("gen", "user_input"), [ - (1, {"username": "test user", "password": "test1 password"}), - (2, {"password": "test2 password"}), - (3, {"password": "test2 password"}), + (1, {CONF_USERNAME: "test user", CONF_PASSWORD: "test1 password"}), + (2, {CONF_PASSWORD: "test2 password"}), + (3, {CONF_PASSWORD: "test2 password"}), ], ) async def test_reauth_successful( @@ -888,7 +897,9 @@ async def test_reauth_successful( ) -> None: """Test starting a reauthentication flow.""" entry = MockConfigEntry( - domain="shelly", unique_id="test-mac", data={"host": "0.0.0.0", "gen": gen} + domain="shelly", + unique_id="test-mac", + data={CONF_HOST: "0.0.0.0", CONF_GEN: gen}, ) entry.add_to_hass(hass) result = await entry.start_reauth_flow(hass) @@ -912,9 +923,9 @@ async def test_reauth_successful( @pytest.mark.parametrize( ("gen", "user_input"), [ - (1, {"username": "test user", "password": "test1 password"}), - (2, {"password": "test2 password"}), - (3, {"password": "test2 password"}), + (1, {CONF_USERNAME: "test user", CONF_PASSWORD: "test1 password"}), + (2, {CONF_PASSWORD: "test2 password"}), + (3, {CONF_PASSWORD: "test2 password"}), ], ) @pytest.mark.parametrize( @@ -933,7 +944,9 @@ async def test_reauth_unsuccessful( ) -> None: """Test reauthentication flow failed.""" entry = MockConfigEntry( - domain="shelly", unique_id="test-mac", data={"host": "0.0.0.0", "gen": gen} + domain="shelly", + unique_id="test-mac", + data={CONF_HOST: "0.0.0.0", CONF_GEN: gen}, ) entry.add_to_hass(hass) result = await entry.start_reauth_flow(hass) @@ -943,7 +956,12 @@ async def test_reauth_unsuccessful( with ( patch( "homeassistant.components.shelly.config_flow.get_info", - return_value={"mac": "test-mac", "type": MODEL_1, "auth": True, "gen": gen}, + return_value={ + "mac": "test-mac", + "type": MODEL_1, + "auth": True, + "gen": gen, + }, ), patch( "aioshelly.block_device.BlockDevice.create", new=AsyncMock(side_effect=exc) @@ -962,7 +980,7 @@ async def test_reauth_unsuccessful( async def test_reauth_get_info_error(hass: HomeAssistant) -> None: """Test reauthentication flow failed with error in get_info().""" entry = MockConfigEntry( - domain="shelly", unique_id="test-mac", data={"host": "0.0.0.0", "gen": 2} + domain="shelly", unique_id="test-mac", data={CONF_HOST: "0.0.0.0", CONF_GEN: 2} ) entry.add_to_hass(hass) result = await entry.start_reauth_flow(hass) @@ -975,7 +993,7 @@ async def test_reauth_get_info_error(hass: HomeAssistant) -> None: ): result = await hass.config_entries.flow.async_configure( result["flow_id"], - user_input={"password": "test2 password"}, + user_input={CONF_PASSWORD: "test2 password"}, ) assert result["type"] is FlowResultType.ABORT @@ -1106,7 +1124,12 @@ async def test_zeroconf_already_configured_triggers_refresh_mac_in_name( entry = MockConfigEntry( domain="shelly", unique_id="AABBCCDDEEFF", - data={"host": "1.1.1.1", "gen": 2, "sleep_period": 0, "model": MODEL_1}, + data={ + CONF_HOST: "1.1.1.1", + CONF_GEN: 2, + CONF_SLEEP_PERIOD: 0, + CONF_MODEL: MODEL_1, + }, ) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) @@ -1141,7 +1164,12 @@ async def test_zeroconf_already_configured_triggers_refresh( entry = MockConfigEntry( domain="shelly", unique_id="AABBCCDDEEFF", - data={"host": "1.1.1.1", "gen": 2, "sleep_period": 0, "model": MODEL_1}, + data={ + CONF_HOST: "1.1.1.1", + CONF_GEN: 2, + CONF_SLEEP_PERIOD: 0, + CONF_MODEL: MODEL_1, + }, ) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) @@ -1181,7 +1209,12 @@ async def test_zeroconf_sleeping_device_not_triggers_refresh( entry = MockConfigEntry( domain="shelly", unique_id="AABBCCDDEEFF", - data={"host": "1.1.1.1", "gen": 2, "sleep_period": 1000, "model": MODEL_1}, + data={ + CONF_HOST: "1.1.1.1", + CONF_GEN: 2, + CONF_SLEEP_PERIOD: 1000, + CONF_MODEL: MODEL_1, + }, ) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) @@ -1228,7 +1261,12 @@ async def test_zeroconf_sleeping_device_attempts_configure( entry = MockConfigEntry( domain="shelly", unique_id="AABBCCDDEEFF", - data={"host": "1.1.1.1", "gen": 2, "sleep_period": 1000, "model": MODEL_1}, + data={ + CONF_HOST: "1.1.1.1", + CONF_GEN: 2, + CONF_SLEEP_PERIOD: 1000, + CONF_MODEL: MODEL_1, + }, ) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) @@ -1288,7 +1326,12 @@ async def test_zeroconf_sleeping_device_attempts_configure_ws_disabled( entry = MockConfigEntry( domain="shelly", unique_id="AABBCCDDEEFF", - data={"host": "1.1.1.1", "gen": 2, "sleep_period": 1000, "model": MODEL_1}, + data={ + CONF_HOST: "1.1.1.1", + CONF_GEN: 2, + CONF_SLEEP_PERIOD: 1000, + CONF_MODEL: MODEL_1, + }, ) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) @@ -1348,7 +1391,12 @@ async def test_zeroconf_sleeping_device_attempts_configure_no_url_available( entry = MockConfigEntry( domain="shelly", unique_id="AABBCCDDEEFF", - data={"host": "1.1.1.1", "gen": 2, "sleep_period": 1000, "model": MODEL_1}, + data={ + CONF_HOST: "1.1.1.1", + CONF_GEN: 2, + CONF_SLEEP_PERIOD: 1000, + CONF_MODEL: MODEL_1, + }, ) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) @@ -1415,20 +1463,20 @@ async def test_sleeping_device_gen2_with_new_firmware( ): result = await hass.config_entries.flow.async_configure( result["flow_id"], - {"host": "1.1.1.1"}, + {CONF_HOST: "1.1.1.1"}, ) await hass.async_block_till_done() assert result["data"] == { - "host": "1.1.1.1", - "port": DEFAULT_HTTP_PORT, - "model": MODEL_PLUS_2PM, - "sleep_period": 666, - "gen": 2, + CONF_HOST: "1.1.1.1", + CONF_PORT: DEFAULT_HTTP_PORT, + CONF_MODEL: MODEL_PLUS_2PM, + CONF_SLEEP_PERIOD: 666, + CONF_GEN: 2, } -@pytest.mark.parametrize("gen", [1, 2, 3]) +@pytest.mark.parametrize(CONF_GEN, [1, 2, 3]) async def test_reconfigure_successful( hass: HomeAssistant, gen: int, @@ -1437,7 +1485,9 @@ async def test_reconfigure_successful( ) -> None: """Test starting a reconfiguration flow.""" entry = MockConfigEntry( - domain="shelly", unique_id="test-mac", data={"host": "0.0.0.0", "gen": gen} + domain="shelly", + unique_id="test-mac", + data={CONF_HOST: "0.0.0.0", CONF_GEN: gen}, ) entry.add_to_hass(hass) @@ -1452,12 +1502,12 @@ async def test_reconfigure_successful( ): result = await hass.config_entries.flow.async_configure( result["flow_id"], - user_input={"host": "10.10.10.10", "port": 99}, + user_input={CONF_HOST: "10.10.10.10", CONF_PORT: 99}, ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reconfigure_successful" - assert entry.data == {"host": "10.10.10.10", "port": 99, "gen": gen} + assert entry.data == {CONF_HOST: "10.10.10.10", CONF_PORT: 99, CONF_GEN: gen} @pytest.mark.parametrize("gen", [1, 2, 3]) @@ -1469,7 +1519,9 @@ async def test_reconfigure_unsuccessful( ) -> None: """Test reconfiguration flow failed.""" entry = MockConfigEntry( - domain="shelly", unique_id="test-mac", data={"host": "0.0.0.0", "gen": gen} + domain="shelly", + unique_id="test-mac", + data={CONF_HOST: "0.0.0.0", CONF_GEN: gen}, ) entry.add_to_hass(hass) @@ -1480,11 +1532,16 @@ async def test_reconfigure_unsuccessful( with patch( "homeassistant.components.shelly.config_flow.get_info", - return_value={"mac": "another-mac", "type": MODEL_1, "auth": False, "gen": gen}, + return_value={ + "mac": "another-mac", + "type": MODEL_1, + "auth": False, + "gen": gen, + }, ): result = await hass.config_entries.flow.async_configure( result["flow_id"], - user_input={"host": "10.10.10.10", "port": 99}, + user_input={CONF_HOST: "10.10.10.10", CONF_PORT: 99}, ) assert result["type"] is FlowResultType.ABORT @@ -1506,7 +1563,7 @@ async def test_reconfigure_with_exception( ) -> None: """Test reconfiguration flow when an exception is raised.""" entry = MockConfigEntry( - domain="shelly", unique_id="test-mac", data={"host": "0.0.0.0", "gen": 2} + domain="shelly", unique_id="test-mac", data={CONF_HOST: "0.0.0.0", CONF_GEN: 2} ) entry.add_to_hass(hass) @@ -1518,7 +1575,7 @@ async def test_reconfigure_with_exception( with patch("homeassistant.components.shelly.config_flow.get_info", side_effect=exc): result = await hass.config_entries.flow.async_configure( result["flow_id"], - user_input={"host": "10.10.10.10", "port": 99}, + user_input={CONF_HOST: "10.10.10.10", CONF_PORT: 99}, ) assert result["errors"] == {"base": base_error} diff --git a/tests/components/shelly/test_coordinator.py b/tests/components/shelly/test_coordinator.py index 8de434d19d0..55a1d8958cd 100644 --- a/tests/components/shelly/test_coordinator.py +++ b/tests/components/shelly/test_coordinator.py @@ -562,7 +562,7 @@ async def test_rpc_update_entry_sleep_period( mock_rpc_device.mock_online() await hass.async_block_till_done(wait_background_tasks=True) - assert entry.data["sleep_period"] == 600 + assert entry.data[CONF_SLEEP_PERIOD] == 600 # Move time to generate sleep period update monkeypatch.setitem(mock_rpc_device.status["sys"], "wakeup_period", 3600) @@ -570,7 +570,7 @@ async def test_rpc_update_entry_sleep_period( async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) - assert entry.data["sleep_period"] == 3600 + assert entry.data[CONF_SLEEP_PERIOD] == 3600 async def test_rpc_sleeping_device_no_periodic_updates( diff --git a/tests/components/shelly/test_init.py b/tests/components/shelly/test_init.py index c9e4ce253e4..ef9b8f72616 100644 --- a/tests/components/shelly/test_init.py +++ b/tests/components/shelly/test_init.py @@ -25,7 +25,13 @@ from homeassistant.components.shelly.const import ( BLEScannerMode, ) from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState -from homeassistant.const import CONF_HOST, CONF_PORT, STATE_ON, STATE_UNAVAILABLE +from homeassistant.const import ( + CONF_HOST, + CONF_MODEL, + CONF_PORT, + STATE_ON, + STATE_UNAVAILABLE, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.device_registry import DeviceRegistry, format_mac @@ -245,7 +251,7 @@ async def test_sleeping_block_device_online( await hass.async_block_till_done(wait_background_tasks=True) assert "online, resuming setup" in caplog.text - assert entry.data["sleep_period"] == device_sleep + assert entry.data[CONF_SLEEP_PERIOD] == device_sleep @pytest.mark.parametrize(("entry_sleep", "device_sleep"), [(None, 0), (1000, 1000)]) @@ -267,7 +273,7 @@ async def test_sleeping_rpc_device_online( await hass.async_block_till_done(wait_background_tasks=True) assert "online, resuming setup" in caplog.text - assert entry.data["sleep_period"] == device_sleep + assert entry.data[CONF_SLEEP_PERIOD] == device_sleep async def test_sleeping_rpc_device_online_new_firmware( @@ -286,7 +292,7 @@ async def test_sleeping_rpc_device_online_new_firmware( await hass.async_block_till_done(wait_background_tasks=True) assert "online, resuming setup" in caplog.text - assert entry.data["sleep_period"] == 1500 + assert entry.data[CONF_SLEEP_PERIOD] == 1500 async def test_sleeping_rpc_device_online_during_setup( @@ -474,7 +480,7 @@ async def test_entry_missing_port(hass: HomeAssistant) -> None: data = { CONF_HOST: "192.168.1.37", CONF_SLEEP_PERIOD: 0, - "model": MODEL_PLUS_2PM, + CONF_MODEL: MODEL_PLUS_2PM, CONF_GEN: 2, } entry = await init_integration(hass, 2, data=data, skip_setup=True) @@ -497,7 +503,7 @@ async def test_rpc_entry_custom_port(hass: HomeAssistant) -> None: data = { CONF_HOST: "192.168.1.37", CONF_SLEEP_PERIOD: 0, - "model": MODEL_PLUS_2PM, + CONF_MODEL: MODEL_PLUS_2PM, CONF_GEN: 2, CONF_PORT: 8001, } From f1a6e949c03722f47581cfe8e44307482c246092 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sun, 9 Mar 2025 13:12:08 +0100 Subject: [PATCH 2399/3148] Update mypy-dev to 1.16.0a5 (#140188) --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index d5dee887214..f40ed46a82f 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -12,7 +12,7 @@ coverage==7.6.12 freezegun==1.5.1 license-expression==30.4.1 mock-open==1.4.0 -mypy-dev==1.16.0a3 +mypy-dev==1.16.0a5 pre-commit==4.0.0 pydantic==2.10.6 pylint==3.3.4 From e2d4e8b65d8f92a76283743a1f3ea132722c4b75 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Sun, 9 Mar 2025 13:47:15 +0100 Subject: [PATCH 2400/3148] Add create_todo action to Habitica integration (#140090) --- homeassistant/components/habitica/const.py | 1 + homeassistant/components/habitica/icons.json | 6 ++ homeassistant/components/habitica/services.py | 24 ++--- .../components/habitica/services.yaml | 17 +++- .../components/habitica/strings.json | 58 ++++++++++- tests/components/habitica/test_services.py | 99 ++++++++++++++++++- 6 files changed, 183 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/habitica/const.py b/homeassistant/components/habitica/const.py index c33edc0161d..cf9d08c160c 100644 --- a/homeassistant/components/habitica/const.py +++ b/homeassistant/components/habitica/const.py @@ -72,6 +72,7 @@ SERVICE_CREATE_REWARD = "create_reward" SERVICE_UPDATE_HABIT = "update_habit" SERVICE_CREATE_HABIT = "create_habit" SERVICE_UPDATE_TODO = "update_todo" +SERVICE_CREATE_TODO = "create_todo" DEVELOPER_ID = "4c4ca53f-c059-4ffa-966e-9d29dd405daf" X_CLIENT = f"{DEVELOPER_ID} - {APPLICATION_NAME} {__version__}" diff --git a/homeassistant/components/habitica/icons.json b/homeassistant/components/habitica/icons.json index f4f045523d4..85adfa09304 100644 --- a/homeassistant/components/habitica/icons.json +++ b/homeassistant/components/habitica/icons.json @@ -253,6 +253,12 @@ "duedate_options": "mdi:calendar-blank", "reminder_options": "mdi:reminder" } + }, + "create_todo": { + "service": "mdi:pencil-box-outline", + "sections": { + "developer_options": "mdi:test-tube" + } } } } diff --git a/homeassistant/components/habitica/services.py b/homeassistant/components/habitica/services.py index f1e92d863ca..bb8f69a8d11 100644 --- a/homeassistant/components/habitica/services.py +++ b/homeassistant/components/habitica/services.py @@ -79,6 +79,7 @@ from .const import ( SERVICE_CAST_SKILL, SERVICE_CREATE_HABIT, SERVICE_CREATE_REWARD, + SERVICE_CREATE_TODO, SERVICE_GET_TASKS, SERVICE_LEAVE_QUEST, SERVICE_REJECT_QUEST, @@ -214,6 +215,7 @@ SERVICE_TASK_TYPE_MAP = { SERVICE_UPDATE_HABIT: TaskType.HABIT, SERVICE_CREATE_HABIT: TaskType.HABIT, SERVICE_UPDATE_TODO: TaskType.TODO, + SERVICE_CREATE_TODO: TaskType.TODO, } @@ -811,20 +813,14 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 schema=SERVICE_UPDATE_TASK_SCHEMA, supports_response=SupportsResponse.ONLY, ) - hass.services.async_register( - DOMAIN, - SERVICE_CREATE_REWARD, - create_or_update_task, - schema=SERVICE_CREATE_TASK_SCHEMA, - supports_response=SupportsResponse.ONLY, - ) - hass.services.async_register( - DOMAIN, - SERVICE_CREATE_HABIT, - create_or_update_task, - schema=SERVICE_CREATE_TASK_SCHEMA, - supports_response=SupportsResponse.ONLY, - ) + for service in (SERVICE_CREATE_HABIT, SERVICE_CREATE_REWARD, SERVICE_CREATE_TODO): + hass.services.async_register( + DOMAIN, + service, + create_or_update_task, + schema=SERVICE_CREATE_TASK_SCHEMA, + supports_response=SupportsResponse.ONLY, + ) hass.services.async_register( DOMAIN, SERVICE_API_CALL, diff --git a/homeassistant/components/habitica/services.yaml b/homeassistant/components/habitica/services.yaml index 2464b39529b..acbe4e62824 100644 --- a/homeassistant/components/habitica/services.yaml +++ b/homeassistant/components/habitica/services.yaml @@ -271,7 +271,7 @@ update_todo: checklist_options: collapsed: true fields: - add_checklist_item: + add_checklist_item: &add_checklist_item required: false selector: text: @@ -295,7 +295,7 @@ update_todo: duedate_options: collapsed: true fields: - date: + date: &due_date required: false selector: date: @@ -308,7 +308,7 @@ update_todo: reminder_options: collapsed: true fields: - reminder: + reminder: &reminder required: false selector: text: @@ -328,3 +328,14 @@ update_todo: label: "🗑️" tag_options: *tag_options developer_options: *developer_options +create_todo: + fields: + config_entry: *config_entry + name: *name + notes: *notes + add_checklist_item: *add_checklist_item + priority: *priority + date: *due_date + reminder: *reminder + tag: *tag + developer_options: *developer_options diff --git a/homeassistant/components/habitica/strings.json b/homeassistant/components/habitica/strings.json index d77bbd6f2be..513c0b36b27 100644 --- a/homeassistant/components/habitica/strings.json +++ b/homeassistant/components/habitica/strings.json @@ -49,7 +49,9 @@ "clear_reminder_name": "Clear all reminders", "clear_reminder_description": "Remove all reminders from a Habitica task.", "reminder_options_name": "Reminders", - "reminder_options_description": "Add, remove or clear reminders of a Habitica task." + "reminder_options_description": "Add, remove or clear reminders of a Habitica task.", + "date_name": "Due date", + "date_description": "The to-do's due date." }, "config": { "abort": { @@ -929,8 +931,8 @@ "description": "[%key:component::habitica::common::priority_description%]" }, "date": { - "name": "Due date", - "description": "The to-do's due date." + "name": "[%key:component::habitica::common::date_name%]", + "description": "[%key:component::habitica::common::date_description%]" }, "clear_date": { "name": "Clear due date", @@ -971,7 +973,7 @@ "description": "[%key:component::habitica::common::checklist_options_description%]" }, "duedate_options": { - "name": "Due date", + "name": "[%key:component::habitica::common::date_name%]", "description": "Set, update or remove due dates of a to-do." }, "reminder_options": { @@ -987,6 +989,54 @@ "description": "[%key:component::habitica::common::developer_options_description%]" } } + }, + "create_todo": { + "name": "Create to-do", + "description": "Adds a new to-do.", + "fields": { + "config_entry": { + "name": "[%key:component::habitica::common::config_entry_name%]", + "description": "Select the Habitica account to create a to-do." + }, + "name": { + "name": "[%key:component::habitica::common::task_name%]", + "description": "[%key:component::habitica::common::name_description%]" + }, + "notes": { + "name": "[%key:component::habitica::common::notes_name%]", + "description": "[%key:component::habitica::common::notes_description%]" + }, + "tag": { + "name": "[%key:component::habitica::common::tag_name%]", + "description": "[%key:component::habitica::common::tag_description%]" + }, + "alias": { + "name": "[%key:component::habitica::common::alias_name%]", + "description": "[%key:component::habitica::common::alias_description%]" + }, + "priority": { + "name": "[%key:component::habitica::common::priority_name%]", + "description": "[%key:component::habitica::common::priority_description%]" + }, + "date": { + "name": "[%key:component::habitica::common::date_name%]", + "description": "[%key:component::habitica::common::date_description%]" + }, + "reminder": { + "name": "[%key:component::habitica::common::reminder_name%]", + "description": "[%key:component::habitica::common::reminder_description%]" + }, + "add_checklist_item": { + "name": "[%key:component::habitica::common::add_checklist_item_name%]", + "description": "[%key:component::habitica::common::add_checklist_item_description%]" + } + }, + "sections": { + "developer_options": { + "name": "[%key:component::habitica::common::developer_options_name%]", + "description": "[%key:component::habitica::common::developer_options_description%]" + } + } } }, "selector": { diff --git a/tests/components/habitica/test_services.py b/tests/components/habitica/test_services.py index 3fd477f6858..238cb8412ba 100644 --- a/tests/components/habitica/test_services.py +++ b/tests/components/habitica/test_services.py @@ -55,6 +55,7 @@ from homeassistant.components.habitica.const import ( SERVICE_CAST_SKILL, SERVICE_CREATE_HABIT, SERVICE_CREATE_REWARD, + SERVICE_CREATE_TODO, SERVICE_GET_TASKS, SERVICE_LEAVE_QUEST, SERVICE_REJECT_QUEST, @@ -1002,7 +1003,7 @@ async def test_update_task_exceptions( ) @pytest.mark.parametrize( "service", - [SERVICE_CREATE_REWARD, SERVICE_CREATE_HABIT], + [SERVICE_CREATE_REWARD, SERVICE_CREATE_HABIT, SERVICE_CREATE_TODO], ) @pytest.mark.usefixtures("habitica") async def test_create_task_exceptions( @@ -1509,6 +1510,102 @@ async def test_update_todo( habitica.update_task.assert_awaited_with(UUID(task_id), call_args) +@pytest.mark.parametrize( + ("service_data", "call_args"), + [ + ( + { + ATTR_NAME: "TITLE", + }, + Task(type=TaskType.TODO, text="TITLE"), + ), + ( + { + ATTR_NAME: "TITLE", + ATTR_NOTES: "NOTES", + }, + Task(type=TaskType.TODO, text="TITLE", notes="NOTES"), + ), + ( + { + ATTR_NAME: "TITLE", + ATTR_ADD_CHECKLIST_ITEM: "Checklist-item", + }, + Task( + type=TaskType.TODO, + text="TITLE", + checklist=[ + Checklist( + id=UUID("12345678-1234-5678-1234-567812345678"), + text="Checklist-item", + completed=False, + ), + ], + ), + ), + ( + { + ATTR_NAME: "TITLE", + ATTR_PRIORITY: "trivial", + }, + Task(type=TaskType.TODO, text="TITLE", priority=TaskPriority.TRIVIAL), + ), + ( + { + ATTR_NAME: "TITLE", + ATTR_DATE: "2025-03-05", + }, + Task(type=TaskType.TODO, text="TITLE", date=datetime(2025, 3, 5)), + ), + ( + { + ATTR_NAME: "TITLE", + ATTR_REMINDER: ["2025-02-25T00:00"], + }, + Task( + type=TaskType.TODO, + text="TITLE", + reminders=[ + Reminders( + id=UUID("12345678-1234-5678-1234-567812345678"), + time=datetime(2025, 2, 25, 0, 0), + startDate=None, + ) + ], + ), + ), + ( + { + ATTR_NAME: "TITLE", + ATTR_ALIAS: "ALIAS", + }, + Task(type=TaskType.TODO, text="TITLE", alias="ALIAS"), + ), + ], +) +@pytest.mark.usefixtures("mock_uuid4") +async def test_create_todo( + hass: HomeAssistant, + config_entry: MockConfigEntry, + habitica: AsyncMock, + service_data: dict[str, Any], + call_args: Task, +) -> None: + """Test Habitica create todo action.""" + + await hass.services.async_call( + DOMAIN, + SERVICE_CREATE_TODO, + service_data={ + ATTR_CONFIG_ENTRY: config_entry.entry_id, + **service_data, + }, + return_response=True, + blocking=True, + ) + habitica.create_task.assert_awaited_with(call_args) + + async def test_tags( hass: HomeAssistant, config_entry: MockConfigEntry, From 1a46edffaa2c55c947997ba665dc5e3c6b7355e0 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sun, 9 Mar 2025 14:20:31 +0100 Subject: [PATCH 2401/3148] Deprecate use of invalid unit of measurement for mqtt sensor (#140164) * Deprecate use of invalid unit of measurement for mqtt sensor * Update learn more URL to point to user docs instead * typo --- homeassistant/components/mqtt/sensor.py | 57 +++++++++++++++++- homeassistant/components/mqtt/strings.json | 4 ++ tests/components/mqtt/test_sensor.py | 68 +++++++++++++++++++++- 3 files changed, 127 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/mqtt/sensor.py b/homeassistant/components/mqtt/sensor.py index 3e8a4fef0fa..4d67b0d56e6 100644 --- a/homeassistant/components/mqtt/sensor.py +++ b/homeassistant/components/mqtt/sensor.py @@ -11,6 +11,7 @@ import voluptuous as vol from homeassistant.components import sensor from homeassistant.components.sensor import ( CONF_STATE_CLASS, + DEVICE_CLASS_UNITS, DEVICE_CLASSES_SCHEMA, ENTITY_ID_FORMAT, STATE_CLASSES_SCHEMA, @@ -33,13 +34,14 @@ from homeassistant.core import CALLBACK_TYPE, HomeAssistant, State, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.event import async_call_later +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.service_info.mqtt import ReceivePayloadType from homeassistant.helpers.typing import ConfigType, VolSchemaType from homeassistant.util import dt as dt_util from . import subscription from .config import MQTT_RO_SCHEMA -from .const import CONF_OPTIONS, CONF_STATE_TOPIC, PAYLOAD_NONE +from .const import CONF_OPTIONS, CONF_STATE_TOPIC, DOMAIN, PAYLOAD_NONE from .entity import MqttAvailabilityMixin, MqttEntity, async_setup_entity_entry_helper from .models import MqttValueTemplate, PayloadSentinel, ReceiveMessage from .schemas import MQTT_ENTITY_COMMON_SCHEMA @@ -63,6 +65,10 @@ MQTT_SENSOR_ATTRIBUTES_BLOCKED = frozenset( DEFAULT_NAME = "MQTT Sensor" DEFAULT_FORCE_UPDATE = False +URL_DOCS_SUPPORTED_SENSOR_UOM = ( + "https://www.home-assistant.io/integrations/sensor/#device-class" +) + _PLATFORM_SCHEMA_BASE = MQTT_RO_SCHEMA.extend( { vol.Optional(CONF_DEVICE_CLASS): vol.Any(DEVICE_CLASSES_SCHEMA, None), @@ -107,6 +113,23 @@ def validate_sensor_state_and_device_class_config(config: ConfigType) -> ConfigT f"got `{CONF_DEVICE_CLASS}` '{device_class}'" ) + if (device_class := config.get(CONF_DEVICE_CLASS)) is None or ( + unit_of_measurement := config.get(CONF_UNIT_OF_MEASUREMENT) + ) is None: + return config + + if ( + device_class in DEVICE_CLASS_UNITS + and unit_of_measurement not in DEVICE_CLASS_UNITS[device_class] + ): + _LOGGER.warning( + "The unit of measurement `%s` is not valid " + "together with device class `%s`. " + "this will stop working in HA Core 2025.7.0", + unit_of_measurement, + device_class, + ) + return config @@ -155,8 +178,40 @@ class MqttSensor(MqttEntity, RestoreSensor): None ) + @callback + def async_check_uom(self) -> None: + """Check if the unit of measurement is valid with the device class.""" + if ( + self._discovery_data is not None + or self.device_class is None + or self.native_unit_of_measurement is None + ): + return + if ( + self.device_class in DEVICE_CLASS_UNITS + and self.native_unit_of_measurement + not in DEVICE_CLASS_UNITS[self.device_class] + ): + async_create_issue( + self.hass, + DOMAIN, + self.entity_id, + issue_domain=sensor.DOMAIN, + is_fixable=False, + severity=IssueSeverity.WARNING, + learn_more_url=URL_DOCS_SUPPORTED_SENSOR_UOM, + translation_placeholders={ + "uom": self.native_unit_of_measurement, + "device_class": self.device_class.value, + "entity_id": self.entity_id, + }, + translation_key="invalid_unit_of_measurement", + breaks_in_ha_version="2025.7.0", + ) + async def mqtt_async_added_to_hass(self) -> None: """Restore state for entities with expire_after set.""" + self.async_check_uom() last_state: State | None last_sensor_data: SensorExtraStoredData | None if ( diff --git a/homeassistant/components/mqtt/strings.json b/homeassistant/components/mqtt/strings.json index 8805f447d69..4eb41b9e39a 100644 --- a/homeassistant/components/mqtt/strings.json +++ b/homeassistant/components/mqtt/strings.json @@ -3,6 +3,10 @@ "invalid_platform_config": { "title": "Invalid config found for mqtt {domain} item", "description": "Home Assistant detected an invalid config for a manually configured item.\n\nPlatform domain: **{domain}**\nConfiguration file: **{config_file}**\nNear line: **{line}**\nConfiguration found:\n```yaml\n{config}\n```\nError: **{error}**.\n\nMake sure the configuration is valid and [reload](/developer-tools/yaml) the manually configured MQTT items or restart Home Assistant to fix this issue." + }, + "invalid_unit_of_measurement": { + "title": "Sensor with invalid unit of measurement", + "description": "Manual configured Sensor entity **{entity_id}** has a configured unit of measurement **{uom}** which is not valid with configured device class **{device_class}**. Make sure a valid unit of measurement is configured or remove the device class, and [reload](/developer-tools/yaml) the manually configured MQTT items or restart Home Assistant to fix this issue." } }, "config": { diff --git a/tests/components/mqtt/test_sensor.py b/tests/components/mqtt/test_sensor.py index 9226b03a7d2..1fcd70a0b10 100644 --- a/tests/components/mqtt/test_sensor.py +++ b/tests/components/mqtt/test_sensor.py @@ -21,7 +21,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import Event, HomeAssistant, State, callback -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import device_registry as dr, issue_registry as ir from homeassistant.helpers.typing import ConfigType from homeassistant.util import dt as dt_util @@ -71,6 +71,7 @@ from .test_common import ( from tests.common import ( MockConfigEntry, + async_capture_events, async_fire_mqtt_message, async_fire_time_changed, mock_restore_cache_with_extra_data, @@ -870,6 +871,71 @@ async def test_invalid_device_class( assert "expected SensorDeviceClass or one of" in caplog.text +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + sensor.DOMAIN: { + "name": "test", + "state_topic": "test-topic", + "device_class": "energy", + "unit_of_measurement": "ppm", + } + } + } + ], +) +async def test_invalid_unit_of_measurement( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test device_class with invalid unit of measurement.""" + events = async_capture_events(hass, ir.EVENT_REPAIRS_ISSUE_REGISTRY_UPDATED) + assert await mqtt_mock_entry() + assert ( + "The unit of measurement `ppm` is not valid together with device class `energy`" + in caplog.text + ) + # A repair issue was logged + assert len(events) == 1 + assert events[0].data["issue_id"] == "sensor.test" + # Assert the sensor works + async_fire_mqtt_message(hass, "test-topic", "100") + await hass.async_block_till_done() + state = hass.states.get("sensor.test") + assert state is not None + assert state.state == "100" + + caplog.clear() + + discovery_payload = { + "name": "bla", + "state_topic": "test-topic2", + "device_class": "temperature", + "unit_of_measurement": "C", + } + # Now discover an other invalid sensor + async_fire_mqtt_message( + hass, "homeassistant/sensor/bla/config", json.dumps(discovery_payload) + ) + await hass.async_block_till_done() + assert ( + "The unit of measurement `C` is not valid together with device class `temperature`" + in caplog.text + ) + # Assert the sensor works + async_fire_mqtt_message(hass, "test-topic2", "21") + await hass.async_block_till_done() + state = hass.states.get("sensor.bla") + assert state is not None + assert state.state == "21" + + # No new issue was registered for the discovered entity + assert len(events) == 1 + + @pytest.mark.parametrize( "hass_config", [ From e8069e1c073e26003956533d3900d83687cc1095 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sun, 9 Mar 2025 15:15:27 +0100 Subject: [PATCH 2402/3148] Add template functions: md5, sha1, sha256, sha512 (#140192) --- homeassistant/helpers/template.py | 29 +++++++++++++++++ tests/helpers/test_template.py | 52 +++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+) diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 357fe15f3be..20531596fdd 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -12,6 +12,7 @@ from contextvars import ContextVar from copy import deepcopy from datetime import date, datetime, time, timedelta from functools import cache, lru_cache, partial, wraps +import hashlib import json import logging import math @@ -2784,6 +2785,26 @@ def flatten(value: Iterable[Any], levels: int | None = None) -> list[Any]: return flattened +def md5(value: str) -> str: + """Generate md5 hash from a string.""" + return hashlib.md5(value.encode()).hexdigest() + + +def sha1(value: str) -> str: + """Generate sha1 hash from a string.""" + return hashlib.sha1(value.encode()).hexdigest() + + +def sha256(value: str) -> str: + """Generate sha256 hash from a string.""" + return hashlib.sha256(value.encode()).hexdigest() + + +def sha512(value: str) -> str: + """Generate sha512 hash from a string.""" + return hashlib.sha512(value.encode()).hexdigest() + + class TemplateContextManager(AbstractContextManager): """Context manager to store template being parsed or rendered in a ContextVar.""" @@ -2987,6 +3008,10 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): self.filters["shuffle"] = shuffle self.filters["typeof"] = typeof self.filters["flatten"] = flatten + self.filters["md5"] = md5 + self.filters["sha1"] = sha1 + self.filters["sha256"] = sha256 + self.filters["sha512"] = sha512 self.globals["log"] = logarithm self.globals["sin"] = sine self.globals["cos"] = cosine @@ -3027,6 +3052,10 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): self.globals["shuffle"] = shuffle self.globals["typeof"] = typeof self.globals["flatten"] = flatten + self.globals["md5"] = md5 + self.globals["sha1"] = sha1 + self.globals["sha256"] = sha256 + self.globals["sha512"] = sha512 self.tests["is_number"] = is_number self.tests["list"] = _is_list self.tests["set"] = _is_set diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index f9154b23bad..bdf400ce357 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -6788,3 +6788,55 @@ def test_flatten(hass: HomeAssistant) -> None: with pytest.raises(TemplateError): template.Template("{{ flatten() }}", hass).async_render() + + +def test_md5(hass: HomeAssistant) -> None: + """Test the md5 function and filter.""" + assert ( + template.Template("{{ md5('Home Assistant') }}", hass).async_render() + == "3d15e5c102c3413d0337393c3287e006" + ) + + assert ( + template.Template("{{ 'Home Assistant' | md5 }}", hass).async_render() + == "3d15e5c102c3413d0337393c3287e006" + ) + + +def test_sha1(hass: HomeAssistant) -> None: + """Test the sha1 function and filter.""" + assert ( + template.Template("{{ sha1('Home Assistant') }}", hass).async_render() + == "c8fd3bb19b94312664faa619af7729bdbf6e9f8a" + ) + + assert ( + template.Template("{{ 'Home Assistant' | sha1 }}", hass).async_render() + == "c8fd3bb19b94312664faa619af7729bdbf6e9f8a" + ) + + +def test_sha256(hass: HomeAssistant) -> None: + """Test the sha256 function and filter.""" + assert ( + template.Template("{{ sha256('Home Assistant') }}", hass).async_render() + == "2a366abb0cd47f51f3725bf0fb7ebcb4fefa6e20f4971e25fe2bb8da8145ce2b" + ) + + assert ( + template.Template("{{ 'Home Assistant' | sha256 }}", hass).async_render() + == "2a366abb0cd47f51f3725bf0fb7ebcb4fefa6e20f4971e25fe2bb8da8145ce2b" + ) + + +def test_sha512(hass: HomeAssistant) -> None: + """Test the sha512 function and filter.""" + assert ( + template.Template("{{ sha512('Home Assistant') }}", hass).async_render() + == "9e3c2cdd1fbab0037378d37e1baf8a3a4bf92c54b56ad1d459deee30ccbb2acbebd7a3614552ea08992ad27dedeb7b4c5473525ba90cb73dbe8b9ec5f69295bb" + ) + + assert ( + template.Template("{{ 'Home Assistant' | sha512 }}", hass).async_render() + == "9e3c2cdd1fbab0037378d37e1baf8a3a4bf92c54b56ad1d459deee30ccbb2acbebd7a3614552ea08992ad27dedeb7b4c5473525ba90cb73dbe8b9ec5f69295bb" + ) From 8a67e89e9154c23a28937f6e1cc19d84d851ca3b Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Sun, 9 Mar 2025 15:18:26 +0100 Subject: [PATCH 2403/3148] Improve category map for air quality and pollen sensors in AccuWeather (#140193) * Fix typo * Improve category map for air quality and pollen * Update test snapshot --- homeassistant/components/accuweather/const.py | 5 +++-- homeassistant/components/accuweather/strings.json | 6 ++++-- .../components/accuweather/snapshots/test_sensor.ambr | 10 ++++++++++ 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/accuweather/const.py b/homeassistant/components/accuweather/const.py index f09b9771ab6..7216f5a0b9b 100644 --- a/homeassistant/components/accuweather/const.py +++ b/homeassistant/components/accuweather/const.py @@ -59,13 +59,14 @@ AIR_QUALITY_CATEGORY_MAP = { 1: "good", 2: "moderate", 3: "unhealthy", - 4: "hazardous", + 4: "very_unhealthy", + 5: "hazardous", } POLLEN_CATEGORY_MAP = { 1: "low", 2: "moderate", 3: "high", - 4: "very high", + 4: "very_high", } UPDATE_INTERVAL_OBSERVATION = timedelta(minutes=40) UPDATE_INTERVAL_DAILY_FORECAST = timedelta(hours=6) diff --git a/homeassistant/components/accuweather/strings.json b/homeassistant/components/accuweather/strings.json index e5190b7a8da..d9777352b93 100644 --- a/homeassistant/components/accuweather/strings.json +++ b/homeassistant/components/accuweather/strings.json @@ -27,7 +27,8 @@ "good": "Good", "hazardous": "Hazardous", "moderate": "Moderate", - "unhealthy": "Unhealthy" + "unhealthy": "Unhealthy", + "very_unhealthy": "Very unhealthy" }, "state_attributes": { "options": { @@ -35,7 +36,8 @@ "good": "[%key:component::accuweather::entity::sensor::air_quality::state::good%]", "hazardous": "[%key:component::accuweather::entity::sensor::air_quality::state::hazardous%]", "moderate": "[%key:component::accuweather::entity::sensor::air_quality::state::moderate%]", - "unhealthy": "[%key:component::accuweather::entity::sensor::air_quality::state::unhealthy%]" + "unhealthy": "[%key:component::accuweather::entity::sensor::air_quality::state::unhealthy%]", + "very_unhealthy": "[%key:component::accuweather::entity::sensor::air_quality::state::very_unhealthy%]" } } } diff --git a/tests/components/accuweather/snapshots/test_sensor.ambr b/tests/components/accuweather/snapshots/test_sensor.ambr index 3176f0a88bd..cbd2e14207e 100644 --- a/tests/components/accuweather/snapshots/test_sensor.ambr +++ b/tests/components/accuweather/snapshots/test_sensor.ambr @@ -9,6 +9,7 @@ 'good', 'moderate', 'unhealthy', + 'very_unhealthy', 'hazardous', ]), }), @@ -50,6 +51,7 @@ 'good', 'moderate', 'unhealthy', + 'very_unhealthy', 'hazardous', ]), }), @@ -71,6 +73,7 @@ 'good', 'moderate', 'unhealthy', + 'very_unhealthy', 'hazardous', ]), }), @@ -112,6 +115,7 @@ 'good', 'moderate', 'unhealthy', + 'very_unhealthy', 'hazardous', ]), }), @@ -133,6 +137,7 @@ 'good', 'moderate', 'unhealthy', + 'very_unhealthy', 'hazardous', ]), }), @@ -174,6 +179,7 @@ 'good', 'moderate', 'unhealthy', + 'very_unhealthy', 'hazardous', ]), }), @@ -195,6 +201,7 @@ 'good', 'moderate', 'unhealthy', + 'very_unhealthy', 'hazardous', ]), }), @@ -236,6 +243,7 @@ 'good', 'moderate', 'unhealthy', + 'very_unhealthy', 'hazardous', ]), }), @@ -257,6 +265,7 @@ 'good', 'moderate', 'unhealthy', + 'very_unhealthy', 'hazardous', ]), }), @@ -298,6 +307,7 @@ 'good', 'moderate', 'unhealthy', + 'very_unhealthy', 'hazardous', ]), }), From 264d4a53a2a8c9ac3ea22d8d6b54d76356cde2ed Mon Sep 17 00:00:00 2001 From: Galorhallen <12990764+Galorhallen@users.noreply.github.com> Date: Sun, 9 Mar 2025 15:23:44 +0100 Subject: [PATCH 2404/3148] Update govee-local-api to 2.1.0 (#140201) --- homeassistant/components/govee_light_local/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/govee_light_local/manifest.json b/homeassistant/components/govee_light_local/manifest.json index cba341cd482..55a6b9e8578 100644 --- a/homeassistant/components/govee_light_local/manifest.json +++ b/homeassistant/components/govee_light_local/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["network"], "documentation": "https://www.home-assistant.io/integrations/govee_light_local", "iot_class": "local_push", - "requirements": ["govee-local-api==2.0.1"] + "requirements": ["govee-local-api==2.1.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 950d4aa12cb..c20da4f9034 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1061,7 +1061,7 @@ gotailwind==0.3.0 govee-ble==0.43.1 # homeassistant.components.govee_light_local -govee-local-api==2.0.1 +govee-local-api==2.1.0 # homeassistant.components.remote_rpi_gpio gpiozero==1.6.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 503d33e8a8b..cc29e4af080 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -911,7 +911,7 @@ gotailwind==0.3.0 govee-ble==0.43.1 # homeassistant.components.govee_light_local -govee-local-api==2.0.1 +govee-local-api==2.1.0 # homeassistant.components.gpsd gps3==0.33.3 From befcd632217eeb1b3c7a2d6faec12730c3d34cb0 Mon Sep 17 00:00:00 2001 From: msm595 Date: Sun, 9 Mar 2025 11:07:35 -0400 Subject: [PATCH 2405/3148] Fix the order of the group members attribute of the Music Assistant integration (#140204) --- .../music_assistant/media_player.py | 32 +++++++++++-------- .../snapshots/test_media_player.ambr | 2 +- 2 files changed, 19 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/music_assistant/media_player.py b/homeassistant/components/music_assistant/media_player.py index c079fd20e91..56bde7bbae7 100644 --- a/homeassistant/components/music_assistant/media_player.py +++ b/homeassistant/components/music_assistant/media_player.py @@ -276,22 +276,26 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity): self._attr_state = MediaPlayerState(player.state.value) else: self._attr_state = MediaPlayerState(STATE_OFF) - group_members_entity_ids: list[str] = [] + + group_members: list[str] = [] if player.group_childs: - # translate MA group_childs to HA group_members as entity id's - entity_registry = er.async_get(self.hass) - group_members_entity_ids = [ - entity_id - for child_id in player.group_childs - if ( - entity_id := entity_registry.async_get_entity_id( - self.platform.domain, DOMAIN, child_id - ) + group_members = player.group_childs + elif player.synced_to and (parent := self.mass.players.get(player.synced_to)): + group_members = parent.group_childs + + # translate MA group_childs to HA group_members as entity id's + entity_registry = er.async_get(self.hass) + group_members_entity_ids: list[str] = [ + entity_id + for child_id in group_members + if ( + entity_id := entity_registry.async_get_entity_id( + self.platform.domain, DOMAIN, child_id ) - ] - # NOTE: we sort the group_members for now, - # until the MA API returns them sorted (group_childs is now a set) - self._attr_group_members = sorted(group_members_entity_ids) + ) + ] + + self._attr_group_members = group_members_entity_ids self._attr_volume_level = ( player.volume_level / 100 if player.volume_level is not None else None ) diff --git a/tests/components/music_assistant/snapshots/test_media_player.ambr b/tests/components/music_assistant/snapshots/test_media_player.ambr index a07bde4b29d..50223ddf623 100644 --- a/tests/components/music_assistant/snapshots/test_media_player.ambr +++ b/tests/components/music_assistant/snapshots/test_media_player.ambr @@ -109,8 +109,8 @@ 'entity_picture_local': None, 'friendly_name': 'Test Group Player 1', 'group_members': list([ - 'media_player.my_super_test_player_2', 'media_player.test_player_1', + 'media_player.my_super_test_player_2', ]), 'icon': 'mdi:speaker-multiple', 'is_volume_muted': False, From 8a51644d1db59bc39642043a2c11c4ad42394715 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Sun, 9 Mar 2025 18:04:00 +0100 Subject: [PATCH 2406/3148] Align CONF_ in Shelly integration (#140202) * Allign CONST_ in Shelly integration * apply review comment --- homeassistant/components/shelly/__init__.py | 10 +++++-- .../components/shelly/config_flow.py | 27 ++++++++++--------- .../components/shelly/coordinator.py | 3 ++- .../components/shelly/diagnostics.py | 20 +++++++++----- homeassistant/components/shelly/utils.py | 11 +++++--- 5 files changed, 46 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/shelly/__init__.py b/homeassistant/components/shelly/__init__.py index 55b75b3face..7440013940c 100644 --- a/homeassistant/components/shelly/__init__.py +++ b/homeassistant/components/shelly/__init__.py @@ -16,7 +16,13 @@ from aioshelly.rpc_device import RpcDevice, bluetooth_mac_from_primary_mac import voluptuous as vol from homeassistant.components.bluetooth import async_remove_scanner -from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform +from homeassistant.const import ( + CONF_HOST, + CONF_MODEL, + CONF_PASSWORD, + CONF_USERNAME, + Platform, +) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import ( @@ -159,7 +165,7 @@ async def _async_setup_block_entry( # Following code block will force the right value for affected devices if ( sleep_period == BLOCK_WRONG_SLEEP_PERIOD - and entry.data["model"] in MODELS_WITH_WRONG_SLEEP_PERIOD + and entry.data[CONF_MODEL] in MODELS_WITH_WRONG_SLEEP_PERIOD ): LOGGER.warning( "Updating stored sleep period for %s: from %s to %s", diff --git a/homeassistant/components/shelly/config_flow.py b/homeassistant/components/shelly/config_flow.py index 5c5e187a0f4..8e47235c981 100644 --- a/homeassistant/components/shelly/config_flow.py +++ b/homeassistant/components/shelly/config_flow.py @@ -31,6 +31,7 @@ from homeassistant.config_entries import ( from homeassistant.const import ( CONF_HOST, CONF_MAC, + CONF_MODEL, CONF_PASSWORD, CONF_PORT, CONF_USERNAME, @@ -116,7 +117,9 @@ async def validate_input( return { "title": rpc_device.name, CONF_SLEEP_PERIOD: sleep_period, - "model": rpc_device.xmod_info.get("p") or rpc_device.shelly.get("model"), + CONF_MODEL: ( + rpc_device.xmod_info.get("p") or rpc_device.shelly.get(CONF_MODEL) + ), CONF_GEN: gen, } @@ -136,7 +139,7 @@ async def validate_input( return { "title": block_device.name, CONF_SLEEP_PERIOD: sleep_period, - "model": block_device.model, + CONF_MODEL: block_device.model, CONF_GEN: gen, } @@ -191,14 +194,14 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN): LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: - if device_info["model"]: + if device_info[CONF_MODEL]: return self.async_create_entry( title=device_info["title"], data={ CONF_HOST: user_input[CONF_HOST], CONF_PORT: user_input[CONF_PORT], CONF_SLEEP_PERIOD: device_info[CONF_SLEEP_PERIOD], - "model": device_info["model"], + CONF_MODEL: device_info[CONF_MODEL], CONF_GEN: device_info[CONF_GEN], }, ) @@ -230,7 +233,7 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN): LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: - if device_info["model"]: + if device_info[CONF_MODEL]: return self.async_create_entry( title=device_info["title"], data={ @@ -238,7 +241,7 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN): CONF_HOST: self.host, CONF_PORT: self.port, CONF_SLEEP_PERIOD: device_info[CONF_SLEEP_PERIOD], - "model": device_info["model"], + CONF_MODEL: device_info[CONF_MODEL], CONF_GEN: device_info[CONF_GEN], }, ) @@ -336,7 +339,7 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN): """Handle discovery confirm.""" errors: dict[str, str] = {} - if not self.device_info["model"]: + if not self.device_info[CONF_MODEL]: errors["base"] = "firmware_not_fully_provisioned" model = "Shelly" else: @@ -345,9 +348,9 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_create_entry( title=self.device_info["title"], data={ - "host": self.host, + CONF_HOST: self.host, CONF_SLEEP_PERIOD: self.device_info[CONF_SLEEP_PERIOD], - "model": self.device_info["model"], + CONF_MODEL: self.device_info[CONF_MODEL], CONF_GEN: self.device_info[CONF_GEN], }, ) @@ -356,8 +359,8 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_show_form( step_id="confirm_discovery", description_placeholders={ - "model": model, - "host": self.host, + CONF_MODEL: model, + CONF_HOST: self.host, }, errors=errors, ) @@ -466,7 +469,7 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN): return ( get_device_entry_gen(config_entry) in RPC_GENERATIONS and not config_entry.data.get(CONF_SLEEP_PERIOD) - and config_entry.data.get("model") != MODEL_WALL_DISPLAY + and config_entry.data.get(CONF_MODEL) != MODEL_WALL_DISPLAY ) diff --git a/homeassistant/components/shelly/coordinator.py b/homeassistant/components/shelly/coordinator.py index bebf8efbdd7..95812c12e10 100644 --- a/homeassistant/components/shelly/coordinator.py +++ b/homeassistant/components/shelly/coordinator.py @@ -26,6 +26,7 @@ from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import ( ATTR_DEVICE_ID, CONF_HOST, + CONF_MODEL, EVENT_HOMEASSISTANT_STOP, Platform, ) @@ -139,7 +140,7 @@ class ShellyCoordinatorBase[_DeviceT: BlockDevice | RpcDevice]( @cached_property def model(self) -> str: """Model of the device.""" - return cast(str, self.config_entry.data["model"]) + return cast(str, self.config_entry.data[CONF_MODEL]) @cached_property def mac(self) -> str: diff --git a/homeassistant/components/shelly/diagnostics.py b/homeassistant/components/shelly/diagnostics.py index d56a2884e17..cac2bb2f16b 100644 --- a/homeassistant/components/shelly/diagnostics.py +++ b/homeassistant/components/shelly/diagnostics.py @@ -6,7 +6,13 @@ from typing import Any from homeassistant.components.bluetooth import async_scanner_by_source from homeassistant.components.diagnostics import async_redact_data -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import ( + ATTR_MODEL, + ATTR_NAME, + ATTR_SW_VERSION, + CONF_PASSWORD, + CONF_USERNAME, +) from homeassistant.core import HomeAssistant from .coordinator import ShellyConfigEntry @@ -30,9 +36,9 @@ async def async_get_config_entry_diagnostics( block_coordinator = shelly_entry_data.block assert block_coordinator device_info = { - "name": block_coordinator.name, - "model": block_coordinator.model, - "sw_version": block_coordinator.sw_version, + ATTR_NAME: block_coordinator.name, + ATTR_MODEL: block_coordinator.model, + ATTR_SW_VERSION: block_coordinator.sw_version, } if block_coordinator.device.initialized: device_settings = { @@ -65,9 +71,9 @@ async def async_get_config_entry_diagnostics( rpc_coordinator = shelly_entry_data.rpc assert rpc_coordinator device_info = { - "name": rpc_coordinator.name, - "model": rpc_coordinator.model, - "sw_version": rpc_coordinator.sw_version, + ATTR_NAME: rpc_coordinator.name, + ATTR_MODEL: rpc_coordinator.model, + ATTR_SW_VERSION: rpc_coordinator.sw_version, } if rpc_coordinator.device.initialized: device_settings = { diff --git a/homeassistant/components/shelly/utils.py b/homeassistant/components/shelly/utils.py index b478e416c50..626cb287f64 100644 --- a/homeassistant/components/shelly/utils.py +++ b/homeassistant/components/shelly/utils.py @@ -28,7 +28,12 @@ from yarl import URL from homeassistant.components import network from homeassistant.components.http import HomeAssistantView from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_PORT, EVENT_HOMEASSISTANT_STOP +from homeassistant.const import ( + CONF_HOST, + CONF_MODEL, + CONF_PORT, + EVENT_HOMEASSISTANT_STOP, +) from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers import ( device_registry as dr, @@ -322,7 +327,7 @@ def get_info_gen(info: dict[str, Any]) -> int: def get_model_name(info: dict[str, Any]) -> str: """Return the device model name.""" if get_info_gen(info) in RPC_GENERATIONS: - return cast(str, MODEL_NAMES.get(info["model"], info["model"])) + return cast(str, MODEL_NAMES.get(info[CONF_MODEL], info[CONF_MODEL])) return cast(str, MODEL_NAMES.get(info["type"], info["type"])) @@ -514,7 +519,7 @@ def async_create_issue_unsupported_firmware( translation_key="unsupported_firmware", translation_placeholders={ "device_name": entry.title, - "ip_address": entry.data["host"], + "ip_address": entry.data[CONF_HOST], }, ) From 7cbcdbe6104136b9ba81adfefa934c1e33104f84 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Sun, 9 Mar 2025 20:01:07 +0100 Subject: [PATCH 2407/3148] Log broad exception in Electricity Maps config flow (#140219) --- homeassistant/components/co2signal/config_flow.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/co2signal/config_flow.py b/homeassistant/components/co2signal/config_flow.py index 530496811d9..00acd2829a6 100644 --- a/homeassistant/components/co2signal/config_flow.py +++ b/homeassistant/components/co2signal/config_flow.py @@ -3,11 +3,11 @@ from __future__ import annotations from collections.abc import Mapping +import logging from typing import Any from aioelectricitymaps import ( ElectricityMaps, - ElectricityMapsError, ElectricityMapsInvalidTokenError, ElectricityMapsNoDataError, ) @@ -36,6 +36,8 @@ TYPE_USE_HOME = "use_home_location" TYPE_SPECIFY_COORDINATES = "specify_coordinates" TYPE_SPECIFY_COUNTRY = "specify_country_code" +_LOGGER = logging.getLogger(__name__) + class ElectricityMapsConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Co2signal.""" @@ -158,7 +160,8 @@ class ElectricityMapsConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "invalid_auth" except ElectricityMapsNoDataError: errors["base"] = "no_data" - except ElectricityMapsError: + except Exception: + _LOGGER.exception("Unexpected error occurred while checking API key") errors["base"] = "unknown" else: if self.source == SOURCE_REAUTH: From 7eeb3df1c29c9b01661976e1f31cd19c90570a3a Mon Sep 17 00:00:00 2001 From: Glenn Waters Date: Sun, 9 Mar 2025 15:03:03 -0400 Subject: [PATCH 2408/3148] Bump upb-lib to 0.6.1 (#140212) --- homeassistant/components/upb/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/upb/manifest.json b/homeassistant/components/upb/manifest.json index e5da4c4d621..b40388be71b 100644 --- a/homeassistant/components/upb/manifest.json +++ b/homeassistant/components/upb/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/upb", "iot_class": "local_push", "loggers": ["upb_lib"], - "requirements": ["upb-lib==0.6.0"] + "requirements": ["upb-lib==0.6.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index c20da4f9034..2cf57251ac6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2977,7 +2977,7 @@ unifiled==0.11 universal-silabs-flasher==0.0.29 # homeassistant.components.upb -upb-lib==0.6.0 +upb-lib==0.6.1 # homeassistant.components.upcloud upcloud-api==2.6.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index cc29e4af080..c17f56f5eb1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2393,7 +2393,7 @@ ultraheat-api==0.5.7 unifi-discovery==1.2.0 # homeassistant.components.upb -upb-lib==0.6.0 +upb-lib==0.6.1 # homeassistant.components.upcloud upcloud-api==2.6.0 From f3a43e273aa9dfa6ddc7c09fdec9cfb205b848ad Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Sun, 9 Mar 2025 20:11:13 +0100 Subject: [PATCH 2409/3148] Fix mysensors climate target temps (#140220) * Test hvac node only hvac * Assert supported features in all climate tests * Fix mysensors climate target temperatures --- homeassistant/components/mysensors/climate.py | 27 +++--- tests/components/mysensors/conftest.py | 15 ++++ .../fixtures/hvac_node_only_hvac_state.json | 22 +++++ tests/components/mysensors/test_climate.py | 88 ++++++++++++++++++- 4 files changed, 134 insertions(+), 18 deletions(-) create mode 100644 tests/components/mysensors/fixtures/hvac_node_only_hvac_state.json diff --git a/homeassistant/components/mysensors/climate.py b/homeassistant/components/mysensors/climate.py index d1b697a3458..a42861c5fa2 100644 --- a/homeassistant/components/mysensors/climate.py +++ b/homeassistant/components/mysensors/climate.py @@ -82,7 +82,10 @@ class MySensorsHVAC(MySensorsChildEntity, ClimateEntity): and set_req.V_HVAC_SETPOINT_HEAT in self._values ): features = features | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE - else: + elif ( + set_req.V_HVAC_SETPOINT_COOL in self._values + or set_req.V_HVAC_SETPOINT_HEAT in self._values + ): features = features | ClimateEntityFeature.TARGET_TEMPERATURE return features @@ -108,13 +111,11 @@ class MySensorsHVAC(MySensorsChildEntity, ClimateEntity): @property def target_temperature(self) -> float | None: - """Return the temperature we try to reach.""" + """Return the temperature we try to reach. + + Either V_HVAC_SETPOINT_COOL or V_HVAC_SETPOINT_HEAT may be used. + """ set_req = self.gateway.const.SetReq - if ( - set_req.V_HVAC_SETPOINT_COOL in self._values - and set_req.V_HVAC_SETPOINT_HEAT in self._values - ): - return None temp = self._values.get(set_req.V_HVAC_SETPOINT_COOL) if temp is None: temp = self._values.get(set_req.V_HVAC_SETPOINT_HEAT) @@ -124,21 +125,13 @@ class MySensorsHVAC(MySensorsChildEntity, ClimateEntity): def target_temperature_high(self) -> float | None: """Return the highbound target temperature we try to reach.""" set_req = self.gateway.const.SetReq - if set_req.V_HVAC_SETPOINT_HEAT in self._values: - temp = self._values.get(set_req.V_HVAC_SETPOINT_COOL) - return float(temp) if temp is not None else None - - return None + return float(self._values[set_req.V_HVAC_SETPOINT_COOL]) @property def target_temperature_low(self) -> float | None: """Return the lowbound target temperature we try to reach.""" set_req = self.gateway.const.SetReq - if set_req.V_HVAC_SETPOINT_COOL in self._values: - temp = self._values.get(set_req.V_HVAC_SETPOINT_HEAT) - return float(temp) if temp is not None else None - - return None + return float(self._values[set_req.V_HVAC_SETPOINT_HEAT]) @property def hvac_mode(self) -> HVACMode: diff --git a/tests/components/mysensors/conftest.py b/tests/components/mysensors/conftest.py index b14a3f9c529..c2c110466e6 100644 --- a/tests/components/mysensors/conftest.py +++ b/tests/components/mysensors/conftest.py @@ -320,6 +320,21 @@ def hvac_node_heat( return nodes[1] +@pytest.fixture(name="hvac_node_only_hvac_state", scope="package") +def hvac_node_only_hvac_state_fixture() -> dict: + """Load the hvac node only hvac state.""" + return load_nodes_state("hvac_node_only_hvac_state.json") + + +@pytest.fixture +def hvac_node_only_hvac( + gateway_nodes: dict[int, Sensor], hvac_node_only_hvac_state: dict +) -> Sensor: + """Load the hvac only hvac child node.""" + nodes = update_gateway_nodes(gateway_nodes, deepcopy(hvac_node_only_hvac_state)) + return nodes[1] + + @pytest.fixture(name="power_sensor_state", scope="package") def power_sensor_state_fixture() -> dict: """Load the power sensor state.""" diff --git a/tests/components/mysensors/fixtures/hvac_node_only_hvac_state.json b/tests/components/mysensors/fixtures/hvac_node_only_hvac_state.json new file mode 100644 index 00000000000..b41470e6076 --- /dev/null +++ b/tests/components/mysensors/fixtures/hvac_node_only_hvac_state.json @@ -0,0 +1,22 @@ +{ + "1": { + "sensor_id": 1, + "children": { + "1": { + "id": 1, + "type": 29, + "description": "", + "values": { + "0": "20.0", + "21": "Off" + } + } + }, + "type": 17, + "sketch_name": "HVAC Node", + "sketch_version": "1.0", + "battery_level": 0, + "protocol_version": "2.3.2", + "heartbeat": 0 + } +} diff --git a/tests/components/mysensors/test_climate.py b/tests/components/mysensors/test_climate.py index 959f92ff512..b919287e046 100644 --- a/tests/components/mysensors/test_climate.py +++ b/tests/components/mysensors/test_climate.py @@ -38,6 +38,8 @@ async def test_hvac_node_auto( assert state assert state.state == HVACMode.OFF assert state.attributes[ATTR_BATTERY_LEVEL] == 0 + assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 20.0 + assert state.attributes["supported_features"] == 394 # Test set hvac mode auto await hass.services.async_call( @@ -153,6 +155,8 @@ async def test_hvac_node_heat( assert state assert state.state == HVACMode.OFF assert state.attributes[ATTR_BATTERY_LEVEL] == 0 + assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 20.0 + assert state.attributes["supported_features"] == 393 # Test set hvac mode heat await hass.services.async_call( @@ -263,8 +267,10 @@ async def test_hvac_node_cool( assert state assert state.state == HVACMode.OFF assert state.attributes[ATTR_BATTERY_LEVEL] == 0 + assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 20.0 + assert state.attributes["supported_features"] == 393 - # Test set hvac mode heat + # Test set hvac mode cool await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, @@ -357,3 +363,83 @@ async def test_hvac_node_cool( assert state assert state.state == HVACMode.OFF + + +async def test_hvac_node_only_hvac( + hass: HomeAssistant, + hvac_node_only_hvac: Sensor, + receive_message: Callable[[str], None], + transport_write: MagicMock, +) -> None: + """Test a hvac only hvac node.""" + entity_id = "climate.hvac_node_1_1" + + state = hass.states.get(entity_id) + + assert state + assert state.state == HVACMode.OFF + assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 20.0 + assert state.attributes["supported_features"] == 384 + + # Test set hvac mode heat + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_HVAC_MODE: HVACMode.HEAT}, + blocking=True, + ) + + assert transport_write.call_count == 1 + assert transport_write.call_args == call("1;1;1;1;21;HeatOn\n") + + receive_message("1;1;1;0;21;HeatOn\n") + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + + assert state + assert state.state == HVACMode.HEAT + assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 20.0 + + transport_write.reset_mock() + + # Test set hvac mode cool + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_HVAC_MODE: HVACMode.COOL}, + blocking=True, + ) + + assert transport_write.call_count == 1 + assert transport_write.call_args == call("1;1;1;1;21;CoolOn\n") + + receive_message("1;1;1;0;21;CoolOn\n") + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + + assert state + assert state.state == HVACMode.COOL + assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 20.0 + + transport_write.reset_mock() + + # Test set hvac mode off + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_HVAC_MODE: HVACMode.OFF}, + blocking=True, + ) + + assert transport_write.call_count == 1 + assert transport_write.call_args == call("1;1;1;1;21;Off\n") + + receive_message("1;1;1;0;21;Off\n") + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + + assert state + assert state.state == HVACMode.OFF From 8b4d9f96d443f40abc56038bfa5348719fe0b55c Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Sun, 9 Mar 2025 20:16:34 +0100 Subject: [PATCH 2410/3148] Remove mysensors assumed state dead code (#140227) --- homeassistant/components/mysensors/climate.py | 12 ------- homeassistant/components/mysensors/cover.py | 20 +---------- homeassistant/components/mysensors/light.py | 33 +------------------ homeassistant/components/mysensors/switch.py | 10 +----- 4 files changed, 3 insertions(+), 72 deletions(-) diff --git a/homeassistant/components/mysensors/climate.py b/homeassistant/components/mysensors/climate.py index a42861c5fa2..eb54a76b8a8 100644 --- a/homeassistant/components/mysensors/climate.py +++ b/homeassistant/components/mysensors/climate.py @@ -175,10 +175,6 @@ class MySensorsHVAC(MySensorsChildEntity, ClimateEntity): self.gateway.set_child_value( self.node_id, self.child_id, value_type, value, ack=1 ) - if self.assumed_state: - # Optimistically assume that device has changed state - self._values[value_type] = value - self.async_write_ha_state() async def async_set_fan_mode(self, fan_mode: str) -> None: """Set new target temperature.""" @@ -186,10 +182,6 @@ class MySensorsHVAC(MySensorsChildEntity, ClimateEntity): self.gateway.set_child_value( self.node_id, self.child_id, set_req.V_HVAC_SPEED, fan_mode, ack=1 ) - if self.assumed_state: - # Optimistically assume that device has changed state - self._values[set_req.V_HVAC_SPEED] = fan_mode - self.async_write_ha_state() async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set new target temperature.""" @@ -200,10 +192,6 @@ class MySensorsHVAC(MySensorsChildEntity, ClimateEntity): DICT_HA_TO_MYS[hvac_mode], ack=1, ) - if self.assumed_state: - # Optimistically assume that device has changed state - self._values[self.value_type] = hvac_mode - self.async_write_ha_state() @callback def _async_update(self) -> None: diff --git a/homeassistant/components/mysensors/cover.py b/homeassistant/components/mysensors/cover.py index 2ac0367d1fc..84346a5d10a 100644 --- a/homeassistant/components/mysensors/cover.py +++ b/homeassistant/components/mysensors/cover.py @@ -7,7 +7,7 @@ from typing import Any from homeassistant.components.cover import ATTR_POSITION, CoverEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.const import STATE_OFF, STATE_ON, Platform +from homeassistant.const import STATE_ON, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -110,13 +110,6 @@ class MySensorsCover(MySensorsChildEntity, CoverEntity): self.gateway.set_child_value( self.node_id, self.child_id, set_req.V_UP, 1, ack=1 ) - if self.assumed_state: - # Optimistically assume that cover has changed state. - if set_req.V_DIMMER in self._values: - self._values[set_req.V_DIMMER] = 100 - else: - self._values[set_req.V_LIGHT] = STATE_ON - self.async_write_ha_state() async def async_close_cover(self, **kwargs: Any) -> None: """Move the cover down.""" @@ -124,13 +117,6 @@ class MySensorsCover(MySensorsChildEntity, CoverEntity): self.gateway.set_child_value( self.node_id, self.child_id, set_req.V_DOWN, 1, ack=1 ) - if self.assumed_state: - # Optimistically assume that cover has changed state. - if set_req.V_DIMMER in self._values: - self._values[set_req.V_DIMMER] = 0 - else: - self._values[set_req.V_LIGHT] = STATE_OFF - self.async_write_ha_state() async def async_set_cover_position(self, **kwargs: Any) -> None: """Move the cover to a specific position.""" @@ -139,10 +125,6 @@ class MySensorsCover(MySensorsChildEntity, CoverEntity): self.gateway.set_child_value( self.node_id, self.child_id, set_req.V_DIMMER, position, ack=1 ) - if self.assumed_state: - # Optimistically assume that cover has changed state. - self._values[set_req.V_DIMMER] = position - self.async_write_ha_state() async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the device.""" diff --git a/homeassistant/components/mysensors/light.py b/homeassistant/components/mysensors/light.py index 4fa9eaa8ea6..fa5e625c72b 100644 --- a/homeassistant/components/mysensors/light.py +++ b/homeassistant/components/mysensors/light.py @@ -12,7 +12,7 @@ from homeassistant.components.light import ( LightEntity, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import STATE_OFF, STATE_ON, Platform +from homeassistant.const import STATE_ON, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -77,11 +77,6 @@ class MySensorsLight(MySensorsChildEntity, LightEntity): self.node_id, self.child_id, set_req.V_LIGHT, 1, ack=1 ) - if self.assumed_state: - # optimistically assume that light has changed state - self._state = True - self._values[set_req.V_LIGHT] = STATE_ON - def _turn_on_dimmer(self, **kwargs: Any) -> None: """Turn on dimmer child device.""" set_req = self.gateway.const.SetReq @@ -98,20 +93,10 @@ class MySensorsLight(MySensorsChildEntity, LightEntity): self.node_id, self.child_id, set_req.V_DIMMER, percent, ack=1 ) - if self.assumed_state: - # optimistically assume that light has changed state - self._attr_brightness = brightness - self._values[set_req.V_DIMMER] = percent - async def async_turn_off(self, **kwargs: Any) -> None: """Turn the device off.""" value_type = self.gateway.const.SetReq.V_LIGHT self.gateway.set_child_value(self.node_id, self.child_id, value_type, 0, ack=1) - if self.assumed_state: - # optimistically assume that light has changed state - self._state = False - self._values[value_type] = STATE_OFF - self.async_write_ha_state() @callback def _async_update_light(self) -> None: @@ -139,8 +124,6 @@ class MySensorsLightDimmer(MySensorsLight): """Turn the device on.""" self._turn_on_light() self._turn_on_dimmer(**kwargs) - if self.assumed_state: - self.async_write_ha_state() @callback def _async_update(self) -> None: @@ -161,8 +144,6 @@ class MySensorsLightRGB(MySensorsLight): self._turn_on_light() self._turn_on_dimmer(**kwargs) self._turn_on_rgb(**kwargs) - if self.assumed_state: - self.async_write_ha_state() def _turn_on_rgb(self, **kwargs: Any) -> None: """Turn on RGB child device.""" @@ -176,11 +157,6 @@ class MySensorsLightRGB(MySensorsLight): self.node_id, self.child_id, self.value_type, hex_color, ack=1 ) - if self.assumed_state: - # optimistically assume that light has changed state - self._attr_rgb_color = new_rgb - self._values[self.value_type] = hex_color - @callback def _async_update(self) -> None: """Update the controller with the latest value from a sensor.""" @@ -209,8 +185,6 @@ class MySensorsLightRGBW(MySensorsLightRGB): self._turn_on_light() self._turn_on_dimmer(**kwargs) self._turn_on_rgbw(**kwargs) - if self.assumed_state: - self.async_write_ha_state() def _turn_on_rgbw(self, **kwargs: Any) -> None: """Turn on RGBW child device.""" @@ -224,11 +198,6 @@ class MySensorsLightRGBW(MySensorsLightRGB): self.node_id, self.child_id, self.value_type, hex_color, ack=1 ) - if self.assumed_state: - # optimistically assume that light has changed state - self._attr_rgbw_color = new_rgbw - self._values[self.value_type] = hex_color - @callback def _async_update_rgb_or_w(self) -> None: """Update the controller with values from RGBW child.""" diff --git a/homeassistant/components/mysensors/switch.py b/homeassistant/components/mysensors/switch.py index 499124919b5..9b57102a94c 100644 --- a/homeassistant/components/mysensors/switch.py +++ b/homeassistant/components/mysensors/switch.py @@ -6,7 +6,7 @@ from typing import Any from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.const import STATE_OFF, STATE_ON, Platform +from homeassistant.const import STATE_ON, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -69,17 +69,9 @@ class MySensorsSwitch(MySensorsChildEntity, SwitchEntity): self.gateway.set_child_value( self.node_id, self.child_id, self.value_type, 1, ack=1 ) - if self.assumed_state: - # Optimistically assume that switch has changed state - self._values[self.value_type] = STATE_ON - self.async_write_ha_state() async def async_turn_off(self, **kwargs: Any) -> None: """Turn the switch off.""" self.gateway.set_child_value( self.node_id, self.child_id, self.value_type, 0, ack=1 ) - if self.assumed_state: - # Optimistically assume that switch has changed state - self._values[self.value_type] = STATE_OFF - self.async_write_ha_state() From ff622af888a02adaa7ef1e8cc665e0374228b678 Mon Sep 17 00:00:00 2001 From: Keith <22891515+keithle888@users.noreply.github.com> Date: Sun, 9 Mar 2025 20:47:13 +0100 Subject: [PATCH 2411/3148] Add locking and unlocking feature to igloohome integration (#136002) * - Added lock platform - Added creation of IgloohomeLockEntity when bridge devices are included. * - Migrated retrieval of linked_bridge utility to utils module. - Added ability for lock to update it's own linked bridge automatically * - Added mock bridge device to test fixture * - Added snapshot test for lock module * - Added bridge with no linked devices - Added test for util.get_linked_bridge * - Added handling of errors from API call * - Bump igloohome-api to v0.1.0 * - Minor change * - Removed async update for locks. Focus on MVP * - Removed need for update on entity creation * - Updated snapshot test * - Updated snapshot * - Updated to use walrus during lock entity creation - Updated callback class for async_setup_entry based on lint suggestion * - Set _attr_name as None - Updated snapshot test * Update homeassistant/components/igloohome/lock.py * Update homeassistant/components/igloohome/lock.py --------- Co-authored-by: Josef Zweck --- .../components/igloohome/__init__.py | 3 +- homeassistant/components/igloohome/lock.py | 91 +++++++++++++++++++ homeassistant/components/igloohome/utils.py | 16 ++++ tests/components/igloohome/conftest.py | 29 +++++- .../igloohome/snapshots/test_lock.ambr | 50 ++++++++++ tests/components/igloohome/test_lock.py | 26 ++++++ tests/components/igloohome/test_utils.py | 31 +++++++ 7 files changed, 242 insertions(+), 4 deletions(-) create mode 100644 homeassistant/components/igloohome/lock.py create mode 100644 homeassistant/components/igloohome/utils.py create mode 100644 tests/components/igloohome/snapshots/test_lock.ambr create mode 100644 tests/components/igloohome/test_lock.py create mode 100644 tests/components/igloohome/test_utils.py diff --git a/homeassistant/components/igloohome/__init__.py b/homeassistant/components/igloohome/__init__.py index 5e5e21452cf..a3907fcbcf3 100644 --- a/homeassistant/components/igloohome/__init__.py +++ b/homeassistant/components/igloohome/__init__.py @@ -19,7 +19,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession -PLATFORMS: list[Platform] = [Platform.SENSOR] +PLATFORMS: list[Platform] = [Platform.LOCK, Platform.SENSOR] @dataclass @@ -35,7 +35,6 @@ type IgloohomeConfigEntry = ConfigEntry[IgloohomeRuntimeData] async def async_setup_entry(hass: HomeAssistant, entry: IgloohomeConfigEntry) -> bool: """Set up igloohome from a config entry.""" - authentication = IgloohomeAuth( session=async_get_clientsession(hass), client_id=entry.data[CONF_CLIENT_ID], diff --git a/homeassistant/components/igloohome/lock.py b/homeassistant/components/igloohome/lock.py new file mode 100644 index 00000000000..b434c055145 --- /dev/null +++ b/homeassistant/components/igloohome/lock.py @@ -0,0 +1,91 @@ +"""Implementation of the lock platform.""" + +from datetime import timedelta + +from aiohttp import ClientError +from igloohome_api import ( + BRIDGE_JOB_LOCK, + BRIDGE_JOB_UNLOCK, + DEVICE_TYPE_LOCK, + Api as IgloohomeApi, + ApiException, + GetDeviceInfoResponse, +) + +from homeassistant.components.lock import LockEntity, LockEntityFeature +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import IgloohomeConfigEntry +from .entity import IgloohomeBaseEntity +from .utils import get_linked_bridge + +# Scan interval set to allow Lock entity update the bridge linked to it. +SCAN_INTERVAL = timedelta(hours=1) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: IgloohomeConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up lock entities.""" + async_add_entities( + IgloohomeLockEntity( + api_device_info=device, + api=entry.runtime_data.api, + bridge_id=str(bridge), + ) + for device in entry.runtime_data.devices + if device.type == DEVICE_TYPE_LOCK + and (bridge := get_linked_bridge(device.deviceId, entry.runtime_data.devices)) + is not None + ) + + +class IgloohomeLockEntity(IgloohomeBaseEntity, LockEntity): + """Implementation of a device that has locking capabilities.""" + + # Operating on assumed state because there is no API to query the state. + _attr_assumed_state = True + _attr_supported_features = LockEntityFeature.OPEN + _attr_name = None + + def __init__( + self, api_device_info: GetDeviceInfoResponse, api: IgloohomeApi, bridge_id: str + ) -> None: + """Initialize the class.""" + super().__init__( + api_device_info=api_device_info, + api=api, + unique_key="lock", + ) + self.bridge_id = bridge_id + + async def async_lock(self, **kwargs): + """Lock this lock.""" + try: + await self.api.create_bridge_proxied_job( + self.api_device_info.deviceId, self.bridge_id, BRIDGE_JOB_LOCK + ) + except (ApiException, ClientError) as err: + raise HomeAssistantError from err + + async def async_unlock(self, **kwargs): + """Unlock this lock.""" + try: + await self.api.create_bridge_proxied_job( + self.api_device_info.deviceId, self.bridge_id, BRIDGE_JOB_UNLOCK + ) + except (ApiException, ClientError) as err: + raise HomeAssistantError from err + + async def async_open(self, **kwargs): + """Open (unlatch) this lock.""" + try: + await self.api.create_bridge_proxied_job( + self.api_device_info.deviceId, self.bridge_id, BRIDGE_JOB_UNLOCK + ) + except (ApiException, ClientError) as err: + raise HomeAssistantError from err diff --git a/homeassistant/components/igloohome/utils.py b/homeassistant/components/igloohome/utils.py new file mode 100644 index 00000000000..be17912b8b8 --- /dev/null +++ b/homeassistant/components/igloohome/utils.py @@ -0,0 +1,16 @@ +"""House utility functions.""" + +from igloohome_api import DEVICE_TYPE_BRIDGE, GetDeviceInfoResponse + + +def get_linked_bridge( + device_id: str, devices: list[GetDeviceInfoResponse] +) -> str | None: + """Return the ID of the bridge that is linked to the device. None if no bridge is linked.""" + bridges = (bridge for bridge in devices if bridge.type == DEVICE_TYPE_BRIDGE) + for bridge in bridges: + if device_id in ( + linked_device.deviceId for linked_device in bridge.linkedDevices + ): + return bridge.deviceId + return None diff --git a/tests/components/igloohome/conftest.py b/tests/components/igloohome/conftest.py index d630f5af7cb..6c4eb4904ae 100644 --- a/tests/components/igloohome/conftest.py +++ b/tests/components/igloohome/conftest.py @@ -3,7 +3,7 @@ from collections.abc import Generator from unittest.mock import AsyncMock, patch -from igloohome_api import GetDeviceInfoResponse, GetDevicesResponse +from igloohome_api import GetDeviceInfoResponse, GetDevicesResponse, LinkedDevice import pytest from homeassistant.components.igloohome.const import DOMAIN @@ -23,6 +23,28 @@ GET_DEVICE_INFO_RESPONSE_LOCK = GetDeviceInfoResponse( batteryLevel=100, ) +GET_DEVICE_INFO_RESPONSE_BRIDGE_LINKED_LOCK = GetDeviceInfoResponse( + id="001", + type="Bridge", + deviceId="EB1X04eeeeee", + deviceName="Home Bridge", + pairedAt="2024-11-09T12:19:25+00:00", + homeId=[], + linkedDevices=[LinkedDevice(type="Lock", deviceId="OE1X123cbb11")], + batteryLevel=None, +) + +GET_DEVICE_INFO_RESPONSE_BRIDGE_NO_LINKED_DEVICE = GetDeviceInfoResponse( + id="001", + type="Bridge", + deviceId="EB1X04eeeeee", + deviceName="Home Bridge", + pairedAt="2024-11-09T12:19:25+00:00", + homeId=[], + linkedDevices=[], + batteryLevel=None, +) + @pytest.fixture def mock_setup_entry() -> Generator[AsyncMock]: @@ -66,7 +88,10 @@ def mock_api() -> Generator[AsyncMock]: api = api_mock.return_value api.get_devices.return_value = GetDevicesResponse( nextCursor="", - payload=[GET_DEVICE_INFO_RESPONSE_LOCK], + payload=[ + GET_DEVICE_INFO_RESPONSE_LOCK, + GET_DEVICE_INFO_RESPONSE_BRIDGE_LINKED_LOCK, + ], ) api.get_device_info.return_value = GET_DEVICE_INFO_RESPONSE_LOCK yield api diff --git a/tests/components/igloohome/snapshots/test_lock.ambr b/tests/components/igloohome/snapshots/test_lock.ambr new file mode 100644 index 00000000000..5d94cf27c6b --- /dev/null +++ b/tests/components/igloohome/snapshots/test_lock.ambr @@ -0,0 +1,50 @@ +# serializer version: 1 +# name: test_lock[lock.front_door-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'lock', + 'entity_category': None, + 'entity_id': 'lock.front_door', + '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': None, + 'platform': 'igloohome', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'lock_OE1X123cbb11', + 'unit_of_measurement': None, + }) +# --- +# name: test_lock[lock.front_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'assumed_state': True, + 'friendly_name': 'Front Door', + 'supported_features': , + }), + 'context': , + 'entity_id': 'lock.front_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/igloohome/test_lock.py b/tests/components/igloohome/test_lock.py new file mode 100644 index 00000000000..324a4ab231a --- /dev/null +++ b/tests/components/igloohome/test_lock.py @@ -0,0 +1,26 @@ +"""Test lock module for igloohome integration.""" + +from unittest.mock import patch + +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, snapshot_platform + + +async def test_lock( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, +) -> None: + """Test lock entity created.""" + with patch("homeassistant.components.igloohome.PLATFORMS", [Platform.LOCK]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/igloohome/test_utils.py b/tests/components/igloohome/test_utils.py new file mode 100644 index 00000000000..a6262076eed --- /dev/null +++ b/tests/components/igloohome/test_utils.py @@ -0,0 +1,31 @@ +"""Test functions in utils module.""" + +from homeassistant.components.igloohome.utils import get_linked_bridge + +from .conftest import ( + GET_DEVICE_INFO_RESPONSE_BRIDGE_LINKED_LOCK, + GET_DEVICE_INFO_RESPONSE_BRIDGE_NO_LINKED_DEVICE, + GET_DEVICE_INFO_RESPONSE_LOCK, +) + + +def test_get_linked_bridge_expect_bridge_id_returned() -> None: + """Test that get_linked_bridge returns the bridge ID.""" + assert ( + get_linked_bridge( + GET_DEVICE_INFO_RESPONSE_LOCK.deviceId, + [GET_DEVICE_INFO_RESPONSE_BRIDGE_LINKED_LOCK], + ) + == GET_DEVICE_INFO_RESPONSE_BRIDGE_LINKED_LOCK.deviceId + ) + + +def test_get_linked_bridge_expect_none_returned() -> None: + """Test that get_linked_bridge returns None.""" + assert ( + get_linked_bridge( + GET_DEVICE_INFO_RESPONSE_LOCK.deviceId, + [GET_DEVICE_INFO_RESPONSE_BRIDGE_NO_LINKED_DEVICE], + ) + is None + ) From 717e5b95e65117139d35ef7fe8d1bee246a16f8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Sun, 9 Mar 2025 21:40:15 +0100 Subject: [PATCH 2412/3148] Add 900 RPM option to washer spin speed options at Home Connect (#140234) Add 900 RPM option to washer spin speed options --- homeassistant/components/home_connect/const.py | 1 + homeassistant/components/home_connect/strings.json | 2 ++ 2 files changed, 3 insertions(+) diff --git a/homeassistant/components/home_connect/const.py b/homeassistant/components/home_connect/const.py index 66c635f5d95..999bb5da13d 100644 --- a/homeassistant/components/home_connect/const.py +++ b/homeassistant/components/home_connect/const.py @@ -285,6 +285,7 @@ SPIN_SPEED_OPTIONS = { "LaundryCare.Washer.EnumType.SpinSpeed.RPM400", "LaundryCare.Washer.EnumType.SpinSpeed.RPM600", "LaundryCare.Washer.EnumType.SpinSpeed.RPM800", + "LaundryCare.Washer.EnumType.SpinSpeed.RPM900", "LaundryCare.Washer.EnumType.SpinSpeed.RPM1000", "LaundryCare.Washer.EnumType.SpinSpeed.RPM1200", "LaundryCare.Washer.EnumType.SpinSpeed.RPM1400", diff --git a/homeassistant/components/home_connect/strings.json b/homeassistant/components/home_connect/strings.json index 7b06128dbe6..ec95f5fdb92 100644 --- a/homeassistant/components/home_connect/strings.json +++ b/homeassistant/components/home_connect/strings.json @@ -461,6 +461,7 @@ "laundry_care_washer_enum_type_spin_speed_r_p_m400": "400 rpm", "laundry_care_washer_enum_type_spin_speed_r_p_m600": "600 rpm", "laundry_care_washer_enum_type_spin_speed_r_p_m800": "800 rpm", + "laundry_care_washer_enum_type_spin_speed_r_p_m900": "900 rpm", "laundry_care_washer_enum_type_spin_speed_r_p_m1000": "1000 rpm", "laundry_care_washer_enum_type_spin_speed_r_p_m1200": "1200 rpm", "laundry_care_washer_enum_type_spin_speed_r_p_m1400": "1400 rpm", @@ -1430,6 +1431,7 @@ "laundry_care_washer_enum_type_spin_speed_r_p_m400": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m400%]", "laundry_care_washer_enum_type_spin_speed_r_p_m600": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m600%]", "laundry_care_washer_enum_type_spin_speed_r_p_m800": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m800%]", + "laundry_care_washer_enum_type_spin_speed_r_p_m900": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m900%]", "laundry_care_washer_enum_type_spin_speed_r_p_m1000": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m1000%]", "laundry_care_washer_enum_type_spin_speed_r_p_m1200": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m1200%]", "laundry_care_washer_enum_type_spin_speed_r_p_m1400": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m1400%]", From 1766f87620bb0f3f63f438e01676fc5e575c183e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Sun, 9 Mar 2025 21:59:09 +0100 Subject: [PATCH 2413/3148] Refresh Home Connect token during config entry setup (#140233) * Refresh token during config entry setup * Test 500 error --------- Co-authored-by: Martin Hjelmare --- .../components/home_connect/__init__.py | 16 ++++- tests/components/home_connect/test_init.py | 61 +++++++++++++++++-- 2 files changed, 71 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/home_connect/__init__.py b/homeassistant/components/home_connect/__init__.py index 3e1bd1da156..6814ab3eed2 100644 --- a/homeassistant/components/home_connect/__init__.py +++ b/homeassistant/components/home_connect/__init__.py @@ -16,11 +16,17 @@ from aiohomeconnect.model import ( SettingKey, ) from aiohomeconnect.model.error import HomeConnectError +import aiohttp import voluptuous as vol from homeassistant.const import ATTR_DEVICE_ID, Platform from homeassistant.core import HomeAssistant, ServiceCall, callback -from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + ConfigEntryNotReady, + HomeAssistantError, + ServiceValidationError, +) from homeassistant.helpers import ( config_entry_oauth2_flow, config_validation as cv, @@ -611,6 +617,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: HomeConnectConfigEntry) session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation) config_entry_auth = AsyncConfigEntryAuth(hass, session) + try: + await config_entry_auth.async_get_access_token() + except aiohttp.ClientResponseError as err: + if 400 <= err.status < 500: + raise ConfigEntryAuthFailed from err + raise ConfigEntryNotReady from err + except aiohttp.ClientError as err: + raise ConfigEntryNotReady from err home_connect_client = HomeConnectClient(config_entry_auth) diff --git a/tests/components/home_connect/test_init.py b/tests/components/home_connect/test_init.py index 6e4e428bf6a..4287ac9d227 100644 --- a/tests/components/home_connect/test_init.py +++ b/tests/components/home_connect/test_init.py @@ -8,9 +8,8 @@ from unittest.mock import MagicMock, patch from aiohomeconnect.const import OAUTH2_TOKEN from aiohomeconnect.model import OptionKey, ProgramKey, SettingKey, StatusKey from aiohomeconnect.model.error import HomeConnectError, UnauthorizedError +import aiohttp import pytest -import requests_mock -import respx from syrupy.assertion import SnapshotAssertion from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN @@ -221,14 +220,12 @@ async def test_exception_handling( @pytest.mark.parametrize("token_expiration_time", [12345]) -@respx.mock async def test_token_refresh_success( hass: HomeAssistant, platforms: list[Platform], integration_setup: Callable[[MagicMock], Awaitable[bool]], config_entry: MockConfigEntry, aioclient_mock: AiohttpClientMocker, - requests_mock: requests_mock.Mocker, setup_credentials: None, client: MagicMock, ) -> None: @@ -236,7 +233,6 @@ async def test_token_refresh_success( assert config_entry.data["token"]["access_token"] == FAKE_ACCESS_TOKEN - requests_mock.post(OAUTH2_TOKEN, json=SERVER_ACCESS_TOKEN) aioclient_mock.post( OAUTH2_TOKEN, json=SERVER_ACCESS_TOKEN, @@ -280,6 +276,61 @@ async def test_token_refresh_success( ) +@pytest.mark.parametrize("token_expiration_time", [12345]) +@pytest.mark.parametrize( + ("aioclient_mock_args", "expected_config_entry_state"), + [ + ( + { + "status": 400, + "json": {"error": "invalid_grant"}, + }, + ConfigEntryState.SETUP_ERROR, + ), + ( + { + "status": 500, + }, + ConfigEntryState.SETUP_RETRY, + ), + ( + { + "exc": aiohttp.ClientError, + }, + ConfigEntryState.SETUP_RETRY, + ), + ], +) +async def test_token_refresh_error( + aioclient_mock_args: dict[str, Any], + expected_config_entry_state: ConfigEntryState, + hass: HomeAssistant, + platforms: list[Platform], + integration_setup: Callable[[MagicMock], Awaitable[bool]], + config_entry: MockConfigEntry, + aioclient_mock: AiohttpClientMocker, + setup_credentials: None, + client: MagicMock, +) -> None: + """Test where token is expired and the refresh attempt fails.""" + + config_entry.data["token"]["access_token"] = FAKE_ACCESS_TOKEN + + aioclient_mock.post( + OAUTH2_TOKEN, + **aioclient_mock_args, + ) + + assert config_entry.state == ConfigEntryState.NOT_LOADED + with patch( + "homeassistant.components.home_connect.HomeConnectClient", return_value=client + ): + assert not await integration_setup(client) + await hass.async_block_till_done() + + assert config_entry.state == expected_config_entry_state + + @pytest.mark.parametrize( ("exception", "expected_state"), [ From 3c6b49b34fde4eaf799dac75200414b0b285c13d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 9 Mar 2025 11:03:19 -1000 Subject: [PATCH 2414/3148] Bump aioesphomeapi to 29.5.1 (#140231) changelog: https://github.com/esphome/aioesphomeapi/compare/v29.4.1...v29.5.1 Adds a `--verbose` flag to `aioesphomeapi-discover` to help track down https://github.com/esphome/issues/issues/6311 --- 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 25d9e407044..f0eeecfdb1e 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -16,7 +16,7 @@ "loggers": ["aioesphomeapi", "noiseprotocol", "bleak_esphome"], "mqtt": ["esphome/discover/#"], "requirements": [ - "aioesphomeapi==29.4.1", + "aioesphomeapi==29.5.1", "esphome-dashboard-api==1.2.3", "bleak-esphome==2.11.0" ], diff --git a/requirements_all.txt b/requirements_all.txt index 2cf57251ac6..72d00d4fcfc 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==29.4.1 +aioesphomeapi==29.5.1 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c17f56f5eb1..a1ae217ef65 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -231,7 +231,7 @@ aioelectricitymaps==0.4.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==29.4.1 +aioesphomeapi==29.5.1 # homeassistant.components.flo aioflo==2021.11.0 From 93982241a210eb76e846ba1c140fab43384aab52 Mon Sep 17 00:00:00 2001 From: David Bonnes Date: Sun, 9 Mar 2025 21:45:47 +0000 Subject: [PATCH 2415/3148] Bump evohome-async to 1.0.4 to fix #140194 (#140230) bump client, add test for fix #140194 --- .../components/evohome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../fixtures/botched/user_locations.json | 10 +-- .../evohome/snapshots/test_climate.ambr | 62 +++++++++---------- .../evohome/snapshots/test_water_heater.ambr | 8 +-- 6 files changed, 43 insertions(+), 43 deletions(-) diff --git a/homeassistant/components/evohome/manifest.json b/homeassistant/components/evohome/manifest.json index 700872ef92b..44e4cdb1128 100644 --- a/homeassistant/components/evohome/manifest.json +++ b/homeassistant/components/evohome/manifest.json @@ -6,5 +6,5 @@ "iot_class": "cloud_polling", "loggers": ["evohome", "evohomeasync", "evohomeasync2"], "quality_scale": "legacy", - "requirements": ["evohome-async==1.0.3"] + "requirements": ["evohome-async==1.0.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index 72d00d4fcfc..fea08e809d5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -899,7 +899,7 @@ eufylife-ble-client==0.1.8 # evdev==1.6.1 # homeassistant.components.evohome -evohome-async==1.0.3 +evohome-async==1.0.4 # homeassistant.components.bryant_evolution evolutionhttp==0.0.18 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a1ae217ef65..d5bd6a6317f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -765,7 +765,7 @@ eternalegypt==0.0.16 eufylife-ble-client==0.1.8 # homeassistant.components.evohome -evohome-async==1.0.3 +evohome-async==1.0.4 # homeassistant.components.bryant_evolution evolutionhttp==0.0.18 diff --git a/tests/components/evohome/fixtures/botched/user_locations.json b/tests/components/evohome/fixtures/botched/user_locations.json index f2f4091a2dc..0016c5db007 100644 --- a/tests/components/evohome/fixtures/botched/user_locations.json +++ b/tests/components/evohome/fixtures/botched/user_locations.json @@ -8,14 +8,14 @@ "country": "UnitedKingdom", "postcode": "E1 1AA", "locationType": "Residential", - "useDaylightSaveSwitching": true, "timeZone": { - "timeZoneId": "GMTStandardTime", - "displayName": "(UTC+00:00) Dublin, Edinburgh, Lisbon, London", - "offsetMinutes": 0, - "currentOffsetMinutes": 60, + "timeZoneId": "PacificSAStandardTime", + "displayName": "(UTC-04:00) Santiago", + "offsetMinutes": -240, + "currentOffsetMinutes": -180, "supportsDaylightSaving": true }, + "useDaylightSaveSwitching": true, "locationOwner": { "userId": "2263181", "username": "user_2263181@gmail.com", diff --git a/tests/components/evohome/snapshots/test_climate.ambr b/tests/components/evohome/snapshots/test_climate.ambr index 5a6a6bff863..7fb0ae5aaec 100644 --- a/tests/components/evohome/snapshots/test_climate.ambr +++ b/tests/components/evohome/snapshots/test_climate.ambr @@ -168,10 +168,10 @@ 'target_heat_temperature': 16.0, }), 'setpoints': dict({ - 'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), - 'next_sp_temp': 18.6, - 'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), - 'this_sp_temp': 16.0, + 'next_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='America/Santiago')), + 'next_sp_temp': 16.0, + 'this_sp_from': HAFakeDatetime(2024, 7, 10, 7, 0, tzinfo=zoneinfo.ZoneInfo(key='America/Santiago')), + 'this_sp_temp': 18.1, }), 'temperature_status': dict({ 'is_available': True, @@ -215,10 +215,10 @@ 'target_heat_temperature': 17.0, }), 'setpoints': dict({ - 'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), - 'next_sp_temp': 18.6, - 'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), - 'this_sp_temp': 16.0, + 'next_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='America/Santiago')), + 'next_sp_temp': 16.0, + 'this_sp_from': HAFakeDatetime(2024, 7, 10, 7, 0, tzinfo=zoneinfo.ZoneInfo(key='America/Santiago')), + 'this_sp_temp': 18.1, }), 'temperature_status': dict({ 'is_available': False, @@ -257,19 +257,19 @@ 'activeFaults': tuple( dict({ 'fault_type': 'TempZoneActuatorLowBattery', - 'since': '2022-03-02T04:50:20+00:00', + 'since': '2022-03-02T04:50:20-03:00', }), ), 'setpoint_status': dict({ 'setpoint_mode': 'TemporaryOverride', 'target_heat_temperature': 21.0, - 'until': '2022-03-07T19:00:00+00:00', + 'until': '2022-03-07T16:00:00-03:00', }), 'setpoints': dict({ - 'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), - 'next_sp_temp': 18.6, - 'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), - 'this_sp_temp': 16.0, + 'next_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='America/Santiago')), + 'next_sp_temp': 16.0, + 'this_sp_from': HAFakeDatetime(2024, 7, 10, 7, 0, tzinfo=zoneinfo.ZoneInfo(key='America/Santiago')), + 'this_sp_temp': 18.1, }), 'temperature_status': dict({ 'is_available': True, @@ -313,10 +313,10 @@ 'target_heat_temperature': 17.0, }), 'setpoints': dict({ - 'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), - 'next_sp_temp': 18.6, - 'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), - 'this_sp_temp': 16.0, + 'next_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='America/Santiago')), + 'next_sp_temp': 16.0, + 'this_sp_from': HAFakeDatetime(2024, 7, 10, 7, 0, tzinfo=zoneinfo.ZoneInfo(key='America/Santiago')), + 'this_sp_temp': 18.1, }), 'temperature_status': dict({ 'is_available': True, @@ -360,10 +360,10 @@ 'target_heat_temperature': 17.0, }), 'setpoints': dict({ - 'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), - 'next_sp_temp': 18.6, - 'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), - 'this_sp_temp': 16.0, + 'next_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='America/Santiago')), + 'next_sp_temp': 16.0, + 'this_sp_from': HAFakeDatetime(2024, 7, 10, 7, 0, tzinfo=zoneinfo.ZoneInfo(key='America/Santiago')), + 'this_sp_temp': 18.1, }), 'temperature_status': dict({ 'is_available': True, @@ -407,10 +407,10 @@ 'target_heat_temperature': 16.0, }), 'setpoints': dict({ - 'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), - 'next_sp_temp': 18.6, - 'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), - 'this_sp_temp': 16.0, + 'next_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='America/Santiago')), + 'next_sp_temp': 16.0, + 'this_sp_from': HAFakeDatetime(2024, 7, 10, 7, 0, tzinfo=zoneinfo.ZoneInfo(key='America/Santiago')), + 'this_sp_temp': 18.1, }), 'temperature_status': dict({ 'is_available': True, @@ -450,7 +450,7 @@ 'activeFaults': tuple( dict({ 'fault_type': 'TempZoneActuatorCommunicationLost', - 'since': '2022-03-02T15:56:01+00:00', + 'since': '2022-03-02T15:56:01-03:00', }), ), 'setpoint_status': dict({ @@ -458,10 +458,10 @@ 'target_heat_temperature': 17.0, }), 'setpoints': dict({ - 'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), - 'next_sp_temp': 18.6, - 'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), - 'this_sp_temp': 16.0, + 'next_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='America/Santiago')), + 'next_sp_temp': 16.0, + 'this_sp_from': HAFakeDatetime(2024, 7, 10, 7, 0, tzinfo=zoneinfo.ZoneInfo(key='America/Santiago')), + 'this_sp_temp': 18.1, }), 'temperature_status': dict({ 'is_available': True, diff --git a/tests/components/evohome/snapshots/test_water_heater.ambr b/tests/components/evohome/snapshots/test_water_heater.ambr index 7b1bc44550a..13fb375c097 100644 --- a/tests/components/evohome/snapshots/test_water_heater.ambr +++ b/tests/components/evohome/snapshots/test_water_heater.ambr @@ -2,10 +2,10 @@ # name: test_set_operation_mode[botched] list([ dict({ - 'until': HAFakeDatetime(2024, 7, 10, 12, 0, tzinfo=datetime.timezone.utc), + 'until': HAFakeDatetime(2024, 7, 10, 12, 30, tzinfo=datetime.timezone.utc), }), dict({ - 'until': HAFakeDatetime(2024, 7, 10, 12, 0, tzinfo=datetime.timezone.utc), + 'until': HAFakeDatetime(2024, 7, 10, 12, 30, tzinfo=datetime.timezone.utc), }), ]) # --- @@ -39,9 +39,9 @@ ), 'dhw_id': '3933910', 'setpoints': dict({ - 'next_sp_from': HAFakeDatetime(2024, 7, 10, 13, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), + 'next_sp_from': HAFakeDatetime(2024, 7, 10, 8, 30, tzinfo=zoneinfo.ZoneInfo(key='America/Santiago')), 'next_sp_state': 'Off', - 'this_sp_from': HAFakeDatetime(2024, 7, 10, 12, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), + 'this_sp_from': HAFakeDatetime(2024, 7, 10, 6, 30, tzinfo=zoneinfo.ZoneInfo(key='America/Santiago')), 'this_sp_state': 'On', }), 'state_status': dict({ From b3d640982d764d0dd6bf0045802bad364d579dee Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Mon, 10 Mar 2025 00:29:25 +0100 Subject: [PATCH 2416/3148] Bump `nettigo_air_monitor` to version 4.1.0 (#140241) * Bump nam to 4.1.0 * Update test snapshot --- homeassistant/components/nam/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/nam/snapshots/test_diagnostics.ambr | 1 + 4 files changed, 4 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/nam/manifest.json b/homeassistant/components/nam/manifest.json index c3a559de50b..1c3b9db7a86 100644 --- a/homeassistant/components/nam/manifest.json +++ b/homeassistant/components/nam/manifest.json @@ -7,7 +7,7 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["nettigo_air_monitor"], - "requirements": ["nettigo-air-monitor==4.0.0"], + "requirements": ["nettigo-air-monitor==4.1.0"], "zeroconf": [ { "type": "_http._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index fea08e809d5..5e0d36fbe1b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1477,7 +1477,7 @@ netdata==1.3.0 netmap==0.7.0.2 # homeassistant.components.nam -nettigo-air-monitor==4.0.0 +nettigo-air-monitor==4.1.0 # homeassistant.components.neurio_energy neurio==0.3.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d5bd6a6317f..b136c1127cb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1243,7 +1243,7 @@ nessclient==1.1.2 netmap==0.7.0.2 # homeassistant.components.nam -nettigo-air-monitor==4.0.0 +nettigo-air-monitor==4.1.0 # homeassistant.components.nexia nexia==2.2.2 diff --git a/tests/components/nam/snapshots/test_diagnostics.ambr b/tests/components/nam/snapshots/test_diagnostics.ambr index e92e02fa1d8..135266e3376 100644 --- a/tests/components/nam/snapshots/test_diagnostics.ambr +++ b/tests/components/nam/snapshots/test_diagnostics.ambr @@ -2,6 +2,7 @@ # name: test_entry_diagnostics dict({ 'data': dict({ + 'bh1750_illuminance': None, 'bme280_humidity': 45.69, 'bme280_pressure': 1011.0117, 'bme280_temperature': 7.56, From 8192f2ef2e401b05c7fef295b70f6143daf4c970 Mon Sep 17 00:00:00 2001 From: Felipe Santos Date: Mon, 10 Mar 2025 00:17:55 -0300 Subject: [PATCH 2417/3148] Fix ONVIF camera entities ids getting shuffled on reload (#139676) --- homeassistant/components/onvif/__init__.py | 60 +++++++++++- homeassistant/components/onvif/camera.py | 5 +- tests/components/onvif/__init__.py | 4 +- tests/components/onvif/test_init.py | 102 +++++++++++++++++++++ 4 files changed, 164 insertions(+), 7 deletions(-) create mode 100644 tests/components/onvif/test_init.py diff --git a/homeassistant/components/onvif/__init__.py b/homeassistant/components/onvif/__init__.py index 02e7e28ea18..09a4aba52bf 100644 --- a/homeassistant/components/onvif/__init__.py +++ b/homeassistant/components/onvif/__init__.py @@ -19,8 +19,9 @@ from homeassistant.const import ( HTTP_DIGEST_AUTHENTICATION, Platform, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers import entity_registry as er from .const import ( CONF_ENABLE_WEBHOOKS, @@ -99,6 +100,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if device.capabilities.imaging: device.platforms += [Platform.SWITCH] + _async_migrate_camera_entities_unique_ids(hass, entry, device) + await hass.config_entries.async_forward_entry_setups(entry, device.platforms) entry.async_on_unload( @@ -155,3 +158,58 @@ async def async_populate_options(hass: HomeAssistant, entry: ConfigEntry) -> Non } hass.config_entries.async_update_entry(entry, options=options) + + +@callback +def _async_migrate_camera_entities_unique_ids( + hass: HomeAssistant, config_entry: ConfigEntry, device: ONVIFDevice +) -> None: + """Migrate unique ids of camera entities from profile index to profile token.""" + entity_reg = er.async_get(hass) + entities: list[er.RegistryEntry] = er.async_entries_for_config_entry( + entity_reg, config_entry.entry_id + ) + + mac_or_serial = device.info.mac or device.info.serial_number + old_uid_start = f"{mac_or_serial}_" + new_uid_start = f"{mac_or_serial}#" + + for entity in entities: + if entity.domain != Platform.CAMERA: + continue + + if ( + not entity.unique_id.startswith(old_uid_start) + and entity.unique_id != mac_or_serial + ): + continue + + index = 0 + if entity.unique_id.startswith(old_uid_start): + try: + index = int(entity.unique_id[len(old_uid_start) :]) + except ValueError: + LOGGER.error( + "Failed to migrate unique id for '%s' as the ONVIF profile index could not be parsed from unique id '%s'", + entity.entity_id, + entity.unique_id, + ) + continue + try: + token = device.profiles[index].token + except IndexError: + LOGGER.error( + "Failed to migrate unique id for '%s' as the ONVIF profile index '%d' parsed from unique id '%s' could not be found", + entity.entity_id, + index, + entity.unique_id, + ) + continue + new_uid = f"{new_uid_start}{token}" + LOGGER.debug( + "Migrating unique id for '%s' from '%s' to '%s'", + entity.entity_id, + entity.unique_id, + new_uid, + ) + entity_reg.async_update_entity(entity.entity_id, new_unique_id=new_uid) diff --git a/homeassistant/components/onvif/camera.py b/homeassistant/components/onvif/camera.py index da99e170ff6..fc17e912fcc 100644 --- a/homeassistant/components/onvif/camera.py +++ b/homeassistant/components/onvif/camera.py @@ -117,10 +117,7 @@ class ONVIFCameraEntity(ONVIFBaseEntity, Camera): self._attr_entity_registry_enabled_default = ( device.max_resolution == profile.video.resolution.width ) - if profile.index: - self._attr_unique_id = f"{self.mac_or_serial}_{profile.index}" - else: - self._attr_unique_id = self.mac_or_serial + self._attr_unique_id = f"{self.mac_or_serial}#{profile.token}" self._attr_name = f"{device.name} {profile.name}" @property diff --git a/tests/components/onvif/__init__.py b/tests/components/onvif/__init__.py index 8a86538b977..868624fb2e4 100644 --- a/tests/components/onvif/__init__.py +++ b/tests/components/onvif/__init__.py @@ -123,7 +123,7 @@ def setup_mock_onvif_camera( mock_onvif_camera.side_effect = mock_constructor -def setup_mock_device(mock_device, capabilities=None): +def setup_mock_device(mock_device, capabilities=None, profiles=None): """Prepare mock ONVIFDevice.""" mock_device.async_setup = AsyncMock(return_value=True) mock_device.port = 80 @@ -145,7 +145,7 @@ def setup_mock_device(mock_device, capabilities=None): ptz=None, video_source_token=None, ) - mock_device.profiles = [profile1] + mock_device.profiles = profiles or [profile1] mock_device.events = MagicMock( webhook_manager=MagicMock(state=WebHookManagerState.STARTED), pullpoint_manager=MagicMock(state=PullPointManagerState.PAUSED), diff --git a/tests/components/onvif/test_init.py b/tests/components/onvif/test_init.py new file mode 100644 index 00000000000..c176bdcc112 --- /dev/null +++ b/tests/components/onvif/test_init.py @@ -0,0 +1,102 @@ +"""Tests for the ONVIF integration __init__ module.""" + +from unittest.mock import MagicMock, patch + +import pytest + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import MAC, setup_mock_device + +from tests.common import MockConfigEntry + + +@pytest.mark.asyncio +async def test_migrate_camera_entities_unique_ids(hass: HomeAssistant) -> None: + """Test that camera entities unique ids get migrated properly.""" + config_entry = MockConfigEntry(domain="onvif", unique_id=MAC) + config_entry.add_to_hass(hass) + + entity_registry = er.async_get(hass) + + entity_with_only_mac = entity_registry.async_get_or_create( + domain="camera", + platform="onvif", + unique_id=MAC, + config_entry=config_entry, + ) + entity_with_index = entity_registry.async_get_or_create( + domain="camera", + platform="onvif", + unique_id=f"{MAC}_1", + config_entry=config_entry, + ) + # This one should not be migrated (different domain) + entity_sensor = entity_registry.async_get_or_create( + domain="sensor", + platform="onvif", + unique_id=MAC, + config_entry=config_entry, + ) + # This one should not be migrated (already migrated) + entity_migrated = entity_registry.async_get_or_create( + domain="camera", + platform="onvif", + unique_id=f"{MAC}#profile_token_2", + config_entry=config_entry, + ) + # Unparsable index + entity_unparsable_index = entity_registry.async_get_or_create( + domain="camera", + platform="onvif", + unique_id=f"{MAC}_a", + config_entry=config_entry, + ) + # Unexisting index + entity_unexisting_index = entity_registry.async_get_or_create( + domain="camera", + platform="onvif", + unique_id=f"{MAC}_9", + config_entry=config_entry, + ) + + with patch("homeassistant.components.onvif.ONVIFDevice") as mock_device: + setup_mock_device( + mock_device, + capabilities=None, + profiles=[ + MagicMock(token="profile_token_0"), + MagicMock(token="profile_token_1"), + MagicMock(token="profile_token_2"), + ], + ) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + entity_with_only_mac = entity_registry.async_get(entity_with_only_mac.entity_id) + entity_with_index = entity_registry.async_get(entity_with_index.entity_id) + entity_sensor = entity_registry.async_get(entity_sensor.entity_id) + entity_migrated = entity_registry.async_get(entity_migrated.entity_id) + + assert entity_with_only_mac is not None + assert entity_with_only_mac.unique_id == f"{MAC}#profile_token_0" + + assert entity_with_index is not None + assert entity_with_index.unique_id == f"{MAC}#profile_token_1" + + # Make sure the sensor entity is unchanged + assert entity_sensor is not None + assert entity_sensor.unique_id == MAC + + # Make sure the already migrated entity is unchanged + assert entity_migrated is not None + assert entity_migrated.unique_id == f"{MAC}#profile_token_2" + + # Make sure the unparsable index entity is unchanged + assert entity_unparsable_index is not None + assert entity_unparsable_index.unique_id == f"{MAC}_a" + + # Make sure the unexisting index entity is unchanged + assert entity_unexisting_index is not None + assert entity_unexisting_index.unique_id == f"{MAC}_9" From 40292a154d72ee2e206d642bc31c03daed3250d2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 10 Mar 2025 09:11:15 +0100 Subject: [PATCH 2418/3148] Bump github/codeql-action from 3.28.10 to 3.28.11 (#140254) Bumps [github/codeql-action](https://github.com/github/codeql-action) from 3.28.10 to 3.28.11. - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/v3.28.10...v3.28.11) --- updated-dependencies: - dependency-name: github/codeql-action dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] 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 4bdddf50c25..c4f98f2d863 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -24,11 +24,11 @@ jobs: uses: actions/checkout@v4.2.2 - name: Initialize CodeQL - uses: github/codeql-action/init@v3.28.10 + uses: github/codeql-action/init@v3.28.11 with: languages: python - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3.28.10 + uses: github/codeql-action/analyze@v3.28.11 with: category: "/language:python" From 0abe7514b99c84ef36609bad96c3094b9df64301 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 9 Mar 2025 22:15:41 -1000 Subject: [PATCH 2419/3148] Bump inkbird-ble to 0.8.0 (#140244) Adds support for the ITH-21-B and ITH-13-B models --- homeassistant/components/inkbird/manifest.json | 10 +++++++++- homeassistant/generated/bluetooth.py | 10 ++++++++++ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 21 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/inkbird/manifest.json b/homeassistant/components/inkbird/manifest.json index acc7414edac..e2e9550dd7c 100644 --- a/homeassistant/components/inkbird/manifest.json +++ b/homeassistant/components/inkbird/manifest.json @@ -21,6 +21,14 @@ { "local_name": "tps", "connectable": false + }, + { + "local_name": "ITH-13-B", + "connectable": false + }, + { + "local_name": "ITH-21-B", + "connectable": false } ], "codeowners": ["@bdraco"], @@ -28,5 +36,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/inkbird", "iot_class": "local_push", - "requirements": ["inkbird-ble==0.7.1"] + "requirements": ["inkbird-ble==0.8.0"] } diff --git a/homeassistant/generated/bluetooth.py b/homeassistant/generated/bluetooth.py index 587fea8b941..be75c675a91 100644 --- a/homeassistant/generated/bluetooth.py +++ b/homeassistant/generated/bluetooth.py @@ -356,6 +356,16 @@ BLUETOOTH: Final[list[dict[str, bool | str | int | list[int]]]] = [ "domain": "inkbird", "local_name": "tps", }, + { + "connectable": False, + "domain": "inkbird", + "local_name": "ITH-13-B", + }, + { + "connectable": False, + "domain": "inkbird", + "local_name": "ITH-21-B", + }, { "connectable": True, "domain": "iron_os", diff --git a/requirements_all.txt b/requirements_all.txt index 5e0d36fbe1b..0f345997a7a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1226,7 +1226,7 @@ influxdb-client==1.24.0 influxdb==5.3.1 # homeassistant.components.inkbird -inkbird-ble==0.7.1 +inkbird-ble==0.8.0 # homeassistant.components.insteon insteon-frontend-home-assistant==0.5.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b136c1127cb..c2d38aea5cb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1040,7 +1040,7 @@ influxdb-client==1.24.0 influxdb==5.3.1 # homeassistant.components.inkbird -inkbird-ble==0.7.1 +inkbird-ble==0.8.0 # homeassistant.components.insteon insteon-frontend-home-assistant==0.5.0 From a3e981f1489b1b9ce9f526cc5e985e50a313d34e Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 10 Mar 2025 09:16:05 +0100 Subject: [PATCH 2420/3148] Fix version not always available in onewire (#140260) --- homeassistant/components/onewire/onewirehub.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/onewire/onewirehub.py b/homeassistant/components/onewire/onewirehub.py index d65d7a90950..dc894a4242e 100644 --- a/homeassistant/components/onewire/onewirehub.py +++ b/homeassistant/components/onewire/onewirehub.py @@ -2,6 +2,7 @@ from __future__ import annotations +import contextlib from datetime import datetime, timedelta import logging import os @@ -58,7 +59,7 @@ class OneWireHub: owproxy: protocol._Proxy devices: list[OWDeviceDescription] - _version: str + _version: str | None = None def __init__(self, hass: HomeAssistant, config_entry: OneWireConfigEntry) -> None: """Initialize.""" @@ -74,7 +75,9 @@ class OneWireHub: port = self._config_entry.data[CONF_PORT] _LOGGER.debug("Initializing connection to %s:%s", host, port) self.owproxy = protocol.proxy(host, port) - self._version = self.owproxy.read(protocol.PTH_VERSION).decode() + with contextlib.suppress(protocol.OwnetError): + # Version is not available on all servers + self._version = self.owproxy.read(protocol.PTH_VERSION).decode() self.devices = _discover_devices(self.owproxy) async def initialize(self) -> None: From e831b1b2301ec0834f52c90a0015f539d54cb455 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Mon, 10 Mar 2025 09:38:44 +0100 Subject: [PATCH 2421/3148] Add support for BH1750 illuminance sensor in Nettigo Air Monitor integration (#140242) * Add support for BH1750 illuminance sensor * Update strings * Update test snapshot --- homeassistant/components/nam/const.py | 1 + homeassistant/components/nam/sensor.py | 11 ++++ homeassistant/components/nam/strings.json | 3 + tests/components/nam/fixtures/nam_data.json | 1 + .../nam/snapshots/test_diagnostics.ambr | 2 +- .../components/nam/snapshots/test_sensor.ambr | 55 +++++++++++++++++++ 6 files changed, 72 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/nam/const.py b/homeassistant/components/nam/const.py index 4b7b50b309a..2dedcf3c68a 100644 --- a/homeassistant/components/nam/const.py +++ b/homeassistant/components/nam/const.py @@ -11,6 +11,7 @@ SUFFIX_P1: Final = "_p1" SUFFIX_P2: Final = "_p2" SUFFIX_P4: Final = "_p4" +ATTR_BH1750_ILLUMINANCE: Final = "bh1750_illuminance" ATTR_BME280_HUMIDITY: Final = "bme280_humidity" ATTR_BME280_PRESSURE: Final = "bme280_pressure" ATTR_BME280_TEMPERATURE: Final = "bme280_temperature" diff --git a/homeassistant/components/nam/sensor.py b/homeassistant/components/nam/sensor.py index 4478507dc59..45cfd313e8f 100644 --- a/homeassistant/components/nam/sensor.py +++ b/homeassistant/components/nam/sensor.py @@ -19,6 +19,7 @@ from homeassistant.components.sensor import ( from homeassistant.const import ( CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONCENTRATION_PARTS_PER_MILLION, + LIGHT_LUX, PERCENTAGE, SIGNAL_STRENGTH_DECIBELS_MILLIWATT, EntityCategory, @@ -33,6 +34,7 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util.dt import utcnow from .const import ( + ATTR_BH1750_ILLUMINANCE, ATTR_BME280_HUMIDITY, ATTR_BME280_PRESSURE, ATTR_BME280_TEMPERATURE, @@ -83,6 +85,15 @@ class NAMSensorEntityDescription(SensorEntityDescription): SENSORS: tuple[NAMSensorEntityDescription, ...] = ( + NAMSensorEntityDescription( + key=ATTR_BH1750_ILLUMINANCE, + translation_key="bh1750_illuminance", + suggested_display_precision=0, + native_unit_of_measurement=LIGHT_LUX, + device_class=SensorDeviceClass.ILLUMINANCE, + state_class=SensorStateClass.MEASUREMENT, + value=lambda sensors: sensors.bh1750_illuminance, + ), NAMSensorEntityDescription( key=ATTR_BME280_HUMIDITY, translation_key="bme280_humidity", diff --git a/homeassistant/components/nam/strings.json b/homeassistant/components/nam/strings.json index 2caa4d8bd97..22fb1dc30d2 100644 --- a/homeassistant/components/nam/strings.json +++ b/homeassistant/components/nam/strings.json @@ -54,6 +54,9 @@ }, "entity": { "sensor": { + "bh1750_illuminance": { + "name": "BH1750 illuminance" + }, "bme280_humidity": { "name": "BME280 humidity" }, diff --git a/tests/components/nam/fixtures/nam_data.json b/tests/components/nam/fixtures/nam_data.json index 82dacbefb34..47ebe099dc7 100644 --- a/tests/components/nam/fixtures/nam_data.json +++ b/tests/components/nam/fixtures/nam_data.json @@ -26,6 +26,7 @@ { "value_type": "temperature", "value": "6.26" }, { "value_type": "HECA_temperature", "value": "7.95" }, { "value_type": "HECA_humidity", "value": "49.97" }, + { "value_type": "ambient_light", "value": "298.45" }, { "value_type": "signal", "value": "-72" } ] } diff --git a/tests/components/nam/snapshots/test_diagnostics.ambr b/tests/components/nam/snapshots/test_diagnostics.ambr index 135266e3376..c0009899d16 100644 --- a/tests/components/nam/snapshots/test_diagnostics.ambr +++ b/tests/components/nam/snapshots/test_diagnostics.ambr @@ -2,7 +2,7 @@ # name: test_entry_diagnostics dict({ 'data': dict({ - 'bh1750_illuminance': None, + 'bh1750_illuminance': 298.45, 'bme280_humidity': 45.69, 'bme280_pressure': 1011.0117, 'bme280_temperature': 7.56, diff --git a/tests/components/nam/snapshots/test_sensor.ambr b/tests/components/nam/snapshots/test_sensor.ambr index 429d069b741..c6c32737a31 100644 --- a/tests/components/nam/snapshots/test_sensor.ambr +++ b/tests/components/nam/snapshots/test_sensor.ambr @@ -1,4 +1,59 @@ # serializer version: 1 +# name: test_sensor[sensor.nettigo_air_monitor_bh1750_illuminance-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.nettigo_air_monitor_bh1750_illuminance', + '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': None, + 'original_name': 'BH1750 illuminance', + 'platform': 'nam', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'bh1750_illuminance', + 'unique_id': 'aa:bb:cc:dd:ee:ff-bh1750_illuminance', + 'unit_of_measurement': 'lx', + }) +# --- +# name: test_sensor[sensor.nettigo_air_monitor_bh1750_illuminance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'illuminance', + 'friendly_name': 'Nettigo Air Monitor BH1750 illuminance', + 'state_class': , + 'unit_of_measurement': 'lx', + }), + 'context': , + 'entity_id': 'sensor.nettigo_air_monitor_bh1750_illuminance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '298.45', + }) +# --- # name: test_sensor[sensor.nettigo_air_monitor_bme280_humidity-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 25f15c11494854fa1eb487f4503bc9bab797c95c Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 10 Mar 2025 10:46:54 +0100 Subject: [PATCH 2422/3148] Use short-hand attributes in remote-rpi-gpio (#140263) --- .../remote_rpi_gpio/binary_sensor.py | 23 ++++++---------- .../components/remote_rpi_gpio/switch.py | 27 +++++-------------- 2 files changed, 15 insertions(+), 35 deletions(-) diff --git a/homeassistant/components/remote_rpi_gpio/binary_sensor.py b/homeassistant/components/remote_rpi_gpio/binary_sensor.py index 42e8517c1e8..1d970bb3541 100644 --- a/homeassistant/components/remote_rpi_gpio/binary_sensor.py +++ b/homeassistant/components/remote_rpi_gpio/binary_sensor.py @@ -2,6 +2,7 @@ from __future__ import annotations +from gpiozero import DigitalInputDevice import requests import voluptuous as vol @@ -48,10 +49,10 @@ def setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Raspberry PI GPIO devices.""" - address = config["host"] + address = config[CONF_HOST] invert_logic = config[CONF_INVERT_LOGIC] pull_mode = config[CONF_PULL_MODE] - ports = config["ports"] + ports = config[CONF_PORTS] bouncetime = config[CONF_BOUNCETIME] / 1000 devices = [] @@ -71,9 +72,11 @@ class RemoteRPiGPIOBinarySensor(BinarySensorEntity): _attr_should_poll = False - def __init__(self, name, sensor, invert_logic): + def __init__( + self, name: str | None, sensor: DigitalInputDevice, invert_logic: bool + ) -> None: """Initialize the RPi binary sensor.""" - self._name = name + self._attr_name = name self._invert_logic = invert_logic self._state = False self._sensor = sensor @@ -90,20 +93,10 @@ class RemoteRPiGPIOBinarySensor(BinarySensorEntity): self._sensor.when_activated = read_gpio @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def is_on(self): + def is_on(self) -> bool: """Return the state of the entity.""" return self._state != self._invert_logic - @property - def device_class(self): - """Return the class of this sensor, from DEVICE_CLASSES.""" - return - def update(self) -> None: """Update the GPIO state.""" try: diff --git a/homeassistant/components/remote_rpi_gpio/switch.py b/homeassistant/components/remote_rpi_gpio/switch.py index 91b389c5a1e..25f95045e4b 100644 --- a/homeassistant/components/remote_rpi_gpio/switch.py +++ b/homeassistant/components/remote_rpi_gpio/switch.py @@ -4,6 +4,7 @@ from __future__ import annotations from typing import Any +from gpiozero import LED import voluptuous as vol from homeassistant.components.switch import ( @@ -57,37 +58,23 @@ def setup_platform( class RemoteRPiGPIOSwitch(SwitchEntity): """Representation of a Remote Raspberry Pi GPIO.""" + _attr_assumed_state = True _attr_should_poll = False - def __init__(self, name, led): + def __init__(self, name: str | None, led: LED) -> None: """Initialize the pin.""" - self._name = name or DEVICE_DEFAULT_NAME - self._state = False + self._attr_name = name or DEVICE_DEFAULT_NAME + self._attr_is_on = False self._switch = led - @property - def name(self): - """Return the name of the switch.""" - return self._name - - @property - def assumed_state(self): - """If unable to access real state of the entity.""" - return True - - @property - def is_on(self): - """Return true if device is on.""" - return self._state - def turn_on(self, **kwargs: Any) -> None: """Turn the device on.""" write_output(self._switch, 1) - self._state = True + self._attr_is_on = True self.schedule_update_ha_state() def turn_off(self, **kwargs: Any) -> None: """Turn the device off.""" write_output(self._switch, 0) - self._state = False + self._attr_is_on = False self.schedule_update_ha_state() From 6284a83a34baa5db490781edbea660510063c143 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Mon, 10 Mar 2025 11:04:49 +0100 Subject: [PATCH 2423/3148] Fix `client_id` not generated when connecting to the MQTT broker (#140264) Fix client_id not generated when connecting to the MQTT broker --- homeassistant/components/mqtt/client.py | 10 ++++--- tests/components/mqtt/test_client.py | 36 +++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/mqtt/client.py b/homeassistant/components/mqtt/client.py index d35b3db7518..e985dc9b87f 100644 --- a/homeassistant/components/mqtt/client.py +++ b/homeassistant/components/mqtt/client.py @@ -15,6 +15,7 @@ import socket import ssl import time from typing import TYPE_CHECKING, Any +from uuid import uuid4 import certifi @@ -292,7 +293,7 @@ class MqttClientSetup: """ # We don't import on the top because some integrations # should be able to optionally rely on MQTT. - import paho.mqtt.client as mqtt # pylint: disable=import-outside-toplevel + from paho.mqtt import client as mqtt # pylint: disable=import-outside-toplevel # pylint: disable-next=import-outside-toplevel from .async_client import AsyncMQTTClient @@ -309,9 +310,10 @@ class MqttClientSetup: clean_session = True if (client_id := config.get(CONF_CLIENT_ID)) is None: - # PAHO MQTT relies on the MQTT server to generate random client IDs. - # However, that feature is not mandatory so we generate our own. - client_id = None + # PAHO MQTT relies on the MQTT server to generate random client ID + # for protocol version 3.1, however, that feature is not mandatory + # so we generate our own. + client_id = mqtt._base62(uuid4().int, padding=22) # noqa: SLF001 transport: str = config.get(CONF_TRANSPORT, DEFAULT_TRANSPORT) self._client = AsyncMQTTClient( callback_api_version=mqtt.CallbackAPIVersion.VERSION2, diff --git a/tests/components/mqtt/test_client.py b/tests/components/mqtt/test_client.py index 9d5401fd437..0dbbff58026 100644 --- a/tests/components/mqtt/test_client.py +++ b/tests/components/mqtt/test_client.py @@ -1556,6 +1556,42 @@ async def test_setup_uses_certificate_on_certificate_set_to_auto_and_insecure( assert insecure_check["insecure"] == insecure_param +@pytest.mark.parametrize( + ("mqtt_config_entry_data", "client_id"), + [ + ( + { + mqtt.CONF_BROKER: "mock-broker", + "client_id": "random01234random0124", + }, + "random01234random0124", + ), + ( + { + mqtt.CONF_BROKER: "mock-broker", + }, + None, + ), + ], +) +async def test_client_id_is_set( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + client_id: str | None, +) -> None: + """Test setup defaults for tls.""" + with patch( + "homeassistant.components.mqtt.async_client.AsyncMQTTClient" + ) as async_client_mock: + await mqtt_mock_entry() + await hass.async_block_till_done() + assert async_client_mock.call_count == 1 + call_params: dict[str, Any] = async_client_mock.call_args[1] + assert "client_id" in call_params + assert client_id is None or client_id == call_params["client_id"] + assert call_params["client_id"] is not None + + @pytest.mark.parametrize( "mqtt_config_entry_data", [ From 994bf2702402da9252321be46db5abd07aff9332 Mon Sep 17 00:00:00 2001 From: Maikel Punie Date: Mon, 10 Mar 2025 11:45:37 +0100 Subject: [PATCH 2424/3148] Bump velbusaio to 2025.3.0 (#140267) --- homeassistant/components/velbus/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/velbus/manifest.json b/homeassistant/components/velbus/manifest.json index 29504277651..ff30ee14a8a 100644 --- a/homeassistant/components/velbus/manifest.json +++ b/homeassistant/components/velbus/manifest.json @@ -14,7 +14,7 @@ "velbus-protocol" ], "quality_scale": "bronze", - "requirements": ["velbus-aio==2025.1.1"], + "requirements": ["velbus-aio==2025.3.0"], "usb": [ { "vid": "10CF", diff --git a/requirements_all.txt b/requirements_all.txt index 0f345997a7a..11079d72e1d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3000,7 +3000,7 @@ vallox-websocket-api==5.3.0 vehicle==2.2.2 # homeassistant.components.velbus -velbus-aio==2025.1.1 +velbus-aio==2025.3.0 # homeassistant.components.venstar venstarcolortouch==0.19 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c2d38aea5cb..8d82c9f673e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2416,7 +2416,7 @@ vallox-websocket-api==5.3.0 vehicle==2.2.2 # homeassistant.components.velbus -velbus-aio==2025.1.1 +velbus-aio==2025.3.0 # homeassistant.components.venstar venstarcolortouch==0.19 From 76e76a417c372db1dd5c7a9e4434f95e106c608c Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 10 Mar 2025 12:19:18 +0100 Subject: [PATCH 2425/3148] Fix dryer operating state in SmartThings (#140277) --- .../components/smartthings/__init__.py | 3 + tests/components/smartthings/conftest.py | 1 + .../device_status/da_wm_wd_000001_1.json | 692 ++++++++++++++++++ .../fixtures/devices/da_wm_wd_000001_1.json | 205 ++++++ .../smartthings/snapshots/test_init.ambr | 33 + .../smartthings/snapshots/test_sensor.ambr | 467 ++++++++++++ .../smartthings/snapshots/test_switch.ambr | 47 ++ 7 files changed, 1448 insertions(+) create mode 100644 tests/components/smartthings/fixtures/device_status/da_wm_wd_000001_1.json create mode 100644 tests/components/smartthings/fixtures/devices/da_wm_wd_000001_1.json diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py index 3e0e66e890f..9d8881bc1c1 100644 --- a/homeassistant/components/smartthings/__init__.py +++ b/homeassistant/components/smartthings/__init__.py @@ -219,6 +219,9 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: KEEP_CAPABILITY_QUIRK: dict[ Capability | str, Callable[[dict[Attribute | str, Status]], bool] ] = { + Capability.DRYER_OPERATING_STATE: ( + lambda status: status[Attribute.SUPPORTED_MACHINE_STATES].value is not None + ), Capability.WASHER_OPERATING_STATE: ( lambda status: status[Attribute.SUPPORTED_MACHINE_STATES].value is not None ), diff --git a/tests/components/smartthings/conftest.py b/tests/components/smartthings/conftest.py index 7f27d3eecc4..db6e49b2135 100644 --- a/tests/components/smartthings/conftest.py +++ b/tests/components/smartthings/conftest.py @@ -104,6 +104,7 @@ def mock_smartthings() -> Generator[AsyncMock]: "iphone", "da_wm_dw_000001", "da_wm_wd_000001", + "da_wm_wd_000001_1", "da_wm_wm_000001", "da_wm_wm_000001_1", "da_rvc_normal_000001", diff --git a/tests/components/smartthings/fixtures/device_status/da_wm_wd_000001_1.json b/tests/components/smartthings/fixtures/device_status/da_wm_wd_000001_1.json new file mode 100644 index 00000000000..b45bac95237 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/da_wm_wd_000001_1.json @@ -0,0 +1,692 @@ +{ + "components": { + "hca.main": { + "hca.dryerMode": { + "mode": { + "value": "normal", + "timestamp": "2025-03-09T16:31:41.247Z" + }, + "supportedModes": { + "value": ["normal", "quickDry", "mix", "timeDry"], + "timestamp": "2025-03-09T16:31:40.486Z" + } + } + }, + "main": { + "custom.dryerWrinklePrevent": { + "operatingState": { + "value": "ready", + "timestamp": "2025-03-09T16:31:40.486Z" + }, + "dryerWrinklePrevent": { + "value": "off", + "timestamp": "2025-03-09T16:31:41.077Z" + } + }, + "samsungce.dryerDryingTemperature": { + "dryingTemperature": { + "value": null, + "timestamp": "2021-04-02T18:31:36.756Z" + }, + "supportedDryingTemperature": { + "value": null, + "timestamp": "2021-04-02T18:29:52.258Z" + } + }, + "samsungce.welcomeMessage": { + "welcomeMessage": { + "value": null, + "timestamp": "2021-04-02T18:32:37.913Z" + } + }, + "samsungce.dongleSoftwareInstallation": { + "status": { + "value": "completed", + "timestamp": "2022-06-17T17:07:35.734Z" + } + }, + "samsungce.dryerCyclePreset": { + "maxNumberOfPresets": { + "value": 10, + "timestamp": "2025-03-09T16:31:41.229Z" + }, + "presets": { + "value": null, + "timestamp": "2021-04-02T18:30:36.772Z" + } + }, + "samsungce.deviceIdentification": { + "micomAssayCode": { + "value": "20221341", + "timestamp": "2025-03-09T16:31:40.834Z" + }, + "modelName": { + "value": null, + "timestamp": "2021-04-02T18:29:53.622Z" + }, + "serialNumber": { + "value": null, + "timestamp": "2021-04-02T18:29:52.641Z" + }, + "serialNumberExtra": { + "value": null, + "timestamp": "2021-04-02T18:29:51.653Z" + }, + "modelClassificationCode": { + "value": "30010102001211000103000000000000", + "timestamp": "2025-03-09T16:31:40.834Z" + }, + "description": { + "value": "DA_WM_A51_20_COMMON_DV6800N/DC92-01967B_0404", + "timestamp": "2025-03-09T16:31:40.834Z" + }, + "releaseYear": { + "value": null + }, + "binaryId": { + "value": "DA_WM_A51_20_COMMON", + "timestamp": "2025-03-09T19:07:40.295Z" + } + }, + "switch": { + "switch": { + "value": "off", + "timestamp": "2025-03-09T19:47:36.549Z" + } + }, + "samsungce.quickControl": { + "version": { + "value": null + } + }, + "samsungce.dryerFreezePrevent": { + "operatingState": { + "value": null + } + }, + "ocf": { + "st": { + "value": null, + "timestamp": "2020-06-20T10:01:02.741Z" + }, + "mndt": { + "value": null, + "timestamp": "2020-06-25T01:53:25.278Z" + }, + "mnfv": { + "value": "DA_WM_A51_20_COMMON_30230708", + "timestamp": "2024-12-15T10:53:49.561Z" + }, + "mnhw": { + "value": "ARTIK051", + "timestamp": "2024-12-15T10:53:49.561Z" + }, + "di": { + "value": "3a6c4e05-811d-5041-e956-3d04c424cbcd", + "timestamp": "2024-12-15T10:53:49.561Z" + }, + "mnsl": { + "value": "http://www.samsung.com", + "timestamp": "2024-12-15T10:53:49.561Z" + }, + "dmv": { + "value": "res.1.1.0,sh.1.1.0", + "timestamp": "2024-12-15T10:53:49.561Z" + }, + "n": { + "value": "[dryer] Samsung", + "timestamp": "2024-12-15T10:53:49.561Z" + }, + "mnmo": { + "value": "DA_WM_A51_20_COMMON|20221341|30010102001211000103000000000000", + "timestamp": "2024-12-15T10:53:49.561Z" + }, + "vid": { + "value": "DA-WM-WD-000001", + "timestamp": "2024-12-15T10:53:49.561Z" + }, + "mnmn": { + "value": "Samsung Electronics", + "timestamp": "2024-12-15T10:53:49.561Z" + }, + "mnml": { + "value": "http://www.samsung.com", + "timestamp": "2024-12-15T10:53:49.561Z" + }, + "mnpv": { + "value": "DAWIT 2.0", + "timestamp": "2024-12-15T10:53:49.561Z" + }, + "mnos": { + "value": "TizenRT 1.0 + IPv6", + "timestamp": "2024-12-15T10:53:49.561Z" + }, + "pi": { + "value": "3a6c4e05-811d-5041-e956-3d04c424cbcd", + "timestamp": "2024-12-15T10:53:49.561Z" + }, + "icv": { + "value": "core.1.1.0", + "timestamp": "2024-12-15T10:53:49.561Z" + } + }, + "custom.dryerDryLevel": { + "dryerDryLevel": { + "value": "2", + "timestamp": "2025-03-09T19:47:36.806Z" + }, + "supportedDryerDryLevel": { + "value": ["none", "1", "2", "3"], + "timestamp": "2020-11-18T20:16:43.428Z" + } + }, + "samsungce.dryerAutoCycleLink": { + "dryerAutoCycleLink": { + "value": null, + "timestamp": "2020-08-11T12:41:38.646Z" + } + }, + "samsungce.dryerCycle": { + "dryerCycle": { + "value": "Table_00_Course_9A", + "timestamp": "2025-03-09T16:31:41.247Z" + }, + "supportedCycles": { + "value": [ + { + "cycle": "9A", + "supportedOptions": { + "dryingLevel": { + "raw": "D20E", + "default": "2", + "options": ["1", "2", "3"] + } + } + }, + { + "cycle": "CA", + "supportedOptions": { + "dryingLevel": { + "raw": "D10E", + "default": "1", + "options": ["1", "2", "3"] + } + } + }, + { + "cycle": "DB", + "supportedOptions": { + "dryingLevel": { + "raw": "D204", + "default": "2", + "options": ["2"] + } + } + }, + { + "cycle": "99", + "supportedOptions": { + "dryingLevel": { + "raw": "D20E", + "default": "2", + "options": ["1", "2", "3"] + } + } + }, + { + "cycle": "93", + "supportedOptions": { + "dryingLevel": { + "raw": "D102", + "default": "1", + "options": ["1"] + } + } + }, + { + "cycle": "B5", + "supportedOptions": { + "dryingLevel": { + "raw": "D102", + "default": "1", + "options": ["1"] + } + } + }, + { + "cycle": "D7", + "supportedOptions": { + "dryingLevel": { + "raw": "D204", + "default": "2", + "options": ["2"] + } + } + }, + { + "cycle": "A5", + "supportedOptions": { + "dryingLevel": { + "raw": "D204", + "default": "2", + "options": ["2"] + } + } + }, + { + "cycle": "96", + "supportedOptions": { + "dryingLevel": { + "raw": "D000", + "default": "none", + "options": [] + } + } + }, + { + "cycle": "97", + "supportedOptions": { + "dryingLevel": { + "raw": "D000", + "default": "none", + "options": [] + } + } + }, + { + "cycle": "7F", + "supportedOptions": { + "dryingLevel": { + "raw": "D000", + "default": "none", + "options": [] + } + } + }, + { + "cycle": "98", + "supportedOptions": { + "dryingLevel": { + "raw": "D000", + "default": "none", + "options": [] + } + } + }, + { + "cycle": "EB", + "supportedOptions": { + "dryingLevel": { + "raw": "D204", + "default": "2", + "options": ["2"] + } + } + }, + { + "cycle": "B6", + "supportedOptions": { + "dryingLevel": { + "raw": "D20E", + "default": "2", + "options": ["1", "2", "3"] + } + } + } + ], + "timestamp": "2025-02-10T02:24:03.524Z" + }, + "referenceTable": { + "value": { + "id": "Table_00" + }, + "timestamp": "2025-03-09T16:31:41.247Z" + }, + "specializedFunctionClassification": { + "value": 4, + "timestamp": "2025-03-09T16:31:40.486Z" + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": [ + "samsungce.dryerDelayEnd", + "dryerOperatingState", + "samsungce.dryerCyclePreset", + "samsungce.welcomeMessage", + "samsungce.dongleSoftwareInstallation", + "sec.wifiConfiguration", + "samsungce.quickControl", + "samsungce.deviceInfoPrivate", + "demandResponseLoadControl", + "samsungce.dryerFreezePrevent", + "samsungce.dryerDryingTemperature", + "sec.diagnosticsInformation" + ], + "timestamp": "2024-07-02T14:42:38.334Z" + } + }, + "samsungce.driverVersion": { + "versionNumber": { + "value": 24110101, + "timestamp": "2024-12-02T07:43:41.263Z" + } + }, + "sec.diagnosticsInformation": { + "logType": { + "value": null + }, + "endpoint": { + "value": null + }, + "minVersion": { + "value": null + }, + "signinPermission": { + "value": null + }, + "setupId": { + "value": null + }, + "protocolType": { + "value": null + }, + "tsId": { + "value": null + }, + "mnId": { + "value": null + }, + "dumpType": { + "value": null + } + }, + "samsungce.kidsLock": { + "lockState": { + "value": "unlocked", + "timestamp": "2025-03-09T16:31:40.882Z" + } + }, + "demandResponseLoadControl": { + "drlcStatus": { + "value": null + } + }, + "samsungce.detergentOrder": { + "alarmEnabled": { + "value": false, + "timestamp": "2025-03-09T16:31:40.486Z" + }, + "orderThreshold": { + "value": 0, + "unit": "cc", + "timestamp": "2025-03-09T16:31:40.486Z" + } + }, + "powerConsumptionReport": { + "powerConsumption": { + "value": { + "energy": 796400, + "deltaEnergy": 0, + "power": 0, + "powerEnergy": 0.0, + "persistedEnergy": 0, + "energySaved": 0, + "start": "2025-03-09T19:47:26Z", + "end": "2025-03-09T19:47:37Z" + }, + "timestamp": "2025-03-09T19:47:37.283Z" + } + }, + "dryerOperatingState": { + "completionTime": { + "value": "2025-03-09T22:55:37Z", + "timestamp": "2025-03-09T19:47:37.015Z" + }, + "machineState": { + "value": "stop", + "timestamp": "2025-03-09T19:47:37.015Z" + }, + "supportedMachineStates": { + "value": ["stop", "run", "pause"], + "timestamp": "2025-03-09T16:31:41.172Z" + }, + "dryerJobState": { + "value": "none", + "timestamp": "2025-03-09T19:47:37.015Z" + } + }, + "samsungce.detergentState": { + "remainingAmount": { + "value": 0, + "unit": "cc", + "timestamp": "2025-03-09T16:31:40.486Z" + }, + "dosage": { + "value": 0, + "unit": "cc", + "timestamp": "2025-03-09T16:31:40.486Z" + }, + "initialAmount": { + "value": 0, + "unit": "cc", + "timestamp": "2025-03-09T16:31:40.486Z" + }, + "detergentType": { + "value": "none", + "timestamp": "2021-04-02T18:29:51.428Z" + } + }, + "samsungce.dryerDelayEnd": { + "remainingTime": { + "value": 0, + "unit": "min", + "timestamp": "2025-03-09T16:31:41.172Z" + } + }, + "refresh": {}, + "custom.jobBeginningStatus": { + "jobBeginningStatus": { + "value": null, + "timestamp": "2020-06-25T01:53:34.974Z" + } + }, + "execute": { + "data": { + "value": { + "payload": { + "rt": ["x.com.samsung.da.information"], + "if": ["oic.if.baseline", "oic.if.a"], + "x.com.samsung.da.modelNum": "DA_WM_A51_20_COMMON|20221341|30010102001211000103000000000000", + "x.com.samsung.da.description": "DA_WM_A51_20_COMMON_DV6800N/DC92-01967B_0404", + "x.com.samsung.da.serialNum": "0T625AEN100200N", + "x.com.samsung.da.otnDUID": "SHCDM6YAPCCXC", + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "DA_WM_A51_20_COMMON|20221341|30010102001211000103000000000000", + "x.com.samsung.da.type": "Software", + "x.com.samsung.da.number": "02198A220728(E256)", + "x.com.samsung.da.newVersionAvailable": "0" + }, + { + "x.com.samsung.da.id": "1", + "x.com.samsung.da.description": "DA_WM_A51_20_COMMON", + "x.com.samsung.da.type": "Firmware", + "x.com.samsung.da.number": "17111305,19060420", + "x.com.samsung.da.newVersionAvailable": "0" + } + ] + } + }, + "data": { + "href": "/information/vs/0" + }, + "timestamp": "2023-08-07T00:06:05.984Z" + } + }, + "sec.wifiConfiguration": { + "autoReconnection": { + "value": null + }, + "minVersion": { + "value": null + }, + "supportedWiFiFreq": { + "value": null + }, + "supportedAuthType": { + "value": null + }, + "protocolType": { + "value": null + } + }, + "remoteControlStatus": { + "remoteControlEnabled": { + "value": "false", + "timestamp": "2025-03-09T16:31:41.180Z" + } + }, + "custom.supportedOptions": { + "course": { + "value": null + }, + "referenceTable": { + "value": { + "id": "Table_00" + }, + "timestamp": "2025-03-09T16:31:41.247Z" + }, + "supportedCourses": { + "value": [ + "9A", + "CA", + "DB", + "99", + "93", + "B5", + "D7", + "A5", + "96", + "97", + "7F", + "98", + "EB", + "B6" + ], + "timestamp": "2025-03-09T16:31:40.486Z" + } + }, + "custom.energyType": { + "energyType": { + "value": "2.0", + "timestamp": "2022-06-17T17:07:35.734Z" + }, + "energySavingSupport": { + "value": false, + "timestamp": "2022-06-17T17:07:35.734Z" + }, + "drMaxDuration": { + "value": null + }, + "energySavingLevel": { + "value": null + }, + "energySavingInfo": { + "value": null + }, + "supportedEnergySavingLevels": { + "value": null + }, + "energySavingOperation": { + "value": null + }, + "notificationTemplateID": { + "value": null + }, + "energySavingOperationSupport": { + "value": null + } + }, + "samsungce.dryerOperatingState": { + "operatingState": { + "value": "ready", + "timestamp": "2025-03-09T19:47:37.015Z" + }, + "supportedOperatingStates": { + "value": ["ready", "running", "paused"], + "timestamp": "2022-11-01T12:48:22.390Z" + }, + "scheduledJobs": { + "value": [ + { + "jobName": "drying", + "timeInMin": 192 + }, + { + "jobName": "cooling", + "timeInMin": 1 + } + ], + "timestamp": "2025-03-09T16:31:40.486Z" + }, + "progress": { + "value": 1, + "unit": "%", + "timestamp": "2025-03-09T19:47:37.015Z" + }, + "remainingTimeStr": { + "value": "03:08", + "timestamp": "2025-03-09T19:47:37.015Z" + }, + "dryerJobState": { + "value": "none", + "timestamp": "2025-03-09T19:47:37.015Z" + }, + "remainingTime": { + "value": 188, + "unit": "min", + "timestamp": "2025-03-09T19:47:37.015Z" + } + }, + "samsungce.softwareUpdate": { + "targetModule": { + "value": null + }, + "otnDUID": { + "value": "SHCDM6YAPCCXC", + "timestamp": "2025-03-09T16:31:40.834Z" + }, + "lastUpdatedDate": { + "value": null + }, + "availableModules": { + "value": [], + "timestamp": "2024-12-01T21:16:50.598Z" + }, + "newVersionAvailable": { + "value": false, + "timestamp": "2024-12-01T21:16:50.598Z" + }, + "operatingState": { + "value": null + }, + "progress": { + "value": null + } + }, + "samsungce.dryerDryingTime": { + "supportedDryingTime": { + "value": ["0", "30", "60", "90", "120", "150"], + "timestamp": "2021-04-02T18:29:51.428Z" + }, + "dryingTime": { + "value": "0", + "unit": "min", + "timestamp": "2025-03-09T16:31:41.077Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/devices/da_wm_wd_000001_1.json b/tests/components/smartthings/fixtures/devices/da_wm_wd_000001_1.json new file mode 100644 index 00000000000..995646438c4 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/da_wm_wd_000001_1.json @@ -0,0 +1,205 @@ +{ + "items": [ + { + "deviceId": "3a6c4e05-811d-5041-e956-3d04c424cbcd", + "name": "[dryer] Samsung", + "label": "Seca-Roupa", + "manufacturerName": "Samsung Electronics", + "presentationId": "DA-WM-WD-000001", + "deviceManufacturerCode": "Samsung Electronics", + "locationId": "06efa178-ad2f-4d22-838c-d63e05e5a58a", + "ownerId": "1a5f5619-e9ec-4302-beb9-633bb1657897", + "roomId": "dde24053-9707-49a5-ba0e-f19681514f37", + "deviceTypeName": "Samsung OCF Dryer", + "components": [ + { + "id": "main", + "label": "Seca-Roupa", + "capabilities": [ + { + "id": "ocf", + "version": 1 + }, + { + "id": "execute", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "switch", + "version": 1 + }, + { + "id": "remoteControlStatus", + "version": 1 + }, + { + "id": "dryerOperatingState", + "version": 1 + }, + { + "id": "powerConsumptionReport", + "version": 1 + }, + { + "id": "demandResponseLoadControl", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + }, + { + "id": "custom.dryerDryLevel", + "version": 1 + }, + { + "id": "custom.dryerWrinklePrevent", + "version": 1 + }, + { + "id": "custom.energyType", + "version": 1 + }, + { + "id": "custom.jobBeginningStatus", + "version": 1 + }, + { + "id": "custom.supportedOptions", + "version": 1 + }, + { + "id": "samsungce.softwareUpdate", + "version": 1 + }, + { + "id": "samsungce.detergentOrder", + "version": 1 + }, + { + "id": "samsungce.detergentState", + "version": 1 + }, + { + "id": "samsungce.deviceIdentification", + "version": 1 + }, + { + "id": "samsungce.dongleSoftwareInstallation", + "version": 1 + }, + { + "id": "samsungce.driverVersion", + "version": 1 + }, + { + "id": "samsungce.dryerAutoCycleLink", + "version": 1 + }, + { + "id": "samsungce.dryerCycle", + "version": 1 + }, + { + "id": "samsungce.dryerCyclePreset", + "version": 1 + }, + { + "id": "samsungce.dryerDelayEnd", + "version": 1 + }, + { + "id": "samsungce.dryerDryingTemperature", + "version": 1 + }, + { + "id": "samsungce.dryerDryingTime", + "version": 1 + }, + { + "id": "samsungce.dryerFreezePrevent", + "version": 1 + }, + { + "id": "samsungce.dryerOperatingState", + "version": 1 + }, + { + "id": "samsungce.kidsLock", + "version": 1 + }, + { + "id": "samsungce.welcomeMessage", + "version": 1 + }, + { + "id": "samsungce.quickControl", + "version": 1 + }, + { + "id": "sec.diagnosticsInformation", + "version": 1 + }, + { + "id": "sec.wifiConfiguration", + "version": 1 + } + ], + "categories": [ + { + "name": "Dryer", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "hca.main", + "label": "hca.main", + "capabilities": [ + { + "id": "hca.dryerMode", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2020-06-20T10:00:42Z", + "profile": { + "id": "53a1d049-eeda-396c-8324-e33438ef57be" + }, + "ocf": { + "ocfDeviceType": "oic.d.dryer", + "name": "[dryer] Samsung", + "specVersion": "core.1.1.0", + "verticalDomainSpecVersion": "res.1.1.0,sh.1.1.0", + "manufacturerName": "Samsung Electronics", + "modelNumber": "DA_WM_A51_20_COMMON|20221341|30010102001211000103000000000000", + "platformVersion": "DAWIT 2.0", + "platformOS": "TizenRT 1.0 + IPv6", + "hwVersion": "ARTIK051", + "firmwareVersion": "DA_WM_A51_20_COMMON_30230708", + "vendorId": "DA-WM-WD-000001", + "vendorResourceClientServerVersion": "ARTIK051 Release 2.210224.1", + "lastSignupTime": "2020-11-19T04:43:50.736Z", + "transferCandidate": false, + "additionalAuthCodeRequired": false + }, + "type": "OCF", + "restrictionTier": 0, + "allowed": null, + "executionContext": "CLOUD", + "relationships": [] + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/snapshots/test_init.ambr b/tests/components/smartthings/snapshots/test_init.ambr index 7c2589590c5..2c45c466fa2 100644 --- a/tests/components/smartthings/snapshots/test_init.ambr +++ b/tests/components/smartthings/snapshots/test_init.ambr @@ -497,6 +497,39 @@ 'via_device_id': None, }) # --- +# name: test_devices[da_wm_wd_000001_1] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': 'ARTIK051', + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '3a6c4e05-811d-5041-e956-3d04c424cbcd', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Samsung Electronics', + 'model': 'DA_WM_A51_20_COMMON', + 'model_id': None, + 'name': 'Seca-Roupa', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': 'DA_WM_A51_20_COMMON_30230708', + 'via_device_id': None, + }) +# --- # name: test_devices[da_wm_wm_000001] DeviceRegistryEntrySnapshot({ 'area_id': 'theater', diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index b939547ca32..e7b36e7d028 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -3532,6 +3532,473 @@ 'state': '0.0', }) # --- +# name: test_all_entities[da_wm_wd_000001_1][sensor.seca_roupa_completion_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.seca_roupa_completion_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Completion time', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'completion_time', + 'unique_id': '3a6c4e05-811d-5041-e956-3d04c424cbcd.completionTime', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wd_000001_1][sensor.seca_roupa_completion_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Seca-Roupa Completion time', + }), + 'context': , + 'entity_id': 'sensor.seca_roupa_completion_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2025-03-09T22:55:37+00:00', + }) +# --- +# name: test_all_entities[da_wm_wd_000001_1][sensor.seca_roupa_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.seca_roupa_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '3a6c4e05-811d-5041-e956-3d04c424cbcd.energy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_wm_wd_000001_1][sensor.seca_roupa_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Seca-Roupa Energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.seca_roupa_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '796.4', + }) +# --- +# name: test_all_entities[da_wm_wd_000001_1][sensor.seca_roupa_energy_difference-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.seca_roupa_energy_difference', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy difference', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_difference', + 'unique_id': '3a6c4e05-811d-5041-e956-3d04c424cbcd.deltaEnergy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_wm_wd_000001_1][sensor.seca_roupa_energy_difference-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Seca-Roupa Energy difference', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.seca_roupa_energy_difference', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_wm_wd_000001_1][sensor.seca_roupa_energy_saved-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.seca_roupa_energy_saved', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy saved', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_saved', + 'unique_id': '3a6c4e05-811d-5041-e956-3d04c424cbcd.energySaved_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_wm_wd_000001_1][sensor.seca_roupa_energy_saved-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Seca-Roupa Energy saved', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.seca_roupa_energy_saved', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_wm_wd_000001_1][sensor.seca_roupa_job_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'cooling', + 'delay_wash', + 'drying', + 'finished', + 'none', + 'refreshing', + 'weight_sensing', + 'wrinkle_prevent', + 'dehumidifying', + 'ai_drying', + 'sanitizing', + 'internal_care', + 'freeze_protection', + 'continuous_dehumidifying', + 'thawing_frozen_inside', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.seca_roupa_job_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Job state', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'dryer_job_state', + 'unique_id': '3a6c4e05-811d-5041-e956-3d04c424cbcd.dryerJobState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wd_000001_1][sensor.seca_roupa_job_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Seca-Roupa Job state', + 'options': list([ + 'cooling', + 'delay_wash', + 'drying', + 'finished', + 'none', + 'refreshing', + 'weight_sensing', + 'wrinkle_prevent', + 'dehumidifying', + 'ai_drying', + 'sanitizing', + 'internal_care', + 'freeze_protection', + 'continuous_dehumidifying', + 'thawing_frozen_inside', + ]), + }), + 'context': , + 'entity_id': 'sensor.seca_roupa_job_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'none', + }) +# --- +# name: test_all_entities[da_wm_wd_000001_1][sensor.seca_roupa_machine_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'pause', + 'run', + 'stop', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.seca_roupa_machine_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Machine state', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'dryer_machine_state', + 'unique_id': '3a6c4e05-811d-5041-e956-3d04c424cbcd.machineState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wd_000001_1][sensor.seca_roupa_machine_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Seca-Roupa Machine state', + 'options': list([ + 'pause', + 'run', + 'stop', + ]), + }), + 'context': , + 'entity_id': 'sensor.seca_roupa_machine_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'stop', + }) +# --- +# name: test_all_entities[da_wm_wd_000001_1][sensor.seca_roupa_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.seca_roupa_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '3a6c4e05-811d-5041-e956-3d04c424cbcd.power_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_wm_wd_000001_1][sensor.seca_roupa_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Seca-Roupa Power', + 'power_consumption_end': '2025-03-09T19:47:37Z', + 'power_consumption_start': '2025-03-09T19:47:26Z', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.seca_roupa_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_all_entities[da_wm_wd_000001_1][sensor.seca_roupa_power_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.seca_roupa_power_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power energy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_energy', + 'unique_id': '3a6c4e05-811d-5041-e956-3d04c424cbcd.powerEnergy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_wm_wd_000001_1][sensor.seca_roupa_power_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Seca-Roupa Power energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.seca_roupa_power_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- # name: test_all_entities[da_wm_wm_000001][sensor.washer_completion_time-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/smartthings/snapshots/test_switch.ambr b/tests/components/smartthings/snapshots/test_switch.ambr index 81b73874a6a..e119428c183 100644 --- a/tests/components/smartthings/snapshots/test_switch.ambr +++ b/tests/components/smartthings/snapshots/test_switch.ambr @@ -234,6 +234,53 @@ 'state': 'off', }) # --- +# name: test_all_entities[da_wm_wd_000001_1][switch.seca_roupa-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.seca_roupa', + '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': None, + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '3a6c4e05-811d-5041-e956-3d04c424cbcd', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wd_000001_1][switch.seca_roupa-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Seca-Roupa', + }), + 'context': , + 'entity_id': 'switch.seca_roupa', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_all_entities[da_wm_wm_000001][switch.washer-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 219b441be0d66d5a016ea2d726846f249eb2ed0a Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 10 Mar 2025 14:40:08 +0100 Subject: [PATCH 2426/3148] Don't allow creating backups if Home Assistant is not running (#139499) * Don't allow creating backups if hass is not running * Revert "Don't allow creating backups if hass is not running" This reverts commit 1bf545eb25f20fc27fe161691a94531cba7e005c. * Set backup manager to idle only after Home Assistant has started * Update according to discussion, add tests * Add more test --- homeassistant/components/backup/manager.py | 21 ++++++- tests/components/backup/test_manager.py | 66 +++++++++++++++++++++- tests/components/hassio/conftest.py | 3 +- 3 files changed, 85 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index bfaa5c5a48e..998e443a3b2 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -120,6 +120,7 @@ class BackupManagerState(StrEnum): IDLE = "idle" CREATE_BACKUP = "create_backup" + BLOCKED = "blocked" RECEIVE_BACKUP = "receive_backup" RESTORE_BACKUP = "restore_backup" @@ -228,6 +229,13 @@ class RestoreBackupEvent(ManagerStateEvent): state: RestoreBackupState +@dataclass(frozen=True, kw_only=True, slots=True) +class BlockedEvent(ManagerStateEvent): + """Backup manager blocked, Home Assistant is starting.""" + + manager_state: BackupManagerState = BackupManagerState.BLOCKED + + class BackupPlatformProtocol(Protocol): """Define the format that backup platforms can have.""" @@ -342,7 +350,7 @@ class BackupManager: self.remove_next_delete_event: Callable[[], None] | None = None # Latest backup event and backup event subscribers - self.last_event: ManagerStateEvent = IdleEvent() + self.last_event: ManagerStateEvent = BlockedEvent() self.last_non_idle_event: ManagerStateEvent | None = None self._backup_event_subscriptions = hass.data[ DATA_BACKUP @@ -356,10 +364,19 @@ class BackupManager: self.known_backups.load(stored["backups"]) await self._reader_writer.async_validate_config(config=self.config) + await self._reader_writer.async_resume_restore_progress_after_restart( on_progress=self.async_on_backup_event ) + async def set_manager_idle_after_start(hass: HomeAssistant) -> None: + """Set manager to idle after start.""" + self.async_on_backup_event(IdleEvent()) + + if self.state == BackupManagerState.BLOCKED: + # If we're not finishing a restore job, set the manager to idle after start + start.async_at_started(self.hass, set_manager_idle_after_start) + await self.load_platforms() @property @@ -1319,7 +1336,7 @@ class BackupManager: if (current_state := self.state) != (new_state := event.manager_state): LOGGER.debug("Backup state: %s -> %s", current_state, new_state) self.last_event = event - if not isinstance(event, IdleEvent): + if not isinstance(event, (BlockedEvent, IdleEvent)): self.last_non_idle_event = event for subscription in self._backup_event_subscriptions: subscription(event) diff --git a/tests/components/backup/test_manager.py b/tests/components/backup/test_manager.py index e4762f35327..41f98d6fa53 100644 --- a/tests/components/backup/test_manager.py +++ b/tests/components/backup/test_manager.py @@ -47,7 +47,8 @@ from homeassistant.components.backup.manager import ( WrittenBackup, ) from homeassistant.components.backup.util import password_to_key -from homeassistant.core import HomeAssistant +from homeassistant.const import EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STARTED +from homeassistant.core import CoreState, HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import issue_registry as ir @@ -3469,3 +3470,66 @@ async def test_restore_progress_after_restart_fail_to_remove( "Unexpected error deleting backup restore result file: Boom!" in caplog.text ) + + +async def test_manager_blocked_until_home_assistant_started( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test backup manager's state is blocked until Home Assistant has started.""" + + hass.set_state(CoreState.not_running) + + await setup_backup_integration(hass) + manager = hass.data[DATA_MANAGER] + + assert manager.state == BackupManagerState.BLOCKED + assert manager.last_non_idle_event is None + + # Fired when Home Assistant changes to starting state + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + await hass.async_block_till_done() + await hass.async_block_till_done() + assert manager.state == BackupManagerState.BLOCKED + assert manager.last_non_idle_event is None + + # Fired when Home Assistant changes to running state + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + assert manager.state == BackupManagerState.IDLE + assert manager.last_non_idle_event is None + + +async def test_manager_not_blocked_after_restore( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test restore backup progress after restart.""" + restore_result = {"error": None, "error_type": None, "success": True} + + hass.set_state(CoreState.not_running) + with patch( + "pathlib.Path.read_bytes", return_value=json.dumps(restore_result).encode() + ): + await setup_backup_integration(hass) + + ws_client = await hass_ws_client(hass) + await ws_client.send_json_auto_id({"type": "backup/info"}) + result = await ws_client.receive_json() + assert result["success"] is True + assert result["result"] == { + "agent_errors": {}, + "backups": [], + "last_attempted_automatic_backup": None, + "last_completed_automatic_backup": None, + "last_non_idle_event": { + "manager_state": "restore_backup", + "reason": None, + "stage": None, + "state": "completed", + }, + "next_automatic_backup": None, + "next_automatic_backup_additional": False, + "state": "idle", + } diff --git a/tests/components/hassio/conftest.py b/tests/components/hassio/conftest.py index 7075b9d6982..c9fbf1a7c56 100644 --- a/tests/components/hassio/conftest.py +++ b/tests/components/hassio/conftest.py @@ -11,7 +11,7 @@ import pytest from homeassistant.auth.models import RefreshToken from homeassistant.components.hassio.handler import HassIO, HassioAPIError -from homeassistant.core import CoreState, HomeAssistant +from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.setup import async_setup_component @@ -75,7 +75,6 @@ def hassio_stubs( "homeassistant.components.hassio.issues.SupervisorIssues.setup", ), ): - hass.set_state(CoreState.starting) hass.loop.run_until_complete(async_setup_component(hass, "hassio", {})) return hass_api.call_args[0][1] From f5c73027bb5ac783952f932cdd8ff90310b06d2f Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 10 Mar 2025 14:45:14 +0100 Subject: [PATCH 2427/3148] Improve description of `schedule.get_schedule` action (#140284) Changes to descriptive style and adds a little more detail from the online docs. --- homeassistant/components/schedule/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/schedule/strings.json b/homeassistant/components/schedule/strings.json index 8638e4a8a84..bb81c029dbf 100644 --- a/homeassistant/components/schedule/strings.json +++ b/homeassistant/components/schedule/strings.json @@ -28,7 +28,7 @@ }, "get_schedule": { "name": "Get schedule", - "description": "Retrieve one or multiple schedules." + "description": "Retrieves the configured time ranges of one or multiple schedules." } } } From 00fc3f294b6910b705d2204e030690875d162222 Mon Sep 17 00:00:00 2001 From: Petar Petrov Date: Mon, 10 Mar 2025 15:45:48 +0200 Subject: [PATCH 2428/3148] Bump zwave-js-server-python to 0.61.0 (#140282) --- homeassistant/components/zwave_js/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zwave_js/manifest.json b/homeassistant/components/zwave_js/manifest.json index 3178bdf46ad..16831853290 100644 --- a/homeassistant/components/zwave_js/manifest.json +++ b/homeassistant/components/zwave_js/manifest.json @@ -9,7 +9,7 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["zwave_js_server"], - "requirements": ["pyserial==3.5", "zwave-js-server-python==0.60.1"], + "requirements": ["pyserial==3.5", "zwave-js-server-python==0.61.0"], "usb": [ { "vid": "0658", diff --git a/requirements_all.txt b/requirements_all.txt index 11079d72e1d..76b13da45d9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3161,7 +3161,7 @@ ziggo-mediabox-xl==1.1.0 zm-py==0.5.4 # homeassistant.components.zwave_js -zwave-js-server-python==0.60.1 +zwave-js-server-python==0.61.0 # homeassistant.components.zwave_me zwave-me-ws==0.4.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8d82c9f673e..49eabe61ec1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2541,7 +2541,7 @@ zeversolar==0.3.2 zha==0.0.51 # homeassistant.components.zwave_js -zwave-js-server-python==0.60.1 +zwave-js-server-python==0.61.0 # homeassistant.components.zwave_me zwave-me-ws==0.4.3 From 9edec57a82263b38e15a290ba60b54c8609c0e1f Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 10 Mar 2025 14:46:09 +0100 Subject: [PATCH 2429/3148] Improve action descriptions in `energyzero` integration (#140283) - use descriptive style to match HA standard - fix sentence-casing of "Config entry" --- homeassistant/components/energyzero/strings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/energyzero/strings.json b/homeassistant/components/energyzero/strings.json index 7788f4d4d8e..48682ab31ee 100644 --- a/homeassistant/components/energyzero/strings.json +++ b/homeassistant/components/energyzero/strings.json @@ -54,10 +54,10 @@ "services": { "get_gas_prices": { "name": "Get gas prices", - "description": "Request gas prices from EnergyZero.", + "description": "Requests gas prices from EnergyZero.", "fields": { "config_entry": { - "name": "Config Entry", + "name": "Config entry", "description": "The config entry to use for this action." }, "incl_vat": { @@ -76,7 +76,7 @@ }, "get_energy_prices": { "name": "Get energy prices", - "description": "Request energy prices from EnergyZero.", + "description": "Requests energy prices from EnergyZero.", "fields": { "config_entry": { "name": "[%key:component::energyzero::services::get_gas_prices::fields::config_entry::name%]", From 688d5bb4c98130358e4cf9ae5ec8d30139cf6f19 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 10 Mar 2025 03:54:02 -1000 Subject: [PATCH 2430/3148] Bump bluetooth-data-tools to 1.26.0 (#140262) changelog: https://github.com/Bluetooth-Devices/bluetooth-data-tools/compare/v1.25.0...v1.26.0 --- homeassistant/components/bluetooth/manifest.json | 2 +- homeassistant/components/ld2410_ble/manifest.json | 2 +- homeassistant/components/led_ble/manifest.json | 2 +- homeassistant/components/private_ble_device/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index ec617b82a04..f6fb4f68e91 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -19,7 +19,7 @@ "bleak-retry-connector==3.9.0", "bluetooth-adapters==0.21.4", "bluetooth-auto-recovery==1.4.4", - "bluetooth-data-tools==1.25.0", + "bluetooth-data-tools==1.26.0", "dbus-fast==2.39.3", "habluetooth==3.25.0" ] diff --git a/homeassistant/components/ld2410_ble/manifest.json b/homeassistant/components/ld2410_ble/manifest.json index c92bcb3294f..f0d06a4e880 100644 --- a/homeassistant/components/ld2410_ble/manifest.json +++ b/homeassistant/components/ld2410_ble/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/ld2410_ble", "integration_type": "device", "iot_class": "local_push", - "requirements": ["bluetooth-data-tools==1.25.0", "ld2410-ble==0.1.1"] + "requirements": ["bluetooth-data-tools==1.26.0", "ld2410-ble==0.1.1"] } diff --git a/homeassistant/components/led_ble/manifest.json b/homeassistant/components/led_ble/manifest.json index 8f624a3c225..5e12c395c2c 100644 --- a/homeassistant/components/led_ble/manifest.json +++ b/homeassistant/components/led_ble/manifest.json @@ -35,5 +35,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/led_ble", "iot_class": "local_polling", - "requirements": ["bluetooth-data-tools==1.25.0", "led-ble==1.1.6"] + "requirements": ["bluetooth-data-tools==1.26.0", "led-ble==1.1.6"] } diff --git a/homeassistant/components/private_ble_device/manifest.json b/homeassistant/components/private_ble_device/manifest.json index 98a9f757585..d79b93388f5 100644 --- a/homeassistant/components/private_ble_device/manifest.json +++ b/homeassistant/components/private_ble_device/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/private_ble_device", "iot_class": "local_push", - "requirements": ["bluetooth-data-tools==1.25.0"] + "requirements": ["bluetooth-data-tools==1.26.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index bce7a2ddcdd..d9c761e6341 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -23,7 +23,7 @@ bleak-retry-connector==3.9.0 bleak==0.22.3 bluetooth-adapters==0.21.4 bluetooth-auto-recovery==1.4.4 -bluetooth-data-tools==1.25.0 +bluetooth-data-tools==1.26.0 cached-ipaddress==0.10.0 certifi>=2021.5.30 ciso8601==2.3.2 diff --git a/requirements_all.txt b/requirements_all.txt index 76b13da45d9..0a6e67f18c0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -640,7 +640,7 @@ bluetooth-auto-recovery==1.4.4 # homeassistant.components.ld2410_ble # homeassistant.components.led_ble # homeassistant.components.private_ble_device -bluetooth-data-tools==1.25.0 +bluetooth-data-tools==1.26.0 # homeassistant.components.bond bond-async==0.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 49eabe61ec1..0d246d59b1e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -564,7 +564,7 @@ bluetooth-auto-recovery==1.4.4 # homeassistant.components.ld2410_ble # homeassistant.components.led_ble # homeassistant.components.private_ble_device -bluetooth-data-tools==1.25.0 +bluetooth-data-tools==1.26.0 # homeassistant.components.bond bond-async==0.2.1 From 8620309f9e2284823f8d16c24f25dff415b8455c Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Tue, 11 Mar 2025 00:06:40 +1000 Subject: [PATCH 2431/3148] Add streaming to Teslemetry update platform (#140021) * Update platform * Tests * fix tests --- homeassistant/components/teslemetry/update.py | 158 ++++++++++++++++-- .../teslemetry/snapshots/test_update.ambr | 125 ++++++++++++++ tests/components/teslemetry/test_update.py | 94 ++++++++++- 3 files changed, 363 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/teslemetry/update.py b/homeassistant/components/teslemetry/update.py index f560f25a8ff..0b0255508e0 100644 --- a/homeassistant/components/teslemetry/update.py +++ b/homeassistant/components/teslemetry/update.py @@ -2,16 +2,22 @@ from __future__ import annotations -from typing import Any, cast +from typing import Any from tesla_fleet_api.const import Scope +from tesla_fleet_api.vehiclespecific import VehicleSpecific from homeassistant.components.update import UpdateEntity, UpdateEntityFeature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.restore_state import RestoreEntity from . import TeslemetryConfigEntry -from .entity import TeslemetryVehicleEntity +from .entity import ( + TeslemetryRootEntity, + TeslemetryVehicleEntity, + TeslemetryVehicleStreamEntity, +) from .helpers import handle_vehicle_command from .models import TeslemetryVehicleData @@ -32,12 +38,31 @@ async def async_setup_entry( """Set up the Teslemetry update platform from a config entry.""" async_add_entities( - TeslemetryUpdateEntity(vehicle, entry.runtime_data.scopes) + TeslemetryPollingUpdateEntity(vehicle, entry.runtime_data.scopes) + if vehicle.api.pre2021 or vehicle.firmware < "2024.44.25" + else TeslemetryStreamingUpdateEntity(vehicle, entry.runtime_data.scopes) for vehicle in entry.runtime_data.vehicles ) -class TeslemetryUpdateEntity(TeslemetryVehicleEntity, UpdateEntity): +class TeslemetryUpdateEntity(TeslemetryRootEntity, UpdateEntity): + """Teslemetry Updates entity.""" + + api: VehicleSpecific + _attr_supported_features = UpdateEntityFeature.PROGRESS + + async def async_install( + self, version: str | None, backup: bool, **kwargs: Any + ) -> None: + """Install an update.""" + self.raise_for_scope(Scope.VEHICLE_CMDS) + + await handle_vehicle_command(self.api.schedule_software_update(offset_sec=0)) + self._attr_in_progress = True + self.async_write_ha_state() + + +class TeslemetryPollingUpdateEntity(TeslemetryVehicleEntity, TeslemetryUpdateEntity): """Teslemetry Updates entity.""" def __init__( @@ -94,18 +119,125 @@ class TeslemetryUpdateEntity(TeslemetryVehicleEntity, UpdateEntity): ): self._attr_in_progress = True if install_perc := self.get("vehicle_state_software_update_install_perc"): - self._attr_update_percentage = cast(int, install_perc) + self._attr_update_percentage = install_perc else: self._attr_in_progress = False self._attr_update_percentage = None - async def async_install( - self, version: str | None, backup: bool, **kwargs: Any + +class TeslemetryStreamingUpdateEntity( + TeslemetryVehicleStreamEntity, TeslemetryUpdateEntity, RestoreEntity +): + """Teslemetry Updates entity.""" + + _download_percentage: int = 0 + _install_percentage: int = 0 + + def __init__( + self, + data: TeslemetryVehicleData, + scopes: list[Scope], ) -> None: - """Install an update.""" - self.raise_for_scope(Scope.ENERGY_CMDS) - await self.wake_up_if_asleep() - await handle_vehicle_command(self.api.schedule_software_update(offset_sec=60)) - self._attr_in_progress = True - self._attr_update_percentage = None + """Initialize the Update.""" + self.scoped = Scope.VEHICLE_CMDS in scopes + super().__init__( + data, + "vehicle_state_software_update_status", + ) + + async def async_added_to_hass(self) -> None: + """Handle entity which will be added.""" + await super().async_added_to_hass() + if (state := await self.async_get_last_state()) is not None: + self._attr_in_progress = state.attributes.get("in_progress", False) + self._install_percentage = state.attributes.get("install_percentage", False) + self._attr_installed_version = state.attributes.get("installed_version") + self._attr_latest_version = state.attributes.get("latest_version") + self._attr_supported_features = UpdateEntityFeature( + state.attributes.get( + "supported_features", self._attr_supported_features + ) + ) + self.async_write_ha_state() + + self.async_on_remove( + self.vehicle.stream_vehicle.listen_SoftwareUpdateDownloadPercentComplete( + self._async_handle_software_update_download_percent_complete + ) + ) + self.async_on_remove( + self.vehicle.stream_vehicle.listen_SoftwareUpdateInstallationPercentComplete( + self._async_handle_software_update_installation_percent_complete + ) + ) + self.async_on_remove( + self.vehicle.stream_vehicle.listen_SoftwareUpdateScheduledStartTime( + self._async_handle_software_update_scheduled_start_time + ) + ) + self.async_on_remove( + self.vehicle.stream_vehicle.listen_SoftwareUpdateVersion( + self._async_handle_software_update_version + ) + ) + self.async_on_remove( + self.vehicle.stream_vehicle.listen_Version(self._async_handle_version) + ) + + def _async_handle_software_update_download_percent_complete( + self, value: float | None + ): + """Handle software update download percent complete.""" + + self._download_percentage = round(value) if value is not None else 0 + if self.scoped and self._download_percentage == 100: + self._attr_supported_features = ( + UpdateEntityFeature.PROGRESS | UpdateEntityFeature.INSTALL + ) + else: + self._attr_supported_features = UpdateEntityFeature.PROGRESS + self._async_update_progress() self.async_write_ha_state() + + def _async_handle_software_update_installation_percent_complete( + self, value: float | None + ): + """Handle software update installation percent complete.""" + + self._install_percentage = round(value) if value is not None else 0 + self._async_update_progress() + self.async_write_ha_state() + + def _async_handle_software_update_scheduled_start_time(self, value: str | None): + """Handle software update scheduled start time.""" + + self._attr_in_progress = value is not None + self.async_write_ha_state() + + def _async_handle_software_update_version(self, value: str | None): + """Handle software update version.""" + + self._attr_latest_version = ( + value if value and value != " " else self._attr_installed_version + ) + self.async_write_ha_state() + + def _async_handle_version(self, value: str | None): + """Handle version.""" + + if value is not None: + self._attr_installed_version = value.split(" ")[0] + self.async_write_ha_state() + + def _async_update_progress(self) -> None: + """Update the progress of the update.""" + + if self._download_percentage > 1 and self._download_percentage < 100: + self._attr_in_progress = True + self._attr_update_percentage = self._download_percentage + elif self._install_percentage > 1: + self._attr_in_progress = True + self._attr_update_percentage = self._install_percentage + else: + self._attr_in_progress = False + self._attr_update_percentage = None diff --git a/tests/components/teslemetry/snapshots/test_update.ambr b/tests/components/teslemetry/snapshots/test_update.ambr index 1c7d525af86..fcd6f421993 100644 --- a/tests/components/teslemetry/snapshots/test_update.ambr +++ b/tests/components/teslemetry/snapshots/test_update.ambr @@ -117,3 +117,128 @@ 'state': 'off', }) # --- +# name: test_update_streaming[downloading] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'auto_update': False, + 'display_precision': 0, + 'entity_picture': 'https://brands.home-assistant.io/_/teslemetry/icon.png', + 'friendly_name': 'Test Update', + 'in_progress': False, + 'installed_version': '2025.1.1', + 'latest_version': '2025.2.1', + 'release_summary': None, + 'release_url': None, + 'skipped_version': None, + 'supported_features': , + 'title': None, + 'update_percentage': None, + }), + 'context': , + 'entity_id': 'update.test_update', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_update_streaming[installing] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'auto_update': False, + 'display_precision': 0, + 'entity_picture': 'https://brands.home-assistant.io/_/teslemetry/icon.png', + 'friendly_name': 'Test Update', + 'in_progress': False, + 'installed_version': '2025.1.1', + 'latest_version': '2025.2.1', + 'release_summary': None, + 'release_url': None, + 'skipped_version': None, + 'supported_features': , + 'title': None, + 'update_percentage': None, + }), + 'context': , + 'entity_id': 'update.test_update', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_update_streaming[ready] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'auto_update': False, + 'display_precision': 0, + 'entity_picture': 'https://brands.home-assistant.io/_/teslemetry/icon.png', + 'friendly_name': 'Test Update', + 'in_progress': False, + 'installed_version': '2025.1.1', + 'latest_version': '2025.2.1', + 'release_summary': None, + 'release_url': None, + 'skipped_version': None, + 'supported_features': , + 'title': None, + 'update_percentage': None, + }), + 'context': , + 'entity_id': 'update.test_update', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_update_streaming[restored] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'auto_update': False, + 'display_precision': 0, + 'entity_picture': 'https://brands.home-assistant.io/_/teslemetry/icon.png', + 'friendly_name': 'Test Update', + 'in_progress': False, + 'installed_version': '2025.2.1', + 'latest_version': '2025.1.1', + 'release_summary': None, + 'release_url': None, + 'skipped_version': None, + 'supported_features': , + 'title': None, + 'update_percentage': None, + }), + 'context': , + 'entity_id': 'update.test_update', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_update_streaming[updated] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'auto_update': False, + 'display_precision': 0, + 'entity_picture': 'https://brands.home-assistant.io/_/teslemetry/icon.png', + 'friendly_name': 'Test Update', + 'in_progress': False, + 'installed_version': '2025.2.1', + 'latest_version': '2025.1.1', + 'release_summary': None, + 'release_url': None, + 'skipped_version': None, + 'supported_features': , + 'title': None, + 'update_percentage': None, + }), + 'context': , + 'entity_id': 'update.test_update', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/teslemetry/test_update.py b/tests/components/teslemetry/test_update.py index 448f31afd67..0f26b162043 100644 --- a/tests/components/teslemetry/test_update.py +++ b/tests/components/teslemetry/test_update.py @@ -4,7 +4,9 @@ import copy from unittest.mock import AsyncMock, patch from freezegun.api import FrozenDateTimeFactory +import pytest from syrupy.assertion import SnapshotAssertion +from teslemetry_stream import Signal from homeassistant.components.teslemetry.coordinator import VEHICLE_INTERVAL from homeassistant.components.teslemetry.update import INSTALLING @@ -13,7 +15,7 @@ from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from . import assert_entities, setup_platform +from . import assert_entities, reload_platform, setup_platform from .const import COMMAND_OK, VEHICLE_DATA, VEHICLE_DATA_ALT from tests.common import async_fire_time_changed @@ -23,6 +25,7 @@ async def test_update( hass: HomeAssistant, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, + mock_legacy: AsyncMock, ) -> None: """Tests that the update entities are correct.""" @@ -35,6 +38,7 @@ async def test_update_alt( snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, mock_vehicle_data: AsyncMock, + mock_legacy: AsyncMock, ) -> None: """Tests that the update entities are correct.""" @@ -48,6 +52,7 @@ async def test_update_services( mock_vehicle_data: AsyncMock, freezer: FrozenDateTimeFactory, snapshot: SnapshotAssertion, + mock_legacy: AsyncMock, ) -> None: """Tests that the update services work.""" @@ -78,3 +83,90 @@ async def test_update_services( state = hass.states.get(entity_id) assert state.attributes["in_progress"] == 1 + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_update_streaming( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_vehicle_data: AsyncMock, + mock_add_listener: AsyncMock, +) -> None: + """Tests that the select entities with streaming are correct.""" + + entry = await setup_platform(hass, [Platform.UPDATE]) + + # Stream update + mock_add_listener.send( + { + "vin": VEHICLE_DATA_ALT["response"]["vin"], + "data": { + Signal.SOFTWARE_UPDATE_DOWNLOAD_PERCENT_COMPLETE: 50, + Signal.SOFTWARE_UPDATE_INSTALLATION_PERCENT_COMPLETE: None, + Signal.SOFTWARE_UPDATE_SCHEDULED_START_TIME: None, + Signal.SOFTWARE_UPDATE_VERSION: "2025.2.1", + Signal.VERSION: "2025.1.1", + }, + "createdAt": "2024-10-04T10:45:17.537Z", + } + ) + await hass.async_block_till_done() + + state = hass.states.get("update.test_update") + assert state == snapshot(name="downloading") + + mock_add_listener.send( + { + "vin": VEHICLE_DATA_ALT["response"]["vin"], + "data": { + Signal.SOFTWARE_UPDATE_DOWNLOAD_PERCENT_COMPLETE: 100, + Signal.SOFTWARE_UPDATE_INSTALLATION_PERCENT_COMPLETE: 1, + Signal.SOFTWARE_UPDATE_SCHEDULED_START_TIME: None, + Signal.SOFTWARE_UPDATE_VERSION: "2025.2.1", + Signal.VERSION: "2025.1.1", + }, + "createdAt": "2024-10-04T10:45:17.537Z", + } + ) + await hass.async_block_till_done() + state = hass.states.get("update.test_update") + assert state == snapshot(name="ready") + + mock_add_listener.send( + { + "vin": VEHICLE_DATA_ALT["response"]["vin"], + "data": { + Signal.SOFTWARE_UPDATE_DOWNLOAD_PERCENT_COMPLETE: 100, + Signal.SOFTWARE_UPDATE_INSTALLATION_PERCENT_COMPLETE: 50, + Signal.SOFTWARE_UPDATE_SCHEDULED_START_TIME: None, + Signal.SOFTWARE_UPDATE_VERSION: "2025.2.1", + Signal.VERSION: "2025.1.1", + }, + "createdAt": "2024-10-04T10:45:17.537Z", + } + ) + await hass.async_block_till_done() + state = hass.states.get("update.test_update") + assert state == snapshot(name="installing") + + mock_add_listener.send( + { + "vin": VEHICLE_DATA_ALT["response"]["vin"], + "data": { + Signal.SOFTWARE_UPDATE_DOWNLOAD_PERCENT_COMPLETE: None, + Signal.SOFTWARE_UPDATE_INSTALLATION_PERCENT_COMPLETE: None, + Signal.SOFTWARE_UPDATE_SCHEDULED_START_TIME: None, + Signal.SOFTWARE_UPDATE_VERSION: "", + Signal.VERSION: "2025.2.1", + }, + "createdAt": "2024-10-04T10:45:17.537Z", + } + ) + await hass.async_block_till_done() + state = hass.states.get("update.test_update") + assert state == snapshot(name="updated") + + await reload_platform(hass, entry, [Platform.UPDATE]) + + state = hass.states.get("update.test_update") + assert state == snapshot(name="restored") From e4e476f83edb59999242fdb3f250abe0ccb3c7d1 Mon Sep 17 00:00:00 2001 From: Austin Mroczek Date: Mon, 10 Mar 2025 07:18:13 -0700 Subject: [PATCH 2432/3148] TotalConnect add partition arming_state in diagnostic (#140140) add partition arming_state --- homeassistant/components/totalconnect/diagnostics.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/totalconnect/diagnostics.py b/homeassistant/components/totalconnect/diagnostics.py index f42ed5e44c3..fc310bf850c 100644 --- a/homeassistant/components/totalconnect/diagnostics.py +++ b/homeassistant/components/totalconnect/diagnostics.py @@ -83,6 +83,7 @@ async def async_get_config_entry_diagnostics( "is_new_partition": partition.is_new_partition, "is_night_stay_enabled": partition.is_night_stay_enabled, "exit_delay_timer": partition.exit_delay_timer, + "arming_state": partition.arming_state, } new_location["partitions"].append(new_partition) From ed20947e30b25c50af47dc338ed9686c9eace346 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Mon, 10 Mar 2025 15:49:29 +0100 Subject: [PATCH 2433/3148] Fix events without user in Bring integration (#140213) Fix events without publicUserUuid --- homeassistant/components/bring/event.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/bring/event.py b/homeassistant/components/bring/event.py index 08d06b596b8..403856405ce 100644 --- a/homeassistant/components/bring/event.py +++ b/homeassistant/components/bring/event.py @@ -77,9 +77,12 @@ class BringEventEntity(BringBaseEntity, EventEntity): attributes = asdict(activity.content) attributes["last_activity_by"] = next( - x.name - for x in bring_list.users.users - if x.publicUuid == activity.content.publicUserUuid + ( + x.name + for x in bring_list.users.users + if x.publicUuid == activity.content.publicUserUuid + ), + None, ) self._trigger_event( From 290116029b3b651b6496430dd44b5a6a41195411 Mon Sep 17 00:00:00 2001 From: Andrew Jackson Date: Mon, 10 Mar 2025 14:54:18 +0000 Subject: [PATCH 2434/3148] Add strict typing of account & instance to Mastodon (#139739) Add strict typing of account & instance --- homeassistant/components/mastodon/__init__.py | 6 +- .../components/mastodon/config_flow.py | 11 +- homeassistant/components/mastodon/const.py | 8 - .../components/mastodon/coordinator.py | 13 +- .../components/mastodon/diagnostics.py | 4 +- homeassistant/components/mastodon/entity.py | 4 +- homeassistant/components/mastodon/sensor.py | 16 +- homeassistant/components/mastodon/utils.py | 12 +- tests/components/mastodon/conftest.py | 11 +- .../fixtures/account_verify_credentials.json | 104 +++----- .../mastodon/fixtures/instance.json | 159 ++--------- .../mastodon/snapshots/test_diagnostics.ambr | 247 +++--------------- .../mastodon/snapshots/test_init.ambr | 2 +- .../mastodon/snapshots/test_sensor.ambr | 6 +- 14 files changed, 146 insertions(+), 457 deletions(-) diff --git a/homeassistant/components/mastodon/__init__.py b/homeassistant/components/mastodon/__init__.py index ab8514c8321..17b8614a2e9 100644 --- a/homeassistant/components/mastodon/__init__.py +++ b/homeassistant/components/mastodon/__init__.py @@ -2,7 +2,7 @@ from __future__ import annotations -from mastodon.Mastodon import Mastodon, MastodonError +from mastodon.Mastodon import Account, Instance, InstanceV2, Mastodon, MastodonError from homeassistant.const import ( CONF_ACCESS_TOKEN, @@ -107,7 +107,9 @@ async def async_migrate_entry(hass: HomeAssistant, entry: MastodonConfigEntry) - return True -def setup_mastodon(entry: MastodonConfigEntry) -> tuple[Mastodon, dict, dict]: +def setup_mastodon( + entry: MastodonConfigEntry, +) -> tuple[Mastodon, InstanceV2 | Instance, Account]: """Get mastodon details.""" client = create_mastodon_client( entry.data[CONF_BASE_URL], diff --git a/homeassistant/components/mastodon/config_flow.py b/homeassistant/components/mastodon/config_flow.py index 1b93cbecd98..1ae1e6b229e 100644 --- a/homeassistant/components/mastodon/config_flow.py +++ b/homeassistant/components/mastodon/config_flow.py @@ -4,7 +4,12 @@ from __future__ import annotations from typing import Any -from mastodon.Mastodon import MastodonNetworkError, MastodonUnauthorizedError +from mastodon.Mastodon import ( + Account, + Instance, + MastodonNetworkError, + MastodonUnauthorizedError, +) import voluptuous as vol from yarl import URL @@ -56,8 +61,8 @@ class MastodonConfigFlow(ConfigFlow, domain=DOMAIN): client_secret: str, access_token: str, ) -> tuple[ - dict[str, str] | None, - dict[str, str] | None, + Instance | None, + Account | None, dict[str, str], ]: """Check connection to the Mastodon instance.""" diff --git a/homeassistant/components/mastodon/const.py b/homeassistant/components/mastodon/const.py index a4af49a27a6..2efda329467 100644 --- a/homeassistant/components/mastodon/const.py +++ b/homeassistant/components/mastodon/const.py @@ -12,14 +12,6 @@ DATA_HASS_CONFIG = "mastodon_hass_config" DEFAULT_URL: Final = "https://mastodon.social" DEFAULT_NAME: Final = "Mastodon" -INSTANCE_VERSION: Final = "version" -INSTANCE_URI: Final = "uri" -INSTANCE_DOMAIN: Final = "domain" -ACCOUNT_USERNAME: Final = "username" -ACCOUNT_FOLLOWERS_COUNT: Final = "followers_count" -ACCOUNT_FOLLOWING_COUNT: Final = "following_count" -ACCOUNT_STATUSES_COUNT: Final = "statuses_count" - ATTR_CONFIG_ENTRY_ID = "config_entry_id" ATTR_STATUS = "status" ATTR_VISIBILITY = "visibility" diff --git a/homeassistant/components/mastodon/coordinator.py b/homeassistant/components/mastodon/coordinator.py index 5d2b193b4a8..99785eca80b 100644 --- a/homeassistant/components/mastodon/coordinator.py +++ b/homeassistant/components/mastodon/coordinator.py @@ -4,10 +4,9 @@ from __future__ import annotations from dataclasses import dataclass from datetime import timedelta -from typing import Any from mastodon import Mastodon -from mastodon.Mastodon import MastodonError +from mastodon.Mastodon import Account, Instance, InstanceV2, MastodonError from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -21,15 +20,15 @@ class MastodonData: """Mastodon data type.""" client: Mastodon - instance: dict - account: dict + instance: InstanceV2 | Instance + account: Account coordinator: MastodonCoordinator type MastodonConfigEntry = ConfigEntry[MastodonData] -class MastodonCoordinator(DataUpdateCoordinator[dict[str, Any]]): +class MastodonCoordinator(DataUpdateCoordinator[Account]): """Class to manage fetching Mastodon data.""" config_entry: MastodonConfigEntry @@ -47,9 +46,9 @@ class MastodonCoordinator(DataUpdateCoordinator[dict[str, Any]]): ) self.client = client - async def _async_update_data(self) -> dict[str, Any]: + async def _async_update_data(self) -> Account: try: - account: dict = await self.hass.async_add_executor_job( + account: Account = await self.hass.async_add_executor_job( self.client.account_verify_credentials ) except MastodonError as ex: diff --git a/homeassistant/components/mastodon/diagnostics.py b/homeassistant/components/mastodon/diagnostics.py index dc7c1b785ab..31444413dfd 100644 --- a/homeassistant/components/mastodon/diagnostics.py +++ b/homeassistant/components/mastodon/diagnostics.py @@ -4,6 +4,8 @@ from __future__ import annotations from typing import Any +from mastodon.Mastodon import Account, Instance + from homeassistant.core import HomeAssistant from .coordinator import MastodonConfigEntry @@ -25,7 +27,7 @@ async def async_get_config_entry_diagnostics( } -def get_diagnostics(config_entry: MastodonConfigEntry) -> tuple[dict, dict]: +def get_diagnostics(config_entry: MastodonConfigEntry) -> tuple[Instance, Account]: """Get mastodon diagnostics.""" client = config_entry.runtime_data.client diff --git a/homeassistant/components/mastodon/entity.py b/homeassistant/components/mastodon/entity.py index 2ae8c0d852e..60224e75e41 100644 --- a/homeassistant/components/mastodon/entity.py +++ b/homeassistant/components/mastodon/entity.py @@ -4,7 +4,7 @@ from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DEFAULT_NAME, DOMAIN, INSTANCE_VERSION +from .const import DEFAULT_NAME, DOMAIN from .coordinator import MastodonConfigEntry, MastodonCoordinator from .utils import construct_mastodon_username @@ -40,7 +40,7 @@ class MastodonEntity(CoordinatorEntity[MastodonCoordinator]): manufacturer="Mastodon gGmbH", model=full_account_name, entry_type=DeviceEntryType.SERVICE, - sw_version=data.runtime_data.instance[INSTANCE_VERSION], + sw_version=data.runtime_data.instance.version, name=name, ) diff --git a/homeassistant/components/mastodon/sensor.py b/homeassistant/components/mastodon/sensor.py index 74537e33cae..bfdc9c90333 100644 --- a/homeassistant/components/mastodon/sensor.py +++ b/homeassistant/components/mastodon/sensor.py @@ -4,7 +4,8 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass -from typing import Any + +from mastodon.Mastodon import Account from homeassistant.components.sensor import ( SensorEntity, @@ -15,11 +16,6 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType -from .const import ( - ACCOUNT_FOLLOWERS_COUNT, - ACCOUNT_FOLLOWING_COUNT, - ACCOUNT_STATUSES_COUNT, -) from .coordinator import MastodonConfigEntry from .entity import MastodonEntity @@ -31,7 +27,7 @@ PARALLEL_UPDATES = 0 class MastodonSensorEntityDescription(SensorEntityDescription): """Describes Mastodon sensor entity.""" - value_fn: Callable[[dict[str, Any]], StateType] + value_fn: Callable[[Account], StateType] ENTITY_DESCRIPTIONS = ( @@ -39,19 +35,19 @@ ENTITY_DESCRIPTIONS = ( key="followers", translation_key="followers", state_class=SensorStateClass.TOTAL, - value_fn=lambda data: data.get(ACCOUNT_FOLLOWERS_COUNT), + value_fn=lambda data: data.followers_count, ), MastodonSensorEntityDescription( key="following", translation_key="following", state_class=SensorStateClass.TOTAL, - value_fn=lambda data: data.get(ACCOUNT_FOLLOWING_COUNT), + value_fn=lambda data: data.following_count, ), MastodonSensorEntityDescription( key="posts", translation_key="posts", state_class=SensorStateClass.TOTAL, - value_fn=lambda data: data.get(ACCOUNT_STATUSES_COUNT), + value_fn=lambda data: data.statuses_count, ), ) diff --git a/homeassistant/components/mastodon/utils.py b/homeassistant/components/mastodon/utils.py index e9c2567b675..898578c931b 100644 --- a/homeassistant/components/mastodon/utils.py +++ b/homeassistant/components/mastodon/utils.py @@ -6,8 +6,9 @@ import mimetypes from typing import Any from mastodon import Mastodon +from mastodon.Mastodon import Account, Instance, InstanceV2 -from .const import ACCOUNT_USERNAME, DEFAULT_NAME, INSTANCE_DOMAIN, INSTANCE_URI +from .const import DEFAULT_NAME def create_mastodon_client( @@ -23,14 +24,13 @@ def create_mastodon_client( def construct_mastodon_username( - instance: dict[str, str] | None, account: dict[str, str] | None + instance: InstanceV2 | Instance | None, account: Account | None ) -> str: """Construct a mastodon username from the account and instance.""" if instance and account: - return ( - f"@{account[ACCOUNT_USERNAME]}@" - f"{instance.get(INSTANCE_URI, instance.get(INSTANCE_DOMAIN))}" - ) + if type(instance) is InstanceV2: + return f"@{account.username}@{instance.domain}" + return f"@{account.username}@{instance.uri}" return DEFAULT_NAME diff --git a/tests/components/mastodon/conftest.py b/tests/components/mastodon/conftest.py index ac23141be55..d8979083de9 100644 --- a/tests/components/mastodon/conftest.py +++ b/tests/components/mastodon/conftest.py @@ -3,12 +3,13 @@ from collections.abc import Generator from unittest.mock import AsyncMock, patch +from mastodon.Mastodon import Account, InstanceV2 import pytest from homeassistant.components.mastodon.const import CONF_BASE_URL, DOMAIN from homeassistant.const import CONF_ACCESS_TOKEN, CONF_CLIENT_ID, CONF_CLIENT_SECRET -from tests.common import MockConfigEntry, load_json_object_fixture +from tests.common import MockConfigEntry, load_fixture @pytest.fixture @@ -31,9 +32,11 @@ def mock_mastodon_client() -> Generator[AsyncMock]: ) as mock_client, ): client = mock_client.return_value - client.instance.return_value = load_json_object_fixture("instance.json", DOMAIN) - client.account_verify_credentials.return_value = load_json_object_fixture( - "account_verify_credentials.json", DOMAIN + client.instance.return_value = InstanceV2.from_json( + load_fixture("instance.json", DOMAIN) + ) + client.account_verify_credentials.return_value = Account.from_json( + load_fixture("account_verify_credentials.json", DOMAIN) ) client.status_post.return_value = None yield client diff --git a/tests/components/mastodon/fixtures/account_verify_credentials.json b/tests/components/mastodon/fixtures/account_verify_credentials.json index 401caa121ae..7806d280ab9 100644 --- a/tests/components/mastodon/fixtures/account_verify_credentials.json +++ b/tests/components/mastodon/fixtures/account_verify_credentials.json @@ -1,78 +1,60 @@ { - "id": "14715", - "username": "trwnh", - "acct": "trwnh", - "display_name": "infinite love ⴳ", - "locked": false, - "bot": false, - "created_at": "2016-11-24T10:02:12.085Z", - "note": "

i have approximate knowledge of many things. perpetual student. (nb/ace/they)

xmpp/email: a@trwnh.com
https://trwnh.com
help me live: https://liberapay.com/at or https://paypal.me/trwnh

- my triggers are moths and glitter
- i have all notifs except mentions turned off, so please interact if you wanna be friends! i literally will not notice otherwise
- dm me if i did something wrong, so i can improve
- purest person on fedi, do not lewd in my presence
- #1 ami cole fan account

:fatyoshi:

", - "url": "https://mastodon.social/@trwnh", - "avatar": "https://files.mastodon.social/accounts/avatars/000/014/715/original/34aa222f4ae2e0a9.png", - "avatar_static": "https://files.mastodon.social/accounts/avatars/000/014/715/original/34aa222f4ae2e0a9.png", - "header": "https://files.mastodon.social/accounts/headers/000/014/715/original/5c6fc24edb3bb873.jpg", - "header_static": "https://files.mastodon.social/accounts/headers/000/014/715/original/5c6fc24edb3bb873.jpg", - "followers_count": 821, - "following_count": 178, - "statuses_count": 33120, - "last_status_at": "2019-11-24T15:49:42.251Z", - "source": { - "privacy": "public", - "sensitive": false, - "language": "", - "note": "i have approximate knowledge of many things. perpetual student. (nb/ace/they)\r\n\r\nxmpp/email: a@trwnh.com\r\nhttps://trwnh.com\r\nhelp me live: https://liberapay.com/at or https://paypal.me/trwnh\r\n\r\n- my triggers are moths and glitter\r\n- i have all notifs except mentions turned off, so please interact if you wanna be friends! i literally will not notice otherwise\r\n- dm me if i did something wrong, so i can improve\r\n- purest person on fedi, do not lewd in my presence\r\n- #1 ami cole fan account\r\n\r\n:fatyoshi:", + "_mastopy_version": "2.0.0", + "_mastopy_type": "Account", + "_mastopy_data": { + "id": "14715", + "username": "trwnh", + "acct": "trwnh", + "display_name": "infinite love \u2d33", + "discoverable": true, + "group": false, + "locked": false, + "created_at": "2016-11-24T00:00:00+00:00", + "following_count": 328, + "followers_count": 3169, + "statuses_count": 69523, + "note": "

i have approximate knowledge of many things. perpetual student. (nb/ace/they)

xmpp/email: a@trwnh.com
https://trwnh.com
help me live:
- https://donate.stripe.com/4gwcPCaMpcQ19RC4gg
- https://liberapay.com/trwnh

notes:
- my triggers are moths and glitter
- i have all notifs except mentions turned off, so please interact if you wanna be friends! i literally will not notice otherwise
- dm me if i did something wrong, so i can improve
- purest person on fedi, do not lewd in my presence

", + "url": "https://mastodon.social/@trwnh", + "uri": "https://mastodon.social/users/trwnh", + "avatar": "https://files.mastodon.social/accounts/avatars/000/014/715/original/051c958388818705.png", + "header": "https://files.mastodon.social/accounts/headers/000/014/715/original/5c6fc24edb3bb873.jpg", + "avatar_static": "https://files.mastodon.social/accounts/avatars/000/014/715/original/051c958388818705.png", + "header_static": "https://files.mastodon.social/accounts/headers/000/014/715/original/5c6fc24edb3bb873.jpg", + "moved_to_account": null, + "suspended": null, + "limited": null, + "bot": true, "fields": [ { "name": "Website", - "value": "https://trwnh.com", + "value": "https://trwnh.com", "verified_at": "2019-08-29T04:14:55.571+00:00" }, { - "name": "Sponsor", - "value": "https://liberapay.com/at", - "verified_at": "2019-11-15T10:06:15.557+00:00" + "name": "Portfolio", + "value": "https://abdullahtarawneh.com", + "verified_at": "2021-02-11T20:34:13.574+00:00" }, { "name": "Fan of:", - "value": "Punk-rock and post-hardcore (Circa Survive, letlive., La Dispute, THE FEVER 333)Manga (Yu-Gi-Oh!, One Piece, JoJo's Bizarre Adventure, Death Note, Shaman King)Platformers and RPGs (Banjo-Kazooie, Boktai, Final Fantasy Crystal Chronicles)", + "value": "Punk-rock and post-hardcore (Circa Survive, letlive., La Dispute, THE FEVER 333)Manga (Yu-Gi-Oh!, One Piece, JoJo's Bizarre Adventure, Death Note, Shaman King)Platformers and RPGs (Banjo-Kazooie, Boktai, Final Fantasy Crystal Chronicles)", "verified_at": null }, { - "name": "Main topics:", - "value": "systemic analysis, design patterns, anticapitalism, info/tech freedom, theory and philosophy, and otherwise being a genuine and decent wholesome poster. i'm just here to hang out and talk to cool people!", + "name": "What to expect:", + "value": "talking about various things i find interesting, and otherwise being a genuine and decent wholesome poster. i'm just here to hang out and talk to cool people! and to spill my thoughts.", "verified_at": null } ], - "follow_requests_count": 0 - }, - "emojis": [ - { - "shortcode": "fatyoshi", - "url": "https://files.mastodon.social/custom_emojis/images/000/023/920/original/e57ecb623faa0dc9.png", - "static_url": "https://files.mastodon.social/custom_emojis/images/000/023/920/static/e57ecb623faa0dc9.png", - "visible_in_picker": true - } - ], - "fields": [ - { - "name": "Website", - "value": "https://trwnh.com", - "verified_at": "2019-08-29T04:14:55.571+00:00" - }, - { - "name": "Sponsor", - "value": "https://liberapay.com/at", - "verified_at": "2019-11-15T10:06:15.557+00:00" - }, - { - "name": "Fan of:", - "value": "Punk-rock and post-hardcore (Circa Survive, letlive., La Dispute, THE FEVER 333)Manga (Yu-Gi-Oh!, One Piece, JoJo's Bizarre Adventure, Death Note, Shaman King)Platformers and RPGs (Banjo-Kazooie, Boktai, Final Fantasy Crystal Chronicles)", - "verified_at": null - }, - { - "name": "Main topics:", - "value": "systemic analysis, design patterns, anticapitalism, info/tech freedom, theory and philosophy, and otherwise being a genuine and decent wholesome poster. i'm just here to hang out and talk to cool people!", - "verified_at": null - } - ] + "emojis": [], + "last_status_at": "2025-03-04T00:00:00", + "noindex": false, + "roles": [], + "role": null, + "source": null, + "mute_expires_at": null, + "indexable": false, + "hide_collections": true, + "memorial": null + } } diff --git a/tests/components/mastodon/fixtures/instance.json b/tests/components/mastodon/fixtures/instance.json index b0e904e80ef..2e3dfe2d46d 100644 --- a/tests/components/mastodon/fixtures/instance.json +++ b/tests/components/mastodon/fixtures/instance.json @@ -1,147 +1,18 @@ { - "domain": "mastodon.social", - "title": "Mastodon", - "version": "4.0.0rc1", - "source_url": "https://github.com/mastodon/mastodon", - "description": "The original server operated by the Mastodon gGmbH non-profit", - "usage": { - "users": { - "active_month": 123122 + "_mastopy_version": "2.0.0", + "_mastopy_type": "InstanceV2", + "_mastopy_data": { + "uri": "mastodon.social", + "domain": "mastodon.social", + "title": "Mastodon", + "version": "4.4.0-nightly.2025-02-07", + "source_url": "https://github.com/mastodon/mastodon", + + "description": "The original server operated by the Mastodon gGmbH non-profit", + "usage": { + "users": { + "active_month": 380143 + } } - }, - "thumbnail": { - "url": "https://files.mastodon.social/site_uploads/files/000/000/001/@1x/57c12f441d083cde.png", - "blurhash": "UeKUpFxuo~R%0nW;WCnhF6RjaJt757oJodS$", - "versions": { - "@1x": "https://files.mastodon.social/site_uploads/files/000/000/001/@1x/57c12f441d083cde.png", - "@2x": "https://files.mastodon.social/site_uploads/files/000/000/001/@2x/57c12f441d083cde.png" - } - }, - "languages": ["en"], - "configuration": { - "urls": { - "streaming": "wss://mastodon.social" - }, - "vapid": { - "public_key": "BCkMmVdKDnKYwzVCDC99Iuc9GvId-x7-kKtuHnLgfF98ENiZp_aj-UNthbCdI70DqN1zUVis-x0Wrot2sBagkMc=" - }, - "accounts": { - "max_featured_tags": 10, - "max_pinned_statuses": 4 - }, - "statuses": { - "max_characters": 500, - "max_media_attachments": 4, - "characters_reserved_per_url": 23 - }, - "media_attachments": { - "supported_mime_types": [ - "image/jpeg", - "image/png", - "image/gif", - "image/heic", - "image/heif", - "image/webp", - "video/webm", - "video/mp4", - "video/quicktime", - "video/ogg", - "audio/wave", - "audio/wav", - "audio/x-wav", - "audio/x-pn-wave", - "audio/vnd.wave", - "audio/ogg", - "audio/vorbis", - "audio/mpeg", - "audio/mp3", - "audio/webm", - "audio/flac", - "audio/aac", - "audio/m4a", - "audio/x-m4a", - "audio/mp4", - "audio/3gpp", - "video/x-ms-asf" - ], - "image_size_limit": 10485760, - "image_matrix_limit": 16777216, - "video_size_limit": 41943040, - "video_frame_rate_limit": 60, - "video_matrix_limit": 2304000 - }, - "polls": { - "max_options": 4, - "max_characters_per_option": 50, - "min_expiration": 300, - "max_expiration": 2629746 - }, - "translation": { - "enabled": true - } - }, - "registrations": { - "enabled": false, - "approval_required": false, - "message": null - }, - "contact": { - "email": "staff@mastodon.social", - "account": { - "id": "1", - "username": "Gargron", - "acct": "Gargron", - "display_name": "Eugen 💀", - "locked": false, - "bot": false, - "discoverable": true, - "group": false, - "created_at": "2016-03-16T00:00:00.000Z", - "note": "

Founder, CEO and lead developer @Mastodon, Germany.

", - "url": "https://mastodon.social/@Gargron", - "avatar": "https://files.mastodon.social/accounts/avatars/000/000/001/original/dc4286ceb8fab734.jpg", - "avatar_static": "https://files.mastodon.social/accounts/avatars/000/000/001/original/dc4286ceb8fab734.jpg", - "header": "https://files.mastodon.social/accounts/headers/000/000/001/original/3b91c9965d00888b.jpeg", - "header_static": "https://files.mastodon.social/accounts/headers/000/000/001/original/3b91c9965d00888b.jpeg", - "followers_count": 133026, - "following_count": 311, - "statuses_count": 72605, - "last_status_at": "2022-10-31", - "noindex": false, - "emojis": [], - "fields": [ - { - "name": "Patreon", - "value": "https://www.patreon.com/mastodon", - "verified_at": null - } - ] - } - }, - "rules": [ - { - "id": "1", - "text": "Sexually explicit or violent media must be marked as sensitive when posting" - }, - { - "id": "2", - "text": "No racism, sexism, homophobia, transphobia, xenophobia, or casteism" - }, - { - "id": "3", - "text": "No incitement of violence or promotion of violent ideologies" - }, - { - "id": "4", - "text": "No harassment, dogpiling or doxxing of other users" - }, - { - "id": "5", - "text": "No content illegal in Germany" - }, - { - "id": "7", - "text": "Do not share intentionally false or misleading information" - } - ] + } } diff --git a/tests/components/mastodon/snapshots/test_diagnostics.ambr b/tests/components/mastodon/snapshots/test_diagnostics.ambr index 982ecee7ee2..9198410f066 100644 --- a/tests/components/mastodon/snapshots/test_diagnostics.ambr +++ b/tests/components/mastodon/snapshots/test_diagnostics.ambr @@ -3,245 +3,82 @@ dict({ 'account': dict({ 'acct': 'trwnh', - 'avatar': 'https://files.mastodon.social/accounts/avatars/000/014/715/original/34aa222f4ae2e0a9.png', - 'avatar_static': 'https://files.mastodon.social/accounts/avatars/000/014/715/original/34aa222f4ae2e0a9.png', - 'bot': False, - 'created_at': '2016-11-24T10:02:12.085Z', + 'avatar': 'https://files.mastodon.social/accounts/avatars/000/014/715/original/051c958388818705.png', + 'avatar_static': 'https://files.mastodon.social/accounts/avatars/000/014/715/original/051c958388818705.png', + 'bot': True, + 'created_at': '2016-11-24T00:00:00+00:00', + 'discoverable': True, 'display_name': 'infinite love ⴳ', 'emojis': list([ - dict({ - 'shortcode': 'fatyoshi', - 'static_url': 'https://files.mastodon.social/custom_emojis/images/000/023/920/static/e57ecb623faa0dc9.png', - 'url': 'https://files.mastodon.social/custom_emojis/images/000/023/920/original/e57ecb623faa0dc9.png', - 'visible_in_picker': True, - }), ]), 'fields': list([ dict({ 'name': 'Website', - 'value': 'trwnh.com', + 'value': 'trwnh.com', 'verified_at': '2019-08-29T04:14:55.571+00:00', }), dict({ - 'name': 'Sponsor', - 'value': 'liberapay.com/at', - 'verified_at': '2019-11-15T10:06:15.557+00:00', + 'name': 'Portfolio', + 'value': 'abdullahtarawneh.com', + 'verified_at': '2021-02-11T20:34:13.574+00:00', }), dict({ 'name': 'Fan of:', - 'value': 'Punk-rock and post-hardcore (Circa Survive, letlive., La Dispute, THE FEVER 333)Manga (Yu-Gi-Oh!, One Piece, JoJo's Bizarre Adventure, Death Note, Shaman King)Platformers and RPGs (Banjo-Kazooie, Boktai, Final Fantasy Crystal Chronicles)', + 'value': 'Punk-rock and post-hardcore (Circa Survive, letlive., La Dispute, THE FEVER 333)Manga (Yu-Gi-Oh!, One Piece, JoJo's Bizarre Adventure, Death Note, Shaman King)Platformers and RPGs (Banjo-Kazooie, Boktai, Final Fantasy Crystal Chronicles)', 'verified_at': None, }), dict({ - 'name': 'Main topics:', - 'value': 'systemic analysis, design patterns, anticapitalism, info/tech freedom, theory and philosophy, and otherwise being a genuine and decent wholesome poster. i'm just here to hang out and talk to cool people!', + 'name': 'What to expect:', + 'value': 'talking about various things i find interesting, and otherwise being a genuine and decent wholesome poster. i'm just here to hang out and talk to cool people! and to spill my thoughts.', 'verified_at': None, }), ]), - 'followers_count': 821, - 'following_count': 178, + 'followers_count': 3169, + 'following_count': 328, + 'group': False, 'header': 'https://files.mastodon.social/accounts/headers/000/014/715/original/5c6fc24edb3bb873.jpg', 'header_static': 'https://files.mastodon.social/accounts/headers/000/014/715/original/5c6fc24edb3bb873.jpg', + 'hide_collections': True, 'id': '14715', - 'last_status_at': '2019-11-24T15:49:42.251Z', + 'indexable': False, + 'last_status_at': '2025-03-04T00:00:00', + 'limited': None, 'locked': False, - 'note': '

i have approximate knowledge of many things. perpetual student. (nb/ace/they)

xmpp/email: a@trwnh.com
trwnh.com
help me live: liberapay.com/at or paypal.me/trwnh

- my triggers are moths and glitter
- i have all notifs except mentions turned off, so please interact if you wanna be friends! i literally will not notice otherwise
- dm me if i did something wrong, so i can improve
- purest person on fedi, do not lewd in my presence
- #1 ami cole fan account

:fatyoshi:

', - 'source': dict({ - 'fields': list([ - dict({ - 'name': 'Website', - 'value': 'https://trwnh.com', - 'verified_at': '2019-08-29T04:14:55.571+00:00', - }), - dict({ - 'name': 'Sponsor', - 'value': 'https://liberapay.com/at', - 'verified_at': '2019-11-15T10:06:15.557+00:00', - }), - dict({ - 'name': 'Fan of:', - 'value': "Punk-rock and post-hardcore (Circa Survive, letlive., La Dispute, THE FEVER 333)Manga (Yu-Gi-Oh!, One Piece, JoJo's Bizarre Adventure, Death Note, Shaman King)Platformers and RPGs (Banjo-Kazooie, Boktai, Final Fantasy Crystal Chronicles)", - 'verified_at': None, - }), - dict({ - 'name': 'Main topics:', - 'value': "systemic analysis, design patterns, anticapitalism, info/tech freedom, theory and philosophy, and otherwise being a genuine and decent wholesome poster. i'm just here to hang out and talk to cool people!", - 'verified_at': None, - }), - ]), - 'follow_requests_count': 0, - 'language': '', - 'note': ''' - i have approximate knowledge of many things. perpetual student. (nb/ace/they) - - xmpp/email: a@trwnh.com - https://trwnh.com - help me live: https://liberapay.com/at or https://paypal.me/trwnh - - - my triggers are moths and glitter - - i have all notifs except mentions turned off, so please interact if you wanna be friends! i literally will not notice otherwise - - dm me if i did something wrong, so i can improve - - purest person on fedi, do not lewd in my presence - - #1 ami cole fan account - - :fatyoshi: - ''', - 'privacy': 'public', - 'sensitive': False, - }), - 'statuses_count': 33120, + 'memorial': None, + 'moved_to_account': None, + 'mute_expires_at': None, + 'noindex': False, + 'note': '

i have approximate knowledge of many things. perpetual student. (nb/ace/they)

xmpp/email: a@trwnh.com
trwnh.com
help me live:
- donate.stripe.com/4gwcPCaMpcQ1
- liberapay.com/trwnh

notes:
- my triggers are moths and glitter
- i have all notifs except mentions turned off, so please interact if you wanna be friends! i literally will not notice otherwise
- dm me if i did something wrong, so i can improve
- purest person on fedi, do not lewd in my presence

', + 'role': None, + 'roles': list([ + ]), + 'source': None, + 'statuses_count': 69523, + 'suspended': None, + 'uri': 'https://mastodon.social/users/trwnh', 'url': 'https://mastodon.social/@trwnh', 'username': 'trwnh', }), 'instance': dict({ - 'configuration': dict({ - 'accounts': dict({ - 'max_featured_tags': 10, - 'max_pinned_statuses': 4, - }), - 'media_attachments': dict({ - 'image_matrix_limit': 16777216, - 'image_size_limit': 10485760, - 'supported_mime_types': list([ - 'image/jpeg', - 'image/png', - 'image/gif', - 'image/heic', - 'image/heif', - 'image/webp', - 'video/webm', - 'video/mp4', - 'video/quicktime', - 'video/ogg', - 'audio/wave', - 'audio/wav', - 'audio/x-wav', - 'audio/x-pn-wave', - 'audio/vnd.wave', - 'audio/ogg', - 'audio/vorbis', - 'audio/mpeg', - 'audio/mp3', - 'audio/webm', - 'audio/flac', - 'audio/aac', - 'audio/m4a', - 'audio/x-m4a', - 'audio/mp4', - 'audio/3gpp', - 'video/x-ms-asf', - ]), - 'video_frame_rate_limit': 60, - 'video_matrix_limit': 2304000, - 'video_size_limit': 41943040, - }), - 'polls': dict({ - 'max_characters_per_option': 50, - 'max_expiration': 2629746, - 'max_options': 4, - 'min_expiration': 300, - }), - 'statuses': dict({ - 'characters_reserved_per_url': 23, - 'max_characters': 500, - 'max_media_attachments': 4, - }), - 'translation': dict({ - 'enabled': True, - }), - 'urls': dict({ - 'streaming': 'wss://mastodon.social', - }), - 'vapid': dict({ - 'public_key': 'BCkMmVdKDnKYwzVCDC99Iuc9GvId-x7-kKtuHnLgfF98ENiZp_aj-UNthbCdI70DqN1zUVis-x0Wrot2sBagkMc=', - }), - }), - 'contact': dict({ - 'account': dict({ - 'acct': 'Gargron', - 'avatar': 'https://files.mastodon.social/accounts/avatars/000/000/001/original/dc4286ceb8fab734.jpg', - 'avatar_static': 'https://files.mastodon.social/accounts/avatars/000/000/001/original/dc4286ceb8fab734.jpg', - 'bot': False, - 'created_at': '2016-03-16T00:00:00.000Z', - 'discoverable': True, - 'display_name': 'Eugen 💀', - 'emojis': list([ - ]), - 'fields': list([ - dict({ - 'name': 'Patreon', - 'value': 'patreon.com/mastodon', - 'verified_at': None, - }), - ]), - 'followers_count': 133026, - 'following_count': 311, - 'group': False, - 'header': 'https://files.mastodon.social/accounts/headers/000/000/001/original/3b91c9965d00888b.jpeg', - 'header_static': 'https://files.mastodon.social/accounts/headers/000/000/001/original/3b91c9965d00888b.jpeg', - 'id': '1', - 'last_status_at': '2022-10-31', - 'locked': False, - 'noindex': False, - 'note': '

Founder, CEO and lead developer @Mastodon, Germany.

', - 'statuses_count': 72605, - 'url': 'https://mastodon.social/@Gargron', - 'username': 'Gargron', - }), - 'email': 'staff@mastodon.social', - }), + 'api_versions': None, + 'configuration': None, + 'contact': None, 'description': 'The original server operated by the Mastodon gGmbH non-profit', 'domain': 'mastodon.social', - 'languages': list([ - 'en', - ]), - 'registrations': dict({ - 'approval_required': False, - 'enabled': False, - 'message': None, - }), - 'rules': list([ - dict({ - 'id': '1', - 'text': 'Sexually explicit or violent media must be marked as sensitive when posting', - }), - dict({ - 'id': '2', - 'text': 'No racism, sexism, homophobia, transphobia, xenophobia, or casteism', - }), - dict({ - 'id': '3', - 'text': 'No incitement of violence or promotion of violent ideologies', - }), - dict({ - 'id': '4', - 'text': 'No harassment, dogpiling or doxxing of other users', - }), - dict({ - 'id': '5', - 'text': 'No content illegal in Germany', - }), - dict({ - 'id': '7', - 'text': 'Do not share intentionally false or misleading information', - }), - ]), + 'icon': None, + 'languages': None, + 'registrations': None, + 'rules': None, 'source_url': 'https://github.com/mastodon/mastodon', - 'thumbnail': dict({ - 'blurhash': 'UeKUpFxuo~R%0nW;WCnhF6RjaJt757oJodS$', - 'url': 'https://files.mastodon.social/site_uploads/files/000/000/001/@1x/57c12f441d083cde.png', - 'versions': dict({ - '@1x': 'https://files.mastodon.social/site_uploads/files/000/000/001/@1x/57c12f441d083cde.png', - '@2x': 'https://files.mastodon.social/site_uploads/files/000/000/001/@2x/57c12f441d083cde.png', - }), - }), + 'thumbnail': None, 'title': 'Mastodon', + 'uri': 'mastodon.social', 'usage': dict({ 'users': dict({ - 'active_month': 123122, + 'active_month': 380143, }), }), - 'version': '4.0.0rc1', + 'version': '4.4.0-nightly.2025-02-07', }), }) # --- diff --git a/tests/components/mastodon/snapshots/test_init.ambr b/tests/components/mastodon/snapshots/test_init.ambr index 28157b9e6eb..46fb4c1d4e0 100644 --- a/tests/components/mastodon/snapshots/test_init.ambr +++ b/tests/components/mastodon/snapshots/test_init.ambr @@ -28,7 +28,7 @@ 'primary_config_entry': , 'serial_number': None, 'suggested_area': None, - 'sw_version': '4.0.0rc1', + 'sw_version': '4.4.0-nightly.2025-02-07', 'via_device_id': None, }) # --- diff --git a/tests/components/mastodon/snapshots/test_sensor.ambr b/tests/components/mastodon/snapshots/test_sensor.ambr index 22ac2671c36..40986210454 100644 --- a/tests/components/mastodon/snapshots/test_sensor.ambr +++ b/tests/components/mastodon/snapshots/test_sensor.ambr @@ -47,7 +47,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '821', + 'state': '3169', }) # --- # name: test_sensors[sensor.mastodon_trwnh_mastodon_social_following-entry] @@ -98,7 +98,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '178', + 'state': '328', }) # --- # name: test_sensors[sensor.mastodon_trwnh_mastodon_social_posts-entry] @@ -149,6 +149,6 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '33120', + 'state': '69523', }) # --- From 8807e326d1d1fc9a53e9d438728aecaff2c99770 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Mon, 10 Mar 2025 17:15:52 +0100 Subject: [PATCH 2435/3148] Bump go2rtc to 1.9.9 (#140302) --- Dockerfile | 2 +- homeassistant/components/go2rtc/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 3ab0bb37b9a..251c92539a1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -25,7 +25,7 @@ RUN \ "armv7") go2rtc_suffix='arm' ;; \ *) go2rtc_suffix=${BUILD_ARCH} ;; \ esac \ - && curl -L https://github.com/AlexxIT/go2rtc/releases/download/v1.9.8/go2rtc_linux_${go2rtc_suffix} --output /bin/go2rtc \ + && curl -L https://github.com/AlexxIT/go2rtc/releases/download/v1.9.9/go2rtc_linux_${go2rtc_suffix} --output /bin/go2rtc \ && chmod +x /bin/go2rtc \ # Verify go2rtc can be executed && go2rtc --version diff --git a/homeassistant/components/go2rtc/const.py b/homeassistant/components/go2rtc/const.py index 234411936cb..491b2269043 100644 --- a/homeassistant/components/go2rtc/const.py +++ b/homeassistant/components/go2rtc/const.py @@ -6,4 +6,4 @@ CONF_DEBUG_UI = "debug_ui" DEBUG_UI_URL_MESSAGE = "Url and debug_ui cannot be set at the same time." HA_MANAGED_API_PORT = 11984 HA_MANAGED_URL = f"http://localhost:{HA_MANAGED_API_PORT}/" -RECOMMENDED_VERSION = "1.9.8" +RECOMMENDED_VERSION = "1.9.9" From d498dbd5ace41dc9657c265e42749dc49b9c8ea7 Mon Sep 17 00:00:00 2001 From: Antoine Reversat Date: Mon, 10 Mar 2025 12:37:30 -0400 Subject: [PATCH 2436/3148] FGLair : Upgrade to ayla-iot-unofficial 1.4.7 (#140296) Upgrade to ayla-iot-unofficial 1.4.7 --- homeassistant/components/fujitsu_fglair/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/fujitsu_fglair/manifest.json b/homeassistant/components/fujitsu_fglair/manifest.json index 330685f89fc..c8fed9b45c9 100644 --- a/homeassistant/components/fujitsu_fglair/manifest.json +++ b/homeassistant/components/fujitsu_fglair/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/fujitsu_fglair", "iot_class": "cloud_polling", - "requirements": ["ayla-iot-unofficial==1.4.5"] + "requirements": ["ayla-iot-unofficial==1.4.7"] } diff --git a/requirements_all.txt b/requirements_all.txt index 0a6e67f18c0..b06f8d2bb00 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -557,7 +557,7 @@ av==13.1.0 axis==64 # homeassistant.components.fujitsu_fglair -ayla-iot-unofficial==1.4.5 +ayla-iot-unofficial==1.4.7 # homeassistant.components.azure_event_hub azure-eventhub==5.11.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0d246d59b1e..8e6d0d61e23 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -506,7 +506,7 @@ av==13.1.0 axis==64 # homeassistant.components.fujitsu_fglair -ayla-iot-unofficial==1.4.5 +ayla-iot-unofficial==1.4.7 # homeassistant.components.azure_event_hub azure-eventhub==5.11.1 From 1665d9474f72f71f4012b9c1b31192dd0db96bfe Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 10 Mar 2025 15:12:37 -0400 Subject: [PATCH 2437/3148] Enable TTS streaming implementations (#140176) * Enable TTS streaming implementations * Update comment * Revert type change --- homeassistant/components/tts/__init__.py | 12 ++++-- homeassistant/components/tts/entity.py | 49 +++++++++++++++++++++--- 2 files changed, 52 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/tts/__init__.py b/homeassistant/components/tts/__init__.py index 98ce76cafde..31a92c62258 100644 --- a/homeassistant/components/tts/__init__.py +++ b/homeassistant/components/tts/__init__.py @@ -62,7 +62,7 @@ from .const import ( DOMAIN, TtsAudioType, ) -from .entity import TextToSpeechEntity +from .entity import TextToSpeechEntity, TTSAudioRequest from .helper import get_engine_instance from .legacy import PLATFORM_SCHEMA, PLATFORM_SCHEMA_BASE, Provider, async_setup_legacy from .media_source import generate_media_source_id, media_source_id_to_kwargs @@ -795,9 +795,15 @@ class SpeechManager: message, language, options ) else: - extension, data = await engine_instance.internal_async_get_tts_audio( - message, language, options + + async def message_gen() -> AsyncGenerator[str]: + yield message + + tts_result = await engine_instance.internal_async_stream_tts_audio( + TTSAudioRequest(language, options, message_gen()) ) + extension = tts_result.extension + data = b"".join([chunk async for chunk in tts_result.data_gen]) if data is None or extension is None: raise HomeAssistantError( diff --git a/homeassistant/components/tts/entity.py b/homeassistant/components/tts/entity.py index ef65886452d..199d673398e 100644 --- a/homeassistant/components/tts/entity.py +++ b/homeassistant/components/tts/entity.py @@ -1,6 +1,7 @@ """Entity for Text-to-Speech.""" -from collections.abc import Mapping +from collections.abc import AsyncGenerator, Mapping +from dataclasses import dataclass from functools import partial from typing import Any, final @@ -16,6 +17,7 @@ from homeassistant.components.media_player import ( ) from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.core import callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.util import dt as dt_util @@ -31,6 +33,23 @@ CACHED_PROPERTIES_WITH_ATTR_ = { } +@dataclass +class TTSAudioRequest: + """Request to get TTS audio.""" + + language: str + options: dict[str, Any] + message_gen: AsyncGenerator[str] + + +@dataclass +class TTSAudioResponse: + """Response containing TTS audio stream.""" + + extension: str + data_gen: AsyncGenerator[bytes] + + class TextToSpeechEntity(RestoreEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Represent a single TTS engine.""" @@ -128,19 +147,37 @@ class TextToSpeechEntity(RestoreEntity, cached_properties=CACHED_PROPERTIES_WITH ) @final - async def internal_async_get_tts_audio( - self, message: str, language: str, options: dict[str, Any] - ) -> TtsAudioType: + async def internal_async_stream_tts_audio( + self, request: TTSAudioRequest + ) -> TTSAudioResponse: """Process an audio stream to TTS service. Only streaming content is allowed! """ self.__last_tts_loaded = dt_util.utcnow().isoformat() self.async_write_ha_state() - return await self.async_get_tts_audio( - message=message, language=language, options=options + return await self.async_stream_tts_audio(request) + + async def async_stream_tts_audio( + self, request: TTSAudioRequest + ) -> TTSAudioResponse: + """Generate speech from an incoming message. + + The default implementation is backwards compatible with async_get_tts_audio. + """ + message = "".join([chunk async for chunk in request.message_gen]) + extension, data = await self.async_get_tts_audio( + message, request.language, request.options ) + if extension is None or data is None: + raise HomeAssistantError(f"No TTS from {self.entity_id} for '{message}'") + + async def data_gen() -> AsyncGenerator[bytes]: + yield data + + return TTSAudioResponse(extension, data_gen()) + def get_tts_audio( self, message: str, language: str, options: dict[str, Any] ) -> TtsAudioType: From 49a62d52947ff8e92f8e9e9921cd71e23acf84ca Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 10 Mar 2025 15:15:10 -0400 Subject: [PATCH 2438/3148] Standardize conversation.async_process method (#140125) --- .../components/anthropic/conversation.py | 14 +--- .../components/conversation/default_agent.py | 76 +++++++++---------- .../components/conversation/entity.py | 16 +++- .../conversation.py | 14 +--- .../components/ollama/conversation.py | 14 +--- .../openai_conversation/conversation.py | 14 +--- 6 files changed, 55 insertions(+), 93 deletions(-) diff --git a/homeassistant/components/anthropic/conversation.py b/homeassistant/components/anthropic/conversation.py index 8d3ba5085ee..ff403e61a91 100644 --- a/homeassistant/components/anthropic/conversation.py +++ b/homeassistant/components/anthropic/conversation.py @@ -30,7 +30,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_LLM_HASS_API, MATCH_ALL from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import chat_session, device_registry as dr, intent, llm +from homeassistant.helpers import device_registry as dr, intent, llm from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import AnthropicConfigEntry @@ -226,18 +226,6 @@ class AnthropicConversationEntity( self.entry.add_update_listener(self._async_entry_update_listener) ) - async def async_process( - self, user_input: conversation.ConversationInput - ) -> conversation.ConversationResult: - """Process a sentence.""" - with ( - chat_session.async_get_chat_session( - self.hass, user_input.conversation_id - ) as session, - conversation.async_get_chat_log(self.hass, session, user_input) as chat_log, - ): - return await self._async_handle_message(user_input, chat_log) - async def _async_handle_message( self, user_input: conversation.ConversationInput, diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py index 3a7aa0c26e8..c30e8bb4a92 100644 --- a/homeassistant/components/conversation/default_agent.py +++ b/homeassistant/components/conversation/default_agent.py @@ -42,7 +42,6 @@ from homeassistant.components.homeassistant.exposed_entities import ( from homeassistant.const import EVENT_STATE_CHANGED, MATCH_ALL from homeassistant.helpers import ( area_registry as ar, - chat_session, device_registry as dr, entity_registry as er, floor_registry as fr, @@ -56,7 +55,7 @@ from homeassistant.helpers.event import async_track_state_added_domain from homeassistant.util import language as language_util from homeassistant.util.json import JsonObjectType, json_loads_object -from .chat_log import AssistantContent, async_get_chat_log +from .chat_log import AssistantContent, ChatLog from .const import ( DATA_DEFAULT_ENTITY, DEFAULT_EXPOSED_ATTRIBUTES, @@ -332,49 +331,46 @@ class DefaultAgent(ConversationEntity): return result - async def async_process(self, user_input: ConversationInput) -> ConversationResult: - """Process a sentence.""" + async def _async_handle_message( + self, + user_input: ConversationInput, + chat_log: ChatLog, + ) -> ConversationResult: + """Handle a message.""" response: intent.IntentResponse | None = None - with ( - chat_session.async_get_chat_session( - self.hass, user_input.conversation_id - ) as session, - async_get_chat_log(self.hass, session, user_input) as chat_log, - ): - # Check if a trigger matched - if trigger_result := await self.async_recognize_sentence_trigger( - user_input - ): - # Process callbacks and get response - response_text = await self._handle_trigger_result( - trigger_result, user_input - ) - # Convert to conversation result - response = intent.IntentResponse( - language=user_input.language or self.hass.config.language - ) - response.response_type = intent.IntentResponseType.ACTION_DONE - response.async_set_speech(response_text) - - if response is None: - # Match intents - intent_result = await self.async_recognize_intent(user_input) - response = await self._async_process_intent_result( - intent_result, user_input - ) - - speech: str = response.speech.get("plain", {}).get("speech", "") - chat_log.async_add_assistant_content_without_tools( - AssistantContent( - agent_id=user_input.agent_id, - content=speech, - ) + # Check if a trigger matched + if trigger_result := await self.async_recognize_sentence_trigger(user_input): + # Process callbacks and get response + response_text = await self._handle_trigger_result( + trigger_result, user_input ) - return ConversationResult( - response=response, conversation_id=session.conversation_id + # Convert to conversation result + response = intent.IntentResponse( + language=user_input.language or self.hass.config.language ) + response.response_type = intent.IntentResponseType.ACTION_DONE + response.async_set_speech(response_text) + + if response is None: + # Match intents + intent_result = await self.async_recognize_intent(user_input) + response = await self._async_process_intent_result( + intent_result, user_input + ) + + speech: str = response.speech.get("plain", {}).get("speech", "") + chat_log.async_add_assistant_content_without_tools( + AssistantContent( + agent_id=user_input.agent_id, + content=speech, + ) + ) + + return ConversationResult( + response=response, conversation_id=chat_log.conversation_id + ) async def _async_process_intent_result( self, diff --git a/homeassistant/components/conversation/entity.py b/homeassistant/components/conversation/entity.py index d9598dee7eb..ca4d18ab9f5 100644 --- a/homeassistant/components/conversation/entity.py +++ b/homeassistant/components/conversation/entity.py @@ -4,9 +4,11 @@ from abc import abstractmethod from typing import Literal, final from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.helpers.chat_session import async_get_chat_session from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.util import dt as dt_util +from .chat_log import ChatLog, async_get_chat_log from .const import ConversationEntityFeature from .models import ConversationInput, ConversationResult @@ -51,9 +53,21 @@ class ConversationEntity(RestoreEntity): def supported_languages(self) -> list[str] | Literal["*"]: """Return a list of supported languages.""" - @abstractmethod async def async_process(self, user_input: ConversationInput) -> ConversationResult: """Process a sentence.""" + with ( + async_get_chat_session(self.hass, user_input.conversation_id) as session, + async_get_chat_log(self.hass, session, user_input) as chat_log, + ): + return await self._async_handle_message(user_input, chat_log) + + async def _async_handle_message( + self, + user_input: ConversationInput, + chat_log: ChatLog, + ) -> ConversationResult: + """Call the API.""" + raise NotImplementedError async def async_prepare(self, language: str | None = None) -> None: """Load intents for a language.""" diff --git a/homeassistant/components/google_generative_ai_conversation/conversation.py b/homeassistant/components/google_generative_ai_conversation/conversation.py index b43558c6768..93b7bbe5ebc 100644 --- a/homeassistant/components/google_generative_ai_conversation/conversation.py +++ b/homeassistant/components/google_generative_ai_conversation/conversation.py @@ -25,7 +25,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_LLM_HASS_API, MATCH_ALL from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import chat_session, device_registry as dr, intent, llm +from homeassistant.helpers import device_registry as dr, intent, llm from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( @@ -264,18 +264,6 @@ class GoogleGenerativeAIConversationEntity( conversation.async_unset_agent(self.hass, self.entry) await super().async_will_remove_from_hass() - async def async_process( - self, user_input: conversation.ConversationInput - ) -> conversation.ConversationResult: - """Process a sentence.""" - with ( - chat_session.async_get_chat_session( - self.hass, user_input.conversation_id - ) as session, - conversation.async_get_chat_log(self.hass, session, user_input) as chat_log, - ): - return await self._async_handle_message(user_input, chat_log) - async def _async_handle_message( self, user_input: conversation.ConversationInput, diff --git a/homeassistant/components/ollama/conversation.py b/homeassistant/components/ollama/conversation.py index 85daf742035..ab9e05b5fbe 100644 --- a/homeassistant/components/ollama/conversation.py +++ b/homeassistant/components/ollama/conversation.py @@ -15,7 +15,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_LLM_HASS_API, MATCH_ALL from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import chat_session, intent, llm +from homeassistant.helpers import intent, llm from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( @@ -206,18 +206,6 @@ class OllamaConversationEntity( """Return a list of supported languages.""" return MATCH_ALL - async def async_process( - self, user_input: conversation.ConversationInput - ) -> conversation.ConversationResult: - """Process a sentence.""" - with ( - chat_session.async_get_chat_session( - self.hass, user_input.conversation_id - ) as session, - conversation.async_get_chat_log(self.hass, session, user_input) as chat_log, - ): - return await self._async_handle_message(user_input, chat_log) - async def _async_handle_message( self, user_input: conversation.ConversationInput, diff --git a/homeassistant/components/openai_conversation/conversation.py b/homeassistant/components/openai_conversation/conversation.py index 37be41947f7..e42319f8e96 100644 --- a/homeassistant/components/openai_conversation/conversation.py +++ b/homeassistant/components/openai_conversation/conversation.py @@ -24,7 +24,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_LLM_HASS_API, MATCH_ALL from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import chat_session, device_registry as dr, intent, llm +from homeassistant.helpers import device_registry as dr, intent, llm from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import OpenAIConfigEntry @@ -223,18 +223,6 @@ class OpenAIConversationEntity( conversation.async_unset_agent(self.hass, self.entry) await super().async_will_remove_from_hass() - async def async_process( - self, user_input: conversation.ConversationInput - ) -> conversation.ConversationResult: - """Process a sentence.""" - with ( - chat_session.async_get_chat_session( - self.hass, user_input.conversation_id - ) as session, - conversation.async_get_chat_log(self.hass, session, user_input) as chat_log, - ): - return await self._async_handle_message(user_input, chat_log) - async def _async_handle_message( self, user_input: conversation.ConversationInput, From 8fe45fb994e037b12ef99b265a047367e3a86771 Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Mon, 10 Mar 2025 17:02:07 -0400 Subject: [PATCH 2439/3148] Fix todo tool broken with Gemini 2.0 models. (#140246) * Change tool name for addlist item * Change to HasListAddItem * extract to function --- .../conversation.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/google_generative_ai_conversation/conversation.py b/homeassistant/components/google_generative_ai_conversation/conversation.py index 93b7bbe5ebc..93546431391 100644 --- a/homeassistant/components/google_generative_ai_conversation/conversation.py +++ b/homeassistant/components/google_generative_ai_conversation/conversation.py @@ -264,6 +264,13 @@ class GoogleGenerativeAIConversationEntity( conversation.async_unset_agent(self.hass, self.entry) await super().async_will_remove_from_hass() + def _fix_tool_name(self, tool_name: str) -> str: + """Fix tool name if needed.""" + # The Gemini 2.0+ tokenizer seemingly has a issue with the HassListAddItem tool + # name. This makes sure when it incorrectly changes the name, that we change it + # back for HA to call. + return tool_name if tool_name != "HasListAddItem" else "HassListAddItem" + async def _async_handle_message( self, user_input: conversation.ConversationInput, @@ -423,7 +430,10 @@ class GoogleGenerativeAIConversationEntity( tool_name = tool_call.name tool_args = _escape_decode(tool_call.args) tool_calls.append( - llm.ToolInput(tool_name=tool_name, tool_args=tool_args) + llm.ToolInput( + tool_name=self._fix_tool_name(tool_name), + tool_args=tool_args, + ) ) chat_request = _create_google_tool_response_content( From 058c965b88779ce46a1d4b62b5dfcf6e7422960a Mon Sep 17 00:00:00 2001 From: Glen Robertson Date: Mon, 10 Mar 2025 17:25:38 -0400 Subject: [PATCH 2440/3148] Set anthemav volume_step to 0.01 (#140130) --- homeassistant/components/anthemav/media_player.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/anthemav/media_player.py b/homeassistant/components/anthemav/media_player.py index cfbd3c29547..317498e96b5 100644 --- a/homeassistant/components/anthemav/media_player.py +++ b/homeassistant/components/anthemav/media_player.py @@ -22,6 +22,7 @@ from . import AnthemavConfigEntry from .const import ANTHEMAV_UPDATE_SIGNAL, DOMAIN, MANUFACTURER _LOGGER = logging.getLogger(__name__) +VOLUME_STEP = 0.01 async def async_setup_entry( @@ -60,6 +61,7 @@ class AnthemAVR(MediaPlayerEntity): | MediaPlayerEntityFeature.TURN_OFF | MediaPlayerEntityFeature.SELECT_SOURCE ) + _attr_volume_step = VOLUME_STEP def __init__( self, From bf50ee9b5e365ae2bdf58bb7a0f21962d4f6442d Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 10 Mar 2025 23:12:47 +0100 Subject: [PATCH 2441/3148] Capitalize abbreviations in `lektrico` integration (#140311) * Capitalize abbreviations in `lektrico` integration * Update test_number.ambr * Update test_binary_sensor.ambr * Update test_binary_sensor.ambr * Update test_number.ambr --- homeassistant/components/lektrico/strings.json | 8 ++++---- .../lektrico/snapshots/test_binary_sensor.ambr | 12 ++++++------ tests/components/lektrico/snapshots/test_number.ambr | 4 ++-- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/lektrico/strings.json b/homeassistant/components/lektrico/strings.json index 3b4417c346a..eb0203e0661 100644 --- a/homeassistant/components/lektrico/strings.json +++ b/homeassistant/components/lektrico/strings.json @@ -24,7 +24,7 @@ "entity": { "binary_sensor": { "state_e_activated": { - "name": "Ev error" + "name": "EV error" }, "overtemp": { "name": "Thermal throttling" @@ -45,10 +45,10 @@ "name": "Overvoltage" }, "rcd_error": { - "name": "Rcd error" + "name": "RCD error" }, "cp_diode_failure": { - "name": "Ev diode short" + "name": "EV diode short" }, "contactor_failure": { "name": "Relay contacts welded" @@ -64,7 +64,7 @@ }, "number": { "led_max_brightness": { - "name": "Led brightness" + "name": "LED brightness" }, "dynamic_limit": { "name": "Dynamic limit" diff --git a/tests/components/lektrico/snapshots/test_binary_sensor.ambr b/tests/components/lektrico/snapshots/test_binary_sensor.ambr index b365ff84187..7d812c0fc67 100644 --- a/tests/components/lektrico/snapshots/test_binary_sensor.ambr +++ b/tests/components/lektrico/snapshots/test_binary_sensor.ambr @@ -24,7 +24,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Ev diode short', + 'original_name': 'EV diode short', 'platform': 'lektrico', 'previous_unique_id': None, 'supported_features': 0, @@ -37,7 +37,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'problem', - 'friendly_name': '1p7k_500006 Ev diode short', + 'friendly_name': '1p7k_500006 EV diode short', }), 'context': , 'entity_id': 'binary_sensor.1p7k_500006_ev_diode_short', @@ -72,7 +72,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Ev error', + 'original_name': 'EV error', 'platform': 'lektrico', 'previous_unique_id': None, 'supported_features': 0, @@ -85,7 +85,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'problem', - 'friendly_name': '1p7k_500006 Ev error', + 'friendly_name': '1p7k_500006 EV error', }), 'context': , 'entity_id': 'binary_sensor.1p7k_500006_ev_error', @@ -312,7 +312,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Rcd error', + 'original_name': 'RCD error', 'platform': 'lektrico', 'previous_unique_id': None, 'supported_features': 0, @@ -325,7 +325,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'problem', - 'friendly_name': '1p7k_500006 Rcd error', + 'friendly_name': '1p7k_500006 RCD error', }), 'context': , 'entity_id': 'binary_sensor.1p7k_500006_rcd_error', diff --git a/tests/components/lektrico/snapshots/test_number.ambr b/tests/components/lektrico/snapshots/test_number.ambr index 57cf40567e7..368479cdd06 100644 --- a/tests/components/lektrico/snapshots/test_number.ambr +++ b/tests/components/lektrico/snapshots/test_number.ambr @@ -86,7 +86,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Led brightness', + 'original_name': 'LED brightness', 'platform': 'lektrico', 'previous_unique_id': None, 'supported_features': 0, @@ -98,7 +98,7 @@ # name: test_all_entities[number.1p7k_500006_led_brightness-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': '1p7k_500006 Led brightness', + 'friendly_name': '1p7k_500006 LED brightness', 'max': 100, 'min': 0, 'mode': , From 37213503b1b389aa3d2bce388695859aae5a04bc Mon Sep 17 00:00:00 2001 From: Antoine Reversat Date: Mon, 10 Mar 2025 18:16:44 -0400 Subject: [PATCH 2442/3148] Do not add outside temperature sensor for FGLair if reading is None (#140298) * Do not add outside temperature sensor if reading is None * Fix comments --- .../components/fujitsu_fglair/sensor.py | 1 + tests/components/fujitsu_fglair/test_sensor.py | 17 +++++++++++++++++ 2 files changed, 18 insertions(+) diff --git a/homeassistant/components/fujitsu_fglair/sensor.py b/homeassistant/components/fujitsu_fglair/sensor.py index 0ad5bec3117..3bb693e1068 100644 --- a/homeassistant/components/fujitsu_fglair/sensor.py +++ b/homeassistant/components/fujitsu_fglair/sensor.py @@ -24,6 +24,7 @@ async def async_setup_entry( async_add_entities( FGLairOutsideTemperature(entry.runtime_data, device) for device in entry.runtime_data.data.values() + if device.outdoor_temperature is not None ) diff --git a/tests/components/fujitsu_fglair/test_sensor.py b/tests/components/fujitsu_fglair/test_sensor.py index e3f6109a2e8..b8200f114ad 100644 --- a/tests/components/fujitsu_fglair/test_sensor.py +++ b/tests/components/fujitsu_fglair/test_sensor.py @@ -31,3 +31,20 @@ async def test_entities( assert await integration_setup() await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_no_outside_temperature( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_ayla_api: AsyncMock, + integration_setup: Callable[[], Awaitable[bool]], +) -> None: + """Test that the outside sensor doesn't get added if the reading is None.""" + mock_ayla_api.async_get_devices.return_value[0].outdoor_temperature = None + + assert await integration_setup() + + assert ( + len(entity_registry.entities) + == len(mock_ayla_api.async_get_devices.return_value) - 1 + ) From 2e79db369585e1ec0fb9f2bc92fef9140ff13d2c Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Tue, 11 Mar 2025 02:29:26 +0100 Subject: [PATCH 2443/3148] Fix hass stop in bootstrap (#132795) --- homeassistant/bootstrap.py | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 734439842b2..e301912806c 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -299,14 +299,6 @@ async def async_setup_hass( return hass - async def stop_hass(hass: core.HomeAssistant) -> None: - """Stop hass.""" - # Ask integrations to shut down. It's messy but we can't - # do a clean stop without knowing what is broken - with contextlib.suppress(TimeoutError): - async with hass.timeout.async_timeout(10): - await hass.async_stop() - hass = await create_hass() if runtime_config.skip_pip or runtime_config.skip_pip_packages: @@ -345,7 +337,7 @@ async def async_setup_hass( if config_dict is None: recovery_mode = True - await stop_hass(hass) + await hass.async_stop(force=True) hass = await create_hass() elif not basic_setup_success: @@ -353,7 +345,7 @@ async def async_setup_hass( "Unable to set up core integrations. Activating recovery mode" ) recovery_mode = True - await stop_hass(hass) + await hass.async_stop(force=True) hass = await create_hass() elif any( @@ -368,7 +360,7 @@ async def async_setup_hass( old_logging = hass.data.get(DATA_LOGGING) recovery_mode = True - await stop_hass(hass) + await hass.async_stop(force=True) hass = await create_hass() if old_logging: From b6df07b2ed1e89326fac4c29da0bbca91599ac4d Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 11 Mar 2025 06:14:22 +0100 Subject: [PATCH 2444/3148] Improve user-facing strings of `nordpool` integration (#140286) --- homeassistant/components/nordpool/strings.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/nordpool/strings.json b/homeassistant/components/nordpool/strings.json index cc10a1a0640..7b33f032de1 100644 --- a/homeassistant/components/nordpool/strings.json +++ b/homeassistant/components/nordpool/strings.json @@ -15,7 +15,7 @@ }, "data_description": { "currency": "Select currency to display prices in, EUR is the base currency.", - "areas": "Areas to display prices for according to Nordpool market areas." + "areas": "Areas to display prices for according to Nord Pool market areas." } }, "reconfigure": { @@ -95,11 +95,11 @@ "services": { "get_prices_for_date": { "name": "Get prices for date", - "description": "Retrieve the prices for a specific date.", + "description": "Retrieves the prices for a specific date.", "fields": { "config_entry": { - "name": "Select Nord Pool configuration entry", - "description": "Choose the configuration entry." + "name": "Config entry", + "description": "The Nord Pool configuration entry for this action." }, "date": { "name": "Date", From a65bf35a06022f15e2e5e251f8f5b837921d92ba Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Tue, 11 Mar 2025 18:06:29 +1000 Subject: [PATCH 2445/3148] Bump teslemetry-stream (#140335) Bump --- homeassistant/components/teslemetry/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/teslemetry/manifest.json b/homeassistant/components/teslemetry/manifest.json index 4e9228acd2f..7c27024d9f0 100644 --- a/homeassistant/components/teslemetry/manifest.json +++ b/homeassistant/components/teslemetry/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/teslemetry", "iot_class": "cloud_polling", "loggers": ["tesla-fleet-api"], - "requirements": ["tesla-fleet-api==0.9.12", "teslemetry-stream==0.6.10"] + "requirements": ["tesla-fleet-api==0.9.12", "teslemetry-stream==0.6.12"] } diff --git a/requirements_all.txt b/requirements_all.txt index b06f8d2bb00..56991204f5a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2881,7 +2881,7 @@ tesla-powerwall==0.5.2 tesla-wall-connector==1.0.2 # homeassistant.components.teslemetry -teslemetry-stream==0.6.10 +teslemetry-stream==0.6.12 # homeassistant.components.tessie tessie-api==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8e6d0d61e23..580190feef5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2321,7 +2321,7 @@ tesla-powerwall==0.5.2 tesla-wall-connector==1.0.2 # homeassistant.components.teslemetry -teslemetry-stream==0.6.10 +teslemetry-stream==0.6.12 # homeassistant.components.tessie tessie-api==0.1.1 From 873cf6ac09c010e31ebd0e69ae5c09bcdfc7da5d Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 11 Mar 2025 09:09:15 +0100 Subject: [PATCH 2446/3148] Fix sentence-casing and spelling of "LED" in `baf` integration (#140343) --- homeassistant/components/baf/strings.json | 26 +++++++++++------------ 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/baf/strings.json b/homeassistant/components/baf/strings.json index e2f02a6095e..64956984bb8 100644 --- a/homeassistant/components/baf/strings.json +++ b/homeassistant/components/baf/strings.json @@ -23,7 +23,7 @@ "entity": { "climate": { "auto_comfort": { - "name": "Auto comfort" + "name": "Auto Comfort" } }, "fan": { @@ -39,25 +39,25 @@ }, "number": { "comfort_min_speed": { - "name": "Auto Comfort Minimum Speed" + "name": "Auto Comfort minimum speed" }, "comfort_max_speed": { - "name": "Auto Comfort Maximum Speed" + "name": "Auto Comfort maximum speed" }, "comfort_heat_assist_speed": { - "name": "Auto Comfort Heat Assist Speed" + "name": "Auto Comfort Heat Assist speed" }, "return_to_auto_timeout": { - "name": "Return to Auto Timeout" + "name": "Return to Auto timeout" }, "motion_sense_timeout": { - "name": "Motion Sense Timeout" + "name": "Motion sense timeout" }, "light_return_to_auto_timeout": { - "name": "Light Return to Auto Timeout" + "name": "Light return to Auto timeout" }, "light_auto_motion_timeout": { - "name": "Light Motion Sense Timeout" + "name": "Light motion sense timeout" } }, "sensor": { @@ -76,10 +76,10 @@ }, "switch": { "legacy_ir_remote_enable": { - "name": "Legacy IR Remote" + "name": "Legacy IR remote" }, "led_indicators_enable": { - "name": "Led Indicators" + "name": "LED indicators" }, "comfort_heat_assist_enable": { "name": "Auto Comfort Heat Assist" @@ -88,10 +88,10 @@ "name": "Beep" }, "eco_enable": { - "name": "Eco Mode" + "name": "Eco mode" }, "motion_sense_enable": { - "name": "Motion Sense" + "name": "Motion sense" }, "return_to_auto_enable": { "name": "Return to Auto" @@ -103,7 +103,7 @@ "name": "Dim to Warm" }, "light_return_to_auto_enable": { - "name": "Light Return to Auto" + "name": "Light return to Auto" } } } From 6b601b9aad866ffbc522d965d370207a7454a07c Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Tue, 11 Mar 2025 04:09:53 -0400 Subject: [PATCH 2447/3148] Bump ZHA to 0.0.52 (#140325) --- homeassistant/components/zha/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 0cc2524469e..d16ce5a64bf 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -21,7 +21,7 @@ "zha", "universal_silabs_flasher" ], - "requirements": ["zha==0.0.51"], + "requirements": ["zha==0.0.52"], "usb": [ { "vid": "10C4", diff --git a/requirements_all.txt b/requirements_all.txt index 56991204f5a..aeb3a52f625 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3149,7 +3149,7 @@ zeroconf==0.146.0 zeversolar==0.3.2 # homeassistant.components.zha -zha==0.0.51 +zha==0.0.52 # homeassistant.components.zhong_hong zhong-hong-hvac==1.0.13 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 580190feef5..b2a59379f68 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2538,7 +2538,7 @@ zeroconf==0.146.0 zeversolar==0.3.2 # homeassistant.components.zha -zha==0.0.51 +zha==0.0.52 # homeassistant.components.zwave_js zwave-js-server-python==0.61.0 From cdff2e46480188156567ea104881e51108be4622 Mon Sep 17 00:00:00 2001 From: Andrew Jackson Date: Tue, 11 Mar 2025 08:11:46 +0000 Subject: [PATCH 2448/3148] Add strict typing of post to Mastodon (#140299) * Type post API * Update quality scale --- homeassistant/components/mastodon/notify.py | 10 ++++++---- homeassistant/components/mastodon/quality_scale.yaml | 5 +---- homeassistant/components/mastodon/services.py | 6 +++--- tests/components/mastodon/test_services.py | 8 +++++--- 4 files changed, 15 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/mastodon/notify.py b/homeassistant/components/mastodon/notify.py index 8af98ec3ab1..149ef1f6a48 100644 --- a/homeassistant/components/mastodon/notify.py +++ b/homeassistant/components/mastodon/notify.py @@ -5,7 +5,7 @@ from __future__ import annotations from typing import Any, cast from mastodon import Mastodon -from mastodon.Mastodon import MastodonAPIError +from mastodon.Mastodon import MastodonAPIError, MediaAttachment import voluptuous as vol from homeassistant.components.notify import ( @@ -114,7 +114,7 @@ class MastodonNotificationService(BaseNotificationService): message, visibility=target, spoiler_text=content_warning, - media_ids=mediadata["id"], + media_ids=mediadata.id, sensitive=sensitive, ) except MastodonAPIError as err: @@ -134,12 +134,14 @@ class MastodonNotificationService(BaseNotificationService): translation_key="unable_to_send_message", ) from err - def _upload_media(self, media_path: Any = None) -> Any: + def _upload_media(self, media_path: Any = None) -> MediaAttachment: """Upload media.""" with open(media_path, "rb"): media_type = get_media_type(media_path) try: - mediadata = self.client.media_post(media_path, mime_type=media_type) + mediadata: MediaAttachment = self.client.media_post( + media_path, mime_type=media_type + ) except MastodonAPIError as err: raise HomeAssistantError( translation_domain=DOMAIN, diff --git a/homeassistant/components/mastodon/quality_scale.yaml b/homeassistant/components/mastodon/quality_scale.yaml index 43636ed6924..f07f7e0a8ad 100644 --- a/homeassistant/components/mastodon/quality_scale.yaml +++ b/homeassistant/components/mastodon/quality_scale.yaml @@ -93,7 +93,4 @@ rules: # Platinum async-dependency: todo inject-websession: todo - strict-typing: - status: todo - comment: | - Requirement 'Mastodon.py==1.8.1' appears untyped + strict-typing: done diff --git a/homeassistant/components/mastodon/services.py b/homeassistant/components/mastodon/services.py index 7ab351f8c29..68e95e726a1 100644 --- a/homeassistant/components/mastodon/services.py +++ b/homeassistant/components/mastodon/services.py @@ -5,7 +5,7 @@ from functools import partial from typing import Any, cast from mastodon import Mastodon -from mastodon.Mastodon import MastodonAPIError +from mastodon.Mastodon import MastodonAPIError, MediaAttachment import voluptuous as vol from homeassistant.config_entries import ConfigEntryState @@ -104,7 +104,7 @@ def setup_services(hass: HomeAssistant) -> None: def _post(client: Mastodon, **kwargs: Any) -> None: """Post to Mastodon.""" - media_data: dict[str, Any] | None = None + media_data: MediaAttachment | None = None media_path = kwargs.get("media_path") if media_path: @@ -137,7 +137,7 @@ def setup_services(hass: HomeAssistant) -> None: try: media_ids: str | None = None if media_data: - media_ids = media_data["id"] + media_ids = media_data.id client.status_post(media_ids=media_ids, **kwargs) except MastodonAPIError as err: raise HomeAssistantError( diff --git a/tests/components/mastodon/test_services.py b/tests/components/mastodon/test_services.py index 4dafa9a8e5b..f51d39f8687 100644 --- a/tests/components/mastodon/test_services.py +++ b/tests/components/mastodon/test_services.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock, Mock, patch -from mastodon.Mastodon import MastodonAPIError +from mastodon.Mastodon import MastodonAPIError, MediaAttachment import pytest from homeassistant.components.mastodon.const import ( @@ -106,7 +106,9 @@ async def test_service_post( with ( patch.object(hass.config, "is_allowed_path", return_value=True), - patch.object(mock_mastodon_client, "media_post", return_value={"id": "1"}), + patch.object( + mock_mastodon_client, "media_post", return_value=MediaAttachment(id="1") + ), ): await hass.services.async_call( DOMAIN, @@ -163,7 +165,7 @@ async def test_post_service_failed( await hass.async_block_till_done() hass.config.is_allowed_path = Mock(return_value=True) - mock_mastodon_client.media_post.return_value = {"id": "1"} + mock_mastodon_client.media_post.return_value = MediaAttachment(id="1") mock_mastodon_client.status_post.side_effect = MastodonAPIError From 711f9ab900373eb85a58f4e472a4891fe559fcf2 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 11 Mar 2025 09:12:29 +0100 Subject: [PATCH 2449/3148] Correct sentence-casing and spelling of "LED" in `zha` integration (#140342) --- homeassistant/components/zha/strings.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/zha/strings.json b/homeassistant/components/zha/strings.json index be1642227bd..23bb9ae051e 100644 --- a/homeassistant/components/zha/strings.json +++ b/homeassistant/components/zha/strings.json @@ -176,7 +176,7 @@ }, "config_panel": { "zha_options": { - "title": "Global Options", + "title": "Global options", "enhanced_light_transition": "Enable enhanced light color/temperature transition from an off-state", "light_transitioning_flag": "Enable enhanced brightness slider during light transition", "group_members_assume_state": "Group members assume state of group", @@ -187,7 +187,7 @@ "consider_unavailable_battery": "Consider battery powered devices unavailable after (seconds)" }, "zha_alarm_options": { - "title": "Alarm Control Panel Options", + "title": "Alarm control panel options", "alarm_master_code": "Master code for the alarm control panel(s)", "alarm_failed_tries": "The number of consecutive failed code entries to trigger an alarm", "alarm_arm_requires_code": "Code required for arming actions" @@ -1144,10 +1144,10 @@ "name": "Switch type" }, "led_scaling_mode": { - "name": "Led scaling mode" + "name": "LED scaling mode" }, "smart_fan_led_display_levels": { - "name": "Smart fan led display levels" + "name": "Smart fan LED display levels" }, "increased_non_neutral_output": { "name": "Non neutral output" From a45ce3083bc3a889335d099a2716cd6cda99f5bd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 10 Mar 2025 22:15:20 -1000 Subject: [PATCH 2450/3148] Bump pylutron-caseta 0.24.0 (#140338) changelog: https://github.com/gurumitts/pylutron-caseta/compare/v0.23.0...v0.24.0 --- homeassistant/components/lutron_caseta/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/lutron_caseta/manifest.json b/homeassistant/components/lutron_caseta/manifest.json index bbb6df41a89..96b00a1f392 100644 --- a/homeassistant/components/lutron_caseta/manifest.json +++ b/homeassistant/components/lutron_caseta/manifest.json @@ -9,7 +9,7 @@ }, "iot_class": "local_push", "loggers": ["pylutron_caseta"], - "requirements": ["pylutron-caseta==0.23.0"], + "requirements": ["pylutron-caseta==0.24.0"], "zeroconf": [ { "type": "_lutron._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index aeb3a52f625..fc586ec35d0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2098,7 +2098,7 @@ pylitejet==0.6.3 pylitterbot==2024.0.0 # homeassistant.components.lutron_caseta -pylutron-caseta==0.23.0 +pylutron-caseta==0.24.0 # homeassistant.components.lutron pylutron==0.2.16 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b2a59379f68..e957aa518f9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1712,7 +1712,7 @@ pylitejet==0.6.3 pylitterbot==2024.0.0 # homeassistant.components.lutron_caseta -pylutron-caseta==0.23.0 +pylutron-caseta==0.24.0 # homeassistant.components.lutron pylutron==0.2.16 From e0f4da390af6e37be49c2a34ea554d9f5095c5f9 Mon Sep 17 00:00:00 2001 From: David Knowles Date: Tue, 11 Mar 2025 04:16:44 -0400 Subject: [PATCH 2451/3148] Bump pydrawise to 2025.3.0 (#140330) --- homeassistant/components/hydrawise/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/hydrawise/manifest.json b/homeassistant/components/hydrawise/manifest.json index 73423882e4a..0c355c34a71 100644 --- a/homeassistant/components/hydrawise/manifest.json +++ b/homeassistant/components/hydrawise/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/hydrawise", "iot_class": "cloud_polling", "loggers": ["pydrawise"], - "requirements": ["pydrawise==2025.2.0"] + "requirements": ["pydrawise==2025.3.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index fc586ec35d0..7530b75b0b3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1906,7 +1906,7 @@ pydiscovergy==3.0.2 pydoods==1.0.2 # homeassistant.components.hydrawise -pydrawise==2025.2.0 +pydrawise==2025.3.0 # homeassistant.components.android_ip_webcam pydroid-ipcam==2.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e957aa518f9..a1c8c3ff509 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1556,7 +1556,7 @@ pydexcom==0.2.3 pydiscovergy==3.0.2 # homeassistant.components.hydrawise -pydrawise==2025.2.0 +pydrawise==2025.3.0 # homeassistant.components.android_ip_webcam pydroid-ipcam==2.0.0 From 6e2148193a9a904f72a1a0b35296beb2f8ba688a Mon Sep 17 00:00:00 2001 From: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> Date: Tue, 11 Mar 2025 03:18:31 -0500 Subject: [PATCH 2452/3148] Bump pyheos to v1.0.3 (#140310) Bump pyheos v1.0.3 --- homeassistant/components/heos/coordinator.py | 3 +- homeassistant/components/heos/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../heos/snapshots/test_diagnostics.ambr | 4 ++ tests/components/heos/test_init.py | 4 +- tests/components/heos/test_media_player.py | 38 +------------------ 7 files changed, 11 insertions(+), 44 deletions(-) diff --git a/homeassistant/components/heos/coordinator.py b/homeassistant/components/heos/coordinator.py index 0303d150794..93fe069d9be 100644 --- a/homeassistant/components/heos/coordinator.py +++ b/homeassistant/components/heos/coordinator.py @@ -159,13 +159,12 @@ class HeosCoordinator(DataUpdateCoordinator[None]): async def _async_on_reconnected(self) -> None: """Handle when reconnected so resources are updated and entities marked available.""" - await self._async_update_players() await self._async_update_sources() _LOGGER.warning("Successfully reconnected to HEOS host %s", self.host) self.async_update_listeners() async def _async_on_controller_event( - self, event: str, data: PlayerUpdateResult | None + self, event: str, data: PlayerUpdateResult | None = None ) -> None: """Handle a controller event, such as players or groups changed.""" if event == const.EVENT_PLAYERS_CHANGED: diff --git a/homeassistant/components/heos/manifest.json b/homeassistant/components/heos/manifest.json index 573deda2132..19feffd8ef1 100644 --- a/homeassistant/components/heos/manifest.json +++ b/homeassistant/components/heos/manifest.json @@ -8,7 +8,7 @@ "iot_class": "local_push", "loggers": ["pyheos"], "quality_scale": "platinum", - "requirements": ["pyheos==1.0.2"], + "requirements": ["pyheos==1.0.3"], "ssdp": [ { "st": "urn:schemas-denon-com:device:ACT-Denon:1" diff --git a/requirements_all.txt b/requirements_all.txt index 7530b75b0b3..0f8e41cddf2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1996,7 +1996,7 @@ pygti==0.9.4 pyhaversion==22.8.0 # homeassistant.components.heos -pyheos==1.0.2 +pyheos==1.0.3 # homeassistant.components.hive pyhive-integration==1.0.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a1c8c3ff509..8e0569e161b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1625,7 +1625,7 @@ pygti==0.9.4 pyhaversion==22.8.0 # homeassistant.components.heos -pyheos==1.0.2 +pyheos==1.0.3 # homeassistant.components.hive pyhive-integration==1.0.2 diff --git a/tests/components/heos/snapshots/test_diagnostics.ambr b/tests/components/heos/snapshots/test_diagnostics.ambr index 98ce8a7bcbf..58685f5cf8f 100644 --- a/tests/components/heos/snapshots/test_diagnostics.ambr +++ b/tests/components/heos/snapshots/test_diagnostics.ambr @@ -106,6 +106,7 @@ 'model': 'HEOS Drive HS2', 'name': 'Test Player', 'network': 'wired', + 'preferred_host': True, 'serial': '**REDACTED**', 'supported_version': True, 'version': '1.0.0', @@ -116,6 +117,7 @@ 'model': 'HEOS Drive HS2', 'name': 'Test Player', 'network': 'wired', + 'preferred_host': True, 'serial': '**REDACTED**', 'supported_version': True, 'version': '1.0.0', @@ -125,6 +127,7 @@ 'model': 'Speaker', 'name': 'Test Player 2', 'network': 'wifi', + 'preferred_host': False, 'serial': '**REDACTED**', 'supported_version': True, 'version': '1.0.0', @@ -137,6 +140,7 @@ 'model': 'HEOS Drive HS2', 'name': 'Test Player', 'network': 'wired', + 'preferred_host': True, 'serial': '**REDACTED**', 'supported_version': True, 'version': '1.0.0', diff --git a/tests/components/heos/test_init.py b/tests/components/heos/test_init.py index 87cc8dd7dde..b155abaf0e9 100644 --- a/tests/components/heos/test_init.py +++ b/tests/components/heos/test_init.py @@ -285,11 +285,11 @@ async def test_reconnected_new_entities_created( players = controller.players.copy() players[3] = player_factory(3, "Test Player 3", "HEOS Link") controller.mock_set_players(players) - controller.load_players.return_value = PlayerUpdateResult([3], [], {}) + update = PlayerUpdateResult([3], [], {}) # Simulate reconnection await controller.dispatcher.wait_send( - SignalType.HEOS_EVENT, SignalHeosEvent.CONNECTED + SignalType.CONTROLLER_EVENT, const.EVENT_PLAYERS_CHANGED, update ) await hass.async_block_till_done() diff --git a/tests/components/heos/test_media_player.py b/tests/components/heos/test_media_player.py index 3e755a29a0a..debfe31f427 100644 --- a/tests/components/heos/test_media_player.py +++ b/tests/components/heos/test_media_player.py @@ -158,7 +158,6 @@ async def test_updates_from_connection_event( state = hass.states.get("media_player.test_player") assert state is not None assert state.state == STATE_IDLE - assert controller.load_players.call_count == 1 # Disconnected controller.load_players.reset_mock() @@ -170,11 +169,8 @@ async def test_updates_from_connection_event( state = hass.states.get("media_player.test_player") assert state is not None assert state.state == STATE_UNAVAILABLE - assert controller.load_players.call_count == 0 - # Connected handles refresh failure - controller.load_players.reset_mock() - controller.load_players.side_effect = CommandFailedError("", "Failure", 1) + # Reconnect and state updates player.available = True await controller.dispatcher.wait_send( SignalType.HEOS_EVENT, SignalHeosEvent.CONNECTED @@ -183,38 +179,6 @@ async def test_updates_from_connection_event( state = hass.states.get("media_player.test_player") assert state is not None assert state.state == STATE_IDLE - assert controller.load_players.call_count == 1 - assert "Unable to refresh players" in caplog.text - - -async def test_updates_from_connection_event_new_player_ids( - hass: HomeAssistant, - entity_registry: er.EntityRegistry, - device_registry: dr.DeviceRegistry, - config_entry: MockConfigEntry, - controller: MockHeos, - change_data_mapped_ids: PlayerUpdateResult, -) -> None: - """Test player ids changed after reconnection updates ids.""" - config_entry.add_to_hass(hass) - assert await hass.config_entries.async_setup(config_entry.entry_id) - - # Assert current IDs - assert device_registry.async_get_device(identifiers={(DOMAIN, "1")}) - assert entity_registry.async_get_entity_id(MEDIA_PLAYER_DOMAIN, DOMAIN, "1") - - # Send event which will result in updated IDs. - controller.load_players.return_value = change_data_mapped_ids - await controller.dispatcher.wait_send( - SignalType.HEOS_EVENT, SignalHeosEvent.CONNECTED - ) - await hass.async_block_till_done() - - # Assert updated IDs and previous don't exist - assert not device_registry.async_get_device(identifiers={(DOMAIN, "1")}) - assert device_registry.async_get_device(identifiers={(DOMAIN, "101")}) - assert not entity_registry.async_get_entity_id(MEDIA_PLAYER_DOMAIN, DOMAIN, "1") - assert entity_registry.async_get_entity_id(MEDIA_PLAYER_DOMAIN, DOMAIN, "101") async def test_updates_from_sources_updated( From 3b115506b99335ce25b8e4bc3ca2eff2cb742c3c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 10 Mar 2025 22:19:21 -1000 Subject: [PATCH 2453/3148] Bump inkbird-ble to 0.9.0 (#140339) changelog: https://github.com/Bluetooth-Devices/inkbird-ble/compare/v0.8.0...v0.9.0 --- homeassistant/components/inkbird/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/inkbird/manifest.json b/homeassistant/components/inkbird/manifest.json index e2e9550dd7c..6b570b27fe2 100644 --- a/homeassistant/components/inkbird/manifest.json +++ b/homeassistant/components/inkbird/manifest.json @@ -36,5 +36,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/inkbird", "iot_class": "local_push", - "requirements": ["inkbird-ble==0.8.0"] + "requirements": ["inkbird-ble==0.9.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 0f8e41cddf2..e2d63ab0ccd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1226,7 +1226,7 @@ influxdb-client==1.24.0 influxdb==5.3.1 # homeassistant.components.inkbird -inkbird-ble==0.8.0 +inkbird-ble==0.9.0 # homeassistant.components.insteon insteon-frontend-home-assistant==0.5.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8e0569e161b..e4b4e91e1d9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1040,7 +1040,7 @@ influxdb-client==1.24.0 influxdb==5.3.1 # homeassistant.components.inkbird -inkbird-ble==0.8.0 +inkbird-ble==0.9.0 # homeassistant.components.insteon insteon-frontend-home-assistant==0.5.0 From 52408e67b2e98ff7708ff5667f8b944535ee8094 Mon Sep 17 00:00:00 2001 From: Tsvi Mostovicz Date: Tue, 11 Mar 2025 10:43:29 +0200 Subject: [PATCH 2454/3148] Update hdate dependency to 1.0.3 (#137247) * Update hdate version * Update code to reflect changes from hdate==1.0.0 * Fix some tests * Fix parasha tests * Fix holiday tests * Cleanup holidays changes * Zmanim objects should now access the local attribute * Fix binary sensors * Update test values on upcoming shabbat times * Update hdate to 1.0.1 * Adapt to changes from 1.0.0 -> 1.0.1 * Change shabbat candle lighthing test scenario to 40 minutes as expected in Jerusalem * Update to version 1.0.2 * Update keys based on updated nomenclature in library * Update HolidayDatabase .get_all_names in test * Make holiday type an ordered set * Fix freeze_time * Fix imports * Fix tests and minor change * Update hdate version 1.0.3, add migration method * Fix migration code * Add test for migration * The change is not backwards compatible if config is not restored --- .../components/jewish_calendar/__init__.py | 51 ++++++++- .../jewish_calendar/binary_sensor.py | 22 ++-- .../components/jewish_calendar/config_flow.py | 2 +- .../components/jewish_calendar/entity.py | 4 +- .../components/jewish_calendar/manifest.json | 2 +- .../components/jewish_calendar/sensor.py | 101 +++++++++--------- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/jewish_calendar/__init__.py | 4 +- .../jewish_calendar/test_binary_sensor.py | 11 +- tests/components/jewish_calendar/test_init.py | 43 ++++++++ .../components/jewish_calendar/test_sensor.py | 68 ++++++------ 12 files changed, 202 insertions(+), 110 deletions(-) diff --git a/homeassistant/components/jewish_calendar/__init__.py b/homeassistant/components/jewish_calendar/__init__.py index 823e9bd59be..9f7ec6ba976 100644 --- a/homeassistant/components/jewish_calendar/__init__.py +++ b/homeassistant/components/jewish_calendar/__init__.py @@ -3,6 +3,7 @@ from __future__ import annotations from functools import partial +import logging from hdate import Location @@ -14,7 +15,8 @@ from homeassistant.const import ( CONF_TIME_ZONE, Platform, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import entity_registry as er from .const import ( CONF_CANDLE_LIGHT_MINUTES, @@ -27,6 +29,7 @@ from .const import ( ) from .entity import JewishCalendarConfigEntry, JewishCalendarData +_LOGGER = logging.getLogger(__name__) PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR] @@ -80,3 +83,49 @@ async def async_unload_entry( ) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) + + +async def async_migrate_entry( + hass: HomeAssistant, config_entry: JewishCalendarConfigEntry +) -> bool: + """Migrate old entry.""" + + _LOGGER.debug("Migrating from version %s", config_entry.version) + + @callback + def update_unique_id( + entity_entry: er.RegistryEntry, + ) -> dict[str, str] | None: + """Update unique ID of entity entry.""" + key_translations = { + "first_light": "alot_hashachar", + "talit": "talit_and_tefillin", + "sunrise": "netz_hachama", + "gra_end_shma": "sof_zman_shema_gra", + "mga_end_shma": "sof_zman_shema_mga", + "gra_end_tfila": "sof_zman_tfilla_gra", + "mga_end_tfila": "sof_zman_tfilla_mga", + "midday": "chatzot_hayom", + "big_mincha": "mincha_gedola", + "small_mincha": "mincha_ketana", + "plag_mincha": "plag_hamincha", + "sunset": "shkia", + "first_stars": "tset_hakohavim_tsom", + "three_stars": "tset_hakohavim_shabbat", + } + new_keys = tuple(key_translations.values()) + if not entity_entry.unique_id.endswith(new_keys): + old_key = entity_entry.unique_id.split("-")[1] + new_unique_id = f"{config_entry.entry_id}-{key_translations[old_key]}" + return {"new_unique_id": new_unique_id} + return None + + if config_entry.version > 1: + # This means the user has downgraded from a future version + return False + + if config_entry.version == 1: + await er.async_migrate_entries(hass, config_entry.entry_id, update_unique_id) + hass.config_entries.async_update_entry(config_entry, version=2) + + return True diff --git a/homeassistant/components/jewish_calendar/binary_sensor.py b/homeassistant/components/jewish_calendar/binary_sensor.py index 5ff3171b7de..f33d79a01f5 100644 --- a/homeassistant/components/jewish_calendar/binary_sensor.py +++ b/homeassistant/components/jewish_calendar/binary_sensor.py @@ -5,9 +5,7 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass import datetime as dt -from datetime import datetime -import hdate from hdate.zmanim import Zmanim from homeassistant.components.binary_sensor import ( @@ -27,7 +25,7 @@ from .entity import JewishCalendarConfigEntry, JewishCalendarEntity class JewishCalendarBinarySensorMixIns(BinarySensorEntityDescription): """Binary Sensor description mixin class for Jewish Calendar.""" - is_on: Callable[[Zmanim], bool] = lambda _: False + is_on: Callable[[Zmanim, dt.datetime], bool] = lambda _, __: False @dataclass(frozen=True) @@ -42,18 +40,18 @@ BINARY_SENSORS: tuple[JewishCalendarBinarySensorEntityDescription, ...] = ( key="issur_melacha_in_effect", name="Issur Melacha in Effect", icon="mdi:power-plug-off", - is_on=lambda state: bool(state.issur_melacha_in_effect), + is_on=lambda state, now: bool(state.issur_melacha_in_effect(now)), ), JewishCalendarBinarySensorEntityDescription( key="erev_shabbat_hag", name="Erev Shabbat/Hag", - is_on=lambda state: bool(state.erev_shabbat_chag), + is_on=lambda state, now: bool(state.erev_shabbat_chag(now)), entity_registry_enabled_default=False, ), JewishCalendarBinarySensorEntityDescription( key="motzei_shabbat_hag", name="Motzei Shabbat/Hag", - is_on=lambda state: bool(state.motzei_shabbat_chag), + is_on=lambda state, now: bool(state.motzei_shabbat_chag(now)), entity_registry_enabled_default=False, ), ) @@ -84,16 +82,16 @@ class JewishCalendarBinarySensor(JewishCalendarEntity, BinarySensorEntity): def is_on(self) -> bool: """Return true if sensor is on.""" zmanim = self._get_zmanim() - return self.entity_description.is_on(zmanim) + return self.entity_description.is_on(zmanim, dt_util.now()) def _get_zmanim(self) -> Zmanim: """Return the Zmanim object for now().""" - return hdate.Zmanim( - date=dt_util.now(), + return Zmanim( + date=dt.date.today(), location=self._location, candle_lighting_offset=self._candle_lighting_offset, havdalah_offset=self._havdalah_offset, - hebrew=self._hebrew, + language=self._language, ) async def async_added_to_hass(self) -> None: @@ -109,7 +107,7 @@ class JewishCalendarBinarySensor(JewishCalendarEntity, BinarySensorEntity): return await super().async_will_remove_from_hass() @callback - def _update(self, now: datetime | None = None) -> None: + def _update(self, now: dt.datetime | None = None) -> None: """Update the state of the sensor.""" self._update_unsub = None self._schedule_update() @@ -119,7 +117,7 @@ class JewishCalendarBinarySensor(JewishCalendarEntity, BinarySensorEntity): """Schedule the next update of the sensor.""" now = dt_util.now() zmanim = self._get_zmanim() - update = zmanim.zmanim["sunrise"] + dt.timedelta(days=1) + update = zmanim.netz_hachama.local + dt.timedelta(days=1) candle_lighting = zmanim.candle_lighting if candle_lighting is not None and now < candle_lighting < update: update = candle_lighting diff --git a/homeassistant/components/jewish_calendar/config_flow.py b/homeassistant/components/jewish_calendar/config_flow.py index a2eadbf57bd..23bcb23435b 100644 --- a/homeassistant/components/jewish_calendar/config_flow.py +++ b/homeassistant/components/jewish_calendar/config_flow.py @@ -86,7 +86,7 @@ def _get_data_schema(hass: HomeAssistant) -> vol.Schema: class JewishCalendarConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Jewish calendar.""" - VERSION = 1 + VERSION = 2 @staticmethod @callback diff --git a/homeassistant/components/jewish_calendar/entity.py b/homeassistant/components/jewish_calendar/entity.py index 1d2a6e45c0a..2c031f0d160 100644 --- a/homeassistant/components/jewish_calendar/entity.py +++ b/homeassistant/components/jewish_calendar/entity.py @@ -3,6 +3,7 @@ from dataclasses import dataclass from hdate import Location +from hdate.translator import Language from homeassistant.config_entries import ConfigEntry from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo @@ -17,7 +18,7 @@ type JewishCalendarConfigEntry = ConfigEntry[JewishCalendarData] class JewishCalendarData: """Jewish Calendar runtime dataclass.""" - language: str + language: Language diaspora: bool location: Location candle_lighting_offset: int @@ -43,7 +44,6 @@ class JewishCalendarEntity(Entity): ) data = config_entry.runtime_data self._location = data.location - self._hebrew = data.language == "hebrew" self._language = data.language self._candle_lighting_offset = data.candle_lighting_offset self._havdalah_offset = data.havdalah_offset diff --git a/homeassistant/components/jewish_calendar/manifest.json b/homeassistant/components/jewish_calendar/manifest.json index aca45320002..877c4cf9a99 100644 --- a/homeassistant/components/jewish_calendar/manifest.json +++ b/homeassistant/components/jewish_calendar/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/jewish_calendar", "iot_class": "calculated", "loggers": ["hdate"], - "requirements": ["hdate==0.11.1"], + "requirements": ["hdate[astral]==1.0.3"], "single_config_entry": true } diff --git a/homeassistant/components/jewish_calendar/sensor.py b/homeassistant/components/jewish_calendar/sensor.py index eee1d966ae6..7cb281b3af4 100644 --- a/homeassistant/components/jewish_calendar/sensor.py +++ b/homeassistant/components/jewish_calendar/sensor.py @@ -2,12 +2,13 @@ from __future__ import annotations -from datetime import date as Date +import datetime as dt import logging -from typing import Any, cast +from typing import Any -from hdate import HDate, HebrewDate, htables -from hdate.zmanim import Zmanim +from hdate import HDateInfo, Zmanim +from hdate.holidays import HolidayDatabase +from hdate.parasha import Parasha from homeassistant.components.sensor import ( SensorDeviceClass, @@ -59,83 +60,83 @@ INFO_SENSORS: tuple[SensorEntityDescription, ...] = ( TIME_SENSORS: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( - key="first_light", + key="alot_hashachar", name="Alot Hashachar", # codespell:ignore alot icon="mdi:weather-sunset-up", entity_registry_enabled_default=False, ), SensorEntityDescription( - key="talit", + key="talit_and_tefillin", name="Talit and Tefillin", icon="mdi:calendar-clock", entity_registry_enabled_default=False, ), SensorEntityDescription( - key="sunrise", + key="netz_hachama", name="Hanetz Hachama", icon="mdi:calendar-clock", ), SensorEntityDescription( - key="gra_end_shma", + key="sof_zman_shema_gra", name='Latest time for Shma Gr"a', icon="mdi:calendar-clock", entity_registry_enabled_default=False, ), SensorEntityDescription( - key="mga_end_shma", + key="sof_zman_shema_mga", name='Latest time for Shma MG"A', icon="mdi:calendar-clock", entity_registry_enabled_default=False, ), SensorEntityDescription( - key="gra_end_tfila", + key="sof_zman_tfilla_gra", name='Latest time for Tefilla Gr"a', icon="mdi:calendar-clock", entity_registry_enabled_default=False, ), SensorEntityDescription( - key="mga_end_tfila", + key="sof_zman_tfilla_mga", name='Latest time for Tefilla MG"A', icon="mdi:calendar-clock", entity_registry_enabled_default=False, ), SensorEntityDescription( - key="midday", + key="chatzot_hayom", name="Chatzot Hayom", icon="mdi:calendar-clock", entity_registry_enabled_default=False, ), SensorEntityDescription( - key="big_mincha", + key="mincha_gedola", name="Mincha Gedola", icon="mdi:calendar-clock", entity_registry_enabled_default=False, ), SensorEntityDescription( - key="small_mincha", + key="mincha_ketana", name="Mincha Ketana", icon="mdi:calendar-clock", entity_registry_enabled_default=False, ), SensorEntityDescription( - key="plag_mincha", + key="plag_hamincha", name="Plag Hamincha", icon="mdi:weather-sunset-down", entity_registry_enabled_default=False, ), SensorEntityDescription( - key="sunset", + key="shkia", name="Shkia", icon="mdi:weather-sunset", ), SensorEntityDescription( - key="first_stars", + key="tset_hakohavim_tsom", name="T'set Hakochavim", icon="mdi:weather-night", entity_registry_enabled_default=False, ), SensorEntityDescription( - key="three_stars", + key="tset_hakohavim_shabbat", name="T'set Hakochavim, 3 stars", icon="mdi:weather-night", entity_registry_enabled_default=False, @@ -212,7 +213,9 @@ class JewishCalendarSensor(JewishCalendarEntity, SensorEntity): _LOGGER.debug("Now: %s Sunset: %s", now, sunset) - daytime_date = HDate(today, diaspora=self._diaspora, hebrew=self._hebrew) + daytime_date = HDateInfo( + today, diaspora=self._diaspora, language=self._language + ) # The Jewish day starts after darkness (called "tzais") and finishes at # sunset ("shkia"). The time in between is a gray area @@ -238,14 +241,14 @@ class JewishCalendarSensor(JewishCalendarEntity, SensorEntity): "New value for %s: %s", self.entity_description.key, self._attr_native_value ) - def make_zmanim(self, date: Date) -> Zmanim: + def make_zmanim(self, date: dt.date) -> Zmanim: """Create a Zmanim object.""" return Zmanim( date=date, location=self._location, candle_lighting_offset=self._candle_lighting_offset, havdalah_offset=self._havdalah_offset, - hebrew=self._hebrew, + language=self._language, ) @property @@ -254,43 +257,40 @@ class JewishCalendarSensor(JewishCalendarEntity, SensorEntity): return self._attrs def get_state( - self, daytime_date: HDate, after_shkia_date: HDate, after_tzais_date: HDate + self, + daytime_date: HDateInfo, + after_shkia_date: HDateInfo, + after_tzais_date: HDateInfo, ) -> Any | None: """For a given type of sensor, return the state.""" # Terminology note: by convention in py-libhdate library, "upcoming" # refers to "current" or "upcoming" dates. if self.entity_description.key == "date": - hdate = cast(HebrewDate, after_shkia_date.hdate) - month = htables.MONTHS[hdate.month.value - 1] + hdate = after_shkia_date.hdate + hdate.month.set_language(self._language) self._attrs = { - "hebrew_year": hdate.year, - "hebrew_month_name": month.hebrew if self._hebrew else month.english, - "hebrew_day": hdate.day, + "hebrew_year": str(hdate.year), + "hebrew_month_name": str(hdate.month), + "hebrew_day": str(hdate.day), } - return after_shkia_date.hebrew_date + return after_shkia_date.hdate if self.entity_description.key == "weekly_portion": - self._attr_options = [ - (p.hebrew if self._hebrew else p.english) for p in htables.PARASHAOT - ] + self._attr_options = list(Parasha) # Compute the weekly portion based on the upcoming shabbat. return after_tzais_date.upcoming_shabbat.parasha if self.entity_description.key == "holiday": - _id = _type = _type_id = "" - _holiday_type = after_shkia_date.holiday_type - if isinstance(_holiday_type, list): - _id = ", ".join(after_shkia_date.holiday_name) - _type = ", ".join([_htype.name for _htype in _holiday_type]) - _type_id = ", ".join([str(_htype.value) for _htype in _holiday_type]) - else: - _id = after_shkia_date.holiday_name - _type = _holiday_type.name - _type_id = _holiday_type.value - self._attrs = {"id": _id, "type": _type, "type_id": _type_id} - self._attr_options = htables.get_all_holidays(self._language) - - return after_shkia_date.holiday_description + _holidays = after_shkia_date.holidays + _id = ", ".join(holiday.name for holiday in _holidays) + _type = ", ".join( + dict.fromkeys(_holiday.type.name for _holiday in _holidays) + ) + self._attrs = {"id": _id, "type": _type} + self._attr_options = HolidayDatabase(self._diaspora).get_all_names( + self._language + ) + return ", ".join(str(holiday) for holiday in _holidays) if _holidays else "" if self.entity_description.key == "omer_count": - return after_shkia_date.omer_day + return after_shkia_date.omer.total_days if after_shkia_date.omer else 0 if self.entity_description.key == "daf_yomi": return daytime_date.daf_yomi @@ -303,7 +303,10 @@ class JewishCalendarTimeSensor(JewishCalendarSensor): _attr_device_class = SensorDeviceClass.TIMESTAMP def get_state( - self, daytime_date: HDate, after_shkia_date: HDate, after_tzais_date: HDate + self, + daytime_date: HDateInfo, + after_shkia_date: HDateInfo, + after_tzais_date: HDateInfo, ) -> Any | None: """For a given type of sensor, return the state.""" if self.entity_description.key == "upcoming_shabbat_candle_lighting": @@ -325,5 +328,5 @@ class JewishCalendarTimeSensor(JewishCalendarSensor): ) return times.havdalah - times = self.make_zmanim(dt_util.now()).zmanim - return times[self.entity_description.key] + times = self.make_zmanim(dt_util.now().date()) + return times.zmanim[self.entity_description.key].local diff --git a/requirements_all.txt b/requirements_all.txt index e2d63ab0ccd..8a2aa375b3e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1121,7 +1121,7 @@ hass-splunk==0.1.1 hassil==2.2.3 # homeassistant.components.jewish_calendar -hdate==0.11.1 +hdate[astral]==1.0.3 # homeassistant.components.heatmiser heatmiserV3==2.0.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e4b4e91e1d9..bfc9262316c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -959,7 +959,7 @@ hass-nabucasa==0.94.0 hassil==2.2.3 # homeassistant.components.jewish_calendar -hdate==0.11.1 +hdate[astral]==1.0.3 # homeassistant.components.here_travel_time here-routing==1.0.1 diff --git a/tests/components/jewish_calendar/__init__.py b/tests/components/jewish_calendar/__init__.py index ba0a2b4835e..dc66c1e0d7d 100644 --- a/tests/components/jewish_calendar/__init__.py +++ b/tests/components/jewish_calendar/__init__.py @@ -3,8 +3,6 @@ from collections import namedtuple from datetime import datetime -from freezegun import freeze_time as alter_time # noqa: F401 - from homeassistant.components import jewish_calendar from homeassistant.util import dt as dt_util @@ -49,7 +47,7 @@ def make_jerusalem_test_params(dtime, results, havdalah_offset=0): } return ( dtime, - jewish_calendar.DEFAULT_CANDLE_LIGHT, + 40, havdalah_offset, False, "Asia/Jerusalem", diff --git a/tests/components/jewish_calendar/test_binary_sensor.py b/tests/components/jewish_calendar/test_binary_sensor.py index 5cfaaedfc72..194e6fe9d01 100644 --- a/tests/components/jewish_calendar/test_binary_sensor.py +++ b/tests/components/jewish_calendar/test_binary_sensor.py @@ -3,6 +3,7 @@ from datetime import datetime as dt, timedelta import logging +from freezegun import freeze_time import pytest from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN @@ -18,7 +19,7 @@ from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util -from . import alter_time, make_jerusalem_test_params, make_nyc_test_params +from . import make_jerusalem_test_params, make_nyc_test_params from tests.common import MockConfigEntry, async_fire_time_changed @@ -191,7 +192,7 @@ async def test_issur_melacha_sensor( hass.config.latitude = latitude hass.config.longitude = longitude - with alter_time(test_time): + with freeze_time(test_time): entry = MockConfigEntry( title=DEFAULT_NAME, domain=DOMAIN, @@ -213,7 +214,7 @@ async def test_issur_melacha_sensor( == result["state"] ) - with alter_time(result["update"]): + with freeze_time(result["update"]): async_fire_time_changed(hass, result["update"]) await hass.async_block_till_done() assert ( @@ -264,7 +265,7 @@ async def test_issur_melacha_sensor_update( hass.config.latitude = latitude hass.config.longitude = longitude - with alter_time(test_time): + with freeze_time(test_time): entry = MockConfigEntry( title=DEFAULT_NAME, domain=DOMAIN, @@ -286,7 +287,7 @@ async def test_issur_melacha_sensor_update( ) test_time += timedelta(microseconds=1) - with alter_time(test_time): + with freeze_time(test_time): async_fire_time_changed(hass, test_time) await hass.async_block_till_done() assert ( diff --git a/tests/components/jewish_calendar/test_init.py b/tests/components/jewish_calendar/test_init.py index cb982afec0f..6a4f57513fa 100644 --- a/tests/components/jewish_calendar/test_init.py +++ b/tests/components/jewish_calendar/test_init.py @@ -1 +1,44 @@ """Tests for the Jewish Calendar component's init.""" + +import pytest + +from homeassistant.components.jewish_calendar.const import DOMAIN +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry + + +@pytest.mark.parametrize( + ("old_key", "new_key"), + [ + ("first_light", "alot_hashachar"), + ("sunset", "shkia"), + ("havdalah", "havdalah"), # Test no change + ], +) +async def test_migrate_unique_id( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + old_key: str, + new_key: str, +) -> None: + """Test unique id migration.""" + entry = MockConfigEntry(domain=DOMAIN, data={}) + entry.add_to_hass(hass) + + entity: er.RegistryEntry = entity_registry.async_get_or_create( + domain=SENSOR_DOMAIN, + platform=DOMAIN, + unique_id=f"{entry.entry_id}-{old_key}", + config_entry=entry, + ) + assert entity.unique_id.endswith(f"-{old_key}") + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + entity_migrated = entity_registry.async_get(entity.entity_id) + assert entity_migrated + assert entity_migrated.unique_id == f"{entry.entry_id}-{new_key}" diff --git a/tests/components/jewish_calendar/test_sensor.py b/tests/components/jewish_calendar/test_sensor.py index aac0f583b05..bc9e69a9717 100644 --- a/tests/components/jewish_calendar/test_sensor.py +++ b/tests/components/jewish_calendar/test_sensor.py @@ -2,10 +2,11 @@ from datetime import datetime as dt, timedelta -from hdate import htables +from freezegun import freeze_time +from hdate.holidays import HolidayDatabase +from hdate.parasha import Parasha import pytest -from homeassistant.components.binary_sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.components.jewish_calendar.const import ( CONF_CANDLE_LIGHT_MINUTES, CONF_DIASPORA, @@ -13,12 +14,13 @@ from homeassistant.components.jewish_calendar.const import ( DEFAULT_NAME, DOMAIN, ) +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.const import CONF_LANGUAGE, CONF_PLATFORM from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util -from . import alter_time, make_jerusalem_test_params, make_nyc_test_params +from . import make_jerusalem_test_params, make_nyc_test_params from tests.common import MockConfigEntry, async_fire_time_changed @@ -92,8 +94,7 @@ TEST_PARAMS = [ "icon": "mdi:calendar-star", "id": "rosh_hashana_i", "type": "YOM_TOV", - "type_id": 1, - "options": htables.get_all_holidays("english"), + "options": HolidayDatabase(False).get_all_names("english"), }, ), ( @@ -111,8 +112,7 @@ TEST_PARAMS = [ "icon": "mdi:calendar-star", "id": "chanukah, rosh_chodesh", "type": "MELACHA_PERMITTED_HOLIDAY, ROSH_CHODESH", - "type_id": "4, 10", - "options": htables.get_all_holidays("english"), + "options": HolidayDatabase(False).get_all_names("english"), }, ), ( @@ -128,7 +128,7 @@ TEST_PARAMS = [ "device_class": "enum", "friendly_name": "Jewish Calendar Parshat Hashavua", "icon": "mdi:book-open-variant", - "options": [p.hebrew for p in htables.PARASHAOT], + "options": list(Parasha), }, ), ( @@ -139,7 +139,7 @@ TEST_PARAMS = [ "hebrew", "t_set_hakochavim", True, - dt(2018, 9, 8, 19, 45), + dt(2018, 9, 8, 19, 47), None, ), ( @@ -150,7 +150,7 @@ TEST_PARAMS = [ "hebrew", "t_set_hakochavim", False, - dt(2018, 9, 8, 19, 19), + dt(2018, 9, 8, 19, 21), None, ), ( @@ -185,9 +185,9 @@ TEST_PARAMS = [ False, "ו' מרחשוון ה' תשע\"ט", { - "hebrew_year": 5779, + "hebrew_year": "5779", "hebrew_month_name": "מרחשוון", - "hebrew_day": 6, + "hebrew_day": "6", "icon": "mdi:star-david", "friendly_name": "Jewish Calendar Date", }, @@ -245,7 +245,7 @@ async def test_jewish_calendar_sensor( hass.config.latitude = latitude hass.config.longitude = longitude - with alter_time(test_time): + with freeze_time(test_time): entry = MockConfigEntry( title=DEFAULT_NAME, domain=DOMAIN, @@ -258,7 +258,7 @@ async def test_jewish_calendar_sensor( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - future = dt_util.utcnow() + timedelta(seconds=30) + future = test_time + timedelta(seconds=30) async_fire_time_changed(hass, future) await hass.async_block_till_done() @@ -424,9 +424,9 @@ SHABBAT_PARAMS = [ make_jerusalem_test_params( dt(2018, 9, 29, 21, 25), { - "english_upcoming_candle_lighting": dt(2018, 9, 30, 18, 7), + "english_upcoming_candle_lighting": dt(2018, 9, 30, 17, 45), "english_upcoming_havdalah": dt(2018, 10, 1, 19, 1), - "english_upcoming_shabbat_candle_lighting": dt(2018, 10, 5, 18, 1), + "english_upcoming_shabbat_candle_lighting": dt(2018, 10, 5, 17, 39), "english_upcoming_shabbat_havdalah": dt(2018, 10, 6, 18, 54), "english_parshat_hashavua": "Bereshit", "hebrew_parshat_hashavua": "בראשית", @@ -437,22 +437,22 @@ SHABBAT_PARAMS = [ make_jerusalem_test_params( dt(2018, 9, 30, 21, 25), { - "english_upcoming_candle_lighting": dt(2018, 9, 30, 18, 7), + "english_upcoming_candle_lighting": dt(2018, 9, 30, 17, 45), "english_upcoming_havdalah": dt(2018, 10, 1, 19, 1), - "english_upcoming_shabbat_candle_lighting": dt(2018, 10, 5, 18, 1), + "english_upcoming_shabbat_candle_lighting": dt(2018, 10, 5, 17, 39), "english_upcoming_shabbat_havdalah": dt(2018, 10, 6, 18, 54), "english_parshat_hashavua": "Bereshit", "hebrew_parshat_hashavua": "בראשית", - "english_holiday": "Shmini Atzeret", - "hebrew_holiday": "שמיני עצרת", + "english_holiday": "Shmini Atzeret, Simchat Torah", + "hebrew_holiday": "שמיני עצרת, שמחת תורה", }, ), make_jerusalem_test_params( dt(2018, 10, 1, 21, 25), { - "english_upcoming_candle_lighting": dt(2018, 10, 5, 18, 1), + "english_upcoming_candle_lighting": dt(2018, 10, 5, 17, 39), "english_upcoming_havdalah": dt(2018, 10, 6, 18, 54), - "english_upcoming_shabbat_candle_lighting": dt(2018, 10, 5, 18, 1), + "english_upcoming_shabbat_candle_lighting": dt(2018, 10, 5, 17, 39), "english_upcoming_shabbat_havdalah": dt(2018, 10, 6, 18, 54), "english_parshat_hashavua": "Bereshit", "hebrew_parshat_hashavua": "בראשית", @@ -487,9 +487,9 @@ SHABBAT_PARAMS = [ make_jerusalem_test_params( dt(2017, 9, 21, 8, 25), { - "english_upcoming_candle_lighting": dt(2017, 9, 20, 18, 20), + "english_upcoming_candle_lighting": dt(2017, 9, 20, 17, 58), "english_upcoming_havdalah": dt(2017, 9, 23, 19, 11), - "english_upcoming_shabbat_candle_lighting": dt(2017, 9, 22, 19, 12), + "english_upcoming_shabbat_candle_lighting": dt(2017, 9, 22, 17, 56), "english_upcoming_shabbat_havdalah": dt(2017, 9, 23, 19, 11), "english_parshat_hashavua": "Ha'Azinu", "hebrew_parshat_hashavua": "האזינו", @@ -500,9 +500,9 @@ SHABBAT_PARAMS = [ make_jerusalem_test_params( dt(2017, 9, 22, 8, 25), { - "english_upcoming_candle_lighting": dt(2017, 9, 20, 18, 20), + "english_upcoming_candle_lighting": dt(2017, 9, 20, 17, 58), "english_upcoming_havdalah": dt(2017, 9, 23, 19, 11), - "english_upcoming_shabbat_candle_lighting": dt(2017, 9, 22, 19, 12), + "english_upcoming_shabbat_candle_lighting": dt(2017, 9, 22, 17, 56), "english_upcoming_shabbat_havdalah": dt(2017, 9, 23, 19, 11), "english_parshat_hashavua": "Ha'Azinu", "hebrew_parshat_hashavua": "האזינו", @@ -513,9 +513,9 @@ SHABBAT_PARAMS = [ make_jerusalem_test_params( dt(2017, 9, 23, 8, 25), { - "english_upcoming_candle_lighting": dt(2017, 9, 20, 18, 20), + "english_upcoming_candle_lighting": dt(2017, 9, 20, 17, 58), "english_upcoming_havdalah": dt(2017, 9, 23, 19, 11), - "english_upcoming_shabbat_candle_lighting": dt(2017, 9, 22, 19, 12), + "english_upcoming_shabbat_candle_lighting": dt(2017, 9, 22, 17, 56), "english_upcoming_shabbat_havdalah": dt(2017, 9, 23, 19, 11), "english_parshat_hashavua": "Ha'Azinu", "hebrew_parshat_hashavua": "האזינו", @@ -587,7 +587,7 @@ async def test_shabbat_times_sensor( hass.config.latitude = latitude hass.config.longitude = longitude - with alter_time(test_time): + with freeze_time(test_time): entry = MockConfigEntry( title=DEFAULT_NAME, domain=DOMAIN, @@ -604,7 +604,7 @@ async def test_shabbat_times_sensor( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - future = dt_util.utcnow() + timedelta(seconds=30) + future = test_time + timedelta(seconds=30) async_fire_time_changed(hass, future) await hass.async_block_till_done() @@ -649,13 +649,13 @@ async def test_omer_sensor(hass: HomeAssistant, test_time, result) -> None: """Test Omer Count sensor output.""" test_time = test_time.replace(tzinfo=dt_util.get_time_zone(hass.config.time_zone)) - with alter_time(test_time): + with freeze_time(test_time): entry = MockConfigEntry(title=DEFAULT_NAME, domain=DOMAIN) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - future = dt_util.utcnow() + timedelta(seconds=30) + future = test_time + timedelta(seconds=30) async_fire_time_changed(hass, future) await hass.async_block_till_done() @@ -684,13 +684,13 @@ async def test_dafyomi_sensor(hass: HomeAssistant, test_time, result) -> None: """Test Daf Yomi sensor output.""" test_time = test_time.replace(tzinfo=dt_util.get_time_zone(hass.config.time_zone)) - with alter_time(test_time): + with freeze_time(test_time): entry = MockConfigEntry(title=DEFAULT_NAME, domain=DOMAIN) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - future = dt_util.utcnow() + timedelta(seconds=30) + future = test_time + timedelta(seconds=30) async_fire_time_changed(hass, future) await hass.async_block_till_done() From 4f25296c5024b2ef45a7b31cae04a2e82b96f5e7 Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Tue, 11 Mar 2025 10:12:23 +0100 Subject: [PATCH 2455/3148] Improve dependencies resolution (#138502) * Improve dependencies resolution * Improve tests * Better docstrings * Fix comment * Improve tests * Improve logging * Address feedback * Address feedback * Address feedback * Address feedback * Address feedback * Simplify error handling * small log change * Add comment * Address feedback * shorter comments * Add test --- homeassistant/bootstrap.py | 281 ++++++++++++++++++------------------ homeassistant/loader.py | 285 ++++++++++++++++++++++++++----------- homeassistant/setup.py | 2 +- tests/test_bootstrap.py | 15 +- tests/test_loader.py | 62 +++++--- 5 files changed, 398 insertions(+), 247 deletions(-) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index e301912806c..02a3b8c8fcc 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -93,6 +93,7 @@ from .helpers.dispatcher import async_dispatcher_send_internal from .helpers.storage import get_internal_store_manager from .helpers.system_info import async_get_system_info from .helpers.typing import ConfigType +from .loader import Integration from .setup import ( # _setup_started is marked as protected to make it clear # that it is not part of the public API and should not be used @@ -711,20 +712,25 @@ def _get_domains(hass: core.HomeAssistant, config: dict[str, Any]) -> set[str]: return domains -async def _async_resolve_domains_to_setup( +async def _async_resolve_domains_and_preload( hass: core.HomeAssistant, config: dict[str, Any] -) -> tuple[set[str], dict[str, loader.Integration]]: - """Resolve all dependencies and return list of domains to set up.""" +) -> tuple[dict[str, Integration], dict[str, Integration]]: + """Resolve all dependencies and return integrations to set up. + + The return value is a tuple of two dictionaries: + - The first dictionary contains integrations + specified by the configuration (including config entries). + - The second dictionary contains the same integrations as the first dictionary + together with all their dependencies. + """ domains_to_setup = _get_domains(hass, config) - needed_requirements: set[str] = set() platform_integrations = conf_util.extract_platform_integrations( config, BASE_PLATFORMS ) - # Ensure base platforms that have platform integrations are added to - # to `domains_to_setup so they can be setup first instead of - # discovering them when later when a config entry setup task - # notices its needed and there is already a long line to use - # the import executor. + # Ensure base platforms that have platform integrations are added to `domains`, + # so they can be setup first instead of discovering them later when a config + # entry setup task notices that it's needed and there is already a long line + # to use the import executor. # # For example if we have # sensor: @@ -740,111 +746,78 @@ async def _async_resolve_domains_to_setup( # so this will be less of a problem in the future. domains_to_setup.update(platform_integrations) - # Load manifests for base platforms and platform based integrations - # that are defined under base platforms right away since we do not require - # the manifest to list them as dependencies and we want to avoid the lock - # contention when multiple integrations try to load them at once - additional_manifests_to_load = { + # Additionally process base platforms since we do not require the manifest + # to list them as dependencies. + # We want to later avoid lock contention when multiple integrations try to load + # their manifests at once. + # Also process integrations that are defined under base platforms + # to speed things up. + additional_domains_to_process = { *BASE_PLATFORMS, *chain.from_iterable(platform_integrations.values()), } - translations_to_load = additional_manifests_to_load.copy() - # Resolve all dependencies so we know all integrations # that will have to be loaded and start right-away - integration_cache: dict[str, loader.Integration] = {} - to_resolve: set[str] = domains_to_setup - while to_resolve or additional_manifests_to_load: - old_to_resolve: set[str] = to_resolve - to_resolve = set() + integrations_or_excs = await loader.async_get_integrations( + hass, {*domains_to_setup, *additional_domains_to_process} + ) + # Eliminate those missing or with invalid manifest + integrations_to_process = { + domain: itg + for domain, itg in integrations_or_excs.items() + if isinstance(itg, Integration) + } + integrations_dependencies = await loader.resolve_integrations_dependencies( + hass, integrations_to_process.values() + ) + # Eliminate those without valid dependencies + integrations_to_process = { + domain: integrations_to_process[domain] for domain in integrations_dependencies + } - if additional_manifests_to_load: - to_get = {*old_to_resolve, *additional_manifests_to_load} - additional_manifests_to_load.clear() - else: - to_get = old_to_resolve + integrations_to_setup = { + domain: itg + for domain, itg in integrations_to_process.items() + if domain in domains_to_setup + } + all_integrations_to_setup = integrations_to_setup.copy() + all_integrations_to_setup.update( + (dep, loader.async_get_loaded_integration(hass, dep)) + for domain in integrations_to_setup + for dep in integrations_dependencies[domain].difference( + all_integrations_to_setup + ) + ) - manifest_deps: set[str] = set() - resolve_dependencies_tasks: list[asyncio.Task[bool]] = [] - integrations_to_process: list[loader.Integration] = [] - - for domain, itg in (await loader.async_get_integrations(hass, to_get)).items(): - if not isinstance(itg, loader.Integration): - continue - integration_cache[domain] = itg - needed_requirements.update(itg.requirements) - - # Make sure manifests for dependencies are loaded in the next - # loop to try to group as many as manifest loads in a single - # call to avoid the creating one-off executor jobs later in - # the setup process - additional_manifests_to_load.update( - dep - for dep in chain(itg.dependencies, itg.after_dependencies) - if dep not in integration_cache - ) - - if domain not in old_to_resolve: - continue - - integrations_to_process.append(itg) - manifest_deps.update(itg.dependencies) - manifest_deps.update(itg.after_dependencies) - if not itg.all_dependencies_resolved: - resolve_dependencies_tasks.append( - create_eager_task( - itg.resolve_dependencies(), - name=f"resolve dependencies {domain}", - loop=hass.loop, - ) - ) - - if unseen_deps := manifest_deps - integration_cache.keys(): - # If there are dependencies, try to preload all - # the integrations manifest at once and add them - # to the list of requirements we need to install - # so we can try to check if they are already installed - # in a single call below which avoids each integration - # having to wait for the lock to do it individually - deps = await loader.async_get_integrations(hass, unseen_deps) - for dependant_domain, dependant_itg in deps.items(): - if isinstance(dependant_itg, loader.Integration): - integration_cache[dependant_domain] = dependant_itg - needed_requirements.update(dependant_itg.requirements) - - if resolve_dependencies_tasks: - await asyncio.gather(*resolve_dependencies_tasks) - - for itg in integrations_to_process: - try: - all_deps = itg.all_dependencies - except RuntimeError: - # Integration.all_dependencies raises RuntimeError if - # dependencies could not be resolved - continue - for dep in all_deps: - if dep in domains_to_setup: - continue - domains_to_setup.add(dep) - to_resolve.add(dep) - - _LOGGER.info("Domains to be set up: %s", domains_to_setup) + # Gather requirements for all integrations, + # their dependencies and after dependencies. + # To gather all the requirements we must ignore exceptions here. + # The exceptions will be detected and handled later in the bootstrap process. + integrations_after_dependencies = ( + await loader.resolve_integrations_after_dependencies( + hass, integrations_to_process.values(), ignore_exceptions=True + ) + ) + integrations_requirements = { + domain: itg.requirements for domain, itg in integrations_to_process.items() + } + integrations_requirements.update( + (dep, loader.async_get_loaded_integration(hass, dep).requirements) + for deps in integrations_after_dependencies.values() + for dep in deps.difference(integrations_requirements) + ) + all_requirements = set(chain.from_iterable(integrations_requirements.values())) # Optimistically check if requirements are already installed # ahead of setting up the integrations so we can prime the cache - # We do not wait for this since its an optimization only + # We do not wait for this since it's an optimization only hass.async_create_background_task( - requirements.async_load_installed_versions(hass, needed_requirements), + requirements.async_load_installed_versions(hass, all_requirements), "check installed requirements", eager_start=True, ) - # - # Only add the domains_to_setup after we finish resolving - # as new domains are likely to added in the process - # - translations_to_load.update(domains_to_setup) # Start loading translations for all integrations we are going to set up # in the background so they are ready when we need them. This avoids a # lot of waiting for the translation load lock and a thundering herd of @@ -855,6 +828,7 @@ async def _async_resolve_domains_to_setup( # hold the translation load lock and if anything is fast enough to # wait for the translation load lock, loading will be done by the # time it gets to it. + translations_to_load = {*all_integrations_to_setup, *additional_domains_to_process} hass.async_create_background_task( translation.async_load_integrations(hass, translations_to_load), "load translations", @@ -866,13 +840,13 @@ async def _async_resolve_domains_to_setup( # in the setup process. hass.async_create_background_task( get_internal_store_manager(hass).async_preload( - [*PRELOAD_STORAGE, *domains_to_setup] + [*PRELOAD_STORAGE, *all_integrations_to_setup] ), "preload storage", eager_start=True, ) - return domains_to_setup, integration_cache + return integrations_to_setup, all_integrations_to_setup async def _async_set_up_integrations( @@ -882,69 +856,90 @@ async def _async_set_up_integrations( watcher = _WatchPendingSetups(hass, _setup_started(hass)) watcher.async_start() - domains_to_setup, integration_cache = await _async_resolve_domains_to_setup( + integrations, all_integrations = await _async_resolve_domains_and_preload( hass, config ) - stage_2_domains = domains_to_setup.copy() + all_domains = set(all_integrations) + domains = set(integrations) + + _LOGGER.info( + "Domains to be set up: %s | %s", + domains, + all_domains - domains, + ) # Initialize recorder - if "recorder" in domains_to_setup: + if "recorder" in all_domains: recorder.async_initialize_recorder(hass) # Initialize backup - if "backup" in domains_to_setup: + if "backup" in all_domains: backup.async_initialize_backup(hass) - stage_0_and_1_domains: list[tuple[str, set[str], int | None]] = [ + stages: list[tuple[str, set[str], int | None]] = [ *( - (name, domain_group & domains_to_setup, timeout) + (name, domain_group, timeout) for name, domain_group, timeout in STAGE_0_INTEGRATIONS ), - ("stage 1", STAGE_1_INTEGRATIONS & domains_to_setup, STAGE_1_TIMEOUT), + ("1", STAGE_1_INTEGRATIONS, STAGE_1_TIMEOUT), + ("2", domains, STAGE_2_TIMEOUT), ] - _LOGGER.info("Setting up stage 0 and 1") - for name, domain_group, timeout in stage_0_and_1_domains: - if not domain_group: + _LOGGER.info("Setting up stage 0") + for name, domain_group, timeout in stages: + stage_domains_unfiltered = domain_group & all_domains + if not stage_domains_unfiltered: + _LOGGER.info("Nothing to set up in stage %s: %s", name, domain_group) continue - _LOGGER.info("Setting up %s: %s", name, domain_group) - to_be_loaded = domain_group.copy() - to_be_loaded.update( + stage_domains = stage_domains_unfiltered - hass.config.components + if not stage_domains: + _LOGGER.info("Already set up stage %s: %s", name, stage_domains_unfiltered) + continue + + stage_dep_domains_unfiltered = { dep - for domain in domain_group - if (integration := integration_cache.get(domain)) is not None - for dep in integration.all_dependencies + for domain in stage_domains + for dep in all_integrations[domain].all_dependencies + if dep not in stage_domains + } + stage_dep_domains = stage_dep_domains_unfiltered - hass.config.components + + stage_all_domains = stage_domains | stage_dep_domains + stage_all_integrations = { + domain: all_integrations[domain] for domain in stage_all_domains + } + # Detect all cycles + stage_integrations_after_dependencies = ( + await loader.resolve_integrations_after_dependencies( + hass, stage_all_integrations.values(), stage_all_domains + ) ) - async_set_domains_to_be_loaded(hass, to_be_loaded) - stage_2_domains -= to_be_loaded + stage_all_domains = set(stage_integrations_after_dependencies) + stage_domains &= stage_all_domains + stage_dep_domains &= stage_all_domains + + _LOGGER.info( + "Setting up stage %s: %s | %s\nDependencies: %s | %s", + name, + stage_domains, + stage_domains_unfiltered - stage_domains, + stage_dep_domains, + stage_dep_domains_unfiltered - stage_dep_domains, + ) + + async_set_domains_to_be_loaded(hass, stage_all_domains) if timeout is None: - await _async_setup_multi_components(hass, domain_group, config) - else: - try: - async with hass.timeout.async_timeout(timeout, cool_down=COOLDOWN_TIME): - await _async_setup_multi_components(hass, domain_group, config) - except TimeoutError: - _LOGGER.warning( - "Setup timed out for %s waiting on %s - moving forward", - name, - hass._active_tasks, # noqa: SLF001 - ) - - # Add after dependencies when setting up stage 2 domains - async_set_domains_to_be_loaded(hass, stage_2_domains) - - if stage_2_domains: - _LOGGER.info("Setting up stage 2: %s", stage_2_domains) + await _async_setup_multi_components(hass, stage_all_domains, config) + continue try: - async with hass.timeout.async_timeout( - STAGE_2_TIMEOUT, cool_down=COOLDOWN_TIME - ): - await _async_setup_multi_components(hass, stage_2_domains, config) + async with hass.timeout.async_timeout(timeout, cool_down=COOLDOWN_TIME): + await _async_setup_multi_components(hass, stage_all_domains, config) except TimeoutError: _LOGGER.warning( - "Setup timed out for stage 2 waiting on %s - moving forward", + "Setup timed out for stage %s waiting on %s - moving forward", + name, hass._active_tasks, # noqa: SLF001 ) @@ -1046,8 +1041,6 @@ async def _async_setup_multi_components( config: dict[str, Any], ) -> None: """Set up multiple domains. Log on failure.""" - # Avoid creating tasks for domains that were setup in a previous stage - domains_not_yet_setup = domains - hass.config.components # Create setup tasks for base platforms first since everything will have # to wait to be imported, and the sooner we can get the base platforms # loaded the sooner we can start loading the rest of the integrations. @@ -1057,9 +1050,7 @@ async def _async_setup_multi_components( f"setup component {domain}", eager_start=True, ) - for domain in sorted( - domains_not_yet_setup, key=SETUP_ORDER_SORT_KEY, reverse=True - ) + for domain in sorted(domains, key=SETUP_ORDER_SORT_KEY, reverse=True) } results = await asyncio.gather(*futures.values(), return_exceptions=True) for idx, domain in enumerate(futures): diff --git a/homeassistant/loader.py b/homeassistant/loader.py index 3bc33f8374c..20763dc7b30 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -40,6 +40,8 @@ from .generated.ssdp import SSDP from .generated.usb import USB from .generated.zeroconf import HOMEKIT, ZEROCONF from .helpers.json import json_bytes, json_fragment +from .helpers.typing import UNDEFINED, UndefinedType +from .util.async_ import create_eager_task from .util.hass_dict import HassKey from .util.json import JSON_DECODE_EXCEPTIONS, json_loads @@ -758,10 +760,8 @@ class Integration: manifest["overwrites_built_in"] = self.overwrites_built_in if self.dependencies: - self._all_dependencies_resolved: bool | None = None - self._all_dependencies: set[str] | None = None + self._all_dependencies: set[str] | Exception | None = None else: - self._all_dependencies_resolved = True self._all_dependencies = set() self._platforms_to_preload = hass.data[DATA_PRELOAD_PLATFORMS] @@ -933,47 +933,25 @@ class Integration: """Return all dependencies including sub-dependencies.""" if self._all_dependencies is None: raise RuntimeError("Dependencies not resolved!") + if isinstance(self._all_dependencies, Exception): + raise self._all_dependencies return self._all_dependencies @property def all_dependencies_resolved(self) -> bool: """Return if all dependencies have been resolved.""" - return self._all_dependencies_resolved is not None + return self._all_dependencies is not None - async def resolve_dependencies(self) -> bool: + async def resolve_dependencies(self) -> set[str] | None: """Resolve all dependencies.""" - if self._all_dependencies_resolved is not None: - return self._all_dependencies_resolved + if self._all_dependencies is not None: + if isinstance(self._all_dependencies, Exception): + return None + return self._all_dependencies - self._all_dependencies_resolved = False - try: - dependencies = await _async_component_dependencies(self.hass, self) - except IntegrationNotFound as err: - _LOGGER.error( - ( - "Unable to resolve dependencies for %s: unable to resolve" - " (sub)dependency %s" - ), - self.domain, - err.domain, - ) - except CircularDependency as err: - _LOGGER.error( - ( - "Unable to resolve dependencies for %s: it contains a circular" - " dependency: %s -> %s" - ), - self.domain, - err.from_domain, - err.to_domain, - ) - else: - dependencies.discard(self.domain) - self._all_dependencies = dependencies - self._all_dependencies_resolved = True - - return self._all_dependencies_resolved + result = await resolve_integrations_dependencies(self.hass, (self,)) + return result.get(self.domain) async def async_get_component(self) -> ComponentProtocol: """Return the component. @@ -1441,6 +1419,189 @@ async def async_get_integrations( return results +class _ResolveDependenciesCacheProtocol(Protocol): + def get(self, itg: Integration) -> set[str] | Exception | None: ... + + def __setitem__( + self, itg: Integration, all_dependencies: set[str] | Exception + ) -> None: ... + + +class _ResolveDependenciesCache(_ResolveDependenciesCacheProtocol): + """Cache for resolve_integrations_dependencies.""" + + def get(self, itg: Integration) -> set[str] | Exception | None: + return itg._all_dependencies # noqa: SLF001 + + def __setitem__( + self, itg: Integration, all_dependencies: set[str] | Exception + ) -> None: + itg._all_dependencies = all_dependencies # noqa: SLF001 + + +async def resolve_integrations_dependencies( + hass: HomeAssistant, integrations: Iterable[Integration] +) -> dict[str, set[str]]: + """Resolve all dependencies for integrations. + + Detects circular dependencies and missing integrations. + """ + resolved = _ResolveDependenciesCache() + + async def _resolve_deps_catch_exceptions(itg: Integration) -> set[str] | None: + try: + return await _do_resolve_dependencies(itg, cache=resolved) + except Exception as exc: # noqa: BLE001 + _LOGGER.error("Unable to resolve dependencies for %s: %s", itg.domain, exc) + return None + + resolve_dependencies_tasks = { + itg.domain: create_eager_task( + _resolve_deps_catch_exceptions(itg), + name=f"resolve dependencies {itg.domain}", + loop=hass.loop, + ) + for itg in integrations + } + + result = await asyncio.gather(*resolve_dependencies_tasks.values()) + + return { + domain: deps + for domain, deps in zip(resolve_dependencies_tasks, result, strict=True) + if deps is not None + } + + +async def resolve_integrations_after_dependencies( + hass: HomeAssistant, + integrations: Iterable[Integration], + possible_after_dependencies: set[str] | None = None, + *, + ignore_exceptions: bool = False, +) -> dict[str, set[str]]: + """Resolve all dependencies, including after_dependencies, for integrations. + + Detects circular dependencies and missing integrations. + """ + resolved: dict[Integration, set[str] | Exception] = {} + + async def _resolve_deps_catch_exceptions(itg: Integration) -> set[str] | None: + try: + return await _do_resolve_dependencies( + itg, + cache=resolved, + possible_after_dependencies=possible_after_dependencies, + ignore_exceptions=ignore_exceptions, + ) + except Exception as exc: # noqa: BLE001 + _LOGGER.error( + "Unable to resolve (after) dependencies for %s: %s", itg.domain, exc + ) + return None + + resolve_dependencies_tasks = { + itg.domain: create_eager_task( + _resolve_deps_catch_exceptions(itg), + name=f"resolve after dependencies {itg.domain}", + loop=hass.loop, + ) + for itg in integrations + } + + result = await asyncio.gather(*resolve_dependencies_tasks.values()) + + return { + domain: deps + for domain, deps in zip(resolve_dependencies_tasks, result, strict=True) + if deps is not None + } + + +async def _do_resolve_dependencies( + itg: Integration, + *, + cache: _ResolveDependenciesCacheProtocol, + possible_after_dependencies: set[str] | None | UndefinedType = UNDEFINED, + ignore_exceptions: bool = False, +) -> set[str]: + """Recursively resolve all dependencies. + + Uses `cache` to cache the results. + + If `possible_after_dependencies` is not UNDEFINED, + listed after dependencies are also considered. + If `possible_after_dependencies` is None, + all the possible after dependencies are considered. + + If `ignore_exceptions` is True, exceptions are caught and ignored + and the normal resolution algorithm continues. + Otherwise, exceptions are raised. + """ + resolved = cache + resolving: set[str] = set() + + async def do_resolve_dependencies_impl(itg: Integration) -> set[str]: + domain = itg.domain + + # If it's already resolved, no point doing it again. + if (result := resolved.get(itg)) is not None: + if isinstance(result, Exception): + raise result + return result + + # If we are already resolving it, we have a circular dependency. + if domain in resolving: + if ignore_exceptions: + resolved[itg] = set() + return set() + exc = CircularDependency([domain]) + resolved[itg] = exc + raise exc + + resolving.add(domain) + + dependencies_domains = set(itg.dependencies) + if possible_after_dependencies is not UNDEFINED: + if possible_after_dependencies is None: + after_dependencies: Iterable[str] = itg.after_dependencies + else: + after_dependencies = ( + set(itg.after_dependencies) & possible_after_dependencies + ) + dependencies_domains.update(after_dependencies) + dependencies = await async_get_integrations(itg.hass, dependencies_domains) + + all_dependencies: set[str] = set() + for dep_domain, dep_integration in dependencies.items(): + if isinstance(dep_integration, Exception): + if ignore_exceptions: + continue + resolved[itg] = dep_integration + raise dep_integration + + all_dependencies.add(dep_domain) + + try: + dep_dependencies = await do_resolve_dependencies_impl(dep_integration) + except CircularDependency as exc: + exc.extend_cycle(domain) + resolved[itg] = exc + raise + except Exception as exc: + resolved[itg] = exc + raise + + all_dependencies.update(dep_dependencies) + + resolving.remove(domain) + + resolved[itg] = all_dependencies + return all_dependencies + + return await do_resolve_dependencies_impl(itg) + + class LoaderError(Exception): """Loader base error.""" @@ -1466,11 +1627,13 @@ class IntegrationNotLoaded(LoaderError): class CircularDependency(LoaderError): """Raised when a circular dependency is found when resolving components.""" - def __init__(self, from_domain: str | set[str], to_domain: str) -> None: + def __init__(self, domain_cycle: list[str]) -> None: """Initialize circular dependency error.""" - super().__init__(f"Circular dependency detected: {from_domain} -> {to_domain}.") - self.from_domain = from_domain - self.to_domain = to_domain + super().__init__("Circular dependency detected", domain_cycle) + + def extend_cycle(self, domain: str) -> None: + """Extend the cycle with the domain.""" + self.args[1].insert(0, domain) def _load_file( @@ -1624,50 +1787,6 @@ def bind_hass[_CallableT: Callable[..., Any]](func: _CallableT) -> _CallableT: return func -async def _async_component_dependencies( - hass: HomeAssistant, - integration: Integration, -) -> set[str]: - """Get component dependencies.""" - loading: set[str] = set() - loaded: set[str] = set() - - async def component_dependencies_impl(integration: Integration) -> None: - """Recursively get component dependencies.""" - domain = integration.domain - if not (dependencies := integration.dependencies): - loaded.add(domain) - return - - loading.add(domain) - dep_integrations = await async_get_integrations(hass, dependencies) - for dependency_domain, dep_integration in dep_integrations.items(): - if isinstance(dep_integration, Exception): - raise dep_integration - - # If we are already loading it, we have a circular dependency. - # We have to check it here to make sure that every integration that - # depends on us, does not appear in our own after_dependencies. - if conflict := loading.intersection(dep_integration.after_dependencies): - raise CircularDependency(conflict, dependency_domain) - - # If we have already loaded it, no point doing it again. - if dependency_domain in loaded: - continue - - # If we are already loading it, we have a circular dependency. - if dependency_domain in loading: - raise CircularDependency(dependency_domain, domain) - - await component_dependencies_impl(dep_integration) - loading.remove(domain) - loaded.add(domain) - - await component_dependencies_impl(integration) - - return loaded - - def _async_mount_config_dir(hass: HomeAssistant) -> None: """Mount config dir in order to load custom_component. diff --git a/homeassistant/setup.py b/homeassistant/setup.py index dc4d0988b91..9572136559a 100644 --- a/homeassistant/setup.py +++ b/homeassistant/setup.py @@ -323,7 +323,7 @@ async def _async_setup_component( translation.async_load_integrations(hass, integration_set), loop=hass.loop ) # Validate all dependencies exist and there are no circular dependencies - if not await integration.resolve_dependencies(): + if await integration.resolve_dependencies() is None: return False # Process requirements as soon as possible, so we can import the component diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index e89d038f8ce..050963316dc 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -572,7 +572,7 @@ async def test_setup_after_deps_not_present(hass: HomeAssistant) -> None: MockModule( domain="second_dep", async_setup=gen_domain_setup("second_dep"), - partial_manifest={"after_dependencies": ["first_dep"]}, + partial_manifest={"after_dependencies": ["first_dep", "root"]}, ), ) @@ -1169,6 +1169,7 @@ async def test_bootstrap_is_cancellation_safe( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: """Test cancellation during async_setup_component does not cancel bootstrap.""" + mock_integration(hass, MockModule(domain="cancel_integration")) with patch.object( bootstrap, "async_setup_component", side_effect=asyncio.CancelledError ): @@ -1185,6 +1186,18 @@ async def test_bootstrap_empty_integrations(hass: HomeAssistant) -> None: await hass.async_block_till_done() +@pytest.mark.parametrize("load_registries", [False]) +async def test_bootstrap_log_already_setup_stage( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test logging when all integrations in a stage were already setup.""" + with patch.object(bootstrap, "STAGE_1_INTEGRATIONS", {"frontend"}): + await bootstrap._async_set_up_integrations(hass, {}) + await hass.async_block_till_done() + + assert "Already set up stage 1: {'frontend'}" in caplog.text + + @pytest.fixture(name="mock_mqtt_config_flow") def mock_mqtt_config_flow_fixture() -> Generator[None]: """Mock MQTT config flow.""" diff --git a/tests/test_loader.py b/tests/test_loader.py index 548091a3503..0b83ddee3ea 100644 --- a/tests/test_loader.py +++ b/tests/test_loader.py @@ -27,33 +27,42 @@ async def test_circular_component_dependencies(hass: HomeAssistant) -> None: mock_integration(hass, MockModule("mod2", dependencies=["mod1"])) mock_integration(hass, MockModule("mod3", dependencies=["mod1"])) mod_4 = mock_integration(hass, MockModule("mod4", dependencies=["mod2", "mod3"])) + all_domains = {"mod1", "mod2", "mod3", "mod4"} - deps = await loader._async_component_dependencies(hass, mod_4) - assert deps == {"mod1", "mod2", "mod3", "mod4"} + deps = await loader._do_resolve_dependencies(mod_4, cache={}) + assert deps == {"mod1", "mod2", "mod3"} # Create a circular dependency mock_integration(hass, MockModule("mod1", dependencies=["mod4"])) with pytest.raises(loader.CircularDependency): - await loader._async_component_dependencies(hass, mod_4) + await loader._do_resolve_dependencies(mod_4, cache={}) # Create a different circular dependency mock_integration(hass, MockModule("mod1", dependencies=["mod3"])) with pytest.raises(loader.CircularDependency): - await loader._async_component_dependencies(hass, mod_4) + await loader._do_resolve_dependencies(mod_4, cache={}) # Create a circular after_dependency mock_integration( hass, MockModule("mod1", partial_manifest={"after_dependencies": ["mod4"]}) ) with pytest.raises(loader.CircularDependency): - await loader._async_component_dependencies(hass, mod_4) + await loader._do_resolve_dependencies( + mod_4, + cache={}, + possible_after_dependencies=all_domains, + ) # Create a different circular after_dependency mock_integration( hass, MockModule("mod1", partial_manifest={"after_dependencies": ["mod3"]}) ) with pytest.raises(loader.CircularDependency): - await loader._async_component_dependencies(hass, mod_4) + await loader._do_resolve_dependencies( + mod_4, + cache={}, + possible_after_dependencies=all_domains, + ) # Create a circular after_dependency without a hard dependency mock_integration( @@ -62,29 +71,48 @@ async def test_circular_component_dependencies(hass: HomeAssistant) -> None: mod_4 = mock_integration( hass, MockModule("mod4", partial_manifest={"after_dependencies": ["mod2"]}) ) - # this currently doesn't raise, but it should. Will be improved in a follow-up. - await loader._async_component_dependencies(hass, mod_4) + with pytest.raises(loader.CircularDependency): + await loader._do_resolve_dependencies( + mod_4, + cache={}, + possible_after_dependencies=all_domains, + ) + + result = await loader.resolve_integrations_after_dependencies(hass, (mod_4,)) + assert result == {} + result = await loader.resolve_integrations_after_dependencies( + hass, (mod_4,), ignore_exceptions=True + ) + assert result["mod4"] == {"mod4", "mod2", "mod1"} async def test_nonexistent_component_dependencies(hass: HomeAssistant) -> None: """Test if we can detect nonexistent dependencies of components.""" mod_1 = mock_integration(hass, MockModule("mod1", dependencies=["nonexistent"])) - with pytest.raises(loader.IntegrationNotFound): - await loader._async_component_dependencies(hass, mod_1) mod_2 = mock_integration(hass, MockModule("mod2", dependencies=["mod1"])) - assert not await mod_2.resolve_dependencies() + assert await mod_2.resolve_dependencies() is None assert mod_2.all_dependencies_resolved - with pytest.raises(RuntimeError): + with pytest.raises(loader.IntegrationNotFound): mod_2.all_dependencies # noqa: B018 - # this currently is not resolved, because intermediate results are not cached - # this will be improved in a follow-up - assert not mod_1.all_dependencies_resolved - assert not await mod_1.resolve_dependencies() - with pytest.raises(RuntimeError): + assert mod_1.all_dependencies_resolved + assert await mod_1.resolve_dependencies() is None + with pytest.raises(loader.IntegrationNotFound): mod_1.all_dependencies # noqa: B018 + result = await loader.resolve_integrations_dependencies(hass, (mod_2, mod_1)) + assert result == {} + + mod_1 = mock_integration( + hass, + MockModule("mod1", partial_manifest={"after_dependencies": ["non.existent"]}), + ) + mod_2 = mock_integration(hass, MockModule("mod2", dependencies=["mod1"])) + + result = await loader.resolve_integrations_after_dependencies(hass, (mod_2, mod_1)) + assert result == {} + def test_component_loader(hass: HomeAssistant) -> None: """Test loading components.""" From 3199b538eee6941c897e5f6053b16b189a822ab5 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 11 Mar 2025 11:12:22 +0100 Subject: [PATCH 2456/3148] Capitalize "HVAC" abbreviation in `fritzbox` integration (#140344) * Capitalize "HVAC" abbreviation in `fritzbox` integration * Update test_climate.py * Update test_climate.py (2) --- homeassistant/components/fritzbox/strings.json | 2 +- tests/components/fritzbox/test_climate.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/fritzbox/strings.json b/homeassistant/components/fritzbox/strings.json index c7c2439b566..e0df30875bc 100644 --- a/homeassistant/components/fritzbox/strings.json +++ b/homeassistant/components/fritzbox/strings.json @@ -89,7 +89,7 @@ "message": "Can't change preset while holiday or summer mode is active on the device." }, "change_hvac_while_active_mode": { - "message": "Can't change hvac mode while holiday or summer mode is active on the device." + "message": "Can't change HVAC mode while holiday or summer mode is active on the device." } } } diff --git a/tests/components/fritzbox/test_climate.py b/tests/components/fritzbox/test_climate.py index f170836fa9b..0784d7b6188 100644 --- a/tests/components/fritzbox/test_climate.py +++ b/tests/components/fritzbox/test_climate.py @@ -528,7 +528,7 @@ async def test_holidy_summer_mode( with pytest.raises( HomeAssistantError, - match="Can't change hvac mode while holiday or summer mode is active on the device", + match="Can't change HVAC mode while holiday or summer mode is active on the device", ): await hass.services.async_call( "climate", @@ -564,7 +564,7 @@ async def test_holidy_summer_mode( with pytest.raises( HomeAssistantError, - match="Can't change hvac mode while holiday or summer mode is active on the device", + match="Can't change HVAC mode while holiday or summer mode is active on the device", ): await hass.services.async_call( "climate", From 47a9f25ba675bdf336ecb1d22890a00e6b3e1fc1 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 11 Mar 2025 11:14:22 +0100 Subject: [PATCH 2457/3148] Improve name and description of `nexia.set_hvac_run_mode` action (#140348) - use proper capitalization of "HVAC" in action name - better explain that you can set the run mode ("permanent_hold" / "run_schedule") and / or the operation mode ("auto" / "cool" / "heat") of the HVAC system --- homeassistant/components/nexia/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/nexia/strings.json b/homeassistant/components/nexia/strings.json index 05d86d3a495..43da2cf05c7 100644 --- a/homeassistant/components/nexia/strings.json +++ b/homeassistant/components/nexia/strings.json @@ -86,8 +86,8 @@ } }, "set_hvac_run_mode": { - "name": "Set hvac run mode", - "description": "Sets the HVAC operation mode.", + "name": "Set HVAC run mode", + "description": "Sets the run and/or operation mode of the HVAC system.", "fields": { "run_mode": { "name": "Run mode", From d3a96ba688b4f7d21e2a3616531884a1e618d3f6 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 11 Mar 2025 11:18:35 +0100 Subject: [PATCH 2458/3148] Use trademark "Time-of-Use Price Plan" in `srp_energy` integration (#140350) --- homeassistant/components/srp_energy/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/srp_energy/strings.json b/homeassistant/components/srp_energy/strings.json index eca4f465435..5fa97b00b57 100644 --- a/homeassistant/components/srp_energy/strings.json +++ b/homeassistant/components/srp_energy/strings.json @@ -3,10 +3,10 @@ "step": { "user": { "data": { - "id": "Account Id", + "id": "Account ID", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]", - "is_tou": "Is Time of Use Plan" + "is_tou": "Is Time-of-Use Price Plan" } } }, From 98cf936ff54fe594aa4989b449b4c0066e73ae4e Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Tue, 11 Mar 2025 12:52:40 +0100 Subject: [PATCH 2459/3148] Improve config flow for GIOS (#139935) * Initial commit * Use TYPE_CHECKING * Update strings * Remove default value * Improve tests --- homeassistant/components/gios/config_flow.py | 61 ++++++++++++++------ homeassistant/components/gios/strings.json | 6 +- tests/components/gios/test_config_flow.py | 58 +++++++++++-------- 3 files changed, 79 insertions(+), 46 deletions(-) diff --git a/homeassistant/components/gios/config_flow.py b/homeassistant/components/gios/config_flow.py index ecd0baee6f9..9b242a8cc99 100644 --- a/homeassistant/components/gios/config_flow.py +++ b/homeassistant/components/gios/config_flow.py @@ -3,7 +3,7 @@ from __future__ import annotations import asyncio -from typing import Any +from typing import TYPE_CHECKING, Any from aiohttp.client_exceptions import ClientConnectorError from gios import ApiError, Gios, InvalidSensorsDataError, NoStationError @@ -12,6 +12,12 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_NAME from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.selector import ( + SelectOptionDict, + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, +) from .const import API_TIMEOUT, CONF_STATION_ID, DOMAIN @@ -27,40 +33,59 @@ class GiosFlowHandler(ConfigFlow, domain=DOMAIN): """Handle a flow initialized by the user.""" errors = {} + websession = async_get_clientsession(self.hass) + if user_input is not None: + station_id = user_input[CONF_STATION_ID] + try: - await self.async_set_unique_id( - str(user_input[CONF_STATION_ID]), raise_on_progress=False - ) + await self.async_set_unique_id(station_id, raise_on_progress=False) self._abort_if_unique_id_configured() - websession = async_get_clientsession(self.hass) - async with asyncio.timeout(API_TIMEOUT): - gios = await Gios.create(websession, user_input[CONF_STATION_ID]) + gios = await Gios.create(websession, int(station_id)) await gios.async_update() - assert gios.station_name is not None + # GIOS treats station ID as int + user_input[CONF_STATION_ID] = int(station_id) + + if TYPE_CHECKING: + assert gios.station_name is not None + return self.async_create_entry( title=gios.station_name, data=user_input, ) except (ApiError, ClientConnectorError, TimeoutError): errors["base"] = "cannot_connect" - except NoStationError: - errors[CONF_STATION_ID] = "wrong_station_id" except InvalidSensorsDataError: errors[CONF_STATION_ID] = "invalid_sensors_data" + try: + gios = await Gios.create(websession) + except (ApiError, ClientConnectorError, NoStationError): + return self.async_abort(reason="cannot_connect") + + options: list[SelectOptionDict] = [ + SelectOptionDict(value=str(station.id), label=station.name) + for station in gios.measurement_stations.values() + ] + + schema: vol.Schema = vol.Schema( + { + vol.Required(CONF_STATION_ID): SelectSelector( + SelectSelectorConfig( + options=options, + sort=True, + mode=SelectSelectorMode.DROPDOWN, + ), + ), + vol.Optional(CONF_NAME, default=self.hass.config.location_name): str, + } + ) + return self.async_show_form( step_id="user", - data_schema=vol.Schema( - { - vol.Required(CONF_STATION_ID): int, - vol.Optional( - CONF_NAME, default=self.hass.config.location_name - ): str, - } - ), + data_schema=schema, errors=errors, ) diff --git a/homeassistant/components/gios/strings.json b/homeassistant/components/gios/strings.json index fc82f1c843d..ff4c2a80b16 100644 --- a/homeassistant/components/gios/strings.json +++ b/homeassistant/components/gios/strings.json @@ -5,17 +5,17 @@ "title": "GIO\u015a (Polish Chief Inspectorate Of Environmental Protection)", "data": { "name": "[%key:common::config_flow::data::name%]", - "station_id": "ID of the measuring station" + "station_id": "Measuring station" } } }, "error": { - "wrong_station_id": "ID of the measuring station is not correct.", "invalid_sensors_data": "Invalid sensors' data for this measuring station.", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_location%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_location%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" } }, "system_health": { diff --git a/tests/components/gios/test_config_flow.py b/tests/components/gios/test_config_flow.py index d81758b0de0..3764c52a810 100644 --- a/tests/components/gios/test_config_flow.py +++ b/tests/components/gios/test_config_flow.py @@ -6,7 +6,8 @@ from unittest.mock import patch from gios import ApiError from homeassistant.components.gios import config_flow -from homeassistant.components.gios.const import CONF_STATION_ID +from homeassistant.components.gios.const import CONF_STATION_ID, DOMAIN +from homeassistant.config_entries import SOURCE_USER from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -17,36 +18,35 @@ from tests.common import load_fixture CONFIG = { CONF_NAME: "Foo", - CONF_STATION_ID: 123, + CONF_STATION_ID: "123", } async def test_show_form(hass: HomeAssistant) -> None: """Test that the form is served with no input.""" - flow = config_flow.GiosFlowHandler() - flow.hass = hass - - result = await flow.async_step_user(user_input=None) + with patch( + "homeassistant.components.gios.coordinator.Gios._get_stations", + return_value=STATIONS, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" -async def test_invalid_station_id(hass: HomeAssistant) -> None: - """Test that errors are shown when measuring station ID is invalid.""" +async def test_form_with_api_error(hass: HomeAssistant) -> None: + """Test the form is aborted because of API error.""" with patch( "homeassistant.components.gios.coordinator.Gios._get_stations", - return_value=STATIONS, + side_effect=ApiError("error"), ): - flow = config_flow.GiosFlowHandler() - flow.hass = hass - flow.context = {} - - result = await flow.async_step_user( - user_input={CONF_NAME: "Foo", CONF_STATION_ID: 0} + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} ) - assert result["errors"] == {CONF_STATION_ID: "wrong_station_id"} + assert result["type"] is FlowResultType.ABORT async def test_invalid_sensor_data(hass: HomeAssistant) -> None: @@ -76,17 +76,25 @@ async def test_invalid_sensor_data(hass: HomeAssistant) -> None: async def test_cannot_connect(hass: HomeAssistant) -> None: """Test that errors are shown when cannot connect to GIOS server.""" - with patch( - "homeassistant.components.gios.coordinator.Gios._async_get", - side_effect=ApiError("error"), + with ( + patch( + "homeassistant.components.gios.coordinator.Gios._get_stations", + return_value=STATIONS, + ), + patch( + "homeassistant.components.gios.coordinator.Gios._async_get", + side_effect=ApiError("error"), + ), ): - flow = config_flow.GiosFlowHandler() - flow.hass = hass - flow.context = {} + 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() - result = await flow.async_step_user(user_input=CONFIG) - - assert result["errors"] == {"base": "cannot_connect"} + assert result["errors"] == {"base": "cannot_connect"} async def test_create_entry(hass: HomeAssistant) -> None: From b160ce21fce41bdcb12786752ff8376b5cb8328f Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 11 Mar 2025 13:21:00 +0100 Subject: [PATCH 2460/3148] Migrate google_assistant tests to use unit system (#140357) --- .../components/google_assistant/test_trait.py | 55 +++++++++---------- 1 file changed, 25 insertions(+), 30 deletions(-) diff --git a/tests/components/google_assistant/test_trait.py b/tests/components/google_assistant/test_trait.py index dafe85d97b2..1fc4a0e3a0c 100644 --- a/tests/components/google_assistant/test_trait.py +++ b/tests/components/google_assistant/test_trait.py @@ -79,6 +79,11 @@ from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, St from homeassistant.core_config import async_process_ha_core_config from homeassistant.util import dt as dt_util from homeassistant.util.unit_conversion import TemperatureConverter +from homeassistant.util.unit_system import ( + METRIC_SYSTEM, + US_CUSTOMARY_SYSTEM, + UnitSystem, +) from . import BASIC_CONFIG, MockConfig @@ -1072,7 +1077,7 @@ async def test_temperature_setting_climate_onoff(hass: HomeAssistant) -> None: assert helpers.get_google_type(climate.DOMAIN, None) is not None assert trait.TemperatureSettingTrait.supported(climate.DOMAIN, 0, None, None) - hass.config.units.temperature_unit = UnitOfTemperature.FAHRENHEIT + hass.config.units = US_CUSTOMARY_SYSTEM trt = trait.TemperatureSettingTrait( hass, @@ -1123,8 +1128,6 @@ async def test_temperature_setting_climate_no_modes(hass: HomeAssistant) -> None assert helpers.get_google_type(climate.DOMAIN, None) is not None assert trait.TemperatureSettingTrait.supported(climate.DOMAIN, 0, None, None) - hass.config.units.temperature_unit = UnitOfTemperature.CELSIUS - trt = trait.TemperatureSettingTrait( hass, State( @@ -1153,7 +1156,7 @@ async def test_temperature_setting_climate_range(hass: HomeAssistant) -> None: assert helpers.get_google_type(climate.DOMAIN, None) is not None assert trait.TemperatureSettingTrait.supported(climate.DOMAIN, 0, None, None) - hass.config.units.temperature_unit = UnitOfTemperature.FAHRENHEIT + hass.config.units = US_CUSTOMARY_SYSTEM trt = trait.TemperatureSettingTrait( hass, @@ -1261,7 +1264,6 @@ async def test_temperature_setting_climate_range(hass: HomeAssistant) -> None: ATTR_ENTITY_ID: "climate.bla", climate.ATTR_TEMPERATURE: 75, } - hass.config.units.temperature_unit = UnitOfTemperature.CELSIUS async def test_temperature_setting_climate_setpoint(hass: HomeAssistant) -> None: @@ -1269,8 +1271,6 @@ async def test_temperature_setting_climate_setpoint(hass: HomeAssistant) -> None assert helpers.get_google_type(climate.DOMAIN, None) is not None assert trait.TemperatureSettingTrait.supported(climate.DOMAIN, 0, None, None) - hass.config.units.temperature_unit = UnitOfTemperature.CELSIUS - trt = trait.TemperatureSettingTrait( hass, State( @@ -1356,8 +1356,6 @@ async def test_temperature_setting_climate_setpoint_auto(hass: HomeAssistant) -> Setpoint in auto mode. """ - hass.config.units.temperature_unit = UnitOfTemperature.CELSIUS - trt = trait.TemperatureSettingTrait( hass, State( @@ -1407,8 +1405,6 @@ async def test_temperature_setting_climate_setpoint_auto(hass: HomeAssistant) -> async def test_temperature_control(hass: HomeAssistant) -> None: """Test TemperatureControl trait support for sensor domain.""" - hass.config.units.temperature_unit = UnitOfTemperature.CELSIUS - trt = trait.TemperatureControlTrait( hass, State("sensor.temp", 18), @@ -1431,13 +1427,13 @@ async def test_temperature_control(hass: HomeAssistant) -> None: @pytest.mark.parametrize( ("unit_in", "unit_out", "temp_in", "temp_out", "current_in", "current_out"), [ - (UnitOfTemperature.CELSIUS, "C", "120", 120, "130", 130), - (UnitOfTemperature.FAHRENHEIT, "F", "248", 120, "266", 130), + (METRIC_SYSTEM, "C", "120", 120, "130", 130), + (US_CUSTOMARY_SYSTEM, "F", "248", 120, "266", 130), ], ) async def test_temperature_control_water_heater( hass: HomeAssistant, - unit_in: UnitOfTemperature, + unit_in: UnitSystem, unit_out: str, temp_in: str, temp_out: float, @@ -1445,17 +1441,17 @@ async def test_temperature_control_water_heater( current_out: float, ) -> None: """Test TemperatureControl trait support for water heater domain.""" - hass.config.units.temperature_unit = unit_in + hass.config.units = unit_in min_temp = TemperatureConverter.convert( water_heater.DEFAULT_MIN_TEMP, UnitOfTemperature.CELSIUS, - unit_in, + unit_in.temperature_unit, ) max_temp = TemperatureConverter.convert( water_heater.DEFAULT_MAX_TEMP, UnitOfTemperature.CELSIUS, - unit_in, + unit_in.temperature_unit, ) trt = trait.TemperatureControlTrait( @@ -1489,30 +1485,30 @@ async def test_temperature_control_water_heater( @pytest.mark.parametrize( ("unit", "temp_init", "temp_in", "temp_out", "current_init"), [ - (UnitOfTemperature.CELSIUS, "180", 220, 220, "180"), - (UnitOfTemperature.FAHRENHEIT, "356", 220, 428, "356"), + (METRIC_SYSTEM, "180", 220, 220, "180"), + (US_CUSTOMARY_SYSTEM, "356", 220, 428, "356"), ], ) async def test_temperature_control_water_heater_set_temperature( hass: HomeAssistant, - unit: UnitOfTemperature, + unit: UnitSystem, temp_init: str, temp_in: float, temp_out: float, current_init: str, ) -> None: """Test TemperatureControl trait support for water heater domain - SetTemperature.""" - hass.config.units.temperature_unit = unit + hass.config.units = unit min_temp = TemperatureConverter.convert( 40, UnitOfTemperature.CELSIUS, - unit, + unit.temperature_unit, ) max_temp = TemperatureConverter.convert( 230, UnitOfTemperature.CELSIUS, - unit, + unit.temperature_unit, ) trt = trait.TemperatureControlTrait( @@ -3633,17 +3629,17 @@ async def test_temperature_control_sensor(hass: HomeAssistant) -> None: @pytest.mark.parametrize( ("unit_in", "unit_out", "state", "ambient"), [ - (UnitOfTemperature.FAHRENHEIT, "F", "70", 21.1), - (UnitOfTemperature.CELSIUS, "C", "21.1", 21.1), - (UnitOfTemperature.FAHRENHEIT, "F", "unavailable", None), - (UnitOfTemperature.FAHRENHEIT, "F", "unknown", None), + (US_CUSTOMARY_SYSTEM, "F", "70", 21.1), + (METRIC_SYSTEM, "C", "21.1", 21.1), + (US_CUSTOMARY_SYSTEM, "F", "unavailable", None), + (US_CUSTOMARY_SYSTEM, "F", "unknown", None), ], ) async def test_temperature_control_sensor_data( - hass: HomeAssistant, unit_in, unit_out, state, ambient + hass: HomeAssistant, unit_in: UnitSystem, unit_out, state, ambient ) -> None: """Test TemperatureControl trait support for temperature sensor.""" - hass.config.units.temperature_unit = unit_in + hass.config.units = unit_in trt = trait.TemperatureControlTrait( hass, @@ -3668,7 +3664,6 @@ async def test_temperature_control_sensor_data( } else: assert trt.query_attributes() == {} - hass.config.units.temperature_unit = UnitOfTemperature.CELSIUS async def test_humidity_setting_sensor(hass: HomeAssistant) -> None: From 289e94f270e7e5ae8c0ba5aec57799402b867ca1 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 11 Mar 2025 13:38:44 +0100 Subject: [PATCH 2461/3148] Migrate gree tests to use unit system (#140358) --- tests/components/gree/test_climate.py | 49 ++++++++++++--------------- 1 file changed, 22 insertions(+), 27 deletions(-) diff --git a/tests/components/gree/test_climate.py b/tests/components/gree/test_climate.py index d7c011a4c25..e6bfc43252f 100644 --- a/tests/components/gree/test_climate.py +++ b/tests/components/gree/test_climate.py @@ -67,6 +67,11 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import entity_registry as er +from homeassistant.util.unit_system import ( + METRIC_SYSTEM, + US_CUSTOMARY_SYSTEM, + UnitSystem, +) from .common import async_setup_gree, build_device_mock @@ -411,19 +416,19 @@ async def test_send_power_off_device_timeout( @pytest.mark.parametrize( ("units", "temperature"), - [(UnitOfTemperature.CELSIUS, 26), (UnitOfTemperature.FAHRENHEIT, 73)], + [(METRIC_SYSTEM, 26), (US_CUSTOMARY_SYSTEM, 73)], ) async def test_send_target_temperature( - hass: HomeAssistant, discovery, device, units, temperature + hass: HomeAssistant, discovery, device, units: UnitSystem, temperature ) -> None: """Test for sending target temperature command to the device.""" - hass.config.units.temperature_unit = units + hass.config.units = units device().power = True device().mode = HVAC_MODES_REVERSE.get(HVACMode.AUTO) fake_device = device() - if units == UnitOfTemperature.FAHRENHEIT: + if units.temperature_unit == UnitOfTemperature.FAHRENHEIT: fake_device.temperature_units = 1 await async_setup_gree(hass) @@ -435,7 +440,7 @@ async def test_send_target_temperature( ENTITY_ID, "off", { - ATTR_UNIT_OF_MEASUREMENT: units, + ATTR_UNIT_OF_MEASUREMENT: units.temperature_unit, }, ) @@ -451,10 +456,6 @@ async def test_send_target_temperature( assert state.attributes.get(ATTR_TEMPERATURE) == temperature assert state.state == HVAC_MODES.get(fake_device.mode) - # Reset config temperature_unit back to CELSIUS, required for - # additional tests outside this component. - hass.config.units.temperature_unit = UnitOfTemperature.CELSIUS - @pytest.mark.parametrize( ("temperature", "hvac_mode"), @@ -493,17 +494,17 @@ async def test_send_target_temperature_with_hvac_mode( @pytest.mark.parametrize( ("units", "temperature"), [ - (UnitOfTemperature.CELSIUS, 25), - (UnitOfTemperature.FAHRENHEIT, 73), - (UnitOfTemperature.FAHRENHEIT, 74), + (METRIC_SYSTEM, 25), + (US_CUSTOMARY_SYSTEM, 73), + (US_CUSTOMARY_SYSTEM, 74), ], ) async def test_send_target_temperature_device_timeout( - hass: HomeAssistant, discovery, device, units, temperature + hass: HomeAssistant, discovery, device, units: UnitSystem, temperature ) -> None: """Test for sending target temperature command to the device with a device timeout.""" - hass.config.units.temperature_unit = units - if units == UnitOfTemperature.FAHRENHEIT: + hass.config.units = units + if units.temperature_unit == UnitOfTemperature.FAHRENHEIT: device().temperature_units = 1 device().push_state_update.side_effect = DeviceTimeoutError @@ -520,24 +521,21 @@ async def test_send_target_temperature_device_timeout( assert state is not None assert state.attributes.get(ATTR_TEMPERATURE) == temperature - # Reset config temperature_unit back to CELSIUS, required for additional tests outside this component. - hass.config.units.temperature_unit = UnitOfTemperature.CELSIUS - @pytest.mark.parametrize( ("units", "temperature"), [ - (UnitOfTemperature.CELSIUS, 25), - (UnitOfTemperature.FAHRENHEIT, 73), - (UnitOfTemperature.FAHRENHEIT, 74), + (METRIC_SYSTEM, 25), + (US_CUSTOMARY_SYSTEM, 73), + (US_CUSTOMARY_SYSTEM, 74), ], ) async def test_update_target_temperature( - hass: HomeAssistant, discovery, device, units, temperature + hass: HomeAssistant, discovery, device, units: UnitSystem, temperature ) -> None: """Test for updating target temperature from the device.""" - hass.config.units.temperature_unit = units - if units == UnitOfTemperature.FAHRENHEIT: + hass.config.units = units + if units.temperature_unit == UnitOfTemperature.FAHRENHEIT: device().temperature_units = 1 device().target_temperature = temperature @@ -554,9 +552,6 @@ async def test_update_target_temperature( assert state is not None assert state.attributes.get(ATTR_TEMPERATURE) == temperature - # Reset config temperature_unit back to CELSIUS, required for additional tests outside this component. - hass.config.units.temperature_unit = UnitOfTemperature.CELSIUS - @pytest.mark.parametrize( "preset", [PRESET_AWAY, PRESET_ECO, PRESET_SLEEP, PRESET_BOOST, PRESET_NONE] From 7826bb9323acc23edd5161fe6c7bc7818e15e37e Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 11 Mar 2025 13:51:56 +0100 Subject: [PATCH 2462/3148] Migrate google_assistant tests to use unit system (#140366) --- .../google_assistant/test_google_assistant.py | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/tests/components/google_assistant/test_google_assistant.py b/tests/components/google_assistant/test_google_assistant.py index 2b0bfd82908..035a8d151c4 100644 --- a/tests/components/google_assistant/test_google_assistant.py +++ b/tests/components/google_assistant/test_google_assistant.py @@ -16,13 +16,9 @@ from homeassistant.components import ( light, media_player, ) -from homeassistant.const import ( - CLOUD_NEVER_EXPOSED_ENTITIES, - EntityCategory, - Platform, - UnitOfTemperature, -) +from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES, EntityCategory, Platform from homeassistant.helpers import entity_registry as er +from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM from . import DEMO_DEVICES @@ -275,7 +271,7 @@ async def test_query_climate_request_f( ) -> None: """Test a query request.""" # Mock demo devices as fahrenheit to see if we convert to celsius - hass_fixture.config.units.temperature_unit = UnitOfTemperature.FAHRENHEIT + hass_fixture.config.units = US_CUSTOMARY_SYSTEM for entity_id in ("climate.hvac", "climate.heatpump", "climate.ecobee"): state = hass_fixture.states.get(entity_id) attr = dict(state.attributes) @@ -332,7 +328,6 @@ async def test_query_climate_request_f( "thermostatHumidityAmbient": 54.2, "currentFanSpeedSetting": "on_high", } - hass_fixture.config.units.temperature_unit = UnitOfTemperature.CELSIUS async def test_query_humidifier_request( From daaa1486fc22193243935a4a4631d2c6c7c09f92 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 11 Mar 2025 13:54:21 +0100 Subject: [PATCH 2463/3148] Migrate lg_thinq tests to use unit system (#140365) --- tests/components/lg_thinq/test_climate.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/components/lg_thinq/test_climate.py b/tests/components/lg_thinq/test_climate.py index 4ac2fa55a21..e53b1c5ff39 100644 --- a/tests/components/lg_thinq/test_climate.py +++ b/tests/components/lg_thinq/test_climate.py @@ -5,9 +5,10 @@ from unittest.mock import AsyncMock, patch import pytest from syrupy import SnapshotAssertion -from homeassistant.const import Platform, UnitOfTemperature +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er +from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM from . import setup_integration @@ -23,7 +24,7 @@ async def test_all_entities( entity_registry: er.EntityRegistry, ) -> None: """Test all entities.""" - hass.config.units.temperature_unit = UnitOfTemperature.FAHRENHEIT + hass.config.units = US_CUSTOMARY_SYSTEM with patch("homeassistant.components.lg_thinq.PLATFORMS", [Platform.CLIMATE]): await setup_integration(hass, mock_config_entry) From bc6d342919dff9663f34d74116285c8dc47e10fc Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 11 Mar 2025 14:03:15 +0100 Subject: [PATCH 2464/3148] Fix no temperature unit in SmartThings (#140363) --- .../components/smartthings/climate.py | 12 +- tests/components/smartthings/conftest.py | 1 + .../ecobee_thermostat_offline.json | 81 ++++++++++++++ .../devices/ecobee_thermostat_offline.json | 82 ++++++++++++++ .../smartthings/snapshots/test_climate.ambr | 64 +++++++++++ .../smartthings/snapshots/test_init.ambr | 33 ++++++ .../smartthings/snapshots/test_sensor.ambr | 103 ++++++++++++++++++ 7 files changed, 372 insertions(+), 4 deletions(-) create mode 100644 tests/components/smartthings/fixtures/device_status/ecobee_thermostat_offline.json create mode 100644 tests/components/smartthings/fixtures/devices/ecobee_thermostat_offline.json diff --git a/homeassistant/components/smartthings/climate.py b/homeassistant/components/smartthings/climate.py index 650b0c5540a..a95105efaa6 100644 --- a/homeassistant/components/smartthings/climate.py +++ b/homeassistant/components/smartthings/climate.py @@ -321,10 +321,14 @@ class SmartThingsThermostat(SmartThingsEntity, ClimateEntity): @property def temperature_unit(self) -> str: """Return the unit of measurement.""" - unit = self._internal_state[Capability.TEMPERATURE_MEASUREMENT][ - Attribute.TEMPERATURE - ].unit - assert unit + # Offline third party thermostats may not have a unit + # Since climate always requires a unit, default to Celsius + if ( + unit := self._internal_state[Capability.TEMPERATURE_MEASUREMENT][ + Attribute.TEMPERATURE + ].unit + ) is None: + return UnitOfTemperature.CELSIUS return UNIT_MAP[unit] diff --git a/tests/components/smartthings/conftest.py b/tests/components/smartthings/conftest.py index db6e49b2135..3b39fc921d7 100644 --- a/tests/components/smartthings/conftest.py +++ b/tests/components/smartthings/conftest.py @@ -120,6 +120,7 @@ def mock_smartthings() -> Generator[AsyncMock]: "sensibo_airconditioner_1", "ecobee_sensor", "ecobee_thermostat", + "ecobee_thermostat_offline", "fake_fan", "generic_fan_3_speed", "heatit_ztrm3_thermostat", diff --git a/tests/components/smartthings/fixtures/device_status/ecobee_thermostat_offline.json b/tests/components/smartthings/fixtures/device_status/ecobee_thermostat_offline.json new file mode 100644 index 00000000000..fdda31783f6 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/ecobee_thermostat_offline.json @@ -0,0 +1,81 @@ +{ + "components": { + "main": { + "relativeHumidityMeasurement": { + "humidity": { + "value": null + } + }, + "thermostatOperatingState": { + "thermostatOperatingState": { + "value": null + } + }, + "healthCheck": { + "checkInterval": { + "value": 60, + "unit": "s", + "data": { + "deviceScheme": "UNTRACKED", + "protocol": "cloud" + }, + "timestamp": "2025-03-10T00:57:26.866Z" + }, + "healthStatus": { + "value": null + }, + "DeviceWatch-Enroll": { + "value": null + }, + "DeviceWatch-DeviceStatus": { + "value": "offline", + "data": { + "reason": "DEVICE-OFFLINE" + }, + "timestamp": "2025-03-11T10:22:17.013Z" + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": null + } + }, + "thermostatHeatingSetpoint": { + "heatingSetpoint": { + "value": null + }, + "heatingSetpointRange": { + "value": null + } + }, + "thermostatFanMode": { + "thermostatFanMode": { + "value": null + }, + "supportedThermostatFanModes": { + "value": null + } + }, + "refresh": {}, + "thermostatMode": { + "thermostatMode": { + "value": null + }, + "supportedThermostatModes": { + "value": null + } + }, + "thermostatCoolingSetpoint": { + "coolingSetpointRange": { + "value": null + }, + "coolingSetpoint": { + "value": null + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/devices/ecobee_thermostat_offline.json b/tests/components/smartthings/fixtures/devices/ecobee_thermostat_offline.json new file mode 100644 index 00000000000..5fe8d8d28be --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/ecobee_thermostat_offline.json @@ -0,0 +1,82 @@ +{ + "items": [ + { + "deviceId": "1888b38f-6246-4f1e-911b-bfcfb66999db", + "name": "v4 - ecobee Thermostat - Heat and Cool (F)", + "label": "Downstairs", + "manufacturerName": "0A0b", + "presentationId": "ST_5334da38-8076-4b40-9f6c-ac3fccaa5d24", + "deviceManufacturerCode": "ecobee", + "locationId": "1030449a-22c1-4a80-9781-0bd4ab7f0f2f", + "ownerId": "e7dbb793-4351-4cdc-b037-e6e0b4f9df67", + "roomId": "d22e6f98-78fe-4a76-b904-6cad8628da59", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "relativeHumidityMeasurement", + "version": 1 + }, + { + "id": "thermostatHeatingSetpoint", + "version": 1 + }, + { + "id": "thermostatCoolingSetpoint", + "version": 1 + }, + { + "id": "thermostatOperatingState", + "version": 1 + }, + { + "id": "thermostatMode", + "version": 1 + }, + { + "id": "thermostatFanMode", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "healthCheck", + "version": 1 + } + ], + "categories": [ + { + "name": "Thermostat", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2025-03-10T00:57:26.760Z", + "profile": { + "id": "234d537d-d388-497f-b0f4-2e25025119ba" + }, + "viper": { + "manufacturerName": "ecobee", + "modelName": "nikeSmart-thermostat", + "swVersion": "250308073247", + "hwVersion": "250308073247", + "endpointAppId": "viper_92ccdcc0-4184-11eb-b9c5-036180216747" + }, + "type": "VIPER", + "restrictionTier": 0, + "allowed": null, + "executionContext": "CLOUD", + "relationships": [] + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/snapshots/test_climate.ambr b/tests/components/smartthings/snapshots/test_climate.ambr index 6b512f93d39..20389f38a46 100644 --- a/tests/components/smartthings/snapshots/test_climate.ambr +++ b/tests/components/smartthings/snapshots/test_climate.ambr @@ -432,6 +432,70 @@ 'state': 'heat', }) # --- +# name: test_all_entities[ecobee_thermostat_offline][climate.downstairs-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'fan_modes': None, + 'hvac_modes': list([ + ]), + 'max_temp': 35, + 'min_temp': 7, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.downstairs', + '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': None, + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '1888b38f-6246-4f1e-911b-bfcfb66999db', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[ecobee_thermostat_offline][climate.downstairs-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': None, + 'fan_mode': None, + 'fan_modes': None, + 'friendly_name': 'Downstairs', + 'hvac_modes': list([ + ]), + 'max_temp': 35, + 'min_temp': 7, + 'supported_features': , + 'target_temp_high': None, + 'target_temp_low': None, + 'temperature': None, + }), + 'context': , + 'entity_id': 'climate.downstairs', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_all_entities[generic_ef00_v1][climate.thermostat_kuche-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/smartthings/snapshots/test_init.ambr b/tests/components/smartthings/snapshots/test_init.ambr index 2c45c466fa2..dad6c523a55 100644 --- a/tests/components/smartthings/snapshots/test_init.ambr +++ b/tests/components/smartthings/snapshots/test_init.ambr @@ -662,6 +662,39 @@ 'via_device_id': None, }) # --- +# name: test_devices[ecobee_thermostat_offline] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '250308073247', + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '1888b38f-6246-4f1e-911b-bfcfb66999db', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'ecobee', + 'model': 'nikeSmart-thermostat', + 'model_id': None, + 'name': 'Downstairs', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '250308073247', + 'via_device_id': None, + }) +# --- # name: test_devices[fake_fan] DeviceRegistryEntrySnapshot({ 'area_id': 'theater', diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index e7b36e7d028..94fe1924fd2 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -5093,6 +5093,109 @@ 'state': '22', }) # --- +# name: test_all_entities[ecobee_thermostat_offline][sensor.downstairs_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.downstairs_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1888b38f-6246-4f1e-911b-bfcfb66999db.humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[ecobee_thermostat_offline][sensor.downstairs_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Downstairs Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.downstairs_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[ecobee_thermostat_offline][sensor.downstairs_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.downstairs_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': 'Temperature', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1888b38f-6246-4f1e-911b-bfcfb66999db.temperature', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[ecobee_thermostat_offline][sensor.downstairs_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Downstairs Temperature', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.downstairs_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_all_entities[generic_ef00_v1][sensor.thermostat_kuche_link_quality-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From d2124db3ece929060127a6fc2a1d9b0299c7446f Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Tue, 11 Mar 2025 14:06:44 +0100 Subject: [PATCH 2465/3148] Fix double space quoting in WebDAV (#140364) --- homeassistant/components/webdav/__init__.py | 13 ++- homeassistant/components/webdav/helpers.py | 29 ++++++ homeassistant/components/webdav/manifest.json | 2 +- homeassistant/components/webdav/strings.json | 3 + requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/webdav/__init__.py | 13 +++ tests/components/webdav/conftest.py | 1 + tests/components/webdav/test_init.py | 96 +++++++++++++++++++ 9 files changed, 154 insertions(+), 7 deletions(-) create mode 100644 tests/components/webdav/test_init.py diff --git a/homeassistant/components/webdav/__init__.py b/homeassistant/components/webdav/__init__.py index 952a68d829f..36a03dce4d7 100644 --- a/homeassistant/components/webdav/__init__.py +++ b/homeassistant/components/webdav/__init__.py @@ -13,7 +13,11 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady from .const import CONF_BACKUP_PATH, DATA_BACKUP_AGENT_LISTENERS, DOMAIN -from .helpers import async_create_client, async_ensure_path_exists +from .helpers import ( + async_create_client, + async_ensure_path_exists, + async_migrate_wrong_folder_path, +) type WebDavConfigEntry = ConfigEntry[Client] @@ -46,10 +50,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: WebDavConfigEntry) -> bo translation_key="cannot_connect", ) + path = entry.data.get(CONF_BACKUP_PATH, "/") + await async_migrate_wrong_folder_path(client, path) + # Ensure the backup directory exists - if not await async_ensure_path_exists( - client, entry.data.get(CONF_BACKUP_PATH, "/") - ): + if not await async_ensure_path_exists(client, path): raise ConfigEntryNotReady( translation_domain=DOMAIN, translation_key="cannot_access_or_create_backup_path", diff --git a/homeassistant/components/webdav/helpers.py b/homeassistant/components/webdav/helpers.py index 9f91ed3bdb3..5db15bba0f7 100644 --- a/homeassistant/components/webdav/helpers.py +++ b/homeassistant/components/webdav/helpers.py @@ -1,10 +1,18 @@ """Helper functions for the WebDAV component.""" +import logging + from aiowebdav2.client import Client, ClientOptions +from aiowebdav2.exceptions import WebDavError from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + @callback def async_create_client( @@ -36,3 +44,24 @@ async def async_ensure_path_exists(client: Client, path: str) -> bool: return False return True + + +async def async_migrate_wrong_folder_path(client: Client, path: str) -> None: + """Migrate the wrong encoded folder path to the correct one.""" + wrong_path = path.replace(" ", "%20") + if await client.check(wrong_path): + try: + await client.move(wrong_path, path) + except WebDavError as err: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="failed_to_migrate_folder", + translation_placeholders={ + "wrong_path": wrong_path, + "correct_path": path, + }, + ) from err + + _LOGGER.debug( + "Migrated wrong encoded folder path from %s to %s", wrong_path, path + ) diff --git a/homeassistant/components/webdav/manifest.json b/homeassistant/components/webdav/manifest.json index fd3c749781e..30028cb28c9 100644 --- a/homeassistant/components/webdav/manifest.json +++ b/homeassistant/components/webdav/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["aiowebdav2"], "quality_scale": "bronze", - "requirements": ["aiowebdav2==0.4.1"] + "requirements": ["aiowebdav2==0.4.2"] } diff --git a/homeassistant/components/webdav/strings.json b/homeassistant/components/webdav/strings.json index 57117cdd9de..b03ffaf2a3d 100644 --- a/homeassistant/components/webdav/strings.json +++ b/homeassistant/components/webdav/strings.json @@ -36,6 +36,9 @@ }, "cannot_access_or_create_backup_path": { "message": "Cannot access or create backup path. Please check the path and permissions." + }, + "failed_to_migrate_folder": { + "message": "Failed to migrate wrong encoded folder \"{wrong_path}\" to \"{correct_path}\"." } } } diff --git a/requirements_all.txt b/requirements_all.txt index 8a2aa375b3e..83833f3a665 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -422,7 +422,7 @@ aiowaqi==3.1.0 aiowatttime==0.1.1 # homeassistant.components.webdav -aiowebdav2==0.4.1 +aiowebdav2==0.4.2 # homeassistant.components.webostv aiowebostv==0.7.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bfc9262316c..583df047cdd 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -404,7 +404,7 @@ aiowaqi==3.1.0 aiowatttime==0.1.1 # homeassistant.components.webdav -aiowebdav2==0.4.1 +aiowebdav2==0.4.2 # homeassistant.components.webostv aiowebostv==0.7.3 diff --git a/tests/components/webdav/__init__.py b/tests/components/webdav/__init__.py index 33e0222fb34..3b901bdd308 100644 --- a/tests/components/webdav/__init__.py +++ b/tests/components/webdav/__init__.py @@ -1 +1,14 @@ """Tests for the WebDAV integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Set up the WebDAV integration for testing.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/webdav/conftest.py b/tests/components/webdav/conftest.py index 4fdd6fb7870..645e2111364 100644 --- a/tests/components/webdav/conftest.py +++ b/tests/components/webdav/conftest.py @@ -62,4 +62,5 @@ def mock_webdav_client() -> Generator[AsyncMock]: mock.download_iter.side_effect = _download_mock mock.upload_iter.return_value = None mock.clean.return_value = None + mock.move.return_value = None yield mock diff --git a/tests/components/webdav/test_init.py b/tests/components/webdav/test_init.py new file mode 100644 index 00000000000..c267f7c3251 --- /dev/null +++ b/tests/components/webdav/test_init.py @@ -0,0 +1,96 @@ +"""Test WebDAV component setup.""" + +from unittest.mock import AsyncMock + +from aiowebdav2.exceptions import WebDavError +import pytest + +from homeassistant.components.webdav.const import CONF_BACKUP_PATH, DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME +from homeassistant.core import HomeAssistant + +from . import setup_integration + +from tests.common import MockConfigEntry + + +async def test_migrate_wrong_path( + hass: HomeAssistant, webdav_client: AsyncMock +) -> None: + """Test migration of wrong encoded folder path.""" + webdav_client.list_with_properties.return_value = [ + {"/wrong%20path": []}, + ] + + config_entry = MockConfigEntry( + title="user@webdav.demo", + domain=DOMAIN, + data={ + CONF_URL: "https://webdav.demo", + CONF_USERNAME: "user", + CONF_PASSWORD: "supersecretpassword", + CONF_BACKUP_PATH: "/wrong path", + }, + entry_id="01JKXV07ASC62D620DGYNG2R8H", + ) + await setup_integration(hass, config_entry) + + webdav_client.move.assert_called_once_with("/wrong%20path", "/wrong path") + + +async def test_migrate_non_wrong_path( + hass: HomeAssistant, webdav_client: AsyncMock +) -> None: + """Test no migration of correct folder path.""" + webdav_client.list_with_properties.return_value = [ + {"/correct path": []}, + ] + webdav_client.check.side_effect = lambda path: path == "/correct path" + + config_entry = MockConfigEntry( + title="user@webdav.demo", + domain=DOMAIN, + data={ + CONF_URL: "https://webdav.demo", + CONF_USERNAME: "user", + CONF_PASSWORD: "supersecretpassword", + CONF_BACKUP_PATH: "/correct path", + }, + entry_id="01JKXV07ASC62D620DGYNG2R8H", + ) + + await setup_integration(hass, config_entry) + + webdav_client.move.assert_not_called() + + +async def test_migrate_error( + hass: HomeAssistant, + webdav_client: AsyncMock, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test migration of wrong encoded folder path with error.""" + webdav_client.list_with_properties.return_value = [ + {"/wrong%20path": []}, + ] + webdav_client.move.side_effect = WebDavError("Failed to move") + + config_entry = MockConfigEntry( + title="user@webdav.demo", + domain=DOMAIN, + data={ + CONF_URL: "https://webdav.demo", + CONF_USERNAME: "user", + CONF_PASSWORD: "supersecretpassword", + CONF_BACKUP_PATH: "/wrong path", + }, + entry_id="01JKXV07ASC62D620DGYNG2R8H", + ) + await setup_integration(hass, config_entry) + + assert config_entry.state is ConfigEntryState.SETUP_RETRY + assert ( + 'Failed to migrate wrong encoded folder "/wrong%20path" to "/wrong path"' + in caplog.text + ) From 25d6974137b7b8d3f0afe58bc5ec8a55b79d0f8d Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 11 Mar 2025 14:09:50 +0100 Subject: [PATCH 2466/3148] Migrate balboa tests to use unit system (#140371) --- tests/components/balboa/test_climate.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/components/balboa/test_climate.py b/tests/components/balboa/test_climate.py index 850184a7d71..9c23833518e 100644 --- a/tests/components/balboa/test_climate.py +++ b/tests/components/balboa/test_climate.py @@ -26,10 +26,11 @@ from homeassistant.components.climate import ( HVACAction, HVACMode, ) -from homeassistant.const import ATTR_TEMPERATURE, Platform, UnitOfTemperature +from homeassistant.const import ATTR_TEMPERATURE, Platform from homeassistant.core import HomeAssistant, State from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import entity_registry as er +from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM from . import client_update, init_integration @@ -97,11 +98,10 @@ async def test_spa_temperature_unit( hass: HomeAssistant, client: MagicMock, integration: MockConfigEntry ) -> None: """Test temperature unit conversions.""" - with patch.object( - hass.config.units, "temperature_unit", UnitOfTemperature.FAHRENHEIT - ): - state = await _patch_spa_settemp(hass, client, 0, 15.4) - assert state.attributes.get(ATTR_TEMPERATURE) == 15.0 + hass.config.units = US_CUSTOMARY_SYSTEM + + state = await _patch_spa_settemp(hass, client, 0, 15.4) + assert state.attributes.get(ATTR_TEMPERATURE) == 15.0 async def test_spa_hvac_modes( From 13e9906929885774e859b8bc753349ef91588e39 Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Tue, 11 Mar 2025 14:09:58 +0100 Subject: [PATCH 2467/3148] Remove redundant after dependencies in search (#140353) --- homeassistant/components/search/manifest.json | 1 - 1 file changed, 1 deletion(-) diff --git a/homeassistant/components/search/manifest.json b/homeassistant/components/search/manifest.json index cd372139451..42a54fe8b55 100644 --- a/homeassistant/components/search/manifest.json +++ b/homeassistant/components/search/manifest.json @@ -1,7 +1,6 @@ { "domain": "search", "name": "Search", - "after_dependencies": ["scene", "group", "automation", "script"], "codeowners": ["@home-assistant/core"], "dependencies": ["websocket_api"], "documentation": "https://www.home-assistant.io/integrations/search", From 0e7a08384771ed34c9b75f3ffbab9377d6a92aff Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 11 Mar 2025 14:10:06 +0100 Subject: [PATCH 2468/3148] Handle incomplete power consumption reports in SmartThings (#140370) --- .../components/smartthings/__init__.py | 26 ----- .../components/smartthings/sensor.py | 29 ++++- tests/components/smartthings/conftest.py | 1 + .../fixtures/device_status/tplink_p110.json | 46 ++++++++ .../fixtures/devices/tplink_p110.json | 73 ++++++++++++ .../smartthings/snapshots/test_init.ambr | 33 ++++++ .../smartthings/snapshots/test_sensor.ambr | 110 ++++++++++++++++++ .../smartthings/snapshots/test_switch.ambr | 47 ++++++++ 8 files changed, 337 insertions(+), 28 deletions(-) create mode 100644 tests/components/smartthings/fixtures/device_status/tplink_p110.json create mode 100644 tests/components/smartthings/fixtures/devices/tplink_p110.json diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py index 9d8881bc1c1..9b9494dd9c5 100644 --- a/homeassistant/components/smartthings/__init__.py +++ b/homeassistant/components/smartthings/__init__.py @@ -228,28 +228,6 @@ KEEP_CAPABILITY_QUIRK: dict[ Capability.DEMAND_RESPONSE_LOAD_CONTROL: lambda _: True, } -POWER_CONSUMPTION_FIELDS = { - "energy", - "power", - "deltaEnergy", - "powerEnergy", - "energySaved", -} - -CAPABILITY_VALIDATION: dict[ - Capability | str, Callable[[dict[Attribute | str, Status]], bool] -] = { - Capability.POWER_CONSUMPTION_REPORT: ( - lambda status: ( - (power_consumption := status[Attribute.POWER_CONSUMPTION].value) is not None - and all( - field in cast(dict, power_consumption) - for field in POWER_CONSUMPTION_FIELDS - ) - ) - ) -} - def process_status( status: dict[str, dict[Capability | str, dict[Attribute | str, Status]]], @@ -273,8 +251,4 @@ def process_status( or not KEEP_CAPABILITY_QUIRK[capability](main_component[capability]) ): del main_component[capability] - for capability in list(main_component): - if capability in CAPABILITY_VALIDATION: - if not CAPABILITY_VALIDATION[capability](main_component[capability]): - del main_component[capability] return status diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index 5a2fdcf3854..ce0f30a1f1a 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -5,9 +5,9 @@ from __future__ import annotations from collections.abc import Callable, Mapping from dataclasses import dataclass from datetime import datetime -from typing import Any +from typing import Any, cast -from pysmartthings import Attribute, Capability, SmartThings +from pysmartthings import Attribute, Capability, SmartThings, Status from homeassistant.components.sensor import ( SensorDeviceClass, @@ -131,6 +131,7 @@ class SmartThingsSensorEntityDescription(SensorEntityDescription): unique_id_separator: str = "." capability_ignore_list: list[set[Capability]] | None = None options_attribute: Attribute | None = None + exists_fn: Callable[[Status], bool] | None = None CAPABILITY_TO_SENSORS: dict[ @@ -583,6 +584,10 @@ CAPABILITY_TO_SENSORS: dict[ native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, value_fn=lambda value: value["energy"] / 1000, suggested_display_precision=2, + exists_fn=lambda status: ( + (value := cast(dict | None, status.value)) is not None + and "energy" in value + ), ), SmartThingsSensorEntityDescription( key="power_meter", @@ -592,6 +597,10 @@ CAPABILITY_TO_SENSORS: dict[ value_fn=lambda value: value["power"], extra_state_attributes_fn=power_attributes, suggested_display_precision=2, + exists_fn=lambda status: ( + (value := cast(dict | None, status.value)) is not None + and "power" in value + ), ), SmartThingsSensorEntityDescription( key="deltaEnergy_meter", @@ -601,6 +610,10 @@ CAPABILITY_TO_SENSORS: dict[ native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, value_fn=lambda value: value["deltaEnergy"] / 1000, suggested_display_precision=2, + exists_fn=lambda status: ( + (value := cast(dict | None, status.value)) is not None + and "deltaEnergy" in value + ), ), SmartThingsSensorEntityDescription( key="powerEnergy_meter", @@ -610,6 +623,10 @@ CAPABILITY_TO_SENSORS: dict[ native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, value_fn=lambda value: value["powerEnergy"] / 1000, suggested_display_precision=2, + exists_fn=lambda status: ( + (value := cast(dict | None, status.value)) is not None + and "powerEnergy" in value + ), ), SmartThingsSensorEntityDescription( key="energySaved_meter", @@ -619,6 +636,10 @@ CAPABILITY_TO_SENSORS: dict[ native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, value_fn=lambda value: value["energySaved"] / 1000, suggested_display_precision=2, + exists_fn=lambda status: ( + (value := cast(dict | None, status.value)) is not None + and "energySaved" in value + ), ), ] }, @@ -980,6 +1001,10 @@ async def async_setup_entry( for capability_list in description.capability_ignore_list ) ) + and ( + not description.exists_fn + or description.exists_fn(device.status[MAIN][capability][attribute]) + ) ) diff --git a/tests/components/smartthings/conftest.py b/tests/components/smartthings/conftest.py index 3b39fc921d7..d9c31d44a7a 100644 --- a/tests/components/smartthings/conftest.py +++ b/tests/components/smartthings/conftest.py @@ -127,6 +127,7 @@ def mock_smartthings() -> Generator[AsyncMock]: "generic_ef00_v1", "bosch_radiator_thermostat_ii", "im_speaker_ai_0001", + "tplink_p110", ] ) def device_fixture( diff --git a/tests/components/smartthings/fixtures/device_status/tplink_p110.json b/tests/components/smartthings/fixtures/device_status/tplink_p110.json new file mode 100644 index 00000000000..9e1d41ed66e --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/tplink_p110.json @@ -0,0 +1,46 @@ +{ + "components": { + "main": { + "powerConsumptionReport": { + "powerConsumption": { + "value": { + "start": "2025-03-10T14:43:42.500Z", + "end": "2025-03-10T14:59:42.500Z", + "energy": 15720, + "deltaEnergy": 0 + }, + "timestamp": "2025-03-10T14:59:50.010Z" + } + }, + "healthCheck": { + "checkInterval": { + "value": 60, + "unit": "s", + "data": { + "deviceScheme": "UNTRACKED", + "protocol": "cloud" + }, + "timestamp": "2024-03-07T21:14:59.839Z" + }, + "healthStatus": { + "value": null + }, + "DeviceWatch-Enroll": { + "value": null + }, + "DeviceWatch-DeviceStatus": { + "value": "online", + "data": {}, + "timestamp": "2025-03-10T14:14:37.232Z" + } + }, + "refresh": {}, + "switch": { + "switch": { + "value": "on", + "timestamp": "2025-03-10T14:14:37.232Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/devices/tplink_p110.json b/tests/components/smartthings/fixtures/devices/tplink_p110.json new file mode 100644 index 00000000000..ffe7de5ff68 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/tplink_p110.json @@ -0,0 +1,73 @@ +{ + "items": [ + { + "deviceId": "6602696a-1e48-49e4-919f-69406f5b5da1", + "name": "plug-energy-usage-report", + "label": "Sp\u00fclmaschine", + "manufacturerName": "0AI2", + "presentationId": "ST_8f2be0ec-1113-46e0-ad56-3e92eb27410f", + "deviceManufacturerCode": "TP-Link", + "locationId": "70da36b0-bd25-410c-beed-7f0dbf658448", + "ownerId": "be5d4173-dd49-1eee-56f5-f98306ee872c", + "roomId": "bd13616d-b7e2-44ff-914c-eb38ea18c4b4", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "healthCheck", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "switch", + "version": 1 + }, + { + "id": "powerConsumptionReport", + "version": 1 + } + ], + "categories": [ + { + "name": "SmartPlug", + "categoryType": "manufacturer" + }, + { + "name": "SmartPlug", + "categoryType": "user" + } + ] + } + ], + "createTime": "2024-03-07T21:14:59.762Z", + "profile": { + "id": "a25b207e-cbb9-40ae-8a88-906637c22ab6" + }, + "viper": { + "uniqueIdentifier": "8022F7F6FE0A6EACA52B5D89C0D667352136D8C6", + "manufacturerName": "TP-Link", + "modelName": "P110", + "swVersion": "1.3.1 Build 240621 Rel.162048", + "hwVersion": "1.0", + "endpointAppId": "viper_7ea6bb80-b876-11eb-be42-952f31ab3f7b" + }, + "type": "VIPER", + "restrictionTier": 0, + "allowed": null, + "indoorMap": { + "coordinates": [0.0, 0.0, 0.0], + "rotation": [0.0, 180.0, 0.0], + "visible": false, + "data": null + }, + "executionContext": "CLOUD", + "relationships": [] + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/snapshots/test_init.ambr b/tests/components/smartthings/snapshots/test_init.ambr index dad6c523a55..473b9cb580a 100644 --- a/tests/components/smartthings/snapshots/test_init.ambr +++ b/tests/components/smartthings/snapshots/test_init.ambr @@ -1124,6 +1124,39 @@ 'via_device_id': None, }) # --- +# name: test_devices[tplink_p110] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '1.0', + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '6602696a-1e48-49e4-919f-69406f5b5da1', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'TP-Link', + 'model': 'P110', + 'model_id': None, + 'name': 'Spülmaschine', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '1.3.1 Build 240621 Rel.162048', + 'via_device_id': None, + }) +# --- # name: test_devices[vd_network_audio_002s] DeviceRegistryEntrySnapshot({ 'area_id': 'theater', diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index 94fe1924fd2..52df02f55b8 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -6212,6 +6212,116 @@ 'state': '15', }) # --- +# name: test_all_entities[tplink_p110][sensor.spulmaschine_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.spulmaschine_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '6602696a-1e48-49e4-919f-69406f5b5da1.energy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[tplink_p110][sensor.spulmaschine_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Spülmaschine Energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.spulmaschine_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '15.72', + }) +# --- +# name: test_all_entities[tplink_p110][sensor.spulmaschine_energy_difference-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.spulmaschine_energy_difference', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy difference', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_difference', + 'unique_id': '6602696a-1e48-49e4-919f-69406f5b5da1.deltaEnergy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[tplink_p110][sensor.spulmaschine_energy_difference-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Spülmaschine Energy difference', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.spulmaschine_energy_difference', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- # name: test_all_entities[vd_network_audio_002s][sensor.soundbar_living_media_playback_status-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/smartthings/snapshots/test_switch.ambr b/tests/components/smartthings/snapshots/test_switch.ambr index e119428c183..f1b5ce8412e 100644 --- a/tests/components/smartthings/snapshots/test_switch.ambr +++ b/tests/components/smartthings/snapshots/test_switch.ambr @@ -516,6 +516,53 @@ 'state': 'on', }) # --- +# name: test_all_entities[tplink_p110][switch.spulmaschine-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.spulmaschine', + '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': None, + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '6602696a-1e48-49e4-919f-69406f5b5da1', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[tplink_p110][switch.spulmaschine-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Spülmaschine', + }), + 'context': , + 'entity_id': 'switch.spulmaschine', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_all_entities[vd_network_audio_002s][switch.soundbar_living-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 1c242a6602446aac01a32a3ff55dbede7a0386c2 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 11 Mar 2025 14:10:20 +0100 Subject: [PATCH 2469/3148] Migrate homekit tests to use unit system (#140372) --- tests/components/homekit/test_type_thermostats.py | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/tests/components/homekit/test_type_thermostats.py b/tests/components/homekit/test_type_thermostats.py index fc4cfa78ca4..69c347ef55a 100644 --- a/tests/components/homekit/test_type_thermostats.py +++ b/tests/components/homekit/test_type_thermostats.py @@ -69,7 +69,6 @@ from homeassistant.const import ( ATTR_FRIENDLY_NAME, ATTR_SUPPORTED_FEATURES, ATTR_TEMPERATURE, - CONF_TEMPERATURE_UNIT, EVENT_HOMEASSISTANT_START, STATE_UNAVAILABLE, STATE_UNKNOWN, @@ -77,6 +76,7 @@ from homeassistant.const import ( ) from homeassistant.core import CoreState, Event, HomeAssistant from homeassistant.helpers import entity_registry as er +from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM from tests.common import async_mock_service @@ -858,6 +858,7 @@ async def test_thermostat_fahrenheit( ) -> None: """Test if accessory and HA are updated accordingly.""" entity_id = "climate.test" + hass.config.units = US_CUSTOMARY_SYSTEM # support_ = True hass.states.async_set( @@ -869,10 +870,7 @@ async def test_thermostat_fahrenheit( }, ) await hass.async_block_till_done() - with patch.object( - hass.config.units, CONF_TEMPERATURE_UNIT, new=UnitOfTemperature.FAHRENHEIT - ): - acc = Thermostat(hass, hk_driver, "Climate", entity_id, 1, None) + acc = Thermostat(hass, hk_driver, "Climate", entity_id, 1, None) hk_driver.add_accessory(acc) acc.run() await hass.async_block_till_done() @@ -1786,13 +1784,11 @@ async def test_water_heater_fahrenheit( ) -> None: """Test if accessory and HA are update accordingly.""" entity_id = "water_heater.test" + hass.config.units = US_CUSTOMARY_SYSTEM hass.states.async_set(entity_id, HVACMode.HEAT) await hass.async_block_till_done() - with patch.object( - hass.config.units, CONF_TEMPERATURE_UNIT, new=UnitOfTemperature.FAHRENHEIT - ): - acc = WaterHeater(hass, hk_driver, "WaterHeater", entity_id, 2, None) + acc = WaterHeater(hass, hk_driver, "WaterHeater", entity_id, 2, None) acc.run() await hass.async_block_till_done() From ca5ce74740416b4b6813a2392329840ffa26b5bf Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 11 Mar 2025 14:10:54 +0100 Subject: [PATCH 2470/3148] Improve user-facing strings of `hassio` component (#140355) - capitalize "Internet" - remove excessive space character - add "the" and trailing period in description of `homeassistant_exclude_database` field - replace duplicate strings in `backup_partial` with references to `backup_full` action --- homeassistant/components/hassio/strings.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/hassio/strings.json b/homeassistant/components/hassio/strings.json index 799067b8215..a543dbc7f89 100644 --- a/homeassistant/components/hassio/strings.json +++ b/homeassistant/components/hassio/strings.json @@ -152,7 +152,7 @@ }, "unsupported_connectivity_check": { "title": "Unsupported system - Connectivity check disabled", - "description": "System is unsupported because Home Assistant cannot determine when an internet connection is available. Use the link to learn more and how to fix this." + "description": "System is unsupported because Home Assistant cannot determine when an Internet connection is available. Use the link to learn more and how to fix this." }, "unsupported_content_trust": { "title": "Unsupported system - Content-trust check disabled", @@ -216,7 +216,7 @@ }, "unsupported_systemd_journal": { "title": "Unsupported system - Systemd Journal issues", - "description": "System is unsupported because Systemd Journal and/or the gateway service is missing, inactive or misconfigured . Use the link to learn more and how to fix this." + "description": "System is unsupported because Systemd Journal and/or the gateway service is missing, inactive or misconfigured. Use the link to learn more and how to fix this." }, "unsupported_systemd_resolved": { "title": "Unsupported system - Systemd-Resolved issues", @@ -348,7 +348,7 @@ }, "homeassistant_exclude_database": { "name": "Home Assistant exclude database", - "description": "Exclude the Home Assistant database file from backup" + "description": "Exclude the Home Assistant database file from the backup." } } }, @@ -385,8 +385,8 @@ "description": "[%key:component::hassio::services::backup_full::fields::location::description%]" }, "homeassistant_exclude_database": { - "name": "Home Assistant exclude database", - "description": "Exclude the Home Assistant database file from backup" + "name": "[%key:component::hassio::services::backup_full::fields::homeassistant_exclude_database::name%]", + "description": "[%key:component::hassio::services::backup_full::fields::homeassistant_exclude_database::description%]" } } }, From d82c30364a86a4f21a7e3454185de7f05b67f57b Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Tue, 11 Mar 2025 14:12:30 +0100 Subject: [PATCH 2471/3148] Remove redundant after dependencies in person (#140354) --- homeassistant/components/person/manifest.json | 1 - 1 file changed, 1 deletion(-) diff --git a/homeassistant/components/person/manifest.json b/homeassistant/components/person/manifest.json index 7f370be6fbe..0c1792e9277 100644 --- a/homeassistant/components/person/manifest.json +++ b/homeassistant/components/person/manifest.json @@ -1,7 +1,6 @@ { "domain": "person", "name": "Person", - "after_dependencies": ["device_tracker"], "codeowners": [], "dependencies": ["image_upload", "http"], "documentation": "https://www.home-assistant.io/integrations/person", From 536109251e4286b77a151de565ca090e243f9ed4 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 11 Mar 2025 14:47:13 +0100 Subject: [PATCH 2472/3148] Make sure SmartThings light can deal with unknown states (#140190) * Fix * add comment * Make light unknown * Make light unknown --- homeassistant/components/smartthings/light.py | 54 +++++++++----- tests/components/smartthings/conftest.py | 1 + .../device_status/abl_light_b_001.json | 27 +++++++ .../fixtures/devices/abl_light_b_001.json | 59 ++++++++++++++++ .../smartthings/snapshots/test_init.ambr | 33 +++++++++ .../smartthings/snapshots/test_light.ambr | 70 +++++++++++++++++++ 6 files changed, 225 insertions(+), 19 deletions(-) create mode 100644 tests/components/smartthings/fixtures/device_status/abl_light_b_001.json create mode 100644 tests/components/smartthings/fixtures/devices/abl_light_b_001.json diff --git a/homeassistant/components/smartthings/light.py b/homeassistant/components/smartthings/light.py index eee333f131f..12c7f7ebbcb 100644 --- a/homeassistant/components/smartthings/light.py +++ b/homeassistant/components/smartthings/light.py @@ -150,14 +150,21 @@ class SmartThingsLight(SmartThingsEntity, LightEntity, RestoreEntity): """Update entity attributes when the device status has changed.""" # Brightness and transition if brightness_supported(self._attr_supported_color_modes): - self._attr_brightness = int( - convert_scale( - self.get_attribute_value(Capability.SWITCH_LEVEL, Attribute.LEVEL), - 100, - 255, - 0, + if ( + brightness := self.get_attribute_value( + Capability.SWITCH_LEVEL, Attribute.LEVEL + ) + ) is None: + self._attr_brightness = None + else: + self._attr_brightness = int( + convert_scale( + brightness, + 100, + 255, + 0, + ) ) - ) # Color Temperature if ColorMode.COLOR_TEMP in self._attr_supported_color_modes: self._attr_color_temp_kelvin = self.get_attribute_value( @@ -165,16 +172,21 @@ class SmartThingsLight(SmartThingsEntity, LightEntity, RestoreEntity): ) # Color if ColorMode.HS in self._attr_supported_color_modes: - self._attr_hs_color = ( - convert_scale( - self.get_attribute_value(Capability.COLOR_CONTROL, Attribute.HUE), - 100, - 360, - ), - self.get_attribute_value( - Capability.COLOR_CONTROL, Attribute.SATURATION - ), - ) + if ( + hue := self.get_attribute_value(Capability.COLOR_CONTROL, Attribute.HUE) + ) is None: + self._attr_hs_color = None + else: + self._attr_hs_color = ( + convert_scale( + hue, + 100, + 360, + ), + self.get_attribute_value( + Capability.COLOR_CONTROL, Attribute.SATURATION + ), + ) async def async_set_color(self, hs_color): """Set the color of the device.""" @@ -220,6 +232,10 @@ class SmartThingsLight(SmartThingsEntity, LightEntity, RestoreEntity): super()._update_handler(event) @property - def is_on(self) -> bool: + def is_on(self) -> bool | None: """Return true if light is on.""" - return self.get_attribute_value(Capability.SWITCH, Attribute.SWITCH) == "on" + if ( + state := self.get_attribute_value(Capability.SWITCH, Attribute.SWITCH) + ) is None: + return None + return state == "on" diff --git a/tests/components/smartthings/conftest.py b/tests/components/smartthings/conftest.py index d9c31d44a7a..6de472a59a8 100644 --- a/tests/components/smartthings/conftest.py +++ b/tests/components/smartthings/conftest.py @@ -127,6 +127,7 @@ def mock_smartthings() -> Generator[AsyncMock]: "generic_ef00_v1", "bosch_radiator_thermostat_ii", "im_speaker_ai_0001", + "abl_light_b_001", "tplink_p110", ] ) diff --git a/tests/components/smartthings/fixtures/device_status/abl_light_b_001.json b/tests/components/smartthings/fixtures/device_status/abl_light_b_001.json new file mode 100644 index 00000000000..6dba85d7dc4 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/abl_light_b_001.json @@ -0,0 +1,27 @@ +{ + "components": { + "main": { + "switchLevel": { + "levelRange": { + "value": null + }, + "level": { + "value": null + } + }, + "switch": { + "switch": { + "value": null + } + }, + "colorTemperature": { + "colorTemperatureRange": { + "value": null + }, + "colorTemperature": { + "value": null + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/devices/abl_light_b_001.json b/tests/components/smartthings/fixtures/devices/abl_light_b_001.json new file mode 100644 index 00000000000..bb4970b6d5a --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/abl_light_b_001.json @@ -0,0 +1,59 @@ +{ + "items": [ + { + "deviceId": "7c16163e-c94e-482f-95f6-139ae0cd9d5e", + "name": "ABL Wafer Down Light(BLE)", + "label": "Kitchen Light 5", + "manufacturerName": "Samsung Electronics", + "presentationId": "ABL-LIGHT-B-001", + "deviceManufacturerCode": "Samsung Electronics", + "locationId": "6c314222-8baf-48a0-9442-5b1102a8757f", + "ownerId": "f24ff388-700c-7d1e-91f2-1c37ae68ce2b", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "switch", + "version": 1 + }, + { + "id": "colorTemperature", + "version": 1 + }, + { + "id": "switchLevel", + "version": 1 + } + ], + "categories": [ + { + "name": "Light", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2025-03-08T22:40:25.073Z", + "profile": { + "id": "65f5db53-9a78-4b19-8e40-d32187cd59ab" + }, + "bleD2D": { + "encryptionKey": "f593369dcea915f6352a4a42cd4b2ea6", + "cipher": "AES_128-CBC-PKCS7Padding", + "advertisingId": "b13d7192", + "identifier": "88-57-1d-7c-cb-cf", + "configurationUrl": "https://apis.samsungiotcloud.com/v1/miniature/profile/65f5db53-9a78-4b19-8e40-d32187cd59ab", + "bleDeviceType": "BLE", + "metadata": null + }, + "type": "BLE_D2D", + "restrictionTier": 0, + "allowed": null, + "executionContext": "CLOUD", + "relationships": [] + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/snapshots/test_init.ambr b/tests/components/smartthings/snapshots/test_init.ambr index 473b9cb580a..0276873384a 100644 --- a/tests/components/smartthings/snapshots/test_init.ambr +++ b/tests/components/smartthings/snapshots/test_init.ambr @@ -2,6 +2,39 @@ # name: test_button_event[button] # --- +# name: test_devices[abl_light_b_001] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '7c16163e-c94e-482f-95f6-139ae0cd9d5e', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': None, + 'model': None, + 'model_id': None, + 'name': 'Kitchen Light 5', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- # name: test_devices[aeotec_home_energy_meter_gen5] DeviceRegistryEntrySnapshot({ 'area_id': 'toilet', diff --git a/tests/components/smartthings/snapshots/test_light.ambr b/tests/components/smartthings/snapshots/test_light.ambr index 8766811c443..f1f2b92de77 100644 --- a/tests/components/smartthings/snapshots/test_light.ambr +++ b/tests/components/smartthings/snapshots/test_light.ambr @@ -1,4 +1,74 @@ # serializer version: 1 +# name: test_all_entities[abl_light_b_001][light.kitchen_light_5-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 9000, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 111, + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.kitchen_light_5', + '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': None, + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '7c16163e-c94e-482f-95f6-139ae0cd9d5e', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[abl_light_b_001][light.kitchen_light_5-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': None, + 'color_mode': None, + 'color_temp': None, + 'color_temp_kelvin': None, + 'friendly_name': 'Kitchen Light 5', + 'hs_color': None, + 'max_color_temp_kelvin': 9000, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 111, + 'rgb_color': None, + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + 'xy_color': None, + }), + 'context': , + 'entity_id': 'light.kitchen_light_5', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_all_entities[centralite][light.dimmer_debian-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 8edecd8671b033edf44d9cf99700397a9b66a717 Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Tue, 11 Mar 2025 09:47:30 -0400 Subject: [PATCH 2473/3148] Bump python-roborock to 2.12.2 (#140368) bump python roboorck to 2.12.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 db2654d4baa..1b143591203 100644 --- a/homeassistant/components/roborock/manifest.json +++ b/homeassistant/components/roborock/manifest.json @@ -7,7 +7,7 @@ "iot_class": "local_polling", "loggers": ["roborock"], "requirements": [ - "python-roborock==2.11.1", + "python-roborock==2.12.2", "vacuum-map-parser-roborock==0.1.2" ] } diff --git a/requirements_all.txt b/requirements_all.txt index 83833f3a665..7d2ca933235 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2461,7 +2461,7 @@ python-rabbitair==0.0.8 python-ripple-api==0.0.3 # homeassistant.components.roborock -python-roborock==2.11.1 +python-roborock==2.12.2 # homeassistant.components.smarttub python-smarttub==0.0.39 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 583df047cdd..9f30c342c95 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1994,7 +1994,7 @@ python-picnic-api2==1.2.2 python-rabbitair==0.0.8 # homeassistant.components.roborock -python-roborock==2.11.1 +python-roborock==2.12.2 # homeassistant.components.smarttub python-smarttub==0.0.39 From 7bdec5f19f3e83649035e3535b91279f2e1a0089 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Tue, 11 Mar 2025 14:54:02 +0100 Subject: [PATCH 2474/3148] Bump reolink-aio to 0.12.2 (#140369) --- homeassistant/components/reolink/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json index f923efdbbf2..c07d63c184c 100644 --- a/homeassistant/components/reolink/manifest.json +++ b/homeassistant/components/reolink/manifest.json @@ -19,5 +19,5 @@ "iot_class": "local_push", "loggers": ["reolink_aio"], "quality_scale": "platinum", - "requirements": ["reolink-aio==0.12.1"] + "requirements": ["reolink-aio==0.12.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 7d2ca933235..f22db6ffd7b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2618,7 +2618,7 @@ renault-api==0.2.9 renson-endura-delta==1.7.2 # homeassistant.components.reolink -reolink-aio==0.12.1 +reolink-aio==0.12.2 # homeassistant.components.idteck_prox rfk101py==0.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9f30c342c95..e8e9c477fff 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2121,7 +2121,7 @@ renault-api==0.2.9 renson-endura-delta==1.7.2 # homeassistant.components.reolink -reolink-aio==0.12.1 +reolink-aio==0.12.2 # homeassistant.components.rflink rflink==0.0.66 From 6c54f8dff2edd39b4803ec3c88f1f72846bf045c Mon Sep 17 00:00:00 2001 From: Pete Sage <76050312+PeteRager@users.noreply.github.com> Date: Tue, 11 Mar 2025 09:56:41 -0400 Subject: [PATCH 2475/3148] Fix browsing Audible Favorites in Sonos (#140378) * initial commit * updates * update test data --- homeassistant/components/sonos/const.py | 4 ++ .../sonos/fixtures/sonos_favorites.json | 18 +++++ .../sonos/snapshots/test_media_browser.ambr | 70 +++++++++++++++++++ tests/components/sonos/test_media_browser.py | 37 ++++++++++ 4 files changed, 129 insertions(+) diff --git a/homeassistant/components/sonos/const.py b/homeassistant/components/sonos/const.py index 8fb704cbfbc..cda40729dbc 100644 --- a/homeassistant/components/sonos/const.py +++ b/homeassistant/components/sonos/const.py @@ -32,6 +32,7 @@ SONOS_TRACKS = "tracks" SONOS_COMPOSER = "composers" SONOS_RADIO = "radio" SONOS_OTHER_ITEM = "other items" +SONOS_AUDIO_BOOK = "audio book" SONOS_STATE_PLAYING = "PLAYING" SONOS_STATE_TRANSITIONING = "TRANSITIONING" @@ -67,6 +68,7 @@ SONOS_TO_MEDIA_CLASSES = { "object.item": MediaClass.TRACK, "object.item.audioItem.musicTrack": MediaClass.TRACK, "object.item.audioItem.audioBroadcast": MediaClass.GENRE, + "object.item.audioItem.audioBook": MediaClass.TRACK, } SONOS_TO_MEDIA_TYPES = { @@ -84,6 +86,7 @@ SONOS_TO_MEDIA_TYPES = { "object.container.playlistContainer.sameArtist": MediaType.ARTIST, "object.container.playlistContainer": MediaType.PLAYLIST, "object.item.audioItem.musicTrack": MediaType.TRACK, + "object.item.audioItem.audioBook": MediaType.TRACK, } MEDIA_TYPES_TO_SONOS: dict[MediaType | str, str] = { @@ -113,6 +116,7 @@ SONOS_TYPES_MAPPING = { "object.item": SONOS_OTHER_ITEM, "object.item.audioItem.musicTrack": SONOS_TRACKS, "object.item.audioItem.audioBroadcast": SONOS_RADIO, + "object.item.audioItem.audioBook": SONOS_AUDIO_BOOK, } LIBRARY_TITLES_MAPPING = { diff --git a/tests/components/sonos/fixtures/sonos_favorites.json b/tests/components/sonos/fixtures/sonos_favorites.json index 21ee68f4872..d5463c3d02b 100644 --- a/tests/components/sonos/fixtures/sonos_favorites.json +++ b/tests/components/sonos/fixtures/sonos_favorites.json @@ -34,5 +34,23 @@ "protocol_info": "a:b:c:d" } ] + }, + { + "title": "American Tall Tales", + "parent_id": "FV:2", + "item_id": "FV:2/66", + "restricted": false, + "resource_meta_data": "American Tall Talesobject.item.audioItem.audioBookSA_RINCON61191_X_#Svc6-0-Token", + "resources": [ + { + "uri": "x-rincon-cpcontainer:101340c8reftitle%C9F27_com?sid=239&flags=16584&sn=5", + "protocol_info": "x-rincon-cpcontainer:*:*:*" + } + ], + "desc": null, + "album_art_uri": "https://m.media-amazon.com/images/I/810lqLo5m0L._SL600_.jpg", + "type": "instantPlay", + "description": "Audible", + "favorite_nr": "0" } ] diff --git a/tests/components/sonos/snapshots/test_media_browser.ambr b/tests/components/sonos/snapshots/test_media_browser.ambr index ae8e813ae5d..9f6560c0f75 100644 --- a/tests/components/sonos/snapshots/test_media_browser.ambr +++ b/tests/components/sonos/snapshots/test_media_browser.ambr @@ -1,4 +1,74 @@ # serializer version: 1 +# name: test_browse_media_favorites[-favorites] + dict({ + 'can_expand': True, + 'can_play': False, + 'children': list([ + dict({ + 'can_expand': True, + 'can_play': False, + 'children_media_class': None, + 'media_class': 'album', + 'media_content_id': 'object.container.album.musicAlbum', + 'media_content_type': 'favorites_folder', + 'thumbnail': None, + 'title': 'Albums', + }), + dict({ + 'can_expand': True, + 'can_play': False, + 'children_media_class': None, + 'media_class': 'track', + 'media_content_id': 'object.item.audioItem.audioBook', + 'media_content_type': 'favorites_folder', + 'thumbnail': None, + 'title': 'Audio Book', + }), + dict({ + 'can_expand': True, + 'can_play': False, + 'children_media_class': None, + 'media_class': 'genre', + 'media_content_id': 'object.item.audioItem.audioBroadcast', + 'media_content_type': 'favorites_folder', + 'thumbnail': None, + 'title': 'Radio', + }), + ]), + 'children_media_class': 'directory', + 'media_class': 'directory', + 'media_content_id': '', + 'media_content_type': 'favorites', + 'not_shown': 0, + 'thumbnail': None, + 'title': 'Favorites', + }) +# --- +# name: test_browse_media_favorites[object.item.audioItem.audioBook-favorites_folder] + dict({ + 'can_expand': True, + 'can_play': False, + 'children': list([ + dict({ + 'can_expand': False, + 'can_play': True, + 'children_media_class': None, + 'media_class': 'track', + 'media_content_id': 'FV:2/66', + 'media_content_type': 'favorite_item_id', + 'thumbnail': 'https://m.media-amazon.com/images/I/810lqLo5m0L._SL600_.jpg', + 'title': 'American Tall Tales', + }), + ]), + 'children_media_class': 'track', + 'media_class': 'directory', + 'media_content_id': '', + 'media_content_type': 'favorites', + 'not_shown': 0, + 'thumbnail': None, + 'title': 'Audio Book', + }) +# --- # name: test_browse_media_library list([ dict({ diff --git a/tests/components/sonos/test_media_browser.py b/tests/components/sonos/test_media_browser.py index 6e03935f7f6..323140e285d 100644 --- a/tests/components/sonos/test_media_browser.py +++ b/tests/components/sonos/test_media_browser.py @@ -2,6 +2,7 @@ from functools import partial +import pytest from syrupy import SnapshotAssertion from homeassistant.components.media_player import BrowseMedia, MediaClass, MediaType @@ -176,3 +177,39 @@ async def test_browse_media_library_albums( assert response["success"] assert response["result"]["children"] == snapshot assert soco_mock.music_library.browse_by_idstring.call_count == 1 + + +@pytest.mark.parametrize( + ("media_content_id", "media_content_type"), + [ + ( + "", + "favorites", + ), + ( + "object.item.audioItem.audioBook", + "favorites_folder", + ), + ], +) +async def test_browse_media_favorites( + async_autosetup_sonos, + hass_ws_client: WebSocketGenerator, + snapshot: SnapshotAssertion, + media_content_id, + media_content_type, +) -> None: + """Test the async_browse_media method.""" + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "media_player/browse_media", + "entity_id": "media_player.zone_a", + "media_content_id": media_content_id, + "media_content_type": media_content_type, + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == snapshot From ca33d7263f93bcd3a817c4e93f0883f29d021754 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Tue, 11 Mar 2025 07:12:19 -0700 Subject: [PATCH 2476/3148] Improve roborock map image (#140379) --- homeassistant/components/roborock/const.py | 1 + homeassistant/components/roborock/image.py | 7 ++++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/roborock/const.py b/homeassistant/components/roborock/const.py index cc8d34fbadc..5a725ff5586 100644 --- a/homeassistant/components/roborock/const.py +++ b/homeassistant/components/roborock/const.py @@ -49,6 +49,7 @@ IMAGE_CACHE_INTERVAL = 90 MAP_SLEEP = 3 GET_MAPS_SERVICE_NAME = "get_maps" +MAP_SCALE = 4 MAP_FILE_FORMAT = "PNG" MAP_FILENAME_SUFFIX = ".png" SET_VACUUM_GOTO_POSITION_SERVICE_NAME = "set_vacuum_goto_position" diff --git a/homeassistant/components/roborock/image.py b/homeassistant/components/roborock/image.py index 66088d6453c..70f06dd4b92 100644 --- a/homeassistant/components/roborock/image.py +++ b/homeassistant/components/roborock/image.py @@ -26,6 +26,7 @@ from .const import ( DRAWABLES, IMAGE_CACHE_INTERVAL, MAP_FILE_FORMAT, + MAP_SCALE, MAP_SLEEP, ) from .coordinator import RoborockConfigEntry, RoborockDataUpdateCoordinator @@ -47,7 +48,11 @@ async def async_setup_entry( if config_entry.options.get(DRAWABLES, {}).get(drawable, default_value) ] parser = RoborockMapDataParser( - ColorsPalette(), Sizes(), drawables, ImageConfig(), [] + ColorsPalette(), + Sizes({k: v * MAP_SCALE for k, v in Sizes.SIZES.items()}), + drawables, + ImageConfig(scale=MAP_SCALE), + [], ) def parse_image(map_bytes: bytes) -> bytes | None: From 3c57b12cd1daef98bb5287b255a2ba48b28b89cd Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Tue, 11 Mar 2025 10:31:20 -0400 Subject: [PATCH 2477/3148] Fix bug with all Roborock maps being set to the wrong map when empty (#138493) * Fix bug with all maps being set to the same when empty * fix parens * fix other parens * rework some of the logic * few small updates * Remove test that is no longer relevant * remove updated time bump --- homeassistant/components/roborock/image.py | 28 +++++++----------- tests/components/roborock/test_image.py | 34 ---------------------- 2 files changed, 11 insertions(+), 51 deletions(-) diff --git a/homeassistant/components/roborock/image.py b/homeassistant/components/roborock/image.py index 70f06dd4b92..2fb5d644826 100644 --- a/homeassistant/components/roborock/image.py +++ b/homeassistant/components/roborock/image.py @@ -117,19 +117,6 @@ class RoborockMap(RoborockCoordinatedEntityV1, ImageEntity): """Return if this map is the currently selected map.""" return self.map_flag == self.coordinator.current_map - def is_map_valid(self) -> bool: - """Update the map if it is valid. - - Update this map if it is the currently active map, and the - vacuum is cleaning, or if it has never been set at all. - """ - return self.cached_map == b"" or ( - self.is_selected - and self.image_last_updated is not None - and self.coordinator.roborock_device_info.props.status is not None - and bool(self.coordinator.roborock_device_info.props.status.in_cleaning) - ) - async def async_added_to_hass(self) -> None: """When entity is added to hass load any previously cached maps from disk.""" await super().async_added_to_hass() @@ -142,15 +129,22 @@ class RoborockMap(RoborockCoordinatedEntityV1, ImageEntity): # Bump last updated every third time the coordinator runs, so that async_image # will be called and we will evaluate on the new coordinator data if we should # update the cache. - if ( - dt_util.utcnow() - self.image_last_updated - ).total_seconds() > IMAGE_CACHE_INTERVAL and self.is_map_valid(): + if self.is_selected and ( + ( + (dt_util.utcnow() - self.image_last_updated).total_seconds() + > IMAGE_CACHE_INTERVAL + and self.coordinator.roborock_device_info.props.status is not None + and bool(self.coordinator.roborock_device_info.props.status.in_cleaning) + ) + or self.cached_map == b"" + ): + # This will tell async_image it should update. self._attr_image_last_updated = dt_util.utcnow() super()._handle_coordinator_update() async def async_image(self) -> bytes | None: """Update the image if it is not cached.""" - if self.is_map_valid(): + if self.is_selected: response = await asyncio.gather( *( self.cloud_api.get_map_v1(), diff --git a/tests/components/roborock/test_image.py b/tests/components/roborock/test_image.py index fd6c8b2796a..7d79cf4f6ab 100644 --- a/tests/components/roborock/test_image.py +++ b/tests/components/roborock/test_image.py @@ -3,7 +3,6 @@ import copy from datetime import timedelta from http import HTTPStatus -import io from unittest.mock import patch from PIL import Image @@ -111,39 +110,6 @@ async def test_floorplan_image_failed_parse( assert not resp.ok -async def test_load_stored_image( - hass: HomeAssistant, - hass_client: ClientSessionGenerator, - setup_entry: MockConfigEntry, -) -> None: - """Test that we correctly load an image from storage when it already exists.""" - img_byte_arr = io.BytesIO() - MAP_DATA.image.data.save(img_byte_arr, format="PNG") - img_bytes = img_byte_arr.getvalue() - - # Load the image on demand, which should queue it to be cached on disk - client = await hass_client() - resp = await client.get("/api/image_proxy/image.roborock_s7_maxv_upstairs") - assert resp.status == HTTPStatus.OK - - with patch( - "homeassistant.components.roborock.image.RoborockMapDataParser.parse", - ) as parse_map: - # Reload the config entry so that the map is saved in storage and entities exist. - await hass.config_entries.async_reload(setup_entry.entry_id) - await hass.async_block_till_done() - assert hass.states.get("image.roborock_s7_maxv_upstairs") is not None - client = await hass_client() - resp = await client.get("/api/image_proxy/image.roborock_s7_maxv_upstairs") - # Test that we can get the image and it correctly serialized and unserialized. - assert resp.status == HTTPStatus.OK - body = await resp.read() - assert body == img_bytes - - # Ensure that we never tried to update the map, and only used the cached image. - assert parse_map.call_count == 0 - - async def test_fail_to_save_image( hass: HomeAssistant, hass_client: ClientSessionGenerator, From 71159c755f2151cb0b15dca7c37e36c05ba5cfae Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 11 Mar 2025 15:33:32 +0100 Subject: [PATCH 2478/3148] Delete subscription on shutdown of SmartThings (#140135) * Cache subscription url in SmartThings * Cache subscription url in SmartThings * Fix * Fix * Fix * Fix * Fix * Fix * Fix * Fix * Bump pysmartthings to 2.7.1 * 2.7.2 --------- Co-authored-by: Martin Hjelmare --- .../components/smartthings/__init__.py | 74 ++++++- homeassistant/components/smartthings/const.py | 1 + .../components/smartthings/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/smartthings/conftest.py | 4 + .../smartthings/fixtures/subscription.json | 16 ++ .../smartthings/test_config_flow.py | 2 + tests/components/smartthings/test_init.py | 185 +++++++++++++++++- 9 files changed, 275 insertions(+), 13 deletions(-) create mode 100644 tests/components/smartthings/fixtures/subscription.json diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py index 9b9494dd9c5..f95719a8d02 100644 --- a/homeassistant/components/smartthings/__init__.py +++ b/homeassistant/components/smartthings/__init__.py @@ -16,12 +16,18 @@ from pysmartthings import ( Scene, SmartThings, SmartThingsAuthenticationFailedError, + SmartThingsSinkError, Status, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN, Platform -from homeassistant.core import HomeAssistant +from homeassistant.const import ( + CONF_ACCESS_TOKEN, + CONF_TOKEN, + EVENT_HOMEASSISTANT_STOP, + Platform, +) +from homeassistant.core import Event, HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -33,6 +39,7 @@ from homeassistant.helpers.config_entry_oauth2_flow import ( from .const import ( CONF_INSTALLED_APP_ID, CONF_LOCATION_ID, + CONF_SUBSCRIPTION_ID, DOMAIN, EVENT_BUTTON, MAIN, @@ -100,6 +107,54 @@ async def async_setup_entry(hass: HomeAssistant, entry: SmartThingsConfigEntry) client.refresh_token_function = _refresh_token + def _handle_max_connections() -> None: + _LOGGER.debug("We hit the limit of max connections") + hass.config_entries.async_schedule_reload(entry.entry_id) + + client.max_connections_reached_callback = _handle_max_connections + + def _handle_new_subscription_identifier(identifier: str | None) -> None: + """Handle a new subscription identifier.""" + hass.config_entries.async_update_entry( + entry, + data={ + **entry.data, + CONF_SUBSCRIPTION_ID: identifier, + }, + ) + if identifier is not None: + _LOGGER.debug("Updating subscription ID to %s", identifier) + else: + _LOGGER.debug("Removing subscription ID") + + client.new_subscription_id_callback = _handle_new_subscription_identifier + + if (old_identifier := entry.data.get(CONF_SUBSCRIPTION_ID)) is not None: + _LOGGER.debug("Trying to delete old subscription %s", old_identifier) + await client.delete_subscription(old_identifier) + + _LOGGER.debug("Trying to create a new subscription") + try: + subscription = await client.create_subscription( + entry.data[CONF_LOCATION_ID], + entry.data[CONF_TOKEN][CONF_INSTALLED_APP_ID], + ) + except SmartThingsSinkError as err: + _LOGGER.debug("Couldn't create a new subscription: %s", err) + raise ConfigEntryNotReady from err + subscription_id = subscription.subscription_id + _handle_new_subscription_identifier(subscription_id) + + entry.async_create_background_task( + hass, + client.subscribe( + entry.data[CONF_LOCATION_ID], + entry.data[CONF_TOKEN][CONF_INSTALLED_APP_ID], + subscription, + ), + "smartthings_socket", + ) + device_status: dict[str, FullDevice] = {} try: rooms = { @@ -171,12 +226,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: SmartThingsConfigEntry) client.add_unspecified_device_event_listener(handle_button_press) ) - entry.async_create_background_task( - hass, - client.subscribe( - entry.data[CONF_LOCATION_ID], entry.data[CONF_TOKEN][CONF_INSTALLED_APP_ID] - ), - "smartthings_webhook", + async def _handle_shutdown(_: Event) -> None: + """Handle shutdown.""" + await client.delete_subscription(subscription_id) + + entry.async_on_unload( + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _handle_shutdown) ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -201,6 +256,9 @@ async def async_unload_entry( hass: HomeAssistant, entry: SmartThingsConfigEntry ) -> bool: """Unload a config entry.""" + client = entry.runtime_data.client + if (subscription_id := entry.data.get(CONF_SUBSCRIPTION_ID)) is not None: + await client.delete_subscription(subscription_id) return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/smartthings/const.py b/homeassistant/components/smartthings/const.py index a6d028aed06..2ba59ade4e8 100644 --- a/homeassistant/components/smartthings/const.py +++ b/homeassistant/components/smartthings/const.py @@ -33,4 +33,5 @@ CONF_REFRESH_TOKEN = "refresh_token" MAIN = "main" OLD_DATA = "old_data" +CONF_SUBSCRIPTION_ID = "subscription_id" EVENT_BUTTON = "smartthings.button" diff --git a/homeassistant/components/smartthings/manifest.json b/homeassistant/components/smartthings/manifest.json index 2a4e79bff58..74f0e4bae83 100644 --- a/homeassistant/components/smartthings/manifest.json +++ b/homeassistant/components/smartthings/manifest.json @@ -29,5 +29,5 @@ "documentation": "https://www.home-assistant.io/integrations/smartthings", "iot_class": "cloud_push", "loggers": ["pysmartthings"], - "requirements": ["pysmartthings==2.7.0"] + "requirements": ["pysmartthings==2.7.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index f22db6ffd7b..10e305cc47e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2310,7 +2310,7 @@ pysma==0.7.5 pysmappee==0.2.29 # homeassistant.components.smartthings -pysmartthings==2.7.0 +pysmartthings==2.7.2 # homeassistant.components.smarty pysmarty2==0.10.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e8e9c477fff..c2043684a80 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1882,7 +1882,7 @@ pysma==0.7.5 pysmappee==0.2.29 # homeassistant.components.smartthings -pysmartthings==2.7.0 +pysmartthings==2.7.2 # homeassistant.components.smarty pysmarty2==0.10.2 diff --git a/tests/components/smartthings/conftest.py b/tests/components/smartthings/conftest.py index 6de472a59a8..2deef344b5e 100644 --- a/tests/components/smartthings/conftest.py +++ b/tests/components/smartthings/conftest.py @@ -10,6 +10,7 @@ from pysmartthings.models import ( LocationResponse, RoomResponse, SceneResponse, + Subscription, ) import pytest @@ -82,6 +83,9 @@ def mock_smartthings() -> Generator[AsyncMock]: client.get_rooms.return_value = RoomResponse.from_json( load_fixture("rooms.json", DOMAIN) ).items + client.create_subscription.return_value = Subscription.from_json( + load_fixture("subscription.json", DOMAIN) + ) yield client diff --git a/tests/components/smartthings/fixtures/subscription.json b/tests/components/smartthings/fixtures/subscription.json new file mode 100644 index 00000000000..80f37445524 --- /dev/null +++ b/tests/components/smartthings/fixtures/subscription.json @@ -0,0 +1,16 @@ +{ + "subscriptionId": "f5768ce8-c9e5-4507-9020-912c0c60e0ab", + "registrationUrl": "https://spigot-regional.api.smartthings.com/filters/f5768ce8-c9e5-4507-9020-912c0c60e0ab/activate?filterRegion=eu-west-1", + "name": "My Home Assistant sub", + "version": 20250122, + "subscriptionFilters": [ + { + "type": "LOCATIONIDS", + "value": ["88a3a314-f0c8-40b4-bb44-44ba06c9c42e"], + "eventType": ["DEVICE_EVENT"], + "attribute": null, + "capability": null, + "component": null + } + ] +} diff --git a/tests/components/smartthings/test_config_flow.py b/tests/components/smartthings/test_config_flow.py index 7472d7d6b71..4069c201225 100644 --- a/tests/components/smartthings/test_config_flow.py +++ b/tests/components/smartthings/test_config_flow.py @@ -10,6 +10,7 @@ from homeassistant.components.smartthings.const import ( CONF_INSTALLED_APP_ID, CONF_LOCATION_ID, CONF_REFRESH_TOKEN, + CONF_SUBSCRIPTION_ID, DOMAIN, ) from homeassistant.config_entries import SOURCE_USER, ConfigEntryState @@ -508,6 +509,7 @@ async def test_migration( "installed_app_id": "123123123-2be1-4e40-b257-e4ef59083324", }, CONF_LOCATION_ID: "397678e5-9995-4a39-9d9f-ae6ba310236c", + CONF_SUBSCRIPTION_ID: "f5768ce8-c9e5-4507-9020-912c0c60e0ab", } assert mock_old_config_entry.unique_id == "397678e5-9995-4a39-9d9f-ae6ba310236c" assert mock_old_config_entry.version == 3 diff --git a/tests/components/smartthings/test_init.py b/tests/components/smartthings/test_init.py index e3d865fc5c8..2083bb7ea24 100644 --- a/tests/components/smartthings/test_init.py +++ b/tests/components/smartthings/test_init.py @@ -2,12 +2,21 @@ from unittest.mock import AsyncMock -from pysmartthings import Attribute, Capability, DeviceResponse, DeviceStatus +from pysmartthings import ( + Attribute, + Capability, + DeviceResponse, + DeviceStatus, + SmartThingsSinkError, +) +from pysmartthings.models import Subscription import pytest from syrupy import SnapshotAssertion from homeassistant.components.smartthings import EVENT_BUTTON -from homeassistant.components.smartthings.const import DOMAIN +from homeassistant.components.smartthings.const import CONF_SUBSCRIPTION_ID, DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import Event, HomeAssistant from homeassistant.helpers import device_registry as dr @@ -63,6 +72,178 @@ async def test_button_event( assert events[0] == snapshot +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) +async def test_create_subscription( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test creating a subscription.""" + assert CONF_SUBSCRIPTION_ID not in mock_config_entry.data + + await setup_integration(hass, mock_config_entry) + + devices.create_subscription.assert_called_once() + + assert ( + mock_config_entry.data[CONF_SUBSCRIPTION_ID] + == "f5768ce8-c9e5-4507-9020-912c0c60e0ab" + ) + + devices.subscribe.assert_called_once_with( + "397678e5-9995-4a39-9d9f-ae6ba310236c", + "5aaaa925-2be1-4e40-b257-e4ef59083324", + Subscription.from_json(load_fixture("subscription.json", DOMAIN)), + ) + + +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) +async def test_create_subscription_sink_error( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test handling an error when creating a subscription.""" + assert CONF_SUBSCRIPTION_ID not in mock_config_entry.data + + devices.create_subscription.side_effect = SmartThingsSinkError("Sink error") + + await setup_integration(hass, mock_config_entry) + + devices.subscribe.assert_not_called() + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + assert CONF_SUBSCRIPTION_ID not in mock_config_entry.data + + +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) +async def test_update_subscription_identifier( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test updating the subscription identifier.""" + await setup_integration(hass, mock_config_entry) + + assert ( + mock_config_entry.data[CONF_SUBSCRIPTION_ID] + == "f5768ce8-c9e5-4507-9020-912c0c60e0ab" + ) + + devices.new_subscription_id_callback("abc") + + await hass.async_block_till_done() + + assert mock_config_entry.data[CONF_SUBSCRIPTION_ID] == "abc" + + +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) +async def test_stale_subscription_id( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test updating the subscription identifier.""" + mock_config_entry.add_to_hass(hass) + + hass.config_entries.async_update_entry( + mock_config_entry, + data={**mock_config_entry.data, CONF_SUBSCRIPTION_ID: "test"}, + ) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert ( + mock_config_entry.data[CONF_SUBSCRIPTION_ID] + == "f5768ce8-c9e5-4507-9020-912c0c60e0ab" + ) + devices.delete_subscription.assert_called_once_with("test") + + +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) +async def test_remove_subscription_identifier( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test removing the subscription identifier.""" + await setup_integration(hass, mock_config_entry) + + assert ( + mock_config_entry.data[CONF_SUBSCRIPTION_ID] + == "f5768ce8-c9e5-4507-9020-912c0c60e0ab" + ) + + devices.new_subscription_id_callback(None) + + await hass.async_block_till_done() + + assert mock_config_entry.data[CONF_SUBSCRIPTION_ID] is None + + +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) +async def test_max_connections_handling( + hass: HomeAssistant, devices: AsyncMock, mock_config_entry: MockConfigEntry +) -> None: + """Test handling reaching max connections.""" + await setup_integration(hass, mock_config_entry) + + assert ( + mock_config_entry.data[CONF_SUBSCRIPTION_ID] + == "f5768ce8-c9e5-4507-9020-912c0c60e0ab" + ) + + devices.create_subscription.side_effect = SmartThingsSinkError("Sink error") + + devices.max_connections_reached_callback() + + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) +async def test_unloading( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test unloading the integration.""" + await setup_integration(hass, mock_config_entry) + + await hass.config_entries.async_unload(mock_config_entry.entry_id) + devices.delete_subscription.assert_called_once_with( + "f5768ce8-c9e5-4507-9020-912c0c60e0ab" + ) + # Deleting the subscription automatically deletes the subscription ID + devices.new_subscription_id_callback(None) + + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + assert mock_config_entry.data[CONF_SUBSCRIPTION_ID] is None + + +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) +async def test_shutdown( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test shutting down Home Assistant.""" + await setup_integration(hass, mock_config_entry) + + hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) + devices.delete_subscription.assert_called_once_with( + "f5768ce8-c9e5-4507-9020-912c0c60e0ab" + ) + # Deleting the subscription automatically deletes the subscription ID + devices.new_subscription_id_callback(None) + + assert mock_config_entry.state is ConfigEntryState.LOADED + assert mock_config_entry.data[CONF_SUBSCRIPTION_ID] is None + + @pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) async def test_removing_stale_devices( hass: HomeAssistant, From 3ce4f3f918be7aaaaf45660d2ec41bbaa279fb37 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 10 Mar 2025 14:40:08 +0100 Subject: [PATCH 2479/3148] Don't allow creating backups if Home Assistant is not running (#139499) * Don't allow creating backups if hass is not running * Revert "Don't allow creating backups if hass is not running" This reverts commit 1bf545eb25f20fc27fe161691a94531cba7e005c. * Set backup manager to idle only after Home Assistant has started * Update according to discussion, add tests * Add more test --- homeassistant/components/backup/manager.py | 21 ++++++- tests/components/backup/test_manager.py | 66 +++++++++++++++++++++- tests/components/hassio/conftest.py | 3 +- 3 files changed, 85 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index c8b515e3aee..872ea0d0e02 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -118,6 +118,7 @@ class BackupManagerState(StrEnum): IDLE = "idle" CREATE_BACKUP = "create_backup" + BLOCKED = "blocked" RECEIVE_BACKUP = "receive_backup" RESTORE_BACKUP = "restore_backup" @@ -226,6 +227,13 @@ class RestoreBackupEvent(ManagerStateEvent): state: RestoreBackupState +@dataclass(frozen=True, kw_only=True, slots=True) +class BlockedEvent(ManagerStateEvent): + """Backup manager blocked, Home Assistant is starting.""" + + manager_state: BackupManagerState = BackupManagerState.BLOCKED + + class BackupPlatformProtocol(Protocol): """Define the format that backup platforms can have.""" @@ -340,7 +348,7 @@ class BackupManager: self.remove_next_delete_event: Callable[[], None] | None = None # Latest backup event and backup event subscribers - self.last_event: ManagerStateEvent = IdleEvent() + self.last_event: ManagerStateEvent = BlockedEvent() self.last_non_idle_event: ManagerStateEvent | None = None self._backup_event_subscriptions = hass.data[ DATA_BACKUP @@ -354,10 +362,19 @@ class BackupManager: self.known_backups.load(stored["backups"]) await self._reader_writer.async_validate_config(config=self.config) + await self._reader_writer.async_resume_restore_progress_after_restart( on_progress=self.async_on_backup_event ) + async def set_manager_idle_after_start(hass: HomeAssistant) -> None: + """Set manager to idle after start.""" + self.async_on_backup_event(IdleEvent()) + + if self.state == BackupManagerState.BLOCKED: + # If we're not finishing a restore job, set the manager to idle after start + start.async_at_started(self.hass, set_manager_idle_after_start) + await self.load_platforms() @property @@ -1293,7 +1310,7 @@ class BackupManager: if (current_state := self.state) != (new_state := event.manager_state): LOGGER.debug("Backup state: %s -> %s", current_state, new_state) self.last_event = event - if not isinstance(event, IdleEvent): + if not isinstance(event, (BlockedEvent, IdleEvent)): self.last_non_idle_event = event for subscription in self._backup_event_subscriptions: subscription(event) diff --git a/tests/components/backup/test_manager.py b/tests/components/backup/test_manager.py index e4762f35327..41f98d6fa53 100644 --- a/tests/components/backup/test_manager.py +++ b/tests/components/backup/test_manager.py @@ -47,7 +47,8 @@ from homeassistant.components.backup.manager import ( WrittenBackup, ) from homeassistant.components.backup.util import password_to_key -from homeassistant.core import HomeAssistant +from homeassistant.const import EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STARTED +from homeassistant.core import CoreState, HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import issue_registry as ir @@ -3469,3 +3470,66 @@ async def test_restore_progress_after_restart_fail_to_remove( "Unexpected error deleting backup restore result file: Boom!" in caplog.text ) + + +async def test_manager_blocked_until_home_assistant_started( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test backup manager's state is blocked until Home Assistant has started.""" + + hass.set_state(CoreState.not_running) + + await setup_backup_integration(hass) + manager = hass.data[DATA_MANAGER] + + assert manager.state == BackupManagerState.BLOCKED + assert manager.last_non_idle_event is None + + # Fired when Home Assistant changes to starting state + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + await hass.async_block_till_done() + await hass.async_block_till_done() + assert manager.state == BackupManagerState.BLOCKED + assert manager.last_non_idle_event is None + + # Fired when Home Assistant changes to running state + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + assert manager.state == BackupManagerState.IDLE + assert manager.last_non_idle_event is None + + +async def test_manager_not_blocked_after_restore( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test restore backup progress after restart.""" + restore_result = {"error": None, "error_type": None, "success": True} + + hass.set_state(CoreState.not_running) + with patch( + "pathlib.Path.read_bytes", return_value=json.dumps(restore_result).encode() + ): + await setup_backup_integration(hass) + + ws_client = await hass_ws_client(hass) + await ws_client.send_json_auto_id({"type": "backup/info"}) + result = await ws_client.receive_json() + assert result["success"] is True + assert result["result"] == { + "agent_errors": {}, + "backups": [], + "last_attempted_automatic_backup": None, + "last_completed_automatic_backup": None, + "last_non_idle_event": { + "manager_state": "restore_backup", + "reason": None, + "stage": None, + "state": "completed", + }, + "next_automatic_backup": None, + "next_automatic_backup_additional": False, + "state": "idle", + } diff --git a/tests/components/hassio/conftest.py b/tests/components/hassio/conftest.py index 7075b9d6982..c9fbf1a7c56 100644 --- a/tests/components/hassio/conftest.py +++ b/tests/components/hassio/conftest.py @@ -11,7 +11,7 @@ import pytest from homeassistant.auth.models import RefreshToken from homeassistant.components.hassio.handler import HassIO, HassioAPIError -from homeassistant.core import CoreState, HomeAssistant +from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.setup import async_setup_component @@ -75,7 +75,6 @@ def hassio_stubs( "homeassistant.components.hassio.issues.SupervisorIssues.setup", ), ): - hass.set_state(CoreState.starting) hass.loop.run_until_complete(async_setup_component(hass, "hassio", {})) return hass_api.call_args[0][1] From 91cf8cb5474782af6367dba5df1bf04fd54d7b02 Mon Sep 17 00:00:00 2001 From: Evan Farrell Date: Fri, 7 Mar 2025 16:15:22 -0500 Subject: [PATCH 2480/3148] Bump govee_ble to 0.43.1 (#139862) Bump govee_ble to 0.43.0 --- homeassistant/components/govee_ble/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/govee_ble/manifest.json b/homeassistant/components/govee_ble/manifest.json index 1c61ae31010..b06dab243af 100644 --- a/homeassistant/components/govee_ble/manifest.json +++ b/homeassistant/components/govee_ble/manifest.json @@ -135,5 +135,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/govee_ble", "iot_class": "local_push", - "requirements": ["govee-ble==0.43.0"] + "requirements": ["govee-ble==0.43.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 0d1a593aebb..92202d7fad5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1058,7 +1058,7 @@ goslide-api==0.7.0 gotailwind==0.3.0 # homeassistant.components.govee_ble -govee-ble==0.43.0 +govee-ble==0.43.1 # homeassistant.components.govee_light_local govee-local-api==2.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b42d31188bf..cec4cafae5d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -908,7 +908,7 @@ goslide-api==0.7.0 gotailwind==0.3.0 # homeassistant.components.govee_ble -govee-ble==0.43.0 +govee-ble==0.43.1 # homeassistant.components.govee_light_local govee-local-api==2.0.1 From 95fd096bdd39eec87d3f15ebdc806c7eae018d7e Mon Sep 17 00:00:00 2001 From: John Hillery <34005807+jrhillery@users.noreply.github.com> Date: Sat, 8 Mar 2025 13:22:26 -0500 Subject: [PATCH 2481/3148] Label emergency heat switch (#139872) * Add label to emergency heat switch * Use sentence case names Co-authored-by: Franck Nijhof --------- Co-authored-by: Franck Nijhof --- homeassistant/components/nexia/strings.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/nexia/strings.json b/homeassistant/components/nexia/strings.json index d88ce0b898d..05d86d3a495 100644 --- a/homeassistant/components/nexia/strings.json +++ b/homeassistant/components/nexia/strings.json @@ -58,6 +58,9 @@ "switch": { "hold": { "name": "Hold" + }, + "emergency_heat": { + "name": "Emergency heat" } } }, From cab4890246954e3b5edc576580ddbd303d722c5f Mon Sep 17 00:00:00 2001 From: Keilin Bickar Date: Fri, 7 Mar 2025 14:29:11 -0500 Subject: [PATCH 2482/3148] Bump sense-energy lib to 0.13.7 (#140068) --- homeassistant/components/emulated_kasa/manifest.json | 2 +- homeassistant/components/sense/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/emulated_kasa/manifest.json b/homeassistant/components/emulated_kasa/manifest.json index d607372136c..fc54fb50064 100644 --- a/homeassistant/components/emulated_kasa/manifest.json +++ b/homeassistant/components/emulated_kasa/manifest.json @@ -6,5 +6,5 @@ "iot_class": "local_push", "loggers": ["sense_energy"], "quality_scale": "internal", - "requirements": ["sense-energy==0.13.6"] + "requirements": ["sense-energy==0.13.7"] } diff --git a/homeassistant/components/sense/manifest.json b/homeassistant/components/sense/manifest.json index dda49b661e5..0a21dbf4cc3 100644 --- a/homeassistant/components/sense/manifest.json +++ b/homeassistant/components/sense/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/sense", "iot_class": "cloud_polling", "loggers": ["sense_energy"], - "requirements": ["sense-energy==0.13.6"] + "requirements": ["sense-energy==0.13.7"] } diff --git a/requirements_all.txt b/requirements_all.txt index 92202d7fad5..b1247220b07 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2694,7 +2694,7 @@ sendgrid==6.8.2 # homeassistant.components.emulated_kasa # homeassistant.components.sense -sense-energy==0.13.6 +sense-energy==0.13.7 # homeassistant.components.sensirion_ble sensirion-ble==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index cec4cafae5d..7794e6319fd 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2173,7 +2173,7 @@ securetar==2025.2.1 # homeassistant.components.emulated_kasa # homeassistant.components.sense -sense-energy==0.13.6 +sense-energy==0.13.7 # homeassistant.components.sensirion_ble sensirion-ble==0.1.1 From 227f3cea25143169348f0505a3b27485fceb7adf Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 7 Mar 2025 20:35:36 +0100 Subject: [PATCH 2483/3148] Update jinja to 3.1.6 (#140069) --- 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 cda2665dcf3..02d635007a5 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -41,7 +41,7 @@ home-assistant-frontend==20250306.0 home-assistant-intents==2025.3.5 httpx==0.28.1 ifaddr==0.2.0 -Jinja2==3.1.5 +Jinja2==3.1.6 lru-dict==1.3.0 mutagen==1.47.0 orjson==3.10.12 diff --git a/pyproject.toml b/pyproject.toml index 12aec7e8f39..27b029acf45 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,7 +52,7 @@ dependencies = [ "httpx==0.28.1", "home-assistant-bluetooth==1.13.1", "ifaddr==0.2.0", - "Jinja2==3.1.5", + "Jinja2==3.1.6", "lru-dict==1.3.0", "PyJWT==2.10.1", # PyJWT has loose dependency. We want the latest one. diff --git a/requirements.txt b/requirements.txt index b378688106d..20fd6f3dfb8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -25,7 +25,7 @@ hass-nabucasa==0.92.0 httpx==0.28.1 home-assistant-bluetooth==1.13.1 ifaddr==0.2.0 -Jinja2==3.1.5 +Jinja2==3.1.6 lru-dict==1.3.0 PyJWT==2.10.1 cryptography==44.0.1 From a78e9039c6d9ec16ad04128fba3bd22f0ed913c3 Mon Sep 17 00:00:00 2001 From: David Bonnes Date: Fri, 7 Mar 2025 23:17:29 +0000 Subject: [PATCH 2484/3148] Update evohome-async to 1.0.3 (#140083) bump client to 1.0.3 --- homeassistant/components/evohome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/evohome/manifest.json b/homeassistant/components/evohome/manifest.json index 823ad7be5df..700872ef92b 100644 --- a/homeassistant/components/evohome/manifest.json +++ b/homeassistant/components/evohome/manifest.json @@ -6,5 +6,5 @@ "iot_class": "cloud_polling", "loggers": ["evohome", "evohomeasync", "evohomeasync2"], "quality_scale": "legacy", - "requirements": ["evohome-async==1.0.2"] + "requirements": ["evohome-async==1.0.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index b1247220b07..5ff2ee495a5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -899,7 +899,7 @@ eufylife-ble-client==0.1.8 # evdev==1.6.1 # homeassistant.components.evohome -evohome-async==1.0.2 +evohome-async==1.0.3 # homeassistant.components.bryant_evolution evolutionhttp==0.0.18 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7794e6319fd..30e3c6c1325 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -765,7 +765,7 @@ eternalegypt==0.0.16 eufylife-ble-client==0.1.8 # homeassistant.components.evohome -evohome-async==1.0.2 +evohome-async==1.0.3 # homeassistant.components.bryant_evolution evolutionhttp==0.0.18 From 5cfaeda95b8eafec3e02e5f66fbec9a6d5e0bea9 Mon Sep 17 00:00:00 2001 From: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> Date: Fri, 7 Mar 2025 22:31:32 -0600 Subject: [PATCH 2485/3148] Fix HEOS discovery error when previously ignored (#140091) Abort ignored discovery --- homeassistant/components/heos/config_flow.py | 13 ++++++++--- tests/components/heos/test_config_flow.py | 23 +++++++++++++++++++- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/heos/config_flow.py b/homeassistant/components/heos/config_flow.py index a2f9671c94b..f1cd11f0914 100644 --- a/homeassistant/components/heos/config_flow.py +++ b/homeassistant/components/heos/config_flow.py @@ -14,7 +14,12 @@ from pyheos import ( ) import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow +from homeassistant.config_entries import ( + SOURCE_IGNORE, + ConfigFlow, + ConfigFlowResult, + OptionsFlow, +) from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import callback from homeassistant.helpers import selector @@ -141,8 +146,10 @@ class HeosFlowHandler(ConfigFlow, domain=DOMAIN): hostname = urlparse(discovery_info.ssdp_location).hostname assert hostname is not None - # Abort early when discovered host is part of the current system - if entry and hostname in _get_current_hosts(entry): + # Abort early when discovery is ignored or host is part of the current system + if entry and ( + entry.source == SOURCE_IGNORE or hostname in _get_current_hosts(entry) + ): return self.async_abort(reason="single_instance_allowed") # Connect to discovered host and get system information diff --git a/tests/components/heos/test_config_flow.py b/tests/components/heos/test_config_flow.py index 396c3743663..69df3734690 100644 --- a/tests/components/heos/test_config_flow.py +++ b/tests/components/heos/test_config_flow.py @@ -14,7 +14,12 @@ from pyheos import ( import pytest from homeassistant.components.heos.const import DOMAIN -from homeassistant.config_entries import SOURCE_SSDP, SOURCE_USER, ConfigEntryState +from homeassistant.config_entries import ( + SOURCE_IGNORE, + SOURCE_SSDP, + SOURCE_USER, + ConfigEntryState, +) from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -160,6 +165,22 @@ async def test_discovery_aborts_same_system( assert config_entry.data[CONF_HOST] == "127.0.0.1" +async def test_discovery_ignored_aborts( + hass: HomeAssistant, + discovery_data: SsdpServiceInfo, +) -> None: + """Test discovery aborts when ignored.""" + MockConfigEntry(domain=DOMAIN, unique_id=DOMAIN, source=SOURCE_IGNORE).add_to_hass( + hass + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_SSDP}, data=discovery_data + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "single_instance_allowed" + + async def test_discovery_fails_to_connect_aborts( hass: HomeAssistant, discovery_data: SsdpServiceInfo, controller: MockHeos ) -> None: From 7336c8fc0755a91c77e71f176592c6386c232874 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 8 Mar 2025 10:57:25 +0100 Subject: [PATCH 2486/3148] Map prewash job state in SmartThings (#140097) --- homeassistant/components/smartthings/sensor.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index a0b39917c71..438b0e805b1 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -57,6 +57,7 @@ JOB_STATE_MAP = { "freezeProtection": "freeze_protection", "preDrain": "pre_drain", "preWash": "pre_wash", + "prewash": "pre_wash", "wrinklePrevent": "wrinkle_prevent", "unknown": None, } From faf9977abb29f5834e52173cf622e3028736ae57 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 8 Mar 2025 11:22:09 +0100 Subject: [PATCH 2487/3148] Check support for thermostat operating state in SmartThings (#140103) --- .../components/smartthings/climate.py | 2 + tests/components/smartthings/conftest.py | 1 + .../bosch_radiator_thermostat_ii.json | 89 +++++++++++++++ .../devices/bosch_radiator_thermostat_ii.json | 102 ++++++++++++++++++ .../smartthings/snapshots/test_climate.ambr | 63 +++++++++++ .../smartthings/snapshots/test_init.ambr | 33 ++++++ .../smartthings/snapshots/test_sensor.ambr | 101 +++++++++++++++++ 7 files changed, 391 insertions(+) create mode 100644 tests/components/smartthings/fixtures/device_status/bosch_radiator_thermostat_ii.json create mode 100644 tests/components/smartthings/fixtures/devices/bosch_radiator_thermostat_ii.json diff --git a/homeassistant/components/smartthings/climate.py b/homeassistant/components/smartthings/climate.py index b634321fe43..8abc0b4a590 100644 --- a/homeassistant/components/smartthings/climate.py +++ b/homeassistant/components/smartthings/climate.py @@ -251,6 +251,8 @@ class SmartThingsThermostat(SmartThingsEntity, ClimateEntity): @property def hvac_action(self) -> HVACAction | None: """Return the current running hvac operation if supported.""" + if not self.supports_capability(Capability.THERMOSTAT_OPERATING_STATE): + return None return OPERATING_STATE_TO_ACTION.get( self.get_attribute_value( Capability.THERMOSTAT_OPERATING_STATE, diff --git a/tests/components/smartthings/conftest.py b/tests/components/smartthings/conftest.py index d60099e8e76..131308c687f 100644 --- a/tests/components/smartthings/conftest.py +++ b/tests/components/smartthings/conftest.py @@ -118,6 +118,7 @@ def mock_smartthings() -> Generator[AsyncMock]: "fake_fan", "generic_fan_3_speed", "heatit_ztrm3_thermostat", + "bosch_radiator_thermostat_ii", ] ) def device_fixture( diff --git a/tests/components/smartthings/fixtures/device_status/bosch_radiator_thermostat_ii.json b/tests/components/smartthings/fixtures/device_status/bosch_radiator_thermostat_ii.json new file mode 100644 index 00000000000..6248eb05e93 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/bosch_radiator_thermostat_ii.json @@ -0,0 +1,89 @@ +{ + "components": { + "main": { + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": 23.9, + "unit": "C", + "timestamp": "2025-03-07T19:55:13.328Z" + } + }, + "thermostatHeatingSetpoint": { + "heatingSetpoint": { + "value": 22.0, + "unit": "C", + "timestamp": "2025-03-05T03:05:26.510Z" + }, + "heatingSetpointRange": { + "value": { + "minimum": 5.0, + "maximum": 40.0, + "step": 0.1 + }, + "unit": "C", + "timestamp": "2025-03-05T03:05:26.510Z" + } + }, + "refresh": {}, + "thermostatMode": { + "thermostatMode": { + "value": "heat", + "data": { + "supportedThermostatModes": ["off", "heat"] + }, + "timestamp": "2025-03-05T03:05:26.489Z" + }, + "supportedThermostatModes": { + "value": ["off", "heat"], + "timestamp": "2025-03-05T03:05:26.509Z" + } + }, + "battery": { + "quantity": { + "value": null + }, + "battery": { + "value": 94, + "unit": "%", + "timestamp": "2025-03-07T20:47:27.362Z" + }, + "type": { + "value": null + } + }, + "firmwareUpdate": { + "lastUpdateStatusReason": { + "value": null + }, + "availableVersion": { + "value": "2.00.09 (20009)", + "timestamp": "2024-11-29T19:55:02.005Z" + }, + "lastUpdateStatus": { + "value": null + }, + "supportedCommands": { + "value": null + }, + "state": { + "value": "normalOperation", + "timestamp": "2024-11-29T19:55:02.009Z" + }, + "updateAvailable": { + "value": false, + "timestamp": "2024-11-29T19:55:02.004Z" + }, + "currentVersion": { + "value": "2.00.09 (20009)", + "timestamp": "2024-11-29T19:55:02.037Z" + }, + "lastUpdateTime": { + "value": null + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/devices/bosch_radiator_thermostat_ii.json b/tests/components/smartthings/fixtures/devices/bosch_radiator_thermostat_ii.json new file mode 100644 index 00000000000..7a2e2d338cd --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/bosch_radiator_thermostat_ii.json @@ -0,0 +1,102 @@ +{ + "items": [ + { + "deviceId": "286ba274-4093-4bcb-849c-a1a3efe7b1e5", + "name": "thermostat", + "label": "Radiator Thermostat II [+M] Wohnzimmer", + "manufacturerName": "SmartThingsCommunity", + "presentationId": "2a1c9915-f61b-3f3a-a02b-703b8cccf3d6", + "deviceManufacturerCode": "BOSCH", + "locationId": "0b6618a6-c3ab-4b6e-968d-59cc8c2761bc", + "ownerId": "8a20b799-9d87-ecdc-39de-c93c6e4d3ea1", + "roomId": "11374ab5-9b4e-416b-91d1-745bbf9b6db4", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "thermostatMode", + "version": 1 + }, + { + "id": "thermostatHeatingSetpoint", + "version": 1 + }, + { + "id": "battery", + "version": 1 + }, + { + "id": "firmwareUpdate", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + } + ], + "categories": [ + { + "name": "Thermostat", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2024-11-29T19:55:00.910Z", + "parentDeviceId": "61bd280e-71c4-44fb-9b6e-53fdf14718a2", + "profile": { + "id": "4da5d086-111e-3084-a039-616974326833" + }, + "matter": { + "driverId": "5f3c42eb-5704-4c95-9705-c51c1a6764bf", + "hubId": "61bd280e-71c4-44fb-9b6e-53fdf14718a2", + "provisioningState": "PROVISIONED", + "networkId": "8EF2CF7A212285B2-46C6B9F266A4521A", + "executingLocally": true, + "uniqueId": "8475B3FEFF6748D4", + "vendorId": 4617, + "productId": 12306, + "serialNumber": "D44867FFFEB37584", + "listeningType": "SLEEPY", + "supportedNetworkInterfaces": ["THREAD"], + "version": { + "hardware": 18, + "hardwareLabel": "1.2.0", + "software": 20009, + "softwareLabel": "2.00.09" + }, + "endpoints": [ + { + "endpointId": 0, + "deviceTypes": [ + { + "deviceTypeId": 22 + } + ] + }, + { + "endpointId": 1, + "deviceTypes": [ + { + "deviceTypeId": 769 + } + ] + } + ], + "syncDrivers": true + }, + "type": "MATTER", + "restrictionTier": 0, + "allowed": null, + "executionContext": "LOCAL", + "relationships": [] + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/snapshots/test_climate.ambr b/tests/components/smartthings/snapshots/test_climate.ambr index c85c7af19a6..4d3fd15aeb9 100644 --- a/tests/components/smartthings/snapshots/test_climate.ambr +++ b/tests/components/smartthings/snapshots/test_climate.ambr @@ -1,4 +1,67 @@ # serializer version: 1 +# name: test_all_entities[bosch_radiator_thermostat_ii][climate.radiator_thermostat_ii_m_wohnzimmer-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + ]), + 'max_temp': 35, + 'min_temp': 7, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.radiator_thermostat_ii_m_wohnzimmer', + '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': None, + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '286ba274-4093-4bcb-849c-a1a3efe7b1e5', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[bosch_radiator_thermostat_ii][climate.radiator_thermostat_ii_m_wohnzimmer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 23.9, + 'friendly_name': 'Radiator Thermostat II [+M] Wohnzimmer', + 'hvac_modes': list([ + , + ]), + 'max_temp': 35, + 'min_temp': 7, + 'supported_features': , + 'target_temp_high': None, + 'target_temp_low': None, + 'temperature': 22.0, + }), + 'context': , + 'entity_id': 'climate.radiator_thermostat_ii_m_wohnzimmer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }) +# --- # name: test_all_entities[da_ac_rac_000001][climate.ac_office_granit-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/smartthings/snapshots/test_init.ambr b/tests/components/smartthings/snapshots/test_init.ambr index e25abf918cd..5342830e4ca 100644 --- a/tests/components/smartthings/snapshots/test_init.ambr +++ b/tests/components/smartthings/snapshots/test_init.ambr @@ -68,6 +68,39 @@ 'via_device_id': None, }) # --- +# name: test_devices[bosch_radiator_thermostat_ii] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '286ba274-4093-4bcb-849c-a1a3efe7b1e5', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': None, + 'model': None, + 'model_id': None, + 'name': 'Radiator Thermostat II [+M] Wohnzimmer', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- # name: test_devices[c2c_arlo_pro_3_switch] DeviceRegistryEntrySnapshot({ 'area_id': None, diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index 017689f13fd..cb282e24b27 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -258,6 +258,107 @@ 'state': '938.3', }) # --- +# name: test_all_entities[bosch_radiator_thermostat_ii][sensor.radiator_thermostat_ii_m_wohnzimmer_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.radiator_thermostat_ii_m_wohnzimmer_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': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '286ba274-4093-4bcb-849c-a1a3efe7b1e5.battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[bosch_radiator_thermostat_ii][sensor.radiator_thermostat_ii_m_wohnzimmer_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Radiator Thermostat II [+M] Wohnzimmer Battery', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.radiator_thermostat_ii_m_wohnzimmer_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '94', + }) +# --- +# name: test_all_entities[bosch_radiator_thermostat_ii][sensor.radiator_thermostat_ii_m_wohnzimmer_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.radiator_thermostat_ii_m_wohnzimmer_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': 'Temperature', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '286ba274-4093-4bcb-849c-a1a3efe7b1e5.temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[bosch_radiator_thermostat_ii][sensor.radiator_thermostat_ii_m_wohnzimmer_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Radiator Thermostat II [+M] Wohnzimmer Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.radiator_thermostat_ii_m_wohnzimmer_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '23.9', + }) +# --- # name: test_all_entities[c2c_arlo_pro_3_switch][sensor.2nd_floor_hallway_alarm-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From fc53322c07ff2fdc88027cac20a7e7d847a3cb54 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 8 Mar 2025 14:59:10 +0100 Subject: [PATCH 2488/3148] Handle None options in SmartThings (#140110) * Handle None options in SmartThings * Handle None options in SmartThings --- .../components/smartthings/sensor.py | 9 +- tests/components/smartthings/conftest.py | 1 + .../device_status/im_speaker_ai_0001.json | 222 +++++++++++++++ .../fixtures/devices/im_speaker_ai_0001.json | 136 ++++++++++ .../smartthings/snapshots/test_init.ambr | 33 +++ .../smartthings/snapshots/test_sensor.ambr | 255 ++++++++++++++++++ 6 files changed, 653 insertions(+), 3 deletions(-) create mode 100644 tests/components/smartthings/fixtures/device_status/im_speaker_ai_0001.json create mode 100644 tests/components/smartthings/fixtures/devices/im_speaker_ai_0001.json diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index 438b0e805b1..3e6a7c20533 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -1023,8 +1023,11 @@ class SmartThingsSensor(SmartThingsEntity, SensorEntity): def options(self) -> list[str] | None: """Return the options for this sensor.""" if self.entity_description.options_attribute: - options = self.get_attribute_value( - self.capability, self.entity_description.options_attribute - ) + if ( + options := self.get_attribute_value( + self.capability, self.entity_description.options_attribute + ) + ) is None: + return [] return [option.lower() for option in options] return super().options diff --git a/tests/components/smartthings/conftest.py b/tests/components/smartthings/conftest.py index 131308c687f..089fc472d59 100644 --- a/tests/components/smartthings/conftest.py +++ b/tests/components/smartthings/conftest.py @@ -119,6 +119,7 @@ def mock_smartthings() -> Generator[AsyncMock]: "generic_fan_3_speed", "heatit_ztrm3_thermostat", "bosch_radiator_thermostat_ii", + "im_speaker_ai_0001", ] ) def device_fixture( diff --git a/tests/components/smartthings/fixtures/device_status/im_speaker_ai_0001.json b/tests/components/smartthings/fixtures/device_status/im_speaker_ai_0001.json new file mode 100644 index 00000000000..4b23ca7086f --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/im_speaker_ai_0001.json @@ -0,0 +1,222 @@ +{ + "components": { + "main": { + "mediaPlayback": { + "supportedPlaybackCommands": { + "value": ["play", "pause", "stop"], + "timestamp": "2025-03-08T12:06:24.496Z" + }, + "playbackStatus": { + "value": "stopped", + "timestamp": "2025-03-08T12:06:24.496Z" + } + }, + "audioVolume": { + "volume": { + "value": 52, + "unit": "%", + "timestamp": "2025-03-08T12:08:00.153Z" + } + }, + "mediaInputSource": { + "supportedInputSources": { + "value": null + }, + "inputSource": { + "value": null + } + }, + "audioTrackAddressing": {}, + "samsungim.networkAudioGroupInfo": { + "groupName": { + "value": "", + "timestamp": "2025-03-08T12:06:24.628Z" + }, + "role": { + "value": "", + "timestamp": "2025-03-08T12:06:24.628Z" + }, + "channel": { + "value": "", + "timestamp": "2025-03-08T12:06:24.628Z" + }, + "stereoType": { + "value": "A", + "timestamp": "2025-03-08T12:06:24.628Z" + }, + "masterDi": { + "value": "", + "timestamp": "2025-03-08T12:06:24.628Z" + }, + "acmMode": { + "value": "", + "timestamp": "2025-03-08T12:06:24.628Z" + }, + "status": { + "value": "single", + "timestamp": "2025-03-08T12:06:24.628Z" + }, + "masterName": { + "value": "", + "timestamp": "2025-03-08T12:06:24.628Z" + } + }, + "refresh": {}, + "audioNotification": {}, + "execute": { + "data": { + "value": null + } + }, + "samsungim.networkAudioMode": { + "mode": { + "value": "wifi", + "timestamp": "2025-03-08T12:06:24.573Z" + } + }, + "mediaPlaybackRepeat": { + "playbackRepeatMode": { + "value": "off", + "timestamp": "2025-03-08T12:06:24.519Z" + } + }, + "musicPlayer": { + "trackDescription": { + "value": null + }, + "level": { + "value": null + }, + "mute": { + "value": null + }, + "trackData": { + "value": null + }, + "status": { + "value": null + } + }, + "ocf": { + "st": { + "value": null + }, + "mndt": { + "value": null + }, + "mnfv": { + "value": "V310XXU1AWK1", + "timestamp": "2025-03-08T12:06:18.931Z" + }, + "mnhw": { + "value": "1.0", + "timestamp": "2025-03-08T12:06:18.931Z" + }, + "di": { + "value": "c9276e43-fe3c-88c3-1dcc-2eb79e292b8c", + "timestamp": "2025-03-08T12:06:18.931Z" + }, + "mnsl": { + "value": null + }, + "dmv": { + "value": "IoTivity1.2.1", + "timestamp": "2025-03-08T12:06:18.942Z" + }, + "n": { + "value": "Galaxy Home Mini (MQVL)", + "timestamp": "2025-03-08T12:06:18.931Z" + }, + "mnmo": { + "value": "SM-V310", + "timestamp": "2025-03-08T12:06:18.931Z" + }, + "vid": { + "value": "IM-SPEAKER-AI-0001", + "timestamp": "2025-03-08T12:06:18.931Z" + }, + "mnmn": { + "value": "Samsung Electronics", + "timestamp": "2025-03-08T12:06:18.931Z" + }, + "mnml": { + "value": null + }, + "mnpv": { + "value": "4.0.0.1", + "timestamp": "2025-03-08T12:06:18.931Z" + }, + "mnos": { + "value": "Tizen", + "timestamp": "2025-03-08T12:06:18.931Z" + }, + "pi": { + "value": "c9276e43-fe3c-88c3-1dcc-2eb79e292b8c", + "timestamp": "2025-03-08T12:06:18.931Z" + }, + "icv": { + "value": "core0.0.1", + "timestamp": "2025-03-08T12:06:18.942Z" + } + }, + "samsungim.announcement": { + "enableState": { + "value": null + }, + "supportedCategories": { + "value": null + }, + "supportedTypes": { + "value": null + }, + "supportedMimes": { + "value": null + } + }, + "samsungim.bixbyContent": { + "supportedModes": { + "value": ["news", "weather", "music", "search_all"], + "timestamp": "2025-03-08T12:06:24.817Z" + } + }, + "mediaPlaybackShuffle": { + "playbackShuffle": { + "value": "disabled", + "timestamp": "2025-03-08T12:06:24.592Z" + } + }, + "audioMute": { + "mute": { + "value": "unmuted", + "timestamp": "2025-03-08T12:06:24.478Z" + } + }, + "mediaTrackControl": { + "supportedTrackControlCommands": { + "value": null + } + }, + "speechSynthesis": {}, + "samsungim.networkAudioTrackData": { + "appName": { + "value": null + }, + "source": { + "value": "wifi", + "timestamp": "2025-03-08T12:06:24.540Z" + } + }, + "audioTrackData": { + "totalTime": { + "value": null + }, + "audioTrackData": { + "value": null + }, + "elapsedTime": { + "value": null + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/devices/im_speaker_ai_0001.json b/tests/components/smartthings/fixtures/devices/im_speaker_ai_0001.json new file mode 100644 index 00000000000..81fb1b07ff2 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/im_speaker_ai_0001.json @@ -0,0 +1,136 @@ +{ + "items": [ + { + "deviceId": "c9276e43-fe3c-88c3-1dcc-2eb79e292b8c", + "name": "Galaxy Home Mini (MQVL)", + "label": "Galaxy Home Mini", + "manufacturerName": "Samsung Electronics", + "presentationId": "IM-SPEAKER-AI-0001", + "deviceManufacturerCode": "Samsung Electronics", + "locationId": "33db9e71-abe9-43a0-acd3-3f0927bbe5b7", + "ownerId": "9a1ee192-04ba-46ca-9c3d-196d8dbcf807", + "roomId": "445c006d-1796-4dd6-8308-1c3cd045e8ff", + "deviceTypeName": "Samsung OCF Network Audio Player", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "execute", + "version": 1 + }, + { + "id": "ocf", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "audioMute", + "version": 1 + }, + { + "id": "audioVolume", + "version": 1 + }, + { + "id": "mediaInputSource", + "version": 1 + }, + { + "id": "mediaPlaybackRepeat", + "version": 1 + }, + { + "id": "mediaPlaybackShuffle", + "version": 1 + }, + { + "id": "mediaPlayback", + "version": 1 + }, + { + "id": "mediaTrackControl", + "version": 1 + }, + { + "id": "audioTrackAddressing", + "version": 1 + }, + { + "id": "audioTrackData", + "version": 1 + }, + { + "id": "musicPlayer", + "version": 1 + }, + { + "id": "audioNotification", + "version": 1 + }, + { + "id": "speechSynthesis", + "version": 1 + }, + { + "id": "samsungim.bixbyContent", + "version": 1 + }, + { + "id": "samsungim.announcement", + "version": 1 + }, + { + "id": "samsungim.networkAudioMode", + "version": 1 + }, + { + "id": "samsungim.networkAudioGroupInfo", + "version": 1 + }, + { + "id": "samsungim.networkAudioTrackData", + "version": 1 + } + ], + "categories": [ + { + "name": "NetworkAudio", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2025-03-08T12:06:18.865Z", + "profile": { + "id": "09df8e36-e94f-339c-9086-9639505e1fb2" + }, + "ocf": { + "ocfDeviceType": "oic.d.networkaudio", + "name": "Galaxy Home Mini (MQVL)", + "specVersion": "core0.0.1", + "verticalDomainSpecVersion": "IoTivity1.2.1", + "manufacturerName": "Samsung Electronics", + "modelNumber": "SM-V310", + "platformVersion": "4.0.0.1", + "platformOS": "Tizen", + "hwVersion": "1.0", + "firmwareVersion": "V310XXU1AWK1", + "vendorId": "IM-SPEAKER-AI-0001", + "lastSignupTime": "2025-03-08T12:06:16.386696652Z", + "transferCandidate": false, + "additionalAuthCodeRequired": false + }, + "type": "OCF", + "restrictionTier": 0, + "allowed": null, + "executionContext": "CLOUD", + "relationships": [] + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/snapshots/test_init.ambr b/tests/components/smartthings/snapshots/test_init.ambr index 5342830e4ca..04857d371fd 100644 --- a/tests/components/smartthings/snapshots/test_init.ambr +++ b/tests/components/smartthings/snapshots/test_init.ambr @@ -827,6 +827,39 @@ 'via_device_id': None, }) # --- +# name: test_devices[im_speaker_ai_0001] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '1.0', + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + 'c9276e43-fe3c-88c3-1dcc-2eb79e292b8c', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Samsung Electronics', + 'model': 'SM-V310', + 'model_id': None, + 'name': 'Galaxy Home Mini', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': 'V310XXU1AWK1', + 'via_device_id': None, + }) +# --- # name: test_devices[iphone] DeviceRegistryEntrySnapshot({ 'area_id': None, diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index cb282e24b27..d8146f3dc66 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -4781,6 +4781,261 @@ 'state': '19.0', }) # --- +# name: test_all_entities[im_speaker_ai_0001][sensor.galaxy_home_mini_media_input_source-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.galaxy_home_mini_media_input_source', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Media input source', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'media_input_source', + 'unique_id': 'c9276e43-fe3c-88c3-1dcc-2eb79e292b8c.inputSource', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[im_speaker_ai_0001][sensor.galaxy_home_mini_media_input_source-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Galaxy Home Mini Media input source', + }), + 'context': , + 'entity_id': 'sensor.galaxy_home_mini_media_input_source', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[im_speaker_ai_0001][sensor.galaxy_home_mini_media_playback_repeat-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.galaxy_home_mini_media_playback_repeat', + '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': 'Media playback repeat', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'media_playback_repeat', + 'unique_id': 'c9276e43-fe3c-88c3-1dcc-2eb79e292b8c.playbackRepeatMode', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[im_speaker_ai_0001][sensor.galaxy_home_mini_media_playback_repeat-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Galaxy Home Mini Media playback repeat', + }), + 'context': , + 'entity_id': 'sensor.galaxy_home_mini_media_playback_repeat', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[im_speaker_ai_0001][sensor.galaxy_home_mini_media_playback_shuffle-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.galaxy_home_mini_media_playback_shuffle', + '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': 'Media playback shuffle', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'media_playback_shuffle', + 'unique_id': 'c9276e43-fe3c-88c3-1dcc-2eb79e292b8c.playbackShuffle', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[im_speaker_ai_0001][sensor.galaxy_home_mini_media_playback_shuffle-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Galaxy Home Mini Media playback shuffle', + }), + 'context': , + 'entity_id': 'sensor.galaxy_home_mini_media_playback_shuffle', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'disabled', + }) +# --- +# name: test_all_entities[im_speaker_ai_0001][sensor.galaxy_home_mini_media_playback_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'paused', + 'playing', + 'stopped', + 'fast_forwarding', + 'rewinding', + 'buffering', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.galaxy_home_mini_media_playback_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Media playback status', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'media_playback_status', + 'unique_id': 'c9276e43-fe3c-88c3-1dcc-2eb79e292b8c.playbackStatus', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[im_speaker_ai_0001][sensor.galaxy_home_mini_media_playback_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Galaxy Home Mini Media playback status', + 'options': list([ + 'paused', + 'playing', + 'stopped', + 'fast_forwarding', + 'rewinding', + 'buffering', + ]), + }), + 'context': , + 'entity_id': 'sensor.galaxy_home_mini_media_playback_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'stopped', + }) +# --- +# name: test_all_entities[im_speaker_ai_0001][sensor.galaxy_home_mini_volume-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.galaxy_home_mini_volume', + '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': 'Volume', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'audio_volume', + 'unique_id': 'c9276e43-fe3c-88c3-1dcc-2eb79e292b8c.volume', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[im_speaker_ai_0001][sensor.galaxy_home_mini_volume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Galaxy Home Mini Volume', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.galaxy_home_mini_volume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '52', + }) +# --- # name: test_all_entities[multipurpose_sensor][sensor.deck_door_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From fd2dee3c11649fd4193aab4247519c59dfe64062 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sat, 8 Mar 2025 20:15:56 +0100 Subject: [PATCH 2489/3148] Fix MQTT JSON light not reporting color temp status if color is not supported (#140113) --- .../components/mqtt/light/schema_json.py | 3 +- tests/components/mqtt/test_light_json.py | 59 +++++++++++++++++++ 2 files changed, 60 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/mqtt/light/schema_json.py b/homeassistant/components/mqtt/light/schema_json.py index 4473385d550..d18da9e917a 100644 --- a/homeassistant/components/mqtt/light/schema_json.py +++ b/homeassistant/components/mqtt/light/schema_json.py @@ -31,7 +31,6 @@ from homeassistant.components.light import ( LightEntity, LightEntityFeature, brightness_supported, - color_supported, valid_supported_color_modes, ) from homeassistant.const import ( @@ -293,7 +292,7 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity): elif values["state"] is None: self._attr_is_on = None - if color_supported(self.supported_color_modes) and "color_mode" in values: + if "color_mode" in values: self._update_color(values) if brightness_supported(self.supported_color_modes): diff --git a/tests/components/mqtt/test_light_json.py b/tests/components/mqtt/test_light_json.py index bcf9d4bd736..67d382826ae 100644 --- a/tests/components/mqtt/test_light_json.py +++ b/tests/components/mqtt/test_light_json.py @@ -432,6 +432,65 @@ async def test_brightness_only( assert state.state == STATE_OFF +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + light.DOMAIN: { + "schema": "json", + "name": "test", + "state_topic": "test_light_rgb", + "command_topic": "test_light_rgb/set", + "supported_color_modes": ["color_temp"], + } + } + }, + ], +) +async def test_color_temp_only( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test a light that only support color_temp as supported color mode.""" + await mqtt_mock_entry() + + state = hass.states.get("light.test") + assert state.state == STATE_UNKNOWN + assert state.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES) == [ + light.ColorMode.COLOR_TEMP + ] + expected_features = ( + light.LightEntityFeature.FLASH | light.LightEntityFeature.TRANSITION + ) + assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == expected_features + assert state.attributes.get("rgb_color") is None + assert state.attributes.get("brightness") is None + assert state.attributes.get("color_temp_kelvin") is None + assert state.attributes.get("effect") is None + assert state.attributes.get("xy_color") is None + assert state.attributes.get("hs_color") is None + + async_fire_mqtt_message( + hass, + "test_light_rgb", + '{"state":"ON", "color_mode": "color_temp", "color_temp": 250, "brightness": 50}', + ) + + state = hass.states.get("light.test") + assert state.state == STATE_ON + assert state.attributes.get("rgb_color") == (255, 206, 166) + assert state.attributes.get("brightness") == 50 + assert state.attributes.get("color_temp_kelvin") == 4000 + assert state.attributes.get("effect") is None + assert state.attributes.get("xy_color") == (0.42, 0.365) + assert state.attributes.get("hs_color") == (26.812, 34.87) + + async_fire_mqtt_message(hass, "test_light_rgb", '{"state":"OFF"}') + + state = hass.states.get("light.test") + assert state.state == STATE_OFF + + @pytest.mark.parametrize( "hass_config", [ From 323bc54efcdd37405ac39f16f52441d125f6d63c Mon Sep 17 00:00:00 2001 From: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> Date: Sat, 8 Mar 2025 07:57:44 -0600 Subject: [PATCH 2490/3148] Fix HEOS user initiated setup when discovery is waiting confirmation (#140119) --- homeassistant/components/heos/config_flow.py | 2 +- tests/components/heos/test_config_flow.py | 29 ++++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/heos/config_flow.py b/homeassistant/components/heos/config_flow.py index f1cd11f0914..e2d3e2522dc 100644 --- a/homeassistant/components/heos/config_flow.py +++ b/homeassistant/components/heos/config_flow.py @@ -205,7 +205,7 @@ class HeosFlowHandler(ConfigFlow, domain=DOMAIN): self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Obtain host and validate connection.""" - await self.async_set_unique_id(DOMAIN) + await self.async_set_unique_id(DOMAIN, raise_on_progress=False) self._abort_if_unique_id_configured(error="single_instance_allowed") # Try connecting to host if provided errors: dict[str, str] = {} diff --git a/tests/components/heos/test_config_flow.py b/tests/components/heos/test_config_flow.py index 69df3734690..69d9aa3a38e 100644 --- a/tests/components/heos/test_config_flow.py +++ b/tests/components/heos/test_config_flow.py @@ -88,6 +88,35 @@ async def test_create_entry_when_host_valid( assert controller.disconnect.call_count == 1 +async def test_manual_setup_with_discovery_in_progress( + hass: HomeAssistant, + discovery_data: SsdpServiceInfo, + controller: MockHeos, + system: HeosSystem, +) -> None: + """Test user can manually set up when discovery is in progress.""" + # Single discovered, selects preferred host, shows confirm + controller.get_system_info.return_value = system + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_SSDP}, data=discovery_data + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "confirm_discovery" + + # Setup manually + user_result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert user_result["type"] is FlowResultType.FORM + user_result = await hass.config_entries.flow.async_configure( + user_result["flow_id"], user_input={CONF_HOST: "127.0.0.1"} + ) + assert user_result["type"] is FlowResultType.CREATE_ENTRY + + # Discovery flow is removed + assert not hass.config_entries.flow.async_progress_by_handler(DOMAIN) + + async def test_discovery( hass: HomeAssistant, discovery_data: SsdpServiceInfo, From ee78e21950ba898648d36e1b9d4b68db860d2e11 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 8 Mar 2025 11:27:26 +0100 Subject: [PATCH 2491/3148] Support null supported Thermostat modes in SmartThings (#140101) --- .../components/smartthings/climate.py | 10 +- tests/components/smartthings/conftest.py | 1 + .../device_status/generic_ef00_v1.json | 76 +++++++++ .../fixtures/devices/generic_ef00_v1.json | 95 +++++++++++ .../smartthings/snapshots/test_climate.ambr | 61 +++++++ .../smartthings/snapshots/test_init.ambr | 33 ++++ .../smartthings/snapshots/test_sensor.ambr | 154 ++++++++++++++++++ .../smartthings/snapshots/test_switch.ambr | 47 ++++++ 8 files changed, 474 insertions(+), 3 deletions(-) create mode 100644 tests/components/smartthings/fixtures/device_status/generic_ef00_v1.json create mode 100644 tests/components/smartthings/fixtures/devices/generic_ef00_v1.json diff --git a/homeassistant/components/smartthings/climate.py b/homeassistant/components/smartthings/climate.py index 8abc0b4a590..7299be699b7 100644 --- a/homeassistant/components/smartthings/climate.py +++ b/homeassistant/components/smartthings/climate.py @@ -272,11 +272,15 @@ class SmartThingsThermostat(SmartThingsEntity, ClimateEntity): @property def hvac_modes(self) -> list[HVACMode]: """Return the list of available operation modes.""" - return [ - state - for mode in self.get_attribute_value( + if ( + supported_thermostat_modes := self.get_attribute_value( Capability.THERMOSTAT_MODE, Attribute.SUPPORTED_THERMOSTAT_MODES ) + ) is None: + return [] + return [ + state + for mode in supported_thermostat_modes if (state := AC_MODE_TO_STATE.get(mode)) is not None ] diff --git a/tests/components/smartthings/conftest.py b/tests/components/smartthings/conftest.py index 089fc472d59..347dfa378cf 100644 --- a/tests/components/smartthings/conftest.py +++ b/tests/components/smartthings/conftest.py @@ -118,6 +118,7 @@ def mock_smartthings() -> Generator[AsyncMock]: "fake_fan", "generic_fan_3_speed", "heatit_ztrm3_thermostat", + "generic_ef00_v1", "bosch_radiator_thermostat_ii", "im_speaker_ai_0001", ] diff --git a/tests/components/smartthings/fixtures/device_status/generic_ef00_v1.json b/tests/components/smartthings/fixtures/device_status/generic_ef00_v1.json new file mode 100644 index 00000000000..cbfdf0d9092 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/generic_ef00_v1.json @@ -0,0 +1,76 @@ +{ + "components": { + "main02": { + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": 200.0, + "unit": "C", + "timestamp": "2024-12-02T20:18:52.095Z" + } + } + }, + "main": { + "thermostatOperatingState": { + "thermostatOperatingState": { + "value": null + } + }, + "signalStrength": { + "rssi": { + "value": -84, + "unit": "dBm", + "timestamp": "2025-03-07T20:53:55.346Z" + }, + "lqi": { + "value": 255, + "timestamp": "2025-03-07T20:53:55.387Z" + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": 21.0, + "unit": "C", + "timestamp": "2025-03-07T16:58:23.773Z" + } + }, + "thermostatHeatingSetpoint": { + "heatingSetpoint": { + "value": 23.0, + "unit": "C", + "timestamp": "2025-02-10T17:48:38.299Z" + }, + "heatingSetpointRange": { + "value": null + } + }, + "refresh": {}, + "valleyboard16460.debug": { + "value": { + "value": "\n \n \n \n \n \n \n \n
Actual_TZE200_rxntag7i
Expected_TZE200_4hbx5cvx
Profilenormal-thermostat-v3
ModeSimilarity
PreferencesModified
Exposes EF00Yes
Default DPNo
", + "timestamp": "2025-03-05T03:04:54.025Z" + } + }, + "thermostatMode": { + "thermostatMode": { + "value": "heat", + "data": {}, + "timestamp": "2024-12-30T08:22:19.273Z" + }, + "supportedThermostatModes": { + "value": null + } + }, + "switch": { + "switch": { + "value": null + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/devices/generic_ef00_v1.json b/tests/components/smartthings/fixtures/devices/generic_ef00_v1.json new file mode 100644 index 00000000000..96937769b41 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/generic_ef00_v1.json @@ -0,0 +1,95 @@ +{ + "items": [ + { + "deviceId": "656569c2-7976-4232-a789-34b4d1176c3a", + "name": "generic-ef00-v1", + "label": "Thermostat K\u00fcche", + "manufacturerName": "SmartThingsCommunity", + "presentationId": "be641577-f796-315b-af6f-b3ad14dd7a58", + "deviceManufacturerCode": "_TZE200_rxntag7i", + "locationId": "0b6618a6-c3ab-4b6e-968d-59cc8c2761bc", + "ownerId": "8a20b799-9d87-ecdc-39de-c93c6e4d3ea1", + "roomId": "eeb2f9d2-19cc-4dad-9f23-28ec807de97e", + "components": [ + { + "id": "main", + "label": "Thermostat", + "capabilities": [ + { + "id": "switch", + "version": 1 + }, + { + "id": "thermostatMode", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "thermostatHeatingSetpoint", + "version": 1 + }, + { + "id": "thermostatOperatingState", + "version": 1 + }, + { + "id": "signalStrength", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "valleyboard16460.debug", + "version": 1 + } + ], + "categories": [ + { + "name": "Thermostat", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "main02", + "label": "Floor", + "capabilities": [ + { + "id": "temperatureMeasurement", + "version": 1 + } + ], + "categories": [ + { + "name": "Thermostat", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2024-12-02T15:58:01.598Z", + "profile": { + "id": "3ad2e1e3-8867-332c-85b5-b291602c324f" + }, + "zigbee": { + "eui": "A4C1388B31017B5F", + "networkId": "162F", + "driverId": "585328e6-ac85-4ac5-bce4-286efd0ab980", + "executingLocally": true, + "hubId": "61bd280e-71c4-44fb-9b6e-53fdf14718a2", + "provisioningState": "DRIVER_SWITCH" + }, + "type": "ZIGBEE", + "restrictionTier": 0, + "allowed": null, + "executionContext": "LOCAL", + "relationships": [] + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/snapshots/test_climate.ambr b/tests/components/smartthings/snapshots/test_climate.ambr index 4d3fd15aeb9..6b512f93d39 100644 --- a/tests/components/smartthings/snapshots/test_climate.ambr +++ b/tests/components/smartthings/snapshots/test_climate.ambr @@ -432,6 +432,67 @@ 'state': 'heat', }) # --- +# name: test_all_entities[generic_ef00_v1][climate.thermostat_kuche-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + ]), + 'max_temp': 35, + 'min_temp': 7, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.thermostat_kuche', + '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': None, + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '656569c2-7976-4232-a789-34b4d1176c3a', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[generic_ef00_v1][climate.thermostat_kuche-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 21.0, + 'friendly_name': 'Thermostat Küche', + 'hvac_modes': list([ + ]), + 'max_temp': 35, + 'min_temp': 7, + 'supported_features': , + 'target_temp_high': None, + 'target_temp_low': None, + 'temperature': 23.0, + }), + 'context': , + 'entity_id': 'climate.thermostat_kuche', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }) +# --- # name: test_all_entities[heatit_ztrm3_thermostat][climate.hall_thermostat-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/smartthings/snapshots/test_init.ambr b/tests/components/smartthings/snapshots/test_init.ambr index 04857d371fd..9651575e337 100644 --- a/tests/components/smartthings/snapshots/test_init.ambr +++ b/tests/components/smartthings/snapshots/test_init.ambr @@ -695,6 +695,39 @@ 'via_device_id': None, }) # --- +# name: test_devices[generic_ef00_v1] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '656569c2-7976-4232-a789-34b4d1176c3a', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': None, + 'model': None, + 'model_id': None, + 'name': 'Thermostat Küche', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- # name: test_devices[generic_fan_3_speed] DeviceRegistryEntrySnapshot({ 'area_id': None, diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index d8146f3dc66..5909fec2707 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -4625,6 +4625,160 @@ 'state': '22', }) # --- +# name: test_all_entities[generic_ef00_v1][sensor.thermostat_kuche_link_quality-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.thermostat_kuche_link_quality', + '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': 'Link quality', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'link_quality', + 'unique_id': '656569c2-7976-4232-a789-34b4d1176c3a.lqi', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[generic_ef00_v1][sensor.thermostat_kuche_link_quality-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Thermostat Küche Link quality', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.thermostat_kuche_link_quality', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '255', + }) +# --- +# name: test_all_entities[generic_ef00_v1][sensor.thermostat_kuche_signal_strength-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.thermostat_kuche_signal_strength', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Signal strength', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '656569c2-7976-4232-a789-34b4d1176c3a.rssi', + 'unit_of_measurement': 'dBm', + }) +# --- +# name: test_all_entities[generic_ef00_v1][sensor.thermostat_kuche_signal_strength-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'signal_strength', + 'friendly_name': 'Thermostat Küche Signal strength', + 'state_class': , + 'unit_of_measurement': 'dBm', + }), + 'context': , + 'entity_id': 'sensor.thermostat_kuche_signal_strength', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-84', + }) +# --- +# name: test_all_entities[generic_ef00_v1][sensor.thermostat_kuche_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.thermostat_kuche_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': 'Temperature', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '656569c2-7976-4232-a789-34b4d1176c3a.temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[generic_ef00_v1][sensor.thermostat_kuche_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Thermostat Küche Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.thermostat_kuche_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '21.0', + }) +# --- # name: test_all_entities[heatit_ztrm3_thermostat][sensor.hall_thermostat_energy-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/smartthings/snapshots/test_switch.ambr b/tests/components/smartthings/snapshots/test_switch.ambr index 00177b3b603..81b73874a6a 100644 --- a/tests/components/smartthings/snapshots/test_switch.ambr +++ b/tests/components/smartthings/snapshots/test_switch.ambr @@ -328,6 +328,53 @@ 'state': 'on', }) # --- +# name: test_all_entities[generic_ef00_v1][switch.thermostat_kuche-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.thermostat_kuche', + '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': None, + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '656569c2-7976-4232-a789-34b4d1176c3a', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[generic_ef00_v1][switch.thermostat_kuche-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Thermostat Küche', + }), + 'context': , + 'entity_id': 'switch.thermostat_kuche', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_all_entities[sensibo_airconditioner_1][switch.office-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 134b5319e1d93fc74a3b8194f7b513f027fc29e9 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 8 Mar 2025 19:58:10 +0100 Subject: [PATCH 2492/3148] Set device class for Oven Completion time in SmartThings (#140139) --- homeassistant/components/smartthings/sensor.py | 2 ++ tests/components/smartthings/snapshots/test_sensor.ambr | 5 +++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index 3e6a7c20533..1b7f59a20e9 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -561,6 +561,8 @@ CAPABILITY_TO_SENSORS: dict[ SmartThingsSensorEntityDescription( key=Attribute.COMPLETION_TIME, translation_key="completion_time", + device_class=SensorDeviceClass.TIMESTAMP, + value_fn=dt_util.parse_datetime, ) ], }, diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index 5909fec2707..b939547ca32 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -1710,7 +1710,7 @@ 'name': None, 'options': dict({ }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Completion time', 'platform': 'smartthings', @@ -1724,6 +1724,7 @@ # name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_completion_time-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', 'friendly_name': 'Microwave Completion time', }), 'context': , @@ -1731,7 +1732,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '2025-02-08T21:13:36.184Z', + 'state': '2025-02-08T21:13:36+00:00', }) # --- # name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_job_state-entry] From 61f0eabcbb48fc0973f7a0f5230bcb323682bcf2 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sat, 8 Mar 2025 23:04:05 +0100 Subject: [PATCH 2493/3148] Revert "Check if the unit of measurement is valid before creating the entity" (#140155) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Revert "Check if the unit of measurement is valid before creating the entity …" This reverts commit 99e1a7a676b2fc14f9f8a8db64bee2840fae4646. --- homeassistant/components/mqtt/sensor.py | 15 -------------- tests/components/mqtt/test_sensor.py | 26 ------------------------- 2 files changed, 41 deletions(-) diff --git a/homeassistant/components/mqtt/sensor.py b/homeassistant/components/mqtt/sensor.py index 432431c96d9..3e8a4fef0fa 100644 --- a/homeassistant/components/mqtt/sensor.py +++ b/homeassistant/components/mqtt/sensor.py @@ -11,7 +11,6 @@ import voluptuous as vol from homeassistant.components import sensor from homeassistant.components.sensor import ( CONF_STATE_CLASS, - DEVICE_CLASS_UNITS, DEVICE_CLASSES_SCHEMA, ENTITY_ID_FORMAT, STATE_CLASSES_SCHEMA, @@ -108,20 +107,6 @@ def validate_sensor_state_and_device_class_config(config: ConfigType) -> ConfigT f"got `{CONF_DEVICE_CLASS}` '{device_class}'" ) - if (device_class := config.get(CONF_DEVICE_CLASS)) is None or ( - unit_of_measurement := config.get(CONF_UNIT_OF_MEASUREMENT) - ) is None: - return config - - if ( - device_class in DEVICE_CLASS_UNITS - and unit_of_measurement not in DEVICE_CLASS_UNITS[device_class] - ): - raise vol.Invalid( - f"The unit of measurement `{unit_of_measurement}` is not valid " - f"together with device class `{device_class}`" - ) - return config diff --git a/tests/components/mqtt/test_sensor.py b/tests/components/mqtt/test_sensor.py index f40082d84be..9226b03a7d2 100644 --- a/tests/components/mqtt/test_sensor.py +++ b/tests/components/mqtt/test_sensor.py @@ -870,32 +870,6 @@ async def test_invalid_device_class( assert "expected SensorDeviceClass or one of" in caplog.text -@pytest.mark.parametrize( - "hass_config", - [ - { - mqtt.DOMAIN: { - sensor.DOMAIN: { - "name": "test", - "state_topic": "test-topic", - "device_class": "energy", - "unit_of_measurement": "ppm", - } - } - } - ], -) -async def test_invalid_unit_of_measurement( - mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture -) -> None: - """Test device_class with invalid unit of measurement.""" - assert await mqtt_mock_entry() - assert ( - "The unit of measurement `ppm` is not valid together with device class `energy`" - in caplog.text - ) - - @pytest.mark.parametrize( "hass_config", [ From 873e4b77eb059039a163f216fde4c0590467365f Mon Sep 17 00:00:00 2001 From: msm595 Date: Sun, 9 Mar 2025 11:07:35 -0400 Subject: [PATCH 2494/3148] Fix the order of the group members attribute of the Music Assistant integration (#140204) --- .../music_assistant/media_player.py | 32 +++++++++++-------- .../snapshots/test_media_player.ambr | 2 +- 2 files changed, 19 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/music_assistant/media_player.py b/homeassistant/components/music_assistant/media_player.py index c079fd20e91..56bde7bbae7 100644 --- a/homeassistant/components/music_assistant/media_player.py +++ b/homeassistant/components/music_assistant/media_player.py @@ -276,22 +276,26 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity): self._attr_state = MediaPlayerState(player.state.value) else: self._attr_state = MediaPlayerState(STATE_OFF) - group_members_entity_ids: list[str] = [] + + group_members: list[str] = [] if player.group_childs: - # translate MA group_childs to HA group_members as entity id's - entity_registry = er.async_get(self.hass) - group_members_entity_ids = [ - entity_id - for child_id in player.group_childs - if ( - entity_id := entity_registry.async_get_entity_id( - self.platform.domain, DOMAIN, child_id - ) + group_members = player.group_childs + elif player.synced_to and (parent := self.mass.players.get(player.synced_to)): + group_members = parent.group_childs + + # translate MA group_childs to HA group_members as entity id's + entity_registry = er.async_get(self.hass) + group_members_entity_ids: list[str] = [ + entity_id + for child_id in group_members + if ( + entity_id := entity_registry.async_get_entity_id( + self.platform.domain, DOMAIN, child_id ) - ] - # NOTE: we sort the group_members for now, - # until the MA API returns them sorted (group_childs is now a set) - self._attr_group_members = sorted(group_members_entity_ids) + ) + ] + + self._attr_group_members = group_members_entity_ids self._attr_volume_level = ( player.volume_level / 100 if player.volume_level is not None else None ) diff --git a/tests/components/music_assistant/snapshots/test_media_player.ambr b/tests/components/music_assistant/snapshots/test_media_player.ambr index a07bde4b29d..50223ddf623 100644 --- a/tests/components/music_assistant/snapshots/test_media_player.ambr +++ b/tests/components/music_assistant/snapshots/test_media_player.ambr @@ -109,8 +109,8 @@ 'entity_picture_local': None, 'friendly_name': 'Test Group Player 1', 'group_members': list([ - 'media_player.my_super_test_player_2', 'media_player.test_player_1', + 'media_player.my_super_test_player_2', ]), 'icon': 'mdi:speaker-multiple', 'is_volume_muted': False, From 7d93ceb0f009dd38bc3e828048349beab6e835ad Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Mon, 10 Mar 2025 15:49:29 +0100 Subject: [PATCH 2495/3148] Fix events without user in Bring integration (#140213) Fix events without publicUserUuid --- homeassistant/components/bring/event.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/bring/event.py b/homeassistant/components/bring/event.py index 08d06b596b8..403856405ce 100644 --- a/homeassistant/components/bring/event.py +++ b/homeassistant/components/bring/event.py @@ -77,9 +77,12 @@ class BringEventEntity(BringBaseEntity, EventEntity): attributes = asdict(activity.content) attributes["last_activity_by"] = next( - x.name - for x in bring_list.users.users - if x.publicUuid == activity.content.publicUserUuid + ( + x.name + for x in bring_list.users.users + if x.publicUuid == activity.content.publicUserUuid + ), + None, ) self._trigger_event( From 52fcdda42985ae89cec937bf09b67083b9d78913 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Sun, 9 Mar 2025 20:01:07 +0100 Subject: [PATCH 2496/3148] Log broad exception in Electricity Maps config flow (#140219) --- homeassistant/components/co2signal/config_flow.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/co2signal/config_flow.py b/homeassistant/components/co2signal/config_flow.py index 530496811d9..00acd2829a6 100644 --- a/homeassistant/components/co2signal/config_flow.py +++ b/homeassistant/components/co2signal/config_flow.py @@ -3,11 +3,11 @@ from __future__ import annotations from collections.abc import Mapping +import logging from typing import Any from aioelectricitymaps import ( ElectricityMaps, - ElectricityMapsError, ElectricityMapsInvalidTokenError, ElectricityMapsNoDataError, ) @@ -36,6 +36,8 @@ TYPE_USE_HOME = "use_home_location" TYPE_SPECIFY_COORDINATES = "specify_coordinates" TYPE_SPECIFY_COUNTRY = "specify_country_code" +_LOGGER = logging.getLogger(__name__) + class ElectricityMapsConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Co2signal.""" @@ -158,7 +160,8 @@ class ElectricityMapsConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "invalid_auth" except ElectricityMapsNoDataError: errors["base"] = "no_data" - except ElectricityMapsError: + except Exception: + _LOGGER.exception("Unexpected error occurred while checking API key") errors["base"] = "unknown" else: if self.source == SOURCE_REAUTH: From bbbb5cadd4c9b319c2a2e225dd330d972826d096 Mon Sep 17 00:00:00 2001 From: David Bonnes Date: Sun, 9 Mar 2025 21:45:47 +0000 Subject: [PATCH 2497/3148] Bump evohome-async to 1.0.4 to fix #140194 (#140230) bump client, add test for fix #140194 --- .../components/evohome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../fixtures/botched/user_locations.json | 10 +-- .../evohome/snapshots/test_climate.ambr | 62 +++++++++---------- .../evohome/snapshots/test_water_heater.ambr | 8 +-- 6 files changed, 43 insertions(+), 43 deletions(-) diff --git a/homeassistant/components/evohome/manifest.json b/homeassistant/components/evohome/manifest.json index 700872ef92b..44e4cdb1128 100644 --- a/homeassistant/components/evohome/manifest.json +++ b/homeassistant/components/evohome/manifest.json @@ -6,5 +6,5 @@ "iot_class": "cloud_polling", "loggers": ["evohome", "evohomeasync", "evohomeasync2"], "quality_scale": "legacy", - "requirements": ["evohome-async==1.0.3"] + "requirements": ["evohome-async==1.0.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index 5ff2ee495a5..3c18b34008c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -899,7 +899,7 @@ eufylife-ble-client==0.1.8 # evdev==1.6.1 # homeassistant.components.evohome -evohome-async==1.0.3 +evohome-async==1.0.4 # homeassistant.components.bryant_evolution evolutionhttp==0.0.18 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 30e3c6c1325..5be08efd5cd 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -765,7 +765,7 @@ eternalegypt==0.0.16 eufylife-ble-client==0.1.8 # homeassistant.components.evohome -evohome-async==1.0.3 +evohome-async==1.0.4 # homeassistant.components.bryant_evolution evolutionhttp==0.0.18 diff --git a/tests/components/evohome/fixtures/botched/user_locations.json b/tests/components/evohome/fixtures/botched/user_locations.json index f2f4091a2dc..0016c5db007 100644 --- a/tests/components/evohome/fixtures/botched/user_locations.json +++ b/tests/components/evohome/fixtures/botched/user_locations.json @@ -8,14 +8,14 @@ "country": "UnitedKingdom", "postcode": "E1 1AA", "locationType": "Residential", - "useDaylightSaveSwitching": true, "timeZone": { - "timeZoneId": "GMTStandardTime", - "displayName": "(UTC+00:00) Dublin, Edinburgh, Lisbon, London", - "offsetMinutes": 0, - "currentOffsetMinutes": 60, + "timeZoneId": "PacificSAStandardTime", + "displayName": "(UTC-04:00) Santiago", + "offsetMinutes": -240, + "currentOffsetMinutes": -180, "supportsDaylightSaving": true }, + "useDaylightSaveSwitching": true, "locationOwner": { "userId": "2263181", "username": "user_2263181@gmail.com", diff --git a/tests/components/evohome/snapshots/test_climate.ambr b/tests/components/evohome/snapshots/test_climate.ambr index 5a6a6bff863..7fb0ae5aaec 100644 --- a/tests/components/evohome/snapshots/test_climate.ambr +++ b/tests/components/evohome/snapshots/test_climate.ambr @@ -168,10 +168,10 @@ 'target_heat_temperature': 16.0, }), 'setpoints': dict({ - 'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), - 'next_sp_temp': 18.6, - 'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), - 'this_sp_temp': 16.0, + 'next_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='America/Santiago')), + 'next_sp_temp': 16.0, + 'this_sp_from': HAFakeDatetime(2024, 7, 10, 7, 0, tzinfo=zoneinfo.ZoneInfo(key='America/Santiago')), + 'this_sp_temp': 18.1, }), 'temperature_status': dict({ 'is_available': True, @@ -215,10 +215,10 @@ 'target_heat_temperature': 17.0, }), 'setpoints': dict({ - 'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), - 'next_sp_temp': 18.6, - 'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), - 'this_sp_temp': 16.0, + 'next_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='America/Santiago')), + 'next_sp_temp': 16.0, + 'this_sp_from': HAFakeDatetime(2024, 7, 10, 7, 0, tzinfo=zoneinfo.ZoneInfo(key='America/Santiago')), + 'this_sp_temp': 18.1, }), 'temperature_status': dict({ 'is_available': False, @@ -257,19 +257,19 @@ 'activeFaults': tuple( dict({ 'fault_type': 'TempZoneActuatorLowBattery', - 'since': '2022-03-02T04:50:20+00:00', + 'since': '2022-03-02T04:50:20-03:00', }), ), 'setpoint_status': dict({ 'setpoint_mode': 'TemporaryOverride', 'target_heat_temperature': 21.0, - 'until': '2022-03-07T19:00:00+00:00', + 'until': '2022-03-07T16:00:00-03:00', }), 'setpoints': dict({ - 'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), - 'next_sp_temp': 18.6, - 'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), - 'this_sp_temp': 16.0, + 'next_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='America/Santiago')), + 'next_sp_temp': 16.0, + 'this_sp_from': HAFakeDatetime(2024, 7, 10, 7, 0, tzinfo=zoneinfo.ZoneInfo(key='America/Santiago')), + 'this_sp_temp': 18.1, }), 'temperature_status': dict({ 'is_available': True, @@ -313,10 +313,10 @@ 'target_heat_temperature': 17.0, }), 'setpoints': dict({ - 'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), - 'next_sp_temp': 18.6, - 'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), - 'this_sp_temp': 16.0, + 'next_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='America/Santiago')), + 'next_sp_temp': 16.0, + 'this_sp_from': HAFakeDatetime(2024, 7, 10, 7, 0, tzinfo=zoneinfo.ZoneInfo(key='America/Santiago')), + 'this_sp_temp': 18.1, }), 'temperature_status': dict({ 'is_available': True, @@ -360,10 +360,10 @@ 'target_heat_temperature': 17.0, }), 'setpoints': dict({ - 'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), - 'next_sp_temp': 18.6, - 'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), - 'this_sp_temp': 16.0, + 'next_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='America/Santiago')), + 'next_sp_temp': 16.0, + 'this_sp_from': HAFakeDatetime(2024, 7, 10, 7, 0, tzinfo=zoneinfo.ZoneInfo(key='America/Santiago')), + 'this_sp_temp': 18.1, }), 'temperature_status': dict({ 'is_available': True, @@ -407,10 +407,10 @@ 'target_heat_temperature': 16.0, }), 'setpoints': dict({ - 'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), - 'next_sp_temp': 18.6, - 'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), - 'this_sp_temp': 16.0, + 'next_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='America/Santiago')), + 'next_sp_temp': 16.0, + 'this_sp_from': HAFakeDatetime(2024, 7, 10, 7, 0, tzinfo=zoneinfo.ZoneInfo(key='America/Santiago')), + 'this_sp_temp': 18.1, }), 'temperature_status': dict({ 'is_available': True, @@ -450,7 +450,7 @@ 'activeFaults': tuple( dict({ 'fault_type': 'TempZoneActuatorCommunicationLost', - 'since': '2022-03-02T15:56:01+00:00', + 'since': '2022-03-02T15:56:01-03:00', }), ), 'setpoint_status': dict({ @@ -458,10 +458,10 @@ 'target_heat_temperature': 17.0, }), 'setpoints': dict({ - 'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), - 'next_sp_temp': 18.6, - 'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), - 'this_sp_temp': 16.0, + 'next_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='America/Santiago')), + 'next_sp_temp': 16.0, + 'this_sp_from': HAFakeDatetime(2024, 7, 10, 7, 0, tzinfo=zoneinfo.ZoneInfo(key='America/Santiago')), + 'this_sp_temp': 18.1, }), 'temperature_status': dict({ 'is_available': True, diff --git a/tests/components/evohome/snapshots/test_water_heater.ambr b/tests/components/evohome/snapshots/test_water_heater.ambr index 7b1bc44550a..13fb375c097 100644 --- a/tests/components/evohome/snapshots/test_water_heater.ambr +++ b/tests/components/evohome/snapshots/test_water_heater.ambr @@ -2,10 +2,10 @@ # name: test_set_operation_mode[botched] list([ dict({ - 'until': HAFakeDatetime(2024, 7, 10, 12, 0, tzinfo=datetime.timezone.utc), + 'until': HAFakeDatetime(2024, 7, 10, 12, 30, tzinfo=datetime.timezone.utc), }), dict({ - 'until': HAFakeDatetime(2024, 7, 10, 12, 0, tzinfo=datetime.timezone.utc), + 'until': HAFakeDatetime(2024, 7, 10, 12, 30, tzinfo=datetime.timezone.utc), }), ]) # --- @@ -39,9 +39,9 @@ ), 'dhw_id': '3933910', 'setpoints': dict({ - 'next_sp_from': HAFakeDatetime(2024, 7, 10, 13, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), + 'next_sp_from': HAFakeDatetime(2024, 7, 10, 8, 30, tzinfo=zoneinfo.ZoneInfo(key='America/Santiago')), 'next_sp_state': 'Off', - 'this_sp_from': HAFakeDatetime(2024, 7, 10, 12, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), + 'this_sp_from': HAFakeDatetime(2024, 7, 10, 6, 30, tzinfo=zoneinfo.ZoneInfo(key='America/Santiago')), 'this_sp_state': 'On', }), 'state_status': dict({ From 06188b8fbd33ad50c23018dc4f22c44386193ef2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Sun, 9 Mar 2025 21:59:09 +0100 Subject: [PATCH 2498/3148] Refresh Home Connect token during config entry setup (#140233) * Refresh token during config entry setup * Test 500 error --------- Co-authored-by: Martin Hjelmare --- .../components/home_connect/__init__.py | 16 ++++- tests/components/home_connect/test_init.py | 61 +++++++++++++++++-- 2 files changed, 71 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/home_connect/__init__.py b/homeassistant/components/home_connect/__init__.py index 3e1bd1da156..6814ab3eed2 100644 --- a/homeassistant/components/home_connect/__init__.py +++ b/homeassistant/components/home_connect/__init__.py @@ -16,11 +16,17 @@ from aiohomeconnect.model import ( SettingKey, ) from aiohomeconnect.model.error import HomeConnectError +import aiohttp import voluptuous as vol from homeassistant.const import ATTR_DEVICE_ID, Platform from homeassistant.core import HomeAssistant, ServiceCall, callback -from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + ConfigEntryNotReady, + HomeAssistantError, + ServiceValidationError, +) from homeassistant.helpers import ( config_entry_oauth2_flow, config_validation as cv, @@ -611,6 +617,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: HomeConnectConfigEntry) session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation) config_entry_auth = AsyncConfigEntryAuth(hass, session) + try: + await config_entry_auth.async_get_access_token() + except aiohttp.ClientResponseError as err: + if 400 <= err.status < 500: + raise ConfigEntryAuthFailed from err + raise ConfigEntryNotReady from err + except aiohttp.ClientError as err: + raise ConfigEntryNotReady from err home_connect_client = HomeConnectClient(config_entry_auth) diff --git a/tests/components/home_connect/test_init.py b/tests/components/home_connect/test_init.py index 6e4e428bf6a..4287ac9d227 100644 --- a/tests/components/home_connect/test_init.py +++ b/tests/components/home_connect/test_init.py @@ -8,9 +8,8 @@ from unittest.mock import MagicMock, patch from aiohomeconnect.const import OAUTH2_TOKEN from aiohomeconnect.model import OptionKey, ProgramKey, SettingKey, StatusKey from aiohomeconnect.model.error import HomeConnectError, UnauthorizedError +import aiohttp import pytest -import requests_mock -import respx from syrupy.assertion import SnapshotAssertion from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN @@ -221,14 +220,12 @@ async def test_exception_handling( @pytest.mark.parametrize("token_expiration_time", [12345]) -@respx.mock async def test_token_refresh_success( hass: HomeAssistant, platforms: list[Platform], integration_setup: Callable[[MagicMock], Awaitable[bool]], config_entry: MockConfigEntry, aioclient_mock: AiohttpClientMocker, - requests_mock: requests_mock.Mocker, setup_credentials: None, client: MagicMock, ) -> None: @@ -236,7 +233,6 @@ async def test_token_refresh_success( assert config_entry.data["token"]["access_token"] == FAKE_ACCESS_TOKEN - requests_mock.post(OAUTH2_TOKEN, json=SERVER_ACCESS_TOKEN) aioclient_mock.post( OAUTH2_TOKEN, json=SERVER_ACCESS_TOKEN, @@ -280,6 +276,61 @@ async def test_token_refresh_success( ) +@pytest.mark.parametrize("token_expiration_time", [12345]) +@pytest.mark.parametrize( + ("aioclient_mock_args", "expected_config_entry_state"), + [ + ( + { + "status": 400, + "json": {"error": "invalid_grant"}, + }, + ConfigEntryState.SETUP_ERROR, + ), + ( + { + "status": 500, + }, + ConfigEntryState.SETUP_RETRY, + ), + ( + { + "exc": aiohttp.ClientError, + }, + ConfigEntryState.SETUP_RETRY, + ), + ], +) +async def test_token_refresh_error( + aioclient_mock_args: dict[str, Any], + expected_config_entry_state: ConfigEntryState, + hass: HomeAssistant, + platforms: list[Platform], + integration_setup: Callable[[MagicMock], Awaitable[bool]], + config_entry: MockConfigEntry, + aioclient_mock: AiohttpClientMocker, + setup_credentials: None, + client: MagicMock, +) -> None: + """Test where token is expired and the refresh attempt fails.""" + + config_entry.data["token"]["access_token"] = FAKE_ACCESS_TOKEN + + aioclient_mock.post( + OAUTH2_TOKEN, + **aioclient_mock_args, + ) + + assert config_entry.state == ConfigEntryState.NOT_LOADED + with patch( + "homeassistant.components.home_connect.HomeConnectClient", return_value=client + ): + assert not await integration_setup(client) + await hass.async_block_till_done() + + assert config_entry.state == expected_config_entry_state + + @pytest.mark.parametrize( ("exception", "expected_state"), [ From 0bbab63193181ea7fd07f991a0f6eaf92eaaca0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Sun, 9 Mar 2025 21:40:15 +0100 Subject: [PATCH 2499/3148] Add 900 RPM option to washer spin speed options at Home Connect (#140234) Add 900 RPM option to washer spin speed options --- homeassistant/components/home_connect/const.py | 1 + homeassistant/components/home_connect/strings.json | 2 ++ 2 files changed, 3 insertions(+) diff --git a/homeassistant/components/home_connect/const.py b/homeassistant/components/home_connect/const.py index 66c635f5d95..999bb5da13d 100644 --- a/homeassistant/components/home_connect/const.py +++ b/homeassistant/components/home_connect/const.py @@ -285,6 +285,7 @@ SPIN_SPEED_OPTIONS = { "LaundryCare.Washer.EnumType.SpinSpeed.RPM400", "LaundryCare.Washer.EnumType.SpinSpeed.RPM600", "LaundryCare.Washer.EnumType.SpinSpeed.RPM800", + "LaundryCare.Washer.EnumType.SpinSpeed.RPM900", "LaundryCare.Washer.EnumType.SpinSpeed.RPM1000", "LaundryCare.Washer.EnumType.SpinSpeed.RPM1200", "LaundryCare.Washer.EnumType.SpinSpeed.RPM1400", diff --git a/homeassistant/components/home_connect/strings.json b/homeassistant/components/home_connect/strings.json index 92b59919583..8ebf1e0cb1b 100644 --- a/homeassistant/components/home_connect/strings.json +++ b/homeassistant/components/home_connect/strings.json @@ -461,6 +461,7 @@ "laundry_care_washer_enum_type_spin_speed_r_p_m400": "400 rpm", "laundry_care_washer_enum_type_spin_speed_r_p_m600": "600 rpm", "laundry_care_washer_enum_type_spin_speed_r_p_m800": "800 rpm", + "laundry_care_washer_enum_type_spin_speed_r_p_m900": "900 rpm", "laundry_care_washer_enum_type_spin_speed_r_p_m1000": "1000 rpm", "laundry_care_washer_enum_type_spin_speed_r_p_m1200": "1200 rpm", "laundry_care_washer_enum_type_spin_speed_r_p_m1400": "1400 rpm", @@ -1430,6 +1431,7 @@ "laundry_care_washer_enum_type_spin_speed_r_p_m400": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m400%]", "laundry_care_washer_enum_type_spin_speed_r_p_m600": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m600%]", "laundry_care_washer_enum_type_spin_speed_r_p_m800": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m800%]", + "laundry_care_washer_enum_type_spin_speed_r_p_m900": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m900%]", "laundry_care_washer_enum_type_spin_speed_r_p_m1000": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m1000%]", "laundry_care_washer_enum_type_spin_speed_r_p_m1200": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m1200%]", "laundry_care_washer_enum_type_spin_speed_r_p_m1400": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m1400%]", From 490dd3b525a57f1081e9e8856652cde0994dfeb0 Mon Sep 17 00:00:00 2001 From: victorclaessen Date: Tue, 11 Mar 2025 15:52:55 +0100 Subject: [PATCH 2500/3148] Add microseconds as unit for device class duration (#140307) * Add microseconds as unit for device class duration. Add microseconds as unit for device class duration. The converter already supports it. * Update const.py Also update number component --- homeassistant/components/number/const.py | 3 ++- homeassistant/components/sensor/const.py | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/number/const.py b/homeassistant/components/number/const.py index 61a4fa644b0..a7493194847 100644 --- a/homeassistant/components/number/const.py +++ b/homeassistant/components/number/const.py @@ -159,7 +159,7 @@ class NumberDeviceClass(StrEnum): DURATION = "duration" """Fixed duration. - Unit of measurement: `d`, `h`, `min`, `s`, `ms` + Unit of measurement: `d`, `h`, `min`, `s`, `ms`, `µs` """ ENERGY = "energy" @@ -462,6 +462,7 @@ DEVICE_CLASS_UNITS: dict[NumberDeviceClass, set[type[StrEnum] | str | None]] = { UnitOfTime.MINUTES, UnitOfTime.SECONDS, UnitOfTime.MILLISECONDS, + UnitOfTime.MICROSECONDS, }, NumberDeviceClass.ENERGY: set(UnitOfEnergy), NumberDeviceClass.ENERGY_DISTANCE: set(UnitOfEnergyDistance), diff --git a/homeassistant/components/sensor/const.py b/homeassistant/components/sensor/const.py index 8eccb758756..774f2a9cff2 100644 --- a/homeassistant/components/sensor/const.py +++ b/homeassistant/components/sensor/const.py @@ -186,7 +186,7 @@ class SensorDeviceClass(StrEnum): DURATION = "duration" """Fixed duration. - Unit of measurement: `d`, `h`, `min`, `s`, `ms` + Unit of measurement: `d`, `h`, `min`, `s`, `ms`, `µs` """ ENERGY = "energy" @@ -558,6 +558,7 @@ DEVICE_CLASS_UNITS: dict[SensorDeviceClass, set[type[StrEnum] | str | None]] = { UnitOfTime.MINUTES, UnitOfTime.SECONDS, UnitOfTime.MILLISECONDS, + UnitOfTime.MICROSECONDS, }, SensorDeviceClass.ENERGY: set(UnitOfEnergy), SensorDeviceClass.ENERGY_DISTANCE: set(UnitOfEnergyDistance), From c43f6a67d000483531ad27dc0909404ffe42ef51 Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Mon, 10 Mar 2025 17:02:07 -0400 Subject: [PATCH 2501/3148] Fix todo tool broken with Gemini 2.0 models. (#140246) * Change tool name for addlist item * Change to HasListAddItem * extract to function --- .../conversation.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/google_generative_ai_conversation/conversation.py b/homeassistant/components/google_generative_ai_conversation/conversation.py index 168e867d857..5fd373acf72 100644 --- a/homeassistant/components/google_generative_ai_conversation/conversation.py +++ b/homeassistant/components/google_generative_ai_conversation/conversation.py @@ -276,6 +276,13 @@ class GoogleGenerativeAIConversationEntity( ): return await self._async_handle_message(user_input, chat_log) + def _fix_tool_name(self, tool_name: str) -> str: + """Fix tool name if needed.""" + # The Gemini 2.0+ tokenizer seemingly has a issue with the HassListAddItem tool + # name. This makes sure when it incorrectly changes the name, that we change it + # back for HA to call. + return tool_name if tool_name != "HasListAddItem" else "HassListAddItem" + async def _async_handle_message( self, user_input: conversation.ConversationInput, @@ -435,7 +442,10 @@ class GoogleGenerativeAIConversationEntity( tool_name = tool_call.name tool_args = _escape_decode(tool_call.args) tool_calls.append( - llm.ToolInput(tool_name=tool_name, tool_args=tool_args) + llm.ToolInput( + tool_name=self._fix_tool_name(tool_name), + tool_args=tool_args, + ) ) chat_request = _create_google_tool_response_content( From e4b31640b3160f35032f413cac2bf23cd6a60bfd Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 10 Mar 2025 09:16:05 +0100 Subject: [PATCH 2502/3148] Fix version not always available in onewire (#140260) --- homeassistant/components/onewire/onewirehub.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/onewire/onewirehub.py b/homeassistant/components/onewire/onewirehub.py index d65d7a90950..dc894a4242e 100644 --- a/homeassistant/components/onewire/onewirehub.py +++ b/homeassistant/components/onewire/onewirehub.py @@ -2,6 +2,7 @@ from __future__ import annotations +import contextlib from datetime import datetime, timedelta import logging import os @@ -58,7 +59,7 @@ class OneWireHub: owproxy: protocol._Proxy devices: list[OWDeviceDescription] - _version: str + _version: str | None = None def __init__(self, hass: HomeAssistant, config_entry: OneWireConfigEntry) -> None: """Initialize.""" @@ -74,7 +75,9 @@ class OneWireHub: port = self._config_entry.data[CONF_PORT] _LOGGER.debug("Initializing connection to %s:%s", host, port) self.owproxy = protocol.proxy(host, port) - self._version = self.owproxy.read(protocol.PTH_VERSION).decode() + with contextlib.suppress(protocol.OwnetError): + # Version is not available on all servers + self._version = self.owproxy.read(protocol.PTH_VERSION).decode() self.devices = _discover_devices(self.owproxy) async def initialize(self) -> None: From 5d9d6f099c518a8cce754123f7a5519f5d1063bc Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Mon, 10 Mar 2025 11:04:49 +0100 Subject: [PATCH 2503/3148] Fix `client_id` not generated when connecting to the MQTT broker (#140264) Fix client_id not generated when connecting to the MQTT broker --- homeassistant/components/mqtt/client.py | 10 ++++--- tests/components/mqtt/test_client.py | 36 +++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/mqtt/client.py b/homeassistant/components/mqtt/client.py index d35b3db7518..e985dc9b87f 100644 --- a/homeassistant/components/mqtt/client.py +++ b/homeassistant/components/mqtt/client.py @@ -15,6 +15,7 @@ import socket import ssl import time from typing import TYPE_CHECKING, Any +from uuid import uuid4 import certifi @@ -292,7 +293,7 @@ class MqttClientSetup: """ # We don't import on the top because some integrations # should be able to optionally rely on MQTT. - import paho.mqtt.client as mqtt # pylint: disable=import-outside-toplevel + from paho.mqtt import client as mqtt # pylint: disable=import-outside-toplevel # pylint: disable-next=import-outside-toplevel from .async_client import AsyncMQTTClient @@ -309,9 +310,10 @@ class MqttClientSetup: clean_session = True if (client_id := config.get(CONF_CLIENT_ID)) is None: - # PAHO MQTT relies on the MQTT server to generate random client IDs. - # However, that feature is not mandatory so we generate our own. - client_id = None + # PAHO MQTT relies on the MQTT server to generate random client ID + # for protocol version 3.1, however, that feature is not mandatory + # so we generate our own. + client_id = mqtt._base62(uuid4().int, padding=22) # noqa: SLF001 transport: str = config.get(CONF_TRANSPORT, DEFAULT_TRANSPORT) self._client = AsyncMQTTClient( callback_api_version=mqtt.CallbackAPIVersion.VERSION2, diff --git a/tests/components/mqtt/test_client.py b/tests/components/mqtt/test_client.py index 9d5401fd437..0dbbff58026 100644 --- a/tests/components/mqtt/test_client.py +++ b/tests/components/mqtt/test_client.py @@ -1556,6 +1556,42 @@ async def test_setup_uses_certificate_on_certificate_set_to_auto_and_insecure( assert insecure_check["insecure"] == insecure_param +@pytest.mark.parametrize( + ("mqtt_config_entry_data", "client_id"), + [ + ( + { + mqtt.CONF_BROKER: "mock-broker", + "client_id": "random01234random0124", + }, + "random01234random0124", + ), + ( + { + mqtt.CONF_BROKER: "mock-broker", + }, + None, + ), + ], +) +async def test_client_id_is_set( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + client_id: str | None, +) -> None: + """Test setup defaults for tls.""" + with patch( + "homeassistant.components.mqtt.async_client.AsyncMQTTClient" + ) as async_client_mock: + await mqtt_mock_entry() + await hass.async_block_till_done() + assert async_client_mock.call_count == 1 + call_params: dict[str, Any] = async_client_mock.call_args[1] + assert "client_id" in call_params + assert client_id is None or client_id == call_params["client_id"] + assert call_params["client_id"] is not None + + @pytest.mark.parametrize( "mqtt_config_entry_data", [ From 76d478c84f865cf897414e5e5f58a6bd2f0f3d76 Mon Sep 17 00:00:00 2001 From: Maikel Punie Date: Mon, 10 Mar 2025 11:45:37 +0100 Subject: [PATCH 2504/3148] Bump velbusaio to 2025.3.0 (#140267) --- homeassistant/components/velbus/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/velbus/manifest.json b/homeassistant/components/velbus/manifest.json index 29504277651..ff30ee14a8a 100644 --- a/homeassistant/components/velbus/manifest.json +++ b/homeassistant/components/velbus/manifest.json @@ -14,7 +14,7 @@ "velbus-protocol" ], "quality_scale": "bronze", - "requirements": ["velbus-aio==2025.1.1"], + "requirements": ["velbus-aio==2025.3.0"], "usb": [ { "vid": "10CF", diff --git a/requirements_all.txt b/requirements_all.txt index 3c18b34008c..1fd4fe9bf96 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3000,7 +3000,7 @@ vallox-websocket-api==5.3.0 vehicle==2.2.2 # homeassistant.components.velbus -velbus-aio==2025.1.1 +velbus-aio==2025.3.0 # homeassistant.components.venstar venstarcolortouch==0.19 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5be08efd5cd..98cae017b68 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2416,7 +2416,7 @@ vallox-websocket-api==5.3.0 vehicle==2.2.2 # homeassistant.components.velbus -velbus-aio==2025.1.1 +velbus-aio==2025.3.0 # homeassistant.components.venstar venstarcolortouch==0.19 From 29c9d3804b9d304e341ca96215d791300fba5d30 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 10 Mar 2025 12:19:18 +0100 Subject: [PATCH 2505/3148] Fix dryer operating state in SmartThings (#140277) --- .../components/smartthings/__init__.py | 3 + tests/components/smartthings/conftest.py | 1 + .../device_status/da_wm_wd_000001_1.json | 692 ++++++++++++++++++ .../fixtures/devices/da_wm_wd_000001_1.json | 205 ++++++ .../smartthings/snapshots/test_init.ambr | 33 + .../smartthings/snapshots/test_sensor.ambr | 467 ++++++++++++ .../smartthings/snapshots/test_switch.ambr | 47 ++ 7 files changed, 1448 insertions(+) create mode 100644 tests/components/smartthings/fixtures/device_status/da_wm_wd_000001_1.json create mode 100644 tests/components/smartthings/fixtures/devices/da_wm_wd_000001_1.json diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py index 3169a249189..e4dc4b0be7a 100644 --- a/homeassistant/components/smartthings/__init__.py +++ b/homeassistant/components/smartthings/__init__.py @@ -194,6 +194,9 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: KEEP_CAPABILITY_QUIRK: dict[ Capability | str, Callable[[dict[Attribute | str, Status]], bool] ] = { + Capability.DRYER_OPERATING_STATE: ( + lambda status: status[Attribute.SUPPORTED_MACHINE_STATES].value is not None + ), Capability.WASHER_OPERATING_STATE: ( lambda status: status[Attribute.SUPPORTED_MACHINE_STATES].value is not None ), diff --git a/tests/components/smartthings/conftest.py b/tests/components/smartthings/conftest.py index 347dfa378cf..2fac8e99456 100644 --- a/tests/components/smartthings/conftest.py +++ b/tests/components/smartthings/conftest.py @@ -100,6 +100,7 @@ def mock_smartthings() -> Generator[AsyncMock]: "iphone", "da_wm_dw_000001", "da_wm_wd_000001", + "da_wm_wd_000001_1", "da_wm_wm_000001", "da_wm_wm_000001_1", "da_rvc_normal_000001", diff --git a/tests/components/smartthings/fixtures/device_status/da_wm_wd_000001_1.json b/tests/components/smartthings/fixtures/device_status/da_wm_wd_000001_1.json new file mode 100644 index 00000000000..b45bac95237 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/da_wm_wd_000001_1.json @@ -0,0 +1,692 @@ +{ + "components": { + "hca.main": { + "hca.dryerMode": { + "mode": { + "value": "normal", + "timestamp": "2025-03-09T16:31:41.247Z" + }, + "supportedModes": { + "value": ["normal", "quickDry", "mix", "timeDry"], + "timestamp": "2025-03-09T16:31:40.486Z" + } + } + }, + "main": { + "custom.dryerWrinklePrevent": { + "operatingState": { + "value": "ready", + "timestamp": "2025-03-09T16:31:40.486Z" + }, + "dryerWrinklePrevent": { + "value": "off", + "timestamp": "2025-03-09T16:31:41.077Z" + } + }, + "samsungce.dryerDryingTemperature": { + "dryingTemperature": { + "value": null, + "timestamp": "2021-04-02T18:31:36.756Z" + }, + "supportedDryingTemperature": { + "value": null, + "timestamp": "2021-04-02T18:29:52.258Z" + } + }, + "samsungce.welcomeMessage": { + "welcomeMessage": { + "value": null, + "timestamp": "2021-04-02T18:32:37.913Z" + } + }, + "samsungce.dongleSoftwareInstallation": { + "status": { + "value": "completed", + "timestamp": "2022-06-17T17:07:35.734Z" + } + }, + "samsungce.dryerCyclePreset": { + "maxNumberOfPresets": { + "value": 10, + "timestamp": "2025-03-09T16:31:41.229Z" + }, + "presets": { + "value": null, + "timestamp": "2021-04-02T18:30:36.772Z" + } + }, + "samsungce.deviceIdentification": { + "micomAssayCode": { + "value": "20221341", + "timestamp": "2025-03-09T16:31:40.834Z" + }, + "modelName": { + "value": null, + "timestamp": "2021-04-02T18:29:53.622Z" + }, + "serialNumber": { + "value": null, + "timestamp": "2021-04-02T18:29:52.641Z" + }, + "serialNumberExtra": { + "value": null, + "timestamp": "2021-04-02T18:29:51.653Z" + }, + "modelClassificationCode": { + "value": "30010102001211000103000000000000", + "timestamp": "2025-03-09T16:31:40.834Z" + }, + "description": { + "value": "DA_WM_A51_20_COMMON_DV6800N/DC92-01967B_0404", + "timestamp": "2025-03-09T16:31:40.834Z" + }, + "releaseYear": { + "value": null + }, + "binaryId": { + "value": "DA_WM_A51_20_COMMON", + "timestamp": "2025-03-09T19:07:40.295Z" + } + }, + "switch": { + "switch": { + "value": "off", + "timestamp": "2025-03-09T19:47:36.549Z" + } + }, + "samsungce.quickControl": { + "version": { + "value": null + } + }, + "samsungce.dryerFreezePrevent": { + "operatingState": { + "value": null + } + }, + "ocf": { + "st": { + "value": null, + "timestamp": "2020-06-20T10:01:02.741Z" + }, + "mndt": { + "value": null, + "timestamp": "2020-06-25T01:53:25.278Z" + }, + "mnfv": { + "value": "DA_WM_A51_20_COMMON_30230708", + "timestamp": "2024-12-15T10:53:49.561Z" + }, + "mnhw": { + "value": "ARTIK051", + "timestamp": "2024-12-15T10:53:49.561Z" + }, + "di": { + "value": "3a6c4e05-811d-5041-e956-3d04c424cbcd", + "timestamp": "2024-12-15T10:53:49.561Z" + }, + "mnsl": { + "value": "http://www.samsung.com", + "timestamp": "2024-12-15T10:53:49.561Z" + }, + "dmv": { + "value": "res.1.1.0,sh.1.1.0", + "timestamp": "2024-12-15T10:53:49.561Z" + }, + "n": { + "value": "[dryer] Samsung", + "timestamp": "2024-12-15T10:53:49.561Z" + }, + "mnmo": { + "value": "DA_WM_A51_20_COMMON|20221341|30010102001211000103000000000000", + "timestamp": "2024-12-15T10:53:49.561Z" + }, + "vid": { + "value": "DA-WM-WD-000001", + "timestamp": "2024-12-15T10:53:49.561Z" + }, + "mnmn": { + "value": "Samsung Electronics", + "timestamp": "2024-12-15T10:53:49.561Z" + }, + "mnml": { + "value": "http://www.samsung.com", + "timestamp": "2024-12-15T10:53:49.561Z" + }, + "mnpv": { + "value": "DAWIT 2.0", + "timestamp": "2024-12-15T10:53:49.561Z" + }, + "mnos": { + "value": "TizenRT 1.0 + IPv6", + "timestamp": "2024-12-15T10:53:49.561Z" + }, + "pi": { + "value": "3a6c4e05-811d-5041-e956-3d04c424cbcd", + "timestamp": "2024-12-15T10:53:49.561Z" + }, + "icv": { + "value": "core.1.1.0", + "timestamp": "2024-12-15T10:53:49.561Z" + } + }, + "custom.dryerDryLevel": { + "dryerDryLevel": { + "value": "2", + "timestamp": "2025-03-09T19:47:36.806Z" + }, + "supportedDryerDryLevel": { + "value": ["none", "1", "2", "3"], + "timestamp": "2020-11-18T20:16:43.428Z" + } + }, + "samsungce.dryerAutoCycleLink": { + "dryerAutoCycleLink": { + "value": null, + "timestamp": "2020-08-11T12:41:38.646Z" + } + }, + "samsungce.dryerCycle": { + "dryerCycle": { + "value": "Table_00_Course_9A", + "timestamp": "2025-03-09T16:31:41.247Z" + }, + "supportedCycles": { + "value": [ + { + "cycle": "9A", + "supportedOptions": { + "dryingLevel": { + "raw": "D20E", + "default": "2", + "options": ["1", "2", "3"] + } + } + }, + { + "cycle": "CA", + "supportedOptions": { + "dryingLevel": { + "raw": "D10E", + "default": "1", + "options": ["1", "2", "3"] + } + } + }, + { + "cycle": "DB", + "supportedOptions": { + "dryingLevel": { + "raw": "D204", + "default": "2", + "options": ["2"] + } + } + }, + { + "cycle": "99", + "supportedOptions": { + "dryingLevel": { + "raw": "D20E", + "default": "2", + "options": ["1", "2", "3"] + } + } + }, + { + "cycle": "93", + "supportedOptions": { + "dryingLevel": { + "raw": "D102", + "default": "1", + "options": ["1"] + } + } + }, + { + "cycle": "B5", + "supportedOptions": { + "dryingLevel": { + "raw": "D102", + "default": "1", + "options": ["1"] + } + } + }, + { + "cycle": "D7", + "supportedOptions": { + "dryingLevel": { + "raw": "D204", + "default": "2", + "options": ["2"] + } + } + }, + { + "cycle": "A5", + "supportedOptions": { + "dryingLevel": { + "raw": "D204", + "default": "2", + "options": ["2"] + } + } + }, + { + "cycle": "96", + "supportedOptions": { + "dryingLevel": { + "raw": "D000", + "default": "none", + "options": [] + } + } + }, + { + "cycle": "97", + "supportedOptions": { + "dryingLevel": { + "raw": "D000", + "default": "none", + "options": [] + } + } + }, + { + "cycle": "7F", + "supportedOptions": { + "dryingLevel": { + "raw": "D000", + "default": "none", + "options": [] + } + } + }, + { + "cycle": "98", + "supportedOptions": { + "dryingLevel": { + "raw": "D000", + "default": "none", + "options": [] + } + } + }, + { + "cycle": "EB", + "supportedOptions": { + "dryingLevel": { + "raw": "D204", + "default": "2", + "options": ["2"] + } + } + }, + { + "cycle": "B6", + "supportedOptions": { + "dryingLevel": { + "raw": "D20E", + "default": "2", + "options": ["1", "2", "3"] + } + } + } + ], + "timestamp": "2025-02-10T02:24:03.524Z" + }, + "referenceTable": { + "value": { + "id": "Table_00" + }, + "timestamp": "2025-03-09T16:31:41.247Z" + }, + "specializedFunctionClassification": { + "value": 4, + "timestamp": "2025-03-09T16:31:40.486Z" + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": [ + "samsungce.dryerDelayEnd", + "dryerOperatingState", + "samsungce.dryerCyclePreset", + "samsungce.welcomeMessage", + "samsungce.dongleSoftwareInstallation", + "sec.wifiConfiguration", + "samsungce.quickControl", + "samsungce.deviceInfoPrivate", + "demandResponseLoadControl", + "samsungce.dryerFreezePrevent", + "samsungce.dryerDryingTemperature", + "sec.diagnosticsInformation" + ], + "timestamp": "2024-07-02T14:42:38.334Z" + } + }, + "samsungce.driverVersion": { + "versionNumber": { + "value": 24110101, + "timestamp": "2024-12-02T07:43:41.263Z" + } + }, + "sec.diagnosticsInformation": { + "logType": { + "value": null + }, + "endpoint": { + "value": null + }, + "minVersion": { + "value": null + }, + "signinPermission": { + "value": null + }, + "setupId": { + "value": null + }, + "protocolType": { + "value": null + }, + "tsId": { + "value": null + }, + "mnId": { + "value": null + }, + "dumpType": { + "value": null + } + }, + "samsungce.kidsLock": { + "lockState": { + "value": "unlocked", + "timestamp": "2025-03-09T16:31:40.882Z" + } + }, + "demandResponseLoadControl": { + "drlcStatus": { + "value": null + } + }, + "samsungce.detergentOrder": { + "alarmEnabled": { + "value": false, + "timestamp": "2025-03-09T16:31:40.486Z" + }, + "orderThreshold": { + "value": 0, + "unit": "cc", + "timestamp": "2025-03-09T16:31:40.486Z" + } + }, + "powerConsumptionReport": { + "powerConsumption": { + "value": { + "energy": 796400, + "deltaEnergy": 0, + "power": 0, + "powerEnergy": 0.0, + "persistedEnergy": 0, + "energySaved": 0, + "start": "2025-03-09T19:47:26Z", + "end": "2025-03-09T19:47:37Z" + }, + "timestamp": "2025-03-09T19:47:37.283Z" + } + }, + "dryerOperatingState": { + "completionTime": { + "value": "2025-03-09T22:55:37Z", + "timestamp": "2025-03-09T19:47:37.015Z" + }, + "machineState": { + "value": "stop", + "timestamp": "2025-03-09T19:47:37.015Z" + }, + "supportedMachineStates": { + "value": ["stop", "run", "pause"], + "timestamp": "2025-03-09T16:31:41.172Z" + }, + "dryerJobState": { + "value": "none", + "timestamp": "2025-03-09T19:47:37.015Z" + } + }, + "samsungce.detergentState": { + "remainingAmount": { + "value": 0, + "unit": "cc", + "timestamp": "2025-03-09T16:31:40.486Z" + }, + "dosage": { + "value": 0, + "unit": "cc", + "timestamp": "2025-03-09T16:31:40.486Z" + }, + "initialAmount": { + "value": 0, + "unit": "cc", + "timestamp": "2025-03-09T16:31:40.486Z" + }, + "detergentType": { + "value": "none", + "timestamp": "2021-04-02T18:29:51.428Z" + } + }, + "samsungce.dryerDelayEnd": { + "remainingTime": { + "value": 0, + "unit": "min", + "timestamp": "2025-03-09T16:31:41.172Z" + } + }, + "refresh": {}, + "custom.jobBeginningStatus": { + "jobBeginningStatus": { + "value": null, + "timestamp": "2020-06-25T01:53:34.974Z" + } + }, + "execute": { + "data": { + "value": { + "payload": { + "rt": ["x.com.samsung.da.information"], + "if": ["oic.if.baseline", "oic.if.a"], + "x.com.samsung.da.modelNum": "DA_WM_A51_20_COMMON|20221341|30010102001211000103000000000000", + "x.com.samsung.da.description": "DA_WM_A51_20_COMMON_DV6800N/DC92-01967B_0404", + "x.com.samsung.da.serialNum": "0T625AEN100200N", + "x.com.samsung.da.otnDUID": "SHCDM6YAPCCXC", + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "DA_WM_A51_20_COMMON|20221341|30010102001211000103000000000000", + "x.com.samsung.da.type": "Software", + "x.com.samsung.da.number": "02198A220728(E256)", + "x.com.samsung.da.newVersionAvailable": "0" + }, + { + "x.com.samsung.da.id": "1", + "x.com.samsung.da.description": "DA_WM_A51_20_COMMON", + "x.com.samsung.da.type": "Firmware", + "x.com.samsung.da.number": "17111305,19060420", + "x.com.samsung.da.newVersionAvailable": "0" + } + ] + } + }, + "data": { + "href": "/information/vs/0" + }, + "timestamp": "2023-08-07T00:06:05.984Z" + } + }, + "sec.wifiConfiguration": { + "autoReconnection": { + "value": null + }, + "minVersion": { + "value": null + }, + "supportedWiFiFreq": { + "value": null + }, + "supportedAuthType": { + "value": null + }, + "protocolType": { + "value": null + } + }, + "remoteControlStatus": { + "remoteControlEnabled": { + "value": "false", + "timestamp": "2025-03-09T16:31:41.180Z" + } + }, + "custom.supportedOptions": { + "course": { + "value": null + }, + "referenceTable": { + "value": { + "id": "Table_00" + }, + "timestamp": "2025-03-09T16:31:41.247Z" + }, + "supportedCourses": { + "value": [ + "9A", + "CA", + "DB", + "99", + "93", + "B5", + "D7", + "A5", + "96", + "97", + "7F", + "98", + "EB", + "B6" + ], + "timestamp": "2025-03-09T16:31:40.486Z" + } + }, + "custom.energyType": { + "energyType": { + "value": "2.0", + "timestamp": "2022-06-17T17:07:35.734Z" + }, + "energySavingSupport": { + "value": false, + "timestamp": "2022-06-17T17:07:35.734Z" + }, + "drMaxDuration": { + "value": null + }, + "energySavingLevel": { + "value": null + }, + "energySavingInfo": { + "value": null + }, + "supportedEnergySavingLevels": { + "value": null + }, + "energySavingOperation": { + "value": null + }, + "notificationTemplateID": { + "value": null + }, + "energySavingOperationSupport": { + "value": null + } + }, + "samsungce.dryerOperatingState": { + "operatingState": { + "value": "ready", + "timestamp": "2025-03-09T19:47:37.015Z" + }, + "supportedOperatingStates": { + "value": ["ready", "running", "paused"], + "timestamp": "2022-11-01T12:48:22.390Z" + }, + "scheduledJobs": { + "value": [ + { + "jobName": "drying", + "timeInMin": 192 + }, + { + "jobName": "cooling", + "timeInMin": 1 + } + ], + "timestamp": "2025-03-09T16:31:40.486Z" + }, + "progress": { + "value": 1, + "unit": "%", + "timestamp": "2025-03-09T19:47:37.015Z" + }, + "remainingTimeStr": { + "value": "03:08", + "timestamp": "2025-03-09T19:47:37.015Z" + }, + "dryerJobState": { + "value": "none", + "timestamp": "2025-03-09T19:47:37.015Z" + }, + "remainingTime": { + "value": 188, + "unit": "min", + "timestamp": "2025-03-09T19:47:37.015Z" + } + }, + "samsungce.softwareUpdate": { + "targetModule": { + "value": null + }, + "otnDUID": { + "value": "SHCDM6YAPCCXC", + "timestamp": "2025-03-09T16:31:40.834Z" + }, + "lastUpdatedDate": { + "value": null + }, + "availableModules": { + "value": [], + "timestamp": "2024-12-01T21:16:50.598Z" + }, + "newVersionAvailable": { + "value": false, + "timestamp": "2024-12-01T21:16:50.598Z" + }, + "operatingState": { + "value": null + }, + "progress": { + "value": null + } + }, + "samsungce.dryerDryingTime": { + "supportedDryingTime": { + "value": ["0", "30", "60", "90", "120", "150"], + "timestamp": "2021-04-02T18:29:51.428Z" + }, + "dryingTime": { + "value": "0", + "unit": "min", + "timestamp": "2025-03-09T16:31:41.077Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/devices/da_wm_wd_000001_1.json b/tests/components/smartthings/fixtures/devices/da_wm_wd_000001_1.json new file mode 100644 index 00000000000..995646438c4 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/da_wm_wd_000001_1.json @@ -0,0 +1,205 @@ +{ + "items": [ + { + "deviceId": "3a6c4e05-811d-5041-e956-3d04c424cbcd", + "name": "[dryer] Samsung", + "label": "Seca-Roupa", + "manufacturerName": "Samsung Electronics", + "presentationId": "DA-WM-WD-000001", + "deviceManufacturerCode": "Samsung Electronics", + "locationId": "06efa178-ad2f-4d22-838c-d63e05e5a58a", + "ownerId": "1a5f5619-e9ec-4302-beb9-633bb1657897", + "roomId": "dde24053-9707-49a5-ba0e-f19681514f37", + "deviceTypeName": "Samsung OCF Dryer", + "components": [ + { + "id": "main", + "label": "Seca-Roupa", + "capabilities": [ + { + "id": "ocf", + "version": 1 + }, + { + "id": "execute", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "switch", + "version": 1 + }, + { + "id": "remoteControlStatus", + "version": 1 + }, + { + "id": "dryerOperatingState", + "version": 1 + }, + { + "id": "powerConsumptionReport", + "version": 1 + }, + { + "id": "demandResponseLoadControl", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + }, + { + "id": "custom.dryerDryLevel", + "version": 1 + }, + { + "id": "custom.dryerWrinklePrevent", + "version": 1 + }, + { + "id": "custom.energyType", + "version": 1 + }, + { + "id": "custom.jobBeginningStatus", + "version": 1 + }, + { + "id": "custom.supportedOptions", + "version": 1 + }, + { + "id": "samsungce.softwareUpdate", + "version": 1 + }, + { + "id": "samsungce.detergentOrder", + "version": 1 + }, + { + "id": "samsungce.detergentState", + "version": 1 + }, + { + "id": "samsungce.deviceIdentification", + "version": 1 + }, + { + "id": "samsungce.dongleSoftwareInstallation", + "version": 1 + }, + { + "id": "samsungce.driverVersion", + "version": 1 + }, + { + "id": "samsungce.dryerAutoCycleLink", + "version": 1 + }, + { + "id": "samsungce.dryerCycle", + "version": 1 + }, + { + "id": "samsungce.dryerCyclePreset", + "version": 1 + }, + { + "id": "samsungce.dryerDelayEnd", + "version": 1 + }, + { + "id": "samsungce.dryerDryingTemperature", + "version": 1 + }, + { + "id": "samsungce.dryerDryingTime", + "version": 1 + }, + { + "id": "samsungce.dryerFreezePrevent", + "version": 1 + }, + { + "id": "samsungce.dryerOperatingState", + "version": 1 + }, + { + "id": "samsungce.kidsLock", + "version": 1 + }, + { + "id": "samsungce.welcomeMessage", + "version": 1 + }, + { + "id": "samsungce.quickControl", + "version": 1 + }, + { + "id": "sec.diagnosticsInformation", + "version": 1 + }, + { + "id": "sec.wifiConfiguration", + "version": 1 + } + ], + "categories": [ + { + "name": "Dryer", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "hca.main", + "label": "hca.main", + "capabilities": [ + { + "id": "hca.dryerMode", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2020-06-20T10:00:42Z", + "profile": { + "id": "53a1d049-eeda-396c-8324-e33438ef57be" + }, + "ocf": { + "ocfDeviceType": "oic.d.dryer", + "name": "[dryer] Samsung", + "specVersion": "core.1.1.0", + "verticalDomainSpecVersion": "res.1.1.0,sh.1.1.0", + "manufacturerName": "Samsung Electronics", + "modelNumber": "DA_WM_A51_20_COMMON|20221341|30010102001211000103000000000000", + "platformVersion": "DAWIT 2.0", + "platformOS": "TizenRT 1.0 + IPv6", + "hwVersion": "ARTIK051", + "firmwareVersion": "DA_WM_A51_20_COMMON_30230708", + "vendorId": "DA-WM-WD-000001", + "vendorResourceClientServerVersion": "ARTIK051 Release 2.210224.1", + "lastSignupTime": "2020-11-19T04:43:50.736Z", + "transferCandidate": false, + "additionalAuthCodeRequired": false + }, + "type": "OCF", + "restrictionTier": 0, + "allowed": null, + "executionContext": "CLOUD", + "relationships": [] + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/snapshots/test_init.ambr b/tests/components/smartthings/snapshots/test_init.ambr index 9651575e337..13958d942f3 100644 --- a/tests/components/smartthings/snapshots/test_init.ambr +++ b/tests/components/smartthings/snapshots/test_init.ambr @@ -497,6 +497,39 @@ 'via_device_id': None, }) # --- +# name: test_devices[da_wm_wd_000001_1] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': 'ARTIK051', + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '3a6c4e05-811d-5041-e956-3d04c424cbcd', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Samsung Electronics', + 'model': 'DA_WM_A51_20_COMMON', + 'model_id': None, + 'name': 'Seca-Roupa', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': 'DA_WM_A51_20_COMMON_30230708', + 'via_device_id': None, + }) +# --- # name: test_devices[da_wm_wm_000001] DeviceRegistryEntrySnapshot({ 'area_id': None, diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index b939547ca32..e7b36e7d028 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -3532,6 +3532,473 @@ 'state': '0.0', }) # --- +# name: test_all_entities[da_wm_wd_000001_1][sensor.seca_roupa_completion_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.seca_roupa_completion_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Completion time', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'completion_time', + 'unique_id': '3a6c4e05-811d-5041-e956-3d04c424cbcd.completionTime', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wd_000001_1][sensor.seca_roupa_completion_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Seca-Roupa Completion time', + }), + 'context': , + 'entity_id': 'sensor.seca_roupa_completion_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2025-03-09T22:55:37+00:00', + }) +# --- +# name: test_all_entities[da_wm_wd_000001_1][sensor.seca_roupa_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.seca_roupa_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '3a6c4e05-811d-5041-e956-3d04c424cbcd.energy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_wm_wd_000001_1][sensor.seca_roupa_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Seca-Roupa Energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.seca_roupa_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '796.4', + }) +# --- +# name: test_all_entities[da_wm_wd_000001_1][sensor.seca_roupa_energy_difference-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.seca_roupa_energy_difference', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy difference', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_difference', + 'unique_id': '3a6c4e05-811d-5041-e956-3d04c424cbcd.deltaEnergy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_wm_wd_000001_1][sensor.seca_roupa_energy_difference-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Seca-Roupa Energy difference', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.seca_roupa_energy_difference', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_wm_wd_000001_1][sensor.seca_roupa_energy_saved-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.seca_roupa_energy_saved', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy saved', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_saved', + 'unique_id': '3a6c4e05-811d-5041-e956-3d04c424cbcd.energySaved_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_wm_wd_000001_1][sensor.seca_roupa_energy_saved-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Seca-Roupa Energy saved', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.seca_roupa_energy_saved', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_wm_wd_000001_1][sensor.seca_roupa_job_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'cooling', + 'delay_wash', + 'drying', + 'finished', + 'none', + 'refreshing', + 'weight_sensing', + 'wrinkle_prevent', + 'dehumidifying', + 'ai_drying', + 'sanitizing', + 'internal_care', + 'freeze_protection', + 'continuous_dehumidifying', + 'thawing_frozen_inside', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.seca_roupa_job_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Job state', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'dryer_job_state', + 'unique_id': '3a6c4e05-811d-5041-e956-3d04c424cbcd.dryerJobState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wd_000001_1][sensor.seca_roupa_job_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Seca-Roupa Job state', + 'options': list([ + 'cooling', + 'delay_wash', + 'drying', + 'finished', + 'none', + 'refreshing', + 'weight_sensing', + 'wrinkle_prevent', + 'dehumidifying', + 'ai_drying', + 'sanitizing', + 'internal_care', + 'freeze_protection', + 'continuous_dehumidifying', + 'thawing_frozen_inside', + ]), + }), + 'context': , + 'entity_id': 'sensor.seca_roupa_job_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'none', + }) +# --- +# name: test_all_entities[da_wm_wd_000001_1][sensor.seca_roupa_machine_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'pause', + 'run', + 'stop', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.seca_roupa_machine_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Machine state', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'dryer_machine_state', + 'unique_id': '3a6c4e05-811d-5041-e956-3d04c424cbcd.machineState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wd_000001_1][sensor.seca_roupa_machine_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Seca-Roupa Machine state', + 'options': list([ + 'pause', + 'run', + 'stop', + ]), + }), + 'context': , + 'entity_id': 'sensor.seca_roupa_machine_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'stop', + }) +# --- +# name: test_all_entities[da_wm_wd_000001_1][sensor.seca_roupa_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.seca_roupa_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '3a6c4e05-811d-5041-e956-3d04c424cbcd.power_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_wm_wd_000001_1][sensor.seca_roupa_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Seca-Roupa Power', + 'power_consumption_end': '2025-03-09T19:47:37Z', + 'power_consumption_start': '2025-03-09T19:47:26Z', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.seca_roupa_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_all_entities[da_wm_wd_000001_1][sensor.seca_roupa_power_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.seca_roupa_power_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power energy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_energy', + 'unique_id': '3a6c4e05-811d-5041-e956-3d04c424cbcd.powerEnergy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_wm_wd_000001_1][sensor.seca_roupa_power_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Seca-Roupa Power energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.seca_roupa_power_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- # name: test_all_entities[da_wm_wm_000001][sensor.washer_completion_time-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/smartthings/snapshots/test_switch.ambr b/tests/components/smartthings/snapshots/test_switch.ambr index 81b73874a6a..e119428c183 100644 --- a/tests/components/smartthings/snapshots/test_switch.ambr +++ b/tests/components/smartthings/snapshots/test_switch.ambr @@ -234,6 +234,53 @@ 'state': 'off', }) # --- +# name: test_all_entities[da_wm_wd_000001_1][switch.seca_roupa-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.seca_roupa', + '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': None, + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '3a6c4e05-811d-5041-e956-3d04c424cbcd', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wd_000001_1][switch.seca_roupa-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Seca-Roupa', + }), + 'context': , + 'entity_id': 'switch.seca_roupa', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_all_entities[da_wm_wm_000001][switch.washer-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From d67ccd2fce257b29fe280baed4db0fb322bd92b2 Mon Sep 17 00:00:00 2001 From: Antoine Reversat Date: Mon, 10 Mar 2025 12:37:30 -0400 Subject: [PATCH 2506/3148] FGLair : Upgrade to ayla-iot-unofficial 1.4.7 (#140296) Upgrade to ayla-iot-unofficial 1.4.7 --- homeassistant/components/fujitsu_fglair/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/fujitsu_fglair/manifest.json b/homeassistant/components/fujitsu_fglair/manifest.json index 330685f89fc..c8fed9b45c9 100644 --- a/homeassistant/components/fujitsu_fglair/manifest.json +++ b/homeassistant/components/fujitsu_fglair/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/fujitsu_fglair", "iot_class": "cloud_polling", - "requirements": ["ayla-iot-unofficial==1.4.5"] + "requirements": ["ayla-iot-unofficial==1.4.7"] } diff --git a/requirements_all.txt b/requirements_all.txt index 1fd4fe9bf96..d7955d9f687 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -557,7 +557,7 @@ av==13.1.0 axis==64 # homeassistant.components.fujitsu_fglair -ayla-iot-unofficial==1.4.5 +ayla-iot-unofficial==1.4.7 # homeassistant.components.azure_event_hub azure-eventhub==5.11.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 98cae017b68..6aec4b384af 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -506,7 +506,7 @@ av==13.1.0 axis==64 # homeassistant.components.fujitsu_fglair -ayla-iot-unofficial==1.4.5 +ayla-iot-unofficial==1.4.7 # homeassistant.components.azure_event_hub azure-eventhub==5.11.1 From 5f158f5c87facafd451ce37f4e11015aa862fae2 Mon Sep 17 00:00:00 2001 From: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> Date: Tue, 11 Mar 2025 03:18:31 -0500 Subject: [PATCH 2507/3148] Bump pyheos to v1.0.3 (#140310) Bump pyheos v1.0.3 --- homeassistant/components/heos/coordinator.py | 3 +- homeassistant/components/heos/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../heos/snapshots/test_diagnostics.ambr | 4 ++ tests/components/heos/test_init.py | 4 +- tests/components/heos/test_media_player.py | 38 +------------------ 7 files changed, 11 insertions(+), 44 deletions(-) diff --git a/homeassistant/components/heos/coordinator.py b/homeassistant/components/heos/coordinator.py index 0303d150794..93fe069d9be 100644 --- a/homeassistant/components/heos/coordinator.py +++ b/homeassistant/components/heos/coordinator.py @@ -159,13 +159,12 @@ class HeosCoordinator(DataUpdateCoordinator[None]): async def _async_on_reconnected(self) -> None: """Handle when reconnected so resources are updated and entities marked available.""" - await self._async_update_players() await self._async_update_sources() _LOGGER.warning("Successfully reconnected to HEOS host %s", self.host) self.async_update_listeners() async def _async_on_controller_event( - self, event: str, data: PlayerUpdateResult | None + self, event: str, data: PlayerUpdateResult | None = None ) -> None: """Handle a controller event, such as players or groups changed.""" if event == const.EVENT_PLAYERS_CHANGED: diff --git a/homeassistant/components/heos/manifest.json b/homeassistant/components/heos/manifest.json index 573deda2132..19feffd8ef1 100644 --- a/homeassistant/components/heos/manifest.json +++ b/homeassistant/components/heos/manifest.json @@ -8,7 +8,7 @@ "iot_class": "local_push", "loggers": ["pyheos"], "quality_scale": "platinum", - "requirements": ["pyheos==1.0.2"], + "requirements": ["pyheos==1.0.3"], "ssdp": [ { "st": "urn:schemas-denon-com:device:ACT-Denon:1" diff --git a/requirements_all.txt b/requirements_all.txt index d7955d9f687..810b191b852 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1996,7 +1996,7 @@ pygti==0.9.4 pyhaversion==22.8.0 # homeassistant.components.heos -pyheos==1.0.2 +pyheos==1.0.3 # homeassistant.components.hive pyhive-integration==1.0.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6aec4b384af..b0cc730d9da 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1625,7 +1625,7 @@ pygti==0.9.4 pyhaversion==22.8.0 # homeassistant.components.heos -pyheos==1.0.2 +pyheos==1.0.3 # homeassistant.components.hive pyhive-integration==1.0.2 diff --git a/tests/components/heos/snapshots/test_diagnostics.ambr b/tests/components/heos/snapshots/test_diagnostics.ambr index 98ce8a7bcbf..58685f5cf8f 100644 --- a/tests/components/heos/snapshots/test_diagnostics.ambr +++ b/tests/components/heos/snapshots/test_diagnostics.ambr @@ -106,6 +106,7 @@ 'model': 'HEOS Drive HS2', 'name': 'Test Player', 'network': 'wired', + 'preferred_host': True, 'serial': '**REDACTED**', 'supported_version': True, 'version': '1.0.0', @@ -116,6 +117,7 @@ 'model': 'HEOS Drive HS2', 'name': 'Test Player', 'network': 'wired', + 'preferred_host': True, 'serial': '**REDACTED**', 'supported_version': True, 'version': '1.0.0', @@ -125,6 +127,7 @@ 'model': 'Speaker', 'name': 'Test Player 2', 'network': 'wifi', + 'preferred_host': False, 'serial': '**REDACTED**', 'supported_version': True, 'version': '1.0.0', @@ -137,6 +140,7 @@ 'model': 'HEOS Drive HS2', 'name': 'Test Player', 'network': 'wired', + 'preferred_host': True, 'serial': '**REDACTED**', 'supported_version': True, 'version': '1.0.0', diff --git a/tests/components/heos/test_init.py b/tests/components/heos/test_init.py index 87cc8dd7dde..b155abaf0e9 100644 --- a/tests/components/heos/test_init.py +++ b/tests/components/heos/test_init.py @@ -285,11 +285,11 @@ async def test_reconnected_new_entities_created( players = controller.players.copy() players[3] = player_factory(3, "Test Player 3", "HEOS Link") controller.mock_set_players(players) - controller.load_players.return_value = PlayerUpdateResult([3], [], {}) + update = PlayerUpdateResult([3], [], {}) # Simulate reconnection await controller.dispatcher.wait_send( - SignalType.HEOS_EVENT, SignalHeosEvent.CONNECTED + SignalType.CONTROLLER_EVENT, const.EVENT_PLAYERS_CHANGED, update ) await hass.async_block_till_done() diff --git a/tests/components/heos/test_media_player.py b/tests/components/heos/test_media_player.py index 3e755a29a0a..debfe31f427 100644 --- a/tests/components/heos/test_media_player.py +++ b/tests/components/heos/test_media_player.py @@ -158,7 +158,6 @@ async def test_updates_from_connection_event( state = hass.states.get("media_player.test_player") assert state is not None assert state.state == STATE_IDLE - assert controller.load_players.call_count == 1 # Disconnected controller.load_players.reset_mock() @@ -170,11 +169,8 @@ async def test_updates_from_connection_event( state = hass.states.get("media_player.test_player") assert state is not None assert state.state == STATE_UNAVAILABLE - assert controller.load_players.call_count == 0 - # Connected handles refresh failure - controller.load_players.reset_mock() - controller.load_players.side_effect = CommandFailedError("", "Failure", 1) + # Reconnect and state updates player.available = True await controller.dispatcher.wait_send( SignalType.HEOS_EVENT, SignalHeosEvent.CONNECTED @@ -183,38 +179,6 @@ async def test_updates_from_connection_event( state = hass.states.get("media_player.test_player") assert state is not None assert state.state == STATE_IDLE - assert controller.load_players.call_count == 1 - assert "Unable to refresh players" in caplog.text - - -async def test_updates_from_connection_event_new_player_ids( - hass: HomeAssistant, - entity_registry: er.EntityRegistry, - device_registry: dr.DeviceRegistry, - config_entry: MockConfigEntry, - controller: MockHeos, - change_data_mapped_ids: PlayerUpdateResult, -) -> None: - """Test player ids changed after reconnection updates ids.""" - config_entry.add_to_hass(hass) - assert await hass.config_entries.async_setup(config_entry.entry_id) - - # Assert current IDs - assert device_registry.async_get_device(identifiers={(DOMAIN, "1")}) - assert entity_registry.async_get_entity_id(MEDIA_PLAYER_DOMAIN, DOMAIN, "1") - - # Send event which will result in updated IDs. - controller.load_players.return_value = change_data_mapped_ids - await controller.dispatcher.wait_send( - SignalType.HEOS_EVENT, SignalHeosEvent.CONNECTED - ) - await hass.async_block_till_done() - - # Assert updated IDs and previous don't exist - assert not device_registry.async_get_device(identifiers={(DOMAIN, "1")}) - assert device_registry.async_get_device(identifiers={(DOMAIN, "101")}) - assert not entity_registry.async_get_entity_id(MEDIA_PLAYER_DOMAIN, DOMAIN, "1") - assert entity_registry.async_get_entity_id(MEDIA_PLAYER_DOMAIN, DOMAIN, "101") async def test_updates_from_sources_updated( From cbfd8707b97ae06310df6192a29292193f5b4a01 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Tue, 11 Mar 2025 04:09:53 -0400 Subject: [PATCH 2508/3148] Bump ZHA to 0.0.52 (#140325) --- homeassistant/components/zha/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 0cc2524469e..d16ce5a64bf 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -21,7 +21,7 @@ "zha", "universal_silabs_flasher" ], - "requirements": ["zha==0.0.51"], + "requirements": ["zha==0.0.52"], "usb": [ { "vid": "10C4", diff --git a/requirements_all.txt b/requirements_all.txt index 810b191b852..adb5d18b822 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3149,7 +3149,7 @@ zeroconf==0.145.1 zeversolar==0.3.2 # homeassistant.components.zha -zha==0.0.51 +zha==0.0.52 # homeassistant.components.zhong_hong zhong-hong-hvac==1.0.13 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b0cc730d9da..3e9559c12fc 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2538,7 +2538,7 @@ zeroconf==0.145.1 zeversolar==0.3.2 # homeassistant.components.zha -zha==0.0.51 +zha==0.0.52 # homeassistant.components.zwave_js zwave-js-server-python==0.60.1 From 29987d443edbbe78c990269f281b1891aa144fa9 Mon Sep 17 00:00:00 2001 From: David Knowles Date: Tue, 11 Mar 2025 04:16:44 -0400 Subject: [PATCH 2509/3148] Bump pydrawise to 2025.3.0 (#140330) --- homeassistant/components/hydrawise/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/hydrawise/manifest.json b/homeassistant/components/hydrawise/manifest.json index 73423882e4a..0c355c34a71 100644 --- a/homeassistant/components/hydrawise/manifest.json +++ b/homeassistant/components/hydrawise/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/hydrawise", "iot_class": "cloud_polling", "loggers": ["pydrawise"], - "requirements": ["pydrawise==2025.2.0"] + "requirements": ["pydrawise==2025.3.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index adb5d18b822..f5ec528c32c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1906,7 +1906,7 @@ pydiscovergy==3.0.2 pydoods==1.0.2 # homeassistant.components.hydrawise -pydrawise==2025.2.0 +pydrawise==2025.3.0 # homeassistant.components.android_ip_webcam pydroid-ipcam==2.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3e9559c12fc..28cb28fee6e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1556,7 +1556,7 @@ pydexcom==0.2.3 pydiscovergy==3.0.2 # homeassistant.components.hydrawise -pydrawise==2025.2.0 +pydrawise==2025.3.0 # homeassistant.components.android_ip_webcam pydroid-ipcam==2.0.0 From 0318b85517faeab11219f6d4505ce65d1abced22 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Tue, 11 Mar 2025 18:06:29 +1000 Subject: [PATCH 2510/3148] Bump teslemetry-stream (#140335) Bump --- homeassistant/components/teslemetry/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/teslemetry/manifest.json b/homeassistant/components/teslemetry/manifest.json index 4e9228acd2f..7c27024d9f0 100644 --- a/homeassistant/components/teslemetry/manifest.json +++ b/homeassistant/components/teslemetry/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/teslemetry", "iot_class": "cloud_polling", "loggers": ["tesla-fleet-api"], - "requirements": ["tesla-fleet-api==0.9.12", "teslemetry-stream==0.6.10"] + "requirements": ["tesla-fleet-api==0.9.12", "teslemetry-stream==0.6.12"] } diff --git a/requirements_all.txt b/requirements_all.txt index f5ec528c32c..e02eb2873d6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2881,7 +2881,7 @@ tesla-powerwall==0.5.2 tesla-wall-connector==1.0.2 # homeassistant.components.teslemetry -teslemetry-stream==0.6.10 +teslemetry-stream==0.6.12 # homeassistant.components.tessie tessie-api==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 28cb28fee6e..2914c8e09f7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2321,7 +2321,7 @@ tesla-powerwall==0.5.2 tesla-wall-connector==1.0.2 # homeassistant.components.teslemetry -teslemetry-stream==0.6.10 +teslemetry-stream==0.6.12 # homeassistant.components.tessie tessie-api==0.1.1 From e6dea4179b186b841b87a095df4572f6fb2f10c9 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 11 Mar 2025 14:03:15 +0100 Subject: [PATCH 2511/3148] Fix no temperature unit in SmartThings (#140363) --- .../components/smartthings/climate.py | 12 +- tests/components/smartthings/conftest.py | 1 + .../ecobee_thermostat_offline.json | 81 ++++++++++++++ .../devices/ecobee_thermostat_offline.json | 82 ++++++++++++++ .../smartthings/snapshots/test_climate.ambr | 64 +++++++++++ .../smartthings/snapshots/test_init.ambr | 33 ++++++ .../smartthings/snapshots/test_sensor.ambr | 103 ++++++++++++++++++ 7 files changed, 372 insertions(+), 4 deletions(-) create mode 100644 tests/components/smartthings/fixtures/device_status/ecobee_thermostat_offline.json create mode 100644 tests/components/smartthings/fixtures/devices/ecobee_thermostat_offline.json diff --git a/homeassistant/components/smartthings/climate.py b/homeassistant/components/smartthings/climate.py index 7299be699b7..f80d5b8afab 100644 --- a/homeassistant/components/smartthings/climate.py +++ b/homeassistant/components/smartthings/climate.py @@ -318,10 +318,14 @@ class SmartThingsThermostat(SmartThingsEntity, ClimateEntity): @property def temperature_unit(self) -> str: """Return the unit of measurement.""" - unit = self._internal_state[Capability.TEMPERATURE_MEASUREMENT][ - Attribute.TEMPERATURE - ].unit - assert unit + # Offline third party thermostats may not have a unit + # Since climate always requires a unit, default to Celsius + if ( + unit := self._internal_state[Capability.TEMPERATURE_MEASUREMENT][ + Attribute.TEMPERATURE + ].unit + ) is None: + return UnitOfTemperature.CELSIUS return UNIT_MAP[unit] diff --git a/tests/components/smartthings/conftest.py b/tests/components/smartthings/conftest.py index 2fac8e99456..b314e74e5c4 100644 --- a/tests/components/smartthings/conftest.py +++ b/tests/components/smartthings/conftest.py @@ -116,6 +116,7 @@ def mock_smartthings() -> Generator[AsyncMock]: "sensibo_airconditioner_1", "ecobee_sensor", "ecobee_thermostat", + "ecobee_thermostat_offline", "fake_fan", "generic_fan_3_speed", "heatit_ztrm3_thermostat", diff --git a/tests/components/smartthings/fixtures/device_status/ecobee_thermostat_offline.json b/tests/components/smartthings/fixtures/device_status/ecobee_thermostat_offline.json new file mode 100644 index 00000000000..fdda31783f6 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/ecobee_thermostat_offline.json @@ -0,0 +1,81 @@ +{ + "components": { + "main": { + "relativeHumidityMeasurement": { + "humidity": { + "value": null + } + }, + "thermostatOperatingState": { + "thermostatOperatingState": { + "value": null + } + }, + "healthCheck": { + "checkInterval": { + "value": 60, + "unit": "s", + "data": { + "deviceScheme": "UNTRACKED", + "protocol": "cloud" + }, + "timestamp": "2025-03-10T00:57:26.866Z" + }, + "healthStatus": { + "value": null + }, + "DeviceWatch-Enroll": { + "value": null + }, + "DeviceWatch-DeviceStatus": { + "value": "offline", + "data": { + "reason": "DEVICE-OFFLINE" + }, + "timestamp": "2025-03-11T10:22:17.013Z" + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": null + } + }, + "thermostatHeatingSetpoint": { + "heatingSetpoint": { + "value": null + }, + "heatingSetpointRange": { + "value": null + } + }, + "thermostatFanMode": { + "thermostatFanMode": { + "value": null + }, + "supportedThermostatFanModes": { + "value": null + } + }, + "refresh": {}, + "thermostatMode": { + "thermostatMode": { + "value": null + }, + "supportedThermostatModes": { + "value": null + } + }, + "thermostatCoolingSetpoint": { + "coolingSetpointRange": { + "value": null + }, + "coolingSetpoint": { + "value": null + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/devices/ecobee_thermostat_offline.json b/tests/components/smartthings/fixtures/devices/ecobee_thermostat_offline.json new file mode 100644 index 00000000000..5fe8d8d28be --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/ecobee_thermostat_offline.json @@ -0,0 +1,82 @@ +{ + "items": [ + { + "deviceId": "1888b38f-6246-4f1e-911b-bfcfb66999db", + "name": "v4 - ecobee Thermostat - Heat and Cool (F)", + "label": "Downstairs", + "manufacturerName": "0A0b", + "presentationId": "ST_5334da38-8076-4b40-9f6c-ac3fccaa5d24", + "deviceManufacturerCode": "ecobee", + "locationId": "1030449a-22c1-4a80-9781-0bd4ab7f0f2f", + "ownerId": "e7dbb793-4351-4cdc-b037-e6e0b4f9df67", + "roomId": "d22e6f98-78fe-4a76-b904-6cad8628da59", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "relativeHumidityMeasurement", + "version": 1 + }, + { + "id": "thermostatHeatingSetpoint", + "version": 1 + }, + { + "id": "thermostatCoolingSetpoint", + "version": 1 + }, + { + "id": "thermostatOperatingState", + "version": 1 + }, + { + "id": "thermostatMode", + "version": 1 + }, + { + "id": "thermostatFanMode", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "healthCheck", + "version": 1 + } + ], + "categories": [ + { + "name": "Thermostat", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2025-03-10T00:57:26.760Z", + "profile": { + "id": "234d537d-d388-497f-b0f4-2e25025119ba" + }, + "viper": { + "manufacturerName": "ecobee", + "modelName": "nikeSmart-thermostat", + "swVersion": "250308073247", + "hwVersion": "250308073247", + "endpointAppId": "viper_92ccdcc0-4184-11eb-b9c5-036180216747" + }, + "type": "VIPER", + "restrictionTier": 0, + "allowed": null, + "executionContext": "CLOUD", + "relationships": [] + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/snapshots/test_climate.ambr b/tests/components/smartthings/snapshots/test_climate.ambr index 6b512f93d39..20389f38a46 100644 --- a/tests/components/smartthings/snapshots/test_climate.ambr +++ b/tests/components/smartthings/snapshots/test_climate.ambr @@ -432,6 +432,70 @@ 'state': 'heat', }) # --- +# name: test_all_entities[ecobee_thermostat_offline][climate.downstairs-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'fan_modes': None, + 'hvac_modes': list([ + ]), + 'max_temp': 35, + 'min_temp': 7, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.downstairs', + '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': None, + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '1888b38f-6246-4f1e-911b-bfcfb66999db', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[ecobee_thermostat_offline][climate.downstairs-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': None, + 'fan_mode': None, + 'fan_modes': None, + 'friendly_name': 'Downstairs', + 'hvac_modes': list([ + ]), + 'max_temp': 35, + 'min_temp': 7, + 'supported_features': , + 'target_temp_high': None, + 'target_temp_low': None, + 'temperature': None, + }), + 'context': , + 'entity_id': 'climate.downstairs', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_all_entities[generic_ef00_v1][climate.thermostat_kuche-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/smartthings/snapshots/test_init.ambr b/tests/components/smartthings/snapshots/test_init.ambr index 13958d942f3..401f5c88454 100644 --- a/tests/components/smartthings/snapshots/test_init.ambr +++ b/tests/components/smartthings/snapshots/test_init.ambr @@ -662,6 +662,39 @@ 'via_device_id': None, }) # --- +# name: test_devices[ecobee_thermostat_offline] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '250308073247', + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '1888b38f-6246-4f1e-911b-bfcfb66999db', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'ecobee', + 'model': 'nikeSmart-thermostat', + 'model_id': None, + 'name': 'Downstairs', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '250308073247', + 'via_device_id': None, + }) +# --- # name: test_devices[fake_fan] DeviceRegistryEntrySnapshot({ 'area_id': None, diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index e7b36e7d028..94fe1924fd2 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -5093,6 +5093,109 @@ 'state': '22', }) # --- +# name: test_all_entities[ecobee_thermostat_offline][sensor.downstairs_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.downstairs_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1888b38f-6246-4f1e-911b-bfcfb66999db.humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[ecobee_thermostat_offline][sensor.downstairs_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Downstairs Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.downstairs_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[ecobee_thermostat_offline][sensor.downstairs_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.downstairs_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': 'Temperature', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1888b38f-6246-4f1e-911b-bfcfb66999db.temperature', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[ecobee_thermostat_offline][sensor.downstairs_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Downstairs Temperature', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.downstairs_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_all_entities[generic_ef00_v1][sensor.thermostat_kuche_link_quality-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 4ddc43a9d91c1642b00f75582dae5b8ce7b43226 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Tue, 11 Mar 2025 14:06:44 +0100 Subject: [PATCH 2512/3148] Fix double space quoting in WebDAV (#140364) --- homeassistant/components/webdav/__init__.py | 13 ++- homeassistant/components/webdav/helpers.py | 29 ++++++ homeassistant/components/webdav/manifest.json | 2 +- homeassistant/components/webdav/strings.json | 3 + requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/webdav/__init__.py | 13 +++ tests/components/webdav/conftest.py | 1 + tests/components/webdav/test_init.py | 96 +++++++++++++++++++ 9 files changed, 154 insertions(+), 7 deletions(-) create mode 100644 tests/components/webdav/test_init.py diff --git a/homeassistant/components/webdav/__init__.py b/homeassistant/components/webdav/__init__.py index 952a68d829f..36a03dce4d7 100644 --- a/homeassistant/components/webdav/__init__.py +++ b/homeassistant/components/webdav/__init__.py @@ -13,7 +13,11 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady from .const import CONF_BACKUP_PATH, DATA_BACKUP_AGENT_LISTENERS, DOMAIN -from .helpers import async_create_client, async_ensure_path_exists +from .helpers import ( + async_create_client, + async_ensure_path_exists, + async_migrate_wrong_folder_path, +) type WebDavConfigEntry = ConfigEntry[Client] @@ -46,10 +50,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: WebDavConfigEntry) -> bo translation_key="cannot_connect", ) + path = entry.data.get(CONF_BACKUP_PATH, "/") + await async_migrate_wrong_folder_path(client, path) + # Ensure the backup directory exists - if not await async_ensure_path_exists( - client, entry.data.get(CONF_BACKUP_PATH, "/") - ): + if not await async_ensure_path_exists(client, path): raise ConfigEntryNotReady( translation_domain=DOMAIN, translation_key="cannot_access_or_create_backup_path", diff --git a/homeassistant/components/webdav/helpers.py b/homeassistant/components/webdav/helpers.py index 9f91ed3bdb3..5db15bba0f7 100644 --- a/homeassistant/components/webdav/helpers.py +++ b/homeassistant/components/webdav/helpers.py @@ -1,10 +1,18 @@ """Helper functions for the WebDAV component.""" +import logging + from aiowebdav2.client import Client, ClientOptions +from aiowebdav2.exceptions import WebDavError from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + @callback def async_create_client( @@ -36,3 +44,24 @@ async def async_ensure_path_exists(client: Client, path: str) -> bool: return False return True + + +async def async_migrate_wrong_folder_path(client: Client, path: str) -> None: + """Migrate the wrong encoded folder path to the correct one.""" + wrong_path = path.replace(" ", "%20") + if await client.check(wrong_path): + try: + await client.move(wrong_path, path) + except WebDavError as err: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="failed_to_migrate_folder", + translation_placeholders={ + "wrong_path": wrong_path, + "correct_path": path, + }, + ) from err + + _LOGGER.debug( + "Migrated wrong encoded folder path from %s to %s", wrong_path, path + ) diff --git a/homeassistant/components/webdav/manifest.json b/homeassistant/components/webdav/manifest.json index fd3c749781e..30028cb28c9 100644 --- a/homeassistant/components/webdav/manifest.json +++ b/homeassistant/components/webdav/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["aiowebdav2"], "quality_scale": "bronze", - "requirements": ["aiowebdav2==0.4.1"] + "requirements": ["aiowebdav2==0.4.2"] } diff --git a/homeassistant/components/webdav/strings.json b/homeassistant/components/webdav/strings.json index 57117cdd9de..b03ffaf2a3d 100644 --- a/homeassistant/components/webdav/strings.json +++ b/homeassistant/components/webdav/strings.json @@ -36,6 +36,9 @@ }, "cannot_access_or_create_backup_path": { "message": "Cannot access or create backup path. Please check the path and permissions." + }, + "failed_to_migrate_folder": { + "message": "Failed to migrate wrong encoded folder \"{wrong_path}\" to \"{correct_path}\"." } } } diff --git a/requirements_all.txt b/requirements_all.txt index e02eb2873d6..79570685c47 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -422,7 +422,7 @@ aiowaqi==3.1.0 aiowatttime==0.1.1 # homeassistant.components.webdav -aiowebdav2==0.4.1 +aiowebdav2==0.4.2 # homeassistant.components.webostv aiowebostv==0.7.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2914c8e09f7..ca183124a17 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -404,7 +404,7 @@ aiowaqi==3.1.0 aiowatttime==0.1.1 # homeassistant.components.webdav -aiowebdav2==0.4.1 +aiowebdav2==0.4.2 # homeassistant.components.webostv aiowebostv==0.7.3 diff --git a/tests/components/webdav/__init__.py b/tests/components/webdav/__init__.py index 33e0222fb34..3b901bdd308 100644 --- a/tests/components/webdav/__init__.py +++ b/tests/components/webdav/__init__.py @@ -1 +1,14 @@ """Tests for the WebDAV integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Set up the WebDAV integration for testing.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/webdav/conftest.py b/tests/components/webdav/conftest.py index 4fdd6fb7870..645e2111364 100644 --- a/tests/components/webdav/conftest.py +++ b/tests/components/webdav/conftest.py @@ -62,4 +62,5 @@ def mock_webdav_client() -> Generator[AsyncMock]: mock.download_iter.side_effect = _download_mock mock.upload_iter.return_value = None mock.clean.return_value = None + mock.move.return_value = None yield mock diff --git a/tests/components/webdav/test_init.py b/tests/components/webdav/test_init.py new file mode 100644 index 00000000000..c267f7c3251 --- /dev/null +++ b/tests/components/webdav/test_init.py @@ -0,0 +1,96 @@ +"""Test WebDAV component setup.""" + +from unittest.mock import AsyncMock + +from aiowebdav2.exceptions import WebDavError +import pytest + +from homeassistant.components.webdav.const import CONF_BACKUP_PATH, DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME +from homeassistant.core import HomeAssistant + +from . import setup_integration + +from tests.common import MockConfigEntry + + +async def test_migrate_wrong_path( + hass: HomeAssistant, webdav_client: AsyncMock +) -> None: + """Test migration of wrong encoded folder path.""" + webdav_client.list_with_properties.return_value = [ + {"/wrong%20path": []}, + ] + + config_entry = MockConfigEntry( + title="user@webdav.demo", + domain=DOMAIN, + data={ + CONF_URL: "https://webdav.demo", + CONF_USERNAME: "user", + CONF_PASSWORD: "supersecretpassword", + CONF_BACKUP_PATH: "/wrong path", + }, + entry_id="01JKXV07ASC62D620DGYNG2R8H", + ) + await setup_integration(hass, config_entry) + + webdav_client.move.assert_called_once_with("/wrong%20path", "/wrong path") + + +async def test_migrate_non_wrong_path( + hass: HomeAssistant, webdav_client: AsyncMock +) -> None: + """Test no migration of correct folder path.""" + webdav_client.list_with_properties.return_value = [ + {"/correct path": []}, + ] + webdav_client.check.side_effect = lambda path: path == "/correct path" + + config_entry = MockConfigEntry( + title="user@webdav.demo", + domain=DOMAIN, + data={ + CONF_URL: "https://webdav.demo", + CONF_USERNAME: "user", + CONF_PASSWORD: "supersecretpassword", + CONF_BACKUP_PATH: "/correct path", + }, + entry_id="01JKXV07ASC62D620DGYNG2R8H", + ) + + await setup_integration(hass, config_entry) + + webdav_client.move.assert_not_called() + + +async def test_migrate_error( + hass: HomeAssistant, + webdav_client: AsyncMock, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test migration of wrong encoded folder path with error.""" + webdav_client.list_with_properties.return_value = [ + {"/wrong%20path": []}, + ] + webdav_client.move.side_effect = WebDavError("Failed to move") + + config_entry = MockConfigEntry( + title="user@webdav.demo", + domain=DOMAIN, + data={ + CONF_URL: "https://webdav.demo", + CONF_USERNAME: "user", + CONF_PASSWORD: "supersecretpassword", + CONF_BACKUP_PATH: "/wrong path", + }, + entry_id="01JKXV07ASC62D620DGYNG2R8H", + ) + await setup_integration(hass, config_entry) + + assert config_entry.state is ConfigEntryState.SETUP_RETRY + assert ( + 'Failed to migrate wrong encoded folder "/wrong%20path" to "/wrong path"' + in caplog.text + ) From 5327996bad7c6908502703426c49178195ed9887 Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Tue, 11 Mar 2025 09:47:30 -0400 Subject: [PATCH 2513/3148] Bump python-roborock to 2.12.2 (#140368) bump python roboorck to 2.12.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 db2654d4baa..1b143591203 100644 --- a/homeassistant/components/roborock/manifest.json +++ b/homeassistant/components/roborock/manifest.json @@ -7,7 +7,7 @@ "iot_class": "local_polling", "loggers": ["roborock"], "requirements": [ - "python-roborock==2.11.1", + "python-roborock==2.12.2", "vacuum-map-parser-roborock==0.1.2" ] } diff --git a/requirements_all.txt b/requirements_all.txt index 79570685c47..e5ef86a66e1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2461,7 +2461,7 @@ python-rabbitair==0.0.8 python-ripple-api==0.0.3 # homeassistant.components.roborock -python-roborock==2.11.1 +python-roborock==2.12.2 # homeassistant.components.smarttub python-smarttub==0.0.39 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ca183124a17..a85de43702e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1994,7 +1994,7 @@ python-picnic-api2==1.2.2 python-rabbitair==0.0.8 # homeassistant.components.roborock -python-roborock==2.11.1 +python-roborock==2.12.2 # homeassistant.components.smarttub python-smarttub==0.0.39 From 8541dc5bde786476583b2f1dc9806ae987bf42ab Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 11 Mar 2025 14:10:06 +0100 Subject: [PATCH 2514/3148] Handle incomplete power consumption reports in SmartThings (#140370) --- .../components/smartthings/__init__.py | 26 ----- .../components/smartthings/sensor.py | 29 ++++- tests/components/smartthings/conftest.py | 1 + .../fixtures/device_status/tplink_p110.json | 46 ++++++++ .../fixtures/devices/tplink_p110.json | 73 ++++++++++++ .../smartthings/snapshots/test_init.ambr | 33 ++++++ .../smartthings/snapshots/test_sensor.ambr | 110 ++++++++++++++++++ .../smartthings/snapshots/test_switch.ambr | 47 ++++++++ 8 files changed, 337 insertions(+), 28 deletions(-) create mode 100644 tests/components/smartthings/fixtures/device_status/tplink_p110.json create mode 100644 tests/components/smartthings/fixtures/devices/tplink_p110.json diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py index e4dc4b0be7a..71fa4454fa0 100644 --- a/homeassistant/components/smartthings/__init__.py +++ b/homeassistant/components/smartthings/__init__.py @@ -203,28 +203,6 @@ KEEP_CAPABILITY_QUIRK: dict[ Capability.DEMAND_RESPONSE_LOAD_CONTROL: lambda _: True, } -POWER_CONSUMPTION_FIELDS = { - "energy", - "power", - "deltaEnergy", - "powerEnergy", - "energySaved", -} - -CAPABILITY_VALIDATION: dict[ - Capability | str, Callable[[dict[Attribute | str, Status]], bool] -] = { - Capability.POWER_CONSUMPTION_REPORT: ( - lambda status: ( - (power_consumption := status[Attribute.POWER_CONSUMPTION].value) is not None - and all( - field in cast(dict, power_consumption) - for field in POWER_CONSUMPTION_FIELDS - ) - ) - ) -} - def process_status( status: dict[str, dict[Capability | str, dict[Attribute | str, Status]]], @@ -248,8 +226,4 @@ def process_status( or not KEEP_CAPABILITY_QUIRK[capability](main_component[capability]) ): del main_component[capability] - for capability in list(main_component): - if capability in CAPABILITY_VALIDATION: - if not CAPABILITY_VALIDATION[capability](main_component[capability]): - del main_component[capability] return status diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index 1b7f59a20e9..f9070c6d718 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -5,9 +5,9 @@ from __future__ import annotations from collections.abc import Callable, Mapping from dataclasses import dataclass from datetime import datetime -from typing import Any +from typing import Any, cast -from pysmartthings import Attribute, Capability, SmartThings +from pysmartthings import Attribute, Capability, SmartThings, Status from homeassistant.components.sensor import ( SensorDeviceClass, @@ -131,6 +131,7 @@ class SmartThingsSensorEntityDescription(SensorEntityDescription): unique_id_separator: str = "." capability_ignore_list: list[set[Capability]] | None = None options_attribute: Attribute | None = None + exists_fn: Callable[[Status], bool] | None = None CAPABILITY_TO_SENSORS: dict[ @@ -583,6 +584,10 @@ CAPABILITY_TO_SENSORS: dict[ native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, value_fn=lambda value: value["energy"] / 1000, suggested_display_precision=2, + exists_fn=lambda status: ( + (value := cast(dict | None, status.value)) is not None + and "energy" in value + ), ), SmartThingsSensorEntityDescription( key="power_meter", @@ -592,6 +597,10 @@ CAPABILITY_TO_SENSORS: dict[ value_fn=lambda value: value["power"], extra_state_attributes_fn=power_attributes, suggested_display_precision=2, + exists_fn=lambda status: ( + (value := cast(dict | None, status.value)) is not None + and "power" in value + ), ), SmartThingsSensorEntityDescription( key="deltaEnergy_meter", @@ -601,6 +610,10 @@ CAPABILITY_TO_SENSORS: dict[ native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, value_fn=lambda value: value["deltaEnergy"] / 1000, suggested_display_precision=2, + exists_fn=lambda status: ( + (value := cast(dict | None, status.value)) is not None + and "deltaEnergy" in value + ), ), SmartThingsSensorEntityDescription( key="powerEnergy_meter", @@ -610,6 +623,10 @@ CAPABILITY_TO_SENSORS: dict[ native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, value_fn=lambda value: value["powerEnergy"] / 1000, suggested_display_precision=2, + exists_fn=lambda status: ( + (value := cast(dict | None, status.value)) is not None + and "powerEnergy" in value + ), ), SmartThingsSensorEntityDescription( key="energySaved_meter", @@ -619,6 +636,10 @@ CAPABILITY_TO_SENSORS: dict[ native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, value_fn=lambda value: value["energySaved"] / 1000, suggested_display_precision=2, + exists_fn=lambda status: ( + (value := cast(dict | None, status.value)) is not None + and "energySaved" in value + ), ), ] }, @@ -973,6 +994,10 @@ async def async_setup_entry( for capability_list in description.capability_ignore_list ) ) + and ( + not description.exists_fn + or description.exists_fn(device.status[MAIN][capability][attribute]) + ) ) diff --git a/tests/components/smartthings/conftest.py b/tests/components/smartthings/conftest.py index b314e74e5c4..9c1a0df3554 100644 --- a/tests/components/smartthings/conftest.py +++ b/tests/components/smartthings/conftest.py @@ -123,6 +123,7 @@ def mock_smartthings() -> Generator[AsyncMock]: "generic_ef00_v1", "bosch_radiator_thermostat_ii", "im_speaker_ai_0001", + "tplink_p110", ] ) def device_fixture( diff --git a/tests/components/smartthings/fixtures/device_status/tplink_p110.json b/tests/components/smartthings/fixtures/device_status/tplink_p110.json new file mode 100644 index 00000000000..9e1d41ed66e --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/tplink_p110.json @@ -0,0 +1,46 @@ +{ + "components": { + "main": { + "powerConsumptionReport": { + "powerConsumption": { + "value": { + "start": "2025-03-10T14:43:42.500Z", + "end": "2025-03-10T14:59:42.500Z", + "energy": 15720, + "deltaEnergy": 0 + }, + "timestamp": "2025-03-10T14:59:50.010Z" + } + }, + "healthCheck": { + "checkInterval": { + "value": 60, + "unit": "s", + "data": { + "deviceScheme": "UNTRACKED", + "protocol": "cloud" + }, + "timestamp": "2024-03-07T21:14:59.839Z" + }, + "healthStatus": { + "value": null + }, + "DeviceWatch-Enroll": { + "value": null + }, + "DeviceWatch-DeviceStatus": { + "value": "online", + "data": {}, + "timestamp": "2025-03-10T14:14:37.232Z" + } + }, + "refresh": {}, + "switch": { + "switch": { + "value": "on", + "timestamp": "2025-03-10T14:14:37.232Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/devices/tplink_p110.json b/tests/components/smartthings/fixtures/devices/tplink_p110.json new file mode 100644 index 00000000000..ffe7de5ff68 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/tplink_p110.json @@ -0,0 +1,73 @@ +{ + "items": [ + { + "deviceId": "6602696a-1e48-49e4-919f-69406f5b5da1", + "name": "plug-energy-usage-report", + "label": "Sp\u00fclmaschine", + "manufacturerName": "0AI2", + "presentationId": "ST_8f2be0ec-1113-46e0-ad56-3e92eb27410f", + "deviceManufacturerCode": "TP-Link", + "locationId": "70da36b0-bd25-410c-beed-7f0dbf658448", + "ownerId": "be5d4173-dd49-1eee-56f5-f98306ee872c", + "roomId": "bd13616d-b7e2-44ff-914c-eb38ea18c4b4", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "healthCheck", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "switch", + "version": 1 + }, + { + "id": "powerConsumptionReport", + "version": 1 + } + ], + "categories": [ + { + "name": "SmartPlug", + "categoryType": "manufacturer" + }, + { + "name": "SmartPlug", + "categoryType": "user" + } + ] + } + ], + "createTime": "2024-03-07T21:14:59.762Z", + "profile": { + "id": "a25b207e-cbb9-40ae-8a88-906637c22ab6" + }, + "viper": { + "uniqueIdentifier": "8022F7F6FE0A6EACA52B5D89C0D667352136D8C6", + "manufacturerName": "TP-Link", + "modelName": "P110", + "swVersion": "1.3.1 Build 240621 Rel.162048", + "hwVersion": "1.0", + "endpointAppId": "viper_7ea6bb80-b876-11eb-be42-952f31ab3f7b" + }, + "type": "VIPER", + "restrictionTier": 0, + "allowed": null, + "indoorMap": { + "coordinates": [0.0, 0.0, 0.0], + "rotation": [0.0, 180.0, 0.0], + "visible": false, + "data": null + }, + "executionContext": "CLOUD", + "relationships": [] + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/snapshots/test_init.ambr b/tests/components/smartthings/snapshots/test_init.ambr index 401f5c88454..7dd0583c7fd 100644 --- a/tests/components/smartthings/snapshots/test_init.ambr +++ b/tests/components/smartthings/snapshots/test_init.ambr @@ -1124,6 +1124,39 @@ 'via_device_id': None, }) # --- +# name: test_devices[tplink_p110] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '1.0', + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '6602696a-1e48-49e4-919f-69406f5b5da1', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'TP-Link', + 'model': 'P110', + 'model_id': None, + 'name': 'Spülmaschine', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '1.3.1 Build 240621 Rel.162048', + 'via_device_id': None, + }) +# --- # name: test_devices[vd_network_audio_002s] DeviceRegistryEntrySnapshot({ 'area_id': None, diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index 94fe1924fd2..52df02f55b8 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -6212,6 +6212,116 @@ 'state': '15', }) # --- +# name: test_all_entities[tplink_p110][sensor.spulmaschine_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.spulmaschine_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '6602696a-1e48-49e4-919f-69406f5b5da1.energy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[tplink_p110][sensor.spulmaschine_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Spülmaschine Energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.spulmaschine_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '15.72', + }) +# --- +# name: test_all_entities[tplink_p110][sensor.spulmaschine_energy_difference-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.spulmaschine_energy_difference', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy difference', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_difference', + 'unique_id': '6602696a-1e48-49e4-919f-69406f5b5da1.deltaEnergy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[tplink_p110][sensor.spulmaschine_energy_difference-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Spülmaschine Energy difference', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.spulmaschine_energy_difference', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- # name: test_all_entities[vd_network_audio_002s][sensor.soundbar_living_media_playback_status-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/smartthings/snapshots/test_switch.ambr b/tests/components/smartthings/snapshots/test_switch.ambr index e119428c183..f1b5ce8412e 100644 --- a/tests/components/smartthings/snapshots/test_switch.ambr +++ b/tests/components/smartthings/snapshots/test_switch.ambr @@ -516,6 +516,53 @@ 'state': 'on', }) # --- +# name: test_all_entities[tplink_p110][switch.spulmaschine-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.spulmaschine', + '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': None, + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '6602696a-1e48-49e4-919f-69406f5b5da1', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[tplink_p110][switch.spulmaschine-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Spülmaschine', + }), + 'context': , + 'entity_id': 'switch.spulmaschine', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_all_entities[vd_network_audio_002s][switch.soundbar_living-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 38e61332022e185e3d525f82ef8c5857dc670edb Mon Sep 17 00:00:00 2001 From: Pete Sage <76050312+PeteRager@users.noreply.github.com> Date: Tue, 11 Mar 2025 09:56:41 -0400 Subject: [PATCH 2515/3148] Fix browsing Audible Favorites in Sonos (#140378) * initial commit * updates * update test data --- homeassistant/components/sonos/const.py | 4 ++ .../sonos/fixtures/sonos_favorites.json | 18 +++++ .../sonos/snapshots/test_media_browser.ambr | 70 +++++++++++++++++++ tests/components/sonos/test_media_browser.py | 37 ++++++++++ 4 files changed, 129 insertions(+) diff --git a/homeassistant/components/sonos/const.py b/homeassistant/components/sonos/const.py index 8fb704cbfbc..cda40729dbc 100644 --- a/homeassistant/components/sonos/const.py +++ b/homeassistant/components/sonos/const.py @@ -32,6 +32,7 @@ SONOS_TRACKS = "tracks" SONOS_COMPOSER = "composers" SONOS_RADIO = "radio" SONOS_OTHER_ITEM = "other items" +SONOS_AUDIO_BOOK = "audio book" SONOS_STATE_PLAYING = "PLAYING" SONOS_STATE_TRANSITIONING = "TRANSITIONING" @@ -67,6 +68,7 @@ SONOS_TO_MEDIA_CLASSES = { "object.item": MediaClass.TRACK, "object.item.audioItem.musicTrack": MediaClass.TRACK, "object.item.audioItem.audioBroadcast": MediaClass.GENRE, + "object.item.audioItem.audioBook": MediaClass.TRACK, } SONOS_TO_MEDIA_TYPES = { @@ -84,6 +86,7 @@ SONOS_TO_MEDIA_TYPES = { "object.container.playlistContainer.sameArtist": MediaType.ARTIST, "object.container.playlistContainer": MediaType.PLAYLIST, "object.item.audioItem.musicTrack": MediaType.TRACK, + "object.item.audioItem.audioBook": MediaType.TRACK, } MEDIA_TYPES_TO_SONOS: dict[MediaType | str, str] = { @@ -113,6 +116,7 @@ SONOS_TYPES_MAPPING = { "object.item": SONOS_OTHER_ITEM, "object.item.audioItem.musicTrack": SONOS_TRACKS, "object.item.audioItem.audioBroadcast": SONOS_RADIO, + "object.item.audioItem.audioBook": SONOS_AUDIO_BOOK, } LIBRARY_TITLES_MAPPING = { diff --git a/tests/components/sonos/fixtures/sonos_favorites.json b/tests/components/sonos/fixtures/sonos_favorites.json index 21ee68f4872..d5463c3d02b 100644 --- a/tests/components/sonos/fixtures/sonos_favorites.json +++ b/tests/components/sonos/fixtures/sonos_favorites.json @@ -34,5 +34,23 @@ "protocol_info": "a:b:c:d" } ] + }, + { + "title": "American Tall Tales", + "parent_id": "FV:2", + "item_id": "FV:2/66", + "restricted": false, + "resource_meta_data": "American Tall Talesobject.item.audioItem.audioBookSA_RINCON61191_X_#Svc6-0-Token", + "resources": [ + { + "uri": "x-rincon-cpcontainer:101340c8reftitle%C9F27_com?sid=239&flags=16584&sn=5", + "protocol_info": "x-rincon-cpcontainer:*:*:*" + } + ], + "desc": null, + "album_art_uri": "https://m.media-amazon.com/images/I/810lqLo5m0L._SL600_.jpg", + "type": "instantPlay", + "description": "Audible", + "favorite_nr": "0" } ] diff --git a/tests/components/sonos/snapshots/test_media_browser.ambr b/tests/components/sonos/snapshots/test_media_browser.ambr index ae8e813ae5d..9f6560c0f75 100644 --- a/tests/components/sonos/snapshots/test_media_browser.ambr +++ b/tests/components/sonos/snapshots/test_media_browser.ambr @@ -1,4 +1,74 @@ # serializer version: 1 +# name: test_browse_media_favorites[-favorites] + dict({ + 'can_expand': True, + 'can_play': False, + 'children': list([ + dict({ + 'can_expand': True, + 'can_play': False, + 'children_media_class': None, + 'media_class': 'album', + 'media_content_id': 'object.container.album.musicAlbum', + 'media_content_type': 'favorites_folder', + 'thumbnail': None, + 'title': 'Albums', + }), + dict({ + 'can_expand': True, + 'can_play': False, + 'children_media_class': None, + 'media_class': 'track', + 'media_content_id': 'object.item.audioItem.audioBook', + 'media_content_type': 'favorites_folder', + 'thumbnail': None, + 'title': 'Audio Book', + }), + dict({ + 'can_expand': True, + 'can_play': False, + 'children_media_class': None, + 'media_class': 'genre', + 'media_content_id': 'object.item.audioItem.audioBroadcast', + 'media_content_type': 'favorites_folder', + 'thumbnail': None, + 'title': 'Radio', + }), + ]), + 'children_media_class': 'directory', + 'media_class': 'directory', + 'media_content_id': '', + 'media_content_type': 'favorites', + 'not_shown': 0, + 'thumbnail': None, + 'title': 'Favorites', + }) +# --- +# name: test_browse_media_favorites[object.item.audioItem.audioBook-favorites_folder] + dict({ + 'can_expand': True, + 'can_play': False, + 'children': list([ + dict({ + 'can_expand': False, + 'can_play': True, + 'children_media_class': None, + 'media_class': 'track', + 'media_content_id': 'FV:2/66', + 'media_content_type': 'favorite_item_id', + 'thumbnail': 'https://m.media-amazon.com/images/I/810lqLo5m0L._SL600_.jpg', + 'title': 'American Tall Tales', + }), + ]), + 'children_media_class': 'track', + 'media_class': 'directory', + 'media_content_id': '', + 'media_content_type': 'favorites', + 'not_shown': 0, + 'thumbnail': None, + 'title': 'Audio Book', + }) +# --- # name: test_browse_media_library list([ dict({ diff --git a/tests/components/sonos/test_media_browser.py b/tests/components/sonos/test_media_browser.py index 6e03935f7f6..323140e285d 100644 --- a/tests/components/sonos/test_media_browser.py +++ b/tests/components/sonos/test_media_browser.py @@ -2,6 +2,7 @@ from functools import partial +import pytest from syrupy import SnapshotAssertion from homeassistant.components.media_player import BrowseMedia, MediaClass, MediaType @@ -176,3 +177,39 @@ async def test_browse_media_library_albums( assert response["success"] assert response["result"]["children"] == snapshot assert soco_mock.music_library.browse_by_idstring.call_count == 1 + + +@pytest.mark.parametrize( + ("media_content_id", "media_content_type"), + [ + ( + "", + "favorites", + ), + ( + "object.item.audioItem.audioBook", + "favorites_folder", + ), + ], +) +async def test_browse_media_favorites( + async_autosetup_sonos, + hass_ws_client: WebSocketGenerator, + snapshot: SnapshotAssertion, + media_content_id, + media_content_type, +) -> None: + """Test the async_browse_media method.""" + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "media_player/browse_media", + "entity_id": "media_player.zone_a", + "media_content_id": media_content_id, + "media_content_type": media_content_type, + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == snapshot From ad126a745a59dbc124e61a2585196339ee2157ba Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 11 Mar 2025 15:58:16 +0100 Subject: [PATCH 2516/3148] Fix sentence-casing in `hive` integration (#140382) Use sentence-casing for all strings following the HA standard. Capitalize "Internet" as a name. --- homeassistant/components/hive/strings.json | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/hive/strings.json b/homeassistant/components/hive/strings.json index 219776ad7e6..064ced42d54 100644 --- a/homeassistant/components/hive/strings.json +++ b/homeassistant/components/hive/strings.json @@ -2,27 +2,27 @@ "config": { "step": { "user": { - "title": "Hive Login", + "title": "Hive login", "description": "Enter your Hive login information.", "data": { "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]", - "scan_interval": "Scan Interval (seconds)" + "scan_interval": "Scan interval (seconds)" } }, "2fa": { - "title": "Hive Two-factor Authentication.", - "description": "Enter your Hive authentication code. \n \n Please enter code 0000 to request another code.", + "title": "Hive two-factor authentication.", + "description": "Enter your Hive authentication code.\n\nPlease enter code 0000 to request another code.", "data": { "2fa": "Two-factor code" } }, "configuration": { "data": { - "device_name": "Device Name" + "device_name": "Device name" }, "description": "Enter your Hive configuration", - "title": "Hive Configuration." + "title": "Hive configuration." }, "reauth": { "title": "[%key:component::hive::config::step::user::title%]", @@ -37,7 +37,7 @@ "invalid_username": "Failed to sign into Hive. Your email address is not recognised.", "invalid_password": "Failed to sign into Hive. Incorrect password, please try again.", "invalid_code": "Failed to sign into Hive. Your two-factor authentication code was incorrect.", - "no_internet_available": "An internet connection is required to connect to Hive.", + "no_internet_available": "An Internet connection is required to connect to Hive.", "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { From b5c7bdd98f221f27c3621fe78019f15eb5f4acf0 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 11 Mar 2025 14:47:13 +0100 Subject: [PATCH 2517/3148] Make sure SmartThings light can deal with unknown states (#140190) * Fix * add comment * Make light unknown * Make light unknown --- homeassistant/components/smartthings/light.py | 54 +++++++++----- tests/components/smartthings/conftest.py | 1 + .../device_status/abl_light_b_001.json | 27 +++++++ .../fixtures/devices/abl_light_b_001.json | 59 ++++++++++++++++ .../smartthings/snapshots/test_init.ambr | 33 +++++++++ .../smartthings/snapshots/test_light.ambr | 70 +++++++++++++++++++ 6 files changed, 225 insertions(+), 19 deletions(-) create mode 100644 tests/components/smartthings/fixtures/device_status/abl_light_b_001.json create mode 100644 tests/components/smartthings/fixtures/devices/abl_light_b_001.json diff --git a/homeassistant/components/smartthings/light.py b/homeassistant/components/smartthings/light.py index aa3a8d35859..1ad315bcd97 100644 --- a/homeassistant/components/smartthings/light.py +++ b/homeassistant/components/smartthings/light.py @@ -147,14 +147,21 @@ class SmartThingsLight(SmartThingsEntity, LightEntity, RestoreEntity): """Update entity attributes when the device status has changed.""" # Brightness and transition if brightness_supported(self._attr_supported_color_modes): - self._attr_brightness = int( - convert_scale( - self.get_attribute_value(Capability.SWITCH_LEVEL, Attribute.LEVEL), - 100, - 255, - 0, + if ( + brightness := self.get_attribute_value( + Capability.SWITCH_LEVEL, Attribute.LEVEL + ) + ) is None: + self._attr_brightness = None + else: + self._attr_brightness = int( + convert_scale( + brightness, + 100, + 255, + 0, + ) ) - ) # Color Temperature if ColorMode.COLOR_TEMP in self._attr_supported_color_modes: self._attr_color_temp_kelvin = self.get_attribute_value( @@ -162,16 +169,21 @@ class SmartThingsLight(SmartThingsEntity, LightEntity, RestoreEntity): ) # Color if ColorMode.HS in self._attr_supported_color_modes: - self._attr_hs_color = ( - convert_scale( - self.get_attribute_value(Capability.COLOR_CONTROL, Attribute.HUE), - 100, - 360, - ), - self.get_attribute_value( - Capability.COLOR_CONTROL, Attribute.SATURATION - ), - ) + if ( + hue := self.get_attribute_value(Capability.COLOR_CONTROL, Attribute.HUE) + ) is None: + self._attr_hs_color = None + else: + self._attr_hs_color = ( + convert_scale( + hue, + 100, + 360, + ), + self.get_attribute_value( + Capability.COLOR_CONTROL, Attribute.SATURATION + ), + ) async def async_set_color(self, hs_color): """Set the color of the device.""" @@ -217,6 +229,10 @@ class SmartThingsLight(SmartThingsEntity, LightEntity, RestoreEntity): super()._update_handler(event) @property - def is_on(self) -> bool: + def is_on(self) -> bool | None: """Return true if light is on.""" - return self.get_attribute_value(Capability.SWITCH, Attribute.SWITCH) == "on" + if ( + state := self.get_attribute_value(Capability.SWITCH, Attribute.SWITCH) + ) is None: + return None + return state == "on" diff --git a/tests/components/smartthings/conftest.py b/tests/components/smartthings/conftest.py index 9c1a0df3554..aa10c7af333 100644 --- a/tests/components/smartthings/conftest.py +++ b/tests/components/smartthings/conftest.py @@ -123,6 +123,7 @@ def mock_smartthings() -> Generator[AsyncMock]: "generic_ef00_v1", "bosch_radiator_thermostat_ii", "im_speaker_ai_0001", + "abl_light_b_001", "tplink_p110", ] ) diff --git a/tests/components/smartthings/fixtures/device_status/abl_light_b_001.json b/tests/components/smartthings/fixtures/device_status/abl_light_b_001.json new file mode 100644 index 00000000000..6dba85d7dc4 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/abl_light_b_001.json @@ -0,0 +1,27 @@ +{ + "components": { + "main": { + "switchLevel": { + "levelRange": { + "value": null + }, + "level": { + "value": null + } + }, + "switch": { + "switch": { + "value": null + } + }, + "colorTemperature": { + "colorTemperatureRange": { + "value": null + }, + "colorTemperature": { + "value": null + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/devices/abl_light_b_001.json b/tests/components/smartthings/fixtures/devices/abl_light_b_001.json new file mode 100644 index 00000000000..bb4970b6d5a --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/abl_light_b_001.json @@ -0,0 +1,59 @@ +{ + "items": [ + { + "deviceId": "7c16163e-c94e-482f-95f6-139ae0cd9d5e", + "name": "ABL Wafer Down Light(BLE)", + "label": "Kitchen Light 5", + "manufacturerName": "Samsung Electronics", + "presentationId": "ABL-LIGHT-B-001", + "deviceManufacturerCode": "Samsung Electronics", + "locationId": "6c314222-8baf-48a0-9442-5b1102a8757f", + "ownerId": "f24ff388-700c-7d1e-91f2-1c37ae68ce2b", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "switch", + "version": 1 + }, + { + "id": "colorTemperature", + "version": 1 + }, + { + "id": "switchLevel", + "version": 1 + } + ], + "categories": [ + { + "name": "Light", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2025-03-08T22:40:25.073Z", + "profile": { + "id": "65f5db53-9a78-4b19-8e40-d32187cd59ab" + }, + "bleD2D": { + "encryptionKey": "f593369dcea915f6352a4a42cd4b2ea6", + "cipher": "AES_128-CBC-PKCS7Padding", + "advertisingId": "b13d7192", + "identifier": "88-57-1d-7c-cb-cf", + "configurationUrl": "https://apis.samsungiotcloud.com/v1/miniature/profile/65f5db53-9a78-4b19-8e40-d32187cd59ab", + "bleDeviceType": "BLE", + "metadata": null + }, + "type": "BLE_D2D", + "restrictionTier": 0, + "allowed": null, + "executionContext": "CLOUD", + "relationships": [] + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/snapshots/test_init.ambr b/tests/components/smartthings/snapshots/test_init.ambr index 7dd0583c7fd..5de382c75b8 100644 --- a/tests/components/smartthings/snapshots/test_init.ambr +++ b/tests/components/smartthings/snapshots/test_init.ambr @@ -2,6 +2,39 @@ # name: test_button_event[button] # --- +# name: test_devices[abl_light_b_001] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '7c16163e-c94e-482f-95f6-139ae0cd9d5e', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': None, + 'model': None, + 'model_id': None, + 'name': 'Kitchen Light 5', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- # name: test_devices[aeotec_home_energy_meter_gen5] DeviceRegistryEntrySnapshot({ 'area_id': None, diff --git a/tests/components/smartthings/snapshots/test_light.ambr b/tests/components/smartthings/snapshots/test_light.ambr index 8766811c443..f1f2b92de77 100644 --- a/tests/components/smartthings/snapshots/test_light.ambr +++ b/tests/components/smartthings/snapshots/test_light.ambr @@ -1,4 +1,74 @@ # serializer version: 1 +# name: test_all_entities[abl_light_b_001][light.kitchen_light_5-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 9000, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 111, + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.kitchen_light_5', + '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': None, + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '7c16163e-c94e-482f-95f6-139ae0cd9d5e', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[abl_light_b_001][light.kitchen_light_5-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': None, + 'color_mode': None, + 'color_temp': None, + 'color_temp_kelvin': None, + 'friendly_name': 'Kitchen Light 5', + 'hs_color': None, + 'max_color_temp_kelvin': 9000, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 111, + 'rgb_color': None, + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + 'xy_color': None, + }), + 'context': , + 'entity_id': 'light.kitchen_light_5', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_all_entities[centralite][light.dimmer_debian-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From f2f653efcf49d067c061311a87feb8b40b8fd2a9 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 11 Mar 2025 15:33:32 +0100 Subject: [PATCH 2518/3148] Delete subscription on shutdown of SmartThings (#140135) * Cache subscription url in SmartThings * Cache subscription url in SmartThings * Fix * Fix * Fix * Fix * Fix * Fix * Fix * Fix * Bump pysmartthings to 2.7.1 * 2.7.2 --------- Co-authored-by: Martin Hjelmare --- .../components/smartthings/__init__.py | 74 ++++++- homeassistant/components/smartthings/const.py | 1 + .../components/smartthings/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/smartthings/conftest.py | 4 + .../smartthings/fixtures/subscription.json | 16 ++ .../smartthings/test_config_flow.py | 2 + tests/components/smartthings/test_init.py | 181 +++++++++++++++++- 9 files changed, 270 insertions(+), 14 deletions(-) create mode 100644 tests/components/smartthings/fixtures/subscription.json diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py index 71fa4454fa0..849044945d1 100644 --- a/homeassistant/components/smartthings/__init__.py +++ b/homeassistant/components/smartthings/__init__.py @@ -16,12 +16,18 @@ from pysmartthings import ( Scene, SmartThings, SmartThingsAuthenticationFailedError, + SmartThingsSinkError, Status, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN, Platform -from homeassistant.core import HomeAssistant +from homeassistant.const import ( + CONF_ACCESS_TOKEN, + CONF_TOKEN, + EVENT_HOMEASSISTANT_STOP, + Platform, +) +from homeassistant.core import Event, HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -33,6 +39,7 @@ from homeassistant.helpers.config_entry_oauth2_flow import ( from .const import ( CONF_INSTALLED_APP_ID, CONF_LOCATION_ID, + CONF_SUBSCRIPTION_ID, DOMAIN, EVENT_BUTTON, MAIN, @@ -99,6 +106,54 @@ async def async_setup_entry(hass: HomeAssistant, entry: SmartThingsConfigEntry) client.refresh_token_function = _refresh_token + def _handle_max_connections() -> None: + _LOGGER.debug("We hit the limit of max connections") + hass.config_entries.async_schedule_reload(entry.entry_id) + + client.max_connections_reached_callback = _handle_max_connections + + def _handle_new_subscription_identifier(identifier: str | None) -> None: + """Handle a new subscription identifier.""" + hass.config_entries.async_update_entry( + entry, + data={ + **entry.data, + CONF_SUBSCRIPTION_ID: identifier, + }, + ) + if identifier is not None: + _LOGGER.debug("Updating subscription ID to %s", identifier) + else: + _LOGGER.debug("Removing subscription ID") + + client.new_subscription_id_callback = _handle_new_subscription_identifier + + if (old_identifier := entry.data.get(CONF_SUBSCRIPTION_ID)) is not None: + _LOGGER.debug("Trying to delete old subscription %s", old_identifier) + await client.delete_subscription(old_identifier) + + _LOGGER.debug("Trying to create a new subscription") + try: + subscription = await client.create_subscription( + entry.data[CONF_LOCATION_ID], + entry.data[CONF_TOKEN][CONF_INSTALLED_APP_ID], + ) + except SmartThingsSinkError as err: + _LOGGER.debug("Couldn't create a new subscription: %s", err) + raise ConfigEntryNotReady from err + subscription_id = subscription.subscription_id + _handle_new_subscription_identifier(subscription_id) + + entry.async_create_background_task( + hass, + client.subscribe( + entry.data[CONF_LOCATION_ID], + entry.data[CONF_TOKEN][CONF_INSTALLED_APP_ID], + subscription, + ), + "smartthings_socket", + ) + device_status: dict[str, FullDevice] = {} try: devices = await client.get_devices() @@ -145,12 +200,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: SmartThingsConfigEntry) client.add_unspecified_device_event_listener(handle_button_press) ) - entry.async_create_background_task( - hass, - client.subscribe( - entry.data[CONF_LOCATION_ID], entry.data[CONF_TOKEN][CONF_INSTALLED_APP_ID] - ), - "smartthings_webhook", + async def _handle_shutdown(_: Event) -> None: + """Handle shutdown.""" + await client.delete_subscription(subscription_id) + + entry.async_on_unload( + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _handle_shutdown) ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -176,6 +231,9 @@ async def async_unload_entry( hass: HomeAssistant, entry: SmartThingsConfigEntry ) -> bool: """Unload a config entry.""" + client = entry.runtime_data.client + if (subscription_id := entry.data.get(CONF_SUBSCRIPTION_ID)) is not None: + await client.delete_subscription(subscription_id) return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/smartthings/const.py b/homeassistant/components/smartthings/const.py index a6d028aed06..2ba59ade4e8 100644 --- a/homeassistant/components/smartthings/const.py +++ b/homeassistant/components/smartthings/const.py @@ -33,4 +33,5 @@ CONF_REFRESH_TOKEN = "refresh_token" MAIN = "main" OLD_DATA = "old_data" +CONF_SUBSCRIPTION_ID = "subscription_id" EVENT_BUTTON = "smartthings.button" diff --git a/homeassistant/components/smartthings/manifest.json b/homeassistant/components/smartthings/manifest.json index 2a4e79bff58..74f0e4bae83 100644 --- a/homeassistant/components/smartthings/manifest.json +++ b/homeassistant/components/smartthings/manifest.json @@ -29,5 +29,5 @@ "documentation": "https://www.home-assistant.io/integrations/smartthings", "iot_class": "cloud_push", "loggers": ["pysmartthings"], - "requirements": ["pysmartthings==2.7.0"] + "requirements": ["pysmartthings==2.7.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index e5ef86a66e1..82f567631fb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2310,7 +2310,7 @@ pysma==0.7.5 pysmappee==0.2.29 # homeassistant.components.smartthings -pysmartthings==2.7.0 +pysmartthings==2.7.2 # homeassistant.components.smarty pysmarty2==0.10.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a85de43702e..bd96a9ef79f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1882,7 +1882,7 @@ pysma==0.7.5 pysmappee==0.2.29 # homeassistant.components.smartthings -pysmartthings==2.7.0 +pysmartthings==2.7.2 # homeassistant.components.smarty pysmarty2==0.10.2 diff --git a/tests/components/smartthings/conftest.py b/tests/components/smartthings/conftest.py index aa10c7af333..57ca8b7877f 100644 --- a/tests/components/smartthings/conftest.py +++ b/tests/components/smartthings/conftest.py @@ -9,6 +9,7 @@ from pysmartthings.models import ( DeviceStatus, LocationResponse, SceneResponse, + Subscription, ) import pytest @@ -78,6 +79,9 @@ def mock_smartthings() -> Generator[AsyncMock]: client.get_locations.return_value = LocationResponse.from_json( load_fixture("locations.json", DOMAIN) ).items + client.create_subscription.return_value = Subscription.from_json( + load_fixture("subscription.json", DOMAIN) + ) yield client diff --git a/tests/components/smartthings/fixtures/subscription.json b/tests/components/smartthings/fixtures/subscription.json new file mode 100644 index 00000000000..80f37445524 --- /dev/null +++ b/tests/components/smartthings/fixtures/subscription.json @@ -0,0 +1,16 @@ +{ + "subscriptionId": "f5768ce8-c9e5-4507-9020-912c0c60e0ab", + "registrationUrl": "https://spigot-regional.api.smartthings.com/filters/f5768ce8-c9e5-4507-9020-912c0c60e0ab/activate?filterRegion=eu-west-1", + "name": "My Home Assistant sub", + "version": 20250122, + "subscriptionFilters": [ + { + "type": "LOCATIONIDS", + "value": ["88a3a314-f0c8-40b4-bb44-44ba06c9c42e"], + "eventType": ["DEVICE_EVENT"], + "attribute": null, + "capability": null, + "component": null + } + ] +} diff --git a/tests/components/smartthings/test_config_flow.py b/tests/components/smartthings/test_config_flow.py index 7472d7d6b71..4069c201225 100644 --- a/tests/components/smartthings/test_config_flow.py +++ b/tests/components/smartthings/test_config_flow.py @@ -10,6 +10,7 @@ from homeassistant.components.smartthings.const import ( CONF_INSTALLED_APP_ID, CONF_LOCATION_ID, CONF_REFRESH_TOKEN, + CONF_SUBSCRIPTION_ID, DOMAIN, ) from homeassistant.config_entries import SOURCE_USER, ConfigEntryState @@ -508,6 +509,7 @@ async def test_migration( "installed_app_id": "123123123-2be1-4e40-b257-e4ef59083324", }, CONF_LOCATION_ID: "397678e5-9995-4a39-9d9f-ae6ba310236c", + CONF_SUBSCRIPTION_ID: "f5768ce8-c9e5-4507-9020-912c0c60e0ab", } assert mock_old_config_entry.unique_id == "397678e5-9995-4a39-9d9f-ae6ba310236c" assert mock_old_config_entry.version == 3 diff --git a/tests/components/smartthings/test_init.py b/tests/components/smartthings/test_init.py index 2158282e9e6..cea2b6bb396 100644 --- a/tests/components/smartthings/test_init.py +++ b/tests/components/smartthings/test_init.py @@ -2,18 +2,21 @@ from unittest.mock import AsyncMock -from pysmartthings import Attribute, Capability +from pysmartthings import Attribute, Capability, SmartThingsSinkError +from pysmartthings.models import Subscription import pytest from syrupy import SnapshotAssertion from homeassistant.components.smartthings import EVENT_BUTTON -from homeassistant.components.smartthings.const import DOMAIN +from homeassistant.components.smartthings.const import CONF_SUBSCRIPTION_ID, DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import Event, HomeAssistant from homeassistant.helpers import device_registry as dr from . import setup_integration, trigger_update -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, load_fixture async def test_devices( @@ -63,6 +66,178 @@ async def test_button_event( assert events[0] == snapshot +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) +async def test_create_subscription( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test creating a subscription.""" + assert CONF_SUBSCRIPTION_ID not in mock_config_entry.data + + await setup_integration(hass, mock_config_entry) + + devices.create_subscription.assert_called_once() + + assert ( + mock_config_entry.data[CONF_SUBSCRIPTION_ID] + == "f5768ce8-c9e5-4507-9020-912c0c60e0ab" + ) + + devices.subscribe.assert_called_once_with( + "397678e5-9995-4a39-9d9f-ae6ba310236c", + "5aaaa925-2be1-4e40-b257-e4ef59083324", + Subscription.from_json(load_fixture("subscription.json", DOMAIN)), + ) + + +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) +async def test_create_subscription_sink_error( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test handling an error when creating a subscription.""" + assert CONF_SUBSCRIPTION_ID not in mock_config_entry.data + + devices.create_subscription.side_effect = SmartThingsSinkError("Sink error") + + await setup_integration(hass, mock_config_entry) + + devices.subscribe.assert_not_called() + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + assert CONF_SUBSCRIPTION_ID not in mock_config_entry.data + + +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) +async def test_update_subscription_identifier( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test updating the subscription identifier.""" + await setup_integration(hass, mock_config_entry) + + assert ( + mock_config_entry.data[CONF_SUBSCRIPTION_ID] + == "f5768ce8-c9e5-4507-9020-912c0c60e0ab" + ) + + devices.new_subscription_id_callback("abc") + + await hass.async_block_till_done() + + assert mock_config_entry.data[CONF_SUBSCRIPTION_ID] == "abc" + + +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) +async def test_stale_subscription_id( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test updating the subscription identifier.""" + mock_config_entry.add_to_hass(hass) + + hass.config_entries.async_update_entry( + mock_config_entry, + data={**mock_config_entry.data, CONF_SUBSCRIPTION_ID: "test"}, + ) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert ( + mock_config_entry.data[CONF_SUBSCRIPTION_ID] + == "f5768ce8-c9e5-4507-9020-912c0c60e0ab" + ) + devices.delete_subscription.assert_called_once_with("test") + + +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) +async def test_remove_subscription_identifier( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test removing the subscription identifier.""" + await setup_integration(hass, mock_config_entry) + + assert ( + mock_config_entry.data[CONF_SUBSCRIPTION_ID] + == "f5768ce8-c9e5-4507-9020-912c0c60e0ab" + ) + + devices.new_subscription_id_callback(None) + + await hass.async_block_till_done() + + assert mock_config_entry.data[CONF_SUBSCRIPTION_ID] is None + + +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) +async def test_max_connections_handling( + hass: HomeAssistant, devices: AsyncMock, mock_config_entry: MockConfigEntry +) -> None: + """Test handling reaching max connections.""" + await setup_integration(hass, mock_config_entry) + + assert ( + mock_config_entry.data[CONF_SUBSCRIPTION_ID] + == "f5768ce8-c9e5-4507-9020-912c0c60e0ab" + ) + + devices.create_subscription.side_effect = SmartThingsSinkError("Sink error") + + devices.max_connections_reached_callback() + + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) +async def test_unloading( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test unloading the integration.""" + await setup_integration(hass, mock_config_entry) + + await hass.config_entries.async_unload(mock_config_entry.entry_id) + devices.delete_subscription.assert_called_once_with( + "f5768ce8-c9e5-4507-9020-912c0c60e0ab" + ) + # Deleting the subscription automatically deletes the subscription ID + devices.new_subscription_id_callback(None) + + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + assert mock_config_entry.data[CONF_SUBSCRIPTION_ID] is None + + +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) +async def test_shutdown( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test shutting down Home Assistant.""" + await setup_integration(hass, mock_config_entry) + + hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) + devices.delete_subscription.assert_called_once_with( + "f5768ce8-c9e5-4507-9020-912c0c60e0ab" + ) + # Deleting the subscription automatically deletes the subscription ID + devices.new_subscription_id_callback(None) + + assert mock_config_entry.state is ConfigEntryState.LOADED + assert mock_config_entry.data[CONF_SUBSCRIPTION_ID] is None + + @pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) async def test_removing_stale_devices( hass: HomeAssistant, From 3d5e4b980f152697816cbf0bd84cee06351631f4 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 11 Mar 2025 15:22:38 +0000 Subject: [PATCH 2519/3148] Bump version to 2025.3.2 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 35d00103074..6ff91029072 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -25,7 +25,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2025 MINOR_VERSION: Final = 3 -PATCH_VERSION: Final = "1" +PATCH_VERSION: Final = "2" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 13, 0) diff --git a/pyproject.toml b/pyproject.toml index 27b029acf45..b65046713db 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.3.1" +version = "2025.3.2" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 95afebceb49f85c80d8e7356373b30b59908e348 Mon Sep 17 00:00:00 2001 From: Lincoln Kirchoff Date: Tue, 11 Mar 2025 10:27:47 -0500 Subject: [PATCH 2520/3148] Add modbus climate hvac action (#139864) * Added the hvac action attribute for modbus climate entities. * Fixed issue in hvac action unit test, was incorrectly referencing the hvac mode attribute. * Fixed the modbus climate test for hvac action, it now correctly checks that hvac actions in the config match HVACActions. * Made changes recommended by @crug80 to remove dead code and to add ability to use input or holding register for hvac action. * Moved action test case in test_climate.py * Updated comment for `test_service_climate_action_update` * Fixed ruff formatting error. * Addressed request to update labels from `state_*` to `action_*` --- homeassistant/components/modbus/__init__.py | 49 +++++++ homeassistant/components/modbus/climate.py | 55 +++++++- homeassistant/components/modbus/const.py | 10 ++ tests/components/modbus/test_climate.py | 138 ++++++++++++++++++++ 4 files changed, 251 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/modbus/__init__.py b/homeassistant/components/modbus/__init__.py index 61df7206402..52642cc32e3 100644 --- a/homeassistant/components/modbus/__init__.py +++ b/homeassistant/components/modbus/__init__.py @@ -79,6 +79,16 @@ from .const import ( CONF_FAN_MODE_TOP, CONF_FAN_MODE_VALUES, CONF_FANS, + CONF_HVAC_ACTION_COOLING, + CONF_HVAC_ACTION_DEFROSTING, + CONF_HVAC_ACTION_DRYING, + CONF_HVAC_ACTION_FAN, + CONF_HVAC_ACTION_HEATING, + CONF_HVAC_ACTION_IDLE, + CONF_HVAC_ACTION_OFF, + CONF_HVAC_ACTION_PREHEATING, + CONF_HVAC_ACTION_REGISTER, + CONF_HVAC_ACTION_VALUES, CONF_HVAC_MODE_AUTO, CONF_HVAC_MODE_COOL, CONF_HVAC_MODE_DRY, @@ -297,6 +307,45 @@ CLIMATE_SCHEMA = vol.All( vol.Optional(CONF_WRITE_REGISTERS, default=False): cv.boolean, } ), + vol.Optional(CONF_HVAC_ACTION_REGISTER): vol.Maybe( + { + CONF_ADDRESS: cv.positive_int, + CONF_HVAC_ACTION_VALUES: { + vol.Optional(CONF_HVAC_ACTION_COOLING): vol.Any( + cv.positive_int, [cv.positive_int] + ), + vol.Optional(CONF_HVAC_ACTION_DEFROSTING): vol.Any( + cv.positive_int, [cv.positive_int] + ), + vol.Optional(CONF_HVAC_ACTION_DRYING): vol.Any( + cv.positive_int, [cv.positive_int] + ), + vol.Optional(CONF_HVAC_ACTION_FAN): vol.Any( + cv.positive_int, [cv.positive_int] + ), + vol.Optional(CONF_HVAC_ACTION_HEATING): vol.Any( + cv.positive_int, [cv.positive_int] + ), + vol.Optional(CONF_HVAC_ACTION_IDLE): vol.Any( + cv.positive_int, [cv.positive_int] + ), + vol.Optional(CONF_HVAC_ACTION_OFF): vol.Any( + cv.positive_int, [cv.positive_int] + ), + vol.Optional(CONF_HVAC_ACTION_PREHEATING): vol.Any( + cv.positive_int, [cv.positive_int] + ), + }, + vol.Optional( + CONF_INPUT_TYPE, default=CALL_TYPE_REGISTER_HOLDING + ): vol.In( + [ + CALL_TYPE_REGISTER_HOLDING, + CALL_TYPE_REGISTER_INPUT, + ] + ), + } + ), vol.Optional(CONF_FAN_MODE_REGISTER): vol.Maybe( vol.All( { diff --git a/homeassistant/components/modbus/climate.py b/homeassistant/components/modbus/climate.py index fca1b94611a..be10a9495c6 100644 --- a/homeassistant/components/modbus/climate.py +++ b/homeassistant/components/modbus/climate.py @@ -24,6 +24,7 @@ from homeassistant.components.climate import ( SWING_VERTICAL, ClimateEntity, ClimateEntityFeature, + HVACAction, HVACMode, ) from homeassistant.const import ( @@ -61,6 +62,16 @@ from .const import ( CONF_FAN_MODE_REGISTER, CONF_FAN_MODE_TOP, CONF_FAN_MODE_VALUES, + CONF_HVAC_ACTION_COOLING, + CONF_HVAC_ACTION_DEFROSTING, + CONF_HVAC_ACTION_DRYING, + CONF_HVAC_ACTION_FAN, + CONF_HVAC_ACTION_HEATING, + CONF_HVAC_ACTION_IDLE, + CONF_HVAC_ACTION_OFF, + CONF_HVAC_ACTION_PREHEATING, + CONF_HVAC_ACTION_REGISTER, + CONF_HVAC_ACTION_VALUES, CONF_HVAC_MODE_AUTO, CONF_HVAC_MODE_COOL, CONF_HVAC_MODE_DRY, @@ -74,6 +85,7 @@ from .const import ( CONF_HVAC_ON_VALUE, CONF_HVAC_ONOFF_COIL, CONF_HVAC_ONOFF_REGISTER, + CONF_INPUT_TYPE, CONF_MAX_TEMP, CONF_MIN_TEMP, CONF_STEP, @@ -188,6 +200,34 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): self._attr_hvac_mode = HVACMode.AUTO self._attr_hvac_modes = [HVACMode.AUTO] + if CONF_HVAC_ACTION_REGISTER in config: + action_config = config[CONF_HVAC_ACTION_REGISTER] + self._hvac_action_register = action_config[CONF_ADDRESS] + self._hvac_action_type = action_config[CONF_INPUT_TYPE] + + self._attr_hvac_action = None + self._hvac_action_mapping: list[tuple[int, HVACAction]] = [] + action_value_config = action_config[CONF_HVAC_ACTION_VALUES] + + for hvac_action_kw, hvac_action in ( + (CONF_HVAC_ACTION_COOLING, HVACAction.COOLING), + (CONF_HVAC_ACTION_DEFROSTING, HVACAction.DEFROSTING), + (CONF_HVAC_ACTION_DRYING, HVACAction.DRYING), + (CONF_HVAC_ACTION_FAN, HVACAction.FAN), + (CONF_HVAC_ACTION_HEATING, HVACAction.HEATING), + (CONF_HVAC_ACTION_IDLE, HVACAction.IDLE), + (CONF_HVAC_ACTION_OFF, HVACAction.OFF), + (CONF_HVAC_ACTION_PREHEATING, HVACAction.PREHEATING), + ): + if hvac_action_kw in action_value_config: + values = action_value_config[hvac_action_kw] + if not isinstance(values, list): + values = [values] + for value in values: + self._hvac_action_mapping.append((value, hvac_action)) + else: + self._hvac_action_register = None + if CONF_FAN_MODE_REGISTER in config: self._attr_supported_features = ( self._attr_supported_features | ClimateEntityFeature.FAN_MODE @@ -216,7 +256,6 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): self._fan_mode_mapping_from_modbus[value] = fan_mode self._fan_mode_mapping_to_modbus[fan_mode] = value self._attr_fan_modes.append(fan_mode) - else: # No FAN modes defined self._fan_mode_register = None @@ -457,6 +496,20 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): self._attr_hvac_mode = mode break + # Read the HVAC action register if defined + if self._hvac_action_register is not None: + hvac_action = await self._async_read_register( + self._hvac_action_type, self._hvac_action_register, raw=True + ) + + # Translate the value received + if hvac_action is not None: + self._attr_hvac_action = None + for value, action in self._hvac_action_mapping: + if hvac_action == value: + self._attr_hvac_action = action + break + # Read the Fan mode register if defined if self._fan_mode_register is not None: fan_mode = await self._async_read_register( diff --git a/homeassistant/components/modbus/const.py b/homeassistant/components/modbus/const.py index 5926569040d..634637a6b08 100644 --- a/homeassistant/components/modbus/const.py +++ b/homeassistant/components/modbus/const.py @@ -63,6 +63,16 @@ CONF_HVAC_ONOFF_REGISTER = "hvac_onoff_register" CONF_HVAC_ON_VALUE = "hvac_on_value" CONF_HVAC_OFF_VALUE = "hvac_off_value" CONF_HVAC_ONOFF_COIL = "hvac_onoff_coil" +CONF_HVAC_ACTION_REGISTER = "hvac_action_register" +CONF_HVAC_ACTION_COOLING = "action_cooling" +CONF_HVAC_ACTION_DEFROSTING = "action_defrosting" +CONF_HVAC_ACTION_DRYING = "action_drying" +CONF_HVAC_ACTION_FAN = "action_fan" +CONF_HVAC_ACTION_HEATING = "action_heating" +CONF_HVAC_ACTION_IDLE = "action_idle" +CONF_HVAC_ACTION_OFF = "action_off" +CONF_HVAC_ACTION_PREHEATING = "action_preheating" +CONF_HVAC_ACTION_VALUES = "values" CONF_HVAC_MODE_OFF = "state_off" CONF_HVAC_MODE_HEAT = "state_heat" CONF_HVAC_MODE_COOL = "state_cool" diff --git a/tests/components/modbus/test_climate.py b/tests/components/modbus/test_climate.py index 3c30efe9dce..54d4c5f6666 100644 --- a/tests/components/modbus/test_climate.py +++ b/tests/components/modbus/test_climate.py @@ -5,6 +5,7 @@ import pytest from homeassistant.components.climate import ( ATTR_FAN_MODE, ATTR_FAN_MODES, + ATTR_HVAC_ACTION, ATTR_HVAC_MODE, ATTR_HVAC_MODES, ATTR_SWING_MODE, @@ -31,6 +32,7 @@ from homeassistant.components.climate import ( SWING_OFF, SWING_ON, SWING_VERTICAL, + HVACAction, HVACMode, ) from homeassistant.components.homeassistant import SERVICE_UPDATE_ENTITY @@ -47,6 +49,16 @@ from homeassistant.components.modbus.const import ( CONF_FAN_MODE_REGISTER, CONF_FAN_MODE_TOP, CONF_FAN_MODE_VALUES, + CONF_HVAC_ACTION_COOLING, + CONF_HVAC_ACTION_DEFROSTING, + CONF_HVAC_ACTION_DRYING, + CONF_HVAC_ACTION_FAN, + CONF_HVAC_ACTION_HEATING, + CONF_HVAC_ACTION_IDLE, + CONF_HVAC_ACTION_OFF, + CONF_HVAC_ACTION_PREHEATING, + CONF_HVAC_ACTION_REGISTER, + CONF_HVAC_ACTION_VALUES, CONF_HVAC_MODE_AUTO, CONF_HVAC_MODE_COOL, CONF_HVAC_MODE_DRY, @@ -224,6 +236,43 @@ ENTITY_ID = f"{CLIMATE_DOMAIN}.{TEST_ENTITY_NAME}".replace(" ", "_") } ], }, + { + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_TARGET_TEMP: 117, + CONF_ADDRESS: 117, + CONF_SLAVE: 10, + CONF_HVAC_ONOFF_REGISTER: 12, + CONF_HVAC_MODE_REGISTER: { + CONF_ADDRESS: 11, + CONF_WRITE_REGISTERS: True, + CONF_HVAC_MODE_VALUES: { + CONF_HVAC_MODE_OFF: 0, + CONF_HVAC_MODE_HEAT: 1, + CONF_HVAC_MODE_COOL: 2, + CONF_HVAC_MODE_HEAT_COOL: 3, + CONF_HVAC_MODE_DRY: 4, + CONF_HVAC_MODE_FAN_ONLY: 5, + CONF_HVAC_MODE_AUTO: 6, + }, + }, + CONF_HVAC_ACTION_REGISTER: { + CONF_ADDRESS: 14, + CONF_HVAC_ACTION_VALUES: { + CONF_HVAC_ACTION_COOLING: 0, + CONF_HVAC_ACTION_DEFROSTING: 1, + CONF_HVAC_ACTION_DRYING: 2, + CONF_HVAC_ACTION_FAN: 3, + CONF_HVAC_ACTION_HEATING: 4, + CONF_HVAC_ACTION_IDLE: 5, + CONF_HVAC_ACTION_OFF: 6, + CONF_HVAC_ACTION_PREHEATING: 7, + }, + }, + } + ], + }, ], ) async def test_config_climate(hass: HomeAssistant, mock_modbus) -> None: @@ -745,6 +794,95 @@ async def test_hvac_onoff_coil_update( assert state.state == result +@pytest.mark.parametrize( + ("do_config", "result", "register_words"), + [ + ( + { + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_TARGET_TEMP: 116, + CONF_ADDRESS: 117, + CONF_SLAVE: 10, + CONF_SCAN_INTERVAL: 0, + CONF_DATA_TYPE: DataType.INT32, + CONF_HVAC_ACTION_REGISTER: { + CONF_ADDRESS: 118, + CONF_HVAC_ACTION_VALUES: { + CONF_HVAC_ACTION_IDLE: 0, + CONF_HVAC_ACTION_HEATING: 1, + }, + }, + }, + ] + }, + HVACAction.HEATING, + [0x01], + ), + ( + { + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_TARGET_TEMP: 116, + CONF_ADDRESS: 117, + CONF_SLAVE: 10, + CONF_SCAN_INTERVAL: 0, + CONF_DATA_TYPE: DataType.INT32, + CONF_HVAC_ACTION_REGISTER: { + CONF_ADDRESS: 118, + CONF_HVAC_ACTION_VALUES: { + CONF_HVAC_ACTION_COOLING: 0, + CONF_HVAC_ACTION_HEATING: 1, + }, + }, + }, + ] + }, + HVACAction.COOLING, + [0x00], + ), + ( + { + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_TARGET_TEMP: 116, + CONF_ADDRESS: 117, + CONF_SLAVE: 10, + CONF_SCAN_INTERVAL: 0, + CONF_DATA_TYPE: DataType.INT32, + CONF_HVAC_ACTION_REGISTER: { + CONF_ADDRESS: 118, + CONF_HVAC_ACTION_VALUES: { + CONF_HVAC_ACTION_OFF: 0, + CONF_HVAC_ACTION_DRYING: 1, + }, + }, + }, + ] + }, + HVACAction.DRYING, + [0x01], + ), + ], +) +async def test_service_climate_action_update( + hass: HomeAssistant, mock_modbus_ha, result, register_words +) -> None: + """Test HVAC action updates.""" + mock_modbus_ha.read_holding_registers.return_value = ReadResult(register_words) + await hass.services.async_call( + HOMEASSISTANT_DOMAIN, + SERVICE_UPDATE_ENTITY, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + await hass.async_block_till_done() + assert hass.states.get(ENTITY_ID).attributes[ATTR_HVAC_ACTION] == result + + @pytest.mark.parametrize( ("do_config", "result", "register_words"), [ From 7b7483b254789ff5defe973250d3b17294b9212f Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 11 Mar 2025 16:44:52 +0100 Subject: [PATCH 2521/3148] Fix wrong punctuation in `hive` integration (#140390) --- homeassistant/components/hive/strings.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/hive/strings.json b/homeassistant/components/hive/strings.json index 064ced42d54..6323a2eecbf 100644 --- a/homeassistant/components/hive/strings.json +++ b/homeassistant/components/hive/strings.json @@ -11,18 +11,18 @@ } }, "2fa": { - "title": "Hive two-factor authentication.", + "title": "Hive two-factor authentication", "description": "Enter your Hive authentication code.\n\nPlease enter code 0000 to request another code.", "data": { "2fa": "Two-factor code" } }, "configuration": { + "title": "Hive configuration", + "description": "Enter your Hive configuration.", "data": { "device_name": "Device name" - }, - "description": "Enter your Hive configuration", - "title": "Hive configuration." + } }, "reauth": { "title": "[%key:component::hive::config::step::user::title%]", From 36cbd28d9d4e6e6df15882bca2e732cac0e0e929 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Tue, 11 Mar 2025 17:41:19 +0100 Subject: [PATCH 2522/3148] Add platinum quality scale to incomfort integration (#136387) * Add platinum quality scale to incomfort integration * Add platinum quality scale to incomfort integration * Exempt actions attributes * Comment on known limitations --- .../components/incomfort/manifest.json | 1 + .../components/incomfort/quality_scale.yaml | 77 +++++++++++++++++++ script/hassfest/quality_scale.py | 2 - 3 files changed, 78 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/incomfort/quality_scale.yaml diff --git a/homeassistant/components/incomfort/manifest.json b/homeassistant/components/incomfort/manifest.json index d02b1d27554..825f198dd30 100644 --- a/homeassistant/components/incomfort/manifest.json +++ b/homeassistant/components/incomfort/manifest.json @@ -10,5 +10,6 @@ "documentation": "https://www.home-assistant.io/integrations/incomfort", "iot_class": "local_polling", "loggers": ["incomfortclient"], + "quality_scale": "platinum", "requirements": ["incomfort-client==0.6.7"] } diff --git a/homeassistant/components/incomfort/quality_scale.yaml b/homeassistant/components/incomfort/quality_scale.yaml new file mode 100644 index 00000000000..f5af3c9d061 --- /dev/null +++ b/homeassistant/components/incomfort/quality_scale.yaml @@ -0,0 +1,77 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + No actions implemented. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: | + No actions implemented. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: done + comment: | + Entities are set up dand updated through the datacoordimator. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + # Silver + config-entry-unloading: done + log-when-unavailable: done + entity-unavailable: done + action-exceptions: done + reauthentication-flow: done + parallel-updates: done + test-coverage: done + integration-owner: done + docs-installation-parameters: done + docs-configuration-parameters: done + + # Gold + entity-translations: done + entity-device-class: done + devices: done + entity-category: done + entity-disabled-by-default: done + discovery: done + stale-devices: + status: exempt + comment: > + There is a maximum of 3 heaters that can be discovered by the gateway. + The user must remove manually any heeater devices that have been replaced. + diagnostics: done + exception-translations: done + icon-translations: done + reconfiguration-flow: done + dynamic-devices: done + discovery-update-info: done + repair-issues: + status: exempt + comment: | + No current issues to repair. + docs-use-cases: done + docs-supported-devices: done + docs-supported-functions: done + docs-data-update: done + docs-known-limitations: + status: done + comment: There are no known limmitations, + docs-troubleshooting: done + docs-examples: done + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index 65e9d4ed9cc..e1898afc79b 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -515,7 +515,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "ihc", "imgw_pib", "improv_ble", - "incomfort", "influxdb", "inkbird", "insteon", @@ -1579,7 +1578,6 @@ INTEGRATIONS_WITHOUT_SCALE = [ "imap", "imgw_pib", "improv_ble", - "incomfort", "influxdb", "inkbird", "insteon", From 0ba571160391591d6851f50b08769594edae05d7 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 11 Mar 2025 12:54:39 -0400 Subject: [PATCH 2523/3148] Add TTS token to TTS-END event (#140333) --- .../components/assist_pipeline/pipeline.py | 2 ++ homeassistant/components/tts/__init__.py | 6 +++++ .../assist_pipeline/snapshots/test_init.ambr | 10 ++++++++ .../snapshots/test_websocket.ambr | 21 ++++++++++++++++ tests/components/tts/common.py | 25 +++++++++++++++++++ tests/components/tts/test_init.py | 17 +++++++++++++ 6 files changed, 81 insertions(+) diff --git a/homeassistant/components/assist_pipeline/pipeline.py b/homeassistant/components/assist_pipeline/pipeline.py index a028fa638df..42bb2d4ced8 100644 --- a/homeassistant/components/assist_pipeline/pipeline.py +++ b/homeassistant/components/assist_pipeline/pipeline.py @@ -649,6 +649,7 @@ class PipelineRun: data["runner_data"] = self.runner_data if self.tts_stream: data["tts_output"] = { + "token": self.tts_stream.token, "url": self.tts_stream.url, "mime_type": self.tts_stream.content_type, } @@ -1295,6 +1296,7 @@ class PipelineRun: tts_output = { "media_id": tts_media_id, + "token": self.tts_stream.token, "url": self.tts_stream.url, "mime_type": self.tts_stream.content_type, } diff --git a/homeassistant/components/tts/__init__.py b/homeassistant/components/tts/__init__.py index 31a92c62258..6fc25e32091 100644 --- a/homeassistant/components/tts/__init__.py +++ b/homeassistant/components/tts/__init__.py @@ -182,6 +182,12 @@ def async_create_stream( ) +@callback +def async_get_stream(hass: HomeAssistant, token: str) -> ResultStream | None: + """Return a result stream given a token.""" + return hass.data[DATA_TTS_MANAGER].token_to_stream.get(token) + + async def async_get_media_source_audio( hass: HomeAssistant, media_source_id: str, diff --git a/tests/components/assist_pipeline/snapshots/test_init.ambr b/tests/components/assist_pipeline/snapshots/test_init.ambr index 2375d48fcf9..f772f877d3a 100644 --- a/tests/components/assist_pipeline/snapshots/test_init.ambr +++ b/tests/components/assist_pipeline/snapshots/test_init.ambr @@ -8,6 +8,7 @@ 'pipeline': , 'tts_output': dict({ 'mime_type': 'audio/mpeg', + 'token': 'test_token.mp3', 'url': '/api/tts_proxy/test_token.mp3', }), }), @@ -85,6 +86,7 @@ 'tts_output': dict({ 'media_id': "media-source://tts/test?message=Sorry,+I+couldn't+understand+that&language=en-US&tts_options=%7B%22voice%22:%22james_earl_jones%22%7D", 'mime_type': 'audio/mpeg', + 'token': 'test_token.mp3', 'url': '/api/tts_proxy/test_token.mp3', }), }), @@ -105,6 +107,7 @@ 'pipeline': , 'tts_output': dict({ 'mime_type': 'audio/mpeg', + 'token': 'test_token.mp3', 'url': '/api/tts_proxy/test_token.mp3', }), }), @@ -182,6 +185,7 @@ 'tts_output': dict({ 'media_id': "media-source://tts/test?message=Sorry,+I+couldn't+understand+that&language=en-US&tts_options=%7B%22voice%22:%22Arnold+Schwarzenegger%22%7D", 'mime_type': 'audio/mpeg', + 'token': 'test_token.mp3', 'url': '/api/tts_proxy/test_token.mp3', }), }), @@ -202,6 +206,7 @@ 'pipeline': , 'tts_output': dict({ 'mime_type': 'audio/mpeg', + 'token': 'test_token.mp3', 'url': '/api/tts_proxy/test_token.mp3', }), }), @@ -279,6 +284,7 @@ 'tts_output': dict({ 'media_id': "media-source://tts/test?message=Sorry,+I+couldn't+understand+that&language=en-US&tts_options=%7B%22voice%22:%22Arnold+Schwarzenegger%22%7D", 'mime_type': 'audio/mpeg', + 'token': 'test_token.mp3', 'url': '/api/tts_proxy/test_token.mp3', }), }), @@ -299,6 +305,7 @@ 'pipeline': , 'tts_output': dict({ 'mime_type': 'audio/mpeg', + 'token': 'test_token.mp3', 'url': '/api/tts_proxy/test_token.mp3', }), }), @@ -400,6 +407,7 @@ 'tts_output': dict({ 'media_id': "media-source://tts/test?message=Sorry,+I+couldn't+understand+that&language=en-US&tts_options=%7B%22voice%22:%22james_earl_jones%22%7D", 'mime_type': 'audio/mpeg', + 'token': 'test_token.mp3', 'url': '/api/tts_proxy/test_token.mp3', }), }), @@ -420,6 +428,7 @@ 'pipeline': , 'tts_output': dict({ 'mime_type': 'audio/mpeg', + 'token': 'mocked-token.mp3', 'url': '/api/tts_proxy/mocked-token.mp3', }), }), @@ -620,6 +629,7 @@ 'pipeline': , 'tts_output': dict({ 'mime_type': 'audio/mpeg', + 'token': 'mocked-token.mp3', 'url': '/api/tts_proxy/mocked-token.mp3', }), }), diff --git a/tests/components/assist_pipeline/snapshots/test_websocket.ambr b/tests/components/assist_pipeline/snapshots/test_websocket.ambr index d937b5396d1..57ae0095236 100644 --- a/tests/components/assist_pipeline/snapshots/test_websocket.ambr +++ b/tests/components/assist_pipeline/snapshots/test_websocket.ambr @@ -10,6 +10,7 @@ }), 'tts_output': dict({ 'mime_type': 'audio/mpeg', + 'token': 'test_token.mp3', 'url': '/api/tts_proxy/test_token.mp3', }), }) @@ -81,6 +82,7 @@ 'tts_output': dict({ 'media_id': "media-source://tts/test?message=Sorry,+I+couldn't+understand+that&language=en-US&tts_options=%7B%22voice%22:%22james_earl_jones%22%7D", 'mime_type': 'audio/mpeg', + 'token': 'test_token.mp3', 'url': '/api/tts_proxy/test_token.mp3', }), }) @@ -99,6 +101,7 @@ }), 'tts_output': dict({ 'mime_type': 'audio/mpeg', + 'token': 'test_token.mp3', 'url': '/api/tts_proxy/test_token.mp3', }), }) @@ -170,6 +173,7 @@ 'tts_output': dict({ 'media_id': "media-source://tts/test?message=Sorry,+I+couldn't+understand+that&language=en-US&tts_options=%7B%22voice%22:%22james_earl_jones%22%7D", 'mime_type': 'audio/mpeg', + 'token': 'test_token.mp3', 'url': '/api/tts_proxy/test_token.mp3', }), }) @@ -200,6 +204,7 @@ }), 'tts_output': dict({ 'mime_type': 'audio/mpeg', + 'token': 'test_token.mp3', 'url': '/api/tts_proxy/test_token.mp3', }), }) @@ -271,6 +276,7 @@ 'tts_output': dict({ 'media_id': "media-source://tts/test?message=Sorry,+I+couldn't+understand+that&language=en-US&tts_options=%7B%22voice%22:%22james_earl_jones%22%7D", 'mime_type': 'audio/mpeg', + 'token': 'test_token.mp3', 'url': '/api/tts_proxy/test_token.mp3', }), }) @@ -289,6 +295,7 @@ }), 'tts_output': dict({ 'mime_type': 'audio/mpeg', + 'token': 'test_token.mp3', 'url': '/api/tts_proxy/test_token.mp3', }), }) @@ -382,6 +389,7 @@ 'tts_output': dict({ 'media_id': "media-source://tts/test?message=Sorry,+I+couldn't+understand+that&language=en-US&tts_options=%7B%22voice%22:%22james_earl_jones%22%7D", 'mime_type': 'audio/mpeg', + 'token': 'test_token.mp3', 'url': '/api/tts_proxy/test_token.mp3', }), }) @@ -400,6 +408,7 @@ }), 'tts_output': dict({ 'mime_type': 'audio/mpeg', + 'token': 'test_token.mp3', 'url': '/api/tts_proxy/test_token.mp3', }), }) @@ -607,6 +616,7 @@ }), 'tts_output': dict({ 'mime_type': 'audio/mpeg', + 'token': 'mocked-token.mp3', 'url': '/api/tts_proxy/mocked-token.mp3', }), }) @@ -660,6 +670,7 @@ }), 'tts_output': dict({ 'mime_type': 'audio/mpeg', + 'token': 'mocked-token.mp3', 'url': '/api/tts_proxy/mocked-token.mp3', }), }) @@ -675,6 +686,7 @@ }), 'tts_output': dict({ 'mime_type': 'audio/mpeg', + 'token': 'mocked-token.mp3', 'url': '/api/tts_proxy/mocked-token.mp3', }), }) @@ -690,6 +702,7 @@ }), 'tts_output': dict({ 'mime_type': 'audio/mpeg', + 'token': 'mocked-token.mp3', 'url': '/api/tts_proxy/mocked-token.mp3', }), }) @@ -705,6 +718,7 @@ }), 'tts_output': dict({ 'mime_type': 'audio/mpeg', + 'token': 'mocked-token.mp3', 'url': '/api/tts_proxy/mocked-token.mp3', }), }) @@ -720,6 +734,7 @@ }), 'tts_output': dict({ 'mime_type': 'audio/mpeg', + 'token': 'mocked-token.mp3', 'url': '/api/tts_proxy/mocked-token.mp3', }), }) @@ -853,6 +868,7 @@ }), 'tts_output': dict({ 'mime_type': 'audio/mpeg', + 'token': 'mocked-token.mp3', 'url': '/api/tts_proxy/mocked-token.mp3', }), }) @@ -868,6 +884,7 @@ }), 'tts_output': dict({ 'mime_type': 'audio/mpeg', + 'token': 'mocked-token.mp3', 'url': '/api/tts_proxy/mocked-token.mp3', }), }) @@ -924,6 +941,7 @@ }), 'tts_output': dict({ 'mime_type': 'audio/mpeg', + 'token': 'mocked-token.mp3', 'url': '/api/tts_proxy/mocked-token.mp3', }), }) @@ -939,6 +957,7 @@ }), 'tts_output': dict({ 'mime_type': 'audio/mpeg', + 'token': 'mocked-token.mp3', 'url': '/api/tts_proxy/mocked-token.mp3', }), }) @@ -998,6 +1017,7 @@ }), 'tts_output': dict({ 'mime_type': 'audio/mpeg', + 'token': 'mocked-token.mp3', 'url': '/api/tts_proxy/mocked-token.mp3', }), }) @@ -1013,6 +1033,7 @@ }), 'tts_output': dict({ 'mime_type': 'audio/mpeg', + 'token': 'mocked-token.mp3', 'url': '/api/tts_proxy/mocked-token.mp3', }), }) diff --git a/tests/components/tts/common.py b/tests/components/tts/common.py index 921cab4cba2..9ae83cb2bb5 100644 --- a/tests/components/tts/common.py +++ b/tests/components/tts/common.py @@ -14,9 +14,11 @@ import voluptuous as vol from homeassistant.components import media_source from homeassistant.components.tts import ( CONF_LANG, + DATA_TTS_MANAGER, DOMAIN as TTS_DOMAIN, PLATFORM_SCHEMA as TTS_PLATFORM_SCHEMA, Provider, + ResultStream, TextToSpeechEntity, TtsAudioType, Voice, @@ -263,3 +265,26 @@ async def mock_config_entry_setup( await hass.async_block_till_done() return config_entry + + +class MockResultStream(ResultStream): + """Mock result stream.""" + + def __init__(self, hass: HomeAssistant, extension: str, data: bytes) -> None: + """Initialize the result stream.""" + super().__init__( + token="test-token", + extension=extension, + content_type=f"audio/mock-{extension}", + engine="test-engine", + use_file_cache=True, + language="en", + options={}, + _manager=hass.data[DATA_TTS_MANAGER], + ) + hass.data[DATA_TTS_MANAGER].token_to_stream[self.token] = self + self._mock_data = data + + async def async_stream_result(self): + """Stream the result.""" + yield self._mock_data diff --git a/tests/components/tts/test_init.py b/tests/components/tts/test_init.py index 1b9692cc70c..8bdd17cf3e9 100644 --- a/tests/components/tts/test_init.py +++ b/tests/components/tts/test_init.py @@ -28,6 +28,7 @@ from homeassistant.util import dt as dt_util from .common import ( DEFAULT_LANG, TEST_DOMAIN, + MockResultStream, MockTTS, MockTTSEntity, MockTTSProvider, @@ -1829,3 +1830,19 @@ async def test_default_engine_prefer_cloud_entity( provider_engine = tts.async_resolve_engine(hass, "test") assert provider_engine == "test" assert tts.async_default_engine(hass) == "tts.cloud_tts_entity" + + +async def test_stream(hass: HomeAssistant, mock_tts_entity: MockTTSEntity) -> None: + """Test creating streams.""" + await mock_config_entry_setup(hass, mock_tts_entity) + stream = tts.async_create_stream(hass, mock_tts_entity.entity_id) + assert stream.language == mock_tts_entity.default_language + assert stream.options == (mock_tts_entity.default_options or {}) + assert tts.async_get_stream(hass, stream.token) is stream + + data = b"beer" + stream2 = MockResultStream(hass, "wav", data) + assert tts.async_get_stream(hass, stream2.token) is stream2 + assert stream2.extension == "wav" + result_data = b"".join([chunk async for chunk in stream2.async_stream_result()]) + assert result_data == data From a13911e00ecf492fe1ca8ddec3602a7fee6a6cef Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 11 Mar 2025 18:00:51 +0100 Subject: [PATCH 2524/3148] Rename test helpers module in mqtt (#140375) * Rename test helpers module in mqtt * missed a file --- tests/components/mqtt/{test_common.py => common.py} | 0 tests/components/mqtt/test_alarm_control_panel.py | 2 +- tests/components/mqtt/test_binary_sensor.py | 2 +- tests/components/mqtt/test_button.py | 2 +- tests/components/mqtt/test_camera.py | 2 +- tests/components/mqtt/test_client.py | 2 +- tests/components/mqtt/test_climate.py | 2 +- tests/components/mqtt/test_cover.py | 2 +- tests/components/mqtt/test_device_tracker.py | 2 +- tests/components/mqtt/test_device_trigger.py | 2 +- tests/components/mqtt/test_discovery.py | 2 +- tests/components/mqtt/test_event.py | 2 +- tests/components/mqtt/test_fan.py | 2 +- tests/components/mqtt/test_humidifier.py | 2 +- tests/components/mqtt/test_image.py | 2 +- tests/components/mqtt/test_lawn_mower.py | 2 +- tests/components/mqtt/test_light.py | 2 +- tests/components/mqtt/test_light_json.py | 2 +- tests/components/mqtt/test_light_template.py | 2 +- tests/components/mqtt/test_lock.py | 2 +- tests/components/mqtt/test_notify.py | 2 +- tests/components/mqtt/test_number.py | 2 +- tests/components/mqtt/test_scene.py | 2 +- tests/components/mqtt/test_select.py | 2 +- tests/components/mqtt/test_sensor.py | 2 +- tests/components/mqtt/test_siren.py | 2 +- tests/components/mqtt/test_switch.py | 2 +- tests/components/mqtt/test_tag.py | 2 +- tests/components/mqtt/test_text.py | 2 +- tests/components/mqtt/test_update.py | 2 +- tests/components/mqtt/test_vacuum.py | 2 +- tests/components/mqtt/test_valve.py | 2 +- tests/components/mqtt/test_water_heater.py | 2 +- 33 files changed, 32 insertions(+), 32 deletions(-) rename tests/components/mqtt/{test_common.py => common.py} (100%) diff --git a/tests/components/mqtt/test_common.py b/tests/components/mqtt/common.py similarity index 100% rename from tests/components/mqtt/test_common.py rename to tests/components/mqtt/common.py diff --git a/tests/components/mqtt/test_alarm_control_panel.py b/tests/components/mqtt/test_alarm_control_panel.py index b46829650f6..9241106496b 100644 --- a/tests/components/mqtt/test_alarm_control_panel.py +++ b/tests/components/mqtt/test_alarm_control_panel.py @@ -33,7 +33,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError -from .test_common import ( +from .common import ( help_custom_config, help_test_availability_when_connection_lost, help_test_availability_without_topic, diff --git a/tests/components/mqtt/test_binary_sensor.py b/tests/components/mqtt/test_binary_sensor.py index 8809f2201f2..169e1ab4c6b 100644 --- a/tests/components/mqtt/test_binary_sensor.py +++ b/tests/components/mqtt/test_binary_sensor.py @@ -23,7 +23,7 @@ from homeassistant.core import HomeAssistant, State, callback from homeassistant.helpers.typing import ConfigType from homeassistant.util import dt as dt_util -from .test_common import ( +from .common import ( help_custom_config, help_test_availability_when_connection_lost, help_test_availability_without_topic, diff --git a/tests/components/mqtt/test_button.py b/tests/components/mqtt/test_button.py index f147b33c88b..f99c48a440f 100644 --- a/tests/components/mqtt/test_button.py +++ b/tests/components/mqtt/test_button.py @@ -10,7 +10,7 @@ from homeassistant.components import button, mqtt from homeassistant.const import ATTR_ENTITY_ID, ATTR_FRIENDLY_NAME, STATE_UNKNOWN from homeassistant.core import HomeAssistant -from .test_common import ( +from .common import ( help_test_availability_when_connection_lost, help_test_availability_without_topic, help_test_custom_availability_payload, diff --git a/tests/components/mqtt/test_camera.py b/tests/components/mqtt/test_camera.py index cda536dc19e..b5971adcb92 100644 --- a/tests/components/mqtt/test_camera.py +++ b/tests/components/mqtt/test_camera.py @@ -11,7 +11,7 @@ from homeassistant.components import camera, mqtt from homeassistant.components.mqtt.camera import MQTT_CAMERA_ATTRIBUTES_BLOCKED from homeassistant.core import HomeAssistant -from .test_common import ( +from .common import ( help_test_availability_when_connection_lost, help_test_availability_without_topic, help_test_custom_availability_payload, diff --git a/tests/components/mqtt/test_client.py b/tests/components/mqtt/test_client.py index 0dbbff58026..c2cce3d1344 100644 --- a/tests/components/mqtt/test_client.py +++ b/tests/components/mqtt/test_client.py @@ -27,8 +27,8 @@ from homeassistant.core import CALLBACK_TYPE, CoreState, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.util.dt import utcnow +from .common import help_all_subscribe_calls from .conftest import ENTRY_DEFAULT_BIRTH_MESSAGE -from .test_common import help_all_subscribe_calls from tests.common import ( MockConfigEntry, diff --git a/tests/components/mqtt/test_climate.py b/tests/components/mqtt/test_climate.py index 3760b0226f5..5279dfe93f7 100644 --- a/tests/components/mqtt/test_climate.py +++ b/tests/components/mqtt/test_climate.py @@ -37,7 +37,7 @@ from homeassistant.const import ATTR_TEMPERATURE, STATE_UNKNOWN, UnitOfTemperatu from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError -from .test_common import ( +from .common import ( help_custom_config, help_test_availability_when_connection_lost, help_test_availability_without_topic, diff --git a/tests/components/mqtt/test_cover.py b/tests/components/mqtt/test_cover.py index ee74b78be81..1e45853026a 100644 --- a/tests/components/mqtt/test_cover.py +++ b/tests/components/mqtt/test_cover.py @@ -45,7 +45,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant -from .test_common import ( +from .common import ( help_custom_config, help_test_availability_when_connection_lost, help_test_availability_without_topic, diff --git a/tests/components/mqtt/test_device_tracker.py b/tests/components/mqtt/test_device_tracker.py index 00e88860299..02289c8e476 100644 --- a/tests/components/mqtt/test_device_tracker.py +++ b/tests/components/mqtt/test_device_tracker.py @@ -12,7 +12,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component -from .test_common import ( +from .common import ( help_custom_config, help_test_reloadable, help_test_setting_blocked_attribute_via_mqtt_json_message, diff --git a/tests/components/mqtt/test_device_trigger.py b/tests/components/mqtt/test_device_trigger.py index 5cdfb14a5cf..ecf922e54a1 100644 --- a/tests/components/mqtt/test_device_trigger.py +++ b/tests/components/mqtt/test_device_trigger.py @@ -16,7 +16,7 @@ from homeassistant.helpers import device_registry as dr from homeassistant.helpers.trigger import async_initialize_triggers from homeassistant.setup import async_setup_component -from .test_common import help_test_unload_config_entry +from .common import help_test_unload_config_entry from tests.common import async_fire_mqtt_message, async_get_device_automations from tests.typing import MqttMockHAClientGenerator, WebSocketGenerator diff --git a/tests/components/mqtt/test_discovery.py b/tests/components/mqtt/test_discovery.py index 47c3a1e1988..ee33cbcbaa1 100644 --- a/tests/components/mqtt/test_discovery.py +++ b/tests/components/mqtt/test_discovery.py @@ -46,8 +46,8 @@ from homeassistant.helpers.service_info.mqtt import MqttServiceInfo from homeassistant.setup import async_setup_component from homeassistant.util.signal_type import SignalTypeFormat +from .common import help_all_subscribe_calls, help_test_unload_config_entry from .conftest import ENTRY_DEFAULT_BIRTH_MESSAGE -from .test_common import help_all_subscribe_calls, help_test_unload_config_entry from .test_tag import DEFAULT_TAG_ID, DEFAULT_TAG_SCAN from tests.common import ( diff --git a/tests/components/mqtt/test_event.py b/tests/components/mqtt/test_event.py index 41049ed0887..a7f00a1d1a8 100644 --- a/tests/components/mqtt/test_event.py +++ b/tests/components/mqtt/test_event.py @@ -13,7 +13,7 @@ from homeassistant.const import STATE_UNKNOWN from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr -from .test_common import ( +from .common import ( help_custom_config, help_test_availability_when_connection_lost, help_test_availability_without_topic, diff --git a/tests/components/mqtt/test_fan.py b/tests/components/mqtt/test_fan.py index 6c8afe8c1b4..36b5032e282 100644 --- a/tests/components/mqtt/test_fan.py +++ b/tests/components/mqtt/test_fan.py @@ -36,7 +36,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant -from .test_common import ( +from .common import ( help_custom_config, help_test_availability_when_connection_lost, help_test_availability_without_topic, diff --git a/tests/components/mqtt/test_humidifier.py b/tests/components/mqtt/test_humidifier.py index 20ca89181eb..435531182ed 100644 --- a/tests/components/mqtt/test_humidifier.py +++ b/tests/components/mqtt/test_humidifier.py @@ -36,7 +36,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant -from .test_common import ( +from .common import ( help_custom_config, help_test_availability_when_connection_lost, help_test_availability_without_topic, diff --git a/tests/components/mqtt/test_image.py b/tests/components/mqtt/test_image.py index 6f0eb8edf49..9b64a8836a0 100644 --- a/tests/components/mqtt/test_image.py +++ b/tests/components/mqtt/test_image.py @@ -14,7 +14,7 @@ from homeassistant.components import image, mqtt from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.core import HomeAssistant -from .test_common import ( +from .common import ( help_custom_config, help_test_availability_when_connection_lost, help_test_availability_without_topic, diff --git a/tests/components/mqtt/test_lawn_mower.py b/tests/components/mqtt/test_lawn_mower.py index 0bef4196ef2..c58402c4f5c 100644 --- a/tests/components/mqtt/test_lawn_mower.py +++ b/tests/components/mqtt/test_lawn_mower.py @@ -19,7 +19,7 @@ from homeassistant.components.mqtt.lawn_mower import MQTT_LAWN_MOWER_ATTRIBUTES_ from homeassistant.const import ATTR_ASSUMED_STATE, ATTR_ENTITY_ID, STATE_UNKNOWN from homeassistant.core import HomeAssistant, State -from .test_common import ( +from .common import ( help_custom_config, help_test_availability_when_connection_lost, help_test_availability_without_topic, diff --git a/tests/components/mqtt/test_light.py b/tests/components/mqtt/test_light.py index f8c66a3de1d..a8be259c1c9 100644 --- a/tests/components/mqtt/test_light.py +++ b/tests/components/mqtt/test_light.py @@ -210,7 +210,7 @@ from homeassistant.components.mqtt.models import PublishPayloadType from homeassistant.const import ATTR_ASSUMED_STATE, STATE_OFF, STATE_ON, STATE_UNKNOWN from homeassistant.core import HomeAssistant, State -from .test_common import ( +from .common import ( help_custom_config, help_test_availability_when_connection_lost, help_test_availability_without_topic, diff --git a/tests/components/mqtt/test_light_json.py b/tests/components/mqtt/test_light_json.py index 67d382826ae..f3264858095 100644 --- a/tests/components/mqtt/test_light_json.py +++ b/tests/components/mqtt/test_light_json.py @@ -102,7 +102,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, State from homeassistant.util.json import json_loads -from .test_common import ( +from .common import ( help_custom_config, help_test_availability_when_connection_lost, help_test_availability_without_topic, diff --git a/tests/components/mqtt/test_light_template.py b/tests/components/mqtt/test_light_template.py index 568d86f8bd9..b3a1c11c2b6 100644 --- a/tests/components/mqtt/test_light_template.py +++ b/tests/components/mqtt/test_light_template.py @@ -45,7 +45,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, State -from .test_common import ( +from .common import ( help_custom_config, help_test_availability_when_connection_lost, help_test_availability_without_topic, diff --git a/tests/components/mqtt/test_lock.py b/tests/components/mqtt/test_lock.py index 034f9b5ff6e..4aa6ecd03ef 100644 --- a/tests/components/mqtt/test_lock.py +++ b/tests/components/mqtt/test_lock.py @@ -23,7 +23,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant -from .test_common import ( +from .common import ( help_custom_config, help_test_availability_when_connection_lost, help_test_availability_without_topic, diff --git a/tests/components/mqtt/test_notify.py b/tests/components/mqtt/test_notify.py index 4837ee214c4..56da809d1b6 100644 --- a/tests/components/mqtt/test_notify.py +++ b/tests/components/mqtt/test_notify.py @@ -11,7 +11,7 @@ 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 ( +from .common import ( help_test_availability_when_connection_lost, help_test_availability_without_topic, help_test_custom_availability_payload, diff --git a/tests/components/mqtt/test_number.py b/tests/components/mqtt/test_number.py index 7bdd39e81a7..f391236aca4 100644 --- a/tests/components/mqtt/test_number.py +++ b/tests/components/mqtt/test_number.py @@ -31,7 +31,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, State from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM -from .test_common import ( +from .common import ( help_custom_config, help_test_availability_when_connection_lost, help_test_availability_without_topic, diff --git a/tests/components/mqtt/test_scene.py b/tests/components/mqtt/test_scene.py index d78dbe5c003..1650fe74601 100644 --- a/tests/components/mqtt/test_scene.py +++ b/tests/components/mqtt/test_scene.py @@ -10,7 +10,7 @@ from homeassistant.components import mqtt, scene from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_ON, STATE_UNKNOWN from homeassistant.core import HomeAssistant, State -from .test_common import ( +from .common import ( help_test_availability_when_connection_lost, help_test_availability_without_topic, help_test_custom_availability_payload, diff --git a/tests/components/mqtt/test_select.py b/tests/components/mqtt/test_select.py index 8d79a3ce609..a880368fa51 100644 --- a/tests/components/mqtt/test_select.py +++ b/tests/components/mqtt/test_select.py @@ -21,7 +21,7 @@ from homeassistant.const import ATTR_ASSUMED_STATE, ATTR_ENTITY_ID, STATE_UNKNOW from homeassistant.core import HomeAssistant, State from homeassistant.helpers.typing import ConfigType -from .test_common import ( +from .common import ( help_custom_config, help_test_availability_when_connection_lost, help_test_availability_without_topic, diff --git a/tests/components/mqtt/test_sensor.py b/tests/components/mqtt/test_sensor.py index 1fcd70a0b10..74dc94de21e 100644 --- a/tests/components/mqtt/test_sensor.py +++ b/tests/components/mqtt/test_sensor.py @@ -25,7 +25,7 @@ from homeassistant.helpers import device_registry as dr, issue_registry as ir from homeassistant.helpers.typing import ConfigType from homeassistant.util import dt as dt_util -from .test_common import ( +from .common import ( help_custom_config, help_test_availability_when_connection_lost, help_test_availability_without_topic, diff --git a/tests/components/mqtt/test_siren.py b/tests/components/mqtt/test_siren.py index 58a5cb735f9..5d82708e242 100644 --- a/tests/components/mqtt/test_siren.py +++ b/tests/components/mqtt/test_siren.py @@ -20,7 +20,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant -from .test_common import ( +from .common import ( help_custom_config, help_test_availability_when_connection_lost, help_test_availability_without_topic, diff --git a/tests/components/mqtt/test_switch.py b/tests/components/mqtt/test_switch.py index dceeff07377..d834595afe0 100644 --- a/tests/components/mqtt/test_switch.py +++ b/tests/components/mqtt/test_switch.py @@ -16,7 +16,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, State -from .test_common import ( +from .common import ( help_custom_config, help_test_availability_when_connection_lost, help_test_availability_without_topic, diff --git a/tests/components/mqtt/test_tag.py b/tests/components/mqtt/test_tag.py index 41c417fe3e9..95326382dcc 100644 --- a/tests/components/mqtt/test_tag.py +++ b/tests/components/mqtt/test_tag.py @@ -13,7 +13,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component -from .test_common import help_test_unload_config_entry +from .common import help_test_unload_config_entry from tests.common import ( MockConfigEntry, diff --git a/tests/components/mqtt/test_text.py b/tests/components/mqtt/test_text.py index 96924030279..050b2b59590 100644 --- a/tests/components/mqtt/test_text.py +++ b/tests/components/mqtt/test_text.py @@ -11,7 +11,7 @@ from homeassistant.components import mqtt, text from homeassistant.const import ATTR_ASSUMED_STATE, ATTR_ENTITY_ID, STATE_UNKNOWN from homeassistant.core import HomeAssistant -from .test_common import ( +from .common import ( help_custom_config, help_test_availability_when_connection_lost, help_test_availability_without_topic, diff --git a/tests/components/mqtt/test_update.py b/tests/components/mqtt/test_update.py index 4ca10cbe8b2..d70d7dd792b 100644 --- a/tests/components/mqtt/test_update.py +++ b/tests/components/mqtt/test_update.py @@ -10,7 +10,7 @@ from homeassistant.components.update import DOMAIN as UPDATE_DOMAIN, SERVICE_INS from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON, STATE_UNKNOWN from homeassistant.core import HomeAssistant -from .test_common import ( +from .common import ( help_custom_config, help_test_availability_when_connection_lost, help_test_availability_without_topic, diff --git a/tests/components/mqtt/test_vacuum.py b/tests/components/mqtt/test_vacuum.py index c1c662048d7..ba404e2dff0 100644 --- a/tests/components/mqtt/test_vacuum.py +++ b/tests/components/mqtt/test_vacuum.py @@ -33,7 +33,7 @@ from homeassistant.const import CONF_NAME, ENTITY_MATCH_ALL, STATE_UNKNOWN from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from .test_common import ( +from .common import ( help_custom_config, help_test_availability_when_connection_lost, help_test_availability_without_topic, diff --git a/tests/components/mqtt/test_valve.py b/tests/components/mqtt/test_valve.py index 6dd0102b8a3..10387a5b19e 100644 --- a/tests/components/mqtt/test_valve.py +++ b/tests/components/mqtt/test_valve.py @@ -27,7 +27,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant -from .test_common import ( +from .common import ( help_custom_config, help_test_availability_when_connection_lost, help_test_availability_without_topic, diff --git a/tests/components/mqtt/test_water_heater.py b/tests/components/mqtt/test_water_heater.py index 02ae54c1a85..bd688af6f21 100644 --- a/tests/components/mqtt/test_water_heater.py +++ b/tests/components/mqtt/test_water_heater.py @@ -34,7 +34,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.util.unit_conversion import TemperatureConverter -from .test_common import ( +from .common import ( help_custom_config, help_test_availability_when_connection_lost, help_test_availability_without_topic, From d309239bcc46614b0d08b38f7d5250256b166b3b Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 11 Mar 2025 18:18:34 +0100 Subject: [PATCH 2525/3148] Fix typo in Google Generative AI conversation: intead -> instead (#140398) --- .../components/google_generative_ai_conversation/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/google_generative_ai_conversation/strings.json b/homeassistant/components/google_generative_ai_conversation/strings.json index 772fadb089c..7bf1831a34b 100644 --- a/homeassistant/components/google_generative_ai_conversation/strings.json +++ b/homeassistant/components/google_generative_ai_conversation/strings.json @@ -70,7 +70,7 @@ "issues": { "deprecated_image_filename_parameter": { "title": "Deprecated 'image_filename' parameter", - "description": "The 'image_filename' parameter in Google Generative AI actions is deprecated. Please edit scripts and automations to use 'filenames' intead." + "description": "The 'image_filename' parameter in Google Generative AI actions is deprecated. Please edit scripts and automations to use 'filenames' instead." } } } From d8bcba9ef0a31383537c87d47ea8d58d12b2e18f Mon Sep 17 00:00:00 2001 From: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> Date: Tue, 11 Mar 2025 13:00:43 -0500 Subject: [PATCH 2526/3148] Enable HEOS automatic failover (#140394) Failover --- homeassistant/components/heos/coordinator.py | 18 +++++++++++++++--- tests/components/heos/__init__.py | 4 ++++ tests/components/heos/test_init.py | 19 +++++++++++++++++++ 3 files changed, 38 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/heos/coordinator.py b/homeassistant/components/heos/coordinator.py index 93fe069d9be..0333c60ec21 100644 --- a/homeassistant/components/heos/coordinator.py +++ b/homeassistant/components/heos/coordinator.py @@ -43,7 +43,6 @@ class HeosCoordinator(DataUpdateCoordinator[None]): def __init__(self, hass: HomeAssistant, config_entry: HeosConfigEntry) -> None: """Set up the coordinator and set in config_entry.""" - self.host: str = config_entry.data[CONF_HOST] credentials: Credentials | None = None if config_entry.options: credentials = Credentials( @@ -53,9 +52,10 @@ class HeosCoordinator(DataUpdateCoordinator[None]): # media position update upon start of playback or when media changes self.heos = Heos( HeosOptions( - self.host, + config_entry.data[CONF_HOST], all_progress_events=False, auto_reconnect=True, + auto_failover=True, credentials=credentials, ) ) @@ -66,6 +66,11 @@ class HeosCoordinator(DataUpdateCoordinator[None]): self._inputs: Sequence[MediaItem] = [] super().__init__(hass, _LOGGER, config_entry=config_entry, name=DOMAIN) + @property + def host(self) -> str: + """Get the host address of the device.""" + return self.heos.current_host + @property def inputs(self) -> Sequence[MediaItem]: """Get input sources across all devices.""" @@ -159,8 +164,15 @@ class HeosCoordinator(DataUpdateCoordinator[None]): async def _async_on_reconnected(self) -> None: """Handle when reconnected so resources are updated and entities marked available.""" + assert self.config_entry is not None + if self.host != self.config_entry.data[CONF_HOST]: + self.hass.config_entries.async_update_entry( + self.config_entry, data={CONF_HOST: self.host} + ) + _LOGGER.warning("Successfully failed over to HEOS host %s", self.host) + else: + _LOGGER.warning("Successfully reconnected to HEOS host %s", self.host) await self._async_update_sources() - _LOGGER.warning("Successfully reconnected to HEOS host %s", self.host) self.async_update_listeners() async def _async_on_controller_event( diff --git a/tests/components/heos/__init__.py b/tests/components/heos/__init__.py index 016cc7b3580..862b1e5ffab 100644 --- a/tests/components/heos/__init__.py +++ b/tests/components/heos/__init__.py @@ -64,3 +64,7 @@ class MockHeos(Heos): def mock_set_connection_state(self, connection_state: ConnectionState) -> None: """Set the connection state on the mock instance.""" self._connection._state = connection_state + + def mock_set_current_host(self, host: str) -> None: + """Set the current host on the mock instance.""" + self._connection._host = host diff --git a/tests/components/heos/test_init.py b/tests/components/heos/test_init.py index b155abaf0e9..7bc232ad5a6 100644 --- a/tests/components/heos/test_init.py +++ b/tests/components/heos/test_init.py @@ -297,6 +297,25 @@ async def test_reconnected_new_entities_created( assert entity_registry.async_get_entity_id(MEDIA_PLAYER_DOMAIN, DOMAIN, "3") +async def test_reconnected_failover_updates_host( + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos +) -> None: + """Test the config entry host is updated after failover.""" + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + assert config_entry.data[CONF_HOST] == "127.0.0.1" + + # Simulate reconnection + controller.mock_set_current_host("127.0.0.2") + await controller.dispatcher.wait_send( + SignalType.HEOS_EVENT, SignalHeosEvent.CONNECTED + ) + await hass.async_block_till_done() + + # Assert config entry host updated + assert config_entry.data[CONF_HOST] == "127.0.0.2" + + async def test_players_changed_new_entities_created( hass: HomeAssistant, entity_registry: er.EntityRegistry, From 0b41d056d3a68bfdf7beb9ae715c2706b57ea903 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Tue, 11 Mar 2025 20:05:02 +0100 Subject: [PATCH 2527/3148] Only do WebDAV path migration when path differs (#140402) --- homeassistant/components/webdav/helpers.py | 3 ++- tests/components/webdav/test_init.py | 24 ++++++++++++++++++---- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/webdav/helpers.py b/homeassistant/components/webdav/helpers.py index 5db15bba0f7..442f69b4d3c 100644 --- a/homeassistant/components/webdav/helpers.py +++ b/homeassistant/components/webdav/helpers.py @@ -49,7 +49,8 @@ async def async_ensure_path_exists(client: Client, path: str) -> bool: async def async_migrate_wrong_folder_path(client: Client, path: str) -> None: """Migrate the wrong encoded folder path to the correct one.""" wrong_path = path.replace(" ", "%20") - if await client.check(wrong_path): + # migrate folder when the old folder exists + if wrong_path != path and await client.check(wrong_path): try: await client.move(wrong_path, path) except WebDavError as err: diff --git a/tests/components/webdav/test_init.py b/tests/components/webdav/test_init.py index c267f7c3251..124a644fa93 100644 --- a/tests/components/webdav/test_init.py +++ b/tests/components/webdav/test_init.py @@ -39,14 +39,30 @@ async def test_migrate_wrong_path( webdav_client.move.assert_called_once_with("/wrong%20path", "/wrong path") +@pytest.mark.parametrize( + ("expected_path", "remote_path_check"), + [ + ( + "/correct path", + False, + ), # remote_path_check is False as /correct%20path is not there + ("/", True), + ("/folder_with_underscores", True), + ], +) async def test_migrate_non_wrong_path( - hass: HomeAssistant, webdav_client: AsyncMock + hass: HomeAssistant, + webdav_client: AsyncMock, + expected_path: str, + remote_path_check: bool, ) -> None: """Test no migration of correct folder path.""" webdav_client.list_with_properties.return_value = [ - {"/correct path": []}, + {expected_path: []}, ] - webdav_client.check.side_effect = lambda path: path == "/correct path" + # first return is used to check the connectivity + # second is used in the migration to determine if wrong quoted path is there + webdav_client.check.side_effect = [True, remote_path_check] config_entry = MockConfigEntry( title="user@webdav.demo", @@ -55,7 +71,7 @@ async def test_migrate_non_wrong_path( CONF_URL: "https://webdav.demo", CONF_USERNAME: "user", CONF_PASSWORD: "supersecretpassword", - CONF_BACKUP_PATH: "/correct path", + CONF_BACKUP_PATH: expected_path, }, entry_id="01JKXV07ASC62D620DGYNG2R8H", ) From f50325fc7df7f37cde1159643b6fb2d9827f4647 Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Tue, 11 Mar 2025 15:21:28 -0400 Subject: [PATCH 2528/3148] Add dock dryer control to Roborock (#138495) * Add a dock dryer select * change import * Change name to match app --- homeassistant/components/roborock/select.py | 47 ++++++++++++------- .../components/roborock/strings.json | 9 ++++ 2 files changed, 39 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/roborock/select.py b/homeassistant/components/roborock/select.py index 6133eed0652..b76c90b44f5 100644 --- a/homeassistant/components/roborock/select.py +++ b/homeassistant/components/roborock/select.py @@ -4,9 +4,9 @@ import asyncio from collections.abc import Callable from dataclasses import dataclass -from roborock.containers import Status +from roborock.code_mappings import RoborockDockDustCollectionModeCode from roborock.roborock_message import RoborockDataProtocol -from roborock.roborock_typing import RoborockCommand +from roborock.roborock_typing import DeviceProp, RoborockCommand from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.const import EntityCategory @@ -25,11 +25,11 @@ class RoborockSelectDescription(SelectEntityDescription): # The command that the select entity will send to the api. api_command: RoborockCommand # Gets the current value of the select entity. - value_fn: Callable[[Status], str | None] + value_fn: Callable[[DeviceProp], str | None] # Gets all options of the select entity. - options_lambda: Callable[[Status], list[str] | None] + options_lambda: Callable[[DeviceProp], list[str] | None] # Takes the value from the select entity and converts it for the api. - parameter_lambda: Callable[[str, Status], list[int]] + parameter_lambda: Callable[[str, DeviceProp], list[int]] protocol_listener: RoborockDataProtocol | None = None @@ -39,24 +39,37 @@ SELECT_DESCRIPTIONS: list[RoborockSelectDescription] = [ key="water_box_mode", translation_key="mop_intensity", api_command=RoborockCommand.SET_WATER_BOX_CUSTOM_MODE, - value_fn=lambda data: data.water_box_mode_name, + value_fn=lambda data: data.status.water_box_mode_name, entity_category=EntityCategory.CONFIG, - options_lambda=lambda data: data.water_box_mode.keys() - if data.water_box_mode is not None + options_lambda=lambda data: data.status.water_box_mode.keys() + if data.status.water_box_mode is not None else None, - parameter_lambda=lambda key, status: [status.get_mop_intensity_code(key)], + parameter_lambda=lambda key, prop: [prop.status.get_mop_intensity_code(key)], protocol_listener=RoborockDataProtocol.WATER_BOX_MODE, ), RoborockSelectDescription( key="mop_mode", translation_key="mop_mode", api_command=RoborockCommand.SET_MOP_MODE, - value_fn=lambda data: data.mop_mode_name, + value_fn=lambda data: data.status.mop_mode_name, entity_category=EntityCategory.CONFIG, - options_lambda=lambda data: data.mop_mode.keys() - if data.mop_mode is not None + options_lambda=lambda data: data.status.mop_mode.keys() + if data.status.mop_mode is not None else None, - parameter_lambda=lambda key, status: [status.get_mop_mode_code(key)], + parameter_lambda=lambda key, prop: [prop.status.get_mop_mode_code(key)], + ), + RoborockSelectDescription( + key="dust_collection_mode", + translation_key="dust_collection_mode", + api_command=RoborockCommand.SET_DUST_COLLECTION_MODE, + value_fn=lambda data: data.dust_collection_mode_name, + entity_category=EntityCategory.CONFIG, + options_lambda=lambda data: RoborockDockDustCollectionModeCode.keys() + if data.dust_collection_mode_name is not None + else None, + parameter_lambda=lambda key, _: [ + RoborockDockDustCollectionModeCode.as_dict().get(key) + ], ), ] @@ -74,7 +87,7 @@ async def async_setup_entry( for description in SELECT_DESCRIPTIONS if ( options := description.options_lambda( - coordinator.roborock_device_info.props.status + coordinator.roborock_device_info.props ) ) is not None @@ -111,13 +124,13 @@ class RoborockSelectEntity(RoborockCoordinatedEntityV1, SelectEntity): """Set the option.""" await self.send( self.entity_description.api_command, - self.entity_description.parameter_lambda(option, self._device_status), + self.entity_description.parameter_lambda(option, self.coordinator.data), ) @property def current_option(self) -> str | None: - """Get the current status of the select entity from device_status.""" - return self.entity_description.value_fn(self._device_status) + """Get the current status of the select entity from device props.""" + return self.entity_description.value_fn(self.coordinator.data) class RoborockCurrentMapSelectEntity(RoborockCoordinatedEntityV1, SelectEntity): diff --git a/homeassistant/components/roborock/strings.json b/homeassistant/components/roborock/strings.json index eb058ea74e3..efb17ef407e 100644 --- a/homeassistant/components/roborock/strings.json +++ b/homeassistant/components/roborock/strings.json @@ -353,6 +353,15 @@ }, "selected_map": { "name": "Selected map" + }, + "dust_collection_mode": { + "name": "Empty mode", + "state": { + "smart": "Smart", + "light": "Light", + "balanced": "[%key:component::roborock::entity::vacuum::roborock::state_attributes::fan_speed::state::balanced%]", + "max": "[%key:component::roborock::entity::select::mop_intensity::state::max%]" + } } }, "switch": { From 6fb6f9298543aecaa5f954b67feb4f1304380c04 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 11 Mar 2025 20:23:41 +0100 Subject: [PATCH 2529/3148] Improve descriptions of `lifx.effect_sky` action (#140400) * Improve descriptions of `lifx.effect_sky` action The 'Sky Effect' action of the LIFX integration allows three types of sky types to choose from: - "Clouds" - "Sunrise" - "Sunset" This commit fixes the wrong naming of the "Clouds" effect as "Cloud" and adds details about it to the descriptions of the `cloud_saturation_min`and `cloud_saturation_max` fields (from the online docs). In addition the inconsistent capitalization of their `name` strings is fixed, too. * Improve action description as well --- homeassistant/components/lifx/strings.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/lifx/strings.json b/homeassistant/components/lifx/strings.json index c407489d52d..97cd007ef22 100644 --- a/homeassistant/components/lifx/strings.json +++ b/homeassistant/components/lifx/strings.json @@ -223,23 +223,23 @@ }, "effect_sky": { "name": "Sky effect", - "description": "Starts the firmware-based Sky effect on LIFX Ceiling.", + "description": "Starts a firmware-based effect on LIFX Ceiling lights that animates a sky scene across the device.", "fields": { "speed": { "name": "Speed", - "description": "How long the Sunrise and Sunset sky types will take to complete. For the Cloud sky type, it is the speed of the clouds across the device." + "description": "How long the Sunrise and Sunset sky types will take to complete. For the Clouds sky type, it is the speed of the clouds across the device." }, "sky_type": { "name": "Sky type", "description": "The style of sky that will be animated by the effect." }, "cloud_saturation_min": { - "name": "Cloud saturation Minimum", - "description": "Minimum cloud saturation." + "name": "Cloud saturation minimum", + "description": "The minimum cloud saturation for the Clouds sky type." }, "cloud_saturation_max": { - "name": "Cloud Saturation maximum", - "description": "Maximum cloud saturation." + "name": "Cloud saturation maximum", + "description": "The maximum cloud saturation for the Clouds sky type." }, "palette": { "name": "Palette", From 7aeefa1400e956384b5da144ce270d5457d918bc Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 11 Mar 2025 15:28:13 -0400 Subject: [PATCH 2530/3148] Only store strings in cloud TTS default options (#140332) * Only store strings in cloud TTS default options * more type check * Don't stringify strenum --- homeassistant/components/cloud/tts.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/cloud/tts.py b/homeassistant/components/cloud/tts.py index 3ac3f3d1c2d..f901adfa99e 100644 --- a/homeassistant/components/cloud/tts.py +++ b/homeassistant/components/cloud/tts.py @@ -286,7 +286,7 @@ class CloudTTSEntity(TextToSpeechEntity): return self._language @property - def default_options(self) -> dict[str, Any]: + def default_options(self) -> dict[str, str]: """Return a dict include default options.""" return { ATTR_AUDIO_OUTPUT: AudioOutput.MP3, @@ -363,7 +363,7 @@ class CloudTTSEntity(TextToSpeechEntity): _LOGGER.error("Voice error: %s", err) return (None, None) - return (str(options[ATTR_AUDIO_OUTPUT].value), data) + return (options[ATTR_AUDIO_OUTPUT], data) class CloudProvider(Provider): @@ -404,7 +404,7 @@ class CloudProvider(Provider): return [Voice(voice, voice) for voice in voices] @property - def default_options(self) -> dict[str, Any]: + def default_options(self) -> dict[str, str]: """Return a dict include default options.""" return { ATTR_AUDIO_OUTPUT: AudioOutput.MP3, @@ -444,7 +444,7 @@ class CloudProvider(Provider): _LOGGER.error("Voice error: %s", err) return (None, None) - return (str(options[ATTR_AUDIO_OUTPUT].value), data) + return options[ATTR_AUDIO_OUTPUT], data @callback From b88d662677e6e1986a4494650a377916c78388b9 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Tue, 11 Mar 2025 13:02:56 -0700 Subject: [PATCH 2531/3148] Add Roborock data_description for config flow and options flow (#140384) * Add Roborock data_description for config flow and options flow * Remove the drawables logging --- .../components/roborock/quality_scale.yaml | 4 +-- .../components/roborock/strings.json | 25 +++++++++++++++++++ 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/roborock/quality_scale.yaml b/homeassistant/components/roborock/quality_scale.yaml index 845d77d0fbe..fa5e1f4ceeb 100644 --- a/homeassistant/components/roborock/quality_scale.yaml +++ b/homeassistant/components/roborock/quality_scale.yaml @@ -9,9 +9,7 @@ rules: separate cloud vs local intervals. brands: done common-modules: done - config-flow: - status: todo - comment: Not all fields have a data_description. + config-flow: done config-flow-test-coverage: done dependency-transparency: done docs-actions: diff --git a/homeassistant/components/roborock/strings.json b/homeassistant/components/roborock/strings.json index efb17ef407e..c115ec33851 100644 --- a/homeassistant/components/roborock/strings.json +++ b/homeassistant/components/roborock/strings.json @@ -5,12 +5,18 @@ "description": "Enter your Roborock email address.", "data": { "username": "[%key:common::config_flow::data::email%]" + }, + "data_description": { + "username": "The email address used to sign in to the Roborock app." } }, "code": { "description": "Type the verification code sent to your email", "data": { "code": "Verification code" + }, + "data_description": { + "code": "The verification code sent to your email." } }, "reauth_confirm": { @@ -54,6 +60,25 @@ "vacuum_position": "Vacuum position", "virtual_walls": "Virtual walls", "zones": "Zones" + }, + "data_description": { + "charger": "Show the charger on the map.", + "cleaned_area": "Show the area cleaned on the map.", + "goto_path": "Show the go-to path on the map.", + "ignored_obstacles": "Show ignored obstacles on the map.", + "ignored_obstacles_with_photo": "Show ignored obstacles with photos on the map.", + "mop_path": "Show the mop path on the map.", + "no_carpet_zones": "Show the no carpet zones on the map.", + "no_go_zones": "Show the no-go zones on the map.", + "no_mopping_zones": "Show the no-mop zones on the map.", + "obstacles": "Show obstacles on the map.", + "obstacles_with_photo": "Show obstacles with photos on the map.", + "path": "Show the path on the map.", + "predicted_path": "Show the predicted path on the map.", + "room_names": "Show room names on the map.", + "vacuum_position": "Show the vacuum position on the map.", + "virtual_walls": "Show virtual walls on the map.", + "zones": "Show zones on the map." } } } From 2f44e300138c5497d19ab128ace224f81eba53a7 Mon Sep 17 00:00:00 2001 From: Tiddly Widdly Date: Tue, 11 Mar 2025 16:39:31 -0400 Subject: [PATCH 2532/3148] Add lutron caseta model Caseta Shade SerenaEssentialsRollerShade (#139800) * Update cover.py Add support for new model roller shade SerenaEssentialsRollerShade, SYERX-B-X * update requirements modified: homeassistant/components/lutron_caseta/cover.py modified: homeassistant/components/lutron_caseta/manifest.json modified: requirements_all.txt modified: requirements_test_all.txt --------- Co-authored-by: J. Nick Koston --- homeassistant/components/lutron_caseta/cover.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/lutron_caseta/cover.py b/homeassistant/components/lutron_caseta/cover.py index 3727dbf17ba..e05fddb996f 100644 --- a/homeassistant/components/lutron_caseta/cover.py +++ b/homeassistant/components/lutron_caseta/cover.py @@ -108,6 +108,7 @@ PYLUTRON_TYPE_TO_CLASSES = { "QsWirelessHorizontalSheerBlind": LutronCasetaShade, "Shade": LutronCasetaShade, "PalladiomWireFreeShade": LutronCasetaShade, + "SerenaEssentialsRollerShade": LutronCasetaShade, } From e858e21a402889c5052cd4a24a2499125f3b1649 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 11 Mar 2025 12:57:16 -1000 Subject: [PATCH 2533/3148] Add Bluetooth discovery support for InkBird ITH-11-B (#140423) Add support for InkBird ITH-11-B --- homeassistant/components/inkbird/manifest.json | 4 ++++ homeassistant/generated/bluetooth.py | 5 +++++ 2 files changed, 9 insertions(+) diff --git a/homeassistant/components/inkbird/manifest.json b/homeassistant/components/inkbird/manifest.json index 6b570b27fe2..aaa9c4b3473 100644 --- a/homeassistant/components/inkbird/manifest.json +++ b/homeassistant/components/inkbird/manifest.json @@ -22,6 +22,10 @@ "local_name": "tps", "connectable": false }, + { + "local_name": "ITH-11-B", + "connectable": false + }, { "local_name": "ITH-13-B", "connectable": false diff --git a/homeassistant/generated/bluetooth.py b/homeassistant/generated/bluetooth.py index be75c675a91..1ff444ca25f 100644 --- a/homeassistant/generated/bluetooth.py +++ b/homeassistant/generated/bluetooth.py @@ -356,6 +356,11 @@ BLUETOOTH: Final[list[dict[str, bool | str | int | list[int]]]] = [ "domain": "inkbird", "local_name": "tps", }, + { + "connectable": False, + "domain": "inkbird", + "local_name": "ITH-11-B", + }, { "connectable": False, "domain": "inkbird", From 7b736908fa1a128c0b775e9fa264594e95bb401c Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 12 Mar 2025 00:15:25 +0100 Subject: [PATCH 2534/3148] Fix typo in description of `lifx.effect_morph` action (#140416) --- homeassistant/components/lifx/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/lifx/strings.json b/homeassistant/components/lifx/strings.json index 97cd007ef22..be0485c6dff 100644 --- a/homeassistant/components/lifx/strings.json +++ b/homeassistant/components/lifx/strings.json @@ -201,7 +201,7 @@ }, "effect_morph": { "name": "Morph effect", - "description": "Starts the firmware-based Morph effect on LIFX Tiles on Candle.", + "description": "Starts the firmware-based Morph effect on LIFX Tiles or Candle.", "fields": { "speed": { "name": "Speed", From 7197b8ebffe901714b4e4d9d9908e523bcf5a6f7 Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Tue, 11 Mar 2025 22:22:36 -0400 Subject: [PATCH 2535/3148] Set Roborock current map to config instead of select (#140429) Set current map to config instead of select --- homeassistant/components/roborock/select.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/roborock/select.py b/homeassistant/components/roborock/select.py index b76c90b44f5..c22a4deed3b 100644 --- a/homeassistant/components/roborock/select.py +++ b/homeassistant/components/roborock/select.py @@ -136,7 +136,7 @@ class RoborockSelectEntity(RoborockCoordinatedEntityV1, SelectEntity): class RoborockCurrentMapSelectEntity(RoborockCoordinatedEntityV1, SelectEntity): """A class to let you set the selected map on Roborock vacuum.""" - _attr_entity_category = EntityCategory.DIAGNOSTIC + _attr_entity_category = EntityCategory.CONFIG _attr_translation_key = "selected_map" async def async_select_option(self, option: str) -> None: From 25cfd6ceda30547b2b46034285d23846236efd8b Mon Sep 17 00:00:00 2001 From: Tobias Perschon Date: Wed, 12 Mar 2025 07:31:58 +0100 Subject: [PATCH 2536/3148] bump pydaikin to 2.14.1 (#140424) Signed-off-by: Tobias Perschon --- homeassistant/components/daikin/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/daikin/manifest.json b/homeassistant/components/daikin/manifest.json index f794d97a9ba..86fc804ec92 100644 --- a/homeassistant/components/daikin/manifest.json +++ b/homeassistant/components/daikin/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/daikin", "iot_class": "local_polling", "loggers": ["pydaikin"], - "requirements": ["pydaikin==2.13.8"], + "requirements": ["pydaikin==2.14.1"], "zeroconf": ["_dkapi._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 10e305cc47e..6830c3880e3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1882,7 +1882,7 @@ pycsspeechtts==1.0.8 # pycups==2.0.4 # homeassistant.components.daikin -pydaikin==2.13.8 +pydaikin==2.14.1 # homeassistant.components.danfoss_air pydanfossair==0.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c2043684a80..29425a177c5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1541,7 +1541,7 @@ pycountry==24.6.1 pycsspeechtts==1.0.8 # homeassistant.components.daikin -pydaikin==2.13.8 +pydaikin==2.14.1 # homeassistant.components.deako pydeako==0.6.0 From 593ae48aa2a75d9bfd89ab1845ff54d87ae2a95c Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 12 Mar 2025 08:47:34 +0100 Subject: [PATCH 2537/3148] Migrate mqtt tests to use unit system (#140376) * Migrate mqtt tests to use unit system * Fix param list * Missed one --------- Co-authored-by: jbouwh --- tests/components/mqtt/test_climate.py | 52 +++++++++------------- tests/components/mqtt/test_water_heater.py | 48 ++++++++------------ 2 files changed, 38 insertions(+), 62 deletions(-) diff --git a/tests/components/mqtt/test_climate.py b/tests/components/mqtt/test_climate.py index 5279dfe93f7..fd0b95f2b13 100644 --- a/tests/components/mqtt/test_climate.py +++ b/tests/components/mqtt/test_climate.py @@ -33,9 +33,14 @@ from homeassistant.components.mqtt.climate import ( MQTT_CLIMATE_ATTRIBUTES_BLOCKED, VALUE_TEMPLATE_KEYS, ) -from homeassistant.const import ATTR_TEMPERATURE, STATE_UNKNOWN, UnitOfTemperature +from homeassistant.const import ATTR_TEMPERATURE, STATE_UNKNOWN from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError +from homeassistant.util.unit_system import ( + METRIC_SYSTEM, + US_CUSTOMARY_SYSTEM, + UnitSystem, +) from .common import ( help_custom_config, @@ -1823,7 +1828,7 @@ async def test_temperature_unit( @pytest.mark.parametrize( - ("hass_config", "temperature_unit", "initial", "min", "max", "current"), + ("hass_config", "units", "initial", "min", "max", "current"), [ ( help_custom_config( @@ -1836,7 +1841,7 @@ async def test_temperature_unit( }, ), ), - UnitOfTemperature.CELSIUS, + METRIC_SYSTEM, DEFAULT_INITIAL_TEMPERATURE, DEFAULT_MIN_TEMP, DEFAULT_MAX_TEMP, @@ -1854,7 +1859,7 @@ async def test_temperature_unit( }, ), ), - UnitOfTemperature.CELSIUS, + METRIC_SYSTEM, 20.5, DEFAULT_MIN_TEMP, DEFAULT_MAX_TEMP, @@ -1871,24 +1876,7 @@ async def test_temperature_unit( }, ), ), - UnitOfTemperature.KELVIN, - 294, - 280, - 308, - 298, - ), - ( - help_custom_config( - climate.DOMAIN, - DEFAULT_CONFIG, - ( - { - "temperature_unit": "F", - "current_temperature_topic": "current_temperature", - }, - ), - ), - UnitOfTemperature.FAHRENHEIT, + US_CUSTOMARY_SYSTEM, 70, 45, 95, @@ -1899,25 +1887,25 @@ async def test_temperature_unit( async def test_alt_temperature_unit( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator, - temperature_unit: UnitOfTemperature, + units: UnitSystem, initial: float, min: float, max: float, current: float, ) -> None: """Test deriving the systems temperature unit.""" - with patch.object(hass.config.units, "temperature_unit", temperature_unit): - await mqtt_mock_entry() + hass.config.units = units + await mqtt_mock_entry() - state = hass.states.get(ENTITY_CLIMATE) - assert state.attributes.get("temperature") == initial - assert state.attributes.get("min_temp") == min - assert state.attributes.get("max_temp") == max + state = hass.states.get(ENTITY_CLIMATE) + assert state.attributes.get("temperature") == initial + assert state.attributes.get("min_temp") == min + assert state.attributes.get("max_temp") == max - async_fire_mqtt_message(hass, "current_temperature", "77") + async_fire_mqtt_message(hass, "current_temperature", "77") - state = hass.states.get(ENTITY_CLIMATE) - assert state.attributes.get("current_temperature") == current + state = hass.states.get(ENTITY_CLIMATE) + assert state.attributes.get("current_temperature") == current async def test_setting_attribute_via_mqtt_json_message( diff --git a/tests/components/mqtt/test_water_heater.py b/tests/components/mqtt/test_water_heater.py index bd688af6f21..21969ad7788 100644 --- a/tests/components/mqtt/test_water_heater.py +++ b/tests/components/mqtt/test_water_heater.py @@ -33,6 +33,11 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.util.unit_conversion import TemperatureConverter +from homeassistant.util.unit_system import ( + METRIC_SYSTEM, + US_CUSTOMARY_SYSTEM, + UnitSystem, +) from .common import ( help_custom_config, @@ -714,7 +719,7 @@ async def test_temperature_unit( @pytest.mark.parametrize( - ("hass_config", "temperature_unit", "initial", "min_temp", "max_temp", "current"), + ("hass_config", "units", "initial", "min_temp", "max_temp", "current"), [ ( help_custom_config( @@ -727,7 +732,7 @@ async def test_temperature_unit( }, ), ), - UnitOfTemperature.CELSIUS, + METRIC_SYSTEM, _DEFAULT_MIN_TEMP_CELSIUS, _DEFAULT_MIN_TEMP_CELSIUS, _DEFAULT_MAX_TEMP_CELSIUS, @@ -744,24 +749,7 @@ async def test_temperature_unit( }, ), ), - UnitOfTemperature.KELVIN, - 316, - 316, - 333, - 322, - ), - ( - help_custom_config( - water_heater.DOMAIN, - DEFAULT_CONFIG, - ( - { - "temperature_unit": "F", - "current_temperature_topic": "current_temperature", - }, - ), - ), - UnitOfTemperature.FAHRENHEIT, + US_CUSTOMARY_SYSTEM, DEFAULT_MIN_TEMP, DEFAULT_MIN_TEMP, DEFAULT_MAX_TEMP, @@ -772,25 +760,25 @@ async def test_temperature_unit( async def test_alt_temperature_unit( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator, - temperature_unit: UnitOfTemperature, + units: UnitSystem, initial: float, min_temp: float, max_temp: float, current: float, ) -> None: """Test deriving the systems temperature unit.""" - with patch.object(hass.config.units, "temperature_unit", temperature_unit): - await mqtt_mock_entry() + hass.config.units = units + await mqtt_mock_entry() - state = hass.states.get(ENTITY_WATER_HEATER) - assert state.attributes.get("temperature") == initial - assert state.attributes.get("min_temp") == min_temp - assert state.attributes.get("max_temp") == max_temp + state = hass.states.get(ENTITY_WATER_HEATER) + assert state.attributes.get("temperature") == initial + assert state.attributes.get("min_temp") == min_temp + assert state.attributes.get("max_temp") == max_temp - async_fire_mqtt_message(hass, "current_temperature", "120") + async_fire_mqtt_message(hass, "current_temperature", "120") - state = hass.states.get(ENTITY_WATER_HEATER) - assert state.attributes.get("current_temperature") == current + state = hass.states.get(ENTITY_WATER_HEATER) + assert state.attributes.get("current_temperature") == current async def test_setting_attribute_via_mqtt_json_message( From 2f1ff5ab95b060d678c1ec0f027460f27c332a02 Mon Sep 17 00:00:00 2001 From: Austin Mroczek Date: Wed, 12 Mar 2025 00:52:28 -0700 Subject: [PATCH 2538/3148] TotalConnect refactor tests (#140240) * refactor button * refactor test_options_flow --- tests/components/totalconnect/test_button.py | 26 ++++++---- .../totalconnect/test_config_flow.py | 50 ++++++------------- 2 files changed, 30 insertions(+), 46 deletions(-) diff --git a/tests/components/totalconnect/test_button.py b/tests/components/totalconnect/test_button.py index 80de004be1d..87764e55186 100644 --- a/tests/components/totalconnect/test_button.py +++ b/tests/components/totalconnect/test_button.py @@ -11,12 +11,7 @@ from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from .common import ( - RESPONSE_ZONE_BYPASS_FAILURE, - RESPONSE_ZONE_BYPASS_SUCCESS, - TOTALCONNECT_REQUEST, - setup_platform, -) +from .common import setup_platform from tests.common import snapshot_platform @@ -34,12 +29,23 @@ async def test_entity_registry( await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) -@pytest.mark.parametrize("entity_id", [ZONE_BYPASS_ID, PANEL_BYPASS_ID]) -async def test_bypass_button(hass: HomeAssistant, entity_id: str) -> None: +@pytest.mark.parametrize( + ("entity_id", "tcc_request"), + [ + (ZONE_BYPASS_ID, "total_connect_client.zone.TotalConnectZone.bypass"), + ( + PANEL_BYPASS_ID, + "total_connect_client.location.TotalConnectLocation.zone_bypass_all", + ), + ], +) +async def test_bypass_button( + hass: HomeAssistant, entity_id: str, tcc_request: str +) -> None: """Test pushing a bypass button.""" - responses = [RESPONSE_ZONE_BYPASS_FAILURE, RESPONSE_ZONE_BYPASS_SUCCESS] + responses = [FailedToBypassZone, None] await setup_platform(hass, BUTTON) - with patch(TOTALCONNECT_REQUEST, side_effect=responses) as mock_request: + with patch(tcc_request, side_effect=responses) as mock_request: # try to bypass, but fails with pytest.raises(FailedToBypassZone): await hass.services.async_call( diff --git a/tests/components/totalconnect/test_config_flow.py b/tests/components/totalconnect/test_config_flow.py index f5020394bce..b7ac42c84b5 100644 --- a/tests/components/totalconnect/test_config_flow.py +++ b/tests/components/totalconnect/test_config_flow.py @@ -28,6 +28,7 @@ from .common import ( TOTALCONNECT_REQUEST, TOTALCONNECT_REQUEST_TOKEN, USERNAME, + init_integration, ) from tests.common import MockConfigEntry @@ -219,42 +220,19 @@ async def test_no_locations(hass: HomeAssistant) -> None: async def test_options_flow(hass: HomeAssistant) -> None: """Test config flow options.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - data=CONFIG_DATA, - unique_id=USERNAME, + config_entry = await init_integration(hass) + 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={AUTO_BYPASS: True, CODE_REQUIRED: False} ) - config_entry.add_to_hass(hass) - responses = [ - RESPONSE_SESSION_DETAILS, - RESPONSE_PARTITION_DETAILS, - RESPONSE_GET_ZONE_DETAILS_SUCCESS, - RESPONSE_DISARMED, - RESPONSE_DISARMED, - RESPONSE_DISARMED, - ] + assert result["type"] is FlowResultType.CREATE_ENTRY + assert config_entry.options == {AUTO_BYPASS: True, CODE_REQUIRED: False} + await hass.async_block_till_done() - with ( - patch(TOTALCONNECT_REQUEST, side_effect=responses), - patch(TOTALCONNECT_GET_CONFIG, side_effect=None), - patch(TOTALCONNECT_REQUEST_TOKEN, side_effect=None), - ): - 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={AUTO_BYPASS: True, CODE_REQUIRED: False} - ) - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert config_entry.options == {AUTO_BYPASS: True, CODE_REQUIRED: False} - await hass.async_block_till_done() - - assert await hass.config_entries.async_unload(config_entry.entry_id) - await hass.async_block_till_done() + assert await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() From 06019e7995edb0ac3e8743c5eb6d0fdb72f65cd2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 12 Mar 2025 00:59:36 -1000 Subject: [PATCH 2539/3148] Split timeout in lutron_caseta to increase configure timeout (#138875) --- .../components/lutron_caseta/__init__.py | 42 ++++++++----- .../components/lutron_caseta/config_flow.py | 5 +- .../components/lutron_caseta/const.py | 3 +- tests/components/lutron_caseta/__init__.py | 62 ++++++++++++++----- .../lutron_caseta/test_device_trigger.py | 15 +---- .../lutron_caseta/test_diagnostics.py | 11 +--- tests/components/lutron_caseta/test_init.py | 54 ++++++++++++++++ .../components/lutron_caseta/test_logbook.py | 21 ++----- 8 files changed, 143 insertions(+), 70 deletions(-) create mode 100644 tests/components/lutron_caseta/test_init.py diff --git a/homeassistant/components/lutron_caseta/__init__.py b/homeassistant/components/lutron_caseta/__init__.py index d697d6244b5..b489fe9dba7 100644 --- a/homeassistant/components/lutron_caseta/__init__.py +++ b/homeassistant/components/lutron_caseta/__init__.py @@ -3,7 +3,6 @@ from __future__ import annotations import asyncio -import contextlib from itertools import chain import logging import ssl @@ -37,11 +36,12 @@ from .const import ( ATTR_SERIAL, ATTR_TYPE, BRIDGE_DEVICE_ID, - BRIDGE_TIMEOUT, CONF_CA_CERTS, CONF_CERTFILE, CONF_KEYFILE, CONF_SUBTYPE, + CONFIGURE_TIMEOUT, + CONNECT_TIMEOUT, DOMAIN, LUTRON_CASETA_BUTTON_EVENT, MANUFACTURER, @@ -161,28 +161,40 @@ async def async_setup_entry( keyfile = hass.config.path(entry.data[CONF_KEYFILE]) certfile = hass.config.path(entry.data[CONF_CERTFILE]) ca_certs = hass.config.path(entry.data[CONF_CA_CERTS]) - bridge = None + connected_future: asyncio.Future[None] = hass.loop.create_future() + + def _on_connect() -> None: + nonlocal connected_future + if not connected_future.done(): + connected_future.set_result(None) try: bridge = Smartbridge.create_tls( - hostname=host, keyfile=keyfile, certfile=certfile, ca_certs=ca_certs + hostname=host, + keyfile=keyfile, + certfile=certfile, + ca_certs=ca_certs, + on_connect_callback=_on_connect, ) except ssl.SSLError: _LOGGER.error("Invalid certificate used to connect to bridge at %s", host) return False - timed_out = True - with contextlib.suppress(TimeoutError): - async with asyncio.timeout(BRIDGE_TIMEOUT): - await bridge.connect() - timed_out = False + connect_task = hass.async_create_task(bridge.connect()) + for future, name, timeout in ( + (connected_future, "connect", CONNECT_TIMEOUT), + (connect_task, "configure", CONFIGURE_TIMEOUT), + ): + try: + async with asyncio.timeout(timeout): + await future + except TimeoutError as ex: + connect_task.cancel() + await bridge.close() + raise ConfigEntryNotReady(f"Timed out on {name} for {host}") from ex - if timed_out or not bridge.is_connected(): - await bridge.close() - if timed_out: - raise ConfigEntryNotReady(f"Timed out while trying to connect to {host}") - if not bridge.is_connected(): - raise ConfigEntryNotReady(f"Cannot connect to {host}") + if not bridge.is_connected(): + raise ConfigEntryNotReady(f"Connection failed to {host}") _LOGGER.debug("Connected to Lutron Caseta bridge via LEAP at %s", host) await _async_migrate_unique_ids(hass, entry) diff --git a/homeassistant/components/lutron_caseta/config_flow.py b/homeassistant/components/lutron_caseta/config_flow.py index 767c3d2f2b7..45e7a04bdc9 100644 --- a/homeassistant/components/lutron_caseta/config_flow.py +++ b/homeassistant/components/lutron_caseta/config_flow.py @@ -20,10 +20,11 @@ from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import ( ABORT_REASON_CANNOT_CONNECT, BRIDGE_DEVICE_ID, - BRIDGE_TIMEOUT, CONF_CA_CERTS, CONF_CERTFILE, CONF_KEYFILE, + CONFIGURE_TIMEOUT, + CONNECT_TIMEOUT, DOMAIN, ERROR_CANNOT_CONNECT, STEP_IMPORT_FAILED, @@ -232,7 +233,7 @@ class LutronCasetaFlowHandler(ConfigFlow, domain=DOMAIN): return None try: - async with asyncio.timeout(BRIDGE_TIMEOUT): + async with asyncio.timeout(CONNECT_TIMEOUT + CONFIGURE_TIMEOUT): await bridge.connect() except TimeoutError: _LOGGER.error( diff --git a/homeassistant/components/lutron_caseta/const.py b/homeassistant/components/lutron_caseta/const.py index 809b9e8d007..26a83de6f4b 100644 --- a/homeassistant/components/lutron_caseta/const.py +++ b/homeassistant/components/lutron_caseta/const.py @@ -34,7 +34,8 @@ ACTION_RELEASE = "release" CONF_SUBTYPE = "subtype" -BRIDGE_TIMEOUT = 35 +CONNECT_TIMEOUT = 9 +CONFIGURE_TIMEOUT = 50 UNASSIGNED_AREA = "Unassigned" diff --git a/tests/components/lutron_caseta/__init__.py b/tests/components/lutron_caseta/__init__.py index b27d30ac31f..5f146cd988a 100644 --- a/tests/components/lutron_caseta/__init__.py +++ b/tests/components/lutron_caseta/__init__.py @@ -1,5 +1,8 @@ """Tests for the Lutron Caseta integration.""" +import asyncio +from collections.abc import Callable +from typing import Any from unittest.mock import patch from homeassistant.components.lutron_caseta import DOMAIN @@ -84,25 +87,12 @@ _LEAP_DEVICE_TYPES = { } -async def async_setup_integration(hass: HomeAssistant, mock_bridge) -> MockConfigEntry: - """Set up a mock bridge.""" - mock_entry = MockConfigEntry(domain=DOMAIN, data=ENTRY_MOCK_DATA) - mock_entry.add_to_hass(hass) - - with patch( - "homeassistant.components.lutron_caseta.Smartbridge.create_tls" - ) as create_tls: - create_tls.return_value = mock_bridge(can_connect=True) - await hass.config_entries.async_setup(mock_entry.entry_id) - await hass.async_block_till_done() - return mock_entry - - class MockBridge: """Mock Lutron bridge that emulates configured connected status.""" - def __init__(self, can_connect=True) -> None: + def __init__(self, can_connect=True, timeout_on_connect=False) -> None: """Initialize MockBridge instance with configured mock connectivity.""" + self.timeout_on_connect = timeout_on_connect self.can_connect = can_connect self.is_currently_connected = False self.areas = self.load_areas() @@ -113,6 +103,8 @@ class MockBridge: async def connect(self): """Connect the mock bridge.""" + if self.timeout_on_connect: + await asyncio.Event().wait() # wait forever if self.can_connect: self.is_currently_connected = True @@ -320,3 +312,43 @@ class MockBridge: async def close(self): """Close the mock bridge connection.""" self.is_currently_connected = False + + +def make_mock_entry() -> MockConfigEntry: + """Create a mock config entry.""" + return MockConfigEntry(domain=DOMAIN, data=ENTRY_MOCK_DATA) + + +async def async_setup_integration( + hass: HomeAssistant, + mock_bridge: MockBridge, + config_entry_id: str | None = None, + can_connect: bool = True, + timeout_during_connect: bool = False, + timeout_during_configure: bool = False, +) -> MockConfigEntry: + """Set up a mock bridge.""" + if config_entry_id is None: + mock_entry = make_mock_entry() + mock_entry.add_to_hass(hass) + config_entry_id = mock_entry.entry_id + else: + mock_entry = hass.config_entries.async_get_entry(config_entry_id) + + def create_tls_factory( + *args: Any, on_connect_callback: Callable[[], None], **kwargs: Any + ) -> None: + """Return a mock bridge.""" + if not timeout_during_connect: + on_connect_callback() + return mock_bridge( + can_connect=can_connect, timeout_on_connect=timeout_during_configure + ) + + with patch( + "homeassistant.components.lutron_caseta.Smartbridge.create_tls", + create_tls_factory, + ): + await hass.config_entries.async_setup(config_entry_id) + await hass.async_block_till_done() + return mock_entry diff --git a/tests/components/lutron_caseta/test_device_trigger.py b/tests/components/lutron_caseta/test_device_trigger.py index 1ab45bf7582..001bf86ad54 100644 --- a/tests/components/lutron_caseta/test_device_trigger.py +++ b/tests/components/lutron_caseta/test_device_trigger.py @@ -1,7 +1,5 @@ """The tests for Lutron Caséta device triggers.""" -from unittest.mock import patch - import pytest from pytest_unordered import unordered @@ -37,7 +35,7 @@ from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component -from . import MockBridge +from . import MockBridge, async_setup_integration from tests.common import MockConfigEntry, async_get_device_automations @@ -112,12 +110,7 @@ async def _async_setup_lutron_with_picos(hass: HomeAssistant) -> str: ) config_entry.add_to_hass(hass) - with patch( - "homeassistant.components.lutron_caseta.Smartbridge.create_tls", - return_value=MockBridge(can_connect=True), - ): - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() + await async_setup_integration(hass, MockBridge, config_entry.entry_id) return config_entry.entry_id @@ -487,9 +480,7 @@ async def test_if_fires_on_button_event_late_setup( }, ) - with patch("homeassistant.components.lutron_caseta.Smartbridge.create_tls"): - await hass.config_entries.async_setup(config_entry_id) - await hass.async_block_till_done() + await async_setup_integration(hass, MockBridge, config_entry_id) message = { ATTR_SERIAL: device.get("serial"), diff --git a/tests/components/lutron_caseta/test_diagnostics.py b/tests/components/lutron_caseta/test_diagnostics.py index 5c7d20da208..45229918578 100644 --- a/tests/components/lutron_caseta/test_diagnostics.py +++ b/tests/components/lutron_caseta/test_diagnostics.py @@ -1,6 +1,6 @@ """Test the Lutron Caseta diagnostics.""" -from unittest.mock import ANY, patch +from unittest.mock import ANY from homeassistant.components.lutron_caseta import DOMAIN from homeassistant.components.lutron_caseta.const import ( @@ -11,7 +11,7 @@ from homeassistant.components.lutron_caseta.const import ( from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant -from . import MockBridge +from . import MockBridge, async_setup_integration from tests.common import MockConfigEntry from tests.components.diagnostics import get_diagnostics_for_config_entry @@ -34,12 +34,7 @@ async def test_diagnostics( ) config_entry.add_to_hass(hass) - with patch( - "homeassistant.components.lutron_caseta.Smartbridge.create_tls", - return_value=MockBridge(can_connect=True), - ): - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() + await async_setup_integration(hass, MockBridge, config_entry.entry_id) diag = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) assert diag == { diff --git a/tests/components/lutron_caseta/test_init.py b/tests/components/lutron_caseta/test_init.py new file mode 100644 index 00000000000..7e509acbf62 --- /dev/null +++ b/tests/components/lutron_caseta/test_init.py @@ -0,0 +1,54 @@ +"""Tests for the Lutron Caseta integration.""" + +from unittest.mock import patch + +import pytest + +from homeassistant.components import lutron_caseta +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from . import MockBridge, async_setup_integration, make_mock_entry + + +@pytest.mark.parametrize( + ("constant", "message", "timeout_during_connect", "timeout_during_configure"), + [ + ("CONNECT_TIMEOUT", "Timed out on connect", True, False), + ("CONFIGURE_TIMEOUT", "Timed out on configure", False, True), + ], +) +async def test_timeout_during_setup( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + constant: str, + message: str, + timeout_during_connect: bool, + timeout_during_configure: bool, +) -> None: + """Test a timeout during setup.""" + mock_entry = make_mock_entry() + mock_entry.add_to_hass(hass) + with patch.object(lutron_caseta, constant, 0.001): + await async_setup_integration( + hass, + MockBridge, + config_entry_id=mock_entry.entry_id, + timeout_during_connect=timeout_during_connect, + timeout_during_configure=timeout_during_configure, + ) + assert mock_entry.state is ConfigEntryState.SETUP_RETRY + assert f"{message} for 1.1.1.1" in caplog.text + + +async def test_cannot_connect( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test failing to connect.""" + mock_entry = make_mock_entry() + mock_entry.add_to_hass(hass) + await async_setup_integration( + hass, MockBridge, config_entry_id=mock_entry.entry_id, can_connect=False + ) + assert mock_entry.state is ConfigEntryState.SETUP_RETRY + assert "Connection failed to 1.1.1.1" in caplog.text diff --git a/tests/components/lutron_caseta/test_logbook.py b/tests/components/lutron_caseta/test_logbook.py index 9a58838d65c..8b4a3e00fa9 100644 --- a/tests/components/lutron_caseta/test_logbook.py +++ b/tests/components/lutron_caseta/test_logbook.py @@ -1,7 +1,5 @@ """The tests for lutron caseta logbook.""" -from unittest.mock import patch - from homeassistant.components.lutron_caseta.const import ( ATTR_ACTION, ATTR_AREA_NAME, @@ -43,13 +41,7 @@ async def test_humanify_lutron_caseta_button_event(hass: HomeAssistant) -> None: unique_id="abc", ) config_entry.add_to_hass(hass) - - with patch( - "homeassistant.components.lutron_caseta.Smartbridge.create_tls", - return_value=MockBridge(can_connect=True), - ): - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() + await async_setup_integration(hass, MockBridge, config_entry.entry_id) await hass.async_block_till_done() @@ -104,15 +96,10 @@ async def test_humanify_lutron_caseta_button_event_integration_not_loaded( ) config_entry.add_to_hass(hass) - with patch( - "homeassistant.components.lutron_caseta.Smartbridge.create_tls", - return_value=MockBridge(can_connect=True), - ): - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() + await async_setup_integration(hass, MockBridge, config_entry.entry_id) - await hass.config_entries.async_unload(config_entry.entry_id) - await hass.async_block_till_done() + await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() for device in device_registry.devices.values(): if device.config_entries == {config_entry.entry_id}: From d3376f31d0382c80e468edd1ac23c9230dcd5c2d Mon Sep 17 00:00:00 2001 From: dontinelli <73341522+dontinelli@users.noreply.github.com> Date: Wed, 12 Mar 2025 15:29:43 +0100 Subject: [PATCH 2540/3148] Bump fyta_cli to 0.7.1 (#140452) --- homeassistant/components/fyta/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../fyta/fixtures/plant_status1.json | 20 ++++++++++- .../fyta/fixtures/plant_status1_update.json | 20 ++++++++++- .../fyta/fixtures/plant_status2.json | 20 ++++++++++- .../fyta/fixtures/plant_status3.json | 20 ++++++++++- .../fyta/snapshots/test_diagnostics.ambr | 36 +++++++++++++++++++ 8 files changed, 115 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/fyta/manifest.json b/homeassistant/components/fyta/manifest.json index ea628f55c6c..1c91807b711 100644 --- a/homeassistant/components/fyta/manifest.json +++ b/homeassistant/components/fyta/manifest.json @@ -9,5 +9,5 @@ "iot_class": "cloud_polling", "loggers": ["fyta_cli"], "quality_scale": "platinum", - "requirements": ["fyta_cli==0.7.0"] + "requirements": ["fyta_cli==0.7.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 6830c3880e3..f7183090743 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -968,7 +968,7 @@ freesms==0.2.0 fritzconnection[qr]==1.14.0 # homeassistant.components.fyta -fyta_cli==0.7.0 +fyta_cli==0.7.1 # homeassistant.components.google_translate gTTS==2.5.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 29425a177c5..3023294a095 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -821,7 +821,7 @@ freebox-api==1.2.2 fritzconnection[qr]==1.14.0 # homeassistant.components.fyta -fyta_cli==0.7.0 +fyta_cli==0.7.1 # homeassistant.components.google_translate gTTS==2.5.3 diff --git a/tests/components/fyta/fixtures/plant_status1.json b/tests/components/fyta/fixtures/plant_status1.json index 21e1fcfb0ab..91157c57c3a 100644 --- a/tests/components/fyta/fixtures/plant_status1.json +++ b/tests/components/fyta/fixtures/plant_status1.json @@ -6,10 +6,18 @@ "low_battery": false, "last_updated": "2023-01-10 10:10:00", "light": 2, + "light_min_good": "20", + "light_max_good": "450", + "light_min_acceptable": "18", + "light_max_acceptable": "675", "light_status": 3, "nickname": "Gummibaum", "nutrients_status": 3, "moisture": 61, + "moisture_min_good": "35", + "moisture_max_good": "70", + "moisture_min_acceptable": "25", + "moisture_max_acceptable": "80", "moisture_status": 3, "sensor_available": true, "sensor_id": "FD:1D:B7:E3:D0:E2", @@ -17,14 +25,24 @@ "sw_version": "1.0", "status": 1, "online": true, + "origin_path": "http://www.plant_picture.com/user_picture", "ph": null, "plant_id": 0, "plant_origin_path": "http://www.plant_picture.com/picture", "plant_thumb_path": "http://www.plant_picture.com/picture_thumb", "is_productive_plant": false, "salinity": 1, + "salinity_min_good": "0.6", + "salinity_max_good": "1", + "salinity_min_acceptable": "0.4", + "salinity_max_acceptable": "1.2", "salinity_status": 4, "scientific_name": "Ficus elastica", "temperature": 25.2, - "temperature_status": 3 + "temperature_min_good": "17", + "temperature_max_good": "36", + "temperature_min_acceptable": "10", + "temperature_max_acceptable": "42", + "temperature_status": 3, + "thumb_path": "http://www.plant_picture.com/user_picture_thumb" } diff --git a/tests/components/fyta/fixtures/plant_status1_update.json b/tests/components/fyta/fixtures/plant_status1_update.json index 98a4c6a9d91..5363c5bd290 100644 --- a/tests/components/fyta/fixtures/plant_status1_update.json +++ b/tests/components/fyta/fixtures/plant_status1_update.json @@ -6,10 +6,18 @@ "low_battery": false, "last_updated": "2023-01-10 10:10:00", "light": 2, + "light_min_good": "20", + "light_max_good": "450", + "light_min_acceptable": "18", + "light_max_acceptable": "675", "light_status": 3, "nickname": "Gummibaum", "nutrients_status": 3, "moisture": 61, + "moisture_min_good": "35", + "moisture_max_good": "70", + "moisture_min_acceptable": "25", + "moisture_max_acceptable": "80", "moisture_status": 3, "sensor_available": true, "sensor_id": "FD:1D:B7:E3:D0:E2", @@ -17,14 +25,24 @@ "sw_version": "1.0", "status": 1, "online": true, + "origin_path": "http://www.plant_picture.com/user_picture", "ph": null, "plant_id": 0, "plant_origin_path": "http://www.plant_picture.com/picture1", "plant_thumb_path": "http://www.plant_picture.com/picture_thumb", "is_productive_plant": false, "salinity": 1, + "salinity_min_good": "0.6", + "salinity_max_good": "1", + "salinity_min_acceptable": "0.4", + "salinity_max_acceptable": "1.2", "salinity_status": 4, "scientific_name": "Ficus elastica", "temperature": 25.2, - "temperature_status": 3 + "temperature_min_good": "17", + "temperature_max_good": "36", + "temperature_min_acceptable": "10", + "temperature_max_acceptable": "42", + "temperature_status": 3, + "thumb_path": "http://www.plant_picture.com/user_picture_thumb" } diff --git a/tests/components/fyta/fixtures/plant_status2.json b/tests/components/fyta/fixtures/plant_status2.json index bf90ab1e50d..5a181bee576 100644 --- a/tests/components/fyta/fixtures/plant_status2.json +++ b/tests/components/fyta/fixtures/plant_status2.json @@ -6,10 +6,18 @@ "low_battery": true, "last_updated": "2023-01-02 10:10:00", "light": 2, + "light_min_good": "20", + "light_max_good": "450", + "light_min_acceptable": "18", + "light_max_acceptable": "675", "light_status": 3, "nickname": "Kakaobaum", "nutrients_status": 3, "moisture": 61, + "moisture_min_good": "35", + "moisture_max_good": "70", + "moisture_min_acceptable": "25", + "moisture_max_acceptable": "80", "moisture_status": 3, "sensor_available": true, "sensor_id": "FD:1D:B7:E3:D0:E3", @@ -17,14 +25,24 @@ "sw_version": "1.0", "status": 1, "online": true, + "origin_path": "http://www.plant_picture.com/user_picture", "ph": 7, "plant_id": 0, "plant_origin_path": "", "plant_thumb_path": "", "is_productive_plant": false, "salinity": 1, + "salinity_min_good": "0.6", + "salinity_max_good": "1", + "salinity_min_acceptable": "0.4", + "salinity_max_acceptable": "1.2", "salinity_status": 4, "scientific_name": "Theobroma cacao", "temperature": 25.2, - "temperature_status": 3 + "temperature_min_good": "17", + "temperature_max_good": "36", + "temperature_min_acceptable": "10", + "temperature_max_acceptable": "42", + "temperature_status": 3, + "thumb_path": "http://www.plant_picture.com/user_picture_thumb" } diff --git a/tests/components/fyta/fixtures/plant_status3.json b/tests/components/fyta/fixtures/plant_status3.json index 4bb4e0b81a7..ad34e01065e 100644 --- a/tests/components/fyta/fixtures/plant_status3.json +++ b/tests/components/fyta/fixtures/plant_status3.json @@ -6,10 +6,18 @@ "low_battery": true, "last_updated": "2023-01-02 10:10:00", "light": 2, + "light_min_good": "20", + "light_max_good": "450", + "light_min_acceptable": "18", + "light_max_acceptable": "675", "light_status": 3, "nickname": "Tomatenpflanze", "nutrients_status": 0, "moisture": 61, + "moisture_min_good": "35", + "moisture_max_good": "70", + "moisture_min_acceptable": "25", + "moisture_max_acceptable": "80", "moisture_status": 3, "sensor_available": true, "sensor_id": "FD:1D:B7:E3:D0:E3", @@ -17,14 +25,24 @@ "sw_version": "1.0", "status": 1, "online": true, + "origin_path": "http://www.plant_picture.com/user_picture", "ph": 7, "plant_id": 0, "plant_origin_path": "http://www.plant_picture.com/picture", "plant_thumb_path": "http://www.plant_picture.com/picture_thumb", "is_productive_plant": true, "salinity": 1, + "salinity_min_good": "0.6", + "salinity_max_good": "1", + "salinity_min_acceptable": "0.4", + "salinity_max_acceptable": "1.2", "salinity_status": 4, "scientific_name": "Solanum lycopersicum", "temperature": 25.2, - "temperature_status": 3 + "temperature_min_good": "17", + "temperature_max_good": "36", + "temperature_min_acceptable": "10", + "temperature_max_acceptable": "42", + "temperature_status": 3, + "thumb_path": "http://www.plant_picture.com/user_picture_thumb" } diff --git a/tests/components/fyta/snapshots/test_diagnostics.ambr b/tests/components/fyta/snapshots/test_diagnostics.ambr index 24206fbb875..7bc6a6f7b5a 100644 --- a/tests/components/fyta/snapshots/test_diagnostics.ambr +++ b/tests/components/fyta/snapshots/test_diagnostics.ambr @@ -32,9 +32,17 @@ 'fertilise_next': None, 'last_updated': '2023-01-10T10:10:00', 'light': 2.0, + 'light_max_acceptable': 675.0, + 'light_max_good': 450.0, + 'light_min_acceptable': 18.0, + 'light_min_good': 20.0, 'light_status': 3, 'low_battery': False, 'moisture': 61.0, + 'moisture_max_acceptable': 80.0, + 'moisture_max_good': 70.0, + 'moisture_min_acceptable': 25.0, + 'moisture_min_good': 35.0, 'moisture_status': 3, 'name': 'Gummibaum', 'notification_light': False, @@ -50,6 +58,10 @@ 'productive_plant': False, 'repotted': True, 'salinity': 1.0, + 'salinity_max_acceptable': 1.2, + 'salinity_max_good': 1.0, + 'salinity_min_acceptable': 0.4, + 'salinity_min_good': 0.6, 'salinity_status': 4, 'scientific_name': 'Ficus elastica', 'sensor_available': True, @@ -59,7 +71,13 @@ 'status': 1, 'sw_version': '1.0', 'temperature': 25.2, + 'temperature_max_acceptable': 42.0, + 'temperature_max_good': 36.0, + 'temperature_min_acceptable': 10.0, + 'temperature_min_good': 17.0, 'temperature_status': 3, + 'user_picture_path': 'http://www.plant_picture.com/user_picture', + 'user_thumb_path': 'http://www.plant_picture.com/user_picture_thumb', }), '1': dict({ 'battery_level': 80.0, @@ -67,9 +85,17 @@ 'fertilise_next': None, 'last_updated': '2023-01-02T10:10:00', 'light': 2.0, + 'light_max_acceptable': 675.0, + 'light_max_good': 450.0, + 'light_min_acceptable': 18.0, + 'light_min_good': 20.0, 'light_status': 3, 'low_battery': True, 'moisture': 61.0, + 'moisture_max_acceptable': 80.0, + 'moisture_max_good': 70.0, + 'moisture_min_acceptable': 25.0, + 'moisture_min_good': 35.0, 'moisture_status': 3, 'name': 'Kakaobaum', 'notification_light': False, @@ -85,6 +111,10 @@ 'productive_plant': False, 'repotted': True, 'salinity': 1.0, + 'salinity_max_acceptable': 1.2, + 'salinity_max_good': 1.0, + 'salinity_min_acceptable': 0.4, + 'salinity_min_good': 0.6, 'salinity_status': 4, 'scientific_name': 'Theobroma cacao', 'sensor_available': True, @@ -94,7 +124,13 @@ 'status': 1, 'sw_version': '1.0', 'temperature': 25.2, + 'temperature_max_acceptable': 42.0, + 'temperature_max_good': 36.0, + 'temperature_min_acceptable': 10.0, + 'temperature_min_good': 17.0, 'temperature_status': 3, + 'user_picture_path': 'http://www.plant_picture.com/user_picture', + 'user_thumb_path': 'http://www.plant_picture.com/user_picture_thumb', }), }), }) From 70c355b52e55d4881f2a198cf014366a7014282b Mon Sep 17 00:00:00 2001 From: Maikel Punie Date: Wed, 12 Mar 2025 16:30:01 +0100 Subject: [PATCH 2541/3148] Bump velbusaio to 2025.3.1 (#140443) --- homeassistant/components/velbus/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/velbus/manifest.json b/homeassistant/components/velbus/manifest.json index ff30ee14a8a..1cb540b22ec 100644 --- a/homeassistant/components/velbus/manifest.json +++ b/homeassistant/components/velbus/manifest.json @@ -14,7 +14,7 @@ "velbus-protocol" ], "quality_scale": "bronze", - "requirements": ["velbus-aio==2025.3.0"], + "requirements": ["velbus-aio==2025.3.1"], "usb": [ { "vid": "10CF", diff --git a/requirements_all.txt b/requirements_all.txt index f7183090743..0c057e8f537 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3000,7 +3000,7 @@ vallox-websocket-api==5.3.0 vehicle==2.2.2 # homeassistant.components.velbus -velbus-aio==2025.3.0 +velbus-aio==2025.3.1 # homeassistant.components.venstar venstarcolortouch==0.19 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3023294a095..e2dca4383ed 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2416,7 +2416,7 @@ vallox-websocket-api==5.3.0 vehicle==2.2.2 # homeassistant.components.velbus -velbus-aio==2025.3.0 +velbus-aio==2025.3.1 # homeassistant.components.venstar venstarcolortouch==0.19 From 892b78a1f9ebe18c3ffc38c4f5b879fe1b1aae33 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Wed, 12 Mar 2025 17:12:27 +0100 Subject: [PATCH 2542/3148] Add exceptions translation for Vodafone Station (#140410) --- .../vodafone_station/coordinator.py | 6 +- .../components/vodafone_station/strings.json | 65 ++++++++++++++----- 2 files changed, 55 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/vodafone_station/coordinator.py b/homeassistant/components/vodafone_station/coordinator.py index b7986d06c25..424abc4fafd 100644 --- a/homeassistant/components/vodafone_station/coordinator.py +++ b/homeassistant/components/vodafone_station/coordinator.py @@ -127,7 +127,11 @@ class VodafoneStationRouter(DataUpdateCoordinator[UpdateCoordinatorDataType]): exceptions.GenericLoginError, JSONDecodeError, ) as err: - raise UpdateFailed(f"Error fetching data: {err!r}") from err + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_failed", + translation_placeholders={"error": repr(err)}, + ) from err except (ConfigEntryAuthFailed, UpdateFailed): await self.api.close() raise diff --git a/homeassistant/components/vodafone_station/strings.json b/homeassistant/components/vodafone_station/strings.json index 8910d7178b7..dd847df4d6b 100644 --- a/homeassistant/components/vodafone_station/strings.json +++ b/homeassistant/components/vodafone_station/strings.json @@ -47,14 +47,26 @@ }, "entity": { "button": { - "dsl_reconnect": { "name": "DSL reconnect" }, - "fiber_reconnect": { "name": "Fiber reconnect" }, - "internet_key_reconnect": { "name": "Internet key reconnect" } + "dsl_reconnect": { + "name": "DSL reconnect" + }, + "fiber_reconnect": { + "name": "Fiber reconnect" + }, + "internet_key_reconnect": { + "name": "Internet key reconnect" + } }, "sensor": { - "external_ipv4": { "name": "WAN IPv4 address" }, - "external_ipv6": { "name": "WAN IPv6 address" }, - "external_ip_key": { "name": "WAN internet key address" }, + "external_ipv4": { + "name": "WAN IPv4 address" + }, + "external_ipv6": { + "name": "WAN IPv6 address" + }, + "external_ip_key": { + "name": "WAN internet key address" + }, "active_connection": { "name": "Active connection", "state": { @@ -64,15 +76,38 @@ "internet_key": "Internet key" } }, - "down_stream": { "name": "WAN download rate" }, - "up_stream": { "name": "WAN upload rate" }, - "fw_version": { "name": "Firmware version" }, - "phone_num1": { "name": "Phone number (1)" }, - "phone_num2": { "name": "Phone number (2)" }, - "sys_uptime": { "name": "Uptime" }, - "sys_cpu_usage": { "name": "CPU usage" }, - "sys_memory_usage": { "name": "Memory usage" }, - "sys_reboot_cause": { "name": "Reboot cause" } + "down_stream": { + "name": "WAN download rate" + }, + "up_stream": { + "name": "WAN upload rate" + }, + "fw_version": { + "name": "Firmware version" + }, + "phone_num1": { + "name": "Phone number (1)" + }, + "phone_num2": { + "name": "Phone number (2)" + }, + "sys_uptime": { + "name": "Uptime" + }, + "sys_cpu_usage": { + "name": "CPU usage" + }, + "sys_memory_usage": { + "name": "Memory usage" + }, + "sys_reboot_cause": { + "name": "Reboot cause" + } + } + }, + "exceptions": { + "update_failed": { + "message": "Error fetching data: {error}" } } } From bad109dec5afa1101c18ca42e15038dde51fdf2f Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 12 Mar 2025 19:07:41 +0100 Subject: [PATCH 2543/3148] Mark value in number.set_value action as required (#140445) --- homeassistant/components/number/services.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/number/services.yaml b/homeassistant/components/number/services.yaml index dcbb955d739..6a7083a7613 100644 --- a/homeassistant/components/number/services.yaml +++ b/homeassistant/components/number/services.yaml @@ -7,5 +7,6 @@ set_value: fields: value: example: 42 + required: true selector: text: From 1f6658fca0ccfdd333c8bf62712e21bbe1560057 Mon Sep 17 00:00:00 2001 From: Pete Sage <76050312+PeteRager@users.noreply.github.com> Date: Wed, 12 Mar 2025 15:19:09 -0400 Subject: [PATCH 2544/3148] Prevent ipv6 discovery messages for Sonos (#139648) --- homeassistant/components/sonos/__init__.py | 9 ++++++ homeassistant/components/sonos/config_flow.py | 2 ++ homeassistant/components/sonos/strings.json | 3 +- tests/components/sonos/test_config_flow.py | 16 ++++++++++ tests/components/sonos/test_init.py | 29 +++++++++++++++++++ 5 files changed, 58 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/sonos/__init__.py b/homeassistant/components/sonos/__init__.py index d530fa21e39..24580971ae2 100644 --- a/homeassistant/components/sonos/__init__.py +++ b/homeassistant/components/sonos/__init__.py @@ -7,6 +7,7 @@ from collections import OrderedDict from dataclasses import dataclass, field import datetime from functools import partial +from ipaddress import AddressValueError, IPv4Address import logging import socket from typing import Any, cast @@ -208,6 +209,14 @@ class SonosDiscoveryManager: async def async_subscribe_to_zone_updates(self, ip_address: str) -> None: """Test subscriptions and create SonosSpeakers based on results.""" + try: + _ = IPv4Address(ip_address) + except AddressValueError: + _LOGGER.debug( + "Sonos integration only supports IPv4 addresses, invalid ip_address received: %s", + ip_address, + ) + return soco = SoCo(ip_address) # Cache now to avoid household ID lookup during first ZoneGroupState processing await self.hass.async_add_executor_job( diff --git a/homeassistant/components/sonos/config_flow.py b/homeassistant/components/sonos/config_flow.py index 057cdb8ec08..b5e2c684281 100644 --- a/homeassistant/components/sonos/config_flow.py +++ b/homeassistant/components/sonos/config_flow.py @@ -31,6 +31,8 @@ class SonosDiscoveryFlowHandler(DiscoveryFlowHandler[Awaitable[bool]], domain=DO hostname = discovery_info.hostname if hostname is None or not hostname.lower().startswith("sonos"): return self.async_abort(reason="not_sonos_device") + if discovery_info.ip_address.version != 4: + return self.async_abort(reason="not_ipv4_address") if discovery_manager := self.hass.data.get(DATA_SONOS_DISCOVERY_MANAGER): host = discovery_info.host mdns_name = discovery_info.name diff --git a/homeassistant/components/sonos/strings.json b/homeassistant/components/sonos/strings.json index 07d2e2db4e0..433bb3cc36a 100644 --- a/homeassistant/components/sonos/strings.json +++ b/homeassistant/components/sonos/strings.json @@ -8,7 +8,8 @@ "abort": { "not_sonos_device": "Discovered device is not a Sonos device", "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]", - "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]" + "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]", + "not_ipv4_address": "No IPv4 address in SSDP discovery information" } }, "issues": { diff --git a/tests/components/sonos/test_config_flow.py b/tests/components/sonos/test_config_flow.py index 70605092da1..8454b4ad673 100644 --- a/tests/components/sonos/test_config_flow.py +++ b/tests/components/sonos/test_config_flow.py @@ -123,6 +123,22 @@ async def test_zeroconf_form( assert len(mock_manager.mock_calls) == 2 +async def test_zeroconf_form_not_ipv4( + hass: HomeAssistant, zeroconf_payload: ZeroconfServiceInfo +) -> None: + """Test we pass Zeroconf discoveries to the manager.""" + mock_manager = hass.data[DATA_SONOS_DISCOVERY_MANAGER] = MagicMock() + zeroconf_payload.ip_address = ip_address("2001:db8:3333:4444:5555:6666:7777:8888") + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=zeroconf_payload, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "not_ipv4_address" + assert mock_manager.call_count == 0 + + async def test_ssdp_discovery(hass: HomeAssistant, soco) -> None: """Test that SSDP discoveries create a config flow.""" diff --git a/tests/components/sonos/test_init.py b/tests/components/sonos/test_init.py index a7ad2f4cb82..c6be606eb20 100644 --- a/tests/components/sonos/test_init.py +++ b/tests/components/sonos/test_init.py @@ -455,3 +455,32 @@ async def test_async_poll_manual_hosts_8( assert "media_player.garage" in entity_registry.entities assert "media_player.studio" in entity_registry.entities await hass.async_block_till_done(wait_background_tasks=True) + + +async def _setup_hass_ipv6_address_not_supported(hass: HomeAssistant): + await async_setup_component( + hass, + sonos.DOMAIN, + { + "sonos": { + "media_player": { + "interface_addr": "127.0.0.1", + "hosts": ["2001:db8:3333:4444:5555:6666:7777:8888"], + } + } + }, + ) + await hass.async_block_till_done() + + +async def test_ipv6_not_supported( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Tests that invalid ipv4 addresses do not generate stack dump.""" + with caplog.at_level(logging.DEBUG): + caplog.clear() + await _setup_hass_ipv6_address_not_supported(hass) + await hass.async_block_till_done() + assert "invalid ip_address received" in caplog.text + assert "2001:db8:3333:4444:5555:6666:7777:8888" in caplog.text From e78dc486f7d6944dc56e513f2980ca71022bbcf1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 12 Mar 2025 13:09:41 -1000 Subject: [PATCH 2545/3148] Bump SQLAlchemy to 2.0.39 (#140473) * Bump SQLAlchemy to 2.0.39 changelog: https://docs.sqlalchemy.org/en/20/changelog/changelog_20.html#change-2.0.39 * fix typing --- homeassistant/components/recorder/db_schema.py | 4 ++-- homeassistant/components/recorder/manifest.json | 2 +- homeassistant/components/recorder/migration.py | 17 +++++++++++------ homeassistant/components/sql/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 9 files changed, 20 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/recorder/db_schema.py b/homeassistant/components/recorder/db_schema.py index d1a2405406e..bc8fcd1310e 100644 --- a/homeassistant/components/recorder/db_schema.py +++ b/homeassistant/components/recorder/db_schema.py @@ -203,11 +203,11 @@ UINT_32_TYPE = BigInteger().with_variant( "mariadb", ) JSON_VARIANT_CAST = Text().with_variant( - postgresql.JSON(none_as_null=True), # type: ignore[no-untyped-call] + postgresql.JSON(none_as_null=True), "postgresql", ) JSONB_VARIANT_CAST = Text().with_variant( - postgresql.JSONB(none_as_null=True), # type: ignore[no-untyped-call] + postgresql.JSONB(none_as_null=True), "postgresql", ) DATETIME_TYPE = ( diff --git a/homeassistant/components/recorder/manifest.json b/homeassistant/components/recorder/manifest.json index 3ba36ab86c0..f5336e2a85b 100644 --- a/homeassistant/components/recorder/manifest.json +++ b/homeassistant/components/recorder/manifest.json @@ -7,7 +7,7 @@ "iot_class": "local_push", "quality_scale": "internal", "requirements": [ - "SQLAlchemy==2.0.38", + "SQLAlchemy==2.0.39", "fnv-hash-fast==1.4.0", "psutil-home-assistant==0.0.1" ] diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index 3aa12f2b1f9..c5eea0f7088 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -9,7 +9,7 @@ from dataclasses import dataclass, replace as dataclass_replace from datetime import timedelta import logging from time import time -from typing import TYPE_CHECKING, Any, cast, final +from typing import TYPE_CHECKING, Any, TypedDict, cast, final from uuid import UUID import sqlalchemy @@ -712,6 +712,11 @@ def _modify_columns( raise +class _FKAlterDict(TypedDict): + old_fk: ForeignKeyConstraint + columns: list[str] + + def _update_states_table_with_foreign_key_options( session_maker: Callable[[], Session], engine: Engine ) -> None: @@ -729,7 +734,7 @@ def _update_states_table_with_foreign_key_options( inspector = sqlalchemy.inspect(engine) tmp_states_table = Table(TABLE_STATES, MetaData()) - alters = [ + alters: list[_FKAlterDict] = [ { "old_fk": ForeignKeyConstraint( (), (), name=foreign_key["name"], table=tmp_states_table @@ -755,14 +760,14 @@ def _update_states_table_with_foreign_key_options( with session_scope(session=session_maker()) as session: try: connection = session.connection() - connection.execute(DropConstraint(alter["old_fk"])) # type: ignore[no-untyped-call] + connection.execute(DropConstraint(alter["old_fk"])) for fkc in states_key_constraints: if fkc.column_keys == alter["columns"]: # AddConstraint mutates the constraint passed to it, we need to # undo that to avoid changing the behavior of the table schema. # https://github.com/sqlalchemy/sqlalchemy/blob/96f1172812f858fead45cdc7874abac76f45b339/lib/sqlalchemy/sql/ddl.py#L746-L748 create_rule = fkc._create_rule # noqa: SLF001 - add_constraint = AddConstraint(fkc) # type: ignore[no-untyped-call] + add_constraint = AddConstraint(fkc) fkc._create_rule = create_rule # noqa: SLF001 connection.execute(add_constraint) except (InternalError, OperationalError): @@ -800,7 +805,7 @@ def _drop_foreign_key_constraints( with session_scope(session=session_maker()) as session: try: connection = session.connection() - connection.execute(DropConstraint(drop)) # type: ignore[no-untyped-call] + connection.execute(DropConstraint(drop)) except (InternalError, OperationalError): _LOGGER.exception( "Could not drop foreign constraints in %s table on %s", @@ -845,7 +850,7 @@ def _restore_foreign_key_constraints( # undo that to avoid changing the behavior of the table schema. # https://github.com/sqlalchemy/sqlalchemy/blob/96f1172812f858fead45cdc7874abac76f45b339/lib/sqlalchemy/sql/ddl.py#L746-L748 create_rule = constraint._create_rule # noqa: SLF001 - add_constraint = AddConstraint(constraint) # type: ignore[no-untyped-call] + add_constraint = AddConstraint(constraint) constraint._create_rule = create_rule # noqa: SLF001 try: _add_constraint(session_maker, add_constraint, table, column) diff --git a/homeassistant/components/sql/manifest.json b/homeassistant/components/sql/manifest.json index 2b00a5b0d65..37b5dc2b647 100644 --- a/homeassistant/components/sql/manifest.json +++ b/homeassistant/components/sql/manifest.json @@ -6,5 +6,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/sql", "iot_class": "local_polling", - "requirements": ["SQLAlchemy==2.0.38", "sqlparse==0.5.0"] + "requirements": ["SQLAlchemy==2.0.39", "sqlparse==0.5.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index d9c761e6341..24ce6e23e86 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -61,7 +61,7 @@ PyTurboJPEG==1.7.5 PyYAML==6.0.2 requests==2.32.3 securetar==2025.2.1 -SQLAlchemy==2.0.38 +SQLAlchemy==2.0.39 standard-aifc==3.13.0 standard-telnetlib==3.13.0 typing-extensions>=4.12.2,<5.0 diff --git a/pyproject.toml b/pyproject.toml index 09c14cbde69..8e3fe4e25a7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -67,7 +67,7 @@ dependencies = [ "PyYAML==6.0.2", "requests==2.32.3", "securetar==2025.2.1", - "SQLAlchemy==2.0.38", + "SQLAlchemy==2.0.39", "standard-aifc==3.13.0", "standard-telnetlib==3.13.0", "typing-extensions>=4.12.2,<5.0", diff --git a/requirements.txt b/requirements.txt index 6ae428d5420..13c58f6cd71 100644 --- a/requirements.txt +++ b/requirements.txt @@ -39,7 +39,7 @@ python-slugify==8.0.4 PyYAML==6.0.2 requests==2.32.3 securetar==2025.2.1 -SQLAlchemy==2.0.38 +SQLAlchemy==2.0.39 standard-aifc==3.13.0 standard-telnetlib==3.13.0 typing-extensions>=4.12.2,<5.0 diff --git a/requirements_all.txt b/requirements_all.txt index 0c057e8f537..b40ab7110c4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -116,7 +116,7 @@ RtmAPI==0.7.2 # homeassistant.components.recorder # homeassistant.components.sql -SQLAlchemy==2.0.38 +SQLAlchemy==2.0.39 # homeassistant.components.tami4 Tami4EdgeAPI==3.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e2dca4383ed..eef5fc03173 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -110,7 +110,7 @@ RtmAPI==0.7.2 # homeassistant.components.recorder # homeassistant.components.sql -SQLAlchemy==2.0.38 +SQLAlchemy==2.0.39 # homeassistant.components.tami4 Tami4EdgeAPI==3.0 From db9a805ff0720ccab64fd3b1af4c6d1fc9a09085 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Thu, 13 Mar 2025 00:32:55 +0100 Subject: [PATCH 2546/3148] Add rain state binary sensor to ecowitt (#140463) --- homeassistant/components/ecowitt/binary_sensor.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/ecowitt/binary_sensor.py b/homeassistant/components/ecowitt/binary_sensor.py index a2ed279f601..1d36f5232db 100644 --- a/homeassistant/components/ecowitt/binary_sensor.py +++ b/homeassistant/components/ecowitt/binary_sensor.py @@ -26,6 +26,9 @@ ECOWITT_BINARYSENSORS_MAPPING: Final = { device_class=BinarySensorDeviceClass.BATTERY, entity_category=EntityCategory.DIAGNOSTIC, ), + EcoWittSensorTypes.RAIN_STATE: BinarySensorEntityDescription( + key="RAIN_STATE", device_class=BinarySensorDeviceClass.MOISTURE + ), } From ab56a4ca69d088a3a3c307bb1291be02dcda3467 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 12 Mar 2025 16:15:28 -1000 Subject: [PATCH 2547/3148] Bump aioesphomeapi to 29.6.0 (#140481) changelog: https://github.com/esphome/aioesphomeapi/compare/v29.5.1...v29.6.0 --- 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 f0eeecfdb1e..6783b05fa0f 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -16,7 +16,7 @@ "loggers": ["aioesphomeapi", "noiseprotocol", "bleak_esphome"], "mqtt": ["esphome/discover/#"], "requirements": [ - "aioesphomeapi==29.5.1", + "aioesphomeapi==29.6.0", "esphome-dashboard-api==1.2.3", "bleak-esphome==2.11.0" ], diff --git a/requirements_all.txt b/requirements_all.txt index b40ab7110c4..afee136b7da 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==29.5.1 +aioesphomeapi==29.6.0 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index eef5fc03173..f3c1dacff23 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -231,7 +231,7 @@ aioelectricitymaps==0.4.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==29.5.1 +aioesphomeapi==29.6.0 # homeassistant.components.flo aioflo==2021.11.0 From 6a743310bb5c66bfe46fcc5081c54ce715063f7c Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Wed, 12 Mar 2025 19:38:50 -0700 Subject: [PATCH 2548/3148] Change the local to-do list creation button to 'Create' (#140484) --- homeassistant/components/local_todo/strings.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/local_todo/strings.json b/homeassistant/components/local_todo/strings.json index 2403fae60a5..ebf7810494c 100644 --- a/homeassistant/components/local_todo/strings.json +++ b/homeassistant/components/local_todo/strings.json @@ -6,7 +6,8 @@ "description": "Please choose a name for your new To-do list", "data": { "todo_list_name": "To-do list name" - } + }, + "submit": "Create" } }, "abort": { From 6d58dd541ee79f22015de2884ab622508d7fcbbe Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Wed, 12 Mar 2025 19:50:42 -0700 Subject: [PATCH 2549/3148] Update roborock quality scale for docs items (#140483) --- .../components/roborock/quality_scale.yaml | 28 ++++++------------- 1 file changed, 8 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/roborock/quality_scale.yaml b/homeassistant/components/roborock/quality_scale.yaml index fa5e1f4ceeb..1077888ed14 100644 --- a/homeassistant/components/roborock/quality_scale.yaml +++ b/homeassistant/components/roborock/quality_scale.yaml @@ -12,15 +12,10 @@ rules: config-flow: done config-flow-test-coverage: done dependency-transparency: done - docs-actions: - status: todo - comment: | - The documentation for `roborock.get_maps` should be updated so it is next - to the other actions rather than only an example. All actions should be - updated to use the simple table format. + docs-actions: done docs-high-level-description: done - docs-installation-instructions: todo - docs-removal-instructions: todo + docs-installation-instructions: done + docs-removal-instructions: done entity-event-setup: done entity-unique-id: done has-entity-name: done @@ -33,8 +28,8 @@ rules: # Silver action-exceptions: todo config-entry-unloading: done - docs-configuration-parameters: todo - docs-installation-parameters: todo + docs-configuration-parameters: done + docs-installation-parameters: done entity-unavailable: done integration-owner: done log-when-unavailable: done @@ -50,20 +45,13 @@ rules: discovery-update-info: status: exempt comment: Devices do not support discovery. - docs-data-update: - status: todo - comment: | - The docs talk about device communication works (cloud vs local), but does - not yet describe data flow (e.g. polling). We should move into a separate - section. - docs-examples: todo + docs-data-update: done + docs-examples: done docs-known-limitations: status: todo comment: Documentation does not describe known limitations like rate limiting docs-supported-devices: todo - docs-supported-functions: - status: todo - comment: Mostly complete, but some documentation is outdated (e.g. maps/images) + docs-supported-functions: done docs-troubleshooting: status: todo comment: | From f5412dd2090e79bddc14f9b6b477efc1a8a3f6b2 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Thu, 13 Mar 2025 17:23:26 +1000 Subject: [PATCH 2550/3148] Bump Tesla Fleet API to 0.9.13 (#140485) --- homeassistant/components/tesla_fleet/manifest.json | 2 +- homeassistant/components/teslemetry/manifest.json | 2 +- homeassistant/components/tessie/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/tesla_fleet/manifest.json b/homeassistant/components/tesla_fleet/manifest.json index 53aff3d0a54..010197ccbd9 100644 --- a/homeassistant/components/tesla_fleet/manifest.json +++ b/homeassistant/components/tesla_fleet/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/tesla_fleet", "iot_class": "cloud_polling", "loggers": ["tesla-fleet-api"], - "requirements": ["tesla-fleet-api==0.9.12"] + "requirements": ["tesla-fleet-api==0.9.13"] } diff --git a/homeassistant/components/teslemetry/manifest.json b/homeassistant/components/teslemetry/manifest.json index 7c27024d9f0..3d37ced8cff 100644 --- a/homeassistant/components/teslemetry/manifest.json +++ b/homeassistant/components/teslemetry/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/teslemetry", "iot_class": "cloud_polling", "loggers": ["tesla-fleet-api"], - "requirements": ["tesla-fleet-api==0.9.12", "teslemetry-stream==0.6.12"] + "requirements": ["tesla-fleet-api==0.9.13", "teslemetry-stream==0.6.12"] } diff --git a/homeassistant/components/tessie/manifest.json b/homeassistant/components/tessie/manifest.json index d4ac56883e8..4ddd63552f0 100644 --- a/homeassistant/components/tessie/manifest.json +++ b/homeassistant/components/tessie/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/tessie", "iot_class": "cloud_polling", "loggers": ["tessie", "tesla-fleet-api"], - "requirements": ["tessie-api==0.1.1", "tesla-fleet-api==0.9.12"] + "requirements": ["tessie-api==0.1.1", "tesla-fleet-api==0.9.13"] } diff --git a/requirements_all.txt b/requirements_all.txt index afee136b7da..d4081f1a968 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2872,7 +2872,7 @@ temperusb==1.6.1 # homeassistant.components.tesla_fleet # homeassistant.components.teslemetry # homeassistant.components.tessie -tesla-fleet-api==0.9.12 +tesla-fleet-api==0.9.13 # homeassistant.components.powerwall tesla-powerwall==0.5.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f3c1dacff23..9a6bf446cea 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2312,7 +2312,7 @@ temperusb==1.6.1 # homeassistant.components.tesla_fleet # homeassistant.components.teslemetry # homeassistant.components.tessie -tesla-fleet-api==0.9.12 +tesla-fleet-api==0.9.13 # homeassistant.components.powerwall tesla-powerwall==0.5.2 From ffa6f42c0e1355ea66c4529ac97f88c1ab06eee7 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Thu, 13 Mar 2025 00:52:42 -0700 Subject: [PATCH 2551/3148] Use `runtime_data` to store coordinator state (#140486) Use runtime-data to save coordinator state --- .../components/purpleair/__init__.py | 35 +++++++++---------- homeassistant/components/purpleair/const.py | 11 +++--- .../components/purpleair/coordinator.py | 7 ++-- .../components/purpleair/diagnostics.py | 9 ++--- homeassistant/components/purpleair/entity.py | 8 ++--- homeassistant/components/purpleair/sensor.py | 15 ++++---- tests/components/purpleair/conftest.py | 4 +-- .../components/purpleair/test_config_flow.py | 3 +- 8 files changed, 44 insertions(+), 48 deletions(-) diff --git a/homeassistant/components/purpleair/__init__.py b/homeassistant/components/purpleair/__init__.py index 2d4022946b2..78986b34351 100644 --- a/homeassistant/components/purpleair/__init__.py +++ b/homeassistant/components/purpleair/__init__.py @@ -2,37 +2,34 @@ from __future__ import annotations -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from .const import DOMAIN -from .coordinator import PurpleAirDataUpdateCoordinator - -PLATFORMS = [Platform.SENSOR] +from .const import PLATFORMS +from .coordinator import PurpleAirConfigEntry, PurpleAirDataUpdateCoordinator -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Set up PurpleAir from a config entry.""" - coordinator = PurpleAirDataUpdateCoordinator(hass, entry) +async def async_setup_entry(hass: HomeAssistant, entry: PurpleAirConfigEntry) -> bool: + """Set up PurpleAir config entry.""" + coordinator = PurpleAirDataUpdateCoordinator( + hass, + entry, + ) + entry.runtime_data = coordinator + 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) - entry.async_on_unload(entry.add_update_listener(async_handle_entry_update)) + entry.async_on_unload(entry.add_update_listener(async_reload_entry)) return True -async def async_handle_entry_update(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Handle an options update.""" +async def async_reload_entry(hass: HomeAssistant, entry: PurpleAirConfigEntry) -> None: + """Reload config entry.""" await hass.config_entries.async_reload(entry.entry_id) -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 +async def async_unload_entry(hass: HomeAssistant, entry: PurpleAirConfigEntry) -> bool: + """Unload config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/purpleair/const.py b/homeassistant/components/purpleair/const.py index 5f1ec84d469..fcb928bd4f3 100644 --- a/homeassistant/components/purpleair/const.py +++ b/homeassistant/components/purpleair/const.py @@ -1,10 +1,13 @@ """Constants for the PurpleAir integration.""" import logging +from typing import Final -DOMAIN = "purpleair" +from homeassistant.const import Platform -LOGGER = logging.getLogger(__package__) +LOGGER: Final = logging.getLogger(__package__) +PLATFORMS: Final = [Platform.SENSOR] -CONF_READ_KEY = "read_key" -CONF_SENSOR_INDICES = "sensor_indices" +DOMAIN: Final[str] = "purpleair" + +CONF_SENSOR_INDICES: Final[str] = "sensor_indices" diff --git a/homeassistant/components/purpleair/coordinator.py b/homeassistant/components/purpleair/coordinator.py index f1511733cfa..4ed0c0340c6 100644 --- a/homeassistant/components/purpleair/coordinator.py +++ b/homeassistant/components/purpleair/coordinator.py @@ -46,12 +46,15 @@ SENSOR_FIELDS_TO_RETRIEVE = [ UPDATE_INTERVAL = timedelta(minutes=2) +type PurpleAirConfigEntry = ConfigEntry[PurpleAirDataUpdateCoordinator] + + class PurpleAirDataUpdateCoordinator(DataUpdateCoordinator[GetSensorsResponse]): """Define a PurpleAir-specific coordinator.""" - config_entry: ConfigEntry + config_entry: PurpleAirConfigEntry - def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + def __init__(self, hass: HomeAssistant, entry: PurpleAirConfigEntry) -> None: """Initialize.""" self._api = API( entry.data[CONF_API_KEY], diff --git a/homeassistant/components/purpleair/diagnostics.py b/homeassistant/components/purpleair/diagnostics.py index f7c44b7e9b2..71b83e277d3 100644 --- a/homeassistant/components/purpleair/diagnostics.py +++ b/homeassistant/components/purpleair/diagnostics.py @@ -5,7 +5,6 @@ from __future__ import annotations from typing import Any from homeassistant.components.diagnostics import async_redact_data -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_API_KEY, CONF_LATITUDE, @@ -14,8 +13,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant -from .const import DOMAIN -from .coordinator import PurpleAirDataUpdateCoordinator +from .coordinator import PurpleAirConfigEntry CONF_TITLE = "title" @@ -30,14 +28,13 @@ TO_REDACT = { async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: PurpleAirConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - coordinator: PurpleAirDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] return async_redact_data( { "entry": entry.as_dict(), - "data": coordinator.data.model_dump(), + "data": entry.runtime_data.data.model_dump(), }, TO_REDACT, ) diff --git a/homeassistant/components/purpleair/entity.py b/homeassistant/components/purpleair/entity.py index 4f7be1874ed..410fdd9b942 100644 --- a/homeassistant/components/purpleair/entity.py +++ b/homeassistant/components/purpleair/entity.py @@ -7,13 +7,12 @@ from typing import Any from aiopurpleair.models.sensors import SensorModel -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE, CONF_SHOW_ON_MAP from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN -from .coordinator import PurpleAirDataUpdateCoordinator +from .coordinator import PurpleAirConfigEntry, PurpleAirDataUpdateCoordinator class PurpleAirEntity(CoordinatorEntity[PurpleAirDataUpdateCoordinator]): @@ -23,12 +22,11 @@ class PurpleAirEntity(CoordinatorEntity[PurpleAirDataUpdateCoordinator]): def __init__( self, - coordinator: PurpleAirDataUpdateCoordinator, - entry: ConfigEntry, + entry: PurpleAirConfigEntry, sensor_index: int, ) -> None: """Initialize.""" - super().__init__(coordinator) + super().__init__(entry.runtime_data) self._sensor_index = sensor_index diff --git a/homeassistant/components/purpleair/sensor.py b/homeassistant/components/purpleair/sensor.py index bed1d878557..a85a23b6144 100644 --- a/homeassistant/components/purpleair/sensor.py +++ b/homeassistant/components/purpleair/sensor.py @@ -13,7 +13,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, PERCENTAGE, @@ -27,8 +26,8 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import CONF_SENSOR_INDICES, DOMAIN -from .coordinator import PurpleAirDataUpdateCoordinator +from .const import CONF_SENSOR_INDICES +from .coordinator import PurpleAirConfigEntry from .entity import PurpleAirEntity CONCENTRATION_PARTICLES_PER_100_MILLILITERS = f"particles/100{UnitOfVolume.MILLILITERS}" @@ -165,13 +164,12 @@ SENSOR_DESCRIPTIONS = [ async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: PurpleAirConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up PurpleAir sensors based on a config entry.""" - coordinator: PurpleAirDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] async_add_entities( - PurpleAirSensorEntity(coordinator, entry, sensor_index, description) + PurpleAirSensorEntity(entry, sensor_index, description) for sensor_index in entry.options[CONF_SENSOR_INDICES] for description in SENSOR_DESCRIPTIONS ) @@ -184,13 +182,12 @@ class PurpleAirSensorEntity(PurpleAirEntity, SensorEntity): def __init__( self, - coordinator: PurpleAirDataUpdateCoordinator, - entry: ConfigEntry, + entry: PurpleAirConfigEntry, sensor_index: int, description: PurpleAirSensorEntityDescription, ) -> None: """Initialize.""" - super().__init__(coordinator, entry, sensor_index) + super().__init__(entry, sensor_index) self._attr_unique_id = f"{self._sensor_index}-{description.key}" self.entity_description = description diff --git a/tests/components/purpleair/conftest.py b/tests/components/purpleair/conftest.py index 1809b16bd75..a9a51c22b7c 100644 --- a/tests/components/purpleair/conftest.py +++ b/tests/components/purpleair/conftest.py @@ -8,7 +8,7 @@ from aiopurpleair.endpoints.sensors import NearbySensorResult from aiopurpleair.models.sensors import GetSensorsResponse import pytest -from homeassistant.components.purpleair import DOMAIN +from homeassistant.components.purpleair.const import DOMAIN from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry, load_fixture @@ -20,7 +20,7 @@ TEST_SENSOR_INDEX2 = 567890 @pytest.fixture(name="api") def api_fixture(get_sensors_response: GetSensorsResponse) -> Mock: - """Define a fixture to return a mocked aiopurple API object.""" + """Define a fixture to return a mocked aiopurpleair API object.""" return Mock( async_check_api_key=AsyncMock(), get_map_url=Mock(return_value="http://example.com"), diff --git a/tests/components/purpleair/test_config_flow.py b/tests/components/purpleair/test_config_flow.py index 998cb2b7878..5ee15de4e6b 100644 --- a/tests/components/purpleair/test_config_flow.py +++ b/tests/components/purpleair/test_config_flow.py @@ -5,7 +5,7 @@ from unittest.mock import AsyncMock, patch from aiopurpleair.errors import InvalidApiKeyError, PurpleAirError import pytest -from homeassistant.components.purpleair import DOMAIN +from homeassistant.components.purpleair.const import DOMAIN from homeassistant.config_entries import SOURCE_USER from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -288,6 +288,7 @@ async def test_options_remove_sensor( device_entry = device_registry.async_get_device( identifiers={(DOMAIN, str(TEST_SENSOR_INDEX1))} ) + assert device_entry is not None result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={"sensor_device_id": device_entry.id}, From 427aa55789d172f7cfb9cdf6d6912d5616c34a2b Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 13 Mar 2025 09:28:15 +0100 Subject: [PATCH 2552/3148] Correct fallback to state in state machine when processing statistics (#140396) --- homeassistant/components/sensor/recorder.py | 17 ++-- tests/components/sensor/test_recorder.py | 105 ++++++++++++++++++-- 2 files changed, 104 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/sensor/recorder.py b/homeassistant/components/sensor/recorder.py index 675d24b9240..4e8e27e0c79 100644 --- a/homeassistant/components/sensor/recorder.py +++ b/homeassistant/components/sensor/recorder.py @@ -134,16 +134,7 @@ def _time_weighted_average( duration = end - old_start_time accumulated += old_fstate * duration.total_seconds() - period_seconds = (end - start).total_seconds() - if period_seconds == 0: - # If the only state changed that happened was at the exact moment - # at the end of the period, we can't calculate a meaningful average - # so we return 0.0 since it represents a time duration smaller than - # we can measure. This probably means the precision of statistics - # column schema in the database is incorrect but it is actually possible - # to happen if the state change event fired at the exact microsecond - return 0.0 - return accumulated / period_seconds + return accumulated / (end - start).total_seconds() def _get_units(fstates: list[tuple[float, State]]) -> set[str | None]: @@ -447,7 +438,11 @@ def compile_statistics( # noqa: C901 entity_id = _state.entity_id # If there are no recent state changes, the sensor's state may already be pruned # from the recorder. Get the state from the state machine instead. - if not (entity_history := history_list.get(entity_id, [_state])): + try: + entity_history = history_list[entity_id] + except KeyError: + entity_history = [_state] if _state.last_changed < end else [] + if not entity_history: continue if not (float_states := _entity_history_to_float_and_state(entity_history)): continue diff --git a/tests/components/sensor/test_recorder.py b/tests/components/sensor/test_recorder.py index a5b6a07dde5..1dd8fb4905a 100644 --- a/tests/components/sensor/test_recorder.py +++ b/tests/components/sensor/test_recorder.py @@ -541,11 +541,11 @@ async def test_compile_hourly_statistics_with_all_same_last_updated( "max", ), [ - ("temperature", "°C", "°C", "°C", "temperature", 0, 60, 60), - ("temperature", "°F", "°F", "°F", "temperature", 0, 60, 60), + ("temperature", "°C", "°C", "°C", "temperature", 60, -10, 60), + ("temperature", "°F", "°F", "°F", "temperature", 60, -10, 60), ], ) -async def test_compile_hourly_statistics_only_state_is_and_end_of_period( +async def test_compile_hourly_statistics_only_state_is_at_end_of_period( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, device_class, @@ -557,7 +557,7 @@ async def test_compile_hourly_statistics_only_state_is_and_end_of_period( min, max, ) -> None: - """Test compiling hourly statistics when the only state at end of period.""" + """Test compiling hourly statistics when the only states are at end of period.""" zero = get_start_time(dt_util.utcnow()) await async_setup_component(hass, "sensor", {}) # Wait for the sensor recorder platform to be added @@ -604,6 +604,7 @@ async def test_compile_hourly_statistics_only_state_is_and_end_of_period( assert_dict_of_states_equal_without_context_and_last_changed(states, hist) do_adhoc_statistics(hass, start=zero) + do_adhoc_statistics(hass, start=zero + timedelta(minutes=5)) await async_wait_recording_done(hass) statistic_ids = await async_list_statistic_ids(hass) assert statistic_ids == [ @@ -622,8 +623,8 @@ async def test_compile_hourly_statistics_only_state_is_and_end_of_period( assert stats == { "sensor.test1": [ { - "start": process_timestamp(zero).timestamp(), - "end": process_timestamp(zero + timedelta(minutes=5)).timestamp(), + "start": process_timestamp(zero + timedelta(minutes=5)).timestamp(), + "end": process_timestamp(zero + timedelta(minutes=10)).timestamp(), "mean": pytest.approx(mean), "min": pytest.approx(min), "max": pytest.approx(max), @@ -651,7 +652,10 @@ async def test_compile_hourly_statistics_purged_state_changes( statistics_unit, unit_class, ) -> None: - """Test compiling hourly statistics.""" + """Test compiling hourly statistics. + + This tests statistics falls back to the state machine when states are purged. + """ zero = get_start_time(dt_util.utcnow()) await async_setup_component(hass, "sensor", {}) # Wait for the sensor recorder platform to be added @@ -716,6 +720,93 @@ async def test_compile_hourly_statistics_purged_state_changes( assert "Error while processing event StatisticsTask" not in caplog.text +@pytest.mark.parametrize( + ( + "device_class", + "state_unit", + "display_unit", + "statistics_unit", + "unit_class", + "mean", + "min", + "max", + ), + [ + (None, "%", "%", "%", "unitless", 13.050847, -10, 30), + ], +) +async def test_compile_hourly_statistics_ignore_future_state( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + device_class, + state_unit, + display_unit, + statistics_unit, + unit_class, + mean, + min, + max, +) -> None: + """Test compiling hourly statistics. + + This tests statistics does not fall back to the state machine if the state + in the state machine is newer than the end of the statistics period. + """ + zero = get_start_time(dt_util.utcnow() + timedelta(minutes=5)) + previous_period = zero - timedelta(minutes=5) + await async_setup_component(hass, "sensor", {}) + # Wait for the sensor recorder platform to be added + await async_recorder_block_till_done(hass) + attributes = { + "device_class": device_class, + "state_class": "measurement", + "unit_of_measurement": state_unit, + } + with freeze_time(zero) as freezer: + four, states = await async_record_states( + hass, freezer, zero, "sensor.test1", attributes + ) + await async_wait_recording_done(hass) + hist = history.get_significant_states( + hass, zero, four, hass.states.async_entity_ids() + ) + assert_dict_of_states_equal_without_context_and_last_changed(states, hist) + + do_adhoc_statistics(hass, start=previous_period) + do_adhoc_statistics(hass, start=zero) + await async_wait_recording_done(hass) + statistic_ids = await async_list_statistic_ids(hass) + assert statistic_ids == [ + { + "statistic_id": "sensor.test1", + "display_unit_of_measurement": display_unit, + "has_mean": True, + "has_sum": False, + "name": None, + "source": "recorder", + "statistics_unit_of_measurement": statistics_unit, + "unit_class": unit_class, + } + ] + stats = statistics_during_period(hass, previous_period, period="5minute") + # Check we get no stats from the previous period + assert stats == { + "sensor.test1": [ + { + "start": process_timestamp(zero).timestamp(), + "end": process_timestamp(zero + timedelta(minutes=5)).timestamp(), + "mean": pytest.approx(mean), + "min": pytest.approx(min), + "max": pytest.approx(max), + "last_reset": None, + "state": None, + "sum": None, + } + ] + } + assert "Error while processing event StatisticsTask" not in caplog.text + + @pytest.mark.parametrize("attributes", [TEMPERATURE_SENSOR_ATTRIBUTES]) async def test_compile_hourly_statistics_wrong_unit( hass: HomeAssistant, From 26e3624610114d1314ca12e003bf8d78990f2404 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Thu, 13 Mar 2025 13:23:00 +0100 Subject: [PATCH 2553/3148] Update pipdeptree to 2.25.1 (#140507) --- requirements_test.txt | 2 +- script/hassfest/docker/Dockerfile | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements_test.txt b/requirements_test.txt index f40ed46a82f..6a95b6dadb1 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -17,7 +17,7 @@ pre-commit==4.0.0 pydantic==2.10.6 pylint==3.3.4 pylint-per-file-ignores==1.4.0 -pipdeptree==2.25.0 +pipdeptree==2.25.1 pytest-asyncio==0.25.3 pytest-aiohttp==1.1.0 pytest-cov==6.0.0 diff --git a/script/hassfest/docker/Dockerfile b/script/hassfest/docker/Dockerfile index 104939c3808..e4e0c751d78 100644 --- a/script/hassfest/docker/Dockerfile +++ b/script/hassfest/docker/Dockerfile @@ -24,7 +24,7 @@ RUN --mount=from=ghcr.io/astral-sh/uv:0.6.1,source=/uv,target=/bin/uv \ --no-cache \ -c /usr/src/homeassistant/homeassistant/package_constraints.txt \ -r /usr/src/homeassistant/requirements.txt \ - stdlib-list==0.10.0 pipdeptree==2.25.0 tqdm==4.67.1 ruff==0.9.10 \ + stdlib-list==0.10.0 pipdeptree==2.25.1 tqdm==4.67.1 ruff==0.9.10 \ PyTurboJPEG==1.7.5 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 hassil==2.2.3 home-assistant-intents==2025.3.5 mutagen==1.47.0 pymicro-vad==1.0.1 pyspeex-noise==1.0.2 LABEL "name"="hassfest" From e710d3699c1b5a51147ba2e37a4fc10dd68215bb Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Thu, 13 Mar 2025 13:23:52 +0100 Subject: [PATCH 2554/3148] Improve frontend typing (#140503) --- homeassistant/components/frontend/__init__.py | 22 +++++++++---------- homeassistant/components/frontend/storage.py | 5 ++++- 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 6184d888004..9a0627f9f42 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -52,10 +52,9 @@ CONF_JS_VERSION = "javascript_version" DEFAULT_THEME_COLOR = "#03A9F4" -DATA_PANELS = "frontend_panels" -DATA_JS_VERSION = "frontend_js_version" -DATA_EXTRA_MODULE_URL = "frontend_extra_module_url" -DATA_EXTRA_JS_URL_ES5 = "frontend_extra_js_url_es5" +DATA_PANELS: HassKey[dict[str, Panel]] = HassKey("frontend_panels") +DATA_EXTRA_MODULE_URL: HassKey[UrlManager] = HassKey("frontend_extra_module_url") +DATA_EXTRA_JS_URL_ES5: HassKey[UrlManager] = HassKey("frontend_extra_js_url_es5") DATA_WS_SUBSCRIBERS: HassKey[set[tuple[websocket_api.ActiveConnection, int]]] = HassKey( "frontend_ws_subscribers" @@ -64,8 +63,8 @@ DATA_WS_SUBSCRIBERS: HassKey[set[tuple[websocket_api.ActiveConnection, int]]] = THEMES_STORAGE_KEY = f"{DOMAIN}_theme" THEMES_STORAGE_VERSION = 1 THEMES_SAVE_DELAY = 60 -DATA_THEMES_STORE = "frontend_themes_store" -DATA_THEMES = "frontend_themes" +DATA_THEMES_STORE: HassKey[Store] = HassKey("frontend_themes_store") +DATA_THEMES: HassKey[dict[str, Any]] = HassKey("frontend_themes") DATA_DEFAULT_THEME = "frontend_default_theme" DATA_DEFAULT_DARK_THEME = "frontend_default_dark_theme" DEFAULT_THEME = "default" @@ -242,7 +241,7 @@ class Panel: sidebar_title: str | None = None # Url to show the panel in the frontend - frontend_url_path: str | None = None + frontend_url_path: str # Config to pass to the webcomponent config: dict[str, Any] | None = None @@ -273,7 +272,7 @@ class Panel: self.config_panel_domain = config_panel_domain @callback - def to_response(self) -> PanelRespons: + def to_response(self) -> PanelResponse: """Panel as dictionary.""" return { "component_name": self.component_name, @@ -631,7 +630,8 @@ class IndexView(web_urldispatcher.AbstractResource): def get_info(self) -> dict[str, list[str]]: # type: ignore[override] """Return a dict with additional info useful for introspection.""" - return {"panels": list(self.hass.data[DATA_PANELS])} + panels = self.hass.data[DATA_PANELS] + return {"panels": list(panels)} def raw_match(self, path: str) -> bool: """Perform a raw match against path.""" @@ -841,13 +841,13 @@ def websocket_subscribe_extra_js( connection.send_message(websocket_api.result_message(msg["id"])) -class PanelRespons(TypedDict): +class PanelResponse(TypedDict): """Represent the panel response type.""" component_name: str icon: str | None title: str | None config: dict[str, Any] | None - url_path: str | None + url_path: str require_admin: bool config_panel_domain: str | None diff --git a/homeassistant/components/frontend/storage.py b/homeassistant/components/frontend/storage.py index cbcc3024aa7..a33a9de7ac5 100644 --- a/homeassistant/components/frontend/storage.py +++ b/homeassistant/components/frontend/storage.py @@ -12,8 +12,11 @@ from homeassistant.components import websocket_api from homeassistant.components.websocket_api import ActiveConnection from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.storage import Store +from homeassistant.util.hass_dict import HassKey -DATA_STORAGE = "frontend_storage" +DATA_STORAGE: HassKey[tuple[dict[str, Store], dict[str, dict]]] = HassKey( + "frontend_storage" +) STORAGE_VERSION_USER_DATA = 1 From f32bb1a318cd2cf912d0900f923dc4d80f12c849 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 13 Mar 2025 09:36:38 -0400 Subject: [PATCH 2555/3148] Assist satellite to use TTS tokens for announcements (#140336) * Migrate Assist Satellite to use token * Fix tests * Fix tests --- .../components/assist_satellite/entity.py | 34 ++++++++++---- .../assist_satellite/test_entity.py | 32 ++++++++++++-- .../assist_satellite/test_intent.py | 44 +++++++++++-------- .../esphome/test_assist_satellite.py | 18 ++++---- tests/components/tts/common.py | 7 +++ tests/components/voip/test_voip.py | 18 +++++++- 6 files changed, 113 insertions(+), 40 deletions(-) diff --git a/homeassistant/components/assist_satellite/entity.py b/homeassistant/components/assist_satellite/entity.py index 8c63525294c..3db38a23889 100644 --- a/homeassistant/components/assist_satellite/entity.py +++ b/homeassistant/components/assist_satellite/entity.py @@ -23,9 +23,6 @@ from homeassistant.components.assist_pipeline import ( vad, ) from homeassistant.components.media_player import async_process_play_media_url -from homeassistant.components.tts import ( - generate_media_source_id as tts_generate_media_source_id, -) from homeassistant.core import Context, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import chat_session, entity @@ -98,6 +95,9 @@ class AssistSatelliteAnnouncement: original_media_id: str """The raw media ID before processing.""" + tts_token: str | None + """The TTS token of the media.""" + media_id_source: Literal["url", "media_id", "tts"] """Source of the media ID.""" @@ -474,6 +474,7 @@ class AssistSatelliteEntity(entity.Entity): ) -> AssistSatelliteAnnouncement: """Resolve the media ID.""" media_id_source: Literal["url", "media_id", "tts"] | None = None + tts_token: str | None = None if media_id: original_media_id = media_id @@ -484,6 +485,10 @@ class AssistSatelliteEntity(entity.Entity): pipeline_id = self._resolve_pipeline() pipeline = async_get_pipeline(self.hass, pipeline_id) + engine = tts.async_resolve_engine(self.hass, pipeline.tts_engine) + if engine is None: + raise HomeAssistantError(f"TTS engine {pipeline.tts_engine} not found") + tts_options: dict[str, Any] = {} if pipeline.tts_voice is not None: tts_options[tts.ATTR_VOICE] = pipeline.tts_voice @@ -491,14 +496,23 @@ class AssistSatelliteEntity(entity.Entity): if self.tts_options is not None: tts_options.update(self.tts_options) - media_id = tts_generate_media_source_id( + stream = tts.async_create_stream( self.hass, - message, - engine=pipeline.tts_engine, + engine=engine, + language=pipeline.tts_language, + options=tts_options, + ) + stream.async_set_message(message) + + tts_token = stream.token + media_id = stream.url + original_media_id = tts.generate_media_source_id( + self.hass, + message, + engine=engine, language=pipeline.tts_language, options=tts_options, ) - original_media_id = media_id if media_source.is_media_source_id(media_id): if not media_id_source: @@ -517,5 +531,9 @@ class AssistSatelliteEntity(entity.Entity): media_id = async_process_play_media_url(self.hass, media_id) return AssistSatelliteAnnouncement( - message, media_id, original_media_id, media_id_source + message=message, + media_id=media_id, + original_media_id=original_media_id, + tts_token=tts_token, + media_id_source=media_id_source, ) diff --git a/tests/components/assist_satellite/test_entity.py b/tests/components/assist_satellite/test_entity.py index 42b4adf742c..6604fdc3f25 100644 --- a/tests/components/assist_satellite/test_entity.py +++ b/tests/components/assist_satellite/test_entity.py @@ -31,6 +31,8 @@ from homeassistant.exceptions import HomeAssistantError from . import ENTITY_ID from .conftest import MockAssistSatellite +from tests.components.tts.common import MockResultStream + @pytest.fixture def mock_chat_session_conversation_id() -> Generator[Mock]: @@ -186,8 +188,9 @@ async def test_new_pipeline_cancels_pipeline( {"message": "Hello"}, AssistSatelliteAnnouncement( message="Hello", - media_id="https://www.home-assistant.io/resolved.mp3", + media_id="http://10.10.10.10:8123/api/tts_proxy/test-token", original_media_id="media-source://bla", + tts_token="test-token", media_id_source="tts", ), ), @@ -200,6 +203,7 @@ async def test_new_pipeline_cancels_pipeline( message="Hello", media_id="https://www.home-assistant.io/resolved.mp3", original_media_id="media-source://given", + tts_token=None, media_id_source="media_id", ), ), @@ -209,6 +213,7 @@ async def test_new_pipeline_cancels_pipeline( message="", media_id="http://example.com/bla.mp3", original_media_id="http://example.com/bla.mp3", + tts_token=None, media_id_source="url", ), ), @@ -243,9 +248,17 @@ async def test_announce( with ( patch( - "homeassistant.components.assist_satellite.entity.tts_generate_media_source_id", + "homeassistant.components.tts.generate_media_source_id", new=tts_generate_media_source_id, ), + patch( + "homeassistant.components.tts.async_resolve_engine", + return_value="tts.cloud", + ), + patch( + "homeassistant.components.tts.async_create_stream", + return_value=MockResultStream(hass, "wav", b""), + ), patch( "homeassistant.components.media_source.async_resolve_media", return_value=PlayMedia( @@ -500,7 +513,8 @@ async def test_vad_sensitivity_entity_not_found( "Better system prompt", AssistSatelliteAnnouncement( message="Hello", - media_id="https://www.home-assistant.io/resolved.mp3", + media_id="http://10.10.10.10:8123/api/tts_proxy/test-token", + tts_token="test-token", original_media_id="media-source://generated", media_id_source="tts", ), @@ -517,6 +531,7 @@ async def test_vad_sensitivity_entity_not_found( AssistSatelliteAnnouncement( message="Hello", media_id="https://www.home-assistant.io/resolved.mp3", + tts_token=None, original_media_id="media-source://given", media_id_source="media_id", ), @@ -530,6 +545,7 @@ async def test_vad_sensitivity_entity_not_found( AssistSatelliteAnnouncement( message="", media_id="http://example.com/given.mp3", + tts_token=None, original_media_id="http://example.com/given.mp3", media_id_source="url", ), @@ -554,9 +570,17 @@ async def test_start_conversation( with ( patch( - "homeassistant.components.assist_satellite.entity.tts_generate_media_source_id", + "homeassistant.components.tts.generate_media_source_id", return_value="media-source://generated", ), + patch( + "homeassistant.components.tts.async_resolve_engine", + return_value="tts.cloud", + ), + patch( + "homeassistant.components.tts.async_create_stream", + return_value=MockResultStream(hass, "wav", b""), + ), patch( "homeassistant.components.media_source.async_resolve_media", return_value=PlayMedia( diff --git a/tests/components/assist_satellite/test_intent.py b/tests/components/assist_satellite/test_intent.py index 9304229dbe3..0e531811adc 100644 --- a/tests/components/assist_satellite/test_intent.py +++ b/tests/components/assist_satellite/test_intent.py @@ -4,28 +4,28 @@ from unittest.mock import patch import pytest -from homeassistant.components.media_source import PlayMedia from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import intent +from homeassistant.setup import async_setup_component from .conftest import TEST_DOMAIN, MockAssistSatellite +from tests.components.tts.common import MockResultStream + @pytest.fixture -def mock_tts(): +async def mock_tts(hass: HomeAssistant): """Mock TTS service.""" + assert await async_setup_component(hass, "tts", {}) with ( patch( - "homeassistant.components.assist_satellite.entity.tts_generate_media_source_id", + "homeassistant.components.tts.generate_media_source_id", return_value="media-source://bla", ), patch( - "homeassistant.components.media_source.async_resolve_media", - return_value=PlayMedia( - url="https://www.home-assistant.io/resolved.mp3", - mime_type="audio/mp3", - ), + "homeassistant.components.tts.async_create_stream", + return_value=MockResultStream(hass, "wav", b""), ), ): yield @@ -41,9 +41,13 @@ async def test_broadcast_intent( ) -> None: """Test we can invoke a broadcast intent.""" - result = await intent.async_handle( - hass, "test", intent.INTENT_BROADCAST, {"message": {"value": "Hello"}} - ) + with patch( + "homeassistant.components.tts.async_resolve_engine", + return_value="tts.cloud", + ): + result = await intent.async_handle( + hass, "test", intent.INTENT_BROADCAST, {"message": {"value": "Hello"}} + ) assert result.as_dict() == { "card": {}, @@ -71,13 +75,17 @@ async def test_broadcast_intent( assert len(entity2.announcements) == 1 assert len(entity_no_features.announcements) == 0 - result = await intent.async_handle( - hass, - "test", - intent.INTENT_BROADCAST, - {"message": {"value": "Hello"}}, - device_id=entity.device_entry.id, - ) + with patch( + "homeassistant.components.tts.async_resolve_engine", + return_value="tts.cloud", + ): + result = await intent.async_handle( + hass, + "test", + intent.INTENT_BROADCAST, + {"message": {"value": "Hello"}}, + device_id=entity.device_entry.id, + ) # Broadcast doesn't targets device that triggered it. assert result.as_dict() == { "card": {}, diff --git a/tests/components/esphome/test_assist_satellite.py b/tests/components/esphome/test_assist_satellite.py index 3281a760c39..329a7b5179a 100644 --- a/tests/components/esphome/test_assist_satellite.py +++ b/tests/components/esphome/test_assist_satellite.py @@ -41,7 +41,6 @@ from homeassistant.components.esphome.assist_satellite import ( EsphomeAssistSatellite, VoiceAssistantUDPServer, ) -from homeassistant.components.media_source import PlayMedia from homeassistant.components.select import ( DOMAIN as SELECT_DOMAIN, SERVICE_SELECT_OPTION, @@ -57,6 +56,8 @@ from homeassistant.helpers.entity_component import EntityComponent from .conftest import MockESPHomeDevice +from tests.components.tts.common import MockResultStream + def get_satellite_entity( hass: HomeAssistant, mac_address: str @@ -1209,22 +1210,23 @@ async def test_announce_message( media_id: str, timeout: float, text: str ): assert satellite.state == AssistSatelliteState.RESPONDING - assert media_id == "https://www.home-assistant.io/resolved.mp3" + assert media_id == "http://10.10.10.10:8123/api/tts_proxy/test-token" assert text == "test-text" done.set() with ( patch( - "homeassistant.components.assist_satellite.entity.tts_generate_media_source_id", + "homeassistant.components.tts.generate_media_source_id", return_value="media-source://bla", ), patch( - "homeassistant.components.media_source.async_resolve_media", - return_value=PlayMedia( - url="https://www.home-assistant.io/resolved.mp3", - mime_type="audio/mp3", - ), + "homeassistant.components.tts.async_resolve_engine", + return_value="tts.cloud_tts", + ), + patch( + "homeassistant.components.tts.async_create_stream", + return_value=MockResultStream(hass, "wav", b""), ), patch.object( mock_client, diff --git a/tests/components/tts/common.py b/tests/components/tts/common.py index 9ae83cb2bb5..99c698771f7 100644 --- a/tests/components/tts/common.py +++ b/tests/components/tts/common.py @@ -270,6 +270,8 @@ async def mock_config_entry_setup( class MockResultStream(ResultStream): """Mock result stream.""" + test_set_message: str | None = None + def __init__(self, hass: HomeAssistant, extension: str, data: bytes) -> None: """Initialize the result stream.""" super().__init__( @@ -285,6 +287,11 @@ class MockResultStream(ResultStream): hass.data[DATA_TTS_MANAGER].token_to_stream[self.token] = self self._mock_data = data + @callback + def async_set_message(self, message: str) -> None: + """Set message to be generated.""" + self.test_set_message = message + async def async_stream_result(self): """Stream the result.""" yield self._mock_data diff --git a/tests/components/voip/test_voip.py b/tests/components/voip/test_voip.py index d971591c79a..459ab020336 100644 --- a/tests/components/voip/test_voip.py +++ b/tests/components/voip/test_voip.py @@ -27,6 +27,8 @@ from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_component import EntityComponent from homeassistant.setup import async_setup_component +from tests.components.tts.common import MockResultStream + _ONE_SECOND = 16000 * 2 # 16Khz 16-bit _MEDIA_ID = "12345" @@ -879,6 +881,7 @@ async def test_announce( announcement = assist_satellite.AssistSatelliteAnnouncement( message="test announcement", media_id=_MEDIA_ID, + tts_token="test-token", original_media_id=_MEDIA_ID, media_id_source="tts", ) @@ -926,6 +929,7 @@ async def test_voip_id_is_ip_address( announcement = assist_satellite.AssistSatelliteAnnouncement( message="test announcement", media_id=_MEDIA_ID, + tts_token="test-token", original_media_id=_MEDIA_ID, media_id_source="tts", ) @@ -978,6 +982,7 @@ async def test_announce_timeout( announcement = assist_satellite.AssistSatelliteAnnouncement( message="test announcement", media_id=_MEDIA_ID, + tts_token="test-token", original_media_id=_MEDIA_ID, media_id_source="tts", ) @@ -1018,6 +1023,7 @@ async def test_start_conversation( announcement = assist_satellite.AssistSatelliteAnnouncement( message="test announcement", media_id=_MEDIA_ID, + tts_token="test-token", original_media_id=_MEDIA_ID, media_id_source="tts", ) @@ -1162,8 +1168,16 @@ async def test_start_conversation_user_doesnt_pick_up( new=async_pipeline_from_audio_stream, ), patch( - "homeassistant.components.assist_satellite.entity.tts_generate_media_source_id", - return_value="test media id", + "homeassistant.components.tts.generate_media_source_id", + return_value="media-source://bla", + ), + patch( + "homeassistant.components.tts.async_resolve_engine", + return_value="test tts", + ), + patch( + "homeassistant.components.tts.async_create_stream", + return_value=MockResultStream(hass, "wav", b""), ), ): satellite.transport = Mock() From 5526585eeb3e19e37dc648ad7cdf01df7d42570c Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 13 Mar 2025 15:35:40 +0100 Subject: [PATCH 2556/3148] Fix spelling of "ID" and excessive colon in `bang_olufsen` integration (#140518) --- homeassistant/components/bang_olufsen/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/bang_olufsen/strings.json b/homeassistant/components/bang_olufsen/strings.json index 278e9b6d47c..422dc4be567 100644 --- a/homeassistant/components/bang_olufsen/strings.json +++ b/homeassistant/components/bang_olufsen/strings.json @@ -274,7 +274,7 @@ "message": "An error occurred while attempting to play {media_type}: {error_message}." }, "invalid_grouping_entity": { - "message": "Entity with id: {entity_id} can't be added to the Beolink session. Is the entity a Bang & Olufsen media_player?" + "message": "Entity with ID {entity_id} can't be added to the Beolink session. Is the entity a Bang & Olufsen media_player?" }, "invalid_sound_mode": { "message": "{invalid_sound_mode} is an invalid sound mode. Valid values are: {valid_sound_modes}." From bc6eb94c0db65705f0b2a37eed5c6e92b250b2a1 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 13 Mar 2025 15:36:12 +0100 Subject: [PATCH 2557/3148] Fix sentence-casing and spelling of "ID" in `system_bridge` integration (#140516) --- homeassistant/components/system_bridge/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/system_bridge/strings.json b/homeassistant/components/system_bridge/strings.json index ef7495ef74f..1c079c1ef0c 100644 --- a/homeassistant/components/system_bridge/strings.json +++ b/homeassistant/components/system_bridge/strings.json @@ -109,7 +109,7 @@ "message": "No data received from {host}" }, "process_not_found": { - "message": "Could not find process with id {id}." + "message": "Could not find process with ID {id}." }, "timeout": { "message": "A timeout occurred for {title} ({host})" @@ -120,7 +120,7 @@ }, "issues": { "unsupported_version": { - "title": "System Bridge Upgrade Required", + "title": "System Bridge upgrade required", "description": "Your version of System Bridge for host {host} is not supported.\n\nPlease upgrade to the latest version." } }, From 3bba781554948a22b445632174fd133de880558c Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Thu, 13 Mar 2025 15:53:01 +0100 Subject: [PATCH 2558/3148] Use runtime data in Vodafone Station (#140464) * Use runtime data in Vodafone Station * specialize config entry * revert unwanted change --- .../components/vodafone_station/__init__.py | 13 ++++++------- homeassistant/components/vodafone_station/button.py | 9 ++++----- .../components/vodafone_station/coordinator.py | 6 ++++-- .../components/vodafone_station/device_tracker.py | 13 ++++++++----- .../components/vodafone_station/diagnostics.py | 8 +++----- homeassistant/components/vodafone_station/sensor.py | 9 ++++----- 6 files changed, 29 insertions(+), 29 deletions(-) diff --git a/homeassistant/components/vodafone_station/__init__.py b/homeassistant/components/vodafone_station/__init__.py index 871afe09a2e..9f118fe4fbd 100644 --- a/homeassistant/components/vodafone_station/__init__.py +++ b/homeassistant/components/vodafone_station/__init__.py @@ -1,16 +1,15 @@ """Vodafone Station integration.""" -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant from .const import DOMAIN -from .coordinator import VodafoneStationRouter +from .coordinator import VodafoneConfigEntry, VodafoneStationRouter PLATFORMS = [Platform.BUTTON, Platform.DEVICE_TRACKER, Platform.SENSOR] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: VodafoneConfigEntry) -> bool: """Set up Vodafone Station platform.""" coordinator = VodafoneStationRouter( hass, @@ -22,7 +21,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + entry.runtime_data = coordinator entry.async_on_unload(entry.add_update_listener(update_listener)) @@ -31,10 +30,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: VodafoneConfigEntry) -> bool: """Unload a config entry.""" if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - coordinator: VodafoneStationRouter = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data await coordinator.api.logout() await coordinator.api.close() hass.data[DOMAIN].pop(entry.entry_id) @@ -42,7 +41,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return unload_ok -async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def update_listener(hass: HomeAssistant, entry: VodafoneConfigEntry) -> None: """Update when config_entry options update.""" if entry.options: await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/vodafone_station/button.py b/homeassistant/components/vodafone_station/button.py index 9812cef48d6..9227611ce22 100644 --- a/homeassistant/components/vodafone_station/button.py +++ b/homeassistant/components/vodafone_station/button.py @@ -11,14 +11,13 @@ from homeassistant.components.button import ( ButtonEntity, ButtonEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import _LOGGER, DOMAIN -from .coordinator import VodafoneStationRouter +from .const import _LOGGER +from .coordinator import VodafoneConfigEntry, VodafoneStationRouter @dataclass(frozen=True, kw_only=True) @@ -68,13 +67,13 @@ BUTTON_TYPES: Final = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: VodafoneConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up entry.""" _LOGGER.debug("Setting up Vodafone Station buttons") - coordinator: VodafoneStationRouter = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data sensors_data = coordinator.data.sensors diff --git a/homeassistant/components/vodafone_station/coordinator.py b/homeassistant/components/vodafone_station/coordinator.py index 424abc4fafd..55643cd2778 100644 --- a/homeassistant/components/vodafone_station/coordinator.py +++ b/homeassistant/components/vodafone_station/coordinator.py @@ -21,6 +21,8 @@ from .helpers import cleanup_device_tracker CONSIDER_HOME_SECONDS = DEFAULT_CONSIDER_HOME.total_seconds() +type VodafoneConfigEntry = ConfigEntry[VodafoneStationRouter] + @dataclass(slots=True) class VodafoneStationDeviceInfo: @@ -42,7 +44,7 @@ class UpdateCoordinatorDataType: class VodafoneStationRouter(DataUpdateCoordinator[UpdateCoordinatorDataType]): """Queries router running Vodafone Station firmware.""" - config_entry: ConfigEntry + config_entry: VodafoneConfigEntry def __init__( self, @@ -50,7 +52,7 @@ class VodafoneStationRouter(DataUpdateCoordinator[UpdateCoordinatorDataType]): host: str, username: str, password: str, - config_entry: ConfigEntry, + config_entry: VodafoneConfigEntry, ) -> None: """Initialize the scanner.""" diff --git a/homeassistant/components/vodafone_station/device_tracker.py b/homeassistant/components/vodafone_station/device_tracker.py index ece4bd05a02..984355287a4 100644 --- a/homeassistant/components/vodafone_station/device_tracker.py +++ b/homeassistant/components/vodafone_station/device_tracker.py @@ -3,25 +3,28 @@ from __future__ import annotations from homeassistant.components.device_tracker import ScannerEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import _LOGGER, DOMAIN -from .coordinator import VodafoneStationDeviceInfo, VodafoneStationRouter +from .const import _LOGGER +from .coordinator import ( + VodafoneConfigEntry, + VodafoneStationDeviceInfo, + VodafoneStationRouter, +) async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: VodafoneConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up device tracker for Vodafone Station component.""" _LOGGER.debug("Start device trackers setup") - coordinator: VodafoneStationRouter = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data tracked: set = set() diff --git a/homeassistant/components/vodafone_station/diagnostics.py b/homeassistant/components/vodafone_station/diagnostics.py index e306d6caca2..4778e7d5a4e 100644 --- a/homeassistant/components/vodafone_station/diagnostics.py +++ b/homeassistant/components/vodafone_station/diagnostics.py @@ -5,22 +5,20 @@ from __future__ import annotations from typing import Any from homeassistant.components.diagnostics import async_redact_data -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant -from .const import DOMAIN -from .coordinator import VodafoneStationRouter +from .coordinator import VodafoneConfigEntry TO_REDACT = {CONF_USERNAME, CONF_PASSWORD} async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: VodafoneConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - coordinator: VodafoneStationRouter = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data sensors_data = coordinator.data.sensors return { diff --git a/homeassistant/components/vodafone_station/sensor.py b/homeassistant/components/vodafone_station/sensor.py index d29fb7f21e9..bdb429aa6dd 100644 --- a/homeassistant/components/vodafone_station/sensor.py +++ b/homeassistant/components/vodafone_station/sensor.py @@ -12,14 +12,13 @@ from homeassistant.components.sensor import ( SensorEntity, SensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfDataRate from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import _LOGGER, DOMAIN, LINE_TYPES -from .coordinator import VodafoneStationRouter +from .const import _LOGGER, LINE_TYPES +from .coordinator import VodafoneConfigEntry, VodafoneStationRouter NOT_AVAILABLE: list = ["", "N/A", "0.0.0.0"] UPTIME_DEVIATION = 60 @@ -166,13 +165,13 @@ SENSOR_TYPES: Final = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: VodafoneConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up entry.""" _LOGGER.debug("Setting up Vodafone Station sensors") - coordinator: VodafoneStationRouter = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data sensors_data = coordinator.data.sensors From c92ee120b609876a18d814da37e60af7580761b9 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 13 Mar 2025 16:39:12 +0100 Subject: [PATCH 2559/3148] Make actions in `flo` integration UI-friendly (#140522) Makes actions in `flo` integration UI-friendly - replace key name `sleep_minutes` with its friendly name to match the UI (in translations) - replace "time" with "duration" to reduce the ambiguity - use third-person singular for `run_health_test` description for consistency (in translations) --- homeassistant/components/flo/strings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/flo/strings.json b/homeassistant/components/flo/strings.json index 3444911fbd4..64e22bedec3 100644 --- a/homeassistant/components/flo/strings.json +++ b/homeassistant/components/flo/strings.json @@ -60,11 +60,11 @@ "fields": { "sleep_minutes": { "name": "Sleep minutes", - "description": "The time to sleep in minutes." + "description": "The duration to sleep in minutes." }, "revert_to_mode": { "name": "Revert to mode", - "description": "The mode to revert to after sleep_minutes has elapsed." + "description": "The mode to revert to after the 'Sleep minutes' duration has elapsed." } } }, @@ -78,7 +78,7 @@ }, "run_health_test": { "name": "Run health test", - "description": "Have the Flo device run a health test." + "description": "Requests the Flo device to run a health test." } } } From 473a5559cc8597275ff44cf329194d9d0b5e4c38 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Thu, 13 Mar 2025 16:48:04 +0100 Subject: [PATCH 2560/3148] Improve tado typing (#140505) --- homeassistant/components/tado/climate.py | 4 ++-- homeassistant/components/tado/helper.py | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/tado/climate.py b/homeassistant/components/tado/climate.py index e6aa921d428..6a2067ffff1 100644 --- a/homeassistant/components/tado/climate.py +++ b/homeassistant/components/tado/climate.py @@ -157,8 +157,8 @@ async def create_climate_entity( TADO_TO_HA_HVAC_MODE_MAP[CONST_MODE_OFF], TADO_TO_HA_HVAC_MODE_MAP[CONST_MODE_SMART_SCHEDULE], ] - supported_fan_modes = None - supported_swing_modes = None + supported_fan_modes: list[str] | None = None + supported_swing_modes: list[str] | None = None heat_temperatures = None cool_temperatures = None diff --git a/homeassistant/components/tado/helper.py b/homeassistant/components/tado/helper.py index 571a757a3e8..5c515e00cf0 100644 --- a/homeassistant/components/tado/helper.py +++ b/homeassistant/components/tado/helper.py @@ -53,13 +53,13 @@ def decide_duration( return duration -def generate_supported_fanmodes(tado_to_ha_mapping: dict[str, str], options: list[str]): +def generate_supported_fanmodes( + tado_to_ha_mapping: dict[str, str], options: list[str] +) -> list[str] | None: """Return correct list of fan modes or None.""" supported_fanmodes = [ - tado_to_ha_mapping.get(option) - for option in options - if tado_to_ha_mapping.get(option) is not None + val for option in options if (val := tado_to_ha_mapping.get(option)) is not None ] if not supported_fanmodes: return None From b07ac301b9beedb6f9fb51079272d64f73f17ace Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Thu, 13 Mar 2025 16:57:22 +0100 Subject: [PATCH 2561/3148] Update xknxproject to 3.8.2 (#140499) --- homeassistant/components/knx/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/knx/manifest.json b/homeassistant/components/knx/manifest.json index 8cfb034a793..98e3a6a5242 100644 --- a/homeassistant/components/knx/manifest.json +++ b/homeassistant/components/knx/manifest.json @@ -11,7 +11,7 @@ "loggers": ["xknx", "xknxproject"], "requirements": [ "xknx==3.6.0", - "xknxproject==3.8.1", + "xknxproject==3.8.2", "knx-frontend==2025.1.30.194235" ], "single_config_entry": true diff --git a/requirements_all.txt b/requirements_all.txt index d4081f1a968..11a9df4ba16 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3091,7 +3091,7 @@ xiaomi-ble==0.33.0 xknx==3.6.0 # homeassistant.components.knx -xknxproject==3.8.1 +xknxproject==3.8.2 # homeassistant.components.fritz # homeassistant.components.rest diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9a6bf446cea..7769e8e824f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2492,7 +2492,7 @@ xiaomi-ble==0.33.0 xknx==3.6.0 # homeassistant.components.knx -xknxproject==3.8.1 +xknxproject==3.8.2 # homeassistant.components.fritz # homeassistant.components.rest From 55895df54dba28802e1f0abc0953f37b18e09793 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 13 Mar 2025 13:24:44 -0400 Subject: [PATCH 2562/3148] Switch more TTS core to async generators (#140432) * Switch more TTS core to async generators * Document a design choice * robust * Add more tests * Update comment * Clarify and document TTSCache variables --- homeassistant/components/tts/__init__.py | 369 ++++++++++++++--------- tests/components/tts/test_init.py | 109 ++++++- tests/components/wyoming/test_tts.py | 18 +- 3 files changed, 330 insertions(+), 166 deletions(-) diff --git a/homeassistant/components/tts/__init__.py b/homeassistant/components/tts/__init__.py index 6fc25e32091..350b03a2e80 100644 --- a/homeassistant/components/tts/__init__.py +++ b/homeassistant/components/tts/__init__.py @@ -17,7 +17,7 @@ import secrets import subprocess import tempfile from time import monotonic -from typing import Any, Final, TypedDict +from typing import Any, Final from aiohttp import web import mutagen @@ -123,13 +123,94 @@ KEY_PATTERN = "{0}_{1}_{2}_{3}" SCHEMA_SERVICE_CLEAR_CACHE = vol.Schema({}) -class TTSCache(TypedDict): - """Cached TTS file.""" +class TTSCache: + """Cached bytes of a TTS result.""" - extension: str - voice: bytes - pending: asyncio.Task | None - last_used: float + _result_data: bytes | None = None + """When fully loaded, contains the result data.""" + + _partial_data: list[bytes] | None = None + """While loading, contains the data already received from the generator.""" + + _loading_error: Exception | None = None + """If an error occurred while loading, contains the error.""" + + _consumers: list[asyncio.Queue[bytes | None]] | None = None + """A queue for each current consumer to notify of new data while the generator is loading.""" + + def __init__( + self, + cache_key: str, + extension: str, + data_gen: AsyncGenerator[bytes], + ) -> None: + """Initialize the TTS cache.""" + self.cache_key = cache_key + self.extension = extension + self.last_used = monotonic() + self._data_gen = data_gen + + async def async_load_data(self) -> bytes: + """Load the data from the generator.""" + if self._result_data is not None or self._partial_data is not None: + raise RuntimeError("Data already being loaded") + + self._partial_data = [] + self._consumers = [] + + try: + async for chunk in self._data_gen: + self._partial_data.append(chunk) + for queue in self._consumers: + queue.put_nowait(chunk) + except Exception as err: # pylint: disable=broad-except + self._loading_error = err + raise + finally: + for queue in self._consumers: + queue.put_nowait(None) + self._consumers = None + + self._result_data = b"".join(self._partial_data) + self._partial_data = None + return self._result_data + + async def async_stream_data(self) -> AsyncGenerator[bytes]: + """Stream the data. + + Will return all data already returned from the generator. + Will listen for future data returned from the generator. + Raises error if one occurred. + """ + if self._result_data is not None: + yield self._result_data + return + if self._loading_error: + raise self._loading_error + + if self._partial_data is None: + raise RuntimeError("Data not being loaded") + + queue: asyncio.Queue[bytes | None] | None = None + # Check if generator is still feeding data + if self._consumers is not None: + queue = asyncio.Queue() + self._consumers.append(queue) + + for chunk in list(self._partial_data): + yield chunk + + if self._loading_error: + raise self._loading_error + + if queue is not None: + while (chunk2 := await queue.get()) is not None: + yield chunk2 + + if self._loading_error: + raise self._loading_error + + self.last_used = monotonic() @callback @@ -194,10 +275,11 @@ async def async_get_media_source_audio( ) -> tuple[str, bytes]: """Get TTS audio as extension, data.""" manager = hass.data[DATA_TTS_MANAGER] - cache_key = manager.async_cache_message_in_memory( + cache = manager.async_cache_message_in_memory( **media_source_id_to_kwargs(media_source_id) ) - return await manager.async_get_tts_audio(cache_key) + data = b"".join([chunk async for chunk in cache.async_stream_data()]) + return cache.extension, data @callback @@ -216,18 +298,19 @@ def async_get_text_to_speech_languages(hass: HomeAssistant) -> set[str]: return languages -async def async_convert_audio( +async def _async_convert_audio( hass: HomeAssistant, from_extension: str, - audio_bytes: bytes, + audio_bytes_gen: AsyncGenerator[bytes], to_extension: str, to_sample_rate: int | None = None, to_sample_channels: int | None = None, to_sample_bytes: int | None = None, -) -> bytes: +) -> AsyncGenerator[bytes]: """Convert audio to a preferred format using ffmpeg.""" ffmpeg_manager = ffmpeg.get_ffmpeg_manager(hass) - return await hass.async_add_executor_job( + audio_bytes = b"".join([chunk async for chunk in audio_bytes_gen]) + data = await hass.async_add_executor_job( lambda: _convert_audio( ffmpeg_manager.binary, from_extension, @@ -238,6 +321,7 @@ async def async_convert_audio( to_sample_bytes=to_sample_bytes, ) ) + yield data def _convert_audio( @@ -401,32 +485,33 @@ class ResultStream: return f"/api/tts_proxy/{self.token}" @cached_property - def _result_cache_key(self) -> asyncio.Future[str]: - """Get the future that returns the cache key.""" + def _result_cache(self) -> asyncio.Future[TTSCache]: + """Get the future that returns the cache.""" return asyncio.Future() @callback - def async_set_message_cache_key(self, cache_key: str) -> None: - """Set cache key for message to be streamed.""" - self._result_cache_key.set_result(cache_key) + def async_set_message_cache(self, cache: TTSCache) -> None: + """Set cache containing message audio to be streamed.""" + self._result_cache.set_result(cache) @callback def async_set_message(self, message: str) -> None: """Set message to be generated.""" - cache_key = self._manager.async_cache_message_in_memory( - engine=self.engine, - message=message, - use_file_cache=self.use_file_cache, - language=self.language, - options=self.options, + self._result_cache.set_result( + self._manager.async_cache_message_in_memory( + engine=self.engine, + message=message, + use_file_cache=self.use_file_cache, + language=self.language, + options=self.options, + ) ) - self._result_cache_key.set_result(cache_key) async def async_stream_result(self) -> AsyncGenerator[bytes]: """Get the stream of this result.""" - cache_key = await self._result_cache_key - _extension, data = await self._manager.async_get_tts_audio(cache_key) - yield data + cache = await self._result_cache + async for chunk in cache.async_stream_data(): + yield chunk def _hash_options(options: dict) -> str: @@ -483,7 +568,7 @@ class MemcacheCleanup: now = monotonic() for cache_key, info in list(memcache.items()): - if info["last_used"] + maxage < now: + if info.last_used + maxage < now: _LOGGER.debug("Cleaning up %s", cache_key) del memcache[cache_key] @@ -638,15 +723,18 @@ class SpeechManager: if message is None: return result_stream - cache_key = self._async_ensure_cached_in_memory( - engine=engine, - engine_instance=engine_instance, - message=message, - use_file_cache=use_file_cache, - language=language, - options=options, + # We added this method as an alternative to stream.async_set_message + # to avoid the options being processed twice + result_stream.async_set_message_cache( + self._async_ensure_cached_in_memory( + engine=engine, + engine_instance=engine_instance, + message=message, + use_file_cache=use_file_cache, + language=language, + options=options, + ) ) - result_stream.async_set_message_cache_key(cache_key) return result_stream @@ -658,7 +746,7 @@ class SpeechManager: use_file_cache: bool | None = None, language: str | None = None, options: dict | None = None, - ) -> str: + ) -> TTSCache: """Make sure a message is cached in memory and returns cache key.""" if (engine_instance := get_engine_instance(self.hass, engine)) is None: raise HomeAssistantError(f"Provider {engine} not found") @@ -685,7 +773,7 @@ class SpeechManager: use_file_cache: bool, language: str, options: dict, - ) -> str: + ) -> TTSCache: """Ensure a message is cached. Requires options, language to be processed. @@ -697,62 +785,101 @@ class SpeechManager: ).lower() # Is speech already in memory - if cache_key in self.mem_cache: - return cache_key + if cache := self.mem_cache.get(cache_key): + _LOGGER.debug("Found audio in cache for %s", message[0:32]) + return cache - if use_file_cache and cache_key in self.file_cache: - coro = self._async_load_file_to_mem(cache_key) + store_to_disk = use_file_cache + + if use_file_cache and (filename := self.file_cache.get(cache_key)): + _LOGGER.debug("Loading audio from disk for %s", message[0:32]) + extension = os.path.splitext(filename)[1][1:] + data_gen = self._async_load_file(cache_key) + store_to_disk = False else: - coro = self._async_generate_tts_audio( - engine_instance, cache_key, message, use_file_cache, language, options + _LOGGER.debug("Generating audio for %s", message[0:32]) + extension = options.get(ATTR_PREFERRED_FORMAT, _DEFAULT_FORMAT) + data_gen = self._async_generate_tts_audio( + engine_instance, message, language, options ) - task = self.hass.async_create_task(coro, eager_start=False) + cache = TTSCache( + cache_key=cache_key, + extension=extension, + data_gen=data_gen, + ) - def handle_error(future: asyncio.Future) -> None: - """Handle error.""" - if not (err := future.exception()): - return + self.mem_cache[cache_key] = cache + self.hass.async_create_background_task( + self._load_data_into_cache( + cache, engine_instance, message, store_to_disk, language, options + ), + f"tts_load_data_into_cache_{engine_instance.name}", + ) + self.memcache_cleanup.schedule() + return cache + + async def _load_data_into_cache( + self, + cache: TTSCache, + engine_instance: TextToSpeechEntity | Provider, + message: str, + store_to_disk: bool, + language: str, + options: dict, + ) -> None: + """Load and process a finished loading TTS Cache.""" + try: + data = await cache.async_load_data() + except Exception as err: # pylint: disable=broad-except # noqa: BLE001 # Truncate message so we don't flood the logs. Cutting off at 32 chars # but since we add 3 dots to truncated message, we cut off at 35. trunc_msg = message if len(message) < 35 else f"{message[0:32]}…" - _LOGGER.error("Error generating audio for %s: %s", trunc_msg, err) - self.mem_cache.pop(cache_key, None) + _LOGGER.error("Error getting audio for %s: %s", trunc_msg, err) + self.mem_cache.pop(cache.cache_key, None) + return - task.add_done_callback(handle_error) + if not store_to_disk: + return - self.mem_cache[cache_key] = { - "extension": "", - "voice": b"", - "pending": task, - "last_used": monotonic(), - } - return cache_key + filename = f"{cache.cache_key}.{cache.extension}".lower() - async def async_get_tts_audio(self, cache_key: str) -> tuple[str, bytes]: - """Fetch TTS audio.""" - cached = self.mem_cache.get(cache_key) - if cached is None: - raise HomeAssistantError("Audio not cached") - if pending := cached.get("pending"): - await pending - cached = self.mem_cache[cache_key] - cached["last_used"] = monotonic() - return cached["extension"], cached["voice"] + # Validate filename + if not _RE_VOICE_FILE.match(filename) and not _RE_LEGACY_VOICE_FILE.match( + filename + ): + raise HomeAssistantError( + f"TTS filename '{filename}' from {engine_instance.name} is invalid!" + ) + + if cache.extension == "mp3": + name = ( + engine_instance.name if isinstance(engine_instance.name, str) else "-" + ) + data = self.write_tags(filename, data, name, message, language, options) + + voice_file = os.path.join(self.cache_dir, filename) + + def save_speech() -> None: + """Store speech to filesystem.""" + with open(voice_file, "wb") as speech: + speech.write(data) + + try: + await self.hass.async_add_executor_job(save_speech) + except OSError as err: + _LOGGER.error("Can't write %s: %s", filename, err) + else: + self.file_cache[cache.cache_key] = filename async def _async_generate_tts_audio( self, engine_instance: TextToSpeechEntity | Provider, - cache_key: str, message: str, - cache_to_disk: bool, language: str, options: dict[str, Any], - ) -> None: - """Start loading of the TTS audio. - - This method is a coroutine. - """ + ) -> AsyncGenerator[bytes]: + """Generate TTS audio from an engine.""" options = dict(options or {}) supported_options = engine_instance.supported_options or [] @@ -800,6 +927,17 @@ class SpeechManager: extension, data = await engine_instance.async_get_tts_audio( message, language, options ) + + if data is None or extension is None: + raise HomeAssistantError( + f"No TTS from {engine_instance.name} for '{message}'" + ) + + async def make_data_generator(data: bytes) -> AsyncGenerator[bytes]: + yield data + + data_gen = make_data_generator(data) + else: async def message_gen() -> AsyncGenerator[str]: @@ -809,12 +947,7 @@ class SpeechManager: TTSAudioRequest(language, options, message_gen()) ) extension = tts_result.extension - data = b"".join([chunk async for chunk in tts_result.data_gen]) - - if data is None or extension is None: - raise HomeAssistantError( - f"No TTS from {engine_instance.name} for '{message}'" - ) + data_gen = tts_result.data_gen # Only convert if we have a preferred format different than the # expected format from the TTS system, or if a specific sample @@ -827,62 +960,21 @@ class SpeechManager: ) if needs_conversion: - data = await async_convert_audio( + data_gen = _async_convert_audio( self.hass, extension, - data, + data_gen, to_extension=final_extension, to_sample_rate=sample_rate, to_sample_channels=sample_channels, to_sample_bytes=sample_bytes, ) - # Create file infos - filename = f"{cache_key}.{final_extension}".lower() + async for chunk in data_gen: + yield chunk - # Validate filename - if not _RE_VOICE_FILE.match(filename) and not _RE_LEGACY_VOICE_FILE.match( - filename - ): - raise HomeAssistantError( - f"TTS filename '{filename}' from {engine_instance.name} is invalid!" - ) - - # Save to memory - if final_extension == "mp3": - data = self.write_tags( - filename, data, engine_instance.name, message, language, options - ) - - self._async_store_to_memcache(cache_key, final_extension, data) - - if not cache_to_disk: - return - - voice_file = os.path.join(self.cache_dir, filename) - - def save_speech() -> None: - """Store speech to filesystem.""" - with open(voice_file, "wb") as speech: - speech.write(data) - - # Don't await, we're going to do this in the background - task = self.hass.async_add_executor_job(save_speech) - - def write_done(future: asyncio.Future) -> None: - """Write is done task.""" - if err := future.exception(): - _LOGGER.error("Can't write %s: %s", filename, err) - else: - self.file_cache[cache_key] = filename - - task.add_done_callback(write_done) - - async def _async_load_file_to_mem(self, cache_key: str) -> None: - """Load voice from file cache into memory. - - This method is a coroutine. - """ + async def _async_load_file(self, cache_key: str) -> AsyncGenerator[bytes]: + """Load TTS audio from disk.""" if not (filename := self.file_cache.get(cache_key)): raise HomeAssistantError(f"Key {cache_key} not in file cache!") @@ -899,22 +991,7 @@ class SpeechManager: del self.file_cache[cache_key] raise HomeAssistantError(f"Can't read {voice_file}") from err - extension = os.path.splitext(filename)[1][1:] - - self._async_store_to_memcache(cache_key, extension, data) - - @callback - def _async_store_to_memcache( - self, cache_key: str, extension: str, data: bytes - ) -> None: - """Store data to memcache and set timer to remove it.""" - self.mem_cache[cache_key] = { - "extension": extension, - "voice": data, - "pending": None, - "last_used": monotonic(), - } - self.memcache_cleanup.schedule() + yield data @staticmethod def write_tags( diff --git a/tests/components/tts/test_init.py b/tests/components/tts/test_init.py index 8bdd17cf3e9..be14e006610 100644 --- a/tests/components/tts/test_init.py +++ b/tests/components/tts/test_init.py @@ -168,7 +168,7 @@ async def test_service( assert await get_media_source_url( hass, calls[0].data[ATTR_MEDIA_CONTENT_ID] ) == ("/api/tts_proxy/test_token.mp3") - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert ( mock_tts_cache_dir / f"42f18378fd4393d18c8dd11d03fa9563c1e54491_en-us_-_{expected_url_suffix}.mp3" @@ -230,7 +230,7 @@ async def test_service_default_language( assert await get_media_source_url( hass, calls[0].data[ATTR_MEDIA_CONTENT_ID] ) == ("/api/tts_proxy/test_token.mp3") - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert ( mock_tts_cache_dir / ( @@ -294,7 +294,7 @@ async def test_service_default_special_language( assert await get_media_source_url( hass, calls[0].data[ATTR_MEDIA_CONTENT_ID] ) == ("/api/tts_proxy/test_token.mp3") - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert ( mock_tts_cache_dir / f"42f18378fd4393d18c8dd11d03fa9563c1e54491_en-us_-_{expected_url_suffix}.mp3" @@ -354,7 +354,7 @@ async def test_service_language( assert await get_media_source_url( hass, calls[0].data[ATTR_MEDIA_CONTENT_ID] ) == ("/api/tts_proxy/test_token.mp3") - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert ( mock_tts_cache_dir / f"42f18378fd4393d18c8dd11d03fa9563c1e54491_de-de_-_{expected_url_suffix}.mp3" @@ -470,7 +470,7 @@ async def test_service_options( assert await get_media_source_url( hass, calls[0].data[ATTR_MEDIA_CONTENT_ID] ) == ("/api/tts_proxy/test_token.mp3") - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert ( mock_tts_cache_dir / ( @@ -554,7 +554,7 @@ async def test_service_default_options( assert await get_media_source_url( hass, calls[0].data[ATTR_MEDIA_CONTENT_ID] ) == ("/api/tts_proxy/test_token.mp3") - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert ( mock_tts_cache_dir / ( @@ -628,7 +628,7 @@ async def test_merge_default_service_options( assert await get_media_source_url( hass, calls[0].data[ATTR_MEDIA_CONTENT_ID] ) == ("/api/tts_proxy/test_token.mp3") - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert ( mock_tts_cache_dir / ( @@ -743,7 +743,7 @@ async def test_service_clear_cache( # To make sure the file is persisted assert len(calls) == 1 await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert ( mock_tts_cache_dir / f"42f18378fd4393d18c8dd11d03fa9563c1e54491_en-us_-_{expected_url_suffix}.mp3" @@ -1769,9 +1769,15 @@ async def test_async_convert_audio_error(hass: HomeAssistant) -> None: """Test that ffmpeg failing during audio conversion will raise an error.""" assert await async_setup_component(hass, ffmpeg.DOMAIN, {}) - with pytest.raises(RuntimeError): + async def bad_data_gen(): + yield bytes(0) + + with pytest.raises(RuntimeError): # noqa: PT012 # Simulate a bad WAV file - await tts.async_convert_audio(hass, "wav", bytes(0), "mp3") + async for _chunk in tts._async_convert_audio( + hass, "wav", bad_data_gen(), "mp3" + ): + pass async def test_default_engine_prefer_entity( @@ -1846,3 +1852,86 @@ async def test_stream(hass: HomeAssistant, mock_tts_entity: MockTTSEntity) -> No assert stream2.extension == "wav" result_data = b"".join([chunk async for chunk in stream2.async_stream_result()]) assert result_data == data + + +async def test_tts_cache() -> None: + """Test TTSCache.""" + + async def data_gen(queue: asyncio.Queue[bytes | None | Exception]): + while chunk := await queue.get(): + if isinstance(chunk, Exception): + raise chunk + yield chunk + + queue = asyncio.Queue() + cache = tts.TTSCache("test-key", "mp3", data_gen(queue)) + assert cache.cache_key == "test-key" + assert cache.extension == "mp3" + + for i in range(10): + queue.put_nowait(f"{i}".encode()) + queue.put_nowait(None) + + assert await cache.async_load_data() == b"0123456789" + + with pytest.raises(RuntimeError): + await cache.async_load_data() + + # When data is loaded, we get it all in 1 chunk + cur = 0 + async for chunk in cache.async_stream_data(): + assert chunk == b"0123456789" + cur += 1 + assert cur == 1 + + # Show we can stream the data while it's still being generated + async def consume_cache(cache: tts.TTSCache): + return b"".join([chunk async for chunk in cache.async_stream_data()]) + + queue = asyncio.Queue() + cache = tts.TTSCache("test-key", "mp3", data_gen(queue)) + + load_data_task = asyncio.create_task(cache.async_load_data()) + consume_pre_data_loaded_task = asyncio.create_task(consume_cache(cache)) + queue.put_nowait(b"0") + await asyncio.sleep(0) + queue.put_nowait(b"1") + await asyncio.sleep(0) + consume_mid_data_task = asyncio.create_task(consume_cache(cache)) + queue.put_nowait(b"2") + await asyncio.sleep(0) + queue.put_nowait(None) + consume_post_data_loaded_task = asyncio.create_task(consume_cache(cache)) + await asyncio.sleep(0) + assert await load_data_task == b"012" + assert await consume_post_data_loaded_task == b"012" + assert await consume_mid_data_task == b"012" + assert await consume_pre_data_loaded_task == b"012" + + # Now with errors + async def consume_cache(cache: tts.TTSCache): + return b"".join([chunk async for chunk in cache.async_stream_data()]) + + queue = asyncio.Queue() + cache = tts.TTSCache("test-key", "mp3", data_gen(queue)) + + load_data_task = asyncio.create_task(cache.async_load_data()) + consume_pre_data_loaded_task = asyncio.create_task(consume_cache(cache)) + queue.put_nowait(b"0") + await asyncio.sleep(0) + queue.put_nowait(b"1") + await asyncio.sleep(0) + consume_mid_data_task = asyncio.create_task(consume_cache(cache)) + queue.put_nowait(ValueError("Boom!")) + await asyncio.sleep(0) + queue.put_nowait(None) + consume_post_data_loaded_task = asyncio.create_task(consume_cache(cache)) + await asyncio.sleep(0) + with pytest.raises(ValueError): + assert await load_data_task == b"012" + with pytest.raises(ValueError): + assert await consume_post_data_loaded_task == b"012" + with pytest.raises(ValueError): + assert await consume_mid_data_task == b"012" + with pytest.raises(ValueError): + assert await consume_pre_data_loaded_task == b"012" diff --git a/tests/components/wyoming/test_tts.py b/tests/components/wyoming/test_tts.py index 263804787b1..73fb68b44e5 100644 --- a/tests/components/wyoming/test_tts.py +++ b/tests/components/wyoming/test_tts.py @@ -150,17 +150,15 @@ async def test_get_tts_audio_connection_lost( hass: HomeAssistant, init_wyoming_tts ) -> None: """Test streaming audio and losing connection.""" - with ( - patch( - "homeassistant.components.wyoming.tts.AsyncTcpClient", - MockAsyncTcpClient([None]), - ), - pytest.raises(HomeAssistantError), + stream = tts.async_create_stream(hass, "tts.test_tts", "en-US") + with patch( + "homeassistant.components.wyoming.tts.AsyncTcpClient", + MockAsyncTcpClient([None]), ): - await tts.async_get_media_source_audio( - hass, - tts.generate_media_source_id(hass, "Hello world", "tts.test_tts", "en-US"), - ) + stream.async_set_message("Hello world") + with pytest.raises(HomeAssistantError): # noqa: PT012 + async for _chunk in stream.async_stream_result(): + pass async def test_get_tts_audio_audio_oserror( From d5af542dd1fa4cda2b7e6870e205a17b79c845bb Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Thu, 13 Mar 2025 18:32:45 +0100 Subject: [PATCH 2563/3148] Add parallel updates to Vodafone Station (#140532) --- homeassistant/components/vodafone_station/button.py | 3 +++ homeassistant/components/vodafone_station/device_tracker.py | 3 +++ homeassistant/components/vodafone_station/sensor.py | 3 +++ 3 files changed, 9 insertions(+) diff --git a/homeassistant/components/vodafone_station/button.py b/homeassistant/components/vodafone_station/button.py index 9227611ce22..5c98c3241e9 100644 --- a/homeassistant/components/vodafone_station/button.py +++ b/homeassistant/components/vodafone_station/button.py @@ -19,6 +19,9 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import _LOGGER from .coordinator import VodafoneConfigEntry, VodafoneStationRouter +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + @dataclass(frozen=True, kw_only=True) class VodafoneStationEntityDescription(ButtonEntityDescription): diff --git a/homeassistant/components/vodafone_station/device_tracker.py b/homeassistant/components/vodafone_station/device_tracker.py index 984355287a4..4efa26cda8c 100644 --- a/homeassistant/components/vodafone_station/device_tracker.py +++ b/homeassistant/components/vodafone_station/device_tracker.py @@ -15,6 +15,9 @@ from .coordinator import ( VodafoneStationRouter, ) +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + async def async_setup_entry( hass: HomeAssistant, diff --git a/homeassistant/components/vodafone_station/sensor.py b/homeassistant/components/vodafone_station/sensor.py index bdb429aa6dd..2573864330d 100644 --- a/homeassistant/components/vodafone_station/sensor.py +++ b/homeassistant/components/vodafone_station/sensor.py @@ -20,6 +20,9 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import _LOGGER, LINE_TYPES from .coordinator import VodafoneConfigEntry, VodafoneStationRouter +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + NOT_AVAILABLE: list = ["", "N/A", "0.0.0.0"] UPTIME_DEVIATION = 60 From 8ea2d40467c4667fef32941765b8f5334520c923 Mon Sep 17 00:00:00 2001 From: Dan Raper Date: Thu, 13 Mar 2025 18:57:05 +0000 Subject: [PATCH 2564/3148] Bump ohmepy to 1.4.1 (#140535) --- homeassistant/components/ohme/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ohme/manifest.json b/homeassistant/components/ohme/manifest.json index f31af213387..f0021808d92 100644 --- a/homeassistant/components/ohme/manifest.json +++ b/homeassistant/components/ohme/manifest.json @@ -7,5 +7,5 @@ "integration_type": "device", "iot_class": "cloud_polling", "quality_scale": "silver", - "requirements": ["ohme==1.4.0"] + "requirements": ["ohme==1.4.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 11a9df4ba16..947e025115c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1553,7 +1553,7 @@ odp-amsterdam==6.0.2 oemthermostat==1.1.1 # homeassistant.components.ohme -ohme==1.4.0 +ohme==1.4.1 # homeassistant.components.ollama ollama==0.4.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7769e8e824f..6d9f549be38 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1301,7 +1301,7 @@ objgraph==3.5.0 odp-amsterdam==6.0.2 # homeassistant.components.ohme -ohme==1.4.0 +ohme==1.4.1 # homeassistant.components.ollama ollama==0.4.7 From fa57d572154f3c3c53e881e75c5370c59750db36 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Thu, 13 Mar 2025 19:58:09 +0100 Subject: [PATCH 2565/3148] Fix Shelly diagnostics for devices without WebSocket Outbound support (#140501) * Don't assume that `ws` is always in config * Fix device --- homeassistant/components/shelly/diagnostics.py | 14 ++++++++------ tests/components/shelly/test_diagnostics.py | 18 ++++++++++++++++++ 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/shelly/diagnostics.py b/homeassistant/components/shelly/diagnostics.py index cac2bb2f16b..2a9699e0a08 100644 --- a/homeassistant/components/shelly/diagnostics.py +++ b/homeassistant/components/shelly/diagnostics.py @@ -79,12 +79,14 @@ async def async_get_config_entry_diagnostics( device_settings = { k: v for k, v in rpc_coordinator.device.config.items() if k in ["cloud"] } - ws_config = rpc_coordinator.device.config["ws"] - device_settings["ws_outbound_enabled"] = ws_config["enable"] - if ws_config["enable"]: - device_settings["ws_outbound_server_valid"] = bool( - ws_config["server"] == get_rpc_ws_url(hass) - ) + if not (ws_config := rpc_coordinator.device.config.get("ws", {})): + device_settings["ws_outbound"] = "not supported" + if (ws_outbound_enabled := ws_config.get("enable")) is not None: + device_settings["ws_outbound_enabled"] = ws_outbound_enabled + if ws_outbound_enabled: + device_settings["ws_outbound_server_valid"] = bool( + ws_config["server"] == get_rpc_ws_url(hass) + ) device_status = { k: v for k, v in rpc_coordinator.device.status.items() diff --git a/tests/components/shelly/test_diagnostics.py b/tests/components/shelly/test_diagnostics.py index 3826631c580..d89f21f5992 100644 --- a/tests/components/shelly/test_diagnostics.py +++ b/tests/components/shelly/test_diagnostics.py @@ -194,3 +194,21 @@ async def test_rpc_config_entry_diagnostics_ws_outbound( result["device_settings"]["ws_outbound_server_valid"] == ws_outbound_server_valid ) + + +async def test_rpc_config_entry_diagnostics_no_ws( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_rpc_device: Mock, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test config entry diagnostics for rpc device which doesn't support ws outbound.""" + config = deepcopy(mock_rpc_device.config) + config.pop("ws") + monkeypatch.setattr(mock_rpc_device, "config", config) + + entry = await init_integration(hass, 3) + + result = await get_diagnostics_for_config_entry(hass, hass_client, entry) + + assert result["device_settings"]["ws_outbound"] == "not supported" From 87f726141a485704236e2def9ec9d99800b57d7c Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Thu, 13 Mar 2025 21:41:45 +0200 Subject: [PATCH 2566/3148] Fix ollama history trimming test (#140538) --- tests/components/ollama/test_conversation.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/components/ollama/test_conversation.py b/tests/components/ollama/test_conversation.py index db641ba703b..c718aab1e81 100644 --- a/tests/components/ollama/test_conversation.py +++ b/tests/components/ollama/test_conversation.py @@ -4,6 +4,7 @@ from collections.abc import AsyncGenerator from typing import Any from unittest.mock import AsyncMock, Mock, patch +from freezegun.api import FrozenDateTimeFactory from ollama import Message, ResponseError import pytest from syrupy.assertion import SnapshotAssertion @@ -404,7 +405,10 @@ async def test_unknown_hass_api( async def test_message_history_trimming( - hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_init_component + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_init_component, + freezer: FrozenDateTimeFactory, ) -> None: """Test that a single message history is trimmed according to the config.""" response_idx = 0 From 474d427b879d2a923433bc09f03f17f48f1b4cab Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 13 Mar 2025 10:01:41 -1000 Subject: [PATCH 2567/3148] Bump bleak-esphome to 2.12.0 (#140543) changelog: https://github.com/Bluetooth-Devices/bleak-esphome/compare/v2.11.0...v2.12.0 --- homeassistant/components/eq3btsmart/manifest.json | 2 +- homeassistant/components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/eq3btsmart/manifest.json b/homeassistant/components/eq3btsmart/manifest.json index 4b65852d205..ab62c962982 100644 --- a/homeassistant/components/eq3btsmart/manifest.json +++ b/homeassistant/components/eq3btsmart/manifest.json @@ -22,5 +22,5 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["eq3btsmart"], - "requirements": ["eq3btsmart==1.4.1", "bleak-esphome==2.11.0"] + "requirements": ["eq3btsmart==1.4.1", "bleak-esphome==2.12.0"] } diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 6783b05fa0f..8d1cafee926 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -18,7 +18,7 @@ "requirements": [ "aioesphomeapi==29.6.0", "esphome-dashboard-api==1.2.3", - "bleak-esphome==2.11.0" + "bleak-esphome==2.12.0" ], "zeroconf": ["_esphomelib._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 947e025115c..5331fdb6800 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -603,7 +603,7 @@ bizkaibus==0.1.1 # homeassistant.components.eq3btsmart # homeassistant.components.esphome -bleak-esphome==2.11.0 +bleak-esphome==2.12.0 # homeassistant.components.bluetooth bleak-retry-connector==3.9.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6d9f549be38..31d99827de1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -534,7 +534,7 @@ bimmer-connected[china]==0.17.2 # homeassistant.components.eq3btsmart # homeassistant.components.esphome -bleak-esphome==2.11.0 +bleak-esphome==2.12.0 # homeassistant.components.bluetooth bleak-retry-connector==3.9.0 From cdead8661d7c0a8fbdfd51f3b5039af5b1d30a21 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Thu, 13 Mar 2025 21:27:00 +0100 Subject: [PATCH 2568/3148] Add lawn mower support to HomeKit (#140438) Add lawn mower support to homekit --- .../components/homekit/accessories.py | 8 ++ .../components/homekit/config_flow.py | 2 + .../components/homekit/type_switches.py | 29 +++++++ .../components/homekit/test_type_switches.py | 75 +++++++++++++++++++ 4 files changed, 114 insertions(+) diff --git a/homeassistant/components/homekit/accessories.py b/homeassistant/components/homekit/accessories.py index 8d10387e239..0d810d6986d 100644 --- a/homeassistant/components/homekit/accessories.py +++ b/homeassistant/components/homekit/accessories.py @@ -15,6 +15,7 @@ from pyhap.service import Service from pyhap.util import callback as pyhap_callback from homeassistant.components.cover import CoverDeviceClass, CoverEntityFeature +from homeassistant.components.lawn_mower import LawnMowerEntityFeature from homeassistant.components.media_player import MediaPlayerDeviceClass from homeassistant.components.remote import RemoteEntityFeature from homeassistant.components.sensor import SensorDeviceClass @@ -250,6 +251,13 @@ def get_accessory( # noqa: C901 elif state.domain == "vacuum": a_type = "Vacuum" + elif ( + state.domain == "lawn_mower" + and features & LawnMowerEntityFeature.DOCK + and features & LawnMowerEntityFeature.START_MOWING + ): + a_type = "LawnMower" + elif state.domain == "remote" and features & RemoteEntityFeature.ACTIVITY: a_type = "ActivityRemote" diff --git a/homeassistant/components/homekit/config_flow.py b/homeassistant/components/homekit/config_flow.py index 53db7774821..0ef2e8563bc 100644 --- a/homeassistant/components/homekit/config_flow.py +++ b/homeassistant/components/homekit/config_flow.py @@ -106,6 +106,7 @@ SUPPORTED_DOMAINS = [ "sensor", "switch", "vacuum", + "lawn_mower", "water_heater", VALVE_DOMAIN, ] @@ -123,6 +124,7 @@ DEFAULT_DOMAINS = [ REMOTE_DOMAIN, "switch", "vacuum", + "lawn_mower", "water_heater", ] diff --git a/homeassistant/components/homekit/type_switches.py b/homeassistant/components/homekit/type_switches.py index 0482a5956ac..8c6fc1ed672 100644 --- a/homeassistant/components/homekit/type_switches.py +++ b/homeassistant/components/homekit/type_switches.py @@ -16,6 +16,12 @@ from pyhap.const import ( from homeassistant.components import button, input_button from homeassistant.components.input_select import ATTR_OPTIONS, SERVICE_SELECT_OPTION +from homeassistant.components.lawn_mower import ( + DOMAIN as LAWN_MOWER_DOMAIN, + SERVICE_DOCK, + SERVICE_START_MOWING, + LawnMowerActivity, +) from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.components.vacuum import ( DOMAIN as VACUUM_DOMAIN, @@ -218,6 +224,29 @@ class Vacuum(Switch): self.char_on.set_value(current_state) +@TYPES.register("LawnMower") +class LawnMower(Switch): + """Generate a Switch accessory.""" + + def set_state(self, value: bool) -> None: + """Move switch state to value if call came from HomeKit.""" + _LOGGER.debug("%s: Set switch state to %s", self.entity_id, value) + state = self.hass.states.get(self.entity_id) + assert state + + service = SERVICE_START_MOWING if value else SERVICE_DOCK + self.async_call_service( + LAWN_MOWER_DOMAIN, service, {ATTR_ENTITY_ID: self.entity_id} + ) + + @callback + def async_update_state(self, new_state: State) -> None: + """Update switch state after state changed.""" + current_state = new_state.state in (LawnMowerActivity.MOWING, STATE_ON) + _LOGGER.debug("%s: Set current state to %s", self.entity_id, current_state) + self.char_on.set_value(current_state) + + class ValveBase(HomeAccessory): """Valve base class.""" diff --git a/tests/components/homekit/test_type_switches.py b/tests/components/homekit/test_type_switches.py index 141141e7f15..6a30877a795 100644 --- a/tests/components/homekit/test_type_switches.py +++ b/tests/components/homekit/test_type_switches.py @@ -12,6 +12,7 @@ from homeassistant.components.homekit.const import ( TYPE_VALVE, ) from homeassistant.components.homekit.type_switches import ( + LawnMower, Outlet, SelectSwitch, Switch, @@ -19,6 +20,13 @@ from homeassistant.components.homekit.type_switches import ( Valve, ValveSwitch, ) +from homeassistant.components.lawn_mower import ( + DOMAIN as LAWN_MOWER_DOMAIN, + SERVICE_DOCK, + SERVICE_START_MOWING, + LawnMowerActivity, + LawnMowerEntityFeature, +) from homeassistant.components.select import ATTR_OPTIONS from homeassistant.components.vacuum import ( DOMAIN as VACUUM_DOMAIN, @@ -383,6 +391,73 @@ async def test_vacuum_set_state_without_returnhome_and_start_support( assert events[-1].data[ATTR_VALUE] is None +async def test_lawn_mower_set_state( + hass: HomeAssistant, hk_driver, events: list[Event] +) -> None: + """Test if Lawn mower accessory and HA are updated accordingly.""" + entity_id = "lawn_mower.mower" + + hass.states.async_set( + entity_id, + None, + { + ATTR_SUPPORTED_FEATURES: LawnMowerEntityFeature.DOCK + | LawnMowerEntityFeature.START_MOWING + }, + ) + await hass.async_block_till_done() + + acc = LawnMower(hass, hk_driver, "LawnMower", entity_id, 2, None) + acc.run() + await hass.async_block_till_done() + assert acc.aid == 2 + assert acc.category == 8 # Switch + + assert acc.char_on.value == 0 + + hass.states.async_set( + entity_id, + LawnMowerActivity.MOWING, + { + ATTR_SUPPORTED_FEATURES: LawnMowerEntityFeature.DOCK + | LawnMowerEntityFeature.START_MOWING + }, + ) + await hass.async_block_till_done() + assert acc.char_on.value == 1 + + hass.states.async_set( + entity_id, + LawnMowerActivity.DOCKED, + { + ATTR_SUPPORTED_FEATURES: LawnMowerEntityFeature.DOCK + | LawnMowerEntityFeature.START_MOWING + }, + ) + await hass.async_block_till_done() + assert acc.char_on.value == 0 + + # Set from HomeKit + call_turn_on = async_mock_service(hass, LAWN_MOWER_DOMAIN, SERVICE_START_MOWING) + call_turn_off = async_mock_service(hass, LAWN_MOWER_DOMAIN, SERVICE_DOCK) + + acc.char_on.client_update_value(1) + await hass.async_block_till_done() + assert acc.char_on.value == 1 + assert call_turn_on + assert call_turn_on[0].data[ATTR_ENTITY_ID] == entity_id + assert len(events) == 1 + assert events[-1].data[ATTR_VALUE] is None + + acc.char_on.client_update_value(0) + await hass.async_block_till_done() + assert acc.char_on.value == 0 + assert call_turn_off + assert call_turn_off[0].data[ATTR_ENTITY_ID] == entity_id + assert len(events) == 2 + assert events[-1].data[ATTR_VALUE] is None + + async def test_reset_switch( hass: HomeAssistant, hk_driver, events: list[Event] ) -> None: From b48ab77a38efc1df8df3680eaac373a02c2564a0 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Thu, 13 Mar 2025 23:02:26 +0100 Subject: [PATCH 2569/3148] Fix call on root logger (LOG015) (#140556) --- homeassistant/components/point/config_flow.py | 4 +++- homeassistant/components/sky_remote/config_flow.py | 8 +++++--- tests/components/stream/conftest.py | 6 ++++-- tests/components/stream/test_worker.py | 4 +++- 4 files changed, 15 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/point/config_flow.py b/homeassistant/components/point/config_flow.py index a0a51c7b9e6..b26ade8b725 100644 --- a/homeassistant/components/point/config_flow.py +++ b/homeassistant/components/point/config_flow.py @@ -11,6 +11,8 @@ from homeassistant.helpers.config_entry_oauth2_flow import AbstractOAuth2FlowHan from .const import DOMAIN +_LOGGER = logging.getLogger(__name__) + class OAuth2FlowHandler(AbstractOAuth2FlowHandler, domain=DOMAIN): """Config flow to handle Minut Point OAuth2 authentication.""" @@ -56,7 +58,7 @@ class OAuth2FlowHandler(AbstractOAuth2FlowHandler, domain=DOMAIN): if reauth_entry.unique_id is not None: self._abort_if_unique_id_mismatch(reason="wrong_account") - logging.debug("user_id: %s", user_id) + _LOGGER.debug("user_id: %s", user_id) return self.async_update_reload_and_abort( reauth_entry, data_updates=data, unique_id=user_id ) diff --git a/homeassistant/components/sky_remote/config_flow.py b/homeassistant/components/sky_remote/config_flow.py index 13cddf99332..51cf9c9bf64 100644 --- a/homeassistant/components/sky_remote/config_flow.py +++ b/homeassistant/components/sky_remote/config_flow.py @@ -12,6 +12,8 @@ from homeassistant.helpers import config_validation as cv from .const import DEFAULT_PORT, DOMAIN, LEGACY_PORT +_LOGGER = logging.getLogger(__name__) + DATA_SCHEMA = vol.Schema( { vol.Required(CONF_HOST): cv.string, @@ -21,7 +23,7 @@ DATA_SCHEMA = vol.Schema( async def async_find_box_port(host: str) -> int: """Find port box uses for communication.""" - logging.debug("Attempting to find port to connect to %s on", host) + _LOGGER.debug("Attempting to find port to connect to %s on", host) remote = RemoteControl(host, DEFAULT_PORT) try: await remote.check_connectable() @@ -46,12 +48,12 @@ class SkyRemoteConfigFlow(ConfigFlow, domain=DOMAIN): errors: dict[str, str] = {} if user_input is not None: - logging.debug("user_input: %s", user_input) + _LOGGER.debug("user_input: %s", user_input) self._async_abort_entries_match(user_input) try: port = await async_find_box_port(user_input[CONF_HOST]) except SkyBoxConnectionError: - logging.exception("while finding port of skybox") + _LOGGER.exception("While finding port of skybox") errors["base"] = "cannot_connect" else: return self.async_create_entry( diff --git a/tests/components/stream/conftest.py b/tests/components/stream/conftest.py index 39e4de13fed..296505271c0 100644 --- a/tests/components/stream/conftest.py +++ b/tests/components/stream/conftest.py @@ -27,6 +27,8 @@ from homeassistant.components.stream.worker import StreamState from .common import generate_h264_video, stream_teardown +_LOGGER = logging.getLogger(__name__) + TEST_TIMEOUT = 7.0 # Lower than 9s home assistant timeout @@ -44,7 +46,7 @@ class WorkerSync: def resume(self): """Allow the worker thread to finalize the stream.""" - logging.debug("waking blocked worker") + _LOGGER.debug("waking blocked worker") self._event.set() def blocking_discontinuity(self, stream_state: StreamState): @@ -52,7 +54,7 @@ class WorkerSync: # Worker is ending the stream, which clears all output buffers. # Block the worker thread until the test has a chance to verify # the segments under test. - logging.debug("blocking worker") + _LOGGER.debug("blocking worker") if self._event: self._event.wait() diff --git a/tests/components/stream/test_worker.py b/tests/components/stream/test_worker.py index 2be972cc6a2..276b4109652 100644 --- a/tests/components/stream/test_worker.py +++ b/tests/components/stream/test_worker.py @@ -56,6 +56,8 @@ from .test_ll_hls import TEST_PART_DURATION from tests.components.camera.common import EMPTY_8_6_JPEG, mock_turbo_jpeg +_LOGGER = logging.getLogger(__name__) + STREAM_SOURCE = "some-stream-source" # Formats here are arbitrary, not exercised by tests AUDIO_STREAM_FORMAT = "mp3" @@ -229,7 +231,7 @@ class FakePyAvBuffer: return def mux(self, packet): - logging.debug("Muxed packet: %s", packet) + _LOGGER.debug("Muxed packet: %s", packet) self.capture_packets.append(packet) def __str__(self) -> str: From 5cf3bea8fe79c89d2ec750535996ec08d1819b09 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Thu, 13 Mar 2025 23:32:00 +0100 Subject: [PATCH 2570/3148] Fix unnecessary-dict-comprehension-for-iterable (C420) (#140555) --- homeassistant/components/isy994/sensor.py | 6 +-- homeassistant/components/knx/__init__.py | 2 +- homeassistant/components/knx/services.py | 2 +- homeassistant/components/logger/helpers.py | 2 +- .../components/netatmo/config_flow.py | 2 +- .../components/onewire/config_flow.py | 7 +-- .../recorder/auto_repairs/schema.py | 4 +- .../components/recorder/statistics.py | 47 +++++++++---------- homeassistant/components/risco/const.py | 6 +-- .../components/solarlog/coordinator.py | 2 +- .../components/telegram_bot/__init__.py | 2 +- .../components/tesla_fleet/coordinator.py | 2 +- .../components/teslemetry/coordinator.py | 2 +- .../components/ukraine_alarm/coordinator.py | 2 +- .../components/xiaomi_miio/button.py | 2 +- tests/components/conftest.py | 2 +- tests/components/harmony/test_subscriber.py | 2 +- tests/components/nws/const.py | 4 +- tests/components/stream/test_ll_hls.py | 2 +- 19 files changed, 46 insertions(+), 54 deletions(-) diff --git a/homeassistant/components/isy994/sensor.py b/homeassistant/components/isy994/sensor.py index 2655f4d3c4e..2d27f4602c6 100644 --- a/homeassistant/components/isy994/sensor.py +++ b/homeassistant/components/isy994/sensor.py @@ -97,9 +97,9 @@ ISY_CONTROL_TO_DEVICE_CLASS = { "WEIGHT": SensorDeviceClass.WEIGHT, "WINDCH": SensorDeviceClass.TEMPERATURE, } -ISY_CONTROL_TO_STATE_CLASS = { - control: SensorStateClass.MEASUREMENT for control in ISY_CONTROL_TO_DEVICE_CLASS -} +ISY_CONTROL_TO_STATE_CLASS = dict.fromkeys( + ISY_CONTROL_TO_DEVICE_CLASS, SensorStateClass.MEASUREMENT +) ISY_CONTROL_TO_ENTITY_CATEGORY = { PROP_RAMP_RATE: EntityCategory.DIAGNOSTIC, PROP_ON_LEVEL: EntityCategory.DIAGNOSTIC, diff --git a/homeassistant/components/knx/__init__.py b/homeassistant/components/knx/__init__.py index fa3439b02f4..8ad16642e45 100644 --- a/homeassistant/components/knx/__init__.py +++ b/homeassistant/components/knx/__init__.py @@ -486,7 +486,7 @@ class KNXModule: transcoder := DPTBase.parse_transcoder(dpt) ): self._address_filter_transcoder.update( - {_filter: transcoder for _filter in _filters} + dict.fromkeys(_filters, transcoder) ) return self.xknx.telegram_queue.register_telegram_received_cb( diff --git a/homeassistant/components/knx/services.py b/homeassistant/components/knx/services.py index f0f760180f4..fc28e0850ed 100644 --- a/homeassistant/components/knx/services.py +++ b/homeassistant/components/knx/services.py @@ -126,7 +126,7 @@ async def service_event_register_modify(call: ServiceCall) -> None: transcoder := DPTBase.parse_transcoder(dpt) ): knx_module.group_address_transcoder.update( - {_address: transcoder for _address in group_addresses} + dict.fromkeys(group_addresses, transcoder) ) for group_address in group_addresses: if group_address in knx_module.knx_event_callback.group_addresses: diff --git a/homeassistant/components/logger/helpers.py b/homeassistant/components/logger/helpers.py index 034266428a3..00cea7e8aa5 100644 --- a/homeassistant/components/logger/helpers.py +++ b/homeassistant/components/logger/helpers.py @@ -203,7 +203,7 @@ class LoggerSettings: else: loggers = {domain} - combined_logs = {logger: LOGSEVERITY[settings.level] for logger in loggers} + combined_logs = dict.fromkeys(loggers, LOGSEVERITY[settings.level]) # Don't override the log levels with the ones from YAML # since we want whatever the user is asking for to be honored. diff --git a/homeassistant/components/netatmo/config_flow.py b/homeassistant/components/netatmo/config_flow.py index d853694ffea..02d9c2fa3a6 100644 --- a/homeassistant/components/netatmo/config_flow.py +++ b/homeassistant/components/netatmo/config_flow.py @@ -135,7 +135,7 @@ class NetatmoOptionsFlowHandler(OptionsFlow): vol.Optional( CONF_WEATHER_AREAS, default=weather_areas, - ): cv.multi_select({wa: None for wa in weather_areas}), + ): cv.multi_select(dict.fromkeys(weather_areas)), vol.Optional(CONF_NEW_AREA): str, } ) diff --git a/homeassistant/components/onewire/config_flow.py b/homeassistant/components/onewire/config_flow.py index 8a5623772f7..2099d9aabb5 100644 --- a/homeassistant/components/onewire/config_flow.py +++ b/homeassistant/components/onewire/config_flow.py @@ -234,12 +234,7 @@ class OnewireOptionsFlowHandler(OptionsFlow): INPUT_ENTRY_DEVICE_SELECTION, default=self._get_current_configured_sensors(), description="Multiselect with list of devices to choose from", - ): cv.multi_select( - { - friendly_name: False - for friendly_name in self.configurable_devices - } - ), + ): cv.multi_select(dict.fromkeys(self.configurable_devices, False)), } ), errors=errors, diff --git a/homeassistant/components/recorder/auto_repairs/schema.py b/homeassistant/components/recorder/auto_repairs/schema.py index 1373f466bc2..cf3addd4f20 100644 --- a/homeassistant/components/recorder/auto_repairs/schema.py +++ b/homeassistant/components/recorder/auto_repairs/schema.py @@ -175,7 +175,7 @@ def _validate_db_schema_precision( # Mark the session as read_only to ensure that the test data is not committed # to the database and we always rollback when the scope is exited with session_scope(session=instance.get_session(), read_only=True) as session: - db_object = table_object(**{column: PRECISE_NUMBER for column in columns}) + db_object = table_object(**dict.fromkeys(columns, PRECISE_NUMBER)) table = table_object.__tablename__ try: session.add(db_object) @@ -184,7 +184,7 @@ def _validate_db_schema_precision( _check_columns( schema_errors=schema_errors, stored={column: getattr(db_object, column) for column in columns}, - expected={column: PRECISE_NUMBER for column in columns}, + expected=dict.fromkeys(columns, PRECISE_NUMBER), columns=columns, table_name=table, supports="double precision", diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index 97fe73c54fe..e26a69c0db9 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -136,31 +136,28 @@ QUERY_STATISTICS_SUMMARY_SUM = ( STATISTIC_UNIT_TO_UNIT_CONVERTER: dict[str | None, type[BaseUnitConverter]] = { - **{unit: AreaConverter for unit in AreaConverter.VALID_UNITS}, - **{ - unit: BloodGlucoseConcentrationConverter - for unit in BloodGlucoseConcentrationConverter.VALID_UNITS - }, - **{unit: ConductivityConverter for unit in ConductivityConverter.VALID_UNITS}, - **{unit: DataRateConverter for unit in DataRateConverter.VALID_UNITS}, - **{unit: DistanceConverter for unit in DistanceConverter.VALID_UNITS}, - **{unit: DurationConverter for unit in DurationConverter.VALID_UNITS}, - **{unit: ElectricCurrentConverter for unit in ElectricCurrentConverter.VALID_UNITS}, - **{ - unit: ElectricPotentialConverter - for unit in ElectricPotentialConverter.VALID_UNITS - }, - **{unit: EnergyConverter for unit in EnergyConverter.VALID_UNITS}, - **{unit: EnergyDistanceConverter for unit in EnergyDistanceConverter.VALID_UNITS}, - **{unit: InformationConverter for unit in InformationConverter.VALID_UNITS}, - **{unit: MassConverter for unit in MassConverter.VALID_UNITS}, - **{unit: PowerConverter for unit in PowerConverter.VALID_UNITS}, - **{unit: PressureConverter for unit in PressureConverter.VALID_UNITS}, - **{unit: SpeedConverter for unit in SpeedConverter.VALID_UNITS}, - **{unit: TemperatureConverter for unit in TemperatureConverter.VALID_UNITS}, - **{unit: UnitlessRatioConverter for unit in UnitlessRatioConverter.VALID_UNITS}, - **{unit: VolumeConverter for unit in VolumeConverter.VALID_UNITS}, - **{unit: VolumeFlowRateConverter for unit in VolumeFlowRateConverter.VALID_UNITS}, + **dict.fromkeys(AreaConverter.VALID_UNITS, AreaConverter), + **dict.fromkeys( + BloodGlucoseConcentrationConverter.VALID_UNITS, + BloodGlucoseConcentrationConverter, + ), + **dict.fromkeys(ConductivityConverter.VALID_UNITS, ConductivityConverter), + **dict.fromkeys(DataRateConverter.VALID_UNITS, DataRateConverter), + **dict.fromkeys(DistanceConverter.VALID_UNITS, DistanceConverter), + **dict.fromkeys(DurationConverter.VALID_UNITS, DurationConverter), + **dict.fromkeys(ElectricCurrentConverter.VALID_UNITS, ElectricCurrentConverter), + **dict.fromkeys(ElectricPotentialConverter.VALID_UNITS, ElectricPotentialConverter), + **dict.fromkeys(EnergyConverter.VALID_UNITS, EnergyConverter), + **dict.fromkeys(EnergyDistanceConverter.VALID_UNITS, EnergyDistanceConverter), + **dict.fromkeys(InformationConverter.VALID_UNITS, InformationConverter), + **dict.fromkeys(MassConverter.VALID_UNITS, MassConverter), + **dict.fromkeys(PowerConverter.VALID_UNITS, PowerConverter), + **dict.fromkeys(PressureConverter.VALID_UNITS, PressureConverter), + **dict.fromkeys(SpeedConverter.VALID_UNITS, SpeedConverter), + **dict.fromkeys(TemperatureConverter.VALID_UNITS, TemperatureConverter), + **dict.fromkeys(UnitlessRatioConverter.VALID_UNITS, UnitlessRatioConverter), + **dict.fromkeys(VolumeConverter.VALID_UNITS, VolumeConverter), + **dict.fromkeys(VolumeFlowRateConverter.VALID_UNITS, VolumeFlowRateConverter), } diff --git a/homeassistant/components/risco/const.py b/homeassistant/components/risco/const.py index 078e26c43b5..ef3280fe232 100644 --- a/homeassistant/components/risco/const.py +++ b/homeassistant/components/risco/const.py @@ -30,9 +30,9 @@ RISCO_ARM = "arm" RISCO_PARTIAL_ARM = "partial_arm" RISCO_STATES = [RISCO_ARM, RISCO_PARTIAL_ARM, *RISCO_GROUPS] -DEFAULT_RISCO_GROUPS_TO_HA = { - group: AlarmControlPanelState.ARMED_HOME for group in RISCO_GROUPS -} +DEFAULT_RISCO_GROUPS_TO_HA = dict.fromkeys( + RISCO_GROUPS, AlarmControlPanelState.ARMED_HOME +) DEFAULT_RISCO_STATES_TO_HA = { RISCO_ARM: AlarmControlPanelState.ARMED_AWAY, RISCO_PARTIAL_ARM: AlarmControlPanelState.ARMED_HOME, diff --git a/homeassistant/components/solarlog/coordinator.py b/homeassistant/components/solarlog/coordinator.py index 6292b1332d7..48ebeece1ba 100644 --- a/homeassistant/components/solarlog/coordinator.py +++ b/homeassistant/components/solarlog/coordinator.py @@ -75,7 +75,7 @@ class SolarLogCoordinator(DataUpdateCoordinator[SolarlogData]): await self.solarlog.test_extended_data_available() if logged_in or await self.solarlog.test_extended_data_available(): device_list = await self.solarlog.update_device_list() - self.solarlog.set_enabled_devices({key: True for key in device_list}) + self.solarlog.set_enabled_devices(dict.fromkeys(device_list, True)) async def _async_update_data(self) -> SolarlogData: """Update the data from the SolarLog device.""" diff --git a/homeassistant/components/telegram_bot/__init__.py b/homeassistant/components/telegram_bot/__init__.py index b3c09049ae5..15e1f7d4f0e 100644 --- a/homeassistant/components/telegram_bot/__init__.py +++ b/homeassistant/components/telegram_bot/__init__.py @@ -548,7 +548,7 @@ class TelegramNotificationService: """Initialize the service.""" self.allowed_chat_ids = allowed_chat_ids self._default_user = self.allowed_chat_ids[0] - self._last_message_id = {user: None for user in self.allowed_chat_ids} + self._last_message_id = dict.fromkeys(self.allowed_chat_ids) self._parsers = { PARSER_HTML: ParseMode.HTML, PARSER_MD: ParseMode.MARKDOWN, diff --git a/homeassistant/components/tesla_fleet/coordinator.py b/homeassistant/components/tesla_fleet/coordinator.py index 128c15068f6..6f881d0feba 100644 --- a/homeassistant/components/tesla_fleet/coordinator.py +++ b/homeassistant/components/tesla_fleet/coordinator.py @@ -248,7 +248,7 @@ class TeslaFleetEnergySiteHistoryCoordinator(DataUpdateCoordinator[dict[str, Any self.updated_once = True # Add all time periods together - output = {key: 0 for key in ENERGY_HISTORY_FIELDS} + output = dict.fromkeys(ENERGY_HISTORY_FIELDS, 0) for period in data.get("time_series", []): for key in ENERGY_HISTORY_FIELDS: output[key] += period.get(key, 0) diff --git a/homeassistant/components/teslemetry/coordinator.py b/homeassistant/components/teslemetry/coordinator.py index 0cd2a5a62d6..f902fb4cc1b 100644 --- a/homeassistant/components/teslemetry/coordinator.py +++ b/homeassistant/components/teslemetry/coordinator.py @@ -192,7 +192,7 @@ class TeslemetryEnergyHistoryCoordinator(DataUpdateCoordinator[dict[str, Any]]): raise UpdateFailed(e.message) from e # Add all time periods together - output = {key: 0 for key in ENERGY_HISTORY_FIELDS} + output = dict.fromkeys(ENERGY_HISTORY_FIELDS, 0) for period in data.get("time_series", []): for key in ENERGY_HISTORY_FIELDS: output[key] += period.get(key, 0) diff --git a/homeassistant/components/ukraine_alarm/coordinator.py b/homeassistant/components/ukraine_alarm/coordinator.py index 267358e4aa6..b4e1decb1a1 100644 --- a/homeassistant/components/ukraine_alarm/coordinator.py +++ b/homeassistant/components/ukraine_alarm/coordinator.py @@ -52,7 +52,7 @@ class UkraineAlarmDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): except aiohttp.ClientError as error: raise UpdateFailed(f"Error fetching alerts from API: {error}") from error - current = {alert_type: False for alert_type in ALERT_TYPES} + current = dict.fromkeys(ALERT_TYPES, False) for alert in res[0]["activeAlerts"]: current[alert["type"]] = True diff --git a/homeassistant/components/xiaomi_miio/button.py b/homeassistant/components/xiaomi_miio/button.py index a5d1b4b69c6..a7bcb3a12fe 100644 --- a/homeassistant/components/xiaomi_miio/button.py +++ b/homeassistant/components/xiaomi_miio/button.py @@ -117,7 +117,7 @@ MODEL_TO_BUTTON_MAP: dict[str, tuple[str, ...]] = { ATTR_RESET_DUST_FILTER, ATTR_RESET_UPPER_FILTER, ), - **{model: BUTTONS_FOR_VACUUM for model in MODELS_VACUUM}, + **dict.fromkeys(MODELS_VACUUM, BUTTONS_FOR_VACUUM), } diff --git a/tests/components/conftest.py b/tests/components/conftest.py index 6d6d0d4641f..e0db306cae9 100644 --- a/tests/components/conftest.py +++ b/tests/components/conftest.py @@ -929,7 +929,7 @@ async def check_translations( ignored_domains = set(ignore_translations_for_mock_domains) # Set all ignored translation keys to "unused" - translation_errors = {k: "unused" for k in ignore_missing_translations} + translation_errors = dict.fromkeys(ignore_missing_translations, "unused") translation_coros = set() diff --git a/tests/components/harmony/test_subscriber.py b/tests/components/harmony/test_subscriber.py index f1d1866a044..22957fc3f69 100644 --- a/tests/components/harmony/test_subscriber.py +++ b/tests/components/harmony/test_subscriber.py @@ -38,7 +38,7 @@ async def test_empty_callbacks(hass: HomeAssistant) -> None: """Ensure we handle a missing callback in a subscription.""" subscriber = HarmonySubscriberMixin(hass) - callbacks = {k: None for k in _ALL_CALLBACK_NAMES} + callbacks = dict.fromkeys(_ALL_CALLBACK_NAMES) subscriber.async_subscribe(HarmonyCallback(**callbacks)) _call_all_callbacks(subscriber) await hass.async_block_till_done() diff --git a/tests/components/nws/const.py b/tests/components/nws/const.py index 39e954af15a..1de8f67fbdb 100644 --- a/tests/components/nws/const.py +++ b/tests/components/nws/const.py @@ -176,7 +176,7 @@ WEATHER_EXPECTED_OBSERVATION_METRIC = { ATTR_WEATHER_HUMIDITY: 10, } -NONE_OBSERVATION = {key: None for key in DEFAULT_OBSERVATION} +NONE_OBSERVATION = dict.fromkeys(DEFAULT_OBSERVATION) DEFAULT_FORECAST = [ { @@ -235,4 +235,4 @@ EXPECTED_FORECAST_METRIC = { ATTR_FORECAST_HUMIDITY: 75, } -NONE_FORECAST = [{key: None for key in DEFAULT_FORECAST[0]}] +NONE_FORECAST = [dict.fromkeys(DEFAULT_FORECAST[0])] diff --git a/tests/components/stream/test_ll_hls.py b/tests/components/stream/test_ll_hls.py index 443103fdf92..1eb638237af 100644 --- a/tests/components/stream/test_ll_hls.py +++ b/tests/components/stream/test_ll_hls.py @@ -202,7 +202,7 @@ async def test_ll_hls_stream( datetime_re = re.compile(r"#EXT-X-PROGRAM-DATE-TIME:(?P.+)") inf_re = re.compile(r"#EXTINF:(?P[0-9]{1,}.[0-9]{3,}),") # keep track of which tests were done (indexed by re) - tested = {regex: False for regex in (part_re, datetime_re, inf_re)} + tested = dict.fromkeys((part_re, datetime_re, inf_re), False) # keep track of times and durations along playlist for checking consistency part_durations = [] segment_duration = 0 From d56680e05e69fa800957efe99e794c294f5dc0db Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Fri, 14 Mar 2025 00:13:16 +0100 Subject: [PATCH 2571/3148] Update to version 1.6.0 of gardena library (#140559) --- homeassistant/components/gardena_bluetooth/config_flow.py | 2 ++ homeassistant/components/gardena_bluetooth/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 5 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/gardena_bluetooth/config_flow.py b/homeassistant/components/gardena_bluetooth/config_flow.py index c7631b62f47..613d0cf21db 100644 --- a/homeassistant/components/gardena_bluetooth/config_flow.py +++ b/homeassistant/components/gardena_bluetooth/config_flow.py @@ -41,6 +41,8 @@ def _is_supported(discovery_info: BluetoothServiceInfo): ProductType.PUMP, ProductType.VALVE, ProductType.WATER_COMPUTER, + ProductType.AUTOMATS, + ProductType.PRESSURE_TANKS, ): _LOGGER.debug("Unsupported device: %s", manufacturer_data) return False diff --git a/homeassistant/components/gardena_bluetooth/manifest.json b/homeassistant/components/gardena_bluetooth/manifest.json index 28bba1015f5..8c9cda7d998 100644 --- a/homeassistant/components/gardena_bluetooth/manifest.json +++ b/homeassistant/components/gardena_bluetooth/manifest.json @@ -14,5 +14,5 @@ "documentation": "https://www.home-assistant.io/integrations/gardena_bluetooth", "iot_class": "local_polling", "loggers": ["bleak", "bleak_esphome", "gardena_bluetooth"], - "requirements": ["gardena-bluetooth==1.5.0"] + "requirements": ["gardena-bluetooth==1.6.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 5331fdb6800..9fc11d08b32 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -974,7 +974,7 @@ fyta_cli==0.7.1 gTTS==2.5.3 # homeassistant.components.gardena_bluetooth -gardena-bluetooth==1.5.0 +gardena-bluetooth==1.6.0 # homeassistant.components.google_assistant_sdk gassist-text==0.0.11 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 31d99827de1..fd6f2e9112a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -827,7 +827,7 @@ fyta_cli==0.7.1 gTTS==2.5.3 # homeassistant.components.gardena_bluetooth -gardena-bluetooth==1.5.0 +gardena-bluetooth==1.6.0 # homeassistant.components.google_assistant_sdk gassist-text==0.0.11 From b1285fcc4e28a8a9e960eb9c57f52e37364603bc Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 14 Mar 2025 00:28:01 +0100 Subject: [PATCH 2572/3148] Set unit of measurement for SmartThings oven setpoint (#140560) --- .../components/smartthings/sensor.py | 3 + tests/components/smartthings/conftest.py | 1 + .../device_status/da_ks_range_0101x.json | 688 ++++++++++++++++++ .../fixtures/devices/da_ks_range_0101x.json | 197 +++++ .../smartthings/snapshots/test_init.ambr | 33 + .../smartthings/snapshots/test_sensor.ambr | 406 ++++++++++- 6 files changed, 1325 insertions(+), 3 deletions(-) create mode 100644 tests/components/smartthings/fixtures/device_status/da_ks_range_0101x.json create mode 100644 tests/components/smartthings/fixtures/devices/da_ks_range_0101x.json diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index ce0f30a1f1a..ec4d9ee6207 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -572,6 +572,9 @@ CAPABILITY_TO_SENSORS: dict[ SmartThingsSensorEntityDescription( key=Attribute.OVEN_SETPOINT, translation_key="oven_setpoint", + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT, + value_fn=lambda value: value if value != 0 else None, ) ] }, diff --git a/tests/components/smartthings/conftest.py b/tests/components/smartthings/conftest.py index 2deef344b5e..f0e2f76c112 100644 --- a/tests/components/smartthings/conftest.py +++ b/tests/components/smartthings/conftest.py @@ -113,6 +113,7 @@ def mock_smartthings() -> Generator[AsyncMock]: "da_wm_wm_000001_1", "da_rvc_normal_000001", "da_ks_microwave_0101x", + "da_ks_range_0101x", "hue_color_temperature_bulb", "hue_rgbw_color_bulb", "c2c_shade", diff --git a/tests/components/smartthings/fixtures/device_status/da_ks_range_0101x.json b/tests/components/smartthings/fixtures/device_status/da_ks_range_0101x.json new file mode 100644 index 00000000000..6d15aa4696d --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/da_ks_range_0101x.json @@ -0,0 +1,688 @@ +{ + "components": { + "cavity-01": { + "ovenSetpoint": { + "ovenSetpointRange": { + "value": null + }, + "ovenSetpoint": { + "value": 0, + "timestamp": "2022-02-21T22:37:06.976Z" + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": [], + "timestamp": "2022-09-07T22:35:34.197Z" + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": 175, + "unit": "F", + "timestamp": "2022-02-21T22:37:06.976Z" + } + }, + "samsungce.ovenOperatingState": { + "completionTime": { + "value": "2024-05-14T19:00:04.579Z", + "timestamp": "2024-05-14T19:00:04.584Z" + }, + "operatingState": { + "value": "ready", + "timestamp": "2022-02-21T22:37:05.415Z" + }, + "progress": { + "value": 1, + "timestamp": "2022-02-21T22:37:05.415Z" + }, + "ovenJobState": { + "value": "ready", + "timestamp": "2022-02-21T22:37:05.415Z" + }, + "operationTime": { + "value": "00:00:00", + "timestamp": "2022-02-21T22:37:05.415Z" + } + }, + "samsungce.kitchenDeviceDefaults": { + "defaultOperationTime": { + "value": null + }, + "defaultOvenMode": { + "value": "ConvectionBake", + "timestamp": "2022-02-21T22:37:06.983Z" + }, + "defaultOvenSetpoint": { + "value": 350, + "timestamp": "2022-02-21T22:37:06.976Z" + } + }, + "custom.ovenCavityStatus": { + "ovenCavityStatus": { + "value": "off", + "timestamp": "2025-03-12T20:38:01.259Z" + } + }, + "ovenMode": { + "supportedOvenModes": { + "value": ["Others"], + "timestamp": "2022-02-21T22:37:08.409Z" + }, + "ovenMode": { + "value": "Others", + "timestamp": "2022-02-21T22:37:06.983Z" + } + }, + "ovenOperatingState": { + "completionTime": { + "value": "2024-05-14T19:00:04.579Z", + "timestamp": "2024-05-14T19:00:04.584Z" + }, + "machineState": { + "value": "ready", + "timestamp": "2022-02-21T22:37:05.415Z" + }, + "progress": { + "value": 1, + "unit": "%", + "timestamp": "2022-02-21T22:37:05.415Z" + }, + "supportedMachineStates": { + "value": null + }, + "ovenJobState": { + "value": "ready", + "timestamp": "2022-02-21T22:37:05.415Z" + }, + "operationTime": { + "value": 0, + "timestamp": "2022-02-21T22:37:05.415Z" + } + }, + "samsungce.ovenMode": { + "supportedOvenModes": { + "value": ["SelfClean", "SteamClean", "NoOperation"], + "timestamp": "2022-02-21T22:37:08.409Z" + }, + "ovenMode": { + "value": "NoOperation", + "timestamp": "2022-02-21T22:37:06.983Z" + } + } + }, + "main": { + "ovenSetpoint": { + "ovenSetpointRange": { + "value": null + }, + "ovenSetpoint": { + "value": 425, + "timestamp": "2025-03-13T21:42:23.492Z" + } + }, + "samsungce.meatProbe": { + "temperatureSetpoint": { + "value": 0, + "unit": "F", + "timestamp": "2022-02-21T22:37:02.619Z" + }, + "temperature": { + "value": 0, + "unit": "F", + "timestamp": "2022-02-21T22:37:02.619Z" + }, + "status": { + "value": "disconnected", + "timestamp": "2022-02-21T22:37:02.679Z" + } + }, + "refresh": {}, + "samsungce.doorState": { + "doorState": { + "value": "closed", + "timestamp": "2025-03-12T20:38:01.255Z" + } + }, + "samsungce.kitchenDeviceDefaults": { + "defaultOperationTime": { + "value": 3600, + "timestamp": "2025-03-13T21:23:24.771Z" + }, + "defaultOvenMode": { + "value": "ConvectionBake", + "timestamp": "2025-03-13T21:23:27.659Z" + }, + "defaultOvenSetpoint": { + "value": 350, + "timestamp": "2025-03-13T21:23:27.596Z" + } + }, + "execute": { + "data": { + "value": { + "payload": { + "rt": ["x.com.samsung.da.information"], + "if": ["oic.if.baseline", "oic.if.a"], + "x.com.samsung.da.modelNum": "TP1X_DA-KS-RANGE-0101X|40445041|5001011E03151101020000000000000", + "x.com.samsung.da.description": "TP1X_DA-KS-OVEN-01011", + "x.com.samsung.da.serialNum": "0J4D7DARB03393K", + "x.com.samsung.da.otnDUID": "ZPCNQWBWXI47Q", + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "Version", + "x.com.samsung.da.type": "Software", + "x.com.samsung.da.number": "02144A221005", + "x.com.samsung.da.newVersionAvailable": "0" + }, + { + "x.com.samsung.da.id": "1", + "x.com.samsung.da.description": "Version", + "x.com.samsung.da.type": "Firmware", + "x.com.samsung.da.number": "20121600,FFFFFFFF", + "x.com.samsung.da.newVersionAvailable": "0" + } + ] + } + }, + "data": { + "href": "/information/vs/0" + }, + "timestamp": "2023-11-28T22:49:09.333Z" + } + }, + "samsungce.deviceIdentification": { + "micomAssayCode": { + "value": null + }, + "modelName": { + "value": null + }, + "serialNumber": { + "value": null + }, + "serialNumberExtra": { + "value": null + }, + "modelClassificationCode": { + "value": null + }, + "description": { + "value": null + }, + "releaseYear": { + "value": null + }, + "binaryId": { + "value": "TP1X_DA-KS-RANGE-0101X", + "timestamp": "2025-03-12T20:40:29.034Z" + } + }, + "ocf": { + "st": { + "value": null + }, + "mndt": { + "value": null + }, + "mnfv": { + "value": "AKS-WW-TP1-20-OVEN-3-CR_40240205", + "timestamp": "2024-05-14T19:00:26.132Z" + }, + "mnhw": { + "value": "Realtek", + "timestamp": "2024-05-14T19:00:26.132Z" + }, + "di": { + "value": "2c3cbaa0-1899-5ddc-7b58-9d657bd48f18", + "timestamp": "2022-02-21T22:37:02.282Z" + }, + "mnsl": { + "value": "http://www.samsung.com", + "timestamp": "2022-02-21T22:37:02.282Z" + }, + "dmv": { + "value": "1.2.1", + "timestamp": "2022-12-19T22:33:09.710Z" + }, + "n": { + "value": "Samsung Range", + "timestamp": "2024-05-14T19:00:26.132Z" + }, + "mnmo": { + "value": "TP1X_DA-KS-RANGE-0101X|40445041|5001011E031511010200000000000000", + "timestamp": "2024-05-14T19:00:26.132Z" + }, + "vid": { + "value": "DA-KS-RANGE-0101X", + "timestamp": "2022-02-21T22:37:02.282Z" + }, + "mnmn": { + "value": "Samsung Electronics", + "timestamp": "2022-02-21T22:37:02.282Z" + }, + "mnml": { + "value": "http://www.samsung.com", + "timestamp": "2022-02-21T22:37:02.282Z" + }, + "mnpv": { + "value": "DAWIT 3.0", + "timestamp": "2024-05-14T19:00:26.132Z" + }, + "mnos": { + "value": "TizenRT 3.1", + "timestamp": "2024-05-14T19:00:26.132Z" + }, + "pi": { + "value": "2c3cbaa0-1899-5ddc-7b58-9d657bd48f18", + "timestamp": "2022-02-21T22:37:02.282Z" + }, + "icv": { + "value": "core.1.1.0", + "timestamp": "2022-02-21T22:37:02.282Z" + } + }, + "remoteControlStatus": { + "remoteControlEnabled": { + "value": "true", + "timestamp": "2025-03-13T21:42:23.615Z" + } + }, + "samsungce.customRecipe": {}, + "samsungce.kitchenDeviceIdentification": { + "regionCode": { + "value": "US", + "timestamp": "2025-03-13T21:23:27.659Z" + }, + "modelCode": { + "value": "NE6516A-/AA0", + "timestamp": "2025-03-13T21:23:27.659Z" + }, + "fuel": { + "value": null + }, + "type": { + "value": "range", + "timestamp": "2022-02-21T22:37:02.487Z" + }, + "representativeComponent": { + "value": null + } + }, + "samsungce.kitchenModeSpecification": { + "specification": { + "value": { + "single": [ + { + "mode": "Bake", + "supportedOperations": ["start", "set"], + "supportedOptions": { + "temperature": { + "C": { + "min": 80, + "max": 285, + "default": 175, + "resolution": 0 + }, + "F": { + "min": 175, + "max": 550, + "default": 350, + "resolution": 0 + } + }, + "operationTime": { + "min": "00:01:00", + "max": "09:59:00", + "default": "01:00:00", + "resolution": "00:01:00" + } + } + }, + { + "mode": "Broil", + "supportedOperations": ["set"], + "supportedOptions": { + "temperature": { + "C": { + "min": 61441, + "max": 61442, + "default": 61441, + "supportedValues": [61441, 61442] + }, + "F": { + "min": 61441, + "max": 61442, + "default": 61441, + "supportedValues": [61441, 61442] + } + } + } + }, + { + "mode": "ConvectionBake", + "supportedOperations": ["start", "set"], + "supportedOptions": { + "temperature": { + "C": { + "min": 80, + "max": 285, + "default": 160, + "resolution": 0 + }, + "F": { + "min": 175, + "max": 550, + "default": 325, + "resolution": 0 + } + }, + "operationTime": { + "min": "00:01:00", + "max": "09:59:00", + "default": "01:00:00", + "resolution": "00:01:00" + } + } + }, + { + "mode": "ConvectionRoast", + "supportedOperations": ["start", "set"], + "supportedOptions": { + "temperature": { + "C": { + "min": 80, + "max": 285, + "default": 160, + "resolution": 0 + }, + "F": { + "min": 175, + "max": 550, + "default": 325, + "resolution": 0 + } + }, + "operationTime": { + "min": "00:01:00", + "max": "09:59:00", + "default": "01:00:00", + "resolution": "00:01:00" + } + } + }, + { + "mode": "KeepWarm", + "supportedOperations": ["set"], + "supportedOptions": { + "temperature": { + "C": { + "min": 80, + "max": 80, + "default": 80, + "supportedValues": [80] + }, + "F": { + "min": 175, + "max": 175, + "default": 175, + "supportedValues": [175] + } + } + } + }, + { + "mode": "BreadProof", + "supportedOperations": ["set"], + "supportedOptions": { + "temperature": { + "C": { + "min": 35, + "max": 35, + "default": 35, + "supportedValues": [35] + }, + "F": { + "min": 95, + "max": 95, + "default": 95, + "supportedValues": [95] + } + } + } + }, + { + "mode": "AirFryer", + "supportedOperations": ["start", "set"], + "supportedOptions": { + "temperature": { + "C": { + "min": 175, + "max": 260, + "default": 220, + "resolution": 0 + }, + "F": { + "min": 350, + "max": 500, + "default": 425, + "resolution": 0 + } + }, + "operationTime": { + "min": "00:01:00", + "max": "09:59:00", + "default": "01:00:00", + "resolution": "00:01:00" + } + } + }, + { + "mode": "Dehydrate", + "supportedOperations": ["start", "set"], + "supportedOptions": { + "temperature": { + "C": { + "min": 40, + "max": 105, + "default": 65, + "resolution": 0 + }, + "F": { + "min": 100, + "max": 225, + "default": 150, + "resolution": 0 + } + }, + "operationTime": { + "min": "00:01:00", + "max": "09:59:00", + "default": "01:00:00", + "resolution": "00:01:00" + } + } + }, + { + "mode": "SelfClean", + "supportedOperations": [], + "supportedOptions": {} + }, + { + "mode": "SteamClean", + "supportedOperations": [], + "supportedOptions": {} + } + ] + }, + "timestamp": "2024-05-14T19:00:30.062Z" + } + }, + "custom.cooktopOperatingState": { + "supportedCooktopOperatingState": { + "value": ["run", "ready"], + "timestamp": "2022-02-21T22:37:05.293Z" + }, + "cooktopOperatingState": { + "value": "ready", + "timestamp": "2025-03-12T20:38:01.402Z" + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": [], + "timestamp": "2025-03-13T21:23:27.659Z" + } + }, + "samsungce.driverVersion": { + "versionNumber": { + "value": 22100101, + "timestamp": "2022-11-01T21:37:51.304Z" + } + }, + "samsungce.softwareUpdate": { + "targetModule": { + "value": null + }, + "otnDUID": { + "value": "ZPCNQWBWXI47Q", + "timestamp": "2025-03-12T20:38:01.262Z" + }, + "lastUpdatedDate": { + "value": null + }, + "availableModules": { + "value": [], + "timestamp": "2025-03-12T20:38:01.262Z" + }, + "newVersionAvailable": { + "value": false, + "timestamp": "2025-03-12T20:38:01.262Z" + }, + "operatingState": { + "value": null + }, + "progress": { + "value": null + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": 425, + "unit": "F", + "timestamp": "2025-03-13T21:46:35.545Z" + } + }, + "samsungce.ovenOperatingState": { + "completionTime": { + "value": "2025-03-14T03:23:28.048Z", + "timestamp": "2025-03-13T22:09:29.052Z" + }, + "operatingState": { + "value": "running", + "timestamp": "2025-03-13T21:23:24.771Z" + }, + "progress": { + "value": 13, + "timestamp": "2025-03-13T22:06:35.591Z" + }, + "ovenJobState": { + "value": "cooking", + "timestamp": "2025-03-13T21:46:34.327Z" + }, + "operationTime": { + "value": "06:00:00", + "timestamp": "2025-03-13T21:23:24.771Z" + } + }, + "ovenMode": { + "supportedOvenModes": { + "value": [ + "Bake", + "Broil", + "ConvectionBake", + "ConvectionRoast", + "warming", + "Others", + "Dehydrate" + ], + "timestamp": "2025-03-12T20:38:01.259Z" + }, + "ovenMode": { + "value": "Bake", + "timestamp": "2025-03-13T21:23:27.659Z" + } + }, + "ovenOperatingState": { + "completionTime": { + "value": "2025-03-14T03:23:28.048Z", + "timestamp": "2025-03-13T22:09:29.052Z" + }, + "machineState": { + "value": "running", + "timestamp": "2025-03-13T21:23:24.771Z" + }, + "progress": { + "value": 13, + "unit": "%", + "timestamp": "2025-03-13T22:06:35.591Z" + }, + "supportedMachineStates": { + "value": null + }, + "ovenJobState": { + "value": "cooking", + "timestamp": "2025-03-13T21:46:34.327Z" + }, + "operationTime": { + "value": 21600, + "timestamp": "2025-03-13T21:23:24.771Z" + } + }, + "samsungce.ovenMode": { + "supportedOvenModes": { + "value": [ + "Bake", + "Broil", + "ConvectionBake", + "ConvectionRoast", + "KeepWarm", + "BreadProof", + "AirFryer", + "Dehydrate", + "SelfClean", + "SteamClean" + ], + "timestamp": "2025-03-12T20:38:01.259Z" + }, + "ovenMode": { + "value": "Bake", + "timestamp": "2025-03-13T21:23:27.659Z" + } + }, + "samsungce.lamp": { + "brightnessLevel": { + "value": "off", + "timestamp": "2025-03-13T21:23:27.659Z" + }, + "supportedBrightnessLevel": { + "value": ["off", "high"], + "timestamp": "2025-03-13T21:23:27.659Z" + } + }, + "samsungce.kidsLock": { + "lockState": { + "value": "unlocked", + "timestamp": "2025-03-12T20:38:01.400Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/devices/da_ks_range_0101x.json b/tests/components/smartthings/fixtures/devices/da_ks_range_0101x.json new file mode 100644 index 00000000000..e918e2d77ca --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/da_ks_range_0101x.json @@ -0,0 +1,197 @@ +{ + "items": [ + { + "deviceId": "2c3cbaa0-1899-5ddc-7b58-9d657bd48f18", + "name": "Samsung Range", + "label": "Vulcan", + "manufacturerName": "Samsung Electronics", + "presentationId": "DA-KS-RANGE-0101X", + "deviceManufacturerCode": "Samsung Electronics", + "locationId": "597a4912-13c9-47ab-9956-7ebc38b61abd", + "ownerId": "c4478c70-9014-e5c9-993c-f62707fa1e61", + "roomId": "fc407cd9-3b32-4fc0-bf23-e0d4995101e9", + "deviceTypeName": "Samsung OCF Range", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "ocf", + "version": 1 + }, + { + "id": "execute", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "remoteControlStatus", + "version": 1 + }, + { + "id": "ovenSetpoint", + "version": 1 + }, + { + "id": "ovenMode", + "version": 1 + }, + { + "id": "ovenOperatingState", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "samsungce.deviceIdentification", + "version": 1 + }, + { + "id": "samsungce.driverVersion", + "version": 1 + }, + { + "id": "samsungce.kitchenDeviceIdentification", + "version": 1 + }, + { + "id": "samsungce.kitchenDeviceDefaults", + "version": 1 + }, + { + "id": "samsungce.doorState", + "version": 1 + }, + { + "id": "samsungce.customRecipe", + "version": 1 + }, + { + "id": "samsungce.ovenMode", + "version": 1 + }, + { + "id": "samsungce.ovenOperatingState", + "version": 1 + }, + { + "id": "samsungce.meatProbe", + "version": 1 + }, + { + "id": "samsungce.lamp", + "version": 1 + }, + { + "id": "samsungce.kitchenModeSpecification", + "version": 1 + }, + { + "id": "samsungce.kidsLock", + "version": 1 + }, + { + "id": "samsungce.softwareUpdate", + "version": 1 + }, + { + "id": "custom.cooktopOperatingState", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + } + ], + "categories": [ + { + "name": "Range", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "cavity-01", + "label": "cavity-01", + "capabilities": [ + { + "id": "ovenSetpoint", + "version": 1 + }, + { + "id": "ovenMode", + "version": 1 + }, + { + "id": "ovenOperatingState", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "samsungce.ovenMode", + "version": 1 + }, + { + "id": "samsungce.ovenOperatingState", + "version": 1 + }, + { + "id": "samsungce.kitchenDeviceDefaults", + "version": 1 + }, + { + "id": "custom.ovenCavityStatus", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2022-02-21T22:37:01.648Z", + "profile": { + "id": "8e479dd0-9719-337a-9fbe-2c4572f95c71" + }, + "ocf": { + "ocfDeviceType": "oic.d.range", + "name": "Samsung Range", + "specVersion": "core.1.1.0", + "verticalDomainSpecVersion": "1.2.1", + "manufacturerName": "Samsung Electronics", + "modelNumber": "TP1X_DA-KS-RANGE-0101X|40445041|5001011E031511010200000000000000", + "platformVersion": "DAWIT 3.0", + "platformOS": "TizenRT 3.1", + "hwVersion": "Realtek", + "firmwareVersion": "AKS-WW-TP1-20-OVEN-3-CR_40240205", + "vendorId": "DA-KS-RANGE-0101X", + "vendorResourceClientServerVersion": "Realtek Release 3.1.220727", + "lastSignupTime": "2023-11-28T22:49:01.876575Z", + "transferCandidate": false, + "additionalAuthCodeRequired": false + }, + "type": "OCF", + "restrictionTier": 0, + "allowed": null, + "executionContext": "CLOUD", + "relationships": [] + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/snapshots/test_init.ambr b/tests/components/smartthings/snapshots/test_init.ambr index 0276873384a..74297ac6a0b 100644 --- a/tests/components/smartthings/snapshots/test_init.ambr +++ b/tests/components/smartthings/snapshots/test_init.ambr @@ -398,6 +398,39 @@ 'via_device_id': None, }) # --- +# name: test_devices[da_ks_range_0101x] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': 'Realtek', + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '2c3cbaa0-1899-5ddc-7b58-9d657bd48f18', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Samsung Electronics', + 'model': 'TP1X_DA-KS-RANGE-0101X', + 'model_id': None, + 'name': 'Vulcan', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': 'AKS-WW-TP1-20-OVEN-3-CR_40240205', + 'via_device_id': None, + }) +# --- # name: test_devices[da_ref_normal_000001] DeviceRegistryEntrySnapshot({ 'area_id': 'theater', diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index 52df02f55b8..43d26f4f987 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -2007,7 +2007,7 @@ 'name': None, 'options': dict({ }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Set point', 'platform': 'smartthings', @@ -2015,20 +2015,22 @@ 'supported_features': 0, 'translation_key': 'oven_setpoint', 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a.ovenSetpoint', - 'unit_of_measurement': None, + 'unit_of_measurement': , }) # --- # name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_set_point-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', 'friendly_name': 'Microwave Set point', + 'unit_of_measurement': , }), 'context': , 'entity_id': 'sensor.microwave_set_point', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0', + 'state': 'unknown', }) # --- # name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_temperature-entry] @@ -2083,6 +2085,404 @@ 'state': '-17', }) # --- +# name: test_all_entities[da_ks_range_0101x][sensor.vulcan_completion_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.vulcan_completion_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Completion time', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'completion_time', + 'unique_id': '2c3cbaa0-1899-5ddc-7b58-9d657bd48f18.completionTime', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_range_0101x][sensor.vulcan_completion_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Vulcan Completion time', + }), + 'context': , + 'entity_id': 'sensor.vulcan_completion_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2025-03-14T03:23:28+00:00', + }) +# --- +# name: test_all_entities[da_ks_range_0101x][sensor.vulcan_job_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'cleaning', + 'cooking', + 'cooling', + 'draining', + 'preheat', + 'ready', + 'rinsing', + 'finished', + 'scheduled_start', + 'warming', + 'defrosting', + 'sensing', + 'searing', + 'fast_preheat', + 'scheduled_end', + 'stone_heating', + 'time_hold_preheat', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.vulcan_job_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Job state', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'oven_job_state', + 'unique_id': '2c3cbaa0-1899-5ddc-7b58-9d657bd48f18.ovenJobState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_range_0101x][sensor.vulcan_job_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Vulcan Job state', + 'options': list([ + 'cleaning', + 'cooking', + 'cooling', + 'draining', + 'preheat', + 'ready', + 'rinsing', + 'finished', + 'scheduled_start', + 'warming', + 'defrosting', + 'sensing', + 'searing', + 'fast_preheat', + 'scheduled_end', + 'stone_heating', + 'time_hold_preheat', + ]), + }), + 'context': , + 'entity_id': 'sensor.vulcan_job_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'cooking', + }) +# --- +# name: test_all_entities[da_ks_range_0101x][sensor.vulcan_machine_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'ready', + 'running', + 'paused', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.vulcan_machine_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Machine state', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'oven_machine_state', + 'unique_id': '2c3cbaa0-1899-5ddc-7b58-9d657bd48f18.machineState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_range_0101x][sensor.vulcan_machine_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Vulcan Machine state', + 'options': list([ + 'ready', + 'running', + 'paused', + ]), + }), + 'context': , + 'entity_id': 'sensor.vulcan_machine_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'running', + }) +# --- +# name: test_all_entities[da_ks_range_0101x][sensor.vulcan_oven_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'conventional', + 'bake', + 'bottom_heat', + 'convection_bake', + 'convection_roast', + 'broil', + 'convection_broil', + 'steam_cook', + 'steam_bake', + 'steam_roast', + 'steam_bottom_heat_plus_convection', + 'microwave', + 'microwave_plus_grill', + 'microwave_plus_convection', + 'microwave_plus_hot_blast', + 'microwave_plus_hot_blast_2', + 'slim_middle', + 'slim_strong', + 'slow_cook', + 'proof', + 'dehydrate', + 'others', + 'strong_steam', + 'descale', + 'rinse', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.vulcan_oven_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Oven mode', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'oven_mode', + 'unique_id': '2c3cbaa0-1899-5ddc-7b58-9d657bd48f18.ovenMode', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_range_0101x][sensor.vulcan_oven_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Vulcan Oven mode', + 'options': list([ + 'conventional', + 'bake', + 'bottom_heat', + 'convection_bake', + 'convection_roast', + 'broil', + 'convection_broil', + 'steam_cook', + 'steam_bake', + 'steam_roast', + 'steam_bottom_heat_plus_convection', + 'microwave', + 'microwave_plus_grill', + 'microwave_plus_convection', + 'microwave_plus_hot_blast', + 'microwave_plus_hot_blast_2', + 'slim_middle', + 'slim_strong', + 'slow_cook', + 'proof', + 'dehydrate', + 'others', + 'strong_steam', + 'descale', + 'rinse', + ]), + }), + 'context': , + 'entity_id': 'sensor.vulcan_oven_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'bake', + }) +# --- +# name: test_all_entities[da_ks_range_0101x][sensor.vulcan_set_point-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.vulcan_set_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': 'Set point', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'oven_setpoint', + 'unique_id': '2c3cbaa0-1899-5ddc-7b58-9d657bd48f18.ovenSetpoint', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ks_range_0101x][sensor.vulcan_set_point-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Vulcan Set point', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.vulcan_set_point', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '218', + }) +# --- +# name: test_all_entities[da_ks_range_0101x][sensor.vulcan_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.vulcan_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': 'Temperature', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '2c3cbaa0-1899-5ddc-7b58-9d657bd48f18.temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ks_range_0101x][sensor.vulcan_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Vulcan Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.vulcan_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '218', + }) +# --- # name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_energy-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 058aed96d24e9129de34949bb02b257b862d5322 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 14 Mar 2025 00:28:08 +0100 Subject: [PATCH 2573/3148] Fix windowShadeLevel capability in SmartThings (#140552) --- homeassistant/components/smartthings/cover.py | 4 + tests/components/smartthings/conftest.py | 1 + .../fixtures/device_status/ikea_kadrilj.json | 68 ++++++++++++++++ .../fixtures/devices/ikea_kadrilj.json | 78 +++++++++++++++++++ .../smartthings/snapshots/test_cover.ambr | 51 ++++++++++++ .../smartthings/snapshots/test_init.ambr | 33 ++++++++ .../smartthings/snapshots/test_sensor.ambr | 49 ++++++++++++ 7 files changed, 284 insertions(+) create mode 100644 tests/components/smartthings/fixtures/device_status/ikea_kadrilj.json create mode 100644 tests/components/smartthings/fixtures/devices/ikea_kadrilj.json diff --git a/homeassistant/components/smartthings/cover.py b/homeassistant/components/smartthings/cover.py index 564de8443b1..0b0817d7c56 100644 --- a/homeassistant/components/smartthings/cover.py +++ b/homeassistant/components/smartthings/cover.py @@ -125,6 +125,10 @@ class SmartThingsCover(SmartThingsEntity, CoverEntity): self._attr_current_cover_position = self.get_attribute_value( Capability.SWITCH_LEVEL, Attribute.LEVEL ) + elif self.supports_capability(Capability.WINDOW_SHADE_LEVEL): + self._attr_current_cover_position = self.get_attribute_value( + Capability.WINDOW_SHADE_LEVEL, Attribute.SHADE_LEVEL + ) self._attr_extra_state_attributes = {} if self.supports_capability(Capability.BATTERY): diff --git a/tests/components/smartthings/conftest.py b/tests/components/smartthings/conftest.py index f0e2f76c112..c10668210e0 100644 --- a/tests/components/smartthings/conftest.py +++ b/tests/components/smartthings/conftest.py @@ -134,6 +134,7 @@ def mock_smartthings() -> Generator[AsyncMock]: "im_speaker_ai_0001", "abl_light_b_001", "tplink_p110", + "ikea_kadrilj", ] ) def device_fixture( diff --git a/tests/components/smartthings/fixtures/device_status/ikea_kadrilj.json b/tests/components/smartthings/fixtures/device_status/ikea_kadrilj.json new file mode 100644 index 00000000000..56a2d9e762d --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/ikea_kadrilj.json @@ -0,0 +1,68 @@ +{ + "components": { + "main": { + "windowShadeLevel": { + "shadeLevel": { + "value": 32, + "unit": "%", + "timestamp": "2025-03-13T10:40:25.613Z" + } + }, + "refresh": {}, + "windowShadePreset": {}, + "battery": { + "quantity": { + "value": null + }, + "battery": { + "value": 37, + "unit": "%", + "timestamp": "2025-03-13T07:09:05.149Z" + }, + "type": { + "value": null + } + }, + "firmwareUpdate": { + "lastUpdateStatusReason": { + "value": null + }, + "availableVersion": { + "value": "22007631", + "timestamp": "2025-03-12T20:35:04.576Z" + }, + "lastUpdateStatus": { + "value": null + }, + "supportedCommands": { + "value": null + }, + "state": { + "value": "updateRequested", + "timestamp": "2025-03-12T20:35:03.879Z" + }, + "updateAvailable": { + "value": false, + "timestamp": "2025-03-12T20:35:04.577Z" + }, + "currentVersion": { + "value": "22007631", + "timestamp": "2025-03-12T20:35:04.508Z" + }, + "lastUpdateTime": { + "value": null + } + }, + "windowShade": { + "supportedWindowShadeCommands": { + "value": ["open", "close", "pause"], + "timestamp": "2025-03-13T10:33:48.402Z" + }, + "windowShade": { + "value": "partially open", + "timestamp": "2025-03-13T10:55:58.205Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/devices/ikea_kadrilj.json b/tests/components/smartthings/fixtures/devices/ikea_kadrilj.json new file mode 100644 index 00000000000..36f9d40f7e4 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/ikea_kadrilj.json @@ -0,0 +1,78 @@ +{ + "items": [ + { + "deviceId": "71afed1c-006d-4e48-b16e-e7f88f9fd638", + "name": "window-treatment-battery", + "label": "Kitchen IKEA KADRILJ Window blind", + "manufacturerName": "SmartThingsCommunity", + "presentationId": "fa41d7d3-4c03-327f-b0ce-2edc829f0e34", + "deviceManufacturerCode": "IKEA of Sweden", + "locationId": "5b5f96b5-0286-4f4a-86ef-d5d5c1a78cb8", + "ownerId": "f43fd9e5-2ecd-4aae-aeac-73a8e5cb04da", + "roomId": "89f675a1-1f16-451c-8ab1-a7fdacc5852d", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "windowShade", + "version": 1 + }, + { + "id": "windowShadePreset", + "version": 1 + }, + { + "id": "windowShadeLevel", + "version": 1 + }, + { + "id": "battery", + "version": 1 + }, + { + "id": "firmwareUpdate", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + } + ], + "categories": [ + { + "name": "Blind", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2023-04-26T18:19:06.792Z", + "parentDeviceId": "3ffe04c4-a12c-41f5-b83d-c1b28eca2b5f", + "profile": { + "id": "6d9804bc-9e56-3823-95be-4b315669c481" + }, + "zigbee": { + "eui": "000D6FFFFE2AD0E7", + "networkId": "3009", + "driverId": "46b8bada-1a55-4f84-8915-47ce2cad3621", + "executingLocally": true, + "hubId": "3ffe04c4-a12c-41f5-b83d-c1b28eca2b5f", + "provisioningState": "NONFUNCTIONAL" + }, + "type": "ZIGBEE", + "restrictionTier": 0, + "allowed": null, + "indoorMap": { + "coordinates": [10.0, 36.0, 98.0], + "rotation": [270.0, 0.0, 0.0], + "visible": true, + "data": null + }, + "executionContext": "LOCAL", + "relationships": [] + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/snapshots/test_cover.ambr b/tests/components/smartthings/snapshots/test_cover.ambr index aa928c09b7a..6877a8ccc01 100644 --- a/tests/components/smartthings/snapshots/test_cover.ambr +++ b/tests/components/smartthings/snapshots/test_cover.ambr @@ -49,3 +49,54 @@ 'state': 'open', }) # --- +# name: test_all_entities[ikea_kadrilj][cover.kitchen_ikea_kadrilj_window_blind-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.kitchen_ikea_kadrilj_window_blind', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '71afed1c-006d-4e48-b16e-e7f88f9fd638', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[ikea_kadrilj][cover.kitchen_ikea_kadrilj_window_blind-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'battery_level': 37, + 'current_position': 32, + 'device_class': 'shade', + 'friendly_name': 'Kitchen IKEA KADRILJ Window blind', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.kitchen_ikea_kadrilj_window_blind', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'open', + }) +# --- diff --git a/tests/components/smartthings/snapshots/test_init.ambr b/tests/components/smartthings/snapshots/test_init.ambr index 74297ac6a0b..825ab49e814 100644 --- a/tests/components/smartthings/snapshots/test_init.ambr +++ b/tests/components/smartthings/snapshots/test_init.ambr @@ -992,6 +992,39 @@ 'via_device_id': None, }) # --- +# name: test_devices[ikea_kadrilj] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '71afed1c-006d-4e48-b16e-e7f88f9fd638', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': None, + 'model': None, + 'model_id': None, + 'name': 'Kitchen IKEA KADRILJ Window blind', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- # name: test_devices[im_speaker_ai_0001] DeviceRegistryEntrySnapshot({ 'area_id': None, diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index 43d26f4f987..98e619596fd 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -5906,6 +5906,55 @@ 'state': '19.0', }) # --- +# name: test_all_entities[ikea_kadrilj][sensor.kitchen_ikea_kadrilj_window_blind_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.kitchen_ikea_kadrilj_window_blind_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': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '71afed1c-006d-4e48-b16e-e7f88f9fd638.battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[ikea_kadrilj][sensor.kitchen_ikea_kadrilj_window_blind_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Kitchen IKEA KADRILJ Window blind Battery', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.kitchen_ikea_kadrilj_window_blind_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '37', + }) +# --- # name: test_all_entities[im_speaker_ai_0001][sensor.galaxy_home_mini_media_input_source-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 3383e8b70d56e5255163ea55123882a327e59723 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Fri, 14 Mar 2025 00:47:11 +0100 Subject: [PATCH 2574/3148] Fix missing RGBW field description reference in Lokalise - step 1 (#140526) Empties the string to trigger an export to Lokalise. Will be followed up by a second PR to restore the reference. --- homeassistant/components/light/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/light/strings.json b/homeassistant/components/light/strings.json index c0f658c3a44..0a9686b601e 100644 --- a/homeassistant/components/light/strings.json +++ b/homeassistant/components/light/strings.json @@ -297,7 +297,7 @@ }, "rgbw_color": { "name": "[%key:component::light::common::field_rgbw_color_name%]", - "description": "[%key:component::light::common::field_rgbw_color_description%]" + "description": "" }, "rgbww_color": { "name": "[%key:component::light::common::field_rgbww_color_name%]", From f0b86c512dab12d60dd191e45c63dbaaf333f9d9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 13 Mar 2025 14:06:24 -1000 Subject: [PATCH 2575/3148] Bump habluetooth to 3.25.1 and bluetooth-auto-recovery to 1.4.5 (#140561) habluetooth: https://github.com/Bluetooth-Devices/habluetooth/compare/v3.25.0...v3.25.1 bluetooth-auto-recovery: https://github.com/Bluetooth-Devices/bluetooth-auto-recovery/compare/v1.4.4...v1.4.5 --- homeassistant/components/bluetooth/manifest.json | 4 ++-- homeassistant/package_constraints.txt | 4 ++-- requirements_all.txt | 4 ++-- requirements_test_all.txt | 4 ++-- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index f6fb4f68e91..45a424c48b2 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -18,9 +18,9 @@ "bleak==0.22.3", "bleak-retry-connector==3.9.0", "bluetooth-adapters==0.21.4", - "bluetooth-auto-recovery==1.4.4", + "bluetooth-auto-recovery==1.4.5", "bluetooth-data-tools==1.26.0", "dbus-fast==2.39.3", - "habluetooth==3.25.0" + "habluetooth==3.25.1" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 24ce6e23e86..b7cd4227715 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -22,7 +22,7 @@ bcrypt==4.2.0 bleak-retry-connector==3.9.0 bleak==0.22.3 bluetooth-adapters==0.21.4 -bluetooth-auto-recovery==1.4.4 +bluetooth-auto-recovery==1.4.5 bluetooth-data-tools==1.26.0 cached-ipaddress==0.10.0 certifi>=2021.5.30 @@ -33,7 +33,7 @@ dbus-fast==2.39.3 fnv-hash-fast==1.4.0 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 -habluetooth==3.25.0 +habluetooth==3.25.1 hass-nabucasa==0.94.0 hassil==2.2.3 home-assistant-bluetooth==1.13.1 diff --git a/requirements_all.txt b/requirements_all.txt index 9fc11d08b32..d6b5ba1c359 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -634,7 +634,7 @@ bluemaestro-ble==0.2.3 bluetooth-adapters==0.21.4 # homeassistant.components.bluetooth -bluetooth-auto-recovery==1.4.4 +bluetooth-auto-recovery==1.4.5 # homeassistant.components.bluetooth # homeassistant.components.ld2410_ble @@ -1109,7 +1109,7 @@ ha-philipsjs==3.2.2 habiticalib==0.3.7 # homeassistant.components.bluetooth -habluetooth==3.25.0 +habluetooth==3.25.1 # homeassistant.components.cloud hass-nabucasa==0.94.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fd6f2e9112a..7907e9474f2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -558,7 +558,7 @@ bluemaestro-ble==0.2.3 bluetooth-adapters==0.21.4 # homeassistant.components.bluetooth -bluetooth-auto-recovery==1.4.4 +bluetooth-auto-recovery==1.4.5 # homeassistant.components.bluetooth # homeassistant.components.ld2410_ble @@ -950,7 +950,7 @@ ha-philipsjs==3.2.2 habiticalib==0.3.7 # homeassistant.components.bluetooth -habluetooth==3.25.0 +habluetooth==3.25.1 # homeassistant.components.cloud hass-nabucasa==0.94.0 From 9f801e77859fb2497a9f09084e664162edc0ef93 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 13 Mar 2025 14:49:37 -1000 Subject: [PATCH 2576/3148] Bump dbus-fast to 2.39.5 (#140565) --- 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 45a424c48b2..50d115dc89b 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -20,7 +20,7 @@ "bluetooth-adapters==0.21.4", "bluetooth-auto-recovery==1.4.5", "bluetooth-data-tools==1.26.0", - "dbus-fast==2.39.3", + "dbus-fast==2.39.5", "habluetooth==3.25.1" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index b7cd4227715..b4823d1a549 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -29,7 +29,7 @@ certifi>=2021.5.30 ciso8601==2.3.2 cronsim==2.6 cryptography==44.0.1 -dbus-fast==2.39.3 +dbus-fast==2.39.5 fnv-hash-fast==1.4.0 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 diff --git a/requirements_all.txt b/requirements_all.txt index d6b5ba1c359..445d89ec651 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -744,7 +744,7 @@ datadog==0.15.0 datapoint==0.9.9 # homeassistant.components.bluetooth -dbus-fast==2.39.3 +dbus-fast==2.39.5 # homeassistant.components.debugpy debugpy==1.8.13 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7907e9474f2..12001c6a121 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -640,7 +640,7 @@ datadog==0.15.0 datapoint==0.9.9 # homeassistant.components.bluetooth -dbus-fast==2.39.3 +dbus-fast==2.39.5 # homeassistant.components.debugpy debugpy==1.8.13 From 6f926d0a66e72332ea7d5aa42800365b096620d8 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Fri, 14 Mar 2025 08:28:56 +0100 Subject: [PATCH 2577/3148] Add missing typing to Vodafone Station (#140562) --- .../components/vodafone_station/config_flow.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/vodafone_station/config_flow.py b/homeassistant/components/vodafone_station/config_flow.py index 7a80244f8d6..fd0683bdacc 100644 --- a/homeassistant/components/vodafone_station/config_flow.py +++ b/homeassistant/components/vodafone_station/config_flow.py @@ -12,16 +12,12 @@ from homeassistant.components.device_tracker import ( CONF_CONSIDER_HOME, DEFAULT_CONSIDER_HOME, ) -from homeassistant.config_entries import ( - ConfigEntry, - ConfigFlow, - ConfigFlowResult, - OptionsFlow, -) +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant, callback from .const import _LOGGER, DEFAULT_HOST, DEFAULT_USERNAME, DOMAIN +from .coordinator import VodafoneConfigEntry def user_form_schema(user_input: dict[str, Any] | None) -> vol.Schema: @@ -63,7 +59,7 @@ class VodafoneStationConfigFlow(ConfigFlow, domain=DOMAIN): @staticmethod @callback def async_get_options_flow( - config_entry: ConfigEntry, + config_entry: VodafoneConfigEntry, ) -> VodafoneStationOptionsFlowHandler: """Get the options flow for this handler.""" return VodafoneStationOptionsFlowHandler() From e42a6c5d4f9da680e68502f97f40be77ec136c3f Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Fri, 14 Mar 2025 08:51:49 +0100 Subject: [PATCH 2578/3148] Fix missing RGBW field description reference in Lokalise - step 2 (#140576) Reverts step 1, re-adding the field reference. --- homeassistant/components/light/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/light/strings.json b/homeassistant/components/light/strings.json index 0a9686b601e..c0f658c3a44 100644 --- a/homeassistant/components/light/strings.json +++ b/homeassistant/components/light/strings.json @@ -297,7 +297,7 @@ }, "rgbw_color": { "name": "[%key:component::light::common::field_rgbw_color_name%]", - "description": "" + "description": "[%key:component::light::common::field_rgbw_color_description%]" }, "rgbww_color": { "name": "[%key:component::light::common::field_rgbww_color_name%]", From 84667fd32dcfba52ce347d9e2c79f37aebcce495 Mon Sep 17 00:00:00 2001 From: Petro31 <35082313+Petro31@users.noreply.github.com> Date: Fri, 14 Mar 2025 04:00:46 -0400 Subject: [PATCH 2579/3148] Migrate template light to new style (#140326) * Migrate template light to new style * add modern templates to tests * fix comments --- homeassistant/components/template/config.py | 7 +- homeassistant/components/template/light.py | 216 ++- tests/components/template/conftest.py | 9 + tests/components/template/test_light.py | 1599 ++++++++++++------- 4 files changed, 1177 insertions(+), 654 deletions(-) diff --git a/homeassistant/components/template/config.py b/homeassistant/components/template/config.py index 9963731c784..07c3c1b437f 100644 --- a/homeassistant/components/template/config.py +++ b/homeassistant/components/template/config.py @@ -13,6 +13,7 @@ from homeassistant.components.blueprint import ( ) from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN from homeassistant.components.image import DOMAIN as IMAGE_DOMAIN +from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN from homeassistant.components.select import DOMAIN as SELECT_DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN @@ -36,6 +37,7 @@ from . import ( binary_sensor as binary_sensor_platform, button as button_platform, image as image_platform, + light as light_platform, number as number_platform, select as select_platform, sensor as sensor_platform, @@ -104,11 +106,14 @@ CONFIG_SECTION_SCHEMA = vol.Schema( vol.Optional(IMAGE_DOMAIN): vol.All( cv.ensure_list, [image_platform.IMAGE_SCHEMA] ), + vol.Optional(LIGHT_DOMAIN): vol.All( + cv.ensure_list, [light_platform.LIGHT_SCHEMA] + ), vol.Optional(WEATHER_DOMAIN): vol.All( cv.ensure_list, [weather_platform.WEATHER_SCHEMA] ), }, - ensure_domains_do_not_have_trigger_or_action(BUTTON_DOMAIN), + ensure_domains_do_not_have_trigger_or_action(BUTTON_DOMAIN, LIGHT_DOMAIN), ) ) diff --git a/homeassistant/components/template/light.py b/homeassistant/components/template/light.py index 352f571078a..1cc47c74aa0 100644 --- a/homeassistant/components/template/light.py +++ b/homeassistant/components/template/light.py @@ -26,9 +26,13 @@ from homeassistant.components.light import ( filter_supported_color_modes, ) from homeassistant.const import ( + CONF_EFFECT, CONF_ENTITY_ID, CONF_FRIENDLY_NAME, CONF_LIGHTS, + CONF_NAME, + CONF_RGB, + CONF_STATE, CONF_UNIQUE_ID, CONF_VALUE_TEMPLATE, STATE_OFF, @@ -36,15 +40,18 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import TemplateError -from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import config_validation as cv, template from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import color as color_util -from .const import DOMAIN +from .const import CONF_OBJECT_ID, CONF_PICTURE, DOMAIN from .template_entity import ( + LEGACY_FIELDS as TEMPLATE_ENTITY_LEGACY_FIELDS, + TEMPLATE_ENTITY_AVAILABILITY_SCHEMA, TEMPLATE_ENTITY_COMMON_SCHEMA_LEGACY, + TEMPLATE_ENTITY_ICON_SCHEMA, TemplateEntity, rewrite_common_legacy_to_modern_conf, ) @@ -56,33 +63,96 @@ _VALID_STATES = [STATE_ON, STATE_OFF, "true", "false"] CONF_COLOR_ACTION = "set_color" CONF_COLOR_TEMPLATE = "color_template" +CONF_HS = "hs" CONF_HS_ACTION = "set_hs" CONF_HS_TEMPLATE = "hs_template" CONF_RGB_ACTION = "set_rgb" CONF_RGB_TEMPLATE = "rgb_template" +CONF_RGBW = "rgbw" CONF_RGBW_ACTION = "set_rgbw" CONF_RGBW_TEMPLATE = "rgbw_template" +CONF_RGBWW = "rgbww" CONF_RGBWW_ACTION = "set_rgbww" CONF_RGBWW_TEMPLATE = "rgbww_template" CONF_EFFECT_ACTION = "set_effect" +CONF_EFFECT_LIST = "effect_list" CONF_EFFECT_LIST_TEMPLATE = "effect_list_template" CONF_EFFECT_TEMPLATE = "effect_template" +CONF_LEVEL = "level" CONF_LEVEL_ACTION = "set_level" CONF_LEVEL_TEMPLATE = "level_template" +CONF_MAX_MIREDS = "max_mireds" CONF_MAX_MIREDS_TEMPLATE = "max_mireds_template" +CONF_MIN_MIREDS = "min_mireds" CONF_MIN_MIREDS_TEMPLATE = "min_mireds_template" CONF_OFF_ACTION = "turn_off" CONF_ON_ACTION = "turn_on" -CONF_SUPPORTS_TRANSITION = "supports_transition_template" +CONF_SUPPORTS_TRANSITION = "supports_transition" +CONF_SUPPORTS_TRANSITION_TEMPLATE = "supports_transition_template" CONF_TEMPERATURE_ACTION = "set_temperature" +CONF_TEMPERATURE = "temperature" CONF_TEMPERATURE_TEMPLATE = "temperature_template" CONF_WHITE_VALUE_ACTION = "set_white_value" +CONF_WHITE_VALUE = "white_value" CONF_WHITE_VALUE_TEMPLATE = "white_value_template" DEFAULT_MIN_MIREDS = 153 DEFAULT_MAX_MIREDS = 500 -LIGHT_SCHEMA = vol.All( +LEGACY_FIELDS = TEMPLATE_ENTITY_LEGACY_FIELDS | { + CONF_COLOR_ACTION: CONF_HS_ACTION, + CONF_COLOR_TEMPLATE: CONF_HS, + CONF_EFFECT_LIST_TEMPLATE: CONF_EFFECT_LIST, + CONF_EFFECT_TEMPLATE: CONF_EFFECT, + CONF_HS_TEMPLATE: CONF_HS, + CONF_LEVEL_TEMPLATE: CONF_LEVEL, + CONF_MAX_MIREDS_TEMPLATE: CONF_MAX_MIREDS, + CONF_MIN_MIREDS_TEMPLATE: CONF_MIN_MIREDS, + CONF_RGB_TEMPLATE: CONF_RGB, + CONF_RGBW_TEMPLATE: CONF_RGBW, + CONF_RGBWW_TEMPLATE: CONF_RGBWW, + CONF_SUPPORTS_TRANSITION_TEMPLATE: CONF_SUPPORTS_TRANSITION, + CONF_TEMPERATURE_TEMPLATE: CONF_TEMPERATURE, + CONF_VALUE_TEMPLATE: CONF_STATE, + CONF_WHITE_VALUE_TEMPLATE: CONF_WHITE_VALUE, +} + +DEFAULT_NAME = "Template Light" + +LIGHT_SCHEMA = ( + vol.Schema( + { + vol.Inclusive(CONF_EFFECT_ACTION, "effect"): cv.SCRIPT_SCHEMA, + vol.Inclusive(CONF_EFFECT_LIST, "effect"): cv.template, + vol.Inclusive(CONF_EFFECT, "effect"): cv.template, + vol.Optional(CONF_HS_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_HS): cv.template, + vol.Optional(CONF_LEVEL_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_LEVEL): cv.template, + vol.Optional(CONF_MAX_MIREDS): cv.template, + vol.Optional(CONF_MIN_MIREDS): cv.template, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.template, + vol.Optional(CONF_PICTURE): cv.template, + vol.Optional(CONF_RGB_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_RGB): cv.template, + vol.Optional(CONF_RGBW_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_RGBW): cv.template, + vol.Optional(CONF_RGBWW_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_RGBWW): cv.template, + vol.Optional(CONF_STATE): cv.template, + vol.Optional(CONF_SUPPORTS_TRANSITION): cv.template, + vol.Optional(CONF_TEMPERATURE_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_TEMPERATURE): cv.template, + vol.Optional(CONF_UNIQUE_ID): cv.string, + vol.Required(CONF_OFF_ACTION): cv.SCRIPT_SCHEMA, + vol.Required(CONF_ON_ACTION): cv.SCRIPT_SCHEMA, + } + ) + .extend(TEMPLATE_ENTITY_AVAILABILITY_SCHEMA.schema) + .extend(TEMPLATE_ENTITY_ICON_SCHEMA.schema) +) + +LEGACY_LIGHT_SCHEMA = vol.All( cv.deprecated(CONF_ENTITY_ID), vol.Schema( { @@ -107,7 +177,7 @@ LIGHT_SCHEMA = vol.All( vol.Optional(CONF_MIN_MIREDS_TEMPLATE): cv.template, vol.Required(CONF_OFF_ACTION): cv.SCRIPT_SCHEMA, vol.Required(CONF_ON_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_SUPPORTS_TRANSITION): cv.template, + vol.Optional(CONF_SUPPORTS_TRANSITION_TEMPLATE): cv.template, vol.Optional(CONF_TEMPERATURE_ACTION): cv.SCRIPT_SCHEMA, vol.Optional(CONF_TEMPERATURE_TEMPLATE): cv.template, vol.Optional(CONF_UNIQUE_ID): cv.string, @@ -121,29 +191,50 @@ PLATFORM_SCHEMA = vol.All( cv.removed(CONF_WHITE_VALUE_ACTION), cv.removed(CONF_WHITE_VALUE_TEMPLATE), LIGHT_PLATFORM_SCHEMA.extend( - {vol.Required(CONF_LIGHTS): cv.schema_with_slug_keys(LIGHT_SCHEMA)} + {vol.Required(CONF_LIGHTS): cv.schema_with_slug_keys(LEGACY_LIGHT_SCHEMA)} ), ) -async def _async_create_entities(hass: HomeAssistant, config): +def rewrite_legacy_to_modern_conf( + hass: HomeAssistant, config: dict[str, dict] +) -> list[dict]: + """Rewrite legacy switch configuration definitions to modern ones.""" + lights = [] + for object_id, entity_conf in config.items(): + entity_conf = {**entity_conf, CONF_OBJECT_ID: object_id} + + entity_conf = rewrite_common_legacy_to_modern_conf( + hass, entity_conf, LEGACY_FIELDS + ) + + if CONF_NAME not in entity_conf: + entity_conf[CONF_NAME] = template.Template(object_id, hass) + + lights.append(entity_conf) + + return lights + + +@callback +def _async_create_template_tracking_entities( + async_add_entities: AddEntitiesCallback, + hass: HomeAssistant, + definitions: list[dict], + unique_id_prefix: str | None, +) -> None: """Create the Template Lights.""" lights = [] - for object_id, entity_config in config[CONF_LIGHTS].items(): - entity_config = rewrite_common_legacy_to_modern_conf(hass, entity_config) - unique_id = entity_config.get(CONF_UNIQUE_ID) + for entity_conf in definitions: + unique_id = entity_conf.get(CONF_UNIQUE_ID) - lights.append( - LightTemplate( - hass, - object_id, - entity_config, - unique_id, - ) - ) + if unique_id and unique_id_prefix: + unique_id = f"{unique_id_prefix}-{unique_id}" - return lights + lights.append(LightTemplate(hass, entity_conf, unique_id)) + + async_add_entities(lights) async def async_setup_platform( @@ -153,7 +244,21 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the template lights.""" - async_add_entities(await _async_create_entities(hass, config)) + if discovery_info is None: + _async_create_template_tracking_entities( + async_add_entities, + hass, + rewrite_legacy_to_modern_conf(hass, config[CONF_LIGHTS]), + None, + ) + return + + _async_create_template_tracking_entities( + async_add_entities, + hass, + discovery_info["entities"], + discovery_info["unique_id"], + ) class LightTemplate(TemplateEntity, LightEntity): @@ -164,33 +269,30 @@ class LightTemplate(TemplateEntity, LightEntity): def __init__( self, hass: HomeAssistant, - object_id, config: dict[str, Any], - unique_id, + unique_id: str | None, ) -> None: """Initialize the light.""" - super().__init__( - hass, config=config, fallback_name=object_id, unique_id=unique_id - ) - self.entity_id = async_generate_entity_id( - ENTITY_ID_FORMAT, object_id, hass=hass - ) + super().__init__(hass, config=config, fallback_name=None, unique_id=unique_id) + if (object_id := config.get(CONF_OBJECT_ID)) is not None: + self.entity_id = async_generate_entity_id( + ENTITY_ID_FORMAT, object_id, hass=hass + ) name = self._attr_name if TYPE_CHECKING: assert name is not None - self._template = config.get(CONF_VALUE_TEMPLATE) - self._level_template = config.get(CONF_LEVEL_TEMPLATE) - self._temperature_template = config.get(CONF_TEMPERATURE_TEMPLATE) - self._color_template = config.get(CONF_COLOR_TEMPLATE) - self._hs_template = config.get(CONF_HS_TEMPLATE) - self._rgb_template = config.get(CONF_RGB_TEMPLATE) - self._rgbw_template = config.get(CONF_RGBW_TEMPLATE) - self._rgbww_template = config.get(CONF_RGBWW_TEMPLATE) - self._effect_list_template = config.get(CONF_EFFECT_LIST_TEMPLATE) - self._effect_template = config.get(CONF_EFFECT_TEMPLATE) - self._max_mireds_template = config.get(CONF_MAX_MIREDS_TEMPLATE) - self._min_mireds_template = config.get(CONF_MIN_MIREDS_TEMPLATE) + self._template = config.get(CONF_STATE) + self._level_template = config.get(CONF_LEVEL) + self._temperature_template = config.get(CONF_TEMPERATURE) + self._hs_template = config.get(CONF_HS) + self._rgb_template = config.get(CONF_RGB) + self._rgbw_template = config.get(CONF_RGBW) + self._rgbww_template = config.get(CONF_RGBWW) + self._effect_list_template = config.get(CONF_EFFECT_LIST) + self._effect_template = config.get(CONF_EFFECT) + self._max_mireds_template = config.get(CONF_MAX_MIREDS) + self._min_mireds_template = config.get(CONF_MIN_MIREDS) self._supports_transition_template = config.get(CONF_SUPPORTS_TRANSITION) for action_id in (CONF_ON_ACTION, CONF_OFF_ACTION, CONF_EFFECT_ACTION): @@ -216,7 +318,6 @@ class LightTemplate(TemplateEntity, LightEntity): for action_id, color_mode in ( (CONF_TEMPERATURE_ACTION, ColorMode.COLOR_TEMP), (CONF_LEVEL_ACTION, ColorMode.BRIGHTNESS), - (CONF_COLOR_ACTION, ColorMode.HS), (CONF_HS_ACTION, ColorMode.HS), (CONF_RGB_ACTION, ColorMode.RGB), (CONF_RGBW_ACTION, ColorMode.RGBW), @@ -349,14 +450,6 @@ class LightTemplate(TemplateEntity, LightEntity): self._update_temperature, none_on_template_error=True, ) - if self._color_template: - self.add_template_attribute( - "_hs_color", - self._color_template, - None, - self._update_hs, - none_on_template_error=True, - ) if self._hs_template: self.add_template_attribute( "_hs_color", @@ -440,7 +533,7 @@ class LightTemplate(TemplateEntity, LightEntity): ) self._color_mode = ColorMode.COLOR_TEMP self._temperature = color_temp - if self._hs_template is None and self._color_template is None: + if self._hs_template is None: self._hs_color = None if self._rgb_template is None: self._rgb_color = None @@ -450,11 +543,7 @@ class LightTemplate(TemplateEntity, LightEntity): self._rgbww_color = None optimistic_set = True - if ( - self._hs_template is None - and self._color_template is None - and ATTR_HS_COLOR in kwargs - ): + if self._hs_template is None and ATTR_HS_COLOR in kwargs: _LOGGER.debug( "Optimistically setting hs color to %s", kwargs[ATTR_HS_COLOR], @@ -480,7 +569,7 @@ class LightTemplate(TemplateEntity, LightEntity): self._rgb_color = kwargs[ATTR_RGB_COLOR] if self._temperature_template is None: self._temperature = None - if self._hs_template is None and self._color_template is None: + if self._hs_template is None: self._hs_color = None if self._rgbw_template is None: self._rgbw_color = None @@ -497,7 +586,7 @@ class LightTemplate(TemplateEntity, LightEntity): self._rgbw_color = kwargs[ATTR_RGBW_COLOR] if self._temperature_template is None: self._temperature = None - if self._hs_template is None and self._color_template is None: + if self._hs_template is None: self._hs_color = None if self._rgb_template is None: self._rgb_color = None @@ -514,7 +603,7 @@ class LightTemplate(TemplateEntity, LightEntity): self._rgbww_color = kwargs[ATTR_RGBWW_COLOR] if self._temperature_template is None: self._temperature = None - if self._hs_template is None and self._color_template is None: + if self._hs_template is None: self._hs_color = None if self._rgb_template is None: self._rgb_color = None @@ -561,17 +650,6 @@ class LightTemplate(TemplateEntity, LightEntity): await self.async_run_script( effect_script, run_variables=common_params, context=self._context ) - elif ATTR_HS_COLOR in kwargs and ( - color_script := self._action_scripts.get(CONF_COLOR_ACTION) - ): - hs_value = kwargs[ATTR_HS_COLOR] - common_params["hs"] = hs_value - common_params["h"] = int(hs_value[0]) - common_params["s"] = int(hs_value[1]) - - await self.async_run_script( - color_script, run_variables=common_params, context=self._context - ) elif ATTR_HS_COLOR in kwargs and ( hs_script := self._action_scripts.get(CONF_HS_ACTION) ): diff --git a/tests/components/template/conftest.py b/tests/components/template/conftest.py index bdca84ba071..86a30535e92 100644 --- a/tests/components/template/conftest.py +++ b/tests/components/template/conftest.py @@ -1,5 +1,7 @@ """template conftest.""" +from enum import Enum + import pytest from homeassistant.core import HomeAssistant, ServiceCall @@ -9,6 +11,13 @@ from homeassistant.setup import async_setup_component from tests.common import assert_setup_component, async_mock_service +class ConfigurationStyle(Enum): + """Configuration Styles for template testing.""" + + LEGACY = "Legacy" + MODERN = "Modern" + + @pytest.fixture def calls(hass: HomeAssistant) -> list[ServiceCall]: """Track calls to a mock service.""" diff --git a/tests/components/template/test_light.py b/tests/components/template/test_light.py index a94ec233f81..1a739b4921e 100644 --- a/tests/components/template/test_light.py +++ b/tests/components/template/test_light.py @@ -4,7 +4,7 @@ from typing import Any import pytest -from homeassistant.components import light +from homeassistant.components import light, template from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_TEMP_KELVIN, @@ -17,6 +17,7 @@ from homeassistant.components.light import ( ColorMode, LightEntityFeature, ) +from homeassistant.components.template.light import rewrite_legacy_to_modern_conf from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_TURN_OFF, @@ -26,8 +27,12 @@ from homeassistant.const import ( STATE_UNAVAILABLE, ) from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.template import Template from homeassistant.setup import async_setup_component +from .conftest import ConfigurationStyle + from tests.common import assert_setup_component # Represent for light's availability @@ -154,10 +159,245 @@ OPTIMISTIC_RGBWW_COLOR_LIGHT_CONFIG = { } -async def async_setup_light( +TEST_MISSING_KEY_CONFIG = { + "turn_on": { + "service": "light.turn_on", + "entity_id": "light.test_state", + }, + "set_level": { + "service": "light.turn_on", + "data_template": { + "entity_id": "light.test_state", + "brightness": "{{brightness}}", + }, + }, +} + + +TEST_ON_ACTION_WITH_TRANSITION_CONFIG = { + "turn_on": { + "service": "test.automation", + "data_template": { + "transition": "{{transition}}", + }, + }, + "turn_off": { + "service": "light.turn_off", + "entity_id": "light.test_state", + }, + "set_level": { + "service": "light.turn_on", + "data_template": { + "entity_id": "light.test_state", + "brightness": "{{brightness}}", + "transition": "{{transition}}", + }, + }, +} + + +TEST_OFF_ACTION_WITH_TRANSITION_CONFIG = { + "turn_on": { + "service": "light.turn_on", + "entity_id": "light.test_state", + }, + "turn_off": { + "service": "test.automation", + "data_template": { + "transition": "{{transition}}", + }, + }, + "set_level": { + "service": "light.turn_on", + "data_template": { + "entity_id": "light.test_state", + "brightness": "{{brightness}}", + "transition": "{{transition}}", + }, + }, +} + + +TEST_ALL_COLORS_NO_TEMPLATE_CONFIG = { + "set_hs": { + "service": "test.automation", + "data_template": { + "entity_id": "test.test_state", + "h": "{{h}}", + "s": "{{s}}", + }, + }, + "set_temperature": { + "service": "test.automation", + "data_template": { + "entity_id": "test.test_state", + "color_temp": "{{color_temp}}", + }, + }, + "set_rgb": { + "service": "test.automation", + "data_template": { + "entity_id": "test.test_state", + "r": "{{r}}", + "g": "{{g}}", + "b": "{{b}}", + }, + }, + "set_rgbw": { + "service": "test.automation", + "data_template": { + "entity_id": "test.test_state", + "r": "{{r}}", + "g": "{{g}}", + "b": "{{b}}", + "w": "{{w}}", + }, + }, + "set_rgbww": { + "service": "test.automation", + "data_template": { + "entity_id": "test.test_state", + "r": "{{r}}", + "g": "{{g}}", + "b": "{{b}}", + "cw": "{{cw}}", + "ww": "{{ww}}", + }, + }, +} + + +TEST_UNIQUE_ID_CONFIG = { + **OPTIMISTIC_ON_OFF_LIGHT_CONFIG, + "unique_id": "not-so-unique-anymore", +} + + +@pytest.mark.parametrize( + ("old_attr", "new_attr", "attr_template"), + [ + ( + "value_template", + "state", + "{{ 1 == 1 }}", + ), + ( + "rgb_template", + "rgb", + "{{ (255,255,255) }}", + ), + ( + "rgbw_template", + "rgbw", + "{{ (255,255,255,255) }}", + ), + ( + "rgbww_template", + "rgbww", + "{{ (255,255,255,255,255) }}", + ), + ( + "effect_list_template", + "effect_list", + "{{ ['a', 'b'] }}", + ), + ( + "effect_template", + "effect", + "{{ 'a' }}", + ), + ( + "level_template", + "level", + "{{ 255 }}", + ), + ( + "max_mireds_template", + "max_mireds", + "{{ 255 }}", + ), + ( + "min_mireds_template", + "min_mireds", + "{{ 255 }}", + ), + ( + "supports_transition_template", + "supports_transition", + "{{ True }}", + ), + ( + "temperature_template", + "temperature", + "{{ 255 }}", + ), + ( + "white_value_template", + "white_value", + "{{ 255 }}", + ), + ( + "hs_template", + "hs", + "{{ (255, 255) }}", + ), + ( + "color_template", + "hs", + "{{ (255, 255) }}", + ), + ], +) +async def test_legacy_to_modern_config( + hass: HomeAssistant, old_attr: str, new_attr: str, attr_template: str +) -> None: + """Test the conversion of legacy template to modern template.""" + config = { + "foo": { + "friendly_name": "foo bar", + "unique_id": "foo-bar-light", + "icon_template": "{{ 'mdi.abc' }}", + "entity_picture_template": "{{ 'mypicture.jpg' }}", + "availability_template": "{{ 1 == 1 }}", + old_attr: attr_template, + **OPTIMISTIC_ON_OFF_LIGHT_CONFIG, + } + } + altered_configs = rewrite_legacy_to_modern_conf(hass, config) + + assert len(altered_configs) == 1 + + assert [ + { + "availability": Template("{{ 1 == 1 }}", hass), + "icon": Template("{{ 'mdi.abc' }}", hass), + "name": Template("foo bar", hass), + "object_id": "foo", + "picture": Template("{{ 'mypicture.jpg' }}", hass), + "turn_off": { + "data_template": { + "action": "turn_off", + "caller": "{{ this.entity_id }}", + }, + "service": "test.automation", + }, + "turn_on": { + "data_template": { + "action": "turn_on", + "caller": "{{ this.entity_id }}", + }, + "service": "test.automation", + }, + "unique_id": "foo-bar-light", + new_attr: Template(attr_template, hass), + } + ] == altered_configs + + +async def async_setup_legacy_format( hass: HomeAssistant, count: int, light_config: dict[str, Any] ) -> None: - """Do setup of light integration.""" + """Do setup of light integration via legacy format.""" config = {"light": {"platform": "template", "lights": light_config}} with assert_setup_component(count, light.DOMAIN): @@ -172,12 +412,291 @@ async def async_setup_light( await hass.async_block_till_done() -@pytest.fixture -async def setup_light( +async def async_setup_legacy_format_with_attribute( + hass: HomeAssistant, + count: int, + attribute: str, + attribute_template: str, + extra_config: dict, +) -> None: + """Do setup of a legacy light that has a single templated attribute.""" + extra = {attribute: attribute_template} if attribute and attribute_template else {} + await async_setup_legacy_format( + hass, + count, + { + "test_template_light": { + **extra_config, + "value_template": "{{ 1 == 1 }}", + **extra, + } + }, + ) + + +async def async_setup_new_format( hass: HomeAssistant, count: int, light_config: dict[str, Any] +) -> None: + """Do setup of light integration via new format.""" + config = {"template": {"light": light_config}} + + with assert_setup_component(count, template.DOMAIN): + assert await async_setup_component( + hass, + template.DOMAIN, + config, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + +async def async_setup_modern_format_with_attribute( + hass: HomeAssistant, + count: int, + attribute: str, + attribute_template: str, + extra_config: dict, +) -> None: + """Do setup of a legacy light that has a single templated attribute.""" + extra = {attribute: attribute_template} if attribute and attribute_template else {} + await async_setup_new_format( + hass, + count, + { + "name": "test_template_light", + **extra_config, + "state": "{{ 1 == 1 }}", + **extra, + }, + ) + + +@pytest.fixture +async def setup_light( + hass: HomeAssistant, + count: int, + style: ConfigurationStyle, + light_config: dict[str, Any], ) -> None: """Do setup of light integration.""" - await async_setup_light(hass, count, light_config) + if style == ConfigurationStyle.LEGACY: + await async_setup_legacy_format(hass, count, light_config) + elif style == ConfigurationStyle.MODERN: + await async_setup_new_format(hass, count, light_config) + + +@pytest.fixture +async def setup_state_light( + hass: HomeAssistant, + count: int, + style: ConfigurationStyle, + state_template: str, +): + """Do setup of light integration.""" + if style == ConfigurationStyle.LEGACY: + await async_setup_legacy_format( + hass, + count, + { + "test_template_light": { + **OPTIMISTIC_BRIGHTNESS_LIGHT_CONFIG, + "value_template": state_template, + } + }, + ) + elif style == ConfigurationStyle.MODERN: + await async_setup_new_format( + hass, + count, + { + **OPTIMISTIC_BRIGHTNESS_LIGHT_CONFIG, + "name": "test_template_light", + "state": state_template, + }, + ) + + +@pytest.fixture +async def setup_single_attribute_light( + hass: HomeAssistant, + count: int, + style: ConfigurationStyle, + attribute: str, + attribute_template: str, + extra_config: dict, +) -> None: + """Do setup of light integration.""" + if style == ConfigurationStyle.LEGACY: + await async_setup_legacy_format_with_attribute( + hass, count, attribute, attribute_template, extra_config + ) + elif style == ConfigurationStyle.MODERN: + await async_setup_modern_format_with_attribute( + hass, count, attribute, attribute_template, extra_config + ) + + +@pytest.fixture +async def setup_single_action_light( + hass: HomeAssistant, + count: int, + style: ConfigurationStyle, + extra_config: dict, +) -> None: + """Do setup of light integration.""" + if style == ConfigurationStyle.LEGACY: + await async_setup_legacy_format_with_attribute( + hass, count, "", "", extra_config + ) + elif style == ConfigurationStyle.MODERN: + await async_setup_modern_format_with_attribute( + hass, count, "", "", extra_config + ) + + +@pytest.fixture +async def setup_light_with_effects( + hass: HomeAssistant, + count: int, + style: ConfigurationStyle, + effect_list_template: str, + effect_template: str, +) -> None: + """Do setup of light with effects.""" + common = { + "set_effect": { + "service": "test.automation", + "data_template": { + "action": "set_effect", + "caller": "{{ this.entity_id }}", + "entity_id": "test.test_state", + "effect": "{{effect}}", + }, + }, + } + if style == ConfigurationStyle.LEGACY: + await async_setup_legacy_format( + hass, + count, + { + "test_template_light": { + **OPTIMISTIC_BRIGHTNESS_LIGHT_CONFIG, + "value_template": "{{true}}", + **common, + "effect_list_template": effect_list_template, + "effect_template": effect_template, + } + }, + ) + elif style == ConfigurationStyle.MODERN: + await async_setup_new_format( + hass, + count, + { + "name": "test_template_light", + **OPTIMISTIC_BRIGHTNESS_LIGHT_CONFIG, + "state": "{{true}}", + **common, + "effect_list": effect_list_template, + "effect": effect_template, + }, + ) + + +@pytest.fixture +async def setup_light_with_mireds( + hass: HomeAssistant, + count: int, + style: ConfigurationStyle, + attribute: str, + attribute_template: str, +) -> None: + """Do setup of light that uses mireds.""" + common = { + "set_temperature": { + "service": "light.turn_on", + "data_template": { + "entity_id": "light.test_state", + "color_temp": "{{color_temp}}", + }, + }, + attribute: attribute_template, + } + if style == ConfigurationStyle.LEGACY: + await async_setup_legacy_format( + hass, + count, + { + "test_template_light": { + **OPTIMISTIC_ON_OFF_LIGHT_CONFIG, + "value_template": "{{ 1 == 1 }}", + **common, + "temperature_template": "{{200}}", + } + }, + ) + elif style == ConfigurationStyle.MODERN: + await async_setup_new_format( + hass, + count, + { + "name": "test_template_light", + **OPTIMISTIC_ON_OFF_LIGHT_CONFIG, + "state": "{{ 1 == 1 }}", + **common, + "temperature": "{{200}}", + }, + ) + + +@pytest.fixture +async def setup_light_with_transition_template( + hass: HomeAssistant, + count: int, + style: ConfigurationStyle, + transition_template: str, +) -> None: + """Do setup of light that uses mireds.""" + common = { + "set_effect": { + "service": "test.automation", + "data_template": { + "entity_id": "test.test_state", + "effect": "{{effect}}", + }, + }, + } + if style == ConfigurationStyle.LEGACY: + await async_setup_legacy_format( + hass, + count, + { + "test_template_light": { + **OPTIMISTIC_COLOR_TEMP_LIGHT_CONFIG, + "value_template": "{{ 1 == 1 }}", + **common, + "effect_list_template": "{{ ['Disco', 'Police'] }}", + "effect_template": "{{ None }}", + "supports_transition_template": transition_template, + } + }, + ) + elif style == ConfigurationStyle.MODERN: + await async_setup_new_format( + hass, + count, + { + "name": "test_template_light", + **OPTIMISTIC_COLOR_TEMP_LIGHT_CONFIG, + "state": "{{ 1 == 1 }}", + **common, + "effect_list": "{{ ['Disco', 'Police'] }}", + "effect": "{{ None }}", + "supports_transition": transition_template, + }, + ) @pytest.mark.parametrize("count", [1]) @@ -186,18 +705,15 @@ async def setup_light( [(0, [ColorMode.BRIGHTNESS])], ) @pytest.mark.parametrize( - "light_config", + "style", [ - { - "test_template_light": { - **OPTIMISTIC_BRIGHTNESS_LIGHT_CONFIG, - "value_template": "{{states.test['big.fat...']}}", - } - }, + ConfigurationStyle.LEGACY, + ConfigurationStyle.MODERN, ], ) +@pytest.mark.parametrize("state_template", ["{{states.test['big.fat...']}}"]) async def test_template_state_invalid( - hass: HomeAssistant, supported_features, supported_color_modes, setup_light + hass: HomeAssistant, supported_features, supported_color_modes, setup_state_light ) -> None: """Test template state with render error.""" state = hass.states.get("light.test_template_light") @@ -209,17 +725,14 @@ async def test_template_state_invalid( @pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - "light_config", + "style", [ - { - "test_template_light": { - **OPTIMISTIC_BRIGHTNESS_LIGHT_CONFIG, - "value_template": "{{ states.light.test_state.state }}", - } - }, + ConfigurationStyle.LEGACY, + ConfigurationStyle.MODERN, ], ) -async def test_template_state_text(hass: HomeAssistant, setup_light) -> None: +@pytest.mark.parametrize("state_template", ["{{ states.light.test_state.state }}"]) +async def test_template_state_text(hass: HomeAssistant, setup_state_light) -> None: """Test the state text of a template.""" set_state = STATE_ON hass.states.async_set("light.test_state", set_state) @@ -242,7 +755,14 @@ async def test_template_state_text(hass: HomeAssistant, setup_light) -> None: @pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - ("value_template", "expected_state", "expected_color_mode"), + "style", + [ + ConfigurationStyle.LEGACY, + ConfigurationStyle.MODERN, + ], +) +@pytest.mark.parametrize( + ("state_template", "expected_state", "expected_color_mode"), [ ( "{{ 1 == 1 }}", @@ -256,21 +776,13 @@ async def test_template_state_text(hass: HomeAssistant, setup_light) -> None: ), ], ) -async def test_templatex_state_boolean( +async def test_legacy_template_state_boolean( hass: HomeAssistant, expected_color_mode, expected_state, - count, - value_template, + setup_state_light, ) -> None: """Test the setting of the state with boolean on.""" - light_config = { - "test_template_light": { - **OPTIMISTIC_BRIGHTNESS_LIGHT_CONFIG, - "value_template": value_template, - } - } - await async_setup_light(hass, count, light_config) state = hass.states.get("light.test_template_light") assert state.state == expected_state assert state.attributes.get("color_mode") == expected_color_mode @@ -280,48 +792,56 @@ async def test_templatex_state_boolean( @pytest.mark.parametrize("count", [0]) @pytest.mark.parametrize( - "light_config", + ("light_config", "style"), [ - { - "test_template_light": { + ( + { + "test_template_light": { + **OPTIMISTIC_BRIGHTNESS_LIGHT_CONFIG, + "value_template": "{%- if false -%}", + } + }, + ConfigurationStyle.LEGACY, + ), + ( + { + "bad name here": { + **OPTIMISTIC_BRIGHTNESS_LIGHT_CONFIG, + "value_template": "{{ 1== 1}}", + } + }, + ConfigurationStyle.LEGACY, + ), + ( + {"test_template_light": "Invalid"}, + ConfigurationStyle.LEGACY, + ), + ( + { **OPTIMISTIC_BRIGHTNESS_LIGHT_CONFIG, - "value_template": "{%- if false -%}", - } - }, - { - "bad name here": { - **OPTIMISTIC_BRIGHTNESS_LIGHT_CONFIG, - "value_template": "{{ 1== 1}}", - } - }, - {"test_template_light": "Invalid"}, + "name": "test_template_light", + "state": "{%- if false -%}", + }, + ConfigurationStyle.MODERN, + ), ], ) -async def test_template_syntax_error(hass: HomeAssistant, setup_light) -> None: - """Test templating syntax error.""" +async def test_template_config_errors(hass: HomeAssistant, setup_light) -> None: + """Test template light configuration errors.""" assert hass.states.async_all("light") == [] @pytest.mark.parametrize( - ("light_config", "count"), + ("light_config", "style", "count"), [ ( - { - "light_one": { - "value_template": "{{ 1== 1}}", - "turn_on": { - "service": "light.turn_on", - "entity_id": "light.test_state", - }, - "set_level": { - "service": "light.turn_on", - "data_template": { - "entity_id": "light.test_state", - "brightness": "{{brightness}}", - }, - }, - } - }, + {"light_one": {"value_template": "{{ 1== 1}}", **TEST_MISSING_KEY_CONFIG}}, + ConfigurationStyle.LEGACY, + 0, + ), + ( + {"name": "light_one", "state": "{{ 1== 1}}", **TEST_MISSING_KEY_CONFIG}, + ConfigurationStyle.MODERN, 0, ), ], @@ -336,18 +856,15 @@ async def test_missing_key(hass: HomeAssistant, count, setup_light) -> None: @pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - "light_config", + "style", [ - { - "test_template_light": { - **OPTIMISTIC_BRIGHTNESS_LIGHT_CONFIG, - "value_template": "{{states.light.test_state.state}}", - } - }, + ConfigurationStyle.LEGACY, + ConfigurationStyle.MODERN, ], ) +@pytest.mark.parametrize("state_template", ["{{ states.light.test_state.state }}"]) async def test_on_action( - hass: HomeAssistant, setup_light, calls: list[ServiceCall] + hass: HomeAssistant, setup_state_light, calls: list[ServiceCall] ) -> None: """Test on action.""" hass.states.async_set("light.test_state", STATE_OFF) @@ -378,32 +895,26 @@ async def test_on_action( @pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - "light_config", + ("light_config", "style"), [ - { - "test_template_light": { - "value_template": "{{states.light.test_state.state}}", - "turn_on": { - "service": "test.automation", - "data_template": { - "transition": "{{transition}}", - }, - }, - "turn_off": { - "service": "light.turn_off", - "entity_id": "light.test_state", - }, - "supports_transition_template": "{{true}}", - "set_level": { - "service": "light.turn_on", - "data_template": { - "entity_id": "light.test_state", - "brightness": "{{brightness}}", - "transition": "{{transition}}", - }, - }, - } - }, + ( + { + "test_template_light": { + "value_template": "{{states.light.test_state.state}}", + **TEST_ON_ACTION_WITH_TRANSITION_CONFIG, + "supports_transition_template": "{{true}}", + } + }, + ConfigurationStyle.LEGACY, + ), + ( + { + "name": "test_template_light", + **TEST_ON_ACTION_WITH_TRANSITION_CONFIG, + "supports_transition": "{{true}}", + }, + ConfigurationStyle.MODERN, + ), ], ) async def test_on_action_with_transition( @@ -437,13 +948,23 @@ async def test_on_action_with_transition( @pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - "light_config", + ("light_config", "style"), [ - { - "test_template_light": { + ( + { + "test_template_light": { + **OPTIMISTIC_BRIGHTNESS_LIGHT_CONFIG, + } + }, + ConfigurationStyle.LEGACY, + ), + ( + { + "name": "test_template_light", **OPTIMISTIC_BRIGHTNESS_LIGHT_CONFIG, - } - }, + }, + ConfigurationStyle.MODERN, + ), ], ) async def test_on_action_optimistic( @@ -497,18 +1018,15 @@ async def test_on_action_optimistic( @pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - "light_config", + "style", [ - { - "test_template_light": { - **OPTIMISTIC_BRIGHTNESS_LIGHT_CONFIG, - "value_template": "{{states.light.test_state.state}}", - } - }, + ConfigurationStyle.LEGACY, + ConfigurationStyle.MODERN, ], ) +@pytest.mark.parametrize("state_template", ["{{ states.light.test_state.state }}"]) async def test_off_action( - hass: HomeAssistant, setup_light, calls: list[ServiceCall] + hass: HomeAssistant, setup_state_light, calls: list[ServiceCall] ) -> None: """Test off action.""" hass.states.async_set("light.test_state", STATE_ON) @@ -538,32 +1056,27 @@ async def test_off_action( @pytest.mark.parametrize("count", [(1)]) @pytest.mark.parametrize( - "light_config", + ("light_config", "style"), [ - { - "test_template_light": { - "value_template": "{{states.light.test_state.state}}", - "turn_on": { - "service": "light.turn_on", - "entity_id": "light.test_state", - }, - "turn_off": { - "service": "test.automation", - "data_template": { - "transition": "{{transition}}", - }, - }, - "supports_transition_template": "{{true}}", - "set_level": { - "service": "light.turn_on", - "data_template": { - "entity_id": "light.test_state", - "brightness": "{{brightness}}", - "transition": "{{transition}}", - }, - }, - } - }, + ( + { + "test_template_light": { + "value_template": "{{states.light.test_state.state}}", + **TEST_OFF_ACTION_WITH_TRANSITION_CONFIG, + "supports_transition_template": "{{true}}", + } + }, + ConfigurationStyle.LEGACY, + ), + ( + { + "name": "test_template_light", + "state": "{{states.light.test_state.state}}", + **TEST_OFF_ACTION_WITH_TRANSITION_CONFIG, + "supports_transition": "{{true}}", + }, + ConfigurationStyle.MODERN, + ), ], ) async def test_off_action_with_transition( @@ -596,13 +1109,23 @@ async def test_off_action_with_transition( @pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - "light_config", + ("light_config", "style"), [ - { - "test_template_light": { + ( + { + "test_template_light": { + **OPTIMISTIC_BRIGHTNESS_LIGHT_CONFIG, + } + }, + ConfigurationStyle.LEGACY, + ), + ( + { + "name": "test_template_light", **OPTIMISTIC_BRIGHTNESS_LIGHT_CONFIG, - } - }, + }, + ConfigurationStyle.MODERN, + ), ], ) async def test_off_action_optimistic( @@ -632,19 +1155,16 @@ async def test_off_action_optimistic( @pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - "light_config", + "style", [ - { - "test_template_light": { - **OPTIMISTIC_BRIGHTNESS_LIGHT_CONFIG, - "value_template": "{{1 == 1}}", - } - }, + ConfigurationStyle.LEGACY, + ConfigurationStyle.MODERN, ], ) +@pytest.mark.parametrize("state_template", ["{{1 == 1}}"]) async def test_level_action_no_template( hass: HomeAssistant, - setup_light, + setup_state_light, calls: list[ServiceCall], ) -> None: """Test setting brightness with optimistic template.""" @@ -671,9 +1191,18 @@ async def test_level_action_no_template( assert state.attributes["supported_features"] == 0 -@pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - ("expected_level", "level_template", "expected_color_mode"), + ("count", "extra_config"), [(1, OPTIMISTIC_BRIGHTNESS_LIGHT_CONFIG)] +) +@pytest.mark.parametrize( + ("style", "attribute"), + [ + (ConfigurationStyle.LEGACY, "level_template"), + (ConfigurationStyle.MODERN, "level"), + ], +) +@pytest.mark.parametrize( + ("expected_level", "attribute_template", "expected_color_mode"), [ (255, "{{255}}", ColorMode.BRIGHTNESS), (None, "{{256}}", ColorMode.BRIGHTNESS), @@ -690,20 +1219,11 @@ async def test_level_action_no_template( ) async def test_level_template( hass: HomeAssistant, - expected_level, - expected_color_mode, - count, - level_template, + expected_level: Any, + expected_color_mode: ColorMode, + setup_single_attribute_light, ) -> None: """Test the template for the level.""" - light_config = { - "test_template_light": { - **OPTIMISTIC_BRIGHTNESS_LIGHT_CONFIG, - "value_template": "{{ 1 == 1 }}", - "level_template": level_template, - } - } - await async_setup_light(hass, count, light_config) state = hass.states.get("light.test_template_light") assert state.attributes.get("brightness") == expected_level assert state.state == STATE_ON @@ -712,9 +1232,18 @@ async def test_level_template( assert state.attributes["supported_features"] == 0 -@pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - ("expected_temp", "temperature_template", "expected_color_mode"), + ("count", "extra_config"), [(1, OPTIMISTIC_COLOR_TEMP_LIGHT_CONFIG)] +) +@pytest.mark.parametrize( + ("style", "attribute"), + [ + (ConfigurationStyle.LEGACY, "temperature_template"), + (ConfigurationStyle.MODERN, "temperature"), + ], +) +@pytest.mark.parametrize( + ("expected_temp", "attribute_template", "expected_color_mode"), [ (500, "{{500}}", ColorMode.COLOR_TEMP), (None, "{{501}}", ColorMode.COLOR_TEMP), @@ -727,20 +1256,11 @@ async def test_level_template( ) async def test_temperature_template( hass: HomeAssistant, - expected_temp, - expected_color_mode, - count, - temperature_template, + expected_temp: Any, + expected_color_mode: ColorMode, + setup_single_attribute_light, ) -> None: """Test the template for the temperature.""" - light_config = { - "test_template_light": { - **OPTIMISTIC_COLOR_TEMP_LIGHT_CONFIG, - "value_template": "{{ 1 == 1 }}", - "temperature_template": temperature_template, - } - } - await async_setup_light(hass, count, light_config) state = hass.states.get("light.test_template_light") assert state.attributes.get("color_temp") == expected_temp assert state.state == STATE_ON @@ -749,21 +1269,19 @@ async def test_temperature_template( assert state.attributes["supported_features"] == 0 -@pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - "light_config", + ("count", "extra_config"), [(1, OPTIMISTIC_COLOR_TEMP_LIGHT_CONFIG)] +) +@pytest.mark.parametrize( + "style", [ - { - "test_template_light": { - **OPTIMISTIC_COLOR_TEMP_LIGHT_CONFIG, - "value_template": "{{1 == 1}}", - } - }, + ConfigurationStyle.LEGACY, + ConfigurationStyle.MODERN, ], ) async def test_temperature_action_no_template( hass: HomeAssistant, - setup_light, + setup_single_action_light, calls: list[ServiceCall], ) -> None: """Test setting temperature with optimistic template.""" @@ -793,43 +1311,53 @@ async def test_temperature_action_no_template( @pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - "light_config", + ("light_config", "style", "entity_id"), [ - { - "test_template_light": { + ( + { + "test_template_light": { + **OPTIMISTIC_BRIGHTNESS_LIGHT_CONFIG, + "friendly_name": "Template light", + "value_template": "{{ 1 == 1 }}", + } + }, + ConfigurationStyle.LEGACY, + "light.test_template_light", + ), + ( + { **OPTIMISTIC_BRIGHTNESS_LIGHT_CONFIG, - "friendly_name": "Template light", - "value_template": "{{ 1 == 1 }}", - } - }, + "name": "Template light", + "state": "{{ 1 == 1 }}", + }, + ConfigurationStyle.MODERN, + "light.template_light", + ), ], ) -async def test_friendly_name(hass: HomeAssistant, setup_light) -> None: +async def test_friendly_name(hass: HomeAssistant, entity_id: str, setup_light) -> None: """Test the accessibility of the friendly_name attribute.""" - state = hass.states.get("light.test_template_light") + state = hass.states.get(entity_id) assert state is not None assert state.attributes.get("friendly_name") == "Template light" -@pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - "light_config", + ("count", "extra_config"), [(1, OPTIMISTIC_BRIGHTNESS_LIGHT_CONFIG)] +) +@pytest.mark.parametrize( + ("style", "attribute"), [ - { - "test_template_light": { - **OPTIMISTIC_BRIGHTNESS_LIGHT_CONFIG, - "friendly_name": "Template light", - "value_template": "{{ 1 == 1 }}", - "icon_template": ( - "{% if states.light.test_state.state %}mdi:check{% endif %}" - ), - } - }, + (ConfigurationStyle.LEGACY, "icon_template"), + (ConfigurationStyle.MODERN, "icon"), ], ) -async def test_icon_template(hass: HomeAssistant, setup_light) -> None: +@pytest.mark.parametrize( + "attribute_template", ["{% if states.light.test_state.state %}mdi:check{% endif %}"] +) +async def test_icon_template(hass: HomeAssistant, setup_single_attribute_light) -> None: """Test icon template.""" state = hass.states.get("light.test_template_light") assert state.attributes.get("icon") == "" @@ -842,23 +1370,23 @@ async def test_icon_template(hass: HomeAssistant, setup_light) -> None: assert state.attributes["icon"] == "mdi:check" -@pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - "light_config", + ("count", "extra_config"), [(1, OPTIMISTIC_BRIGHTNESS_LIGHT_CONFIG)] +) +@pytest.mark.parametrize( + ("style", "attribute"), [ - { - "test_template_light": { - **OPTIMISTIC_BRIGHTNESS_LIGHT_CONFIG, - "friendly_name": "Template light", - "value_template": "{{ 1 == 1 }}", - "entity_picture_template": ( - "{% if states.light.test_state.state %}/local/light.png{% endif %}" - ), - } - }, + (ConfigurationStyle.LEGACY, "entity_picture_template"), + (ConfigurationStyle.MODERN, "picture"), ], ) -async def test_entity_picture_template(hass: HomeAssistant, setup_light) -> None: +@pytest.mark.parametrize( + "attribute_template", + ["{% if states.light.test_state.state %}/local/light.png{% endif %}"], +) +async def test_entity_picture_template( + hass: HomeAssistant, setup_single_attribute_light +) -> None: """Test entity_picture template.""" state = hass.states.get("light.test_template_light") assert state.attributes.get("entity_picture") == "" @@ -871,21 +1399,21 @@ async def test_entity_picture_template(hass: HomeAssistant, setup_light) -> None assert state.attributes["entity_picture"] == "/local/light.png" -@pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - "light_config", + ("count", "extra_config"), [ - { - "test_template_light": { - **OPTIMISTIC_LEGACY_COLOR_LIGHT_CONFIG, - "value_template": "{{1 == 1}}", - } - }, + (1, OPTIMISTIC_LEGACY_COLOR_LIGHT_CONFIG), + ], +) +@pytest.mark.parametrize( + "style", + [ + ConfigurationStyle.LEGACY, ], ) async def test_legacy_color_action_no_template( hass: HomeAssistant, - setup_light, + setup_single_action_light, calls: list[ServiceCall], ) -> None: """Test setting color with optimistic template.""" @@ -913,24 +1441,25 @@ async def test_legacy_color_action_no_template( assert state.attributes["supported_features"] == 0 -@pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - "light_config", + ("count", "extra_config"), [ - { - "test_template_light": { - **OPTIMISTIC_HS_COLOR_LIGHT_CONFIG, - "value_template": "{{1 == 1}}", - } - }, + (1, OPTIMISTIC_HS_COLOR_LIGHT_CONFIG), + ], +) +@pytest.mark.parametrize( + "style", + [ + ConfigurationStyle.LEGACY, + ConfigurationStyle.MODERN, ], ) async def test_hs_color_action_no_template( hass: HomeAssistant, - setup_light, + setup_single_action_light, calls: list[ServiceCall], ) -> None: - """Test setting hs color with optimistic template.""" + """Test setting color with optimistic template.""" state = hass.states.get("light.test_template_light") assert state.attributes.get("hs_color") is None @@ -955,21 +1484,20 @@ async def test_hs_color_action_no_template( assert state.attributes["supported_features"] == 0 -@pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - "light_config", + ("count", "extra_config"), + [(1, OPTIMISTIC_RGB_COLOR_LIGHT_CONFIG)], +) +@pytest.mark.parametrize( + "style", [ - { - "test_template_light": { - **OPTIMISTIC_RGB_COLOR_LIGHT_CONFIG, - "value_template": "{{1 == 1}}", - } - }, + ConfigurationStyle.LEGACY, + ConfigurationStyle.MODERN, ], ) async def test_rgb_color_action_no_template( hass: HomeAssistant, - setup_light, + setup_single_action_light, calls: list[ServiceCall], ) -> None: """Test setting rgb color with optimistic template.""" @@ -998,21 +1526,20 @@ async def test_rgb_color_action_no_template( assert state.attributes["supported_features"] == 0 -@pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - "light_config", + ("count", "extra_config"), + [(1, OPTIMISTIC_RGBW_COLOR_LIGHT_CONFIG)], +) +@pytest.mark.parametrize( + "style", [ - { - "test_template_light": { - **OPTIMISTIC_RGBW_COLOR_LIGHT_CONFIG, - "value_template": "{{1 == 1}}", - } - }, + ConfigurationStyle.LEGACY, + ConfigurationStyle.MODERN, ], ) async def test_rgbw_color_action_no_template( hass: HomeAssistant, - setup_light, + setup_single_action_light, calls: list[ServiceCall], ) -> None: """Test setting rgbw color with optimistic template.""" @@ -1045,21 +1572,20 @@ async def test_rgbw_color_action_no_template( assert state.attributes["supported_features"] == 0 -@pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - "light_config", + ("count", "extra_config"), + [(1, OPTIMISTIC_RGBWW_COLOR_LIGHT_CONFIG)], +) +@pytest.mark.parametrize( + "style", [ - { - "test_template_light": { - **OPTIMISTIC_RGBWW_COLOR_LIGHT_CONFIG, - "value_template": "{{1 == 1}}", - } - }, + ConfigurationStyle.LEGACY, + ConfigurationStyle.MODERN, ], ) async def test_rgbww_color_action_no_template( hass: HomeAssistant, - setup_light, + setup_single_action_light, calls: list[ServiceCall], ) -> None: """Test setting rgbww color with optimistic template.""" @@ -1123,7 +1649,7 @@ async def test_legacy_color_template( "color_template": color_template, } } - await async_setup_light(hass, count, light_config) + await async_setup_legacy_format(hass, count, light_config) state = hass.states.get("light.test_template_light") assert state.attributes.get("hs_color") == expected_hs assert state.state == STATE_ON @@ -1132,9 +1658,18 @@ async def test_legacy_color_template( assert state.attributes["supported_features"] == 0 -@pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - ("expected_hs", "hs_template", "expected_color_mode"), + ("count", "extra_config"), [(1, OPTIMISTIC_HS_COLOR_LIGHT_CONFIG)] +) +@pytest.mark.parametrize( + ("style", "attribute"), + [ + (ConfigurationStyle.LEGACY, "hs_template"), + (ConfigurationStyle.MODERN, "hs"), + ], +) +@pytest.mark.parametrize( + ("expected_hs", "attribute_template", "expected_color_mode"), [ ((360, 100), "{{(360, 100)}}", ColorMode.HS), ((360, 100), "(360, 100)", ColorMode.HS), @@ -1152,18 +1687,9 @@ async def test_hs_template( hass: HomeAssistant, expected_hs, expected_color_mode, - count, - hs_template, + setup_single_attribute_light, ) -> None: """Test the template for the color.""" - light_config = { - "test_template_light": { - **OPTIMISTIC_HS_COLOR_LIGHT_CONFIG, - "value_template": "{{ 1 == 1 }}", - "hs_template": hs_template, - } - } - await async_setup_light(hass, count, light_config) state = hass.states.get("light.test_template_light") assert state.attributes.get("hs_color") == expected_hs assert state.state == STATE_ON @@ -1172,9 +1698,18 @@ async def test_hs_template( assert state.attributes["supported_features"] == 0 -@pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - ("expected_rgb", "rgb_template", "expected_color_mode"), + ("count", "extra_config"), [(1, OPTIMISTIC_RGB_COLOR_LIGHT_CONFIG)] +) +@pytest.mark.parametrize( + ("style", "attribute"), + [ + (ConfigurationStyle.LEGACY, "rgb_template"), + (ConfigurationStyle.MODERN, "rgb"), + ], +) +@pytest.mark.parametrize( + ("expected_rgb", "attribute_template", "expected_color_mode"), [ ((160, 78, 192), "{{(160, 78, 192)}}", ColorMode.RGB), ((160, 78, 192), "{{[160, 78, 192]}}", ColorMode.RGB), @@ -1193,18 +1728,9 @@ async def test_rgb_template( hass: HomeAssistant, expected_rgb, expected_color_mode, - count, - rgb_template, + setup_single_attribute_light, ) -> None: """Test the template for the color.""" - light_config = { - "test_template_light": { - **OPTIMISTIC_RGB_COLOR_LIGHT_CONFIG, - "value_template": "{{ 1 == 1 }}", - "rgb_template": rgb_template, - } - } - await async_setup_light(hass, count, light_config) state = hass.states.get("light.test_template_light") assert state.attributes.get("rgb_color") == expected_rgb assert state.state == STATE_ON @@ -1213,9 +1739,18 @@ async def test_rgb_template( assert state.attributes["supported_features"] == 0 -@pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - ("expected_rgbw", "rgbw_template", "expected_color_mode"), + ("count", "extra_config"), [(1, OPTIMISTIC_RGBW_COLOR_LIGHT_CONFIG)] +) +@pytest.mark.parametrize( + ("style", "attribute"), + [ + (ConfigurationStyle.LEGACY, "rgbw_template"), + (ConfigurationStyle.MODERN, "rgbw"), + ], +) +@pytest.mark.parametrize( + ("expected_rgbw", "attribute_template", "expected_color_mode"), [ ((160, 78, 192, 25), "{{(160, 78, 192, 25)}}", ColorMode.RGBW), ((160, 78, 192, 25), "{{[160, 78, 192, 25]}}", ColorMode.RGBW), @@ -1235,18 +1770,9 @@ async def test_rgbw_template( hass: HomeAssistant, expected_rgbw, expected_color_mode, - count, - rgbw_template, + setup_single_attribute_light, ) -> None: """Test the template for the color.""" - light_config = { - "test_template_light": { - **OPTIMISTIC_RGBW_COLOR_LIGHT_CONFIG, - "value_template": "{{ 1 == 1 }}", - "rgbw_template": rgbw_template, - } - } - await async_setup_light(hass, count, light_config) state = hass.states.get("light.test_template_light") assert state.attributes.get("rgbw_color") == expected_rgbw assert state.state == STATE_ON @@ -1255,9 +1781,18 @@ async def test_rgbw_template( assert state.attributes["supported_features"] == 0 -@pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - ("expected_rgbww", "rgbww_template", "expected_color_mode"), + ("count", "extra_config"), [(1, OPTIMISTIC_RGBWW_COLOR_LIGHT_CONFIG)] +) +@pytest.mark.parametrize( + ("style", "attribute"), + [ + (ConfigurationStyle.LEGACY, "rgbww_template"), + (ConfigurationStyle.MODERN, "rgbww"), + ], +) +@pytest.mark.parametrize( + ("expected_rgbww", "attribute_template", "expected_color_mode"), [ ((160, 78, 192, 25, 55), "{{(160, 78, 192, 25, 55)}}", ColorMode.RGBWW), ((160, 78, 192, 25, 55), "(160, 78, 192, 25, 55)", ColorMode.RGBWW), @@ -1282,18 +1817,9 @@ async def test_rgbww_template( hass: HomeAssistant, expected_rgbww, expected_color_mode, - count, - rgbww_template, + setup_single_attribute_light, ) -> None: """Test the template for the color.""" - light_config = { - "test_template_light": { - **OPTIMISTIC_RGBWW_COLOR_LIGHT_CONFIG, - "value_template": "{{ 1 == 1 }}", - "rgbww_template": rgbww_template, - } - } - await async_setup_light(hass, count, light_config) state = hass.states.get("light.test_template_light") assert state.attributes.get("rgbww_color") == expected_rgbww assert state.state == STATE_ON @@ -1304,59 +1830,27 @@ async def test_rgbww_template( @pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - "light_config", + ("light_config", "style"), [ - { - "test_template_light": { + ( + { + "test_template_light": { + **OPTIMISTIC_ON_OFF_LIGHT_CONFIG, + "value_template": "{{1 == 1}}", + **TEST_ALL_COLORS_NO_TEMPLATE_CONFIG, + } + }, + ConfigurationStyle.LEGACY, + ), + ( + { + "name": "test_template_light", **OPTIMISTIC_ON_OFF_LIGHT_CONFIG, - "value_template": "{{1 == 1}}", - "set_hs": { - "service": "test.automation", - "data_template": { - "entity_id": "test.test_state", - "h": "{{h}}", - "s": "{{s}}", - }, - }, - "set_temperature": { - "service": "test.automation", - "data_template": { - "entity_id": "test.test_state", - "color_temp": "{{color_temp}}", - }, - }, - "set_rgb": { - "service": "test.automation", - "data_template": { - "entity_id": "test.test_state", - "r": "{{r}}", - "g": "{{g}}", - "b": "{{b}}", - }, - }, - "set_rgbw": { - "service": "test.automation", - "data_template": { - "entity_id": "test.test_state", - "r": "{{r}}", - "g": "{{g}}", - "b": "{{b}}", - "w": "{{w}}", - }, - }, - "set_rgbww": { - "service": "test.automation", - "data_template": { - "entity_id": "test.test_state", - "r": "{{r}}", - "g": "{{g}}", - "b": "{{b}}", - "cw": "{{cw}}", - "ww": "{{ww}}", - }, - }, - } - }, + "state": "{{1 == 1}}", + **TEST_ALL_COLORS_NO_TEMPLATE_CONFIG, + }, + ConfigurationStyle.MODERN, + ), ], ) async def test_all_colors_mode_no_template( @@ -1554,29 +2048,21 @@ async def test_all_colors_mode_no_template( @pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - "light_config", + "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] +) +@pytest.mark.parametrize( + ("effect_list_template", "effect_template", "effect", "expected"), [ - { - "test_template_light": { - **OPTIMISTIC_BRIGHTNESS_LIGHT_CONFIG, - "value_template": "{{true}}", - "set_effect": { - "service": "test.automation", - "data_template": { - "action": "set_effect", - "caller": "{{ this.entity_id }}", - "entity_id": "test.test_state", - "effect": "{{effect}}", - }, - }, - "effect_list_template": "{{ ['Disco', 'Police'] }}", - "effect_template": "{{ 'Disco' }}", - } - }, + ("{{ ['Disco', 'Police'] }}", "{{ 'Disco' }}", "Disco", "Disco"), + ("{{ ['Disco', 'Police'] }}", "{{ 'None' }}", "RGB", None), ], ) -async def test_effect_action_valid_effect( - hass: HomeAssistant, setup_light, calls: list[ServiceCall] +async def test_effect_action( + hass: HomeAssistant, + effect: str, + expected: Any, + setup_light_with_effects, + calls: list[ServiceCall], ) -> None: """Test setting valid effect with template.""" state = hass.states.get("light.test_template_light") @@ -1585,64 +2071,24 @@ async def test_effect_action_valid_effect( await hass.services.async_call( light.DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_template_light", ATTR_EFFECT: "Disco"}, + {ATTR_ENTITY_ID: "light.test_template_light", ATTR_EFFECT: effect}, blocking=True, ) assert len(calls) == 1 assert calls[-1].data["action"] == "set_effect" assert calls[-1].data["caller"] == "light.test_template_light" - assert calls[-1].data["effect"] == "Disco" + assert calls[-1].data["effect"] == effect state = hass.states.get("light.test_template_light") assert state is not None - assert state.attributes.get("effect") == "Disco" + assert state.attributes.get("effect") == expected -@pytest.mark.parametrize("count", [1]) +@pytest.mark.parametrize(("count", "effect_template"), [(1, "{{ None }}")]) @pytest.mark.parametrize( - "light_config", - [ - { - "test_template_light": { - **OPTIMISTIC_BRIGHTNESS_LIGHT_CONFIG, - "value_template": "{{true}}", - "set_effect": { - "service": "test.automation", - "data_template": { - "entity_id": "test.test_state", - "effect": "{{effect}}", - }, - }, - "effect_list_template": "{{ ['Disco', 'Police'] }}", - "effect_template": "{{ None }}", - } - }, - ], + "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] ) -async def test_effect_action_invalid_effect( - hass: HomeAssistant, setup_light, calls: list[ServiceCall] -) -> None: - """Test setting invalid effect with template.""" - state = hass.states.get("light.test_template_light") - assert state is not None - - await hass.services.async_call( - light.DOMAIN, - SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_template_light", ATTR_EFFECT: "RGB"}, - blocking=True, - ) - - assert len(calls) == 1 - assert calls[0].data["effect"] == "RGB" - - state = hass.states.get("light.test_template_light") - assert state is not None - assert state.attributes.get("effect") is None - - -@pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( ("expected_effect_list", "effect_list_template"), [ @@ -1663,31 +2109,21 @@ async def test_effect_action_invalid_effect( ], ) async def test_effect_list_template( - hass: HomeAssistant, expected_effect_list, count, effect_list_template + hass: HomeAssistant, expected_effect_list, setup_light_with_effects ) -> None: """Test the template for the effect list.""" - light_config = { - "test_template_light": { - **OPTIMISTIC_ON_OFF_LIGHT_CONFIG, - "value_template": "{{ 1 == 1 }}", - "set_effect": { - "service": "test.automation", - "data_template": { - "entity_id": "test.test_state", - "effect": "{{effect}}", - }, - }, - "effect_template": "{{ None }}", - "effect_list_template": effect_list_template, - } - } - await async_setup_light(hass, count, light_config) state = hass.states.get("light.test_template_light") assert state is not None assert state.attributes.get("effect_list") == expected_effect_list -@pytest.mark.parametrize("count", [1]) +@pytest.mark.parametrize( + ("count", "effect_list_template"), + [(1, "{{ ['Strobe color', 'Police', 'Christmas', 'RGB', 'Random Loop'] }}")], +) +@pytest.mark.parametrize( + "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] +) @pytest.mark.parametrize( ("expected_effect", "effect_template"), [ @@ -1699,27 +2135,9 @@ async def test_effect_list_template( ], ) async def test_effect_template( - hass: HomeAssistant, expected_effect, count, effect_template + hass: HomeAssistant, expected_effect, setup_light_with_effects ) -> None: """Test the template for the effect.""" - light_config = { - "test_template_light": { - **OPTIMISTIC_ON_OFF_LIGHT_CONFIG, - "value_template": "{{ 1 == 1 }}", - "set_effect": { - "service": "test.automation", - "data_template": { - "entity_id": "test.test_state", - "effect": "{{effect}}", - }, - }, - "effect_list_template": ( - "{{ ['Strobe color', 'Police', 'Christmas', 'RGB', 'Random Loop'] }}" - ), - "effect_template": effect_template, - } - } - await async_setup_light(hass, count, light_config) state = hass.states.get("light.test_template_light") assert state is not None assert state.attributes.get("effect") == expected_effect @@ -1727,7 +2145,14 @@ async def test_effect_template( @pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - ("expected_min_mireds", "min_mireds_template"), + ("style", "attribute"), + [ + (ConfigurationStyle.LEGACY, "min_mireds_template"), + (ConfigurationStyle.MODERN, "min_mireds"), + ], +) +@pytest.mark.parametrize( + ("expected_min_mireds", "attribute_template"), [ (118, "{{118}}"), (153, "{{x - 12}}"), @@ -1738,25 +2163,9 @@ async def test_effect_template( ], ) async def test_min_mireds_template( - hass: HomeAssistant, expected_min_mireds, count, min_mireds_template + hass: HomeAssistant, expected_min_mireds, setup_light_with_mireds ) -> None: """Test the template for the min mireds.""" - light_config = { - "test_template_light": { - **OPTIMISTIC_ON_OFF_LIGHT_CONFIG, - "value_template": "{{ 1 == 1 }}", - "set_temperature": { - "service": "light.turn_on", - "data_template": { - "entity_id": "light.test_state", - "color_temp": "{{color_temp}}", - }, - }, - "temperature_template": "{{200}}", - "min_mireds_template": min_mireds_template, - } - } - await async_setup_light(hass, count, light_config) state = hass.states.get("light.test_template_light") assert state is not None assert state.attributes.get("min_mireds") == expected_min_mireds @@ -1764,7 +2173,14 @@ async def test_min_mireds_template( @pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - ("expected_max_mireds", "max_mireds_template"), + ("style", "attribute"), + [ + (ConfigurationStyle.LEGACY, "max_mireds_template"), + (ConfigurationStyle.MODERN, "max_mireds"), + ], +) +@pytest.mark.parametrize( + ("expected_max_mireds", "attribute_template"), [ (488, "{{488}}"), (500, "{{x - 12}}"), @@ -1775,33 +2191,26 @@ async def test_min_mireds_template( ], ) async def test_max_mireds_template( - hass: HomeAssistant, expected_max_mireds, count, max_mireds_template + hass: HomeAssistant, expected_max_mireds, setup_light_with_mireds ) -> None: """Test the template for the max mireds.""" - light_config = { - "test_template_light": { - **OPTIMISTIC_ON_OFF_LIGHT_CONFIG, - "value_template": "{{ 1 == 1 }}", - "set_temperature": { - "service": "light.turn_on", - "data_template": { - "entity_id": "light.test_state", - "color_temp": "{{color_temp}}", - }, - }, - "temperature_template": "{{200}}", - "max_mireds_template": max_mireds_template, - } - } - await async_setup_light(hass, count, light_config) state = hass.states.get("light.test_template_light") assert state is not None assert state.attributes.get("max_mireds") == expected_max_mireds -@pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - ("expected_supports_transition", "supports_transition_template"), + ("count", "extra_config"), [(1, OPTIMISTIC_COLOR_TEMP_LIGHT_CONFIG)] +) +@pytest.mark.parametrize( + ("style", "attribute"), + [ + (ConfigurationStyle.LEGACY, "supports_transition_template"), + (ConfigurationStyle.MODERN, "supports_transition"), + ], +) +@pytest.mark.parametrize( + ("expected_supports_transition", "attribute_template"), [ (True, "{{true}}"), (True, "{{1 == 1}}"), @@ -1812,28 +2221,9 @@ async def test_max_mireds_template( ], ) async def test_supports_transition_template( - hass: HomeAssistant, - expected_supports_transition, - count, - supports_transition_template, + hass: HomeAssistant, expected_supports_transition, setup_single_attribute_light ) -> None: """Test the template for the supports transition.""" - light_config = { - "test_template_light": { - "value_template": "{{ 1 == 1 }}", - "turn_on": {"service": "light.turn_on", "entity_id": "light.test_state"}, - "turn_off": {"service": "light.turn_off", "entity_id": "light.test_state"}, - "set_temperature": { - "service": "light.turn_on", - "data_template": { - "entity_id": "light.test_state", - "color_temp": "{{color_temp}}", - }, - }, - "supports_transition_template": supports_transition_template, - } - } - await async_setup_light(hass, count, light_config) state = hass.states.get("light.test_template_light") expected_value = 1 @@ -1847,36 +2237,16 @@ async def test_supports_transition_template( ) != expected_value -@pytest.mark.parametrize("count", [1]) +@pytest.mark.parametrize( + ("count", "transition_template"), [(1, "{{ states('sensor.test') }}")] +) +@pytest.mark.parametrize( + "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] +) async def test_supports_transition_template_updates( - hass: HomeAssistant, count: int + hass: HomeAssistant, setup_light_with_transition_template ) -> None: """Test the template for the supports transition dynamically.""" - light_config = { - "test_template_light": { - "value_template": "{{ 1 == 1 }}", - "turn_on": {"service": "light.turn_on", "entity_id": "light.test_state"}, - "turn_off": {"service": "light.turn_off", "entity_id": "light.test_state"}, - "set_temperature": { - "service": "light.turn_on", - "data_template": { - "entity_id": "light.test_state", - "color_temp": "{{color_temp}}", - }, - }, - "set_effect": { - "service": "test.automation", - "data_template": { - "entity_id": "test.test_state", - "effect": "{{effect}}", - }, - }, - "effect_list_template": "{{ ['Disco', 'Police'] }}", - "effect_template": "{{ None }}", - "supports_transition_template": "{{ states('sensor.test') }}", - } - } - await async_setup_light(hass, count, light_config) state = hass.states.get("light.test_template_light") assert state is not None @@ -1901,22 +2271,25 @@ async def test_supports_transition_template_updates( assert supported_features == LightEntityFeature.EFFECT -@pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - "light_config", + ("count", "extra_config", "attribute_template"), [ - { - "test_template_light": { - **OPTIMISTIC_BRIGHTNESS_LIGHT_CONFIG, - "availability_template": ( - "{{ is_state('availability_boolean.state', 'on') }}" - ), - } - }, + ( + 1, + OPTIMISTIC_BRIGHTNESS_LIGHT_CONFIG, + "{{ is_state('availability_boolean.state', 'on') }}", + ) + ], +) +@pytest.mark.parametrize( + ("style", "attribute"), + [ + (ConfigurationStyle.LEGACY, "availability_template"), + (ConfigurationStyle.MODERN, "availability"), ], ) async def test_available_template_with_entities( - hass: HomeAssistant, setup_light + hass: HomeAssistant, setup_single_attribute_light ) -> None: """Test availability templates with values from other entities.""" # When template returns true.. @@ -1934,20 +2307,25 @@ async def test_available_template_with_entities( assert hass.states.get("light.test_template_light").state == STATE_UNAVAILABLE -@pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - "light_config", + ("count", "extra_config", "attribute_template"), [ - { - "test_template_light": { - **OPTIMISTIC_BRIGHTNESS_LIGHT_CONFIG, - "availability_template": "{{ x - 12 }}", - } - }, + ( + 1, + OPTIMISTIC_BRIGHTNESS_LIGHT_CONFIG, + "{{ x - 12 }}", + ) + ], +) +@pytest.mark.parametrize( + ("style", "attribute"), + [ + (ConfigurationStyle.LEGACY, "availability_template"), + (ConfigurationStyle.MODERN, "availability"), ], ) async def test_invalid_availability_template_keeps_component_available( - hass: HomeAssistant, setup_light, caplog_setup_text + hass: HomeAssistant, setup_single_attribute_light, caplog_setup_text ) -> None: """Test that an invalid availability keeps the device available.""" assert hass.states.get("light.test_template_light").state != STATE_UNAVAILABLE @@ -1956,20 +2334,73 @@ async def test_invalid_availability_template_keeps_component_available( @pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - "light_config", + ("light_config", "style"), [ - { - "test_template_light_01": { - **OPTIMISTIC_ON_OFF_LIGHT_CONFIG, - "unique_id": "not-so-unique-anymore", + ( + { + "test_template_light_01": TEST_UNIQUE_ID_CONFIG, + "test_template_light_02": TEST_UNIQUE_ID_CONFIG, }, - "test_template_light_02": { - **OPTIMISTIC_ON_OFF_LIGHT_CONFIG, - "unique_id": "not-so-unique-anymore", - }, - }, + ConfigurationStyle.LEGACY, + ), + ( + [ + { + "name": "test_template_light_01", + **TEST_UNIQUE_ID_CONFIG, + }, + { + "name": "test_template_light_02", + **TEST_UNIQUE_ID_CONFIG, + }, + ], + ConfigurationStyle.MODERN, + ), ], ) async def test_unique_id(hass: HomeAssistant, setup_light) -> None: """Test unique_id option only creates one light per id.""" assert len(hass.states.async_all("light")) == 1 + + +async def test_nested_unique_id( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: + """Test unique_id option creates one light per nested id.""" + + with assert_setup_component(1, template.DOMAIN): + assert await async_setup_component( + hass, + template.DOMAIN, + { + "template": { + "unique_id": "x", + "light": [ + { + "name": "test_a", + **OPTIMISTIC_ON_OFF_LIGHT_CONFIG, + "unique_id": "a", + }, + { + "name": "test_b", + **OPTIMISTIC_ON_OFF_LIGHT_CONFIG, + "unique_id": "b", + }, + ], + } + }, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + assert len(hass.states.async_all("light")) == 2 + + entry = entity_registry.async_get("light.test_a") + assert entry + assert entry.unique_id == "x-a" + + entry = entity_registry.async_get("light.test_b") + assert entry + assert entry.unique_id == "x-b" From 1e8f211725a66e4876c15fc9ff7d302255a1475a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 13 Mar 2025 22:47:36 -1000 Subject: [PATCH 2580/3148] Bump aioshelly to 13.3.0 (#140571) changelog: https://github.com/home-assistant-libs/aioshelly/compare/13.2.0...13.3.0 --- homeassistant/components/shelly/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/shelly/manifest.json b/homeassistant/components/shelly/manifest.json index c8ac5520b13..c9cbd778e95 100644 --- a/homeassistant/components/shelly/manifest.json +++ b/homeassistant/components/shelly/manifest.json @@ -8,7 +8,7 @@ "integration_type": "device", "iot_class": "local_push", "loggers": ["aioshelly"], - "requirements": ["aioshelly==13.2.0"], + "requirements": ["aioshelly==13.3.0"], "zeroconf": [ { "type": "_http._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index 445d89ec651..c29183c95b6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -371,7 +371,7 @@ aioruuvigateway==0.1.0 aiosenz==1.0.0 # homeassistant.components.shelly -aioshelly==13.2.0 +aioshelly==13.3.0 # homeassistant.components.skybell aioskybell==22.7.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 12001c6a121..05d6ed6390c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -353,7 +353,7 @@ aioruuvigateway==0.1.0 aiosenz==1.0.0 # homeassistant.components.shelly -aioshelly==13.2.0 +aioshelly==13.3.0 # homeassistant.components.skybell aioskybell==22.7.0 From 23f4f97603e6721926abe3b55d4a9200680fff83 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 13 Mar 2025 22:57:24 -1000 Subject: [PATCH 2581/3148] Bump habluetooth to 3.27.0 (#140569) changelog: https://github.com/Bluetooth-Devices/habluetooth/compare/v3.25.1...v3.27.0 --- homeassistant/components/bluetooth/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/bluetooth/test_manager.py | 6 ------ 5 files changed, 4 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index 50d115dc89b..3430787958e 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -21,6 +21,6 @@ "bluetooth-auto-recovery==1.4.5", "bluetooth-data-tools==1.26.0", "dbus-fast==2.39.5", - "habluetooth==3.25.1" + "habluetooth==3.27.0" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index b4823d1a549..8f9a9670fee 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -33,7 +33,7 @@ dbus-fast==2.39.5 fnv-hash-fast==1.4.0 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 -habluetooth==3.25.1 +habluetooth==3.27.0 hass-nabucasa==0.94.0 hassil==2.2.3 home-assistant-bluetooth==1.13.1 diff --git a/requirements_all.txt b/requirements_all.txt index c29183c95b6..76926fd1001 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1109,7 +1109,7 @@ ha-philipsjs==3.2.2 habiticalib==0.3.7 # homeassistant.components.bluetooth -habluetooth==3.25.1 +habluetooth==3.27.0 # homeassistant.components.cloud hass-nabucasa==0.94.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 05d6ed6390c..819d9756f85 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -950,7 +950,7 @@ ha-philipsjs==3.2.2 habiticalib==0.3.7 # homeassistant.components.bluetooth -habluetooth==3.25.1 +habluetooth==3.27.0 # homeassistant.components.cloud hass-nabucasa==0.94.0 diff --git a/tests/components/bluetooth/test_manager.py b/tests/components/bluetooth/test_manager.py index be23a536f49..48d1a38375d 100644 --- a/tests/components/bluetooth/test_manager.py +++ b/tests/components/bluetooth/test_manager.py @@ -1019,8 +1019,6 @@ async def test_goes_unavailable_dismisses_discovery_and_makes_discoverable( def clear_all_devices(self) -> None: """Clear all devices.""" - self._discovered_device_advertisement_datas.clear() - self._discovered_device_timestamps.clear() self._previous_service_info.clear() connector = ( @@ -1446,8 +1444,6 @@ async def test_bluetooth_rediscover( def clear_all_devices(self) -> None: """Clear all devices.""" - self._discovered_device_advertisement_datas.clear() - self._discovered_device_timestamps.clear() self._previous_service_info.clear() connector = ( @@ -1625,8 +1621,6 @@ async def test_bluetooth_rediscover_no_match( def clear_all_devices(self) -> None: """Clear all devices.""" - self._discovered_device_advertisement_datas.clear() - self._discovered_device_timestamps.clear() self._previous_service_info.clear() connector = ( From 5daa3167ca93f703736efffaf43167ddf5a43072 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Fri, 14 Mar 2025 10:03:29 +0100 Subject: [PATCH 2582/3148] Add parallel updates to Comelit (#140527) --- homeassistant/components/comelit/alarm_control_panel.py | 3 +++ homeassistant/components/comelit/binary_sensor.py | 3 +++ homeassistant/components/comelit/climate.py | 3 +++ homeassistant/components/comelit/cover.py | 3 +++ homeassistant/components/comelit/humidifier.py | 3 +++ homeassistant/components/comelit/light.py | 3 +++ homeassistant/components/comelit/sensor.py | 3 +++ homeassistant/components/comelit/switch.py | 3 +++ 8 files changed, 24 insertions(+) diff --git a/homeassistant/components/comelit/alarm_control_panel.py b/homeassistant/components/comelit/alarm_control_panel.py index 0a01dd957a6..5ecc9a63599 100644 --- a/homeassistant/components/comelit/alarm_control_panel.py +++ b/homeassistant/components/comelit/alarm_control_panel.py @@ -20,6 +20,9 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .coordinator import ComelitConfigEntry, ComelitVedoSystem +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + _LOGGER = logging.getLogger(__name__) AWAY = "away" diff --git a/homeassistant/components/comelit/binary_sensor.py b/homeassistant/components/comelit/binary_sensor.py index c17057d19d1..dfa6d3e97f3 100644 --- a/homeassistant/components/comelit/binary_sensor.py +++ b/homeassistant/components/comelit/binary_sensor.py @@ -16,6 +16,9 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .coordinator import ComelitConfigEntry, ComelitVedoSystem +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + async def async_setup_entry( hass: HomeAssistant, diff --git a/homeassistant/components/comelit/climate.py b/homeassistant/components/comelit/climate.py index 3433d1bdf04..505c2b6b8e8 100644 --- a/homeassistant/components/comelit/climate.py +++ b/homeassistant/components/comelit/climate.py @@ -23,6 +23,9 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .coordinator import ComelitConfigEntry, ComelitSerialBridge +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + class ClimaComelitMode(StrEnum): """Serial Bridge clima modes.""" diff --git a/homeassistant/components/comelit/cover.py b/homeassistant/components/comelit/cover.py index 64412569f95..9bcf52ac111 100644 --- a/homeassistant/components/comelit/cover.py +++ b/homeassistant/components/comelit/cover.py @@ -15,6 +15,9 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .coordinator import ComelitConfigEntry, ComelitSerialBridge +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + async def async_setup_entry( hass: HomeAssistant, diff --git a/homeassistant/components/comelit/humidifier.py b/homeassistant/components/comelit/humidifier.py index da6d44b1bbe..b28a9bf0036 100644 --- a/homeassistant/components/comelit/humidifier.py +++ b/homeassistant/components/comelit/humidifier.py @@ -24,6 +24,9 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN from .coordinator import ComelitConfigEntry, ComelitSerialBridge +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + class HumidifierComelitMode(StrEnum): """Serial Bridge humidifier modes.""" diff --git a/homeassistant/components/comelit/light.py b/homeassistant/components/comelit/light.py index 45f4146ece6..09180d628a6 100644 --- a/homeassistant/components/comelit/light.py +++ b/homeassistant/components/comelit/light.py @@ -14,6 +14,9 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .coordinator import ComelitConfigEntry, ComelitSerialBridge +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + async def async_setup_entry( hass: HomeAssistant, diff --git a/homeassistant/components/comelit/sensor.py b/homeassistant/components/comelit/sensor.py index 3d57d9dca9c..c93ccd30eb6 100644 --- a/homeassistant/components/comelit/sensor.py +++ b/homeassistant/components/comelit/sensor.py @@ -20,6 +20,9 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .coordinator import ComelitConfigEntry, ComelitSerialBridge, ComelitVedoSystem +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + SENSOR_BRIDGE_TYPES: Final = ( SensorEntityDescription( key="power", diff --git a/homeassistant/components/comelit/switch.py b/homeassistant/components/comelit/switch.py index f6e5b192c38..db89bd082f6 100644 --- a/homeassistant/components/comelit/switch.py +++ b/homeassistant/components/comelit/switch.py @@ -14,6 +14,9 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .coordinator import ComelitConfigEntry, ComelitSerialBridge +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + async def async_setup_entry( hass: HomeAssistant, From f48d94ce343be20c83ed6ef921ddac6ed789e206 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Fri, 14 Mar 2025 10:08:39 +0100 Subject: [PATCH 2583/3148] Use TypeVar default for Generator (#140506) --- tests/test_backup_restore.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_backup_restore.py b/tests/test_backup_restore.py index 4c6bc930667..7efe25c8428 100644 --- a/tests/test_backup_restore.py +++ b/tests/test_backup_restore.py @@ -15,7 +15,7 @@ from .common import get_test_config_dir @pytest.fixture(autouse=True) -def remove_restore_result_file() -> Generator[None, Any, Any]: +def remove_restore_result_file() -> Generator[None]: """Remove the restore result file.""" yield Path(get_test_config_dir(".HA_RESTORE_RESULT")).unlink(missing_ok=True) From 9820cbb036f96cc40af36f6b9a90028daa0f3734 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Fri, 14 Mar 2025 10:17:10 +0100 Subject: [PATCH 2584/3148] Add exceptions translation for Comelit (#140404) * Add exceptions translation for Comelit * apply review comment * Add climate tests for Comelit * Revert "Add climate tests for Comelit" This reverts commit 6d76d312a064491be4dbfb960a28b00f742f4186. --- homeassistant/components/comelit/climate.py | 5 ++++- homeassistant/components/comelit/humidifier.py | 4 +++- homeassistant/components/comelit/strings.json | 3 +++ 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/comelit/climate.py b/homeassistant/components/comelit/climate.py index 505c2b6b8e8..8064d478c32 100644 --- a/homeassistant/components/comelit/climate.py +++ b/homeassistant/components/comelit/climate.py @@ -21,6 +21,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity +from .const import DOMAIN from .coordinator import ComelitConfigEntry, ComelitSerialBridge # Coordinator is used to centralize the data updates @@ -124,7 +125,9 @@ class ComelitClimateEntity(CoordinatorEntity[ComelitSerialBridge], ClimateEntity """Handle updated data from the coordinator.""" device = self.coordinator.data[CLIMATE][self._device.index] if not isinstance(device.val, list): - raise HomeAssistantError("Invalid clima data") + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="invalid_clima_data" + ) # CLIMATE has a 2 item tuple: # - first for Clima diff --git a/homeassistant/components/comelit/humidifier.py b/homeassistant/components/comelit/humidifier.py index b28a9bf0036..c5edfb1c2de 100644 --- a/homeassistant/components/comelit/humidifier.py +++ b/homeassistant/components/comelit/humidifier.py @@ -130,7 +130,9 @@ class ComelitHumidifierEntity(CoordinatorEntity[ComelitSerialBridge], Humidifier """Handle updated data from the coordinator.""" device = self.coordinator.data[CLIMATE][self._device.index] if not isinstance(device.val, list): - raise HomeAssistantError("Invalid clima data") + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="invalid_clima_data" + ) # CLIMATE has a 2 item tuple: # - first for Clima diff --git a/homeassistant/components/comelit/strings.json b/homeassistant/components/comelit/strings.json index 14d947c7323..5ff4fa54688 100644 --- a/homeassistant/components/comelit/strings.json +++ b/homeassistant/components/comelit/strings.json @@ -58,6 +58,9 @@ "exceptions": { "humidity_while_off": { "message": "Cannot change humidity while off" + }, + "invalid_clima_data": { + "message": "Invalid 'clima' data" } } } From 2b0a2e76447525919041fcfe6a31f2edfeff2a3b Mon Sep 17 00:00:00 2001 From: ashionky <35916938+ashionky@users.noreply.github.com> Date: Fri, 14 Mar 2025 17:19:43 +0800 Subject: [PATCH 2585/3148] Fix missing UnitOfPower.MILLIWATT in sensor and number allowed units (#140567) * MILLIWATT * MILLIWATT --- homeassistant/components/number/const.py | 1 + homeassistant/components/sensor/const.py | 1 + 2 files changed, 2 insertions(+) diff --git a/homeassistant/components/number/const.py b/homeassistant/components/number/const.py index a7493194847..f44a510b1c0 100644 --- a/homeassistant/components/number/const.py +++ b/homeassistant/components/number/const.py @@ -487,6 +487,7 @@ DEVICE_CLASS_UNITS: dict[NumberDeviceClass, set[type[StrEnum] | str | None]] = { NumberDeviceClass.PM25: {CONCENTRATION_MICROGRAMS_PER_CUBIC_METER}, NumberDeviceClass.POWER_FACTOR: {PERCENTAGE, None}, NumberDeviceClass.POWER: { + UnitOfPower.MILLIWATT, UnitOfPower.WATT, UnitOfPower.KILO_WATT, UnitOfPower.MEGA_WATT, diff --git a/homeassistant/components/sensor/const.py b/homeassistant/components/sensor/const.py index 774f2a9cff2..e1f7dd13d93 100644 --- a/homeassistant/components/sensor/const.py +++ b/homeassistant/components/sensor/const.py @@ -583,6 +583,7 @@ DEVICE_CLASS_UNITS: dict[SensorDeviceClass, set[type[StrEnum] | str | None]] = { SensorDeviceClass.PM25: {CONCENTRATION_MICROGRAMS_PER_CUBIC_METER}, SensorDeviceClass.POWER_FACTOR: {PERCENTAGE, None}, SensorDeviceClass.POWER: { + UnitOfPower.MILLIWATT, UnitOfPower.WATT, UnitOfPower.KILO_WATT, UnitOfPower.MEGA_WATT, From d952e8186f32c9f53ef9edc29d379ccb094ebd3a Mon Sep 17 00:00:00 2001 From: Louis Christ Date: Fri, 14 Mar 2025 10:20:16 +0100 Subject: [PATCH 2586/3148] Use only IPv4 for zeroconf in bluesound integration (#140226) * Use only ipv4 for zeroconf * Fix tests * Use only ip_address for ip version check * Add test * Reduce test --- .../components/bluesound/config_flow.py | 3 ++ .../components/bluesound/strings.json | 3 +- .../components/bluesound/test_config_flow.py | 33 +++++++++++++++---- 3 files changed, 32 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/bluesound/config_flow.py b/homeassistant/components/bluesound/config_flow.py index 2f002b70e1d..cfb6646d829 100644 --- a/homeassistant/components/bluesound/config_flow.py +++ b/homeassistant/components/bluesound/config_flow.py @@ -75,6 +75,9 @@ class BluesoundConfigFlow(ConfigFlow, domain=DOMAIN): self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle a flow initialized by zeroconf discovery.""" + # the player can have an ipv6 address, but the api is only available on ipv4 + if discovery_info.ip_address.version != 4: + return self.async_abort(reason="no_ipv4_address") if discovery_info.port is not None: self._port = discovery_info.port diff --git a/homeassistant/components/bluesound/strings.json b/homeassistant/components/bluesound/strings.json index b50c01a11bf..1170e0b92e0 100644 --- a/homeassistant/components/bluesound/strings.json +++ b/homeassistant/components/bluesound/strings.json @@ -19,7 +19,8 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]" + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "no_ipv4_address": "No IPv4 address found." }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" diff --git a/tests/components/bluesound/test_config_flow.py b/tests/components/bluesound/test_config_flow.py index d0e0f75991b..a4d5eecd744 100644 --- a/tests/components/bluesound/test_config_flow.py +++ b/tests/components/bluesound/test_config_flow.py @@ -1,5 +1,6 @@ """Test the Bluesound config flow.""" +from ipaddress import IPv4Address, IPv6Address from unittest.mock import AsyncMock from pyblu.errors import PlayerUnreachableError @@ -121,8 +122,8 @@ async def test_zeroconf_flow_success( DOMAIN, context={"source": SOURCE_ZEROCONF}, data=ZeroconfServiceInfo( - ip_address="1.1.1.1", - ip_addresses=["1.1.1.1"], + ip_address=IPv4Address("1.1.1.1"), + ip_addresses=[IPv4Address("1.1.1.1")], port=11000, hostname="player-name1111", type="_musc._tcp.local.", @@ -160,8 +161,8 @@ async def test_zeroconf_flow_cannot_connect( DOMAIN, context={"source": SOURCE_ZEROCONF}, data=ZeroconfServiceInfo( - ip_address="1.1.1.1", - ip_addresses=["1.1.1.1"], + ip_address=IPv4Address("1.1.1.1"), + ip_addresses=[IPv4Address("1.1.1.1")], port=11000, hostname="player-name1111", type="_musc._tcp.local.", @@ -187,8 +188,8 @@ async def test_zeroconf_flow_already_configured( DOMAIN, context={"source": SOURCE_ZEROCONF}, data=ZeroconfServiceInfo( - ip_address="1.1.1.2", - ip_addresses=["1.1.1.2"], + ip_address=IPv4Address("1.1.1.2"), + ip_addresses=[IPv4Address("1.1.1.2")], port=11000, hostname="player-name1112", type="_musc._tcp.local.", @@ -203,3 +204,23 @@ async def test_zeroconf_flow_already_configured( assert config_entry.data[CONF_HOST] == "1.1.1.2" player_mocks.player_data_for_already_configured.player.sync_status.assert_called_once() + + +async def test_zeroconf_flow_no_ipv4_address(hass: HomeAssistant) -> None: + """Test abort flow when no ipv4 address is found in zeroconf data.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=ZeroconfServiceInfo( + ip_address=IPv6Address("2001:db8::1"), + ip_addresses=[IPv6Address("2001:db8::1")], + port=11000, + hostname="player-name1112", + type="_musc._tcp.local.", + name="player-name._musc._tcp.local.", + properties={}, + ), + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_ipv4_address" From 99b140f73f16fe8676e6164c6697b88469f3d7c0 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Fri, 14 Mar 2025 10:21:16 +0100 Subject: [PATCH 2587/3148] Remove WebDAV properties and rely on metadata file (#140539) --- homeassistant/components/webdav/backup.py | 107 ++++++++-------------- tests/components/webdav/conftest.py | 4 +- tests/components/webdav/const.py | 21 +---- tests/components/webdav/test_backup.py | 39 +------- 4 files changed, 48 insertions(+), 123 deletions(-) diff --git a/homeassistant/components/webdav/backup.py b/homeassistant/components/webdav/backup.py index 321ed98bfa8..fb2927a58bb 100644 --- a/homeassistant/components/webdav/backup.py +++ b/homeassistant/components/webdav/backup.py @@ -5,10 +5,10 @@ from __future__ import annotations from collections.abc import AsyncIterator, Callable, Coroutine from functools import wraps import logging +from time import time from typing import Any, Concatenate from aiohttp import ClientTimeout -from aiowebdav2 import Property, PropertyRequest from aiowebdav2.exceptions import UnauthorizedError, WebDavError from propcache.api import cached_property @@ -28,9 +28,8 @@ from .const import CONF_BACKUP_PATH, DATA_BACKUP_AGENT_LISTENERS, DOMAIN _LOGGER = logging.getLogger(__name__) -METADATA_VERSION = "1" BACKUP_TIMEOUT = ClientTimeout(connect=10, total=43200) -NAMESPACE = "https://home-assistant.io" +CACHE_TTL = 300 async def async_get_backup_agents( @@ -96,23 +95,6 @@ def suggested_filenames(backup: AgentBackup) -> tuple[str, str]: return f"{base_name}.tar", f"{base_name}.metadata.json" -def _is_current_metadata_version(properties: list[Property]) -> bool: - """Check if any property is of the current metadata version.""" - return any( - prop.value == METADATA_VERSION - for prop in properties - if prop.namespace == NAMESPACE and prop.name == "metadata_version" - ) - - -def _backup_id_from_properties(properties: list[Property]) -> str | None: - """Return the backup ID from properties.""" - for prop in properties: - if prop.namespace == NAMESPACE and prop.name == "backup_id": - return prop.value - return None - - class WebDavBackupAgent(BackupAgent): """Backup agent interface.""" @@ -126,6 +108,8 @@ class WebDavBackupAgent(BackupAgent): self._client = entry.runtime_data self.name = entry.title self.unique_id = entry.entry_id + self._cache_metadata_files: dict[str, AgentBackup] = {} + self._cache_expiration = time() @cached_property def _backup_path(self) -> str: @@ -182,27 +166,14 @@ class WebDavBackupAgent(BackupAgent): f"{self._backup_path}/{filename_meta}", ) - await self._client.set_property_batch( - f"{self._backup_path}/{filename_meta}", - [ - Property( - namespace=NAMESPACE, - name="backup_id", - value=backup.backup_id, - ), - Property( - namespace=NAMESPACE, - name="metadata_version", - value=METADATA_VERSION, - ), - ], - ) - _LOGGER.debug( "Uploaded metadata file for %s", f"{self._backup_path}/{filename_meta}", ) + # reset cache + self._cache_expiration = time() + @handle_backup_errors async def async_delete_backup( self, @@ -226,14 +197,13 @@ class WebDavBackupAgent(BackupAgent): backup_path, ) + # reset cache + self._cache_expiration = time() + @handle_backup_errors async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]: """List backups.""" - metadata_files = await self._list_metadata_files() - return [ - await self._download_metadata(metadata_file) - for metadata_file in metadata_files.values() - ] + return list((await self._list_cached_metadata_files()).values()) @handle_backup_errors async def async_get_backup( @@ -244,38 +214,35 @@ class WebDavBackupAgent(BackupAgent): """Return a backup.""" return await self._find_backup_by_id(backup_id) - async def _list_metadata_files(self) -> dict[str, str]: - """List metadata files.""" - files = await self._client.list_with_properties( - self._backup_path, - [ - PropertyRequest( - namespace=NAMESPACE, - name="metadata_version", - ), - PropertyRequest( - namespace=NAMESPACE, - name="backup_id", - ), - ], - ) - return { - backup_id: file_name - for file_name, properties in files.items() - if file_name.endswith(".json") and _is_current_metadata_version(properties) - if (backup_id := _backup_id_from_properties(properties)) - } + async def _list_cached_metadata_files(self) -> dict[str, AgentBackup]: + """List metadata files with a cache.""" + if time() <= self._cache_expiration: + return self._cache_metadata_files + + async def _download_metadata(path: str) -> AgentBackup: + """Download metadata file.""" + iterator = await self._client.download_iter(path) + metadata = await anext(iterator) + return AgentBackup.from_dict(json_loads_object(metadata)) + + async def _list_metadata_files() -> dict[str, AgentBackup]: + """List metadata files.""" + files = await self._client.list_files(self._backup_path) + return { + metadata_content.backup_id: metadata_content + for file_name in files + if file_name.endswith(".json") + if (metadata_content := await _download_metadata(file_name)) + } + + self._cache_metadata_files = await _list_metadata_files() + self._cache_expiration = time() + CACHE_TTL + return self._cache_metadata_files async def _find_backup_by_id(self, backup_id: str) -> AgentBackup: """Find a backup by its backup ID on remote.""" - metadata_files = await self._list_metadata_files() + metadata_files = await self._list_cached_metadata_files() if metadata_file := metadata_files.get(backup_id): - return await self._download_metadata(metadata_file) + return metadata_file raise BackupNotFound(f"Backup {backup_id} not found") - - async def _download_metadata(self, path: str) -> AgentBackup: - """Download metadata file.""" - iterator = await self._client.download_iter(path) - metadata = await anext(iterator) - return AgentBackup.from_dict(json_loads_object(metadata)) diff --git a/tests/components/webdav/conftest.py b/tests/components/webdav/conftest.py index 645e2111364..5fa972e5fae 100644 --- a/tests/components/webdav/conftest.py +++ b/tests/components/webdav/conftest.py @@ -9,7 +9,7 @@ import pytest from homeassistant.components.webdav.const import DOMAIN from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME -from .const import BACKUP_METADATA, MOCK_LIST_WITH_PROPERTIES +from .const import BACKUP_METADATA, MOCK_LIST_FILES from tests.common import MockConfigEntry @@ -58,7 +58,7 @@ def mock_webdav_client() -> Generator[AsyncMock]: mock = mock_webdav_client.return_value mock.check.return_value = True mock.mkdir.return_value = True - mock.list_with_properties.return_value = MOCK_LIST_WITH_PROPERTIES + mock.list_files.return_value = MOCK_LIST_FILES mock.download_iter.side_effect = _download_mock mock.upload_iter.return_value = None mock.clean.return_value = None diff --git a/tests/components/webdav/const.py b/tests/components/webdav/const.py index 8d6b8ad67d7..0147826a777 100644 --- a/tests/components/webdav/const.py +++ b/tests/components/webdav/const.py @@ -1,7 +1,5 @@ """Constants for WebDAV tests.""" -from aiowebdav2 import Property - BACKUP_METADATA = { "addons": [], "backup_id": "23e64aec", @@ -16,18 +14,7 @@ BACKUP_METADATA = { "size": 34519040, } -MOCK_LIST_WITH_PROPERTIES = { - "/Automatic_backup_2025.2.1_2025-02-10_18.31_30202686.tar": [], - "/Automatic_backup_2025.2.1_2025-02-10_18.31_30202686.metadata.json": [ - Property( - namespace="https://home-assistant.io", - name="backup_id", - value="23e64aec", - ), - Property( - namespace="https://home-assistant.io", - name="metadata_version", - value="1", - ), - ], -} +MOCK_LIST_FILES = [ + "/Automatic_backup_2025.2.1_2025-02-10_18.31_30202686.tar", + "/Automatic_backup_2025.2.1_2025-02-10_18.31_30202686.metadata.json", +] diff --git a/tests/components/webdav/test_backup.py b/tests/components/webdav/test_backup.py index c20e73cc786..ca20467484f 100644 --- a/tests/components/webdav/test_backup.py +++ b/tests/components/webdav/test_backup.py @@ -6,7 +6,6 @@ from collections.abc import AsyncGenerator from io import StringIO from unittest.mock import Mock, patch -from aiowebdav2 import Property from aiowebdav2.exceptions import UnauthorizedError, WebDavError import pytest @@ -17,7 +16,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.backup import async_initialize_backup from homeassistant.setup import async_setup_component -from .const import BACKUP_METADATA, MOCK_LIST_WITH_PROPERTIES +from .const import BACKUP_METADATA from tests.common import AsyncMock, MockConfigEntry from tests.typing import ClientSessionGenerator, WebSocketGenerator @@ -184,7 +183,6 @@ async def test_agents_upload( assert resp.status == 201 assert webdav_client.upload_iter.call_count == 2 - assert webdav_client.set_property_batch.call_count == 1 async def test_agents_download( @@ -211,7 +209,7 @@ async def test_error_on_agents_download( """Test we get not found on a not existing backup on download.""" client = await hass_client() backup_id = BACKUP_METADATA["backup_id"] - webdav_client.list_with_properties.side_effect = [MOCK_LIST_WITH_PROPERTIES, {}] + webdav_client.list_files.return_value = [] resp = await client.get( f"/api/backup/download/{backup_id}?agent_id={DOMAIN}.{mock_config_entry.entry_id}" @@ -262,7 +260,7 @@ async def test_agents_delete_not_found_does_not_throw( webdav_client: AsyncMock, ) -> None: """Test agent delete backup.""" - webdav_client.list_with_properties.return_value = {} + webdav_client.list_files.return_value = {} client = await hass_ws_client(hass) await client.send_json_auto_id( @@ -283,7 +281,7 @@ async def test_agents_backup_not_found( webdav_client: AsyncMock, ) -> None: """Test backup not found.""" - webdav_client.list_with_properties.return_value = [] + webdav_client.list_files.return_value = [] backup_id = BACKUP_METADATA["backup_id"] client = await hass_ws_client(hass) await client.send_json_auto_id({"type": "backup/details", "backup_id": backup_id}) @@ -300,7 +298,7 @@ async def test_raises_on_403( mock_config_entry: MockConfigEntry, ) -> None: """Test we raise on 403.""" - webdav_client.list_with_properties.side_effect = UnauthorizedError( + webdav_client.list_files.side_effect = UnauthorizedError( "https://webdav.example.com" ) backup_id = BACKUP_METADATA["backup_id"] @@ -324,30 +322,3 @@ async def test_listeners_get_cleaned_up(hass: HomeAssistant) -> None: remove_listener() assert hass.data.get(DATA_BACKUP_AGENT_LISTENERS) is None - - -async def test_metadata_misses_backup_id( - hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, - webdav_client: AsyncMock, - mock_config_entry: MockConfigEntry, -) -> None: - """Test getting a backup when metadata has backup id property.""" - MOCK_LIST_WITH_PROPERTIES[ - "/Automatic_backup_2025.2.1_2025-02-10_18.31_30202686.metadata.json" - ] = [ - Property( - namespace="homeassistant", - name="metadata_version", - value="1", - ) - ] - webdav_client.list_with_properties.return_value = MOCK_LIST_WITH_PROPERTIES - - backup_id = BACKUP_METADATA["backup_id"] - client = await hass_ws_client(hass) - await client.send_json_auto_id({"type": "backup/details", "backup_id": backup_id}) - response = await client.receive_json() - - assert response["success"] - assert response["result"]["backup"] is None From 8726be31ff52b40d43d7e912a26a348084e608c8 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Fri, 14 Mar 2025 10:28:37 +0100 Subject: [PATCH 2588/3148] Use correct unit symbol "min" for minutes in `webmin` integration (#140448) * Use correct unit symbol "min" for minutes in `webmin` integration Replace the unit symbol "m" which stands for meter with the correct SI uni symbol "min". * Update test_sensor.ambr * Update test_sensor.ambr (2) --- homeassistant/components/webmin/strings.json | 6 ++-- .../webmin/snapshots/test_sensor.ambr | 36 +++++++++---------- 2 files changed, 21 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/webmin/strings.json b/homeassistant/components/webmin/strings.json index 9a6d6d4fbe4..b92986f917a 100644 --- a/homeassistant/components/webmin/strings.json +++ b/homeassistant/components/webmin/strings.json @@ -29,13 +29,13 @@ "entity": { "sensor": { "load_1m": { - "name": "Load (1m)" + "name": "Load (1 min)" }, "load_5m": { - "name": "Load (5m)" + "name": "Load (5 min)" }, "load_15m": { - "name": "Load (15m)" + "name": "Load (15 min)" }, "mem_total": { "name": "Memory total" diff --git a/tests/components/webmin/snapshots/test_sensor.ambr b/tests/components/webmin/snapshots/test_sensor.ambr index a2068f662ba..1af5fe46b5c 100644 --- a/tests/components/webmin/snapshots/test_sensor.ambr +++ b/tests/components/webmin/snapshots/test_sensor.ambr @@ -1451,7 +1451,7 @@ 'state': '8794.3125', }) # --- -# name: test_sensor[sensor.192_168_1_1_load_15m-entry] +# name: test_sensor[sensor.192_168_1_1_load_15_min-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -1466,7 +1466,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.192_168_1_1_load_15m', + 'entity_id': 'sensor.192_168_1_1_load_15_min', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -1478,7 +1478,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Load (15m)', + 'original_name': 'Load (15 min)', 'platform': 'webmin', 'previous_unique_id': None, 'supported_features': 0, @@ -1487,21 +1487,21 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[sensor.192_168_1_1_load_15m-state] +# name: test_sensor[sensor.192_168_1_1_load_15_min-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': '192.168.1.1 Load (15m)', + 'friendly_name': '192.168.1.1 Load (15 min)', 'state_class': , }), 'context': , - 'entity_id': 'sensor.192_168_1_1_load_15m', + 'entity_id': 'sensor.192_168_1_1_load_15_min', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '1.37', }) # --- -# name: test_sensor[sensor.192_168_1_1_load_1m-entry] +# name: test_sensor[sensor.192_168_1_1_load_1_min-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -1516,7 +1516,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.192_168_1_1_load_1m', + 'entity_id': 'sensor.192_168_1_1_load_1_min', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -1528,7 +1528,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Load (1m)', + 'original_name': 'Load (1 min)', 'platform': 'webmin', 'previous_unique_id': None, 'supported_features': 0, @@ -1537,21 +1537,21 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[sensor.192_168_1_1_load_1m-state] +# name: test_sensor[sensor.192_168_1_1_load_1_min-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': '192.168.1.1 Load (1m)', + 'friendly_name': '192.168.1.1 Load (1 min)', 'state_class': , }), 'context': , - 'entity_id': 'sensor.192_168_1_1_load_1m', + 'entity_id': 'sensor.192_168_1_1_load_1_min', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '1.29', }) # --- -# name: test_sensor[sensor.192_168_1_1_load_5m-entry] +# name: test_sensor[sensor.192_168_1_1_load_5_min-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -1566,7 +1566,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.192_168_1_1_load_5m', + 'entity_id': 'sensor.192_168_1_1_load_5_min', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -1578,7 +1578,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Load (5m)', + 'original_name': 'Load (5 min)', 'platform': 'webmin', 'previous_unique_id': None, 'supported_features': 0, @@ -1587,14 +1587,14 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[sensor.192_168_1_1_load_5m-state] +# name: test_sensor[sensor.192_168_1_1_load_5_min-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': '192.168.1.1 Load (5m)', + 'friendly_name': '192.168.1.1 Load (5 min)', 'state_class': , }), 'context': , - 'entity_id': 'sensor.192_168_1_1_load_5m', + 'entity_id': 'sensor.192_168_1_1_load_5_min', 'last_changed': , 'last_reported': , 'last_updated': , From 2e20245cdff4bd26d2d65bc3b27315707c1e3f56 Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Tue, 11 Mar 2025 10:31:20 -0400 Subject: [PATCH 2589/3148] Fix bug with all Roborock maps being set to the wrong map when empty (#138493) * Fix bug with all maps being set to the same when empty * fix parens * fix other parens * rework some of the logic * few small updates * Remove test that is no longer relevant * remove updated time bump --- homeassistant/components/roborock/image.py | 28 +++++++----------- tests/components/roborock/test_image.py | 34 ---------------------- 2 files changed, 11 insertions(+), 51 deletions(-) diff --git a/homeassistant/components/roborock/image.py b/homeassistant/components/roborock/image.py index 66088d6453c..3bd2fec2d90 100644 --- a/homeassistant/components/roborock/image.py +++ b/homeassistant/components/roborock/image.py @@ -112,19 +112,6 @@ class RoborockMap(RoborockCoordinatedEntityV1, ImageEntity): """Return if this map is the currently selected map.""" return self.map_flag == self.coordinator.current_map - def is_map_valid(self) -> bool: - """Update the map if it is valid. - - Update this map if it is the currently active map, and the - vacuum is cleaning, or if it has never been set at all. - """ - return self.cached_map == b"" or ( - self.is_selected - and self.image_last_updated is not None - and self.coordinator.roborock_device_info.props.status is not None - and bool(self.coordinator.roborock_device_info.props.status.in_cleaning) - ) - async def async_added_to_hass(self) -> None: """When entity is added to hass load any previously cached maps from disk.""" await super().async_added_to_hass() @@ -137,15 +124,22 @@ class RoborockMap(RoborockCoordinatedEntityV1, ImageEntity): # Bump last updated every third time the coordinator runs, so that async_image # will be called and we will evaluate on the new coordinator data if we should # update the cache. - if ( - dt_util.utcnow() - self.image_last_updated - ).total_seconds() > IMAGE_CACHE_INTERVAL and self.is_map_valid(): + if self.is_selected and ( + ( + (dt_util.utcnow() - self.image_last_updated).total_seconds() + > IMAGE_CACHE_INTERVAL + and self.coordinator.roborock_device_info.props.status is not None + and bool(self.coordinator.roborock_device_info.props.status.in_cleaning) + ) + or self.cached_map == b"" + ): + # This will tell async_image it should update. self._attr_image_last_updated = dt_util.utcnow() super()._handle_coordinator_update() async def async_image(self) -> bytes | None: """Update the image if it is not cached.""" - if self.is_map_valid(): + if self.is_selected: response = await asyncio.gather( *( self.cloud_api.get_map_v1(), diff --git a/tests/components/roborock/test_image.py b/tests/components/roborock/test_image.py index fd6c8b2796a..7d79cf4f6ab 100644 --- a/tests/components/roborock/test_image.py +++ b/tests/components/roborock/test_image.py @@ -3,7 +3,6 @@ import copy from datetime import timedelta from http import HTTPStatus -import io from unittest.mock import patch from PIL import Image @@ -111,39 +110,6 @@ async def test_floorplan_image_failed_parse( assert not resp.ok -async def test_load_stored_image( - hass: HomeAssistant, - hass_client: ClientSessionGenerator, - setup_entry: MockConfigEntry, -) -> None: - """Test that we correctly load an image from storage when it already exists.""" - img_byte_arr = io.BytesIO() - MAP_DATA.image.data.save(img_byte_arr, format="PNG") - img_bytes = img_byte_arr.getvalue() - - # Load the image on demand, which should queue it to be cached on disk - client = await hass_client() - resp = await client.get("/api/image_proxy/image.roborock_s7_maxv_upstairs") - assert resp.status == HTTPStatus.OK - - with patch( - "homeassistant.components.roborock.image.RoborockMapDataParser.parse", - ) as parse_map: - # Reload the config entry so that the map is saved in storage and entities exist. - await hass.config_entries.async_reload(setup_entry.entry_id) - await hass.async_block_till_done() - assert hass.states.get("image.roborock_s7_maxv_upstairs") is not None - client = await hass_client() - resp = await client.get("/api/image_proxy/image.roborock_s7_maxv_upstairs") - # Test that we can get the image and it correctly serialized and unserialized. - assert resp.status == HTTPStatus.OK - body = await resp.read() - assert body == img_bytes - - # Ensure that we never tried to update the map, and only used the cached image. - assert parse_map.call_count == 0 - - async def test_fail_to_save_image( hass: HomeAssistant, hass_client: ClientSessionGenerator, From e648716ddf0d5b001a59e623df265709d4682956 Mon Sep 17 00:00:00 2001 From: jb101010-2 <168106462+jb101010-2@users.noreply.github.com> Date: Wed, 5 Mar 2025 18:12:34 +0100 Subject: [PATCH 2590/3148] Bump pysuezV2 to 2.0.4 (#139824) --- homeassistant/components/suez_water/coordinator.py | 4 ++-- homeassistant/components/suez_water/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/suez_water/coordinator.py b/homeassistant/components/suez_water/coordinator.py index 38f94b8937e..10d4d3cdbcb 100644 --- a/homeassistant/components/suez_water/coordinator.py +++ b/homeassistant/components/suez_water/coordinator.py @@ -20,8 +20,8 @@ class SuezWaterAggregatedAttributes: this_month_consumption: dict[str, float] previous_month_consumption: dict[str, float] - last_year_overall: dict[str, float] - this_year_overall: dict[str, float] + last_year_overall: int + this_year_overall: int history: dict[str, float] highest_monthly_consumption: float diff --git a/homeassistant/components/suez_water/manifest.json b/homeassistant/components/suez_water/manifest.json index 5d317ea5ba3..f09d2e22633 100644 --- a/homeassistant/components/suez_water/manifest.json +++ b/homeassistant/components/suez_water/manifest.json @@ -7,5 +7,5 @@ "iot_class": "cloud_polling", "loggers": ["pysuez", "regex"], "quality_scale": "bronze", - "requirements": ["pysuezV2==2.0.3"] + "requirements": ["pysuezV2==2.0.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index 82f567631fb..299e2283e6b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2346,7 +2346,7 @@ pysqueezebox==0.12.0 pystiebeleltron==0.0.1.dev2 # homeassistant.components.suez_water -pysuezV2==2.0.3 +pysuezV2==2.0.4 # homeassistant.components.switchbee pyswitchbee==1.8.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bd96a9ef79f..30c07a08a07 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1915,7 +1915,7 @@ pyspeex-noise==1.0.2 pysqueezebox==0.12.0 # homeassistant.components.suez_water -pysuezV2==2.0.3 +pysuezV2==2.0.4 # homeassistant.components.switchbee pyswitchbee==1.8.3 From 74fe35f44eff0fdc93e3e6517f0ee47cb6235080 Mon Sep 17 00:00:00 2001 From: Glenn Waters Date: Sun, 9 Mar 2025 15:03:03 -0400 Subject: [PATCH 2591/3148] Bump upb-lib to 0.6.1 (#140212) --- homeassistant/components/upb/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/upb/manifest.json b/homeassistant/components/upb/manifest.json index e5da4c4d621..b40388be71b 100644 --- a/homeassistant/components/upb/manifest.json +++ b/homeassistant/components/upb/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/upb", "iot_class": "local_push", "loggers": ["upb_lib"], - "requirements": ["upb-lib==0.6.0"] + "requirements": ["upb-lib==0.6.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 299e2283e6b..92ed0bea1a9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2977,7 +2977,7 @@ unifiled==0.11 universal-silabs-flasher==0.0.29 # homeassistant.components.upb -upb-lib==0.6.0 +upb-lib==0.6.1 # homeassistant.components.upcloud upcloud-api==2.6.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 30c07a08a07..03f313002d0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2393,7 +2393,7 @@ ultraheat-api==0.5.7 unifi-discovery==1.2.0 # homeassistant.components.upb -upb-lib==0.6.0 +upb-lib==0.6.1 # homeassistant.components.upcloud upcloud-api==2.6.0 From db26a4273427fffa473618201453b91a330aae8a Mon Sep 17 00:00:00 2001 From: Louis Christ Date: Fri, 14 Mar 2025 10:20:16 +0100 Subject: [PATCH 2592/3148] Use only IPv4 for zeroconf in bluesound integration (#140226) * Use only ipv4 for zeroconf * Fix tests * Use only ip_address for ip version check * Add test * Reduce test --- .../components/bluesound/config_flow.py | 3 ++ .../components/bluesound/strings.json | 3 +- .../components/bluesound/test_config_flow.py | 33 +++++++++++++++---- 3 files changed, 32 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/bluesound/config_flow.py b/homeassistant/components/bluesound/config_flow.py index 2f002b70e1d..cfb6646d829 100644 --- a/homeassistant/components/bluesound/config_flow.py +++ b/homeassistant/components/bluesound/config_flow.py @@ -75,6 +75,9 @@ class BluesoundConfigFlow(ConfigFlow, domain=DOMAIN): self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle a flow initialized by zeroconf discovery.""" + # the player can have an ipv6 address, but the api is only available on ipv4 + if discovery_info.ip_address.version != 4: + return self.async_abort(reason="no_ipv4_address") if discovery_info.port is not None: self._port = discovery_info.port diff --git a/homeassistant/components/bluesound/strings.json b/homeassistant/components/bluesound/strings.json index b50c01a11bf..1170e0b92e0 100644 --- a/homeassistant/components/bluesound/strings.json +++ b/homeassistant/components/bluesound/strings.json @@ -19,7 +19,8 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]" + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "no_ipv4_address": "No IPv4 address found." }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" diff --git a/tests/components/bluesound/test_config_flow.py b/tests/components/bluesound/test_config_flow.py index d0e0f75991b..a4d5eecd744 100644 --- a/tests/components/bluesound/test_config_flow.py +++ b/tests/components/bluesound/test_config_flow.py @@ -1,5 +1,6 @@ """Test the Bluesound config flow.""" +from ipaddress import IPv4Address, IPv6Address from unittest.mock import AsyncMock from pyblu.errors import PlayerUnreachableError @@ -121,8 +122,8 @@ async def test_zeroconf_flow_success( DOMAIN, context={"source": SOURCE_ZEROCONF}, data=ZeroconfServiceInfo( - ip_address="1.1.1.1", - ip_addresses=["1.1.1.1"], + ip_address=IPv4Address("1.1.1.1"), + ip_addresses=[IPv4Address("1.1.1.1")], port=11000, hostname="player-name1111", type="_musc._tcp.local.", @@ -160,8 +161,8 @@ async def test_zeroconf_flow_cannot_connect( DOMAIN, context={"source": SOURCE_ZEROCONF}, data=ZeroconfServiceInfo( - ip_address="1.1.1.1", - ip_addresses=["1.1.1.1"], + ip_address=IPv4Address("1.1.1.1"), + ip_addresses=[IPv4Address("1.1.1.1")], port=11000, hostname="player-name1111", type="_musc._tcp.local.", @@ -187,8 +188,8 @@ async def test_zeroconf_flow_already_configured( DOMAIN, context={"source": SOURCE_ZEROCONF}, data=ZeroconfServiceInfo( - ip_address="1.1.1.2", - ip_addresses=["1.1.1.2"], + ip_address=IPv4Address("1.1.1.2"), + ip_addresses=[IPv4Address("1.1.1.2")], port=11000, hostname="player-name1112", type="_musc._tcp.local.", @@ -203,3 +204,23 @@ async def test_zeroconf_flow_already_configured( assert config_entry.data[CONF_HOST] == "1.1.1.2" player_mocks.player_data_for_already_configured.player.sync_status.assert_called_once() + + +async def test_zeroconf_flow_no_ipv4_address(hass: HomeAssistant) -> None: + """Test abort flow when no ipv4 address is found in zeroconf data.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=ZeroconfServiceInfo( + ip_address=IPv6Address("2001:db8::1"), + ip_addresses=[IPv6Address("2001:db8::1")], + port=11000, + hostname="player-name1112", + type="_musc._tcp.local.", + name="player-name._musc._tcp.local.", + properties={}, + ), + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_ipv4_address" From 6349821037f3486f7c160d2bdde9f2a91ed0a898 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Tue, 11 Mar 2025 20:05:02 +0100 Subject: [PATCH 2593/3148] Only do WebDAV path migration when path differs (#140402) --- homeassistant/components/webdav/helpers.py | 3 ++- tests/components/webdav/test_init.py | 24 ++++++++++++++++++---- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/webdav/helpers.py b/homeassistant/components/webdav/helpers.py index 5db15bba0f7..442f69b4d3c 100644 --- a/homeassistant/components/webdav/helpers.py +++ b/homeassistant/components/webdav/helpers.py @@ -49,7 +49,8 @@ async def async_ensure_path_exists(client: Client, path: str) -> bool: async def async_migrate_wrong_folder_path(client: Client, path: str) -> None: """Migrate the wrong encoded folder path to the correct one.""" wrong_path = path.replace(" ", "%20") - if await client.check(wrong_path): + # migrate folder when the old folder exists + if wrong_path != path and await client.check(wrong_path): try: await client.move(wrong_path, path) except WebDavError as err: diff --git a/tests/components/webdav/test_init.py b/tests/components/webdav/test_init.py index c267f7c3251..124a644fa93 100644 --- a/tests/components/webdav/test_init.py +++ b/tests/components/webdav/test_init.py @@ -39,14 +39,30 @@ async def test_migrate_wrong_path( webdav_client.move.assert_called_once_with("/wrong%20path", "/wrong path") +@pytest.mark.parametrize( + ("expected_path", "remote_path_check"), + [ + ( + "/correct path", + False, + ), # remote_path_check is False as /correct%20path is not there + ("/", True), + ("/folder_with_underscores", True), + ], +) async def test_migrate_non_wrong_path( - hass: HomeAssistant, webdav_client: AsyncMock + hass: HomeAssistant, + webdav_client: AsyncMock, + expected_path: str, + remote_path_check: bool, ) -> None: """Test no migration of correct folder path.""" webdav_client.list_with_properties.return_value = [ - {"/correct path": []}, + {expected_path: []}, ] - webdav_client.check.side_effect = lambda path: path == "/correct path" + # first return is used to check the connectivity + # second is used in the migration to determine if wrong quoted path is there + webdav_client.check.side_effect = [True, remote_path_check] config_entry = MockConfigEntry( title="user@webdav.demo", @@ -55,7 +71,7 @@ async def test_migrate_non_wrong_path( CONF_URL: "https://webdav.demo", CONF_USERNAME: "user", CONF_PASSWORD: "supersecretpassword", - CONF_BACKUP_PATH: "/correct path", + CONF_BACKUP_PATH: expected_path, }, entry_id="01JKXV07ASC62D620DGYNG2R8H", ) From 8b96a9606d7b3dcf0e2ebca5ccc6b83102515f0f Mon Sep 17 00:00:00 2001 From: Maikel Punie Date: Wed, 12 Mar 2025 16:30:01 +0100 Subject: [PATCH 2594/3148] Bump velbusaio to 2025.3.1 (#140443) --- homeassistant/components/velbus/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/velbus/manifest.json b/homeassistant/components/velbus/manifest.json index ff30ee14a8a..1cb540b22ec 100644 --- a/homeassistant/components/velbus/manifest.json +++ b/homeassistant/components/velbus/manifest.json @@ -14,7 +14,7 @@ "velbus-protocol" ], "quality_scale": "bronze", - "requirements": ["velbus-aio==2025.3.0"], + "requirements": ["velbus-aio==2025.3.1"], "usb": [ { "vid": "10CF", diff --git a/requirements_all.txt b/requirements_all.txt index 92ed0bea1a9..c97273f355a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3000,7 +3000,7 @@ vallox-websocket-api==5.3.0 vehicle==2.2.2 # homeassistant.components.velbus -velbus-aio==2025.3.0 +velbus-aio==2025.3.1 # homeassistant.components.venstar venstarcolortouch==0.19 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 03f313002d0..0110b23b2c2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2416,7 +2416,7 @@ vallox-websocket-api==5.3.0 vehicle==2.2.2 # homeassistant.components.velbus -velbus-aio==2025.3.0 +velbus-aio==2025.3.1 # homeassistant.components.venstar venstarcolortouch==0.19 From 7607b7d494f2e7436b0bd618ff2884fdd869e2b9 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 12 Mar 2025 19:07:41 +0100 Subject: [PATCH 2595/3148] Mark value in number.set_value action as required (#140445) --- homeassistant/components/number/services.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/number/services.yaml b/homeassistant/components/number/services.yaml index dcbb955d739..6a7083a7613 100644 --- a/homeassistant/components/number/services.yaml +++ b/homeassistant/components/number/services.yaml @@ -7,5 +7,6 @@ set_value: fields: value: example: 42 + required: true selector: text: From 019a0ebf9bab6c4edfc0186c915169cf70bda462 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Thu, 13 Mar 2025 17:23:26 +1000 Subject: [PATCH 2596/3148] Bump Tesla Fleet API to 0.9.13 (#140485) --- homeassistant/components/tesla_fleet/manifest.json | 2 +- homeassistant/components/teslemetry/manifest.json | 2 +- homeassistant/components/tessie/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/tesla_fleet/manifest.json b/homeassistant/components/tesla_fleet/manifest.json index 53aff3d0a54..010197ccbd9 100644 --- a/homeassistant/components/tesla_fleet/manifest.json +++ b/homeassistant/components/tesla_fleet/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/tesla_fleet", "iot_class": "cloud_polling", "loggers": ["tesla-fleet-api"], - "requirements": ["tesla-fleet-api==0.9.12"] + "requirements": ["tesla-fleet-api==0.9.13"] } diff --git a/homeassistant/components/teslemetry/manifest.json b/homeassistant/components/teslemetry/manifest.json index 7c27024d9f0..3d37ced8cff 100644 --- a/homeassistant/components/teslemetry/manifest.json +++ b/homeassistant/components/teslemetry/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/teslemetry", "iot_class": "cloud_polling", "loggers": ["tesla-fleet-api"], - "requirements": ["tesla-fleet-api==0.9.12", "teslemetry-stream==0.6.12"] + "requirements": ["tesla-fleet-api==0.9.13", "teslemetry-stream==0.6.12"] } diff --git a/homeassistant/components/tessie/manifest.json b/homeassistant/components/tessie/manifest.json index d4ac56883e8..4ddd63552f0 100644 --- a/homeassistant/components/tessie/manifest.json +++ b/homeassistant/components/tessie/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/tessie", "iot_class": "cloud_polling", "loggers": ["tessie", "tesla-fleet-api"], - "requirements": ["tessie-api==0.1.1", "tesla-fleet-api==0.9.12"] + "requirements": ["tessie-api==0.1.1", "tesla-fleet-api==0.9.13"] } diff --git a/requirements_all.txt b/requirements_all.txt index c97273f355a..725ba0339ca 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2872,7 +2872,7 @@ temperusb==1.6.1 # homeassistant.components.tesla_fleet # homeassistant.components.teslemetry # homeassistant.components.tessie -tesla-fleet-api==0.9.12 +tesla-fleet-api==0.9.13 # homeassistant.components.powerwall tesla-powerwall==0.5.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0110b23b2c2..84d3be99232 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2312,7 +2312,7 @@ temperusb==1.6.1 # homeassistant.components.tesla_fleet # homeassistant.components.teslemetry # homeassistant.components.tessie -tesla-fleet-api==0.9.12 +tesla-fleet-api==0.9.13 # homeassistant.components.powerwall tesla-powerwall==0.5.2 From fed4015bab3288fa361909bfc36c4cd144cec3da Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Thu, 13 Mar 2025 16:57:22 +0100 Subject: [PATCH 2597/3148] Update xknxproject to 3.8.2 (#140499) --- homeassistant/components/knx/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/knx/manifest.json b/homeassistant/components/knx/manifest.json index 8cfb034a793..98e3a6a5242 100644 --- a/homeassistant/components/knx/manifest.json +++ b/homeassistant/components/knx/manifest.json @@ -11,7 +11,7 @@ "loggers": ["xknx", "xknxproject"], "requirements": [ "xknx==3.6.0", - "xknxproject==3.8.1", + "xknxproject==3.8.2", "knx-frontend==2025.1.30.194235" ], "single_config_entry": true diff --git a/requirements_all.txt b/requirements_all.txt index 725ba0339ca..9f41ec2fded 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3091,7 +3091,7 @@ xiaomi-ble==0.33.0 xknx==3.6.0 # homeassistant.components.knx -xknxproject==3.8.1 +xknxproject==3.8.2 # homeassistant.components.fritz # homeassistant.components.rest diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 84d3be99232..3bfae75e1fb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2492,7 +2492,7 @@ xiaomi-ble==0.33.0 xknx==3.6.0 # homeassistant.components.knx -xknxproject==3.8.1 +xknxproject==3.8.2 # homeassistant.components.fritz # homeassistant.components.rest From 54ad44a5742abb8881d6874dfd6474ee7ee3e8a8 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Thu, 13 Mar 2025 19:58:09 +0100 Subject: [PATCH 2598/3148] Fix Shelly diagnostics for devices without WebSocket Outbound support (#140501) * Don't assume that `ws` is always in config * Fix device --- homeassistant/components/shelly/diagnostics.py | 14 ++++++++------ tests/components/shelly/test_diagnostics.py | 18 ++++++++++++++++++ 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/shelly/diagnostics.py b/homeassistant/components/shelly/diagnostics.py index a5fe1f5b6c0..9250206b8ab 100644 --- a/homeassistant/components/shelly/diagnostics.py +++ b/homeassistant/components/shelly/diagnostics.py @@ -74,12 +74,14 @@ async def async_get_config_entry_diagnostics( device_settings = { k: v for k, v in rpc_coordinator.device.config.items() if k in ["cloud"] } - ws_config = rpc_coordinator.device.config["ws"] - device_settings["ws_outbound_enabled"] = ws_config["enable"] - if ws_config["enable"]: - device_settings["ws_outbound_server_valid"] = bool( - ws_config["server"] == get_rpc_ws_url(hass) - ) + if not (ws_config := rpc_coordinator.device.config.get("ws", {})): + device_settings["ws_outbound"] = "not supported" + if (ws_outbound_enabled := ws_config.get("enable")) is not None: + device_settings["ws_outbound_enabled"] = ws_outbound_enabled + if ws_outbound_enabled: + device_settings["ws_outbound_server_valid"] = bool( + ws_config["server"] == get_rpc_ws_url(hass) + ) device_status = { k: v for k, v in rpc_coordinator.device.status.items() diff --git a/tests/components/shelly/test_diagnostics.py b/tests/components/shelly/test_diagnostics.py index c0f78d48d9b..85bf1cc4b37 100644 --- a/tests/components/shelly/test_diagnostics.py +++ b/tests/components/shelly/test_diagnostics.py @@ -194,3 +194,21 @@ async def test_rpc_config_entry_diagnostics_ws_outbound( result["device_settings"]["ws_outbound_server_valid"] == ws_outbound_server_valid ) + + +async def test_rpc_config_entry_diagnostics_no_ws( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_rpc_device: Mock, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test config entry diagnostics for rpc device which doesn't support ws outbound.""" + config = deepcopy(mock_rpc_device.config) + config.pop("ws") + monkeypatch.setattr(mock_rpc_device, "config", config) + + entry = await init_integration(hass, 3) + + result = await get_diagnostics_for_config_entry(hass, hass_client, entry) + + assert result["device_settings"]["ws_outbound"] == "not supported" From 761be9342e0c36322d7de5da5a7b93a43425c9a3 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 14 Mar 2025 00:28:08 +0100 Subject: [PATCH 2599/3148] Fix windowShadeLevel capability in SmartThings (#140552) --- homeassistant/components/smartthings/cover.py | 4 + tests/components/smartthings/conftest.py | 1 + .../fixtures/device_status/ikea_kadrilj.json | 68 ++++++++++++++++ .../fixtures/devices/ikea_kadrilj.json | 78 +++++++++++++++++++ .../smartthings/snapshots/test_cover.ambr | 51 ++++++++++++ .../smartthings/snapshots/test_init.ambr | 33 ++++++++ .../smartthings/snapshots/test_sensor.ambr | 49 ++++++++++++ 7 files changed, 284 insertions(+) create mode 100644 tests/components/smartthings/fixtures/device_status/ikea_kadrilj.json create mode 100644 tests/components/smartthings/fixtures/devices/ikea_kadrilj.json diff --git a/homeassistant/components/smartthings/cover.py b/homeassistant/components/smartthings/cover.py index 0b0f03679eb..29250031be4 100644 --- a/homeassistant/components/smartthings/cover.py +++ b/homeassistant/components/smartthings/cover.py @@ -118,6 +118,10 @@ class SmartThingsCover(SmartThingsEntity, CoverEntity): self._attr_current_cover_position = self.get_attribute_value( Capability.SWITCH_LEVEL, Attribute.LEVEL ) + elif self.supports_capability(Capability.WINDOW_SHADE_LEVEL): + self._attr_current_cover_position = self.get_attribute_value( + Capability.WINDOW_SHADE_LEVEL, Attribute.SHADE_LEVEL + ) self._attr_extra_state_attributes = {} if self.supports_capability(Capability.BATTERY): diff --git a/tests/components/smartthings/conftest.py b/tests/components/smartthings/conftest.py index 57ca8b7877f..1a2276b80b2 100644 --- a/tests/components/smartthings/conftest.py +++ b/tests/components/smartthings/conftest.py @@ -129,6 +129,7 @@ def mock_smartthings() -> Generator[AsyncMock]: "im_speaker_ai_0001", "abl_light_b_001", "tplink_p110", + "ikea_kadrilj", ] ) def device_fixture( diff --git a/tests/components/smartthings/fixtures/device_status/ikea_kadrilj.json b/tests/components/smartthings/fixtures/device_status/ikea_kadrilj.json new file mode 100644 index 00000000000..56a2d9e762d --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/ikea_kadrilj.json @@ -0,0 +1,68 @@ +{ + "components": { + "main": { + "windowShadeLevel": { + "shadeLevel": { + "value": 32, + "unit": "%", + "timestamp": "2025-03-13T10:40:25.613Z" + } + }, + "refresh": {}, + "windowShadePreset": {}, + "battery": { + "quantity": { + "value": null + }, + "battery": { + "value": 37, + "unit": "%", + "timestamp": "2025-03-13T07:09:05.149Z" + }, + "type": { + "value": null + } + }, + "firmwareUpdate": { + "lastUpdateStatusReason": { + "value": null + }, + "availableVersion": { + "value": "22007631", + "timestamp": "2025-03-12T20:35:04.576Z" + }, + "lastUpdateStatus": { + "value": null + }, + "supportedCommands": { + "value": null + }, + "state": { + "value": "updateRequested", + "timestamp": "2025-03-12T20:35:03.879Z" + }, + "updateAvailable": { + "value": false, + "timestamp": "2025-03-12T20:35:04.577Z" + }, + "currentVersion": { + "value": "22007631", + "timestamp": "2025-03-12T20:35:04.508Z" + }, + "lastUpdateTime": { + "value": null + } + }, + "windowShade": { + "supportedWindowShadeCommands": { + "value": ["open", "close", "pause"], + "timestamp": "2025-03-13T10:33:48.402Z" + }, + "windowShade": { + "value": "partially open", + "timestamp": "2025-03-13T10:55:58.205Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/devices/ikea_kadrilj.json b/tests/components/smartthings/fixtures/devices/ikea_kadrilj.json new file mode 100644 index 00000000000..36f9d40f7e4 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/ikea_kadrilj.json @@ -0,0 +1,78 @@ +{ + "items": [ + { + "deviceId": "71afed1c-006d-4e48-b16e-e7f88f9fd638", + "name": "window-treatment-battery", + "label": "Kitchen IKEA KADRILJ Window blind", + "manufacturerName": "SmartThingsCommunity", + "presentationId": "fa41d7d3-4c03-327f-b0ce-2edc829f0e34", + "deviceManufacturerCode": "IKEA of Sweden", + "locationId": "5b5f96b5-0286-4f4a-86ef-d5d5c1a78cb8", + "ownerId": "f43fd9e5-2ecd-4aae-aeac-73a8e5cb04da", + "roomId": "89f675a1-1f16-451c-8ab1-a7fdacc5852d", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "windowShade", + "version": 1 + }, + { + "id": "windowShadePreset", + "version": 1 + }, + { + "id": "windowShadeLevel", + "version": 1 + }, + { + "id": "battery", + "version": 1 + }, + { + "id": "firmwareUpdate", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + } + ], + "categories": [ + { + "name": "Blind", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2023-04-26T18:19:06.792Z", + "parentDeviceId": "3ffe04c4-a12c-41f5-b83d-c1b28eca2b5f", + "profile": { + "id": "6d9804bc-9e56-3823-95be-4b315669c481" + }, + "zigbee": { + "eui": "000D6FFFFE2AD0E7", + "networkId": "3009", + "driverId": "46b8bada-1a55-4f84-8915-47ce2cad3621", + "executingLocally": true, + "hubId": "3ffe04c4-a12c-41f5-b83d-c1b28eca2b5f", + "provisioningState": "NONFUNCTIONAL" + }, + "type": "ZIGBEE", + "restrictionTier": 0, + "allowed": null, + "indoorMap": { + "coordinates": [10.0, 36.0, 98.0], + "rotation": [270.0, 0.0, 0.0], + "visible": true, + "data": null + }, + "executionContext": "LOCAL", + "relationships": [] + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/snapshots/test_cover.ambr b/tests/components/smartthings/snapshots/test_cover.ambr index aa928c09b7a..6877a8ccc01 100644 --- a/tests/components/smartthings/snapshots/test_cover.ambr +++ b/tests/components/smartthings/snapshots/test_cover.ambr @@ -49,3 +49,54 @@ 'state': 'open', }) # --- +# name: test_all_entities[ikea_kadrilj][cover.kitchen_ikea_kadrilj_window_blind-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.kitchen_ikea_kadrilj_window_blind', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '71afed1c-006d-4e48-b16e-e7f88f9fd638', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[ikea_kadrilj][cover.kitchen_ikea_kadrilj_window_blind-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'battery_level': 37, + 'current_position': 32, + 'device_class': 'shade', + 'friendly_name': 'Kitchen IKEA KADRILJ Window blind', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.kitchen_ikea_kadrilj_window_blind', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'open', + }) +# --- diff --git a/tests/components/smartthings/snapshots/test_init.ambr b/tests/components/smartthings/snapshots/test_init.ambr index 5de382c75b8..849dfea6a68 100644 --- a/tests/components/smartthings/snapshots/test_init.ambr +++ b/tests/components/smartthings/snapshots/test_init.ambr @@ -959,6 +959,39 @@ 'via_device_id': None, }) # --- +# name: test_devices[ikea_kadrilj] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '71afed1c-006d-4e48-b16e-e7f88f9fd638', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': None, + 'model': None, + 'model_id': None, + 'name': 'Kitchen IKEA KADRILJ Window blind', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- # name: test_devices[im_speaker_ai_0001] DeviceRegistryEntrySnapshot({ 'area_id': None, diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index 52df02f55b8..4de3541ee23 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -5506,6 +5506,55 @@ 'state': '19.0', }) # --- +# name: test_all_entities[ikea_kadrilj][sensor.kitchen_ikea_kadrilj_window_blind_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.kitchen_ikea_kadrilj_window_blind_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': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '71afed1c-006d-4e48-b16e-e7f88f9fd638.battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[ikea_kadrilj][sensor.kitchen_ikea_kadrilj_window_blind_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Kitchen IKEA KADRILJ Window blind Battery', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.kitchen_ikea_kadrilj_window_blind_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '37', + }) +# --- # name: test_all_entities[im_speaker_ai_0001][sensor.galaxy_home_mini_media_input_source-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From c852e1398cb91dae042a90fcbc4246e12f9b065b Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 14 Mar 2025 00:28:01 +0100 Subject: [PATCH 2600/3148] Set unit of measurement for SmartThings oven setpoint (#140560) --- .../components/smartthings/sensor.py | 3 + tests/components/smartthings/conftest.py | 1 + .../device_status/da_ks_range_0101x.json | 688 ++++++++++++++++++ .../fixtures/devices/da_ks_range_0101x.json | 197 +++++ .../smartthings/snapshots/test_init.ambr | 33 + .../smartthings/snapshots/test_sensor.ambr | 406 ++++++++++- 6 files changed, 1325 insertions(+), 3 deletions(-) create mode 100644 tests/components/smartthings/fixtures/device_status/da_ks_range_0101x.json create mode 100644 tests/components/smartthings/fixtures/devices/da_ks_range_0101x.json diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index f9070c6d718..87e19f2502e 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -572,6 +572,9 @@ CAPABILITY_TO_SENSORS: dict[ SmartThingsSensorEntityDescription( key=Attribute.OVEN_SETPOINT, translation_key="oven_setpoint", + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT, + value_fn=lambda value: value if value != 0 else None, ) ] }, diff --git a/tests/components/smartthings/conftest.py b/tests/components/smartthings/conftest.py index 1a2276b80b2..9f17e61d652 100644 --- a/tests/components/smartthings/conftest.py +++ b/tests/components/smartthings/conftest.py @@ -109,6 +109,7 @@ def mock_smartthings() -> Generator[AsyncMock]: "da_wm_wm_000001_1", "da_rvc_normal_000001", "da_ks_microwave_0101x", + "da_ks_range_0101x", "hue_color_temperature_bulb", "hue_rgbw_color_bulb", "c2c_shade", diff --git a/tests/components/smartthings/fixtures/device_status/da_ks_range_0101x.json b/tests/components/smartthings/fixtures/device_status/da_ks_range_0101x.json new file mode 100644 index 00000000000..6d15aa4696d --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/da_ks_range_0101x.json @@ -0,0 +1,688 @@ +{ + "components": { + "cavity-01": { + "ovenSetpoint": { + "ovenSetpointRange": { + "value": null + }, + "ovenSetpoint": { + "value": 0, + "timestamp": "2022-02-21T22:37:06.976Z" + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": [], + "timestamp": "2022-09-07T22:35:34.197Z" + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": 175, + "unit": "F", + "timestamp": "2022-02-21T22:37:06.976Z" + } + }, + "samsungce.ovenOperatingState": { + "completionTime": { + "value": "2024-05-14T19:00:04.579Z", + "timestamp": "2024-05-14T19:00:04.584Z" + }, + "operatingState": { + "value": "ready", + "timestamp": "2022-02-21T22:37:05.415Z" + }, + "progress": { + "value": 1, + "timestamp": "2022-02-21T22:37:05.415Z" + }, + "ovenJobState": { + "value": "ready", + "timestamp": "2022-02-21T22:37:05.415Z" + }, + "operationTime": { + "value": "00:00:00", + "timestamp": "2022-02-21T22:37:05.415Z" + } + }, + "samsungce.kitchenDeviceDefaults": { + "defaultOperationTime": { + "value": null + }, + "defaultOvenMode": { + "value": "ConvectionBake", + "timestamp": "2022-02-21T22:37:06.983Z" + }, + "defaultOvenSetpoint": { + "value": 350, + "timestamp": "2022-02-21T22:37:06.976Z" + } + }, + "custom.ovenCavityStatus": { + "ovenCavityStatus": { + "value": "off", + "timestamp": "2025-03-12T20:38:01.259Z" + } + }, + "ovenMode": { + "supportedOvenModes": { + "value": ["Others"], + "timestamp": "2022-02-21T22:37:08.409Z" + }, + "ovenMode": { + "value": "Others", + "timestamp": "2022-02-21T22:37:06.983Z" + } + }, + "ovenOperatingState": { + "completionTime": { + "value": "2024-05-14T19:00:04.579Z", + "timestamp": "2024-05-14T19:00:04.584Z" + }, + "machineState": { + "value": "ready", + "timestamp": "2022-02-21T22:37:05.415Z" + }, + "progress": { + "value": 1, + "unit": "%", + "timestamp": "2022-02-21T22:37:05.415Z" + }, + "supportedMachineStates": { + "value": null + }, + "ovenJobState": { + "value": "ready", + "timestamp": "2022-02-21T22:37:05.415Z" + }, + "operationTime": { + "value": 0, + "timestamp": "2022-02-21T22:37:05.415Z" + } + }, + "samsungce.ovenMode": { + "supportedOvenModes": { + "value": ["SelfClean", "SteamClean", "NoOperation"], + "timestamp": "2022-02-21T22:37:08.409Z" + }, + "ovenMode": { + "value": "NoOperation", + "timestamp": "2022-02-21T22:37:06.983Z" + } + } + }, + "main": { + "ovenSetpoint": { + "ovenSetpointRange": { + "value": null + }, + "ovenSetpoint": { + "value": 425, + "timestamp": "2025-03-13T21:42:23.492Z" + } + }, + "samsungce.meatProbe": { + "temperatureSetpoint": { + "value": 0, + "unit": "F", + "timestamp": "2022-02-21T22:37:02.619Z" + }, + "temperature": { + "value": 0, + "unit": "F", + "timestamp": "2022-02-21T22:37:02.619Z" + }, + "status": { + "value": "disconnected", + "timestamp": "2022-02-21T22:37:02.679Z" + } + }, + "refresh": {}, + "samsungce.doorState": { + "doorState": { + "value": "closed", + "timestamp": "2025-03-12T20:38:01.255Z" + } + }, + "samsungce.kitchenDeviceDefaults": { + "defaultOperationTime": { + "value": 3600, + "timestamp": "2025-03-13T21:23:24.771Z" + }, + "defaultOvenMode": { + "value": "ConvectionBake", + "timestamp": "2025-03-13T21:23:27.659Z" + }, + "defaultOvenSetpoint": { + "value": 350, + "timestamp": "2025-03-13T21:23:27.596Z" + } + }, + "execute": { + "data": { + "value": { + "payload": { + "rt": ["x.com.samsung.da.information"], + "if": ["oic.if.baseline", "oic.if.a"], + "x.com.samsung.da.modelNum": "TP1X_DA-KS-RANGE-0101X|40445041|5001011E03151101020000000000000", + "x.com.samsung.da.description": "TP1X_DA-KS-OVEN-01011", + "x.com.samsung.da.serialNum": "0J4D7DARB03393K", + "x.com.samsung.da.otnDUID": "ZPCNQWBWXI47Q", + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "Version", + "x.com.samsung.da.type": "Software", + "x.com.samsung.da.number": "02144A221005", + "x.com.samsung.da.newVersionAvailable": "0" + }, + { + "x.com.samsung.da.id": "1", + "x.com.samsung.da.description": "Version", + "x.com.samsung.da.type": "Firmware", + "x.com.samsung.da.number": "20121600,FFFFFFFF", + "x.com.samsung.da.newVersionAvailable": "0" + } + ] + } + }, + "data": { + "href": "/information/vs/0" + }, + "timestamp": "2023-11-28T22:49:09.333Z" + } + }, + "samsungce.deviceIdentification": { + "micomAssayCode": { + "value": null + }, + "modelName": { + "value": null + }, + "serialNumber": { + "value": null + }, + "serialNumberExtra": { + "value": null + }, + "modelClassificationCode": { + "value": null + }, + "description": { + "value": null + }, + "releaseYear": { + "value": null + }, + "binaryId": { + "value": "TP1X_DA-KS-RANGE-0101X", + "timestamp": "2025-03-12T20:40:29.034Z" + } + }, + "ocf": { + "st": { + "value": null + }, + "mndt": { + "value": null + }, + "mnfv": { + "value": "AKS-WW-TP1-20-OVEN-3-CR_40240205", + "timestamp": "2024-05-14T19:00:26.132Z" + }, + "mnhw": { + "value": "Realtek", + "timestamp": "2024-05-14T19:00:26.132Z" + }, + "di": { + "value": "2c3cbaa0-1899-5ddc-7b58-9d657bd48f18", + "timestamp": "2022-02-21T22:37:02.282Z" + }, + "mnsl": { + "value": "http://www.samsung.com", + "timestamp": "2022-02-21T22:37:02.282Z" + }, + "dmv": { + "value": "1.2.1", + "timestamp": "2022-12-19T22:33:09.710Z" + }, + "n": { + "value": "Samsung Range", + "timestamp": "2024-05-14T19:00:26.132Z" + }, + "mnmo": { + "value": "TP1X_DA-KS-RANGE-0101X|40445041|5001011E031511010200000000000000", + "timestamp": "2024-05-14T19:00:26.132Z" + }, + "vid": { + "value": "DA-KS-RANGE-0101X", + "timestamp": "2022-02-21T22:37:02.282Z" + }, + "mnmn": { + "value": "Samsung Electronics", + "timestamp": "2022-02-21T22:37:02.282Z" + }, + "mnml": { + "value": "http://www.samsung.com", + "timestamp": "2022-02-21T22:37:02.282Z" + }, + "mnpv": { + "value": "DAWIT 3.0", + "timestamp": "2024-05-14T19:00:26.132Z" + }, + "mnos": { + "value": "TizenRT 3.1", + "timestamp": "2024-05-14T19:00:26.132Z" + }, + "pi": { + "value": "2c3cbaa0-1899-5ddc-7b58-9d657bd48f18", + "timestamp": "2022-02-21T22:37:02.282Z" + }, + "icv": { + "value": "core.1.1.0", + "timestamp": "2022-02-21T22:37:02.282Z" + } + }, + "remoteControlStatus": { + "remoteControlEnabled": { + "value": "true", + "timestamp": "2025-03-13T21:42:23.615Z" + } + }, + "samsungce.customRecipe": {}, + "samsungce.kitchenDeviceIdentification": { + "regionCode": { + "value": "US", + "timestamp": "2025-03-13T21:23:27.659Z" + }, + "modelCode": { + "value": "NE6516A-/AA0", + "timestamp": "2025-03-13T21:23:27.659Z" + }, + "fuel": { + "value": null + }, + "type": { + "value": "range", + "timestamp": "2022-02-21T22:37:02.487Z" + }, + "representativeComponent": { + "value": null + } + }, + "samsungce.kitchenModeSpecification": { + "specification": { + "value": { + "single": [ + { + "mode": "Bake", + "supportedOperations": ["start", "set"], + "supportedOptions": { + "temperature": { + "C": { + "min": 80, + "max": 285, + "default": 175, + "resolution": 0 + }, + "F": { + "min": 175, + "max": 550, + "default": 350, + "resolution": 0 + } + }, + "operationTime": { + "min": "00:01:00", + "max": "09:59:00", + "default": "01:00:00", + "resolution": "00:01:00" + } + } + }, + { + "mode": "Broil", + "supportedOperations": ["set"], + "supportedOptions": { + "temperature": { + "C": { + "min": 61441, + "max": 61442, + "default": 61441, + "supportedValues": [61441, 61442] + }, + "F": { + "min": 61441, + "max": 61442, + "default": 61441, + "supportedValues": [61441, 61442] + } + } + } + }, + { + "mode": "ConvectionBake", + "supportedOperations": ["start", "set"], + "supportedOptions": { + "temperature": { + "C": { + "min": 80, + "max": 285, + "default": 160, + "resolution": 0 + }, + "F": { + "min": 175, + "max": 550, + "default": 325, + "resolution": 0 + } + }, + "operationTime": { + "min": "00:01:00", + "max": "09:59:00", + "default": "01:00:00", + "resolution": "00:01:00" + } + } + }, + { + "mode": "ConvectionRoast", + "supportedOperations": ["start", "set"], + "supportedOptions": { + "temperature": { + "C": { + "min": 80, + "max": 285, + "default": 160, + "resolution": 0 + }, + "F": { + "min": 175, + "max": 550, + "default": 325, + "resolution": 0 + } + }, + "operationTime": { + "min": "00:01:00", + "max": "09:59:00", + "default": "01:00:00", + "resolution": "00:01:00" + } + } + }, + { + "mode": "KeepWarm", + "supportedOperations": ["set"], + "supportedOptions": { + "temperature": { + "C": { + "min": 80, + "max": 80, + "default": 80, + "supportedValues": [80] + }, + "F": { + "min": 175, + "max": 175, + "default": 175, + "supportedValues": [175] + } + } + } + }, + { + "mode": "BreadProof", + "supportedOperations": ["set"], + "supportedOptions": { + "temperature": { + "C": { + "min": 35, + "max": 35, + "default": 35, + "supportedValues": [35] + }, + "F": { + "min": 95, + "max": 95, + "default": 95, + "supportedValues": [95] + } + } + } + }, + { + "mode": "AirFryer", + "supportedOperations": ["start", "set"], + "supportedOptions": { + "temperature": { + "C": { + "min": 175, + "max": 260, + "default": 220, + "resolution": 0 + }, + "F": { + "min": 350, + "max": 500, + "default": 425, + "resolution": 0 + } + }, + "operationTime": { + "min": "00:01:00", + "max": "09:59:00", + "default": "01:00:00", + "resolution": "00:01:00" + } + } + }, + { + "mode": "Dehydrate", + "supportedOperations": ["start", "set"], + "supportedOptions": { + "temperature": { + "C": { + "min": 40, + "max": 105, + "default": 65, + "resolution": 0 + }, + "F": { + "min": 100, + "max": 225, + "default": 150, + "resolution": 0 + } + }, + "operationTime": { + "min": "00:01:00", + "max": "09:59:00", + "default": "01:00:00", + "resolution": "00:01:00" + } + } + }, + { + "mode": "SelfClean", + "supportedOperations": [], + "supportedOptions": {} + }, + { + "mode": "SteamClean", + "supportedOperations": [], + "supportedOptions": {} + } + ] + }, + "timestamp": "2024-05-14T19:00:30.062Z" + } + }, + "custom.cooktopOperatingState": { + "supportedCooktopOperatingState": { + "value": ["run", "ready"], + "timestamp": "2022-02-21T22:37:05.293Z" + }, + "cooktopOperatingState": { + "value": "ready", + "timestamp": "2025-03-12T20:38:01.402Z" + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": [], + "timestamp": "2025-03-13T21:23:27.659Z" + } + }, + "samsungce.driverVersion": { + "versionNumber": { + "value": 22100101, + "timestamp": "2022-11-01T21:37:51.304Z" + } + }, + "samsungce.softwareUpdate": { + "targetModule": { + "value": null + }, + "otnDUID": { + "value": "ZPCNQWBWXI47Q", + "timestamp": "2025-03-12T20:38:01.262Z" + }, + "lastUpdatedDate": { + "value": null + }, + "availableModules": { + "value": [], + "timestamp": "2025-03-12T20:38:01.262Z" + }, + "newVersionAvailable": { + "value": false, + "timestamp": "2025-03-12T20:38:01.262Z" + }, + "operatingState": { + "value": null + }, + "progress": { + "value": null + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": 425, + "unit": "F", + "timestamp": "2025-03-13T21:46:35.545Z" + } + }, + "samsungce.ovenOperatingState": { + "completionTime": { + "value": "2025-03-14T03:23:28.048Z", + "timestamp": "2025-03-13T22:09:29.052Z" + }, + "operatingState": { + "value": "running", + "timestamp": "2025-03-13T21:23:24.771Z" + }, + "progress": { + "value": 13, + "timestamp": "2025-03-13T22:06:35.591Z" + }, + "ovenJobState": { + "value": "cooking", + "timestamp": "2025-03-13T21:46:34.327Z" + }, + "operationTime": { + "value": "06:00:00", + "timestamp": "2025-03-13T21:23:24.771Z" + } + }, + "ovenMode": { + "supportedOvenModes": { + "value": [ + "Bake", + "Broil", + "ConvectionBake", + "ConvectionRoast", + "warming", + "Others", + "Dehydrate" + ], + "timestamp": "2025-03-12T20:38:01.259Z" + }, + "ovenMode": { + "value": "Bake", + "timestamp": "2025-03-13T21:23:27.659Z" + } + }, + "ovenOperatingState": { + "completionTime": { + "value": "2025-03-14T03:23:28.048Z", + "timestamp": "2025-03-13T22:09:29.052Z" + }, + "machineState": { + "value": "running", + "timestamp": "2025-03-13T21:23:24.771Z" + }, + "progress": { + "value": 13, + "unit": "%", + "timestamp": "2025-03-13T22:06:35.591Z" + }, + "supportedMachineStates": { + "value": null + }, + "ovenJobState": { + "value": "cooking", + "timestamp": "2025-03-13T21:46:34.327Z" + }, + "operationTime": { + "value": 21600, + "timestamp": "2025-03-13T21:23:24.771Z" + } + }, + "samsungce.ovenMode": { + "supportedOvenModes": { + "value": [ + "Bake", + "Broil", + "ConvectionBake", + "ConvectionRoast", + "KeepWarm", + "BreadProof", + "AirFryer", + "Dehydrate", + "SelfClean", + "SteamClean" + ], + "timestamp": "2025-03-12T20:38:01.259Z" + }, + "ovenMode": { + "value": "Bake", + "timestamp": "2025-03-13T21:23:27.659Z" + } + }, + "samsungce.lamp": { + "brightnessLevel": { + "value": "off", + "timestamp": "2025-03-13T21:23:27.659Z" + }, + "supportedBrightnessLevel": { + "value": ["off", "high"], + "timestamp": "2025-03-13T21:23:27.659Z" + } + }, + "samsungce.kidsLock": { + "lockState": { + "value": "unlocked", + "timestamp": "2025-03-12T20:38:01.400Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/devices/da_ks_range_0101x.json b/tests/components/smartthings/fixtures/devices/da_ks_range_0101x.json new file mode 100644 index 00000000000..e918e2d77ca --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/da_ks_range_0101x.json @@ -0,0 +1,197 @@ +{ + "items": [ + { + "deviceId": "2c3cbaa0-1899-5ddc-7b58-9d657bd48f18", + "name": "Samsung Range", + "label": "Vulcan", + "manufacturerName": "Samsung Electronics", + "presentationId": "DA-KS-RANGE-0101X", + "deviceManufacturerCode": "Samsung Electronics", + "locationId": "597a4912-13c9-47ab-9956-7ebc38b61abd", + "ownerId": "c4478c70-9014-e5c9-993c-f62707fa1e61", + "roomId": "fc407cd9-3b32-4fc0-bf23-e0d4995101e9", + "deviceTypeName": "Samsung OCF Range", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "ocf", + "version": 1 + }, + { + "id": "execute", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "remoteControlStatus", + "version": 1 + }, + { + "id": "ovenSetpoint", + "version": 1 + }, + { + "id": "ovenMode", + "version": 1 + }, + { + "id": "ovenOperatingState", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "samsungce.deviceIdentification", + "version": 1 + }, + { + "id": "samsungce.driverVersion", + "version": 1 + }, + { + "id": "samsungce.kitchenDeviceIdentification", + "version": 1 + }, + { + "id": "samsungce.kitchenDeviceDefaults", + "version": 1 + }, + { + "id": "samsungce.doorState", + "version": 1 + }, + { + "id": "samsungce.customRecipe", + "version": 1 + }, + { + "id": "samsungce.ovenMode", + "version": 1 + }, + { + "id": "samsungce.ovenOperatingState", + "version": 1 + }, + { + "id": "samsungce.meatProbe", + "version": 1 + }, + { + "id": "samsungce.lamp", + "version": 1 + }, + { + "id": "samsungce.kitchenModeSpecification", + "version": 1 + }, + { + "id": "samsungce.kidsLock", + "version": 1 + }, + { + "id": "samsungce.softwareUpdate", + "version": 1 + }, + { + "id": "custom.cooktopOperatingState", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + } + ], + "categories": [ + { + "name": "Range", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "cavity-01", + "label": "cavity-01", + "capabilities": [ + { + "id": "ovenSetpoint", + "version": 1 + }, + { + "id": "ovenMode", + "version": 1 + }, + { + "id": "ovenOperatingState", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "samsungce.ovenMode", + "version": 1 + }, + { + "id": "samsungce.ovenOperatingState", + "version": 1 + }, + { + "id": "samsungce.kitchenDeviceDefaults", + "version": 1 + }, + { + "id": "custom.ovenCavityStatus", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2022-02-21T22:37:01.648Z", + "profile": { + "id": "8e479dd0-9719-337a-9fbe-2c4572f95c71" + }, + "ocf": { + "ocfDeviceType": "oic.d.range", + "name": "Samsung Range", + "specVersion": "core.1.1.0", + "verticalDomainSpecVersion": "1.2.1", + "manufacturerName": "Samsung Electronics", + "modelNumber": "TP1X_DA-KS-RANGE-0101X|40445041|5001011E031511010200000000000000", + "platformVersion": "DAWIT 3.0", + "platformOS": "TizenRT 3.1", + "hwVersion": "Realtek", + "firmwareVersion": "AKS-WW-TP1-20-OVEN-3-CR_40240205", + "vendorId": "DA-KS-RANGE-0101X", + "vendorResourceClientServerVersion": "Realtek Release 3.1.220727", + "lastSignupTime": "2023-11-28T22:49:01.876575Z", + "transferCandidate": false, + "additionalAuthCodeRequired": false + }, + "type": "OCF", + "restrictionTier": 0, + "allowed": null, + "executionContext": "CLOUD", + "relationships": [] + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/snapshots/test_init.ambr b/tests/components/smartthings/snapshots/test_init.ambr index 849dfea6a68..ab71164ddef 100644 --- a/tests/components/smartthings/snapshots/test_init.ambr +++ b/tests/components/smartthings/snapshots/test_init.ambr @@ -398,6 +398,39 @@ 'via_device_id': None, }) # --- +# name: test_devices[da_ks_range_0101x] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': 'Realtek', + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '2c3cbaa0-1899-5ddc-7b58-9d657bd48f18', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Samsung Electronics', + 'model': 'TP1X_DA-KS-RANGE-0101X', + 'model_id': None, + 'name': 'Vulcan', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': 'AKS-WW-TP1-20-OVEN-3-CR_40240205', + 'via_device_id': None, + }) +# --- # name: test_devices[da_ref_normal_000001] DeviceRegistryEntrySnapshot({ 'area_id': None, diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index 4de3541ee23..98e619596fd 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -2007,7 +2007,7 @@ 'name': None, 'options': dict({ }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Set point', 'platform': 'smartthings', @@ -2015,20 +2015,22 @@ 'supported_features': 0, 'translation_key': 'oven_setpoint', 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a.ovenSetpoint', - 'unit_of_measurement': None, + 'unit_of_measurement': , }) # --- # name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_set_point-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', 'friendly_name': 'Microwave Set point', + 'unit_of_measurement': , }), 'context': , 'entity_id': 'sensor.microwave_set_point', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0', + 'state': 'unknown', }) # --- # name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_temperature-entry] @@ -2083,6 +2085,404 @@ 'state': '-17', }) # --- +# name: test_all_entities[da_ks_range_0101x][sensor.vulcan_completion_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.vulcan_completion_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Completion time', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'completion_time', + 'unique_id': '2c3cbaa0-1899-5ddc-7b58-9d657bd48f18.completionTime', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_range_0101x][sensor.vulcan_completion_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Vulcan Completion time', + }), + 'context': , + 'entity_id': 'sensor.vulcan_completion_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2025-03-14T03:23:28+00:00', + }) +# --- +# name: test_all_entities[da_ks_range_0101x][sensor.vulcan_job_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'cleaning', + 'cooking', + 'cooling', + 'draining', + 'preheat', + 'ready', + 'rinsing', + 'finished', + 'scheduled_start', + 'warming', + 'defrosting', + 'sensing', + 'searing', + 'fast_preheat', + 'scheduled_end', + 'stone_heating', + 'time_hold_preheat', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.vulcan_job_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Job state', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'oven_job_state', + 'unique_id': '2c3cbaa0-1899-5ddc-7b58-9d657bd48f18.ovenJobState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_range_0101x][sensor.vulcan_job_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Vulcan Job state', + 'options': list([ + 'cleaning', + 'cooking', + 'cooling', + 'draining', + 'preheat', + 'ready', + 'rinsing', + 'finished', + 'scheduled_start', + 'warming', + 'defrosting', + 'sensing', + 'searing', + 'fast_preheat', + 'scheduled_end', + 'stone_heating', + 'time_hold_preheat', + ]), + }), + 'context': , + 'entity_id': 'sensor.vulcan_job_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'cooking', + }) +# --- +# name: test_all_entities[da_ks_range_0101x][sensor.vulcan_machine_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'ready', + 'running', + 'paused', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.vulcan_machine_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Machine state', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'oven_machine_state', + 'unique_id': '2c3cbaa0-1899-5ddc-7b58-9d657bd48f18.machineState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_range_0101x][sensor.vulcan_machine_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Vulcan Machine state', + 'options': list([ + 'ready', + 'running', + 'paused', + ]), + }), + 'context': , + 'entity_id': 'sensor.vulcan_machine_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'running', + }) +# --- +# name: test_all_entities[da_ks_range_0101x][sensor.vulcan_oven_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'conventional', + 'bake', + 'bottom_heat', + 'convection_bake', + 'convection_roast', + 'broil', + 'convection_broil', + 'steam_cook', + 'steam_bake', + 'steam_roast', + 'steam_bottom_heat_plus_convection', + 'microwave', + 'microwave_plus_grill', + 'microwave_plus_convection', + 'microwave_plus_hot_blast', + 'microwave_plus_hot_blast_2', + 'slim_middle', + 'slim_strong', + 'slow_cook', + 'proof', + 'dehydrate', + 'others', + 'strong_steam', + 'descale', + 'rinse', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.vulcan_oven_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Oven mode', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'oven_mode', + 'unique_id': '2c3cbaa0-1899-5ddc-7b58-9d657bd48f18.ovenMode', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_range_0101x][sensor.vulcan_oven_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Vulcan Oven mode', + 'options': list([ + 'conventional', + 'bake', + 'bottom_heat', + 'convection_bake', + 'convection_roast', + 'broil', + 'convection_broil', + 'steam_cook', + 'steam_bake', + 'steam_roast', + 'steam_bottom_heat_plus_convection', + 'microwave', + 'microwave_plus_grill', + 'microwave_plus_convection', + 'microwave_plus_hot_blast', + 'microwave_plus_hot_blast_2', + 'slim_middle', + 'slim_strong', + 'slow_cook', + 'proof', + 'dehydrate', + 'others', + 'strong_steam', + 'descale', + 'rinse', + ]), + }), + 'context': , + 'entity_id': 'sensor.vulcan_oven_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'bake', + }) +# --- +# name: test_all_entities[da_ks_range_0101x][sensor.vulcan_set_point-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.vulcan_set_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': 'Set point', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'oven_setpoint', + 'unique_id': '2c3cbaa0-1899-5ddc-7b58-9d657bd48f18.ovenSetpoint', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ks_range_0101x][sensor.vulcan_set_point-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Vulcan Set point', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.vulcan_set_point', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '218', + }) +# --- +# name: test_all_entities[da_ks_range_0101x][sensor.vulcan_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.vulcan_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': 'Temperature', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '2c3cbaa0-1899-5ddc-7b58-9d657bd48f18.temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ks_range_0101x][sensor.vulcan_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Vulcan Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.vulcan_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '218', + }) +# --- # name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_energy-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 1566ab3b28b6fac8974869ed85f670b61ebdef9b Mon Sep 17 00:00:00 2001 From: ashionky <35916938+ashionky@users.noreply.github.com> Date: Fri, 14 Mar 2025 17:19:43 +0800 Subject: [PATCH 2601/3148] Fix missing UnitOfPower.MILLIWATT in sensor and number allowed units (#140567) * MILLIWATT * MILLIWATT --- homeassistant/components/number/const.py | 1 + homeassistant/components/sensor/const.py | 1 + 2 files changed, 2 insertions(+) diff --git a/homeassistant/components/number/const.py b/homeassistant/components/number/const.py index 61a4fa644b0..07c849278d4 100644 --- a/homeassistant/components/number/const.py +++ b/homeassistant/components/number/const.py @@ -486,6 +486,7 @@ DEVICE_CLASS_UNITS: dict[NumberDeviceClass, set[type[StrEnum] | str | None]] = { NumberDeviceClass.PM25: {CONCENTRATION_MICROGRAMS_PER_CUBIC_METER}, NumberDeviceClass.POWER_FACTOR: {PERCENTAGE, None}, NumberDeviceClass.POWER: { + UnitOfPower.MILLIWATT, UnitOfPower.WATT, UnitOfPower.KILO_WATT, UnitOfPower.MEGA_WATT, diff --git a/homeassistant/components/sensor/const.py b/homeassistant/components/sensor/const.py index 8eccb758756..1edb87f4bce 100644 --- a/homeassistant/components/sensor/const.py +++ b/homeassistant/components/sensor/const.py @@ -582,6 +582,7 @@ DEVICE_CLASS_UNITS: dict[SensorDeviceClass, set[type[StrEnum] | str | None]] = { SensorDeviceClass.PM25: {CONCENTRATION_MICROGRAMS_PER_CUBIC_METER}, SensorDeviceClass.POWER_FACTOR: {PERCENTAGE, None}, SensorDeviceClass.POWER: { + UnitOfPower.MILLIWATT, UnitOfPower.WATT, UnitOfPower.KILO_WATT, UnitOfPower.MEGA_WATT, From 831f2dc30ea48bcfda87816d0deca3188488f929 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 14 Mar 2025 09:56:13 +0000 Subject: [PATCH 2602/3148] Bump version to 2025.3.3 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 6ff91029072..ce3c8225dfb 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -25,7 +25,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2025 MINOR_VERSION: Final = 3 -PATCH_VERSION: Final = "2" +PATCH_VERSION: Final = "3" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 13, 0) diff --git a/pyproject.toml b/pyproject.toml index b65046713db..a471379e28e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.3.2" +version = "2025.3.3" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 5ea7c113b0b33ecb0784550f337179fedd34b741 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Fri, 14 Mar 2025 11:15:38 +0100 Subject: [PATCH 2603/3148] Use test snapshots for Shelly climate (#140582) --- tests/components/shelly/conftest.py | 1 + .../shelly/snapshots/test_climate.ambr | 276 ++++++++++++++++++ tests/components/shelly/test_climate.py | 32 +- 3 files changed, 295 insertions(+), 14 deletions(-) create mode 100644 tests/components/shelly/snapshots/test_climate.ambr diff --git a/tests/components/shelly/conftest.py b/tests/components/shelly/conftest.py index 0063c5c2697..8ea04ea3bfb 100644 --- a/tests/components/shelly/conftest.py +++ b/tests/components/shelly/conftest.py @@ -502,6 +502,7 @@ def _mock_blu_rtv_device(version: str | None = None): firmware_version="some fw string", initialized=True, connected=True, + xmod_info={}, ) type(device).name = PropertyMock(return_value="Test name") return device diff --git a/tests/components/shelly/snapshots/test_climate.ambr b/tests/components/shelly/snapshots/test_climate.ambr new file mode 100644 index 00000000000..991c570172e --- /dev/null +++ b/tests/components/shelly/snapshots/test_climate.ambr @@ -0,0 +1,276 @@ +# serializer version: 1 +# name: test_blu_trv_climate_set_temperature[climate.trv_name-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + ]), + 'max_temp': 30, + 'min_temp': 4, + 'target_temp_step': 0.1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.trv_name', + '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': None, + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'f8:44:77:25:f0:dd-blutrv:200', + 'unit_of_measurement': None, + }) +# --- +# name: test_blu_trv_climate_set_temperature[climate.trv_name-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 15.2, + 'friendly_name': 'TRV-Name', + 'hvac_action': , + 'hvac_modes': list([ + , + ]), + 'max_temp': 30, + 'min_temp': 4, + 'supported_features': , + 'target_temp_step': 0.1, + 'temperature': 17.1, + }), + 'context': , + 'entity_id': 'climate.trv_name', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }) +# --- +# name: test_climate_hvac_mode[climate.test_name-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 31, + 'min_temp': 4, + 'preset_modes': list([ + 'none', + 'Profile1', + 'Profile2', + ]), + 'target_temp_step': 0.5, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.test_name', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Test name', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '123456789ABC-sensor_0', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate_hvac_mode[climate.test_name-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 22.1, + 'friendly_name': 'Test name', + 'hvac_action': , + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 31, + 'min_temp': 4, + 'preset_mode': 'none', + 'preset_modes': list([ + 'none', + 'Profile1', + 'Profile2', + ]), + 'supported_features': , + 'target_temp_step': 0.5, + 'temperature': 4, + }), + 'context': , + 'entity_id': 'climate.test_name', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_rpc_climate_hvac_mode[climate.test_name_thermostat_0-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 35, + 'min_temp': 5, + 'target_temp_step': 0.5, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.test_name_thermostat_0', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Test name Thermostat 0', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '123456789ABC-thermostat:0', + 'unit_of_measurement': None, + }) +# --- +# name: test_rpc_climate_hvac_mode[climate.test_name_thermostat_0-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_humidity': 44.4, + 'current_temperature': 12.3, + 'friendly_name': 'Test name Thermostat 0', + 'hvac_action': , + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 35, + 'min_temp': 5, + 'supported_features': , + 'target_temp_step': 0.5, + 'temperature': 23, + }), + 'context': , + 'entity_id': 'climate.test_name_thermostat_0', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }) +# --- +# name: test_wall_display_thermostat_mode[climate.test_name_thermostat_0-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 35, + 'min_temp': 5, + 'target_temp_step': 0.5, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.test_name_thermostat_0', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Test name Thermostat 0', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '123456789ABC-thermostat:0', + 'unit_of_measurement': None, + }) +# --- +# name: test_wall_display_thermostat_mode[climate.test_name_thermostat_0-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_humidity': 44.4, + 'current_temperature': 12.3, + 'friendly_name': 'Test name Thermostat 0', + 'hvac_action': , + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 35, + 'min_temp': 5, + 'supported_features': , + 'target_temp_step': 0.5, + 'temperature': 23, + }), + 'context': , + 'entity_id': 'climate.test_name_thermostat_0', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }) +# --- diff --git a/tests/components/shelly/test_climate.py b/tests/components/shelly/test_climate.py index c78e87ebfce..fcfed090a66 100644 --- a/tests/components/shelly/test_climate.py +++ b/tests/components/shelly/test_climate.py @@ -11,6 +11,7 @@ from aioshelly.const import ( ) from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError import pytest +from syrupy import SnapshotAssertion from homeassistant.components.climate import ( ATTR_CURRENT_HUMIDITY, @@ -65,6 +66,7 @@ async def test_climate_hvac_mode( mock_block_device: Mock, monkeypatch: pytest.MonkeyPatch, entity_registry: EntityRegistry, + snapshot: SnapshotAssertion, ) -> None: """Test climate hvac mode service.""" monkeypatch.delattr(mock_block_device.blocks[DEVICE_BLOCK_ID], "targetTemp") @@ -84,11 +86,10 @@ async def test_climate_hvac_mode( # Test initial hvac mode - off state = hass.states.get(ENTITY_ID) - assert state.state == HVACMode.OFF + assert state == snapshot(name=f"{ENTITY_ID}-state") entry = entity_registry.async_get(ENTITY_ID) - assert entry - assert entry.unique_id == "123456789ABC-sensor_0" + assert entry == snapshot(name=f"{ENTITY_ID}-entry") # Test set hvac mode heat await hass.services.async_call( @@ -603,6 +604,7 @@ async def test_rpc_climate_hvac_mode( entity_registry: EntityRegistry, mock_rpc_device: Mock, monkeypatch: pytest.MonkeyPatch, + snapshot: SnapshotAssertion, ) -> None: """Test climate hvac mode service.""" entity_id = "climate.test_name_thermostat_0" @@ -610,15 +612,10 @@ async def test_rpc_climate_hvac_mode( await init_integration(hass, 2, model=MODEL_WALL_DISPLAY) state = hass.states.get(entity_id) - assert state.state == HVACMode.HEAT - assert state.attributes[ATTR_TEMPERATURE] == 23 - assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 12.3 - assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.HEATING - assert state.attributes[ATTR_CURRENT_HUMIDITY] == 44.4 + assert state == snapshot(name=f"{entity_id}-state") entry = entity_registry.async_get(entity_id) - assert entry - assert entry.unique_id == "123456789ABC-thermostat:0" + assert entry == snapshot(name=f"{entity_id}-entry") monkeypatch.setitem(mock_rpc_device.status["thermostat:0"], "output", False) mock_rpc_device.mock_update() @@ -717,6 +714,7 @@ async def test_wall_display_thermostat_mode( mock_rpc_device: Mock, entity_registry: EntityRegistry, monkeypatch: pytest.MonkeyPatch, + snapshot: SnapshotAssertion, ) -> None: """Test Wall Display in thermostat mode.""" climate_entity_id = "climate.test_name_thermostat_0" @@ -730,13 +728,11 @@ async def test_wall_display_thermostat_mode( # the climate entity should be created state = hass.states.get(climate_entity_id) - assert state - assert state.state == HVACMode.HEAT + assert state == snapshot(name=f"{climate_entity_id}-state") assert len(hass.states.async_entity_ids(CLIMATE_DOMAIN)) == 1 entry = entity_registry.async_get(climate_entity_id) - assert entry - assert entry.unique_id == "123456789ABC-thermostat:0" + assert entry == snapshot(name=f"{climate_entity_id}-entry") async def test_wall_display_thermostat_mode_external_actuator( @@ -776,7 +772,9 @@ async def test_wall_display_thermostat_mode_external_actuator( async def test_blu_trv_climate_set_temperature( hass: HomeAssistant, mock_blu_trv: Mock, + entity_registry: EntityRegistry, monkeypatch: pytest.MonkeyPatch, + snapshot: SnapshotAssertion, ) -> None: """Test BLU TRV set target temperature.""" @@ -785,6 +783,12 @@ async def test_blu_trv_climate_set_temperature( await init_integration(hass, 3, model=MODEL_BLU_GATEWAY_G3) + state = hass.states.get(entity_id) + assert state == snapshot(name=f"{entity_id}-state") + + entry = entity_registry.async_get(entity_id) + assert entry == snapshot(name=f"{entity_id}-entry") + assert get_entity_attribute(hass, entity_id, ATTR_TEMPERATURE) == 17.1 monkeypatch.setitem( From ae8709be21f89375869cd0728e0f1b5f68b17f3d Mon Sep 17 00:00:00 2001 From: Petar Petrov Date: Fri, 14 Mar 2025 13:19:49 +0200 Subject: [PATCH 2604/3148] Expose ZWaveJS`supports_long_range` to the frontend (#140489) * Expose ZWaveJS`supports_long_range` to the frontend * update test --- homeassistant/components/zwave_js/api.py | 1 + tests/components/zwave_js/fixtures/controller_state.json | 1 + tests/components/zwave_js/test_api.py | 1 + 3 files changed, 3 insertions(+) diff --git a/homeassistant/components/zwave_js/api.py b/homeassistant/components/zwave_js/api.py index aef23cb73ea..cc47339a6a6 100644 --- a/homeassistant/components/zwave_js/api.py +++ b/homeassistant/components/zwave_js/api.py @@ -518,6 +518,7 @@ async def websocket_network_status( "supported_function_types": controller.supported_function_types, "suc_node_id": controller.suc_node_id, "supports_timers": controller.supports_timers, + "supports_long_range": controller.supports_long_range, "is_rebuilding_routes": controller.is_rebuilding_routes, "inclusion_state": controller.inclusion_state, "rf_region": controller.rf_region, diff --git a/tests/components/zwave_js/fixtures/controller_state.json b/tests/components/zwave_js/fixtures/controller_state.json index d6d9dcacd9e..c3b9de4bdec 100644 --- a/tests/components/zwave_js/fixtures/controller_state.json +++ b/tests/components/zwave_js/fixtures/controller_state.json @@ -23,6 +23,7 @@ ], "sucNodeId": 1, "supportsTimers": false, + "supportsLongRange": true, "isHealNetworkActive": false, "inclusionState": 0, "status": 0 diff --git a/tests/components/zwave_js/test_api.py b/tests/components/zwave_js/test_api.py index 42c5d59d7ad..dcb8c8dafe4 100644 --- a/tests/components/zwave_js/test_api.py +++ b/tests/components/zwave_js/test_api.py @@ -168,6 +168,7 @@ async def test_network_status( assert result["client"]["server_version"] == "1.0.0" assert not result["client"]["server_logging_enabled"] assert result["controller"]["inclusion_state"] == InclusionState.IDLE + assert result["controller"]["supports_long_range"] # Try API call with device ID device = device_registry.async_get_device( From dcc63a6f2e495f92d703698203c5a495dc4378ac Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 14 Mar 2025 13:32:50 +0100 Subject: [PATCH 2605/3148] Bump ruff to 0.10.0 (#140541) * Bump ruff to 0.10.0 * Bump ruff to 0.10.0 * Bump ruff to 0.10.0 * Bump ruff to 0.10.0 * Update pyproject.toml Co-authored-by: Marc Mueller <30130371+cdce8p@users.noreply.github.com> * Fix --------- Co-authored-by: Marc Mueller <30130371+cdce8p@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- homeassistant/helpers/deprecation.py | 2 +- homeassistant/helpers/entity.py | 2 +- homeassistant/util/frozen_dataclass_compat.py | 2 +- pyproject.toml | 9 ++++++--- requirements_test_pre_commit.txt | 2 +- script/hassfest/docker/Dockerfile | 2 +- tests/components/conversation/test_chat_log.py | 4 ++-- tests/components/tts/test_init.py | 2 +- tests/components/wyoming/test_tts.py | 2 +- 10 files changed, 16 insertions(+), 13 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index cf6fe7030e9..1af73b2b5e0 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.9.10 + rev: v0.10.0 hooks: - id: ruff args: diff --git a/homeassistant/helpers/deprecation.py b/homeassistant/helpers/deprecation.py index 375ec58c26f..101b9731caf 100644 --- a/homeassistant/helpers/deprecation.py +++ b/homeassistant/helpers/deprecation.py @@ -369,7 +369,7 @@ class EnumWithDeprecatedMembers(EnumType): """Enum with deprecated members.""" def __new__( - mcs, # noqa: N804 ruff bug, ruff does not understand this is a metaclass + mcs, cls: str, bases: tuple[type, ...], classdict: _EnumDict, diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index bed5ce586c5..bdcda58c054 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -281,7 +281,7 @@ class CachedProperties(type): """ def __new__( - mcs, # noqa: N804 ruff bug, ruff does not understand this is a metaclass + mcs, name: str, bases: tuple[type, ...], namespace: dict[Any, Any], diff --git a/homeassistant/util/frozen_dataclass_compat.py b/homeassistant/util/frozen_dataclass_compat.py index 81ce9961a0b..518515d4f85 100644 --- a/homeassistant/util/frozen_dataclass_compat.py +++ b/homeassistant/util/frozen_dataclass_compat.py @@ -63,7 +63,7 @@ class FrozenOrThawed(type): ) def __new__( - mcs, # noqa: N804 ruff bug, ruff does not understand this is a metaclass + mcs, name: str, bases: tuple[type, ...], namespace: dict[Any, Any], diff --git a/pyproject.toml b/pyproject.toml index 8e3fe4e25a7..bcc657528a3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -700,7 +700,7 @@ exclude_lines = [ ] [tool.ruff] -required-version = ">=0.9.1" +required-version = ">=0.10.0" [tool.ruff.lint] select = [ @@ -784,7 +784,6 @@ select = [ "S317", # suspicious-xml-sax-usage "S318", # suspicious-xml-mini-dom-usage "S319", # suspicious-xml-pull-dom-usage - "S320", # suspicious-xmle-tree-usage "S601", # paramiko-call "S602", # subprocess-popen-with-shell-equals-true "S604", # call-with-shell-equals-true @@ -836,6 +835,8 @@ ignore = [ "TC001", # Move application import {} into a type-checking block "TC002", # Move third-party import {} into a type-checking block "TC003", # Move standard library import {} into a type-checking block + # Quotes for typing.cast generally not necessary, only for performance critical paths + "TC006", # Add quotes to type expression in typing.cast() "TRY003", # Avoid specifying long messages outside the exception class "TRY400", # Use `logging.exception` instead of `logging.error` @@ -854,7 +855,9 @@ ignore = [ "COM819", # Disabled because ruff does not understand type of __all__ generated by a function - "PLE0605" + "PLE0605", + + "PLC1802", # disabled temporarily on ruff 0.10.0 update ] [tool.ruff.lint.flake8-import-conventions.extend-aliases] diff --git a/requirements_test_pre_commit.txt b/requirements_test_pre_commit.txt index 1cf9ef3fcf5..a6ce0d38cb1 100644 --- a/requirements_test_pre_commit.txt +++ b/requirements_test_pre_commit.txt @@ -1,5 +1,5 @@ # Automatically generated from .pre-commit-config.yaml by gen_requirements_all.py, do not edit codespell==2.4.1 -ruff==0.9.10 +ruff==0.10.0 yamllint==1.35.1 diff --git a/script/hassfest/docker/Dockerfile b/script/hassfest/docker/Dockerfile index e4e0c751d78..a9201bff6ce 100644 --- a/script/hassfest/docker/Dockerfile +++ b/script/hassfest/docker/Dockerfile @@ -24,7 +24,7 @@ RUN --mount=from=ghcr.io/astral-sh/uv:0.6.1,source=/uv,target=/bin/uv \ --no-cache \ -c /usr/src/homeassistant/homeassistant/package_constraints.txt \ -r /usr/src/homeassistant/requirements.txt \ - stdlib-list==0.10.0 pipdeptree==2.25.1 tqdm==4.67.1 ruff==0.9.10 \ + stdlib-list==0.10.0 pipdeptree==2.25.1 tqdm==4.67.1 ruff==0.10.0 \ PyTurboJPEG==1.7.5 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 hassil==2.2.3 home-assistant-intents==2025.3.5 mutagen==1.47.0 pymicro-vad==1.0.1 pyspeex-noise==1.0.2 LABEL "name"="hassfest" diff --git a/tests/components/conversation/test_chat_log.py b/tests/components/conversation/test_chat_log.py index 97094740af0..d7b3531c658 100644 --- a/tests/components/conversation/test_chat_log.py +++ b/tests/components/conversation/test_chat_log.py @@ -591,7 +591,7 @@ async def test_add_delta_content_stream_errors( async_get_chat_log(hass, session, mock_conversation_input) as chat_log, ): # Stream content without LLM API set - with pytest.raises(ValueError): # noqa: PT012 + with pytest.raises(ValueError): async for _tool_result_content in chat_log.async_add_delta_content_stream( "mock-agent-id", stream( @@ -613,7 +613,7 @@ async def test_add_delta_content_stream_errors( # Non assistant role for role in "system", "user": - with pytest.raises(ValueError): # noqa: PT012 + with pytest.raises(ValueError): async for ( _tool_result_content ) in chat_log.async_add_delta_content_stream( diff --git a/tests/components/tts/test_init.py b/tests/components/tts/test_init.py index be14e006610..4e17bc68a5e 100644 --- a/tests/components/tts/test_init.py +++ b/tests/components/tts/test_init.py @@ -1772,7 +1772,7 @@ async def test_async_convert_audio_error(hass: HomeAssistant) -> None: async def bad_data_gen(): yield bytes(0) - with pytest.raises(RuntimeError): # noqa: PT012 + with pytest.raises(RuntimeError): # Simulate a bad WAV file async for _chunk in tts._async_convert_audio( hass, "wav", bad_data_gen(), "mp3" diff --git a/tests/components/wyoming/test_tts.py b/tests/components/wyoming/test_tts.py index 73fb68b44e5..6e0edc022c0 100644 --- a/tests/components/wyoming/test_tts.py +++ b/tests/components/wyoming/test_tts.py @@ -156,7 +156,7 @@ async def test_get_tts_audio_connection_lost( MockAsyncTcpClient([None]), ): stream.async_set_message("Hello world") - with pytest.raises(HomeAssistantError): # noqa: PT012 + with pytest.raises(HomeAssistantError): async for _chunk in stream.async_stream_result(): pass From bd4d0ec4b84b1867493cb0d4d49d31ef0adc3a6a Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Fri, 14 Mar 2025 14:00:07 +0100 Subject: [PATCH 2606/3148] Add initial MQTT subentry support for notify entities (#138461) * Add initial MQTT subentry support for notify entities * Fix componts assigment is reset on device config. Translation tweaks * Rephrase * Go to summary menu when components are set up already - add test * Fix suggested device info on config flow * Invert * Simplify subentry config flow and omit menu * Use constants instead of literals * More constants * Teak some translations * Only show save when the the entry is dirty * Do not trigger an entry reload twice * Remove encoding, entity_category * Remove icon from mqtt subentry flow * Separate entity settings and MQTT specific settings * Remove object_id and refactor * Migrate translations * Make subconfig flow test extensible * Make sub reconfig flow tests extensible * Rename entity_platform_config step to mqtt_platform_config * Make component unique ID independent from the name * Move code for update of component data to helper * Follow up on code review * Skip dirty stuff * Fix rebase issues #1 * Do not allow reconfig for entity platform/name, default QoS and refactor tests * Add entity platform and entity name label to basic entity config dialog * Rename to exclude_from_reconfig and make reconfig option not optional --- homeassistant/components/mqtt/__init__.py | 25 +- homeassistant/components/mqtt/config_flow.py | 439 +++++++++++- homeassistant/components/mqtt/entity.py | 45 +- homeassistant/components/mqtt/models.py | 19 + homeassistant/components/mqtt/strings.json | 109 +++ tests/components/mqtt/common.py | 112 +++ tests/components/mqtt/test_config_flow.py | 712 +++++++++++++++++++ tests/components/mqtt/test_mixins.py | 82 ++- tests/conftest.py | 23 +- 9 files changed, 1544 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index 6656afe2c8a..ae010bf18c9 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -13,7 +13,7 @@ import voluptuous as vol from homeassistant import config as conf_util from homeassistant.components import websocket_api from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_DISCOVERY, SERVICE_RELOAD +from homeassistant.const import CONF_DISCOVERY, CONF_PLATFORM, SERVICE_RELOAD from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import ( ConfigValidationError, @@ -81,6 +81,7 @@ from .const import ( ENTRY_OPTION_FIELDS, MQTT_CONNECTION_STATE, TEMPLATE_ERRORS, + Platform, ) from .models import ( DATA_MQTT, @@ -293,6 +294,21 @@ async def async_check_config_schema( ) from exc +def _platforms_in_use(hass: HomeAssistant, entry: ConfigEntry) -> set[str | Platform]: + """Return a set of platforms in use.""" + domains: set[str | Platform] = { + entry.domain + for entry in er.async_entries_for_config_entry( + er.async_get(hass), entry.entry_id + ) + } + # Update with domains from subentries + for subentry in entry.subentries.values(): + components = subentry.data["components"].values() + domains.update(component[CONF_PLATFORM] for component in components) + return domains + + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the actions and websocket API for the MQTT component.""" @@ -434,12 +450,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: mqtt_data, conf = await _setup_client() platforms_used = platforms_from_config(mqtt_data.config) - platforms_used.update( - entry.domain - for entry in er.async_entries_for_config_entry( - er.async_get(hass), entry.entry_id - ) - ) + platforms_used.update(_platforms_in_use(hass, entry)) integration = async_get_loaded_integration(hass, DOMAIN) # Preload platforms we know we are going to use so # discovery can setup each platform synchronously diff --git a/homeassistant/components/mqtt/config_flow.py b/homeassistant/components/mqtt/config_flow.py index ad188c50aa9..8922b059a23 100644 --- a/homeassistant/components/mqtt/config_flow.py +++ b/homeassistant/components/mqtt/config_flow.py @@ -5,12 +5,15 @@ from __future__ import annotations import asyncio from collections import OrderedDict from collections.abc import Callable, Mapping +from copy import deepcopy +from dataclasses import dataclass from enum import IntEnum import logging import queue from ssl import PROTOCOL_TLS_CLIENT, SSLContext, SSLError from types import MappingProxyType -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, cast +from uuid import uuid4 from cryptography.hazmat.primitives.serialization import ( Encoding, @@ -29,21 +32,32 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, + ConfigSubentryFlow, OptionsFlow, + SubentryFlowResult, ) from homeassistant.const import ( + ATTR_CONFIGURATION_URL, + ATTR_HW_VERSION, + ATTR_MODEL, + ATTR_MODEL_ID, + ATTR_NAME, + ATTR_SW_VERSION, CONF_CLIENT_ID, + CONF_DEVICE, CONF_DISCOVERY, CONF_HOST, + CONF_NAME, CONF_PASSWORD, CONF_PAYLOAD, + CONF_PLATFORM, CONF_PORT, CONF_PROTOCOL, CONF_USERNAME, ) from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import AbortFlow -from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.hassio import is_hassio from homeassistant.helpers.json import json_dumps from homeassistant.helpers.selector import ( @@ -54,9 +68,12 @@ from homeassistant.helpers.selector import ( NumberSelectorConfig, NumberSelectorMode, SelectOptionDict, + Selector, SelectSelector, SelectSelectorConfig, SelectSelectorMode, + TemplateSelector, + TemplateSelectorConfig, TextSelector, TextSelectorConfig, TextSelectorType, @@ -76,8 +93,13 @@ from .const import ( CONF_CERTIFICATE, CONF_CLIENT_CERT, CONF_CLIENT_KEY, + CONF_COMMAND_TEMPLATE, + CONF_COMMAND_TOPIC, CONF_DISCOVERY_PREFIX, + CONF_ENTITY_PICTURE, CONF_KEEPALIVE, + CONF_QOS, + CONF_RETAIN, CONF_TLS_INSECURE, CONF_TRANSPORT, CONF_WILL_MESSAGE, @@ -99,12 +121,15 @@ from .const import ( SUPPORTED_PROTOCOLS, TRANSPORT_TCP, TRANSPORT_WEBSOCKETS, + Platform, ) +from .models import MqttDeviceData, MqttSubentryData from .util import ( async_create_certificate_temp_files, get_file_path, valid_birth_will, valid_publish_topic, + valid_qos_schema, ) _LOGGER = logging.getLogger(__name__) @@ -128,10 +153,10 @@ PORT_SELECTOR = vol.All( vol.Coerce(int), ) PASSWORD_SELECTOR = TextSelector(TextSelectorConfig(type=TextSelectorType.PASSWORD)) -QOS_SELECTOR = vol.All( - NumberSelector(NumberSelectorConfig(mode=NumberSelectorMode.BOX, min=0, max=2)), - vol.Coerce(int), +QOS_SELECTOR = NumberSelector( + NumberSelectorConfig(mode=NumberSelectorMode.BOX, min=0, max=2) ) +QOS_DATA_SCHEMA = vol.All(QOS_SELECTOR, valid_qos_schema) KEEPALIVE_SELECTOR = vol.All( NumberSelector( NumberSelectorConfig( @@ -183,6 +208,65 @@ KEY_UPLOAD_SELECTOR = FileSelector( FileSelectorConfig(accept=".pem,.key,.der,.pk8,application/pkcs8") ) +# Subentry selectors +SUBENTRY_PLATFORMS = [Platform.NOTIFY] +SUBENTRY_PLATFORM_SELECTOR = SelectSelector( + SelectSelectorConfig( + options=[platform.value for platform in SUBENTRY_PLATFORMS], + mode=SelectSelectorMode.DROPDOWN, + translation_key=CONF_PLATFORM, + ) +) + +TEMPLATE_SELECTOR = TemplateSelector(TemplateSelectorConfig()) + + +@dataclass(frozen=True) +class PlatformField: + """Stores a platform config field schema, required flag and validator.""" + + selector: Selector + required: bool + validator: Callable[..., Any] + error: str | None = None + default: str | int | vol.Undefined = vol.UNDEFINED + exclude_from_reconfig: bool = False + + +COMMON_ENTITY_FIELDS = { + CONF_PLATFORM: PlatformField( + SUBENTRY_PLATFORM_SELECTOR, True, str, exclude_from_reconfig=True + ), + CONF_NAME: PlatformField(TEXT_SELECTOR, False, str, exclude_from_reconfig=True), + CONF_ENTITY_PICTURE: PlatformField(TEXT_SELECTOR, False, cv.url, "invalid_url"), +} + +COMMON_MQTT_FIELDS = { + CONF_QOS: PlatformField(QOS_SELECTOR, False, valid_qos_schema, default=0), + CONF_RETAIN: PlatformField(BOOLEAN_SELECTOR, False, bool), +} +PLATFORM_MQTT_FIELDS = { + Platform.NOTIFY.value: { + CONF_COMMAND_TOPIC: PlatformField( + TEXT_SELECTOR, True, valid_publish_topic, "invalid_publish_topic" + ), + CONF_COMMAND_TEMPLATE: PlatformField( + TEMPLATE_SELECTOR, False, cv.template, "invalid_template" + ), + }, +} + +MQTT_DEVICE_SCHEMA = vol.Schema( + { + vol.Required(ATTR_NAME): TEXT_SELECTOR, + vol.Optional(ATTR_SW_VERSION): TEXT_SELECTOR, + vol.Optional(ATTR_HW_VERSION): TEXT_SELECTOR, + vol.Optional(ATTR_MODEL): TEXT_SELECTOR, + vol.Optional(ATTR_MODEL_ID): TEXT_SELECTOR, + vol.Optional(ATTR_CONFIGURATION_URL): TEXT_SELECTOR, + } +) + REAUTH_SCHEMA = vol.Schema( { vol.Required(CONF_USERNAME): TEXT_SELECTOR, @@ -215,6 +299,57 @@ def update_password_from_user_input( return substituted_used_data +@callback +def validate_field( + field: str, + validator: Callable[..., Any], + user_input: dict[str, Any] | None, + errors: dict[str, str], + error: str, +) -> None: + """Validate a single field.""" + if user_input is None or field not in user_input: + return + try: + validator(user_input[field]) + except (ValueError, vol.Invalid): + errors[field] = error + + +@callback +def validate_user_input( + user_input: dict[str, Any], + data_schema_fields: dict[str, PlatformField], + errors: dict[str, str], +) -> None: + """Validate user input.""" + for field, value in user_input.items(): + validator = data_schema_fields[field].validator + try: + validator(value) + except (ValueError, vol.Invalid): + errors[field] = data_schema_fields[field].error or "invalid_input" + + +@callback +def data_schema_from_fields( + data_schema_fields: dict[str, PlatformField], + reconfig: bool, +) -> vol.Schema: + """Generate data schema from platform fields.""" + return vol.Schema( + { + vol.Required(field_name, default=field_details.default) + if field_details.required + else vol.Optional( + field_name, default=field_details.default + ): field_details.selector + for field_name, field_details in data_schema_fields.items() + if not field_details.exclude_from_reconfig or not reconfig + } + ) + + class FlowHandler(ConfigFlow, domain=DOMAIN): """Handle a config flow.""" @@ -230,6 +365,14 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): self.install_task: asyncio.Task | None = None self.start_task: asyncio.Task | None = None + @classmethod + @callback + def async_get_supported_subentry_types( + cls, config_entry: ConfigEntry + ) -> dict[str, type[ConfigSubentryFlow]]: + """Return subentries supported by this handler.""" + return {CONF_DEVICE: MQTTSubentryFlowHandler} + @staticmethod @callback def async_get_options_flow( @@ -685,7 +828,7 @@ class MQTTOptionsFlowHandler(OptionsFlow): "birth_payload", description={"suggested_value": birth[CONF_PAYLOAD]} ) ] = TEXT_SELECTOR - fields[vol.Optional("birth_qos", default=birth[ATTR_QOS])] = QOS_SELECTOR + fields[vol.Optional("birth_qos", default=birth[ATTR_QOS])] = QOS_DATA_SCHEMA fields[vol.Optional("birth_retain", default=birth[ATTR_RETAIN])] = ( BOOLEAN_SELECTOR ) @@ -708,7 +851,7 @@ class MQTTOptionsFlowHandler(OptionsFlow): "will_payload", description={"suggested_value": will[CONF_PAYLOAD]} ) ] = TEXT_SELECTOR - fields[vol.Optional("will_qos", default=will[ATTR_QOS])] = QOS_SELECTOR + fields[vol.Optional("will_qos", default=will[ATTR_QOS])] = QOS_DATA_SCHEMA fields[vol.Optional("will_retain", default=will[ATTR_RETAIN])] = ( BOOLEAN_SELECTOR ) @@ -721,6 +864,288 @@ class MQTTOptionsFlowHandler(OptionsFlow): ) +class MQTTSubentryFlowHandler(ConfigSubentryFlow): + """Handle MQTT subentry flow.""" + + _subentry_data: MqttSubentryData + _component_id: str | None = None + + @callback + def update_component_fields( + self, data_schema: vol.Schema, user_input: dict[str, Any] + ) -> None: + """Update the componment fields.""" + if TYPE_CHECKING: + assert self._component_id is not None + component_data = self._subentry_data["components"][self._component_id] + # Remove the fields from the component data if they are not in the user input + for field in [ + form_field + for form_field in data_schema.schema + if form_field in component_data and form_field not in user_input + ]: + component_data.pop(field) + component_data.update(user_input) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Add a subentry.""" + self._subentry_data = MqttSubentryData(device=MqttDeviceData(), components={}) + return await self.async_step_device() + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Reconfigure a subentry.""" + reconfigure_subentry = self._get_reconfigure_subentry() + self._subentry_data = cast( + MqttSubentryData, deepcopy(dict(reconfigure_subentry.data)) + ) + return await self.async_step_summary_menu() + + async def async_step_device( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Add a new MQTT device.""" + errors: dict[str, str] = {} + validate_field("configuration_url", cv.url, user_input, errors, "invalid_url") + if not errors and user_input is not None: + self._subentry_data[CONF_DEVICE] = cast(MqttDeviceData, user_input) + if self.source == SOURCE_RECONFIGURE: + return await self.async_step_summary_menu() + return await self.async_step_entity() + + data_schema = self.add_suggested_values_to_schema( + MQTT_DEVICE_SCHEMA, + self._subentry_data[CONF_DEVICE] if user_input is None else user_input, + ) + return self.async_show_form( + step_id=CONF_DEVICE, + data_schema=data_schema, + errors=errors, + last_step=False, + ) + + async def async_step_entity( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Add or edit an mqtt entity.""" + errors: dict[str, str] = {} + data_schema_fields = COMMON_ENTITY_FIELDS + entity_name_label: str = "" + platform_label: str = "" + if reconfig := (self._component_id is not None): + name: str | None = self._subentry_data["components"][ + self._component_id + ].get(CONF_NAME) + platform_label = f"{self._subentry_data['components'][self._component_id][CONF_PLATFORM]} " + entity_name_label = f" ({name})" if name is not None else "" + data_schema = data_schema_from_fields(data_schema_fields, reconfig=reconfig) + if user_input is not None: + validate_user_input(user_input, data_schema_fields, errors) + if not errors: + if self._component_id is None: + self._component_id = uuid4().hex + self._subentry_data["components"].setdefault(self._component_id, {}) + self.update_component_fields(data_schema, user_input) + return await self.async_step_mqtt_platform_config() + data_schema = self.add_suggested_values_to_schema(data_schema, user_input) + elif self.source == SOURCE_RECONFIGURE and self._component_id is not None: + data_schema = self.add_suggested_values_to_schema( + data_schema, self._subentry_data["components"][self._component_id] + ) + device_name = self._subentry_data[CONF_DEVICE][CONF_NAME] + return self.async_show_form( + step_id="entity", + data_schema=data_schema, + description_placeholders={ + "mqtt_device": device_name, + "entity_name_label": entity_name_label, + "platform_label": platform_label, + }, + errors=errors, + last_step=False, + ) + + def _show_update_or_delete_form(self, step_id: str) -> SubentryFlowResult: + """Help selecting an entity to update or delete.""" + device_name = self._subentry_data[CONF_DEVICE][CONF_NAME] + entities = [ + SelectOptionDict( + value=key, label=f"{device_name} {component.get(CONF_NAME, '-')}" + ) + for key, component in self._subentry_data["components"].items() + ] + data_schema = vol.Schema( + { + vol.Required("component"): SelectSelector( + SelectSelectorConfig( + options=entities, + mode=SelectSelectorMode.LIST, + ) + ) + } + ) + return self.async_show_form( + step_id=step_id, data_schema=data_schema, last_step=False + ) + + async def async_step_update_entity( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Select the entity to update.""" + if user_input: + self._component_id = user_input["component"] + return await self.async_step_entity() + if len(self._subentry_data["components"]) == 1: + # Return first key + self._component_id = next(iter(self._subentry_data["components"])) + return await self.async_step_entity() + return self._show_update_or_delete_form("update_entity") + + async def async_step_delete_entity( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Select the entity to delete.""" + if user_input: + del self._subentry_data["components"][user_input["component"]] + return await self.async_step_summary_menu() + return self._show_update_or_delete_form("delete_entity") + + async def async_step_mqtt_platform_config( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Configure entity platform MQTT details.""" + errors: dict[str, str] = {} + if TYPE_CHECKING: + assert self._component_id is not None + platform = self._subentry_data["components"][self._component_id][CONF_PLATFORM] + data_schema_fields = PLATFORM_MQTT_FIELDS[platform] | COMMON_MQTT_FIELDS + data_schema = data_schema_from_fields( + data_schema_fields, reconfig=self._component_id is not None + ) + if user_input is not None: + # Test entity fields against the validator + validate_user_input(user_input, data_schema_fields, errors) + if not errors: + self.update_component_fields(data_schema, user_input) + self._component_id = None + if self.source == SOURCE_RECONFIGURE: + return await self.async_step_summary_menu() + return self._async_create_subentry() + + data_schema = self.add_suggested_values_to_schema(data_schema, user_input) + else: + data_schema = self.add_suggested_values_to_schema( + data_schema, self._subentry_data["components"][self._component_id] + ) + device_name = self._subentry_data[CONF_DEVICE][CONF_NAME] + entity_name: str | None + if entity_name := self._subentry_data["components"][self._component_id].get( + CONF_NAME + ): + full_entity_name: str = f"{device_name} {entity_name}" + else: + full_entity_name = device_name + return self.async_show_form( + step_id="mqtt_platform_config", + data_schema=data_schema, + description_placeholders={ + "mqtt_device": device_name, + CONF_PLATFORM: platform, + "entity": full_entity_name, + }, + errors=errors, + last_step=False, + ) + + @callback + def _async_create_subentry( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Create a subentry for a new MQTT device.""" + device_name = self._subentry_data[CONF_DEVICE][CONF_NAME] + component: dict[str, Any] = next( + iter(self._subentry_data["components"].values()) + ) + platform = component[CONF_PLATFORM] + entity_name: str | None + if entity_name := component.get(CONF_NAME): + full_entity_name: str = f"{device_name} {entity_name}" + else: + full_entity_name = device_name + + return self.async_create_entry( + data=self._subentry_data, + title=self._subentry_data[CONF_DEVICE][CONF_NAME], + description_placeholders={ + "entity": full_entity_name, + CONF_PLATFORM: platform, + }, + ) + + async def async_step_summary_menu( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Show summary menu and decide to add more entities or to finish the flow.""" + self._component_id = None + mqtt_device = self._subentry_data[CONF_DEVICE][CONF_NAME] + mqtt_items = ", ".join( + f"{mqtt_device} {component.get(CONF_NAME, '-')}" + for component in self._subentry_data["components"].values() + ) + menu_options = [ + "entity", + "update_entity", + ] + if len(self._subentry_data["components"]) > 1: + menu_options.append("delete_entity") + menu_options.append("device") + if self._subentry_data != self._get_reconfigure_subentry().data: + menu_options.append("save_changes") + return self.async_show_menu( + step_id="summary_menu", + menu_options=menu_options, + description_placeholders={ + "mqtt_device": mqtt_device, + "mqtt_items": mqtt_items, + }, + ) + + async def async_step_save_changes( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Save the changes made to the subentry.""" + entry = self._get_reconfigure_entry() + subentry = self._get_reconfigure_subentry() + entity_registry = er.async_get(self.hass) + + # When a component is removed from the MQTT device, + # And we save the changes to the subentry, + # we need to clean up stale entity registry entries. + # The component id is used as a part of the unique id of the entity. + for unique_id, platform in [ + ( + f"{subentry.subentry_id}_{component_id}", + subentry.data["components"][component_id][CONF_PLATFORM], + ) + for component_id in subentry.data["components"] + if component_id not in self._subentry_data["components"] + ]: + if entity_id := entity_registry.async_get_entity_id( + platform, DOMAIN, unique_id + ): + entity_registry.async_remove(entity_id) + + return self.async_update_and_abort( + entry, + subentry, + data=self._subentry_data, + title=self._subentry_data[CONF_DEVICE][CONF_NAME], + ) + + @callback def async_is_pem_data(data: bytes) -> bool: """Return True if data is in PEM format.""" diff --git a/homeassistant/components/mqtt/entity.py b/homeassistant/components/mqtt/entity.py index fb047cc8d5e..df6a904fab2 100644 --- a/homeassistant/components/mqtt/entity.py +++ b/homeassistant/components/mqtt/entity.py @@ -43,7 +43,7 @@ from homeassistant.helpers.dispatcher import ( async_dispatcher_send, ) from homeassistant.helpers.entity import Entity, async_generate_entity_id -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.event import ( async_track_device_registry_updated_event, async_track_entity_registry_updated_event, @@ -111,6 +111,7 @@ from .discovery import ( from .models import ( DATA_MQTT, MessageCallbackType, + MqttSubentryData, MqttValueTemplate, MqttValueTemplateException, PublishPayloadType, @@ -238,7 +239,7 @@ def async_setup_entity_entry_helper( entry: ConfigEntry, entity_class: type[MqttEntity] | None, domain: str, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, discovery_schema: VolSchemaType, platform_schema_modern: VolSchemaType, schema_class_mapping: dict[str, type[MqttEntity]] | None = None, @@ -282,11 +283,10 @@ def async_setup_entity_entry_helper( @callback def _async_setup_entities() -> None: - """Set up MQTT items from configuration.yaml.""" + """Set up MQTT items from subentries and configuration.yaml.""" nonlocal entity_class mqtt_data = hass.data[DATA_MQTT] - if not (config_yaml := mqtt_data.config): - return + config_yaml = mqtt_data.config yaml_configs: list[ConfigType] = [ config for config_item in config_yaml @@ -294,6 +294,41 @@ def async_setup_entity_entry_helper( for config in configs if config_domain == domain ] + # process subentry entity setup + for config_subentry_id, subentry in entry.subentries.items(): + subentry_data = cast(MqttSubentryData, subentry.data) + subentry_entities: list[Entity] = [] + device_config = subentry_data["device"].copy() + device_config["identifiers"] = config_subentry_id + for component_id, component_data in subentry_data["components"].items(): + if component_data["platform"] != domain: + continue + component_config: dict[str, Any] = component_data.copy() + component_config[CONF_UNIQUE_ID] = ( + f"{config_subentry_id}_{component_id}" + ) + component_config[CONF_DEVICE] = device_config + component_config.pop("platform") + + try: + config = platform_schema_modern(component_config) + if schema_class_mapping is not None: + entity_class = schema_class_mapping[config[CONF_SCHEMA]] + if TYPE_CHECKING: + assert entity_class is not None + subentry_entities.append(entity_class(hass, config, entry, None)) + except vol.Invalid as exc: + _LOGGER.error( + "Schema violation occurred when trying to set up " + "entity from subentry %s %s %s: %s", + config_subentry_id, + subentry.title, + subentry.data, + exc, + ) + + async_add_entities(subentry_entities, config_subentry_id=config_subentry_id) + entities: list[Entity] = [] for yaml_config in yaml_configs: try: diff --git a/homeassistant/components/mqtt/models.py b/homeassistant/components/mqtt/models.py index 34c1f304944..5bbd7967ad8 100644 --- a/homeassistant/components/mqtt/models.py +++ b/homeassistant/components/mqtt/models.py @@ -420,5 +420,24 @@ class MqttComponentConfig: discovery_payload: MQTTDiscoveryPayload +class MqttDeviceData(TypedDict, total=False): + """Hold the data for an MQTT device.""" + + name: str + identifiers: str + configuration_url: str + sw_version: str + hw_version: str + model: str + model_id: str + + +class MqttSubentryData(TypedDict): + """Hold the data for a MQTT subentry.""" + + device: MqttDeviceData + components: dict[str, dict[str, Any]] + + DATA_MQTT: HassKey[MqttData] = HassKey("mqtt") DATA_MQTT_AVAILABLE: HassKey[asyncio.Future[bool]] = HassKey("mqtt_client_available") diff --git a/homeassistant/components/mqtt/strings.json b/homeassistant/components/mqtt/strings.json index 4eb41b9e39a..13595c2d462 100644 --- a/homeassistant/components/mqtt/strings.json +++ b/homeassistant/components/mqtt/strings.json @@ -108,6 +108,110 @@ "invalid_inclusion": "The client certificate and private key must be configured together" } }, + "config_subentries": { + "device": { + "initiate_flow": { + "user": "Add MQTT Device", + "reconfigure": "Reconfigure MQTT Device" + }, + "entry_type": "MQTT Device", + "step": { + "device": { + "title": "Configure MQTT device details", + "description": "Enter the MQTT device details:", + "data": { + "name": "[%key:common::config_flow::data::name%]", + "configuration_url": "Configuration URL", + "sw_version": "Software version", + "hw_version": "Hardware version", + "model": "Model", + "model_id": "Model ID" + }, + "data_description": { + "name": "The name of the manually added MQTT device.", + "configuration_url": "A link to the webpage that can manage the configuration of this device. Can be either a 'http://', 'https://' or an internal 'homeassistant://' URL.", + "sw_version": "The software version of the device. E.g. '2025.1.0'.", + "hw_version": "The hardware version of the device. E.g. 'v1.0 rev a'.", + "model": "E.g. 'Cleanmaster Pro'.", + "model_id": "E.g. '123NK2PRO'." + } + }, + "summary_menu": { + "title": "Reconfigure \"{mqtt_device}\"", + "description": "Entities set up:\n{mqtt_items}\n\nDecide what to do next:", + "menu_options": { + "entity": "Add another entity to \"{mqtt_device}\"", + "update_entity": "Update entity properties", + "delete_entity": "Delete an entity", + "device": "Update device properties", + "save_changes": "Save changes" + } + }, + "entity": { + "title": "Configure MQTT device \"{mqtt_device}\"", + "description": "Configure the basic {platform_label}entity settings{entity_name_label}", + "data": { + "platform": "Type of entity", + "name": "Entity name", + "entity_picture": "Entity picture" + }, + "data_description": { + "platform": "The type of the entity to configure.", + "name": "The name of the entity. Leave empty to set it to `None` to [mark it as main feature of the MQTT device](https://www.home-assistant.io/integrations/mqtt/#naming-of-mqtt-entities).", + "entity_picture": "An URL to a picture to be assigned." + } + }, + "delete_entity": { + "title": "Delete entity", + "description": "Delete an entity. The entity will be removed from the device. Removing an entity will break any automations or scripts that depend on it.", + "data": { + "component": "Entity" + }, + "data_description": { + "component": "Select the entity you want to delete. Minimal one entity is required." + } + }, + "update_entity": { + "title": "Select entity", + "description": "Select the entity you want to update", + "data": { + "component": "Entity" + }, + "data_description": { + "component": "Select the entity you want to update." + } + }, + "mqtt_platform_config": { + "title": "Configure MQTT device \"{mqtt_device}\"", + "description": "Please configure MQTT specific details for {platform} entity \"{entity}\":", + "data": { + "command_topic": "Command topic", + "command_template": "Command template", + "retain": "Retain", + "qos": "QoS" + }, + "data_description": { + "command_topic": "The publishing topic that will be used to control the {platform} entity.", + "command_template": "A [template](https://www.home-assistant.io/docs/configuration/templating/#using-command-templates-with-mqtt) to render the payload to be published at the command topic.", + "retain": "Select if values published by the {platform} entity should be retained at the MQTT broker.", + "qos": "The QoS value {platform} entity should use." + } + } + }, + "abort": { + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" + }, + "create_entry": { + "default": "MQTT device with {platform} entity \"{entity}\" was set up successfully.\n\nNote that you can reconfigure the MQTT device at any time, e.g. to add more entities." + }, + "error": { + "invalid_input": "Invalid value", + "invalid_subscribe_topic": "Invalid subscribe topic", + "invalid_template": "Invalid template", + "invalid_url": "Invalid URL" + } + } + }, "device_automation": { "trigger_type": { "button_short_press": "\"{subtype}\" pressed", @@ -221,6 +325,11 @@ } }, "selector": { + "platform": { + "options": { + "notify": "Notify" + } + }, "set_ca_cert": { "options": { "off": "[%key:common::state::off%]", diff --git a/tests/components/mqtt/common.py b/tests/components/mqtt/common.py index 3bb8657e2f2..55458b9e4c8 100644 --- a/tests/components/mqtt/common.py +++ b/tests/components/mqtt/common.py @@ -66,6 +66,118 @@ DEFAULT_CONFIG_DEVICE_INFO_MAC = { "configuration_url": "http://example.com", } +MOCK_SUBENTRY_NOTIFY_COMPONENT1 = { + "363a7ecad6be4a19b939a016ea93e994": { + "platform": "notify", + "name": "Milkman alert", + "qos": 0, + "command_topic": "test-topic", + "command_template": "{{ value_json.value }}", + "entity_picture": "https://example.com/363a7ecad6be4a19b939a016ea93e994", + "retain": False, + }, +} +MOCK_SUBENTRY_NOTIFY_COMPONENT2 = { + "6494827dac294fa0827c54b02459d309": { + "platform": "notify", + "name": "The second notifier", + "qos": 0, + "command_topic": "test-topic2", + "entity_picture": "https://example.com/6494827dac294fa0827c54b02459d309", + }, +} +MOCK_SUBENTRY_NOTIFY_COMPONENT_NO_NAME = { + "5269352dd9534c908d22812ea5d714cd": { + "platform": "notify", + "qos": 0, + "command_topic": "test-topic", + "command_template": "{{ value_json.value }}", + "entity_picture": "https://example.com/5269352dd9534c908d22812ea5d714cd", + "retain": False, + }, +} + +# Bogus light component just for code coverage +# Note that light cannot be setup through the UI yet +# The test is for code coverage +MOCK_SUBENTRY_LIGHT_COMPONENT = { + "8131babc5e8d4f44b82e0761d39091a2": { + "platform": "light", + "name": "Test light", + "qos": 1, + "command_topic": "test-topic4", + "schema": "basic", + "entity_picture": "https://example.com/8131babc5e8d4f44b82e0761d39091a2", + }, +} +MOCK_SUBENTRY_NOTIFY_BAD_SCHEMA = { + "b10b531e15244425a74bb0abb1e9d2c6": { + "platform": "notify", + "name": "Test", + "qos": 1, + "command_topic": "bad#topic", + }, +} + +MOCK_NOTIFY_SUBENTRY_DATA_MULTI = { + "device": { + "name": "Milk notifier", + "sw_version": "1.0", + "hw_version": "2.1 rev a", + "model": "Model XL", + "model_id": "mn002", + "configuration_url": "https://example.com", + }, + "components": MOCK_SUBENTRY_NOTIFY_COMPONENT1 | MOCK_SUBENTRY_NOTIFY_COMPONENT2, +} + +MOCK_NOTIFY_SUBENTRY_DATA_SINGLE = { + "device": { + "name": "Milk notifier", + "sw_version": "1.0", + "hw_version": "2.1 rev a", + "model": "Model XL", + "model_id": "mn002", + "configuration_url": "https://example.com", + }, + "components": MOCK_SUBENTRY_NOTIFY_COMPONENT1, +} +MOCK_SUBENTRY_DATA_NOTIFY_NO_NAME = { + "device": { + "name": "Milk notifier", + "sw_version": "1.0", + "hw_version": "2.1 rev a", + "model": "Model XL", + "model_id": "mn002", + "configuration_url": "https://example.com", + }, + "components": MOCK_SUBENTRY_NOTIFY_COMPONENT_NO_NAME, +} + +MOCK_SUBENTRY_DATA_BAD_COMPONENT_SCHEMA = { + "device": { + "name": "Milk notifier", + "sw_version": "1.0", + "hw_version": "2.1 rev a", + "model": "Model XL", + "model_id": "mn002", + "configuration_url": "https://example.com", + }, + "components": MOCK_SUBENTRY_NOTIFY_BAD_SCHEMA, +} +MOCK_SUBENTRY_DATA_SET_MIX = { + "device": { + "name": "Milk notifier", + "sw_version": "1.0", + "hw_version": "2.1 rev a", + "model": "Model XL", + "model_id": "mn002", + "configuration_url": "https://example.com", + }, + "components": MOCK_SUBENTRY_NOTIFY_COMPONENT1 + | MOCK_SUBENTRY_NOTIFY_COMPONENT2 + | MOCK_SUBENTRY_LIGHT_COMPONENT, +} _SENTINEL = object() DISCOVERY_COUNT = sum(len(discovery_topic) for discovery_topic in MQTT.values()) diff --git a/tests/components/mqtt/test_config_flow.py b/tests/components/mqtt/test_config_flow.py index f39e32a0d8b..9007c49635b 100644 --- a/tests/components/mqtt/test_config_flow.py +++ b/tests/components/mqtt/test_config_flow.py @@ -2,6 +2,7 @@ from collections.abc import Generator, Iterator from contextlib import contextmanager +from copy import deepcopy from pathlib import Path from ssl import SSLError from typing import Any @@ -17,6 +18,7 @@ from homeassistant import config_entries from homeassistant.components import mqtt from homeassistant.components.hassio import AddonError from homeassistant.components.mqtt.config_flow import PWD_NOT_CHANGED +from homeassistant.config_entries import ConfigSubentry, ConfigSubentryData from homeassistant.const import ( CONF_CLIENT_ID, CONF_PASSWORD, @@ -26,8 +28,15 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.service_info.hassio import HassioServiceInfo +from .common import ( + MOCK_NOTIFY_SUBENTRY_DATA_MULTI, + MOCK_NOTIFY_SUBENTRY_DATA_SINGLE, + MOCK_SUBENTRY_DATA_NOTIFY_NO_NAME, +) + from tests.common import MockConfigEntry, MockMqttReasonCode from tests.typing import MqttMockHAClientGenerator, MqttMockPahoClient @@ -2598,3 +2607,706 @@ async def test_migrate_of_incompatible_config_entry( await mqtt_mock_entry() assert config_entry.state is config_entries.ConfigEntryState.MIGRATION_ERROR + + +@pytest.mark.parametrize( + ( + "config_subentries_data", + "mock_entity_user_input", + "mock_mqtt_user_input", + "mock_failed_mqtt_user_input", + "mock_failed_mqtt_user_input_errors", + "entity_name", + ), + [ + ( + MOCK_NOTIFY_SUBENTRY_DATA_SINGLE, + {"name": "Milkman alert"}, + { + "command_topic": "test-topic", + "command_template": "{{ value_json.value }}", + "qos": 0, + "retain": False, + }, + {"command_topic": "test-topic#invalid"}, + {"command_topic": "invalid_publish_topic"}, + "Milk notifier Milkman alert", + ), + ( + MOCK_SUBENTRY_DATA_NOTIFY_NO_NAME, + {}, + { + "command_topic": "test-topic", + "command_template": "{{ value_json.value }}", + "qos": 0, + "retain": False, + }, + {"command_topic": "test-topic#invalid"}, + {"command_topic": "invalid_publish_topic"}, + "Milk notifier", + ), + ], + ids=["notify_with_entity_name", "notify_no_entity_name"], +) +async def test_subentry_configflow( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + config_subentries_data: dict[str, Any], + mock_entity_user_input: dict[str, Any], + mock_mqtt_user_input: dict[str, Any], + mock_failed_mqtt_user_input: dict[str, Any], + mock_failed_mqtt_user_input_errors: dict[str, Any], + entity_name: str, +) -> None: + """Test the subentry ConfigFlow.""" + device_name = config_subentries_data["device"]["name"] + component = next(iter(config_subentries_data["components"].values())) + + await mqtt_mock_entry() + config_entry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] + + result = await hass.config_entries.subentries.async_init( + (config_entry.entry_id, "device"), + context={"source": config_entries.SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "device" + + # Test the URL validation + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={ + "name": device_name, + "configuration_url": "http:/badurl.example.com", + }, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "device" + assert result["errors"]["configuration_url"] == "invalid_url" + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={ + "name": device_name, + "sw_version": "1.0", + "hw_version": "2.1 rev a", + "model": "Model XL", + "model_id": "mn002", + "configuration_url": "https://example.com", + }, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "entity" + assert result["errors"] == {} + + # Process entity flow (initial step) + + # Test the entity picture URL validation + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={ + "platform": component["platform"], + "entity_picture": "invalid url", + } + | mock_entity_user_input, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "entity" + + # Try again with valid data + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={ + "platform": component["platform"], + "entity_picture": component["entity_picture"], + } + | mock_entity_user_input, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "mqtt_platform_config" + assert result["errors"] == {} + assert result["description_placeholders"] == { + "mqtt_device": "Milk notifier", + "platform": "notify", + "entity": entity_name, + } + + # Process entity platform config flow + + # Test an invalid mqtt user_input case + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input=mock_failed_mqtt_user_input, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == mock_failed_mqtt_user_input_errors + + # Try again with a valid configuration + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], user_input=mock_mqtt_user_input + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == device_name + + subentry_component = next( + iter(next(iter(config_entry.subentries.values())).data["components"].values()) + ) + assert subentry_component == next( + iter(config_subentries_data["components"].values()) + ) + + await hass.async_block_till_done() + + +@pytest.mark.parametrize( + "mqtt_config_subentries_data", + [ + ( + ConfigSubentryData( + data=MOCK_NOTIFY_SUBENTRY_DATA_MULTI, + subentry_type="device", + title="Mock subentry", + ), + ) + ], + ids=["notify"], +) +async def test_subentry_reconfigure_remove_entity( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test the subentry ConfigFlow reconfigure removing an entity.""" + await mqtt_mock_entry() + config_entry: MockConfigEntry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] + subentry_id: str + subentry: ConfigSubentry + subentry_id, subentry = next(iter(config_entry.subentries.items())) + result = await config_entry.start_subentry_reconfigure_flow( + hass, "device", subentry_id + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "summary_menu" + + # assert we have a device for the subentry + device = device_registry.async_get_device(identifiers={(mqtt.DOMAIN, subentry_id)}) + assert device is not None + + # assert we have an entity for all subentry components + components = deepcopy(dict(subentry.data))["components"] + assert len(components) == 2 + object_list = list(components) + component_list = list(components.values()) + entity_name_0 = f"{device.name} {component_list[0]['name']}" + entity_name_1 = f"{device.name} {component_list[1]['name']}" + + for key, component in components.items(): + unique_entity_id = f"{subentry_id}_{key}" + entity_id = entity_registry.async_get_entity_id( + domain=component["platform"], + platform=mqtt.DOMAIN, + unique_id=unique_entity_id, + ) + assert entity_id is not None + entity_entry = entity_registry.async_get(entity_id) + assert entity_entry is not None + assert entity_entry.config_subentry_id == subentry_id + + # assert menu options, we have the option to delete one entity + # we have no option to save and finish yet + assert result["menu_options"] == [ + "entity", + "update_entity", + "delete_entity", + "device", + ] + + # assert we can delete an entity + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {"next_step_id": "delete_entity"}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "delete_entity" + assert result["data_schema"].schema["component"].config["options"] == [ + {"value": object_list[0], "label": entity_name_0}, + {"value": object_list[1], "label": entity_name_1}, + ] + # remove notify_the_second_notifier + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={ + "component": object_list[1], + }, + ) + + # assert menu options, we have only one item left, we cannot delete it + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "summary_menu" + assert result["menu_options"] == [ + "entity", + "update_entity", + "device", + "save_changes", + ] + + # finish reconfigure flow + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {"next_step_id": "save_changes"}, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + # check if the second entity was removed from the subentry and entity registry + unique_entity_id = f"{subentry_id}_{object_list[1]}" + entity_id = entity_registry.async_get_entity_id( + domain=components[object_list[1]]["platform"], + platform=mqtt.DOMAIN, + unique_id=unique_entity_id, + ) + assert entity_id is None + new_components = deepcopy(dict(subentry.data))["components"] + assert object_list[0] in new_components + assert object_list[1] not in new_components + + +@pytest.mark.parametrize( + ("mqtt_config_subentries_data", "user_input_mqtt"), + [ + ( + ( + ConfigSubentryData( + data=MOCK_NOTIFY_SUBENTRY_DATA_MULTI, + subentry_type="device", + title="Mock subentry", + ), + ), + {"command_topic": "test-topic2-updated"}, + ) + ], + ids=["notify"], +) +async def test_subentry_reconfigure_edit_entity_multi_entitites( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + user_input_mqtt: dict[str, Any], +) -> None: + """Test the subentry ConfigFlow reconfigure with multi entities.""" + await mqtt_mock_entry() + config_entry: MockConfigEntry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] + subentry_id: str + subentry: ConfigSubentry + subentry_id, subentry = next(iter(config_entry.subentries.items())) + result = await config_entry.start_subentry_reconfigure_flow( + hass, "device", subentry_id + ) + + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "summary_menu" + + # assert we have a device for the subentry + device = device_registry.async_get_device(identifiers={(mqtt.DOMAIN, subentry_id)}) + assert device is not None + + # assert we have an entity for all subentry components + components = deepcopy(dict(subentry.data))["components"] + assert len(components) == 2 + object_list = list(components) + component_list = list(components.values()) + entity_name_0 = f"{device.name} {component_list[0]['name']}" + entity_name_1 = f"{device.name} {component_list[1]['name']}" + + for key in components: + unique_entity_id = f"{subentry_id}_{key}" + entity_id = entity_registry.async_get_entity_id( + domain="notify", platform=mqtt.DOMAIN, unique_id=unique_entity_id + ) + assert entity_id is not None + entity_entry = entity_registry.async_get(entity_id) + assert entity_entry is not None + assert entity_entry.config_subentry_id == subentry_id + + # assert menu options, we have the option to delete one entity + # we have no option to save and finish yet + assert result["menu_options"] == [ + "entity", + "update_entity", + "delete_entity", + "device", + ] + + # assert we can update an entity + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {"next_step_id": "update_entity"}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "update_entity" + assert result["data_schema"].schema["component"].config["options"] == [ + {"value": object_list[0], "label": entity_name_0}, + {"value": object_list[1], "label": entity_name_1}, + ] + # select second entity + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={ + "component": object_list[1], + }, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "entity" + + # submit the common entity data with changed entity_picture + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={ + "entity_picture": "https://example.com", + }, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "mqtt_platform_config" + + # submit the new platform specific entity data + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input=user_input_mqtt, + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "summary_menu" + + # finish reconfigure flow + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {"next_step_id": "save_changes"}, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + # Check we still have our components + new_components = deepcopy(dict(subentry.data))["components"] + + # Check the second component was updated + assert new_components[object_list[0]] == components[object_list[0]] + for key, value in user_input_mqtt.items(): + assert new_components[object_list[1]][key] == value + + +@pytest.mark.parametrize( + ("mqtt_config_subentries_data", "user_input_mqtt"), + [ + ( + ( + ConfigSubentryData( + data=MOCK_NOTIFY_SUBENTRY_DATA_SINGLE, + subentry_type="device", + title="Mock subentry", + ), + ), + { + "command_topic": "test-topic1-updated", + "command_template": "{{ value_json.value }}", + "retain": True, + }, + ) + ], + ids=["notify"], +) +async def test_subentry_reconfigure_edit_entity_single_entity( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + user_input_mqtt: dict[str, Any], +) -> None: + """Test the subentry ConfigFlow reconfigure with single entity.""" + await mqtt_mock_entry() + config_entry: MockConfigEntry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] + subentry_id: str + subentry: ConfigSubentry + subentry_id, subentry = next(iter(config_entry.subentries.items())) + result = await config_entry.start_subentry_reconfigure_flow( + hass, "device", subentry_id + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "summary_menu" + + # assert we have a device for the subentry + device = device_registry.async_get_device(identifiers={(mqtt.DOMAIN, subentry_id)}) + assert device is not None + + # assert we have an entity for the subentry component + # Check we have "notify_milkman_alert" in our mock data + components = deepcopy(dict(subentry.data))["components"] + assert len(components) == 1 + + component_id, component = next(iter(components.items())) + + unique_entity_id = f"{subentry_id}_{component_id}" + entity_id = entity_registry.async_get_entity_id( + domain=component["platform"], platform=mqtt.DOMAIN, unique_id=unique_entity_id + ) + assert entity_id is not None + entity_entry = entity_registry.async_get(entity_id) + assert entity_entry is not None + assert entity_entry.config_subentry_id == subentry_id + + # assert menu options, we do not have the option to delete an entity + # we have no option to save and finish yet + assert result["menu_options"] == [ + "entity", + "update_entity", + "device", + ] + + # assert we can update the entity, there is no select step + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {"next_step_id": "update_entity"}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "entity" + + # submit the new common entity data, reset entity_picture + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "mqtt_platform_config" + + # submit the new platform specific entity data, + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input=user_input_mqtt, + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "summary_menu" + + # finish reconfigure flow + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {"next_step_id": "save_changes"}, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + # Check we still have out components + new_components = deepcopy(dict(subentry.data))["components"] + assert len(new_components) == 1 + + # Check our update was successful + assert "entity_picture" not in new_components[component_id] + + # Check the second component was updated + for key, value in user_input_mqtt.items(): + assert new_components[component_id][key] == value + + +@pytest.mark.parametrize( + ("mqtt_config_subentries_data", "user_input_entity", "user_input_mqtt"), + [ + ( + ( + ConfigSubentryData( + data=MOCK_NOTIFY_SUBENTRY_DATA_SINGLE, + subentry_type="device", + title="Mock subentry", + ), + ), + { + "platform": "notify", + "name": "The second notifier", + "entity_picture": "https://example.com", + }, + { + "command_topic": "test-topic2", + "qos": 0, + }, + ) + ], + ids=["notify_notify"], +) +async def test_subentry_reconfigure_add_entity( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + user_input_entity: dict[str, Any], + user_input_mqtt: dict[str, Any], +) -> None: + """Test the subentry ConfigFlow reconfigure and add an entity.""" + await mqtt_mock_entry() + config_entry: MockConfigEntry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] + subentry_id: str + subentry: ConfigSubentry + subentry_id, subentry = next(iter(config_entry.subentries.items())) + result = await config_entry.start_subentry_reconfigure_flow( + hass, "device", subentry_id + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "summary_menu" + + # assert we have a device for the subentry + device = device_registry.async_get_device(identifiers={(mqtt.DOMAIN, subentry_id)}) + assert device is not None + + # assert we have an entity for the subentry component + components = deepcopy(dict(subentry.data))["components"] + assert len(components) == 1 + component_id_1, component1 = next(iter(components.items())) + unique_entity_id = f"{subentry_id}_{component_id_1}" + entity_id = entity_registry.async_get_entity_id( + domain=component1["platform"], platform=mqtt.DOMAIN, unique_id=unique_entity_id + ) + assert entity_id is not None + entity_entry = entity_registry.async_get(entity_id) + assert entity_entry is not None + assert entity_entry.config_subentry_id == subentry_id + + # assert menu options, we do not have the option to delete an entity + # we have no option to save and finish yet + assert result["menu_options"] == [ + "entity", + "update_entity", + "device", + ] + + # assert we can update the entity, there is no select step + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {"next_step_id": "entity"}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "entity" + + # submit the new common entity data + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input=user_input_entity, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "mqtt_platform_config" + + # submit the new platform specific entity data + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input=user_input_mqtt, + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "summary_menu" + + # Finish reconfigure flow + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {"next_step_id": "save_changes"}, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + # Check we still have out components + new_components = deepcopy(dict(subentry.data))["components"] + assert len(new_components) == 2 + + component_id_2 = next(iter(set(new_components) - {component_id_1})) + + # Check our new entity was added correctly + expected_component_config = user_input_entity | user_input_mqtt + for key, value in expected_component_config.items(): + assert new_components[component_id_2][key] == value + + +@pytest.mark.parametrize( + "mqtt_config_subentries_data", + [ + ( + ConfigSubentryData( + data=MOCK_NOTIFY_SUBENTRY_DATA_MULTI, + subentry_type="device", + title="Mock subentry", + ), + ) + ], +) +async def test_subentry_reconfigure_update_device_properties( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + device_registry: dr.DeviceRegistry, +) -> None: + """Test the subentry ConfigFlow reconfigure and update device properties.""" + await mqtt_mock_entry() + config_entry: MockConfigEntry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] + subentry_id: str + subentry: ConfigSubentry + subentry_id, subentry = next(iter(config_entry.subentries.items())) + result = await config_entry.start_subentry_reconfigure_flow( + hass, "device", subentry_id + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "summary_menu" + + # assert we have a device for the subentry + device = device_registry.async_get_device(identifiers={(mqtt.DOMAIN, subentry_id)}) + assert device is not None + + # assert we have an entity for all subentry components + components = deepcopy(dict(subentry.data))["components"] + assert len(components) == 2 + + # Assert initial data + device = deepcopy(dict(subentry.data))["device"] + assert device["name"] == "Milk notifier" + assert device["sw_version"] == "1.0" + assert device["hw_version"] == "2.1 rev a" + assert device["model"] == "Model XL" + assert device["model_id"] == "mn002" + + # assert menu options, we have the option to delete one entity + # we have no option to save and finish yet + assert result["menu_options"] == [ + "entity", + "update_entity", + "delete_entity", + "device", + ] + + # assert we can update the device properties + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {"next_step_id": "device"}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "device" + + # Update the device details + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={ + "name": "Beer notifier", + "sw_version": "1.1", + "model": "Beer bottle XL", + "model_id": "bn003", + "configuration_url": "https://example.com", + }, + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "summary_menu" + + # finish reconfigure flow + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {"next_step_id": "save_changes"}, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + # Check our device was updated + device = deepcopy(dict(subentry.data))["device"] + assert device["name"] == "Beer notifier" + assert "hw_version" not in device + assert device["model"] == "Beer bottle XL" + assert device["model_id"] == "bn003" diff --git a/tests/components/mqtt/test_mixins.py b/tests/components/mqtt/test_mixins.py index d65f1a4d661..ecc045b3871 100644 --- a/tests/components/mqtt/test_mixins.py +++ b/tests/components/mqtt/test_mixins.py @@ -1,18 +1,27 @@ """The tests for shared code of the MQTT platform.""" +from typing import Any from unittest.mock import patch import pytest from homeassistant.components import mqtt, sensor from homeassistant.components.mqtt.sensor import DEFAULT_NAME as DEFAULT_SENSOR_NAME +from homeassistant.config_entries import ConfigSubentryData from homeassistant.const import ( ATTR_FRIENDLY_NAME, EVENT_HOMEASSISTANT_STARTED, EVENT_STATE_CHANGED, ) from homeassistant.core import CoreState, HomeAssistant, callback -from homeassistant.helpers import device_registry as dr, issue_registry as ir +from homeassistant.helpers import ( + device_registry as dr, + entity_registry as er, + issue_registry as ir, +) +from homeassistant.util import slugify + +from .common import MOCK_SUBENTRY_DATA_BAD_COMPONENT_SCHEMA, MOCK_SUBENTRY_DATA_SET_MIX from tests.common import MockConfigEntry, async_capture_events, async_fire_mqtt_message from tests.typing import MqttMockHAClientGenerator @@ -453,3 +462,74 @@ async def test_value_template_fails( "TypeError: unsupported operand type(s) for *: 'NoneType' and 'int' rendering template" in caplog.text ) + + +@pytest.mark.parametrize( + "mqtt_config_subentries_data", + [ + ( + ConfigSubentryData( + data=MOCK_SUBENTRY_DATA_SET_MIX, + subentry_type="device", + title="Mock subentry", + ), + ) + ], +) +async def test_loading_subentries( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + mqtt_config_subentries_data: tuple[dict[str, Any]], + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test loading subentries.""" + await mqtt_mock_entry() + entry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] + subentry_id = next(iter(entry.subentries)) + # Each subentry has one device + device = device_registry.async_get_device({("mqtt", subentry_id)}) + assert device is not None + for object_id, component in mqtt_config_subentries_data[0]["data"][ + "components" + ].items(): + platform = component["platform"] + entity_id = f"{platform}.{slugify(device.name)}_{slugify(component['name'])}" + entity_entry_entity_id = entity_registry.async_get_entity_id( + platform, mqtt.DOMAIN, f"{subentry_id}_{object_id}" + ) + assert entity_entry_entity_id == entity_id + state = hass.states.get(entity_id) + assert state is not None + + +@pytest.mark.parametrize( + "mqtt_config_subentries_data", + [ + ( + ConfigSubentryData( + data=MOCK_SUBENTRY_DATA_BAD_COMPONENT_SCHEMA, + subentry_type="device", + title="Mock subentry", + ), + ) + ], +) +async def test_loading_subentry_with_bad_component_schema( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + mqtt_config_subentries_data: tuple[dict[str, Any]], + device_registry: dr.DeviceRegistry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test loading subentries.""" + await mqtt_mock_entry() + entry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] + subentry_id = next(iter(entry.subentries)) + # Each subentry has one device + device = device_registry.async_get_device({("mqtt", subentry_id)}) + assert device is None + assert ( + "Schema violation occurred when trying to set up entity from subentry" + in caplog.text + ) diff --git a/tests/conftest.py b/tests/conftest.py index 7725189aa53..65e3518956e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -67,7 +67,12 @@ from homeassistant.components.websocket_api.auth import ( # pylint: disable-next=hass-component-root-import from homeassistant.components.websocket_api.http import URL from homeassistant.config import YAML_CONFIG_FILE -from homeassistant.config_entries import ConfigEntries, ConfigEntry, ConfigEntryState +from homeassistant.config_entries import ( + ConfigEntries, + ConfigEntry, + ConfigEntryState, + ConfigSubentryData, +) from homeassistant.const import BASE_PLATFORMS, HASSIO_USER_NAME from homeassistant.core import ( Context, @@ -946,6 +951,12 @@ def mqtt_config_entry_data() -> dict[str, Any] | None: return None +@pytest.fixture +def mqtt_config_subentries_data() -> tuple[ConfigSubentryData] | None: + """Fixture to allow overriding MQTT subentries data.""" + return None + + @pytest.fixture def mqtt_config_entry_options() -> dict[str, Any] | None: """Fixture to allow overriding MQTT entry options.""" @@ -1032,6 +1043,7 @@ async def mqtt_mock( mqtt_client_mock: MqttMockPahoClient, mqtt_config_entry_data: dict[str, Any] | None, mqtt_config_entry_options: dict[str, Any] | None, + mqtt_config_subentries_data: tuple[ConfigSubentryData] | None, mqtt_mock_entry: MqttMockHAClientGenerator, ) -> AsyncGenerator[MqttMockHAClient]: """Fixture to mock MQTT component.""" @@ -1044,6 +1056,7 @@ async def _mqtt_mock_entry( mqtt_client_mock: MqttMockPahoClient, mqtt_config_entry_data: dict[str, Any] | None, mqtt_config_entry_options: dict[str, Any] | None, + mqtt_config_subentries_data: tuple[ConfigSubentryData] | None, ) -> AsyncGenerator[MqttMockHAClientGenerator]: """Fixture to mock a delayed setup of the MQTT config entry.""" # Local import to avoid processing MQTT modules when running a testcase @@ -1060,6 +1073,7 @@ async def _mqtt_mock_entry( entry = MockConfigEntry( data=mqtt_config_entry_data, options=mqtt_config_entry_options, + subentries_data=mqtt_config_subentries_data, domain=mqtt.DOMAIN, title="MQTT", version=1, @@ -1174,6 +1188,7 @@ async def mqtt_mock_entry( mqtt_client_mock: MqttMockPahoClient, mqtt_config_entry_data: dict[str, Any] | None, mqtt_config_entry_options: dict[str, Any] | None, + mqtt_config_subentries_data: tuple[ConfigSubentryData] | None, ) -> AsyncGenerator[MqttMockHAClientGenerator]: """Set up an MQTT config entry.""" @@ -1190,7 +1205,11 @@ async def mqtt_mock_entry( return await mqtt_mock_entry(_async_setup_config_entry) async with _mqtt_mock_entry( - hass, mqtt_client_mock, mqtt_config_entry_data, mqtt_config_entry_options + hass, + mqtt_client_mock, + mqtt_config_entry_data, + mqtt_config_entry_options, + mqtt_config_subentries_data, ) as mqtt_mock_entry: yield _setup_mqtt_entry From 4e759e59a42da9548ac6f22851d68d7adee201b1 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Fri, 14 Mar 2025 23:41:09 +1000 Subject: [PATCH 2607/3148] Add streaming switches to Teslemetry (#137145) * Add streaming switches * Add switch tests * Update snapshot * Fix sentry * update test docstring --- homeassistant/components/teslemetry/switch.py | 133 ++++++++++++++---- .../teslemetry/snapshots/test_switch.ambr | 18 +++ tests/components/teslemetry/test_switch.py | 49 ++++++- 3 files changed, 170 insertions(+), 30 deletions(-) diff --git a/homeassistant/components/teslemetry/switch.py b/homeassistant/components/teslemetry/switch.py index 83441e6c4f6..4098a050fd9 100644 --- a/homeassistant/components/teslemetry/switch.py +++ b/homeassistant/components/teslemetry/switch.py @@ -8,6 +8,7 @@ from itertools import chain from typing import Any from tesla_fleet_api.const import Scope, Seat +from teslemetry_stream import TeslemetryStreamVehicle from homeassistant.components.switch import ( SwitchDeviceClass, @@ -16,10 +17,16 @@ from homeassistant.components.switch import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import StateType from . import TeslemetryConfigEntry -from .entity import TeslemetryEnergyInfoEntity, TeslemetryVehicleEntity +from .entity import ( + TeslemetryEnergyInfoEntity, + TeslemetryRootEntity, + TeslemetryVehicleEntity, + TeslemetryVehicleStreamEntity, +) from .helpers import handle_command, handle_vehicle_command from .models import TeslemetryEnergyData, TeslemetryVehicleData @@ -34,18 +41,27 @@ class TeslemetrySwitchEntityDescription(SwitchEntityDescription): off_func: Callable scopes: list[Scope] value_func: Callable[[StateType], bool] = bool + streaming_listener: Callable[ + [TeslemetryStreamVehicle, Callable[[StateType], None]], + Callable[[], None], + ] + streaming_value_fn: Callable[[StateType], bool] = bool + streaming_firmware: str = "2024.26" unique_id: str | None = None VEHICLE_DESCRIPTIONS: tuple[TeslemetrySwitchEntityDescription, ...] = ( TeslemetrySwitchEntityDescription( key="vehicle_state_sentry_mode", + streaming_listener=lambda x, y: x.listen_SentryMode(y), + streaming_value_fn=lambda x: x != "Off", on_func=lambda api: api.set_sentry_mode(on=True), off_func=lambda api: api.set_sentry_mode(on=False), scopes=[Scope.VEHICLE_CMDS], ), TeslemetrySwitchEntityDescription( key="climate_state_auto_seat_climate_left", + streaming_listener=lambda x, y: x.listen_AutoSeatClimateLeft(y), on_func=lambda api: api.remote_auto_seat_climate_request(Seat.FRONT_LEFT, True), off_func=lambda api: api.remote_auto_seat_climate_request( Seat.FRONT_LEFT, False @@ -54,6 +70,7 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetrySwitchEntityDescription, ...] = ( ), TeslemetrySwitchEntityDescription( key="climate_state_auto_seat_climate_right", + streaming_listener=lambda x, y: x.listen_AutoSeatClimateRight(y), on_func=lambda api: api.remote_auto_seat_climate_request( Seat.FRONT_RIGHT, True ), @@ -64,6 +81,7 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetrySwitchEntityDescription, ...] = ( ), TeslemetrySwitchEntityDescription( key="climate_state_auto_steering_wheel_heat", + streaming_listener=lambda x, y: x.listen_HvacSteeringWheelHeatAuto(y), on_func=lambda api: api.remote_auto_steering_wheel_heat_climate_request( on=True ), @@ -74,6 +92,8 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetrySwitchEntityDescription, ...] = ( ), TeslemetrySwitchEntityDescription( key="climate_state_defrost_mode", + streaming_listener=lambda x, y: x.listen_DefrostMode(y), + streaming_value_fn=lambda x: x != "Off", on_func=lambda api: api.set_preconditioning_max(on=True, manual_override=False), off_func=lambda api: api.set_preconditioning_max( on=False, manual_override=False @@ -83,9 +103,11 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetrySwitchEntityDescription, ...] = ( TeslemetrySwitchEntityDescription( key="charge_state_charging_state", unique_id="charge_state_user_charge_enable_request", + value_func=lambda state: state in {"Starting", "Charging"}, + streaming_listener=lambda x, y: x.listen_DetailedChargeState(y), + streaming_value_fn=lambda x: x in {"Starting", "Charging"}, on_func=lambda api: api.charge_start(), off_func=lambda api: api.charge_stop(), - value_func=lambda state: state in {"Starting", "Charging"}, scopes=[Scope.VEHICLE_CMDS, Scope.VEHICLE_CHARGING_CMDS], ), ) @@ -101,12 +123,16 @@ async def async_setup_entry( async_add_entities( chain( ( - TeslemetryVehicleSwitchEntity( + TeslemetryPollingVehicleSwitchEntity( + vehicle, description, entry.runtime_data.scopes + ) + if vehicle.api.pre2021 + or vehicle.firmware < description.streaming_firmware + else TeslemetryStreamingVehicleSwitchEntity( vehicle, description, entry.runtime_data.scopes ) for vehicle in entry.runtime_data.vehicles for description in VEHICLE_DESCRIPTIONS - if description.key in vehicle.coordinator.data ), ( TeslemetryChargeFromGridSwitchEntity( @@ -126,15 +152,31 @@ async def async_setup_entry( ) -class TeslemetrySwitchEntity(SwitchEntity): +class TeslemetryVehicleSwitchEntity(TeslemetryRootEntity, SwitchEntity): """Base class for all Teslemetry switch entities.""" _attr_device_class = SwitchDeviceClass.SWITCH entity_description: TeslemetrySwitchEntityDescription + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on the Switch.""" + self.raise_for_scope(self.entity_description.scopes[0]) + await handle_vehicle_command(self.entity_description.on_func(self.api)) + self._attr_is_on = True + self.async_write_ha_state() -class TeslemetryVehicleSwitchEntity(TeslemetryVehicleEntity, TeslemetrySwitchEntity): - """Base class for Teslemetry vehicle switch entities.""" + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off the Switch.""" + self.raise_for_scope(self.entity_description.scopes[0]) + await handle_vehicle_command(self.entity_description.off_func(self.api)) + self._attr_is_on = False + self.async_write_ha_state() + + +class TeslemetryPollingVehicleSwitchEntity( + TeslemetryVehicleEntity, TeslemetryVehicleSwitchEntity +): + """Base class for Teslemetry polling vehicle switch entities.""" def __init__( self, @@ -151,30 +193,63 @@ class TeslemetryVehicleSwitchEntity(TeslemetryVehicleEntity, TeslemetrySwitchEnt def _async_update_attrs(self) -> None: """Update the attributes of the sensor.""" - self._attr_is_on = self.entity_description.value_func(self._value) - - async def async_turn_on(self, **kwargs: Any) -> None: - """Turn on the Switch.""" - self.raise_for_scope(self.entity_description.scopes[0]) - await self.wake_up_if_asleep() - await handle_vehicle_command(self.entity_description.on_func(self.api)) - self._attr_is_on = True - self.async_write_ha_state() - - async def async_turn_off(self, **kwargs: Any) -> None: - """Turn off the Switch.""" - self.raise_for_scope(self.entity_description.scopes[0]) - await self.wake_up_if_asleep() - await handle_vehicle_command(self.entity_description.off_func(self.api)) - self._attr_is_on = False - self.async_write_ha_state() + self._attr_is_on = ( + None + if self._value is None + else self.entity_description.value_func(self._value) + ) -class TeslemetryChargeFromGridSwitchEntity( - TeslemetryEnergyInfoEntity, TeslemetrySwitchEntity +class TeslemetryStreamingVehicleSwitchEntity( + TeslemetryVehicleStreamEntity, TeslemetryVehicleSwitchEntity, RestoreEntity ): + """Base class for Teslemetry streaming vehicle switch entities.""" + + def __init__( + self, + data: TeslemetryVehicleData, + description: TeslemetrySwitchEntityDescription, + scopes: list[Scope], + ) -> None: + """Initialize the Switch.""" + + self.entity_description = description + self.scoped = any(scope in scopes for scope in description.scopes) + super().__init__(data, description.key) + if description.unique_id: + self._attr_unique_id = f"{data.vin}-{description.unique_id}" + + async def async_added_to_hass(self) -> None: + """Handle entity which will be added.""" + await super().async_added_to_hass() + + # Restore previous state + if (state := await self.async_get_last_state()) is not None: + if state.state == "on": + self._attr_is_on = True + elif state.state == "off": + self._attr_is_on = False + + # Add listener + self.async_on_remove( + self.entity_description.streaming_listener( + self.vehicle.stream_vehicle, self._value_callback + ) + ) + + def _value_callback(self, value: StateType) -> None: + """Update the value of the entity.""" + self._attr_is_on = ( + None if value is None else self.entity_description.streaming_value_fn(value) + ) + self.async_write_ha_state() + + +class TeslemetryChargeFromGridSwitchEntity(TeslemetryEnergyInfoEntity, SwitchEntity): """Entity class for Charge From Grid switch.""" + _attr_device_class = SwitchDeviceClass.SWITCH + def __init__( self, data: TeslemetryEnergyData, @@ -215,11 +290,11 @@ class TeslemetryChargeFromGridSwitchEntity( self.async_write_ha_state() -class TeslemetryStormModeSwitchEntity( - TeslemetryEnergyInfoEntity, TeslemetrySwitchEntity -): +class TeslemetryStormModeSwitchEntity(TeslemetryEnergyInfoEntity, SwitchEntity): """Entity class for Storm Mode switch.""" + _attr_device_class = SwitchDeviceClass.SWITCH + def __init__( self, data: TeslemetryEnergyData, diff --git a/tests/components/teslemetry/snapshots/test_switch.ambr b/tests/components/teslemetry/snapshots/test_switch.ambr index f9997133044..0586b454a91 100644 --- a/tests/components/teslemetry/snapshots/test_switch.ambr +++ b/tests/components/teslemetry/snapshots/test_switch.ambr @@ -495,3 +495,21 @@ 'state': 'off', }) # --- +# name: test_switch_streaming[switch.test_auto_seat_climate_left] + 'on' +# --- +# name: test_switch_streaming[switch.test_auto_seat_climate_right] + 'off' +# --- +# name: test_switch_streaming[switch.test_auto_steering_wheel_heater] + 'on' +# --- +# name: test_switch_streaming[switch.test_charge] + 'on' +# --- +# name: test_switch_streaming[switch.test_defrost] + 'off' +# --- +# name: test_switch_streaming[switch.test_sentry_mode] + 'on' +# --- diff --git a/tests/components/teslemetry/test_switch.py b/tests/components/teslemetry/test_switch.py index 6a1ddb430ce..17522f0ce2a 100644 --- a/tests/components/teslemetry/test_switch.py +++ b/tests/components/teslemetry/test_switch.py @@ -4,6 +4,7 @@ from unittest.mock import AsyncMock, patch import pytest from syrupy.assertion import SnapshotAssertion +from teslemetry_stream import Signal from homeassistant.components.switch import ( DOMAIN as SWITCH_DOMAIN, @@ -14,7 +15,7 @@ from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from . import assert_entities, assert_entities_alt, setup_platform +from . import assert_entities, assert_entities_alt, reload_platform, setup_platform from .const import COMMAND_OK, VEHICLE_DATA_ALT @@ -22,6 +23,7 @@ async def test_switch( hass: HomeAssistant, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, + mock_legacy: AsyncMock, ) -> None: """Tests that the switch entities are correct.""" @@ -34,6 +36,7 @@ async def test_switch_alt( snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, mock_vehicle_data: AsyncMock, + mock_legacy: AsyncMock, ) -> None: """Tests that the switch entities are correct.""" @@ -119,3 +122,47 @@ async def test_switch_services( state = hass.states.get(entity_id) assert state.state == STATE_OFF call.assert_called_once() + + +async def test_switch_streaming( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + mock_vehicle_data: AsyncMock, + mock_add_listener: AsyncMock, +) -> None: + """Tests that the switch entities with streaming are correct.""" + + entry = await setup_platform(hass, [Platform.SWITCH]) + + # Stream update + mock_add_listener.send( + { + "vin": VEHICLE_DATA_ALT["response"]["vin"], + "data": { + Signal.SENTRY_MODE: "SentryModeStateIdle", + Signal.AUTO_SEAT_CLIMATE_LEFT: True, + Signal.AUTO_SEAT_CLIMATE_RIGHT: False, + Signal.HVAC_STEERING_WHEEL_HEAT_AUTO: True, + Signal.DEFROST_MODE: "DefrostModeStateOff", + Signal.DETAILED_CHARGE_STATE: "DetailedChargeStateCharging", + }, + "createdAt": "2024-10-04T10:45:17.537Z", + } + ) + await hass.async_block_till_done() + + # Reload the entry + await reload_platform(hass, entry, [Platform.SWITCH]) + + # Assert the entities restored their values + for entity_id in ( + "switch.test_sentry_mode", + "switch.test_auto_seat_climate_left", + "switch.test_auto_seat_climate_right", + "switch.test_auto_steering_wheel_heater", + "switch.test_defrost", + "switch.test_charge", + ): + state = hass.states.get(entity_id) + assert state.state == snapshot(name=entity_id) From 220bd5a27fef98fd5490393a5c0086b27e2eb4b9 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Fri, 14 Mar 2025 23:48:17 +1000 Subject: [PATCH 2608/3148] Fix time to full charge in Teslemetry (#137996) * Fix streaming full charge * ruff --- homeassistant/components/teslemetry/sensor.py | 34 ++++++++++++------- tests/components/teslemetry/test_sensor.py | 2 +- 2 files changed, 23 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/teslemetry/sensor.py b/homeassistant/components/teslemetry/sensor.py index f1859ad39de..b1c6b487bf9 100644 --- a/homeassistant/components/teslemetry/sensor.py +++ b/homeassistant/components/teslemetry/sensor.py @@ -7,7 +7,7 @@ from dataclasses import dataclass from datetime import datetime, timedelta from propcache.api import cached_property -from teslemetry_stream import Signal +from teslemetry_stream import Signal, TeslemetryStreamVehicle from teslemetry_stream.const import ShiftState from homeassistant.components.sensor import ( @@ -50,6 +50,7 @@ from .models import TeslemetryEnergyData, TeslemetryVehicleData PARALLEL_UPDATES = 0 + CHARGE_STATES = { "Starting": "starting", "Charging": "charging", @@ -350,21 +351,26 @@ class TeslemetryTimeEntityDescription(SensorEntityDescription): """Describes Teslemetry Sensor entity.""" variance: int - streaming_key: Signal + streaming_listener: Callable[ + [TeslemetryStreamVehicle, Callable[[float | None], None]], + Callable[[], None], + ] streaming_firmware: str = "2024.26" + streaming_value_fn: Callable[[float], float] = lambda x: x VEHICLE_TIME_DESCRIPTIONS: tuple[TeslemetryTimeEntityDescription, ...] = ( TeslemetryTimeEntityDescription( key="charge_state_minutes_to_full_charge", - streaming_key=Signal.TIME_TO_FULL_CHARGE, + streaming_value_fn=lambda x: x * 60, + streaming_listener=lambda x, y: x.listen_TimeToFullCharge(y), device_class=SensorDeviceClass.TIMESTAMP, entity_category=EntityCategory.DIAGNOSTIC, variance=4, ), TeslemetryTimeEntityDescription( key="drive_state_active_route_minutes_to_arrival", - streaming_key=Signal.MINUTES_TO_ARRIVAL, + streaming_listener=lambda x, y: x.listen_MinutesToArrival(y), device_class=SensorDeviceClass.TIMESTAMP, variance=1, ), @@ -667,18 +673,22 @@ class TeslemetryStreamTimeSensorEntity(TeslemetryVehicleStreamEntity, SensorEnti """Initialize the sensor.""" self.entity_description = description self._get_timestamp = ignore_variance( - func=lambda value: dt_util.now() + timedelta(minutes=value), + func=lambda value: dt_util.now() + + timedelta(minutes=description.streaming_value_fn(value)), ignored_variance=timedelta(minutes=description.variance), ) - assert description.streaming_key - super().__init__(data, description.key, description.streaming_key) + super().__init__(data, description.key) - @cached_property - def available(self) -> bool: - """Return True if entity is available.""" - return self.stream.connected + async def async_added_to_hass(self) -> None: + """When entity is added to hass.""" + await super().async_added_to_hass() + self.async_on_remove( + self.entity_description.streaming_listener( + self.vehicle.stream_vehicle, self._value_callback + ) + ) - def _async_value_from_stream(self, value) -> None: + def _value_callback(self, value: float | None) -> None: """Update the value of the entity.""" if value is None: self._attr_native_value = None diff --git a/tests/components/teslemetry/test_sensor.py b/tests/components/teslemetry/test_sensor.py index a488ebc8a06..c3c2252ab89 100644 --- a/tests/components/teslemetry/test_sensor.py +++ b/tests/components/teslemetry/test_sensor.py @@ -72,7 +72,7 @@ async def test_sensors_streaming( Signal.AC_CHARGING_ENERGY_IN: 10, Signal.AC_CHARGING_POWER: 2, Signal.CHARGING_CABLE_TYPE: None, - Signal.TIME_TO_FULL_CHARGE: 10, + Signal.TIME_TO_FULL_CHARGE: 0.166666667, Signal.MINUTES_TO_ARRIVAL: None, }, "createdAt": "2024-10-04T10:45:17.537Z", From 7ff842fc372400074984f92f0fb1cf558dd1415d Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Fri, 14 Mar 2025 09:55:18 -0400 Subject: [PATCH 2609/3148] Add dynamic update interval to Roborock (#140563) * Add dynamic update interval to Roborock * mr comments * update time intervals * Set A01 to 1 minute * set interval to 30 --- homeassistant/components/roborock/const.py | 13 ++- .../components/roborock/coordinator.py | 26 ++++- .../components/roborock/quality_scale.yaml | 7 +- tests/components/roborock/test_coordinator.py | 107 ++++++++++++++++++ 4 files changed, 142 insertions(+), 11 deletions(-) create mode 100644 tests/components/roborock/test_coordinator.py diff --git a/homeassistant/components/roborock/const.py b/homeassistant/components/roborock/const.py index 5a725ff5586..4e2588c9478 100644 --- a/homeassistant/components/roborock/const.py +++ b/homeassistant/components/roborock/const.py @@ -1,5 +1,7 @@ """Constants for Roborock.""" +from datetime import timedelta + from vacuum_map_parser_base.config.drawable import Drawable from homeassistant.const import Platform @@ -43,8 +45,8 @@ PLATFORMS = [ Platform.VACUUM, ] - -IMAGE_CACHE_INTERVAL = 90 +# This can be lowered in the future if we do not receive rate limiting issues. +IMAGE_CACHE_INTERVAL = 30 MAP_SLEEP = 3 @@ -54,3 +56,10 @@ MAP_FILE_FORMAT = "PNG" MAP_FILENAME_SUFFIX = ".png" SET_VACUUM_GOTO_POSITION_SERVICE_NAME = "set_vacuum_goto_position" GET_VACUUM_CURRENT_POSITION_SERVICE_NAME = "get_vacuum_current_position" + + +A01_UPDATE_INTERVAL = timedelta(minutes=1) +V1_CLOUD_IN_CLEANING_INTERVAL = timedelta(seconds=30) +V1_CLOUD_NOT_CLEANING_INTERVAL = timedelta(minutes=1) +V1_LOCAL_IN_CLEANING_INTERVAL = timedelta(seconds=15) +V1_LOCAL_NOT_CLEANING_INTERVAL = timedelta(seconds=30) diff --git a/homeassistant/components/roborock/coordinator.py b/homeassistant/components/roborock/coordinator.py index 1ab23fc927a..c94fb785079 100644 --- a/homeassistant/components/roborock/coordinator.py +++ b/homeassistant/components/roborock/coordinator.py @@ -36,7 +36,14 @@ from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import slugify -from .const import DOMAIN +from .const import ( + A01_UPDATE_INTERVAL, + DOMAIN, + V1_CLOUD_IN_CLEANING_INTERVAL, + V1_CLOUD_NOT_CLEANING_INTERVAL, + V1_LOCAL_IN_CLEANING_INTERVAL, + V1_LOCAL_NOT_CLEANING_INTERVAL, +) from .models import RoborockA01HassDeviceInfo, RoborockHassDeviceInfo, RoborockMapInfo from .roborock_storage import RoborockMapStorage @@ -85,7 +92,8 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): _LOGGER, config_entry=config_entry, name=DOMAIN, - update_interval=SCAN_INTERVAL, + # Assume we can use the local api. + update_interval=V1_LOCAL_NOT_CLEANING_INTERVAL, ) self.roborock_device_info = RoborockHassDeviceInfo( device, @@ -118,6 +126,7 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): ) self._user_data = user_data self._api_client = api_client + self._is_cloud_api = False async def _async_setup(self) -> None: """Set up the coordinator.""" @@ -152,6 +161,8 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): await self.api.async_disconnect() # We use the cloud api if the local api fails to connect. self.api = self.cloud_api + self.update_interval = V1_CLOUD_NOT_CLEANING_INTERVAL + self._is_cloud_api = True # Right now this should never be called if the cloud api is the primary api, # but in the future if it is, a new else should be added. @@ -181,6 +192,15 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): except RoborockException as ex: _LOGGER.debug("Failed to update data: %s", ex) raise UpdateFailed(ex) from ex + if self.roborock_device_info.props.status.in_cleaning: + if self._is_cloud_api: + self.update_interval = V1_CLOUD_IN_CLEANING_INTERVAL + else: + self.update_interval = V1_LOCAL_IN_CLEANING_INTERVAL + elif self._is_cloud_api: + self.update_interval = V1_CLOUD_NOT_CLEANING_INTERVAL + else: + self.update_interval = V1_LOCAL_NOT_CLEANING_INTERVAL return self.roborock_device_info.props def _set_current_map(self) -> None: @@ -269,7 +289,7 @@ class RoborockDataUpdateCoordinatorA01( _LOGGER, config_entry=config_entry, name=DOMAIN, - update_interval=SCAN_INTERVAL, + update_interval=A01_UPDATE_INTERVAL, ) self.api = api self.device_info = DeviceInfo( diff --git a/homeassistant/components/roborock/quality_scale.yaml b/homeassistant/components/roborock/quality_scale.yaml index 1077888ed14..2cf664beb40 100644 --- a/homeassistant/components/roborock/quality_scale.yaml +++ b/homeassistant/components/roborock/quality_scale.yaml @@ -1,12 +1,7 @@ rules: # Bronze action-setup: done - appropriate-polling: - status: todo - comment: | - The device currently polls every 30 seconds, which is a bit high when idle. - We should consider dynamic polling intervals (e.g. when cleaning) and - separate cloud vs local intervals. + appropriate-polling: done brands: done common-modules: done config-flow: done diff --git a/tests/components/roborock/test_coordinator.py b/tests/components/roborock/test_coordinator.py new file mode 100644 index 00000000000..94976ba92f5 --- /dev/null +++ b/tests/components/roborock/test_coordinator.py @@ -0,0 +1,107 @@ +"""Test Roborock Coordinator specific logic.""" + +import copy +from datetime import timedelta +from unittest.mock import patch + +import pytest +from roborock.exceptions import RoborockException + +from homeassistant.components.roborock.const import ( + V1_CLOUD_IN_CLEANING_INTERVAL, + V1_CLOUD_NOT_CLEANING_INTERVAL, + V1_LOCAL_IN_CLEANING_INTERVAL, + V1_LOCAL_NOT_CLEANING_INTERVAL, +) +from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util + +from .mock_data import PROP + +from tests.common import MockConfigEntry, async_fire_time_changed + + +@pytest.mark.parametrize( + ("interval", "in_cleaning"), + [ + (V1_CLOUD_IN_CLEANING_INTERVAL, 1), + (V1_CLOUD_NOT_CLEANING_INTERVAL, 0), + ], +) +async def test_dynamic_cloud_scan_interval( + hass: HomeAssistant, + mock_roborock_entry: MockConfigEntry, + bypass_api_fixture_v1_only, + interval: timedelta, + in_cleaning: int, +) -> None: + """Test dynamic scan interval.""" + prop = copy.deepcopy(PROP) + prop.status.in_cleaning = in_cleaning + with ( + # Force the system to use the cloud api. + patch( + "homeassistant.components.roborock.coordinator.RoborockLocalClientV1.ping", + side_effect=RoborockException(), + ), + patch( + "homeassistant.components.roborock.RoborockMqttClientV1.get_prop", + return_value=prop, + ), + ): + await hass.config_entries.async_setup(mock_roborock_entry.entry_id) + assert hass.states.get("sensor.roborock_s7_maxv_battery").state == "100" + prop = copy.deepcopy(prop) + prop.status.battery = 20 + with patch( + "homeassistant.components.roborock.RoborockMqttClientV1.get_prop", + return_value=prop, + ): + async_fire_time_changed( + hass, dt_util.utcnow() + interval - timedelta(seconds=5) + ) + assert hass.states.get("sensor.roborock_s7_maxv_battery").state == "100" + async_fire_time_changed(hass, dt_util.utcnow() + interval) + + assert hass.states.get("sensor.roborock_s7_maxv_battery").state == "20" + + +@pytest.mark.parametrize( + ("interval", "in_cleaning"), + [ + (V1_LOCAL_IN_CLEANING_INTERVAL, 1), + (V1_LOCAL_NOT_CLEANING_INTERVAL, 0), + ], +) +async def test_dynamic_local_scan_interval( + hass: HomeAssistant, + mock_roborock_entry: MockConfigEntry, + bypass_api_fixture_v1_only, + interval: timedelta, + in_cleaning: int, +) -> None: + """Test dynamic scan interval.""" + prop = copy.deepcopy(PROP) + prop.status.in_cleaning = in_cleaning + with ( + patch( + "homeassistant.components.roborock.coordinator.RoborockLocalClientV1.get_prop", + return_value=prop, + ), + ): + await hass.config_entries.async_setup(mock_roborock_entry.entry_id) + assert hass.states.get("sensor.roborock_s7_maxv_battery").state == "100" + prop = copy.deepcopy(prop) + prop.status.battery = 20 + with patch( + "homeassistant.components.roborock.coordinator.RoborockLocalClientV1.get_prop", + return_value=prop, + ): + async_fire_time_changed( + hass, dt_util.utcnow() + interval - timedelta(seconds=5) + ) + assert hass.states.get("sensor.roborock_s7_maxv_battery").state == "100" + + async_fire_time_changed(hass, dt_util.utcnow() + interval) + + assert hass.states.get("sensor.roborock_s7_maxv_battery").state == "20" From a8f1df3e55441969d32d7c1d71a6b727df929a37 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Fri, 14 Mar 2025 14:56:27 +0100 Subject: [PATCH 2610/3148] Add availability support for MQTT subentries (#138673) * Add availability support for MQTT subentries * Update homeassistant/components/mqtt/config_flow.py Co-authored-by: Erik Montnemery * Update homeassistant/components/mqtt/config_flow.py Co-authored-by: Erik Montnemery * Update homeassistant/components/mqtt/config_flow.py Co-authored-by: Erik Montnemery * Update homeassistant/components/mqtt/strings.json Co-authored-by: Erik Montnemery --------- Co-authored-by: Erik Montnemery --- homeassistant/components/mqtt/config_flow.py | 63 +++++++++- homeassistant/components/mqtt/entity.py | 2 + homeassistant/components/mqtt/models.py | 12 +- homeassistant/components/mqtt/strings.json | 17 +++ tests/components/mqtt/common.py | 13 +- tests/components/mqtt/test_config_flow.py | 122 +++++++++++++++++++ tests/components/mqtt/test_mixins.py | 14 +++ 7 files changed, 238 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/mqtt/config_flow.py b/homeassistant/components/mqtt/config_flow.py index 8922b059a23..8dfccbb6b2a 100644 --- a/homeassistant/components/mqtt/config_flow.py +++ b/homeassistant/components/mqtt/config_flow.py @@ -88,6 +88,8 @@ from .const import ( ATTR_QOS, ATTR_RETAIN, ATTR_TOPIC, + CONF_AVAILABILITY_TEMPLATE, + CONF_AVAILABILITY_TOPIC, CONF_BIRTH_MESSAGE, CONF_BROKER, CONF_CERTIFICATE, @@ -98,6 +100,8 @@ from .const import ( CONF_DISCOVERY_PREFIX, CONF_ENTITY_PICTURE, CONF_KEEPALIVE, + CONF_PAYLOAD_AVAILABLE, + CONF_PAYLOAD_NOT_AVAILABLE, CONF_QOS, CONF_RETAIN, CONF_TLS_INSECURE, @@ -111,6 +115,8 @@ from .const import ( DEFAULT_DISCOVERY, DEFAULT_ENCODING, DEFAULT_KEEPALIVE, + DEFAULT_PAYLOAD_AVAILABLE, + DEFAULT_PAYLOAD_NOT_AVAILABLE, DEFAULT_PORT, DEFAULT_PREFIX, DEFAULT_PROTOCOL, @@ -123,13 +129,15 @@ from .const import ( TRANSPORT_WEBSOCKETS, Platform, ) -from .models import MqttDeviceData, MqttSubentryData +from .models import MqttAvailabilityData, MqttDeviceData, MqttSubentryData from .util import ( async_create_certificate_temp_files, get_file_path, valid_birth_will, valid_publish_topic, valid_qos_schema, + valid_subscribe_topic, + valid_subscribe_topic_template, ) _LOGGER = logging.getLogger(__name__) @@ -220,6 +228,19 @@ SUBENTRY_PLATFORM_SELECTOR = SelectSelector( TEMPLATE_SELECTOR = TemplateSelector(TemplateSelectorConfig()) +SUBENTRY_AVAILABILITY_SCHEMA = vol.Schema( + { + vol.Optional(CONF_AVAILABILITY_TOPIC): TEXT_SELECTOR, + vol.Optional(CONF_AVAILABILITY_TEMPLATE): TEMPLATE_SELECTOR, + vol.Optional( + CONF_PAYLOAD_AVAILABLE, default=DEFAULT_PAYLOAD_AVAILABLE + ): TEXT_SELECTOR, + vol.Optional( + CONF_PAYLOAD_NOT_AVAILABLE, default=DEFAULT_PAYLOAD_NOT_AVAILABLE + ): TEXT_SELECTOR, + } +) + @dataclass(frozen=True) class PlatformField: @@ -1085,6 +1106,44 @@ class MQTTSubentryFlowHandler(ConfigSubentryFlow): }, ) + async def async_step_availability( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Configure availability options.""" + errors: dict[str, str] = {} + validate_field( + "availability_topic", + valid_subscribe_topic, + user_input, + errors, + "invalid_subscribe_topic", + ) + validate_field( + "availability_template", + valid_subscribe_topic_template, + user_input, + errors, + "invalid_template", + ) + if not errors and user_input is not None: + self._subentry_data.setdefault("availability", MqttAvailabilityData()) + self._subentry_data["availability"] = cast(MqttAvailabilityData, user_input) + return await self.async_step_summary_menu() + + data_schema = SUBENTRY_AVAILABILITY_SCHEMA + data_schema = self.add_suggested_values_to_schema( + data_schema, + dict(self._subentry_data.setdefault("availability", {})) + if self.source == SOURCE_RECONFIGURE + else user_input, + ) + return self.async_show_form( + step_id="availability", + data_schema=data_schema, + errors=errors, + last_step=False, + ) + async def async_step_summary_menu( self, user_input: dict[str, Any] | None = None ) -> SubentryFlowResult: @@ -1101,7 +1160,7 @@ class MQTTSubentryFlowHandler(ConfigSubentryFlow): ] if len(self._subentry_data["components"]) > 1: menu_options.append("delete_entity") - menu_options.append("device") + menu_options.extend(["device", "availability"]) if self._subentry_data != self._get_reconfigure_subentry().data: menu_options.append("save_changes") return self.async_show_menu( diff --git a/homeassistant/components/mqtt/entity.py b/homeassistant/components/mqtt/entity.py index df6a904fab2..0b4f65fab47 100644 --- a/homeassistant/components/mqtt/entity.py +++ b/homeassistant/components/mqtt/entity.py @@ -297,6 +297,7 @@ def async_setup_entity_entry_helper( # process subentry entity setup for config_subentry_id, subentry in entry.subentries.items(): subentry_data = cast(MqttSubentryData, subentry.data) + availability_config = subentry_data.get("availability", {}) subentry_entities: list[Entity] = [] device_config = subentry_data["device"].copy() device_config["identifiers"] = config_subentry_id @@ -309,6 +310,7 @@ def async_setup_entity_entry_helper( ) component_config[CONF_DEVICE] = device_config component_config.pop("platform") + component_config.update(availability_config) try: config = platform_schema_modern(component_config) diff --git a/homeassistant/components/mqtt/models.py b/homeassistant/components/mqtt/models.py index 5bbd7967ad8..bcfe94bbd58 100644 --- a/homeassistant/components/mqtt/models.py +++ b/homeassistant/components/mqtt/models.py @@ -432,11 +432,21 @@ class MqttDeviceData(TypedDict, total=False): model_id: str -class MqttSubentryData(TypedDict): +class MqttAvailabilityData(TypedDict, total=False): + """Hold the availability configuration for a device.""" + + availability_topic: str + availability_template: str + payload_available: str + payload_not_available: str + + +class MqttSubentryData(TypedDict, total=False): """Hold the data for a MQTT subentry.""" device: MqttDeviceData components: dict[str, dict[str, Any]] + availability: MqttAvailabilityData DATA_MQTT: HassKey[MqttData] = HassKey("mqtt") diff --git a/homeassistant/components/mqtt/strings.json b/homeassistant/components/mqtt/strings.json index 13595c2d462..c3338948ff5 100644 --- a/homeassistant/components/mqtt/strings.json +++ b/homeassistant/components/mqtt/strings.json @@ -116,6 +116,22 @@ }, "entry_type": "MQTT Device", "step": { + "availability": { + "title": "Availability options", + "description": "The availability feature allows a device to report it's availability.", + "data": { + "availability_topic": "Availability topic", + "availability_template": "Availability template", + "payload_available": "Payload available", + "payload_not_available": "Payload not available" + }, + "data_description": { + "availability_topic": "Topic to receive the availabillity payload on", + "availability_template": "A [template](https://www.home-assistant.io/docs/configuration/templating/#using-templates-with-the-mqtt-integration) to render the availability payload received on the availability topic", + "payload_available": "The payload that indicates the device is available (defaults to 'online')", + "payload_not_available": "The payload that indicates the device is not available (defaults to 'offline')" + } + }, "device": { "title": "Configure MQTT device details", "description": "Enter the MQTT device details:", @@ -143,6 +159,7 @@ "entity": "Add another entity to \"{mqtt_device}\"", "update_entity": "Update entity properties", "delete_entity": "Delete an entity", + "availability": "Configure availability", "device": "Update device properties", "save_changes": "Save changes" } diff --git a/tests/components/mqtt/common.py b/tests/components/mqtt/common.py index 55458b9e4c8..f000c4e0b9b 100644 --- a/tests/components/mqtt/common.py +++ b/tests/components/mqtt/common.py @@ -119,6 +119,15 @@ MOCK_SUBENTRY_NOTIFY_BAD_SCHEMA = { }, } +MOCK_SUBENTRY_AVAILABILITY_DATA = { + "availability": { + "availability_topic": "test/availability", + "availability_template": "{{ value_json.availability }}", + "payload_available": "online", + "payload_not_available": "offline", + } +} + MOCK_NOTIFY_SUBENTRY_DATA_MULTI = { "device": { "name": "Milk notifier", @@ -129,7 +138,7 @@ MOCK_NOTIFY_SUBENTRY_DATA_MULTI = { "configuration_url": "https://example.com", }, "components": MOCK_SUBENTRY_NOTIFY_COMPONENT1 | MOCK_SUBENTRY_NOTIFY_COMPONENT2, -} +} | MOCK_SUBENTRY_AVAILABILITY_DATA MOCK_NOTIFY_SUBENTRY_DATA_SINGLE = { "device": { @@ -177,7 +186,7 @@ MOCK_SUBENTRY_DATA_SET_MIX = { "components": MOCK_SUBENTRY_NOTIFY_COMPONENT1 | MOCK_SUBENTRY_NOTIFY_COMPONENT2 | MOCK_SUBENTRY_LIGHT_COMPONENT, -} +} | MOCK_SUBENTRY_AVAILABILITY_DATA _SENTINEL = object() DISCOVERY_COUNT = sum(len(discovery_topic) for discovery_topic in MQTT.values()) diff --git a/tests/components/mqtt/test_config_flow.py b/tests/components/mqtt/test_config_flow.py index 9007c49635b..354cb33ba39 100644 --- a/tests/components/mqtt/test_config_flow.py +++ b/tests/components/mqtt/test_config_flow.py @@ -2821,6 +2821,7 @@ async def test_subentry_reconfigure_remove_entity( "update_entity", "delete_entity", "device", + "availability", ] # assert we can delete an entity @@ -2849,6 +2850,7 @@ async def test_subentry_reconfigure_remove_entity( "entity", "update_entity", "device", + "availability", "save_changes", ] @@ -2938,6 +2940,7 @@ async def test_subentry_reconfigure_edit_entity_multi_entitites( "update_entity", "delete_entity", "device", + "availability", ] # assert we can update an entity @@ -3061,6 +3064,7 @@ async def test_subentry_reconfigure_edit_entity_single_entity( "entity", "update_entity", "device", + "availability", ] # assert we can update the entity, there is no select step @@ -3174,6 +3178,7 @@ async def test_subentry_reconfigure_add_entity( "entity", "update_entity", "device", + "availability", ] # assert we can update the entity, there is no select step @@ -3272,6 +3277,7 @@ async def test_subentry_reconfigure_update_device_properties( "update_entity", "delete_entity", "device", + "availability", ] # assert we can update the device properties @@ -3310,3 +3316,119 @@ async def test_subentry_reconfigure_update_device_properties( assert "hw_version" not in device assert device["model"] == "Beer bottle XL" assert device["model_id"] == "bn003" + + +@pytest.mark.parametrize( + "mqtt_config_subentries_data", + [ + ( + ConfigSubentryData( + data=MOCK_NOTIFY_SUBENTRY_DATA_MULTI, + subentry_type="device", + title="Mock subentry", + ), + ) + ], +) +async def test_subentry_reconfigure_availablity( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, +) -> None: + """Test the subentry ConfigFlow reconfigure and update device properties.""" + await mqtt_mock_entry() + config_entry: MockConfigEntry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] + subentry_id: str + subentry: ConfigSubentry + subentry_id, subentry = next(iter(config_entry.subentries.items())) + + expected_availability = { + "availability_topic": "test/availability", + "availability_template": "{{ value_json.availability }}", + "payload_available": "online", + "payload_not_available": "offline", + } + assert subentry.data.get("availability") == expected_availability + + result = await config_entry.start_subentry_reconfigure_flow( + hass, "device", subentry_id + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "summary_menu" + + # assert we can set the availability config + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {"next_step_id": "availability"}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "availability" + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={ + "availability_topic": "test/new_availability#invalid_topic", + "payload_available": "1", + "payload_not_available": "0", + }, + ) + assert result["errors"] == {"availability_topic": "invalid_subscribe_topic"} + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={ + "availability_topic": "test/new_availability", + "payload_available": "1", + "payload_not_available": "0", + }, + ) + + # finish reconfigure flow + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {"next_step_id": "save_changes"}, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + # Check the availability was updated + expected_availability = { + "availability_topic": "test/new_availability", + "payload_available": "1", + "payload_not_available": "0", + } + assert subentry.data.get("availability") == expected_availability + + # Assert we can reset the availability config + result = await config_entry.start_subentry_reconfigure_flow( + hass, "device", subentry_id + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "summary_menu" + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {"next_step_id": "availability"}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "availability" + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={ + "payload_available": "1", + "payload_not_available": "0", + }, + ) + + # Finish reconfigure flow + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {"next_step_id": "save_changes"}, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + # Check the availability was updated + assert subentry.data.get("availability") == { + "payload_available": "1", + "payload_not_available": "0", + } diff --git a/tests/components/mqtt/test_mixins.py b/tests/components/mqtt/test_mixins.py index ecc045b3871..2049dec0437 100644 --- a/tests/components/mqtt/test_mixins.py +++ b/tests/components/mqtt/test_mixins.py @@ -501,6 +501,20 @@ async def test_loading_subentries( assert entity_entry_entity_id == entity_id state = hass.states.get(entity_id) assert state is not None + assert ( + state.attributes.get("entity_picture") == f"https://example.com/{object_id}" + ) + # Availability was configured, so entities are unavailable + assert state.state == "unavailable" + + # Make entities available + async_fire_mqtt_message(hass, "test/availability", '{"availability": "online"}') + for component in mqtt_config_subentries_data[0]["data"]["components"].values(): + platform = component["platform"] + entity_id = f"{platform}.{slugify(device.name)}_{slugify(component['name'])}" + state = hass.states.get(entity_id) + assert state is not None + assert state.state == "unknown" @pytest.mark.parametrize( From 1bd8ff884e07129ccd2befe3bc2c1d87af377405 Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Fri, 14 Mar 2025 09:58:55 -0400 Subject: [PATCH 2611/3148] Improve Snoo testing (#139302) * improve snoo testing * change to asyncMock method of testing * Apply suggestions from code review Co-authored-by: Joost Lekkerkerker * address comments * Apply suggestions from code review Co-authored-by: Joost Lekkerkerker * adress comments --------- Co-authored-by: Joost Lekkerkerker --- tests/components/snoo/__init__.py | 16 +++++++ tests/components/snoo/conftest.py | 58 ++++------------------- tests/components/snoo/const.py | 37 +++++++++++++++ tests/components/snoo/test_config_flow.py | 13 ++--- tests/components/snoo/test_init.py | 22 ++++++++- tests/components/snoo/test_sensor.py | 22 +++++++++ 6 files changed, 108 insertions(+), 60 deletions(-) create mode 100644 tests/components/snoo/test_sensor.py diff --git a/tests/components/snoo/__init__.py b/tests/components/snoo/__init__.py index f8529251720..b4692e6f08b 100644 --- a/tests/components/snoo/__init__.py +++ b/tests/components/snoo/__init__.py @@ -1,5 +1,11 @@ """Tests for the Happiest Baby Snoo integration.""" +from collections.abc import Awaitable, Callable +from unittest.mock import AsyncMock + +import pytest +from python_snoo.containers import SnooData + from homeassistant.components.snoo.const import DOMAIN from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME @@ -36,3 +42,13 @@ async def async_init_integration(hass: HomeAssistant) -> ConfigEntry: await hass.async_block_till_done() return entry + + +def find_update_callback( + mock: AsyncMock, serial_number: str +) -> Callable[[SnooData], Awaitable[None]]: + """Find the update callback for a specific identifier.""" + for call in mock.subscribe.call_args_list: + if call[0][0].serialNumber == serial_number: + return call[0][1] + pytest.fail(f"Callback for identifier {serial_number} not found") diff --git a/tests/components/snoo/conftest.py b/tests/components/snoo/conftest.py index 33642e67ff5..6163fa56b7f 100644 --- a/tests/components/snoo/conftest.py +++ b/tests/components/snoo/conftest.py @@ -5,9 +5,8 @@ from unittest.mock import AsyncMock, patch import pytest from python_snoo.containers import SnooDevice -from python_snoo.snoo import Snoo -from .const import MOCK_AMAZON_AUTH, MOCK_SNOO_AUTH, MOCK_SNOO_DEVICES +from .const import MOCK_SNOO_DEVICES, MOCKED_AUTH @pytest.fixture @@ -19,55 +18,14 @@ def mock_setup_entry() -> Generator[AsyncMock]: yield mock_setup_entry -class MockedSnoo(Snoo): - """Mock the Snoo object.""" - - def __init__(self, email, password, clientsession) -> None: - """Set up a Mocked Snoo.""" - super().__init__(email, password, clientsession) - self.auth_error = None - - async def subscribe(self, device: SnooDevice, function): - """Mock the subscribe function.""" - return AsyncMock() - - async def send_command(self, command: str, device: SnooDevice, **kwargs): - """Mock the send command function.""" - return AsyncMock() - - async def authorize(self): - """Do normal auth flow unless error is patched.""" - if self.auth_error: - raise self.auth_error - return await super().authorize() - - def set_auth_error(self, error: Exception | None): - """Set an error for authentication.""" - self.auth_error = error - - async def auth_amazon(self): - """Mock the amazon auth.""" - return MOCK_AMAZON_AUTH - - async def auth_snoo(self, id_token): - """Mock the snoo auth.""" - return MOCK_SNOO_AUTH - - async def schedule_reauthorization(self, snoo_expiry: int): - """Mock scheduling reauth.""" - return AsyncMock() - - async def get_devices(self) -> list[SnooDevice]: - """Move getting devices.""" - return [SnooDevice.from_dict(dev) for dev in MOCK_SNOO_DEVICES] - - @pytest.fixture(name="bypass_api") -def bypass_api() -> MockedSnoo: +def bypass_api() -> Generator[AsyncMock]: """Bypass the Snoo api.""" - api = MockedSnoo("email", "password", AsyncMock()) with ( - patch("homeassistant.components.snoo.Snoo", return_value=api), - patch("homeassistant.components.snoo.config_flow.Snoo", return_value=api), + patch("homeassistant.components.snoo.Snoo", autospec=True) as mock_client, + patch("homeassistant.components.snoo.config_flow.Snoo", new=mock_client), ): - yield api + client = mock_client.return_value + client.get_devices.return_value = [SnooDevice.from_dict(MOCK_SNOO_DEVICES[0])] + client.authorize.return_value = MOCKED_AUTH + yield client diff --git a/tests/components/snoo/const.py b/tests/components/snoo/const.py index c5d53780fa1..2657048afb8 100644 --- a/tests/components/snoo/const.py +++ b/tests/components/snoo/const.py @@ -1,5 +1,9 @@ """Snoo constants for testing.""" +import time + +from python_snoo.containers import AuthorizationInfo, SnooData + MOCK_AMAZON_AUTH = { # This is a JWT with random values. "AccessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhMWIyYzNkNC1lNWY2" @@ -32,3 +36,36 @@ MOCK_SNOO_DEVICES = [ "provisionedAt": "random_time", } ] + +MOCK_SNOO_DATA = SnooData.from_dict( + { + "system_state": "normal", + "sw_version": "v1.14.27", + "state_machine": { + "session_id": "0", + "state": "ONLINE", + "is_active_session": "false", + "since_session_start_ms": -1, + "time_left": -1, + "hold": "off", + "weaning": "off", + "audio": "on", + "up_transition": "NONE", + "down_transition": "NONE", + "sticky_white_noise": "off", + }, + "left_safety_clip": 1, + "right_safety_clip": 1, + "event": "status_requested", + "event_time_ms": int(time.time()), + "rx_signal": {"rssi": -45, "strength": 100}, + } +) + + +MOCKED_AUTH = AuthorizationInfo( + snoo=MOCK_SNOO_AUTH, + aws_access=MOCK_AMAZON_AUTH["AccessToken"], + aws_id=MOCK_AMAZON_AUTH["IdToken"], + aws_refresh=MOCK_AMAZON_AUTH["RefreshToken"], +) diff --git a/tests/components/snoo/test_config_flow.py b/tests/components/snoo/test_config_flow.py index ffdfb22142d..9e07f011cd4 100644 --- a/tests/components/snoo/test_config_flow.py +++ b/tests/components/snoo/test_config_flow.py @@ -13,11 +13,10 @@ from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from . import create_entry -from .conftest import MockedSnoo async def test_config_flow_success( - hass: HomeAssistant, mock_setup_entry: AsyncMock, bypass_api: MockedSnoo + hass: HomeAssistant, mock_setup_entry: AsyncMock, bypass_api: AsyncMock ) -> None: """Test we create the entry successfully.""" result = await hass.config_entries.flow.async_init( @@ -55,7 +54,7 @@ async def test_config_flow_success( async def test_form_auth_issues( hass: HomeAssistant, mock_setup_entry: AsyncMock, - bypass_api: MockedSnoo, + bypass_api: AsyncMock, exception, error_msg, ) -> None: @@ -64,7 +63,7 @@ async def test_form_auth_issues( DOMAIN, context={"source": config_entries.SOURCE_USER} ) # Set Authorize to fail. - bypass_api.set_auth_error(exception) + bypass_api.authorize.side_effect = exception result = await hass.config_entries.flow.async_configure( result["flow_id"], { @@ -73,10 +72,9 @@ async def test_form_auth_issues( }, ) # Reset auth back to the original - bypass_api.set_auth_error(None) assert result["type"] == FlowResultType.FORM assert result["errors"] == {"base": error_msg} - + bypass_api.authorize.side_effect = None result = await hass.config_entries.flow.async_configure( result["flow_id"], { @@ -84,7 +82,6 @@ async def test_form_auth_issues( CONF_PASSWORD: "test-password", }, ) - await hass.async_block_till_done() assert result["type"] == FlowResultType.CREATE_ENTRY assert result["title"] == "test-username" @@ -96,7 +93,7 @@ async def test_form_auth_issues( async def test_account_already_configured( - hass: HomeAssistant, mock_setup_entry: AsyncMock, bypass_api + hass: HomeAssistant, mock_setup_entry: AsyncMock, bypass_api: AsyncMock ) -> None: """Ensure we abort if the config flow already exists.""" create_entry(hass) diff --git a/tests/components/snoo/test_init.py b/tests/components/snoo/test_init.py index 06f420b6518..72c4b6fb8ab 100644 --- a/tests/components/snoo/test_init.py +++ b/tests/components/snoo/test_init.py @@ -1,14 +1,32 @@ """Test init for Snoo.""" +from unittest.mock import AsyncMock + +from python_snoo.exceptions import SnooAuthException + +from homeassistant.components.snoo import SnooDeviceError from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from . import async_init_integration -from .conftest import MockedSnoo -async def test_async_setup_entry(hass: HomeAssistant, bypass_api: MockedSnoo) -> None: +async def test_async_setup_entry(hass: HomeAssistant, bypass_api: AsyncMock) -> None: """Test a successful setup entry.""" entry = await async_init_integration(hass) assert len(hass.states.async_all("sensor")) == 2 assert entry.state == ConfigEntryState.LOADED + + +async def test_cannot_auth(hass: HomeAssistant, bypass_api: AsyncMock) -> None: + """Test that we are put into retry when we fail to auth.""" + bypass_api.authorize.side_effect = SnooAuthException + entry = await async_init_integration(hass) + assert entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_failed_devices(hass: HomeAssistant, bypass_api: AsyncMock) -> None: + """Test that we are put into retry when we fail to get devices.""" + bypass_api.get_devices.side_effect = SnooDeviceError + entry = await async_init_integration(hass) + assert entry.state is ConfigEntryState.SETUP_RETRY diff --git a/tests/components/snoo/test_sensor.py b/tests/components/snoo/test_sensor.py new file mode 100644 index 00000000000..96a22e548b8 --- /dev/null +++ b/tests/components/snoo/test_sensor.py @@ -0,0 +1,22 @@ +"""Test Snoo Sensors.""" + +from unittest.mock import AsyncMock + +from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.core import HomeAssistant + +from . import async_init_integration, find_update_callback +from .const import MOCK_SNOO_DATA + + +async def test_sensors(hass: HomeAssistant, bypass_api: AsyncMock) -> None: + """Test sensors and check test values are correctly set.""" + await async_init_integration(hass) + assert len(hass.states.async_all("sensor")) == 2 + assert hass.states.get("sensor.test_snoo_state").state == STATE_UNAVAILABLE + assert hass.states.get("sensor.test_snoo_time_left").state == STATE_UNAVAILABLE + find_update_callback(bypass_api, "random_num")(MOCK_SNOO_DATA) + await hass.async_block_till_done() + assert len(hass.states.async_all("sensor")) == 2 + assert hass.states.get("sensor.test_snoo_state").state == "stop" + assert hass.states.get("sensor.test_snoo_time_left").state == STATE_UNKNOWN From 96a6d88dca306deb29b09aa94c0c6dab4978ac2c Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 14 Mar 2025 15:01:43 +0100 Subject: [PATCH 2612/3148] Allow configuring ignored devices from dormakaba_dkey user flow (#140596) --- .../components/dormakaba_dkey/config_flow.py | 2 +- .../dormakaba_dkey/test_config_flow.py | 38 +++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/dormakaba_dkey/config_flow.py b/homeassistant/components/dormakaba_dkey/config_flow.py index 0d23b822231..369accb83d8 100644 --- a/homeassistant/components/dormakaba_dkey/config_flow.py +++ b/homeassistant/components/dormakaba_dkey/config_flow.py @@ -57,7 +57,7 @@ class DormkabaConfigFlow(ConfigFlow, domain=DOMAIN): self._discovery_info = self._discovered_devices[address] return await self.async_step_associate() - current_addresses = self._async_current_ids() + current_addresses = self._async_current_ids(include_ignore=False) for discovery in async_discovered_service_info(self.hass): if ( discovery.address in current_addresses diff --git a/tests/components/dormakaba_dkey/test_config_flow.py b/tests/components/dormakaba_dkey/test_config_flow.py index 8d8140d609a..b3657810006 100644 --- a/tests/components/dormakaba_dkey/test_config_flow.py +++ b/tests/components/dormakaba_dkey/test_config_flow.py @@ -9,6 +9,7 @@ import pytest from homeassistant import config_entries from homeassistant.components.dormakaba_dkey.const import DOMAIN +from homeassistant.config_entries import SOURCE_IGNORE from homeassistant.const import CONF_ADDRESS from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResult, FlowResultType @@ -143,6 +144,43 @@ async def test_async_step_user_takes_precedence_over_discovery( assert not hass.config_entries.flow.async_progress(DOMAIN) +async def test_user_setup_removes_ignored_entry(hass: HomeAssistant) -> None: + """Test the user initiated form can replace an ignored device.""" + ignored_entry = MockConfigEntry( + domain=DOMAIN, + unique_id=DKEY_DISCOVERY_INFO.address, + source=SOURCE_IGNORE, + ) + ignored_entry.add_to_hass(hass) + assert hass.config_entries.async_entries(DOMAIN) == [ignored_entry] + + with patch( + "homeassistant.components.dormakaba_dkey.config_flow.async_discovered_service_info", + return_value=[NOT_DKEY_DISCOVERY_INFO, DKEY_DISCOVERY_INFO], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_ADDRESS: DKEY_DISCOVERY_INFO.address, + }, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "associate" + assert result["errors"] is None + + await _test_common_success(hass, result) + + # Check the ignored entry is removed + assert ignored_entry not in hass.config_entries.async_entries(DOMAIN) + + async def test_bluetooth_step_success(hass: HomeAssistant) -> None: """Test bluetooth step success path.""" result = await hass.config_entries.flow.async_init( From 08fc6dcff643973a044c64c397ba3b995d500457 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 14 Mar 2025 15:05:58 +0100 Subject: [PATCH 2613/3148] Allow configuring ignored devices from improve_ble user flow (#140595) --- .../components/improv_ble/config_flow.py | 19 ++++++++---- .../components/improv_ble/test_config_flow.py | 29 +++++++++++++++++++ 2 files changed, 43 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/improv_ble/config_flow.py b/homeassistant/components/improv_ble/config_flow.py index 22f2bf3623c..0dcefba6428 100644 --- a/homeassistant/components/improv_ble/config_flow.py +++ b/homeassistant/components/improv_ble/config_flow.py @@ -83,12 +83,9 @@ class ImprovBLEConfigFlow(ConfigFlow, domain=DOMAIN): self._discovery_info = self._discovered_devices[address] return await self.async_step_start_improv() - current_addresses = self._async_current_ids() for discovery in bluetooth.async_discovered_service_info(self.hass): - if ( - discovery.address in current_addresses - or discovery.address in self._discovered_devices - or not device_filter(discovery.advertisement) + if discovery.address in self._discovered_devices or not device_filter( + discovery.advertisement ): continue self._discovered_devices[discovery.address] = discovery @@ -364,6 +361,18 @@ class ImprovBLEConfigFlow(ConfigFlow, domain=DOMAIN): assert self._provision_result is not None result = self._provision_result + if result["type"] == "abort" and result["reason"] in ( + "provision_successful", + "provision_successful_url", + ): + # Delete ignored config entry, if it exists + address = self.context["unique_id"] + current_entries = self._async_current_entries(include_ignore=True) + for entry in current_entries: + if entry.unique_id == address: + _LOGGER.debug("Removing ignored entry: %s", entry) + await self.hass.config_entries.async_remove(entry.entry_id) + break self._provision_result = None return result diff --git a/tests/components/improv_ble/test_config_flow.py b/tests/components/improv_ble/test_config_flow.py index 4536c64349c..9d883502d28 100644 --- a/tests/components/improv_ble/test_config_flow.py +++ b/tests/components/improv_ble/test_config_flow.py @@ -10,6 +10,7 @@ import pytest from homeassistant import config_entries from homeassistant.components.bluetooth import BluetoothChange from homeassistant.components.improv_ble.const import DOMAIN +from homeassistant.config_entries import SOURCE_IGNORE from homeassistant.const import CONF_ADDRESS from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResult, FlowResultType @@ -21,6 +22,8 @@ from . import ( PROVISIONED_IMPROV_BLE_DISCOVERY_INFO, ) +from tests.common import MockConfigEntry + IMPROV_BLE = "homeassistant.components.improv_ble" @@ -118,6 +121,32 @@ async def test_async_step_user_takes_precedence_over_discovery( assert not hass.config_entries.flow.async_progress(DOMAIN) +async def test_user_setup_removes_ignored_entry(hass: HomeAssistant) -> None: + """Test the user initiated form can replace an ignored device.""" + ignored_entry = MockConfigEntry( + domain=DOMAIN, + unique_id=IMPROV_BLE_DISCOVERY_INFO.address, + source=SOURCE_IGNORE, + ) + ignored_entry.add_to_hass(hass) + with patch( + f"{IMPROV_BLE}.config_flow.bluetooth.async_discovered_service_info", + return_value=[NOT_IMPROV_BLE_DISCOVERY_INFO, IMPROV_BLE_DISCOVERY_INFO], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + await _test_common_success_wo_identify( + hass, result, IMPROV_BLE_DISCOVERY_INFO.address + ) + # Check the ignored entry is removed + assert not hass.config_entries.async_entries(DOMAIN) + + async def test_bluetooth_step_provisioned_device(hass: HomeAssistant) -> None: """Test bluetooth step when device is already provisioned.""" result = await hass.config_entries.flow.async_init( From e9c8b3acfc56e5d436cb4a4d8a27a892621c939f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 14 Mar 2025 04:07:32 -1000 Subject: [PATCH 2614/3148] Bump aioharmony to 0.5.2 (#140589) mostly logging fixes (some format stings were missing values) related issue #139126 --- homeassistant/components/harmony/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/harmony/manifest.json b/homeassistant/components/harmony/manifest.json index aab4f51b09a..f67eb4db5aa 100644 --- a/homeassistant/components/harmony/manifest.json +++ b/homeassistant/components/harmony/manifest.json @@ -7,7 +7,7 @@ "documentation": "https://www.home-assistant.io/integrations/harmony", "iot_class": "local_push", "loggers": ["aioharmony", "slixmpp"], - "requirements": ["aioharmony==0.4.1"], + "requirements": ["aioharmony==0.5.2"], "ssdp": [ { "manufacturer": "Logitech", diff --git a/requirements_all.txt b/requirements_all.txt index 76926fd1001..cadf1be7645 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -258,7 +258,7 @@ aiogithubapi==24.6.0 aioguardian==2022.07.0 # homeassistant.components.harmony -aioharmony==0.4.1 +aioharmony==0.5.2 # homeassistant.components.hassio aiohasupervisor==0.3.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 819d9756f85..0637d2a737a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -243,7 +243,7 @@ aiogithubapi==24.6.0 aioguardian==2022.07.0 # homeassistant.components.harmony -aioharmony==0.4.1 +aioharmony==0.5.2 # homeassistant.components.hassio aiohasupervisor==0.3.0 From de0efd61d177877f35bb31e557e29e448d8929db Mon Sep 17 00:00:00 2001 From: Petar Petrov Date: Fri, 14 Mar 2025 16:17:23 +0200 Subject: [PATCH 2615/3148] Add Z-Wave JS NVM backup and restore API (#139233) * ZWaveJS: NVM backup and restore API * remove unused const * test fix * switch to WS commands * Backup & restore MVP * Use base64 data directly * update tests * fix mistake * Apply suggestions from code review Co-authored-by: Martin Hjelmare * PR comments * update tests * more tests --------- Co-authored-by: Martin Hjelmare --- homeassistant/components/zwave_js/api.py | 125 ++++++++++++ tests/components/zwave_js/test_api.py | 236 +++++++++++++++++++++++ 2 files changed, 361 insertions(+) diff --git a/homeassistant/components/zwave_js/api.py b/homeassistant/components/zwave_js/api.py index cc47339a6a6..a3d1416962e 100644 --- a/homeassistant/components/zwave_js/api.py +++ b/homeassistant/components/zwave_js/api.py @@ -454,6 +454,8 @@ def async_register_api(hass: HomeAssistant) -> None: websocket_api.async_register_command(hass, websocket_node_capabilities) websocket_api.async_register_command(hass, websocket_invoke_cc_api) websocket_api.async_register_command(hass, websocket_get_integration_settings) + websocket_api.async_register_command(hass, websocket_backup_nvm) + websocket_api.async_register_command(hass, websocket_restore_nvm) hass.http.register_view(FirmwareUploadView(dr.async_get(hass))) @@ -2780,3 +2782,126 @@ def websocket_get_integration_settings( CONF_INSTALLER_MODE: hass.data[DOMAIN].get(CONF_INSTALLER_MODE, False), }, ) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zwave_js/backup_nvm", + vol.Required(ENTRY_ID): str, + } +) +@websocket_api.async_response +@async_handle_failed_command +@async_get_entry +async def websocket_backup_nvm( + hass: HomeAssistant, + connection: ActiveConnection, + msg: dict[str, Any], + entry: ConfigEntry, + client: Client, + driver: Driver, +) -> None: + """Backup NVM data.""" + controller = driver.controller + + @callback + def async_cleanup() -> None: + """Remove signal listeners.""" + for unsub in unsubs: + unsub() + + @callback + def forward_progress(event: dict) -> None: + """Forward progress events to websocket.""" + connection.send_message( + websocket_api.event_message( + msg[ID], + { + "event": event["event"], + "bytesRead": event["bytesRead"], + "total": event["total"], + }, + ) + ) + + # Set up subscription for progress events + connection.subscriptions[msg["id"]] = async_cleanup + msg[DATA_UNSUBSCRIBE] = unsubs = [ + controller.on("nvm backup progress", forward_progress), + ] + + result = await controller.async_backup_nvm_raw_base64() + # Send the finished event with the backup data + connection.send_message( + websocket_api.event_message( + msg[ID], + { + "event": "finished", + "data": result, + }, + ) + ) + connection.send_result(msg[ID]) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zwave_js/restore_nvm", + vol.Required(ENTRY_ID): str, + vol.Required("data"): str, + } +) +@websocket_api.async_response +@async_handle_failed_command +@async_get_entry +async def websocket_restore_nvm( + hass: HomeAssistant, + connection: ActiveConnection, + msg: dict[str, Any], + entry: ConfigEntry, + client: Client, + driver: Driver, +) -> None: + """Restore NVM data.""" + controller = driver.controller + + @callback + def async_cleanup() -> None: + """Remove signal listeners.""" + for unsub in unsubs: + unsub() + + @callback + def forward_progress(event: dict) -> None: + """Forward progress events to websocket.""" + connection.send_message( + websocket_api.event_message( + msg[ID], + { + "event": event["event"], + "bytesRead": event.get("bytesRead"), + "bytesWritten": event.get("bytesWritten"), + "total": event["total"], + }, + ) + ) + + # Set up subscription for progress events + connection.subscriptions[msg["id"]] = async_cleanup + msg[DATA_UNSUBSCRIBE] = unsubs = [ + controller.on("nvm convert progress", forward_progress), + controller.on("nvm restore progress", forward_progress), + ] + + await controller.async_restore_nvm_base64(msg["data"]) + connection.send_message( + websocket_api.event_message( + msg[ID], + { + "event": "finished", + }, + ) + ) + connection.send_result(msg[ID]) diff --git a/tests/components/zwave_js/test_api.py b/tests/components/zwave_js/test_api.py index dcb8c8dafe4..07c874197b6 100644 --- a/tests/components/zwave_js/test_api.py +++ b/tests/components/zwave_js/test_api.py @@ -5201,6 +5201,242 @@ async def test_get_integration_settings( } +async def test_backup_nvm( + hass: HomeAssistant, + integration, + client, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test the backup NVM websocket command.""" + ws_client = await hass_ws_client(hass) + + # Set up mocks for the controller events + controller = client.driver.controller + + # Test subscription and events + with patch.object( + controller, "async_backup_nvm_raw_base64", return_value="test" + ) as mock_backup: + # Send the subscription request + await ws_client.send_json_auto_id( + { + "type": "zwave_js/backup_nvm", + "entry_id": integration.entry_id, + } + ) + + # Verify the finished event with data first + msg = await ws_client.receive_json() + assert msg["type"] == "event" + assert msg["event"]["event"] == "finished" + assert msg["event"]["data"] == "test" + + # Verify subscription success + msg = await ws_client.receive_json() + assert msg["type"] == "result" + assert msg["success"] is True + + # Simulate progress events + event = Event( + "nvm backup progress", + { + "source": "controller", + "event": "nvm backup progress", + "bytesRead": 25, + "total": 100, + }, + ) + controller.receive_event(event) + msg = await ws_client.receive_json() + assert msg["event"]["event"] == "nvm backup progress" + assert msg["event"]["bytesRead"] == 25 + assert msg["event"]["total"] == 100 + + event = Event( + "nvm backup progress", + { + "source": "controller", + "event": "nvm backup progress", + "bytesRead": 50, + "total": 100, + }, + ) + controller.receive_event(event) + msg = await ws_client.receive_json() + assert msg["event"]["event"] == "nvm backup progress" + assert msg["event"]["bytesRead"] == 50 + assert msg["event"]["total"] == 100 + + # Wait for the backup to complete + await hass.async_block_till_done() + + # Verify the backup was called + assert mock_backup.called + + # Test backup failure + with patch.object( + controller, + "async_backup_nvm_raw_base64", + side_effect=FailedCommand("failed_command", "Backup failed"), + ): + # Send the subscription request + await ws_client.send_json_auto_id( + { + "type": "zwave_js/backup_nvm", + "entry_id": integration.entry_id, + } + ) + + # Verify error response + msg = await ws_client.receive_json() + assert not msg["success"] + assert msg["error"]["code"] == "Backup failed" + + # Test config entry not found + await ws_client.send_json_auto_id( + { + "type": "zwave_js/backup_nvm", + "entry_id": "invalid_entry_id", + } + ) + msg = await ws_client.receive_json() + assert not msg["success"] + assert msg["error"]["code"] == "not_found" + + # Test config entry not loaded + await hass.config_entries.async_unload(integration.entry_id) + await hass.async_block_till_done() + + await ws_client.send_json_auto_id( + { + "type": "zwave_js/backup_nvm", + "entry_id": integration.entry_id, + } + ) + msg = await ws_client.receive_json() + assert msg["error"]["code"] == "not_loaded" + + +async def test_restore_nvm( + hass: HomeAssistant, + integration, + client, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test the restore NVM websocket command.""" + ws_client = await hass_ws_client(hass) + + # Set up mocks for the controller events + controller = client.driver.controller + + # Test restore success + with patch.object( + controller, "async_restore_nvm_base64", return_value=None + ) as mock_restore: + # Send the subscription request + await ws_client.send_json_auto_id( + { + "type": "zwave_js/restore_nvm", + "entry_id": integration.entry_id, + "data": "dGVzdA==", # base64 encoded "test" + } + ) + + # Verify the finished event first + msg = await ws_client.receive_json() + assert msg["type"] == "event" + assert msg["event"]["event"] == "finished" + + # Verify subscription success + msg = await ws_client.receive_json() + assert msg["type"] == "result" + assert msg["success"] is True + + # Simulate progress events + event = Event( + "nvm restore progress", + { + "source": "controller", + "event": "nvm restore progress", + "bytesWritten": 25, + "total": 100, + }, + ) + controller.receive_event(event) + msg = await ws_client.receive_json() + assert msg["event"]["event"] == "nvm restore progress" + assert msg["event"]["bytesWritten"] == 25 + assert msg["event"]["total"] == 100 + + event = Event( + "nvm restore progress", + { + "source": "controller", + "event": "nvm restore progress", + "bytesWritten": 50, + "total": 100, + }, + ) + controller.receive_event(event) + msg = await ws_client.receive_json() + assert msg["event"]["event"] == "nvm restore progress" + assert msg["event"]["bytesWritten"] == 50 + assert msg["event"]["total"] == 100 + + # Wait for the restore to complete + await hass.async_block_till_done() + + # Verify the restore was called + assert mock_restore.called + + # Test restore failure + with patch.object( + controller, + "async_restore_nvm_base64", + side_effect=FailedCommand("failed_command", "Restore failed"), + ): + # Send the subscription request + await ws_client.send_json_auto_id( + { + "type": "zwave_js/restore_nvm", + "entry_id": integration.entry_id, + "data": "dGVzdA==", # base64 encoded "test" + } + ) + + # Verify error response + msg = await ws_client.receive_json() + assert not msg["success"] + assert msg["error"]["code"] == "Restore failed" + + # Test entry_id not found + await ws_client.send_json_auto_id( + { + "type": "zwave_js/restore_nvm", + "entry_id": "invalid_entry_id", + "data": "dGVzdA==", # base64 encoded "test" + } + ) + msg = await ws_client.receive_json() + assert not msg["success"] + assert msg["error"]["code"] == "not_found" + + # Test config entry not loaded + await hass.config_entries.async_unload(integration.entry_id) + await hass.async_block_till_done() + + await ws_client.send_json_auto_id( + { + "type": "zwave_js/restore_nvm", + "entry_id": integration.entry_id, + "data": "dGVzdA==", # base64 encoded "test" + } + ) + msg = await ws_client.receive_json() + assert not msg["success"] + assert msg["error"]["code"] == "not_loaded" + + async def test_cancel_secure_bootstrap_s2( hass: HomeAssistant, client, integration, hass_ws_client: WebSocketGenerator ) -> None: From 251bb30dc7bb2ea0c17ca41f9800be4f275c9a59 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Sat, 15 Mar 2025 00:27:18 +1000 Subject: [PATCH 2616/3148] Add streaming media platform to Teslemetry (#140482) * Update media player * Add media player platform with tests and bump firmware --- .../components/teslemetry/media_player.py | 312 +++++++++++++----- tests/components/teslemetry/const.py | 3 +- .../teslemetry/fixtures/metadata.json | 22 -- .../teslemetry/fixtures/vehicle_data.json | 2 +- .../snapshots/test_binary_sensor.ambr | 180 ++++++++++ .../snapshots/test_diagnostics.ambr | 2 +- .../snapshots/test_media_player.ambr | 42 ++- .../teslemetry/snapshots/test_update.ambr | 2 +- .../teslemetry/test_media_player.py | 67 +++- 9 files changed, 523 insertions(+), 109 deletions(-) delete mode 100644 tests/components/teslemetry/fixtures/metadata.json diff --git a/homeassistant/components/teslemetry/media_player.py b/homeassistant/components/teslemetry/media_player.py index 1bfc9bf66dc..409b409e325 100644 --- a/homeassistant/components/teslemetry/media_player.py +++ b/homeassistant/components/teslemetry/media_player.py @@ -2,6 +2,7 @@ from __future__ import annotations +from tesla_fleet_api import VehicleSpecific from tesla_fleet_api.const import Scope from homeassistant.components.media_player import ( @@ -12,9 +13,14 @@ from homeassistant.components.media_player import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.restore_state import RestoreEntity from . import TeslemetryConfigEntry -from .entity import TeslemetryVehicleEntity +from .entity import ( + TeslemetryRootEntity, + TeslemetryVehicleEntity, + TeslemetryVehicleStreamEntity, +) from .helpers import handle_vehicle_command from .models import TeslemetryVehicleData @@ -24,8 +30,16 @@ STATES = { "Stopped": MediaPlayerState.IDLE, "Off": MediaPlayerState.OFF, } -VOLUME_MAX = 11.0 -VOLUME_STEP = 1.0 / 3 +DISPLAY_STATES = { + "On": MediaPlayerState.IDLE, + "Accessory": MediaPlayerState.IDLE, + "Charging": MediaPlayerState.OFF, + "Sentry": MediaPlayerState.OFF, + "Off": MediaPlayerState.OFF, +} +# Tesla uses 31 steps, in 0.333 increments up to 10.333 +VOLUME_STEP = 1 / 31 +VOLUME_FACTOR = 31 / 3 # 10.333 PARALLEL_UPDATES = 0 @@ -38,68 +52,99 @@ async def async_setup_entry( """Set up the Teslemetry Media platform from a config entry.""" async_add_entities( - TeslemetryMediaEntity(vehicle, Scope.VEHICLE_CMDS in entry.runtime_data.scopes) + TeslemetryPollingMediaEntity(vehicle, entry.runtime_data.scopes) + if vehicle.api.pre2021 or vehicle.firmware < "2025.2.6" + else TeslemetryStreamingMediaEntity(vehicle, entry.runtime_data.scopes) for vehicle in entry.runtime_data.vehicles ) -class TeslemetryMediaEntity(TeslemetryVehicleEntity, MediaPlayerEntity): - """Vehicle media player class.""" +class TeslemetryMediaEntity(TeslemetryRootEntity, MediaPlayerEntity): + """Base vehicle media player class.""" + + api: VehicleSpecific _attr_device_class = MediaPlayerDeviceClass.SPEAKER - _attr_supported_features = ( - MediaPlayerEntityFeature.NEXT_TRACK - | MediaPlayerEntityFeature.PAUSE - | MediaPlayerEntityFeature.PLAY - | MediaPlayerEntityFeature.PREVIOUS_TRACK - | MediaPlayerEntityFeature.VOLUME_SET - ) - _volume_max: float = VOLUME_MAX + _attr_volume_step = VOLUME_STEP + + async def async_set_volume_level(self, volume: float) -> None: + """Set volume level, range 0..1.""" + self.raise_for_scope(Scope.VEHICLE_CMDS) + + await handle_vehicle_command(self.api.adjust_volume(volume * VOLUME_FACTOR)) + self._attr_volume_level = volume + self.async_write_ha_state() + + async def async_media_play(self) -> None: + """Send play command.""" + if self.state != MediaPlayerState.PLAYING: + self.raise_for_scope(Scope.VEHICLE_CMDS) + + await handle_vehicle_command(self.api.media_toggle_playback()) + self._attr_state = MediaPlayerState.PLAYING + self.async_write_ha_state() + + async def async_media_pause(self) -> None: + """Send pause command.""" + + if self.state == MediaPlayerState.PLAYING: + self.raise_for_scope(Scope.VEHICLE_CMDS) + + await handle_vehicle_command(self.api.media_toggle_playback()) + self._attr_state = MediaPlayerState.PAUSED + self.async_write_ha_state() + + async def async_media_next_track(self) -> None: + """Send next track command.""" + + self.raise_for_scope(Scope.VEHICLE_CMDS) + await handle_vehicle_command(self.api.media_next_track()) + + async def async_media_previous_track(self) -> None: + """Send previous track command.""" + + self.raise_for_scope(Scope.VEHICLE_CMDS) + await handle_vehicle_command(self.api.media_prev_track()) + + +class TeslemetryPollingMediaEntity(TeslemetryVehicleEntity, TeslemetryMediaEntity): + """Polling vehicle media player class.""" def __init__( self, data: TeslemetryVehicleData, - scoped: bool, + scopes: list[Scope], ) -> None: """Initialize the media player entity.""" super().__init__(data, "media") - self.scoped = scoped - if not scoped: + + self._attr_supported_features = ( + MediaPlayerEntityFeature.NEXT_TRACK + | MediaPlayerEntityFeature.PAUSE + | MediaPlayerEntityFeature.PLAY + | MediaPlayerEntityFeature.PREVIOUS_TRACK + | MediaPlayerEntityFeature.VOLUME_SET + ) + self.scoped = Scope.VEHICLE_CMDS in scopes + if not self.scoped: self._attr_supported_features = MediaPlayerEntityFeature(0) def _async_update_attrs(self) -> None: """Update entity attributes.""" - self._volume_max = ( - self.get("vehicle_state_media_info_audio_volume_max") or VOLUME_MAX - ) - self._attr_state = STATES.get( - self.get("vehicle_state_media_info_media_playback_status") or "Off", - ) - self._attr_volume_step = ( - 1.0 - / self._volume_max - / ( - self.get("vehicle_state_media_info_audio_volume_increment") - or VOLUME_STEP - ) - ) + state = self.get("vehicle_state_media_info_media_playback_status") + self._attr_state = STATES.get(state) if state else None + self._attr_volume_level = ( + self.get("vehicle_state_media_info_audio_volume") or 0 + ) / VOLUME_FACTOR - if volume := self.get("vehicle_state_media_info_audio_volume"): - self._attr_volume_level = volume / self._volume_max - else: - self._attr_volume_level = None + duration = self.get("vehicle_state_media_info_now_playing_duration") + self._attr_media_duration = duration / 1000 if duration is not None else None - if duration := self.get("vehicle_state_media_info_now_playing_duration"): - self._attr_media_duration = duration / 1000 - else: - self._attr_media_duration = None - - if duration and ( - position := self.get("vehicle_state_media_info_now_playing_elapsed") - ): - self._attr_media_position = position / 1000 - else: - self._attr_media_position = None + # Return media position only when a media duration is > 0. + elapsed = self.get("vehicle_state_media_info_now_playing_elapsed") + self._attr_media_position = ( + elapsed / 1000 if duration and elapsed is not None else None + ) self._attr_media_title = self.get("vehicle_state_media_info_now_playing_title") self._attr_media_artist = self.get( @@ -113,42 +158,151 @@ class TeslemetryMediaEntity(TeslemetryVehicleEntity, MediaPlayerEntity): ) self._attr_source = self.get("vehicle_state_media_info_now_playing_source") - async def async_set_volume_level(self, volume: float) -> None: - """Set volume level, range 0..1.""" - self.raise_for_scope(Scope.VEHICLE_CMDS) - await self.wake_up_if_asleep() - await handle_vehicle_command( - self.api.adjust_volume(int(volume * self._volume_max)) + +class TeslemetryStreamingMediaEntity( + TeslemetryVehicleStreamEntity, TeslemetryMediaEntity, RestoreEntity +): + """Streaming vehicle media player class.""" + + def __init__( + self, + data: TeslemetryVehicleData, + scopes: list[Scope], + ) -> None: + """Initialize the media player entity.""" + super().__init__(data, "media") + + self._attr_supported_features = ( + MediaPlayerEntityFeature.NEXT_TRACK + | MediaPlayerEntityFeature.PAUSE + | MediaPlayerEntityFeature.PLAY + | MediaPlayerEntityFeature.PREVIOUS_TRACK + | MediaPlayerEntityFeature.VOLUME_SET ) - self._attr_volume_level = volume + self.scoped = Scope.VEHICLE_CMDS in scopes + if not self.scoped: + self._attr_supported_features = MediaPlayerEntityFeature(0) + + async def async_added_to_hass(self) -> None: + """Call when entity is added to hass.""" + + await super().async_added_to_hass() + if (state := await self.async_get_last_state()) is not None: + try: + self._attr_state = MediaPlayerState(state.state) + except ValueError: + self._attr_state = None + self._attr_volume_level = state.attributes.get("volume_level") + self._attr_media_title = state.attributes.get("media_title") + self._attr_media_artist = state.attributes.get("media_artist") + self._attr_media_album_name = state.attributes.get("media_album_name") + self._attr_media_playlist = state.attributes.get("media_playlist") + self._attr_media_duration = state.attributes.get("media_duration") + self._attr_media_position = state.attributes.get("media_position") + self._attr_source = state.attributes.get("source") + + self.async_write_ha_state() + + self.async_on_remove( + self.vehicle.stream_vehicle.listen_CenterDisplay( + self._async_handle_center_display + ) + ) + self.async_on_remove( + self.vehicle.stream_vehicle.listen_MediaPlaybackStatus( + self._async_handle_media_playback_status + ) + ) + self.async_on_remove( + self.vehicle.stream_vehicle.listen_MediaPlaybackSource( + self._async_handle_media_playback_source + ) + ) + self.async_on_remove( + self.vehicle.stream_vehicle.listen_MediaAudioVolume( + self._async_handle_media_audio_volume + ) + ) + self.async_on_remove( + self.vehicle.stream_vehicle.listen_MediaNowPlayingDuration( + self._async_handle_media_now_playing_duration + ) + ) + self.async_on_remove( + self.vehicle.stream_vehicle.listen_MediaNowPlayingElapsed( + self._async_handle_media_now_playing_elapsed + ) + ) + self.async_on_remove( + self.vehicle.stream_vehicle.listen_MediaNowPlayingArtist( + self._async_handle_media_now_playing_artist + ) + ) + self.async_on_remove( + self.vehicle.stream_vehicle.listen_MediaNowPlayingAlbum( + self._async_handle_media_now_playing_album + ) + ) + self.async_on_remove( + self.vehicle.stream_vehicle.listen_MediaNowPlayingTitle( + self._async_handle_media_now_playing_title + ) + ) + self.async_on_remove( + self.vehicle.stream_vehicle.listen_MediaNowPlayingStation( + self._async_handle_media_now_playing_station + ) + ) + + def _async_handle_center_display(self, value: str | None) -> None: + """Update entity attributes.""" + if value is not None: + self._attr_state = DISPLAY_STATES.get(value) + self.async_write_ha_state() + + def _async_handle_media_playback_status(self, value: str | None) -> None: + """Update entity attributes.""" + self._attr_state = MediaPlayerState.OFF if value is None else STATES.get(value) self.async_write_ha_state() - async def async_media_play(self) -> None: - """Send play command.""" - if self.state != MediaPlayerState.PLAYING: - self.raise_for_scope(Scope.VEHICLE_CMDS) - await self.wake_up_if_asleep() - await handle_vehicle_command(self.api.media_toggle_playback()) - self._attr_state = MediaPlayerState.PLAYING - self.async_write_ha_state() + def _async_handle_media_playback_source(self, value: str | None) -> None: + """Update entity attributes.""" + self._attr_source = value + self.async_write_ha_state() - async def async_media_pause(self) -> None: - """Send pause command.""" - if self.state == MediaPlayerState.PLAYING: - self.raise_for_scope(Scope.VEHICLE_CMDS) - await self.wake_up_if_asleep() - await handle_vehicle_command(self.api.media_toggle_playback()) - self._attr_state = MediaPlayerState.PAUSED - self.async_write_ha_state() + def _async_handle_media_audio_volume(self, value: float | None) -> None: + """Update entity attributes.""" + self._attr_volume_level = None if value is None else value / VOLUME_FACTOR + self.async_write_ha_state() - async def async_media_next_track(self) -> None: - """Send next track command.""" - self.raise_for_scope(Scope.VEHICLE_CMDS) - await self.wake_up_if_asleep() - await handle_vehicle_command(self.api.media_next_track()) + def _async_handle_media_now_playing_duration(self, value: int | None) -> None: + """Update entity attributes.""" + self._attr_media_duration = None if value is None else int(value / 1000) + self.async_write_ha_state() - async def async_media_previous_track(self) -> None: - """Send previous track command.""" - self.raise_for_scope(Scope.VEHICLE_CMDS) - await self.wake_up_if_asleep() - await handle_vehicle_command(self.api.media_prev_track()) + def _async_handle_media_now_playing_elapsed(self, value: int | None) -> None: + """Update entity attributes.""" + self._attr_media_position = None if value is None else int(value / 1000) + self.async_write_ha_state() + + def _async_handle_media_now_playing_artist(self, value: str | None) -> None: + """Update entity attributes.""" + self._attr_media_artist = value # Check if this is album artist or not + self.async_write_ha_state() + + def _async_handle_media_now_playing_album(self, value: str | None) -> None: + """Update entity attributes.""" + self._attr_media_album_name = value + self.async_write_ha_state() + + def _async_handle_media_now_playing_title(self, value: str | None) -> None: + """Update entity attributes.""" + self._attr_media_title = value + self.async_write_ha_state() + + def _async_handle_media_now_playing_station(self, value: str | None) -> None: + """Update entity attributes.""" + self._attr_media_channel = ( + value # could also be _attr_media_playlist when Spotify + ) + self.async_write_ha_state() diff --git a/tests/components/teslemetry/const.py b/tests/components/teslemetry/const.py index 40d55dab71f..31915630951 100644 --- a/tests/components/teslemetry/const.py +++ b/tests/components/teslemetry/const.py @@ -18,7 +18,6 @@ VEHICLE_DATA_ALT = load_json_object_fixture("vehicle_data_alt.json", DOMAIN) LIVE_STATUS = load_json_object_fixture("live_status.json", DOMAIN) SITE_INFO = load_json_object_fixture("site_info.json", DOMAIN) ENERGY_HISTORY = load_json_object_fixture("energy_history.json", DOMAIN) -METADATA = load_json_object_fixture("metadata.json", DOMAIN) COMMAND_OK = {"response": {"result": True, "reason": ""}} COMMAND_REASON = {"response": {"result": False, "reason": "already closed"}} @@ -52,7 +51,7 @@ METADATA = { "proxy": False, "access": True, "polling": True, - "firmware": "2024.44.25", + "firmware": "2026.0.0", } }, } diff --git a/tests/components/teslemetry/fixtures/metadata.json b/tests/components/teslemetry/fixtures/metadata.json deleted file mode 100644 index 60282afc934..00000000000 --- a/tests/components/teslemetry/fixtures/metadata.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "uid": "abc-123", - "region": "NA", - "scopes": [ - "openid", - "offline_access", - "user_data", - "vehicle_device_data", - "vehicle_cmds", - "vehicle_charging_cmds", - "energy_device_data", - "energy_cmds" - ], - "vehicles": { - "LRW3F7EK4NC700000": { - "access": true, - "polling": true, - "proxy": true, - "firmware": "2024.44.25" - } - } -} diff --git a/tests/components/teslemetry/fixtures/vehicle_data.json b/tests/components/teslemetry/fixtures/vehicle_data.json index 0cd238c4e52..051c7199d00 100644 --- a/tests/components/teslemetry/fixtures/vehicle_data.json +++ b/tests/components/teslemetry/fixtures/vehicle_data.json @@ -192,7 +192,7 @@ "api_version": 71, "autopark_state_v2": "unavailable", "calendar_supported": true, - "car_version": "2024.44.25 06f534d46010", + "car_version": "2026.0.0 06f534d46010", "center_display_state": 0, "dashcam_clip_save_available": true, "dashcam_state": "Recording", diff --git a/tests/components/teslemetry/snapshots/test_binary_sensor.ambr b/tests/components/teslemetry/snapshots/test_binary_sensor.ambr index 6a6e9826dc2..84c50c3ebe9 100644 --- a/tests/components/teslemetry/snapshots/test_binary_sensor.ambr +++ b/tests/components/teslemetry/snapshots/test_binary_sensor.ambr @@ -1371,6 +1371,147 @@ 'state': 'unknown', }) # --- +# name: test_binary_sensor[binary_sensor.test_located_at_favorite-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_located_at_favorite', + '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': 'Located at favorite', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'located_at_favorite', + 'unique_id': 'LRW3F7EK4NC700000-located_at_favorite', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_located_at_favorite-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Located at favorite', + }), + 'context': , + 'entity_id': 'binary_sensor.test_located_at_favorite', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor[binary_sensor.test_located_at_home-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_located_at_home', + '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': 'Located at home', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'located_at_home', + 'unique_id': 'LRW3F7EK4NC700000-located_at_home', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_located_at_home-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Located at home', + }), + 'context': , + 'entity_id': 'binary_sensor.test_located_at_home', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor[binary_sensor.test_located_at_work-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_located_at_work', + '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': 'Located at work', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'located_at_work', + 'unique_id': 'LRW3F7EK4NC700000-located_at_work', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_located_at_work-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Located at work', + }), + 'context': , + 'entity_id': 'binary_sensor.test_located_at_work', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_binary_sensor[binary_sensor.test_offroad_lightbar-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2801,6 +2942,45 @@ 'state': 'unknown', }) # --- +# name: test_binary_sensor_refresh[binary_sensor.test_located_at_favorite-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Located at favorite', + }), + 'context': , + 'entity_id': 'binary_sensor.test_located_at_favorite', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor_refresh[binary_sensor.test_located_at_home-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Located at home', + }), + 'context': , + 'entity_id': 'binary_sensor.test_located_at_home', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor_refresh[binary_sensor.test_located_at_work-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Located at work', + }), + 'context': , + 'entity_id': 'binary_sensor.test_located_at_work', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_binary_sensor_refresh[binary_sensor.test_offroad_lightbar-statealt] StateSnapshot({ 'attributes': ReadOnlyDict({ diff --git a/tests/components/teslemetry/snapshots/test_diagnostics.ambr b/tests/components/teslemetry/snapshots/test_diagnostics.ambr index 56a8f759a21..a39e8a0ff74 100644 --- a/tests/components/teslemetry/snapshots/test_diagnostics.ambr +++ b/tests/components/teslemetry/snapshots/test_diagnostics.ambr @@ -375,7 +375,7 @@ 'vehicle_state_api_version': 71, 'vehicle_state_autopark_state_v2': 'unavailable', 'vehicle_state_calendar_supported': True, - 'vehicle_state_car_version': '2024.44.25 06f534d46010', + 'vehicle_state_car_version': '2026.0.0 06f534d46010', 'vehicle_state_center_display_state': 0, 'vehicle_state_dashcam_clip_save_available': True, 'vehicle_state_dashcam_state': 'Recording', diff --git a/tests/components/teslemetry/snapshots/test_media_player.ambr b/tests/components/teslemetry/snapshots/test_media_player.ambr index 663e91a502c..7f721b95289 100644 --- a/tests/components/teslemetry/snapshots/test_media_player.ambr +++ b/tests/components/teslemetry/snapshots/test_media_player.ambr @@ -47,7 +47,7 @@ 'media_title': 'Chapter 51: Cybertruck: Tesla, 2018–2019', 'source': 'Audible', 'supported_features': , - 'volume_level': 0.16129355359011466, + 'volume_level': 0.16129354838709678, }), 'context': , 'entity_id': 'media_player.test_media_player', @@ -64,10 +64,12 @@ 'friendly_name': 'Test Media player', 'media_album_name': '', 'media_artist': '', + 'media_duration': 0.0, 'media_playlist': '', 'media_title': '', 'source': 'Spotify', 'supported_features': , + 'volume_level': 0.0, }), 'context': , 'entity_id': 'media_player.test_media_player', @@ -125,7 +127,43 @@ 'media_title': 'Chapter 51: Cybertruck: Tesla, 2018–2019', 'source': 'Audible', 'supported_features': , - 'volume_level': 0.16129355359011466, + 'volume_level': 0.16129354838709678, + }), + 'context': , + 'entity_id': 'media_player.test_media_player', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'playing', + }) +# --- +# name: test_update_streaming[off] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'speaker', + 'friendly_name': 'Test Media player', + 'supported_features': , + }), + 'context': , + 'entity_id': 'media_player.test_media_player', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_update_streaming[on] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'speaker', + 'friendly_name': 'Test Media player', + 'media_album_name': 'Test Album', + 'media_artist': 'Test Artist', + 'media_duration': 60, + 'media_position': 5, + 'source': 'Spotify', + 'supported_features': , + 'volume_level': 0.1935483870967742, }), 'context': , 'entity_id': 'media_player.test_media_player', diff --git a/tests/components/teslemetry/snapshots/test_update.ambr b/tests/components/teslemetry/snapshots/test_update.ambr index fcd6f421993..391d81c086e 100644 --- a/tests/components/teslemetry/snapshots/test_update.ambr +++ b/tests/components/teslemetry/snapshots/test_update.ambr @@ -41,7 +41,7 @@ 'entity_picture': 'https://brands.home-assistant.io/_/teslemetry/icon.png', 'friendly_name': 'Test Update', 'in_progress': False, - 'installed_version': '2024.44.25', + 'installed_version': '2026.0.0', 'latest_version': '2024.12.0.0', 'release_summary': None, 'release_url': None, diff --git a/tests/components/teslemetry/test_media_player.py b/tests/components/teslemetry/test_media_player.py index ae462bfd026..de990dbe7bc 100644 --- a/tests/components/teslemetry/test_media_player.py +++ b/tests/components/teslemetry/test_media_player.py @@ -2,7 +2,9 @@ from unittest.mock import AsyncMock, patch +import pytest from syrupy.assertion import SnapshotAssertion +from teslemetry_stream import Signal from homeassistant.components.media_player import ( ATTR_MEDIA_VOLUME_LEVEL, @@ -18,7 +20,7 @@ from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from . import assert_entities, assert_entities_alt, setup_platform +from . import assert_entities, assert_entities_alt, reload_platform, setup_platform from .const import COMMAND_OK, METADATA_NOSCOPE, VEHICLE_DATA_ALT @@ -26,6 +28,7 @@ async def test_media_player( hass: HomeAssistant, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, + mock_legacy: AsyncMock, ) -> None: """Tests that the media player entities are correct.""" @@ -38,6 +41,7 @@ async def test_media_player_alt( snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, mock_vehicle_data: AsyncMock, + mock_legacy: AsyncMock, ) -> None: """Tests that the media player entities are correct.""" @@ -51,6 +55,7 @@ async def test_media_player_noscope( snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, mock_metadata: AsyncMock, + mock_legacy: AsyncMock, ) -> None: """Tests that the media player entities are correct without required scope.""" @@ -62,6 +67,7 @@ async def test_media_player_noscope( async def test_media_player_services( hass: HomeAssistant, snapshot: SnapshotAssertion, + mock_legacy: AsyncMock, ) -> None: """Tests that the media player services work.""" @@ -137,3 +143,62 @@ async def test_media_player_services( ) state = hass.states.get(entity_id) call.assert_called_once() + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_update_streaming( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_vehicle_data: AsyncMock, + mock_add_listener: AsyncMock, +) -> None: + """Tests that the media player entities with streaming are correct.""" + + entry = await setup_platform(hass, [Platform.MEDIA_PLAYER]) + + # Stream update + mock_add_listener.send( + { + "vin": VEHICLE_DATA_ALT["response"]["vin"], + "data": { + Signal.CENTER_DISPLAY: "Off", + Signal.MEDIA_PLAYBACK_STATUS: None, + Signal.MEDIA_PLAYBACK_SOURCE: None, + Signal.MEDIA_AUDIO_VOLUME: None, + Signal.MEDIA_NOW_PLAYING_DURATION: None, + Signal.MEDIA_NOW_PLAYING_ELAPSED: None, + Signal.MEDIA_NOW_PLAYING_ARTIST: None, + Signal.MEDIA_NOW_PLAYING_ALBUM: None, + }, + "createdAt": "2024-10-04T10:45:17.537Z", + } + ) + await hass.async_block_till_done() + state = hass.states.get("media_player.test_media_player") + assert state == snapshot(name="off") + + mock_add_listener.send( + { + "vin": VEHICLE_DATA_ALT["response"]["vin"], + "data": { + Signal.CENTER_DISPLAY: "Driving", + Signal.MEDIA_PLAYBACK_STATUS: "Playing", + Signal.MEDIA_PLAYBACK_SOURCE: "Spotify", + Signal.MEDIA_AUDIO_VOLUME: 2, + Signal.MEDIA_NOW_PLAYING_DURATION: 60000, + Signal.MEDIA_NOW_PLAYING_ELAPSED: 5000, + Signal.MEDIA_NOW_PLAYING_ARTIST: "Test Artist", + Signal.MEDIA_NOW_PLAYING_ALBUM: "Test Album", + }, + "createdAt": "2024-10-04T10:55:17.000Z", + } + ) + await hass.async_block_till_done() + state = hass.states.get("media_player.test_media_player") + assert state == snapshot(name="on") + + await reload_platform(hass, entry, [Platform.MEDIA_PLAYER]) + + # Ensure the restored state is the same as the previous state + state = hass.states.get("media_player.test_media_player") + assert state == snapshot(name="on") From 532c860bf02c98c6374ed59636c8e708f52759a8 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 14 Mar 2025 16:04:11 +0100 Subject: [PATCH 2617/3148] Bump ruff to 0.11.0 (#140598) --- .pre-commit-config.yaml | 2 +- pyproject.toml | 2 +- requirements_test_pre_commit.txt | 2 +- script/hassfest/docker/Dockerfile | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 1af73b2b5e0..42e05a869c3 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.10.0 + rev: v0.11.0 hooks: - id: ruff args: diff --git a/pyproject.toml b/pyproject.toml index bcc657528a3..a9548844e62 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -700,7 +700,7 @@ exclude_lines = [ ] [tool.ruff] -required-version = ">=0.10.0" +required-version = ">=0.11.0" [tool.ruff.lint] select = [ diff --git a/requirements_test_pre_commit.txt b/requirements_test_pre_commit.txt index a6ce0d38cb1..ff86915bbf3 100644 --- a/requirements_test_pre_commit.txt +++ b/requirements_test_pre_commit.txt @@ -1,5 +1,5 @@ # Automatically generated from .pre-commit-config.yaml by gen_requirements_all.py, do not edit codespell==2.4.1 -ruff==0.10.0 +ruff==0.11.0 yamllint==1.35.1 diff --git a/script/hassfest/docker/Dockerfile b/script/hassfest/docker/Dockerfile index a9201bff6ce..758a4355176 100644 --- a/script/hassfest/docker/Dockerfile +++ b/script/hassfest/docker/Dockerfile @@ -24,7 +24,7 @@ RUN --mount=from=ghcr.io/astral-sh/uv:0.6.1,source=/uv,target=/bin/uv \ --no-cache \ -c /usr/src/homeassistant/homeassistant/package_constraints.txt \ -r /usr/src/homeassistant/requirements.txt \ - stdlib-list==0.10.0 pipdeptree==2.25.1 tqdm==4.67.1 ruff==0.10.0 \ + stdlib-list==0.10.0 pipdeptree==2.25.1 tqdm==4.67.1 ruff==0.11.0 \ PyTurboJPEG==1.7.5 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 hassil==2.2.3 home-assistant-intents==2025.3.5 mutagen==1.47.0 pymicro-vad==1.0.1 pyspeex-noise==1.0.2 LABEL "name"="hassfest" From e740e341c8514bf1f1469f6c536076807dc47483 Mon Sep 17 00:00:00 2001 From: Hessel Date: Fri, 14 Mar 2025 16:13:07 +0100 Subject: [PATCH 2618/3148] Change max ICP value to fixed value for Wallbox Integration (#140592) change max ICP value to fixed value Co-authored-by: Hessel van Es --- homeassistant/components/wallbox/number.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/homeassistant/components/wallbox/number.py b/homeassistant/components/wallbox/number.py index 462266636d7..a5880f6e0f7 100644 --- a/homeassistant/components/wallbox/number.py +++ b/homeassistant/components/wallbox/number.py @@ -71,9 +71,7 @@ NUMBER_TYPES: dict[str, WallboxNumberEntityDescription] = { CHARGER_MAX_ICP_CURRENT_KEY: WallboxNumberEntityDescription( key=CHARGER_MAX_ICP_CURRENT_KEY, translation_key="maximum_icp_current", - max_value_fn=lambda coordinator: cast( - float, coordinator.data[CHARGER_MAX_AVAILABLE_POWER_KEY] - ), + max_value_fn=lambda _: 255, min_value_fn=lambda _: 6, set_value_fn=lambda coordinator: coordinator.async_set_icp_current, native_step=1, From 324f208d68f3d577efa9261edb47def1f6817d41 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Fri, 14 Mar 2025 16:22:23 +0100 Subject: [PATCH 2619/3148] Add lawn mower support to Google Assistant (#140530) * Add lawn mower support to google assistant * Update snapshots * Sort alphabetically * Refactor service call * Refactor service call * Feedback --- .../components/google_assistant/const.py | 4 + .../components/google_assistant/trait.py | 118 +++++++++++------- .../snapshots/test_diagnostics.ambr | 1 + .../components/google_assistant/test_trait.py | 60 +++++++++ 4 files changed, 141 insertions(+), 42 deletions(-) diff --git a/homeassistant/components/google_assistant/const.py b/homeassistant/components/google_assistant/const.py index 8132ecaae2c..71738c9d13e 100644 --- a/homeassistant/components/google_assistant/const.py +++ b/homeassistant/components/google_assistant/const.py @@ -14,6 +14,7 @@ from homeassistant.components import ( input_boolean, input_button, input_select, + lawn_mower, light, lock, media_player, @@ -58,6 +59,7 @@ DEFAULT_EXPOSED_DOMAINS = [ "humidifier", "input_boolean", "input_select", + "lawn_mower", "light", "lock", "media_player", @@ -88,6 +90,7 @@ TYPE_GATE = f"{PREFIX_TYPES}GATE" TYPE_HUMIDIFIER = f"{PREFIX_TYPES}HUMIDIFIER" TYPE_LIGHT = f"{PREFIX_TYPES}LIGHT" TYPE_LOCK = f"{PREFIX_TYPES}LOCK" +TYPE_MOWER = f"{PREFIX_TYPES}MOWER" TYPE_OUTLET = f"{PREFIX_TYPES}OUTLET" TYPE_RECEIVER = f"{PREFIX_TYPES}AUDIO_VIDEO_RECEIVER" TYPE_SCENE = f"{PREFIX_TYPES}SCENE" @@ -149,6 +152,7 @@ DOMAIN_TO_GOOGLE_TYPES = { input_boolean.DOMAIN: TYPE_SWITCH, input_button.DOMAIN: TYPE_SCENE, input_select.DOMAIN: TYPE_SENSOR, + lawn_mower.DOMAIN: TYPE_MOWER, light.DOMAIN: TYPE_LIGHT, lock.DOMAIN: TYPE_LOCK, media_player.DOMAIN: TYPE_SETTOP, diff --git a/homeassistant/components/google_assistant/trait.py b/homeassistant/components/google_assistant/trait.py index 44251a3be04..9edd340d7d9 100644 --- a/homeassistant/components/google_assistant/trait.py +++ b/homeassistant/components/google_assistant/trait.py @@ -21,6 +21,7 @@ from homeassistant.components import ( input_boolean, input_button, input_select, + lawn_mower, light, lock, media_player, @@ -42,6 +43,7 @@ from homeassistant.components.climate import ClimateEntityFeature from homeassistant.components.cover import CoverEntityFeature from homeassistant.components.fan import FanEntityFeature from homeassistant.components.humidifier import HumidifierEntityFeature +from homeassistant.components.lawn_mower import LawnMowerEntityFeature from homeassistant.components.light import LightEntityFeature from homeassistant.components.lock import LockState from homeassistant.components.media_player import MediaPlayerEntityFeature, MediaType @@ -714,7 +716,7 @@ class DockTrait(_Trait): @staticmethod def supported(domain, features, device_class, _): """Test if state is supported.""" - return domain == vacuum.DOMAIN + return domain in (vacuum.DOMAIN, lawn_mower.DOMAIN) def sync_attributes(self) -> dict[str, Any]: """Return dock attributes for a sync request.""" @@ -722,17 +724,32 @@ class DockTrait(_Trait): def query_attributes(self) -> dict[str, Any]: """Return dock query attributes.""" - return {"isDocked": self.state.state == vacuum.VacuumActivity.DOCKED} + domain = self.state.domain + state = self.state.state + if domain == vacuum.DOMAIN: + return {"isDocked": state == vacuum.VacuumActivity.DOCKED} + if domain == lawn_mower.DOMAIN: + return {"isDocked": state == lawn_mower.LawnMowerActivity.DOCKED} + raise NotImplementedError(f"Unsupported domain {domain}") async def execute(self, command, data, params, challenge): """Execute a dock command.""" - await self.hass.services.async_call( - self.state.domain, - vacuum.SERVICE_RETURN_TO_BASE, - {ATTR_ENTITY_ID: self.state.entity_id}, - blocking=not self.config.should_report_state, - context=data.context, - ) + domain = self.state.domain + service: str | None = None + + if domain == vacuum.DOMAIN: + service = vacuum.SERVICE_RETURN_TO_BASE + elif domain == lawn_mower.DOMAIN: + service = lawn_mower.SERVICE_DOCK + + if service: + await self.hass.services.async_call( + self.state.domain, + service, + {ATTR_ENTITY_ID: self.state.entity_id}, + blocking=not self.config.should_report_state, + context=data.context, + ) @register_trait @@ -843,7 +860,7 @@ class StartStopTrait(_Trait): @staticmethod def supported(domain, features, device_class, _): """Test if state is supported.""" - if domain == vacuum.DOMAIN: + if domain in (vacuum.DOMAIN, lawn_mower.DOMAIN): return True if ( @@ -863,6 +880,12 @@ class StartStopTrait(_Trait): & VacuumEntityFeature.PAUSE != 0 } + if domain == lawn_mower.DOMAIN: + return { + "pausable": self.state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + & LawnMowerEntityFeature.PAUSE + != 0 + } if domain in COVER_VALVE_DOMAINS: return {} @@ -878,6 +901,11 @@ class StartStopTrait(_Trait): "isRunning": state == vacuum.VacuumActivity.CLEANING, "isPaused": state == vacuum.VacuumActivity.PAUSED, } + if domain == lawn_mower.DOMAIN: + return { + "isRunning": state == lawn_mower.LawnMowerActivity.MOWING, + "isPaused": state == lawn_mower.LawnMowerActivity.PAUSED, + } if domain in COVER_VALVE_DOMAINS: return { @@ -896,46 +924,52 @@ class StartStopTrait(_Trait): if domain == vacuum.DOMAIN: await self._execute_vacuum(command, data, params, challenge) return + if domain == lawn_mower.DOMAIN: + await self._execute_lawn_mower(command, data, params, challenge) + return if domain in COVER_VALVE_DOMAINS: await self._execute_cover_or_valve(command, data, params, challenge) return async def _execute_vacuum(self, command, data, params, challenge): """Execute a StartStop command.""" + service: str | None = None if command == COMMAND_START_STOP: - if params["start"]: - await self.hass.services.async_call( - self.state.domain, - vacuum.SERVICE_START, - {ATTR_ENTITY_ID: self.state.entity_id}, - blocking=not self.config.should_report_state, - context=data.context, - ) - else: - await self.hass.services.async_call( - self.state.domain, - vacuum.SERVICE_STOP, - {ATTR_ENTITY_ID: self.state.entity_id}, - blocking=not self.config.should_report_state, - context=data.context, - ) + service = vacuum.SERVICE_START if params["start"] else vacuum.SERVICE_STOP elif command == COMMAND_PAUSE_UNPAUSE: - if params["pause"]: - await self.hass.services.async_call( - self.state.domain, - vacuum.SERVICE_PAUSE, - {ATTR_ENTITY_ID: self.state.entity_id}, - blocking=not self.config.should_report_state, - context=data.context, - ) - else: - await self.hass.services.async_call( - self.state.domain, - vacuum.SERVICE_START, - {ATTR_ENTITY_ID: self.state.entity_id}, - blocking=not self.config.should_report_state, - context=data.context, - ) + service = vacuum.SERVICE_PAUSE if params["pause"] else vacuum.SERVICE_START + if service: + await self.hass.services.async_call( + self.state.domain, + service, + {ATTR_ENTITY_ID: self.state.entity_id}, + blocking=not self.config.should_report_state, + context=data.context, + ) + + async def _execute_lawn_mower(self, command, data, params, challenge): + """Execute a StartStop command.""" + service: str | None = None + if command == COMMAND_START_STOP: + service = ( + lawn_mower.SERVICE_START_MOWING + if params["start"] + else lawn_mower.SERVICE_DOCK + ) + elif command == COMMAND_PAUSE_UNPAUSE: + service = ( + lawn_mower.SERVICE_PAUSE + if params["pause"] + else lawn_mower.SERVICE_START_MOWING + ) + if service: + await self.hass.services.async_call( + self.state.domain, + service, + {ATTR_ENTITY_ID: self.state.entity_id}, + blocking=not self.config.should_report_state, + context=data.context, + ) async def _execute_cover_or_valve(self, command, data, params, challenge): """Execute a StartStop command.""" diff --git a/tests/components/google_assistant/snapshots/test_diagnostics.ambr b/tests/components/google_assistant/snapshots/test_diagnostics.ambr index 1ecedbd1173..cc5ccbb1de1 100644 --- a/tests/components/google_assistant/snapshots/test_diagnostics.ambr +++ b/tests/components/google_assistant/snapshots/test_diagnostics.ambr @@ -98,6 +98,7 @@ 'humidifier', 'input_boolean', 'input_select', + 'lawn_mower', 'light', 'lock', 'media_player', diff --git a/tests/components/google_assistant/test_trait.py b/tests/components/google_assistant/test_trait.py index 1fc4a0e3a0c..cf9c8047049 100644 --- a/tests/components/google_assistant/test_trait.py +++ b/tests/components/google_assistant/test_trait.py @@ -21,6 +21,7 @@ from homeassistant.components import ( input_boolean, input_button, input_select, + lawn_mower, light, lock, media_player, @@ -44,6 +45,7 @@ from homeassistant.components.fan import FanEntityFeature from homeassistant.components.google_assistant import const, error, helpers, trait from homeassistant.components.google_assistant.error import SmartHomeError from homeassistant.components.humidifier import HumidifierEntityFeature +from homeassistant.components.lawn_mower import LawnMowerEntityFeature from homeassistant.components.light import LightEntityFeature from homeassistant.components.lock import LockEntityFeature from homeassistant.components.media_player import ( @@ -589,6 +591,64 @@ async def test_startstop_vacuum(hass: HomeAssistant) -> None: assert unpause_calls[0].data == {ATTR_ENTITY_ID: "vacuum.bla"} +async def test_dock_lawn_mower(hass: HomeAssistant) -> None: + """Test dock trait support for lawn mower domain.""" + assert helpers.get_google_type(lawn_mower.DOMAIN, None) is not None + assert trait.DockTrait.supported(lawn_mower.DOMAIN, 0, None, None) + + trt = trait.DockTrait( + hass, State("lawn_mower.bla", lawn_mower.LawnMowerActivity.MOWING), BASIC_CONFIG + ) + + assert trt.sync_attributes() == {} + + assert trt.query_attributes() == {"isDocked": False} + + calls = async_mock_service(hass, lawn_mower.DOMAIN, lawn_mower.SERVICE_DOCK) + await trt.execute(trait.COMMAND_DOCK, BASIC_DATA, {}, {}) + assert len(calls) == 1 + assert calls[0].data == {ATTR_ENTITY_ID: "lawn_mower.bla"} + + +async def test_startstop_lawn_mower(hass: HomeAssistant) -> None: + """Test startStop trait support for lawn mower domain.""" + assert helpers.get_google_type(lawn_mower.DOMAIN, None) is not None + assert trait.StartStopTrait.supported(lawn_mower.DOMAIN, 0, None, None) + + trt = trait.StartStopTrait( + hass, + State( + "lawn_mower.bla", + lawn_mower.LawnMowerActivity.PAUSED, + {ATTR_SUPPORTED_FEATURES: LawnMowerEntityFeature.PAUSE}, + ), + BASIC_CONFIG, + ) + + assert trt.sync_attributes() == {"pausable": True} + + assert trt.query_attributes() == {"isRunning": False, "isPaused": True} + + start_calls = async_mock_service( + hass, lawn_mower.DOMAIN, lawn_mower.SERVICE_START_MOWING + ) + await trt.execute(trait.COMMAND_START_STOP, BASIC_DATA, {"start": True}, {}) + assert len(start_calls) == 1 + assert start_calls[0].data == {ATTR_ENTITY_ID: "lawn_mower.bla"} + + pause_calls = async_mock_service(hass, lawn_mower.DOMAIN, lawn_mower.SERVICE_PAUSE) + await trt.execute(trait.COMMAND_PAUSE_UNPAUSE, BASIC_DATA, {"pause": True}, {}) + assert len(pause_calls) == 1 + assert pause_calls[0].data == {ATTR_ENTITY_ID: "lawn_mower.bla"} + + unpause_calls = async_mock_service( + hass, lawn_mower.DOMAIN, lawn_mower.SERVICE_START_MOWING + ) + await trt.execute(trait.COMMAND_PAUSE_UNPAUSE, BASIC_DATA, {"pause": False}, {}) + assert len(unpause_calls) == 1 + assert unpause_calls[0].data == {ATTR_ENTITY_ID: "lawn_mower.bla"} + + @pytest.mark.parametrize( ( "domain", From 78a04776e4b6acacb1eb077d686dce0bd8a4e178 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Fri, 14 Mar 2025 16:49:56 +0100 Subject: [PATCH 2620/3148] Add update_daily action to Habitica integration (#140328) * add update_daily action * day strings --- homeassistant/components/habitica/const.py | 8 + homeassistant/components/habitica/icons.json | 11 + homeassistant/components/habitica/services.py | 138 +++++++-- .../components/habitica/services.yaml | 94 +++++- .../components/habitica/strings.json | 136 ++++++++- tests/components/habitica/fixtures/tasks.json | 13 +- .../habitica/snapshots/test_services.ambr | 40 +++ tests/components/habitica/test_services.py | 275 +++++++++++++++++- 8 files changed, 691 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/habitica/const.py b/homeassistant/components/habitica/const.py index cf9d08c160c..8b745ff2b99 100644 --- a/homeassistant/components/habitica/const.py +++ b/homeassistant/components/habitica/const.py @@ -52,6 +52,11 @@ ATTR_REMINDER = "reminder" ATTR_REMOVE_REMINDER = "remove_reminder" ATTR_CLEAR_REMINDER = "clear_reminder" ATTR_CLEAR_DATE = "clear_date" +ATTR_REPEAT = "repeat" +ATTR_INTERVAL = "every_x" +ATTR_START_DATE = "start_date" +ATTR_REPEAT_MONTHLY = "repeat_monthly" +ATTR_STREAK = "streak" SERVICE_CAST_SKILL = "cast_skill" SERVICE_START_QUEST = "start_quest" @@ -73,6 +78,7 @@ SERVICE_UPDATE_HABIT = "update_habit" SERVICE_CREATE_HABIT = "create_habit" SERVICE_UPDATE_TODO = "update_todo" SERVICE_CREATE_TODO = "create_todo" +SERVICE_UPDATE_DAILY = "update_daily" DEVELOPER_ID = "4c4ca53f-c059-4ffa-966e-9d29dd405daf" X_CLIENT = f"{DEVELOPER_ID} - {APPLICATION_NAME} {__version__}" @@ -80,3 +86,5 @@ X_CLIENT = f"{DEVELOPER_ID} - {APPLICATION_NAME} {__version__}" SECTION_REAUTH_LOGIN = "reauth_login" SECTION_REAUTH_API_KEY = "reauth_api_key" SECTION_DANGER_ZONE = "danger_zone" + +WEEK_DAYS = ["m", "t", "w", "th", "f", "s", "su"] diff --git a/homeassistant/components/habitica/icons.json b/homeassistant/components/habitica/icons.json index 85adfa09304..fcb9ec56fa7 100644 --- a/homeassistant/components/habitica/icons.json +++ b/homeassistant/components/habitica/icons.json @@ -259,6 +259,17 @@ "sections": { "developer_options": "mdi:test-tube" } + }, + "update_daily": { + "service": "mdi:calendar-month", + "sections": { + "checklist_options": "mdi:format-list-checks", + "tag_options": "mdi:tag", + "developer_options": "mdi:test-tube", + "reminder_options": "mdi:reminder", + "repeat_weekly_options": "mdi:calendar-refresh", + "repeat_monthly_options": "mdi:calendar-refresh" + } } } } diff --git a/homeassistant/components/habitica/services.py b/homeassistant/components/habitica/services.py index bb8f69a8d11..9fb0b0b7537 100644 --- a/homeassistant/components/habitica/services.py +++ b/homeassistant/components/habitica/services.py @@ -3,7 +3,7 @@ from __future__ import annotations from dataclasses import asdict -from datetime import datetime, time +from datetime import UTC, date, datetime, time import logging from typing import TYPE_CHECKING, Any, cast from uuid import UUID, uuid4 @@ -17,6 +17,7 @@ from habiticalib import ( NotAuthorizedError, NotFoundError, Reminders, + Repeat, Skill, Task, TaskData, @@ -39,6 +40,7 @@ from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import config_validation as cv from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.selector import ConfigEntrySelector +from homeassistant.util import dt as dt_util from .const import ( ATTR_ADD_CHECKLIST_ITEM, @@ -53,6 +55,7 @@ from .const import ( ATTR_DATA, ATTR_DIRECTION, ATTR_FREQUENCY, + ATTR_INTERVAL, ATTR_ITEM, ATTR_KEYWORD, ATTR_NOTES, @@ -62,8 +65,12 @@ from .const import ( ATTR_REMOVE_CHECKLIST_ITEM, ATTR_REMOVE_REMINDER, ATTR_REMOVE_TAG, + ATTR_REPEAT, + ATTR_REPEAT_MONTHLY, ATTR_SCORE_CHECKLIST_ITEM, ATTR_SKILL, + ATTR_START_DATE, + ATTR_STREAK, ATTR_TAG, ATTR_TARGET, ATTR_TASK, @@ -87,9 +94,11 @@ from .const import ( SERVICE_SCORE_REWARD, SERVICE_START_QUEST, SERVICE_TRANSFORMATION, + SERVICE_UPDATE_DAILY, SERVICE_UPDATE_HABIT, SERVICE_UPDATE_REWARD, SERVICE_UPDATE_TODO, + WEEK_DAYS, ) from .coordinator import HabiticaConfigEntry @@ -152,13 +161,24 @@ BASE_TASK_SCHEMA = vol.Schema( vol.Optional(ATTR_FREQUENCY): vol.Coerce(Frequency), vol.Optional(ATTR_DATE): cv.date, vol.Optional(ATTR_CLEAR_DATE): cv.boolean, - vol.Optional(ATTR_REMINDER): vol.All(cv.ensure_list, [cv.datetime]), - vol.Optional(ATTR_REMOVE_REMINDER): vol.All(cv.ensure_list, [cv.datetime]), + vol.Optional(ATTR_REMINDER): vol.All( + cv.ensure_list, [vol.Any(cv.datetime, cv.time)] + ), + vol.Optional(ATTR_REMOVE_REMINDER): vol.All( + cv.ensure_list, [vol.Any(cv.datetime, cv.time)] + ), vol.Optional(ATTR_CLEAR_REMINDER): cv.boolean, vol.Optional(ATTR_ADD_CHECKLIST_ITEM): vol.All(cv.ensure_list, [str]), vol.Optional(ATTR_REMOVE_CHECKLIST_ITEM): vol.All(cv.ensure_list, [str]), vol.Optional(ATTR_SCORE_CHECKLIST_ITEM): vol.All(cv.ensure_list, [str]), vol.Optional(ATTR_UNSCORE_CHECKLIST_ITEM): vol.All(cv.ensure_list, [str]), + vol.Optional(ATTR_START_DATE): cv.date, + vol.Optional(ATTR_INTERVAL): vol.All(int, vol.Range(0)), + vol.Optional(ATTR_REPEAT): vol.All(cv.ensure_list, [vol.In(WEEK_DAYS)]), + vol.Optional(ATTR_REPEAT_MONTHLY): vol.All( + cv.string, vol.In({"day_of_month", "day_of_week"}) + ), + vol.Optional(ATTR_STREAK): vol.All(int, vol.Range(0)), } ) @@ -175,6 +195,12 @@ SERVICE_CREATE_TASK_SCHEMA = BASE_TASK_SCHEMA.extend( } ) +SERVICE_DAILY_SCHEMA = { + vol.Optional(ATTR_REMINDER): vol.All(cv.ensure_list, [cv.time]), + vol.Optional(ATTR_REMOVE_REMINDER): vol.All(cv.ensure_list, [cv.time]), +} + + SERVICE_GET_TASKS_SCHEMA = vol.Schema( { vol.Required(ATTR_CONFIG_ENTRY): ConfigEntrySelector({"integration": DOMAIN}), @@ -216,6 +242,7 @@ SERVICE_TASK_TYPE_MAP = { SERVICE_CREATE_HABIT: TaskType.HABIT, SERVICE_UPDATE_TODO: TaskType.TODO, SERVICE_CREATE_TODO: TaskType.TODO, + SERVICE_UPDATE_DAILY: TaskType.DAILY, } @@ -605,7 +632,9 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 SERVICE_UPDATE_HABIT, SERVICE_UPDATE_REWARD, SERVICE_UPDATE_TODO, + SERVICE_UPDATE_DAILY, ) + task_type = SERVICE_TASK_TYPE_MAP[call.service] current_task = None if is_update: @@ -614,7 +643,7 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 task for task in coordinator.data.tasks if call.data[ATTR_TASK] in (str(task.id), task.alias, task.text) - and task.Type is SERVICE_TASK_TYPE_MAP[call.service] + and task.Type is task_type ) except StopIteration as e: raise ServiceValidationError( @@ -626,7 +655,7 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 data = Task() if not is_update: - data["type"] = SERVICE_TASK_TYPE_MAP[call.service] + data["type"] = task_type if (text := call.data.get(ATTR_RENAME)) or (text := call.data.get(ATTR_NAME)): data["text"] = text @@ -702,6 +731,8 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 if frequency := call.data.get(ATTR_FREQUENCY): data["frequency"] = frequency + else: + frequency = current_task.frequency if current_task else Frequency.WEEKLY if up_down := call.data.get(ATTR_UP_DOWN): data["up"] = "up" in up_down @@ -752,23 +783,46 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 reminders = current_task.reminders if current_task else [] if add_reminders := call.data.get(ATTR_REMINDER): - existing_reminder_datetimes = { - r.time.replace(tzinfo=None) for r in reminders - } + if task_type is TaskType.TODO: + existing_reminder_datetimes = { + r.time.replace(tzinfo=None) for r in reminders + } - reminders.extend( - Reminders(id=uuid4(), time=r) - for r in add_reminders - if r not in existing_reminder_datetimes - ) + reminders.extend( + Reminders(id=uuid4(), time=r) + for r in add_reminders + if r not in existing_reminder_datetimes + ) + if task_type is TaskType.DAILY: + existing_reminder_times = { + r.time.time().replace(microsecond=0, second=0) for r in reminders + } + + reminders.extend( + Reminders( + id=uuid4(), + time=datetime.combine(date.today(), r, tzinfo=UTC), + ) + for r in add_reminders + if r not in existing_reminder_times + ) if remove_reminder := call.data.get(ATTR_REMOVE_REMINDER): - reminders = list( - filter( - lambda r: r.time.replace(tzinfo=None) not in remove_reminder, - reminders, + if task_type is TaskType.TODO: + reminders = list( + filter( + lambda r: r.time.replace(tzinfo=None) not in remove_reminder, + reminders, + ) + ) + if task_type is TaskType.DAILY: + reminders = list( + filter( + lambda r: r.time.time().replace(second=0, microsecond=0) + not in remove_reminder, + reminders, + ) ) - ) if clear_reminders := call.data.get(ATTR_CLEAR_REMINDER): reminders = [] @@ -776,6 +830,47 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 if add_reminders or remove_reminder or clear_reminders: data["reminders"] = reminders + if start_date := call.data.get(ATTR_START_DATE): + data["startDate"] = datetime.combine(start_date, time()) + else: + start_date = ( + current_task.startDate + if current_task and current_task.startDate + else dt_util.start_of_local_day() + ) + if repeat := call.data.get(ATTR_REPEAT): + if frequency is Frequency.WEEKLY: + data["repeat"] = Repeat(**{d: d in repeat for d in WEEK_DAYS}) + else: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="frequency_not_weekly", + ) + if repeat_monthly := call.data.get(ATTR_REPEAT_MONTHLY): + if frequency is not Frequency.MONTHLY: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="frequency_not_monthly", + ) + + if repeat_monthly == "day_of_week": + weekday = start_date.weekday() + data["weeksOfMonth"] = [(start_date.day - 1) // 7] + data["repeat"] = Repeat( + **{day: i == weekday for i, day in enumerate(WEEK_DAYS)} + ) + data["daysOfMonth"] = [] + + else: + data["daysOfMonth"] = [start_date.day] + data["weeksOfMonth"] = [] + + if interval := call.data.get(ATTR_INTERVAL): + data["everyX"] = interval + + if streak := call.data.get(ATTR_STREAK): + data["streak"] = streak + try: if is_update: if TYPE_CHECKING: @@ -805,7 +900,12 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 else: return response.data.to_dict(omit_none=True) - for service in (SERVICE_UPDATE_TODO, SERVICE_UPDATE_REWARD, SERVICE_UPDATE_HABIT): + for service in ( + SERVICE_UPDATE_DAILY, + SERVICE_UPDATE_HABIT, + SERVICE_UPDATE_REWARD, + SERVICE_UPDATE_TODO, + ): hass.services.async_register( DOMAIN, service, diff --git a/homeassistant/components/habitica/services.yaml b/homeassistant/components/habitica/services.yaml index acbe4e62824..46b3211790e 100644 --- a/homeassistant/components/habitica/services.yaml +++ b/homeassistant/components/habitica/services.yaml @@ -268,7 +268,7 @@ update_todo: task: *task rename: *rename notes: *notes - checklist_options: + checklist_options: &checklist_options collapsed: true fields: add_checklist_item: &add_checklist_item @@ -320,7 +320,7 @@ update_todo: text: type: datetime-local multiple: true - clear_reminder: + clear_reminder: &clear_reminder required: false selector: constant: @@ -339,3 +339,93 @@ create_todo: reminder: *reminder tag: *tag developer_options: *developer_options +update_daily: + fields: + config_entry: *config_entry + task: *task + rename: *rename + notes: *notes + checklist_options: *checklist_options + priority: *priority + start_date: + required: false + selector: + date: + frequency: + required: false + selector: + select: + options: + - "daily" + - "weekly" + - "monthly" + - "yearly" + translation_key: "frequency" + mode: dropdown + every_x: + required: false + selector: + number: + min: 0 + step: 1 + unit_of_measurement: "🔃" + mode: box + repeat_weekly_options: + collapsed: true + fields: + repeat: + required: false + selector: + select: + options: + - "m" + - "t" + - "w" + - "th" + - "f" + - "s" + - "su" + mode: list + translation_key: repeat + multiple: true + repeat_monthly_options: + collapsed: true + fields: + repeat_monthly: + required: false + selector: + select: + options: + - "day_of_month" + - "day_of_week" + translation_key: repeat_monthly + mode: list + reminder_options: + collapsed: true + fields: + reminder: + required: false + selector: + text: + type: time + multiple: true + remove_reminder: + required: false + selector: + text: + type: time + multiple: true + clear_reminder: *clear_reminder + tag_options: *tag_options + developer_options: + collapsed: true + fields: + streak: + required: false + selector: + number: + min: 0 + step: 1 + unit_of_measurement: "▶▶" + mode: box + alias: *alias diff --git a/homeassistant/components/habitica/strings.json b/homeassistant/components/habitica/strings.json index 513c0b36b27..cc67b767519 100644 --- a/homeassistant/components/habitica/strings.json +++ b/homeassistant/components/habitica/strings.json @@ -51,7 +51,8 @@ "reminder_options_name": "Reminders", "reminder_options_description": "Add, remove or clear reminders of a Habitica task.", "date_name": "Due date", - "date_description": "The to-do's due date." + "date_description": "The to-do's due date.", + "repeat_name": "Repeat on" }, "config": { "abort": { @@ -1037,6 +1038,122 @@ "description": "[%key:component::habitica::common::developer_options_description%]" } } + }, + "update_daily": { + "name": "Update a daily", + "description": "Updates a specific daily for a selected Habitica character", + "fields": { + "config_entry": { + "name": "[%key:component::habitica::common::config_entry_name%]", + "description": "[%key:component::habitica::common::config_entry_description%]" + }, + "task": { + "name": "[%key:component::habitica::common::task_name%]", + "description": "The name (or task ID) of the daily you want to update." + }, + "rename": { + "name": "[%key:component::habitica::common::rename_name%]", + "description": "[%key:component::habitica::common::rename_description%]" + }, + "notes": { + "name": "[%key:component::habitica::common::notes_name%]", + "description": "[%key:component::habitica::common::notes_description%]" + }, + "tag": { + "name": "[%key:component::habitica::common::tag_name%]", + "description": "[%key:component::habitica::common::tag_description%]" + }, + "remove_tag": { + "name": "[%key:component::habitica::common::remove_tag_name%]", + "description": "[%key:component::habitica::common::remove_tag_description%]" + }, + "alias": { + "name": "[%key:component::habitica::common::alias_name%]", + "description": "[%key:component::habitica::common::alias_description%]" + }, + "priority": { + "name": "[%key:component::habitica::common::priority_name%]", + "description": "[%key:component::habitica::common::priority_description%]" + }, + "start_date": { + "name": "Start date", + "description": "Defines when the daily task becomes active and specifies the exact weekday or day of the month it repeats on." + }, + "frequency": { + "name": "Repeat interval", + "description": "The repetition interval of a daily." + }, + "every_x": { + "name": "Repeat every X", + "description": "The number of intervals (days, weeks, months, or years) after which the daily repeats, based on the chosen repetition interval. A value of 0 makes the daily inactive ('Grey Daily')." + }, + "repeat": { + "name": "[%key:component::habitica::common::repeat_name%]", + "description": "The days of the week the daily repeats." + }, + "repeat_monthly": { + "name": "[%key:component::habitica::common::repeat_name%]", + "description": "Whether a monthly recurring task repeats on the same calendar day each month or on the same weekday and week of the month, based on the start date." + }, + "add_checklist_item": { + "name": "[%key:component::habitica::common::add_checklist_item_name%]", + "description": "[%key:component::habitica::common::add_checklist_item_description%]" + }, + "remove_checklist_item": { + "name": "[%key:component::habitica::common::remove_checklist_item_name%]", + "description": "[%key:component::habitica::common::remove_checklist_item_description%]" + }, + "score_checklist_item": { + "name": "[%key:component::habitica::common::score_checklist_item_name%]", + "description": "[%key:component::habitica::common::score_checklist_item_description%]" + }, + "unscore_checklist_item": { + "name": "[%key:component::habitica::common::unscore_checklist_item_name%]", + "description": "[%key:component::habitica::common::unscore_checklist_item_description%]" + }, + "streak": { + "name": "Adjust streak", + "description": "Adjust or reset the streak counter of the daily." + }, + "reminder": { + "name": "[%key:component::habitica::common::reminder_name%]", + "description": "[%key:component::habitica::common::reminder_description%]" + }, + "remove_reminder": { + "name": "[%key:component::habitica::common::remove_reminder_name%]", + "description": "[%key:component::habitica::common::remove_reminder_description%]" + }, + "clear_reminder": { + "name": "[%key:component::habitica::common::clear_reminder_name%]", + "description": "[%key:component::habitica::common::clear_reminder_description%]" + } + }, + "sections": { + "checklist_options": { + "name": "[%key:component::habitica::common::checklist_options_name%]", + "description": "[%key:component::habitica::common::checklist_options_description%]" + }, + "repeat_weekly_options": { + "name": "Weekly repeat days", + "description": "Options related to weekly repetition, applicable when the repetition interval is set to weekly." + }, + "repeat_monthly_options": { + "name": "Monthly repeat day", + "description": "Options related to monthly repetition, applicable when the repetition interval is set to monthly." + }, + "tag_options": { + "name": "[%key:component::habitica::common::tag_options_name%]", + "description": "[%key:component::habitica::common::tag_options_description%]" + }, + "developer_options": { + "name": "[%key:component::habitica::common::developer_options_name%]", + "description": "[%key:component::habitica::common::developer_options_description%]" + }, + "reminder_options": { + "name": "[%key:component::habitica::common::reminder_options_name%]", + "description": "[%key:component::habitica::common::reminder_options_description%]" + } + } } }, "selector": { @@ -1079,6 +1196,23 @@ "monthly": "Monthly", "yearly": "Yearly" } + }, + "repeat": { + "options": { + "m": "[%key:common::time::monday%]", + "t": "[%key:common::time::tuesday%]", + "w": "[%key:common::time::wednesday%]", + "th": "[%key:common::time::thursday%]", + "f": "[%key:common::time::friday%]", + "s": "[%key:common::time::saturday%]", + "su": "[%key:common::time::sunday%]" + } + }, + "repeat_monthly": { + "options": { + "day_of_month": "Day of the month", + "day_of_week": "Day of the week" + } } } } diff --git a/tests/components/habitica/fixtures/tasks.json b/tests/components/habitica/fixtures/tasks.json index 3dff57bdd51..085508b4432 100644 --- a/tests/components/habitica/fixtures/tasks.json +++ b/tests/components/habitica/fixtures/tasks.json @@ -605,7 +605,18 @@ "startDate": "2024-09-20T23:00:00.000Z", "daysOfMonth": [], "weeksOfMonth": [3], - "checklist": [], + "checklist": [ + { + "completed": false, + "id": "a2a6702d-58e1-46c2-a3ce-422d525cc0b6", + "text": "Checklist-item1" + }, + { + "completed": true, + "id": "9f64e1cd-b0ab-4577-8344-c7a5e1827997", + "text": "Checklist-item2" + } + ], "reminders": [], "createdAt": "2024-10-10T15:57:14.304Z", "updatedAt": "2024-11-27T23:47:29.986Z", diff --git a/tests/components/habitica/snapshots/test_services.ambr b/tests/components/habitica/snapshots/test_services.ambr index af0ec76f3a4..430cd379c0d 100644 --- a/tests/components/habitica/snapshots/test_services.ambr +++ b/tests/components/habitica/snapshots/test_services.ambr @@ -1116,6 +1116,16 @@ 'winner': None, }), 'checklist': list([ + dict({ + 'completed': False, + 'id': 'a2a6702d-58e1-46c2-a3ce-422d525cc0b6', + 'text': 'Checklist-item1', + }), + dict({ + 'completed': True, + 'id': '9f64e1cd-b0ab-4577-8344-c7a5e1827997', + 'text': 'Checklist-item2', + }), ]), 'collapseChecklist': False, 'completed': False, @@ -3378,6 +3388,16 @@ 'winner': None, }), 'checklist': list([ + dict({ + 'completed': False, + 'id': 'a2a6702d-58e1-46c2-a3ce-422d525cc0b6', + 'text': 'Checklist-item1', + }), + dict({ + 'completed': True, + 'id': '9f64e1cd-b0ab-4577-8344-c7a5e1827997', + 'text': 'Checklist-item2', + }), ]), 'collapseChecklist': False, 'completed': False, @@ -4511,6 +4531,16 @@ 'winner': None, }), 'checklist': list([ + dict({ + 'completed': False, + 'id': 'a2a6702d-58e1-46c2-a3ce-422d525cc0b6', + 'text': 'Checklist-item1', + }), + dict({ + 'completed': True, + 'id': '9f64e1cd-b0ab-4577-8344-c7a5e1827997', + 'text': 'Checklist-item2', + }), ]), 'collapseChecklist': False, 'completed': False, @@ -5092,6 +5122,16 @@ 'winner': None, }), 'checklist': list([ + dict({ + 'completed': False, + 'id': 'a2a6702d-58e1-46c2-a3ce-422d525cc0b6', + 'text': 'Checklist-item1', + }), + dict({ + 'completed': True, + 'id': '9f64e1cd-b0ab-4577-8344-c7a5e1827997', + 'text': 'Checklist-item2', + }), ]), 'collapseChecklist': False, 'completed': False, diff --git a/tests/components/habitica/test_services.py b/tests/components/habitica/test_services.py index 238cb8412ba..258346b9ca7 100644 --- a/tests/components/habitica/test_services.py +++ b/tests/components/habitica/test_services.py @@ -1,18 +1,20 @@ """Test Habitica actions.""" from collections.abc import Generator -from datetime import datetime +from datetime import UTC, datetime from typing import Any from unittest.mock import AsyncMock, patch from uuid import UUID from aiohttp import ClientError +from freezegun.api import freeze_time from habiticalib import ( Checklist, Direction, Frequency, HabiticaTaskResponse, Reminders, + Repeat, Skill, Task, TaskPriority, @@ -32,6 +34,7 @@ from homeassistant.components.habitica.const import ( ATTR_COUNTER_UP, ATTR_DIRECTION, ATTR_FREQUENCY, + ATTR_INTERVAL, ATTR_ITEM, ATTR_KEYWORD, ATTR_NOTES, @@ -40,8 +43,12 @@ from homeassistant.components.habitica.const import ( ATTR_REMOVE_CHECKLIST_ITEM, ATTR_REMOVE_REMINDER, ATTR_REMOVE_TAG, + ATTR_REPEAT, + ATTR_REPEAT_MONTHLY, ATTR_SCORE_CHECKLIST_ITEM, ATTR_SKILL, + ATTR_START_DATE, + ATTR_STREAK, ATTR_TAG, ATTR_TARGET, ATTR_TASK, @@ -63,6 +70,7 @@ from homeassistant.components.habitica.const import ( SERVICE_SCORE_REWARD, SERVICE_START_QUEST, SERVICE_TRANSFORMATION, + SERVICE_UPDATE_DAILY, SERVICE_UPDATE_HABIT, SERVICE_UPDATE_REWARD, SERVICE_UPDATE_TODO, @@ -952,6 +960,7 @@ async def test_get_tasks( (SERVICE_UPDATE_REWARD, "5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b"), (SERVICE_UPDATE_HABIT, "f21fa608-cfc6-4413-9fc7-0eb1b48ca43a"), (SERVICE_UPDATE_TODO, "88de7cd9-af2b-49ce-9afd-bf941d87336b"), + (SERVICE_UPDATE_DAILY, "6e53f1f5-a315-4edd-984d-8d762e4a08ef"), ], ) @pytest.mark.usefixtures("habitica") @@ -1606,6 +1615,270 @@ async def test_create_todo( habitica.create_task.assert_awaited_with(call_args) +@pytest.mark.parametrize( + ("service_data", "call_args"), + [ + ( + { + ATTR_RENAME: "RENAME", + }, + Task(text="RENAME"), + ), + ( + { + ATTR_NOTES: "NOTES", + }, + Task(notes="NOTES"), + ), + ( + { + ATTR_ADD_CHECKLIST_ITEM: "Checklist-item", + }, + Task( + { + "checklist": [ + Checklist( + id=UUID("a2a6702d-58e1-46c2-a3ce-422d525cc0b6"), + text="Checklist-item1", + completed=False, + ), + Checklist( + id=UUID("9f64e1cd-b0ab-4577-8344-c7a5e1827997"), + text="Checklist-item2", + completed=True, + ), + Checklist( + id=UUID("12345678-1234-5678-1234-567812345678"), + text="Checklist-item", + completed=False, + ), + ] + } + ), + ), + ( + { + ATTR_REMOVE_CHECKLIST_ITEM: "Checklist-item1", + }, + Task( + { + "checklist": [ + Checklist( + id=UUID("9f64e1cd-b0ab-4577-8344-c7a5e1827997"), + text="Checklist-item2", + completed=True, + ), + ] + } + ), + ), + ( + { + ATTR_SCORE_CHECKLIST_ITEM: "Checklist-item1", + }, + Task( + { + "checklist": [ + Checklist( + id=UUID("a2a6702d-58e1-46c2-a3ce-422d525cc0b6"), + text="Checklist-item1", + completed=True, + ), + Checklist( + id=UUID("9f64e1cd-b0ab-4577-8344-c7a5e1827997"), + text="Checklist-item2", + completed=True, + ), + ] + } + ), + ), + ( + { + ATTR_UNSCORE_CHECKLIST_ITEM: "Checklist-item2", + }, + Task( + { + "checklist": [ + Checklist( + id=UUID("a2a6702d-58e1-46c2-a3ce-422d525cc0b6"), + text="Checklist-item1", + completed=False, + ), + Checklist( + id=UUID("9f64e1cd-b0ab-4577-8344-c7a5e1827997"), + text="Checklist-item2", + completed=False, + ), + ] + } + ), + ), + ( + { + ATTR_PRIORITY: "trivial", + }, + Task(priority=TaskPriority.TRIVIAL), + ), + ( + { + ATTR_START_DATE: "2025-03-05", + }, + Task(startDate=datetime(2025, 3, 5)), + ), + ( + { + ATTR_FREQUENCY: "weekly", + }, + Task(frequency=Frequency.WEEKLY), + ), + ( + { + ATTR_INTERVAL: 5, + }, + Task(everyX=5), + ), + ( + { + ATTR_FREQUENCY: "weekly", + ATTR_REPEAT: ["m", "t", "w", "th"], + }, + Task( + frequency=Frequency.WEEKLY, + repeat=Repeat(m=True, t=True, w=True, th=True), + ), + ), + ( + { + ATTR_FREQUENCY: "monthly", + ATTR_REPEAT_MONTHLY: "day_of_month", + }, + Task(frequency=Frequency.MONTHLY, daysOfMonth=[20], weeksOfMonth=[]), + ), + ( + { + ATTR_FREQUENCY: "monthly", + ATTR_REPEAT_MONTHLY: "day_of_week", + }, + Task( + frequency=Frequency.MONTHLY, + daysOfMonth=[], + weeksOfMonth=[2], + repeat=Repeat( + m=False, t=False, w=False, th=False, f=True, s=False, su=False + ), + ), + ), + ( + { + ATTR_REMINDER: ["10:00"], + }, + Task( + { + "reminders": [ + Reminders( + id=UUID("12345678-1234-5678-1234-567812345678"), + time=datetime(2025, 2, 25, 10, 0, tzinfo=UTC), + startDate=None, + ) + ] + } + ), + ), + ( + { + ATTR_REMOVE_REMINDER: ["10:00"], + }, + Task({"reminders": []}), + ), + ( + { + ATTR_CLEAR_REMINDER: True, + }, + Task({"reminders": []}), + ), + ( + { + ATTR_STREAK: 10, + }, + Task(streak=10), + ), + ( + { + ATTR_ALIAS: "ALIAS", + }, + Task(alias="ALIAS"), + ), + ], +) +@pytest.mark.usefixtures("mock_uuid4") +@freeze_time("2025-02-25T22:00:00.000Z") +async def test_update_daily( + hass: HomeAssistant, + config_entry: MockConfigEntry, + habitica: AsyncMock, + service_data: dict[str, Any], + call_args: Task, +) -> None: + """Test Habitica update daily action.""" + task_id = "6e53f1f5-a315-4edd-984d-8d762e4a08ef" + + await hass.services.async_call( + DOMAIN, + SERVICE_UPDATE_DAILY, + service_data={ + ATTR_CONFIG_ENTRY: config_entry.entry_id, + ATTR_TASK: task_id, + **service_data, + }, + return_response=True, + blocking=True, + ) + habitica.update_task.assert_awaited_with(UUID(task_id), call_args) + + +@pytest.mark.parametrize( + "service_data", + [ + { + ATTR_FREQUENCY: "daily", + ATTR_REPEAT: ["m", "t", "w", "th"], + }, + { + ATTR_FREQUENCY: "weekly", + ATTR_REPEAT_MONTHLY: "day_of_month", + }, + { + ATTR_FREQUENCY: "weekly", + ATTR_REPEAT_MONTHLY: "day_of_week", + }, + ], +) +@pytest.mark.usefixtures("mock_uuid4") +@freeze_time("2025-02-25T22:00:00.000Z") +async def test_update_daily_service_validation_errors( + hass: HomeAssistant, + config_entry: MockConfigEntry, + habitica: AsyncMock, + service_data: dict[str, Any], +) -> None: + """Test Habitica update daily action.""" + task_id = "6e53f1f5-a315-4edd-984d-8d762e4a08ef" + + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + DOMAIN, + SERVICE_UPDATE_DAILY, + service_data={ + ATTR_CONFIG_ENTRY: config_entry.entry_id, + ATTR_TASK: task_id, + **service_data, + }, + return_response=True, + blocking=True, + ) + + async def test_tags( hass: HomeAssistant, config_entry: MockConfigEntry, From 588366a514f3017cc0e17517cd133c9a55142803 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 14 Mar 2025 16:55:42 +0100 Subject: [PATCH 2621/3148] Add setup function to improv_ble (#140594) --- homeassistant/components/improv_ble/__init__.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/homeassistant/components/improv_ble/__init__.py b/homeassistant/components/improv_ble/__init__.py index 985684cb5b8..ff40b65a8d0 100644 --- a/homeassistant/components/improv_ble/__init__.py +++ b/homeassistant/components/improv_ble/__init__.py @@ -1 +1,11 @@ """The Improv BLE integration.""" + +from __future__ import annotations + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up improv_ble from a config entry.""" + raise NotImplementedError From 2951eb5cc8e6c9be9ba16e2a9c5263ba9d57747d Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Fri, 14 Mar 2025 17:03:42 +0100 Subject: [PATCH 2622/3148] Fix len-test (PLC1802) (#140600) --- homeassistant/components/thethingsnetwork/sensor.py | 2 +- pyproject.toml | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/homeassistant/components/thethingsnetwork/sensor.py b/homeassistant/components/thethingsnetwork/sensor.py index ba512d07f18..5aa851d99ae 100644 --- a/homeassistant/components/thethingsnetwork/sensor.py +++ b/homeassistant/components/thethingsnetwork/sensor.py @@ -40,7 +40,7 @@ async def async_setup_entry( if (device_id, field_id) not in sensors and isinstance(ttn_value, TTNSensorValue) } - if len(new_sensors): + if new_sensors: async_add_entities(new_sensors.values()) sensors.update(new_sensors.keys()) diff --git a/pyproject.toml b/pyproject.toml index a9548844e62..6003b3d1de3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -856,8 +856,6 @@ ignore = [ # Disabled because ruff does not understand type of __all__ generated by a function "PLE0605", - - "PLC1802", # disabled temporarily on ruff 0.10.0 update ] [tool.ruff.lint.flake8-import-conventions.extend-aliases] From 160b98bd2858aa3f98efd4382be9249f58cc9458 Mon Sep 17 00:00:00 2001 From: Michel van de Wetering Date: Fri, 14 Mar 2025 17:24:39 +0100 Subject: [PATCH 2623/3148] Fix media_player Toggle when in idle (#78192) * Remove idle as off state * Fix merge mistake * Fix merge mistake --------- Co-authored-by: Erik Montnemery --- homeassistant/components/media_player/__init__.py | 1 - tests/components/media_player/test_async_helpers.py | 10 +++++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index a30b01694fa..45d08bea7ce 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -1031,7 +1031,6 @@ class MediaPlayerEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): if self.state in { MediaPlayerState.OFF, - MediaPlayerState.IDLE, MediaPlayerState.STANDBY, }: await self.async_turn_on() diff --git a/tests/components/media_player/test_async_helpers.py b/tests/components/media_player/test_async_helpers.py index 680603c097d..3ab79db73e1 100644 --- a/tests/components/media_player/test_async_helpers.py +++ b/tests/components/media_player/test_async_helpers.py @@ -69,6 +69,10 @@ class SimpleMediaPlayer(mp.MediaPlayerEntity): """Put device in standby.""" self._state = STATE_STANDBY + def idle(self): + """Put device in idle.""" + self._state = STATE_IDLE + class ExtendedMediaPlayer(SimpleMediaPlayer): """Media player test class.""" @@ -92,7 +96,7 @@ class ExtendedMediaPlayer(SimpleMediaPlayer): def toggle(self): """Toggle the power on the media player.""" - if self._state in [STATE_OFF, STATE_IDLE, STATE_STANDBY]: + if self._state in [STATE_OFF, STATE_STANDBY]: self._state = STATE_ON else: self._state = STATE_OFF @@ -187,3 +191,7 @@ async def test_toggle(player) -> None: assert player.state == STATE_STANDBY await player.async_toggle() assert player.state == STATE_ON + player.idle() + assert player.state == STATE_IDLE + await player.async_toggle() + assert player.state == STATE_OFF From 8964af428ae71d8e792d01ee42cdb38dff422b6b Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Fri, 14 Mar 2025 19:20:18 +0100 Subject: [PATCH 2624/3148] Add missing translations for `options` attribute in AccuWeather integration (#140610) Add missing translations for options attribute --- homeassistant/components/accuweather/strings.json | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/homeassistant/components/accuweather/strings.json b/homeassistant/components/accuweather/strings.json index d9777352b93..92428a9d599 100644 --- a/homeassistant/components/accuweather/strings.json +++ b/homeassistant/components/accuweather/strings.json @@ -106,6 +106,15 @@ "steady": "Steady", "rising": "Rising", "falling": "Falling" + }, + "state_attributes": { + "options": { + "state": { + "falling": "[%key:component::accuweather::entity::sensor::pressure_tendency::state::falling%]", + "rising": "[%key:component::accuweather::entity::sensor::pressure_tendency::state::rising%]", + "steady": "[%key:component::accuweather::entity::sensor::pressure_tendency::state::steady%]" + } + } } }, "ragweed_pollen": { From 59cab7cd588d677f01d32738e4d9074020fab9db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Fri, 14 Mar 2025 19:35:13 +0100 Subject: [PATCH 2625/3148] Add 700 RPM option to washer spin speed options at Home Connect (#140607) Add 700 RPM option to washer spin speed options --- homeassistant/components/home_connect/const.py | 1 + homeassistant/components/home_connect/services.yaml | 2 ++ homeassistant/components/home_connect/strings.json | 2 ++ 3 files changed, 5 insertions(+) diff --git a/homeassistant/components/home_connect/const.py b/homeassistant/components/home_connect/const.py index 999bb5da13d..1c607ccec28 100644 --- a/homeassistant/components/home_connect/const.py +++ b/homeassistant/components/home_connect/const.py @@ -284,6 +284,7 @@ SPIN_SPEED_OPTIONS = { "LaundryCare.Washer.EnumType.SpinSpeed.Off", "LaundryCare.Washer.EnumType.SpinSpeed.RPM400", "LaundryCare.Washer.EnumType.SpinSpeed.RPM600", + "LaundryCare.Washer.EnumType.SpinSpeed.RPM700", "LaundryCare.Washer.EnumType.SpinSpeed.RPM800", "LaundryCare.Washer.EnumType.SpinSpeed.RPM900", "LaundryCare.Washer.EnumType.SpinSpeed.RPM1000", diff --git a/homeassistant/components/home_connect/services.yaml b/homeassistant/components/home_connect/services.yaml index 91b0089d653..613b3f5af3a 100644 --- a/homeassistant/components/home_connect/services.yaml +++ b/homeassistant/components/home_connect/services.yaml @@ -559,7 +559,9 @@ set_program_and_options: - laundry_care_washer_enum_type_spin_speed_off - laundry_care_washer_enum_type_spin_speed_r_p_m400 - laundry_care_washer_enum_type_spin_speed_r_p_m600 + - laundry_care_washer_enum_type_spin_speed_r_p_m700 - laundry_care_washer_enum_type_spin_speed_r_p_m800 + - laundry_care_washer_enum_type_spin_speed_r_p_m900 - laundry_care_washer_enum_type_spin_speed_r_p_m1000 - laundry_care_washer_enum_type_spin_speed_r_p_m1200 - laundry_care_washer_enum_type_spin_speed_r_p_m1400 diff --git a/homeassistant/components/home_connect/strings.json b/homeassistant/components/home_connect/strings.json index ec95f5fdb92..8d377ac9e04 100644 --- a/homeassistant/components/home_connect/strings.json +++ b/homeassistant/components/home_connect/strings.json @@ -460,6 +460,7 @@ "laundry_care_washer_enum_type_spin_speed_off": "Off", "laundry_care_washer_enum_type_spin_speed_r_p_m400": "400 rpm", "laundry_care_washer_enum_type_spin_speed_r_p_m600": "600 rpm", + "laundry_care_washer_enum_type_spin_speed_r_p_m700": "700 rpm", "laundry_care_washer_enum_type_spin_speed_r_p_m800": "800 rpm", "laundry_care_washer_enum_type_spin_speed_r_p_m900": "900 rpm", "laundry_care_washer_enum_type_spin_speed_r_p_m1000": "1000 rpm", @@ -1430,6 +1431,7 @@ "laundry_care_washer_enum_type_spin_speed_off": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_off%]", "laundry_care_washer_enum_type_spin_speed_r_p_m400": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m400%]", "laundry_care_washer_enum_type_spin_speed_r_p_m600": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m600%]", + "laundry_care_washer_enum_type_spin_speed_r_p_m700": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m700%]", "laundry_care_washer_enum_type_spin_speed_r_p_m800": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m800%]", "laundry_care_washer_enum_type_spin_speed_r_p_m900": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m900%]", "laundry_care_washer_enum_type_spin_speed_r_p_m1000": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m1000%]", From b07c28126a687e02b828fd65bb34c2c949516cd6 Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Fri, 14 Mar 2025 23:42:10 +0100 Subject: [PATCH 2626/3148] Bump pyOverkiz to 1.16.3 (#140621) Bump Overkiz to 1.16.3 --- homeassistant/components/overkiz/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/overkiz/manifest.json b/homeassistant/components/overkiz/manifest.json index 07ec02d76a6..70857f0ba11 100644 --- a/homeassistant/components/overkiz/manifest.json +++ b/homeassistant/components/overkiz/manifest.json @@ -13,7 +13,7 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["boto3", "botocore", "pyhumps", "pyoverkiz", "s3transfer"], - "requirements": ["pyoverkiz==1.16.2"], + "requirements": ["pyoverkiz==1.16.3"], "zeroconf": [ { "type": "_kizbox._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index cadf1be7645..916bdcee3c4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2199,7 +2199,7 @@ pyotgw==2.2.2 pyotp==2.8.0 # homeassistant.components.overkiz -pyoverkiz==1.16.2 +pyoverkiz==1.16.3 # homeassistant.components.onewire pyownet==0.10.0.post1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0637d2a737a..929437bb7bf 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1795,7 +1795,7 @@ pyotgw==2.2.2 pyotp==2.8.0 # homeassistant.components.overkiz -pyoverkiz==1.16.2 +pyoverkiz==1.16.3 # homeassistant.components.onewire pyownet==0.10.0.post1 From 537302ce56e0b27429bf69e29d85e2855e23e271 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Fri, 14 Mar 2025 19:28:02 -0400 Subject: [PATCH 2627/3148] ZBT-1 and Yellow firmware update entities for Zigbee/Thread (#138505) * Initial implementation of hardware update model * Fixes * WIP: change the `homeassistant_sky_connect` integration type * More fixes * WIP * Display firmware info in the device page * Make progress more responsive * WIP: Yellow * Abstract the bootloader reset type * Clean up comments * Make the Yellow integration non-hardware * Use the correct radio device for Yellow * Avoid hardcoding strings * Use `FIRMWARE_VERSION` within config flows * Fix up unit tests * Revert integration type changes * Rewrite hardware ownership context manager name, for clarity * Move manifest parsing logic into a new package Pass the correct type to the firmware API library * Create and delete entities instead of mutating the entity description * Move entity replacement into a `async_setup_entry` callback * Change update entity category from "diagnostic" to "config" * Have the client library handle firmware fetching * Switch from dispatcher to `async_on_state_change` * Remove unnecessary type annotation on base update entity * Simplify state recomputation * Remove device registry code, since the devices will not be visible * Further simplify state computation * Give the device-less update entity a more descriptive name * Limit state changes to integer increments when sending firmware update progress * Re-raise `HomeAssistantError` if there is a problem during flashing * Remove unnecessary state write during entity creation * Rename `_maybe_recompute_state` to `_update_attributes` * Bump the flasher to 0.0.30 * Add some tests * Ensure the update entity has a sensible name * Initial ZBT-1 unit tests * Replace `_update_config_entry_after_install` with a more explicit `_firmware_info_callback` override * Write the firmware version to the config entry as well * Test the hardware update platform independently * Add unit tests to the Yellow and ZBT-1 integrations * Load firmware info from the config entry when creating the update entity * Test entity state restoration * Test the reloading of integrations marked as "owning" * Test installation failure cases * Test firmware type change callback failure case * Address review comments --- .../homeassistant_hardware/coordinator.py | 47 ++ .../homeassistant_hardware/manifest.json | 5 +- .../homeassistant_hardware/update.py | 331 +++++++++ .../components/homeassistant_hardware/util.py | 42 +- .../homeassistant_sky_connect/__init__.py | 25 +- .../homeassistant_sky_connect/config_flow.py | 53 +- .../homeassistant_sky_connect/const.py | 14 + .../homeassistant_sky_connect/update.py | 169 +++++ .../homeassistant_yellow/__init__.py | 16 +- .../homeassistant_yellow/config_flow.py | 12 +- .../components/homeassistant_yellow/const.py | 8 + .../components/homeassistant_yellow/update.py | 172 +++++ requirements_all.txt | 5 +- .../test_coordinator.py | 55 ++ .../homeassistant_hardware/test_update.py | 637 ++++++++++++++++++ .../homeassistant_hardware/test_util.py | 148 ++++ .../homeassistant_sky_connect/common.py | 21 + .../test_config_flow.py | 26 +- .../homeassistant_sky_connect/test_init.py | 3 +- .../homeassistant_sky_connect/test_update.py | 86 +++ .../homeassistant_yellow/test_config_flow.py | 3 +- .../homeassistant_yellow/test_update.py | 89 +++ 22 files changed, 1916 insertions(+), 51 deletions(-) create mode 100644 homeassistant/components/homeassistant_hardware/coordinator.py create mode 100644 homeassistant/components/homeassistant_hardware/update.py create mode 100644 homeassistant/components/homeassistant_sky_connect/update.py create mode 100644 homeassistant/components/homeassistant_yellow/update.py create mode 100644 tests/components/homeassistant_hardware/test_coordinator.py create mode 100644 tests/components/homeassistant_hardware/test_update.py create mode 100644 tests/components/homeassistant_sky_connect/common.py create mode 100644 tests/components/homeassistant_sky_connect/test_update.py create mode 100644 tests/components/homeassistant_yellow/test_update.py diff --git a/homeassistant/components/homeassistant_hardware/coordinator.py b/homeassistant/components/homeassistant_hardware/coordinator.py new file mode 100644 index 00000000000..9eb900b13fd --- /dev/null +++ b/homeassistant/components/homeassistant_hardware/coordinator.py @@ -0,0 +1,47 @@ +"""Home Assistant hardware firmware update coordinator.""" + +from __future__ import annotations + +from datetime import timedelta +import logging + +from aiohttp import ClientSession +from ha_silabs_firmware_client import ( + FirmwareManifest, + FirmwareUpdateClient, + ManifestMissing, +) + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +_LOGGER = logging.getLogger(__name__) + + +FIRMWARE_REFRESH_INTERVAL = timedelta(hours=8) + + +class FirmwareUpdateCoordinator(DataUpdateCoordinator[FirmwareManifest]): + """Coordinator to manage firmware updates.""" + + def __init__(self, hass: HomeAssistant, session: ClientSession, url: str) -> None: + """Initialize the firmware update coordinator.""" + super().__init__( + hass, + _LOGGER, + name="firmware update coordinator", + update_interval=FIRMWARE_REFRESH_INTERVAL, + always_update=False, + ) + self.hass = hass + self.session = session + + self.client = FirmwareUpdateClient(url, session) + + async def _async_update_data(self) -> FirmwareManifest: + try: + return await self.client.async_update_data() + except ManifestMissing as err: + raise UpdateFailed( + "GitHub release assets haven't been uploaded yet" + ) from err diff --git a/homeassistant/components/homeassistant_hardware/manifest.json b/homeassistant/components/homeassistant_hardware/manifest.json index 8f59ab61600..f3a02185b83 100644 --- a/homeassistant/components/homeassistant_hardware/manifest.json +++ b/homeassistant/components/homeassistant_hardware/manifest.json @@ -5,5 +5,8 @@ "codeowners": ["@home-assistant/core"], "documentation": "https://www.home-assistant.io/integrations/homeassistant_hardware", "integration_type": "system", - "requirements": ["universal-silabs-flasher==0.0.29"] + "requirements": [ + "universal-silabs-flasher==0.0.30", + "ha-silabs-firmware-client==0.2.0" + ] } diff --git a/homeassistant/components/homeassistant_hardware/update.py b/homeassistant/components/homeassistant_hardware/update.py new file mode 100644 index 00000000000..e835286238f --- /dev/null +++ b/homeassistant/components/homeassistant_hardware/update.py @@ -0,0 +1,331 @@ +"""Home Assistant Hardware base firmware update entity.""" + +from __future__ import annotations + +from collections.abc import AsyncIterator, Callable +from contextlib import AsyncExitStack, asynccontextmanager +from dataclasses import dataclass +import logging +from typing import Any, cast + +from ha_silabs_firmware_client import FirmwareManifest, FirmwareMetadata +from universal_silabs_flasher.firmware import parse_firmware_image +from universal_silabs_flasher.flasher import Flasher +from yarl import URL + +from homeassistant.components.update import ( + UpdateEntity, + UpdateEntityDescription, + UpdateEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import CALLBACK_TYPE, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.restore_state import ExtraStoredData +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .coordinator import FirmwareUpdateCoordinator +from .helpers import async_register_firmware_info_callback +from .util import ( + ApplicationType, + FirmwareInfo, + guess_firmware_info, + probe_silabs_firmware_info, +) + +_LOGGER = logging.getLogger(__name__) + +type FirmwareChangeCallbackType = Callable[ + [ApplicationType | None, ApplicationType | None], None +] + + +@dataclass(kw_only=True, frozen=True) +class FirmwareUpdateEntityDescription(UpdateEntityDescription): + """Describes Home Assistant Hardware firmware update entity.""" + + version_parser: Callable[[str], str] + fw_type: str | None + version_key: str | None + expected_firmware_type: ApplicationType | None + firmware_name: str | None + + +@dataclass +class FirmwareUpdateExtraStoredData(ExtraStoredData): + """Extra stored data for Home Assistant Hardware firmware update entity.""" + + firmware_manifest: FirmwareManifest | None = None + + def as_dict(self) -> dict[str, Any]: + """Return a dict representation of the extra data.""" + return { + "firmware_manifest": ( + self.firmware_manifest.as_dict() + if self.firmware_manifest is not None + else None + ) + } + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> FirmwareUpdateExtraStoredData: + """Initialize the extra data from a dict.""" + if data["firmware_manifest"] is None: + return cls(firmware_manifest=None) + + return cls( + FirmwareManifest.from_json( + data["firmware_manifest"], + # This data is not technically part of the manifest and is loaded externally + url=URL(data["firmware_manifest"]["url"]), + html_url=URL(data["firmware_manifest"]["html_url"]), + ) + ) + + +class BaseFirmwareUpdateEntity( + CoordinatorEntity[FirmwareUpdateCoordinator], UpdateEntity +): + """Base Home Assistant Hardware firmware update entity.""" + + # Subclasses provide the mapping between firmware types and entity descriptions + entity_description: FirmwareUpdateEntityDescription + bootloader_reset_type: str | None = None + + _attr_supported_features = ( + UpdateEntityFeature.INSTALL | UpdateEntityFeature.PROGRESS + ) + # Until this entity can be associated with a device, we must manually name it + _attr_has_entity_name = False + + def __init__( + self, + device: str, + config_entry: ConfigEntry, + update_coordinator: FirmwareUpdateCoordinator, + entity_description: FirmwareUpdateEntityDescription, + ) -> None: + """Initialize the Hardware firmware update entity.""" + super().__init__(update_coordinator) + + self.entity_description = entity_description + self._current_device = device + self._config_entry = config_entry + self._current_firmware_info: FirmwareInfo | None = None + self._firmware_type_change_callbacks: set[FirmwareChangeCallbackType] = set() + + self._latest_manifest: FirmwareManifest | None = None + self._latest_firmware: FirmwareMetadata | None = None + + def add_firmware_type_changed_callback( + self, + change_callback: FirmwareChangeCallbackType, + ) -> CALLBACK_TYPE: + """Add a callback for when the firmware type changes.""" + self._firmware_type_change_callbacks.add(change_callback) + + @callback + def remove_callback() -> None: + self._firmware_type_change_callbacks.discard(change_callback) + + return remove_callback + + async def async_added_to_hass(self) -> None: + """Handle entity which will be added.""" + await super().async_added_to_hass() + + self.async_on_remove( + async_register_firmware_info_callback( + self.hass, + self._current_device, + self._firmware_info_callback, + ) + ) + + self.async_on_remove( + self._config_entry.async_on_state_change(self._on_config_entry_change) + ) + + if (extra_data := await self.async_get_last_extra_data()) and ( + hardware_extra_data := FirmwareUpdateExtraStoredData.from_dict( + extra_data.as_dict() + ) + ): + self._latest_manifest = hardware_extra_data.firmware_manifest + + self._update_attributes() + + @property + def extra_restore_state_data(self) -> FirmwareUpdateExtraStoredData: + """Return state data to be restored.""" + return FirmwareUpdateExtraStoredData(firmware_manifest=self._latest_manifest) + + @callback + def _on_config_entry_change(self) -> None: + """Handle config entry changes.""" + self._update_attributes() + self.async_write_ha_state() + + @callback + def _firmware_info_callback(self, firmware_info: FirmwareInfo) -> None: + """Handle updated firmware info being pushed by an integration.""" + self._current_firmware_info = firmware_info + + # If the firmware type does not change, we can just update the attributes + if ( + self._current_firmware_info.firmware_type + == self.entity_description.expected_firmware_type + ): + self._update_attributes() + self.async_write_ha_state() + return + + # Otherwise, fire the firmware type change callbacks. They are expected to + # replace the entity so there is no purpose in firing other callbacks. + for change_callback in self._firmware_type_change_callbacks.copy(): + try: + change_callback( + self.entity_description.expected_firmware_type, + self._current_firmware_info.firmware_type, + ) + except Exception: # noqa: BLE001 + _LOGGER.warning( + "Failed to call firmware type changed callback", exc_info=True + ) + + def _update_attributes(self) -> None: + """Recompute the attributes of the entity.""" + + # This entity is not currently associated with a device so we must manually + # give it a name + self._attr_name = f"{self._config_entry.title} Update" + self._attr_title = self.entity_description.firmware_name or "unknown" + + if ( + self._current_firmware_info is None + or self._current_firmware_info.firmware_version is None + ): + self._attr_installed_version = None + else: + self._attr_installed_version = self.entity_description.version_parser( + self._current_firmware_info.firmware_version + ) + + self._latest_firmware = None + self._attr_latest_version = None + self._attr_release_summary = None + self._attr_release_url = None + + if ( + self._latest_manifest is None + or self.entity_description.fw_type is None + or self.entity_description.version_key is None + ): + return + + try: + self._latest_firmware = next( + f + for f in self._latest_manifest.firmwares + if f.filename.startswith(self.entity_description.fw_type) + ) + except StopIteration: + pass + else: + version = cast( + str, self._latest_firmware.metadata[self.entity_description.version_key] + ) + self._attr_latest_version = self.entity_description.version_parser(version) + self._attr_release_summary = self._latest_firmware.release_notes + self._attr_release_url = str(self._latest_manifest.html_url) + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._latest_manifest = self.coordinator.data + self._update_attributes() + self.async_write_ha_state() + + def _update_progress(self, offset: int, total_size: int) -> None: + """Handle update progress.""" + + # Firmware updates in ~30s so we still get responsive update progress even + # without decimal places + self._attr_update_percentage = round((offset * 100) / total_size) + self.async_write_ha_state() + + @asynccontextmanager + async def _temporarily_stop_hardware_owners( + self, device: str + ) -> AsyncIterator[None]: + """Temporarily stop addons and integrations communicating with the device.""" + firmware_info = await guess_firmware_info(self.hass, device) + _LOGGER.debug("Identified firmware info: %s", firmware_info) + + async with AsyncExitStack() as stack: + for owner in firmware_info.owners: + await stack.enter_async_context(owner.temporarily_stop(self.hass)) + + yield + + async def async_install( + self, version: str | None, backup: bool, **kwargs: Any + ) -> None: + """Install an update.""" + assert self._latest_firmware is not None + assert self.entity_description.expected_firmware_type is not None + + # Start off by setting the progress bar to an indeterminate state + self._attr_in_progress = True + self._attr_update_percentage = None + self.async_write_ha_state() + + fw_data = await self.coordinator.client.async_fetch_firmware( + self._latest_firmware + ) + fw_image = await self.hass.async_add_executor_job(parse_firmware_image, fw_data) + + device = self._current_device + + flasher = Flasher( + device=device, + probe_methods=( + ApplicationType.GECKO_BOOTLOADER.as_flasher_application_type(), + ApplicationType.EZSP.as_flasher_application_type(), + ApplicationType.SPINEL.as_flasher_application_type(), + ApplicationType.CPC.as_flasher_application_type(), + ), + bootloader_reset=self.bootloader_reset_type, + ) + + async with self._temporarily_stop_hardware_owners(device): + try: + try: + # Enter the bootloader with indeterminate progress + await flasher.enter_bootloader() + + # Flash the firmware, with progress + await flasher.flash_firmware( + fw_image, progress_callback=self._update_progress + ) + except Exception as err: + raise HomeAssistantError("Failed to flash firmware") from err + + # Probe the running application type with indeterminate progress + self._attr_update_percentage = None + self.async_write_ha_state() + + firmware_info = await probe_silabs_firmware_info( + device, + probe_methods=(self.entity_description.expected_firmware_type,), + ) + + if firmware_info is None: + raise HomeAssistantError( + "Failed to probe the firmware after flashing" + ) + + self._firmware_info_callback(firmware_info) + finally: + self._attr_in_progress = False + self.async_write_ha_state() diff --git a/homeassistant/components/homeassistant_hardware/util.py b/homeassistant/components/homeassistant_hardware/util.py index 1afb786369e..64f363e4f23 100644 --- a/homeassistant/components/homeassistant_hardware/util.py +++ b/homeassistant/components/homeassistant_hardware/util.py @@ -4,7 +4,8 @@ from __future__ import annotations import asyncio from collections import defaultdict -from collections.abc import Iterable +from collections.abc import AsyncIterator, Iterable +from contextlib import asynccontextmanager from dataclasses import dataclass from enum import StrEnum import logging @@ -105,6 +106,28 @@ class OwningAddon: else: return addon_info.state == AddonState.RUNNING + @asynccontextmanager + async def temporarily_stop(self, hass: HomeAssistant) -> AsyncIterator[None]: + """Temporarily stop the add-on, restarting it after completion.""" + addon_manager = self._get_addon_manager(hass) + + try: + addon_info = await addon_manager.async_get_addon_info() + except AddonError: + yield + return + + if addon_info.state != AddonState.RUNNING: + yield + return + + try: + await addon_manager.async_stop_addon() + await addon_manager.async_wait_until_addon_state(AddonState.NOT_RUNNING) + yield + finally: + await addon_manager.async_start_addon_waiting() + @dataclass(kw_only=True) class OwningIntegration: @@ -123,6 +146,23 @@ class OwningIntegration: ConfigEntryState.SETUP_IN_PROGRESS, ) + @asynccontextmanager + async def temporarily_stop(self, hass: HomeAssistant) -> AsyncIterator[None]: + """Temporarily stop the integration, restarting it after completion.""" + if (entry := hass.config_entries.async_get_entry(self.config_entry_id)) is None: + yield + return + + if entry.state != ConfigEntryState.LOADED: + yield + return + + try: + await hass.config_entries.async_unload(entry.entry_id) + yield + finally: + await hass.config_entries.async_setup(entry.entry_id) + @dataclass(kw_only=True) class FirmwareInfo: diff --git a/homeassistant/components/homeassistant_sky_connect/__init__.py b/homeassistant/components/homeassistant_sky_connect/__init__.py index 758f0c1e1ef..b3af47df61d 100644 --- a/homeassistant/components/homeassistant_sky_connect/__init__.py +++ b/homeassistant/components/homeassistant_sky_connect/__init__.py @@ -8,11 +8,16 @@ from homeassistant.components.homeassistant_hardware.util import guess_firmware_ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from .const import DESCRIPTION, DEVICE, FIRMWARE, FIRMWARE_VERSION, PRODUCT + _LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a Home Assistant SkyConnect config entry.""" + + await hass.config_entries.async_forward_entry_setups(entry, ["update"]) + return True @@ -33,15 +38,13 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> # Add-on startup with type service get started before Core, always (e.g. the # Multi-Protocol add-on). Probing the firmware would interfere with the add-on, # so we can't safely probe here. Instead, we must make an educated guess! - firmware_guess = await guess_firmware_info( - hass, config_entry.data["device"] - ) + firmware_guess = await guess_firmware_info(hass, config_entry.data[DEVICE]) new_data = {**config_entry.data} - new_data["firmware"] = firmware_guess.firmware_type.value + new_data[FIRMWARE] = firmware_guess.firmware_type.value # Copy `description` to `product` - new_data["product"] = new_data["description"] + new_data[PRODUCT] = new_data[DESCRIPTION] hass.config_entries.async_update_entry( config_entry, @@ -50,6 +53,18 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> minor_version=2, ) + if config_entry.minor_version == 2: + # Add a `firmware_version` key + hass.config_entries.async_update_entry( + config_entry, + data={ + **config_entry.data, + FIRMWARE_VERSION: None, + }, + version=1, + minor_version=3, + ) + _LOGGER.debug( "Migration to version %s.%s successful", config_entry.version, diff --git a/homeassistant/components/homeassistant_sky_connect/config_flow.py b/homeassistant/components/homeassistant_sky_connect/config_flow.py index d8446c2d3f9..d28d74a681c 100644 --- a/homeassistant/components/homeassistant_sky_connect/config_flow.py +++ b/homeassistant/components/homeassistant_sky_connect/config_flow.py @@ -24,7 +24,20 @@ from homeassistant.config_entries import ( from homeassistant.core import callback from homeassistant.helpers.service_info.usb import UsbServiceInfo -from .const import DOCS_WEB_FLASHER_URL, DOMAIN, HardwareVariant +from .const import ( + DESCRIPTION, + DEVICE, + DOCS_WEB_FLASHER_URL, + DOMAIN, + FIRMWARE, + FIRMWARE_VERSION, + MANUFACTURER, + PID, + PRODUCT, + SERIAL_NUMBER, + VID, + HardwareVariant, +) from .util import get_hardware_variant, get_usb_service_info _LOGGER = logging.getLogger(__name__) @@ -37,6 +50,7 @@ if TYPE_CHECKING: def _get_translation_placeholders(self) -> dict[str, str]: return {} + else: # Multiple inheritance with `Protocol` seems to break TranslationPlaceholderProtocol = object @@ -67,7 +81,7 @@ class HomeAssistantSkyConnectConfigFlow( """Handle a config flow for Home Assistant SkyConnect.""" VERSION = 1 - MINOR_VERSION = 2 + MINOR_VERSION = 3 def __init__(self, *args: Any, **kwargs: Any) -> None: """Initialize the config flow.""" @@ -82,7 +96,7 @@ class HomeAssistantSkyConnectConfigFlow( config_entry: ConfigEntry, ) -> OptionsFlow: """Return the options flow.""" - firmware_type = ApplicationType(config_entry.data["firmware"]) + firmware_type = ApplicationType(config_entry.data[FIRMWARE]) if firmware_type is ApplicationType.CPC: return HomeAssistantSkyConnectMultiPanOptionsFlowHandler(config_entry) @@ -100,7 +114,7 @@ class HomeAssistantSkyConnectConfigFlow( unique_id = f"{vid}:{pid}_{serial_number}_{manufacturer}_{description}" if await self.async_set_unique_id(unique_id): - self._abort_if_unique_id_configured(updates={"device": device}) + self._abort_if_unique_id_configured(updates={DEVICE: device}) discovery_info.device = await self.hass.async_add_executor_job( usb.get_serial_by_id, discovery_info.device @@ -126,14 +140,15 @@ class HomeAssistantSkyConnectConfigFlow( return self.async_create_entry( title=self._hw_variant.full_name, data={ - "vid": self._usb_info.vid, - "pid": self._usb_info.pid, - "serial_number": self._usb_info.serial_number, - "manufacturer": self._usb_info.manufacturer, - "description": self._usb_info.description, # For backwards compatibility - "product": self._usb_info.description, - "device": self._usb_info.device, - "firmware": self._probed_firmware_info.firmware_type.value, + VID: self._usb_info.vid, + PID: self._usb_info.pid, + SERIAL_NUMBER: self._usb_info.serial_number, + MANUFACTURER: self._usb_info.manufacturer, + DESCRIPTION: self._usb_info.description, # For backwards compatibility + PRODUCT: self._usb_info.description, + DEVICE: self._usb_info.device, + FIRMWARE: self._probed_firmware_info.firmware_type.value, + FIRMWARE_VERSION: self._probed_firmware_info.firmware_version, }, ) @@ -148,7 +163,7 @@ class HomeAssistantSkyConnectMultiPanOptionsFlowHandler( ) -> silabs_multiprotocol_addon.SerialPortSettings: """Return the radio serial port settings.""" return silabs_multiprotocol_addon.SerialPortSettings( - device=self.config_entry.data["device"], + device=self.config_entry.data[DEVICE], baudrate="115200", flow_control=True, ) @@ -182,7 +197,8 @@ class HomeAssistantSkyConnectMultiPanOptionsFlowHandler( entry=self.config_entry, data={ **self.config_entry.data, - "firmware": ApplicationType.EZSP.value, + FIRMWARE: ApplicationType.EZSP.value, + FIRMWARE_VERSION: None, }, options=self.config_entry.options, ) @@ -201,15 +217,15 @@ class HomeAssistantSkyConnectOptionsFlowHandler( self._usb_info = get_usb_service_info(self.config_entry) self._hw_variant = HardwareVariant.from_usb_product_name( - self.config_entry.data["product"] + self.config_entry.data[PRODUCT] ) self._hardware_name = self._hw_variant.full_name self._device = self._usb_info.device self._probed_firmware_info = FirmwareInfo( device=self._device, - firmware_type=ApplicationType(self.config_entry.data["firmware"]), - firmware_version=None, + firmware_type=ApplicationType(self.config_entry.data[FIRMWARE]), + firmware_version=self.config_entry.data[FIRMWARE_VERSION], source="guess", owners=[], ) @@ -225,7 +241,8 @@ class HomeAssistantSkyConnectOptionsFlowHandler( entry=self.config_entry, data={ **self.config_entry.data, - "firmware": self._probed_firmware_info.firmware_type.value, + FIRMWARE: self._probed_firmware_info.firmware_type.value, + FIRMWARE_VERSION: self._probed_firmware_info.firmware_version, }, options=self.config_entry.options, ) diff --git a/homeassistant/components/homeassistant_sky_connect/const.py b/homeassistant/components/homeassistant_sky_connect/const.py index cae0b98a25b..70ff047366d 100644 --- a/homeassistant/components/homeassistant_sky_connect/const.py +++ b/homeassistant/components/homeassistant_sky_connect/const.py @@ -7,6 +7,20 @@ from typing import Self DOMAIN = "homeassistant_sky_connect" DOCS_WEB_FLASHER_URL = "https://skyconnect.home-assistant.io/firmware-update/" +NABU_CASA_FIRMWARE_RELEASES_URL = ( + "https://api.github.com/repos/NabuCasa/silabs-firmware-builder/releases/latest" +) + +FIRMWARE = "firmware" +FIRMWARE_VERSION = "firmware_version" +SERIAL_NUMBER = "serial_number" +MANUFACTURER = "manufacturer" +PRODUCT = "product" +DESCRIPTION = "description" +PID = "pid" +VID = "vid" +DEVICE = "device" + @dataclasses.dataclass(frozen=True) class VariantInfo: diff --git a/homeassistant/components/homeassistant_sky_connect/update.py b/homeassistant/components/homeassistant_sky_connect/update.py new file mode 100644 index 00000000000..43e3f1ca255 --- /dev/null +++ b/homeassistant/components/homeassistant_sky_connect/update.py @@ -0,0 +1,169 @@ +"""Home Assistant SkyConnect firmware update entity.""" + +from __future__ import annotations + +import logging + +import aiohttp + +from homeassistant.components.homeassistant_hardware.coordinator import ( + FirmwareUpdateCoordinator, +) +from homeassistant.components.homeassistant_hardware.update import ( + BaseFirmwareUpdateEntity, + FirmwareUpdateEntityDescription, +) +from homeassistant.components.homeassistant_hardware.util import ( + ApplicationType, + FirmwareInfo, +) +from homeassistant.components.update import UpdateDeviceClass +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import FIRMWARE, FIRMWARE_VERSION, NABU_CASA_FIRMWARE_RELEASES_URL + +_LOGGER = logging.getLogger(__name__) + + +FIRMWARE_ENTITY_DESCRIPTIONS: dict[ + ApplicationType | None, FirmwareUpdateEntityDescription +] = { + ApplicationType.EZSP: FirmwareUpdateEntityDescription( + key="firmware", + display_precision=0, + device_class=UpdateDeviceClass.FIRMWARE, + entity_category=EntityCategory.CONFIG, + version_parser=lambda fw: fw.split(" ", 1)[0], + fw_type="skyconnect_zigbee_ncp", + version_key="ezsp_version", + expected_firmware_type=ApplicationType.EZSP, + firmware_name="EmberZNet", + ), + ApplicationType.SPINEL: FirmwareUpdateEntityDescription( + key="firmware", + display_precision=0, + device_class=UpdateDeviceClass.FIRMWARE, + entity_category=EntityCategory.CONFIG, + version_parser=lambda fw: fw.split("/", 1)[1].split("_", 1)[0], + fw_type="skyconnect_openthread_rcp", + version_key="ot_rcp_version", + expected_firmware_type=ApplicationType.SPINEL, + firmware_name="OpenThread RCP", + ), + None: FirmwareUpdateEntityDescription( + key="firmware", + display_precision=0, + device_class=UpdateDeviceClass.FIRMWARE, + entity_category=EntityCategory.CONFIG, + version_parser=lambda fw: fw, + fw_type=None, + version_key=None, + expected_firmware_type=None, + firmware_name=None, + ), +} + + +def _async_create_update_entity( + hass: HomeAssistant, + config_entry: ConfigEntry, + session: aiohttp.ClientSession, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> FirmwareUpdateEntity: + """Create an update entity that handles firmware type changes.""" + firmware_type = config_entry.data[FIRMWARE] + entity_description = FIRMWARE_ENTITY_DESCRIPTIONS[ + ApplicationType(firmware_type) if firmware_type is not None else None + ] + + entity = FirmwareUpdateEntity( + device=config_entry.data["device"], + config_entry=config_entry, + update_coordinator=FirmwareUpdateCoordinator( + hass, + session, + NABU_CASA_FIRMWARE_RELEASES_URL, + ), + entity_description=entity_description, + ) + + def firmware_type_changed( + old_type: ApplicationType | None, new_type: ApplicationType | None + ) -> None: + """Replace the current entity when the firmware type changes.""" + er.async_get(hass).async_remove(entity.entity_id) + async_add_entities( + [ + _async_create_update_entity( + hass, config_entry, session, async_add_entities + ) + ] + ) + + entity.async_on_remove( + entity.add_firmware_type_changed_callback(firmware_type_changed) + ) + + return entity + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the firmware update config entry.""" + session = async_get_clientsession(hass) + entity = _async_create_update_entity( + hass, config_entry, session, async_add_entities + ) + + async_add_entities([entity]) + + +class FirmwareUpdateEntity(BaseFirmwareUpdateEntity): + """SkyConnect firmware update entity.""" + + bootloader_reset_type = None + + def __init__( + self, + device: str, + config_entry: ConfigEntry, + update_coordinator: FirmwareUpdateCoordinator, + entity_description: FirmwareUpdateEntityDescription, + ) -> None: + """Initialize the SkyConnect firmware update entity.""" + super().__init__(device, config_entry, update_coordinator, entity_description) + + self._attr_unique_id = ( + f"{self._config_entry.data['serial_number']}_{self.entity_description.key}" + ) + + # Use the cached firmware info if it exists + if self._config_entry.data[FIRMWARE] is not None: + self._current_firmware_info = FirmwareInfo( + device=device, + firmware_type=ApplicationType(self._config_entry.data[FIRMWARE]), + firmware_version=self._config_entry.data[FIRMWARE_VERSION], + owners=[], + source="homeassistant_sky_connect", + ) + + @callback + def _firmware_info_callback(self, firmware_info: FirmwareInfo) -> None: + """Handle updated firmware info being pushed by an integration.""" + self.hass.config_entries.async_update_entry( + self._config_entry, + data={ + **self._config_entry.data, + FIRMWARE: firmware_info.firmware_type, + FIRMWARE_VERSION: firmware_info.firmware_version, + }, + ) + super()._firmware_info_callback(firmware_info) diff --git a/homeassistant/components/homeassistant_yellow/__init__.py b/homeassistant/components/homeassistant_yellow/__init__.py index b0837eeedbe..06f908ab61e 100644 --- a/homeassistant/components/homeassistant_yellow/__init__.py +++ b/homeassistant/components/homeassistant_yellow/__init__.py @@ -18,7 +18,7 @@ from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError from homeassistant.helpers import discovery_flow from homeassistant.helpers.hassio import is_hassio -from .const import FIRMWARE, RADIO_DEVICE, ZHA_HW_DISCOVERY_DATA +from .const import FIRMWARE, FIRMWARE_VERSION, RADIO_DEVICE, ZHA_HW_DISCOVERY_DATA _LOGGER = logging.getLogger(__name__) @@ -55,6 +55,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: data=ZHA_HW_DISCOVERY_DATA, ) + await hass.config_entries.async_forward_entry_setups(entry, ["update"]) + return True @@ -87,6 +89,18 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> minor_version=2, ) + if config_entry.minor_version == 2: + # Add a `firmware_version` key + hass.config_entries.async_update_entry( + config_entry, + data={ + **config_entry.data, + FIRMWARE_VERSION: None, + }, + version=1, + minor_version=3, + ) + _LOGGER.debug( "Migration to version %s.%s successful", config_entry.version, diff --git a/homeassistant/components/homeassistant_yellow/config_flow.py b/homeassistant/components/homeassistant_yellow/config_flow.py index b916c6e46ca..5472c346e94 100644 --- a/homeassistant/components/homeassistant_yellow/config_flow.py +++ b/homeassistant/components/homeassistant_yellow/config_flow.py @@ -37,7 +37,14 @@ from homeassistant.config_entries import ( from homeassistant.core import HomeAssistant, async_get_hass, callback from homeassistant.helpers import discovery_flow, selector -from .const import DOMAIN, FIRMWARE, RADIO_DEVICE, ZHA_DOMAIN, ZHA_HW_DISCOVERY_DATA +from .const import ( + DOMAIN, + FIRMWARE, + FIRMWARE_VERSION, + RADIO_DEVICE, + ZHA_DOMAIN, + ZHA_HW_DISCOVERY_DATA, +) from .hardware import BOARD_NAME _LOGGER = logging.getLogger(__name__) @@ -55,7 +62,7 @@ class HomeAssistantYellowConfigFlow(BaseFirmwareConfigFlow, domain=DOMAIN): """Handle a config flow for Home Assistant Yellow.""" VERSION = 1 - MINOR_VERSION = 2 + MINOR_VERSION = 3 def __init__(self, *args: Any, **kwargs: Any) -> None: """Instantiate config flow.""" @@ -310,6 +317,7 @@ class HomeAssistantYellowOptionsFlowHandler( data={ **self.config_entry.data, FIRMWARE: self._probed_firmware_info.firmware_type.value, + FIRMWARE_VERSION: self._probed_firmware_info.firmware_version, }, ) diff --git a/homeassistant/components/homeassistant_yellow/const.py b/homeassistant/components/homeassistant_yellow/const.py index 79753ae9b9e..b98b1133d01 100644 --- a/homeassistant/components/homeassistant_yellow/const.py +++ b/homeassistant/components/homeassistant_yellow/const.py @@ -2,7 +2,10 @@ DOMAIN = "homeassistant_yellow" +RADIO_MODEL = "Home Assistant Yellow" +RADIO_MANUFACTURER = "Nabu Casa" RADIO_DEVICE = "/dev/ttyAMA1" + ZHA_HW_DISCOVERY_DATA = { "name": "Yellow", "port": { @@ -14,4 +17,9 @@ ZHA_HW_DISCOVERY_DATA = { } FIRMWARE = "firmware" +FIRMWARE_VERSION = "firmware_version" ZHA_DOMAIN = "zha" + +NABU_CASA_FIRMWARE_RELEASES_URL = ( + "https://api.github.com/repos/NabuCasa/silabs-firmware-builder/releases/latest" +) diff --git a/homeassistant/components/homeassistant_yellow/update.py b/homeassistant/components/homeassistant_yellow/update.py new file mode 100644 index 00000000000..88d4f2912d3 --- /dev/null +++ b/homeassistant/components/homeassistant_yellow/update.py @@ -0,0 +1,172 @@ +"""Home Assistant Yellow firmware update entity.""" + +from __future__ import annotations + +import logging + +import aiohttp + +from homeassistant.components.homeassistant_hardware.coordinator import ( + FirmwareUpdateCoordinator, +) +from homeassistant.components.homeassistant_hardware.update import ( + BaseFirmwareUpdateEntity, + FirmwareUpdateEntityDescription, +) +from homeassistant.components.homeassistant_hardware.util import ( + ApplicationType, + FirmwareInfo, +) +from homeassistant.components.update import UpdateDeviceClass +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import ( + FIRMWARE, + FIRMWARE_VERSION, + NABU_CASA_FIRMWARE_RELEASES_URL, + RADIO_DEVICE, +) + +_LOGGER = logging.getLogger(__name__) + + +FIRMWARE_ENTITY_DESCRIPTIONS: dict[ + ApplicationType | None, FirmwareUpdateEntityDescription +] = { + ApplicationType.EZSP: FirmwareUpdateEntityDescription( + key="firmware", + display_precision=0, + device_class=UpdateDeviceClass.FIRMWARE, + entity_category=EntityCategory.CONFIG, + version_parser=lambda fw: fw.split(" ", 1)[0], + fw_type="yellow_zigbee_ncp", + version_key="ezsp_version", + expected_firmware_type=ApplicationType.EZSP, + firmware_name="EmberZNet", + ), + ApplicationType.SPINEL: FirmwareUpdateEntityDescription( + key="firmware", + display_precision=0, + device_class=UpdateDeviceClass.FIRMWARE, + entity_category=EntityCategory.CONFIG, + version_parser=lambda fw: fw.split("/", 1)[1].split("_", 1)[0], + fw_type="yellow_openthread_rcp", + version_key="ot_rcp_version", + expected_firmware_type=ApplicationType.SPINEL, + firmware_name="OpenThread RCP", + ), + None: FirmwareUpdateEntityDescription( + key="firmware", + display_precision=0, + device_class=UpdateDeviceClass.FIRMWARE, + entity_category=EntityCategory.CONFIG, + version_parser=lambda fw: fw, + fw_type=None, + version_key=None, + expected_firmware_type=None, + firmware_name=None, + ), +} + + +def _async_create_update_entity( + hass: HomeAssistant, + config_entry: ConfigEntry, + session: aiohttp.ClientSession, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> FirmwareUpdateEntity: + """Create an update entity that handles firmware type changes.""" + firmware_type = config_entry.data[FIRMWARE] + entity_description = FIRMWARE_ENTITY_DESCRIPTIONS[ + ApplicationType(firmware_type) if firmware_type is not None else None + ] + + entity = FirmwareUpdateEntity( + device=RADIO_DEVICE, + config_entry=config_entry, + update_coordinator=FirmwareUpdateCoordinator( + hass, + session, + NABU_CASA_FIRMWARE_RELEASES_URL, + ), + entity_description=entity_description, + ) + + def firmware_type_changed( + old_type: ApplicationType | None, new_type: ApplicationType | None + ) -> None: + """Replace the current entity when the firmware type changes.""" + er.async_get(hass).async_remove(entity.entity_id) + async_add_entities( + [ + _async_create_update_entity( + hass, config_entry, session, async_add_entities + ) + ] + ) + + entity.async_on_remove( + entity.add_firmware_type_changed_callback(firmware_type_changed) + ) + + return entity + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the firmware update config entry.""" + session = async_get_clientsession(hass) + entity = _async_create_update_entity( + hass, config_entry, session, async_add_entities + ) + + async_add_entities([entity]) + + +class FirmwareUpdateEntity(BaseFirmwareUpdateEntity): + """Yellow firmware update entity.""" + + bootloader_reset_type = "yellow" # Triggers a GPIO reset + + def __init__( + self, + device: str, + config_entry: ConfigEntry, + update_coordinator: FirmwareUpdateCoordinator, + entity_description: FirmwareUpdateEntityDescription, + ) -> None: + """Initialize the Yellow firmware update entity.""" + super().__init__(device, config_entry, update_coordinator, entity_description) + + self._attr_unique_id = self.entity_description.key + + # Use the cached firmware info if it exists + if self._config_entry.data[FIRMWARE] is not None: + self._current_firmware_info = FirmwareInfo( + device=device, + firmware_type=ApplicationType(self._config_entry.data[FIRMWARE]), + firmware_version=self._config_entry.data[FIRMWARE_VERSION], + owners=[], + source="homeassistant_yellow", + ) + + @callback + def _firmware_info_callback(self, firmware_info: FirmwareInfo) -> None: + """Handle updated firmware info being pushed by an integration.""" + self.hass.config_entries.async_update_entry( + self._config_entry, + data={ + **self._config_entry.data, + FIRMWARE: firmware_info.firmware_type, + FIRMWARE_VERSION: firmware_info.firmware_version, + }, + ) + super()._firmware_info_callback(firmware_info) diff --git a/requirements_all.txt b/requirements_all.txt index 916bdcee3c4..f02cdb56fc9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1105,6 +1105,9 @@ ha-iotawattpy==0.1.2 # homeassistant.components.philips_js ha-philipsjs==3.2.2 +# homeassistant.components.homeassistant_hardware +ha-silabs-firmware-client==0.2.0 + # homeassistant.components.habitica habiticalib==0.3.7 @@ -2974,7 +2977,7 @@ unifi_ap==0.0.2 unifiled==0.11 # homeassistant.components.homeassistant_hardware -universal-silabs-flasher==0.0.29 +universal-silabs-flasher==0.0.30 # homeassistant.components.upb upb-lib==0.6.1 diff --git a/tests/components/homeassistant_hardware/test_coordinator.py b/tests/components/homeassistant_hardware/test_coordinator.py new file mode 100644 index 00000000000..9c57aac6811 --- /dev/null +++ b/tests/components/homeassistant_hardware/test_coordinator.py @@ -0,0 +1,55 @@ +"""Test firmware update coordinator for Home Assistant Hardware.""" + +from unittest.mock import AsyncMock, Mock, call, patch + +from ha_silabs_firmware_client import FirmwareManifest, ManifestMissing +import pytest +from yarl import URL + +from homeassistant.components.homeassistant_hardware.coordinator import ( + FirmwareUpdateCoordinator, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.util import dt as dt_util + + +async def test_firmware_update_coordinator_fetching( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test the firmware update coordinator loads manifests.""" + session = async_get_clientsession(hass) + + manifest = FirmwareManifest( + url=URL("https://example.org/firmware"), + html_url=URL("https://example.org/release_notes"), + created_at=dt_util.utcnow(), + firmwares=(), + ) + + mock_client = Mock() + mock_client.async_update_data = AsyncMock(side_effect=[ManifestMissing(), manifest]) + + with patch( + "homeassistant.components.homeassistant_hardware.coordinator.FirmwareUpdateClient", + return_value=mock_client, + ): + coordinator = FirmwareUpdateCoordinator( + hass, session, "https://example.org/firmware" + ) + + listener = Mock() + coordinator.async_add_listener(listener) + + # The first update will fail + await coordinator.async_refresh() + assert listener.mock_calls == [call()] + assert coordinator.data is None + assert "GitHub release assets haven't been uploaded yet" in caplog.text + + # The second will succeed + await coordinator.async_refresh() + assert listener.mock_calls == [call(), call()] + assert coordinator.data == manifest + + await coordinator.async_shutdown() diff --git a/tests/components/homeassistant_hardware/test_update.py b/tests/components/homeassistant_hardware/test_update.py new file mode 100644 index 00000000000..0c351141e12 --- /dev/null +++ b/tests/components/homeassistant_hardware/test_update.py @@ -0,0 +1,637 @@ +"""Test Home Assistant Hardware firmware update entity.""" + +from __future__ import annotations + +import asyncio +from collections.abc import AsyncGenerator +import dataclasses +import logging +from unittest.mock import AsyncMock, Mock, patch + +import aiohttp +from ha_silabs_firmware_client import FirmwareManifest, FirmwareMetadata +import pytest +from yarl import URL + +from homeassistant.components.homeassistant_hardware.coordinator import ( + FirmwareUpdateCoordinator, +) +from homeassistant.components.homeassistant_hardware.helpers import ( + async_notify_firmware_info, + async_register_firmware_info_provider, +) +from homeassistant.components.homeassistant_hardware.update import ( + BaseFirmwareUpdateEntity, + FirmwareUpdateEntityDescription, + FirmwareUpdateExtraStoredData, +) +from homeassistant.components.homeassistant_hardware.util import ( + ApplicationType, + FirmwareInfo, + OwningIntegration, +) +from homeassistant.components.update import UpdateDeviceClass +from homeassistant.config_entries import ConfigEntry, ConfigEntryState, ConfigFlow +from homeassistant.const import EVENT_STATE_CHANGED, EntityCategory +from homeassistant.core import ( + Event, + EventStateChangedData, + HomeAssistant, + HomeAssistantError, + State, + callback, +) +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util + +from tests.common import ( + MockConfigEntry, + MockModule, + MockPlatform, + async_capture_events, + mock_config_flow, + mock_integration, + mock_platform, + mock_restore_cache_with_extra_data, +) + +TEST_DOMAIN = "test" +TEST_DEVICE = "/dev/serial/by-id/some-unique-serial-device-12345" +TEST_FIRMWARE_RELEASES_URL = "https://example.org/firmware" +TEST_UPDATE_ENTITY_ID = "update.test_firmware" +TEST_MANIFEST = FirmwareManifest( + url=URL("https://example.org/firmware"), + html_url=URL("https://example.org/release_notes"), + created_at=dt_util.utcnow(), + firmwares=( + FirmwareMetadata( + filename="skyconnect_zigbee_ncp_test.gbl", + checksum="aaa", + size=123, + release_notes="Some release notes go here", + metadata={ + "baudrate": 115200, + "ezsp_version": "7.4.4.0", + "fw_type": "zigbee_ncp", + "fw_variant": None, + "metadata_version": 2, + "sdk_version": "4.4.4", + }, + url=URL("https://example.org/firmwares/skyconnect_zigbee_ncp_test.gbl"), + ), + ), +) + + +TEST_FIRMWARE_ENTITY_DESCRIPTIONS: dict[ + ApplicationType | None, FirmwareUpdateEntityDescription +] = { + ApplicationType.EZSP: FirmwareUpdateEntityDescription( + key="firmware", + display_precision=0, + device_class=UpdateDeviceClass.FIRMWARE, + entity_category=EntityCategory.CONFIG, + version_parser=lambda fw: fw.split(" ", 1)[0], + fw_type="skyconnect_zigbee_ncp", + version_key="ezsp_version", + expected_firmware_type=ApplicationType.EZSP, + firmware_name="EmberZNet", + ), + ApplicationType.SPINEL: FirmwareUpdateEntityDescription( + key="firmware", + display_precision=0, + device_class=UpdateDeviceClass.FIRMWARE, + entity_category=EntityCategory.CONFIG, + version_parser=lambda fw: fw.split("/", 1)[1].split("_", 1)[0], + fw_type="skyconnect_openthread_rcp", + version_key="ot_rcp_version", + expected_firmware_type=ApplicationType.SPINEL, + firmware_name="OpenThread RCP", + ), + None: FirmwareUpdateEntityDescription( + key="firmware", + display_precision=0, + device_class=UpdateDeviceClass.FIRMWARE, + entity_category=EntityCategory.CONFIG, + version_parser=lambda fw: fw, + fw_type=None, + version_key=None, + expected_firmware_type=None, + firmware_name=None, + ), +} + + +def _mock_async_create_update_entity( + hass: HomeAssistant, + config_entry: ConfigEntry, + session: aiohttp.ClientSession, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> MockFirmwareUpdateEntity: + """Create an update entity that handles firmware type changes.""" + firmware_type = config_entry.data["firmware"] + entity_description = TEST_FIRMWARE_ENTITY_DESCRIPTIONS[ + ApplicationType(firmware_type) if firmware_type is not None else None + ] + + entity = MockFirmwareUpdateEntity( + device=config_entry.data["device"], + config_entry=config_entry, + update_coordinator=FirmwareUpdateCoordinator( + hass, + session, + TEST_FIRMWARE_RELEASES_URL, + ), + entity_description=entity_description, + ) + + def firmware_type_changed( + old_type: ApplicationType | None, new_type: ApplicationType | None + ) -> None: + """Replace the current entity when the firmware type changes.""" + er.async_get(hass).async_remove(entity.entity_id) + async_add_entities( + [ + _mock_async_create_update_entity( + hass, config_entry, session, async_add_entities + ) + ] + ) + + entity.async_on_remove( + entity.add_firmware_type_changed_callback(firmware_type_changed) + ) + + return entity + + +async def mock_async_setup_entry( + hass: HomeAssistant, config_entry: ConfigEntry +) -> bool: + """Set up test config entry.""" + await hass.config_entries.async_forward_entry_setups(config_entry, ["update"]) + return True + + +async def mock_async_setup_update_entities( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the firmware update config entry.""" + session = async_get_clientsession(hass) + entity = _mock_async_create_update_entity( + hass, config_entry, session, async_add_entities + ) + + async_add_entities([entity]) + + +class MockFirmwareUpdateEntity(BaseFirmwareUpdateEntity): + """Mock SkyConnect firmware update entity.""" + + bootloader_reset_type = None + + def __init__( + self, + device: str, + config_entry: ConfigEntry, + update_coordinator: FirmwareUpdateCoordinator, + entity_description: FirmwareUpdateEntityDescription, + ) -> None: + """Initialize the mock SkyConnect firmware update entity.""" + super().__init__(device, config_entry, update_coordinator, entity_description) + self._attr_unique_id = self.entity_description.key + + # Use the cached firmware info if it exists + if self._config_entry.data["firmware"] is not None: + self._current_firmware_info = FirmwareInfo( + device=device, + firmware_type=ApplicationType(self._config_entry.data["firmware"]), + firmware_version=self._config_entry.data["firmware_version"], + owners=[], + source=TEST_DOMAIN, + ) + + @callback + def _firmware_info_callback(self, firmware_info: FirmwareInfo) -> None: + """Handle updated firmware info being pushed by an integration.""" + super()._firmware_info_callback(firmware_info) + + self.hass.config_entries.async_update_entry( + self._config_entry, + data={ + **self._config_entry.data, + "firmware": firmware_info.firmware_type, + "firmware_version": firmware_info.firmware_version, + }, + ) + + +@pytest.fixture(name="update_config_entry") +async def mock_update_config_entry( + hass: HomeAssistant, +) -> AsyncGenerator[ConfigEntry]: + """Set up a mock Home Assistant Hardware firmware update entity.""" + await async_setup_component(hass, "homeassistant", {}) + await async_setup_component(hass, "homeassistant_hardware", {}) + + mock_integration( + hass, + MockModule( + TEST_DOMAIN, + async_setup_entry=mock_async_setup_entry, + ), + built_in=False, + ) + mock_platform(hass, "test.config_flow") + mock_platform( + hass, + "test.update", + MockPlatform(async_setup_entry=mock_async_setup_update_entities), + ) + + # Set up a mock integration using the hardware update entity + config_entry = MockConfigEntry( + domain=TEST_DOMAIN, + data={ + "device": TEST_DEVICE, + "firmware": "ezsp", + "firmware_version": "7.3.1.0 build 0", + }, + ) + config_entry.add_to_hass(hass) + + with ( + patch( + "homeassistant.components.homeassistant_hardware.coordinator.FirmwareUpdateClient", + autospec=True, + ) as mock_update_client, + mock_config_flow(TEST_DOMAIN, ConfigFlow), + ): + mock_update_client.return_value.async_update_data.return_value = TEST_MANIFEST + yield config_entry + + +async def test_update_entity_installation( + hass: HomeAssistant, update_config_entry: ConfigEntry +) -> None: + """Test the Hardware firmware update entity installation.""" + + assert await hass.config_entries.async_setup(update_config_entry.entry_id) + await hass.async_block_till_done() + + # Set up another integration communicating with the device + owning_config_entry = MockConfigEntry( + domain="another_integration", + data={ + "device": { + "path": TEST_DEVICE, + "flow_control": "hardware", + "baudrate": 115200, + }, + "radio_type": "ezsp", + }, + version=4, + ) + owning_config_entry.add_to_hass(hass) + owning_config_entry.mock_state(hass, ConfigEntryState.LOADED) + + # The integration provides firmware info + mock_hw_module = Mock() + mock_hw_module.get_firmware_info = lambda hass, config_entry: FirmwareInfo( + device=TEST_DEVICE, + firmware_type=ApplicationType.EZSP, + firmware_version="7.3.1.0 build 0", + owners=[OwningIntegration(config_entry_id=config_entry.entry_id)], + source="another_integration", + ) + + async_register_firmware_info_provider(hass, "another_integration", mock_hw_module) + + # Pretend the other integration loaded and notified hardware of the running firmware + await async_notify_firmware_info( + hass, + "another_integration", + mock_hw_module.get_firmware_info(hass, owning_config_entry), + ) + + state_before_update = hass.states.get(TEST_UPDATE_ENTITY_ID) + assert state_before_update is not None + assert state_before_update.state == "unknown" + assert state_before_update.attributes["title"] == "EmberZNet" + assert state_before_update.attributes["installed_version"] == "7.3.1.0" + assert state_before_update.attributes["latest_version"] is None + + # When we check for an update, one will be shown + await hass.services.async_call( + "homeassistant", + "update_entity", + {"entity_id": TEST_UPDATE_ENTITY_ID}, + blocking=True, + ) + state_after_update = hass.states.get(TEST_UPDATE_ENTITY_ID) + assert state_after_update is not None + assert state_after_update.state == "on" + assert state_after_update.attributes["title"] == "EmberZNet" + assert state_after_update.attributes["installed_version"] == "7.3.1.0" + assert state_after_update.attributes["latest_version"] == "7.4.4.0" + assert state_after_update.attributes["release_summary"] == ( + "Some release notes go here" + ) + assert state_after_update.attributes["release_url"] == ( + "https://example.org/release_notes" + ) + + mock_firmware = Mock() + mock_flasher = AsyncMock() + + async def mock_flash_firmware(fw_image, progress_callback): + await asyncio.sleep(0) + progress_callback(0, 100) + await asyncio.sleep(0) + progress_callback(50, 100) + await asyncio.sleep(0) + progress_callback(100, 100) + + mock_flasher.flash_firmware = mock_flash_firmware + + # When we install it, the other integration is reloaded + with ( + patch( + "homeassistant.components.homeassistant_hardware.update.parse_firmware_image", + return_value=mock_firmware, + ), + patch( + "homeassistant.components.homeassistant_hardware.update.Flasher", + return_value=mock_flasher, + ), + patch( + "homeassistant.components.homeassistant_hardware.update.probe_silabs_firmware_info", + return_value=FirmwareInfo( + device=TEST_DEVICE, + firmware_type=ApplicationType.EZSP, + firmware_version="7.4.4.0 build 0", + owners=[], + source="probe", + ), + ), + patch.object( + owning_config_entry, "async_unload", wraps=owning_config_entry.async_unload + ) as owning_config_entry_unload, + ): + state_changes: list[Event[EventStateChangedData]] = async_capture_events( + hass, EVENT_STATE_CHANGED + ) + await hass.services.async_call( + "update", + "install", + {"entity_id": TEST_UPDATE_ENTITY_ID}, + blocking=True, + ) + + # Progress events are emitted during the installation + assert len(state_changes) == 7 + + # Indeterminate progress first + assert state_changes[0].data["new_state"].attributes["in_progress"] is True + assert state_changes[0].data["new_state"].attributes["update_percentage"] is None + + # Then the update starts + assert state_changes[1].data["new_state"].attributes["update_percentage"] == 0 + assert state_changes[2].data["new_state"].attributes["update_percentage"] == 50 + assert state_changes[3].data["new_state"].attributes["update_percentage"] == 100 + + # Once it is done, we probe the firmware + assert state_changes[4].data["new_state"].attributes["in_progress"] is True + assert state_changes[4].data["new_state"].attributes["update_percentage"] is None + + # Finally, the update finishes + assert state_changes[5].data["new_state"].attributes["update_percentage"] is None + assert state_changes[6].data["new_state"].attributes["update_percentage"] is None + assert state_changes[6].data["new_state"].attributes["in_progress"] is False + + # The owning integration was unloaded and is again running + assert len(owning_config_entry_unload.mock_calls) == 1 + + # After the firmware update, the entity has the new version and the correct state + state_after_install = hass.states.get(TEST_UPDATE_ENTITY_ID) + assert state_after_install is not None + assert state_after_install.state == "off" + assert state_after_install.attributes["title"] == "EmberZNet" + assert state_after_install.attributes["installed_version"] == "7.4.4.0" + assert state_after_install.attributes["latest_version"] == "7.4.4.0" + + +async def test_update_entity_installation_failure( + hass: HomeAssistant, update_config_entry: ConfigEntry +) -> None: + """Test installation failing during flashing.""" + assert await hass.config_entries.async_setup(update_config_entry.entry_id) + await hass.async_block_till_done() + + await hass.services.async_call( + "homeassistant", + "update_entity", + {"entity_id": TEST_UPDATE_ENTITY_ID}, + blocking=True, + ) + + state_before_install = hass.states.get(TEST_UPDATE_ENTITY_ID) + assert state_before_install is not None + assert state_before_install.state == "on" + assert state_before_install.attributes["title"] == "EmberZNet" + assert state_before_install.attributes["installed_version"] == "7.3.1.0" + assert state_before_install.attributes["latest_version"] == "7.4.4.0" + + mock_flasher = AsyncMock() + mock_flasher.flash_firmware.side_effect = RuntimeError( + "Something broke during flashing!" + ) + + with ( + patch( + "homeassistant.components.homeassistant_hardware.update.parse_firmware_image", + return_value=Mock(), + ), + patch( + "homeassistant.components.homeassistant_hardware.update.Flasher", + return_value=mock_flasher, + ), + pytest.raises(HomeAssistantError, match="Failed to flash firmware"), + ): + await hass.services.async_call( + "update", + "install", + {"entity_id": TEST_UPDATE_ENTITY_ID}, + blocking=True, + ) + + # After the firmware update fails, we can still try again + state_after_install = hass.states.get(TEST_UPDATE_ENTITY_ID) + assert state_after_install is not None + assert state_after_install.state == "on" + assert state_after_install.attributes["title"] == "EmberZNet" + assert state_after_install.attributes["installed_version"] == "7.3.1.0" + assert state_after_install.attributes["latest_version"] == "7.4.4.0" + + +async def test_update_entity_installation_probe_failure( + hass: HomeAssistant, update_config_entry: ConfigEntry +) -> None: + """Test installation failing during post-flashing probing.""" + assert await hass.config_entries.async_setup(update_config_entry.entry_id) + await hass.async_block_till_done() + + await hass.services.async_call( + "homeassistant", + "update_entity", + {"entity_id": TEST_UPDATE_ENTITY_ID}, + blocking=True, + ) + + state_before_install = hass.states.get(TEST_UPDATE_ENTITY_ID) + assert state_before_install is not None + assert state_before_install.state == "on" + assert state_before_install.attributes["title"] == "EmberZNet" + assert state_before_install.attributes["installed_version"] == "7.3.1.0" + assert state_before_install.attributes["latest_version"] == "7.4.4.0" + + with ( + patch( + "homeassistant.components.homeassistant_hardware.update.parse_firmware_image", + return_value=Mock(), + ), + patch( + "homeassistant.components.homeassistant_hardware.update.Flasher", + return_value=AsyncMock(), + ), + patch( + "homeassistant.components.homeassistant_hardware.update.probe_silabs_firmware_info", + return_value=None, + ), + pytest.raises( + HomeAssistantError, match="Failed to probe the firmware after flashing" + ), + ): + await hass.services.async_call( + "update", + "install", + {"entity_id": TEST_UPDATE_ENTITY_ID}, + blocking=True, + ) + + # After the firmware update fails, we can still try again + state_after_install = hass.states.get(TEST_UPDATE_ENTITY_ID) + assert state_after_install is not None + assert state_after_install.state == "on" + assert state_after_install.attributes["title"] == "EmberZNet" + assert state_after_install.attributes["installed_version"] == "7.3.1.0" + assert state_after_install.attributes["latest_version"] == "7.4.4.0" + + +async def test_update_entity_state_restoration( + hass: HomeAssistant, update_config_entry: ConfigEntry +) -> None: + """Test the Hardware firmware update entity state restoration.""" + + mock_restore_cache_with_extra_data( + hass, + [ + ( + State(TEST_UPDATE_ENTITY_ID, "on"), + FirmwareUpdateExtraStoredData( + firmware_manifest=TEST_MANIFEST + ).as_dict(), + ) + ], + ) + + assert await hass.config_entries.async_setup(update_config_entry.entry_id) + await hass.async_block_till_done() + + # The state is correctly restored + state = hass.states.get(TEST_UPDATE_ENTITY_ID) + assert state is not None + assert state.state == "on" + assert state.attributes["title"] == "EmberZNet" + assert state.attributes["installed_version"] == "7.3.1.0" + assert state.attributes["latest_version"] == "7.4.4.0" + assert state.attributes["release_summary"] == ("Some release notes go here") + assert state.attributes["release_url"] == ("https://example.org/release_notes") + + +async def test_update_entity_firmware_missing_from_manifest( + hass: HomeAssistant, update_config_entry: ConfigEntry +) -> None: + """Test the Hardware firmware update entity handles missing firmware.""" + + mock_restore_cache_with_extra_data( + hass, + [ + ( + State(TEST_UPDATE_ENTITY_ID, "on"), + # Ensure the manifest does not contain our expected firmware type + FirmwareUpdateExtraStoredData( + firmware_manifest=dataclasses.replace(TEST_MANIFEST, firmwares=()) + ).as_dict(), + ) + ], + ) + + assert await hass.config_entries.async_setup(update_config_entry.entry_id) + await hass.async_block_till_done() + + # The state is restored, accounting for the missing firmware + state = hass.states.get(TEST_UPDATE_ENTITY_ID) + assert state is not None + assert state.state == "unknown" + assert state.attributes["title"] == "EmberZNet" + assert state.attributes["installed_version"] == "7.3.1.0" + assert state.attributes["latest_version"] is None + assert state.attributes["release_summary"] is None + assert state.attributes["release_url"] is None + + +async def test_update_entity_graceful_firmware_type_callback_errors( + hass: HomeAssistant, + update_config_entry: ConfigEntry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test firmware update entity handling of firmware type callback errors.""" + + session = async_get_clientsession(hass) + update_entity = MockFirmwareUpdateEntity( + device=TEST_DEVICE, + config_entry=update_config_entry, + update_coordinator=FirmwareUpdateCoordinator( + hass, + session, + TEST_FIRMWARE_RELEASES_URL, + ), + entity_description=TEST_FIRMWARE_ENTITY_DESCRIPTIONS[ApplicationType.EZSP], + ) + update_entity.hass = hass + await update_entity.async_added_to_hass() + + callback = Mock(side_effect=RuntimeError("Callback failed")) + unregister_callback = update_entity.add_firmware_type_changed_callback(callback) + + with caplog.at_level(logging.WARNING): + await async_notify_firmware_info( + hass, + "some_integration", + FirmwareInfo( + device=TEST_DEVICE, + firmware_type=ApplicationType.SPINEL, + firmware_version="SL-OPENTHREAD/2.4.4.0_GitHub-7074a43e4; EFR32; Oct 21 2024 14:40:57", + owners=[], + source="probe", + ), + ) + + unregister_callback() + assert "Failed to call firmware type changed callback" in caplog.text diff --git a/tests/components/homeassistant_hardware/test_util.py b/tests/components/homeassistant_hardware/test_util.py index b467380c431..1b7bfe4a8ac 100644 --- a/tests/components/homeassistant_hardware/test_util.py +++ b/tests/components/homeassistant_hardware/test_util.py @@ -205,6 +205,93 @@ async def test_owning_addon(hass: HomeAssistant) -> None: assert (await owning_addon.is_running(hass)) is False +async def test_owning_addon_temporarily_stop_info_error(hass: HomeAssistant) -> None: + """Test `OwningAddon` temporarily stopping with an info error.""" + + owning_addon = OwningAddon(slug="some-addon-slug") + mock_manager = AsyncMock() + mock_manager.async_get_addon_info.side_effect = AddonError() + + with patch( + "homeassistant.components.homeassistant_hardware.util.WaitingAddonManager", + return_value=mock_manager, + ): + async with owning_addon.temporarily_stop(hass): + pass + + # We never restart it + assert len(mock_manager.async_get_addon_info.mock_calls) == 1 + assert len(mock_manager.async_stop_addon.mock_calls) == 0 + assert len(mock_manager.async_wait_until_addon_state.mock_calls) == 0 + assert len(mock_manager.async_start_addon_waiting.mock_calls) == 0 + + +async def test_owning_addon_temporarily_stop_not_running(hass: HomeAssistant) -> None: + """Test `OwningAddon` temporarily stopping when the addon is not running.""" + + owning_addon = OwningAddon(slug="some-addon-slug") + + mock_manager = AsyncMock() + mock_manager.async_get_addon_info.return_value = AddonInfo( + available=True, + hostname="core_some_addon_slug", + options={}, + state=AddonState.NOT_RUNNING, + update_available=False, + version="1.0.0", + ) + + with patch( + "homeassistant.components.homeassistant_hardware.util.WaitingAddonManager", + return_value=mock_manager, + ): + async with owning_addon.temporarily_stop(hass): + pass + + # We never restart it + assert len(mock_manager.async_get_addon_info.mock_calls) == 1 + assert len(mock_manager.async_stop_addon.mock_calls) == 0 + assert len(mock_manager.async_wait_until_addon_state.mock_calls) == 0 + assert len(mock_manager.async_start_addon_waiting.mock_calls) == 0 + + +async def test_owning_addon_temporarily_stop(hass: HomeAssistant) -> None: + """Test `OwningAddon` temporarily stopping when the addon is running.""" + + owning_addon = OwningAddon(slug="some-addon-slug") + + mock_manager = AsyncMock() + mock_manager.async_get_addon_info.return_value = AddonInfo( + available=True, + hostname="core_some_addon_slug", + options={}, + state=AddonState.RUNNING, + update_available=False, + version="1.0.0", + ) + + mock_manager.async_stop_addon = AsyncMock() + mock_manager.async_wait_until_addon_state = AsyncMock() + mock_manager.async_start_addon_waiting = AsyncMock() + + # The error is propagated but it doesn't affect restarting the addon + with ( + patch( + "homeassistant.components.homeassistant_hardware.util.WaitingAddonManager", + return_value=mock_manager, + ), + pytest.raises(RuntimeError), + ): + async with owning_addon.temporarily_stop(hass): + raise RuntimeError("Some error") + + # We restart it + assert len(mock_manager.async_get_addon_info.mock_calls) == 1 + assert len(mock_manager.async_stop_addon.mock_calls) == 1 + assert len(mock_manager.async_wait_until_addon_state.mock_calls) == 1 + assert len(mock_manager.async_start_addon_waiting.mock_calls) == 1 + + async def test_owning_integration(hass: HomeAssistant) -> None: """Test `OwningIntegration`.""" config_entry = MockConfigEntry(domain="mock_domain", unique_id="some_unique_id") @@ -225,6 +312,67 @@ async def test_owning_integration(hass: HomeAssistant) -> None: assert (await owning_integration2.is_running(hass)) is False +async def test_owning_integration_temporarily_stop_missing_entry( + hass: HomeAssistant, +) -> None: + """Test temporarily stopping the integration when the config entry doesn't exist.""" + missing_integration = OwningIntegration(config_entry_id="missing_entry_id") + + with ( + patch.object(hass.config_entries, "async_unload") as mock_unload, + patch.object(hass.config_entries, "async_setup") as mock_setup, + ): + async with missing_integration.temporarily_stop(hass): + pass + + # Because there's no matching entry, no unload or setup calls are made + assert len(mock_unload.mock_calls) == 0 + assert len(mock_setup.mock_calls) == 0 + + +async def test_owning_integration_temporarily_stop_not_loaded( + hass: HomeAssistant, +) -> None: + """Test temporarily stopping the integration when the config entry is not loaded.""" + entry = MockConfigEntry(domain="test_domain") + entry.add_to_hass(hass) + entry.mock_state(hass, ConfigEntryState.NOT_LOADED) + + integration = OwningIntegration(config_entry_id=entry.entry_id) + + with ( + patch.object(hass.config_entries, "async_unload") as mock_unload, + patch.object(hass.config_entries, "async_setup") as mock_setup, + ): + async with integration.temporarily_stop(hass): + pass + + # Since the entry was not loaded, we never unload or re-setup + assert len(mock_unload.mock_calls) == 0 + assert len(mock_setup.mock_calls) == 0 + + +async def test_owning_integration_temporarily_stop_loaded(hass: HomeAssistant) -> None: + """Test temporarily stopping the integration when the config entry is loaded.""" + entry = MockConfigEntry(domain="test_domain") + entry.add_to_hass(hass) + entry.mock_state(hass, ConfigEntryState.LOADED) + + integration = OwningIntegration(config_entry_id=entry.entry_id) + + with ( + patch.object(hass.config_entries, "async_unload") as mock_unload, + patch.object(hass.config_entries, "async_setup") as mock_setup, + pytest.raises(RuntimeError), + ): + async with integration.temporarily_stop(hass): + raise RuntimeError("Some error during the temporary stop") + + # We expect one unload followed by one setup call + mock_unload.assert_called_once_with(entry.entry_id) + mock_setup.assert_called_once_with(entry.entry_id) + + async def test_firmware_info(hass: HomeAssistant) -> None: """Test `FirmwareInfo`.""" diff --git a/tests/components/homeassistant_sky_connect/common.py b/tests/components/homeassistant_sky_connect/common.py new file mode 100644 index 00000000000..335fd6d2e12 --- /dev/null +++ b/tests/components/homeassistant_sky_connect/common.py @@ -0,0 +1,21 @@ +"""Common constants for the SkyConnect integration tests.""" + +from homeassistant.helpers.service_info.usb import UsbServiceInfo + +USB_DATA_SKY = UsbServiceInfo( + device="/dev/serial/by-id/usb-Nabu_Casa_SkyConnect_v1.0_9e2adbd75b8beb119fe564a0f320645d-if00-port0", + vid="10C4", + pid="EA60", + serial_number="9e2adbd75b8beb119fe564a0f320645d", + manufacturer="Nabu Casa", + description="SkyConnect v1.0", +) + +USB_DATA_ZBT1 = UsbServiceInfo( + device="/dev/serial/by-id/usb-Nabu_Casa_Home_Assistant_Connect_ZBT-1_9e2adbd75b8beb119fe564a0f320645d-if00-port0", + vid="10C4", + pid="EA60", + serial_number="9e2adbd75b8beb119fe564a0f320645d", + manufacturer="Nabu Casa", + description="Home Assistant Connect ZBT-1", +) diff --git a/tests/components/homeassistant_sky_connect/test_config_flow.py b/tests/components/homeassistant_sky_connect/test_config_flow.py index d8542002ae8..44a5e0029c3 100644 --- a/tests/components/homeassistant_sky_connect/test_config_flow.py +++ b/tests/components/homeassistant_sky_connect/test_config_flow.py @@ -22,26 +22,10 @@ from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers.service_info.usb import UsbServiceInfo +from .common import USB_DATA_SKY, USB_DATA_ZBT1 + from tests.common import MockConfigEntry -USB_DATA_SKY = UsbServiceInfo( - device="/dev/serial/by-id/usb-Nabu_Casa_SkyConnect_v1.0_9e2adbd75b8beb119fe564a0f320645d-if00-port0", - vid="10C4", - pid="EA60", - serial_number="9e2adbd75b8beb119fe564a0f320645d", - manufacturer="Nabu Casa", - description="SkyConnect v1.0", -) - -USB_DATA_ZBT1 = UsbServiceInfo( - device="/dev/serial/by-id/usb-Nabu_Casa_Home_Assistant_Connect_ZBT-1_9e2adbd75b8beb119fe564a0f320645d-if00-port0", - vid="10C4", - pid="EA60", - serial_number="9e2adbd75b8beb119fe564a0f320645d", - manufacturer="Nabu Casa", - description="Home Assistant Connect ZBT-1", -) - @pytest.mark.parametrize( ("usb_data", "model"), @@ -76,7 +60,7 @@ async def test_config_flow( return_value=FirmwareInfo( device=usb_data.device, firmware_type=ApplicationType.EZSP, - firmware_version=None, + firmware_version="7.4.4.0 build 0", owners=[], source="probe", ), @@ -92,6 +76,7 @@ async def test_config_flow( config_entry = result["result"] assert config_entry.data == { "firmware": "ezsp", + "firmware_version": "7.4.4.0 build 0", "device": usb_data.device, "manufacturer": usb_data.manufacturer, "pid": usb_data.pid, @@ -161,7 +146,7 @@ async def test_options_flow( return_value=FirmwareInfo( device=usb_data.device, firmware_type=ApplicationType.EZSP, - firmware_version=None, + firmware_version="7.4.4.0 build 0", owners=[], source="probe", ), @@ -177,6 +162,7 @@ async def test_options_flow( assert config_entry.data == { "firmware": "ezsp", + "firmware_version": "7.4.4.0 build 0", "device": usb_data.device, "manufacturer": usb_data.manufacturer, "pid": usb_data.pid, diff --git a/tests/components/homeassistant_sky_connect/test_init.py b/tests/components/homeassistant_sky_connect/test_init.py index 8e90039a4fc..c467a9e0d60 100644 --- a/tests/components/homeassistant_sky_connect/test_init.py +++ b/tests/components/homeassistant_sky_connect/test_init.py @@ -44,7 +44,7 @@ async def test_config_entry_migration_v2(hass: HomeAssistant) -> None: await hass.config_entries.async_setup(config_entry.entry_id) assert config_entry.version == 1 - assert config_entry.minor_version == 2 + assert config_entry.minor_version == 3 assert config_entry.data == { "description": "SkyConnect v1.0", "device": "/dev/serial/by-id/usb-Nabu_Casa_SkyConnect_v1.0_9e2adbd75b8beb119fe564a0f320645d-if00-port0", @@ -54,6 +54,7 @@ async def test_config_entry_migration_v2(hass: HomeAssistant) -> None: "manufacturer": "Nabu Casa", "product": "SkyConnect v1.0", # `description` has been copied to `product` "firmware": "spinel", # new key + "firmware_version": None, # new key } await hass.config_entries.async_unload(config_entry.entry_id) diff --git a/tests/components/homeassistant_sky_connect/test_update.py b/tests/components/homeassistant_sky_connect/test_update.py new file mode 100644 index 00000000000..9fb7528987e --- /dev/null +++ b/tests/components/homeassistant_sky_connect/test_update.py @@ -0,0 +1,86 @@ +"""Test SkyConnect firmware update entity.""" + +from homeassistant.components.homeassistant_hardware.helpers import ( + async_notify_firmware_info, +) +from homeassistant.components.homeassistant_hardware.util import ( + ApplicationType, + FirmwareInfo, +) +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from .common import USB_DATA_ZBT1 + +from tests.common import MockConfigEntry + +UPDATE_ENTITY_ID = ( + "update.homeassistant_sky_connect_9e2adbd75b8beb119fe564a0f320645d_firmware" +) + + +async def test_zbt1_update_entity(hass: HomeAssistant) -> None: + """Test the ZBT-1 firmware update entity.""" + await async_setup_component(hass, "homeassistant", {}) + + # Set up the ZBT-1 integration + zbt1_config_entry = MockConfigEntry( + domain="homeassistant_sky_connect", + data={ + "firmware": "ezsp", + "firmware_version": "7.3.1.0 build 0", + "device": USB_DATA_ZBT1.device, + "manufacturer": USB_DATA_ZBT1.manufacturer, + "pid": USB_DATA_ZBT1.pid, + "product": USB_DATA_ZBT1.description, + "serial_number": USB_DATA_ZBT1.serial_number, + "vid": USB_DATA_ZBT1.vid, + }, + version=1, + minor_version=3, + ) + zbt1_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(zbt1_config_entry.entry_id) + await hass.async_block_till_done() + + # Pretend ZHA loaded and notified hardware of the running firmware + await async_notify_firmware_info( + hass, + "zha", + FirmwareInfo( + device=USB_DATA_ZBT1.device, + firmware_type=ApplicationType.EZSP, + firmware_version="7.3.1.0 build 0", + owners=[], + source="zha", + ), + ) + await hass.async_block_till_done() + + state_ezsp = hass.states.get(UPDATE_ENTITY_ID) + assert state_ezsp.state == "unknown" + assert state_ezsp.attributes["title"] == "EmberZNet" + assert state_ezsp.attributes["installed_version"] == "7.3.1.0" + assert state_ezsp.attributes["latest_version"] is None + + # Now, have OTBR push some info + await async_notify_firmware_info( + hass, + "otbr", + FirmwareInfo( + device=USB_DATA_ZBT1.device, + firmware_type=ApplicationType.SPINEL, + firmware_version="SL-OPENTHREAD/2.4.4.0_GitHub-7074a43e4; EFR32; Oct 21 2024 14:40:57", + owners=[], + source="otbr", + ), + ) + await hass.async_block_till_done() + + # After the firmware update, the entity has the new version and the correct state + state_spinel = hass.states.get(UPDATE_ENTITY_ID) + assert state_spinel.state == "unknown" + assert state_spinel.attributes["title"] == "OpenThread RCP" + assert state_spinel.attributes["installed_version"] == "2.4.4.0" + assert state_spinel.attributes["latest_version"] is None diff --git a/tests/components/homeassistant_yellow/test_config_flow.py b/tests/components/homeassistant_yellow/test_config_flow.py index 78fd45c6b5b..46fec0a1f30 100644 --- a/tests/components/homeassistant_yellow/test_config_flow.py +++ b/tests/components/homeassistant_yellow/test_config_flow.py @@ -350,7 +350,7 @@ async def test_firmware_options_flow(hass: HomeAssistant) -> None: return_value=FirmwareInfo( device=RADIO_DEVICE, firmware_type=ApplicationType.EZSP, - firmware_version=None, + firmware_version="7.4.4.0 build 0", owners=[], source="probe", ), @@ -366,6 +366,7 @@ async def test_firmware_options_flow(hass: HomeAssistant) -> None: assert config_entry.data == { "firmware": "ezsp", + "firmware_version": "7.4.4.0 build 0", } diff --git a/tests/components/homeassistant_yellow/test_update.py b/tests/components/homeassistant_yellow/test_update.py new file mode 100644 index 00000000000..269ff2afc49 --- /dev/null +++ b/tests/components/homeassistant_yellow/test_update.py @@ -0,0 +1,89 @@ +"""Test Yellow firmware update entity.""" + +from unittest.mock import patch + +from homeassistant.components.homeassistant_hardware.helpers import ( + async_notify_firmware_info, +) +from homeassistant.components.homeassistant_hardware.util import ( + ApplicationType, + FirmwareInfo, +) +from homeassistant.components.homeassistant_yellow.const import RADIO_DEVICE +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry + +UPDATE_ENTITY_ID = "update.homeassistant_yellow_firmware" + + +async def test_yellow_update_entity(hass: HomeAssistant) -> None: + """Test the Yellow firmware update entity.""" + await async_setup_component(hass, "homeassistant", {}) + + # Set up the Yellow integration + yellow_config_entry = MockConfigEntry( + domain="homeassistant_yellow", + data={ + "firmware": "ezsp", + "firmware_version": "7.3.1.0 build 0", + "device": RADIO_DEVICE, + }, + version=1, + minor_version=3, + ) + yellow_config_entry.add_to_hass(hass) + + with ( + patch( + "homeassistant.components.homeassistant_yellow.is_hassio", return_value=True + ), + patch( + "homeassistant.components.homeassistant_yellow.get_os_info", + return_value={"board": "yellow"}, + ), + ): + assert await hass.config_entries.async_setup(yellow_config_entry.entry_id) + await hass.async_block_till_done() + + # Pretend ZHA loaded and notified hardware of the running firmware + await async_notify_firmware_info( + hass, + "zha", + FirmwareInfo( + device=RADIO_DEVICE, + firmware_type=ApplicationType.EZSP, + firmware_version="7.3.1.0 build 0", + owners=[], + source="zha", + ), + ) + await hass.async_block_till_done() + + state_ezsp = hass.states.get(UPDATE_ENTITY_ID) + assert state_ezsp.state == "unknown" + assert state_ezsp.attributes["title"] == "EmberZNet" + assert state_ezsp.attributes["installed_version"] == "7.3.1.0" + assert state_ezsp.attributes["latest_version"] is None + + # Now, have OTBR push some info + await async_notify_firmware_info( + hass, + "otbr", + FirmwareInfo( + device=RADIO_DEVICE, + firmware_type=ApplicationType.SPINEL, + firmware_version="SL-OPENTHREAD/2.4.4.0_GitHub-7074a43e4; EFR32; Oct 21 2024 14:40:57", + owners=[], + source="otbr", + ), + ) + await hass.async_block_till_done() + + # After the firmware update, the entity has the new version and the correct state + state_spinel = hass.states.get(UPDATE_ENTITY_ID) + assert state_spinel.state == "unknown" + assert state_spinel.attributes["title"] == "OpenThread RCP" + assert state_spinel.attributes["installed_version"] == "2.4.4.0" + assert state_spinel.attributes["latest_version"] is None From 11e15b1405f651cd1f8f2293194e2be9be9bcebf Mon Sep 17 00:00:00 2001 From: Jeff Terrace Date: Fri, 14 Mar 2025 20:16:35 -0400 Subject: [PATCH 2628/3148] Move redundant attribute and key error handling to event parser caller (#140630) --- homeassistant/components/onvif/event.py | 13 +- homeassistant/components/onvif/parsers.py | 867 ++++++++++------------ tests/components/onvif/test_parsers.py | 38 +- 3 files changed, 420 insertions(+), 498 deletions(-) diff --git a/homeassistant/components/onvif/event.py b/homeassistant/components/onvif/event.py index b7b34f7be9f..d1b93304ccc 100644 --- a/homeassistant/components/onvif/event.py +++ b/homeassistant/components/onvif/event.py @@ -174,11 +174,20 @@ class EventManager: UNHANDLED_TOPICS.add(topic) continue - event = await parser(unique_id, msg) + try: + event = await parser(unique_id, msg) + error = None + except (AttributeError, KeyError) as e: + event = None + error = e if not event: LOGGER.warning( - "%s: Unable to parse event from %s: %s", self.name, unique_id, msg + "%s: Unable to parse event from %s: %s: %s", + self.name, + unique_id, + error, + msg, ) return diff --git a/homeassistant/components/onvif/parsers.py b/homeassistant/components/onvif/parsers.py index 6eb1d001796..7544f92292a 100644 --- a/homeassistant/components/onvif/parsers.py +++ b/homeassistant/components/onvif/parsers.py @@ -54,19 +54,16 @@ async def async_parse_motion_alarm(uid: str, msg) -> Event | None: Topic: tns1:VideoSource/MotionAlarm """ - try: - topic, payload = extract_message(msg) - source = payload.Source.SimpleItem[0].Value - return Event( - f"{uid}_{topic}_{source}", - "Motion Alarm", - "binary_sensor", - "motion", - None, - payload.Data.SimpleItem[0].Value == "true", - ) - except (AttributeError, KeyError): - return None + topic, payload = extract_message(msg) + source = payload.Source.SimpleItem[0].Value + return Event( + f"{uid}_{topic}_{source}", + "Motion Alarm", + "binary_sensor", + "motion", + None, + payload.Data.SimpleItem[0].Value == "true", + ) @PARSERS.register("tns1:VideoSource/ImageTooBlurry/AnalyticsService") @@ -77,20 +74,17 @@ async def async_parse_image_too_blurry(uid: str, msg) -> Event | None: Topic: tns1:VideoSource/ImageTooBlurry/* """ - try: - topic, payload = extract_message(msg) - source = payload.Source.SimpleItem[0].Value - return Event( - f"{uid}_{topic}_{source}", - "Image Too Blurry", - "binary_sensor", - "problem", - None, - payload.Data.SimpleItem[0].Value == "true", - EntityCategory.DIAGNOSTIC, - ) - except (AttributeError, KeyError): - return None + topic, payload = extract_message(msg) + source = payload.Source.SimpleItem[0].Value + return Event( + f"{uid}_{topic}_{source}", + "Image Too Blurry", + "binary_sensor", + "problem", + None, + payload.Data.SimpleItem[0].Value == "true", + EntityCategory.DIAGNOSTIC, + ) @PARSERS.register("tns1:VideoSource/ImageTooDark/AnalyticsService") @@ -101,20 +95,17 @@ async def async_parse_image_too_dark(uid: str, msg) -> Event | None: Topic: tns1:VideoSource/ImageTooDark/* """ - try: - topic, payload = extract_message(msg) - source = payload.Source.SimpleItem[0].Value - return Event( - f"{uid}_{topic}_{source}", - "Image Too Dark", - "binary_sensor", - "problem", - None, - payload.Data.SimpleItem[0].Value == "true", - EntityCategory.DIAGNOSTIC, - ) - except (AttributeError, KeyError): - return None + topic, payload = extract_message(msg) + source = payload.Source.SimpleItem[0].Value + return Event( + f"{uid}_{topic}_{source}", + "Image Too Dark", + "binary_sensor", + "problem", + None, + payload.Data.SimpleItem[0].Value == "true", + EntityCategory.DIAGNOSTIC, + ) @PARSERS.register("tns1:VideoSource/ImageTooBright/AnalyticsService") @@ -125,20 +116,17 @@ async def async_parse_image_too_bright(uid: str, msg) -> Event | None: Topic: tns1:VideoSource/ImageTooBright/* """ - try: - topic, payload = extract_message(msg) - source = payload.Source.SimpleItem[0].Value - return Event( - f"{uid}_{topic}_{source}", - "Image Too Bright", - "binary_sensor", - "problem", - None, - payload.Data.SimpleItem[0].Value == "true", - EntityCategory.DIAGNOSTIC, - ) - except (AttributeError, KeyError): - return None + topic, payload = extract_message(msg) + source = payload.Source.SimpleItem[0].Value + return Event( + f"{uid}_{topic}_{source}", + "Image Too Bright", + "binary_sensor", + "problem", + None, + payload.Data.SimpleItem[0].Value == "true", + EntityCategory.DIAGNOSTIC, + ) @PARSERS.register("tns1:VideoSource/GlobalSceneChange/AnalyticsService") @@ -149,19 +137,16 @@ async def async_parse_scene_change(uid: str, msg) -> Event | None: Topic: tns1:VideoSource/GlobalSceneChange/* """ - try: - topic, payload = extract_message(msg) - source = payload.Source.SimpleItem[0].Value - return Event( - f"{uid}_{topic}_{source}", - "Global Scene Change", - "binary_sensor", - "problem", - None, - payload.Data.SimpleItem[0].Value == "true", - ) - except (AttributeError, KeyError): - return None + topic, payload = extract_message(msg) + source = payload.Source.SimpleItem[0].Value + return Event( + f"{uid}_{topic}_{source}", + "Global Scene Change", + "binary_sensor", + "problem", + None, + payload.Data.SimpleItem[0].Value == "true", + ) @PARSERS.register("tns1:AudioAnalytics/Audio/DetectedSound") @@ -170,29 +155,26 @@ async def async_parse_detected_sound(uid: str, msg) -> Event | None: Topic: tns1:AudioAnalytics/Audio/DetectedSound """ - try: - audio_source = "" - audio_analytics = "" - rule = "" - topic, payload = extract_message(msg) - for source in payload.Source.SimpleItem: - if source.Name == "AudioSourceConfigurationToken": - audio_source = source.Value - if source.Name == "AudioAnalyticsConfigurationToken": - audio_analytics = source.Value - if source.Name == "Rule": - rule = source.Value + audio_source = "" + audio_analytics = "" + rule = "" + topic, payload = extract_message(msg) + for source in payload.Source.SimpleItem: + if source.Name == "AudioSourceConfigurationToken": + audio_source = source.Value + if source.Name == "AudioAnalyticsConfigurationToken": + audio_analytics = source.Value + if source.Name == "Rule": + rule = source.Value - return Event( - f"{uid}_{topic}_{audio_source}_{audio_analytics}_{rule}", - "Detected Sound", - "binary_sensor", - "sound", - None, - payload.Data.SimpleItem[0].Value == "true", - ) - except (AttributeError, KeyError): - return None + return Event( + f"{uid}_{topic}_{audio_source}_{audio_analytics}_{rule}", + "Detected Sound", + "binary_sensor", + "sound", + None, + payload.Data.SimpleItem[0].Value == "true", + ) @PARSERS.register("tns1:RuleEngine/FieldDetector/ObjectsInside") @@ -201,30 +183,26 @@ async def async_parse_field_detector(uid: str, msg) -> Event | None: Topic: tns1:RuleEngine/FieldDetector/ObjectsInside """ - try: - video_source = "" - video_analytics = "" - rule = "" - topic, payload = extract_message(msg) - for source in payload.Source.SimpleItem: - if source.Name == "VideoSourceConfigurationToken": - video_source = _normalize_video_source(source.Value) - if source.Name == "VideoAnalyticsConfigurationToken": - video_analytics = source.Value - if source.Name == "Rule": - rule = source.Value + video_source = "" + video_analytics = "" + rule = "" + topic, payload = extract_message(msg) + for source in payload.Source.SimpleItem: + if source.Name == "VideoSourceConfigurationToken": + video_source = _normalize_video_source(source.Value) + if source.Name == "VideoAnalyticsConfigurationToken": + video_analytics = source.Value + if source.Name == "Rule": + rule = source.Value - evt = Event( - f"{uid}_{topic}_{video_source}_{video_analytics}_{rule}", - "Field Detection", - "binary_sensor", - "motion", - None, - payload.Data.SimpleItem[0].Value == "true", - ) - except (AttributeError, KeyError): - return None - return evt + return Event( + f"{uid}_{topic}_{video_source}_{video_analytics}_{rule}", + "Field Detection", + "binary_sensor", + "motion", + None, + payload.Data.SimpleItem[0].Value == "true", + ) @PARSERS.register("tns1:RuleEngine/CellMotionDetector/Motion") @@ -233,29 +211,26 @@ async def async_parse_cell_motion_detector(uid: str, msg) -> Event | None: Topic: tns1:RuleEngine/CellMotionDetector/Motion """ - try: - video_source = "" - video_analytics = "" - rule = "" - topic, payload = extract_message(msg) - for source in payload.Source.SimpleItem: - if source.Name == "VideoSourceConfigurationToken": - video_source = _normalize_video_source(source.Value) - if source.Name == "VideoAnalyticsConfigurationToken": - video_analytics = source.Value - if source.Name == "Rule": - rule = source.Value + video_source = "" + video_analytics = "" + rule = "" + topic, payload = extract_message(msg) + for source in payload.Source.SimpleItem: + if source.Name == "VideoSourceConfigurationToken": + video_source = _normalize_video_source(source.Value) + if source.Name == "VideoAnalyticsConfigurationToken": + video_analytics = source.Value + if source.Name == "Rule": + rule = source.Value - return Event( - f"{uid}_{topic}_{video_source}_{video_analytics}_{rule}", - "Cell Motion Detection", - "binary_sensor", - "motion", - None, - payload.Data.SimpleItem[0].Value == "true", - ) - except (AttributeError, KeyError): - return None + return Event( + f"{uid}_{topic}_{video_source}_{video_analytics}_{rule}", + "Cell Motion Detection", + "binary_sensor", + "motion", + None, + payload.Data.SimpleItem[0].Value == "true", + ) @PARSERS.register("tns1:RuleEngine/MotionRegionDetector/Motion") @@ -264,29 +239,26 @@ async def async_parse_motion_region_detector(uid: str, msg) -> Event | None: Topic: tns1:RuleEngine/MotionRegionDetector/Motion """ - try: - video_source = "" - video_analytics = "" - rule = "" - topic, payload = extract_message(msg) - for source in payload.Source.SimpleItem: - if source.Name == "VideoSourceConfigurationToken": - video_source = _normalize_video_source(source.Value) - if source.Name == "VideoAnalyticsConfigurationToken": - video_analytics = source.Value - if source.Name == "Rule": - rule = source.Value + video_source = "" + video_analytics = "" + rule = "" + topic, payload = extract_message(msg) + for source in payload.Source.SimpleItem: + if source.Name == "VideoSourceConfigurationToken": + video_source = _normalize_video_source(source.Value) + if source.Name == "VideoAnalyticsConfigurationToken": + video_analytics = source.Value + if source.Name == "Rule": + rule = source.Value - return Event( - f"{uid}_{topic}_{video_source}_{video_analytics}_{rule}", - "Motion Region Detection", - "binary_sensor", - "motion", - None, - payload.Data.SimpleItem[0].Value in ["1", "true"], - ) - except (AttributeError, KeyError): - return None + return Event( + f"{uid}_{topic}_{video_source}_{video_analytics}_{rule}", + "Motion Region Detection", + "binary_sensor", + "motion", + None, + payload.Data.SimpleItem[0].Value in ["1", "true"], + ) @PARSERS.register("tns1:RuleEngine/TamperDetector/Tamper") @@ -295,30 +267,27 @@ async def async_parse_tamper_detector(uid: str, msg) -> Event | None: Topic: tns1:RuleEngine/TamperDetector/Tamper """ - try: - video_source = "" - video_analytics = "" - rule = "" - topic, payload = extract_message(msg) - for source in payload.Source.SimpleItem: - if source.Name == "VideoSourceConfigurationToken": - video_source = _normalize_video_source(source.Value) - if source.Name == "VideoAnalyticsConfigurationToken": - video_analytics = source.Value - if source.Name == "Rule": - rule = source.Value + video_source = "" + video_analytics = "" + rule = "" + topic, payload = extract_message(msg) + for source in payload.Source.SimpleItem: + if source.Name == "VideoSourceConfigurationToken": + video_source = _normalize_video_source(source.Value) + if source.Name == "VideoAnalyticsConfigurationToken": + video_analytics = source.Value + if source.Name == "Rule": + rule = source.Value - return Event( - f"{uid}_{topic}_{video_source}_{video_analytics}_{rule}", - "Tamper Detection", - "binary_sensor", - "problem", - None, - payload.Data.SimpleItem[0].Value == "true", - EntityCategory.DIAGNOSTIC, - ) - except (AttributeError, KeyError): - return None + return Event( + f"{uid}_{topic}_{video_source}_{video_analytics}_{rule}", + "Tamper Detection", + "binary_sensor", + "problem", + None, + payload.Data.SimpleItem[0].Value == "true", + EntityCategory.DIAGNOSTIC, + ) @PARSERS.register("tns1:RuleEngine/MyRuleDetector/DogCatDetect") @@ -327,23 +296,20 @@ async def async_parse_dog_cat_detector(uid: str, msg) -> Event | None: Topic: tns1:RuleEngine/MyRuleDetector/DogCatDetect """ - try: - video_source = "" - topic, payload = extract_message(msg) - for source in payload.Source.SimpleItem: - if source.Name == "Source": - video_source = _normalize_video_source(source.Value) + video_source = "" + topic, payload = extract_message(msg) + for source in payload.Source.SimpleItem: + if source.Name == "Source": + video_source = _normalize_video_source(source.Value) - return Event( - f"{uid}_{topic}_{video_source}", - "Pet Detection", - "binary_sensor", - "motion", - None, - payload.Data.SimpleItem[0].Value == "true", - ) - except (AttributeError, KeyError): - return None + return Event( + f"{uid}_{topic}_{video_source}", + "Pet Detection", + "binary_sensor", + "motion", + None, + payload.Data.SimpleItem[0].Value == "true", + ) @PARSERS.register("tns1:RuleEngine/MyRuleDetector/VehicleDetect") @@ -352,23 +318,20 @@ async def async_parse_vehicle_detector(uid: str, msg) -> Event | None: Topic: tns1:RuleEngine/MyRuleDetector/VehicleDetect """ - try: - video_source = "" - topic, payload = extract_message(msg) - for source in payload.Source.SimpleItem: - if source.Name == "Source": - video_source = _normalize_video_source(source.Value) + video_source = "" + topic, payload = extract_message(msg) + for source in payload.Source.SimpleItem: + if source.Name == "Source": + video_source = _normalize_video_source(source.Value) - return Event( - f"{uid}_{topic}_{video_source}", - "Vehicle Detection", - "binary_sensor", - "motion", - None, - payload.Data.SimpleItem[0].Value == "true", - ) - except (AttributeError, KeyError): - return None + return Event( + f"{uid}_{topic}_{video_source}", + "Vehicle Detection", + "binary_sensor", + "motion", + None, + payload.Data.SimpleItem[0].Value == "true", + ) _TAPO_EVENT_TEMPLATES: dict[str, Event] = { @@ -420,32 +383,28 @@ async def async_parse_tplink_detector(uid: str, msg) -> Event | None: Topic: tns1:RuleEngine/PeopleDetector/People Topic: tns1:RuleEngine/TPSmartEventDetector/TPSmartEvent """ - try: - video_source = "" - video_analytics = "" - rule = "" - topic, payload = extract_message(msg) - for source in payload.Source.SimpleItem: - if source.Name == "VideoSourceConfigurationToken": - video_source = _normalize_video_source(source.Value) - if source.Name == "VideoAnalyticsConfigurationToken": - video_analytics = source.Value - if source.Name == "Rule": - rule = source.Value + video_source = "" + video_analytics = "" + rule = "" + topic, payload = extract_message(msg) + for source in payload.Source.SimpleItem: + if source.Name == "VideoSourceConfigurationToken": + video_source = _normalize_video_source(source.Value) + if source.Name == "VideoAnalyticsConfigurationToken": + video_analytics = source.Value + if source.Name == "Rule": + rule = source.Value - for item in payload.Data.SimpleItem: - event_template = _TAPO_EVENT_TEMPLATES.get(item.Name, None) - if event_template is None: - continue + for item in payload.Data.SimpleItem: + event_template = _TAPO_EVENT_TEMPLATES.get(item.Name, None) + if event_template is None: + continue - return dataclasses.replace( - event_template, - uid=f"{uid}_{topic}_{video_source}_{video_analytics}_{rule}", - value=item.Value == "true", - ) - - except (AttributeError, KeyError): - return None + return dataclasses.replace( + event_template, + uid=f"{uid}_{topic}_{video_source}_{video_analytics}_{rule}", + value=item.Value == "true", + ) return None @@ -456,23 +415,20 @@ async def async_parse_person_detector(uid: str, msg) -> Event | None: Topic: tns1:RuleEngine/MyRuleDetector/PeopleDetect """ - try: - video_source = "" - topic, payload = extract_message(msg) - for source in payload.Source.SimpleItem: - if source.Name == "Source": - video_source = _normalize_video_source(source.Value) + video_source = "" + topic, payload = extract_message(msg) + for source in payload.Source.SimpleItem: + if source.Name == "Source": + video_source = _normalize_video_source(source.Value) - return Event( - f"{uid}_{topic}_{video_source}", - "Person Detection", - "binary_sensor", - "motion", - None, - payload.Data.SimpleItem[0].Value == "true", - ) - except (AttributeError, KeyError): - return None + return Event( + f"{uid}_{topic}_{video_source}", + "Person Detection", + "binary_sensor", + "motion", + None, + payload.Data.SimpleItem[0].Value == "true", + ) @PARSERS.register("tns1:RuleEngine/MyRuleDetector/FaceDetect") @@ -481,23 +437,20 @@ async def async_parse_face_detector(uid: str, msg) -> Event | None: Topic: tns1:RuleEngine/MyRuleDetector/FaceDetect """ - try: - video_source = "" - topic, payload = extract_message(msg) - for source in payload.Source.SimpleItem: - if source.Name == "Source": - video_source = _normalize_video_source(source.Value) + video_source = "" + topic, payload = extract_message(msg) + for source in payload.Source.SimpleItem: + if source.Name == "Source": + video_source = _normalize_video_source(source.Value) - return Event( - f"{uid}_{topic}_{video_source}", - "Face Detection", - "binary_sensor", - "motion", - None, - payload.Data.SimpleItem[0].Value == "true", - ) - except (AttributeError, KeyError): - return None + return Event( + f"{uid}_{topic}_{video_source}", + "Face Detection", + "binary_sensor", + "motion", + None, + payload.Data.SimpleItem[0].Value == "true", + ) @PARSERS.register("tns1:RuleEngine/MyRuleDetector/Visitor") @@ -506,23 +459,20 @@ async def async_parse_visitor_detector(uid: str, msg) -> Event | None: Topic: tns1:RuleEngine/MyRuleDetector/Visitor """ - try: - video_source = "" - topic, payload = extract_message(msg) - for source in payload.Source.SimpleItem: - if source.Name == "Source": - video_source = _normalize_video_source(source.Value) + video_source = "" + topic, payload = extract_message(msg) + for source in payload.Source.SimpleItem: + if source.Name == "Source": + video_source = _normalize_video_source(source.Value) - return Event( - f"{uid}_{topic}_{video_source}", - "Visitor Detection", - "binary_sensor", - "occupancy", - None, - payload.Data.SimpleItem[0].Value == "true", - ) - except (AttributeError, KeyError): - return None + return Event( + f"{uid}_{topic}_{video_source}", + "Visitor Detection", + "binary_sensor", + "occupancy", + None, + payload.Data.SimpleItem[0].Value == "true", + ) @PARSERS.register("tns1:Device/Trigger/DigitalInput") @@ -531,19 +481,16 @@ async def async_parse_digital_input(uid: str, msg) -> Event | None: Topic: tns1:Device/Trigger/DigitalInput """ - try: - topic, payload = extract_message(msg) - source = payload.Source.SimpleItem[0].Value - return Event( - f"{uid}_{topic}_{source}", - "Digital Input", - "binary_sensor", - None, - None, - payload.Data.SimpleItem[0].Value == "true", - ) - except (AttributeError, KeyError): - return None + topic, payload = extract_message(msg) + source = payload.Source.SimpleItem[0].Value + return Event( + f"{uid}_{topic}_{source}", + "Digital Input", + "binary_sensor", + None, + None, + payload.Data.SimpleItem[0].Value == "true", + ) @PARSERS.register("tns1:Device/Trigger/Relay") @@ -552,19 +499,16 @@ async def async_parse_relay(uid: str, msg) -> Event | None: Topic: tns1:Device/Trigger/Relay """ - try: - topic, payload = extract_message(msg) - source = payload.Source.SimpleItem[0].Value - return Event( - f"{uid}_{topic}_{source}", - "Relay Triggered", - "binary_sensor", - None, - None, - payload.Data.SimpleItem[0].Value == "active", - ) - except (AttributeError, KeyError): - return None + topic, payload = extract_message(msg) + source = payload.Source.SimpleItem[0].Value + return Event( + f"{uid}_{topic}_{source}", + "Relay Triggered", + "binary_sensor", + None, + None, + payload.Data.SimpleItem[0].Value == "active", + ) @PARSERS.register("tns1:Device/HardwareFailure/StorageFailure") @@ -573,20 +517,17 @@ async def async_parse_storage_failure(uid: str, msg) -> Event | None: Topic: tns1:Device/HardwareFailure/StorageFailure """ - try: - topic, payload = extract_message(msg) - source = payload.Source.SimpleItem[0].Value - return Event( - f"{uid}_{topic}_{source}", - "Storage Failure", - "binary_sensor", - "problem", - None, - payload.Data.SimpleItem[0].Value == "true", - EntityCategory.DIAGNOSTIC, - ) - except (AttributeError, KeyError): - return None + topic, payload = extract_message(msg) + source = payload.Source.SimpleItem[0].Value + return Event( + f"{uid}_{topic}_{source}", + "Storage Failure", + "binary_sensor", + "problem", + None, + payload.Data.SimpleItem[0].Value == "true", + EntityCategory.DIAGNOSTIC, + ) @PARSERS.register("tns1:Monitoring/ProcessorUsage") @@ -595,23 +536,20 @@ async def async_parse_processor_usage(uid: str, msg) -> Event | None: Topic: tns1:Monitoring/ProcessorUsage """ - try: - topic, payload = extract_message(msg) - usage = float(payload.Data.SimpleItem[0].Value) - if usage <= 1: - usage *= 100 + topic, payload = extract_message(msg) + usage = float(payload.Data.SimpleItem[0].Value) + if usage <= 1: + usage *= 100 - return Event( - f"{uid}_{topic}", - "Processor Usage", - "sensor", - None, - "percent", - int(usage), - EntityCategory.DIAGNOSTIC, - ) - except (AttributeError, KeyError): - return None + return Event( + f"{uid}_{topic}", + "Processor Usage", + "sensor", + None, + "percent", + int(usage), + EntityCategory.DIAGNOSTIC, + ) @PARSERS.register("tns1:Monitoring/OperatingTime/LastReboot") @@ -620,20 +558,17 @@ async def async_parse_last_reboot(uid: str, msg) -> Event | None: Topic: tns1:Monitoring/OperatingTime/LastReboot """ - try: - topic, payload = extract_message(msg) - date_time = local_datetime_or_none(payload.Data.SimpleItem[0].Value) - return Event( - f"{uid}_{topic}", - "Last Reboot", - "sensor", - "timestamp", - None, - date_time, - EntityCategory.DIAGNOSTIC, - ) - except (AttributeError, KeyError): - return None + topic, payload = extract_message(msg) + date_time = local_datetime_or_none(payload.Data.SimpleItem[0].Value) + return Event( + f"{uid}_{topic}", + "Last Reboot", + "sensor", + "timestamp", + None, + date_time, + EntityCategory.DIAGNOSTIC, + ) @PARSERS.register("tns1:Monitoring/OperatingTime/LastReset") @@ -642,21 +577,18 @@ async def async_parse_last_reset(uid: str, msg) -> Event | None: Topic: tns1:Monitoring/OperatingTime/LastReset """ - try: - topic, payload = extract_message(msg) - date_time = local_datetime_or_none(payload.Data.SimpleItem[0].Value) - return Event( - f"{uid}_{topic}", - "Last Reset", - "sensor", - "timestamp", - None, - date_time, - EntityCategory.DIAGNOSTIC, - entity_enabled=False, - ) - except (AttributeError, KeyError): - return None + topic, payload = extract_message(msg) + date_time = local_datetime_or_none(payload.Data.SimpleItem[0].Value) + return Event( + f"{uid}_{topic}", + "Last Reset", + "sensor", + "timestamp", + None, + date_time, + EntityCategory.DIAGNOSTIC, + entity_enabled=False, + ) @PARSERS.register("tns1:Monitoring/Backup/Last") @@ -665,22 +597,18 @@ async def async_parse_backup_last(uid: str, msg) -> Event | None: Topic: tns1:Monitoring/Backup/Last """ - - try: - topic, payload = extract_message(msg) - date_time = local_datetime_or_none(payload.Data.SimpleItem[0].Value) - return Event( - f"{uid}_{topic}", - "Last Backup", - "sensor", - "timestamp", - None, - date_time, - EntityCategory.DIAGNOSTIC, - entity_enabled=False, - ) - except (AttributeError, KeyError): - return None + topic, payload = extract_message(msg) + date_time = local_datetime_or_none(payload.Data.SimpleItem[0].Value) + return Event( + f"{uid}_{topic}", + "Last Backup", + "sensor", + "timestamp", + None, + date_time, + EntityCategory.DIAGNOSTIC, + entity_enabled=False, + ) @PARSERS.register("tns1:Monitoring/OperatingTime/LastClockSynchronization") @@ -689,21 +617,18 @@ async def async_parse_last_clock_sync(uid: str, msg) -> Event | None: Topic: tns1:Monitoring/OperatingTime/LastClockSynchronization """ - try: - topic, payload = extract_message(msg) - date_time = local_datetime_or_none(payload.Data.SimpleItem[0].Value) - return Event( - f"{uid}_{topic}", - "Last Clock Synchronization", - "sensor", - "timestamp", - None, - date_time, - EntityCategory.DIAGNOSTIC, - entity_enabled=False, - ) - except (AttributeError, KeyError): - return None + topic, payload = extract_message(msg) + date_time = local_datetime_or_none(payload.Data.SimpleItem[0].Value) + return Event( + f"{uid}_{topic}", + "Last Clock Synchronization", + "sensor", + "timestamp", + None, + date_time, + EntityCategory.DIAGNOSTIC, + entity_enabled=False, + ) @PARSERS.register("tns1:RecordingConfig/JobState") @@ -713,20 +638,17 @@ async def async_parse_jobstate(uid: str, msg) -> Event | None: Topic: tns1:RecordingConfig/JobState """ - try: - topic, payload = extract_message(msg) - source = payload.Source.SimpleItem[0].Value - return Event( - f"{uid}_{topic}_{source}", - "Recording Job State", - "binary_sensor", - None, - None, - payload.Data.SimpleItem[0].Value == "Active", - EntityCategory.DIAGNOSTIC, - ) - except (AttributeError, KeyError): - return None + topic, payload = extract_message(msg) + source = payload.Source.SimpleItem[0].Value + return Event( + f"{uid}_{topic}_{source}", + "Recording Job State", + "binary_sensor", + None, + None, + payload.Data.SimpleItem[0].Value == "Active", + EntityCategory.DIAGNOSTIC, + ) @PARSERS.register("tns1:RuleEngine/LineDetector/Crossed") @@ -735,30 +657,27 @@ async def async_parse_linedetector_crossed(uid: str, msg) -> Event | None: Topic: tns1:RuleEngine/LineDetector/Crossed """ - try: - video_source = "" - video_analytics = "" - rule = "" - topic, payload = extract_message(msg) - for source in payload.Source.SimpleItem: - if source.Name == "VideoSourceConfigurationToken": - video_source = source.Value - if source.Name == "VideoAnalyticsConfigurationToken": - video_analytics = source.Value - if source.Name == "Rule": - rule = source.Value + video_source = "" + video_analytics = "" + rule = "" + topic, payload = extract_message(msg) + for source in payload.Source.SimpleItem: + if source.Name == "VideoSourceConfigurationToken": + video_source = source.Value + if source.Name == "VideoAnalyticsConfigurationToken": + video_analytics = source.Value + if source.Name == "Rule": + rule = source.Value - return Event( - f"{uid}_{topic}_{video_source}_{video_analytics}_{rule}", - "Line Detector Crossed", - "sensor", - None, - None, - payload.Data.SimpleItem[0].Value, - EntityCategory.DIAGNOSTIC, - ) - except (AttributeError, KeyError): - return None + return Event( + f"{uid}_{topic}_{video_source}_{video_analytics}_{rule}", + "Line Detector Crossed", + "sensor", + None, + None, + payload.Data.SimpleItem[0].Value, + EntityCategory.DIAGNOSTIC, + ) @PARSERS.register("tns1:RuleEngine/CountAggregation/Counter") @@ -767,30 +686,27 @@ async def async_parse_count_aggregation_counter(uid: str, msg) -> Event | None: Topic: tns1:RuleEngine/CountAggregation/Counter """ - try: - video_source = "" - video_analytics = "" - rule = "" - topic, payload = extract_message(msg) - for source in payload.Source.SimpleItem: - if source.Name == "VideoSourceConfigurationToken": - video_source = _normalize_video_source(source.Value) - if source.Name == "VideoAnalyticsConfigurationToken": - video_analytics = source.Value - if source.Name == "Rule": - rule = source.Value + video_source = "" + video_analytics = "" + rule = "" + topic, payload = extract_message(msg) + for source in payload.Source.SimpleItem: + if source.Name == "VideoSourceConfigurationToken": + video_source = _normalize_video_source(source.Value) + if source.Name == "VideoAnalyticsConfigurationToken": + video_analytics = source.Value + if source.Name == "Rule": + rule = source.Value - return Event( - f"{uid}_{topic}_{video_source}_{video_analytics}_{rule}", - "Count Aggregation Counter", - "sensor", - None, - None, - payload.Data.SimpleItem[0].Value, - EntityCategory.DIAGNOSTIC, - ) - except (AttributeError, KeyError): - return None + return Event( + f"{uid}_{topic}_{video_source}_{video_analytics}_{rule}", + "Count Aggregation Counter", + "sensor", + None, + None, + payload.Data.SimpleItem[0].Value, + EntityCategory.DIAGNOSTIC, + ) @PARSERS.register("tns1:UserAlarm/IVA/HumanShapeDetect") @@ -799,21 +715,18 @@ async def async_parse_human_shape_detect(uid: str, msg) -> Event | None: Topic: tns1:UserAlarm/IVA/HumanShapeDetect """ - try: - topic, payload = extract_message(msg) - video_source = "" - for source in payload.Source.SimpleItem: - if source.Name == "VideoSourceConfigurationToken": - video_source = _normalize_video_source(source.Value) - break + topic, payload = extract_message(msg) + video_source = "" + for source in payload.Source.SimpleItem: + if source.Name == "VideoSourceConfigurationToken": + video_source = _normalize_video_source(source.Value) + break - return Event( - f"{uid}_{topic}_{video_source}", - "Human Shape Detect", - "binary_sensor", - "motion", - None, - payload.Data.SimpleItem[0].Value == "true", - ) - except (AttributeError, KeyError): - return None + return Event( + f"{uid}_{topic}_{video_source}", + "Human Shape Detect", + "binary_sensor", + "motion", + None, + payload.Data.SimpleItem[0].Value == "true", + ) diff --git a/tests/components/onvif/test_parsers.py b/tests/components/onvif/test_parsers.py index 4f7e10abae6..70b78fea971 100644 --- a/tests/components/onvif/test_parsers.py +++ b/tests/components/onvif/test_parsers.py @@ -5,6 +5,7 @@ import os import onvif import onvif.settings +import pytest from zeep import Client from zeep.transports import Transport @@ -732,25 +733,24 @@ async def test_tapo_intrusion(hass: HomeAssistant) -> None: async def test_tapo_missing_attributes(hass: HomeAssistant) -> None: """Tests async_parse_tplink_detector with missing fields.""" - event = await get_event( - { - "Message": { - "_value_1": { - "Data": { - "ElementItem": [], - "Extension": None, - "SimpleItem": [{"Name": "IsPeople", "Value": "true"}], - "_attr_1": None, - }, - } - }, - "Topic": { - "_value_1": "tns1:RuleEngine/PeopleDetector/People", - }, - } - ) - - assert event is None + with pytest.raises(AttributeError, match="SimpleItem"): + await get_event( + { + "Message": { + "_value_1": { + "Data": { + "ElementItem": [], + "Extension": None, + "SimpleItem": [{"Name": "IsPeople", "Value": "true"}], + "_attr_1": None, + }, + } + }, + "Topic": { + "_value_1": "tns1:RuleEngine/PeopleDetector/People", + }, + } + ) async def test_tapo_unknown_type(hass: HomeAssistant) -> None: From fa836118b2e59aa70a1078ffe2d44a447dee3f1f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 14 Mar 2025 15:24:55 -1000 Subject: [PATCH 2629/3148] Bump bluetooth-data-tools to 1.26.1 (#140635) changelog: https://github.com/Bluetooth-Devices/bluetooth-data-tools/compare/v1.26.0...v1.26.1 --- homeassistant/components/bluetooth/manifest.json | 2 +- homeassistant/components/ld2410_ble/manifest.json | 2 +- homeassistant/components/led_ble/manifest.json | 2 +- homeassistant/components/private_ble_device/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index 3430787958e..eed21dcc0c8 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -19,7 +19,7 @@ "bleak-retry-connector==3.9.0", "bluetooth-adapters==0.21.4", "bluetooth-auto-recovery==1.4.5", - "bluetooth-data-tools==1.26.0", + "bluetooth-data-tools==1.26.1", "dbus-fast==2.39.5", "habluetooth==3.27.0" ] diff --git a/homeassistant/components/ld2410_ble/manifest.json b/homeassistant/components/ld2410_ble/manifest.json index f0d06a4e880..1896f2109a7 100644 --- a/homeassistant/components/ld2410_ble/manifest.json +++ b/homeassistant/components/ld2410_ble/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/ld2410_ble", "integration_type": "device", "iot_class": "local_push", - "requirements": ["bluetooth-data-tools==1.26.0", "ld2410-ble==0.1.1"] + "requirements": ["bluetooth-data-tools==1.26.1", "ld2410-ble==0.1.1"] } diff --git a/homeassistant/components/led_ble/manifest.json b/homeassistant/components/led_ble/manifest.json index 5e12c395c2c..270495c8770 100644 --- a/homeassistant/components/led_ble/manifest.json +++ b/homeassistant/components/led_ble/manifest.json @@ -35,5 +35,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/led_ble", "iot_class": "local_polling", - "requirements": ["bluetooth-data-tools==1.26.0", "led-ble==1.1.6"] + "requirements": ["bluetooth-data-tools==1.26.1", "led-ble==1.1.6"] } diff --git a/homeassistant/components/private_ble_device/manifest.json b/homeassistant/components/private_ble_device/manifest.json index d79b93388f5..810fce41e05 100644 --- a/homeassistant/components/private_ble_device/manifest.json +++ b/homeassistant/components/private_ble_device/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/private_ble_device", "iot_class": "local_push", - "requirements": ["bluetooth-data-tools==1.26.0"] + "requirements": ["bluetooth-data-tools==1.26.1"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 8f9a9670fee..ef50d88c44a 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -23,7 +23,7 @@ bleak-retry-connector==3.9.0 bleak==0.22.3 bluetooth-adapters==0.21.4 bluetooth-auto-recovery==1.4.5 -bluetooth-data-tools==1.26.0 +bluetooth-data-tools==1.26.1 cached-ipaddress==0.10.0 certifi>=2021.5.30 ciso8601==2.3.2 diff --git a/requirements_all.txt b/requirements_all.txt index f02cdb56fc9..4ed89f94334 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -640,7 +640,7 @@ bluetooth-auto-recovery==1.4.5 # homeassistant.components.ld2410_ble # homeassistant.components.led_ble # homeassistant.components.private_ble_device -bluetooth-data-tools==1.26.0 +bluetooth-data-tools==1.26.1 # homeassistant.components.bond bond-async==0.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 929437bb7bf..3322b42a3b1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -564,7 +564,7 @@ bluetooth-auto-recovery==1.4.5 # homeassistant.components.ld2410_ble # homeassistant.components.led_ble # homeassistant.components.private_ble_device -bluetooth-data-tools==1.26.0 +bluetooth-data-tools==1.26.1 # homeassistant.components.bond bond-async==0.2.1 From c54a2e733872104c6eeaf2948a14a8d26ef2cee8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 14 Mar 2025 15:27:51 -1000 Subject: [PATCH 2630/3148] Bump nexia to 2.4.0 (#140634) changelog: https://github.com/bdraco/nexia/compare/2.2.2...2.4.0 --- homeassistant/components/nexia/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/nexia/manifest.json b/homeassistant/components/nexia/manifest.json index 09b79d37c55..e7ab63d4712 100644 --- a/homeassistant/components/nexia/manifest.json +++ b/homeassistant/components/nexia/manifest.json @@ -12,5 +12,5 @@ "documentation": "https://www.home-assistant.io/integrations/nexia", "iot_class": "cloud_polling", "loggers": ["nexia"], - "requirements": ["nexia==2.2.2"] + "requirements": ["nexia==2.4.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 4ed89f94334..68a07bc732c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1486,7 +1486,7 @@ nettigo-air-monitor==4.1.0 neurio==0.3.1 # homeassistant.components.nexia -nexia==2.2.2 +nexia==2.4.0 # homeassistant.components.nextcloud nextcloudmonitor==1.5.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3322b42a3b1..e3ce76ad507 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1246,7 +1246,7 @@ netmap==0.7.0.2 nettigo-air-monitor==4.1.0 # homeassistant.components.nexia -nexia==2.2.2 +nexia==2.4.0 # homeassistant.components.nextcloud nextcloudmonitor==1.5.1 From ed2ef04b984aceec094f6eec26be5bf6263350e9 Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Fri, 14 Mar 2025 21:48:47 -0400 Subject: [PATCH 2631/3148] Bump Python-Snoo to 0.6.3 (#140628) Bump python-Snoo to 0.6.3 --- homeassistant/components/snoo/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/snoo/manifest.json b/homeassistant/components/snoo/manifest.json index c9306e58413..0de1e6cf760 100644 --- a/homeassistant/components/snoo/manifest.json +++ b/homeassistant/components/snoo/manifest.json @@ -7,5 +7,5 @@ "iot_class": "cloud_push", "loggers": ["snoo"], "quality_scale": "bronze", - "requirements": ["python-snoo==0.6.1"] + "requirements": ["python-snoo==0.6.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index 68a07bc732c..250d6597718 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2470,7 +2470,7 @@ python-roborock==2.12.2 python-smarttub==0.0.39 # homeassistant.components.snoo -python-snoo==0.6.1 +python-snoo==0.6.3 # homeassistant.components.songpal python-songpal==0.16.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e3ce76ad507..c4c6463d48a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2000,7 +2000,7 @@ python-roborock==2.12.2 python-smarttub==0.0.39 # homeassistant.components.snoo -python-snoo==0.6.1 +python-snoo==0.6.3 # homeassistant.components.songpal python-songpal==0.16.2 From baafcf48dcd9c1e5bd8ebc8a9f96e1e05c90eecc Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Fri, 14 Mar 2025 22:06:09 -0400 Subject: [PATCH 2632/3148] Separate Roborock entities to a new dock device (#140612) * Seperate entities to a new dock device * update entity names * Update homeassistant/components/roborock/coordinator.py --------- Co-authored-by: Paulus Schoutsen --- .../components/roborock/binary_sensor.py | 4 ++++ .../components/roborock/coordinator.py | 17 +++++++++++++++++ homeassistant/components/roborock/entity.py | 5 ++++- homeassistant/components/roborock/select.py | 4 ++++ homeassistant/components/roborock/sensor.py | 6 ++++++ homeassistant/components/roborock/switch.py | 12 +++++++++++- tests/components/roborock/test_sensor.py | 2 +- tests/components/roborock/test_switch.py | 8 ++++---- 8 files changed, 51 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/roborock/binary_sensor.py b/homeassistant/components/roborock/binary_sensor.py index f2b1564c7b5..95640812b11 100644 --- a/homeassistant/components/roborock/binary_sensor.py +++ b/homeassistant/components/roborock/binary_sensor.py @@ -26,6 +26,8 @@ class RoborockBinarySensorDescription(BinarySensorEntityDescription): """A class that describes Roborock binary sensors.""" value_fn: Callable[[DeviceProp], bool | int | None] + # If it is a dock entity + is_dock_entity: bool = False BINARY_SENSOR_DESCRIPTIONS = [ @@ -35,6 +37,7 @@ BINARY_SENSOR_DESCRIPTIONS = [ device_class=BinarySensorDeviceClass.RUNNING, entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda data: data.status.dry_status, + is_dock_entity=True, ), RoborockBinarySensorDescription( key="water_box_carriage_status", @@ -105,6 +108,7 @@ class RoborockBinarySensorEntity(RoborockCoordinatedEntityV1, BinarySensorEntity super().__init__( f"{description.key}_{coordinator.duid_slug}", coordinator, + is_dock_entity=description.is_dock_entity, ) self.entity_description = description diff --git a/homeassistant/components/roborock/coordinator.py b/homeassistant/components/roborock/coordinator.py index c94fb785079..bf06387b377 100644 --- a/homeassistant/components/roborock/coordinator.py +++ b/homeassistant/components/roborock/coordinator.py @@ -128,6 +128,23 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): self._api_client = api_client self._is_cloud_api = False + @cached_property + def dock_device_info(self) -> DeviceInfo: + """Gets the device info for the dock. + + This must happen after the coordinator does the first update. + Which will be the case when this is called. + """ + dock_type = self.roborock_device_info.props.status.dock_type + return DeviceInfo( + name=f"{self.roborock_device_info.device.name} Dock", + identifiers={(DOMAIN, f"{self.duid}_dock")}, + manufacturer="Roborock", + model=f"{self.roborock_device_info.product.model} Dock", + model_id=str(dock_type.value) if dock_type is not None else "Unknown", + sw_version=self.roborock_device_info.device.fv, + ) + async def _async_setup(self) -> None: """Set up the coordinator.""" # Verify we can communicate locally - if we can't, switch to cloud api diff --git a/homeassistant/components/roborock/entity.py b/homeassistant/components/roborock/entity.py index d417ac17159..404f239c93a 100644 --- a/homeassistant/components/roborock/entity.py +++ b/homeassistant/components/roborock/entity.py @@ -121,12 +121,15 @@ class RoborockCoordinatedEntityV1( listener_request: list[RoborockDataProtocol] | RoborockDataProtocol | None = None, + is_dock_entity: bool = False, ) -> None: """Initialize the coordinated Roborock Device.""" RoborockEntityV1.__init__( self, unique_id=unique_id, - device_info=coordinator.device_info, + device_info=coordinator.device_info + if not is_dock_entity + else coordinator.dock_device_info, api=coordinator.api, ) CoordinatorEntity.__init__(self, coordinator=coordinator) diff --git a/homeassistant/components/roborock/select.py b/homeassistant/components/roborock/select.py index c22a4deed3b..42245c458eb 100644 --- a/homeassistant/components/roborock/select.py +++ b/homeassistant/components/roborock/select.py @@ -32,6 +32,8 @@ class RoborockSelectDescription(SelectEntityDescription): parameter_lambda: Callable[[str, DeviceProp], list[int]] protocol_listener: RoborockDataProtocol | None = None + # If it is a dock entity + is_dock_entity: bool = False SELECT_DESCRIPTIONS: list[RoborockSelectDescription] = [ @@ -70,6 +72,7 @@ SELECT_DESCRIPTIONS: list[RoborockSelectDescription] = [ parameter_lambda=lambda key, _: [ RoborockDockDustCollectionModeCode.as_dict().get(key) ], + is_dock_entity=True, ), ] @@ -117,6 +120,7 @@ class RoborockSelectEntity(RoborockCoordinatedEntityV1, SelectEntity): f"{entity_description.key}_{coordinator.duid_slug}", coordinator, entity_description.protocol_listener, + is_dock_entity=entity_description.is_dock_entity, ) self._attr_options = options diff --git a/homeassistant/components/roborock/sensor.py b/homeassistant/components/roborock/sensor.py index f95dc5fa98f..7b019acb39b 100644 --- a/homeassistant/components/roborock/sensor.py +++ b/homeassistant/components/roborock/sensor.py @@ -47,6 +47,9 @@ class RoborockSensorDescription(SensorEntityDescription): protocol_listener: RoborockDataProtocol | None = None + # If it is a dock entity + is_dock_entity: bool = False + @dataclass(frozen=True, kw_only=True) class RoborockSensorDescriptionA01(SensorEntityDescription): @@ -197,6 +200,7 @@ SENSOR_DESCRIPTIONS = [ entity_category=EntityCategory.DIAGNOSTIC, device_class=SensorDeviceClass.ENUM, options=RoborockDockErrorCode.keys(), + is_dock_entity=True, ), RoborockSensorDescription( key="mop_clean_remaining", @@ -205,6 +209,7 @@ SENSOR_DESCRIPTIONS = [ value_fn=lambda data: data.status.rdt, translation_key="mop_drying_remaining_time", entity_category=EntityCategory.DIAGNOSTIC, + is_dock_entity=True, ), ] @@ -335,6 +340,7 @@ class RoborockSensorEntity(RoborockCoordinatedEntityV1, SensorEntity): f"{description.key}_{coordinator.duid_slug}", coordinator, description.protocol_listener, + is_dock_entity=description.is_dock_entity, ) @property diff --git a/homeassistant/components/roborock/switch.py b/homeassistant/components/roborock/switch.py index 0171d59abfd..636066c1ed5 100644 --- a/homeassistant/components/roborock/switch.py +++ b/homeassistant/components/roborock/switch.py @@ -35,6 +35,8 @@ class RoborockSwitchDescription(SwitchEntityDescription): update_value: Callable[[AttributeCache, bool], Coroutine[Any, Any, None]] # Attribute from cache attribute: str + # If it is a dock entity + is_dock_entity: bool = False SWITCH_DESCRIPTIONS: list[RoborockSwitchDescription] = [ @@ -47,6 +49,7 @@ SWITCH_DESCRIPTIONS: list[RoborockSwitchDescription] = [ key="child_lock", translation_key="child_lock", entity_category=EntityCategory.CONFIG, + is_dock_entity=True, ), RoborockSwitchDescription( cache_key=CacheableAttribute.flow_led_status, @@ -57,6 +60,7 @@ SWITCH_DESCRIPTIONS: list[RoborockSwitchDescription] = [ key="status_indicator", translation_key="status_indicator", entity_category=EntityCategory.CONFIG, + is_dock_entity=True, ), RoborockSwitchDescription( cache_key=CacheableAttribute.dnd_timer, @@ -147,7 +151,13 @@ class RoborockSwitch(RoborockEntityV1, SwitchEntity): ) -> None: """Initialize the entity.""" self.entity_description = entity_description - super().__init__(unique_id, coordinator.device_info, coordinator.api) + super().__init__( + unique_id, + coordinator.device_info + if not entity_description.is_dock_entity + else coordinator.dock_device_info, + coordinator.api, + ) async def async_turn_off(self, **kwargs: Any) -> None: """Turn off the switch.""" diff --git a/tests/components/roborock/test_sensor.py b/tests/components/roborock/test_sensor.py index e33d3aa78d5..4925c5da219 100644 --- a/tests/components/roborock/test_sensor.py +++ b/tests/components/roborock/test_sensor.py @@ -53,7 +53,7 @@ async def test_sensors(hass: HomeAssistant, setup_entry: MockConfigEntry) -> Non assert hass.states.get("sensor.roborock_s7_maxv_cleaning_area").state == "21.0" assert hass.states.get("sensor.roborock_s7_maxv_vacuum_error").state == "none" assert hass.states.get("sensor.roborock_s7_maxv_battery").state == "100" - assert hass.states.get("sensor.roborock_s7_maxv_dock_error").state == "ok" + assert hass.states.get("sensor.roborock_s7_maxv_dock_dock_error").state == "ok" assert hass.states.get("sensor.roborock_s7_maxv_total_cleaning_count").state == "31" assert ( hass.states.get("sensor.roborock_s7_maxv_last_clean_begin").state diff --git a/tests/components/roborock/test_switch.py b/tests/components/roborock/test_switch.py index e2df9a3498f..120c4fc4860 100644 --- a/tests/components/roborock/test_switch.py +++ b/tests/components/roborock/test_switch.py @@ -22,8 +22,8 @@ def platforms() -> list[Platform]: @pytest.mark.parametrize( ("entity_id"), [ - ("switch.roborock_s7_maxv_child_lock"), - ("switch.roborock_s7_maxv_status_indicator_light"), + ("switch.roborock_s7_maxv_dock_child_lock"), + ("switch.roborock_s7_maxv_dock_status_indicator_light"), ("switch.roborock_s7_maxv_do_not_disturb"), ], ) @@ -59,8 +59,8 @@ async def test_update_success( @pytest.mark.parametrize( ("entity_id", "service"), [ - ("switch.roborock_s7_maxv_status_indicator_light", SERVICE_TURN_ON), - ("switch.roborock_s7_maxv_status_indicator_light", SERVICE_TURN_OFF), + ("switch.roborock_s7_maxv_dock_status_indicator_light", SERVICE_TURN_ON), + ("switch.roborock_s7_maxv_dock_status_indicator_light", SERVICE_TURN_OFF), ], ) @pytest.mark.parametrize( From 07e7672b78f3f399e4a23fb4087b60fa50388c44 Mon Sep 17 00:00:00 2001 From: Denis Shulyaka Date: Sat, 15 Mar 2025 05:07:59 +0300 Subject: [PATCH 2633/3148] Anthropic conversation extended thinking support (#139662) * Anthropic conversation extended thinking support * update conversation snapshots * Add conversation test * Update openai_conversation snapshots * Removed metadata * Removed metadata * Removed thinking * cosmetic fix * combine user messages * Apply suggestions from code review * Add tests for chat_log messages conversion * s/THINKING_BUDGET_TOKENS/THINKING_BUDGET/ * Apply suggestions from code review * Update tests * Update homeassistant/components/anthropic/strings.json Co-authored-by: Paulus Schoutsen * apply suggestions from code review --------- Co-authored-by: Robert Resch Co-authored-by: Paulus Schoutsen --- .../components/anthropic/config_flow.py | 31 +- homeassistant/components/anthropic/const.py | 5 + .../components/anthropic/conversation.py | 296 ++++++++++----- .../components/anthropic/strings.json | 9 +- tests/components/anthropic/conftest.py | 16 + .../snapshots/test_conversation.ambr | 317 ++++++++++++++++ .../components/anthropic/test_config_flow.py | 26 ++ .../components/anthropic/test_conversation.py | 344 +++++++++++++++++- 8 files changed, 940 insertions(+), 104 deletions(-) diff --git a/homeassistant/components/anthropic/config_flow.py b/homeassistant/components/anthropic/config_flow.py index 5f1f4fdeea7..e53a479d7d4 100644 --- a/homeassistant/components/anthropic/config_flow.py +++ b/homeassistant/components/anthropic/config_flow.py @@ -34,10 +34,12 @@ from .const import ( CONF_PROMPT, CONF_RECOMMENDED, CONF_TEMPERATURE, + CONF_THINKING_BUDGET, DOMAIN, RECOMMENDED_CHAT_MODEL, RECOMMENDED_MAX_TOKENS, RECOMMENDED_TEMPERATURE, + RECOMMENDED_THINKING_BUDGET, ) _LOGGER = logging.getLogger(__name__) @@ -128,21 +130,29 @@ class AnthropicOptionsFlow(OptionsFlow): ) -> ConfigFlowResult: """Manage the options.""" options: dict[str, Any] | MappingProxyType[str, Any] = self.config_entry.options + errors: dict[str, str] = {} if user_input is not None: if user_input[CONF_RECOMMENDED] == self.last_rendered_recommended: if user_input[CONF_LLM_HASS_API] == "none": user_input.pop(CONF_LLM_HASS_API) - return self.async_create_entry(title="", data=user_input) - # Re-render the options again, now with the recommended options shown/hidden - self.last_rendered_recommended = user_input[CONF_RECOMMENDED] + if user_input.get( + CONF_THINKING_BUDGET, RECOMMENDED_THINKING_BUDGET + ) >= user_input.get(CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS): + errors[CONF_THINKING_BUDGET] = "thinking_budget_too_large" - options = { - CONF_RECOMMENDED: user_input[CONF_RECOMMENDED], - CONF_PROMPT: user_input[CONF_PROMPT], - CONF_LLM_HASS_API: user_input[CONF_LLM_HASS_API], - } + if not errors: + return self.async_create_entry(title="", data=user_input) + else: + # Re-render the options again, now with the recommended options shown/hidden + self.last_rendered_recommended = user_input[CONF_RECOMMENDED] + + options = { + CONF_RECOMMENDED: user_input[CONF_RECOMMENDED], + CONF_PROMPT: user_input[CONF_PROMPT], + CONF_LLM_HASS_API: user_input[CONF_LLM_HASS_API], + } suggested_values = options.copy() if not suggested_values.get(CONF_PROMPT): @@ -156,6 +166,7 @@ class AnthropicOptionsFlow(OptionsFlow): return self.async_show_form( step_id="init", data_schema=schema, + errors=errors or None, ) @@ -205,6 +216,10 @@ def anthropic_config_option_schema( CONF_TEMPERATURE, default=RECOMMENDED_TEMPERATURE, ): NumberSelector(NumberSelectorConfig(min=0, max=1, step=0.05)), + vol.Optional( + CONF_THINKING_BUDGET, + default=RECOMMENDED_THINKING_BUDGET, + ): int, } ) return schema diff --git a/homeassistant/components/anthropic/const.py b/homeassistant/components/anthropic/const.py index 0dbf9c51ac1..38e4270e6e1 100644 --- a/homeassistant/components/anthropic/const.py +++ b/homeassistant/components/anthropic/const.py @@ -13,3 +13,8 @@ CONF_MAX_TOKENS = "max_tokens" RECOMMENDED_MAX_TOKENS = 1024 CONF_TEMPERATURE = "temperature" RECOMMENDED_TEMPERATURE = 1.0 +CONF_THINKING_BUDGET = "thinking_budget" +RECOMMENDED_THINKING_BUDGET = 0 +MIN_THINKING_BUDGET = 1024 + +THINKING_MODELS = ["claude-3-7-sonnet-20250219", "claude-3-7-sonnet-latest"] diff --git a/homeassistant/components/anthropic/conversation.py b/homeassistant/components/anthropic/conversation.py index ff403e61a91..5e5ad464eaa 100644 --- a/homeassistant/components/anthropic/conversation.py +++ b/homeassistant/components/anthropic/conversation.py @@ -1,23 +1,32 @@ """Conversation support for Anthropic.""" -from collections.abc import AsyncGenerator, Callable +from collections.abc import AsyncGenerator, Callable, Iterable import json -from typing import Any, Literal +from typing import Any, Literal, cast import anthropic from anthropic import AsyncStream from anthropic._types import NOT_GIVEN from anthropic.types import ( InputJSONDelta, - Message, MessageParam, MessageStreamEvent, RawContentBlockDeltaEvent, RawContentBlockStartEvent, RawContentBlockStopEvent, + RawMessageStartEvent, + RawMessageStopEvent, + RedactedThinkingBlock, + RedactedThinkingBlockParam, + SignatureDelta, TextBlock, TextBlockParam, TextDelta, + ThinkingBlock, + ThinkingBlockParam, + ThinkingConfigDisabledParam, + ThinkingConfigEnabledParam, + ThinkingDelta, ToolParam, ToolResultBlockParam, ToolUseBlock, @@ -39,11 +48,15 @@ from .const import ( CONF_MAX_TOKENS, CONF_PROMPT, CONF_TEMPERATURE, + CONF_THINKING_BUDGET, DOMAIN, LOGGER, + MIN_THINKING_BUDGET, RECOMMENDED_CHAT_MODEL, RECOMMENDED_MAX_TOKENS, RECOMMENDED_TEMPERATURE, + RECOMMENDED_THINKING_BUDGET, + THINKING_MODELS, ) # Max number of back and forth with the LLM to generate a response @@ -71,73 +84,101 @@ def _format_tool( ) -def _message_convert( - message: Message, -) -> MessageParam: - """Convert from class to TypedDict.""" - param_content: list[TextBlockParam | ToolUseBlockParam] = [] +def _convert_content( + chat_content: Iterable[conversation.Content], +) -> list[MessageParam]: + """Transform HA chat_log content into Anthropic API format.""" + messages: list[MessageParam] = [] - for message_content in message.content: - if isinstance(message_content, TextBlock): - param_content.append(TextBlockParam(type="text", text=message_content.text)) - elif isinstance(message_content, ToolUseBlock): - param_content.append( - ToolUseBlockParam( - type="tool_use", - id=message_content.id, - name=message_content.name, - input=message_content.input, - ) + for content in chat_content: + if isinstance(content, conversation.ToolResultContent): + tool_result_block = ToolResultBlockParam( + type="tool_result", + tool_use_id=content.tool_call_id, + content=json.dumps(content.tool_result), ) - - return MessageParam(role=message.role, content=param_content) - - -def _convert_content(chat_content: conversation.Content) -> MessageParam: - """Create tool response content.""" - if isinstance(chat_content, conversation.ToolResultContent): - return MessageParam( - role="user", - content=[ - ToolResultBlockParam( - type="tool_result", - tool_use_id=chat_content.tool_call_id, - content=json.dumps(chat_content.tool_result), - ) - ], - ) - if isinstance(chat_content, conversation.AssistantContent): - return MessageParam( - role="assistant", - content=[ - TextBlockParam(type="text", text=chat_content.content or ""), - *[ - ToolUseBlockParam( - type="tool_use", - id=tool_call.id, - name=tool_call.tool_name, - input=tool_call.tool_args, + if not messages or messages[-1]["role"] != "user": + messages.append( + MessageParam( + role="user", + content=[tool_result_block], ) - for tool_call in chat_content.tool_calls or () - ], - ], - ) - if isinstance(chat_content, conversation.UserContent): - return MessageParam( - role="user", - content=chat_content.content, - ) - # Note: We don't pass SystemContent here as its passed to the API as the prompt - raise ValueError(f"Unexpected content type: {type(chat_content)}") + ) + elif isinstance(messages[-1]["content"], str): + messages[-1]["content"] = [ + TextBlockParam(type="text", text=messages[-1]["content"]), + tool_result_block, + ] + else: + messages[-1]["content"].append(tool_result_block) # type: ignore[attr-defined] + elif isinstance(content, conversation.UserContent): + # Combine consequent user messages + if not messages or messages[-1]["role"] != "user": + messages.append( + MessageParam( + role="user", + content=content.content, + ) + ) + elif isinstance(messages[-1]["content"], str): + messages[-1]["content"] = [ + TextBlockParam(type="text", text=messages[-1]["content"]), + TextBlockParam(type="text", text=content.content), + ] + else: + messages[-1]["content"].append( # type: ignore[attr-defined] + TextBlockParam(type="text", text=content.content) + ) + elif isinstance(content, conversation.AssistantContent): + # Combine consequent assistant messages + if not messages or messages[-1]["role"] != "assistant": + messages.append( + MessageParam( + role="assistant", + content=[], + ) + ) + + if content.content: + messages[-1]["content"].append( # type: ignore[union-attr] + TextBlockParam(type="text", text=content.content) + ) + if content.tool_calls: + messages[-1]["content"].extend( # type: ignore[union-attr] + [ + ToolUseBlockParam( + type="tool_use", + id=tool_call.id, + name=tool_call.tool_name, + input=tool_call.tool_args, + ) + for tool_call in content.tool_calls + ] + ) + else: + # Note: We don't pass SystemContent here as its passed to the API as the prompt + raise TypeError(f"Unexpected content type: {type(content)}") + + return messages async def _transform_stream( result: AsyncStream[MessageStreamEvent], + messages: list[MessageParam], ) -> AsyncGenerator[conversation.AssistantContentDeltaDict]: """Transform the response stream into HA format. A typical stream of responses might look something like the following: - RawMessageStartEvent with no content + - RawContentBlockStartEvent with an empty ThinkingBlock (if extended thinking is enabled) + - RawContentBlockDeltaEvent with a ThinkingDelta + - RawContentBlockDeltaEvent with a ThinkingDelta + - RawContentBlockDeltaEvent with a ThinkingDelta + - ... + - RawContentBlockDeltaEvent with a SignatureDelta + - RawContentBlockStopEvent + - RawContentBlockStartEvent with a RedactedThinkingBlock (occasionally) + - RawContentBlockStopEvent (RedactedThinkingBlock does not have a delta) - RawContentBlockStartEvent with an empty TextBlock - RawContentBlockDeltaEvent with a TextDelta - RawContentBlockDeltaEvent with a TextDelta @@ -151,44 +192,103 @@ async def _transform_stream( - RawContentBlockStopEvent - RawMessageDeltaEvent with a stop_reason='tool_use' - RawMessageStopEvent(type='message_stop') + + Each message could contain multiple blocks of the same type. """ if result is None: raise TypeError("Expected a stream of messages") - current_tool_call: dict | None = None + current_message: MessageParam | None = None + current_block: ( + TextBlockParam + | ToolUseBlockParam + | ThinkingBlockParam + | RedactedThinkingBlockParam + | None + ) = None + current_tool_args: str async for response in result: LOGGER.debug("Received response: %s", response) - if isinstance(response, RawContentBlockStartEvent): + if isinstance(response, RawMessageStartEvent): + if response.message.role != "assistant": + raise ValueError("Unexpected message role") + current_message = MessageParam(role=response.message.role, content=[]) + elif isinstance(response, RawContentBlockStartEvent): if isinstance(response.content_block, ToolUseBlock): - current_tool_call = { - "id": response.content_block.id, - "name": response.content_block.name, - "input": "", - } + current_block = ToolUseBlockParam( + type="tool_use", + id=response.content_block.id, + name=response.content_block.name, + input="", + ) + current_tool_args = "" elif isinstance(response.content_block, TextBlock): + current_block = TextBlockParam( + type="text", text=response.content_block.text + ) yield {"role": "assistant"} + if response.content_block.text: + yield {"content": response.content_block.text} + elif isinstance(response.content_block, ThinkingBlock): + current_block = ThinkingBlockParam( + type="thinking", + thinking=response.content_block.thinking, + signature=response.content_block.signature, + ) + elif isinstance(response.content_block, RedactedThinkingBlock): + current_block = RedactedThinkingBlockParam( + type="redacted_thinking", data=response.content_block.data + ) + LOGGER.debug( + "Some of Claude’s internal reasoning has been automatically " + "encrypted for safety reasons. This doesn’t affect the quality of " + "responses" + ) elif isinstance(response, RawContentBlockDeltaEvent): + if current_block is None: + raise ValueError("Unexpected delta without a block") if isinstance(response.delta, InputJSONDelta): - if current_tool_call is None: - raise ValueError("Unexpected delta without a tool call") - current_tool_call["input"] += response.delta.partial_json + current_tool_args += response.delta.partial_json elif isinstance(response.delta, TextDelta): - LOGGER.debug("yielding delta: %s", response.delta.text) + text_block = cast(TextBlockParam, current_block) + text_block["text"] += response.delta.text yield {"content": response.delta.text} + elif isinstance(response.delta, ThinkingDelta): + thinking_block = cast(ThinkingBlockParam, current_block) + thinking_block["thinking"] += response.delta.thinking + elif isinstance(response.delta, SignatureDelta): + thinking_block = cast(ThinkingBlockParam, current_block) + thinking_block["signature"] += response.delta.signature elif isinstance(response, RawContentBlockStopEvent): - if current_tool_call: + if current_block is None: + raise ValueError("Unexpected stop event without a current block") + if current_block["type"] == "tool_use": + tool_block = cast(ToolUseBlockParam, current_block) + tool_args = json.loads(current_tool_args) + tool_block["input"] = tool_args yield { "tool_calls": [ llm.ToolInput( - id=current_tool_call["id"], - tool_name=current_tool_call["name"], - tool_args=json.loads(current_tool_call["input"]), + id=tool_block["id"], + tool_name=tool_block["name"], + tool_args=tool_args, ) ] } - current_tool_call = None + elif current_block["type"] == "thinking": + thinking_block = cast(ThinkingBlockParam, current_block) + LOGGER.debug("Thinking: %s", thinking_block["thinking"]) + + if current_message is None: + raise ValueError("Unexpected stop event without a current message") + current_message["content"].append(current_block) # type: ignore[union-attr] + current_block = None + elif isinstance(response, RawMessageStopEvent): + if current_message is not None: + messages.append(current_message) + current_message = None class AnthropicConversationEntity( @@ -254,34 +354,50 @@ class AnthropicConversationEntity( system = chat_log.content[0] if not isinstance(system, conversation.SystemContent): raise TypeError("First message must be a system message") - messages = [_convert_content(content) for content in chat_log.content[1:]] + messages = _convert_content(chat_log.content[1:]) client = self.entry.runtime_data + thinking_budget = options.get(CONF_THINKING_BUDGET, RECOMMENDED_THINKING_BUDGET) + model = options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL) + # To prevent infinite loops, we limit the number of iterations for _iteration in range(MAX_TOOL_ITERATIONS): - try: - stream = await client.messages.create( - model=options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL), - messages=messages, - tools=tools or NOT_GIVEN, - max_tokens=options.get(CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS), - system=system.content, - temperature=options.get(CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE), - stream=True, + model_args = { + "model": model, + "messages": messages, + "tools": tools or NOT_GIVEN, + "max_tokens": options.get(CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS), + "system": system.content, + "stream": True, + } + if model in THINKING_MODELS and thinking_budget >= MIN_THINKING_BUDGET: + model_args["thinking"] = ThinkingConfigEnabledParam( + type="enabled", budget_tokens=thinking_budget ) + else: + model_args["thinking"] = ThinkingConfigDisabledParam(type="disabled") + model_args["temperature"] = options.get( + CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE + ) + + try: + stream = await client.messages.create(**model_args) except anthropic.AnthropicError as err: raise HomeAssistantError( f"Sorry, I had a problem talking to Anthropic: {err}" ) from err messages.extend( - [ - _convert_content(content) - async for content in chat_log.async_add_delta_content_stream( - user_input.agent_id, _transform_stream(stream) - ) - ] + _convert_content( + [ + content + async for content in chat_log.async_add_delta_content_stream( + user_input.agent_id, _transform_stream(stream, messages) + ) + if not isinstance(content, conversation.AssistantContent) + ] + ) ) if not chat_log.unresponded_tool_results: diff --git a/homeassistant/components/anthropic/strings.json b/homeassistant/components/anthropic/strings.json index 9550a1a6672..c2caf3a6666 100644 --- a/homeassistant/components/anthropic/strings.json +++ b/homeassistant/components/anthropic/strings.json @@ -23,12 +23,17 @@ "max_tokens": "Maximum tokens to return in response", "temperature": "Temperature", "llm_hass_api": "[%key:common::config_flow::data::llm_hass_api%]", - "recommended": "Recommended model settings" + "recommended": "Recommended model settings", + "thinking_budget_tokens": "Thinking budget" }, "data_description": { - "prompt": "Instruct how the LLM should respond. This can be a template." + "prompt": "Instruct how the LLM should respond. This can be a template.", + "thinking_budget_tokens": "The number of tokens the model can use to think about the response out of the total maximum number of tokens. Set to 1024 or greater to enable extended thinking." } } + }, + "error": { + "thinking_budget_too_large": "Maximum tokens must be greater than the thinking budget." } } } diff --git a/tests/components/anthropic/conftest.py b/tests/components/anthropic/conftest.py index f8ab098cc09..7419ea6c28f 100644 --- a/tests/components/anthropic/conftest.py +++ b/tests/components/anthropic/conftest.py @@ -5,6 +5,7 @@ from unittest.mock import patch import pytest +from homeassistant.components.anthropic import CONF_CHAT_MODEL from homeassistant.const import CONF_LLM_HASS_API from homeassistant.core import HomeAssistant from homeassistant.helpers import llm @@ -38,6 +39,21 @@ def mock_config_entry_with_assist( return mock_config_entry +@pytest.fixture +def mock_config_entry_with_extended_thinking( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> MockConfigEntry: + """Mock a config entry with assist.""" + hass.config_entries.async_update_entry( + mock_config_entry, + options={ + CONF_LLM_HASS_API: llm.LLM_API_ASSIST, + CONF_CHAT_MODEL: "claude-3-7-sonnet-latest", + }, + ) + return mock_config_entry + + @pytest.fixture async def mock_init_component( hass: HomeAssistant, mock_config_entry: MockConfigEntry diff --git a/tests/components/anthropic/snapshots/test_conversation.ambr b/tests/components/anthropic/snapshots/test_conversation.ambr index de414019317..c0ed986f002 100644 --- a/tests/components/anthropic/snapshots/test_conversation.ambr +++ b/tests/components/anthropic/snapshots/test_conversation.ambr @@ -1,4 +1,321 @@ # serializer version: 1 +# name: test_extended_thinking_tool_call + list([ + dict({ + 'content': ''' + Current time is 16:00:00. Today's date is 2024-06-03. + You are a voice assistant for Home Assistant. + Answer questions about the world truthfully. + Answer in plain text. Keep it simple and to the point. + Only if the user wants to control a device, tell them to expose entities to their voice assistant in Home Assistant. + ''', + 'role': 'system', + }), + dict({ + 'content': 'Please call the test function', + 'role': 'user', + }), + dict({ + 'agent_id': 'conversation.claude', + 'content': 'Certainly, calling it now!', + 'role': 'assistant', + 'tool_calls': list([ + dict({ + 'id': 'toolu_0123456789AbCdEfGhIjKlM', + 'tool_args': dict({ + 'param1': 'test_value', + }), + 'tool_name': 'test_tool', + }), + ]), + }), + dict({ + 'agent_id': 'conversation.claude', + 'role': 'tool_result', + 'tool_call_id': 'toolu_0123456789AbCdEfGhIjKlM', + 'tool_name': 'test_tool', + 'tool_result': 'Test response', + }), + dict({ + 'agent_id': 'conversation.claude', + 'content': 'I have successfully called the function', + 'role': 'assistant', + 'tool_calls': None, + }), + ]) +# --- +# name: test_extended_thinking_tool_call.1 + list([ + dict({ + 'content': 'Please call the test function', + 'role': 'user', + }), + dict({ + 'content': list([ + dict({ + 'signature': 'ErUBCkYIARgCIkCYXaVNJShe3A86Hp7XUzh9YsCYBbJTbQsrklTAPtJ2sP/NoB6tSzpK/nTL6CjSo2R6n0KNBIg5MH6asM2R/kmaEgyB/X1FtZq5OQAC7jUaDEPWCdcwGQ4RaBy5wiIwmRxExIlDhoY6tILoVPnOExkC/0igZxHEwxK8RU/fmw0b+o+TwAarzUitwzbo21E5Kh3pa3I6yqVROf1t2F8rFocNUeCegsWV/ytwYV+ayA==', + 'thinking': 'The user asked me to call a test function.Is it a test? What would the function do? Would it violate any privacy or security policies?', + 'type': 'thinking', + }), + dict({ + 'data': 'EroBCkYIARgCKkBJDytPJhw//4vy3t7aE+LfIkxvkAh51cBPrAvBCo6AjgI57Zt9KWPnUVV50OQJ0KZzUFoGZG5sxg95zx4qMwkoEgz43Su3myJKckvj03waDBZLIBSeoAeRUeVsJCIwQ5edQN0sa+HNeB/KUBkoMUwV+IT0eIhcpFxnILdvxUAKM4R1o4KG3x+yO0eo/kyOKiKfrCPFQhvBVmTZPFhgA2Ow8L9gGDVipcz6x3Uu9YETGEny', + 'type': 'redacted_thinking', + }), + dict({ + 'signature': 'ErUBCkYIARgCIkCYXaVNJShe3A86Hp7XUzh9YsCYBbJTbQsrklTAPtJ2sP/NoB6tSzpK/nTL6CjSo2R6n0KNBIg5MH6asM2R/kmaEgyB/X1FtZq5OQAC7jUaDEPWCdcwGQ4RaBy5wiIwmRxExIlDhoY6tILoVPnOExkC/0igZxHEwxK8RU/fmw0b+o+TwAarzUitwzbo21E5Kh3pa3I6yqVROf1t2F8rFocNUeCegsWV/ytwYV+ayA==', + 'thinking': "Okay, let's give it a shot. Will I pass the test?", + 'type': 'thinking', + }), + dict({ + 'text': 'Certainly, calling it now!', + 'type': 'text', + }), + dict({ + 'id': 'toolu_0123456789AbCdEfGhIjKlM', + 'input': dict({ + 'param1': 'test_value', + }), + 'name': 'test_tool', + 'type': 'tool_use', + }), + ]), + 'role': 'assistant', + }), + dict({ + 'content': list([ + dict({ + 'content': '"Test response"', + 'tool_use_id': 'toolu_0123456789AbCdEfGhIjKlM', + 'type': 'tool_result', + }), + ]), + 'role': 'user', + }), + dict({ + 'content': list([ + dict({ + 'text': 'I have successfully called the function', + 'type': 'text', + }), + ]), + 'role': 'assistant', + }), + ]) +# --- +# name: test_history_conversion[content0] + list([ + dict({ + 'content': 'Are you sure?', + 'role': 'user', + }), + dict({ + 'content': list([ + dict({ + 'text': 'Yes, I am sure!', + 'type': 'text', + }), + ]), + 'role': 'assistant', + }), + ]) +# --- +# name: test_history_conversion[content1] + list([ + dict({ + 'content': 'What shape is a donut?', + 'role': 'user', + }), + dict({ + 'content': list([ + dict({ + 'text': 'A donut is a torus.', + 'type': 'text', + }), + ]), + 'role': 'assistant', + }), + dict({ + 'content': 'Are you sure?', + 'role': 'user', + }), + dict({ + 'content': list([ + dict({ + 'text': 'Yes, I am sure!', + 'type': 'text', + }), + ]), + 'role': 'assistant', + }), + ]) +# --- +# name: test_history_conversion[content2] + list([ + dict({ + 'content': list([ + dict({ + 'text': 'What shape is a donut?', + 'type': 'text', + }), + dict({ + 'text': 'Can you tell me?', + 'type': 'text', + }), + ]), + 'role': 'user', + }), + dict({ + 'content': list([ + dict({ + 'text': 'A donut is a torus.', + 'type': 'text', + }), + dict({ + 'text': 'Hope this helps.', + 'type': 'text', + }), + ]), + 'role': 'assistant', + }), + dict({ + 'content': 'Are you sure?', + 'role': 'user', + }), + dict({ + 'content': list([ + dict({ + 'text': 'Yes, I am sure!', + 'type': 'text', + }), + ]), + 'role': 'assistant', + }), + ]) +# --- +# name: test_history_conversion[content3] + list([ + dict({ + 'content': list([ + dict({ + 'text': 'What shape is a donut?', + 'type': 'text', + }), + dict({ + 'text': 'Can you tell me?', + 'type': 'text', + }), + dict({ + 'text': 'Please?', + 'type': 'text', + }), + ]), + 'role': 'user', + }), + dict({ + 'content': list([ + dict({ + 'text': 'A donut is a torus.', + 'type': 'text', + }), + dict({ + 'text': 'Hope this helps.', + 'type': 'text', + }), + dict({ + 'text': 'You are welcome.', + 'type': 'text', + }), + ]), + 'role': 'assistant', + }), + dict({ + 'content': 'Are you sure?', + 'role': 'user', + }), + dict({ + 'content': list([ + dict({ + 'text': 'Yes, I am sure!', + 'type': 'text', + }), + ]), + 'role': 'assistant', + }), + ]) +# --- +# name: test_history_conversion[content4] + list([ + dict({ + 'content': 'Turn off the lights and make me coffee', + 'role': 'user', + }), + dict({ + 'content': list([ + dict({ + 'text': 'Sure.', + 'type': 'text', + }), + dict({ + 'id': 'mock-tool-call-id', + 'input': dict({ + 'domain': 'light', + }), + 'name': 'HassTurnOff', + 'type': 'tool_use', + }), + dict({ + 'id': 'mock-tool-call-id-2', + 'input': dict({ + }), + 'name': 'MakeCoffee', + 'type': 'tool_use', + }), + ]), + 'role': 'assistant', + }), + dict({ + 'content': list([ + dict({ + 'text': 'Thank you', + 'type': 'text', + }), + dict({ + 'content': '{"success": true, "response": "Lights are off."}', + 'tool_use_id': 'mock-tool-call-id', + 'type': 'tool_result', + }), + dict({ + 'content': '{"success": false, "response": "Not enough milk."}', + 'tool_use_id': 'mock-tool-call-id-2', + 'type': 'tool_result', + }), + ]), + 'role': 'user', + }), + dict({ + 'content': list([ + dict({ + 'text': 'Should I add milk to the shopping list?', + 'type': 'text', + }), + ]), + 'role': 'assistant', + }), + dict({ + 'content': 'Are you sure?', + 'role': 'user', + }), + dict({ + 'content': list([ + dict({ + 'text': 'Yes, I am sure!', + 'type': 'text', + }), + ]), + 'role': 'assistant', + }), + ]) +# --- # name: test_unknown_hass_api dict({ 'continue_conversation': False, diff --git a/tests/components/anthropic/test_config_flow.py b/tests/components/anthropic/test_config_flow.py index 5973d9a3ee8..30aba6e1b1f 100644 --- a/tests/components/anthropic/test_config_flow.py +++ b/tests/components/anthropic/test_config_flow.py @@ -21,9 +21,11 @@ from homeassistant.components.anthropic.const import ( CONF_PROMPT, CONF_RECOMMENDED, CONF_TEMPERATURE, + CONF_THINKING_BUDGET, DOMAIN, RECOMMENDED_CHAT_MODEL, RECOMMENDED_MAX_TOKENS, + RECOMMENDED_THINKING_BUDGET, ) from homeassistant.const import CONF_LLM_HASS_API from homeassistant.core import HomeAssistant @@ -94,6 +96,28 @@ async def test_options( assert options["data"][CONF_CHAT_MODEL] == RECOMMENDED_CHAT_MODEL +async def test_options_thinking_budget_more_than_max( + hass: HomeAssistant, mock_config_entry, mock_init_component +) -> None: + """Test error about thinking budget being more than max tokens.""" + options_flow = await hass.config_entries.options.async_init( + mock_config_entry.entry_id + ) + options = await hass.config_entries.options.async_configure( + options_flow["flow_id"], + { + "prompt": "Speak like a pirate", + "max_tokens": 8192, + "chat_model": "claude-3-7-sonnet-latest", + "temperature": 1, + "thinking_budget": 16384, + }, + ) + await hass.async_block_till_done() + assert options["type"] is FlowResultType.FORM + assert options["errors"] == {"thinking_budget": "thinking_budget_too_large"} + + @pytest.mark.parametrize( ("side_effect", "error"), [ @@ -186,6 +210,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non CONF_TEMPERATURE: 0.3, CONF_CHAT_MODEL: RECOMMENDED_CHAT_MODEL, CONF_MAX_TOKENS: RECOMMENDED_MAX_TOKENS, + CONF_THINKING_BUDGET: RECOMMENDED_THINKING_BUDGET, }, ), ( @@ -195,6 +220,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non CONF_TEMPERATURE: 0.3, CONF_CHAT_MODEL: RECOMMENDED_CHAT_MODEL, CONF_MAX_TOKENS: RECOMMENDED_MAX_TOKENS, + CONF_THINKING_BUDGET: RECOMMENDED_THINKING_BUDGET, }, { CONF_RECOMMENDED: True, diff --git a/tests/components/anthropic/test_conversation.py b/tests/components/anthropic/test_conversation.py index 6c8244a59ba..67a4434a664 100644 --- a/tests/components/anthropic/test_conversation.py +++ b/tests/components/anthropic/test_conversation.py @@ -14,13 +14,18 @@ from anthropic.types import ( RawMessageStartEvent, RawMessageStopEvent, RawMessageStreamEvent, + RedactedThinkingBlock, + SignatureDelta, TextBlock, TextDelta, + ThinkingBlock, + ThinkingDelta, ToolUseBlock, Usage, ) from freezegun import freeze_time from httpx import URL, Request, Response +import pytest from syrupy.assertion import SnapshotAssertion import voluptuous as vol @@ -28,7 +33,7 @@ from homeassistant.components import conversation from homeassistant.const import CONF_LLM_HASS_API from homeassistant.core import Context, HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import intent, llm +from homeassistant.helpers import chat_session, intent, llm from homeassistant.setup import async_setup_component from homeassistant.util import ulid as ulid_util @@ -86,6 +91,57 @@ def create_content_block( ] +def create_thinking_block( + index: int, thinking_parts: list[str] +) -> list[RawMessageStreamEvent]: + """Create a thinking block with the specified deltas.""" + return [ + RawContentBlockStartEvent( + type="content_block_start", + content_block=ThinkingBlock(signature="", thinking="", type="thinking"), + index=index, + ), + *[ + RawContentBlockDeltaEvent( + delta=ThinkingDelta(thinking=thinking_part, type="thinking_delta"), + index=index, + type="content_block_delta", + ) + for thinking_part in thinking_parts + ], + RawContentBlockDeltaEvent( + delta=SignatureDelta( + signature="ErUBCkYIARgCIkCYXaVNJShe3A86Hp7XUzh9YsCYBbJTbQsrklTAPtJ2sP/N" + "oB6tSzpK/nTL6CjSo2R6n0KNBIg5MH6asM2R/kmaEgyB/X1FtZq5OQAC7jUaDEPWCdcwGQ" + "4RaBy5wiIwmRxExIlDhoY6tILoVPnOExkC/0igZxHEwxK8RU/fmw0b+o+TwAarzUitwzbo" + "21E5Kh3pa3I6yqVROf1t2F8rFocNUeCegsWV/ytwYV+ayA==", + type="signature_delta", + ), + index=index, + type="content_block_delta", + ), + RawContentBlockStopEvent(index=index, type="content_block_stop"), + ] + + +def create_redacted_thinking_block(index: int) -> list[RawMessageStreamEvent]: + """Create a redacted thinking block.""" + return [ + RawContentBlockStartEvent( + type="content_block_start", + content_block=RedactedThinkingBlock( + data="EroBCkYIARgCKkBJDytPJhw//4vy3t7aE+LfIkxvkAh51cBPrAvBCo6AjgI57Zt9K" + "WPnUVV50OQJ0KZzUFoGZG5sxg95zx4qMwkoEgz43Su3myJKckvj03waDBZLIBSeoAeRUeV" + "sJCIwQ5edQN0sa+HNeB/KUBkoMUwV+IT0eIhcpFxnILdvxUAKM4R1o4KG3x+yO0eo/kyOK" + "iKfrCPFQhvBVmTZPFhgA2Ow8L9gGDVipcz6x3Uu9YETGEny", + type="redacted_thinking", + ), + index=index, + ), + RawContentBlockStopEvent(index=index, type="content_block_stop"), + ] + + def create_tool_use_block( index: int, tool_id: str, tool_name: str, json_parts: list[str] ) -> list[RawMessageStreamEvent]: @@ -381,7 +437,7 @@ async def test_function_exception( return stream_generator( create_messages( [ - *create_content_block(0, "Certainly, calling it now!"), + *create_content_block(0, ["Certainly, calling it now!"]), *create_tool_use_block( 1, "toolu_0123456789AbCdEfGhIjKlM", @@ -464,7 +520,7 @@ async def test_assist_api_tools_conversion( new_callable=AsyncMock, return_value=stream_generator( create_messages( - create_content_block(0, "Hello, how can I help you?"), + create_content_block(0, ["Hello, how can I help you?"]), ), ), ) as mock_create: @@ -509,7 +565,7 @@ async def test_conversation_id( def create_stream_generator(*args, **kwargs) -> Any: return stream_generator( create_messages( - create_content_block(0, "Hello, how can I help you?"), + create_content_block(0, ["Hello, how can I help you?"]), ), ) @@ -547,3 +603,283 @@ async def test_conversation_id( ) assert result.conversation_id == "koala" + + +async def test_extended_thinking( + hass: HomeAssistant, + mock_config_entry_with_extended_thinking: MockConfigEntry, + mock_init_component, +) -> None: + """Test extended thinking support.""" + with patch( + "anthropic.resources.messages.AsyncMessages.create", + new_callable=AsyncMock, + return_value=stream_generator( + create_messages( + [ + *create_thinking_block( + 0, + [ + "The user has just", + ' greeted me with "Hi".', + " This is a simple greeting an", + "d doesn't require any Home Assistant function", + " calls. I should respond with", + " a friendly greeting and let them know I'm available", + " to help with their smart home.", + ], + ), + *create_content_block(1, ["Hello, how can I help you today?"]), + ] + ), + ), + ): + result = await conversation.async_converse( + hass, "hello", None, Context(), agent_id="conversation.claude" + ) + + chat_log = hass.data.get(conversation.chat_log.DATA_CHAT_LOGS).get( + result.conversation_id + ) + assert len(chat_log.content) == 3 + assert chat_log.content[1].content == "hello" + assert chat_log.content[2].content == "Hello, how can I help you today?" + + +async def test_redacted_thinking( + hass: HomeAssistant, + mock_config_entry_with_extended_thinking: MockConfigEntry, + mock_init_component, +) -> None: + """Test extended thinking with redacted thinking blocks.""" + with patch( + "anthropic.resources.messages.AsyncMessages.create", + new_callable=AsyncMock, + return_value=stream_generator( + create_messages( + [ + *create_redacted_thinking_block(0), + *create_redacted_thinking_block(1), + *create_redacted_thinking_block(2), + *create_content_block(3, ["How can I help you today?"]), + ] + ), + ), + ): + result = await conversation.async_converse( + hass, + "ANTHROPIC_MAGIC_STRING_TRIGGER_REDACTED_THINKING_46C9A13E193C177646C7398A9" + "8432ECCCE4C1253D5E2D82641AC0E52CC2876CB", + None, + Context(), + agent_id="conversation.claude", + ) + + chat_log = hass.data.get(conversation.chat_log.DATA_CHAT_LOGS).get( + result.conversation_id + ) + assert len(chat_log.content) == 3 + assert chat_log.content[2].content == "How can I help you today?" + + +@patch("homeassistant.components.anthropic.conversation.llm.AssistAPI._async_get_tools") +async def test_extended_thinking_tool_call( + mock_get_tools, + hass: HomeAssistant, + mock_config_entry_with_extended_thinking: MockConfigEntry, + mock_init_component, + snapshot: SnapshotAssertion, +) -> None: + """Test that thinking blocks and their order are preserved in with tool calls.""" + agent_id = "conversation.claude" + context = Context() + + mock_tool = AsyncMock() + mock_tool.name = "test_tool" + mock_tool.description = "Test function" + mock_tool.parameters = vol.Schema( + {vol.Optional("param1", description="Test parameters"): str} + ) + mock_tool.async_call.return_value = "Test response" + + mock_get_tools.return_value = [mock_tool] + + def completion_result(*args, messages, **kwargs): + for message in messages: + for content in message["content"]: + if not isinstance(content, str) and content["type"] == "tool_use": + return stream_generator( + create_messages( + create_content_block( + 0, ["I have ", "successfully called ", "the function"] + ), + ) + ) + + return stream_generator( + create_messages( + [ + *create_thinking_block( + 0, + [ + "The user asked me to", + " call a test function.", + "Is it a test? What", + " would the function", + " do? Would it violate", + " any privacy or security", + " policies?", + ], + ), + *create_redacted_thinking_block(1), + *create_thinking_block( + 2, ["Okay, let's give it a shot.", " Will I pass the test?"] + ), + *create_content_block(3, ["Certainly, calling it now!"]), + *create_tool_use_block( + 1, + "toolu_0123456789AbCdEfGhIjKlM", + "test_tool", + ['{"para', 'm1": "test_valu', 'e"}'], + ), + ] + ) + ) + + with ( + patch( + "anthropic.resources.messages.AsyncMessages.create", + new_callable=AsyncMock, + side_effect=completion_result, + ) as mock_create, + freeze_time("2024-06-03 23:00:00"), + ): + result = await conversation.async_converse( + hass, + "Please call the test function", + None, + context, + agent_id=agent_id, + ) + + chat_log = hass.data.get(conversation.chat_log.DATA_CHAT_LOGS).get( + result.conversation_id + ) + + assert chat_log.content == snapshot + assert mock_create.mock_calls[1][2]["messages"] == snapshot + + +@pytest.mark.parametrize( + "content", + [ + [ + conversation.chat_log.SystemContent("You are a helpful assistant."), + ], + [ + conversation.chat_log.SystemContent("You are a helpful assistant."), + conversation.chat_log.UserContent("What shape is a donut?"), + conversation.chat_log.AssistantContent( + agent_id="conversation.claude", content="A donut is a torus." + ), + ], + [ + conversation.chat_log.SystemContent("You are a helpful assistant."), + conversation.chat_log.UserContent("What shape is a donut?"), + conversation.chat_log.UserContent("Can you tell me?"), + conversation.chat_log.AssistantContent( + agent_id="conversation.claude", content="A donut is a torus." + ), + conversation.chat_log.AssistantContent( + agent_id="conversation.claude", content="Hope this helps." + ), + ], + [ + conversation.chat_log.SystemContent("You are a helpful assistant."), + conversation.chat_log.UserContent("What shape is a donut?"), + conversation.chat_log.UserContent("Can you tell me?"), + conversation.chat_log.UserContent("Please?"), + conversation.chat_log.AssistantContent( + agent_id="conversation.claude", content="A donut is a torus." + ), + conversation.chat_log.AssistantContent( + agent_id="conversation.claude", content="Hope this helps." + ), + conversation.chat_log.AssistantContent( + agent_id="conversation.claude", content="You are welcome." + ), + ], + [ + conversation.chat_log.SystemContent("You are a helpful assistant."), + conversation.chat_log.UserContent("Turn off the lights and make me coffee"), + conversation.chat_log.AssistantContent( + agent_id="conversation.claude", + content="Sure.", + tool_calls=[ + llm.ToolInput( + id="mock-tool-call-id", + tool_name="HassTurnOff", + tool_args={"domain": "light"}, + ), + llm.ToolInput( + id="mock-tool-call-id-2", + tool_name="MakeCoffee", + tool_args={}, + ), + ], + ), + conversation.chat_log.UserContent("Thank you"), + conversation.chat_log.ToolResultContent( + agent_id="conversation.claude", + tool_call_id="mock-tool-call-id", + tool_name="HassTurnOff", + tool_result={"success": True, "response": "Lights are off."}, + ), + conversation.chat_log.ToolResultContent( + agent_id="conversation.claude", + tool_call_id="mock-tool-call-id-2", + tool_name="MakeCoffee", + tool_result={"success": False, "response": "Not enough milk."}, + ), + conversation.chat_log.AssistantContent( + agent_id="conversation.claude", + content="Should I add milk to the shopping list?", + ), + ], + ], +) +async def test_history_conversion( + hass: HomeAssistant, + mock_config_entry_with_assist: MockConfigEntry, + mock_init_component, + snapshot: SnapshotAssertion, + content: list[conversation.chat_log.Content], +) -> None: + """Test conversion of chat_log entries into API parameters.""" + conversation_id = "conversation_id" + with ( + chat_session.async_get_chat_session(hass, conversation_id) as session, + conversation.async_get_chat_log(hass, session) as chat_log, + patch( + "anthropic.resources.messages.AsyncMessages.create", + new_callable=AsyncMock, + return_value=stream_generator( + create_messages( + [ + *create_content_block(0, ["Yes, I am sure!"]), + ] + ), + ), + ) as mock_create, + ): + chat_log.content = content + + await conversation.async_converse( + hass, + "Are you sure?", + conversation_id, + Context(), + agent_id="conversation.claude", + ) + + assert mock_create.mock_calls[0][2]["messages"] == snapshot From 5dc1a321dd8623efc681ff9781a6c93e54c76276 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 14 Mar 2025 16:14:09 -1000 Subject: [PATCH 2634/3148] Rework cover reproduce_state to consider supported features (#140558) * Handle open/closed state in reproduce_state for tilt only covers fixes #137144 * cleanups * cleanups * cleanups * cleanups * cleanups * cleanups * cleanups * cleanups * cleanups * cleanups * cleanups * cleanups * rework * rework * rework * rework * more coverage * more coverage * more coverage * more coverage * more coverage * more coverage * more coverage * more coverage * more coverage * more coverage * more coverage * back compat * back compat * back compat * cleanups * cleanups * cleanups * cleanups * comments * comments --- .../components/cover/reproduce_state.py | 256 ++++++++--- .../components/cover/test_reproduce_state.py | 407 ++++++++++++++++-- 2 files changed, 570 insertions(+), 93 deletions(-) diff --git a/homeassistant/components/cover/reproduce_state.py b/homeassistant/components/cover/reproduce_state.py index 307fe5f11bd..de3e0cebfb7 100644 --- a/homeassistant/components/cover/reproduce_state.py +++ b/homeassistant/components/cover/reproduce_state.py @@ -3,12 +3,14 @@ from __future__ import annotations import asyncio -from collections.abc import Iterable +from collections.abc import Coroutine, Iterable +from functools import partial import logging -from typing import Any +from typing import Any, Final from homeassistant.const import ( ATTR_ENTITY_ID, + ATTR_SUPPORTED_FEATURES, SERVICE_CLOSE_COVER, SERVICE_CLOSE_COVER_TILT, SERVICE_OPEN_COVER, @@ -16,7 +18,8 @@ from homeassistant.const import ( SERVICE_SET_COVER_POSITION, SERVICE_SET_COVER_TILT_POSITION, ) -from homeassistant.core import Context, HomeAssistant, State +from homeassistant.core import Context, HomeAssistant, ServiceResponse, State +from homeassistant.util.enum import try_parse_enum from . import ( ATTR_CURRENT_POSITION, @@ -24,17 +27,140 @@ from . import ( ATTR_POSITION, ATTR_TILT_POSITION, DOMAIN, + CoverEntityFeature, CoverState, ) _LOGGER = logging.getLogger(__name__) -VALID_STATES = { - CoverState.CLOSED, - CoverState.CLOSING, - CoverState.OPEN, - CoverState.OPENING, -} + +OPENING_STATES = {CoverState.OPENING, CoverState.OPEN} +CLOSING_STATES = {CoverState.CLOSING, CoverState.CLOSED} +VALID_STATES: set[CoverState] = OPENING_STATES | CLOSING_STATES + +FULL_OPEN: Final = 100 +FULL_CLOSE: Final = 0 + + +def _determine_features(current_attrs: dict[str, Any]) -> CoverEntityFeature: + """Determine supported features based on current attributes.""" + features = CoverEntityFeature(0) + if ATTR_CURRENT_POSITION in current_attrs: + features |= ( + CoverEntityFeature.SET_POSITION + | CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE + ) + if ATTR_CURRENT_TILT_POSITION in current_attrs: + features |= ( + CoverEntityFeature.SET_TILT_POSITION + | CoverEntityFeature.OPEN_TILT + | CoverEntityFeature.CLOSE_TILT + ) + if features == CoverEntityFeature(0): + features = CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE + return features + + +async def _async_set_position( + service_call: partial[Coroutine[Any, Any, ServiceResponse]], + service_data: dict[str, Any], + features: CoverEntityFeature, + target_position: int, +) -> bool: + """Set the position of the cover. + + Returns True if the position was set, False if there is no + supported method for setting the position. + """ + if target_position == FULL_CLOSE and CoverEntityFeature.CLOSE in features: + await service_call(SERVICE_CLOSE_COVER, service_data) + elif target_position == FULL_OPEN and CoverEntityFeature.OPEN in features: + await service_call(SERVICE_OPEN_COVER, service_data) + elif CoverEntityFeature.SET_POSITION in features: + await service_call( + SERVICE_SET_COVER_POSITION, service_data | {ATTR_POSITION: target_position} + ) + else: + # Requested a position but the cover doesn't support it + return False + return True + + +async def _async_set_tilt_position( + service_call: partial[Coroutine[Any, Any, ServiceResponse]], + service_data: dict[str, Any], + features: CoverEntityFeature, + target_tilt_position: int, +) -> bool: + """Set the tilt position of the cover. + + Returns True if the tilt position was set, False if there is no + supported method for setting the tilt position. + """ + if target_tilt_position == FULL_CLOSE and CoverEntityFeature.CLOSE_TILT in features: + await service_call(SERVICE_CLOSE_COVER_TILT, service_data) + elif target_tilt_position == FULL_OPEN and CoverEntityFeature.OPEN_TILT in features: + await service_call(SERVICE_OPEN_COVER_TILT, service_data) + elif CoverEntityFeature.SET_TILT_POSITION in features: + await service_call( + SERVICE_SET_COVER_TILT_POSITION, + service_data | {ATTR_TILT_POSITION: target_tilt_position}, + ) + else: + # Requested a tilt position but the cover doesn't support it + return False + return True + + +async def _async_close_cover( + service_call: partial[Coroutine[Any, Any, ServiceResponse]], + service_data: dict[str, Any], + features: CoverEntityFeature, + set_position: bool, + set_tilt: bool, +) -> None: + """Close the cover if it was not closed by setting the position.""" + if not set_position: + if CoverEntityFeature.CLOSE in features: + await service_call(SERVICE_CLOSE_COVER, service_data) + elif CoverEntityFeature.SET_POSITION in features: + await service_call( + SERVICE_SET_COVER_POSITION, service_data | {ATTR_POSITION: FULL_CLOSE} + ) + if not set_tilt: + if CoverEntityFeature.CLOSE_TILT in features: + await service_call(SERVICE_CLOSE_COVER_TILT, service_data) + elif CoverEntityFeature.SET_TILT_POSITION in features: + await service_call( + SERVICE_SET_COVER_TILT_POSITION, + service_data | {ATTR_TILT_POSITION: FULL_CLOSE}, + ) + + +async def _async_open_cover( + service_call: partial[Coroutine[Any, Any, ServiceResponse]], + service_data: dict[str, Any], + features: CoverEntityFeature, + set_position: bool, + set_tilt: bool, +) -> None: + """Open the cover if it was not opened by setting the position.""" + if not set_position: + if CoverEntityFeature.OPEN in features: + await service_call(SERVICE_OPEN_COVER, service_data) + elif CoverEntityFeature.SET_POSITION in features: + await service_call( + SERVICE_SET_COVER_POSITION, service_data | {ATTR_POSITION: FULL_OPEN} + ) + if not set_tilt: + if CoverEntityFeature.OPEN_TILT in features: + await service_call(SERVICE_OPEN_COVER_TILT, service_data) + elif CoverEntityFeature.SET_TILT_POSITION in features: + await service_call( + SERVICE_SET_COVER_TILT_POSITION, + service_data | {ATTR_TILT_POSITION: FULL_OPEN}, + ) async def _async_reproduce_state( @@ -45,74 +171,72 @@ async def _async_reproduce_state( reproduce_options: dict[str, Any] | None = None, ) -> None: """Reproduce a single state.""" - if (cur_state := hass.states.get(state.entity_id)) is None: - _LOGGER.warning("Unable to find entity %s", state.entity_id) + entity_id = state.entity_id + if (cur_state := hass.states.get(entity_id)) is None: + _LOGGER.warning("Unable to find entity %s", entity_id) return - if state.state not in VALID_STATES: - _LOGGER.warning( - "Invalid state specified for %s: %s", state.entity_id, state.state - ) + if (target_state := state.state) not in VALID_STATES: + _LOGGER.warning("Invalid state specified for %s: %s", entity_id, target_state) return + current_attrs = cur_state.attributes + target_attrs = state.attributes + + current_position = current_attrs.get(ATTR_CURRENT_POSITION) + target_position = target_attrs.get(ATTR_CURRENT_POSITION) + position_matches = current_position == target_position + + current_tilt_position = current_attrs.get(ATTR_CURRENT_TILT_POSITION) + target_tilt_position = target_attrs.get(ATTR_CURRENT_TILT_POSITION) + tilt_position_matches = current_tilt_position == target_tilt_position + + state_matches = cur_state.state == target_state # Return if we are already at the right state. - if ( - cur_state.state == state.state - and cur_state.attributes.get(ATTR_CURRENT_POSITION) - == state.attributes.get(ATTR_CURRENT_POSITION) - and cur_state.attributes.get(ATTR_CURRENT_TILT_POSITION) - == state.attributes.get(ATTR_CURRENT_TILT_POSITION) - ): + if state_matches and position_matches and tilt_position_matches: return - service_data = {ATTR_ENTITY_ID: state.entity_id} - service_data_tilting = {ATTR_ENTITY_ID: state.entity_id} + features = try_parse_enum( + CoverEntityFeature, current_attrs.get(ATTR_SUPPORTED_FEATURES) + ) + if features is None: + # Backwards compatibility for integrations that + # don't set supported features since it previously + # worked without it. + _LOGGER.warning("Supported features is not set for %s", entity_id) + features = _determine_features(current_attrs) - if not ( - cur_state.state == state.state - and cur_state.attributes.get(ATTR_CURRENT_POSITION) - == state.attributes.get(ATTR_CURRENT_POSITION) - ): - # Open/Close - if state.state in [CoverState.CLOSED, CoverState.CLOSING]: - service = SERVICE_CLOSE_COVER - elif state.state in [CoverState.OPEN, CoverState.OPENING]: - if ( - ATTR_CURRENT_POSITION in cur_state.attributes - and ATTR_CURRENT_POSITION in state.attributes - ): - service = SERVICE_SET_COVER_POSITION - service_data[ATTR_POSITION] = state.attributes[ATTR_CURRENT_POSITION] - else: - service = SERVICE_OPEN_COVER + service_call = partial( + hass.services.async_call, + DOMAIN, + context=context, + blocking=True, + ) + service_data = {ATTR_ENTITY_ID: entity_id} - await hass.services.async_call( - DOMAIN, service, service_data, context=context, blocking=True + set_position = ( + not position_matches + and target_position is not None + and await _async_set_position( + service_call, service_data, features, target_position + ) + ) + set_tilt = ( + not tilt_position_matches + and target_tilt_position is not None + and await _async_set_tilt_position( + service_call, service_data, features, target_tilt_position + ) + ) + + if target_state in CLOSING_STATES: + await _async_close_cover( + service_call, service_data, features, set_position, set_tilt ) - if ( - ATTR_CURRENT_TILT_POSITION in state.attributes - and ATTR_CURRENT_TILT_POSITION in cur_state.attributes - and cur_state.attributes.get(ATTR_CURRENT_TILT_POSITION) - != state.attributes.get(ATTR_CURRENT_TILT_POSITION) - ): - # Tilt position - if state.attributes.get(ATTR_CURRENT_TILT_POSITION) == 100: - service_tilting = SERVICE_OPEN_COVER_TILT - elif state.attributes.get(ATTR_CURRENT_TILT_POSITION) == 0: - service_tilting = SERVICE_CLOSE_COVER_TILT - else: - service_tilting = SERVICE_SET_COVER_TILT_POSITION - service_data_tilting[ATTR_TILT_POSITION] = state.attributes[ - ATTR_CURRENT_TILT_POSITION - ] - - await hass.services.async_call( - DOMAIN, - service_tilting, - service_data_tilting, - context=context, - blocking=True, + elif target_state in OPENING_STATES: + await _async_open_cover( + service_call, service_data, features, set_position, set_tilt ) diff --git a/tests/components/cover/test_reproduce_state.py b/tests/components/cover/test_reproduce_state.py index 4aad27011fa..57fc5aed5e9 100644 --- a/tests/components/cover/test_reproduce_state.py +++ b/tests/components/cover/test_reproduce_state.py @@ -7,9 +7,11 @@ from homeassistant.components.cover import ( ATTR_CURRENT_TILT_POSITION, ATTR_POSITION, ATTR_TILT_POSITION, + CoverEntityFeature, CoverState, ) from homeassistant.const import ( + ATTR_SUPPORTED_FEATURES, SERVICE_CLOSE_COVER, SERVICE_CLOSE_COVER_TILT, SERVICE_OPEN_COVER, @@ -27,35 +29,213 @@ async def test_reproducing_states( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: """Test reproducing Cover states.""" - hass.states.async_set("cover.entity_close", CoverState.CLOSED, {}) + hass.states.async_set( + "cover.entity_close", + CoverState.CLOSED, + { + ATTR_SUPPORTED_FEATURES: CoverEntityFeature.CLOSE | CoverEntityFeature.OPEN, + }, + ) + hass.states.async_set( + "cover.closed_only_supports_close_open", + CoverState.CLOSED, + { + ATTR_SUPPORTED_FEATURES: CoverEntityFeature.CLOSE | CoverEntityFeature.OPEN, + }, + ) + hass.states.async_set( + "cover.open_only_supports_close_open", + CoverState.OPEN, + { + ATTR_SUPPORTED_FEATURES: CoverEntityFeature.CLOSE | CoverEntityFeature.OPEN, + }, + ) + hass.states.async_set( + "cover.open_missing_all_features", + CoverState.OPEN, + ) + hass.states.async_set( + "cover.closed_missing_all_features_has_position", + CoverState.CLOSED, + { + ATTR_CURRENT_POSITION: 0, + }, + ) + hass.states.async_set( + "cover.open_missing_all_features_has_tilt_position", + CoverState.OPEN, + { + ATTR_CURRENT_TILT_POSITION: 50, + }, + ) + hass.states.async_set( + "cover.closed_only_supports_tilt_close_open", + CoverState.CLOSED, + { + ATTR_SUPPORTED_FEATURES: CoverEntityFeature.CLOSE_TILT + | CoverEntityFeature.OPEN_TILT, + }, + ) + hass.states.async_set( + "cover.open_only_supports_tilt_close_open", + CoverState.OPEN, + { + ATTR_SUPPORTED_FEATURES: CoverEntityFeature.CLOSE_TILT + | CoverEntityFeature.OPEN_TILT, + }, + ) + hass.states.async_set( + "cover.closed_only_supports_position", + CoverState.CLOSED, + { + ATTR_CURRENT_POSITION: 0, + ATTR_SUPPORTED_FEATURES: CoverEntityFeature.SET_POSITION, + }, + ) + hass.states.async_set( + "cover.open_only_supports_position", + CoverState.OPEN, + {ATTR_SUPPORTED_FEATURES: CoverEntityFeature.SET_POSITION}, + ) hass.states.async_set( "cover.entity_close_attr", CoverState.CLOSED, - {ATTR_CURRENT_POSITION: 0, ATTR_CURRENT_TILT_POSITION: 0}, + { + ATTR_CURRENT_POSITION: 0, + ATTR_CURRENT_TILT_POSITION: 0, + ATTR_SUPPORTED_FEATURES: CoverEntityFeature.CLOSE_TILT + | CoverEntityFeature.OPEN_TILT + | CoverEntityFeature.SET_POSITION + | CoverEntityFeature.SET_TILT_POSITION + | CoverEntityFeature.CLOSE + | CoverEntityFeature.OPEN, + }, ) hass.states.async_set( - "cover.entity_close_tilt", CoverState.CLOSED, {ATTR_CURRENT_TILT_POSITION: 50} + "cover.entity_close_tilt", + CoverState.CLOSED, + { + ATTR_CURRENT_TILT_POSITION: 50, + ATTR_SUPPORTED_FEATURES: CoverEntityFeature.CLOSE_TILT + | CoverEntityFeature.OPEN_TILT + | CoverEntityFeature.SET_TILT_POSITION, + }, ) - hass.states.async_set("cover.entity_open", CoverState.OPEN, {}) hass.states.async_set( - "cover.entity_slightly_open", CoverState.OPEN, {ATTR_CURRENT_POSITION: 50} + "cover.entity_open", + CoverState.OPEN, + {ATTR_SUPPORTED_FEATURES: CoverEntityFeature.CLOSE | CoverEntityFeature.OPEN}, + ) + hass.states.async_set( + "cover.entity_slightly_open", + CoverState.OPEN, + { + ATTR_CURRENT_POSITION: 50, + ATTR_SUPPORTED_FEATURES: CoverEntityFeature.SET_POSITION + | CoverEntityFeature.CLOSE + | CoverEntityFeature.OPEN, + }, ) hass.states.async_set( "cover.entity_open_attr", CoverState.OPEN, - {ATTR_CURRENT_POSITION: 100, ATTR_CURRENT_TILT_POSITION: 0}, + { + ATTR_CURRENT_POSITION: 100, + ATTR_CURRENT_TILT_POSITION: 0, + ATTR_SUPPORTED_FEATURES: CoverEntityFeature.CLOSE_TILT + | CoverEntityFeature.OPEN_TILT + | CoverEntityFeature.SET_POSITION + | CoverEntityFeature.SET_TILT_POSITION + | CoverEntityFeature.CLOSE + | CoverEntityFeature.OPEN, + }, ) hass.states.async_set( "cover.entity_open_tilt", CoverState.OPEN, - {ATTR_CURRENT_POSITION: 50, ATTR_CURRENT_TILT_POSITION: 50}, + { + ATTR_CURRENT_POSITION: 50, + ATTR_CURRENT_TILT_POSITION: 50, + ATTR_SUPPORTED_FEATURES: CoverEntityFeature.CLOSE_TILT + | CoverEntityFeature.OPEN_TILT + | CoverEntityFeature.SET_POSITION + | CoverEntityFeature.SET_TILT_POSITION + | CoverEntityFeature.CLOSE + | CoverEntityFeature.OPEN, + }, ) hass.states.async_set( "cover.entity_entirely_open", CoverState.OPEN, - {ATTR_CURRENT_POSITION: 100, ATTR_CURRENT_TILT_POSITION: 100}, + { + ATTR_CURRENT_POSITION: 100, + ATTR_CURRENT_TILT_POSITION: 100, + ATTR_SUPPORTED_FEATURES: CoverEntityFeature.CLOSE_TILT + | CoverEntityFeature.OPEN_TILT + | CoverEntityFeature.SET_POSITION + | CoverEntityFeature.SET_TILT_POSITION + | CoverEntityFeature.CLOSE + | CoverEntityFeature.OPEN, + }, + ) + hass.states.async_set( + "cover.tilt_only_open", + CoverState.OPEN, + { + ATTR_SUPPORTED_FEATURES: CoverEntityFeature.CLOSE_TILT + | CoverEntityFeature.OPEN_TILT, + }, + ) + hass.states.async_set( + "cover.tilt_only_closed", + CoverState.CLOSED, + { + ATTR_SUPPORTED_FEATURES: CoverEntityFeature.CLOSE_TILT + | CoverEntityFeature.OPEN_TILT, + }, + ) + hass.states.async_set( + "cover.tilt_only_tilt_position_100", + CoverState.OPEN, + { + ATTR_CURRENT_TILT_POSITION: 100, + ATTR_SUPPORTED_FEATURES: CoverEntityFeature.CLOSE_TILT + | CoverEntityFeature.OPEN_TILT + | CoverEntityFeature.SET_TILT_POSITION, + }, + ) + hass.states.async_set( + "cover.tilt_only_tilt_position_0", + CoverState.CLOSED, + { + ATTR_CURRENT_TILT_POSITION: 0, + ATTR_SUPPORTED_FEATURES: CoverEntityFeature.CLOSE_TILT + | CoverEntityFeature.OPEN_TILT + | CoverEntityFeature.SET_TILT_POSITION, + }, + ) + hass.states.async_set( + "cover.tilt_open_only_supports_tilt_position", + CoverState.OPEN, + { + ATTR_SUPPORTED_FEATURES: CoverEntityFeature.SET_TILT_POSITION, + }, + ) + hass.states.async_set( + "cover.tilt_partial_open_only_supports_tilt_position", + CoverState.OPEN, + { + ATTR_SUPPORTED_FEATURES: CoverEntityFeature.SET_TILT_POSITION, + ATTR_CURRENT_TILT_POSITION: 50, + }, + ) + hass.states.async_set( + "cover.tilt_closed_only_supports_tilt_position", + CoverState.CLOSED, + { + ATTR_SUPPORTED_FEATURES: CoverEntityFeature.SET_TILT_POSITION, + }, ) - close_calls = async_mock_service(hass, "cover", SERVICE_CLOSE_COVER) open_calls = async_mock_service(hass, "cover", SERVICE_OPEN_COVER) close_tilt_calls = async_mock_service(hass, "cover", SERVICE_CLOSE_COVER_TILT) @@ -70,6 +250,31 @@ async def test_reproducing_states( hass, [ State("cover.entity_close", CoverState.CLOSED), + State("cover.closed_only_supports_close_open", CoverState.CLOSED), + State("cover.closed_only_supports_tilt_close_open", CoverState.CLOSED), + State("cover.open_only_supports_close_open", CoverState.OPEN), + State("cover.open_only_supports_tilt_close_open", CoverState.OPEN), + State("cover.open_missing_all_features", CoverState.OPEN), + State( + "cover.closed_missing_all_features_has_position", + CoverState.CLOSED, + { + ATTR_CURRENT_POSITION: 0, + }, + ), + State( + "cover.open_missing_all_features_has_tilt_position", + CoverState.OPEN, + { + ATTR_CURRENT_TILT_POSITION: 50, + }, + ), + State( + "cover.closed_only_supports_position", + CoverState.CLOSED, + {ATTR_CURRENT_POSITION: 0}, + ), + State("cover.open_only_supports_position", CoverState.OPEN), State( "cover.entity_close_attr", CoverState.CLOSED, @@ -101,6 +306,39 @@ async def test_reproducing_states( CoverState.OPEN, {ATTR_CURRENT_POSITION: 100, ATTR_CURRENT_TILT_POSITION: 100}, ), + State( + "cover.tilt_only_open", + CoverState.OPEN, + {}, + ), + State( + "cover.tilt_only_tilt_position_100", + CoverState.OPEN, + {ATTR_CURRENT_TILT_POSITION: 100}, + ), + State( + "cover.tilt_only_closed", + CoverState.CLOSED, + {}, + ), + State( + "cover.tilt_only_tilt_position_0", + CoverState.CLOSED, + {ATTR_CURRENT_TILT_POSITION: 0}, + ), + State( + "cover.tilt_partial_open_only_supports_tilt_position", + CoverState.OPEN, + {ATTR_CURRENT_TILT_POSITION: 50}, + ), + State( + "cover.tilt_open_only_supports_tilt_position", + CoverState.OPEN, + ), + State( + "cover.tilt_closed_only_supports_tilt_position", + CoverState.CLOSED, + ), ], ) @@ -127,6 +365,35 @@ async def test_reproducing_states( hass, [ State("cover.entity_close", CoverState.OPEN), + State( + "cover.closed_only_supports_close_open", + CoverState.OPEN, + {ATTR_CURRENT_POSITION: 100}, + ), + State( + "cover.open_only_supports_close_open", + CoverState.CLOSED, + {ATTR_CURRENT_POSITION: 50}, + ), + State( + "cover.open_only_supports_tilt_close_open", + CoverState.CLOSED, + {ATTR_CURRENT_TILT_POSITION: 50}, + ), + State("cover.closed_only_supports_tilt_close_open", CoverState.OPEN), + State("cover.open_missing_all_features", CoverState.CLOSED), + State( + "cover.closed_missing_all_features_has_position", + CoverState.OPEN, + {ATTR_CURRENT_POSITION: 70}, + ), + State( + "cover.open_missing_all_features_has_tilt_position", + CoverState.OPEN, + {ATTR_CURRENT_TILT_POSITION: 20}, + ), + State("cover.closed_only_supports_position", CoverState.OPEN), + State("cover.open_only_supports_position", CoverState.CLOSED), State( "cover.entity_close_attr", CoverState.OPEN, @@ -152,6 +419,39 @@ async def test_reproducing_states( ), # Should not raise State("cover.non_existing", "on"), + State( + "cover.tilt_only_open", + CoverState.CLOSED, + {}, + ), + State( + "cover.tilt_only_tilt_position_100", + CoverState.CLOSED, + {ATTR_CURRENT_TILT_POSITION: 0}, + ), + State( + "cover.tilt_only_closed", + CoverState.OPEN, + {}, + ), + State( + "cover.tilt_only_tilt_position_0", + CoverState.OPEN, + {ATTR_CURRENT_TILT_POSITION: 100}, + ), + State( + "cover.tilt_partial_open_only_supports_tilt_position", + CoverState.OPEN, + {ATTR_CURRENT_TILT_POSITION: 70}, + ), + State( + "cover.tilt_open_only_supports_tilt_position", + CoverState.CLOSED, + ), + State( + "cover.tilt_closed_only_supports_tilt_position", + CoverState.OPEN, + ), ], ) @@ -159,8 +459,10 @@ async def test_reproducing_states( {"entity_id": "cover.entity_open"}, {"entity_id": "cover.entity_open_attr"}, {"entity_id": "cover.entity_entirely_open"}, + {"entity_id": "cover.open_only_supports_close_open"}, + {"entity_id": "cover.open_missing_all_features"}, ] - assert len(close_calls) == 3 + assert len(close_calls) == len(valid_close_calls) for call in close_calls: assert call.domain == "cover" assert call.data in valid_close_calls @@ -170,8 +472,9 @@ async def test_reproducing_states( {"entity_id": "cover.entity_close"}, {"entity_id": "cover.entity_slightly_open"}, {"entity_id": "cover.entity_open_tilt"}, + {"entity_id": "cover.closed_only_supports_close_open"}, ] - assert len(open_calls) == 3 + assert len(open_calls) == len(valid_open_calls) for call in open_calls: assert call.domain == "cover" assert call.data in valid_open_calls @@ -180,27 +483,77 @@ async def test_reproducing_states( valid_close_tilt_calls = [ {"entity_id": "cover.entity_open_tilt"}, {"entity_id": "cover.entity_entirely_open"}, + {"entity_id": "cover.tilt_only_open"}, + {"entity_id": "cover.entity_open_attr"}, + {"entity_id": "cover.tilt_only_tilt_position_100"}, + {"entity_id": "cover.open_only_supports_tilt_close_open"}, ] - assert len(close_tilt_calls) == 2 + assert len(close_tilt_calls) == len(valid_close_tilt_calls) for call in close_tilt_calls: assert call.domain == "cover" assert call.data in valid_close_tilt_calls valid_close_tilt_calls.remove(call.data) - assert len(open_tilt_calls) == 1 - assert open_tilt_calls[0].domain == "cover" - assert open_tilt_calls[0].data == {"entity_id": "cover.entity_close_tilt"} + valid_open_tilt_calls = [ + {"entity_id": "cover.entity_close_tilt"}, + {"entity_id": "cover.tilt_only_closed"}, + {"entity_id": "cover.tilt_only_tilt_position_0"}, + {"entity_id": "cover.closed_only_supports_tilt_close_open"}, + ] + assert len(open_tilt_calls) == len(valid_open_tilt_calls) + for call in open_tilt_calls: + assert call.domain == "cover" + assert call.data in valid_open_tilt_calls + valid_open_tilt_calls.remove(call.data) - assert len(position_calls) == 1 - assert position_calls[0].domain == "cover" - assert position_calls[0].data == { - "entity_id": "cover.entity_close_attr", - ATTR_POSITION: 50, - } + valid_position_calls = [ + { + "entity_id": "cover.entity_close_attr", + ATTR_POSITION: 50, + }, + { + "entity_id": "cover.closed_missing_all_features_has_position", + ATTR_POSITION: 70, + }, + { + "entity_id": "cover.closed_only_supports_position", + ATTR_POSITION: 100, + }, + { + "entity_id": "cover.open_only_supports_position", + ATTR_POSITION: 0, + }, + ] + assert len(position_calls) == len(valid_position_calls) + for call in position_calls: + assert call.domain == "cover" + assert call.data in valid_position_calls + valid_position_calls.remove(call.data) - assert len(position_tilt_calls) == 1 - assert position_tilt_calls[0].domain == "cover" - assert position_tilt_calls[0].data == { - "entity_id": "cover.entity_close_attr", - ATTR_TILT_POSITION: 50, - } + valid_position_tilt_calls = [ + { + "entity_id": "cover.entity_close_attr", + ATTR_TILT_POSITION: 50, + }, + { + "entity_id": "cover.open_missing_all_features_has_tilt_position", + ATTR_TILT_POSITION: 20, + }, + { + "entity_id": "cover.tilt_open_only_supports_tilt_position", + ATTR_TILT_POSITION: 0, + }, + { + "entity_id": "cover.tilt_closed_only_supports_tilt_position", + ATTR_TILT_POSITION: 100, + }, + { + "entity_id": "cover.tilt_partial_open_only_supports_tilt_position", + ATTR_TILT_POSITION: 70, + }, + ] + assert len(position_tilt_calls) == len(valid_position_tilt_calls) + for call in position_tilt_calls: + assert call.domain == "cover" + assert call.data in valid_position_tilt_calls + valid_position_tilt_calls.remove(call.data) From 13b6cfa438441f258e6de1934e045f2611a9b220 Mon Sep 17 00:00:00 2001 From: Tim Laing <11019084+timlaing@users.noreply.github.com> Date: Sat, 15 Mar 2025 02:54:49 +0000 Subject: [PATCH 2635/3148] Add generate content service for OpenAI to match Google AI (#122818) * Aded Generate Content Service for OpenAI to match Google AI * Fixed code for commit checks * Addressed code review comments * Address review comments * Addressed @balloob review comments. * Address futher review comments from @balloob --- .../openai_conversation/__init__.py | 145 +++++++- .../components/openai_conversation/const.py | 22 +- .../components/openai_conversation/icons.json | 3 + .../openai_conversation/manifest.json | 2 +- .../openai_conversation/services.yaml | 20 ++ .../openai_conversation/strings.json | 18 + requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../openai_conversation/test_init.py | 314 +++++++++++++++++- 9 files changed, 500 insertions(+), 28 deletions(-) diff --git a/homeassistant/components/openai_conversation/__init__.py b/homeassistant/components/openai_conversation/__init__.py index 0fbda9b7f4a..d7fc5205f17 100644 --- a/homeassistant/components/openai_conversation/__init__.py +++ b/homeassistant/components/openai_conversation/__init__.py @@ -2,7 +2,26 @@ from __future__ import annotations +import base64 +from mimetypes import guess_file_type +from pathlib import Path + import openai +from openai.types.chat.chat_completion import ChatCompletion +from openai.types.chat.chat_completion_content_part_image_param import ( + ChatCompletionContentPartImageParam, + ImageURL, +) +from openai.types.chat.chat_completion_content_part_param import ( + ChatCompletionContentPartParam, +) +from openai.types.chat.chat_completion_content_part_text_param import ( + ChatCompletionContentPartTextParam, +) +from openai.types.chat.chat_completion_user_message_param import ( + ChatCompletionUserMessageParam, +) +from openai.types.images_response import ImagesResponse import voluptuous as vol from homeassistant.config_entries import ConfigEntry @@ -22,15 +41,33 @@ from homeassistant.helpers import config_validation as cv, selector from homeassistant.helpers.httpx_client import get_async_client from homeassistant.helpers.typing import ConfigType -from .const import DOMAIN, LOGGER +from .const import ( + CONF_CHAT_MODEL, + CONF_FILENAMES, + CONF_PROMPT, + DOMAIN, + LOGGER, + RECOMMENDED_CHAT_MODEL, +) SERVICE_GENERATE_IMAGE = "generate_image" +SERVICE_GENERATE_CONTENT = "generate_content" + PLATFORMS = (Platform.CONVERSATION,) CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) type OpenAIConfigEntry = ConfigEntry[openai.AsyncClient] +def encode_file(file_path: str) -> tuple[str, str]: + """Return base64 version of file contents.""" + mime_type, _ = guess_file_type(file_path) + if mime_type is None: + mime_type = "application/octet-stream" + with open(file_path, "rb") as image_file: + return (mime_type, base64.b64encode(image_file.read()).decode("utf-8")) + + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up OpenAI Conversation.""" @@ -49,9 +86,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: client: openai.AsyncClient = entry.runtime_data try: - response = await client.images.generate( + response: ImagesResponse = await client.images.generate( model="dall-e-3", - prompt=call.data["prompt"], + prompt=call.data[CONF_PROMPT], size=call.data["size"], quality=call.data["quality"], style=call.data["style"], @@ -63,6 +100,105 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return response.data[0].model_dump(exclude={"b64_json"}) + async def send_prompt(call: ServiceCall) -> ServiceResponse: + """Send a prompt to ChatGPT and return the response.""" + entry_id = call.data["config_entry"] + entry = hass.config_entries.async_get_entry(entry_id) + + if entry is None or entry.domain != DOMAIN: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_config_entry", + translation_placeholders={"config_entry": entry_id}, + ) + + model: str = entry.data.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL) + client: openai.AsyncClient = entry.runtime_data + + prompt_parts: list[ChatCompletionContentPartParam] = [ + ChatCompletionContentPartTextParam( + type="text", + text=call.data[CONF_PROMPT], + ) + ] + + def append_files_to_prompt() -> None: + for filename in call.data[CONF_FILENAMES]: + if not hass.config.is_allowed_path(filename): + raise HomeAssistantError( + f"Cannot read `{filename}`, no access to path; " + "`allowlist_external_dirs` may need to be adjusted in " + "`configuration.yaml`" + ) + if not Path(filename).exists(): + raise HomeAssistantError(f"`{filename}` does not exist") + mime_type, base64_file = encode_file(filename) + if "image/" not in mime_type: + raise HomeAssistantError( + "Only images are supported by the OpenAI API," + f"`{filename}` is not an image file" + ) + prompt_parts.append( + ChatCompletionContentPartImageParam( + type="image_url", + image_url=ImageURL( + url=f"data:{mime_type};base64,{base64_file}" + ), + ) + ) + + if CONF_FILENAMES in call.data: + await hass.async_add_executor_job(append_files_to_prompt) + + messages: list[ChatCompletionUserMessageParam] = [ + ChatCompletionUserMessageParam( + role="user", + content=prompt_parts, + ) + ] + + try: + response: ChatCompletion = await client.chat.completions.create( + model=model, + messages=messages, + n=1, + response_format={ + "type": "json_object", + }, + ) + + except openai.OpenAIError as err: + raise HomeAssistantError(f"Error generating content: {err}") from err + except FileNotFoundError as err: + raise HomeAssistantError(f"Error generating content: {err}") from err + + response_text: str = "" + for response_choice in response.choices: + if response_choice.message.content is not None: + response_text += response_choice.message.content.strip() + + return {"text": response_text} + + hass.services.async_register( + DOMAIN, + SERVICE_GENERATE_CONTENT, + send_prompt, + schema=vol.Schema( + { + vol.Required("config_entry"): selector.ConfigEntrySelector( + { + "integration": DOMAIN, + } + ), + vol.Required(CONF_PROMPT): cv.string, + vol.Optional(CONF_FILENAMES, default=[]): vol.All( + cv.ensure_list, [cv.string] + ), + } + ), + supports_response=SupportsResponse.ONLY, + ) + hass.services.async_register( DOMAIN, SERVICE_GENERATE_IMAGE, @@ -74,7 +210,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: "integration": DOMAIN, } ), - vol.Required("prompt"): cv.string, + vol.Required(CONF_PROMPT): cv.string, vol.Optional("size", default="1024x1024"): vol.In( ("1024x1024", "1024x1792", "1792x1024") ), @@ -84,6 +220,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ), supports_response=SupportsResponse.ONLY, ) + return True diff --git a/homeassistant/components/openai_conversation/const.py b/homeassistant/components/openai_conversation/const.py index 793e021e332..c9987cb81b9 100644 --- a/homeassistant/components/openai_conversation/const.py +++ b/homeassistant/components/openai_conversation/const.py @@ -3,22 +3,24 @@ import logging DOMAIN = "openai_conversation" -LOGGER = logging.getLogger(__package__) +LOGGER: logging.Logger = logging.getLogger(__package__) -CONF_RECOMMENDED = "recommended" -CONF_PROMPT = "prompt" CONF_CHAT_MODEL = "chat_model" -RECOMMENDED_CHAT_MODEL = "gpt-4o-mini" +CONF_FILENAMES = "filenames" CONF_MAX_TOKENS = "max_tokens" -RECOMMENDED_MAX_TOKENS = 150 -CONF_TOP_P = "top_p" -RECOMMENDED_TOP_P = 1.0 -CONF_TEMPERATURE = "temperature" -RECOMMENDED_TEMPERATURE = 1.0 +CONF_PROMPT = "prompt" +CONF_PROMPT = "prompt" CONF_REASONING_EFFORT = "reasoning_effort" +CONF_RECOMMENDED = "recommended" +CONF_TEMPERATURE = "temperature" +CONF_TOP_P = "top_p" +RECOMMENDED_CHAT_MODEL = "gpt-4o-mini" +RECOMMENDED_MAX_TOKENS = 150 RECOMMENDED_REASONING_EFFORT = "low" +RECOMMENDED_TEMPERATURE = 1.0 +RECOMMENDED_TOP_P = 1.0 -UNSUPPORTED_MODELS = [ +UNSUPPORTED_MODELS: list[str] = [ "o1-mini", "o1-mini-2024-09-12", "o1-preview", diff --git a/homeassistant/components/openai_conversation/icons.json b/homeassistant/components/openai_conversation/icons.json index 3abecd640d1..f0ece31c304 100644 --- a/homeassistant/components/openai_conversation/icons.json +++ b/homeassistant/components/openai_conversation/icons.json @@ -2,6 +2,9 @@ "services": { "generate_image": { "service": "mdi:image-sync" + }, + "generate_content": { + "service": "mdi:receipt-text" } } } diff --git a/homeassistant/components/openai_conversation/manifest.json b/homeassistant/components/openai_conversation/manifest.json index a7aa7884dc4..cc1c56b0927 100644 --- a/homeassistant/components/openai_conversation/manifest.json +++ b/homeassistant/components/openai_conversation/manifest.json @@ -8,5 +8,5 @@ "documentation": "https://www.home-assistant.io/integrations/openai_conversation", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["openai==1.61.0"] + "requirements": ["openai==1.65.2"] } diff --git a/homeassistant/components/openai_conversation/services.yaml b/homeassistant/components/openai_conversation/services.yaml index 3db71cae383..75fa097f25d 100644 --- a/homeassistant/components/openai_conversation/services.yaml +++ b/homeassistant/components/openai_conversation/services.yaml @@ -38,3 +38,23 @@ generate_image: options: - "vivid" - "natural" +generate_content: + fields: + config_entry: + required: true + selector: + config_entry: + integration: openai_conversation + prompt: + required: true + selector: + text: + multiline: true + example: "Hello, how can I help you?" + filenames: + selector: + text: + multiline: true + example: | + - /path/to/file1.txt + - /path/to/file2.txt diff --git a/homeassistant/components/openai_conversation/strings.json b/homeassistant/components/openai_conversation/strings.json index aba4fdc3d40..c9d7ee112bd 100644 --- a/homeassistant/components/openai_conversation/strings.json +++ b/homeassistant/components/openai_conversation/strings.json @@ -72,6 +72,24 @@ "description": "The style of the generated image" } } + }, + "generate_content": { + "name": "Generate content", + "description": "Sends a conversational query to ChatGPT including any attached image files", + "fields": { + "config_entry": { + "name": "Config entry", + "description": "The config entry to use for this action" + }, + "prompt": { + "name": "Prompt", + "description": "The prompt to send" + }, + "filenames": { + "name": "Files", + "description": "List of files to upload" + } + } } }, "exceptions": { diff --git a/requirements_all.txt b/requirements_all.txt index 250d6597718..5947a0c5ad9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1580,7 +1580,7 @@ open-garage==0.2.0 open-meteo==0.3.2 # homeassistant.components.openai_conversation -openai==1.61.0 +openai==1.65.2 # homeassistant.components.openerz openerz-api==0.3.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c4c6463d48a..97af399a260 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1325,7 +1325,7 @@ open-garage==0.2.0 open-meteo==0.3.2 # homeassistant.components.openai_conversation -openai==1.61.0 +openai==1.65.2 # homeassistant.components.openerz openerz-api==0.3.0 diff --git a/tests/components/openai_conversation/test_init.py b/tests/components/openai_conversation/test_init.py index d78ce398c92..05a92d0b98e 100644 --- a/tests/components/openai_conversation/test_init.py +++ b/tests/components/openai_conversation/test_init.py @@ -1,18 +1,21 @@ """Tests for the OpenAI integration.""" -from unittest.mock import patch +from unittest.mock import AsyncMock, mock_open, patch -from httpx import Response +from httpx import Request, Response from openai import ( APIConnectionError, AuthenticationError, BadRequestError, RateLimitError, ) +from openai.types.chat.chat_completion import ChatCompletion, Choice +from openai.types.chat.chat_completion_message import ChatCompletionMessage from openai.types.image import Image from openai.types.images_response import ImagesResponse import pytest +from homeassistant.components.openai_conversation import CONF_FILENAMES from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.setup import async_setup_component @@ -114,7 +117,9 @@ async def test_generate_image_service_error( patch( "openai.resources.images.AsyncImages.generate", side_effect=RateLimitError( - response=Response(status_code=None, request=""), + response=Response( + status_code=500, request=Request(method="GET", url="") + ), body=None, message="Reason", ), @@ -133,22 +138,60 @@ async def test_generate_image_service_error( ) +@pytest.mark.usefixtures("mock_init_component") +async def test_generate_content_service_with_image_not_allowed_path( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test generate content service with an image in a not allowed path.""" + with ( + patch("pathlib.Path.exists", return_value=True), + patch.object(hass.config, "is_allowed_path", return_value=False), + pytest.raises( + HomeAssistantError, + match=( + "Cannot read `doorbell_snapshot.jpg`, no access to path; " + "`allowlist_external_dirs` may need to be adjusted in " + "`configuration.yaml`" + ), + ), + ): + await hass.services.async_call( + "openai_conversation", + "generate_content", + { + "config_entry": mock_config_entry.entry_id, + "prompt": "Describe this image from my doorbell camera", + "filenames": "doorbell_snapshot.jpg", + }, + blocking=True, + return_response=True, + ) + + +@pytest.mark.parametrize( + ("service_name", "error"), + [ + ("generate_image", "Invalid config entry provided. Got invalid_entry"), + ("generate_content", "Invalid config entry provided. Got invalid_entry"), + ], +) async def test_invalid_config_entry( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_init_component, + service_name: str, + error: str, ) -> None: """Assert exception when invalid config entry is provided.""" service_data = { "prompt": "Picture of a dog", "config_entry": "invalid_entry", } - with pytest.raises( - ServiceValidationError, match="Invalid config entry provided. Got invalid_entry" - ): + with pytest.raises(ServiceValidationError, match=error): await hass.services.async_call( "openai_conversation", - "generate_image", + service_name, service_data, blocking=True, return_response=True, @@ -158,18 +201,29 @@ async def test_invalid_config_entry( @pytest.mark.parametrize( ("side_effect", "error"), [ - (APIConnectionError(request=None), "Connection error"), + ( + APIConnectionError(request=Request(method="GET", url="test")), + "Connection error", + ), ( AuthenticationError( - response=Response(status_code=None, request=""), body=None, message=None + response=Response( + status_code=500, request=Request(method="GET", url="test") + ), + body=None, + message="", ), "Invalid API key", ), ( BadRequestError( - response=Response(status_code=None, request=""), body=None, message=None + response=Response( + status_code=500, request=Request(method="GET", url="test") + ), + body=None, + message="", ), - "openai_conversation integration not ready yet: None", + "openai_conversation integration not ready yet", ), ], ) @@ -188,3 +242,241 @@ async def test_init_error( assert await async_setup_component(hass, "openai_conversation", {}) await hass.async_block_till_done() assert error in caplog.text + + +@pytest.mark.parametrize( + ("service_data", "expected_args", "number_of_files"), + [ + ( + {"prompt": "Picture of a dog", "filenames": []}, + { + "messages": [ + { + "content": [ + { + "type": "text", + "text": "Picture of a dog", + }, + ], + }, + ], + }, + 0, + ), + ( + {"prompt": "Picture of a dog", "filenames": ["/a/b/c.jpg"]}, + { + "messages": [ + { + "content": [ + { + "type": "text", + "text": "Picture of a dog", + }, + { + "type": "image_url", + "image_url": { + "url": "data:image/jpeg;base64,BASE64IMAGE1", + }, + }, + ], + }, + ], + }, + 1, + ), + ( + { + "prompt": "Picture of a dog", + "filenames": ["/a/b/c.jpg", "d/e/f.jpg"], + }, + { + "messages": [ + { + "content": [ + { + "type": "text", + "text": "Picture of a dog", + }, + { + "type": "image_url", + "image_url": { + "url": "data:image/jpeg;base64,BASE64IMAGE1", + }, + }, + { + "type": "image_url", + "image_url": { + "url": "data:image/jpeg;base64,BASE64IMAGE2", + }, + }, + ], + }, + ], + }, + 2, + ), + ], +) +async def test_generate_content_service( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_init_component, + service_data, + expected_args, + number_of_files, +) -> None: + """Test generate content service.""" + service_data["config_entry"] = mock_config_entry.entry_id + expected_args["model"] = "gpt-4o-mini" + expected_args["n"] = 1 + expected_args["response_format"] = {"type": "json_object"} + expected_args["messages"][0]["role"] = "user" + + with ( + patch( + "openai.resources.chat.completions.AsyncCompletions.create", + new_callable=AsyncMock, + ) as mock_create, + patch( + "base64.b64encode", side_effect=[b"BASE64IMAGE1", b"BASE64IMAGE2"] + ) as mock_b64encode, + patch("builtins.open", mock_open(read_data="ABC")) as mock_file, + patch("pathlib.Path.exists", return_value=True), + patch.object(hass.config, "is_allowed_path", return_value=True), + ): + mock_create.return_value = ChatCompletion( + id="", + model="", + created=1700000000, + object="chat.completion", + choices=[ + Choice( + index=0, + finish_reason="stop", + message=ChatCompletionMessage( + role="assistant", + content="This is the response", + ), + ) + ], + ) + + response = await hass.services.async_call( + "openai_conversation", + "generate_content", + service_data, + blocking=True, + return_response=True, + ) + assert response == {"text": "This is the response"} + assert len(mock_create.mock_calls) == 1 + assert mock_create.mock_calls[0][2] == expected_args + assert mock_b64encode.call_count == number_of_files + for idx, file in enumerate(service_data[CONF_FILENAMES]): + assert mock_file.call_args_list[idx][0][0] == file + + +@pytest.mark.parametrize( + ( + "service_data", + "error", + "number_of_files", + "exists_side_effect", + "is_allowed_side_effect", + ), + [ + ( + {"prompt": "Picture of a dog", "filenames": ["/a/b/c.jpg"]}, + "`/a/b/c.jpg` does not exist", + 0, + [False], + [True], + ), + ( + { + "prompt": "Picture of a dog", + "filenames": ["/a/b/c.jpg", "d/e/f.png"], + }, + "Cannot read `d/e/f.png`, no access to path; `allowlist_external_dirs` may need to be adjusted in `configuration.yaml`", + 1, + [True, True], + [True, False], + ), + ( + {"prompt": "Not a picture of a dog", "filenames": ["/a/b/c.pdf"]}, + "Only images are supported by the OpenAI API,`/a/b/c.pdf` is not an image file", + 1, + [True], + [True], + ), + ], +) +async def test_generate_content_service_invalid( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_init_component, + service_data, + error, + number_of_files, + exists_side_effect, + is_allowed_side_effect, +) -> None: + """Test generate content service.""" + service_data["config_entry"] = mock_config_entry.entry_id + + with ( + patch( + "openai.resources.chat.completions.AsyncCompletions.create", + new_callable=AsyncMock, + ) as mock_create, + patch( + "base64.b64encode", side_effect=[b"BASE64IMAGE1", b"BASE64IMAGE2"] + ) as mock_b64encode, + patch("builtins.open", mock_open(read_data="ABC")), + patch("pathlib.Path.exists", side_effect=exists_side_effect), + patch.object( + hass.config, "is_allowed_path", side_effect=is_allowed_side_effect + ), + ): + with pytest.raises(HomeAssistantError, match=error): + await hass.services.async_call( + "openai_conversation", + "generate_content", + service_data, + blocking=True, + return_response=True, + ) + assert len(mock_create.mock_calls) == 0 + assert mock_b64encode.call_count == number_of_files + + +@pytest.mark.usefixtures("mock_init_component") +async def test_generate_content_service_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test generate content service handles errors.""" + with ( + patch( + "openai.resources.chat.completions.AsyncCompletions.create", + side_effect=RateLimitError( + response=Response( + status_code=417, request=Request(method="GET", url="") + ), + body=None, + message="Reason", + ), + ), + pytest.raises(HomeAssistantError, match="Error generating content: Reason"), + ): + await hass.services.async_call( + "openai_conversation", + "generate_content", + { + "config_entry": mock_config_entry.entry_id, + "prompt": "Image of an epic fail", + }, + blocking=True, + return_response=True, + ) From 99f661538d073b33f577a3e4868d6ea176e4a5de Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 15 Mar 2025 00:27:06 -1000 Subject: [PATCH 2636/3148] Bump aioesphomeapi to 29.7.0 (#140641) changelog: https://github.com/esphome/aioesphomeapi/compare/v29.6.0...v29.7.0 --- 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 8d1cafee926..075185dffbb 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -16,7 +16,7 @@ "loggers": ["aioesphomeapi", "noiseprotocol", "bleak_esphome"], "mqtt": ["esphome/discover/#"], "requirements": [ - "aioesphomeapi==29.6.0", + "aioesphomeapi==29.7.0", "esphome-dashboard-api==1.2.3", "bleak-esphome==2.12.0" ], diff --git a/requirements_all.txt b/requirements_all.txt index 5947a0c5ad9..4b2e0e0053a 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==29.6.0 +aioesphomeapi==29.7.0 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 97af399a260..e9f7d5bee74 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -231,7 +231,7 @@ aioelectricitymaps==0.4.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==29.6.0 +aioesphomeapi==29.7.0 # homeassistant.components.flo aioflo==2021.11.0 From f801cfee7e59d917cbb73fc30af05695b249502e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 15 Mar 2025 00:27:21 -1000 Subject: [PATCH 2637/3148] Bump habluetooth to 3.32.0 (#140640) changelog: https://github.com/Bluetooth-Devices/habluetooth/compare/v3.27.0...v3.32.0 --- 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 eed21dcc0c8..ff8de8509a3 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -21,6 +21,6 @@ "bluetooth-auto-recovery==1.4.5", "bluetooth-data-tools==1.26.1", "dbus-fast==2.39.5", - "habluetooth==3.27.0" + "habluetooth==3.32.0" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index ef50d88c44a..59a56c8ea15 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -33,7 +33,7 @@ dbus-fast==2.39.5 fnv-hash-fast==1.4.0 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 -habluetooth==3.27.0 +habluetooth==3.32.0 hass-nabucasa==0.94.0 hassil==2.2.3 home-assistant-bluetooth==1.13.1 diff --git a/requirements_all.txt b/requirements_all.txt index 4b2e0e0053a..8e52f822de3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1112,7 +1112,7 @@ ha-silabs-firmware-client==0.2.0 habiticalib==0.3.7 # homeassistant.components.bluetooth -habluetooth==3.27.0 +habluetooth==3.32.0 # homeassistant.components.cloud hass-nabucasa==0.94.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e9f7d5bee74..2960379bda0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -950,7 +950,7 @@ ha-philipsjs==3.2.2 habiticalib==0.3.7 # homeassistant.components.bluetooth -habluetooth==3.27.0 +habluetooth==3.32.0 # homeassistant.components.cloud hass-nabucasa==0.94.0 From 940625505f56f0adef0b0978927d5072ab139250 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Sat, 15 Mar 2025 14:17:16 +0100 Subject: [PATCH 2638/3148] Handle non documented options at Home Connect select entities (#140608) * Allow non documented options at select entities * Don't allow undocumented options --- .../components/home_connect/select.py | 12 ++++++---- tests/components/home_connect/test_select.py | 22 ++++++++++++++++++- 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/home_connect/select.py b/homeassistant/components/home_connect/select.py index ef3e2ccbf82..527fd827399 100644 --- a/homeassistant/components/home_connect/select.py +++ b/homeassistant/components/home_connect/select.py @@ -413,6 +413,7 @@ class HomeConnectSelectEntity(HomeConnectEntity, SelectEntity): """Select setting class for Home Connect.""" entity_description: HomeConnectSelectEntityDescription + _original_option_keys: set[str | None] def __init__( self, @@ -421,6 +422,7 @@ class HomeConnectSelectEntity(HomeConnectEntity, SelectEntity): desc: HomeConnectSelectEntityDescription, ) -> None: """Initialize the entity.""" + self._original_option_keys = set(desc.values_translation_key) super().__init__( coordinator, appliance, @@ -471,10 +473,12 @@ class HomeConnectSelectEntity(HomeConnectEntity, SelectEntity): ) if setting and setting.constraints and setting.constraints.allowed_values: + self._original_option_keys = set(setting.constraints.allowed_values) self._attr_options = [ self.entity_description.values_translation_key[option] - for option in setting.constraints.allowed_values - if option in self.entity_description.values_translation_key + for option in self._original_option_keys + if option is not None + and option in self.entity_description.values_translation_key ] @@ -491,7 +495,7 @@ class HomeConnectSelectOptionEntity(HomeConnectOptionEntity, SelectEntity): desc: HomeConnectSelectEntityDescription, ) -> None: """Initialize the entity.""" - self._original_option_keys = set(desc.values_translation_key.keys()) + self._original_option_keys = set(desc.values_translation_key) super().__init__( coordinator, appliance, @@ -524,5 +528,5 @@ class HomeConnectSelectOptionEntity(HomeConnectOptionEntity, SelectEntity): self.entity_description.values_translation_key[option] for option in self._original_option_keys if option is not None + and option in self.entity_description.values_translation_key ] - self.__dict__.pop("options", None) diff --git a/tests/components/home_connect/test_select.py b/tests/components/home_connect/test_select.py index 22ece365e6b..8ce91ed681c 100644 --- a/tests/components/home_connect/test_select.py +++ b/tests/components/home_connect/test_select.py @@ -521,9 +521,18 @@ async def test_select_functionality( ( "select.hood_ambient_light_color", SettingKey.BSH_COMMON_AMBIENT_LIGHT_COLOR, - [f"BSH.Common.EnumType.AmbientLightColor.Color{i}" for i in range(50)], + [f"BSH.Common.EnumType.AmbientLightColor.Color{i}" for i in range(1, 50)], {str(i) for i in range(1, 50)}, ), + ( + "select.hood_ambient_light_color", + SettingKey.BSH_COMMON_AMBIENT_LIGHT_COLOR, + [ + "A.Non.Documented.Option", + "BSH.Common.EnumType.AmbientLightColor.Color42", + ], + {"42"}, + ), ], ) async def test_fetch_allowed_values( @@ -679,6 +688,17 @@ async def test_select_entity_error( "laundry_care_washer_enum_type_temperature_ul_extra_hot", }, ), + ( + "select.washer_temperature", + OptionKey.LAUNDRY_CARE_WASHER_TEMPERATURE, + [ + "A.Non.Documented.Option", + "LaundryCare.Washer.EnumType.Temperature.UlWarm", + ], + { + "laundry_care_washer_enum_type_temperature_ul_warm", + }, + ), ], ) async def test_options_functionality( From b7e2e041bcb253de998973f9ff3f0047420fcb7b Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 15 Mar 2025 15:08:21 +0100 Subject: [PATCH 2639/3148] Make Oven setpoint follow temperature UoM in SmartThings (#140666) --- .../components/smartthings/sensor.py | 15 +- tests/components/smartthings/conftest.py | 1 + .../device_status/da_ks_oven_01061.json | 566 ++++++++++++++++++ .../fixtures/devices/da_ks_oven_01061.json | 153 +++++ .../smartthings/snapshots/test_init.ambr | 33 + .../smartthings/snapshots/test_sensor.ambr | 398 ++++++++++++ 6 files changed, 1163 insertions(+), 3 deletions(-) create mode 100644 tests/components/smartthings/fixtures/device_status/da_ks_oven_01061.json create mode 100644 tests/components/smartthings/fixtures/devices/da_ks_oven_01061.json diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index ec4d9ee6207..fd447da427e 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -132,6 +132,7 @@ class SmartThingsSensorEntityDescription(SensorEntityDescription): capability_ignore_list: list[set[Capability]] | None = None options_attribute: Attribute | None = None exists_fn: Callable[[Status], bool] | None = None + use_temperature_unit: bool = False CAPABILITY_TO_SENSORS: dict[ @@ -573,7 +574,7 @@ CAPABILITY_TO_SENSORS: dict[ key=Attribute.OVEN_SETPOINT, translation_key="oven_setpoint", device_class=SensorDeviceClass.TEMPERATURE, - native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT, + use_temperature_unit=True, value_fn=lambda value: value if value != 0 else None, ) ] @@ -1026,7 +1027,10 @@ class SmartThingsSensor(SmartThingsEntity, SensorEntity): attribute: Attribute, ) -> None: """Init the class.""" - super().__init__(client, device, rooms, {capability}) + capabilities_to_subscribe = {capability} + if entity_description.use_temperature_unit: + capabilities_to_subscribe.add(Capability.TEMPERATURE_MEASUREMENT) + super().__init__(client, device, rooms, capabilities_to_subscribe) self._attr_unique_id = f"{device.device.device_id}{entity_description.unique_id_separator}{entity_description.key}" self._attribute = attribute self.capability = capability @@ -1041,7 +1045,12 @@ class SmartThingsSensor(SmartThingsEntity, SensorEntity): @property def native_unit_of_measurement(self) -> str | None: """Return the unit this state is expressed in.""" - unit = self._internal_state[self.capability][self._attribute].unit + if self.entity_description.use_temperature_unit: + unit = self._internal_state[Capability.TEMPERATURE_MEASUREMENT][ + Attribute.TEMPERATURE + ].unit + else: + unit = self._internal_state[self.capability][self._attribute].unit return ( UNITS.get(unit, unit) if unit diff --git a/tests/components/smartthings/conftest.py b/tests/components/smartthings/conftest.py index c10668210e0..8e2956440cb 100644 --- a/tests/components/smartthings/conftest.py +++ b/tests/components/smartthings/conftest.py @@ -114,6 +114,7 @@ def mock_smartthings() -> Generator[AsyncMock]: "da_rvc_normal_000001", "da_ks_microwave_0101x", "da_ks_range_0101x", + "da_ks_oven_01061", "hue_color_temperature_bulb", "hue_rgbw_color_bulb", "c2c_shade", diff --git a/tests/components/smartthings/fixtures/device_status/da_ks_oven_01061.json b/tests/components/smartthings/fixtures/device_status/da_ks_oven_01061.json new file mode 100644 index 00000000000..b8b403ba908 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/da_ks_oven_01061.json @@ -0,0 +1,566 @@ +{ + "components": { + "main": { + "ovenSetpoint": { + "ovenSetpointRange": { + "value": null + }, + "ovenSetpoint": { + "value": 220, + "timestamp": "2025-03-15T12:06:07.818Z" + } + }, + "refresh": {}, + "samsungce.doorState": { + "doorState": { + "value": "closed", + "timestamp": "2025-03-15T09:25:35.157Z" + } + }, + "samsungce.microwavePower": { + "supportedPowerLevels": { + "value": null + }, + "powerLevel": { + "value": "0W", + "timestamp": "2025-03-15T12:06:07.803Z" + } + }, + "samsungce.waterReservoir": { + "slotState": { + "value": null + } + }, + "samsungce.kitchenDeviceDefaults": { + "defaultOperationTime": { + "value": null + }, + "defaultOvenMode": { + "value": "Convection", + "timestamp": "2025-03-15T12:06:07.758Z" + }, + "defaultOvenSetpoint": { + "value": null + } + }, + "execute": { + "data": { + "value": null + } + }, + "samsungce.deviceIdentification": { + "micomAssayCode": { + "value": null + }, + "modelName": { + "value": null + }, + "serialNumber": { + "value": null + }, + "serialNumberExtra": { + "value": null + }, + "modelClassificationCode": { + "value": null + }, + "description": { + "value": null + }, + "releaseYear": { + "value": null + }, + "binaryId": { + "value": "TP1X_DA-KS-OVEN-01061", + "timestamp": "2025-03-13T20:35:02.073Z" + } + }, + "samsungce.ovenDrainageRequirement": { + "drainageRequirement": { + "value": null + } + }, + "ocf": { + "st": { + "value": null + }, + "mndt": { + "value": null + }, + "mnfv": { + "value": "AKS-WW-TP1X-21-OVEN_40211229", + "timestamp": "2025-01-08T17:29:14.260Z" + }, + "mnhw": { + "value": "Realtek", + "timestamp": "2025-01-08T17:29:14.260Z" + }, + "di": { + "value": "9447959a-0dfa-6b27-d40d-650da525c53f", + "timestamp": "2025-01-08T17:29:14.260Z" + }, + "mnsl": { + "value": "http://www.samsung.com", + "timestamp": "2025-01-08T17:29:14.260Z" + }, + "dmv": { + "value": "res.1.1.0,sh.1.1.0", + "timestamp": "2025-01-08T17:29:14.260Z" + }, + "n": { + "value": "[oven] Samsung", + "timestamp": "2025-01-08T17:29:14.260Z" + }, + "mnmo": { + "value": "TP1X_DA-KS-OVEN-01061|40457041|50030018001611000A00000000000000", + "timestamp": "2025-01-08T17:29:14.260Z" + }, + "vid": { + "value": "DA-KS-OVEN-01061", + "timestamp": "2025-01-08T17:29:14.260Z" + }, + "mnmn": { + "value": "Samsung Electronics", + "timestamp": "2025-01-08T17:29:14.260Z" + }, + "mnml": { + "value": "http://www.samsung.com", + "timestamp": "2025-01-08T17:29:14.260Z" + }, + "mnpv": { + "value": "DAWIT 3.0", + "timestamp": "2025-01-08T17:29:14.260Z" + }, + "mnos": { + "value": "TizenRT 3.1", + "timestamp": "2025-01-08T17:29:14.260Z" + }, + "pi": { + "value": "9447959a-0dfa-6b27-d40d-650da525c53f", + "timestamp": "2025-01-08T17:29:14.260Z" + }, + "icv": { + "value": "core.1.1.0", + "timestamp": "2025-01-08T17:29:14.260Z" + } + }, + "remoteControlStatus": { + "remoteControlEnabled": { + "value": "true", + "timestamp": "2025-03-15T09:47:55.406Z" + } + }, + "samsungce.kitchenDeviceIdentification": { + "regionCode": { + "value": "EU", + "timestamp": "2025-03-15T12:06:07.758Z" + }, + "modelCode": { + "value": "NQ7000B-/EU7", + "timestamp": "2025-03-15T12:06:07.758Z" + }, + "fuel": { + "value": null + }, + "type": { + "value": "oven", + "timestamp": "2025-01-08T17:29:12.924Z" + }, + "representativeComponent": { + "value": null + } + }, + "samsungce.kitchenModeSpecification": { + "specification": { + "value": { + "single": [ + { + "mode": "NoOperation", + "supportedOperations": [], + "supportedOptions": {} + }, + { + "mode": "Autocook", + "supportedOperations": [], + "supportedOptions": {} + }, + { + "mode": "Convection", + "supportedOperations": ["start", "set"], + "supportedOptions": { + "temperature": { + "C": { + "min": 40, + "max": 230, + "default": 160, + "resolution": 5 + } + }, + "operationTime": { + "min": "00:01:00", + "max": "10:00:00", + "default": "01:00:00", + "resolution": "00:01:00" + } + } + }, + { + "mode": "FanConventional", + "supportedOperations": ["start", "set"], + "supportedOptions": { + "temperature": { + "C": { + "min": 40, + "max": 230, + "default": 180, + "resolution": 5 + } + }, + "operationTime": { + "min": "00:01:00", + "max": "10:00:00", + "default": "01:00:00", + "resolution": "00:01:00" + } + } + }, + { + "mode": "LargeGrill", + "supportedOperations": ["start", "set"], + "supportedOptions": { + "temperature": { + "C": { + "min": 150, + "max": 230, + "default": 220, + "resolution": 5 + } + }, + "operationTime": { + "min": "00:01:00", + "max": "10:00:00", + "default": "01:00:00", + "resolution": "00:01:00" + } + } + }, + { + "mode": "FanGrill", + "supportedOperations": ["start", "set"], + "supportedOptions": { + "temperature": { + "C": { + "min": 40, + "max": 230, + "default": 180, + "resolution": 5 + } + }, + "operationTime": { + "min": "00:01:00", + "max": "10:00:00", + "default": "01:00:00", + "resolution": "00:01:00" + } + } + }, + { + "mode": "MicroWaveGrill", + "supportedOperations": ["set"], + "supportedOptions": { + "temperature": { + "C": { + "min": 40, + "max": 200, + "default": 200, + "resolution": 5 + } + }, + "operationTime": { + "min": "00:00:10", + "max": "01:30:00", + "default": "00:00:30", + "resolution": "00:00:10" + }, + "powerLevel": { + "default": "300W", + "supportedValues": ["100W", "180W", "300W", "450W", "600W"] + } + } + }, + { + "mode": "MicroWaveConvection", + "supportedOperations": ["set"], + "supportedOptions": { + "temperature": { + "C": { + "min": 40, + "max": 200, + "default": 180, + "resolution": 5 + } + }, + "operationTime": { + "min": "00:00:10", + "max": "01:30:00", + "default": "00:00:30", + "resolution": "00:00:10" + }, + "powerLevel": { + "default": "300W", + "supportedValues": ["100W", "180W", "300W", "450W", "600W"] + } + } + }, + { + "mode": "AirFryer", + "supportedOperations": ["start", "set"], + "supportedOptions": { + "temperature": { + "C": { + "min": 150, + "max": 230, + "default": 220, + "resolution": 5 + } + }, + "operationTime": { + "min": "00:01:00", + "max": "10:00:00", + "default": "01:00:00", + "resolution": "00:01:00" + } + } + }, + { + "mode": "MicroWave", + "supportedOperations": ["set"], + "supportedOptions": { + "operationTime": { + "min": "00:00:10", + "max": "01:30:00", + "default": "00:00:30", + "resolution": "00:00:10" + }, + "powerLevel": { + "default": "800W", + "supportedValues": [ + "100W", + "180W", + "300W", + "450W", + "600W", + "700W", + "800W" + ] + } + } + }, + { + "mode": "Deodorization", + "supportedOperations": ["start", "set"], + "supportedOptions": { + "operationTime": { + "min": "00:00:10", + "max": "00:15:00", + "default": "00:05:00", + "resolution": "00:00:10" + } + } + }, + { + "mode": "KeepWarm", + "supportedOperations": ["start", "set"], + "supportedOptions": { + "temperature": { + "C": { + "min": 60, + "max": 100, + "default": 60, + "resolution": 5 + } + }, + "operationTime": { + "min": "00:01:00", + "max": "10:00:00", + "default": "01:00:00", + "resolution": "00:01:00" + } + } + }, + { + "mode": "SteamClean", + "supportedOperations": ["set"], + "supportedOptions": {} + } + ] + }, + "timestamp": "2025-01-08T17:29:14.757Z" + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": [ + "samsungce.waterReservoir", + "samsungce.ovenDrainageRequirement" + ], + "timestamp": "2025-03-15T12:06:07.758Z" + } + }, + "samsungce.definedRecipe": { + "definedRecipe": { + "value": { + "cavityId": "0", + "recipeType": "0", + "categoryId": 0, + "itemId": 0, + "servingSize": 0, + "browingLevel": 0, + "option": 0 + }, + "timestamp": "2025-03-15T12:06:07.803Z" + } + }, + "samsungce.driverVersion": { + "versionNumber": { + "value": 22100101, + "timestamp": "2025-01-08T17:29:12.924Z" + } + }, + "samsungce.softwareUpdate": { + "targetModule": { + "value": null + }, + "otnDUID": { + "value": "43CB2ZD4VUEGW", + "timestamp": "2025-03-13T20:35:02.073Z" + }, + "lastUpdatedDate": { + "value": null + }, + "availableModules": { + "value": [], + "timestamp": "2025-03-13T20:35:02.073Z" + }, + "newVersionAvailable": { + "value": false, + "timestamp": "2025-03-13T20:35:02.073Z" + }, + "operatingState": { + "value": null + }, + "progress": { + "value": null + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": 30, + "unit": "C", + "timestamp": "2025-03-15T12:06:32.918Z" + } + }, + "samsungce.ovenOperatingState": { + "completionTime": { + "value": "2025-03-15T12:06:09.550Z", + "timestamp": "2025-03-15T12:06:09.554Z" + }, + "operatingState": { + "value": "running", + "timestamp": "2025-03-15T12:06:07.866Z" + }, + "progress": { + "value": 0, + "timestamp": "2025-03-15T12:06:07.866Z" + }, + "ovenJobState": { + "value": "preheat", + "timestamp": "2025-03-15T12:06:07.803Z" + }, + "operationTime": { + "value": "00:00:00", + "timestamp": "2025-03-15T12:06:07.866Z" + } + }, + "ovenMode": { + "supportedOvenModes": { + "value": ["Others", "Bake", "Broil", "ConvectionBroil", "warming"], + "timestamp": "2025-01-08T17:29:14.757Z" + }, + "ovenMode": { + "value": "Bake", + "timestamp": "2025-03-15T12:06:07.758Z" + } + }, + "ovenOperatingState": { + "completionTime": { + "value": "2025-03-15T12:06:09.550Z", + "timestamp": "2025-03-15T12:06:09.554Z" + }, + "machineState": { + "value": "running", + "timestamp": "2025-03-15T12:06:07.866Z" + }, + "progress": { + "value": 0, + "unit": "%", + "timestamp": "2025-03-15T12:06:07.866Z" + }, + "supportedMachineStates": { + "value": null + }, + "ovenJobState": { + "value": "preheat", + "timestamp": "2025-03-15T12:06:07.803Z" + }, + "operationTime": { + "value": 0, + "timestamp": "2025-03-15T12:06:07.866Z" + } + }, + "samsungce.ovenMode": { + "supportedOvenModes": { + "value": [ + "NoOperation", + "Autocook", + "Convection", + "FanConventional", + "LargeGrill", + "FanGrill", + "MicroWaveGrill", + "MicroWaveConvection", + "AirFryer", + "MicroWave", + "Deodorization", + "KeepWarm", + "SteamClean" + ], + "timestamp": "2025-01-08T17:29:14.757Z" + }, + "ovenMode": { + "value": "Convection", + "timestamp": "2025-03-15T12:06:07.758Z" + } + }, + "samsungce.lamp": { + "brightnessLevel": { + "value": "high", + "timestamp": "2025-03-15T12:06:07.956Z" + }, + "supportedBrightnessLevel": { + "value": ["off", "high"], + "timestamp": "2025-03-15T12:06:07.758Z" + } + }, + "samsungce.kidsLock": { + "lockState": { + "value": "unlocked", + "timestamp": "2025-03-13T20:35:02.170Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/devices/da_ks_oven_01061.json b/tests/components/smartthings/fixtures/devices/da_ks_oven_01061.json new file mode 100644 index 00000000000..e82e28d2275 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/da_ks_oven_01061.json @@ -0,0 +1,153 @@ +{ + "items": [ + { + "deviceId": "9447959a-0dfa-6b27-d40d-650da525c53f", + "name": "[oven] Samsung", + "label": "Oven", + "manufacturerName": "Samsung Electronics", + "presentationId": "DA-KS-OVEN-01061", + "deviceManufacturerCode": "Samsung Electronics", + "locationId": "a81dc8da-5a3f-43b6-8c8a-1309f37eeeb9", + "ownerId": "97ee2149-9de0-3287-8245-24d6fd1609aa", + "roomId": "eb2167dd-8b8d-4131-b59e-5dd391b2e151", + "deviceTypeName": "Samsung OCF Oven", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "ocf", + "version": 1 + }, + { + "id": "execute", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "remoteControlStatus", + "version": 1 + }, + { + "id": "ovenSetpoint", + "version": 1 + }, + { + "id": "ovenMode", + "version": 1 + }, + { + "id": "ovenOperatingState", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "samsungce.deviceIdentification", + "version": 1 + }, + { + "id": "samsungce.doorState", + "version": 1 + }, + { + "id": "samsungce.definedRecipe", + "version": 1 + }, + { + "id": "samsungce.kitchenDeviceIdentification", + "version": 1 + }, + { + "id": "samsungce.kitchenDeviceDefaults", + "version": 1 + }, + { + "id": "samsungce.driverVersion", + "version": 1 + }, + { + "id": "samsungce.ovenMode", + "version": 1 + }, + { + "id": "samsungce.ovenOperatingState", + "version": 1 + }, + { + "id": "samsungce.microwavePower", + "version": 1 + }, + { + "id": "samsungce.lamp", + "version": 1 + }, + { + "id": "samsungce.kitchenModeSpecification", + "version": 1 + }, + { + "id": "samsungce.kidsLock", + "version": 1 + }, + { + "id": "samsungce.softwareUpdate", + "version": 1 + }, + { + "id": "samsungce.waterReservoir", + "version": 1 + }, + { + "id": "samsungce.ovenDrainageRequirement", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + } + ], + "categories": [ + { + "name": "Oven", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2025-01-08T17:29:12.549Z", + "profile": { + "id": "eb34598f-f96a-3420-a90a-71693052eaa3" + }, + "ocf": { + "ocfDeviceType": "oic.d.oven", + "name": "[oven] Samsung", + "specVersion": "core.1.1.0", + "verticalDomainSpecVersion": "res.1.1.0,sh.1.1.0", + "manufacturerName": "Samsung Electronics", + "modelNumber": "TP1X_DA-KS-OVEN-01061|40457041|50030018001611000A00000000000000", + "platformVersion": "DAWIT 3.0", + "platformOS": "TizenRT 3.1", + "hwVersion": "Realtek", + "firmwareVersion": "AKS-WW-TP1X-21-OVEN_40211229", + "vendorId": "DA-KS-OVEN-01061", + "vendorResourceClientServerVersion": "Realtek Release 3.1.211122", + "lastSignupTime": "2025-01-08T17:29:08.536664213Z", + "transferCandidate": false, + "additionalAuthCodeRequired": false + }, + "type": "OCF", + "restrictionTier": 0, + "allowed": null, + "executionContext": "CLOUD", + "relationships": [] + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/snapshots/test_init.ambr b/tests/components/smartthings/snapshots/test_init.ambr index 825ab49e814..e4db4742a3b 100644 --- a/tests/components/smartthings/snapshots/test_init.ambr +++ b/tests/components/smartthings/snapshots/test_init.ambr @@ -398,6 +398,39 @@ 'via_device_id': None, }) # --- +# name: test_devices[da_ks_oven_01061] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': 'Realtek', + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '9447959a-0dfa-6b27-d40d-650da525c53f', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Samsung Electronics', + 'model': 'TP1X_DA-KS-OVEN-01061', + 'model_id': None, + 'name': 'Oven', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': 'AKS-WW-TP1X-21-OVEN_40211229', + 'via_device_id': None, + }) +# --- # name: test_devices[da_ks_range_0101x] DeviceRegistryEntrySnapshot({ 'area_id': None, diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index 98e619596fd..b6d7bd80333 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -2085,6 +2085,404 @@ 'state': '-17', }) # --- +# name: test_all_entities[da_ks_oven_01061][sensor.oven_completion_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.oven_completion_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Completion time', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'completion_time', + 'unique_id': '9447959a-0dfa-6b27-d40d-650da525c53f.completionTime', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_oven_01061][sensor.oven_completion_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Oven Completion time', + }), + 'context': , + 'entity_id': 'sensor.oven_completion_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2025-03-15T12:06:09+00:00', + }) +# --- +# name: test_all_entities[da_ks_oven_01061][sensor.oven_job_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'cleaning', + 'cooking', + 'cooling', + 'draining', + 'preheat', + 'ready', + 'rinsing', + 'finished', + 'scheduled_start', + 'warming', + 'defrosting', + 'sensing', + 'searing', + 'fast_preheat', + 'scheduled_end', + 'stone_heating', + 'time_hold_preheat', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.oven_job_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Job state', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'oven_job_state', + 'unique_id': '9447959a-0dfa-6b27-d40d-650da525c53f.ovenJobState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_oven_01061][sensor.oven_job_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Oven Job state', + 'options': list([ + 'cleaning', + 'cooking', + 'cooling', + 'draining', + 'preheat', + 'ready', + 'rinsing', + 'finished', + 'scheduled_start', + 'warming', + 'defrosting', + 'sensing', + 'searing', + 'fast_preheat', + 'scheduled_end', + 'stone_heating', + 'time_hold_preheat', + ]), + }), + 'context': , + 'entity_id': 'sensor.oven_job_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'preheat', + }) +# --- +# name: test_all_entities[da_ks_oven_01061][sensor.oven_machine_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'ready', + 'running', + 'paused', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.oven_machine_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Machine state', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'oven_machine_state', + 'unique_id': '9447959a-0dfa-6b27-d40d-650da525c53f.machineState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_oven_01061][sensor.oven_machine_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Oven Machine state', + 'options': list([ + 'ready', + 'running', + 'paused', + ]), + }), + 'context': , + 'entity_id': 'sensor.oven_machine_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'running', + }) +# --- +# name: test_all_entities[da_ks_oven_01061][sensor.oven_oven_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'conventional', + 'bake', + 'bottom_heat', + 'convection_bake', + 'convection_roast', + 'broil', + 'convection_broil', + 'steam_cook', + 'steam_bake', + 'steam_roast', + 'steam_bottom_heat_plus_convection', + 'microwave', + 'microwave_plus_grill', + 'microwave_plus_convection', + 'microwave_plus_hot_blast', + 'microwave_plus_hot_blast_2', + 'slim_middle', + 'slim_strong', + 'slow_cook', + 'proof', + 'dehydrate', + 'others', + 'strong_steam', + 'descale', + 'rinse', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.oven_oven_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Oven mode', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'oven_mode', + 'unique_id': '9447959a-0dfa-6b27-d40d-650da525c53f.ovenMode', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_oven_01061][sensor.oven_oven_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Oven Oven mode', + 'options': list([ + 'conventional', + 'bake', + 'bottom_heat', + 'convection_bake', + 'convection_roast', + 'broil', + 'convection_broil', + 'steam_cook', + 'steam_bake', + 'steam_roast', + 'steam_bottom_heat_plus_convection', + 'microwave', + 'microwave_plus_grill', + 'microwave_plus_convection', + 'microwave_plus_hot_blast', + 'microwave_plus_hot_blast_2', + 'slim_middle', + 'slim_strong', + 'slow_cook', + 'proof', + 'dehydrate', + 'others', + 'strong_steam', + 'descale', + 'rinse', + ]), + }), + 'context': , + 'entity_id': 'sensor.oven_oven_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'bake', + }) +# --- +# name: test_all_entities[da_ks_oven_01061][sensor.oven_set_point-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.oven_set_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': 'Set point', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'oven_setpoint', + 'unique_id': '9447959a-0dfa-6b27-d40d-650da525c53f.ovenSetpoint', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ks_oven_01061][sensor.oven_set_point-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Oven Set point', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.oven_set_point', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '220', + }) +# --- +# name: test_all_entities[da_ks_oven_01061][sensor.oven_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.oven_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': 'Temperature', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '9447959a-0dfa-6b27-d40d-650da525c53f.temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ks_oven_01061][sensor.oven_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Oven Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.oven_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '30', + }) +# --- # name: test_all_entities[da_ks_range_0101x][sensor.vulcan_completion_time-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 89e75367311adc7066388e26d5416b4b653e6d1c Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Sat, 15 Mar 2025 15:38:45 +0100 Subject: [PATCH 2640/3148] Add missing translations for `options` attribute in Nettigo Air Monitor integration (#140662) Add missing translations for options attribute --- homeassistant/components/nam/strings.json | 33 +++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/homeassistant/components/nam/strings.json b/homeassistant/components/nam/strings.json index 22fb1dc30d2..be9fb1fbb07 100644 --- a/homeassistant/components/nam/strings.json +++ b/homeassistant/components/nam/strings.json @@ -101,6 +101,17 @@ "medium": "Medium", "high": "High", "very_high": "Very high" + }, + "state_attributes": { + "options": { + "state": { + "very_low": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::very_low%]", + "low": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::low%]", + "medium": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::medium%]", + "high": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::high%]", + "very_high": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::very_high%]" + } + } } }, "pmsx003_pm1": { @@ -123,6 +134,17 @@ "medium": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::medium%]", "high": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::high%]", "very_high": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::very_high%]" + }, + "state_attributes": { + "options": { + "state": { + "very_low": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::very_low%]", + "low": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::low%]", + "medium": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::medium%]", + "high": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::high%]", + "very_high": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::very_high%]" + } + } } }, "sds011_pm10": { @@ -148,6 +170,17 @@ "medium": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::medium%]", "high": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::high%]", "very_high": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::very_high%]" + }, + "state_attributes": { + "options": { + "state": { + "very_low": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::very_low%]", + "low": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::low%]", + "medium": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::medium%]", + "high": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::high%]", + "very_high": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::very_high%]" + } + } } }, "sps30_pm1": { From 58ff593f96f8f751207728da269712285f847523 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Sat, 15 Mar 2025 17:11:04 +0100 Subject: [PATCH 2641/3148] Bump `aioshelly` to version 13.4.0 (#140671) Bump aioshelly to version 13.4.0 --- homeassistant/components/shelly/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/shelly/manifest.json b/homeassistant/components/shelly/manifest.json index c9cbd778e95..e863720e476 100644 --- a/homeassistant/components/shelly/manifest.json +++ b/homeassistant/components/shelly/manifest.json @@ -8,7 +8,7 @@ "integration_type": "device", "iot_class": "local_push", "loggers": ["aioshelly"], - "requirements": ["aioshelly==13.3.0"], + "requirements": ["aioshelly==13.4.0"], "zeroconf": [ { "type": "_http._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index 8e52f822de3..67a7a1e8c1f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -371,7 +371,7 @@ aioruuvigateway==0.1.0 aiosenz==1.0.0 # homeassistant.components.shelly -aioshelly==13.3.0 +aioshelly==13.4.0 # homeassistant.components.skybell aioskybell==22.7.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2960379bda0..b80ad271ffa 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -353,7 +353,7 @@ aioruuvigateway==0.1.0 aiosenz==1.0.0 # homeassistant.components.shelly -aioshelly==13.3.0 +aioshelly==13.4.0 # homeassistant.components.skybell aioskybell==22.7.0 From 2fd91e7f9c940923f6332d9db432a11ff06add26 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Sat, 15 Mar 2025 18:10:35 +0100 Subject: [PATCH 2642/3148] Remove unknown from Shelly sensor state (#140597) --- homeassistant/components/shelly/sensor.py | 6 +++--- homeassistant/components/shelly/strings.json | 5 +---- tests/components/shelly/conftest.py | 9 +++++++- tests/components/shelly/test_sensor.py | 22 +++++++++++++++++--- 4 files changed, 31 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/shelly/sensor.py b/homeassistant/components/shelly/sensor.py index 183a1aa06a1..0020c6e0614 100644 --- a/homeassistant/components/shelly/sensor.py +++ b/homeassistant/components/shelly/sensor.py @@ -374,9 +374,9 @@ SENSORS: dict[tuple[str, str], BlockSensorDescription] = { key="sensor|sensorOp", name="Operation", device_class=SensorDeviceClass.ENUM, - options=["unknown", "warmup", "normal", "fault"], + options=["warmup", "normal", "fault"], translation_key="operation", - value=lambda value: value, + value=lambda value: None if value == "unknown" else value, extra_state_attributes=lambda block: {"self_test": block.selfTest}, ), ("valve", "valve"): BlockSensorDescription( @@ -391,8 +391,8 @@ SENSORS: dict[tuple[str, str], BlockSensorDescription] = { "failure", "opened", "opening", - "unknown", ], + value=lambda value: None if value == "unknown" else value, entity_category=EntityCategory.DIAGNOSTIC, removal_condition=lambda _, block: block.valve == "not_connected", ), diff --git a/homeassistant/components/shelly/strings.json b/homeassistant/components/shelly/strings.json index eb869b54e4c..cc511c93afe 100644 --- a/homeassistant/components/shelly/strings.json +++ b/homeassistant/components/shelly/strings.json @@ -106,7 +106,6 @@ "state_attributes": { "detected": { "state": { - "unknown": "Unknown", "none": "None", "mild": "Mild", "heavy": "Heavy", @@ -141,7 +140,6 @@ "sensor": { "operation": { "state": { - "unknown": "Unknown", "warmup": "Warm-up", "normal": "Normal", "fault": "Fault" @@ -164,8 +162,7 @@ "closing": "Closing", "failure": "Failure", "opened": "Opened", - "opening": "Opening", - "unknown": "[%key:component::shelly::entity::sensor::operation::state::unknown%]" + "opening": "Opening" } } } diff --git a/tests/components/shelly/conftest.py b/tests/components/shelly/conftest.py index 8ea04ea3bfb..5c0f912b72d 100644 --- a/tests/components/shelly/conftest.py +++ b/tests/components/shelly/conftest.py @@ -134,11 +134,18 @@ MOCK_BLOCKS = [ set_state=AsyncMock(side_effect=mock_light_set_state), ), Mock( - sensor_ids={"motion": 0, "temp": 22.1, "gas": "mild", "motionActive": 1}, + sensor_ids={ + "motion": 0, + "temp": 22.1, + "gas": "mild", + "motionActive": 1, + "sensorOp": "normal", + }, channel="0", motion=0, temp=22.1, gas="mild", + sensorOp="normal", targetTemp=4, description="sensor_0", type="sensor", diff --git a/tests/components/shelly/test_sensor.py b/tests/components/shelly/test_sensor.py index d0fec65c7de..d37a146e314 100644 --- a/tests/components/shelly/test_sensor.py +++ b/tests/components/shelly/test_sensor.py @@ -345,14 +345,30 @@ async def test_block_sensor_without_value( assert hass.states.get(entity_id) is None +@pytest.mark.parametrize( + ("entity", "initial_state", "block_id", "attribute", "value"), + [ + ("test_name_battery", "98", DEVICE_BLOCK_ID, "battery", None), + ("test_name_operation", "normal", SENSOR_BLOCK_ID, "sensorOp", "unknown"), + ], +) async def test_block_sensor_unknown_value( - hass: HomeAssistant, mock_block_device: Mock, monkeypatch: pytest.MonkeyPatch + hass: HomeAssistant, + mock_block_device: Mock, + monkeypatch: pytest.MonkeyPatch, + entity: str, + initial_state: str, + block_id: int, + attribute: str, + value: str | None, ) -> None: """Test block sensor unknown value.""" - entity_id = f"{SENSOR_DOMAIN}.test_name_battery" + entity_id = f"{SENSOR_DOMAIN}.{entity}" await init_integration(hass, 1) - monkeypatch.setattr(mock_block_device.blocks[DEVICE_BLOCK_ID], "battery", None) + assert hass.states.get(entity_id).state == initial_state + + monkeypatch.setattr(mock_block_device.blocks[block_id], attribute, value) mock_block_device.mock_update() assert hass.states.get(entity_id).state == STATE_UNKNOWN From c1c8deed0ccfc8dd33e44f7efc332b26f739ccea Mon Sep 17 00:00:00 2001 From: EnjoyingM <6302356+mtielen@users.noreply.github.com> Date: Sat, 15 Mar 2025 19:56:45 +0100 Subject: [PATCH 2643/3148] Fix sensor values for Power and Energy for Wolf Heatpumps (#139007) * Add sensor values for Power and Energy * test * test * Sensor test * Fix test * fix test * Fixing test coverage * refactored * WolfllinkSensorEntityDescriptions and updated tests * fix test * Add name_fn and test_sensor adoptions * fix test coverage * Revert "fix test coverage" This reverts commit 2405751f5a9d0d5be67b78b39a510240a794a7e5. * resolve requested changes and fix test * Fix Snapshot * clean up * Fixed unknown state in snapshot test --- homeassistant/components/wolflink/sensor.py | 178 ++++--- tests/components/wolflink/__init__.py | 13 + tests/components/wolflink/conftest.py | 109 +++++ .../wolflink/snapshots/test_sensor.ambr | 445 ++++++++++++++++++ tests/components/wolflink/test_sensor.py | 45 ++ 5 files changed, 721 insertions(+), 69 deletions(-) create mode 100644 tests/components/wolflink/conftest.py create mode 100644 tests/components/wolflink/snapshots/test_sensor.ambr create mode 100644 tests/components/wolflink/test_sensor.py diff --git a/homeassistant/components/wolflink/sensor.py b/homeassistant/components/wolflink/sensor.py index cf6d712dd0d..0f58817a38d 100644 --- a/homeassistant/components/wolflink/sensor.py +++ b/homeassistant/components/wolflink/sensor.py @@ -2,19 +2,35 @@ from __future__ import annotations +from collections.abc import Callable +from dataclasses import dataclass + from wolf_comm.models import ( + EnergyParameter, HoursParameter, ListItemParameter, Parameter, PercentageParameter, + PowerParameter, Pressure, SimpleParameter, Temperature, ) -from homeassistant.components.sensor import SensorDeviceClass, SensorEntity +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, +) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import UnitOfPressure, UnitOfTemperature, UnitOfTime +from homeassistant.const import ( + PERCENTAGE, + UnitOfEnergy, + UnitOfPower, + UnitOfPressure, + UnitOfTemperature, + UnitOfTime, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -23,31 +39,88 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import COORDINATOR, DEVICE_ID, DOMAIN, MANUFACTURER, PARAMETERS, STATES +def get_listitem_resolve_state(wolf_object, state): + """Resolve list item state.""" + resolved_state = [item for item in wolf_object.items if item.value == int(state)] + if resolved_state: + resolved_name = resolved_state[0].name + state = STATES.get(resolved_name, resolved_name) + return state + + +@dataclass(kw_only=True, frozen=True) +class WolflinkSensorEntityDescription(SensorEntityDescription): + """Describes Wolflink sensor entity.""" + + value_fn: Callable[[Parameter, str], str | None] = lambda param, value: value + supported_fn: Callable[[Parameter], bool] + + +SENSOR_DESCRIPTIONS = [ + WolflinkSensorEntityDescription( + key="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + supported_fn=lambda param: isinstance(param, Temperature), + ), + WolflinkSensorEntityDescription( + key="pressure", + device_class=SensorDeviceClass.PRESSURE, + native_unit_of_measurement=UnitOfPressure.BAR, + supported_fn=lambda param: isinstance(param, Pressure), + ), + WolflinkSensorEntityDescription( + key="energy", + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + supported_fn=lambda param: isinstance(param, EnergyParameter), + ), + WolflinkSensorEntityDescription( + key="power", + device_class=SensorDeviceClass.POWER, + native_unit_of_measurement=UnitOfPower.KILO_WATT, + supported_fn=lambda param: isinstance(param, PowerParameter), + ), + WolflinkSensorEntityDescription( + key="percentage", + native_unit_of_measurement=PERCENTAGE, + supported_fn=lambda param: isinstance(param, PercentageParameter), + ), + WolflinkSensorEntityDescription( + key="list_item", + translation_key="state", + supported_fn=lambda param: isinstance(param, ListItemParameter), + value_fn=get_listitem_resolve_state, + ), + WolflinkSensorEntityDescription( + key="hours", + icon="mdi:clock", + native_unit_of_measurement=UnitOfTime.HOURS, + supported_fn=lambda param: isinstance(param, HoursParameter), + ), + WolflinkSensorEntityDescription( + key="default", + supported_fn=lambda param: isinstance(param, SimpleParameter), + ), +] + + async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up all entries for Wolf Platform.""" - coordinator = hass.data[DOMAIN][config_entry.entry_id][COORDINATOR] parameters = hass.data[DOMAIN][config_entry.entry_id][PARAMETERS] device_id = hass.data[DOMAIN][config_entry.entry_id][DEVICE_ID] - entities: list[WolfLinkSensor] = [] - for parameter in parameters: - if isinstance(parameter, Temperature): - entities.append(WolfLinkTemperature(coordinator, parameter, device_id)) - if isinstance(parameter, Pressure): - entities.append(WolfLinkPressure(coordinator, parameter, device_id)) - if isinstance(parameter, PercentageParameter): - entities.append(WolfLinkPercentage(coordinator, parameter, device_id)) - if isinstance(parameter, ListItemParameter): - entities.append(WolfLinkState(coordinator, parameter, device_id)) - if isinstance(parameter, HoursParameter): - entities.append(WolfLinkHours(coordinator, parameter, device_id)) - if isinstance(parameter, SimpleParameter): - entities.append(WolfLinkSensor(coordinator, parameter, device_id)) + entities: list[WolfLinkSensor] = [ + WolfLinkSensor(coordinator, parameter, device_id, description) + for parameter in parameters + for description in SENSOR_DESCRIPTIONS + if description.supported_fn(parameter) + ] async_add_entities(entities, True) @@ -55,9 +128,18 @@ async def async_setup_entry( class WolfLinkSensor(CoordinatorEntity, SensorEntity): """Base class for all Wolf entities.""" - def __init__(self, coordinator, wolf_object: Parameter, device_id) -> None: + entity_description: WolflinkSensorEntityDescription + + def __init__( + self, + coordinator, + wolf_object: Parameter, + device_id: str, + description: WolflinkSensorEntityDescription, + ) -> None: """Initialize.""" super().__init__(coordinator) + self.entity_description = description self.wolf_object = wolf_object self._attr_name = wolf_object.name self._attr_unique_id = f"{device_id}:{wolf_object.parameter_id}" @@ -69,68 +151,26 @@ class WolfLinkSensor(CoordinatorEntity, SensorEntity): ) @property - def native_value(self): + def native_value(self) -> str | None: """Return the state. Wolf Client is returning only changed values so we need to store old value here.""" if self.wolf_object.parameter_id in self.coordinator.data: new_state = self.coordinator.data[self.wolf_object.parameter_id] self.wolf_object.value_id = new_state[0] self._state = new_state[1] + if ( + isinstance(self.wolf_object, ListItemParameter) + and self._state is not None + ): + self._state = self.entity_description.value_fn( + self.wolf_object, self._state + ) return self._state @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, str | None]: """Return the state attributes.""" return { "parameter_id": self.wolf_object.parameter_id, "value_id": self.wolf_object.value_id, "parent": self.wolf_object.parent, } - - -class WolfLinkHours(WolfLinkSensor): - """Class for hour based entities.""" - - _attr_icon = "mdi:clock" - _attr_native_unit_of_measurement = UnitOfTime.HOURS - - -class WolfLinkTemperature(WolfLinkSensor): - """Class for temperature based entities.""" - - _attr_device_class = SensorDeviceClass.TEMPERATURE - _attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS - - -class WolfLinkPressure(WolfLinkSensor): - """Class for pressure based entities.""" - - _attr_device_class = SensorDeviceClass.PRESSURE - _attr_native_unit_of_measurement = UnitOfPressure.BAR - - -class WolfLinkPercentage(WolfLinkSensor): - """Class for percentage based entities.""" - - @property - def native_unit_of_measurement(self): - """Return the unit the value is expressed in.""" - return self.wolf_object.unit - - -class WolfLinkState(WolfLinkSensor): - """Class for entities which has defined list of state.""" - - _attr_translation_key = "state" - - @property - def native_value(self): - """Return the state converting with supported values.""" - state = super().native_value - if state is not None: - resolved_state = [ - item for item in self.wolf_object.items if item.value == int(state) - ] - if resolved_state: - resolved_name = resolved_state[0].name - return STATES.get(resolved_name, resolved_name) - return state diff --git a/tests/components/wolflink/__init__.py b/tests/components/wolflink/__init__.py index dea7c5195ad..11c82ad9f61 100644 --- a/tests/components/wolflink/__init__.py +++ b/tests/components/wolflink/__init__.py @@ -1 +1,14 @@ """Tests for the Wolf SmartSet Service integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Set up the wolflink integration for testing.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/wolflink/conftest.py b/tests/components/wolflink/conftest.py new file mode 100644 index 00000000000..9c69c0d69bb --- /dev/null +++ b/tests/components/wolflink/conftest.py @@ -0,0 +1,109 @@ +"""Fixtures for Wolflink integration tests.""" + +from __future__ import annotations + +from collections.abc import Generator +from unittest.mock import MagicMock, patch + +import pytest +from wolf_comm import ( + EnergyParameter, + HoursParameter, + ListItem, + ListItemParameter, + PercentageParameter, + PowerParameter, + Pressure, + SimpleParameter, + Temperature, + Value, +) + +from homeassistant.components.wolflink.const import ( + DEVICE_GATEWAY, + DEVICE_ID, + DEVICE_NAME, + DOMAIN, +) +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant + +from . import setup_integration + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + title="Wolf SmartSet", + domain=DOMAIN, + data={ + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + DEVICE_NAME: "test-device", + DEVICE_GATEWAY: "5678", + DEVICE_ID: "1234", + }, + unique_id="1234", + version=1, + minor_version=2, + ) + + +@pytest.fixture +def mock_wolflink() -> Generator[MagicMock]: + """Return a mocked wolflink client.""" + with ( + patch( + "homeassistant.components.wolflink.WolfClient", autospec=True + ) as wolflink_mock, + patch( + "homeassistant.components.wolflink.config_flow.WolfClient", + new=wolflink_mock, + ), + ): + wolflink = wolflink_mock.return_value + + wolflink.fetch_parameters.return_value = [ + EnergyParameter(6002800000, "Energy Parameter", "Heating", 6005200000), + ListItemParameter( + 8002800000, + "List Item Parameter", + "Heating", + [ListItem("0", "Aus"), ListItem("1", "Ein")], + 8005200000, + ), + PowerParameter(5002800000, "Power Parameter", "Heating", 5005200000), + Pressure(4002800000, "Pressure Parameter", "Heating", 4005200000), + Temperature(3002800000, "Temperature Parameter", "Solar", 3005200000), + PercentageParameter( + 2002800000, "Percentage Parameter", "Solar", 2005200000 + ), + HoursParameter(7002800000, "Hours Parameter", "Heating", 7005200000), + SimpleParameter(1002800000, "Simple Parameter", "DHW", 1005200000), + ] + + wolflink.fetch_value.return_value = [ + Value(6002800000, "183", 1), + Value(8002800000, "1", 1), + Value(5002800000, "50", 1), + Value(4002800000, "3", 1), + Value(3002800000, "65", 1), + Value(2002800000, "20", 1), + Value(7002800000, "10", 1), + Value(1002800000, "12", 1), + ] + + yield wolflink + + +@pytest.fixture +async def init_integration( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_wolflink: MagicMock +) -> MockConfigEntry: + """Set up the Wolflink integration for testing.""" + await setup_integration(hass, mock_config_entry) + + return mock_config_entry diff --git a/tests/components/wolflink/snapshots/test_sensor.ambr b/tests/components/wolflink/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..6fdccfb303c --- /dev/null +++ b/tests/components/wolflink/snapshots/test_sensor.ambr @@ -0,0 +1,445 @@ +# serializer version: 1 +# name: test_device_entry + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://www.wolf-smartset.com/', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'wolflink', + '1234', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'WOLF GmbH', + 'model': None, + 'model_id': None, + 'name': 'Wolf SmartSet', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_sensors[sensor.energy_parameter-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energy_parameter', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy Parameter', + 'platform': 'wolflink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234:6005200000', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.energy_parameter-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Parameter', + 'parameter_id': 6005200000, + 'parent': 'Heating', + 'unit_of_measurement': , + 'value_id': 6002800000, + }), + 'context': , + 'entity_id': 'sensor.energy_parameter', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '183', + }) +# --- +# name: test_sensors[sensor.hours_parameter-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.hours_parameter', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:clock', + 'original_name': 'Hours Parameter', + 'platform': 'wolflink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234:7005200000', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.hours_parameter-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Hours Parameter', + 'icon': 'mdi:clock', + 'parameter_id': 7005200000, + 'parent': 'Heating', + 'unit_of_measurement': , + 'value_id': 7002800000, + }), + 'context': , + 'entity_id': 'sensor.hours_parameter', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10', + }) +# --- +# name: test_sensors[sensor.list_item_parameter-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.list_item_parameter', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'List Item Parameter', + 'platform': 'wolflink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'state', + 'unique_id': '1234:8005200000', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.list_item_parameter-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'List Item Parameter', + 'parameter_id': 8005200000, + 'parent': 'Heating', + 'value_id': 8002800000, + }), + 'context': , + 'entity_id': 'sensor.list_item_parameter', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'ein', + }) +# --- +# name: test_sensors[sensor.percentage_parameter-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.percentage_parameter', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Percentage Parameter', + 'platform': 'wolflink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234:2005200000', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.percentage_parameter-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Percentage Parameter', + 'parameter_id': 2005200000, + 'parent': 'Solar', + 'unit_of_measurement': '%', + 'value_id': 2002800000, + }), + 'context': , + 'entity_id': 'sensor.percentage_parameter', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '20', + }) +# --- +# name: test_sensors[sensor.power_parameter-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.power_parameter', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power Parameter', + 'platform': 'wolflink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234:5005200000', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.power_parameter-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Power Parameter', + 'parameter_id': 5005200000, + 'parent': 'Heating', + 'unit_of_measurement': , + 'value_id': 5002800000, + }), + 'context': , + 'entity_id': 'sensor.power_parameter', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50', + }) +# --- +# name: test_sensors[sensor.pressure_parameter-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.pressure_parameter', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Pressure Parameter', + 'platform': 'wolflink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234:4005200000', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.pressure_parameter-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pressure', + 'friendly_name': 'Pressure Parameter', + 'parameter_id': 4005200000, + 'parent': 'Heating', + 'unit_of_measurement': , + 'value_id': 4002800000, + }), + 'context': , + 'entity_id': 'sensor.pressure_parameter', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3', + }) +# --- +# name: test_sensors[sensor.simple_parameter-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.simple_parameter', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Simple Parameter', + 'platform': 'wolflink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234:1005200000', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.simple_parameter-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Simple Parameter', + 'parameter_id': 1005200000, + 'parent': 'DHW', + 'value_id': 1002800000, + }), + 'context': , + 'entity_id': 'sensor.simple_parameter', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '12', + }) +# --- +# name: test_sensors[sensor.temperature_parameter-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.temperature_parameter', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature Parameter', + 'platform': 'wolflink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234:3005200000', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.temperature_parameter-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Temperature Parameter', + 'parameter_id': 3005200000, + 'parent': 'Solar', + 'unit_of_measurement': , + 'value_id': 3002800000, + }), + 'context': , + 'entity_id': 'sensor.temperature_parameter', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '65', + }) +# --- diff --git a/tests/components/wolflink/test_sensor.py b/tests/components/wolflink/test_sensor.py new file mode 100644 index 00000000000..8fc78f707d5 --- /dev/null +++ b/tests/components/wolflink/test_sensor.py @@ -0,0 +1,45 @@ +"""Test the Wolf SmartSet Service Sensor platform.""" + +from unittest.mock import MagicMock + +from syrupy import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, patch, snapshot_platform + + +async def test_device_entry( + hass: HomeAssistant, + mock_wolflink: MagicMock, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test device entry creation.""" + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + device = device_registry.async_get_device({(mock_config_entry.domain, "1234")}) + assert device == snapshot + + +async def test_sensors( + hass: HomeAssistant, + mock_wolflink: MagicMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test wolflink sensors.""" + + with patch("homeassistant.components.wolflink.PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) From 02a75edf1da04795c835f153a1b5e0d4a3e9944b Mon Sep 17 00:00:00 2001 From: Jeff Terrace Date: Sat, 15 Mar 2025 15:03:40 -0400 Subject: [PATCH 2644/3148] Add onvif parser support for reolink package and hikvision alarm (#140669) --- homeassistant/components/onvif/parsers.py | 23 ++++++ tests/components/onvif/test_parsers.py | 90 +++++++++++++++++++++++ 2 files changed, 113 insertions(+) diff --git a/homeassistant/components/onvif/parsers.py b/homeassistant/components/onvif/parsers.py index 7544f92292a..e5a731c73f6 100644 --- a/homeassistant/components/onvif/parsers.py +++ b/homeassistant/components/onvif/parsers.py @@ -49,6 +49,7 @@ def local_datetime_or_none(value: str) -> datetime.datetime | None: @PARSERS.register("tns1:VideoSource/MotionAlarm") +@PARSERS.register("tns1:Device/Trigger/tnshik:AlarmIn") async def async_parse_motion_alarm(uid: str, msg) -> Event | None: """Handle parsing event message. @@ -475,6 +476,28 @@ async def async_parse_visitor_detector(uid: str, msg) -> Event | None: ) +@PARSERS.register("tns1:RuleEngine/MyRuleDetector/Package") +async def async_parse_package_detector(uid: str, msg) -> Event | None: + """Handle parsing event message. + + Topic: tns1:RuleEngine/MyRuleDetector/Package + """ + video_source = "" + topic, payload = extract_message(msg) + for source in payload.Source.SimpleItem: + if source.Name == "Source": + video_source = _normalize_video_source(source.Value) + + return Event( + f"{uid}_{topic}_{video_source}", + "Package Detection", + "binary_sensor", + "occupancy", + None, + payload.Data.SimpleItem[0].Value == "true", + ) + + @PARSERS.register("tns1:Device/Trigger/DigitalInput") async def async_parse_digital_input(uid: str, msg) -> Event | None: """Handle parsing event message. diff --git a/tests/components/onvif/test_parsers.py b/tests/components/onvif/test_parsers.py index 70b78fea971..8448a6e8195 100644 --- a/tests/components/onvif/test_parsers.py +++ b/tests/components/onvif/test_parsers.py @@ -789,3 +789,93 @@ async def test_tapo_unknown_type(hass: HomeAssistant) -> None: ) assert event is None + + +async def test_reolink_package(hass: HomeAssistant) -> None: + """Tests reolink package event.""" + event = await get_event( + { + "SubscriptionReference": None, + "Topic": { + "_value_1": "tns1:RuleEngine/MyRuleDetector/Package", + "Dialect": "http://www.onvif.org/ver10/tev/topicExpression/ConcreteSet", + "_attr_1": {}, + }, + "ProducerReference": None, + "Message": { + "_value_1": { + "Source": { + "SimpleItem": [{"Name": "Source", "Value": "000"}], + "ElementItem": [], + "Extension": None, + "_attr_1": None, + }, + "Key": None, + "Data": { + "SimpleItem": [{"Name": "State", "Value": "true"}], + "ElementItem": [], + "Extension": None, + "_attr_1": None, + }, + "Extension": None, + "UtcTime": datetime.datetime( + 2025, 3, 12, 9, 54, 27, tzinfo=datetime.UTC + ), + "PropertyOperation": "Initialized", + "_attr_1": {}, + } + }, + } + ) + + assert event is not None + assert event.name == "Package Detection" + assert event.platform == "binary_sensor" + assert event.device_class == "occupancy" + assert event.value + assert event.uid == (f"{TEST_UID}_tns1:RuleEngine/MyRuleDetector/Package_000") + + +async def test_hikvision_alarm(hass: HomeAssistant) -> None: + """Tests hikvision camera alarm event.""" + event = await get_event( + { + "SubscriptionReference": None, + "Topic": { + "_value_1": "tns1:Device/Trigger/tnshik:AlarmIn", + "Dialect": "http://www.onvif.org/ver10/tev/topicExpression/ConcreteSet", + "_attr_1": {}, + }, + "ProducerReference": None, + "Message": { + "_value_1": { + "Source": { + "SimpleItem": [{"Name": "AlarmInToken", "Value": "AlarmIn_1"}], + "ElementItem": [], + "Extension": None, + "_attr_1": None, + }, + "Key": None, + "Data": { + "SimpleItem": [{"Name": "State", "Value": "true"}], + "ElementItem": [], + "Extension": None, + "_attr_1": None, + }, + "Extension": None, + "UtcTime": datetime.datetime( + 2025, 3, 13, 22, 57, 26, tzinfo=datetime.UTC + ), + "PropertyOperation": "Initialized", + "_attr_1": {}, + } + }, + } + ) + + assert event is not None + assert event.name == "Motion Alarm" + assert event.platform == "binary_sensor" + assert event.device_class == "motion" + assert event.value + assert event.uid == (f"{TEST_UID}_tns1:Device/Trigger/tnshik:AlarmIn_AlarmIn_1") From bff73ee5f8f92a886f79c20fd213074541f2f1e9 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 15 Mar 2025 20:28:04 +0100 Subject: [PATCH 2645/3148] Add EHS test fixture to SmartThings (#140199) --- tests/components/smartthings/conftest.py | 1 + .../device_status/da_sac_ehs_000001_sub.json | 680 ++++++++++++++++++ .../devices/da_sac_ehs_000001_sub.json | 202 ++++++ .../smartthings/snapshots/test_init.ambr | 33 + .../smartthings/snapshots/test_sensor.ambr | 378 ++++++++++ .../smartthings/snapshots/test_switch.ambr | 47 ++ 6 files changed, 1341 insertions(+) create mode 100644 tests/components/smartthings/fixtures/device_status/da_sac_ehs_000001_sub.json create mode 100644 tests/components/smartthings/fixtures/devices/da_sac_ehs_000001_sub.json diff --git a/tests/components/smartthings/conftest.py b/tests/components/smartthings/conftest.py index 8e2956440cb..3e0047e255a 100644 --- a/tests/components/smartthings/conftest.py +++ b/tests/components/smartthings/conftest.py @@ -106,6 +106,7 @@ def mock_smartthings() -> Generator[AsyncMock]: "da_ref_normal_000001", "vd_network_audio_002s", "iphone", + "da_sac_ehs_000001_sub", "da_wm_dw_000001", "da_wm_wd_000001", "da_wm_wd_000001_1", diff --git a/tests/components/smartthings/fixtures/device_status/da_sac_ehs_000001_sub.json b/tests/components/smartthings/fixtures/device_status/da_sac_ehs_000001_sub.json new file mode 100644 index 00000000000..e27c6c3de21 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/da_sac_ehs_000001_sub.json @@ -0,0 +1,680 @@ +{ + "components": { + "main": { + "demandResponseLoadControl": { + "drlcStatus": { + "value": { + "drlcType": 1, + "drlcLevel": -1, + "start": "1970-01-01T00:00:00Z", + "duration": 0, + "override": false + }, + "timestamp": "2025-03-09T08:18:06.394Z" + } + }, + "powerConsumptionReport": { + "powerConsumption": { + "value": { + "energy": 8193810.0, + "deltaEnergy": 0, + "power": 2.539, + "powerEnergy": 0.009404173966911105, + "persistedEnergy": 8193810.0, + "energySaved": 0, + "start": "2025-03-09T11:14:44Z", + "end": "2025-03-09T11:14:57Z" + }, + "timestamp": "2025-03-09T11:14:57.338Z" + } + }, + "samsungce.ehsCycleData": { + "outdoor": { + "value": [ + { + "timestamp": "2025-03-09T02:00:29Z", + "data": "0038003870FF3C3B46020218019A00050000" + }, + { + "timestamp": "2025-03-09T02:05:29Z", + "data": "0034003471FF3C3C46020218019A00050000" + }, + { + "timestamp": "2025-03-09T02:10:29Z", + "data": "002D002D71FF3D3D460201C9019A00050000" + } + ], + "unit": "C", + "timestamp": "2025-03-09T11:11:30.786Z" + }, + "indoor": { + "value": [ + { + "timestamp": "2025-03-09T02:00:29Z", + "data": "5F055C050505002564000000000000000001FFFF00079440" + }, + { + "timestamp": "2025-03-09T02:05:29Z", + "data": "60055E050505002563000000000000000001FFFF00079445" + }, + { + "timestamp": "2025-03-09T02:10:29Z", + "data": "61055F050505002560000000000000000001FFFF0007944B" + } + ], + "unit": "C", + "timestamp": "2025-03-09T11:11:30.786Z" + } + }, + "custom.outingMode": { + "outingMode": { + "value": "off", + "timestamp": "2025-03-09T08:00:05.571Z" + } + }, + "samsungce.ehsThermostat": { + "connectionState": { + "value": "disconnected", + "timestamp": "2025-03-09T08:00:05.562Z" + } + }, + "refresh": {}, + "custom.thermostatSetpointControl": { + "minimumSetpoint": { + "value": 40, + "unit": "C", + "timestamp": "2025-03-09T08:18:06.394Z" + }, + "maximumSetpoint": { + "value": 55, + "unit": "C", + "timestamp": "2025-03-09T08:18:06.394Z" + } + }, + "airConditionerMode": { + "availableAcModes": { + "value": null + }, + "supportedAcModes": { + "value": ["eco", "std", "force"], + "timestamp": "2025-03-09T08:18:06.394Z" + }, + "airConditionerMode": { + "value": "std", + "timestamp": "2025-03-09T08:00:05.562Z" + } + }, + "samsungce.ehsFsvSettings": { + "fsvSettings": { + "value": [ + { + "id": "1031", + "inUse": true, + "resolution": 1, + "type": "temperature", + "minValue": 37, + "maxValue": 70, + "value": 70, + "isValid": true, + "temperatureUnit": "C" + }, + { + "id": "1032", + "inUse": true, + "resolution": 1, + "type": "temperature", + "minValue": 15, + "maxValue": 37, + "value": 25, + "isValid": true, + "temperatureUnit": "C" + }, + { + "id": "1051", + "inUse": true, + "resolution": 1, + "type": "temperature", + "minValue": 50, + "maxValue": 70, + "value": 55, + "isValid": true, + "temperatureUnit": "C" + }, + { + "id": "1052", + "inUse": true, + "resolution": 1, + "type": "temperature", + "minValue": 30, + "maxValue": 40, + "value": 40, + "isValid": true, + "temperatureUnit": "C" + }, + { + "id": "2011", + "inUse": true, + "resolution": 1, + "type": "temperature", + "minValue": -20, + "maxValue": 5, + "value": -3, + "isValid": true, + "temperatureUnit": "C" + }, + { + "id": "2012", + "inUse": true, + "resolution": 1, + "type": "temperature", + "minValue": 10, + "maxValue": 20, + "value": 15, + "isValid": true, + "temperatureUnit": "C" + }, + { + "id": "2021", + "inUse": true, + "resolution": 1, + "type": "temperature", + "minValue": 17, + "maxValue": 70, + "value": 50, + "isValid": true, + "temperatureUnit": "C" + }, + { + "id": "2022", + "inUse": true, + "resolution": 1, + "type": "temperature", + "minValue": 17, + "maxValue": 70, + "value": 32, + "isValid": true, + "temperatureUnit": "C" + }, + { + "id": "2031", + "inUse": true, + "resolution": 1, + "type": "temperature", + "minValue": 17, + "maxValue": 70, + "value": 50, + "isValid": true, + "temperatureUnit": "C" + }, + { + "id": "2032", + "inUse": true, + "resolution": 1, + "type": "temperature", + "minValue": 17, + "maxValue": 70, + "value": 38, + "isValid": true, + "temperatureUnit": "C" + }, + { + "id": "2091", + "inUse": true, + "resolution": 1, + "type": "etc", + "minValue": 0, + "maxValue": 4, + "value": 0, + "isValid": true + }, + { + "id": "2092", + "inUse": true, + "resolution": 1, + "type": "etc", + "minValue": 0, + "maxValue": 4, + "value": 0, + "isValid": true + }, + { + "id": "2093", + "inUse": true, + "resolution": 1, + "type": "etc", + "minValue": 1, + "maxValue": 4, + "value": 4, + "isValid": true + }, + { + "id": "3011", + "inUse": true, + "resolution": 1, + "type": "etc", + "minValue": 0, + "maxValue": 2, + "value": 2, + "isValid": true + }, + { + "id": "3071", + "inUse": true, + "resolution": 1, + "type": "etc", + "minValue": 0, + "maxValue": 1, + "value": 0, + "isValid": true + }, + { + "id": "4011", + "inUse": true, + "resolution": 1, + "type": "etc", + "minValue": 0, + "maxValue": 1, + "value": 0, + "isValid": true + }, + { + "id": "4012", + "inUse": true, + "resolution": 1, + "type": "temperature", + "minValue": -15, + "maxValue": 20, + "value": 0, + "isValid": true, + "temperatureUnit": "C" + }, + { + "id": "4021", + "inUse": true, + "resolution": 1, + "type": "etc", + "minValue": 0, + "maxValue": 2, + "value": 0, + "isValid": true + }, + { + "id": "4042", + "inUse": true, + "resolution": 1, + "type": "temperature", + "minValue": 5, + "maxValue": 15, + "value": 10, + "isValid": true, + "temperatureUnit": "C" + }, + { + "id": "4061", + "inUse": true, + "resolution": 1, + "type": "etc", + "minValue": 0, + "maxValue": 1, + "value": 0, + "isValid": true + } + ], + "timestamp": "2025-03-09T08:18:06.394Z" + } + }, + "execute": { + "data": { + "value": { + "payload": { + "rt": ["x.com.samsung.da.information"], + "if": ["oic.if.a"], + "x.com.samsung.da.modelNum": "SAC_EHS_MONO|220614|61007400001600000400000000000000", + "x.com.samsung.da.description": "EHS", + "x.com.samsung.da.serialNum": "", + "x.com.samsung.da.versionId": "Samsung Electronics", + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.number": "DB91-02102A 2023-01-11", + "x.com.samsung.da.type": "Software", + "x.com.samsung.da.newVersionAvailable": "false", + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "Version" + }, + { + "x.com.samsung.da.number": "DB91-02100A 2020-07-10", + "x.com.samsung.da.type": "Firmware", + "x.com.samsung.da.newVersionAvailable": "false", + "x.com.samsung.da.id": "1", + "x.com.samsung.da.description": "Version" + }, + { + "x.com.samsung.da.number": "DB91-02103B 2022-06-14", + "x.com.samsung.da.type": "Firmware", + "x.com.samsung.da.newVersionAvailable": "false", + "x.com.samsung.da.id": "2", + "x.com.samsung.da.description": "" + }, + { + "x.com.samsung.da.number": "DB91-02450A 2022-07-06", + "x.com.samsung.da.type": "Firmware", + "x.com.samsung.da.newVersionAvailable": "false", + "x.com.samsung.da.id": "3", + "x.com.samsung.da.description": "EHS MONO LOWTEMP" + } + ] + } + }, + "data": { + "href": "/information/vs/0" + }, + "timestamp": "2023-08-02T14:32:28.435Z" + } + }, + "samsungce.deviceIdentification": { + "micomAssayCode": { + "value": null + }, + "modelName": { + "value": null + }, + "serialNumber": { + "value": null + }, + "serialNumberExtra": { + "value": null + }, + "modelClassificationCode": { + "value": null + }, + "description": { + "value": null + }, + "releaseYear": { + "value": null + }, + "binaryId": { + "value": "SAC_EHS_MONO", + "timestamp": "2025-03-09T08:18:06.394Z" + } + }, + "samsungce.sacDisplayCondition": { + "switch": { + "value": "enabled", + "timestamp": "2025-03-09T08:00:05.514Z" + } + }, + "switch": { + "switch": { + "value": "off", + "timestamp": "2025-03-09T11:00:27.522Z" + } + }, + "ocf": { + "st": { + "value": "2025-03-06T08:37:35Z", + "timestamp": "2025-03-09T08:18:05.953Z" + }, + "mndt": { + "value": "", + "timestamp": "2025-03-09T08:18:05.953Z" + }, + "mnfv": { + "value": "20240611.1", + "timestamp": "2025-03-09T08:18:05.953Z" + }, + "mnhw": { + "value": "", + "timestamp": "2025-03-09T08:18:05.953Z" + }, + "di": { + "value": "1f98ebd0-ac48-d802-7f62-000001200100", + "timestamp": "2025-03-09T08:18:05.955Z" + }, + "mnsl": { + "value": "", + "timestamp": "2025-03-09T08:18:05.953Z" + }, + "dmv": { + "value": "res.1.1.0,sh.1.1.0", + "timestamp": "2025-03-09T08:18:05.955Z" + }, + "n": { + "value": "Eco Heating System", + "timestamp": "2025-03-09T08:18:05.955Z" + }, + "mnmo": { + "value": "SAC_EHS_MONO|220614|61007400001600000400000000000000", + "timestamp": "2025-03-09T08:18:06.394Z" + }, + "vid": { + "value": "DA-SAC-EHS-000001-SUB", + "timestamp": "2025-03-09T08:18:05.953Z" + }, + "mnmn": { + "value": "Samsung Electronics", + "timestamp": "2025-03-09T08:18:05.953Z" + }, + "mnml": { + "value": "", + "timestamp": "2025-03-09T08:18:05.953Z" + }, + "mnpv": { + "value": "4.0", + "timestamp": "2025-03-09T08:18:05.953Z" + }, + "mnos": { + "value": "Tizen", + "timestamp": "2025-03-09T08:18:05.953Z" + }, + "pi": { + "value": "1f98ebd0-ac48-d802-7f62-000001200100", + "timestamp": "2025-03-09T08:18:05.953Z" + }, + "icv": { + "value": "core.1.1.0", + "timestamp": "2025-03-09T08:18:05.955Z" + } + }, + "remoteControlStatus": { + "remoteControlEnabled": { + "value": "true", + "timestamp": "2025-03-09T08:18:06.394Z" + } + }, + "custom.energyType": { + "energyType": { + "value": "2.0", + "timestamp": "2025-03-09T08:18:06.394Z" + }, + "energySavingSupport": { + "value": false, + "timestamp": "2023-08-02T14:36:25.480Z" + }, + "drMaxDuration": { + "value": null + }, + "energySavingLevel": { + "value": null + }, + "energySavingInfo": { + "value": null + }, + "supportedEnergySavingLevels": { + "value": null + }, + "energySavingOperation": { + "value": null + }, + "notificationTemplateID": { + "value": null + }, + "energySavingOperationSupport": { + "value": null + } + }, + "samsungce.toggleSwitch": { + "switch": { + "value": "off", + "timestamp": "2025-03-09T11:00:22.880Z" + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": ["remoteControlStatus", "demandResponseLoadControl"], + "timestamp": "2025-03-09T08:31:30.641Z" + } + }, + "samsungce.driverVersion": { + "versionNumber": { + "value": 23070101, + "timestamp": "2023-08-02T14:32:26.195Z" + } + }, + "samsungce.softwareUpdate": { + "targetModule": { + "value": null + }, + "otnDUID": { + "value": null + }, + "lastUpdatedDate": { + "value": null + }, + "availableModules": { + "value": [], + "timestamp": "2025-03-09T08:18:06.394Z" + }, + "newVersionAvailable": { + "value": false, + "timestamp": "2025-03-09T08:18:06.394Z" + }, + "operatingState": { + "value": null + }, + "progress": { + "value": null + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": 54.3, + "unit": "C", + "timestamp": "2025-03-09T10:43:24.134Z" + } + }, + "custom.deviceReportStateConfiguration": { + "reportStateRealtimePeriod": { + "value": "enabled", + "timestamp": "2024-11-08T01:41:37.280Z" + }, + "reportStateRealtime": { + "value": { + "state": "disabled" + }, + "timestamp": "2025-03-08T12:06:55.069Z" + }, + "reportStatePeriod": { + "value": "enabled", + "timestamp": "2024-11-08T01:41:37.280Z" + } + }, + "samsungce.ehsTemperatureReference": { + "temperatureReference": { + "value": "water", + "timestamp": "2025-03-09T07:15:48.438Z" + } + }, + "thermostatCoolingSetpoint": { + "coolingSetpointRange": { + "value": null + }, + "coolingSetpoint": { + "value": 48, + "unit": "C", + "timestamp": "2025-03-09T10:58:50.857Z" + } + } + }, + "INDOOR": { + "samsungce.ehsThermostat": { + "connectionState": { + "value": "disconnected", + "timestamp": "2025-03-09T08:18:06.394Z" + } + }, + "samsungce.toggleSwitch": { + "switch": { + "value": "off", + "timestamp": "2025-03-09T11:14:44.775Z" + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": 39.2, + "unit": "C", + "timestamp": "2025-03-09T11:15:49.852Z" + } + }, + "custom.thermostatSetpointControl": { + "minimumSetpoint": { + "value": 25, + "unit": "C", + "timestamp": "2025-03-09T07:06:20.699Z" + }, + "maximumSetpoint": { + "value": 65, + "unit": "C", + "timestamp": "2025-03-09T07:06:20.699Z" + } + }, + "airConditionerMode": { + "availableAcModes": { + "value": null + }, + "supportedAcModes": { + "value": ["auto", "cool", "heat"], + "timestamp": "2025-03-09T08:18:06.394Z" + }, + "airConditionerMode": { + "value": "heat", + "timestamp": "2025-03-09T08:18:06.394Z" + } + }, + "samsungce.ehsTemperatureReference": { + "temperatureReference": { + "value": "water", + "timestamp": "2025-03-09T07:06:20.699Z" + } + }, + "thermostatCoolingSetpoint": { + "coolingSetpointRange": { + "value": null + }, + "coolingSetpoint": { + "value": 25, + "unit": "C", + "timestamp": "2025-03-09T11:14:44.734Z" + } + }, + "samsungce.sacDisplayCondition": { + "switch": { + "value": "enabled", + "timestamp": "2025-03-09T08:18:06.394Z" + } + }, + "switch": { + "switch": { + "value": "off", + "timestamp": "2025-03-09T11:14:57.238Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/devices/da_sac_ehs_000001_sub.json b/tests/components/smartthings/fixtures/devices/da_sac_ehs_000001_sub.json new file mode 100644 index 00000000000..dffe57b3280 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/da_sac_ehs_000001_sub.json @@ -0,0 +1,202 @@ +{ + "items": [ + { + "deviceId": "1f98ebd0-ac48-d802-7f62-000001200100", + "name": "Eco Heating System", + "label": "Eco Heating System", + "manufacturerName": "Samsung Electronics", + "presentationId": "DA-SAC-EHS-000001-SUB", + "deviceManufacturerCode": "Samsung Electronics", + "locationId": "d22d6401-6070-4928-8e7b-b724e2dbf425", + "ownerId": "35445a41-3ae2-4bc0-6f51-31705de6b96f", + "roomId": "169ef666-a51d-4d74-9b45-e660ecd4a8d7", + "deviceTypeName": "Samsung OCF Air Conditioner", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "ocf", + "version": 1 + }, + { + "id": "switch", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "thermostatCoolingSetpoint", + "version": 1 + }, + { + "id": "airConditionerMode", + "version": 1 + }, + { + "id": "powerConsumptionReport", + "version": 1 + }, + { + "id": "demandResponseLoadControl", + "version": 1 + }, + { + "id": "remoteControlStatus", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "execute", + "version": 1 + }, + { + "id": "custom.energyType", + "version": 1 + }, + { + "id": "custom.outingMode", + "version": 1 + }, + { + "id": "custom.thermostatSetpointControl", + "version": 1 + }, + { + "id": "custom.deviceReportStateConfiguration", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + }, + { + "id": "samsungce.deviceIdentification", + "version": 1 + }, + { + "id": "samsungce.driverVersion", + "version": 1 + }, + { + "id": "samsungce.sacDisplayCondition", + "version": 1 + }, + { + "id": "samsungce.softwareUpdate", + "version": 1 + }, + { + "id": "samsungce.ehsFsvSettings", + "version": 1 + }, + { + "id": "samsungce.ehsCycleData", + "version": 1 + }, + { + "id": "samsungce.ehsTemperatureReference", + "version": 1 + }, + { + "id": "samsungce.ehsThermostat", + "version": 1 + }, + { + "id": "samsungce.toggleSwitch", + "version": 1 + } + ], + "categories": [ + { + "name": "AirConditioner", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "INDOOR", + "label": "INDOOR", + "capabilities": [ + { + "id": "switch", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "thermostatCoolingSetpoint", + "version": 1 + }, + { + "id": "airConditionerMode", + "version": 1 + }, + { + "id": "custom.thermostatSetpointControl", + "version": 1 + }, + { + "id": "samsungce.ehsTemperatureReference", + "version": 1 + }, + { + "id": "samsungce.sacDisplayCondition", + "version": 1 + }, + { + "id": "samsungce.ehsThermostat", + "version": 1 + }, + { + "id": "samsungce.toggleSwitch", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2023-08-02T14:32:26.006Z", + "parentDeviceId": "1f98ebd0-ac48-d802-7f62-12592d8286b7", + "profile": { + "id": "54b9789f-2c8c-310d-9e14-9a84903c792b" + }, + "ocf": { + "ocfDeviceType": "oic.d.airconditioner", + "name": "Eco Heating System", + "specVersion": "core.1.1.0", + "verticalDomainSpecVersion": "1.2.1", + "manufacturerName": "Samsung Electronics", + "modelNumber": "SAC_EHS_MONO|220614|61007400001600000400000000000000", + "platformVersion": "4.0", + "platformOS": "Tizen", + "hwVersion": "", + "firmwareVersion": "20240611.1", + "vendorId": "DA-SAC-EHS-000001-SUB", + "vendorResourceClientServerVersion": "3.2.20", + "lastSignupTime": "2023-08-02T14:32:25.282882Z", + "transferCandidate": false, + "additionalAuthCodeRequired": false + }, + "type": "OCF", + "restrictionTier": 0, + "allowed": null, + "executionContext": "CLOUD", + "relationships": [] + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/snapshots/test_init.ambr b/tests/components/smartthings/snapshots/test_init.ambr index e4db4742a3b..5a3ba833cf5 100644 --- a/tests/components/smartthings/snapshots/test_init.ambr +++ b/tests/components/smartthings/snapshots/test_init.ambr @@ -530,6 +530,39 @@ 'via_device_id': None, }) # --- +# name: test_devices[da_sac_ehs_000001_sub] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '1f98ebd0-ac48-d802-7f62-000001200100', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Samsung Electronics', + 'model': 'SAC_EHS_MONO', + 'model_id': None, + 'name': 'Eco Heating System', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '20240611.1', + 'via_device_id': None, + }) +# --- # name: test_devices[da_wm_dw_000001] DeviceRegistryEntrySnapshot({ 'area_id': 'theater', diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index b6d7bd80333..d5ee2ffad22 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -3406,6 +3406,384 @@ 'state': 'off', }) # --- +# name: test_all_entities[da_sac_ehs_000001_sub][sensor.eco_heating_system_cooling_set_point-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.eco_heating_system_cooling_set_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': 'Cooling set point', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'thermostat_cooling_setpoint', + 'unique_id': '1f98ebd0-ac48-d802-7f62-000001200100.coolingSetpoint', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_sac_ehs_000001_sub][sensor.eco_heating_system_cooling_set_point-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Eco Heating System Cooling set point', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.eco_heating_system_cooling_set_point', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '48', + }) +# --- +# name: test_all_entities[da_sac_ehs_000001_sub][sensor.eco_heating_system_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.eco_heating_system_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1f98ebd0-ac48-d802-7f62-000001200100.energy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_sac_ehs_000001_sub][sensor.eco_heating_system_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Eco Heating System Energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.eco_heating_system_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '8193.81', + }) +# --- +# name: test_all_entities[da_sac_ehs_000001_sub][sensor.eco_heating_system_energy_difference-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.eco_heating_system_energy_difference', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy difference', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_difference', + 'unique_id': '1f98ebd0-ac48-d802-7f62-000001200100.deltaEnergy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_sac_ehs_000001_sub][sensor.eco_heating_system_energy_difference-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Eco Heating System Energy difference', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.eco_heating_system_energy_difference', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_sac_ehs_000001_sub][sensor.eco_heating_system_energy_saved-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.eco_heating_system_energy_saved', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy saved', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_saved', + 'unique_id': '1f98ebd0-ac48-d802-7f62-000001200100.energySaved_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_sac_ehs_000001_sub][sensor.eco_heating_system_energy_saved-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Eco Heating System Energy saved', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.eco_heating_system_energy_saved', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_sac_ehs_000001_sub][sensor.eco_heating_system_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.eco_heating_system_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1f98ebd0-ac48-d802-7f62-000001200100.power_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_sac_ehs_000001_sub][sensor.eco_heating_system_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Eco Heating System Power', + 'power_consumption_end': '2025-03-09T11:14:57Z', + 'power_consumption_start': '2025-03-09T11:14:44Z', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.eco_heating_system_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.539', + }) +# --- +# name: test_all_entities[da_sac_ehs_000001_sub][sensor.eco_heating_system_power_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.eco_heating_system_power_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power energy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_energy', + 'unique_id': '1f98ebd0-ac48-d802-7f62-000001200100.powerEnergy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_sac_ehs_000001_sub][sensor.eco_heating_system_power_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Eco Heating System Power energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.eco_heating_system_power_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '9.4041739669111e-06', + }) +# --- +# name: test_all_entities[da_sac_ehs_000001_sub][sensor.eco_heating_system_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.eco_heating_system_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': 'Temperature', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1f98ebd0-ac48-d802-7f62-000001200100.temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_sac_ehs_000001_sub][sensor.eco_heating_system_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Eco Heating System Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.eco_heating_system_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '54.3', + }) +# --- # name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_completion_time-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/smartthings/snapshots/test_switch.ambr b/tests/components/smartthings/snapshots/test_switch.ambr index f1b5ce8412e..08db5ffc244 100644 --- a/tests/components/smartthings/snapshots/test_switch.ambr +++ b/tests/components/smartthings/snapshots/test_switch.ambr @@ -140,6 +140,53 @@ 'state': 'off', }) # --- +# name: test_all_entities[da_sac_ehs_000001_sub][switch.eco_heating_system-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.eco_heating_system', + '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': None, + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1f98ebd0-ac48-d802-7f62-000001200100', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_sac_ehs_000001_sub][switch.eco_heating_system-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Eco Heating System', + }), + 'context': , + 'entity_id': 'switch.eco_heating_system', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_all_entities[da_wm_dw_000001][switch.dishwasher-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 43898d7845760eea9fe77cd0e9c1c2a6ac1ee190 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 15 Mar 2025 20:28:51 +0100 Subject: [PATCH 2646/3148] Add valve platform to SmartThings (#140195) * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * fix * fix * Add AC tests * Add thermostat tests * Add cover tests * Add device tests * Add light tests * Add rest of the tests * Add valve * Add oauth * Add oauth tests * Add oauth tests * Add oauth tests * Add oauth tests * Bump version * Add rest of the tests * Finalize * Finalize * Finalize * Finalize * Finalize * Finalize * Finalize * Finalize * Finalize * Finalize * Finalize * Fix * Fix --- .../components/smartthings/__init__.py | 1 + homeassistant/components/smartthings/valve.py | 73 ++++++++++++++++ .../smartthings/snapshots/test_valve.ambr | 50 +++++++++++ tests/components/smartthings/test_valve.py | 87 +++++++++++++++++++ 4 files changed, 211 insertions(+) create mode 100644 homeassistant/components/smartthings/valve.py create mode 100644 tests/components/smartthings/snapshots/test_valve.ambr create mode 100644 tests/components/smartthings/test_valve.py diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py index f95719a8d02..538a4a16171 100644 --- a/homeassistant/components/smartthings/__init__.py +++ b/homeassistant/components/smartthings/__init__.py @@ -79,6 +79,7 @@ PLATFORMS = [ Platform.SCENE, Platform.SENSOR, Platform.SWITCH, + Platform.VALVE, ] diff --git a/homeassistant/components/smartthings/valve.py b/homeassistant/components/smartthings/valve.py new file mode 100644 index 00000000000..a38eb9e65c4 --- /dev/null +++ b/homeassistant/components/smartthings/valve.py @@ -0,0 +1,73 @@ +"""Support for valves through the SmartThings cloud API.""" + +from __future__ import annotations + +from pysmartthings import Attribute, Capability, Category, Command, SmartThings + +from homeassistant.components.valve import ( + ValveDeviceClass, + ValveEntity, + ValveEntityFeature, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import FullDevice, SmartThingsConfigEntry +from .const import MAIN +from .entity import SmartThingsEntity + +DEVICE_CLASS_MAP: dict[Category | str, ValveDeviceClass] = { + Category.WATER_VALVE: ValveDeviceClass.WATER, + Category.GAS_VALVE: ValveDeviceClass.GAS, +} + + +async def async_setup_entry( + hass: HomeAssistant, + entry: SmartThingsConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Add valves for a config entry.""" + entry_data = entry.runtime_data + async_add_entities( + SmartThingsValve(entry_data.client, entry_data.rooms, device) + for device in entry_data.devices.values() + if Capability.VALVE in device.status[MAIN] + ) + + +class SmartThingsValve(SmartThingsEntity, ValveEntity): + """Define a SmartThings valve.""" + + _attr_supported_features = ValveEntityFeature.OPEN | ValveEntityFeature.CLOSE + _attr_reports_position = False + _attr_name = None + + def __init__( + self, client: SmartThings, rooms: dict[str, str], device: FullDevice + ) -> None: + """Init the class.""" + super().__init__(client, device, rooms, {Capability.VALVE}) + self._attr_device_class = DEVICE_CLASS_MAP.get( + device.device.components[0].user_category + or device.device.components[0].manufacturer_category + ) + + async def async_open_valve(self) -> None: + """Open the valve.""" + await self.execute_device_command( + Capability.VALVE, + Command.OPEN, + ) + + async def async_close_valve(self) -> None: + """Close the valve.""" + await self.execute_device_command( + Capability.VALVE, + Command.CLOSE, + ) + + @property + def is_closed(self) -> bool: + """Return if the valve is closed.""" + return self.get_attribute_value(Capability.VALVE, Attribute.VALVE) == "closed" diff --git a/tests/components/smartthings/snapshots/test_valve.ambr b/tests/components/smartthings/snapshots/test_valve.ambr new file mode 100644 index 00000000000..bdb61187e3a --- /dev/null +++ b/tests/components/smartthings/snapshots/test_valve.ambr @@ -0,0 +1,50 @@ +# serializer version: 1 +# name: test_all_entities[virtual_valve][valve.volvo-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'valve', + 'entity_category': None, + 'entity_id': 'valve.volvo', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '612ab3c2-3bb0-48f7-b2c0-15b169cb2fc3', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[virtual_valve][valve.volvo-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'water', + 'friendly_name': 'volvo', + 'supported_features': , + }), + 'context': , + 'entity_id': 'valve.volvo', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'closed', + }) +# --- diff --git a/tests/components/smartthings/test_valve.py b/tests/components/smartthings/test_valve.py new file mode 100644 index 00000000000..f0ba34c8264 --- /dev/null +++ b/tests/components/smartthings/test_valve.py @@ -0,0 +1,87 @@ +"""Test for the SmartThings valve platform.""" + +from unittest.mock import AsyncMock + +from pysmartthings import Attribute, Capability, Command +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.smartthings import MAIN +from homeassistant.components.valve import DOMAIN as VALVE_DOMAIN, ValveState +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_CLOSE_VALVE, + SERVICE_OPEN_VALVE, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration, snapshot_smartthings_entities, trigger_update + +from tests.common import MockConfigEntry + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + await setup_integration(hass, mock_config_entry) + + snapshot_smartthings_entities(hass, entity_registry, snapshot, Platform.VALVE) + + +@pytest.mark.parametrize("device_fixture", ["virtual_valve"]) +@pytest.mark.parametrize( + ("action", "command"), + [ + (SERVICE_OPEN_VALVE, Command.OPEN), + (SERVICE_CLOSE_VALVE, Command.CLOSE), + ], +) +async def test_valve_open_close( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + action: str, + command: Command, +) -> None: + """Test valve open and close command.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + VALVE_DOMAIN, + action, + {ATTR_ENTITY_ID: "valve.volvo"}, + blocking=True, + ) + devices.execute_device_command.assert_called_once_with( + "612ab3c2-3bb0-48f7-b2c0-15b169cb2fc3", Capability.VALVE, command, MAIN + ) + + +@pytest.mark.parametrize("device_fixture", ["virtual_valve"]) +async def test_state_update( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test state update.""" + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("valve.volvo").state == ValveState.CLOSED + + await trigger_update( + hass, + devices, + "612ab3c2-3bb0-48f7-b2c0-15b169cb2fc3", + Capability.VALVE, + Attribute.VALVE, + "open", + ) + + assert hass.states.get("valve.volvo").state == ValveState.OPEN From 16556fa2a9ec089acf472394febb48aef35bee62 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 15 Mar 2025 10:06:00 -1000 Subject: [PATCH 2647/3148] Bump PySwitchBot to 0.57.1 (#140681) changelog: https://github.com/sblibs/pySwitchbot/compare/0.56.1...0.57.1 fixes #140405 --- homeassistant/components/switchbot/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/manifest.json b/homeassistant/components/switchbot/manifest.json index 567a33a8f43..85d5bcf6436 100644 --- a/homeassistant/components/switchbot/manifest.json +++ b/homeassistant/components/switchbot/manifest.json @@ -39,5 +39,5 @@ "documentation": "https://www.home-assistant.io/integrations/switchbot", "iot_class": "local_push", "loggers": ["switchbot"], - "requirements": ["PySwitchbot==0.56.1"] + "requirements": ["PySwitchbot==0.57.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 67a7a1e8c1f..dea5efd2c33 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -84,7 +84,7 @@ PyQRCode==1.2.1 PyRMVtransport==0.3.3 # homeassistant.components.switchbot -PySwitchbot==0.56.1 +PySwitchbot==0.57.1 # homeassistant.components.switchmate PySwitchmate==0.5.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b80ad271ffa..90e81a93c7c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -81,7 +81,7 @@ PyQRCode==1.2.1 PyRMVtransport==0.3.3 # homeassistant.components.switchbot -PySwitchbot==0.56.1 +PySwitchbot==0.57.1 # homeassistant.components.syncthru PySyncThru==0.8.0 From ed0b1f58dc4bafb0472b8e2c046ae8c9a9f1fb82 Mon Sep 17 00:00:00 2001 From: Thomas55555 <59625598+Thomas55555@users.noreply.github.com> Date: Sat, 15 Mar 2025 21:30:19 +0100 Subject: [PATCH 2648/3148] Bump aioautomower to 2025.3.1 (#140682) --- homeassistant/components/husqvarna_automower/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/husqvarna_automower/fixtures/mower.json | 4 +++- .../husqvarna_automower/snapshots/test_diagnostics.ambr | 2 ++ 5 files changed, 8 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/husqvarna_automower/manifest.json b/homeassistant/components/husqvarna_automower/manifest.json index 0eabf5ec0d6..45d4df95a04 100644 --- a/homeassistant/components/husqvarna_automower/manifest.json +++ b/homeassistant/components/husqvarna_automower/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_push", "loggers": ["aioautomower"], "quality_scale": "silver", - "requirements": ["aioautomower==2025.1.1"] + "requirements": ["aioautomower==2025.3.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index dea5efd2c33..bf7db107b74 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -201,7 +201,7 @@ aioaseko==1.0.0 aioasuswrt==1.4.0 # homeassistant.components.husqvarna_automower -aioautomower==2025.1.1 +aioautomower==2025.3.1 # homeassistant.components.azure_devops aioazuredevops==2.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 90e81a93c7c..9714d3003f6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -189,7 +189,7 @@ aioaseko==1.0.0 aioasuswrt==1.4.0 # homeassistant.components.husqvarna_automower -aioautomower==2025.1.1 +aioautomower==2025.3.1 # homeassistant.components.azure_devops aioazuredevops==2.2.1 diff --git a/tests/components/husqvarna_automower/fixtures/mower.json b/tests/components/husqvarna_automower/fixtures/mower.json index 8ab2f96e42f..ee368bf6546 100644 --- a/tests/components/husqvarna_automower/fixtures/mower.json +++ b/tests/components/husqvarna_automower/fixtures/mower.json @@ -176,13 +176,15 @@ ], "statistics": { "cuttingBladeUsageTime": 123, + "downTime": 123, "numberOfChargingCycles": 1380, "numberOfCollisions": 11396, "totalChargingTime": 4334400, "totalCuttingTime": 4194000, "totalDriveDistance": 1780272, "totalRunningTime": 4564800, - "totalSearchingTime": 370800 + "totalSearchingTime": 370800, + "upTime": 456 }, "stayOutZones": { "dirty": false, diff --git a/tests/components/husqvarna_automower/snapshots/test_diagnostics.ambr b/tests/components/husqvarna_automower/snapshots/test_diagnostics.ambr index 2dab82451a6..9d5004c8f6d 100644 --- a/tests/components/husqvarna_automower/snapshots/test_diagnostics.ambr +++ b/tests/components/husqvarna_automower/snapshots/test_diagnostics.ambr @@ -95,6 +95,7 @@ }), 'statistics': dict({ 'cutting_blade_usage_time': 123, + 'downtime': 123, 'number_of_charging_cycles': 1380, 'number_of_collisions': 11396, 'total_charging_time': 4334400, @@ -102,6 +103,7 @@ 'total_drive_distance': 1780272, 'total_running_time': 4564800, 'total_searching_time': 370800, + 'uptime': 456, }), 'stay_out_zones': dict({ 'dirty': False, From 76244e0d6b488396e4dd496e1be6c48adb8545e9 Mon Sep 17 00:00:00 2001 From: Glenn Waters Date: Sat, 15 Mar 2025 17:07:45 -0400 Subject: [PATCH 2649/3148] Fix Elk-M1 missing TLS 1.2 check (#140672) * Fix for missing TLS 1.2 check * Fix error message. * combine startswith --------- Co-authored-by: J. Nick Koston --- homeassistant/components/elkm1/__init__.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/elkm1/__init__.py b/homeassistant/components/elkm1/__init__.py index 5286b7ad66f..4bf51b99de1 100644 --- a/homeassistant/components/elkm1/__init__.py +++ b/homeassistant/components/elkm1/__init__.py @@ -101,9 +101,11 @@ def hostname_from_url(url: str) -> str: def _host_validator(config: dict[str, str]) -> dict[str, str]: """Validate that a host is properly configured.""" - if config[CONF_HOST].startswith("elks://"): + if config[CONF_HOST].startswith(("elks://", "elksv1_2://")): if CONF_USERNAME not in config or CONF_PASSWORD not in config: - raise vol.Invalid("Specify username and password for elks://") + raise vol.Invalid( + "Specify username and password for elks:// or elksv1_2://" + ) elif not config[CONF_HOST].startswith("elk://") and not config[ CONF_HOST ].startswith("serial://"): From d69bcc02b0800b3794e34c04f3301371a1be3615 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 15 Mar 2025 12:00:23 -1000 Subject: [PATCH 2650/3148] Pass scanner mode to shelly Bluetooth scanner (#140689) habluetooth will eventually be able to make better decisions on how to route data based on the scanning mode. --- .../components/shelly/bluetooth/__init__.py | 18 ++++++++++++++++-- tests/components/shelly/test_diagnostics.py | 10 ++++++++-- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/shelly/bluetooth/__init__.py b/homeassistant/components/shelly/bluetooth/__init__.py index cad1b9f044d..2b772bd1b78 100644 --- a/homeassistant/components/shelly/bluetooth/__init__.py +++ b/homeassistant/components/shelly/bluetooth/__init__.py @@ -7,7 +7,10 @@ from typing import TYPE_CHECKING from aioshelly.ble import async_start_scanner, create_scanner from aioshelly.ble.const import BLE_SCAN_RESULT_EVENT, BLE_SCAN_RESULT_VERSION -from homeassistant.components.bluetooth import async_register_scanner +from homeassistant.components.bluetooth import ( + BluetoothScanningMode, + async_register_scanner, +) from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback as hass_callback from ..const import BLEScannerMode @@ -15,6 +18,11 @@ from ..const import BLEScannerMode if TYPE_CHECKING: from ..coordinator import ShellyRpcCoordinator +BLE_SCANNER_MODE_TO_BLUETOOTH_SCANNING_MODE = { + BLEScannerMode.PASSIVE: BluetoothScanningMode.PASSIVE, + BLEScannerMode.ACTIVE: BluetoothScanningMode.ACTIVE, +} + async def async_connect_scanner( hass: HomeAssistant, @@ -25,7 +33,13 @@ async def async_connect_scanner( """Connect scanner.""" device = coordinator.device entry = coordinator.config_entry - scanner = create_scanner(coordinator.bluetooth_source, entry.title) + bluetooth_scanning_mode = BLE_SCANNER_MODE_TO_BLUETOOTH_SCANNING_MODE[scanner_mode] + scanner = create_scanner( + coordinator.bluetooth_source, + entry.title, + requested_mode=bluetooth_scanning_mode, + current_mode=bluetooth_scanning_mode, + ) unload_callbacks = [ async_register_scanner( hass, diff --git a/tests/components/shelly/test_diagnostics.py b/tests/components/shelly/test_diagnostics.py index d89f21f5992..84ebd50c425 100644 --- a/tests/components/shelly/test_diagnostics.py +++ b/tests/components/shelly/test_diagnostics.py @@ -109,8 +109,14 @@ async def test_rpc_config_entry_diagnostics( "bluetooth": { "scanner": { "connectable": False, - "current_mode": None, - "requested_mode": None, + "current_mode": { + "__type": "", + "repr": "", + }, + "requested_mode": { + "__type": "", + "repr": "", + }, "discovered_device_timestamps": {"AA:BB:CC:DD:EE:FF": ANY}, "discovered_devices_and_advertisement_data": [ { From 675b6842902ca8a4689e842b3ed9c6ff01142e21 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 15 Mar 2025 23:09:55 +0100 Subject: [PATCH 2651/3148] Check Celsius in SmartThings oven setpoint (#140687) --- homeassistant/components/smartthings/sensor.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index fd447da427e..1437cbe6000 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -575,7 +575,8 @@ CAPABILITY_TO_SENSORS: dict[ translation_key="oven_setpoint", device_class=SensorDeviceClass.TEMPERATURE, use_temperature_unit=True, - value_fn=lambda value: value if value != 0 else None, + # Set the value to None if it is 0 F (-17 C) + value_fn=lambda value: None if value in {0, -17} else value, ) ] }, From 91e0f1cb466a01acd42705e2435a8a728961f0a8 Mon Sep 17 00:00:00 2001 From: IceBotYT <34712694+IceBotYT@users.noreply.github.com> Date: Sat, 15 Mar 2025 18:40:02 -0400 Subject: [PATCH 2652/3148] Add voip_utils to voip loggers (#140695) * Add voip_utils to voip loggers * Sort --- homeassistant/components/voip/manifest.json | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/voip/manifest.json b/homeassistant/components/voip/manifest.json index 1e4c249c720..dfd397fde14 100644 --- a/homeassistant/components/voip/manifest.json +++ b/homeassistant/components/voip/manifest.json @@ -6,6 +6,7 @@ "dependencies": ["assist_pipeline", "assist_satellite", "intent", "network"], "documentation": "https://www.home-assistant.io/integrations/voip", "iot_class": "local_push", + "loggers": ["voip_utils"], "quality_scale": "internal", "requirements": ["voip-utils==0.3.1"] } From 4050c216ed213bfe09d76d45381f55446ee9a005 Mon Sep 17 00:00:00 2001 From: Thomas55555 <59625598+Thomas55555@users.noreply.github.com> Date: Sun, 16 Mar 2025 02:57:45 +0100 Subject: [PATCH 2653/3148] Add Remote calendar integration (#138862) * Add remote_calendar with storage * Use coordinator and remove storage * cleanup * cleanup * remove init from config_flow * add some tests * some fixes * test-before-setup * fix error handling * remove unneeded code * fix updates * load calendar in the event loop * allow redirects * test_update_failed * tests * address review * use error from local_calendar * adress more comments * remove unique_id * add unique entity_id * add excemption * abort_entries_match * unique_id * add , * cleanup * deduplicate call * don't raise for status end de-nest * multiline * test * tests * use raise_for_status again * use respx * just use config_entry argument that already is defined * Also assert on the config entry result title and data * improve config_flow * update quality scale * address review --------- Co-authored-by: Allen Porter --- CODEOWNERS | 2 + .../components/remote_calendar/__init__.py | 33 ++ .../components/remote_calendar/calendar.py | 92 ++++ .../components/remote_calendar/config_flow.py | 70 ++++ .../components/remote_calendar/const.py | 4 + .../components/remote_calendar/coordinator.py | 67 +++ .../components/remote_calendar/manifest.json | 12 + .../remote_calendar/quality_scale.yaml | 100 +++++ .../components/remote_calendar/strings.json | 33 ++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + requirements_all.txt | 1 + requirements_test_all.txt | 1 + script/hassfest/translations.py | 1 + tests/components/remote_calendar/__init__.py | 11 + tests/components/remote_calendar/conftest.py | 89 ++++ .../remote_calendar/test_calendar.py | 394 ++++++++++++++++++ .../remote_calendar/test_config_flow.py | 276 ++++++++++++ tests/components/remote_calendar/test_init.py | 73 ++++ 19 files changed, 1266 insertions(+) create mode 100644 homeassistant/components/remote_calendar/__init__.py create mode 100644 homeassistant/components/remote_calendar/calendar.py create mode 100644 homeassistant/components/remote_calendar/config_flow.py create mode 100644 homeassistant/components/remote_calendar/const.py create mode 100644 homeassistant/components/remote_calendar/coordinator.py create mode 100644 homeassistant/components/remote_calendar/manifest.json create mode 100644 homeassistant/components/remote_calendar/quality_scale.yaml create mode 100644 homeassistant/components/remote_calendar/strings.json create mode 100644 tests/components/remote_calendar/__init__.py create mode 100644 tests/components/remote_calendar/conftest.py create mode 100644 tests/components/remote_calendar/test_calendar.py create mode 100644 tests/components/remote_calendar/test_config_flow.py create mode 100644 tests/components/remote_calendar/test_init.py diff --git a/CODEOWNERS b/CODEOWNERS index 4e8f78ca873..cfc37f6f908 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1252,6 +1252,8 @@ build.json @home-assistant/supervisor /tests/components/refoss/ @ashionky /homeassistant/components/remote/ @home-assistant/core /tests/components/remote/ @home-assistant/core +/homeassistant/components/remote_calendar/ @Thomas55555 +/tests/components/remote_calendar/ @Thomas55555 /homeassistant/components/renault/ @epenet /tests/components/renault/ @epenet /homeassistant/components/renson/ @jimmyd-be diff --git a/homeassistant/components/remote_calendar/__init__.py b/homeassistant/components/remote_calendar/__init__.py new file mode 100644 index 00000000000..910eeae8268 --- /dev/null +++ b/homeassistant/components/remote_calendar/__init__.py @@ -0,0 +1,33 @@ +"""The Remote Calendar integration.""" + +import logging + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .const import DOMAIN +from .coordinator import RemoteCalendarConfigEntry, RemoteCalendarDataUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + + +PLATFORMS: list[Platform] = [Platform.CALENDAR] + + +async def async_setup_entry( + hass: HomeAssistant, entry: RemoteCalendarConfigEntry +) -> bool: + """Set up Remote Calendar from a config entry.""" + hass.data.setdefault(DOMAIN, {}) + coordinator = RemoteCalendarDataUpdateCoordinator(hass, entry) + await coordinator.async_config_entry_first_refresh() + entry.runtime_data = coordinator + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry( + hass: HomeAssistant, entry: RemoteCalendarConfigEntry +) -> bool: + """Handle unload of an entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/remote_calendar/calendar.py b/homeassistant/components/remote_calendar/calendar.py new file mode 100644 index 00000000000..bd83a5f18cc --- /dev/null +++ b/homeassistant/components/remote_calendar/calendar.py @@ -0,0 +1,92 @@ +"""Calendar platform for a Remote Calendar.""" + +from datetime import datetime +import logging + +from ical.event import Event + +from homeassistant.components.calendar import CalendarEntity, CalendarEvent +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity +from homeassistant.util import dt as dt_util + +from . import RemoteCalendarConfigEntry +from .const import CONF_CALENDAR_NAME +from .coordinator import RemoteCalendarDataUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + entry: RemoteCalendarConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the remote calendar platform.""" + coordinator = entry.runtime_data + entity = RemoteCalendarEntity(coordinator, entry) + async_add_entities([entity]) + + +class RemoteCalendarEntity( + CoordinatorEntity[RemoteCalendarDataUpdateCoordinator], CalendarEntity +): + """A calendar entity backed by a remote iCalendar url.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: RemoteCalendarDataUpdateCoordinator, + entry: RemoteCalendarConfigEntry, + ) -> None: + """Initialize RemoteCalendarEntity.""" + super().__init__(coordinator) + self._attr_name = entry.data[CONF_CALENDAR_NAME] + self._attr_unique_id = entry.entry_id + + @property + def event(self) -> CalendarEvent | None: + """Return the next upcoming event.""" + now = dt_util.now() + events = self.coordinator.data.timeline_tz(now.tzinfo).active_after(now) + if event := next(events, None): + return _get_calendar_event(event) + return None + + async def async_get_events( + self, hass: HomeAssistant, start_date: datetime, end_date: datetime + ) -> list[CalendarEvent]: + """Get all events in a specific time frame.""" + events = self.coordinator.data.timeline_tz(start_date.tzinfo).overlapping( + start_date, + end_date, + ) + return [_get_calendar_event(event) for event in events] + + +def _get_calendar_event(event: Event) -> CalendarEvent: + """Return a CalendarEvent from an API event.""" + + return CalendarEvent( + summary=event.summary, + start=( + dt_util.as_local(event.start) + if isinstance(event.start, datetime) + else event.start + ), + end=( + dt_util.as_local(event.end) + if isinstance(event.end, datetime) + else event.end + ), + description=event.description, + uid=event.uid, + rrule=event.rrule.as_rrule_str() if event.rrule else None, + recurrence_id=event.recurrence_id, + location=event.location, + ) diff --git a/homeassistant/components/remote_calendar/config_flow.py b/homeassistant/components/remote_calendar/config_flow.py new file mode 100644 index 00000000000..03d0e7ea96a --- /dev/null +++ b/homeassistant/components/remote_calendar/config_flow.py @@ -0,0 +1,70 @@ +"""Config flow for Remote Calendar integration.""" + +import logging +from typing import Any + +from httpx import HTTPError, InvalidURL +from ical.calendar_stream import IcsCalendarStream +from ical.exceptions import CalendarParseError +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_URL +from homeassistant.helpers.httpx_client import get_async_client + +from .const import CONF_CALENDAR_NAME, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_CALENDAR_NAME): str, + vol.Required(CONF_URL): str, + } +) + + +class RemoteCalendarConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Remote Calendar.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + if user_input is None: + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA + ) + errors: dict = {} + _LOGGER.debug("User input: %s", user_input) + self._async_abort_entries_match( + {CONF_CALENDAR_NAME: user_input[CONF_CALENDAR_NAME]} + ) + self._async_abort_entries_match({CONF_URL: user_input[CONF_URL]}) + client = get_async_client(self.hass) + try: + res = await client.get(user_input[CONF_URL], follow_redirects=True) + res.raise_for_status() + except (HTTPError, InvalidURL) as err: + errors["base"] = "cannot_connect" + _LOGGER.debug("An error occurred: %s", err) + else: + try: + await self.hass.async_add_executor_job( + IcsCalendarStream.calendar_from_ics, res.text + ) + except CalendarParseError as err: + errors["base"] = "invalid_ics_file" + _LOGGER.debug("Invalid .ics file: %s", err) + else: + return self.async_create_entry( + title=user_input[CONF_CALENDAR_NAME], data=user_input + ) + + return self.async_show_form( + step_id="user", + data_schema=STEP_USER_DATA_SCHEMA, + errors=errors, + ) diff --git a/homeassistant/components/remote_calendar/const.py b/homeassistant/components/remote_calendar/const.py new file mode 100644 index 00000000000..060d7633111 --- /dev/null +++ b/homeassistant/components/remote_calendar/const.py @@ -0,0 +1,4 @@ +"""Constants for the Remote Calendar integration.""" + +DOMAIN = "remote_calendar" +CONF_CALENDAR_NAME = "calendar_name" diff --git a/homeassistant/components/remote_calendar/coordinator.py b/homeassistant/components/remote_calendar/coordinator.py new file mode 100644 index 00000000000..7ee95695e61 --- /dev/null +++ b/homeassistant/components/remote_calendar/coordinator.py @@ -0,0 +1,67 @@ +"""Data UpdateCoordinator for the Remote Calendar integration.""" + +from datetime import timedelta +import logging + +from httpx import HTTPError, InvalidURL +from ical.calendar import Calendar +from ical.calendar_stream import IcsCalendarStream +from ical.exceptions import CalendarParseError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_URL +from homeassistant.core import HomeAssistant +from homeassistant.helpers.httpx_client import get_async_client +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN + +type RemoteCalendarConfigEntry = ConfigEntry[RemoteCalendarDataUpdateCoordinator] + +_LOGGER = logging.getLogger(__name__) +SCAN_INTERVAL = timedelta(days=1) + + +class RemoteCalendarDataUpdateCoordinator(DataUpdateCoordinator[Calendar]): + """Class to manage fetching calendar data.""" + + config_entry: RemoteCalendarConfigEntry + + def __init__( + self, + hass: HomeAssistant, + config_entry: RemoteCalendarConfigEntry, + ) -> None: + """Initialize data updater.""" + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=SCAN_INTERVAL, + always_update=True, + ) + self._etag = None + self._client = get_async_client(hass) + self._url = config_entry.data[CONF_URL] + + async def _async_update_data(self) -> Calendar: + """Update data from the url.""" + try: + res = await self._client.get(self._url, follow_redirects=True) + res.raise_for_status() + except (HTTPError, InvalidURL) as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="unable_to_fetch", + translation_placeholders={"err": str(err)}, + ) from err + try: + return await self.hass.async_add_executor_job( + IcsCalendarStream.calendar_from_ics, res.text + ) + except CalendarParseError as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="unable_to_parse", + translation_placeholders={"err": str(err)}, + ) from err diff --git a/homeassistant/components/remote_calendar/manifest.json b/homeassistant/components/remote_calendar/manifest.json new file mode 100644 index 00000000000..260f465f993 --- /dev/null +++ b/homeassistant/components/remote_calendar/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "remote_calendar", + "name": "Remote Calendar", + "codeowners": ["@Thomas55555"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/remote_calendar", + "integration_type": "service", + "iot_class": "cloud_polling", + "loggers": ["ical"], + "quality_scale": "silver", + "requirements": ["ical==8.3.0"] +} diff --git a/homeassistant/components/remote_calendar/quality_scale.yaml b/homeassistant/components/remote_calendar/quality_scale.yaml new file mode 100644 index 00000000000..3693d75f2cf --- /dev/null +++ b/homeassistant/components/remote_calendar/quality_scale.yaml @@ -0,0 +1,100 @@ +rules: + # Bronze + config-flow: done + test-before-configure: done + unique-config-entry: + status: exempt + comment: | + No unique identifier. + config-flow-test-coverage: done + runtime-data: done + test-before-setup: done + appropriate-polling: done + entity-unique-id: done + has-entity-name: done + entity-event-setup: + status: exempt + comment: | + Entities of this integration does not explicitly subscribe to events. + dependency-transparency: done + action-setup: + status: exempt + comment: | + There are no actions. + common-modules: done + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + docs-actions: + status: exempt + comment: No actions available. + brands: done + # Silver + config-entry-unloading: done + log-when-unavailable: done + entity-unavailable: done + action-exceptions: + status: exempt + comment: | + There are no actions. + reauthentication-flow: + status: exempt + comment: | + There is no authentication required. + parallel-updates: done + test-coverage: done + integration-owner: done + docs-installation-parameters: done + docs-configuration-parameters: + status: exempt + comment: no configuration options + + # Gold + devices: + status: exempt + comment: No devices. One URL is always assigned to one calendar. + diagnostics: + status: todo + comment: Diagnostics not implemented, yet. + discovery-update-info: + status: todo + comment: No discovery protocol available. + discovery: + status: exempt + comment: No discovery protocol available. + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: + status: exempt + comment: No devices. One URL is always assigned to one calendar. + entity-category: done + entity-device-class: + status: exempt + comment: No devices classes for calendars. + entity-disabled-by-default: + status: exempt + comment: Only one entity per entry. + entity-translations: + status: exempt + comment: Entity name is defined by the user, so no translation possible. + exception-translations: done + icon-translations: + status: exempt + comment: Only the default icon is used. + reconfiguration-flow: + status: exempt + comment: no configuration possible + repair-issues: todo + stale-devices: + status: exempt + comment: No devices. One URL is always assigned to one calendar. + + # Platinum + async-dependency: todo + inject-websession: done + strict-typing: todo diff --git a/homeassistant/components/remote_calendar/strings.json b/homeassistant/components/remote_calendar/strings.json new file mode 100644 index 00000000000..c833676a410 --- /dev/null +++ b/homeassistant/components/remote_calendar/strings.json @@ -0,0 +1,33 @@ +{ + "title": "Remote Calendar", + "config": { + "step": { + "user": { + "description": "Please choose a name for the calendar to be imported", + "data": { + "calendar_name": "Calendar Name", + "url": "Calendar URL" + }, + "data_description": { + "calendar_name": "The name of the calendar shown in th UI.", + "url": "The URL of the remote calendar." + } + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_ics_file": "[%key:component::local_calendar::config::error::invalid_ics_file%]" + } + }, + "exceptions": { + "unable_to_fetch": { + "message": "Unable to fetch calendar data: {err}" + }, + "unable_to_parse": { + "message": "Unable to parse calendar data: {err}" + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 8284f77ef94..a9c4a6b0a93 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -513,6 +513,7 @@ FLOWS = { "rdw", "recollect_waste", "refoss", + "remote_calendar", "renault", "renson", "reolink", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index b916526aaf3..55fcb08ba92 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -5265,6 +5265,11 @@ "config_flow": false, "iot_class": "cloud_push" }, + "remote_calendar": { + "integration_type": "service", + "config_flow": true, + "iot_class": "cloud_polling" + }, "renault": { "name": "Renault", "integration_type": "hub", @@ -7690,6 +7695,7 @@ "plant", "proximity", "random", + "remote_calendar", "rpi_power", "schedule", "season", diff --git a/requirements_all.txt b/requirements_all.txt index bf7db107b74..98ce16a4560 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1193,6 +1193,7 @@ ibmiotf==0.3.4 # homeassistant.components.google # homeassistant.components.local_calendar # homeassistant.components.local_todo +# homeassistant.components.remote_calendar ical==8.3.0 # homeassistant.components.caldav diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9714d3003f6..f6880d377be 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1010,6 +1010,7 @@ ibeacon-ble==1.2.0 # homeassistant.components.google # homeassistant.components.local_calendar # homeassistant.components.local_todo +# homeassistant.components.remote_calendar ical==8.3.0 # homeassistant.components.caldav diff --git a/script/hassfest/translations.py b/script/hassfest/translations.py index c257f185f51..8e59bd8582e 100644 --- a/script/hassfest/translations.py +++ b/script/hassfest/translations.py @@ -40,6 +40,7 @@ ALLOW_NAME_TRANSLATION = { "local_ip", "local_todo", "nmap_tracker", + "remote_calendar", "rpi_power", "swiss_public_transport", "waze_travel_time", diff --git a/tests/components/remote_calendar/__init__.py b/tests/components/remote_calendar/__init__.py new file mode 100644 index 00000000000..2ffb157f072 --- /dev/null +++ b/tests/components/remote_calendar/__init__.py @@ -0,0 +1,11 @@ +"""Tests for the Remote Calendar integration.""" + +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) + await hass.config_entries.async_setup(config_entry.entry_id) diff --git a/tests/components/remote_calendar/conftest.py b/tests/components/remote_calendar/conftest.py new file mode 100644 index 00000000000..bf5184bbf54 --- /dev/null +++ b/tests/components/remote_calendar/conftest.py @@ -0,0 +1,89 @@ +"""Fixtures for Remote Calendar.""" + +from collections.abc import Awaitable, Callable +from http import HTTPStatus +import textwrap +from typing import Any +import urllib + +import pytest + +from homeassistant.components.remote_calendar.const import CONF_CALENDAR_NAME, DOMAIN +from homeassistant.const import CONF_URL +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.typing import ClientSessionGenerator + +CALENDAR_NAME = "Home Assistant Events" +TEST_ENTITY = "calendar.home_assistant_events" +CALENDER_URL = "https://some.calendar.com/calendar.ics" +FRIENDLY_NAME = "Home Assistant Events" + + +@pytest.fixture(name="time_zone") +def mock_time_zone() -> str: + """Fixture for time zone to use in tests.""" + # Set our timezone to CST/Regina so we can check calculations + # This keeps UTC-6 all year round + return "America/Regina" + + +@pytest.fixture(autouse=True) +async def set_time_zone(hass: HomeAssistant, time_zone: str): + """Set the time zone for the tests.""" + # Set our timezone to CST/Regina so we can check calculations + # This keeps UTC-6 all year round + await hass.config.async_set_time_zone(time_zone) + + +@pytest.fixture(name="config_entry") +def mock_config_entry() -> MockConfigEntry: + """Fixture for mock configuration entry.""" + return MockConfigEntry( + domain=DOMAIN, data={CONF_CALENDAR_NAME: CALENDAR_NAME, CONF_URL: CALENDER_URL} + ) + + +type GetEventsFn = Callable[[str, str], Awaitable[list[dict[str, Any]]]] + + +@pytest.fixture(name="get_events") +def get_events_fixture(hass_client: ClientSessionGenerator) -> GetEventsFn: + """Fetch calendar events from the HTTP API.""" + + async def _fetch(start: str, end: str) -> list[dict[str, Any]]: + client = await hass_client() + response = await client.get( + f"/api/calendars/{TEST_ENTITY}?start={urllib.parse.quote(start)}&end={urllib.parse.quote(end)}" + ) + assert response.status == HTTPStatus.OK + return await response.json() + + return _fetch + + +def event_fields(data: dict[str, str]) -> dict[str, str]: + """Filter event API response to minimum fields.""" + return { + k: data[k] + for k in ("summary", "start", "end", "recurrence_id", "location") + if data.get(k) + } + + +@pytest.fixture(name="ics_content") +def mock_ics_content(request: pytest.FixtureRequest) -> str: + """Fixture to allow tests to set initial ics content for the calendar store.""" + default_content = textwrap.dedent( + """\ + BEGIN:VCALENDAR + BEGIN:VEVENT + SUMMARY:Bastille Day Party + DTSTART:19970714T170000Z + DTEND:19970715T040000Z + END:VEVENT + END:VCALENDAR + """ + ) + return request.param if hasattr(request, "param") else default_content diff --git a/tests/components/remote_calendar/test_calendar.py b/tests/components/remote_calendar/test_calendar.py new file mode 100644 index 00000000000..6ae817321c3 --- /dev/null +++ b/tests/components/remote_calendar/test_calendar.py @@ -0,0 +1,394 @@ +"""Tests for calendar platform of Remote Calendar.""" + +from datetime import datetime +import textwrap + +from httpx import Response +import pytest +import respx + +from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant + +from . import setup_integration +from .conftest import ( + CALENDER_URL, + FRIENDLY_NAME, + TEST_ENTITY, + GetEventsFn, + event_fields, +) + +from tests.common import MockConfigEntry + + +@respx.mock +async def test_empty_calendar( + hass: HomeAssistant, + config_entry: MockConfigEntry, + get_events: GetEventsFn, +) -> None: + """Test querying the API and fetching events.""" + respx.get(CALENDER_URL).mock( + return_value=Response( + status_code=200, + text=textwrap.dedent( + """BEGIN:VCALENDAR + VERSION:2.0 + PRODID:-//hacksw/handcal//NONSGML v1.0//EN + END:VCALENDAR + """ + ), + ) + ) + await setup_integration(hass, config_entry) + events = await get_events("1997-07-14T00:00:00", "1997-07-16T00:00:00") + assert len(events) == 0 + + state = hass.states.get(TEST_ENTITY) + assert state + assert state.name == FRIENDLY_NAME + assert state.state == STATE_OFF + assert dict(state.attributes) == { + "friendly_name": FRIENDLY_NAME, + } + + +@pytest.mark.parametrize( + "ics_content", + [ + textwrap.dedent( + """\ + BEGIN:VCALENDAR + VERSION:2.0 + BEGIN:VEVENT + SUMMARY:Bastille Day Party + DTSTART;TZID=Europe/Berlin:19970714T190000 + DTEND;TZID=Europe/Berlin:19970715T060000 + END:VEVENT + END:VCALENDAR + """ + ), + textwrap.dedent( + """\ + BEGIN:VCALENDAR + BEGIN:VEVENT + SUMMARY:Bastille Day Party + DTSTART:19970714T170000Z + DTEND:19970715T040000Z + END:VEVENT + END:VCALENDAR + """ + ), + textwrap.dedent( + """\ + BEGIN:VCALENDAR + VERSION:2.0 + BEGIN:VEVENT + SUMMARY:Bastille Day Party + DTSTART;TZID=America/Regina:19970714T110000 + DTEND;TZID=America/Regina:19970714T220000 + END:VEVENT + END:VCALENDAR + """ + ), + textwrap.dedent( + """\ + BEGIN:VCALENDAR + VERSION:2.0 + BEGIN:VEVENT + SUMMARY:Bastille Day Party + DTSTART;TZID=America/Los_Angeles:19970714T100000 + DTEND;TZID=America/Los_Angeles:19970714T210000 + END:VEVENT + END:VCALENDAR + """ + ), + ], +) +@respx.mock +async def test_api_date_time_event( + get_events: GetEventsFn, + hass: HomeAssistant, + config_entry: MockConfigEntry, + ics_content: str, +) -> None: + """Test an event with a start/end date time.""" + respx.get(CALENDER_URL).mock( + return_value=Response( + status_code=200, + text=ics_content, + ) + ) + await setup_integration(hass, config_entry) + events = await get_events("1997-07-14T00:00:00Z", "1997-07-16T00:00:00Z") + assert list(map(event_fields, events)) == [ + { + "summary": "Bastille Day Party", + "start": {"dateTime": "1997-07-14T11:00:00-06:00"}, + "end": {"dateTime": "1997-07-14T22:00:00-06:00"}, + } + ] + + # Query events in UTC + + # Time range before event + events = await get_events("1997-07-13T00:00:00Z", "1997-07-14T16:00:00Z") + assert len(events) == 0 + # Time range after event + events = await get_events("1997-07-15T05:00:00Z", "1997-07-15T06:00:00Z") + assert len(events) == 0 + + # Overlap with event start + events = await get_events("1997-07-13T00:00:00Z", "1997-07-14T18:00:00Z") + assert len(events) == 1 + # Overlap with event end + events = await get_events("1997-07-15T03:00:00Z", "1997-07-15T06:00:00Z") + assert len(events) == 1 + + # Query events overlapping with start and end but in another timezone + events = await get_events("1997-07-12T23:00:00-01:00", "1997-07-14T17:00:00-01:00") + assert len(events) == 1 + events = await get_events("1997-07-15T02:00:00-01:00", "1997-07-15T05:00:00-01:00") + assert len(events) == 1 + + +@respx.mock +async def test_api_date_event( + get_events: GetEventsFn, + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test an event with a start/end date all day event.""" + respx.get(CALENDER_URL).mock( + return_value=Response( + status_code=200, + text=textwrap.dedent( + """\ + BEGIN:VCALENDAR + VERSION:2.0 + BEGIN:VEVENT + SUMMARY:Festival International de Jazz de Montreal + DTSTART:20070628 + DTEND:20070709 + END:VEVENT + END:VCALENDAR + """ + ), + ) + ) + await setup_integration(hass, config_entry) + events = await get_events("2007-06-20T00:00:00", "2007-07-20T00:00:00") + assert list(map(event_fields, events)) == [ + { + "summary": "Festival International de Jazz de Montreal", + "start": {"date": "2007-06-28"}, + "end": {"date": "2007-07-09"}, + } + ] + + # Time range before event (timezone is -6) + events = await get_events("2007-06-26T00:00:00Z", "2007-06-28T01:00:00Z") + assert len(events) == 0 + # Time range after event + events = await get_events("2007-07-10T00:00:00Z", "2007-07-11T00:00:00Z") + assert len(events) == 0 + + # Overlap with event start (timezone is -6) + events = await get_events("2007-06-26T00:00:00Z", "2007-06-28T08:00:00Z") + assert len(events) == 1 + # Overlap with event end + events = await get_events("2007-07-09T00:00:00Z", "2007-07-11T00:00:00Z") + assert len(events) == 1 + + +@pytest.mark.freeze_time(datetime(2007, 6, 28, 12)) +@respx.mock +async def test_active_event( + get_events: GetEventsFn, + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test an event with a start/end date time.""" + respx.get(CALENDER_URL).mock( + return_value=Response( + status_code=200, + text=textwrap.dedent( + """\ + BEGIN:VCALENDAR + VERSION:2.0 + BEGIN:VEVENT + SUMMARY:Festival International de Jazz de Montreal + LOCATION:Montreal + DTSTART:20070628 + DTEND:20070709 + END:VEVENT + END:VCALENDAR + """ + ), + ) + ) + await setup_integration(hass, config_entry) + state = hass.states.get(TEST_ENTITY) + assert state + assert state.name == FRIENDLY_NAME + assert state.state == STATE_ON + assert dict(state.attributes) == { + "friendly_name": FRIENDLY_NAME, + "message": "Festival International de Jazz de Montreal", + "all_day": True, + "description": "", + "location": "Montreal", + "start_time": "2007-06-28 00:00:00", + "end_time": "2007-07-09 00:00:00", + } + + +@pytest.mark.freeze_time(datetime(2007, 6, 27, 12)) +@respx.mock +async def test_upcoming_event( + get_events: GetEventsFn, + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test an event with a start/end date time.""" + respx.get(CALENDER_URL).mock( + return_value=Response( + status_code=200, + text=textwrap.dedent( + """\ + BEGIN:VCALENDAR + VERSION:2.0 + BEGIN:VEVENT + SUMMARY:Festival International de Jazz de Montreal + LOCATION:Montreal + DTSTART:20070628 + DTEND:20070709 + END:VEVENT + END:VCALENDAR + """ + ), + ) + ) + await setup_integration(hass, config_entry) + state = hass.states.get(TEST_ENTITY) + assert state + assert state.name == FRIENDLY_NAME + assert state.state == STATE_OFF + assert dict(state.attributes) == { + "friendly_name": FRIENDLY_NAME, + "message": "Festival International de Jazz de Montreal", + "all_day": True, + "description": "", + "location": "Montreal", + "start_time": "2007-06-28 00:00:00", + "end_time": "2007-07-09 00:00:00", + } + + +@respx.mock +async def test_recurring_event( + get_events: GetEventsFn, + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test an event with a recurrence rule.""" + respx.get(CALENDER_URL).mock( + return_value=Response( + status_code=200, + text=textwrap.dedent( + """\ + BEGIN:VCALENDAR + BEGIN:VEVENT + DTSTART:20220829T090000 + DTEND:20220829T100000 + SUMMARY:Monday meeting + RRULE:FREQ=WEEKLY;BYDAY=MO + END:VEVENT + END:VCALENDAR + """ + ), + ) + ) + await setup_integration(hass, config_entry) + + events = await get_events("2022-08-20T00:00:00", "2022-09-20T00:00:00") + assert list(map(event_fields, events)) == [ + { + "summary": "Monday meeting", + "start": {"dateTime": "2022-08-29T09:00:00-06:00"}, + "end": {"dateTime": "2022-08-29T10:00:00-06:00"}, + "recurrence_id": "20220829T090000", + }, + { + "summary": "Monday meeting", + "start": {"dateTime": "2022-09-05T09:00:00-06:00"}, + "end": {"dateTime": "2022-09-05T10:00:00-06:00"}, + "recurrence_id": "20220905T090000", + }, + { + "summary": "Monday meeting", + "start": {"dateTime": "2022-09-12T09:00:00-06:00"}, + "end": {"dateTime": "2022-09-12T10:00:00-06:00"}, + "recurrence_id": "20220912T090000", + }, + { + "summary": "Monday meeting", + "start": {"dateTime": "2022-09-19T09:00:00-06:00"}, + "end": {"dateTime": "2022-09-19T10:00:00-06:00"}, + "recurrence_id": "20220919T090000", + }, + ] + + +@respx.mock +@pytest.mark.parametrize( + ("time_zone", "event_order"), + [ + ("America/Los_Angeles", ["One", "Two", "All Day Event"]), + ("America/Regina", ["One", "Two", "All Day Event"]), + ("UTC", ["One", "All Day Event", "Two"]), + ("Asia/Tokyo", ["All Day Event", "One", "Two"]), + ], +) +async def test_all_day_iter_order( + get_events: GetEventsFn, + hass: HomeAssistant, + config_entry: MockConfigEntry, + event_order: list[str], +) -> None: + """Test the sort order of an all day events depending on the time zone.""" + respx.get(CALENDER_URL).mock( + return_value=Response( + status_code=200, + text=textwrap.dedent( + """\ + BEGIN:VCALENDAR + + BEGIN:VEVENT + DTSTART:20221008 + DTEND:20221009 + SUMMARY:All Day Event + END:VEVENT + + BEGIN:VEVENT + DTSTART:20221007T230000Z + DTEND:20221008T233000Z + SUMMARY:One + END:VEVENT + + BEGIN:VEVENT + DTSTART:20221008T010000Z + DTEND:20221008T020000Z + SUMMARY:Two + END:VEVENT + + END:VCALENDAR + """ + ), + ) + ) + await setup_integration(hass, config_entry) + + events = await get_events("2022-10-06T00:00:00Z", "2022-10-09T00:00:00Z") + assert [event["summary"] for event in events] == event_order diff --git a/tests/components/remote_calendar/test_config_flow.py b/tests/components/remote_calendar/test_config_flow.py new file mode 100644 index 00000000000..626bc2c6e03 --- /dev/null +++ b/tests/components/remote_calendar/test_config_flow.py @@ -0,0 +1,276 @@ +"""Test the Remote Calendar config flow.""" + +from httpx import ConnectError, Response, UnsupportedProtocol +import pytest +import respx + +from homeassistant.components.remote_calendar.const import CONF_CALENDAR_NAME, DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_URL +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from . import setup_integration +from .conftest import CALENDAR_NAME, CALENDER_URL + +from tests.common import MockConfigEntry + + +@respx.mock +async def test_form_import_ics(hass: HomeAssistant, ics_content: str) -> None: + """Test we get the import form.""" + respx.get(CALENDER_URL).mock( + return_value=Response( + status_code=200, + text=ics_content, + ) + ) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_CALENDAR_NAME: CALENDAR_NAME, + CONF_URL: CALENDER_URL, + }, + ) + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["title"] == CALENDAR_NAME + assert result2["data"] == { + CONF_CALENDAR_NAME: CALENDAR_NAME, + CONF_URL: CALENDER_URL, + } + + +@pytest.mark.parametrize( + ("side_effect"), + [ + ConnectError("Connection failed"), + UnsupportedProtocol("Unsupported protocol"), + ], +) +@respx.mock +async def test_form_inavild_url( + hass: HomeAssistant, + side_effect: Exception, + ics_content: str, +) -> None: + """Test we get the import form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + respx.get("invalid-url.com").mock(side_effect=side_effect) + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_CALENDAR_NAME: CALENDAR_NAME, + CONF_URL: "invalid-url.com", + }, + ) + assert result2["type"] is FlowResultType.FORM + assert result2["errors"] == {"base": "cannot_connect"} + respx.get(CALENDER_URL).mock( + return_value=Response( + status_code=200, + text=ics_content, + ) + ) + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_CALENDAR_NAME: CALENDAR_NAME, + CONF_URL: CALENDER_URL, + }, + ) + assert result3["type"] is FlowResultType.CREATE_ENTRY + assert result3["title"] == CALENDAR_NAME + assert result3["data"] == { + CONF_CALENDAR_NAME: CALENDAR_NAME, + CONF_URL: CALENDER_URL, + } + + +@pytest.mark.parametrize( + ("url", "log_message"), + [ + ( + "unsupported://protocol.com", # Test for httpx.UnsupportedProtocol + "Request URL has an unsupported protocol 'unsupported://'", + ), + ( + "invalid-url", # Test for httpx.ProtocolError + "Request URL is missing an 'http://' or 'https://' protocol", + ), + ( + "https://example.com:abc/", # Test for httpx.InvalidURL + "Invalid port: 'abc'", + ), + ], +) +async def test_unsupported_inputs( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, url: str, log_message: str +) -> None: + """Test that an unsupported inputs results in a form error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_CALENDAR_NAME: CALENDAR_NAME, + CONF_URL: url, + }, + ) + + assert result2["type"] is FlowResultType.FORM + assert result2["errors"] == {"base": "cannot_connect"} + assert log_message in caplog.text + ## It's not possible to test a successful config flow because, we need to mock httpx.get here + ## and then the exception isn't raised anymore. + + +@respx.mock +async def test_form_http_status_error(hass: HomeAssistant, ics_content: str) -> None: + """Test we http status.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + respx.get(CALENDER_URL).mock( + return_value=Response( + status_code=403, + ) + ) + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_CALENDAR_NAME: CALENDAR_NAME, + CONF_URL: CALENDER_URL, + }, + ) + assert result2["type"] is FlowResultType.FORM + assert result2["errors"] == {"base": "cannot_connect"} + respx.get(CALENDER_URL).mock( + return_value=Response( + status_code=200, + text=ics_content, + ) + ) + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_CALENDAR_NAME: CALENDAR_NAME, + CONF_URL: CALENDER_URL, + }, + ) + assert result3["type"] is FlowResultType.CREATE_ENTRY + assert result3["title"] == CALENDAR_NAME + assert result3["data"] == { + CONF_CALENDAR_NAME: CALENDAR_NAME, + CONF_URL: CALENDER_URL, + } + + +@respx.mock +async def test_no_valid_calendar(hass: HomeAssistant, ics_content: str) -> None: + """Test invalid ics content.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + respx.get(CALENDER_URL).mock( + return_value=Response( + status_code=200, + text="blabla", + ) + ) + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_CALENDAR_NAME: CALENDAR_NAME, + CONF_URL: CALENDER_URL, + }, + ) + + assert result2["type"] is FlowResultType.FORM + assert result2["errors"] == {"base": "invalid_ics_file"} + respx.get(CALENDER_URL).mock( + return_value=Response( + status_code=200, + text=ics_content, + ) + ) + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_CALENDAR_NAME: CALENDAR_NAME, + CONF_URL: CALENDER_URL, + }, + ) + assert result3["type"] is FlowResultType.CREATE_ENTRY + assert result3["title"] == CALENDAR_NAME + assert result3["data"] == { + CONF_CALENDAR_NAME: CALENDAR_NAME, + CONF_URL: CALENDER_URL, + } + + +async def test_duplicate_name( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test two calendars cannot be added with the same name.""" + + await setup_integration(hass, config_entry) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert not result.get("errors") + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_CALENDAR_NAME: CALENDAR_NAME, + CONF_URL: "http://other-calendar.com", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "already_configured" + + +async def test_duplicate_url( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test two calendars cannot be added with the same url.""" + + await setup_integration(hass, config_entry) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert not result.get("errors") + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_CALENDAR_NAME: "new name", + CONF_URL: CALENDER_URL, + }, + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "already_configured" diff --git a/tests/components/remote_calendar/test_init.py b/tests/components/remote_calendar/test_init.py new file mode 100644 index 00000000000..08f5c8b45c0 --- /dev/null +++ b/tests/components/remote_calendar/test_init.py @@ -0,0 +1,73 @@ +"""Tests for init platform of Remote Calendar.""" + +from httpx import ConnectError, Response, UnsupportedProtocol +import pytest +import respx + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import STATE_OFF +from homeassistant.core import HomeAssistant + +from . import setup_integration +from .conftest import CALENDER_URL, TEST_ENTITY + +from tests.common import MockConfigEntry + + +@respx.mock +async def test_load_unload( + hass: HomeAssistant, config_entry: MockConfigEntry, ics_content: str +) -> None: + """Test loading and unloading a config entry.""" + respx.get(CALENDER_URL).mock( + return_value=Response( + status_code=200, + text=ics_content, + ) + ) + await setup_integration(hass, config_entry) + assert config_entry.state is ConfigEntryState.LOADED + + state = hass.states.get(TEST_ENTITY) + assert state + assert state.state == STATE_OFF + + await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.NOT_LOADED + + +@respx.mock +async def test_raise_for_status( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test update failed using respx to simulate HTTP exceptions.""" + respx.get(CALENDER_URL).mock( + return_value=Response( + status_code=403, + ) + ) + await setup_integration(hass, config_entry) + assert config_entry.state is ConfigEntryState.SETUP_RETRY + + +@pytest.mark.parametrize( + "side_effect", + [ + ConnectError("Connection failed"), + UnsupportedProtocol("Unsupported protocol"), + ValueError("Invalid response"), + ], +) +@respx.mock +async def test_update_failed( + hass: HomeAssistant, + config_entry: MockConfigEntry, + side_effect: Exception, +) -> None: + """Test update failed using respx to simulate different exceptions.""" + respx.get(CALENDER_URL).mock(side_effect=side_effect) + await setup_integration(hass, config_entry) + assert config_entry.state is ConfigEntryState.SETUP_RETRY From 3a6ddcf4285df8ed1aefaf330db24a7b97e368c3 Mon Sep 17 00:00:00 2001 From: Denis Shulyaka Date: Sun, 16 Mar 2025 05:24:27 +0300 Subject: [PATCH 2654/3148] Bump openai to 1.66.3 (#140690) --- homeassistant/components/openai_conversation/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/openai_conversation/manifest.json b/homeassistant/components/openai_conversation/manifest.json index cc1c56b0927..a4e46f6457b 100644 --- a/homeassistant/components/openai_conversation/manifest.json +++ b/homeassistant/components/openai_conversation/manifest.json @@ -8,5 +8,5 @@ "documentation": "https://www.home-assistant.io/integrations/openai_conversation", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["openai==1.65.2"] + "requirements": ["openai==1.66.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index 98ce16a4560..0b8d1da4499 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1581,7 +1581,7 @@ open-garage==0.2.0 open-meteo==0.3.2 # homeassistant.components.openai_conversation -openai==1.65.2 +openai==1.66.3 # homeassistant.components.openerz openerz-api==0.3.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f6880d377be..99cdb5004a0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1326,7 +1326,7 @@ open-garage==0.2.0 open-meteo==0.3.2 # homeassistant.components.openai_conversation -openai==1.65.2 +openai==1.66.3 # homeassistant.components.openerz openerz-api==0.3.0 From 7b9ea63f171f3c7fb9f186a38833e5ea383497d4 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Sun, 16 Mar 2025 03:26:18 +0100 Subject: [PATCH 2655/3148] Split out yaml loading into own package (#140683) * Split out yaml loading into library * Code review * Code review * Fix check config script --- homeassistant/helpers/check_config.py | 2 +- homeassistant/package_constraints.txt | 1 + homeassistant/scripts/check_config.py | 8 +- homeassistant/util/yaml/__init__.py | 16 +- homeassistant/util/yaml/const.py | 3 - homeassistant/util/yaml/dumper.py | 95 +---- homeassistant/util/yaml/input.py | 51 +-- homeassistant/util/yaml/loader.py | 501 +++----------------------- homeassistant/util/yaml/objects.py | 50 +-- pyproject.toml | 1 + requirements.txt | 1 + tests/common.py | 2 +- tests/helpers/test_service.py | 4 +- tests/snapshots/test_config.ambr | 10 +- tests/util/yaml/test_init.py | 4 +- 15 files changed, 71 insertions(+), 678 deletions(-) delete mode 100644 homeassistant/util/yaml/const.py diff --git a/homeassistant/helpers/check_config.py b/homeassistant/helpers/check_config.py index 0841585e1a1..836536da9ee 100644 --- a/homeassistant/helpers/check_config.py +++ b/homeassistant/helpers/check_config.py @@ -8,6 +8,7 @@ import os from pathlib import Path from typing import NamedTuple, Self +from annotatedyaml import loader as yaml_loader import voluptuous as vol from homeassistant import loader @@ -29,7 +30,6 @@ from homeassistant.requirements import ( async_clear_install_history, async_get_integration_with_requirements, ) -from homeassistant.util.yaml import loader as yaml_loader from . import config_validation as cv from .typing import ConfigType diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 59a56c8ea15..3a13b59eced 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -10,6 +10,7 @@ aiohttp==3.11.13 aiohttp_cors==0.7.0 aiousbwatcher==1.1.1 aiozoneinfo==0.2.3 +annotatedyaml==0.1.1 astral==2.2 async-interrupt==1.2.2 async-upnp-client==0.43.0 diff --git a/homeassistant/scripts/check_config.py b/homeassistant/scripts/check_config.py index a24568e9a6f..ca3df5080b5 100644 --- a/homeassistant/scripts/check_config.py +++ b/homeassistant/scripts/check_config.py @@ -12,6 +12,9 @@ import os from typing import Any from unittest.mock import patch +from annotatedyaml import loader as yaml_loader +from annotatedyaml.loader import Secrets + from homeassistant import core, loader from homeassistant.config import get_default_config_dir from homeassistant.config_entries import ConfigEntries @@ -23,7 +26,6 @@ from homeassistant.helpers import ( issue_registry as ir, ) from homeassistant.helpers.check_config import async_check_ha_config_file -from homeassistant.util.yaml import Secrets, loader as yaml_loader # mypy: allow-untyped-calls, allow-untyped-defs @@ -31,9 +33,9 @@ REQUIREMENTS = ("colorlog==6.8.2",) _LOGGER = logging.getLogger(__name__) MOCKS: dict[str, tuple[str, Callable]] = { - "load": ("homeassistant.util.yaml.loader.load_yaml", yaml_loader.load_yaml), + "load": ("annotatedyaml.loader.load_yaml", yaml_loader.load_yaml), "load*": ("homeassistant.config.load_yaml_dict", yaml_loader.load_yaml_dict), - "secrets": ("homeassistant.util.yaml.loader.secret_yaml", yaml_loader.secret_yaml), + "secrets": ("annotatedyaml.loader.secret_yaml", yaml_loader.secret_yaml), } PATCHES: dict[str, Any] = {} diff --git a/homeassistant/util/yaml/__init__.py b/homeassistant/util/yaml/__init__.py index 3b1f5c4cc0a..a3c0ab3d083 100644 --- a/homeassistant/util/yaml/__init__.py +++ b/homeassistant/util/yaml/__init__.py @@ -1,17 +1,11 @@ """YAML utility functions.""" -from .const import SECRET_YAML +from annotatedyaml import SECRET_YAML, YamlTypeError +from annotatedyaml.input import UndefinedSubstitution, extract_inputs, substitute +from annotatedyaml.objects import Input + from .dumper import dump, save_yaml -from .input import UndefinedSubstitution, extract_inputs, substitute -from .loader import ( - Secrets, - YamlTypeError, - load_yaml, - load_yaml_dict, - parse_yaml, - secret_yaml, -) -from .objects import Input +from .loader import Secrets, load_yaml, load_yaml_dict, parse_yaml, secret_yaml __all__ = [ "SECRET_YAML", diff --git a/homeassistant/util/yaml/const.py b/homeassistant/util/yaml/const.py deleted file mode 100644 index 811c7d149f7..00000000000 --- a/homeassistant/util/yaml/const.py +++ /dev/null @@ -1,3 +0,0 @@ -"""Constants.""" - -SECRET_YAML = "secrets.yaml" diff --git a/homeassistant/util/yaml/dumper.py b/homeassistant/util/yaml/dumper.py index 61772b6989d..059be2c1c5b 100644 --- a/homeassistant/util/yaml/dumper.py +++ b/homeassistant/util/yaml/dumper.py @@ -1,96 +1,5 @@ """Custom dumper and representers.""" -from collections import OrderedDict -from typing import Any +from annotatedyaml.dumper import add_representer, dump, represent_odict, save_yaml -import yaml - -from .objects import Input, NodeDictClass, NodeListClass, NodeStrClass - -# mypy: allow-untyped-calls, no-warn-return-any - - -try: - from yaml import CSafeDumper as FastestAvailableSafeDumper -except ImportError: - from yaml import ( # type: ignore[assignment] - SafeDumper as FastestAvailableSafeDumper, - ) - - -def dump(_dict: dict | list) -> str: - """Dump YAML to a string and remove null.""" - return yaml.dump( - _dict, - default_flow_style=False, - allow_unicode=True, - sort_keys=False, - Dumper=FastestAvailableSafeDumper, - ).replace(": null\n", ":\n") - - -def save_yaml(path: str, data: dict) -> None: - """Save YAML to a file.""" - # Dump before writing to not truncate the file if dumping fails - str_data = dump(data) - with open(path, "w", encoding="utf-8") as outfile: - outfile.write(str_data) - - -# From: https://gist.github.com/miracle2k/3184458 -def represent_odict( # type: ignore[no-untyped-def] - dumper, tag, mapping, flow_style=None -) -> yaml.MappingNode: - """Like BaseRepresenter.represent_mapping but does not issue the sort().""" - value: list = [] - node = yaml.MappingNode(tag, value, flow_style=flow_style) - if dumper.alias_key is not None: - dumper.represented_objects[dumper.alias_key] = node - best_style = True - if hasattr(mapping, "items"): - mapping = mapping.items() - for item_key, item_value in mapping: - node_key = dumper.represent_data(item_key) - node_value = dumper.represent_data(item_value) - if not (isinstance(node_key, yaml.ScalarNode) and not node_key.style): - best_style = False - if not (isinstance(node_value, yaml.ScalarNode) and not node_value.style): - best_style = False - value.append((node_key, node_value)) - if flow_style is None: - if dumper.default_flow_style is not None: - node.flow_style = dumper.default_flow_style - else: - node.flow_style = best_style - return node - - -def add_representer(klass: Any, representer: Any) -> None: - """Add to representer to the dumper.""" - FastestAvailableSafeDumper.add_representer(klass, representer) - - -add_representer( - OrderedDict, - lambda dumper, value: represent_odict(dumper, "tag:yaml.org,2002:map", value), -) - -add_representer( - NodeDictClass, - lambda dumper, value: represent_odict(dumper, "tag:yaml.org,2002:map", value), -) - -add_representer( - NodeListClass, - lambda dumper, value: dumper.represent_sequence("tag:yaml.org,2002:seq", value), -) - -add_representer( - NodeStrClass, - lambda dumper, value: dumper.represent_scalar("tag:yaml.org,2002:str", str(value)), -) - -add_representer( - Input, - lambda dumper, value: dumper.represent_scalar("!input", value.name), -) +__all__ = ["add_representer", "dump", "represent_odict", "save_yaml"] diff --git a/homeassistant/util/yaml/input.py b/homeassistant/util/yaml/input.py index ff9b37f18f1..5dad8a63ae5 100644 --- a/homeassistant/util/yaml/input.py +++ b/homeassistant/util/yaml/input.py @@ -2,55 +2,8 @@ from __future__ import annotations -from typing import Any +from annotatedyaml.input import UndefinedSubstitution, extract_inputs, substitute from .objects import Input - -class UndefinedSubstitution(Exception): - """Error raised when we find a substitution that is not defined.""" - - def __init__(self, input_name: str) -> None: - """Initialize the undefined substitution exception.""" - super().__init__(f"No substitution found for input {input_name}") - self.input = input - - -def extract_inputs(obj: Any) -> set[str]: - """Extract input from a structure.""" - found: set[str] = set() - _extract_inputs(obj, found) - return found - - -def _extract_inputs(obj: Any, found: set[str]) -> None: - """Extract input from a structure.""" - if isinstance(obj, Input): - found.add(obj.name) - return - - if isinstance(obj, list): - for val in obj: - _extract_inputs(val, found) - return - - if isinstance(obj, dict): - for val in obj.values(): - _extract_inputs(val, found) - return - - -def substitute(obj: Any, substitutions: dict[str, Any]) -> Any: - """Substitute values.""" - if isinstance(obj, Input): - if obj.name not in substitutions: - raise UndefinedSubstitution(obj.name) - return substitutions[obj.name] - - if isinstance(obj, list): - return [substitute(val, substitutions) for val in obj] - - if isinstance(obj, dict): - return {key: substitute(val, substitutions) for key, val in obj.items()} - - return obj +__all__ = ["Input", "UndefinedSubstitution", "extract_inputs", "substitute"] diff --git a/homeassistant/util/yaml/loader.py b/homeassistant/util/yaml/loader.py index 3911d62040b..1f8338a1ff7 100644 --- a/homeassistant/util/yaml/loader.py +++ b/homeassistant/util/yaml/loader.py @@ -2,157 +2,37 @@ from __future__ import annotations -from collections.abc import Callable, Iterator -import fnmatch -from io import StringIO, TextIOWrapper -import logging +from io import StringIO import os -from pathlib import Path -from typing import Any, TextIO, overload +from typing import TextIO +from annotatedyaml import YAMLException, YamlTypeError +from annotatedyaml.loader import ( + HAS_C_LOADER, + JSON_TYPE, + LoaderType, + Secrets, + add_constructor, + load_yaml as load_annotated_yaml, + load_yaml_dict as load_annotated_yaml_dict, + parse_yaml as parse_annotated_yaml, + secret_yaml as annotated_secret_yaml, +) import yaml -try: - from yaml import CSafeLoader as FastestAvailableSafeLoader - - HAS_C_LOADER = True -except ImportError: - HAS_C_LOADER = False - from yaml import ( # type: ignore[assignment] - SafeLoader as FastestAvailableSafeLoader, - ) - -from propcache.api import cached_property - from homeassistant.exceptions import HomeAssistantError -from .const import SECRET_YAML -from .objects import Input, NodeDictClass, NodeListClass, NodeStrClass - -# mypy: allow-untyped-calls, no-warn-return-any - -JSON_TYPE = list | dict | str - -_LOGGER = logging.getLogger(__name__) - - -class YamlTypeError(HomeAssistantError): - """Raised by load_yaml_dict if top level data is not a dict.""" - - -class Secrets: - """Store secrets while loading YAML.""" - - def __init__(self, config_dir: Path) -> None: - """Initialize secrets.""" - self.config_dir = config_dir - self._cache: dict[Path, dict[str, str]] = {} - - def get(self, requester_path: str, secret: str) -> str: - """Return the value of a secret.""" - current_path = Path(requester_path) - - secret_dir = current_path - while True: - secret_dir = secret_dir.parent - - try: - secret_dir.relative_to(self.config_dir) - except ValueError: - # We went above the config dir - break - - secrets = self._load_secret_yaml(secret_dir) - - if secret in secrets: - _LOGGER.debug( - "Secret %s retrieved from secrets.yaml in folder %s", - secret, - secret_dir, - ) - return secrets[secret] - - raise HomeAssistantError(f"Secret {secret} not defined") - - def _load_secret_yaml(self, secret_dir: Path) -> dict[str, str]: - """Load the secrets yaml from path.""" - if (secret_path := secret_dir / SECRET_YAML) in self._cache: - return self._cache[secret_path] - - _LOGGER.debug("Loading %s", secret_path) - try: - secrets = load_yaml(str(secret_path)) - - if not isinstance(secrets, dict): - raise HomeAssistantError("Secrets is not a dictionary") - - if "logger" in secrets: - logger = str(secrets["logger"]).lower() - if logger == "debug": - _LOGGER.setLevel(logging.DEBUG) - else: - _LOGGER.error( - ( - "Error in secrets.yaml: 'logger: debug' expected, but" - " 'logger: %s' found" - ), - logger, - ) - del secrets["logger"] - except FileNotFoundError: - secrets = {} - - self._cache[secret_path] = secrets - - return secrets - - -class _LoaderMixin: - """Mixin class with extensions for YAML loader.""" - - name: str - stream: Any - - @cached_property - def get_name(self) -> str: - """Get the name of the loader.""" - return self.name - - @cached_property - def get_stream_name(self) -> str: - """Get the name of the stream.""" - return getattr(self.stream, "name", "") - - -class FastSafeLoader(FastestAvailableSafeLoader, _LoaderMixin): - """The fastest available safe loader, either C or Python.""" - - def __init__(self, stream: Any, secrets: Secrets | None = None) -> None: - """Initialize a safe line loader.""" - self.stream = stream - - # Set name in same way as the Python loader does in yaml.reader.__init__ - if isinstance(stream, str): - self.name = "" - elif isinstance(stream, bytes): - self.name = "" - else: - self.name = getattr(stream, "name", "") - - super().__init__(stream) - self.secrets = secrets - - -class PythonSafeLoader(yaml.SafeLoader, _LoaderMixin): - """Python safe loader.""" - - def __init__(self, stream: Any, secrets: Secrets | None = None) -> None: - """Initialize a safe line loader.""" - super().__init__(stream) - self.secrets = secrets - - -type LoaderType = FastSafeLoader | PythonSafeLoader +__all__ = [ + "HAS_C_LOADER", + "JSON_TYPE", + "Secrets", + "YamlTypeError", + "add_constructor", + "load_yaml", + "load_yaml_dict", + "parse_yaml", + "secret_yaml", +] def load_yaml( @@ -164,15 +44,9 @@ def load_yaml( except for FileNotFoundError which will be re-raised. """ try: - with open(fname, encoding="utf-8") as conf_file: - return parse_yaml(conf_file, secrets) - except UnicodeDecodeError as exc: - _LOGGER.error("Unable to read file %s: %s", fname, exc) - raise HomeAssistantError(exc) from exc - except FileNotFoundError: - raise - except OSError as exc: - raise HomeAssistantError(exc) from exc + return load_annotated_yaml(fname, secrets) + except YAMLException as exc: + raise HomeAssistantError(str(exc)) from exc def load_yaml_dict( @@ -183,320 +57,27 @@ def load_yaml_dict( Raise if the top level is not a dict. Return an empty dict if the file is empty. """ - loaded_yaml = load_yaml(fname, secrets) - if loaded_yaml is None: - loaded_yaml = {} - if not isinstance(loaded_yaml, dict): - raise YamlTypeError(f"YAML file {fname} does not contain a dict") - return loaded_yaml + try: + return load_annotated_yaml_dict(fname, secrets) + except YamlTypeError: + raise + except YAMLException as exc: + raise HomeAssistantError(str(exc)) from exc def parse_yaml( content: str | TextIO | StringIO, secrets: Secrets | None = None ) -> JSON_TYPE: """Parse YAML with the fastest available loader.""" - if not HAS_C_LOADER: - return _parse_yaml_python(content, secrets) try: - return _parse_yaml(FastSafeLoader, content, secrets) - except yaml.YAMLError: - # Loading failed, so we now load with the Python loader which has more - # readable exceptions - if isinstance(content, (StringIO, TextIO, TextIOWrapper)): - # Rewind the stream so we can try again - content.seek(0, 0) - return _parse_yaml_python(content, secrets) - - -def _parse_yaml_python( - content: str | TextIO | StringIO, secrets: Secrets | None = None -) -> JSON_TYPE: - """Parse YAML with the python loader (this is very slow).""" - try: - return _parse_yaml(PythonSafeLoader, content, secrets) - except yaml.YAMLError as exc: - _LOGGER.error(str(exc)) - raise HomeAssistantError(exc) from exc - - -def _parse_yaml( - loader: type[FastSafeLoader | PythonSafeLoader], - content: str | TextIO, - secrets: Secrets | None = None, -) -> JSON_TYPE: - """Load a YAML file.""" - return yaml.load(content, Loader=lambda stream: loader(stream, secrets)) # type: ignore[arg-type] - - -@overload -def _add_reference( - obj: list | NodeListClass, loader: LoaderType, node: yaml.nodes.Node -) -> NodeListClass: ... - - -@overload -def _add_reference( - obj: str | NodeStrClass, loader: LoaderType, node: yaml.nodes.Node -) -> NodeStrClass: ... - - -@overload -def _add_reference( - obj: dict | NodeDictClass, loader: LoaderType, node: yaml.nodes.Node -) -> NodeDictClass: ... - - -def _add_reference( - obj: dict | list | str | NodeDictClass | NodeListClass | NodeStrClass, - loader: LoaderType, - node: yaml.nodes.Node, -) -> NodeDictClass | NodeListClass | NodeStrClass: - """Add file reference information to an object.""" - if isinstance(obj, list): - obj = NodeListClass(obj) - elif isinstance(obj, str): - obj = NodeStrClass(obj) - elif isinstance(obj, dict): - obj = NodeDictClass(obj) - return _add_reference_to_node_class(obj, loader, node) - - -@overload -def _add_reference_to_node_class( - obj: NodeListClass, loader: LoaderType, node: yaml.nodes.Node -) -> NodeListClass: ... - - -@overload -def _add_reference_to_node_class( - obj: NodeStrClass, loader: LoaderType, node: yaml.nodes.Node -) -> NodeStrClass: ... - - -@overload -def _add_reference_to_node_class( - obj: NodeDictClass, loader: LoaderType, node: yaml.nodes.Node -) -> NodeDictClass: ... - - -def _add_reference_to_node_class( - obj: NodeDictClass | NodeListClass | NodeStrClass, - loader: LoaderType, - node: yaml.nodes.Node, -) -> NodeDictClass | NodeListClass | NodeStrClass: - """Add file reference information to a node class object.""" - try: # suppress is much slower - obj.__config_file__ = loader.get_name - obj.__line__ = node.start_mark.line + 1 - except AttributeError: - pass - return obj - - -def _raise_if_no_value[NodeT: yaml.nodes.Node, _R]( - func: Callable[[LoaderType, NodeT], _R], -) -> Callable[[LoaderType, NodeT], _R]: - def wrapper(loader: LoaderType, node: NodeT) -> _R: - if not node.value: - raise HomeAssistantError( - f"{node.start_mark}: {node.tag} needs an argument." - ) - return func(loader, node) - - return wrapper - - -@_raise_if_no_value -def _include_yaml(loader: LoaderType, node: yaml.nodes.Node) -> JSON_TYPE: - """Load another YAML file and embed it using the !include tag. - - Example: - device_tracker: !include device_tracker.yaml - - """ - fname = os.path.join(os.path.dirname(loader.get_name), node.value) - try: - loaded_yaml = load_yaml(fname, loader.secrets) - if loaded_yaml is None: - loaded_yaml = NodeDictClass() - return _add_reference(loaded_yaml, loader, node) - except FileNotFoundError as exc: - raise HomeAssistantError( - f"{node.start_mark}: Unable to read file {fname}" - ) from exc - - -def _is_file_valid(name: str) -> bool: - """Decide if a file is valid.""" - return not name.startswith(".") - - -def _find_files(directory: str, pattern: str) -> Iterator[str]: - """Recursively load files in a directory.""" - for root, dirs, files in os.walk(directory, topdown=True): - dirs[:] = [d for d in dirs if _is_file_valid(d)] - for basename in sorted(files): - if _is_file_valid(basename) and fnmatch.fnmatch(basename, pattern): - filename = os.path.join(root, basename) - yield filename - - -@_raise_if_no_value -def _include_dir_named_yaml(loader: LoaderType, node: yaml.nodes.Node) -> NodeDictClass: - """Load multiple files from directory as a dictionary.""" - mapping = NodeDictClass() - loc = os.path.join(os.path.dirname(loader.get_name), node.value) - for fname in _find_files(loc, "*.yaml"): - filename = os.path.splitext(os.path.basename(fname))[0] - if os.path.basename(fname) == SECRET_YAML: - continue - loaded_yaml = load_yaml(fname, loader.secrets) - if loaded_yaml is None: - # Special case, an empty file included by !include_dir_named is treated - # as an empty dictionary - loaded_yaml = NodeDictClass() - mapping[filename] = loaded_yaml - return _add_reference_to_node_class(mapping, loader, node) - - -@_raise_if_no_value -def _include_dir_merge_named_yaml( - loader: LoaderType, node: yaml.nodes.Node -) -> NodeDictClass: - """Load multiple files from directory as a merged dictionary.""" - mapping = NodeDictClass() - loc = os.path.join(os.path.dirname(loader.get_name), node.value) - for fname in _find_files(loc, "*.yaml"): - if os.path.basename(fname) == SECRET_YAML: - continue - loaded_yaml = load_yaml(fname, loader.secrets) - if isinstance(loaded_yaml, dict): - mapping.update(loaded_yaml) - return _add_reference_to_node_class(mapping, loader, node) - - -@_raise_if_no_value -def _include_dir_list_yaml( - loader: LoaderType, node: yaml.nodes.Node -) -> list[JSON_TYPE]: - """Load multiple files from directory as a list.""" - loc = os.path.join(os.path.dirname(loader.get_name), node.value) - return [ - loaded_yaml - for f in _find_files(loc, "*.yaml") - if os.path.basename(f) != SECRET_YAML - and (loaded_yaml := load_yaml(f, loader.secrets)) is not None - ] - - -@_raise_if_no_value -def _include_dir_merge_list_yaml( - loader: LoaderType, node: yaml.nodes.Node -) -> JSON_TYPE: - """Load multiple files from directory as a merged list.""" - loc: str = os.path.join(os.path.dirname(loader.get_name), node.value) - merged_list: list[JSON_TYPE] = [] - for fname in _find_files(loc, "*.yaml"): - if os.path.basename(fname) == SECRET_YAML: - continue - loaded_yaml = load_yaml(fname, loader.secrets) - if isinstance(loaded_yaml, list): - merged_list.extend(loaded_yaml) - return _add_reference(merged_list, loader, node) - - -def _handle_mapping_tag( - loader: LoaderType, node: yaml.nodes.MappingNode -) -> NodeDictClass: - """Load YAML mappings into an ordered dictionary to preserve key order.""" - loader.flatten_mapping(node) - nodes = loader.construct_pairs(node) - - seen: dict = {} - for (key, _), (child_node, _) in zip(nodes, node.value, strict=False): - line = child_node.start_mark.line - - try: - hash(key) - except TypeError as exc: - fname = loader.get_stream_name - raise yaml.MarkedYAMLError( - context=f'invalid key: "{key}"', - context_mark=yaml.Mark( - fname, - 0, - line, - -1, - None, - None, # type: ignore[arg-type] - ), - ) from exc - - if key in seen: - fname = loader.get_stream_name - _LOGGER.warning( - 'YAML file %s contains duplicate key "%s". Check lines %d and %d', - fname, - key, - seen[key], - line, - ) - seen[key] = line - - return _add_reference_to_node_class(NodeDictClass(nodes), loader, node) - - -def _construct_seq(loader: LoaderType, node: yaml.nodes.Node) -> JSON_TYPE: - """Add line number and file name to Load YAML sequence.""" - (obj,) = loader.construct_yaml_seq(node) - return _add_reference(obj, loader, node) - - -def _handle_scalar_tag( - loader: LoaderType, node: yaml.nodes.ScalarNode -) -> str | int | float | None: - """Add line number and file name to Load YAML sequence.""" - obj = node.value - if not isinstance(obj, str): - return obj - return _add_reference_to_node_class(NodeStrClass(obj), loader, node) - - -def _env_var_yaml(loader: LoaderType, node: yaml.nodes.Node) -> str: - """Load environment variables and embed it into the configuration YAML.""" - args = node.value.split() - - # Check for a default value - if len(args) > 1: - return os.getenv(args[0], " ".join(args[1:])) - if args[0] in os.environ: - return os.environ[args[0]] - _LOGGER.error("Environment variable %s not defined", node.value) - raise HomeAssistantError(node.value) + return parse_annotated_yaml(content, secrets) + except YAMLException as exc: + raise HomeAssistantError(str(exc)) from exc def secret_yaml(loader: LoaderType, node: yaml.nodes.Node) -> JSON_TYPE: """Load secrets and embed it into the configuration YAML.""" - if loader.secrets is None: - raise HomeAssistantError("Secrets not supported in this YAML file") - - return loader.secrets.get(loader.get_name, node.value) - - -def add_constructor(tag: Any, constructor: Any) -> None: - """Add to constructor to all loaders.""" - for yaml_loader in (FastSafeLoader, PythonSafeLoader): - yaml_loader.add_constructor(tag, constructor) - - -add_constructor("!include", _include_yaml) -add_constructor(yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, _handle_mapping_tag) -add_constructor(yaml.resolver.BaseResolver.DEFAULT_SCALAR_TAG, _handle_scalar_tag) -add_constructor(yaml.resolver.BaseResolver.DEFAULT_SEQUENCE_TAG, _construct_seq) -add_constructor("!env_var", _env_var_yaml) -add_constructor("!secret", secret_yaml) -add_constructor("!include_dir_list", _include_dir_list_yaml) -add_constructor("!include_dir_merge_list", _include_dir_merge_list_yaml) -add_constructor("!include_dir_named", _include_dir_named_yaml) -add_constructor("!include_dir_merge_named", _include_dir_merge_named_yaml) -add_constructor("!input", Input.from_node) + try: + return annotated_secret_yaml(loader, node) + except YAMLException as exc: + raise HomeAssistantError(str(exc)) from exc diff --git a/homeassistant/util/yaml/objects.py b/homeassistant/util/yaml/objects.py index 7e4019331c6..4b21e8118b3 100644 --- a/homeassistant/util/yaml/objects.py +++ b/homeassistant/util/yaml/objects.py @@ -2,52 +2,6 @@ from __future__ import annotations -from dataclasses import dataclass -from typing import Any +from annotatedyaml.objects import Input, NodeDictClass, NodeListClass, NodeStrClass -import voluptuous as vol -from voluptuous.schema_builder import _compile_scalar -import yaml - - -class NodeListClass(list): - """Wrapper class to be able to add attributes on a list.""" - - __slots__ = ("__config_file__", "__line__") - - __config_file__: str - __line__: int | str - - -class NodeStrClass(str): - """Wrapper class to be able to add attributes on a string.""" - - __slots__ = ("__config_file__", "__line__") - - __config_file__: str - __line__: int | str - - def __voluptuous_compile__(self, schema: vol.Schema) -> Any: - """Needed because vol.Schema.compile does not handle str subclasses.""" - return _compile_scalar(self) # type: ignore[no-untyped-call] - - -class NodeDictClass(dict): - """Wrapper class to be able to add attributes on a dict.""" - - __slots__ = ("__config_file__", "__line__") - - __config_file__: str - __line__: int | str - - -@dataclass(slots=True, frozen=True) -class Input: - """Input that should be substituted.""" - - name: str - - @classmethod - def from_node(cls, loader: yaml.Loader, node: yaml.nodes.Node) -> Input: - """Create a new placeholder from a node.""" - return cls(node.value) +__all__ = ["Input", "NodeDictClass", "NodeListClass", "NodeStrClass"] diff --git a/pyproject.toml b/pyproject.toml index 6003b3d1de3..a2f1e9360f3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,6 +33,7 @@ dependencies = [ "aiohttp-fast-zlib==0.2.3", "aiohttp-asyncmdnsresolver==0.1.1", "aiozoneinfo==0.2.3", + "annotatedyaml==0.1.1", "astral==2.2", "async-interrupt==1.2.2", "attrs==25.1.0", diff --git a/requirements.txt b/requirements.txt index 13c58f6cd71..1397b6bec06 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,6 +10,7 @@ aiohttp_cors==0.7.0 aiohttp-fast-zlib==0.2.3 aiohttp-asyncmdnsresolver==0.1.1 aiozoneinfo==0.2.3 +annotatedyaml==0.1.1 astral==2.2 async-interrupt==1.2.2 attrs==25.1.0 diff --git a/tests/common.py b/tests/common.py index df674d1824c..f426d2aebd2 100644 --- a/tests/common.py +++ b/tests/common.py @@ -29,6 +29,7 @@ from typing import Any, Literal, NoReturn from unittest.mock import AsyncMock, Mock, patch from aiohttp.test_utils import unused_port as get_test_instance_port # noqa: F401 +from annotatedyaml import load_yaml_dict, loader as yaml_loader import pytest from syrupy import SnapshotAssertion import voluptuous as vol @@ -109,7 +110,6 @@ from homeassistant.util.json import ( ) from homeassistant.util.signal_type import SignalType from homeassistant.util.unit_system import METRIC_SYSTEM -from homeassistant.util.yaml import load_yaml_dict, loader as yaml_loader from .testing_config.custom_components.test_constant_deprecation import ( import_deprecated_constant, diff --git a/tests/helpers/test_service.py b/tests/helpers/test_service.py index 142f7a23f81..70ab20e87fa 100644 --- a/tests/helpers/test_service.py +++ b/tests/helpers/test_service.py @@ -961,7 +961,7 @@ async def test_async_get_all_descriptions_dot_keys(hass: HomeAssistant) -> None: side_effect=service._load_services_files, ) as proxy_load_services_files, patch( - "homeassistant.util.yaml.loader.load_yaml", + "annotatedyaml.loader.load_yaml", side_effect=load_yaml, ) as mock_load_yaml, ): @@ -1033,7 +1033,7 @@ async def test_async_get_all_descriptions_filter(hass: HomeAssistant) -> None: side_effect=service._load_services_files, ) as proxy_load_services_files, patch( - "homeassistant.util.yaml.loader.load_yaml", + "annotatedyaml.loader.load_yaml", side_effect=load_yaml, ) as mock_load_yaml, ): diff --git a/tests/snapshots/test_config.ambr b/tests/snapshots/test_config.ambr index 6fcbce7d8d6..7531bf5a663 100644 --- a/tests/snapshots/test_config.ambr +++ b/tests/snapshots/test_config.ambr @@ -434,7 +434,7 @@ # name: test_yaml_error[basic] ''' mapping values are not allowed here - in "configuration.yaml", line 4, column 14 + in "/fixtures/core/config/yaml_errors/basic/configuration.yaml", line 4, column 14 ''' # --- # name: test_yaml_error[basic].1 @@ -448,7 +448,7 @@ # name: test_yaml_error[basic_include] ''' mapping values are not allowed here - in "integrations/iot_domain.yaml", line 3, column 12 + in "/fixtures/core/config/yaml_errors/basic_include/integrations/iot_domain.yaml", line 3, column 12 ''' # --- # name: test_yaml_error[basic_include].1 @@ -462,7 +462,7 @@ # name: test_yaml_error[include_dir_list] ''' mapping values are not allowed here - in "iot_domain/iot_domain_1.yaml", line 3, column 10 + in "/fixtures/core/config/yaml_errors/include_dir_list/iot_domain/iot_domain_1.yaml", line 3, column 10 ''' # --- # name: test_yaml_error[include_dir_list].1 @@ -476,7 +476,7 @@ # name: test_yaml_error[include_dir_merge_list] ''' mapping values are not allowed here - in "iot_domain/iot_domain_1.yaml", line 3, column 12 + in "/fixtures/core/config/yaml_errors/include_dir_merge_list/iot_domain/iot_domain_1.yaml", line 3, column 12 ''' # --- # name: test_yaml_error[include_dir_merge_list].1 @@ -490,7 +490,7 @@ # name: test_yaml_error[packages_include_dir_named] ''' mapping values are not allowed here - in "integrations/adr_0007_1.yaml", line 4, column 9 + in "/fixtures/core/config/yaml_errors/packages_include_dir_named/integrations/adr_0007_1.yaml", line 4, column 9 ''' # --- # name: test_yaml_error[packages_include_dir_named].1 diff --git a/tests/util/yaml/test_init.py b/tests/util/yaml/test_init.py index 0346e21044f..dacbd2c1247 100644 --- a/tests/util/yaml/test_init.py +++ b/tests/util/yaml/test_init.py @@ -374,7 +374,7 @@ def test_include_dir_merge_named_recursive(mock_walk: Mock) -> None: } -@patch("homeassistant.util.yaml.loader.open", create=True) +@patch("annotatedyaml.loader.open", create=True) @pytest.mark.usefixtures("try_both_loaders") def test_load_yaml_encoding_error(mock_open: Mock) -> None: """Test raising a UnicodeDecodeError.""" @@ -598,7 +598,7 @@ def test_load_yaml_wrap_oserror( ) -> None: """Test load_yaml wraps OSError in HomeAssistantError.""" with ( - patch("homeassistant.util.yaml.loader.open", side_effect=open_exception), + patch("annotatedyaml.loader.open", side_effect=open_exception), pytest.raises(load_yaml_exception), ): yaml_loader.load_yaml("bla") From 6b6470f3456929b06ca7ebbed329bcdde1e0036d Mon Sep 17 00:00:00 2001 From: Serge Wagener <5746932+Foxi352@users.noreply.github.com> Date: Sun, 16 Mar 2025 08:29:44 +0100 Subject: [PATCH 2656/3148] Update knx-frontend and increase BinarySensor reset_after limit (#140196) Bumped to newest knx-frontend version and adapt knx ui schema --- homeassistant/components/knx/manifest.json | 2 +- homeassistant/components/knx/storage/entity_store_schema.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/knx/manifest.json b/homeassistant/components/knx/manifest.json index 98e3a6a5242..bde6dfa226f 100644 --- a/homeassistant/components/knx/manifest.json +++ b/homeassistant/components/knx/manifest.json @@ -12,7 +12,7 @@ "requirements": [ "xknx==3.6.0", "xknxproject==3.8.2", - "knx-frontend==2025.1.30.194235" + "knx-frontend==2025.3.8.214559" ], "single_config_entry": true } diff --git a/homeassistant/components/knx/storage/entity_store_schema.py b/homeassistant/components/knx/storage/entity_store_schema.py index d99ffa86f52..cde18a181ec 100644 --- a/homeassistant/components/knx/storage/entity_store_schema.py +++ b/homeassistant/components/knx/storage/entity_store_schema.py @@ -114,7 +114,7 @@ BINARY_SENSOR_SCHEMA = vol.Schema( ), vol.Optional(CONF_RESET_AFTER): selector.NumberSelector( selector.NumberSelectorConfig( - min=0, max=10, step=0.1, unit_of_measurement="s" + min=0, max=600, step=0.1, unit_of_measurement="s" ) ), }, diff --git a/requirements_all.txt b/requirements_all.txt index 0b8d1da4499..758456c5e9b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1285,7 +1285,7 @@ kiwiki-client==0.1.1 knocki==0.4.2 # homeassistant.components.knx -knx-frontend==2025.1.30.194235 +knx-frontend==2025.3.8.214559 # homeassistant.components.konnected konnected==1.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 99cdb5004a0..562ccd14163 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1084,7 +1084,7 @@ kegtron-ble==0.4.0 knocki==0.4.2 # homeassistant.components.knx -knx-frontend==2025.1.30.194235 +knx-frontend==2025.3.8.214559 # homeassistant.components.konnected konnected==1.2.0 From 5f8564bfc5572ae15b346d8fcbe4e0fb1fe698c8 Mon Sep 17 00:00:00 2001 From: Pete Sage <76050312+PeteRager@users.noreply.github.com> Date: Sun, 16 Mar 2025 05:11:08 -0400 Subject: [PATCH 2657/3148] Fix audiobooks always start from beginning on Sonos (#140663) * play audible favorite * play audible favorite * simplify tests --- .../components/sonos/media_player.py | 19 ++++++++++++++----- tests/components/sonos/test_media_player.py | 12 ++++++++++++ 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/sonos/media_player.py b/homeassistant/components/sonos/media_player.py index 0c66484202f..a774de0ae5b 100644 --- a/homeassistant/components/sonos/media_player.py +++ b/homeassistant/components/sonos/media_player.py @@ -462,11 +462,20 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): """Play a favorite.""" uri = favorite.reference.get_uri() soco = self.coordinator.soco - if soco.music_source_from_uri(uri) in [ - MUSIC_SRC_RADIO, - MUSIC_SRC_LINE_IN, - ]: - soco.play_uri(uri, title=favorite.title, timeout=LONG_SERVICE_TIMEOUT) + if ( + soco.music_source_from_uri(uri) + in [ + MUSIC_SRC_RADIO, + MUSIC_SRC_LINE_IN, + ] + or favorite.reference.item_class == "object.item.audioItem.audioBook" + ): + soco.play_uri( + uri, + title=favorite.title, + meta=favorite.resource_meta_data, + timeout=LONG_SERVICE_TIMEOUT, + ) else: soco.clear_queue() soco.add_to_queue(favorite.reference, timeout=LONG_SERVICE_TIMEOUT) diff --git a/tests/components/sonos/test_media_player.py b/tests/components/sonos/test_media_player.py index cec40c997a7..78d88a1ea98 100644 --- a/tests/components/sonos/test_media_player.py +++ b/tests/components/sonos/test_media_player.py @@ -692,6 +692,7 @@ async def test_select_source_line_in_tv( "play_uri": 1, "play_uri_uri": "x-sonosapi-radio:ST%3aetc", "play_uri_title": "James Taylor Radio", + "play_uri_meta": 'James Taylor Radioobject.item.audioItem.audioBroadcast.#stationSA_RINCON60423_X_#Svc60423-99999999-Token', }, ), ( @@ -700,6 +701,16 @@ async def test_select_source_line_in_tv( "play_uri": 1, "play_uri_uri": "x-sonosapi-hls:Api%3atune%3aliveAudio%3ajazzcafe%3aetc", "play_uri_title": "66 - Watercolors", + "play_uri_meta": '66 - Watercolorsobject.item.audioItem.audioBroadcastSA_RINCON9479_X_#Svc9479-99999999-Token', + }, + ), + ( + "American Tall Tales", + { + "play_uri": 1, + "play_uri_uri": "x-rincon-cpcontainer:101340c8reftitle%C9F27_com?sid=239&flags=16584&sn=5", + "play_uri_title": "American Tall Tales", + "play_uri_meta": 'American Tall Talesobject.item.audioItem.audioBookSA_RINCON61191_X_#Svc6-0-Token', }, ), ], @@ -726,6 +737,7 @@ async def test_select_source_play_uri( soco_mock.play_uri.assert_called_with( result.get("play_uri_uri"), title=result.get("play_uri_title"), + meta=result.get("play_uri_meta"), timeout=LONG_SERVICE_TIMEOUT, ) From 011a07615574a871d418b28b0211d25bedac9796 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Sun, 16 Mar 2025 19:16:21 +1000 Subject: [PATCH 2658/3148] Fix auto seat heater in Teslemetry (#140703) Fix auto seat heater --- homeassistant/components/teslemetry/switch.py | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/teslemetry/switch.py b/homeassistant/components/teslemetry/switch.py index 4098a050fd9..516a6f9852f 100644 --- a/homeassistant/components/teslemetry/switch.py +++ b/homeassistant/components/teslemetry/switch.py @@ -7,7 +7,7 @@ from dataclasses import dataclass from itertools import chain from typing import Any -from tesla_fleet_api.const import Scope, Seat +from tesla_fleet_api.const import Scope from teslemetry_stream import TeslemetryStreamVehicle from homeassistant.components.switch import ( @@ -62,21 +62,15 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetrySwitchEntityDescription, ...] = ( TeslemetrySwitchEntityDescription( key="climate_state_auto_seat_climate_left", streaming_listener=lambda x, y: x.listen_AutoSeatClimateLeft(y), - on_func=lambda api: api.remote_auto_seat_climate_request(Seat.FRONT_LEFT, True), - off_func=lambda api: api.remote_auto_seat_climate_request( - Seat.FRONT_LEFT, False - ), + on_func=lambda api: api.remote_auto_seat_climate_request(1, True), + off_func=lambda api: api.remote_auto_seat_climate_request(1, False), scopes=[Scope.VEHICLE_CMDS], ), TeslemetrySwitchEntityDescription( key="climate_state_auto_seat_climate_right", streaming_listener=lambda x, y: x.listen_AutoSeatClimateRight(y), - on_func=lambda api: api.remote_auto_seat_climate_request( - Seat.FRONT_RIGHT, True - ), - off_func=lambda api: api.remote_auto_seat_climate_request( - Seat.FRONT_RIGHT, False - ), + on_func=lambda api: api.remote_auto_seat_climate_request(2, True), + off_func=lambda api: api.remote_auto_seat_climate_request(2, False), scopes=[Scope.VEHICLE_CMDS], ), TeslemetrySwitchEntityDescription( From 4e0985e1a73fbffa3c1fb6fcbaa804692453b445 Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Sun, 16 Mar 2025 09:00:43 -0400 Subject: [PATCH 2659/3148] Add Select entity to Snoo (#140638) --- homeassistant/components/snoo/__init__.py | 2 +- homeassistant/components/snoo/select.py | 78 ++++++++++++++++++++++ homeassistant/components/snoo/strings.json | 18 +++++ tests/components/snoo/test_select.py | 75 +++++++++++++++++++++ 4 files changed, 172 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/snoo/select.py create mode 100644 tests/components/snoo/test_select.py diff --git a/homeassistant/components/snoo/__init__.py b/homeassistant/components/snoo/__init__.py index aaf0c828830..ca561a52a3f 100644 --- a/homeassistant/components/snoo/__init__.py +++ b/homeassistant/components/snoo/__init__.py @@ -17,7 +17,7 @@ from .coordinator import SnooConfigEntry, SnooCoordinator _LOGGER = logging.getLogger(__name__) -PLATFORMS: list[Platform] = [Platform.SENSOR] +PLATFORMS: list[Platform] = [Platform.SELECT, Platform.SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: SnooConfigEntry) -> bool: diff --git a/homeassistant/components/snoo/select.py b/homeassistant/components/snoo/select.py new file mode 100644 index 00000000000..44624ed1a2d --- /dev/null +++ b/homeassistant/components/snoo/select.py @@ -0,0 +1,78 @@ +"""Support for Snoo Select.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass + +from python_snoo.containers import SnooData, SnooDevice, SnooLevels +from python_snoo.exceptions import SnooCommandException +from python_snoo.snoo import Snoo + +from homeassistant.components.select import SelectEntity, SelectEntityDescription +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import DOMAIN +from .coordinator import SnooConfigEntry +from .entity import SnooDescriptionEntity + + +@dataclass(frozen=True, kw_only=True) +class SnooSelectEntityDescription(SelectEntityDescription): + """Describes a Snoo Select.""" + + value_fn: Callable[[SnooData], str] + set_value_fn: Callable[[Snoo, SnooDevice, str], Awaitable[None]] + + +SELECT_DESCRIPTIONS: list[SnooSelectEntityDescription] = [ + SnooSelectEntityDescription( + key="intensity", + translation_key="intensity", + value_fn=lambda data: data.state_machine.level.name, + set_value_fn=lambda snoo_api, device, state: snoo_api.set_level( + device, SnooLevels[state] + ), + options=[level.name for level in SnooLevels], + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + entry: SnooConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Snoo device.""" + coordinators = entry.runtime_data + async_add_entities( + SnooSelect(coordinator, description) + for coordinator in coordinators.values() + for description in SELECT_DESCRIPTIONS + ) + + +class SnooSelect(SnooDescriptionEntity, SelectEntity): + """A sensor using Snoo coordinator.""" + + entity_description: SnooSelectEntityDescription + + @property + def current_option(self) -> str | None: + """Return the selected entity option to represent the entity state.""" + return self.entity_description.value_fn(self.coordinator.data) + + async def async_select_option(self, option: str) -> None: + """Change the selected option.""" + try: + await self.entity_description.set_value_fn( + self.coordinator.snoo, self.device, option + ) + except SnooCommandException as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="select_failed", + translation_placeholders={"name": str(self.name), "option": option}, + ) from err diff --git a/homeassistant/components/snoo/strings.json b/homeassistant/components/snoo/strings.json index 567fa30fca7..47e59603a14 100644 --- a/homeassistant/components/snoo/strings.json +++ b/homeassistant/components/snoo/strings.json @@ -21,6 +21,11 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } }, + "exceptions": { + "select_failed": { + "message": "Error while updating {name} to {option}" + } + }, "entity": { "sensor": { "state": { @@ -39,6 +44,19 @@ "time_left": { "name": "Time left" } + }, + "select": { + "intensity": { + "name": "Intensity", + "state": { + "baseline": "[%key:component::snoo::entity::sensor::state::state::baseline%]", + "level1": "[%key:component::snoo::entity::sensor::state::state::level1%]", + "level2": "[%key:component::snoo::entity::sensor::state::state::level2%]", + "level3": "[%key:component::snoo::entity::sensor::state::state::level3%]", + "level4": "[%key:component::snoo::entity::sensor::state::state::level4%]", + "stop": "[%key:component::snoo::entity::sensor::state::state::stop%]" + } + } } } } diff --git a/tests/components/snoo/test_select.py b/tests/components/snoo/test_select.py new file mode 100644 index 00000000000..e00721b2ab8 --- /dev/null +++ b/tests/components/snoo/test_select.py @@ -0,0 +1,75 @@ +"""Test Snoo Selects.""" + +import copy +from unittest.mock import AsyncMock + +import pytest +from python_snoo.containers import SnooDevice, SnooLevels, SnooStates + +from homeassistant.components.select import SERVICE_SELECT_OPTION +from homeassistant.components.snoo.select import SnooCommandException +from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError + +from . import async_init_integration, find_update_callback +from .const import MOCK_SNOO_DATA + + +async def test_select(hass: HomeAssistant, bypass_api: AsyncMock) -> None: + """Test select and check test values are correctly set.""" + await async_init_integration(hass) + assert len(hass.states.async_all("select")) == 1 + assert hass.states.get("select.test_snoo_intensity").state == STATE_UNAVAILABLE + find_update_callback(bypass_api, "random_num")(MOCK_SNOO_DATA) + await hass.async_block_till_done() + assert len(hass.states.async_all("select")) == 1 + assert hass.states.get("select.test_snoo_intensity").state == "stop" + + +async def test_update_success(hass: HomeAssistant, bypass_api: AsyncMock) -> None: + """Test changing values for select entities.""" + await async_init_integration(hass) + + find_update_callback(bypass_api, "random_num")(MOCK_SNOO_DATA) + assert hass.states.get("select.test_snoo_intensity").state == "stop" + + async def update_level(device: SnooDevice, level: SnooStates, _hold: bool = False): + new_data = copy.deepcopy(MOCK_SNOO_DATA) + new_data.state_machine.level = SnooLevels(level.value) + find_update_callback(bypass_api, device.serialNumber)(new_data) + + bypass_api.set_level.side_effect = update_level + await hass.services.async_call( + "select", + SERVICE_SELECT_OPTION, + service_data={"option": "level1"}, + blocking=True, + target={"entity_id": "select.test_snoo_intensity"}, + ) + + assert bypass_api.set_level.assert_called_once + assert hass.states.get("select.test_snoo_intensity").state == "level1" + + +async def test_update_failed(hass: HomeAssistant, bypass_api: AsyncMock) -> None: + """Test failing to change values for select entities.""" + await async_init_integration(hass) + + find_update_callback(bypass_api, "random_num")(MOCK_SNOO_DATA) + assert hass.states.get("select.test_snoo_intensity").state == "stop" + + bypass_api.set_level.side_effect = SnooCommandException + with pytest.raises( + HomeAssistantError, match="Error while updating Intensity to level1" + ): + await hass.services.async_call( + "select", + SERVICE_SELECT_OPTION, + service_data={"option": "level1"}, + blocking=True, + target={"entity_id": "select.test_snoo_intensity"}, + ) + + assert bypass_api.set_level.assert_called_once + assert hass.states.get("select.test_snoo_intensity").state == "stop" From d365092bcc2591ce4f0e253ae78ecccc976eece7 Mon Sep 17 00:00:00 2001 From: Dan Raper Date: Sun, 16 Mar 2025 13:05:08 +0000 Subject: [PATCH 2660/3148] Add price cap support to Ohme (#140537) * Add price cap support * Change service input to box mode * Add icon for set_price_cap service * Improve test coverage * Change ohme service description wording --- homeassistant/components/ohme/icons.json | 6 ++ homeassistant/components/ohme/services.py | 36 +++++++++- homeassistant/components/ohme/services.yaml | 13 ++++ homeassistant/components/ohme/strings.json | 17 +++++ homeassistant/components/ohme/switch.py | 70 +++++++++++++++---- tests/components/ohme/conftest.py | 2 + .../ohme/snapshots/test_switch.ambr | 47 +++++++++++++ tests/components/ohme/test_services.py | 27 ++++++- tests/components/ohme/test_switch.py | 48 ++++++++++++- 9 files changed, 246 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/ohme/icons.json b/homeassistant/components/ohme/icons.json index 0e4d58a5294..8613f2542c4 100644 --- a/homeassistant/components/ohme/icons.json +++ b/homeassistant/components/ohme/icons.json @@ -54,6 +54,9 @@ "state": { "off": "mdi:sleep-off" } + }, + "price_cap": { + "default": "mdi:car-speed-limiter" } }, "time": { @@ -65,6 +68,9 @@ "services": { "list_charge_slots": { "service": "mdi:clock-start" + }, + "set_price_cap": { + "service": "mdi:car-speed-limiter" } } } diff --git a/homeassistant/components/ohme/services.py b/homeassistant/components/ohme/services.py index 7d06b909d88..be044f01740 100644 --- a/homeassistant/components/ohme/services.py +++ b/homeassistant/components/ohme/services.py @@ -17,9 +17,11 @@ from homeassistant.helpers import selector from .const import DOMAIN -SERVICE_LIST_CHARGE_SLOTS = "list_charge_slots" ATTR_CONFIG_ENTRY: Final = "config_entry" -SERVICE_SCHEMA: Final = vol.Schema( +ATTR_PRICE_CAP: Final = "price_cap" + +SERVICE_LIST_CHARGE_SLOTS = "list_charge_slots" +SERVICE_LIST_CHARGE_SLOTS_SCHEMA: Final = vol.Schema( { vol.Required(ATTR_CONFIG_ENTRY): selector.ConfigEntrySelector( { @@ -29,6 +31,18 @@ SERVICE_SCHEMA: Final = vol.Schema( } ) +SERVICE_SET_PRICE_CAP = "set_price_cap" +SERVICE_SET_PRICE_CAP_SCHEMA: Final = vol.Schema( + { + vol.Required(ATTR_CONFIG_ENTRY): selector.ConfigEntrySelector( + { + "integration": DOMAIN, + } + ), + vol.Required(ATTR_PRICE_CAP): vol.Coerce(float), + } +) + def __get_client(call: ServiceCall) -> OhmeApiClient: """Get the client from the config entry.""" @@ -66,10 +80,26 @@ def async_setup_services(hass: HomeAssistant) -> None: return {"slots": client.slots} + async def set_price_cap( + service_call: ServiceCall, + ) -> None: + """List of charge slots.""" + client = __get_client(service_call) + price_cap = service_call.data[ATTR_PRICE_CAP] + await client.async_change_price_cap(cap=price_cap) + hass.services.async_register( DOMAIN, SERVICE_LIST_CHARGE_SLOTS, list_charge_slots, - schema=SERVICE_SCHEMA, + schema=SERVICE_LIST_CHARGE_SLOTS_SCHEMA, supports_response=SupportsResponse.ONLY, ) + + hass.services.async_register( + DOMAIN, + SERVICE_SET_PRICE_CAP, + set_price_cap, + schema=SERVICE_SET_PRICE_CAP_SCHEMA, + supports_response=SupportsResponse.NONE, + ) diff --git a/homeassistant/components/ohme/services.yaml b/homeassistant/components/ohme/services.yaml index c5c8ee18138..a45bc131511 100644 --- a/homeassistant/components/ohme/services.yaml +++ b/homeassistant/components/ohme/services.yaml @@ -5,3 +5,16 @@ list_charge_slots: selector: config_entry: integration: ohme +set_price_cap: + fields: + config_entry: + required: true + selector: + config_entry: + integration: ohme + price_cap: + required: true + selector: + number: + min: 0 + mode: box diff --git a/homeassistant/components/ohme/strings.json b/homeassistant/components/ohme/strings.json index 187e825c159..1da17183bb2 100644 --- a/homeassistant/components/ohme/strings.json +++ b/homeassistant/components/ohme/strings.json @@ -42,6 +42,20 @@ "description": "The Ohme config entry for which to return charge slots." } } + }, + "set_price_cap": { + "name": "Set price cap", + "description": "Prevents charging when the electricity price exceeds a defined threshold.", + "fields": { + "config_entry": { + "name": "Ohme account", + "description": "The Ohme config entry for which to return charge slots." + }, + "price_cap": { + "name": "Price cap", + "description": "Threshold in 1/100ths of your local currency." + } + } } }, "entity": { @@ -102,6 +116,9 @@ }, "sleep_when_inactive": { "name": "Sleep when inactive" + }, + "price_cap": { + "name": "Price cap" } }, "time": { diff --git a/homeassistant/components/ohme/switch.py b/homeassistant/components/ohme/switch.py index c4465ec7e97..47e3bf8a99d 100644 --- a/homeassistant/components/ohme/switch.py +++ b/homeassistant/components/ohme/switch.py @@ -1,9 +1,10 @@ """Platform for switch.""" +from collections.abc import Awaitable, Callable from dataclasses import dataclass from typing import Any -from ohme import ApiException +from ohme import ApiException, OhmeApiClient from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.const import EntityCategory @@ -19,28 +20,37 @@ PARALLEL_UPDATES = 1 @dataclass(frozen=True, kw_only=True) -class OhmeSwitchDescription(OhmeEntityDescription, SwitchEntityDescription): - """Class describing Ohme switch entities.""" +class OhmeConfigSwitchDescription(OhmeEntityDescription, SwitchEntityDescription): + """Class describing Ohme configuration switch entities.""" configuration_key: str -SWITCH_DEVICE_INFO = [ - OhmeSwitchDescription( +@dataclass(frozen=True, kw_only=True) +class OhmeSwitchDescription(OhmeEntityDescription, SwitchEntityDescription): + """Class describing basic Ohme switch entities.""" + + is_on_fn: Callable[[OhmeApiClient], bool] + off_fn: Callable[[OhmeApiClient], Awaitable] + on_fn: Callable[[OhmeApiClient], Awaitable] + + +SWITCH_CONFIG = [ + OhmeConfigSwitchDescription( key="lock_buttons", translation_key="lock_buttons", entity_category=EntityCategory.CONFIG, is_supported_fn=lambda client: client.is_capable("buttonsLockable"), configuration_key="buttonsLocked", ), - OhmeSwitchDescription( + OhmeConfigSwitchDescription( key="require_approval", translation_key="require_approval", entity_category=EntityCategory.CONFIG, is_supported_fn=lambda client: client.is_capable("pluginsRequireApprovalMode"), configuration_key="pluginsRequireApproval", ), - OhmeSwitchDescription( + OhmeConfigSwitchDescription( key="sleep_when_inactive", translation_key="sleep_when_inactive", entity_category=EntityCategory.CONFIG, @@ -49,6 +59,17 @@ SWITCH_DEVICE_INFO = [ ), ] +SWITCH_DESCRIPTION = [ + OhmeSwitchDescription( + key="price_cap", + translation_key="price_cap", + is_supported_fn=lambda client: client.cap_available, + is_on_fn=lambda client: client.cap_enabled, + on_fn=lambda client: client.async_change_price_cap(True), + off_fn=lambda client: client.async_change_price_cap(False), + ), +] + async def async_setup_entry( hass: HomeAssistant, @@ -56,15 +77,17 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up switches.""" - coordinators = config_entry.runtime_data - coordinator_map = [ - (SWITCH_DEVICE_INFO, coordinators.device_info_coordinator), - ] + coordinator = config_entry.runtime_data.device_info_coordinator + + async_add_entities( + OhmeConfigSwitch(coordinator, description) + for description in SWITCH_CONFIG + if description.is_supported_fn(coordinator.client) + ) async_add_entities( OhmeSwitch(coordinator, description) - for entities, coordinator in coordinator_map - for description in entities + for description in SWITCH_DESCRIPTION if description.is_supported_fn(coordinator.client) ) @@ -74,6 +97,27 @@ class OhmeSwitch(OhmeEntity, SwitchEntity): entity_description: OhmeSwitchDescription + @property + def is_on(self) -> bool: + """Return True if entity is on.""" + return self.entity_description.is_on_fn(self.coordinator.client) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the switch off.""" + await self.entity_description.off_fn(self.coordinator.client) + await self.coordinator.async_request_refresh() + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the switch on.""" + await self.entity_description.on_fn(self.coordinator.client) + await self.coordinator.async_request_refresh() + + +class OhmeConfigSwitch(OhmeEntity, SwitchEntity): + """Configuration switch for Ohme.""" + + entity_description: OhmeConfigSwitchDescription + @property def is_on(self) -> bool: """Return the entity value to represent the entity state.""" diff --git a/tests/components/ohme/conftest.py b/tests/components/ohme/conftest.py index d05e34d1ed2..e8a7d27b2c3 100644 --- a/tests/components/ohme/conftest.py +++ b/tests/components/ohme/conftest.py @@ -60,6 +60,8 @@ def mock_client(): client.preconditioning = 15 client.serial = "chargerid" client.ct_connected = True + client.cap_available = True + client.cap_enabled = True client.energy = 1000 client.device_info = { "name": "Ohme Home Pro", diff --git a/tests/components/ohme/snapshots/test_switch.ambr b/tests/components/ohme/snapshots/test_switch.ambr index 49bf5d5709a..4790d96c551 100644 --- a/tests/components/ohme/snapshots/test_switch.ambr +++ b/tests/components/ohme/snapshots/test_switch.ambr @@ -46,6 +46,53 @@ 'state': 'on', }) # --- +# name: test_switches[switch.ohme_home_pro_price_cap-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.ohme_home_pro_price_cap', + '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': 'Price cap', + 'platform': 'ohme', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'price_cap', + 'unique_id': 'chargerid_price_cap', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.ohme_home_pro_price_cap-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Ohme Home Pro Price cap', + }), + 'context': , + 'entity_id': 'switch.ohme_home_pro_price_cap', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_switches[switch.ohme_home_pro_require_approval-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/ohme/test_services.py b/tests/components/ohme/test_services.py index 76c7ce94b57..2513635c1c2 100644 --- a/tests/components/ohme/test_services.py +++ b/tests/components/ohme/test_services.py @@ -1,6 +1,6 @@ """Tests for services.""" -from unittest.mock import MagicMock +from unittest.mock import AsyncMock, MagicMock import pytest from syrupy.assertion import SnapshotAssertion @@ -8,6 +8,7 @@ from syrupy.assertion import SnapshotAssertion from homeassistant.components.ohme.const import DOMAIN from homeassistant.components.ohme.services import ( ATTR_CONFIG_ENTRY, + ATTR_PRICE_CAP, SERVICE_LIST_CHARGE_SLOTS, ) from homeassistant.core import HomeAssistant @@ -47,6 +48,30 @@ async def test_list_charge_slots( ) +async def test_set_price_cap( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_client: MagicMock, + snapshot: SnapshotAssertion, +) -> None: + """Test set price cap service.""" + + await setup_integration(hass, mock_config_entry) + mock_client.async_change_price_cap = AsyncMock() + + await hass.services.async_call( + DOMAIN, + "set_price_cap", + { + ATTR_CONFIG_ENTRY: mock_config_entry.entry_id, + ATTR_PRICE_CAP: 10.0, + }, + blocking=True, + ) + + mock_client.async_change_price_cap.assert_called_once_with(cap=10.0) + + async def test_list_charge_slots_exception( hass: HomeAssistant, mock_config_entry: MockConfigEntry, diff --git a/tests/components/ohme/test_switch.py b/tests/components/ohme/test_switch.py index b16b70d67f8..8d82a5a3ea4 100644 --- a/tests/components/ohme/test_switch.py +++ b/tests/components/ohme/test_switch.py @@ -1,6 +1,6 @@ """Tests for switches.""" -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, patch from syrupy import SnapshotAssertion @@ -32,7 +32,49 @@ async def test_switches( await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) -async def test_switch_on( +async def test_cap_switch_on( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_client: MagicMock, +) -> None: + """Test the switch turn_on action.""" + await setup_integration(hass, mock_config_entry) + mock_client.async_change_price_cap = AsyncMock() + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: "switch.ohme_home_pro_price_cap", + }, + blocking=True, + ) + + mock_client.async_change_price_cap.assert_called_once_with(True) + + +async def test_cap_switch_off( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_client: MagicMock, +) -> None: + """Test the switch turn_off action.""" + await setup_integration(hass, mock_config_entry) + mock_client.async_change_price_cap = AsyncMock() + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + { + ATTR_ENTITY_ID: "switch.ohme_home_pro_price_cap", + }, + blocking=True, + ) + + mock_client.async_change_price_cap.assert_called_once_with(False) + + +async def test_config_switch_on( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_client: MagicMock, @@ -52,7 +94,7 @@ async def test_switch_on( assert len(mock_client.async_set_configuration_value.mock_calls) == 1 -async def test_switch_off( +async def test_config_switch_off( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_client: MagicMock, From d560083e150ccff48e27882bbb47180b6d146be8 Mon Sep 17 00:00:00 2001 From: Pete Sage <76050312+PeteRager@users.noreply.github.com> Date: Sun, 16 Mar 2025 09:09:21 -0400 Subject: [PATCH 2661/3148] Album art not available for Sonos media library favorites (#140557) * get album art uri for favorites * add tests * update typing * update typing * update typing * simplify --- homeassistant/components/sonos/favorites.py | 2 +- .../components/sonos/media_browser.py | 16 ++++++++++-- .../sonos/fixtures/sonos_favorites.json | 1 + .../sonos/snapshots/test_media_browser.ambr | 25 +++++++++++++++++++ tests/components/sonos/test_media_browser.py | 4 +++ 5 files changed, 45 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sonos/favorites.py b/homeassistant/components/sonos/favorites.py index 5050555a7cb..333c4809e62 100644 --- a/homeassistant/components/sonos/favorites.py +++ b/homeassistant/components/sonos/favorites.py @@ -105,7 +105,7 @@ class SonosFavorites(SonosHouseholdCoordinator): @soco_error() def update_cache(self, soco: SoCo, update_id: int | None = None) -> bool: """Update cache of known favorites and return if cache has changed.""" - new_favorites = soco.music_library.get_sonos_favorites() + new_favorites = soco.music_library.get_sonos_favorites(full_album_art_uri=True) # Polled update_id values do not match event_id values # Each speaker can return a different polled update_id diff --git a/homeassistant/components/sonos/media_browser.py b/homeassistant/components/sonos/media_browser.py index 995d6cea08c..16b425dae50 100644 --- a/homeassistant/components/sonos/media_browser.py +++ b/homeassistant/components/sonos/media_browser.py @@ -165,6 +165,8 @@ async def async_browse_media( favorites_folder_payload, speaker.favorites, media_content_id, + media, + get_browse_image_url, ) payload = { @@ -443,7 +445,10 @@ def favorites_payload(favorites: SonosFavorites) -> BrowseMedia: def favorites_folder_payload( - favorites: SonosFavorites, media_content_id: str + favorites: SonosFavorites, + media_content_id: str, + media: SonosMedia, + get_browse_image_url: GetBrowseImageUrlType, ) -> BrowseMedia: """Create response payload to describe all items of a type of favorite. @@ -463,7 +468,14 @@ def favorites_folder_payload( media_content_type="favorite_item_id", can_play=True, can_expand=False, - thumbnail=getattr(favorite, "album_art_uri", None), + thumbnail=get_thumbnail_url_full( + media=media, + is_internal=True, + media_content_type="favorite_item_id", + media_content_id=favorite.item_id, + get_browse_image_url=get_browse_image_url, + item=favorite, + ), ) ) diff --git a/tests/components/sonos/fixtures/sonos_favorites.json b/tests/components/sonos/fixtures/sonos_favorites.json index d5463c3d02b..40213ea8715 100644 --- a/tests/components/sonos/fixtures/sonos_favorites.json +++ b/tests/components/sonos/fixtures/sonos_favorites.json @@ -27,6 +27,7 @@ "title": "1984", "parent_id": "FV:2", "item_id": "FV:2/8", + "album_art_uri": "http://192.168.42.2:1400/getaa?u=x-file-cifs%3a%2f%2f192.168.42.2%2fmusic%2fiTunes%2520Music%2fAerosmith%2f1984&v=742", "resource_meta_data": "1984object.container.album.musicAlbumRINCON_AssociatedZPUDN", "resources": [ { diff --git a/tests/components/sonos/snapshots/test_media_browser.ambr b/tests/components/sonos/snapshots/test_media_browser.ambr index 9f6560c0f75..24f08eaf95b 100644 --- a/tests/components/sonos/snapshots/test_media_browser.ambr +++ b/tests/components/sonos/snapshots/test_media_browser.ambr @@ -44,6 +44,31 @@ 'title': 'Favorites', }) # --- +# name: test_browse_media_favorites[object.container.album.musicAlbum-favorites_folder] + dict({ + 'can_expand': True, + 'can_play': False, + 'children': list([ + dict({ + 'can_expand': False, + 'can_play': True, + 'children_media_class': None, + 'media_class': 'album', + 'media_content_id': 'FV:2/8', + 'media_content_type': 'favorite_item_id', + 'thumbnail': 'http://192.168.42.2:1400/getaa?u=x-file-cifs://192.168.42.2/music/iTunes%20Music/Aerosmith/1984&v=742', + 'title': '1984', + }), + ]), + 'children_media_class': 'album', + 'media_class': 'directory', + 'media_content_id': '', + 'media_content_type': 'favorites', + 'not_shown': 0, + 'thumbnail': None, + 'title': 'Albums', + }) +# --- # name: test_browse_media_favorites[object.item.audioItem.audioBook-favorites_folder] dict({ 'can_expand': True, diff --git a/tests/components/sonos/test_media_browser.py b/tests/components/sonos/test_media_browser.py index 323140e285d..ce6e103be58 100644 --- a/tests/components/sonos/test_media_browser.py +++ b/tests/components/sonos/test_media_browser.py @@ -190,6 +190,10 @@ async def test_browse_media_library_albums( "object.item.audioItem.audioBook", "favorites_folder", ), + ( + "object.container.album.musicAlbum", + "favorites_folder", + ), ], ) async def test_browse_media_favorites( From 4ca31da0a504a8b9824397988de43bab8278a124 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Sun, 16 Mar 2025 14:51:36 +0100 Subject: [PATCH 2662/3148] Bump annotatedyaml to 0.2.0 (#140715) --- 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 3a13b59eced..af437c4b079 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -10,7 +10,7 @@ aiohttp==3.11.13 aiohttp_cors==0.7.0 aiousbwatcher==1.1.1 aiozoneinfo==0.2.3 -annotatedyaml==0.1.1 +annotatedyaml==0.2.0 astral==2.2 async-interrupt==1.2.2 async-upnp-client==0.43.0 diff --git a/pyproject.toml b/pyproject.toml index a2f1e9360f3..31d0ce4e42d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,7 +33,7 @@ dependencies = [ "aiohttp-fast-zlib==0.2.3", "aiohttp-asyncmdnsresolver==0.1.1", "aiozoneinfo==0.2.3", - "annotatedyaml==0.1.1", + "annotatedyaml==0.2.0", "astral==2.2", "async-interrupt==1.2.2", "attrs==25.1.0", diff --git a/requirements.txt b/requirements.txt index 1397b6bec06..22ffcfb54e1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,7 +10,7 @@ aiohttp_cors==0.7.0 aiohttp-fast-zlib==0.2.3 aiohttp-asyncmdnsresolver==0.1.1 aiozoneinfo==0.2.3 -annotatedyaml==0.1.1 +annotatedyaml==0.2.0 astral==2.2 async-interrupt==1.2.2 attrs==25.1.0 From 012b4645f314aea2d867128be602fc169c8d168f Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Sun, 16 Mar 2025 14:51:53 +0100 Subject: [PATCH 2663/3148] Don't reload onedrive on options flow (#140712) --- homeassistant/components/onedrive/__init__.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/homeassistant/components/onedrive/__init__.py b/homeassistant/components/onedrive/__init__.py index 17dead653f0..f5d841683d5 100644 --- a/homeassistant/components/onedrive/__init__.py +++ b/homeassistant/components/onedrive/__init__.py @@ -106,11 +106,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: OneDriveConfigEntry) -> await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - async def update_listener(hass: HomeAssistant, entry: OneDriveConfigEntry) -> None: - await hass.config_entries.async_reload(entry.entry_id) - - entry.async_on_unload(entry.add_update_listener(update_listener)) - def async_notify_backup_listeners() -> None: for listener in hass.data.get(DATA_BACKUP_AGENT_LISTENERS, []): listener() From 056616f9c51fb707733e59b2d779d269d460df85 Mon Sep 17 00:00:00 2001 From: Denis Shulyaka Date: Sun, 16 Mar 2025 17:59:25 +0300 Subject: [PATCH 2664/3148] Stronger type annotations for conversation content (#140725) stronger type annotations for conversation content --- .../components/conversation/chat_log.py | 15 +++++++-------- .../conversation.py | 16 +++------------- .../openai_conversation/conversation.py | 6 +++--- 3 files changed, 13 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/conversation/chat_log.py b/homeassistant/components/conversation/chat_log.py index 355f423dbb6..2de785dae7d 100644 --- a/homeassistant/components/conversation/chat_log.py +++ b/homeassistant/components/conversation/chat_log.py @@ -51,8 +51,7 @@ def async_get_chat_log( ) if user_input is not None and ( (content := chat_log.content[-1]).role != "user" - # MyPy doesn't understand that content is a UserContent here - or content.content != user_input.text # type: ignore[union-attr] + or content.content != user_input.text ): chat_log.async_add_user_content(UserContent(content=user_input.text)) @@ -128,7 +127,7 @@ class ConverseError(HomeAssistantError): class SystemContent: """Base class for chat messages.""" - role: str = field(init=False, default="system") + role: Literal["system"] = field(init=False, default="system") content: str @@ -136,7 +135,7 @@ class SystemContent: class UserContent: """Assistant content.""" - role: str = field(init=False, default="user") + role: Literal["user"] = field(init=False, default="user") content: str @@ -144,7 +143,7 @@ class UserContent: class AssistantContent: """Assistant content.""" - role: str = field(init=False, default="assistant") + role: Literal["assistant"] = field(init=False, default="assistant") agent_id: str content: str | None = None tool_calls: list[llm.ToolInput] | None = None @@ -154,7 +153,7 @@ class AssistantContent: class ToolResultContent: """Tool result content.""" - role: str = field(init=False, default="tool_result") + role: Literal["tool_result"] = field(init=False, default="tool_result") agent_id: str tool_call_id: str tool_name: str @@ -193,8 +192,8 @@ class ChatLog: return ( last_msg.role == "assistant" - and last_msg.content is not None # type: ignore[union-attr] - and last_msg.content.strip().endswith( # type: ignore[union-attr] + and last_msg.content is not None + and last_msg.content.strip().endswith( ( "?", ";", # Greek question mark diff --git a/homeassistant/components/google_generative_ai_conversation/conversation.py b/homeassistant/components/google_generative_ai_conversation/conversation.py index 93546431391..4648f1afb4c 100644 --- a/homeassistant/components/google_generative_ai_conversation/conversation.py +++ b/homeassistant/components/google_generative_ai_conversation/conversation.py @@ -188,7 +188,7 @@ def _convert_content( | conversation.SystemContent, ) -> Content: """Convert HA content to Google content.""" - if content.role != "assistant" or not content.tool_calls: # type: ignore[union-attr] + if content.role != "assistant" or not content.tool_calls: role = "model" if content.role == "assistant" else content.role return Content( role=role, @@ -321,24 +321,14 @@ class GoogleGenerativeAIConversationEntity( for chat_content in chat_log.content[1:-1]: if chat_content.role == "tool_result": - # mypy doesn't like picking a type based on checking shared property 'role' - tool_results.append(cast(conversation.ToolResultContent, chat_content)) + tool_results.append(chat_content) continue if tool_results: messages.append(_create_google_tool_response_content(tool_results)) tool_results.clear() - messages.append( - _convert_content( - cast( - conversation.UserContent - | conversation.SystemContent - | conversation.AssistantContent, - chat_content, - ) - ) - ) + messages.append(_convert_content(chat_content)) if tool_results: messages.append(_create_google_tool_response_content(tool_results)) diff --git a/homeassistant/components/openai_conversation/conversation.py b/homeassistant/components/openai_conversation/conversation.py index e42319f8e96..d910cf54471 100644 --- a/homeassistant/components/openai_conversation/conversation.py +++ b/homeassistant/components/openai_conversation/conversation.py @@ -82,13 +82,13 @@ def _convert_content_to_param( tool_call_id=content.tool_call_id, content=json.dumps(content.tool_result), ) - if content.role != "assistant" or not content.tool_calls: # type: ignore[union-attr] - role = content.role + if content.role != "assistant" or not content.tool_calls: + role: Literal["system", "user", "assistant", "developer"] = content.role if role == "system": role = "developer" return cast( ChatCompletionMessageParam, - {"role": content.role, "content": content.content}, # type: ignore[union-attr] + {"role": content.role, "content": content.content}, ) # Handle the Assistant content including tool calls. From 214d14b06b7dfef7ffdf5099b5705d39ae764353 Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Sun, 16 Mar 2025 11:57:21 -0400 Subject: [PATCH 2665/3148] Add binary sensor to Snoo (#140729) * Add binary sensor * Update homeassistant/components/snoo/binary_sensor.py Co-authored-by: Joost Lekkerkerker --------- Co-authored-by: Joost Lekkerkerker --- homeassistant/components/snoo/__init__.py | 2 +- .../components/snoo/binary_sensor.py | 70 +++++++++++++++++++ homeassistant/components/snoo/strings.json | 9 +++ tests/components/snoo/test_binary_sensor.py | 30 ++++++++ 4 files changed, 110 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/snoo/binary_sensor.py create mode 100644 tests/components/snoo/test_binary_sensor.py diff --git a/homeassistant/components/snoo/__init__.py b/homeassistant/components/snoo/__init__.py index ca561a52a3f..23b5d5201db 100644 --- a/homeassistant/components/snoo/__init__.py +++ b/homeassistant/components/snoo/__init__.py @@ -17,7 +17,7 @@ from .coordinator import SnooConfigEntry, SnooCoordinator _LOGGER = logging.getLogger(__name__) -PLATFORMS: list[Platform] = [Platform.SELECT, Platform.SENSOR] +PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SELECT, Platform.SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: SnooConfigEntry) -> bool: diff --git a/homeassistant/components/snoo/binary_sensor.py b/homeassistant/components/snoo/binary_sensor.py new file mode 100644 index 00000000000..3c91db5b86d --- /dev/null +++ b/homeassistant/components/snoo/binary_sensor.py @@ -0,0 +1,70 @@ +"""Support for Snoo Binary Sensors.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from python_snoo.containers import SnooData + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, + BinarySensorEntityDescription, + EntityCategory, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import SnooConfigEntry +from .entity import SnooDescriptionEntity + + +@dataclass(frozen=True, kw_only=True) +class SnooBinarySensorEntityDescription(BinarySensorEntityDescription): + """Describes a Snoo Binary Sensor.""" + + value_fn: Callable[[SnooData], bool] + + +BINARY_SENSOR_DESCRIPTIONS: list[SnooBinarySensorEntityDescription] = [ + SnooBinarySensorEntityDescription( + key="left_clip", + translation_key="left_clip", + value_fn=lambda data: data.left_safety_clip, + device_class=BinarySensorDeviceClass.CONNECTIVITY, + entity_category=EntityCategory.DIAGNOSTIC, + ), + SnooBinarySensorEntityDescription( + key="right_clip", + translation_key="right_clip", + value_fn=lambda data: data.left_safety_clip, + device_class=BinarySensorDeviceClass.CONNECTIVITY, + entity_category=EntityCategory.DIAGNOSTIC, + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + entry: SnooConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Snoo device.""" + coordinators = entry.runtime_data + async_add_entities( + SnooBinarySensor(coordinator, description) + for coordinator in coordinators.values() + for description in BINARY_SENSOR_DESCRIPTIONS + ) + + +class SnooBinarySensor(SnooDescriptionEntity, BinarySensorEntity): + """A Binary sensor using Snoo coordinator.""" + + entity_description: SnooBinarySensorEntityDescription + + @property + def is_on(self) -> bool: + """Return true if the binary sensor is on.""" + return self.entity_description.value_fn(self.coordinator.data) diff --git a/homeassistant/components/snoo/strings.json b/homeassistant/components/snoo/strings.json index 47e59603a14..8211480f771 100644 --- a/homeassistant/components/snoo/strings.json +++ b/homeassistant/components/snoo/strings.json @@ -27,6 +27,15 @@ } }, "entity": { + "binary_sensor": { + "left_clip": { + "name": "Left safety clip" + }, + "right_clip": { + "name": "Right safety clip" + } + }, + "sensor": { "state": { "name": "State", diff --git a/tests/components/snoo/test_binary_sensor.py b/tests/components/snoo/test_binary_sensor.py new file mode 100644 index 00000000000..77b2e36c1fe --- /dev/null +++ b/tests/components/snoo/test_binary_sensor.py @@ -0,0 +1,30 @@ +"""Test Snoo Binary Sensors.""" + +from unittest.mock import AsyncMock + +from homeassistant.const import STATE_ON, STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant + +from . import async_init_integration, find_update_callback +from .const import MOCK_SNOO_DATA + + +async def test_binary_sensors(hass: HomeAssistant, bypass_api: AsyncMock) -> None: + """Test binary sensors and check test values are correctly set.""" + await async_init_integration(hass) + assert len(hass.states.async_all("binary_sensor")) == 2 + assert ( + hass.states.get("binary_sensor.test_snoo_left_safety_clip").state + == STATE_UNAVAILABLE + ) + assert ( + hass.states.get("binary_sensor.test_snoo_right_safety_clip").state + == STATE_UNAVAILABLE + ) + find_update_callback(bypass_api, "random_num")(MOCK_SNOO_DATA) + await hass.async_block_till_done() + assert len(hass.states.async_all("binary_sensor")) == 2 + assert hass.states.get("binary_sensor.test_snoo_left_safety_clip").state == STATE_ON + assert ( + hass.states.get("binary_sensor.test_snoo_right_safety_clip").state == STATE_ON + ) From bb7b5b9ccb7fe3a9d97048e7ff25418562f998c4 Mon Sep 17 00:00:00 2001 From: Denis Shulyaka Date: Sun, 16 Mar 2025 20:18:18 +0300 Subject: [PATCH 2666/3148] OpenAI Responses API (#140713) --- .../openai_conversation/__init__.py | 99 ++-- .../openai_conversation/conversation.py | 200 +++---- .../openai_conversation/test_conversation.py | 488 ++++++++++-------- .../openai_conversation/test_init.py | 109 ++-- 4 files changed, 463 insertions(+), 433 deletions(-) diff --git a/homeassistant/components/openai_conversation/__init__.py b/homeassistant/components/openai_conversation/__init__.py index d7fc5205f17..fcf6ab298dc 100644 --- a/homeassistant/components/openai_conversation/__init__.py +++ b/homeassistant/components/openai_conversation/__init__.py @@ -7,21 +7,15 @@ from mimetypes import guess_file_type from pathlib import Path import openai -from openai.types.chat.chat_completion import ChatCompletion -from openai.types.chat.chat_completion_content_part_image_param import ( - ChatCompletionContentPartImageParam, - ImageURL, -) -from openai.types.chat.chat_completion_content_part_param import ( - ChatCompletionContentPartParam, -) -from openai.types.chat.chat_completion_content_part_text_param import ( - ChatCompletionContentPartTextParam, -) -from openai.types.chat.chat_completion_user_message_param import ( - ChatCompletionUserMessageParam, -) from openai.types.images_response import ImagesResponse +from openai.types.responses import ( + EasyInputMessageParam, + Response, + ResponseInputImageParam, + ResponseInputMessageContentListParam, + ResponseInputParam, + ResponseInputTextParam, +) import voluptuous as vol from homeassistant.config_entries import ConfigEntry @@ -44,10 +38,18 @@ from homeassistant.helpers.typing import ConfigType from .const import ( CONF_CHAT_MODEL, CONF_FILENAMES, + CONF_MAX_TOKENS, CONF_PROMPT, + CONF_REASONING_EFFORT, + CONF_TEMPERATURE, + CONF_TOP_P, DOMAIN, LOGGER, RECOMMENDED_CHAT_MODEL, + RECOMMENDED_MAX_TOKENS, + RECOMMENDED_REASONING_EFFORT, + RECOMMENDED_TEMPERATURE, + RECOMMENDED_TOP_P, ) SERVICE_GENERATE_IMAGE = "generate_image" @@ -112,17 +114,14 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: translation_placeholders={"config_entry": entry_id}, ) - model: str = entry.data.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL) + model: str = entry.options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL) client: openai.AsyncClient = entry.runtime_data - prompt_parts: list[ChatCompletionContentPartParam] = [ - ChatCompletionContentPartTextParam( - type="text", - text=call.data[CONF_PROMPT], - ) + content: ResponseInputMessageContentListParam = [ + ResponseInputTextParam(type="input_text", text=call.data[CONF_PROMPT]) ] - def append_files_to_prompt() -> None: + def append_files_to_content() -> None: for filename in call.data[CONF_FILENAMES]: if not hass.config.is_allowed_path(filename): raise HomeAssistantError( @@ -138,46 +137,52 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: "Only images are supported by the OpenAI API," f"`{filename}` is not an image file" ) - prompt_parts.append( - ChatCompletionContentPartImageParam( - type="image_url", - image_url=ImageURL( - url=f"data:{mime_type};base64,{base64_file}" - ), + content.append( + ResponseInputImageParam( + type="input_image", + file_id=filename, + image_url=f"data:{mime_type};base64,{base64_file}", + detail="auto", ) ) if CONF_FILENAMES in call.data: - await hass.async_add_executor_job(append_files_to_prompt) + await hass.async_add_executor_job(append_files_to_content) - messages: list[ChatCompletionUserMessageParam] = [ - ChatCompletionUserMessageParam( - role="user", - content=prompt_parts, - ) + messages: ResponseInputParam = [ + EasyInputMessageParam(type="message", role="user", content=content) ] try: - response: ChatCompletion = await client.chat.completions.create( - model=model, - messages=messages, - n=1, - response_format={ - "type": "json_object", - }, - ) + model_args = { + "model": model, + "input": messages, + "max_output_tokens": entry.options.get( + CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS + ), + "top_p": entry.options.get(CONF_TOP_P, RECOMMENDED_TOP_P), + "temperature": entry.options.get( + CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE + ), + "user": call.context.user_id, + "store": False, + } + + if model.startswith("o"): + model_args["reasoning"] = { + "effort": entry.options.get( + CONF_REASONING_EFFORT, RECOMMENDED_REASONING_EFFORT + ) + } + + response: Response = await client.responses.create(**model_args) except openai.OpenAIError as err: raise HomeAssistantError(f"Error generating content: {err}") from err except FileNotFoundError as err: raise HomeAssistantError(f"Error generating content: {err}") from err - response_text: str = "" - for response_choice in response.choices: - if response_choice.message.content is not None: - response_text += response_choice.message.content.strip() - - return {"text": response_text} + return {"text": response.output_text} hass.services.async_register( DOMAIN, diff --git a/homeassistant/components/openai_conversation/conversation.py b/homeassistant/components/openai_conversation/conversation.py index d910cf54471..7a8830ffd95 100644 --- a/homeassistant/components/openai_conversation/conversation.py +++ b/homeassistant/components/openai_conversation/conversation.py @@ -2,21 +2,25 @@ from collections.abc import AsyncGenerator, Callable import json -from typing import Any, Literal, cast +from typing import Any, Literal import openai from openai._streaming import AsyncStream -from openai._types import NOT_GIVEN -from openai.types.chat import ( - ChatCompletionAssistantMessageParam, - ChatCompletionChunk, - ChatCompletionMessageParam, - ChatCompletionMessageToolCallParam, - ChatCompletionToolMessageParam, - ChatCompletionToolParam, +from openai.types.responses import ( + EasyInputMessageParam, + FunctionToolParam, + ResponseFunctionCallArgumentsDeltaEvent, + ResponseFunctionCallArgumentsDoneEvent, + ResponseFunctionToolCall, + ResponseFunctionToolCallParam, + ResponseInputParam, + ResponseOutputItemAddedEvent, + ResponseOutputMessage, + ResponseStreamEvent, + ResponseTextDeltaEvent, + ToolParam, ) -from openai.types.chat.chat_completion_message_tool_call_param import Function -from openai.types.shared_params import FunctionDefinition +from openai.types.responses.response_input_param import FunctionCallOutput from voluptuous_openapi import convert from homeassistant.components import assist_pipeline, conversation @@ -60,123 +64,81 @@ async def async_setup_entry( def _format_tool( tool: llm.Tool, custom_serializer: Callable[[Any], Any] | None -) -> ChatCompletionToolParam: +) -> FunctionToolParam: """Format tool specification.""" - tool_spec = FunctionDefinition( + return FunctionToolParam( + type="function", name=tool.name, parameters=convert(tool.parameters, custom_serializer=custom_serializer), + description=tool.description, + strict=False, ) - if tool.description: - tool_spec["description"] = tool.description - return ChatCompletionToolParam(type="function", function=tool_spec) def _convert_content_to_param( content: conversation.Content, -) -> ChatCompletionMessageParam: +) -> ResponseInputParam: """Convert any native chat message for this agent to the native format.""" - if content.role == "tool_result": - assert type(content) is conversation.ToolResultContent - return ChatCompletionToolMessageParam( - role="tool", - tool_call_id=content.tool_call_id, - content=json.dumps(content.tool_result), - ) - if content.role != "assistant" or not content.tool_calls: - role: Literal["system", "user", "assistant", "developer"] = content.role + messages: ResponseInputParam = [] + if isinstance(content, conversation.ToolResultContent): + return [ + FunctionCallOutput( + type="function_call_output", + call_id=content.tool_call_id, + output=json.dumps(content.tool_result), + ) + ] + + if content.content: + role: Literal["user", "assistant", "system", "developer"] = content.role if role == "system": role = "developer" - return cast( - ChatCompletionMessageParam, - {"role": content.role, "content": content.content}, + messages.append( + EasyInputMessageParam(type="message", role=role, content=content.content) ) - # Handle the Assistant content including tool calls. - assert type(content) is conversation.AssistantContent - return ChatCompletionAssistantMessageParam( - role="assistant", - content=content.content, - tool_calls=[ - ChatCompletionMessageToolCallParam( - id=tool_call.id, - function=Function( - arguments=json.dumps(tool_call.tool_args), - name=tool_call.tool_name, - ), - type="function", + if isinstance(content, conversation.AssistantContent) and content.tool_calls: + messages.extend( + # https://github.com/openai/openai-python/issues/2205 + ResponseFunctionToolCallParam( # type: ignore[typeddict-item] + type="function_call", + name=tool_call.tool_name, + arguments=json.dumps(tool_call.tool_args), + call_id=tool_call.id, ) for tool_call in content.tool_calls - ], - ) + ) + return messages async def _transform_stream( - result: AsyncStream[ChatCompletionChunk], + result: AsyncStream[ResponseStreamEvent], ) -> AsyncGenerator[conversation.AssistantContentDeltaDict]: """Transform an OpenAI delta stream into HA format.""" - current_tool_call: dict | None = None + async for event in result: + LOGGER.debug("Received event: %s", event) - async for chunk in result: - LOGGER.debug("Received chunk: %s", chunk) - choice = chunk.choices[0] - - if choice.finish_reason: - if current_tool_call: - yield { - "tool_calls": [ - llm.ToolInput( - id=current_tool_call["id"], - tool_name=current_tool_call["tool_name"], - tool_args=json.loads(current_tool_call["tool_args"]), - ) - ] - } - - break - - delta = chunk.choices[0].delta - - # We can yield delta messages not continuing or starting tool calls - if current_tool_call is None and not delta.tool_calls: - yield { # type: ignore[misc] - key: value - for key in ("role", "content") - if (value := getattr(delta, key)) is not None - } - continue - - # When doing tool calls, we should always have a tool call - # object or we have gotten stopped above with a finish_reason set. - if ( - not delta.tool_calls - or not (delta_tool_call := delta.tool_calls[0]) - or not delta_tool_call.function - ): - raise ValueError("Expected delta with tool call") - - if current_tool_call and delta_tool_call.index == current_tool_call["index"]: - current_tool_call["tool_args"] += delta_tool_call.function.arguments or "" - continue - - # We got tool call with new index, so we need to yield the previous - if current_tool_call: + if isinstance(event, ResponseOutputItemAddedEvent): + if isinstance(event.item, ResponseOutputMessage): + yield {"role": event.item.role} + elif isinstance(event.item, ResponseFunctionToolCall): + current_tool_call = event.item + elif isinstance(event, ResponseTextDeltaEvent): + yield {"content": event.delta} + elif isinstance(event, ResponseFunctionCallArgumentsDeltaEvent): + current_tool_call.arguments += event.delta + elif isinstance(event, ResponseFunctionCallArgumentsDoneEvent): + current_tool_call.status = "completed" yield { "tool_calls": [ llm.ToolInput( - id=current_tool_call["id"], - tool_name=current_tool_call["tool_name"], - tool_args=json.loads(current_tool_call["tool_args"]), + id=current_tool_call.call_id, + tool_name=current_tool_call.name, + tool_args=json.loads(current_tool_call.arguments), ) ] } - current_tool_call = { - "index": delta_tool_call.index, - "id": delta_tool_call.id, - "tool_name": delta_tool_call.function.name, - "tool_args": delta_tool_call.function.arguments or "", - } - class OpenAIConversationEntity( conversation.ConversationEntity, conversation.AbstractConversationAgent @@ -241,7 +203,7 @@ class OpenAIConversationEntity( except conversation.ConverseError as err: return err.as_conversation_result() - tools: list[ChatCompletionToolParam] | None = None + tools: list[ToolParam] | None = None if chat_log.llm_api: tools = [ _format_tool(tool, chat_log.llm_api.custom_serializer) @@ -249,7 +211,11 @@ class OpenAIConversationEntity( ] model = options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL) - messages = [_convert_content_to_param(content) for content in chat_log.content] + messages = [ + m + for content in chat_log.content + for m in _convert_content_to_param(content) + ] client = self.entry.runtime_data @@ -257,24 +223,28 @@ class OpenAIConversationEntity( for _iteration in range(MAX_TOOL_ITERATIONS): model_args = { "model": model, - "messages": messages, - "tools": tools or NOT_GIVEN, - "max_completion_tokens": options.get( + "input": messages, + "max_output_tokens": options.get( CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS ), "top_p": options.get(CONF_TOP_P, RECOMMENDED_TOP_P), "temperature": options.get(CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE), "user": chat_log.conversation_id, + "store": False, "stream": True, } + if tools: + model_args["tools"] = tools if model.startswith("o"): - model_args["reasoning_effort"] = options.get( - CONF_REASONING_EFFORT, RECOMMENDED_REASONING_EFFORT - ) + model_args["reasoning"] = { + "effort": options.get( + CONF_REASONING_EFFORT, RECOMMENDED_REASONING_EFFORT + ) + } try: - result = await client.chat.completions.create(**model_args) + result = await client.responses.create(**model_args) except openai.RateLimitError as err: LOGGER.error("Rate limited by OpenAI: %s", err) raise HomeAssistantError("Rate limited or insufficient funds") from err @@ -282,14 +252,10 @@ class OpenAIConversationEntity( LOGGER.error("Error talking to OpenAI: %s", err) raise HomeAssistantError("Error talking to OpenAI") from err - messages.extend( - [ - _convert_content_to_param(content) - async for content in chat_log.async_add_delta_content_stream( - user_input.agent_id, _transform_stream(result) - ) - ] - ) + async for content in chat_log.async_add_delta_content_stream( + user_input.agent_id, _transform_stream(result) + ): + messages.extend(_convert_content_to_param(content)) if not chat_log.unresponded_tool_results: break diff --git a/tests/components/openai_conversation/test_conversation.py b/tests/components/openai_conversation/test_conversation.py index 238fd5f2d7b..bfcacefb044 100644 --- a/tests/components/openai_conversation/test_conversation.py +++ b/tests/components/openai_conversation/test_conversation.py @@ -3,14 +3,28 @@ from collections.abc import Generator from unittest.mock import AsyncMock, patch -from httpx import Response +import httpx from openai import AuthenticationError, RateLimitError -from openai.types.chat.chat_completion_chunk import ( - ChatCompletionChunk, - Choice, - ChoiceDelta, - ChoiceDeltaToolCall, - ChoiceDeltaToolCallFunction, +from openai.types import ResponseFormatText +from openai.types.responses import ( + Response, + ResponseCompletedEvent, + ResponseContentPartAddedEvent, + ResponseContentPartDoneEvent, + ResponseCreatedEvent, + ResponseFunctionCallArgumentsDeltaEvent, + ResponseFunctionCallArgumentsDoneEvent, + ResponseFunctionToolCall, + ResponseInProgressEvent, + ResponseOutputItemAddedEvent, + ResponseOutputItemDoneEvent, + ResponseOutputMessage, + ResponseOutputText, + ResponseReasoningItem, + ResponseStreamEvent, + ResponseTextConfig, + ResponseTextDeltaEvent, + ResponseTextDoneEvent, ) import pytest from syrupy.assertion import SnapshotAssertion @@ -28,40 +42,65 @@ from tests.components.conversation import ( mock_chat_log, # noqa: F401 ) -ASSIST_RESPONSE_FINISH = ( - # Assistant message - ChatCompletionChunk( - id="chatcmpl-B", - created=1700000000, - model="gpt-4-1106-preview", - object="chat.completion.chunk", - choices=[Choice(index=0, delta=ChoiceDelta(content="Cool"))], - ), - # Finish stream - ChatCompletionChunk( - id="chatcmpl-B", - created=1700000000, - model="gpt-4-1106-preview", - object="chat.completion.chunk", - choices=[Choice(index=0, finish_reason="stop", delta=ChoiceDelta())], - ), -) - @pytest.fixture def mock_create_stream() -> Generator[AsyncMock]: """Mock stream response.""" - async def mock_generator(stream): - for value in stream: + async def mock_generator(events, **kwargs): + response = Response( + id="resp_A", + created_at=1700000000, + error=None, + incomplete_details=None, + instructions=kwargs.get("instructions"), + metadata=kwargs.get("metadata", {}), + model=kwargs.get("model", "gpt-4o-mini"), + object="response", + output=[], + parallel_tool_calls=kwargs.get("parallel_tool_calls", True), + temperature=kwargs.get("temperature", 1.0), + tool_choice=kwargs.get("tool_choice", "auto"), + tools=kwargs.get("tools"), + top_p=kwargs.get("top_p", 1.0), + max_output_tokens=kwargs.get("max_output_tokens", 100000), + previous_response_id=kwargs.get("previous_response_id"), + reasoning=kwargs.get("reasoning"), + status="in_progress", + text=kwargs.get( + "text", ResponseTextConfig(format=ResponseFormatText(type="text")) + ), + truncation=kwargs.get("truncation", "disabled"), + usage=None, + user=kwargs.get("user"), + store=kwargs.get("store", True), + ) + yield ResponseCreatedEvent( + response=response, + type="response.created", + ) + yield ResponseInProgressEvent( + response=response, + type="response.in_progress", + ) + + for value in events: + if isinstance(value, ResponseOutputItemDoneEvent): + response.output.append(value.item) yield value + response.status = "completed" + yield ResponseCompletedEvent( + response=response, + type="response.completed", + ) + with patch( - "openai.resources.chat.completions.AsyncCompletions.create", + "openai.resources.responses.AsyncResponses.create", AsyncMock(), ) as mock_create: mock_create.side_effect = lambda **kwargs: mock_generator( - mock_create.return_value.pop(0) + mock_create.return_value.pop(0), **kwargs ) yield mock_create @@ -99,13 +138,17 @@ async def test_entity( [ ( RateLimitError( - response=Response(status_code=429, request=""), body=None, message=None + response=httpx.Response(status_code=429, request=""), + body=None, + message=None, ), "Rate limited or insufficient funds", ), ( AuthenticationError( - response=Response(status_code=401, request=""), body=None, message=None + response=httpx.Response(status_code=401, request=""), + body=None, + message=None, ), "Error talking to OpenAI", ), @@ -120,7 +163,7 @@ async def test_error_handling( ) -> None: """Test that we handle errors when calling completion API.""" with patch( - "openai.resources.chat.completions.AsyncCompletions.create", + "openai.resources.responses.AsyncResponses.create", new_callable=AsyncMock, side_effect=exception, ): @@ -144,6 +187,165 @@ async def test_conversation_agent( assert agent.supported_languages == "*" +def create_message_item( + id: str, text: str | list[str], output_index: int +) -> list[ResponseStreamEvent]: + """Create a message item.""" + if isinstance(text, str): + text = [text] + + content = ResponseOutputText(annotations=[], text="", type="output_text") + events = [ + ResponseOutputItemAddedEvent( + item=ResponseOutputMessage( + id=id, + content=[], + type="message", + role="assistant", + status="in_progress", + ), + output_index=output_index, + type="response.output_item.added", + ), + ResponseContentPartAddedEvent( + content_index=0, + item_id=id, + output_index=output_index, + part=content, + type="response.content_part.added", + ), + ] + + content.text = "".join(text) + events.extend( + ResponseTextDeltaEvent( + content_index=0, + delta=delta, + item_id=id, + output_index=output_index, + type="response.output_text.delta", + ) + for delta in text + ) + + events.extend( + [ + ResponseTextDoneEvent( + content_index=0, + item_id=id, + output_index=output_index, + text="".join(text), + type="response.output_text.done", + ), + ResponseContentPartDoneEvent( + content_index=0, + item_id=id, + output_index=output_index, + part=content, + type="response.content_part.done", + ), + ResponseOutputItemDoneEvent( + item=ResponseOutputMessage( + id=id, + content=[content], + role="assistant", + status="completed", + type="message", + ), + output_index=output_index, + type="response.output_item.done", + ), + ] + ) + + return events + + +def create_function_tool_call_item( + id: str, arguments: str | list[str], call_id: str, name: str, output_index: int +) -> list[ResponseStreamEvent]: + """Create a function tool call item.""" + if isinstance(arguments, str): + arguments = [arguments] + + events = [ + ResponseOutputItemAddedEvent( + item=ResponseFunctionToolCall( + id=id, + arguments="", + call_id=call_id, + name=name, + type="function_call", + status="in_progress", + ), + output_index=output_index, + type="response.output_item.added", + ) + ] + + events.extend( + ResponseFunctionCallArgumentsDeltaEvent( + delta=delta, + item_id=id, + output_index=output_index, + type="response.function_call_arguments.delta", + ) + for delta in arguments + ) + + events.append( + ResponseFunctionCallArgumentsDoneEvent( + arguments="".join(arguments), + item_id=id, + output_index=output_index, + type="response.function_call_arguments.done", + ) + ) + + events.append( + ResponseOutputItemDoneEvent( + item=ResponseFunctionToolCall( + id=id, + arguments="".join(arguments), + call_id=call_id, + name=name, + type="function_call", + status="completed", + ), + output_index=output_index, + type="response.output_item.done", + ) + ) + + return events + + +def create_reasoning_item(id: str, output_index: int) -> list[ResponseStreamEvent]: + """Create a reasoning item.""" + return [ + ResponseOutputItemAddedEvent( + item=ResponseReasoningItem( + id=id, + summary=[], + type="reasoning", + status=None, + ), + output_index=output_index, + type="response.output_item.added", + ), + ResponseOutputItemDoneEvent( + item=ResponseReasoningItem( + id=id, + summary=[], + type="reasoning", + status=None, + ), + output_index=output_index, + type="response.output_item.done", + ), + ] + + async def test_function_call( hass: HomeAssistant, mock_config_entry_with_assist: MockConfigEntry, @@ -156,111 +358,27 @@ async def test_function_call( mock_create_stream.return_value = [ # Initial conversation ( + # Wait for the model to think + *create_reasoning_item(id="rs_A", output_index=0), # First tool call - ChatCompletionChunk( - id="chatcmpl-A", - created=1700000000, - model="gpt-4-1106-preview", - object="chat.completion.chunk", - choices=[ - Choice( - index=0, - delta=ChoiceDelta( - tool_calls=[ - ChoiceDeltaToolCall( - id="call_call_1", - index=0, - function=ChoiceDeltaToolCallFunction( - name="test_tool", - arguments=None, - ), - ) - ] - ), - ) - ], - ), - ChatCompletionChunk( - id="chatcmpl-A", - created=1700000000, - model="gpt-4-1106-preview", - object="chat.completion.chunk", - choices=[ - Choice( - index=0, - delta=ChoiceDelta( - tool_calls=[ - ChoiceDeltaToolCall( - index=0, - function=ChoiceDeltaToolCallFunction( - name=None, - arguments='{"para', - ), - ) - ] - ), - ) - ], - ), - ChatCompletionChunk( - id="chatcmpl-A", - created=1700000000, - model="gpt-4-1106-preview", - object="chat.completion.chunk", - choices=[ - Choice( - index=0, - delta=ChoiceDelta( - tool_calls=[ - ChoiceDeltaToolCall( - index=0, - function=ChoiceDeltaToolCallFunction( - name=None, - arguments='m1":"call1"}', - ), - ) - ] - ), - ) - ], + *create_function_tool_call_item( + id="fc_1", + arguments=['{"para', 'm1":"call1"}'], + call_id="call_call_1", + name="test_tool", + output_index=1, ), # Second tool call - ChatCompletionChunk( - id="chatcmpl-A", - created=1700000000, - model="gpt-4-1106-preview", - object="chat.completion.chunk", - choices=[ - Choice( - index=0, - delta=ChoiceDelta( - tool_calls=[ - ChoiceDeltaToolCall( - id="call_call_2", - index=1, - function=ChoiceDeltaToolCallFunction( - name="test_tool", - arguments='{"param1":"call2"}', - ), - ) - ] - ), - ) - ], - ), - # Finish stream - ChatCompletionChunk( - id="chatcmpl-A", - created=1700000000, - model="gpt-4-1106-preview", - object="chat.completion.chunk", - choices=[ - Choice(index=0, finish_reason="tool_calls", delta=ChoiceDelta()) - ], + *create_function_tool_call_item( + id="fc_2", + arguments='{"param1":"call2"}', + call_id="call_call_2", + name="test_tool", + output_index=2, ), ), # Response after tool responses - ASSIST_RESPONSE_FINISH, + create_message_item(id="msg_A", text="Cool", output_index=0), ] mock_chat_log.mock_tool_results( { @@ -288,99 +406,27 @@ async def test_function_call( ( "Test function call started with missing arguments", ( - ChatCompletionChunk( - id="chatcmpl-A", - created=1700000000, - model="gpt-4-1106-preview", - object="chat.completion.chunk", - choices=[ - Choice( - index=0, - delta=ChoiceDelta( - tool_calls=[ - ChoiceDeltaToolCall( - id="call_call_1", - index=0, - function=ChoiceDeltaToolCallFunction( - name="test_tool", - arguments=None, - ), - ) - ] - ), - ) - ], - ), - ChatCompletionChunk( - id="chatcmpl-B", - created=1700000000, - model="gpt-4-1106-preview", - object="chat.completion.chunk", - choices=[Choice(index=0, delta=ChoiceDelta(content="Cool"))], + *create_function_tool_call_item( + id="fc_1", + arguments=[], + call_id="call_call_1", + name="test_tool", + output_index=0, ), + *create_message_item(id="msg_A", text="Cool", output_index=1), ), ), ( "Test invalid JSON", ( - ChatCompletionChunk( - id="chatcmpl-A", - created=1700000000, - model="gpt-4-1106-preview", - object="chat.completion.chunk", - choices=[ - Choice( - index=0, - delta=ChoiceDelta( - tool_calls=[ - ChoiceDeltaToolCall( - id="call_call_1", - index=0, - function=ChoiceDeltaToolCallFunction( - name="test_tool", - arguments=None, - ), - ) - ] - ), - ) - ], - ), - ChatCompletionChunk( - id="chatcmpl-A", - created=1700000000, - model="gpt-4-1106-preview", - object="chat.completion.chunk", - choices=[ - Choice( - index=0, - delta=ChoiceDelta( - tool_calls=[ - ChoiceDeltaToolCall( - index=0, - function=ChoiceDeltaToolCallFunction( - name=None, - arguments='{"para', - ), - ) - ] - ), - ) - ], - ), - ChatCompletionChunk( - id="chatcmpl-B", - created=1700000000, - model="gpt-4-1106-preview", - object="chat.completion.chunk", - choices=[ - Choice( - index=0, - delta=ChoiceDelta(content="Cool"), - finish_reason="tool_calls", - ) - ], + *create_function_tool_call_item( + id="fc_1", + arguments=['{"para'], + call_id="call_call_1", + name="test_tool", + output_index=0, ), + *create_message_item(id="msg_A", text="Cool", output_index=1), ), ), ], @@ -392,7 +438,7 @@ async def test_function_call_invalid( mock_create_stream: AsyncMock, mock_chat_log: MockChatLog, # noqa: F811 description: str, - messages: tuple[ChatCompletionChunk], + messages: tuple[ResponseStreamEvent], ) -> None: """Test function call containing invalid data.""" mock_create_stream.return_value = [messages] @@ -432,7 +478,9 @@ async def test_assist_api_tools_conversion( hass.states.async_set(f"{component}.test", "on") async_expose_entity(hass, "conversation", f"{component}.test", True) - mock_create_stream.return_value = [ASSIST_RESPONSE_FINISH] + mock_create_stream.return_value = [ + create_message_item(id="msg_A", text="Cool", output_index=0) + ] await conversation.async_converse( hass, "hello", None, Context(), agent_id="conversation.openai" diff --git a/tests/components/openai_conversation/test_init.py b/tests/components/openai_conversation/test_init.py index 05a92d0b98e..5aef68841ee 100644 --- a/tests/components/openai_conversation/test_init.py +++ b/tests/components/openai_conversation/test_init.py @@ -2,17 +2,16 @@ from unittest.mock import AsyncMock, mock_open, patch -from httpx import Request, Response +import httpx from openai import ( APIConnectionError, AuthenticationError, BadRequestError, RateLimitError, ) -from openai.types.chat.chat_completion import ChatCompletion, Choice -from openai.types.chat.chat_completion_message import ChatCompletionMessage from openai.types.image import Image from openai.types.images_response import ImagesResponse +from openai.types.responses import Response, ResponseOutputMessage, ResponseOutputText import pytest from homeassistant.components.openai_conversation import CONF_FILENAMES @@ -117,8 +116,8 @@ async def test_generate_image_service_error( patch( "openai.resources.images.AsyncImages.generate", side_effect=RateLimitError( - response=Response( - status_code=500, request=Request(method="GET", url="") + response=httpx.Response( + status_code=500, request=httpx.Request(method="GET", url="") ), body=None, message="Reason", @@ -202,13 +201,13 @@ async def test_invalid_config_entry( ("side_effect", "error"), [ ( - APIConnectionError(request=Request(method="GET", url="test")), + APIConnectionError(request=httpx.Request(method="GET", url="test")), "Connection error", ), ( AuthenticationError( - response=Response( - status_code=500, request=Request(method="GET", url="test") + response=httpx.Response( + status_code=500, request=httpx.Request(method="GET", url="test") ), body=None, message="", @@ -217,8 +216,8 @@ async def test_invalid_config_entry( ), ( BadRequestError( - response=Response( - status_code=500, request=Request(method="GET", url="test") + response=httpx.Response( + status_code=500, request=httpx.Request(method="GET", url="test") ), body=None, message="", @@ -250,11 +249,11 @@ async def test_init_error( ( {"prompt": "Picture of a dog", "filenames": []}, { - "messages": [ + "input": [ { "content": [ { - "type": "text", + "type": "input_text", "text": "Picture of a dog", }, ], @@ -266,18 +265,18 @@ async def test_init_error( ( {"prompt": "Picture of a dog", "filenames": ["/a/b/c.jpg"]}, { - "messages": [ + "input": [ { "content": [ { - "type": "text", + "type": "input_text", "text": "Picture of a dog", }, { - "type": "image_url", - "image_url": { - "url": "data:image/jpeg;base64,BASE64IMAGE1", - }, + "type": "input_image", + "image_url": "data:image/jpeg;base64,BASE64IMAGE1", + "detail": "auto", + "file_id": "/a/b/c.jpg", }, ], }, @@ -291,24 +290,24 @@ async def test_init_error( "filenames": ["/a/b/c.jpg", "d/e/f.jpg"], }, { - "messages": [ + "input": [ { "content": [ { - "type": "text", + "type": "input_text", "text": "Picture of a dog", }, { - "type": "image_url", - "image_url": { - "url": "data:image/jpeg;base64,BASE64IMAGE1", - }, + "type": "input_image", + "image_url": "data:image/jpeg;base64,BASE64IMAGE1", + "detail": "auto", + "file_id": "/a/b/c.jpg", }, { - "type": "image_url", - "image_url": { - "url": "data:image/jpeg;base64,BASE64IMAGE2", - }, + "type": "input_image", + "image_url": "data:image/jpeg;base64,BASE64IMAGE2", + "detail": "auto", + "file_id": "d/e/f.jpg", }, ], }, @@ -329,13 +328,17 @@ async def test_generate_content_service( """Test generate content service.""" service_data["config_entry"] = mock_config_entry.entry_id expected_args["model"] = "gpt-4o-mini" - expected_args["n"] = 1 - expected_args["response_format"] = {"type": "json_object"} - expected_args["messages"][0]["role"] = "user" + expected_args["max_output_tokens"] = 150 + expected_args["top_p"] = 1.0 + expected_args["temperature"] = 1.0 + expected_args["user"] = None + expected_args["store"] = False + expected_args["input"][0]["type"] = "message" + expected_args["input"][0]["role"] = "user" with ( patch( - "openai.resources.chat.completions.AsyncCompletions.create", + "openai.resources.responses.AsyncResponses.create", new_callable=AsyncMock, ) as mock_create, patch( @@ -345,19 +348,27 @@ async def test_generate_content_service( patch("pathlib.Path.exists", return_value=True), patch.object(hass.config, "is_allowed_path", return_value=True), ): - mock_create.return_value = ChatCompletion( - id="", - model="", - created=1700000000, - object="chat.completion", - choices=[ - Choice( - index=0, - finish_reason="stop", - message=ChatCompletionMessage( - role="assistant", - content="This is the response", - ), + mock_create.return_value = Response( + object="response", + id="resp_A", + created_at=1700000000, + model="gpt-4o-mini", + parallel_tool_calls=True, + tool_choice="auto", + tools=[], + output=[ + ResponseOutputMessage( + type="message", + id="msg_A", + content=[ + ResponseOutputText( + type="output_text", + text="This is the response", + annotations=[], + ) + ], + role="assistant", + status="completed", ) ], ) @@ -427,7 +438,7 @@ async def test_generate_content_service_invalid( with ( patch( - "openai.resources.chat.completions.AsyncCompletions.create", + "openai.resources.responses.AsyncResponses.create", new_callable=AsyncMock, ) as mock_create, patch( @@ -459,10 +470,10 @@ async def test_generate_content_service_error( """Test generate content service handles errors.""" with ( patch( - "openai.resources.chat.completions.AsyncCompletions.create", + "openai.resources.responses.AsyncResponses.create", side_effect=RateLimitError( - response=Response( - status_code=417, request=Request(method="GET", url="") + response=httpx.Response( + status_code=417, request=httpx.Request(method="GET", url="") ), body=None, message="Reason", From 2424d1c615274ed3fe49485f64cb336dda1cd8f9 Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Sun, 16 Mar 2025 13:19:32 -0400 Subject: [PATCH 2667/3148] bump Python-Roborock to 2.14.0 (#140727) bump Python Roborock to 2.14.0 --- 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 1b143591203..45cfe4e12d8 100644 --- a/homeassistant/components/roborock/manifest.json +++ b/homeassistant/components/roborock/manifest.json @@ -7,7 +7,7 @@ "iot_class": "local_polling", "loggers": ["roborock"], "requirements": [ - "python-roborock==2.12.2", + "python-roborock==2.14.0", "vacuum-map-parser-roborock==0.1.2" ] } diff --git a/requirements_all.txt b/requirements_all.txt index 758456c5e9b..e5840c757bd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2465,7 +2465,7 @@ python-rabbitair==0.0.8 python-ripple-api==0.0.3 # homeassistant.components.roborock -python-roborock==2.12.2 +python-roborock==2.14.0 # homeassistant.components.smarttub python-smarttub==0.0.39 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 562ccd14163..4da33240d7a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1995,7 +1995,7 @@ python-picnic-api2==1.2.2 python-rabbitair==0.0.8 # homeassistant.components.roborock -python-roborock==2.12.2 +python-roborock==2.14.0 # homeassistant.components.smarttub python-smarttub==0.0.39 From 2ece7fbc112ba65071527e2617f9480328f05dab Mon Sep 17 00:00:00 2001 From: Thomas55555 <59625598+Thomas55555@users.noreply.github.com> Date: Sun, 16 Mar 2025 19:32:59 +0100 Subject: [PATCH 2668/3148] Add strict typing to remote_calendar (#140734) --- .strict-typing | 1 + .../components/remote_calendar/quality_scale.yaml | 2 +- mypy.ini | 10 ++++++++++ 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/.strict-typing b/.strict-typing index 56d3e299281..0e00c2e9e07 100644 --- a/.strict-typing +++ b/.strict-typing @@ -412,6 +412,7 @@ homeassistant.components.recollect_waste.* homeassistant.components.recorder.* homeassistant.components.remember_the_milk.* homeassistant.components.remote.* +homeassistant.components.remote_calendar.* homeassistant.components.renault.* homeassistant.components.reolink.* homeassistant.components.repairs.* diff --git a/homeassistant/components/remote_calendar/quality_scale.yaml b/homeassistant/components/remote_calendar/quality_scale.yaml index 3693d75f2cf..05dc32e5da9 100644 --- a/homeassistant/components/remote_calendar/quality_scale.yaml +++ b/homeassistant/components/remote_calendar/quality_scale.yaml @@ -97,4 +97,4 @@ rules: # Platinum async-dependency: todo inject-websession: done - strict-typing: todo + strict-typing: done diff --git a/mypy.ini b/mypy.ini index 520fad7d738..852678677bb 100644 --- a/mypy.ini +++ b/mypy.ini @@ -3876,6 +3876,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.remote_calendar.*] +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.renault.*] check_untyped_defs = true disallow_incomplete_defs = true From 8a552aef9dc67be53c60c01652439b16452cb383 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Sun, 16 Mar 2025 19:33:28 +0100 Subject: [PATCH 2669/3148] Adjusts strings in create actions in Habitica integration (#140742) Adjusts strings in create actions --- homeassistant/components/habitica/strings.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/habitica/strings.json b/homeassistant/components/habitica/strings.json index cc67b767519..fac0fdf3868 100644 --- a/homeassistant/components/habitica/strings.json +++ b/homeassistant/components/habitica/strings.json @@ -768,7 +768,7 @@ "description": "[%key:component::habitica::common::notes_description%]" }, "tag": { - "name": "[%key:component::habitica::common::tag_name%]", + "name": "[%key:component::habitica::common::tag_options_name%]", "description": "[%key:component::habitica::common::tag_description%]" }, "alias": { @@ -868,7 +868,7 @@ "description": "[%key:component::habitica::common::notes_description%]" }, "tag": { - "name": "[%key:component::habitica::common::tag_name%]", + "name": "[%key:component::habitica::common::tag_options_name%]", "description": "[%key:component::habitica::common::tag_description%]" }, "alias": { @@ -1008,7 +1008,7 @@ "description": "[%key:component::habitica::common::notes_description%]" }, "tag": { - "name": "[%key:component::habitica::common::tag_name%]", + "name": "[%key:component::habitica::common::tag_options_name%]", "description": "[%key:component::habitica::common::tag_description%]" }, "alias": { @@ -1024,11 +1024,11 @@ "description": "[%key:component::habitica::common::date_description%]" }, "reminder": { - "name": "[%key:component::habitica::common::reminder_name%]", + "name": "[%key:component::habitica::common::reminder_options_name%]", "description": "[%key:component::habitica::common::reminder_description%]" }, "add_checklist_item": { - "name": "[%key:component::habitica::common::add_checklist_item_name%]", + "name": "[%key:component::habitica::common::checklist_options_name%]", "description": "[%key:component::habitica::common::add_checklist_item_description%]" } }, From b5fa3e74c0b7c6c25cbb43fb9f53aeda4af81412 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Sun, 16 Mar 2025 19:51:06 +0100 Subject: [PATCH 2670/3148] Add option to specify Reolink Basic Service Port (#137603) * Allow changing the baichuan port * styling * Add description * Add tests * Review feedback * capital letters Co-authored-by: Robert Resch --------- Co-authored-by: Robert Resch --- homeassistant/components/reolink/__init__.py | 14 +++++++++++++- .../components/reolink/config_flow.py | 7 +++++-- homeassistant/components/reolink/const.py | 1 + homeassistant/components/reolink/host.py | 4 +++- homeassistant/components/reolink/strings.json | 4 +++- tests/components/reolink/conftest.py | 4 ++++ tests/components/reolink/test_config_flow.py | 18 ++++++++++++++++++ tests/components/reolink/test_init.py | 18 +++++++++++++++++- tests/components/reolink/test_media_source.py | 4 +++- 9 files changed, 67 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/reolink/__init__.py b/homeassistant/components/reolink/__init__.py index 71ca5428740..2489133841a 100644 --- a/homeassistant/components/reolink/__init__.py +++ b/homeassistant/components/reolink/__init__.py @@ -28,7 +28,7 @@ from homeassistant.helpers.event import async_call_later from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import CONF_SUPPORTS_PRIVACY_MODE, CONF_USE_HTTPS, DOMAIN +from .const import CONF_BC_PORT, CONF_SUPPORTS_PRIVACY_MODE, CONF_USE_HTTPS, DOMAIN from .exceptions import PasswordIncompatible, ReolinkException, UserNotAdmin from .host import ReolinkHost from .services import async_setup_services @@ -100,6 +100,7 @@ async def async_setup_entry( or host.api.use_https != config_entry.data[CONF_USE_HTTPS] or host.api.supported(None, "privacy_mode") != config_entry.data.get(CONF_SUPPORTS_PRIVACY_MODE) + or host.api.baichuan.port != config_entry.data.get(CONF_BC_PORT) ): if host.api.port != config_entry.data[CONF_PORT]: _LOGGER.warning( @@ -108,10 +109,21 @@ async def async_setup_entry( config_entry.data[CONF_PORT], host.api.port, ) + if ( + config_entry.data.get(CONF_BC_PORT, host.api.baichuan.port) + != host.api.baichuan.port + ): + _LOGGER.warning( + "Baichuan port of Reolink %s, changed from %s to %s", + host.api.nvr_name, + config_entry.data.get(CONF_BC_PORT), + host.api.baichuan.port, + ) data = { **config_entry.data, CONF_PORT: host.api.port, CONF_USE_HTTPS: host.api.use_https, + CONF_BC_PORT: host.api.baichuan.port, CONF_SUPPORTS_PRIVACY_MODE: host.api.supported(None, "privacy_mode"), } hass.config_entries.async_update_entry(config_entry, data=data) diff --git a/homeassistant/components/reolink/config_flow.py b/homeassistant/components/reolink/config_flow.py index 7943cadef21..12ccd455be3 100644 --- a/homeassistant/components/reolink/config_flow.py +++ b/homeassistant/components/reolink/config_flow.py @@ -8,6 +8,7 @@ import logging from typing import Any from reolink_aio.api import ALLOWED_SPECIAL_CHARS +from reolink_aio.baichuan import DEFAULT_BC_PORT from reolink_aio.exceptions import ( ApiError, CredentialsInvalidError, @@ -37,7 +38,7 @@ from homeassistant.helpers import config_validation as cv, selector from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo -from .const import CONF_SUPPORTS_PRIVACY_MODE, CONF_USE_HTTPS, DOMAIN +from .const import CONF_BC_PORT, CONF_SUPPORTS_PRIVACY_MODE, CONF_USE_HTTPS, DOMAIN from .exceptions import ( PasswordIncompatible, ReolinkException, @@ -287,6 +288,7 @@ class ReolinkFlowHandler(ConfigFlow, domain=DOMAIN): if not errors: user_input[CONF_PORT] = host.api.port user_input[CONF_USE_HTTPS] = host.api.use_https + user_input[CONF_BC_PORT] = host.api.baichuan.port user_input[CONF_SUPPORTS_PRIVACY_MODE] = host.api.supported( None, "privacy_mode" ) @@ -326,8 +328,9 @@ class ReolinkFlowHandler(ConfigFlow, domain=DOMAIN): if errors: data_schema = data_schema.extend( { - vol.Optional(CONF_PORT): cv.positive_int, + vol.Optional(CONF_PORT): cv.port, vol.Required(CONF_USE_HTTPS, default=False): bool, + vol.Required(CONF_BC_PORT, default=DEFAULT_BC_PORT): cv.port, } ) diff --git a/homeassistant/components/reolink/const.py b/homeassistant/components/reolink/const.py index 7bd93337c46..026d1219881 100644 --- a/homeassistant/components/reolink/const.py +++ b/homeassistant/components/reolink/const.py @@ -3,4 +3,5 @@ DOMAIN = "reolink" CONF_USE_HTTPS = "use_https" +CONF_BC_PORT = "baichuan_port" CONF_SUPPORTS_PRIVACY_MODE = "privacy_mode_supported" diff --git a/homeassistant/components/reolink/host.py b/homeassistant/components/reolink/host.py index 2f646ba9090..53061500e32 100644 --- a/homeassistant/components/reolink/host.py +++ b/homeassistant/components/reolink/host.py @@ -12,6 +12,7 @@ from typing import Any, Literal import aiohttp from aiohttp.web import Request from reolink_aio.api import ALLOWED_SPECIAL_CHARS, Host +from reolink_aio.baichuan import DEFAULT_BC_PORT from reolink_aio.enums import SubType from reolink_aio.exceptions import NotSupportedError, ReolinkError, SubscriptionError @@ -33,7 +34,7 @@ from homeassistant.helpers.network import NoURLAvailableError, get_url from homeassistant.helpers.storage import Store from homeassistant.util.ssl import SSLCipherList -from .const import CONF_SUPPORTS_PRIVACY_MODE, CONF_USE_HTTPS, DOMAIN +from .const import CONF_BC_PORT, CONF_SUPPORTS_PRIVACY_MODE, CONF_USE_HTTPS, DOMAIN from .exceptions import ( PasswordIncompatible, ReolinkSetupException, @@ -91,6 +92,7 @@ class ReolinkHost: protocol=options[CONF_PROTOCOL], timeout=DEFAULT_TIMEOUT, aiohttp_get_session_callback=get_aiohttp_session, + bc_port=config.get(CONF_BC_PORT, DEFAULT_BC_PORT), ) self.last_wake: float = 0 diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index 335ed92d32e..daa87fb401c 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -8,13 +8,15 @@ "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]", "use_https": "Enable HTTPS", + "baichuan_port": "Basic service port", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" }, "data_description": { "host": "The hostname or IP address of your Reolink device. For example: '192.168.1.25'.", - "port": "The port to connect to the Reolink device. For HTTP normally: '80', for HTTPS normally '443'.", + "port": "The HTTP(s) port to connect to the Reolink device API. For HTTP normally: '80', for HTTPS normally '443'.", "use_https": "Use a HTTPS (SSL) connection to the Reolink device.", + "baichuan_port": "The 'Basic Service Port' to connect to the Reolink device over TCP. Normally '9000' unless manually changed in the Reolink desktop client.", "username": "Username to login to the Reolink device itself. Not the Reolink cloud account.", "password": "Password to login to the Reolink device itself. Not the Reolink cloud account." } diff --git a/tests/components/reolink/conftest.py b/tests/components/reolink/conftest.py index 5af55b48dda..293103e7eb2 100644 --- a/tests/components/reolink/conftest.py +++ b/tests/components/reolink/conftest.py @@ -10,6 +10,7 @@ from reolink_aio.exceptions import ReolinkError from homeassistant.components.reolink.config_flow import DEFAULT_PROTOCOL from homeassistant.components.reolink.const import ( + CONF_BC_PORT, CONF_SUPPORTS_PRIVACY_MODE, CONF_USE_HTTPS, DOMAIN, @@ -48,6 +49,7 @@ TEST_ITEM_NUMBER = "P000" TEST_CAM_MODEL = "RLC-123" TEST_DUO_MODEL = "Reolink Duo PoE" TEST_PRIVACY = True +TEST_BC_PORT = 5678 @pytest.fixture @@ -136,6 +138,7 @@ def reolink_connect_class() -> Generator[MagicMock]: # Baichuan host_mock.baichuan = create_autospec(Baichuan) # Disable tcp push by default for tests + host_mock.baichuan.port = TEST_BC_PORT host_mock.baichuan.events_active = False host_mock.baichuan.privacy_mode.return_value = False host_mock.baichuan.subscribe_events.side_effect = ReolinkError("Test error") @@ -175,6 +178,7 @@ def config_entry(hass: HomeAssistant) -> MockConfigEntry: CONF_PORT: TEST_PORT, CONF_USE_HTTPS: TEST_USE_HTTPS, CONF_SUPPORTS_PRIVACY_MODE: TEST_PRIVACY, + CONF_BC_PORT: TEST_BC_PORT, }, options={ CONF_PROTOCOL: DEFAULT_PROTOCOL, diff --git a/tests/components/reolink/test_config_flow.py b/tests/components/reolink/test_config_flow.py index 4fe671f8cca..e706af0d067 100644 --- a/tests/components/reolink/test_config_flow.py +++ b/tests/components/reolink/test_config_flow.py @@ -19,6 +19,7 @@ from homeassistant import config_entries from homeassistant.components.reolink import DEVICE_UPDATE_INTERVAL from homeassistant.components.reolink.config_flow import DEFAULT_PROTOCOL from homeassistant.components.reolink.const import ( + CONF_BC_PORT, CONF_SUPPORTS_PRIVACY_MODE, CONF_USE_HTTPS, DOMAIN, @@ -40,6 +41,7 @@ from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .conftest import ( DHCP_FORMATTED_MAC, + TEST_BC_PORT, TEST_HOST, TEST_HOST2, TEST_MAC, @@ -88,6 +90,7 @@ async def test_config_flow_manual_success( CONF_PORT: TEST_PORT, CONF_USE_HTTPS: TEST_USE_HTTPS, CONF_SUPPORTS_PRIVACY_MODE: TEST_PRIVACY, + CONF_BC_PORT: TEST_BC_PORT, } assert result["options"] == { CONF_PROTOCOL: DEFAULT_PROTOCOL, @@ -140,6 +143,7 @@ async def test_config_flow_privacy_success( CONF_PORT: TEST_PORT, CONF_USE_HTTPS: TEST_USE_HTTPS, CONF_SUPPORTS_PRIVACY_MODE: TEST_PRIVACY, + CONF_BC_PORT: TEST_BC_PORT, } assert result["options"] == { CONF_PROTOCOL: DEFAULT_PROTOCOL, @@ -290,6 +294,7 @@ async def test_config_flow_errors( CONF_HOST: TEST_HOST, CONF_PORT: TEST_PORT, CONF_USE_HTTPS: TEST_USE_HTTPS, + CONF_BC_PORT: TEST_BC_PORT, }, ) @@ -302,6 +307,7 @@ async def test_config_flow_errors( CONF_PORT: TEST_PORT, CONF_USE_HTTPS: TEST_USE_HTTPS, CONF_SUPPORTS_PRIVACY_MODE: TEST_PRIVACY, + CONF_BC_PORT: TEST_BC_PORT, } assert result["options"] == { CONF_PROTOCOL: DEFAULT_PROTOCOL, @@ -322,6 +328,7 @@ async def test_options_flow(hass: HomeAssistant, mock_setup_entry: MagicMock) -> CONF_PASSWORD: TEST_PASSWORD, CONF_PORT: TEST_PORT, CONF_USE_HTTPS: TEST_USE_HTTPS, + CONF_BC_PORT: TEST_BC_PORT, }, options={ CONF_PROTOCOL: "rtsp", @@ -360,6 +367,7 @@ async def test_reauth(hass: HomeAssistant, mock_setup_entry: MagicMock) -> None: CONF_PASSWORD: TEST_PASSWORD, CONF_PORT: TEST_PORT, CONF_USE_HTTPS: TEST_USE_HTTPS, + CONF_BC_PORT: TEST_BC_PORT, }, options={ CONF_PROTOCOL: DEFAULT_PROTOCOL, @@ -405,6 +413,7 @@ async def test_reauth_abort_unique_id_mismatch( CONF_PASSWORD: TEST_PASSWORD, CONF_PORT: TEST_PORT, CONF_USE_HTTPS: TEST_USE_HTTPS, + CONF_BC_PORT: TEST_BC_PORT, }, options={ CONF_PROTOCOL: DEFAULT_PROTOCOL, @@ -474,6 +483,7 @@ async def test_dhcp_flow(hass: HomeAssistant, mock_setup_entry: MagicMock) -> No CONF_PORT: TEST_PORT, CONF_USE_HTTPS: TEST_USE_HTTPS, CONF_SUPPORTS_PRIVACY_MODE: TEST_PRIVACY, + CONF_BC_PORT: TEST_BC_PORT, } assert result["options"] == { CONF_PROTOCOL: DEFAULT_PROTOCOL, @@ -496,6 +506,7 @@ async def test_dhcp_ip_update_aborted_if_wrong_mac( CONF_PASSWORD: TEST_PASSWORD, CONF_PORT: TEST_PORT, CONF_USE_HTTPS: TEST_USE_HTTPS, + CONF_BC_PORT: TEST_BC_PORT, }, options={ CONF_PROTOCOL: DEFAULT_PROTOCOL, @@ -536,6 +547,7 @@ async def test_dhcp_ip_update_aborted_if_wrong_mac( protocol=DEFAULT_PROTOCOL, timeout=DEFAULT_TIMEOUT, aiohttp_get_session_callback=ANY, + bc_port=TEST_BC_PORT, ) assert expected_call in reolink_connect_class.call_args_list @@ -593,6 +605,7 @@ async def test_dhcp_ip_update( CONF_PASSWORD: TEST_PASSWORD, CONF_PORT: TEST_PORT, CONF_USE_HTTPS: TEST_USE_HTTPS, + CONF_BC_PORT: TEST_BC_PORT, }, options={ CONF_PROTOCOL: DEFAULT_PROTOCOL, @@ -635,6 +648,7 @@ async def test_dhcp_ip_update( protocol=DEFAULT_PROTOCOL, timeout=DEFAULT_TIMEOUT, aiohttp_get_session_callback=ANY, + bc_port=TEST_BC_PORT, ) assert expected_call in reolink_connect_class.call_args_list @@ -671,6 +685,7 @@ async def test_dhcp_ip_update_ingnored_if_still_connected( CONF_PASSWORD: TEST_PASSWORD, CONF_PORT: TEST_PORT, CONF_USE_HTTPS: TEST_USE_HTTPS, + CONF_BC_PORT: TEST_BC_PORT, }, options={ CONF_PROTOCOL: DEFAULT_PROTOCOL, @@ -702,6 +717,7 @@ async def test_dhcp_ip_update_ingnored_if_still_connected( protocol=DEFAULT_PROTOCOL, timeout=DEFAULT_TIMEOUT, aiohttp_get_session_callback=ANY, + bc_port=TEST_BC_PORT, ) assert expected_call in reolink_connect_class.call_args_list @@ -731,6 +747,7 @@ async def test_reconfig(hass: HomeAssistant, mock_setup_entry: MagicMock) -> Non CONF_PASSWORD: TEST_PASSWORD, CONF_PORT: TEST_PORT, CONF_USE_HTTPS: TEST_USE_HTTPS, + CONF_BC_PORT: TEST_BC_PORT, }, options={ CONF_PROTOCOL: DEFAULT_PROTOCOL, @@ -777,6 +794,7 @@ async def test_reconfig_abort_unique_id_mismatch( CONF_PASSWORD: TEST_PASSWORD, CONF_PORT: TEST_PORT, CONF_USE_HTTPS: TEST_USE_HTTPS, + CONF_BC_PORT: TEST_BC_PORT, }, options={ CONF_PROTOCOL: DEFAULT_PROTOCOL, diff --git a/tests/components/reolink/test_init.py b/tests/components/reolink/test_init.py index 28d8c542f4f..ad7f5540b04 100644 --- a/tests/components/reolink/test_init.py +++ b/tests/components/reolink/test_init.py @@ -19,7 +19,7 @@ from homeassistant.components.reolink import ( FIRMWARE_UPDATE_INTERVAL, NUM_CRED_ERRORS, ) -from homeassistant.components.reolink.const import DOMAIN +from homeassistant.components.reolink.const import CONF_BC_PORT, DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( CONF_PORT, @@ -38,6 +38,7 @@ from homeassistant.helpers import ( from homeassistant.setup import async_setup_component from .conftest import ( + TEST_BC_PORT, TEST_CAM_MODEL, TEST_HOST_MODEL, TEST_MAC, @@ -762,6 +763,21 @@ async def test_port_changed( assert config_entry.data[CONF_PORT] == 4567 +async def test_baichuan_port_changed( + hass: HomeAssistant, + reolink_connect: MagicMock, + config_entry: MockConfigEntry, +) -> None: + """Test config_entry baichuan port update when it has changed during initial login.""" + assert config_entry.data[CONF_BC_PORT] == TEST_BC_PORT + reolink_connect.baichuan.port = 8901 + + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.data[CONF_BC_PORT] == 8901 + + async def test_privacy_mode_on( hass: HomeAssistant, freezer: FrozenDateTimeFactory, diff --git a/tests/components/reolink/test_media_source.py b/tests/components/reolink/test_media_source.py index a5a34514598..7044ea53671 100644 --- a/tests/components/reolink/test_media_source.py +++ b/tests/components/reolink/test_media_source.py @@ -15,7 +15,7 @@ from homeassistant.components.media_source import ( async_resolve_media, ) from homeassistant.components.reolink.config_flow import DEFAULT_PROTOCOL -from homeassistant.components.reolink.const import CONF_USE_HTTPS, DOMAIN +from homeassistant.components.reolink.const import CONF_BC_PORT, CONF_USE_HTTPS, DOMAIN from homeassistant.components.stream import DOMAIN as MEDIA_STREAM_DOMAIN from homeassistant.const import ( CONF_HOST, @@ -31,6 +31,7 @@ from homeassistant.helpers.device_registry import format_mac from homeassistant.setup import async_setup_component from .conftest import ( + TEST_BC_PORT, TEST_HOST2, TEST_HOST_MODEL, TEST_MAC2, @@ -348,6 +349,7 @@ async def test_browsing_not_loaded( CONF_PASSWORD: TEST_PASSWORD2, CONF_PORT: TEST_PORT, CONF_USE_HTTPS: TEST_USE_HTTPS, + CONF_BC_PORT: TEST_BC_PORT, }, options={ CONF_PROTOCOL: DEFAULT_PROTOCOL, From 735c98cb861c0cb647e998044fbdd29f58e0ad6e Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 16 Mar 2025 19:54:00 +0100 Subject: [PATCH 2671/3148] Set Home Connect button unique id to shorthand attribute (#140745) --- homeassistant/components/home_connect/button.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/home_connect/button.py b/homeassistant/components/home_connect/button.py index 0a5538ec588..726ca8cf670 100644 --- a/homeassistant/components/home_connect/button.py +++ b/homeassistant/components/home_connect/button.py @@ -102,7 +102,7 @@ class HomeConnectButtonEntity(HomeConnectEntity, ButtonEntity): ) self.entity_description = desc self.appliance = appliance - self.unique_id = f"{appliance.info.ha_id}-{desc.key}" + self._attr_unique_id = f"{appliance.info.ha_id}-{desc.key}" def update_native_value(self) -> None: """Set the value of the entity.""" From 46973f0446cc2d814afdb7e2d58ebb73e98abc02 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Sun, 16 Mar 2025 20:00:10 +0100 Subject: [PATCH 2672/3148] Redact emails and names in Bring! diagnostics (#140746) --- homeassistant/components/bring/diagnostics.py | 9 ++++++++- .../bring/snapshots/test_diagnostics.ambr | 20 +++++++++---------- 2 files changed, 18 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/bring/diagnostics.py b/homeassistant/components/bring/diagnostics.py index 6c2f779ef05..e5cafd30ab5 100644 --- a/homeassistant/components/bring/diagnostics.py +++ b/homeassistant/components/bring/diagnostics.py @@ -4,10 +4,14 @@ from __future__ import annotations from typing import Any +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.const import CONF_EMAIL, CONF_NAME from homeassistant.core import HomeAssistant from .coordinator import BringConfigEntry +TO_REDACT = {CONF_NAME, CONF_EMAIL} + async def async_get_config_entry_diagnostics( hass: HomeAssistant, config_entry: BringConfigEntry @@ -15,7 +19,10 @@ async def async_get_config_entry_diagnostics( """Return diagnostics for a config entry.""" return { - "data": {k: v.to_dict() for k, v in config_entry.runtime_data.data.items()}, + "data": { + k: async_redact_data(v.to_dict(), TO_REDACT) + for k, v in config_entry.runtime_data.data.items() + }, "lists": [lst.to_dict() for lst in config_entry.runtime_data.lists], "user_settings": config_entry.runtime_data.user_settings.to_dict(), } diff --git a/tests/components/bring/snapshots/test_diagnostics.ambr b/tests/components/bring/snapshots/test_diagnostics.ambr index 951c3d3f808..8570bc0410f 100644 --- a/tests/components/bring/snapshots/test_diagnostics.ambr +++ b/tests/components/bring/snapshots/test_diagnostics.ambr @@ -128,16 +128,16 @@ }), 'lst': dict({ 'listUuid': 'b4776778-7f6c-496e-951b-92a35d3db0dd', - 'name': 'Baumarkt', + 'name': '**REDACTED**', 'theme': 'ch.publisheria.bring.theme.home', }), 'users': dict({ 'users': list([ dict({ 'country': 'DE', - 'email': 'test-email', + 'email': '**REDACTED**', 'language': 'de', - 'name': 'Bring', + 'name': '**REDACTED**', 'photoPath': '', 'plusTryOut': False, 'publicUuid': '9a21fdfc-63a4-441a-afc1-ef3030605a9d', @@ -145,9 +145,9 @@ }), dict({ 'country': 'US', - 'email': 'EMAIL', + 'email': '**REDACTED**', 'language': 'en', - 'name': 'NAME', + 'name': '**REDACTED**', 'photoPath': '', 'plusTryOut': False, 'publicUuid': '73af455f-c158-4004-a5e0-79f4f8a6d4bd', @@ -292,16 +292,16 @@ }), 'lst': dict({ 'listUuid': 'e542eef6-dba7-4c31-a52c-29e6ab9d83a5', - 'name': 'Einkauf', + 'name': '**REDACTED**', 'theme': 'ch.publisheria.bring.theme.home', }), 'users': dict({ 'users': list([ dict({ 'country': 'DE', - 'email': 'test-email', + 'email': '**REDACTED**', 'language': 'de', - 'name': 'Bring', + 'name': '**REDACTED**', 'photoPath': '', 'plusTryOut': False, 'publicUuid': '9a21fdfc-63a4-441a-afc1-ef3030605a9d', @@ -309,9 +309,9 @@ }), dict({ 'country': 'US', - 'email': 'EMAIL', + 'email': '**REDACTED**', 'language': 'en', - 'name': 'NAME', + 'name': '**REDACTED**', 'photoPath': '', 'plusTryOut': False, 'publicUuid': '73af455f-c158-4004-a5e0-79f4f8a6d4bd', From a7b6bcf1d6aba99ad03ac9c36256d3e45465dcf1 Mon Sep 17 00:00:00 2001 From: Thomas55555 <59625598+Thomas55555@users.noreply.github.com> Date: Sun, 16 Mar 2025 20:03:02 +0100 Subject: [PATCH 2673/3148] Address post merge comments for remote calendar (#140735) --- homeassistant/components/remote_calendar/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/remote_calendar/strings.json b/homeassistant/components/remote_calendar/strings.json index c833676a410..1ad62821818 100644 --- a/homeassistant/components/remote_calendar/strings.json +++ b/homeassistant/components/remote_calendar/strings.json @@ -5,11 +5,11 @@ "user": { "description": "Please choose a name for the calendar to be imported", "data": { - "calendar_name": "Calendar Name", + "calendar_name": "Calendar name", "url": "Calendar URL" }, "data_description": { - "calendar_name": "The name of the calendar shown in th UI.", + "calendar_name": "The name of the calendar shown in the UI.", "url": "The URL of the remote calendar." } } From 56fe4319a07a51707f886335e1a326b09a0e0457 Mon Sep 17 00:00:00 2001 From: MarkGodwin <10632972+MarkGodwin@users.noreply.github.com> Date: Sun, 16 Mar 2025 19:04:58 +0000 Subject: [PATCH 2674/3148] Bump TP-Link Omada API to 1.4.4 (#140738) --- homeassistant/components/tplink_omada/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/tplink_omada/manifest.json b/homeassistant/components/tplink_omada/manifest.json index af20b54675b..274f2815330 100644 --- a/homeassistant/components/tplink_omada/manifest.json +++ b/homeassistant/components/tplink_omada/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/tplink_omada", "integration_type": "hub", "iot_class": "local_polling", - "requirements": ["tplink-omada-client==1.4.3"] + "requirements": ["tplink-omada-client==1.4.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index e5840c757bd..e86de5d2f71 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2930,7 +2930,7 @@ total-connect-client==2025.1.4 tp-connected==0.0.4 # homeassistant.components.tplink_omada -tplink-omada-client==1.4.3 +tplink-omada-client==1.4.4 # homeassistant.components.transmission transmission-rpc==7.0.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4da33240d7a..5ce29dff3ba 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2352,7 +2352,7 @@ toonapi==0.3.0 total-connect-client==2025.1.4 # homeassistant.components.tplink_omada -tplink-omada-client==1.4.3 +tplink-omada-client==1.4.4 # homeassistant.components.transmission transmission-rpc==7.0.3 From d061f4ee05e2c1560a02029903b09f456e8d70fa Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 16 Mar 2025 20:06:50 +0100 Subject: [PATCH 2675/3148] Fix SmartThings ACs without supported AC modes (#140744) --- .../components/smartthings/climate.py | 15 ++-- tests/components/smartthings/conftest.py | 1 + .../fixtures/device_status/aux_ac.json | 69 ++++++++++++++++ .../smartthings/fixtures/devices/aux_ac.json | 81 +++++++++++++++++++ .../smartthings/snapshots/test_climate.ambr | 64 +++++++++++++++ .../smartthings/snapshots/test_init.ambr | 33 ++++++++ .../smartthings/snapshots/test_sensor.ambr | 52 ++++++++++++ 7 files changed, 309 insertions(+), 6 deletions(-) create mode 100644 tests/components/smartthings/fixtures/device_status/aux_ac.json create mode 100644 tests/components/smartthings/fixtures/devices/aux_ac.json diff --git a/homeassistant/components/smartthings/climate.py b/homeassistant/components/smartthings/climate.py index a95105efaa6..c6dee3e2be4 100644 --- a/homeassistant/components/smartthings/climate.py +++ b/homeassistant/components/smartthings/climate.py @@ -571,12 +571,15 @@ class SmartThingsAirConditioner(SmartThingsEntity, ClimateEntity): def _determine_hvac_modes(self) -> list[HVACMode]: """Determine the supported HVAC modes.""" modes = [HVACMode.OFF] - modes.extend( - state - for mode in self.get_attribute_value( + if ( + ac_modes := self.get_attribute_value( Capability.AIR_CONDITIONER_MODE, Attribute.SUPPORTED_AC_MODES ) - if (state := AC_MODE_TO_STATE.get(mode)) is not None - if state not in modes - ) + ) is not None: + modes.extend( + state + for mode in ac_modes + if (state := AC_MODE_TO_STATE.get(mode)) is not None + if state not in modes + ) return modes diff --git a/tests/components/smartthings/conftest.py b/tests/components/smartthings/conftest.py index 3e0047e255a..d26805eb04b 100644 --- a/tests/components/smartthings/conftest.py +++ b/tests/components/smartthings/conftest.py @@ -137,6 +137,7 @@ def mock_smartthings() -> Generator[AsyncMock]: "abl_light_b_001", "tplink_p110", "ikea_kadrilj", + "aux_ac", ] ) def device_fixture( diff --git a/tests/components/smartthings/fixtures/device_status/aux_ac.json b/tests/components/smartthings/fixtures/device_status/aux_ac.json new file mode 100644 index 00000000000..a3ebede7a10 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/aux_ac.json @@ -0,0 +1,69 @@ +{ + "components": { + "main": { + "partyvoice23922.vtempset": { + "vtemp": { + "value": 20, + "unit": "C", + "timestamp": "2024-12-05T20:03:33.161Z" + } + }, + "airConditionerFanMode": { + "fanMode": { + "value": "auto", + "timestamp": "2024-12-05T20:03:32.930Z" + }, + "supportedAcFanModes": { + "value": null + }, + "availableAcFanModes": { + "value": null + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": 20.0, + "unit": "C", + "timestamp": "2024-12-05T20:03:33.066Z" + } + }, + "airConditionerMode": { + "availableAcModes": { + "value": null + }, + "supportedAcModes": { + "value": null + }, + "airConditionerMode": { + "value": "cool", + "timestamp": "2024-12-05T20:03:32.845Z" + } + }, + "fanSpeed": { + "fanSpeed": { + "value": 0, + "timestamp": "2024-12-05T20:03:33.334Z" + } + }, + "thermostatCoolingSetpoint": { + "coolingSetpointRange": { + "value": null + }, + "coolingSetpoint": { + "value": 20.0, + "unit": "C", + "timestamp": "2024-12-05T20:03:33.243Z" + } + }, + "switch": { + "switch": { + "value": "off", + "timestamp": "2024-12-05T20:03:32.662Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/devices/aux_ac.json b/tests/components/smartthings/fixtures/devices/aux_ac.json new file mode 100644 index 00000000000..fcdb581748c --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/aux_ac.json @@ -0,0 +1,81 @@ +{ + "items": [ + { + "deviceId": "bf53a150-f8a4-45d1-aac4-86252475d551", + "name": "vedgeaircon.v1", + "label": "AUX A/C on-off", + "manufacturerName": "SmartThingsCommunity", + "presentationId": "ab252042-5669-3c2c-8b1b-d606bbcc9e04", + "deviceManufacturerCode": "SmartThings Community", + "locationId": "5db1e3d8-ea26-44b4-8ed0-1ba9c841fd57", + "ownerId": "5404aa57-6a68-4fe2-83ff-168ef769d1c7", + "roomId": "564cdd9a-fa9f-4187-902f-95656ef22989", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "switch", + "version": 1 + }, + { + "id": "airConditionerMode", + "version": 1 + }, + { + "id": "thermostatCoolingSetpoint", + "version": 1 + }, + { + "id": "airConditionerFanMode", + "version": 1 + }, + { + "id": "fanSpeed", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "partyvoice23922.vtempset", + "version": 1 + } + ], + "categories": [ + { + "name": "AirConditioner", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2024-06-19T20:18:45.407Z", + "parentDeviceId": "e699599d-30f8-4cf0-8de7-6dbdba6a665f", + "profile": { + "id": "87f0ac35-e024-3c0a-8153-78ca27a6fe0c" + }, + "lan": { + "networkId": "vEdge_A/C_1718828324.999", + "driverId": "0fd9a9a4-8863-4a83-97a7-5a288ff0f5a6", + "executingLocally": true, + "hubId": "e699599d-30f8-4cf0-8de7-6dbdba6a665f", + "provisioningState": "TYPED" + }, + "type": "LAN", + "restrictionTier": 0, + "allowed": null, + "indoorMap": { + "coordinates": [130.0, 36.0, 378.0], + "rotation": [270.0, 0.0, 0.0], + "visible": true, + "data": null + }, + "executionContext": "LOCAL", + "relationships": [] + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/snapshots/test_climate.ambr b/tests/components/smartthings/snapshots/test_climate.ambr index 20389f38a46..893093ee2aa 100644 --- a/tests/components/smartthings/snapshots/test_climate.ambr +++ b/tests/components/smartthings/snapshots/test_climate.ambr @@ -1,4 +1,68 @@ # serializer version: 1 +# name: test_all_entities[aux_ac][climate.aux_a_c_on_off-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'fan_modes': None, + 'hvac_modes': list([ + , + ]), + 'max_temp': 35, + 'min_temp': 7, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.aux_a_c_on_off', + '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': None, + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'bf53a150-f8a4-45d1-aac4-86252475d551', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[aux_ac][climate.aux_a_c_on_off-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 20.0, + 'fan_mode': 'auto', + 'fan_modes': None, + 'friendly_name': 'AUX A/C on-off', + 'hvac_modes': list([ + , + ]), + 'max_temp': 35, + 'min_temp': 7, + 'supported_features': , + 'temperature': 20.0, + }), + 'context': , + 'entity_id': 'climate.aux_a_c_on_off', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_all_entities[bosch_radiator_thermostat_ii][climate.radiator_thermostat_ii_m_wohnzimmer-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/smartthings/snapshots/test_init.ambr b/tests/components/smartthings/snapshots/test_init.ambr index 5a3ba833cf5..e62c34cd11c 100644 --- a/tests/components/smartthings/snapshots/test_init.ambr +++ b/tests/components/smartthings/snapshots/test_init.ambr @@ -68,6 +68,39 @@ 'via_device_id': None, }) # --- +# name: test_devices[aux_ac] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + 'bf53a150-f8a4-45d1-aac4-86252475d551', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': None, + 'model': None, + 'model_id': None, + 'name': 'AUX A/C on-off', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- # name: test_devices[base_electric_meter] DeviceRegistryEntrySnapshot({ 'area_id': 'theater', diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index d5ee2ffad22..954bcc5c281 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -154,6 +154,58 @@ 'state': 'unknown', }) # --- +# name: test_all_entities[aux_ac][sensor.aux_a_c_on_off_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.aux_a_c_on_off_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': 'Temperature', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'bf53a150-f8a4-45d1-aac4-86252475d551.temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[aux_ac][sensor.aux_a_c_on_off_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'AUX A/C on-off Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.aux_a_c_on_off_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '20.0', + }) +# --- # name: test_all_entities[base_electric_meter][sensor.aeon_energy_monitor_energy-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 1ee4f02e7089480ed11f57679bcace96b753ed1f Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sun, 16 Mar 2025 12:10:40 -0700 Subject: [PATCH 2676/3148] Bump ical to 9.0.1 (#140726) --- homeassistant/components/google/manifest.json | 2 +- homeassistant/components/local_calendar/manifest.json | 2 +- homeassistant/components/local_todo/manifest.json | 2 +- homeassistant/components/remote_calendar/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/google/manifest.json b/homeassistant/components/google/manifest.json index bd04597e513..81fd2b07de4 100644 --- a/homeassistant/components/google/manifest.json +++ b/homeassistant/components/google/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/google", "iot_class": "cloud_polling", "loggers": ["googleapiclient"], - "requirements": ["gcal-sync==7.0.0", "oauth2client==4.1.3", "ical==8.3.0"] + "requirements": ["gcal-sync==7.0.0", "oauth2client==4.1.3", "ical==9.0.1"] } diff --git a/homeassistant/components/local_calendar/manifest.json b/homeassistant/components/local_calendar/manifest.json index 21a4134a8b6..fc6d0bc00c7 100644 --- a/homeassistant/components/local_calendar/manifest.json +++ b/homeassistant/components/local_calendar/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/local_calendar", "iot_class": "local_polling", "loggers": ["ical"], - "requirements": ["ical==8.3.0"] + "requirements": ["ical==9.0.1"] } diff --git a/homeassistant/components/local_todo/manifest.json b/homeassistant/components/local_todo/manifest.json index 68154f10885..27d3ccce4a7 100644 --- a/homeassistant/components/local_todo/manifest.json +++ b/homeassistant/components/local_todo/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/local_todo", "iot_class": "local_polling", - "requirements": ["ical==8.3.0"] + "requirements": ["ical==9.0.1"] } diff --git a/homeassistant/components/remote_calendar/manifest.json b/homeassistant/components/remote_calendar/manifest.json index 260f465f993..fe17a3d2c34 100644 --- a/homeassistant/components/remote_calendar/manifest.json +++ b/homeassistant/components/remote_calendar/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["ical"], "quality_scale": "silver", - "requirements": ["ical==8.3.0"] + "requirements": ["ical==9.0.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index e86de5d2f71..825983be33b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1194,7 +1194,7 @@ ibmiotf==0.3.4 # homeassistant.components.local_calendar # homeassistant.components.local_todo # homeassistant.components.remote_calendar -ical==8.3.0 +ical==9.0.1 # homeassistant.components.caldav icalendar==6.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5ce29dff3ba..c73a3c8e9e9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1011,7 +1011,7 @@ ibeacon-ble==1.2.0 # homeassistant.components.local_calendar # homeassistant.components.local_todo # homeassistant.components.remote_calendar -ical==8.3.0 +ical==9.0.1 # homeassistant.components.caldav icalendar==6.1.0 From 42f0e70cde924b2c9087fc164e33d01d61348e66 Mon Sep 17 00:00:00 2001 From: Markus Adrario Date: Sun, 16 Mar 2025 20:13:36 +0100 Subject: [PATCH 2677/3148] Add Homee binary sensor platform (#140088) * binary-sensor initial * Add binary sensor tests * small string changes * fix review comments * review change 1 --- homeassistant/components/homee/__init__.py | 1 + .../components/homee/binary_sensor.py | 190 +++ homeassistant/components/homee/strings.json | 70 + .../homee/fixtures/binary_sensors.json | 891 +++++++++++ .../homee/snapshots/test_binary_sensor.ambr | 1392 +++++++++++++++++ tests/components/homee/test_binary_sensor.py | 29 + 6 files changed, 2573 insertions(+) create mode 100644 homeassistant/components/homee/binary_sensor.py create mode 100644 tests/components/homee/fixtures/binary_sensors.json create mode 100644 tests/components/homee/snapshots/test_binary_sensor.ambr create mode 100644 tests/components/homee/test_binary_sensor.py diff --git a/homeassistant/components/homee/__init__.py b/homeassistant/components/homee/__init__.py index 92773dae656..6158a699302 100644 --- a/homeassistant/components/homee/__init__.py +++ b/homeassistant/components/homee/__init__.py @@ -15,6 +15,7 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) PLATFORMS = [ + Platform.BINARY_SENSOR, Platform.BUTTON, Platform.COVER, Platform.LIGHT, diff --git a/homeassistant/components/homee/binary_sensor.py b/homeassistant/components/homee/binary_sensor.py new file mode 100644 index 00000000000..3f5f5c46a29 --- /dev/null +++ b/homeassistant/components/homee/binary_sensor.py @@ -0,0 +1,190 @@ +"""The Homee binary sensor platform.""" + +from pyHomee.const import AttributeType +from pyHomee.model import HomeeAttribute + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import HomeeConfigEntry +from .entity import HomeeEntity + +PARALLEL_UPDATES = 0 + +BINARY_SENSOR_DESCRIPTIONS: dict[AttributeType, BinarySensorEntityDescription] = { + AttributeType.BATTERY_LOW_ALARM: BinarySensorEntityDescription( + key="battery", + device_class=BinarySensorDeviceClass.BATTERY, + entity_category=EntityCategory.DIAGNOSTIC, + ), + AttributeType.BLACKOUT_ALARM: BinarySensorEntityDescription( + key="blackout_alarm", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + ), + AttributeType.COALARM: BinarySensorEntityDescription( + key="carbon_monoxide", device_class=BinarySensorDeviceClass.CO + ), + AttributeType.CO2ALARM: BinarySensorEntityDescription( + key="carbon_dioxide", device_class=BinarySensorDeviceClass.PROBLEM + ), + AttributeType.FLOOD_ALARM: BinarySensorEntityDescription( + key="flood", + device_class=BinarySensorDeviceClass.MOISTURE, + ), + AttributeType.HIGH_TEMPERATURE_ALARM: BinarySensorEntityDescription( + key="high_temperature", + device_class=BinarySensorDeviceClass.HEAT, + entity_category=EntityCategory.DIAGNOSTIC, + ), + AttributeType.LEAK_ALARM: BinarySensorEntityDescription( + key="leak_alarm", + device_class=BinarySensorDeviceClass.PROBLEM, + ), + AttributeType.LOAD_ALARM: BinarySensorEntityDescription( + key="load_alarm", + entity_category=EntityCategory.DIAGNOSTIC, + ), + AttributeType.LOCK_STATE: BinarySensorEntityDescription( + key="lock", + device_class=BinarySensorDeviceClass.LOCK, + ), + AttributeType.LOW_TEMPERATURE_ALARM: BinarySensorEntityDescription( + key="low_temperature", + device_class=BinarySensorDeviceClass.COLD, + entity_category=EntityCategory.DIAGNOSTIC, + ), + AttributeType.MALFUNCTION_ALARM: BinarySensorEntityDescription( + key="malfunction", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + ), + AttributeType.MAXIMUM_ALARM: BinarySensorEntityDescription( + key="maximum", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + ), + AttributeType.MINIMUM_ALARM: BinarySensorEntityDescription( + key="minimum", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + ), + AttributeType.MOTION_ALARM: BinarySensorEntityDescription( + key="motion", + device_class=BinarySensorDeviceClass.MOTION, + ), + AttributeType.MOTOR_BLOCKED_ALARM: BinarySensorEntityDescription( + key="motor_blocked", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + ), + AttributeType.ON_OFF: BinarySensorEntityDescription( + key="plug", + device_class=BinarySensorDeviceClass.PLUG, + ), + AttributeType.OPEN_CLOSE: BinarySensorEntityDescription( + key="opening", + device_class=BinarySensorDeviceClass.OPENING, + ), + AttributeType.OVER_CURRENT_ALARM: BinarySensorEntityDescription( + key="overcurrent", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + ), + AttributeType.OVERLOAD_ALARM: BinarySensorEntityDescription( + key="overload", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + ), + AttributeType.PRESENCE_ALARM: BinarySensorEntityDescription( + key="presence", + device_class=BinarySensorDeviceClass.PRESENCE, + ), + AttributeType.POWER_SUPPLY_ALARM: BinarySensorEntityDescription( + key="power", + device_class=BinarySensorDeviceClass.POWER, + entity_category=EntityCategory.DIAGNOSTIC, + ), + AttributeType.RAIN_FALL: BinarySensorEntityDescription( + key="rain", + device_class=BinarySensorDeviceClass.MOISTURE, + ), + AttributeType.REPLACE_FILTER_ALARM: BinarySensorEntityDescription( + key="replace_filter", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + ), + AttributeType.SMOKE_ALARM: BinarySensorEntityDescription( + key="smoke", + device_class=BinarySensorDeviceClass.SMOKE, + ), + AttributeType.STORAGE_ALARM: BinarySensorEntityDescription( + key="storage", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + ), + AttributeType.SURGE_ALARM: BinarySensorEntityDescription( + key="surge", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + ), + AttributeType.TAMPER_ALARM: BinarySensorEntityDescription( + key="tamper", + device_class=BinarySensorDeviceClass.TAMPER, + entity_category=EntityCategory.DIAGNOSTIC, + ), + AttributeType.VOLTAGE_DROP_ALARM: BinarySensorEntityDescription( + key="voltage_drop", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + ), + AttributeType.WATER_ALARM: BinarySensorEntityDescription( + key="water", + device_class=BinarySensorDeviceClass.MOISTURE, + entity_category=EntityCategory.DIAGNOSTIC, + ), +} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: HomeeConfigEntry, + async_add_devices: AddConfigEntryEntitiesCallback, +) -> None: + """Add the Homee platform for the binary sensor component.""" + + async_add_devices( + HomeeBinarySensor( + attribute, config_entry, BINARY_SENSOR_DESCRIPTIONS[attribute.type] + ) + for node in config_entry.runtime_data.nodes + for attribute in node.attributes + if attribute.type in BINARY_SENSOR_DESCRIPTIONS and not attribute.editable + ) + + +class HomeeBinarySensor(HomeeEntity, BinarySensorEntity): + """Representation of a Homee binary sensor.""" + + def __init__( + self, + attribute: HomeeAttribute, + entry: HomeeConfigEntry, + description: BinarySensorEntityDescription, + ) -> None: + """Initialize a Homee binary sensor entity.""" + super().__init__(attribute, entry) + + self.entity_description = description + self._attr_translation_key = description.key + + @property + def is_on(self) -> bool: + """Return true if the binary sensor is on.""" + return bool(self._attribute.current_value) diff --git a/homeassistant/components/homee/strings.json b/homeassistant/components/homee/strings.json index 8b61cc6d28c..050ed13bcad 100644 --- a/homeassistant/components/homee/strings.json +++ b/homeassistant/components/homee/strings.json @@ -26,6 +26,76 @@ } }, "entity": { + "binary_sensor": { + "blackout_alarm": { + "name": "Blackout" + }, + "carbon_dioxide": { + "name": "Carbon dioxide" + }, + "flood": { + "name": "Flood" + }, + "high_temperature": { + "name": "High temperature" + }, + "leak_alarm": { + "name": "Leak" + }, + "load_alarm": { + "name": "Load", + "state": { + "off": "Normal", + "on": "Overload" + } + }, + "low_temperature": { + "name": "Low temperature" + }, + "malfunction": { + "name": "Malfunction" + }, + "maximum": { + "name": "Maximumn level" + }, + "minimum": { + "name": "Minumum level" + }, + "motor_blocked": { + "name": "Motor blocked" + }, + "overcurrent": { + "name": "Overcurrent" + }, + "overload": { + "name": "Overload" + }, + "rain": { + "name": "Rain" + }, + "replace_filter": { + "name": "Replace filter", + "state": { + "on": "Replace" + } + }, + "storage": { + "name": "Storage", + "state": { + "off": "Space available", + "on": "Storage full" + } + }, + "surge": { + "name": "Surge" + }, + "voltage_drop": { + "name": "Voltage drop" + }, + "water": { + "name": "Water" + } + }, "button": { "automatic_mode": { "name": "Automatic mode" diff --git a/tests/components/homee/fixtures/binary_sensors.json b/tests/components/homee/fixtures/binary_sensors.json new file mode 100644 index 00000000000..5ced5dc51da --- /dev/null +++ b/tests/components/homee/fixtures/binary_sensors.json @@ -0,0 +1,891 @@ +{ + "id": 1, + "name": "Test Binary Sensor", + "profile": 4026, + "image": "default", + "favorite": 0, + "order": 20, + "protocol": 1, + "routing": 0, + "state": 1, + "state_changed": 1709379826, + "added": 1676199446, + "history": 1, + "cube_type": 1, + "note": "", + "services": 5, + "phonetic_name": "", + "owner": 2, + "security": 0, + "attributes": [ + { + "id": 1, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 1, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 0.0, + "unit": "", + "step_value": 1.0, + "editable": 0, + "type": 69, + "state": 1, + "last_changed": 1706461181, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["reset"], + "history": { + "day": 182, + "week": 26, + "month": 6, + "stepped": true + } + } + }, + { + "id": 2, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 1, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 0.0, + "unit": "", + "step_value": 1.0, + "editable": 0, + "type": 17, + "state": 1, + "last_changed": 1691668428, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["reset"], + "history": { + "day": 182, + "week": 26, + "month": 6, + "stepped": true + } + } + }, + { + "id": 3, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 1, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 0.0, + "unit": "", + "step_value": 1.0, + "editable": 0, + "type": 132, + "state": 1, + "last_changed": 1691668428, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["reset"], + "history": { + "day": 182, + "week": 26, + "month": 6, + "stepped": true + } + } + }, + { + "id": 4, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 1, + "current_value": 1.0, + "target_value": 1.0, + "last_value": 0.0, + "unit": "", + "step_value": 1.0, + "editable": 0, + "type": 228, + "state": 1, + "last_changed": 1691668428, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["reset"], + "history": { + "day": 182, + "week": 26, + "month": 6, + "stepped": true + } + } + }, + { + "id": 5, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 1, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 1.0, + "unit": "", + "step_value": 1.0, + "editable": 0, + "type": 12, + "state": 1, + "last_changed": 1699456267, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["reset"], + "history": { + "day": 182, + "week": 26, + "month": 6, + "stepped": true + } + } + }, + { + "id": 6, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 1, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 0.0, + "unit": "", + "step_value": 1.0, + "editable": 0, + "type": 52, + "state": 1, + "last_changed": 1694176210, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["reset"], + "history": { + "day": 182, + "week": 26, + "month": 6, + "stepped": true + } + } + }, + { + "id": 7, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 1, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 0.0, + "unit": "", + "step_value": 1.0, + "editable": 0, + "type": 68, + "state": 1, + "last_changed": 1694176210, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["reset"], + "history": { + "day": 182, + "week": 26, + "month": 6, + "stepped": true + } + } + }, + { + "id": 8, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 1, + "current_value": 1.0, + "target_value": 1.0, + "last_value": 0.0, + "unit": "", + "step_value": 1.0, + "editable": 0, + "type": 139, + "state": 1, + "last_changed": 1650402359, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["reset"], + "history": { + "day": 182, + "week": 26, + "month": 6, + "stepped": true + } + } + }, + { + "id": 9, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 1, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 1.0, + "unit": "", + "step_value": 1.0, + "editable": 0, + "type": 232, + "state": 1, + "last_changed": 1711897362, + "changed_by": 4, + "changed_by_id": 5, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["reset"], + "history": { + "day": 35, + "week": 5, + "month": 1, + "stepped": true + } + } + }, + { + "id": 10, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 1, + "current_value": 1.0, + "target_value": 1.0, + "last_value": 0.0, + "unit": "", + "step_value": 1.0, + "editable": 0, + "type": 54, + "state": 1, + "last_changed": 1650402359, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["reset"], + "history": { + "day": 182, + "week": 26, + "month": 6, + "stepped": true + } + } + }, + { + "id": 11, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 1, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 0.0, + "unit": "", + "step_value": 1.0, + "editable": 0, + "type": 70, + "state": 1, + "last_changed": 1738231378, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 4, + "data": "", + "name": "", + "options": { + "automations": ["reset"], + "history": { + "day": 182, + "week": 26, + "month": 6, + "stepped": true + } + } + }, + { + "id": 12, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 1, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 0.0, + "unit": "", + "step_value": 1.0, + "editable": 0, + "type": 78, + "state": 1, + "last_changed": 1738231378, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 4, + "data": "", + "name": "", + "options": { + "automations": ["reset"], + "history": { + "day": 182, + "week": 26, + "month": 6, + "stepped": true + } + } + }, + { + "id": 13, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 1, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 0.0, + "unit": "", + "step_value": 1.0, + "editable": 0, + "type": 77, + "state": 1, + "last_changed": 1735964135, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["reset"], + "history": { + "day": 182, + "week": 26, + "month": 6, + "stepped": true + } + } + }, + { + "id": 14, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 1, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 1.0, + "unit": "", + "step_value": 1.0, + "editable": 0, + "type": 25, + "state": 1, + "last_changed": 1709933563, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["reset"], + "history": { + "day": 35, + "week": 5, + "month": 1, + "stepped": true + } + } + }, + { + "id": 15, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 1, + "current_value": 1.0, + "target_value": 1.0, + "last_value": 0.0, + "unit": "", + "step_value": 1.0, + "editable": 0, + "type": 330, + "state": 1, + "last_changed": 1709933563, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["reset"], + "history": { + "day": 35, + "week": 5, + "month": 1, + "stepped": true + } + } + }, + { + "id": 16, + "node_id": 1, + "instance": 2, + "minimum": 0, + "maximum": 1, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 0.0, + "unit": "", + "step_value": 1.0, + "editable": 0, + "type": 1, + "state": 1, + "last_changed": 1694024544, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "can_observe": [300], + "automations": ["reset"], + "history": { + "day": 35, + "week": 5, + "month": 1, + "stepped": true + } + } + }, + { + "id": 17, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 1, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 0.0, + "unit": "", + "step_value": 1.0, + "editable": 0, + "type": 14, + "state": 1, + "last_changed": 1739320320, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "history": { + "day": 35, + "week": 5, + "month": 1, + "stepped": true + } + } + }, + { + "id": 18, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 1, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 0.0, + "unit": "", + "step_value": 1.0, + "editable": 0, + "type": 143, + "state": 1, + "last_changed": 1694992768, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["reset"], + "history": { + "day": 182, + "week": 26, + "month": 6, + "stepped": true + } + } + }, + { + "id": 19, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 1, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 0.0, + "unit": "", + "step_value": 1.0, + "editable": 0, + "type": 140, + "state": 1, + "last_changed": 1718900928, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["reset"], + "history": { + "day": 182, + "week": 26, + "month": 6, + "stepped": true + } + } + }, + { + "id": 20, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 1, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 0.0, + "unit": "", + "step_value": 1.0, + "editable": 0, + "type": 76, + "state": 1, + "last_changed": 1718900928, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["reset"], + "history": { + "day": 182, + "week": 26, + "month": 6, + "stepped": true + } + } + }, + { + "id": 21, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 1, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 0.0, + "unit": "", + "step_value": 1.0, + "editable": 0, + "type": 182, + "state": 1, + "last_changed": 1718900928, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["reset"], + "history": { + "day": 182, + "week": 26, + "month": 6, + "stepped": true + } + } + }, + { + "id": 22, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 1, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 0.0, + "unit": "", + "step_value": 1.0, + "editable": 0, + "type": 101, + "state": 1, + "last_changed": 1700056646, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "history": { + "day": 35, + "week": 5, + "month": 1, + "stepped": true + } + } + }, + { + "id": 23, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 1, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 0.0, + "unit": "n%2Fa", + "step_value": 1.0, + "editable": 0, + "type": 289, + "state": 1, + "last_changed": 1736106312, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["reset"], + "history": { + "day": 182, + "week": 26, + "month": 6, + "stepped": true + } + } + }, + { + "id": 24, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 2, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 1.0, + "unit": "", + "step_value": 1.0, + "editable": 0, + "type": 16, + "state": 1, + "last_changed": 1616314530, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["reset"], + "history": { + "day": 182, + "week": 26, + "month": 6, + "stepped": true + } + } + }, + { + "id": 25, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 2, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 1.0, + "unit": "", + "step_value": 1.0, + "editable": 0, + "type": 181, + "state": 1, + "last_changed": 1616314530, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["reset"], + "history": { + "day": 182, + "week": 26, + "month": 6, + "stepped": true + } + } + }, + { + "id": 26, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 1, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 0.0, + "unit": "", + "step_value": 1.0, + "editable": 0, + "type": 138, + "state": 1, + "last_changed": 1700747644, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["reset"], + "history": { + "day": 182, + "week": 26, + "month": 6, + "stepped": true + } + } + }, + { + "id": 27, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 1, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 0.0, + "unit": "", + "step_value": 1.0, + "editable": 0, + "type": 30, + "state": 1, + "last_changed": 1709933563, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["reset"], + "history": { + "day": 182, + "week": 26, + "month": 6, + "stepped": true + } + } + }, + { + "id": 28, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 1, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 0.0, + "unit": "", + "step_value": 1.0, + "editable": 0, + "type": 141, + "state": 1, + "last_changed": 1700747644, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["reset"], + "history": { + "day": 182, + "week": 26, + "month": 6, + "stepped": true + } + } + }, + { + "id": 29, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 1, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 0.0, + "unit": "", + "step_value": 1.0, + "editable": 0, + "type": 80, + "state": 1, + "last_changed": 1700747644, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["reset"], + "history": { + "day": 182, + "week": 26, + "month": 6, + "stepped": true + } + } + } + ] +} diff --git a/tests/components/homee/snapshots/test_binary_sensor.ambr b/tests/components/homee/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000..01f1f1e42ba --- /dev/null +++ b/tests/components/homee/snapshots/test_binary_sensor.ambr @@ -0,0 +1,1392 @@ +# serializer version: 1 +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_binary_sensor_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': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'battery', + 'unique_id': '00055511EECC-1-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Test Binary Sensor Battery', + }), + 'context': , + 'entity_id': 'binary_sensor.test_binary_sensor_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_blackout-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_binary_sensor_blackout', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Blackout', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'blackout_alarm', + 'unique_id': '00055511EECC-1-2', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_blackout-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Test Binary Sensor Blackout', + }), + 'context': , + 'entity_id': 'binary_sensor.test_binary_sensor_blackout', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_carbon_dioxide-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_binary_sensor_carbon_dioxide', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Carbon dioxide', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'carbon_dioxide', + 'unique_id': '00055511EECC-1-4', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_carbon_dioxide-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Test Binary Sensor Carbon dioxide', + }), + 'context': , + 'entity_id': 'binary_sensor.test_binary_sensor_carbon_dioxide', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_carbon_monoxide-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_binary_sensor_carbon_monoxide', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Carbon monoxide', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'carbon_monoxide', + 'unique_id': '00055511EECC-1-3', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_carbon_monoxide-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'carbon_monoxide', + 'friendly_name': 'Test Binary Sensor Carbon monoxide', + }), + 'context': , + 'entity_id': 'binary_sensor.test_binary_sensor_carbon_monoxide', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_flood-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_binary_sensor_flood', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Flood', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'flood', + 'unique_id': '00055511EECC-1-5', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_flood-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'moisture', + 'friendly_name': 'Test Binary Sensor Flood', + }), + 'context': , + 'entity_id': 'binary_sensor.test_binary_sensor_flood', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_high_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_binary_sensor_high_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': 'High temperature', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'high_temperature', + 'unique_id': '00055511EECC-1-6', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_high_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'heat', + 'friendly_name': 'Test Binary Sensor High temperature', + }), + 'context': , + 'entity_id': 'binary_sensor.test_binary_sensor_high_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_leak-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_binary_sensor_leak', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Leak', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'leak_alarm', + 'unique_id': '00055511EECC-1-7', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_leak-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Test Binary Sensor Leak', + }), + 'context': , + 'entity_id': 'binary_sensor.test_binary_sensor_leak', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_load-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_binary_sensor_load', + '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': 'Load', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'load_alarm', + 'unique_id': '00055511EECC-1-8', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_load-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Binary Sensor Load', + }), + 'context': , + 'entity_id': 'binary_sensor.test_binary_sensor_load', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_lock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_binary_sensor_lock', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Lock', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'lock', + 'unique_id': '00055511EECC-1-9', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'lock', + 'friendly_name': 'Test Binary Sensor Lock', + }), + 'context': , + 'entity_id': 'binary_sensor.test_binary_sensor_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_low_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_binary_sensor_low_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': 'Low temperature', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'low_temperature', + 'unique_id': '00055511EECC-1-10', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_low_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'cold', + 'friendly_name': 'Test Binary Sensor Low temperature', + }), + 'context': , + 'entity_id': 'binary_sensor.test_binary_sensor_low_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_malfunction-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_binary_sensor_malfunction', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Malfunction', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'malfunction', + 'unique_id': '00055511EECC-1-11', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_malfunction-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Test Binary Sensor Malfunction', + }), + 'context': , + 'entity_id': 'binary_sensor.test_binary_sensor_malfunction', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_maximumn_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_binary_sensor_maximumn_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Maximumn level', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'maximum', + 'unique_id': '00055511EECC-1-12', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_maximumn_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Test Binary Sensor Maximumn level', + }), + 'context': , + 'entity_id': 'binary_sensor.test_binary_sensor_maximumn_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_minumum_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_binary_sensor_minumum_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Minumum level', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'minimum', + 'unique_id': '00055511EECC-1-13', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_minumum_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Test Binary Sensor Minumum level', + }), + 'context': , + 'entity_id': 'binary_sensor.test_binary_sensor_minumum_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_motion-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_binary_sensor_motion', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Motion', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'motion', + 'unique_id': '00055511EECC-1-14', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_motion-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'motion', + 'friendly_name': 'Test Binary Sensor Motion', + }), + 'context': , + 'entity_id': 'binary_sensor.test_binary_sensor_motion', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_motor_blocked-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_binary_sensor_motor_blocked', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Motor blocked', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'motor_blocked', + 'unique_id': '00055511EECC-1-15', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_motor_blocked-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Test Binary Sensor Motor blocked', + }), + 'context': , + 'entity_id': 'binary_sensor.test_binary_sensor_motor_blocked', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_opening-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_binary_sensor_opening', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Opening', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'opening', + 'unique_id': '00055511EECC-1-17', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_opening-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'opening', + 'friendly_name': 'Test Binary Sensor Opening', + }), + 'context': , + 'entity_id': 'binary_sensor.test_binary_sensor_opening', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_overcurrent-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_binary_sensor_overcurrent', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Overcurrent', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'overcurrent', + 'unique_id': '00055511EECC-1-18', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_overcurrent-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Test Binary Sensor Overcurrent', + }), + 'context': , + 'entity_id': 'binary_sensor.test_binary_sensor_overcurrent', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_overload-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_binary_sensor_overload', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Overload', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'overload', + 'unique_id': '00055511EECC-1-19', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_overload-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Test Binary Sensor Overload', + }), + 'context': , + 'entity_id': 'binary_sensor.test_binary_sensor_overload', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_plug-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_binary_sensor_plug', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Plug', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'plug', + 'unique_id': '00055511EECC-1-16', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_plug-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'plug', + 'friendly_name': 'Test Binary Sensor Plug', + }), + 'context': , + 'entity_id': 'binary_sensor.test_binary_sensor_plug', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_binary_sensor_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power', + 'unique_id': '00055511EECC-1-21', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Test Binary Sensor Power', + }), + 'context': , + 'entity_id': 'binary_sensor.test_binary_sensor_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_presence-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_binary_sensor_presence', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Presence', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'presence', + 'unique_id': '00055511EECC-1-20', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_presence-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'presence', + 'friendly_name': 'Test Binary Sensor Presence', + }), + 'context': , + 'entity_id': 'binary_sensor.test_binary_sensor_presence', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_rain-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_binary_sensor_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': 'Rain', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'rain', + 'unique_id': '00055511EECC-1-22', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_rain-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'moisture', + 'friendly_name': 'Test Binary Sensor Rain', + }), + 'context': , + 'entity_id': 'binary_sensor.test_binary_sensor_rain', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_replace_filter-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_binary_sensor_replace_filter', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Replace filter', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'replace_filter', + 'unique_id': '00055511EECC-1-23', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_replace_filter-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Test Binary Sensor Replace filter', + }), + 'context': , + 'entity_id': 'binary_sensor.test_binary_sensor_replace_filter', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_smoke-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_binary_sensor_smoke', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Smoke', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'smoke', + 'unique_id': '00055511EECC-1-24', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_smoke-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'smoke', + 'friendly_name': 'Test Binary Sensor Smoke', + }), + 'context': , + 'entity_id': 'binary_sensor.test_binary_sensor_smoke', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_storage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_binary_sensor_storage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Storage', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'storage', + 'unique_id': '00055511EECC-1-25', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_storage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Test Binary Sensor Storage', + }), + 'context': , + 'entity_id': 'binary_sensor.test_binary_sensor_storage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_surge-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_binary_sensor_surge', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Surge', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'surge', + 'unique_id': '00055511EECC-1-26', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_surge-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Test Binary Sensor Surge', + }), + 'context': , + 'entity_id': 'binary_sensor.test_binary_sensor_surge', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_tamper-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_binary_sensor_tamper', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tamper', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'tamper', + 'unique_id': '00055511EECC-1-27', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_tamper-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'tamper', + 'friendly_name': 'Test Binary Sensor Tamper', + }), + 'context': , + 'entity_id': 'binary_sensor.test_binary_sensor_tamper', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_voltage_drop-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_binary_sensor_voltage_drop', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage drop', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_drop', + 'unique_id': '00055511EECC-1-28', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_voltage_drop-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Test Binary Sensor Voltage drop', + }), + 'context': , + 'entity_id': 'binary_sensor.test_binary_sensor_voltage_drop', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_water-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_binary_sensor_water', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Water', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'water', + 'unique_id': '00055511EECC-1-29', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_water-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'moisture', + 'friendly_name': 'Test Binary Sensor Water', + }), + 'context': , + 'entity_id': 'binary_sensor.test_binary_sensor_water', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/homee/test_binary_sensor.py b/tests/components/homee/test_binary_sensor.py new file mode 100644 index 00000000000..50662616379 --- /dev/null +++ b/tests/components/homee/test_binary_sensor.py @@ -0,0 +1,29 @@ +"""Test homee binary sensors.""" + +from unittest.mock import MagicMock, patch + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import build_mock_node, setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +async def test_sensor_snapshot( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test the multisensor snapshot.""" + mock_homee.nodes = [build_mock_node("binary_sensors.json")] + mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] + with patch("homeassistant.components.homee.PLATFORMS", [Platform.BINARY_SENSOR]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) From 784381a25f1ce0ec23665100f9c560583277e5fe Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 16 Mar 2025 20:45:46 +0100 Subject: [PATCH 2678/3148] Deprecate SmartThings cover battery state attribute (#140752) --- homeassistant/components/smartthings/cover.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/smartthings/cover.py b/homeassistant/components/smartthings/cover.py index 0b0817d7c56..84bf0412ab4 100644 --- a/homeassistant/components/smartthings/cover.py +++ b/homeassistant/components/smartthings/cover.py @@ -130,6 +130,7 @@ class SmartThingsCover(SmartThingsEntity, CoverEntity): Capability.WINDOW_SHADE_LEVEL, Attribute.SHADE_LEVEL ) + # Deprecated, remove in 2025.10 self._attr_extra_state_attributes = {} if self.supports_capability(Capability.BATTERY): self._attr_extra_state_attributes[ATTR_BATTERY_LEVEL] = ( From b0db7b432e2c9590a51004b881608fc7d9dfe386 Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Sun, 16 Mar 2025 15:55:00 -0400 Subject: [PATCH 2679/3148] Move Roborock MapParser to coordinator (#140750) Move MapParser to coordinator --- .../components/roborock/coordinator.py | 34 ++++++++++++++ homeassistant/components/roborock/image.py | 46 +------------------ homeassistant/components/roborock/vacuum.py | 5 +- tests/components/roborock/conftest.py | 2 +- tests/components/roborock/test_image.py | 10 ++-- tests/components/roborock/test_vacuum.py | 4 +- 6 files changed, 48 insertions(+), 53 deletions(-) diff --git a/homeassistant/components/roborock/coordinator.py b/homeassistant/components/roborock/coordinator.py index bf06387b377..2f156545929 100644 --- a/homeassistant/components/roborock/coordinator.py +++ b/homeassistant/components/roborock/coordinator.py @@ -5,6 +5,7 @@ from __future__ import annotations import asyncio from dataclasses import dataclass from datetime import timedelta +import io import logging from propcache.api import cached_property @@ -25,6 +26,10 @@ from roborock.version_1_apis.roborock_local_client_v1 import RoborockLocalClient from roborock.version_1_apis.roborock_mqtt_client_v1 import RoborockMqttClientV1 from roborock.version_a01_apis import RoborockClientA01 from roborock.web_api import RoborockApiClient +from vacuum_map_parser_base.config.color import ColorsPalette +from vacuum_map_parser_base.config.image_config import ImageConfig +from vacuum_map_parser_base.config.size import Sizes +from vacuum_map_parser_roborock.map_data_parser import RoborockMapDataParser from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_CONNECTIONS @@ -38,7 +43,11 @@ from homeassistant.util import slugify from .const import ( A01_UPDATE_INTERVAL, + DEFAULT_DRAWABLES, DOMAIN, + DRAWABLES, + MAP_FILE_FORMAT, + MAP_SCALE, V1_CLOUD_IN_CLEANING_INTERVAL, V1_CLOUD_NOT_CLEANING_INTERVAL, V1_LOCAL_IN_CLEANING_INTERVAL, @@ -127,6 +136,18 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): self._user_data = user_data self._api_client = api_client self._is_cloud_api = False + drawables = [ + drawable + for drawable, default_value in DEFAULT_DRAWABLES.items() + if config_entry.options.get(DRAWABLES, {}).get(drawable, default_value) + ] + self.map_parser = RoborockMapDataParser( + ColorsPalette(), + Sizes({k: v * MAP_SCALE for k, v in Sizes.SIZES.items()}), + drawables, + ImageConfig(scale=MAP_SCALE), + [], + ) @cached_property def dock_device_info(self) -> DeviceInfo: @@ -145,6 +166,19 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): sw_version=self.roborock_device_info.device.fv, ) + def parse_image(self, map_bytes: bytes) -> bytes | None: + """Parse map_bytes and store it as image bytes.""" + try: + parsed_map = self.map_parser.parse(map_bytes) + except (IndexError, ValueError) as err: + _LOGGER.debug("Exception when parsing map contents: %s", err) + return None + if parsed_map.image is None: + return None + img_byte_arr = io.BytesIO() + parsed_map.image.data.save(img_byte_arr, format=MAP_FILE_FORMAT) + return img_byte_arr.getvalue() + async def _async_setup(self) -> None: """Set up the coordinator.""" # Verify we can communicate locally - if we can't, switch to cloud api diff --git a/homeassistant/components/roborock/image.py b/homeassistant/components/roborock/image.py index 2fb5d644826..b56abaeebdb 100644 --- a/homeassistant/components/roborock/image.py +++ b/homeassistant/components/roborock/image.py @@ -1,16 +1,10 @@ """Support for Roborock image.""" import asyncio -from collections.abc import Callable from datetime import datetime -import io import logging from roborock import RoborockCommand -from vacuum_map_parser_base.config.color import ColorsPalette -from vacuum_map_parser_base.config.image_config import ImageConfig -from vacuum_map_parser_base.config.size import Sizes -from vacuum_map_parser_roborock.map_data_parser import RoborockMapDataParser from homeassistant.components.image import ImageEntity from homeassistant.config_entries import ConfigEntry @@ -20,15 +14,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util -from .const import ( - DEFAULT_DRAWABLES, - DOMAIN, - DRAWABLES, - IMAGE_CACHE_INTERVAL, - MAP_FILE_FORMAT, - MAP_SCALE, - MAP_SLEEP, -) +from .const import DOMAIN, IMAGE_CACHE_INTERVAL, MAP_SLEEP from .coordinator import RoborockConfigEntry, RoborockDataUpdateCoordinator from .entity import RoborockCoordinatedEntityV1 @@ -42,31 +28,6 @@ async def async_setup_entry( ) -> None: """Set up Roborock image platform.""" - drawables = [ - drawable - for drawable, default_value in DEFAULT_DRAWABLES.items() - if config_entry.options.get(DRAWABLES, {}).get(drawable, default_value) - ] - parser = RoborockMapDataParser( - ColorsPalette(), - Sizes({k: v * MAP_SCALE for k, v in Sizes.SIZES.items()}), - drawables, - ImageConfig(scale=MAP_SCALE), - [], - ) - - def parse_image(map_bytes: bytes) -> bytes | None: - try: - parsed_map = parser.parse(map_bytes) - except (IndexError, ValueError) as err: - _LOGGER.debug("Exception when parsing map contents: %s", err) - return None - if parsed_map.image is None: - return None - img_byte_arr = io.BytesIO() - parsed_map.image.data.save(img_byte_arr, format=MAP_FILE_FORMAT) - return img_byte_arr.getvalue() - await asyncio.gather( *(refresh_coordinators(hass, coord) for coord in config_entry.runtime_data.v1) ) @@ -78,7 +39,6 @@ async def async_setup_entry( coord, map_info.flag, map_info.name, - parse_image, ) for coord in config_entry.runtime_data.v1 for map_info in coord.maps.values() @@ -100,14 +60,12 @@ class RoborockMap(RoborockCoordinatedEntityV1, ImageEntity): coordinator: RoborockDataUpdateCoordinator, map_flag: int, map_name: str, - parser: Callable[[bytes], bytes | None], ) -> None: """Initialize a Roborock map.""" RoborockCoordinatedEntityV1.__init__(self, unique_id, coordinator) ImageEntity.__init__(self, coordinator.hass) self.config_entry = config_entry self._attr_name = map_name - self.parser = parser self.map_flag = map_flag self.cached_map = b"" self._attr_entity_category = EntityCategory.DIAGNOSTIC @@ -154,7 +112,7 @@ class RoborockMap(RoborockCoordinatedEntityV1, ImageEntity): ) if ( not isinstance(response[0], bytes) - or (content := self.parser(response[0])) is None + or (content := self.coordinator.parse_image(response[0])) is None ): _LOGGER.debug("Failed to parse map contents: %s", response[0]) raise HomeAssistantError( diff --git a/homeassistant/components/roborock/vacuum.py b/homeassistant/components/roborock/vacuum.py index 59abc888673..db201ff06d2 100644 --- a/homeassistant/components/roborock/vacuum.py +++ b/homeassistant/components/roborock/vacuum.py @@ -6,6 +6,10 @@ from typing import Any from roborock.code_mappings import RoborockStateCode from roborock.roborock_message import RoborockDataProtocol from roborock.roborock_typing import RoborockCommand +from vacuum_map_parser_base.config.color import ColorsPalette +from vacuum_map_parser_base.config.image_config import ImageConfig +from vacuum_map_parser_base.config.size import Sizes +from vacuum_map_parser_roborock.map_data_parser import RoborockMapDataParser import voluptuous as vol from homeassistant.components.vacuum import ( @@ -26,7 +30,6 @@ from .const import ( ) from .coordinator import RoborockConfigEntry, RoborockDataUpdateCoordinator from .entity import RoborockCoordinatedEntityV1 -from .image import ColorsPalette, ImageConfig, RoborockMapDataParser, Sizes STATE_CODE_TO_STATE = { RoborockStateCode.starting: VacuumActivity.IDLE, # "Starting" diff --git a/tests/components/roborock/conftest.py b/tests/components/roborock/conftest.py index 9b3a6633c62..cafac280620 100644 --- a/tests/components/roborock/conftest.py +++ b/tests/components/roborock/conftest.py @@ -110,7 +110,7 @@ def bypass_api_fixture(bypass_api_client_fixture: Any) -> None: return_value=MULTI_MAP_LIST, ), patch( - "homeassistant.components.roborock.image.RoborockMapDataParser.parse", + "homeassistant.components.roborock.coordinator.RoborockMapDataParser.parse", return_value=MAP_DATA, ), patch( diff --git a/tests/components/roborock/test_image.py b/tests/components/roborock/test_image.py index 7d79cf4f6ab..d81f1289fe3 100644 --- a/tests/components/roborock/test_image.py +++ b/tests/components/roborock/test_image.py @@ -65,7 +65,7 @@ async def test_floorplan_image( "homeassistant.components.roborock.image.dt_util.utcnow", return_value=now ), patch( - "homeassistant.components.roborock.image.RoborockMapDataParser.parse", + "homeassistant.components.roborock.coordinator.RoborockMapDataParser.parse", return_value=new_map_data, ) as parse_map, ): @@ -94,7 +94,7 @@ async def test_floorplan_image_failed_parse( # Update image, but get none for parse image. with ( patch( - "homeassistant.components.roborock.image.RoborockMapDataParser.parse", + "homeassistant.components.roborock.coordinator.RoborockMapDataParser.parse", return_value=map_data, ), patch( @@ -148,7 +148,7 @@ async def test_fail_to_load_image( """Test that we gracefully handle failing to load an image.""" with ( patch( - "homeassistant.components.roborock.image.RoborockMapDataParser.parse", + "homeassistant.components.roborock.coordinator.RoborockMapDataParser.parse", ) as parse_map, patch( "homeassistant.components.roborock.roborock_storage.Path.exists", @@ -178,7 +178,7 @@ async def test_fail_parse_on_startup( map_data = copy.deepcopy(MAP_DATA) map_data.image = None with patch( - "homeassistant.components.roborock.image.RoborockMapDataParser.parse", + "homeassistant.components.roborock.coordinator.RoborockMapDataParser.parse", return_value=map_data, ): await async_setup_component(hass, DOMAIN, {}) @@ -226,7 +226,7 @@ async def test_fail_updating_image( # Update image, but get none for parse image. with ( patch( - "homeassistant.components.roborock.image.RoborockMapDataParser.parse", + "homeassistant.components.roborock.coordinator.RoborockMapDataParser.parse", return_value=map_data, ), patch( diff --git a/tests/components/roborock/test_vacuum.py b/tests/components/roborock/test_vacuum.py index 15fdeb4767c..2a2d9f210ed 100644 --- a/tests/components/roborock/test_vacuum.py +++ b/tests/components/roborock/test_vacuum.py @@ -261,7 +261,7 @@ async def test_get_current_position( return_value=b"", ), patch( - "homeassistant.components.roborock.image.RoborockMapDataParser.parse", + "homeassistant.components.roborock.coordinator.RoborockMapDataParser.parse", return_value=map_data, ), ): @@ -316,7 +316,7 @@ async def test_get_current_position_no_robot_position( return_value=b"", ), patch( - "homeassistant.components.roborock.image.RoborockMapDataParser.parse", + "homeassistant.components.roborock.coordinator.RoborockMapDataParser.parse", return_value=map_data, ), pytest.raises(HomeAssistantError, match="Robot position not found"), From 5351fe3f9bd6620b69b6c206f0521a4ce639b298 Mon Sep 17 00:00:00 2001 From: mbraem Date: Sun, 16 Mar 2025 21:06:49 +0100 Subject: [PATCH 2680/3148] Add specific sensor device_class, state_class and unit_of_measurement (#137038) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Support additional units in the coil unit descriptions: min, s, Pa, kPa, bar, l/m, m³/h and %RH. Co-authored-by: Joost Lekkerkerker --- .../components/nibe_heatpump/sensor.py | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/homeassistant/components/nibe_heatpump/sensor.py b/homeassistant/components/nibe_heatpump/sensor.py index ac4f9eba308..54cd0f7ea34 100644 --- a/homeassistant/components/nibe_heatpump/sensor.py +++ b/homeassistant/components/nibe_heatpump/sensor.py @@ -13,14 +13,17 @@ from homeassistant.components.sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( + PERCENTAGE, EntityCategory, UnitOfElectricCurrent, UnitOfElectricPotential, UnitOfEnergy, UnitOfFrequency, UnitOfPower, + UnitOfPressure, UnitOfTemperature, UnitOfTime, + UnitOfVolumeFlowRate, ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -114,6 +117,20 @@ UNIT_DESCRIPTIONS = { state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement=UnitOfTime.HOURS, ), + "min": SensorEntityDescription( + key="min", + entity_category=EntityCategory.DIAGNOSTIC, + device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.TOTAL_INCREASING, + native_unit_of_measurement=UnitOfTime.MINUTES, + ), + "s": SensorEntityDescription( + key="s", + entity_category=EntityCategory.DIAGNOSTIC, + device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.TOTAL_INCREASING, + native_unit_of_measurement=UnitOfTime.SECONDS, + ), "Hz": SensorEntityDescription( key="Hz", entity_category=EntityCategory.DIAGNOSTIC, @@ -121,6 +138,48 @@ UNIT_DESCRIPTIONS = { state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfFrequency.HERTZ, ), + "Pa": SensorEntityDescription( + key="Pa", + entity_category=EntityCategory.DIAGNOSTIC, + device_class=SensorDeviceClass.PRESSURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPressure.PA, + ), + "kPa": SensorEntityDescription( + key="kPa", + entity_category=EntityCategory.DIAGNOSTIC, + device_class=SensorDeviceClass.PRESSURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPressure.KPA, + ), + "bar": SensorEntityDescription( + key="bar", + entity_category=EntityCategory.DIAGNOSTIC, + device_class=SensorDeviceClass.PRESSURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPressure.BAR, + ), + "l/m": SensorEntityDescription( + key="l/m", + entity_category=EntityCategory.DIAGNOSTIC, + device_class=SensorDeviceClass.VOLUME_FLOW_RATE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfVolumeFlowRate.LITERS_PER_MINUTE, + ), + "m³/h": SensorEntityDescription( + key="m³/h", + entity_category=EntityCategory.DIAGNOSTIC, + device_class=SensorDeviceClass.VOLUME_FLOW_RATE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, + ), + "%RH": SensorEntityDescription( + key="%RH", + entity_category=EntityCategory.DIAGNOSTIC, + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + ), } From bbe2a95b3d1f871810fe92b856afcaac5b2af231 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 16 Mar 2025 21:29:03 +0100 Subject: [PATCH 2681/3148] Deprecate Valve binary sensor in SmartThings (#140751) Deprecate Valve binary sensor --- .../components/smartthings/binary_sensor.py | 62 ++++++++++++++++- .../components/smartthings/strings.json | 6 ++ .../smartthings/test_binary_sensor.py | 69 ++++++++++++++++++- 3 files changed, 135 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/smartthings/binary_sensor.py b/homeassistant/components/smartthings/binary_sensor.py index 080a90440be..25b9cbefb6f 100644 --- a/homeassistant/components/smartthings/binary_sensor.py +++ b/homeassistant/components/smartthings/binary_sensor.py @@ -6,17 +6,25 @@ from dataclasses import dataclass from pysmartthings import Attribute, Capability, SmartThings +from homeassistant.components.automation import automations_with_entity from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, BinarySensorEntityDescription, ) +from homeassistant.components.script import scripts_with_entity from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.issue_registry import ( + IssueSeverity, + async_create_issue, + async_delete_issue, +) from . import FullDevice, SmartThingsConfigEntry -from .const import MAIN +from .const import DOMAIN, MAIN from .entity import SmartThingsEntity @@ -151,3 +159,55 @@ class SmartThingsBinarySensor(SmartThingsEntity, BinarySensorEntity): self.get_attribute_value(self.capability, self._attribute) == self.entity_description.is_on_key ) + + async def async_added_to_hass(self) -> None: + """Call when entity is added to hass.""" + await super().async_added_to_hass() + if self.capability is not Capability.VALVE: + return + automations = automations_with_entity(self.hass, self.entity_id) + scripts = scripts_with_entity(self.hass, self.entity_id) + items = automations + scripts + if not items: + return + + entity_reg: er.EntityRegistry = er.async_get(self.hass) + entity_automations = [ + automation_entity + for automation_id in automations + if (automation_entity := entity_reg.async_get(automation_id)) + ] + entity_scripts = [ + script_entity + for script_id in scripts + if (script_entity := entity_reg.async_get(script_id)) + ] + + items_list = [ + f"- [{item.original_name}](/config/automation/edit/{item.unique_id})" + for item in entity_automations + ] + [ + f"- [{item.original_name}](/config/script/edit/{item.unique_id})" + for item in entity_scripts + ] + + async_create_issue( + self.hass, + DOMAIN, + f"deprecated_binary_valve_{self.entity_id}", + breaks_in_ha_version="2025.10.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_binary_valve", + translation_placeholders={ + "entity": self.entity_id, + "items": "\n".join(items_list), + }, + ) + + async def async_will_remove_from_hass(self) -> None: + """Call when entity will be removed from hass.""" + await super().async_will_remove_from_hass() + async_delete_issue( + self.hass, DOMAIN, f"deprecated_binary_valve_{self.entity_id}" + ) diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index 844ebd12004..99e1550caba 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -390,5 +390,11 @@ } } } + }, + "issues": { + "deprecated_binary_valve": { + "title": "Deprecated valve binary sensor detected in some automations or scripts", + "description": "The valve binary sensor `{entity}` is deprecated and is used in the following automations or scripts:\n{items}\n\nA valve entity with controls is available and should be used going forward; Please use it on the above automations or scripts to fix this issue." + } } } diff --git a/tests/components/smartthings/test_binary_sensor.py b/tests/components/smartthings/test_binary_sensor.py index f46be2edc89..4d58b5ddd48 100644 --- a/tests/components/smartthings/test_binary_sensor.py +++ b/tests/components/smartthings/test_binary_sensor.py @@ -6,9 +6,14 @@ from pysmartthings import Attribute, Capability import pytest from syrupy import SnapshotAssertion +from homeassistant.components import automation, script +from homeassistant.components.automation import automations_with_entity +from homeassistant.components.script import scripts_with_entity +from homeassistant.components.smartthings import DOMAIN from homeassistant.const import STATE_OFF, STATE_ON, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import entity_registry as er, issue_registry as ir +from homeassistant.setup import async_setup_component from . import setup_integration, snapshot_smartthings_entities, trigger_update @@ -51,3 +56,65 @@ async def test_state_update( ) assert hass.states.get("binary_sensor.refrigerator_door").state == STATE_ON + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.parametrize("device_fixture", ["virtual_valve"]) +async def test_create_issue( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + issue_registry: ir.IssueRegistry, +) -> None: + """Test we create an issue when an automation or script is using a deprecated entity.""" + entity_id = "binary_sensor.volvo_valve" + issue_id = f"deprecated_binary_valve_{entity_id}" + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "alias": "test", + "trigger": {"platform": "state", "entity_id": entity_id}, + "action": { + "action": "automation.turn_on", + "target": { + "entity_id": "automation.test", + }, + }, + } + }, + ) + assert await async_setup_component( + hass, + script.DOMAIN, + { + script.DOMAIN: { + "test": { + "sequence": [ + { + "condition": "state", + "entity_id": entity_id, + "state": "on", + }, + ], + } + } + }, + ) + + await setup_integration(hass, mock_config_entry) + + assert automations_with_entity(hass, entity_id)[0] == "automation.test" + assert scripts_with_entity(hass, entity_id)[0] == "script.test" + + assert len(issue_registry.issues) == 1 + assert issue_registry.async_get_issue(DOMAIN, issue_id) + + await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Assert the issue is no longer present + assert not issue_registry.async_get_issue(DOMAIN, issue_id) + assert len(issue_registry.issues) == 0 From 1b91240d54540777aea69f8685facffe8f38b1e0 Mon Sep 17 00:00:00 2001 From: Ivaylo Iliev <43753631+iiliev-nemetschek@users.noreply.github.com> Date: Sun, 16 Mar 2025 22:31:34 +0200 Subject: [PATCH 2682/3148] Bump nibe_heatpump component version to add S332/S330 model (#140741) --- homeassistant/components/nibe_heatpump/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/nibe_heatpump/manifest.json b/homeassistant/components/nibe_heatpump/manifest.json index 049ba905f04..a8441fb90d8 100644 --- a/homeassistant/components/nibe_heatpump/manifest.json +++ b/homeassistant/components/nibe_heatpump/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/nibe_heatpump", "iot_class": "local_polling", - "requirements": ["nibe==2.14.0"] + "requirements": ["nibe==2.17.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 825983be33b..de3e2146a6f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1502,7 +1502,7 @@ nextdns==4.0.0 nhc==0.4.10 # homeassistant.components.nibe_heatpump -nibe==2.14.0 +nibe==2.17.0 # homeassistant.components.nice_go nice-go==1.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c73a3c8e9e9..63eb1780956 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1262,7 +1262,7 @@ nextdns==4.0.0 nhc==0.4.10 # homeassistant.components.nibe_heatpump -nibe==2.14.0 +nibe==2.17.0 # homeassistant.components.nice_go nice-go==1.0.1 From a40bb2790ebb5f652b5a7112cb455e50d55ecd65 Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Sun, 16 Mar 2025 17:15:04 -0400 Subject: [PATCH 2683/3148] Move Roborock map refresh to coordinator (#140758) Move refresh coordinator to coordinator --- homeassistant/components/roborock/__init__.py | 3 ++ .../components/roborock/coordinator.py | 31 ++++++++++++++++ homeassistant/components/roborock/image.py | 37 +------------------ tests/components/roborock/conftest.py | 5 ++- tests/components/roborock/test_image.py | 30 +++++++++++++++ 5 files changed, 69 insertions(+), 37 deletions(-) diff --git a/homeassistant/components/roborock/__init__.py b/homeassistant/components/roborock/__init__.py index 955e50cd15b..1b90adaf6ec 100644 --- a/homeassistant/components/roborock/__init__.py +++ b/homeassistant/components/roborock/__init__.py @@ -111,6 +111,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: RoborockConfigEntry) -> translation_key="no_coordinators", ) valid_coordinators = RoborockCoordinators(v1_coords, a01_coords) + await asyncio.gather( + *(coord.refresh_coordinator_map() for coord in valid_coordinators.v1) + ) async def on_stop(_: Any) -> None: _LOGGER.debug("Shutting down roborock") diff --git a/homeassistant/components/roborock/coordinator.py b/homeassistant/components/roborock/coordinator.py index 2f156545929..cbfd5e95a90 100644 --- a/homeassistant/components/roborock/coordinator.py +++ b/homeassistant/components/roborock/coordinator.py @@ -48,6 +48,7 @@ from .const import ( DRAWABLES, MAP_FILE_FORMAT, MAP_SCALE, + MAP_SLEEP, V1_CLOUD_IN_CLEANING_INTERVAL, V1_CLOUD_NOT_CLEANING_INTERVAL, V1_LOCAL_IN_CLEANING_INTERVAL, @@ -316,6 +317,36 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): """Get the slug of the duid.""" return slugify(self.duid) + async def refresh_coordinator_map(self) -> None: + """Get the starting map information for all maps for this device. + + The following steps must be done synchronously. + Only one map can be loaded at a time per device. + """ + cur_map = self.current_map + # This won't be None at this point as the coordinator will have run first. + if cur_map is None: + # If we don't have a cur map(shouldn't happen) just + # return as we can't do anything. + return + map_flags = sorted(self.maps, key=lambda data: data == cur_map, reverse=True) + for map_flag in map_flags: + if map_flag != cur_map: + # Only change the map and sleep if we have multiple maps. + await self.api.load_multi_map(map_flag) + self.current_map = map_flag + # We cannot get the map until the roborock servers fully process the + # map change. + await asyncio.sleep(MAP_SLEEP) + await self.set_current_map_rooms() + + if len(self.maps) != 1: + # Set the map back to the map the user previously had selected so that it + # does not change the end user's app. + # Only needs to happen when we changed maps above. + await self.api.load_multi_map(cur_map) + self.current_map = cur_map + class RoborockDataUpdateCoordinatorA01( DataUpdateCoordinator[ diff --git a/homeassistant/components/roborock/image.py b/homeassistant/components/roborock/image.py index b56abaeebdb..382edbca744 100644 --- a/homeassistant/components/roborock/image.py +++ b/homeassistant/components/roborock/image.py @@ -4,8 +4,6 @@ import asyncio from datetime import datetime import logging -from roborock import RoborockCommand - from homeassistant.components.image import ImageEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory @@ -14,7 +12,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util -from .const import DOMAIN, IMAGE_CACHE_INTERVAL, MAP_SLEEP +from .const import DOMAIN, IMAGE_CACHE_INTERVAL from .coordinator import RoborockConfigEntry, RoborockDataUpdateCoordinator from .entity import RoborockCoordinatedEntityV1 @@ -28,9 +26,6 @@ async def async_setup_entry( ) -> None: """Set up Roborock image platform.""" - await asyncio.gather( - *(refresh_coordinators(hass, coord) for coord in config_entry.runtime_data.v1) - ) async_add_entities( ( RoborockMap( @@ -126,33 +121,3 @@ class RoborockMap(RoborockCoordinatedEntityV1, ImageEntity): content, ) return self.cached_map - - -async def refresh_coordinators( - hass: HomeAssistant, coord: RoborockDataUpdateCoordinator -) -> None: - """Get the starting map information for all maps for this device. - - The following steps must be done synchronously. - Only one map can be loaded at a time per device. - """ - cur_map = coord.current_map - # This won't be None at this point as the coordinator will have run first. - assert cur_map is not None - map_flags = sorted(coord.maps, key=lambda data: data == cur_map, reverse=True) - for map_flag in map_flags: - if map_flag != cur_map: - # Only change the map and sleep if we have multiple maps. - await coord.api.send_command(RoborockCommand.LOAD_MULTI_MAP, [map_flag]) - coord.current_map = map_flag - # We cannot get the map until the roborock servers fully process the - # map change. - await asyncio.sleep(MAP_SLEEP) - await coord.set_current_map_rooms() - - if len(coord.maps) != 1: - # Set the map back to the map the user previously had selected so that it - # does not change the end user's app. - # Only needs to happen when we changed maps above. - await coord.cloud_api.send_command(RoborockCommand.LOAD_MULTI_MAP, [cur_map]) - coord.current_map = cur_map diff --git a/tests/components/roborock/conftest.py b/tests/components/roborock/conftest.py index cafac280620..b4fde5cc513 100644 --- a/tests/components/roborock/conftest.py +++ b/tests/components/roborock/conftest.py @@ -80,6 +80,9 @@ def bypass_api_client_fixture() -> None: "homeassistant.components.roborock.RoborockApiClient.get_scenes", return_value=SCENES, ), + patch( + "homeassistant.components.roborock.coordinator.RoborockLocalClientV1.load_multi_map" + ), ): yield @@ -127,7 +130,7 @@ def bypass_api_fixture(bypass_api_client_fixture: Any) -> None: "roborock.version_1_apis.AttributeCache.value", ), patch( - "homeassistant.components.roborock.image.MAP_SLEEP", + "homeassistant.components.roborock.coordinator.MAP_SLEEP", 0, ), patch( diff --git a/tests/components/roborock/test_image.py b/tests/components/roborock/test_image.py index d81f1289fe3..08f8ac504bf 100644 --- a/tests/components/roborock/test_image.py +++ b/tests/components/roborock/test_image.py @@ -244,3 +244,33 @@ async def test_fail_updating_image( async_fire_time_changed(hass, now) resp = await client.get("/api/image_proxy/image.roborock_s7_maxv_upstairs") assert not resp.ok + + +async def test_index_error_map( + hass: HomeAssistant, + setup_entry: MockConfigEntry, + hass_client: ClientSessionGenerator, +) -> None: + """Test that we handle failing getting the image after it has already been setup with a indexerror.""" + client = await hass_client() + now = dt_util.utcnow() + timedelta(seconds=91) + # Copy the device prop so we don't override it + prop = copy.deepcopy(PROP) + prop.status.in_cleaning = 1 + # Update image, but get IndexError for image. + with ( + patch( + "homeassistant.components.roborock.coordinator.RoborockMapDataParser.parse", + side_effect=IndexError, + ), + patch( + "homeassistant.components.roborock.coordinator.RoborockLocalClientV1.get_prop", + return_value=prop, + ), + patch( + "homeassistant.components.roborock.image.dt_util.utcnow", return_value=now + ), + ): + async_fire_time_changed(hass, now) + resp = await client.get("/api/image_proxy/image.roborock_s7_maxv_upstairs") + assert not resp.ok From 15e983e9972be5d9e8162e8d3baf6475e8b9031b Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Sun, 16 Mar 2025 17:24:49 -0400 Subject: [PATCH 2684/3148] Add snoo switches (#140748) * Add snoo switches * change naming * change wording --- homeassistant/components/snoo/__init__.py | 7 +- homeassistant/components/snoo/strings.json | 14 +++ homeassistant/components/snoo/switch.py | 105 +++++++++++++++++++++ tests/components/snoo/test_switch.py | 88 +++++++++++++++++ 4 files changed, 213 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/snoo/switch.py create mode 100644 tests/components/snoo/test_switch.py diff --git a/homeassistant/components/snoo/__init__.py b/homeassistant/components/snoo/__init__.py index 23b5d5201db..1934a2607a0 100644 --- a/homeassistant/components/snoo/__init__.py +++ b/homeassistant/components/snoo/__init__.py @@ -17,7 +17,12 @@ from .coordinator import SnooConfigEntry, SnooCoordinator _LOGGER = logging.getLogger(__name__) -PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SELECT, Platform.SENSOR] +PLATFORMS: list[Platform] = [ + Platform.BINARY_SENSOR, + Platform.SELECT, + Platform.SENSOR, + Platform.SWITCH, +] async def async_setup_entry(hass: HomeAssistant, entry: SnooConfigEntry) -> bool: diff --git a/homeassistant/components/snoo/strings.json b/homeassistant/components/snoo/strings.json index 8211480f771..ddeab83b6d4 100644 --- a/homeassistant/components/snoo/strings.json +++ b/homeassistant/components/snoo/strings.json @@ -24,6 +24,12 @@ "exceptions": { "select_failed": { "message": "Error while updating {name} to {option}" + }, + "switch_on_failed": { + "message": "Turning {name} on failed" + }, + "switch_off_failed": { + "message": "Turning {name} off failed" } }, "entity": { @@ -66,6 +72,14 @@ "stop": "[%key:component::snoo::entity::sensor::state::state::stop%]" } } + }, + "switch": { + "sticky_white_noise": { + "name": "Sleepytime sounds" + }, + "hold": { + "name": "Level lock" + } } } } diff --git a/homeassistant/components/snoo/switch.py b/homeassistant/components/snoo/switch.py new file mode 100644 index 00000000000..2ed322d5f6b --- /dev/null +++ b/homeassistant/components/snoo/switch.py @@ -0,0 +1,105 @@ +"""Support for Snoo Switches.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from typing import Any + +from python_snoo.containers import SnooData, SnooDevice +from python_snoo.exceptions import SnooCommandException +from python_snoo.snoo import Snoo + +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import DOMAIN +from .coordinator import SnooConfigEntry +from .entity import SnooDescriptionEntity + + +@dataclass(frozen=True, kw_only=True) +class SnooSwitchEntityDescription(SwitchEntityDescription): + """Describes a Snoo sensor.""" + + value_fn: Callable[[SnooData], bool] + set_value_fn: Callable[[Snoo, SnooDevice, SnooData, bool], Awaitable[None]] + + +BINARY_SENSOR_DESCRIPTIONS: list[SnooSwitchEntityDescription] = [ + SnooSwitchEntityDescription( + key="sticky_white_noise", + translation_key="sticky_white_noise", + value_fn=lambda data: data.state_machine.sticky_white_noise == "on", + set_value_fn=lambda snoo_api, device, _, state: snoo_api.set_sticky_white_noise( + device, state + ), + ), + SnooSwitchEntityDescription( + key="hold", + translation_key="hold", + value_fn=lambda data: data.state_machine.hold == "on", + set_value_fn=lambda snoo_api, device, data, state: snoo_api.set_level( + device, data.state_machine.level, state + ), + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + entry: SnooConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Snoo device.""" + coordinators = entry.runtime_data + async_add_entities( + SnooSwitch(coordinator, description) + for coordinator in coordinators.values() + for description in BINARY_SENSOR_DESCRIPTIONS + ) + + +class SnooSwitch(SnooDescriptionEntity, SwitchEntity): + """A switch using Snoo coordinator.""" + + entity_description: SnooSwitchEntityDescription + + @property + def is_on(self) -> bool | None: + """Return True if entity is on.""" + return self.entity_description.value_fn(self.coordinator.data) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the entity on.""" + try: + await self.entity_description.set_value_fn( + self.coordinator.snoo, + self.coordinator.device, + self.coordinator.data, + True, + ) + except SnooCommandException as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="switch_on_failed", + translation_placeholders={"name": str(self.name), "status": "on"}, + ) from err + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the entity off.""" + try: + await self.entity_description.set_value_fn( + self.coordinator.snoo, + self.coordinator.device, + self.coordinator.data, + False, + ) + except SnooCommandException as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="switch_off_failed", + translation_placeholders={"name": str(self.name), "status": "off"}, + ) from err diff --git a/tests/components/snoo/test_switch.py b/tests/components/snoo/test_switch.py new file mode 100644 index 00000000000..2343ff6c0d8 --- /dev/null +++ b/tests/components/snoo/test_switch.py @@ -0,0 +1,88 @@ +"""Test Snoo Switches.""" + +import copy +from unittest.mock import AsyncMock + +import pytest +from python_snoo.containers import SnooDevice +from python_snoo.exceptions import SnooCommandException + +from homeassistant.components.switch import ( + SERVICE_TOGGLE, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError + +from . import async_init_integration, find_update_callback +from .const import MOCK_SNOO_DATA + + +async def test_switch(hass: HomeAssistant, bypass_api: AsyncMock) -> None: + """Test switch and check test values are correctly set.""" + await async_init_integration(hass) + assert len(hass.states.async_all("switch")) == 2 + assert hass.states.get("switch.test_snoo_level_lock").state == STATE_UNAVAILABLE + assert ( + hass.states.get("switch.test_snoo_sleepytime_sounds").state == STATE_UNAVAILABLE + ) + find_update_callback(bypass_api, "random_num")(MOCK_SNOO_DATA) + await hass.async_block_till_done() + assert len(hass.states.async_all("switch")) == 2 + assert hass.states.get("switch.test_snoo_sleepytime_sounds").state == STATE_OFF + assert hass.states.get("switch.test_snoo_level_lock").state == STATE_OFF + + +async def test_update_success(hass: HomeAssistant, bypass_api: AsyncMock) -> None: + """Test changing values for switch entities.""" + await async_init_integration(hass) + + find_update_callback(bypass_api, "random_num")(MOCK_SNOO_DATA) + assert hass.states.get("switch.test_snoo_sleepytime_sounds").state == STATE_OFF + + async def set_sticky_white_noise(device: SnooDevice, state: bool): + new_data = copy.deepcopy(MOCK_SNOO_DATA) + new_data.state_machine.sticky_white_noise = "off" if not state else "on" + find_update_callback(bypass_api, device.serialNumber)(new_data) + + bypass_api.set_sticky_white_noise.side_effect = set_sticky_white_noise + await hass.services.async_call( + "switch", + SERVICE_TOGGLE, + blocking=True, + target={"entity_id": "switch.test_snoo_sleepytime_sounds"}, + ) + + assert bypass_api.set_sticky_white_noise.assert_called_once + assert hass.states.get("switch.test_snoo_sleepytime_sounds").state == STATE_ON + + +@pytest.mark.parametrize( + ("command", "error_str"), + [ + (SERVICE_TURN_ON, "Turning Sleepytime sounds on failed"), + (SERVICE_TURN_OFF, "Turning Sleepytime sounds off failed"), + ], +) +async def test_update_failed( + hass: HomeAssistant, bypass_api: AsyncMock, command: str, error_str: str +) -> None: + """Test failing to change values for switch entities.""" + await async_init_integration(hass) + + find_update_callback(bypass_api, "random_num")(MOCK_SNOO_DATA) + assert hass.states.get("switch.test_snoo_sleepytime_sounds").state == STATE_OFF + + bypass_api.set_sticky_white_noise.side_effect = SnooCommandException + with pytest.raises(HomeAssistantError, match=error_str): + await hass.services.async_call( + "switch", + command, + blocking=True, + target={"entity_id": "switch.test_snoo_sleepytime_sounds"}, + ) + + assert bypass_api.set_level.assert_called_once + assert hass.states.get("switch.test_snoo_sleepytime_sounds").state == STATE_OFF From a9949aece0c1eedd7b8da3957782e1265150c162 Mon Sep 17 00:00:00 2001 From: Johnny Willemsen Date: Sun, 16 Mar 2025 22:27:35 +0100 Subject: [PATCH 2685/3148] Fix typo in Homee (#140759) * Update strings.json Fixed typo * Update homeassistant/components/homee/strings.json * Fix --------- Co-authored-by: Joost Lekkerkerker --- homeassistant/components/homee/strings.json | 4 ++-- .../homee/snapshots/test_binary_sensor.ambr | 24 +++++++++---------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/homee/strings.json b/homeassistant/components/homee/strings.json index 050ed13bcad..da8357d16bc 100644 --- a/homeassistant/components/homee/strings.json +++ b/homeassistant/components/homee/strings.json @@ -56,10 +56,10 @@ "name": "Malfunction" }, "maximum": { - "name": "Maximumn level" + "name": "Maximum level" }, "minimum": { - "name": "Minumum level" + "name": "Minimum level" }, "motor_blocked": { "name": "Motor blocked" diff --git a/tests/components/homee/snapshots/test_binary_sensor.ambr b/tests/components/homee/snapshots/test_binary_sensor.ambr index 01f1f1e42ba..4926c048f5b 100644 --- a/tests/components/homee/snapshots/test_binary_sensor.ambr +++ b/tests/components/homee/snapshots/test_binary_sensor.ambr @@ -526,7 +526,7 @@ 'state': 'off', }) # --- -# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_maximumn_level-entry] +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_maximum_level-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -539,7 +539,7 @@ 'disabled_by': None, 'domain': 'binary_sensor', 'entity_category': , - 'entity_id': 'binary_sensor.test_binary_sensor_maximumn_level', + 'entity_id': 'binary_sensor.test_binary_sensor_maximum_level', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -551,7 +551,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Maximumn level', + 'original_name': 'Maximum level', 'platform': 'homee', 'previous_unique_id': None, 'supported_features': 0, @@ -560,21 +560,21 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_maximumn_level-state] +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_maximum_level-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'problem', - 'friendly_name': 'Test Binary Sensor Maximumn level', + 'friendly_name': 'Test Binary Sensor Maximum level', }), 'context': , - 'entity_id': 'binary_sensor.test_binary_sensor_maximumn_level', + 'entity_id': 'binary_sensor.test_binary_sensor_maximum_level', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'off', }) # --- -# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_minumum_level-entry] +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_minimum_level-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -587,7 +587,7 @@ 'disabled_by': None, 'domain': 'binary_sensor', 'entity_category': , - 'entity_id': 'binary_sensor.test_binary_sensor_minumum_level', + 'entity_id': 'binary_sensor.test_binary_sensor_minimum_level', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -599,7 +599,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Minumum level', + 'original_name': 'Minimum level', 'platform': 'homee', 'previous_unique_id': None, 'supported_features': 0, @@ -608,14 +608,14 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_minumum_level-state] +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_minimum_level-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'problem', - 'friendly_name': 'Test Binary Sensor Minumum level', + 'friendly_name': 'Test Binary Sensor Minimum level', }), 'context': , - 'entity_id': 'binary_sensor.test_binary_sensor_minumum_level', + 'entity_id': 'binary_sensor.test_binary_sensor_minimum_level', 'last_changed': , 'last_reported': , 'last_updated': , From f19a5b28f7bdfd882b0bf2bcf0e238f8c7d2dd5c Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 16 Mar 2025 22:38:25 +0100 Subject: [PATCH 2686/3148] Update description of `evaluate_payload` to use friendly name (#140736) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Update description of `evaluate_payload` to use friendly name For the graphical UI the action descriptions need to refer to the friendly names of other fields so these can be translated to match. Small change from `payload` to 'Payload'. * Replace "When …" with "If …" --- homeassistant/components/mqtt/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/mqtt/strings.json b/homeassistant/components/mqtt/strings.json index c3338948ff5..f0112097f4e 100644 --- a/homeassistant/components/mqtt/strings.json +++ b/homeassistant/components/mqtt/strings.json @@ -362,7 +362,7 @@ "fields": { "evaluate_payload": { "name": "Evaluate payload", - "description": "When `payload` is a Python bytes literal, evaluate the bytes literal and publish the raw data." + "description": "If 'Payload' is a Python bytes literal, evaluate the bytes literal and publish the raw data." }, "topic": { "name": "Topic", From bddec1168b1017e9a19911f3e1fcb94305419aae Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 17 Mar 2025 01:38:05 +0100 Subject: [PATCH 2687/3148] Bump ci cache version (#140767) --- .github/workflows/ci.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 3f970ce5874..49cb7ae019c 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -37,7 +37,7 @@ on: type: boolean env: - CACHE_VERSION: 11 + CACHE_VERSION: 12 UV_CACHE_VERSION: 1 MYPY_CACHE_VERSION: 9 HA_SHORT_VERSION: "2025.4" From 5fb03114b5e4b1da8b9c3f46e0d4bc5769d7ace1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 16 Mar 2025 20:35:49 -1000 Subject: [PATCH 2688/3148] Bump dbus-fast to 2.39.6 (#140775) changelog: https://github.com/Bluetooth-Devices/dbus-fast/compare/v2.39.5...v2.39.6 --- 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 ff8de8509a3..a0679f8e842 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -20,7 +20,7 @@ "bluetooth-adapters==0.21.4", "bluetooth-auto-recovery==1.4.5", "bluetooth-data-tools==1.26.1", - "dbus-fast==2.39.5", + "dbus-fast==2.39.6", "habluetooth==3.32.0" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index af437c4b079..21bb2dc7612 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -30,7 +30,7 @@ certifi>=2021.5.30 ciso8601==2.3.2 cronsim==2.6 cryptography==44.0.1 -dbus-fast==2.39.5 +dbus-fast==2.39.6 fnv-hash-fast==1.4.0 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 diff --git a/requirements_all.txt b/requirements_all.txt index de3e2146a6f..c5d27e38a49 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -744,7 +744,7 @@ datadog==0.15.0 datapoint==0.9.9 # homeassistant.components.bluetooth -dbus-fast==2.39.5 +dbus-fast==2.39.6 # homeassistant.components.debugpy debugpy==1.8.13 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 63eb1780956..33fc90c307b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -640,7 +640,7 @@ datadog==0.15.0 datapoint==0.9.9 # homeassistant.components.bluetooth -dbus-fast==2.39.5 +dbus-fast==2.39.6 # homeassistant.components.debugpy debugpy==1.8.13 From ab6c5af374e367247a18253493a4053f55b25321 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 16 Mar 2025 20:36:43 -1000 Subject: [PATCH 2689/3148] Bump aiohttp to 3.11.14 (#140773) changelog: https://github.com/aio-libs/aiohttp/compare/v3.11.13...v3.11.14 --- 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 21bb2dc7612..f63492a8b3f 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -6,7 +6,7 @@ aiodns==3.2.0 aiohasupervisor==0.3.0 aiohttp-asyncmdnsresolver==0.1.1 aiohttp-fast-zlib==0.2.3 -aiohttp==3.11.13 +aiohttp==3.11.14 aiohttp_cors==0.7.0 aiousbwatcher==1.1.1 aiozoneinfo==0.2.3 diff --git a/pyproject.toml b/pyproject.toml index 31d0ce4e42d..1879a2544c3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,7 @@ dependencies = [ # change behavior based on presence of supervisor. Deprecated with #127228 # Lib can be removed with 2025.11 "aiohasupervisor==0.3.0", - "aiohttp==3.11.13", + "aiohttp==3.11.14", "aiohttp_cors==0.7.0", "aiohttp-fast-zlib==0.2.3", "aiohttp-asyncmdnsresolver==0.1.1", diff --git a/requirements.txt b/requirements.txt index 22ffcfb54e1..176b1ae0c24 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,7 +5,7 @@ # Home Assistant Core aiodns==3.2.0 aiohasupervisor==0.3.0 -aiohttp==3.11.13 +aiohttp==3.11.14 aiohttp_cors==0.7.0 aiohttp-fast-zlib==0.2.3 aiohttp-asyncmdnsresolver==0.1.1 From 4baf72d80b3969ffa2e79c45f5e0ecf6c3ee9f35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Mon, 17 Mar 2025 07:43:02 +0100 Subject: [PATCH 2690/3148] Call only required listeners on CONNECT/PAIRED in Home Connect (#140765) Call only to the required listeners on CONNECT/PAIRED --- homeassistant/components/home_connect/coordinator.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/home_connect/coordinator.py b/homeassistant/components/home_connect/coordinator.py index dfac68084d1..e877dc7bfe4 100644 --- a/homeassistant/components/home_connect/coordinator.py +++ b/homeassistant/components/home_connect/coordinator.py @@ -231,15 +231,15 @@ class HomeConnectCoordinator( self.data[event_message_ha_id].update(appliance_data) else: self.data[event_message_ha_id] = appliance_data - for listener, context in list( - self._special_listeners.values() - ) + list(self._listeners.values()): - assert isinstance(context, tuple) + for listener, context in self._special_listeners.values(): if ( EventKey.BSH_COMMON_APPLIANCE_DEPAIRED not in context ): listener() + self._call_all_event_listeners_for_appliance( + event_message_ha_id + ) case EventType.DISCONNECTED: self.data[event_message_ha_id].info.connected = False From 74ce703755dfd10f5455e065d2f8dfcc6b8e280a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 17 Mar 2025 09:08:47 +0100 Subject: [PATCH 2691/3148] Bump docker/login-action from 3.3.0 to 3.4.0 (#140780) Bumps [docker/login-action](https://github.com/docker/login-action) from 3.3.0 to 3.4.0. - [Release notes](https://github.com/docker/login-action/releases) - [Commits](https://github.com/docker/login-action/compare/v3.3.0...v3.4.0) --- updated-dependencies: - dependency-name: docker/login-action dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/builder.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index 346f90fbe4f..ab64f1f3e7e 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -190,7 +190,7 @@ jobs: echo "${{ github.sha }};${{ github.ref }};${{ github.event_name }};${{ github.actor }}" > rootfs/OFFICIAL_IMAGE - name: Login to GitHub Container Registry - uses: docker/login-action@v3.3.0 + uses: docker/login-action@v3.4.0 with: registry: ghcr.io username: ${{ github.repository_owner }} @@ -256,7 +256,7 @@ jobs: fi - name: Login to GitHub Container Registry - uses: docker/login-action@v3.3.0 + uses: docker/login-action@v3.4.0 with: registry: ghcr.io username: ${{ github.repository_owner }} @@ -330,14 +330,14 @@ jobs: - name: Login to DockerHub if: matrix.registry == 'docker.io/homeassistant' - uses: docker/login-action@v3.3.0 + uses: docker/login-action@v3.4.0 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Login to GitHub Container Registry if: matrix.registry == 'ghcr.io/home-assistant' - uses: docker/login-action@v3.3.0 + uses: docker/login-action@v3.4.0 with: registry: ghcr.io username: ${{ github.repository_owner }} @@ -502,7 +502,7 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Login to GitHub Container Registry - uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3.3.0 + uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0 with: registry: ghcr.io username: ${{ github.repository_owner }} From 110e827edef8f84713ce3e9a2af9be5edbfc82c7 Mon Sep 17 00:00:00 2001 From: Ivan Lopez Hernandez Date: Mon, 17 Mar 2025 01:12:22 -0700 Subject: [PATCH 2692/3148] Add @IvanLH to owners of google_generative_ai_conversation (#140764) Update CODEOWNERS --- CODEOWNERS | 4 ++-- .../google_generative_ai_conversation/manifest.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index cfc37f6f908..1835e6d0be4 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -570,8 +570,8 @@ build.json @home-assistant/supervisor /tests/components/google_cloud/ @lufton @tronikos /homeassistant/components/google_drive/ @tronikos /tests/components/google_drive/ @tronikos -/homeassistant/components/google_generative_ai_conversation/ @tronikos -/tests/components/google_generative_ai_conversation/ @tronikos +/homeassistant/components/google_generative_ai_conversation/ @tronikos @ivanlh +/tests/components/google_generative_ai_conversation/ @tronikos @ivanlh /homeassistant/components/google_mail/ @tkdrob /tests/components/google_mail/ @tkdrob /homeassistant/components/google_photos/ @allenporter diff --git a/homeassistant/components/google_generative_ai_conversation/manifest.json b/homeassistant/components/google_generative_ai_conversation/manifest.json index cc381532c6f..ed215970d7f 100644 --- a/homeassistant/components/google_generative_ai_conversation/manifest.json +++ b/homeassistant/components/google_generative_ai_conversation/manifest.json @@ -2,7 +2,7 @@ "domain": "google_generative_ai_conversation", "name": "Google Generative AI", "after_dependencies": ["assist_pipeline", "intent"], - "codeowners": ["@tronikos"], + "codeowners": ["@tronikos", "@ivanlh"], "config_flow": true, "dependencies": ["conversation"], "documentation": "https://www.home-assistant.io/integrations/google_generative_ai_conversation", From a5913147e74f3f4675ed771222418555e82b477d Mon Sep 17 00:00:00 2001 From: Indu Prakash <6459774+iprak@users.noreply.github.com> Date: Mon, 17 Mar 2025 04:32:52 -0500 Subject: [PATCH 2693/3148] Add support for fan night light in VeSync (#140637) * style: rename humidifier night const * fix: separate night light for fan and humidifier Check for the presence of set_night_light_brightness and set_night_light to indentify humidifier and fan devices. set_night_light is defined on VeSyncAirBypass and set_night_light_brightness is defined on VeSyncHumid200300S. update test --- homeassistant/components/vesync/const.py | 10 ++-- homeassistant/components/vesync/select.py | 50 ++++++++++++++------ homeassistant/components/vesync/strings.json | 3 +- tests/components/vesync/test_select.py | 22 +++++---- 4 files changed, 58 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/vesync/const.py b/homeassistant/components/vesync/const.py index 1273ab914f8..4e39fe40f2d 100644 --- a/homeassistant/components/vesync/const.py +++ b/homeassistant/components/vesync/const.py @@ -30,9 +30,13 @@ VS_HUMIDIFIER_MODE_HUMIDITY = "humidity" VS_HUMIDIFIER_MODE_MANUAL = "manual" VS_HUMIDIFIER_MODE_SLEEP = "sleep" -NIGHT_LIGHT_LEVEL_BRIGHT = "bright" -NIGHT_LIGHT_LEVEL_DIM = "dim" -NIGHT_LIGHT_LEVEL_OFF = "off" +FAN_NIGHT_LIGHT_LEVEL_DIM = "dim" +FAN_NIGHT_LIGHT_LEVEL_OFF = "off" +FAN_NIGHT_LIGHT_LEVEL_ON = "on" + +HUMIDIFIER_NIGHT_LIGHT_LEVEL_BRIGHT = "bright" +HUMIDIFIER_NIGHT_LIGHT_LEVEL_DIM = "dim" +HUMIDIFIER_NIGHT_LIGHT_LEVEL_OFF = "off" VeSyncHumidifierDevice = VeSyncHumid200300S | VeSyncSuperior6000S """Humidifier device types""" diff --git a/homeassistant/components/vesync/select.py b/homeassistant/components/vesync/select.py index c266985fc2b..a9d2e1b533a 100644 --- a/homeassistant/components/vesync/select.py +++ b/homeassistant/components/vesync/select.py @@ -15,9 +15,12 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .common import rgetattr from .const import ( DOMAIN, - NIGHT_LIGHT_LEVEL_BRIGHT, - NIGHT_LIGHT_LEVEL_DIM, - NIGHT_LIGHT_LEVEL_OFF, + FAN_NIGHT_LIGHT_LEVEL_DIM, + FAN_NIGHT_LIGHT_LEVEL_OFF, + FAN_NIGHT_LIGHT_LEVEL_ON, + HUMIDIFIER_NIGHT_LIGHT_LEVEL_BRIGHT, + HUMIDIFIER_NIGHT_LIGHT_LEVEL_DIM, + HUMIDIFIER_NIGHT_LIGHT_LEVEL_OFF, VS_COORDINATOR, VS_DEVICES, VS_DISCOVERY, @@ -27,14 +30,14 @@ from .entity import VeSyncBaseEntity _LOGGER = logging.getLogger(__name__) -VS_TO_HA_NIGHT_LIGHT_LEVEL_MAP = { - 100: NIGHT_LIGHT_LEVEL_BRIGHT, - 50: NIGHT_LIGHT_LEVEL_DIM, - 0: NIGHT_LIGHT_LEVEL_OFF, +VS_TO_HA_HUMIDIFIER_NIGHT_LIGHT_LEVEL_MAP = { + 100: HUMIDIFIER_NIGHT_LIGHT_LEVEL_BRIGHT, + 50: HUMIDIFIER_NIGHT_LIGHT_LEVEL_DIM, + 0: HUMIDIFIER_NIGHT_LIGHT_LEVEL_OFF, } -HA_TO_VS_NIGHT_LIGHT_LEVEL_MAP = { - v: k for k, v in VS_TO_HA_NIGHT_LIGHT_LEVEL_MAP.items() +HA_TO_VS_HUMIDIFIER_NIGHT_LIGHT_LEVEL_MAP = { + v: k for k, v in VS_TO_HA_HUMIDIFIER_NIGHT_LIGHT_LEVEL_MAP.items() } @@ -48,20 +51,39 @@ class VeSyncSelectEntityDescription(SelectEntityDescription): SELECT_DESCRIPTIONS: list[VeSyncSelectEntityDescription] = [ + # night_light for humidifier VeSyncSelectEntityDescription( key="night_light_level", translation_key="night_light_level", - options=list(VS_TO_HA_NIGHT_LIGHT_LEVEL_MAP.values()), + options=list(VS_TO_HA_HUMIDIFIER_NIGHT_LIGHT_LEVEL_MAP.values()), icon="mdi:brightness-6", - exists_fn=lambda device: rgetattr(device, "night_light"), + exists_fn=lambda device: rgetattr(device, "set_night_light_brightness"), # The select_option service framework ensures that only options specified are # accepted. ServiceValidationError gets raised for invalid value. select_option_fn=lambda device, value: device.set_night_light_brightness( - HA_TO_VS_NIGHT_LIGHT_LEVEL_MAP.get(value, 0) + HA_TO_VS_HUMIDIFIER_NIGHT_LIGHT_LEVEL_MAP.get(value, 0) ), # Reporting "off" as the choice for unhandled level. - current_option_fn=lambda device: VS_TO_HA_NIGHT_LIGHT_LEVEL_MAP.get( - device.details.get("night_light_brightness"), NIGHT_LIGHT_LEVEL_OFF + current_option_fn=lambda device: VS_TO_HA_HUMIDIFIER_NIGHT_LIGHT_LEVEL_MAP.get( + device.details.get("night_light_brightness"), + HUMIDIFIER_NIGHT_LIGHT_LEVEL_OFF, + ), + ), + # night_light for fan devices based on pyvesync.VeSyncAirBypass + VeSyncSelectEntityDescription( + key="night_light_level", + translation_key="night_light_level", + options=[ + FAN_NIGHT_LIGHT_LEVEL_OFF, + FAN_NIGHT_LIGHT_LEVEL_DIM, + FAN_NIGHT_LIGHT_LEVEL_ON, + ], + icon="mdi:brightness-6", + exists_fn=lambda device: rgetattr(device, "set_night_light"), + select_option_fn=lambda device, value: device.set_night_light(value), + current_option_fn=lambda device: VS_TO_HA_HUMIDIFIER_NIGHT_LIGHT_LEVEL_MAP.get( + device.details.get("night_light"), + FAN_NIGHT_LIGHT_LEVEL_OFF, ), ), ] diff --git a/homeassistant/components/vesync/strings.json b/homeassistant/components/vesync/strings.json index eabb2969580..9b63bf3e614 100644 --- a/homeassistant/components/vesync/strings.json +++ b/homeassistant/components/vesync/strings.json @@ -71,7 +71,8 @@ "state": { "bright": "Bright", "dim": "Dim", - "off": "[%key:common::state::off%]" + "off": "[%key:common::state::off%]", + "on": "[%key:common::state::on%]" } } }, diff --git a/tests/components/vesync/test_select.py b/tests/components/vesync/test_select.py index 30c83c89e0e..c96d687dfd2 100644 --- a/tests/components/vesync/test_select.py +++ b/tests/components/vesync/test_select.py @@ -7,8 +7,10 @@ from homeassistant.components.select import ( DOMAIN as SELECT_DOMAIN, SERVICE_SELECT_OPTION, ) -from homeassistant.components.vesync.const import NIGHT_LIGHT_LEVEL_DIM -from homeassistant.components.vesync.select import HA_TO_VS_NIGHT_LIGHT_LEVEL_MAP +from homeassistant.components.vesync.const import HUMIDIFIER_NIGHT_LIGHT_LEVEL_DIM +from homeassistant.components.vesync.select import ( + HA_TO_VS_HUMIDIFIER_NIGHT_LIGHT_LEVEL_MAP, +) from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant @@ -18,24 +20,24 @@ from .common import ENTITY_HUMIDIFIER_300S_NIGHT_LIGHT_SELECT @pytest.mark.parametrize( "install_humidifier_device", ["humidifier_300s"], indirect=True ) -async def test_set_nightlight_level( +async def test_humidifier_set_nightlight_level( hass: HomeAssistant, manager, humidifier_300s, install_humidifier_device ) -> None: - """Test set of night light level.""" + """Test set of humidifier night light level.""" await hass.services.async_call( SELECT_DOMAIN, SERVICE_SELECT_OPTION, { ATTR_ENTITY_ID: ENTITY_HUMIDIFIER_300S_NIGHT_LIGHT_SELECT, - ATTR_OPTION: NIGHT_LIGHT_LEVEL_DIM, + ATTR_OPTION: HUMIDIFIER_NIGHT_LIGHT_LEVEL_DIM, }, blocking=True, ) # Assert that setter API was invoked with the expected translated value humidifier_300s.set_night_light_brightness.assert_called_once_with( - HA_TO_VS_NIGHT_LIGHT_LEVEL_MAP[NIGHT_LIGHT_LEVEL_DIM] + HA_TO_VS_HUMIDIFIER_NIGHT_LIGHT_LEVEL_MAP[HUMIDIFIER_NIGHT_LIGHT_LEVEL_DIM] ) # Assert that devices were refreshed manager.update_all_devices.assert_called_once() @@ -44,11 +46,13 @@ async def test_set_nightlight_level( @pytest.mark.parametrize( "install_humidifier_device", ["humidifier_300s"], indirect=True ) -async def test_nightlight_level(hass: HomeAssistant, install_humidifier_device) -> None: - """Test the state of night light level select entity.""" +async def test_humidifier_nightlight_level( + hass: HomeAssistant, install_humidifier_device +) -> None: + """Test the state of humidifier night light level select entity.""" # The mocked device has night_light_brightness=50 which is "dim" assert ( hass.states.get(ENTITY_HUMIDIFIER_300S_NIGHT_LIGHT_SELECT).state - == NIGHT_LIGHT_LEVEL_DIM + == HUMIDIFIER_NIGHT_LIGHT_LEVEL_DIM ) From 0d1c79b427ff91db3036672b87068f570d96cde7 Mon Sep 17 00:00:00 2001 From: Petar Petrov Date: Mon, 17 Mar 2025 14:18:15 +0200 Subject: [PATCH 2694/3148] Bump zwave-js-server-python to 0.62.0 (#140796) * Bump zwave-js-server-python to 0.62.0 * fix breaking change --- homeassistant/components/zwave_js/helpers.py | 2 +- homeassistant/components/zwave_js/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/zwave_js/helpers.py b/homeassistant/components/zwave_js/helpers.py index 904a26acc78..8a90ebf6f88 100644 --- a/homeassistant/components/zwave_js/helpers.py +++ b/homeassistant/components/zwave_js/helpers.py @@ -187,7 +187,7 @@ async def async_disable_server_logging_if_needed( old_server_log_level, ) await driver.async_update_log_config(LogConfig(level=old_server_log_level)) - await driver.client.disable_server_logging() + driver.client.disable_server_logging() LOGGER.info("Zwave-js-server logging is enabled") diff --git a/homeassistant/components/zwave_js/manifest.json b/homeassistant/components/zwave_js/manifest.json index 16831853290..7e8b473922f 100644 --- a/homeassistant/components/zwave_js/manifest.json +++ b/homeassistant/components/zwave_js/manifest.json @@ -9,7 +9,7 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["zwave_js_server"], - "requirements": ["pyserial==3.5", "zwave-js-server-python==0.61.0"], + "requirements": ["pyserial==3.5", "zwave-js-server-python==0.62.0"], "usb": [ { "vid": "0658", diff --git a/requirements_all.txt b/requirements_all.txt index c5d27e38a49..41f0462f558 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3165,7 +3165,7 @@ ziggo-mediabox-xl==1.1.0 zm-py==0.5.4 # homeassistant.components.zwave_js -zwave-js-server-python==0.61.0 +zwave-js-server-python==0.62.0 # homeassistant.components.zwave_me zwave-me-ws==0.4.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 33fc90c307b..7876f567064 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2542,7 +2542,7 @@ zeversolar==0.3.2 zha==0.0.52 # homeassistant.components.zwave_js -zwave-js-server-python==0.61.0 +zwave-js-server-python==0.62.0 # homeassistant.components.zwave_me zwave-me-ws==0.4.3 From fb2b3ce7d21998e216567dc6fc81ccd95553bdc0 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 17 Mar 2025 13:19:27 +0100 Subject: [PATCH 2695/3148] Bump pychromecast to 14.0.6 (#140794) --- homeassistant/components/cast/helpers.py | 10 +++++++--- homeassistant/components/cast/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 10 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/cast/helpers.py b/homeassistant/components/cast/helpers.py index 8f4af197b8e..7f46100afca 100644 --- a/homeassistant/components/cast/helpers.py +++ b/homeassistant/components/cast/helpers.py @@ -7,6 +7,7 @@ from dataclasses import dataclass import logging from typing import TYPE_CHECKING, ClassVar from urllib.parse import urlparse +from uuid import UUID import aiohttp import attr @@ -40,7 +41,7 @@ class ChromecastInfo: is_dynamic_group = attr.ib(type=bool | None, default=None) @property - def friendly_name(self) -> str: + def friendly_name(self) -> str | None: """Return the Friendly Name.""" return self.cast_info.friendly_name @@ -50,7 +51,7 @@ class ChromecastInfo: return self.cast_info.cast_type == CAST_TYPE_GROUP @property - def uuid(self) -> bool: + def uuid(self) -> UUID: """Return the UUID.""" return self.cast_info.uuid @@ -111,7 +112,10 @@ class ChromecastInfo: is_dynamic_group = False http_group_status = None http_group_status = dial.get_multizone_status( - None, + # We pass services which will be used for the HTTP request, and we + # don't care about the host in http_group_status.dynamic_groups so + # we pass an empty string to simplify the code. + "", services=self.cast_info.services, zconf=ChromeCastZeroconf.get_zeroconf(), ) diff --git a/homeassistant/components/cast/manifest.json b/homeassistant/components/cast/manifest.json index 0650f267544..feb613f4765 100644 --- a/homeassistant/components/cast/manifest.json +++ b/homeassistant/components/cast/manifest.json @@ -14,7 +14,7 @@ "documentation": "https://www.home-assistant.io/integrations/cast", "iot_class": "local_polling", "loggers": ["casttube", "pychromecast"], - "requirements": ["PyChromecast==14.0.5"], + "requirements": ["PyChromecast==14.0.6"], "single_config_entry": true, "zeroconf": ["_googlecast._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 41f0462f558..ae0f6114b0e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -45,7 +45,7 @@ ProgettiHWSW==0.1.3 # PyBluez==0.22 # homeassistant.components.cast -PyChromecast==14.0.5 +PyChromecast==14.0.6 # homeassistant.components.flick_electric PyFlick==1.1.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7876f567064..48dbb5deae6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -42,7 +42,7 @@ PlexAPI==4.15.16 ProgettiHWSW==0.1.3 # homeassistant.components.cast -PyChromecast==14.0.5 +PyChromecast==14.0.6 # homeassistant.components.flick_electric PyFlick==1.1.3 From 76aef5be9f0f68cf6d7a7a400d21adc4956613b2 Mon Sep 17 00:00:00 2001 From: Stephan van Rooij <1292510+svrooij@users.noreply.github.com> Date: Mon, 17 Mar 2025 14:16:52 +0100 Subject: [PATCH 2696/3148] Add PKCE implementation in oauth2 helper (#139509) * Update config_entry_oauth2_flow.py * Specify type on request_data * Added LocalOAuth2ImplementationWithPkce * LocalOAuth2ImplementationWithPkce works more like specs * fix: Adding tests for pkce flow and feedback applied * fix last test for pkce * Clean test_abort_if_oauth_with_pkce_rejected * Improve assertion of code verifier and code challenge * Break long docstrings * Shorten docstring --------- Co-authored-by: Martin Hjelmare --- .../helpers/config_entry_oauth2_flow.py | 117 +++++++++++- .../helpers/test_config_entry_oauth2_flow.py | 167 +++++++++++++++++- 2 files changed, 273 insertions(+), 11 deletions(-) diff --git a/homeassistant/helpers/config_entry_oauth2_flow.py b/homeassistant/helpers/config_entry_oauth2_flow.py index 24a9de5b562..84728978ede 100644 --- a/homeassistant/helpers/config_entry_oauth2_flow.py +++ b/homeassistant/helpers/config_entry_oauth2_flow.py @@ -11,7 +11,9 @@ from __future__ import annotations from abc import ABC, ABCMeta, abstractmethod import asyncio from asyncio import Lock +import base64 from collections.abc import Awaitable, Callable +import hashlib from http import HTTPStatus from json import JSONDecodeError import logging @@ -166,6 +168,11 @@ class LocalOAuth2Implementation(AbstractOAuth2Implementation): """Extra data that needs to be appended to the authorize url.""" return {} + @property + def extra_token_resolve_data(self) -> dict: + """Extra data for the token resolve request.""" + return {} + async def async_generate_authorize_url(self, flow_id: str) -> str: """Generate a url for the user to authorize.""" redirect_uri = self.redirect_uri @@ -186,13 +193,13 @@ class LocalOAuth2Implementation(AbstractOAuth2Implementation): async def async_resolve_external_data(self, external_data: Any) -> dict: """Resolve the authorization code to tokens.""" - return await self._token_request( - { - "grant_type": "authorization_code", - "code": external_data["code"], - "redirect_uri": external_data["state"]["redirect_uri"], - } - ) + request_data: dict = { + "grant_type": "authorization_code", + "code": external_data["code"], + "redirect_uri": external_data["state"]["redirect_uri"], + } + request_data.update(self.extra_token_resolve_data) + return await self._token_request(request_data) async def _async_refresh_token(self, token: dict) -> dict: """Refresh tokens.""" @@ -211,7 +218,7 @@ class LocalOAuth2Implementation(AbstractOAuth2Implementation): data["client_id"] = self.client_id - if self.client_secret is not None: + if self.client_secret: data["client_secret"] = self.client_secret _LOGGER.debug("Sending token request to %s", self.token_url) @@ -233,6 +240,100 @@ class LocalOAuth2Implementation(AbstractOAuth2Implementation): return cast(dict, await resp.json()) +class LocalOAuth2ImplementationWithPkce(LocalOAuth2Implementation): + """Local OAuth2 implementation with PKCE.""" + + def __init__( + self, + hass: HomeAssistant, + domain: str, + client_id: str, + authorize_url: str, + token_url: str, + client_secret: str = "", + code_verifier_length: int = 128, + ) -> None: + """Initialize local auth implementation.""" + super().__init__( + hass, + domain, + client_id, + client_secret, + authorize_url, + token_url, + ) + + # Generate code verifier + self.code_verifier = LocalOAuth2ImplementationWithPkce.generate_code_verifier( + code_verifier_length + ) + + @property + def extra_authorize_data(self) -> dict: + """Extra data that needs to be appended to the authorize url. + + If you want to override this method, + calling super is mandatory (for adding scopes): + ``` + @def extra_authorize_data(self) -> dict: + data: dict = { + "scope": "openid profile email", + } + data.update(super().extra_authorize_data) + return data + ``` + """ + return { + "code_challenge": LocalOAuth2ImplementationWithPkce.compute_code_challenge( + self.code_verifier + ), + "code_challenge_method": "S256", + } + + @property + def extra_token_resolve_data(self) -> dict: + """Extra data that needs to be included in the token resolve request. + + If you want to override this method, + calling super is mandatory (for adding `someKey`): + ``` + @def extra_token_resolve_data(self) -> dict: + data: dict = { + "someKey": "someValue", + } + data.update(super().extra_token_resolve_data) + return data + ``` + """ + + return {"code_verifier": self.code_verifier} + + @staticmethod + def generate_code_verifier(code_verifier_length: int = 128) -> str: + """Generate a code verifier.""" + if not 43 <= code_verifier_length <= 128: + msg = ( + "Parameter `code_verifier_length` must validate" + "`43 <= code_verifier_length <= 128`." + ) + raise ValueError(msg) + return secrets.token_urlsafe(96)[:code_verifier_length] + + @staticmethod + def compute_code_challenge(code_verifier: str) -> str: + """Compute the code challenge.""" + if not 43 <= len(code_verifier) <= 128: + msg = ( + "Parameter `code_verifier` must validate " + "`43 <= len(code_verifier) <= 128`." + ) + raise ValueError(msg) + + hashed = hashlib.sha256(code_verifier.encode("ascii")).digest() + encoded = base64.urlsafe_b64encode(hashed) + return encoded.decode("ascii").replace("=", "") + + class AbstractOAuth2FlowHandler(config_entries.ConfigFlow, metaclass=ABCMeta): """Handle a config flow.""" diff --git a/tests/helpers/test_config_entry_oauth2_flow.py b/tests/helpers/test_config_entry_oauth2_flow.py index 0fc6b582bb5..5d16a9a62fd 100644 --- a/tests/helpers/test_config_entry_oauth2_flow.py +++ b/tests/helpers/test_config_entry_oauth2_flow.py @@ -1,11 +1,11 @@ """Tests for the Somfy config flow.""" -from collections.abc import Generator +from collections.abc import AsyncGenerator, Generator from http import HTTPStatus import logging import time from typing import Any -from unittest.mock import patch +from unittest.mock import AsyncMock, patch import aiohttp import pytest @@ -15,7 +15,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import config_entry_oauth2_flow from homeassistant.helpers.network import NoURLAvailableError -from tests.common import MockConfigEntry, mock_platform +from tests.common import MockConfigEntry, MockModule, mock_integration, mock_platform from tests.test_util.aiohttp import AiohttpClientMocker from tests.typing import ClientSessionGenerator @@ -27,6 +27,11 @@ ACCESS_TOKEN_1 = "mock-access-token-1" ACCESS_TOKEN_2 = "mock-access-token-2" AUTHORIZE_URL = "https://example.como/auth/authorize" TOKEN_URL = "https://example.como/auth/token" +MOCK_SECRET_TOKEN_URLSAFE = ( + "token-" + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" +) @pytest.fixture @@ -40,6 +45,22 @@ async def local_impl( ) +@pytest.fixture +async def local_impl_pkce( + hass: HomeAssistant, +) -> AsyncGenerator[config_entry_oauth2_flow.LocalOAuth2ImplementationWithPkce]: + """Local implementation.""" + assert await setup.async_setup_component(hass, "auth", {}) + with patch( + "homeassistant.helpers.config_entry_oauth2_flow.secrets.token_urlsafe", + return_value=MOCK_SECRET_TOKEN_URLSAFE + + "bbbbbb", # Add some characters that should be removed by the logic. + ): + yield config_entry_oauth2_flow.LocalOAuth2ImplementationWithPkce( + hass, TEST_DOMAIN, CLIENT_ID, AUTHORIZE_URL, TOKEN_URL + ) + + @pytest.fixture def flow_handler( hass: HomeAssistant, @@ -963,3 +984,143 @@ async def test_oauth2_without_secret_init( client = await hass_client_no_auth() resp = await client.get("/auth/external/callback?code=abcd&state=qwer") assert resp.status == 400 + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_abort_oauth_with_pkce_rejected( + hass: HomeAssistant, + flow_handler: type[config_entry_oauth2_flow.AbstractOAuth2FlowHandler], + local_impl_pkce: config_entry_oauth2_flow.LocalOAuth2ImplementationWithPkce, + hass_client_no_auth: ClientSessionGenerator, +) -> None: + """Check bad oauth token.""" + flow_handler.async_register_implementation(hass, local_impl_pkce) + + result = await hass.config_entries.flow.async_init( + TEST_DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + code_challenge = local_impl_pkce.compute_code_challenge(MOCK_SECRET_TOKEN_URLSAFE) + assert result["type"] == data_entry_flow.FlowResultType.EXTERNAL_STEP + + assert result["url"].startswith(f"{AUTHORIZE_URL}?") + assert f"client_id={CLIENT_ID}" in result["url"] + assert "redirect_uri=https://example.com/auth/external/callback" in result["url"] + assert f"state={state}" in result["url"] + assert "scope=read+write" in result["url"] + assert "response_type=code" in result["url"] + assert f"code_challenge={code_challenge}" in result["url"] + assert "code_challenge_method=S256" in result["url"] + + client = await hass_client_no_auth() + resp = await client.get( + f"/auth/external/callback?error=access_denied&state={state}" + ) + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] == data_entry_flow.FlowResultType.ABORT + assert result["reason"] == "user_rejected_authorize" + assert result["description_placeholders"] == {"error": "access_denied"} + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_oauth_with_pkce_adds_code_verifier_to_token_resolve( + hass: HomeAssistant, + flow_handler: type[config_entry_oauth2_flow.AbstractOAuth2FlowHandler], + local_impl_pkce: config_entry_oauth2_flow.LocalOAuth2ImplementationWithPkce, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Check pkce flow.""" + + mock_integration( + hass, + MockModule( + domain=TEST_DOMAIN, + async_setup_entry=AsyncMock(return_value=True), + ), + ) + mock_platform(hass, f"{TEST_DOMAIN}.config_flow", None) + flow_handler.async_register_implementation(hass, local_impl_pkce) + + result = await hass.config_entries.flow.async_init( + TEST_DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + code_challenge = local_impl_pkce.compute_code_challenge(MOCK_SECRET_TOKEN_URLSAFE) + assert result["type"] == data_entry_flow.FlowResultType.EXTERNAL_STEP + + assert result["url"].startswith(f"{AUTHORIZE_URL}?") + assert f"client_id={CLIENT_ID}" in result["url"] + assert "redirect_uri=https://example.com/auth/external/callback" in result["url"] + assert f"state={state}" in result["url"] + assert "scope=read+write" in result["url"] + assert "response_type=code" in result["url"] + assert f"code_challenge={code_challenge}" in result["url"] + assert "code_challenge_method=S256" in result["url"] + + # Setup the response when HA tries to fetch a token with the code + aioclient_mock.post( + TOKEN_URL, + json={ + "refresh_token": REFRESH_TOKEN, + "access_token": ACCESS_TOKEN_1, + "type": "bearer", + "expires_in": 60, + }, + ) + + client = await hass_client_no_auth() + # trigger the callback + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + # Verify the token resolve request occurred + assert len(aioclient_mock.mock_calls) == 1 + assert aioclient_mock.mock_calls[0][2] == { + "client_id": CLIENT_ID, + "grant_type": "authorization_code", + "code": "abcd", + "redirect_uri": "https://example.com/auth/external/callback", + "code_verifier": MOCK_SECRET_TOKEN_URLSAFE, + } + + +@pytest.mark.parametrize("code_verifier_length", [40, 129]) +def test_generate_code_verifier_invalid_length(code_verifier_length: int) -> None: + """Test generate_code_verifier with an invalid length.""" + with pytest.raises(ValueError): + config_entry_oauth2_flow.LocalOAuth2ImplementationWithPkce.generate_code_verifier( + code_verifier_length + ) + + +@pytest.mark.parametrize("code_verifier", ["", "yyy", "a" * 129]) +def test_compute_code_challenge_invalid_code_verifier(code_verifier: str) -> None: + """Test compute_code_challenge with an invalid code_verifier.""" + with pytest.raises(ValueError): + config_entry_oauth2_flow.LocalOAuth2ImplementationWithPkce.compute_code_challenge( + code_verifier + ) From 18bd8b561ab4d228a24662d115cee2fa49b52408 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Mon, 17 Mar 2025 15:49:13 +0100 Subject: [PATCH 2697/3148] Add Reolink smart ai binary sensors (#140408) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add Crossline smart AI binary sensor * Add intrusion, lingering, forgotten item, item taken detection * Use unique_index instead of location for unique_id * Add test * Apply suggestions from code review Co-authored-by: Abílio Costa * Name changes * Update homeassistant/components/reolink/binary_sensor.py Co-authored-by: Abílio Costa * Use smart_type instead of key * Use occupancy translation instead of gas (point to the same thing). * Revert "Use occupancy translation instead of gas (point to the same thing)." This reverts commit 9caf796585e1cffdea6e66f16824fe8e34d03276. * fix styling --------- Co-authored-by: Abílio Costa --- .../components/reolink/binary_sensor.py | 210 +++++++++++++++++- homeassistant/components/reolink/icons.json | 66 ++++++ homeassistant/components/reolink/strings.json | 77 +++++++ tests/components/reolink/conftest.py | 4 + .../components/reolink/test_binary_sensor.py | 26 +++ 5 files changed, 378 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/reolink/binary_sensor.py b/homeassistant/components/reolink/binary_sensor.py index 4e90bfc9eef..39910bbc52a 100644 --- a/homeassistant/components/reolink/binary_sensor.py +++ b/homeassistant/components/reolink/binary_sensor.py @@ -25,7 +25,11 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .entity import ReolinkChannelCoordinatorEntity, ReolinkChannelEntityDescription +from .entity import ( + ReolinkChannelCoordinatorEntity, + ReolinkChannelEntityDescription, + ReolinkEntityDescription, +) from .util import ReolinkConfigEntry, ReolinkData PARALLEL_UPDATES = 0 @@ -41,6 +45,18 @@ class ReolinkBinarySensorEntityDescription( value: Callable[[Host, int], bool] +@dataclass(frozen=True, kw_only=True) +class ReolinkSmartAIBinarySensorEntityDescription( + BinarySensorEntityDescription, + ReolinkEntityDescription, +): + """A class that describes Smart AI binary sensor entities.""" + + smart_type: str + value: Callable[[Host, int, int], bool] + supported: Callable[[Host, int, int], bool] = lambda api, ch, loc: True + + BINARY_PUSH_SENSORS = ( ReolinkBinarySensorEntityDescription( key="motion", @@ -121,6 +137,142 @@ BINARY_SENSORS = ( ), ) +BINARY_SMART_AI_SENSORS = ( + ReolinkSmartAIBinarySensorEntityDescription( + key="crossline_person", + smart_type="crossline", + cmd_id=33, + translation_key="crossline_person", + value=lambda api, ch, loc: ( + api.baichuan.smart_ai_state(ch, "crossline", loc, "people") + ), + supported=lambda api, ch, loc: ( + api.supported(ch, "ai_crossline") + and "people" in api.baichuan.smart_ai_type_list(ch, "crossline", loc) + ), + ), + ReolinkSmartAIBinarySensorEntityDescription( + key="crossline_vehicle", + smart_type="crossline", + cmd_id=33, + translation_key="crossline_vehicle", + value=lambda api, ch, loc: ( + api.baichuan.smart_ai_state(ch, "crossline", loc, "vehicle") + ), + supported=lambda api, ch, loc: ( + api.supported(ch, "ai_crossline") + and "vehicle" in api.baichuan.smart_ai_type_list(ch, "crossline", loc) + ), + ), + ReolinkSmartAIBinarySensorEntityDescription( + key="crossline_dog_cat", + smart_type="crossline", + cmd_id=33, + translation_key="crossline_dog_cat", + value=lambda api, ch, loc: ( + api.baichuan.smart_ai_state(ch, "crossline", loc, "dog_cat") + ), + supported=lambda api, ch, loc: ( + api.supported(ch, "ai_crossline") + and "dog_cat" in api.baichuan.smart_ai_type_list(ch, "crossline", loc) + ), + ), + ReolinkSmartAIBinarySensorEntityDescription( + key="intrusion_person", + smart_type="intrusion", + cmd_id=33, + translation_key="intrusion_person", + value=lambda api, ch, loc: ( + api.baichuan.smart_ai_state(ch, "intrusion", loc, "people") + ), + supported=lambda api, ch, loc: ( + api.supported(ch, "ai_intrusion") + and "people" in api.baichuan.smart_ai_type_list(ch, "intrusion", loc) + ), + ), + ReolinkSmartAIBinarySensorEntityDescription( + key="intrusion_vehicle", + smart_type="intrusion", + cmd_id=33, + translation_key="intrusion_vehicle", + value=lambda api, ch, loc: ( + api.baichuan.smart_ai_state(ch, "intrusion", loc, "vehicle") + ), + supported=lambda api, ch, loc: ( + api.supported(ch, "ai_intrusion") + and "vehicle" in api.baichuan.smart_ai_type_list(ch, "intrusion", loc) + ), + ), + ReolinkSmartAIBinarySensorEntityDescription( + key="intrusion_dog_cat", + smart_type="intrusion", + cmd_id=33, + translation_key="intrusion_dog_cat", + value=lambda api, ch, loc: ( + api.baichuan.smart_ai_state(ch, "intrusion", loc, "dog_cat") + ), + supported=lambda api, ch, loc: ( + api.supported(ch, "ai_intrusion") + and "dog_cat" in api.baichuan.smart_ai_type_list(ch, "intrusion", loc) + ), + ), + ReolinkSmartAIBinarySensorEntityDescription( + key="linger_person", + smart_type="loitering", + cmd_id=33, + translation_key="linger_person", + value=lambda api, ch, loc: ( + api.baichuan.smart_ai_state(ch, "loitering", loc, "people") + ), + supported=lambda api, ch, loc: ( + api.supported(ch, "ai_linger") + and "people" in api.baichuan.smart_ai_type_list(ch, "loitering", loc) + ), + ), + ReolinkSmartAIBinarySensorEntityDescription( + key="linger_vehicle", + smart_type="loitering", + cmd_id=33, + translation_key="linger_vehicle", + value=lambda api, ch, loc: ( + api.baichuan.smart_ai_state(ch, "loitering", loc, "vehicle") + ), + supported=lambda api, ch, loc: ( + api.supported(ch, "ai_linger") + and "vehicle" in api.baichuan.smart_ai_type_list(ch, "loitering", loc) + ), + ), + ReolinkSmartAIBinarySensorEntityDescription( + key="linger_dog_cat", + smart_type="loitering", + cmd_id=33, + translation_key="linger_dog_cat", + value=lambda api, ch, loc: ( + api.baichuan.smart_ai_state(ch, "loitering", loc, "dog_cat") + ), + supported=lambda api, ch, loc: ( + api.supported(ch, "ai_linger") + and "dog_cat" in api.baichuan.smart_ai_type_list(ch, "loitering", loc) + ), + ), + ReolinkSmartAIBinarySensorEntityDescription( + key="forgotten_item", + smart_type="legacy", + cmd_id=33, + translation_key="forgotten_item", + value=lambda api, ch, loc: (api.baichuan.smart_ai_state(ch, "legacy", loc)), + supported=lambda api, ch, loc: api.supported(ch, "ai_forgotten_item"), + ), + ReolinkSmartAIBinarySensorEntityDescription( + key="taken_item", + smart_type="loss", + cmd_id=33, + translation_key="taken_item", + value=lambda api, ch, loc: (api.baichuan.smart_ai_state(ch, "loss", loc)), + supported=lambda api, ch, loc: api.supported(ch, "ai_taken_item"), + ), +) + async def async_setup_entry( hass: HomeAssistant, @@ -129,18 +281,29 @@ async def async_setup_entry( ) -> None: """Set up a Reolink IP Camera.""" reolink_data: ReolinkData = config_entry.runtime_data + api = reolink_data.host.api - entities: list[ReolinkBinarySensorEntity] = [] - for channel in reolink_data.host.api.channels: + entities: list[ReolinkBinarySensorEntity | ReolinkSmartAIBinarySensorEntity] = [] + for channel in api.channels: entities.extend( ReolinkPushBinarySensorEntity(reolink_data, channel, entity_description) for entity_description in BINARY_PUSH_SENSORS - if entity_description.supported(reolink_data.host.api, channel) + if entity_description.supported(api, channel) ) entities.extend( ReolinkBinarySensorEntity(reolink_data, channel, entity_description) for entity_description in BINARY_SENSORS - if entity_description.supported(reolink_data.host.api, channel) + if entity_description.supported(api, channel) + ) + entities.extend( + ReolinkSmartAIBinarySensorEntity( + reolink_data, channel, location, entity_description + ) + for entity_description in BINARY_SMART_AI_SENSORS + for location in api.baichuan.smart_location_list( + channel, entity_description.key + ) + if entity_description.supported(api, channel, location) ) async_add_entities(entities) @@ -198,3 +361,40 @@ class ReolinkPushBinarySensorEntity(ReolinkBinarySensorEntity): async def _async_handle_event(self, event: str) -> None: """Handle incoming event for motion detection.""" self.async_write_ha_state() + + +class ReolinkSmartAIBinarySensorEntity( + ReolinkChannelCoordinatorEntity, BinarySensorEntity +): + """Binary-sensor class for Reolink IP camera Smart AI sensors.""" + + entity_description: ReolinkSmartAIBinarySensorEntityDescription + + def __init__( + self, + reolink_data: ReolinkData, + channel: int, + location: int, + entity_description: ReolinkSmartAIBinarySensorEntityDescription, + ) -> None: + """Initialize Reolink binary sensor.""" + self.entity_description = entity_description + super().__init__(reolink_data, channel) + unique_index = self._host.api.baichuan.smart_ai_index( + channel, entity_description.smart_type, location + ) + self._attr_unique_id = f"{self._attr_unique_id}_{unique_index}" + + self._location = location + self._attr_translation_placeholders = { + "zone_name": self._host.api.baichuan.smart_ai_name( + channel, entity_description.smart_type, location + ) + } + + @property + def is_on(self) -> bool: + """State of the sensor.""" + return self.entity_description.value( + self._host.api, self._channel, self._location + ) diff --git a/homeassistant/components/reolink/icons.json b/homeassistant/components/reolink/icons.json index 26198a11594..0b019277a77 100644 --- a/homeassistant/components/reolink/icons.json +++ b/homeassistant/components/reolink/icons.json @@ -54,6 +54,72 @@ "state": { "on": "mdi:sleep" } + }, + "crossline_person": { + "default": "mdi:fence", + "state": { + "on": "mdi:fence-electric" + } + }, + "crossline_vehicle": { + "default": "mdi:fence", + "state": { + "on": "mdi:fence-electric" + } + }, + "crossline_dog_cat": { + "default": "mdi:fence", + "state": { + "on": "mdi:fence-electric" + } + }, + "intrusion_person": { + "default": "mdi:location-enter", + "state": { + "on": "mdi:alert-circle-outline" + } + }, + "intrusion_vehicle": { + "default": "mdi:location-enter", + "state": { + "on": "mdi:alert-circle-outline" + } + }, + "intrusion_dog_cat": { + "default": "mdi:location-enter", + "state": { + "on": "mdi:alert-circle-outline" + } + }, + "linger_person": { + "default": "mdi:account-switch", + "state": { + "on": "mdi:account-alert" + } + }, + "linger_vehicle": { + "default": "mdi:account-switch", + "state": { + "on": "mdi:account-alert" + } + }, + "linger_dog_cat": { + "default": "mdi:account-switch", + "state": { + "on": "mdi:account-alert" + } + }, + "forgotten_item": { + "default": "mdi:package-variant-closed-plus", + "state": { + "on": "mdi:package-variant-closed-check" + } + }, + "taken_item": { + "default": "mdi:package-variant-closed-minus", + "state": { + "on": "mdi:package-variant-closed-check" + } } }, "button": { diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index daa87fb401c..a22c93611b6 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -337,6 +337,83 @@ "off": "Awake", "on": "Sleeping" } + }, + "crossline_person": { + "name": "Crossline {zone_name} person", + "state": { + "off": "[%key:component::binary_sensor::entity_component::gas::state::off%]", + "on": "[%key:component::binary_sensor::entity_component::gas::state::on%]" + } + }, + "crossline_vehicle": { + "name": "Crossline {zone_name} vehicle", + "state": { + "off": "[%key:component::binary_sensor::entity_component::gas::state::off%]", + "on": "[%key:component::binary_sensor::entity_component::gas::state::on%]" + } + }, + "crossline_dog_cat": { + "name": "Crossline {zone_name} animal", + "state": { + "off": "[%key:component::binary_sensor::entity_component::gas::state::off%]", + "on": "[%key:component::binary_sensor::entity_component::gas::state::on%]" + } + }, + "intrusion_person": { + "name": "Intrusion {zone_name} person", + "state": { + "off": "[%key:component::binary_sensor::entity_component::gas::state::off%]", + "on": "[%key:component::binary_sensor::entity_component::gas::state::on%]" + } + }, + "intrusion_vehicle": { + "name": "Intrusion {zone_name} vehicle", + "state": { + "off": "[%key:component::binary_sensor::entity_component::gas::state::off%]", + "on": "[%key:component::binary_sensor::entity_component::gas::state::on%]" + } + }, + "intrusion_dog_cat": { + "name": "Intrusion {zone_name} animal", + "state": { + "off": "[%key:component::binary_sensor::entity_component::gas::state::off%]", + "on": "[%key:component::binary_sensor::entity_component::gas::state::on%]" + } + }, + "linger_person": { + "name": "Linger {zone_name} person", + "state": { + "off": "[%key:component::binary_sensor::entity_component::gas::state::off%]", + "on": "[%key:component::binary_sensor::entity_component::gas::state::on%]" + } + }, + "linger_vehicle": { + "name": "Linger {zone_name} vehicle", + "state": { + "off": "[%key:component::binary_sensor::entity_component::gas::state::off%]", + "on": "[%key:component::binary_sensor::entity_component::gas::state::on%]" + } + }, + "linger_dog_cat": { + "name": "Linger {zone_name} animal", + "state": { + "off": "[%key:component::binary_sensor::entity_component::gas::state::off%]", + "on": "[%key:component::binary_sensor::entity_component::gas::state::on%]" + } + }, + "forgotten_item": { + "name": "Item forgotten {zone_name}", + "state": { + "off": "[%key:component::binary_sensor::entity_component::gas::state::off%]", + "on": "[%key:component::binary_sensor::entity_component::gas::state::on%]" + } + }, + "taken_item": { + "name": "Item taken {zone_name}", + "state": { + "off": "[%key:component::binary_sensor::entity_component::gas::state::off%]", + "on": "[%key:component::binary_sensor::entity_component::gas::state::on%]" + } } }, "button": { diff --git a/tests/components/reolink/conftest.py b/tests/components/reolink/conftest.py index 293103e7eb2..cd793b9b620 100644 --- a/tests/components/reolink/conftest.py +++ b/tests/components/reolink/conftest.py @@ -146,6 +146,10 @@ def reolink_connect_class() -> Generator[MagicMock]: 0: {"chnID": 0, "aitype": 34615}, "Host": {"pushAlarm": 7}, } + host_mock.baichuan.smart_location_list.return_value = [0] + host_mock.baichuan.smart_ai_type_list.return_value = ["people"] + host_mock.baichuan.smart_ai_index.return_value = 1 + host_mock.baichuan.smart_ai_name.return_value = "zone1" yield host_mock_class diff --git a/tests/components/reolink/test_binary_sensor.py b/tests/components/reolink/test_binary_sensor.py index 71318c27b25..99c9efba002 100644 --- a/tests/components/reolink/test_binary_sensor.py +++ b/tests/components/reolink/test_binary_sensor.py @@ -51,6 +51,32 @@ async def test_motion_sensor( assert hass.states.get(entity_id).state == STATE_ON +async def test_smart_ai_sensor( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + freezer: FrozenDateTimeFactory, + config_entry: MockConfigEntry, + reolink_connect: MagicMock, +) -> None: + """Test smart ai binary sensor entity.""" + reolink_connect.model = TEST_HOST_MODEL + reolink_connect.baichuan.smart_ai_state.return_value = True + with patch("homeassistant.components.reolink.PLATFORMS", [Platform.BINARY_SENSOR]): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.LOADED + + entity_id = f"{Platform.BINARY_SENSOR}.{TEST_NVR_NAME}_crossline_zone1_person" + assert hass.states.get(entity_id).state == STATE_ON + + reolink_connect.baichuan.smart_ai_state.return_value = False + freezer.tick(DEVICE_UPDATE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get(entity_id).state == STATE_OFF + + async def test_tcp_callback( hass: HomeAssistant, config_entry: MockConfigEntry, From 9b57a831f78a22a4df3e3d923045c456a320e1e1 Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Mon, 17 Mar 2025 17:33:11 +0200 Subject: [PATCH 2698/3148] Fix Shelly Air lamp life sensor (#140799) --- homeassistant/components/shelly/sensor.py | 5 +++-- homeassistant/components/shelly/utils.py | 9 ++++++++ tests/components/shelly/conftest.py | 2 ++ tests/components/shelly/test_sensor.py | 27 +++++++++++++++++++++++ 4 files changed, 41 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/shelly/sensor.py b/homeassistant/components/shelly/sensor.py index 0020c6e0614..f2c858aeb84 100644 --- a/homeassistant/components/shelly/sensor.py +++ b/homeassistant/components/shelly/sensor.py @@ -39,7 +39,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_registry import RegistryEntry from homeassistant.helpers.typing import StateType -from .const import CONF_SLEEP_PERIOD, ROLE_TO_DEVICE_CLASS_MAP, SHAIR_MAX_WORK_HOURS +from .const import CONF_SLEEP_PERIOD, ROLE_TO_DEVICE_CLASS_MAP from .coordinator import ShellyBlockCoordinator, ShellyConfigEntry, ShellyRpcCoordinator from .entity import ( BlockEntityDescription, @@ -58,6 +58,7 @@ from .utils import ( async_remove_orphaned_entities, get_device_entry_gen, get_device_uptime, + get_shelly_air_lamp_life, get_virtual_component_ids, is_rpc_wifi_stations_disabled, ) @@ -355,7 +356,7 @@ SENSORS: dict[tuple[str, str], BlockSensorDescription] = { name="Lamp life", native_unit_of_measurement=PERCENTAGE, translation_key="lamp_life", - value=lambda value: 100 - (value / 3600 / SHAIR_MAX_WORK_HOURS), + value=get_shelly_air_lamp_life, suggested_display_precision=1, extra_state_attributes=lambda block: { "Operational hours": round(cast(int, block.totalWorkTime) / 3600, 1) diff --git a/homeassistant/components/shelly/utils.py b/homeassistant/components/shelly/utils.py index 626cb287f64..19897dbb185 100644 --- a/homeassistant/components/shelly/utils.py +++ b/homeassistant/components/shelly/utils.py @@ -59,6 +59,7 @@ from .const import ( GEN2_RELEASE_URL, LOGGER, RPC_INPUTS_EVENTS_TYPES, + SHAIR_MAX_WORK_HOURS, SHBTN_INPUTS_EVENTS_TYPES, SHBTN_MODELS, SHELLY_EMIT_EVENT_PATTERN, @@ -655,3 +656,11 @@ def is_rpc_exclude_from_relay( return True return is_rpc_channel_type_light(settings, ch) + + +def get_shelly_air_lamp_life(lamp_seconds: int) -> float: + """Return Shelly Air lamp life in percentage.""" + lamp_hours = lamp_seconds / 3600 + if lamp_hours >= SHAIR_MAX_WORK_HOURS: + return 0.0 + return 100 * (1 - lamp_hours / SHAIR_MAX_WORK_HOURS) diff --git a/tests/components/shelly/conftest.py b/tests/components/shelly/conftest.py index 5c0f912b72d..c68d52526c5 100644 --- a/tests/components/shelly/conftest.py +++ b/tests/components/shelly/conftest.py @@ -102,12 +102,14 @@ MOCK_BLOCKS = [ "power": 53.4, "energy": 1234567.89, "output": True, + "totalWorkTime": 3600, }, channel="0", type="relay", overpower=0, power=53.4, energy=1234567.89, + totalWorkTime=3600, description="relay_0", set_state=AsyncMock(side_effect=lambda turn: {"ison": turn == "on"}), ), diff --git a/tests/components/shelly/test_sensor.py b/tests/components/shelly/test_sensor.py index d37a146e314..00db4ade8ac 100644 --- a/tests/components/shelly/test_sensor.py +++ b/tests/components/shelly/test_sensor.py @@ -374,6 +374,33 @@ async def test_block_sensor_unknown_value( assert hass.states.get(entity_id).state == STATE_UNKNOWN +@pytest.mark.parametrize( + ("lamp_life_seconds", "percentage"), + [ + (0 * 3600, "100.0"), # 0 hours, 100% remaining + (16 * 3600, "99.8222222222222"), + (4500 * 3600, "50.0"), # 4500 hours, 50% remaining + (9000 * 3600, "0.0"), # 9000 hours, 0% remaining + (10000 * 3600, "0.0"), # > 9000 hours, 0% remaining + ], +) +async def test_block_shelly_air_lamp_life( + hass: HomeAssistant, + mock_block_device: Mock, + monkeypatch: pytest.MonkeyPatch, + lamp_life_seconds: int, + percentage: float, +) -> None: + """Test block Shelly Air lamp life percentage sensor.""" + entity_id = f"{SENSOR_DOMAIN}.{'test_name_channel_1_lamp_life'}" + monkeypatch.setattr( + mock_block_device.blocks[RELAY_BLOCK_ID], "totalWorkTime", lamp_life_seconds + ) + await init_integration(hass, 1) + + assert hass.states.get(entity_id).state == percentage + + async def test_rpc_sensor( hass: HomeAssistant, mock_rpc_device: Mock, monkeypatch: pytest.MonkeyPatch ) -> None: From a252c19e7c26c80ee24d3c1e1b09c3f5c231f4bf Mon Sep 17 00:00:00 2001 From: Thomas55555 <59625598+Thomas55555@users.noreply.github.com> Date: Mon, 17 Mar 2025 16:57:03 +0100 Subject: [PATCH 2699/3148] Use MowerDictionary in Husqvarna Automower (#140805) --- .../husqvarna_automower/coordinator.py | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/husqvarna_automower/coordinator.py b/homeassistant/components/husqvarna_automower/coordinator.py index 819ee41a43d..9456074596a 100644 --- a/homeassistant/components/husqvarna_automower/coordinator.py +++ b/homeassistant/components/husqvarna_automower/coordinator.py @@ -13,7 +13,7 @@ from aioautomower.exceptions import ( HusqvarnaTimeoutError, HusqvarnaWSServerHandshakeError, ) -from aioautomower.model import MowerAttributes +from aioautomower.model import MowerDictionary from aioautomower.session import AutomowerSession from homeassistant.config_entries import ConfigEntry @@ -32,7 +32,7 @@ DEFAULT_RECONNECT_TIME = 2 # Define a default reconnect time type AutomowerConfigEntry = ConfigEntry[AutomowerDataUpdateCoordinator] -class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[dict[str, MowerAttributes]]): +class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[MowerDictionary]): """Class to manage fetching Husqvarna data.""" config_entry: AutomowerConfigEntry @@ -61,7 +61,7 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[dict[str, MowerAttrib self._zones_last_update: dict[str, set[str]] = {} self._areas_last_update: dict[str, set[int]] = {} - async def _async_update_data(self) -> dict[str, MowerAttributes]: + async def _async_update_data(self) -> MowerDictionary: """Subscribe for websocket and poll data from the API.""" if not self.ws_connected: await self.api.connect() @@ -84,7 +84,7 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[dict[str, MowerAttrib return data @callback - def callback(self, ws_data: dict[str, MowerAttributes]) -> None: + def callback(self, ws_data: MowerDictionary) -> None: """Process websocket callbacks and write them to the DataUpdateCoordinator.""" self.async_set_updated_data(ws_data) @@ -119,7 +119,7 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[dict[str, MowerAttrib "reconnect_task", ) - def _async_add_remove_devices(self, data: dict[str, MowerAttributes]) -> None: + def _async_add_remove_devices(self, data: MowerDictionary) -> None: """Add new device, remove non-existing device.""" current_devices = set(data) @@ -159,9 +159,7 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[dict[str, MowerAttrib for mower_callback in self.new_devices_callbacks: mower_callback(new_devices) - def _async_add_remove_stay_out_zones( - self, data: dict[str, MowerAttributes] - ) -> None: + def _async_add_remove_stay_out_zones(self, data: MowerDictionary) -> None: """Add new stay-out zones, remove non-existing stay-out zones.""" current_zones = { mower_id: set(mower_data.stay_out_zones.zones) @@ -207,7 +205,7 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[dict[str, MowerAttrib return current_zones - def _async_add_remove_work_areas(self, data: dict[str, MowerAttributes]) -> None: + def _async_add_remove_work_areas(self, data: MowerDictionary) -> None: """Add new work areas, remove non-existing work areas.""" current_areas = { mower_id: set(mower_data.work_areas) From f4787d469a9559a2be3b74ffce85bf25e4eae4bf Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Mon, 17 Mar 2025 17:27:01 +0100 Subject: [PATCH 2700/3148] Remove Shelly extra_attributes for RPC & REST devices (#140792) * Remove Shelly extra_attributes for RPC devices * apply review comment --- homeassistant/components/shelly/entity.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/homeassistant/components/shelly/entity.py b/homeassistant/components/shelly/entity.py index 001727c74b3..58ac34fc5ca 100644 --- a/homeassistant/components/shelly/entity.py +++ b/homeassistant/components/shelly/entity.py @@ -296,7 +296,6 @@ class RpcEntityDescription(EntityDescription): value: Callable[[Any, Any], Any] | None = None available: Callable[[dict], bool] | None = None removal_condition: Callable[[dict, dict, str], bool] | None = None - extra_state_attributes: Callable[[dict, dict], dict | None] | None = None use_polling_coordinator: bool = False supported: Callable = lambda _: False unit: Callable[[dict], str | None] | None = None @@ -313,7 +312,6 @@ class RestEntityDescription(EntityDescription): name: str = "" value: Callable[[dict, Any], Any] | None = None - extra_state_attributes: Callable[[dict], dict | None] | None = None class ShellyBlockEntity(CoordinatorEntity[ShellyBlockCoordinator]): From 9a0837593a452d32584f2309cf8328b6e15d0730 Mon Sep 17 00:00:00 2001 From: Thomas55555 <59625598+Thomas55555@users.noreply.github.com> Date: Mon, 17 Mar 2025 17:38:40 +0100 Subject: [PATCH 2701/3148] Improve test coverage and add comment for loading in executor for remote calendar (#140807) Improve calendar loading by executing in a separate thread and add test for CalendarParseError --- .../components/remote_calendar/coordinator.py | 3 +++ tests/components/remote_calendar/test_init.py | 13 +++++++++++++ 2 files changed, 16 insertions(+) diff --git a/homeassistant/components/remote_calendar/coordinator.py b/homeassistant/components/remote_calendar/coordinator.py index 7ee95695e61..7f29f7e2ea8 100644 --- a/homeassistant/components/remote_calendar/coordinator.py +++ b/homeassistant/components/remote_calendar/coordinator.py @@ -56,6 +56,9 @@ class RemoteCalendarDataUpdateCoordinator(DataUpdateCoordinator[Calendar]): translation_placeholders={"err": str(err)}, ) from err try: + # calendar_from_ics will dynamically load packages + # the first time it is called, so we need to do it + # in a separate thread to avoid blocking the event loop return await self.hass.async_add_executor_job( IcsCalendarStream.calendar_from_ics, res.text ) diff --git a/tests/components/remote_calendar/test_init.py b/tests/components/remote_calendar/test_init.py index 08f5c8b45c0..f4ca500b2e1 100644 --- a/tests/components/remote_calendar/test_init.py +++ b/tests/components/remote_calendar/test_init.py @@ -71,3 +71,16 @@ async def test_update_failed( respx.get(CALENDER_URL).mock(side_effect=side_effect) await setup_integration(hass, config_entry) assert config_entry.state is ConfigEntryState.SETUP_RETRY + + +@respx.mock +async def test_calendar_parse_error( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test CalendarParseError using respx.""" + respx.get(CALENDER_URL).mock( + return_value=Response(status_code=200, text="not a calendar") + ) + await setup_integration(hass, config_entry) + assert config_entry.state is ConfigEntryState.SETUP_RETRY From a2fec8c2ce7949c7aecfe02f9e2706fc50a96b12 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 17 Mar 2025 18:21:56 +0100 Subject: [PATCH 2702/3148] Fix inconsistent capitalization in `growatt_server` entities (#140803) * Fix inconsistent capitalization in `growatt_server` entities * Makes "amperage" and "wattage" consistent (with "voltage") --- .../components/growatt_server/strings.json | 40 +++++++++---------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/growatt_server/strings.json b/homeassistant/components/growatt_server/strings.json index 9a985d98034..758428d7a55 100644 --- a/homeassistant/components/growatt_server/strings.json +++ b/homeassistant/components/growatt_server/strings.json @@ -38,28 +38,28 @@ "name": "Input 1 voltage" }, "inverter_amperage_input_1": { - "name": "Input 1 Amperage" + "name": "Input 1 amperage" }, "inverter_wattage_input_1": { - "name": "Input 1 Wattage" + "name": "Input 1 wattage" }, "inverter_voltage_input_2": { "name": "Input 2 voltage" }, "inverter_amperage_input_2": { - "name": "Input 2 Amperage" + "name": "Input 2 amperage" }, "inverter_wattage_input_2": { - "name": "Input 2 Wattage" + "name": "Input 2 wattage" }, "inverter_voltage_input_3": { "name": "Input 3 voltage" }, "inverter_amperage_input_3": { - "name": "Input 3 Amperage" + "name": "Input 3 amperage" }, "inverter_wattage_input_3": { - "name": "Input 3 Wattage" + "name": "Input 3 wattage" }, "inverter_internal_wattage": { "name": "Internal wattage" @@ -137,13 +137,13 @@ "name": "Load consumption" }, "mix_wattage_pv_1": { - "name": "PV1 Wattage" + "name": "PV1 wattage" }, "mix_wattage_pv_2": { - "name": "PV2 Wattage" + "name": "PV2 wattage" }, "mix_wattage_pv_all": { - "name": "All PV Wattage" + "name": "All PV wattage" }, "mix_export_to_grid": { "name": "Export to grid" @@ -182,7 +182,7 @@ "name": "Storage production today" }, "storage_storage_production_lifetime": { - "name": "Lifetime Storage production" + "name": "Lifetime storage production" }, "storage_grid_discharge_today": { "name": "Grid discharged today" @@ -224,7 +224,7 @@ "name": "Storage charging/ discharging(-ve)" }, "storage_load_consumption_solar_storage": { - "name": "Load consumption (Solar + Storage)" + "name": "Load consumption (solar + storage)" }, "storage_charge_today": { "name": "Charge today" @@ -257,7 +257,7 @@ "name": "Output voltage" }, "storage_ac_output_frequency": { - "name": "Ac output frequency" + "name": "AC output frequency" }, "storage_current_pv": { "name": "Solar charge current" @@ -290,7 +290,7 @@ "name": "Lifetime total energy input 1" }, "tlx_energy_today_input_1": { - "name": "Energy Today Input 1" + "name": "Energy today input 1" }, "tlx_voltage_input_1": { "name": "[%key:component::growatt_server::entity::sensor::inverter_voltage_input_1::name%]" @@ -305,7 +305,7 @@ "name": "Lifetime total energy input 2" }, "tlx_energy_today_input_2": { - "name": "Energy Today Input 2" + "name": "Energy today input 2" }, "tlx_voltage_input_2": { "name": "[%key:component::growatt_server::entity::sensor::inverter_voltage_input_2::name%]" @@ -320,7 +320,7 @@ "name": "Lifetime total energy input 3" }, "tlx_energy_today_input_3": { - "name": "Energy Today Input 3" + "name": "Energy today input 3" }, "tlx_voltage_input_3": { "name": "[%key:component::growatt_server::entity::sensor::inverter_voltage_input_3::name%]" @@ -335,16 +335,16 @@ "name": "Lifetime total energy input 4" }, "tlx_energy_today_input_4": { - "name": "Energy Today Input 4" + "name": "Energy today input 4" }, "tlx_voltage_input_4": { "name": "Input 4 voltage" }, "tlx_amperage_input_4": { - "name": "Input 4 Amperage" + "name": "Input 4 amperage" }, "tlx_wattage_input_4": { - "name": "Input 4 Wattage" + "name": "Input 4 wattage" }, "tlx_solar_generation_total": { "name": "Lifetime total solar energy" @@ -434,10 +434,10 @@ "name": "Money lifetime" }, "total_energy_today": { - "name": "Energy Today" + "name": "Energy today" }, "total_output_power": { - "name": "Output Power" + "name": "Output power" }, "total_energy_output": { "name": "[%key:component::growatt_server::entity::sensor::inverter_energy_total::name%]" From e16f0e9af3de4ac8fa374bd39c4672c8888d4a95 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 17 Mar 2025 19:03:05 +0100 Subject: [PATCH 2703/3148] Clarify action descriptions of `smarttub.snooze_reminder` / `reset_reminder` (#140810) - change both descriptions to descriptive HA style - change "reminder" to "maintenance reminder" (helps translators a lot) - use more of the wording from the online documentation --- homeassistant/components/smarttub/strings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/smarttub/strings.json b/homeassistant/components/smarttub/strings.json index 974e5fb7d37..79fa7a4820f 100644 --- a/homeassistant/components/smarttub/strings.json +++ b/homeassistant/components/smarttub/strings.json @@ -49,17 +49,17 @@ }, "snooze_reminder": { "name": "Snooze a reminder", - "description": "Delay a reminder, so that it won't trigger again for a period of time.", + "description": "Temporarily suppresses the maintenance reminder on a hot tub.", "fields": { "days": { "name": "Days", - "description": "The number of days to delay the reminder." + "description": "The number of days to snooze the reminder." } } }, "reset_reminder": { "name": "Reset a reminder", - "description": "Reset a reminder, and set the next time it will be triggered.", + "description": "Resets the maintenance reminder on a hot tub.", "fields": { "days": { "name": "[%key:component::smarttub::services::snooze_reminder::fields::days::name%]", From 290dab25bf1a256ed972609ca000e2ac8b21c942 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 17 Mar 2025 20:04:30 +0100 Subject: [PATCH 2704/3148] Don't raise in ConfigFlow.async_set_unique_id if the other flow is a reauth flow (#140723) * Don't raise in ConfigFlow.async_set_unique_id if the other flow is a reauth flow * Improve test --- homeassistant/config_entries.py | 7 +++- tests/test_config_entries.py | 70 ++++++++++++++++++++++++++++++--- 2 files changed, 70 insertions(+), 7 deletions(-) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index bfea2c29eac..9336ead633a 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -2986,8 +2986,11 @@ class ConfigFlow(ConfigEntryBaseFlow): return None if raise_on_progress: - if self._async_in_progress( - include_uninitialized=True, match_context={"unique_id": unique_id} + if any( + flow["context"]["source"] != SOURCE_REAUTH + for flow in self._async_in_progress( + include_uninitialized=True, match_context={"unique_id": unique_id} + ) ): raise data_entry_flow.AbortFlow("already_in_progress") diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index d19c3b38650..788225365e0 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -3566,37 +3566,97 @@ async def test_unique_id_not_update_existing_entry( assert len(async_reload.mock_calls) == 0 +ABORT_IN_PROGRESS = { + "type": data_entry_flow.FlowResultType.ABORT, + "reason": "already_in_progress", +} + + +@pytest.mark.parametrize( + ("existing_flow_source", "expected_result"), + # Test all sources except SOURCE_IGNORE + [ + (config_entries.SOURCE_BLUETOOTH, ABORT_IN_PROGRESS), + (config_entries.SOURCE_DHCP, ABORT_IN_PROGRESS), + (config_entries.SOURCE_DISCOVERY, ABORT_IN_PROGRESS), + (config_entries.SOURCE_HARDWARE, ABORT_IN_PROGRESS), + (config_entries.SOURCE_HASSIO, ABORT_IN_PROGRESS), + (config_entries.SOURCE_HOMEKIT, ABORT_IN_PROGRESS), + (config_entries.SOURCE_IMPORT, ABORT_IN_PROGRESS), + (config_entries.SOURCE_INTEGRATION_DISCOVERY, ABORT_IN_PROGRESS), + (config_entries.SOURCE_MQTT, ABORT_IN_PROGRESS), + (config_entries.SOURCE_REAUTH, {"type": data_entry_flow.FlowResultType.FORM}), + (config_entries.SOURCE_RECONFIGURE, ABORT_IN_PROGRESS), + (config_entries.SOURCE_SSDP, ABORT_IN_PROGRESS), + (config_entries.SOURCE_SYSTEM, ABORT_IN_PROGRESS), + (config_entries.SOURCE_USB, ABORT_IN_PROGRESS), + (config_entries.SOURCE_USER, ABORT_IN_PROGRESS), + (config_entries.SOURCE_ZEROCONF, ABORT_IN_PROGRESS), + ], +) async def test_unique_id_in_progress( - hass: HomeAssistant, manager: config_entries.ConfigEntries + hass: HomeAssistant, + manager: config_entries.ConfigEntries, + existing_flow_source: str, + expected_result: dict, ) -> None: """Test that we abort if there is already a flow in progress with same unique id.""" mock_integration(hass, MockModule("comp")) mock_platform(hass, "comp.config_flow", None) + entry = MockConfigEntry(domain="comp") + entry.add_to_hass(hass) class TestFlow(config_entries.ConfigFlow): """Test flow.""" VERSION = 1 + async def _async_step_discovery_without_unique_id(self): + """Handle a flow initialized by discovery.""" + return await self._async_step() + + async def async_step_hardware(self, user_input=None): + """Test hardware step.""" + return await self._async_step() + + async def async_step_import(self, user_input=None): + """Test import step.""" + return await self._async_step() + + async def async_step_reauth(self, user_input=None): + """Test reauth step.""" + return await self._async_step() + + async def async_step_reconfigure(self, user_input=None): + """Test reconfigure step.""" + return await self._async_step() + + async def async_step_system(self, user_input=None): + """Test system step.""" + return await self._async_step() + async def async_step_user(self, user_input=None): """Test user step.""" + return await self._async_step() + + async def _async_step(self, user_input=None): + """Test step.""" await self.async_set_unique_id("mock-unique-id") return self.async_show_form(step_id="discovery") with mock_config_flow("comp", TestFlow): # Create one to be in progress result = await manager.flow.async_init( - "comp", context={"source": config_entries.SOURCE_USER} + "comp", context={"source": existing_flow_source, "entry_id": entry.entry_id} ) assert result["type"] == data_entry_flow.FlowResultType.FORM - # Will be canceled result2 = await manager.flow.async_init( "comp", context={"source": config_entries.SOURCE_USER} ) - assert result2["type"] == data_entry_flow.FlowResultType.ABORT - assert result2["reason"] == "already_in_progress" + for k, v in expected_result.items(): + assert result2[k] == v async def test_finish_flow_aborts_progress( From 4dfb56a2f74d16554cb2ab11ae5686e979b6b808 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Mon, 17 Mar 2025 20:06:49 +0100 Subject: [PATCH 2705/3148] Bump reolink-aio to 0.12.3b1 (#140811) --- homeassistant/components/reolink/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json index c07d63c184c..0cb5eb3e13c 100644 --- a/homeassistant/components/reolink/manifest.json +++ b/homeassistant/components/reolink/manifest.json @@ -19,5 +19,5 @@ "iot_class": "local_push", "loggers": ["reolink_aio"], "quality_scale": "platinum", - "requirements": ["reolink-aio==0.12.2"] + "requirements": ["reolink-aio==0.12.3b1"] } diff --git a/requirements_all.txt b/requirements_all.txt index ae0f6114b0e..76f8cbb46dc 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2622,7 +2622,7 @@ renault-api==0.2.9 renson-endura-delta==1.7.2 # homeassistant.components.reolink -reolink-aio==0.12.2 +reolink-aio==0.12.3b1 # homeassistant.components.idteck_prox rfk101py==0.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 48dbb5deae6..b8e265df455 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2122,7 +2122,7 @@ renault-api==0.2.9 renson-endura-delta==1.7.2 # homeassistant.components.reolink -reolink-aio==0.12.2 +reolink-aio==0.12.3b1 # homeassistant.components.rflink rflink==0.0.66 From 52d86ede3ecb4311a01bfbe68f2a0f437fd9202f Mon Sep 17 00:00:00 2001 From: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> Date: Mon, 17 Mar 2025 14:10:56 -0500 Subject: [PATCH 2706/3148] Add ability to browse (and play) HEOS media (#140433) * Add browse and play * Tests * Add tests involving media source --- homeassistant/components/heos/media_player.py | 133 ++++++++- homeassistant/components/heos/strings.json | 3 + tests/components/heos/__init__.py | 21 +- tests/components/heos/conftest.py | 48 +++- .../heos/snapshots/test_media_player.ambr | 140 +++++++++ tests/components/heos/test_media_player.py | 267 ++++++++++++++++++ 6 files changed, 602 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/heos/media_player.py b/homeassistant/components/heos/media_player.py index 9edc674d1cf..5c0a66a02fa 100644 --- a/homeassistant/components/heos/media_player.py +++ b/homeassistant/components/heos/media_player.py @@ -3,27 +3,35 @@ from __future__ import annotations from collections.abc import Awaitable, Callable, Coroutine, Sequence +from contextlib import suppress from datetime import datetime from functools import reduce, wraps +import logging from operator import ior -from typing import Any +from typing import Any, Final from pyheos import ( AddCriteriaType, ControlType, HeosError, HeosPlayer, + MediaItem, + MediaMusicSource, + MediaType as HeosMediaType, PlayState, RepeatType, const as heos_const, ) +from pyheos.util import mediauri as heos_source import voluptuous as vol from homeassistant.components import media_source from homeassistant.components.media_player import ( ATTR_MEDIA_ENQUEUE, ATTR_MEDIA_VOLUME_LEVEL, + BrowseError, BrowseMedia, + MediaClass, MediaPlayerEnqueue, MediaPlayerEntity, MediaPlayerEntityFeature, @@ -32,6 +40,7 @@ from homeassistant.components.media_player import ( RepeatMode, async_process_play_media_url, ) +from homeassistant.components.media_source import BrowseMediaSource from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError, ServiceValidationError @@ -55,6 +64,8 @@ from .coordinator import HeosConfigEntry, HeosCoordinator PARALLEL_UPDATES = 0 +BROWSE_ROOT: Final = "heos://media" + BASE_SUPPORTED_FEATURES = ( MediaPlayerEntityFeature.VOLUME_MUTE | MediaPlayerEntityFeature.VOLUME_SET @@ -97,6 +108,21 @@ HEOS_HA_REPEAT_TYPE_MAP = { } HA_HEOS_REPEAT_TYPE_MAP = {v: k for k, v in HEOS_HA_REPEAT_TYPE_MAP.items()} +HEOS_MEDIA_TYPE_TO_MEDIA_CLASS = { + HeosMediaType.ALBUM: MediaClass.ALBUM, + HeosMediaType.ARTIST: MediaClass.ARTIST, + HeosMediaType.CONTAINER: MediaClass.DIRECTORY, + HeosMediaType.GENRE: MediaClass.GENRE, + HeosMediaType.HEOS_SERVER: MediaClass.DIRECTORY, + HeosMediaType.HEOS_SERVICE: MediaClass.DIRECTORY, + HeosMediaType.MUSIC_SERVICE: MediaClass.DIRECTORY, + HeosMediaType.PLAYLIST: MediaClass.PLAYLIST, + HeosMediaType.SONG: MediaClass.TRACK, + HeosMediaType.STATION: MediaClass.TRACK, +} + +_LOGGER = logging.getLogger(__name__) + async def async_setup_entry( hass: HomeAssistant, @@ -282,6 +308,16 @@ class HeosMediaPlayer(CoordinatorEntity[HeosCoordinator], MediaPlayerEntity): self, media_type: MediaType | str, media_id: str, **kwargs: Any ) -> None: """Play a piece of media.""" + if heos_source.is_media_uri(media_id): + media, data = heos_source.from_media_uri(media_id) + if not isinstance(media, MediaItem): + raise ValueError(f"Invalid media id '{media_id}'") + await self._player.play_media( + media, + HA_HEOS_ENQUEUE_MAP[kwargs.get(ATTR_MEDIA_ENQUEUE)], + ) + return + if media_source.is_media_source_id(media_id): media_type = MediaType.URL play_item = await media_source.async_resolve_media( @@ -534,14 +570,101 @@ class HeosMediaPlayer(CoordinatorEntity[HeosCoordinator], MediaPlayerEntity): """Volume level of the media player (0..1).""" return self._player.volume / 100 + async def _async_browse_media_root(self) -> BrowseMedia: + """Return media browsing root.""" + if not self.coordinator.heos.music_sources: + try: + await self.coordinator.heos.get_music_sources() + except HeosError as error: + _LOGGER.debug("Unable to load music sources: %s", error) + children: list[BrowseMedia] = [ + _media_to_browse_media(source) + for source in self.coordinator.heos.music_sources.values() + if source.available + ] + root = BrowseMedia( + title="Music Sources", + media_class=MediaClass.DIRECTORY, + children_media_class=MediaClass.DIRECTORY, + media_content_type="", + media_content_id=BROWSE_ROOT, + can_expand=True, + can_play=False, + children=children, + ) + # Append media source items + with suppress(BrowseError): + browse = await self._async_browse_media_source() + # If domain is None, it's an overview of available sources + if browse.domain is None and browse.children: + children.extend(browse.children) + else: + children.append(browse) + return root + + async def _async_browse_heos_media(self, media_content_id: str) -> BrowseMedia: + """Browse a HEOS media item.""" + media, data = heos_source.from_media_uri(media_content_id) + browse_media = _media_to_browse_media(media) + try: + browse_result = await self.coordinator.heos.browse_media(media) + except HeosError as error: + _LOGGER.debug("Unable to browse media %s: %s", media, error) + else: + browse_media.children = [ + _media_to_browse_media(item) + for item in browse_result.items + if item.browsable or item.playable + ] + return browse_media + + async def _async_browse_media_source( + self, media_content_id: str | None = None + ) -> BrowseMediaSource: + """Browse a media source item.""" + return await media_source.async_browse_media( + self.hass, + media_content_id, + content_filter=lambda item: item.media_content_type.startswith("audio/"), + ) + async def async_browse_media( self, media_content_type: MediaType | str | None = None, media_content_id: str | None = None, ) -> BrowseMedia: """Implement the websocket media browsing helper.""" - return await media_source.async_browse_media( - self.hass, - media_content_id, - content_filter=lambda item: item.media_content_type.startswith("audio/"), + if media_content_id in (None, BROWSE_ROOT): + return await self._async_browse_media_root() + assert media_content_id is not None + if heos_source.is_media_uri(media_content_id): + return await self._async_browse_heos_media(media_content_id) + if media_source.is_media_source_id(media_content_id): + return await self._async_browse_media_source(media_content_id) + raise ServiceValidationError( + translation_domain=HEOS_DOMAIN, + translation_key="unsupported_media_content_id", + translation_placeholders={"media_content_id": media_content_id}, ) + + +def _media_to_browse_media(media: MediaItem | MediaMusicSource) -> BrowseMedia: + """Convert a HEOS media item to a browse media item.""" + can_expand = False + can_play = False + + if isinstance(media, MediaMusicSource): + can_expand = media.available + else: + can_expand = media.browsable + can_play = media.playable + + return BrowseMedia( + can_expand=can_expand, + can_play=can_play, + media_content_id=heos_source.to_media_uri(media), + media_content_type="", + media_class=HEOS_MEDIA_TYPE_TO_MEDIA_CLASS[media.type], + title=media.name, + thumbnail=media.image_url, + ) diff --git a/homeassistant/components/heos/strings.json b/homeassistant/components/heos/strings.json index 340eecb9f8b..593c437accc 100644 --- a/homeassistant/components/heos/strings.json +++ b/homeassistant/components/heos/strings.json @@ -146,6 +146,9 @@ }, "unknown_source": { "message": "Unknown source: {source}" + }, + "unsupported_media_content_id": { + "message": "Unsupported media_content_id: {media_content_id}" } }, "issues": { diff --git a/tests/components/heos/__init__.py b/tests/components/heos/__init__.py index 862b1e5ffab..cb4313bbd10 100644 --- a/tests/components/heos/__init__.py +++ b/tests/components/heos/__init__.py @@ -2,7 +2,14 @@ from unittest.mock import AsyncMock -from pyheos import ConnectionState, Heos, HeosGroup, HeosOptions, HeosPlayer +from pyheos import ( + ConnectionState, + Heos, + HeosGroup, + HeosOptions, + HeosPlayer, + MediaMusicSource, +) class MockHeos(Heos): @@ -13,6 +20,7 @@ class MockHeos(Heos): super().__init__(options) # Overwrite the methods with async mocks, changing type self.add_to_queue: AsyncMock = AsyncMock() + self.browse_media: AsyncMock = AsyncMock() self.connect: AsyncMock = AsyncMock() self.disconnect: AsyncMock = AsyncMock() self.get_favorites: AsyncMock = AsyncMock() @@ -20,6 +28,7 @@ class MockHeos(Heos): self.get_input_sources: AsyncMock = AsyncMock() self.get_playlists: AsyncMock = AsyncMock() self.get_players: AsyncMock = AsyncMock() + self.get_music_sources: AsyncMock = AsyncMock() self.group_volume_down: AsyncMock = AsyncMock() self.group_volume_up: AsyncMock = AsyncMock() self.get_system_info: AsyncMock = AsyncMock() @@ -68,3 +77,13 @@ class MockHeos(Heos): def mock_set_current_host(self, host: str) -> None: """Set the current host on the mock instance.""" self._connection._host = host + + def mock_set_music_sources( + self, music_sources: dict[int, MediaMusicSource] + ) -> None: + """Set the music sources on the mock instance.""" + for music_source in music_sources.values(): + music_source.heos = self + self._music_sources = music_sources + self._music_sources_loaded = bool(music_sources) + self.get_music_sources.return_value = music_sources diff --git a/tests/components/heos/conftest.py b/tests/components/heos/conftest.py index 7bed05a0289..5d06d1812ea 100644 --- a/tests/components/heos/conftest.py +++ b/tests/components/heos/conftest.py @@ -6,6 +6,7 @@ from collections.abc import Callable, Iterator from unittest.mock import Mock, patch from pyheos import ( + BrowseResult, HeosGroup, HeosHost, HeosNowPlayingMedia, @@ -14,6 +15,7 @@ from pyheos import ( HeosSystem, LineOutLevelType, MediaItem, + MediaMusicSource, MediaType, NetworkType, PlayerUpdateResult, @@ -294,10 +296,10 @@ def quick_selects_fixture() -> dict[int, str]: } -@pytest.fixture(name="playlists") -def playlists_fixture() -> list[MediaItem]: - """Create favorites fixture.""" - playlist = MediaItem( +@pytest.fixture(name="playlist") +def playlist_fixture() -> MediaItem: + """Create playlist fixture.""" + return MediaItem( source_id=const.MUSIC_SOURCE_PLAYLISTS, name="Awesome Music", type=MediaType.PLAYLIST, @@ -306,6 +308,44 @@ def playlists_fixture() -> list[MediaItem]: image_url="", heos=None, ) + + +@pytest.fixture(name="music_sources") +def music_sources_fixture() -> dict[int, MediaMusicSource]: + """Create music sources fixture.""" + return { + const.MUSIC_SOURCE_PANDORA: MediaMusicSource( + source_id=const.MUSIC_SOURCE_PANDORA, + name="Pandora", + type=MediaType.MUSIC_SERVICE, + available=True, + service_username="user", + image_url="", + heos=None, + ), + const.MUSIC_SOURCE_TUNEIN: MediaMusicSource( + source_id=const.MUSIC_SOURCE_TUNEIN, + name="TuneIn", + type=MediaType.MUSIC_SERVICE, + available=False, + service_username=None, + image_url="", + heos=None, + ), + } + + +@pytest.fixture(name="pandora_browse_result") +def pandora_browse_response_fixture(favorites: dict[int, MediaItem]) -> BrowseResult: + """Create a mock response for browsing Pandora.""" + return BrowseResult( + 1, 1, const.MUSIC_SOURCE_PANDORA, items=[favorites[1]], options=[] + ) + + +@pytest.fixture(name="playlists") +def playlists_fixture(playlist: MediaItem) -> list[MediaItem]: + """Create playlists fixture.""" return [playlist] diff --git a/tests/components/heos/snapshots/test_media_player.ambr b/tests/components/heos/snapshots/test_media_player.ambr index 88d27f2073a..d2cd8b3e12a 100644 --- a/tests/components/heos/snapshots/test_media_player.ambr +++ b/tests/components/heos/snapshots/test_media_player.ambr @@ -1,4 +1,144 @@ # serializer version: 1 +# name: test_browse_media_heos_media + dict({ + 'can_expand': True, + 'can_play': False, + 'children': list([ + dict({ + 'can_expand': False, + 'can_play': True, + 'children_media_class': None, + 'media_class': 'track', + 'media_content_id': 'heos://media/1/station?name=Today%27s+Hits+Radio&image_url=&playable=True&browsable=False&media_id=123456789', + 'media_content_type': '', + 'thumbnail': '', + 'title': "Today's Hits Radio", + }), + ]), + 'children_media_class': 'track', + 'media_class': 'directory', + 'media_content_id': 'heos://media/1/music_service?name=Pandora&image_url=&available=True&service_username=user', + 'media_content_type': '', + 'not_shown': 0, + 'thumbnail': '', + 'title': 'Pandora', + }) +# --- +# name: test_browse_media_heos_media_error_returns_empty + dict({ + 'can_expand': True, + 'can_play': False, + 'children': list([ + ]), + 'children_media_class': None, + 'media_class': 'directory', + 'media_content_id': 'heos://media/1/music_service?name=Pandora&image_url=&available=True&service_username=user', + 'media_content_type': '', + 'not_shown': 0, + 'thumbnail': '', + 'title': 'Pandora', + }) +# --- +# name: test_browse_media_media_source + dict({ + 'can_expand': True, + 'can_play': False, + 'children': list([ + dict({ + 'can_expand': False, + 'can_play': True, + 'children_media_class': None, + 'media_class': 'music', + 'media_content_id': 'media-source://media_source/local/test.mp3', + 'media_content_type': 'audio/mpeg', + 'thumbnail': None, + 'title': 'test.mp3', + }), + ]), + 'children_media_class': 'music', + 'media_class': 'directory', + 'media_content_id': 'media-source://media_source/local/.', + 'media_content_type': '', + 'not_shown': 1, + 'thumbnail': None, + 'title': 'media', + }) +# --- +# name: test_browse_media_root + dict({ + 'can_expand': True, + 'can_play': False, + 'children': list([ + dict({ + 'can_expand': True, + 'can_play': False, + 'children_media_class': None, + 'media_class': 'directory', + 'media_content_id': 'heos://media/1/music_service?name=Pandora&image_url=&available=True&service_username=user', + 'media_content_type': '', + 'thumbnail': '', + 'title': 'Pandora', + }), + dict({ + 'can_expand': True, + 'can_play': False, + 'children_media_class': 'music', + 'media_class': 'directory', + 'media_content_id': 'media-source://media_source/local/.', + 'media_content_type': '', + 'thumbnail': None, + 'title': 'media', + }), + ]), + 'children_media_class': 'directory', + 'media_class': 'directory', + 'media_content_id': 'heos://media', + 'media_content_type': '', + 'not_shown': 0, + 'thumbnail': None, + 'title': 'Music Sources', + }) +# --- +# name: test_browse_media_root_no_media_source + dict({ + 'can_expand': True, + 'can_play': False, + 'children': list([ + dict({ + 'can_expand': True, + 'can_play': False, + 'children_media_class': None, + 'media_class': 'directory', + 'media_content_id': 'heos://media/1/music_service?name=Pandora&image_url=&available=True&service_username=user', + 'media_content_type': '', + 'thumbnail': '', + 'title': 'Pandora', + }), + ]), + 'children_media_class': 'directory', + 'media_class': 'directory', + 'media_content_id': 'heos://media', + 'media_content_type': '', + 'not_shown': 0, + 'thumbnail': None, + 'title': 'Music Sources', + }) +# --- +# name: test_browse_media_root_source_error_continues + dict({ + 'can_expand': True, + 'can_play': False, + 'children': list([ + ]), + 'children_media_class': 'directory', + 'media_class': 'directory', + 'media_content_id': 'heos://media', + 'media_content_type': '', + 'not_shown': 0, + 'thumbnail': None, + 'title': 'Music Sources', + }) +# --- # name: test_state_attributes StateSnapshot({ 'attributes': ReadOnlyDict({ diff --git a/tests/components/heos/test_media_player.py b/tests/components/heos/test_media_player.py index debfe31f427..d5bc8cab488 100644 --- a/tests/components/heos/test_media_player.py +++ b/tests/components/heos/test_media_player.py @@ -7,9 +7,11 @@ from typing import Any from freezegun.api import FrozenDateTimeFactory from pyheos import ( AddCriteriaType, + BrowseResult, CommandFailedError, HeosError, MediaItem, + MediaMusicSource, MediaType as HeosMediaType, PlayerUpdateResult, PlayState, @@ -18,6 +20,7 @@ from pyheos import ( SignalType, const, ) +from pyheos.util import mediauri import pytest from syrupy.assertion import SnapshotAssertion from syrupy.filters import props @@ -51,6 +54,7 @@ from homeassistant.components.media_player import ( MediaType, RepeatMode, ) +from homeassistant.components.media_source import DOMAIN as MS_DOMAIN from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_MEDIA_NEXT_TRACK, @@ -73,6 +77,8 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er from . import MockHeos from tests.common import MockConfigEntry, async_fire_time_changed +from tests.conftest import async_setup_component +from tests.typing import WebSocketGenerator async def test_state_attributes( @@ -1239,6 +1245,267 @@ async def test_play_media_invalid_type( ) +async def test_play_media_media_uri( + hass: HomeAssistant, + config_entry: MockConfigEntry, + controller: MockHeos, + playlist: MediaItem, +) -> None: + """Test the play media service with HEOS media uri.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + media_content_id = mediauri.to_media_uri(playlist) + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: "media_player.test_player", + ATTR_MEDIA_CONTENT_ID: media_content_id, + ATTR_MEDIA_CONTENT_TYPE: "", + }, + blocking=True, + ) + controller.play_media.assert_called_once() + + +async def test_play_media_media_uri_invalid( + hass: HomeAssistant, + config_entry: MockConfigEntry, + controller: MockHeos, +) -> None: + """Test the play media service with an invalid HEOS media uri raises.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + media_id = "heos://media/1/music_service?name=Pandora&available=False&image_url=" + + with pytest.raises( + HomeAssistantError, + match=re.escape(f"Unable to play media: Invalid media id '{media_id}'"), + ): + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: "media_player.test_player", + ATTR_MEDIA_CONTENT_ID: media_id, + ATTR_MEDIA_CONTENT_TYPE: "", + }, + blocking=True, + ) + controller.play_media.assert_not_called() + + +async def test_play_media_music_source_url( + hass: HomeAssistant, + config_entry: MockConfigEntry, + controller: MockHeos, +) -> None: + """Test the play media service with a music source url.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await async_setup_component(hass, MS_DOMAIN, {MS_DOMAIN: {}}) + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: "media_player.test_player", + ATTR_MEDIA_CONTENT_ID: "media-source://media_source/local/test.mp3", + ATTR_MEDIA_CONTENT_TYPE: "", + }, + blocking=True, + ) + controller.play_url.assert_called_once() + + +async def test_browse_media_root( + hass: HomeAssistant, + config_entry: MockConfigEntry, + controller: MockHeos, + music_sources: dict[int, MediaMusicSource], + hass_ws_client: WebSocketGenerator, + snapshot: SnapshotAssertion, +) -> None: + """Test browsing the root.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await async_setup_component(hass, MS_DOMAIN, {MS_DOMAIN: {}}) + + controller.mock_set_music_sources(music_sources) + + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "media_player/browse_media", + "entity_id": "media_player.test_player", + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == snapshot + + +async def test_browse_media_root_no_media_source( + hass: HomeAssistant, + config_entry: MockConfigEntry, + controller: MockHeos, + music_sources: dict[int, MediaMusicSource], + hass_ws_client: WebSocketGenerator, + snapshot: SnapshotAssertion, +) -> None: + """Test browsing the root.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + controller.mock_set_music_sources(music_sources) + + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "media_player/browse_media", + "entity_id": "media_player.test_player", + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == snapshot + + +async def test_browse_media_root_source_error_continues( + hass: HomeAssistant, + config_entry: MockConfigEntry, + controller: MockHeos, + hass_ws_client: WebSocketGenerator, + caplog: pytest.LogCaptureFixture, + snapshot: SnapshotAssertion, +) -> None: + """Test browsing the root with an error getting sources continues.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + controller.get_music_sources.side_effect = HeosError("error") + + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "media_player/browse_media", + "entity_id": "media_player.test_player", + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == snapshot + assert "Unable to load music sources" in caplog.text + + +async def test_browse_media_heos_media( + hass: HomeAssistant, + config_entry: MockConfigEntry, + controller: MockHeos, + hass_ws_client: WebSocketGenerator, + pandora_browse_result: BrowseResult, + snapshot: SnapshotAssertion, +) -> None: + """Test browsing a heos media item.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + controller.browse_media.return_value = pandora_browse_result + + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "media_player/browse_media", + "entity_id": "media_player.test_player", + "media_content_id": "heos://media/1/music_service?name=Pandora&image_url=&available=True&service_username=user", + "media_content_type": "", + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == snapshot + + +async def test_browse_media_heos_media_error_returns_empty( + hass: HomeAssistant, + config_entry: MockConfigEntry, + controller: MockHeos, + hass_ws_client: WebSocketGenerator, + caplog: pytest.LogCaptureFixture, + snapshot: SnapshotAssertion, +) -> None: + """Test browsing a heos media item results in an error, returns empty children.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + controller.browse_media.side_effect = HeosError("error") + + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "media_player/browse_media", + "entity_id": "media_player.test_player", + "media_content_id": "heos://media/1/music_service?name=Pandora&image_url=&available=True&service_username=user", + "media_content_type": "", + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == snapshot + assert "Unable to browse media" in caplog.text + + +async def test_browse_media_media_source( + hass: HomeAssistant, + config_entry: MockConfigEntry, + hass_ws_client: WebSocketGenerator, + snapshot: SnapshotAssertion, +) -> None: + """Test browsing a media source.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await async_setup_component(hass, MS_DOMAIN, {MS_DOMAIN: {}}) + + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "media_player/browse_media", + "entity_id": "media_player.test_player", + "media_content_id": "media-source://media_source/local/.", + "media_content_type": "", + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == snapshot + + +async def test_browse_media_invalid_content_id( + hass: HomeAssistant, + config_entry: MockConfigEntry, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test browsing an invalid content id fails.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "media_player/browse_media", + "entity_id": "media_player.test_player", + "media_content_id": "invalid", + "media_content_type": "", + } + ) + response = await client.receive_json() + assert not response["success"] + + @pytest.mark.parametrize( ("members", "expected"), [ From 539a28dcba6921d0acd5005b51f91a980575e237 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 17 Mar 2025 20:19:38 +0100 Subject: [PATCH 2707/3148] Make all action descriptions in `rachio` consistent (#140816) Changes 4 of the 6 action descriptions in the `rachio` integration to also use the descriptive style of Home Assistant. In addition "API key" is sentence-cased to match the common string used in the same dialog. --- homeassistant/components/rachio/strings.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/rachio/strings.json b/homeassistant/components/rachio/strings.json index 308403d805d..d51a1d5f920 100644 --- a/homeassistant/components/rachio/strings.json +++ b/homeassistant/components/rachio/strings.json @@ -3,7 +3,7 @@ "step": { "user": { "title": "Connect to your Rachio device", - "description": "You will need the API Key from https://app.rach.io/. Go to Settings, then select 'GET API KEY'.", + "description": "You will need the API key from https://app.rach.io/. Go to Settings, then select 'GET API KEY'.", "data": { "api_key": "[%key:common::config_flow::data::api_key%]" } @@ -70,7 +70,7 @@ }, "start_watering": { "name": "Start watering", - "description": "Start a single zone, a schedule or any number of smart hose timers.", + "description": "Starts a single zone, a schedule or any number of smart hose timers.", "fields": { "duration": { "name": "Duration", @@ -80,7 +80,7 @@ }, "pause_watering": { "name": "Pause watering", - "description": "Pause any currently running zones or schedules.", + "description": "Pauses any currently running zones or schedules.", "fields": { "devices": { "name": "Devices", @@ -94,7 +94,7 @@ }, "resume_watering": { "name": "Resume watering", - "description": "Resume any paused zone runs or schedules.", + "description": "Resumes any paused zone runs or schedules.", "fields": { "devices": { "name": "[%key:component::rachio::services::pause_watering::fields::devices::name%]", @@ -104,7 +104,7 @@ }, "stop_watering": { "name": "Stop watering", - "description": "Stop any currently running zones or schedules.", + "description": "Stops any currently running zones or schedules.", "fields": { "devices": { "name": "[%key:component::rachio::services::pause_watering::fields::devices::name%]", From eafea6070d92ab6bda8d815b05badf17ddd1bdd3 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 17 Mar 2025 20:45:17 +0100 Subject: [PATCH 2708/3148] Improve action description in `mealie` integration (#140817) - change all action descriptions to third-person singular - use neutral wording for the description of `config_entry_id` so it works with all the different action contexts. --- homeassistant/components/mealie/strings.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/mealie/strings.json b/homeassistant/components/mealie/strings.json index fa63252e837..186fc4c4ac0 100644 --- a/homeassistant/components/mealie/strings.json +++ b/homeassistant/components/mealie/strings.json @@ -146,11 +146,11 @@ "services": { "get_mealplan": { "name": "Get mealplan", - "description": "Get mealplan from Mealie", + "description": "Gets a mealplan from Mealie", "fields": { "config_entry_id": { "name": "Mealie instance", - "description": "Select the Mealie instance to get mealplan from" + "description": "The Mealie instance to use for this action." }, "start_date": { "name": "Start date", @@ -164,7 +164,7 @@ }, "get_recipe": { "name": "Get recipe", - "description": "Get recipe from Mealie", + "description": "Gets a recipe from Mealie", "fields": { "config_entry_id": { "name": "[%key:component::mealie::services::get_mealplan::fields::config_entry_id::name%]", @@ -178,7 +178,7 @@ }, "import_recipe": { "name": "Import recipe", - "description": "Import recipe from an URL", + "description": "Imports a recipe from an URL", "fields": { "config_entry_id": { "name": "[%key:component::mealie::services::get_mealplan::fields::config_entry_id::name%]", @@ -196,7 +196,7 @@ }, "set_random_mealplan": { "name": "Set random mealplan", - "description": "Set a random mealplan for a specific date", + "description": "Sets a random mealplan for a specific date", "fields": { "config_entry_id": { "name": "[%key:component::mealie::services::get_mealplan::fields::config_entry_id::name%]", @@ -214,7 +214,7 @@ }, "set_mealplan": { "name": "Set a mealplan", - "description": "Set a mealplan for a specific date", + "description": "Sets a mealplan for a specific date", "fields": { "config_entry_id": { "name": "[%key:component::mealie::services::get_mealplan::fields::config_entry_id::name%]", From c9276aedde098fae761f60f7b0e082a955f69501 Mon Sep 17 00:00:00 2001 From: LG-ThinQ-Integration Date: Tue, 18 Mar 2025 05:38:37 +0900 Subject: [PATCH 2709/3148] Bump thinqconnect to 1.0.5 (#140577) Co-authored-by: yunseon.park --- homeassistant/components/lg_thinq/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/lg_thinq/manifest.json b/homeassistant/components/lg_thinq/manifest.json index b00d28c1d4f..cffc61cb1c4 100644 --- a/homeassistant/components/lg_thinq/manifest.json +++ b/homeassistant/components/lg_thinq/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/lg_thinq", "iot_class": "cloud_push", "loggers": ["thinqconnect"], - "requirements": ["thinqconnect==1.0.4"] + "requirements": ["thinqconnect==1.0.5"] } diff --git a/requirements_all.txt b/requirements_all.txt index 76f8cbb46dc..57f40b4c018 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2903,7 +2903,7 @@ thermopro-ble==0.11.0 thingspeak==1.0.0 # homeassistant.components.lg_thinq -thinqconnect==1.0.4 +thinqconnect==1.0.5 # homeassistant.components.tikteck tikteck==0.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b8e265df455..65a64a8b2ea 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2334,7 +2334,7 @@ thermobeacon-ble==0.8.1 thermopro-ble==0.11.0 # homeassistant.components.lg_thinq -thinqconnect==1.0.4 +thinqconnect==1.0.5 # homeassistant.components.tilt_ble tilt-ble==0.2.3 From 412705302dab5bf069fcb0f367211aa752dd28c1 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Mon, 17 Mar 2025 14:38:21 -0700 Subject: [PATCH 2710/3148] Update MCP server to make the stateless API implicit (#140753) * Update MCP server to not register the stateless API, but use it implicitly as an Assist API replacement * Ensure backwards compatibility with old registration --- .../components/mcp_server/__init__.py | 3 +-- .../components/mcp_server/config_flow.py | 9 +------- homeassistant/components/mcp_server/const.py | 5 ++-- .../components/mcp_server/llm_api.py | 23 +++++++------------ homeassistant/components/mcp_server/server.py | 23 ++++++++++++++----- tests/components/mcp_server/conftest.py | 13 ++++++++--- tests/components/mcp_server/test_http.py | 20 ++++++++-------- 7 files changed, 50 insertions(+), 46 deletions(-) diff --git a/homeassistant/components/mcp_server/__init__.py b/homeassistant/components/mcp_server/__init__.py index 941eccbe528..e523f46228f 100644 --- a/homeassistant/components/mcp_server/__init__.py +++ b/homeassistant/components/mcp_server/__init__.py @@ -6,7 +6,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType -from . import http, llm_api +from . import http from .const import DOMAIN from .session import SessionManager from .types import MCPServerConfigEntry @@ -25,7 +25,6 @@ CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Model Context Protocol component.""" http.async_register(hass) - llm_api.async_register_api(hass) return True diff --git a/homeassistant/components/mcp_server/config_flow.py b/homeassistant/components/mcp_server/config_flow.py index 8d8d311b874..e8df68de5e2 100644 --- a/homeassistant/components/mcp_server/config_flow.py +++ b/homeassistant/components/mcp_server/config_flow.py @@ -16,7 +16,7 @@ from homeassistant.helpers.selector import ( SelectSelectorConfig, ) -from .const import DOMAIN, LLM_API, LLM_API_NAME +from .const import DOMAIN _LOGGER = logging.getLogger(__name__) @@ -33,13 +33,6 @@ class ModelContextServerProtocolConfigFlow(ConfigFlow, domain=DOMAIN): ) -> ConfigFlowResult: """Handle the initial step.""" llm_apis = {api.id: api.name for api in llm.async_get_apis(self.hass)} - if LLM_API not in llm_apis: - # MCP server component is not loaded yet, so make the LLM API a choice. - llm_apis = { - LLM_API: LLM_API_NAME, - **llm_apis, - } - if user_input is not None: return self.async_create_entry( title=llm_apis[user_input[CONF_LLM_HASS_API]], data=user_input diff --git a/homeassistant/components/mcp_server/const.py b/homeassistant/components/mcp_server/const.py index 8958ac36616..3f2e12cbb6a 100644 --- a/homeassistant/components/mcp_server/const.py +++ b/homeassistant/components/mcp_server/const.py @@ -2,5 +2,6 @@ DOMAIN = "mcp_server" TITLE = "Model Context Protocol Server" -LLM_API = "stateless_assist" -LLM_API_NAME = "Stateless Assist" +# The Stateless API is no longer registered explicitly, but this name may still exist in the +# users config entry. +STATELESS_LLM_API = "stateless_assist" diff --git a/homeassistant/components/mcp_server/llm_api.py b/homeassistant/components/mcp_server/llm_api.py index 5c29b29153e..f7dd4421480 100644 --- a/homeassistant/components/mcp_server/llm_api.py +++ b/homeassistant/components/mcp_server/llm_api.py @@ -1,19 +1,18 @@ -"""LLM API for MCP Server.""" +"""LLM API for MCP Server. -from homeassistant.core import HomeAssistant, callback +This is a modified version of the AssistAPI that does not include the home state +in the prompt. This API is not registered with the LLM API registry since it is +only used by the MCP Server. The MCP server will substitute this API when the +user selects the Assist API. +""" + +from homeassistant.core import callback from homeassistant.helpers import llm from homeassistant.util import yaml as yaml_util -from .const import LLM_API, LLM_API_NAME - EXPOSED_ENTITY_FIELDS = {"name", "domain", "description", "areas", "names"} -def async_register_api(hass: HomeAssistant) -> None: - """Register the LLM API.""" - llm.async_register_api(hass, StatelessAssistAPI(hass)) - - class StatelessAssistAPI(llm.AssistAPI): """LLM API for MCP Server that provides the Assist API without state information in the prompt. @@ -22,12 +21,6 @@ class StatelessAssistAPI(llm.AssistAPI): actions don't care about the current state, there is little quality loss. """ - def __init__(self, hass: HomeAssistant) -> None: - """Initialize the StatelessAssistAPI.""" - super().__init__(hass) - self.id = LLM_API - self.name = LLM_API_NAME - @callback def _async_get_exposed_entities_prompt( self, llm_context: llm.LLMContext, exposed_entities: dict | None diff --git a/homeassistant/components/mcp_server/server.py b/homeassistant/components/mcp_server/server.py index ba21abd722c..307fcdda8f3 100644 --- a/homeassistant/components/mcp_server/server.py +++ b/homeassistant/components/mcp_server/server.py @@ -21,6 +21,9 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import llm +from .const import STATELESS_LLM_API +from .llm_api import StatelessAssistAPI + _LOGGER = logging.getLogger(__name__) @@ -50,13 +53,21 @@ async def create_server( server = Server("home-assistant") + async def get_api_instance() -> llm.APIInstance: + """Substitute the StatelessAssistAPI for the Assist API if selected.""" + if llm_api_id in (STATELESS_LLM_API, llm.LLM_API_ASSIST): + api = StatelessAssistAPI(hass) + return await api.async_get_api_instance(llm_context) + + return await llm.async_get_api(hass, llm_api_id, llm_context) + @server.list_prompts() # type: ignore[no-untyped-call, misc] async def handle_list_prompts() -> list[types.Prompt]: - llm_api = await llm.async_get_api(hass, llm_api_id, llm_context) + llm_api = await get_api_instance() return [ types.Prompt( name=llm_api.api.name, - description=f"Default prompt for the Home Assistant LLM API {llm_api.api.name}", + description=f"Default prompt for Home Assistant {llm_api.api.name} API", ) ] @@ -64,12 +75,12 @@ async def create_server( async def handle_get_prompt( name: str, arguments: dict[str, str] | None ) -> types.GetPromptResult: - llm_api = await llm.async_get_api(hass, llm_api_id, llm_context) + llm_api = await get_api_instance() if name != llm_api.api.name: raise ValueError(f"Unknown prompt: {name}") return types.GetPromptResult( - description=f"Default prompt for the Home Assistant LLM API {llm_api.api.name}", + description=f"Default prompt for Home Assistant {llm_api.api.name} API", messages=[ types.PromptMessage( role="assistant", @@ -84,13 +95,13 @@ async def create_server( @server.list_tools() # type: ignore[no-untyped-call, misc] async def list_tools() -> list[types.Tool]: """List available time tools.""" - llm_api = await llm.async_get_api(hass, llm_api_id, llm_context) + llm_api = await get_api_instance() return [_format_tool(tool, llm_api.custom_serializer) for tool in llm_api.tools] @server.call_tool() # type: ignore[no-untyped-call, misc] async def call_tool(name: str, arguments: dict) -> Sequence[types.TextContent]: """Handle calling tools.""" - llm_api = await llm.async_get_api(hass, llm_api_id, llm_context) + llm_api = await get_api_instance() tool_input = llm.ToolInput(tool_name=name, tool_args=arguments) _LOGGER.debug("Tool call: %s(%s)", tool_input.tool_name, tool_input.tool_args) diff --git a/tests/components/mcp_server/conftest.py b/tests/components/mcp_server/conftest.py index 5ec67fb6ce3..b5e25d9fe50 100644 --- a/tests/components/mcp_server/conftest.py +++ b/tests/components/mcp_server/conftest.py @@ -5,9 +5,10 @@ from unittest.mock import AsyncMock, patch import pytest -from homeassistant.components.mcp_server.const import DOMAIN, LLM_API +from homeassistant.components.mcp_server.const import DOMAIN from homeassistant.const import CONF_LLM_HASS_API from homeassistant.core import HomeAssistant +from homeassistant.helpers import llm from tests.common import MockConfigEntry @@ -21,13 +22,19 @@ def mock_setup_entry() -> Generator[AsyncMock]: yield mock_setup_entry +@pytest.fixture(name="llm_hass_api") +def llm_hass_api_fixture() -> str: + """Fixture for the config entry llm_hass_api.""" + return llm.LLM_API_ASSIST + + @pytest.fixture(name="config_entry") -def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: +def mock_config_entry(hass: HomeAssistant, llm_hass_api: str) -> MockConfigEntry: """Fixture to load the integration.""" config_entry = MockConfigEntry( domain=DOMAIN, data={ - CONF_LLM_HASS_API: LLM_API, + CONF_LLM_HASS_API: llm_hass_api, }, ) config_entry.add_to_hass(hass) diff --git a/tests/components/mcp_server/test_http.py b/tests/components/mcp_server/test_http.py index 905bfaa11d7..70efd211b57 100644 --- a/tests/components/mcp_server/test_http.py +++ b/tests/components/mcp_server/test_http.py @@ -16,6 +16,7 @@ import pytest from homeassistant.components.conversation import DOMAIN as CONVERSATION_DOMAIN from homeassistant.components.homeassistant.exposed_entities import async_expose_entity from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN +from homeassistant.components.mcp_server.const import STATELESS_LLM_API from homeassistant.components.mcp_server.http import MESSAGES_API, SSE_API from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_LLM_HASS_API, STATE_OFF, STATE_ON @@ -24,6 +25,7 @@ from homeassistant.helpers import ( area_registry as ar, device_registry as dr, entity_registry as er, + llm, ) from homeassistant.setup import async_setup_component @@ -297,6 +299,7 @@ async def mcp_session( yield session +@pytest.mark.parametrize("llm_hass_api", [llm.LLM_API_ASSIST, STATELESS_LLM_API]) async def test_mcp_tools_list( hass: HomeAssistant, setup_integration: None, @@ -319,6 +322,7 @@ async def test_mcp_tools_list( assert properties.get("name") == {"type": "string"} +@pytest.mark.parametrize("llm_hass_api", [llm.LLM_API_ASSIST, STATELESS_LLM_API]) async def test_mcp_tool_call( hass: HomeAssistant, setup_integration: None, @@ -371,6 +375,7 @@ async def test_mcp_tool_call_failed( assert "Error calling tool" in result.content[0].text +@pytest.mark.parametrize("llm_hass_api", [llm.LLM_API_ASSIST, STATELESS_LLM_API]) async def test_prompt_list( hass: HomeAssistant, setup_integration: None, @@ -384,13 +389,11 @@ async def test_prompt_list( assert len(result.prompts) == 1 prompt = result.prompts[0] - assert prompt.name == "Stateless Assist" - assert ( - prompt.description - == "Default prompt for the Home Assistant LLM API Stateless Assist" - ) + assert prompt.name == "Assist" + assert prompt.description == "Default prompt for Home Assistant Assist API" +@pytest.mark.parametrize("llm_hass_api", [llm.LLM_API_ASSIST, STATELESS_LLM_API]) async def test_prompt_get( hass: HomeAssistant, setup_integration: None, @@ -400,12 +403,9 @@ async def test_prompt_get( """Test the get prompt endpoint.""" async with mcp_session(mcp_sse_url, hass_supervisor_access_token) as session: - result = await session.get_prompt(name="Stateless Assist") + result = await session.get_prompt(name="Assist") - assert ( - result.description - == "Default prompt for the Home Assistant LLM API Stateless Assist" - ) + assert result.description == "Default prompt for Home Assistant Assist API" assert len(result.messages) == 1 assert result.messages[0].role == "assistant" assert result.messages[0].content.type == "text" From 73a24bf79987340115004cb0960d2da657cfb47b Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Mon, 17 Mar 2025 21:39:48 -0400 Subject: [PATCH 2711/3148] Set Parallel updates to 0 in Roborock (#140837) roborock set parallel updates to 0 --- homeassistant/components/roborock/binary_sensor.py | 2 ++ homeassistant/components/roborock/button.py | 2 ++ homeassistant/components/roborock/image.py | 2 ++ homeassistant/components/roborock/number.py | 2 ++ homeassistant/components/roborock/quality_scale.yaml | 2 +- homeassistant/components/roborock/select.py | 2 ++ homeassistant/components/roborock/sensor.py | 2 ++ homeassistant/components/roborock/switch.py | 2 ++ homeassistant/components/roborock/time.py | 2 ++ homeassistant/components/roborock/vacuum.py | 2 ++ 10 files changed, 19 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/roborock/binary_sensor.py b/homeassistant/components/roborock/binary_sensor.py index 95640812b11..a2c34f5c59d 100644 --- a/homeassistant/components/roborock/binary_sensor.py +++ b/homeassistant/components/roborock/binary_sensor.py @@ -20,6 +20,8 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import RoborockConfigEntry, RoborockDataUpdateCoordinator from .entity import RoborockCoordinatedEntityV1 +PARALLEL_UPDATES = 0 + @dataclass(frozen=True, kw_only=True) class RoborockBinarySensorDescription(BinarySensorEntityDescription): diff --git a/homeassistant/components/roborock/button.py b/homeassistant/components/roborock/button.py index f0f0d7beea2..fea38524fe0 100644 --- a/homeassistant/components/roborock/button.py +++ b/homeassistant/components/roborock/button.py @@ -17,6 +17,8 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import RoborockConfigEntry, RoborockDataUpdateCoordinator from .entity import RoborockEntity, RoborockEntityV1 +PARALLEL_UPDATES = 0 + @dataclass(frozen=True, kw_only=True) class RoborockButtonDescription(ButtonEntityDescription): diff --git a/homeassistant/components/roborock/image.py b/homeassistant/components/roborock/image.py index 382edbca744..79d6dafdc7a 100644 --- a/homeassistant/components/roborock/image.py +++ b/homeassistant/components/roborock/image.py @@ -18,6 +18,8 @@ from .entity import RoborockCoordinatedEntityV1 _LOGGER = logging.getLogger(__name__) +PARALLEL_UPDATES = 0 + async def async_setup_entry( hass: HomeAssistant, diff --git a/homeassistant/components/roborock/number.py b/homeassistant/components/roborock/number.py index a710eeefb90..73ac14fca71 100644 --- a/homeassistant/components/roborock/number.py +++ b/homeassistant/components/roborock/number.py @@ -22,6 +22,8 @@ from .entity import RoborockEntityV1 _LOGGER = logging.getLogger(__name__) +PARALLEL_UPDATES = 0 + @dataclass(frozen=True, kw_only=True) class RoborockNumberDescription(NumberEntityDescription): diff --git a/homeassistant/components/roborock/quality_scale.yaml b/homeassistant/components/roborock/quality_scale.yaml index 2cf664beb40..c7675ef96d1 100644 --- a/homeassistant/components/roborock/quality_scale.yaml +++ b/homeassistant/components/roborock/quality_scale.yaml @@ -28,7 +28,7 @@ rules: entity-unavailable: done integration-owner: done log-when-unavailable: done - parallel-updates: todo + parallel-updates: done reauthentication-flow: todo test-coverage: done # Gold diff --git a/homeassistant/components/roborock/select.py b/homeassistant/components/roborock/select.py index 42245c458eb..c79bf817d09 100644 --- a/homeassistant/components/roborock/select.py +++ b/homeassistant/components/roborock/select.py @@ -17,6 +17,8 @@ from .const import MAP_SLEEP from .coordinator import RoborockConfigEntry, RoborockDataUpdateCoordinator from .entity import RoborockCoordinatedEntityV1 +PARALLEL_UPDATES = 0 + @dataclass(frozen=True, kw_only=True) class RoborockSelectDescription(SelectEntityDescription): diff --git a/homeassistant/components/roborock/sensor.py b/homeassistant/components/roborock/sensor.py index 7b019acb39b..556d8443669 100644 --- a/homeassistant/components/roborock/sensor.py +++ b/homeassistant/components/roborock/sensor.py @@ -38,6 +38,8 @@ from .coordinator import ( ) from .entity import RoborockCoordinatedEntityA01, RoborockCoordinatedEntityV1 +PARALLEL_UPDATES = 0 + @dataclass(frozen=True, kw_only=True) class RoborockSensorDescription(SensorEntityDescription): diff --git a/homeassistant/components/roborock/switch.py b/homeassistant/components/roborock/switch.py index 636066c1ed5..44feccdebac 100644 --- a/homeassistant/components/roborock/switch.py +++ b/homeassistant/components/roborock/switch.py @@ -24,6 +24,8 @@ from .entity import RoborockEntityV1 _LOGGER = logging.getLogger(__name__) +PARALLEL_UPDATES = 0 + @dataclass(frozen=True, kw_only=True) class RoborockSwitchDescription(SwitchEntityDescription): diff --git a/homeassistant/components/roborock/time.py b/homeassistant/components/roborock/time.py index 6aa70e300e5..83d341fa2dd 100644 --- a/homeassistant/components/roborock/time.py +++ b/homeassistant/components/roborock/time.py @@ -24,6 +24,8 @@ from .entity import RoborockEntityV1 _LOGGER = logging.getLogger(__name__) +PARALLEL_UPDATES = 0 + @dataclass(frozen=True, kw_only=True) class RoborockTimeDescription(TimeEntityDescription): diff --git a/homeassistant/components/roborock/vacuum.py b/homeassistant/components/roborock/vacuum.py index db201ff06d2..f17cab7e922 100644 --- a/homeassistant/components/roborock/vacuum.py +++ b/homeassistant/components/roborock/vacuum.py @@ -57,6 +57,8 @@ STATE_CODE_TO_STATE = { RoborockStateCode.device_offline: VacuumActivity.ERROR, # "Device offline" } +PARALLEL_UPDATES = 0 + async def async_setup_entry( hass: HomeAssistant, From 0eac679a5a2d5e884f6771e9df7f6dd2f4072822 Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Mon, 17 Mar 2025 22:34:47 -0400 Subject: [PATCH 2712/3148] Move MapData to Coordinator for Roborock (#140766) * Move MapData to Coordinator * seeing if mypy likes this * delete dead code * Some MR comments * remove MapData and always update on startup if we don't have a stored map. * don't do on demand updates * remove unneeded logic and pull out map save * Apply suggestions from code review Co-authored-by: Allen Porter * see if mypy is happy --------- Co-authored-by: Allen Porter --- homeassistant/components/roborock/const.py | 2 +- .../components/roborock/coordinator.py | 71 ++++++++++++++++++- homeassistant/components/roborock/image.py | 56 +++------------ homeassistant/components/roborock/models.py | 3 + homeassistant/components/roborock/vacuum.py | 10 ++- tests/components/roborock/conftest.py | 2 +- tests/components/roborock/test_config_flow.py | 42 +++++++---- tests/components/roborock/test_image.py | 44 +++++++++--- tests/components/roborock/test_init.py | 6 +- 9 files changed, 155 insertions(+), 81 deletions(-) diff --git a/homeassistant/components/roborock/const.py b/homeassistant/components/roborock/const.py index 4e2588c9478..e56fade7078 100644 --- a/homeassistant/components/roborock/const.py +++ b/homeassistant/components/roborock/const.py @@ -46,7 +46,7 @@ PLATFORMS = [ ] # This can be lowered in the future if we do not receive rate limiting issues. -IMAGE_CACHE_INTERVAL = 30 +IMAGE_CACHE_INTERVAL = timedelta(seconds=30) MAP_SLEEP = 3 diff --git a/homeassistant/components/roborock/coordinator.py b/homeassistant/components/roborock/coordinator.py index cbfd5e95a90..e430e2f6301 100644 --- a/homeassistant/components/roborock/coordinator.py +++ b/homeassistant/components/roborock/coordinator.py @@ -39,13 +39,14 @@ from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from homeassistant.util import slugify +from homeassistant.util import dt as dt_util, slugify from .const import ( A01_UPDATE_INTERVAL, DEFAULT_DRAWABLES, DOMAIN, DRAWABLES, + IMAGE_CACHE_INTERVAL, MAP_FILE_FORMAT, MAP_SCALE, MAP_SLEEP, @@ -191,15 +192,59 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): except RoborockException as err: raise UpdateFailed("Failed to get map data: {err}") from err # Rooms names populated later with calls to `set_current_map_rooms` for each map + roborock_maps = maps.map_info if (maps and maps.map_info) else () + stored_images = await asyncio.gather( + *[ + self.map_storage.async_load_map(roborock_map.mapFlag) + for roborock_map in roborock_maps + ] + ) self.maps = { roborock_map.mapFlag: RoborockMapInfo( flag=roborock_map.mapFlag, name=roborock_map.name or f"Map {roborock_map.mapFlag}", rooms={}, + image=image, + last_updated=dt_util.utcnow() - IMAGE_CACHE_INTERVAL, ) - for roborock_map in (maps.map_info if (maps and maps.map_info) else ()) + for image, roborock_map in zip(stored_images, roborock_maps, strict=False) } + async def update_map(self) -> None: + """Update the currently selected map.""" + # The current map was set in the props update, so these can be done without + # worry of applying them to the wrong map. + if self.current_map is None: + # This exists as a safeguard/ to keep mypy happy. + return + try: + response = await self.cloud_api.get_map_v1() + except RoborockException as ex: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="map_failure", + ) from ex + if not isinstance(response, bytes): + _LOGGER.debug("Failed to parse map contents: %s", response) + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="map_failure", + ) + parsed_image = self.parse_image(response) + if parsed_image is None: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="map_failure", + ) + if parsed_image != self.maps[self.current_map].image: + await self.map_storage.async_save_map( + self.current_map, + parsed_image, + ) + current_roborock_map_info = self.maps[self.current_map] + current_roborock_map_info.image = parsed_image + current_roborock_map_info.last_updated = dt_util.utcnow() + async def _verify_api(self) -> None: """Verify that the api is reachable. If it is not, switch clients.""" if isinstance(self.api, RoborockLocalClientV1): @@ -240,6 +285,19 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): # Set the new map id from the updated device props self._set_current_map() # Get the rooms for that map id. + + # If the vacuum is currently cleaning and it has been IMAGE_CACHE_INTERVAL + # since the last map update, you can update the map. + if ( + self.current_map is not None + and self.roborock_device_info.props.status.in_cleaning + and (dt_util.utcnow() - self.maps[self.current_map].last_updated) + > IMAGE_CACHE_INTERVAL + ): + try: + await self.update_map() + except HomeAssistantError as err: + _LOGGER.debug("Failed to update map: %s", err) await self.set_current_map_rooms() except RoborockException as ex: _LOGGER.debug("Failed to update data: %s", ex) @@ -338,7 +396,14 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): # We cannot get the map until the roborock servers fully process the # map change. await asyncio.sleep(MAP_SLEEP) - await self.set_current_map_rooms() + tasks = [self.set_current_map_rooms()] + # The image is set within async_setup, so if it exists, we have it here. + if self.maps[map_flag].image is None: + # If we don't have a cached map, let's update it here so that it can be + # cached in the future. + tasks.append(self.update_map()) + # If either of these fail, we don't care, and we want to continue. + await asyncio.gather(*tasks, return_exceptions=True) if len(self.maps) != 1: # Set the map back to the map the user previously had selected so that it diff --git a/homeassistant/components/roborock/image.py b/homeassistant/components/roborock/image.py index 79d6dafdc7a..d1c19331ba4 100644 --- a/homeassistant/components/roborock/image.py +++ b/homeassistant/components/roborock/image.py @@ -1,6 +1,5 @@ """Support for Roborock image.""" -import asyncio from datetime import datetime import logging @@ -8,11 +7,8 @@ from homeassistant.components.image import ImageEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.util import dt as dt_util -from .const import DOMAIN, IMAGE_CACHE_INTERVAL from .coordinator import RoborockConfigEntry, RoborockDataUpdateCoordinator from .entity import RoborockCoordinatedEntityV1 @@ -75,51 +71,19 @@ class RoborockMap(RoborockCoordinatedEntityV1, ImageEntity): async def async_added_to_hass(self) -> None: """When entity is added to hass load any previously cached maps from disk.""" await super().async_added_to_hass() - content = await self.coordinator.map_storage.async_load_map(self.map_flag) - self.cached_map = content or b"" - self._attr_image_last_updated = dt_util.utcnow() + self._attr_image_last_updated = self.coordinator.maps[ + self.map_flag + ].last_updated self.async_write_ha_state() def _handle_coordinator_update(self) -> None: - # Bump last updated every third time the coordinator runs, so that async_image - # will be called and we will evaluate on the new coordinator data if we should - # update the cache. - if self.is_selected and ( - ( - (dt_util.utcnow() - self.image_last_updated).total_seconds() - > IMAGE_CACHE_INTERVAL - and self.coordinator.roborock_device_info.props.status is not None - and bool(self.coordinator.roborock_device_info.props.status.in_cleaning) - ) - or self.cached_map == b"" - ): - # This will tell async_image it should update. - self._attr_image_last_updated = dt_util.utcnow() + # If the coordinator has updated the map, we can update the image. + self._attr_image_last_updated = self.coordinator.maps[ + self.map_flag + ].last_updated + super()._handle_coordinator_update() async def async_image(self) -> bytes | None: - """Update the image if it is not cached.""" - if self.is_selected: - response = await asyncio.gather( - *( - self.cloud_api.get_map_v1(), - self.coordinator.set_current_map_rooms(), - ), - return_exceptions=True, - ) - if ( - not isinstance(response[0], bytes) - or (content := self.coordinator.parse_image(response[0])) is None - ): - _LOGGER.debug("Failed to parse map contents: %s", response[0]) - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="map_failure", - ) - if self.cached_map != content: - self.cached_map = content - await self.coordinator.map_storage.async_save_map( - self.map_flag, - content, - ) - return self.cached_map + """Get the cached image.""" + return self.coordinator.maps[self.map_flag].image diff --git a/homeassistant/components/roborock/models.py b/homeassistant/components/roborock/models.py index 4b8ab43b4a1..113f99d9474 100644 --- a/homeassistant/components/roborock/models.py +++ b/homeassistant/components/roborock/models.py @@ -1,6 +1,7 @@ """Roborock Models.""" from dataclasses import dataclass +from datetime import datetime from typing import Any from roborock.containers import HomeDataDevice, HomeDataProduct, NetworkInfo @@ -48,3 +49,5 @@ class RoborockMapInfo: flag: int name: str rooms: dict[int, str] + image: bytes | None + last_updated: datetime diff --git a/homeassistant/components/roborock/vacuum.py b/homeassistant/components/roborock/vacuum.py index f17cab7e922..c5357597527 100644 --- a/homeassistant/components/roborock/vacuum.py +++ b/homeassistant/components/roborock/vacuum.py @@ -1,6 +1,5 @@ """Support for Roborock vacuum class.""" -from dataclasses import asdict from typing import Any from roborock.code_mappings import RoborockStateCode @@ -206,7 +205,14 @@ class RoborockVacuum(RoborockCoordinatedEntityV1, StateVacuumEntity): """Get map information such as map id and room ids.""" return { "maps": [ - asdict(vacuum_map) for vacuum_map in self.coordinator.maps.values() + { + "flag": vacuum_map.flag, + "name": vacuum_map.name, + # JsonValueType does not accept a int as a key - was not a + # issue with previous asdict() implementation. + "rooms": vacuum_map.rooms, # type: ignore[dict-item] + } + for vacuum_map in self.coordinator.maps.values() ] } diff --git a/tests/components/roborock/conftest.py b/tests/components/roborock/conftest.py index b4fde5cc513..332a9143c51 100644 --- a/tests/components/roborock/conftest.py +++ b/tests/components/roborock/conftest.py @@ -228,7 +228,7 @@ async def setup_entry( yield mock_roborock_entry -@pytest.fixture +@pytest.fixture(autouse=True) async def cleanup_map_storage( hass: HomeAssistant, mock_roborock_entry: MockConfigEntry ) -> Generator[pathlib.Path]: diff --git a/tests/components/roborock/test_config_flow.py b/tests/components/roborock/test_config_flow.py index 13bc23e6e2b..1bcb72c2f5b 100644 --- a/tests/components/roborock/test_config_flow.py +++ b/tests/components/roborock/test_config_flow.py @@ -25,6 +25,12 @@ from .mock_data import MOCK_CONFIG, USER_DATA, USER_EMAIL from tests.common import MockConfigEntry +@pytest.fixture +def cleanup_map_storage(): + """Override the map storage fixture as it is not relevant here.""" + return + + async def test_config_flow_success( hass: HomeAssistant, bypass_api_fixture, @@ -189,25 +195,31 @@ async def test_config_flow_failures_code_login( async def test_options_flow_drawables( - hass: HomeAssistant, setup_entry: MockConfigEntry + hass: HomeAssistant, mock_roborock_entry: MockConfigEntry ) -> None: """Test that the options flow works.""" - result = await hass.config_entries.options.async_init(setup_entry.entry_id) - - assert result["type"] == FlowResultType.FORM - assert result["step_id"] == DRAWABLES - with patch( - "homeassistant.components.roborock.async_setup_entry", return_value=True - ) as mock_setup: - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={Drawable.PREDICTED_PATH: True}, - ) + with patch("homeassistant.components.roborock.roborock_storage"): + await hass.config_entries.async_setup(mock_roborock_entry.entry_id) await hass.async_block_till_done() - assert result["type"] == FlowResultType.CREATE_ENTRY - assert setup_entry.options[DRAWABLES][Drawable.PREDICTED_PATH] is True - assert len(mock_setup.mock_calls) == 1 + result = await hass.config_entries.options.async_init( + mock_roborock_entry.entry_id + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == DRAWABLES + with patch( + "homeassistant.components.roborock.async_setup_entry", return_value=True + ) as mock_setup: + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={Drawable.PREDICTED_PATH: True}, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert mock_roborock_entry.options[DRAWABLES][Drawable.PREDICTED_PATH] is True + assert len(mock_setup.mock_calls) == 1 async def test_reauth_flow( diff --git a/tests/components/roborock/test_image.py b/tests/components/roborock/test_image.py index 08f8ac504bf..0cd9d625920 100644 --- a/tests/components/roborock/test_image.py +++ b/tests/components/roborock/test_image.py @@ -62,20 +62,26 @@ async def test_floorplan_image( return_value=prop, ), patch( - "homeassistant.components.roborock.image.dt_util.utcnow", return_value=now + "homeassistant.components.roborock.coordinator.dt_util.utcnow", + return_value=now, ), patch( "homeassistant.components.roborock.coordinator.RoborockMapDataParser.parse", - return_value=new_map_data, + return_value=MAP_DATA, ) as parse_map, ): + # This should call parse_map twice as the both devices are in cleaning. async_fire_time_changed(hass, now) - await hass.async_block_till_done() resp = await client.get("/api/image_proxy/image.roborock_s7_maxv_upstairs") + assert resp.status == HTTPStatus.OK + resp = await client.get("/api/image_proxy/image.roborock_s7_2_upstairs") + assert resp.status == HTTPStatus.OK + resp = await client.get("/api/image_proxy/image.roborock_s7_maxv_downstairs") assert resp.status == HTTPStatus.OK body = await resp.read() assert body is not None - assert parse_map.call_count == 1 + + assert parse_map.call_count == 2 async def test_floorplan_image_failed_parse( @@ -91,6 +97,7 @@ async def test_floorplan_image_failed_parse( # Copy the device prop so we don't override it prop = copy.deepcopy(PROP) prop.status.in_cleaning = 1 + previous_state = hass.states.get("image.roborock_s7_maxv_upstairs").state # Update image, but get none for parse image. with ( patch( @@ -102,12 +109,16 @@ async def test_floorplan_image_failed_parse( return_value=prop, ), patch( - "homeassistant.components.roborock.image.dt_util.utcnow", return_value=now + "homeassistant.components.roborock.coordinator.dt_util.utcnow", + return_value=now, ), ): async_fire_time_changed(hass, now) resp = await client.get("/api/image_proxy/image.roborock_s7_maxv_upstairs") - assert not resp.ok + # The map should load fine from the coordinator, but it should not update the + # last_updated timestamp. + assert resp.ok + assert previous_state == hass.states.get("image.roborock_s7_maxv_upstairs").state async def test_fail_to_save_image( @@ -158,6 +169,9 @@ async def test_fail_to_load_image( "homeassistant.components.roborock.roborock_storage.Path.read_bytes", side_effect=OSError, ) as read_bytes, + patch( + "homeassistant.components.roborock.coordinator.RoborockDataUpdateCoordinator.refresh_coordinator_map" + ), ): # Reload the config entry so that the map is saved in storage and entities exist. await hass.config_entries.async_reload(setup_entry.entry_id) @@ -224,6 +238,7 @@ async def test_fail_updating_image( prop = copy.deepcopy(PROP) prop.status.in_cleaning = 1 # Update image, but get none for parse image. + previous_state = hass.states.get("image.roborock_s7_maxv_upstairs").state with ( patch( "homeassistant.components.roborock.coordinator.RoborockMapDataParser.parse", @@ -234,7 +249,8 @@ async def test_fail_updating_image( return_value=prop, ), patch( - "homeassistant.components.roborock.image.dt_util.utcnow", return_value=now + "homeassistant.components.roborock.coordinator.dt_util.utcnow", + return_value=now, ), patch( "homeassistant.components.roborock.coordinator.RoborockMqttClientV1.get_map_v1", @@ -243,7 +259,10 @@ async def test_fail_updating_image( ): async_fire_time_changed(hass, now) resp = await client.get("/api/image_proxy/image.roborock_s7_maxv_upstairs") - assert not resp.ok + # The map should load fine from the coordinator, but it should not update the + # last_updated timestamp. + assert resp.ok + assert previous_state == hass.states.get("image.roborock_s7_maxv_upstairs").state async def test_index_error_map( @@ -257,6 +276,7 @@ async def test_index_error_map( # Copy the device prop so we don't override it prop = copy.deepcopy(PROP) prop.status.in_cleaning = 1 + previous_state = hass.states.get("image.roborock_s7_maxv_upstairs").state # Update image, but get IndexError for image. with ( patch( @@ -268,9 +288,13 @@ async def test_index_error_map( return_value=prop, ), patch( - "homeassistant.components.roborock.image.dt_util.utcnow", return_value=now + "homeassistant.components.roborock.coordinator.dt_util.utcnow", + return_value=now, ), ): async_fire_time_changed(hass, now) resp = await client.get("/api/image_proxy/image.roborock_s7_maxv_upstairs") - assert not resp.ok + # The map should load fine from the coordinator, but it should not update the + # last_updated timestamp. + assert resp.ok + assert previous_state == hass.states.get("image.roborock_s7_maxv_upstairs").state diff --git a/tests/components/roborock/test_init.py b/tests/components/roborock/test_init.py index 904a3af89d6..9a749a71e30 100644 --- a/tests/components/roborock/test_init.py +++ b/tests/components/roborock/test_init.py @@ -189,7 +189,7 @@ async def test_remove_from_hass( await hass.config_entries.async_unload(setup_entry.entry_id) assert cleanup_map_storage.exists() paths = list(cleanup_map_storage.walk()) - assert len(paths) == 3 # One map image and two directories + assert len(paths) == 4 # Two map image and two directories await hass.config_entries.async_remove(setup_entry.entry_id) # After removal, directories should be empty. @@ -219,7 +219,7 @@ async def test_oserror_remove_image( assert cleanup_map_storage.exists() paths = list(cleanup_map_storage.walk()) - assert len(paths) == 3 # One map image and two directories + assert len(paths) == 4 # Two map image and two directories with patch( "homeassistant.components.roborock.roborock_storage.shutil.rmtree", @@ -242,7 +242,7 @@ async def test_not_supported_protocol( "homeassistant.components.roborock.RoborockApiClient.get_home_data_v2", return_value=home_data_copy, ): - await async_setup_component(hass, DOMAIN, {}) + await hass.config_entries.async_setup(mock_roborock_entry.entry_id) await hass.async_block_till_done() assert "because its protocol version random" in caplog.text From a93ab74e402d5fddbf506d004f09cae21ff8123d Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 18 Mar 2025 08:21:06 +0100 Subject: [PATCH 2713/3148] Sentence-case "Zip code" in `iqvia` integration strings (#140853) --- homeassistant/components/iqvia/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/iqvia/strings.json b/homeassistant/components/iqvia/strings.json index 5dc0dea53d5..a0697a6c210 100644 --- a/homeassistant/components/iqvia/strings.json +++ b/homeassistant/components/iqvia/strings.json @@ -4,7 +4,7 @@ "user": { "description": "Fill out your U.S. or Canadian ZIP code.", "data": { - "zip_code": "ZIP Code" + "zip_code": "ZIP code" } } }, From 426be3c11b8f170f9ceb1c1693cc79a6b186e36d Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 18 Mar 2025 08:21:28 +0100 Subject: [PATCH 2714/3148] Capitalize "ZIP" as abbreviation in `rova` integration strings (#140852) Capitalized "ZIP" as abbreviation in `rova` --- homeassistant/components/rova/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/rova/strings.json b/homeassistant/components/rova/strings.json index 3b89fc789ee..21f4146bf78 100644 --- a/homeassistant/components/rova/strings.json +++ b/homeassistant/components/rova/strings.json @@ -4,7 +4,7 @@ "user": { "title": "Provide your address details", "data": { - "zip_code": "Your zip code", + "zip_code": "Your ZIP code", "house_number": "Your house number", "house_number_suffix": "A suffix for your house number" } From 776495dfa2dbf680b2ee50529bc32bded5a735e2 Mon Sep 17 00:00:00 2001 From: Adam Feldman Date: Tue, 18 Mar 2025 03:24:05 -0500 Subject: [PATCH 2715/3148] Fix broken core integration Smart Meter Texas by switching it to use HA's SSL Context (#140694) * Update __init__.py to use HA's SSLContext * Update config_flow.py to use HA's SSLContext * Use default context for config_flow.py * Use default context instead in __init__.py Co-authored-by: Josef Zweck * Fix import in __init__.py * Fix import in config_flow.py --------- Co-authored-by: Josef Zweck --- homeassistant/components/smart_meter_texas/__init__.py | 6 +++--- homeassistant/components/smart_meter_texas/config_flow.py | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/smart_meter_texas/__init__.py b/homeassistant/components/smart_meter_texas/__init__.py index 1cd7df68e91..ce87b85c322 100644 --- a/homeassistant/components/smart_meter_texas/__init__.py +++ b/homeassistant/components/smart_meter_texas/__init__.py @@ -3,7 +3,7 @@ import logging import ssl -from smart_meter_texas import Account, Client, ClientSSLContext +from smart_meter_texas import Account, Client from smart_meter_texas.exceptions import ( SmartMeterTexasAPIError, SmartMeterTexasAuthError, @@ -16,6 +16,7 @@ from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import aiohttp_client from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.util.ssl import get_default_context from .const import ( DATA_COORDINATOR, @@ -38,8 +39,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: account = Account(username, password) - client_ssl_context = ClientSSLContext() - ssl_context = await client_ssl_context.get_ssl_context() + ssl_context = get_default_context() smart_meter_texas_data = SmartMeterTexasData(hass, entry, account, ssl_context) try: diff --git a/homeassistant/components/smart_meter_texas/config_flow.py b/homeassistant/components/smart_meter_texas/config_flow.py index b60855b62c8..18a3716e1b9 100644 --- a/homeassistant/components/smart_meter_texas/config_flow.py +++ b/homeassistant/components/smart_meter_texas/config_flow.py @@ -4,7 +4,7 @@ import logging from typing import Any from aiohttp import ClientError -from smart_meter_texas import Account, Client, ClientSSLContext +from smart_meter_texas import Account, Client from smart_meter_texas.exceptions import ( SmartMeterTexasAPIError, SmartMeterTexasAuthError, @@ -16,6 +16,7 @@ from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import aiohttp_client +from homeassistant.util.ssl import get_default_context from .const import DOMAIN @@ -31,8 +32,7 @@ async def validate_input(hass: HomeAssistant, data): Data has the keys from DATA_SCHEMA with values provided by the user. """ - client_ssl_context = ClientSSLContext() - ssl_context = await client_ssl_context.get_ssl_context() + ssl_context = get_default_context() client_session = aiohttp_client.async_get_clientsession(hass) account = Account(data["username"], data["password"]) client = Client(client_session, account, ssl_context) From 74992344d53142a9e353365a8dce0f69a9b96df7 Mon Sep 17 00:00:00 2001 From: Dan Raper Date: Tue, 18 Mar 2025 08:31:08 +0000 Subject: [PATCH 2716/3148] Add diagnostics for Ohme (#140833) --- homeassistant/components/ohme/diagnostics.py | 24 ++++++++++++++++ .../components/ohme/quality_scale.yaml | 2 +- .../ohme/snapshots/test_diagnostics.ambr | 16 +++++++++++ tests/components/ohme/test_diagnostics.py | 28 +++++++++++++++++++ 4 files changed, 69 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/ohme/diagnostics.py create mode 100644 tests/components/ohme/snapshots/test_diagnostics.ambr create mode 100644 tests/components/ohme/test_diagnostics.py diff --git a/homeassistant/components/ohme/diagnostics.py b/homeassistant/components/ohme/diagnostics.py new file mode 100644 index 00000000000..a955b3b76e2 --- /dev/null +++ b/homeassistant/components/ohme/diagnostics.py @@ -0,0 +1,24 @@ +"""Provides diagnostics for Ohme.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.core import HomeAssistant + +from .coordinator import OhmeConfigEntry + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, config_entry: OhmeConfigEntry +) -> dict[str, Any]: + """Return diagnostics for Ohme.""" + coordinators = config_entry.runtime_data + client = coordinators.charge_session_coordinator.client + + return { + "device_info": client.device_info, + "vehicles": client.vehicles, + "ct_connected": client.ct_connected, + "cap_available": client.cap_available, + } diff --git a/homeassistant/components/ohme/quality_scale.yaml b/homeassistant/components/ohme/quality_scale.yaml index 497d5ad32e5..ba814202cdc 100644 --- a/homeassistant/components/ohme/quality_scale.yaml +++ b/homeassistant/components/ohme/quality_scale.yaml @@ -39,7 +39,7 @@ rules: # Gold devices: done - diagnostics: todo + diagnostics: done discovery: status: exempt comment: | diff --git a/tests/components/ohme/snapshots/test_diagnostics.ambr b/tests/components/ohme/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..f51c701b71b --- /dev/null +++ b/tests/components/ohme/snapshots/test_diagnostics.ambr @@ -0,0 +1,16 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'cap_available': True, + 'ct_connected': True, + 'device_info': dict({ + 'model': 'Home Pro', + 'name': 'Ohme Home Pro', + 'sw_version': 'v2.65', + }), + 'vehicles': list([ + 'Nissan Leaf', + 'Tesla Model 3', + ]), + }) +# --- diff --git a/tests/components/ohme/test_diagnostics.py b/tests/components/ohme/test_diagnostics.py new file mode 100644 index 00000000000..6aab1262189 --- /dev/null +++ b/tests/components/ohme/test_diagnostics.py @@ -0,0 +1,28 @@ +"""Tests for the diagnostics data provided by the Ohme integration.""" + +from unittest.mock import MagicMock + +from syrupy import SnapshotAssertion + +from homeassistant.core import HomeAssistant + +from . import setup_integration + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def test_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_config_entry: MockConfigEntry, + mock_client: MagicMock, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics.""" + await setup_integration(hass, mock_config_entry) + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, mock_config_entry) + == snapshot + ) From 52054d69c780626e8a804192bdd696023b359c81 Mon Sep 17 00:00:00 2001 From: Jan Schneider Date: Tue, 18 Mar 2025 09:32:28 +0100 Subject: [PATCH 2717/3148] Update moehlenhoff-alpha2 to 1.4.0 (#140829) * Update moehlenhoff-alpha2 to 1.4.0 * Fix test --- homeassistant/components/moehlenhoff_alpha2/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/moehlenhoff_alpha2/__init__.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/moehlenhoff_alpha2/manifest.json b/homeassistant/components/moehlenhoff_alpha2/manifest.json index 14f40991a84..45b7f8c9565 100644 --- a/homeassistant/components/moehlenhoff_alpha2/manifest.json +++ b/homeassistant/components/moehlenhoff_alpha2/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/moehlenhoff_alpha2", "iot_class": "local_push", - "requirements": ["moehlenhoff-alpha2==1.3.1"] + "requirements": ["moehlenhoff-alpha2==1.4.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 57f40b4c018..977c8a1f574 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1427,7 +1427,7 @@ minio==7.1.12 moat-ble==0.1.1 # homeassistant.components.moehlenhoff_alpha2 -moehlenhoff-alpha2==1.3.1 +moehlenhoff-alpha2==1.4.0 # homeassistant.components.monzo monzopy==1.4.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 65a64a8b2ea..418e030d42f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1196,7 +1196,7 @@ minio==7.1.12 moat-ble==0.1.1 # homeassistant.components.moehlenhoff_alpha2 -moehlenhoff-alpha2==1.3.1 +moehlenhoff-alpha2==1.4.0 # homeassistant.components.monzo monzopy==1.4.2 diff --git a/tests/components/moehlenhoff_alpha2/__init__.py b/tests/components/moehlenhoff_alpha2/__init__.py index 50087794560..90d6d88fedc 100644 --- a/tests/components/moehlenhoff_alpha2/__init__.py +++ b/tests/components/moehlenhoff_alpha2/__init__.py @@ -19,7 +19,7 @@ async def mock_update_data(self): for _type in ("HEATAREA", "HEATCTRL", "IODEVICE"): if not isinstance(data["Devices"]["Device"][_type], list): data["Devices"]["Device"][_type] = [data["Devices"]["Device"][_type]] - self.static_data = data + self._static_data = data async def init_integration(hass: HomeAssistant) -> MockConfigEntry: From ea259ffa66db69e31781fd1c4a5efb0d70ff3a94 Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Tue, 18 Mar 2025 04:35:57 -0400 Subject: [PATCH 2718/3148] Add event to Snoo (#140827) --- homeassistant/components/snoo/__init__.py | 1 + homeassistant/components/snoo/event.py | 62 ++++++++++++++++++++++ homeassistant/components/snoo/strings.json | 21 +++++++- tests/components/snoo/test_event.py | 45 ++++++++++++++++ 4 files changed, 128 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/snoo/event.py create mode 100644 tests/components/snoo/test_event.py diff --git a/homeassistant/components/snoo/__init__.py b/homeassistant/components/snoo/__init__.py index 1934a2607a0..54834bf58ce 100644 --- a/homeassistant/components/snoo/__init__.py +++ b/homeassistant/components/snoo/__init__.py @@ -19,6 +19,7 @@ _LOGGER = logging.getLogger(__name__) PLATFORMS: list[Platform] = [ Platform.BINARY_SENSOR, + Platform.EVENT, Platform.SELECT, Platform.SENSOR, Platform.SWITCH, diff --git a/homeassistant/components/snoo/event.py b/homeassistant/components/snoo/event.py new file mode 100644 index 00000000000..5932bfd9862 --- /dev/null +++ b/homeassistant/components/snoo/event.py @@ -0,0 +1,62 @@ +"""Support for Snoo Events.""" + +from homeassistant.components.event import EventEntity, EventEntityDescription +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import SnooConfigEntry +from .entity import SnooDescriptionEntity + + +async def async_setup_entry( + hass: HomeAssistant, + entry: SnooConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Snoo device.""" + coordinators = entry.runtime_data + async_add_entities( + SnooEvent( + coordinator, + EventEntityDescription( + key="event", + translation_key="event", + event_types=[ + "timer", + "cry", + "command", + "safety_clip", + "long_activity_press", + "activity", + "power", + "status_requested", + "sticky_white_noise_updated", + ], + ), + ) + for coordinator in coordinators.values() + ) + + +class SnooEvent(SnooDescriptionEntity, EventEntity): + """A event using Snoo coordinator.""" + + @callback + def _async_handle_event(self) -> None: + """Handle the demo button event.""" + self._trigger_event( + self.coordinator.data.event.value, + ) + self.async_write_ha_state() + + async def async_added_to_hass(self) -> None: + """Add Event.""" + await super().async_added_to_hass() + if self.coordinator.data: + # If we were able to get data on startup - set it + # Otherwise, it will update when the coordinator gets data. + self._async_handle_event() + + def _handle_coordinator_update(self) -> None: + self._async_handle_event() + return super()._handle_coordinator_update() diff --git a/homeassistant/components/snoo/strings.json b/homeassistant/components/snoo/strings.json index ddeab83b6d4..f7cf6a4820b 100644 --- a/homeassistant/components/snoo/strings.json +++ b/homeassistant/components/snoo/strings.json @@ -41,7 +41,26 @@ "name": "Right safety clip" } }, - + "event": { + "event": { + "name": "Snoo event", + "state_attributes": { + "event_type": { + "state": { + "timer": "Timer", + "cry": "Cry", + "command": "Command sent", + "safety_clip": "Safety clip changed", + "long_activity_press": "Long activity press", + "activity": "Activity press", + "power": "Power button pressed", + "status_requested": "Status requested", + "sticky_white_noise_updated": "Sleepytime sounds updated" + } + } + } + } + }, "sensor": { "state": { "name": "State", diff --git a/tests/components/snoo/test_event.py b/tests/components/snoo/test_event.py new file mode 100644 index 00000000000..41cb386a599 --- /dev/null +++ b/tests/components/snoo/test_event.py @@ -0,0 +1,45 @@ +"""Test Snoo Events.""" + +from unittest.mock import AsyncMock + +from freezegun import freeze_time + +from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant + +from . import async_init_integration, find_update_callback +from .const import MOCK_SNOO_DATA + + +@freeze_time("2025-01-01 12:00:00") +async def test_events(hass: HomeAssistant, bypass_api: AsyncMock) -> None: + """Test events and check test values are correctly set.""" + await async_init_integration(hass) + assert len(hass.states.async_all("event")) == 1 + assert hass.states.get("event.test_snoo_snoo_event").state == STATE_UNAVAILABLE + find_update_callback(bypass_api, "random_num")(MOCK_SNOO_DATA) + await hass.async_block_till_done() + assert len(hass.states.async_all("event")) == 1 + assert ( + hass.states.get("event.test_snoo_snoo_event").state + == "2025-01-01T12:00:00.000+00:00" + ) + + +@freeze_time("2025-01-01 12:00:00") +async def test_events_data_on_startup( + hass: HomeAssistant, bypass_api: AsyncMock +) -> None: + """Test events and check test values are correctly set if data exists on first update.""" + + def update_status(_): + find_update_callback(bypass_api, "random_num")(MOCK_SNOO_DATA) + + bypass_api.get_status.side_effect = update_status + await async_init_integration(hass) + await hass.async_block_till_done() + assert len(hass.states.async_all("event")) == 1 + assert ( + hass.states.get("event.test_snoo_snoo_event").state + == "2025-01-01T12:00:00.000+00:00" + ) From 36d42760a436748e781058eb5178d79fdf7128d0 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 18 Mar 2025 10:07:05 +0100 Subject: [PATCH 2719/3148] Fix capitalization in `nextcloud` entity names (#140856) * Fix capitalization in `nextcloud` entity names Use uppercase for abbreviations, sentence-case for words. * Update test_sensor.ambr --- homeassistant/components/nextcloud/strings.json | 8 ++++---- .../nextcloud/snapshots/test_sensor.ambr | 16 ++++++++-------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/nextcloud/strings.json b/homeassistant/components/nextcloud/strings.json index 9b22a6924bc..ef4e3de0f62 100644 --- a/homeassistant/components/nextcloud/strings.json +++ b/homeassistant/components/nextcloud/strings.json @@ -88,7 +88,7 @@ "name": "Cache start time" }, "nextcloud_cache_ttl": { - "name": "Cache ttl" + "name": "Cache TTL" }, "nextcloud_database_size": { "name": "Database size" @@ -268,13 +268,13 @@ "name": "Updates available" }, "nextcloud_system_cpuload_1": { - "name": "CPU Load last 1 minute" + "name": "CPU load last 1 minute" }, "nextcloud_system_cpuload_15": { - "name": "CPU Load last 15 minutes" + "name": "CPU load last 15 minutes" }, "nextcloud_system_cpuload_5": { - "name": "CPU Load last 5 minutes" + "name": "CPU load last 5 minutes" }, "nextcloud_system_freespace": { "name": "Free space" diff --git a/tests/components/nextcloud/snapshots/test_sensor.ambr b/tests/components/nextcloud/snapshots/test_sensor.ambr index 84c1d33f886..e6154841a28 100644 --- a/tests/components/nextcloud/snapshots/test_sensor.ambr +++ b/tests/components/nextcloud/snapshots/test_sensor.ambr @@ -1424,7 +1424,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Cache ttl', + 'original_name': 'Cache TTL', 'platform': 'nextcloud', 'previous_unique_id': None, 'supported_features': 0, @@ -1436,7 +1436,7 @@ # name: test_async_setup_entry[sensor.my_nc_url_local_cache_ttl-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'my.nc_url.local Cache ttl', + 'friendly_name': 'my.nc_url.local Cache TTL', }), 'context': , 'entity_id': 'sensor.my_nc_url_local_cache_ttl', @@ -1474,7 +1474,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'CPU Load last 15 minutes', + 'original_name': 'CPU load last 15 minutes', 'platform': 'nextcloud', 'previous_unique_id': None, 'supported_features': 0, @@ -1486,7 +1486,7 @@ # name: test_async_setup_entry[sensor.my_nc_url_local_cpu_load_last_15_minutes-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'my.nc_url.local CPU Load last 15 minutes', + 'friendly_name': 'my.nc_url.local CPU load last 15 minutes', 'unit_of_measurement': 'load', }), 'context': , @@ -1525,7 +1525,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'CPU Load last 1 minute', + 'original_name': 'CPU load last 1 minute', 'platform': 'nextcloud', 'previous_unique_id': None, 'supported_features': 0, @@ -1537,7 +1537,7 @@ # name: test_async_setup_entry[sensor.my_nc_url_local_cpu_load_last_1_minute-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'my.nc_url.local CPU Load last 1 minute', + 'friendly_name': 'my.nc_url.local CPU load last 1 minute', 'unit_of_measurement': 'load', }), 'context': , @@ -1576,7 +1576,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'CPU Load last 5 minutes', + 'original_name': 'CPU load last 5 minutes', 'platform': 'nextcloud', 'previous_unique_id': None, 'supported_features': 0, @@ -1588,7 +1588,7 @@ # name: test_async_setup_entry[sensor.my_nc_url_local_cpu_load_last_5_minutes-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'my.nc_url.local CPU Load last 5 minutes', + 'friendly_name': 'my.nc_url.local CPU load last 5 minutes', 'unit_of_measurement': 'load', }), 'context': , From 603557af737b992e002d1ba43925129ab3c6edd4 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 18 Mar 2025 10:16:21 +0100 Subject: [PATCH 2720/3148] Improve description of `vicare.set_vicare_mode` action (#140826) Add some additional information from the online docs so they get included in translations. --- homeassistant/components/vicare/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/vicare/strings.json b/homeassistant/components/vicare/strings.json index 733cda363e5..04049f026bd 100644 --- a/homeassistant/components/vicare/strings.json +++ b/homeassistant/components/vicare/strings.json @@ -515,11 +515,11 @@ "services": { "set_vicare_mode": { "name": "Set ViCare mode", - "description": "Set a ViCare mode.", + "description": "Sets the mode of the climate device as defined by Viessmann.", "fields": { "vicare_mode": { "name": "ViCare mode", - "description": "ViCare mode." + "description": "For supported values, see the `vicare_modes` attribute of the climate entity." } } } From fdd36e457d11c2fad3d88feaa2f5aca4c75287c6 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Tue, 18 Mar 2025 10:19:45 +0100 Subject: [PATCH 2721/3148] Add Reolink day night state sensor (#140825) * Add day night state sensor * Update test_diagnostics.ambr --- homeassistant/components/reolink/icons.json | 3 +++ homeassistant/components/reolink/sensor.py | 11 +++++++++++ homeassistant/components/reolink/strings.json | 8 ++++++++ .../reolink/snapshots/test_diagnostics.ambr | 4 ++++ 4 files changed, 26 insertions(+) diff --git a/homeassistant/components/reolink/icons.json b/homeassistant/components/reolink/icons.json index 0b019277a77..bcfea0bebd1 100644 --- a/homeassistant/components/reolink/icons.json +++ b/homeassistant/components/reolink/icons.json @@ -365,6 +365,9 @@ "battery_state": { "default": "mdi:battery-charging" }, + "day_night_state": { + "default": "mdi:theme-light-dark" + }, "wifi_signal": { "default": "mdi:wifi" }, diff --git a/homeassistant/components/reolink/sensor.py b/homeassistant/components/reolink/sensor.py index ecad555b481..85de03dd1a3 100644 --- a/homeassistant/components/reolink/sensor.py +++ b/homeassistant/components/reolink/sensor.py @@ -107,6 +107,17 @@ SENSORS = ( value=lambda api, ch: BatteryEnum(api.battery_status(ch)).name, supported=lambda api, ch: api.supported(ch, "battery"), ), + ReolinkSensorEntityDescription( + key="day_night_state", + cmd_id=33, + cmd_key="296", + translation_key="day_night_state", + device_class=SensorDeviceClass.ENUM, + entity_category=EntityCategory.DIAGNOSTIC, + options=["day", "night", "led_day"], + value=lambda api, ch: api.baichuan.day_night_state(ch), + supported=lambda api, ch: api.supported(ch, "day_night_state"), + ), ) HOST_SENSORS = ( diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index a22c93611b6..80d9156e420 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -825,6 +825,14 @@ "chargecomplete": "Charge complete" } }, + "day_night_state": { + "name": "Day night state", + "state": { + "day": "Color", + "night": "Black & white", + "led_day": "Color with floodlight" + } + }, "hdd_storage": { "name": "HDD {hdd_index} storage" }, diff --git a/tests/components/reolink/snapshots/test_diagnostics.ambr b/tests/components/reolink/snapshots/test_diagnostics.ambr index f8d5318e9bd..b034122e1fc 100644 --- a/tests/components/reolink/snapshots/test_diagnostics.ambr +++ b/tests/components/reolink/snapshots/test_diagnostics.ambr @@ -62,6 +62,10 @@ 0, ]), 'cmd list': dict({ + '296': dict({ + '0': 1, + 'null': 1, + }), 'DingDongOpt': dict({ '0': 2, 'null': 2, From 5438532780829acdeda80e289844da851bb2dbbe Mon Sep 17 00:00:00 2001 From: EnjoyingM <6302356+mtielen@users.noreply.github.com> Date: Tue, 18 Mar 2025 10:22:32 +0100 Subject: [PATCH 2722/3148] Bump wolf-comm to 0.0.23 (#140840) * Bump wolf-comm to 0.0.23 * fix test for new lib --- homeassistant/components/wolflink/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/wolflink/conftest.py | 17 ++++++++++------- 4 files changed, 13 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/wolflink/manifest.json b/homeassistant/components/wolflink/manifest.json index 964d192d279..5f3a6366fe1 100644 --- a/homeassistant/components/wolflink/manifest.json +++ b/homeassistant/components/wolflink/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/wolflink", "iot_class": "cloud_polling", "loggers": ["wolf_comm"], - "requirements": ["wolf-comm==0.0.19"] + "requirements": ["wolf-comm==0.0.23"] } diff --git a/requirements_all.txt b/requirements_all.txt index 977c8a1f574..082c61b6fcb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3080,7 +3080,7 @@ wirelesstagpy==0.8.1 wled==0.21.0 # homeassistant.components.wolflink -wolf-comm==0.0.19 +wolf-comm==0.0.23 # homeassistant.components.wyoming wyoming==1.5.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 418e030d42f..9bf1f0979b3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2478,7 +2478,7 @@ wiffi==1.1.2 wled==0.21.0 # homeassistant.components.wolflink -wolf-comm==0.0.19 +wolf-comm==0.0.23 # homeassistant.components.wyoming wyoming==1.5.4 diff --git a/tests/components/wolflink/conftest.py b/tests/components/wolflink/conftest.py index 9c69c0d69bb..bfa41c4a4af 100644 --- a/tests/components/wolflink/conftest.py +++ b/tests/components/wolflink/conftest.py @@ -67,22 +67,25 @@ def mock_wolflink() -> Generator[MagicMock]: wolflink = wolflink_mock.return_value wolflink.fetch_parameters.return_value = [ - EnergyParameter(6002800000, "Energy Parameter", "Heating", 6005200000), + EnergyParameter( + 6002800000, "Energy Parameter", "Heating", 6005200000, 2000 + ), ListItemParameter( 8002800000, "List Item Parameter", "Heating", [ListItem("0", "Aus"), ListItem("1", "Ein")], 8005200000, + 3001, ), - PowerParameter(5002800000, "Power Parameter", "Heating", 5005200000), - Pressure(4002800000, "Pressure Parameter", "Heating", 4005200000), - Temperature(3002800000, "Temperature Parameter", "Solar", 3005200000), + PowerParameter(5002800000, "Power Parameter", "Heating", 5005200000, 1000), + Pressure(4002800000, "Pressure Parameter", "Heating", 4005200000, 1000), + Temperature(3002800000, "Temperature Parameter", "Solar", 3005200000, 1000), PercentageParameter( - 2002800000, "Percentage Parameter", "Solar", 2005200000 + 2002800000, "Percentage Parameter", "Solar", 2005200000, 1000 ), - HoursParameter(7002800000, "Hours Parameter", "Heating", 7005200000), - SimpleParameter(1002800000, "Simple Parameter", "DHW", 1005200000), + HoursParameter(7002800000, "Hours Parameter", "Heating", 7005200000, 1000), + SimpleParameter(1002800000, "Simple Parameter", "DHW", 1005200000, 1000), ] wolflink.fetch_value.return_value = [ From 30c19ec37354b1bc946cb5cbaf90a4c89d52a1d1 Mon Sep 17 00:00:00 2001 From: Dan Raper Date: Tue, 18 Mar 2025 09:36:21 +0000 Subject: [PATCH 2723/3148] Add reconfigure flow to Ohme (#140835) * Add reconfigure flow to Ohme * Remove incorrect unique ID check from ohme reconfig --- homeassistant/components/ohme/config_flow.py | 23 ++++++ .../components/ohme/quality_scale.yaml | 2 +- homeassistant/components/ohme/strings.json | 13 ++- tests/components/ohme/test_config_flow.py | 81 +++++++++++++++++++ 4 files changed, 117 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/ohme/config_flow.py b/homeassistant/components/ohme/config_flow.py index 748ea558983..1037c3a7c8b 100644 --- a/homeassistant/components/ohme/config_flow.py +++ b/homeassistant/components/ohme/config_flow.py @@ -99,6 +99,29 @@ class OhmeConfigFlow(ConfigFlow, domain=DOMAIN): errors=errors, ) + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle re-configuration.""" + errors: dict[str, str] = {} + reconfigure_entry = self._get_reconfigure_entry() + if user_input: + errors = await self._validate_account( + reconfigure_entry.data[CONF_EMAIL], + user_input[CONF_PASSWORD], + ) + if not errors: + return self.async_update_reload_and_abort( + reconfigure_entry, + data_updates=user_input, + ) + return self.async_show_form( + step_id="reconfigure", + data_schema=REAUTH_SCHEMA, + description_placeholders={"email": reconfigure_entry.data[CONF_EMAIL]}, + errors=errors, + ) + async def _validate_account(self, email: str, password: str) -> dict[str, str]: """Validate Ohme account and return dict of errors.""" errors: dict[str, str] = {} diff --git a/homeassistant/components/ohme/quality_scale.yaml b/homeassistant/components/ohme/quality_scale.yaml index ba814202cdc..f748cf339b4 100644 --- a/homeassistant/components/ohme/quality_scale.yaml +++ b/homeassistant/components/ohme/quality_scale.yaml @@ -62,7 +62,7 @@ rules: entity-translations: done exception-translations: done icon-translations: done - reconfiguration-flow: todo + reconfiguration-flow: done repair-issues: status: exempt comment: | diff --git a/homeassistant/components/ohme/strings.json b/homeassistant/components/ohme/strings.json index 1da17183bb2..4a2170babeb 100644 --- a/homeassistant/components/ohme/strings.json +++ b/homeassistant/components/ohme/strings.json @@ -21,6 +21,16 @@ "data_description": { "password": "Enter the password for your Ohme account" } + }, + "reconfigure": { + "description": "Update your password for {email}", + "title": "Reconfigure Ohme Account", + "data": { + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "password": "Enter the password for your Ohme account" + } } }, "error": { @@ -29,7 +39,8 @@ }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", - "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" } }, "services": { diff --git a/tests/components/ohme/test_config_flow.py b/tests/components/ohme/test_config_flow.py index bb7ecc00bdc..b8754711d76 100644 --- a/tests/components/ohme/test_config_flow.py +++ b/tests/components/ohme/test_config_flow.py @@ -182,3 +182,84 @@ async def test_reauth_fail( await hass.async_block_till_done() assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reauth_successful" + + +async def test_reconfigure_form(hass: HomeAssistant, mock_client: MagicMock) -> None: + """Test reconfigure form.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_EMAIL: "test@example.com", + CONF_PASSWORD: "hunter1", + }, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "reconfigure", "entry_id": entry.entry_id} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + assert not result["errors"] + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_PASSWORD: "hunter2"}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + +@pytest.mark.parametrize( + ("test_exception", "expected_error"), + [(AuthException, "invalid_auth"), (ApiException, "unknown")], +) +async def test_reconfigure_fail( + hass: HomeAssistant, + mock_client: MagicMock, + test_exception: Exception, + expected_error: str, +) -> None: + """Test reconfigure errors.""" + + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_EMAIL: "test@example.com", + CONF_PASSWORD: "hunter1", + }, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "reconfigure", "entry_id": entry.entry_id} + ) + + assert result["step_id"] == "reconfigure" + assert result["type"] is FlowResultType.FORM + assert not result["errors"] + + # Simulate failed login attempt + mock_client.async_login.side_effect = test_exception + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_PASSWORD: "hunter1"}, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": expected_error} + + # Retry with a successful login + mock_client.async_login.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_PASSWORD: "hunter2"}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" From 12f5bd2aea2430d2af37d6717a725766408790f8 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Tue, 18 Mar 2025 11:48:18 +0100 Subject: [PATCH 2724/3148] Add dedicated sensors for extra_state_attributes in Shelly integration (#140793) * Add dedicated sensors for extra_state_attributes in Shelly integration * add tests * apply review comment * fix text syntax * add gas test * update strings * add icons --- homeassistant/components/shelly/icons.json | 6 +++ homeassistant/components/shelly/sensor.py | 22 ++++++++++ homeassistant/components/shelly/strings.json | 36 +++++++++++++++++ tests/components/shelly/conftest.py | 2 + tests/components/shelly/test_sensor.py | 42 +++++++++++++++++--- 5 files changed, 103 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/shelly/icons.json b/homeassistant/components/shelly/icons.json index f93abf6b854..08b269a73c5 100644 --- a/homeassistant/components/shelly/icons.json +++ b/homeassistant/components/shelly/icons.json @@ -23,12 +23,18 @@ "gas_concentration": { "default": "mdi:gauge" }, + "gas_detected": { + "default": "mdi:gas-burner" + }, "lamp_life": { "default": "mdi:progress-wrench" }, "operation": { "default": "mdi:cog-transfer" }, + "self_test": { + "default": "mdi:progress-wrench" + }, "tilt": { "default": "mdi:angle-acute" }, diff --git a/homeassistant/components/shelly/sensor.py b/homeassistant/components/shelly/sensor.py index f2c858aeb84..b6820921b4f 100644 --- a/homeassistant/components/shelly/sensor.py +++ b/homeassistant/components/shelly/sensor.py @@ -397,6 +397,28 @@ SENSORS: dict[tuple[str, str], BlockSensorDescription] = { entity_category=EntityCategory.DIAGNOSTIC, removal_condition=lambda _, block: block.valve == "not_connected", ), + ("sensor", "gas"): BlockSensorDescription( + key="sensor|gas", + name="Gas detected", + translation_key="gas_detected", + device_class=SensorDeviceClass.ENUM, + options=[ + "none", + "mild", + "heavy", + "test", + ], + value=lambda value: None if value == "unknown" else value, + entity_category=EntityCategory.DIAGNOSTIC, + ), + ("sensor", "selfTest"): BlockSensorDescription( + key="sensor|selfTest", + name="Self test", + translation_key="self_test", + device_class=SensorDeviceClass.ENUM, + options=["not_completed", "completed", "running", "pending"], + entity_category=EntityCategory.DIAGNOSTIC, + ), } REST_SENSORS: Final = { diff --git a/homeassistant/components/shelly/strings.json b/homeassistant/components/shelly/strings.json index cc511c93afe..ba9a8492194 100644 --- a/homeassistant/components/shelly/strings.json +++ b/homeassistant/components/shelly/strings.json @@ -138,6 +138,24 @@ } }, "sensor": { + "gas_detected": { + "state": { + "none": "None", + "mild": "Mild", + "heavy": "Heavy", + "test": "Test" + }, + "state_attributes": { + "options": { + "state": { + "none": "[%key:component::shelly::entity::sensor::gas_detected::state::none%]", + "mild": "[%key:component::shelly::entity::sensor::gas_detected::state::mild%]", + "heavy": "[%key:component::shelly::entity::sensor::gas_detected::state::heavy%]", + "test": "[%key:component::shelly::entity::sensor::gas_detected::state::test%]" + } + } + } + }, "operation": { "state": { "warmup": "Warm-up", @@ -155,6 +173,24 @@ } } }, + "self_test": { + "state": { + "not_completed": "Not completed", + "completed": "Completed", + "running": "Running", + "pending": "Pending" + }, + "state_attributes": { + "options": { + "state": { + "not_completed": "[%key:component::shelly::entity::sensor::self_test::state::not_completed%]", + "completed": "[%key:component::shelly::entity::sensor::self_test::state::completed%]", + "running": "[%key:component::shelly::entity::sensor::self_test::state::running%]", + "pending": "[%key:component::shelly::entity::sensor::self_test::state::pending%]" + } + } + } + }, "valve_status": { "state": { "checking": "Checking", diff --git a/tests/components/shelly/conftest.py b/tests/components/shelly/conftest.py index c68d52526c5..8030df6e473 100644 --- a/tests/components/shelly/conftest.py +++ b/tests/components/shelly/conftest.py @@ -142,12 +142,14 @@ MOCK_BLOCKS = [ "gas": "mild", "motionActive": 1, "sensorOp": "normal", + "selfTest": "pending", }, channel="0", motion=0, temp=22.1, gas="mild", sensorOp="normal", + selfTest="pending", targetTemp=4, description="sensor_0", type="sensor", diff --git a/tests/components/shelly/test_sensor.py b/tests/components/shelly/test_sensor.py index 00db4ade8ac..5c1f03de3e8 100644 --- a/tests/components/shelly/test_sensor.py +++ b/tests/components/shelly/test_sensor.py @@ -346,13 +346,44 @@ async def test_block_sensor_without_value( @pytest.mark.parametrize( - ("entity", "initial_state", "block_id", "attribute", "value"), + ("entity", "initial_state", "block_id", "attribute", "value", "final_value"), [ - ("test_name_battery", "98", DEVICE_BLOCK_ID, "battery", None), - ("test_name_operation", "normal", SENSOR_BLOCK_ID, "sensorOp", "unknown"), + ("test_name_battery", "98", DEVICE_BLOCK_ID, "battery", None, STATE_UNKNOWN), + ( + "test_name_operation", + "normal", + SENSOR_BLOCK_ID, + "sensorOp", + None, + STATE_UNKNOWN, + ), + ( + "test_name_operation", + "normal", + SENSOR_BLOCK_ID, + "sensorOp", + "normal", + "normal", + ), + ( + "test_name_self_test", + "pending", + SENSOR_BLOCK_ID, + "selfTest", + "completed", + "completed", + ), + ( + "test_name_gas_detected", + "mild", + SENSOR_BLOCK_ID, + "gas", + "heavy", + "heavy", + ), ], ) -async def test_block_sensor_unknown_value( +async def test_block_sensor_values( hass: HomeAssistant, mock_block_device: Mock, monkeypatch: pytest.MonkeyPatch, @@ -361,6 +392,7 @@ async def test_block_sensor_unknown_value( block_id: int, attribute: str, value: str | None, + final_value: str, ) -> None: """Test block sensor unknown value.""" entity_id = f"{SENSOR_DOMAIN}.{entity}" @@ -371,7 +403,7 @@ async def test_block_sensor_unknown_value( monkeypatch.setattr(mock_block_device.blocks[block_id], attribute, value) mock_block_device.mock_update() - assert hass.states.get(entity_id).state == STATE_UNKNOWN + assert hass.states.get(entity_id).state == final_value @pytest.mark.parametrize( From 516aaa741d3b46c79342abf433facc2b608af362 Mon Sep 17 00:00:00 2001 From: Petar Petrov Date: Tue, 18 Mar 2025 13:05:10 +0200 Subject: [PATCH 2725/3148] Add Z-Wave JS lookup_device API (#140802) * ZwaveJS lookup_device API * add FailedCommand test * test tweak --- homeassistant/components/zwave_js/api.py | 36 +++++++ tests/components/zwave_js/test_api.py | 126 ++++++++++++++++++++++- 2 files changed, 161 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/zwave_js/api.py b/homeassistant/components/zwave_js/api.py index a3d1416962e..ec164e2b505 100644 --- a/homeassistant/components/zwave_js/api.py +++ b/homeassistant/components/zwave_js/api.py @@ -405,6 +405,7 @@ def async_register_api(hass: HomeAssistant) -> None: websocket_api.async_register_command( hass, websocket_try_parse_dsk_from_qr_code_string ) + websocket_api.async_register_command(hass, websocket_lookup_device) websocket_api.async_register_command(hass, websocket_supports_feature) websocket_api.async_register_command(hass, websocket_stop_inclusion) websocket_api.async_register_command(hass, websocket_stop_exclusion) @@ -1138,6 +1139,41 @@ async def websocket_try_parse_dsk_from_qr_code_string( ) +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zwave_js/lookup_device", + vol.Required(ENTRY_ID): str, + vol.Required(MANUFACTURER_ID): int, + vol.Required(PRODUCT_TYPE): int, + vol.Required(PRODUCT_ID): int, + vol.Optional(APPLICATION_VERSION): str, + } +) +@websocket_api.async_response +@async_handle_failed_command +@async_get_entry +async def websocket_lookup_device( + hass: HomeAssistant, + connection: ActiveConnection, + msg: dict[str, Any], + entry: ConfigEntry, + client: Client, + driver: Driver, +) -> None: + """Look up the definition of a given device in the configuration DB.""" + device = await driver.config_manager.lookup_device( + msg[MANUFACTURER_ID], + msg[PRODUCT_TYPE], + msg[PRODUCT_ID], + msg.get(APPLICATION_VERSION), + ) + if device is None: + connection.send_error(msg[ID], ERR_NOT_FOUND, "Device not found") + else: + connection.send_result(msg[ID], device.to_dict()) + + @websocket_api.require_admin @websocket_api.websocket_command( { diff --git a/tests/components/zwave_js/test_api.py b/tests/components/zwave_js/test_api.py index 07c874197b6..b2741a53a92 100644 --- a/tests/components/zwave_js/test_api.py +++ b/tests/components/zwave_js/test_api.py @@ -5,7 +5,7 @@ from http import HTTPStatus from io import BytesIO import json from typing import Any -from unittest.mock import MagicMock, PropertyMock, patch +from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch import pytest from zwave_js_server.const import ( @@ -5577,3 +5577,127 @@ async def test_subscribe_s2_inclusion( msg = await ws_client.receive_json() assert not msg["success"] assert msg["error"]["code"] == ERR_NOT_FOUND + + +async def test_lookup_device( + hass: HomeAssistant, + integration: MockConfigEntry, + client: MagicMock, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test lookup_device websocket command.""" + entry = integration + ws_client = await hass_ws_client(hass) + + # Create mock device response + mock_device = MagicMock() + mock_device.to_dict.return_value = { + "manufacturer": "Test Manufacturer", + "label": "Test Device", + "description": "Test Device Description", + "devices": [{"productType": 1, "productId": 2}], + "firmwareVersion": {"min": "1.0", "max": "2.0"}, + } + + # Test successful lookup + client.driver.config_manager.lookup_device = AsyncMock(return_value=mock_device) + + await ws_client.send_json_auto_id( + { + TYPE: "zwave_js/lookup_device", + ENTRY_ID: entry.entry_id, + MANUFACTURER_ID: 1, + PRODUCT_TYPE: 2, + PRODUCT_ID: 3, + APPLICATION_VERSION: "1.5", + } + ) + msg = await ws_client.receive_json() + + assert msg["success"] + assert msg["result"] == mock_device.to_dict.return_value + + client.driver.config_manager.lookup_device.assert_called_once_with(1, 2, 3, "1.5") + + # Reset mock + client.driver.config_manager.lookup_device.reset_mock() + + # Test lookup without optional application_version + await ws_client.send_json_auto_id( + { + TYPE: "zwave_js/lookup_device", + ENTRY_ID: entry.entry_id, + MANUFACTURER_ID: 4, + PRODUCT_TYPE: 5, + PRODUCT_ID: 6, + } + ) + msg = await ws_client.receive_json() + + assert msg["success"] + assert msg["result"] == mock_device.to_dict.return_value + + client.driver.config_manager.lookup_device.assert_called_once_with(4, 5, 6, None) + + # Test device not found + with patch.object( + client.driver.config_manager, + "lookup_device", + return_value=None, + ): + await ws_client.send_json_auto_id( + { + TYPE: "zwave_js/lookup_device", + ENTRY_ID: entry.entry_id, + MANUFACTURER_ID: 99, + PRODUCT_TYPE: 99, + PRODUCT_ID: 99, + APPLICATION_VERSION: "9.9", + } + ) + msg = await ws_client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == ERR_NOT_FOUND + assert msg["error"]["message"] == "Device not found" + + # Test sending command with improper entry ID fails + await ws_client.send_json_auto_id( + { + TYPE: "zwave_js/lookup_device", + ENTRY_ID: "invalid_entry_id", + MANUFACTURER_ID: 1, + PRODUCT_TYPE: 1, + PRODUCT_ID: 1, + APPLICATION_VERSION: "1.0", + } + ) + msg = await ws_client.receive_json() + assert not msg["success"] + assert msg["error"]["code"] == ERR_NOT_FOUND + assert msg["error"]["message"] == "Config entry invalid_entry_id not found" + + # Test FailedCommand exception + error_message = "Failed to execute lookup_device command" + with patch.object( + client.driver.config_manager, + "lookup_device", + side_effect=FailedCommand("lookup_device", error_message), + ): + # Send the subscription request + await ws_client.send_json_auto_id( + { + TYPE: "zwave_js/lookup_device", + ENTRY_ID: entry.entry_id, + MANUFACTURER_ID: 1, + PRODUCT_TYPE: 2, + PRODUCT_ID: 3, + APPLICATION_VERSION: "1.0", + } + ) + + # Verify error response + msg = await ws_client.receive_json() + assert not msg["success"] + assert msg["error"]["code"] == error_message + assert msg["error"]["message"] == f"Command failed: {error_message}" From 29f03f5b875d45aa70557c18a8be0249fe6f463f Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Tue, 18 Mar 2025 12:23:51 +0100 Subject: [PATCH 2726/3148] Add exception translations for AccuWeather integration (#140863) * Add exception translations * Improve error strings --- homeassistant/components/accuweather/coordinator.py | 12 ++++++++++-- homeassistant/components/accuweather/strings.json | 8 ++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/accuweather/coordinator.py b/homeassistant/components/accuweather/coordinator.py index 67e3e2ad76e..780c977f930 100644 --- a/homeassistant/components/accuweather/coordinator.py +++ b/homeassistant/components/accuweather/coordinator.py @@ -75,7 +75,11 @@ class AccuWeatherObservationDataUpdateCoordinator( async with timeout(10): result = await self.accuweather.async_get_current_conditions() except EXCEPTIONS as error: - raise UpdateFailed(error) from error + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="current_conditions_update_error", + translation_placeholders={"error": repr(error)}, + ) from error _LOGGER.debug("Requests remaining: %d", self.accuweather.requests_remaining) @@ -121,7 +125,11 @@ class AccuWeatherDailyForecastDataUpdateCoordinator( language=self.hass.config.language ) except EXCEPTIONS as error: - raise UpdateFailed(error) from error + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="forecast_update_error", + translation_placeholders={"error": repr(error)}, + ) from error _LOGGER.debug("Requests remaining: %d", self.accuweather.requests_remaining) diff --git a/homeassistant/components/accuweather/strings.json b/homeassistant/components/accuweather/strings.json index 92428a9d599..e1a71c5e1a5 100644 --- a/homeassistant/components/accuweather/strings.json +++ b/homeassistant/components/accuweather/strings.json @@ -229,6 +229,14 @@ } } }, + "exceptions": { + "current_conditions_update_error": { + "message": "An error occurred while retrieving weather current conditions data from the AccuWeather API: {error}" + }, + "forecast_update_error": { + "message": "An error occurred while retrieving weather forecast data from the AccuWeather API: {error}" + } + }, "system_health": { "info": { "can_reach_server": "Reach AccuWeather server", From de1823070ffddafc9001be97234125a6e08b4611 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 18 Mar 2025 12:55:32 +0100 Subject: [PATCH 2727/3148] Replace unsupported markup of examples in `humidifier.set_mode` action (#140824) Markup language is not supported in the action UI. Thus the underscores for italics are replaced with quote marks. --- homeassistant/components/humidifier/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/humidifier/strings.json b/homeassistant/components/humidifier/strings.json index 753368dc572..436f7df8312 100644 --- a/homeassistant/components/humidifier/strings.json +++ b/homeassistant/components/humidifier/strings.json @@ -89,7 +89,7 @@ "fields": { "mode": { "name": "Mode", - "description": "Operation mode. For example, _normal_, _eco_, or _away_. For a list of possible values, refer to the integration documentation." + "description": "Operation mode. For example, \"normal\", \"eco\", or \"away\". For a list of possible values, refer to the integration documentation." } } }, From 1cae866da968a842a2c2a42110adece71d490079 Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Tue, 18 Mar 2025 10:34:02 -0400 Subject: [PATCH 2728/3148] Update Roborock Map on status change (#140873) * update map on status change * Update tests/components/roborock/test_image.py Co-authored-by: Allen Porter * update code to handle state logic within async_update_data * Update homeassistant/components/roborock/coordinator.py Co-authored-by: Allen Porter * move previous_state and allow update on None --------- Co-authored-by: Allen Porter --- .../components/roborock/coordinator.py | 14 +++-- tests/components/roborock/test_image.py | 55 +++++++++++++++++-- 2 files changed, 59 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/roborock/coordinator.py b/homeassistant/components/roborock/coordinator.py index e430e2f6301..c333b143b10 100644 --- a/homeassistant/components/roborock/coordinator.py +++ b/homeassistant/components/roborock/coordinator.py @@ -279,6 +279,7 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): async def _async_update_data(self) -> DeviceProp: """Update data via library.""" + previous_state = self.roborock_device_info.props.status.state_name try: # Update device props and standard api information await self._update_device_prop() @@ -288,11 +289,14 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): # If the vacuum is currently cleaning and it has been IMAGE_CACHE_INTERVAL # since the last map update, you can update the map. - if ( - self.current_map is not None - and self.roborock_device_info.props.status.in_cleaning - and (dt_util.utcnow() - self.maps[self.current_map].last_updated) - > IMAGE_CACHE_INTERVAL + new_status = self.roborock_device_info.props.status + if self.current_map is not None and ( + ( + new_status.in_cleaning + and (dt_util.utcnow() - self.maps[self.current_map].last_updated) + > IMAGE_CACHE_INTERVAL + ) + or previous_state != new_status.state_name ): try: await self.update_map() diff --git a/tests/components/roborock/test_image.py b/tests/components/roborock/test_image.py index 0cd9d625920..b7c811e0ce2 100644 --- a/tests/components/roborock/test_image.py +++ b/tests/components/roborock/test_image.py @@ -11,6 +11,7 @@ from roborock import RoborockException from vacuum_map_parser_base.map_data import ImageConfig, ImageData from homeassistant.components.roborock import DOMAIN +from homeassistant.components.roborock.const import V1_LOCAL_NOT_CLEANING_INTERVAL from homeassistant.config_entries import ConfigEntryState from homeassistant.const import Platform from homeassistant.core import HomeAssistant @@ -158,9 +159,6 @@ async def test_fail_to_load_image( ) -> None: """Test that we gracefully handle failing to load an image.""" with ( - patch( - "homeassistant.components.roborock.coordinator.RoborockMapDataParser.parse", - ) as parse_map, patch( "homeassistant.components.roborock.roborock_storage.Path.exists", return_value=True, @@ -177,8 +175,6 @@ async def test_fail_to_load_image( await hass.config_entries.async_reload(setup_entry.entry_id) await hass.async_block_till_done() assert read_bytes.call_count == 4 - # Ensure that we never updated the map manually since we couldn't load it. - assert parse_map.call_count == 0 assert "Unable to read map file" in caplog.text @@ -298,3 +294,52 @@ async def test_index_error_map( # last_updated timestamp. assert resp.ok assert previous_state == hass.states.get("image.roborock_s7_maxv_upstairs").state + + +async def test_map_status_change( + hass: HomeAssistant, + setup_entry: MockConfigEntry, + hass_client: ClientSessionGenerator, +) -> None: + """Test floor plan map image is correctly updated on status change.""" + assert len(hass.states.async_all("image")) == 4 + + assert hass.states.get("image.roborock_s7_maxv_upstairs") is not None + client = await hass_client() + resp = await client.get("/api/image_proxy/image.roborock_s7_maxv_upstairs") + assert resp.status == HTTPStatus.OK + old_body = await resp.read() + assert old_body[0:4] == b"\x89PNG" + + # Call a second time. This interval does not directly trigger a map update, but does + # trigger a status update which detects the state has changed and uddates the map + now = dt_util.utcnow() + V1_LOCAL_NOT_CLEANING_INTERVAL + + # Copy the device prop so we don't override it + prop = copy.deepcopy(PROP) + prop.status.state_name = "testing" + new_map_data = copy.deepcopy(MAP_DATA) + new_map_data.image = ImageData( + 100, 10, 10, 10, 10, ImageConfig(), Image.new("RGB", (2, 2)), lambda p: p + ) + with ( + patch( + "homeassistant.components.roborock.coordinator.RoborockLocalClientV1.get_prop", + return_value=prop, + ), + patch( + "homeassistant.components.roborock.coordinator.dt_util.utcnow", + return_value=now, + ), + patch( + "homeassistant.components.roborock.coordinator.RoborockMapDataParser.parse", + return_value=new_map_data, + ), + ): + async_fire_time_changed(hass, now) + resp = await client.get("/api/image_proxy/image.roborock_s7_maxv_upstairs") + assert resp.status == HTTPStatus.OK + assert resp.status == HTTPStatus.OK + body = await resp.read() + assert body is not None + assert body != old_body From 4176776d70900c6c62fb64164a90bf6f0da77b50 Mon Sep 17 00:00:00 2001 From: Maikel Punie Date: Tue, 18 Mar 2025 15:49:27 +0100 Subject: [PATCH 2729/3148] Fix optional password in Velbus config flow (#140615) * Fix velbusconfigflow * add tests * Paramtize the tests * Removed duplicate test in favor of another case * more comments --- .../components/velbus/config_flow.py | 2 +- tests/components/velbus/test_config_flow.py | 66 ++++++++----------- 2 files changed, 27 insertions(+), 41 deletions(-) diff --git a/homeassistant/components/velbus/config_flow.py b/homeassistant/components/velbus/config_flow.py index fc5da92588a..7c93d8784ad 100644 --- a/homeassistant/components/velbus/config_flow.py +++ b/homeassistant/components/velbus/config_flow.py @@ -63,7 +63,7 @@ class VelbusConfigFlow(ConfigFlow, domain=DOMAIN): self._device = "tls://" else: self._device = "" - if user_input[CONF_PASSWORD] != "": + if CONF_PASSWORD in user_input and user_input[CONF_PASSWORD] != "": self._device += f"{user_input[CONF_PASSWORD]}@" self._device += f"{user_input[CONF_HOST]}:{user_input[CONF_PORT]}" self._async_abort_entries_match({CONF_PORT: self._device}) diff --git a/tests/components/velbus/test_config_flow.py b/tests/components/velbus/test_config_flow.py index ee714624b45..36d658f9633 100644 --- a/tests/components/velbus/test_config_flow.py +++ b/tests/components/velbus/test_config_flow.py @@ -59,43 +59,30 @@ def mock_controller_connection_failed(): @pytest.mark.usefixtures("controller") -async def test_user_network_succes(hass: HomeAssistant) -> None: - """Test user network config.""" - # inttial menu show - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} - ) - assert result - assert result.get("flow_id") - assert result.get("type") is FlowResultType.MENU - assert result.get("step_id") == "user" - assert result.get("menu_options") == ["network", "usbselect"] - # select the network option - result = await hass.config_entries.flow.async_configure( - result.get("flow_id"), - {"next_step_id": "network"}, - ) - assert result.get("type") is FlowResultType.FORM - # fill in the network form - result = await hass.config_entries.flow.async_configure( - result.get("flow_id"), - { - CONF_TLS: False, - CONF_HOST: "velbus", - CONF_PORT: 6000, - CONF_PASSWORD: "", - }, - ) - assert result - assert result.get("type") is FlowResultType.CREATE_ENTRY - assert result.get("title") == "Velbus Network" - data = result.get("data") - assert data - assert data[CONF_PORT] == "velbus:6000" - - -@pytest.mark.usefixtures("controller") -async def test_user_network_succes_tls(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + ("inputParams", "expected"), + [ + ( + { + CONF_TLS: True, + CONF_PASSWORD: "password", + }, + "tls://password@velbus:6000", + ), + ( + { + CONF_TLS: True, + CONF_PASSWORD: "", + }, + "tls://velbus:6000", + ), + ({CONF_TLS: True}, "tls://velbus:6000"), + ({CONF_TLS: False}, "velbus:6000"), + ], +) +async def test_user_network_succes( + hass: HomeAssistant, inputParams: str, expected: str +) -> None: """Test user network config.""" # inttial menu show result = await hass.config_entries.flow.async_init( @@ -116,10 +103,9 @@ async def test_user_network_succes_tls(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_configure( result.get("flow_id"), { - CONF_TLS: True, CONF_HOST: "velbus", CONF_PORT: 6000, - CONF_PASSWORD: "password", + **inputParams, }, ) assert result @@ -127,7 +113,7 @@ async def test_user_network_succes_tls(hass: HomeAssistant) -> None: assert result.get("title") == "Velbus Network" data = result.get("data") assert data - assert data[CONF_PORT] == "tls://password@velbus:6000" + assert data[CONF_PORT] == expected @pytest.mark.usefixtures("controller") From a170e328525774036805a4d20bfcf03154c2fbd2 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Tue, 18 Mar 2025 16:29:21 +0100 Subject: [PATCH 2730/3148] Deprecate Shelly state attributes (#140791) --- homeassistant/components/shelly/binary_sensor.py | 1 + homeassistant/components/shelly/sensor.py | 2 ++ 2 files changed, 3 insertions(+) diff --git a/homeassistant/components/shelly/binary_sensor.py b/homeassistant/components/shelly/binary_sensor.py index ed2ac68d264..b74578f1fb3 100644 --- a/homeassistant/components/shelly/binary_sensor.py +++ b/homeassistant/components/shelly/binary_sensor.py @@ -130,6 +130,7 @@ SENSORS: dict[tuple[str, str], BlockBinarySensorDescription] = { device_class=BinarySensorDeviceClass.GAS, translation_key="gas", value=lambda value: value in ["mild", "heavy"], + # Deprecated, remove in 2025.10 extra_state_attributes=lambda block: {"detected": block.gas}, ), ("sensor", "smoke"): BlockBinarySensorDescription( diff --git a/homeassistant/components/shelly/sensor.py b/homeassistant/components/shelly/sensor.py index b6820921b4f..79e4c97aead 100644 --- a/homeassistant/components/shelly/sensor.py +++ b/homeassistant/components/shelly/sensor.py @@ -358,6 +358,7 @@ SENSORS: dict[tuple[str, str], BlockSensorDescription] = { translation_key="lamp_life", value=get_shelly_air_lamp_life, suggested_display_precision=1, + # Deprecated, remove in 2025.10 extra_state_attributes=lambda block: { "Operational hours": round(cast(int, block.totalWorkTime) / 3600, 1) }, @@ -378,6 +379,7 @@ SENSORS: dict[tuple[str, str], BlockSensorDescription] = { options=["warmup", "normal", "fault"], translation_key="operation", value=lambda value: None if value == "unknown" else value, + # Deprecated, remove in 2025.10 extra_state_attributes=lambda block: {"self_test": block.selfTest}, ), ("valve", "valve"): BlockSensorDescription( From e2460a43937da12d8bfc50dc458487f9582cf873 Mon Sep 17 00:00:00 2001 From: Markus Adrario Date: Tue, 18 Mar 2025 16:32:14 +0100 Subject: [PATCH 2731/3148] bump pyHomee to 1.2.8 (#140870) --- homeassistant/components/homee/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/homee/manifest.json b/homeassistant/components/homee/manifest.json index e4622222be1..3c2a99c30dc 100644 --- a/homeassistant/components/homee/manifest.json +++ b/homeassistant/components/homee/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "loggers": ["homee"], "quality_scale": "bronze", - "requirements": ["pyHomee==1.2.7"] + "requirements": ["pyHomee==1.2.8"] } diff --git a/requirements_all.txt b/requirements_all.txt index 082c61b6fcb..bf8ac5df9ff 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1783,7 +1783,7 @@ pyEmby==1.10 pyHik==0.3.2 # homeassistant.components.homee -pyHomee==1.2.7 +pyHomee==1.2.8 # homeassistant.components.rfxtrx pyRFXtrx==0.31.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9bf1f0979b3..ffa587ad5cf 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1469,7 +1469,7 @@ pyDuotecno==2024.10.1 pyElectra==1.2.4 # homeassistant.components.homee -pyHomee==1.2.7 +pyHomee==1.2.8 # homeassistant.components.rfxtrx pyRFXtrx==0.31.1 From 4564d2537bb0cd756d368c141c58c0a0f0d0ffb8 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Tue, 18 Mar 2025 16:38:34 +0100 Subject: [PATCH 2732/3148] Fix flakey reolink test (#140877) --- tests/components/reolink/conftest.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/components/reolink/conftest.py b/tests/components/reolink/conftest.py index cd793b9b620..1fa46271353 100644 --- a/tests/components/reolink/conftest.py +++ b/tests/components/reolink/conftest.py @@ -141,6 +141,7 @@ def reolink_connect_class() -> Generator[MagicMock]: host_mock.baichuan.port = TEST_BC_PORT host_mock.baichuan.events_active = False host_mock.baichuan.privacy_mode.return_value = False + host_mock.baichuan.day_night_state.return_value = "day" host_mock.baichuan.subscribe_events.side_effect = ReolinkError("Test error") host_mock.baichuan.abilities = { 0: {"chnID": 0, "aitype": 34615}, From 11e02f89cf4e7388d74b43ed364c93a87b53b960 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Tue, 18 Mar 2025 16:40:47 +0100 Subject: [PATCH 2733/3148] Add exception translations for Brother integration (#140868) Add exception translations --- homeassistant/components/brother/__init__.py | 10 +++++++++- homeassistant/components/brother/coordinator.py | 10 +++++++++- homeassistant/components/brother/strings.json | 8 ++++++++ 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/brother/__init__.py b/homeassistant/components/brother/__init__.py index 464e6629224..1c1768b58fd 100644 --- a/homeassistant/components/brother/__init__.py +++ b/homeassistant/components/brother/__init__.py @@ -9,6 +9,7 @@ from homeassistant.const import CONF_HOST, CONF_TYPE, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady +from .const import DOMAIN from .coordinator import BrotherConfigEntry, BrotherDataUpdateCoordinator PLATFORMS = [Platform.SENSOR] @@ -25,7 +26,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: BrotherConfigEntry) -> b host, printer_type=printer_type, snmp_engine=snmp_engine ) except (ConnectionError, SnmpError, TimeoutError) as error: - raise ConfigEntryNotReady from error + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="cannot_connect", + translation_placeholders={ + "device": entry.title, + "error": repr(error), + }, + ) from error coordinator = BrotherDataUpdateCoordinator(hass, entry, brother) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/brother/coordinator.py b/homeassistant/components/brother/coordinator.py index 4f518ba8a25..a3c337f27f7 100644 --- a/homeassistant/components/brother/coordinator.py +++ b/homeassistant/components/brother/coordinator.py @@ -26,6 +26,7 @@ class BrotherDataUpdateCoordinator(DataUpdateCoordinator[BrotherSensors]): ) -> None: """Initialize.""" self.brother = brother + self.device_name = config_entry.title super().__init__( hass, @@ -41,5 +42,12 @@ class BrotherDataUpdateCoordinator(DataUpdateCoordinator[BrotherSensors]): async with timeout(20): data = await self.brother.async_update() except (ConnectionError, SnmpError, UnsupportedModelError) as error: - raise UpdateFailed(error) from error + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_error", + translation_placeholders={ + "device": self.device_name, + "error": repr(error), + }, + ) from error return data diff --git a/homeassistant/components/brother/strings.json b/homeassistant/components/brother/strings.json index b502ed7e3b9..d0714a199c4 100644 --- a/homeassistant/components/brother/strings.json +++ b/homeassistant/components/brother/strings.json @@ -159,5 +159,13 @@ "name": "Last restart" } } + }, + "exceptions": { + "cannot_connect": { + "message": "An error occurred while connecting to the {device} printer: {error}" + }, + "update_error": { + "message": "An error occurred while retrieving data from the {device} printer: {error}" + } } } From f8ab4d0238d18732842314eea853ef6c7b49e69c Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Tue, 18 Mar 2025 16:47:33 +0100 Subject: [PATCH 2734/3148] Fix warnings in Reolink tests (#140878) --- tests/components/reolink/conftest.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/components/reolink/conftest.py b/tests/components/reolink/conftest.py index 1fa46271353..672919bc7a9 100644 --- a/tests/components/reolink/conftest.py +++ b/tests/components/reolink/conftest.py @@ -134,6 +134,8 @@ def reolink_connect_class() -> Generator[MagicMock]: host_mock.doorbell_led_list.return_value = ["stayoff", "auto"] host_mock.auto_track_method.return_value = 3 host_mock.daynight_state.return_value = "Black&White" + host_mock.hub_alarm_tone_id.return_value = 1 + host_mock.hub_visitor_tone_id.return_value = 1 # Baichuan host_mock.baichuan = create_autospec(Baichuan) From 2d82a12e0a85742c19408ec8b6e465de7908328a Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 18 Mar 2025 19:47:14 +0100 Subject: [PATCH 2735/3148] Make description of `homeassistant.reload_all` action consistent (#140887) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Change it to "Reloads …" like all other `homeassistant.reload_xyz` actions. --- homeassistant/components/homeassistant/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/homeassistant/strings.json b/homeassistant/components/homeassistant/strings.json index 4ca56471452..b8b5f77cf52 100644 --- a/homeassistant/components/homeassistant/strings.json +++ b/homeassistant/components/homeassistant/strings.json @@ -188,7 +188,7 @@ }, "reload_all": { "name": "Reload all", - "description": "Reload all YAML configuration that can be reloaded without restarting Home Assistant." + "description": "Reloads all YAML configuration that can be reloaded without restarting Home Assistant." } }, "exceptions": { From 07302ea1788b1dcae2e612383492f11f14f110aa Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 18 Mar 2025 20:27:21 +0100 Subject: [PATCH 2736/3148] =?UTF-8?q?Fix=20duplicate=20descriptions=20of?= =?UTF-8?q?=20`homematicip=5Fcloud.activate=5Feco=5Fmode=5Fwith=5F?= =?UTF-8?q?=E2=80=A6`=20actions=20(#140885)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update strings.json Currently both the `activate_eco_mode_with_duration` and the `activate_eco_mode_with_period` actions have the identical description: "Activates eco mode with period." To resolve this confusing duplicate, both actions get their own descriptions, making the latter consistent with that of the `activate_vacation` action. --- homeassistant/components/homematicip_cloud/strings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/homematicip_cloud/strings.json b/homeassistant/components/homematicip_cloud/strings.json index 228ebc7500e..7b1b08ac4e2 100644 --- a/homeassistant/components/homematicip_cloud/strings.json +++ b/homeassistant/components/homematicip_cloud/strings.json @@ -35,7 +35,7 @@ "services": { "activate_eco_mode_with_duration": { "name": "Activate eco mode with duration", - "description": "Activates eco mode with period.", + "description": "Activates the eco mode for a specified duration.", "fields": { "duration": { "name": "Duration", @@ -49,7 +49,7 @@ }, "activate_eco_mode_with_period": { "name": "Activate eco more with period", - "description": "[%key:component::homematicip_cloud::services::activate_eco_mode_with_duration::description%]", + "description": "Activates the eco mode until a given time.", "fields": { "endtime": { "name": "Endtime", @@ -63,7 +63,7 @@ }, "activate_vacation": { "name": "Activate vacation", - "description": "Activates the vacation mode until the given time.", + "description": "Activates the vacation mode until a given time.", "fields": { "endtime": { "name": "[%key:component::homematicip_cloud::services::activate_eco_mode_with_period::fields::endtime::name%]", From 3ce9d47d7dd51aed8c9059562e5ceb774388db51 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Tue, 18 Mar 2025 20:27:36 +0100 Subject: [PATCH 2737/3148] Add exception translations for Airly integration (#140864) * Add exception translations * Improve error strings --- homeassistant/components/airly/coordinator.py | 15 +++++++++++++-- homeassistant/components/airly/strings.json | 8 ++++++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/airly/coordinator.py b/homeassistant/components/airly/coordinator.py index b255c5f078f..668cabdae63 100644 --- a/homeassistant/components/airly/coordinator.py +++ b/homeassistant/components/airly/coordinator.py @@ -105,7 +105,14 @@ class AirlyDataUpdateCoordinator(DataUpdateCoordinator[dict[str, str | float | i try: await measurements.update() except (AirlyError, ClientConnectorError) as error: - raise UpdateFailed(error) from error + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_error", + translation_placeholders={ + "entry": self.config_entry.title, + "error": repr(error), + }, + ) from error _LOGGER.debug( "Requests remaining: %s/%s", @@ -126,7 +133,11 @@ class AirlyDataUpdateCoordinator(DataUpdateCoordinator[dict[str, str | float | i standards = measurements.current["standards"] if index["description"] == NO_AIRLY_SENSORS: - raise UpdateFailed("Can't retrieve data: no Airly sensors in this area") + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="no_station", + translation_placeholders={"entry": self.config_entry.title}, + ) for value in values: data[value["name"]] = value["value"] for standard in standards: diff --git a/homeassistant/components/airly/strings.json b/homeassistant/components/airly/strings.json index 33ee8bbe4c9..fe4ccbb4745 100644 --- a/homeassistant/components/airly/strings.json +++ b/homeassistant/components/airly/strings.json @@ -36,5 +36,13 @@ "name": "[%key:component::sensor::entity_component::carbon_monoxide::name%]" } } + }, + "exceptions": { + "update_error": { + "message": "An error occurred while retrieving data from the Airly API for {entry}: {error}" + }, + "no_station": { + "message": "An error occurred while retrieving data from the Airly API for {entry}: no measuring stations in this area" + } } } From c41d5f2577e873f46366fac7337739afcaccd56c Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 18 Mar 2025 23:13:08 +0100 Subject: [PATCH 2738/3148] Fix cast.show_lovelace_view service description (#140859) --- homeassistant/components/cast/services.yaml | 2 +- homeassistant/components/cast/strings.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/cast/services.yaml b/homeassistant/components/cast/services.yaml index e2e23ad40a2..45b36f6d983 100644 --- a/homeassistant/components/cast/services.yaml +++ b/homeassistant/components/cast/services.yaml @@ -7,11 +7,11 @@ show_lovelace_view: integration: cast domain: media_player dashboard_path: - required: true example: lovelace-cast selector: text: view_path: + required: true example: downstairs selector: text: diff --git a/homeassistant/components/cast/strings.json b/homeassistant/components/cast/strings.json index 9c49813bd83..a8dccdff804 100644 --- a/homeassistant/components/cast/strings.json +++ b/homeassistant/components/cast/strings.json @@ -49,7 +49,7 @@ }, "dashboard_path": { "name": "Dashboard path", - "description": "The URL path of the dashboard to show." + "description": "The URL path of the dashboard to show, defaults to lovelace if not specified." }, "view_path": { "name": "View path", From 254622878af97ae9e3f8df06b2c2e6d252e367e5 Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Tue, 18 Mar 2025 21:48:34 -0400 Subject: [PATCH 2739/3148] Add Roborock entity with the name of the current room (#140895) * Add current room entity * Update homeassistant/components/roborock/models.py Co-authored-by: Allen Porter * Update homeassistant/components/roborock/models.py Co-authored-by: Allen Porter * use current_room property * remove select changes --------- Co-authored-by: Allen Porter --- .../components/roborock/coordinator.py | 21 +++++--- homeassistant/components/roborock/models.py | 9 ++++ homeassistant/components/roborock/sensor.py | 50 +++++++++++++++++-- .../components/roborock/strings.json | 3 ++ tests/components/roborock/mock_data.py | 1 + tests/components/roborock/test_sensor.py | 6 ++- 6 files changed, 77 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/roborock/coordinator.py b/homeassistant/components/roborock/coordinator.py index c333b143b10..698e2c268ed 100644 --- a/homeassistant/components/roborock/coordinator.py +++ b/homeassistant/components/roborock/coordinator.py @@ -29,6 +29,7 @@ from roborock.web_api import RoborockApiClient from vacuum_map_parser_base.config.color import ColorsPalette from vacuum_map_parser_base.config.image_config import ImageConfig from vacuum_map_parser_base.config.size import Sizes +from vacuum_map_parser_base.map_data import MapData from vacuum_map_parser_roborock.map_data_parser import RoborockMapDataParser from homeassistant.config_entries import ConfigEntry @@ -168,18 +169,20 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): sw_version=self.roborock_device_info.device.fv, ) - def parse_image(self, map_bytes: bytes) -> bytes | None: - """Parse map_bytes and store it as image bytes.""" + def parse_map_data_v1( + self, map_bytes: bytes + ) -> tuple[bytes | None, MapData | None]: + """Parse map_bytes and return MapData and the image.""" try: parsed_map = self.map_parser.parse(map_bytes) except (IndexError, ValueError) as err: _LOGGER.debug("Exception when parsing map contents: %s", err) - return None + return None, None if parsed_map.image is None: - return None + return None, None img_byte_arr = io.BytesIO() parsed_map.image.data.save(img_byte_arr, format=MAP_FILE_FORMAT) - return img_byte_arr.getvalue() + return img_byte_arr.getvalue(), parsed_map async def _async_setup(self) -> None: """Set up the coordinator.""" @@ -206,6 +209,7 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): rooms={}, image=image, last_updated=dt_util.utcnow() - IMAGE_CACHE_INTERVAL, + map_data=None, ) for image, roborock_map in zip(stored_images, roborock_maps, strict=False) } @@ -230,20 +234,21 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): translation_domain=DOMAIN, translation_key="map_failure", ) - parsed_image = self.parse_image(response) - if parsed_image is None: + parsed_image, parsed_map = self.parse_map_data_v1(response) + if parsed_image is None or parsed_map is None: raise HomeAssistantError( translation_domain=DOMAIN, translation_key="map_failure", ) + current_roborock_map_info = self.maps[self.current_map] if parsed_image != self.maps[self.current_map].image: await self.map_storage.async_save_map( self.current_map, parsed_image, ) - current_roborock_map_info = self.maps[self.current_map] current_roborock_map_info.image = parsed_image current_roborock_map_info.last_updated = dt_util.utcnow() + current_roborock_map_info.map_data = parsed_map async def _verify_api(self) -> None: """Verify that the api is reachable. If it is not, switch clients.""" diff --git a/homeassistant/components/roborock/models.py b/homeassistant/components/roborock/models.py index 113f99d9474..ab40f23d574 100644 --- a/homeassistant/components/roborock/models.py +++ b/homeassistant/components/roborock/models.py @@ -6,6 +6,7 @@ from typing import Any from roborock.containers import HomeDataDevice, HomeDataProduct, NetworkInfo from roborock.roborock_typing import DeviceProp +from vacuum_map_parser_base.map_data import MapData @dataclass @@ -51,3 +52,11 @@ class RoborockMapInfo: rooms: dict[int, str] image: bytes | None last_updated: datetime + map_data: MapData | None + + @property + def current_room(self) -> str | None: + """Get the currently active room for this map if any.""" + if self.map_data is None or self.map_data.vacuum_room is None: + return None + return self.rooms.get(self.map_data.vacuum_room) diff --git a/homeassistant/components/roborock/sensor.py b/homeassistant/components/roborock/sensor.py index 556d8443669..33ecaf74d4f 100644 --- a/homeassistant/components/roborock/sensor.py +++ b/homeassistant/components/roborock/sensor.py @@ -36,7 +36,11 @@ from .coordinator import ( RoborockDataUpdateCoordinator, RoborockDataUpdateCoordinatorA01, ) -from .entity import RoborockCoordinatedEntityA01, RoborockCoordinatedEntityV1 +from .entity import ( + RoborockCoordinatedEntityA01, + RoborockCoordinatedEntityV1, + RoborockEntity, +) PARALLEL_UPDATES = 0 @@ -306,7 +310,7 @@ async def async_setup_entry( ) -> None: """Set up the Roborock vacuum sensors.""" coordinators = config_entry.runtime_data - async_add_entities( + entities: list[RoborockEntity] = [ RoborockSensorEntity( coordinator, description, @@ -314,8 +318,9 @@ async def async_setup_entry( for coordinator in coordinators.v1 for description in SENSOR_DESCRIPTIONS if description.value_fn(coordinator.roborock_device_info.props) is not None - ) - async_add_entities( + ] + entities.extend(RoborockCurrentRoom(coordinator) for coordinator in coordinators.v1) + entities.extend( RoborockSensorEntityA01( coordinator, description, @@ -324,6 +329,7 @@ async def async_setup_entry( for description in A01_SENSOR_DESCRIPTIONS if description.data_protocol in coordinator.data ) + async_add_entities(entities) class RoborockSensorEntity(RoborockCoordinatedEntityV1, SensorEntity): @@ -353,6 +359,42 @@ class RoborockSensorEntity(RoborockCoordinatedEntityV1, SensorEntity): ) +class RoborockCurrentRoom(RoborockCoordinatedEntityV1, SensorEntity): + """Representation of a Current Room Sensor.""" + + _attr_device_class = SensorDeviceClass.ENUM + _attr_translation_key = "current_room" + _attr_entity_category = EntityCategory.DIAGNOSTIC + + def __init__( + self, + coordinator: RoborockDataUpdateCoordinator, + ) -> None: + """Initialize the entity.""" + super().__init__( + f"current_room_{coordinator.duid_slug}", + coordinator, + None, + is_dock_entity=False, + ) + + @property + def options(self) -> list[str]: + """Return the currently valid rooms.""" + if self.coordinator.current_map is not None: + return list( + self.coordinator.maps[self.coordinator.current_map].rooms.values() + ) + return [] + + @property + def native_value(self) -> str | None: + """Return the value reported by the sensor.""" + if self.coordinator.current_map is not None: + return self.coordinator.maps[self.coordinator.current_map].current_room + return None + + class RoborockSensorEntityA01(RoborockCoordinatedEntityA01, SensorEntity): """Representation of a A01 Roborock sensor.""" diff --git a/homeassistant/components/roborock/strings.json b/homeassistant/components/roborock/strings.json index c115ec33851..a59dc80e65d 100644 --- a/homeassistant/components/roborock/strings.json +++ b/homeassistant/components/roborock/strings.json @@ -181,6 +181,9 @@ "countdown": { "name": "Countdown" }, + "current_room": { + "name": "Current room" + }, "dock_error": { "name": "Dock error", "state": { diff --git a/tests/components/roborock/mock_data.py b/tests/components/roborock/mock_data.py index 59c54892687..87acc85b2aa 100644 --- a/tests/components/roborock/mock_data.py +++ b/tests/components/roborock/mock_data.py @@ -1151,6 +1151,7 @@ MAP_DATA = MapData(0, 0) MAP_DATA.image = ImageData( 100, 10, 10, 10, 10, ImageConfig(), Image.new("RGB", (1, 1)), lambda p: p ) +MAP_DATA.vacuum_room = 17 SCENES = [ diff --git a/tests/components/roborock/test_sensor.py b/tests/components/roborock/test_sensor.py index 4925c5da219..719b398de94 100644 --- a/tests/components/roborock/test_sensor.py +++ b/tests/components/roborock/test_sensor.py @@ -29,7 +29,7 @@ def platforms() -> list[Platform]: async def test_sensors(hass: HomeAssistant, setup_entry: MockConfigEntry) -> None: """Test sensors and check test values are correctly set.""" - assert len(hass.states.async_all("sensor")) == 40 + assert len(hass.states.async_all("sensor")) == 42 assert hass.states.get("sensor.roborock_s7_maxv_main_brush_time_left").state == str( MAIN_BRUSH_REPLACE_TIME - 74382 ) @@ -63,6 +63,10 @@ async def test_sensors(hass: HomeAssistant, setup_entry: MockConfigEntry) -> Non hass.states.get("sensor.roborock_s7_maxv_last_clean_end").state == "2023-01-01T03:43:58+00:00" ) + assert ( + hass.states.get("sensor.roborock_s7_maxv_current_room").state + == "Example room 2" + ) assert hass.states.get("sensor.dyad_pro_status").state == "drying" assert hass.states.get("sensor.dyad_pro_battery").state == "100" assert hass.states.get("sensor.dyad_pro_filter_time_left").state == "111" From caf81eecd384a76bc38bc4e500eeafb1c3b5ad38 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Wed, 19 Mar 2025 07:25:41 +0100 Subject: [PATCH 2740/3148] Bump bring-api to v1.1.0 (#140906) --- homeassistant/components/bring/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/bring/snapshots/test_diagnostics.ambr | 6 ++++++ 4 files changed, 9 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/bring/manifest.json b/homeassistant/components/bring/manifest.json index f292b10f7dc..b2d42835cce 100644 --- a/homeassistant/components/bring/manifest.json +++ b/homeassistant/components/bring/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["bring_api"], "quality_scale": "platinum", - "requirements": ["bring-api==1.0.2"] + "requirements": ["bring-api==1.1.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index bf8ac5df9ff..06ad6d0b816 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -656,7 +656,7 @@ boto3==1.34.131 botocore==1.34.131 # homeassistant.components.bring -bring-api==1.0.2 +bring-api==1.1.0 # homeassistant.components.broadlink broadlink==0.19.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ffa587ad5cf..844e6b6b246 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -576,7 +576,7 @@ boschshcpy==0.2.91 botocore==1.34.131 # homeassistant.components.bring -bring-api==1.0.2 +bring-api==1.1.0 # homeassistant.components.broadlink broadlink==0.19.0 diff --git a/tests/components/bring/snapshots/test_diagnostics.ambr b/tests/components/bring/snapshots/test_diagnostics.ambr index 8570bc0410f..3f4c8f5f339 100644 --- a/tests/components/bring/snapshots/test_diagnostics.ambr +++ b/tests/components/bring/snapshots/test_diagnostics.ambr @@ -139,6 +139,7 @@ 'language': 'de', 'name': '**REDACTED**', 'photoPath': '', + 'plusExpiry': None, 'plusTryOut': False, 'publicUuid': '9a21fdfc-63a4-441a-afc1-ef3030605a9d', 'pushEnabled': True, @@ -149,6 +150,7 @@ 'language': 'en', 'name': '**REDACTED**', 'photoPath': '', + 'plusExpiry': None, 'plusTryOut': False, 'publicUuid': '73af455f-c158-4004-a5e0-79f4f8a6d4bd', 'pushEnabled': True, @@ -159,6 +161,7 @@ 'language': 'en', 'name': None, 'photoPath': None, + 'plusExpiry': None, 'plusTryOut': False, 'publicUuid': '7d5e9d08-877a-4c36-8740-a9bf74ec690a', 'pushEnabled': True, @@ -303,6 +306,7 @@ 'language': 'de', 'name': '**REDACTED**', 'photoPath': '', + 'plusExpiry': None, 'plusTryOut': False, 'publicUuid': '9a21fdfc-63a4-441a-afc1-ef3030605a9d', 'pushEnabled': True, @@ -313,6 +317,7 @@ 'language': 'en', 'name': '**REDACTED**', 'photoPath': '', + 'plusExpiry': None, 'plusTryOut': False, 'publicUuid': '73af455f-c158-4004-a5e0-79f4f8a6d4bd', 'pushEnabled': True, @@ -323,6 +328,7 @@ 'language': 'en', 'name': None, 'photoPath': None, + 'plusExpiry': None, 'plusTryOut': False, 'publicUuid': '7d5e9d08-877a-4c36-8740-a9bf74ec690a', 'pushEnabled': True, From d37783fb219cdc4ca8865353be53a713fd3c8341 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 19 Mar 2025 09:53:00 +0200 Subject: [PATCH 2741/3148] Bump actions/download-artifact from 4.1.9 to 4.2.0 (#140907) Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 4.1.9 to 4.2.0. - [Release notes](https://github.com/actions/download-artifact/releases) - [Commits](https://github.com/actions/download-artifact/compare/v4.1.9...v4.2.0) --- updated-dependencies: - dependency-name: actions/download-artifact dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/builder.yml | 4 ++-- .github/workflows/ci.yaml | 8 ++++---- .github/workflows/wheels.yml | 14 +++++++------- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index ab64f1f3e7e..0aac66c2747 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -175,7 +175,7 @@ jobs: sed -i "s|pykrakenapi|# pykrakenapi|g" requirements_all.txt - name: Download translations - uses: actions/download-artifact@v4.1.9 + uses: actions/download-artifact@v4.2.0 with: name: translations @@ -462,7 +462,7 @@ jobs: python-version: ${{ env.DEFAULT_PYTHON }} - name: Download translations - uses: actions/download-artifact@v4.1.9 + uses: actions/download-artifact@v4.2.0 with: name: translations diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 49cb7ae019c..4d8849abfda 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -968,7 +968,7 @@ jobs: run: | echo "::add-matcher::.github/workflows/matchers/pytest-slow.json" - name: Download pytest_buckets - uses: actions/download-artifact@v4.1.9 + uses: actions/download-artifact@v4.2.0 with: name: pytest_buckets - name: Compile English translations @@ -1312,7 +1312,7 @@ jobs: - name: Check out code from GitHub uses: actions/checkout@v4.2.2 - name: Download all coverage artifacts - uses: actions/download-artifact@v4.1.9 + uses: actions/download-artifact@v4.2.0 with: pattern: coverage-* - name: Upload coverage to Codecov @@ -1454,7 +1454,7 @@ jobs: - name: Check out code from GitHub uses: actions/checkout@v4.2.2 - name: Download all coverage artifacts - uses: actions/download-artifact@v4.1.9 + uses: actions/download-artifact@v4.2.0 with: pattern: coverage-* - name: Upload coverage to Codecov @@ -1479,7 +1479,7 @@ jobs: timeout-minutes: 10 steps: - name: Download all coverage artifacts - uses: actions/download-artifact@v4.1.9 + uses: actions/download-artifact@v4.2.0 with: pattern: test-results-* - name: Upload test results to Codecov diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index c651ccbe715..4baddd3a80f 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -138,17 +138,17 @@ jobs: uses: actions/checkout@v4.2.2 - name: Download env_file - uses: actions/download-artifact@v4.1.9 + uses: actions/download-artifact@v4.2.0 with: name: env_file - name: Download build_constraints - uses: actions/download-artifact@v4.1.9 + uses: actions/download-artifact@v4.2.0 with: name: build_constraints - name: Download requirements_diff - uses: actions/download-artifact@v4.1.9 + uses: actions/download-artifact@v4.2.0 with: name: requirements_diff @@ -187,22 +187,22 @@ jobs: uses: actions/checkout@v4.2.2 - name: Download env_file - uses: actions/download-artifact@v4.1.9 + uses: actions/download-artifact@v4.2.0 with: name: env_file - name: Download build_constraints - uses: actions/download-artifact@v4.1.9 + uses: actions/download-artifact@v4.2.0 with: name: build_constraints - name: Download requirements_diff - uses: actions/download-artifact@v4.1.9 + uses: actions/download-artifact@v4.2.0 with: name: requirements_diff - name: Download requirements_all_wheels - uses: actions/download-artifact@v4.1.9 + uses: actions/download-artifact@v4.2.0 with: name: requirements_all_wheels From f4fe2342790e8ba67662a78b1f952dbf8ea124e9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 18 Mar 2025 22:26:23 -1000 Subject: [PATCH 2742/3148] Bump annotatedyaml to 0.4.4 (#140861) * Bump annotatedyaml to 0.4.2 changelog: https://github.com/home-assistant-libs/annotatedyaml/compare/v0.2.0...v0.4.2 ~10-11% performance improvement * tweak imports * bump to .3 to make pylint happy * bump again for fixes --------- Co-authored-by: Shay Levy --- homeassistant/package_constraints.txt | 2 +- homeassistant/util/yaml/__init__.py | 3 +-- homeassistant/util/yaml/objects.py | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- 5 files changed, 5 insertions(+), 6 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index f63492a8b3f..c72c5c4c646 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -10,7 +10,7 @@ aiohttp==3.11.14 aiohttp_cors==0.7.0 aiousbwatcher==1.1.1 aiozoneinfo==0.2.3 -annotatedyaml==0.2.0 +annotatedyaml==0.4.4 astral==2.2 async-interrupt==1.2.2 async-upnp-client==0.43.0 diff --git a/homeassistant/util/yaml/__init__.py b/homeassistant/util/yaml/__init__.py index a3c0ab3d083..323383ef53f 100644 --- a/homeassistant/util/yaml/__init__.py +++ b/homeassistant/util/yaml/__init__.py @@ -1,8 +1,7 @@ """YAML utility functions.""" -from annotatedyaml import SECRET_YAML, YamlTypeError +from annotatedyaml import SECRET_YAML, Input, YamlTypeError from annotatedyaml.input import UndefinedSubstitution, extract_inputs, substitute -from annotatedyaml.objects import Input from .dumper import dump, save_yaml from .loader import Secrets, load_yaml, load_yaml_dict, parse_yaml, secret_yaml diff --git a/homeassistant/util/yaml/objects.py b/homeassistant/util/yaml/objects.py index 4b21e8118b3..26714b0fdd4 100644 --- a/homeassistant/util/yaml/objects.py +++ b/homeassistant/util/yaml/objects.py @@ -2,6 +2,6 @@ from __future__ import annotations -from annotatedyaml.objects import Input, NodeDictClass, NodeListClass, NodeStrClass +from annotatedyaml import Input, NodeDictClass, NodeListClass, NodeStrClass __all__ = ["Input", "NodeDictClass", "NodeListClass", "NodeStrClass"] diff --git a/pyproject.toml b/pyproject.toml index 1879a2544c3..628ec457bf0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,7 +33,7 @@ dependencies = [ "aiohttp-fast-zlib==0.2.3", "aiohttp-asyncmdnsresolver==0.1.1", "aiozoneinfo==0.2.3", - "annotatedyaml==0.2.0", + "annotatedyaml==0.4.4", "astral==2.2", "async-interrupt==1.2.2", "attrs==25.1.0", diff --git a/requirements.txt b/requirements.txt index 176b1ae0c24..1aa96e89bb6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,7 +10,7 @@ aiohttp_cors==0.7.0 aiohttp-fast-zlib==0.2.3 aiohttp-asyncmdnsresolver==0.1.1 aiozoneinfo==0.2.3 -annotatedyaml==0.2.0 +annotatedyaml==0.4.4 astral==2.2 async-interrupt==1.2.2 attrs==25.1.0 From 7c6abe17a280879fae82884a46083fd05594622f Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 19 Mar 2025 09:55:49 +0100 Subject: [PATCH 2743/3148] Clarify description of `speed` field in `omnilogic.set_pump_speed` action (#140912) Replace "VSP" (for variable speed pump) with just "pump" so it can be properly translated. --- homeassistant/components/omnilogic/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/omnilogic/strings.json b/homeassistant/components/omnilogic/strings.json index 5b193b7f5ba..6f207337789 100644 --- a/homeassistant/components/omnilogic/strings.json +++ b/homeassistant/components/omnilogic/strings.json @@ -34,7 +34,7 @@ "fields": { "speed": { "name": "Speed", - "description": "Speed for the VSP between min and max speed." + "description": "Speed for the pump between min and max speed." } } } From 793e36635b760fd5fee1d9485c9c16c821a8f4ae Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 19 Mar 2025 10:07:47 +0100 Subject: [PATCH 2744/3148] Improve google cast known hosts configuration (#140913) --- homeassistant/components/cast/config_flow.py | 97 ++++++++------------ homeassistant/components/cast/strings.json | 10 +- tests/components/cast/test_config_flow.py | 26 ++++-- 3 files changed, 63 insertions(+), 70 deletions(-) diff --git a/homeassistant/components/cast/config_flow.py b/homeassistant/components/cast/config_flow.py index 034cf856023..6c33eac230f 100644 --- a/homeassistant/components/cast/config_flow.py +++ b/homeassistant/components/cast/config_flow.py @@ -16,12 +16,21 @@ from homeassistant.config_entries import ( from homeassistant.const import CONF_UUID from homeassistant.core import callback from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.selector import SelectSelector, SelectSelectorConfig from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import CONF_IGNORE_CEC, CONF_KNOWN_HOSTS, DOMAIN IGNORE_CEC_SCHEMA = vol.Schema(vol.All(cv.ensure_list, [cv.string])) -KNOWN_HOSTS_SCHEMA = vol.Schema(vol.All(cv.ensure_list, [cv.string])) +KNOWN_HOSTS_SCHEMA = vol.Schema( + { + vol.Optional( + CONF_KNOWN_HOSTS, + ): SelectSelector( + SelectSelectorConfig(custom_value=True, options=[], multiple=True), + ) + } +) WANTED_UUID_SCHEMA = vol.Schema(vol.All(cv.ensure_list, [cv.string])) @@ -30,12 +39,6 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): VERSION = 1 - def __init__(self) -> None: - """Initialize flow.""" - self._ignore_cec = set[str]() - self._known_hosts = set[str]() - self._wanted_uuid = set[str]() - @staticmethod @callback def async_get_options_flow( @@ -62,48 +65,31 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Confirm the setup.""" - errors = {} - data = {CONF_KNOWN_HOSTS: self._known_hosts} - if user_input is not None: - bad_hosts = False - known_hosts = user_input[CONF_KNOWN_HOSTS] - known_hosts = [x.strip() for x in known_hosts.split(",") if x.strip()] - try: - known_hosts = KNOWN_HOSTS_SCHEMA(known_hosts) - except vol.Invalid: - errors["base"] = "invalid_known_hosts" - bad_hosts = True - else: - self._known_hosts = known_hosts - data = self._get_data() - if not bad_hosts: - return self.async_create_entry(title="Google Cast", data=data) + known_hosts = _trim_items(user_input.get(CONF_KNOWN_HOSTS, [])) + return self.async_create_entry( + title="Google Cast", + data=self._get_data(known_hosts=known_hosts), + ) - fields = {} - fields[vol.Optional(CONF_KNOWN_HOSTS, default="")] = str - - return self.async_show_form( - step_id="config", data_schema=vol.Schema(fields), errors=errors - ) + return self.async_show_form(step_id="config", data_schema=KNOWN_HOSTS_SCHEMA) async def async_step_confirm( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Confirm the setup.""" - - data = self._get_data() - if user_input is not None or not onboarding.async_is_onboarded(self.hass): - return self.async_create_entry(title="Google Cast", data=data) + return self.async_create_entry(title="Google Cast", data=self._get_data()) return self.async_show_form(step_id="confirm") - def _get_data(self): + def _get_data( + self, *, known_hosts: list[str] | None = None + ) -> dict[str, list[str]]: return { - CONF_IGNORE_CEC: list(self._ignore_cec), - CONF_KNOWN_HOSTS: list(self._known_hosts), - CONF_UUID: list(self._wanted_uuid), + CONF_IGNORE_CEC: [], + CONF_KNOWN_HOSTS: known_hosts or [], + CONF_UUID: [], } @@ -123,31 +109,24 @@ class CastOptionsFlowHandler(OptionsFlow): ) -> ConfigFlowResult: """Manage the Google Cast options.""" errors: dict[str, str] = {} - current_config = self.config_entry.data if user_input is not None: - bad_hosts, known_hosts = _string_to_list( - user_input.get(CONF_KNOWN_HOSTS, ""), KNOWN_HOSTS_SCHEMA + known_hosts = _trim_items(user_input.get(CONF_KNOWN_HOSTS, [])) + self.updated_config = dict(self.config_entry.data) + self.updated_config[CONF_KNOWN_HOSTS] = known_hosts + + if self.show_advanced_options: + return await self.async_step_advanced_options() + + self.hass.config_entries.async_update_entry( + self.config_entry, data=self.updated_config ) - - if not bad_hosts: - self.updated_config = dict(current_config) - self.updated_config[CONF_KNOWN_HOSTS] = known_hosts - - if self.show_advanced_options: - return await self.async_step_advanced_options() - - self.hass.config_entries.async_update_entry( - self.config_entry, data=self.updated_config - ) - return self.async_create_entry(title="", data={}) - - fields: dict[vol.Marker, type[str]] = {} - suggested_value = _list_to_string(current_config.get(CONF_KNOWN_HOSTS)) - _add_with_suggestion(fields, CONF_KNOWN_HOSTS, suggested_value) + return self.async_create_entry(title="", data={}) return self.async_show_form( step_id="basic_options", - data_schema=vol.Schema(fields), + data_schema=self.add_suggested_values_to_schema( + KNOWN_HOSTS_SCHEMA, self.config_entry.data + ), errors=errors, last_step=not self.show_advanced_options, ) @@ -206,6 +185,10 @@ def _string_to_list(string, schema): return invalid, items +def _trim_items(items: list[str]) -> list[str]: + return [x.strip() for x in items if x.strip()] + + def _add_with_suggestion( fields: dict[vol.Marker, type[str]], key: str, suggested_value: str ) -> None: diff --git a/homeassistant/components/cast/strings.json b/homeassistant/components/cast/strings.json index a8dccdff804..8c7c7c0cff0 100644 --- a/homeassistant/components/cast/strings.json +++ b/homeassistant/components/cast/strings.json @@ -6,9 +6,11 @@ }, "config": { "title": "Google Cast configuration", - "description": "Known Hosts - A comma-separated list of hostnames or IP-addresses of cast devices, use if mDNS discovery is not working.", "data": { - "known_hosts": "Known hosts" + "known_hosts": "Add known host" + }, + "data_description": { + "known_hosts": "Hostnames or IP-addresses of cast devices, use if mDNS discovery is not working" } } }, @@ -20,9 +22,11 @@ "step": { "basic_options": { "title": "[%key:component::cast::config::step::config::title%]", - "description": "[%key:component::cast::config::step::config::description%]", "data": { "known_hosts": "[%key:component::cast::config::step::config::data::known_hosts%]" + }, + "data_description": { + "known_hosts": "[%key:component::cast::config::step::config::data_description::known_hosts%]" } }, "advanced_options": { diff --git a/tests/components/cast/test_config_flow.py b/tests/components/cast/test_config_flow.py index 2dcf007c6d4..e02230892bf 100644 --- a/tests/components/cast/test_config_flow.py +++ b/tests/components/cast/test_config_flow.py @@ -87,7 +87,7 @@ async def test_user_setup_options(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.FORM result = await hass.config_entries.flow.async_configure( - result["flow_id"], {"known_hosts": "192.168.0.1, , 192.168.0.2 "} + result["flow_id"], {"known_hosts": ["192.168.0.1", "", " ", "192.168.0.2 "]} ) users = await hass.auth.async_get_users() @@ -152,13 +152,13 @@ def get_suggested(schema, key): @pytest.mark.parametrize( - "parameter_data", + ("parameter", "initial", "suggested", "user_input", "updated"), [ ( "known_hosts", ["192.168.0.10", "192.168.0.11"], - "192.168.0.10,192.168.0.11", - "192.168.0.1, , 192.168.0.2 ", + ["192.168.0.10", "192.168.0.11"], + ["192.168.0.1", " ", " 192.168.0.2 "], ["192.168.0.1", "192.168.0.2"], ), ( @@ -177,11 +177,17 @@ def get_suggested(schema, key): ), ], ) -async def test_option_flow(hass: HomeAssistant, parameter_data) -> None: +async def test_option_flow( + hass: HomeAssistant, + parameter: str, + initial: list[str], + suggested: str | list[str], + user_input: str | list[str], + updated: list[str], +) -> None: """Test config flow options.""" basic_parameters = ["known_hosts"] advanced_parameters = ["ignore_cec", "uuid"] - parameter, initial, suggested, user_input, updated = parameter_data data = { "ignore_cec": [], @@ -213,7 +219,7 @@ async def test_option_flow(hass: HomeAssistant, parameter_data) -> None: for other_param in basic_parameters: if other_param == parameter: continue - assert get_suggested(data_schema, other_param) == "" + assert get_suggested(data_schema, other_param) == [] if parameter in basic_parameters: assert get_suggested(data_schema, parameter) == suggested @@ -261,7 +267,7 @@ async def test_option_flow(hass: HomeAssistant, parameter_data) -> None: result = await hass.config_entries.options.async_init(config_entry.entry_id) result = await hass.config_entries.options.async_configure( result["flow_id"], - user_input={"known_hosts": ""}, + user_input={}, ) assert result["type"] is FlowResultType.CREATE_ENTRY assert result["data"] == {} @@ -277,7 +283,7 @@ async def test_known_hosts(hass: HomeAssistant, castbrowser_mock) -> None: "cast", context={"source": config_entries.SOURCE_USER} ) result = await hass.config_entries.flow.async_configure( - result["flow_id"], {"known_hosts": "192.168.0.1, 192.168.0.2"} + result["flow_id"], {"known_hosts": ["192.168.0.1", "192.168.0.2"]} ) assert result["type"] is FlowResultType.CREATE_ENTRY await hass.async_block_till_done(wait_background_tasks=True) @@ -290,7 +296,7 @@ async def test_known_hosts(hass: HomeAssistant, castbrowser_mock) -> None: result = await hass.config_entries.options.async_init(config_entry.entry_id) result = await hass.config_entries.options.async_configure( result["flow_id"], - user_input={"known_hosts": "192.168.0.11, 192.168.0.12"}, + user_input={"known_hosts": ["192.168.0.11", "192.168.0.12"]}, ) await hass.async_block_till_done(wait_background_tasks=True) From f28b9ba9618066d785334a10546362edbdd2fc64 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 19 Mar 2025 10:36:49 +0100 Subject: [PATCH 2745/3148] Fix sentence-casing in `nibe_heatpump` strings (#140915) --- homeassistant/components/nibe_heatpump/strings.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/nibe_heatpump/strings.json b/homeassistant/components/nibe_heatpump/strings.json index 6fa421e0855..3ca70189964 100644 --- a/homeassistant/components/nibe_heatpump/strings.json +++ b/homeassistant/components/nibe_heatpump/strings.json @@ -10,13 +10,13 @@ }, "modbus": { "data": { - "model": "Model of Heat Pump", + "model": "Model of heat pump", "modbus_url": "Modbus URL", - "modbus_unit": "Modbus Unit Identifier" + "modbus_unit": "Modbus unit identifier" }, "data_description": { - "modbus_url": "Modbus URL that describes the connection to your Heat Pump or MODBUS40 unit. It should be on the form:\n - `tcp://[HOST]:[PORT]` for Modbus TCP connection\n - `serial://[LOCAL DEVICE]` for a local Modbus RTU connection\n - `rfc2217://[HOST]:[PORT]` for a remote telnet based Modbus RTU connection.", - "modbus_unit": "Unit identification for your Heat Pump. Can usually be left at 0." + "modbus_url": "Modbus URL that describes the connection to your heat pump or MODBUS40 unit. It should be on the form:\n - `tcp://[HOST]:[PORT]` for Modbus TCP connection\n - `serial://[LOCAL DEVICE]` for a local Modbus RTU connection\n - `rfc2217://[HOST]:[PORT]` for a remote telnet based Modbus RTU connection.", + "modbus_unit": "Unit identification for your heat pump. Can usually be left at 0." } }, "nibegw": { From f79aa2f73e1ed33542ba8ae8419cb207b991a706 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 19 Mar 2025 11:02:37 +0100 Subject: [PATCH 2746/3148] Fix typos in `nibe_heatpump` strings (#140917) * Fix typo in `nibe_heatpump` strings * Also capitalize "Telnet" --- homeassistant/components/nibe_heatpump/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/nibe_heatpump/strings.json b/homeassistant/components/nibe_heatpump/strings.json index 3ca70189964..c65a76d3364 100644 --- a/homeassistant/components/nibe_heatpump/strings.json +++ b/homeassistant/components/nibe_heatpump/strings.json @@ -15,7 +15,7 @@ "modbus_unit": "Modbus unit identifier" }, "data_description": { - "modbus_url": "Modbus URL that describes the connection to your heat pump or MODBUS40 unit. It should be on the form:\n - `tcp://[HOST]:[PORT]` for Modbus TCP connection\n - `serial://[LOCAL DEVICE]` for a local Modbus RTU connection\n - `rfc2217://[HOST]:[PORT]` for a remote telnet based Modbus RTU connection.", + "modbus_url": "Modbus URL that describes the connection to your heat pump or MODBUS40 unit. It should be in the form:\n - `tcp://[HOST]:[PORT]` for Modbus TCP connection\n - `serial://[LOCAL DEVICE]` for a local Modbus RTU connection\n - `rfc2217://[HOST]:[PORT]` for a remote Telnet-based Modbus RTU connection.", "modbus_unit": "Unit identification for your heat pump. Can usually be left at 0." } }, From 3fd17c802c6eb6077eb1c5baee13dad6877d71d0 Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Wed, 19 Mar 2025 11:25:12 +0100 Subject: [PATCH 2747/3148] Bump pylamarzocco to 1.4.9 (#140916) --- .../components/lamarzocco/__init__.py | 52 ++++++++++++----- .../components/lamarzocco/manifest.json | 2 +- homeassistant/components/lamarzocco/number.py | 18 ++++-- homeassistant/components/lamarzocco/select.py | 1 + .../components/lamarzocco/strings.json | 1 + requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../lamarzocco/fixtures/config.json | 38 ++++++++++++- .../lamarzocco/fixtures/config_mini.json | 10 +++- .../snapshots/test_diagnostics.ambr | 56 +++++++++++++------ .../lamarzocco/snapshots/test_number.ambr | 46 +++++++-------- tests/components/lamarzocco/test_init.py | 25 ++++++++- 12 files changed, 183 insertions(+), 70 deletions(-) diff --git a/homeassistant/components/lamarzocco/__init__.py b/homeassistant/components/lamarzocco/__init__.py index d20616e1940..25c8fd1091e 100644 --- a/homeassistant/components/lamarzocco/__init__.py +++ b/homeassistant/components/lamarzocco/__init__.py @@ -61,6 +61,42 @@ async def async_setup_entry(hass: HomeAssistant, entry: LaMarzoccoConfigEntry) - client=client, ) + # initialize the firmware update coordinator early to check the firmware version + firmware_device = LaMarzoccoMachine( + model=entry.data[CONF_MODEL], + serial_number=entry.unique_id, + name=entry.data[CONF_NAME], + cloud_client=cloud_client, + ) + + firmware_coordinator = LaMarzoccoFirmwareUpdateCoordinator( + hass, entry, firmware_device + ) + await firmware_coordinator.async_config_entry_first_refresh() + gateway_version = version.parse( + firmware_device.firmware[FirmwareType.GATEWAY].current_version + ) + + if gateway_version >= version.parse("v5.0.9"): + # remove host from config entry, it is not supported anymore + data = {k: v for k, v in entry.data.items() if k != CONF_HOST} + hass.config_entries.async_update_entry( + entry, + data=data, + ) + + elif gateway_version < version.parse("v3.4-rc5"): + # incompatible gateway firmware, create an issue + ir.async_create_issue( + hass, + DOMAIN, + "unsupported_gateway_firmware", + is_fixable=False, + severity=ir.IssueSeverity.ERROR, + translation_key="unsupported_gateway_firmware", + translation_placeholders={"gateway_version": str(gateway_version)}, + ) + # initialize local API local_client: LaMarzoccoLocalClient | None = None if (host := entry.data.get(CONF_HOST)) is not None: @@ -117,30 +153,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: LaMarzoccoConfigEntry) - coordinators = LaMarzoccoRuntimeData( LaMarzoccoConfigUpdateCoordinator(hass, entry, device, local_client), - LaMarzoccoFirmwareUpdateCoordinator(hass, entry, device), + firmware_coordinator, LaMarzoccoStatisticsUpdateCoordinator(hass, entry, device), ) # API does not like concurrent requests, so no asyncio.gather here await coordinators.config_coordinator.async_config_entry_first_refresh() - await coordinators.firmware_coordinator.async_config_entry_first_refresh() await coordinators.statistics_coordinator.async_config_entry_first_refresh() entry.runtime_data = coordinators - gateway_version = device.firmware[FirmwareType.GATEWAY].current_version - if version.parse(gateway_version) < version.parse("v3.4-rc5"): - # incompatible gateway firmware, create an issue - ir.async_create_issue( - hass, - DOMAIN, - "unsupported_gateway_firmware", - is_fixable=False, - severity=ir.IssueSeverity.ERROR, - translation_key="unsupported_gateway_firmware", - translation_placeholders={"gateway_version": gateway_version}, - ) - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) async def update_listener( diff --git a/homeassistant/components/lamarzocco/manifest.json b/homeassistant/components/lamarzocco/manifest.json index eceb2bbf53b..73f00b2bdd0 100644 --- a/homeassistant/components/lamarzocco/manifest.json +++ b/homeassistant/components/lamarzocco/manifest.json @@ -37,5 +37,5 @@ "iot_class": "cloud_polling", "loggers": ["pylamarzocco"], "quality_scale": "platinum", - "requirements": ["pylamarzocco==1.4.7"] + "requirements": ["pylamarzocco==1.4.9"] } diff --git a/homeassistant/components/lamarzocco/number.py b/homeassistant/components/lamarzocco/number.py index 666c57c1866..08e9ad7e590 100644 --- a/homeassistant/components/lamarzocco/number.py +++ b/homeassistant/components/lamarzocco/number.py @@ -144,9 +144,12 @@ KEY_ENTITIES: tuple[LaMarzoccoKeyNumberEntityDescription, ...] = ( set_value_fn=lambda machine, value, key: machine.set_prebrew_time( prebrew_off_time=value, key=key ), - native_value_fn=lambda config, key: config.prebrew_configuration[key].off_time, + native_value_fn=lambda config, key: config.prebrew_configuration[key][ + 0 + ].off_time, available_fn=lambda device: len(device.config.prebrew_configuration) > 0 - and device.config.prebrew_mode == PrebrewMode.PREBREW, + and device.config.prebrew_mode + in (PrebrewMode.PREBREW, PrebrewMode.PREBREW_ENABLED), supported_fn=lambda coordinator: coordinator.device.model != MachineModel.GS3_MP, ), @@ -162,9 +165,12 @@ KEY_ENTITIES: tuple[LaMarzoccoKeyNumberEntityDescription, ...] = ( set_value_fn=lambda machine, value, key: machine.set_prebrew_time( prebrew_on_time=value, key=key ), - native_value_fn=lambda config, key: config.prebrew_configuration[key].off_time, + native_value_fn=lambda config, key: config.prebrew_configuration[key][ + 0 + ].off_time, available_fn=lambda device: len(device.config.prebrew_configuration) > 0 - and device.config.prebrew_mode == PrebrewMode.PREBREW, + and device.config.prebrew_mode + in (PrebrewMode.PREBREW, PrebrewMode.PREBREW_ENABLED), supported_fn=lambda coordinator: coordinator.device.model != MachineModel.GS3_MP, ), @@ -180,8 +186,8 @@ KEY_ENTITIES: tuple[LaMarzoccoKeyNumberEntityDescription, ...] = ( set_value_fn=lambda machine, value, key: machine.set_preinfusion_time( preinfusion_time=value, key=key ), - native_value_fn=lambda config, key: config.prebrew_configuration[ - key + native_value_fn=lambda config, key: config.prebrew_configuration[key][ + 1 ].preinfusion_time, available_fn=lambda device: len(device.config.prebrew_configuration) > 0 and device.config.prebrew_mode == PrebrewMode.PREINFUSION, diff --git a/homeassistant/components/lamarzocco/select.py b/homeassistant/components/lamarzocco/select.py index d8217cefaff..5ebe2d7b9da 100644 --- a/homeassistant/components/lamarzocco/select.py +++ b/homeassistant/components/lamarzocco/select.py @@ -38,6 +38,7 @@ STEAM_LEVEL_LM_TO_HA = {value: key for key, value in STEAM_LEVEL_HA_TO_LM.items( PREBREW_MODE_HA_TO_LM = { "disabled": PrebrewMode.DISABLED, "prebrew": PrebrewMode.PREBREW, + "prebrew_enabled": PrebrewMode.PREBREW_ENABLED, "preinfusion": PrebrewMode.PREINFUSION, } diff --git a/homeassistant/components/lamarzocco/strings.json b/homeassistant/components/lamarzocco/strings.json index 62050685c27..04853b8d0ca 100644 --- a/homeassistant/components/lamarzocco/strings.json +++ b/homeassistant/components/lamarzocco/strings.json @@ -148,6 +148,7 @@ "state": { "disabled": "Disabled", "prebrew": "Prebrew", + "prebrew_enabled": "Prebrew", "preinfusion": "Preinfusion" } }, diff --git a/requirements_all.txt b/requirements_all.txt index 06ad6d0b816..d1081bd3341 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2081,7 +2081,7 @@ pykwb==0.0.8 pylacrosse==0.4 # homeassistant.components.lamarzocco -pylamarzocco==1.4.7 +pylamarzocco==1.4.9 # homeassistant.components.lastfm pylast==5.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 844e6b6b246..ab44df341d7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1692,7 +1692,7 @@ pykrakenapi==0.1.8 pykulersky==0.5.2 # homeassistant.components.lamarzocco -pylamarzocco==1.4.7 +pylamarzocco==1.4.9 # homeassistant.components.lastfm pylast==5.1.0 diff --git a/tests/components/lamarzocco/fixtures/config.json b/tests/components/lamarzocco/fixtures/config.json index ea6e2ee76b8..5aac86dde97 100644 --- a/tests/components/lamarzocco/fixtures/config.json +++ b/tests/components/lamarzocco/fixtures/config.json @@ -101,28 +101,60 @@ "mode": "TypeB", "Group1": [ { + "mode": "TypeA", "groupNumber": "Group1", "doseType": "DoseA", "preWetTime": 0.5, "preWetHoldTime": 1 }, { + "mode": "TypeB", + "groupNumber": "Group1", + "doseType": "DoseA", + "preWetTime": 0, + "preWetHoldTime": 4 + }, + { + "mode": "TypeA", "groupNumber": "Group1", "doseType": "DoseB", "preWetTime": 0.5, "preWetHoldTime": 1 }, { + "mode": "TypeB", "groupNumber": "Group1", - "doseType": "DoseC", - "preWetTime": 3.2999999523162842, - "preWetHoldTime": 3.2999999523162842 + "doseType": "DoseB", + "preWetTime": 0, + "preWetHoldTime": 4 }, { + "mode": "TypeA", + "groupNumber": "Group1", + "doseType": "DoseC", + "preWetTime": 3.3, + "preWetHoldTime": 3.3 + }, + { + "mode": "TypeB", + "groupNumber": "Group1", + "doseType": "DoseC", + "preWetTime": 0, + "preWetHoldTime": 4 + }, + { + "mode": "TypeA", "groupNumber": "Group1", "doseType": "DoseD", "preWetTime": 2, "preWetHoldTime": 2 + }, + { + "mode": "TypeB", + "groupNumber": "Group1", + "doseType": "DoseD", + "preWetTime": 0, + "preWetHoldTime": 4 } ] }, diff --git a/tests/components/lamarzocco/fixtures/config_mini.json b/tests/components/lamarzocco/fixtures/config_mini.json index 22533a94872..a726d715a6f 100644 --- a/tests/components/lamarzocco/fixtures/config_mini.json +++ b/tests/components/lamarzocco/fixtures/config_mini.json @@ -82,10 +82,18 @@ "mode": "TypeB", "Group1": [ { + "mode": "TypeA", "groupNumber": "Group1", - "doseType": "DoseA", + "doseType": "Continuous", "preWetTime": 2, "preWetHoldTime": 3 + }, + { + "mode": "TypeB", + "groupNumber": "Group1", + "doseType": "Continuous", + "preWetTime": 0, + "preWetHoldTime": 3 } ] }, diff --git a/tests/components/lamarzocco/snapshots/test_diagnostics.ambr b/tests/components/lamarzocco/snapshots/test_diagnostics.ambr index b1d8140b2ce..018449f7c9a 100644 --- a/tests/components/lamarzocco/snapshots/test_diagnostics.ambr +++ b/tests/components/lamarzocco/snapshots/test_diagnostics.ambr @@ -27,22 +27,46 @@ }), 'plumbed_in': True, 'prebrew_configuration': dict({ - '1': dict({ - 'off_time': 1, - 'on_time': 0.5, - }), - '2': dict({ - 'off_time': 1, - 'on_time': 0.5, - }), - '3': dict({ - 'off_time': 3.299999952316284, - 'on_time': 3.299999952316284, - }), - '4': dict({ - 'off_time': 2, - 'on_time': 2, - }), + '1': list([ + dict({ + 'off_time': 1, + 'on_time': 0.5, + }), + dict({ + 'off_time': 4, + 'on_time': 0, + }), + ]), + '2': list([ + dict({ + 'off_time': 1, + 'on_time': 0.5, + }), + dict({ + 'off_time': 4, + 'on_time': 0, + }), + ]), + '3': list([ + dict({ + 'off_time': 3.3, + 'on_time': 3.3, + }), + dict({ + 'off_time': 4, + 'on_time': 0, + }), + ]), + '4': list([ + dict({ + 'off_time': 2, + 'on_time': 2, + }), + dict({ + 'off_time': 4, + 'on_time': 0, + }), + ]), }), 'prebrew_mode': 'TypeB', 'scale': None, diff --git a/tests/components/lamarzocco/snapshots/test_number.ambr b/tests/components/lamarzocco/snapshots/test_number.ambr index 0748c9384a9..de1f11b14eb 100644 --- a/tests/components/lamarzocco/snapshots/test_number.ambr +++ b/tests/components/lamarzocco/snapshots/test_number.ambr @@ -419,7 +419,7 @@ 'state': '121', }) # --- -# name: test_pre_brew_infusion_key_numbers[prebrew_off_time-6-Enabled-set_prebrew_time-kwargs0-GS3 AV][GS012345_prebrew_off_time_key_1-state] +# name: test_pre_brew_infusion_key_numbers[prebrew_off_time-6-TypeA-set_prebrew_time-kwargs0-GS3 AV][GS012345_prebrew_off_time_key_1-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', @@ -438,7 +438,7 @@ 'state': '1', }) # --- -# name: test_pre_brew_infusion_key_numbers[prebrew_off_time-6-Enabled-set_prebrew_time-kwargs0-GS3 AV][GS012345_prebrew_off_time_key_2-state] +# name: test_pre_brew_infusion_key_numbers[prebrew_off_time-6-TypeA-set_prebrew_time-kwargs0-GS3 AV][GS012345_prebrew_off_time_key_2-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', @@ -457,7 +457,7 @@ 'state': '1', }) # --- -# name: test_pre_brew_infusion_key_numbers[prebrew_off_time-6-Enabled-set_prebrew_time-kwargs0-GS3 AV][GS012345_prebrew_off_time_key_3-state] +# name: test_pre_brew_infusion_key_numbers[prebrew_off_time-6-TypeA-set_prebrew_time-kwargs0-GS3 AV][GS012345_prebrew_off_time_key_3-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', @@ -473,10 +473,10 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '3.29999995231628', + 'state': '3.3', }) # --- -# name: test_pre_brew_infusion_key_numbers[prebrew_off_time-6-Enabled-set_prebrew_time-kwargs0-GS3 AV][GS012345_prebrew_off_time_key_4-state] +# name: test_pre_brew_infusion_key_numbers[prebrew_off_time-6-TypeA-set_prebrew_time-kwargs0-GS3 AV][GS012345_prebrew_off_time_key_4-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', @@ -495,7 +495,7 @@ 'state': '2', }) # --- -# name: test_pre_brew_infusion_key_numbers[prebrew_on_time-6-Enabled-set_prebrew_time-kwargs1-GS3 AV][GS012345_prebrew_on_time_key_1-state] +# name: test_pre_brew_infusion_key_numbers[prebrew_on_time-6-TypeA-set_prebrew_time-kwargs1-GS3 AV][GS012345_prebrew_on_time_key_1-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', @@ -514,7 +514,7 @@ 'state': '1', }) # --- -# name: test_pre_brew_infusion_key_numbers[prebrew_on_time-6-Enabled-set_prebrew_time-kwargs1-GS3 AV][GS012345_prebrew_on_time_key_2-state] +# name: test_pre_brew_infusion_key_numbers[prebrew_on_time-6-TypeA-set_prebrew_time-kwargs1-GS3 AV][GS012345_prebrew_on_time_key_2-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', @@ -533,7 +533,7 @@ 'state': '1', }) # --- -# name: test_pre_brew_infusion_key_numbers[prebrew_on_time-6-Enabled-set_prebrew_time-kwargs1-GS3 AV][GS012345_prebrew_on_time_key_3-state] +# name: test_pre_brew_infusion_key_numbers[prebrew_on_time-6-TypeA-set_prebrew_time-kwargs1-GS3 AV][GS012345_prebrew_on_time_key_3-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', @@ -549,10 +549,10 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '3.29999995231628', + 'state': '3.3', }) # --- -# name: test_pre_brew_infusion_key_numbers[prebrew_on_time-6-Enabled-set_prebrew_time-kwargs1-GS3 AV][GS012345_prebrew_on_time_key_4-state] +# name: test_pre_brew_infusion_key_numbers[prebrew_on_time-6-TypeA-set_prebrew_time-kwargs1-GS3 AV][GS012345_prebrew_on_time_key_4-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', @@ -587,7 +587,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '1', + 'state': '4', }) # --- # name: test_pre_brew_infusion_key_numbers[preinfusion_time-7-TypeB-set_preinfusion_time-kwargs2-GS3 AV][GS012345_preinfusion_time_key_2-state] @@ -606,7 +606,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '1', + 'state': '4', }) # --- # name: test_pre_brew_infusion_key_numbers[preinfusion_time-7-TypeB-set_preinfusion_time-kwargs2-GS3 AV][GS012345_preinfusion_time_key_3-state] @@ -625,7 +625,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '3.29999995231628', + 'state': '4', }) # --- # name: test_pre_brew_infusion_key_numbers[preinfusion_time-7-TypeB-set_preinfusion_time-kwargs2-GS3 AV][GS012345_preinfusion_time_key_4-state] @@ -644,10 +644,10 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '2', + 'state': '4', }) # --- -# name: test_pre_brew_infusion_numbers[prebrew_off_time-set_prebrew_time-Enabled-6-kwargs0-Linea Mini] +# name: test_pre_brew_infusion_numbers[prebrew_off_time-set_prebrew_time-TypeA-6-kwargs0-Linea Mini] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', @@ -666,7 +666,7 @@ 'state': '3', }) # --- -# name: test_pre_brew_infusion_numbers[prebrew_off_time-set_prebrew_time-Enabled-6-kwargs0-Linea Mini].1 +# name: test_pre_brew_infusion_numbers[prebrew_off_time-set_prebrew_time-TypeA-6-kwargs0-Linea Mini].1 EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -705,7 +705,7 @@ 'unit_of_measurement': , }) # --- -# name: test_pre_brew_infusion_numbers[prebrew_off_time-set_prebrew_time-Enabled-6-kwargs0-Micra] +# name: test_pre_brew_infusion_numbers[prebrew_off_time-set_prebrew_time-TypeA-6-kwargs0-Micra] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', @@ -724,7 +724,7 @@ 'state': '1', }) # --- -# name: test_pre_brew_infusion_numbers[prebrew_off_time-set_prebrew_time-Enabled-6-kwargs0-Micra].1 +# name: test_pre_brew_infusion_numbers[prebrew_off_time-set_prebrew_time-TypeA-6-kwargs0-Micra].1 EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -763,7 +763,7 @@ 'unit_of_measurement': , }) # --- -# name: test_pre_brew_infusion_numbers[prebrew_on_time-set_prebrew_time-Enabled-6-kwargs1-Linea Mini] +# name: test_pre_brew_infusion_numbers[prebrew_on_time-set_prebrew_time-TypeA-6-kwargs1-Linea Mini] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', @@ -782,7 +782,7 @@ 'state': '3', }) # --- -# name: test_pre_brew_infusion_numbers[prebrew_on_time-set_prebrew_time-Enabled-6-kwargs1-Linea Mini].1 +# name: test_pre_brew_infusion_numbers[prebrew_on_time-set_prebrew_time-TypeA-6-kwargs1-Linea Mini].1 EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -821,7 +821,7 @@ 'unit_of_measurement': , }) # --- -# name: test_pre_brew_infusion_numbers[prebrew_on_time-set_prebrew_time-Enabled-6-kwargs1-Micra] +# name: test_pre_brew_infusion_numbers[prebrew_on_time-set_prebrew_time-TypeA-6-kwargs1-Micra] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', @@ -840,7 +840,7 @@ 'state': '1', }) # --- -# name: test_pre_brew_infusion_numbers[prebrew_on_time-set_prebrew_time-Enabled-6-kwargs1-Micra].1 +# name: test_pre_brew_infusion_numbers[prebrew_on_time-set_prebrew_time-TypeA-6-kwargs1-Micra].1 EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -953,7 +953,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '1', + 'state': '4', }) # --- # name: test_pre_brew_infusion_numbers[preinfusion_time-set_preinfusion_time-TypeB-7-kwargs2-Micra].1 diff --git a/tests/components/lamarzocco/test_init.py b/tests/components/lamarzocco/test_init.py index 09ebc462952..a9a3b9f23e1 100644 --- a/tests/components/lamarzocco/test_init.py +++ b/tests/components/lamarzocco/test_init.py @@ -170,12 +170,18 @@ async def test_bluetooth_is_set_from_discovery( "homeassistant.components.lamarzocco.async_discovered_service_info", return_value=[service_info], ) as discovery, - patch("homeassistant.components.lamarzocco.LaMarzoccoMachine") as init_device, + patch( + "homeassistant.components.lamarzocco.LaMarzoccoMachine" + ) as mock_machine_class, ): + mock_machine = MagicMock() + mock_machine.get_firmware = AsyncMock() + mock_machine.firmware = mock_lamarzocco.firmware + mock_machine_class.return_value = mock_machine await async_init_integration(hass, mock_config_entry) discovery.assert_called_once() - init_device.assert_called_once() - _, kwargs = init_device.call_args + assert mock_machine_class.call_count == 2 + _, kwargs = mock_machine_class.call_args assert kwargs["bluetooth_client"] is not None assert mock_config_entry.data[CONF_NAME] == service_info.name assert mock_config_entry.data[CONF_MAC] == service_info.address @@ -223,6 +229,19 @@ async def test_gateway_version_issue( assert (issue is not None) == issue_exists +async def test_conf_host_removed_for_new_gateway( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_lamarzocco: MagicMock, +) -> None: + """Make sure we get the issue for certain gateway firmware versions.""" + mock_lamarzocco.firmware[FirmwareType.GATEWAY].current_version = "v5.0.9" + + await async_init_integration(hass, mock_config_entry) + + assert CONF_HOST not in mock_config_entry.data + + async def test_device( hass: HomeAssistant, mock_lamarzocco: MagicMock, From adc3f542cfe5486c51832a87078f899b8d864be0 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Wed, 19 Mar 2025 13:11:29 +0100 Subject: [PATCH 2748/3148] Update strings for Vodafone Station (#140919) --- .../components/vodafone_station/strings.json | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/vodafone_station/strings.json b/homeassistant/components/vodafone_station/strings.json index dd847df4d6b..7d804d9ac3b 100644 --- a/homeassistant/components/vodafone_station/strings.json +++ b/homeassistant/components/vodafone_station/strings.json @@ -3,9 +3,11 @@ "flow_title": "{host}", "step": { "reauth_confirm": { - "description": "Please enter the correct password for host: {host}", "data": { "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "password": "Please enter the correct password for host: {host}" } }, "user": { @@ -15,7 +17,9 @@ "password": "[%key:common::config_flow::data::password%]" }, "data_description": { - "host": "The hostname or IP address of your Vodafone Station." + "host": "The hostname or IP address of your Vodafone Station.", + "username": "The username for your Vodafone Station.", + "password": "The password for your Vodafone Station." } } }, @@ -41,6 +45,9 @@ "init": { "data": { "consider_home": "Seconds to consider a device at 'home'" + }, + "data_description": { + "consider_home": "The number of seconds to wait until marking a device as not home after it disconnects from the network." } } } From 245f0a19585576771b2868fc65adc8f8ed60a583 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 19 Mar 2025 13:52:27 +0100 Subject: [PATCH 2749/3148] Minor typing tweak in cast (#140911) --- homeassistant/components/cast/const.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/cast/const.py b/homeassistant/components/cast/const.py index 056ee054d1d..0a85a0007b3 100644 --- a/homeassistant/components/cast/const.py +++ b/homeassistant/components/cast/const.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING, TypedDict +from typing import TYPE_CHECKING, NotRequired, TypedDict from homeassistant.util.signal_type import SignalType @@ -46,3 +46,4 @@ class HomeAssistantControllerData(TypedDict): hass_uuid: str client_id: str | None refresh_token: str + app_id: NotRequired[str] From 334359871d573e5fe7ac0155cd642e35008b780b Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Wed, 19 Mar 2025 14:34:49 +0100 Subject: [PATCH 2750/3148] Add Reolink home hub scene select entity (#140823) --- homeassistant/components/reolink/icons.json | 3 + homeassistant/components/reolink/select.py | 63 ++++++++++++++++++- homeassistant/components/reolink/strings.json | 9 +++ tests/components/reolink/conftest.py | 2 + .../reolink/snapshots/test_diagnostics.ambr | 3 + tests/components/reolink/test_select.py | 52 +++++++++++++++ 6 files changed, 131 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/reolink/icons.json b/homeassistant/components/reolink/icons.json index bcfea0bebd1..00045c4cda2 100644 --- a/homeassistant/components/reolink/icons.json +++ b/homeassistant/components/reolink/icons.json @@ -350,6 +350,9 @@ }, "sub_bit_rate": { "default": "mdi:play-speed" + }, + "scene_mode": { + "default": "mdi:view-list" } }, "sensor": { diff --git a/homeassistant/components/reolink/select.py b/homeassistant/components/reolink/select.py index c0b20da0238..e5d66ed3901 100644 --- a/homeassistant/components/reolink/select.py +++ b/homeassistant/components/reolink/select.py @@ -30,6 +30,8 @@ from .entity import ( ReolinkChannelEntityDescription, ReolinkChimeCoordinatorEntity, ReolinkChimeEntityDescription, + ReolinkHostCoordinatorEntity, + ReolinkHostEntityDescription, ) from .util import ReolinkConfigEntry, ReolinkData, raise_translated_error @@ -49,6 +51,18 @@ class ReolinkSelectEntityDescription( value: Callable[[Host, int], str] | None = None +@dataclass(frozen=True, kw_only=True) +class ReolinkHostSelectEntityDescription( + SelectEntityDescription, + ReolinkHostEntityDescription, +): + """A class that describes host select entities.""" + + get_options: Callable[[Host], list[str]] + method: Callable[[Host, str], Any] + value: Callable[[Host], str] + + @dataclass(frozen=True, kw_only=True) class ReolinkChimeSelectEntityDescription( SelectEntityDescription, @@ -238,6 +252,19 @@ SELECT_ENTITIES = ( ), ) +HOST_SELECT_ENTITIES = ( + ReolinkHostSelectEntityDescription( + key="scene_mode", + cmd_key="GetScene", + translation_key="scene_mode", + entity_category=EntityCategory.CONFIG, + get_options=lambda api: api.baichuan.scene_names, + supported=lambda api: api.supported(None, "scenes"), + value=lambda api: api.baichuan.active_scene, + method=lambda api, name: api.baichuan.set_scene(scene_name=name), + ), +) + CHIME_SELECT_ENTITIES = ( ReolinkChimeSelectEntityDescription( key="motion_tone", @@ -300,12 +327,19 @@ async def async_setup_entry( """Set up a Reolink select entities.""" reolink_data: ReolinkData = config_entry.runtime_data - entities: list[ReolinkSelectEntity | ReolinkChimeSelectEntity] = [ + entities: list[ + ReolinkSelectEntity | ReolinkHostSelectEntity | ReolinkChimeSelectEntity + ] = [ ReolinkSelectEntity(reolink_data, channel, entity_description) for entity_description in SELECT_ENTITIES for channel in reolink_data.host.api.channels if entity_description.supported(reolink_data.host.api, channel) ] + entities.extend( + ReolinkHostSelectEntity(reolink_data, entity_description) + for entity_description in HOST_SELECT_ENTITIES + if entity_description.supported(reolink_data.host.api) + ) entities.extend( ReolinkChimeSelectEntity(reolink_data, chime, entity_description) for entity_description in CHIME_SELECT_ENTITIES @@ -360,6 +394,33 @@ class ReolinkSelectEntity(ReolinkChannelCoordinatorEntity, SelectEntity): self.async_write_ha_state() +class ReolinkHostSelectEntity(ReolinkHostCoordinatorEntity, SelectEntity): + """Base select entity class for Reolink Host.""" + + entity_description: ReolinkHostSelectEntityDescription + + def __init__( + self, + reolink_data: ReolinkData, + entity_description: ReolinkHostSelectEntityDescription, + ) -> None: + """Initialize Reolink select entity.""" + self.entity_description = entity_description + super().__init__(reolink_data) + self._attr_options = entity_description.get_options(self._host.api) + + @property + def current_option(self) -> str | None: + """Return the current option.""" + return self.entity_description.value(self._host.api) + + @raise_translated_error + async def async_select_option(self, option: str) -> None: + """Change the selected option.""" + await self.entity_description.method(self._host.api, option) + self.async_write_ha_state() + + class ReolinkChimeSelectEntity(ReolinkChimeCoordinatorEntity, SelectEntity): """Base select entity class for Reolink IP cameras.""" diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index 80d9156e420..53df658239c 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -799,6 +799,15 @@ }, "sub_bit_rate": { "name": "Fluent bit rate" + }, + "scene_mode": { + "name": "Scene mode", + "state": { + "off": "[%key:common::state::off%]", + "disarm": "Disarmed", + "home": "Home", + "away": "Away" + } } }, "sensor": { diff --git a/tests/components/reolink/conftest.py b/tests/components/reolink/conftest.py index 672919bc7a9..f2474d640d8 100644 --- a/tests/components/reolink/conftest.py +++ b/tests/components/reolink/conftest.py @@ -145,6 +145,8 @@ def reolink_connect_class() -> Generator[MagicMock]: host_mock.baichuan.privacy_mode.return_value = False host_mock.baichuan.day_night_state.return_value = "day" host_mock.baichuan.subscribe_events.side_effect = ReolinkError("Test error") + host_mock.baichuan.active_scene = "off" + host_mock.baichuan.scene_names = ["off", "home"] host_mock.baichuan.abilities = { 0: {"chnID": 0, "aitype": 34615}, "Host": {"pushAlarm": 7}, diff --git a/tests/components/reolink/snapshots/test_diagnostics.ambr b/tests/components/reolink/snapshots/test_diagnostics.ambr index b034122e1fc..5eb80d16356 100644 --- a/tests/components/reolink/snapshots/test_diagnostics.ambr +++ b/tests/components/reolink/snapshots/test_diagnostics.ambr @@ -170,6 +170,9 @@ '0': 1, 'null': 2, }), + 'GetScene': dict({ + 'null': 1, + }), 'GetStateLight': dict({ 'null': 1, }), diff --git a/tests/components/reolink/test_select.py b/tests/components/reolink/test_select.py index 7910174380a..32bc5e4435e 100644 --- a/tests/components/reolink/test_select.py +++ b/tests/components/reolink/test_select.py @@ -104,6 +104,58 @@ async def test_play_quick_reply_message( reolink_connect.quick_reply_dict = MagicMock() +async def test_host_scene_select( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + config_entry: MockConfigEntry, + reolink_connect: MagicMock, +) -> None: + """Test host select entity with scene mode.""" + with patch("homeassistant.components.reolink.PLATFORMS", [Platform.SELECT]): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.LOADED + + entity_id = f"{Platform.SELECT}.{TEST_NVR_NAME}_scene_mode" + assert hass.states.get(entity_id).state == "off" + + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: entity_id, "option": "home"}, + blocking=True, + ) + reolink_connect.baichuan.set_scene.assert_called_once() + + reolink_connect.baichuan.set_scene.side_effect = ReolinkError("Test error") + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: entity_id, "option": "home"}, + blocking=True, + ) + + reolink_connect.baichuan.set_scene.side_effect = InvalidParameterError("Test error") + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: entity_id, "option": "home"}, + blocking=True, + ) + + reolink_connect.baichuan.active_scene = "Invalid value" + freezer.tick(DEVICE_UPDATE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get(entity_id).state == STATE_UNKNOWN + + reolink_connect.baichuan.set_scene.reset_mock(side_effect=True) + reolink_connect.baichuan.active_scene = "off" + + async def test_chime_select( hass: HomeAssistant, freezer: FrozenDateTimeFactory, From a2f0970dfcd8876106ba720b47d3e6d400d06848 Mon Sep 17 00:00:00 2001 From: dontinelli <73341522+dontinelli@users.noreply.github.com> Date: Wed, 19 Mar 2025 15:09:10 +0100 Subject: [PATCH 2751/3148] Bump fyta_cli to 0.7.2 (#140930) --- homeassistant/components/fyta/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/fyta/manifest.json b/homeassistant/components/fyta/manifest.json index 1c91807b711..615197203a8 100644 --- a/homeassistant/components/fyta/manifest.json +++ b/homeassistant/components/fyta/manifest.json @@ -9,5 +9,5 @@ "iot_class": "cloud_polling", "loggers": ["fyta_cli"], "quality_scale": "platinum", - "requirements": ["fyta_cli==0.7.1"] + "requirements": ["fyta_cli==0.7.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index d1081bd3341..37f84836635 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -968,7 +968,7 @@ freesms==0.2.0 fritzconnection[qr]==1.14.0 # homeassistant.components.fyta -fyta_cli==0.7.1 +fyta_cli==0.7.2 # homeassistant.components.google_translate gTTS==2.5.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ab44df341d7..9c084dfd70e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -821,7 +821,7 @@ freebox-api==1.2.2 fritzconnection[qr]==1.14.0 # homeassistant.components.fyta -fyta_cli==0.7.1 +fyta_cli==0.7.2 # homeassistant.components.google_translate gTTS==2.5.3 From 6434befdcdcd1a7442c8ed57b82f5b354692e7ca Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 19 Mar 2025 15:12:43 +0100 Subject: [PATCH 2752/3148] Fix misleading airthings_ble test (#140933) --- tests/components/airthings_ble/test_config_flow.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/components/airthings_ble/test_config_flow.py b/tests/components/airthings_ble/test_config_flow.py index 314594c612f..2adc5498e7b 100644 --- a/tests/components/airthings_ble/test_config_flow.py +++ b/tests/components/airthings_ble/test_config_flow.py @@ -159,7 +159,6 @@ async def test_user_setup_replaces_ignored_device(hass: HomeAssistant) -> None: domain=DOMAIN, unique_id="cc:cc:cc:cc:cc:cc", source=SOURCE_IGNORE, - data={CONF_ADDRESS: "cc:cc:cc:cc:cc:cc"}, ) entry.add_to_hass(hass) with ( From 6af23d2348004a156b9488126667788d71bfebe1 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Wed, 19 Mar 2025 15:35:47 +0100 Subject: [PATCH 2753/3148] Add quality scale to Vodafone Station (#139444) * Add quality scale and strict typing to Vodafone Station * mypy and hassfest * tweek * parallel-updates * update * update manifest * apply review comment --- .../components/vodafone_station/manifest.json | 1 + .../vodafone_station/quality_scale.yaml | 88 +++++++++++++++++++ script/hassfest/quality_scale.py | 2 - 3 files changed, 89 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/vodafone_station/quality_scale.yaml diff --git a/homeassistant/components/vodafone_station/manifest.json b/homeassistant/components/vodafone_station/manifest.json index 4acafc8df3a..e3a595d5af8 100644 --- a/homeassistant/components/vodafone_station/manifest.json +++ b/homeassistant/components/vodafone_station/manifest.json @@ -7,5 +7,6 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["aiovodafone"], + "quality_scale": "bronze", "requirements": ["aiovodafone==0.6.1"] } diff --git a/homeassistant/components/vodafone_station/quality_scale.yaml b/homeassistant/components/vodafone_station/quality_scale.yaml new file mode 100644 index 00000000000..d9240afc2e7 --- /dev/null +++ b/homeassistant/components/vodafone_station/quality_scale.yaml @@ -0,0 +1,88 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: no actions + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: no actions + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: no events + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: todo + comment: button presses not exception handled with HomeAssistantError + config-entry-unloading: done + docs-configuration-parameters: done + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: done + test-coverage: done + + # Gold + devices: done + diagnostics: done + discovery-update-info: + status: exempt + comment: device not discoverable + discovery: + status: exempt + comment: device not discoverable + docs-data-update: done + docs-examples: + status: todo + comment: add some automation example + docs-known-limitations: + status: exempt + comment: no known limitations, yet + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: + status: todo + comment: add some info for troubleshooting + docs-use-cases: + status: todo + comment: add some use caes + dynamic-devices: done + entity-category: done + entity-device-class: done + entity-disabled-by-default: + status: exempt + comment: no known use case + entity-translations: done + exception-translations: + status: todo + comment: some missing in coordinator + icon-translations: done + reconfiguration-flow: + status: todo + comment: handle host change + repair-issues: + status: exempt + comment: no known use cases for repair issues or flows, yet + stale-devices: done + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index e1898afc79b..cdd062d2f4c 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -1087,7 +1087,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "vizio", "vlc", "vlc_telnet", - "vodafone_station", "voicerss", "voip", "volkszaehler", @@ -2171,7 +2170,6 @@ INTEGRATIONS_WITHOUT_SCALE = [ "vizio", "vlc", "vlc_telnet", - "vodafone_station", "voicerss", "voip", "volkszaehler", From 6211e378c3abd6d27ca922865814a5570fa114f2 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 19 Mar 2025 15:50:09 +0100 Subject: [PATCH 2754/3148] Fix flaky cast tests (#140928) --- tests/components/cast/test_media_player.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/components/cast/test_media_player.py b/tests/components/cast/test_media_player.py index b2ce60e9393..668ed985154 100644 --- a/tests/components/cast/test_media_player.py +++ b/tests/components/cast/test_media_player.py @@ -1909,6 +1909,7 @@ async def test_group_media_control( ) +@pytest.mark.usefixtures("mock_tts_cache_dir") async def test_failed_cast_on_idle( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: @@ -1939,6 +1940,7 @@ async def test_failed_cast_on_idle( assert "Failed to cast media http://example.com:8123/tts.mp3." in caplog.text +@pytest.mark.usefixtures("mock_tts_cache_dir") async def test_failed_cast_other_url( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: @@ -1963,6 +1965,7 @@ async def test_failed_cast_other_url( assert "Failed to cast media http://example.com:8123/tts.mp3." in caplog.text +@pytest.mark.usefixtures("mock_tts_cache_dir") async def test_failed_cast_internal_url( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: @@ -1992,6 +1995,7 @@ async def test_failed_cast_internal_url( ) +@pytest.mark.usefixtures("mock_tts_cache_dir") async def test_failed_cast_external_url( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: From 4a5567806b897ea2ebe4f0579c8187c19ea159cc Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Wed, 19 Mar 2025 16:14:02 +0100 Subject: [PATCH 2755/3148] Add exception translations for IMGW-PIB integration (#140936) Add exception translations --- homeassistant/components/imgw_pib/__init__.py | 9 ++++++++- homeassistant/components/imgw_pib/coordinator.py | 9 ++++++++- homeassistant/components/imgw_pib/strings.json | 8 ++++++++ 3 files changed, 24 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/imgw_pib/__init__.py b/homeassistant/components/imgw_pib/__init__.py index f9524316570..4bceee51f8e 100644 --- a/homeassistant/components/imgw_pib/__init__.py +++ b/homeassistant/components/imgw_pib/__init__.py @@ -38,7 +38,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ImgwPibConfigEntry) -> b hydrological_details=False, ) except (ClientError, TimeoutError, ApiError) as err: - raise ConfigEntryNotReady from err + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="cannot_connect", + translation_placeholders={ + "entry": entry.title, + "error": repr(err), + }, + ) from err coordinator = ImgwPibDataUpdateCoordinator(hass, entry, imgwpib, station_id) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/imgw_pib/coordinator.py b/homeassistant/components/imgw_pib/coordinator.py index fbe470ca953..f74878d672c 100644 --- a/homeassistant/components/imgw_pib/coordinator.py +++ b/homeassistant/components/imgw_pib/coordinator.py @@ -63,4 +63,11 @@ class ImgwPibDataUpdateCoordinator(DataUpdateCoordinator[HydrologicalData]): try: return await self.imgwpib.get_hydrological_data() except ApiError as err: - raise UpdateFailed(err) from err + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_error", + translation_placeholders={ + "entry": self.config_entry.title, + "error": repr(err), + }, + ) from err diff --git a/homeassistant/components/imgw_pib/strings.json b/homeassistant/components/imgw_pib/strings.json index 9a17dcf7087..89be0661c6f 100644 --- a/homeassistant/components/imgw_pib/strings.json +++ b/homeassistant/components/imgw_pib/strings.json @@ -25,5 +25,13 @@ "name": "Water temperature" } } + }, + "exceptions": { + "cannot_connect": { + "message": "An error occurred while connecting to the IMGW-PIB API for {entry}: {error}" + }, + "update_error": { + "message": "An error occurred while retrieving data from the IMGW-PIB API for {entry}: {error}" + } } } From 6b9c1e17e05fb19488c611627dd5caf25794d37f Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 19 Mar 2025 16:37:07 +0100 Subject: [PATCH 2756/3148] Fix docstring in selector helper (#140929) --- homeassistant/helpers/selector.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/helpers/selector.py b/homeassistant/helpers/selector.py index dd2fd8a677c..f2c76d1d019 100644 --- a/homeassistant/helpers/selector.py +++ b/homeassistant/helpers/selector.py @@ -1136,7 +1136,7 @@ class SelectOptionDict(TypedDict): class SelectSelectorMode(StrEnum): - """Possible modes for a number selector.""" + """Possible modes for a select selector.""" LIST = "list" DROPDOWN = "dropdown" From 2c9eb288e3eb6c8737c9c5f7998ce12f761ed26e Mon Sep 17 00:00:00 2001 From: jukrebs <76174575+MaestroOnICe@users.noreply.github.com> Date: Wed, 19 Mar 2025 16:51:39 +0100 Subject: [PATCH 2757/3148] Add capability to display updated firmware versions in Home Assistant (#140524) * add firmware version update * incoperate review feedback --- .../components/iometer/coordinator.py | 15 +++++++ homeassistant/components/iometer/entity.py | 2 +- tests/components/iometer/__init__.py | 12 ++++++ tests/components/iometer/test_init.py | 42 +++++++++++++++++++ 4 files changed, 70 insertions(+), 1 deletion(-) create mode 100644 tests/components/iometer/test_init.py diff --git a/homeassistant/components/iometer/coordinator.py b/homeassistant/components/iometer/coordinator.py index 708983fb28e..4050341151b 100644 --- a/homeassistant/components/iometer/coordinator.py +++ b/homeassistant/components/iometer/coordinator.py @@ -8,6 +8,7 @@ from iometer import IOmeterClient, IOmeterConnectionError, Reading, Status from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN @@ -31,6 +32,7 @@ class IOMeterCoordinator(DataUpdateCoordinator[IOmeterData]): config_entry: IOmeterConfigEntry client: IOmeterClient + current_fw_version: str = "" def __init__( self, @@ -58,4 +60,17 @@ class IOMeterCoordinator(DataUpdateCoordinator[IOmeterData]): except IOmeterConnectionError as error: raise UpdateFailed(f"Error communicating with IOmeter: {error}") from error + fw_version = f"{status.device.core.version}/{status.device.bridge.version}" + if self.current_fw_version and fw_version != self.current_fw_version: + device_registry = dr.async_get(self.hass) + device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, status.device.id)} + ) + assert device_entry + device_registry.async_update_device( + device_entry.id, + sw_version=fw_version, + ) + self.current_fw_version = fw_version + return IOmeterData(reading=reading, status=status) diff --git a/homeassistant/components/iometer/entity.py b/homeassistant/components/iometer/entity.py index 86494857e18..a52ef1c66ed 100644 --- a/homeassistant/components/iometer/entity.py +++ b/homeassistant/components/iometer/entity.py @@ -20,5 +20,5 @@ class IOmeterEntity(CoordinatorEntity[IOMeterCoordinator]): identifiers={(DOMAIN, status.device.id)}, manufacturer="IOmeter GmbH", model="IOmeter", - sw_version=f"{status.device.core.version}/{status.device.bridge.version}", + sw_version=coordinator.current_fw_version, ) diff --git a/tests/components/iometer/__init__.py b/tests/components/iometer/__init__.py index 5c08438925e..9e48fb982b3 100644 --- a/tests/components/iometer/__init__.py +++ b/tests/components/iometer/__init__.py @@ -1 +1,13 @@ """Tests for the IOmeter integration.""" + +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) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/iometer/test_init.py b/tests/components/iometer/test_init.py new file mode 100644 index 00000000000..22a20b50c60 --- /dev/null +++ b/tests/components/iometer/test_init.py @@ -0,0 +1,42 @@ +"""Tests for the AirGradient integration.""" + +from datetime import timedelta +from unittest.mock import AsyncMock + +from freezegun.api import FrozenDateTimeFactory + +from homeassistant.components.iometer.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from . import setup_integration + +from tests.common import MockConfigEntry, async_fire_time_changed + + +async def test_new_firmware_version( + hass: HomeAssistant, + mock_iometer_client: AsyncMock, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test device registry integration.""" + await setup_integration(hass, mock_config_entry) + device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, mock_config_entry.unique_id)} + ) + assert device_entry is not None + assert device_entry.sw_version == "build-58/build-65" + mock_iometer_client.get_current_status.return_value.device.core.version = "build-62" + mock_iometer_client.get_current_status.return_value.device.bridge.version = ( + "build-69" + ) + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, mock_config_entry.unique_id)} + ) + assert device_entry is not None + assert device_entry.sw_version == "build-62/build-69" From 05c61b7ec35b444b0782cc260206b6e30c1d3828 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 19 Mar 2025 17:28:40 +0100 Subject: [PATCH 2758/3148] Rename BackupManager last_non_idle_event to last_action_event (#140291) * Rename BackupManager last_non_idle_event to last_action_event * Update snapshots --- homeassistant/components/backup/manager.py | 4 +- homeassistant/components/backup/websocket.py | 2 +- homeassistant/components/onboarding/views.py | 2 +- .../backup/snapshots/test_backup.ambr | 10 ++-- .../backup/snapshots/test_websocket.ambr | 46 +++++++++---------- tests/components/backup/test_manager.py | 44 +++++++++--------- tests/components/cloud/test_backup.py | 2 +- tests/components/hassio/test_backup.py | 10 ++-- .../onboarding/snapshots/test_views.ambr | 2 +- tests/components/synology_dsm/test_backup.py | 2 +- 10 files changed, 62 insertions(+), 62 deletions(-) diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index 998e443a3b2..6dbe863185c 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -351,7 +351,7 @@ class BackupManager: # Latest backup event and backup event subscribers self.last_event: ManagerStateEvent = BlockedEvent() - self.last_non_idle_event: ManagerStateEvent | None = None + self.last_action_event: ManagerStateEvent | None = None self._backup_event_subscriptions = hass.data[ DATA_BACKUP ].backup_event_subscriptions @@ -1337,7 +1337,7 @@ class BackupManager: LOGGER.debug("Backup state: %s -> %s", current_state, new_state) self.last_event = event if not isinstance(event, (BlockedEvent, IdleEvent)): - self.last_non_idle_event = event + self.last_action_event = event for subscription in self._backup_event_subscriptions: subscription(event) diff --git a/homeassistant/components/backup/websocket.py b/homeassistant/components/backup/websocket.py index 8b5f35287dd..4c370a4224d 100644 --- a/homeassistant/components/backup/websocket.py +++ b/homeassistant/components/backup/websocket.py @@ -55,7 +55,7 @@ async def handle_info( "backups": list(backups.values()), "last_attempted_automatic_backup": manager.config.data.last_attempted_automatic_backup, "last_completed_automatic_backup": manager.config.data.last_completed_automatic_backup, - "last_non_idle_event": manager.last_non_idle_event, + "last_action_event": manager.last_action_event, "next_automatic_backup": manager.config.data.schedule.next_automatic_backup, "next_automatic_backup_additional": manager.config.data.schedule.next_automatic_backup_additional, "state": manager.state, diff --git a/homeassistant/components/onboarding/views.py b/homeassistant/components/onboarding/views.py index a590588c009..5f1d908f7f8 100644 --- a/homeassistant/components/onboarding/views.py +++ b/homeassistant/components/onboarding/views.py @@ -367,7 +367,7 @@ class BackupInfoView(BackupOnboardingView): { "backups": list(backups.values()), "state": manager.state, - "last_non_idle_event": manager.last_non_idle_event, + "last_action_event": manager.last_action_event, } ) diff --git a/tests/components/backup/snapshots/test_backup.ambr b/tests/components/backup/snapshots/test_backup.ambr index 28ee9b834c1..7cbbb9ddbce 100644 --- a/tests/components/backup/snapshots/test_backup.ambr +++ b/tests/components/backup/snapshots/test_backup.ambr @@ -114,9 +114,9 @@ 'with_automatic_settings': None, }), ]), + 'last_action_event': None, 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, - 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, 'state': 'idle', @@ -148,9 +148,9 @@ }), 'backups': list([ ]), + 'last_action_event': None, 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, - 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, 'state': 'idle', @@ -182,9 +182,9 @@ }), 'backups': list([ ]), + 'last_action_event': None, 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, - 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, 'state': 'idle', @@ -216,9 +216,9 @@ }), 'backups': list([ ]), + 'last_action_event': None, 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, - 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, 'state': 'idle', @@ -250,9 +250,9 @@ }), 'backups': list([ ]), + 'last_action_event': None, 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, - 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, 'state': 'idle', diff --git a/tests/components/backup/snapshots/test_websocket.ambr b/tests/components/backup/snapshots/test_websocket.ambr index 6ecb508d9e9..0bef632f0b4 100644 --- a/tests/components/backup/snapshots/test_websocket.ambr +++ b/tests/components/backup/snapshots/test_websocket.ambr @@ -3951,9 +3951,9 @@ }), 'backups': list([ ]), + 'last_action_event': None, 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, - 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, 'state': 'idle', @@ -3981,9 +3981,9 @@ }), 'backups': list([ ]), + 'last_action_event': None, 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, - 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, 'state': 'idle', @@ -4032,9 +4032,9 @@ 'with_automatic_settings': True, }), ]), + 'last_action_event': None, 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, - 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, 'state': 'idle', @@ -4062,9 +4062,9 @@ }), 'backups': list([ ]), + 'last_action_event': None, 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, - 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, 'state': 'idle', @@ -4113,9 +4113,9 @@ 'with_automatic_settings': True, }), ]), + 'last_action_event': None, 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, - 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, 'state': 'idle', @@ -4175,9 +4175,9 @@ 'with_automatic_settings': True, }), ]), + 'last_action_event': None, 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, - 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, 'state': 'idle', @@ -4221,9 +4221,9 @@ 'with_automatic_settings': None, }), ]), + 'last_action_event': None, 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, - 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, 'state': 'idle', @@ -4278,9 +4278,9 @@ 'with_automatic_settings': None, }), ]), + 'last_action_event': None, 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, - 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, 'state': 'idle', @@ -4333,9 +4333,9 @@ 'with_automatic_settings': True, }), ]), + 'last_action_event': None, 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, - 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, 'state': 'idle', @@ -4395,9 +4395,9 @@ 'with_automatic_settings': True, }), ]), + 'last_action_event': None, 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, - 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, 'state': 'idle', @@ -4458,9 +4458,9 @@ 'with_automatic_settings': True, }), ]), + 'last_action_event': None, 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, - 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, 'state': 'idle', @@ -4522,9 +4522,9 @@ 'with_automatic_settings': True, }), ]), + 'last_action_event': None, 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, - 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, 'state': 'idle', @@ -4584,9 +4584,9 @@ 'with_automatic_settings': True, }), ]), + 'last_action_event': None, 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, - 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, 'state': 'idle', @@ -4646,9 +4646,9 @@ 'with_automatic_settings': True, }), ]), + 'last_action_event': None, 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, - 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, 'state': 'idle', @@ -4709,9 +4709,9 @@ 'with_automatic_settings': True, }), ]), + 'last_action_event': None, 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, - 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, 'state': 'idle', @@ -4773,9 +4773,9 @@ 'with_automatic_settings': True, }), ]), + 'last_action_event': None, 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, - 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, 'state': 'idle', @@ -5350,9 +5350,9 @@ 'with_automatic_settings': True, }), ]), + 'last_action_event': None, 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, - 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, 'state': 'idle', @@ -5401,9 +5401,9 @@ 'with_automatic_settings': True, }), ]), + 'last_action_event': None, 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, - 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, 'state': 'idle', @@ -5456,9 +5456,9 @@ 'with_automatic_settings': True, }), ]), + 'last_action_event': None, 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, - 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, 'state': 'idle', @@ -5534,9 +5534,9 @@ 'with_automatic_settings': True, }), ]), + 'last_action_event': None, 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, - 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, 'state': 'idle', @@ -5586,9 +5586,9 @@ 'with_automatic_settings': True, }), ]), + 'last_action_event': None, 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, - 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, 'state': 'idle', @@ -5638,9 +5638,9 @@ 'with_automatic_settings': True, }), ]), + 'last_action_event': None, 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, - 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, 'state': 'idle', @@ -5690,9 +5690,9 @@ 'with_automatic_settings': True, }), ]), + 'last_action_event': None, 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, - 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, 'state': 'idle', diff --git a/tests/components/backup/test_manager.py b/tests/components/backup/test_manager.py index 41f98d6fa53..fef4b84ac61 100644 --- a/tests/components/backup/test_manager.py +++ b/tests/components/backup/test_manager.py @@ -538,7 +538,7 @@ async def test_initiate_backup( "agent_errors": {}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, - "last_non_idle_event": None, + "last_action_event": None, "next_automatic_backup": None, "next_automatic_backup_additional": False, "state": "idle", @@ -771,7 +771,7 @@ async def test_initiate_backup_with_agent_error( "agent_errors": {}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, - "last_non_idle_event": None, + "last_action_event": None, "next_automatic_backup": None, "next_automatic_backup_additional": False, "state": "idle", @@ -863,7 +863,7 @@ async def test_initiate_backup_with_agent_error( "agent_errors": {}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, - "last_non_idle_event": { + "last_action_event": { "manager_state": "create_backup", "reason": "upload_failed", "stage": None, @@ -1153,7 +1153,7 @@ async def test_initiate_backup_non_agent_upload_error( "agent_errors": {}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, - "last_non_idle_event": None, + "last_action_event": None, "next_automatic_backup": None, "next_automatic_backup_additional": False, "state": "idle", @@ -1250,7 +1250,7 @@ async def test_initiate_backup_with_task_error( "agent_errors": {}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, - "last_non_idle_event": None, + "last_action_event": None, "next_automatic_backup": None, "next_automatic_backup_additional": False, "state": "idle", @@ -1346,7 +1346,7 @@ async def test_initiate_backup_file_error_upload_to_agents( "agent_errors": {}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, - "last_non_idle_event": None, + "last_action_event": None, "next_automatic_backup": None, "next_automatic_backup_additional": False, "state": "idle", @@ -1470,7 +1470,7 @@ async def test_initiate_backup_file_error_create_backup( "agent_errors": {}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, - "last_non_idle_event": None, + "last_action_event": None, "next_automatic_backup": None, "next_automatic_backup_additional": False, "state": "idle", @@ -1967,7 +1967,7 @@ async def test_receive_backup_agent_error( "agent_errors": {}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, - "last_non_idle_event": None, + "last_action_event": None, "next_automatic_backup": None, "next_automatic_backup_additional": False, "state": "idle", @@ -2050,7 +2050,7 @@ async def test_receive_backup_agent_error( "agent_errors": {}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, - "last_non_idle_event": { + "last_action_event": { "manager_state": "receive_backup", "reason": None, "stage": None, @@ -2103,7 +2103,7 @@ async def test_receive_backup_non_agent_upload_error( "agent_errors": {}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, - "last_non_idle_event": None, + "last_action_event": None, "next_automatic_backup": None, "next_automatic_backup_additional": False, "state": "idle", @@ -2215,7 +2215,7 @@ async def test_receive_backup_file_write_error( "agent_errors": {}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, - "last_non_idle_event": None, + "last_action_event": None, "next_automatic_backup": None, "next_automatic_backup_additional": False, "state": "idle", @@ -2311,7 +2311,7 @@ async def test_receive_backup_read_tar_error( "agent_errors": {}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, - "last_non_idle_event": None, + "last_action_event": None, "next_automatic_backup": None, "next_automatic_backup_additional": False, "state": "idle", @@ -2476,7 +2476,7 @@ async def test_receive_backup_file_read_error( "agent_errors": {}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, - "last_non_idle_event": None, + "last_action_event": None, "next_automatic_backup": None, "next_automatic_backup_additional": False, "state": "idle", @@ -3287,7 +3287,7 @@ async def test_initiate_backup_per_agent_encryption( "agent_errors": {}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, - "last_non_idle_event": None, + "last_action_event": None, "next_automatic_backup": None, "next_automatic_backup_additional": False, "state": "idle", @@ -3390,7 +3390,7 @@ async def test_initiate_backup_per_agent_encryption( @pytest.mark.parametrize( - ("restore_result", "last_non_idle_event"), + ("restore_result", "last_action_event"), [ ( {"error": None, "error_type": None, "success": True}, @@ -3416,7 +3416,7 @@ async def test_restore_progress_after_restart( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, restore_result: dict[str, Any], - last_non_idle_event: dict[str, Any], + last_action_event: dict[str, Any], ) -> None: """Test restore backup progress after restart.""" @@ -3434,7 +3434,7 @@ async def test_restore_progress_after_restart( "backups": [], "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, - "last_non_idle_event": last_non_idle_event, + "last_action_event": last_action_event, "next_automatic_backup": None, "next_automatic_backup_additional": False, "state": "idle", @@ -3460,7 +3460,7 @@ async def test_restore_progress_after_restart_fail_to_remove( "backups": [], "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, - "last_non_idle_event": None, + "last_action_event": None, "next_automatic_backup": None, "next_automatic_backup_additional": False, "state": "idle", @@ -3485,20 +3485,20 @@ async def test_manager_blocked_until_home_assistant_started( manager = hass.data[DATA_MANAGER] assert manager.state == BackupManagerState.BLOCKED - assert manager.last_non_idle_event is None + assert manager.last_action_event is None # Fired when Home Assistant changes to starting state hass.bus.async_fire(EVENT_HOMEASSISTANT_START) await hass.async_block_till_done() await hass.async_block_till_done() assert manager.state == BackupManagerState.BLOCKED - assert manager.last_non_idle_event is None + assert manager.last_action_event is None # Fired when Home Assistant changes to running state hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() assert manager.state == BackupManagerState.IDLE - assert manager.last_non_idle_event is None + assert manager.last_action_event is None async def test_manager_not_blocked_after_restore( @@ -3523,7 +3523,7 @@ async def test_manager_not_blocked_after_restore( "backups": [], "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, - "last_non_idle_event": { + "last_action_event": { "manager_state": "restore_backup", "reason": None, "stage": None, diff --git a/tests/components/cloud/test_backup.py b/tests/components/cloud/test_backup.py index 5220d3eccd5..dd6252c4d62 100644 --- a/tests/components/cloud/test_backup.py +++ b/tests/components/cloud/test_backup.py @@ -208,7 +208,7 @@ async def test_agents_list_backups_fail_cloud( "backups": [], "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, - "last_non_idle_event": None, + "last_action_event": None, "next_automatic_backup": None, "next_automatic_backup_additional": False, "state": "idle", diff --git a/tests/components/hassio/test_backup.py b/tests/components/hassio/test_backup.py index 07a68b158d3..e00994b355a 100644 --- a/tests/components/hassio/test_backup.py +++ b/tests/components/hassio/test_backup.py @@ -2394,7 +2394,7 @@ async def test_reader_writer_restore_wrong_parameters( @pytest.mark.parametrize( - ("get_job_result", "last_non_idle_event"), + ("get_job_result", "last_action_event"), [ ( TEST_JOB_DONE, @@ -2422,7 +2422,7 @@ async def test_restore_progress_after_restart( hass_ws_client: WebSocketGenerator, supervisor_client: AsyncMock, get_job_result: supervisor_jobs.Job, - last_non_idle_event: dict[str, Any], + last_action_event: dict[str, Any], ) -> None: """Test restore backup progress after restart.""" @@ -2438,7 +2438,7 @@ async def test_restore_progress_after_restart( response = await client.receive_json() assert response["success"] - assert response["result"]["last_non_idle_event"] == last_non_idle_event + assert response["result"]["last_action_event"] == last_action_event assert response["result"]["state"] == "idle" @@ -2516,7 +2516,7 @@ async def test_restore_progress_after_restart_report_progress( response = await client.receive_json() assert response["success"] - assert response["result"]["last_non_idle_event"] == { + assert response["result"]["last_action_event"] == { "manager_state": "restore_backup", "reason": None, "stage": "addons", @@ -2545,7 +2545,7 @@ async def test_restore_progress_after_restart_unknown_job( response = await client.receive_json() assert response["success"] - assert response["result"]["last_non_idle_event"] is None + assert response["result"]["last_action_event"] is None assert response["result"]["state"] == "idle" diff --git a/tests/components/onboarding/snapshots/test_views.ambr b/tests/components/onboarding/snapshots/test_views.ambr index 2d084bd9ade..48ddf30d1f2 100644 --- a/tests/components/onboarding/snapshots/test_views.ambr +++ b/tests/components/onboarding/snapshots/test_views.ambr @@ -62,7 +62,7 @@ 'with_automatic_settings': None, }), ]), - 'last_non_idle_event': None, + 'last_action_event': None, 'state': 'idle', }) # --- diff --git a/tests/components/synology_dsm/test_backup.py b/tests/components/synology_dsm/test_backup.py index 24cfe29f52b..8475a253231 100644 --- a/tests/components/synology_dsm/test_backup.py +++ b/tests/components/synology_dsm/test_backup.py @@ -338,7 +338,7 @@ async def test_agents_list_backups_error( "backups": [], "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, - "last_non_idle_event": None, + "last_action_event": None, "next_automatic_backup": None, "next_automatic_backup_additional": False, "state": "idle", From d99df8701cbac0136d0de1fe55ca28009a1b6f26 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 19 Mar 2025 18:50:19 +0100 Subject: [PATCH 2759/3148] Use official spelling "FFmpeg" in user-facing strings (#140937) * Use official spelling "FFmpeg" in user-facing strings * Replace "a" with "an" --- homeassistant/components/ffmpeg/strings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ffmpeg/strings.json b/homeassistant/components/ffmpeg/strings.json index 66c1f19de5b..cac7fcfc48c 100644 --- a/homeassistant/components/ffmpeg/strings.json +++ b/homeassistant/components/ffmpeg/strings.json @@ -2,7 +2,7 @@ "services": { "restart": { "name": "[%key:common::action::restart%]", - "description": "Sends a restart command to a ffmpeg based sensor.", + "description": "Sends a restart command to an FFmpeg-based sensor.", "fields": { "entity_id": { "name": "Entity", @@ -12,7 +12,7 @@ }, "start": { "name": "[%key:common::action::start%]", - "description": "Sends a start command to a ffmpeg based sensor.", + "description": "Sends a start command to an FFmpeg-based sensor.", "fields": { "entity_id": { "name": "Entity", @@ -22,7 +22,7 @@ }, "stop": { "name": "[%key:common::action::stop%]", - "description": "Sends a stop command to a ffmpeg based sensor.", + "description": "Sends a stop command to an FFmpeg-based sensor.", "fields": { "entity_id": { "name": "Entity", From 8afd9c0c448ca5ebfee90e2409ca694ed1d8f6f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Wed, 19 Mar 2025 18:53:14 +0100 Subject: [PATCH 2760/3148] Handle API rate limit error on Home Connect entities fetch (#139384) * Handle API rate limit error on entities fetch * Apply suggestions Co-authored-by: Martin Hjelmare * Add decorator (does not work) * Fix decorator * Apply suggestions Co-authored-by: Martin Hjelmare * Add test --------- Co-authored-by: Martin Hjelmare --- .../components/home_connect/const.py | 1 + .../components/home_connect/entity.py | 44 +++++- .../components/home_connect/number.py | 23 +-- .../components/home_connect/select.py | 20 ++- .../components/home_connect/sensor.py | 21 ++- tests/components/home_connect/test_number.py | 97 ++++++++++++- tests/components/home_connect/test_select.py | 136 +++++++++++++++++- tests/components/home_connect/test_sensor.py | 124 +++++++++++++++- 8 files changed, 431 insertions(+), 35 deletions(-) diff --git a/homeassistant/components/home_connect/const.py b/homeassistant/components/home_connect/const.py index 1c607ccec28..6255a513e39 100644 --- a/homeassistant/components/home_connect/const.py +++ b/homeassistant/components/home_connect/const.py @@ -10,6 +10,7 @@ from .utils import bsh_key_to_translation_key DOMAIN = "home_connect" +API_DEFAULT_RETRY_AFTER = 60 APPLIANCES_WITH_PROGRAMS = ( "CleaningRobot", diff --git a/homeassistant/components/home_connect/entity.py b/homeassistant/components/home_connect/entity.py index b55ff374f34..8a0f9bd7640 100644 --- a/homeassistant/components/home_connect/entity.py +++ b/homeassistant/components/home_connect/entity.py @@ -1,21 +1,28 @@ """Home Connect entity base class.""" from abc import abstractmethod +from collections.abc import Callable, Coroutine import contextlib +from datetime import datetime import logging -from typing import cast +from typing import Any, Concatenate, cast from aiohomeconnect.model import EventKey, OptionKey -from aiohomeconnect.model.error import ActiveProgramNotSetError, HomeConnectError +from aiohomeconnect.model.error import ( + ActiveProgramNotSetError, + HomeConnectError, + TooManyRequestsError, +) from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.event import async_call_later from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN +from .const import API_DEFAULT_RETRY_AFTER, DOMAIN from .coordinator import HomeConnectApplianceData, HomeConnectCoordinator from .utils import get_dict_from_home_connect_error @@ -127,3 +134,34 @@ class HomeConnectOptionEntity(HomeConnectEntity): def bsh_key(self) -> OptionKey: """Return the BSH key.""" return cast(OptionKey, self.entity_description.key) + + +def constraint_fetcher[_EntityT: HomeConnectEntity, **_P]( + func: Callable[Concatenate[_EntityT, _P], Coroutine[Any, Any, Any]], +) -> Callable[Concatenate[_EntityT, _P], Coroutine[Any, Any, None]]: + """Decorate the function to catch Home Connect too many requests error and retry later. + + If it needs to be called later, it will call async_write_ha_state function + """ + + async def handler_to_return( + self: _EntityT, *args: _P.args, **kwargs: _P.kwargs + ) -> None: + async def handler(_datetime: datetime | None = None) -> None: + try: + await func(self, *args, **kwargs) + except TooManyRequestsError as err: + if (retry_after := err.retry_after) is None: + retry_after = API_DEFAULT_RETRY_AFTER + async_call_later(self.hass, retry_after, handler) + except HomeConnectError as err: + _LOGGER.error( + "Error fetching constraints for %s: %s", self.entity_id, err + ) + else: + if _datetime is not None: + self.async_write_ha_state() + + await handler() + + return handler_to_return diff --git a/homeassistant/components/home_connect/number.py b/homeassistant/components/home_connect/number.py index cef35005b32..db0258f2739 100644 --- a/homeassistant/components/home_connect/number.py +++ b/homeassistant/components/home_connect/number.py @@ -25,7 +25,7 @@ from .const import ( UNIT_MAP, ) from .coordinator import HomeConnectApplianceData, HomeConnectConfigEntry -from .entity import HomeConnectEntity, HomeConnectOptionEntity +from .entity import HomeConnectEntity, HomeConnectOptionEntity, constraint_fetcher from .utils import get_dict_from_home_connect_error _LOGGER = logging.getLogger(__name__) @@ -189,19 +189,25 @@ class HomeConnectNumberEntity(HomeConnectEntity, NumberEntity): }, ) from err + @constraint_fetcher async def async_fetch_constraints(self) -> None: """Fetch the max and min values and step for the number entity.""" - try: + setting_key = cast(SettingKey, self.bsh_key) + data = self.appliance.settings.get(setting_key) + if not data or not data.unit or not data.constraints: data = await self.coordinator.client.get_setting( - self.appliance.info.ha_id, setting_key=SettingKey(self.bsh_key) + self.appliance.info.ha_id, setting_key=setting_key ) - except HomeConnectError as err: - _LOGGER.error("An error occurred: %s", err) - else: + if data.unit: + self._attr_native_unit_of_measurement = data.unit self.set_constraints(data) def set_constraints(self, setting: GetSetting) -> None: """Set constraints for the number entity.""" + if setting.unit: + self._attr_native_unit_of_measurement = UNIT_MAP.get( + setting.unit, setting.unit + ) if not (constraints := setting.constraints): return if constraints.max: @@ -222,10 +228,10 @@ class HomeConnectNumberEntity(HomeConnectEntity, NumberEntity): """When entity is added to hass.""" await super().async_added_to_hass() data = self.appliance.settings[cast(SettingKey, self.bsh_key)] - self._attr_native_unit_of_measurement = data.unit self.set_constraints(data) if ( - not hasattr(self, "_attr_native_min_value") + not hasattr(self, "_attr_native_unit_of_measurement") + or not hasattr(self, "_attr_native_min_value") or not hasattr(self, "_attr_native_max_value") or not hasattr(self, "_attr_native_step") ): @@ -253,7 +259,6 @@ class HomeConnectOptionNumberEntity(HomeConnectOptionEntity, NumberEntity): or candidate_unit != self._attr_native_unit_of_measurement ): self._attr_native_unit_of_measurement = candidate_unit - self.__dict__.pop("unit_of_measurement", None) option_constraints = option_definition.constraints if option_constraints: if ( diff --git a/homeassistant/components/home_connect/select.py b/homeassistant/components/home_connect/select.py index 527fd827399..001c2e9ec31 100644 --- a/homeassistant/components/home_connect/select.py +++ b/homeassistant/components/home_connect/select.py @@ -1,8 +1,8 @@ """Provides a select platform for Home Connect.""" from collections.abc import Callable, Coroutine -import contextlib from dataclasses import dataclass +import logging from typing import Any, cast from aiohomeconnect.client import Client as HomeConnectClient @@ -47,9 +47,11 @@ from .coordinator import ( HomeConnectConfigEntry, HomeConnectCoordinator, ) -from .entity import HomeConnectEntity, HomeConnectOptionEntity +from .entity import HomeConnectEntity, HomeConnectOptionEntity, constraint_fetcher from .utils import bsh_key_to_translation_key, get_dict_from_home_connect_error +_LOGGER = logging.getLogger(__name__) + PARALLEL_UPDATES = 1 FUNCTIONAL_LIGHT_COLOR_TEMPERATURE_ENUM = { @@ -460,17 +462,21 @@ class HomeConnectSelectEntity(HomeConnectEntity, SelectEntity): async def async_added_to_hass(self) -> None: """When entity is added to hass.""" await super().async_added_to_hass() + await self.async_fetch_options() + + @constraint_fetcher + async def async_fetch_options(self) -> None: + """Fetch options from the API.""" setting = self.appliance.settings.get(cast(SettingKey, self.bsh_key)) if ( not setting or not setting.constraints or not setting.constraints.allowed_values ): - with contextlib.suppress(HomeConnectError): - setting = await self.coordinator.client.get_setting( - self.appliance.info.ha_id, - setting_key=cast(SettingKey, self.bsh_key), - ) + setting = await self.coordinator.client.get_setting( + self.appliance.info.ha_id, + setting_key=cast(SettingKey, self.bsh_key), + ) if setting and setting.constraints and setting.constraints.allowed_values: self._original_option_keys = set(setting.constraints.allowed_values) diff --git a/homeassistant/components/home_connect/sensor.py b/homeassistant/components/home_connect/sensor.py index c12e1b7b6e4..796af8260fc 100644 --- a/homeassistant/components/home_connect/sensor.py +++ b/homeassistant/components/home_connect/sensor.py @@ -1,12 +1,11 @@ """Provides a sensor for Home Connect.""" -import contextlib from dataclasses import dataclass from datetime import timedelta +import logging from typing import cast from aiohomeconnect.model import EventKey, StatusKey -from aiohomeconnect.model.error import HomeConnectError from homeassistant.components.sensor import ( SensorDeviceClass, @@ -28,7 +27,9 @@ from .const import ( UNIT_MAP, ) from .coordinator import HomeConnectApplianceData, HomeConnectConfigEntry -from .entity import HomeConnectEntity +from .entity import HomeConnectEntity, constraint_fetcher + +_LOGGER = logging.getLogger(__name__) PARALLEL_UPDATES = 0 @@ -335,16 +336,14 @@ class HomeConnectSensor(HomeConnectEntity, SensorEntity): else: await self.fetch_unit() + @constraint_fetcher async def fetch_unit(self) -> None: """Fetch the unit of measurement.""" - with contextlib.suppress(HomeConnectError): - data = await self.coordinator.client.get_status_value( - self.appliance.info.ha_id, status_key=cast(StatusKey, self.bsh_key) - ) - if data.unit: - self._attr_native_unit_of_measurement = UNIT_MAP.get( - data.unit, data.unit - ) + data = await self.coordinator.client.get_status_value( + self.appliance.info.ha_id, status_key=cast(StatusKey, self.bsh_key) + ) + if data.unit: + self._attr_native_unit_of_measurement = UNIT_MAP.get(data.unit, data.unit) class HomeConnectProgramSensor(HomeConnectSensor): diff --git a/tests/components/home_connect/test_number.py b/tests/components/home_connect/test_number.py index 214dcb6137c..bb87cf9f3dc 100644 --- a/tests/components/home_connect/test_number.py +++ b/tests/components/home_connect/test_number.py @@ -2,7 +2,7 @@ from collections.abc import Awaitable, Callable import random -from unittest.mock import AsyncMock, MagicMock +from unittest.mock import AsyncMock, MagicMock, patch from aiohomeconnect.model import ( ArrayOfEvents, @@ -22,6 +22,7 @@ from aiohomeconnect.model.error import ( HomeConnectApiError, HomeConnectError, SelectedProgramNotSetError, + TooManyRequestsError, ) from aiohomeconnect.model.program import ( ProgramDefinitionConstraints, @@ -47,7 +48,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed @pytest.fixture @@ -340,6 +341,98 @@ async def test_number_entity_functionality( assert hass.states.is_state(entity_id, str(float(value))) +@pytest.mark.parametrize("appliance_ha_id", ["FridgeFreezer"], indirect=True) +@pytest.mark.parametrize("retry_after", [0, None]) +@pytest.mark.parametrize( + ( + "entity_id", + "setting_key", + "type", + "min_value", + "max_value", + "step_size", + "unit_of_measurement", + ), + [ + ( + f"{NUMBER_DOMAIN.lower()}.fridgefreezer_refrigerator_temperature", + SettingKey.REFRIGERATION_FRIDGE_FREEZER_SETPOINT_TEMPERATURE_REFRIGERATOR, + "Double", + 7, + 15, + 5, + "°C", + ), + ], +) +@patch("homeassistant.components.home_connect.entity.API_DEFAULT_RETRY_AFTER", new=0) +async def test_fetch_constraints_after_rate_limit_error( + retry_after: int | None, + appliance_ha_id: str, + entity_id: str, + setting_key: SettingKey, + type: str, + min_value: int, + max_value: int, + step_size: int, + unit_of_measurement: str, + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, +) -> None: + """Test that, if a API rate limit error is raised, the constraints are fetched later.""" + + def get_settings_side_effect(ha_id: str): + if ha_id != appliance_ha_id: + return ArrayOfSettings([]) + return ArrayOfSettings( + [ + GetSetting( + key=setting_key, + raw_key=setting_key.value, + value=random.randint(min_value, max_value), + ) + ] + ) + + client.get_settings = AsyncMock(side_effect=get_settings_side_effect) + client.get_setting = AsyncMock( + side_effect=[ + TooManyRequestsError("error.key", retry_after=retry_after), + GetSetting( + key=setting_key, + raw_key=setting_key.value, + value=random.randint(min_value, max_value), + unit=unit_of_measurement, + type=type, + constraints=SettingConstraints( + min=min_value, + max=max_value, + step_size=step_size, + ), + ), + ] + ) + + assert config_entry.state is ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.LOADED + + assert client.get_setting.call_count == 2 + + entity_state = hass.states.get(entity_id) + assert entity_state + attributes = entity_state.attributes + assert attributes["min"] == min_value + assert attributes["max"] == max_value + assert attributes["step"] == step_size + assert attributes["unit_of_measurement"] == unit_of_measurement + + @pytest.mark.parametrize( ("entity_id", "setting_key", "mock_attr"), [ diff --git a/tests/components/home_connect/test_select.py b/tests/components/home_connect/test_select.py index 8ce91ed681c..f20be33081c 100644 --- a/tests/components/home_connect/test_select.py +++ b/tests/components/home_connect/test_select.py @@ -21,6 +21,7 @@ from aiohomeconnect.model.error import ( ActiveProgramNotSetError, HomeConnectError, SelectedProgramNotSetError, + TooManyRequestsError, ) from aiohomeconnect.model.program import ( EnumerateProgram, @@ -50,7 +51,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed @pytest.fixture @@ -575,6 +576,139 @@ async def test_fetch_allowed_values( assert set(entity_state.attributes[ATTR_OPTIONS]) == expected_options +@pytest.mark.parametrize("appliance_ha_id", ["Hood"], indirect=True) +@pytest.mark.parametrize( + ( + "entity_id", + "setting_key", + "allowed_values", + "expected_options", + ), + [ + ( + "select.hood_ambient_light_color", + SettingKey.BSH_COMMON_AMBIENT_LIGHT_COLOR, + [f"BSH.Common.EnumType.AmbientLightColor.Color{i}" for i in range(50)], + {str(i) for i in range(1, 50)}, + ), + ], +) +async def test_fetch_allowed_values_after_rate_limit_error( + appliance_ha_id: str, + entity_id: str, + setting_key: SettingKey, + allowed_values: list[str | None], + expected_options: set[str], + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, +) -> None: + """Test fetch allowed values.""" + + def get_settings_side_effect(ha_id: str): + if ha_id != appliance_ha_id: + return ArrayOfSettings([]) + return ArrayOfSettings( + [ + GetSetting( + key=setting_key, + raw_key=setting_key.value, + value="", # Not important + ) + ] + ) + + client.get_settings = AsyncMock(side_effect=get_settings_side_effect) + client.get_setting = AsyncMock( + side_effect=[ + TooManyRequestsError("error.key", retry_after=0), + GetSetting( + key=setting_key, + raw_key=setting_key.value, + value="", # Not important + constraints=SettingConstraints( + allowed_values=allowed_values, + ), + ), + ] + ) + + assert config_entry.state is ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.LOADED + + assert client.get_setting.call_count == 2 + + entity_state = hass.states.get(entity_id) + assert entity_state + assert set(entity_state.attributes[ATTR_OPTIONS]) == expected_options + + +@pytest.mark.parametrize("appliance_ha_id", ["Hood"], indirect=True) +@pytest.mark.parametrize( + ( + "entity_id", + "setting_key", + "exception", + "expected_options", + ), + [ + ( + "select.hood_ambient_light_color", + SettingKey.BSH_COMMON_AMBIENT_LIGHT_COLOR, + HomeConnectError(), + { + "b_s_h_common_enum_type_ambient_light_color_custom_color", + *{str(i) for i in range(1, 100)}, + }, + ), + ], +) +async def test_default_values_after_fetch_allowed_values_error( + appliance_ha_id: str, + entity_id: str, + setting_key: SettingKey, + exception: Exception, + expected_options: set[str], + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, +) -> None: + """Test fetch allowed values.""" + + def get_settings_side_effect(ha_id: str): + if ha_id != appliance_ha_id: + return ArrayOfSettings([]) + return ArrayOfSettings( + [ + GetSetting( + key=setting_key, + raw_key=setting_key.value, + value="", # Not important + ) + ] + ) + + client.get_settings = AsyncMock(side_effect=get_settings_side_effect) + client.get_setting = AsyncMock(side_effect=exception) + + assert config_entry.state is ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state is ConfigEntryState.LOADED + + assert client.get_setting.call_count == 1 + + entity_state = hass.states.get(entity_id) + assert entity_state + assert set(entity_state.attributes[ATTR_OPTIONS]) == expected_options + + @pytest.mark.parametrize( ("entity_id", "setting_key", "allowed_value", "value_to_set", "mock_attr"), [ diff --git a/tests/components/home_connect/test_sensor.py b/tests/components/home_connect/test_sensor.py index 04f5e056aa5..a7836223737 100644 --- a/tests/components/home_connect/test_sensor.py +++ b/tests/components/home_connect/test_sensor.py @@ -13,7 +13,7 @@ from aiohomeconnect.model import ( Status, StatusKey, ) -from aiohomeconnect.model.error import HomeConnectApiError +from aiohomeconnect.model.error import HomeConnectApiError, TooManyRequestsError from freezegun.api import FrozenDateTimeFactory import pytest @@ -26,12 +26,13 @@ from homeassistant.components.home_connect.const import ( BSH_EVENT_PRESENT_STATE_PRESENT, DOMAIN, ) +from homeassistant.components.home_connect.coordinator import HomeConnectError from homeassistant.config_entries import ConfigEntryState from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed TEST_HC_APP = "Dishwasher" @@ -724,3 +725,122 @@ async def test_sensor_unit_fetching( ) assert client.get_status_value.call_count == get_status_value_call_count + + +@pytest.mark.parametrize( + ( + "appliance_ha_id", + "entity_id", + "status_key", + ), + [ + ( + "Oven", + "sensor.oven_current_oven_cavity_temperature", + StatusKey.COOKING_OVEN_CURRENT_CAVITY_TEMPERATURE, + ), + ], + indirect=["appliance_ha_id"], +) +async def test_sensor_unit_fetching_error( + appliance_ha_id: str, + entity_id: str, + status_key: StatusKey, + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, +) -> None: + """Test that the sensor entities are capable of fetching units.""" + + async def get_status_mock(ha_id: str) -> ArrayOfStatus: + if ha_id != appliance_ha_id: + return ArrayOfStatus([]) + return ArrayOfStatus( + [ + Status( + key=status_key, + raw_key=status_key.value, + value=0, + ) + ] + ) + + client.get_status = AsyncMock(side_effect=get_status_mock) + client.get_status_value = AsyncMock(side_effect=HomeConnectError()) + + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + assert hass.states.get(entity_id) + + +@pytest.mark.parametrize( + ( + "appliance_ha_id", + "entity_id", + "status_key", + "unit", + ), + [ + ( + "Oven", + "sensor.oven_current_oven_cavity_temperature", + StatusKey.COOKING_OVEN_CURRENT_CAVITY_TEMPERATURE, + "°C", + ), + ], + indirect=["appliance_ha_id"], +) +async def test_sensor_unit_fetching_after_rate_limit_error( + appliance_ha_id: str, + entity_id: str, + status_key: StatusKey, + unit: str, + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, +) -> None: + """Test that the sensor entities are capable of fetching units.""" + + async def get_status_mock(ha_id: str) -> ArrayOfStatus: + if ha_id != appliance_ha_id: + return ArrayOfStatus([]) + return ArrayOfStatus( + [ + Status( + key=status_key, + raw_key=status_key.value, + value=0, + ) + ] + ) + + client.get_status = AsyncMock(side_effect=get_status_mock) + client.get_status_value = AsyncMock( + side_effect=[ + TooManyRequestsError("error.key", retry_after=0), + Status( + key=status_key, + raw_key=status_key.value, + value=0, + unit=unit, + ), + ] + ) + + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert config_entry.state == ConfigEntryState.LOADED + + assert client.get_status_value.call_count == 2 + + entity_state = hass.states.get(entity_id) + assert entity_state + assert entity_state.attributes["unit_of_measurement"] == unit From 2ffec3415cc46908c539367e482bd8d2504d8490 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 19 Mar 2025 19:17:42 +0100 Subject: [PATCH 2761/3148] Use official spelling "FFmpeg" in `ezviz` / `canary` / `onvif` (#140938) * Use official spelling "FFmpeg" in `ezviz` * Use official spelling "FFmpeg" in `canary` Fix sentence-casing along the way. * Use official spelling "FFmpeg" in `onvif` Fix sentence-casing along the way --- homeassistant/components/canary/strings.json | 4 ++-- homeassistant/components/ezviz/strings.json | 2 +- homeassistant/components/onvif/strings.json | 6 +++--- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/canary/strings.json b/homeassistant/components/canary/strings.json index 699e8b25e11..8be11a48b5e 100644 --- a/homeassistant/components/canary/strings.json +++ b/homeassistant/components/canary/strings.json @@ -21,8 +21,8 @@ "step": { "init": { "data": { - "ffmpeg_arguments": "Arguments passed to ffmpeg for cameras", - "timeout": "Request Timeout (seconds)" + "ffmpeg_arguments": "Arguments passed to FFmpeg for cameras", + "timeout": "Request timeout (seconds)" } } } diff --git a/homeassistant/components/ezviz/strings.json b/homeassistant/components/ezviz/strings.json index f1653661cdd..cd8bbc9d199 100644 --- a/homeassistant/components/ezviz/strings.json +++ b/homeassistant/components/ezviz/strings.json @@ -54,7 +54,7 @@ "init": { "data": { "timeout": "Request timeout (seconds)", - "ffmpeg_arguments": "Arguments passed to ffmpeg for cameras" + "ffmpeg_arguments": "Arguments passed to FFmpeg for cameras" } } } diff --git a/homeassistant/components/onvif/strings.json b/homeassistant/components/onvif/strings.json index 0afb5e59e8e..7988c50b1ac 100644 --- a/homeassistant/components/onvif/strings.json +++ b/homeassistant/components/onvif/strings.json @@ -62,12 +62,12 @@ "step": { "onvif_devices": { "data": { - "extra_arguments": "Extra FFMPEG arguments", + "extra_arguments": "Extra FFmpeg arguments", "rtsp_transport": "RTSP transport mechanism", "use_wallclock_as_timestamps": "Use wall clock as timestamps", - "enable_webhooks": "Enable Webhooks" + "enable_webhooks": "Enable webhooks" }, - "title": "ONVIF Device Options" + "title": "ONVIF device options" } } }, From 4344e9d604a6cc7f930c2fcf52fd9b86d38e0bf9 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 19 Mar 2025 19:23:15 +0100 Subject: [PATCH 2762/3148] Add remote control status to SmartThings (#140197) * Add remote control status to SmartThings * Add remote control status to SmartThings * Fix --- .../components/smartthings/binary_sensor.py | 7 + .../components/smartthings/icons.json | 12 + .../components/smartthings/strings.json | 3 + .../snapshots/test_binary_sensor.ambr | 376 ++++++++++++++++++ 4 files changed, 398 insertions(+) create mode 100644 homeassistant/components/smartthings/icons.json diff --git a/homeassistant/components/smartthings/binary_sensor.py b/homeassistant/components/smartthings/binary_sensor.py index 25b9cbefb6f..0654846273e 100644 --- a/homeassistant/components/smartthings/binary_sensor.py +++ b/homeassistant/components/smartthings/binary_sensor.py @@ -75,6 +75,13 @@ CAPABILITY_TO_SENSORS: dict[ is_on_key="present", ) }, + Capability.REMOTE_CONTROL_STATUS: { + Attribute.REMOTE_CONTROL_ENABLED: SmartThingsBinarySensorEntityDescription( + key=Attribute.REMOTE_CONTROL_ENABLED, + translation_key="remote_control", + is_on_key="true", + ) + }, Capability.SOUND_SENSOR: { Attribute.SOUND: SmartThingsBinarySensorEntityDescription( key=Attribute.SOUND, diff --git a/homeassistant/components/smartthings/icons.json b/homeassistant/components/smartthings/icons.json new file mode 100644 index 00000000000..cbc4b6b80ce --- /dev/null +++ b/homeassistant/components/smartthings/icons.json @@ -0,0 +1,12 @@ +{ + "entity": { + "binary_sensor": { + "remote_control": { + "default": "mdi:remote-off", + "state": { + "on": "mdi:remote" + } + } + } + } +} diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index 99e1550caba..fdc905468f5 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -36,6 +36,9 @@ "filter_status": { "name": "Filter status" }, + "remote_control": { + "name": "Remote control" + }, "valve": { "name": "Valve" } diff --git a/tests/components/smartthings/snapshots/test_binary_sensor.ambr b/tests/components/smartthings/snapshots/test_binary_sensor.ambr index 27a5e38a123..6223c6c526c 100644 --- a/tests/components/smartthings/snapshots/test_binary_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_binary_sensor.ambr @@ -143,6 +143,147 @@ 'state': 'off', }) # --- +# name: test_all_entities[da_ks_microwave_0101x][binary_sensor.microwave_remote_control-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.microwave_remote_control', + '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': 'Remote control', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'remote_control', + 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a.remoteControlEnabled', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_microwave_0101x][binary_sensor.microwave_remote_control-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Microwave Remote control', + }), + 'context': , + 'entity_id': 'binary_sensor.microwave_remote_control', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[da_ks_oven_01061][binary_sensor.oven_remote_control-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.oven_remote_control', + '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': 'Remote control', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'remote_control', + 'unique_id': '9447959a-0dfa-6b27-d40d-650da525c53f.remoteControlEnabled', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_oven_01061][binary_sensor.oven_remote_control-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Oven Remote control', + }), + 'context': , + 'entity_id': 'binary_sensor.oven_remote_control', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[da_ks_range_0101x][binary_sensor.vulcan_remote_control-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.vulcan_remote_control', + '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': 'Remote control', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'remote_control', + 'unique_id': '2c3cbaa0-1899-5ddc-7b58-9d657bd48f18.remoteControlEnabled', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_range_0101x][binary_sensor.vulcan_remote_control-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Vulcan Remote control', + }), + 'context': , + 'entity_id': 'binary_sensor.vulcan_remote_control', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_all_entities[da_ref_normal_000001][binary_sensor.refrigerator_door-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -191,6 +332,241 @@ 'state': 'off', }) # --- +# name: test_all_entities[da_wm_dw_000001][binary_sensor.dishwasher_remote_control-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.dishwasher_remote_control', + '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': 'Remote control', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'remote_control', + 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676.remoteControlEnabled', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_dw_000001][binary_sensor.dishwasher_remote_control-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dishwasher Remote control', + }), + 'context': , + 'entity_id': 'binary_sensor.dishwasher_remote_control', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[da_wm_wd_000001][binary_sensor.dryer_remote_control-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.dryer_remote_control', + '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': 'Remote control', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'remote_control', + 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b.remoteControlEnabled', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wd_000001][binary_sensor.dryer_remote_control-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dryer Remote control', + }), + 'context': , + 'entity_id': 'binary_sensor.dryer_remote_control', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[da_wm_wd_000001_1][binary_sensor.seca_roupa_remote_control-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.seca_roupa_remote_control', + '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': 'Remote control', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'remote_control', + 'unique_id': '3a6c4e05-811d-5041-e956-3d04c424cbcd.remoteControlEnabled', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wd_000001_1][binary_sensor.seca_roupa_remote_control-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Seca-Roupa Remote control', + }), + 'context': , + 'entity_id': 'binary_sensor.seca_roupa_remote_control', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[da_wm_wm_000001][binary_sensor.washer_remote_control-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.washer_remote_control', + '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': 'Remote control', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'remote_control', + 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47.remoteControlEnabled', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wm_000001][binary_sensor.washer_remote_control-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Washer Remote control', + }), + 'context': , + 'entity_id': 'binary_sensor.washer_remote_control', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[da_wm_wm_000001_1][binary_sensor.washing_machine_remote_control-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.washing_machine_remote_control', + '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': 'Remote control', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'remote_control', + 'unique_id': '63803fae-cbed-f356-a063-2cf148ae3ca7.remoteControlEnabled', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wm_000001_1][binary_sensor.washing_machine_remote_control-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Washing Machine Remote control', + }), + 'context': , + 'entity_id': 'binary_sensor.washing_machine_remote_control', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_all_entities[ecobee_sensor][binary_sensor.child_bedroom_motion-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 100e4425e4f301856b20672e9505c692faaf276e Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 19 Mar 2025 20:13:46 +0100 Subject: [PATCH 2763/3148] Log SmartThings subscription error on exception (#140939) --- homeassistant/components/smartthings/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py index 538a4a16171..58afbb6cb41 100644 --- a/homeassistant/components/smartthings/__init__.py +++ b/homeassistant/components/smartthings/__init__.py @@ -141,7 +141,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: SmartThingsConfigEntry) entry.data[CONF_TOKEN][CONF_INSTALLED_APP_ID], ) except SmartThingsSinkError as err: - _LOGGER.debug("Couldn't create a new subscription: %s", err) + _LOGGER.exception("Couldn't create a new subscription") raise ConfigEntryNotReady from err subscription_id = subscription.subscription_id _handle_new_subscription_identifier(subscription_id) From a600bc5e5788d757ad10bbcd8ce78fc6a92b3bc6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 19 Mar 2025 11:19:04 -1000 Subject: [PATCH 2764/3148] Add turn on/off support to HomeKit TVs (#140957) * Add turn on/off support to HomeKit TVs * 0 = off, 1 = on, not a bool * add coverage * update snapshot --- .../homekit_controller/media_player.py | 10 +++- .../snapshots/test_init.ambr | 4 +- .../specific_devices/test_lg_tv.py | 56 ++++++++++++++++++ .../homekit_controller/test_media_player.py | 59 +++++++++++++++++++ 4 files changed, 126 insertions(+), 3 deletions(-) create mode 100644 tests/components/homekit_controller/specific_devices/test_lg_tv.py diff --git a/homeassistant/components/homekit_controller/media_player.py b/homeassistant/components/homekit_controller/media_player.py index 5315c7c89f3..e3b4a760680 100644 --- a/homeassistant/components/homekit_controller/media_player.py +++ b/homeassistant/components/homekit_controller/media_player.py @@ -83,7 +83,7 @@ class HomeKitTelevision(HomeKitEntity, MediaPlayerEntity): @property def supported_features(self) -> MediaPlayerEntityFeature: """Flag media player features that are supported.""" - features = MediaPlayerEntityFeature(0) + features = MediaPlayerEntityFeature.TURN_OFF | MediaPlayerEntityFeature.TURN_ON if self.service.has(CharacteristicsTypes.ACTIVE_IDENTIFIER): features |= MediaPlayerEntityFeature.SELECT_SOURCE @@ -177,6 +177,14 @@ class HomeKitTelevision(HomeKitEntity, MediaPlayerEntity): return MediaPlayerState.ON + async def async_turn_on(self) -> None: + """Turn the tv on.""" + await self.async_put_characteristics({CharacteristicsTypes.ACTIVE: 1}) + + async def async_turn_off(self) -> None: + """Turn the tv off.""" + await self.async_put_characteristics({CharacteristicsTypes.ACTIVE: 0}) + async def async_media_play(self) -> None: """Send play command.""" if self.state == MediaPlayerState.PLAYING: diff --git a/tests/components/homekit_controller/snapshots/test_init.ambr b/tests/components/homekit_controller/snapshots/test_init.ambr index a41964d98cc..62b53df33f2 100644 --- a/tests/components/homekit_controller/snapshots/test_init.ambr +++ b/tests/components/homekit_controller/snapshots/test_init.ambr @@ -14352,7 +14352,7 @@ 'original_name': 'LG webOS TV AF80', 'platform': 'homekit_controller', 'previous_unique_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_48', 'unit_of_measurement': None, @@ -14371,7 +14371,7 @@ 'AV', 'HDMI 4', ]), - 'supported_features': , + 'supported_features': , }), 'entity_id': 'media_player.lg_webos_tv_af80', 'state': 'on', diff --git a/tests/components/homekit_controller/specific_devices/test_lg_tv.py b/tests/components/homekit_controller/specific_devices/test_lg_tv.py new file mode 100644 index 00000000000..48d1fc3ebdc --- /dev/null +++ b/tests/components/homekit_controller/specific_devices/test_lg_tv.py @@ -0,0 +1,56 @@ +"""Test against characteristics captured from an LG TV.""" + +from homeassistant.components.media_player import ( + ATTR_INPUT_SOURCE_LIST, + MediaPlayerEntityFeature, +) +from homeassistant.const import ATTR_SUPPORTED_FEATURES, STATE_ON +from homeassistant.core import HomeAssistant + +from ..common import ( + HUB_TEST_ACCESSORY_ID, + DeviceTestInfo, + assert_devices_and_entities_created, + setup_accessories_from_file, + setup_test_accessories, +) + + +async def test_lg_tv_setup(hass: HomeAssistant) -> None: + """Test that a LG TV can be correctly setup in HA.""" + accessories = await setup_accessories_from_file(hass, "lg_tv.json") + await setup_test_accessories(hass, accessories) + + await assert_devices_and_entities_created( + hass, + DeviceTestInfo( + unique_id=HUB_TEST_ACCESSORY_ID, + name="LG webOS TV AF80", + model="OLED55B9PUA", + manufacturer="LG Electronics", + sw_version="04.71.04", + hw_version="1", + serial_number="A0000A000000000A", + devices=[], + entities=[], + ), + ) + + state = hass.states.get("media_player.lg_webos_tv_af80") + assert state is not None + assert state.state == STATE_ON + assert state.attributes[ATTR_INPUT_SOURCE_LIST] == [ + "AirPlay", + "Live TV", + "HDMI 1", + "Sony", + "Apple", + "AV", + "HDMI 4", + ] + features = state.attributes[ATTR_SUPPORTED_FEATURES] + assert features & MediaPlayerEntityFeature.TURN_ON + assert features & MediaPlayerEntityFeature.TURN_OFF + assert features & MediaPlayerEntityFeature.SELECT_SOURCE + assert features & MediaPlayerEntityFeature.PLAY + assert features & MediaPlayerEntityFeature.PAUSE diff --git a/tests/components/homekit_controller/test_media_player.py b/tests/components/homekit_controller/test_media_player.py index d1d280ef265..e00dde92a81 100644 --- a/tests/components/homekit_controller/test_media_player.py +++ b/tests/components/homekit_controller/test_media_player.py @@ -10,6 +10,11 @@ from aiohomekit.model.characteristics import ( from aiohomekit.model.services import Service, ServicesTypes import pytest +from homeassistant.components.media_player import ( + DOMAIN as MEDIA_PLAYER_DOMAIN, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -408,3 +413,57 @@ async def test_migrate_unique_id( entity_registry.async_get(media_player_entry.entity_id).unique_id == f"00:00:00:00:00:00_{aid}_8" ) + + +async def test_turn_on(hass: HomeAssistant, get_next_aid: Callable[[], int]) -> None: + """Test that we can turn on a media player.""" + helper = await setup_test_component( + hass, get_next_aid(), create_tv_service_with_target_media_state + ) + + await helper.async_update( + ServicesTypes.TELEVISION, + { + CharacteristicsTypes.CURRENT_MEDIA_STATE: 0, + }, + ) + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_TURN_ON, + {"entity_id": "media_player.testdevice"}, + blocking=True, + ) + helper.async_assert_service_values( + ServicesTypes.TELEVISION, + { + CharacteristicsTypes.ACTIVE: 1, + }, + ) + + +async def test_turn_off(hass: HomeAssistant, get_next_aid: Callable[[], int]) -> None: + """Test that we can turn off a media player.""" + helper = await setup_test_component( + hass, get_next_aid(), create_tv_service_with_target_media_state + ) + + await helper.async_update( + ServicesTypes.TELEVISION, + { + CharacteristicsTypes.CURRENT_MEDIA_STATE: 0, + }, + ) + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_TURN_OFF, + {"entity_id": "media_player.testdevice"}, + blocking=True, + ) + helper.async_assert_service_values( + ServicesTypes.TELEVISION, + { + CharacteristicsTypes.ACTIVE: 0, + }, + ) From d9cf2750d5b48113a7d063856f498698f4df43f7 Mon Sep 17 00:00:00 2001 From: Ivan Lopez Hernandez Date: Wed, 19 Mar 2025 22:58:19 -0700 Subject: [PATCH 2765/3148] Ensure file is correctly uploaded by the GenAI SDK (#140969) Opened the file outside of the SDK --- .../google_generative_ai_conversation/__init__.py | 8 +++++++- .../google_generative_ai_conversation/test_init.py | 4 +++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/google_generative_ai_conversation/__init__.py b/homeassistant/components/google_generative_ai_conversation/__init__.py index 6b10565e0b5..c32d7b5ddea 100644 --- a/homeassistant/components/google_generative_ai_conversation/__init__.py +++ b/homeassistant/components/google_generative_ai_conversation/__init__.py @@ -2,6 +2,7 @@ from __future__ import annotations +import mimetypes from pathlib import Path from google import genai # type: ignore[attr-defined] @@ -83,7 +84,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ) if not Path(filename).exists(): raise HomeAssistantError(f"`{filename}` does not exist") - prompt_parts.append(client.files.upload(file=filename)) + mimetype = mimetypes.guess_type(filename)[0] + with open(filename, "rb") as file: + uploaded_file = client.files.upload( + file=file, config={"mime_type": mimetype} + ) + prompt_parts.append(uploaded_file) await hass.async_add_executor_job(append_files_to_prompt) diff --git a/tests/components/google_generative_ai_conversation/test_init.py b/tests/components/google_generative_ai_conversation/test_init.py index 25533ffd46e..a08acc0df3f 100644 --- a/tests/components/google_generative_ai_conversation/test_init.py +++ b/tests/components/google_generative_ai_conversation/test_init.py @@ -1,6 +1,6 @@ """Tests for the Google Generative AI Conversation integration.""" -from unittest.mock import AsyncMock, Mock, patch +from unittest.mock import AsyncMock, Mock, mock_open, patch import pytest from requests.exceptions import Timeout @@ -71,6 +71,8 @@ async def test_generate_content_service_with_image( ), patch("pathlib.Path.exists", return_value=True), patch.object(hass.config, "is_allowed_path", return_value=True), + patch("builtins.open", mock_open(read_data="this is an image")), + patch("mimetypes.guess_type", return_value=["image/jpeg"]), ): response = await hass.services.async_call( "google_generative_ai_conversation", From 9f68ac575dbf4e46e003bea8b5128d095a81d2e0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 20 Mar 2025 09:20:33 +0100 Subject: [PATCH 2766/3148] Bump actions/upload-artifact from 4.6.1 to 4.6.2 (#140976) Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4.6.1 to 4.6.2. - [Release notes](https://github.com/actions/upload-artifact/releases) - [Commits](https://github.com/actions/upload-artifact/compare/v4.6.1...v4.6.2) --- updated-dependencies: - dependency-name: actions/upload-artifact dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/builder.yml | 2 +- .github/workflows/ci.yaml | 30 +++++++++++++++--------------- .github/workflows/wheels.yml | 8 ++++---- 3 files changed, 20 insertions(+), 20 deletions(-) diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index 0aac66c2747..44dea4dc6ec 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -69,7 +69,7 @@ jobs: run: find ./homeassistant/components/*/translations -name "*.json" | tar zcvf translations.tar.gz -T - - name: Upload translations - uses: actions/upload-artifact@v4.6.1 + uses: actions/upload-artifact@v4.6.2 with: name: translations path: translations.tar.gz diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 4d8849abfda..584c9f10e42 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -552,7 +552,7 @@ jobs: python --version uv pip freeze >> pip_freeze.txt - name: Upload pip_freeze artifact - uses: actions/upload-artifact@v4.6.1 + uses: actions/upload-artifact@v4.6.2 with: name: pip-freeze-${{ matrix.python-version }} path: pip_freeze.txt @@ -695,7 +695,7 @@ jobs: . venv/bin/activate python -m script.licenses extract --output-file=licenses-${{ matrix.python-version }}.json - name: Upload licenses - uses: actions/upload-artifact@v4.6.1 + uses: actions/upload-artifact@v4.6.2 with: name: licenses-${{ github.run_number }}-${{ matrix.python-version }} path: licenses-${{ matrix.python-version }}.json @@ -907,7 +907,7 @@ jobs: . venv/bin/activate python -m script.split_tests ${{ needs.info.outputs.test_group_count }} tests - name: Upload pytest_buckets - uses: actions/upload-artifact@v4.6.1 + uses: actions/upload-artifact@v4.6.2 with: name: pytest_buckets path: pytest_buckets.txt @@ -1007,21 +1007,21 @@ jobs: 2>&1 | tee pytest-${{ matrix.python-version }}-${{ matrix.group }}.txt - name: Upload pytest output if: success() || failure() && steps.pytest-full.conclusion == 'failure' - uses: actions/upload-artifact@v4.6.1 + uses: actions/upload-artifact@v4.6.2 with: name: pytest-${{ github.run_number }}-${{ matrix.python-version }}-${{ matrix.group }} path: pytest-*.txt overwrite: true - name: Upload coverage artifact if: needs.info.outputs.skip_coverage != 'true' - uses: actions/upload-artifact@v4.6.1 + uses: actions/upload-artifact@v4.6.2 with: name: coverage-${{ matrix.python-version }}-${{ matrix.group }} path: coverage.xml overwrite: true - name: Upload test results artifact if: needs.info.outputs.skip_coverage != 'true' && !cancelled() - uses: actions/upload-artifact@v4.6.1 + uses: actions/upload-artifact@v4.6.2 with: name: test-results-full-${{ matrix.python-version }}-${{ matrix.group }} path: junit.xml @@ -1138,7 +1138,7 @@ jobs: 2>&1 | tee pytest-${{ matrix.python-version }}-${mariadb}.txt - name: Upload pytest output if: success() || failure() && steps.pytest-partial.conclusion == 'failure' - uses: actions/upload-artifact@v4.6.1 + uses: actions/upload-artifact@v4.6.2 with: name: pytest-${{ github.run_number }}-${{ matrix.python-version }}-${{ steps.pytest-partial.outputs.mariadb }} @@ -1146,7 +1146,7 @@ jobs: overwrite: true - name: Upload coverage artifact if: needs.info.outputs.skip_coverage != 'true' - uses: actions/upload-artifact@v4.6.1 + uses: actions/upload-artifact@v4.6.2 with: name: coverage-${{ matrix.python-version }}-${{ steps.pytest-partial.outputs.mariadb }} @@ -1154,7 +1154,7 @@ jobs: overwrite: true - name: Upload test results artifact if: needs.info.outputs.skip_coverage != 'true' && !cancelled() - uses: actions/upload-artifact@v4.6.1 + uses: actions/upload-artifact@v4.6.2 with: name: test-results-mariadb-${{ matrix.python-version }}-${{ steps.pytest-partial.outputs.mariadb }} @@ -1273,7 +1273,7 @@ jobs: 2>&1 | tee pytest-${{ matrix.python-version }}-${postgresql}.txt - name: Upload pytest output if: success() || failure() && steps.pytest-partial.conclusion == 'failure' - uses: actions/upload-artifact@v4.6.1 + uses: actions/upload-artifact@v4.6.2 with: name: pytest-${{ github.run_number }}-${{ matrix.python-version }}-${{ steps.pytest-partial.outputs.postgresql }} @@ -1281,7 +1281,7 @@ jobs: overwrite: true - name: Upload coverage artifact if: needs.info.outputs.skip_coverage != 'true' - uses: actions/upload-artifact@v4.6.1 + uses: actions/upload-artifact@v4.6.2 with: name: coverage-${{ matrix.python-version }}-${{ steps.pytest-partial.outputs.postgresql }} @@ -1289,7 +1289,7 @@ jobs: overwrite: true - name: Upload test results artifact if: needs.info.outputs.skip_coverage != 'true' && !cancelled() - uses: actions/upload-artifact@v4.6.1 + uses: actions/upload-artifact@v4.6.2 with: name: test-results-postgres-${{ matrix.python-version }}-${{ steps.pytest-partial.outputs.postgresql }} @@ -1420,21 +1420,21 @@ jobs: 2>&1 | tee pytest-${{ matrix.python-version }}-${{ matrix.group }}.txt - name: Upload pytest output if: success() || failure() && steps.pytest-partial.conclusion == 'failure' - uses: actions/upload-artifact@v4.6.1 + uses: actions/upload-artifact@v4.6.2 with: name: pytest-${{ github.run_number }}-${{ matrix.python-version }}-${{ matrix.group }} path: pytest-*.txt overwrite: true - name: Upload coverage artifact if: needs.info.outputs.skip_coverage != 'true' - uses: actions/upload-artifact@v4.6.1 + uses: actions/upload-artifact@v4.6.2 with: name: coverage-${{ matrix.python-version }}-${{ matrix.group }} path: coverage.xml overwrite: true - name: Upload test results artifact if: needs.info.outputs.skip_coverage != 'true' && !cancelled() - uses: actions/upload-artifact@v4.6.1 + uses: actions/upload-artifact@v4.6.2 with: name: test-results-partial-${{ matrix.python-version }}-${{ matrix.group }} path: junit.xml diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 4baddd3a80f..3c3af223c25 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -91,7 +91,7 @@ jobs: ) > build_constraints.txt - name: Upload env_file - uses: actions/upload-artifact@v4.6.1 + uses: actions/upload-artifact@v4.6.2 with: name: env_file path: ./.env_file @@ -99,14 +99,14 @@ jobs: overwrite: true - name: Upload build_constraints - uses: actions/upload-artifact@v4.6.1 + uses: actions/upload-artifact@v4.6.2 with: name: build_constraints path: ./build_constraints.txt overwrite: true - name: Upload requirements_diff - uses: actions/upload-artifact@v4.6.1 + uses: actions/upload-artifact@v4.6.2 with: name: requirements_diff path: ./requirements_diff.txt @@ -118,7 +118,7 @@ jobs: python -m script.gen_requirements_all ci - name: Upload requirements_all_wheels - uses: actions/upload-artifact@v4.6.1 + uses: actions/upload-artifact@v4.6.2 with: name: requirements_all_wheels path: ./requirements_all_wheels_*.txt From 006dde435e5e270f2893e7897c0630159cd0cdd8 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 20 Mar 2025 09:26:39 +0100 Subject: [PATCH 2767/3148] Clarify descriptions of `lcn.address_to_device_id` action (#140979) Clarify descriptions of `lcn.address_to_device` action Changes the wording of the action and field descriptions so there is less ambiguity for translations. --- homeassistant/components/lcn/strings.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/lcn/strings.json b/homeassistant/components/lcn/strings.json index 0bdd85a3678..0a8112d997a 100644 --- a/homeassistant/components/lcn/strings.json +++ b/homeassistant/components/lcn/strings.json @@ -396,19 +396,19 @@ }, "address_to_device_id": { "name": "Address to device ID", - "description": "Convert LCN address to device ID.", + "description": "Converts an LCN address into a device ID.", "fields": { "id": { "name": "Module or group ID", - "description": "Target module or group ID." + "description": "Module or group number of the target." }, "segment_id": { "name": "Segment ID", - "description": "Target segment ID." + "description": "Segment number of the target." }, "type": { "name": "Type", - "description": "Target type." + "description": "Module type of the target." }, "host": { "name": "Host name", From 03bd8cd251cbb15032c37c69eba8bf228f07b4b6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 20 Mar 2025 09:30:36 +0100 Subject: [PATCH 2768/3148] Bump github/codeql-action from 3.28.11 to 3.28.12 (#140975) Bumps [github/codeql-action](https://github.com/github/codeql-action) from 3.28.11 to 3.28.12. - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/v3.28.11...v3.28.12) --- updated-dependencies: - dependency-name: github/codeql-action dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] 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 c4f98f2d863..f4d4144243c 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -24,11 +24,11 @@ jobs: uses: actions/checkout@v4.2.2 - name: Initialize CodeQL - uses: github/codeql-action/init@v3.28.11 + uses: github/codeql-action/init@v3.28.12 with: languages: python - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3.28.11 + uses: github/codeql-action/analyze@v3.28.12 with: category: "/language:python" From adf3e4fccad1e935b4cbb652c364415e6ce85d88 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 20 Mar 2025 09:30:59 +0100 Subject: [PATCH 2769/3148] Bump actions/download-artifact from 4.2.0 to 4.2.1 (#140974) Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 4.2.0 to 4.2.1. - [Release notes](https://github.com/actions/download-artifact/releases) - [Commits](https://github.com/actions/download-artifact/compare/v4.2.0...v4.2.1) --- updated-dependencies: - dependency-name: actions/download-artifact dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/builder.yml | 4 ++-- .github/workflows/ci.yaml | 8 ++++---- .github/workflows/wheels.yml | 14 +++++++------- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index 44dea4dc6ec..03c38c60a10 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -175,7 +175,7 @@ jobs: sed -i "s|pykrakenapi|# pykrakenapi|g" requirements_all.txt - name: Download translations - uses: actions/download-artifact@v4.2.0 + uses: actions/download-artifact@v4.2.1 with: name: translations @@ -462,7 +462,7 @@ jobs: python-version: ${{ env.DEFAULT_PYTHON }} - name: Download translations - uses: actions/download-artifact@v4.2.0 + uses: actions/download-artifact@v4.2.1 with: name: translations diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 584c9f10e42..d0b5923b1fc 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -968,7 +968,7 @@ jobs: run: | echo "::add-matcher::.github/workflows/matchers/pytest-slow.json" - name: Download pytest_buckets - uses: actions/download-artifact@v4.2.0 + uses: actions/download-artifact@v4.2.1 with: name: pytest_buckets - name: Compile English translations @@ -1312,7 +1312,7 @@ jobs: - name: Check out code from GitHub uses: actions/checkout@v4.2.2 - name: Download all coverage artifacts - uses: actions/download-artifact@v4.2.0 + uses: actions/download-artifact@v4.2.1 with: pattern: coverage-* - name: Upload coverage to Codecov @@ -1454,7 +1454,7 @@ jobs: - name: Check out code from GitHub uses: actions/checkout@v4.2.2 - name: Download all coverage artifacts - uses: actions/download-artifact@v4.2.0 + uses: actions/download-artifact@v4.2.1 with: pattern: coverage-* - name: Upload coverage to Codecov @@ -1479,7 +1479,7 @@ jobs: timeout-minutes: 10 steps: - name: Download all coverage artifacts - uses: actions/download-artifact@v4.2.0 + uses: actions/download-artifact@v4.2.1 with: pattern: test-results-* - name: Upload test results to Codecov diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 3c3af223c25..cdf0c07cccf 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -138,17 +138,17 @@ jobs: uses: actions/checkout@v4.2.2 - name: Download env_file - uses: actions/download-artifact@v4.2.0 + uses: actions/download-artifact@v4.2.1 with: name: env_file - name: Download build_constraints - uses: actions/download-artifact@v4.2.0 + uses: actions/download-artifact@v4.2.1 with: name: build_constraints - name: Download requirements_diff - uses: actions/download-artifact@v4.2.0 + uses: actions/download-artifact@v4.2.1 with: name: requirements_diff @@ -187,22 +187,22 @@ jobs: uses: actions/checkout@v4.2.2 - name: Download env_file - uses: actions/download-artifact@v4.2.0 + uses: actions/download-artifact@v4.2.1 with: name: env_file - name: Download build_constraints - uses: actions/download-artifact@v4.2.0 + uses: actions/download-artifact@v4.2.1 with: name: build_constraints - name: Download requirements_diff - uses: actions/download-artifact@v4.2.0 + uses: actions/download-artifact@v4.2.1 with: name: requirements_diff - name: Download requirements_all_wheels - uses: actions/download-artifact@v4.2.0 + uses: actions/download-artifact@v4.2.1 with: name: requirements_all_wheels From 2ec80fd1ca8e8909b2f31d218b740ab8381b1482 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Thu, 20 Mar 2025 09:39:28 +0100 Subject: [PATCH 2770/3148] Fix initial fetch of Home Connect appliance data to handle API rate limit errors (#139379) * Fix initial fetch of appliance data to handle API rate limit errors * Apply comments * Delete stale function * Handle api rate limit error at options fetching * Update appliances after stream non-breaking error * Always initialize coordinator data * Improve device update * Update test description Co-authored-by: Martin Hjelmare --------- Co-authored-by: Martin Hjelmare --- .../components/home_connect/__init__.py | 9 +- .../components/home_connect/common.py | 35 ------ .../components/home_connect/coordinator.py | 100 ++++++++++++++++-- .../home_connect/test_coordinator.py | 44 +++++++- tests/components/home_connect/test_init.py | 50 ++++++++- 5 files changed, 188 insertions(+), 50 deletions(-) diff --git a/homeassistant/components/home_connect/__init__.py b/homeassistant/components/home_connect/__init__.py index 6814ab3eed2..70b357518da 100644 --- a/homeassistant/components/home_connect/__init__.py +++ b/homeassistant/components/home_connect/__init__.py @@ -629,14 +629,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: HomeConnectConfigEntry) home_connect_client = HomeConnectClient(config_entry_auth) coordinator = HomeConnectCoordinator(hass, entry, home_connect_client) - await coordinator.async_config_entry_first_refresh() - + await coordinator.async_setup() entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) entry.runtime_data.start_event_listener() + entry.async_create_background_task( + hass, + coordinator.async_refresh(), + f"home_connect-initial-full-refresh-{entry.entry_id}", + ) + return True diff --git a/homeassistant/components/home_connect/common.py b/homeassistant/components/home_connect/common.py index f52b59bc213..cd3fefad80c 100644 --- a/homeassistant/components/home_connect/common.py +++ b/homeassistant/components/home_connect/common.py @@ -137,41 +137,6 @@ def setup_home_connect_entry( defaultdict(list) ) - entities: list[HomeConnectEntity] = [] - for appliance in entry.runtime_data.data.values(): - entities_to_add = get_entities_for_appliance(entry, appliance) - if get_option_entities_for_appliance: - entities_to_add.extend(get_option_entities_for_appliance(entry, appliance)) - for event_key in ( - EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM, - EventKey.BSH_COMMON_ROOT_SELECTED_PROGRAM, - ): - changed_options_listener_remove_callback = ( - entry.runtime_data.async_add_listener( - partial( - _create_option_entities, - entry, - appliance, - known_entity_unique_ids, - get_option_entities_for_appliance, - async_add_entities, - ), - (appliance.info.ha_id, event_key), - ) - ) - entry.async_on_unload(changed_options_listener_remove_callback) - changed_options_listener_remove_callbacks[appliance.info.ha_id].append( - changed_options_listener_remove_callback - ) - known_entity_unique_ids.update( - { - cast(str, entity.unique_id): appliance.info.ha_id - for entity in entities_to_add - } - ) - entities.extend(entities_to_add) - async_add_entities(entities) - entry.async_on_unload( entry.runtime_data.async_add_special_listener( partial( diff --git a/homeassistant/components/home_connect/coordinator.py b/homeassistant/components/home_connect/coordinator.py index e877dc7bfe4..495b4efab32 100644 --- a/homeassistant/components/home_connect/coordinator.py +++ b/homeassistant/components/home_connect/coordinator.py @@ -2,7 +2,7 @@ from __future__ import annotations -import asyncio +from asyncio import sleep as asyncio_sleep from collections import defaultdict from collections.abc import Callable from dataclasses import dataclass @@ -29,6 +29,7 @@ from aiohomeconnect.model.error import ( HomeConnectApiError, HomeConnectError, HomeConnectRequestError, + TooManyRequestsError, UnauthorizedError, ) from aiohomeconnect.model.program import EnumerateProgram, ProgramDefinitionOption @@ -36,11 +37,11 @@ from propcache.api import cached_property from homeassistant.config_entries import ConfigEntry from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback -from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import device_registry as dr from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import APPLIANCES_WITH_PROGRAMS, DOMAIN +from .const import API_DEFAULT_RETRY_AFTER, APPLIANCES_WITH_PROGRAMS, DOMAIN from .utils import get_dict_from_home_connect_error _LOGGER = logging.getLogger(__name__) @@ -154,7 +155,7 @@ class HomeConnectCoordinator( f"home_connect-events_listener_task-{self.config_entry.entry_id}", ) - async def _event_listener(self) -> None: + async def _event_listener(self) -> None: # noqa: C901 """Match event with listener for event type.""" retry_time = 10 while True: @@ -269,7 +270,7 @@ class HomeConnectCoordinator( error, retry_time, ) - await asyncio.sleep(retry_time) + await asyncio_sleep(retry_time) retry_time = min(retry_time * 2, 3600) except HomeConnectApiError as error: _LOGGER.error("Error while listening for events: %s", error) @@ -278,6 +279,13 @@ class HomeConnectCoordinator( ) break + # Trigger to delete the possible depaired device entities + # from known_entities variable at common.py + for listener, context in self._special_listeners.values(): + assert isinstance(context, tuple) + if EventKey.BSH_COMMON_APPLIANCE_DEPAIRED in context: + listener() + @callback def _call_event_listener(self, event_message: EventMessage) -> None: """Call listener for event.""" @@ -295,6 +303,42 @@ class HomeConnectCoordinator( async def _async_update_data(self) -> dict[str, HomeConnectApplianceData]: """Fetch data from Home Connect.""" + await self._async_setup() + + for appliance_data in self.data.values(): + appliance = appliance_data.info + ha_id = appliance.ha_id + while True: + try: + self.data[ha_id] = await self._get_appliance_data( + appliance, self.data.get(ha_id) + ) + except TooManyRequestsError as err: + _LOGGER.debug( + "Rate limit exceeded on initial fetch: %s", + err, + ) + await asyncio_sleep(err.retry_after or API_DEFAULT_RETRY_AFTER) + else: + break + + for listener, context in self._special_listeners.values(): + assert isinstance(context, tuple) + if EventKey.BSH_COMMON_APPLIANCE_PAIRED in context: + listener() + + return self.data + + async def async_setup(self) -> None: + """Set up the devices.""" + try: + await self._async_setup() + except UpdateFailed as err: + raise ConfigEntryNotReady from err + + async def _async_setup(self) -> None: + """Set up the devices.""" + old_appliances = set(self.data.keys()) try: appliances = await self.client.get_home_appliances() except UnauthorizedError as error: @@ -312,12 +356,38 @@ class HomeConnectCoordinator( translation_placeholders=get_dict_from_home_connect_error(error), ) from error - return { - appliance.ha_id: await self._get_appliance_data( - appliance, self.data.get(appliance.ha_id) + for appliance in appliances.homeappliances: + self.device_registry.async_get_or_create( + config_entry_id=self.config_entry.entry_id, + identifiers={(DOMAIN, appliance.ha_id)}, + manufacturer=appliance.brand, + name=appliance.name, + model=appliance.vib, ) - for appliance in appliances.homeappliances - } + if appliance.ha_id not in self.data: + self.data[appliance.ha_id] = HomeConnectApplianceData( + commands=set(), + events={}, + info=appliance, + options={}, + programs=[], + settings={}, + status={}, + ) + else: + self.data[appliance.ha_id].info.connected = appliance.connected + old_appliances.remove(appliance.ha_id) + + for ha_id in old_appliances: + self.data.pop(ha_id, None) + device = self.device_registry.async_get_device( + identifiers={(DOMAIN, ha_id)} + ) + if device: + self.device_registry.async_update_device( + device_id=device.id, + remove_config_entry_id=self.config_entry.entry_id, + ) async def _get_appliance_data( self, @@ -339,6 +409,8 @@ class HomeConnectCoordinator( await self.client.get_settings(appliance.ha_id) ).settings } + except TooManyRequestsError: + raise except HomeConnectError as error: _LOGGER.debug( "Error fetching settings for %s: %s", @@ -351,6 +423,8 @@ class HomeConnectCoordinator( status.key: status for status in (await self.client.get_status(appliance.ha_id)).status } + except TooManyRequestsError: + raise except HomeConnectError as error: _LOGGER.debug( "Error fetching status for %s: %s", @@ -365,6 +439,8 @@ class HomeConnectCoordinator( if appliance.type in APPLIANCES_WITH_PROGRAMS: try: all_programs = await self.client.get_all_programs(appliance.ha_id) + except TooManyRequestsError: + raise except HomeConnectError as error: _LOGGER.debug( "Error fetching programs for %s: %s", @@ -421,6 +497,8 @@ class HomeConnectCoordinator( await self.client.get_available_commands(appliance.ha_id) ).commands } + except TooManyRequestsError: + raise except HomeConnectError: commands = set() @@ -455,6 +533,8 @@ class HomeConnectCoordinator( ).options or [] } + except TooManyRequestsError: + raise except HomeConnectError as error: _LOGGER.debug( "Error fetching options for %s: %s", diff --git a/tests/components/home_connect/test_coordinator.py b/tests/components/home_connect/test_coordinator.py index 1e584335fcd..84bef94d658 100644 --- a/tests/components/home_connect/test_coordinator.py +++ b/tests/components/home_connect/test_coordinator.py @@ -29,6 +29,7 @@ from homeassistant.components.home_connect.const import ( BSH_DOOR_STATE_OPEN, BSH_EVENT_PRESENT_STATE_PRESENT, BSH_POWER_OFF, + DOMAIN, ) from homeassistant.config_entries import ConfigEntries, ConfigEntryState from homeassistant.const import EVENT_STATE_REPORTED, Platform @@ -38,7 +39,7 @@ from homeassistant.core import ( HomeAssistant, callback, ) -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -499,3 +500,44 @@ async def test_event_listener_resilience( state = hass.states.get(entity_id) assert state assert state.state == after_event_expected_state + + +async def test_devices_updated_on_refresh( + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + device_registry: dr.DeviceRegistry, +) -> None: + """Test handling of devices added or deleted while event stream is down.""" + appliances: list[HomeAppliance] = ( + client.get_home_appliances.return_value.homeappliances + ) + assert len(appliances) >= 3 + client.get_home_appliances = AsyncMock( + return_value=ArrayOfHomeAppliances(appliances[:2]), + ) + + await async_setup_component(hass, "homeassistant", {}) + assert config_entry.state == ConfigEntryState.NOT_LOADED + await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + for appliance in appliances[:2]: + assert device_registry.async_get_device({(DOMAIN, appliance.ha_id)}) + assert not device_registry.async_get_device({(DOMAIN, appliances[2].ha_id)}) + + client.get_home_appliances = AsyncMock( + return_value=ArrayOfHomeAppliances(appliances[1:3]), + ) + await hass.services.async_call( + "homeassistant", + "update_entity", + {"entity_id": "switch.dishwasher_power"}, + blocking=True, + ) + + assert not device_registry.async_get_device({(DOMAIN, appliances[0].ha_id)}) + for appliance in appliances[2:3]: + assert device_registry.async_get_device({(DOMAIN, appliance.ha_id)}) diff --git a/tests/components/home_connect/test_init.py b/tests/components/home_connect/test_init.py index 4287ac9d227..291caeafd58 100644 --- a/tests/components/home_connect/test_init.py +++ b/tests/components/home_connect/test_init.py @@ -3,11 +3,15 @@ from collections.abc import Awaitable, Callable from http import HTTPStatus from typing import Any -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, patch from aiohomeconnect.const import OAUTH2_TOKEN from aiohomeconnect.model import OptionKey, ProgramKey, SettingKey, StatusKey -from aiohomeconnect.model.error import HomeConnectError, UnauthorizedError +from aiohomeconnect.model.error import ( + HomeConnectError, + TooManyRequestsError, + UnauthorizedError, +) import aiohttp import pytest from syrupy.assertion import SnapshotAssertion @@ -355,6 +359,48 @@ async def test_client_error( assert client_with_exception.get_home_appliances.call_count == 1 +@pytest.mark.parametrize( + "raising_exception_method", + [ + "get_settings", + "get_status", + "get_all_programs", + "get_available_commands", + "get_available_program", + ], +) +async def test_client_rate_limit_error( + raising_exception_method: str, + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, +) -> None: + """Test client errors during setup integration.""" + retry_after = 42 + + original_mock = getattr(client, raising_exception_method) + mock = AsyncMock() + + async def side_effect(*args, **kwargs): + if mock.call_count <= 1: + raise TooManyRequestsError("error.key", retry_after=retry_after) + return await original_mock(*args, **kwargs) + + mock.side_effect = side_effect + setattr(client, raising_exception_method, mock) + + assert config_entry.state == ConfigEntryState.NOT_LOADED + with patch( + "homeassistant.components.home_connect.coordinator.asyncio_sleep", + ) as asyncio_sleep_mock: + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + assert mock.call_count >= 2 + asyncio_sleep_mock.assert_called_once_with(retry_after) + + @pytest.mark.parametrize( "service_call", SERVICE_KV_CALL_PARAMS + SERVICE_COMMAND_CALL_PARAMS + SERVICE_PROGRAM_CALL_PARAMS, From 32f9c07254c535ed6d658d02d32ddd3ee998eff7 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Thu, 20 Mar 2025 09:47:02 +0100 Subject: [PATCH 2771/3148] Add missing exception translation in Vodafone Station (#140951) * Add missing exception translation in Vodafone Station * strings --- homeassistant/components/vodafone_station/coordinator.py | 6 +++++- .../components/vodafone_station/quality_scale.yaml | 4 +--- homeassistant/components/vodafone_station/strings.json | 3 +++ 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/vodafone_station/coordinator.py b/homeassistant/components/vodafone_station/coordinator.py index 55643cd2778..cee66bd2e7c 100644 --- a/homeassistant/components/vodafone_station/coordinator.py +++ b/homeassistant/components/vodafone_station/coordinator.py @@ -122,7 +122,11 @@ class VodafoneStationRouter(DataUpdateCoordinator[UpdateCoordinatorDataType]): data_sensors = await self.api.get_sensor_data() await self.api.logout() except exceptions.CannotAuthenticate as err: - raise ConfigEntryAuthFailed from err + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="cannot_authenticate", + translation_placeholders={"error": repr(err)}, + ) from err except ( exceptions.CannotConnect, exceptions.AlreadyLogged, diff --git a/homeassistant/components/vodafone_station/quality_scale.yaml b/homeassistant/components/vodafone_station/quality_scale.yaml index d9240afc2e7..f9fa27b3032 100644 --- a/homeassistant/components/vodafone_station/quality_scale.yaml +++ b/homeassistant/components/vodafone_station/quality_scale.yaml @@ -70,9 +70,7 @@ rules: status: exempt comment: no known use case entity-translations: done - exception-translations: - status: todo - comment: some missing in coordinator + exception-translations: done icon-translations: done reconfiguration-flow: status: todo diff --git a/homeassistant/components/vodafone_station/strings.json b/homeassistant/components/vodafone_station/strings.json index 7d804d9ac3b..de4bc364d4b 100644 --- a/homeassistant/components/vodafone_station/strings.json +++ b/homeassistant/components/vodafone_station/strings.json @@ -115,6 +115,9 @@ "exceptions": { "update_failed": { "message": "Error fetching data: {error}" + }, + "cannot_authenticate": { + "message": "Error authenticating: {error}" } } } From 2674b02bfa4296d0ed6a19d0780197d8f71a9743 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Thu, 20 Mar 2025 10:16:48 +0100 Subject: [PATCH 2772/3148] Refactor zwave_js config entry setup (#107635) * Refactor zwave_js config entry setup * Fix blocking update test * Address timeout comment * Remove platform tasks * Replace deprecated async_add_job * Use ConfigEntry.async_on_state_change * Use modern config entry methods * Clarify exception message * Test listen error after config entry setup * Test listen failure during setup after forward entry * Test not reloading when hass is stopping * Test client disconnect is called on entry unload * Fix and test client not connected during driver setup * Fix and test driver ready timeout * Stringify listen task exception when logging * Use identity compare * Guard for closed connection * Consolidate listen task checking and tests --- homeassistant/components/zwave_js/__init__.py | 228 ++++++++++-------- .../components/zwave_js/config_flow.py | 3 +- tests/components/zwave_js/conftest.py | 25 +- tests/components/zwave_js/test_init.py | 224 ++++++++++++++++- tests/components/zwave_js/test_update.py | 11 +- 5 files changed, 362 insertions(+), 129 deletions(-) diff --git a/homeassistant/components/zwave_js/__init__.py b/homeassistant/components/zwave_js/__init__.py index c8503b1f4c6..a7b8f9ed665 100644 --- a/homeassistant/components/zwave_js/__init__.py +++ b/homeassistant/components/zwave_js/__init__.py @@ -4,7 +4,7 @@ from __future__ import annotations import asyncio from collections import defaultdict -from contextlib import suppress +import contextlib import logging from typing import Any @@ -12,7 +12,11 @@ from awesomeversion import AwesomeVersion import voluptuous as vol from zwave_js_server.client import Client as ZwaveClient from zwave_js_server.const import CommandClass, RemoveNodeReason -from zwave_js_server.exceptions import BaseZwaveJSServerError, InvalidServerVersion +from zwave_js_server.exceptions import ( + BaseZwaveJSServerError, + InvalidServerVersion, + NotConnected, +) from zwave_js_server.model.driver import Driver from zwave_js_server.model.node import Node as ZwaveNode from zwave_js_server.model.notification import ( @@ -25,7 +29,7 @@ from zwave_js_server.model.value import Value, ValueNotification from homeassistant.components.hassio import AddonError, AddonManager, AddonState from homeassistant.components.persistent_notification import async_create -from homeassistant.config_entries import ConfigEntry +from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import ( ATTR_DEVICE_ID, ATTR_DOMAIN, @@ -36,7 +40,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import Event, HomeAssistant, callback -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError from homeassistant.helpers import ( config_validation as cv, device_registry as dr, @@ -130,9 +134,8 @@ from .migrate import async_migrate_discovered_value from .services import ZWaveServices CONNECT_TIMEOUT = 10 -DATA_CLIENT_LISTEN_TASK = "client_listen_task" DATA_DRIVER_EVENTS = "driver_events" -DATA_START_CLIENT_TASK = "start_client_task" +DRIVER_READY_TIMEOUT = 60 CONFIG_SCHEMA = vol.Schema( { @@ -145,6 +148,24 @@ CONFIG_SCHEMA = vol.Schema( extra=vol.ALLOW_EXTRA, ) +PLATFORMS = [ + Platform.BINARY_SENSOR, + Platform.BUTTON, + Platform.CLIMATE, + Platform.COVER, + Platform.EVENT, + Platform.FAN, + Platform.HUMIDIFIER, + Platform.LIGHT, + Platform.LOCK, + Platform.NUMBER, + Platform.SELECT, + Platform.SENSOR, + Platform.SIREN, + Platform.SWITCH, + Platform.UPDATE, +] + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Z-Wave JS component.""" @@ -196,53 +217,99 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: raise ConfigEntryNotReady(f"Failed to connect: {err}") from err async_delete_issue(hass, DOMAIN, "invalid_server_version") - LOGGER.info("Connected to Zwave JS Server") + LOGGER.debug("Connected to Zwave JS Server") # Set up websocket API async_register_api(hass) - entry.runtime_data = {} - # Create a task to allow the config entry to be unloaded before the driver is ready. - # Unloading the config entry is needed if the client listen task errors. - start_client_task = hass.async_create_task(start_client(hass, entry, client)) - entry.runtime_data[DATA_START_CLIENT_TASK] = start_client_task + driver_ready = asyncio.Event() + listen_task = entry.async_create_background_task( + hass, + client_listen(hass, entry, client, driver_ready), + f"{DOMAIN}_{entry.title}_client_listen", + ) - return True - - -async def start_client( - hass: HomeAssistant, entry: ConfigEntry, client: ZwaveClient -) -> None: - """Start listening with the client.""" - entry.runtime_data[DATA_CLIENT] = client - driver_events = entry.runtime_data[DATA_DRIVER_EVENTS] = DriverEvents(hass, entry) + entry.async_on_unload(client.disconnect) async def handle_ha_shutdown(event: Event) -> None: """Handle HA shutdown.""" - await disconnect_client(hass, entry) + await client.disconnect() - listen_task = asyncio.create_task( - client_listen(hass, entry, client, driver_events.ready) - ) - entry.runtime_data[DATA_CLIENT_LISTEN_TASK] = listen_task entry.async_on_unload( hass.bus.async_listen(EVENT_HOMEASSISTANT_STOP, handle_ha_shutdown) ) - try: - await driver_events.ready.wait() - except asyncio.CancelledError: - LOGGER.debug("Cancelling start client") - return - - LOGGER.info("Connection to Zwave JS Server initialized") - - assert client.driver - async_dispatcher_send( - hass, f"{DOMAIN}_{client.driver.controller.home_id}_connected_to_server" + driver_ready_task = entry.async_create_task( + hass, + driver_ready.wait(), + f"{DOMAIN}_{entry.title}_driver_ready", + ) + done, pending = await asyncio.wait( + (driver_ready_task, listen_task), + return_when=asyncio.FIRST_COMPLETED, + timeout=DRIVER_READY_TIMEOUT, ) - await driver_events.setup(client.driver) + if driver_ready_task in pending or listen_task in done: + error_message = "Driver ready timed out" + listen_error: BaseException | None = None + if listen_task.done(): + listen_error, error_message = _get_listen_task_error(listen_task) + else: + listen_task.cancel() + driver_ready_task.cancel() + raise ConfigEntryNotReady(error_message) from listen_error + + LOGGER.debug("Connection to Zwave JS Server initialized") + + entry_runtime_data = entry.runtime_data = { + DATA_CLIENT: client, + } + entry_runtime_data[DATA_DRIVER_EVENTS] = driver_events = DriverEvents(hass, entry) + + driver = client.driver + # When the driver is ready we know it's set on the client. + assert driver is not None + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + with contextlib.suppress(NotConnected): + # If the client isn't connected the listen task may have an exception + # and we'll handle the clean up below. + await driver_events.setup(driver) + + # If the listen task is already failed, we need to raise ConfigEntryNotReady + if listen_task.done(): + listen_error, error_message = _get_listen_task_error(listen_task) + await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + raise ConfigEntryNotReady(error_message) from listen_error + + # Re-attach trigger listeners. + # Schedule this call to make sure the config entry is loaded first. + + @callback + def on_config_entry_loaded() -> None: + """Signal that server connection and driver are ready.""" + if entry.state is ConfigEntryState.LOADED: + async_dispatcher_send( + hass, + f"{DOMAIN}_{driver.controller.home_id}_connected_to_server", + ) + + entry.async_on_unload(entry.async_on_state_change(on_config_entry_loaded)) + + return True + + +def _get_listen_task_error( + listen_task: asyncio.Task, +) -> tuple[BaseException | None, str]: + """Check the listen task for errors.""" + if listen_error := listen_task.exception(): + error_message = f"Client listen failed: {listen_error}" + else: + error_message = "Client connection was closed" + return listen_error, error_message class DriverEvents: @@ -255,8 +322,6 @@ class DriverEvents: self.config_entry = entry self.dev_reg = dr.async_get(hass) self.hass = hass - self.platform_setup_tasks: dict[str, asyncio.Task] = {} - self.ready = asyncio.Event() # Make sure to not pass self to ControllerEvents until all attributes are set. self.controller_events = ControllerEvents(hass, self) @@ -339,16 +404,6 @@ class DriverEvents: controller.on("identify", self.controller_events.async_on_identify) ) - async def async_setup_platform(self, platform: Platform) -> None: - """Set up platform if needed.""" - if platform not in self.platform_setup_tasks: - self.platform_setup_tasks[platform] = self.hass.async_create_task( - self.hass.config_entries.async_forward_entry_setups( - self.config_entry, [platform] - ) - ) - await self.platform_setup_tasks[platform] - class ControllerEvents: """Represent controller events. @@ -380,9 +435,6 @@ class ControllerEvents: async def async_on_node_added(self, node: ZwaveNode) -> None: """Handle node added event.""" - # Every node including the controller will have at least one sensor - await self.driver_events.async_setup_platform(Platform.SENSOR) - # Remove stale entities that may exist from a previous interview when an # interview is started. base_unique_id = get_valueless_base_unique_id(self.driver_events.driver, node) @@ -411,7 +463,6 @@ class ControllerEvents: ) # Create a ping button for each device - await self.driver_events.async_setup_platform(Platform.BUTTON) async_dispatcher_send( self.hass, f"{DOMAIN}_{self.config_entry.entry_id}_add_ping_button_entity", @@ -668,9 +719,6 @@ class NodeEvents: cc.id == CommandClass.FIRMWARE_UPDATE_MD.value for cc in node.command_classes ): - await self.controller_events.driver_events.async_setup_platform( - Platform.UPDATE - ) async_dispatcher_send( self.hass, f"{DOMAIN}_{self.config_entry.entry_id}_add_firmware_update_entity", @@ -701,21 +749,19 @@ class NodeEvents: value_updates_disc_info: dict[str, ZwaveDiscoveryInfo], ) -> None: """Handle discovery info and all dependent tasks.""" + platform = disc_info.platform # This migration logic was added in 2021.3 to handle a breaking change to # the value_id format. Some time in the future, this call (as well as the # helper functions) can be removed. async_migrate_discovered_value( self.hass, self.ent_reg, - self.controller_events.registered_unique_ids[device.id][disc_info.platform], + self.controller_events.registered_unique_ids[device.id][platform], device, self.controller_events.driver_events.driver, disc_info, ) - platform = disc_info.platform - await self.controller_events.driver_events.async_setup_platform(platform) - LOGGER.debug("Discovered entity: %s", disc_info) async_dispatcher_send( self.hass, @@ -930,63 +976,37 @@ async def client_listen( driver_ready: asyncio.Event, ) -> None: """Listen with the client.""" - should_reload = True try: await client.listen(driver_ready) - except asyncio.CancelledError: - should_reload = False except BaseZwaveJSServerError as err: - LOGGER.error("Failed to listen: %s", err) - except Exception as err: # noqa: BLE001 + if entry.state is not ConfigEntryState.LOADED: + raise + LOGGER.error("Client listen failed: %s", err) + except Exception as err: # We need to guard against unknown exceptions to not crash this task. LOGGER.exception("Unexpected exception: %s", err) + if entry.state is not ConfigEntryState.LOADED: + raise # The entry needs to be reloaded since a new driver state # will be acquired on reconnect. # All model instances will be replaced when the new state is acquired. - if should_reload: - LOGGER.info("Disconnected from server. Reloading integration") - hass.async_create_task(hass.config_entries.async_reload(entry.entry_id)) - - -async def disconnect_client(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Disconnect client.""" - client: ZwaveClient = entry.runtime_data[DATA_CLIENT] - listen_task: asyncio.Task = entry.runtime_data[DATA_CLIENT_LISTEN_TASK] - start_client_task: asyncio.Task = entry.runtime_data[DATA_START_CLIENT_TASK] - driver_events: DriverEvents = entry.runtime_data[DATA_DRIVER_EVENTS] - listen_task.cancel() - start_client_task.cancel() - platform_setup_tasks = driver_events.platform_setup_tasks.values() - for task in platform_setup_tasks: - task.cancel() - - tasks = (listen_task, start_client_task, *platform_setup_tasks) - await asyncio.gather(*tasks, return_exceptions=True) - for task in tasks: - with suppress(asyncio.CancelledError): - await task - - if client.connected: - await client.disconnect() - LOGGER.info("Disconnected from Zwave JS Server") + if not hass.is_stopping: + if entry.state is not ConfigEntryState.LOADED: + raise HomeAssistantError("Listen task ended unexpectedly") + LOGGER.debug("Disconnected from server. Reloading integration") + hass.config_entries.async_schedule_reload(entry.entry_id) async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - client: ZwaveClient = entry.runtime_data[DATA_CLIENT] - driver_events: DriverEvents = entry.runtime_data[DATA_DRIVER_EVENTS] - platforms = [ - platform - for platform, task in driver_events.platform_setup_tasks.items() - if not task.cancel() - ] - unload_ok = await hass.config_entries.async_unload_platforms(entry, platforms) + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if client.connected and client.driver: - await async_disable_server_logging_if_needed(hass, entry, client.driver) - if DATA_CLIENT_LISTEN_TASK in entry.runtime_data: - await disconnect_client(hass, entry) + entry_runtime_data = entry.runtime_data + client: ZwaveClient = entry_runtime_data[DATA_CLIENT] + + if client.connected and (driver := client.driver): + await async_disable_server_logging_if_needed(hass, entry, driver) if entry.data.get(CONF_USE_ADDON) and entry.disabled_by: addon_manager: AddonManager = get_addon_manager(hass) diff --git a/homeassistant/components/zwave_js/config_flow.py b/homeassistant/components/zwave_js/config_flow.py index 44adf6a12ab..aed0dd839be 100644 --- a/homeassistant/components/zwave_js/config_flow.py +++ b/homeassistant/components/zwave_js/config_flow.py @@ -42,7 +42,6 @@ from homeassistant.helpers.service_info.usb import UsbServiceInfo from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from homeassistant.helpers.typing import VolDictType -from . import disconnect_client from .addon import get_addon_manager from .const import ( ADDON_SLUG, @@ -861,7 +860,7 @@ class OptionsFlowHandler(BaseZwaveJSFlow, OptionsFlow): and self.config_entry.state == ConfigEntryState.LOADED ): # Disconnect integration before restarting add-on. - await disconnect_client(self.hass, self.config_entry) + await self.hass.config_entries.async_unload(self.config_entry.entry_id) return await self.async_step_start_addon() diff --git a/tests/components/zwave_js/conftest.py b/tests/components/zwave_js/conftest.py index bcdc0c3ce16..1917ebedd34 100644 --- a/tests/components/zwave_js/conftest.py +++ b/tests/components/zwave_js/conftest.py @@ -511,18 +511,25 @@ def aeotec_smart_switch_7_state_fixture() -> NodeDataType: @pytest.fixture(name="listen_block") -def mock_listen_block_fixture(): +def mock_listen_block_fixture() -> asyncio.Event: """Mock a listen block.""" return asyncio.Event() +@pytest.fixture(name="listen_result") +def listen_result_fixture() -> asyncio.Future[None]: + """Mock a listen result.""" + return asyncio.Future() + + @pytest.fixture(name="client") def mock_client_fixture( - controller_state, - controller_node_state, - version_state, - log_config_state, - listen_block, + controller_state: dict[str, Any], + controller_node_state: dict[str, Any], + version_state: dict[str, Any], + log_config_state: dict[str, Any], + listen_block: asyncio.Event, + listen_result: asyncio.Future[None], ): """Mock a client.""" with patch( @@ -537,6 +544,7 @@ def mock_client_fixture( async def listen(driver_ready: asyncio.Event) -> None: driver_ready.set() await listen_block.wait() + await listen_result async def disconnect(): client.connected = False @@ -817,7 +825,10 @@ def nortek_thermostat_removed_event_fixture(client) -> Node: @pytest.fixture(name="integration") -async def integration_fixture(hass: HomeAssistant, client) -> MockConfigEntry: +async def integration_fixture( + hass: HomeAssistant, + client: MagicMock, +) -> MockConfigEntry: """Set up the zwave_js integration.""" entry = MockConfigEntry(domain="zwave_js", data={"url": "ws://test.org"}) entry.add_to_hass(hass) diff --git a/tests/components/zwave_js/test_init.py b/tests/components/zwave_js/test_init.py index c575066b57c..91e333f7c7d 100644 --- a/tests/components/zwave_js/test_init.py +++ b/tests/components/zwave_js/test_init.py @@ -3,14 +3,19 @@ import asyncio from copy import deepcopy import logging -from unittest.mock import AsyncMock, call, patch +from typing import Any +from unittest.mock import AsyncMock, MagicMock, call, patch from aiohasupervisor import SupervisorError from aiohasupervisor.models import AddonsOptions import pytest from zwave_js_server.client import Client from zwave_js_server.event import Event -from zwave_js_server.exceptions import BaseZwaveJSServerError, InvalidServerVersion +from zwave_js_server.exceptions import ( + BaseZwaveJSServerError, + InvalidServerVersion, + NotConnected, +) from zwave_js_server.model.node import Node from zwave_js_server.model.version import VersionInfo @@ -21,7 +26,7 @@ from homeassistant.components.zwave_js import DOMAIN from homeassistant.components.zwave_js.helpers import get_device_id from homeassistant.config_entries import ConfigEntryDisabler, ConfigEntryState from homeassistant.const import STATE_UNAVAILABLE -from homeassistant.core import HomeAssistant +from homeassistant.core import CoreState, HomeAssistant from homeassistant.helpers import ( area_registry as ar, device_registry as dr, @@ -32,7 +37,11 @@ from homeassistant.setup import async_setup_component from .common import AIR_TEMPERATURE_SENSOR, EATON_RF9640_ENTITY -from tests.common import MockConfigEntry, async_get_persistent_notifications +from tests.common import ( + MockConfigEntry, + async_fire_time_changed, + async_get_persistent_notifications, +) from tests.typing import WebSocketGenerator @@ -127,24 +136,215 @@ async def test_noop_statistics(hass: HomeAssistant, client) -> None: assert not mock_cmd2.called -@pytest.mark.parametrize("error", [BaseZwaveJSServerError("Boom"), Exception("Boom")]) -async def test_listen_failure(hass: HomeAssistant, client, error) -> None: - """Test we handle errors during client listen.""" +async def test_driver_ready_timeout_during_setup( + hass: HomeAssistant, + client: MagicMock, + listen_block: asyncio.Event, +) -> None: + """Test we handle driver ready timeout during setup.""" - async def listen(driver_ready): - """Mock the client listen method.""" - # Set the connect side effect to stop an endless loop on reload. - client.connect.side_effect = BaseZwaveJSServerError("Boom") - raise error + async def listen(driver_ready: asyncio.Event) -> None: + """Mock listen.""" + await listen_block.wait() client.listen.side_effect = listen + + entry = MockConfigEntry( + domain="zwave_js", + data={"url": "ws://test.org", "data_collection_opted_in": True}, + ) + entry.add_to_hass(hass) + assert client.disconnect.call_count == 0 + + with patch("homeassistant.components.zwave_js.DRIVER_READY_TIMEOUT", new=0): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.SETUP_RETRY + assert client.disconnect.call_count == 1 + + +@pytest.mark.parametrize("core_state", [CoreState.running, CoreState.stopping]) +@pytest.mark.parametrize( + ("listen_future_result_method", "listen_future_result"), + [ + ("set_exception", BaseZwaveJSServerError("Boom")), + ("set_exception", Exception("Boom")), + ("set_result", None), + ], +) +async def test_listen_done_during_setup_before_forward_entry( + hass: HomeAssistant, + client: MagicMock, + listen_block: asyncio.Event, + listen_result: asyncio.Future[None], + core_state: CoreState, + listen_future_result_method: str, + listen_future_result: Exception | None, +) -> None: + """Test listen task finishing during setup before forward entry.""" + assert hass.state is CoreState.running + + async def listen(driver_ready: asyncio.Event) -> None: + await listen_block.wait() + await listen_result + async_fire_time_changed(hass, fire_all=True) + + client.listen.side_effect = listen + hass.set_state(core_state) + listen_block.set() + getattr(listen_result, listen_future_result_method)(listen_future_result) + entry = MockConfigEntry(domain="zwave_js", data={"url": "ws://test.org"}) entry.add_to_hass(hass) + assert client.disconnect.call_count == 0 await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() assert entry.state is ConfigEntryState.SETUP_RETRY + assert client.disconnect.call_count == 1 + + +async def test_not_connected_during_setup_after_forward_entry( + hass: HomeAssistant, + client: MagicMock, + listen_block: asyncio.Event, + listen_result: asyncio.Future[None], +) -> None: + """Test we handle not connected client during setup after forward entry.""" + + async def send_command_side_effect(*args: Any, **kwargs: Any) -> None: + """Mock send command.""" + listen_block.set() + listen_result.set_result(None) + # Yield to allow the listen task to run + await asyncio.sleep(0) + raise NotConnected("Boom") + + async def listen(driver_ready: asyncio.Event) -> None: + """Mock listen.""" + driver_ready.set() + client.async_send_command.side_effect = send_command_side_effect + await listen_block.wait() + await listen_result + + client.listen.side_effect = listen + + entry = MockConfigEntry( + domain="zwave_js", + data={"url": "ws://test.org", "data_collection_opted_in": True}, + ) + entry.add_to_hass(hass) + assert client.disconnect.call_count == 0 + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.SETUP_RETRY + assert client.disconnect.call_count == 1 + + +@pytest.mark.parametrize("core_state", [CoreState.running, CoreState.stopping]) +@pytest.mark.parametrize( + ("listen_future_result_method", "listen_future_result"), + [ + ("set_exception", BaseZwaveJSServerError("Boom")), + ("set_exception", Exception("Boom")), + ("set_result", None), + ], +) +async def test_listen_done_during_setup_after_forward_entry( + hass: HomeAssistant, + client: MagicMock, + listen_block: asyncio.Event, + listen_result: asyncio.Future[None], + core_state: CoreState, + listen_future_result_method: str, + listen_future_result: Exception | None, +) -> None: + """Test listen task finishing during setup after forward entry.""" + assert hass.state is CoreState.running + + async def send_command_side_effect(*args: Any, **kwargs: Any) -> None: + """Mock send command.""" + listen_block.set() + getattr(listen_result, listen_future_result_method)(listen_future_result) + # Yield to allow the listen task to run + await asyncio.sleep(0) + + async def listen(driver_ready: asyncio.Event) -> None: + """Mock listen.""" + driver_ready.set() + client.async_send_command.side_effect = send_command_side_effect + await listen_block.wait() + await listen_result + + client.listen.side_effect = listen + hass.set_state(core_state) + + entry = MockConfigEntry( + domain="zwave_js", + data={"url": "ws://test.org", "data_collection_opted_in": True}, + ) + entry.add_to_hass(hass) + assert client.disconnect.call_count == 0 + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.SETUP_RETRY + assert client.disconnect.call_count == 1 + + +@pytest.mark.parametrize( + ("core_state", "final_config_entry_state", "disconnect_call_count"), + [ + ( + CoreState.running, + ConfigEntryState.SETUP_RETRY, + 2, + ), # the reload will cause a disconnect call too + ( + CoreState.stopping, + ConfigEntryState.LOADED, + 0, + ), # the home assistant stop event will handle the disconnect + ], +) +@pytest.mark.parametrize( + ("listen_future_result_method", "listen_future_result"), + [ + ("set_exception", BaseZwaveJSServerError("Boom")), + ("set_exception", Exception("Boom")), + ("set_result", None), + ], +) +async def test_listen_done_after_setup( + hass: HomeAssistant, + client: MagicMock, + integration: MockConfigEntry, + listen_block: asyncio.Event, + listen_result: asyncio.Future[None], + core_state: CoreState, + listen_future_result_method: str, + listen_future_result: Exception | None, + final_config_entry_state: ConfigEntryState, + disconnect_call_count: int, +) -> None: + """Test listen task finishing after setup.""" + config_entry = integration + assert config_entry.state is ConfigEntryState.LOADED + assert hass.state is CoreState.running + assert client.disconnect.call_count == 0 + + hass.set_state(core_state) + listen_block.set() + getattr(listen_result, listen_future_result_method)(listen_future_result) + await hass.async_block_till_done() + + assert config_entry.state is final_config_entry_state + assert client.disconnect.call_count == disconnect_call_count async def test_new_entity_on_value_added( diff --git a/tests/components/zwave_js/test_update.py b/tests/components/zwave_js/test_update.py index d6683fa24cb..6a4f48a0dc5 100644 --- a/tests/components/zwave_js/test_update.py +++ b/tests/components/zwave_js/test_update.py @@ -658,8 +658,10 @@ 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(wait_background_tasks=True) + update_interval = timedelta(minutes=5) + freezer.tick(update_interval) + async_fire_time_changed(hass) + await hass.async_block_till_done() nodes: set[int] = set() @@ -668,8 +670,9 @@ async def test_update_entity_delay( assert args["command"] == "controller.get_available_firmware_updates" nodes.add(args["nodeId"]) - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=10)) - await hass.async_block_till_done(wait_background_tasks=True) + freezer.tick(update_interval) + async_fire_time_changed(hass) + await hass.async_block_till_done() assert len(client.async_send_command.call_args_list) == 4 args = client.async_send_command.call_args_list[3][0][0] From 3fb0290fbacfb6ff379c88dfc893c53db043d1bf Mon Sep 17 00:00:00 2001 From: Petar Petrov Date: Thu, 20 Mar 2025 11:19:26 +0200 Subject: [PATCH 2773/3148] Remove unused params in "zwave_js/provision_smart_start_node" API (#140982) --- homeassistant/components/zwave_js/api.py | 30 +-------- tests/components/zwave_js/test_api.py | 79 +++++------------------- 2 files changed, 18 insertions(+), 91 deletions(-) diff --git a/homeassistant/components/zwave_js/api.py b/homeassistant/components/zwave_js/api.py index ec164e2b505..dd698d9ed66 100644 --- a/homeassistant/components/zwave_js/api.py +++ b/homeassistant/components/zwave_js/api.py @@ -976,13 +976,7 @@ async def websocket_validate_dsk_and_enter_pin( { vol.Required(TYPE): "zwave_js/provision_smart_start_node", vol.Required(ENTRY_ID): str, - vol.Exclusive( - PLANNED_PROVISIONING_ENTRY, "options" - ): PLANNED_PROVISIONING_ENTRY_SCHEMA, - vol.Exclusive( - QR_PROVISIONING_INFORMATION, "options" - ): QR_PROVISIONING_INFORMATION_SCHEMA, - vol.Exclusive(QR_CODE_STRING, "options"): QR_CODE_STRING_SCHEMA, + vol.Required(QR_PROVISIONING_INFORMATION): QR_PROVISIONING_INFORMATION_SCHEMA, } ) @websocket_api.async_response @@ -997,28 +991,10 @@ async def websocket_provision_smart_start_node( driver: Driver, ) -> None: """Pre-provision a smart start node.""" - try: - cv.has_at_least_one_key( - PLANNED_PROVISIONING_ENTRY, QR_PROVISIONING_INFORMATION, QR_CODE_STRING - )(msg) - except vol.Invalid as err: - connection.send_error( - msg[ID], - ERR_INVALID_FORMAT, - err.args[0], - ) - return - provisioning_info = ( - msg.get(PLANNED_PROVISIONING_ENTRY) - or msg.get(QR_PROVISIONING_INFORMATION) - or msg[QR_CODE_STRING] - ) + provisioning_info = msg[QR_PROVISIONING_INFORMATION] - if ( - QR_PROVISIONING_INFORMATION in msg - and provisioning_info.version == QRCodeVersion.S2 - ): + if provisioning_info.version == QRCodeVersion.S2: connection.send_error( msg[ID], ERR_INVALID_FORMAT, diff --git a/tests/components/zwave_js/test_api.py b/tests/components/zwave_js/test_api.py index b2741a53a92..62e7f25bc08 100644 --- a/tests/components/zwave_js/test_api.py +++ b/tests/components/zwave_js/test_api.py @@ -1095,52 +1095,27 @@ async def test_provision_smart_start_node( client.async_send_command.return_value = {"success": True} - # Test provisioning entry - await ws_client.send_json( - { - ID: 2, - TYPE: "zwave_js/provision_smart_start_node", - ENTRY_ID: entry.entry_id, - PLANNED_PROVISIONING_ENTRY: { - DSK: "test", - SECURITY_CLASSES: [0], - }, - } - ) - - msg = await ws_client.receive_json() - assert msg["success"] - - assert len(client.async_send_command.call_args_list) == 1 - assert client.async_send_command.call_args[0][0] == { - "command": "controller.provision_smart_start_node", - "entry": ProvisioningEntry( - "test", [SecurityClass.S2_UNAUTHENTICATED] - ).to_dict(), + valid_qr_info = { + VERSION: 1, + SECURITY_CLASSES: [0], + DSK: "test", + GENERIC_DEVICE_CLASS: 1, + SPECIFIC_DEVICE_CLASS: 1, + INSTALLER_ICON_TYPE: 1, + MANUFACTURER_ID: 1, + PRODUCT_TYPE: 1, + PRODUCT_ID: 1, + APPLICATION_VERSION: "test", + "name": "test", } - client.async_send_command.reset_mock() - client.async_send_command.return_value = {"success": True} - # Test QR provisioning information await ws_client.send_json( { ID: 3, TYPE: "zwave_js/provision_smart_start_node", ENTRY_ID: entry.entry_id, - QR_PROVISIONING_INFORMATION: { - VERSION: 1, - SECURITY_CLASSES: [0], - DSK: "test", - GENERIC_DEVICE_CLASS: 1, - SPECIFIC_DEVICE_CLASS: 1, - INSTALLER_ICON_TYPE: 1, - MANUFACTURER_ID: 1, - PRODUCT_TYPE: 1, - PRODUCT_ID: 1, - APPLICATION_VERSION: "test", - "name": "test", - }, + QR_PROVISIONING_INFORMATION: valid_qr_info, } ) @@ -1171,28 +1146,6 @@ async def test_provision_smart_start_node( client.async_send_command.reset_mock() client.async_send_command.return_value = {"success": True} - # Test QR code string - await ws_client.send_json( - { - ID: 4, - TYPE: "zwave_js/provision_smart_start_node", - ENTRY_ID: entry.entry_id, - QR_CODE_STRING: "90testtesttesttesttesttesttesttesttesttesttesttesttest", - } - ) - - msg = await ws_client.receive_json() - assert msg["success"] - - assert len(client.async_send_command.call_args_list) == 1 - assert client.async_send_command.call_args[0][0] == { - "command": "controller.provision_smart_start_node", - "entry": "90testtesttesttesttesttesttesttesttesttesttesttesttest", - } - - client.async_send_command.reset_mock() - client.async_send_command.return_value = {"success": True} - # Test QR provisioning information with S2 version throws error await ws_client.send_json( { @@ -1243,9 +1196,7 @@ async def test_provision_smart_start_node( ID: 7, TYPE: "zwave_js/provision_smart_start_node", ENTRY_ID: entry.entry_id, - QR_CODE_STRING: ( - "90testtesttesttesttesttesttesttesttesttesttesttesttest" - ), + QR_PROVISIONING_INFORMATION: valid_qr_info, } ) msg = await ws_client.receive_json() @@ -1263,7 +1214,7 @@ async def test_provision_smart_start_node( ID: 8, TYPE: "zwave_js/provision_smart_start_node", ENTRY_ID: entry.entry_id, - QR_CODE_STRING: "90testtesttesttesttesttesttesttesttesttesttesttesttest", + QR_PROVISIONING_INFORMATION: valid_qr_info, } ) msg = await ws_client.receive_json() From c6d3928ed1130a542d23b26d7f51acff07b1aa62 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 20 Mar 2025 10:29:40 +0100 Subject: [PATCH 2774/3148] Add template function: combine (#140948) * Add template function: combine * Add test to take away concern raised --- homeassistant/helpers/template.py | 28 ++++++++++++++++++++++++ tests/helpers/test_template.py | 36 +++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+) diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 20531596fdd..69a9232431f 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -2785,6 +2785,32 @@ def flatten(value: Iterable[Any], levels: int | None = None) -> list[Any]: return flattened +def combine(*args: Any, recursive: bool = False) -> dict[Any, Any]: + """Combine multiple dictionaries into one.""" + if not args: + raise TypeError("combine expected at least 1 argument, got 0") + + result: dict[Any, Any] = {} + for arg in args: + if not isinstance(arg, dict): + raise TypeError(f"combine expected a dict, got {type(arg).__name__}") + + if recursive: + for key, value in arg.items(): + if ( + key in result + and isinstance(result[key], dict) + and isinstance(value, dict) + ): + result[key] = combine(result[key], value, recursive=True) + else: + result[key] = value + else: + result |= arg + + return result + + def md5(value: str) -> str: """Generate md5 hash from a string.""" return hashlib.md5(value.encode()).hexdigest() @@ -3012,6 +3038,7 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): self.filters["sha1"] = sha1 self.filters["sha256"] = sha256 self.filters["sha512"] = sha512 + self.filters["combine"] = combine self.globals["log"] = logarithm self.globals["sin"] = sine self.globals["cos"] = cosine @@ -3056,6 +3083,7 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): self.globals["sha1"] = sha1 self.globals["sha256"] = sha256 self.globals["sha512"] = sha512 + self.globals["combine"] = combine self.tests["is_number"] = is_number self.tests["list"] = _is_list self.tests["set"] = _is_set diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index bdf400ce357..e4e73fc52d9 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -6840,3 +6840,39 @@ def test_sha512(hass: HomeAssistant) -> None: template.Template("{{ 'Home Assistant' | sha512 }}", hass).async_render() == "9e3c2cdd1fbab0037378d37e1baf8a3a4bf92c54b56ad1d459deee30ccbb2acbebd7a3614552ea08992ad27dedeb7b4c5473525ba90cb73dbe8b9ec5f69295bb" ) + + +def test_combine(hass: HomeAssistant) -> None: + """Test combine filter and function.""" + assert template.Template( + "{{ {'a': 1, 'b': 2} | combine({'b': 3, 'c': 4}) }}", hass + ).async_render() == {"a": 1, "b": 3, "c": 4} + + assert template.Template( + "{{ combine({'a': 1, 'b': 2}, {'b': 3, 'c': 4}) }}", hass + ).async_render() == {"a": 1, "b": 3, "c": 4} + + assert template.Template( + "{{ combine({'a': 1, 'b': {'x': 1}}, {'b': {'y': 2}, 'c': 4}, recursive=True) }}", + hass, + ).async_render() == {"a": 1, "b": {"x": 1, "y": 2}, "c": 4} + + # Test that recursive=False does not merge nested dictionaries + assert template.Template( + "{{ combine({'a': 1, 'b': {'x': 1}}, {'b': {'y': 2}, 'c': 4}, recursive=False) }}", + hass, + ).async_render() == {"a": 1, "b": {"y": 2}, "c": 4} + + # Test that None values are handled correctly in recursive merge + assert template.Template( + "{{ combine({'a': 1, 'b': none}, {'b': {'y': 2}, 'c': 4}, recursive=True) }}", + hass, + ).async_render() == {"a": 1, "b": {"y": 2}, "c": 4} + + with pytest.raises( + TemplateError, match="combine expected at least 1 argument, got 0" + ): + template.Template("{{ combine() }}", hass).async_render() + + with pytest.raises(TemplateError, match="combine expected a dict, got str"): + template.Template("{{ {'a': 1} | combine('not a dict') }}", hass).async_render() From 827d5256c60070ad8439a6356e2d2b191ab53c74 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 20 Mar 2025 11:02:51 +0100 Subject: [PATCH 2775/3148] Bump pySmartThings to 2.7.4 (#140720) * Bump pySmartThings to 2.7.3 * Bump pySmartThings to 2.7.3 * Fix * Fix * Fix --- .../components/smartthings/diagnostics.py | 2 +- .../components/smartthings/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../snapshots/test_diagnostics.ambr | 588 +++++++++--------- .../smartthings/test_diagnostics.py | 6 +- 6 files changed, 303 insertions(+), 299 deletions(-) diff --git a/homeassistant/components/smartthings/diagnostics.py b/homeassistant/components/smartthings/diagnostics.py index dbc5d4e8224..04517112802 100644 --- a/homeassistant/components/smartthings/diagnostics.py +++ b/homeassistant/components/smartthings/diagnostics.py @@ -23,7 +23,7 @@ async def async_get_config_entry_diagnostics( ) -> dict[str, Any]: """Return diagnostics for a config entry.""" client = entry.runtime_data.client - return await client.get_raw_devices() + return {"devices": await client.get_raw_devices()} async def async_get_device_diagnostics( diff --git a/homeassistant/components/smartthings/manifest.json b/homeassistant/components/smartthings/manifest.json index 74f0e4bae83..a456a6bef2f 100644 --- a/homeassistant/components/smartthings/manifest.json +++ b/homeassistant/components/smartthings/manifest.json @@ -29,5 +29,5 @@ "documentation": "https://www.home-assistant.io/integrations/smartthings", "iot_class": "cloud_push", "loggers": ["pysmartthings"], - "requirements": ["pysmartthings==2.7.2"] + "requirements": ["pysmartthings==2.7.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index 37f84836635..f4e20f563a1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2314,7 +2314,7 @@ pysma==0.7.5 pysmappee==0.2.29 # homeassistant.components.smartthings -pysmartthings==2.7.2 +pysmartthings==2.7.4 # homeassistant.components.smarty pysmarty2==0.10.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9c084dfd70e..b4435e22827 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1883,7 +1883,7 @@ pysma==0.7.5 pysmappee==0.2.29 # homeassistant.components.smartthings -pysmartthings==2.7.2 +pysmartthings==2.7.4 # homeassistant.components.smarty pysmarty2==0.10.2 diff --git a/tests/components/smartthings/snapshots/test_diagnostics.ambr b/tests/components/smartthings/snapshots/test_diagnostics.ambr index 7610c8839ba..b9847bf9746 100644 --- a/tests/components/smartthings/snapshots/test_diagnostics.ambr +++ b/tests/components/smartthings/snapshots/test_diagnostics.ambr @@ -1,307 +1,311 @@ # serializer version: 1 # name: test_config_entry_diagnostics[da_ac_rac_000001] dict({ - '_links': dict({ - }), - 'items': list([ + 'devices': list([ dict({ - 'allowed': list([ - ]), - 'components': list([ + '_links': dict({ + }), + 'items': list([ dict({ - 'capabilities': list([ + 'allowed': list([ + ]), + 'components': list([ dict({ - 'id': 'ocf', - 'version': 1, + 'capabilities': list([ + dict({ + 'id': 'ocf', + 'version': 1, + }), + dict({ + 'id': 'switch', + 'version': 1, + }), + dict({ + 'id': 'airConditionerMode', + 'version': 1, + }), + dict({ + 'id': 'airConditionerFanMode', + 'version': 1, + }), + dict({ + 'id': 'fanOscillationMode', + 'version': 1, + }), + dict({ + 'id': 'airQualitySensor', + 'version': 1, + }), + dict({ + 'id': 'temperatureMeasurement', + 'version': 1, + }), + dict({ + 'id': 'thermostatCoolingSetpoint', + 'version': 1, + }), + dict({ + 'id': 'relativeHumidityMeasurement', + 'version': 1, + }), + dict({ + 'id': 'dustSensor', + 'version': 1, + }), + dict({ + 'id': 'veryFineDustSensor', + 'version': 1, + }), + dict({ + 'id': 'audioVolume', + 'version': 1, + }), + dict({ + 'id': 'remoteControlStatus', + 'version': 1, + }), + dict({ + 'id': 'powerConsumptionReport', + 'version': 1, + }), + dict({ + 'id': 'demandResponseLoadControl', + 'version': 1, + }), + dict({ + 'id': 'refresh', + 'version': 1, + }), + dict({ + 'id': 'execute', + 'version': 1, + }), + dict({ + 'id': 'custom.spiMode', + 'version': 1, + }), + dict({ + 'id': 'custom.thermostatSetpointControl', + 'version': 1, + }), + dict({ + 'id': 'custom.airConditionerOptionalMode', + 'version': 1, + }), + dict({ + 'id': 'custom.airConditionerTropicalNightMode', + 'version': 1, + }), + dict({ + 'id': 'custom.autoCleaningMode', + 'version': 1, + }), + dict({ + 'id': 'custom.deviceReportStateConfiguration', + 'version': 1, + }), + dict({ + 'id': 'custom.energyType', + 'version': 1, + }), + dict({ + 'id': 'custom.dustFilter', + 'version': 1, + }), + dict({ + 'id': 'custom.airConditionerOdorController', + 'version': 1, + }), + dict({ + 'id': 'custom.deodorFilter', + 'version': 1, + }), + dict({ + 'id': 'custom.disabledComponents', + 'version': 1, + }), + dict({ + 'id': 'custom.disabledCapabilities', + 'version': 1, + }), + dict({ + 'id': 'samsungce.deviceIdentification', + 'version': 1, + }), + dict({ + 'id': 'samsungce.dongleSoftwareInstallation', + 'version': 1, + }), + dict({ + 'id': 'samsungce.softwareUpdate', + 'version': 1, + }), + dict({ + 'id': 'samsungce.selfCheck', + 'version': 1, + }), + dict({ + 'id': 'samsungce.driverVersion', + 'version': 1, + }), + ]), + 'categories': list([ + dict({ + 'categoryType': 'manufacturer', + 'name': 'AirConditioner', + }), + ]), + 'id': 'main', + 'label': 'main', }), dict({ - 'id': 'switch', - 'version': 1, - }), - dict({ - 'id': 'airConditionerMode', - 'version': 1, - }), - dict({ - 'id': 'airConditionerFanMode', - 'version': 1, - }), - dict({ - 'id': 'fanOscillationMode', - 'version': 1, - }), - dict({ - 'id': 'airQualitySensor', - 'version': 1, - }), - dict({ - 'id': 'temperatureMeasurement', - 'version': 1, - }), - dict({ - 'id': 'thermostatCoolingSetpoint', - 'version': 1, - }), - dict({ - 'id': 'relativeHumidityMeasurement', - 'version': 1, - }), - dict({ - 'id': 'dustSensor', - 'version': 1, - }), - dict({ - 'id': 'veryFineDustSensor', - 'version': 1, - }), - dict({ - 'id': 'audioVolume', - 'version': 1, - }), - dict({ - 'id': 'remoteControlStatus', - 'version': 1, - }), - dict({ - 'id': 'powerConsumptionReport', - 'version': 1, - }), - dict({ - 'id': 'demandResponseLoadControl', - 'version': 1, - }), - dict({ - 'id': 'refresh', - 'version': 1, - }), - dict({ - 'id': 'execute', - 'version': 1, - }), - dict({ - 'id': 'custom.spiMode', - 'version': 1, - }), - dict({ - 'id': 'custom.thermostatSetpointControl', - 'version': 1, - }), - dict({ - 'id': 'custom.airConditionerOptionalMode', - 'version': 1, - }), - dict({ - 'id': 'custom.airConditionerTropicalNightMode', - 'version': 1, - }), - dict({ - 'id': 'custom.autoCleaningMode', - 'version': 1, - }), - dict({ - 'id': 'custom.deviceReportStateConfiguration', - 'version': 1, - }), - dict({ - 'id': 'custom.energyType', - 'version': 1, - }), - dict({ - 'id': 'custom.dustFilter', - 'version': 1, - }), - dict({ - 'id': 'custom.airConditionerOdorController', - 'version': 1, - }), - dict({ - 'id': 'custom.deodorFilter', - 'version': 1, - }), - dict({ - 'id': 'custom.disabledComponents', - 'version': 1, - }), - dict({ - 'id': 'custom.disabledCapabilities', - 'version': 1, - }), - dict({ - 'id': 'samsungce.deviceIdentification', - 'version': 1, - }), - dict({ - 'id': 'samsungce.dongleSoftwareInstallation', - 'version': 1, - }), - dict({ - 'id': 'samsungce.softwareUpdate', - 'version': 1, - }), - dict({ - 'id': 'samsungce.selfCheck', - 'version': 1, - }), - dict({ - 'id': 'samsungce.driverVersion', - 'version': 1, + 'capabilities': list([ + dict({ + 'id': 'switch', + 'version': 1, + }), + dict({ + 'id': 'airConditionerMode', + 'version': 1, + }), + dict({ + 'id': 'airConditionerFanMode', + 'version': 1, + }), + dict({ + 'id': 'fanOscillationMode', + 'version': 1, + }), + dict({ + 'id': 'temperatureMeasurement', + 'version': 1, + }), + dict({ + 'id': 'thermostatCoolingSetpoint', + 'version': 1, + }), + dict({ + 'id': 'relativeHumidityMeasurement', + 'version': 1, + }), + dict({ + 'id': 'airQualitySensor', + 'version': 1, + }), + dict({ + 'id': 'dustSensor', + 'version': 1, + }), + dict({ + 'id': 'veryFineDustSensor', + 'version': 1, + }), + dict({ + 'id': 'odorSensor', + 'version': 1, + }), + dict({ + 'id': 'remoteControlStatus', + 'version': 1, + }), + dict({ + 'id': 'audioVolume', + 'version': 1, + }), + dict({ + 'id': 'custom.thermostatSetpointControl', + 'version': 1, + }), + dict({ + 'id': 'custom.autoCleaningMode', + 'version': 1, + }), + dict({ + 'id': 'custom.airConditionerTropicalNightMode', + 'version': 1, + }), + dict({ + 'id': 'custom.disabledCapabilities', + 'version': 1, + }), + dict({ + 'id': 'ocf', + 'version': 1, + }), + dict({ + 'id': 'powerConsumptionReport', + 'version': 1, + }), + dict({ + 'id': 'demandResponseLoadControl', + 'version': 1, + }), + dict({ + 'id': 'custom.spiMode', + 'version': 1, + }), + dict({ + 'id': 'custom.airConditionerOptionalMode', + 'version': 1, + }), + dict({ + 'id': 'custom.deviceReportStateConfiguration', + 'version': 1, + }), + dict({ + 'id': 'custom.energyType', + 'version': 1, + }), + dict({ + 'id': 'custom.dustFilter', + 'version': 1, + }), + dict({ + 'id': 'custom.airConditionerOdorController', + 'version': 1, + }), + dict({ + 'id': 'custom.deodorFilter', + 'version': 1, + }), + ]), + 'categories': list([ + dict({ + 'categoryType': 'manufacturer', + 'name': 'Other', + }), + ]), + 'id': '1', + 'label': '1', }), ]), - 'categories': list([ - dict({ - 'categoryType': 'manufacturer', - 'name': 'AirConditioner', - }), - ]), - 'id': 'main', - 'label': 'main', - }), - dict({ - 'capabilities': list([ - dict({ - 'id': 'switch', - 'version': 1, - }), - dict({ - 'id': 'airConditionerMode', - 'version': 1, - }), - dict({ - 'id': 'airConditionerFanMode', - 'version': 1, - }), - dict({ - 'id': 'fanOscillationMode', - 'version': 1, - }), - dict({ - 'id': 'temperatureMeasurement', - 'version': 1, - }), - dict({ - 'id': 'thermostatCoolingSetpoint', - 'version': 1, - }), - dict({ - 'id': 'relativeHumidityMeasurement', - 'version': 1, - }), - dict({ - 'id': 'airQualitySensor', - 'version': 1, - }), - dict({ - 'id': 'dustSensor', - 'version': 1, - }), - dict({ - 'id': 'veryFineDustSensor', - 'version': 1, - }), - dict({ - 'id': 'odorSensor', - 'version': 1, - }), - dict({ - 'id': 'remoteControlStatus', - 'version': 1, - }), - dict({ - 'id': 'audioVolume', - 'version': 1, - }), - dict({ - 'id': 'custom.thermostatSetpointControl', - 'version': 1, - }), - dict({ - 'id': 'custom.autoCleaningMode', - 'version': 1, - }), - dict({ - 'id': 'custom.airConditionerTropicalNightMode', - 'version': 1, - }), - dict({ - 'id': 'custom.disabledCapabilities', - 'version': 1, - }), - dict({ - 'id': 'ocf', - 'version': 1, - }), - dict({ - 'id': 'powerConsumptionReport', - 'version': 1, - }), - dict({ - 'id': 'demandResponseLoadControl', - 'version': 1, - }), - dict({ - 'id': 'custom.spiMode', - 'version': 1, - }), - dict({ - 'id': 'custom.airConditionerOptionalMode', - 'version': 1, - }), - dict({ - 'id': 'custom.deviceReportStateConfiguration', - 'version': 1, - }), - dict({ - 'id': 'custom.energyType', - 'version': 1, - }), - dict({ - 'id': 'custom.dustFilter', - 'version': 1, - }), - dict({ - 'id': 'custom.airConditionerOdorController', - 'version': 1, - }), - dict({ - 'id': 'custom.deodorFilter', - 'version': 1, - }), - ]), - 'categories': list([ - dict({ - 'categoryType': 'manufacturer', - 'name': 'Other', - }), - ]), - 'id': '1', - 'label': '1', + 'createTime': '2021-04-06T16:43:34.753Z', + 'deviceId': '96a5ef74-5832-a84b-f1f7-ca799957065d', + 'deviceManufacturerCode': 'Samsung Electronics', + 'deviceTypeName': 'Samsung OCF Air Conditioner', + 'executionContext': 'CLOUD', + 'label': 'AC Office Granit', + 'locationId': '58d3fd7c-c512-4da3-b500-ef269382756c', + 'manufacturerName': 'Samsung Electronics', + 'name': '[room a/c] Samsung', + 'ocf': dict({ + 'additionalAuthCodeRequired': False, + 'lastSignupTime': '2025-01-08T02:32:04.631093137Z', + 'manufacturerName': 'Samsung Electronics', + 'ocfDeviceType': 'x.com.st.d.sensor.light', + 'transferCandidate': False, + 'vendorId': 'VD-Sensor.Light-2023', + }), + 'ownerId': 'f9a28d7c-1ed5-d9e9-a81c-18971ec081db', + 'presentationId': 'DA-AC-RAC-000001', + 'profile': dict({ + 'id': '60fbc713-8da5-315d-b31a-6d6dcde4be7b', + }), + 'restrictionTier': 0, + 'roomId': '7715151d-0314-457a-a82c-5ce48900e065', + 'type': 'OCF', }), ]), - 'createTime': '2021-04-06T16:43:34.753Z', - 'deviceId': '96a5ef74-5832-a84b-f1f7-ca799957065d', - 'deviceManufacturerCode': 'Samsung Electronics', - 'deviceTypeName': 'Samsung OCF Air Conditioner', - 'executionContext': 'CLOUD', - 'label': 'AC Office Granit', - 'locationId': '58d3fd7c-c512-4da3-b500-ef269382756c', - 'manufacturerName': 'Samsung Electronics', - 'name': '[room a/c] Samsung', - 'ocf': dict({ - 'additionalAuthCodeRequired': False, - 'lastSignupTime': '2025-01-08T02:32:04.631093137Z', - 'manufacturerName': 'Samsung Electronics', - 'ocfDeviceType': 'x.com.st.d.sensor.light', - 'transferCandidate': False, - 'vendorId': 'VD-Sensor.Light-2023', - }), - 'ownerId': 'f9a28d7c-1ed5-d9e9-a81c-18971ec081db', - 'presentationId': 'DA-AC-RAC-000001', - 'profile': dict({ - 'id': '60fbc713-8da5-315d-b31a-6d6dcde4be7b', - }), - 'restrictionTier': 0, - 'roomId': '7715151d-0314-457a-a82c-5ce48900e065', - 'type': 'OCF', }), ]), }) diff --git a/tests/components/smartthings/test_diagnostics.py b/tests/components/smartthings/test_diagnostics.py index f486c19de14..b28a3a1aff5 100644 --- a/tests/components/smartthings/test_diagnostics.py +++ b/tests/components/smartthings/test_diagnostics.py @@ -30,9 +30,9 @@ async def test_config_entry_diagnostics( snapshot: SnapshotAssertion, ) -> None: """Test generating diagnostics for a device entry.""" - mock_smartthings.get_raw_devices.return_value = load_json_object_fixture( - "devices/da_ac_rac_000001.json", DOMAIN - ) + mock_smartthings.get_raw_devices.return_value = [ + load_json_object_fixture("devices/da_ac_rac_000001.json", DOMAIN) + ] await setup_integration(hass, mock_config_entry) assert ( await get_diagnostics_for_config_entry(hass, hass_client, mock_config_entry) From 56e966a980c437ce249fdf158e8192522a487292 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Thu, 20 Mar 2025 11:04:49 +0100 Subject: [PATCH 2776/3148] Update project metadata for PEP 639 (#140960) --- pyproject.toml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 628ec457bf0..74122927660 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,11 +1,12 @@ [build-system] -requires = ["setuptools==75.1.0"] +requires = ["setuptools==77.0.1"] build-backend = "setuptools.build_meta" [project] name = "homeassistant" version = "2025.4.0.dev0" -license = {text = "Apache-2.0"} +license = "Apache-2.0" +license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"] description = "Open-source home automation platform running on Python 3." readme = "README.rst" authors = [ @@ -16,7 +17,6 @@ classifiers = [ "Development Status :: 5 - Production/Stable", "Intended Audience :: End Users/Desktop", "Intended Audience :: Developers", - "License :: OSI Approved :: Apache Software License", "Operating System :: OS Independent", "Programming Language :: Python :: 3.13", "Topic :: Home Automation", From d3c40939f6b5bab748fe62d1363e7dda80d60550 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 20 Mar 2025 11:34:02 +0100 Subject: [PATCH 2777/3148] Reorder template extensions (#140985) --- homeassistant/helpers/template.py | 342 ++++++++++++++++-------------- 1 file changed, 183 insertions(+), 159 deletions(-) diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 69a9232431f..0d017dda64f 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -2983,116 +2983,119 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): str | jinja2.nodes.Template, CodeType | None ] = weakref.WeakValueDictionary() self.add_extension("jinja2.ext.loopcontrols") - self.filters["round"] = forgiving_round - self.filters["multiply"] = multiply - self.filters["add"] = add - self.filters["log"] = logarithm - self.filters["sin"] = sine - self.filters["cos"] = cosine - self.filters["tan"] = tangent - self.filters["asin"] = arc_sine - self.filters["acos"] = arc_cosine - self.filters["atan"] = arc_tangent - self.filters["atan2"] = arc_tangent2 - self.filters["sqrt"] = square_root - self.filters["as_datetime"] = as_datetime - self.filters["as_timedelta"] = as_timedelta - self.filters["as_timestamp"] = forgiving_as_timestamp - self.filters["as_local"] = dt_util.as_local - self.filters["timestamp_custom"] = timestamp_custom - self.filters["timestamp_local"] = timestamp_local - self.filters["timestamp_utc"] = timestamp_utc - self.filters["to_json"] = to_json - self.filters["from_json"] = from_json - self.filters["is_defined"] = fail_when_undefined - self.filters["average"] = average - self.filters["median"] = median - self.filters["statistical_mode"] = statistical_mode - self.filters["random"] = random_every_time - self.filters["base64_encode"] = base64_encode - self.filters["base64_decode"] = base64_decode - self.filters["ordinal"] = ordinal - self.filters["regex_match"] = regex_match - self.filters["regex_replace"] = regex_replace - self.filters["regex_search"] = regex_search - self.filters["regex_findall"] = regex_findall - self.filters["regex_findall_index"] = regex_findall_index - self.filters["bitwise_and"] = bitwise_and - self.filters["bitwise_or"] = bitwise_or - self.filters["bitwise_xor"] = bitwise_xor - self.filters["pack"] = struct_pack - self.filters["unpack"] = struct_unpack - self.filters["ord"] = ord - self.filters["is_number"] = is_number - self.filters["float"] = forgiving_float_filter - self.filters["int"] = forgiving_int_filter - self.filters["slugify"] = slugify - self.filters["iif"] = iif - self.filters["bool"] = forgiving_boolean - self.filters["version"] = version - self.filters["contains"] = contains - self.filters["shuffle"] = shuffle - self.filters["typeof"] = typeof - self.filters["flatten"] = flatten - self.filters["md5"] = md5 - self.filters["sha1"] = sha1 - self.filters["sha256"] = sha256 - self.filters["sha512"] = sha512 - self.filters["combine"] = combine - self.globals["log"] = logarithm - self.globals["sin"] = sine - self.globals["cos"] = cosine - self.globals["tan"] = tangent - self.globals["sqrt"] = square_root - self.globals["pi"] = math.pi - self.globals["tau"] = math.pi * 2 - self.globals["e"] = math.e - self.globals["asin"] = arc_sine + self.globals["acos"] = arc_cosine - self.globals["atan"] = arc_tangent - self.globals["atan2"] = arc_tangent2 - self.globals["float"] = forgiving_float self.globals["as_datetime"] = as_datetime self.globals["as_local"] = dt_util.as_local self.globals["as_timedelta"] = as_timedelta self.globals["as_timestamp"] = forgiving_as_timestamp - self.globals["timedelta"] = timedelta - self.globals["merge_response"] = merge_response - self.globals["strptime"] = strptime - self.globals["urlencode"] = urlencode + self.globals["asin"] = arc_sine + self.globals["atan"] = arc_tangent + self.globals["atan2"] = arc_tangent2 self.globals["average"] = average - self.globals["median"] = median - self.globals["statistical_mode"] = statistical_mode - self.globals["max"] = min_max_from_filter(self.filters["max"], "max") - self.globals["min"] = min_max_from_filter(self.filters["min"], "min") - self.globals["is_number"] = is_number - self.globals["set"] = _to_set - self.globals["tuple"] = _to_tuple - self.globals["int"] = forgiving_int - self.globals["pack"] = struct_pack - self.globals["unpack"] = struct_unpack - self.globals["slugify"] = slugify - self.globals["iif"] = iif self.globals["bool"] = forgiving_boolean - self.globals["version"] = version - self.globals["zip"] = zip - self.globals["shuffle"] = shuffle - self.globals["typeof"] = typeof + self.globals["combine"] = combine + self.globals["cos"] = cosine + self.globals["e"] = math.e self.globals["flatten"] = flatten + self.globals["float"] = forgiving_float + self.globals["iif"] = iif + self.globals["int"] = forgiving_int + self.globals["is_number"] = is_number + self.globals["log"] = logarithm + self.globals["max"] = min_max_from_filter(self.filters["max"], "max") self.globals["md5"] = md5 + self.globals["median"] = median + self.globals["merge_response"] = merge_response + self.globals["min"] = min_max_from_filter(self.filters["min"], "min") + self.globals["pack"] = struct_pack + self.globals["pi"] = math.pi + self.globals["set"] = _to_set self.globals["sha1"] = sha1 self.globals["sha256"] = sha256 self.globals["sha512"] = sha512 - self.globals["combine"] = combine + self.globals["shuffle"] = shuffle + self.globals["sin"] = sine + self.globals["slugify"] = slugify + self.globals["sqrt"] = square_root + self.globals["statistical_mode"] = statistical_mode + self.globals["strptime"] = strptime + self.globals["tan"] = tangent + self.globals["tau"] = math.pi * 2 + self.globals["timedelta"] = timedelta + self.globals["tuple"] = _to_tuple + self.globals["typeof"] = typeof + self.globals["unpack"] = struct_unpack + self.globals["urlencode"] = urlencode + self.globals["version"] = version + self.globals["zip"] = zip + + self.filters["acos"] = arc_cosine + self.filters["add"] = add + self.filters["as_datetime"] = as_datetime + self.filters["as_local"] = dt_util.as_local + self.filters["as_timedelta"] = as_timedelta + self.filters["as_timestamp"] = forgiving_as_timestamp + self.filters["asin"] = arc_sine + self.filters["atan"] = arc_tangent + self.filters["atan2"] = arc_tangent2 + self.filters["average"] = average + self.filters["base64_decode"] = base64_decode + self.filters["base64_encode"] = base64_encode + self.filters["bitwise_and"] = bitwise_and + self.filters["bitwise_or"] = bitwise_or + self.filters["bitwise_xor"] = bitwise_xor + self.filters["bool"] = forgiving_boolean + self.filters["combine"] = combine + self.filters["contains"] = contains + self.filters["cos"] = cosine + self.filters["flatten"] = flatten + self.filters["float"] = forgiving_float_filter + self.filters["from_json"] = from_json + self.filters["iif"] = iif + self.filters["int"] = forgiving_int_filter + self.filters["is_defined"] = fail_when_undefined + self.filters["is_number"] = is_number + self.filters["log"] = logarithm + self.filters["md5"] = md5 + self.filters["median"] = median + self.filters["multiply"] = multiply + self.filters["ord"] = ord + self.filters["ordinal"] = ordinal + self.filters["pack"] = struct_pack + self.filters["random"] = random_every_time + self.filters["regex_findall_index"] = regex_findall_index + self.filters["regex_findall"] = regex_findall + self.filters["regex_match"] = regex_match + self.filters["regex_replace"] = regex_replace + self.filters["regex_search"] = regex_search + self.filters["round"] = forgiving_round + self.filters["sha1"] = sha1 + self.filters["sha256"] = sha256 + self.filters["sha512"] = sha512 + self.filters["shuffle"] = shuffle + self.filters["sin"] = sine + self.filters["slugify"] = slugify + self.filters["sqrt"] = square_root + self.filters["statistical_mode"] = statistical_mode + self.filters["tan"] = tangent + self.filters["timestamp_custom"] = timestamp_custom + self.filters["timestamp_local"] = timestamp_local + self.filters["timestamp_utc"] = timestamp_utc + self.filters["to_json"] = to_json + self.filters["typeof"] = typeof + self.filters["unpack"] = struct_unpack + self.filters["version"] = version + + self.tests["contains"] = contains + self.tests["datetime"] = _is_datetime self.tests["is_number"] = is_number self.tests["list"] = _is_list - self.tests["set"] = _is_set - self.tests["tuple"] = _is_tuple - self.tests["datetime"] = _is_datetime - self.tests["string_like"] = _is_string_like self.tests["match"] = regex_match self.tests["search"] = regex_search - self.tests["contains"] = contains + self.tests["set"] = _is_set + self.tests["string_like"] = _is_string_like + self.tests["tuple"] = _is_tuple if hass is None: return @@ -3119,28 +3122,7 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): return jinja_context(wrapper) - self.globals["device_entities"] = hassfunction(device_entities) - self.filters["device_entities"] = self.globals["device_entities"] - - self.globals["device_attr"] = hassfunction(device_attr) - self.filters["device_attr"] = self.globals["device_attr"] - - self.globals["config_entry_attr"] = hassfunction(config_entry_attr) - self.filters["config_entry_attr"] = self.globals["config_entry_attr"] - - self.globals["is_device_attr"] = hassfunction(is_device_attr) - self.tests["is_device_attr"] = hassfunction(is_device_attr, pass_eval_context) - - self.globals["config_entry_id"] = hassfunction(config_entry_id) - self.filters["config_entry_id"] = self.globals["config_entry_id"] - - self.globals["device_id"] = hassfunction(device_id) - self.filters["device_id"] = self.globals["device_id"] - - self.globals["issues"] = hassfunction(issues) - - self.globals["issue"] = hassfunction(issue) - self.filters["issue"] = self.globals["issue"] + # Area extensions self.globals["areas"] = hassfunction(areas) @@ -3156,6 +3138,8 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): self.globals["area_devices"] = hassfunction(area_devices) self.filters["area_devices"] = self.globals["area_devices"] + # Floor extensions + self.globals["floors"] = hassfunction(floors) self.filters["floors"] = self.globals["floors"] @@ -3171,9 +3155,35 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): self.globals["floor_entities"] = hassfunction(floor_entities) self.filters["floor_entities"] = self.globals["floor_entities"] + # Integration extensions + self.globals["integration_entities"] = hassfunction(integration_entities) self.filters["integration_entities"] = self.globals["integration_entities"] + # Config entry extensions + + self.globals["config_entry_attr"] = hassfunction(config_entry_attr) + self.filters["config_entry_attr"] = self.globals["config_entry_attr"] + + self.globals["config_entry_id"] = hassfunction(config_entry_id) + self.filters["config_entry_id"] = self.globals["config_entry_id"] + + # Device extensions + + self.globals["device_attr"] = hassfunction(device_attr) + self.filters["device_attr"] = self.globals["device_attr"] + + self.globals["device_entities"] = hassfunction(device_entities) + self.filters["device_entities"] = self.globals["device_entities"] + + self.globals["is_device_attr"] = hassfunction(is_device_attr) + self.tests["is_device_attr"] = hassfunction(is_device_attr, pass_eval_context) + + self.globals["device_id"] = hassfunction(device_id) + self.filters["device_id"] = self.globals["device_id"] + + # Label extensions + self.globals["labels"] = hassfunction(labels) self.filters["labels"] = self.globals["labels"] @@ -3192,6 +3202,12 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): self.globals["label_entities"] = hassfunction(label_entities) self.filters["label_entities"] = self.globals["label_entities"] + # Issue extensions + + self.globals["issues"] = hassfunction(issues) + self.globals["issue"] = hassfunction(issue) + self.filters["issue"] = self.globals["issue"] + if limited: # Only device_entities is available to limited templates, mark other # functions and filters as unsupported. @@ -3204,38 +3220,38 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): return warn_unsupported hass_globals = [ - "closest", - "distance", - "expand", - "is_hidden_entity", - "is_state", - "is_state_attr", - "state_attr", - "states", - "state_translated", - "has_value", - "utcnow", - "now", - "device_attr", - "is_device_attr", - "device_id", "area_id", "area_name", + "closest", + "device_attr", + "device_id", + "distance", + "expand", "floor_id", "floor_name", + "has_value", + "is_device_attr", + "is_hidden_entity", + "is_state_attr", + "is_state", + "label_id", + "label_name", + "now", "relative_time", + "state_attr", + "state_translated", + "states", "time_since", "time_until", "today_at", - "label_id", - "label_name", + "utcnow", ] hass_filters = [ - "closest", - "expand", - "device_id", "area_id", "area_name", + "closest", + "device_id", + "expand", "floor_id", "floor_name", "has_value", @@ -3245,8 +3261,8 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): hass_tests = [ "has_value", "is_hidden_entity", - "is_state", "is_state_attr", + "is_state", ] for glob in hass_globals: self.globals[glob] = unsupported(glob) @@ -3256,38 +3272,46 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): self.filters[test] = unsupported(test) return - self.globals["expand"] = hassfunction(expand) - self.filters["expand"] = self.globals["expand"] self.globals["closest"] = hassfunction(closest) - self.filters["closest"] = hassfunction(closest_filter) self.globals["distance"] = hassfunction(distance) + self.globals["expand"] = hassfunction(expand) + self.globals["has_value"] = hassfunction(has_value) + self.globals["now"] = hassfunction(now) + self.globals["relative_time"] = hassfunction(relative_time) + self.globals["time_since"] = hassfunction(time_since) + self.globals["time_until"] = hassfunction(time_until) + self.globals["today_at"] = hassfunction(today_at) + self.globals["utcnow"] = hassfunction(utcnow) + + self.filters["closest"] = hassfunction(closest_filter) + self.filters["expand"] = self.globals["expand"] + self.filters["has_value"] = self.globals["has_value"] + self.filters["relative_time"] = self.globals["relative_time"] + self.filters["time_since"] = self.globals["time_since"] + self.filters["time_until"] = self.globals["time_until"] + self.filters["today_at"] = self.globals["today_at"] + + self.tests["has_value"] = hassfunction(has_value, pass_eval_context) + + # Entity extensions + self.globals["is_hidden_entity"] = hassfunction(is_hidden_entity) self.tests["is_hidden_entity"] = hassfunction( is_hidden_entity, pass_eval_context ) - self.globals["is_state"] = hassfunction(is_state) - self.tests["is_state"] = hassfunction(is_state, pass_eval_context) + + # State extensions + self.globals["is_state_attr"] = hassfunction(is_state_attr) - self.tests["is_state_attr"] = hassfunction(is_state_attr, pass_eval_context) + self.globals["is_state"] = hassfunction(is_state) self.globals["state_attr"] = hassfunction(state_attr) - self.filters["state_attr"] = self.globals["state_attr"] - self.globals["states"] = AllStates(hass) - self.filters["states"] = self.globals["states"] self.globals["state_translated"] = StateTranslated(hass) + self.globals["states"] = AllStates(hass) + self.filters["state_attr"] = self.globals["state_attr"] self.filters["state_translated"] = self.globals["state_translated"] - self.globals["has_value"] = hassfunction(has_value) - self.filters["has_value"] = self.globals["has_value"] - self.tests["has_value"] = hassfunction(has_value, pass_eval_context) - self.globals["utcnow"] = hassfunction(utcnow) - self.globals["now"] = hassfunction(now) - self.globals["relative_time"] = hassfunction(relative_time) - self.filters["relative_time"] = self.globals["relative_time"] - self.globals["time_since"] = hassfunction(time_since) - self.filters["time_since"] = self.globals["time_since"] - self.globals["time_until"] = hassfunction(time_until) - self.filters["time_until"] = self.globals["time_until"] - self.globals["today_at"] = hassfunction(today_at) - self.filters["today_at"] = self.globals["today_at"] + self.filters["states"] = self.globals["states"] + self.tests["is_state_attr"] = hassfunction(is_state_attr, pass_eval_context) + self.tests["is_state"] = hassfunction(is_state, pass_eval_context) def is_safe_callable(self, obj): """Test if callback is safe.""" From a20601a1f07146358ff55fa3fef3e4f11e132241 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Thu, 20 Mar 2025 11:39:57 +0100 Subject: [PATCH 2778/3148] Bump reolink-aio to 0.12.3 (#140789) * Add password length restriction * Bump reolink-aio to 0.12.3 * Add repair issue for too long password * finish password too long repair issue * add test --- homeassistant/components/reolink/__init__.py | 4 +- homeassistant/components/reolink/host.py | 34 ++++++++++---- .../components/reolink/manifest.json | 2 +- homeassistant/components/reolink/strings.json | 6 ++- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/reolink/test_init.py | 47 +++++++++++++++++++ 7 files changed, 82 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/reolink/__init__.py b/homeassistant/components/reolink/__init__.py index 2489133841a..99ca91c5bdf 100644 --- a/homeassistant/components/reolink/__init__.py +++ b/homeassistant/components/reolink/__init__.py @@ -67,9 +67,7 @@ async def async_setup_entry( hass: HomeAssistant, config_entry: ReolinkConfigEntry ) -> bool: """Set up Reolink from a config entry.""" - host = ReolinkHost( - hass, config_entry.data, config_entry.options, config_entry.entry_id - ) + host = ReolinkHost(hass, config_entry.data, config_entry.options, config_entry) try: await host.async_init() diff --git a/homeassistant/components/reolink/host.py b/homeassistant/components/reolink/host.py index 53061500e32..a027177f1fc 100644 --- a/homeassistant/components/reolink/host.py +++ b/homeassistant/components/reolink/host.py @@ -41,7 +41,7 @@ from .exceptions import ( ReolinkWebhookException, UserNotAdmin, ) -from .util import get_store +from .util import ReolinkConfigEntry, get_store DEFAULT_TIMEOUT = 30 FIRST_TCP_PUSH_TIMEOUT = 10 @@ -67,11 +67,11 @@ class ReolinkHost: hass: HomeAssistant, config: Mapping[str, Any], options: Mapping[str, Any], - config_entry_id: str | None = None, + config_entry: ReolinkConfigEntry | None = None, ) -> None: """Initialize Reolink Host. Could be either NVR, or Camera.""" self._hass: HomeAssistant = hass - self._config_entry_id = config_entry_id + self._config_entry = config_entry self._config = config self._unique_id: str = "" @@ -151,15 +151,33 @@ class ReolinkHost: async def async_init(self) -> None: """Connect to Reolink host.""" if not self._api.valid_password(): + if ( + len(self._config[CONF_PASSWORD]) >= 32 + and self._config_entry is not None + ): + ir.async_create_issue( + self._hass, + DOMAIN, + f"password_too_long_{self._config_entry.entry_id}", + is_fixable=True, + severity=ir.IssueSeverity.ERROR, + translation_key="password_too_long", + translation_placeholders={"name": self._config_entry.title}, + ) + raise PasswordIncompatible( - "Reolink password contains incompatible special character, " - "please change the password to only contain characters: " - f"a-z, A-Z, 0-9 or {ALLOWED_SPECIAL_CHARS}" + "Reolink password contains incompatible special character or " + "is too long, please change the password to only contain characters: " + f"a-z, A-Z, 0-9 or {ALLOWED_SPECIAL_CHARS} " + "and not be longer than 31 characters" ) store: Store[str] | None = None - if self._config_entry_id is not None: - store = get_store(self._hass, self._config_entry_id) + if self._config_entry is not None: + ir.async_delete_issue( + self._hass, DOMAIN, f"password_too_long_{self._config_entry.entry_id}" + ) + store = get_store(self._hass, self._config_entry.entry_id) if self._config.get(CONF_SUPPORTS_PRIVACY_MODE) and ( data := await store.async_load() ): diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json index 0cb5eb3e13c..41cfe1f9ae3 100644 --- a/homeassistant/components/reolink/manifest.json +++ b/homeassistant/components/reolink/manifest.json @@ -19,5 +19,5 @@ "iot_class": "local_push", "loggers": ["reolink_aio"], "quality_scale": "platinum", - "requirements": ["reolink-aio==0.12.3b1"] + "requirements": ["reolink-aio==0.12.3"] } diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index 53df658239c..74823c4bd32 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -31,7 +31,7 @@ "cannot_connect": "Failed to connect, check the IP address of the camera", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "not_admin": "User needs to be admin, user \"{username}\" has authorisation level \"{userlevel}\"", - "password_incompatible": "Password contains incompatible special character, only these characters are allowed: a-z, A-Z, 0-9 or {special_chars}", + "password_incompatible": "Password contains incompatible special character or is too long, maximum 31 characters and only these characters are allowed: a-z, A-Z, 0-9 or {special_chars}", "unknown": "[%key:common::config_flow::error::unknown%]", "update_needed": "Failed to login because of outdated firmware, please update the firmware to version {needed_firmware} using the Reolink Download Center: {download_center_url}, currently version {current_firmware} is installed", "webhook_exception": "Home Assistant URL is not available, go to Settings > System > Network > Home Assistant URL and correct the URLs, see {more_info}" @@ -129,6 +129,10 @@ "hub_switch_deprecated": { "title": "Reolink Home Hub switches deprecated", "description": "The redundant 'Record', 'Email on event', 'FTP upload', 'Push notifications', and 'Buzzer on event' switches on the Reolink Home Hub are deprecated since the new firmware no longer supports these. Please use the equally named switches under each of the camera devices connected to the Home Hub instead. To remove this issue, please adjust automations accordingly and disable the switch entities mentioned." + }, + "password_too_long": { + "title": "Reolink password too long", + "description": "The password for \"{name}\" is more than 31 characters long, this is no longer compatible with the Reolink API. Please change the password using the Reolink app/client to a password with is shorter than 32 characters. After changing the password, fill in the new password in the Reolink Re-authentication flow to continue using this integration. The latest version of the Reolink app/client also has a password limit of 31 characters." } }, "services": { diff --git a/requirements_all.txt b/requirements_all.txt index f4e20f563a1..9848158a10e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2622,7 +2622,7 @@ renault-api==0.2.9 renson-endura-delta==1.7.2 # homeassistant.components.reolink -reolink-aio==0.12.3b1 +reolink-aio==0.12.3 # homeassistant.components.idteck_prox rfk101py==0.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b4435e22827..cc2b8acc214 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2122,7 +2122,7 @@ renault-api==0.2.9 renson-endura-delta==1.7.2 # homeassistant.components.reolink -reolink-aio==0.12.3b1 +reolink-aio==0.12.3 # homeassistant.components.rflink rflink==0.0.66 diff --git a/tests/components/reolink/test_init.py b/tests/components/reolink/test_init.py index ad7f5540b04..4c4908dca6f 100644 --- a/tests/components/reolink/test_init.py +++ b/tests/components/reolink/test_init.py @@ -22,7 +22,11 @@ from homeassistant.components.reolink import ( from homeassistant.components.reolink.const import CONF_BC_PORT, DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, CONF_PORT, + CONF_PROTOCOL, + CONF_USERNAME, STATE_OFF, STATE_ON, STATE_UNAVAILABLE, @@ -35,17 +39,25 @@ from homeassistant.helpers import ( entity_registry as er, issue_registry as ir, ) +from homeassistant.helpers.device_registry import format_mac from homeassistant.setup import async_setup_component from .conftest import ( + CONF_SUPPORTS_PRIVACY_MODE, + CONF_USE_HTTPS, + DEFAULT_PROTOCOL, TEST_BC_PORT, TEST_CAM_MODEL, + TEST_HOST, TEST_HOST_MODEL, TEST_MAC, TEST_NVR_NAME, TEST_PORT, + TEST_PRIVACY, TEST_UID, TEST_UID_CAM, + TEST_USE_HTTPS, + TEST_USERNAME, ) from tests.common import MockConfigEntry, async_fire_time_changed @@ -723,6 +735,41 @@ async def test_firmware_repair_issue( await hass.async_block_till_done() assert (DOMAIN, "firmware_update_host") in issue_registry.issues + reolink_connect.camera_sw_version_update_required.return_value = False + + +async def test_password_too_long_repair_issue( + hass: HomeAssistant, + reolink_connect: MagicMock, + issue_registry: ir.IssueRegistry, +) -> None: + """Test password too long issue is raised.""" + reolink_connect.valid_password.return_value = False + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id=format_mac(TEST_MAC), + data={ + CONF_HOST: TEST_HOST, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: "too_longgggggggggggggggggggggggggggggggggggggggggggggggggg", + CONF_PORT: TEST_PORT, + CONF_USE_HTTPS: TEST_USE_HTTPS, + CONF_SUPPORTS_PRIVACY_MODE: TEST_PRIVACY, + }, + options={ + CONF_PROTOCOL: DEFAULT_PROTOCOL, + }, + title=TEST_NVR_NAME, + ) + config_entry.add_to_hass(hass) + assert not await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert ( + DOMAIN, + f"password_too_long_{config_entry.entry_id}", + ) in issue_registry.issues + reolink_connect.valid_password.return_value = True async def test_new_device_discovered( From d8a4a97ee01de330b4c1ac33e1d150c54b89922c Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Thu, 20 Mar 2025 12:19:14 +0100 Subject: [PATCH 2779/3148] Allow patching Z-Wave platforms specifically in tests (#140987) --- tests/components/zwave_js/conftest.py | 14 ++++++++++++-- tests/components/zwave_js/test_siren.py | 9 ++++++++- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/tests/components/zwave_js/conftest.py b/tests/components/zwave_js/conftest.py index 1917ebedd34..ce7b0e0109e 100644 --- a/tests/components/zwave_js/conftest.py +++ b/tests/components/zwave_js/conftest.py @@ -13,7 +13,9 @@ from zwave_js_server.model.node import Node from zwave_js_server.model.node.data_model import NodeDataType from zwave_js_server.version import VersionInfo +from homeassistant.components.zwave_js import PLATFORMS from homeassistant.components.zwave_js.const import DOMAIN +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.util.json import JsonArrayType @@ -828,18 +830,26 @@ def nortek_thermostat_removed_event_fixture(client) -> Node: async def integration_fixture( hass: HomeAssistant, client: MagicMock, + platforms: list[Platform], ) -> MockConfigEntry: """Set up the zwave_js integration.""" entry = MockConfigEntry(domain="zwave_js", data={"url": "ws://test.org"}) entry.add_to_hass(hass) - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + with patch("homeassistant.components.zwave_js.PLATFORMS", platforms): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() client.async_send_command.reset_mock() return entry +@pytest.fixture +def platforms() -> list[Platform]: + """Fixture to specify platforms to test.""" + return PLATFORMS + + @pytest.fixture(name="chain_actuator_zws12") def window_cover_fixture(client, chain_actuator_zws12_state) -> Node: """Mock a window cover node.""" diff --git a/tests/components/zwave_js/test_siren.py b/tests/components/zwave_js/test_siren.py index 4eb872954d1..d932338f9dc 100644 --- a/tests/components/zwave_js/test_siren.py +++ b/tests/components/zwave_js/test_siren.py @@ -1,5 +1,6 @@ """Test the Z-Wave JS siren platform.""" +import pytest from zwave_js_server.event import Event from homeassistant.components.siren import ( @@ -7,7 +8,7 @@ from homeassistant.components.siren import ( ATTR_TONE, ATTR_VOLUME_LEVEL, ) -from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNKNOWN +from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant SIREN_ENTITY = "siren.indoor_siren_6_play_tone_2" @@ -64,6 +65,12 @@ TONE_ID_VALUE_ID = { } +@pytest.fixture +def platforms() -> list[str]: + """Fixture to specify platforms to test.""" + return [Platform.SIREN] + + async def test_siren( hass: HomeAssistant, client, aeotec_zw164_siren, integration ) -> None: From df0125abdd6672087dcdb41164b74e8c0decd96f Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Thu, 20 Mar 2025 12:54:40 +0100 Subject: [PATCH 2780/3148] Patch Z-Wave platforms in api tests (#140988) --- tests/components/zwave_js/test_api.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/components/zwave_js/test_api.py b/tests/components/zwave_js/test_api.py index 62e7f25bc08..f0134c7c43c 100644 --- a/tests/components/zwave_js/test_api.py +++ b/tests/components/zwave_js/test_api.py @@ -103,6 +103,12 @@ from tests.typing import ClientSessionGenerator, WebSocketGenerator CONTROLLER_PATCH_PREFIX = "zwave_js_server.model.controller.Controller" +@pytest.fixture +def platforms() -> list[str]: + """Fixture to specify platforms to test.""" + return [] + + def get_device(hass: HomeAssistant, node): """Get device ID for a node.""" dev_reg = dr.async_get(hass) From c9b27cf26e3662600abda73aaaadfc67291f7900 Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Thu, 20 Mar 2025 12:56:45 +0100 Subject: [PATCH 2781/3148] Detect early base platforms in bootstrap (#140359) * Detect early base platforms in bootstrap * Address feedback * Address feedback --- tests/test_bootstrap.py | 79 +++++++++++++++++++++++++++-------------- 1 file changed, 53 insertions(+), 26 deletions(-) diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index 050963316dc..1fb87ac5ef6 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -1546,41 +1546,68 @@ def test_should_rollover_is_always_false() -> None: async def test_no_base_platforms_loaded_before_recorder(hass: HomeAssistant) -> None: """Verify stage 0 not load base platforms before recorder. - If a stage 0 integration has a base platform in its dependencies and - it loads before the recorder, it may load integrations that expect - the recorder to be loaded. We need to ensure that no stage 0 integration - has a base platform in its dependencies that loads before the recorder. + If a stage 0 integration implements base platforms or has a base + platform in its dependencies and it loads before the recorder, + because of platform-based YAML schema, it may inadvertently + load integrations that expect the recorder to already be loaded. + We need to ensure that doesn't happen. """ + IGNORE_BASE_PLATFORM_FILES = { + # config/scene.py is not a platform + "config": {"scene.py"}, + # websocket_api/sensor.py is using the platform YAML schema + # we must not migrate it to an integration key until + # we remove the platform YAML schema support for sensors + "websocket_api": {"sensor.py"}, + } + integrations_before_recorder: set[str] = set() for _, integrations, _ in bootstrap.STAGE_0_INTEGRATIONS: integrations_before_recorder |= integrations if "recorder" in integrations: break + else: + pytest.fail("recorder not in stage 0") - integrations_or_execs = await loader.async_get_integrations( + integrations_or_excs = await loader.async_get_integrations( hass, integrations_before_recorder ) - integrations: list[Integration] = [] - resolve_deps_tasks: list[asyncio.Task[bool]] = [] - for integration in integrations_or_execs.values(): - assert not isinstance(integrations_or_execs, Exception) - integrations.append(integration) - resolve_deps_tasks.append(integration.resolve_dependencies()) + integrations: dict[str, Integration] = {} + for domain, integration in integrations_or_excs.items(): + assert not isinstance(integrations_or_excs, Exception) + integrations[domain] = integration + + integrations_all_dependencies = await loader.resolve_integrations_dependencies( + hass, integrations.values() + ) + all_integrations = integrations.copy() + all_integrations.update( + (domain, loader.async_get_loaded_integration(hass, domain)) + for domains in integrations_all_dependencies.values() + for domain in domains + ) + + problems: dict[str, set[str]] = {} + for domain in integrations: + domain_with_base_platforms_deps = ( + integrations_all_dependencies[domain] & BASE_PLATFORMS + ) + if domain_with_base_platforms_deps: + problems[domain] = domain_with_base_platforms_deps + assert not problems, ( + f"Integrations that are setup before recorder have base platforms in their dependencies: {problems}" + ) - await asyncio.gather(*resolve_deps_tasks) base_platform_py_files = {f"{base_platform}.py" for base_platform in BASE_PLATFORMS} - for integration in integrations: - domain_with_base_platforms_deps = BASE_PLATFORMS.intersection( - integration.all_dependencies - ) - assert not domain_with_base_platforms_deps, ( - f"{integration.domain} has base platforms in dependencies: " - f"{domain_with_base_platforms_deps}" - ) - integration_top_level_files = base_platform_py_files.intersection( - integration._top_level_files - ) - assert not integration_top_level_files, ( - f"{integration.domain} has base platform files in top level files: " - f"{integration_top_level_files}" + + for domain, integration in all_integrations.items(): + integration_base_platforms_files = ( + integration._top_level_files & base_platform_py_files ) + if ignore := IGNORE_BASE_PLATFORM_FILES.get(domain): + integration_base_platforms_files -= ignore + if integration_base_platforms_files: + problems[domain] = integration_base_platforms_files + assert not problems, ( + f"Integrations that are setup before recorder implement base platforms: {problems}" + ) From 5f84fc3ee593fdd5ce2eebe37d23956cbbedbf3a Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Thu, 20 Mar 2025 15:06:21 +0100 Subject: [PATCH 2782/3148] Patch Z-Wave platforms in binary sensor tests (#140992) --- tests/components/zwave_js/test_binary_sensor.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/components/zwave_js/test_binary_sensor.py b/tests/components/zwave_js/test_binary_sensor.py index 0054439ef1d..657dd337bf9 100644 --- a/tests/components/zwave_js/test_binary_sensor.py +++ b/tests/components/zwave_js/test_binary_sensor.py @@ -1,5 +1,6 @@ """Test the Z-Wave JS binary sensor platform.""" +import pytest from zwave_js_server.event import Event from zwave_js_server.model.node import Node @@ -10,6 +11,7 @@ from homeassistant.const import ( STATE_ON, STATE_UNKNOWN, EntityCategory, + Platform, ) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -26,6 +28,12 @@ from .common import ( from tests.common import MockConfigEntry +@pytest.fixture +def platforms() -> list[str]: + """Fixture to specify platforms to test.""" + return [Platform.BINARY_SENSOR] + + async def test_low_battery_sensor( hass: HomeAssistant, entity_registry: er.EntityRegistry, multisensor_6, integration ) -> None: From 212d39ba19c3c374514b1d44f910546b5ac444d6 Mon Sep 17 00:00:00 2001 From: Petro31 <35082313+Petro31@users.noreply.github.com> Date: Thu, 20 Mar 2025 10:12:43 -0400 Subject: [PATCH 2783/3148] Migrate template switch to new style (#140324) * Migrate template switch to new style * update tests * Update tests * Add config flow migration * comment fixes * revert entity config migration --- homeassistant/components/template/config.py | 9 +- homeassistant/components/template/switch.py | 124 +- .../template/snapshots/test_switch.ambr | 15 +- tests/components/template/test_config_flow.py | 30 + tests/components/template/test_switch.py | 1090 ++++++++++------- 5 files changed, 798 insertions(+), 470 deletions(-) diff --git a/homeassistant/components/template/config.py b/homeassistant/components/template/config.py index 07c3c1b437f..4e07d67f6e9 100644 --- a/homeassistant/components/template/config.py +++ b/homeassistant/components/template/config.py @@ -17,6 +17,7 @@ from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN from homeassistant.components.select import DOMAIN as SELECT_DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.components.weather import DOMAIN as WEATHER_DOMAIN from homeassistant.config import async_log_schema_error, config_without_domain from homeassistant.const import ( @@ -41,6 +42,7 @@ from . import ( number as number_platform, select as select_platform, sensor as sensor_platform, + switch as switch_platform, weather as weather_platform, ) from .const import ( @@ -112,8 +114,13 @@ CONFIG_SECTION_SCHEMA = vol.Schema( vol.Optional(WEATHER_DOMAIN): vol.All( cv.ensure_list, [weather_platform.WEATHER_SCHEMA] ), + vol.Optional(SWITCH_DOMAIN): vol.All( + cv.ensure_list, [switch_platform.SWITCH_SCHEMA] + ), }, - ensure_domains_do_not_have_trigger_or_action(BUTTON_DOMAIN, LIGHT_DOMAIN), + ensure_domains_do_not_have_trigger_or_action( + BUTTON_DOMAIN, LIGHT_DOMAIN, SWITCH_DOMAIN + ), ) ) diff --git a/homeassistant/components/template/switch.py b/homeassistant/components/template/switch.py index feaabc3b17c..b76fc28b83c 100644 --- a/homeassistant/components/template/switch.py +++ b/homeassistant/components/template/switch.py @@ -17,6 +17,7 @@ from homeassistant.const import ( ATTR_FRIENDLY_NAME, CONF_DEVICE_ID, CONF_NAME, + CONF_STATE, CONF_SWITCHES, CONF_UNIQUE_ID, CONF_VALUE_TEMPLATE, @@ -25,7 +26,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import TemplateError -from homeassistant.helpers import config_validation as cv, selector +from homeassistant.helpers import config_validation as cv, selector, template from homeassistant.helpers.device import async_device_info_to_link_from_device_id from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.entity_platform import ( @@ -35,16 +36,41 @@ from homeassistant.helpers.entity_platform import ( from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .const import CONF_TURN_OFF, CONF_TURN_ON, DOMAIN +from .const import CONF_OBJECT_ID, CONF_PICTURE, CONF_TURN_OFF, CONF_TURN_ON, DOMAIN from .template_entity import ( + LEGACY_FIELDS as TEMPLATE_ENTITY_LEGACY_FIELDS, + TEMPLATE_ENTITY_AVAILABILITY_SCHEMA, TEMPLATE_ENTITY_COMMON_SCHEMA_LEGACY, + TEMPLATE_ENTITY_ICON_SCHEMA, TemplateEntity, rewrite_common_legacy_to_modern_conf, ) _VALID_STATES = [STATE_ON, STATE_OFF, "true", "false"] -SWITCH_SCHEMA = vol.All( +LEGACY_FIELDS = TEMPLATE_ENTITY_LEGACY_FIELDS | { + CONF_VALUE_TEMPLATE: CONF_STATE, +} + +DEFAULT_NAME = "Template Switch" + + +SWITCH_SCHEMA = ( + vol.Schema( + { + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.template, + vol.Optional(CONF_STATE): cv.template, + vol.Required(CONF_TURN_ON): cv.SCRIPT_SCHEMA, + vol.Required(CONF_TURN_OFF): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_UNIQUE_ID): cv.string, + vol.Optional(CONF_PICTURE): cv.template, + } + ) + .extend(TEMPLATE_ENTITY_AVAILABILITY_SCHEMA.schema) + .extend(TEMPLATE_ENTITY_ICON_SCHEMA.schema) +) + +LEGACY_SWITCH_SCHEMA = vol.All( cv.deprecated(ATTR_ENTITY_ID), vol.Schema( { @@ -59,13 +85,13 @@ SWITCH_SCHEMA = vol.All( ) PLATFORM_SCHEMA = SWITCH_PLATFORM_SCHEMA.extend( - {vol.Required(CONF_SWITCHES): cv.schema_with_slug_keys(SWITCH_SCHEMA)} + {vol.Required(CONF_SWITCHES): cv.schema_with_slug_keys(LEGACY_SWITCH_SCHEMA)} ) SWITCH_CONFIG_SCHEMA = vol.Schema( { vol.Required(CONF_NAME): cv.template, - vol.Optional(CONF_VALUE_TEMPLATE): cv.template, + vol.Optional(CONF_STATE): cv.template, vol.Optional(CONF_TURN_ON): cv.SCRIPT_SCHEMA, vol.Optional(CONF_TURN_OFF): cv.SCRIPT_SCHEMA, vol.Optional(CONF_DEVICE_ID): selector.DeviceSelector(), @@ -73,24 +99,62 @@ SWITCH_CONFIG_SCHEMA = vol.Schema( ) -async def _async_create_entities(hass: HomeAssistant, config: ConfigType): - """Create the Template switches.""" +def rewrite_legacy_to_modern_conf( + hass: HomeAssistant, config: dict[str, dict] +) -> list[dict]: + """Rewrite legacy switch configuration definitions to modern ones.""" switches = [] - for object_id, entity_config in config[CONF_SWITCHES].items(): - entity_config = rewrite_common_legacy_to_modern_conf(hass, entity_config) - unique_id = entity_config.get(CONF_UNIQUE_ID) + for object_id, entity_conf in config.items(): + entity_conf = {**entity_conf, CONF_OBJECT_ID: object_id} + + entity_conf = rewrite_common_legacy_to_modern_conf( + hass, entity_conf, LEGACY_FIELDS + ) + + if CONF_NAME not in entity_conf: + entity_conf[CONF_NAME] = template.Template(object_id, hass) + + switches.append(entity_conf) + + return switches + + +def rewrite_options_to_moder_conf(option_config: dict[str, dict]) -> dict[str, dict]: + """Rewrite option configuration to modern configuration.""" + option_config = {**option_config} + + if CONF_VALUE_TEMPLATE in option_config: + option_config[CONF_STATE] = option_config.pop(CONF_VALUE_TEMPLATE) + + return option_config + + +@callback +def _async_create_template_tracking_entities( + async_add_entities: AddEntitiesCallback, + hass: HomeAssistant, + definitions: list[dict], + unique_id_prefix: str | None, +) -> None: + """Create the template switches.""" + switches = [] + + for entity_conf in definitions: + unique_id = entity_conf.get(CONF_UNIQUE_ID) + + if unique_id and unique_id_prefix: + unique_id = f"{unique_id_prefix}-{unique_id}" switches.append( SwitchTemplate( hass, - object_id, - entity_config, + entity_conf, unique_id, ) ) - return switches + async_add_entities(switches) async def async_setup_platform( @@ -100,7 +164,21 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the template switches.""" - async_add_entities(await _async_create_entities(hass, config)) + if discovery_info is None: + _async_create_template_tracking_entities( + async_add_entities, + hass, + rewrite_legacy_to_modern_conf(hass, config[CONF_SWITCHES]), + None, + ) + return + + _async_create_template_tracking_entities( + async_add_entities, + hass, + discovery_info["entities"], + discovery_info["unique_id"], + ) async def async_setup_entry( @@ -111,10 +189,9 @@ async def async_setup_entry( """Initialize config entry.""" _options = dict(config_entry.options) _options.pop("template_type") + _options = rewrite_options_to_moder_conf(_options) validated_config = SWITCH_CONFIG_SCHEMA(_options) - async_add_entities( - [SwitchTemplate(hass, None, validated_config, config_entry.entry_id)] - ) + async_add_entities([SwitchTemplate(hass, validated_config, config_entry.entry_id)]) @callback @@ -123,7 +200,7 @@ def async_create_preview_switch( ) -> SwitchTemplate: """Create a preview switch.""" validated_config = SWITCH_CONFIG_SCHEMA(config | {CONF_NAME: name}) - return SwitchTemplate(hass, None, validated_config, None) + return SwitchTemplate(hass, validated_config, None) class SwitchTemplate(TemplateEntity, SwitchEntity, RestoreEntity): @@ -134,22 +211,19 @@ class SwitchTemplate(TemplateEntity, SwitchEntity, RestoreEntity): def __init__( self, hass: HomeAssistant, - object_id, config: ConfigType, - unique_id, + unique_id: str | None, ) -> None: """Initialize the Template switch.""" - super().__init__( - hass, config=config, fallback_name=object_id, unique_id=unique_id - ) - if object_id is not None: + super().__init__(hass, config=config, fallback_name=None, unique_id=unique_id) + if (object_id := config.get(CONF_OBJECT_ID)) is not None: self.entity_id = async_generate_entity_id( ENTITY_ID_FORMAT, object_id, hass=hass ) name = self._attr_name if TYPE_CHECKING: assert name is not None - self._template = config.get(CONF_VALUE_TEMPLATE) + self._template = config.get(CONF_STATE) if on_action := config.get(CONF_TURN_ON): self.add_script(CONF_TURN_ON, on_action, name, DOMAIN) diff --git a/tests/components/template/snapshots/test_switch.ambr b/tests/components/template/snapshots/test_switch.ambr index c240a9436a0..909110fdbc8 100644 --- a/tests/components/template/snapshots/test_switch.ambr +++ b/tests/components/template/snapshots/test_switch.ambr @@ -1,5 +1,18 @@ # serializer version: 1 -# name: test_setup_config_entry +# name: test_setup_config_entry[state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'My template', + }), + 'context': , + 'entity_id': 'switch.my_template', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_setup_config_entry[value_template] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'My template', diff --git a/tests/components/template/test_config_flow.py b/tests/components/template/test_config_flow.py index 2c9b81e7c91..21d740b165b 100644 --- a/tests/components/template/test_config_flow.py +++ b/tests/components/template/test_config_flow.py @@ -16,6 +16,36 @@ from homeassistant.helpers import device_registry as dr from tests.common import MockConfigEntry from tests.typing import WebSocketGenerator +SWITCH_BEFORE_OPTIONS = { + "name": "test_template_switch", + "template_type": "switch", + "turn_off": [{"event": "test_template_switch", "event_data": {"event": "off"}}], + "turn_on": [{"event": "test_template_switch", "event_data": {"event": "on"}}], + "value_template": "{{ now().minute % 2 == 0 }}", +} + + +SWITCH_AFTER_OPTIONS = { + "name": "test_template_switch", + "template_type": "switch", + "turn_off": [{"event": "test_template_switch", "event_data": {"event": "off"}}], + "turn_on": [{"event": "test_template_switch", "event_data": {"event": "on"}}], + "state": "{{ now().minute % 2 == 0 }}", + "value_template": "{{ now().minute % 2 == 0 }}", +} + +SENSOR_OPTIONS = { + "name": "test_template_sensor", + "template_type": "sensor", + "state": "{{ 'a' if now().minute % 2 == 0 else 'b' }}", +} + +BINARY_SENSOR_OPTIONS = { + "name": "test_template_sensor", + "template_type": "binary_sensor", + "state": "{{ now().minute % 2 == 0 else }}", +} + @pytest.mark.parametrize( ( diff --git a/tests/components/template/test_switch.py b/tests/components/template/test_switch.py index 2fc0f29acaf..f0dbe43b51e 100644 --- a/tests/components/template/test_switch.py +++ b/tests/components/template/test_switch.py @@ -1,11 +1,13 @@ """The tests for the Template switch platform.""" +from typing import Any + import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant import setup -from homeassistant.components import template +from homeassistant.components import switch, template from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.components.template.switch import rewrite_legacy_to_modern_conf from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_TURN_OFF, @@ -16,8 +18,11 @@ from homeassistant.const import ( ) from homeassistant.core import CoreState, HomeAssistant, ServiceCall, State from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.template import Template from homeassistant.setup import async_setup_component +from .conftest import ConfigurationStyle + from tests.common import ( MockConfigEntry, assert_setup_component, @@ -25,26 +30,225 @@ from tests.common import ( mock_restore_cache, ) -OPTIMISTIC_SWITCH_CONFIG = { - "turn_on": { - "service": "test.automation", - "data_template": { - "action": "turn_on", - "caller": "{{ this.entity_id }}", - }, +TEST_OBJECT_ID = "test_template_switch" +TEST_ENTITY_ID = f"switch.{TEST_OBJECT_ID}" +TEST_STATE_ENTITY_ID = "switch.test_state" + +SWITCH_TURN_ON = { + "service": "test.automation", + "data_template": { + "action": "turn_on", + "caller": "{{ this.entity_id }}", }, - "turn_off": { - "service": "test.automation", - "data_template": { - "action": "turn_off", - "caller": "{{ this.entity_id }}", - }, +} +SWITCH_TURN_OFF = { + "service": "test.automation", + "data_template": { + "action": "turn_off", + "caller": "{{ this.entity_id }}", }, } +SWITCH_ACTIONS = { + "turn_on": SWITCH_TURN_ON, + "turn_off": SWITCH_TURN_OFF, +} +NAMED_SWITCH_ACTIONS = { + **SWITCH_ACTIONS, + "name": TEST_OBJECT_ID, +} +UNIQUE_ID_CONFIG = { + **SWITCH_ACTIONS, + "unique_id": "not-so-unique-anymore", +} +async def async_setup_legacy_format( + hass: HomeAssistant, count: int, switch_config: dict[str, Any] +) -> None: + """Do setup of switch integration via legacy format.""" + config = {"switch": {"platform": "template", "switches": switch_config}} + + with assert_setup_component(count, switch.DOMAIN): + assert await async_setup_component( + hass, + switch.DOMAIN, + config, + ) + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + +async def async_setup_modern_format( + hass: HomeAssistant, count: int, switch_config: dict[str, Any] +) -> None: + """Do setup of switch integration via modern format.""" + config = {"template": {"switch": switch_config}} + + with assert_setup_component(count, template.DOMAIN): + assert await async_setup_component( + hass, + template.DOMAIN, + config, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + +@pytest.fixture +async def setup_switch( + hass: HomeAssistant, + count: int, + style: ConfigurationStyle, + switch_config: dict[str, Any], +) -> None: + """Do setup of switch integration.""" + if style == ConfigurationStyle.LEGACY: + await async_setup_legacy_format(hass, count, switch_config) + elif style == ConfigurationStyle.MODERN: + await async_setup_modern_format(hass, count, switch_config) + + +@pytest.fixture +async def setup_state_switch( + hass: HomeAssistant, + count: int, + style: ConfigurationStyle, + state_template: str, +): + """Do setup of switch integration using a state template.""" + if style == ConfigurationStyle.LEGACY: + await async_setup_legacy_format( + hass, + count, + { + TEST_OBJECT_ID: { + **SWITCH_ACTIONS, + "value_template": state_template, + } + }, + ) + elif style == ConfigurationStyle.MODERN: + await async_setup_modern_format( + hass, + count, + { + **NAMED_SWITCH_ACTIONS, + "state": state_template, + }, + ) + + +@pytest.fixture +async def setup_single_attribute_switch( + hass: HomeAssistant, + count: int, + style: ConfigurationStyle, + attribute: str, + attribute_template: str, +) -> None: + """Do setup of switch integration testing a single attribute.""" + extra = {attribute: attribute_template} if attribute and attribute_template else {} + if style == ConfigurationStyle.LEGACY: + await async_setup_legacy_format( + hass, + count, + { + TEST_OBJECT_ID: { + **SWITCH_ACTIONS, + "value_template": "{{ 1 == 1 }}", + **extra, + } + }, + ) + elif style == ConfigurationStyle.MODERN: + await async_setup_modern_format( + hass, + count, + { + **NAMED_SWITCH_ACTIONS, + "state": "{{ 1 == 1 }}", + **extra, + }, + ) + + +@pytest.fixture +async def setup_optimistic_switch( + hass: HomeAssistant, + count: int, + style: ConfigurationStyle, +) -> None: + """Do setup of an optimistic switch.""" + if style == ConfigurationStyle.LEGACY: + await async_setup_legacy_format( + hass, + count, + { + TEST_OBJECT_ID: { + **SWITCH_ACTIONS, + } + }, + ) + elif style == ConfigurationStyle.MODERN: + await async_setup_modern_format( + hass, + count, + { + **NAMED_SWITCH_ACTIONS, + }, + ) + + +async def test_legacy_to_modern_config(hass: HomeAssistant) -> None: + """Test the conversion of legacy template to modern template.""" + config = { + "foo": { + "friendly_name": "foo bar", + "value_template": "{{ 1 == 1 }}", + "unique_id": "foo-bar-switch", + "icon_template": "{{ 'mdi.abc' }}", + "entity_picture_template": "{{ 'mypicture.jpg' }}", + "availability_template": "{{ 1 == 1 }}", + **SWITCH_ACTIONS, + } + } + altered_configs = rewrite_legacy_to_modern_conf(hass, config) + + assert len(altered_configs) == 1 + assert [ + { + "availability": Template("{{ 1 == 1 }}", hass), + "icon": Template("{{ 'mdi.abc' }}", hass), + "name": Template("foo bar", hass), + "object_id": "foo", + "picture": Template("{{ 'mypicture.jpg' }}", hass), + "turn_off": SWITCH_TURN_OFF, + "turn_on": SWITCH_TURN_ON, + "unique_id": "foo-bar-switch", + "state": Template("{{ 1 == 1 }}", hass), + } + ] == altered_configs + + +@pytest.mark.parametrize(("count", "state_template"), [(1, "{{ True }}")]) +@pytest.mark.parametrize( + "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] +) +async def test_setup(hass: HomeAssistant, setup_state_switch) -> None: + """Test template.""" + state = hass.states.get(TEST_ENTITY_ID) + assert state is not None + assert state.name == TEST_OBJECT_ID + assert state.state == STATE_ON + + +@pytest.mark.parametrize("state_key", ["value_template", "state"]) async def test_setup_config_entry( hass: HomeAssistant, + state_key: str, snapshot: SnapshotAssertion, ) -> None: """Test the config flow.""" @@ -60,7 +264,7 @@ async def test_setup_config_entry( domain=template.DOMAIN, options={ "name": "My template", - "value_template": "{{ states('switch.one') }}", + state_key: "{{ states('switch.one') }}", "template_type": SWITCH_DOMAIN, }, title="My template", @@ -75,200 +279,108 @@ async def test_setup_config_entry( assert state == snapshot -async def test_template_state_text(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + ("count", "state_template"), [(1, "{{ states.switch.test_state.state }}")] +) +@pytest.mark.parametrize( + "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] +) +async def test_template_state_text(hass: HomeAssistant, setup_state_switch) -> None: """Test the state text of a template.""" - with assert_setup_component(1, "switch"): - assert await async_setup_component( - hass, - "switch", - { - "switch": { - "platform": "template", - "switches": { - "test_template_switch": { - **OPTIMISTIC_SWITCH_CONFIG, - "value_template": "{{ states.switch.test_state.state }}", - } - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() + hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_ON) await hass.async_block_till_done() - hass.states.async_set("switch.test_state", STATE_ON) - await hass.async_block_till_done() - - state = hass.states.get("switch.test_template_switch") + state = hass.states.get(TEST_ENTITY_ID) assert state.state == STATE_ON - hass.states.async_set("switch.test_state", STATE_OFF) + hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_OFF) await hass.async_block_till_done() - state = hass.states.get("switch.test_template_switch") + state = hass.states.get(TEST_ENTITY_ID) assert state.state == STATE_OFF -async def test_template_state_boolean_on(hass: HomeAssistant) -> None: - """Test the setting of the state with boolean on.""" - with assert_setup_component(1, "switch"): - assert await async_setup_component( - hass, - "switch", - { - "switch": { - "platform": "template", - "switches": { - "test_template_switch": { - **OPTIMISTIC_SWITCH_CONFIG, - "value_template": "{{ 1 == 1 }}", - } - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - state = hass.states.get("switch.test_template_switch") - assert state.state == STATE_ON +@pytest.mark.parametrize("count", [1]) +@pytest.mark.parametrize( + ("expected", "state_template"), + [ + (STATE_ON, "{{ 1 == 1 }}"), + (STATE_OFF, "{{ 1 == 2 }}"), + ], +) +@pytest.mark.parametrize( + "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] +) +async def test_template_state_boolean( + hass: HomeAssistant, expected: str, setup_state_switch +) -> None: + """Test the setting of the state with boolean template.""" + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == expected -async def test_template_state_boolean_off(hass: HomeAssistant) -> None: - """Test the setting of the state with off.""" - with assert_setup_component(1, "switch"): - assert await async_setup_component( - hass, - "switch", - { - "switch": { - "platform": "template", - "switches": { - "test_template_switch": { - **OPTIMISTIC_SWITCH_CONFIG, - "value_template": "{{ 1 == 2 }}", - } - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - state = hass.states.get("switch.test_template_switch") - assert state.state == STATE_OFF - - -async def test_icon_template(hass: HomeAssistant) -> None: - """Test icon template.""" - with assert_setup_component(1, "switch"): - assert await async_setup_component( - hass, - "switch", - { - "switch": { - "platform": "template", - "switches": { - "test_template_switch": { - **OPTIMISTIC_SWITCH_CONFIG, - "value_template": "{{ states.switch.test_state.state }}", - "icon_template": ( - "{% if states.switch.test_state.state %}" - "mdi:check" - "{% endif %}" - ), - } - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - state = hass.states.get("switch.test_template_switch") +@pytest.mark.parametrize( + ("count", "attribute_template"), + [(1, "{% if states.switch.test_state.state %}mdi:check{% endif %}")], +) +@pytest.mark.parametrize( + ("style", "attribute"), + [ + (ConfigurationStyle.LEGACY, "icon_template"), + (ConfigurationStyle.MODERN, "icon"), + ], +) +async def test_icon_template( + hass: HomeAssistant, setup_single_attribute_switch +) -> None: + """Test the state text of a template.""" + state = hass.states.get(TEST_ENTITY_ID) assert state.attributes.get("icon") == "" - hass.states.async_set("switch.test_state", STATE_ON) + hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_ON) await hass.async_block_till_done() - state = hass.states.get("switch.test_template_switch") + state = hass.states.get(TEST_ENTITY_ID) assert state.attributes["icon"] == "mdi:check" -async def test_entity_picture_template(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + ("count", "attribute_template"), + [(1, "{% if states.switch.test_state.state %}/local/switch.png{% endif %}")], +) +@pytest.mark.parametrize( + ("style", "attribute"), + [ + (ConfigurationStyle.LEGACY, "entity_picture_template"), + (ConfigurationStyle.MODERN, "picture"), + ], +) +async def test_entity_picture_template( + hass: HomeAssistant, setup_single_attribute_switch +) -> None: """Test entity_picture template.""" - with assert_setup_component(1, "switch"): - assert await async_setup_component( - hass, - "switch", - { - "switch": { - "platform": "template", - "switches": { - "test_template_switch": { - **OPTIMISTIC_SWITCH_CONFIG, - "value_template": "{{ states.switch.test_state.state }}", - "entity_picture_template": ( - "{% if states.switch.test_state.state %}" - "/local/switch.png" - "{% endif %}" - ), - } - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - state = hass.states.get("switch.test_template_switch") + state = hass.states.get(TEST_ENTITY_ID) assert state.attributes.get("entity_picture") == "" - hass.states.async_set("switch.test_state", STATE_ON) + hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_ON) await hass.async_block_till_done() - state = hass.states.get("switch.test_template_switch") + state = hass.states.get(TEST_ENTITY_ID) assert state.attributes["entity_picture"] == "/local/switch.png" -async def test_template_syntax_error(hass: HomeAssistant) -> None: +@pytest.mark.parametrize(("count", "state_template"), [(0, "{% if rubbish %}")]) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN], +) +async def test_template_syntax_error(hass: HomeAssistant, setup_state_switch) -> None: """Test templating syntax error.""" - with assert_setup_component(0, "switch"): - assert await async_setup_component( - hass, - "switch", - { - "switch": { - "platform": "template", - "switches": { - "test_template_switch": { - **OPTIMISTIC_SWITCH_CONFIG, - "value_template": "{% if rubbish %}", - } - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - assert hass.states.async_all("switch") == [] -async def test_invalid_name_does_not_create(hass: HomeAssistant) -> None: - """Test invalid name.""" +async def test_invalid_legacy_slug_does_not_create(hass: HomeAssistant) -> None: + """Test invalid legacy slug.""" with assert_setup_component(0, "switch"): assert await async_setup_component( hass, @@ -278,7 +390,7 @@ async def test_invalid_name_does_not_create(hass: HomeAssistant) -> None: "platform": "template", "switches": { "test INVALID switch": { - **OPTIMISTIC_SWITCH_CONFIG, + **SWITCH_ACTIONS, "value_template": "{{ rubbish }", } }, @@ -293,19 +405,32 @@ async def test_invalid_name_does_not_create(hass: HomeAssistant) -> None: assert hass.states.async_all("switch") == [] -async def test_invalid_switch_does_not_create(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + ("config", "domain"), + [ + ( + { + "template": {"switch": "Invalid"}, + }, + template.DOMAIN, + ), + ( + { + "switch": { + "platform": "template", + "switches": {TEST_OBJECT_ID: "Invalid"}, + } + }, + switch.DOMAIN, + ), + ], +) +async def test_invalid_switch_does_not_create( + hass: HomeAssistant, config: dict, domain: str +) -> None: """Test invalid switch.""" - with assert_setup_component(0, "switch"): - assert await async_setup_component( - hass, - "switch", - { - "switch": { - "platform": "template", - "switches": {"test_template_switch": "Invalid"}, - } - }, - ) + with assert_setup_component(0, domain): + assert await async_setup_component(hass, domain, config) await hass.async_block_till_done() await hass.async_start() @@ -314,12 +439,33 @@ async def test_invalid_switch_does_not_create(hass: HomeAssistant) -> None: assert hass.states.async_all("switch") == [] -async def test_no_switches_does_not_create(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + ("config", "domain", "count"), + [ + ( + { + "template": {"switch": []}, + }, + template.DOMAIN, + 1, + ), + ( + { + "switch": { + "platform": "template", + } + }, + switch.DOMAIN, + 0, + ), + ], +) +async def test_no_switches_does_not_create( + hass: HomeAssistant, config: dict, domain: str, count: int +) -> None: """Test if there are no switches no creation.""" - with assert_setup_component(0, "switch"): - assert await async_setup_component( - hass, "switch", {"switch": {"platform": "template"}} - ) + with assert_setup_component(count, domain): + assert await async_setup_component(hass, domain, config) await hass.async_block_till_done() await hass.async_start() @@ -328,239 +474,254 @@ async def test_no_switches_does_not_create(hass: HomeAssistant) -> None: assert hass.states.async_all("switch") == [] -async def test_missing_on_does_not_create(hass: HomeAssistant) -> None: - """Test missing on.""" - with assert_setup_component(0, "switch"): - assert await async_setup_component( - hass, - "switch", +@pytest.mark.parametrize( + ("config", "domain"), + [ + ( { - "switch": { - "platform": "template", - "switches": { - "test_template_switch": { - "value_template": "{{ states.switch.test_state.state }}", - "not_on": { - "service": "switch.turn_on", - "entity_id": "switch.test_state", - }, - "turn_off": { - "service": "switch.turn_off", - "entity_id": "switch.test_state", - }, - } - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - assert hass.states.async_all("switch") == [] - - -async def test_missing_off_does_not_create(hass: HomeAssistant) -> None: - """Test missing off.""" - with assert_setup_component(0, "switch"): - assert await async_setup_component( - hass, - "switch", - { - "switch": { - "platform": "template", - "switches": { - "test_template_switch": { - "value_template": "{{ states.switch.test_state.state }}", - "turn_on": { - "service": "switch.turn_on", - "entity_id": "switch.test_state", - }, - "not_off": { - "service": "switch.turn_off", - "entity_id": "switch.test_state", - }, - } - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - assert hass.states.async_all("switch") == [] - - -async def test_on_action(hass: HomeAssistant, calls: list[ServiceCall]) -> None: - """Test on action.""" - assert await async_setup_component( - hass, - "switch", - { - "switch": { - "platform": "template", - "switches": { - "test_template_switch": { - **OPTIMISTIC_SWITCH_CONFIG, - "value_template": "{{ states.switch.test_state.state }}", + "template": { + "switch": { + "not_on": SWITCH_TURN_ON, + "turn_off": SWITCH_TURN_OFF, + "state": "{{ states.switch.test_state.state }}", } }, - } - }, - ) + }, + template.DOMAIN, + ), + ( + { + "switch": { + "platform": "template", + "switches": { + TEST_OBJECT_ID: { + "not_on": SWITCH_TURN_ON, + "turn_off": SWITCH_TURN_OFF, + "value_template": "{{ states.switch.test_state.state }}", + } + }, + } + }, + switch.DOMAIN, + ), + ], +) +async def test_missing_on_does_not_create( + hass: HomeAssistant, config: dict, domain: str +) -> None: + """Test missing on.""" + with assert_setup_component(0, domain): + assert await async_setup_component(hass, domain, config) await hass.async_block_till_done() await hass.async_start() await hass.async_block_till_done() - hass.states.async_set("switch.test_state", STATE_OFF) + assert hass.states.async_all("switch") == [] + + +@pytest.mark.parametrize( + ("config", "domain"), + [ + ( + { + "template": { + "switch": { + "turn_on": SWITCH_TURN_ON, + "not_off": SWITCH_TURN_OFF, + "state": "{{ states.switch.test_state.state }}", + } + }, + }, + template.DOMAIN, + ), + ( + { + "switch": { + "platform": "template", + "switches": { + TEST_OBJECT_ID: { + "turn_on": SWITCH_TURN_ON, + "not_off": SWITCH_TURN_OFF, + "value_template": "{{ states.switch.test_state.state }}", + } + }, + } + }, + switch.DOMAIN, + ), + ], +) +async def test_missing_off_does_not_create( + hass: HomeAssistant, config: dict, domain: str +) -> None: + """Test missing off.""" + with assert_setup_component(0, domain): + assert await async_setup_component(hass, domain, config) + + await hass.async_block_till_done() + await hass.async_start() await hass.async_block_till_done() - state = hass.states.get("switch.test_template_switch") + assert hass.states.async_all("switch") == [] + + +@pytest.mark.parametrize( + ("count", "state_template"), [(1, "{{ states('switch.test_state') }}")] +) +@pytest.mark.parametrize( + "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] +) +async def test_on_action( + hass: HomeAssistant, setup_state_switch, calls: list[ServiceCall] +) -> None: + """Test on action.""" + hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_OFF) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) assert state.state == STATE_OFF await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "switch.test_template_switch"}, + {ATTR_ENTITY_ID: TEST_ENTITY_ID}, blocking=True, ) assert len(calls) == 1 assert calls[-1].data["action"] == "turn_on" - assert calls[-1].data["caller"] == "switch.test_template_switch" + assert calls[-1].data["caller"] == TEST_ENTITY_ID +@pytest.mark.parametrize("count", [1]) +@pytest.mark.parametrize( + "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] +) async def test_on_action_optimistic( - hass: HomeAssistant, calls: list[ServiceCall] + hass: HomeAssistant, setup_optimistic_switch, calls: list[ServiceCall] ) -> None: """Test on action in optimistic mode.""" - assert await async_setup_component( - hass, - "switch", - { - "switch": { - "platform": "template", - "switches": { - "test_template_switch": { - **OPTIMISTIC_SWITCH_CONFIG, - } - }, - } - }, - ) - - await hass.async_start() + hass.states.async_set(TEST_ENTITY_ID, STATE_OFF) await hass.async_block_till_done() - hass.states.async_set("switch.test_template_switch", STATE_OFF) - await hass.async_block_till_done() - - state = hass.states.get("switch.test_template_switch") + state = hass.states.get(TEST_ENTITY_ID) assert state.state == STATE_OFF await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "switch.test_template_switch"}, + {ATTR_ENTITY_ID: TEST_ENTITY_ID}, blocking=True, ) - state = hass.states.get("switch.test_template_switch") + state = hass.states.get(TEST_ENTITY_ID) assert state.state == STATE_ON assert len(calls) == 1 assert calls[-1].data["action"] == "turn_on" - assert calls[-1].data["caller"] == "switch.test_template_switch" + assert calls[-1].data["caller"] == TEST_ENTITY_ID -async def test_off_action(hass: HomeAssistant, calls: list[ServiceCall]) -> None: +@pytest.mark.parametrize( + ("count", "state_template"), [(1, "{{ states.switch.test_state.state }}")] +) +@pytest.mark.parametrize( + "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] +) +async def test_off_action( + hass: HomeAssistant, setup_state_switch, calls: list[ServiceCall] +) -> None: """Test off action.""" - assert await async_setup_component( - hass, - "switch", - { - "switch": { - "platform": "template", - "switches": { - "test_template_switch": { - **OPTIMISTIC_SWITCH_CONFIG, - "value_template": "{{ states.switch.test_state.state }}", - } - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() + hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_ON) await hass.async_block_till_done() - hass.states.async_set("switch.test_state", STATE_ON) - await hass.async_block_till_done() - - state = hass.states.get("switch.test_template_switch") + state = hass.states.get(TEST_ENTITY_ID) assert state.state == STATE_ON await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: "switch.test_template_switch"}, + {ATTR_ENTITY_ID: TEST_ENTITY_ID}, blocking=True, ) assert len(calls) == 1 assert calls[-1].data["action"] == "turn_off" - assert calls[-1].data["caller"] == "switch.test_template_switch" + assert calls[-1].data["caller"] == TEST_ENTITY_ID +@pytest.mark.parametrize("count", [1]) +@pytest.mark.parametrize( + "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] +) async def test_off_action_optimistic( - hass: HomeAssistant, calls: list[ServiceCall] + hass: HomeAssistant, setup_optimistic_switch, calls: list[ServiceCall] ) -> None: """Test off action in optimistic mode.""" - assert await async_setup_component( - hass, - "switch", - { - "switch": { - "platform": "template", - "switches": { - "test_template_switch": { - **OPTIMISTIC_SWITCH_CONFIG, - } - }, - } - }, - ) - - await hass.async_start() + hass.states.async_set(TEST_ENTITY_ID, STATE_ON) await hass.async_block_till_done() - hass.states.async_set("switch.test_template_switch", STATE_ON) - await hass.async_block_till_done() - - state = hass.states.get("switch.test_template_switch") + state = hass.states.get(TEST_ENTITY_ID) assert state.state == STATE_ON await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: "switch.test_template_switch"}, + {ATTR_ENTITY_ID: TEST_ENTITY_ID}, blocking=True, ) - state = hass.states.get("switch.test_template_switch") + state = hass.states.get(TEST_ENTITY_ID) assert state.state == STATE_OFF assert len(calls) == 1 assert calls[-1].data["action"] == "turn_off" - assert calls[-1].data["caller"] == "switch.test_template_switch" + assert calls[-1].data["caller"] == TEST_ENTITY_ID -async def test_restore_state(hass: HomeAssistant) -> None: +@pytest.mark.parametrize("count", [1]) +@pytest.mark.parametrize( + ("config", "domain"), + [ + ( + { + "switch": { + "platform": "template", + "switches": { + "s1": { + **SWITCH_ACTIONS, + }, + "s2": { + **SWITCH_ACTIONS, + }, + }, + } + }, + switch.DOMAIN, + ), + ( + { + "template": { + "switch": [ + { + "name": "s1", + **SWITCH_ACTIONS, + }, + { + "name": "s2", + **SWITCH_ACTIONS, + }, + ], + } + }, + template.DOMAIN, + ), + ], +) +async def test_restore_state( + hass: HomeAssistant, count: int, domain: str, config: dict[str, Any] +) -> None: """Test state restoration.""" mock_restore_cache( hass, @@ -573,23 +734,9 @@ async def test_restore_state(hass: HomeAssistant) -> None: hass.set_state(CoreState.starting) mock_component(hass, "recorder") - await async_setup_component( - hass, - "switch", - { - "switch": { - "platform": "template", - "switches": { - "s1": { - **OPTIMISTIC_SWITCH_CONFIG, - }, - "s2": { - **OPTIMISTIC_SWITCH_CONFIG, - }, - }, - } - }, - ) + with assert_setup_component(count, domain): + await async_setup_component(hass, domain, config) + await hass.async_block_till_done() state = hass.states.get("switch.s1") @@ -601,100 +748,157 @@ async def test_restore_state(hass: HomeAssistant) -> None: assert state.state == STATE_OFF -async def test_available_template_with_entities(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + ("count", "attribute_template"), + [(1, "{{ is_state('switch.test_state', 'on') }}")], +) +@pytest.mark.parametrize( + ("style", "attribute"), + [ + (ConfigurationStyle.LEGACY, "availability_template"), + (ConfigurationStyle.MODERN, "availability"), + ], +) +async def test_available_template_with_entities( + hass: HomeAssistant, setup_single_attribute_switch +) -> None: """Test availability templates with values from other entities.""" - await setup.async_setup_component( - hass, - "switch", - { - "switch": { - "platform": "template", - "switches": { - "test_template_switch": { - **OPTIMISTIC_SWITCH_CONFIG, - "value_template": "{{ 1 == 1 }}", - "availability_template": ( - "{{ is_state('availability_state.state', 'on') }}" - ), - } - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() + hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_ON) await hass.async_block_till_done() - hass.states.async_set("availability_state.state", STATE_ON) + assert hass.states.get(TEST_ENTITY_ID).state != STATE_UNAVAILABLE + + hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_OFF) await hass.async_block_till_done() - assert hass.states.get("switch.test_template_switch").state != STATE_UNAVAILABLE - - hass.states.async_set("availability_state.state", STATE_OFF) - await hass.async_block_till_done() - - assert hass.states.get("switch.test_template_switch").state == STATE_UNAVAILABLE + assert hass.states.get(TEST_ENTITY_ID).state == STATE_UNAVAILABLE +@pytest.mark.parametrize("count", [1]) +@pytest.mark.parametrize( + ("config", "domain"), + [ + ( + { + "switch": { + "platform": "template", + "switches": { + TEST_OBJECT_ID: { + **SWITCH_ACTIONS, + "value_template": "{{ true }}", + "availability_template": "{{ x - 12 }}", + } + }, + } + }, + switch.DOMAIN, + ), + ( + { + "template": { + "switch": { + **NAMED_SWITCH_ACTIONS, + "state": "{{ true }}", + "availability": "{{ x - 12 }}", + }, + } + }, + template.DOMAIN, + ), + ], +) async def test_invalid_availability_template_keeps_component_available( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + count: int, + config: dict[str, Any], + domain: str, + caplog: pytest.LogCaptureFixture, ) -> None: """Test that an invalid availability keeps the device available.""" - await setup.async_setup_component( - hass, - "switch", - { - "switch": { - "platform": "template", - "switches": { - "test_template_switch": { - **OPTIMISTIC_SWITCH_CONFIG, - "value_template": "{{ true }}", - "availability_template": "{{ x - 12 }}", - } - }, - } - }, - ) + with assert_setup_component(count, domain): + await async_setup_component(hass, domain, config) await hass.async_block_till_done() await hass.async_start() await hass.async_block_till_done() - assert hass.states.get("switch.test_template_switch").state != STATE_UNAVAILABLE + assert hass.states.get(TEST_ENTITY_ID).state != STATE_UNAVAILABLE assert "UndefinedError: 'x' is undefined" in caplog.text -async def test_unique_id(hass: HomeAssistant) -> None: - """Test unique_id option only creates one switch per id.""" - await setup.async_setup_component( - hass, - "switch", - { - "switch": { - "platform": "template", - "switches": { - "test_template_switch_01": { - **OPTIMISTIC_SWITCH_CONFIG, - "unique_id": "not-so-unique-anymore", - "value_template": "{{ true }}", - }, - "test_template_switch_02": { - **OPTIMISTIC_SWITCH_CONFIG, - "unique_id": "not-so-unique-anymore", - "value_template": "{{ false }}", - }, +@pytest.mark.parametrize("count", [1]) +@pytest.mark.parametrize( + ("switch_config", "style"), + [ + ( + { + "test_template_switch_01": UNIQUE_ID_CONFIG, + "test_template_switch_02": UNIQUE_ID_CONFIG, + }, + ConfigurationStyle.LEGACY, + ), + ( + [ + { + "name": "test_template_switch_01", + **UNIQUE_ID_CONFIG, }, - } - }, - ) + { + "name": "test_template_switch_02", + **UNIQUE_ID_CONFIG, + }, + ], + ConfigurationStyle.MODERN, + ), + ], +) +async def test_unique_id(hass: HomeAssistant, setup_switch) -> None: + """Test unique_id option only creates one switch per id.""" + assert len(hass.states.async_all("switch")) == 1 + + +async def test_nested_unique_id( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: + """Test a template unique_id propagates to switch unique_ids.""" + with assert_setup_component(1, template.DOMAIN): + assert await async_setup_component( + hass, + template.DOMAIN, + { + "template": { + "unique_id": "x", + "switch": [ + { + **SWITCH_ACTIONS, + "name": "test_a", + "unique_id": "a", + "state": "{{ true }}", + }, + { + **SWITCH_ACTIONS, + "name": "test_b", + "unique_id": "b", + "state": "{{ true }}", + }, + ], + }, + }, + ) await hass.async_block_till_done() await hass.async_start() await hass.async_block_till_done() - assert len(hass.states.async_all("switch")) == 1 + assert len(hass.states.async_all("switch")) == 2 + + entry = entity_registry.async_get("switch.test_a") + assert entry + assert entry.unique_id == "x-a" + + entry = entity_registry.async_get("switch.test_b") + assert entry + assert entry.unique_id == "x-b" async def test_device_id( @@ -720,7 +924,7 @@ async def test_device_id( domain=template.DOMAIN, options={ "name": "My template", - "value_template": "{{ true }}", + "state": "{{ true }}", "template_type": "switch", "device_id": device_entry.id, }, From 2a4ed9ace7853290c3918b389f7e349003ab0703 Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Thu, 20 Mar 2025 10:14:45 -0400 Subject: [PATCH 2784/3148] Add translations for Roborock Exceptions (#140964) * Add translations to a few exceptions * match existing wording * fix regex * consolidate errors * fix test --- homeassistant/components/roborock/coordinator.py | 12 ++++++++++-- homeassistant/components/roborock/quality_scale.yaml | 2 +- homeassistant/components/roborock/strings.json | 6 ++++++ homeassistant/components/roborock/vacuum.py | 9 +++++++-- tests/components/roborock/test_vacuum.py | 4 +++- 5 files changed, 27 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/roborock/coordinator.py b/homeassistant/components/roborock/coordinator.py index 698e2c268ed..6d0c9737a29 100644 --- a/homeassistant/components/roborock/coordinator.py +++ b/homeassistant/components/roborock/coordinator.py @@ -193,7 +193,12 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): try: maps = await self.api.get_multi_maps_list() except RoborockException as err: - raise UpdateFailed("Failed to get map data: {err}") from err + _LOGGER.debug("Failed to get maps: %s", err) + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="map_failure", + translation_placeholders={"error": str(err)}, + ) from err # Rooms names populated later with calls to `set_current_map_rooms` for each map roborock_maps = maps.map_info if (maps and maps.map_info) else () stored_images = await asyncio.gather( @@ -310,7 +315,10 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): await self.set_current_map_rooms() except RoborockException as ex: _LOGGER.debug("Failed to update data: %s", ex) - raise UpdateFailed(ex) from ex + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_data_fail", + ) from ex if self.roborock_device_info.props.status.in_cleaning: if self._is_cloud_api: self.update_interval = V1_CLOUD_IN_CLEANING_INTERVAL diff --git a/homeassistant/components/roborock/quality_scale.yaml b/homeassistant/components/roborock/quality_scale.yaml index c7675ef96d1..feee5cb434c 100644 --- a/homeassistant/components/roborock/quality_scale.yaml +++ b/homeassistant/components/roborock/quality_scale.yaml @@ -64,7 +64,7 @@ rules: status: exempt comment: There are no noisy entities. entity-translations: done - exception-translations: todo + exception-translations: done icon-translations: todo reconfiguration-flow: todo repair-issues: diff --git a/homeassistant/components/roborock/strings.json b/homeassistant/components/roborock/strings.json index a59dc80e65d..caad67e4ce6 100644 --- a/homeassistant/components/roborock/strings.json +++ b/homeassistant/components/roborock/strings.json @@ -457,6 +457,12 @@ "map_failure": { "message": "Something went wrong creating the map" }, + "position_not_found": { + "message": "Robot position not found" + }, + "update_data_fail": { + "message": "Failed to update data" + }, "no_coordinators": { "message": "No devices were able to successfully setup" }, diff --git a/homeassistant/components/roborock/vacuum.py b/homeassistant/components/roborock/vacuum.py index c5357597527..058fffbdb1c 100644 --- a/homeassistant/components/roborock/vacuum.py +++ b/homeassistant/components/roborock/vacuum.py @@ -221,13 +221,18 @@ class RoborockVacuum(RoborockCoordinatedEntityV1, StateVacuumEntity): map_data = await self.coordinator.cloud_api.get_map_v1() if not isinstance(map_data, bytes): - raise HomeAssistantError("Failed to retrieve map data.") + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="map_failure", + ) parser = RoborockMapDataParser(ColorsPalette(), Sizes(), [], ImageConfig(), []) parsed_map = parser.parse(map_data) robot_position = parsed_map.vacuum_position if robot_position is None: - raise HomeAssistantError("Robot position not found") + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="position_not_found" + ) return { "x": robot_position.x, diff --git a/tests/components/roborock/test_vacuum.py b/tests/components/roborock/test_vacuum.py index 2a2d9f210ed..5d6e7a599bd 100644 --- a/tests/components/roborock/test_vacuum.py +++ b/tests/components/roborock/test_vacuum.py @@ -291,7 +291,9 @@ async def test_get_current_position_no_map_data( "homeassistant.components.roborock.coordinator.RoborockMqttClientV1.get_map_v1", return_value=None, ), - pytest.raises(HomeAssistantError, match="Failed to retrieve map data."), + pytest.raises( + HomeAssistantError, match="Something went wrong creating the map" + ), ): await hass.services.async_call( DOMAIN, From a835c85f591e413884acdd4f07dd3cdd9afe1ad0 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Thu, 20 Mar 2025 15:37:02 +0100 Subject: [PATCH 2785/3148] Patch Z-Wave platforms in button tests (#141001) --- tests/components/zwave_js/test_button.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/components/zwave_js/test_button.py b/tests/components/zwave_js/test_button.py index b0c06668926..0282a268b54 100644 --- a/tests/components/zwave_js/test_button.py +++ b/tests/components/zwave_js/test_button.py @@ -5,11 +5,17 @@ import pytest from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS from homeassistant.components.zwave_js.const import DOMAIN, SERVICE_REFRESH_VALUE from homeassistant.components.zwave_js.helpers import get_valueless_base_unique_id -from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er +@pytest.fixture +def platforms() -> list[str]: + """Fixture to specify platforms to test.""" + return [Platform.BUTTON] + + async def test_ping_entity( hass: HomeAssistant, entity_registry: er.EntityRegistry, From 70ed120c6e2f93ecc2f6d4fdad157436409a2bb3 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Thu, 20 Mar 2025 16:58:49 +0100 Subject: [PATCH 2786/3148] Add exception translations for GIOS integration (#141006) Add exception translations --- homeassistant/components/gios/__init__.py | 9 ++++++++- homeassistant/components/gios/coordinator.py | 9 ++++++++- homeassistant/components/gios/strings.json | 8 ++++++++ 3 files changed, 24 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/gios/__init__.py b/homeassistant/components/gios/__init__.py index f756980f5d0..31f704fcacc 100644 --- a/homeassistant/components/gios/__init__.py +++ b/homeassistant/components/gios/__init__.py @@ -44,7 +44,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: GiosConfigEntry) -> bool try: gios = await Gios.create(websession, station_id) except (GiosError, ConnectionError, ClientConnectorError) as err: - raise ConfigEntryNotReady from err + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="cannot_connect", + translation_placeholders={ + "entry": entry.title, + "error": repr(err), + }, + ) from err coordinator = GiosDataUpdateCoordinator(hass, entry, gios) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/gios/coordinator.py b/homeassistant/components/gios/coordinator.py index 95f3b8af797..eb0dd82eb67 100644 --- a/homeassistant/components/gios/coordinator.py +++ b/homeassistant/components/gios/coordinator.py @@ -57,4 +57,11 @@ class GiosDataUpdateCoordinator(DataUpdateCoordinator[GiosSensors]): async with asyncio.timeout(API_TIMEOUT): return await self.gios.async_update() except (GiosError, ClientConnectorError) as error: - raise UpdateFailed(error) from error + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_error", + translation_placeholders={ + "entry": self.config_entry.title, + "error": repr(error), + }, + ) from error diff --git a/homeassistant/components/gios/strings.json b/homeassistant/components/gios/strings.json index ff4c2a80b16..eca23159a13 100644 --- a/homeassistant/components/gios/strings.json +++ b/homeassistant/components/gios/strings.json @@ -170,5 +170,13 @@ } } } + }, + "exceptions": { + "cannot_connect": { + "message": "An error occurred while connecting to the GIOS API for {entry}: {error}" + }, + "update_error": { + "message": "An error occurred while retrieving data from the GIOS API for {entry}: {error}" + } } } From e48a25e9526093b3348f0c91a6f6b29d3c4e6f6a Mon Sep 17 00:00:00 2001 From: peteS-UK <64092177+peteS-UK@users.noreply.github.com> Date: Thu, 20 Mar 2025 16:18:08 +0000 Subject: [PATCH 2787/3148] Add button platform for Squeezebox integration (#140697) * initial * trans key correction * base class updates * model tidy up * Update homeassistant/components/squeezebox/strings.json Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/squeezebox/entity.py Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/squeezebox/media_player.py Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/squeezebox/media_player.py Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/squeezebox/button.py Co-authored-by: Joost Lekkerkerker * review updates * update * move manufacturer to library * updates * list concat * review updates * Update tests/components/squeezebox/test_button.py --------- Co-authored-by: Joost Lekkerkerker --- .../components/squeezebox/__init__.py | 1 + homeassistant/components/squeezebox/button.py | 155 ++++++++++++++++++ homeassistant/components/squeezebox/entity.py | 30 +++- .../components/squeezebox/media_player.py | 34 +--- .../components/squeezebox/strings.json | 23 +++ tests/components/squeezebox/conftest.py | 32 +++- .../snapshots/test_media_player.ambr | 2 +- tests/components/squeezebox/test_button.py | 23 +++ 8 files changed, 266 insertions(+), 34 deletions(-) create mode 100644 homeassistant/components/squeezebox/button.py create mode 100644 tests/components/squeezebox/test_button.py diff --git a/homeassistant/components/squeezebox/__init__.py b/homeassistant/components/squeezebox/__init__.py index fd641d3389d..78a97e38833 100644 --- a/homeassistant/components/squeezebox/__init__.py +++ b/homeassistant/components/squeezebox/__init__.py @@ -53,6 +53,7 @@ _LOGGER = logging.getLogger(__name__) PLATFORMS = [ Platform.BINARY_SENSOR, + Platform.BUTTON, Platform.MEDIA_PLAYER, Platform.SENSOR, ] diff --git a/homeassistant/components/squeezebox/button.py b/homeassistant/components/squeezebox/button.py new file mode 100644 index 00000000000..098df3a1b5c --- /dev/null +++ b/homeassistant/components/squeezebox/button.py @@ -0,0 +1,155 @@ +"""Platform for button integration for squeezebox.""" + +from __future__ import annotations + +from dataclasses import dataclass +import logging + +from homeassistant.components.button import ButtonEntity, ButtonEntityDescription +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import SqueezeboxConfigEntry +from .const import SIGNAL_PLAYER_DISCOVERED +from .coordinator import SqueezeBoxPlayerUpdateCoordinator +from .entity import SqueezeboxEntity + +_LOGGER = logging.getLogger(__name__) + +HARDWARE_MODELS_WITH_SCREEN = [ + "Squeezebox Boom", + "Squeezebox Radio", + "Transporter", + "Squeezebox Touch", + "Squeezebox", + "SliMP3", + "Squeezebox 1", + "Squeezebox 2", + "Squeezebox 3", +] + +HARDWARE_MODELS_WITH_TONE = [ + *HARDWARE_MODELS_WITH_SCREEN, + "Squeezebox Receiver", +] + + +@dataclass(frozen=True, kw_only=True) +class SqueezeboxButtonEntityDescription(ButtonEntityDescription): + """Squeezebox Button description.""" + + press_action: str + + +BUTTON_ENTITIES: tuple[SqueezeboxButtonEntityDescription, ...] = tuple( + SqueezeboxButtonEntityDescription( + key=f"preset_{i}", + translation_key="preset", + translation_placeholders={"index": str(i)}, + press_action=f"preset_{i}.single", + ) + for i in range(1, 7) +) + +SCREEN_BUTTON_ENTITIES: tuple[SqueezeboxButtonEntityDescription, ...] = ( + SqueezeboxButtonEntityDescription( + key="brightness_up", + translation_key="brightness_up", + press_action="brightness_up", + ), + SqueezeboxButtonEntityDescription( + key="brightness_down", + translation_key="brightness_down", + press_action="brightness_down", + ), +) + +TONE_BUTTON_ENTITIES: tuple[SqueezeboxButtonEntityDescription, ...] = ( + SqueezeboxButtonEntityDescription( + key="bass_up", + translation_key="bass_up", + press_action="bass_up", + ), + SqueezeboxButtonEntityDescription( + key="bass_down", + translation_key="bass_down", + press_action="bass_down", + ), + SqueezeboxButtonEntityDescription( + key="treble_up", + translation_key="treble_up", + press_action="treble_up", + ), + SqueezeboxButtonEntityDescription( + key="treble_down", + translation_key="treble_down", + press_action="treble_down", + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: SqueezeboxConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Squeezebox button platform from a server config entry.""" + + # Add button entities when player discovered + async def _player_discovered( + player_coordinator: SqueezeBoxPlayerUpdateCoordinator, + ) -> None: + _LOGGER.debug( + "Setting up button entity for player %s, model %s", + player_coordinator.player.name, + player_coordinator.player.model, + ) + + entities: list[SqueezeboxButtonEntity] = [] + + entities.extend( + SqueezeboxButtonEntity(player_coordinator, description) + for description in BUTTON_ENTITIES + ) + + entities.extend( + SqueezeboxButtonEntity(player_coordinator, description) + for description in TONE_BUTTON_ENTITIES + if player_coordinator.player.model in HARDWARE_MODELS_WITH_TONE + ) + + entities.extend( + SqueezeboxButtonEntity(player_coordinator, description) + for description in SCREEN_BUTTON_ENTITIES + if player_coordinator.player.model in HARDWARE_MODELS_WITH_SCREEN + ) + + async_add_entities(entities) + + entry.async_on_unload( + async_dispatcher_connect(hass, SIGNAL_PLAYER_DISCOVERED, _player_discovered) + ) + + +class SqueezeboxButtonEntity(SqueezeboxEntity, ButtonEntity): + """Representation of Buttons for Squeezebox entities.""" + + entity_description: SqueezeboxButtonEntityDescription + + def __init__( + self, + coordinator: SqueezeBoxPlayerUpdateCoordinator, + entity_description: SqueezeboxButtonEntityDescription, + ) -> None: + """Initialize the SqueezeBox Button.""" + super().__init__(coordinator) + self.entity_description = entity_description + self._attr_unique_id = ( + f"{format_mac(self._player.player_id)}_{entity_description.key}" + ) + + async def async_press(self) -> None: + """Execute the button action.""" + await self._player.async_query("button", self.entity_description.press_action) diff --git a/homeassistant/components/squeezebox/entity.py b/homeassistant/components/squeezebox/entity.py index 027ca68edc6..2c443c24ffd 100644 --- a/homeassistant/components/squeezebox/entity.py +++ b/homeassistant/components/squeezebox/entity.py @@ -1,11 +1,37 @@ """Base class for Squeezebox Sensor entities.""" -from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.device_registry import ( + CONNECTION_NETWORK_MAC, + DeviceInfo, + format_mac, +) from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN, STATUS_QUERY_UUID -from .coordinator import LMSStatusDataUpdateCoordinator +from .coordinator import ( + LMSStatusDataUpdateCoordinator, + SqueezeBoxPlayerUpdateCoordinator, +) + + +class SqueezeboxEntity(CoordinatorEntity[SqueezeBoxPlayerUpdateCoordinator]): + """Base entity class for Squeezebox entities.""" + + _attr_has_entity_name = True + + def __init__(self, coordinator: SqueezeBoxPlayerUpdateCoordinator) -> None: + """Initialize the SqueezeBox entity.""" + super().__init__(coordinator) + self._player = coordinator.player + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, format_mac(self._player.player_id))}, + name=self._player.name, + connections={(CONNECTION_NETWORK_MAC, format_mac(self._player.player_id))}, + via_device=(DOMAIN, coordinator.server_uuid), + model=self._player.model, + manufacturer=self._player.creator, + ) class LMSStatusEntity(CoordinatorEntity[LMSStatusDataUpdateCoordinator]): diff --git a/homeassistant/components/squeezebox/media_player.py b/homeassistant/components/squeezebox/media_player.py index 1767d92730a..40662477745 100644 --- a/homeassistant/components/squeezebox/media_player.py +++ b/homeassistant/components/squeezebox/media_player.py @@ -35,15 +35,10 @@ from homeassistant.helpers import ( entity_platform, entity_registry as er, ) -from homeassistant.helpers.device_registry import ( - CONNECTION_NETWORK_MAC, - DeviceInfo, - format_mac, -) +from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.start import async_at_start -from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util.dt import utcnow from .browse_media import ( @@ -68,6 +63,7 @@ from .const import ( SQUEEZEBOX_SOURCE_STRINGS, ) from .coordinator import SqueezeBoxPlayerUpdateCoordinator +from .entity import SqueezeboxEntity if TYPE_CHECKING: from . import SqueezeboxConfigEntry @@ -181,9 +177,7 @@ def get_announce_timeout(extra: dict) -> int | None: return announce_timeout -class SqueezeBoxMediaPlayerEntity( - CoordinatorEntity[SqueezeBoxPlayerUpdateCoordinator], MediaPlayerEntity -): +class SqueezeBoxMediaPlayerEntity(SqueezeboxEntity, MediaPlayerEntity): """Representation of the media player features of a SqueezeBox device. Wraps a pysqueezebox.Player() object. @@ -217,30 +211,10 @@ class SqueezeBoxMediaPlayerEntity( def __init__(self, coordinator: SqueezeBoxPlayerUpdateCoordinator) -> None: """Initialize the SqueezeBox device.""" super().__init__(coordinator) - player = coordinator.player - self._player = player self._query_result: bool | dict = {} self._remove_dispatcher: Callable | None = None self._previous_media_position = 0 - self._attr_unique_id = format_mac(player.player_id) - _manufacturer = None - if player.model.startswith("SqueezeLite") or "SqueezePlay" in player.model: - _manufacturer = "Ralph Irving" - elif ( - "Squeezebox" in player.model - or "Transporter" in player.model - or "Slim" in player.model - ): - _manufacturer = "Logitech" - - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, self._attr_unique_id)}, - name=player.name, - connections={(CONNECTION_NETWORK_MAC, self._attr_unique_id)}, - via_device=(DOMAIN, coordinator.server_uuid), - model=player.model, - manufacturer=_manufacturer, - ) + self._attr_unique_id = format_mac(self._player.player_id) self._browse_data = BrowseData() @callback diff --git a/homeassistant/components/squeezebox/strings.json b/homeassistant/components/squeezebox/strings.json index ed569989b56..83c5d7dd5d0 100644 --- a/homeassistant/components/squeezebox/strings.json +++ b/homeassistant/components/squeezebox/strings.json @@ -63,6 +63,29 @@ } }, "entity": { + "button": { + "preset": { + "name": "Preset {index}" + }, + "brightness_up": { + "name": "Brightness up" + }, + "brightness_down": { + "name": "Brightness down" + }, + "bass_up": { + "name": "Bass up" + }, + "bass_down": { + "name": "Bass down" + }, + "treble_up": { + "name": "Treble up" + }, + "treble_down": { + "name": "Treble down" + } + }, "binary_sensor": { "rescan": { "name": "Library rescan" diff --git a/tests/components/squeezebox/conftest.py b/tests/components/squeezebox/conftest.py index 429c3b62087..769e611bf28 100644 --- a/tests/components/squeezebox/conftest.py +++ b/tests/components/squeezebox/conftest.py @@ -269,6 +269,7 @@ def mock_pysqueezebox_player(uuid: str) -> MagicMock: mock_player.title = None mock_player.image_url = None mock_player.model = "SqueezeLite" + mock_player.creator = "Ralph Irving & Adrian Smith" return mock_player @@ -309,7 +310,27 @@ async def configure_squeezebox_media_player_platform( ) -> None: """Configure a squeezebox config entry with appropriate mocks for media_player.""" with ( - patch("homeassistant.components.squeezebox.PLATFORMS", [Platform.MEDIA_PLAYER]), + patch( + "homeassistant.components.squeezebox.PLATFORMS", + [Platform.MEDIA_PLAYER], + ), + patch("homeassistant.components.squeezebox.Server", return_value=lms), + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done(wait_background_tasks=True) + + +async def configure_squeezebox_media_player_button_platform( + hass: HomeAssistant, + config_entry: MockConfigEntry, + lms: MagicMock, +) -> None: + """Configure a squeezebox config entry with appropriate mocks for media_player.""" + with ( + patch( + "homeassistant.components.squeezebox.PLATFORMS", + [Platform.BUTTON], + ), patch("homeassistant.components.squeezebox.Server", return_value=lms), ): await hass.config_entries.async_setup(config_entry.entry_id) @@ -325,6 +346,15 @@ async def configured_player( return (await lms.async_get_players())[0] +@pytest.fixture +async def configured_player_with_button( + hass: HomeAssistant, config_entry: MockConfigEntry, lms: MagicMock +) -> MagicMock: + """Fixture mocking calls to pysqueezebox Player from a configured squeezebox.""" + await configure_squeezebox_media_player_button_platform(hass, config_entry, lms) + return (await lms.async_get_players())[0] + + @pytest.fixture async def configured_players( hass: HomeAssistant, config_entry: MockConfigEntry, lms_factory: MagicMock diff --git a/tests/components/squeezebox/snapshots/test_media_player.ambr b/tests/components/squeezebox/snapshots/test_media_player.ambr index 34d6ae16af8..c0633035a84 100644 --- a/tests/components/squeezebox/snapshots/test_media_player.ambr +++ b/tests/components/squeezebox/snapshots/test_media_player.ambr @@ -24,7 +24,7 @@ 'is_new': False, 'labels': set({ }), - 'manufacturer': 'Ralph Irving', + 'manufacturer': 'Ralph Irving & Adrian Smith', 'model': 'SqueezeLite', 'model_id': None, 'name': 'Test Player', diff --git a/tests/components/squeezebox/test_button.py b/tests/components/squeezebox/test_button.py new file mode 100644 index 00000000000..16ced65be61 --- /dev/null +++ b/tests/components/squeezebox/test_button.py @@ -0,0 +1,23 @@ +"""Tests for the squeezebox button component.""" + +from unittest.mock import MagicMock + +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant + + +async def test_squeezebox_press( + hass: HomeAssistant, configured_player_with_button: MagicMock +) -> None: + """Test press service call.""" + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: "button.test_player_preset_1"}, + blocking=True, + ) + + configured_player_with_button.async_query.assert_called_with( + "button", "preset_1.single" + ) From 4bbd49af53febe29a03f4786483337ab86f097bf Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 20 Mar 2025 18:20:08 +0100 Subject: [PATCH 2788/3148] Capitalize "PIN to Drive" feature name in `teslemetry` (#141011) * Capitalize "PIN to Drive" as feature name in `teslemetry` Fixes the spelling of "PIN" for consistency and turns "PIN to Drive" into the feature name that Tesla uses (in English). * Update test_binary_sensor.ambr --- homeassistant/components/teslemetry/strings.json | 2 +- .../components/teslemetry/snapshots/test_binary_sensor.ambr | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/teslemetry/strings.json b/homeassistant/components/teslemetry/strings.json index 9dc17fd2ef7..c1df7d5aa57 100644 --- a/homeassistant/components/teslemetry/strings.json +++ b/homeassistant/components/teslemetry/strings.json @@ -132,7 +132,7 @@ "name": "Tire pressure warning rear right" }, "pin_to_drive_enabled": { - "name": "Pin to drive enabled" + "name": "PIN to Drive enabled" }, "drive_rail": { "name": "Drive rail" diff --git a/tests/components/teslemetry/snapshots/test_binary_sensor.ambr b/tests/components/teslemetry/snapshots/test_binary_sensor.ambr index 84c50c3ebe9..a295dc16344 100644 --- a/tests/components/teslemetry/snapshots/test_binary_sensor.ambr +++ b/tests/components/teslemetry/snapshots/test_binary_sensor.ambr @@ -1631,7 +1631,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Pin to drive enabled', + 'original_name': 'PIN to Drive enabled', 'platform': 'teslemetry', 'previous_unique_id': None, 'supported_features': 0, @@ -1643,7 +1643,7 @@ # name: test_binary_sensor[binary_sensor.test_pin_to_drive_enabled-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Pin to drive enabled', + 'friendly_name': 'Test PIN to Drive enabled', }), 'context': , 'entity_id': 'binary_sensor.test_pin_to_drive_enabled', @@ -3010,7 +3010,7 @@ # name: test_binary_sensor_refresh[binary_sensor.test_pin_to_drive_enabled-statealt] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Pin to drive enabled', + 'friendly_name': 'Test PIN to Drive enabled', }), 'context': , 'entity_id': 'binary_sensor.test_pin_to_drive_enabled', From 32c6fb862939d2cdd5227df71b10d1fc17c1dc46 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Thu, 20 Mar 2025 18:20:40 +0100 Subject: [PATCH 2789/3148] Bump uv to 0.6.8 (#141007) --- Dockerfile | 2 +- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- script/hassfest/docker/Dockerfile | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Dockerfile b/Dockerfile index 251c92539a1..2efb9d59a44 100644 --- a/Dockerfile +++ b/Dockerfile @@ -31,7 +31,7 @@ RUN \ && go2rtc --version # Install uv -RUN pip3 install uv==0.6.1 +RUN pip3 install uv==0.6.8 WORKDIR /usr/src diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index c72c5c4c646..1399c1884ea 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -68,7 +68,7 @@ standard-telnetlib==3.13.0 typing-extensions>=4.12.2,<5.0 ulid-transform==1.4.0 urllib3>=1.26.5,<2 -uv==0.6.1 +uv==0.6.8 voluptuous-openapi==0.0.6 voluptuous-serialize==2.6.0 voluptuous==0.15.2 diff --git a/pyproject.toml b/pyproject.toml index 74122927660..1bd74791a18 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -77,7 +77,7 @@ dependencies = [ # Temporary setting an upper bound, to prevent compat issues with urllib3>=2 # https://github.com/home-assistant/core/issues/97248 "urllib3>=1.26.5,<2", - "uv==0.6.1", + "uv==0.6.8", "voluptuous==0.15.2", "voluptuous-serialize==2.6.0", "voluptuous-openapi==0.0.6", diff --git a/requirements.txt b/requirements.txt index 1aa96e89bb6..0735e38c89c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -46,7 +46,7 @@ standard-telnetlib==3.13.0 typing-extensions>=4.12.2,<5.0 ulid-transform==1.4.0 urllib3>=1.26.5,<2 -uv==0.6.1 +uv==0.6.8 voluptuous==0.15.2 voluptuous-serialize==2.6.0 voluptuous-openapi==0.0.6 diff --git a/script/hassfest/docker/Dockerfile b/script/hassfest/docker/Dockerfile index 758a4355176..79716b6fec3 100644 --- a/script/hassfest/docker/Dockerfile +++ b/script/hassfest/docker/Dockerfile @@ -14,7 +14,7 @@ WORKDIR "/github/workspace" COPY . /usr/src/homeassistant # Uv is only needed during build -RUN --mount=from=ghcr.io/astral-sh/uv:0.6.1,source=/uv,target=/bin/uv \ +RUN --mount=from=ghcr.io/astral-sh/uv:0.6.8,source=/uv,target=/bin/uv \ # Uv creates a lock file in /tmp --mount=type=tmpfs,target=/tmp \ # Required for PyTurboJPEG From eca10ea5913f230366dfade32eed0c86dbb30f98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Thu, 20 Mar 2025 17:45:52 +0000 Subject: [PATCH 2790/3148] Improve Withings sleep and weight default units (#140665) --- homeassistant/components/withings/sensor.py | 8 ++- .../withings/snapshots/test_sensor.ambr | 56 ++++++++++++------- 2 files changed, 44 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/withings/sensor.py b/homeassistant/components/withings/sensor.py index 28a0fbd1492..f20145f8bf9 100644 --- a/homeassistant/components/withings/sensor.py +++ b/homeassistant/components/withings/sensor.py @@ -122,7 +122,7 @@ MEASUREMENT_SENSORS: dict[ measurement_type=MeasurementType.HEIGHT, translation_key="height", native_unit_of_measurement=UnitOfLength.METERS, - suggested_display_precision=1, + suggested_display_precision=2, device_class=SensorDeviceClass.DISTANCE, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, @@ -326,6 +326,7 @@ SLEEP_SENSORS = [ value_fn=lambda sleep_summary: sleep_summary.deep_sleep_duration, translation_key="deep_sleep", native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.HOURS, device_class=SensorDeviceClass.DURATION, state_class=SensorStateClass.MEASUREMENT, ), @@ -334,6 +335,7 @@ SLEEP_SENSORS = [ value_fn=lambda sleep_summary: sleep_summary.sleep_latency, translation_key="time_to_sleep", native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.HOURS, device_class=SensorDeviceClass.DURATION, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, @@ -343,6 +345,7 @@ SLEEP_SENSORS = [ value_fn=lambda sleep_summary: sleep_summary.wake_up_latency, translation_key="time_to_wakeup", native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.HOURS, device_class=SensorDeviceClass.DURATION, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, @@ -376,6 +379,7 @@ SLEEP_SENSORS = [ value_fn=lambda sleep_summary: sleep_summary.light_sleep_duration, translation_key="light_sleep", native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.HOURS, device_class=SensorDeviceClass.DURATION, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, @@ -385,6 +389,7 @@ SLEEP_SENSORS = [ value_fn=lambda sleep_summary: sleep_summary.rem_sleep_duration, translation_key="rem_sleep", native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.HOURS, device_class=SensorDeviceClass.DURATION, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, @@ -451,6 +456,7 @@ SLEEP_SENSORS = [ value_fn=lambda sleep_summary: sleep_summary.total_time_awake, translation_key="wakeup_time", native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.HOURS, device_class=SensorDeviceClass.DURATION, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, diff --git a/tests/components/withings/snapshots/test_sensor.ambr b/tests/components/withings/snapshots/test_sensor.ambr index ec9fc1ed3fc..f735c506f65 100644 --- a/tests/components/withings/snapshots/test_sensor.ambr +++ b/tests/components/withings/snapshots/test_sensor.ambr @@ -503,6 +503,9 @@ }), 'name': None, 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), }), 'original_device_class': , 'original_icon': None, @@ -512,7 +515,7 @@ 'supported_features': 0, 'translation_key': 'deep_sleep', 'unique_id': 'withings_12345_sleep_deep_duration_seconds', - 'unit_of_measurement': , + 'unit_of_measurement': , }) # --- # name: test_all_entities[sensor.henk_deep_sleep-state] @@ -521,14 +524,14 @@ 'device_class': 'duration', 'friendly_name': 'henk Deep sleep', 'state_class': , - 'unit_of_measurement': , + 'unit_of_measurement': , }), 'context': , 'entity_id': 'sensor.henk_deep_sleep', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '5820', + 'state': '1.617', }) # --- # name: test_all_entities[sensor.henk_diastolic_blood_pressure-entry] @@ -1778,7 +1781,7 @@ 'name': None, 'options': dict({ 'sensor': dict({ - 'suggested_display_precision': 1, + 'suggested_display_precision': 2, }), }), 'original_device_class': , @@ -2242,6 +2245,9 @@ }), 'name': None, 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), }), 'original_device_class': , 'original_icon': None, @@ -2251,7 +2257,7 @@ 'supported_features': 0, 'translation_key': 'light_sleep', 'unique_id': 'withings_12345_sleep_light_duration_seconds', - 'unit_of_measurement': , + 'unit_of_measurement': , }) # --- # name: test_all_entities[sensor.henk_light_sleep-state] @@ -2260,14 +2266,14 @@ 'device_class': 'duration', 'friendly_name': 'henk Light sleep', 'state_class': , - 'unit_of_measurement': , + 'unit_of_measurement': , }), 'context': , 'entity_id': 'sensor.henk_light_sleep', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '10440', + 'state': '2.900', }) # --- # name: test_all_entities[sensor.henk_maximum_heart_rate-entry] @@ -2988,6 +2994,9 @@ }), 'name': None, 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), }), 'original_device_class': , 'original_icon': None, @@ -2997,7 +3006,7 @@ 'supported_features': 0, 'translation_key': 'rem_sleep', 'unique_id': 'withings_12345_sleep_rem_duration_seconds', - 'unit_of_measurement': , + 'unit_of_measurement': , }) # --- # name: test_all_entities[sensor.henk_rem_sleep-state] @@ -3006,14 +3015,14 @@ 'device_class': 'duration', 'friendly_name': 'henk REM sleep', 'state_class': , - 'unit_of_measurement': , + 'unit_of_measurement': , }), 'context': , 'entity_id': 'sensor.henk_rem_sleep', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '2400', + 'state': '0.667', }) # --- # name: test_all_entities[sensor.henk_skin_temperature-entry] @@ -3616,6 +3625,9 @@ }), 'name': None, 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), }), 'original_device_class': , 'original_icon': None, @@ -3625,7 +3637,7 @@ 'supported_features': 0, 'translation_key': 'time_to_sleep', 'unique_id': 'withings_12345_sleep_tosleep_duration_seconds', - 'unit_of_measurement': , + 'unit_of_measurement': , }) # --- # name: test_all_entities[sensor.henk_time_to_sleep-state] @@ -3634,14 +3646,14 @@ 'device_class': 'duration', 'friendly_name': 'henk Time to sleep', 'state_class': , - 'unit_of_measurement': , + 'unit_of_measurement': , }), 'context': , 'entity_id': 'sensor.henk_time_to_sleep', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '540', + 'state': '0.150', }) # --- # name: test_all_entities[sensor.henk_time_to_wakeup-entry] @@ -3668,6 +3680,9 @@ }), 'name': None, 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), }), 'original_device_class': , 'original_icon': None, @@ -3677,7 +3692,7 @@ 'supported_features': 0, 'translation_key': 'time_to_wakeup', 'unique_id': 'withings_12345_sleep_towakeup_duration_seconds', - 'unit_of_measurement': , + 'unit_of_measurement': , }) # --- # name: test_all_entities[sensor.henk_time_to_wakeup-state] @@ -3686,14 +3701,14 @@ 'device_class': 'duration', 'friendly_name': 'henk Time to wakeup', 'state_class': , - 'unit_of_measurement': , + 'unit_of_measurement': , }), 'context': , 'entity_id': 'sensor.henk_time_to_wakeup', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '1140', + 'state': '0.317', }) # --- # name: test_all_entities[sensor.henk_total_calories_burnt_today-entry] @@ -3971,6 +3986,9 @@ }), 'name': None, 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), }), 'original_device_class': , 'original_icon': None, @@ -3980,7 +3998,7 @@ 'supported_features': 0, 'translation_key': 'wakeup_time', 'unique_id': 'withings_12345_sleep_wakeup_duration_seconds', - 'unit_of_measurement': , + 'unit_of_measurement': , }) # --- # name: test_all_entities[sensor.henk_wakeup_time-state] @@ -3989,14 +4007,14 @@ 'device_class': 'duration', 'friendly_name': 'henk Wakeup time', 'state_class': , - 'unit_of_measurement': , + 'unit_of_measurement': , }), 'context': , 'entity_id': 'sensor.henk_wakeup_time', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '3060', + 'state': '0.850', }) # --- # name: test_all_entities[sensor.henk_weight-entry] From f9bb25062129cd6c4ef4c32d020363073d487c4b Mon Sep 17 00:00:00 2001 From: EnjoyingM <6302356+mtielen@users.noreply.github.com> Date: Thu, 20 Mar 2025 18:46:04 +0100 Subject: [PATCH 2791/3148] Wolf Smartset: Adding Heatpump Parameters: Frequency, RPM and Flow rate (#140844) * Add missing Heatpump parameters and units * Fix merge issue * Fix snapshot * Removing bundle_id as extra state attribute till functionality is needed and updating api translation with missing phrase * Fix translations for listparameters * Fix translations for listparameters --- homeassistant/components/wolflink/sensor.py | 25 +++ tests/components/wolflink/conftest.py | 13 ++ .../wolflink/snapshots/test_sensor.ambr | 158 ++++++++++++++++++ 3 files changed, 196 insertions(+) diff --git a/homeassistant/components/wolflink/sensor.py b/homeassistant/components/wolflink/sensor.py index 0f58817a38d..9380c28de89 100644 --- a/homeassistant/components/wolflink/sensor.py +++ b/homeassistant/components/wolflink/sensor.py @@ -7,12 +7,15 @@ from dataclasses import dataclass from wolf_comm.models import ( EnergyParameter, + FlowParameter, + FrequencyParameter, HoursParameter, ListItemParameter, Parameter, PercentageParameter, PowerParameter, Pressure, + RPMParameter, SimpleParameter, Temperature, ) @@ -21,15 +24,19 @@ from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, SensorEntityDescription, + SensorStateClass, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( PERCENTAGE, + REVOLUTIONS_PER_MINUTE, UnitOfEnergy, + UnitOfFrequency, UnitOfPower, UnitOfPressure, UnitOfTemperature, UnitOfTime, + UnitOfVolumeFlowRate, ) from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo @@ -98,6 +105,24 @@ SENSOR_DESCRIPTIONS = [ native_unit_of_measurement=UnitOfTime.HOURS, supported_fn=lambda param: isinstance(param, HoursParameter), ), + WolflinkSensorEntityDescription( + key="flow", + device_class=SensorDeviceClass.VOLUME_FLOW_RATE, + native_unit_of_measurement=UnitOfVolumeFlowRate.LITERS_PER_MINUTE, + supported_fn=lambda param: isinstance(param, FlowParameter), + ), + WolflinkSensorEntityDescription( + key="frequency", + device_class=SensorDeviceClass.FREQUENCY, + native_unit_of_measurement=UnitOfFrequency.HERTZ, + supported_fn=lambda param: isinstance(param, FrequencyParameter), + ), + WolflinkSensorEntityDescription( + key="rpm", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=REVOLUTIONS_PER_MINUTE, + supported_fn=lambda param: isinstance(param, RPMParameter), + ), WolflinkSensorEntityDescription( key="default", supported_fn=lambda param: isinstance(param, SimpleParameter), diff --git a/tests/components/wolflink/conftest.py b/tests/components/wolflink/conftest.py index bfa41c4a4af..5142762b5e4 100644 --- a/tests/components/wolflink/conftest.py +++ b/tests/components/wolflink/conftest.py @@ -8,12 +8,15 @@ from unittest.mock import MagicMock, patch import pytest from wolf_comm import ( EnergyParameter, + FlowParameter, + FrequencyParameter, HoursParameter, ListItem, ListItemParameter, PercentageParameter, PowerParameter, Pressure, + RPMParameter, SimpleParameter, Temperature, Value, @@ -86,6 +89,13 @@ def mock_wolflink() -> Generator[MagicMock]: ), HoursParameter(7002800000, "Hours Parameter", "Heating", 7005200000, 1000), SimpleParameter(1002800000, "Simple Parameter", "DHW", 1005200000, 1000), + FrequencyParameter( + 9002800000, "Frequency Parameter", "Heating", 9005200000, 1000 + ), + RPMParameter(1000280001, "RPM Parameter", "Heating", 10005200000, 7000), + FlowParameter(1100280001, "Flow Parameter", "Heating", 11005200000, 8000), + HoursParameter(7002800000, "Hours Parameter", "Heating", 7005200000, 1000), + SimpleParameter(1002800000, "Simple Parameter", "DHW", 1005200000, 1000), ] wolflink.fetch_value.return_value = [ @@ -97,6 +107,9 @@ def mock_wolflink() -> Generator[MagicMock]: Value(2002800000, "20", 1), Value(7002800000, "10", 1), Value(1002800000, "12", 1), + Value(9002800000, "50", 1), + Value(1000280001, "1500", 1), + Value(1100280001, "5", 1), ] yield wolflink diff --git a/tests/components/wolflink/snapshots/test_sensor.ambr b/tests/components/wolflink/snapshots/test_sensor.ambr index 6fdccfb303c..c1ff80c9630 100644 --- a/tests/components/wolflink/snapshots/test_sensor.ambr +++ b/tests/components/wolflink/snapshots/test_sensor.ambr @@ -84,6 +84,110 @@ 'state': '183', }) # --- +# name: test_sensors[sensor.flow_parameter-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.flow_parameter', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Flow Parameter', + 'platform': 'wolflink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234:11005200000', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.flow_parameter-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'volume_flow_rate', + 'friendly_name': 'Flow Parameter', + 'parameter_id': 11005200000, + 'parent': 'Heating', + 'unit_of_measurement': , + 'value_id': 1100280001, + }), + 'context': , + 'entity_id': 'sensor.flow_parameter', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5', + }) +# --- +# name: test_sensors[sensor.frequency_parameter-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.frequency_parameter', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Frequency Parameter', + 'platform': 'wolflink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234:9005200000', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.frequency_parameter-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'frequency', + 'friendly_name': 'Frequency Parameter', + 'parameter_id': 9005200000, + 'parent': 'Heating', + 'unit_of_measurement': , + 'value_id': 9002800000, + }), + 'context': , + 'entity_id': 'sensor.frequency_parameter', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50', + }) +# --- # name: test_sensors[sensor.hours_parameter-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -341,6 +445,60 @@ 'state': '3', }) # --- +# name: test_sensors[sensor.rpm_parameter-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.rpm_parameter', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'RPM Parameter', + 'platform': 'wolflink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234:10005200000', + 'unit_of_measurement': 'rpm', + }) +# --- +# name: test_sensors[sensor.rpm_parameter-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'RPM Parameter', + 'parameter_id': 10005200000, + 'parent': 'Heating', + 'state_class': , + 'unit_of_measurement': 'rpm', + 'value_id': 1000280001, + }), + 'context': , + 'entity_id': 'sensor.rpm_parameter', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1500', + }) +# --- # name: test_sensors[sensor.simple_parameter-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From a030502489b0411f59da65b80e44baf13eff8eb0 Mon Sep 17 00:00:00 2001 From: poucz Date: Thu, 20 Mar 2025 19:20:12 +0100 Subject: [PATCH 2792/3148] Add MQTT cover stop tilt (#139912) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Stop tilt move. Stop tilt use same payload as cover - payload_stop * Add test for STOP_TILT * Tilt action * Revert "Tilt action" This reverts commit 7ce4fbb086616a900fc53277d379cbf03e9e0339. * Update tests/components/mqtt/test_cover.py Co-authored-by: Abílio Costa * Update homeassistant/components/mqtt/cover.py Co-authored-by: Abílio Costa * Append CONF_PAYLOAD_STOP_TILT * Update homeassistant/components/mqtt/cover.py Co-authored-by: Jan Bouwhuis * Test for new payload * Update tests/components/mqtt/test_cover.py Co-authored-by: Jan Bouwhuis * Update tests/components/mqtt/test_cover.py Co-authored-by: Jan Bouwhuis * Ruff format * abbreviation --------- Co-authored-by: Abílio Costa Co-authored-by: Jan Bouwhuis --- .../components/mqtt/abbreviations.py | 1 + homeassistant/components/mqtt/cover.py | 10 ++++ tests/components/mqtt/test_cover.py | 58 +++++++++++++++++++ 3 files changed, 69 insertions(+) diff --git a/homeassistant/components/mqtt/abbreviations.py b/homeassistant/components/mqtt/abbreviations.py index 2d73cc5865c..a9037a5f247 100644 --- a/homeassistant/components/mqtt/abbreviations.py +++ b/homeassistant/components/mqtt/abbreviations.py @@ -150,6 +150,7 @@ ABBREVIATIONS = { "pl_rst_pct": "payload_reset_percentage", "pl_rst_pr_mode": "payload_reset_preset_mode", "pl_stop": "payload_stop", + "pl_stop_tilt": "payload_stop_tilt", "pl_strt": "payload_start", "pl_ret": "payload_return_to_base", "pl_toff": "payload_turn_off", diff --git a/homeassistant/components/mqtt/cover.py b/homeassistant/components/mqtt/cover.py index c93fdd9c760..428c4d0e205 100644 --- a/homeassistant/components/mqtt/cover.py +++ b/homeassistant/components/mqtt/cover.py @@ -81,6 +81,7 @@ CONF_TILT_STATUS_TOPIC = "tilt_status_topic" CONF_TILT_STATUS_TEMPLATE = "tilt_status_template" CONF_STATE_STOPPED = "state_stopped" +CONF_PAYLOAD_STOP_TILT = "payload_stop_tilt" CONF_TILT_CLOSED_POSITION = "tilt_closed_value" CONF_TILT_MAX = "tilt_max" CONF_TILT_MIN = "tilt_min" @@ -203,6 +204,9 @@ _PLATFORM_SCHEMA_BASE = MQTT_BASE_SCHEMA.extend( vol.Optional(CONF_VALUE_TEMPLATE): cv.template, vol.Optional(CONF_GET_POSITION_TEMPLATE): cv.template, vol.Optional(CONF_TILT_COMMAND_TEMPLATE): cv.template, + vol.Optional(CONF_PAYLOAD_STOP_TILT, default=DEFAULT_PAYLOAD_STOP): vol.Any( + cv.string, None + ), } ).extend(MQTT_ENTITY_COMMON_SCHEMA.schema) @@ -592,6 +596,12 @@ class MqttCover(MqttEntity, CoverEntity): self._attr_current_cover_tilt_position = tilt_percentage self.async_write_ha_state() + async def async_stop_cover_tilt(self, **kwargs: Any) -> None: + """Stop moving the cover tilt.""" + await self.async_publish_with_config( + self._config[CONF_TILT_COMMAND_TOPIC], self._config[CONF_PAYLOAD_STOP_TILT] + ) + async def async_set_cover_position(self, **kwargs: Any) -> None: """Move the cover to a specific position.""" position_percentage = kwargs[ATTR_POSITION] diff --git a/tests/components/mqtt/test_cover.py b/tests/components/mqtt/test_cover.py index 1e45853026a..81530758de7 100644 --- a/tests/components/mqtt/test_cover.py +++ b/tests/components/mqtt/test_cover.py @@ -37,6 +37,7 @@ from homeassistant.const import ( SERVICE_SET_COVER_POSITION, SERVICE_SET_COVER_TILT_POSITION, SERVICE_STOP_COVER, + SERVICE_STOP_COVER_TILT, SERVICE_TOGGLE, SERVICE_TOGGLE_COVER_TILT, STATE_CLOSED, @@ -936,6 +937,63 @@ async def test_send_stop_cover_command( assert state.state == STATE_UNKNOWN +@pytest.mark.parametrize( + ("hass_config", "payload_stop"), + [ + ( + { + mqtt.DOMAIN: { + cover.DOMAIN: { + "name": "test", + "state_topic": "state-topic", + "tilt_command_topic": "tilt-command-topic", + "payload_stop_tilt": "TILT_STOP", + "qos": 2, + } + } + }, + "TILT_STOP", + ), + ( + { + mqtt.DOMAIN: { + cover.DOMAIN: { + "name": "test", + "state_topic": "state-topic", + "tilt_command_topic": "tilt-command-topic", + "qos": 2, + } + } + }, + "STOP", + ), + ], +) +async def test_send_stop_tilt_command( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + payload_stop: str, +) -> None: + """Test the sending of stop_cover_tilt.""" + mqtt_mock = await mqtt_mock_entry() + + state = hass.states.get("cover.test") + assert state.state == STATE_UNKNOWN + + await hass.services.async_call( + cover.DOMAIN, + SERVICE_STOP_COVER_TILT, + {ATTR_ENTITY_ID: "cover.test"}, + blocking=True, + ) + + mqtt_mock.async_publish.assert_called_once_with( + "tilt-command-topic", payload_stop, 2, False + ) + state = hass.states.get("cover.test") + assert state.state == STATE_UNKNOWN + + @pytest.mark.parametrize( "hass_config", [ From a338205b73197231b98f6b9f54f5044dee1d3839 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 20 Mar 2025 19:30:28 +0100 Subject: [PATCH 2793/3148] Fix sentence-casing of "round-trip time" sensors in `ping` (#141012) * Fix sentence-casing of "round-trip time" sensors in `ping` Also add a hyphen for better English grammar. * Update test_sensor.ambr --- homeassistant/components/ping/strings.json | 8 ++++---- tests/components/ping/snapshots/test_sensor.ambr | 12 ++++++------ 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/ping/strings.json b/homeassistant/components/ping/strings.json index ef9f74b4207..c301a1b277d 100644 --- a/homeassistant/components/ping/strings.json +++ b/homeassistant/components/ping/strings.json @@ -2,16 +2,16 @@ "entity": { "sensor": { "round_trip_time_avg": { - "name": "Round Trip Time Average" + "name": "Round-trip time average" }, "round_trip_time_max": { - "name": "Round Trip Time Maximum" + "name": "Round-trip time maximum" }, "round_trip_time_mdev": { - "name": "Round Trip Time Mean Deviation" + "name": "Round-trip time mean deviation" }, "round_trip_time_min": { - "name": "Round Trip Time Minimum" + "name": "Round-trip time minimum" } } }, diff --git a/tests/components/ping/snapshots/test_sensor.ambr b/tests/components/ping/snapshots/test_sensor.ambr index bb811af6a34..6b86c327863 100644 --- a/tests/components/ping/snapshots/test_sensor.ambr +++ b/tests/components/ping/snapshots/test_sensor.ambr @@ -26,7 +26,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Round Trip Time Average', + 'original_name': 'Round-trip time average', 'platform': 'ping', 'previous_unique_id': None, 'supported_features': 0, @@ -38,7 +38,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', - 'friendly_name': '10.10.10.10 Round Trip Time Average', + 'friendly_name': '10.10.10.10 Round-trip time average', 'state_class': , 'unit_of_measurement': , }), @@ -77,7 +77,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Round Trip Time Maximum', + 'original_name': 'Round-trip time maximum', 'platform': 'ping', 'previous_unique_id': None, 'supported_features': 0, @@ -89,7 +89,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', - 'friendly_name': '10.10.10.10 Round Trip Time Maximum', + 'friendly_name': '10.10.10.10 Round-trip time maximum', 'state_class': , 'unit_of_measurement': , }), @@ -134,7 +134,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Round Trip Time Minimum', + 'original_name': 'Round-trip time minimum', 'platform': 'ping', 'previous_unique_id': None, 'supported_features': 0, @@ -146,7 +146,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', - 'friendly_name': '10.10.10.10 Round Trip Time Minimum', + 'friendly_name': '10.10.10.10 Round-trip time minimum', 'state_class': , 'unit_of_measurement': , }), From 53f1dd8adf096cb60e6e42f1d2d35d52fd19f0e8 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Thu, 20 Mar 2025 19:33:45 +0100 Subject: [PATCH 2794/3148] Improve error handling and add exception translations for NextDNS integration (#141005) * Add exception translations * Coverage * Add missing auth_error * Coverage * Use async_start_reauth * Fix test * Remove method placeholder --- homeassistant/components/nextdns/__init__.py | 16 ++++- homeassistant/components/nextdns/button.py | 25 ++++++- .../components/nextdns/coordinator.py | 15 +++- homeassistant/components/nextdns/strings.json | 14 ++++ homeassistant/components/nextdns/switch.py | 14 +++- tests/components/nextdns/test_button.py | 70 ++++++++++++++++++- tests/components/nextdns/test_switch.py | 33 ++++++++- 7 files changed, 174 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/nextdns/__init__.py b/homeassistant/components/nextdns/__init__.py index 478ff215c30..eb8bd26cb9b 100644 --- a/homeassistant/components/nextdns/__init__.py +++ b/homeassistant/components/nextdns/__init__.py @@ -36,6 +36,7 @@ from .const import ( ATTR_SETTINGS, ATTR_STATUS, CONF_PROFILE_ID, + DOMAIN, UPDATE_INTERVAL_ANALYTICS, UPDATE_INTERVAL_CONNECTION, UPDATE_INTERVAL_SETTINGS, @@ -88,9 +89,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: NextDnsConfigEntry) -> b try: nextdns = await NextDns.create(websession, api_key) except (ApiError, ClientConnectorError, RetryError, TimeoutError) as err: - raise ConfigEntryNotReady from err + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="cannot_connect", + translation_placeholders={ + "entry": entry.title, + "error": repr(err), + }, + ) from err except InvalidApiKeyError as err: - raise ConfigEntryAuthFailed from err + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="auth_error", + translation_placeholders={"entry": entry.title}, + ) from err tasks = [] coordinators = {} diff --git a/homeassistant/components/nextdns/button.py b/homeassistant/components/nextdns/button.py index b36c243a463..2adccaa304f 100644 --- a/homeassistant/components/nextdns/button.py +++ b/homeassistant/components/nextdns/button.py @@ -2,15 +2,19 @@ from __future__ import annotations -from nextdns import AnalyticsStatus +from aiohttp import ClientError +from aiohttp.client_exceptions import ClientConnectorError +from nextdns import AnalyticsStatus, ApiError, InvalidApiKeyError from homeassistant.components.button import ButtonEntity, ButtonEntityDescription from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import NextDnsConfigEntry +from .const import DOMAIN from .coordinator import NextDnsUpdateCoordinator PARALLEL_UPDATES = 1 @@ -53,4 +57,21 @@ class NextDnsButton( async def async_press(self) -> None: """Trigger cleaning logs.""" - await self.coordinator.nextdns.clear_logs(self.coordinator.profile_id) + try: + await self.coordinator.nextdns.clear_logs(self.coordinator.profile_id) + except ( + ApiError, + ClientConnectorError, + TimeoutError, + ClientError, + ) as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="method_error", + translation_placeholders={ + "entity": self.entity_id, + "error": repr(err), + }, + ) from err + except InvalidApiKeyError: + self.coordinator.config_entry.async_start_reauth(self.hass) diff --git a/homeassistant/components/nextdns/coordinator.py b/homeassistant/components/nextdns/coordinator.py index 850702e4488..41f6ff43a2a 100644 --- a/homeassistant/components/nextdns/coordinator.py +++ b/homeassistant/components/nextdns/coordinator.py @@ -79,9 +79,20 @@ class NextDnsUpdateCoordinator(DataUpdateCoordinator[CoordinatorDataT]): ClientConnectorError, RetryError, ) as err: - raise UpdateFailed(err) from err + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_error", + translation_placeholders={ + "entry": self.config_entry.title, + "error": repr(err), + }, + ) from err except InvalidApiKeyError as err: - raise ConfigEntryAuthFailed from err + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="auth_error", + translation_placeholders={"entry": self.config_entry.title}, + ) from err async def _async_update_data_internal(self) -> CoordinatorDataT: """Update data via library.""" diff --git a/homeassistant/components/nextdns/strings.json b/homeassistant/components/nextdns/strings.json index f2a5fa2816d..38944a0711e 100644 --- a/homeassistant/components/nextdns/strings.json +++ b/homeassistant/components/nextdns/strings.json @@ -359,5 +359,19 @@ "name": "Force YouTube restricted mode" } } + }, + "exceptions": { + "auth_error": { + "message": "Authentication failed for {entry}, please update your API key" + }, + "cannot_connect": { + "message": "An error occurred while connecting to the NextDNS API for {entry}: {error}" + }, + "method_error": { + "message": "An error occurred while calling the NextDNS API method for {entity}: {error}" + }, + "update_error": { + "message": "An error occurred while retrieving data from the NextDNS API for {entry}: {error}" + } } } diff --git a/homeassistant/components/nextdns/switch.py b/homeassistant/components/nextdns/switch.py index b7c77bd9dbd..8bdca76b955 100644 --- a/homeassistant/components/nextdns/switch.py +++ b/homeassistant/components/nextdns/switch.py @@ -8,7 +8,7 @@ from typing import Any from aiohttp import ClientError from aiohttp.client_exceptions import ClientConnectorError -from nextdns import ApiError, Settings +from nextdns import ApiError, InvalidApiKeyError, Settings from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.const import EntityCategory @@ -18,6 +18,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import NextDnsConfigEntry +from .const import DOMAIN from .coordinator import NextDnsUpdateCoordinator PARALLEL_UPDATES = 1 @@ -582,9 +583,16 @@ class NextDnsSwitch( ClientError, ) as err: raise HomeAssistantError( - "NextDNS API returned an error calling set_setting for" - f" {self.entity_id}: {err}" + translation_domain=DOMAIN, + translation_key="method_error", + translation_placeholders={ + "entity": self.entity_id, + "error": repr(err), + }, ) from err + except InvalidApiKeyError: + self.coordinator.config_entry.async_start_reauth(self.hass) + return if result: self._attr_is_on = new_state diff --git a/tests/components/nextdns/test_button.py b/tests/components/nextdns/test_button.py index 51970b9bb48..3d2422c34a7 100644 --- a/tests/components/nextdns/test_button.py +++ b/tests/components/nextdns/test_button.py @@ -1,12 +1,19 @@ """Test button of NextDNS integration.""" -from unittest.mock import patch +from unittest.mock import Mock, patch +from aiohttp import ClientError +from aiohttp.client_exceptions import ClientConnectorError +from nextdns import ApiError, InvalidApiKeyError +import pytest from syrupy import SnapshotAssertion -from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +from homeassistant.components.nextdns.const import DOMAIN +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from homeassistant.util import dt as dt_util @@ -36,7 +43,7 @@ async def test_button_press(hass: HomeAssistant) -> None: ): await hass.services.async_call( BUTTON_DOMAIN, - "press", + SERVICE_PRESS, {ATTR_ENTITY_ID: "button.fake_profile_clear_logs"}, blocking=True, ) @@ -47,3 +54,60 @@ async def test_button_press(hass: HomeAssistant) -> None: state = hass.states.get("button.fake_profile_clear_logs") assert state assert state.state == now.isoformat() + + +@pytest.mark.parametrize( + "exc", + [ + ApiError(Mock()), + TimeoutError, + ClientConnectorError(Mock(), Mock()), + ClientError, + ], +) +async def test_button_failure(hass: HomeAssistant, exc: Exception) -> None: + """Tests that the press action throws HomeAssistantError.""" + await init_integration(hass) + + with ( + patch("homeassistant.components.nextdns.NextDns.clear_logs", side_effect=exc), + pytest.raises( + HomeAssistantError, + match="An error occurred while calling the NextDNS API method for button.fake_profile_clear_logs", + ), + ): + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: "button.fake_profile_clear_logs"}, + blocking=True, + ) + + +async def test_button_auth_error(hass: HomeAssistant) -> None: + """Tests that the press action starts re-auth flow.""" + entry = await init_integration(hass) + + with patch( + "homeassistant.components.nextdns.NextDns.clear_logs", + side_effect=InvalidApiKeyError, + ): + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: "button.fake_profile_clear_logs"}, + blocking=True, + ) + + assert entry.state is ConfigEntryState.LOADED + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + + flow = flows[0] + assert flow.get("step_id") == "reauth_confirm" + assert flow.get("handler") == DOMAIN + + assert "context" in flow + assert flow["context"].get("source") == SOURCE_REAUTH + assert flow["context"].get("entry_id") == entry.entry_id diff --git a/tests/components/nextdns/test_switch.py b/tests/components/nextdns/test_switch.py index 6e344e34336..c85525ac457 100644 --- a/tests/components/nextdns/test_switch.py +++ b/tests/components/nextdns/test_switch.py @@ -5,12 +5,14 @@ from unittest.mock import Mock, patch from aiohttp import ClientError from aiohttp.client_exceptions import ClientConnectorError -from nextdns import ApiError +from nextdns import ApiError, InvalidApiKeyError import pytest from syrupy import SnapshotAssertion from tenacity import RetryError +from homeassistant.components.nextdns.const import DOMAIN from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_TURN_OFF, @@ -158,3 +160,32 @@ async def test_switch_failure(hass: HomeAssistant, exc: Exception) -> None: {ATTR_ENTITY_ID: "switch.fake_profile_block_page"}, blocking=True, ) + + +async def test_switch_auth_error(hass: HomeAssistant) -> None: + """Tests that the turn on/off action starts re-auth flow.""" + entry = await init_integration(hass) + + with patch( + "homeassistant.components.nextdns.NextDns.set_setting", + side_effect=InvalidApiKeyError, + ): + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "switch.fake_profile_block_page"}, + blocking=True, + ) + + assert entry.state is ConfigEntryState.LOADED + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + + flow = flows[0] + assert flow.get("step_id") == "reauth_confirm" + assert flow.get("handler") == DOMAIN + + assert "context" in flow + assert flow["context"].get("source") == SOURCE_REAUTH + assert flow["context"].get("entry_id") == entry.entry_id From 95014dfdd8258da010459db27f4cb45dec8949ed Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 20 Mar 2025 20:43:13 +0100 Subject: [PATCH 2795/3148] Fix name of `energenie_power_sockets` integration (#141014) * Fix name of `energenie_power_sockets` integration Remove "integration." from the integration name. * Fix --------- Co-authored-by: Joostlek --- homeassistant/components/energenie_power_sockets/strings.json | 2 +- script/hassfest/translations.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/energenie_power_sockets/strings.json b/homeassistant/components/energenie_power_sockets/strings.json index 4e4e49c68fb..bd536568d2c 100644 --- a/homeassistant/components/energenie_power_sockets/strings.json +++ b/homeassistant/components/energenie_power_sockets/strings.json @@ -1,5 +1,5 @@ { - "title": "Energenie Power Sockets Integration.", + "title": "Energenie Power Sockets", "config": { "step": { "user": { diff --git a/script/hassfest/translations.py b/script/hassfest/translations.py index 8e59bd8582e..f4c05f504ca 100644 --- a/script/hassfest/translations.py +++ b/script/hassfest/translations.py @@ -29,6 +29,7 @@ ALLOW_NAME_TRANSLATION = { "cert_expiry", "cpuspeed", "emulated_roku", + "energenie_power_sockets", "faa_delays", "garages_amsterdam", "generic", From 5d1c8ea5375164e4825e5890c22743b5331ac4f1 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Thu, 20 Mar 2025 20:45:07 +0100 Subject: [PATCH 2796/3148] Reolink fix playback headers (#141015) --- homeassistant/components/reolink/views.py | 36 +++++++++++++++++------ tests/components/reolink/test_views.py | 8 ++++- 2 files changed, 34 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/reolink/views.py b/homeassistant/components/reolink/views.py index 1a4585bc997..44265244b18 100644 --- a/homeassistant/components/reolink/views.py +++ b/homeassistant/components/reolink/views.py @@ -83,7 +83,16 @@ class PlaybackProxyView(HomeAssistantView): _LOGGER.warning("Reolink playback proxy error: %s", str(err)) return web.Response(body=str(err), status=HTTPStatus.BAD_REQUEST) + headers = dict(request.headers) + headers.pop("Host", None) + headers.pop("Referer", None) + if _LOGGER.isEnabledFor(logging.DEBUG): + _LOGGER.debug( + "Requested Playback Proxy Method %s, Headers: %s", + request.method, + headers, + ) _LOGGER.debug( "Opening VOD stream from %s: %s", host.api.camera_name(ch), @@ -93,6 +102,7 @@ class PlaybackProxyView(HomeAssistantView): try: reolink_response = await self.session.get( reolink_url, + headers=headers, timeout=ClientTimeout( connect=15, sock_connect=15, sock_read=5, total=None ), @@ -118,18 +128,25 @@ class PlaybackProxyView(HomeAssistantView): ]: err_str = f"Reolink playback expected video/mp4 but got {reolink_response.content_type}" _LOGGER.error(err_str) + if reolink_response.content_type == "text/html": + text = await reolink_response.text() + _LOGGER.debug(text) return web.Response(body=err_str, status=HTTPStatus.BAD_REQUEST) - response = web.StreamResponse( - status=200, - reason="OK", - headers={ - "Content-Type": "video/mp4", - }, + response_headers = dict(reolink_response.headers) + _LOGGER.debug( + "Response Playback Proxy Status %s:%s, Headers: %s", + reolink_response.status, + reolink_response.reason, + response_headers, ) + response_headers["Content-Type"] = "video/mp4" - if reolink_response.content_length is not None: - response.content_length = reolink_response.content_length + response = web.StreamResponse( + status=reolink_response.status, + reason=reolink_response.reason, + headers=response_headers, + ) await response.prepare(request) @@ -141,7 +158,8 @@ class PlaybackProxyView(HomeAssistantView): "Timeout while reading Reolink playback from %s, writing EOF", host.api.nvr_name, ) + finally: + reolink_response.release() - reolink_response.release() await response.write_eof() return response diff --git a/tests/components/reolink/test_views.py b/tests/components/reolink/test_views.py index c994cc59c5d..3521de072b6 100644 --- a/tests/components/reolink/test_views.py +++ b/tests/components/reolink/test_views.py @@ -46,8 +46,12 @@ def get_mock_session( mock_response = Mock() mock_response.content_length = content_length + mock_response.headers = {} + mock_response.status = 200 + mock_response.reason = "OK" mock_response.content_type = content_type mock_response.content.iter_chunked = Mock(return_value=content) + mock_response.text = AsyncMock(return_value="test") mock_session = Mock() mock_session.get = AsyncMock(return_value=mock_response) @@ -178,16 +182,18 @@ async def test_playback_proxy_timeout( assert response.status == 200 +@pytest.mark.parametrize(("content_type"), [("video/x-flv"), ("text/html")]) async def test_playback_wrong_content( hass: HomeAssistant, reolink_connect: MagicMock, config_entry: MockConfigEntry, hass_client: ClientSessionGenerator, + content_type: str, ) -> None: """Test playback proxy URL with a wrong content type in the response.""" reolink_connect.get_vod_source.return_value = (TEST_MIME_TYPE_MP4, TEST_URL) - mock_session = get_mock_session(content_type="video/x-flv") + mock_session = get_mock_session(content_type=content_type) with patch( "homeassistant.components.reolink.views.async_get_clientsession", From 98f71939865523ef5e490e8e5e6d11e19a12ce9f Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 20 Mar 2025 21:23:35 +0100 Subject: [PATCH 2797/3148] Apply sentence-casing to all status codes in `litterrobot` (#141020) --- .../components/litterrobot/strings.json | 42 +++++++++---------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/litterrobot/strings.json b/homeassistant/components/litterrobot/strings.json index 19b007de068..052427f3032 100644 --- a/homeassistant/components/litterrobot/strings.json +++ b/homeassistant/components/litterrobot/strings.json @@ -77,31 +77,31 @@ "status_code": { "name": "Status code", "state": { - "br": "Bonnet Removed", - "ccc": "Clean Cycle Complete", - "ccp": "Clean Cycle In Progress", - "cd": "Cat Detected", - "csf": "Cat Sensor Fault", - "csi": "Cat Sensor Interrupted", - "cst": "Cat Sensor Timing", - "df1": "Drawer Almost Full - 2 Cycles Left", - "df2": "Drawer Almost Full - 1 Cycle Left", - "dfs": "Drawer Full", - "dhf": "Dump + Home Position Fault", - "dpf": "Dump Position Fault", - "ec": "Empty Cycle", - "hpf": "Home Position Fault", + "br": "Bonnet removed", + "ccc": "Clean cycle complete", + "ccp": "Clean cycle in progress", + "cd": "Cat detected", + "csf": "Cat sensor fault", + "csi": "Cat sensor interrupted", + "cst": "Cat sensor timing", + "df1": "Drawer almost full - 2 cycles left", + "df2": "Drawer almost full - 1 cycle left", + "dfs": "Drawer full", + "dhf": "Dump + home position fault", + "dpf": "Dump position fault", + "ec": "Empty cycle", + "hpf": "Home position fault", "off": "[%key:common::state::off%]", "offline": "Offline", - "otf": "Over Torque Fault", + "otf": "Over torque fault", "p": "[%key:common::state::paused%]", - "pd": "Pinch Detect", - "pwrd": "Powering Down", - "pwru": "Powering Up", + "pd": "Pinch detect", + "pwrd": "Powering down", + "pwru": "Powering up", "rdy": "Ready", - "scf": "Cat Sensor Fault At Startup", - "sdf": "Drawer Full At Startup", - "spf": "Pinch Detect At Startup" + "scf": "Cat sensor fault at startup", + "sdf": "Drawer full at startup", + "spf": "Pinch detect at startup" } }, "waste_drawer": { From a45c8d282037506804c75148ae9cefcce9816893 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Thu, 20 Mar 2025 22:52:46 +0100 Subject: [PATCH 2798/3148] Fix some Home Connect options keys (#141023) Fix some options keys --- .../components/home_connect/services.yaml | 46 +++++----- .../components/home_connect/strings.json | 88 +++++++++---------- 2 files changed, 67 insertions(+), 67 deletions(-) diff --git a/homeassistant/components/home_connect/services.yaml b/homeassistant/components/home_connect/services.yaml index 613b3f5af3a..2b53090fd34 100644 --- a/homeassistant/components/home_connect/services.yaml +++ b/homeassistant/components/home_connect/services.yaml @@ -468,11 +468,11 @@ set_program_and_options: translation_key: venting_level options: - cooking_hood_enum_type_stage_fan_off - - cooking_hood_enum_type_stage_fan_stage01 - - cooking_hood_enum_type_stage_fan_stage02 - - cooking_hood_enum_type_stage_fan_stage03 - - cooking_hood_enum_type_stage_fan_stage04 - - cooking_hood_enum_type_stage_fan_stage05 + - cooking_hood_enum_type_stage_fan_stage_01 + - cooking_hood_enum_type_stage_fan_stage_02 + - cooking_hood_enum_type_stage_fan_stage_03 + - cooking_hood_enum_type_stage_fan_stage_04 + - cooking_hood_enum_type_stage_fan_stage_05 cooking_hood_option_intensive_level: example: cooking_hood_enum_type_intensive_stage_intensive_stage1 required: false @@ -528,7 +528,7 @@ set_program_and_options: collapsed: true fields: laundry_care_washer_option_temperature: - example: laundry_care_washer_enum_type_temperature_g_c40 + example: laundry_care_washer_enum_type_temperature_g_c_40 required: false selector: select: @@ -536,14 +536,14 @@ set_program_and_options: translation_key: washer_temperature options: - laundry_care_washer_enum_type_temperature_cold - - laundry_care_washer_enum_type_temperature_g_c20 - - laundry_care_washer_enum_type_temperature_g_c30 - - laundry_care_washer_enum_type_temperature_g_c40 - - laundry_care_washer_enum_type_temperature_g_c50 - - laundry_care_washer_enum_type_temperature_g_c60 - - laundry_care_washer_enum_type_temperature_g_c70 - - laundry_care_washer_enum_type_temperature_g_c80 - - laundry_care_washer_enum_type_temperature_g_c90 + - laundry_care_washer_enum_type_temperature_g_c_20 + - laundry_care_washer_enum_type_temperature_g_c_30 + - laundry_care_washer_enum_type_temperature_g_c_40 + - laundry_care_washer_enum_type_temperature_g_c_50 + - laundry_care_washer_enum_type_temperature_g_c_60 + - laundry_care_washer_enum_type_temperature_g_c_70 + - laundry_care_washer_enum_type_temperature_g_c_80 + - laundry_care_washer_enum_type_temperature_g_c_90 - laundry_care_washer_enum_type_temperature_ul_cold - laundry_care_washer_enum_type_temperature_ul_warm - laundry_care_washer_enum_type_temperature_ul_hot @@ -557,15 +557,15 @@ set_program_and_options: translation_key: spin_speed options: - laundry_care_washer_enum_type_spin_speed_off - - laundry_care_washer_enum_type_spin_speed_r_p_m400 - - laundry_care_washer_enum_type_spin_speed_r_p_m600 - - laundry_care_washer_enum_type_spin_speed_r_p_m700 - - laundry_care_washer_enum_type_spin_speed_r_p_m800 - - laundry_care_washer_enum_type_spin_speed_r_p_m900 - - laundry_care_washer_enum_type_spin_speed_r_p_m1000 - - laundry_care_washer_enum_type_spin_speed_r_p_m1200 - - laundry_care_washer_enum_type_spin_speed_r_p_m1400 - - laundry_care_washer_enum_type_spin_speed_r_p_m1600 + - laundry_care_washer_enum_type_spin_speed_r_p_m_400 + - laundry_care_washer_enum_type_spin_speed_r_p_m_600 + - laundry_care_washer_enum_type_spin_speed_r_p_m_700 + - laundry_care_washer_enum_type_spin_speed_r_p_m_800 + - laundry_care_washer_enum_type_spin_speed_r_p_m_900 + - laundry_care_washer_enum_type_spin_speed_r_p_m_1000 + - laundry_care_washer_enum_type_spin_speed_r_p_m_1200 + - laundry_care_washer_enum_type_spin_speed_r_p_m_1400 + - laundry_care_washer_enum_type_spin_speed_r_p_m_1600 - laundry_care_washer_enum_type_spin_speed_ul_off - laundry_care_washer_enum_type_spin_speed_ul_low - laundry_care_washer_enum_type_spin_speed_ul_medium diff --git a/homeassistant/components/home_connect/strings.json b/homeassistant/components/home_connect/strings.json index 8d377ac9e04..1b4c79f6092 100644 --- a/homeassistant/components/home_connect/strings.json +++ b/homeassistant/components/home_connect/strings.json @@ -417,11 +417,11 @@ "venting_level": { "options": { "cooking_hood_enum_type_stage_fan_off": "Fan off", - "cooking_hood_enum_type_stage_fan_stage01": "Fan stage 1", - "cooking_hood_enum_type_stage_fan_stage02": "Fan stage 2", - "cooking_hood_enum_type_stage_fan_stage03": "Fan stage 3", - "cooking_hood_enum_type_stage_fan_stage04": "Fan stage 4", - "cooking_hood_enum_type_stage_fan_stage05": "Fan stage 5" + "cooking_hood_enum_type_stage_fan_stage_01": "Fan stage 1", + "cooking_hood_enum_type_stage_fan_stage_02": "Fan stage 2", + "cooking_hood_enum_type_stage_fan_stage_03": "Fan stage 3", + "cooking_hood_enum_type_stage_fan_stage_04": "Fan stage 4", + "cooking_hood_enum_type_stage_fan_stage_05": "Fan stage 5" } }, "intensive_level": { @@ -441,14 +441,14 @@ "washer_temperature": { "options": { "laundry_care_washer_enum_type_temperature_cold": "Cold", - "laundry_care_washer_enum_type_temperature_g_c20": "20ºC clothes", - "laundry_care_washer_enum_type_temperature_g_c30": "30ºC clothes", - "laundry_care_washer_enum_type_temperature_g_c40": "40ºC clothes", - "laundry_care_washer_enum_type_temperature_g_c50": "50ºC clothes", - "laundry_care_washer_enum_type_temperature_g_c60": "60ºC clothes", - "laundry_care_washer_enum_type_temperature_g_c70": "70ºC clothes", - "laundry_care_washer_enum_type_temperature_g_c80": "80ºC clothes", - "laundry_care_washer_enum_type_temperature_g_c90": "90ºC clothes", + "laundry_care_washer_enum_type_temperature_g_c_20": "20ºC clothes", + "laundry_care_washer_enum_type_temperature_g_c_30": "30ºC clothes", + "laundry_care_washer_enum_type_temperature_g_c_40": "40ºC clothes", + "laundry_care_washer_enum_type_temperature_g_c_50": "50ºC clothes", + "laundry_care_washer_enum_type_temperature_g_c_60": "60ºC clothes", + "laundry_care_washer_enum_type_temperature_g_c_70": "70ºC clothes", + "laundry_care_washer_enum_type_temperature_g_c_80": "80ºC clothes", + "laundry_care_washer_enum_type_temperature_g_c_90": "90ºC clothes", "laundry_care_washer_enum_type_temperature_ul_cold": "Cold", "laundry_care_washer_enum_type_temperature_ul_warm": "Warm", "laundry_care_washer_enum_type_temperature_ul_hot": "Hot", @@ -458,15 +458,15 @@ "spin_speed": { "options": { "laundry_care_washer_enum_type_spin_speed_off": "Off", - "laundry_care_washer_enum_type_spin_speed_r_p_m400": "400 rpm", - "laundry_care_washer_enum_type_spin_speed_r_p_m600": "600 rpm", - "laundry_care_washer_enum_type_spin_speed_r_p_m700": "700 rpm", - "laundry_care_washer_enum_type_spin_speed_r_p_m800": "800 rpm", - "laundry_care_washer_enum_type_spin_speed_r_p_m900": "900 rpm", - "laundry_care_washer_enum_type_spin_speed_r_p_m1000": "1000 rpm", - "laundry_care_washer_enum_type_spin_speed_r_p_m1200": "1200 rpm", - "laundry_care_washer_enum_type_spin_speed_r_p_m1400": "1400 rpm", - "laundry_care_washer_enum_type_spin_speed_r_p_m1600": "1600 rpm", + "laundry_care_washer_enum_type_spin_speed_r_p_m_400": "400 rpm", + "laundry_care_washer_enum_type_spin_speed_r_p_m_600": "600 rpm", + "laundry_care_washer_enum_type_spin_speed_r_p_m_700": "700 rpm", + "laundry_care_washer_enum_type_spin_speed_r_p_m_800": "800 rpm", + "laundry_care_washer_enum_type_spin_speed_r_p_m_900": "900 rpm", + "laundry_care_washer_enum_type_spin_speed_r_p_m_1000": "1000 rpm", + "laundry_care_washer_enum_type_spin_speed_r_p_m_1200": "1200 rpm", + "laundry_care_washer_enum_type_spin_speed_r_p_m_1400": "1400 rpm", + "laundry_care_washer_enum_type_spin_speed_r_p_m_1600": "1600 rpm", "laundry_care_washer_enum_type_spin_speed_ul_off": "Off", "laundry_care_washer_enum_type_spin_speed_ul_low": "Low", "laundry_care_washer_enum_type_spin_speed_ul_medium": "Medium", @@ -1384,11 +1384,11 @@ "name": "[%key:component::home_connect::services::set_program_and_options::fields::cooking_hood_option_venting_level::name%]", "state": { "cooking_hood_enum_type_stage_fan_off": "[%key:component::home_connect::selector::venting_level::options::cooking_hood_enum_type_stage_fan_off%]", - "cooking_hood_enum_type_stage_fan_stage01": "[%key:component::home_connect::selector::venting_level::options::cooking_hood_enum_type_stage_fan_stage01%]", - "cooking_hood_enum_type_stage_fan_stage02": "[%key:component::home_connect::selector::venting_level::options::cooking_hood_enum_type_stage_fan_stage02%]", - "cooking_hood_enum_type_stage_fan_stage03": "[%key:component::home_connect::selector::venting_level::options::cooking_hood_enum_type_stage_fan_stage03%]", - "cooking_hood_enum_type_stage_fan_stage04": "[%key:component::home_connect::selector::venting_level::options::cooking_hood_enum_type_stage_fan_stage04%]", - "cooking_hood_enum_type_stage_fan_stage05": "[%key:component::home_connect::selector::venting_level::options::cooking_hood_enum_type_stage_fan_stage05%]" + "cooking_hood_enum_type_stage_fan_stage_01": "[%key:component::home_connect::selector::venting_level::options::cooking_hood_enum_type_stage_fan_stage_01%]", + "cooking_hood_enum_type_stage_fan_stage_02": "[%key:component::home_connect::selector::venting_level::options::cooking_hood_enum_type_stage_fan_stage_02%]", + "cooking_hood_enum_type_stage_fan_stage_03": "[%key:component::home_connect::selector::venting_level::options::cooking_hood_enum_type_stage_fan_stage_03%]", + "cooking_hood_enum_type_stage_fan_stage_04": "[%key:component::home_connect::selector::venting_level::options::cooking_hood_enum_type_stage_fan_stage_04%]", + "cooking_hood_enum_type_stage_fan_stage_05": "[%key:component::home_connect::selector::venting_level::options::cooking_hood_enum_type_stage_fan_stage_05%]" } }, "intensive_level": { @@ -1411,14 +1411,14 @@ "name": "[%key:component::home_connect::services::set_program_and_options::fields::laundry_care_washer_option_temperature::name%]", "state": { "laundry_care_washer_enum_type_temperature_cold": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_cold%]", - "laundry_care_washer_enum_type_temperature_g_c20": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c20%]", - "laundry_care_washer_enum_type_temperature_g_c30": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c30%]", - "laundry_care_washer_enum_type_temperature_g_c40": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c40%]", - "laundry_care_washer_enum_type_temperature_g_c50": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c50%]", - "laundry_care_washer_enum_type_temperature_g_c60": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c60%]", - "laundry_care_washer_enum_type_temperature_g_c70": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c70%]", - "laundry_care_washer_enum_type_temperature_g_c80": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c80%]", - "laundry_care_washer_enum_type_temperature_g_c90": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c90%]", + "laundry_care_washer_enum_type_temperature_g_c_20": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c_20%]", + "laundry_care_washer_enum_type_temperature_g_c_30": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c_30%]", + "laundry_care_washer_enum_type_temperature_g_c_40": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c_40%]", + "laundry_care_washer_enum_type_temperature_g_c_50": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c_50%]", + "laundry_care_washer_enum_type_temperature_g_c_60": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c_60%]", + "laundry_care_washer_enum_type_temperature_g_c_70": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c_70%]", + "laundry_care_washer_enum_type_temperature_g_c_80": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c_80%]", + "laundry_care_washer_enum_type_temperature_g_c_90": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c_90%]", "laundry_care_washer_enum_type_temperature_ul_cold": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_ul_cold%]", "laundry_care_washer_enum_type_temperature_ul_warm": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_ul_warm%]", "laundry_care_washer_enum_type_temperature_ul_hot": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_ul_hot%]", @@ -1429,15 +1429,15 @@ "name": "[%key:component::home_connect::services::set_program_and_options::fields::laundry_care_washer_option_spin_speed::name%]", "state": { "laundry_care_washer_enum_type_spin_speed_off": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_off%]", - "laundry_care_washer_enum_type_spin_speed_r_p_m400": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m400%]", - "laundry_care_washer_enum_type_spin_speed_r_p_m600": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m600%]", - "laundry_care_washer_enum_type_spin_speed_r_p_m700": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m700%]", - "laundry_care_washer_enum_type_spin_speed_r_p_m800": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m800%]", - "laundry_care_washer_enum_type_spin_speed_r_p_m900": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m900%]", - "laundry_care_washer_enum_type_spin_speed_r_p_m1000": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m1000%]", - "laundry_care_washer_enum_type_spin_speed_r_p_m1200": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m1200%]", - "laundry_care_washer_enum_type_spin_speed_r_p_m1400": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m1400%]", - "laundry_care_washer_enum_type_spin_speed_r_p_m1600": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m1600%]", + "laundry_care_washer_enum_type_spin_speed_r_p_m_400": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m_400%]", + "laundry_care_washer_enum_type_spin_speed_r_p_m_600": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m_600%]", + "laundry_care_washer_enum_type_spin_speed_r_p_m_700": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m_700%]", + "laundry_care_washer_enum_type_spin_speed_r_p_m_800": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m_800%]", + "laundry_care_washer_enum_type_spin_speed_r_p_m_900": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m_900%]", + "laundry_care_washer_enum_type_spin_speed_r_p_m_1000": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m_1000%]", + "laundry_care_washer_enum_type_spin_speed_r_p_m_1200": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m_1200%]", + "laundry_care_washer_enum_type_spin_speed_r_p_m_1400": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m_1400%]", + "laundry_care_washer_enum_type_spin_speed_r_p_m_1600": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m_1600%]", "laundry_care_washer_enum_type_spin_speed_ul_off": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_ul_off%]", "laundry_care_washer_enum_type_spin_speed_ul_low": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_ul_low%]", "laundry_care_washer_enum_type_spin_speed_ul_medium": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_ul_medium%]", From b9367399172f1482e400cd49bd454d450e460518 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Fri, 21 Mar 2025 00:33:16 +0100 Subject: [PATCH 2799/3148] Update pylint to 3.3.6 (#141028) --- homeassistant/components/mqtt/client.py | 2 -- homeassistant/components/template/template_entity.py | 2 +- homeassistant/components/tts/__init__.py | 2 +- requirements_test.txt | 4 ++-- 4 files changed, 4 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/mqtt/client.py b/homeassistant/components/mqtt/client.py index e985dc9b87f..f6f53599363 100644 --- a/homeassistant/components/mqtt/client.py +++ b/homeassistant/components/mqtt/client.py @@ -1022,8 +1022,6 @@ class MQTT: Resubscribe to all topics we were subscribed to and publish birth message. """ - # pylint: disable-next=import-outside-toplevel - if reason_code.is_failure: # 24: Continue authentication # 25: Re-authenticate diff --git a/homeassistant/components/template/template_entity.py b/homeassistant/components/template/template_entity.py index 93ba1fa7471..88708278758 100644 --- a/homeassistant/components/template/template_entity.py +++ b/homeassistant/components/template/template_entity.py @@ -248,7 +248,7 @@ class _TemplateAttribute: return -class TemplateEntity(AbstractTemplateEntity): # pylint: disable=hass-enforce-class-module +class TemplateEntity(AbstractTemplateEntity): """Entity that uses templates to calculate attributes.""" _attr_available = True diff --git a/homeassistant/components/tts/__init__.py b/homeassistant/components/tts/__init__.py index 350b03a2e80..cb207643471 100644 --- a/homeassistant/components/tts/__init__.py +++ b/homeassistant/components/tts/__init__.py @@ -163,7 +163,7 @@ class TTSCache: self._partial_data.append(chunk) for queue in self._consumers: queue.put_nowait(chunk) - except Exception as err: # pylint: disable=broad-except + except Exception as err: self._loading_error = err raise finally: diff --git a/requirements_test.txt b/requirements_test.txt index 6a95b6dadb1..baf72265c40 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -7,7 +7,7 @@ -c homeassistant/package_constraints.txt -r requirements_test_pre_commit.txt -astroid==3.3.8 +astroid==3.3.9 coverage==7.6.12 freezegun==1.5.1 license-expression==30.4.1 @@ -15,7 +15,7 @@ mock-open==1.4.0 mypy-dev==1.16.0a5 pre-commit==4.0.0 pydantic==2.10.6 -pylint==3.3.4 +pylint==3.3.6 pylint-per-file-ignores==1.4.0 pipdeptree==2.25.1 pytest-asyncio==0.25.3 From 72645dff8b9f8199d1c07a1784f9ac1582163705 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 21 Mar 2025 00:34:48 +0100 Subject: [PATCH 2800/3148] Bump actions/cache from 4.2.2 to 4.2.3 (#140977) --- .github/workflows/ci.yaml | 44 +++++++++++++++++++-------------------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index d0b5923b1fc..2b1606568b5 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -255,7 +255,7 @@ jobs: check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache@v4.2.2 + uses: actions/cache@v4.2.3 with: path: venv key: >- @@ -271,7 +271,7 @@ jobs: uv pip install "$(cat requirements_test.txt | grep pre-commit)" - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache@v4.2.2 + uses: actions/cache@v4.2.3 with: path: ${{ env.PRE_COMMIT_CACHE }} lookup-only: true @@ -301,7 +301,7 @@ jobs: check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.2 + uses: actions/cache/restore@v4.2.3 with: path: venv fail-on-cache-miss: true @@ -310,7 +310,7 @@ jobs: needs.info.outputs.pre-commit_cache_key }} - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache/restore@v4.2.2 + uses: actions/cache/restore@v4.2.3 with: path: ${{ env.PRE_COMMIT_CACHE }} fail-on-cache-miss: true @@ -341,7 +341,7 @@ jobs: check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.2 + uses: actions/cache/restore@v4.2.3 with: path: venv fail-on-cache-miss: true @@ -350,7 +350,7 @@ jobs: needs.info.outputs.pre-commit_cache_key }} - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache/restore@v4.2.2 + uses: actions/cache/restore@v4.2.3 with: path: ${{ env.PRE_COMMIT_CACHE }} fail-on-cache-miss: true @@ -381,7 +381,7 @@ jobs: check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.2 + uses: actions/cache/restore@v4.2.3 with: path: venv fail-on-cache-miss: true @@ -390,7 +390,7 @@ jobs: needs.info.outputs.pre-commit_cache_key }} - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache/restore@v4.2.2 + uses: actions/cache/restore@v4.2.3 with: path: ${{ env.PRE_COMMIT_CACHE }} fail-on-cache-miss: true @@ -497,7 +497,7 @@ jobs: env.HA_SHORT_VERSION }}-$(date -u '+%Y-%m-%dT%H:%M:%s')" >> $GITHUB_OUTPUT - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache@v4.2.2 + uses: actions/cache@v4.2.3 with: path: venv key: >- @@ -505,7 +505,7 @@ jobs: needs.info.outputs.python_cache_key }} - name: Restore uv wheel cache if: steps.cache-venv.outputs.cache-hit != 'true' - uses: actions/cache@v4.2.2 + uses: actions/cache@v4.2.3 with: path: ${{ env.UV_CACHE_DIR }} key: >- @@ -593,7 +593,7 @@ jobs: check-latest: true - name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.2 + uses: actions/cache/restore@v4.2.3 with: path: venv fail-on-cache-miss: true @@ -626,7 +626,7 @@ jobs: check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.2 + uses: actions/cache/restore@v4.2.3 with: path: venv fail-on-cache-miss: true @@ -683,7 +683,7 @@ jobs: check-latest: true - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.2 + uses: actions/cache/restore@v4.2.3 with: path: venv fail-on-cache-miss: true @@ -726,7 +726,7 @@ jobs: check-latest: true - name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.2 + uses: actions/cache/restore@v4.2.3 with: path: venv fail-on-cache-miss: true @@ -773,7 +773,7 @@ jobs: check-latest: true - name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.2 + uses: actions/cache/restore@v4.2.3 with: path: venv fail-on-cache-miss: true @@ -825,7 +825,7 @@ jobs: env.HA_SHORT_VERSION }}-$(date -u '+%Y-%m-%dT%H:%M:%s')" >> $GITHUB_OUTPUT - name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.2 + uses: actions/cache/restore@v4.2.3 with: path: venv fail-on-cache-miss: true @@ -833,7 +833,7 @@ jobs: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ needs.info.outputs.python_cache_key }} - name: Restore mypy cache - uses: actions/cache@v4.2.2 + uses: actions/cache@v4.2.3 with: path: .mypy_cache key: >- @@ -895,7 +895,7 @@ jobs: check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.2 + uses: actions/cache/restore@v4.2.3 with: path: venv fail-on-cache-miss: true @@ -955,7 +955,7 @@ jobs: check-latest: true - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.2 + uses: actions/cache/restore@v4.2.3 with: path: venv fail-on-cache-miss: true @@ -1080,7 +1080,7 @@ jobs: check-latest: true - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.2 + uses: actions/cache/restore@v4.2.3 with: path: venv fail-on-cache-miss: true @@ -1214,7 +1214,7 @@ jobs: check-latest: true - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.2 + uses: actions/cache/restore@v4.2.3 with: path: venv fail-on-cache-miss: true @@ -1365,7 +1365,7 @@ jobs: check-latest: true - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.2 + uses: actions/cache/restore@v4.2.3 with: path: venv fail-on-cache-miss: true From 87c8234cdc0e8b5b26887055b3fa82bc66a8a1d3 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Thu, 20 Mar 2025 20:43:29 -0400 Subject: [PATCH 2801/3148] Allow USB polling monitor on macOS for development (#141029) * Allow USB polling on macOS * Remove `_async_supports_monitoring` --- homeassistant/components/usb/__init__.py | 27 ++++++++---------------- 1 file changed, 9 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/usb/__init__.py b/homeassistant/components/usb/__init__.py index d68742522a0..994f4f71c35 100644 --- a/homeassistant/components/usb/__init__.py +++ b/homeassistant/components/usb/__init__.py @@ -265,8 +265,15 @@ class USBDiscovery: async def async_setup(self) -> None: """Set up USB Discovery.""" - if self._async_supports_monitoring(): - await self._async_start_monitor() + try: + await self._async_start_aiousbwatcher() + except InotifyNotAvailableError as ex: + _LOGGER.info( + "Falling back to periodic filesystem polling for development, " + "aiousbwatcher is not available on this system: %s", + ex, + ) + self._async_start_monitor_polling() self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, self.async_start) self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.async_stop) @@ -281,22 +288,6 @@ class USBDiscovery: if self._request_debouncer: self._request_debouncer.async_shutdown() - @hass_callback - def _async_supports_monitoring(self) -> bool: - return sys.platform == "linux" - - async def _async_start_monitor(self) -> None: - """Start monitoring hardware.""" - try: - await self._async_start_aiousbwatcher() - except InotifyNotAvailableError as ex: - _LOGGER.info( - "Falling back to periodic filesystem polling for development, aiousbwatcher " - "is not available on this system: %s", - ex, - ) - self._async_start_monitor_polling() - @hass_callback def _async_start_monitor_polling(self) -> None: """Start monitoring hardware with polling (for development only!).""" From d12b4a14605200fd0f17f1033d21b23764a2fa2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Fri, 21 Mar 2025 00:53:53 +0000 Subject: [PATCH 2802/3148] Log a warning for modules that log too often (#139708) * Log a warning for modules that log too often * Improve var naming * Increase time window; improve log info * Fix zha type * Fix typo * Ignore debug logs * Use timer to avoid now() calls * Switch to async_track_time_interval * Allow using base QueueLister * Add test for counters reset * Make var names consistent; reduce message/time ratio * Use log times instead of timer * Simplify reset test * Warn only once per module * Remove uneeded counter reset --- homeassistant/util/logging.py | 63 ++++++++++++++++-- tests/util/test_logging.py | 120 ++++++++++++++++++++++++++++++++++ 2 files changed, 179 insertions(+), 4 deletions(-) diff --git a/homeassistant/util/logging.py b/homeassistant/util/logging.py index 2c4eb744614..1e516742bfe 100644 --- a/homeassistant/util/logging.py +++ b/homeassistant/util/logging.py @@ -2,14 +2,16 @@ from __future__ import annotations +from collections import defaultdict from collections.abc import Callable, Coroutine from functools import partial, wraps import inspect import logging import logging.handlers -import queue +from queue import SimpleQueue +import time import traceback -from typing import Any, cast, overload +from typing import Any, cast, overload, override from homeassistant.core import ( HassJobType, @@ -18,6 +20,59 @@ from homeassistant.core import ( get_hassjob_callable_job_type, ) +_LOGGER = logging.getLogger(__name__) + + +class HomeAssistantQueueListener(logging.handlers.QueueListener): + """Custom QueueListener to watch for noisy loggers.""" + + LOG_COUNTS_RESET_INTERVAL = 300 + MAX_LOGS_COUNT = 200 + + _last_reset: float + _log_counts: dict[str, int] + _warned_modules: set[str] + + def __init__( + self, queue: SimpleQueue[logging.Handler], *handlers: logging.Handler + ) -> None: + """Initialize the handler.""" + super().__init__(queue, *handlers) + self._warned_modules = set() + self._reset_counters(time.time()) + + @override + def handle(self, record: logging.LogRecord) -> None: + """Handle the record.""" + super().handle(record) + + if record.levelno < logging.INFO: + return + + if (record.created - self._last_reset) > self.LOG_COUNTS_RESET_INTERVAL: + self._reset_counters(record.created) + + module_name = record.name + if module_name == __name__ or module_name in self._warned_modules: + return + + self._log_counts[module_name] += 1 + module_count = self._log_counts[module_name] + if module_count < self.MAX_LOGS_COUNT: + return + + _LOGGER.warning( + "Module %s is logging too frequently. %d messages since last count", + module_name, + module_count, + ) + self._warned_modules.add(module_name) + + def _reset_counters(self, time_sec: float) -> None: + _LOGGER.debug("Resetting log counters") + self._last_reset = time_sec + self._log_counts = defaultdict(int) + class HomeAssistantQueueHandler(logging.handlers.QueueHandler): """Process the log in another thread.""" @@ -60,7 +115,7 @@ def async_activate_log_queue_handler(hass: HomeAssistant) -> None: This allows us to avoid blocking I/O and formatting messages in the event loop as log messages are written in another thread. """ - simple_queue: queue.SimpleQueue[logging.Handler] = queue.SimpleQueue() + simple_queue: SimpleQueue[logging.Handler] = SimpleQueue() queue_handler = HomeAssistantQueueHandler(simple_queue) logging.root.addHandler(queue_handler) @@ -71,7 +126,7 @@ def async_activate_log_queue_handler(hass: HomeAssistant) -> None: logging.root.removeHandler(handler) migrated_handlers.append(handler) - listener = logging.handlers.QueueListener(simple_queue, *migrated_handlers) + listener = HomeAssistantQueueListener(simple_queue, *migrated_handlers) queue_handler.listener = listener listener.start() diff --git a/tests/util/test_logging.py b/tests/util/test_logging.py index e5b85f35693..d213a68d7f2 100644 --- a/tests/util/test_logging.py +++ b/tests/util/test_logging.py @@ -6,6 +6,7 @@ import logging import queue from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.core import ( @@ -17,6 +18,13 @@ from homeassistant.core import ( from homeassistant.util import logging as logging_util +async def empty_log_queue() -> None: + """Empty the log queue.""" + log_queue: queue.SimpleQueue = logging.root.handlers[0].queue + while not log_queue.empty(): + await asyncio.sleep(0) + + async def test_logging_with_queue_handler() -> None: """Test logging with HomeAssistantQueueHandler.""" @@ -149,3 +157,115 @@ async def test_catch_log_exception_catches_and_logs() -> None: func("failure sync passed") assert saved_args == [("failure sync passed",)] + + +@patch("homeassistant.util.logging.HomeAssistantQueueListener.MAX_LOGS_COUNT", 5) +@pytest.mark.parametrize( + ( + "logger1_count", + "logger1_expected_notices", + "logger2_count", + "logger2_expected_notices", + ), + [(4, 0, 0, 0), (5, 1, 1, 0), (11, 1, 5, 1), (20, 1, 20, 1)], +) +async def test_noisy_loggers( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + logger1_count: int, + logger1_expected_notices: int, + logger2_count: int, + logger2_expected_notices: int, +) -> None: + """Test that noisy loggers all logged as warnings.""" + + logging_util.async_activate_log_queue_handler(hass) + logger1 = logging.getLogger("noisy1") + logger2 = logging.getLogger("noisy2.module") + + for _ in range(logger1_count): + logger1.info("This is a log") + + for _ in range(logger2_count): + logger2.info("This is another log") + + await empty_log_queue() + + assert ( + caplog.text.count( + "Module noisy1 is logging too frequently. 5 messages since last count" + ) + == logger1_expected_notices + ) + assert ( + caplog.text.count( + "Module noisy2.module is logging too frequently. 5 messages since last count" + ) + == logger2_expected_notices + ) + + # close the handler so the queue thread stops + logging.root.handlers[0].close() + + +@patch("homeassistant.util.logging.HomeAssistantQueueListener.MAX_LOGS_COUNT", 5) +async def test_noisy_loggers_ignores_lower_than_info( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test that noisy loggers all logged as warnings, except for levels lower than INFO.""" + + logging_util.async_activate_log_queue_handler(hass) + logger = logging.getLogger("noisy_module") + + for _ in range(5): + logger.debug("This is a log") + + await empty_log_queue() + expected_warning = "Module noisy_module is logging too frequently" + assert caplog.text.count(expected_warning) == 0 + + logger.info("This is a log") + logger.info("This is a log") + logger.warning("This is a log") + logger.error("This is a log") + logger.critical("This is a log") + + await empty_log_queue() + assert caplog.text.count(expected_warning) == 1 + + # close the handler so the queue thread stops + logging.root.handlers[0].close() + + +@patch("homeassistant.util.logging.HomeAssistantQueueListener.MAX_LOGS_COUNT", 3) +async def test_noisy_loggers_counters_reset( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + freezer: FrozenDateTimeFactory, +) -> None: + """Test that noisy logger counters reset periodically.""" + + logging_util.async_activate_log_queue_handler(hass) + logger = logging.getLogger("noisy_module") + + expected_warning = "Module noisy_module is logging too frequently" + + # Do multiple iterations to ensure the reset is periodic + for _ in range(logging_util.HomeAssistantQueueListener.MAX_LOGS_COUNT * 2): + logger.info("This is log 0") + await empty_log_queue() + + freezer.tick( + logging_util.HomeAssistantQueueListener.LOG_COUNTS_RESET_INTERVAL + 1 + ) + + logger.info("This is log 1") + await empty_log_queue() + assert caplog.text.count(expected_warning) == 0 + + logger.info("This is log 2") + logger.info("This is log 3") + await empty_log_queue() + assert caplog.text.count(expected_warning) == 1 + # close the handler so the queue thread stops + logging.root.handlers[0].close() From a388863e6291338f0a13a9e6f87239eef10a0f67 Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Thu, 20 Mar 2025 21:28:37 -0400 Subject: [PATCH 2803/3148] Remove stale devices automatically for Roborock (#140991) * Remove stale devices * Add test * extra test + fix networking patch bug --- homeassistant/components/roborock/__init__.py | 22 +++++++ .../components/roborock/quality_scale.yaml | 6 +- tests/components/roborock/conftest.py | 11 +++- tests/components/roborock/mock_data.py | 3 + .../roborock/snapshots/test_diagnostics.ambr | 2 +- tests/components/roborock/test_init.py | 60 ++++++++++++++++++- 6 files changed, 96 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/roborock/__init__.py b/homeassistant/components/roborock/__init__.py index 1b90adaf6ec..a3ccf0c6eed 100644 --- a/homeassistant/components/roborock/__init__.py +++ b/homeassistant/components/roborock/__init__.py @@ -23,6 +23,7 @@ from roborock.web_api import RoborockApiClient from homeassistant.const import CONF_USERNAME, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr from .const import CONF_BASE_URL, CONF_USER_DATA, DOMAIN, PLATFORMS from .coordinator import ( @@ -134,6 +135,27 @@ async def async_setup_entry(hass: HomeAssistant, entry: RoborockConfigEntry) -> await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + device_registry = dr.async_get(hass) + device_entries = dr.async_entries_for_config_entry( + device_registry, config_entry_id=entry.entry_id + ) + for device in device_entries: + # Remove any devices that are no longer in the account. + # The API returns all devices, even if they are offline + device_duids = { + identifier[1].replace("_dock", "") for identifier in device.identifiers + } + if any(device_duid in device_map for device_duid in device_duids): + continue + _LOGGER.info( + "Removing device: %s because it is no longer exists in your account", + device.name, + ) + device_registry.async_update_device( + device_id=device.id, + remove_config_entry_id=entry.entry_id, + ) + return True diff --git a/homeassistant/components/roborock/quality_scale.yaml b/homeassistant/components/roborock/quality_scale.yaml index feee5cb434c..06a7638c222 100644 --- a/homeassistant/components/roborock/quality_scale.yaml +++ b/homeassistant/components/roborock/quality_scale.yaml @@ -70,11 +70,7 @@ rules: repair-issues: status: todo comment: The Cloud vs Local API warning should probably be a repair issue. - stale-devices: - status: todo - comment: | - The integration does not yet handle stale devices. The roborock app does - support deleting devices and this is a gap #132590 + stale-devices: done # Platinum async-dependency: todo inject-websession: diff --git a/tests/components/roborock/conftest.py b/tests/components/roborock/conftest.py index 332a9143c51..fcd469ca10a 100644 --- a/tests/components/roborock/conftest.py +++ b/tests/components/roborock/conftest.py @@ -11,6 +11,7 @@ import uuid import pytest from roborock import RoborockCategory, RoomMapping from roborock.code_mappings import DyadError, RoborockDyadStateCode, ZeoError, ZeoState +from roborock.containers import NetworkInfo from roborock.roborock_message import RoborockDyadDataProtocol, RoborockZeoProtocol from roborock.version_a01_apis import RoborockMqttClientA01 @@ -29,6 +30,7 @@ from .mock_data import ( MAP_DATA, MULTI_MAP_LIST, NETWORK_INFO, + NETWORK_INFO_2, PROP, SCENES, USER_DATA, @@ -87,6 +89,13 @@ def bypass_api_client_fixture() -> None: yield +def cycle_network_info() -> Generator[NetworkInfo]: + """Return the appropriate network info for the corresponding device.""" + while True: + yield NETWORK_INFO + yield NETWORK_INFO_2 + + @pytest.fixture(name="bypass_api_fixture") def bypass_api_fixture(bypass_api_client_fixture: Any) -> None: """Skip calls to the API.""" @@ -98,7 +107,7 @@ def bypass_api_fixture(bypass_api_client_fixture: Any) -> None: ), patch( "homeassistant.components.roborock.RoborockMqttClientV1.get_networking", - return_value=NETWORK_INFO, + side_effect=cycle_network_info(), ), patch( "homeassistant.components.roborock.coordinator.RoborockLocalClientV1.get_prop", diff --git a/tests/components/roborock/mock_data.py b/tests/components/roborock/mock_data.py index 87acc85b2aa..507e8060653 100644 --- a/tests/components/roborock/mock_data.py +++ b/tests/components/roborock/mock_data.py @@ -1122,6 +1122,9 @@ PROP = DeviceProp( NETWORK_INFO = NetworkInfo( ip="123.232.12.1", ssid="wifi", mac="ac:cc:cc:cc:cc", bssid="bssid", rssi=90 ) +NETWORK_INFO_2 = NetworkInfo( + ip="123.232.12.2", ssid="wifi", mac="ac:cc:cc:cc:cd", bssid="bssid", rssi=90 +) MULTI_MAP_LIST = MultiMapsList.from_dict( { diff --git a/tests/components/roborock/snapshots/test_diagnostics.ambr b/tests/components/roborock/snapshots/test_diagnostics.ambr index 26ecb729312..313824e70ec 100644 --- a/tests/components/roborock/snapshots/test_diagnostics.ambr +++ b/tests/components/roborock/snapshots/test_diagnostics.ambr @@ -357,7 +357,7 @@ }), 'network_info': dict({ 'bssid': '**REDACTED**', - 'ip': '123.232.12.1', + 'ip': '123.232.12.2', 'mac': '**REDACTED**', 'rssi': 90, 'ssid': 'wifi', diff --git a/tests/components/roborock/test_init.py b/tests/components/roborock/test_init.py index 9a749a71e30..226eea816b9 100644 --- a/tests/components/roborock/test_init.py +++ b/tests/components/roborock/test_init.py @@ -17,9 +17,10 @@ from homeassistant.components.roborock.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceRegistry from homeassistant.setup import async_setup_component -from .mock_data import HOME_DATA +from .mock_data import HOME_DATA, NETWORK_INFO from tests.common import MockConfigEntry from tests.typing import ClientSessionGenerator @@ -295,3 +296,60 @@ async def test_no_user_agreement( await hass.config_entries.async_setup(mock_roborock_entry.entry_id) assert mock_roborock_entry.state is ConfigEntryState.SETUP_RETRY assert mock_roborock_entry.error_reason_translation_key == "no_user_agreement" + + +async def test_stale_device( + hass: HomeAssistant, + bypass_api_fixture, + mock_roborock_entry: MockConfigEntry, + device_registry: DeviceRegistry, +) -> None: + """Test that we remove a device if it no longer is given by home_data.""" + await hass.config_entries.async_setup(mock_roborock_entry.entry_id) + assert mock_roborock_entry.state is ConfigEntryState.LOADED + existing_devices = device_registry.devices.get_devices_for_config_entry_id( + mock_roborock_entry.entry_id + ) + assert len(existing_devices) == 6 # 2 for each robot, 1 for A01, 1 for Zeo + hd = deepcopy(HOME_DATA) + hd.devices = [hd.devices[0]] + + with patch( + "homeassistant.components.roborock.RoborockApiClient.get_home_data_v2", + return_value=hd, + ): + await hass.config_entries.async_reload(mock_roborock_entry.entry_id) + await hass.async_block_till_done() + new_devices = device_registry.devices.get_devices_for_config_entry_id( + mock_roborock_entry.entry_id + ) + assert ( + len(new_devices) == 4 + ) # 2 for the one remaining robot. 1 for both the A01s which are shared and + # therefore not deleted. + + +async def test_no_stale_device( + hass: HomeAssistant, + bypass_api_fixture, + mock_roborock_entry: MockConfigEntry, + device_registry: DeviceRegistry, +) -> None: + """Test that we don't remove a device if fails to setup.""" + await hass.config_entries.async_setup(mock_roborock_entry.entry_id) + assert mock_roborock_entry.state is ConfigEntryState.LOADED + existing_devices = device_registry.devices.get_devices_for_config_entry_id( + mock_roborock_entry.entry_id + ) + assert len(existing_devices) == 6 # 2 for each robot, 1 for A01, 1 for Zeo + + with patch( + "homeassistant.components.roborock.RoborockMqttClientV1.get_networking", + side_effect=[NETWORK_INFO, RoborockException], + ): + await hass.config_entries.async_reload(mock_roborock_entry.entry_id) + await hass.async_block_till_done() + new_devices = device_registry.devices.get_devices_for_config_entry_id( + mock_roborock_entry.entry_id + ) + assert len(new_devices) == 6 # 2 for each robot, 1 for A01, 1 for Zeo From a83bf4f51496b25cfe35ac6efdf5540cab0d2c35 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Thu, 20 Mar 2025 19:37:54 -0700 Subject: [PATCH 2804/3148] Add a GetHomeState tool to return the current state of the home (#140971) * Add a GetHomeState tool to return the current state of the home * Fix check for exposing entities * Add "all" to get home state description --- homeassistant/helpers/llm.py | 49 +++++++++++++++++++++++++++++++++--- tests/helpers/test_llm.py | 31 ++++++++++++++++++++--- 2 files changed, 73 insertions(+), 7 deletions(-) diff --git a/homeassistant/helpers/llm.py b/homeassistant/helpers/llm.py index 4ad2bdd6563..5995543914f 100644 --- a/homeassistant/helpers/llm.py +++ b/homeassistant/helpers/llm.py @@ -66,6 +66,11 @@ Answer questions about the world truthfully. Answer in plain text. Keep it simple and to the point. """ +NO_ENTITIES_PROMPT = ( + "Only if the user wants to control a device, tell them to expose entities " + "to their voice assistant in Home Assistant." +) + @callback def async_render_no_api_prompt(hass: HomeAssistant) -> str: @@ -329,10 +334,7 @@ class AssistAPI(API): self, llm_context: LLMContext, exposed_entities: dict | None ) -> str: if not exposed_entities or not exposed_entities["entities"]: - return ( - "Only if the user wants to control a device, tell them to expose entities " - "to their voice assistant in Home Assistant." - ) + return NO_ENTITIES_PROMPT return "\n".join( [ *self._async_get_preable(llm_context), @@ -454,6 +456,9 @@ class AssistAPI(API): for script_entity_id in exposed_entities[SCRIPT_DOMAIN] ) + if exposed_domains: + tools.append(GetHomeStateTool()) + return tools @@ -885,3 +890,39 @@ class CalendarGetEventsTool(Tool): ] return {"success": True, "result": events} + + +class GetHomeStateTool(Tool): + """Tool for getting the current state of exposed entities. + + This returns state for all entities that have been exposed to + the assistant. This is different than the GetState intent, which + returns state for entities based on intent parameters. + """ + + name = "get_home_state" + description = "Get the current state of all devices in the home. " + + async def async_call( + self, + hass: HomeAssistant, + tool_input: ToolInput, + llm_context: LLMContext, + ) -> JsonObjectType: + """Get the current state of exposed entities.""" + if llm_context.assistant is None: + # Note this doesn't happen in practice since this tool won't be + # exposed if no assistant is configured. + return {"success": False, "error": "No assistant configured"} + + exposed_entities = _get_exposed_entities(hass, llm_context.assistant) + if not exposed_entities["entities"]: + return {"success": False, "error": NO_ENTITIES_PROMPT} + prompt = [ + "An overview of the areas and the devices in this smart home:", + yaml_util.dump(list(exposed_entities["entities"].values())), + ] + return { + "success": True, + "result": "\n".join(prompt), + } diff --git a/tests/helpers/test_llm.py b/tests/helpers/test_llm.py index 630ed3f4fa1..45ed009fcf1 100644 --- a/tests/helpers/test_llm.py +++ b/tests/helpers/test_llm.py @@ -181,19 +181,19 @@ async def test_assist_api( assert len(llm.async_get_apis(hass)) == 1 api = await llm.async_get_api(hass, "assist", llm_context) - assert len(api.tools) == 0 + assert [tool.name for tool in api.tools] == ["get_home_state"] # Match all intent_handler.platforms = None api = await llm.async_get_api(hass, "assist", llm_context) - assert len(api.tools) == 1 + assert [tool.name for tool in api.tools] == ["test_intent", "get_home_state"] # Match specific domain intent_handler.platforms = {"light"} api = await llm.async_get_api(hass, "assist", llm_context) - assert len(api.tools) == 1 + assert len(api.tools) == 2 tool = api.tools[0] assert tool.name == "test_intent" assert tool.description == "Execute Home Assistant test_intent intent" @@ -643,6 +643,15 @@ async def test_assist_api_prompt( {exposed_entities_prompt}""" ) + # Verify that the get_home_state tool returns the same results as the exposed_entities_prompt + result = await api.async_call_tool( + llm.ToolInput(tool_name="get_home_state", tool_args={}) + ) + assert result == { + "success": True, + "result": exposed_entities_prompt, + } + # Fake that request is made from a specific device ID with an area llm_context.device_id = device.id area_prompt = ( @@ -1267,3 +1276,19 @@ async def test_calendar_get_events_tool(hass: HomeAssistant) -> None: "start_date_time": now, "end_date_time": dt_util.start_of_local_day() + timedelta(days=7), } + + +async def test_no_tools_exposed(hass: HomeAssistant) -> None: + """Test that tools are not exposed when no entities are exposed.""" + assert await async_setup_component(hass, "homeassistant", {}) + context = Context() + llm_context = llm.LLMContext( + platform="test_platform", + context=context, + user_prompt="test_text", + language="*", + assistant="conversation", + device_id=None, + ) + api = await llm.async_get_api(hass, "assist", llm_context) + assert api.tools == [] From e388d0c3449171dd17198ed5b1db114da3a292ec Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Fri, 21 Mar 2025 02:42:02 -0400 Subject: [PATCH 2805/3148] Bump python-snoo to 0.6.4 (#141030) --- homeassistant/components/snoo/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/snoo/manifest.json b/homeassistant/components/snoo/manifest.json index 0de1e6cf760..4084a7e3e79 100644 --- a/homeassistant/components/snoo/manifest.json +++ b/homeassistant/components/snoo/manifest.json @@ -7,5 +7,5 @@ "iot_class": "cloud_push", "loggers": ["snoo"], "quality_scale": "bronze", - "requirements": ["python-snoo==0.6.3"] + "requirements": ["python-snoo==0.6.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index 9848158a10e..d31204ea3fa 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2471,7 +2471,7 @@ python-roborock==2.14.0 python-smarttub==0.0.39 # homeassistant.components.snoo -python-snoo==0.6.3 +python-snoo==0.6.4 # homeassistant.components.songpal python-songpal==0.16.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index cc2b8acc214..fa95c6431ce 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2001,7 +2001,7 @@ python-roborock==2.14.0 python-smarttub==0.0.39 # homeassistant.components.snoo -python-snoo==0.6.3 +python-snoo==0.6.4 # homeassistant.components.songpal python-songpal==0.16.2 From 110500b860e0096087bae81daabb63d1d0ea6477 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Fri, 21 Mar 2025 02:44:57 -0400 Subject: [PATCH 2806/3148] Bump ZHA to 0.0.53 (#141025) * Bump ZHA to 0.0.53 * Regenerate snapshot --- homeassistant/components/zha/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/zha/snapshots/test_diagnostics.ambr | 11 ++++++++++- 4 files changed, 13 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index d16ce5a64bf..6ed8b253e75 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -21,7 +21,7 @@ "zha", "universal_silabs_flasher" ], - "requirements": ["zha==0.0.52"], + "requirements": ["zha==0.0.53"], "usb": [ { "vid": "10C4", diff --git a/requirements_all.txt b/requirements_all.txt index d31204ea3fa..aa0e19c4768 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3153,7 +3153,7 @@ zeroconf==0.146.0 zeversolar==0.3.2 # homeassistant.components.zha -zha==0.0.52 +zha==0.0.53 # homeassistant.components.zhong_hong zhong-hong-hvac==1.0.13 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fa95c6431ce..1c4f23a343f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2539,7 +2539,7 @@ zeroconf==0.146.0 zeversolar==0.3.2 # homeassistant.components.zha -zha==0.0.52 +zha==0.0.53 # homeassistant.components.zwave_js zwave-js-server-python==0.62.0 diff --git a/tests/components/zha/snapshots/test_diagnostics.ambr b/tests/components/zha/snapshots/test_diagnostics.ambr index ba8aa9ea245..7a599b00a21 100644 --- a/tests/components/zha/snapshots/test_diagnostics.ambr +++ b/tests/components/zha/snapshots/test_diagnostics.ambr @@ -179,7 +179,16 @@ }), '0x0010': dict({ 'attribute': "ZCLAttributeDef(id=0x0010, name='cie_addr', type=, zcl_type=, access=, mandatory=True, is_manufacturer_specific=False)", - 'value': None, + 'value': list([ + 50, + 79, + 50, + 2, + 0, + 141, + 21, + 0, + ]), }), '0x0011': dict({ 'attribute': "ZCLAttributeDef(id=0x0011, name='zone_id', type=, zcl_type=, access=, mandatory=True, is_manufacturer_specific=False)", From 021e4fab8c043df1ae2b720ab09818f04e2dc441 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 20 Mar 2025 21:12:55 -1000 Subject: [PATCH 2807/3148] Bump habluetooth to 3.36.0 (#141037) * Bump habluetooth to 3.35.0 changelog: https://github.com/Bluetooth-Devices/habluetooth/compare/v3.32.0...v3.35.0 * adjust --- 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 a0679f8e842..7dfb21a6e0b 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -21,6 +21,6 @@ "bluetooth-auto-recovery==1.4.5", "bluetooth-data-tools==1.26.1", "dbus-fast==2.39.6", - "habluetooth==3.32.0" + "habluetooth==3.36.0" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 1399c1884ea..a797b1b5146 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -34,7 +34,7 @@ dbus-fast==2.39.6 fnv-hash-fast==1.4.0 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 -habluetooth==3.32.0 +habluetooth==3.36.0 hass-nabucasa==0.94.0 hassil==2.2.3 home-assistant-bluetooth==1.13.1 diff --git a/requirements_all.txt b/requirements_all.txt index aa0e19c4768..e45155eb492 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1112,7 +1112,7 @@ ha-silabs-firmware-client==0.2.0 habiticalib==0.3.7 # homeassistant.components.bluetooth -habluetooth==3.32.0 +habluetooth==3.36.0 # homeassistant.components.cloud hass-nabucasa==0.94.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1c4f23a343f..ac047685724 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -950,7 +950,7 @@ ha-philipsjs==3.2.2 habiticalib==0.3.7 # homeassistant.components.bluetooth -habluetooth==3.32.0 +habluetooth==3.36.0 # homeassistant.components.cloud hass-nabucasa==0.94.0 From bce7fcc3c60dc3c18464c46ed7e923e7bde4fdb4 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Fri, 21 Mar 2025 09:44:02 +0100 Subject: [PATCH 2808/3148] Capitalize "DIP" abbreviation in `apcupsd` (#141048) As "DIP" stands for "dual in-line package" it becomes capitalized as an abbreviation. --- homeassistant/components/apcupsd/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/apcupsd/strings.json b/homeassistant/components/apcupsd/strings.json index 93102ac1393..fb5df9ec390 100644 --- a/homeassistant/components/apcupsd/strings.json +++ b/homeassistant/components/apcupsd/strings.json @@ -57,7 +57,7 @@ "name": "Status date" }, "dip_switch_settings": { - "name": "Dip switch settings" + "name": "DIP switch settings" }, "low_battery_signal": { "name": "Low battery signal" From 2785688f573165462bb360f3caa467ba77d931f4 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Fri, 21 Mar 2025 10:14:20 +0100 Subject: [PATCH 2809/3148] Add `calibrate` button for Shelly BLU TRV (#140578) * Initial commit * Refactor * Call async_add_entities() once * Type * Cleaning * `supported` is not needed here * Add error handling * Add test * Fix name * Change class name * Change method name * Move BLU_TRV_TIMEOUT * Fix BLU_TRV_TIMEOUT import * Coverage * Use test snapshots * Support error translations * Fix tests * Introduce ShellyBaseButton class * Rename press_method to _press_method * Improve exception strings --- homeassistant/components/shelly/button.py | 147 ++++++++++++-- homeassistant/components/shelly/climate.py | 8 +- homeassistant/components/shelly/const.py | 3 - homeassistant/components/shelly/number.py | 4 +- homeassistant/components/shelly/strings.json | 8 + .../shelly/snapshots/test_button.ambr | 96 +++++++++ tests/components/shelly/test_button.py | 182 +++++++++++++++++- tests/components/shelly/test_climate.py | 3 +- tests/components/shelly/test_number.py | 4 +- 9 files changed, 426 insertions(+), 29 deletions(-) create mode 100644 tests/components/shelly/snapshots/test_button.ambr diff --git a/homeassistant/components/shelly/button.py b/homeassistant/components/shelly/button.py index 1f3c555a64b..15bde4fbdff 100644 --- a/homeassistant/components/shelly/button.py +++ b/homeassistant/components/shelly/button.py @@ -2,12 +2,13 @@ from __future__ import annotations -from collections.abc import Callable, Coroutine +from collections.abc import Callable from dataclasses import dataclass from functools import partial from typing import TYPE_CHECKING, Any, Final -from aioshelly.const import RPC_GENERATIONS +from aioshelly.const import BLU_TRV_IDENTIFIER, MODEL_BLU_GATEWAY, RPC_GENERATIONS +from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError, RpcCallError from homeassistant.components.button import ( ButtonDeviceClass, @@ -16,15 +17,20 @@ from homeassistant.components.button import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo +from homeassistant.helpers.device_registry import ( + CONNECTION_BLUETOOTH, + CONNECTION_NETWORK_MAC, + DeviceInfo, +) from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import slugify -from .const import LOGGER, SHELLY_GAS_MODELS +from .const import DOMAIN, LOGGER, SHELLY_GAS_MODELS from .coordinator import ShellyBlockCoordinator, ShellyConfigEntry, ShellyRpcCoordinator -from .utils import get_device_entry_gen +from .utils import get_device_entry_gen, get_rpc_key_ids @dataclass(frozen=True, kw_only=True) @@ -33,7 +39,7 @@ class ShellyButtonDescription[ ](ButtonEntityDescription): """Class to describe a Button entity.""" - press_action: Callable[[_ShellyCoordinatorT], Coroutine[Any, Any, None]] + press_action: str supported: Callable[[_ShellyCoordinatorT], bool] = lambda _: True @@ -44,14 +50,14 @@ BUTTONS: Final[list[ShellyButtonDescription[Any]]] = [ name="Reboot", device_class=ButtonDeviceClass.RESTART, entity_category=EntityCategory.CONFIG, - press_action=lambda coordinator: coordinator.device.trigger_reboot(), + press_action="trigger_reboot", ), ShellyButtonDescription[ShellyBlockCoordinator]( key="self_test", name="Self test", translation_key="self_test", entity_category=EntityCategory.DIAGNOSTIC, - press_action=lambda coordinator: coordinator.device.trigger_shelly_gas_self_test(), + press_action="trigger_shelly_gas_self_test", supported=lambda coordinator: coordinator.device.model in SHELLY_GAS_MODELS, ), ShellyButtonDescription[ShellyBlockCoordinator]( @@ -59,7 +65,7 @@ BUTTONS: Final[list[ShellyButtonDescription[Any]]] = [ name="Mute", translation_key="mute", entity_category=EntityCategory.CONFIG, - press_action=lambda coordinator: coordinator.device.trigger_shelly_gas_mute(), + press_action="trigger_shelly_gas_mute", supported=lambda coordinator: coordinator.device.model in SHELLY_GAS_MODELS, ), ShellyButtonDescription[ShellyBlockCoordinator]( @@ -67,11 +73,22 @@ BUTTONS: Final[list[ShellyButtonDescription[Any]]] = [ name="Unmute", translation_key="unmute", entity_category=EntityCategory.CONFIG, - press_action=lambda coordinator: coordinator.device.trigger_shelly_gas_unmute(), + press_action="trigger_shelly_gas_unmute", supported=lambda coordinator: coordinator.device.model in SHELLY_GAS_MODELS, ), ] +BLU_TRV_BUTTONS: Final[list[ShellyButtonDescription]] = [ + ShellyButtonDescription[ShellyRpcCoordinator]( + key="calibrate", + name="Calibrate", + translation_key="calibrate", + entity_category=EntityCategory.CONFIG, + press_action="trigger_blu_trv_calibration", + supported=lambda coordinator: coordinator.device.model == MODEL_BLU_GATEWAY, + ), +] + @callback def async_migrate_unique_ids( @@ -123,14 +140,28 @@ async def async_setup_entry( hass, config_entry.entry_id, partial(async_migrate_unique_ids, coordinator) ) - async_add_entities( + entities: list[ShellyButton | ShellyBluTrvButton] = [] + + entities.extend( ShellyButton(coordinator, button) for button in BUTTONS if button.supported(coordinator) ) + if blutrv_key_ids := get_rpc_key_ids(coordinator.device.status, BLU_TRV_IDENTIFIER): + if TYPE_CHECKING: + assert isinstance(coordinator, ShellyRpcCoordinator) -class ShellyButton( + entities.extend( + ShellyBluTrvButton(coordinator, button, id_) + for id_ in blutrv_key_ids + for button in BLU_TRV_BUTTONS + ) + + async_add_entities(entities) + + +class ShellyBaseButton( CoordinatorEntity[ShellyRpcCoordinator | ShellyBlockCoordinator], ButtonEntity ): """Defines a Shelly base button.""" @@ -148,14 +179,100 @@ class ShellyButton( ) -> None: """Initialize Shelly button.""" super().__init__(coordinator) + self.entity_description = description + async def async_press(self) -> None: + """Triggers the Shelly button press service.""" + try: + await self._press_method() + except DeviceConnectionError as err: + self.coordinator.last_update_success = False + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="device_communication_action_error", + translation_placeholders={ + "entity": self.entity_id, + "device": self.coordinator.device.name, + "error": repr(err), + }, + ) from err + except RpcCallError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="rpc_call_action_error", + translation_placeholders={ + "entity": self.entity_id, + "device": self.coordinator.device.name, + "error": repr(err), + }, + ) from err + except InvalidAuthError: + await self.coordinator.async_shutdown_device_and_start_reauth() + + async def _press_method(self) -> None: + """Press method.""" + raise NotImplementedError + + +class ShellyButton(ShellyBaseButton): + """Defines a Shelly button.""" + + def __init__( + self, + coordinator: ShellyRpcCoordinator | ShellyBlockCoordinator, + description: ShellyButtonDescription[ + ShellyRpcCoordinator | ShellyBlockCoordinator + ], + ) -> None: + """Initialize Shelly button.""" + super().__init__(coordinator, description) + self._attr_name = f"{coordinator.device.name} {description.name}" self._attr_unique_id = f"{coordinator.mac}_{description.key}" self._attr_device_info = DeviceInfo( connections={(CONNECTION_NETWORK_MAC, coordinator.mac)} ) - async def async_press(self) -> None: - """Triggers the Shelly button press service.""" - await self.entity_description.press_action(self.coordinator) + async def _press_method(self) -> None: + """Press method.""" + method = getattr(self.coordinator.device, self.entity_description.press_action) + + if TYPE_CHECKING: + assert method is not None + + await method() + + +class ShellyBluTrvButton(ShellyBaseButton): + """Represent a Shelly BLU TRV button.""" + + def __init__( + self, + coordinator: ShellyRpcCoordinator, + description: ShellyButtonDescription, + id_: int, + ) -> None: + """Initialize.""" + super().__init__(coordinator, description) + + ble_addr: str = coordinator.device.config[f"{BLU_TRV_IDENTIFIER}:{id_}"]["addr"] + device_name = ( + coordinator.device.config[f"{BLU_TRV_IDENTIFIER}:{id_}"]["name"] + or f"shellyblutrv-{ble_addr.replace(':', '')}" + ) + self._attr_name = f"{device_name} {description.name}" + self._attr_unique_id = f"{ble_addr}_{description.key}" + self._attr_device_info = DeviceInfo( + connections={(CONNECTION_BLUETOOTH, ble_addr)} + ) + self._id = id_ + + async def _press_method(self) -> None: + """Press method.""" + method = getattr(self.coordinator.device, self.entity_description.press_action) + + if TYPE_CHECKING: + assert method is not None + + await method(self._id) diff --git a/homeassistant/components/shelly/climate.py b/homeassistant/components/shelly/climate.py index a3ec9be7cb0..c3612ed3f4f 100644 --- a/homeassistant/components/shelly/climate.py +++ b/homeassistant/components/shelly/climate.py @@ -7,7 +7,12 @@ from dataclasses import asdict, dataclass from typing import Any, cast from aioshelly.block_device import Block -from aioshelly.const import BLU_TRV_IDENTIFIER, BLU_TRV_MODEL_NAME, RPC_GENERATIONS +from aioshelly.const import ( + BLU_TRV_IDENTIFIER, + BLU_TRV_MODEL_NAME, + BLU_TRV_TIMEOUT, + RPC_GENERATIONS, +) from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError from homeassistant.components.climate import ( @@ -36,7 +41,6 @@ from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM from .const import ( BLU_TRV_TEMPERATURE_SETTINGS, - BLU_TRV_TIMEOUT, DOMAIN, LOGGER, NOT_CALIBRATED_ISSUE_ID, diff --git a/homeassistant/components/shelly/const.py b/homeassistant/components/shelly/const.py index d47f2b0ae80..c94c827b7db 100644 --- a/homeassistant/components/shelly/const.py +++ b/homeassistant/components/shelly/const.py @@ -271,9 +271,6 @@ API_WS_URL = "/api/shelly/ws" COMPONENT_ID_PATTERN = re.compile(r"[a-z\d]+:\d+") -# value confirmed by Shelly team -BLU_TRV_TIMEOUT = 60 - ROLE_TO_DEVICE_CLASS_MAP = { "current_humidity": SensorDeviceClass.HUMIDITY, "current_temperature": SensorDeviceClass.TEMPERATURE, diff --git a/homeassistant/components/shelly/number.py b/homeassistant/components/shelly/number.py index 59716f39c7f..a8e6de1ca73 100644 --- a/homeassistant/components/shelly/number.py +++ b/homeassistant/components/shelly/number.py @@ -7,7 +7,7 @@ from dataclasses import dataclass from typing import TYPE_CHECKING, Any, Final, cast from aioshelly.block_device import Block -from aioshelly.const import RPC_GENERATIONS +from aioshelly.const import BLU_TRV_TIMEOUT, RPC_GENERATIONS from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError from homeassistant.components.number import ( @@ -25,7 +25,7 @@ from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, DeviceIn from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_registry import RegistryEntry -from .const import BLU_TRV_TIMEOUT, CONF_SLEEP_PERIOD, LOGGER, VIRTUAL_NUMBER_MODE_MAP +from .const import CONF_SLEEP_PERIOD, LOGGER, VIRTUAL_NUMBER_MODE_MAP from .coordinator import ShellyBlockCoordinator, ShellyConfigEntry, ShellyRpcCoordinator from .entity import ( BlockEntityDescription, diff --git a/homeassistant/components/shelly/strings.json b/homeassistant/components/shelly/strings.json index ba9a8492194..22d88928387 100644 --- a/homeassistant/components/shelly/strings.json +++ b/homeassistant/components/shelly/strings.json @@ -203,6 +203,14 @@ } } }, + "exceptions": { + "device_communication_action_error": { + "message": "Device communication error occurred while calling the entity {entity} action for {device} device: {error}" + }, + "rpc_call_action_error": { + "message": "RPC call error occurred while calling the entity {entity} action for {device} device: {error}" + } + }, "issues": { "device_not_calibrated": { "title": "Shelly device {device_name} is not calibrated", diff --git a/tests/components/shelly/snapshots/test_button.ambr b/tests/components/shelly/snapshots/test_button.ambr new file mode 100644 index 00000000000..f5a38f1b847 --- /dev/null +++ b/tests/components/shelly/snapshots/test_button.ambr @@ -0,0 +1,96 @@ +# serializer version: 1 +# name: test_rpc_blu_trv_button[button.trv_name_calibrate-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.trv_name_calibrate', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'TRV-Name Calibrate', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'calibrate', + 'unique_id': 'f8:44:77:25:f0:dd_calibrate', + 'unit_of_measurement': None, + }) +# --- +# name: test_rpc_blu_trv_button[button.trv_name_calibrate-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'TRV-Name Calibrate', + }), + 'context': , + 'entity_id': 'button.trv_name_calibrate', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_rpc_button[button.test_name_reboot-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.test_name_reboot', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Test name Reboot', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC_reboot', + 'unit_of_measurement': None, + }) +# --- +# name: test_rpc_button[button.test_name_reboot-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'restart', + 'friendly_name': 'Test name Reboot', + }), + 'context': , + 'entity_id': 'button.test_name_reboot', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/shelly/test_button.py b/tests/components/shelly/test_button.py index 14349411670..2a9720ca7ae 100644 --- a/tests/components/shelly/test_button.py +++ b/tests/components/shelly/test_button.py @@ -2,12 +2,17 @@ from unittest.mock import Mock +from aioshelly.const import MODEL_BLU_GATEWAY_G3 +from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError, RpcCallError import pytest +from syrupy import SnapshotAssertion from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS from homeassistant.components.shelly.const import DOMAIN +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_registry import EntityRegistry from . import init_integration @@ -38,7 +43,10 @@ async def test_block_button( async def test_rpc_button( - hass: HomeAssistant, mock_rpc_device: Mock, entity_registry: EntityRegistry + hass: HomeAssistant, + mock_rpc_device: Mock, + entity_registry: EntityRegistry, + snapshot: SnapshotAssertion, ) -> None: """Test rpc device OTA button.""" await init_integration(hass, 2) @@ -46,11 +54,11 @@ async def test_rpc_button( entity_id = "button.test_name_reboot" # reboot button - assert hass.states.get(entity_id).state == STATE_UNKNOWN + state = hass.states.get(entity_id) + assert state == snapshot(name=f"{entity_id}-state") entry = entity_registry.async_get(entity_id) - assert entry - assert entry.unique_id == "123456789ABC_reboot" + assert entry == snapshot(name=f"{entity_id}-entry") await hass.services.async_call( BUTTON_DOMAIN, @@ -61,6 +69,68 @@ async def test_rpc_button( assert mock_rpc_device.trigger_reboot.call_count == 1 +@pytest.mark.parametrize( + ("exception", "error"), + [ + ( + DeviceConnectionError, + "Device communication error occurred while calling the entity button.test_name_reboot action for Test name device", + ), + ( + RpcCallError(999), + "RPC call error occurred while calling the entity button.test_name_reboot action for Test name device", + ), + ], +) +async def test_rpc_button_exc( + hass: HomeAssistant, + mock_rpc_device: Mock, + exception: Exception, + error: str, +) -> None: + """Test RPC button with exception.""" + await init_integration(hass, 2) + + mock_rpc_device.trigger_reboot.side_effect = exception + + with pytest.raises(HomeAssistantError, match=error): + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: "button.test_name_reboot"}, + blocking=True, + ) + + +async def test_rpc_button_reauth_error( + hass: HomeAssistant, mock_rpc_device: Mock +) -> None: + """Test rpc device OTA button with authentication error.""" + entry = await init_integration(hass, 2) + + mock_rpc_device.trigger_reboot.side_effect = InvalidAuthError + + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: "button.test_name_reboot"}, + blocking=True, + ) + + assert entry.state is ConfigEntryState.LOADED + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + + flow = flows[0] + assert flow.get("step_id") == "reauth_confirm" + assert flow.get("handler") == DOMAIN + + assert "context" in flow + assert flow["context"].get("source") == SOURCE_REAUTH + assert flow["context"].get("entry_id") == entry.entry_id + + @pytest.mark.parametrize( ("gen", "old_unique_id", "new_unique_id", "migration"), [ @@ -104,3 +174,107 @@ async def test_migrate_unique_id( bool("Migrating unique_id for button.test_name_reboot" in caplog.text) == migration ) + + +async def test_rpc_blu_trv_button( + hass: HomeAssistant, + mock_blu_trv: Mock, + entity_registry: EntityRegistry, + monkeypatch: pytest.MonkeyPatch, + snapshot: SnapshotAssertion, +) -> None: + """Test RPC BLU TRV button.""" + monkeypatch.delitem(mock_blu_trv.status, "script:1") + monkeypatch.delitem(mock_blu_trv.status, "script:2") + monkeypatch.delitem(mock_blu_trv.status, "script:3") + + await init_integration(hass, 3, model=MODEL_BLU_GATEWAY_G3) + + entity_id = "button.trv_name_calibrate" + + state = hass.states.get(entity_id) + assert state == snapshot(name=f"{entity_id}-state") + + entry = entity_registry.async_get(entity_id) + assert entry == snapshot(name=f"{entity_id}-entry") + + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + assert mock_blu_trv.trigger_blu_trv_calibration.call_count == 1 + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + ( + DeviceConnectionError, + "Device communication error occurred while calling the entity button.trv_name_calibrate action for Test name device", + ), + ( + RpcCallError(999), + "RPC call error occurred while calling the entity button.trv_name_calibrate action for Test name device", + ), + ], +) +async def test_rpc_blu_trv_button_exc( + hass: HomeAssistant, + mock_blu_trv: Mock, + monkeypatch: pytest.MonkeyPatch, + exception: Exception, + error: str, +) -> None: + """Test RPC BLU TRV button with exception.""" + monkeypatch.delitem(mock_blu_trv.status, "script:1") + monkeypatch.delitem(mock_blu_trv.status, "script:2") + monkeypatch.delitem(mock_blu_trv.status, "script:3") + + await init_integration(hass, 3, model=MODEL_BLU_GATEWAY_G3) + + mock_blu_trv.trigger_blu_trv_calibration.side_effect = exception + + with pytest.raises(HomeAssistantError, match=error): + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: "button.trv_name_calibrate"}, + blocking=True, + ) + + +async def test_rpc_blu_trv_button_auth_error( + hass: HomeAssistant, + mock_blu_trv: Mock, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test RPC BLU TRV button with authentication error.""" + monkeypatch.delitem(mock_blu_trv.status, "script:1") + monkeypatch.delitem(mock_blu_trv.status, "script:2") + monkeypatch.delitem(mock_blu_trv.status, "script:3") + + entry = await init_integration(hass, 3, model=MODEL_BLU_GATEWAY_G3) + + mock_blu_trv.trigger_blu_trv_calibration.side_effect = InvalidAuthError + + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: "button.trv_name_calibrate"}, + blocking=True, + ) + + assert entry.state is ConfigEntryState.LOADED + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + + flow = flows[0] + assert flow.get("step_id") == "reauth_confirm" + assert flow.get("handler") == DOMAIN + + assert "context" in flow + assert flow["context"].get("source") == SOURCE_REAUTH + assert flow["context"].get("entry_id") == entry.entry_id diff --git a/tests/components/shelly/test_climate.py b/tests/components/shelly/test_climate.py index fcfed090a66..ac9c7967540 100644 --- a/tests/components/shelly/test_climate.py +++ b/tests/components/shelly/test_climate.py @@ -5,6 +5,7 @@ from unittest.mock import AsyncMock, Mock, PropertyMock from aioshelly.const import ( BLU_TRV_IDENTIFIER, + BLU_TRV_TIMEOUT, MODEL_BLU_GATEWAY_G3, MODEL_VALVE, MODEL_WALL_DISPLAY, @@ -27,7 +28,7 @@ from homeassistant.components.climate import ( HVACAction, HVACMode, ) -from homeassistant.components.shelly.const import BLU_TRV_TIMEOUT, DOMAIN +from homeassistant.components.shelly.const import DOMAIN from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.const import ( diff --git a/tests/components/shelly/test_number.py b/tests/components/shelly/test_number.py index 6bddd1eeb23..c032a137bfc 100644 --- a/tests/components/shelly/test_number.py +++ b/tests/components/shelly/test_number.py @@ -3,7 +3,7 @@ from copy import deepcopy from unittest.mock import AsyncMock, Mock -from aioshelly.const import MODEL_BLU_GATEWAY_G3 +from aioshelly.const import BLU_TRV_TIMEOUT, MODEL_BLU_GATEWAY_G3 from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError import pytest from syrupy import SnapshotAssertion @@ -18,7 +18,7 @@ from homeassistant.components.number import ( SERVICE_SET_VALUE, NumberMode, ) -from homeassistant.components.shelly.const import BLU_TRV_TIMEOUT, DOMAIN +from homeassistant.components.shelly.const import DOMAIN from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.const import ATTR_ENTITY_ID, ATTR_UNIT_OF_MEASUREMENT, STATE_UNKNOWN from homeassistant.core import HomeAssistant, State From 3101d9099bda96c10e5231b9e336b0c67fdde8c8 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Fri, 21 Mar 2025 11:19:07 +0100 Subject: [PATCH 2810/3148] Fix spelling of "mDNS" in `esphome` (#141052) Change "MDNS" to the correct "mDNS". --- homeassistant/components/esphome/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/esphome/strings.json b/homeassistant/components/esphome/strings.json index 1534a49e365..c6916a3636d 100644 --- a/homeassistant/components/esphome/strings.json +++ b/homeassistant/components/esphome/strings.json @@ -4,7 +4,7 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", - "mdns_missing_mac": "Missing MAC address in MDNS properties.", + "mdns_missing_mac": "Missing MAC address in mDNS properties.", "service_received": "Action received", "mqtt_missing_mac": "Missing MAC address in MQTT properties.", "mqtt_missing_api": "Missing API port in MQTT properties.", From 1fafe81d20dbbd84eb8da5a6e3d12fc3159fe528 Mon Sep 17 00:00:00 2001 From: fwestenberg <47930023+fwestenberg@users.noreply.github.com> Date: Fri, 21 Mar 2025 11:20:15 +0100 Subject: [PATCH 2811/3148] Update Stookwijzer diagnostics and description (#141041) Update diagnostics and description --- .../components/stookwijzer/diagnostics.py | 1 + .../components/stookwijzer/strings.json | 2 +- tests/components/stookwijzer/conftest.py | 31 +++++++++++++++++++ .../snapshots/test_diagnostics.ambr | 22 +++++++++++++ 4 files changed, 55 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/stookwijzer/diagnostics.py b/homeassistant/components/stookwijzer/diagnostics.py index 2849e0e976a..1f3ef4ee4ba 100644 --- a/homeassistant/components/stookwijzer/diagnostics.py +++ b/homeassistant/components/stookwijzer/diagnostics.py @@ -18,4 +18,5 @@ async def async_get_config_entry_diagnostics( "advice": client.advice, "air_quality_index": client.lki, "windspeed_ms": client.windspeed_ms, + "forecast": await client.async_get_forecast(), } diff --git a/homeassistant/components/stookwijzer/strings.json b/homeassistant/components/stookwijzer/strings.json index d7304fa1238..a028f1f19c5 100644 --- a/homeassistant/components/stookwijzer/strings.json +++ b/homeassistant/components/stookwijzer/strings.json @@ -29,7 +29,7 @@ }, "issues": { "location_migration_failed": { - "description": "The Stookwijzer integration was unable to automatically migrate your location to a new format the updated integrations uses.\n\nMake sure you are connected to the internet and restart Home Assistant to try again.\n\nIf this doesn't resolve the error, remove and re-add the integration.", + "description": "The Stookwijzer integration was unable to automatically migrate your location to a new format the updated integration uses.\n\nMake sure you are connected to the Internet and restart Home Assistant to try again.\n\nIf this doesn't resolve the error, remove and re-add the integration.", "title": "Migration of your location failed" } }, diff --git a/tests/components/stookwijzer/conftest.py b/tests/components/stookwijzer/conftest.py index 40582dc4be3..dd7f2a7bbc3 100644 --- a/tests/components/stookwijzer/conftest.py +++ b/tests/components/stookwijzer/conftest.py @@ -1,6 +1,7 @@ """Fixtures for Stookwijzer integration tests.""" from collections.abc import Generator +from typing import Required, TypedDict from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -12,6 +13,14 @@ from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry +class Forecast(TypedDict): + """Typed Stookwijzer forecast dict.""" + + datetime: Required[str] + advice: str | None + final: bool | None + + @pytest.fixture def mock_config_entry() -> MockConfigEntry: """Return the default mocked config entry.""" @@ -80,6 +89,28 @@ def mock_stookwijzer() -> Generator[MagicMock]: client.windspeed_ms = 2.5 client.windspeed_bft = 2 client.advice = "code_yellow" + client.async_get_forecast.return_value = ( + Forecast( + datetime="2025-02-12T17:00:00+01:00", + advice="code_yellow", + final=True, + ), + Forecast( + datetime="2025-02-12T23:00:00+01:00", + advice="code_yellow", + final=True, + ), + Forecast( + datetime="2025-02-13T05:00:00+01:00", + advice="code_orange", + final=False, + ), + Forecast( + datetime="2025-02-13T11:00:00+01:00", + advice="code_orange", + final=False, + ), + ) yield stookwijzer_mock diff --git a/tests/components/stookwijzer/snapshots/test_diagnostics.ambr b/tests/components/stookwijzer/snapshots/test_diagnostics.ambr index e2535d54466..452b5bd0a30 100644 --- a/tests/components/stookwijzer/snapshots/test_diagnostics.ambr +++ b/tests/components/stookwijzer/snapshots/test_diagnostics.ambr @@ -3,6 +3,28 @@ dict({ 'advice': 'code_yellow', 'air_quality_index': 2, + 'forecast': list([ + dict({ + 'advice': 'code_yellow', + 'datetime': '2025-02-12T17:00:00+01:00', + 'final': True, + }), + dict({ + 'advice': 'code_yellow', + 'datetime': '2025-02-12T23:00:00+01:00', + 'final': True, + }), + dict({ + 'advice': 'code_orange', + 'datetime': '2025-02-13T05:00:00+01:00', + 'final': False, + }), + dict({ + 'advice': 'code_orange', + 'datetime': '2025-02-13T11:00:00+01:00', + 'final': False, + }), + ]), 'windspeed_ms': 2.5, }) # --- From 4ed2689678211246407e2b5ae3855cbd2d9210ce Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Fri, 21 Mar 2025 11:25:26 +0100 Subject: [PATCH 2812/3148] Handle wrong WebDAV URL more gracefully in config flow (#141040) --- homeassistant/components/webdav/config_flow.py | 4 +++- homeassistant/components/webdav/strings.json | 1 + tests/components/webdav/test_config_flow.py | 3 ++- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/webdav/config_flow.py b/homeassistant/components/webdav/config_flow.py index f75544d25ad..fa1a4fe3ca9 100644 --- a/homeassistant/components/webdav/config_flow.py +++ b/homeassistant/components/webdav/config_flow.py @@ -5,7 +5,7 @@ from __future__ import annotations import logging from typing import Any -from aiowebdav2.exceptions import UnauthorizedError +from aiowebdav2.exceptions import MethodNotSupportedError, UnauthorizedError import voluptuous as vol import yarl @@ -65,6 +65,8 @@ class WebDavConfigFlow(ConfigFlow, domain=DOMAIN): result = await client.check() except UnauthorizedError: errors["base"] = "invalid_auth" + except MethodNotSupportedError: + errors["base"] = "invalid_method" except Exception: # pylint: disable=broad-except _LOGGER.exception("Unexpected error") errors["base"] = "unknown" diff --git a/homeassistant/components/webdav/strings.json b/homeassistant/components/webdav/strings.json index b03ffaf2a3d..ac6418f1239 100644 --- a/homeassistant/components/webdav/strings.json +++ b/homeassistant/components/webdav/strings.json @@ -21,6 +21,7 @@ "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "invalid_method": "The server does not support the required methods. Please check whether you have the correct URL. Check with your provider for the correct URL.", "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { diff --git a/tests/components/webdav/test_config_flow.py b/tests/components/webdav/test_config_flow.py index eb887edb1a1..9204e6eadab 100644 --- a/tests/components/webdav/test_config_flow.py +++ b/tests/components/webdav/test_config_flow.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock -from aiowebdav2.exceptions import UnauthorizedError +from aiowebdav2.exceptions import MethodNotSupportedError, UnauthorizedError import pytest from homeassistant import config_entries @@ -86,6 +86,7 @@ async def test_form_fail(hass: HomeAssistant, webdav_client: AsyncMock) -> None: ("exception", "expected_error"), [ (UnauthorizedError("https://webdav.demo"), "invalid_auth"), + (MethodNotSupportedError("check", "https://webdav.demo"), "invalid_method"), (Exception("Unexpected error"), "unknown"), ], ) From 858f0e66573419e2fe87924dd310fd4aa24b3fe6 Mon Sep 17 00:00:00 2001 From: Wouter <33957974+wjtje@users.noreply.github.com> Date: Fri, 21 Mar 2025 13:13:56 +0100 Subject: [PATCH 2813/3148] Fixed issue where the device was already disconnected when setting up the event platform (#140722) * Changed where the script events are collected to remove any device communication from async_setup_entry * Implemented improvements and added a test to test whats happends when script_getcode fails * Renamed script_events to rpc_script_event to make clear this is only for RPC devices Co-authored-by: Shay Levy --------- Co-authored-by: Shay Levy --- homeassistant/components/shelly/__init__.py | 8 +++++++- homeassistant/components/shelly/coordinator.py | 1 + homeassistant/components/shelly/event.py | 12 ++++-------- homeassistant/components/shelly/utils.py | 17 +++++++++++++++++ tests/components/shelly/conftest.py | 3 +++ tests/components/shelly/test_init.py | 15 +++++++++++++++ 6 files changed, 47 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/shelly/__init__.py b/homeassistant/components/shelly/__init__.py index 7440013940c..a7ee1c029df 100644 --- a/homeassistant/components/shelly/__init__.py +++ b/homeassistant/components/shelly/__init__.py @@ -4,6 +4,7 @@ from __future__ import annotations from typing import Final +from aioshelly.ble.const import BLE_SCRIPT_NAME from aioshelly.block_device import BlockDevice from aioshelly.common import ConnectionOptions from aioshelly.const import DEFAULT_COAP_PORT, RPC_GENERATIONS @@ -11,6 +12,7 @@ from aioshelly.exceptions import ( DeviceConnectionError, InvalidAuthError, MacAddressMismatchError, + RpcCallError, ) from aioshelly.rpc_device import RpcDevice, bluetooth_mac_from_primary_mac import voluptuous as vol @@ -59,6 +61,7 @@ from .utils import ( get_coap_context, get_device_entry_gen, get_http_port, + get_rpc_scripts_event_types, get_ws_context, ) @@ -270,7 +273,10 @@ async def _async_setup_rpc_entry(hass: HomeAssistant, entry: ShellyConfigEntry) async_create_issue_unsupported_firmware(hass, entry) await device.shutdown() raise ConfigEntryNotReady - except (DeviceConnectionError, MacAddressMismatchError) as err: + runtime_data.rpc_script_events = await get_rpc_scripts_event_types( + device, ignore_scripts=[BLE_SCRIPT_NAME] + ) + except (DeviceConnectionError, MacAddressMismatchError, RpcCallError) as err: await device.shutdown() raise ConfigEntryNotReady(repr(err)) from err except InvalidAuthError as err: diff --git a/homeassistant/components/shelly/coordinator.py b/homeassistant/components/shelly/coordinator.py index 95812c12e10..85cf430bc5d 100644 --- a/homeassistant/components/shelly/coordinator.py +++ b/homeassistant/components/shelly/coordinator.py @@ -88,6 +88,7 @@ class ShellyEntryData: rest: ShellyRestCoordinator | None = None rpc: ShellyRpcCoordinator | None = None rpc_poll: ShellyRpcPollingCoordinator | None = None + rpc_script_events: dict[int, list[str]] | None = None type ShellyConfigEntry = ConfigEntry[ShellyEntryData] diff --git a/homeassistant/components/shelly/event.py b/homeassistant/components/shelly/event.py index bfd705f447a..ec5810581b1 100644 --- a/homeassistant/components/shelly/event.py +++ b/homeassistant/components/shelly/event.py @@ -34,7 +34,6 @@ from .utils import ( get_device_entry_gen, get_rpc_entity_name, get_rpc_key_instances, - get_rpc_script_event_types, is_block_momentary_input, is_rpc_momentary_input, ) @@ -109,18 +108,15 @@ async def async_setup_entry( script_instances = get_rpc_key_instances( coordinator.device.status, SCRIPT_EVENT.key ) + script_events = config_entry.runtime_data.rpc_script_events for script in script_instances: script_name = get_rpc_entity_name(coordinator.device, script) if script_name == BLE_SCRIPT_NAME: continue - event_types = await get_rpc_script_event_types( - coordinator.device, int(script.split(":")[-1]) - ) - if not event_types: - continue - - entities.append(ShellyRpcScriptEvent(coordinator, script, event_types)) + script_id = int(script.split(":")[-1]) + if script_events and (event_types := script_events[script_id]): + entities.append(ShellyRpcScriptEvent(coordinator, script, event_types)) # If a script is removed, from the device configuration, we need to remove orphaned entities async_remove_orphaned_entities( diff --git a/homeassistant/components/shelly/utils.py b/homeassistant/components/shelly/utils.py index 19897dbb185..474e2bb9410 100644 --- a/homeassistant/components/shelly/utils.py +++ b/homeassistant/components/shelly/utils.py @@ -664,3 +664,20 @@ def get_shelly_air_lamp_life(lamp_seconds: int) -> float: if lamp_hours >= SHAIR_MAX_WORK_HOURS: return 0.0 return 100 * (1 - lamp_hours / SHAIR_MAX_WORK_HOURS) + + +async def get_rpc_scripts_event_types( + device: RpcDevice, ignore_scripts: list[str] +) -> dict[int, list[str]]: + """Return a dict of all scripts and their event types.""" + script_instances = get_rpc_key_instances(device.status, "script") + script_events = {} + for script in script_instances: + script_name = get_rpc_entity_name(device, script) + if script_name in ignore_scripts: + continue + + script_id = int(script.split(":")[-1]) + script_events[script_id] = await get_rpc_script_event_types(device, script_id) + + return script_events diff --git a/tests/components/shelly/conftest.py b/tests/components/shelly/conftest.py index 8030df6e473..8f8255235be 100644 --- a/tests/components/shelly/conftest.py +++ b/tests/components/shelly/conftest.py @@ -513,6 +513,9 @@ def _mock_blu_rtv_device(version: str | None = None): firmware_version="some fw string", initialized=True, connected=True, + script_getcode=AsyncMock( + side_effect=lambda script_id: {"data": MOCK_SCRIPTS[script_id - 1]} + ), xmod_info={}, ) type(device).name = PropertyMock(return_value="Test name") diff --git a/tests/components/shelly/test_init.py b/tests/components/shelly/test_init.py index ef9b8f72616..0cec6383461 100644 --- a/tests/components/shelly/test_init.py +++ b/tests/components/shelly/test_init.py @@ -10,6 +10,7 @@ from aioshelly.exceptions import ( DeviceConnectionError, InvalidAuthError, MacAddressMismatchError, + RpcCallError, ) from aioshelly.rpc_device.utils import bluetooth_mac_from_primary_mac import pytest @@ -555,3 +556,17 @@ async def test_bluetooth_cleanup_on_remove_entry( remove_mock.assert_called_once_with( hass, format_mac(bluetooth_mac_from_primary_mac(entry.unique_id)).upper() ) + + +async def test_device_script_getcode_error( + hass: HomeAssistant, + mock_rpc_device: Mock, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test device script get code error.""" + monkeypatch.setattr( + mock_rpc_device, "script_getcode", AsyncMock(side_effect=RpcCallError(0)) + ) + + entry = await init_integration(hass, 2) + assert entry.state is ConfigEntryState.SETUP_RETRY From 466ec0b596e8d9141d94c00e9946bfcc2799796a Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Fri, 21 Mar 2025 08:31:17 -0400 Subject: [PATCH 2814/3148] Fix failing Roborock test (#141059) Fix the falky test --- tests/components/roborock/conftest.py | 11 +------- .../roborock/snapshots/test_diagnostics.ambr | 2 +- tests/components/roborock/test_init.py | 26 ++++++++++++++----- 3 files changed, 22 insertions(+), 17 deletions(-) diff --git a/tests/components/roborock/conftest.py b/tests/components/roborock/conftest.py index fcd469ca10a..332a9143c51 100644 --- a/tests/components/roborock/conftest.py +++ b/tests/components/roborock/conftest.py @@ -11,7 +11,6 @@ import uuid import pytest from roborock import RoborockCategory, RoomMapping from roborock.code_mappings import DyadError, RoborockDyadStateCode, ZeoError, ZeoState -from roborock.containers import NetworkInfo from roborock.roborock_message import RoborockDyadDataProtocol, RoborockZeoProtocol from roborock.version_a01_apis import RoborockMqttClientA01 @@ -30,7 +29,6 @@ from .mock_data import ( MAP_DATA, MULTI_MAP_LIST, NETWORK_INFO, - NETWORK_INFO_2, PROP, SCENES, USER_DATA, @@ -89,13 +87,6 @@ def bypass_api_client_fixture() -> None: yield -def cycle_network_info() -> Generator[NetworkInfo]: - """Return the appropriate network info for the corresponding device.""" - while True: - yield NETWORK_INFO - yield NETWORK_INFO_2 - - @pytest.fixture(name="bypass_api_fixture") def bypass_api_fixture(bypass_api_client_fixture: Any) -> None: """Skip calls to the API.""" @@ -107,7 +98,7 @@ def bypass_api_fixture(bypass_api_client_fixture: Any) -> None: ), patch( "homeassistant.components.roborock.RoborockMqttClientV1.get_networking", - side_effect=cycle_network_info(), + return_value=NETWORK_INFO, ), patch( "homeassistant.components.roborock.coordinator.RoborockLocalClientV1.get_prop", diff --git a/tests/components/roborock/snapshots/test_diagnostics.ambr b/tests/components/roborock/snapshots/test_diagnostics.ambr index 313824e70ec..26ecb729312 100644 --- a/tests/components/roborock/snapshots/test_diagnostics.ambr +++ b/tests/components/roborock/snapshots/test_diagnostics.ambr @@ -357,7 +357,7 @@ }), 'network_info': dict({ 'bssid': '**REDACTED**', - 'ip': '123.232.12.2', + 'ip': '123.232.12.1', 'mac': '**REDACTED**', 'rssi': 90, 'ssid': 'wifi', diff --git a/tests/components/roborock/test_init.py b/tests/components/roborock/test_init.py index 226eea816b9..3d288b6479b 100644 --- a/tests/components/roborock/test_init.py +++ b/tests/components/roborock/test_init.py @@ -20,7 +20,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceRegistry from homeassistant.setup import async_setup_component -from .mock_data import HOME_DATA, NETWORK_INFO +from .mock_data import HOME_DATA, NETWORK_INFO, NETWORK_INFO_2 from tests.common import MockConfigEntry from tests.typing import ClientSessionGenerator @@ -305,7 +305,11 @@ async def test_stale_device( device_registry: DeviceRegistry, ) -> None: """Test that we remove a device if it no longer is given by home_data.""" - await hass.config_entries.async_setup(mock_roborock_entry.entry_id) + with patch( + "homeassistant.components.roborock.RoborockMqttClientV1.get_networking", + side_effect=[NETWORK_INFO, NETWORK_INFO_2], + ): + await hass.config_entries.async_setup(mock_roborock_entry.entry_id) assert mock_roborock_entry.state is ConfigEntryState.LOADED existing_devices = device_registry.devices.get_devices_for_config_entry_id( mock_roborock_entry.entry_id @@ -314,9 +318,15 @@ async def test_stale_device( hd = deepcopy(HOME_DATA) hd.devices = [hd.devices[0]] - with patch( - "homeassistant.components.roborock.RoborockApiClient.get_home_data_v2", - return_value=hd, + with ( + patch( + "homeassistant.components.roborock.RoborockApiClient.get_home_data_v2", + return_value=hd, + ), + patch( + "homeassistant.components.roborock.RoborockMqttClientV1.get_networking", + side_effect=[NETWORK_INFO, NETWORK_INFO_2], + ), ): await hass.config_entries.async_reload(mock_roborock_entry.entry_id) await hass.async_block_till_done() @@ -336,7 +346,11 @@ async def test_no_stale_device( device_registry: DeviceRegistry, ) -> None: """Test that we don't remove a device if fails to setup.""" - await hass.config_entries.async_setup(mock_roborock_entry.entry_id) + with patch( + "homeassistant.components.roborock.RoborockMqttClientV1.get_networking", + side_effect=[NETWORK_INFO, NETWORK_INFO_2], + ): + await hass.config_entries.async_setup(mock_roborock_entry.entry_id) assert mock_roborock_entry.state is ConfigEntryState.LOADED existing_devices = device_registry.devices.get_devices_for_config_entry_id( mock_roborock_entry.entry_id From a9cbc72ce5493352c6fe988d064b7b30fcffe23c Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 21 Mar 2025 14:03:44 +0100 Subject: [PATCH 2815/3148] Add child lock to SmartThings (#140200) * Add kids lock to SmartThings * Add kids lock to SmartThings * Fix * Fix --- .../components/smartthings/binary_sensor.py | 7 + .../components/smartthings/icons.json | 6 + .../components/smartthings/strings.json | 3 + .../snapshots/test_binary_sensor.ambr | 376 ++++++++++++++++++ 4 files changed, 392 insertions(+) diff --git a/homeassistant/components/smartthings/binary_sensor.py b/homeassistant/components/smartthings/binary_sensor.py index 0654846273e..ace23ba4ec2 100644 --- a/homeassistant/components/smartthings/binary_sensor.py +++ b/homeassistant/components/smartthings/binary_sensor.py @@ -61,6 +61,13 @@ CAPABILITY_TO_SENSORS: dict[ is_on_key="replace", ) }, + Capability.SAMSUNG_CE_KIDS_LOCK: { + Attribute.LOCK_STATE: SmartThingsBinarySensorEntityDescription( + key=Attribute.LOCK_STATE, + translation_key="child_lock", + is_on_key="locked", + ) + }, Capability.MOTION_SENSOR: { Attribute.MOTION: SmartThingsBinarySensorEntityDescription( key=Attribute.MOTION, diff --git a/homeassistant/components/smartthings/icons.json b/homeassistant/components/smartthings/icons.json index cbc4b6b80ce..971550b8f69 100644 --- a/homeassistant/components/smartthings/icons.json +++ b/homeassistant/components/smartthings/icons.json @@ -6,6 +6,12 @@ "state": { "on": "mdi:remote" } + }, + "child_lock": { + "default": "mdi:lock-open", + "state": { + "on": "mdi:lock" + } } } } diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index fdc905468f5..48314341da9 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -39,6 +39,9 @@ "remote_control": { "name": "Remote control" }, + "child_lock": { + "name": "Child lock" + }, "valve": { "name": "Valve" } diff --git a/tests/components/smartthings/snapshots/test_binary_sensor.ambr b/tests/components/smartthings/snapshots/test_binary_sensor.ambr index 6223c6c526c..4edb3160cf8 100644 --- a/tests/components/smartthings/snapshots/test_binary_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_binary_sensor.ambr @@ -143,6 +143,53 @@ 'state': 'off', }) # --- +# name: test_all_entities[da_ks_microwave_0101x][binary_sensor.microwave_child_lock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.microwave_child_lock', + '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': 'Child lock', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a.lockState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_microwave_0101x][binary_sensor.microwave_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Microwave Child lock', + }), + 'context': , + 'entity_id': 'binary_sensor.microwave_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_all_entities[da_ks_microwave_0101x][binary_sensor.microwave_remote_control-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -190,6 +237,53 @@ 'state': 'off', }) # --- +# name: test_all_entities[da_ks_oven_01061][binary_sensor.oven_child_lock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.oven_child_lock', + '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': 'Child lock', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': '9447959a-0dfa-6b27-d40d-650da525c53f.lockState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_oven_01061][binary_sensor.oven_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Oven Child lock', + }), + 'context': , + 'entity_id': 'binary_sensor.oven_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_all_entities[da_ks_oven_01061][binary_sensor.oven_remote_control-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -237,6 +331,53 @@ 'state': 'on', }) # --- +# name: test_all_entities[da_ks_range_0101x][binary_sensor.vulcan_child_lock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.vulcan_child_lock', + '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': 'Child lock', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': '2c3cbaa0-1899-5ddc-7b58-9d657bd48f18.lockState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_range_0101x][binary_sensor.vulcan_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Vulcan Child lock', + }), + 'context': , + 'entity_id': 'binary_sensor.vulcan_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_all_entities[da_ks_range_0101x][binary_sensor.vulcan_remote_control-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -332,6 +473,53 @@ 'state': 'off', }) # --- +# name: test_all_entities[da_wm_dw_000001][binary_sensor.dishwasher_child_lock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.dishwasher_child_lock', + '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': 'Child lock', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676.lockState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_dw_000001][binary_sensor.dishwasher_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dishwasher Child lock', + }), + 'context': , + 'entity_id': 'binary_sensor.dishwasher_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_all_entities[da_wm_dw_000001][binary_sensor.dishwasher_remote_control-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -379,6 +567,53 @@ 'state': 'off', }) # --- +# name: test_all_entities[da_wm_wd_000001][binary_sensor.dryer_child_lock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.dryer_child_lock', + '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': 'Child lock', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b.lockState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wd_000001][binary_sensor.dryer_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dryer Child lock', + }), + 'context': , + 'entity_id': 'binary_sensor.dryer_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_all_entities[da_wm_wd_000001][binary_sensor.dryer_remote_control-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -426,6 +661,53 @@ 'state': 'off', }) # --- +# name: test_all_entities[da_wm_wd_000001_1][binary_sensor.seca_roupa_child_lock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.seca_roupa_child_lock', + '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': 'Child lock', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': '3a6c4e05-811d-5041-e956-3d04c424cbcd.lockState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wd_000001_1][binary_sensor.seca_roupa_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Seca-Roupa Child lock', + }), + 'context': , + 'entity_id': 'binary_sensor.seca_roupa_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_all_entities[da_wm_wd_000001_1][binary_sensor.seca_roupa_remote_control-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -473,6 +755,53 @@ 'state': 'off', }) # --- +# name: test_all_entities[da_wm_wm_000001][binary_sensor.washer_child_lock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.washer_child_lock', + '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': 'Child lock', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47.lockState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wm_000001][binary_sensor.washer_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Washer Child lock', + }), + 'context': , + 'entity_id': 'binary_sensor.washer_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_all_entities[da_wm_wm_000001][binary_sensor.washer_remote_control-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -520,6 +849,53 @@ 'state': 'off', }) # --- +# name: test_all_entities[da_wm_wm_000001_1][binary_sensor.washing_machine_child_lock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.washing_machine_child_lock', + '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': 'Child lock', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': '63803fae-cbed-f356-a063-2cf148ae3ca7.lockState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wm_000001_1][binary_sensor.washing_machine_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Washing Machine Child lock', + }), + 'context': , + 'entity_id': 'binary_sensor.washing_machine_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_all_entities[da_wm_wm_000001_1][binary_sensor.washing_machine_remote_control-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From f84a46680df0393c209c8bf323be6e90007ceb11 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 21 Mar 2025 16:20:42 +0100 Subject: [PATCH 2816/3148] Add event platform to SmartThings (#141066) * Add event platform to SmartThings * Add event platform to SmartThings * Fix --- .../components/smartthings/__init__.py | 1 + .../components/smartthings/entity.py | 13 +- homeassistant/components/smartthings/event.py | 63 +++ .../components/smartthings/strings.json | 32 ++ tests/components/smartthings/__init__.py | 3 +- tests/components/smartthings/conftest.py | 1 + .../device_status/heatit_zpushwall.json | 116 ++++++ .../fixtures/devices/heatit_zpushwall.json | 155 ++++++++ .../smartthings/snapshots/test_event.ambr | 361 ++++++++++++++++++ .../smartthings/snapshots/test_init.ambr | 33 ++ .../smartthings/snapshots/test_sensor.ambr | 49 +++ tests/components/smartthings/test_event.py | 61 +++ 12 files changed, 882 insertions(+), 6 deletions(-) create mode 100644 homeassistant/components/smartthings/event.py create mode 100644 tests/components/smartthings/fixtures/device_status/heatit_zpushwall.json create mode 100644 tests/components/smartthings/fixtures/devices/heatit_zpushwall.json create mode 100644 tests/components/smartthings/snapshots/test_event.ambr create mode 100644 tests/components/smartthings/test_event.py diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py index 58afbb6cb41..1fa6a1e259b 100644 --- a/homeassistant/components/smartthings/__init__.py +++ b/homeassistant/components/smartthings/__init__.py @@ -73,6 +73,7 @@ PLATFORMS = [ Platform.BINARY_SENSOR, Platform.CLIMATE, Platform.COVER, + Platform.EVENT, Platform.FAN, Platform.LIGHT, Platform.LOCK, diff --git a/homeassistant/components/smartthings/entity.py b/homeassistant/components/smartthings/entity.py index c2637174a5c..660ab499d19 100644 --- a/homeassistant/components/smartthings/entity.py +++ b/homeassistant/components/smartthings/entity.py @@ -32,14 +32,17 @@ class SmartThingsEntity(Entity): device: FullDevice, rooms: dict[str, str], capabilities: set[Capability], + *, + component: str = MAIN, ) -> None: """Initialize the instance.""" self.client = client self.capabilities = capabilities + self.component = component self._internal_state: dict[Capability | str, dict[Attribute | str, Status]] = { - capability: device.status[MAIN][capability] + capability: device.status[component][capability] for capability in capabilities - if capability in device.status[MAIN] + if capability in device.status[component] } self.device = device self._attr_unique_id = device.device.device_id @@ -84,7 +87,7 @@ class SmartThingsEntity(Entity): self.async_on_remove( self.client.add_device_capability_event_listener( self.device.device.device_id, - MAIN, + self.component, capability, self._update_handler, ) @@ -98,7 +101,7 @@ class SmartThingsEntity(Entity): def supports_capability(self, capability: Capability) -> bool: """Test if device supports a capability.""" - return capability in self.device.status[MAIN] + return capability in self.device.status[self.component] def get_attribute_value(self, capability: Capability, attribute: Attribute) -> Any: """Get the value of a device attribute.""" @@ -123,5 +126,5 @@ class SmartThingsEntity(Entity): if argument is not None: kwargs["argument"] = argument await self.client.execute_device_command( - self.device.device.device_id, capability, command, MAIN, **kwargs + self.device.device.device_id, capability, command, self.component, **kwargs ) diff --git a/homeassistant/components/smartthings/event.py b/homeassistant/components/smartthings/event.py new file mode 100644 index 00000000000..b629bd92b35 --- /dev/null +++ b/homeassistant/components/smartthings/event.py @@ -0,0 +1,63 @@ +"""Support for events through the SmartThings cloud API.""" + +from __future__ import annotations + +from typing import cast + +from pysmartthings import Attribute, Capability, Component, DeviceEvent, SmartThings + +from homeassistant.components.event import EventDeviceClass, EventEntity +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import FullDevice, SmartThingsConfigEntry +from .entity import SmartThingsEntity + + +async def async_setup_entry( + hass: HomeAssistant, + entry: SmartThingsConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Add events for a config entry.""" + entry_data = entry.runtime_data + async_add_entities( + SmartThingsButtonEvent(entry_data.client, entry_data.rooms, device, component) + for device in entry_data.devices.values() + for component in device.device.components + if Capability.BUTTON in component.capabilities + ) + + +class SmartThingsButtonEvent(SmartThingsEntity, EventEntity): + """Define a SmartThings event.""" + + _attr_device_class = EventDeviceClass.BUTTON + _attr_translation_key = "button" + + def __init__( + self, + client: SmartThings, + rooms: dict[str, str], + device: FullDevice, + component: Component, + ) -> None: + """Init the class.""" + super().__init__( + client, device, rooms, {Capability.BUTTON}, component=component.id + ) + self._attr_name = component.label + self._attr_unique_id = ( + f"{device.device.device_id}_{component.id}_{Capability.BUTTON}" + ) + + @property + def event_types(self) -> list[str]: + """Return the event types.""" + return self.get_attribute_value( + Capability.BUTTON, Attribute.SUPPORTED_BUTTON_VALUES + ) + + def _update_handler(self, event: DeviceEvent) -> None: + self._trigger_event(cast(str, event.value)) + self.async_write_ha_state() diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index 48314341da9..39973ef5380 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -46,6 +46,38 @@ "name": "Valve" } }, + "event": { + "button": { + "state": { + "pushed": "Pushed", + "held": "Held", + "double": "Double", + "pushed_2x": "Pushed 2x", + "pushed_3x": "Pushed 3x", + "pushed_4x": "Pushed 4x", + "pushed_5x": "Pushed 5x", + "pushed_6x": "Pushed 6x", + "down": "Down", + "down_2x": "Down 2x", + "down_3x": "Down 3x", + "down_4x": "Down 4x", + "down_5x": "Down 5x", + "down_6x": "Down 6x", + "down_hold": "Down hold", + "up": "Up", + "up_2x": "Up 2x", + "up_3x": "Up 3x", + "up_4x": "Up 4x", + "up_5x": "Up 5x", + "up_6x": "Up 6x", + "up_hold": "Up hold", + "swipe_up": "Swipe up", + "swipe_down": "Swipe down", + "swipe_left": "Swipe left", + "swipe_right": "Swipe right" + } + } + }, "sensor": { "lighting_mode": { "name": "Activity lighting mode" diff --git a/tests/components/smartthings/__init__.py b/tests/components/smartthings/__init__.py index e87d1a8bcdf..ad09f1a7acf 100644 --- a/tests/components/smartthings/__init__.py +++ b/tests/components/smartthings/__init__.py @@ -55,6 +55,7 @@ async def trigger_update( attribute: Attribute, value: str | float | dict[str, Any] | list[Any] | None, data: dict[str, Any] | None = None, + component: str = MAIN, ) -> None: """Trigger an update.""" event = DeviceEvent( @@ -62,7 +63,7 @@ async def trigger_update( "abc", "abc", device_id, - MAIN, + component, capability, attribute, value, diff --git a/tests/components/smartthings/conftest.py b/tests/components/smartthings/conftest.py index d26805eb04b..9e70c1b2b34 100644 --- a/tests/components/smartthings/conftest.py +++ b/tests/components/smartthings/conftest.py @@ -131,6 +131,7 @@ def mock_smartthings() -> Generator[AsyncMock]: "fake_fan", "generic_fan_3_speed", "heatit_ztrm3_thermostat", + "heatit_zpushwall", "generic_ef00_v1", "bosch_radiator_thermostat_ii", "im_speaker_ai_0001", diff --git a/tests/components/smartthings/fixtures/device_status/heatit_zpushwall.json b/tests/components/smartthings/fixtures/device_status/heatit_zpushwall.json new file mode 100644 index 00000000000..591d1128ea0 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/heatit_zpushwall.json @@ -0,0 +1,116 @@ +{ + "components": { + "button4": { + "button": { + "button": { + "value": "pushed", + "timestamp": "2025-02-10T08:01:11.326Z" + }, + "numberOfButtons": { + "value": 1, + "timestamp": "2023-12-04T16:51:16.695Z" + }, + "supportedButtonValues": { + "value": ["pushed", "held", "down_hold"], + "timestamp": "2023-12-04T16:51:16.717Z" + } + } + }, + "button5": { + "button": { + "button": { + "value": "pushed", + "timestamp": "2025-03-09T16:37:40.792Z" + }, + "numberOfButtons": { + "value": 1, + "timestamp": "2023-12-04T16:51:16.762Z" + }, + "supportedButtonValues": { + "value": ["pushed", "held", "down_hold"], + "timestamp": "2023-12-04T16:51:16.813Z" + } + } + }, + "button2": { + "button": { + "button": { + "value": "pushed", + "timestamp": "2025-02-10T08:00:57.171Z" + }, + "numberOfButtons": { + "value": 1, + "timestamp": "2023-12-04T16:51:16.861Z" + }, + "supportedButtonValues": { + "value": ["pushed", "held", "down_hold"], + "timestamp": "2023-12-04T16:51:16.906Z" + } + } + }, + "button3": { + "button": { + "button": { + "value": "pushed", + "timestamp": "2025-01-30T05:53:00.663Z" + }, + "numberOfButtons": { + "value": 1, + "timestamp": "2023-12-04T16:51:16.852Z" + }, + "supportedButtonValues": { + "value": ["pushed", "held", "down_hold"], + "timestamp": "2023-12-04T16:51:16.848Z" + } + } + }, + "button6": { + "button": { + "button": { + "value": "pushed", + "timestamp": "2024-10-02T13:11:07.346Z" + }, + "numberOfButtons": { + "value": 1, + "timestamp": "2023-12-04T16:51:16.816Z" + }, + "supportedButtonValues": { + "value": ["pushed", "held", "down_hold"], + "timestamp": "2023-12-04T16:51:16.848Z" + } + } + }, + "main": { + "refresh": {}, + "battery": { + "quantity": { + "value": null + }, + "battery": { + "value": 100, + "unit": "%", + "timestamp": "2025-03-10T10:32:19.528Z" + }, + "type": { + "value": null + } + } + }, + "button1": { + "button": { + "button": { + "value": "pushed", + "timestamp": "2025-01-30T05:52:46.718Z" + }, + "numberOfButtons": { + "value": 1, + "timestamp": "2023-12-04T16:51:16.717Z" + }, + "supportedButtonValues": { + "value": ["pushed", "held", "down_hold"], + "timestamp": "2023-12-04T16:51:16.767Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/devices/heatit_zpushwall.json b/tests/components/smartthings/fixtures/devices/heatit_zpushwall.json new file mode 100644 index 00000000000..0cd42e0e2ce --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/heatit_zpushwall.json @@ -0,0 +1,155 @@ +{ + "items": [ + { + "deviceId": "5e5b97f3-3094-44e6-abc0-f61283412d6a", + "name": "heatit-zpushwall", + "label": "Livingroom smart switch", + "manufacturerName": "SmartThingsCommunity", + "presentationId": "52933933-7123-3315-a441-92d65df5f031", + "deviceManufacturerCode": "019B-0004-2403", + "locationId": "c85a9f8a-5d2e-4cdd-8bdb-bc49ba4a3544", + "ownerId": "7b68139b-d068-45d8-bf27-961320350024", + "roomId": "56e43461-2f7d-4c43-ba7c-29465f991289", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "battery", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + } + ], + "categories": [ + { + "name": "RemoteController", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "button1", + "label": "button1", + "capabilities": [ + { + "id": "button", + "version": 1 + } + ], + "categories": [ + { + "name": "RemoteController", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "button2", + "label": "button2", + "capabilities": [ + { + "id": "button", + "version": 1 + } + ], + "categories": [ + { + "name": "RemoteController", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "button3", + "label": "button3", + "capabilities": [ + { + "id": "button", + "version": 1 + } + ], + "categories": [ + { + "name": "RemoteController", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "button4", + "label": "button4", + "capabilities": [ + { + "id": "button", + "version": 1 + } + ], + "categories": [ + { + "name": "RemoteController", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "button5", + "label": "button5", + "capabilities": [ + { + "id": "button", + "version": 1 + } + ], + "categories": [ + { + "name": "RemoteController", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "button6", + "label": "button6", + "capabilities": [ + { + "id": "button", + "version": 1 + } + ], + "categories": [ + { + "name": "RemoteController", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2023-12-04T16:51:15.774Z", + "parentDeviceId": "4869d882-e898-40c3-a198-7611b72187a5", + "profile": { + "id": "2d6e59af-63df-3102-8515-66f3d75c9323" + }, + "zwave": { + "networkId": "12", + "driverId": "1d39c140-ce10-490d-bf52-4de7b72caab6", + "executingLocally": true, + "hubId": "4869d882-e898-40c3-a198-7611b72187a5", + "networkSecurityLevel": "ZWAVE_S2_AUTHENTICATED", + "provisioningState": "NONFUNCTIONAL", + "manufacturerId": 411, + "productType": 4, + "productId": 9219 + }, + "type": "ZWAVE", + "restrictionTier": 0, + "allowed": null, + "executionContext": "LOCAL", + "relationships": [] + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/snapshots/test_event.ambr b/tests/components/smartthings/snapshots/test_event.ambr new file mode 100644 index 00000000000..79c57df5fd7 --- /dev/null +++ b/tests/components/smartthings/snapshots/test_event.ambr @@ -0,0 +1,361 @@ +# serializer version: 1 +# name: test_all_entities[heatit_zpushwall][event.livingroom_smart_switch_button1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'pushed', + 'held', + 'down_hold', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.livingroom_smart_switch_button1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'button1', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'button', + 'unique_id': '5e5b97f3-3094-44e6-abc0-f61283412d6a_button1_button', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[heatit_zpushwall][event.livingroom_smart_switch_button1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'button', + 'event_type': None, + 'event_types': list([ + 'pushed', + 'held', + 'down_hold', + ]), + 'friendly_name': 'Livingroom smart switch button1', + }), + 'context': , + 'entity_id': 'event.livingroom_smart_switch_button1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[heatit_zpushwall][event.livingroom_smart_switch_button2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'pushed', + 'held', + 'down_hold', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.livingroom_smart_switch_button2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'button2', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'button', + 'unique_id': '5e5b97f3-3094-44e6-abc0-f61283412d6a_button2_button', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[heatit_zpushwall][event.livingroom_smart_switch_button2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'button', + 'event_type': None, + 'event_types': list([ + 'pushed', + 'held', + 'down_hold', + ]), + 'friendly_name': 'Livingroom smart switch button2', + }), + 'context': , + 'entity_id': 'event.livingroom_smart_switch_button2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[heatit_zpushwall][event.livingroom_smart_switch_button3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'pushed', + 'held', + 'down_hold', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.livingroom_smart_switch_button3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'button3', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'button', + 'unique_id': '5e5b97f3-3094-44e6-abc0-f61283412d6a_button3_button', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[heatit_zpushwall][event.livingroom_smart_switch_button3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'button', + 'event_type': None, + 'event_types': list([ + 'pushed', + 'held', + 'down_hold', + ]), + 'friendly_name': 'Livingroom smart switch button3', + }), + 'context': , + 'entity_id': 'event.livingroom_smart_switch_button3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[heatit_zpushwall][event.livingroom_smart_switch_button4-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'pushed', + 'held', + 'down_hold', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.livingroom_smart_switch_button4', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'button4', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'button', + 'unique_id': '5e5b97f3-3094-44e6-abc0-f61283412d6a_button4_button', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[heatit_zpushwall][event.livingroom_smart_switch_button4-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'button', + 'event_type': None, + 'event_types': list([ + 'pushed', + 'held', + 'down_hold', + ]), + 'friendly_name': 'Livingroom smart switch button4', + }), + 'context': , + 'entity_id': 'event.livingroom_smart_switch_button4', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[heatit_zpushwall][event.livingroom_smart_switch_button5-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'pushed', + 'held', + 'down_hold', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.livingroom_smart_switch_button5', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'button5', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'button', + 'unique_id': '5e5b97f3-3094-44e6-abc0-f61283412d6a_button5_button', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[heatit_zpushwall][event.livingroom_smart_switch_button5-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'button', + 'event_type': None, + 'event_types': list([ + 'pushed', + 'held', + 'down_hold', + ]), + 'friendly_name': 'Livingroom smart switch button5', + }), + 'context': , + 'entity_id': 'event.livingroom_smart_switch_button5', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[heatit_zpushwall][event.livingroom_smart_switch_button6-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'pushed', + 'held', + 'down_hold', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.livingroom_smart_switch_button6', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'button6', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'button', + 'unique_id': '5e5b97f3-3094-44e6-abc0-f61283412d6a_button6_button', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[heatit_zpushwall][event.livingroom_smart_switch_button6-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'button', + 'event_type': None, + 'event_types': list([ + 'pushed', + 'held', + 'down_hold', + ]), + 'friendly_name': 'Livingroom smart switch button6', + }), + 'context': , + 'entity_id': 'event.livingroom_smart_switch_button6', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/smartthings/snapshots/test_init.ambr b/tests/components/smartthings/snapshots/test_init.ambr index e62c34cd11c..48a1138e344 100644 --- a/tests/components/smartthings/snapshots/test_init.ambr +++ b/tests/components/smartthings/snapshots/test_init.ambr @@ -992,6 +992,39 @@ 'via_device_id': None, }) # --- +# name: test_devices[heatit_zpushwall] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '5e5b97f3-3094-44e6-abc0-f61283412d6a', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': None, + 'model': None, + 'model_id': None, + 'name': 'Livingroom smart switch', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- # name: test_devices[heatit_ztrm3_thermostat] DeviceRegistryEntrySnapshot({ 'area_id': None, diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index 954bcc5c281..8b1a3c9f7d6 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -6578,6 +6578,55 @@ 'state': '21.0', }) # --- +# name: test_all_entities[heatit_zpushwall][sensor.livingroom_smart_switch_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.livingroom_smart_switch_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': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '5e5b97f3-3094-44e6-abc0-f61283412d6a.battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[heatit_zpushwall][sensor.livingroom_smart_switch_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Livingroom smart switch Battery', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.livingroom_smart_switch_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- # name: test_all_entities[heatit_ztrm3_thermostat][sensor.hall_thermostat_energy-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/smartthings/test_event.py b/tests/components/smartthings/test_event.py new file mode 100644 index 00000000000..bdca7674981 --- /dev/null +++ b/tests/components/smartthings/test_event.py @@ -0,0 +1,61 @@ +"""Test for the SmartThings event platform.""" + +from unittest.mock import AsyncMock + +from freezegun.api import FrozenDateTimeFactory +from pysmartthings import Attribute, Capability +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.const import STATE_UNKNOWN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration, snapshot_smartthings_entities, trigger_update + +from tests.common import MockConfigEntry + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + await setup_integration(hass, mock_config_entry) + + snapshot_smartthings_entities(hass, entity_registry, snapshot, Platform.EVENT) + + +@pytest.mark.parametrize("device_fixture", ["heatit_zpushwall"]) +async def test_state_update( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test state update.""" + await setup_integration(hass, mock_config_entry) + + freezer.move_to("2023-10-21") + + assert ( + hass.states.get("event.livingroom_smart_switch_button1").state == STATE_UNKNOWN + ) + + await trigger_update( + hass, + devices, + "5e5b97f3-3094-44e6-abc0-f61283412d6a", + Capability.BUTTON, + Attribute.BUTTON, + "pushed", + component="button1", + ) + + assert ( + hass.states.get("event.livingroom_smart_switch_button1").state + == "2023-10-21T00:00:00.000+00:00" + ) From c1753631b174be54cb3dc17b8c2c0e68e51d48a0 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Fri, 21 Mar 2025 16:26:51 +0100 Subject: [PATCH 2817/3148] Handle button presses exceptions for Vodafone Station (#140953) * Handle button presses execeptions for Vodafone Station * apply review comment --- .../components/vodafone_station/button.py | 34 +++++++++++++++++-- .../vodafone_station/quality_scale.yaml | 4 +-- .../components/vodafone_station/strings.json | 3 ++ 3 files changed, 36 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/vodafone_station/button.py b/homeassistant/components/vodafone_station/button.py index 5c98c3241e9..8dda4d49c7b 100644 --- a/homeassistant/components/vodafone_station/button.py +++ b/homeassistant/components/vodafone_station/button.py @@ -4,8 +4,16 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass +from json.decoder import JSONDecodeError from typing import Any, Final +from aiovodafone.exceptions import ( + AlreadyLogged, + CannotAuthenticate, + CannotConnect, + GenericLoginError, +) + from homeassistant.components.button import ( ButtonDeviceClass, ButtonEntity, @@ -13,10 +21,11 @@ from homeassistant.components.button import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import _LOGGER +from .const import _LOGGER, DOMAIN from .coordinator import VodafoneConfigEntry, VodafoneStationRouter # Coordinator is used to centralize the data updates @@ -108,4 +117,25 @@ class VodafoneStationSensorEntity( async def async_press(self) -> None: """Triggers the Shelly button press service.""" - await self.entity_description.press_action(self.coordinator) + + try: + await self.entity_description.press_action(self.coordinator) + except CannotAuthenticate as err: + self.coordinator.config_entry.async_start_reauth(self.hass) + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="cannot_authenticate", + translation_placeholders={"error": repr(err)}, + ) from err + except ( + CannotConnect, + AlreadyLogged, + GenericLoginError, + JSONDecodeError, + ) as err: + self.coordinator.last_update_success = False + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="cannot_execute_action", + translation_placeholders={"error": repr(err)}, + ) from err diff --git a/homeassistant/components/vodafone_station/quality_scale.yaml b/homeassistant/components/vodafone_station/quality_scale.yaml index f9fa27b3032..fe114b4b324 100644 --- a/homeassistant/components/vodafone_station/quality_scale.yaml +++ b/homeassistant/components/vodafone_station/quality_scale.yaml @@ -26,9 +26,7 @@ rules: unique-config-entry: done # Silver - action-exceptions: - status: todo - comment: button presses not exception handled with HomeAssistantError + action-exceptions: done config-entry-unloading: done docs-configuration-parameters: done docs-installation-parameters: done diff --git a/homeassistant/components/vodafone_station/strings.json b/homeassistant/components/vodafone_station/strings.json index de4bc364d4b..e05e1877798 100644 --- a/homeassistant/components/vodafone_station/strings.json +++ b/homeassistant/components/vodafone_station/strings.json @@ -116,6 +116,9 @@ "update_failed": { "message": "Error fetching data: {error}" }, + "cannot_execute_action": { + "message": "Cannot execute requested action: {error}" + }, "cannot_authenticate": { "message": "Error authenticating: {error}" } From 74ed0e801118450975cc9f2778aeb3b7fa1635f4 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 21 Mar 2025 16:29:00 +0100 Subject: [PATCH 2818/3148] Add support for PM1.0 in SmartThings (#141061) * Add support for PM1.0 in SmartThings * Add test fixtures * Add test fixtures --- .../components/smartthings/sensor.py | 13 +- tests/components/smartthings/conftest.py | 1 + .../device_status/da_ac_airsensor_01001.json | 362 ++++++++++++++++ .../devices/da_ac_airsensor_01001.json | 145 +++++++ .../smartthings/snapshots/test_init.ambr | 33 ++ .../smartthings/snapshots/test_sensor.ambr | 410 ++++++++++++++++++ 6 files changed, 961 insertions(+), 3 deletions(-) create mode 100644 tests/components/smartthings/fixtures/device_status/da_ac_airsensor_01001.json create mode 100644 tests/components/smartthings/fixtures/devices/da_ac_airsensor_01001.json diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index 1437cbe6000..21d256968ae 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -225,7 +225,6 @@ CAPABILITY_TO_SENSORS: dict[ ) ] }, - # Haven't seen at devices yet Capability.CARBON_DIOXIDE_MEASUREMENT: { Attribute.CARBON_DIOXIDE: [ SmartThingsSensorEntityDescription( @@ -467,7 +466,6 @@ CAPABILITY_TO_SENSORS: dict[ ) ] }, - # part of the proposed spec, Haven't seen at devices yet Capability.MEDIA_PLAYBACK_REPEAT: { Attribute.PLAYBACK_REPEAT_MODE: [ SmartThingsSensorEntityDescription( @@ -476,7 +474,6 @@ CAPABILITY_TO_SENSORS: dict[ ) ] }, - # part of the proposed spec, Haven't seen at devices yet Capability.MEDIA_PLAYBACK_SHUFFLE: { Attribute.PLAYBACK_SHUFFLE: [ SmartThingsSensorEntityDescription( @@ -903,6 +900,16 @@ CAPABILITY_TO_SENSORS: dict[ ) ] }, + Capability.VERY_FINE_DUST_SENSOR: { + Attribute.VERY_FINE_DUST_LEVEL: [ + SmartThingsSensorEntityDescription( + key=Attribute.VERY_FINE_DUST_LEVEL, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + device_class=SensorDeviceClass.PM1, + state_class=SensorStateClass.MEASUREMENT, + ) + ] + }, Capability.VOLTAGE_MEASUREMENT: { Attribute.VOLTAGE: [ SmartThingsSensorEntityDescription( diff --git a/tests/components/smartthings/conftest.py b/tests/components/smartthings/conftest.py index 9e70c1b2b34..761b65adc8a 100644 --- a/tests/components/smartthings/conftest.py +++ b/tests/components/smartthings/conftest.py @@ -91,6 +91,7 @@ def mock_smartthings() -> Generator[AsyncMock]: @pytest.fixture( params=[ + "da_ac_airsensor_01001", "da_ac_rac_000001", "da_ac_rac_100001", "da_ac_rac_01001", diff --git a/tests/components/smartthings/fixtures/device_status/da_ac_airsensor_01001.json b/tests/components/smartthings/fixtures/device_status/da_ac_airsensor_01001.json new file mode 100644 index 00000000000..903b5163335 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/da_ac_airsensor_01001.json @@ -0,0 +1,362 @@ +{ + "components": { + "main": { + "samsungce.rechargeableBattery": { + "chargingStatus": { + "value": "charging", + "timestamp": "2025-02-18T05:20:27.966Z" + }, + "battery": { + "value": 100, + "unit": "%", + "timestamp": "2025-02-22T04:50:19.633Z" + }, + "resolution": { + "value": 1, + "timestamp": "2024-12-20T14:38:31.662Z" + } + }, + "relativeHumidityMeasurement": { + "humidity": { + "value": 54, + "unit": "%", + "timestamp": "2025-03-21T07:26:16.872Z" + } + }, + "refresh": {}, + "carbonDioxideHealthConcern": { + "carbonDioxideHealthConcern": { + "value": "moderate", + "timestamp": "2025-03-21T13:40:56.560Z" + } + }, + "execute": { + "data": { + "value": { + "payload": { + "rt": ["x.com.samsung.da.sensors"], + "if": ["oic.if.baseline", "oic.if.s"], + "x.com.samsung.da.cleanLevel": "2", + "x.com.samsung.da.refresh": "Off", + "x.com.samsung.da.lastSensingTime": "1740829045", + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "Sensor for CleanLevel", + "x.com.samsung.da.type": "CleanLevel", + "x.com.samsung.da.value": ["2"] + }, + { + "x.com.samsung.da.id": "1", + "x.com.samsung.da.description": "Sensor for Odor", + "x.com.samsung.da.type": "Odor", + "x.com.samsung.da.value": ["2"] + }, + { + "x.com.samsung.da.id": "2", + "x.com.samsung.da.description": "Sensor for Dust", + "x.com.samsung.da.type": "Dust", + "x.com.samsung.da.value": ["29", "1"] + }, + { + "x.com.samsung.da.id": "3", + "x.com.samsung.da.description": "Sensor for FineDust", + "x.com.samsung.da.type": "FineDust", + "x.com.samsung.da.value": ["7", "1"] + }, + { + "x.com.samsung.da.id": "4", + "x.com.samsung.da.description": "Sensor for SuperFineDust", + "x.com.samsung.da.type": "SuperFineDust", + "x.com.samsung.da.value": ["6", "1"] + }, + { + "x.com.samsung.da.id": "5", + "x.com.samsung.da.description": "Sensor for CO2", + "x.com.samsung.da.type": "CO2", + "x.com.samsung.da.value": ["2527", "3"] + } + ] + } + }, + "data": { + "href": "/sensors/vs/0" + }, + "timestamp": "2025-03-01T11:37:26.334Z" + } + }, + "carbonDioxideMeasurement": { + "carbonDioxide": { + "value": 1045, + "unit": "ppm", + "timestamp": "2025-03-21T15:05:44.312Z" + } + }, + "samsungce.deviceIdentification": { + "micomAssayCode": { + "value": null + }, + "modelName": { + "value": null + }, + "serialNumber": { + "value": null + }, + "serialNumberExtra": { + "value": null + }, + "modelClassificationCode": { + "value": null + }, + "description": { + "value": null + }, + "releaseYear": { + "value": null + }, + "binaryId": { + "value": "ASM-KR-TP1-22-ACMB1M", + "timestamp": "2025-03-20T23:08:07.388Z" + } + }, + "airQualitySensor": { + "airQuality": { + "value": 2, + "unit": "CAQI", + "timestamp": "2025-03-21T15:06:39.609Z" + } + }, + "fineDustHealthConcern": { + "fineDustHealthConcern": { + "value": "good", + "timestamp": "2025-03-21T10:25:04.548Z" + } + }, + "ocf": { + "st": { + "value": null + }, + "mndt": { + "value": null + }, + "mnfv": { + "value": "ASM-KR-TP1-22-ACMB1M_16240426", + "timestamp": "2024-08-19T07:28:01.277Z" + }, + "mnhw": { + "value": "Realtek", + "timestamp": "2024-08-19T07:28:01.277Z" + }, + "di": { + "value": "a3a970ea-e09c-9c04-161b-94c934e21666", + "timestamp": "2024-08-19T07:28:01.277Z" + }, + "mnsl": { + "value": "http://www.samsung.com", + "timestamp": "2024-08-19T07:28:01.277Z" + }, + "dmv": { + "value": "1.2.1", + "timestamp": "2024-08-19T07:28:01.277Z" + }, + "n": { + "value": "Samsung AirMonitor", + "timestamp": "2024-08-19T07:28:01.277Z" + }, + "mnmo": { + "value": "ASM-KR-TP1-22-ACMB1M|10243041|75000000001611C40800020000080000", + "timestamp": "2024-08-19T07:28:01.277Z" + }, + "vid": { + "value": "DA-AC-AIRSENSOR-01001", + "timestamp": "2024-08-19T07:28:01.277Z" + }, + "mnmn": { + "value": "Samsung Electronics", + "timestamp": "2024-08-19T07:28:01.277Z" + }, + "mnml": { + "value": "http://www.samsung.com", + "timestamp": "2024-08-19T07:28:01.277Z" + }, + "mnpv": { + "value": "DAWIT 2.0", + "timestamp": "2024-08-19T07:28:01.277Z" + }, + "mnos": { + "value": "TizenRT 4.0", + "timestamp": "2024-08-19T07:28:01.277Z" + }, + "pi": { + "value": "a3a970ea-e09c-9c04-161b-94c934e21666", + "timestamp": "2024-08-19T07:28:01.277Z" + }, + "icv": { + "value": "core.1.1.0", + "timestamp": "2024-08-19T07:28:01.277Z" + } + }, + "odorSensor": { + "odorLevel": { + "value": 1, + "timestamp": "2025-03-21T13:29:15.650Z" + } + }, + "veryFineDustHealthConcern": { + "veryFineDustHealthConcern": { + "value": "good", + "timestamp": "2025-03-21T02:56:21.007Z" + } + }, + "samsungce.doNotDisturb": { + "settable": { + "value": true, + "timestamp": "2024-12-20T14:38:31.895Z" + }, + "dayOfWeek": { + "value": null + }, + "repeatMode": { + "value": null + }, + "startTime": { + "value": "14:00:00Z", + "timestamp": "2024-12-20T14:38:31.895Z" + }, + "endTime": { + "value": "22:00:00Z", + "timestamp": "2024-12-20T14:38:31.895Z" + }, + "activated": { + "value": false, + "timestamp": "2024-12-20T14:38:31.895Z" + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": [], + "timestamp": "2025-03-01T11:37:26.334Z" + } + }, + "samsungce.driverVersion": { + "versionNumber": { + "value": 22100101, + "timestamp": "2023-12-09T04:05:59.505Z" + } + }, + "samsungce.softwareUpdate": { + "targetModule": { + "value": null + }, + "otnDUID": { + "value": "EXCHUODPSCTZY", + "timestamp": "2024-12-20T14:38:31.716Z" + }, + "lastUpdatedDate": { + "value": null + }, + "availableModules": { + "value": [], + "timestamp": "2024-12-20T14:38:31.716Z" + }, + "newVersionAvailable": { + "value": false, + "timestamp": "2024-12-20T14:38:31.716Z" + }, + "operatingState": { + "value": null + }, + "progress": { + "value": null + } + }, + "sec.diagnosticsInformation": { + "logType": { + "value": ["errCode", "dump"], + "timestamp": "2024-12-20T14:38:31.716Z" + }, + "endpoint": { + "value": "SSM", + "timestamp": "2024-12-20T14:38:31.716Z" + }, + "minVersion": { + "value": "1.0", + "timestamp": "2024-12-20T14:38:31.716Z" + }, + "signinPermission": { + "value": null + }, + "setupId": { + "value": "AM0", + "timestamp": "2024-12-20T14:38:31.716Z" + }, + "protocolType": { + "value": "wifi_https", + "timestamp": "2024-12-20T14:38:31.716Z" + }, + "tsId": { + "value": null + }, + "mnId": { + "value": "0AJT", + "timestamp": "2024-12-20T14:38:31.716Z" + }, + "dumpType": { + "value": "file", + "timestamp": "2024-12-20T14:38:31.716Z" + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": 23.0, + "unit": "C", + "timestamp": "2025-03-21T04:40:33.951Z" + } + }, + "dustSensor": { + "dustLevel": { + "value": 31, + "unit": "\u03bcg/m^3", + "timestamp": "2025-03-21T15:06:39.609Z" + }, + "fineDustLevel": { + "value": 7, + "unit": "\u03bcg/m^3", + "timestamp": "2025-03-21T15:06:28.515Z" + } + }, + "veryFineDustSensor": { + "veryFineDustLevel": { + "value": 6, + "unit": "\u03bcg/m^3", + "timestamp": "2025-03-21T15:06:28.515Z" + } + }, + "custom.deviceReportStateConfiguration": { + "reportStateRealtimePeriod": { + "value": "enabled", + "timestamp": "2024-12-20T14:38:31.769Z" + }, + "reportStateRealtime": { + "value": { + "state": "disabled" + }, + "timestamp": "2025-03-20T22:02:48.215Z" + }, + "reportStatePeriod": { + "value": "enabled", + "timestamp": "2024-12-20T14:38:31.769Z" + } + }, + "dustHealthConcern": { + "dustHealthConcern": { + "value": "moderate", + "timestamp": "2025-03-21T15:06:39.609Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/devices/da_ac_airsensor_01001.json b/tests/components/smartthings/fixtures/devices/da_ac_airsensor_01001.json new file mode 100644 index 00000000000..c8304e9c6d8 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/da_ac_airsensor_01001.json @@ -0,0 +1,145 @@ +{ + "items": [ + { + "deviceId": "a3a970ea-e09c-9c04-161b-94c934e21666", + "name": "Samsung AirMonitor", + "label": "\uc5d0\uc5b4\ubaa8\ub2c8\ud130 \ud50c\ub7ec\uc2a4", + "manufacturerName": "Samsung Electronics", + "presentationId": "DA-AC-AIRSENSOR-01001", + "deviceManufacturerCode": "Samsung Electronics", + "locationId": "33db9e71-abe9-43a0-acd3-3f0927bbe5b7", + "ownerId": "9a1ee192-04ba-46ca-9c3d-196d8dbcf807", + "roomId": "445c006d-1796-4dd6-8308-1c3cd045e8ff", + "deviceTypeName": "x.com.st.d.airqualitysensor", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "ocf", + "version": 1 + }, + { + "id": "execute", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "relativeHumidityMeasurement", + "version": 1 + }, + { + "id": "dustSensor", + "version": 1 + }, + { + "id": "dustHealthConcern", + "version": 1 + }, + { + "id": "fineDustHealthConcern", + "version": 1 + }, + { + "id": "veryFineDustSensor", + "version": 1 + }, + { + "id": "veryFineDustHealthConcern", + "version": 1 + }, + { + "id": "airQualitySensor", + "version": 1 + }, + { + "id": "odorSensor", + "version": 1 + }, + { + "id": "carbonDioxideMeasurement", + "version": 1 + }, + { + "id": "carbonDioxideHealthConcern", + "version": 1 + }, + { + "id": "samsungce.deviceIdentification", + "version": 1 + }, + { + "id": "samsungce.rechargeableBattery", + "version": 1 + }, + { + "id": "samsungce.doNotDisturb", + "version": 1 + }, + { + "id": "samsungce.driverVersion", + "version": 1 + }, + { + "id": "samsungce.softwareUpdate", + "version": 1 + }, + { + "id": "custom.deviceReportStateConfiguration", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + }, + { + "id": "sec.diagnosticsInformation", + "version": 1 + } + ], + "categories": [ + { + "name": "AirQualityDetector", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2023-12-09T04:05:59.040Z", + "profile": { + "id": "1d34dd9d-6840-3df6-a6d0-5d9f4a4af2e1" + }, + "ocf": { + "ocfDeviceType": "x.com.st.d.airqualitysensor", + "name": "Samsung AirMonitor", + "specVersion": "core.1.1.0", + "verticalDomainSpecVersion": "1.2.1", + "manufacturerName": "Samsung Electronics", + "modelNumber": "ASM-KR-TP1-22-ACMB1M|10243041|75000000001611C40800020000080000", + "platformVersion": "DAWIT 2.0", + "platformOS": "TizenRT 4.0", + "hwVersion": "Realtek", + "firmwareVersion": "ASM-KR-TP1-22-ACMB1M_16240426", + "vendorId": "DA-AC-AIRSENSOR-01001", + "vendorResourceClientServerVersion": "MediaTek Release 240426", + "lastSignupTime": "2023-12-09T04:05:54.816486Z", + "transferCandidate": false, + "additionalAuthCodeRequired": false + }, + "type": "OCF", + "restrictionTier": 0, + "allowed": null, + "executionContext": "CLOUD", + "relationships": [] + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/snapshots/test_init.ambr b/tests/components/smartthings/snapshots/test_init.ambr index 48a1138e344..930b3851806 100644 --- a/tests/components/smartthings/snapshots/test_init.ambr +++ b/tests/components/smartthings/snapshots/test_init.ambr @@ -299,6 +299,39 @@ 'via_device_id': None, }) # --- +# name: test_devices[da_ac_airsensor_01001] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': 'Realtek', + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + 'a3a970ea-e09c-9c04-161b-94c934e21666', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Samsung Electronics', + 'model': 'ASM-KR-TP1-22-ACMB1M', + 'model_id': None, + 'name': '에어모니터 플러스', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': 'ASM-KR-TP1-22-ACMB1M_16240426', + 'via_device_id': None, + }) +# --- # name: test_devices[da_ac_rac_000001] DeviceRegistryEntrySnapshot({ 'area_id': 'theater', diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index 8b1a3c9f7d6..8656d12c955 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -674,6 +674,416 @@ 'state': '15.0', }) # --- +# name: test_all_entities[da_ac_airsensor_01001][sensor.eeomoniteo_peulreoseu_air_quality-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.eeomoniteo_peulreoseu_air_quality', + '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': 'Air quality', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'air_quality', + 'unique_id': 'a3a970ea-e09c-9c04-161b-94c934e21666.airQuality', + 'unit_of_measurement': 'CAQI', + }) +# --- +# name: test_all_entities[da_ac_airsensor_01001][sensor.eeomoniteo_peulreoseu_air_quality-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '에어모니터 플러스 Air quality', + 'state_class': , + 'unit_of_measurement': 'CAQI', + }), + 'context': , + 'entity_id': 'sensor.eeomoniteo_peulreoseu_air_quality', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2', + }) +# --- +# name: test_all_entities[da_ac_airsensor_01001][sensor.eeomoniteo_peulreoseu_carbon_dioxide-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.eeomoniteo_peulreoseu_carbon_dioxide', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Carbon dioxide', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'a3a970ea-e09c-9c04-161b-94c934e21666.carbonDioxide', + 'unit_of_measurement': 'ppm', + }) +# --- +# name: test_all_entities[da_ac_airsensor_01001][sensor.eeomoniteo_peulreoseu_carbon_dioxide-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'carbon_dioxide', + 'friendly_name': '에어모니터 플러스 Carbon dioxide', + 'state_class': , + 'unit_of_measurement': 'ppm', + }), + 'context': , + 'entity_id': 'sensor.eeomoniteo_peulreoseu_carbon_dioxide', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1045', + }) +# --- +# name: test_all_entities[da_ac_airsensor_01001][sensor.eeomoniteo_peulreoseu_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.eeomoniteo_peulreoseu_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'a3a970ea-e09c-9c04-161b-94c934e21666.humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[da_ac_airsensor_01001][sensor.eeomoniteo_peulreoseu_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': '에어모니터 플러스 Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.eeomoniteo_peulreoseu_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '54', + }) +# --- +# name: test_all_entities[da_ac_airsensor_01001][sensor.eeomoniteo_peulreoseu_odor_sensor-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.eeomoniteo_peulreoseu_odor_sensor', + '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': 'Odor sensor', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'odor_sensor', + 'unique_id': 'a3a970ea-e09c-9c04-161b-94c934e21666.odorLevel', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ac_airsensor_01001][sensor.eeomoniteo_peulreoseu_odor_sensor-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '에어모니터 플러스 Odor sensor', + }), + 'context': , + 'entity_id': 'sensor.eeomoniteo_peulreoseu_odor_sensor', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1', + }) +# --- +# name: test_all_entities[da_ac_airsensor_01001][sensor.eeomoniteo_peulreoseu_pm1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.eeomoniteo_peulreoseu_pm1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'PM1', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'a3a970ea-e09c-9c04-161b-94c934e21666.veryFineDustLevel', + 'unit_of_measurement': 'µg/m³', + }) +# --- +# name: test_all_entities[da_ac_airsensor_01001][sensor.eeomoniteo_peulreoseu_pm1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pm1', + 'friendly_name': '에어모니터 플러스 PM1', + 'state_class': , + 'unit_of_measurement': 'µg/m³', + }), + 'context': , + 'entity_id': 'sensor.eeomoniteo_peulreoseu_pm1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '6', + }) +# --- +# name: test_all_entities[da_ac_airsensor_01001][sensor.eeomoniteo_peulreoseu_pm10-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.eeomoniteo_peulreoseu_pm10', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'PM10', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'a3a970ea-e09c-9c04-161b-94c934e21666.dustLevel', + 'unit_of_measurement': 'µg/m³', + }) +# --- +# name: test_all_entities[da_ac_airsensor_01001][sensor.eeomoniteo_peulreoseu_pm10-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pm10', + 'friendly_name': '에어모니터 플러스 PM10', + 'state_class': , + 'unit_of_measurement': 'µg/m³', + }), + 'context': , + 'entity_id': 'sensor.eeomoniteo_peulreoseu_pm10', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '31', + }) +# --- +# name: test_all_entities[da_ac_airsensor_01001][sensor.eeomoniteo_peulreoseu_pm2_5-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.eeomoniteo_peulreoseu_pm2_5', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'PM2.5', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'a3a970ea-e09c-9c04-161b-94c934e21666.fineDustLevel', + 'unit_of_measurement': 'µg/m³', + }) +# --- +# name: test_all_entities[da_ac_airsensor_01001][sensor.eeomoniteo_peulreoseu_pm2_5-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pm25', + 'friendly_name': '에어모니터 플러스 PM2.5', + 'state_class': , + 'unit_of_measurement': 'µg/m³', + }), + 'context': , + 'entity_id': 'sensor.eeomoniteo_peulreoseu_pm2_5', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '7', + }) +# --- +# name: test_all_entities[da_ac_airsensor_01001][sensor.eeomoniteo_peulreoseu_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.eeomoniteo_peulreoseu_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': 'Temperature', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'a3a970ea-e09c-9c04-161b-94c934e21666.temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ac_airsensor_01001][sensor.eeomoniteo_peulreoseu_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': '에어모니터 플러스 Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.eeomoniteo_peulreoseu_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '23.0', + }) +# --- # name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_energy-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 16d335efc0261d883f947f200747b954ebf9b6e3 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Fri, 21 Mar 2025 16:59:03 +0100 Subject: [PATCH 2819/3148] Update quality scale for Sensibo (#135924) * Update quality scale for Sensibo * platinum --- .../components/sensibo/manifest.json | 1 + .../components/sensibo/quality_scale.yaml | 22 +++++++++---------- script/hassfest/quality_scale.py | 1 - 3 files changed, 11 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/sensibo/manifest.json b/homeassistant/components/sensibo/manifest.json index e6398c5076e..610695aaf7b 100644 --- a/homeassistant/components/sensibo/manifest.json +++ b/homeassistant/components/sensibo/manifest.json @@ -14,5 +14,6 @@ }, "iot_class": "cloud_polling", "loggers": ["pysensibo"], + "quality_scale": "platinum", "requirements": ["pysensibo==1.1.0"] } diff --git a/homeassistant/components/sensibo/quality_scale.yaml b/homeassistant/components/sensibo/quality_scale.yaml index c21cf100e9d..3d71d0ad3ba 100644 --- a/homeassistant/components/sensibo/quality_scale.yaml +++ b/homeassistant/components/sensibo/quality_scale.yaml @@ -19,9 +19,9 @@ rules: comment: | No integrations services. common-modules: done - docs-high-level-description: todo + docs-high-level-description: done docs-installation-instructions: done - docs-removal-instructions: todo + docs-removal-instructions: done docs-actions: done brands: done # Silver @@ -39,9 +39,7 @@ rules: comment: | Tests are very complex and needs a rewrite for future additions integration-owner: done - docs-installation-parameters: - status: todo - comment: configuration_basic + docs-installation-parameters: done docs-configuration-parameters: status: exempt comment: | @@ -71,13 +69,13 @@ rules: status: exempt comment: | This integration doesn't have any cases where raising an issue is needed. - docs-use-cases: todo - docs-supported-devices: todo - docs-supported-functions: todo - docs-data-update: todo - docs-known-limitations: todo - docs-troubleshooting: todo - docs-examples: todo + docs-use-cases: done + docs-supported-devices: done + docs-supported-functions: done + docs-data-update: done + docs-known-limitations: done + docs-troubleshooting: done + docs-examples: done # Platinum async-dependency: done diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index cdd062d2f4c..3fedebe89f4 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -1958,7 +1958,6 @@ INTEGRATIONS_WITHOUT_SCALE = [ "season", "sendgrid", "sense", - "sensibo", "sensirion_ble", "sensorpro", "sensorpush", From e78e87389204e34ac27e851adfd3cbca7b8c5082 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 21 Mar 2025 17:01:13 +0100 Subject: [PATCH 2820/3148] Add update platform to SmartThings (#141070) * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * Refactor SmartThings * fix * fix * Add AC tests * Add thermostat tests * Add cover tests * Add device tests * Add light tests * Add rest of the tests * Add update * Add oauth * Add oauth tests * Add oauth tests * Add oauth tests * Add oauth tests * Bump version * Add rest of the tests * Finalize * Finalize * Finalize * Finalize * Finalize * Finalize * Finalize * Finalize * Finalize * Finalize * Finalize * Add test fixtures * Add test fixtures --- .../components/smartthings/__init__.py | 1 + .../components/smartthings/update.py | 89 ++++ .../device_status/contact_sensor.json | 2 +- .../smartthings/snapshots/test_update.ambr | 421 ++++++++++++++++++ tests/components/smartthings/test_update.py | 142 ++++++ 5 files changed, 654 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/smartthings/update.py create mode 100644 tests/components/smartthings/snapshots/test_update.ambr create mode 100644 tests/components/smartthings/test_update.py diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py index 1fa6a1e259b..8b5860bc3af 100644 --- a/homeassistant/components/smartthings/__init__.py +++ b/homeassistant/components/smartthings/__init__.py @@ -80,6 +80,7 @@ PLATFORMS = [ Platform.SCENE, Platform.SENSOR, Platform.SWITCH, + Platform.UPDATE, Platform.VALVE, ] diff --git a/homeassistant/components/smartthings/update.py b/homeassistant/components/smartthings/update.py new file mode 100644 index 00000000000..bd856bd38ba --- /dev/null +++ b/homeassistant/components/smartthings/update.py @@ -0,0 +1,89 @@ +"""Support for update entities through the SmartThings cloud API.""" + +from __future__ import annotations + +from typing import Any + +from awesomeversion import AwesomeVersion +from pysmartthings import Attribute, Capability, Command + +from homeassistant.components.update import ( + UpdateDeviceClass, + UpdateEntity, + UpdateEntityFeature, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import SmartThingsConfigEntry +from .const import MAIN +from .entity import SmartThingsEntity + + +async def async_setup_entry( + hass: HomeAssistant, + entry: SmartThingsConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Add update entities for a config entry.""" + entry_data = entry.runtime_data + async_add_entities( + SmartThingsUpdateEntity( + entry_data.client, device, entry_data.rooms, {Capability.FIRMWARE_UPDATE} + ) + for device in entry_data.devices.values() + if Capability.FIRMWARE_UPDATE in device.status[MAIN] + ) + + +def is_hex_version(version: str) -> bool: + """Check if the version is a hex version.""" + return len(version) == 8 and all(c in "0123456789abcdefABCDEF" for c in version) + + +class SmartThingsUpdateEntity(SmartThingsEntity, UpdateEntity): + """Define a SmartThings update entity.""" + + _attr_device_class = UpdateDeviceClass.FIRMWARE + _attr_supported_features = ( + UpdateEntityFeature.INSTALL | UpdateEntityFeature.PROGRESS + ) + + @property + def installed_version(self) -> str | None: + """Return the installed version of the entity.""" + return self.get_attribute_value( + Capability.FIRMWARE_UPDATE, Attribute.CURRENT_VERSION + ) + + @property + def latest_version(self) -> str | None: + """Return the available version of the entity.""" + return self.get_attribute_value( + Capability.FIRMWARE_UPDATE, Attribute.AVAILABLE_VERSION + ) + + @property + def in_progress(self) -> bool: + """Return if the entity is in progress.""" + return ( + self.get_attribute_value(Capability.FIRMWARE_UPDATE, Attribute.STATE) + == "updateInProgress" + ) + + async def async_install( + self, version: str | None, backup: bool, **kwargs: Any + ) -> None: + """Install the firmware update.""" + await self.execute_device_command( + Capability.FIRMWARE_UPDATE, + Command.UPDATE_FIRMWARE, + ) + + def version_is_newer(self, latest_version: str, installed_version: str) -> bool: + """Return if the latest version is newer.""" + if is_hex_version(latest_version): + latest_version = f"0x{latest_version}" + if is_hex_version(installed_version): + installed_version = f"0x{installed_version}" + return AwesomeVersion(latest_version) > AwesomeVersion(installed_version) diff --git a/tests/components/smartthings/fixtures/device_status/contact_sensor.json b/tests/components/smartthings/fixtures/device_status/contact_sensor.json index fa158d41b39..ca8c2628c99 100644 --- a/tests/components/smartthings/fixtures/device_status/contact_sensor.json +++ b/tests/components/smartthings/fixtures/device_status/contact_sensor.json @@ -36,7 +36,7 @@ "value": null }, "availableVersion": { - "value": "00000103", + "value": "00000104", "timestamp": "2025-02-09T13:59:19.101Z" }, "lastUpdateStatus": { diff --git a/tests/components/smartthings/snapshots/test_update.ambr b/tests/components/smartthings/snapshots/test_update.ambr new file mode 100644 index 00000000000..e74d2d8518c --- /dev/null +++ b/tests/components/smartthings/snapshots/test_update.ambr @@ -0,0 +1,421 @@ +# serializer version: 1 +# name: test_all_entities[bosch_radiator_thermostat_ii][update.radiator_thermostat_ii_m_wohnzimmer_firmware-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'update', + 'entity_category': , + 'entity_id': 'update.radiator_thermostat_ii_m_wohnzimmer_firmware', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Firmware', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '286ba274-4093-4bcb-849c-a1a3efe7b1e5', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[bosch_radiator_thermostat_ii][update.radiator_thermostat_ii_m_wohnzimmer_firmware-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'auto_update': False, + 'device_class': 'firmware', + 'display_precision': 0, + 'entity_picture': 'https://brands.home-assistant.io/_/smartthings/icon.png', + 'friendly_name': 'Radiator Thermostat II [+M] Wohnzimmer Firmware', + 'in_progress': False, + 'installed_version': '2.00.09 (20009)', + 'latest_version': '2.00.09 (20009)', + 'release_summary': None, + 'release_url': None, + 'skipped_version': None, + 'supported_features': , + 'title': None, + 'update_percentage': None, + }), + 'context': , + 'entity_id': 'update.radiator_thermostat_ii_m_wohnzimmer_firmware', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[centralite][update.dimmer_debian_firmware-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'update', + 'entity_category': , + 'entity_id': 'update.dimmer_debian_firmware', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Firmware', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'd0268a69-abfb-4c92-a646-61cec2e510ad', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[centralite][update.dimmer_debian_firmware-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'auto_update': False, + 'device_class': 'firmware', + 'display_precision': 0, + 'entity_picture': 'https://brands.home-assistant.io/_/smartthings/icon.png', + 'friendly_name': 'Dimmer Debian Firmware', + 'in_progress': False, + 'installed_version': '16015010', + 'latest_version': '16015010', + 'release_summary': None, + 'release_url': None, + 'skipped_version': None, + 'supported_features': , + 'title': None, + 'update_percentage': None, + }), + 'context': , + 'entity_id': 'update.dimmer_debian_firmware', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[contact_sensor][update.front_door_open_closed_sensor_firmware-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'update', + 'entity_category': , + 'entity_id': 'update.front_door_open_closed_sensor_firmware', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Firmware', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '2d9a892b-1c93-45a5-84cb-0e81889498c6', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[contact_sensor][update.front_door_open_closed_sensor_firmware-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'auto_update': False, + 'device_class': 'firmware', + 'display_precision': 0, + 'entity_picture': 'https://brands.home-assistant.io/_/smartthings/icon.png', + 'friendly_name': '.Front Door Open/Closed Sensor Firmware', + 'in_progress': False, + 'installed_version': '00000103', + 'latest_version': '00000104', + 'release_summary': None, + 'release_url': None, + 'skipped_version': None, + 'supported_features': , + 'title': None, + 'update_percentage': None, + }), + 'context': , + 'entity_id': 'update.front_door_open_closed_sensor_firmware', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[ikea_kadrilj][update.kitchen_ikea_kadrilj_window_blind_firmware-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'update', + 'entity_category': , + 'entity_id': 'update.kitchen_ikea_kadrilj_window_blind_firmware', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Firmware', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '71afed1c-006d-4e48-b16e-e7f88f9fd638', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[ikea_kadrilj][update.kitchen_ikea_kadrilj_window_blind_firmware-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'auto_update': False, + 'device_class': 'firmware', + 'display_precision': 0, + 'entity_picture': 'https://brands.home-assistant.io/_/smartthings/icon.png', + 'friendly_name': 'Kitchen IKEA KADRILJ Window blind Firmware', + 'in_progress': False, + 'installed_version': '22007631', + 'latest_version': '22007631', + 'release_summary': None, + 'release_url': None, + 'skipped_version': None, + 'supported_features': , + 'title': None, + 'update_percentage': None, + }), + 'context': , + 'entity_id': 'update.kitchen_ikea_kadrilj_window_blind_firmware', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[multipurpose_sensor][update.deck_door_firmware-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'update', + 'entity_category': , + 'entity_id': 'update.deck_door_firmware', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Firmware', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '7d246592-93db-4d72-a10d-5a51793ece8c', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[multipurpose_sensor][update.deck_door_firmware-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'auto_update': False, + 'device_class': 'firmware', + 'display_precision': 0, + 'entity_picture': 'https://brands.home-assistant.io/_/smartthings/icon.png', + 'friendly_name': 'Deck Door Firmware', + 'in_progress': False, + 'installed_version': '0000001B', + 'latest_version': '0000001B', + 'release_summary': None, + 'release_url': None, + 'skipped_version': None, + 'supported_features': , + 'title': None, + 'update_percentage': None, + }), + 'context': , + 'entity_id': 'update.deck_door_firmware', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[smart_plug][update.arlo_beta_basestation_firmware-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'update', + 'entity_category': , + 'entity_id': 'update.arlo_beta_basestation_firmware', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Firmware', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '550a1c72-65a0-4d55-b97b-75168e055398', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[smart_plug][update.arlo_beta_basestation_firmware-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'auto_update': False, + 'device_class': 'firmware', + 'display_precision': 0, + 'entity_picture': 'https://brands.home-assistant.io/_/smartthings/icon.png', + 'friendly_name': 'Arlo Beta Basestation Firmware', + 'in_progress': False, + 'installed_version': '00102101', + 'latest_version': '00102101', + 'release_summary': None, + 'release_url': None, + 'skipped_version': None, + 'supported_features': , + 'title': None, + 'update_percentage': None, + }), + 'context': , + 'entity_id': 'update.arlo_beta_basestation_firmware', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[yale_push_button_deadbolt_lock][update.basement_door_lock_firmware-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'update', + 'entity_category': , + 'entity_id': 'update.basement_door_lock_firmware', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Firmware', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'a9f587c5-5d8b-4273-8907-e7f609af5158', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[yale_push_button_deadbolt_lock][update.basement_door_lock_firmware-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'auto_update': False, + 'device_class': 'firmware', + 'display_precision': 0, + 'entity_picture': 'https://brands.home-assistant.io/_/smartthings/icon.png', + 'friendly_name': 'Basement Door Lock Firmware', + 'in_progress': False, + 'installed_version': '00840847', + 'latest_version': '00840847', + 'release_summary': None, + 'release_url': None, + 'skipped_version': None, + 'supported_features': , + 'title': None, + 'update_percentage': None, + }), + 'context': , + 'entity_id': 'update.basement_door_lock_firmware', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/smartthings/test_update.py b/tests/components/smartthings/test_update.py new file mode 100644 index 00000000000..8c3d9e1a968 --- /dev/null +++ b/tests/components/smartthings/test_update.py @@ -0,0 +1,142 @@ +"""Test for the SmartThings update platform.""" + +from unittest.mock import AsyncMock + +from pysmartthings import Attribute, Capability, Command +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.smartthings.const import MAIN +from homeassistant.components.update import ( + ATTR_IN_PROGRESS, + DOMAIN as UPDATE_DOMAIN, + SERVICE_INSTALL, +) +from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration, snapshot_smartthings_entities, trigger_update + +from tests.common import MockConfigEntry + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + await setup_integration(hass, mock_config_entry) + + snapshot_smartthings_entities(hass, entity_registry, snapshot, Platform.UPDATE) + + +@pytest.mark.parametrize("device_fixture", ["contact_sensor"]) +async def test_installing_update( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test installing an update.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + UPDATE_DOMAIN, + SERVICE_INSTALL, + {ATTR_ENTITY_ID: "update.front_door_open_closed_sensor_firmware"}, + blocking=True, + ) + devices.execute_device_command.assert_called_once_with( + "2d9a892b-1c93-45a5-84cb-0e81889498c6", + Capability.FIRMWARE_UPDATE, + Command.UPDATE_FIRMWARE, + MAIN, + ) + + +@pytest.mark.parametrize("device_fixture", ["contact_sensor"]) +async def test_state_update( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test state update.""" + await setup_integration(hass, mock_config_entry) + + assert ( + hass.states.get("update.front_door_open_closed_sensor_firmware").state + == STATE_ON + ) + + await trigger_update( + hass, + devices, + "2d9a892b-1c93-45a5-84cb-0e81889498c6", + Capability.FIRMWARE_UPDATE, + Attribute.CURRENT_VERSION, + "00000104", + ) + + assert ( + hass.states.get("update.front_door_open_closed_sensor_firmware").state + == STATE_OFF + ) + + +@pytest.mark.parametrize("device_fixture", ["contact_sensor"]) +async def test_state_progress_update( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test state progress update.""" + await setup_integration(hass, mock_config_entry) + + assert ( + hass.states.get("update.front_door_open_closed_sensor_firmware").attributes[ + ATTR_IN_PROGRESS + ] + is False + ) + + await trigger_update( + hass, + devices, + "2d9a892b-1c93-45a5-84cb-0e81889498c6", + Capability.FIRMWARE_UPDATE, + Attribute.STATE, + "updateInProgress", + ) + + assert ( + hass.states.get("update.front_door_open_closed_sensor_firmware").attributes[ + ATTR_IN_PROGRESS + ] + is True + ) + + +@pytest.mark.parametrize("device_fixture", ["centralite"]) +async def test_state_update_available( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test state update available.""" + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("update.dimmer_debian_firmware").state == STATE_OFF + + await trigger_update( + hass, + devices, + "d0268a69-abfb-4c92-a646-61cec2e510ad", + Capability.FIRMWARE_UPDATE, + Attribute.AVAILABLE_VERSION, + "16015011", + ) + + assert hass.states.get("update.dimmer_debian_firmware").state == STATE_ON From 5f6762321457a6db69f964a16dd0b0b8aaeeebfa Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 21 Mar 2025 18:26:17 +0100 Subject: [PATCH 2821/3148] Deprecate SmartThings events (#141073) --- homeassistant/components/smartthings/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py index 8b5860bc3af..c90dccfe937 100644 --- a/homeassistant/components/smartthings/__init__.py +++ b/homeassistant/components/smartthings/__init__.py @@ -207,6 +207,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: SmartThingsConfigEntry) rooms=rooms, ) + # Events are deprecated and will be removed in 2025.10 def handle_button_press(event: DeviceEvent) -> None: """Handle a button press.""" if ( From 276e2e8f59f01a088f0b75f6f8147225c75b5c0b Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 21 Mar 2025 18:32:05 +0100 Subject: [PATCH 2822/3148] Move device creation in SmartThings (#141074) Move device creation --- .../components/smartthings/__init__.py | 81 ++++++++++++++----- .../components/smartthings/binary_sensor.py | 4 +- .../components/smartthings/climate.py | 14 +--- homeassistant/components/smartthings/cover.py | 6 +- .../components/smartthings/entity.py | 31 ------- homeassistant/components/smartthings/event.py | 7 +- homeassistant/components/smartthings/fan.py | 7 +- homeassistant/components/smartthings/light.py | 7 +- homeassistant/components/smartthings/lock.py | 2 +- .../components/smartthings/sensor.py | 4 +- .../components/smartthings/switch.py | 4 +- .../components/smartthings/update.py | 4 +- homeassistant/components/smartthings/valve.py | 8 +- .../smartthings/snapshots/test_init.ambr | 2 +- 14 files changed, 81 insertions(+), 100 deletions(-) diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py index c90dccfe937..5cc7b3e2c36 100644 --- a/homeassistant/components/smartthings/__init__.py +++ b/homeassistant/components/smartthings/__init__.py @@ -5,7 +5,7 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass import logging -from typing import TYPE_CHECKING, cast +from typing import TYPE_CHECKING, Any, cast from aiohttp import ClientError from pysmartthings import ( @@ -22,6 +22,12 @@ from pysmartthings import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( + ATTR_CONNECTIONS, + ATTR_HW_VERSION, + ATTR_MANUFACTURER, + ATTR_MODEL, + ATTR_SW_VERSION, + ATTR_VIA_DEVICE, CONF_ACCESS_TOKEN, CONF_TOKEN, EVENT_HOMEASSISTANT_STOP, @@ -172,25 +178,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: SmartThingsConfigEntry) raise ConfigEntryAuthFailed from err device_registry = dr.async_get(hass) - for dev in device_status.values(): - for component in dev.device.components: - if component.id == MAIN and Capability.BRIDGE in component.capabilities: - assert dev.device.hub - device_registry.async_get_or_create( - config_entry_id=entry.entry_id, - identifiers={(DOMAIN, dev.device.device_id)}, - connections=( - {(dr.CONNECTION_NETWORK_MAC, dev.device.hub.mac_address)} - if dev.device.hub.mac_address - else set() - ), - name=dev.device.label, - sw_version=dev.device.hub.firmware_version, - model=dev.device.hub.hardware_type, - suggested_area=( - rooms.get(dev.device.room_id) if dev.device.room_id else None - ), - ) + create_devices(device_registry, device_status, entry, rooms) + scenes = { scene.scene_id: scene for scene in await client.get_scenes(location_id=entry.data[CONF_LOCATION_ID]) @@ -278,6 +267,58 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True +def create_devices( + device_registry: dr.DeviceRegistry, + devices: dict[str, FullDevice], + entry: SmartThingsConfigEntry, + rooms: dict[str, str], +) -> None: + """Create devices in the device registry.""" + for device in devices.values(): + kwargs: dict[str, Any] = {} + if device.device.hub is not None: + kwargs = { + ATTR_SW_VERSION: device.device.hub.firmware_version, + ATTR_MODEL: device.device.hub.hardware_type, + } + if device.device.hub.mac_address: + kwargs[ATTR_CONNECTIONS] = { + (dr.CONNECTION_NETWORK_MAC, device.device.hub.mac_address) + } + if device.device.parent_device_id: + kwargs[ATTR_VIA_DEVICE] = (DOMAIN, device.device.parent_device_id) + if (ocf := device.device.ocf) is not None: + kwargs.update( + { + ATTR_MANUFACTURER: ocf.manufacturer_name, + ATTR_MODEL: ( + (ocf.model_number.split("|")[0]) if ocf.model_number else None + ), + ATTR_HW_VERSION: ocf.hardware_version, + ATTR_SW_VERSION: ocf.firmware_version, + } + ) + if (viper := device.device.viper) is not None: + kwargs.update( + { + ATTR_MANUFACTURER: viper.manufacturer_name, + ATTR_MODEL: viper.model_name, + ATTR_HW_VERSION: viper.hardware_version, + ATTR_SW_VERSION: viper.software_version, + } + ) + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, device.device.device_id)}, + configuration_url="https://account.smartthings.com", + name=device.device.label, + suggested_area=( + rooms.get(device.device.room_id) if device.device.room_id else None + ), + **kwargs, + ) + + KEEP_CAPABILITY_QUIRK: dict[ Capability | str, Callable[[dict[Attribute | str, Status]], bool] ] = { diff --git a/homeassistant/components/smartthings/binary_sensor.py b/homeassistant/components/smartthings/binary_sensor.py index ace23ba4ec2..b67b15dfdbc 100644 --- a/homeassistant/components/smartthings/binary_sensor.py +++ b/homeassistant/components/smartthings/binary_sensor.py @@ -134,7 +134,6 @@ async def async_setup_entry( entry_data.client, device, description, - entry_data.rooms, capability, attribute, ) @@ -155,12 +154,11 @@ class SmartThingsBinarySensor(SmartThingsEntity, BinarySensorEntity): client: SmartThings, device: FullDevice, entity_description: SmartThingsBinarySensorEntityDescription, - rooms: dict[str, str], capability: Capability, attribute: Attribute, ) -> None: """Init the class.""" - super().__init__(client, device, rooms, {capability}) + super().__init__(client, device, {capability}) self._attribute = attribute self.capability = capability self.entity_description = entity_description diff --git a/homeassistant/components/smartthings/climate.py b/homeassistant/components/smartthings/climate.py index c6dee3e2be4..e20f191352f 100644 --- a/homeassistant/components/smartthings/climate.py +++ b/homeassistant/components/smartthings/climate.py @@ -118,12 +118,12 @@ async def async_setup_entry( """Add climate entities for a config entry.""" entry_data = entry.runtime_data entities: list[ClimateEntity] = [ - SmartThingsAirConditioner(entry_data.client, entry_data.rooms, device) + SmartThingsAirConditioner(entry_data.client, device) for device in entry_data.devices.values() if all(capability in device.status[MAIN] for capability in AC_CAPABILITIES) ] entities.extend( - SmartThingsThermostat(entry_data.client, entry_data.rooms, device) + SmartThingsThermostat(entry_data.client, device) for device in entry_data.devices.values() if all( capability in device.status[MAIN] for capability in THERMOSTAT_CAPABILITIES @@ -137,14 +137,11 @@ class SmartThingsThermostat(SmartThingsEntity, ClimateEntity): _attr_name = None - def __init__( - self, client: SmartThings, rooms: dict[str, str], device: FullDevice - ) -> None: + def __init__(self, client: SmartThings, device: FullDevice) -> None: """Init the class.""" super().__init__( client, device, - rooms, { Capability.THERMOSTAT_FAN_MODE, Capability.THERMOSTAT_MODE, @@ -338,14 +335,11 @@ class SmartThingsAirConditioner(SmartThingsEntity, ClimateEntity): _attr_name = None _attr_preset_mode = None - def __init__( - self, client: SmartThings, rooms: dict[str, str], device: FullDevice - ) -> None: + def __init__(self, client: SmartThings, device: FullDevice) -> None: """Init the class.""" super().__init__( client, device, - rooms, { Capability.AIR_CONDITIONER_MODE, Capability.SWITCH, diff --git a/homeassistant/components/smartthings/cover.py b/homeassistant/components/smartthings/cover.py index 84bf0412ab4..0b68409443d 100644 --- a/homeassistant/components/smartthings/cover.py +++ b/homeassistant/components/smartthings/cover.py @@ -41,9 +41,7 @@ async def async_setup_entry( """Add covers for a config entry.""" entry_data = entry.runtime_data async_add_entities( - SmartThingsCover( - entry_data.client, device, entry_data.rooms, Capability(capability) - ) + SmartThingsCover(entry_data.client, device, Capability(capability)) for device in entry_data.devices.values() for capability in device.status[MAIN] if capability in CAPABILITIES @@ -60,14 +58,12 @@ class SmartThingsCover(SmartThingsEntity, CoverEntity): self, client: SmartThings, device: FullDevice, - rooms: dict[str, str], capability: Capability, ) -> None: """Initialize the cover class.""" super().__init__( client, device, - rooms, { capability, Capability.BATTERY, diff --git a/homeassistant/components/smartthings/entity.py b/homeassistant/components/smartthings/entity.py index 660ab499d19..12c07bea983 100644 --- a/homeassistant/components/smartthings/entity.py +++ b/homeassistant/components/smartthings/entity.py @@ -30,7 +30,6 @@ class SmartThingsEntity(Entity): self, client: SmartThings, device: FullDevice, - rooms: dict[str, str], capabilities: set[Capability], *, component: str = MAIN, @@ -47,38 +46,8 @@ class SmartThingsEntity(Entity): self.device = device self._attr_unique_id = device.device.device_id self._attr_device_info = DeviceInfo( - configuration_url="https://account.smartthings.com", identifiers={(DOMAIN, device.device.device_id)}, - name=device.device.label, - suggested_area=( - rooms.get(device.device.room_id) if device.device.room_id else None - ), ) - if device.device.parent_device_id: - self._attr_device_info["via_device"] = ( - DOMAIN, - device.device.parent_device_id, - ) - if (ocf := device.device.ocf) is not None: - self._attr_device_info.update( - { - "manufacturer": ocf.manufacturer_name, - "model": ( - (ocf.model_number.split("|")[0]) if ocf.model_number else None - ), - "hw_version": ocf.hardware_version, - "sw_version": ocf.firmware_version, - } - ) - if (viper := device.device.viper) is not None: - self._attr_device_info.update( - { - "manufacturer": viper.manufacturer_name, - "model": viper.model_name, - "hw_version": viper.hardware_version, - "sw_version": viper.software_version, - } - ) async def async_added_to_hass(self) -> None: """Subscribe to updates.""" diff --git a/homeassistant/components/smartthings/event.py b/homeassistant/components/smartthings/event.py index b629bd92b35..e22a32c7726 100644 --- a/homeassistant/components/smartthings/event.py +++ b/homeassistant/components/smartthings/event.py @@ -22,7 +22,7 @@ async def async_setup_entry( """Add events for a config entry.""" entry_data = entry.runtime_data async_add_entities( - SmartThingsButtonEvent(entry_data.client, entry_data.rooms, device, component) + SmartThingsButtonEvent(entry_data.client, device, component) for device in entry_data.devices.values() for component in device.device.components if Capability.BUTTON in component.capabilities @@ -38,14 +38,11 @@ class SmartThingsButtonEvent(SmartThingsEntity, EventEntity): def __init__( self, client: SmartThings, - rooms: dict[str, str], device: FullDevice, component: Component, ) -> None: """Init the class.""" - super().__init__( - client, device, rooms, {Capability.BUTTON}, component=component.id - ) + super().__init__(client, device, {Capability.BUTTON}, component=component.id) self._attr_name = component.label self._attr_unique_id = ( f"{device.device.device_id}_{component.id}_{Capability.BUTTON}" diff --git a/homeassistant/components/smartthings/fan.py b/homeassistant/components/smartthings/fan.py index ef3d9702ce2..1c4cb4edc4a 100644 --- a/homeassistant/components/smartthings/fan.py +++ b/homeassistant/components/smartthings/fan.py @@ -31,7 +31,7 @@ async def async_setup_entry( """Add fans for a config entry.""" entry_data = entry.runtime_data async_add_entities( - SmartThingsFan(entry_data.client, entry_data.rooms, device) + SmartThingsFan(entry_data.client, device) for device in entry_data.devices.values() if Capability.SWITCH in device.status[MAIN] and any( @@ -51,14 +51,11 @@ class SmartThingsFan(SmartThingsEntity, FanEntity): _attr_name = None _attr_speed_count = int_states_in_range(SPEED_RANGE) - def __init__( - self, client: SmartThings, rooms: dict[str, str], device: FullDevice - ) -> None: + def __init__(self, client: SmartThings, device: FullDevice) -> None: """Init the class.""" super().__init__( client, device, - rooms, { Capability.SWITCH, Capability.FAN_SPEED, diff --git a/homeassistant/components/smartthings/light.py b/homeassistant/components/smartthings/light.py index 12c7f7ebbcb..1ad315bcd97 100644 --- a/homeassistant/components/smartthings/light.py +++ b/homeassistant/components/smartthings/light.py @@ -41,7 +41,7 @@ async def async_setup_entry( """Add lights for a config entry.""" entry_data = entry.runtime_data async_add_entities( - SmartThingsLight(entry_data.client, entry_data.rooms, device) + SmartThingsLight(entry_data.client, device) for device in entry_data.devices.values() if Capability.SWITCH in device.status[MAIN] and any(capability in device.status[MAIN] for capability in CAPABILITIES) @@ -71,14 +71,11 @@ class SmartThingsLight(SmartThingsEntity, LightEntity, RestoreEntity): # highest kelvin found supported across 20+ handlers. _attr_max_color_temp_kelvin = 9000 # 111 mireds - def __init__( - self, client: SmartThings, rooms: dict[str, str], device: FullDevice - ) -> None: + def __init__(self, client: SmartThings, device: FullDevice) -> None: """Initialize a SmartThingsLight.""" super().__init__( client, device, - rooms, { Capability.COLOR_CONTROL, Capability.COLOR_TEMPERATURE, diff --git a/homeassistant/components/smartthings/lock.py b/homeassistant/components/smartthings/lock.py index 76a643e417e..f56ecd5d565 100644 --- a/homeassistant/components/smartthings/lock.py +++ b/homeassistant/components/smartthings/lock.py @@ -33,7 +33,7 @@ async def async_setup_entry( """Add locks for a config entry.""" entry_data = entry.runtime_data async_add_entities( - SmartThingsLock(entry_data.client, device, entry_data.rooms, {Capability.LOCK}) + SmartThingsLock(entry_data.client, device, {Capability.LOCK}) for device in entry_data.devices.values() if Capability.LOCK in device.status[MAIN] ) diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index 21d256968ae..ee8550e4f06 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -997,7 +997,6 @@ async def async_setup_entry( entry_data.client, device, description, - entry_data.rooms, capability, attribute, ) @@ -1030,7 +1029,6 @@ class SmartThingsSensor(SmartThingsEntity, SensorEntity): client: SmartThings, device: FullDevice, entity_description: SmartThingsSensorEntityDescription, - rooms: dict[str, str], capability: Capability, attribute: Attribute, ) -> None: @@ -1038,7 +1036,7 @@ class SmartThingsSensor(SmartThingsEntity, SensorEntity): capabilities_to_subscribe = {capability} if entity_description.use_temperature_unit: capabilities_to_subscribe.add(Capability.TEMPERATURE_MEASUREMENT) - super().__init__(client, device, rooms, capabilities_to_subscribe) + super().__init__(client, device, capabilities_to_subscribe) self._attr_unique_id = f"{device.device.device_id}{entity_description.unique_id_separator}{entity_description.key}" self._attribute = attribute self.capability = capability diff --git a/homeassistant/components/smartthings/switch.py b/homeassistant/components/smartthings/switch.py index f470a90bb39..380005f1b93 100644 --- a/homeassistant/components/smartthings/switch.py +++ b/homeassistant/components/smartthings/switch.py @@ -37,9 +37,7 @@ async def async_setup_entry( """Add switches for a config entry.""" entry_data = entry.runtime_data async_add_entities( - SmartThingsSwitch( - entry_data.client, device, entry_data.rooms, {Capability.SWITCH} - ) + SmartThingsSwitch(entry_data.client, device, {Capability.SWITCH}) for device in entry_data.devices.values() if Capability.SWITCH in device.status[MAIN] and not any(capability in device.status[MAIN] for capability in CAPABILITIES) diff --git a/homeassistant/components/smartthings/update.py b/homeassistant/components/smartthings/update.py index bd856bd38ba..bb226918596 100644 --- a/homeassistant/components/smartthings/update.py +++ b/homeassistant/components/smartthings/update.py @@ -28,9 +28,7 @@ async def async_setup_entry( """Add update entities for a config entry.""" entry_data = entry.runtime_data async_add_entities( - SmartThingsUpdateEntity( - entry_data.client, device, entry_data.rooms, {Capability.FIRMWARE_UPDATE} - ) + SmartThingsUpdateEntity(entry_data.client, device, {Capability.FIRMWARE_UPDATE}) for device in entry_data.devices.values() if Capability.FIRMWARE_UPDATE in device.status[MAIN] ) diff --git a/homeassistant/components/smartthings/valve.py b/homeassistant/components/smartthings/valve.py index a38eb9e65c4..3c401c087ec 100644 --- a/homeassistant/components/smartthings/valve.py +++ b/homeassistant/components/smartthings/valve.py @@ -30,7 +30,7 @@ async def async_setup_entry( """Add valves for a config entry.""" entry_data = entry.runtime_data async_add_entities( - SmartThingsValve(entry_data.client, entry_data.rooms, device) + SmartThingsValve(entry_data.client, device) for device in entry_data.devices.values() if Capability.VALVE in device.status[MAIN] ) @@ -43,11 +43,9 @@ class SmartThingsValve(SmartThingsEntity, ValveEntity): _attr_reports_position = False _attr_name = None - def __init__( - self, client: SmartThings, rooms: dict[str, str], device: FullDevice - ) -> None: + def __init__(self, client: SmartThings, device: FullDevice) -> None: """Init the class.""" - super().__init__(client, device, rooms, {Capability.VALVE}) + super().__init__(client, device, {Capability.VALVE}) self._attr_device_class = DEVICE_CLASS_MAP.get( device.device.components[0].user_category or device.device.components[0].manufacturer_category diff --git a/tests/components/smartthings/snapshots/test_init.ambr b/tests/components/smartthings/snapshots/test_init.ambr index 930b3851806..d6e98553015 100644 --- a/tests/components/smartthings/snapshots/test_init.ambr +++ b/tests/components/smartthings/snapshots/test_init.ambr @@ -1624,7 +1624,7 @@ 'area_id': 'theater', 'config_entries': , 'config_entries_subentries': , - 'configuration_url': None, + 'configuration_url': 'https://account.smartthings.com', 'connections': set({ tuple( 'mac', From 65aef40a3fcf07d5beff65a926f7ac14a4a0179f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Thu, 20 Mar 2025 09:39:28 +0100 Subject: [PATCH 2823/3148] Fix initial fetch of Home Connect appliance data to handle API rate limit errors (#139379) * Fix initial fetch of appliance data to handle API rate limit errors * Apply comments * Delete stale function * Handle api rate limit error at options fetching * Update appliances after stream non-breaking error * Always initialize coordinator data * Improve device update * Update test description Co-authored-by: Martin Hjelmare --------- Co-authored-by: Martin Hjelmare --- .../components/home_connect/__init__.py | 9 +- .../components/home_connect/common.py | 35 ------ .../components/home_connect/coordinator.py | 100 ++++++++++++++++-- .../home_connect/test_coordinator.py | 44 +++++++- tests/components/home_connect/test_init.py | 50 ++++++++- 5 files changed, 188 insertions(+), 50 deletions(-) diff --git a/homeassistant/components/home_connect/__init__.py b/homeassistant/components/home_connect/__init__.py index 6814ab3eed2..70b357518da 100644 --- a/homeassistant/components/home_connect/__init__.py +++ b/homeassistant/components/home_connect/__init__.py @@ -629,14 +629,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: HomeConnectConfigEntry) home_connect_client = HomeConnectClient(config_entry_auth) coordinator = HomeConnectCoordinator(hass, entry, home_connect_client) - await coordinator.async_config_entry_first_refresh() - + await coordinator.async_setup() entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) entry.runtime_data.start_event_listener() + entry.async_create_background_task( + hass, + coordinator.async_refresh(), + f"home_connect-initial-full-refresh-{entry.entry_id}", + ) + return True diff --git a/homeassistant/components/home_connect/common.py b/homeassistant/components/home_connect/common.py index f52b59bc213..cd3fefad80c 100644 --- a/homeassistant/components/home_connect/common.py +++ b/homeassistant/components/home_connect/common.py @@ -137,41 +137,6 @@ def setup_home_connect_entry( defaultdict(list) ) - entities: list[HomeConnectEntity] = [] - for appliance in entry.runtime_data.data.values(): - entities_to_add = get_entities_for_appliance(entry, appliance) - if get_option_entities_for_appliance: - entities_to_add.extend(get_option_entities_for_appliance(entry, appliance)) - for event_key in ( - EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM, - EventKey.BSH_COMMON_ROOT_SELECTED_PROGRAM, - ): - changed_options_listener_remove_callback = ( - entry.runtime_data.async_add_listener( - partial( - _create_option_entities, - entry, - appliance, - known_entity_unique_ids, - get_option_entities_for_appliance, - async_add_entities, - ), - (appliance.info.ha_id, event_key), - ) - ) - entry.async_on_unload(changed_options_listener_remove_callback) - changed_options_listener_remove_callbacks[appliance.info.ha_id].append( - changed_options_listener_remove_callback - ) - known_entity_unique_ids.update( - { - cast(str, entity.unique_id): appliance.info.ha_id - for entity in entities_to_add - } - ) - entities.extend(entities_to_add) - async_add_entities(entities) - entry.async_on_unload( entry.runtime_data.async_add_special_listener( partial( diff --git a/homeassistant/components/home_connect/coordinator.py b/homeassistant/components/home_connect/coordinator.py index 7898fb7be12..669e31f58c1 100644 --- a/homeassistant/components/home_connect/coordinator.py +++ b/homeassistant/components/home_connect/coordinator.py @@ -2,7 +2,7 @@ from __future__ import annotations -import asyncio +from asyncio import sleep as asyncio_sleep from collections import defaultdict from collections.abc import Callable from dataclasses import dataclass @@ -29,6 +29,7 @@ from aiohomeconnect.model.error import ( HomeConnectApiError, HomeConnectError, HomeConnectRequestError, + TooManyRequestsError, UnauthorizedError, ) from aiohomeconnect.model.program import EnumerateProgram, ProgramDefinitionOption @@ -36,11 +37,11 @@ from propcache.api import cached_property from homeassistant.config_entries import ConfigEntry from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback -from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import device_registry as dr from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import APPLIANCES_WITH_PROGRAMS, DOMAIN +from .const import API_DEFAULT_RETRY_AFTER, APPLIANCES_WITH_PROGRAMS, DOMAIN from .utils import get_dict_from_home_connect_error _LOGGER = logging.getLogger(__name__) @@ -154,7 +155,7 @@ class HomeConnectCoordinator( f"home_connect-events_listener_task-{self.config_entry.entry_id}", ) - async def _event_listener(self) -> None: + async def _event_listener(self) -> None: # noqa: C901 """Match event with listener for event type.""" retry_time = 10 while True: @@ -269,7 +270,7 @@ class HomeConnectCoordinator( type(error).__name__, retry_time, ) - await asyncio.sleep(retry_time) + await asyncio_sleep(retry_time) retry_time = min(retry_time * 2, 3600) except HomeConnectApiError as error: _LOGGER.error("Error while listening for events: %s", error) @@ -278,6 +279,13 @@ class HomeConnectCoordinator( ) break + # Trigger to delete the possible depaired device entities + # from known_entities variable at common.py + for listener, context in self._special_listeners.values(): + assert isinstance(context, tuple) + if EventKey.BSH_COMMON_APPLIANCE_DEPAIRED in context: + listener() + @callback def _call_event_listener(self, event_message: EventMessage) -> None: """Call listener for event.""" @@ -295,6 +303,42 @@ class HomeConnectCoordinator( async def _async_update_data(self) -> dict[str, HomeConnectApplianceData]: """Fetch data from Home Connect.""" + await self._async_setup() + + for appliance_data in self.data.values(): + appliance = appliance_data.info + ha_id = appliance.ha_id + while True: + try: + self.data[ha_id] = await self._get_appliance_data( + appliance, self.data.get(ha_id) + ) + except TooManyRequestsError as err: + _LOGGER.debug( + "Rate limit exceeded on initial fetch: %s", + err, + ) + await asyncio_sleep(err.retry_after or API_DEFAULT_RETRY_AFTER) + else: + break + + for listener, context in self._special_listeners.values(): + assert isinstance(context, tuple) + if EventKey.BSH_COMMON_APPLIANCE_PAIRED in context: + listener() + + return self.data + + async def async_setup(self) -> None: + """Set up the devices.""" + try: + await self._async_setup() + except UpdateFailed as err: + raise ConfigEntryNotReady from err + + async def _async_setup(self) -> None: + """Set up the devices.""" + old_appliances = set(self.data.keys()) try: appliances = await self.client.get_home_appliances() except UnauthorizedError as error: @@ -312,12 +356,38 @@ class HomeConnectCoordinator( translation_placeholders=get_dict_from_home_connect_error(error), ) from error - return { - appliance.ha_id: await self._get_appliance_data( - appliance, self.data.get(appliance.ha_id) + for appliance in appliances.homeappliances: + self.device_registry.async_get_or_create( + config_entry_id=self.config_entry.entry_id, + identifiers={(DOMAIN, appliance.ha_id)}, + manufacturer=appliance.brand, + name=appliance.name, + model=appliance.vib, ) - for appliance in appliances.homeappliances - } + if appliance.ha_id not in self.data: + self.data[appliance.ha_id] = HomeConnectApplianceData( + commands=set(), + events={}, + info=appliance, + options={}, + programs=[], + settings={}, + status={}, + ) + else: + self.data[appliance.ha_id].info.connected = appliance.connected + old_appliances.remove(appliance.ha_id) + + for ha_id in old_appliances: + self.data.pop(ha_id, None) + device = self.device_registry.async_get_device( + identifiers={(DOMAIN, ha_id)} + ) + if device: + self.device_registry.async_update_device( + device_id=device.id, + remove_config_entry_id=self.config_entry.entry_id, + ) async def _get_appliance_data( self, @@ -339,6 +409,8 @@ class HomeConnectCoordinator( await self.client.get_settings(appliance.ha_id) ).settings } + except TooManyRequestsError: + raise except HomeConnectError as error: _LOGGER.debug( "Error fetching settings for %s: %s", @@ -353,6 +425,8 @@ class HomeConnectCoordinator( status.key: status for status in (await self.client.get_status(appliance.ha_id)).status } + except TooManyRequestsError: + raise except HomeConnectError as error: _LOGGER.debug( "Error fetching status for %s: %s", @@ -369,6 +443,8 @@ class HomeConnectCoordinator( if appliance.type in APPLIANCES_WITH_PROGRAMS: try: all_programs = await self.client.get_all_programs(appliance.ha_id) + except TooManyRequestsError: + raise except HomeConnectError as error: _LOGGER.debug( "Error fetching programs for %s: %s", @@ -427,6 +503,8 @@ class HomeConnectCoordinator( await self.client.get_available_commands(appliance.ha_id) ).commands } + except TooManyRequestsError: + raise except HomeConnectError: commands = set() @@ -461,6 +539,8 @@ class HomeConnectCoordinator( ).options or [] } + except TooManyRequestsError: + raise except HomeConnectError as error: _LOGGER.debug( "Error fetching options for %s: %s", diff --git a/tests/components/home_connect/test_coordinator.py b/tests/components/home_connect/test_coordinator.py index 1a49d2bb2a0..0c9ff7842b7 100644 --- a/tests/components/home_connect/test_coordinator.py +++ b/tests/components/home_connect/test_coordinator.py @@ -28,6 +28,7 @@ from homeassistant.components.home_connect.const import ( BSH_DOOR_STATE_OPEN, BSH_EVENT_PRESENT_STATE_PRESENT, BSH_POWER_OFF, + DOMAIN, ) from homeassistant.config_entries import ConfigEntries, ConfigEntryState from homeassistant.const import EVENT_STATE_REPORTED, Platform @@ -37,7 +38,7 @@ from homeassistant.core import ( HomeAssistant, callback, ) -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -489,3 +490,44 @@ async def test_event_listener_resilience( state = hass.states.get(entity_id) assert state assert state.state == after_event_expected_state + + +async def test_devices_updated_on_refresh( + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + device_registry: dr.DeviceRegistry, +) -> None: + """Test handling of devices added or deleted while event stream is down.""" + appliances: list[HomeAppliance] = ( + client.get_home_appliances.return_value.homeappliances + ) + assert len(appliances) >= 3 + client.get_home_appliances = AsyncMock( + return_value=ArrayOfHomeAppliances(appliances[:2]), + ) + + await async_setup_component(hass, "homeassistant", {}) + assert config_entry.state == ConfigEntryState.NOT_LOADED + await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + for appliance in appliances[:2]: + assert device_registry.async_get_device({(DOMAIN, appliance.ha_id)}) + assert not device_registry.async_get_device({(DOMAIN, appliances[2].ha_id)}) + + client.get_home_appliances = AsyncMock( + return_value=ArrayOfHomeAppliances(appliances[1:3]), + ) + await hass.services.async_call( + "homeassistant", + "update_entity", + {"entity_id": "switch.dishwasher_power"}, + blocking=True, + ) + + assert not device_registry.async_get_device({(DOMAIN, appliances[0].ha_id)}) + for appliance in appliances[2:3]: + assert device_registry.async_get_device({(DOMAIN, appliance.ha_id)}) diff --git a/tests/components/home_connect/test_init.py b/tests/components/home_connect/test_init.py index 4287ac9d227..291caeafd58 100644 --- a/tests/components/home_connect/test_init.py +++ b/tests/components/home_connect/test_init.py @@ -3,11 +3,15 @@ from collections.abc import Awaitable, Callable from http import HTTPStatus from typing import Any -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, patch from aiohomeconnect.const import OAUTH2_TOKEN from aiohomeconnect.model import OptionKey, ProgramKey, SettingKey, StatusKey -from aiohomeconnect.model.error import HomeConnectError, UnauthorizedError +from aiohomeconnect.model.error import ( + HomeConnectError, + TooManyRequestsError, + UnauthorizedError, +) import aiohttp import pytest from syrupy.assertion import SnapshotAssertion @@ -355,6 +359,48 @@ async def test_client_error( assert client_with_exception.get_home_appliances.call_count == 1 +@pytest.mark.parametrize( + "raising_exception_method", + [ + "get_settings", + "get_status", + "get_all_programs", + "get_available_commands", + "get_available_program", + ], +) +async def test_client_rate_limit_error( + raising_exception_method: str, + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, +) -> None: + """Test client errors during setup integration.""" + retry_after = 42 + + original_mock = getattr(client, raising_exception_method) + mock = AsyncMock() + + async def side_effect(*args, **kwargs): + if mock.call_count <= 1: + raise TooManyRequestsError("error.key", retry_after=retry_after) + return await original_mock(*args, **kwargs) + + mock.side_effect = side_effect + setattr(client, raising_exception_method, mock) + + assert config_entry.state == ConfigEntryState.NOT_LOADED + with patch( + "homeassistant.components.home_connect.coordinator.asyncio_sleep", + ) as asyncio_sleep_mock: + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + assert mock.call_count >= 2 + asyncio_sleep_mock.assert_called_once_with(retry_after) + + @pytest.mark.parametrize( "service_call", SERVICE_KV_CALL_PARAMS + SERVICE_COMMAND_CALL_PARAMS + SERVICE_PROGRAM_CALL_PARAMS, From 43e24cf8335529e652d2c5761790dcdc2b86e828 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Wed, 19 Mar 2025 18:53:14 +0100 Subject: [PATCH 2824/3148] Handle API rate limit error on Home Connect entities fetch (#139384) * Handle API rate limit error on entities fetch * Apply suggestions Co-authored-by: Martin Hjelmare * Add decorator (does not work) * Fix decorator * Apply suggestions Co-authored-by: Martin Hjelmare * Add test --------- Co-authored-by: Martin Hjelmare --- .../components/home_connect/const.py | 1 + .../components/home_connect/entity.py | 44 +++++- .../components/home_connect/number.py | 23 +-- .../components/home_connect/select.py | 20 ++- .../components/home_connect/sensor.py | 21 ++- tests/components/home_connect/test_number.py | 97 ++++++++++++- tests/components/home_connect/test_select.py | 136 +++++++++++++++++- tests/components/home_connect/test_sensor.py | 124 +++++++++++++++- 8 files changed, 431 insertions(+), 35 deletions(-) diff --git a/homeassistant/components/home_connect/const.py b/homeassistant/components/home_connect/const.py index 999bb5da13d..279aaef7b9c 100644 --- a/homeassistant/components/home_connect/const.py +++ b/homeassistant/components/home_connect/const.py @@ -10,6 +10,7 @@ from .utils import bsh_key_to_translation_key DOMAIN = "home_connect" +API_DEFAULT_RETRY_AFTER = 60 APPLIANCES_WITH_PROGRAMS = ( "CleaningRobot", diff --git a/homeassistant/components/home_connect/entity.py b/homeassistant/components/home_connect/entity.py index b55ff374f34..8a0f9bd7640 100644 --- a/homeassistant/components/home_connect/entity.py +++ b/homeassistant/components/home_connect/entity.py @@ -1,21 +1,28 @@ """Home Connect entity base class.""" from abc import abstractmethod +from collections.abc import Callable, Coroutine import contextlib +from datetime import datetime import logging -from typing import cast +from typing import Any, Concatenate, cast from aiohomeconnect.model import EventKey, OptionKey -from aiohomeconnect.model.error import ActiveProgramNotSetError, HomeConnectError +from aiohomeconnect.model.error import ( + ActiveProgramNotSetError, + HomeConnectError, + TooManyRequestsError, +) from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.event import async_call_later from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN +from .const import API_DEFAULT_RETRY_AFTER, DOMAIN from .coordinator import HomeConnectApplianceData, HomeConnectCoordinator from .utils import get_dict_from_home_connect_error @@ -127,3 +134,34 @@ class HomeConnectOptionEntity(HomeConnectEntity): def bsh_key(self) -> OptionKey: """Return the BSH key.""" return cast(OptionKey, self.entity_description.key) + + +def constraint_fetcher[_EntityT: HomeConnectEntity, **_P]( + func: Callable[Concatenate[_EntityT, _P], Coroutine[Any, Any, Any]], +) -> Callable[Concatenate[_EntityT, _P], Coroutine[Any, Any, None]]: + """Decorate the function to catch Home Connect too many requests error and retry later. + + If it needs to be called later, it will call async_write_ha_state function + """ + + async def handler_to_return( + self: _EntityT, *args: _P.args, **kwargs: _P.kwargs + ) -> None: + async def handler(_datetime: datetime | None = None) -> None: + try: + await func(self, *args, **kwargs) + except TooManyRequestsError as err: + if (retry_after := err.retry_after) is None: + retry_after = API_DEFAULT_RETRY_AFTER + async_call_later(self.hass, retry_after, handler) + except HomeConnectError as err: + _LOGGER.error( + "Error fetching constraints for %s: %s", self.entity_id, err + ) + else: + if _datetime is not None: + self.async_write_ha_state() + + await handler() + + return handler_to_return diff --git a/homeassistant/components/home_connect/number.py b/homeassistant/components/home_connect/number.py index cef35005b32..db0258f2739 100644 --- a/homeassistant/components/home_connect/number.py +++ b/homeassistant/components/home_connect/number.py @@ -25,7 +25,7 @@ from .const import ( UNIT_MAP, ) from .coordinator import HomeConnectApplianceData, HomeConnectConfigEntry -from .entity import HomeConnectEntity, HomeConnectOptionEntity +from .entity import HomeConnectEntity, HomeConnectOptionEntity, constraint_fetcher from .utils import get_dict_from_home_connect_error _LOGGER = logging.getLogger(__name__) @@ -189,19 +189,25 @@ class HomeConnectNumberEntity(HomeConnectEntity, NumberEntity): }, ) from err + @constraint_fetcher async def async_fetch_constraints(self) -> None: """Fetch the max and min values and step for the number entity.""" - try: + setting_key = cast(SettingKey, self.bsh_key) + data = self.appliance.settings.get(setting_key) + if not data or not data.unit or not data.constraints: data = await self.coordinator.client.get_setting( - self.appliance.info.ha_id, setting_key=SettingKey(self.bsh_key) + self.appliance.info.ha_id, setting_key=setting_key ) - except HomeConnectError as err: - _LOGGER.error("An error occurred: %s", err) - else: + if data.unit: + self._attr_native_unit_of_measurement = data.unit self.set_constraints(data) def set_constraints(self, setting: GetSetting) -> None: """Set constraints for the number entity.""" + if setting.unit: + self._attr_native_unit_of_measurement = UNIT_MAP.get( + setting.unit, setting.unit + ) if not (constraints := setting.constraints): return if constraints.max: @@ -222,10 +228,10 @@ class HomeConnectNumberEntity(HomeConnectEntity, NumberEntity): """When entity is added to hass.""" await super().async_added_to_hass() data = self.appliance.settings[cast(SettingKey, self.bsh_key)] - self._attr_native_unit_of_measurement = data.unit self.set_constraints(data) if ( - not hasattr(self, "_attr_native_min_value") + not hasattr(self, "_attr_native_unit_of_measurement") + or not hasattr(self, "_attr_native_min_value") or not hasattr(self, "_attr_native_max_value") or not hasattr(self, "_attr_native_step") ): @@ -253,7 +259,6 @@ class HomeConnectOptionNumberEntity(HomeConnectOptionEntity, NumberEntity): or candidate_unit != self._attr_native_unit_of_measurement ): self._attr_native_unit_of_measurement = candidate_unit - self.__dict__.pop("unit_of_measurement", None) option_constraints = option_definition.constraints if option_constraints: if ( diff --git a/homeassistant/components/home_connect/select.py b/homeassistant/components/home_connect/select.py index ef3e2ccbf82..5cfda3585bc 100644 --- a/homeassistant/components/home_connect/select.py +++ b/homeassistant/components/home_connect/select.py @@ -1,8 +1,8 @@ """Provides a select platform for Home Connect.""" from collections.abc import Callable, Coroutine -import contextlib from dataclasses import dataclass +import logging from typing import Any, cast from aiohomeconnect.client import Client as HomeConnectClient @@ -47,9 +47,11 @@ from .coordinator import ( HomeConnectConfigEntry, HomeConnectCoordinator, ) -from .entity import HomeConnectEntity, HomeConnectOptionEntity +from .entity import HomeConnectEntity, HomeConnectOptionEntity, constraint_fetcher from .utils import bsh_key_to_translation_key, get_dict_from_home_connect_error +_LOGGER = logging.getLogger(__name__) + PARALLEL_UPDATES = 1 FUNCTIONAL_LIGHT_COLOR_TEMPERATURE_ENUM = { @@ -458,17 +460,21 @@ class HomeConnectSelectEntity(HomeConnectEntity, SelectEntity): async def async_added_to_hass(self) -> None: """When entity is added to hass.""" await super().async_added_to_hass() + await self.async_fetch_options() + + @constraint_fetcher + async def async_fetch_options(self) -> None: + """Fetch options from the API.""" setting = self.appliance.settings.get(cast(SettingKey, self.bsh_key)) if ( not setting or not setting.constraints or not setting.constraints.allowed_values ): - with contextlib.suppress(HomeConnectError): - setting = await self.coordinator.client.get_setting( - self.appliance.info.ha_id, - setting_key=cast(SettingKey, self.bsh_key), - ) + setting = await self.coordinator.client.get_setting( + self.appliance.info.ha_id, + setting_key=cast(SettingKey, self.bsh_key), + ) if setting and setting.constraints and setting.constraints.allowed_values: self._attr_options = [ diff --git a/homeassistant/components/home_connect/sensor.py b/homeassistant/components/home_connect/sensor.py index c12e1b7b6e4..796af8260fc 100644 --- a/homeassistant/components/home_connect/sensor.py +++ b/homeassistant/components/home_connect/sensor.py @@ -1,12 +1,11 @@ """Provides a sensor for Home Connect.""" -import contextlib from dataclasses import dataclass from datetime import timedelta +import logging from typing import cast from aiohomeconnect.model import EventKey, StatusKey -from aiohomeconnect.model.error import HomeConnectError from homeassistant.components.sensor import ( SensorDeviceClass, @@ -28,7 +27,9 @@ from .const import ( UNIT_MAP, ) from .coordinator import HomeConnectApplianceData, HomeConnectConfigEntry -from .entity import HomeConnectEntity +from .entity import HomeConnectEntity, constraint_fetcher + +_LOGGER = logging.getLogger(__name__) PARALLEL_UPDATES = 0 @@ -335,16 +336,14 @@ class HomeConnectSensor(HomeConnectEntity, SensorEntity): else: await self.fetch_unit() + @constraint_fetcher async def fetch_unit(self) -> None: """Fetch the unit of measurement.""" - with contextlib.suppress(HomeConnectError): - data = await self.coordinator.client.get_status_value( - self.appliance.info.ha_id, status_key=cast(StatusKey, self.bsh_key) - ) - if data.unit: - self._attr_native_unit_of_measurement = UNIT_MAP.get( - data.unit, data.unit - ) + data = await self.coordinator.client.get_status_value( + self.appliance.info.ha_id, status_key=cast(StatusKey, self.bsh_key) + ) + if data.unit: + self._attr_native_unit_of_measurement = UNIT_MAP.get(data.unit, data.unit) class HomeConnectProgramSensor(HomeConnectSensor): diff --git a/tests/components/home_connect/test_number.py b/tests/components/home_connect/test_number.py index 214dcb6137c..bb87cf9f3dc 100644 --- a/tests/components/home_connect/test_number.py +++ b/tests/components/home_connect/test_number.py @@ -2,7 +2,7 @@ from collections.abc import Awaitable, Callable import random -from unittest.mock import AsyncMock, MagicMock +from unittest.mock import AsyncMock, MagicMock, patch from aiohomeconnect.model import ( ArrayOfEvents, @@ -22,6 +22,7 @@ from aiohomeconnect.model.error import ( HomeConnectApiError, HomeConnectError, SelectedProgramNotSetError, + TooManyRequestsError, ) from aiohomeconnect.model.program import ( ProgramDefinitionConstraints, @@ -47,7 +48,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed @pytest.fixture @@ -340,6 +341,98 @@ async def test_number_entity_functionality( assert hass.states.is_state(entity_id, str(float(value))) +@pytest.mark.parametrize("appliance_ha_id", ["FridgeFreezer"], indirect=True) +@pytest.mark.parametrize("retry_after", [0, None]) +@pytest.mark.parametrize( + ( + "entity_id", + "setting_key", + "type", + "min_value", + "max_value", + "step_size", + "unit_of_measurement", + ), + [ + ( + f"{NUMBER_DOMAIN.lower()}.fridgefreezer_refrigerator_temperature", + SettingKey.REFRIGERATION_FRIDGE_FREEZER_SETPOINT_TEMPERATURE_REFRIGERATOR, + "Double", + 7, + 15, + 5, + "°C", + ), + ], +) +@patch("homeassistant.components.home_connect.entity.API_DEFAULT_RETRY_AFTER", new=0) +async def test_fetch_constraints_after_rate_limit_error( + retry_after: int | None, + appliance_ha_id: str, + entity_id: str, + setting_key: SettingKey, + type: str, + min_value: int, + max_value: int, + step_size: int, + unit_of_measurement: str, + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, +) -> None: + """Test that, if a API rate limit error is raised, the constraints are fetched later.""" + + def get_settings_side_effect(ha_id: str): + if ha_id != appliance_ha_id: + return ArrayOfSettings([]) + return ArrayOfSettings( + [ + GetSetting( + key=setting_key, + raw_key=setting_key.value, + value=random.randint(min_value, max_value), + ) + ] + ) + + client.get_settings = AsyncMock(side_effect=get_settings_side_effect) + client.get_setting = AsyncMock( + side_effect=[ + TooManyRequestsError("error.key", retry_after=retry_after), + GetSetting( + key=setting_key, + raw_key=setting_key.value, + value=random.randint(min_value, max_value), + unit=unit_of_measurement, + type=type, + constraints=SettingConstraints( + min=min_value, + max=max_value, + step_size=step_size, + ), + ), + ] + ) + + assert config_entry.state is ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.LOADED + + assert client.get_setting.call_count == 2 + + entity_state = hass.states.get(entity_id) + assert entity_state + attributes = entity_state.attributes + assert attributes["min"] == min_value + assert attributes["max"] == max_value + assert attributes["step"] == step_size + assert attributes["unit_of_measurement"] == unit_of_measurement + + @pytest.mark.parametrize( ("entity_id", "setting_key", "mock_attr"), [ diff --git a/tests/components/home_connect/test_select.py b/tests/components/home_connect/test_select.py index 22ece365e6b..d7ca8a023cd 100644 --- a/tests/components/home_connect/test_select.py +++ b/tests/components/home_connect/test_select.py @@ -21,6 +21,7 @@ from aiohomeconnect.model.error import ( ActiveProgramNotSetError, HomeConnectError, SelectedProgramNotSetError, + TooManyRequestsError, ) from aiohomeconnect.model.program import ( EnumerateProgram, @@ -50,7 +51,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed @pytest.fixture @@ -566,6 +567,139 @@ async def test_fetch_allowed_values( assert set(entity_state.attributes[ATTR_OPTIONS]) == expected_options +@pytest.mark.parametrize("appliance_ha_id", ["Hood"], indirect=True) +@pytest.mark.parametrize( + ( + "entity_id", + "setting_key", + "allowed_values", + "expected_options", + ), + [ + ( + "select.hood_ambient_light_color", + SettingKey.BSH_COMMON_AMBIENT_LIGHT_COLOR, + [f"BSH.Common.EnumType.AmbientLightColor.Color{i}" for i in range(50)], + {str(i) for i in range(1, 50)}, + ), + ], +) +async def test_fetch_allowed_values_after_rate_limit_error( + appliance_ha_id: str, + entity_id: str, + setting_key: SettingKey, + allowed_values: list[str | None], + expected_options: set[str], + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, +) -> None: + """Test fetch allowed values.""" + + def get_settings_side_effect(ha_id: str): + if ha_id != appliance_ha_id: + return ArrayOfSettings([]) + return ArrayOfSettings( + [ + GetSetting( + key=setting_key, + raw_key=setting_key.value, + value="", # Not important + ) + ] + ) + + client.get_settings = AsyncMock(side_effect=get_settings_side_effect) + client.get_setting = AsyncMock( + side_effect=[ + TooManyRequestsError("error.key", retry_after=0), + GetSetting( + key=setting_key, + raw_key=setting_key.value, + value="", # Not important + constraints=SettingConstraints( + allowed_values=allowed_values, + ), + ), + ] + ) + + assert config_entry.state is ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.LOADED + + assert client.get_setting.call_count == 2 + + entity_state = hass.states.get(entity_id) + assert entity_state + assert set(entity_state.attributes[ATTR_OPTIONS]) == expected_options + + +@pytest.mark.parametrize("appliance_ha_id", ["Hood"], indirect=True) +@pytest.mark.parametrize( + ( + "entity_id", + "setting_key", + "exception", + "expected_options", + ), + [ + ( + "select.hood_ambient_light_color", + SettingKey.BSH_COMMON_AMBIENT_LIGHT_COLOR, + HomeConnectError(), + { + "b_s_h_common_enum_type_ambient_light_color_custom_color", + *{str(i) for i in range(1, 100)}, + }, + ), + ], +) +async def test_default_values_after_fetch_allowed_values_error( + appliance_ha_id: str, + entity_id: str, + setting_key: SettingKey, + exception: Exception, + expected_options: set[str], + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, +) -> None: + """Test fetch allowed values.""" + + def get_settings_side_effect(ha_id: str): + if ha_id != appliance_ha_id: + return ArrayOfSettings([]) + return ArrayOfSettings( + [ + GetSetting( + key=setting_key, + raw_key=setting_key.value, + value="", # Not important + ) + ] + ) + + client.get_settings = AsyncMock(side_effect=get_settings_side_effect) + client.get_setting = AsyncMock(side_effect=exception) + + assert config_entry.state is ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state is ConfigEntryState.LOADED + + assert client.get_setting.call_count == 1 + + entity_state = hass.states.get(entity_id) + assert entity_state + assert set(entity_state.attributes[ATTR_OPTIONS]) == expected_options + + @pytest.mark.parametrize( ("entity_id", "setting_key", "allowed_value", "value_to_set", "mock_attr"), [ diff --git a/tests/components/home_connect/test_sensor.py b/tests/components/home_connect/test_sensor.py index 04f5e056aa5..a7836223737 100644 --- a/tests/components/home_connect/test_sensor.py +++ b/tests/components/home_connect/test_sensor.py @@ -13,7 +13,7 @@ from aiohomeconnect.model import ( Status, StatusKey, ) -from aiohomeconnect.model.error import HomeConnectApiError +from aiohomeconnect.model.error import HomeConnectApiError, TooManyRequestsError from freezegun.api import FrozenDateTimeFactory import pytest @@ -26,12 +26,13 @@ from homeassistant.components.home_connect.const import ( BSH_EVENT_PRESENT_STATE_PRESENT, DOMAIN, ) +from homeassistant.components.home_connect.coordinator import HomeConnectError from homeassistant.config_entries import ConfigEntryState from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed TEST_HC_APP = "Dishwasher" @@ -724,3 +725,122 @@ async def test_sensor_unit_fetching( ) assert client.get_status_value.call_count == get_status_value_call_count + + +@pytest.mark.parametrize( + ( + "appliance_ha_id", + "entity_id", + "status_key", + ), + [ + ( + "Oven", + "sensor.oven_current_oven_cavity_temperature", + StatusKey.COOKING_OVEN_CURRENT_CAVITY_TEMPERATURE, + ), + ], + indirect=["appliance_ha_id"], +) +async def test_sensor_unit_fetching_error( + appliance_ha_id: str, + entity_id: str, + status_key: StatusKey, + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, +) -> None: + """Test that the sensor entities are capable of fetching units.""" + + async def get_status_mock(ha_id: str) -> ArrayOfStatus: + if ha_id != appliance_ha_id: + return ArrayOfStatus([]) + return ArrayOfStatus( + [ + Status( + key=status_key, + raw_key=status_key.value, + value=0, + ) + ] + ) + + client.get_status = AsyncMock(side_effect=get_status_mock) + client.get_status_value = AsyncMock(side_effect=HomeConnectError()) + + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + assert hass.states.get(entity_id) + + +@pytest.mark.parametrize( + ( + "appliance_ha_id", + "entity_id", + "status_key", + "unit", + ), + [ + ( + "Oven", + "sensor.oven_current_oven_cavity_temperature", + StatusKey.COOKING_OVEN_CURRENT_CAVITY_TEMPERATURE, + "°C", + ), + ], + indirect=["appliance_ha_id"], +) +async def test_sensor_unit_fetching_after_rate_limit_error( + appliance_ha_id: str, + entity_id: str, + status_key: StatusKey, + unit: str, + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, +) -> None: + """Test that the sensor entities are capable of fetching units.""" + + async def get_status_mock(ha_id: str) -> ArrayOfStatus: + if ha_id != appliance_ha_id: + return ArrayOfStatus([]) + return ArrayOfStatus( + [ + Status( + key=status_key, + raw_key=status_key.value, + value=0, + ) + ] + ) + + client.get_status = AsyncMock(side_effect=get_status_mock) + client.get_status_value = AsyncMock( + side_effect=[ + TooManyRequestsError("error.key", retry_after=0), + Status( + key=status_key, + raw_key=status_key.value, + value=0, + unit=unit, + ), + ] + ) + + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert config_entry.state == ConfigEntryState.LOADED + + assert client.get_status_value.call_count == 2 + + entity_state = hass.states.get(entity_id) + assert entity_state + assert entity_state.attributes["unit_of_measurement"] == unit From 88e3dcccdae40d61cecfc1590f5c8b2368f2d12a Mon Sep 17 00:00:00 2001 From: Pete Sage <76050312+PeteRager@users.noreply.github.com> Date: Sun, 16 Mar 2025 09:09:21 -0400 Subject: [PATCH 2825/3148] Album art not available for Sonos media library favorites (#140557) * get album art uri for favorites * add tests * update typing * update typing * update typing * simplify --- homeassistant/components/sonos/favorites.py | 2 +- .../components/sonos/media_browser.py | 16 ++++++++++-- .../sonos/fixtures/sonos_favorites.json | 1 + .../sonos/snapshots/test_media_browser.ambr | 25 +++++++++++++++++++ tests/components/sonos/test_media_browser.py | 4 +++ 5 files changed, 45 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sonos/favorites.py b/homeassistant/components/sonos/favorites.py index 5050555a7cb..333c4809e62 100644 --- a/homeassistant/components/sonos/favorites.py +++ b/homeassistant/components/sonos/favorites.py @@ -105,7 +105,7 @@ class SonosFavorites(SonosHouseholdCoordinator): @soco_error() def update_cache(self, soco: SoCo, update_id: int | None = None) -> bool: """Update cache of known favorites and return if cache has changed.""" - new_favorites = soco.music_library.get_sonos_favorites() + new_favorites = soco.music_library.get_sonos_favorites(full_album_art_uri=True) # Polled update_id values do not match event_id values # Each speaker can return a different polled update_id diff --git a/homeassistant/components/sonos/media_browser.py b/homeassistant/components/sonos/media_browser.py index 995d6cea08c..16b425dae50 100644 --- a/homeassistant/components/sonos/media_browser.py +++ b/homeassistant/components/sonos/media_browser.py @@ -165,6 +165,8 @@ async def async_browse_media( favorites_folder_payload, speaker.favorites, media_content_id, + media, + get_browse_image_url, ) payload = { @@ -443,7 +445,10 @@ def favorites_payload(favorites: SonosFavorites) -> BrowseMedia: def favorites_folder_payload( - favorites: SonosFavorites, media_content_id: str + favorites: SonosFavorites, + media_content_id: str, + media: SonosMedia, + get_browse_image_url: GetBrowseImageUrlType, ) -> BrowseMedia: """Create response payload to describe all items of a type of favorite. @@ -463,7 +468,14 @@ def favorites_folder_payload( media_content_type="favorite_item_id", can_play=True, can_expand=False, - thumbnail=getattr(favorite, "album_art_uri", None), + thumbnail=get_thumbnail_url_full( + media=media, + is_internal=True, + media_content_type="favorite_item_id", + media_content_id=favorite.item_id, + get_browse_image_url=get_browse_image_url, + item=favorite, + ), ) ) diff --git a/tests/components/sonos/fixtures/sonos_favorites.json b/tests/components/sonos/fixtures/sonos_favorites.json index d5463c3d02b..40213ea8715 100644 --- a/tests/components/sonos/fixtures/sonos_favorites.json +++ b/tests/components/sonos/fixtures/sonos_favorites.json @@ -27,6 +27,7 @@ "title": "1984", "parent_id": "FV:2", "item_id": "FV:2/8", + "album_art_uri": "http://192.168.42.2:1400/getaa?u=x-file-cifs%3a%2f%2f192.168.42.2%2fmusic%2fiTunes%2520Music%2fAerosmith%2f1984&v=742", "resource_meta_data": "1984object.container.album.musicAlbumRINCON_AssociatedZPUDN", "resources": [ { diff --git a/tests/components/sonos/snapshots/test_media_browser.ambr b/tests/components/sonos/snapshots/test_media_browser.ambr index 9f6560c0f75..24f08eaf95b 100644 --- a/tests/components/sonos/snapshots/test_media_browser.ambr +++ b/tests/components/sonos/snapshots/test_media_browser.ambr @@ -44,6 +44,31 @@ 'title': 'Favorites', }) # --- +# name: test_browse_media_favorites[object.container.album.musicAlbum-favorites_folder] + dict({ + 'can_expand': True, + 'can_play': False, + 'children': list([ + dict({ + 'can_expand': False, + 'can_play': True, + 'children_media_class': None, + 'media_class': 'album', + 'media_content_id': 'FV:2/8', + 'media_content_type': 'favorite_item_id', + 'thumbnail': 'http://192.168.42.2:1400/getaa?u=x-file-cifs://192.168.42.2/music/iTunes%20Music/Aerosmith/1984&v=742', + 'title': '1984', + }), + ]), + 'children_media_class': 'album', + 'media_class': 'directory', + 'media_content_id': '', + 'media_content_type': 'favorites', + 'not_shown': 0, + 'thumbnail': None, + 'title': 'Albums', + }) +# --- # name: test_browse_media_favorites[object.item.audioItem.audioBook-favorites_folder] dict({ 'can_expand': True, diff --git a/tests/components/sonos/test_media_browser.py b/tests/components/sonos/test_media_browser.py index 323140e285d..ce6e103be58 100644 --- a/tests/components/sonos/test_media_browser.py +++ b/tests/components/sonos/test_media_browser.py @@ -190,6 +190,10 @@ async def test_browse_media_library_albums( "object.item.audioItem.audioBook", "favorites_folder", ), + ( + "object.container.album.musicAlbum", + "favorites_folder", + ), ], ) async def test_browse_media_favorites( From 1382a001e33ee8792ab529a009764d24ade796b2 Mon Sep 17 00:00:00 2001 From: Hessel Date: Fri, 14 Mar 2025 16:13:07 +0100 Subject: [PATCH 2826/3148] Change max ICP value to fixed value for Wallbox Integration (#140592) change max ICP value to fixed value Co-authored-by: Hessel van Es --- homeassistant/components/wallbox/number.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/homeassistant/components/wallbox/number.py b/homeassistant/components/wallbox/number.py index 462266636d7..a5880f6e0f7 100644 --- a/homeassistant/components/wallbox/number.py +++ b/homeassistant/components/wallbox/number.py @@ -71,9 +71,7 @@ NUMBER_TYPES: dict[str, WallboxNumberEntityDescription] = { CHARGER_MAX_ICP_CURRENT_KEY: WallboxNumberEntityDescription( key=CHARGER_MAX_ICP_CURRENT_KEY, translation_key="maximum_icp_current", - max_value_fn=lambda coordinator: cast( - float, coordinator.data[CHARGER_MAX_AVAILABLE_POWER_KEY] - ), + max_value_fn=lambda _: 255, min_value_fn=lambda _: 6, set_value_fn=lambda coordinator: coordinator.async_set_icp_current, native_step=1, From 9d8dbfbf3f90fda0e4f4e05cf600e2972e170350 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Fri, 14 Mar 2025 19:35:13 +0100 Subject: [PATCH 2827/3148] Add 700 RPM option to washer spin speed options at Home Connect (#140607) Add 700 RPM option to washer spin speed options --- homeassistant/components/home_connect/const.py | 1 + homeassistant/components/home_connect/services.yaml | 2 ++ homeassistant/components/home_connect/strings.json | 2 ++ 3 files changed, 5 insertions(+) diff --git a/homeassistant/components/home_connect/const.py b/homeassistant/components/home_connect/const.py index 279aaef7b9c..6255a513e39 100644 --- a/homeassistant/components/home_connect/const.py +++ b/homeassistant/components/home_connect/const.py @@ -285,6 +285,7 @@ SPIN_SPEED_OPTIONS = { "LaundryCare.Washer.EnumType.SpinSpeed.Off", "LaundryCare.Washer.EnumType.SpinSpeed.RPM400", "LaundryCare.Washer.EnumType.SpinSpeed.RPM600", + "LaundryCare.Washer.EnumType.SpinSpeed.RPM700", "LaundryCare.Washer.EnumType.SpinSpeed.RPM800", "LaundryCare.Washer.EnumType.SpinSpeed.RPM900", "LaundryCare.Washer.EnumType.SpinSpeed.RPM1000", diff --git a/homeassistant/components/home_connect/services.yaml b/homeassistant/components/home_connect/services.yaml index 91b0089d653..613b3f5af3a 100644 --- a/homeassistant/components/home_connect/services.yaml +++ b/homeassistant/components/home_connect/services.yaml @@ -559,7 +559,9 @@ set_program_and_options: - laundry_care_washer_enum_type_spin_speed_off - laundry_care_washer_enum_type_spin_speed_r_p_m400 - laundry_care_washer_enum_type_spin_speed_r_p_m600 + - laundry_care_washer_enum_type_spin_speed_r_p_m700 - laundry_care_washer_enum_type_spin_speed_r_p_m800 + - laundry_care_washer_enum_type_spin_speed_r_p_m900 - laundry_care_washer_enum_type_spin_speed_r_p_m1000 - laundry_care_washer_enum_type_spin_speed_r_p_m1200 - laundry_care_washer_enum_type_spin_speed_r_p_m1400 diff --git a/homeassistant/components/home_connect/strings.json b/homeassistant/components/home_connect/strings.json index 8ebf1e0cb1b..6b7ddc310fe 100644 --- a/homeassistant/components/home_connect/strings.json +++ b/homeassistant/components/home_connect/strings.json @@ -460,6 +460,7 @@ "laundry_care_washer_enum_type_spin_speed_off": "Off", "laundry_care_washer_enum_type_spin_speed_r_p_m400": "400 rpm", "laundry_care_washer_enum_type_spin_speed_r_p_m600": "600 rpm", + "laundry_care_washer_enum_type_spin_speed_r_p_m700": "700 rpm", "laundry_care_washer_enum_type_spin_speed_r_p_m800": "800 rpm", "laundry_care_washer_enum_type_spin_speed_r_p_m900": "900 rpm", "laundry_care_washer_enum_type_spin_speed_r_p_m1000": "1000 rpm", @@ -1430,6 +1431,7 @@ "laundry_care_washer_enum_type_spin_speed_off": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_off%]", "laundry_care_washer_enum_type_spin_speed_r_p_m400": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m400%]", "laundry_care_washer_enum_type_spin_speed_r_p_m600": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m600%]", + "laundry_care_washer_enum_type_spin_speed_r_p_m700": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m700%]", "laundry_care_washer_enum_type_spin_speed_r_p_m800": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m800%]", "laundry_care_washer_enum_type_spin_speed_r_p_m900": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m900%]", "laundry_care_washer_enum_type_spin_speed_r_p_m1000": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m1000%]", From 28cad1d085e41c80991b8d2ea50f359d16914d34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Sat, 15 Mar 2025 14:17:16 +0100 Subject: [PATCH 2828/3148] Handle non documented options at Home Connect select entities (#140608) * Allow non documented options at select entities * Don't allow undocumented options --- .../components/home_connect/select.py | 12 ++++++---- tests/components/home_connect/test_select.py | 22 ++++++++++++++++++- 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/home_connect/select.py b/homeassistant/components/home_connect/select.py index 5cfda3585bc..001c2e9ec31 100644 --- a/homeassistant/components/home_connect/select.py +++ b/homeassistant/components/home_connect/select.py @@ -415,6 +415,7 @@ class HomeConnectSelectEntity(HomeConnectEntity, SelectEntity): """Select setting class for Home Connect.""" entity_description: HomeConnectSelectEntityDescription + _original_option_keys: set[str | None] def __init__( self, @@ -423,6 +424,7 @@ class HomeConnectSelectEntity(HomeConnectEntity, SelectEntity): desc: HomeConnectSelectEntityDescription, ) -> None: """Initialize the entity.""" + self._original_option_keys = set(desc.values_translation_key) super().__init__( coordinator, appliance, @@ -477,10 +479,12 @@ class HomeConnectSelectEntity(HomeConnectEntity, SelectEntity): ) if setting and setting.constraints and setting.constraints.allowed_values: + self._original_option_keys = set(setting.constraints.allowed_values) self._attr_options = [ self.entity_description.values_translation_key[option] - for option in setting.constraints.allowed_values - if option in self.entity_description.values_translation_key + for option in self._original_option_keys + if option is not None + and option in self.entity_description.values_translation_key ] @@ -497,7 +501,7 @@ class HomeConnectSelectOptionEntity(HomeConnectOptionEntity, SelectEntity): desc: HomeConnectSelectEntityDescription, ) -> None: """Initialize the entity.""" - self._original_option_keys = set(desc.values_translation_key.keys()) + self._original_option_keys = set(desc.values_translation_key) super().__init__( coordinator, appliance, @@ -530,5 +534,5 @@ class HomeConnectSelectOptionEntity(HomeConnectOptionEntity, SelectEntity): self.entity_description.values_translation_key[option] for option in self._original_option_keys if option is not None + and option in self.entity_description.values_translation_key ] - self.__dict__.pop("options", None) diff --git a/tests/components/home_connect/test_select.py b/tests/components/home_connect/test_select.py index d7ca8a023cd..f20be33081c 100644 --- a/tests/components/home_connect/test_select.py +++ b/tests/components/home_connect/test_select.py @@ -522,9 +522,18 @@ async def test_select_functionality( ( "select.hood_ambient_light_color", SettingKey.BSH_COMMON_AMBIENT_LIGHT_COLOR, - [f"BSH.Common.EnumType.AmbientLightColor.Color{i}" for i in range(50)], + [f"BSH.Common.EnumType.AmbientLightColor.Color{i}" for i in range(1, 50)], {str(i) for i in range(1, 50)}, ), + ( + "select.hood_ambient_light_color", + SettingKey.BSH_COMMON_AMBIENT_LIGHT_COLOR, + [ + "A.Non.Documented.Option", + "BSH.Common.EnumType.AmbientLightColor.Color42", + ], + {"42"}, + ), ], ) async def test_fetch_allowed_values( @@ -813,6 +822,17 @@ async def test_select_entity_error( "laundry_care_washer_enum_type_temperature_ul_extra_hot", }, ), + ( + "select.washer_temperature", + OptionKey.LAUNDRY_CARE_WASHER_TEMPERATURE, + [ + "A.Non.Documented.Option", + "LaundryCare.Washer.EnumType.Temperature.UlWarm", + ], + { + "laundry_care_washer_enum_type_temperature_ul_warm", + }, + ), ], ) async def test_options_functionality( From a2102f9b986a90475734e3e67e4f9a04aff93d33 Mon Sep 17 00:00:00 2001 From: Maikel Punie Date: Tue, 18 Mar 2025 15:49:27 +0100 Subject: [PATCH 2829/3148] Fix optional password in Velbus config flow (#140615) * Fix velbusconfigflow * add tests * Paramtize the tests * Removed duplicate test in favor of another case * more comments --- .../components/velbus/config_flow.py | 2 +- tests/components/velbus/test_config_flow.py | 66 ++++++++----------- 2 files changed, 27 insertions(+), 41 deletions(-) diff --git a/homeassistant/components/velbus/config_flow.py b/homeassistant/components/velbus/config_flow.py index fc5da92588a..7c93d8784ad 100644 --- a/homeassistant/components/velbus/config_flow.py +++ b/homeassistant/components/velbus/config_flow.py @@ -63,7 +63,7 @@ class VelbusConfigFlow(ConfigFlow, domain=DOMAIN): self._device = "tls://" else: self._device = "" - if user_input[CONF_PASSWORD] != "": + if CONF_PASSWORD in user_input and user_input[CONF_PASSWORD] != "": self._device += f"{user_input[CONF_PASSWORD]}@" self._device += f"{user_input[CONF_HOST]}:{user_input[CONF_PORT]}" self._async_abort_entries_match({CONF_PORT: self._device}) diff --git a/tests/components/velbus/test_config_flow.py b/tests/components/velbus/test_config_flow.py index ee714624b45..36d658f9633 100644 --- a/tests/components/velbus/test_config_flow.py +++ b/tests/components/velbus/test_config_flow.py @@ -59,43 +59,30 @@ def mock_controller_connection_failed(): @pytest.mark.usefixtures("controller") -async def test_user_network_succes(hass: HomeAssistant) -> None: - """Test user network config.""" - # inttial menu show - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} - ) - assert result - assert result.get("flow_id") - assert result.get("type") is FlowResultType.MENU - assert result.get("step_id") == "user" - assert result.get("menu_options") == ["network", "usbselect"] - # select the network option - result = await hass.config_entries.flow.async_configure( - result.get("flow_id"), - {"next_step_id": "network"}, - ) - assert result.get("type") is FlowResultType.FORM - # fill in the network form - result = await hass.config_entries.flow.async_configure( - result.get("flow_id"), - { - CONF_TLS: False, - CONF_HOST: "velbus", - CONF_PORT: 6000, - CONF_PASSWORD: "", - }, - ) - assert result - assert result.get("type") is FlowResultType.CREATE_ENTRY - assert result.get("title") == "Velbus Network" - data = result.get("data") - assert data - assert data[CONF_PORT] == "velbus:6000" - - -@pytest.mark.usefixtures("controller") -async def test_user_network_succes_tls(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + ("inputParams", "expected"), + [ + ( + { + CONF_TLS: True, + CONF_PASSWORD: "password", + }, + "tls://password@velbus:6000", + ), + ( + { + CONF_TLS: True, + CONF_PASSWORD: "", + }, + "tls://velbus:6000", + ), + ({CONF_TLS: True}, "tls://velbus:6000"), + ({CONF_TLS: False}, "velbus:6000"), + ], +) +async def test_user_network_succes( + hass: HomeAssistant, inputParams: str, expected: str +) -> None: """Test user network config.""" # inttial menu show result = await hass.config_entries.flow.async_init( @@ -116,10 +103,9 @@ async def test_user_network_succes_tls(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_configure( result.get("flow_id"), { - CONF_TLS: True, CONF_HOST: "velbus", CONF_PORT: 6000, - CONF_PASSWORD: "password", + **inputParams, }, ) assert result @@ -127,7 +113,7 @@ async def test_user_network_succes_tls(hass: HomeAssistant) -> None: assert result.get("title") == "Velbus Network" data = result.get("data") assert data - assert data[CONF_PORT] == "tls://password@velbus:6000" + assert data[CONF_PORT] == expected @pytest.mark.usefixtures("controller") From 85b6b3a3605be7083b06a161f52f00af2417fbbb Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 15 Mar 2025 15:08:21 +0100 Subject: [PATCH 2830/3148] Make Oven setpoint follow temperature UoM in SmartThings (#140666) --- .../components/smartthings/sensor.py | 15 +- tests/components/smartthings/conftest.py | 1 + .../device_status/da_ks_oven_01061.json | 566 ++++++++++++++++++ .../fixtures/devices/da_ks_oven_01061.json | 153 +++++ .../smartthings/snapshots/test_init.ambr | 33 + .../smartthings/snapshots/test_sensor.ambr | 398 ++++++++++++ 6 files changed, 1163 insertions(+), 3 deletions(-) create mode 100644 tests/components/smartthings/fixtures/device_status/da_ks_oven_01061.json create mode 100644 tests/components/smartthings/fixtures/devices/da_ks_oven_01061.json diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index 87e19f2502e..8e7f8efe09c 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -132,6 +132,7 @@ class SmartThingsSensorEntityDescription(SensorEntityDescription): capability_ignore_list: list[set[Capability]] | None = None options_attribute: Attribute | None = None exists_fn: Callable[[Status], bool] | None = None + use_temperature_unit: bool = False CAPABILITY_TO_SENSORS: dict[ @@ -573,7 +574,7 @@ CAPABILITY_TO_SENSORS: dict[ key=Attribute.OVEN_SETPOINT, translation_key="oven_setpoint", device_class=SensorDeviceClass.TEMPERATURE, - native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT, + use_temperature_unit=True, value_fn=lambda value: value if value != 0 else None, ) ] @@ -1018,7 +1019,10 @@ class SmartThingsSensor(SmartThingsEntity, SensorEntity): attribute: Attribute, ) -> None: """Init the class.""" - super().__init__(client, device, {capability}) + capabilities_to_subscribe = {capability} + if entity_description.use_temperature_unit: + capabilities_to_subscribe.add(Capability.TEMPERATURE_MEASUREMENT) + super().__init__(client, device, capabilities_to_subscribe) self._attr_unique_id = f"{device.device.device_id}{entity_description.unique_id_separator}{entity_description.key}" self._attribute = attribute self.capability = capability @@ -1033,7 +1037,12 @@ class SmartThingsSensor(SmartThingsEntity, SensorEntity): @property def native_unit_of_measurement(self) -> str | None: """Return the unit this state is expressed in.""" - unit = self._internal_state[self.capability][self._attribute].unit + if self.entity_description.use_temperature_unit: + unit = self._internal_state[Capability.TEMPERATURE_MEASUREMENT][ + Attribute.TEMPERATURE + ].unit + else: + unit = self._internal_state[self.capability][self._attribute].unit return ( UNITS.get(unit, unit) if unit diff --git a/tests/components/smartthings/conftest.py b/tests/components/smartthings/conftest.py index 9f17e61d652..ac253da0590 100644 --- a/tests/components/smartthings/conftest.py +++ b/tests/components/smartthings/conftest.py @@ -110,6 +110,7 @@ def mock_smartthings() -> Generator[AsyncMock]: "da_rvc_normal_000001", "da_ks_microwave_0101x", "da_ks_range_0101x", + "da_ks_oven_01061", "hue_color_temperature_bulb", "hue_rgbw_color_bulb", "c2c_shade", diff --git a/tests/components/smartthings/fixtures/device_status/da_ks_oven_01061.json b/tests/components/smartthings/fixtures/device_status/da_ks_oven_01061.json new file mode 100644 index 00000000000..b8b403ba908 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/da_ks_oven_01061.json @@ -0,0 +1,566 @@ +{ + "components": { + "main": { + "ovenSetpoint": { + "ovenSetpointRange": { + "value": null + }, + "ovenSetpoint": { + "value": 220, + "timestamp": "2025-03-15T12:06:07.818Z" + } + }, + "refresh": {}, + "samsungce.doorState": { + "doorState": { + "value": "closed", + "timestamp": "2025-03-15T09:25:35.157Z" + } + }, + "samsungce.microwavePower": { + "supportedPowerLevels": { + "value": null + }, + "powerLevel": { + "value": "0W", + "timestamp": "2025-03-15T12:06:07.803Z" + } + }, + "samsungce.waterReservoir": { + "slotState": { + "value": null + } + }, + "samsungce.kitchenDeviceDefaults": { + "defaultOperationTime": { + "value": null + }, + "defaultOvenMode": { + "value": "Convection", + "timestamp": "2025-03-15T12:06:07.758Z" + }, + "defaultOvenSetpoint": { + "value": null + } + }, + "execute": { + "data": { + "value": null + } + }, + "samsungce.deviceIdentification": { + "micomAssayCode": { + "value": null + }, + "modelName": { + "value": null + }, + "serialNumber": { + "value": null + }, + "serialNumberExtra": { + "value": null + }, + "modelClassificationCode": { + "value": null + }, + "description": { + "value": null + }, + "releaseYear": { + "value": null + }, + "binaryId": { + "value": "TP1X_DA-KS-OVEN-01061", + "timestamp": "2025-03-13T20:35:02.073Z" + } + }, + "samsungce.ovenDrainageRequirement": { + "drainageRequirement": { + "value": null + } + }, + "ocf": { + "st": { + "value": null + }, + "mndt": { + "value": null + }, + "mnfv": { + "value": "AKS-WW-TP1X-21-OVEN_40211229", + "timestamp": "2025-01-08T17:29:14.260Z" + }, + "mnhw": { + "value": "Realtek", + "timestamp": "2025-01-08T17:29:14.260Z" + }, + "di": { + "value": "9447959a-0dfa-6b27-d40d-650da525c53f", + "timestamp": "2025-01-08T17:29:14.260Z" + }, + "mnsl": { + "value": "http://www.samsung.com", + "timestamp": "2025-01-08T17:29:14.260Z" + }, + "dmv": { + "value": "res.1.1.0,sh.1.1.0", + "timestamp": "2025-01-08T17:29:14.260Z" + }, + "n": { + "value": "[oven] Samsung", + "timestamp": "2025-01-08T17:29:14.260Z" + }, + "mnmo": { + "value": "TP1X_DA-KS-OVEN-01061|40457041|50030018001611000A00000000000000", + "timestamp": "2025-01-08T17:29:14.260Z" + }, + "vid": { + "value": "DA-KS-OVEN-01061", + "timestamp": "2025-01-08T17:29:14.260Z" + }, + "mnmn": { + "value": "Samsung Electronics", + "timestamp": "2025-01-08T17:29:14.260Z" + }, + "mnml": { + "value": "http://www.samsung.com", + "timestamp": "2025-01-08T17:29:14.260Z" + }, + "mnpv": { + "value": "DAWIT 3.0", + "timestamp": "2025-01-08T17:29:14.260Z" + }, + "mnos": { + "value": "TizenRT 3.1", + "timestamp": "2025-01-08T17:29:14.260Z" + }, + "pi": { + "value": "9447959a-0dfa-6b27-d40d-650da525c53f", + "timestamp": "2025-01-08T17:29:14.260Z" + }, + "icv": { + "value": "core.1.1.0", + "timestamp": "2025-01-08T17:29:14.260Z" + } + }, + "remoteControlStatus": { + "remoteControlEnabled": { + "value": "true", + "timestamp": "2025-03-15T09:47:55.406Z" + } + }, + "samsungce.kitchenDeviceIdentification": { + "regionCode": { + "value": "EU", + "timestamp": "2025-03-15T12:06:07.758Z" + }, + "modelCode": { + "value": "NQ7000B-/EU7", + "timestamp": "2025-03-15T12:06:07.758Z" + }, + "fuel": { + "value": null + }, + "type": { + "value": "oven", + "timestamp": "2025-01-08T17:29:12.924Z" + }, + "representativeComponent": { + "value": null + } + }, + "samsungce.kitchenModeSpecification": { + "specification": { + "value": { + "single": [ + { + "mode": "NoOperation", + "supportedOperations": [], + "supportedOptions": {} + }, + { + "mode": "Autocook", + "supportedOperations": [], + "supportedOptions": {} + }, + { + "mode": "Convection", + "supportedOperations": ["start", "set"], + "supportedOptions": { + "temperature": { + "C": { + "min": 40, + "max": 230, + "default": 160, + "resolution": 5 + } + }, + "operationTime": { + "min": "00:01:00", + "max": "10:00:00", + "default": "01:00:00", + "resolution": "00:01:00" + } + } + }, + { + "mode": "FanConventional", + "supportedOperations": ["start", "set"], + "supportedOptions": { + "temperature": { + "C": { + "min": 40, + "max": 230, + "default": 180, + "resolution": 5 + } + }, + "operationTime": { + "min": "00:01:00", + "max": "10:00:00", + "default": "01:00:00", + "resolution": "00:01:00" + } + } + }, + { + "mode": "LargeGrill", + "supportedOperations": ["start", "set"], + "supportedOptions": { + "temperature": { + "C": { + "min": 150, + "max": 230, + "default": 220, + "resolution": 5 + } + }, + "operationTime": { + "min": "00:01:00", + "max": "10:00:00", + "default": "01:00:00", + "resolution": "00:01:00" + } + } + }, + { + "mode": "FanGrill", + "supportedOperations": ["start", "set"], + "supportedOptions": { + "temperature": { + "C": { + "min": 40, + "max": 230, + "default": 180, + "resolution": 5 + } + }, + "operationTime": { + "min": "00:01:00", + "max": "10:00:00", + "default": "01:00:00", + "resolution": "00:01:00" + } + } + }, + { + "mode": "MicroWaveGrill", + "supportedOperations": ["set"], + "supportedOptions": { + "temperature": { + "C": { + "min": 40, + "max": 200, + "default": 200, + "resolution": 5 + } + }, + "operationTime": { + "min": "00:00:10", + "max": "01:30:00", + "default": "00:00:30", + "resolution": "00:00:10" + }, + "powerLevel": { + "default": "300W", + "supportedValues": ["100W", "180W", "300W", "450W", "600W"] + } + } + }, + { + "mode": "MicroWaveConvection", + "supportedOperations": ["set"], + "supportedOptions": { + "temperature": { + "C": { + "min": 40, + "max": 200, + "default": 180, + "resolution": 5 + } + }, + "operationTime": { + "min": "00:00:10", + "max": "01:30:00", + "default": "00:00:30", + "resolution": "00:00:10" + }, + "powerLevel": { + "default": "300W", + "supportedValues": ["100W", "180W", "300W", "450W", "600W"] + } + } + }, + { + "mode": "AirFryer", + "supportedOperations": ["start", "set"], + "supportedOptions": { + "temperature": { + "C": { + "min": 150, + "max": 230, + "default": 220, + "resolution": 5 + } + }, + "operationTime": { + "min": "00:01:00", + "max": "10:00:00", + "default": "01:00:00", + "resolution": "00:01:00" + } + } + }, + { + "mode": "MicroWave", + "supportedOperations": ["set"], + "supportedOptions": { + "operationTime": { + "min": "00:00:10", + "max": "01:30:00", + "default": "00:00:30", + "resolution": "00:00:10" + }, + "powerLevel": { + "default": "800W", + "supportedValues": [ + "100W", + "180W", + "300W", + "450W", + "600W", + "700W", + "800W" + ] + } + } + }, + { + "mode": "Deodorization", + "supportedOperations": ["start", "set"], + "supportedOptions": { + "operationTime": { + "min": "00:00:10", + "max": "00:15:00", + "default": "00:05:00", + "resolution": "00:00:10" + } + } + }, + { + "mode": "KeepWarm", + "supportedOperations": ["start", "set"], + "supportedOptions": { + "temperature": { + "C": { + "min": 60, + "max": 100, + "default": 60, + "resolution": 5 + } + }, + "operationTime": { + "min": "00:01:00", + "max": "10:00:00", + "default": "01:00:00", + "resolution": "00:01:00" + } + } + }, + { + "mode": "SteamClean", + "supportedOperations": ["set"], + "supportedOptions": {} + } + ] + }, + "timestamp": "2025-01-08T17:29:14.757Z" + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": [ + "samsungce.waterReservoir", + "samsungce.ovenDrainageRequirement" + ], + "timestamp": "2025-03-15T12:06:07.758Z" + } + }, + "samsungce.definedRecipe": { + "definedRecipe": { + "value": { + "cavityId": "0", + "recipeType": "0", + "categoryId": 0, + "itemId": 0, + "servingSize": 0, + "browingLevel": 0, + "option": 0 + }, + "timestamp": "2025-03-15T12:06:07.803Z" + } + }, + "samsungce.driverVersion": { + "versionNumber": { + "value": 22100101, + "timestamp": "2025-01-08T17:29:12.924Z" + } + }, + "samsungce.softwareUpdate": { + "targetModule": { + "value": null + }, + "otnDUID": { + "value": "43CB2ZD4VUEGW", + "timestamp": "2025-03-13T20:35:02.073Z" + }, + "lastUpdatedDate": { + "value": null + }, + "availableModules": { + "value": [], + "timestamp": "2025-03-13T20:35:02.073Z" + }, + "newVersionAvailable": { + "value": false, + "timestamp": "2025-03-13T20:35:02.073Z" + }, + "operatingState": { + "value": null + }, + "progress": { + "value": null + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": 30, + "unit": "C", + "timestamp": "2025-03-15T12:06:32.918Z" + } + }, + "samsungce.ovenOperatingState": { + "completionTime": { + "value": "2025-03-15T12:06:09.550Z", + "timestamp": "2025-03-15T12:06:09.554Z" + }, + "operatingState": { + "value": "running", + "timestamp": "2025-03-15T12:06:07.866Z" + }, + "progress": { + "value": 0, + "timestamp": "2025-03-15T12:06:07.866Z" + }, + "ovenJobState": { + "value": "preheat", + "timestamp": "2025-03-15T12:06:07.803Z" + }, + "operationTime": { + "value": "00:00:00", + "timestamp": "2025-03-15T12:06:07.866Z" + } + }, + "ovenMode": { + "supportedOvenModes": { + "value": ["Others", "Bake", "Broil", "ConvectionBroil", "warming"], + "timestamp": "2025-01-08T17:29:14.757Z" + }, + "ovenMode": { + "value": "Bake", + "timestamp": "2025-03-15T12:06:07.758Z" + } + }, + "ovenOperatingState": { + "completionTime": { + "value": "2025-03-15T12:06:09.550Z", + "timestamp": "2025-03-15T12:06:09.554Z" + }, + "machineState": { + "value": "running", + "timestamp": "2025-03-15T12:06:07.866Z" + }, + "progress": { + "value": 0, + "unit": "%", + "timestamp": "2025-03-15T12:06:07.866Z" + }, + "supportedMachineStates": { + "value": null + }, + "ovenJobState": { + "value": "preheat", + "timestamp": "2025-03-15T12:06:07.803Z" + }, + "operationTime": { + "value": 0, + "timestamp": "2025-03-15T12:06:07.866Z" + } + }, + "samsungce.ovenMode": { + "supportedOvenModes": { + "value": [ + "NoOperation", + "Autocook", + "Convection", + "FanConventional", + "LargeGrill", + "FanGrill", + "MicroWaveGrill", + "MicroWaveConvection", + "AirFryer", + "MicroWave", + "Deodorization", + "KeepWarm", + "SteamClean" + ], + "timestamp": "2025-01-08T17:29:14.757Z" + }, + "ovenMode": { + "value": "Convection", + "timestamp": "2025-03-15T12:06:07.758Z" + } + }, + "samsungce.lamp": { + "brightnessLevel": { + "value": "high", + "timestamp": "2025-03-15T12:06:07.956Z" + }, + "supportedBrightnessLevel": { + "value": ["off", "high"], + "timestamp": "2025-03-15T12:06:07.758Z" + } + }, + "samsungce.kidsLock": { + "lockState": { + "value": "unlocked", + "timestamp": "2025-03-13T20:35:02.170Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/devices/da_ks_oven_01061.json b/tests/components/smartthings/fixtures/devices/da_ks_oven_01061.json new file mode 100644 index 00000000000..e82e28d2275 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/da_ks_oven_01061.json @@ -0,0 +1,153 @@ +{ + "items": [ + { + "deviceId": "9447959a-0dfa-6b27-d40d-650da525c53f", + "name": "[oven] Samsung", + "label": "Oven", + "manufacturerName": "Samsung Electronics", + "presentationId": "DA-KS-OVEN-01061", + "deviceManufacturerCode": "Samsung Electronics", + "locationId": "a81dc8da-5a3f-43b6-8c8a-1309f37eeeb9", + "ownerId": "97ee2149-9de0-3287-8245-24d6fd1609aa", + "roomId": "eb2167dd-8b8d-4131-b59e-5dd391b2e151", + "deviceTypeName": "Samsung OCF Oven", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "ocf", + "version": 1 + }, + { + "id": "execute", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "remoteControlStatus", + "version": 1 + }, + { + "id": "ovenSetpoint", + "version": 1 + }, + { + "id": "ovenMode", + "version": 1 + }, + { + "id": "ovenOperatingState", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "samsungce.deviceIdentification", + "version": 1 + }, + { + "id": "samsungce.doorState", + "version": 1 + }, + { + "id": "samsungce.definedRecipe", + "version": 1 + }, + { + "id": "samsungce.kitchenDeviceIdentification", + "version": 1 + }, + { + "id": "samsungce.kitchenDeviceDefaults", + "version": 1 + }, + { + "id": "samsungce.driverVersion", + "version": 1 + }, + { + "id": "samsungce.ovenMode", + "version": 1 + }, + { + "id": "samsungce.ovenOperatingState", + "version": 1 + }, + { + "id": "samsungce.microwavePower", + "version": 1 + }, + { + "id": "samsungce.lamp", + "version": 1 + }, + { + "id": "samsungce.kitchenModeSpecification", + "version": 1 + }, + { + "id": "samsungce.kidsLock", + "version": 1 + }, + { + "id": "samsungce.softwareUpdate", + "version": 1 + }, + { + "id": "samsungce.waterReservoir", + "version": 1 + }, + { + "id": "samsungce.ovenDrainageRequirement", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + } + ], + "categories": [ + { + "name": "Oven", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2025-01-08T17:29:12.549Z", + "profile": { + "id": "eb34598f-f96a-3420-a90a-71693052eaa3" + }, + "ocf": { + "ocfDeviceType": "oic.d.oven", + "name": "[oven] Samsung", + "specVersion": "core.1.1.0", + "verticalDomainSpecVersion": "res.1.1.0,sh.1.1.0", + "manufacturerName": "Samsung Electronics", + "modelNumber": "TP1X_DA-KS-OVEN-01061|40457041|50030018001611000A00000000000000", + "platformVersion": "DAWIT 3.0", + "platformOS": "TizenRT 3.1", + "hwVersion": "Realtek", + "firmwareVersion": "AKS-WW-TP1X-21-OVEN_40211229", + "vendorId": "DA-KS-OVEN-01061", + "vendorResourceClientServerVersion": "Realtek Release 3.1.211122", + "lastSignupTime": "2025-01-08T17:29:08.536664213Z", + "transferCandidate": false, + "additionalAuthCodeRequired": false + }, + "type": "OCF", + "restrictionTier": 0, + "allowed": null, + "executionContext": "CLOUD", + "relationships": [] + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/snapshots/test_init.ambr b/tests/components/smartthings/snapshots/test_init.ambr index ab71164ddef..0a0453f67f6 100644 --- a/tests/components/smartthings/snapshots/test_init.ambr +++ b/tests/components/smartthings/snapshots/test_init.ambr @@ -398,6 +398,39 @@ 'via_device_id': None, }) # --- +# name: test_devices[da_ks_oven_01061] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': 'Realtek', + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '9447959a-0dfa-6b27-d40d-650da525c53f', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Samsung Electronics', + 'model': 'TP1X_DA-KS-OVEN-01061', + 'model_id': None, + 'name': 'Oven', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': 'AKS-WW-TP1X-21-OVEN_40211229', + 'via_device_id': None, + }) +# --- # name: test_devices[da_ks_range_0101x] DeviceRegistryEntrySnapshot({ 'area_id': None, diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index 98e619596fd..b6d7bd80333 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -2085,6 +2085,404 @@ 'state': '-17', }) # --- +# name: test_all_entities[da_ks_oven_01061][sensor.oven_completion_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.oven_completion_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Completion time', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'completion_time', + 'unique_id': '9447959a-0dfa-6b27-d40d-650da525c53f.completionTime', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_oven_01061][sensor.oven_completion_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Oven Completion time', + }), + 'context': , + 'entity_id': 'sensor.oven_completion_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2025-03-15T12:06:09+00:00', + }) +# --- +# name: test_all_entities[da_ks_oven_01061][sensor.oven_job_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'cleaning', + 'cooking', + 'cooling', + 'draining', + 'preheat', + 'ready', + 'rinsing', + 'finished', + 'scheduled_start', + 'warming', + 'defrosting', + 'sensing', + 'searing', + 'fast_preheat', + 'scheduled_end', + 'stone_heating', + 'time_hold_preheat', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.oven_job_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Job state', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'oven_job_state', + 'unique_id': '9447959a-0dfa-6b27-d40d-650da525c53f.ovenJobState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_oven_01061][sensor.oven_job_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Oven Job state', + 'options': list([ + 'cleaning', + 'cooking', + 'cooling', + 'draining', + 'preheat', + 'ready', + 'rinsing', + 'finished', + 'scheduled_start', + 'warming', + 'defrosting', + 'sensing', + 'searing', + 'fast_preheat', + 'scheduled_end', + 'stone_heating', + 'time_hold_preheat', + ]), + }), + 'context': , + 'entity_id': 'sensor.oven_job_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'preheat', + }) +# --- +# name: test_all_entities[da_ks_oven_01061][sensor.oven_machine_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'ready', + 'running', + 'paused', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.oven_machine_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Machine state', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'oven_machine_state', + 'unique_id': '9447959a-0dfa-6b27-d40d-650da525c53f.machineState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_oven_01061][sensor.oven_machine_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Oven Machine state', + 'options': list([ + 'ready', + 'running', + 'paused', + ]), + }), + 'context': , + 'entity_id': 'sensor.oven_machine_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'running', + }) +# --- +# name: test_all_entities[da_ks_oven_01061][sensor.oven_oven_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'conventional', + 'bake', + 'bottom_heat', + 'convection_bake', + 'convection_roast', + 'broil', + 'convection_broil', + 'steam_cook', + 'steam_bake', + 'steam_roast', + 'steam_bottom_heat_plus_convection', + 'microwave', + 'microwave_plus_grill', + 'microwave_plus_convection', + 'microwave_plus_hot_blast', + 'microwave_plus_hot_blast_2', + 'slim_middle', + 'slim_strong', + 'slow_cook', + 'proof', + 'dehydrate', + 'others', + 'strong_steam', + 'descale', + 'rinse', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.oven_oven_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Oven mode', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'oven_mode', + 'unique_id': '9447959a-0dfa-6b27-d40d-650da525c53f.ovenMode', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_oven_01061][sensor.oven_oven_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Oven Oven mode', + 'options': list([ + 'conventional', + 'bake', + 'bottom_heat', + 'convection_bake', + 'convection_roast', + 'broil', + 'convection_broil', + 'steam_cook', + 'steam_bake', + 'steam_roast', + 'steam_bottom_heat_plus_convection', + 'microwave', + 'microwave_plus_grill', + 'microwave_plus_convection', + 'microwave_plus_hot_blast', + 'microwave_plus_hot_blast_2', + 'slim_middle', + 'slim_strong', + 'slow_cook', + 'proof', + 'dehydrate', + 'others', + 'strong_steam', + 'descale', + 'rinse', + ]), + }), + 'context': , + 'entity_id': 'sensor.oven_oven_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'bake', + }) +# --- +# name: test_all_entities[da_ks_oven_01061][sensor.oven_set_point-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.oven_set_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': 'Set point', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'oven_setpoint', + 'unique_id': '9447959a-0dfa-6b27-d40d-650da525c53f.ovenSetpoint', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ks_oven_01061][sensor.oven_set_point-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Oven Set point', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.oven_set_point', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '220', + }) +# --- +# name: test_all_entities[da_ks_oven_01061][sensor.oven_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.oven_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': 'Temperature', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '9447959a-0dfa-6b27-d40d-650da525c53f.temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ks_oven_01061][sensor.oven_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Oven Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.oven_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '30', + }) +# --- # name: test_all_entities[da_ks_range_0101x][sensor.vulcan_completion_time-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From c9ceade10dae5a8d9215db66c7941ee51b1ef0cc Mon Sep 17 00:00:00 2001 From: Glenn Waters Date: Sat, 15 Mar 2025 17:07:45 -0400 Subject: [PATCH 2831/3148] Fix Elk-M1 missing TLS 1.2 check (#140672) * Fix for missing TLS 1.2 check * Fix error message. * combine startswith --------- Co-authored-by: J. Nick Koston --- homeassistant/components/elkm1/__init__.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/elkm1/__init__.py b/homeassistant/components/elkm1/__init__.py index 5286b7ad66f..4bf51b99de1 100644 --- a/homeassistant/components/elkm1/__init__.py +++ b/homeassistant/components/elkm1/__init__.py @@ -101,9 +101,11 @@ def hostname_from_url(url: str) -> str: def _host_validator(config: dict[str, str]) -> dict[str, str]: """Validate that a host is properly configured.""" - if config[CONF_HOST].startswith("elks://"): + if config[CONF_HOST].startswith(("elks://", "elksv1_2://")): if CONF_USERNAME not in config or CONF_PASSWORD not in config: - raise vol.Invalid("Specify username and password for elks://") + raise vol.Invalid( + "Specify username and password for elks:// or elksv1_2://" + ) elif not config[CONF_HOST].startswith("elk://") and not config[ CONF_HOST ].startswith("serial://"): From 66fd7d9e8a0626161bd996037600a48e31471d17 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 15 Mar 2025 10:06:00 -1000 Subject: [PATCH 2832/3148] Bump PySwitchBot to 0.57.1 (#140681) changelog: https://github.com/sblibs/pySwitchbot/compare/0.56.1...0.57.1 fixes #140405 --- homeassistant/components/switchbot/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/manifest.json b/homeassistant/components/switchbot/manifest.json index 567a33a8f43..85d5bcf6436 100644 --- a/homeassistant/components/switchbot/manifest.json +++ b/homeassistant/components/switchbot/manifest.json @@ -39,5 +39,5 @@ "documentation": "https://www.home-assistant.io/integrations/switchbot", "iot_class": "local_push", "loggers": ["switchbot"], - "requirements": ["PySwitchbot==0.56.1"] + "requirements": ["PySwitchbot==0.57.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 9f41ec2fded..404a1307946 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -84,7 +84,7 @@ PyQRCode==1.2.1 PyRMVtransport==0.3.3 # homeassistant.components.switchbot -PySwitchbot==0.56.1 +PySwitchbot==0.57.1 # homeassistant.components.switchmate PySwitchmate==0.5.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3bfae75e1fb..ef2b221ce34 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -81,7 +81,7 @@ PyQRCode==1.2.1 PyRMVtransport==0.3.3 # homeassistant.components.switchbot -PySwitchbot==0.56.1 +PySwitchbot==0.57.1 # homeassistant.components.syncthru PySyncThru==0.8.0 From 403fe36489a243bdd633ce0cb5e693e1775413ff Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 15 Mar 2025 23:09:55 +0100 Subject: [PATCH 2833/3148] Check Celsius in SmartThings oven setpoint (#140687) --- homeassistant/components/smartthings/sensor.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index 8e7f8efe09c..08c9cb86c90 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -575,7 +575,8 @@ CAPABILITY_TO_SENSORS: dict[ translation_key="oven_setpoint", device_class=SensorDeviceClass.TEMPERATURE, use_temperature_unit=True, - value_fn=lambda value: value if value != 0 else None, + # Set the value to None if it is 0 F (-17 C) + value_fn=lambda value: None if value in {0, -17} else value, ) ] }, From 3f493dce06a7213b628a2f3543364a98164b1c36 Mon Sep 17 00:00:00 2001 From: Adam Feldman Date: Tue, 18 Mar 2025 03:24:05 -0500 Subject: [PATCH 2834/3148] Fix broken core integration Smart Meter Texas by switching it to use HA's SSL Context (#140694) * Update __init__.py to use HA's SSLContext * Update config_flow.py to use HA's SSLContext * Use default context for config_flow.py * Use default context instead in __init__.py Co-authored-by: Josef Zweck * Fix import in __init__.py * Fix import in config_flow.py --------- Co-authored-by: Josef Zweck --- homeassistant/components/smart_meter_texas/__init__.py | 6 +++--- homeassistant/components/smart_meter_texas/config_flow.py | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/smart_meter_texas/__init__.py b/homeassistant/components/smart_meter_texas/__init__.py index 1cd7df68e91..ce87b85c322 100644 --- a/homeassistant/components/smart_meter_texas/__init__.py +++ b/homeassistant/components/smart_meter_texas/__init__.py @@ -3,7 +3,7 @@ import logging import ssl -from smart_meter_texas import Account, Client, ClientSSLContext +from smart_meter_texas import Account, Client from smart_meter_texas.exceptions import ( SmartMeterTexasAPIError, SmartMeterTexasAuthError, @@ -16,6 +16,7 @@ from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import aiohttp_client from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.util.ssl import get_default_context from .const import ( DATA_COORDINATOR, @@ -38,8 +39,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: account = Account(username, password) - client_ssl_context = ClientSSLContext() - ssl_context = await client_ssl_context.get_ssl_context() + ssl_context = get_default_context() smart_meter_texas_data = SmartMeterTexasData(hass, entry, account, ssl_context) try: diff --git a/homeassistant/components/smart_meter_texas/config_flow.py b/homeassistant/components/smart_meter_texas/config_flow.py index b60855b62c8..18a3716e1b9 100644 --- a/homeassistant/components/smart_meter_texas/config_flow.py +++ b/homeassistant/components/smart_meter_texas/config_flow.py @@ -4,7 +4,7 @@ import logging from typing import Any from aiohttp import ClientError -from smart_meter_texas import Account, Client, ClientSSLContext +from smart_meter_texas import Account, Client from smart_meter_texas.exceptions import ( SmartMeterTexasAPIError, SmartMeterTexasAuthError, @@ -16,6 +16,7 @@ from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import aiohttp_client +from homeassistant.util.ssl import get_default_context from .const import DOMAIN @@ -31,8 +32,7 @@ async def validate_input(hass: HomeAssistant, data): Data has the keys from DATA_SCHEMA with values provided by the user. """ - client_ssl_context = ClientSSLContext() - ssl_context = await client_ssl_context.get_ssl_context() + ssl_context = get_default_context() client_session = aiohttp_client.async_get_clientsession(hass) account = Account(data["username"], data["password"]) client = Client(client_session, account, ssl_context) From 1385bcdb90e7abde0edd7e745661cbecc9feceed Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 21 Mar 2025 20:19:45 +0100 Subject: [PATCH 2835/3148] Grade SmartThings on the integration quality scale (#141078) --- .../components/smartthings/manifest.json | 1 + .../components/smartthings/quality_scale.yaml | 80 +++++++++++++++++++ script/hassfest/quality_scale.py | 2 - 3 files changed, 81 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/smartthings/quality_scale.yaml diff --git a/homeassistant/components/smartthings/manifest.json b/homeassistant/components/smartthings/manifest.json index a456a6bef2f..d7133ce7c6d 100644 --- a/homeassistant/components/smartthings/manifest.json +++ b/homeassistant/components/smartthings/manifest.json @@ -29,5 +29,6 @@ "documentation": "https://www.home-assistant.io/integrations/smartthings", "iot_class": "cloud_push", "loggers": ["pysmartthings"], + "quality_scale": "bronze", "requirements": ["pysmartthings==2.7.4"] } diff --git a/homeassistant/components/smartthings/quality_scale.yaml b/homeassistant/components/smartthings/quality_scale.yaml new file mode 100644 index 00000000000..8a902094687 --- /dev/null +++ b/homeassistant/components/smartthings/quality_scale.yaml @@ -0,0 +1,80 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + This integration does not provide additional actions. + appropriate-polling: + status: exempt + comment: | + This integration works via push. + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: | + This integration does not provide additional actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: done + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: todo + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: No options to configure + docs-installation-parameters: + status: exempt + comment: No parameters needed during installation + entity-unavailable: todo + integration-owner: done + log-when-unavailable: todo + parallel-updates: todo + reauthentication-flow: done + test-coverage: todo + # Gold + devices: done + diagnostics: done + discovery-update-info: + status: exempt + comment: | + This integration connects via the cloud. + discovery: done + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: done + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: todo + entity-category: done + entity-device-class: done + entity-disabled-by-default: + status: exempt + comment: | + This integration does not have any entities that are disabled by default. + entity-translations: done + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: | + This integration doesn't have any cases where raising an issue is needed. + stale-devices: todo + # Platinum + async-dependency: done + inject-websession: done + strict-typing: todo diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index 3fedebe89f4..d74011801d5 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -920,7 +920,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "sma", "smappee", "smart_meter_texas", - "smartthings", "smarttub", "smarty", "smhi", @@ -1996,7 +1995,6 @@ INTEGRATIONS_WITHOUT_SCALE = [ "sma", "smappee", "smart_meter_texas", - "smartthings", "smarttub", "smarty", "smhi", From a453e9d4c28058511f0a2b4f464e67e0c5a2614a Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Sun, 16 Mar 2025 14:51:53 +0100 Subject: [PATCH 2836/3148] Don't reload onedrive on options flow (#140712) --- homeassistant/components/onedrive/__init__.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/homeassistant/components/onedrive/__init__.py b/homeassistant/components/onedrive/__init__.py index f10b8fe0d91..eea18bb2f7e 100644 --- a/homeassistant/components/onedrive/__init__.py +++ b/homeassistant/components/onedrive/__init__.py @@ -97,11 +97,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: OneDriveConfigEntry) -> await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - async def update_listener(hass: HomeAssistant, entry: OneDriveConfigEntry) -> None: - await hass.config_entries.async_reload(entry.entry_id) - - entry.async_on_unload(entry.add_update_listener(update_listener)) - def async_notify_backup_listeners() -> None: for listener in hass.data.get(DATA_BACKUP_AGENT_LISTENERS, []): listener() From 21ced23c3cd337d0a6cbbff02f274eb43123a7be Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 20 Mar 2025 11:02:51 +0100 Subject: [PATCH 2837/3148] Bump pySmartThings to 2.7.4 (#140720) * Bump pySmartThings to 2.7.3 * Bump pySmartThings to 2.7.3 * Fix * Fix * Fix --- .../components/smartthings/diagnostics.py | 2 +- .../components/smartthings/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../snapshots/test_diagnostics.ambr | 588 +++++++++--------- .../smartthings/test_diagnostics.py | 6 +- 6 files changed, 303 insertions(+), 299 deletions(-) diff --git a/homeassistant/components/smartthings/diagnostics.py b/homeassistant/components/smartthings/diagnostics.py index dbc5d4e8224..04517112802 100644 --- a/homeassistant/components/smartthings/diagnostics.py +++ b/homeassistant/components/smartthings/diagnostics.py @@ -23,7 +23,7 @@ async def async_get_config_entry_diagnostics( ) -> dict[str, Any]: """Return diagnostics for a config entry.""" client = entry.runtime_data.client - return await client.get_raw_devices() + return {"devices": await client.get_raw_devices()} async def async_get_device_diagnostics( diff --git a/homeassistant/components/smartthings/manifest.json b/homeassistant/components/smartthings/manifest.json index 74f0e4bae83..a456a6bef2f 100644 --- a/homeassistant/components/smartthings/manifest.json +++ b/homeassistant/components/smartthings/manifest.json @@ -29,5 +29,5 @@ "documentation": "https://www.home-assistant.io/integrations/smartthings", "iot_class": "cloud_push", "loggers": ["pysmartthings"], - "requirements": ["pysmartthings==2.7.2"] + "requirements": ["pysmartthings==2.7.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index 404a1307946..b0af31315f2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2310,7 +2310,7 @@ pysma==0.7.5 pysmappee==0.2.29 # homeassistant.components.smartthings -pysmartthings==2.7.2 +pysmartthings==2.7.4 # homeassistant.components.smarty pysmarty2==0.10.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ef2b221ce34..24cdaecc431 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1882,7 +1882,7 @@ pysma==0.7.5 pysmappee==0.2.29 # homeassistant.components.smartthings -pysmartthings==2.7.2 +pysmartthings==2.7.4 # homeassistant.components.smarty pysmarty2==0.10.2 diff --git a/tests/components/smartthings/snapshots/test_diagnostics.ambr b/tests/components/smartthings/snapshots/test_diagnostics.ambr index 489b79bc904..268cddd5b28 100644 --- a/tests/components/smartthings/snapshots/test_diagnostics.ambr +++ b/tests/components/smartthings/snapshots/test_diagnostics.ambr @@ -1,307 +1,311 @@ # serializer version: 1 # name: test_config_entry_diagnostics[da_ac_rac_000001] dict({ - '_links': dict({ - }), - 'items': list([ + 'devices': list([ dict({ - 'allowed': list([ - ]), - 'components': list([ + '_links': dict({ + }), + 'items': list([ dict({ - 'capabilities': list([ + 'allowed': list([ + ]), + 'components': list([ dict({ - 'id': 'ocf', - 'version': 1, + 'capabilities': list([ + dict({ + 'id': 'ocf', + 'version': 1, + }), + dict({ + 'id': 'switch', + 'version': 1, + }), + dict({ + 'id': 'airConditionerMode', + 'version': 1, + }), + dict({ + 'id': 'airConditionerFanMode', + 'version': 1, + }), + dict({ + 'id': 'fanOscillationMode', + 'version': 1, + }), + dict({ + 'id': 'airQualitySensor', + 'version': 1, + }), + dict({ + 'id': 'temperatureMeasurement', + 'version': 1, + }), + dict({ + 'id': 'thermostatCoolingSetpoint', + 'version': 1, + }), + dict({ + 'id': 'relativeHumidityMeasurement', + 'version': 1, + }), + dict({ + 'id': 'dustSensor', + 'version': 1, + }), + dict({ + 'id': 'veryFineDustSensor', + 'version': 1, + }), + dict({ + 'id': 'audioVolume', + 'version': 1, + }), + dict({ + 'id': 'remoteControlStatus', + 'version': 1, + }), + dict({ + 'id': 'powerConsumptionReport', + 'version': 1, + }), + dict({ + 'id': 'demandResponseLoadControl', + 'version': 1, + }), + dict({ + 'id': 'refresh', + 'version': 1, + }), + dict({ + 'id': 'execute', + 'version': 1, + }), + dict({ + 'id': 'custom.spiMode', + 'version': 1, + }), + dict({ + 'id': 'custom.thermostatSetpointControl', + 'version': 1, + }), + dict({ + 'id': 'custom.airConditionerOptionalMode', + 'version': 1, + }), + dict({ + 'id': 'custom.airConditionerTropicalNightMode', + 'version': 1, + }), + dict({ + 'id': 'custom.autoCleaningMode', + 'version': 1, + }), + dict({ + 'id': 'custom.deviceReportStateConfiguration', + 'version': 1, + }), + dict({ + 'id': 'custom.energyType', + 'version': 1, + }), + dict({ + 'id': 'custom.dustFilter', + 'version': 1, + }), + dict({ + 'id': 'custom.airConditionerOdorController', + 'version': 1, + }), + dict({ + 'id': 'custom.deodorFilter', + 'version': 1, + }), + dict({ + 'id': 'custom.disabledComponents', + 'version': 1, + }), + dict({ + 'id': 'custom.disabledCapabilities', + 'version': 1, + }), + dict({ + 'id': 'samsungce.deviceIdentification', + 'version': 1, + }), + dict({ + 'id': 'samsungce.dongleSoftwareInstallation', + 'version': 1, + }), + dict({ + 'id': 'samsungce.softwareUpdate', + 'version': 1, + }), + dict({ + 'id': 'samsungce.selfCheck', + 'version': 1, + }), + dict({ + 'id': 'samsungce.driverVersion', + 'version': 1, + }), + ]), + 'categories': list([ + dict({ + 'categoryType': 'manufacturer', + 'name': 'AirConditioner', + }), + ]), + 'id': 'main', + 'label': 'main', }), dict({ - 'id': 'switch', - 'version': 1, - }), - dict({ - 'id': 'airConditionerMode', - 'version': 1, - }), - dict({ - 'id': 'airConditionerFanMode', - 'version': 1, - }), - dict({ - 'id': 'fanOscillationMode', - 'version': 1, - }), - dict({ - 'id': 'airQualitySensor', - 'version': 1, - }), - dict({ - 'id': 'temperatureMeasurement', - 'version': 1, - }), - dict({ - 'id': 'thermostatCoolingSetpoint', - 'version': 1, - }), - dict({ - 'id': 'relativeHumidityMeasurement', - 'version': 1, - }), - dict({ - 'id': 'dustSensor', - 'version': 1, - }), - dict({ - 'id': 'veryFineDustSensor', - 'version': 1, - }), - dict({ - 'id': 'audioVolume', - 'version': 1, - }), - dict({ - 'id': 'remoteControlStatus', - 'version': 1, - }), - dict({ - 'id': 'powerConsumptionReport', - 'version': 1, - }), - dict({ - 'id': 'demandResponseLoadControl', - 'version': 1, - }), - dict({ - 'id': 'refresh', - 'version': 1, - }), - dict({ - 'id': 'execute', - 'version': 1, - }), - dict({ - 'id': 'custom.spiMode', - 'version': 1, - }), - dict({ - 'id': 'custom.thermostatSetpointControl', - 'version': 1, - }), - dict({ - 'id': 'custom.airConditionerOptionalMode', - 'version': 1, - }), - dict({ - 'id': 'custom.airConditionerTropicalNightMode', - 'version': 1, - }), - dict({ - 'id': 'custom.autoCleaningMode', - 'version': 1, - }), - dict({ - 'id': 'custom.deviceReportStateConfiguration', - 'version': 1, - }), - dict({ - 'id': 'custom.energyType', - 'version': 1, - }), - dict({ - 'id': 'custom.dustFilter', - 'version': 1, - }), - dict({ - 'id': 'custom.airConditionerOdorController', - 'version': 1, - }), - dict({ - 'id': 'custom.deodorFilter', - 'version': 1, - }), - dict({ - 'id': 'custom.disabledComponents', - 'version': 1, - }), - dict({ - 'id': 'custom.disabledCapabilities', - 'version': 1, - }), - dict({ - 'id': 'samsungce.deviceIdentification', - 'version': 1, - }), - dict({ - 'id': 'samsungce.dongleSoftwareInstallation', - 'version': 1, - }), - dict({ - 'id': 'samsungce.softwareUpdate', - 'version': 1, - }), - dict({ - 'id': 'samsungce.selfCheck', - 'version': 1, - }), - dict({ - 'id': 'samsungce.driverVersion', - 'version': 1, + 'capabilities': list([ + dict({ + 'id': 'switch', + 'version': 1, + }), + dict({ + 'id': 'airConditionerMode', + 'version': 1, + }), + dict({ + 'id': 'airConditionerFanMode', + 'version': 1, + }), + dict({ + 'id': 'fanOscillationMode', + 'version': 1, + }), + dict({ + 'id': 'temperatureMeasurement', + 'version': 1, + }), + dict({ + 'id': 'thermostatCoolingSetpoint', + 'version': 1, + }), + dict({ + 'id': 'relativeHumidityMeasurement', + 'version': 1, + }), + dict({ + 'id': 'airQualitySensor', + 'version': 1, + }), + dict({ + 'id': 'dustSensor', + 'version': 1, + }), + dict({ + 'id': 'veryFineDustSensor', + 'version': 1, + }), + dict({ + 'id': 'odorSensor', + 'version': 1, + }), + dict({ + 'id': 'remoteControlStatus', + 'version': 1, + }), + dict({ + 'id': 'audioVolume', + 'version': 1, + }), + dict({ + 'id': 'custom.thermostatSetpointControl', + 'version': 1, + }), + dict({ + 'id': 'custom.autoCleaningMode', + 'version': 1, + }), + dict({ + 'id': 'custom.airConditionerTropicalNightMode', + 'version': 1, + }), + dict({ + 'id': 'custom.disabledCapabilities', + 'version': 1, + }), + dict({ + 'id': 'ocf', + 'version': 1, + }), + dict({ + 'id': 'powerConsumptionReport', + 'version': 1, + }), + dict({ + 'id': 'demandResponseLoadControl', + 'version': 1, + }), + dict({ + 'id': 'custom.spiMode', + 'version': 1, + }), + dict({ + 'id': 'custom.airConditionerOptionalMode', + 'version': 1, + }), + dict({ + 'id': 'custom.deviceReportStateConfiguration', + 'version': 1, + }), + dict({ + 'id': 'custom.energyType', + 'version': 1, + }), + dict({ + 'id': 'custom.dustFilter', + 'version': 1, + }), + dict({ + 'id': 'custom.airConditionerOdorController', + 'version': 1, + }), + dict({ + 'id': 'custom.deodorFilter', + 'version': 1, + }), + ]), + 'categories': list([ + dict({ + 'categoryType': 'manufacturer', + 'name': 'Other', + }), + ]), + 'id': '1', + 'label': '1', }), ]), - 'categories': list([ - dict({ - 'categoryType': 'manufacturer', - 'name': 'AirConditioner', - }), - ]), - 'id': 'main', - 'label': 'main', - }), - dict({ - 'capabilities': list([ - dict({ - 'id': 'switch', - 'version': 1, - }), - dict({ - 'id': 'airConditionerMode', - 'version': 1, - }), - dict({ - 'id': 'airConditionerFanMode', - 'version': 1, - }), - dict({ - 'id': 'fanOscillationMode', - 'version': 1, - }), - dict({ - 'id': 'temperatureMeasurement', - 'version': 1, - }), - dict({ - 'id': 'thermostatCoolingSetpoint', - 'version': 1, - }), - dict({ - 'id': 'relativeHumidityMeasurement', - 'version': 1, - }), - dict({ - 'id': 'airQualitySensor', - 'version': 1, - }), - dict({ - 'id': 'dustSensor', - 'version': 1, - }), - dict({ - 'id': 'veryFineDustSensor', - 'version': 1, - }), - dict({ - 'id': 'odorSensor', - 'version': 1, - }), - dict({ - 'id': 'remoteControlStatus', - 'version': 1, - }), - dict({ - 'id': 'audioVolume', - 'version': 1, - }), - dict({ - 'id': 'custom.thermostatSetpointControl', - 'version': 1, - }), - dict({ - 'id': 'custom.autoCleaningMode', - 'version': 1, - }), - dict({ - 'id': 'custom.airConditionerTropicalNightMode', - 'version': 1, - }), - dict({ - 'id': 'custom.disabledCapabilities', - 'version': 1, - }), - dict({ - 'id': 'ocf', - 'version': 1, - }), - dict({ - 'id': 'powerConsumptionReport', - 'version': 1, - }), - dict({ - 'id': 'demandResponseLoadControl', - 'version': 1, - }), - dict({ - 'id': 'custom.spiMode', - 'version': 1, - }), - dict({ - 'id': 'custom.airConditionerOptionalMode', - 'version': 1, - }), - dict({ - 'id': 'custom.deviceReportStateConfiguration', - 'version': 1, - }), - dict({ - 'id': 'custom.energyType', - 'version': 1, - }), - dict({ - 'id': 'custom.dustFilter', - 'version': 1, - }), - dict({ - 'id': 'custom.airConditionerOdorController', - 'version': 1, - }), - dict({ - 'id': 'custom.deodorFilter', - 'version': 1, - }), - ]), - 'categories': list([ - dict({ - 'categoryType': 'manufacturer', - 'name': 'Other', - }), - ]), - 'id': '1', - 'label': '1', + 'createTime': '2021-04-06T16:43:34.753Z', + 'deviceId': '96a5ef74-5832-a84b-f1f7-ca799957065d', + 'deviceManufacturerCode': 'Samsung Electronics', + 'deviceTypeName': 'Samsung OCF Air Conditioner', + 'executionContext': 'CLOUD', + 'label': 'AC Office Granit', + 'locationId': '58d3fd7c-c512-4da3-b500-ef269382756c', + 'manufacturerName': 'Samsung Electronics', + 'name': '[room a/c] Samsung', + 'ocf': dict({ + 'additionalAuthCodeRequired': False, + 'lastSignupTime': '2025-01-08T02:32:04.631093137Z', + 'manufacturerName': 'Samsung Electronics', + 'ocfDeviceType': 'x.com.st.d.sensor.light', + 'transferCandidate': False, + 'vendorId': 'VD-Sensor.Light-2023', + }), + 'ownerId': 'f9a28d7c-1ed5-d9e9-a81c-18971ec081db', + 'presentationId': 'DA-AC-RAC-000001', + 'profile': dict({ + 'id': '60fbc713-8da5-315d-b31a-6d6dcde4be7b', + }), + 'restrictionTier': 0, + 'roomId': '85a79db4-9cf2-4f09-a5b2-cd70a5c0cef0', + 'type': 'OCF', }), ]), - 'createTime': '2021-04-06T16:43:34.753Z', - 'deviceId': '96a5ef74-5832-a84b-f1f7-ca799957065d', - 'deviceManufacturerCode': 'Samsung Electronics', - 'deviceTypeName': 'Samsung OCF Air Conditioner', - 'executionContext': 'CLOUD', - 'label': 'AC Office Granit', - 'locationId': '58d3fd7c-c512-4da3-b500-ef269382756c', - 'manufacturerName': 'Samsung Electronics', - 'name': '[room a/c] Samsung', - 'ocf': dict({ - 'additionalAuthCodeRequired': False, - 'lastSignupTime': '2025-01-08T02:32:04.631093137Z', - 'manufacturerName': 'Samsung Electronics', - 'ocfDeviceType': 'x.com.st.d.sensor.light', - 'transferCandidate': False, - 'vendorId': 'VD-Sensor.Light-2023', - }), - 'ownerId': 'f9a28d7c-1ed5-d9e9-a81c-18971ec081db', - 'presentationId': 'DA-AC-RAC-000001', - 'profile': dict({ - 'id': '60fbc713-8da5-315d-b31a-6d6dcde4be7b', - }), - 'restrictionTier': 0, - 'roomId': '85a79db4-9cf2-4f09-a5b2-cd70a5c0cef0', - 'type': 'OCF', }), ]), }) diff --git a/tests/components/smartthings/test_diagnostics.py b/tests/components/smartthings/test_diagnostics.py index f486c19de14..b28a3a1aff5 100644 --- a/tests/components/smartthings/test_diagnostics.py +++ b/tests/components/smartthings/test_diagnostics.py @@ -30,9 +30,9 @@ async def test_config_entry_diagnostics( snapshot: SnapshotAssertion, ) -> None: """Test generating diagnostics for a device entry.""" - mock_smartthings.get_raw_devices.return_value = load_json_object_fixture( - "devices/da_ac_rac_000001.json", DOMAIN - ) + mock_smartthings.get_raw_devices.return_value = [ + load_json_object_fixture("devices/da_ac_rac_000001.json", DOMAIN) + ] await setup_integration(hass, mock_config_entry) assert ( await get_diagnostics_for_config_entry(hass, hass_client, mock_config_entry) From aab349e787a809df692fdd78d11f4b7680309b34 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 16 Mar 2025 20:06:50 +0100 Subject: [PATCH 2838/3148] Fix SmartThings ACs without supported AC modes (#140744) --- .../components/smartthings/climate.py | 15 ++-- tests/components/smartthings/conftest.py | 1 + .../fixtures/device_status/aux_ac.json | 69 ++++++++++++++++ .../smartthings/fixtures/devices/aux_ac.json | 81 +++++++++++++++++++ .../smartthings/snapshots/test_climate.ambr | 64 +++++++++++++++ .../smartthings/snapshots/test_init.ambr | 33 ++++++++ .../smartthings/snapshots/test_sensor.ambr | 52 ++++++++++++ 7 files changed, 309 insertions(+), 6 deletions(-) create mode 100644 tests/components/smartthings/fixtures/device_status/aux_ac.json create mode 100644 tests/components/smartthings/fixtures/devices/aux_ac.json diff --git a/homeassistant/components/smartthings/climate.py b/homeassistant/components/smartthings/climate.py index f80d5b8afab..e20f191352f 100644 --- a/homeassistant/components/smartthings/climate.py +++ b/homeassistant/components/smartthings/climate.py @@ -565,12 +565,15 @@ class SmartThingsAirConditioner(SmartThingsEntity, ClimateEntity): def _determine_hvac_modes(self) -> list[HVACMode]: """Determine the supported HVAC modes.""" modes = [HVACMode.OFF] - modes.extend( - state - for mode in self.get_attribute_value( + if ( + ac_modes := self.get_attribute_value( Capability.AIR_CONDITIONER_MODE, Attribute.SUPPORTED_AC_MODES ) - if (state := AC_MODE_TO_STATE.get(mode)) is not None - if state not in modes - ) + ) is not None: + modes.extend( + state + for mode in ac_modes + if (state := AC_MODE_TO_STATE.get(mode)) is not None + if state not in modes + ) return modes diff --git a/tests/components/smartthings/conftest.py b/tests/components/smartthings/conftest.py index ac253da0590..74bb7a84cba 100644 --- a/tests/components/smartthings/conftest.py +++ b/tests/components/smartthings/conftest.py @@ -132,6 +132,7 @@ def mock_smartthings() -> Generator[AsyncMock]: "abl_light_b_001", "tplink_p110", "ikea_kadrilj", + "aux_ac", ] ) def device_fixture( diff --git a/tests/components/smartthings/fixtures/device_status/aux_ac.json b/tests/components/smartthings/fixtures/device_status/aux_ac.json new file mode 100644 index 00000000000..a3ebede7a10 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/aux_ac.json @@ -0,0 +1,69 @@ +{ + "components": { + "main": { + "partyvoice23922.vtempset": { + "vtemp": { + "value": 20, + "unit": "C", + "timestamp": "2024-12-05T20:03:33.161Z" + } + }, + "airConditionerFanMode": { + "fanMode": { + "value": "auto", + "timestamp": "2024-12-05T20:03:32.930Z" + }, + "supportedAcFanModes": { + "value": null + }, + "availableAcFanModes": { + "value": null + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": 20.0, + "unit": "C", + "timestamp": "2024-12-05T20:03:33.066Z" + } + }, + "airConditionerMode": { + "availableAcModes": { + "value": null + }, + "supportedAcModes": { + "value": null + }, + "airConditionerMode": { + "value": "cool", + "timestamp": "2024-12-05T20:03:32.845Z" + } + }, + "fanSpeed": { + "fanSpeed": { + "value": 0, + "timestamp": "2024-12-05T20:03:33.334Z" + } + }, + "thermostatCoolingSetpoint": { + "coolingSetpointRange": { + "value": null + }, + "coolingSetpoint": { + "value": 20.0, + "unit": "C", + "timestamp": "2024-12-05T20:03:33.243Z" + } + }, + "switch": { + "switch": { + "value": "off", + "timestamp": "2024-12-05T20:03:32.662Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/devices/aux_ac.json b/tests/components/smartthings/fixtures/devices/aux_ac.json new file mode 100644 index 00000000000..fcdb581748c --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/aux_ac.json @@ -0,0 +1,81 @@ +{ + "items": [ + { + "deviceId": "bf53a150-f8a4-45d1-aac4-86252475d551", + "name": "vedgeaircon.v1", + "label": "AUX A/C on-off", + "manufacturerName": "SmartThingsCommunity", + "presentationId": "ab252042-5669-3c2c-8b1b-d606bbcc9e04", + "deviceManufacturerCode": "SmartThings Community", + "locationId": "5db1e3d8-ea26-44b4-8ed0-1ba9c841fd57", + "ownerId": "5404aa57-6a68-4fe2-83ff-168ef769d1c7", + "roomId": "564cdd9a-fa9f-4187-902f-95656ef22989", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "switch", + "version": 1 + }, + { + "id": "airConditionerMode", + "version": 1 + }, + { + "id": "thermostatCoolingSetpoint", + "version": 1 + }, + { + "id": "airConditionerFanMode", + "version": 1 + }, + { + "id": "fanSpeed", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "partyvoice23922.vtempset", + "version": 1 + } + ], + "categories": [ + { + "name": "AirConditioner", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2024-06-19T20:18:45.407Z", + "parentDeviceId": "e699599d-30f8-4cf0-8de7-6dbdba6a665f", + "profile": { + "id": "87f0ac35-e024-3c0a-8153-78ca27a6fe0c" + }, + "lan": { + "networkId": "vEdge_A/C_1718828324.999", + "driverId": "0fd9a9a4-8863-4a83-97a7-5a288ff0f5a6", + "executingLocally": true, + "hubId": "e699599d-30f8-4cf0-8de7-6dbdba6a665f", + "provisioningState": "TYPED" + }, + "type": "LAN", + "restrictionTier": 0, + "allowed": null, + "indoorMap": { + "coordinates": [130.0, 36.0, 378.0], + "rotation": [270.0, 0.0, 0.0], + "visible": true, + "data": null + }, + "executionContext": "LOCAL", + "relationships": [] + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/snapshots/test_climate.ambr b/tests/components/smartthings/snapshots/test_climate.ambr index 20389f38a46..893093ee2aa 100644 --- a/tests/components/smartthings/snapshots/test_climate.ambr +++ b/tests/components/smartthings/snapshots/test_climate.ambr @@ -1,4 +1,68 @@ # serializer version: 1 +# name: test_all_entities[aux_ac][climate.aux_a_c_on_off-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'fan_modes': None, + 'hvac_modes': list([ + , + ]), + 'max_temp': 35, + 'min_temp': 7, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.aux_a_c_on_off', + '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': None, + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'bf53a150-f8a4-45d1-aac4-86252475d551', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[aux_ac][climate.aux_a_c_on_off-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 20.0, + 'fan_mode': 'auto', + 'fan_modes': None, + 'friendly_name': 'AUX A/C on-off', + 'hvac_modes': list([ + , + ]), + 'max_temp': 35, + 'min_temp': 7, + 'supported_features': , + 'temperature': 20.0, + }), + 'context': , + 'entity_id': 'climate.aux_a_c_on_off', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_all_entities[bosch_radiator_thermostat_ii][climate.radiator_thermostat_ii_m_wohnzimmer-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/smartthings/snapshots/test_init.ambr b/tests/components/smartthings/snapshots/test_init.ambr index 0a0453f67f6..301897134e5 100644 --- a/tests/components/smartthings/snapshots/test_init.ambr +++ b/tests/components/smartthings/snapshots/test_init.ambr @@ -68,6 +68,39 @@ 'via_device_id': None, }) # --- +# name: test_devices[aux_ac] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + 'bf53a150-f8a4-45d1-aac4-86252475d551', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': None, + 'model': None, + 'model_id': None, + 'name': 'AUX A/C on-off', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- # name: test_devices[base_electric_meter] DeviceRegistryEntrySnapshot({ 'area_id': None, diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index b6d7bd80333..e345923c414 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -154,6 +154,58 @@ 'state': 'unknown', }) # --- +# name: test_all_entities[aux_ac][sensor.aux_a_c_on_off_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.aux_a_c_on_off_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': 'Temperature', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'bf53a150-f8a4-45d1-aac4-86252475d551.temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[aux_ac][sensor.aux_a_c_on_off_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'AUX A/C on-off Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.aux_a_c_on_off_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '20.0', + }) +# --- # name: test_all_entities[base_electric_meter][sensor.aeon_energy_monitor_energy-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 983a2f513d98677699c4e72bf4038b0ac157ed93 Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Wed, 19 Mar 2025 11:25:12 +0100 Subject: [PATCH 2839/3148] Bump pylamarzocco to 1.4.9 (#140916) --- .../components/lamarzocco/__init__.py | 52 ++++++++++++----- .../components/lamarzocco/manifest.json | 2 +- homeassistant/components/lamarzocco/number.py | 18 ++++-- homeassistant/components/lamarzocco/select.py | 1 + .../components/lamarzocco/strings.json | 1 + requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../lamarzocco/fixtures/config.json | 38 ++++++++++++- .../lamarzocco/fixtures/config_mini.json | 10 +++- .../snapshots/test_diagnostics.ambr | 56 +++++++++++++------ .../lamarzocco/snapshots/test_number.ambr | 46 +++++++-------- tests/components/lamarzocco/test_init.py | 25 ++++++++- 12 files changed, 183 insertions(+), 70 deletions(-) diff --git a/homeassistant/components/lamarzocco/__init__.py b/homeassistant/components/lamarzocco/__init__.py index d20616e1940..25c8fd1091e 100644 --- a/homeassistant/components/lamarzocco/__init__.py +++ b/homeassistant/components/lamarzocco/__init__.py @@ -61,6 +61,42 @@ async def async_setup_entry(hass: HomeAssistant, entry: LaMarzoccoConfigEntry) - client=client, ) + # initialize the firmware update coordinator early to check the firmware version + firmware_device = LaMarzoccoMachine( + model=entry.data[CONF_MODEL], + serial_number=entry.unique_id, + name=entry.data[CONF_NAME], + cloud_client=cloud_client, + ) + + firmware_coordinator = LaMarzoccoFirmwareUpdateCoordinator( + hass, entry, firmware_device + ) + await firmware_coordinator.async_config_entry_first_refresh() + gateway_version = version.parse( + firmware_device.firmware[FirmwareType.GATEWAY].current_version + ) + + if gateway_version >= version.parse("v5.0.9"): + # remove host from config entry, it is not supported anymore + data = {k: v for k, v in entry.data.items() if k != CONF_HOST} + hass.config_entries.async_update_entry( + entry, + data=data, + ) + + elif gateway_version < version.parse("v3.4-rc5"): + # incompatible gateway firmware, create an issue + ir.async_create_issue( + hass, + DOMAIN, + "unsupported_gateway_firmware", + is_fixable=False, + severity=ir.IssueSeverity.ERROR, + translation_key="unsupported_gateway_firmware", + translation_placeholders={"gateway_version": str(gateway_version)}, + ) + # initialize local API local_client: LaMarzoccoLocalClient | None = None if (host := entry.data.get(CONF_HOST)) is not None: @@ -117,30 +153,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: LaMarzoccoConfigEntry) - coordinators = LaMarzoccoRuntimeData( LaMarzoccoConfigUpdateCoordinator(hass, entry, device, local_client), - LaMarzoccoFirmwareUpdateCoordinator(hass, entry, device), + firmware_coordinator, LaMarzoccoStatisticsUpdateCoordinator(hass, entry, device), ) # API does not like concurrent requests, so no asyncio.gather here await coordinators.config_coordinator.async_config_entry_first_refresh() - await coordinators.firmware_coordinator.async_config_entry_first_refresh() await coordinators.statistics_coordinator.async_config_entry_first_refresh() entry.runtime_data = coordinators - gateway_version = device.firmware[FirmwareType.GATEWAY].current_version - if version.parse(gateway_version) < version.parse("v3.4-rc5"): - # incompatible gateway firmware, create an issue - ir.async_create_issue( - hass, - DOMAIN, - "unsupported_gateway_firmware", - is_fixable=False, - severity=ir.IssueSeverity.ERROR, - translation_key="unsupported_gateway_firmware", - translation_placeholders={"gateway_version": gateway_version}, - ) - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) async def update_listener( diff --git a/homeassistant/components/lamarzocco/manifest.json b/homeassistant/components/lamarzocco/manifest.json index eceb2bbf53b..73f00b2bdd0 100644 --- a/homeassistant/components/lamarzocco/manifest.json +++ b/homeassistant/components/lamarzocco/manifest.json @@ -37,5 +37,5 @@ "iot_class": "cloud_polling", "loggers": ["pylamarzocco"], "quality_scale": "platinum", - "requirements": ["pylamarzocco==1.4.7"] + "requirements": ["pylamarzocco==1.4.9"] } diff --git a/homeassistant/components/lamarzocco/number.py b/homeassistant/components/lamarzocco/number.py index 666c57c1866..08e9ad7e590 100644 --- a/homeassistant/components/lamarzocco/number.py +++ b/homeassistant/components/lamarzocco/number.py @@ -144,9 +144,12 @@ KEY_ENTITIES: tuple[LaMarzoccoKeyNumberEntityDescription, ...] = ( set_value_fn=lambda machine, value, key: machine.set_prebrew_time( prebrew_off_time=value, key=key ), - native_value_fn=lambda config, key: config.prebrew_configuration[key].off_time, + native_value_fn=lambda config, key: config.prebrew_configuration[key][ + 0 + ].off_time, available_fn=lambda device: len(device.config.prebrew_configuration) > 0 - and device.config.prebrew_mode == PrebrewMode.PREBREW, + and device.config.prebrew_mode + in (PrebrewMode.PREBREW, PrebrewMode.PREBREW_ENABLED), supported_fn=lambda coordinator: coordinator.device.model != MachineModel.GS3_MP, ), @@ -162,9 +165,12 @@ KEY_ENTITIES: tuple[LaMarzoccoKeyNumberEntityDescription, ...] = ( set_value_fn=lambda machine, value, key: machine.set_prebrew_time( prebrew_on_time=value, key=key ), - native_value_fn=lambda config, key: config.prebrew_configuration[key].off_time, + native_value_fn=lambda config, key: config.prebrew_configuration[key][ + 0 + ].off_time, available_fn=lambda device: len(device.config.prebrew_configuration) > 0 - and device.config.prebrew_mode == PrebrewMode.PREBREW, + and device.config.prebrew_mode + in (PrebrewMode.PREBREW, PrebrewMode.PREBREW_ENABLED), supported_fn=lambda coordinator: coordinator.device.model != MachineModel.GS3_MP, ), @@ -180,8 +186,8 @@ KEY_ENTITIES: tuple[LaMarzoccoKeyNumberEntityDescription, ...] = ( set_value_fn=lambda machine, value, key: machine.set_preinfusion_time( preinfusion_time=value, key=key ), - native_value_fn=lambda config, key: config.prebrew_configuration[ - key + native_value_fn=lambda config, key: config.prebrew_configuration[key][ + 1 ].preinfusion_time, available_fn=lambda device: len(device.config.prebrew_configuration) > 0 and device.config.prebrew_mode == PrebrewMode.PREINFUSION, diff --git a/homeassistant/components/lamarzocco/select.py b/homeassistant/components/lamarzocco/select.py index d8217cefaff..5ebe2d7b9da 100644 --- a/homeassistant/components/lamarzocco/select.py +++ b/homeassistant/components/lamarzocco/select.py @@ -38,6 +38,7 @@ STEAM_LEVEL_LM_TO_HA = {value: key for key, value in STEAM_LEVEL_HA_TO_LM.items( PREBREW_MODE_HA_TO_LM = { "disabled": PrebrewMode.DISABLED, "prebrew": PrebrewMode.PREBREW, + "prebrew_enabled": PrebrewMode.PREBREW_ENABLED, "preinfusion": PrebrewMode.PREINFUSION, } diff --git a/homeassistant/components/lamarzocco/strings.json b/homeassistant/components/lamarzocco/strings.json index 62050685c27..04853b8d0ca 100644 --- a/homeassistant/components/lamarzocco/strings.json +++ b/homeassistant/components/lamarzocco/strings.json @@ -148,6 +148,7 @@ "state": { "disabled": "Disabled", "prebrew": "Prebrew", + "prebrew_enabled": "Prebrew", "preinfusion": "Preinfusion" } }, diff --git a/requirements_all.txt b/requirements_all.txt index b0af31315f2..20aaa3ea4ee 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2077,7 +2077,7 @@ pykwb==0.0.8 pylacrosse==0.4 # homeassistant.components.lamarzocco -pylamarzocco==1.4.7 +pylamarzocco==1.4.9 # homeassistant.components.lastfm pylast==5.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 24cdaecc431..af0900b881f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1691,7 +1691,7 @@ pykrakenapi==0.1.8 pykulersky==0.5.2 # homeassistant.components.lamarzocco -pylamarzocco==1.4.7 +pylamarzocco==1.4.9 # homeassistant.components.lastfm pylast==5.1.0 diff --git a/tests/components/lamarzocco/fixtures/config.json b/tests/components/lamarzocco/fixtures/config.json index ea6e2ee76b8..5aac86dde97 100644 --- a/tests/components/lamarzocco/fixtures/config.json +++ b/tests/components/lamarzocco/fixtures/config.json @@ -101,28 +101,60 @@ "mode": "TypeB", "Group1": [ { + "mode": "TypeA", "groupNumber": "Group1", "doseType": "DoseA", "preWetTime": 0.5, "preWetHoldTime": 1 }, { + "mode": "TypeB", + "groupNumber": "Group1", + "doseType": "DoseA", + "preWetTime": 0, + "preWetHoldTime": 4 + }, + { + "mode": "TypeA", "groupNumber": "Group1", "doseType": "DoseB", "preWetTime": 0.5, "preWetHoldTime": 1 }, { + "mode": "TypeB", "groupNumber": "Group1", - "doseType": "DoseC", - "preWetTime": 3.2999999523162842, - "preWetHoldTime": 3.2999999523162842 + "doseType": "DoseB", + "preWetTime": 0, + "preWetHoldTime": 4 }, { + "mode": "TypeA", + "groupNumber": "Group1", + "doseType": "DoseC", + "preWetTime": 3.3, + "preWetHoldTime": 3.3 + }, + { + "mode": "TypeB", + "groupNumber": "Group1", + "doseType": "DoseC", + "preWetTime": 0, + "preWetHoldTime": 4 + }, + { + "mode": "TypeA", "groupNumber": "Group1", "doseType": "DoseD", "preWetTime": 2, "preWetHoldTime": 2 + }, + { + "mode": "TypeB", + "groupNumber": "Group1", + "doseType": "DoseD", + "preWetTime": 0, + "preWetHoldTime": 4 } ] }, diff --git a/tests/components/lamarzocco/fixtures/config_mini.json b/tests/components/lamarzocco/fixtures/config_mini.json index 22533a94872..a726d715a6f 100644 --- a/tests/components/lamarzocco/fixtures/config_mini.json +++ b/tests/components/lamarzocco/fixtures/config_mini.json @@ -82,10 +82,18 @@ "mode": "TypeB", "Group1": [ { + "mode": "TypeA", "groupNumber": "Group1", - "doseType": "DoseA", + "doseType": "Continuous", "preWetTime": 2, "preWetHoldTime": 3 + }, + { + "mode": "TypeB", + "groupNumber": "Group1", + "doseType": "Continuous", + "preWetTime": 0, + "preWetHoldTime": 3 } ] }, diff --git a/tests/components/lamarzocco/snapshots/test_diagnostics.ambr b/tests/components/lamarzocco/snapshots/test_diagnostics.ambr index b1d8140b2ce..018449f7c9a 100644 --- a/tests/components/lamarzocco/snapshots/test_diagnostics.ambr +++ b/tests/components/lamarzocco/snapshots/test_diagnostics.ambr @@ -27,22 +27,46 @@ }), 'plumbed_in': True, 'prebrew_configuration': dict({ - '1': dict({ - 'off_time': 1, - 'on_time': 0.5, - }), - '2': dict({ - 'off_time': 1, - 'on_time': 0.5, - }), - '3': dict({ - 'off_time': 3.299999952316284, - 'on_time': 3.299999952316284, - }), - '4': dict({ - 'off_time': 2, - 'on_time': 2, - }), + '1': list([ + dict({ + 'off_time': 1, + 'on_time': 0.5, + }), + dict({ + 'off_time': 4, + 'on_time': 0, + }), + ]), + '2': list([ + dict({ + 'off_time': 1, + 'on_time': 0.5, + }), + dict({ + 'off_time': 4, + 'on_time': 0, + }), + ]), + '3': list([ + dict({ + 'off_time': 3.3, + 'on_time': 3.3, + }), + dict({ + 'off_time': 4, + 'on_time': 0, + }), + ]), + '4': list([ + dict({ + 'off_time': 2, + 'on_time': 2, + }), + dict({ + 'off_time': 4, + 'on_time': 0, + }), + ]), }), 'prebrew_mode': 'TypeB', 'scale': None, diff --git a/tests/components/lamarzocco/snapshots/test_number.ambr b/tests/components/lamarzocco/snapshots/test_number.ambr index 0748c9384a9..de1f11b14eb 100644 --- a/tests/components/lamarzocco/snapshots/test_number.ambr +++ b/tests/components/lamarzocco/snapshots/test_number.ambr @@ -419,7 +419,7 @@ 'state': '121', }) # --- -# name: test_pre_brew_infusion_key_numbers[prebrew_off_time-6-Enabled-set_prebrew_time-kwargs0-GS3 AV][GS012345_prebrew_off_time_key_1-state] +# name: test_pre_brew_infusion_key_numbers[prebrew_off_time-6-TypeA-set_prebrew_time-kwargs0-GS3 AV][GS012345_prebrew_off_time_key_1-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', @@ -438,7 +438,7 @@ 'state': '1', }) # --- -# name: test_pre_brew_infusion_key_numbers[prebrew_off_time-6-Enabled-set_prebrew_time-kwargs0-GS3 AV][GS012345_prebrew_off_time_key_2-state] +# name: test_pre_brew_infusion_key_numbers[prebrew_off_time-6-TypeA-set_prebrew_time-kwargs0-GS3 AV][GS012345_prebrew_off_time_key_2-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', @@ -457,7 +457,7 @@ 'state': '1', }) # --- -# name: test_pre_brew_infusion_key_numbers[prebrew_off_time-6-Enabled-set_prebrew_time-kwargs0-GS3 AV][GS012345_prebrew_off_time_key_3-state] +# name: test_pre_brew_infusion_key_numbers[prebrew_off_time-6-TypeA-set_prebrew_time-kwargs0-GS3 AV][GS012345_prebrew_off_time_key_3-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', @@ -473,10 +473,10 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '3.29999995231628', + 'state': '3.3', }) # --- -# name: test_pre_brew_infusion_key_numbers[prebrew_off_time-6-Enabled-set_prebrew_time-kwargs0-GS3 AV][GS012345_prebrew_off_time_key_4-state] +# name: test_pre_brew_infusion_key_numbers[prebrew_off_time-6-TypeA-set_prebrew_time-kwargs0-GS3 AV][GS012345_prebrew_off_time_key_4-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', @@ -495,7 +495,7 @@ 'state': '2', }) # --- -# name: test_pre_brew_infusion_key_numbers[prebrew_on_time-6-Enabled-set_prebrew_time-kwargs1-GS3 AV][GS012345_prebrew_on_time_key_1-state] +# name: test_pre_brew_infusion_key_numbers[prebrew_on_time-6-TypeA-set_prebrew_time-kwargs1-GS3 AV][GS012345_prebrew_on_time_key_1-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', @@ -514,7 +514,7 @@ 'state': '1', }) # --- -# name: test_pre_brew_infusion_key_numbers[prebrew_on_time-6-Enabled-set_prebrew_time-kwargs1-GS3 AV][GS012345_prebrew_on_time_key_2-state] +# name: test_pre_brew_infusion_key_numbers[prebrew_on_time-6-TypeA-set_prebrew_time-kwargs1-GS3 AV][GS012345_prebrew_on_time_key_2-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', @@ -533,7 +533,7 @@ 'state': '1', }) # --- -# name: test_pre_brew_infusion_key_numbers[prebrew_on_time-6-Enabled-set_prebrew_time-kwargs1-GS3 AV][GS012345_prebrew_on_time_key_3-state] +# name: test_pre_brew_infusion_key_numbers[prebrew_on_time-6-TypeA-set_prebrew_time-kwargs1-GS3 AV][GS012345_prebrew_on_time_key_3-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', @@ -549,10 +549,10 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '3.29999995231628', + 'state': '3.3', }) # --- -# name: test_pre_brew_infusion_key_numbers[prebrew_on_time-6-Enabled-set_prebrew_time-kwargs1-GS3 AV][GS012345_prebrew_on_time_key_4-state] +# name: test_pre_brew_infusion_key_numbers[prebrew_on_time-6-TypeA-set_prebrew_time-kwargs1-GS3 AV][GS012345_prebrew_on_time_key_4-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', @@ -587,7 +587,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '1', + 'state': '4', }) # --- # name: test_pre_brew_infusion_key_numbers[preinfusion_time-7-TypeB-set_preinfusion_time-kwargs2-GS3 AV][GS012345_preinfusion_time_key_2-state] @@ -606,7 +606,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '1', + 'state': '4', }) # --- # name: test_pre_brew_infusion_key_numbers[preinfusion_time-7-TypeB-set_preinfusion_time-kwargs2-GS3 AV][GS012345_preinfusion_time_key_3-state] @@ -625,7 +625,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '3.29999995231628', + 'state': '4', }) # --- # name: test_pre_brew_infusion_key_numbers[preinfusion_time-7-TypeB-set_preinfusion_time-kwargs2-GS3 AV][GS012345_preinfusion_time_key_4-state] @@ -644,10 +644,10 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '2', + 'state': '4', }) # --- -# name: test_pre_brew_infusion_numbers[prebrew_off_time-set_prebrew_time-Enabled-6-kwargs0-Linea Mini] +# name: test_pre_brew_infusion_numbers[prebrew_off_time-set_prebrew_time-TypeA-6-kwargs0-Linea Mini] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', @@ -666,7 +666,7 @@ 'state': '3', }) # --- -# name: test_pre_brew_infusion_numbers[prebrew_off_time-set_prebrew_time-Enabled-6-kwargs0-Linea Mini].1 +# name: test_pre_brew_infusion_numbers[prebrew_off_time-set_prebrew_time-TypeA-6-kwargs0-Linea Mini].1 EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -705,7 +705,7 @@ 'unit_of_measurement': , }) # --- -# name: test_pre_brew_infusion_numbers[prebrew_off_time-set_prebrew_time-Enabled-6-kwargs0-Micra] +# name: test_pre_brew_infusion_numbers[prebrew_off_time-set_prebrew_time-TypeA-6-kwargs0-Micra] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', @@ -724,7 +724,7 @@ 'state': '1', }) # --- -# name: test_pre_brew_infusion_numbers[prebrew_off_time-set_prebrew_time-Enabled-6-kwargs0-Micra].1 +# name: test_pre_brew_infusion_numbers[prebrew_off_time-set_prebrew_time-TypeA-6-kwargs0-Micra].1 EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -763,7 +763,7 @@ 'unit_of_measurement': , }) # --- -# name: test_pre_brew_infusion_numbers[prebrew_on_time-set_prebrew_time-Enabled-6-kwargs1-Linea Mini] +# name: test_pre_brew_infusion_numbers[prebrew_on_time-set_prebrew_time-TypeA-6-kwargs1-Linea Mini] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', @@ -782,7 +782,7 @@ 'state': '3', }) # --- -# name: test_pre_brew_infusion_numbers[prebrew_on_time-set_prebrew_time-Enabled-6-kwargs1-Linea Mini].1 +# name: test_pre_brew_infusion_numbers[prebrew_on_time-set_prebrew_time-TypeA-6-kwargs1-Linea Mini].1 EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -821,7 +821,7 @@ 'unit_of_measurement': , }) # --- -# name: test_pre_brew_infusion_numbers[prebrew_on_time-set_prebrew_time-Enabled-6-kwargs1-Micra] +# name: test_pre_brew_infusion_numbers[prebrew_on_time-set_prebrew_time-TypeA-6-kwargs1-Micra] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', @@ -840,7 +840,7 @@ 'state': '1', }) # --- -# name: test_pre_brew_infusion_numbers[prebrew_on_time-set_prebrew_time-Enabled-6-kwargs1-Micra].1 +# name: test_pre_brew_infusion_numbers[prebrew_on_time-set_prebrew_time-TypeA-6-kwargs1-Micra].1 EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -953,7 +953,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '1', + 'state': '4', }) # --- # name: test_pre_brew_infusion_numbers[preinfusion_time-set_preinfusion_time-TypeB-7-kwargs2-Micra].1 diff --git a/tests/components/lamarzocco/test_init.py b/tests/components/lamarzocco/test_init.py index 09ebc462952..a9a3b9f23e1 100644 --- a/tests/components/lamarzocco/test_init.py +++ b/tests/components/lamarzocco/test_init.py @@ -170,12 +170,18 @@ async def test_bluetooth_is_set_from_discovery( "homeassistant.components.lamarzocco.async_discovered_service_info", return_value=[service_info], ) as discovery, - patch("homeassistant.components.lamarzocco.LaMarzoccoMachine") as init_device, + patch( + "homeassistant.components.lamarzocco.LaMarzoccoMachine" + ) as mock_machine_class, ): + mock_machine = MagicMock() + mock_machine.get_firmware = AsyncMock() + mock_machine.firmware = mock_lamarzocco.firmware + mock_machine_class.return_value = mock_machine await async_init_integration(hass, mock_config_entry) discovery.assert_called_once() - init_device.assert_called_once() - _, kwargs = init_device.call_args + assert mock_machine_class.call_count == 2 + _, kwargs = mock_machine_class.call_args assert kwargs["bluetooth_client"] is not None assert mock_config_entry.data[CONF_NAME] == service_info.name assert mock_config_entry.data[CONF_MAC] == service_info.address @@ -223,6 +229,19 @@ async def test_gateway_version_issue( assert (issue is not None) == issue_exists +async def test_conf_host_removed_for_new_gateway( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_lamarzocco: MagicMock, +) -> None: + """Make sure we get the issue for certain gateway firmware versions.""" + mock_lamarzocco.firmware[FirmwareType.GATEWAY].current_version = "v5.0.9" + + await async_init_integration(hass, mock_config_entry) + + assert CONF_HOST not in mock_config_entry.data + + async def test_device( hass: HomeAssistant, mock_lamarzocco: MagicMock, From 8a63fa3bb7a8f6074a2b53a9a39542c0e9711b64 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 19 Mar 2025 20:13:46 +0100 Subject: [PATCH 2840/3148] Log SmartThings subscription error on exception (#140939) --- homeassistant/components/smartthings/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py index 849044945d1..b615f76640c 100644 --- a/homeassistant/components/smartthings/__init__.py +++ b/homeassistant/components/smartthings/__init__.py @@ -139,7 +139,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: SmartThingsConfigEntry) entry.data[CONF_TOKEN][CONF_INSTALLED_APP_ID], ) except SmartThingsSinkError as err: - _LOGGER.debug("Couldn't create a new subscription: %s", err) + _LOGGER.exception("Couldn't create a new subscription") raise ConfigEntryNotReady from err subscription_id = subscription.subscription_id _handle_new_subscription_identifier(subscription_id) From 5681f4f2ead67d2e91c724ca3ae9d54c2ae406a5 Mon Sep 17 00:00:00 2001 From: Ivan Lopez Hernandez Date: Wed, 19 Mar 2025 22:58:19 -0700 Subject: [PATCH 2841/3148] Ensure file is correctly uploaded by the GenAI SDK (#140969) Opened the file outside of the SDK --- .../google_generative_ai_conversation/__init__.py | 8 +++++++- .../google_generative_ai_conversation/test_init.py | 4 +++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/google_generative_ai_conversation/__init__.py b/homeassistant/components/google_generative_ai_conversation/__init__.py index 6b10565e0b5..c32d7b5ddea 100644 --- a/homeassistant/components/google_generative_ai_conversation/__init__.py +++ b/homeassistant/components/google_generative_ai_conversation/__init__.py @@ -2,6 +2,7 @@ from __future__ import annotations +import mimetypes from pathlib import Path from google import genai # type: ignore[attr-defined] @@ -83,7 +84,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ) if not Path(filename).exists(): raise HomeAssistantError(f"`{filename}` does not exist") - prompt_parts.append(client.files.upload(file=filename)) + mimetype = mimetypes.guess_type(filename)[0] + with open(filename, "rb") as file: + uploaded_file = client.files.upload( + file=file, config={"mime_type": mimetype} + ) + prompt_parts.append(uploaded_file) await hass.async_add_executor_job(append_files_to_prompt) diff --git a/tests/components/google_generative_ai_conversation/test_init.py b/tests/components/google_generative_ai_conversation/test_init.py index 25533ffd46e..a08acc0df3f 100644 --- a/tests/components/google_generative_ai_conversation/test_init.py +++ b/tests/components/google_generative_ai_conversation/test_init.py @@ -1,6 +1,6 @@ """Tests for the Google Generative AI Conversation integration.""" -from unittest.mock import AsyncMock, Mock, patch +from unittest.mock import AsyncMock, Mock, mock_open, patch import pytest from requests.exceptions import Timeout @@ -71,6 +71,8 @@ async def test_generate_content_service_with_image( ), patch("pathlib.Path.exists", return_value=True), patch.object(hass.config, "is_allowed_path", return_value=True), + patch("builtins.open", mock_open(read_data="this is an image")), + patch("mimetypes.guess_type", return_value=["image/jpeg"]), ): response = await hass.services.async_call( "google_generative_ai_conversation", From 121ee271055a4b402f0de598d80e3388c9d52634 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Thu, 20 Mar 2025 20:45:07 +0100 Subject: [PATCH 2842/3148] Reolink fix playback headers (#141015) --- homeassistant/components/reolink/views.py | 36 +++++++++++++++++------ tests/components/reolink/test_views.py | 8 ++++- 2 files changed, 34 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/reolink/views.py b/homeassistant/components/reolink/views.py index 1a4585bc997..44265244b18 100644 --- a/homeassistant/components/reolink/views.py +++ b/homeassistant/components/reolink/views.py @@ -83,7 +83,16 @@ class PlaybackProxyView(HomeAssistantView): _LOGGER.warning("Reolink playback proxy error: %s", str(err)) return web.Response(body=str(err), status=HTTPStatus.BAD_REQUEST) + headers = dict(request.headers) + headers.pop("Host", None) + headers.pop("Referer", None) + if _LOGGER.isEnabledFor(logging.DEBUG): + _LOGGER.debug( + "Requested Playback Proxy Method %s, Headers: %s", + request.method, + headers, + ) _LOGGER.debug( "Opening VOD stream from %s: %s", host.api.camera_name(ch), @@ -93,6 +102,7 @@ class PlaybackProxyView(HomeAssistantView): try: reolink_response = await self.session.get( reolink_url, + headers=headers, timeout=ClientTimeout( connect=15, sock_connect=15, sock_read=5, total=None ), @@ -118,18 +128,25 @@ class PlaybackProxyView(HomeAssistantView): ]: err_str = f"Reolink playback expected video/mp4 but got {reolink_response.content_type}" _LOGGER.error(err_str) + if reolink_response.content_type == "text/html": + text = await reolink_response.text() + _LOGGER.debug(text) return web.Response(body=err_str, status=HTTPStatus.BAD_REQUEST) - response = web.StreamResponse( - status=200, - reason="OK", - headers={ - "Content-Type": "video/mp4", - }, + response_headers = dict(reolink_response.headers) + _LOGGER.debug( + "Response Playback Proxy Status %s:%s, Headers: %s", + reolink_response.status, + reolink_response.reason, + response_headers, ) + response_headers["Content-Type"] = "video/mp4" - if reolink_response.content_length is not None: - response.content_length = reolink_response.content_length + response = web.StreamResponse( + status=reolink_response.status, + reason=reolink_response.reason, + headers=response_headers, + ) await response.prepare(request) @@ -141,7 +158,8 @@ class PlaybackProxyView(HomeAssistantView): "Timeout while reading Reolink playback from %s, writing EOF", host.api.nvr_name, ) + finally: + reolink_response.release() - reolink_response.release() await response.write_eof() return response diff --git a/tests/components/reolink/test_views.py b/tests/components/reolink/test_views.py index c994cc59c5d..3521de072b6 100644 --- a/tests/components/reolink/test_views.py +++ b/tests/components/reolink/test_views.py @@ -46,8 +46,12 @@ def get_mock_session( mock_response = Mock() mock_response.content_length = content_length + mock_response.headers = {} + mock_response.status = 200 + mock_response.reason = "OK" mock_response.content_type = content_type mock_response.content.iter_chunked = Mock(return_value=content) + mock_response.text = AsyncMock(return_value="test") mock_session = Mock() mock_session.get = AsyncMock(return_value=mock_response) @@ -178,16 +182,18 @@ async def test_playback_proxy_timeout( assert response.status == 200 +@pytest.mark.parametrize(("content_type"), [("video/x-flv"), ("text/html")]) async def test_playback_wrong_content( hass: HomeAssistant, reolink_connect: MagicMock, config_entry: MockConfigEntry, hass_client: ClientSessionGenerator, + content_type: str, ) -> None: """Test playback proxy URL with a wrong content type in the response.""" reolink_connect.get_vod_source.return_value = (TEST_MIME_TYPE_MP4, TEST_URL) - mock_session = get_mock_session(content_type="video/x-flv") + mock_session = get_mock_session(content_type=content_type) with patch( "homeassistant.components.reolink.views.async_get_clientsession", From e98d518b0b5a625aae54200b16da6c5c0fba0e99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Thu, 20 Mar 2025 22:52:46 +0100 Subject: [PATCH 2843/3148] Fix some Home Connect options keys (#141023) Fix some options keys --- .../components/home_connect/services.yaml | 46 +++++----- .../components/home_connect/strings.json | 88 +++++++++---------- 2 files changed, 67 insertions(+), 67 deletions(-) diff --git a/homeassistant/components/home_connect/services.yaml b/homeassistant/components/home_connect/services.yaml index 613b3f5af3a..2b53090fd34 100644 --- a/homeassistant/components/home_connect/services.yaml +++ b/homeassistant/components/home_connect/services.yaml @@ -468,11 +468,11 @@ set_program_and_options: translation_key: venting_level options: - cooking_hood_enum_type_stage_fan_off - - cooking_hood_enum_type_stage_fan_stage01 - - cooking_hood_enum_type_stage_fan_stage02 - - cooking_hood_enum_type_stage_fan_stage03 - - cooking_hood_enum_type_stage_fan_stage04 - - cooking_hood_enum_type_stage_fan_stage05 + - cooking_hood_enum_type_stage_fan_stage_01 + - cooking_hood_enum_type_stage_fan_stage_02 + - cooking_hood_enum_type_stage_fan_stage_03 + - cooking_hood_enum_type_stage_fan_stage_04 + - cooking_hood_enum_type_stage_fan_stage_05 cooking_hood_option_intensive_level: example: cooking_hood_enum_type_intensive_stage_intensive_stage1 required: false @@ -528,7 +528,7 @@ set_program_and_options: collapsed: true fields: laundry_care_washer_option_temperature: - example: laundry_care_washer_enum_type_temperature_g_c40 + example: laundry_care_washer_enum_type_temperature_g_c_40 required: false selector: select: @@ -536,14 +536,14 @@ set_program_and_options: translation_key: washer_temperature options: - laundry_care_washer_enum_type_temperature_cold - - laundry_care_washer_enum_type_temperature_g_c20 - - laundry_care_washer_enum_type_temperature_g_c30 - - laundry_care_washer_enum_type_temperature_g_c40 - - laundry_care_washer_enum_type_temperature_g_c50 - - laundry_care_washer_enum_type_temperature_g_c60 - - laundry_care_washer_enum_type_temperature_g_c70 - - laundry_care_washer_enum_type_temperature_g_c80 - - laundry_care_washer_enum_type_temperature_g_c90 + - laundry_care_washer_enum_type_temperature_g_c_20 + - laundry_care_washer_enum_type_temperature_g_c_30 + - laundry_care_washer_enum_type_temperature_g_c_40 + - laundry_care_washer_enum_type_temperature_g_c_50 + - laundry_care_washer_enum_type_temperature_g_c_60 + - laundry_care_washer_enum_type_temperature_g_c_70 + - laundry_care_washer_enum_type_temperature_g_c_80 + - laundry_care_washer_enum_type_temperature_g_c_90 - laundry_care_washer_enum_type_temperature_ul_cold - laundry_care_washer_enum_type_temperature_ul_warm - laundry_care_washer_enum_type_temperature_ul_hot @@ -557,15 +557,15 @@ set_program_and_options: translation_key: spin_speed options: - laundry_care_washer_enum_type_spin_speed_off - - laundry_care_washer_enum_type_spin_speed_r_p_m400 - - laundry_care_washer_enum_type_spin_speed_r_p_m600 - - laundry_care_washer_enum_type_spin_speed_r_p_m700 - - laundry_care_washer_enum_type_spin_speed_r_p_m800 - - laundry_care_washer_enum_type_spin_speed_r_p_m900 - - laundry_care_washer_enum_type_spin_speed_r_p_m1000 - - laundry_care_washer_enum_type_spin_speed_r_p_m1200 - - laundry_care_washer_enum_type_spin_speed_r_p_m1400 - - laundry_care_washer_enum_type_spin_speed_r_p_m1600 + - laundry_care_washer_enum_type_spin_speed_r_p_m_400 + - laundry_care_washer_enum_type_spin_speed_r_p_m_600 + - laundry_care_washer_enum_type_spin_speed_r_p_m_700 + - laundry_care_washer_enum_type_spin_speed_r_p_m_800 + - laundry_care_washer_enum_type_spin_speed_r_p_m_900 + - laundry_care_washer_enum_type_spin_speed_r_p_m_1000 + - laundry_care_washer_enum_type_spin_speed_r_p_m_1200 + - laundry_care_washer_enum_type_spin_speed_r_p_m_1400 + - laundry_care_washer_enum_type_spin_speed_r_p_m_1600 - laundry_care_washer_enum_type_spin_speed_ul_off - laundry_care_washer_enum_type_spin_speed_ul_low - laundry_care_washer_enum_type_spin_speed_ul_medium diff --git a/homeassistant/components/home_connect/strings.json b/homeassistant/components/home_connect/strings.json index 6b7ddc310fe..d615d9fc091 100644 --- a/homeassistant/components/home_connect/strings.json +++ b/homeassistant/components/home_connect/strings.json @@ -417,11 +417,11 @@ "venting_level": { "options": { "cooking_hood_enum_type_stage_fan_off": "Fan off", - "cooking_hood_enum_type_stage_fan_stage01": "Fan stage 1", - "cooking_hood_enum_type_stage_fan_stage02": "Fan stage 2", - "cooking_hood_enum_type_stage_fan_stage03": "Fan stage 3", - "cooking_hood_enum_type_stage_fan_stage04": "Fan stage 4", - "cooking_hood_enum_type_stage_fan_stage05": "Fan stage 5" + "cooking_hood_enum_type_stage_fan_stage_01": "Fan stage 1", + "cooking_hood_enum_type_stage_fan_stage_02": "Fan stage 2", + "cooking_hood_enum_type_stage_fan_stage_03": "Fan stage 3", + "cooking_hood_enum_type_stage_fan_stage_04": "Fan stage 4", + "cooking_hood_enum_type_stage_fan_stage_05": "Fan stage 5" } }, "intensive_level": { @@ -441,14 +441,14 @@ "washer_temperature": { "options": { "laundry_care_washer_enum_type_temperature_cold": "Cold", - "laundry_care_washer_enum_type_temperature_g_c20": "20ºC clothes", - "laundry_care_washer_enum_type_temperature_g_c30": "30ºC clothes", - "laundry_care_washer_enum_type_temperature_g_c40": "40ºC clothes", - "laundry_care_washer_enum_type_temperature_g_c50": "50ºC clothes", - "laundry_care_washer_enum_type_temperature_g_c60": "60ºC clothes", - "laundry_care_washer_enum_type_temperature_g_c70": "70ºC clothes", - "laundry_care_washer_enum_type_temperature_g_c80": "80ºC clothes", - "laundry_care_washer_enum_type_temperature_g_c90": "90ºC clothes", + "laundry_care_washer_enum_type_temperature_g_c_20": "20ºC clothes", + "laundry_care_washer_enum_type_temperature_g_c_30": "30ºC clothes", + "laundry_care_washer_enum_type_temperature_g_c_40": "40ºC clothes", + "laundry_care_washer_enum_type_temperature_g_c_50": "50ºC clothes", + "laundry_care_washer_enum_type_temperature_g_c_60": "60ºC clothes", + "laundry_care_washer_enum_type_temperature_g_c_70": "70ºC clothes", + "laundry_care_washer_enum_type_temperature_g_c_80": "80ºC clothes", + "laundry_care_washer_enum_type_temperature_g_c_90": "90ºC clothes", "laundry_care_washer_enum_type_temperature_ul_cold": "Cold", "laundry_care_washer_enum_type_temperature_ul_warm": "Warm", "laundry_care_washer_enum_type_temperature_ul_hot": "Hot", @@ -458,15 +458,15 @@ "spin_speed": { "options": { "laundry_care_washer_enum_type_spin_speed_off": "Off", - "laundry_care_washer_enum_type_spin_speed_r_p_m400": "400 rpm", - "laundry_care_washer_enum_type_spin_speed_r_p_m600": "600 rpm", - "laundry_care_washer_enum_type_spin_speed_r_p_m700": "700 rpm", - "laundry_care_washer_enum_type_spin_speed_r_p_m800": "800 rpm", - "laundry_care_washer_enum_type_spin_speed_r_p_m900": "900 rpm", - "laundry_care_washer_enum_type_spin_speed_r_p_m1000": "1000 rpm", - "laundry_care_washer_enum_type_spin_speed_r_p_m1200": "1200 rpm", - "laundry_care_washer_enum_type_spin_speed_r_p_m1400": "1400 rpm", - "laundry_care_washer_enum_type_spin_speed_r_p_m1600": "1600 rpm", + "laundry_care_washer_enum_type_spin_speed_r_p_m_400": "400 rpm", + "laundry_care_washer_enum_type_spin_speed_r_p_m_600": "600 rpm", + "laundry_care_washer_enum_type_spin_speed_r_p_m_700": "700 rpm", + "laundry_care_washer_enum_type_spin_speed_r_p_m_800": "800 rpm", + "laundry_care_washer_enum_type_spin_speed_r_p_m_900": "900 rpm", + "laundry_care_washer_enum_type_spin_speed_r_p_m_1000": "1000 rpm", + "laundry_care_washer_enum_type_spin_speed_r_p_m_1200": "1200 rpm", + "laundry_care_washer_enum_type_spin_speed_r_p_m_1400": "1400 rpm", + "laundry_care_washer_enum_type_spin_speed_r_p_m_1600": "1600 rpm", "laundry_care_washer_enum_type_spin_speed_ul_off": "Off", "laundry_care_washer_enum_type_spin_speed_ul_low": "Low", "laundry_care_washer_enum_type_spin_speed_ul_medium": "Medium", @@ -1384,11 +1384,11 @@ "name": "[%key:component::home_connect::services::set_program_and_options::fields::cooking_hood_option_venting_level::name%]", "state": { "cooking_hood_enum_type_stage_fan_off": "[%key:component::home_connect::selector::venting_level::options::cooking_hood_enum_type_stage_fan_off%]", - "cooking_hood_enum_type_stage_fan_stage01": "[%key:component::home_connect::selector::venting_level::options::cooking_hood_enum_type_stage_fan_stage01%]", - "cooking_hood_enum_type_stage_fan_stage02": "[%key:component::home_connect::selector::venting_level::options::cooking_hood_enum_type_stage_fan_stage02%]", - "cooking_hood_enum_type_stage_fan_stage03": "[%key:component::home_connect::selector::venting_level::options::cooking_hood_enum_type_stage_fan_stage03%]", - "cooking_hood_enum_type_stage_fan_stage04": "[%key:component::home_connect::selector::venting_level::options::cooking_hood_enum_type_stage_fan_stage04%]", - "cooking_hood_enum_type_stage_fan_stage05": "[%key:component::home_connect::selector::venting_level::options::cooking_hood_enum_type_stage_fan_stage05%]" + "cooking_hood_enum_type_stage_fan_stage_01": "[%key:component::home_connect::selector::venting_level::options::cooking_hood_enum_type_stage_fan_stage_01%]", + "cooking_hood_enum_type_stage_fan_stage_02": "[%key:component::home_connect::selector::venting_level::options::cooking_hood_enum_type_stage_fan_stage_02%]", + "cooking_hood_enum_type_stage_fan_stage_03": "[%key:component::home_connect::selector::venting_level::options::cooking_hood_enum_type_stage_fan_stage_03%]", + "cooking_hood_enum_type_stage_fan_stage_04": "[%key:component::home_connect::selector::venting_level::options::cooking_hood_enum_type_stage_fan_stage_04%]", + "cooking_hood_enum_type_stage_fan_stage_05": "[%key:component::home_connect::selector::venting_level::options::cooking_hood_enum_type_stage_fan_stage_05%]" } }, "intensive_level": { @@ -1411,14 +1411,14 @@ "name": "[%key:component::home_connect::services::set_program_and_options::fields::laundry_care_washer_option_temperature::name%]", "state": { "laundry_care_washer_enum_type_temperature_cold": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_cold%]", - "laundry_care_washer_enum_type_temperature_g_c20": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c20%]", - "laundry_care_washer_enum_type_temperature_g_c30": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c30%]", - "laundry_care_washer_enum_type_temperature_g_c40": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c40%]", - "laundry_care_washer_enum_type_temperature_g_c50": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c50%]", - "laundry_care_washer_enum_type_temperature_g_c60": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c60%]", - "laundry_care_washer_enum_type_temperature_g_c70": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c70%]", - "laundry_care_washer_enum_type_temperature_g_c80": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c80%]", - "laundry_care_washer_enum_type_temperature_g_c90": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c90%]", + "laundry_care_washer_enum_type_temperature_g_c_20": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c_20%]", + "laundry_care_washer_enum_type_temperature_g_c_30": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c_30%]", + "laundry_care_washer_enum_type_temperature_g_c_40": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c_40%]", + "laundry_care_washer_enum_type_temperature_g_c_50": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c_50%]", + "laundry_care_washer_enum_type_temperature_g_c_60": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c_60%]", + "laundry_care_washer_enum_type_temperature_g_c_70": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c_70%]", + "laundry_care_washer_enum_type_temperature_g_c_80": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c_80%]", + "laundry_care_washer_enum_type_temperature_g_c_90": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c_90%]", "laundry_care_washer_enum_type_temperature_ul_cold": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_ul_cold%]", "laundry_care_washer_enum_type_temperature_ul_warm": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_ul_warm%]", "laundry_care_washer_enum_type_temperature_ul_hot": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_ul_hot%]", @@ -1429,15 +1429,15 @@ "name": "[%key:component::home_connect::services::set_program_and_options::fields::laundry_care_washer_option_spin_speed::name%]", "state": { "laundry_care_washer_enum_type_spin_speed_off": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_off%]", - "laundry_care_washer_enum_type_spin_speed_r_p_m400": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m400%]", - "laundry_care_washer_enum_type_spin_speed_r_p_m600": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m600%]", - "laundry_care_washer_enum_type_spin_speed_r_p_m700": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m700%]", - "laundry_care_washer_enum_type_spin_speed_r_p_m800": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m800%]", - "laundry_care_washer_enum_type_spin_speed_r_p_m900": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m900%]", - "laundry_care_washer_enum_type_spin_speed_r_p_m1000": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m1000%]", - "laundry_care_washer_enum_type_spin_speed_r_p_m1200": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m1200%]", - "laundry_care_washer_enum_type_spin_speed_r_p_m1400": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m1400%]", - "laundry_care_washer_enum_type_spin_speed_r_p_m1600": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m1600%]", + "laundry_care_washer_enum_type_spin_speed_r_p_m_400": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m_400%]", + "laundry_care_washer_enum_type_spin_speed_r_p_m_600": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m_600%]", + "laundry_care_washer_enum_type_spin_speed_r_p_m_700": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m_700%]", + "laundry_care_washer_enum_type_spin_speed_r_p_m_800": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m_800%]", + "laundry_care_washer_enum_type_spin_speed_r_p_m_900": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m_900%]", + "laundry_care_washer_enum_type_spin_speed_r_p_m_1000": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m_1000%]", + "laundry_care_washer_enum_type_spin_speed_r_p_m_1200": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m_1200%]", + "laundry_care_washer_enum_type_spin_speed_r_p_m_1400": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m_1400%]", + "laundry_care_washer_enum_type_spin_speed_r_p_m_1600": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m_1600%]", "laundry_care_washer_enum_type_spin_speed_ul_off": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_ul_off%]", "laundry_care_washer_enum_type_spin_speed_ul_low": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_ul_low%]", "laundry_care_washer_enum_type_spin_speed_ul_medium": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_ul_medium%]", From f54a63456388dbed82af3a3c868fef80587fa8c2 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Fri, 21 Mar 2025 02:44:57 -0400 Subject: [PATCH 2844/3148] Bump ZHA to 0.0.53 (#141025) * Bump ZHA to 0.0.53 * Regenerate snapshot --- homeassistant/components/zha/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/zha/snapshots/test_diagnostics.ambr | 11 ++++++++++- 4 files changed, 13 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index d16ce5a64bf..6ed8b253e75 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -21,7 +21,7 @@ "zha", "universal_silabs_flasher" ], - "requirements": ["zha==0.0.52"], + "requirements": ["zha==0.0.53"], "usb": [ { "vid": "10C4", diff --git a/requirements_all.txt b/requirements_all.txt index 20aaa3ea4ee..4b3af82580d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3149,7 +3149,7 @@ zeroconf==0.145.1 zeversolar==0.3.2 # homeassistant.components.zha -zha==0.0.52 +zha==0.0.53 # homeassistant.components.zhong_hong zhong-hong-hvac==1.0.13 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index af0900b881f..03c09b67778 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2538,7 +2538,7 @@ zeroconf==0.145.1 zeversolar==0.3.2 # homeassistant.components.zha -zha==0.0.52 +zha==0.0.53 # homeassistant.components.zwave_js zwave-js-server-python==0.60.1 diff --git a/tests/components/zha/snapshots/test_diagnostics.ambr b/tests/components/zha/snapshots/test_diagnostics.ambr index ba8aa9ea245..7a599b00a21 100644 --- a/tests/components/zha/snapshots/test_diagnostics.ambr +++ b/tests/components/zha/snapshots/test_diagnostics.ambr @@ -179,7 +179,16 @@ }), '0x0010': dict({ 'attribute': "ZCLAttributeDef(id=0x0010, name='cie_addr', type=, zcl_type=, access=, mandatory=True, is_manufacturer_specific=False)", - 'value': None, + 'value': list([ + 50, + 79, + 50, + 2, + 0, + 141, + 21, + 0, + ]), }), '0x0011': dict({ 'attribute': "ZCLAttributeDef(id=0x0011, name='zone_id', type=, zcl_type=, access=, mandatory=True, is_manufacturer_specific=False)", From 14b07087dc9367989dd207104efc2b4d30367e66 Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Fri, 14 Mar 2025 21:48:47 -0400 Subject: [PATCH 2845/3148] Bump Python-Snoo to 0.6.3 (#140628) Bump python-Snoo to 0.6.3 --- homeassistant/components/snoo/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/snoo/manifest.json b/homeassistant/components/snoo/manifest.json index c9306e58413..0de1e6cf760 100644 --- a/homeassistant/components/snoo/manifest.json +++ b/homeassistant/components/snoo/manifest.json @@ -7,5 +7,5 @@ "iot_class": "cloud_push", "loggers": ["snoo"], "quality_scale": "bronze", - "requirements": ["python-snoo==0.6.1"] + "requirements": ["python-snoo==0.6.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index 4b3af82580d..3a7c68939dd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2467,7 +2467,7 @@ python-roborock==2.12.2 python-smarttub==0.0.39 # homeassistant.components.snoo -python-snoo==0.6.1 +python-snoo==0.6.3 # homeassistant.components.songpal python-songpal==0.16.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 03c09b67778..37aeb6fdd00 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2000,7 +2000,7 @@ python-roborock==2.12.2 python-smarttub==0.0.39 # homeassistant.components.snoo -python-snoo==0.6.1 +python-snoo==0.6.3 # homeassistant.components.songpal python-songpal==0.16.2 From c0c997eed87bdf49ab3380eac756b64ec4985457 Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Fri, 21 Mar 2025 02:42:02 -0400 Subject: [PATCH 2846/3148] Bump python-snoo to 0.6.4 (#141030) --- homeassistant/components/snoo/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/snoo/manifest.json b/homeassistant/components/snoo/manifest.json index 0de1e6cf760..4084a7e3e79 100644 --- a/homeassistant/components/snoo/manifest.json +++ b/homeassistant/components/snoo/manifest.json @@ -7,5 +7,5 @@ "iot_class": "cloud_push", "loggers": ["snoo"], "quality_scale": "bronze", - "requirements": ["python-snoo==0.6.3"] + "requirements": ["python-snoo==0.6.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index 3a7c68939dd..0ed5f7ccb03 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2467,7 +2467,7 @@ python-roborock==2.12.2 python-smarttub==0.0.39 # homeassistant.components.snoo -python-snoo==0.6.3 +python-snoo==0.6.4 # homeassistant.components.songpal python-songpal==0.16.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 37aeb6fdd00..830fc17c6e0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2000,7 +2000,7 @@ python-roborock==2.12.2 python-smarttub==0.0.39 # homeassistant.components.snoo -python-snoo==0.6.3 +python-snoo==0.6.4 # homeassistant.components.songpal python-songpal==0.16.2 From bfabf972a892834f32b6a091dcd1674da357aa49 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 21 Mar 2025 19:35:24 +0000 Subject: [PATCH 2847/3148] Bump version to 2025.3.4 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index ce3c8225dfb..bd7a96e0e14 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -25,7 +25,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2025 MINOR_VERSION: Final = 3 -PATCH_VERSION: Final = "3" +PATCH_VERSION: Final = "4" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 13, 0) diff --git a/pyproject.toml b/pyproject.toml index a471379e28e..9c7508e2ebb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.3.3" +version = "2025.3.4" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 1b7e53fd0198382d6438c320270f1971f77ac36c Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Wed, 5 Mar 2025 00:45:58 +0100 Subject: [PATCH 2848/3148] Improve Home Connect appliances test fixture (#139787) Improve Home Connect appliances fixture --- tests/components/home_connect/__init__.py | 5 +- tests/components/home_connect/conftest.py | 211 ++++++++------- .../home_connect/fixtures/appliances.json | 240 +++++++++--------- .../home_connect/test_coordinator.py | 36 ++- 4 files changed, 267 insertions(+), 225 deletions(-) diff --git a/tests/components/home_connect/__init__.py b/tests/components/home_connect/__init__.py index 47a438fd218..8c256cb23f3 100644 --- a/tests/components/home_connect/__init__.py +++ b/tests/components/home_connect/__init__.py @@ -2,13 +2,10 @@ from typing import Any -from aiohomeconnect.model import ArrayOfHomeAppliances, ArrayOfStatus +from aiohomeconnect.model import ArrayOfStatus from tests.common import load_json_object_fixture -MOCK_APPLIANCES = ArrayOfHomeAppliances.from_dict( - load_json_object_fixture("home_connect/appliances.json")["data"] # type: ignore[arg-type] -) MOCK_PROGRAMS: dict[str, Any] = load_json_object_fixture("home_connect/programs.json") MOCK_SETTINGS: dict[str, Any] = load_json_object_fixture("home_connect/settings.json") MOCK_STATUS = ArrayOfStatus.from_dict( diff --git a/tests/components/home_connect/conftest.py b/tests/components/home_connect/conftest.py index 396fe8c5665..c0caf2b2bdd 100644 --- a/tests/components/home_connect/conftest.py +++ b/tests/components/home_connect/conftest.py @@ -11,6 +11,7 @@ from aiohomeconnect.client import Client as HomeConnectClient from aiohomeconnect.model import ( ArrayOfCommands, ArrayOfEvents, + ArrayOfHomeAppliances, ArrayOfOptions, ArrayOfPrograms, ArrayOfSettings, @@ -39,15 +40,9 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from . import ( - MOCK_APPLIANCES, - MOCK_AVAILABLE_COMMANDS, - MOCK_PROGRAMS, - MOCK_SETTINGS, - MOCK_STATUS, -) +from . import MOCK_AVAILABLE_COMMANDS, MOCK_PROGRAMS, MOCK_SETTINGS, MOCK_STATUS -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, load_fixture CLIENT_ID = "1234" CLIENT_SECRET = "5678" @@ -148,14 +143,6 @@ async def mock_integration_setup( return run -def _get_specific_appliance_side_effect(ha_id: str) -> HomeAppliance: - """Get specific appliance side effect.""" - for appliance in copy.deepcopy(MOCK_APPLIANCES).homeappliances: - if appliance.ha_id == ha_id: - return appliance - raise HomeConnectApiError("error.key", "error description") - - def _get_set_program_side_effect( event_queue: asyncio.Queue[list[EventMessage]], event_key: EventKey ): @@ -271,68 +258,12 @@ def _get_set_program_options_side_effect( return set_program_options_side_effect -async def _get_all_programs_side_effect(ha_id: str) -> ArrayOfPrograms: - """Get all programs.""" - appliance_type = next( - appliance - for appliance in MOCK_APPLIANCES.homeappliances - if appliance.ha_id == ha_id - ).type - if appliance_type not in MOCK_PROGRAMS: - raise HomeConnectApiError("error.key", "error description") - - return ArrayOfPrograms( - [ - EnumerateProgram.from_dict(program) - for program in MOCK_PROGRAMS[appliance_type]["data"]["programs"] - ], - Program.from_dict(MOCK_PROGRAMS[appliance_type]["data"]["programs"][0]), - Program.from_dict(MOCK_PROGRAMS[appliance_type]["data"]["programs"][0]), - ) - - -async def _get_settings_side_effect(ha_id: str) -> ArrayOfSettings: - """Get settings.""" - return ArrayOfSettings.from_dict( - MOCK_SETTINGS.get( - next( - appliance - for appliance in MOCK_APPLIANCES.homeappliances - if appliance.ha_id == ha_id - ).type, - {}, - ).get("data", {"settings": []}) - ) - - -async def _get_setting_side_effect(ha_id: str, setting_key: SettingKey): - """Get setting.""" - for appliance in MOCK_APPLIANCES.homeappliances: - if appliance.ha_id == ha_id: - settings = MOCK_SETTINGS.get( - next( - appliance - for appliance in MOCK_APPLIANCES.homeappliances - if appliance.ha_id == ha_id - ).type, - {}, - ).get("data", {"settings": []}) - for setting_dict in cast(list[dict], settings["settings"]): - if setting_dict["key"] == setting_key: - return GetSetting.from_dict(setting_dict) - raise HomeConnectApiError("error.key", "error description") - - -async def _get_available_commands_side_effect(ha_id: str) -> ArrayOfCommands: - """Get available commands.""" - for appliance in MOCK_APPLIANCES.homeappliances: - if appliance.ha_id == ha_id and appliance.type in MOCK_AVAILABLE_COMMANDS: - return ArrayOfCommands.from_dict(MOCK_AVAILABLE_COMMANDS[appliance.type]) - raise HomeConnectApiError("error.key", "error description") - - @pytest.fixture(name="client") -def mock_client(request: pytest.FixtureRequest) -> MagicMock: +def mock_client( + appliances: list[HomeAppliance], + appliance: HomeAppliance | None, + request: pytest.FixtureRequest, +) -> MagicMock: """Fixture to mock Client from HomeConnect.""" mock = MagicMock( @@ -369,17 +300,78 @@ def mock_client(request: pytest.FixtureRequest) -> MagicMock: ] ) + appliances = [appliance] if appliance else appliances + async def stream_all_events() -> AsyncGenerator[EventMessage]: """Mock stream_all_events.""" while True: for event in await event_queue.get(): yield event - mock.get_home_appliances = AsyncMock(return_value=copy.deepcopy(MOCK_APPLIANCES)) + mock.get_home_appliances = AsyncMock(return_value=ArrayOfHomeAppliances(appliances)) + + def _get_specific_appliance_side_effect(ha_id: str) -> HomeAppliance: + """Get specific appliance side effect.""" + for appliance_ in appliances: + if appliance_.ha_id == ha_id: + return appliance_ + raise HomeConnectApiError("error.key", "error description") + mock.get_specific_appliance = AsyncMock( side_effect=_get_specific_appliance_side_effect ) mock.stream_all_events = stream_all_events + + async def _get_all_programs_side_effect(ha_id: str) -> ArrayOfPrograms: + """Get all programs.""" + appliance_type = next( + appliance for appliance in appliances if appliance.ha_id == ha_id + ).type + if appliance_type not in MOCK_PROGRAMS: + raise HomeConnectApiError("error.key", "error description") + + return ArrayOfPrograms( + [ + EnumerateProgram.from_dict(program) + for program in MOCK_PROGRAMS[appliance_type]["data"]["programs"] + ], + Program.from_dict(MOCK_PROGRAMS[appliance_type]["data"]["programs"][0]), + Program.from_dict(MOCK_PROGRAMS[appliance_type]["data"]["programs"][0]), + ) + + async def _get_settings_side_effect(ha_id: str) -> ArrayOfSettings: + """Get settings.""" + return ArrayOfSettings.from_dict( + MOCK_SETTINGS.get( + next( + appliance for appliance in appliances if appliance.ha_id == ha_id + ).type, + {}, + ).get("data", {"settings": []}) + ) + + async def _get_setting_side_effect(ha_id: str, setting_key: SettingKey): + """Get setting.""" + for appliance_ in appliances: + if appliance_.ha_id == ha_id: + settings = MOCK_SETTINGS.get( + appliance_.type, + {}, + ).get("data", {"settings": []}) + for setting_dict in cast(list[dict], settings["settings"]): + if setting_dict["key"] == setting_key: + return GetSetting.from_dict(setting_dict) + raise HomeConnectApiError("error.key", "error description") + + async def _get_available_commands_side_effect(ha_id: str) -> ArrayOfCommands: + """Get available commands.""" + for appliance_ in appliances: + if appliance_.ha_id == ha_id and appliance_.type in MOCK_AVAILABLE_COMMANDS: + return ArrayOfCommands.from_dict( + MOCK_AVAILABLE_COMMANDS[appliance_.type] + ) + raise HomeConnectApiError("error.key", "error description") + mock.start_program = AsyncMock( side_effect=_get_set_program_side_effect( event_queue, EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM @@ -431,7 +423,11 @@ def mock_client(request: pytest.FixtureRequest) -> MagicMock: @pytest.fixture(name="client_with_exception") -def mock_client_with_exception(request: pytest.FixtureRequest) -> MagicMock: +def mock_client_with_exception( + appliances: list[HomeAppliance], + appliance: HomeAppliance | None, + request: pytest.FixtureRequest, +) -> MagicMock: """Fixture to mock Client from HomeConnect that raise exceptions.""" mock = MagicMock( autospec=HomeConnectClient, @@ -449,7 +445,8 @@ def mock_client_with_exception(request: pytest.FixtureRequest) -> MagicMock: for event in await event_queue.get(): yield event - mock.get_home_appliances = AsyncMock(return_value=copy.deepcopy(MOCK_APPLIANCES)) + appliances = [appliance] if appliance else appliances + mock.get_home_appliances = AsyncMock(return_value=ArrayOfHomeAppliances(appliances)) mock.stream_all_events = stream_all_events mock.start_program = AsyncMock(side_effect=exception) @@ -477,12 +474,52 @@ def mock_client_with_exception(request: pytest.FixtureRequest) -> MagicMock: @pytest.fixture(name="appliance_ha_id") -def mock_appliance_ha_id(request: pytest.FixtureRequest) -> str: - """Fixture to mock Appliance.""" - app = "Washer" +def mock_appliance_ha_id( + appliances: list[HomeAppliance], request: pytest.FixtureRequest +) -> str: + """Fixture to get the ha_id of an appliance.""" + appliance_type = "Washer" if hasattr(request, "param") and request.param: - app = request.param - for appliance in MOCK_APPLIANCES.homeappliances: - if appliance.type == app: + appliance_type = request.param + for appliance in appliances: + if appliance.type == appliance_type: return appliance.ha_id - raise ValueError(f"Appliance {app} not found") + raise ValueError(f"Appliance {appliance_type} not found") + + +@pytest.fixture(name="appliances") +def mock_appliances( + appliances_data: str, request: pytest.FixtureRequest +) -> list[HomeAppliance]: + """Fixture to mock the returned appliances.""" + appliances = ArrayOfHomeAppliances.from_json(appliances_data).homeappliances + appliance_types = {appliance.type for appliance in appliances} + if hasattr(request, "param") and request.param: + appliance_types = request.param + return [appliance for appliance in appliances if appliance.type in appliance_types] + + +@pytest.fixture(name="appliance") +def mock_appliance( + appliances_data: str, request: pytest.FixtureRequest +) -> HomeAppliance | None: + """Fixture to mock a single specific appliance to return.""" + appliance_type = None + if hasattr(request, "param") and request.param: + appliance_type = request.param + return next( + ( + appliance + for appliance in ArrayOfHomeAppliances.from_json( + appliances_data + ).homeappliances + if appliance.type == appliance_type + ), + None, + ) + + +@pytest.fixture(name="appliances_data") +def appliances_data_fixture() -> str: + """Fixture to return a the string for an array of appliances.""" + return load_fixture("appliances.json", integration=DOMAIN) diff --git a/tests/components/home_connect/fixtures/appliances.json b/tests/components/home_connect/fixtures/appliances.json index ada18b3482c..081dd44764f 100644 --- a/tests/components/home_connect/fixtures/appliances.json +++ b/tests/components/home_connect/fixtures/appliances.json @@ -1,123 +1,121 @@ { - "data": { - "homeappliances": [ - { - "name": "FridgeFreezer", - "brand": "SIEMENS", - "vib": "HCS05FRF1", - "connected": true, - "type": "FridgeFreezer", - "enumber": "HCS05FRF1/03", - "haId": "SIEMENS-HCS05FRF1-304F4F9E541D" - }, - { - "name": "Dishwasher", - "brand": "SIEMENS", - "vib": "HCS02DWH1", - "connected": true, - "type": "Dishwasher", - "enumber": "HCS02DWH1/03", - "haId": "SIEMENS-HCS02DWH1-6BE58C26DCC1" - }, - { - "name": "Oven", - "brand": "BOSCH", - "vib": "HCS01OVN1", - "connected": true, - "type": "Oven", - "enumber": "HCS01OVN1/03", - "haId": "BOSCH-HCS01OVN1-43E0065FE245" - }, - { - "name": "Washer", - "brand": "SIEMENS", - "vib": "HCS03WCH1", - "connected": true, - "type": "Washer", - "enumber": "HCS03WCH1/03", - "haId": "SIEMENS-HCS03WCH1-7BC6383CF794" - }, - { - "name": "Dryer", - "brand": "BOSCH", - "vib": "HCS04DYR1", - "connected": true, - "type": "Dryer", - "enumber": "HCS04DYR1/03", - "haId": "BOSCH-HCS04DYR1-831694AE3C5A" - }, - { - "name": "CoffeeMaker", - "brand": "BOSCH", - "vib": "HCS06COM1", - "connected": true, - "type": "CoffeeMaker", - "enumber": "HCS06COM1/03", - "haId": "BOSCH-HCS06COM1-D70390681C2C" - }, - { - "name": "WasherDryer", - "brand": "BOSCH", - "vib": "HCS000001", - "connected": true, - "type": "WasherDryer", - "enumber": "HCS000000/01", - "haId": "BOSCH-HCS000000-D00000000001" - }, - { - "name": "Refrigerator", - "brand": "BOSCH", - "vib": "HCS000002", - "connected": true, - "type": "Refrigerator", - "enumber": "HCS000000/02", - "haId": "BOSCH-HCS000000-D00000000002" - }, - { - "name": "Freezer", - "brand": "BOSCH", - "vib": "HCS000003", - "connected": true, - "type": "Freezer", - "enumber": "HCS000000/03", - "haId": "BOSCH-HCS000000-D00000000003" - }, - { - "name": "Hood", - "brand": "BOSCH", - "vib": "HCS000004", - "connected": true, - "type": "Hood", - "enumber": "HCS000000/04", - "haId": "BOSCH-HCS000000-D00000000004" - }, - { - "name": "Hob", - "brand": "BOSCH", - "vib": "HCS000005", - "connected": true, - "type": "Hob", - "enumber": "HCS000000/05", - "haId": "BOSCH-HCS000000-D00000000005" - }, - { - "name": "CookProcessor", - "brand": "BOSCH", - "vib": "HCS000006", - "connected": true, - "type": "CookProcessor", - "enumber": "HCS000000/06", - "haId": "BOSCH-HCS000000-D00000000006" - }, - { - "name": "DNE", - "brand": "BOSCH", - "vib": "HCS000000", - "connected": true, - "type": "DNE", - "enumber": "HCS000000/00", - "haId": "BOSCH-000000000-000000000000" - } - ] - } + "homeappliances": [ + { + "name": "FridgeFreezer", + "brand": "SIEMENS", + "vib": "HCS05FRF1", + "connected": true, + "type": "FridgeFreezer", + "enumber": "HCS05FRF1/03", + "haId": "SIEMENS-HCS05FRF1-304F4F9E541D" + }, + { + "name": "Dishwasher", + "brand": "SIEMENS", + "vib": "HCS02DWH1", + "connected": true, + "type": "Dishwasher", + "enumber": "HCS02DWH1/03", + "haId": "SIEMENS-HCS02DWH1-6BE58C26DCC1" + }, + { + "name": "Oven", + "brand": "BOSCH", + "vib": "HCS01OVN1", + "connected": true, + "type": "Oven", + "enumber": "HCS01OVN1/03", + "haId": "BOSCH-HCS01OVN1-43E0065FE245" + }, + { + "name": "Washer", + "brand": "SIEMENS", + "vib": "HCS03WCH1", + "connected": true, + "type": "Washer", + "enumber": "HCS03WCH1/03", + "haId": "SIEMENS-HCS03WCH1-7BC6383CF794" + }, + { + "name": "Dryer", + "brand": "BOSCH", + "vib": "HCS04DYR1", + "connected": true, + "type": "Dryer", + "enumber": "HCS04DYR1/03", + "haId": "BOSCH-HCS04DYR1-831694AE3C5A" + }, + { + "name": "CoffeeMaker", + "brand": "BOSCH", + "vib": "HCS06COM1", + "connected": true, + "type": "CoffeeMaker", + "enumber": "HCS06COM1/03", + "haId": "BOSCH-HCS06COM1-D70390681C2C" + }, + { + "name": "WasherDryer", + "brand": "BOSCH", + "vib": "HCS000001", + "connected": true, + "type": "WasherDryer", + "enumber": "HCS000000/01", + "haId": "BOSCH-HCS000000-D00000000001" + }, + { + "name": "Refrigerator", + "brand": "BOSCH", + "vib": "HCS000002", + "connected": true, + "type": "Refrigerator", + "enumber": "HCS000000/02", + "haId": "BOSCH-HCS000000-D00000000002" + }, + { + "name": "Freezer", + "brand": "BOSCH", + "vib": "HCS000003", + "connected": true, + "type": "Freezer", + "enumber": "HCS000000/03", + "haId": "BOSCH-HCS000000-D00000000003" + }, + { + "name": "Hood", + "brand": "BOSCH", + "vib": "HCS000004", + "connected": true, + "type": "Hood", + "enumber": "HCS000000/04", + "haId": "BOSCH-HCS000000-D00000000004" + }, + { + "name": "Hob", + "brand": "BOSCH", + "vib": "HCS000005", + "connected": true, + "type": "Hob", + "enumber": "HCS000000/05", + "haId": "BOSCH-HCS000000-D00000000005" + }, + { + "name": "CookProcessor", + "brand": "BOSCH", + "vib": "HCS000006", + "connected": true, + "type": "CookProcessor", + "enumber": "HCS000000/06", + "haId": "BOSCH-HCS000000-D00000000006" + }, + { + "name": "DNE", + "brand": "BOSCH", + "vib": "HCS000000", + "connected": true, + "type": "DNE", + "enumber": "HCS000000/00", + "haId": "BOSCH-000000000-000000000000" + } + ] } diff --git a/tests/components/home_connect/test_coordinator.py b/tests/components/home_connect/test_coordinator.py index 0c9ff7842b7..84bef94d658 100644 --- a/tests/components/home_connect/test_coordinator.py +++ b/tests/components/home_connect/test_coordinator.py @@ -1,19 +1,20 @@ """Test for Home Connect coordinator.""" from collections.abc import Awaitable, Callable -import copy from datetime import timedelta -from typing import Any +from typing import Any, cast from unittest.mock import AsyncMock, MagicMock, patch from aiohomeconnect.model import ( ArrayOfEvents, + ArrayOfHomeAppliances, ArrayOfSettings, ArrayOfStatus, Event, EventKey, EventMessage, EventType, + HomeAppliance, ) from aiohomeconnect.model.error import ( EventStreamInterruptedError, @@ -42,8 +43,6 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util -from . import MOCK_APPLIANCES - from tests.common import MockConfigEntry, async_fire_time_changed @@ -82,16 +81,21 @@ async def test_coordinator_update_failing_get_appliances( @pytest.mark.usefixtures("setup_credentials") @pytest.mark.parametrize("platforms", [("binary_sensor",)]) -@pytest.mark.parametrize("appliance_ha_id", ["Washer"], indirect=True) +@pytest.mark.parametrize("appliance", ["Washer"], indirect=True) async def test_coordinator_failure_refresh_and_stream( hass: HomeAssistant, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], client: MagicMock, freezer: FrozenDateTimeFactory, - appliance_ha_id: str, + appliance: HomeAppliance, ) -> None: """Test entity available state via coordinator refresh and event stream.""" + appliance_data = ( + cast(str, appliance.to_json()) + .replace("ha_id", "haId") + .replace("e_number", "enumber") + ) entity_id_1 = "binary_sensor.washer_remote_control" entity_id_2 = "binary_sensor.washer_remote_start" await async_setup_component(hass, "homeassistant", {}) @@ -122,7 +126,9 @@ async def test_coordinator_failure_refresh_and_stream( # Test that the entity becomes available again after a successful update. client.get_home_appliances.side_effect = None - client.get_home_appliances.return_value = copy.deepcopy(MOCK_APPLIANCES) + client.get_home_appliances.return_value = ArrayOfHomeAppliances( + [HomeAppliance.from_json(appliance_data)] + ) # Move time forward to pass the debounce time. freezer.tick(timedelta(hours=1)) @@ -167,11 +173,13 @@ async def test_coordinator_failure_refresh_and_stream( # Now make the entity available again. client.get_home_appliances.side_effect = None - client.get_home_appliances.return_value = copy.deepcopy(MOCK_APPLIANCES) + client.get_home_appliances.return_value = ArrayOfHomeAppliances( + [HomeAppliance.from_json(appliance_data)] + ) # One event should make all entities for this appliance available again. event_message = EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.STATUS, ArrayOfEvents( [ @@ -400,6 +408,9 @@ async def test_event_listener_error( assert not config_entry._background_tasks +@pytest.mark.usefixtures("setup_credentials") +@pytest.mark.parametrize("platforms", [("sensor",)]) +@pytest.mark.parametrize("appliance", ["Washer"], indirect=True) @pytest.mark.parametrize( "exception", [HomeConnectRequestError(), EventStreamInterruptedError()], @@ -430,11 +441,10 @@ async def test_event_listener_resilience( after_event_expected_state: str, exception: HomeConnectError, hass: HomeAssistant, + appliance: HomeAppliance, + client: MagicMock, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, - appliance_ha_id: str, ) -> None: """Test that the event listener is resilient to interruptions.""" future = hass.loop.create_future() @@ -468,7 +478,7 @@ async def test_event_listener_resilience( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.STATUS, ArrayOfEvents( [ From 84c6fa256cb35e2afecee5b2d43034962f91283a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 21 Mar 2025 21:44:33 +0100 Subject: [PATCH 2849/3148] Bump home-assistant/builder from 2025.02.0 to 2025.03.0 (#141039) Bumps [home-assistant/builder](https://github.com/home-assistant/builder) from 2025.02.0 to 2025.03.0. - [Release notes](https://github.com/home-assistant/builder/releases) - [Commits](https://github.com/home-assistant/builder/compare/2025.02.0...2025.03.0) --- updated-dependencies: - dependency-name: home-assistant/builder dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/builder.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index 03c38c60a10..fcf707fef3d 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -197,7 +197,7 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Build base image - uses: home-assistant/builder@2025.02.0 + uses: home-assistant/builder@2025.03.0 with: args: | $BUILD_ARGS \ @@ -263,7 +263,7 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Build base image - uses: home-assistant/builder@2025.02.0 + uses: home-assistant/builder@2025.03.0 with: args: | $BUILD_ARGS \ From 2571725eb93be9c58203ccccc7cbbf4e0476bcea Mon Sep 17 00:00:00 2001 From: tdfountain <174762217+tdfountain@users.noreply.github.com> Date: Fri, 21 Mar 2025 14:41:43 -0700 Subject: [PATCH 2850/3148] Add metered PDU dynamic outlet sensors to NUT (#140179) * Add metered PDU dynamic outlet sensors * Make deep copy and improve efficiency of loops * Improve performance by creating new dict Co-authored-by: J. Nick Koston * Remove unused import copy * Use outlet name (if available) in friendly name and remove as separate sensor --------- Co-authored-by: J. Nick Koston --- homeassistant/components/nut/icons.json | 15 +++++ homeassistant/components/nut/sensor.py | 72 +++++++++++++++++++--- homeassistant/components/nut/strings.json | 7 +++ tests/components/nut/test_sensor.py | 73 ++++++++++++++++++++--- tests/components/nut/util.py | 5 +- 5 files changed, 153 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/nut/icons.json b/homeassistant/components/nut/icons.json index 261d28d712f..bfd9407bb6c 100644 --- a/homeassistant/components/nut/icons.json +++ b/homeassistant/components/nut/icons.json @@ -67,6 +67,21 @@ "input_voltage_status": { "default": "mdi:information-outline" }, + "outlet_number_current": { + "default": "mdi:gauge" + }, + "outlet_number_current_status": { + "default": "mdi:information-outline" + }, + "outlet_number_desc": { + "default": "mdi:information-outline" + }, + "outlet_number_power": { + "default": "mdi:gauge" + }, + "outlet_number_realpower": { + "default": "mdi:gauge" + }, "outlet_voltage": { "default": "mdi:gauge" }, diff --git a/homeassistant/components/nut/sensor.py b/homeassistant/components/nut/sensor.py index 1484f11dac7..ceea426c06d 100644 --- a/homeassistant/components/nut/sensor.py +++ b/homeassistant/components/nut/sensor.py @@ -1029,6 +1029,7 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the NUT sensors.""" + valid_sensor_types: dict[str, SensorEntityDescription] pynut_data = config_entry.runtime_data coordinator = pynut_data.coordinator @@ -1036,20 +1037,75 @@ async def async_setup_entry( unique_id = pynut_data.unique_id status = coordinator.data - resources = [sensor_id for sensor_id in SENSOR_TYPES if sensor_id in status] - # Display status is a special case that falls back to the status value - # of the UPS instead. - if KEY_STATUS in resources: - resources.append(KEY_STATUS_DISPLAY) + # Dynamically add outlet sensors to valid sensors dictionary + if (num_outlets := status.get("outlet.count")) is not None: + additional_sensor_types: dict[str, SensorEntityDescription] = {} + for outlet_num in range(1, int(num_outlets) + 1): + outlet_num_str: str = str(outlet_num) + outlet_name: str = ( + status.get(f"outlet.{outlet_num_str}.name") or outlet_num_str + ) + additional_sensor_types |= { + f"outlet.{outlet_num_str}.current": SensorEntityDescription( + key=f"outlet.{outlet_num_str}.current", + translation_key="outlet_number_current", + translation_placeholders={"outlet_name": outlet_name}, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + ), + f"outlet.{outlet_num_str}.current_status": SensorEntityDescription( + key=f"outlet.{outlet_num_str}.current_status", + translation_key="outlet_number_current_status", + translation_placeholders={"outlet_name": outlet_name}, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + f"outlet.{outlet_num_str}.desc": SensorEntityDescription( + key=f"outlet.{outlet_num_str}.desc", + translation_key="outlet_number_desc", + translation_placeholders={"outlet_name": outlet_name}, + ), + f"outlet.{outlet_num_str}.power": SensorEntityDescription( + key=f"outlet.{outlet_num_str}.power", + translation_key="outlet_number_power", + translation_placeholders={"outlet_name": outlet_name}, + native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE, + device_class=SensorDeviceClass.APPARENT_POWER, + state_class=SensorStateClass.MEASUREMENT, + ), + f"outlet.{outlet_num_str}.realpower": SensorEntityDescription( + key=f"outlet.{outlet_num_str}.realpower", + translation_key="outlet_number_realpower", + translation_placeholders={"outlet_name": outlet_name}, + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), + } + + valid_sensor_types = {**SENSOR_TYPES, **additional_sensor_types} + else: + valid_sensor_types = SENSOR_TYPES # If device reports ambient sensors are not present, then remove - if status.get(AMBIENT_PRESENT) == "no": - resources = [item for item in resources if item not in AMBIENT_SENSORS] + has_ambient_sensors: bool = status.get(AMBIENT_PRESENT) != "no" + resources = [ + sensor_id + for sensor_id in valid_sensor_types + if sensor_id in status + and (has_ambient_sensors or sensor_id not in AMBIENT_SENSORS) + ] + + # Display status is a special case that falls back to the status value + # of the UPS instead. + if KEY_STATUS in status: + resources.append(KEY_STATUS_DISPLAY) async_add_entities( NUTSensor( coordinator, - SENSOR_TYPES[sensor_type], + valid_sensor_types[sensor_type], data, unique_id, ) diff --git a/homeassistant/components/nut/strings.json b/homeassistant/components/nut/strings.json index 08971732bc6..76d6f6df0b7 100644 --- a/homeassistant/components/nut/strings.json +++ b/homeassistant/components/nut/strings.json @@ -157,6 +157,13 @@ "input_l1_n_voltage": { "name": "Input L1 voltage" }, "input_l2_n_voltage": { "name": "Input L2 voltage" }, "input_l3_n_voltage": { "name": "Input L3 voltage" }, + "outlet_number_current": { "name": "Outlet {outlet_name} current" }, + "outlet_number_current_status": { + "name": "Outlet {outlet_name} current status" + }, + "outlet_number_desc": { "name": "Outlet {outlet_name} description" }, + "outlet_number_power": { "name": "Outlet {outlet_name} power" }, + "outlet_number_realpower": { "name": "Outlet {outlet_name} real power" }, "outlet_voltage": { "name": "Outlet voltage" }, "output_current": { "name": "Output current" }, "output_current_nominal": { "name": "Nominal output current" }, diff --git a/tests/components/nut/test_sensor.py b/tests/components/nut/test_sensor.py index 6483d581070..cdec6c5083b 100644 --- a/tests/components/nut/test_sensor.py +++ b/tests/components/nut/test_sensor.py @@ -12,6 +12,7 @@ from homeassistant.const import ( CONF_RESOURCES, PERCENTAGE, STATE_UNKNOWN, + UnitOfElectricCurrent, UnitOfElectricPotential, ) from homeassistant.core import HomeAssistant @@ -103,7 +104,7 @@ async def test_ups_devices_with_unique_ids( [ ( "EATON-EPDU-G3", - "EATON_ePDU MA 00U-C IN: TYPE 00A 0P OUT: 00xTYPE_A000A00000_", + "EATON_ePDU MA 00U-C IN: TYPE 00A 0P OUT: 00xTYPE_A000A00000", ), ], ) @@ -115,11 +116,13 @@ async def test_pdu_devices_with_unique_ids( ) -> None: """Test creation of device sensors with unique ids.""" - await _test_sensor_and_attributes( + await async_init_integration(hass, model) + + _test_sensor_and_attributes( hass, entity_registry, model, - unique_id=f"{unique_id_base}input.voltage", + unique_id=f"{unique_id_base}_input.voltage", device_id="sensor.ups1_input_voltage", state_value="122.91", expected_attributes={ @@ -130,11 +133,11 @@ async def test_pdu_devices_with_unique_ids( }, ) - await _test_sensor_and_attributes( + _test_sensor_and_attributes( hass, entity_registry, model, - unique_id=f"{unique_id_base}ambient.humidity.status", + unique_id=f"{unique_id_base}_ambient.humidity.status", device_id="sensor.ups1_ambient_humidity_status", state_value="good", expected_attributes={ @@ -143,11 +146,11 @@ async def test_pdu_devices_with_unique_ids( }, ) - await _test_sensor_and_attributes( + _test_sensor_and_attributes( hass, entity_registry, model, - unique_id=f"{unique_id_base}ambient.temperature.status", + unique_id=f"{unique_id_base}_ambient.temperature.status", device_id="sensor.ups1_ambient_temperature_status", state_value="good", expected_attributes={ @@ -248,7 +251,7 @@ async def test_stale_options( [ ( "EATON-EPDU-G3-AMBIENT-NOT-PRESENT", - "EATON_ePDU MA 00U-C IN: TYPE 00A 0P OUT: 00xTYPE_A000A00000_", + "EATON_ePDU MA 00U-C IN: TYPE 00A 0P OUT: 00xTYPE_A000A00000", ), ], ) @@ -273,3 +276,57 @@ async def test_pdu_devices_ambient_not_present( entry = entity_registry.async_get("sensor.ups1_ambient_temperature_status") assert not entry + + +@pytest.mark.parametrize( + ("model", "unique_id_base"), + [ + ( + "EATON-EPDU-G3", + "EATON_ePDU MA 00U-C IN: TYPE 00A 0P OUT: 00xTYPE_A000A00000", + ), + ], +) +async def test_pdu_dynamic_outlets( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + model: str, + unique_id_base: str, +) -> None: + """Test for dynamically created outlet sensors.""" + + await async_init_integration(hass, model) + + _test_sensor_and_attributes( + hass, + entity_registry, + model, + unique_id=f"{unique_id_base}_outlet.1.current", + device_id="sensor.ups1_outlet_a1_current", + state_value="0", + expected_attributes={ + "device_class": SensorDeviceClass.CURRENT, + "friendly_name": "Ups1 Outlet A1 current", + "unit_of_measurement": UnitOfElectricCurrent.AMPERE, + }, + ) + + _test_sensor_and_attributes( + hass, + entity_registry, + model, + unique_id=f"{unique_id_base}_outlet.24.current", + device_id="sensor.ups1_outlet_a24_current", + state_value="0.19", + expected_attributes={ + "device_class": SensorDeviceClass.CURRENT, + "friendly_name": "Ups1 Outlet A24 current", + "unit_of_measurement": UnitOfElectricCurrent.AMPERE, + }, + ) + + entry = entity_registry.async_get("sensor.ups1_outlet_25_current") + assert not entry + + entry = entity_registry.async_get("sensor.ups1_outlet_a25_current") + assert not entry diff --git a/tests/components/nut/util.py b/tests/components/nut/util.py index bd82ffdd6b4..07c073f0286 100644 --- a/tests/components/nut/util.py +++ b/tests/components/nut/util.py @@ -82,7 +82,7 @@ async def async_init_integration( return entry -async def _test_sensor_and_attributes( +def _test_sensor_and_attributes( hass: HomeAssistant, entity_registry: er.EntityRegistry, model: str, @@ -91,9 +91,8 @@ async def _test_sensor_and_attributes( state_value: str, expected_attributes: dict, ) -> None: - """Test creation of device sensors with unique ids.""" + """Test all of the sensor entry attributes.""" - await async_init_integration(hass, model) entry = entity_registry.async_get(device_id) assert entry assert entry.unique_id == unique_id From 6027a26761986983af7486d7f3957f2f8e3ae2e5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 21 Mar 2025 11:50:18 -1000 Subject: [PATCH 2851/3148] Add SSLContext.set_default_verify_paths to asyncio blocking detection (#140648) This one loads a significant number of files from /etc/ssl --- homeassistant/block_async_io.py | 9 +++++++++ tests/test_block_async_io.py | 11 +++++++++++ 2 files changed, 20 insertions(+) diff --git a/homeassistant/block_async_io.py b/homeassistant/block_async_io.py index d224b0b151d..eb81268434b 100644 --- a/homeassistant/block_async_io.py +++ b/homeassistant/block_async_io.py @@ -178,6 +178,15 @@ _BLOCKING_CALLS: tuple[BlockingCall, ...] = ( strict_core=False, skip_for_tests=True, ), + BlockingCall( + original_func=SSLContext.set_default_verify_paths, + object=SSLContext, + function="set_default_verify_paths", + check_allowed=None, + strict=False, + strict_core=False, + skip_for_tests=True, + ), BlockingCall( original_func=Path.open, object=Path, diff --git a/tests/test_block_async_io.py b/tests/test_block_async_io.py index f42fbb9f4ef..337e5500718 100644 --- a/tests/test_block_async_io.py +++ b/tests/test_block_async_io.py @@ -459,3 +459,14 @@ async def test_open_calls_ignored_in_tests(caplog: pytest.LogCaptureFixture) -> pass assert "Detected blocking call to open with args" not in caplog.text + + +async def test_protect_loop_set_default_verify_paths( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test SSLContext.set_default_verify_paths calls in the loop are logged.""" + with patch.object(block_async_io, "_IN_TESTS", False): + block_async_io.enable() + context = ssl.create_default_context() + context.set_default_verify_paths() + assert "Detected blocking call to set_default_verify_paths" in caplog.text From 34318ab655b02a8a34ae5d5037fa9f9d85085d64 Mon Sep 17 00:00:00 2001 From: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> Date: Fri, 21 Mar 2025 17:19:05 -0500 Subject: [PATCH 2852/3148] Bump pyheos to 1.0.4 (#141091) --- homeassistant/components/heos/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/heos/manifest.json b/homeassistant/components/heos/manifest.json index 19feffd8ef1..cbac9f20574 100644 --- a/homeassistant/components/heos/manifest.json +++ b/homeassistant/components/heos/manifest.json @@ -8,7 +8,7 @@ "iot_class": "local_push", "loggers": ["pyheos"], "quality_scale": "platinum", - "requirements": ["pyheos==1.0.3"], + "requirements": ["pyheos==1.0.4"], "ssdp": [ { "st": "urn:schemas-denon-com:device:ACT-Denon:1" diff --git a/requirements_all.txt b/requirements_all.txt index e45155eb492..ab25d9571a8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2000,7 +2000,7 @@ pygti==0.9.4 pyhaversion==22.8.0 # homeassistant.components.heos -pyheos==1.0.3 +pyheos==1.0.4 # homeassistant.components.hive pyhive-integration==1.0.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ac047685724..f5b42042d81 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1626,7 +1626,7 @@ pygti==0.9.4 pyhaversion==22.8.0 # homeassistant.components.heos -pyheos==1.0.3 +pyheos==1.0.4 # homeassistant.components.hive pyhive-integration==1.0.2 From ffd5c003cb9d2bfd279987435fda871adae6c027 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Sat, 22 Mar 2025 10:48:35 +0100 Subject: [PATCH 2853/3148] Remove Home Connect service error string constants (#141102) --- .../components/home_connect/__init__.py | 19 ++++-------- .../components/home_connect/const.py | 7 ----- .../components/home_connect/light.py | 18 +++++------ .../components/home_connect/number.py | 17 +++------- .../components/home_connect/select.py | 15 +++------ .../components/home_connect/switch.py | 31 ++++++------------- homeassistant/components/home_connect/time.py | 16 +++------- 7 files changed, 38 insertions(+), 85 deletions(-) diff --git a/homeassistant/components/home_connect/__init__.py b/homeassistant/components/home_connect/__init__.py index 70b357518da..83de76431f9 100644 --- a/homeassistant/components/home_connect/__init__.py +++ b/homeassistant/components/home_connect/__init__.py @@ -60,9 +60,6 @@ from .const import ( SERVICE_SET_PROGRAM_AND_OPTIONS, SERVICE_SETTING, SERVICE_START_PROGRAM, - SVE_TRANSLATION_PLACEHOLDER_KEY, - SVE_TRANSLATION_PLACEHOLDER_PROGRAM, - SVE_TRANSLATION_PLACEHOLDER_VALUE, TRANSLATION_KEYS_PROGRAMS_MAP, ) from .coordinator import HomeConnectConfigEntry, HomeConnectCoordinator @@ -336,7 +333,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: translation_key="start_program" if start else "select_program", translation_placeholders={ **get_dict_from_home_connect_error(err), - SVE_TRANSLATION_PLACEHOLDER_PROGRAM: program, + "program": program, }, ) from err @@ -410,8 +407,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: else "set_options_selected_program", translation_placeholders={ **get_dict_from_home_connect_error(err), - SVE_TRANSLATION_PLACEHOLDER_KEY: option_key, - SVE_TRANSLATION_PLACEHOLDER_VALUE: str(value), + "key": option_key, + "value": str(value), }, ) from err @@ -466,8 +463,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: translation_key="set_setting", translation_placeholders={ **get_dict_from_home_connect_error(err), - SVE_TRANSLATION_PLACEHOLDER_KEY: key, - SVE_TRANSLATION_PLACEHOLDER_VALUE: str(value), + "key": key, + "value": str(value), }, ) from err @@ -545,11 +542,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: translation_key=exception_translation_key, translation_placeholders={ **get_dict_from_home_connect_error(err), - **( - {SVE_TRANSLATION_PLACEHOLDER_PROGRAM: program} - if program - else {} - ), + **({"program": program} if program else {}), }, ) from err diff --git a/homeassistant/components/home_connect/const.py b/homeassistant/components/home_connect/const.py index 6255a513e39..64bf4af29a4 100644 --- a/homeassistant/components/home_connect/const.py +++ b/homeassistant/components/home_connect/const.py @@ -79,13 +79,6 @@ ATTR_VALUE = "value" AFFECTS_TO_ACTIVE_PROGRAM = "active_program" AFFECTS_TO_SELECTED_PROGRAM = "selected_program" -SVE_TRANSLATION_KEY_SET_SETTING = "set_setting_entity" -SVE_TRANSLATION_PLACEHOLDER_APPLIANCE_NAME = "appliance_name" -SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID = "entity_id" -SVE_TRANSLATION_PLACEHOLDER_PROGRAM = "program" -SVE_TRANSLATION_PLACEHOLDER_KEY = "key" -SVE_TRANSLATION_PLACEHOLDER_VALUE = "value" - TRANSLATION_KEYS_PROGRAMS_MAP = { bsh_key_to_translation_key(program.value): cast(ProgramKey, program) diff --git a/homeassistant/components/home_connect/light.py b/homeassistant/components/home_connect/light.py index 72c6b9aaa2b..707620f099a 100644 --- a/homeassistant/components/home_connect/light.py +++ b/homeassistant/components/home_connect/light.py @@ -21,11 +21,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import color as color_util from .common import setup_home_connect_entry -from .const import ( - BSH_AMBIENT_LIGHT_COLOR_CUSTOM_COLOR, - DOMAIN, - SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID, -) +from .const import BSH_AMBIENT_LIGHT_COLOR_CUSTOM_COLOR, DOMAIN from .coordinator import ( HomeConnectApplianceData, HomeConnectConfigEntry, @@ -164,7 +160,7 @@ class HomeConnectLight(HomeConnectEntity, LightEntity): translation_key="turn_on_light", translation_placeholders={ **get_dict_from_home_connect_error(err), - SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID: self.entity_id, + "entity_id": self.entity_id, }, ) from err if self._color_key and self._custom_color_key: @@ -183,7 +179,7 @@ class HomeConnectLight(HomeConnectEntity, LightEntity): translation_key="select_light_custom_color", translation_placeholders={ **get_dict_from_home_connect_error(err), - SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID: self.entity_id, + "entity_id": self.entity_id, }, ) from err @@ -201,7 +197,7 @@ class HomeConnectLight(HomeConnectEntity, LightEntity): translation_key="set_light_color", translation_placeholders={ **get_dict_from_home_connect_error(err), - SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID: self.entity_id, + "entity_id": self.entity_id, }, ) from err return @@ -231,7 +227,7 @@ class HomeConnectLight(HomeConnectEntity, LightEntity): translation_key="set_light_color", translation_placeholders={ **get_dict_from_home_connect_error(err), - SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID: self.entity_id, + "entity_id": self.entity_id, }, ) from err return @@ -254,7 +250,7 @@ class HomeConnectLight(HomeConnectEntity, LightEntity): translation_key="set_light_brightness", translation_placeholders={ **get_dict_from_home_connect_error(err), - SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID: self.entity_id, + "entity_id": self.entity_id, }, ) from err @@ -272,7 +268,7 @@ class HomeConnectLight(HomeConnectEntity, LightEntity): translation_key="turn_off_light", translation_placeholders={ **get_dict_from_home_connect_error(err), - SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID: self.entity_id, + "entity_id": self.entity_id, }, ) from err diff --git a/homeassistant/components/home_connect/number.py b/homeassistant/components/home_connect/number.py index db0258f2739..99fe6c17296 100644 --- a/homeassistant/components/home_connect/number.py +++ b/homeassistant/components/home_connect/number.py @@ -16,14 +16,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .common import setup_home_connect_entry -from .const import ( - DOMAIN, - SVE_TRANSLATION_KEY_SET_SETTING, - SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID, - SVE_TRANSLATION_PLACEHOLDER_KEY, - SVE_TRANSLATION_PLACEHOLDER_VALUE, - UNIT_MAP, -) +from .const import DOMAIN, UNIT_MAP from .coordinator import HomeConnectApplianceData, HomeConnectConfigEntry from .entity import HomeConnectEntity, HomeConnectOptionEntity, constraint_fetcher from .utils import get_dict_from_home_connect_error @@ -180,12 +173,12 @@ class HomeConnectNumberEntity(HomeConnectEntity, NumberEntity): except HomeConnectError as err: raise HomeAssistantError( translation_domain=DOMAIN, - translation_key=SVE_TRANSLATION_KEY_SET_SETTING, + translation_key="set_setting_entity", translation_placeholders={ **get_dict_from_home_connect_error(err), - SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID: self.entity_id, - SVE_TRANSLATION_PLACEHOLDER_KEY: self.bsh_key, - SVE_TRANSLATION_PLACEHOLDER_VALUE: str(value), + "entity_id": self.entity_id, + "key": self.bsh_key, + "value": str(value), }, ) from err diff --git a/homeassistant/components/home_connect/select.py b/homeassistant/components/home_connect/select.py index 001c2e9ec31..c82e0686cb5 100644 --- a/homeassistant/components/home_connect/select.py +++ b/homeassistant/components/home_connect/select.py @@ -31,11 +31,6 @@ from .const import ( INTENSIVE_LEVEL_OPTIONS, PROGRAMS_TRANSLATION_KEYS_MAP, SPIN_SPEED_OPTIONS, - SVE_TRANSLATION_KEY_SET_SETTING, - SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID, - SVE_TRANSLATION_PLACEHOLDER_KEY, - SVE_TRANSLATION_PLACEHOLDER_PROGRAM, - SVE_TRANSLATION_PLACEHOLDER_VALUE, TEMPERATURE_OPTIONS, TRANSLATION_KEYS_PROGRAMS_MAP, VARIO_PERFECT_OPTIONS, @@ -406,7 +401,7 @@ class HomeConnectProgramSelectEntity(HomeConnectEntity, SelectEntity): translation_key=self.entity_description.error_translation_key, translation_placeholders={ **get_dict_from_home_connect_error(err), - SVE_TRANSLATION_PLACEHOLDER_PROGRAM: program_key.value, + "program": program_key.value, }, ) from err @@ -443,12 +438,12 @@ class HomeConnectSelectEntity(HomeConnectEntity, SelectEntity): except HomeConnectError as err: raise HomeAssistantError( translation_domain=DOMAIN, - translation_key=SVE_TRANSLATION_KEY_SET_SETTING, + translation_key="set_setting_entity", translation_placeholders={ **get_dict_from_home_connect_error(err), - SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID: self.entity_id, - SVE_TRANSLATION_PLACEHOLDER_KEY: self.bsh_key, - SVE_TRANSLATION_PLACEHOLDER_VALUE: value, + "entity_id": self.entity_id, + "key": self.bsh_key, + "value": value, }, ) from err diff --git a/homeassistant/components/home_connect/switch.py b/homeassistant/components/home_connect/switch.py index 6f9aa0e679f..33e30f184b7 100644 --- a/homeassistant/components/home_connect/switch.py +++ b/homeassistant/components/home_connect/switch.py @@ -22,16 +22,7 @@ from homeassistant.helpers.issue_registry import ( from homeassistant.helpers.typing import UNDEFINED, UndefinedType from .common import setup_home_connect_entry -from .const import ( - BSH_POWER_OFF, - BSH_POWER_ON, - BSH_POWER_STANDBY, - DOMAIN, - SVE_TRANSLATION_PLACEHOLDER_APPLIANCE_NAME, - SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID, - SVE_TRANSLATION_PLACEHOLDER_KEY, - SVE_TRANSLATION_PLACEHOLDER_VALUE, -) +from .const import BSH_POWER_OFF, BSH_POWER_ON, BSH_POWER_STANDBY, DOMAIN from .coordinator import ( HomeConnectApplianceData, HomeConnectConfigEntry, @@ -226,8 +217,8 @@ class HomeConnectSwitch(HomeConnectEntity, SwitchEntity): translation_key="turn_on", translation_placeholders={ **get_dict_from_home_connect_error(err), - SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID: self.entity_id, - SVE_TRANSLATION_PLACEHOLDER_KEY: self.bsh_key, + "entity_id": self.entity_id, + "key": self.bsh_key, }, ) from err @@ -246,8 +237,8 @@ class HomeConnectSwitch(HomeConnectEntity, SwitchEntity): translation_key="turn_off", translation_placeholders={ **get_dict_from_home_connect_error(err), - SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID: self.entity_id, - SVE_TRANSLATION_PLACEHOLDER_KEY: self.bsh_key, + "entity_id": self.entity_id, + "key": self.bsh_key, }, ) from err @@ -385,7 +376,7 @@ class HomeConnectPowerSwitch(HomeConnectEntity, SwitchEntity): translation_key="power_on", translation_placeholders={ **get_dict_from_home_connect_error(err), - SVE_TRANSLATION_PLACEHOLDER_APPLIANCE_NAME: self.appliance.info.name, + "appliance_name": self.appliance.info.name, }, ) from err @@ -398,7 +389,7 @@ class HomeConnectPowerSwitch(HomeConnectEntity, SwitchEntity): translation_domain=DOMAIN, translation_key="unable_to_retrieve_turn_off", translation_placeholders={ - SVE_TRANSLATION_PLACEHOLDER_APPLIANCE_NAME: self.appliance.info.name + "appliance_name": self.appliance.info.name }, ) @@ -406,9 +397,7 @@ class HomeConnectPowerSwitch(HomeConnectEntity, SwitchEntity): raise HomeAssistantError( translation_domain=DOMAIN, translation_key="turn_off_not_supported", - translation_placeholders={ - SVE_TRANSLATION_PLACEHOLDER_APPLIANCE_NAME: self.appliance.info.name - }, + translation_placeholders={"appliance_name": self.appliance.info.name}, ) try: await self.coordinator.client.set_setting( @@ -423,8 +412,8 @@ class HomeConnectPowerSwitch(HomeConnectEntity, SwitchEntity): translation_key="power_off", translation_placeholders={ **get_dict_from_home_connect_error(err), - SVE_TRANSLATION_PLACEHOLDER_APPLIANCE_NAME: self.appliance.info.name, - SVE_TRANSLATION_PLACEHOLDER_VALUE: self.power_off_state, + "appliance_name": self.appliance.info.name, + "value": self.power_off_state, }, ) from err diff --git a/homeassistant/components/home_connect/time.py b/homeassistant/components/home_connect/time.py index a1761219d30..7cfa0a7d3e4 100644 --- a/homeassistant/components/home_connect/time.py +++ b/homeassistant/components/home_connect/time.py @@ -12,13 +12,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .common import setup_home_connect_entry -from .const import ( - DOMAIN, - SVE_TRANSLATION_KEY_SET_SETTING, - SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID, - SVE_TRANSLATION_PLACEHOLDER_KEY, - SVE_TRANSLATION_PLACEHOLDER_VALUE, -) +from .const import DOMAIN from .coordinator import HomeConnectApplianceData, HomeConnectConfigEntry from .entity import HomeConnectEntity from .utils import get_dict_from_home_connect_error @@ -84,12 +78,12 @@ class HomeConnectTimeEntity(HomeConnectEntity, TimeEntity): except HomeConnectError as err: raise HomeAssistantError( translation_domain=DOMAIN, - translation_key=SVE_TRANSLATION_KEY_SET_SETTING, + translation_key="set_setting_entity", translation_placeholders={ **get_dict_from_home_connect_error(err), - SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID: self.entity_id, - SVE_TRANSLATION_PLACEHOLDER_KEY: self.bsh_key, - SVE_TRANSLATION_PLACEHOLDER_VALUE: str(value), + "entity_id": self.entity_id, + "key": self.bsh_key, + "value": str(value), }, ) from err From c08cbf3763fe806b20af81f8374d8f4da647c10f Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Sat, 22 Mar 2025 10:57:59 +0100 Subject: [PATCH 2854/3148] Use ShellyConfigEntry type in Shelly config flow (#141103) Use ShellyConfigEntry type in async_get_options_flow --- homeassistant/components/shelly/config_flow.py | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/shelly/config_flow.py b/homeassistant/components/shelly/config_flow.py index 8e47235c981..c7c1cd70a53 100644 --- a/homeassistant/components/shelly/config_flow.py +++ b/homeassistant/components/shelly/config_flow.py @@ -22,12 +22,7 @@ from aioshelly.exceptions import ( from aioshelly.rpc_device import RpcDevice import voluptuous as vol -from homeassistant.config_entries import ( - ConfigEntry, - ConfigFlow, - ConfigFlowResult, - OptionsFlow, -) +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow from homeassistant.const import ( CONF_HOST, CONF_MAC, @@ -49,7 +44,7 @@ from .const import ( LOGGER, BLEScannerMode, ) -from .coordinator import async_reconnect_soon +from .coordinator import ShellyConfigEntry, async_reconnect_soon from .utils import ( get_block_device_sleep_period, get_coap_context, @@ -458,13 +453,13 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlowHandler: + def async_get_options_flow(config_entry: ShellyConfigEntry) -> OptionsFlowHandler: """Get the options flow for this handler.""" return OptionsFlowHandler() @classmethod @callback - def async_supports_options_flow(cls, config_entry: ConfigEntry) -> bool: + def async_supports_options_flow(cls, config_entry: ShellyConfigEntry) -> bool: """Return options flow support for this handler.""" return ( get_device_entry_gen(config_entry) in RPC_GENERATIONS From 9d9b352631b9ed30a179af72c3f272acff4f216a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Sat, 22 Mar 2025 12:35:46 +0100 Subject: [PATCH 2855/3148] Move Home Connect service actions to a services.py (#141100) * Move Home Connect service actions to a actions.py * Rename actions.py to services.py * Move more fuctions to module level --- .../components/home_connect/__init__.py | 576 +----------------- .../components/home_connect/services.py | 572 +++++++++++++++++ .../{test_init.ambr => test_services.ambr} | 0 tests/components/home_connect/test_init.py | 456 +------------- .../components/home_connect/test_services.py | 468 ++++++++++++++ 5 files changed, 1052 insertions(+), 1020 deletions(-) create mode 100644 homeassistant/components/home_connect/services.py rename tests/components/home_connect/snapshots/{test_init.ambr => test_services.ambr} (100%) create mode 100644 tests/components/home_connect/test_services.py diff --git a/homeassistant/components/home_connect/__init__.py b/homeassistant/components/home_connect/__init__.py index 83de76431f9..fe01a3e9564 100644 --- a/homeassistant/components/home_connect/__init__.py +++ b/homeassistant/components/home_connect/__init__.py @@ -2,192 +2,29 @@ from __future__ import annotations -from collections.abc import Awaitable import logging -from typing import Any, cast +from typing import Any from aiohomeconnect.client import Client as HomeConnectClient -from aiohomeconnect.model import ( - ArrayOfOptions, - CommandKey, - Option, - OptionKey, - ProgramKey, - SettingKey, -) -from aiohomeconnect.model.error import HomeConnectError import aiohttp -import voluptuous as vol -from homeassistant.const import ATTR_DEVICE_ID, Platform -from homeassistant.core import HomeAssistant, ServiceCall, callback -from homeassistant.exceptions import ( - ConfigEntryAuthFailed, - ConfigEntryNotReady, - HomeAssistantError, - ServiceValidationError, -) -from homeassistant.helpers import ( - config_entry_oauth2_flow, - config_validation as cv, - device_registry as dr, -) +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers import config_entry_oauth2_flow, config_validation as cv from homeassistant.helpers.entity_registry import RegistryEntry, async_migrate_entries -from homeassistant.helpers.issue_registry import ( - IssueSeverity, - async_create_issue, - async_delete_issue, -) +from homeassistant.helpers.issue_registry import async_delete_issue from homeassistant.helpers.typing import ConfigType from .api import AsyncConfigEntryAuth -from .const import ( - AFFECTS_TO_ACTIVE_PROGRAM, - AFFECTS_TO_SELECTED_PROGRAM, - ATTR_AFFECTS_TO, - ATTR_KEY, - ATTR_PROGRAM, - ATTR_UNIT, - ATTR_VALUE, - DOMAIN, - OLD_NEW_UNIQUE_ID_SUFFIX_MAP, - PROGRAM_ENUM_OPTIONS, - SERVICE_OPTION_ACTIVE, - SERVICE_OPTION_SELECTED, - SERVICE_PAUSE_PROGRAM, - SERVICE_RESUME_PROGRAM, - SERVICE_SELECT_PROGRAM, - SERVICE_SET_PROGRAM_AND_OPTIONS, - SERVICE_SETTING, - SERVICE_START_PROGRAM, - TRANSLATION_KEYS_PROGRAMS_MAP, -) +from .const import DOMAIN, OLD_NEW_UNIQUE_ID_SUFFIX_MAP from .coordinator import HomeConnectConfigEntry, HomeConnectCoordinator -from .utils import bsh_key_to_translation_key, get_dict_from_home_connect_error +from .services import register_actions _LOGGER = logging.getLogger(__name__) CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) - -PROGRAM_OPTIONS = { - bsh_key_to_translation_key(key): ( - key, - value, - ) - for key, value in { - OptionKey.BSH_COMMON_DURATION: int, - OptionKey.BSH_COMMON_START_IN_RELATIVE: int, - OptionKey.BSH_COMMON_FINISH_IN_RELATIVE: int, - OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_FILL_QUANTITY: int, - OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_MULTIPLE_BEVERAGES: bool, - OptionKey.DISHCARE_DISHWASHER_INTENSIV_ZONE: bool, - OptionKey.DISHCARE_DISHWASHER_BRILLIANCE_DRY: bool, - OptionKey.DISHCARE_DISHWASHER_VARIO_SPEED_PLUS: bool, - OptionKey.DISHCARE_DISHWASHER_SILENCE_ON_DEMAND: bool, - OptionKey.DISHCARE_DISHWASHER_HALF_LOAD: bool, - OptionKey.DISHCARE_DISHWASHER_EXTRA_DRY: bool, - OptionKey.DISHCARE_DISHWASHER_HYGIENE_PLUS: bool, - OptionKey.DISHCARE_DISHWASHER_ECO_DRY: bool, - OptionKey.DISHCARE_DISHWASHER_ZEOLITE_DRY: bool, - OptionKey.COOKING_OVEN_SETPOINT_TEMPERATURE: int, - OptionKey.COOKING_OVEN_FAST_PRE_HEAT: bool, - OptionKey.LAUNDRY_CARE_WASHER_I_DOS_1_ACTIVE: bool, - OptionKey.LAUNDRY_CARE_WASHER_I_DOS_2_ACTIVE: bool, - }.items() -} - - -SERVICE_SETTING_SCHEMA = vol.Schema( - { - vol.Required(ATTR_DEVICE_ID): str, - vol.Required(ATTR_KEY): vol.All( - vol.Coerce(SettingKey), - vol.NotIn([SettingKey.UNKNOWN]), - ), - vol.Required(ATTR_VALUE): vol.Any(str, int, bool), - } -) - -# DEPRECATED: Remove in 2025.9.0 -SERVICE_OPTION_SCHEMA = vol.Schema( - { - vol.Required(ATTR_DEVICE_ID): str, - vol.Required(ATTR_KEY): vol.All( - vol.Coerce(OptionKey), - vol.NotIn([OptionKey.UNKNOWN]), - ), - vol.Required(ATTR_VALUE): vol.Any(str, int, bool), - vol.Optional(ATTR_UNIT): str, - } -) - -# DEPRECATED: Remove in 2025.9.0 -SERVICE_PROGRAM_SCHEMA = vol.Any( - { - vol.Required(ATTR_DEVICE_ID): str, - vol.Required(ATTR_PROGRAM): vol.All( - vol.Coerce(ProgramKey), - vol.NotIn([ProgramKey.UNKNOWN]), - ), - vol.Required(ATTR_KEY): vol.All( - vol.Coerce(OptionKey), - vol.NotIn([OptionKey.UNKNOWN]), - ), - vol.Required(ATTR_VALUE): vol.Any(int, str), - vol.Optional(ATTR_UNIT): str, - }, - { - vol.Required(ATTR_DEVICE_ID): str, - vol.Required(ATTR_PROGRAM): vol.All( - vol.Coerce(ProgramKey), - vol.NotIn([ProgramKey.UNKNOWN]), - ), - }, -) - - -def _require_program_or_at_least_one_option(data: dict) -> dict: - if ATTR_PROGRAM not in data and not any( - option_key in data for option_key in (PROGRAM_ENUM_OPTIONS | PROGRAM_OPTIONS) - ): - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="required_program_or_one_option_at_least", - ) - return data - - -SERVICE_PROGRAM_AND_OPTIONS_SCHEMA = vol.All( - vol.Schema( - { - vol.Required(ATTR_DEVICE_ID): str, - vol.Required(ATTR_AFFECTS_TO): vol.In( - [AFFECTS_TO_ACTIVE_PROGRAM, AFFECTS_TO_SELECTED_PROGRAM] - ), - vol.Optional(ATTR_PROGRAM): vol.In(TRANSLATION_KEYS_PROGRAMS_MAP.keys()), - } - ) - .extend( - { - vol.Optional(translation_key): vol.In(allowed_values.keys()) - for translation_key, ( - key, - allowed_values, - ) in PROGRAM_ENUM_OPTIONS.items() - } - ) - .extend( - { - vol.Optional(translation_key): schema - for translation_key, (key, schema) in PROGRAM_OPTIONS.items() - } - ), - _require_program_or_at_least_one_option, -) - -SERVICE_COMMAND_SCHEMA = vol.Schema({vol.Required(ATTR_DEVICE_ID): str}) - PLATFORMS = [ Platform.BINARY_SENSOR, Platform.BUTTON, @@ -200,402 +37,9 @@ PLATFORMS = [ ] -async def _get_client_and_ha_id( - hass: HomeAssistant, device_id: str -) -> tuple[HomeConnectClient, str]: - device_registry = dr.async_get(hass) - device_entry = device_registry.async_get(device_id) - if device_entry is None: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="device_entry_not_found", - translation_placeholders={ - "device_id": device_id, - }, - ) - entry: HomeConnectConfigEntry | None = None - for entry_id in device_entry.config_entries: - _entry = hass.config_entries.async_get_entry(entry_id) - assert _entry - if _entry.domain == DOMAIN: - entry = cast(HomeConnectConfigEntry, _entry) - break - if entry is None: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="config_entry_not_found", - translation_placeholders={ - "device_id": device_id, - }, - ) - - ha_id = next( - ( - identifier[1] - for identifier in device_entry.identifiers - if identifier[0] == DOMAIN - ), - None, - ) - if ha_id is None: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="appliance_not_found", - translation_placeholders={ - "device_id": device_id, - }, - ) - return entry.runtime_data.client, ha_id - - -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: C901 +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Home Connect component.""" - - async def _async_service_program(call: ServiceCall, start: bool) -> None: - """Execute calls to services taking a program.""" - program = call.data[ATTR_PROGRAM] - client, ha_id = await _get_client_and_ha_id(hass, call.data[ATTR_DEVICE_ID]) - - option_key = call.data.get(ATTR_KEY) - options = ( - [ - Option( - option_key, - call.data[ATTR_VALUE], - unit=call.data.get(ATTR_UNIT), - ) - ] - if option_key is not None - else None - ) - - async_create_issue( - hass, - DOMAIN, - "deprecated_set_program_and_option_actions", - breaks_in_ha_version="2025.9.0", - is_fixable=True, - is_persistent=True, - severity=IssueSeverity.WARNING, - translation_key="deprecated_set_program_and_option_actions", - translation_placeholders={ - "new_action_key": SERVICE_SET_PROGRAM_AND_OPTIONS, - "remove_release": "2025.9.0", - "deprecated_action_yaml": "\n".join( - [ - "```yaml", - f"action: {DOMAIN}.{SERVICE_START_PROGRAM if start else SERVICE_SELECT_PROGRAM}", - "data:", - f" {ATTR_DEVICE_ID}: DEVICE_ID", - f" {ATTR_PROGRAM}: {program}", - *([f" {ATTR_KEY}: {options[0].key}"] if options else []), - *([f" {ATTR_VALUE}: {options[0].value}"] if options else []), - *( - [f" {ATTR_UNIT}: {options[0].unit}"] - if options and options[0].unit - else [] - ), - "```", - ] - ), - "new_action_yaml": "\n ".join( - [ - "```yaml", - f"action: {DOMAIN}.{SERVICE_SET_PROGRAM_AND_OPTIONS}", - "data:", - f" {ATTR_DEVICE_ID}: DEVICE_ID", - f" {ATTR_AFFECTS_TO}: {AFFECTS_TO_ACTIVE_PROGRAM if start else AFFECTS_TO_SELECTED_PROGRAM}", - f" {ATTR_PROGRAM}: {bsh_key_to_translation_key(program.value)}", - *( - [ - f" {bsh_key_to_translation_key(options[0].key)}: {options[0].value}" - ] - if options - else [] - ), - "```", - ] - ), - "repo_link": "[aiohomeconnect](https://github.com/MartinHjelmare/aiohomeconnect)", - }, - ) - - try: - if start: - await client.start_program(ha_id, program_key=program, options=options) - else: - await client.set_selected_program( - ha_id, program_key=program, options=options - ) - except HomeConnectError as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="start_program" if start else "select_program", - translation_placeholders={ - **get_dict_from_home_connect_error(err), - "program": program, - }, - ) from err - - async def _async_service_set_program_options( - call: ServiceCall, active: bool - ) -> None: - """Execute calls to services taking a program.""" - option_key = call.data[ATTR_KEY] - value = call.data[ATTR_VALUE] - unit = call.data.get(ATTR_UNIT) - client, ha_id = await _get_client_and_ha_id(hass, call.data[ATTR_DEVICE_ID]) - - async_create_issue( - hass, - DOMAIN, - "deprecated_set_program_and_option_actions", - breaks_in_ha_version="2025.9.0", - is_fixable=True, - is_persistent=True, - severity=IssueSeverity.WARNING, - translation_key="deprecated_set_program_and_option_actions", - translation_placeholders={ - "new_action_key": SERVICE_SET_PROGRAM_AND_OPTIONS, - "remove_release": "2025.9.0", - "deprecated_action_yaml": "\n".join( - [ - "```yaml", - f"action: {DOMAIN}.{SERVICE_OPTION_ACTIVE if active else SERVICE_OPTION_SELECTED}", - "data:", - f" {ATTR_DEVICE_ID}: DEVICE_ID", - f" {ATTR_KEY}: {option_key}", - f" {ATTR_VALUE}: {value}", - *([f" {ATTR_UNIT}: {unit}"] if unit else []), - "```", - ] - ), - "new_action_yaml": "\n ".join( - [ - "```yaml", - f"action: {DOMAIN}.{SERVICE_SET_PROGRAM_AND_OPTIONS}", - "data:", - f" {ATTR_DEVICE_ID}: DEVICE_ID", - f" {ATTR_AFFECTS_TO}: {AFFECTS_TO_ACTIVE_PROGRAM if active else AFFECTS_TO_SELECTED_PROGRAM}", - f" {bsh_key_to_translation_key(option_key)}: {value}", - "```", - ] - ), - "repo_link": "[aiohomeconnect](https://github.com/MartinHjelmare/aiohomeconnect)", - }, - ) - try: - if active: - await client.set_active_program_option( - ha_id, - option_key=option_key, - value=value, - unit=unit, - ) - else: - await client.set_selected_program_option( - ha_id, - option_key=option_key, - value=value, - unit=unit, - ) - except HomeConnectError as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="set_options_active_program" - if active - else "set_options_selected_program", - translation_placeholders={ - **get_dict_from_home_connect_error(err), - "key": option_key, - "value": str(value), - }, - ) from err - - async def _async_service_command( - call: ServiceCall, command_key: CommandKey - ) -> None: - """Execute calls to services executing a command.""" - client, ha_id = await _get_client_and_ha_id(hass, call.data[ATTR_DEVICE_ID]) - - async_create_issue( - hass, - DOMAIN, - "deprecated_command_actions", - breaks_in_ha_version="2025.9.0", - is_fixable=True, - is_persistent=True, - severity=IssueSeverity.WARNING, - translation_key="deprecated_command_actions", - ) - - try: - await client.put_command(ha_id, command_key=command_key, value=True) - except HomeConnectError as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="execute_command", - translation_placeholders={ - **get_dict_from_home_connect_error(err), - "command": command_key.value, - }, - ) from err - - async def async_service_option_active(call: ServiceCall) -> None: - """Service for setting an option for an active program.""" - await _async_service_set_program_options(call, True) - - async def async_service_option_selected(call: ServiceCall) -> None: - """Service for setting an option for a selected program.""" - await _async_service_set_program_options(call, False) - - async def async_service_setting(call: ServiceCall) -> None: - """Service for changing a setting.""" - key = call.data[ATTR_KEY] - value = call.data[ATTR_VALUE] - client, ha_id = await _get_client_and_ha_id(hass, call.data[ATTR_DEVICE_ID]) - - try: - await client.set_setting(ha_id, setting_key=key, value=value) - except HomeConnectError as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="set_setting", - translation_placeholders={ - **get_dict_from_home_connect_error(err), - "key": key, - "value": str(value), - }, - ) from err - - async def async_service_pause_program(call: ServiceCall) -> None: - """Service for pausing a program.""" - await _async_service_command(call, CommandKey.BSH_COMMON_PAUSE_PROGRAM) - - async def async_service_resume_program(call: ServiceCall) -> None: - """Service for resuming a paused program.""" - await _async_service_command(call, CommandKey.BSH_COMMON_RESUME_PROGRAM) - - async def async_service_select_program(call: ServiceCall) -> None: - """Service for selecting a program.""" - await _async_service_program(call, False) - - async def async_service_set_program_and_options(call: ServiceCall) -> None: - """Service for setting a program and options.""" - data = dict(call.data) - program = data.pop(ATTR_PROGRAM, None) - affects_to = data.pop(ATTR_AFFECTS_TO) - client, ha_id = await _get_client_and_ha_id(hass, data.pop(ATTR_DEVICE_ID)) - - options: list[Option] = [] - - for option, value in data.items(): - if option in PROGRAM_ENUM_OPTIONS: - options.append( - Option( - PROGRAM_ENUM_OPTIONS[option][0], - PROGRAM_ENUM_OPTIONS[option][1][value], - ) - ) - elif option in PROGRAM_OPTIONS: - option_key = PROGRAM_OPTIONS[option][0] - options.append(Option(option_key, value)) - - method_call: Awaitable[Any] - exception_translation_key: str - if program: - program = ( - program - if isinstance(program, ProgramKey) - else TRANSLATION_KEYS_PROGRAMS_MAP[program] - ) - - if affects_to == AFFECTS_TO_ACTIVE_PROGRAM: - method_call = client.start_program( - ha_id, program_key=program, options=options - ) - exception_translation_key = "start_program" - elif affects_to == AFFECTS_TO_SELECTED_PROGRAM: - method_call = client.set_selected_program( - ha_id, program_key=program, options=options - ) - exception_translation_key = "select_program" - else: - array_of_options = ArrayOfOptions(options) - if affects_to == AFFECTS_TO_ACTIVE_PROGRAM: - method_call = client.set_active_program_options( - ha_id, array_of_options=array_of_options - ) - exception_translation_key = "set_options_active_program" - else: - # affects_to is AFFECTS_TO_SELECTED_PROGRAM - method_call = client.set_selected_program_options( - ha_id, array_of_options=array_of_options - ) - exception_translation_key = "set_options_selected_program" - - try: - await method_call - except HomeConnectError as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key=exception_translation_key, - translation_placeholders={ - **get_dict_from_home_connect_error(err), - **({"program": program} if program else {}), - }, - ) from err - - async def async_service_start_program(call: ServiceCall) -> None: - """Service for starting a program.""" - await _async_service_program(call, True) - - hass.services.async_register( - DOMAIN, - SERVICE_OPTION_ACTIVE, - async_service_option_active, - schema=SERVICE_OPTION_SCHEMA, - ) - hass.services.async_register( - DOMAIN, - SERVICE_OPTION_SELECTED, - async_service_option_selected, - schema=SERVICE_OPTION_SCHEMA, - ) - hass.services.async_register( - DOMAIN, SERVICE_SETTING, async_service_setting, schema=SERVICE_SETTING_SCHEMA - ) - hass.services.async_register( - DOMAIN, - SERVICE_PAUSE_PROGRAM, - async_service_pause_program, - schema=SERVICE_COMMAND_SCHEMA, - ) - hass.services.async_register( - DOMAIN, - SERVICE_RESUME_PROGRAM, - async_service_resume_program, - schema=SERVICE_COMMAND_SCHEMA, - ) - hass.services.async_register( - DOMAIN, - SERVICE_SELECT_PROGRAM, - async_service_select_program, - schema=SERVICE_PROGRAM_SCHEMA, - ) - hass.services.async_register( - DOMAIN, - SERVICE_START_PROGRAM, - async_service_start_program, - schema=SERVICE_PROGRAM_SCHEMA, - ) - hass.services.async_register( - DOMAIN, - SERVICE_SET_PROGRAM_AND_OPTIONS, - async_service_set_program_and_options, - schema=SERVICE_PROGRAM_AND_OPTIONS_SCHEMA, - ) - + register_actions(hass) return True diff --git a/homeassistant/components/home_connect/services.py b/homeassistant/components/home_connect/services.py new file mode 100644 index 00000000000..fac1c5fe1a9 --- /dev/null +++ b/homeassistant/components/home_connect/services.py @@ -0,0 +1,572 @@ +"""Custom actions (previously known as services) for the Home Connect integration.""" + +from __future__ import annotations + +from collections.abc import Awaitable +from typing import Any, cast + +from aiohomeconnect.client import Client as HomeConnectClient +from aiohomeconnect.model import ( + ArrayOfOptions, + CommandKey, + Option, + OptionKey, + ProgramKey, + SettingKey, +) +from aiohomeconnect.model.error import HomeConnectError +import voluptuous as vol + +from homeassistant.const import ATTR_DEVICE_ID +from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.helpers import config_validation as cv, device_registry as dr +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue + +from .const import ( + AFFECTS_TO_ACTIVE_PROGRAM, + AFFECTS_TO_SELECTED_PROGRAM, + ATTR_AFFECTS_TO, + ATTR_KEY, + ATTR_PROGRAM, + ATTR_UNIT, + ATTR_VALUE, + DOMAIN, + PROGRAM_ENUM_OPTIONS, + SERVICE_OPTION_ACTIVE, + SERVICE_OPTION_SELECTED, + SERVICE_PAUSE_PROGRAM, + SERVICE_RESUME_PROGRAM, + SERVICE_SELECT_PROGRAM, + SERVICE_SET_PROGRAM_AND_OPTIONS, + SERVICE_SETTING, + SERVICE_START_PROGRAM, + TRANSLATION_KEYS_PROGRAMS_MAP, +) +from .coordinator import HomeConnectConfigEntry +from .utils import bsh_key_to_translation_key, get_dict_from_home_connect_error + +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + + +PROGRAM_OPTIONS = { + bsh_key_to_translation_key(key): ( + key, + value, + ) + for key, value in { + OptionKey.BSH_COMMON_DURATION: int, + OptionKey.BSH_COMMON_START_IN_RELATIVE: int, + OptionKey.BSH_COMMON_FINISH_IN_RELATIVE: int, + OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_FILL_QUANTITY: int, + OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_MULTIPLE_BEVERAGES: bool, + OptionKey.DISHCARE_DISHWASHER_INTENSIV_ZONE: bool, + OptionKey.DISHCARE_DISHWASHER_BRILLIANCE_DRY: bool, + OptionKey.DISHCARE_DISHWASHER_VARIO_SPEED_PLUS: bool, + OptionKey.DISHCARE_DISHWASHER_SILENCE_ON_DEMAND: bool, + OptionKey.DISHCARE_DISHWASHER_HALF_LOAD: bool, + OptionKey.DISHCARE_DISHWASHER_EXTRA_DRY: bool, + OptionKey.DISHCARE_DISHWASHER_HYGIENE_PLUS: bool, + OptionKey.DISHCARE_DISHWASHER_ECO_DRY: bool, + OptionKey.DISHCARE_DISHWASHER_ZEOLITE_DRY: bool, + OptionKey.COOKING_OVEN_SETPOINT_TEMPERATURE: int, + OptionKey.COOKING_OVEN_FAST_PRE_HEAT: bool, + OptionKey.LAUNDRY_CARE_WASHER_I_DOS_1_ACTIVE: bool, + OptionKey.LAUNDRY_CARE_WASHER_I_DOS_2_ACTIVE: bool, + }.items() +} + + +SERVICE_SETTING_SCHEMA = vol.Schema( + { + vol.Required(ATTR_DEVICE_ID): str, + vol.Required(ATTR_KEY): vol.All( + vol.Coerce(SettingKey), + vol.NotIn([SettingKey.UNKNOWN]), + ), + vol.Required(ATTR_VALUE): vol.Any(str, int, bool), + } +) + +# DEPRECATED: Remove in 2025.9.0 +SERVICE_OPTION_SCHEMA = vol.Schema( + { + vol.Required(ATTR_DEVICE_ID): str, + vol.Required(ATTR_KEY): vol.All( + vol.Coerce(OptionKey), + vol.NotIn([OptionKey.UNKNOWN]), + ), + vol.Required(ATTR_VALUE): vol.Any(str, int, bool), + vol.Optional(ATTR_UNIT): str, + } +) + +# DEPRECATED: Remove in 2025.9.0 +SERVICE_PROGRAM_SCHEMA = vol.Any( + { + vol.Required(ATTR_DEVICE_ID): str, + vol.Required(ATTR_PROGRAM): vol.All( + vol.Coerce(ProgramKey), + vol.NotIn([ProgramKey.UNKNOWN]), + ), + vol.Required(ATTR_KEY): vol.All( + vol.Coerce(OptionKey), + vol.NotIn([OptionKey.UNKNOWN]), + ), + vol.Required(ATTR_VALUE): vol.Any(int, str), + vol.Optional(ATTR_UNIT): str, + }, + { + vol.Required(ATTR_DEVICE_ID): str, + vol.Required(ATTR_PROGRAM): vol.All( + vol.Coerce(ProgramKey), + vol.NotIn([ProgramKey.UNKNOWN]), + ), + }, +) + + +def _require_program_or_at_least_one_option(data: dict) -> dict: + if ATTR_PROGRAM not in data and not any( + option_key in data for option_key in (PROGRAM_ENUM_OPTIONS | PROGRAM_OPTIONS) + ): + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="required_program_or_one_option_at_least", + ) + return data + + +SERVICE_PROGRAM_AND_OPTIONS_SCHEMA = vol.All( + vol.Schema( + { + vol.Required(ATTR_DEVICE_ID): str, + vol.Required(ATTR_AFFECTS_TO): vol.In( + [AFFECTS_TO_ACTIVE_PROGRAM, AFFECTS_TO_SELECTED_PROGRAM] + ), + vol.Optional(ATTR_PROGRAM): vol.In(TRANSLATION_KEYS_PROGRAMS_MAP.keys()), + } + ) + .extend( + { + vol.Optional(translation_key): vol.In(allowed_values.keys()) + for translation_key, ( + key, + allowed_values, + ) in PROGRAM_ENUM_OPTIONS.items() + } + ) + .extend( + { + vol.Optional(translation_key): schema + for translation_key, (key, schema) in PROGRAM_OPTIONS.items() + } + ), + _require_program_or_at_least_one_option, +) + +SERVICE_COMMAND_SCHEMA = vol.Schema({vol.Required(ATTR_DEVICE_ID): str}) + + +async def _get_client_and_ha_id( + hass: HomeAssistant, device_id: str +) -> tuple[HomeConnectClient, str]: + device_registry = dr.async_get(hass) + device_entry = device_registry.async_get(device_id) + if device_entry is None: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="device_entry_not_found", + translation_placeholders={ + "device_id": device_id, + }, + ) + entry: HomeConnectConfigEntry | None = None + for entry_id in device_entry.config_entries: + _entry = hass.config_entries.async_get_entry(entry_id) + assert _entry + if _entry.domain == DOMAIN: + entry = cast(HomeConnectConfigEntry, _entry) + break + if entry is None: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="config_entry_not_found", + translation_placeholders={ + "device_id": device_id, + }, + ) + + ha_id = next( + ( + identifier[1] + for identifier in device_entry.identifiers + if identifier[0] == DOMAIN + ), + None, + ) + if ha_id is None: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="appliance_not_found", + translation_placeholders={ + "device_id": device_id, + }, + ) + return entry.runtime_data.client, ha_id + + +async def _async_service_program(call: ServiceCall, start: bool) -> None: + """Execute calls to services taking a program.""" + program = call.data[ATTR_PROGRAM] + client, ha_id = await _get_client_and_ha_id(call.hass, call.data[ATTR_DEVICE_ID]) + + option_key = call.data.get(ATTR_KEY) + options = ( + [ + Option( + option_key, + call.data[ATTR_VALUE], + unit=call.data.get(ATTR_UNIT), + ) + ] + if option_key is not None + else None + ) + + async_create_issue( + call.hass, + DOMAIN, + "deprecated_set_program_and_option_actions", + breaks_in_ha_version="2025.9.0", + is_fixable=True, + is_persistent=True, + severity=IssueSeverity.WARNING, + translation_key="deprecated_set_program_and_option_actions", + translation_placeholders={ + "new_action_key": SERVICE_SET_PROGRAM_AND_OPTIONS, + "remove_release": "2025.9.0", + "deprecated_action_yaml": "\n".join( + [ + "```yaml", + f"action: {DOMAIN}.{SERVICE_START_PROGRAM if start else SERVICE_SELECT_PROGRAM}", + "data:", + f" {ATTR_DEVICE_ID}: DEVICE_ID", + f" {ATTR_PROGRAM}: {program}", + *([f" {ATTR_KEY}: {options[0].key}"] if options else []), + *([f" {ATTR_VALUE}: {options[0].value}"] if options else []), + *( + [f" {ATTR_UNIT}: {options[0].unit}"] + if options and options[0].unit + else [] + ), + "```", + ] + ), + "new_action_yaml": "\n ".join( + [ + "```yaml", + f"action: {DOMAIN}.{SERVICE_SET_PROGRAM_AND_OPTIONS}", + "data:", + f" {ATTR_DEVICE_ID}: DEVICE_ID", + f" {ATTR_AFFECTS_TO}: {AFFECTS_TO_ACTIVE_PROGRAM if start else AFFECTS_TO_SELECTED_PROGRAM}", + f" {ATTR_PROGRAM}: {bsh_key_to_translation_key(program.value)}", + *( + [ + f" {bsh_key_to_translation_key(options[0].key)}: {options[0].value}" + ] + if options + else [] + ), + "```", + ] + ), + "repo_link": "[aiohomeconnect](https://github.com/MartinHjelmare/aiohomeconnect)", + }, + ) + + try: + if start: + await client.start_program(ha_id, program_key=program, options=options) + else: + await client.set_selected_program( + ha_id, program_key=program, options=options + ) + except HomeConnectError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="start_program" if start else "select_program", + translation_placeholders={ + **get_dict_from_home_connect_error(err), + "program": program, + }, + ) from err + + +async def _async_service_set_program_options(call: ServiceCall, active: bool) -> None: + """Execute calls to services taking a program.""" + option_key = call.data[ATTR_KEY] + value = call.data[ATTR_VALUE] + unit = call.data.get(ATTR_UNIT) + client, ha_id = await _get_client_and_ha_id(call.hass, call.data[ATTR_DEVICE_ID]) + + async_create_issue( + call.hass, + DOMAIN, + "deprecated_set_program_and_option_actions", + breaks_in_ha_version="2025.9.0", + is_fixable=True, + is_persistent=True, + severity=IssueSeverity.WARNING, + translation_key="deprecated_set_program_and_option_actions", + translation_placeholders={ + "new_action_key": SERVICE_SET_PROGRAM_AND_OPTIONS, + "remove_release": "2025.9.0", + "deprecated_action_yaml": "\n".join( + [ + "```yaml", + f"action: {DOMAIN}.{SERVICE_OPTION_ACTIVE if active else SERVICE_OPTION_SELECTED}", + "data:", + f" {ATTR_DEVICE_ID}: DEVICE_ID", + f" {ATTR_KEY}: {option_key}", + f" {ATTR_VALUE}: {value}", + *([f" {ATTR_UNIT}: {unit}"] if unit else []), + "```", + ] + ), + "new_action_yaml": "\n ".join( + [ + "```yaml", + f"action: {DOMAIN}.{SERVICE_SET_PROGRAM_AND_OPTIONS}", + "data:", + f" {ATTR_DEVICE_ID}: DEVICE_ID", + f" {ATTR_AFFECTS_TO}: {AFFECTS_TO_ACTIVE_PROGRAM if active else AFFECTS_TO_SELECTED_PROGRAM}", + f" {bsh_key_to_translation_key(option_key)}: {value}", + "```", + ] + ), + "repo_link": "[aiohomeconnect](https://github.com/MartinHjelmare/aiohomeconnect)", + }, + ) + try: + if active: + await client.set_active_program_option( + ha_id, + option_key=option_key, + value=value, + unit=unit, + ) + else: + await client.set_selected_program_option( + ha_id, + option_key=option_key, + value=value, + unit=unit, + ) + except HomeConnectError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="set_options_active_program" + if active + else "set_options_selected_program", + translation_placeholders={ + **get_dict_from_home_connect_error(err), + "key": option_key, + "value": str(value), + }, + ) from err + + +async def _async_service_command(call: ServiceCall, command_key: CommandKey) -> None: + """Execute calls to services executing a command.""" + client, ha_id = await _get_client_and_ha_id(call.hass, call.data[ATTR_DEVICE_ID]) + + async_create_issue( + call.hass, + DOMAIN, + "deprecated_command_actions", + breaks_in_ha_version="2025.9.0", + is_fixable=True, + is_persistent=True, + severity=IssueSeverity.WARNING, + translation_key="deprecated_command_actions", + ) + + try: + await client.put_command(ha_id, command_key=command_key, value=True) + except HomeConnectError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="execute_command", + translation_placeholders={ + **get_dict_from_home_connect_error(err), + "command": command_key.value, + }, + ) from err + + +async def async_service_option_active(call: ServiceCall) -> None: + """Service for setting an option for an active program.""" + await _async_service_set_program_options(call, True) + + +async def async_service_option_selected(call: ServiceCall) -> None: + """Service for setting an option for a selected program.""" + await _async_service_set_program_options(call, False) + + +async def async_service_setting(call: ServiceCall) -> None: + """Service for changing a setting.""" + key = call.data[ATTR_KEY] + value = call.data[ATTR_VALUE] + client, ha_id = await _get_client_and_ha_id(call.hass, call.data[ATTR_DEVICE_ID]) + + try: + await client.set_setting(ha_id, setting_key=key, value=value) + except HomeConnectError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="set_setting", + translation_placeholders={ + **get_dict_from_home_connect_error(err), + "key": key, + "value": str(value), + }, + ) from err + + +async def async_service_pause_program(call: ServiceCall) -> None: + """Service for pausing a program.""" + await _async_service_command(call, CommandKey.BSH_COMMON_PAUSE_PROGRAM) + + +async def async_service_resume_program(call: ServiceCall) -> None: + """Service for resuming a paused program.""" + await _async_service_command(call, CommandKey.BSH_COMMON_RESUME_PROGRAM) + + +async def async_service_select_program(call: ServiceCall) -> None: + """Service for selecting a program.""" + await _async_service_program(call, False) + + +async def async_service_set_program_and_options(call: ServiceCall) -> None: + """Service for setting a program and options.""" + data = dict(call.data) + program = data.pop(ATTR_PROGRAM, None) + affects_to = data.pop(ATTR_AFFECTS_TO) + client, ha_id = await _get_client_and_ha_id(call.hass, data.pop(ATTR_DEVICE_ID)) + + options: list[Option] = [] + + for option, value in data.items(): + if option in PROGRAM_ENUM_OPTIONS: + options.append( + Option( + PROGRAM_ENUM_OPTIONS[option][0], + PROGRAM_ENUM_OPTIONS[option][1][value], + ) + ) + elif option in PROGRAM_OPTIONS: + option_key = PROGRAM_OPTIONS[option][0] + options.append(Option(option_key, value)) + + method_call: Awaitable[Any] + exception_translation_key: str + if program: + program = ( + program + if isinstance(program, ProgramKey) + else TRANSLATION_KEYS_PROGRAMS_MAP[program] + ) + + if affects_to == AFFECTS_TO_ACTIVE_PROGRAM: + method_call = client.start_program( + ha_id, program_key=program, options=options + ) + exception_translation_key = "start_program" + elif affects_to == AFFECTS_TO_SELECTED_PROGRAM: + method_call = client.set_selected_program( + ha_id, program_key=program, options=options + ) + exception_translation_key = "select_program" + else: + array_of_options = ArrayOfOptions(options) + if affects_to == AFFECTS_TO_ACTIVE_PROGRAM: + method_call = client.set_active_program_options( + ha_id, array_of_options=array_of_options + ) + exception_translation_key = "set_options_active_program" + else: + # affects_to is AFFECTS_TO_SELECTED_PROGRAM + method_call = client.set_selected_program_options( + ha_id, array_of_options=array_of_options + ) + exception_translation_key = "set_options_selected_program" + + try: + await method_call + except HomeConnectError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key=exception_translation_key, + translation_placeholders={ + **get_dict_from_home_connect_error(err), + **({"program": program} if program else {}), + }, + ) from err + + +async def async_service_start_program(call: ServiceCall) -> None: + """Service for starting a program.""" + await _async_service_program(call, True) + + +def register_actions(hass: HomeAssistant) -> None: + """Register custom actions.""" + + hass.services.async_register( + DOMAIN, + SERVICE_OPTION_ACTIVE, + async_service_option_active, + schema=SERVICE_OPTION_SCHEMA, + ) + hass.services.async_register( + DOMAIN, + SERVICE_OPTION_SELECTED, + async_service_option_selected, + schema=SERVICE_OPTION_SCHEMA, + ) + hass.services.async_register( + DOMAIN, SERVICE_SETTING, async_service_setting, schema=SERVICE_SETTING_SCHEMA + ) + hass.services.async_register( + DOMAIN, + SERVICE_PAUSE_PROGRAM, + async_service_pause_program, + schema=SERVICE_COMMAND_SCHEMA, + ) + hass.services.async_register( + DOMAIN, + SERVICE_RESUME_PROGRAM, + async_service_resume_program, + schema=SERVICE_COMMAND_SCHEMA, + ) + hass.services.async_register( + DOMAIN, + SERVICE_SELECT_PROGRAM, + async_service_select_program, + schema=SERVICE_PROGRAM_SCHEMA, + ) + hass.services.async_register( + DOMAIN, + SERVICE_START_PROGRAM, + async_service_start_program, + schema=SERVICE_PROGRAM_SCHEMA, + ) + hass.services.async_register( + DOMAIN, + SERVICE_SET_PROGRAM_AND_OPTIONS, + async_service_set_program_and_options, + schema=SERVICE_PROGRAM_AND_OPTIONS_SCHEMA, + ) diff --git a/tests/components/home_connect/snapshots/test_init.ambr b/tests/components/home_connect/snapshots/test_services.ambr similarity index 100% rename from tests/components/home_connect/snapshots/test_init.ambr rename to tests/components/home_connect/snapshots/test_services.ambr diff --git a/tests/components/home_connect/test_init.py b/tests/components/home_connect/test_init.py index 291caeafd58..e0e586929a9 100644 --- a/tests/components/home_connect/test_init.py +++ b/tests/components/home_connect/test_init.py @@ -1,12 +1,11 @@ """Test the integration init functionality.""" from collections.abc import Awaitable, Callable -from http import HTTPStatus from typing import Any from unittest.mock import AsyncMock, MagicMock, patch from aiohomeconnect.const import OAUTH2_TOKEN -from aiohomeconnect.model import OptionKey, ProgramKey, SettingKey, StatusKey +from aiohomeconnect.model import SettingKey, StatusKey from aiohomeconnect.model.error import ( HomeConnectError, TooManyRequestsError, @@ -14,7 +13,6 @@ from aiohomeconnect.model.error import ( ) import aiohttp import pytest -from syrupy.assertion import SnapshotAssertion from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN from homeassistant.components.home_connect.const import DOMAIN @@ -25,9 +23,8 @@ from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import device_registry as dr, entity_registry as er -import homeassistant.helpers.issue_registry as ir from script.hassfest.translations import RE_TRANSLATION_KEY from .conftest import ( @@ -40,157 +37,6 @@ from .conftest import ( from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker -from tests.typing import ClientSessionGenerator - -DEPRECATED_SERVICE_KV_CALL_PARAMS = [ - { - "domain": DOMAIN, - "service": "set_option_active", - "service_data": { - "device_id": "DEVICE_ID", - "key": OptionKey.BSH_COMMON_FINISH_IN_RELATIVE.value, - "value": 43200, - "unit": "seconds", - }, - "blocking": True, - }, - { - "domain": DOMAIN, - "service": "set_option_selected", - "service_data": { - "device_id": "DEVICE_ID", - "key": OptionKey.LAUNDRY_CARE_WASHER_TEMPERATURE.value, - "value": "LaundryCare.Washer.EnumType.Temperature.GC40", - }, - "blocking": True, - }, -] - -SERVICE_KV_CALL_PARAMS = [ - *DEPRECATED_SERVICE_KV_CALL_PARAMS, - { - "domain": DOMAIN, - "service": "change_setting", - "service_data": { - "device_id": "DEVICE_ID", - "key": SettingKey.BSH_COMMON_CHILD_LOCK.value, - "value": True, - }, - "blocking": True, - }, -] - -SERVICE_COMMAND_CALL_PARAMS = [ - { - "domain": DOMAIN, - "service": "pause_program", - "service_data": { - "device_id": "DEVICE_ID", - }, - "blocking": True, - }, - { - "domain": DOMAIN, - "service": "resume_program", - "service_data": { - "device_id": "DEVICE_ID", - }, - "blocking": True, - }, -] - - -SERVICE_PROGRAM_CALL_PARAMS = [ - { - "domain": DOMAIN, - "service": "select_program", - "service_data": { - "device_id": "DEVICE_ID", - "program": ProgramKey.LAUNDRY_CARE_WASHER_COTTON.value, - "key": OptionKey.LAUNDRY_CARE_WASHER_TEMPERATURE.value, - "value": "LaundryCare.Washer.EnumType.Temperature.GC40", - }, - "blocking": True, - }, - { - "domain": DOMAIN, - "service": "start_program", - "service_data": { - "device_id": "DEVICE_ID", - "program": ProgramKey.LAUNDRY_CARE_WASHER_COTTON.value, - "key": OptionKey.BSH_COMMON_FINISH_IN_RELATIVE.value, - "value": 43200, - "unit": "seconds", - }, - "blocking": True, - }, -] - -SERVICE_APPLIANCE_METHOD_MAPPING = { - "set_option_active": "set_active_program_option", - "set_option_selected": "set_selected_program_option", - "change_setting": "set_setting", - "pause_program": "put_command", - "resume_program": "put_command", - "select_program": "set_selected_program", - "start_program": "start_program", -} - -SERVICE_VALIDATION_ERROR_MAPPING = { - "set_option_active": r"Error.*setting.*options.*active.*program.*", - "set_option_selected": r"Error.*setting.*options.*selected.*program.*", - "change_setting": r"Error.*assigning.*value.*setting.*", - "pause_program": r"Error.*executing.*command.*", - "resume_program": r"Error.*executing.*command.*", - "select_program": r"Error.*selecting.*program.*", - "start_program": r"Error.*starting.*program.*", -} - - -SERVICES_SET_PROGRAM_AND_OPTIONS = [ - { - "domain": DOMAIN, - "service": "set_program_and_options", - "service_data": { - "device_id": "DEVICE_ID", - "affects_to": "selected_program", - "program": "dishcare_dishwasher_program_eco_50", - "b_s_h_common_option_start_in_relative": 1800, - }, - "blocking": True, - }, - { - "domain": DOMAIN, - "service": "set_program_and_options", - "service_data": { - "device_id": "DEVICE_ID", - "affects_to": "active_program", - "program": "consumer_products_coffee_maker_program_beverage_coffee", - "consumer_products_coffee_maker_option_bean_amount": "consumer_products_coffee_maker_enum_type_bean_amount_normal", - }, - "blocking": True, - }, - { - "domain": DOMAIN, - "service": "set_program_and_options", - "service_data": { - "device_id": "DEVICE_ID", - "affects_to": "active_program", - "consumer_products_coffee_maker_option_coffee_milk_ratio": "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_50_percent", - }, - "blocking": True, - }, - { - "domain": DOMAIN, - "service": "set_program_and_options", - "service_data": { - "device_id": "DEVICE_ID", - "affects_to": "selected_program", - "consumer_products_coffee_maker_option_fill_quantity": 35, - }, - "blocking": True, - }, -] async def test_entry_setup( @@ -401,197 +247,6 @@ async def test_client_rate_limit_error( asyncio_sleep_mock.assert_called_once_with(retry_after) -@pytest.mark.parametrize( - "service_call", - SERVICE_KV_CALL_PARAMS + SERVICE_COMMAND_CALL_PARAMS + SERVICE_PROGRAM_CALL_PARAMS, -) -async def test_key_value_services( - service_call: dict[str, Any], - hass: HomeAssistant, - device_registry: dr.DeviceRegistry, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, - appliance_ha_id: str, -) -> None: - """Create and test services.""" - assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED - - device_entry = device_registry.async_get_or_create( - config_entry_id=config_entry.entry_id, - identifiers={(DOMAIN, appliance_ha_id)}, - ) - - service_name = service_call["service"] - service_call["service_data"]["device_id"] = device_entry.id - await hass.services.async_call(**service_call) - await hass.async_block_till_done() - assert ( - getattr(client, SERVICE_APPLIANCE_METHOD_MAPPING[service_name]).call_count == 1 - ) - - -@pytest.mark.parametrize( - ("service_call", "issue_id"), - [ - *zip( - DEPRECATED_SERVICE_KV_CALL_PARAMS + SERVICE_PROGRAM_CALL_PARAMS, - ["deprecated_set_program_and_option_actions"] - * ( - len(DEPRECATED_SERVICE_KV_CALL_PARAMS) - + len(SERVICE_PROGRAM_CALL_PARAMS) - ), - strict=True, - ), - *zip( - SERVICE_COMMAND_CALL_PARAMS, - ["deprecated_command_actions"] * len(SERVICE_COMMAND_CALL_PARAMS), - strict=True, - ), - ], -) -async def test_programs_and_options_actions_deprecation( - service_call: dict[str, Any], - issue_id: str, - hass: HomeAssistant, - device_registry: dr.DeviceRegistry, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, - appliance_ha_id: str, - issue_registry: ir.IssueRegistry, - hass_client: ClientSessionGenerator, -) -> None: - """Test deprecated service keys.""" - assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED - - device_entry = device_registry.async_get_or_create( - config_entry_id=config_entry.entry_id, - identifiers={(DOMAIN, appliance_ha_id)}, - ) - - service_call["service_data"]["device_id"] = device_entry.id - await hass.services.async_call(**service_call) - await hass.async_block_till_done() - - assert len(issue_registry.issues) == 1 - issue = issue_registry.async_get_issue(DOMAIN, issue_id) - assert issue - - _client = await hass_client() - resp = await _client.post( - "/api/repairs/issues/fix", - json={"handler": DOMAIN, "issue_id": issue.issue_id}, - ) - assert resp.status == HTTPStatus.OK - flow_id = (await resp.json())["flow_id"] - resp = await _client.post(f"/api/repairs/issues/fix/{flow_id}") - - assert not issue_registry.async_get_issue(DOMAIN, issue_id) - assert len(issue_registry.issues) == 0 - - await hass.services.async_call(**service_call) - await hass.async_block_till_done() - - assert len(issue_registry.issues) == 1 - assert issue_registry.async_get_issue(DOMAIN, issue_id) - - await hass.config_entries.async_unload(config_entry.entry_id) - await hass.async_block_till_done() - - # Assert the issue is no longer present - assert not issue_registry.async_get_issue(DOMAIN, issue_id) - assert len(issue_registry.issues) == 0 - - -@pytest.mark.parametrize( - ("service_call", "called_method"), - zip( - SERVICES_SET_PROGRAM_AND_OPTIONS, - [ - "set_selected_program", - "start_program", - "set_active_program_options", - "set_selected_program_options", - ], - strict=True, - ), -) -async def test_set_program_and_options( - service_call: dict[str, Any], - called_method: str, - hass: HomeAssistant, - device_registry: dr.DeviceRegistry, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, - appliance_ha_id: str, - snapshot: SnapshotAssertion, -) -> None: - """Test recognized options.""" - assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED - - device_entry = device_registry.async_get_or_create( - config_entry_id=config_entry.entry_id, - identifiers={(DOMAIN, appliance_ha_id)}, - ) - - service_call["service_data"]["device_id"] = device_entry.id - await hass.services.async_call(**service_call) - await hass.async_block_till_done() - method_mock: MagicMock = getattr(client, called_method) - assert method_mock.call_count == 1 - assert method_mock.call_args == snapshot - - -@pytest.mark.parametrize( - ("service_call", "error_regex"), - zip( - SERVICES_SET_PROGRAM_AND_OPTIONS, - [ - r"Error.*selecting.*program.*", - r"Error.*starting.*program.*", - r"Error.*setting.*options.*active.*program.*", - r"Error.*setting.*options.*selected.*program.*", - ], - strict=True, - ), -) -async def test_set_program_and_options_exceptions( - service_call: dict[str, Any], - error_regex: str, - hass: HomeAssistant, - device_registry: dr.DeviceRegistry, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client_with_exception: MagicMock, - appliance_ha_id: str, -) -> None: - """Test recognized options.""" - assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup(client_with_exception) - assert config_entry.state == ConfigEntryState.LOADED - - device_entry = device_registry.async_get_or_create( - config_entry_id=config_entry.entry_id, - identifiers={(DOMAIN, appliance_ha_id)}, - ) - - service_call["service_data"]["device_id"] = device_entry.id - with pytest.raises(HomeAssistantError, match=error_regex): - await hass.services.async_call(**service_call) - - async def test_required_program_or_at_least_an_option( hass: HomeAssistant, device_registry: dr.DeviceRegistry, @@ -626,113 +281,6 @@ async def test_required_program_or_at_least_an_option( ) -@pytest.mark.parametrize( - "service_call", - SERVICE_KV_CALL_PARAMS + SERVICE_COMMAND_CALL_PARAMS + SERVICE_PROGRAM_CALL_PARAMS, -) -async def test_services_exception_device_id( - service_call: dict[str, Any], - hass: HomeAssistant, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client_with_exception: MagicMock, - appliance_ha_id: str, - device_registry: dr.DeviceRegistry, -) -> None: - """Raise a HomeAssistantError when there is an API error.""" - assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup(client_with_exception) - assert config_entry.state == ConfigEntryState.LOADED - - device_entry = device_registry.async_get_or_create( - config_entry_id=config_entry.entry_id, - identifiers={(DOMAIN, appliance_ha_id)}, - ) - - service_call["service_data"]["device_id"] = device_entry.id - - with pytest.raises(HomeAssistantError): - await hass.services.async_call(**service_call) - - -async def test_services_appliance_not_found( - hass: HomeAssistant, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, - device_registry: dr.DeviceRegistry, -) -> None: - """Raise a ServiceValidationError when device id does not match.""" - assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED - - service_call = SERVICE_KV_CALL_PARAMS[0] - - service_call["service_data"]["device_id"] = "DOES_NOT_EXISTS" - - with pytest.raises(ServiceValidationError, match=r"Device entry.*not found"): - await hass.services.async_call(**service_call) - - unrelated_config_entry = MockConfigEntry( - domain="TEST", - ) - unrelated_config_entry.add_to_hass(hass) - device_entry = device_registry.async_get_or_create( - config_entry_id=unrelated_config_entry.entry_id, - identifiers={("RANDOM", "ABCD")}, - ) - service_call["service_data"]["device_id"] = device_entry.id - - with pytest.raises(ServiceValidationError, match=r"Config entry.*not found"): - await hass.services.async_call(**service_call) - - device_entry = device_registry.async_get_or_create( - config_entry_id=config_entry.entry_id, - identifiers={("RANDOM", "ABCD")}, - ) - service_call["service_data"]["device_id"] = device_entry.id - - with pytest.raises(ServiceValidationError, match=r"Appliance.*not found"): - await hass.services.async_call(**service_call) - - -@pytest.mark.parametrize( - "service_call", - SERVICE_KV_CALL_PARAMS + SERVICE_COMMAND_CALL_PARAMS + SERVICE_PROGRAM_CALL_PARAMS, -) -async def test_services_exception( - service_call: dict[str, Any], - hass: HomeAssistant, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client_with_exception: MagicMock, - appliance_ha_id: str, - device_registry: dr.DeviceRegistry, -) -> None: - """Raise a ValueError when device id does not match.""" - assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup(client_with_exception) - assert config_entry.state == ConfigEntryState.LOADED - - device_entry = device_registry.async_get_or_create( - config_entry_id=config_entry.entry_id, - identifiers={(DOMAIN, appliance_ha_id)}, - ) - - service_call["service_data"]["device_id"] = device_entry.id - - service_name = service_call["service"] - with pytest.raises( - HomeAssistantError, - match=SERVICE_VALIDATION_ERROR_MAPPING[service_name], - ): - await hass.services.async_call(**service_call) - - async def test_entity_migration( hass: HomeAssistant, device_registry: dr.DeviceRegistry, diff --git a/tests/components/home_connect/test_services.py b/tests/components/home_connect/test_services.py new file mode 100644 index 00000000000..517564724a9 --- /dev/null +++ b/tests/components/home_connect/test_services.py @@ -0,0 +1,468 @@ +"""Tests for the Home Connect actions.""" + +from collections.abc import Awaitable, Callable +from http import HTTPStatus +from typing import Any +from unittest.mock import MagicMock + +from aiohomeconnect.model import OptionKey, ProgramKey, SettingKey +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.home_connect.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.helpers import device_registry as dr +import homeassistant.helpers.issue_registry as ir + +from tests.common import MockConfigEntry +from tests.typing import ClientSessionGenerator + +DEPRECATED_SERVICE_KV_CALL_PARAMS = [ + { + "domain": DOMAIN, + "service": "set_option_active", + "service_data": { + "device_id": "DEVICE_ID", + "key": OptionKey.BSH_COMMON_FINISH_IN_RELATIVE.value, + "value": 43200, + "unit": "seconds", + }, + "blocking": True, + }, + { + "domain": DOMAIN, + "service": "set_option_selected", + "service_data": { + "device_id": "DEVICE_ID", + "key": OptionKey.LAUNDRY_CARE_WASHER_TEMPERATURE.value, + "value": "LaundryCare.Washer.EnumType.Temperature.GC40", + }, + "blocking": True, + }, +] + +SERVICE_KV_CALL_PARAMS = [ + *DEPRECATED_SERVICE_KV_CALL_PARAMS, + { + "domain": DOMAIN, + "service": "change_setting", + "service_data": { + "device_id": "DEVICE_ID", + "key": SettingKey.BSH_COMMON_CHILD_LOCK.value, + "value": True, + }, + "blocking": True, + }, +] + +SERVICE_COMMAND_CALL_PARAMS = [ + { + "domain": DOMAIN, + "service": "pause_program", + "service_data": { + "device_id": "DEVICE_ID", + }, + "blocking": True, + }, + { + "domain": DOMAIN, + "service": "resume_program", + "service_data": { + "device_id": "DEVICE_ID", + }, + "blocking": True, + }, +] + + +SERVICE_PROGRAM_CALL_PARAMS = [ + { + "domain": DOMAIN, + "service": "select_program", + "service_data": { + "device_id": "DEVICE_ID", + "program": ProgramKey.LAUNDRY_CARE_WASHER_COTTON.value, + "key": OptionKey.LAUNDRY_CARE_WASHER_TEMPERATURE.value, + "value": "LaundryCare.Washer.EnumType.Temperature.GC40", + }, + "blocking": True, + }, + { + "domain": DOMAIN, + "service": "start_program", + "service_data": { + "device_id": "DEVICE_ID", + "program": ProgramKey.LAUNDRY_CARE_WASHER_COTTON.value, + "key": OptionKey.BSH_COMMON_FINISH_IN_RELATIVE.value, + "value": 43200, + "unit": "seconds", + }, + "blocking": True, + }, +] + +SERVICE_APPLIANCE_METHOD_MAPPING = { + "set_option_active": "set_active_program_option", + "set_option_selected": "set_selected_program_option", + "change_setting": "set_setting", + "pause_program": "put_command", + "resume_program": "put_command", + "select_program": "set_selected_program", + "start_program": "start_program", +} + +SERVICE_VALIDATION_ERROR_MAPPING = { + "set_option_active": r"Error.*setting.*options.*active.*program.*", + "set_option_selected": r"Error.*setting.*options.*selected.*program.*", + "change_setting": r"Error.*assigning.*value.*setting.*", + "pause_program": r"Error.*executing.*command.*", + "resume_program": r"Error.*executing.*command.*", + "select_program": r"Error.*selecting.*program.*", + "start_program": r"Error.*starting.*program.*", +} + + +SERVICES_SET_PROGRAM_AND_OPTIONS = [ + { + "domain": DOMAIN, + "service": "set_program_and_options", + "service_data": { + "device_id": "DEVICE_ID", + "affects_to": "selected_program", + "program": "dishcare_dishwasher_program_eco_50", + "b_s_h_common_option_start_in_relative": 1800, + }, + "blocking": True, + }, + { + "domain": DOMAIN, + "service": "set_program_and_options", + "service_data": { + "device_id": "DEVICE_ID", + "affects_to": "active_program", + "program": "consumer_products_coffee_maker_program_beverage_coffee", + "consumer_products_coffee_maker_option_bean_amount": "consumer_products_coffee_maker_enum_type_bean_amount_normal", + }, + "blocking": True, + }, + { + "domain": DOMAIN, + "service": "set_program_and_options", + "service_data": { + "device_id": "DEVICE_ID", + "affects_to": "active_program", + "consumer_products_coffee_maker_option_coffee_milk_ratio": "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_50_percent", + }, + "blocking": True, + }, + { + "domain": DOMAIN, + "service": "set_program_and_options", + "service_data": { + "device_id": "DEVICE_ID", + "affects_to": "selected_program", + "consumer_products_coffee_maker_option_fill_quantity": 35, + }, + "blocking": True, + }, +] + + +@pytest.mark.parametrize( + "service_call", + SERVICE_KV_CALL_PARAMS + SERVICE_COMMAND_CALL_PARAMS + SERVICE_PROGRAM_CALL_PARAMS, +) +async def test_key_value_services( + service_call: dict[str, Any], + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + appliance_ha_id: str, +) -> None: + """Create and test services.""" + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, appliance_ha_id)}, + ) + + service_name = service_call["service"] + service_call["service_data"]["device_id"] = device_entry.id + await hass.services.async_call(**service_call) + await hass.async_block_till_done() + assert ( + getattr(client, SERVICE_APPLIANCE_METHOD_MAPPING[service_name]).call_count == 1 + ) + + +@pytest.mark.parametrize( + ("service_call", "issue_id"), + [ + *zip( + DEPRECATED_SERVICE_KV_CALL_PARAMS + SERVICE_PROGRAM_CALL_PARAMS, + ["deprecated_set_program_and_option_actions"] + * ( + len(DEPRECATED_SERVICE_KV_CALL_PARAMS) + + len(SERVICE_PROGRAM_CALL_PARAMS) + ), + strict=True, + ), + *zip( + SERVICE_COMMAND_CALL_PARAMS, + ["deprecated_command_actions"] * len(SERVICE_COMMAND_CALL_PARAMS), + strict=True, + ), + ], +) +async def test_programs_and_options_actions_deprecation( + service_call: dict[str, Any], + issue_id: str, + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + appliance_ha_id: str, + issue_registry: ir.IssueRegistry, + hass_client: ClientSessionGenerator, +) -> None: + """Test deprecated service keys.""" + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, appliance_ha_id)}, + ) + + service_call["service_data"]["device_id"] = device_entry.id + await hass.services.async_call(**service_call) + await hass.async_block_till_done() + + assert len(issue_registry.issues) == 1 + issue = issue_registry.async_get_issue(DOMAIN, issue_id) + assert issue + + _client = await hass_client() + resp = await _client.post( + "/api/repairs/issues/fix", + json={"handler": DOMAIN, "issue_id": issue.issue_id}, + ) + assert resp.status == HTTPStatus.OK + flow_id = (await resp.json())["flow_id"] + resp = await _client.post(f"/api/repairs/issues/fix/{flow_id}") + + assert not issue_registry.async_get_issue(DOMAIN, issue_id) + assert len(issue_registry.issues) == 0 + + await hass.services.async_call(**service_call) + await hass.async_block_till_done() + + assert len(issue_registry.issues) == 1 + assert issue_registry.async_get_issue(DOMAIN, issue_id) + + await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() + + # Assert the issue is no longer present + assert not issue_registry.async_get_issue(DOMAIN, issue_id) + assert len(issue_registry.issues) == 0 + + +@pytest.mark.parametrize( + ("service_call", "called_method"), + zip( + SERVICES_SET_PROGRAM_AND_OPTIONS, + [ + "set_selected_program", + "start_program", + "set_active_program_options", + "set_selected_program_options", + ], + strict=True, + ), +) +async def test_set_program_and_options( + service_call: dict[str, Any], + called_method: str, + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + appliance_ha_id: str, + snapshot: SnapshotAssertion, +) -> None: + """Test recognized options.""" + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, appliance_ha_id)}, + ) + + service_call["service_data"]["device_id"] = device_entry.id + await hass.services.async_call(**service_call) + await hass.async_block_till_done() + method_mock: MagicMock = getattr(client, called_method) + assert method_mock.call_count == 1 + assert method_mock.call_args == snapshot + + +@pytest.mark.parametrize( + ("service_call", "error_regex"), + zip( + SERVICES_SET_PROGRAM_AND_OPTIONS, + [ + r"Error.*selecting.*program.*", + r"Error.*starting.*program.*", + r"Error.*setting.*options.*active.*program.*", + r"Error.*setting.*options.*selected.*program.*", + ], + strict=True, + ), +) +async def test_set_program_and_options_exceptions( + service_call: dict[str, Any], + error_regex: str, + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client_with_exception: MagicMock, + appliance_ha_id: str, +) -> None: + """Test recognized options.""" + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client_with_exception) + assert config_entry.state == ConfigEntryState.LOADED + + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, appliance_ha_id)}, + ) + + service_call["service_data"]["device_id"] = device_entry.id + with pytest.raises(HomeAssistantError, match=error_regex): + await hass.services.async_call(**service_call) + + +@pytest.mark.parametrize( + "service_call", + SERVICE_KV_CALL_PARAMS + SERVICE_COMMAND_CALL_PARAMS + SERVICE_PROGRAM_CALL_PARAMS, +) +async def test_services_exception_device_id( + service_call: dict[str, Any], + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client_with_exception: MagicMock, + appliance_ha_id: str, + device_registry: dr.DeviceRegistry, +) -> None: + """Raise a HomeAssistantError when there is an API error.""" + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client_with_exception) + assert config_entry.state == ConfigEntryState.LOADED + + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, appliance_ha_id)}, + ) + + service_call["service_data"]["device_id"] = device_entry.id + + with pytest.raises(HomeAssistantError): + await hass.services.async_call(**service_call) + + +async def test_services_appliance_not_found( + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + device_registry: dr.DeviceRegistry, +) -> None: + """Raise a ServiceValidationError when device id does not match.""" + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + service_call = SERVICE_KV_CALL_PARAMS[0] + + service_call["service_data"]["device_id"] = "DOES_NOT_EXISTS" + + with pytest.raises(ServiceValidationError, match=r"Device entry.*not found"): + await hass.services.async_call(**service_call) + + unrelated_config_entry = MockConfigEntry( + domain="TEST", + ) + unrelated_config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=unrelated_config_entry.entry_id, + identifiers={("RANDOM", "ABCD")}, + ) + service_call["service_data"]["device_id"] = device_entry.id + + with pytest.raises(ServiceValidationError, match=r"Config entry.*not found"): + await hass.services.async_call(**service_call) + + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={("RANDOM", "ABCD")}, + ) + service_call["service_data"]["device_id"] = device_entry.id + + with pytest.raises(ServiceValidationError, match=r"Appliance.*not found"): + await hass.services.async_call(**service_call) + + +@pytest.mark.parametrize( + "service_call", + SERVICE_KV_CALL_PARAMS + SERVICE_COMMAND_CALL_PARAMS + SERVICE_PROGRAM_CALL_PARAMS, +) +async def test_services_exception( + service_call: dict[str, Any], + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client_with_exception: MagicMock, + appliance_ha_id: str, + device_registry: dr.DeviceRegistry, +) -> None: + """Raise a ValueError when device id does not match.""" + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client_with_exception) + assert config_entry.state == ConfigEntryState.LOADED + + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, appliance_ha_id)}, + ) + + service_call["service_data"]["device_id"] = device_entry.id + + service_name = service_call["service"] + with pytest.raises( + HomeAssistantError, + match=SERVICE_VALIDATION_ERROR_MAPPING[service_name], + ): + await hass.services.async_call(**service_call) From dc146e393cc8e6fe9b678792a3f7bef9eb5f8a9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Sat, 22 Mar 2025 12:42:54 +0100 Subject: [PATCH 2856/3148] Add coordinator context override to Home Connect entity constructor (#141104) * Improve Home Connect entity constructor to allow coordinator context override * Simplify context usage at entity constructor --- homeassistant/components/home_connect/button.py | 12 +++--------- homeassistant/components/home_connect/entity.py | 6 +++++- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/home_connect/button.py b/homeassistant/components/home_connect/button.py index 726ca8cf670..0bd31c6b7c9 100644 --- a/homeassistant/components/home_connect/button.py +++ b/homeassistant/components/home_connect/button.py @@ -1,6 +1,6 @@ """Provides button entities for Home Connect.""" -from aiohomeconnect.model import CommandKey, EventKey +from aiohomeconnect.model import CommandKey from aiohomeconnect.model.error import HomeConnectError from homeassistant.components.button import ButtonEntity, ButtonEntityDescription @@ -94,15 +94,9 @@ class HomeConnectButtonEntity(HomeConnectEntity, ButtonEntity): super().__init__( coordinator, appliance, - # The entity is subscribed to the appliance connected event, - # but it will receive also the disconnected event - ButtonEntityDescription( - key=EventKey.BSH_COMMON_APPLIANCE_CONNECTED, - ), + desc, + (appliance.info.ha_id,), ) - self.entity_description = desc - self.appliance = appliance - self._attr_unique_id = f"{appliance.info.ha_id}-{desc.key}" def update_native_value(self) -> None: """Set the value of the entity.""" diff --git a/homeassistant/components/home_connect/entity.py b/homeassistant/components/home_connect/entity.py index 8a0f9bd7640..facb3b14a9b 100644 --- a/homeassistant/components/home_connect/entity.py +++ b/homeassistant/components/home_connect/entity.py @@ -40,9 +40,13 @@ class HomeConnectEntity(CoordinatorEntity[HomeConnectCoordinator]): coordinator: HomeConnectCoordinator, appliance: HomeConnectApplianceData, desc: EntityDescription, + context_override: Any | None = None, ) -> None: """Initialize the entity.""" - super().__init__(coordinator, (appliance.info.ha_id, EventKey(desc.key))) + context = (appliance.info.ha_id, EventKey(desc.key)) + if context_override is not None: + context = context_override + super().__init__(coordinator, context) self.appliance = appliance self.entity_description = desc self._attr_unique_id = f"{appliance.info.ha_id}-{desc.key}" From b7d300b49f320e780924726102807f7ff200c26e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Mar 2025 02:06:49 -1000 Subject: [PATCH 2857/3148] Bump habluetooth to 3.37.0 (#141088) changelog: https://github.com/Bluetooth-Devices/habluetooth/compare/v3.36.0...v3.37.0 --- 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 7dfb21a6e0b..fbff513329c 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -21,6 +21,6 @@ "bluetooth-auto-recovery==1.4.5", "bluetooth-data-tools==1.26.1", "dbus-fast==2.39.6", - "habluetooth==3.36.0" + "habluetooth==3.37.0" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index a797b1b5146..f03c7446614 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -34,7 +34,7 @@ dbus-fast==2.39.6 fnv-hash-fast==1.4.0 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 -habluetooth==3.36.0 +habluetooth==3.37.0 hass-nabucasa==0.94.0 hassil==2.2.3 home-assistant-bluetooth==1.13.1 diff --git a/requirements_all.txt b/requirements_all.txt index ab25d9571a8..8a4cd6dfd7a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1112,7 +1112,7 @@ ha-silabs-firmware-client==0.2.0 habiticalib==0.3.7 # homeassistant.components.bluetooth -habluetooth==3.36.0 +habluetooth==3.37.0 # homeassistant.components.cloud hass-nabucasa==0.94.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f5b42042d81..db212b9a64e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -950,7 +950,7 @@ ha-philipsjs==3.2.2 habiticalib==0.3.7 # homeassistant.components.bluetooth -habluetooth==3.36.0 +habluetooth==3.37.0 # homeassistant.components.cloud hass-nabucasa==0.94.0 From 5961a46fc0f998982c410d55df2e7b8c39cb0873 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 22 Mar 2025 13:12:24 +0100 Subject: [PATCH 2858/3148] Start reauth for SmartThings if token expired (#141082) --- .../components/smartthings/__init__.py | 7 ++- tests/components/smartthings/test_init.py | 54 ++++++++++++++++++- 2 files changed, 58 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py index 5cc7b3e2c36..a5e138639de 100644 --- a/homeassistant/components/smartthings/__init__.py +++ b/homeassistant/components/smartthings/__init__.py @@ -4,10 +4,11 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass +from http import HTTPStatus import logging from typing import TYPE_CHECKING, Any, cast -from aiohttp import ClientError +from aiohttp import ClientResponseError from pysmartthings import ( Attribute, Capability, @@ -102,7 +103,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: SmartThingsConfigEntry) try: await session.async_ensure_token_valid() - except ClientError as err: + except ClientResponseError as err: + if err.status == HTTPStatus.BAD_REQUEST: + raise ConfigEntryAuthFailed("Token not valid, trigger renewal") from err raise ConfigEntryNotReady from err client = SmartThings(session=async_get_clientsession(hass)) diff --git a/tests/components/smartthings/test_init.py b/tests/components/smartthings/test_init.py index 2083bb7ea24..3eaa038027d 100644 --- a/tests/components/smartthings/test_init.py +++ b/tests/components/smartthings/test_init.py @@ -1,7 +1,8 @@ """Tests for the SmartThings component init module.""" -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, patch +from aiohttp import ClientResponseError, RequestInfo from pysmartthings import ( Attribute, Capability, @@ -264,6 +265,57 @@ async def test_removing_stale_devices( assert not device_registry.async_get_device({(DOMAIN, "aaa-bbb-ccc")}) +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) +async def test_refreshing_expired_token( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test removing stale devices.""" + with patch( + "homeassistant.components.smartthings.OAuth2Session.async_ensure_token_valid", + side_effect=ClientResponseError( + request_info=RequestInfo( + url="http://example.com", + method="GET", + headers={}, + real_url="http://example.com", + ), + status=400, + history=(), + ), + ): + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + assert len(hass.config_entries.flow.async_progress()) == 1 + + +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) +async def test_error_refreshing_token( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test removing stale devices.""" + with patch( + "homeassistant.components.smartthings.OAuth2Session.async_ensure_token_valid", + side_effect=ClientResponseError( + request_info=RequestInfo( + url="http://example.com", + method="GET", + headers={}, + real_url="http://example.com", + ), + status=500, + history=(), + ), + ): + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + async def test_hub_via_device( hass: HomeAssistant, snapshot: SnapshotAssertion, From 1492c59abea1d6da261ba2bae1cf868ab2147706 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 22 Mar 2025 13:12:37 +0100 Subject: [PATCH 2859/3148] Delete deleted devices on runtime in SmartThings (#141080) --- .../components/smartthings/__init__.py | 17 ++++++++++++++ .../components/smartthings/quality_scale.yaml | 2 +- tests/components/smartthings/test_init.py | 23 ++++++++++++++++++- 3 files changed, 40 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py index a5e138639de..b3f3e93eeb1 100644 --- a/homeassistant/components/smartthings/__init__.py +++ b/homeassistant/components/smartthings/__init__.py @@ -20,6 +20,7 @@ from pysmartthings import ( SmartThingsSinkError, Status, ) +from pysmartthings.models import Lifecycle from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -188,6 +189,22 @@ async def async_setup_entry(hass: HomeAssistant, entry: SmartThingsConfigEntry) for scene in await client.get_scenes(location_id=entry.data[CONF_LOCATION_ID]) } + def handle_deleted_device(device_id: str) -> None: + """Handle a deleted device.""" + dev_entry = device_registry.async_get_device( + identifiers={(DOMAIN, device_id)}, + ) + if dev_entry is not None: + device_registry.async_update_device( + dev_entry.id, remove_config_entry_id=entry.entry_id + ) + + entry.async_on_unload( + client.add_device_lifecycle_event_listener( + Lifecycle.DELETE, handle_deleted_device + ) + ) + entry.runtime_data = SmartThingsData( devices={ device_id: device diff --git a/homeassistant/components/smartthings/quality_scale.yaml b/homeassistant/components/smartthings/quality_scale.yaml index 8a902094687..be8a9039617 100644 --- a/homeassistant/components/smartthings/quality_scale.yaml +++ b/homeassistant/components/smartthings/quality_scale.yaml @@ -73,7 +73,7 @@ rules: status: exempt comment: | This integration doesn't have any cases where raising an issue is needed. - stale-devices: todo + stale-devices: done # Platinum async-dependency: done inject-websession: done diff --git a/tests/components/smartthings/test_init.py b/tests/components/smartthings/test_init.py index 3eaa038027d..c0d0b8b5840 100644 --- a/tests/components/smartthings/test_init.py +++ b/tests/components/smartthings/test_init.py @@ -10,10 +10,11 @@ from pysmartthings import ( DeviceStatus, SmartThingsSinkError, ) -from pysmartthings.models import Subscription +from pysmartthings.models import Lifecycle, Subscription import pytest from syrupy import SnapshotAssertion +from homeassistant.components.climate import HVACMode from homeassistant.components.smartthings import EVENT_BUTTON from homeassistant.components.smartthings.const import CONF_SUBSCRIPTION_ID, DOMAIN from homeassistant.config_entries import ConfigEntryState @@ -345,3 +346,23 @@ async def test_hub_via_device( ).via_device_id == hub_device.id ) + + +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) +async def test_deleted_device_runtime( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test devices that are deleted in runtime.""" + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("climate.ac_office_granit").state == HVACMode.OFF + + for call in devices.add_device_lifecycle_event_listener.call_args_list: + if call[0][0] == Lifecycle.DELETE: + call[0][1]("96a5ef74-5832-a84b-f1f7-ca799957065d") + await hass.async_block_till_done() + + assert hass.states.get("climate.ac_office_granit") is None From 4479b7b13d04df6bacc43097d5348816e5466fd3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Sat, 22 Mar 2025 14:31:28 +0100 Subject: [PATCH 2860/3148] Add missing Home Connect chiller doors (#141105) --- .../components/home_connect/binary_sensor.py | 18 ++++++++++++++++++ .../components/home_connect/strings.json | 9 +++++++++ 2 files changed, 27 insertions(+) diff --git a/homeassistant/components/home_connect/binary_sensor.py b/homeassistant/components/home_connect/binary_sensor.py index 1f82aa71766..b7b7e50047e 100644 --- a/homeassistant/components/home_connect/binary_sensor.py +++ b/homeassistant/components/home_connect/binary_sensor.py @@ -106,8 +106,26 @@ BINARY_SENSORS = ( key=StatusKey.REFRIGERATION_COMMON_DOOR_CHILLER_COMMON, boolean_map=REFRIGERATION_DOOR_BOOLEAN_MAP, device_class=BinarySensorDeviceClass.DOOR, + translation_key="common_chiller_door", + ), + HomeConnectBinarySensorEntityDescription( + key=StatusKey.REFRIGERATION_COMMON_DOOR_CHILLER, + boolean_map=REFRIGERATION_DOOR_BOOLEAN_MAP, + device_class=BinarySensorDeviceClass.DOOR, translation_key="chiller_door", ), + HomeConnectBinarySensorEntityDescription( + key=StatusKey.REFRIGERATION_COMMON_DOOR_CHILLER_LEFT, + boolean_map=REFRIGERATION_DOOR_BOOLEAN_MAP, + device_class=BinarySensorDeviceClass.DOOR, + translation_key="left_chiller_door", + ), + HomeConnectBinarySensorEntityDescription( + key=StatusKey.REFRIGERATION_COMMON_DOOR_CHILLER_RIGHT, + boolean_map=REFRIGERATION_DOOR_BOOLEAN_MAP, + device_class=BinarySensorDeviceClass.DOOR, + translation_key="right_chiller_door", + ), HomeConnectBinarySensorEntityDescription( key=StatusKey.REFRIGERATION_COMMON_DOOR_FLEX_COMPARTMENT, boolean_map=REFRIGERATION_DOOR_BOOLEAN_MAP, diff --git a/homeassistant/components/home_connect/strings.json b/homeassistant/components/home_connect/strings.json index 1b4c79f6092..00ab29affd8 100644 --- a/homeassistant/components/home_connect/strings.json +++ b/homeassistant/components/home_connect/strings.json @@ -811,9 +811,18 @@ "bottle_cooler_door": { "name": "Bottle cooler door" }, + "common_chiller_door": { + "name": "Common chiller door" + }, "chiller_door": { "name": "Chiller door" }, + "left_chiller_door": { + "name": "Left chiller door" + }, + "right_chiller_door": { + "name": "Right chiller door" + }, "flex_compartment_door": { "name": "Flex compartment door" }, From 2453e7e6868e479764c34b64da305eabf2bcd886 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sat, 22 Mar 2025 15:30:24 +0100 Subject: [PATCH 2861/3148] Improve descriptions of `fan_min_on_time` in `ecobee` actions (#141086) Add the explanations from the online docs to the `description` strings of both the `set_fan_min_on_time` action and its `fan_min_on_time` field. Make the `fan_min_on_time` field of the `create_vacation` action consistent by dropping "(0 to 60)" from it (the UI takes care of that). Fix sentence-casing of "Away indefinitely" state. --- homeassistant/components/ecobee/strings.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/ecobee/strings.json b/homeassistant/components/ecobee/strings.json index 2b44c45edef..078643ee789 100644 --- a/homeassistant/components/ecobee/strings.json +++ b/homeassistant/components/ecobee/strings.json @@ -25,7 +25,7 @@ "state_attributes": { "preset_mode": { "state": { - "away_indefinitely": "Away Indefinitely" + "away_indefinitely": "Away indefinitely" } } } @@ -91,7 +91,7 @@ }, "fan_min_on_time": { "name": "Fan minimum on time", - "description": "Minimum number of minutes to run the fan each hour (0 to 60) during the vacation." + "description": "Minimum number of minutes to run the fan each hour during the vacation." } } }, @@ -125,7 +125,7 @@ }, "set_fan_min_on_time": { "name": "Set fan minimum on time", - "description": "Sets the minimum fan on time.", + "description": "Sets the minimum amount of time that the fan will run per hour.", "fields": { "entity_id": { "name": "Entity", @@ -133,7 +133,7 @@ }, "fan_min_on_time": { "name": "[%key:component::ecobee::services::create_vacation::fields::fan_min_on_time::name%]", - "description": "New value of fan min on time." + "description": "Minimum number of minutes to run the fan each hour." } } }, From 37a048a2cabad036c7d8e056fbdd70ae6406fa3f Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Sat, 22 Mar 2025 15:53:12 +0100 Subject: [PATCH 2862/3148] Move Vodafone Station to silver quality scale (#141106) --- homeassistant/components/vodafone_station/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/vodafone_station/manifest.json b/homeassistant/components/vodafone_station/manifest.json index e3a595d5af8..29cb3c070ab 100644 --- a/homeassistant/components/vodafone_station/manifest.json +++ b/homeassistant/components/vodafone_station/manifest.json @@ -7,6 +7,6 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["aiovodafone"], - "quality_scale": "bronze", + "quality_scale": "silver", "requirements": ["aiovodafone==0.6.1"] } From b2942d61b3f521763916277cfd0f99d00bcd7ec8 Mon Sep 17 00:00:00 2001 From: Matthew FitzGerald-Chamberlain Date: Sat, 22 Mar 2025 09:57:30 -0500 Subject: [PATCH 2863/3148] Update pyaprilaire to 0.8.1 (#141094) * Update pyaprilaire to 0.8.1 * Update requirements --- homeassistant/components/aprilaire/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/aprilaire/manifest.json b/homeassistant/components/aprilaire/manifest.json index 577de8ae88d..b40460dd61b 100644 --- a/homeassistant/components/aprilaire/manifest.json +++ b/homeassistant/components/aprilaire/manifest.json @@ -7,5 +7,5 @@ "integration_type": "device", "iot_class": "local_push", "loggers": ["pyaprilaire"], - "requirements": ["pyaprilaire==0.7.7"] + "requirements": ["pyaprilaire==0.8.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 8a4cd6dfd7a..289184b8eca 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1817,7 +1817,7 @@ pyairnow==1.2.1 pyairvisual==2023.08.1 # homeassistant.components.aprilaire -pyaprilaire==0.7.7 +pyaprilaire==0.8.1 # homeassistant.components.asuswrt pyasuswrt==0.1.21 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index db212b9a64e..9ed83a90659 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1494,7 +1494,7 @@ pyairnow==1.2.1 pyairvisual==2023.08.1 # homeassistant.components.aprilaire -pyaprilaire==0.7.7 +pyaprilaire==0.8.1 # homeassistant.components.asuswrt pyasuswrt==0.1.21 From fc0dbcd6000fd8b697a9f803d3c16b5270bc38e8 Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Sat, 22 Mar 2025 12:01:57 -0400 Subject: [PATCH 2864/3148] Refresh coordinator after map sleep for Roborock (#141093) Refresh coordinator after the map sleep --- homeassistant/components/roborock/select.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/roborock/select.py b/homeassistant/components/roborock/select.py index c79bf817d09..208020dccab 100644 --- a/homeassistant/components/roborock/select.py +++ b/homeassistant/components/roborock/select.py @@ -149,8 +149,9 @@ class RoborockCurrentMapSelectEntity(RoborockCoordinatedEntityV1, SelectEntity): """Set the option.""" for map_id, map_ in self.coordinator.maps.items(): if map_.name == option: - await self.send( + await self._send_command( RoborockCommand.LOAD_MULTI_MAP, + self.api, [map_id], ) # Update the current map id manually so that nothing gets broken @@ -159,6 +160,7 @@ class RoborockCurrentMapSelectEntity(RoborockCoordinatedEntityV1, SelectEntity): # We need to wait after updating the map # so that other commands will be executed correctly. await asyncio.sleep(MAP_SLEEP) + await self.coordinator.async_refresh() break @property From 765691c84d570a73d56083e073d2af67ad82c575 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 22 Mar 2025 17:59:15 +0100 Subject: [PATCH 2865/3148] Add power binary sensor for SmartThings (#141126) --- .../components/smartthings/binary_sensor.py | 25 ++- .../snapshots/test_binary_sensor.ambr | 192 ++++++++++++++++++ 2 files changed, 216 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/smartthings/binary_sensor.py b/homeassistant/components/smartthings/binary_sensor.py index b67b15dfdbc..22e21de399b 100644 --- a/homeassistant/components/smartthings/binary_sensor.py +++ b/homeassistant/components/smartthings/binary_sensor.py @@ -4,7 +4,7 @@ from __future__ import annotations from dataclasses import dataclass -from pysmartthings import Attribute, Capability, SmartThings +from pysmartthings import Attribute, Capability, Category, SmartThings from homeassistant.components.automation import automations_with_entity from homeassistant.components.binary_sensor import ( @@ -33,6 +33,7 @@ class SmartThingsBinarySensorEntityDescription(BinarySensorEntityDescription): """Describe a SmartThings binary sensor entity.""" is_on_key: str + category: set[Category] | None = None CAPABILITY_TO_SENSORS: dict[ @@ -96,6 +97,14 @@ CAPABILITY_TO_SENSORS: dict[ is_on_key="detected", ) }, + Capability.SWITCH: { + Attribute.SWITCH: SmartThingsBinarySensorEntityDescription( + key=Attribute.SWITCH, + device_class=BinarySensorDeviceClass.POWER, + is_on_key="on", + category={Category.DRYER, Category.WASHER}, + ) + }, Capability.TAMPER_ALERT: { Attribute.TAMPER: SmartThingsBinarySensorEntityDescription( key=Attribute.TAMPER, @@ -122,6 +131,16 @@ CAPABILITY_TO_SENSORS: dict[ } +def get_main_component_category( + device: FullDevice, +) -> Category | str: + """Get the main component of a device.""" + main = next( + component for component in device.device.components if component.id == MAIN + ) + return main.user_category or main.manufacturer_category + + async def async_setup_entry( hass: HomeAssistant, entry: SmartThingsConfigEntry, @@ -141,6 +160,10 @@ async def async_setup_entry( for capability, attribute_map in CAPABILITY_TO_SENSORS.items() if capability in device.status[MAIN] for attribute, description in attribute_map.items() + if ( + not description.category + or get_main_component_category(device) in description.category + ) ) diff --git a/tests/components/smartthings/snapshots/test_binary_sensor.ambr b/tests/components/smartthings/snapshots/test_binary_sensor.ambr index 4edb3160cf8..602e3e1d56c 100644 --- a/tests/components/smartthings/snapshots/test_binary_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_binary_sensor.ambr @@ -614,6 +614,54 @@ 'state': 'off', }) # --- +# name: test_all_entities[da_wm_wd_000001][binary_sensor.dryer_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.dryer_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b.switch', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wd_000001][binary_sensor.dryer_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Dryer Power', + }), + 'context': , + 'entity_id': 'binary_sensor.dryer_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_all_entities[da_wm_wd_000001][binary_sensor.dryer_remote_control-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -708,6 +756,54 @@ 'state': 'off', }) # --- +# name: test_all_entities[da_wm_wd_000001_1][binary_sensor.seca_roupa_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.seca_roupa_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '3a6c4e05-811d-5041-e956-3d04c424cbcd.switch', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wd_000001_1][binary_sensor.seca_roupa_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Seca-Roupa Power', + }), + 'context': , + 'entity_id': 'binary_sensor.seca_roupa_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_all_entities[da_wm_wd_000001_1][binary_sensor.seca_roupa_remote_control-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -802,6 +898,54 @@ 'state': 'off', }) # --- +# name: test_all_entities[da_wm_wm_000001][binary_sensor.washer_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.washer_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47.switch', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wm_000001][binary_sensor.washer_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Washer Power', + }), + 'context': , + 'entity_id': 'binary_sensor.washer_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_all_entities[da_wm_wm_000001][binary_sensor.washer_remote_control-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -896,6 +1040,54 @@ 'state': 'off', }) # --- +# name: test_all_entities[da_wm_wm_000001_1][binary_sensor.washing_machine_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.washing_machine_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '63803fae-cbed-f356-a063-2cf148ae3ca7.switch', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wm_000001_1][binary_sensor.washing_machine_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Washing Machine Power', + }), + 'context': , + 'entity_id': 'binary_sensor.washing_machine_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_all_entities[da_wm_wm_000001_1][binary_sensor.washing_machine_remote_control-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 1b8b348effbbf3cf3a1b337565ed5957fce84573 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 22 Mar 2025 18:03:50 +0100 Subject: [PATCH 2866/3148] Add select platform to SmartThings (#141115) * Add select platform to SmartThings * Add select platform to SmartThings --- .../components/smartthings/__init__.py | 1 + .../components/smartthings/icons.json | 9 + .../components/smartthings/select.py | 120 +++++++++ .../components/smartthings/strings.json | 9 + .../smartthings/snapshots/test_select.ambr | 233 ++++++++++++++++++ tests/components/smartthings/test_select.py | 121 +++++++++ 6 files changed, 493 insertions(+) create mode 100644 homeassistant/components/smartthings/select.py create mode 100644 tests/components/smartthings/snapshots/test_select.ambr create mode 100644 tests/components/smartthings/test_select.py diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py index b3f3e93eeb1..9e72a71ee86 100644 --- a/homeassistant/components/smartthings/__init__.py +++ b/homeassistant/components/smartthings/__init__.py @@ -86,6 +86,7 @@ PLATFORMS = [ Platform.LIGHT, Platform.LOCK, Platform.SCENE, + Platform.SELECT, Platform.SENSOR, Platform.SWITCH, Platform.UPDATE, diff --git a/homeassistant/components/smartthings/icons.json b/homeassistant/components/smartthings/icons.json index 971550b8f69..666dc07e686 100644 --- a/homeassistant/components/smartthings/icons.json +++ b/homeassistant/components/smartthings/icons.json @@ -13,6 +13,15 @@ "on": "mdi:lock" } } + }, + "select": { + "operating_state": { + "state": { + "run": "mdi:play", + "pause": "mdi:pause", + "stop": "mdi:stop" + } + } } } } diff --git a/homeassistant/components/smartthings/select.py b/homeassistant/components/smartthings/select.py new file mode 100644 index 00000000000..6011b7947b7 --- /dev/null +++ b/homeassistant/components/smartthings/select.py @@ -0,0 +1,120 @@ +"""Support for select entities through the SmartThings cloud API.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from pysmartthings import Attribute, Capability, Command, SmartThings + +from homeassistant.components.select import SelectEntity, SelectEntityDescription +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import FullDevice, SmartThingsConfigEntry +from .const import MAIN +from .entity import SmartThingsEntity + + +@dataclass(frozen=True, kw_only=True) +class SmartThingsSelectDescription(SelectEntityDescription): + """Class describing SmartThings select entities.""" + + key: Capability + requires_remote_control_status: bool + options_attribute: Attribute + status_attribute: Attribute + command: Command + + +CAPABILITIES_TO_SELECT: dict[Capability | str, SmartThingsSelectDescription] = { + Capability.DRYER_OPERATING_STATE: SmartThingsSelectDescription( + key=Capability.DRYER_OPERATING_STATE, + name=None, + translation_key="operating_state", + requires_remote_control_status=True, + options_attribute=Attribute.SUPPORTED_MACHINE_STATES, + status_attribute=Attribute.MACHINE_STATE, + command=Command.SET_MACHINE_STATE, + ), + Capability.WASHER_OPERATING_STATE: SmartThingsSelectDescription( + key=Capability.WASHER_OPERATING_STATE, + name=None, + translation_key="operating_state", + requires_remote_control_status=True, + options_attribute=Attribute.SUPPORTED_MACHINE_STATES, + status_attribute=Attribute.MACHINE_STATE, + command=Command.SET_MACHINE_STATE, + ), +} + + +async def async_setup_entry( + hass: HomeAssistant, + entry: SmartThingsConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Add select entities for a config entry.""" + entry_data = entry.runtime_data + async_add_entities( + SmartThingsSelectEntity( + entry_data.client, device, CAPABILITIES_TO_SELECT[capability] + ) + for device in entry_data.devices.values() + for capability in device.status[MAIN] + if capability in CAPABILITIES_TO_SELECT + ) + + +class SmartThingsSelectEntity(SmartThingsEntity, SelectEntity): + """Define a SmartThings select.""" + + entity_description: SmartThingsSelectDescription + + def __init__( + self, + client: SmartThings, + device: FullDevice, + entity_description: SmartThingsSelectDescription, + ) -> None: + """Initialize the instance.""" + capabilities = {entity_description.key} + if entity_description.requires_remote_control_status: + capabilities.add(Capability.REMOTE_CONTROL_STATUS) + super().__init__(client, device, capabilities) + self.entity_description = entity_description + self._attr_unique_id = ( + f"{device.device.device_id}_{MAIN}_{entity_description.key}" + ) + + @property + def options(self) -> list[str]: + """Return the list of options.""" + return self.get_attribute_value( + self.entity_description.key, self.entity_description.options_attribute + ) + + @property + def current_option(self) -> str | None: + """Return the current option.""" + return self.get_attribute_value( + self.entity_description.key, self.entity_description.status_attribute + ) + + async def async_select_option(self, option: str) -> None: + """Select an option.""" + if ( + self.entity_description.requires_remote_control_status + and self.get_attribute_value( + Capability.REMOTE_CONTROL_STATUS, Attribute.REMOTE_CONTROL_ENABLED + ) + == "false" + ): + raise ServiceValidationError( + "Can only be updated when remote control is enabled" + ) + await self.execute_device_command( + self.entity_description.key, + self.entity_description.command, + option, + ) diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index 39973ef5380..2f1310b9c27 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -78,6 +78,15 @@ } } }, + "select": { + "operating_state": { + "state": { + "run": "[%key:component::smartthings::entity::sensor::dishwasher_machine_state::state::run%]", + "pause": "[%key:common::state::paused%]", + "stop": "[%key:component::smartthings::entity::sensor::dishwasher_machine_state::state::stop%]" + } + } + }, "sensor": { "lighting_mode": { "name": "Activity lighting mode" diff --git a/tests/components/smartthings/snapshots/test_select.ambr b/tests/components/smartthings/snapshots/test_select.ambr new file mode 100644 index 00000000000..649e876bb9e --- /dev/null +++ b/tests/components/smartthings/snapshots/test_select.ambr @@ -0,0 +1,233 @@ +# serializer version: 1 +# name: test_all_entities[da_wm_wd_000001][select.dryer-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'stop', + 'run', + 'pause', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.dryer', + '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': None, + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'operating_state', + 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b_main_dryerOperatingState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wd_000001][select.dryer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dryer', + 'options': list([ + 'stop', + 'run', + 'pause', + ]), + }), + 'context': , + 'entity_id': 'select.dryer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'stop', + }) +# --- +# name: test_all_entities[da_wm_wd_000001_1][select.seca_roupa-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'stop', + 'run', + 'pause', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.seca_roupa', + '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': None, + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'operating_state', + 'unique_id': '3a6c4e05-811d-5041-e956-3d04c424cbcd_main_dryerOperatingState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wd_000001_1][select.seca_roupa-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Seca-Roupa', + 'options': list([ + 'stop', + 'run', + 'pause', + ]), + }), + 'context': , + 'entity_id': 'select.seca_roupa', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'stop', + }) +# --- +# name: test_all_entities[da_wm_wm_000001][select.washer-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'stop', + 'run', + 'pause', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.washer', + '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': None, + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'operating_state', + 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47_main_washerOperatingState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wm_000001][select.washer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Washer', + 'options': list([ + 'stop', + 'run', + 'pause', + ]), + }), + 'context': , + 'entity_id': 'select.washer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'stop', + }) +# --- +# name: test_all_entities[da_wm_wm_000001_1][select.washing_machine-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'stop', + 'run', + 'pause', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.washing_machine', + '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': None, + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'operating_state', + 'unique_id': '63803fae-cbed-f356-a063-2cf148ae3ca7_main_washerOperatingState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wm_000001_1][select.washing_machine-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Washing Machine', + 'options': list([ + 'stop', + 'run', + 'pause', + ]), + }), + 'context': , + 'entity_id': 'select.washing_machine', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'run', + }) +# --- diff --git a/tests/components/smartthings/test_select.py b/tests/components/smartthings/test_select.py new file mode 100644 index 00000000000..2c5c55239f2 --- /dev/null +++ b/tests/components/smartthings/test_select.py @@ -0,0 +1,121 @@ +"""Test for the SmartThings select platform.""" + +from unittest.mock import AsyncMock + +from pysmartthings import Attribute, Capability, Command +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.select import ( + ATTR_OPTION, + DOMAIN as SELECT_DOMAIN, + SERVICE_SELECT_OPTION, +) +from homeassistant.components.smartthings import MAIN +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import entity_registry as er + +from . import ( + set_attribute_value, + setup_integration, + snapshot_smartthings_entities, + trigger_update, +) + +from tests.common import MockConfigEntry + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + await setup_integration(hass, mock_config_entry) + + snapshot_smartthings_entities(hass, entity_registry, snapshot, Platform.SELECT) + + +@pytest.mark.parametrize("device_fixture", ["da_wm_wd_000001"]) +async def test_state_update( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test state update.""" + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("select.dryer").state == "stop" + + await trigger_update( + hass, + devices, + "02f7256e-8353-5bdd-547f-bd5b1647e01b", + Capability.DRYER_OPERATING_STATE, + Attribute.MACHINE_STATE, + "run", + ) + + assert hass.states.get("select.dryer").state == "run" + + +@pytest.mark.parametrize("device_fixture", ["da_wm_wd_000001"]) +async def test_select_option( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test state update.""" + set_attribute_value( + devices, + Capability.REMOTE_CONTROL_STATUS, + Attribute.REMOTE_CONTROL_ENABLED, + "true", + ) + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: "select.dryer", ATTR_OPTION: "run"}, + blocking=True, + ) + devices.execute_device_command.assert_called_once_with( + "02f7256e-8353-5bdd-547f-bd5b1647e01b", + Capability.DRYER_OPERATING_STATE, + Command.SET_MACHINE_STATE, + MAIN, + argument="run", + ) + + +@pytest.mark.parametrize("device_fixture", ["da_wm_wd_000001"]) +async def test_select_option_without_remote_control( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test state update.""" + set_attribute_value( + devices, + Capability.REMOTE_CONTROL_STATUS, + Attribute.REMOTE_CONTROL_ENABLED, + "false", + ) + await setup_integration(hass, mock_config_entry) + + with pytest.raises( + ServiceValidationError, + match="Can only be updated when remote control is enabled", + ): + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: "select.dryer", ATTR_OPTION: "run"}, + blocking=True, + ) + devices.execute_device_command.assert_not_called() From ec4de0dccee2c6d90275483f7dea9f964cf27268 Mon Sep 17 00:00:00 2001 From: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> Date: Sat, 22 Mar 2025 12:14:42 -0500 Subject: [PATCH 2867/3148] Always allow browsing TuneIn for HEOS (#141131) * Always allow browsing TuneIn * Update test snapshots * Retry CI --- homeassistant/components/heos/media_player.py | 6 ++++-- .../heos/snapshots/test_media_player.ambr | 20 +++++++++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/heos/media_player.py b/homeassistant/components/heos/media_player.py index 5c0a66a02fa..311190ccb74 100644 --- a/homeassistant/components/heos/media_player.py +++ b/homeassistant/components/heos/media_player.py @@ -580,7 +580,7 @@ class HeosMediaPlayer(CoordinatorEntity[HeosCoordinator], MediaPlayerEntity): children: list[BrowseMedia] = [ _media_to_browse_media(source) for source in self.coordinator.heos.music_sources.values() - if source.available + if source.available or source.source_id == heos_const.MUSIC_SOURCE_TUNEIN ] root = BrowseMedia( title="Music Sources", @@ -654,7 +654,9 @@ def _media_to_browse_media(media: MediaItem | MediaMusicSource) -> BrowseMedia: can_play = False if isinstance(media, MediaMusicSource): - can_expand = media.available + can_expand = ( + media.source_id == heos_const.MUSIC_SOURCE_TUNEIN or media.available + ) else: can_expand = media.browsable can_play = media.playable diff --git a/tests/components/heos/snapshots/test_media_player.ambr b/tests/components/heos/snapshots/test_media_player.ambr index d2cd8b3e12a..4cf84363ba0 100644 --- a/tests/components/heos/snapshots/test_media_player.ambr +++ b/tests/components/heos/snapshots/test_media_player.ambr @@ -79,6 +79,16 @@ 'thumbnail': '', 'title': 'Pandora', }), + dict({ + 'can_expand': True, + 'can_play': False, + 'children_media_class': None, + 'media_class': 'directory', + 'media_content_id': 'heos://media/3/music_service?name=TuneIn&image_url=&available=False', + 'media_content_type': '', + 'thumbnail': '', + 'title': 'TuneIn', + }), dict({ 'can_expand': True, 'can_play': False, @@ -114,6 +124,16 @@ 'thumbnail': '', 'title': 'Pandora', }), + dict({ + 'can_expand': True, + 'can_play': False, + 'children_media_class': None, + 'media_class': 'directory', + 'media_content_id': 'heos://media/3/music_service?name=TuneIn&image_url=&available=False', + 'media_content_type': '', + 'thumbnail': '', + 'title': 'TuneIn', + }), ]), 'children_media_class': 'directory', 'media_class': 'directory', From 436acaf3d036cc5574671fa64d261bdfcca9966b Mon Sep 17 00:00:00 2001 From: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> Date: Sat, 22 Mar 2025 12:37:11 -0500 Subject: [PATCH 2868/3148] Remove uncalled function in HEOS (#141134) Remove uncalled function --- homeassistant/components/heos/coordinator.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/homeassistant/components/heos/coordinator.py b/homeassistant/components/heos/coordinator.py index 0333c60ec21..0bc948bccd7 100644 --- a/homeassistant/components/heos/coordinator.py +++ b/homeassistant/components/heos/coordinator.py @@ -268,15 +268,6 @@ class HeosCoordinator(DataUpdateCoordinator[None]): else: self._source_list.extend([source.name for source in self._inputs]) - async def _async_update_players(self) -> None: - """Update players after reconnection.""" - try: - player_updates = await self.heos.load_players() - except HeosError as error: - _LOGGER.error("Unable to refresh players: %s", error) - return - self._async_handle_player_update_result(player_updates) - @callback def async_get_source_list(self) -> list[str]: """Return the list of sources for players.""" From 92c619cdd6bd3812f2fdd43eb3b743334adc7a15 Mon Sep 17 00:00:00 2001 From: tdfountain <174762217+tdfountain@users.noreply.github.com> Date: Sat, 22 Mar 2025 10:40:47 -0700 Subject: [PATCH 2869/3148] Create new entity base class for NUT (#141122) --- homeassistant/components/nut/entity.py | 62 ++++++++++++++++++++++++++ homeassistant/components/nut/sensor.py | 50 ++++----------------- 2 files changed, 70 insertions(+), 42 deletions(-) create mode 100644 homeassistant/components/nut/entity.py diff --git a/homeassistant/components/nut/entity.py b/homeassistant/components/nut/entity.py new file mode 100644 index 00000000000..8179526acf3 --- /dev/null +++ b/homeassistant/components/nut/entity.py @@ -0,0 +1,62 @@ +"""Base entity for the NUT integration.""" + +from __future__ import annotations + +from dataclasses import asdict +from typing import cast + +from homeassistant.const import ( + ATTR_MANUFACTURER, + ATTR_MODEL, + ATTR_SERIAL_NUMBER, + ATTR_SW_VERSION, +) +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) + +from . import PyNUTData +from .const import DOMAIN + +NUT_DEV_INFO_TO_DEV_INFO: dict[str, str] = { + "manufacturer": ATTR_MANUFACTURER, + "model": ATTR_MODEL, + "firmware": ATTR_SW_VERSION, + "serial": ATTR_SERIAL_NUMBER, +} + + +class NUTBaseEntity(CoordinatorEntity[DataUpdateCoordinator]): + """NUT base entity.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: DataUpdateCoordinator, + data: PyNUTData, + unique_id: str, + ) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + + device_name = data.name.title() + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, unique_id)}, + name=device_name, + ) + self._attr_device_info.update(_get_nut_device_info(data)) + + +def _get_nut_device_info(data: PyNUTData) -> DeviceInfo: + """Return a DeviceInfo object filled with NUT device info.""" + nut_dev_infos = asdict(data.device_info) + nut_infos = { + info_key: nut_dev_infos[nut_key] + for nut_key, info_key in NUT_DEV_INFO_TO_DEV_INFO.items() + if nut_dev_infos[nut_key] is not None + } + + return cast(DeviceInfo, nut_infos) diff --git a/homeassistant/components/nut/sensor.py b/homeassistant/components/nut/sensor.py index ceea426c06d..189d5906f6d 100644 --- a/homeassistant/components/nut/sensor.py +++ b/homeassistant/components/nut/sensor.py @@ -1,10 +1,9 @@ -"""Provides a sensor to track various status aspects of a UPS.""" +"""Provides a sensor to track various status aspects of a NUT device.""" from __future__ import annotations -from dataclasses import asdict import logging -from typing import Final, cast +from typing import Final from homeassistant.components.sensor import ( SensorDeviceClass, @@ -13,10 +12,6 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.const import ( - ATTR_MANUFACTURER, - ATTR_MODEL, - ATTR_SERIAL_NUMBER, - ATTR_SW_VERSION, PERCENTAGE, STATE_UNKNOWN, EntityCategory, @@ -29,22 +24,12 @@ from homeassistant.const import ( UnitOfTime, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, -) +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from . import NutConfigEntry, PyNUTData -from .const import DOMAIN, KEY_STATUS, KEY_STATUS_DISPLAY, STATE_TYPES - -NUT_DEV_INFO_TO_DEV_INFO: dict[str, str] = { - "manufacturer": ATTR_MANUFACTURER, - "model": ATTR_MODEL, - "firmware": ATTR_SW_VERSION, - "serial": ATTR_SERIAL_NUMBER, -} +from .const import KEY_STATUS, KEY_STATUS_DISPLAY, STATE_TYPES +from .entity import NUTBaseEntity AMBIENT_PRESENT = "ambient.present" AMBIENT_SENSORS = { @@ -1011,18 +996,6 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { } -def _get_nut_device_info(data: PyNUTData) -> DeviceInfo: - """Return a DeviceInfo object filled with NUT device info.""" - nut_dev_infos = asdict(data.device_info) - nut_infos = { - info_key: nut_dev_infos[nut_key] - for nut_key, info_key in NUT_DEV_INFO_TO_DEV_INFO.items() - if nut_dev_infos[nut_key] is not None - } - - return cast(DeviceInfo, nut_infos) - - async def async_setup_entry( hass: HomeAssistant, config_entry: NutConfigEntry, @@ -1113,7 +1086,7 @@ async def async_setup_entry( ) -class NUTSensor(CoordinatorEntity[DataUpdateCoordinator[dict[str, str]]], SensorEntity): +class NUTSensor(NUTBaseEntity, SensorEntity): """Representation of a sensor entity for NUT status values.""" _attr_has_entity_name = True @@ -1126,20 +1099,13 @@ class NUTSensor(CoordinatorEntity[DataUpdateCoordinator[dict[str, str]]], Sensor unique_id: str, ) -> None: """Initialize the sensor.""" - super().__init__(coordinator) + super().__init__(coordinator, data, unique_id) self.entity_description = sensor_description - - device_name = data.name.title() self._attr_unique_id = f"{unique_id}_{sensor_description.key}" - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, unique_id)}, - name=device_name, - ) - self._attr_device_info.update(_get_nut_device_info(data)) @property def native_value(self) -> str | None: - """Return entity state from ups.""" + """Return entity state from NUT device.""" status = self.coordinator.data if self.entity_description.key == KEY_STATUS_DISPLAY: return _format_display_state(status) From 931ce8951e7f1c9f32ed0f0503064390f4cf2a43 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 22 Mar 2025 18:41:15 +0100 Subject: [PATCH 2870/3148] Use category to define SmartThings binary sensor device class (#141075) * Use category to define SmartThings binary sensor device class * Fix --- .../components/smartthings/binary_sensor.py | 13 ++ .../fixtures/devices/contact_sensor.json | 2 +- .../snapshots/test_binary_sensor.ambr | 112 +++++++++--------- 3 files changed, 70 insertions(+), 57 deletions(-) diff --git a/homeassistant/components/smartthings/binary_sensor.py b/homeassistant/components/smartthings/binary_sensor.py index 22e21de399b..ef431c08f24 100644 --- a/homeassistant/components/smartthings/binary_sensor.py +++ b/homeassistant/components/smartthings/binary_sensor.py @@ -33,6 +33,7 @@ class SmartThingsBinarySensorEntityDescription(BinarySensorEntityDescription): """Describe a SmartThings binary sensor entity.""" is_on_key: str + category_device_class: dict[Category | str, BinarySensorDeviceClass] | None = None category: set[Category] | None = None @@ -52,6 +53,11 @@ CAPABILITY_TO_SENSORS: dict[ key=Attribute.CONTACT, device_class=BinarySensorDeviceClass.DOOR, is_on_key="open", + category_device_class={ + Category.GARAGE_DOOR: BinarySensorDeviceClass.GARAGE_DOOR, + Category.DOOR: BinarySensorDeviceClass.DOOR, + Category.WINDOW: BinarySensorDeviceClass.WINDOW, + }, ) }, Capability.FILTER_STATUS: { @@ -186,6 +192,13 @@ class SmartThingsBinarySensor(SmartThingsEntity, BinarySensorEntity): self.capability = capability self.entity_description = entity_description self._attr_unique_id = f"{device.device.device_id}.{attribute}" + if ( + entity_description.category_device_class + and (category := get_main_component_category(device)) + in entity_description.category_device_class + ): + self._attr_device_class = entity_description.category_device_class[category] + self._attr_name = None @property def is_on(self) -> bool: diff --git a/tests/components/smartthings/fixtures/devices/contact_sensor.json b/tests/components/smartthings/fixtures/devices/contact_sensor.json index 68070abbfc3..9823a70cb61 100644 --- a/tests/components/smartthings/fixtures/devices/contact_sensor.json +++ b/tests/components/smartthings/fixtures/devices/contact_sensor.json @@ -42,7 +42,7 @@ "categoryType": "manufacturer" }, { - "name": "ContactSensor", + "name": "GarageDoor", "categoryType": "user" } ] diff --git a/tests/components/smartthings/snapshots/test_binary_sensor.ambr b/tests/components/smartthings/snapshots/test_binary_sensor.ambr index 602e3e1d56c..d05cf3124fa 100644 --- a/tests/components/smartthings/snapshots/test_binary_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_binary_sensor.ambr @@ -95,7 +95,7 @@ 'state': 'off', }) # --- -# name: test_all_entities[contact_sensor][binary_sensor.front_door_open_closed_sensor_door-entry] +# name: test_all_entities[contact_sensor][binary_sensor.front_door_open_closed_sensor-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -108,7 +108,7 @@ 'disabled_by': None, 'domain': 'binary_sensor', 'entity_category': None, - 'entity_id': 'binary_sensor.front_door_open_closed_sensor_door', + 'entity_id': 'binary_sensor.front_door_open_closed_sensor', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -118,9 +118,9 @@ 'name': None, 'options': dict({ }), - 'original_device_class': , + 'original_device_class': , 'original_icon': None, - 'original_name': 'Door', + 'original_name': None, 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -129,14 +129,14 @@ 'unit_of_measurement': None, }) # --- -# name: test_all_entities[contact_sensor][binary_sensor.front_door_open_closed_sensor_door-state] +# name: test_all_entities[contact_sensor][binary_sensor.front_door_open_closed_sensor-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'door', - 'friendly_name': '.Front Door Open/Closed Sensor Door', + 'device_class': 'garage_door', + 'friendly_name': '.Front Door Open/Closed Sensor', }), 'context': , - 'entity_id': 'binary_sensor.front_door_open_closed_sensor_door', + 'entity_id': 'binary_sensor.front_door_open_closed_sensor', 'last_changed': , 'last_reported': , 'last_updated': , @@ -1279,6 +1279,54 @@ 'state': 'on', }) # --- +# name: test_all_entities[multipurpose_sensor][binary_sensor.deck_door-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.deck_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '7d246592-93db-4d72-a10d-5a51793ece8c.contact', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[multipurpose_sensor][binary_sensor.deck_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Deck Door', + }), + 'context': , + 'entity_id': 'binary_sensor.deck_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_all_entities[multipurpose_sensor][binary_sensor.deck_door_acceleration-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1327,54 +1375,6 @@ 'state': 'off', }) # --- -# name: test_all_entities[multipurpose_sensor][binary_sensor.deck_door_door-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.deck_door_door', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Door', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '7d246592-93db-4d72-a10d-5a51793ece8c.contact', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[multipurpose_sensor][binary_sensor.deck_door_door-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'door', - 'friendly_name': 'Deck Door Door', - }), - 'context': , - 'entity_id': 'binary_sensor.deck_door_door', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }) -# --- # name: test_all_entities[virtual_valve][binary_sensor.volvo_valve-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 4b4d75063cb077820c358b2761d0e163ff03eb6d Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 22 Mar 2025 19:03:26 +0100 Subject: [PATCH 2871/3148] Add number platform to SmartThings (#141063) * Add select platform to SmartThings * Add number platform to SmartThings * Fix * Fix * Fix * Fix --- .../components/smartthings/__init__.py | 1 + .../components/smartthings/icons.json | 5 + .../components/smartthings/number.py | 77 ++++++++++++ .../components/smartthings/strings.json | 6 + .../smartthings/snapshots/test_number.ambr | 115 ++++++++++++++++++ tests/components/smartthings/test_number.py | 81 ++++++++++++ 6 files changed, 285 insertions(+) create mode 100644 homeassistant/components/smartthings/number.py create mode 100644 tests/components/smartthings/snapshots/test_number.ambr create mode 100644 tests/components/smartthings/test_number.py diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py index 9e72a71ee86..31309b73a66 100644 --- a/homeassistant/components/smartthings/__init__.py +++ b/homeassistant/components/smartthings/__init__.py @@ -85,6 +85,7 @@ PLATFORMS = [ Platform.FAN, Platform.LIGHT, Platform.LOCK, + Platform.NUMBER, Platform.SCENE, Platform.SELECT, Platform.SENSOR, diff --git a/homeassistant/components/smartthings/icons.json b/homeassistant/components/smartthings/icons.json index 666dc07e686..c5c18efa5a1 100644 --- a/homeassistant/components/smartthings/icons.json +++ b/homeassistant/components/smartthings/icons.json @@ -14,6 +14,11 @@ } } }, + "number": { + "washer_rinse_cycles": { + "default": "mdi:waves-arrow-up" + } + }, "select": { "operating_state": { "state": { diff --git a/homeassistant/components/smartthings/number.py b/homeassistant/components/smartthings/number.py new file mode 100644 index 00000000000..cbd200e20b6 --- /dev/null +++ b/homeassistant/components/smartthings/number.py @@ -0,0 +1,77 @@ +"""Support for number entities through the SmartThings cloud API.""" + +from __future__ import annotations + +from pysmartthings import Attribute, Capability, Command, SmartThings + +from homeassistant.components.number import NumberEntity +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import FullDevice, SmartThingsConfigEntry +from .const import MAIN +from .entity import SmartThingsEntity + + +async def async_setup_entry( + hass: HomeAssistant, + entry: SmartThingsConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Add number entities for a config entry.""" + entry_data = entry.runtime_data + async_add_entities( + SmartThingsWasherRinseCyclesNumberEntity(entry_data.client, device) + for device in entry_data.devices.values() + if Capability.CUSTOM_WASHER_RINSE_CYCLES in device.status[MAIN] + ) + + +class SmartThingsWasherRinseCyclesNumberEntity(SmartThingsEntity, NumberEntity): + """Define a SmartThings number.""" + + _attr_translation_key = "washer_rinse_cycles" + _attr_native_step = 1.0 + + def __init__(self, client: SmartThings, device: FullDevice) -> None: + """Initialize the instance.""" + super().__init__(client, device, {Capability.CUSTOM_WASHER_RINSE_CYCLES}) + self._attr_unique_id = ( + f"{device.device.device_id}_{MAIN}_{Capability.CUSTOM_WASHER_RINSE_CYCLES}" + ) + + @property + def options(self) -> list[int]: + """Return the list of options.""" + values = self.get_attribute_value( + Capability.CUSTOM_WASHER_RINSE_CYCLES, + Attribute.SUPPORTED_WASHER_RINSE_CYCLES, + ) + return [int(value) for value in values] if values else [] + + @property + def native_value(self) -> float | None: + """Return the current value.""" + return int( + self.get_attribute_value( + Capability.CUSTOM_WASHER_RINSE_CYCLES, Attribute.WASHER_RINSE_CYCLES + ) + ) + + @property + def native_min_value(self) -> float: + """Return the minimum value.""" + return min(self.options) + + @property + def native_max_value(self) -> float: + """Return the maximum value.""" + return max(self.options) + + async def async_set_native_value(self, value: float) -> None: + """Set the value.""" + await self.execute_device_command( + Capability.CUSTOM_WASHER_RINSE_CYCLES, + Command.SET_WASHER_RINSE_CYCLES, + str(int(value)), + ) diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index 2f1310b9c27..c534c2ba29d 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -78,6 +78,12 @@ } } }, + "number": { + "washer_rinse_cycles": { + "name": "Rinse cycles", + "unit_of_measurement": "cycles" + } + }, "select": { "operating_state": { "state": { diff --git a/tests/components/smartthings/snapshots/test_number.ambr b/tests/components/smartthings/snapshots/test_number.ambr new file mode 100644 index 00000000000..18d0a775c95 --- /dev/null +++ b/tests/components/smartthings/snapshots/test_number.ambr @@ -0,0 +1,115 @@ +# serializer version: 1 +# name: test_all_entities[da_wm_wm_000001][number.washer_rinse_cycles-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 5, + 'min': 0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.washer_rinse_cycles', + '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': 'Rinse cycles', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'washer_rinse_cycles', + 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47_main_custom.washerRinseCycles', + 'unit_of_measurement': 'cycles', + }) +# --- +# name: test_all_entities[da_wm_wm_000001][number.washer_rinse_cycles-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Washer Rinse cycles', + 'max': 5, + 'min': 0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': 'cycles', + }), + 'context': , + 'entity_id': 'number.washer_rinse_cycles', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2', + }) +# --- +# name: test_all_entities[da_wm_wm_000001_1][number.washing_machine_rinse_cycles-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 5, + 'min': 0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.washing_machine_rinse_cycles', + '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': 'Rinse cycles', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'washer_rinse_cycles', + 'unique_id': '63803fae-cbed-f356-a063-2cf148ae3ca7_main_custom.washerRinseCycles', + 'unit_of_measurement': 'cycles', + }) +# --- +# name: test_all_entities[da_wm_wm_000001_1][number.washing_machine_rinse_cycles-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Washing Machine Rinse cycles', + 'max': 5, + 'min': 0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': 'cycles', + }), + 'context': , + 'entity_id': 'number.washing_machine_rinse_cycles', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2', + }) +# --- diff --git a/tests/components/smartthings/test_number.py b/tests/components/smartthings/test_number.py new file mode 100644 index 00000000000..578b94e050f --- /dev/null +++ b/tests/components/smartthings/test_number.py @@ -0,0 +1,81 @@ +"""Test for the SmartThings number platform.""" + +from unittest.mock import AsyncMock + +from pysmartthings import Attribute, Capability, Command +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.number import ( + ATTR_VALUE, + DOMAIN as NUMBER_DOMAIN, + SERVICE_SET_VALUE, +) +from homeassistant.components.smartthings import MAIN +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration, snapshot_smartthings_entities, trigger_update + +from tests.common import MockConfigEntry + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + await setup_integration(hass, mock_config_entry) + + snapshot_smartthings_entities(hass, entity_registry, snapshot, Platform.NUMBER) + + +@pytest.mark.parametrize("device_fixture", ["da_wm_wm_000001"]) +async def test_set_value( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setting a value.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: "number.washer_rinse_cycles", ATTR_VALUE: 3}, + blocking=True, + ) + devices.execute_device_command.assert_called_once_with( + "f984b91d-f250-9d42-3436-33f09a422a47", + Capability.CUSTOM_WASHER_RINSE_CYCLES, + Command.SET_WASHER_RINSE_CYCLES, + MAIN, + argument="3", + ) + + +@pytest.mark.parametrize("device_fixture", ["da_wm_wm_000001"]) +async def test_state_update( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test state update.""" + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("number.washer_rinse_cycles").state == "2" + + await trigger_update( + hass, + devices, + "f984b91d-f250-9d42-3436-33f09a422a47", + Capability.CUSTOM_WASHER_RINSE_CYCLES, + Attribute.WASHER_RINSE_CYCLES, + "3", + ) + + assert hass.states.get("number.washer_rinse_cycles").state == "3" From c56b087d0ca5052fc2330ca95af1c4eae1ad8ee8 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 22 Mar 2025 19:05:21 +0100 Subject: [PATCH 2872/3148] Add Dryer Wrinkle Prevent switch to SmartThings (#141085) * Add Dryer Wrinkle Prevent switch to SmartThings * Fix --- .../components/smartthings/icons.json | 8 ++ .../components/smartthings/strings.json | 5 + .../components/smartthings/switch.py | 106 ++++++++++++++++-- .../smartthings/snapshots/test_switch.ambr | 94 ++++++++++++++++ tests/components/smartthings/test_switch.py | 33 ++++++ 5 files changed, 236 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/smartthings/icons.json b/homeassistant/components/smartthings/icons.json index c5c18efa5a1..9cfdb8da7ec 100644 --- a/homeassistant/components/smartthings/icons.json +++ b/homeassistant/components/smartthings/icons.json @@ -27,6 +27,14 @@ "stop": "mdi:stop" } } + }, + "switch": { + "wrinkle_prevent": { + "default": "mdi:tumble-dryer", + "state": { + "off": "mdi:tumble-dryer-off" + } + } } } } diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index c534c2ba29d..9616c97fbe1 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -442,6 +442,11 @@ "freeze_protection": "Freeze protection" } } + }, + "switch": { + "wrinkle_prevent": { + "name": "Wrinkle prevent" + } } }, "issues": { diff --git a/homeassistant/components/smartthings/switch.py b/homeassistant/components/smartthings/switch.py index 380005f1b93..6e0dc1ac93d 100644 --- a/homeassistant/components/smartthings/switch.py +++ b/homeassistant/components/smartthings/switch.py @@ -2,15 +2,16 @@ from __future__ import annotations +from dataclasses import dataclass from typing import Any -from pysmartthings import Attribute, Capability, Command +from pysmartthings import Attribute, Capability, Command, SmartThings -from homeassistant.components.switch import SwitchEntity +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import SmartThingsConfigEntry +from . import FullDevice, SmartThingsConfigEntry from .const import MAIN from .entity import SmartThingsEntity @@ -29,6 +30,37 @@ AC_CAPABILITIES = ( ) +@dataclass(frozen=True, kw_only=True) +class SmartThingsSwitchEntityDescription(SwitchEntityDescription): + """Describe a SmartThings switch entity.""" + + status_attribute: Attribute + + +@dataclass(frozen=True, kw_only=True) +class SmartThingsCommandSwitchEntityDescription(SmartThingsSwitchEntityDescription): + """Describe a SmartThings switch entity.""" + + command: Command + + +SWITCH = SmartThingsSwitchEntityDescription( + key=Capability.SWITCH, + status_attribute=Attribute.SWITCH, + name=None, +) +CAPABILITY_TO_COMMAND_SWITCHES: dict[ + Capability | str, SmartThingsCommandSwitchEntityDescription +] = { + Capability.CUSTOM_DRYER_WRINKLE_PREVENT: SmartThingsCommandSwitchEntityDescription( + key=Capability.CUSTOM_DRYER_WRINKLE_PREVENT, + translation_key="wrinkle_prevent", + status_attribute=Attribute.DRYER_WRINKLE_PREVENT, + command=Command.SET_DRYER_WRINKLE_PREVENT, + ) +} + + async def async_setup_entry( hass: HomeAssistant, entry: SmartThingsConfigEntry, @@ -36,35 +68,89 @@ async def async_setup_entry( ) -> None: """Add switches for a config entry.""" entry_data = entry.runtime_data - async_add_entities( - SmartThingsSwitch(entry_data.client, device, {Capability.SWITCH}) + entities: list[SmartThingsEntity] = [ + SmartThingsSwitch(entry_data.client, device, SWITCH, Capability.SWITCH) for device in entry_data.devices.values() if Capability.SWITCH in device.status[MAIN] and not any(capability in device.status[MAIN] for capability in CAPABILITIES) and not all(capability in device.status[MAIN] for capability in AC_CAPABILITIES) + ] + entities.extend( + SmartThingsCommandSwitch( + entry_data.client, + device, + description, + Capability(capability), + ) + for device in entry_data.devices.values() + for capability, description in CAPABILITY_TO_COMMAND_SWITCHES.items() + if capability in device.status[MAIN] ) + async_add_entities(entities) class SmartThingsSwitch(SmartThingsEntity, SwitchEntity): """Define a SmartThings switch.""" - _attr_name = None + entity_description: SmartThingsSwitchEntityDescription + + def __init__( + self, + client: SmartThings, + device: FullDevice, + entity_description: SmartThingsSwitchEntityDescription, + capability: Capability, + ) -> None: + """Initialize the switch.""" + super().__init__(client, device, {capability}) + self.entity_description = entity_description + self.switch_capability = capability + self._attr_unique_id = device.device.device_id + if capability is not Capability.SWITCH: + self._attr_unique_id = f"{device.device.device_id}_{MAIN}_{capability}" async def async_turn_off(self, **kwargs: Any) -> None: """Turn the switch off.""" await self.execute_device_command( - Capability.SWITCH, + self.switch_capability, Command.OFF, ) async def async_turn_on(self, **kwargs: Any) -> None: """Turn the switch on.""" await self.execute_device_command( - Capability.SWITCH, + self.switch_capability, Command.ON, ) @property def is_on(self) -> bool: - """Return true if light is on.""" - return self.get_attribute_value(Capability.SWITCH, Attribute.SWITCH) == "on" + """Return true if switch is on.""" + return ( + self.get_attribute_value( + self.switch_capability, self.entity_description.status_attribute + ) + == "on" + ) + + +class SmartThingsCommandSwitch(SmartThingsSwitch): + """Define a SmartThings command switch.""" + + entity_description: SmartThingsCommandSwitchEntityDescription + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the switch off.""" + await self.execute_device_command( + self.switch_capability, + self.entity_description.command, + "off", + ) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the switch on.""" + await self.execute_device_command( + self.switch_capability, + self.entity_description.command, + "on", + ) diff --git a/tests/components/smartthings/snapshots/test_switch.ambr b/tests/components/smartthings/snapshots/test_switch.ambr index 08db5ffc244..40f242e82f5 100644 --- a/tests/components/smartthings/snapshots/test_switch.ambr +++ b/tests/components/smartthings/snapshots/test_switch.ambr @@ -281,6 +281,53 @@ 'state': 'off', }) # --- +# name: test_all_entities[da_wm_wd_000001][switch.dryer_wrinkle_prevent-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.dryer_wrinkle_prevent', + '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': 'Wrinkle prevent', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wrinkle_prevent', + 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b_main_custom.dryerWrinklePrevent', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wd_000001][switch.dryer_wrinkle_prevent-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dryer Wrinkle prevent', + }), + 'context': , + 'entity_id': 'switch.dryer_wrinkle_prevent', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_all_entities[da_wm_wd_000001_1][switch.seca_roupa-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -328,6 +375,53 @@ 'state': 'off', }) # --- +# name: test_all_entities[da_wm_wd_000001_1][switch.seca_roupa_wrinkle_prevent-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.seca_roupa_wrinkle_prevent', + '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': 'Wrinkle prevent', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wrinkle_prevent', + 'unique_id': '3a6c4e05-811d-5041-e956-3d04c424cbcd_main_custom.dryerWrinklePrevent', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wd_000001_1][switch.seca_roupa_wrinkle_prevent-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Seca-Roupa Wrinkle prevent', + }), + 'context': , + 'entity_id': 'switch.seca_roupa_wrinkle_prevent', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_all_entities[da_wm_wm_000001][switch.washer-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/smartthings/test_switch.py b/tests/components/smartthings/test_switch.py index a1e420a8edb..28bac49b0b0 100644 --- a/tests/components/smartthings/test_switch.py +++ b/tests/components/smartthings/test_switch.py @@ -66,6 +66,39 @@ async def test_switch_turn_on_off( ) +@pytest.mark.parametrize("device_fixture", ["da_wm_wd_000001"]) +@pytest.mark.parametrize( + ("action", "argument"), + [ + (SERVICE_TURN_ON, "on"), + (SERVICE_TURN_OFF, "off"), + ], +) +async def test_command_switch_turn_on_off( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + action: str, + argument: str, +) -> None: + """Test switch turn on and off command.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + SWITCH_DOMAIN, + action, + {ATTR_ENTITY_ID: "switch.dryer_wrinkle_prevent"}, + blocking=True, + ) + devices.execute_device_command.assert_called_once_with( + "02f7256e-8353-5bdd-547f-bd5b1647e01b", + Capability.CUSTOM_DRYER_WRINKLE_PREVENT, + Command.SET_DRYER_WRINKLE_PREVENT, + MAIN, + argument, + ) + + @pytest.mark.parametrize("device_fixture", ["c2c_arlo_pro_3_switch"]) async def test_state_update( hass: HomeAssistant, From 1e0b89c3817f27bb24f66f29fcc724fd5abe47ee Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Sat, 22 Mar 2025 14:29:32 -0400 Subject: [PATCH 2873/3148] Bump python Roborock to 2.16.1 (#141033) * Bump python Roborock to 2.15.0 * Add aiohttp clientsession * inject websession * fix lint after merge * bump to 2.16 * bump and revert * revert formatting --- 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 45cfe4e12d8..ce797b0db4b 100644 --- a/homeassistant/components/roborock/manifest.json +++ b/homeassistant/components/roborock/manifest.json @@ -7,7 +7,7 @@ "iot_class": "local_polling", "loggers": ["roborock"], "requirements": [ - "python-roborock==2.14.0", + "python-roborock==2.16.1", "vacuum-map-parser-roborock==0.1.2" ] } diff --git a/requirements_all.txt b/requirements_all.txt index 289184b8eca..42ec536c05d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2465,7 +2465,7 @@ python-rabbitair==0.0.8 python-ripple-api==0.0.3 # homeassistant.components.roborock -python-roborock==2.14.0 +python-roborock==2.16.1 # homeassistant.components.smarttub python-smarttub==0.0.39 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9ed83a90659..0ea7bc6593f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1995,7 +1995,7 @@ python-picnic-api2==1.2.2 python-rabbitair==0.0.8 # homeassistant.components.roborock -python-roborock==2.14.0 +python-roborock==2.16.1 # homeassistant.components.smarttub python-smarttub==0.0.39 From 99d0449cbe3872adad8274898b28bd5a386c4eba Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Sat, 22 Mar 2025 19:40:47 +0100 Subject: [PATCH 2874/3148] Bump pyOverkiz to 1.16.4 in Overkiz (#141132) * Bump Overkiz to 1.16.3 * Add missing generated files --- homeassistant/components/overkiz/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/overkiz/manifest.json b/homeassistant/components/overkiz/manifest.json index 70857f0ba11..cfaed4ceb8b 100644 --- a/homeassistant/components/overkiz/manifest.json +++ b/homeassistant/components/overkiz/manifest.json @@ -13,7 +13,7 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["boto3", "botocore", "pyhumps", "pyoverkiz", "s3transfer"], - "requirements": ["pyoverkiz==1.16.3"], + "requirements": ["pyoverkiz==1.16.4"], "zeroconf": [ { "type": "_kizbox._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index 42ec536c05d..db1a05a376d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2203,7 +2203,7 @@ pyotgw==2.2.2 pyotp==2.8.0 # homeassistant.components.overkiz -pyoverkiz==1.16.3 +pyoverkiz==1.16.4 # homeassistant.components.onewire pyownet==0.10.0.post1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0ea7bc6593f..ce3ffd8f620 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1796,7 +1796,7 @@ pyotgw==2.2.2 pyotp==2.8.0 # homeassistant.components.overkiz -pyoverkiz==1.16.3 +pyoverkiz==1.16.4 # homeassistant.components.onewire pyownet==0.10.0.post1 From b47d3076cc3ccb095be60aa2d305758cc160af67 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 22 Mar 2025 19:51:41 +0100 Subject: [PATCH 2875/3148] Add oven stop button to SmartThings (#141142) --- .../components/smartthings/__init__.py | 1 + .../components/smartthings/button.py | 75 +++++++++ .../components/smartthings/icons.json | 5 + .../components/smartthings/strings.json | 5 + .../smartthings/snapshots/test_button.ambr | 142 ++++++++++++++++++ tests/components/smartthings/test_button.py | 56 +++++++ 6 files changed, 284 insertions(+) create mode 100644 homeassistant/components/smartthings/button.py create mode 100644 tests/components/smartthings/snapshots/test_button.ambr create mode 100644 tests/components/smartthings/test_button.py diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py index 31309b73a66..e5351798219 100644 --- a/homeassistant/components/smartthings/__init__.py +++ b/homeassistant/components/smartthings/__init__.py @@ -79,6 +79,7 @@ type SmartThingsConfigEntry = ConfigEntry[SmartThingsData] PLATFORMS = [ Platform.BINARY_SENSOR, + Platform.BUTTON, Platform.CLIMATE, Platform.COVER, Platform.EVENT, diff --git a/homeassistant/components/smartthings/button.py b/homeassistant/components/smartthings/button.py new file mode 100644 index 00000000000..ad61880f3b1 --- /dev/null +++ b/homeassistant/components/smartthings/button.py @@ -0,0 +1,75 @@ +"""Support for button entities through the SmartThings cloud API.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from pysmartthings import Capability, Command, SmartThings + +from homeassistant.components.button import ButtonEntity, ButtonEntityDescription +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import FullDevice, SmartThingsConfigEntry +from .const import MAIN +from .entity import SmartThingsEntity + + +@dataclass(frozen=True, kw_only=True) +class SmartThingsButtonDescription(ButtonEntityDescription): + """Class describing SmartThings button entities.""" + + key: Capability + command: Command + + +CAPABILITIES_TO_BUTTONS: dict[Capability | str, SmartThingsButtonDescription] = { + Capability.OVEN_OPERATING_STATE: SmartThingsButtonDescription( + key=Capability.OVEN_OPERATING_STATE, + translation_key="stop", + command=Command.STOP, + ), +} + + +async def async_setup_entry( + hass: HomeAssistant, + entry: SmartThingsConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Add button entities for a config entry.""" + entry_data = entry.runtime_data + async_add_entities( + SmartThingsButtonEntity( + entry_data.client, device, CAPABILITIES_TO_BUTTONS[capability] + ) + for device in entry_data.devices.values() + for capability in device.status[MAIN] + if capability in CAPABILITIES_TO_BUTTONS + ) + + +class SmartThingsButtonEntity(SmartThingsEntity, ButtonEntity): + """Define a SmartThings button.""" + + entity_description: SmartThingsButtonDescription + + def __init__( + self, + client: SmartThings, + device: FullDevice, + entity_description: SmartThingsButtonDescription, + ) -> None: + """Initialize the instance.""" + super().__init__(client, device, set()) + self.entity_description = entity_description + self._attr_unique_id = ( + f"{device.device.device_id}_{MAIN}_{entity_description.key}" + ) + + async def async_press(self) -> None: + """Press the button.""" + await self.execute_device_command( + self.entity_description.key, + self.entity_description.command, + ) diff --git a/homeassistant/components/smartthings/icons.json b/homeassistant/components/smartthings/icons.json index 9cfdb8da7ec..80ac70edc3f 100644 --- a/homeassistant/components/smartthings/icons.json +++ b/homeassistant/components/smartthings/icons.json @@ -14,6 +14,11 @@ } } }, + "button": { + "stop": { + "default": "mdi:stop" + } + }, "number": { "washer_rinse_cycles": { "default": "mdi:waves-arrow-up" diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index 9616c97fbe1..13f4a6a2831 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -46,6 +46,11 @@ "name": "Valve" } }, + "button": { + "stop": { + "name": "Stop" + } + }, "event": { "button": { "state": { diff --git a/tests/components/smartthings/snapshots/test_button.ambr b/tests/components/smartthings/snapshots/test_button.ambr new file mode 100644 index 00000000000..a16ad794929 --- /dev/null +++ b/tests/components/smartthings/snapshots/test_button.ambr @@ -0,0 +1,142 @@ +# serializer version: 1 +# name: test_all_entities[da_ks_microwave_0101x][button.microwave_stop-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.microwave_stop', + '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': 'Stop', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'stop', + 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a_main_ovenOperatingState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_microwave_0101x][button.microwave_stop-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Microwave Stop', + }), + 'context': , + 'entity_id': 'button.microwave_stop', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[da_ks_oven_01061][button.oven_stop-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.oven_stop', + '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': 'Stop', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'stop', + 'unique_id': '9447959a-0dfa-6b27-d40d-650da525c53f_main_ovenOperatingState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_oven_01061][button.oven_stop-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Oven Stop', + }), + 'context': , + 'entity_id': 'button.oven_stop', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[da_ks_range_0101x][button.vulcan_stop-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.vulcan_stop', + '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': 'Stop', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'stop', + 'unique_id': '2c3cbaa0-1899-5ddc-7b58-9d657bd48f18_main_ovenOperatingState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_range_0101x][button.vulcan_stop-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Vulcan Stop', + }), + 'context': , + 'entity_id': 'button.vulcan_stop', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/smartthings/test_button.py b/tests/components/smartthings/test_button.py new file mode 100644 index 00000000000..4a348d079ca --- /dev/null +++ b/tests/components/smartthings/test_button.py @@ -0,0 +1,56 @@ +"""Test for the SmartThings button platform.""" + +from unittest.mock import AsyncMock + +from freezegun.api import FrozenDateTimeFactory +from pysmartthings import Capability, Command +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +from homeassistant.components.smartthings import MAIN +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration, snapshot_smartthings_entities + +from tests.common import MockConfigEntry + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + await setup_integration(hass, mock_config_entry) + + snapshot_smartthings_entities(hass, entity_registry, snapshot, Platform.BUTTON) + + +@pytest.mark.parametrize("device_fixture", ["da_ks_microwave_0101x"]) +async def test_press( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test state update.""" + await setup_integration(hass, mock_config_entry) + freezer.move_to("2023-10-21") + + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: "button.microwave_stop"}, + blocking=True, + ) + devices.execute_device_command.assert_called_once_with( + "2bad3237-4886-e699-1b90-4a51a3d55c8a", + Capability.OVEN_OPERATING_STATE, + Command.STOP, + MAIN, + ) From f245bbd8ddd2224d535da59771d60691e268ff6d Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 22 Mar 2025 20:04:21 +0100 Subject: [PATCH 2876/3148] Add door state binary sensor to SmartThings (#141143) --- .../components/smartthings/binary_sensor.py | 8 + .../components/smartthings/strings.json | 3 + .../snapshots/test_binary_sensor.ambr | 144 ++++++++++++++++++ 3 files changed, 155 insertions(+) diff --git a/homeassistant/components/smartthings/binary_sensor.py b/homeassistant/components/smartthings/binary_sensor.py index ef431c08f24..8479852a6f6 100644 --- a/homeassistant/components/smartthings/binary_sensor.py +++ b/homeassistant/components/smartthings/binary_sensor.py @@ -134,6 +134,14 @@ CAPABILITY_TO_SENSORS: dict[ is_on_key="wet", ) }, + Capability.SAMSUNG_CE_DOOR_STATE: { + Attribute.DOOR_STATE: SmartThingsBinarySensorEntityDescription( + key=Attribute.DOOR_STATE, + translation_key="door", + device_class=BinarySensorDeviceClass.OPENING, + is_on_key="open", + ) + }, } diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index 13f4a6a2831..7f6e13ab3ba 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -33,6 +33,9 @@ "acceleration": { "name": "Acceleration" }, + "door": { + "name": "[%key:component::binary_sensor::entity_component::door::name%]" + }, "filter_status": { "name": "Filter status" }, diff --git a/tests/components/smartthings/snapshots/test_binary_sensor.ambr b/tests/components/smartthings/snapshots/test_binary_sensor.ambr index d05cf3124fa..45534085ddf 100644 --- a/tests/components/smartthings/snapshots/test_binary_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_binary_sensor.ambr @@ -190,6 +190,54 @@ 'state': 'off', }) # --- +# name: test_all_entities[da_ks_microwave_0101x][binary_sensor.microwave_door-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.microwave_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Door', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'door', + 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a.doorState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_microwave_0101x][binary_sensor.microwave_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'opening', + 'friendly_name': 'Microwave Door', + }), + 'context': , + 'entity_id': 'binary_sensor.microwave_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_all_entities[da_ks_microwave_0101x][binary_sensor.microwave_remote_control-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -284,6 +332,54 @@ 'state': 'off', }) # --- +# name: test_all_entities[da_ks_oven_01061][binary_sensor.oven_door-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.oven_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Door', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'door', + 'unique_id': '9447959a-0dfa-6b27-d40d-650da525c53f.doorState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_oven_01061][binary_sensor.oven_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'opening', + 'friendly_name': 'Oven Door', + }), + 'context': , + 'entity_id': 'binary_sensor.oven_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_all_entities[da_ks_oven_01061][binary_sensor.oven_remote_control-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -378,6 +474,54 @@ 'state': 'off', }) # --- +# name: test_all_entities[da_ks_range_0101x][binary_sensor.vulcan_door-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.vulcan_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Door', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'door', + 'unique_id': '2c3cbaa0-1899-5ddc-7b58-9d657bd48f18.doorState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_range_0101x][binary_sensor.vulcan_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'opening', + 'friendly_name': 'Vulcan Door', + }), + 'context': , + 'entity_id': 'binary_sensor.vulcan_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_all_entities[da_ks_range_0101x][binary_sensor.vulcan_remote_control-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 7f640252a1a199b14ca006e77fe7b41ff9a789c4 Mon Sep 17 00:00:00 2001 From: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> Date: Sat, 22 Mar 2025 14:12:51 -0500 Subject: [PATCH 2877/3148] Use Debouncer helper in HEOS Coordinator (#141133) Use Debouncer --- homeassistant/components/heos/coordinator.py | 41 ++++++-------------- 1 file changed, 12 insertions(+), 29 deletions(-) diff --git a/homeassistant/components/heos/coordinator.py b/homeassistant/components/heos/coordinator.py index 0bc948bccd7..5e72eb1427e 100644 --- a/homeassistant/components/heos/coordinator.py +++ b/homeassistant/components/heos/coordinator.py @@ -6,7 +6,6 @@ entities to update. Entities subscribe to entity-specific updates within the ent """ from collections.abc import Callable, Sequence -from datetime import datetime, timedelta import logging from typing import Any @@ -25,10 +24,10 @@ from pyheos import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform -from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.helpers.event import async_call_later +from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import DOMAIN @@ -60,7 +59,13 @@ class HeosCoordinator(DataUpdateCoordinator[None]): ) ) self._platform_callbacks: list[Callable[[Sequence[HeosPlayer]], None]] = [] - self._update_sources_pending: bool = False + self._update_sources_debouncer = Debouncer( + hass, + _LOGGER, + immediate=True, + cooldown=2.0, + function=self._async_update_sources, + ) self._source_list: list[str] = [] self._favorites: dict[int, MediaItem] = {} self._inputs: Sequence[MediaItem] = [] @@ -182,31 +187,9 @@ class HeosCoordinator(DataUpdateCoordinator[None]): if event == const.EVENT_PLAYERS_CHANGED: assert data is not None self._async_handle_player_update_result(data) - elif ( - event in (const.EVENT_SOURCES_CHANGED, const.EVENT_USER_CHANGED) - and not self._update_sources_pending - ): - # Update the sources after a brief delay as we may have received multiple qualifying - # events at once and devices cannot handle immediately attempting to refresh sources. - self._update_sources_pending = True - - async def update_sources_job(_: datetime | None = None) -> None: - await self._async_update_sources() - self._update_sources_pending = False - self.async_update_listeners() - - assert self.config_entry is not None - self.config_entry.async_on_unload( - async_call_later( - self.hass, - timedelta(seconds=1), - HassJob( - update_sources_job, - "heos_update_sources", - cancel_on_shutdown=True, - ), - ) - ) + elif event in (const.EVENT_SOURCES_CHANGED, const.EVENT_USER_CHANGED): + # Debounce because we may have received multiple qualifying events in rapid succession. + await self._update_sources_debouncer.async_call() self.async_update_listeners() def _async_update_player_ids(self, updated_player_ids: dict[int, int]) -> None: From 6d91bdb02e0c3045f3cced95eec808112427d167 Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Sat, 22 Mar 2025 15:19:54 -0400 Subject: [PATCH 2878/3148] Inject websession for Roborock api client (#141141) --- homeassistant/components/roborock/__init__.py | 7 ++++++- homeassistant/components/roborock/config_flow.py | 9 +++++++-- homeassistant/components/roborock/quality_scale.yaml | 4 +--- 3 files changed, 14 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/roborock/__init__.py b/homeassistant/components/roborock/__init__.py index a3ccf0c6eed..8140b58b86c 100644 --- a/homeassistant/components/roborock/__init__.py +++ b/homeassistant/components/roborock/__init__.py @@ -24,6 +24,7 @@ from homeassistant.const import CONF_USERNAME, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import CONF_BASE_URL, CONF_USER_DATA, DOMAIN, PLATFORMS from .coordinator import ( @@ -45,7 +46,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: RoborockConfigEntry) -> entry.async_on_unload(entry.add_update_listener(update_listener)) user_data = UserData.from_dict(entry.data[CONF_USER_DATA]) - api_client = RoborockApiClient(entry.data[CONF_USERNAME], entry.data[CONF_BASE_URL]) + api_client = RoborockApiClient( + entry.data[CONF_USERNAME], + entry.data[CONF_BASE_URL], + session=async_get_clientsession(hass), + ) _LOGGER.debug("Getting home data") try: home_data = await api_client.get_home_data_v2(user_data) diff --git a/homeassistant/components/roborock/config_flow.py b/homeassistant/components/roborock/config_flow.py index 1a6b67286bb..6a5f1ce08f8 100644 --- a/homeassistant/components/roborock/config_flow.py +++ b/homeassistant/components/roborock/config_flow.py @@ -28,6 +28,7 @@ from homeassistant.config_entries import ( ) from homeassistant.const import CONF_USERNAME from homeassistant.core import callback +from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import ( CONF_BASE_URL, @@ -63,7 +64,9 @@ class RoborockFlowHandler(ConfigFlow, domain=DOMAIN): self._abort_if_unique_id_configured(error="already_configured_account") self._username = username _LOGGER.debug("Requesting code for Roborock account") - self._client = RoborockApiClient(username) + self._client = RoborockApiClient( + username, session=async_get_clientsession(self.hass) + ) errors = await self._request_code() if not errors: return await self.async_step_code() @@ -140,7 +143,9 @@ class RoborockFlowHandler(ConfigFlow, domain=DOMAIN): """Perform reauth upon an API authentication error.""" self._username = entry_data[CONF_USERNAME] assert self._username - self._client = RoborockApiClient(self._username) + self._client = RoborockApiClient( + self._username, session=async_get_clientsession(self.hass) + ) return await self.async_step_reauth_confirm() async def async_step_reauth_confirm( diff --git a/homeassistant/components/roborock/quality_scale.yaml b/homeassistant/components/roborock/quality_scale.yaml index 06a7638c222..430bdd9c2b6 100644 --- a/homeassistant/components/roborock/quality_scale.yaml +++ b/homeassistant/components/roborock/quality_scale.yaml @@ -73,7 +73,5 @@ rules: stale-devices: done # Platinum async-dependency: todo - inject-websession: - status: todo - comment: Web API uses aiohttp but does not yet inject web session. + inject-websession: done strict-typing: todo From 61e30d0e912e7050af8848b672f5631269a175ec Mon Sep 17 00:00:00 2001 From: Thomas55555 <59625598+Thomas55555@users.noreply.github.com> Date: Sat, 22 Mar 2025 20:27:48 +0100 Subject: [PATCH 2879/3148] Add diagnostics to remote calendar (#141111) * Add diagnostics * add diagnostics * address review * ruff * ruff * use raw ics data * mypy * mypy * naming * redact ics * ruff * simpify * reduce data * ruff --- .../components/remote_calendar/coordinator.py | 5 ++- .../components/remote_calendar/diagnostics.py | 25 ++++++++++++ .../remote_calendar/quality_scale.yaml | 4 +- .../snapshots/test_diagnostics.ambr | 17 ++++++++ .../remote_calendar/test_diagnostics.py | 39 +++++++++++++++++++ 5 files changed, 85 insertions(+), 5 deletions(-) create mode 100644 homeassistant/components/remote_calendar/diagnostics.py create mode 100644 tests/components/remote_calendar/snapshots/test_diagnostics.ambr create mode 100644 tests/components/remote_calendar/test_diagnostics.py diff --git a/homeassistant/components/remote_calendar/coordinator.py b/homeassistant/components/remote_calendar/coordinator.py index 7f29f7e2ea8..6caec297c1a 100644 --- a/homeassistant/components/remote_calendar/coordinator.py +++ b/homeassistant/components/remote_calendar/coordinator.py @@ -26,6 +26,7 @@ class RemoteCalendarDataUpdateCoordinator(DataUpdateCoordinator[Calendar]): """Class to manage fetching calendar data.""" config_entry: RemoteCalendarConfigEntry + ics: str def __init__( self, @@ -40,7 +41,6 @@ class RemoteCalendarDataUpdateCoordinator(DataUpdateCoordinator[Calendar]): update_interval=SCAN_INTERVAL, always_update=True, ) - self._etag = None self._client = get_async_client(hass) self._url = config_entry.data[CONF_URL] @@ -59,8 +59,9 @@ class RemoteCalendarDataUpdateCoordinator(DataUpdateCoordinator[Calendar]): # calendar_from_ics will dynamically load packages # the first time it is called, so we need to do it # in a separate thread to avoid blocking the event loop + self.ics = res.text return await self.hass.async_add_executor_job( - IcsCalendarStream.calendar_from_ics, res.text + IcsCalendarStream.calendar_from_ics, self.ics ) except CalendarParseError as err: raise UpdateFailed( diff --git a/homeassistant/components/remote_calendar/diagnostics.py b/homeassistant/components/remote_calendar/diagnostics.py new file mode 100644 index 00000000000..5ebfb3d3812 --- /dev/null +++ b/homeassistant/components/remote_calendar/diagnostics.py @@ -0,0 +1,25 @@ +"""Provides diagnostics for the remote calendar.""" + +import datetime +from typing import Any + +from ical.diagnostics import redact_ics + +from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util + +from . import RemoteCalendarConfigEntry + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: RemoteCalendarConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + coordinator = entry.runtime_data + payload: dict[str, Any] = { + "now": dt_util.now().isoformat(), + "timezone": str(dt_util.get_default_time_zone()), + "system_timezone": str(datetime.datetime.now().astimezone().tzinfo), + } + payload["ics"] = "\n".join(redact_ics(coordinator.ics)) + return payload diff --git a/homeassistant/components/remote_calendar/quality_scale.yaml b/homeassistant/components/remote_calendar/quality_scale.yaml index 05dc32e5da9..964b63d7116 100644 --- a/homeassistant/components/remote_calendar/quality_scale.yaml +++ b/homeassistant/components/remote_calendar/quality_scale.yaml @@ -53,9 +53,7 @@ rules: devices: status: exempt comment: No devices. One URL is always assigned to one calendar. - diagnostics: - status: todo - comment: Diagnostics not implemented, yet. + diagnostics: done discovery-update-info: status: todo comment: No discovery protocol available. diff --git a/tests/components/remote_calendar/snapshots/test_diagnostics.ambr b/tests/components/remote_calendar/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..de955f8a2aa --- /dev/null +++ b/tests/components/remote_calendar/snapshots/test_diagnostics.ambr @@ -0,0 +1,17 @@ +# serializer version: 1 +# name: test_entry_diagnostics + dict({ + 'ics': ''' + BEGIN:VCALENDAR + BEGIN:VEVENT + SUMMARY:*** + DTSTART:19970714T170000Z + DTEND:19970715T040000Z + END:VEVENT + END:VCALENDAR + ''', + 'now': '2023-06-04T18:00:00-06:00', + 'system_timezone': 'tzlocal()', + 'timezone': 'America/Regina', + }) +# --- diff --git a/tests/components/remote_calendar/test_diagnostics.py b/tests/components/remote_calendar/test_diagnostics.py new file mode 100644 index 00000000000..428369b1180 --- /dev/null +++ b/tests/components/remote_calendar/test_diagnostics.py @@ -0,0 +1,39 @@ +"""Test the remote calendar diagnostics.""" + +import datetime + +from httpx import Response +import pytest +import respx +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant + +from . import setup_integration +from .conftest import CALENDER_URL + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +@respx.mock +@pytest.mark.freeze_time(datetime.datetime(2023, 6, 5)) +async def test_entry_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, + config_entry: MockConfigEntry, + ics_content: str, +) -> None: + """Test config entry diagnostics.""" + respx.get(CALENDER_URL).mock( + return_value=Response( + status_code=200, + text=ics_content, + ) + ) + await setup_integration(hass, config_entry) + await hass.async_block_till_done() + result = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + assert result == snapshot From 4e2dfba45fc2a02662c96d1f27e9d2f9f6ad359f Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sat, 22 Mar 2025 12:41:51 -0700 Subject: [PATCH 2880/3148] Omit state from the Assist LLM prompts (#141034) * Omit state from the Assist LLM prompts * Add back the stateful prompt --- .../components/mcp_server/llm_api.py | 41 ------------------ homeassistant/components/mcp_server/server.py | 10 ++--- homeassistant/helpers/llm.py | 30 +++++++------ tests/helpers/test_llm.py | 42 +++++++++++++++++-- 4 files changed, 60 insertions(+), 63 deletions(-) delete mode 100644 homeassistant/components/mcp_server/llm_api.py diff --git a/homeassistant/components/mcp_server/llm_api.py b/homeassistant/components/mcp_server/llm_api.py deleted file mode 100644 index f7dd4421480..00000000000 --- a/homeassistant/components/mcp_server/llm_api.py +++ /dev/null @@ -1,41 +0,0 @@ -"""LLM API for MCP Server. - -This is a modified version of the AssistAPI that does not include the home state -in the prompt. This API is not registered with the LLM API registry since it is -only used by the MCP Server. The MCP server will substitute this API when the -user selects the Assist API. -""" - -from homeassistant.core import callback -from homeassistant.helpers import llm -from homeassistant.util import yaml as yaml_util - -EXPOSED_ENTITY_FIELDS = {"name", "domain", "description", "areas", "names"} - - -class StatelessAssistAPI(llm.AssistAPI): - """LLM API for MCP Server that provides the Assist API without state information in the prompt. - - Syncing the state information is possible, but may put unnecessary load on - the system so we are instead providing the prompt without entity state. Since - actions don't care about the current state, there is little quality loss. - """ - - @callback - def _async_get_exposed_entities_prompt( - self, llm_context: llm.LLMContext, exposed_entities: dict | None - ) -> list[str]: - """Return the prompt for the exposed entities.""" - prompt = [] - - if exposed_entities and exposed_entities["entities"]: - prompt.append( - "An overview of the areas and the devices in this smart home:" - ) - entities = [ - {k: v for k, v in entity_info.items() if k in EXPOSED_ENTITY_FIELDS} - for entity_info in exposed_entities["entities"].values() - ] - prompt.append(yaml_util.dump(list(entities))) - - return prompt diff --git a/homeassistant/components/mcp_server/server.py b/homeassistant/components/mcp_server/server.py index 307fcdda8f3..88b179ae7c2 100644 --- a/homeassistant/components/mcp_server/server.py +++ b/homeassistant/components/mcp_server/server.py @@ -22,7 +22,6 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import llm from .const import STATELESS_LLM_API -from .llm_api import StatelessAssistAPI _LOGGER = logging.getLogger(__name__) @@ -50,15 +49,14 @@ async def create_server( A Model Context Protocol Server object is associated with a single session. The MCP SDK handles the details of the protocol. """ + if llm_api_id == STATELESS_LLM_API: + llm_api_id = llm.LLM_API_ASSIST server = Server("home-assistant") async def get_api_instance() -> llm.APIInstance: - """Substitute the StatelessAssistAPI for the Assist API if selected.""" - if llm_api_id in (STATELESS_LLM_API, llm.LLM_API_ASSIST): - api = StatelessAssistAPI(hass) - return await api.async_get_api_instance(llm_context) - + """Get the LLM API selected.""" + # Backwards compatibility with old MCP Server config return await llm.async_get_api(hass, llm_api_id, llm_context) @server.list_prompts() # type: ignore[no-untyped-call, misc] diff --git a/homeassistant/helpers/llm.py b/homeassistant/helpers/llm.py index 5995543914f..7f6fe22ec70 100644 --- a/homeassistant/helpers/llm.py +++ b/homeassistant/helpers/llm.py @@ -316,7 +316,7 @@ class AssistAPI(API): """Return the instance of the API.""" if llm_context.assistant: exposed_entities: dict | None = _get_exposed_entities( - self.hass, llm_context.assistant + self.hass, llm_context.assistant, include_state=False ) else: exposed_entities = None @@ -463,7 +463,9 @@ class AssistAPI(API): def _get_exposed_entities( - hass: HomeAssistant, assistant: str + hass: HomeAssistant, + assistant: str, + include_state: bool = True, ) -> dict[str, dict[str, dict[str, Any]]]: """Get exposed entities. @@ -524,24 +526,28 @@ def _get_exposed_entities( info: dict[str, Any] = { "names": ", ".join(names), "domain": state.domain, - "state": state.state, } + if include_state: + info["state"] = state.state + if description: info["description"] = description if area_names: info["areas"] = ", ".join(area_names) - if attributes := { - attr_name: ( - str(attr_value) - if isinstance(attr_value, (Enum, Decimal, int)) - else attr_value - ) - for attr_name, attr_value in state.attributes.items() - if attr_name in interesting_attributes - }: + if include_state and ( + attributes := { + attr_name: ( + str(attr_value) + if isinstance(attr_value, (Enum, Decimal, int)) + else attr_value + ) + for attr_name, attr_value in state.attributes.items() + if attr_name in interesting_attributes + } + ): info["attributes"] = attributes if state.domain in data: diff --git a/tests/helpers/test_llm.py b/tests/helpers/test_llm.py index 45ed009fcf1..19ada407550 100644 --- a/tests/helpers/test_llm.py +++ b/tests/helpers/test_llm.py @@ -622,6 +622,40 @@ async def test_assist_api_prompt( domain: light state: unavailable areas: Test Area 2 +""" + stateless_exposed_entities_prompt = """An overview of the areas and the devices in this smart home: +- names: Kitchen + domain: light +- names: Living Room + domain: light + areas: Test Area, Alternative name +- names: Test Device, my test light + domain: light + areas: Test Area, Alternative name +- names: Test Service + domain: light + areas: Test Area, Alternative name +- names: Test Service + domain: light + areas: Test Area, Alternative name +- names: Test Service + domain: light + areas: Test Area, Alternative name +- names: Test Device 2 + domain: light + areas: Test Area 2 +- names: Test Device 3 + domain: light + areas: Test Area 2 +- names: Test Device 4 + domain: light + areas: Test Area 2 +- names: Unnamed Device + domain: light + areas: Test Area 2 +- names: '1' + domain: light + areas: Test Area 2 """ first_part_prompt = ( "When controlling Home Assistant always call the intent tools. " @@ -640,7 +674,7 @@ async def test_assist_api_prompt( f"""{first_part_prompt} {area_prompt} {no_timer_prompt} -{exposed_entities_prompt}""" +{stateless_exposed_entities_prompt}""" ) # Verify that the get_home_state tool returns the same results as the exposed_entities_prompt @@ -663,7 +697,7 @@ async def test_assist_api_prompt( f"""{first_part_prompt} {area_prompt} {no_timer_prompt} -{exposed_entities_prompt}""" +{stateless_exposed_entities_prompt}""" ) # Add floor @@ -678,7 +712,7 @@ async def test_assist_api_prompt( f"""{first_part_prompt} {area_prompt} {no_timer_prompt} -{exposed_entities_prompt}""" +{stateless_exposed_entities_prompt}""" ) # Register device for timers @@ -689,7 +723,7 @@ async def test_assist_api_prompt( assert api.api_prompt == ( f"""{first_part_prompt} {area_prompt} -{exposed_entities_prompt}""" +{stateless_exposed_entities_prompt}""" ) From a9df341abf92952832848c6099ad3f7647ebb986 Mon Sep 17 00:00:00 2001 From: tdfountain <174762217+tdfountain@users.noreply.github.com> Date: Sat, 22 Mar 2025 14:11:48 -0700 Subject: [PATCH 2881/3148] Optimize entity creation by storing device name as data in NUT (#141147) --- homeassistant/components/nut/__init__.py | 5 ++++- homeassistant/components/nut/entity.py | 4 ++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/nut/__init__.py b/homeassistant/components/nut/__init__.py index 169dbbbff5d..94a2599501a 100644 --- a/homeassistant/components/nut/__init__.py +++ b/homeassistant/components/nut/__init__.py @@ -240,6 +240,7 @@ class PyNUTData: self._client = AIONUTClient(self._host, port, username, password, 5, persistent) self.ups_list: dict[str, str] | None = None + self.device_name: str | None = None self._status: dict[str, str] | None = None self._device_info: NUTDeviceInfo | None = None @@ -250,7 +251,7 @@ class PyNUTData: @property def name(self) -> str: - """Return the name of the ups.""" + """Return the name of the NUT device.""" return self._alias or f"Nut-{self._host}" @property @@ -294,6 +295,8 @@ class PyNUTData: self._status = await self._async_get_status() if self._device_info is None: self._device_info = self._get_device_info() + if self.device_name is None: + self.device_name = self.name.title() return self._status async def async_run_command(self, command_name: str) -> None: diff --git a/homeassistant/components/nut/entity.py b/homeassistant/components/nut/entity.py index 8179526acf3..5445b51c5cb 100644 --- a/homeassistant/components/nut/entity.py +++ b/homeassistant/components/nut/entity.py @@ -42,10 +42,10 @@ class NUTBaseEntity(CoordinatorEntity[DataUpdateCoordinator]): """Initialize the entity.""" super().__init__(coordinator) - device_name = data.name.title() + self.pynut_data = data self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, unique_id)}, - name=device_name, + name=self.pynut_data.device_name, ) self._attr_device_info.update(_get_nut_device_info(data)) From ddd67a7e58c2a13d6126e8542ac8eaa74cbf6a25 Mon Sep 17 00:00:00 2001 From: tdfountain <174762217+tdfountain@users.noreply.github.com> Date: Sat, 22 Mar 2025 16:04:20 -0700 Subject: [PATCH 2882/3148] Add PDU dynamic outlet buttons to NUT (#140317) --- homeassistant/components/nut/__init__.py | 26 +++++- homeassistant/components/nut/button.py | 65 ++++++++++++++ homeassistant/components/nut/const.py | 5 +- homeassistant/components/nut/entity.py | 5 ++ homeassistant/components/nut/icons.json | 5 ++ homeassistant/components/nut/sensor.py | 17 +--- homeassistant/components/nut/strings.json | 3 + tests/components/nut/test_button.py | 102 ++++++++++++++++++++++ 8 files changed, 207 insertions(+), 21 deletions(-) create mode 100644 homeassistant/components/nut/button.py create mode 100644 tests/components/nut/test_button.py diff --git a/homeassistant/components/nut/__init__.py b/homeassistant/components/nut/__init__.py index 94a2599501a..8ec8c132ffe 100644 --- a/homeassistant/components/nut/__init__.py +++ b/homeassistant/components/nut/__init__.py @@ -103,7 +103,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: NutConfigEntry) -> bool: ) status = coordinator.data - _LOGGER.debug("NUT Sensors Available: %s", status) + _LOGGER.debug("NUT Sensors Available: %s", status if status else None) entry.async_on_unload(entry.add_update_listener(_async_update_listener)) unique_id = _unique_id_from_status(status) @@ -111,14 +111,32 @@ async def async_setup_entry(hass: HomeAssistant, entry: NutConfigEntry) -> bool: unique_id = entry.entry_id if username is not None and password is not None: + # Dynamically add outlet integration commands + additional_integration_commands = set() + if (num_outlets := status.get("outlet.count")) is not None: + for outlet_num in range(1, int(num_outlets) + 1): + outlet_num_str: str = str(outlet_num) + additional_integration_commands |= { + f"outlet.{outlet_num_str}.load.cycle", + } + + valid_integration_commands = ( + INTEGRATION_SUPPORTED_COMMANDS | additional_integration_commands + ) + user_available_commands = { - device_supported_command - for device_supported_command in await data.async_list_commands() or {} - if device_supported_command in INTEGRATION_SUPPORTED_COMMANDS + device_command + for device_command in await data.async_list_commands() or {} + if device_command in valid_integration_commands } else: user_available_commands = set() + _LOGGER.debug( + "NUT Commands Available: %s", + user_available_commands if user_available_commands else None, + ) + entry.runtime_data = NutRuntimeData( coordinator, data, unique_id, user_available_commands ) diff --git a/homeassistant/components/nut/button.py b/homeassistant/components/nut/button.py new file mode 100644 index 00000000000..436f06b44d7 --- /dev/null +++ b/homeassistant/components/nut/button.py @@ -0,0 +1,65 @@ +"""Provides a switch for switchable NUT outlets.""" + +from __future__ import annotations + +import logging + +from homeassistant.components.button import ( + ButtonDeviceClass, + ButtonEntity, + ButtonEntityDescription, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import NutConfigEntry +from .entity import NUTBaseEntity + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: NutConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the NUT buttons.""" + pynut_data = config_entry.runtime_data + coordinator = pynut_data.coordinator + status = coordinator.data + + # Dynamically add outlet button types + if (num_outlets := status.get("outlet.count")) is None: + return + + data = pynut_data.data + unique_id = pynut_data.unique_id + valid_button_types: dict[str, ButtonEntityDescription] = {} + for outlet_num in range(1, int(num_outlets) + 1): + outlet_num_str = str(outlet_num) + outlet_name: str = status.get(f"outlet.{outlet_num_str}.name") or outlet_num_str + valid_button_types |= { + f"outlet.{outlet_num_str}.load.cycle": ButtonEntityDescription( + key=f"outlet.{outlet_num_str}.load.cycle", + translation_key="outlet_number_load_cycle", + translation_placeholders={"outlet_name": outlet_name}, + device_class=ButtonDeviceClass.RESTART, + entity_registry_enabled_default=True, + ), + } + + async_add_entities( + NUTButton(coordinator, description, data, unique_id) + for button_id, description in valid_button_types.items() + if button_id in pynut_data.user_available_commands + ) + + +class NUTButton(NUTBaseEntity, ButtonEntity): + """Representation of a button entity for NUT.""" + + async def async_press(self) -> None: + """Press the button.""" + name_list = self.entity_description.key.split(".") + command_name = f"{name_list[0]}.{name_list[1]}.load.cycle" + await self.pynut_data.async_run_command(command_name) diff --git a/homeassistant/components/nut/const.py b/homeassistant/components/nut/const.py index e67299aa9a3..a45b072fe65 100644 --- a/homeassistant/components/nut/const.py +++ b/homeassistant/components/nut/const.py @@ -6,7 +6,10 @@ from homeassistant.const import Platform DOMAIN = "nut" -PLATFORMS = [Platform.SENSOR] +PLATFORMS = [ + Platform.BUTTON, + Platform.SENSOR, +] DEFAULT_NAME = "NUT UPS" DEFAULT_HOST = "localhost" diff --git a/homeassistant/components/nut/entity.py b/homeassistant/components/nut/entity.py index 5445b51c5cb..e6536d8aad6 100644 --- a/homeassistant/components/nut/entity.py +++ b/homeassistant/components/nut/entity.py @@ -12,6 +12,7 @@ from homeassistant.const import ( ATTR_SW_VERSION, ) from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -36,12 +37,16 @@ class NUTBaseEntity(CoordinatorEntity[DataUpdateCoordinator]): def __init__( self, coordinator: DataUpdateCoordinator, + entity_description: EntityDescription, data: PyNUTData, unique_id: str, ) -> None: """Initialize the entity.""" super().__init__(coordinator) + self.entity_description = entity_description + self._attr_unique_id = f"{unique_id}_{entity_description.key}" + self.pynut_data = data self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, unique_id)}, diff --git a/homeassistant/components/nut/icons.json b/homeassistant/components/nut/icons.json index bfd9407bb6c..e69d0405756 100644 --- a/homeassistant/components/nut/icons.json +++ b/homeassistant/components/nut/icons.json @@ -151,6 +151,11 @@ "ups_watchdog_status": { "default": "mdi:information-outline" } + }, + "button": { + "outlet_number_load_cycle": { + "default": "mdi:restart" + } } } } diff --git a/homeassistant/components/nut/sensor.py b/homeassistant/components/nut/sensor.py index 189d5906f6d..80046c6ac22 100644 --- a/homeassistant/components/nut/sensor.py +++ b/homeassistant/components/nut/sensor.py @@ -25,9 +25,8 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from . import NutConfigEntry, PyNUTData +from . import NutConfigEntry from .const import KEY_STATUS, KEY_STATUS_DISPLAY, STATE_TYPES from .entity import NUTBaseEntity @@ -1089,20 +1088,6 @@ async def async_setup_entry( class NUTSensor(NUTBaseEntity, SensorEntity): """Representation of a sensor entity for NUT status values.""" - _attr_has_entity_name = True - - def __init__( - self, - coordinator: DataUpdateCoordinator[dict[str, str]], - sensor_description: SensorEntityDescription, - data: PyNUTData, - unique_id: str, - ) -> None: - """Initialize the sensor.""" - super().__init__(coordinator, data, unique_id) - self.entity_description = sensor_description - self._attr_unique_id = f"{unique_id}_{sensor_description.key}" - @property def native_value(self) -> str | None: """Return entity state from NUT device.""" diff --git a/homeassistant/components/nut/strings.json b/homeassistant/components/nut/strings.json index 76d6f6df0b7..7a913d44f9e 100644 --- a/homeassistant/components/nut/strings.json +++ b/homeassistant/components/nut/strings.json @@ -221,6 +221,9 @@ "ups_type": { "name": "UPS type" }, "ups_watchdog_status": { "name": "Watchdog status" }, "watts": { "name": "Watts" } + }, + "button": { + "outlet_number_load_cycle": { "name": "Power cycle outlet {outlet_name}" } } } } diff --git a/tests/components/nut/test_button.py b/tests/components/nut/test_button.py new file mode 100644 index 00000000000..bbcc521b7f3 --- /dev/null +++ b/tests/components/nut/test_button.py @@ -0,0 +1,102 @@ +"""Test the NUT button platform.""" + +import pytest + +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +from homeassistant.components.nut.const import INTEGRATION_SUPPORTED_COMMANDS +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .util import async_init_integration + + +@pytest.mark.parametrize( + "model", + [ + "CP1350C", + "5E650I", + "5E850I", + "CP1500PFCLCD", + "DL650ELCD", + "EATON5P1550", + "blazer_usb", + ], +) +async def test_buttons_ups( + hass: HomeAssistant, entity_registry: er.EntityRegistry, model: str +) -> None: + """Tests that there are no standard buttons.""" + + list_commands_return_value = { + supported_command: supported_command + for supported_command in INTEGRATION_SUPPORTED_COMMANDS + } + + await async_init_integration( + hass, + model, + list_commands_return_value=list_commands_return_value, + ) + + button = hass.states.get("button.ups1_power_cycle_outlet_1") + assert not button + + +@pytest.mark.parametrize( + ("model", "unique_id_base"), + [ + ( + "EATON-EPDU-G3", + "EATON_ePDU MA 00U-C IN: TYPE 00A 0P OUT: 00xTYPE_A000A00000_", + ), + ], +) +async def test_buttons_pdu_dynamic_outlets( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + model: str, + unique_id_base: str, +) -> None: + """Tests that the button entities are correct.""" + + list_commands_return_value = { + supported_command: supported_command + for supported_command in INTEGRATION_SUPPORTED_COMMANDS + } + + for num in range(1, 25): + command = f"outlet.{num!s}.load.cycle" + list_commands_return_value[command] = command + + await async_init_integration( + hass, + model, + list_commands_return_value=list_commands_return_value, + ) + + entity_id = "button.ups1_power_cycle_outlet_a1" + entry = entity_registry.async_get(entity_id) + assert entry + assert entry.unique_id == f"{unique_id_base}outlet.1.load.cycle" + + button = hass.states.get(entity_id) + assert button + assert button.state == STATE_UNKNOWN + + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + await hass.async_block_till_done() + + button = hass.states.get(entity_id) + assert button.state != STATE_UNKNOWN + + button = hass.states.get("button.ups1_power_cycle_outlet_25") + assert not button + + button = hass.states.get("button.ups1_power_cycle_outlet_a25") + assert not button From e2e80a850ccd1ee5ea31c675625721b470d05522 Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Sun, 23 Mar 2025 00:21:43 -0400 Subject: [PATCH 2883/3148] Add dhcp discovery to Roborock (#141148) * Add discovery to Roborock * Update homeassistant/components/roborock/config_flow.py Co-authored-by: Allen Porter * MR comments * go back to removing the ":" * change method of getting devices --------- Co-authored-by: Allen Porter --- .../components/roborock/config_flow.py | 18 +++++ .../components/roborock/coordinator.py | 4 +- .../components/roborock/manifest.json | 11 +++ .../components/roborock/quality_scale.yaml | 4 +- homeassistant/generated/dhcp.py | 12 ++++ tests/components/roborock/conftest.py | 8 ++- tests/components/roborock/mock_data.py | 4 +- tests/components/roborock/test_config_flow.py | 68 ++++++++++++++++++- 8 files changed, 121 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/roborock/config_flow.py b/homeassistant/components/roborock/config_flow.py index 6a5f1ce08f8..c34f7cb87b0 100644 --- a/homeassistant/components/roborock/config_flow.py +++ b/homeassistant/components/roborock/config_flow.py @@ -28,7 +28,9 @@ from homeassistant.config_entries import ( ) from homeassistant.const import CONF_USERNAME from homeassistant.core import callback +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .const import ( CONF_BASE_URL, @@ -137,6 +139,22 @@ class RoborockFlowHandler(ConfigFlow, domain=DOMAIN): errors=errors, ) + async def async_step_dhcp( + self, discovery_info: DhcpServiceInfo + ) -> ConfigFlowResult: + """Handle a flow started by a dhcp discovery.""" + device_registry = dr.async_get(self.hass) + device = device_registry.async_get_device( + connections={ + (dr.CONNECTION_NETWORK_MAC, dr.format_mac(discovery_info.macaddress)) + } + ) + if device is not None and any( + identifier[0] == DOMAIN for identifier in device.identifiers + ): + return self.async_abort(reason="already_configured") + return await self.async_step_user() + async def async_step_reauth( self, entry_data: Mapping[str, Any] ) -> ConfigFlowResult: diff --git a/homeassistant/components/roborock/coordinator.py b/homeassistant/components/roborock/coordinator.py index 6d0c9737a29..cc0bee1cd5f 100644 --- a/homeassistant/components/roborock/coordinator.py +++ b/homeassistant/components/roborock/coordinator.py @@ -129,7 +129,9 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): self.current_map: int | None = None if mac := self.roborock_device_info.network_info.mac: - self.device_info[ATTR_CONNECTIONS] = {(dr.CONNECTION_NETWORK_MAC, mac)} + self.device_info[ATTR_CONNECTIONS] = { + (dr.CONNECTION_NETWORK_MAC, dr.format_mac(mac)) + } # Maps from map flag to map name self.maps: dict[int, RoborockMapInfo] = {} self._home_data_rooms = {str(room.id): room.name for room in home_data_rooms} diff --git a/homeassistant/components/roborock/manifest.json b/homeassistant/components/roborock/manifest.json index ce797b0db4b..60036edb0bc 100644 --- a/homeassistant/components/roborock/manifest.json +++ b/homeassistant/components/roborock/manifest.json @@ -3,6 +3,17 @@ "name": "Roborock", "codeowners": ["@Lash-L", "@allenporter"], "config_flow": true, + "dhcp": [ + { + "macaddress": "249E7D*" + }, + { + "macaddress": "B04A39*" + }, + { + "hostname": "roborock-*" + } + ], "documentation": "https://www.home-assistant.io/integrations/roborock", "iot_class": "local_polling", "loggers": ["roborock"], diff --git a/homeassistant/components/roborock/quality_scale.yaml b/homeassistant/components/roborock/quality_scale.yaml index 430bdd9c2b6..c61db90350f 100644 --- a/homeassistant/components/roborock/quality_scale.yaml +++ b/homeassistant/components/roborock/quality_scale.yaml @@ -34,9 +34,7 @@ rules: # Gold devices: done diagnostics: done - discovery: - status: todo - comment: Determine if these devices can support discovery + discovery: done discovery-update-info: status: exempt comment: Devices do not support discovery. diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py index 3dba5a98f3c..8ee1ea270f3 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -498,6 +498,18 @@ DHCP: Final[list[dict[str, str | bool]]] = [ "hostname": "ring*", "macaddress": "341513*", }, + { + "domain": "roborock", + "macaddress": "249E7D*", + }, + { + "domain": "roborock", + "macaddress": "B04A39*", + }, + { + "domain": "roborock", + "hostname": "roborock-*", + }, { "domain": "roomba", "hostname": "irobot-*", diff --git a/tests/components/roborock/conftest.py b/tests/components/roborock/conftest.py index 332a9143c51..758b002f534 100644 --- a/tests/components/roborock/conftest.py +++ b/tests/components/roborock/conftest.py @@ -229,7 +229,13 @@ async def setup_entry( @pytest.fixture(autouse=True) -async def cleanup_map_storage( +async def cleanup_map_storage(cleanup_map_storage_manual) -> Generator[pathlib.Path]: + """Test cleanup, remove any map storage persisted during the test.""" + return cleanup_map_storage_manual + + +@pytest.fixture +async def cleanup_map_storage_manual( hass: HomeAssistant, mock_roborock_entry: MockConfigEntry ) -> Generator[pathlib.Path]: """Test cleanup, remove any map storage persisted during the test.""" diff --git a/tests/components/roborock/mock_data.py b/tests/components/roborock/mock_data.py index 507e8060653..82b51e67f8d 100644 --- a/tests/components/roborock/mock_data.py +++ b/tests/components/roborock/mock_data.py @@ -1120,10 +1120,10 @@ PROP = DeviceProp( ) NETWORK_INFO = NetworkInfo( - ip="123.232.12.1", ssid="wifi", mac="ac:cc:cc:cc:cc", bssid="bssid", rssi=90 + ip="123.232.12.1", ssid="wifi", mac="ac:cc:cc:cc:cc:cc", bssid="bssid", rssi=90 ) NETWORK_INFO_2 = NetworkInfo( - ip="123.232.12.2", ssid="wifi", mac="ac:cc:cc:cc:cd", bssid="bssid", rssi=90 + ip="123.232.12.2", ssid="wifi", mac="ac:cc:cc:cc:cd:cc", bssid="bssid", rssi=90 ) MULTI_MAP_LIST = MultiMapsList.from_dict( diff --git a/tests/components/roborock/test_config_flow.py b/tests/components/roborock/test_config_flow.py index 1bcb72c2f5b..abd19660fba 100644 --- a/tests/components/roborock/test_config_flow.py +++ b/tests/components/roborock/test_config_flow.py @@ -19,8 +19,9 @@ from homeassistant.components.roborock.const import CONF_ENTRY_CODE, DOMAIN, DRA from homeassistant.const import CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo -from .mock_data import MOCK_CONFIG, USER_DATA, USER_EMAIL +from .mock_data import MOCK_CONFIG, NETWORK_INFO, USER_DATA, USER_EMAIL from tests.common import MockConfigEntry @@ -281,3 +282,68 @@ async def test_account_already_configured( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured_account" + + +async def test_discovery_not_setup( + hass: HomeAssistant, + bypass_api_fixture, +) -> None: + """Handle the config flow and make sure it succeeds.""" + with ( + patch("homeassistant.components.roborock.async_setup_entry", return_value=True), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data=DhcpServiceInfo( + ip=NETWORK_INFO.ip, + macaddress=NETWORK_INFO.mac.replace(":", ""), + hostname="roborock-vacuum-a72", + ), + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + with patch( + "homeassistant.components.roborock.config_flow.RoborockApiClient.request_code" + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_USERNAME: USER_EMAIL} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "code" + assert result["errors"] == {} + with patch( + "homeassistant.components.roborock.config_flow.RoborockApiClient.code_login", + return_value=USER_DATA, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_ENTRY_CODE: "123456"} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == USER_EMAIL + assert result["data"] == MOCK_CONFIG + assert result["result"] + + +async def test_discovery_already_setup( + hass: HomeAssistant, + bypass_api_fixture, + mock_roborock_entry: MockConfigEntry, + cleanup_map_storage_manual, +) -> None: + """Handle aborting if the device is already setup.""" + await hass.config_entries.async_setup(mock_roborock_entry.entry_id) + await hass.async_block_till_done() + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data=DhcpServiceInfo( + ip=NETWORK_INFO.ip, + macaddress=NETWORK_INFO.mac.replace(":", ""), + hostname="roborock-vacuum-a72", + ), + ) + + assert result["type"] is FlowResultType.ABORT From 9e86ca2e9e9c33ed64e8eef72d85fe0bb30714d3 Mon Sep 17 00:00:00 2001 From: tdfountain <174762217+tdfountain@users.noreply.github.com> Date: Sat, 22 Mar 2025 22:27:52 -0700 Subject: [PATCH 2884/3148] Add Switch platform and PDU dynamic outlet switches to NUT (#141159) --- homeassistant/components/nut/__init__.py | 2 + homeassistant/components/nut/const.py | 9 +- homeassistant/components/nut/icons.json | 5 + homeassistant/components/nut/strings.json | 9 +- homeassistant/components/nut/switch.py | 88 ++++++++++++ tests/components/nut/test_switch.py | 159 ++++++++++++++++++++++ 6 files changed, 259 insertions(+), 13 deletions(-) create mode 100644 homeassistant/components/nut/switch.py create mode 100644 tests/components/nut/test_switch.py diff --git a/homeassistant/components/nut/__init__.py b/homeassistant/components/nut/__init__.py index 8ec8c132ffe..5b188868819 100644 --- a/homeassistant/components/nut/__init__.py +++ b/homeassistant/components/nut/__init__.py @@ -118,6 +118,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: NutConfigEntry) -> bool: outlet_num_str: str = str(outlet_num) additional_integration_commands |= { f"outlet.{outlet_num_str}.load.cycle", + f"outlet.{outlet_num_str}.load.on", + f"outlet.{outlet_num_str}.load.off", } valid_integration_commands = ( diff --git a/homeassistant/components/nut/const.py b/homeassistant/components/nut/const.py index a45b072fe65..d741d8e95f9 100644 --- a/homeassistant/components/nut/const.py +++ b/homeassistant/components/nut/const.py @@ -9,6 +9,7 @@ DOMAIN = "nut" PLATFORMS = [ Platform.BUTTON, Platform.SENSOR, + Platform.SWITCH, ] DEFAULT_NAME = "NUT UPS" @@ -66,10 +67,6 @@ COMMAND_TEST_FAILURE_STOP = "test.failure.stop" COMMAND_TEST_PANEL_START = "test.panel.start" COMMAND_TEST_PANEL_STOP = "test.panel.stop" COMMAND_TEST_SYSTEM_START = "test.system.start" -COMMAND_OUTLET_1_LOAD_OFF = "outlet.1.load.off" -COMMAND_OUTLET_1_LOAD_ON = "outlet.1.load.on" -COMMAND_OUTLET_2_LOAD_OFF = "outlet.2.load.off" -COMMAND_OUTLET_2_LOAD_ON = "outlet.2.load.on" INTEGRATION_SUPPORTED_COMMANDS = { COMMAND_BEEPER_DISABLE, @@ -98,8 +95,4 @@ INTEGRATION_SUPPORTED_COMMANDS = { COMMAND_TEST_PANEL_START, COMMAND_TEST_PANEL_STOP, COMMAND_TEST_SYSTEM_START, - COMMAND_OUTLET_1_LOAD_OFF, - COMMAND_OUTLET_1_LOAD_ON, - COMMAND_OUTLET_2_LOAD_OFF, - COMMAND_OUTLET_2_LOAD_ON, } diff --git a/homeassistant/components/nut/icons.json b/homeassistant/components/nut/icons.json index e69d0405756..bfa4703d65e 100644 --- a/homeassistant/components/nut/icons.json +++ b/homeassistant/components/nut/icons.json @@ -156,6 +156,11 @@ "outlet_number_load_cycle": { "default": "mdi:restart" } + }, + "switch": { + "outlet_number_load_poweronoff": { + "default": "mdi:power" + } } } } diff --git a/homeassistant/components/nut/strings.json b/homeassistant/components/nut/strings.json index 7a913d44f9e..3ac5f23a0c1 100644 --- a/homeassistant/components/nut/strings.json +++ b/homeassistant/components/nut/strings.json @@ -74,11 +74,7 @@ "test_failure_stop": "Stop simulating a power failure", "test_panel_start": "Start testing the UPS panel", "test_panel_stop": "Stop a UPS panel test", - "test_system_start": "Start a system test", - "outlet_1_load_on": "Power outlet 1 on", - "outlet_1_load_off": "Power outlet 1 off", - "outlet_2_load_on": "Power outlet 2 on", - "outlet_2_load_off": "Power outlet 2 off" + "test_system_start": "Start a system test" } }, "entity": { @@ -224,6 +220,9 @@ }, "button": { "outlet_number_load_cycle": { "name": "Power cycle outlet {outlet_name}" } + }, + "switch": { + "outlet_number_load_poweronoff": { "name": "Power outlet {outlet_name}" } } } } diff --git a/homeassistant/components/nut/switch.py b/homeassistant/components/nut/switch.py new file mode 100644 index 00000000000..3ab8d0ec60a --- /dev/null +++ b/homeassistant/components/nut/switch.py @@ -0,0 +1,88 @@ +"""Provides a switch for switchable NUT outlets.""" + +from __future__ import annotations + +import logging +from typing import Any + +from homeassistant.components.switch import ( + SwitchDeviceClass, + SwitchEntity, + SwitchEntityDescription, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import NutConfigEntry +from .entity import NUTBaseEntity + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: NutConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the NUT switches.""" + pynut_data = config_entry.runtime_data + coordinator = pynut_data.coordinator + status = coordinator.data + + # Dynamically add outlet switch types + if (num_outlets := status.get("outlet.count")) is None: + return + + data = pynut_data.data + unique_id = pynut_data.unique_id + user_available_commands = pynut_data.user_available_commands + switch_descriptions = [ + SwitchEntityDescription( + key=f"outlet.{outlet_num!s}.load.poweronoff", + translation_key="outlet_number_load_poweronoff", + translation_placeholders={ + "outlet_name": status.get(f"outlet.{outlet_num!s}.name") + or str(outlet_num) + }, + device_class=SwitchDeviceClass.OUTLET, + entity_registry_enabled_default=True, + ) + for outlet_num in range(1, int(num_outlets) + 1) + if ( + status.get(f"outlet.{outlet_num!s}.switchable") == "yes" + and f"outlet.{outlet_num!s}.load.on" in user_available_commands + and f"outlet.{outlet_num!s}.load.off" in user_available_commands + ) + ] + + async_add_entities( + NUTSwitch(coordinator, description, data, unique_id) + for description in switch_descriptions + ) + + +class NUTSwitch(NUTBaseEntity, SwitchEntity): + """Representation of a switch entity for NUT status values.""" + + @property + def is_on(self) -> bool | None: + """Return the state of the switch.""" + status = self.coordinator.data + outlet, outlet_num_str = self.entity_description.key.split(".", 2)[:2] + if (state := status.get(f"{outlet}.{outlet_num_str}.status")) is None: + return None + return bool(state == "on") + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on the device.""" + + outlet, outlet_num_str = self.entity_description.key.split(".", 2)[:2] + command_name = f"{outlet}.{outlet_num_str}.load.on" + await self.pynut_data.async_run_command(command_name) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off the device.""" + + outlet, outlet_num_str = self.entity_description.key.split(".", 2)[:2] + command_name = f"{outlet}.{outlet_num_str}.load.off" + await self.pynut_data.async_run_command(command_name) diff --git a/tests/components/nut/test_switch.py b/tests/components/nut/test_switch.py new file mode 100644 index 00000000000..f2de5eeb5e6 --- /dev/null +++ b/tests/components/nut/test_switch.py @@ -0,0 +1,159 @@ +"""Test the NUT switch platform.""" + +import json +from unittest.mock import AsyncMock + +import pytest + +from homeassistant.components.nut.const import INTEGRATION_SUPPORTED_COMMANDS +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_ON, + STATE_UNKNOWN, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .util import async_init_integration + +from tests.common import load_fixture + + +@pytest.mark.parametrize( + "model", + [ + "CP1350C", + "5E650I", + "5E850I", + "CP1500PFCLCD", + "DL650ELCD", + "EATON5P1550", + "blazer_usb", + ], +) +async def test_switch_ups( + hass: HomeAssistant, entity_registry: er.EntityRegistry, model: str +) -> None: + """Tests that there are no standard switches.""" + + list_commands_return_value = { + supported_command: supported_command + for supported_command in INTEGRATION_SUPPORTED_COMMANDS + } + + await async_init_integration( + hass, + model, + list_commands_return_value=list_commands_return_value, + ) + + switch = hass.states.get("switch.ups1_power_outlet_1") + assert not switch + + +@pytest.mark.parametrize( + ("model", "unique_id_base"), + [ + ( + "EATON-EPDU-G3", + "EATON_ePDU MA 00U-C IN: TYPE 00A 0P OUT: 00xTYPE_A000A00000", + ), + ], +) +async def test_switch_pdu_dynamic_outlets( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + model: str, + unique_id_base: str, +) -> None: + """Tests that the switch entities are correct.""" + + list_commands_return_value = { + supported_command: supported_command + for supported_command in INTEGRATION_SUPPORTED_COMMANDS + } + + for num in range(1, 25): + command = f"outlet.{num!s}.load.on" + list_commands_return_value[command] = command + command = f"outlet.{num!s}.load.off" + list_commands_return_value[command] = command + + ups_fixture = f"nut/{model}.json" + list_vars = json.loads(load_fixture(ups_fixture)) + + run_command = AsyncMock() + + await async_init_integration( + hass, + model, + list_vars=list_vars, + list_commands_return_value=list_commands_return_value, + run_command=run_command, + ) + + entity_id = "switch.ups1_power_outlet_a1" + entry = entity_registry.async_get(entity_id) + assert entry + assert entry.unique_id == f"{unique_id_base}_outlet.1.load.poweronoff" + + switch = hass.states.get(entity_id) + assert switch + assert switch.state == STATE_ON + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + run_command.assert_called_with("ups1", "outlet.1.load.off") + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + run_command.assert_called_with("ups1", "outlet.1.load.on") + + switch = hass.states.get("switch.ups1_power_outlet_25") + assert not switch + + switch = hass.states.get("switch.ups1_power_outlet_a25") + assert not switch + + +async def test_switch_pdu_dynamic_outlets_state_unknown( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, +) -> None: + """Test switch entity with missing status is reported as unknown.""" + + config_entry = await async_init_integration( + hass, + list_ups={"ups1": "UPS 1"}, + list_vars={ + "outlet.count": "1", + "outlet.1.switchable": "yes", + "outlet.1.name": "A1", + }, + list_commands_return_value={ + "outlet.1.load.on": None, + "outlet.1.load.off": None, + }, + ) + + entity_id = "switch.ups1_power_outlet_a1" + entry = entity_registry.async_get(entity_id) + assert entry + assert entry.unique_id == f"{config_entry.entry_id}_outlet.1.load.poweronoff" + + switch = hass.states.get(entity_id) + assert switch + assert switch.state == STATE_UNKNOWN From 153ccf86b0f60dd0372165f4e8d2cf7f0b635bd7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Mar 2025 22:33:44 -1000 Subject: [PATCH 2885/3148] Bump dbus-fast to 2.41.1 (#141162) * Bump dbus-fast to 2.41.0 changelog: https://github.com/Bluetooth-Devices/dbus-fast/compare/v2.39.6...v2.41.0 * Apply suggestions from code review --- 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 fbff513329c..27fed6ad647 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -20,7 +20,7 @@ "bluetooth-adapters==0.21.4", "bluetooth-auto-recovery==1.4.5", "bluetooth-data-tools==1.26.1", - "dbus-fast==2.39.6", + "dbus-fast==2.41.1", "habluetooth==3.37.0" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index f03c7446614..476ab97fe1f 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -30,7 +30,7 @@ certifi>=2021.5.30 ciso8601==2.3.2 cronsim==2.6 cryptography==44.0.1 -dbus-fast==2.39.6 +dbus-fast==2.41.1 fnv-hash-fast==1.4.0 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 diff --git a/requirements_all.txt b/requirements_all.txt index db1a05a376d..b52a0614e31 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -744,7 +744,7 @@ datadog==0.15.0 datapoint==0.9.9 # homeassistant.components.bluetooth -dbus-fast==2.39.6 +dbus-fast==2.41.1 # homeassistant.components.debugpy debugpy==1.8.13 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ce3ffd8f620..b592491b173 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -640,7 +640,7 @@ datadog==0.15.0 datapoint==0.9.9 # homeassistant.components.bluetooth -dbus-fast==2.39.6 +dbus-fast==2.41.1 # homeassistant.components.debugpy debugpy==1.8.13 From 87db9817124ee015c49b5c2cdefd22ee5b33583f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Mar 2025 22:34:49 -1000 Subject: [PATCH 2886/3148] Bump anyio to 4.9.0 (#141161) changelog: https://github.com/agronholm/anyio/compare/4.8.0...4.9.0 --- homeassistant/components/mcp_server/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- script/gen_requirements_all.py | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/mcp_server/manifest.json b/homeassistant/components/mcp_server/manifest.json index 18b2e5bc417..a3e00d13c4b 100644 --- a/homeassistant/components/mcp_server/manifest.json +++ b/homeassistant/components/mcp_server/manifest.json @@ -8,6 +8,6 @@ "integration_type": "service", "iot_class": "local_push", "quality_scale": "silver", - "requirements": ["mcp==1.1.2", "aiohttp_sse==2.2.0", "anyio==4.8.0"], + "requirements": ["mcp==1.1.2", "aiohttp_sse==2.2.0", "anyio==4.9.0"], "single_config_entry": true } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 476ab97fe1f..eef447193c4 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -109,7 +109,7 @@ uuid==1000000000.0.0 # these requirements are quite loose. As the entire stack has some outstanding issues, and # even newer versions seem to introduce new issues, it's useful for us to pin all these # requirements so we can directly link HA versions to these library versions. -anyio==4.8.0 +anyio==4.9.0 h11==0.14.0 httpcore==1.0.7 diff --git a/requirements_all.txt b/requirements_all.txt index b52a0614e31..8f7daaf2243 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -479,7 +479,7 @@ anthemav==1.4.1 anthropic==0.47.2 # homeassistant.components.mcp_server -anyio==4.8.0 +anyio==4.9.0 # homeassistant.components.weatherkit apple_weatherkit==1.1.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b592491b173..280327f8d23 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -452,7 +452,7 @@ anthemav==1.4.1 anthropic==0.47.2 # homeassistant.components.mcp_server -anyio==4.8.0 +anyio==4.9.0 # homeassistant.components.weatherkit apple_weatherkit==1.1.3 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index fa823fa4834..1be6286d30c 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -139,7 +139,7 @@ uuid==1000000000.0.0 # these requirements are quite loose. As the entire stack has some outstanding issues, and # even newer versions seem to introduce new issues, it's useful for us to pin all these # requirements so we can directly link HA versions to these library versions. -anyio==4.8.0 +anyio==4.9.0 h11==0.14.0 httpcore==1.0.7 From 65279c94ac38a920d6db2e4f28f5d560dd141835 Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Sun, 23 Mar 2025 05:07:22 -0400 Subject: [PATCH 2887/3148] Finish strict typing for Roborock (#141165) Mark strict typing as done --- homeassistant/components/roborock/config_flow.py | 6 +++--- homeassistant/components/roborock/quality_scale.yaml | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/roborock/config_flow.py b/homeassistant/components/roborock/config_flow.py index c34f7cb87b0..1a359faca10 100644 --- a/homeassistant/components/roborock/config_flow.py +++ b/homeassistant/components/roborock/config_flow.py @@ -21,7 +21,6 @@ import voluptuous as vol from homeassistant.config_entries import ( SOURCE_REAUTH, - ConfigEntry, ConfigFlow, ConfigFlowResult, OptionsFlow, @@ -32,6 +31,7 @@ from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from . import RoborockConfigEntry from .const import ( CONF_BASE_URL, CONF_ENTRY_CODE, @@ -193,7 +193,7 @@ class RoborockFlowHandler(ConfigFlow, domain=DOMAIN): @staticmethod @callback def async_get_options_flow( - config_entry: ConfigEntry, + config_entry: RoborockConfigEntry, ) -> RoborockOptionsFlowHandler: """Create the options flow.""" return RoborockOptionsFlowHandler(config_entry) @@ -202,7 +202,7 @@ class RoborockFlowHandler(ConfigFlow, domain=DOMAIN): class RoborockOptionsFlowHandler(OptionsFlow): """Handle an option flow for Roborock.""" - def __init__(self, config_entry: ConfigEntry) -> None: + def __init__(self, config_entry: RoborockConfigEntry) -> None: """Initialize options flow.""" self.options = deepcopy(dict(config_entry.options)) diff --git a/homeassistant/components/roborock/quality_scale.yaml b/homeassistant/components/roborock/quality_scale.yaml index c61db90350f..d064c30ccf6 100644 --- a/homeassistant/components/roborock/quality_scale.yaml +++ b/homeassistant/components/roborock/quality_scale.yaml @@ -72,4 +72,4 @@ rules: # Platinum async-dependency: todo inject-websession: done - strict-typing: todo + strict-typing: done From 3a80a2d5b95909e5f4be28fc6e743ee5ca3cb051 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sun, 23 Mar 2025 02:12:02 -0700 Subject: [PATCH 2888/3148] Bump openai to 1.68.2 (#141154) * Bump openai to 1.68.2 * Remove unused type ignore --- homeassistant/components/openai_conversation/conversation.py | 3 +-- homeassistant/components/openai_conversation/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/openai_conversation/conversation.py b/homeassistant/components/openai_conversation/conversation.py index 7a8830ffd95..6767734bb00 100644 --- a/homeassistant/components/openai_conversation/conversation.py +++ b/homeassistant/components/openai_conversation/conversation.py @@ -99,8 +99,7 @@ def _convert_content_to_param( if isinstance(content, conversation.AssistantContent) and content.tool_calls: messages.extend( - # https://github.com/openai/openai-python/issues/2205 - ResponseFunctionToolCallParam( # type: ignore[typeddict-item] + ResponseFunctionToolCallParam( type="function_call", name=tool_call.tool_name, arguments=json.dumps(tool_call.tool_args), diff --git a/homeassistant/components/openai_conversation/manifest.json b/homeassistant/components/openai_conversation/manifest.json index a4e46f6457b..988dd2321d5 100644 --- a/homeassistant/components/openai_conversation/manifest.json +++ b/homeassistant/components/openai_conversation/manifest.json @@ -8,5 +8,5 @@ "documentation": "https://www.home-assistant.io/integrations/openai_conversation", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["openai==1.66.3"] + "requirements": ["openai==1.68.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 8f7daaf2243..663287929cf 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1581,7 +1581,7 @@ open-garage==0.2.0 open-meteo==0.3.2 # homeassistant.components.openai_conversation -openai==1.66.3 +openai==1.68.2 # homeassistant.components.openerz openerz-api==0.3.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 280327f8d23..3f0e1873d9c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1326,7 +1326,7 @@ open-garage==0.2.0 open-meteo==0.3.2 # homeassistant.components.openai_conversation -openai==1.66.3 +openai==1.68.2 # homeassistant.components.openerz openerz-api==0.3.0 From 883ce6842d351197a1bd8d9cf3f8938ea5f91fa6 Mon Sep 17 00:00:00 2001 From: Nerdix <70015952+N3rdix@users.noreply.github.com> Date: Sun, 23 Mar 2025 10:28:10 +0100 Subject: [PATCH 2889/3148] Fix icon for "Coffee and Milk counter" in HomeConnect (#141170) fix coffee and milk counter --- homeassistant/components/home_connect/icons.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/home_connect/icons.json b/homeassistant/components/home_connect/icons.json index f781db3ab24..9b4c9276998 100644 --- a/homeassistant/components/home_connect/icons.json +++ b/homeassistant/components/home_connect/icons.json @@ -113,7 +113,7 @@ "milk_counter": { "default": "mdi:cup" }, - "coffee_and_milk": { + "coffee_and_milk_counter": { "default": "mdi:coffee" }, "ristretto_espresso_counter": { From d8a5881eaa8f6a90cc0fc6a9786a235c02a4a54f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Sun, 23 Mar 2025 11:33:55 +0100 Subject: [PATCH 2890/3148] Home Connect test improvements (#141135) * Home Connect test improvements * Remove `appliance_ha_id` fixture in favour of `appliance` fixture --- tests/components/home_connect/conftest.py | 14 --- .../home_connect/snapshots/test_services.ambr | 8 +- .../home_connect/test_binary_sensor.py | 62 ++++++----- tests/components/home_connect/test_button.py | 47 ++++---- .../home_connect/test_coordinator.py | 62 ++++++----- tests/components/home_connect/test_entity.py | 29 ++--- tests/components/home_connect/test_init.py | 16 +-- tests/components/home_connect/test_light.py | 101 ++++++++++-------- tests/components/home_connect/test_number.py | 56 +++++----- tests/components/home_connect/test_select.py | 77 ++++++------- tests/components/home_connect/test_sensor.py | 89 +++++++-------- .../components/home_connect/test_services.py | 32 +++--- tests/components/home_connect/test_switch.py | 89 +++++++-------- tests/components/home_connect/test_time.py | 43 ++++---- 14 files changed, 386 insertions(+), 339 deletions(-) diff --git a/tests/components/home_connect/conftest.py b/tests/components/home_connect/conftest.py index c0caf2b2bdd..21cd236b1a8 100644 --- a/tests/components/home_connect/conftest.py +++ b/tests/components/home_connect/conftest.py @@ -473,20 +473,6 @@ def mock_client_with_exception( return mock -@pytest.fixture(name="appliance_ha_id") -def mock_appliance_ha_id( - appliances: list[HomeAppliance], request: pytest.FixtureRequest -) -> str: - """Fixture to get the ha_id of an appliance.""" - appliance_type = "Washer" - if hasattr(request, "param") and request.param: - appliance_type = request.param - for appliance in appliances: - if appliance.type == appliance_type: - return appliance.ha_id - raise ValueError(f"Appliance {appliance_type} not found") - - @pytest.fixture(name="appliances") def mock_appliances( appliances_data: str, request: pytest.FixtureRequest diff --git a/tests/components/home_connect/snapshots/test_services.ambr b/tests/components/home_connect/snapshots/test_services.ambr index 709621aaefb..610e9fa1248 100644 --- a/tests/components/home_connect/snapshots/test_services.ambr +++ b/tests/components/home_connect/snapshots/test_services.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_set_program_and_options[service_call0-set_selected_program] +# name: test_set_program_and_options[service_call0-set_selected_program-Washer] _Call( tuple( 'SIEMENS-HCS03WCH1-7BC6383CF794', @@ -18,7 +18,7 @@ }), ) # --- -# name: test_set_program_and_options[service_call1-start_program] +# name: test_set_program_and_options[service_call1-start_program-Washer] _Call( tuple( 'SIEMENS-HCS03WCH1-7BC6383CF794', @@ -37,7 +37,7 @@ }), ) # --- -# name: test_set_program_and_options[service_call2-set_active_program_options] +# name: test_set_program_and_options[service_call2-set_active_program_options-Washer] _Call( tuple( 'SIEMENS-HCS03WCH1-7BC6383CF794', @@ -57,7 +57,7 @@ }), ) # --- -# name: test_set_program_and_options[service_call3-set_selected_program_options] +# name: test_set_program_and_options[service_call3-set_selected_program_options-Washer] _Call( tuple( 'SIEMENS-HCS03WCH1-7BC6383CF794', diff --git a/tests/components/home_connect/test_binary_sensor.py b/tests/components/home_connect/test_binary_sensor.py index a06e386b84f..31c15ec00cf 100644 --- a/tests/components/home_connect/test_binary_sensor.py +++ b/tests/components/home_connect/test_binary_sensor.py @@ -3,7 +3,14 @@ from collections.abc import Awaitable, Callable from unittest.mock import AsyncMock, MagicMock -from aiohomeconnect.model import ArrayOfEvents, Event, EventKey, EventMessage, EventType +from aiohomeconnect.model import ( + ArrayOfEvents, + Event, + EventKey, + EventMessage, + EventType, + HomeAppliance, +) from aiohomeconnect.model.error import HomeConnectApiError import pytest @@ -52,8 +59,9 @@ async def test_binary_sensors( assert config_entry.state == ConfigEntryState.LOADED +@pytest.mark.parametrize("appliance", ["Washer"], indirect=True) async def test_paired_depaired_devices_flow( - appliance_ha_id: str, + appliance: HomeAppliance, hass: HomeAssistant, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], @@ -67,7 +75,7 @@ async def test_paired_depaired_devices_flow( assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED - device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)}) assert device entity_entries = entity_registry.entities.get_entries_for_device_id(device.id) assert entity_entries @@ -75,7 +83,7 @@ async def test_paired_depaired_devices_flow( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.DEPAIRED, data=ArrayOfEvents([]), ) @@ -83,7 +91,7 @@ async def test_paired_depaired_devices_flow( ) await hass.async_block_till_done() - device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)}) assert not device for entity_entry in entity_entries: assert not entity_registry.async_get(entity_entry.entity_id) @@ -92,7 +100,7 @@ async def test_paired_depaired_devices_flow( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.PAIRED, data=ArrayOfEvents([]), ) @@ -100,13 +108,14 @@ async def test_paired_depaired_devices_flow( ) await hass.async_block_till_done() - assert device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + assert device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)}) for entity_entry in entity_entries: assert entity_registry.async_get(entity_entry.entity_id) +@pytest.mark.parametrize("appliance", ["Washer"], indirect=True) async def test_connected_devices( - appliance_ha_id: str, + appliance: HomeAppliance, hass: HomeAssistant, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], @@ -123,7 +132,7 @@ async def test_connected_devices( get_status_original_mock = client.get_status def get_status_side_effect(ha_id: str): - if ha_id == appliance_ha_id: + if ha_id == appliance.ha_id: raise HomeConnectApiError( "SDK.Error.HomeAppliance.Connection.Initialization.Failed" ) @@ -135,14 +144,14 @@ async def test_connected_devices( assert config_entry.state == ConfigEntryState.LOADED client.get_status = get_status_original_mock - device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)}) assert device entity_entries = entity_registry.entities.get_entries_for_device_id(device.id) await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.CONNECTED, data=ArrayOfEvents([]), ) @@ -150,19 +159,20 @@ async def test_connected_devices( ) await hass.async_block_till_done() - device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)}) assert device new_entity_entries = entity_registry.entities.get_entries_for_device_id(device.id) assert len(new_entity_entries) > len(entity_entries) -async def test_binary_sensors_entity_availabilty( +@pytest.mark.parametrize("appliance", ["Washer"], indirect=True) +async def test_binary_sensors_entity_availability( hass: HomeAssistant, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, client: MagicMock, - appliance_ha_id: str, + appliance: HomeAppliance, ) -> None: """Test if binary sensor entities availability are based on the appliance connection state.""" entity_ids = [ @@ -181,7 +191,7 @@ async def test_binary_sensors_entity_availabilty( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.DISCONNECTED, ArrayOfEvents([]), ) @@ -195,7 +205,7 @@ async def test_binary_sensors_entity_availabilty( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.CONNECTED, ArrayOfEvents([]), ) @@ -209,6 +219,7 @@ async def test_binary_sensors_entity_availabilty( assert state.state != STATE_UNAVAILABLE +@pytest.mark.parametrize("appliance", ["Washer"], indirect=True) @pytest.mark.parametrize( ("value", "expected"), [ @@ -219,7 +230,7 @@ async def test_binary_sensors_entity_availabilty( ], ) async def test_binary_sensors_door_states( - appliance_ha_id: str, + appliance: HomeAppliance, expected: str, value: str, hass: HomeAssistant, @@ -237,7 +248,7 @@ async def test_binary_sensors_door_states( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.STATUS, ArrayOfEvents( [ @@ -259,7 +270,7 @@ async def test_binary_sensors_door_states( @pytest.mark.parametrize( - ("entity_id", "event_key", "event_value_update", "expected", "appliance_ha_id"), + ("entity_id", "event_key", "event_value_update", "expected", "appliance"), [ ( "binary_sensor.washer_remote_control", @@ -304,13 +315,13 @@ async def test_binary_sensors_door_states( "FridgeFreezer", ), ], - indirect=["appliance_ha_id"], + indirect=["appliance"], ) async def test_binary_sensors_functionality( entity_id: str, event_key: EventKey, event_value_update: str, - appliance_ha_id: str, + appliance: HomeAppliance, expected: str, hass: HomeAssistant, config_entry: MockConfigEntry, @@ -325,7 +336,7 @@ async def test_binary_sensors_functionality( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.STATUS, ArrayOfEvents( [ @@ -346,13 +357,14 @@ async def test_binary_sensors_functionality( assert hass.states.is_state(entity_id, expected) +@pytest.mark.parametrize("appliance", ["Washer"], indirect=True) async def test_connected_sensor_functionality( hass: HomeAssistant, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, client: MagicMock, - appliance_ha_id: str, + appliance: HomeAppliance, ) -> None: """Test if the connected binary sensor reports the right values.""" entity_id = "binary_sensor.washer_connectivity" @@ -365,7 +377,7 @@ async def test_connected_sensor_functionality( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.DISCONNECTED, ArrayOfEvents([]), ) @@ -378,7 +390,7 @@ async def test_connected_sensor_functionality( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.CONNECTED, ArrayOfEvents([]), ) diff --git a/tests/components/home_connect/test_button.py b/tests/components/home_connect/test_button.py index 5af7e40ca43..f894494792d 100644 --- a/tests/components/home_connect/test_button.py +++ b/tests/components/home_connect/test_button.py @@ -4,7 +4,12 @@ from collections.abc import Awaitable, Callable from typing import Any from unittest.mock import AsyncMock, MagicMock -from aiohomeconnect.model import ArrayOfCommands, CommandKey, EventMessage +from aiohomeconnect.model import ( + ArrayOfCommands, + CommandKey, + EventMessage, + HomeAppliance, +) from aiohomeconnect.model.command import Command from aiohomeconnect.model.error import HomeConnectApiError from aiohomeconnect.model.event import ArrayOfEvents, EventType @@ -40,8 +45,9 @@ async def test_buttons( assert config_entry.state == ConfigEntryState.LOADED +@pytest.mark.parametrize("appliance", ["Washer"], indirect=True) async def test_paired_depaired_devices_flow( - appliance_ha_id: str, + appliance: HomeAppliance, hass: HomeAssistant, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], @@ -55,7 +61,7 @@ async def test_paired_depaired_devices_flow( assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED - device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)}) assert device entity_entries = entity_registry.entities.get_entries_for_device_id(device.id) assert entity_entries @@ -63,7 +69,7 @@ async def test_paired_depaired_devices_flow( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.DEPAIRED, data=ArrayOfEvents([]), ) @@ -71,7 +77,7 @@ async def test_paired_depaired_devices_flow( ) await hass.async_block_till_done() - device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)}) assert not device for entity_entry in entity_entries: assert not entity_registry.async_get(entity_entry.entity_id) @@ -80,7 +86,7 @@ async def test_paired_depaired_devices_flow( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.PAIRED, data=ArrayOfEvents([]), ) @@ -88,13 +94,14 @@ async def test_paired_depaired_devices_flow( ) await hass.async_block_till_done() - assert device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + assert device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)}) for entity_entry in entity_entries: assert entity_registry.async_get(entity_entry.entity_id) +@pytest.mark.parametrize("appliance", ["Washer"], indirect=True) async def test_connected_devices( - appliance_ha_id: str, + appliance: HomeAppliance, hass: HomeAssistant, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], @@ -112,14 +119,14 @@ async def test_connected_devices( get_available_programs_mock = client.get_available_programs async def get_available_commands_side_effect(ha_id: str): - if ha_id == appliance_ha_id: + if ha_id == appliance.ha_id: raise HomeConnectApiError( "SDK.Error.HomeAppliance.Connection.Initialization.Failed" ) return await get_available_commands_original_mock.side_effect(ha_id) async def get_available_programs_side_effect(ha_id: str): - if ha_id == appliance_ha_id: + if ha_id == appliance.ha_id: raise HomeConnectApiError( "SDK.Error.HomeAppliance.Connection.Initialization.Failed" ) @@ -137,14 +144,14 @@ async def test_connected_devices( client.get_available_commands = get_available_commands_original_mock client.get_available_programs = get_available_programs_mock - device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)}) assert device entity_entries = entity_registry.entities.get_entries_for_device_id(device.id) await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.CONNECTED, data=ArrayOfEvents([]), ) @@ -152,19 +159,20 @@ async def test_connected_devices( ) await hass.async_block_till_done() - device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)}) assert device new_entity_entries = entity_registry.entities.get_entries_for_device_id(device.id) assert len(new_entity_entries) > len(entity_entries) -async def test_button_entity_availabilty( +@pytest.mark.parametrize("appliance", ["Washer"], indirect=True) +async def test_button_entity_availability( hass: HomeAssistant, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, client: MagicMock, - appliance_ha_id: str, + appliance: HomeAppliance, ) -> None: """Test if button entities availability are based on the appliance connection state.""" entity_ids = [ @@ -183,7 +191,7 @@ async def test_button_entity_availabilty( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.DISCONNECTED, ArrayOfEvents([]), ) @@ -197,7 +205,7 @@ async def test_button_entity_availabilty( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.CONNECTED, ArrayOfEvents([]), ) @@ -211,6 +219,7 @@ async def test_button_entity_availabilty( assert state.state != STATE_UNAVAILABLE +@pytest.mark.parametrize("appliance", ["Washer"], indirect=True) @pytest.mark.parametrize( ("entity_id", "method_call", "expected_kwargs"), [ @@ -231,7 +240,7 @@ async def test_button_functionality( entity_id: str, method_call: str, expected_kwargs: dict[str, Any], - appliance_ha_id: str, + appliance: HomeAppliance, ) -> None: """Test if button entities availability are based on the appliance connection state.""" assert config_entry.state == ConfigEntryState.NOT_LOADED @@ -248,7 +257,7 @@ async def test_button_functionality( {ATTR_ENTITY_ID: entity_id}, blocking=True, ) - getattr(client, method_call).assert_called_with(appliance_ha_id, **expected_kwargs) + getattr(client, method_call).assert_called_with(appliance.ha_id, **expected_kwargs) async def test_command_button_exception( diff --git a/tests/components/home_connect/test_coordinator.py b/tests/components/home_connect/test_coordinator.py index 84bef94d658..050758a6568 100644 --- a/tests/components/home_connect/test_coordinator.py +++ b/tests/components/home_connect/test_coordinator.py @@ -31,8 +31,17 @@ from homeassistant.components.home_connect.const import ( BSH_POWER_OFF, DOMAIN, ) +from homeassistant.components.homeassistant import ( + DOMAIN as HA_DOMAIN, + SERVICE_UPDATE_ENTITY, +) from homeassistant.config_entries import ConfigEntries, ConfigEntryState -from homeassistant.const import EVENT_STATE_REPORTED, Platform +from homeassistant.const import ( + ATTR_ENTITY_ID, + EVENT_STATE_REPORTED, + STATE_UNAVAILABLE, + Platform, +) from homeassistant.core import ( Event as HassEvent, EventStateReportedData, @@ -98,30 +107,30 @@ async def test_coordinator_failure_refresh_and_stream( ) entity_id_1 = "binary_sensor.washer_remote_control" entity_id_2 = "binary_sensor.washer_remote_start" - await async_setup_component(hass, "homeassistant", {}) + await async_setup_component(hass, HA_DOMAIN, {}) await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED state = hass.states.get(entity_id_1) assert state - assert state.state != "unavailable" + assert state.state != STATE_UNAVAILABLE state = hass.states.get(entity_id_2) assert state - assert state.state != "unavailable" + assert state.state != STATE_UNAVAILABLE client.get_home_appliances.side_effect = HomeConnectError() # Force a coordinator refresh. await hass.services.async_call( - "homeassistant", "update_entity", {"entity_id": entity_id_1}, blocking=True + HA_DOMAIN, SERVICE_UPDATE_ENTITY, {ATTR_ENTITY_ID: entity_id_1}, blocking=True ) await hass.async_block_till_done() state = hass.states.get(entity_id_1) assert state - assert state.state == "unavailable" + assert state.state == STATE_UNAVAILABLE state = hass.states.get(entity_id_2) assert state - assert state.state == "unavailable" + assert state.state == STATE_UNAVAILABLE # Test that the entity becomes available again after a successful update. @@ -137,16 +146,16 @@ async def test_coordinator_failure_refresh_and_stream( # Force a coordinator refresh. await hass.services.async_call( - "homeassistant", "update_entity", {"entity_id": entity_id_1}, blocking=True + HA_DOMAIN, SERVICE_UPDATE_ENTITY, {ATTR_ENTITY_ID: entity_id_1}, blocking=True ) await hass.async_block_till_done() state = hass.states.get(entity_id_1) assert state - assert state.state != "unavailable" + assert state.state != STATE_UNAVAILABLE state = hass.states.get(entity_id_2) assert state - assert state.state != "unavailable" + assert state.state != STATE_UNAVAILABLE # Test that the event stream makes the entity go available too. @@ -160,16 +169,16 @@ async def test_coordinator_failure_refresh_and_stream( # Force a coordinator refresh await hass.services.async_call( - "homeassistant", "update_entity", {"entity_id": entity_id_1}, blocking=True + HA_DOMAIN, SERVICE_UPDATE_ENTITY, {ATTR_ENTITY_ID: entity_id_1}, blocking=True ) await hass.async_block_till_done() state = hass.states.get(entity_id_1) assert state - assert state.state == "unavailable" + assert state.state == STATE_UNAVAILABLE state = hass.states.get(entity_id_2) assert state - assert state.state == "unavailable" + assert state.state == STATE_UNAVAILABLE # Now make the entity available again. client.get_home_appliances.side_effect = None @@ -199,10 +208,10 @@ async def test_coordinator_failure_refresh_and_stream( state = hass.states.get(entity_id_1) assert state - assert state.state != "unavailable" + assert state.state != STATE_UNAVAILABLE state = hass.states.get(entity_id_2) assert state - assert state.state != "unavailable" + assert state.state != STATE_UNAVAILABLE @pytest.mark.parametrize( @@ -235,9 +244,9 @@ async def test_coordinator_update_failing( getattr(client, mock_method).assert_called() -@pytest.mark.parametrize("appliance_ha_id", ["Dishwasher"], indirect=True) +@pytest.mark.parametrize("appliance", ["Dishwasher"], indirect=True) @pytest.mark.parametrize( - ("event_type", "event_key", "event_value", "entity_id"), + ("event_type", "event_key", "event_value", ATTR_ENTITY_ID), [ ( EventType.STATUS, @@ -269,7 +278,7 @@ async def test_event_listener( integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, client: MagicMock, - appliance_ha_id: str, + appliance: HomeAppliance, entity_registry: er.EntityRegistry, ) -> None: """Test that the event listener works.""" @@ -280,7 +289,7 @@ async def test_event_listener( state = hass.states.get(entity_id) assert state event_message = EventMessage( - appliance_ha_id, + appliance.ha_id, event_type, ArrayOfEvents( [ @@ -327,13 +336,14 @@ async def test_event_listener( listener.assert_called_once_with(new_entity_id) +@pytest.mark.parametrize("appliance", ["Washer"], indirect=True) async def tests_receive_setting_and_status_for_first_time_at_events( hass: HomeAssistant, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, client: MagicMock, - appliance_ha_id: str, + appliance: HomeAppliance, ) -> None: """Test that the event listener is capable of receiving settings and status for the first time.""" client.get_setting = AsyncMock(return_value=ArrayOfSettings([])) @@ -346,7 +356,7 @@ async def tests_receive_setting_and_status_for_first_time_at_events( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.NOTIFY, ArrayOfEvents( [ @@ -362,7 +372,7 @@ async def tests_receive_setting_and_status_for_first_time_at_events( ), ), EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.STATUS, ArrayOfEvents( [ @@ -519,7 +529,7 @@ async def test_devices_updated_on_refresh( return_value=ArrayOfHomeAppliances(appliances[:2]), ) - await async_setup_component(hass, "homeassistant", {}) + await async_setup_component(hass, HA_DOMAIN, {}) assert config_entry.state == ConfigEntryState.NOT_LOADED await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED @@ -532,9 +542,9 @@ async def test_devices_updated_on_refresh( return_value=ArrayOfHomeAppliances(appliances[1:3]), ) await hass.services.async_call( - "homeassistant", - "update_entity", - {"entity_id": "switch.dishwasher_power"}, + HA_DOMAIN, + SERVICE_UPDATE_ENTITY, + {ATTR_ENTITY_ID: "switch.dishwasher_power"}, blocking=True, ) diff --git a/tests/components/home_connect/test_entity.py b/tests/components/home_connect/test_entity.py index bad02888dbf..e91a01a907a 100644 --- a/tests/components/home_connect/test_entity.py +++ b/tests/components/home_connect/test_entity.py @@ -11,6 +11,7 @@ from aiohomeconnect.model import ( EventKey, EventMessage, EventType, + HomeAppliance, Option, OptionKey, Program, @@ -67,7 +68,7 @@ def platforms() -> list[str]: ) @pytest.mark.parametrize( ( - "appliance_ha_id", + "appliance", "option_entity_id", "options_state_stage_1", "options_availability_stage_2", @@ -91,12 +92,12 @@ def platforms() -> list[str]: (OptionKey.DISHCARE_DISHWASHER_EXTRA_DRY, "switch.dishwasher_extra_dry"), ) ], - indirect=["appliance_ha_id"], + indirect=["appliance"], ) async def test_program_options_retrieval( array_of_programs_program_arg: str, event_key: EventKey, - appliance_ha_id: str, + appliance: HomeAppliance, option_entity_id: dict[OptionKey, str], options_state_stage_1: list[tuple[str, bool | None]], options_availability_stage_2: list[bool], @@ -122,7 +123,7 @@ async def test_program_options_retrieval( ] async def get_all_programs_with_options_mock(ha_id: str) -> ArrayOfPrograms: - if ha_id != appliance_ha_id: + if ha_id != appliance.ha_id: return await original_get_all_programs_mock(ha_id) array_of_programs: ArrayOfPrograms = await original_get_all_programs_mock(ha_id) @@ -204,7 +205,7 @@ async def test_program_options_retrieval( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.NOTIFY, data=ArrayOfEvents( [ @@ -235,6 +236,7 @@ async def test_program_options_retrieval( assert hass.states.is_state(entity_id, STATE_UNKNOWN) +@pytest.mark.parametrize("appliance", ["Washer"], indirect=True) @pytest.mark.parametrize( ("array_of_programs_program_arg", "event_key"), [ @@ -251,7 +253,7 @@ async def test_program_options_retrieval( async def test_no_options_retrieval_on_unknown_program( array_of_programs_program_arg: str, event_key: EventKey, - appliance_ha_id: str, + appliance: HomeAppliance, hass: HomeAssistant, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], @@ -285,7 +287,7 @@ async def test_no_options_retrieval_on_unknown_program( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.NOTIFY, data=ArrayOfEvents( [ @@ -315,7 +317,7 @@ async def test_no_options_retrieval_on_unknown_program( ], ) @pytest.mark.parametrize( - ("appliance_ha_id", "option_key", "option_entity_id"), + ("appliance", "option_key", "option_entity_id"), [ ( "Dishwasher", @@ -323,11 +325,11 @@ async def test_no_options_retrieval_on_unknown_program( "switch.dishwasher_half_load", ) ], - indirect=["appliance_ha_id"], + indirect=["appliance"], ) async def test_program_options_retrieval_after_appliance_connection( event_key: EventKey, - appliance_ha_id: str, + appliance: HomeAppliance, option_key: OptionKey, option_entity_id: str, hass: HomeAssistant, @@ -344,7 +346,7 @@ async def test_program_options_retrieval_after_appliance_connection( [ appliance for appliance in array_of_home_appliances.homeappliances - if appliance.ha_id != appliance_ha_id + if appliance.ha_id != appliance.ha_id ] ) @@ -367,7 +369,7 @@ async def test_program_options_retrieval_after_appliance_connection( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.CONNECTED, data=ArrayOfEvents( [ @@ -405,7 +407,7 @@ async def test_program_options_retrieval_after_appliance_connection( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.NOTIFY, data=ArrayOfEvents( [ @@ -450,7 +452,6 @@ async def test_program_options_retrieval_after_appliance_connection( async def test_option_entity_functionality_exception( set_active_program_option_side_effect: HomeConnectError | None, set_selected_program_option_side_effect: HomeConnectError | None, - appliance_ha_id: str, hass: HomeAssistant, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], diff --git a/tests/components/home_connect/test_init.py b/tests/components/home_connect/test_init.py index e0e586929a9..21bb0291e1a 100644 --- a/tests/components/home_connect/test_init.py +++ b/tests/components/home_connect/test_init.py @@ -5,7 +5,7 @@ from typing import Any from unittest.mock import AsyncMock, MagicMock, patch from aiohomeconnect.const import OAUTH2_TOKEN -from aiohomeconnect.model import SettingKey, StatusKey +from aiohomeconnect.model import HomeAppliance, SettingKey, StatusKey from aiohomeconnect.model.error import ( HomeConnectError, TooManyRequestsError, @@ -247,6 +247,7 @@ async def test_client_rate_limit_error( asyncio_sleep_mock.assert_called_once_with(retry_after) +@pytest.mark.parametrize("appliance", ["Washer"], indirect=True) async def test_required_program_or_at_least_an_option( hass: HomeAssistant, device_registry: dr.DeviceRegistry, @@ -254,7 +255,7 @@ async def test_required_program_or_at_least_an_option( integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, client: MagicMock, - appliance_ha_id: str, + appliance: HomeAppliance, ) -> None: "Test that the set_program_and_options does raise an exception if no program nor options are set." @@ -264,7 +265,7 @@ async def test_required_program_or_at_least_an_option( device_entry = device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, - identifiers={(DOMAIN, appliance_ha_id)}, + identifiers={(DOMAIN, appliance.ha_id)}, ) with pytest.raises( @@ -281,12 +282,13 @@ async def test_required_program_or_at_least_an_option( ) +@pytest.mark.parametrize("appliance", ["Washer"], indirect=True) async def test_entity_migration( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, config_entry_v1_1: MockConfigEntry, - appliance_ha_id: str, + appliance: HomeAppliance, platforms: list[Platform], ) -> None: """Test entity migration.""" @@ -295,7 +297,7 @@ async def test_entity_migration( device_entry = device_registry.async_get_or_create( config_entry_id=config_entry_v1_1.entry_id, - identifiers={(DOMAIN, appliance_ha_id)}, + identifiers={(DOMAIN, appliance.ha_id)}, ) test_entities = [ @@ -335,7 +337,7 @@ async def test_entity_migration( entity_registry.async_get_or_create( domain, DOMAIN, - f"{appliance_ha_id}-{old_unique_id_suffix}", + f"{appliance.ha_id}-{old_unique_id_suffix}", device_id=device_entry.id, config_entry=config_entry_v1_1, ) @@ -346,7 +348,7 @@ async def test_entity_migration( for domain, _, expected_unique_id_suffix in test_entities: assert entity_registry.async_get_entity_id( - domain, DOMAIN, f"{appliance_ha_id}-{expected_unique_id_suffix}" + domain, DOMAIN, f"{appliance.ha_id}-{expected_unique_id_suffix}" ) assert config_entry_v1_1.minor_version == 2 diff --git a/tests/components/home_connect/test_light.py b/tests/components/home_connect/test_light.py index 6021c99bb5e..50a1a1e374a 100644 --- a/tests/components/home_connect/test_light.py +++ b/tests/components/home_connect/test_light.py @@ -12,6 +12,7 @@ from aiohomeconnect.model import ( EventMessage, EventType, GetSetting, + HomeAppliance, SettingKey, ) from aiohomeconnect.model.error import HomeConnectApiError, HomeConnectError @@ -21,9 +22,15 @@ from homeassistant.components.home_connect.const import ( BSH_AMBIENT_LIGHT_COLOR_CUSTOM_COLOR, DOMAIN, ) -from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_HS_COLOR, + ATTR_RGB_COLOR, + DOMAIN as LIGHT_DOMAIN, +) from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( + ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON, STATE_OFF, @@ -58,9 +65,9 @@ async def test_light( assert config_entry.state == ConfigEntryState.LOADED -@pytest.mark.parametrize("appliance_ha_id", ["Hood"], indirect=True) +@pytest.mark.parametrize("appliance", ["Hood"], indirect=True) async def test_paired_depaired_devices_flow( - appliance_ha_id: str, + appliance: HomeAppliance, hass: HomeAssistant, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], @@ -74,7 +81,7 @@ async def test_paired_depaired_devices_flow( assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED - device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)}) assert device entity_entries = entity_registry.entities.get_entries_for_device_id(device.id) assert entity_entries @@ -82,7 +89,7 @@ async def test_paired_depaired_devices_flow( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.DEPAIRED, data=ArrayOfEvents([]), ) @@ -90,7 +97,7 @@ async def test_paired_depaired_devices_flow( ) await hass.async_block_till_done() - device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)}) assert not device for entity_entry in entity_entries: assert not entity_registry.async_get(entity_entry.entity_id) @@ -99,7 +106,7 @@ async def test_paired_depaired_devices_flow( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.PAIRED, data=ArrayOfEvents([]), ) @@ -107,14 +114,14 @@ async def test_paired_depaired_devices_flow( ) await hass.async_block_till_done() - assert device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + assert device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)}) for entity_entry in entity_entries: assert entity_registry.async_get(entity_entry.entity_id) -@pytest.mark.parametrize("appliance_ha_id", ["Hood"], indirect=True) +@pytest.mark.parametrize("appliance", ["Hood"], indirect=True) async def test_connected_devices( - appliance_ha_id: str, + appliance: HomeAppliance, hass: HomeAssistant, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], @@ -132,14 +139,14 @@ async def test_connected_devices( get_available_programs_mock = client.get_available_programs async def get_settings_side_effect(ha_id: str): - if ha_id == appliance_ha_id: + if ha_id == appliance.ha_id: raise HomeConnectApiError( "SDK.Error.HomeAppliance.Connection.Initialization.Failed" ) return await get_settings_original_mock.side_effect(ha_id) async def get_available_programs_side_effect(ha_id: str): - if ha_id == appliance_ha_id: + if ha_id == appliance.ha_id: raise HomeConnectApiError( "SDK.Error.HomeAppliance.Connection.Initialization.Failed" ) @@ -155,14 +162,14 @@ async def test_connected_devices( client.get_settings = get_settings_original_mock client.get_available_programs = get_available_programs_mock - device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)}) assert device entity_entries = entity_registry.entities.get_entries_for_device_id(device.id) await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.CONNECTED, data=ArrayOfEvents([]), ) @@ -170,20 +177,20 @@ async def test_connected_devices( ) await hass.async_block_till_done() - device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)}) assert device new_entity_entries = entity_registry.entities.get_entries_for_device_id(device.id) assert len(new_entity_entries) > len(entity_entries) -@pytest.mark.parametrize("appliance_ha_id", ["Hood"], indirect=True) -async def test_light_availabilty( +@pytest.mark.parametrize("appliance", ["Hood"], indirect=True) +async def test_light_availability( hass: HomeAssistant, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, client: MagicMock, - appliance_ha_id: str, + appliance: HomeAppliance, ) -> None: """Test if light entities availability are based on the appliance connection state.""" entity_ids = [ @@ -201,7 +208,7 @@ async def test_light_availabilty( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.DISCONNECTED, ArrayOfEvents([]), ) @@ -215,7 +222,7 @@ async def test_light_availabilty( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.CONNECTED, ArrayOfEvents([]), ) @@ -236,7 +243,7 @@ async def test_light_availabilty( "service", "exprected_attributes", "state", - "appliance_ha_id", + "appliance", ), [ ( @@ -256,7 +263,7 @@ async def test_light_availabilty( SettingKey.COOKING_COMMON_LIGHTING_BRIGHTNESS: 80, }, SERVICE_TURN_ON, - {"brightness": 199}, + {ATTR_BRIGHTNESS: 199}, STATE_ON, "Hood", ), @@ -277,7 +284,7 @@ async def test_light_availabilty( SettingKey.BSH_COMMON_AMBIENT_LIGHT_BRIGHTNESS: 80, }, SERVICE_TURN_ON, - {"brightness": 199}, + {ATTR_BRIGHTNESS: 199}, STATE_ON, "Hood", ), @@ -310,7 +317,7 @@ async def test_light_availabilty( }, SERVICE_TURN_ON, { - "rgb_color": (255, 255, 0), + ATTR_RGB_COLOR: (255, 255, 0), }, STATE_ON, "Hood", @@ -324,8 +331,8 @@ async def test_light_availabilty( }, SERVICE_TURN_ON, { - "hs_color": (255.484, 15.196), - "brightness": 199, + ATTR_HS_COLOR: (255.484, 15.196), + ATTR_BRIGHTNESS: 199, }, STATE_ON, "Hood", @@ -341,7 +348,7 @@ async def test_light_availabilty( "FridgeFreezer", ), ], - indirect=["appliance_ha_id"], + indirect=["appliance"], ) async def test_light_functionality( entity_id: str, @@ -349,7 +356,7 @@ async def test_light_functionality( service: str, exprected_attributes: dict[str, Any], state: str, - appliance_ha_id: str, + appliance: HomeAppliance, hass: HomeAssistant, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], @@ -362,7 +369,7 @@ async def test_light_functionality( assert config_entry.state == ConfigEntryState.LOADED service_data = exprected_attributes.copy() - service_data["entity_id"] = entity_id + service_data[ATTR_ENTITY_ID] = entity_id await hass.services.async_call( LIGHT_DOMAIN, service, @@ -371,7 +378,7 @@ async def test_light_functionality( await hass.async_block_till_done() client.set_setting.assert_has_calls( [ - call(appliance_ha_id, setting_key=setting_key, value=value) + call(appliance.ha_id, setting_key=setting_key, value=value) for setting_key, value in set_settings_args.items() ] ) @@ -386,7 +393,7 @@ async def test_light_functionality( ( "entity_id", "events", - "appliance_ha_id", + "appliance", ), [ ( @@ -397,12 +404,12 @@ async def test_light_functionality( "Hood", ), ], - indirect=["appliance_ha_id"], + indirect=["appliance"], ) async def test_light_color_different_than_custom( entity_id: str, events: dict[EventKey, Any], - appliance_ha_id: str, + appliance: HomeAppliance, hass: HomeAssistant, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], @@ -417,21 +424,21 @@ async def test_light_color_different_than_custom( LIGHT_DOMAIN, SERVICE_TURN_ON, { - "rgb_color": (255, 255, 0), - "entity_id": entity_id, + ATTR_RGB_COLOR: (255, 255, 0), + ATTR_ENTITY_ID: entity_id, }, ) await hass.async_block_till_done() entity_state = hass.states.get(entity_id) assert entity_state is not None assert entity_state.state == STATE_ON - assert entity_state.attributes["rgb_color"] is not None - assert entity_state.attributes["hs_color"] is not None + assert entity_state.attributes[ATTR_RGB_COLOR] is not None + assert entity_state.attributes[ATTR_HS_COLOR] is not None await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.NOTIFY, ArrayOfEvents( [ @@ -454,8 +461,8 @@ async def test_light_color_different_than_custom( entity_state = hass.states.get(entity_id) assert entity_state is not None assert entity_state.state == STATE_ON - assert entity_state.attributes["rgb_color"] is None - assert entity_state.attributes["hs_color"] is None + assert entity_state.attributes[ATTR_RGB_COLOR] is None + assert entity_state.attributes[ATTR_HS_COLOR] is None @pytest.mark.parametrize( @@ -485,7 +492,7 @@ async def test_light_color_different_than_custom( SettingKey.COOKING_COMMON_LIGHTING_BRIGHTNESS: 70, }, SERVICE_TURN_ON, - {"brightness": 200}, + {ATTR_BRIGHTNESS: 200}, [HomeConnectError, HomeConnectError], r"Error.*turn.*on.*", ), @@ -517,7 +524,7 @@ async def test_light_color_different_than_custom( SettingKey.BSH_COMMON_AMBIENT_LIGHT_BRIGHTNESS: 70, }, SERVICE_TURN_ON, - {"brightness": 200}, + {ATTR_BRIGHTNESS: 200}, [HomeConnectError, None, HomeConnectError], r"Error.*set.*brightness.*", ), @@ -530,7 +537,7 @@ async def test_light_color_different_than_custom( SettingKey.BSH_COMMON_AMBIENT_LIGHT_CUSTOM_COLOR: "#ffff00", }, SERVICE_TURN_ON, - {"rgb_color": (255, 255, 0)}, + {ATTR_RGB_COLOR: (255, 255, 0)}, [HomeConnectError, None, HomeConnectError], r"Error.*select.*custom color.*", ), @@ -543,7 +550,7 @@ async def test_light_color_different_than_custom( SettingKey.BSH_COMMON_AMBIENT_LIGHT_CUSTOM_COLOR: "#ffff00", }, SERVICE_TURN_ON, - {"rgb_color": (255, 255, 0)}, + {ATTR_RGB_COLOR: (255, 255, 0)}, [HomeConnectError, None, None, HomeConnectError], r"Error.*set.*color.*", ), @@ -556,8 +563,8 @@ async def test_light_color_different_than_custom( }, SERVICE_TURN_ON, { - "hs_color": (255.484, 15.196), - "brightness": 199, + ATTR_HS_COLOR: (255.484, 15.196), + ATTR_BRIGHTNESS: 199, }, [HomeConnectError, None, None, HomeConnectError], r"Error.*set.*color.*", @@ -600,7 +607,7 @@ async def test_light_exception_handling( with pytest.raises(HomeConnectError): await client_with_exception.set_setting() - service_data["entity_id"] = entity_id + service_data[ATTR_ENTITY_ID] = entity_id with pytest.raises(HomeAssistantError, match=exception_match): await hass.services.async_call( LIGHT_DOMAIN, service, service_data, blocking=True diff --git a/tests/components/home_connect/test_number.py b/tests/components/home_connect/test_number.py index bb87cf9f3dc..1de384303ce 100644 --- a/tests/components/home_connect/test_number.py +++ b/tests/components/home_connect/test_number.py @@ -12,6 +12,7 @@ from aiohomeconnect.model import ( EventMessage, EventType, GetSetting, + HomeAppliance, OptionKey, ProgramDefinition, ProgramKey, @@ -69,8 +70,9 @@ async def test_number( assert config_entry.state is ConfigEntryState.LOADED +@pytest.mark.parametrize("appliance", ["Washer"], indirect=True) async def test_paired_depaired_devices_flow( - appliance_ha_id: str, + appliance: HomeAppliance, hass: HomeAssistant, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], @@ -95,7 +97,7 @@ async def test_paired_depaired_devices_flow( assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED - device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)}) assert device entity_entries = entity_registry.entities.get_entries_for_device_id(device.id) assert entity_entries @@ -103,7 +105,7 @@ async def test_paired_depaired_devices_flow( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.DEPAIRED, data=ArrayOfEvents([]), ) @@ -111,7 +113,7 @@ async def test_paired_depaired_devices_flow( ) await hass.async_block_till_done() - device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)}) assert not device for entity_entry in entity_entries: assert not entity_registry.async_get(entity_entry.entity_id) @@ -120,7 +122,7 @@ async def test_paired_depaired_devices_flow( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.PAIRED, data=ArrayOfEvents([]), ) @@ -128,14 +130,14 @@ async def test_paired_depaired_devices_flow( ) await hass.async_block_till_done() - assert device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + assert device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)}) for entity_entry in entity_entries: assert entity_registry.async_get(entity_entry.entity_id) -@pytest.mark.parametrize("appliance_ha_id", ["FridgeFreezer"], indirect=True) +@pytest.mark.parametrize("appliance", ["FridgeFreezer"], indirect=True) async def test_connected_devices( - appliance_ha_id: str, + appliance: HomeAppliance, hass: HomeAssistant, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], @@ -152,7 +154,7 @@ async def test_connected_devices( get_settings_original_mock = client.get_settings def get_settings_side_effect(ha_id: str): - if ha_id == appliance_ha_id: + if ha_id == appliance.ha_id: raise HomeConnectApiError( "SDK.Error.HomeAppliance.Connection.Initialization.Failed" ) @@ -164,14 +166,14 @@ async def test_connected_devices( assert config_entry.state == ConfigEntryState.LOADED client.get_settings = get_settings_original_mock - device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)}) assert device entity_entries = entity_registry.entities.get_entries_for_device_id(device.id) await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.CONNECTED, data=ArrayOfEvents([]), ) @@ -179,20 +181,20 @@ async def test_connected_devices( ) await hass.async_block_till_done() - device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)}) assert device new_entity_entries = entity_registry.entities.get_entries_for_device_id(device.id) assert len(new_entity_entries) > len(entity_entries) -@pytest.mark.parametrize("appliance_ha_id", ["FridgeFreezer"], indirect=True) -async def test_number_entity_availabilty( +@pytest.mark.parametrize("appliance", ["FridgeFreezer"], indirect=True) +async def test_number_entity_availability( hass: HomeAssistant, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, client: MagicMock, - appliance_ha_id: str, + appliance: HomeAppliance, ) -> None: """Test if number entities availability are based on the appliance connection state.""" entity_ids = [ @@ -215,7 +217,7 @@ async def test_number_entity_availabilty( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.DISCONNECTED, ArrayOfEvents([]), ) @@ -229,7 +231,7 @@ async def test_number_entity_availabilty( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.CONNECTED, ArrayOfEvents([]), ) @@ -243,7 +245,7 @@ async def test_number_entity_availabilty( assert state.state != STATE_UNAVAILABLE -@pytest.mark.parametrize("appliance_ha_id", ["FridgeFreezer"], indirect=True) +@pytest.mark.parametrize("appliance", ["FridgeFreezer"], indirect=True) @pytest.mark.parametrize( ( "entity_id", @@ -279,7 +281,7 @@ async def test_number_entity_availabilty( ], ) async def test_number_entity_functionality( - appliance_ha_id: str, + appliance: HomeAppliance, entity_id: str, setting_key: SettingKey, type: str, @@ -336,12 +338,12 @@ async def test_number_entity_functionality( ) await hass.async_block_till_done() client.set_setting.assert_awaited_once_with( - appliance_ha_id, setting_key=setting_key, value=value + appliance.ha_id, setting_key=setting_key, value=value ) assert hass.states.is_state(entity_id, str(float(value))) -@pytest.mark.parametrize("appliance_ha_id", ["FridgeFreezer"], indirect=True) +@pytest.mark.parametrize("appliance", ["FridgeFreezer"], indirect=True) @pytest.mark.parametrize("retry_after", [0, None]) @pytest.mark.parametrize( ( @@ -368,7 +370,7 @@ async def test_number_entity_functionality( @patch("homeassistant.components.home_connect.entity.API_DEFAULT_RETRY_AFTER", new=0) async def test_fetch_constraints_after_rate_limit_error( retry_after: int | None, - appliance_ha_id: str, + appliance: HomeAppliance, entity_id: str, setting_key: SettingKey, type: str, @@ -385,7 +387,7 @@ async def test_fetch_constraints_after_rate_limit_error( """Test that, if a API rate limit error is raised, the constraints are fetched later.""" def get_settings_side_effect(ha_id: str): - if ha_id != appliance_ha_id: + if ha_id != appliance.ha_id: return ArrayOfSettings([]) return ArrayOfSettings( [ @@ -511,7 +513,7 @@ async def test_number_entity_error( ], ) @pytest.mark.parametrize( - ("appliance_ha_id", "entity_id", "option_key", "min", "max", "step_size", "unit"), + ("appliance", "entity_id", "option_key", "min", "max", "step_size", "unit"), [ ( "Oven", @@ -523,12 +525,12 @@ async def test_number_entity_error( "°C", ), ], - indirect=["appliance_ha_id"], + indirect=["appliance"], ) async def test_options_functionality( entity_id: str, option_key: OptionKey, - appliance_ha_id: str, + appliance: HomeAppliance, min: int, max: int, step_size: int, @@ -615,7 +617,7 @@ async def test_options_functionality( await hass.async_block_till_done() assert called_mock.called - assert called_mock.call_args.args == (appliance_ha_id,) + assert called_mock.call_args.args == (appliance.ha_id,) assert called_mock.call_args.kwargs == { "option_key": option_key, "value": 80, diff --git a/tests/components/home_connect/test_select.py b/tests/components/home_connect/test_select.py index f20be33081c..f6009640f72 100644 --- a/tests/components/home_connect/test_select.py +++ b/tests/components/home_connect/test_select.py @@ -12,6 +12,7 @@ from aiohomeconnect.model import ( EventMessage, EventType, GetSetting, + HomeAppliance, OptionKey, ProgramDefinition, ProgramKey, @@ -72,8 +73,9 @@ async def test_select( assert config_entry.state is ConfigEntryState.LOADED +@pytest.mark.parametrize("appliance", ["Washer"], indirect=True) async def test_paired_depaired_devices_flow( - appliance_ha_id: str, + appliance: HomeAppliance, hass: HomeAssistant, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], @@ -98,7 +100,7 @@ async def test_paired_depaired_devices_flow( assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED - device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)}) assert device entity_entries = entity_registry.entities.get_entries_for_device_id(device.id) assert entity_entries @@ -106,7 +108,7 @@ async def test_paired_depaired_devices_flow( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.DEPAIRED, data=ArrayOfEvents([]), ) @@ -114,7 +116,7 @@ async def test_paired_depaired_devices_flow( ) await hass.async_block_till_done() - device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)}) assert not device for entity_entry in entity_entries: assert not entity_registry.async_get(entity_entry.entity_id) @@ -123,7 +125,7 @@ async def test_paired_depaired_devices_flow( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.PAIRED, data=ArrayOfEvents([]), ) @@ -131,13 +133,14 @@ async def test_paired_depaired_devices_flow( ) await hass.async_block_till_done() - assert device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + assert device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)}) for entity_entry in entity_entries: assert entity_registry.async_get(entity_entry.entity_id) +@pytest.mark.parametrize("appliance", ["Washer"], indirect=True) async def test_connected_devices( - appliance_ha_id: str, + appliance: HomeAppliance, hass: HomeAssistant, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], @@ -156,13 +159,13 @@ async def test_connected_devices( assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED - device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)}) assert device await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.CONNECTED, data=ArrayOfEvents([]), ) @@ -170,19 +173,20 @@ async def test_connected_devices( ) await hass.async_block_till_done() - device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)}) assert device entity_entries = entity_registry.entities.get_entries_for_device_id(device.id) assert entity_entries -async def test_select_entity_availabilty( +@pytest.mark.parametrize("appliance", ["Washer"], indirect=True) +async def test_select_entity_availability( hass: HomeAssistant, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, client: MagicMock, - appliance_ha_id: str, + appliance: HomeAppliance, ) -> None: """Test if select entities availability are based on the appliance connection state.""" entity_ids = [ @@ -200,7 +204,7 @@ async def test_select_entity_availabilty( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.DISCONNECTED, ArrayOfEvents([]), ) @@ -214,7 +218,7 @@ async def test_select_entity_availabilty( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.CONNECTED, ArrayOfEvents([]), ) @@ -290,7 +294,7 @@ async def test_filter_programs( @pytest.mark.parametrize( ( - "appliance_ha_id", + "appliance", "entity_id", "expected_initial_state", "mock_method", @@ -318,10 +322,10 @@ async def test_filter_programs( EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM, ), ], - indirect=["appliance_ha_id"], + indirect=["appliance"], ) async def test_select_program_functionality( - appliance_ha_id: str, + appliance: HomeAppliance, entity_id: str, expected_initial_state: str, mock_method: str, @@ -347,14 +351,14 @@ async def test_select_program_functionality( ) await hass.async_block_till_done() getattr(client, mock_method).assert_awaited_once_with( - appliance_ha_id, program_key=program_key + appliance.ha_id, program_key=program_key ) assert hass.states.is_state(entity_id, program_to_set) await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.NOTIFY, ArrayOfEvents( [ @@ -433,13 +437,13 @@ async def test_select_exception_handling( await hass.services.async_call( SELECT_DOMAIN, SERVICE_SELECT_OPTION, - {"entity_id": entity_id, "option": program_to_set}, + {ATTR_ENTITY_ID: entity_id, ATTR_OPTION: program_to_set}, blocking=True, ) assert getattr(client_with_exception, mock_attr).call_count == 2 -@pytest.mark.parametrize("appliance_ha_id", ["Hood"], indirect=True) +@pytest.mark.parametrize("appliance", ["Hood"], indirect=True) @pytest.mark.parametrize( ( "entity_id", @@ -473,7 +477,7 @@ async def test_select_exception_handling( ], ) async def test_select_functionality( - appliance_ha_id: str, + appliance: HomeAppliance, entity_id: str, setting_key: SettingKey, expected_options: set[str], @@ -497,12 +501,12 @@ async def test_select_functionality( await hass.services.async_call( SELECT_DOMAIN, SERVICE_SELECT_OPTION, - {ATTR_ENTITY_ID: entity_id, "option": value_to_set}, + {ATTR_ENTITY_ID: entity_id, ATTR_OPTION: value_to_set}, ) await hass.async_block_till_done() client.set_setting.assert_called_once() - assert client.set_setting.call_args.args == (appliance_ha_id,) + assert client.set_setting.call_args.args == (appliance.ha_id,) assert client.set_setting.call_args.kwargs == { "setting_key": setting_key, "value": expected_value_call_arg, @@ -510,7 +514,7 @@ async def test_select_functionality( assert hass.states.is_state(entity_id, value_to_set) -@pytest.mark.parametrize("appliance_ha_id", ["Hood"], indirect=True) +@pytest.mark.parametrize("appliance", ["Hood"], indirect=True) @pytest.mark.parametrize( ( "entity_id", @@ -537,7 +541,7 @@ async def test_select_functionality( ], ) async def test_fetch_allowed_values( - appliance_ha_id: str, + appliance: HomeAppliance, entity_id: str, test_setting_key: SettingKey, allowed_values: list[str | None], @@ -554,7 +558,7 @@ async def test_fetch_allowed_values( async def get_setting_side_effect( ha_id: str, setting_key: SettingKey ) -> GetSetting: - if ha_id != appliance_ha_id or setting_key != test_setting_key: + if ha_id != appliance.ha_id or setting_key != test_setting_key: return await original_get_setting_side_effect(ha_id, setting_key) return GetSetting( key=test_setting_key, @@ -576,7 +580,7 @@ async def test_fetch_allowed_values( assert set(entity_state.attributes[ATTR_OPTIONS]) == expected_options -@pytest.mark.parametrize("appliance_ha_id", ["Hood"], indirect=True) +@pytest.mark.parametrize("appliance", ["Hood"], indirect=True) @pytest.mark.parametrize( ( "entity_id", @@ -594,7 +598,7 @@ async def test_fetch_allowed_values( ], ) async def test_fetch_allowed_values_after_rate_limit_error( - appliance_ha_id: str, + appliance: HomeAppliance, entity_id: str, setting_key: SettingKey, allowed_values: list[str | None], @@ -608,7 +612,7 @@ async def test_fetch_allowed_values_after_rate_limit_error( """Test fetch allowed values.""" def get_settings_side_effect(ha_id: str): - if ha_id != appliance_ha_id: + if ha_id != appliance.ha_id: return ArrayOfSettings([]) return ArrayOfSettings( [ @@ -648,7 +652,7 @@ async def test_fetch_allowed_values_after_rate_limit_error( assert set(entity_state.attributes[ATTR_OPTIONS]) == expected_options -@pytest.mark.parametrize("appliance_ha_id", ["Hood"], indirect=True) +@pytest.mark.parametrize("appliance", ["Hood"], indirect=True) @pytest.mark.parametrize( ( "entity_id", @@ -669,7 +673,7 @@ async def test_fetch_allowed_values_after_rate_limit_error( ], ) async def test_default_values_after_fetch_allowed_values_error( - appliance_ha_id: str, + appliance: HomeAppliance, entity_id: str, setting_key: SettingKey, exception: Exception, @@ -683,7 +687,7 @@ async def test_default_values_after_fetch_allowed_values_error( """Test fetch allowed values.""" def get_settings_side_effect(ha_id: str): - if ha_id != appliance_ha_id: + if ha_id != appliance.ha_id: return ArrayOfSettings([]) return ArrayOfSettings( [ @@ -758,12 +762,13 @@ async def test_select_entity_error( await hass.services.async_call( SELECT_DOMAIN, SERVICE_SELECT_OPTION, - {ATTR_ENTITY_ID: entity_id, "option": value_to_set}, + {ATTR_ENTITY_ID: entity_id, ATTR_OPTION: value_to_set}, blocking=True, ) assert getattr(client_with_exception, mock_attr).call_count == 2 +@pytest.mark.parametrize("appliance", ["Washer"], indirect=True) @pytest.mark.parametrize( ( "set_active_program_options_side_effect", @@ -840,7 +845,7 @@ async def test_options_functionality( option_key: OptionKey, allowed_values: list[str | None] | None, expected_options: set[str], - appliance_ha_id: str, + appliance: HomeAppliance, set_active_program_options_side_effect: ActiveProgramNotSetError | None, set_selected_program_options_side_effect: SelectedProgramNotSetError | None, called_mock_method: str, @@ -894,7 +899,7 @@ async def test_options_functionality( await hass.async_block_till_done() assert called_mock.called - assert called_mock.call_args.args == (appliance_ha_id,) + assert called_mock.call_args.args == (appliance.ha_id,) assert called_mock.call_args.kwargs == { "option_key": option_key, "value": "LaundryCare.Washer.EnumType.Temperature.UlWarm", diff --git a/tests/components/home_connect/test_sensor.py b/tests/components/home_connect/test_sensor.py index a7836223737..f30723af7fa 100644 --- a/tests/components/home_connect/test_sensor.py +++ b/tests/components/home_connect/test_sensor.py @@ -10,6 +10,7 @@ from aiohomeconnect.model import ( EventKey, EventMessage, EventType, + HomeAppliance, Status, StatusKey, ) @@ -99,8 +100,9 @@ async def test_sensors( assert config_entry.state == ConfigEntryState.LOADED +@pytest.mark.parametrize("appliance", ["Washer"], indirect=True) async def test_paired_depaired_devices_flow( - appliance_ha_id: str, + appliance: HomeAppliance, hass: HomeAssistant, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], @@ -114,7 +116,7 @@ async def test_paired_depaired_devices_flow( assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED - device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)}) assert device entity_entries = entity_registry.entities.get_entries_for_device_id(device.id) assert entity_entries @@ -122,7 +124,7 @@ async def test_paired_depaired_devices_flow( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.DEPAIRED, data=ArrayOfEvents([]), ) @@ -130,7 +132,7 @@ async def test_paired_depaired_devices_flow( ) await hass.async_block_till_done() - device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)}) assert not device for entity_entry in entity_entries: assert not entity_registry.async_get(entity_entry.entity_id) @@ -139,7 +141,7 @@ async def test_paired_depaired_devices_flow( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.PAIRED, data=ArrayOfEvents([]), ) @@ -147,13 +149,14 @@ async def test_paired_depaired_devices_flow( ) await hass.async_block_till_done() - assert device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + assert device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)}) for entity_entry in entity_entries: assert entity_registry.async_get(entity_entry.entity_id) +@pytest.mark.parametrize("appliance", ["Washer"], indirect=True) async def test_connected_devices( - appliance_ha_id: str, + appliance: HomeAppliance, hass: HomeAssistant, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], @@ -170,7 +173,7 @@ async def test_connected_devices( get_status_original_mock = client.get_status def get_status_side_effect(ha_id: str): - if ha_id == appliance_ha_id: + if ha_id == appliance.ha_id: raise HomeConnectApiError( "SDK.Error.HomeAppliance.Connection.Initialization.Failed" ) @@ -182,14 +185,14 @@ async def test_connected_devices( assert config_entry.state == ConfigEntryState.LOADED client.get_status = get_status_original_mock - device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)}) assert device entity_entries = entity_registry.entities.get_entries_for_device_id(device.id) await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.CONNECTED, data=ArrayOfEvents([]), ) @@ -197,20 +200,20 @@ async def test_connected_devices( ) await hass.async_block_till_done() - device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)}) assert device new_entity_entries = entity_registry.entities.get_entries_for_device_id(device.id) assert len(new_entity_entries) > len(entity_entries) -@pytest.mark.parametrize("appliance_ha_id", [TEST_HC_APP], indirect=True) -async def test_sensor_entity_availabilty( +@pytest.mark.parametrize("appliance", [TEST_HC_APP], indirect=True) +async def test_sensor_entity_availability( hass: HomeAssistant, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, client: MagicMock, - appliance_ha_id: str, + appliance: HomeAppliance, ) -> None: """Test if sensor entities availability are based on the appliance connection state.""" entity_ids = [ @@ -229,7 +232,7 @@ async def test_sensor_entity_availabilty( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.DISCONNECTED, ArrayOfEvents([]), ) @@ -243,7 +246,7 @@ async def test_sensor_entity_availabilty( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.CONNECTED, ArrayOfEvents([]), ) @@ -257,7 +260,7 @@ async def test_sensor_entity_availabilty( assert state.state != STATE_UNAVAILABLE -# Appliance_ha_id program sequence with a delayed start. +# Appliance program sequence with a delayed start. PROGRAM_SEQUENCE_EVENTS = ( EVENT_PROG_DELAYED_START, EVENT_PROG_RUN, @@ -292,7 +295,7 @@ ENTITY_ID_STATES = { } -@pytest.mark.parametrize("appliance_ha_id", [TEST_HC_APP], indirect=True) +@pytest.mark.parametrize("appliance", [TEST_HC_APP], indirect=True) @pytest.mark.parametrize( ("states", "event_run"), list( @@ -305,7 +308,7 @@ ENTITY_ID_STATES = { ) async def test_program_sensors( client: MagicMock, - appliance_ha_id: str, + appliance: HomeAppliance, states: tuple, event_run: dict[EventType, dict[EventKey, str | int]], freezer: FrozenDateTimeFactory, @@ -335,7 +338,7 @@ async def test_program_sensors( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, event_type, ArrayOfEvents( [ @@ -359,7 +362,7 @@ async def test_program_sensors( assert hass.states.is_state(entity_id, state) -@pytest.mark.parametrize("appliance_ha_id", [TEST_HC_APP], indirect=True) +@pytest.mark.parametrize("appliance", [TEST_HC_APP], indirect=True) @pytest.mark.parametrize( ("initial_operation_state", "initial_state", "event_order", "entity_states"), [ @@ -382,7 +385,7 @@ async def test_program_sensor_edge_case( initial_state: str, event_order: tuple[EventType, EventType], entity_states: tuple[str, str], - appliance_ha_id: str, + appliance: HomeAppliance, hass: HomeAssistant, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], @@ -413,7 +416,7 @@ async def test_program_sensor_edge_case( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, event_type, ArrayOfEvents( [ @@ -452,9 +455,9 @@ ENTITY_ID_EDGE_CASE_STATES = [ ] -@pytest.mark.parametrize("appliance_ha_id", [TEST_HC_APP], indirect=True) +@pytest.mark.parametrize("appliance", [TEST_HC_APP], indirect=True) async def test_remaining_prog_time_edge_cases( - appliance_ha_id: str, + appliance: HomeAppliance, freezer: FrozenDateTimeFactory, hass: HomeAssistant, config_entry: MockConfigEntry, @@ -478,7 +481,7 @@ async def test_remaining_prog_time_edge_cases( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, event_type, ArrayOfEvents( [ @@ -509,7 +512,7 @@ async def test_remaining_prog_time_edge_cases( "event_type", "event_value_update", "expected", - "appliance_ha_id", + "appliance", ), [ ( @@ -601,14 +604,14 @@ async def test_remaining_prog_time_edge_cases( "CoffeeMaker", ), ], - indirect=["appliance_ha_id"], + indirect=["appliance"], ) async def test_sensors_states( entity_id: str, event_key: EventKey, event_type: EventType, event_value_update: str, - appliance_ha_id: str, + appliance: HomeAppliance, expected: str, hass: HomeAssistant, config_entry: MockConfigEntry, @@ -616,7 +619,7 @@ async def test_sensors_states( setup_credentials: None, client: MagicMock, ) -> None: - """Tests for Appliance_ha_id alarm sensors.""" + """Tests for appliance alarm sensors.""" assert config_entry.state == ConfigEntryState.NOT_LOADED assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED @@ -624,7 +627,7 @@ async def test_sensors_states( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, event_type, ArrayOfEvents( [ @@ -647,7 +650,7 @@ async def test_sensors_states( @pytest.mark.parametrize( ( - "appliance_ha_id", + "appliance", "entity_id", "status_key", "unit_get_status", @@ -672,10 +675,10 @@ async def test_sensors_states( 1, ), ], - indirect=["appliance_ha_id"], + indirect=["appliance"], ) async def test_sensor_unit_fetching( - appliance_ha_id: str, + appliance: HomeAppliance, entity_id: str, status_key: StatusKey, unit_get_status: str | None, @@ -690,7 +693,7 @@ async def test_sensor_unit_fetching( """Test that the sensor entities are capable of fetching units.""" async def get_status_mock(ha_id: str) -> ArrayOfStatus: - if ha_id != appliance_ha_id: + if ha_id != appliance.ha_id: return ArrayOfStatus([]) return ArrayOfStatus( [ @@ -729,7 +732,7 @@ async def test_sensor_unit_fetching( @pytest.mark.parametrize( ( - "appliance_ha_id", + "appliance", "entity_id", "status_key", ), @@ -740,10 +743,10 @@ async def test_sensor_unit_fetching( StatusKey.COOKING_OVEN_CURRENT_CAVITY_TEMPERATURE, ), ], - indirect=["appliance_ha_id"], + indirect=["appliance"], ) async def test_sensor_unit_fetching_error( - appliance_ha_id: str, + appliance: HomeAppliance, entity_id: str, status_key: StatusKey, hass: HomeAssistant, @@ -755,7 +758,7 @@ async def test_sensor_unit_fetching_error( """Test that the sensor entities are capable of fetching units.""" async def get_status_mock(ha_id: str) -> ArrayOfStatus: - if ha_id != appliance_ha_id: + if ha_id != appliance.ha_id: return ArrayOfStatus([]) return ArrayOfStatus( [ @@ -779,7 +782,7 @@ async def test_sensor_unit_fetching_error( @pytest.mark.parametrize( ( - "appliance_ha_id", + "appliance", "entity_id", "status_key", "unit", @@ -792,10 +795,10 @@ async def test_sensor_unit_fetching_error( "°C", ), ], - indirect=["appliance_ha_id"], + indirect=["appliance"], ) async def test_sensor_unit_fetching_after_rate_limit_error( - appliance_ha_id: str, + appliance: HomeAppliance, entity_id: str, status_key: StatusKey, unit: str, @@ -808,7 +811,7 @@ async def test_sensor_unit_fetching_after_rate_limit_error( """Test that the sensor entities are capable of fetching units.""" async def get_status_mock(ha_id: str) -> ArrayOfStatus: - if ha_id != appliance_ha_id: + if ha_id != appliance.ha_id: return ArrayOfStatus([]) return ArrayOfStatus( [ diff --git a/tests/components/home_connect/test_services.py b/tests/components/home_connect/test_services.py index 517564724a9..2915cbe4f69 100644 --- a/tests/components/home_connect/test_services.py +++ b/tests/components/home_connect/test_services.py @@ -5,7 +5,7 @@ from http import HTTPStatus from typing import Any from unittest.mock import MagicMock -from aiohomeconnect.model import OptionKey, ProgramKey, SettingKey +from aiohomeconnect.model import HomeAppliance, OptionKey, ProgramKey, SettingKey import pytest from syrupy.assertion import SnapshotAssertion @@ -170,6 +170,7 @@ SERVICES_SET_PROGRAM_AND_OPTIONS = [ ] +@pytest.mark.parametrize("appliance", ["Washer"], indirect=True) @pytest.mark.parametrize( "service_call", SERVICE_KV_CALL_PARAMS + SERVICE_COMMAND_CALL_PARAMS + SERVICE_PROGRAM_CALL_PARAMS, @@ -182,7 +183,7 @@ async def test_key_value_services( integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, client: MagicMock, - appliance_ha_id: str, + appliance: HomeAppliance, ) -> None: """Create and test services.""" assert config_entry.state == ConfigEntryState.NOT_LOADED @@ -191,7 +192,7 @@ async def test_key_value_services( device_entry = device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, - identifiers={(DOMAIN, appliance_ha_id)}, + identifiers={(DOMAIN, appliance.ha_id)}, ) service_name = service_call["service"] @@ -203,6 +204,7 @@ async def test_key_value_services( ) +@pytest.mark.parametrize("appliance", ["Washer"], indirect=True) @pytest.mark.parametrize( ("service_call", "issue_id"), [ @@ -231,7 +233,7 @@ async def test_programs_and_options_actions_deprecation( integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, client: MagicMock, - appliance_ha_id: str, + appliance: HomeAppliance, issue_registry: ir.IssueRegistry, hass_client: ClientSessionGenerator, ) -> None: @@ -242,7 +244,7 @@ async def test_programs_and_options_actions_deprecation( device_entry = device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, - identifiers={(DOMAIN, appliance_ha_id)}, + identifiers={(DOMAIN, appliance.ha_id)}, ) service_call["service_data"]["device_id"] = device_entry.id @@ -279,6 +281,7 @@ async def test_programs_and_options_actions_deprecation( assert len(issue_registry.issues) == 0 +@pytest.mark.parametrize("appliance", ["Washer"], indirect=True) @pytest.mark.parametrize( ("service_call", "called_method"), zip( @@ -301,7 +304,7 @@ async def test_set_program_and_options( integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, client: MagicMock, - appliance_ha_id: str, + appliance: HomeAppliance, snapshot: SnapshotAssertion, ) -> None: """Test recognized options.""" @@ -311,7 +314,7 @@ async def test_set_program_and_options( device_entry = device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, - identifiers={(DOMAIN, appliance_ha_id)}, + identifiers={(DOMAIN, appliance.ha_id)}, ) service_call["service_data"]["device_id"] = device_entry.id @@ -322,6 +325,7 @@ async def test_set_program_and_options( assert method_mock.call_args == snapshot +@pytest.mark.parametrize("appliance", ["Washer"], indirect=True) @pytest.mark.parametrize( ("service_call", "error_regex"), zip( @@ -344,7 +348,7 @@ async def test_set_program_and_options_exceptions( integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, client_with_exception: MagicMock, - appliance_ha_id: str, + appliance: HomeAppliance, ) -> None: """Test recognized options.""" assert config_entry.state == ConfigEntryState.NOT_LOADED @@ -353,7 +357,7 @@ async def test_set_program_and_options_exceptions( device_entry = device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, - identifiers={(DOMAIN, appliance_ha_id)}, + identifiers={(DOMAIN, appliance.ha_id)}, ) service_call["service_data"]["device_id"] = device_entry.id @@ -361,6 +365,7 @@ async def test_set_program_and_options_exceptions( await hass.services.async_call(**service_call) +@pytest.mark.parametrize("appliance", ["Washer"], indirect=True) @pytest.mark.parametrize( "service_call", SERVICE_KV_CALL_PARAMS + SERVICE_COMMAND_CALL_PARAMS + SERVICE_PROGRAM_CALL_PARAMS, @@ -372,7 +377,7 @@ async def test_services_exception_device_id( integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, client_with_exception: MagicMock, - appliance_ha_id: str, + appliance: HomeAppliance, device_registry: dr.DeviceRegistry, ) -> None: """Raise a HomeAssistantError when there is an API error.""" @@ -382,7 +387,7 @@ async def test_services_exception_device_id( device_entry = device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, - identifiers={(DOMAIN, appliance_ha_id)}, + identifiers={(DOMAIN, appliance.ha_id)}, ) service_call["service_data"]["device_id"] = device_entry.id @@ -434,6 +439,7 @@ async def test_services_appliance_not_found( await hass.services.async_call(**service_call) +@pytest.mark.parametrize("appliance", ["Washer"], indirect=True) @pytest.mark.parametrize( "service_call", SERVICE_KV_CALL_PARAMS + SERVICE_COMMAND_CALL_PARAMS + SERVICE_PROGRAM_CALL_PARAMS, @@ -445,7 +451,7 @@ async def test_services_exception( integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, client_with_exception: MagicMock, - appliance_ha_id: str, + appliance: HomeAppliance, device_registry: dr.DeviceRegistry, ) -> None: """Raise a ValueError when device id does not match.""" @@ -455,7 +461,7 @@ async def test_services_exception( device_entry = device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, - identifiers={(DOMAIN, appliance_ha_id)}, + identifiers={(DOMAIN, appliance.ha_id)}, ) service_call["service_data"]["device_id"] = device_entry.id diff --git a/tests/components/home_connect/test_switch.py b/tests/components/home_connect/test_switch.py index 1b38809dc05..2903c8ac718 100644 --- a/tests/components/home_connect/test_switch.py +++ b/tests/components/home_connect/test_switch.py @@ -13,6 +13,7 @@ from aiohomeconnect.model import ( EventMessage, EventType, GetSetting, + HomeAppliance, OptionKey, ProgramDefinition, ProgramKey, @@ -79,8 +80,9 @@ async def test_switches( assert config_entry.state == ConfigEntryState.LOADED +@pytest.mark.parametrize("appliance", ["Washer"], indirect=True) async def test_paired_depaired_devices_flow( - appliance_ha_id: str, + appliance: HomeAppliance, hass: HomeAssistant, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], @@ -105,7 +107,7 @@ async def test_paired_depaired_devices_flow( assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED - device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)}) assert device entity_entries = entity_registry.entities.get_entries_for_device_id(device.id) assert entity_entries @@ -113,7 +115,7 @@ async def test_paired_depaired_devices_flow( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.DEPAIRED, data=ArrayOfEvents([]), ) @@ -121,7 +123,7 @@ async def test_paired_depaired_devices_flow( ) await hass.async_block_till_done() - device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)}) assert not device for entity_entry in entity_entries: assert not entity_registry.async_get(entity_entry.entity_id) @@ -130,7 +132,7 @@ async def test_paired_depaired_devices_flow( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.PAIRED, data=ArrayOfEvents([]), ) @@ -138,13 +140,14 @@ async def test_paired_depaired_devices_flow( ) await hass.async_block_till_done() - assert device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + assert device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)}) for entity_entry in entity_entries: assert entity_registry.async_get(entity_entry.entity_id) +@pytest.mark.parametrize("appliance", ["Washer"], indirect=True) async def test_connected_devices( - appliance_ha_id: str, + appliance: HomeAppliance, hass: HomeAssistant, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], @@ -162,14 +165,14 @@ async def test_connected_devices( get_available_programs_mock = client.get_available_programs async def get_settings_side_effect(ha_id: str): - if ha_id == appliance_ha_id: + if ha_id == appliance.ha_id: raise HomeConnectApiError( "SDK.Error.HomeAppliance.Connection.Initialization.Failed" ) return await get_settings_original_mock.side_effect(ha_id) async def get_available_programs_side_effect(ha_id: str): - if ha_id == appliance_ha_id: + if ha_id == appliance.ha_id: raise HomeConnectApiError( "SDK.Error.HomeAppliance.Connection.Initialization.Failed" ) @@ -185,14 +188,14 @@ async def test_connected_devices( client.get_settings = get_settings_original_mock client.get_available_programs = get_available_programs_mock - device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)}) assert device entity_entries = entity_registry.entities.get_entries_for_device_id(device.id) await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.CONNECTED, data=ArrayOfEvents([]), ) @@ -200,20 +203,20 @@ async def test_connected_devices( ) await hass.async_block_till_done() - device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)}) assert device new_entity_entries = entity_registry.entities.get_entries_for_device_id(device.id) assert len(new_entity_entries) > len(entity_entries) -@pytest.mark.parametrize("appliance_ha_id", ["Dishwasher"], indirect=True) -async def test_switch_entity_availabilty( +@pytest.mark.parametrize("appliance", ["Dishwasher"], indirect=True) +async def test_switch_entity_availability( hass: HomeAssistant, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, client: MagicMock, - appliance_ha_id: str, + appliance: HomeAppliance, ) -> None: """Test if switch entities availability are based on the appliance connection state.""" entity_ids = [ @@ -233,7 +236,7 @@ async def test_switch_entity_availabilty( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.DISCONNECTED, ArrayOfEvents([]), ) @@ -247,7 +250,7 @@ async def test_switch_entity_availabilty( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.CONNECTED, ArrayOfEvents([]), ) @@ -268,7 +271,7 @@ async def test_switch_entity_availabilty( "settings_key_arg", "setting_value_arg", "state", - "appliance_ha_id", + "appliance", ), [ ( @@ -288,7 +291,7 @@ async def test_switch_entity_availabilty( "Dishwasher", ), ], - indirect=["appliance_ha_id"], + indirect=["appliance"], ) async def test_switch_functionality( entity_id: str, @@ -300,7 +303,7 @@ async def test_switch_functionality( config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - appliance_ha_id: str, + appliance: HomeAppliance, client: MagicMock, ) -> None: """Test switch functionality.""" @@ -312,13 +315,13 @@ async def test_switch_functionality( await hass.services.async_call(SWITCH_DOMAIN, service, {ATTR_ENTITY_ID: entity_id}) await hass.async_block_till_done() client.set_setting.assert_awaited_once_with( - appliance_ha_id, setting_key=settings_key_arg, value=setting_value_arg + appliance.ha_id, setting_key=settings_key_arg, value=setting_value_arg ) assert hass.states.is_state(entity_id, state) @pytest.mark.parametrize( - ("entity_id", "program_key", "initial_state", "appliance_ha_id"), + ("entity_id", "program_key", "initial_state", "appliance"), [ ( "switch.dryer_program_mix", @@ -333,7 +336,7 @@ async def test_switch_functionality( "Dryer", ), ], - indirect=["appliance_ha_id"], + indirect=["appliance"], ) async def test_program_switch_functionality( entity_id: str, @@ -343,7 +346,7 @@ async def test_program_switch_functionality( config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - appliance_ha_id: str, + appliance: HomeAppliance, client: MagicMock, ) -> None: """Test switch functionality.""" @@ -383,7 +386,7 @@ async def test_program_switch_functionality( await hass.async_block_till_done() assert hass.states.is_state(entity_id, STATE_ON) client.start_program.assert_awaited_once_with( - appliance_ha_id, program_key=program_key + appliance.ha_id, program_key=program_key ) await hass.services.async_call( @@ -391,7 +394,7 @@ async def test_program_switch_functionality( ) await hass.async_block_till_done() assert hass.states.is_state(entity_id, STATE_OFF) - client.stop_program.assert_awaited_once_with(appliance_ha_id) + client.stop_program.assert_awaited_once_with(appliance.ha_id) @pytest.mark.parametrize( @@ -496,7 +499,7 @@ async def test_switch_exception_handling( @pytest.mark.parametrize( - ("entity_id", "status", "service", "state", "appliance_ha_id"), + ("entity_id", "status", "service", "state", "appliance"), [ ( "switch.fridgefreezer_freezer_super_mode", @@ -513,7 +516,7 @@ async def test_switch_exception_handling( "FridgeFreezer", ), ], - indirect=["appliance_ha_id"], + indirect=["appliance"], ) async def test_ent_desc_switch_functionality( entity_id: str, @@ -524,7 +527,7 @@ async def test_ent_desc_switch_functionality( config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - appliance_ha_id: str, + appliance: HomeAppliance, client: MagicMock, ) -> None: """Test switch functionality - entity description setup.""" @@ -544,7 +547,7 @@ async def test_ent_desc_switch_functionality( "status", "service", "mock_attr", - "appliance_ha_id", + "appliance", "exception_match", ), [ @@ -565,7 +568,7 @@ async def test_ent_desc_switch_functionality( r"Error.*turn.*off.*", ), ], - indirect=["appliance_ha_id"], + indirect=["appliance"], ) async def test_ent_desc_switch_exception_handling( entity_id: str, @@ -577,7 +580,7 @@ async def test_ent_desc_switch_exception_handling( integration_setup: Callable[[MagicMock], Awaitable[bool]], config_entry: MockConfigEntry, setup_credentials: None, - appliance_ha_id: str, + appliance: HomeAppliance, client_with_exception: MagicMock, ) -> None: """Test switch exception handling - entity description setup.""" @@ -613,7 +616,7 @@ async def test_ent_desc_switch_exception_handling( "service", "setting_value_arg", "power_state", - "appliance_ha_id", + "appliance", ), [ ( @@ -649,9 +652,9 @@ async def test_ent_desc_switch_exception_handling( "Dishwasher", ), ], - indirect=["appliance_ha_id"], + indirect=["appliance"], ) -async def test_power_swtich( +async def test_power_switch( entity_id: str, allowed_values: list[str | None] | None, service: str, @@ -661,7 +664,7 @@ async def test_power_swtich( config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - appliance_ha_id: str, + appliance: HomeAppliance, client: MagicMock, ) -> None: """Test power switch functionality.""" @@ -686,7 +689,7 @@ async def test_power_swtich( await hass.services.async_call(SWITCH_DOMAIN, service, {ATTR_ENTITY_ID: entity_id}) await hass.async_block_till_done() client.set_setting.assert_awaited_once_with( - appliance_ha_id, + appliance.ha_id, setting_key=SettingKey.BSH_COMMON_POWER_STATE, value=setting_value_arg, ) @@ -800,7 +803,7 @@ async def test_power_switch_service_validation_errors( @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_create_issue( hass: HomeAssistant, - appliance_ha_id: str, + appliance: HomeAppliance, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, @@ -882,7 +885,7 @@ async def test_create_issue( ], ) @pytest.mark.parametrize( - ("entity_id", "option_key", "appliance_ha_id"), + ("entity_id", "option_key", "appliance"), [ ( "switch.dishwasher_half_load", @@ -890,12 +893,12 @@ async def test_create_issue( "Dishwasher", ) ], - indirect=["appliance_ha_id"], + indirect=["appliance"], ) async def test_options_functionality( entity_id: str, option_key: OptionKey, - appliance_ha_id: str, + appliance: HomeAppliance, set_active_program_options_side_effect: ActiveProgramNotSetError | None, set_selected_program_options_side_effect: SelectedProgramNotSetError | None, called_mock_method: str, @@ -933,7 +936,7 @@ async def test_options_functionality( await hass.async_block_till_done() assert called_mock.called - assert called_mock.call_args.args == (appliance_ha_id,) + assert called_mock.call_args.args == (appliance.ha_id,) assert called_mock.call_args.kwargs == { "option_key": option_key, "value": False, @@ -946,7 +949,7 @@ async def test_options_functionality( await hass.async_block_till_done() assert called_mock.called - assert called_mock.call_args.args == (appliance_ha_id,) + assert called_mock.call_args.args == (appliance.ha_id,) assert called_mock.call_args.kwargs == { "option_key": option_key, "value": True, diff --git a/tests/components/home_connect/test_time.py b/tests/components/home_connect/test_time.py index affb5ecfedf..6be23460cac 100644 --- a/tests/components/home_connect/test_time.py +++ b/tests/components/home_connect/test_time.py @@ -10,6 +10,7 @@ from aiohomeconnect.model import ( EventMessage, EventType, GetSetting, + HomeAppliance, SettingKey, ) from aiohomeconnect.model.error import HomeConnectApiError, HomeConnectError @@ -44,9 +45,9 @@ async def test_time( assert config_entry.state is ConfigEntryState.LOADED -@pytest.mark.parametrize("appliance_ha_id", ["Oven"], indirect=True) +@pytest.mark.parametrize("appliance", ["Oven"], indirect=True) async def test_paired_depaired_devices_flow( - appliance_ha_id: str, + appliance: HomeAppliance, hass: HomeAssistant, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], @@ -60,7 +61,7 @@ async def test_paired_depaired_devices_flow( assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED - device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)}) assert device entity_entries = entity_registry.entities.get_entries_for_device_id(device.id) assert entity_entries @@ -68,7 +69,7 @@ async def test_paired_depaired_devices_flow( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.DEPAIRED, data=ArrayOfEvents([]), ) @@ -76,7 +77,7 @@ async def test_paired_depaired_devices_flow( ) await hass.async_block_till_done() - device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)}) assert not device for entity_entry in entity_entries: assert not entity_registry.async_get(entity_entry.entity_id) @@ -85,7 +86,7 @@ async def test_paired_depaired_devices_flow( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.PAIRED, data=ArrayOfEvents([]), ) @@ -93,14 +94,14 @@ async def test_paired_depaired_devices_flow( ) await hass.async_block_till_done() - assert device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + assert device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)}) for entity_entry in entity_entries: assert entity_registry.async_get(entity_entry.entity_id) -@pytest.mark.parametrize("appliance_ha_id", ["Oven"], indirect=True) +@pytest.mark.parametrize("appliance", ["Oven"], indirect=True) async def test_connected_devices( - appliance_ha_id: str, + appliance: HomeAppliance, hass: HomeAssistant, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], @@ -117,7 +118,7 @@ async def test_connected_devices( get_settings_original_mock = client.get_settings async def get_settings_side_effect(ha_id: str): - if ha_id == appliance_ha_id: + if ha_id == appliance.ha_id: raise HomeConnectApiError( "SDK.Error.HomeAppliance.Connection.Initialization.Failed" ) @@ -129,14 +130,14 @@ async def test_connected_devices( assert config_entry.state == ConfigEntryState.LOADED client.get_settings = get_settings_original_mock - device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)}) assert device entity_entries = entity_registry.entities.get_entries_for_device_id(device.id) await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.CONNECTED, data=ArrayOfEvents([]), ) @@ -144,20 +145,20 @@ async def test_connected_devices( ) await hass.async_block_till_done() - device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)}) assert device new_entity_entries = entity_registry.entities.get_entries_for_device_id(device.id) assert len(new_entity_entries) > len(entity_entries) -@pytest.mark.parametrize("appliance_ha_id", ["Oven"], indirect=True) -async def test_time_entity_availabilty( +@pytest.mark.parametrize("appliance", ["Oven"], indirect=True) +async def test_time_entity_availability( hass: HomeAssistant, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, client: MagicMock, - appliance_ha_id: str, + appliance: HomeAppliance, ) -> None: """Test if time entities availability are based on the appliance connection state.""" entity_ids = [ @@ -175,7 +176,7 @@ async def test_time_entity_availabilty( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.DISCONNECTED, ArrayOfEvents([]), ) @@ -189,7 +190,7 @@ async def test_time_entity_availabilty( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.CONNECTED, ArrayOfEvents([]), ) @@ -203,7 +204,7 @@ async def test_time_entity_availabilty( assert state.state != STATE_UNAVAILABLE -@pytest.mark.parametrize("appliance_ha_id", ["Oven"], indirect=True) +@pytest.mark.parametrize("appliance", ["Oven"], indirect=True) @pytest.mark.parametrize( ("entity_id", "setting_key"), [ @@ -214,7 +215,7 @@ async def test_time_entity_availabilty( ], ) async def test_time_entity_functionality( - appliance_ha_id: str, + appliance: HomeAppliance, entity_id: str, setting_key: SettingKey, hass: HomeAssistant, @@ -242,7 +243,7 @@ async def test_time_entity_functionality( ) await hass.async_block_till_done() client.set_setting.assert_awaited_once_with( - appliance_ha_id, setting_key=setting_key, value=value + appliance.ha_id, setting_key=setting_key, value=value ) assert hass.states.is_state(entity_id, str(time(second=value))) From 489c4862786b627d56dc286d670d6ffb9f6212f9 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 23 Mar 2025 12:05:40 +0100 Subject: [PATCH 2891/3148] Rework Synology DSM to use config entry runtime_data (#141084) rework to use config entry runtime_data --- .../components/synology_dsm/__init__.py | 24 ++++++++------- .../components/synology_dsm/backup.py | 20 ++++++------- .../components/synology_dsm/binary_sensor.py | 9 ++---- .../components/synology_dsm/button.py | 7 ++--- .../components/synology_dsm/camera.py | 8 ++--- .../components/synology_dsm/config_flow.py | 8 +++-- .../components/synology_dsm/coordinator.py | 24 +++++++++++---- .../components/synology_dsm/diagnostics.py | 9 +++--- .../components/synology_dsm/media_source.py | 29 ++++++++++++++----- .../components/synology_dsm/models.py | 22 -------------- .../components/synology_dsm/repairs.py | 7 ++--- .../components/synology_dsm/sensor.py | 10 +++---- .../components/synology_dsm/service.py | 22 ++++++++++---- .../components/synology_dsm/switch.py | 8 ++--- .../components/synology_dsm/update.py | 9 ++---- 15 files changed, 110 insertions(+), 106 deletions(-) delete mode 100644 homeassistant/components/synology_dsm/models.py diff --git a/homeassistant/components/synology_dsm/__init__.py b/homeassistant/components/synology_dsm/__init__.py index 1b26b7df84d..70c7e76a53a 100644 --- a/homeassistant/components/synology_dsm/__init__.py +++ b/homeassistant/components/synology_dsm/__init__.py @@ -9,7 +9,6 @@ from synology_dsm.api.surveillance_station import SynoSurveillanceStation from synology_dsm.api.surveillance_station.camera import SynoCamera from synology_dsm.exceptions import SynologyDSMNotLoggedInException -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_MAC, CONF_SCAN_INTERVAL, CONF_VERIFY_SSL from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady @@ -31,15 +30,16 @@ from .const import ( from .coordinator import ( SynologyDSMCameraUpdateCoordinator, SynologyDSMCentralUpdateCoordinator, + SynologyDSMConfigEntry, + SynologyDSMData, SynologyDSMSwitchUpdateCoordinator, ) -from .models import SynologyDSMData from .service import async_setup_services _LOGGER = logging.getLogger(__name__) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: SynologyDSMConfigEntry) -> bool: """Set up Synology DSM sensors.""" # Migrate device identifiers @@ -120,13 +120,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except SYNOLOGY_CONNECTION_EXCEPTIONS as ex: raise ConfigEntryNotReady from ex - synology_data = SynologyDSMData( + entry.runtime_data = SynologyDSMData( api=api, coordinator_central=coordinator_central, coordinator_cameras=coordinator_cameras, coordinator_switches=coordinator_switches, ) - hass.data.setdefault(DOMAIN, {})[entry.unique_id] = synology_data await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) entry.async_on_unload(entry.add_update_listener(_async_update_listener)) @@ -143,25 +142,28 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, entry: SynologyDSMConfigEntry +) -> bool: """Unload Synology DSM sensors.""" if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - entry_data: SynologyDSMData = hass.data[DOMAIN][entry.unique_id] + entry_data = entry.runtime_data await entry_data.api.async_unload() - hass.data[DOMAIN].pop(entry.unique_id) return unload_ok -async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def _async_update_listener( + hass: HomeAssistant, entry: SynologyDSMConfigEntry +) -> None: """Handle options update.""" await hass.config_entries.async_reload(entry.entry_id) async def async_remove_config_entry_device( - hass: HomeAssistant, entry: ConfigEntry, device_entry: dr.DeviceEntry + hass: HomeAssistant, entry: SynologyDSMConfigEntry, device_entry: dr.DeviceEntry ) -> bool: """Remove synology_dsm config entry from a device.""" - data: SynologyDSMData = hass.data[DOMAIN][entry.unique_id] + data = entry.runtime_data api = data.api assert api.information is not None serial = api.information.serial diff --git a/homeassistant/components/synology_dsm/backup.py b/homeassistant/components/synology_dsm/backup.py index c4b44542059..11f4287dea2 100644 --- a/homeassistant/components/synology_dsm/backup.py +++ b/homeassistant/components/synology_dsm/backup.py @@ -17,7 +17,6 @@ from homeassistant.components.backup import ( BackupNotFound, suggested_filename, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.aiohttp_client import ChunkAsyncStreamIterator from homeassistant.helpers.json import json_dumps @@ -29,7 +28,7 @@ from .const import ( DATA_BACKUP_AGENT_LISTENERS, DOMAIN, ) -from .models import SynologyDSMData +from .coordinator import SynologyDSMConfigEntry LOGGER = logging.getLogger(__name__) @@ -47,18 +46,17 @@ async def async_get_backup_agents( hass: HomeAssistant, ) -> list[BackupAgent]: """Return a list of backup agents.""" - if not ( - entries := hass.config_entries.async_loaded_entries(DOMAIN) - ) or not hass.data.get(DOMAIN): + entries: list[SynologyDSMConfigEntry] = hass.config_entries.async_loaded_entries( + DOMAIN + ) + if not entries: LOGGER.debug("No proper config entry found") return [] - syno_datas: dict[str, SynologyDSMData] = hass.data[DOMAIN] return [ SynologyDSMBackupAgent(hass, entry, entry.unique_id) for entry in entries if entry.unique_id is not None - and (syno_data := syno_datas.get(entry.unique_id)) - and syno_data.api.file_station + and entry.runtime_data.api.file_station and entry.options.get(CONF_BACKUP_PATH) ] @@ -91,7 +89,9 @@ class SynologyDSMBackupAgent(BackupAgent): domain = DOMAIN - def __init__(self, hass: HomeAssistant, entry: ConfigEntry, unique_id: str) -> None: + def __init__( + self, hass: HomeAssistant, entry: SynologyDSMConfigEntry, unique_id: str + ) -> None: """Initialize the Synology DSM backup agent.""" super().__init__() LOGGER.debug("Initializing Synology DSM backup agent for %s", entry.unique_id) @@ -100,7 +100,7 @@ class SynologyDSMBackupAgent(BackupAgent): self.path = ( f"{entry.options[CONF_BACKUP_SHARE]}/{entry.options[CONF_BACKUP_PATH]}" ) - syno_data: SynologyDSMData = hass.data[DOMAIN][entry.unique_id] + syno_data = entry.runtime_data self.api = syno_data.api self.backup_base_names: dict[str, str] = {} diff --git a/homeassistant/components/synology_dsm/binary_sensor.py b/homeassistant/components/synology_dsm/binary_sensor.py index 2f7d041cb10..1ae5fa90760 100644 --- a/homeassistant/components/synology_dsm/binary_sensor.py +++ b/homeassistant/components/synology_dsm/binary_sensor.py @@ -12,20 +12,17 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DISKS, EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import SynoApi -from .const import DOMAIN -from .coordinator import SynologyDSMCentralUpdateCoordinator +from .coordinator import SynologyDSMCentralUpdateCoordinator, SynologyDSMConfigEntry from .entity import ( SynologyDSMBaseEntity, SynologyDSMDeviceEntity, SynologyDSMEntityDescription, ) -from .models import SynologyDSMData @dataclass(frozen=True, kw_only=True) @@ -64,11 +61,11 @@ STORAGE_DISK_BINARY_SENSORS: tuple[SynologyDSMBinarySensorEntityDescription, ... async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: SynologyDSMConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Synology NAS binary sensor.""" - data: SynologyDSMData = hass.data[DOMAIN][entry.unique_id] + data = entry.runtime_data api = data.api coordinator = data.coordinator_central assert api.storage is not None diff --git a/homeassistant/components/synology_dsm/button.py b/homeassistant/components/synology_dsm/button.py index 6512c370334..79297b1f1b4 100644 --- a/homeassistant/components/synology_dsm/button.py +++ b/homeassistant/components/synology_dsm/button.py @@ -12,7 +12,6 @@ from homeassistant.components.button import ( ButtonEntity, ButtonEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo @@ -20,7 +19,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import SynoApi from .const import DOMAIN -from .models import SynologyDSMData +from .coordinator import SynologyDSMConfigEntry LOGGER = logging.getLogger(__name__) @@ -52,11 +51,11 @@ BUTTONS: Final = [ async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: SynologyDSMConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set buttons for device.""" - data: SynologyDSMData = hass.data[DOMAIN][entry.unique_id] + data = entry.runtime_data async_add_entities(SynologyDSMButton(data.api, button) for button in BUTTONS) diff --git a/homeassistant/components/synology_dsm/camera.py b/homeassistant/components/synology_dsm/camera.py index acbcccb8894..f393b8efb55 100644 --- a/homeassistant/components/synology_dsm/camera.py +++ b/homeassistant/components/synology_dsm/camera.py @@ -16,7 +16,6 @@ from homeassistant.components.camera import ( CameraEntityDescription, CameraEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -29,9 +28,8 @@ from .const import ( DOMAIN, SIGNAL_CAMERA_SOURCE_CHANGED, ) -from .coordinator import SynologyDSMCameraUpdateCoordinator +from .coordinator import SynologyDSMCameraUpdateCoordinator, SynologyDSMConfigEntry from .entity import SynologyDSMBaseEntity, SynologyDSMEntityDescription -from .models import SynologyDSMData _LOGGER = logging.getLogger(__name__) @@ -47,11 +45,11 @@ class SynologyDSMCameraEntityDescription( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: SynologyDSMConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Synology NAS cameras.""" - data: SynologyDSMData = hass.data[DOMAIN][entry.unique_id] + data = entry.runtime_data if coordinator := data.coordinator_cameras: async_add_entities( SynoDSMCamera(data.api, coordinator, camera_id) diff --git a/homeassistant/components/synology_dsm/config_flow.py b/homeassistant/components/synology_dsm/config_flow.py index 58784862305..f0da6f8fe47 100644 --- a/homeassistant/components/synology_dsm/config_flow.py +++ b/homeassistant/components/synology_dsm/config_flow.py @@ -72,7 +72,7 @@ from .const import ( DOMAIN, SYNOLOGY_CONNECTION_EXCEPTIONS, ) -from .models import SynologyDSMData +from .coordinator import SynologyDSMConfigEntry _LOGGER = logging.getLogger(__name__) @@ -131,7 +131,7 @@ class SynologyDSMFlowHandler(ConfigFlow, domain=DOMAIN): @staticmethod @callback def async_get_options_flow( - config_entry: ConfigEntry, + config_entry: SynologyDSMConfigEntry, ) -> SynologyDSMOptionsFlowHandler: """Get the options flow for this handler.""" return SynologyDSMOptionsFlowHandler() @@ -444,6 +444,8 @@ class SynologyDSMFlowHandler(ConfigFlow, domain=DOMAIN): class SynologyDSMOptionsFlowHandler(OptionsFlow): """Handle a option flow.""" + config_entry: SynologyDSMConfigEntry + async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -451,7 +453,7 @@ class SynologyDSMOptionsFlowHandler(OptionsFlow): if user_input is not None: return self.async_create_entry(title="", data=user_input) - syno_data: SynologyDSMData = self.hass.data[DOMAIN][self.config_entry.unique_id] + syno_data = self.config_entry.runtime_data data_schema = vol.Schema( { diff --git a/homeassistant/components/synology_dsm/coordinator.py b/homeassistant/components/synology_dsm/coordinator.py index 1b3e21090b8..a35432f0774 100644 --- a/homeassistant/components/synology_dsm/coordinator.py +++ b/homeassistant/components/synology_dsm/coordinator.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Awaitable, Callable, Coroutine +from dataclasses import dataclass from datetime import timedelta import logging from typing import Any, Concatenate @@ -28,6 +29,19 @@ from .const import ( _LOGGER = logging.getLogger(__name__) +@dataclass +class SynologyDSMData: + """Data for the synology_dsm integration.""" + + api: SynoApi + coordinator_central: SynologyDSMCentralUpdateCoordinator + coordinator_cameras: SynologyDSMCameraUpdateCoordinator | None + coordinator_switches: SynologyDSMSwitchUpdateCoordinator | None + + +type SynologyDSMConfigEntry = ConfigEntry[SynologyDSMData] + + def async_re_login_on_expired[_T: SynologyDSMUpdateCoordinator[Any], **_P, _R]( func: Callable[Concatenate[_T, _P], Awaitable[_R]], ) -> Callable[Concatenate[_T, _P], Coroutine[Any, Any, _R]]: @@ -57,12 +71,12 @@ def async_re_login_on_expired[_T: SynologyDSMUpdateCoordinator[Any], **_P, _R]( class SynologyDSMUpdateCoordinator[_DataT](DataUpdateCoordinator[_DataT]): """DataUpdateCoordinator base class for synology_dsm.""" - config_entry: ConfigEntry + config_entry: SynologyDSMConfigEntry def __init__( self, hass: HomeAssistant, - entry: ConfigEntry, + entry: SynologyDSMConfigEntry, api: SynoApi, update_interval: timedelta, ) -> None: @@ -85,7 +99,7 @@ class SynologyDSMSwitchUpdateCoordinator( def __init__( self, hass: HomeAssistant, - entry: ConfigEntry, + entry: SynologyDSMConfigEntry, api: SynoApi, ) -> None: """Initialize DataUpdateCoordinator for switch devices.""" @@ -116,7 +130,7 @@ class SynologyDSMCentralUpdateCoordinator(SynologyDSMUpdateCoordinator[None]): def __init__( self, hass: HomeAssistant, - entry: ConfigEntry, + entry: SynologyDSMConfigEntry, api: SynoApi, ) -> None: """Initialize DataUpdateCoordinator for central device.""" @@ -136,7 +150,7 @@ class SynologyDSMCameraUpdateCoordinator( def __init__( self, hass: HomeAssistant, - entry: ConfigEntry, + entry: SynologyDSMConfigEntry, api: SynoApi, ) -> None: """Initialize DataUpdateCoordinator for cameras.""" diff --git a/homeassistant/components/synology_dsm/diagnostics.py b/homeassistant/components/synology_dsm/diagnostics.py index b30955ae682..a673be23096 100644 --- a/homeassistant/components/synology_dsm/diagnostics.py +++ b/homeassistant/components/synology_dsm/diagnostics.py @@ -6,21 +6,20 @@ from typing import Any from homeassistant.components.camera import diagnostics as camera_diagnostics from homeassistant.components.diagnostics import async_redact_data -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant -from .const import CONF_DEVICE_TOKEN, DOMAIN -from .models import SynologyDSMData +from .const import CONF_DEVICE_TOKEN +from .coordinator import SynologyDSMConfigEntry TO_REDACT = {CONF_USERNAME, CONF_PASSWORD, CONF_DEVICE_TOKEN} async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: SynologyDSMConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - data: SynologyDSMData = hass.data[DOMAIN][entry.unique_id] + data = entry.runtime_data syno_api = data.api dsm_info = syno_api.dsm.information diff --git a/homeassistant/components/synology_dsm/media_source.py b/homeassistant/components/synology_dsm/media_source.py index d35b262809c..6234f5e8dd0 100644 --- a/homeassistant/components/synology_dsm/media_source.py +++ b/homeassistant/components/synology_dsm/media_source.py @@ -2,6 +2,7 @@ from __future__ import annotations +from logging import getLogger import mimetypes from aiohttp import web @@ -22,7 +23,9 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from .const import DOMAIN, SHARED_SUFFIX -from .models import SynologyDSMData +from .coordinator import SynologyDSMConfigEntry, SynologyDSMData + +LOGGER = getLogger(__name__) async def async_get_media_source(hass: HomeAssistant) -> MediaSource: @@ -41,15 +44,13 @@ class SynologyPhotosMediaSourceIdentifier: """Split identifier into parts.""" parts = identifier.split("/") - self.unique_id = None + self.unique_id = parts[0] self.album_id = None self.cache_key = None self.file_name = None self.is_shared = False self.passphrase = "" - self.unique_id = parts[0] - if len(parts) > 1: album_parts = parts[1].split("_") self.album_id = album_parts[0] @@ -82,7 +83,7 @@ class SynologyPhotosMediaSource(MediaSource): item: MediaSourceItem, ) -> BrowseMediaSource: """Return media.""" - if not self.hass.data.get(DOMAIN): + if not self.hass.config_entries.async_loaded_entries(DOMAIN): raise BrowseError("Diskstation not initialized") return BrowseMediaSource( domain=DOMAIN, @@ -116,7 +117,13 @@ class SynologyPhotosMediaSource(MediaSource): for entry in self.entries ] identifier = SynologyPhotosMediaSourceIdentifier(item.identifier) - diskstation: SynologyDSMData = self.hass.data[DOMAIN][identifier.unique_id] + entry: SynologyDSMConfigEntry | None = ( + self.hass.config_entries.async_entry_for_domain_unique_id( + DOMAIN, identifier.unique_id + ) + ) + assert entry + diskstation = entry.runtime_data assert diskstation.api.photos is not None if identifier.album_id is None: @@ -244,7 +251,7 @@ class SynologyDsmMediaView(http.HomeAssistantView): self, request: web.Request, source_dir_id: str, location: str ) -> web.Response: """Start a GET request.""" - if not self.hass.data.get(DOMAIN): + if not self.hass.config_entries.async_loaded_entries(DOMAIN): raise web.HTTPNotFound # location: {cache_key}/{filename} cache_key, file_name, passphrase = location.split("/") @@ -257,7 +264,13 @@ class SynologyDsmMediaView(http.HomeAssistantView): if not isinstance(mime_type, str): raise web.HTTPNotFound - diskstation: SynologyDSMData = self.hass.data[DOMAIN][source_dir_id] + entry: SynologyDSMConfigEntry | None = ( + self.hass.config_entries.async_entry_for_domain_unique_id( + DOMAIN, source_dir_id + ) + ) + assert entry + diskstation = entry.runtime_data assert diskstation.api.photos is not None item = SynoPhotosItem(image_id, "", "", "", cache_key, "xl", shared, passphrase) try: diff --git a/homeassistant/components/synology_dsm/models.py b/homeassistant/components/synology_dsm/models.py deleted file mode 100644 index 4f51d329ded..00000000000 --- a/homeassistant/components/synology_dsm/models.py +++ /dev/null @@ -1,22 +0,0 @@ -"""The synology_dsm integration models.""" - -from __future__ import annotations - -from dataclasses import dataclass - -from .common import SynoApi -from .coordinator import ( - SynologyDSMCameraUpdateCoordinator, - SynologyDSMCentralUpdateCoordinator, - SynologyDSMSwitchUpdateCoordinator, -) - - -@dataclass -class SynologyDSMData: - """Data for the synology_dsm integration.""" - - api: SynoApi - coordinator_central: SynologyDSMCentralUpdateCoordinator - coordinator_cameras: SynologyDSMCameraUpdateCoordinator | None - coordinator_switches: SynologyDSMSwitchUpdateCoordinator | None diff --git a/homeassistant/components/synology_dsm/repairs.py b/homeassistant/components/synology_dsm/repairs.py index 725e77a2593..8a4e47a32b5 100644 --- a/homeassistant/components/synology_dsm/repairs.py +++ b/homeassistant/components/synology_dsm/repairs.py @@ -11,7 +11,6 @@ import voluptuous as vol from homeassistant import data_entry_flow from homeassistant.components.repairs import ConfirmRepairFlow, RepairsFlow -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.selector import ( @@ -28,7 +27,7 @@ from .const import ( ISSUE_MISSING_BACKUP_SETUP, SYNOLOGY_CONNECTION_EXCEPTIONS, ) -from .models import SynologyDSMData +from .coordinator import SynologyDSMConfigEntry LOGGER = logging.getLogger(__name__) @@ -36,7 +35,7 @@ LOGGER = logging.getLogger(__name__) class MissingBackupSetupRepairFlow(RepairsFlow): """Handler for an issue fixing flow.""" - def __init__(self, entry: ConfigEntry, issue_id: str) -> None: + def __init__(self, entry: SynologyDSMConfigEntry, issue_id: str) -> None: """Create flow.""" self.entry = entry self.issue_id = issue_id @@ -59,7 +58,7 @@ class MissingBackupSetupRepairFlow(RepairsFlow): ) -> data_entry_flow.FlowResult: """Handle the confirm step of a fix flow.""" - syno_data: SynologyDSMData = self.hass.data[DOMAIN][self.entry.unique_id] + syno_data = self.entry.runtime_data if user_input is not None: self.hass.config_entries.async_update_entry( diff --git a/homeassistant/components/synology_dsm/sensor.py b/homeassistant/components/synology_dsm/sensor.py index 2987de7a7c7..566885e3989 100644 --- a/homeassistant/components/synology_dsm/sensor.py +++ b/homeassistant/components/synology_dsm/sensor.py @@ -16,7 +16,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_DISKS, PERCENTAGE, @@ -31,14 +30,13 @@ from homeassistant.helpers.typing import StateType from homeassistant.util.dt import utcnow from . import SynoApi -from .const import CONF_VOLUMES, DOMAIN, ENTITY_UNIT_LOAD -from .coordinator import SynologyDSMCentralUpdateCoordinator +from .const import CONF_VOLUMES, ENTITY_UNIT_LOAD +from .coordinator import SynologyDSMCentralUpdateCoordinator, SynologyDSMConfigEntry from .entity import ( SynologyDSMBaseEntity, SynologyDSMDeviceEntity, SynologyDSMEntityDescription, ) -from .models import SynologyDSMData @dataclass(frozen=True, kw_only=True) @@ -287,11 +285,11 @@ INFORMATION_SENSORS: tuple[SynologyDSMSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: SynologyDSMConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Synology NAS Sensor.""" - data: SynologyDSMData = hass.data[DOMAIN][entry.unique_id] + data = entry.runtime_data api = data.api coordinator = data.coordinator_central storage = api.storage diff --git a/homeassistant/components/synology_dsm/service.py b/homeassistant/components/synology_dsm/service.py index 366f7d4ba3a..40b6fd4bc30 100644 --- a/homeassistant/components/synology_dsm/service.py +++ b/homeassistant/components/synology_dsm/service.py @@ -3,13 +3,14 @@ from __future__ import annotations import logging +from typing import cast from synology_dsm.exceptions import SynologyDSMException from homeassistant.core import HomeAssistant, ServiceCall from .const import CONF_SERIAL, DOMAIN, SERVICE_REBOOT, SERVICE_SHUTDOWN, SERVICES -from .models import SynologyDSMData +from .coordinator import SynologyDSMConfigEntry LOGGER = logging.getLogger(__name__) @@ -19,11 +20,20 @@ async def async_setup_services(hass: HomeAssistant) -> None: async def service_handler(call: ServiceCall) -> None: """Handle service call.""" - serial = call.data.get(CONF_SERIAL) - dsm_devices = hass.data[DOMAIN] + serial: str | None = call.data.get(CONF_SERIAL) + entries: list[SynologyDSMConfigEntry] = ( + hass.config_entries.async_loaded_entries(DOMAIN) + ) + dsm_devices = { + cast(str, entry.unique_id): entry.runtime_data for entry in entries + } if serial: - dsm_device: SynologyDSMData = hass.data[DOMAIN][serial] + entry: SynologyDSMConfigEntry | None = ( + hass.config_entries.async_entry_for_domain_unique_id(DOMAIN, serial) + ) + assert entry + dsm_device = entry.runtime_data elif len(dsm_devices) == 1: dsm_device = next(iter(dsm_devices.values())) serial = next(iter(dsm_devices)) @@ -39,7 +49,7 @@ async def async_setup_services(hass: HomeAssistant) -> None: return if call.service in [SERVICE_REBOOT, SERVICE_SHUTDOWN]: - if serial not in hass.data[DOMAIN]: + if serial not in dsm_devices: LOGGER.error("DSM with specified serial %s not found", serial) return LOGGER.debug("%s DSM with serial %s", call.service, serial) @@ -50,7 +60,7 @@ async def async_setup_services(hass: HomeAssistant) -> None: ), call.service, ) - dsm_device = hass.data[DOMAIN][serial] + dsm_device = dsm_devices[serial] dsm_api = dsm_device.api try: await getattr(dsm_api, f"async_{call.service}")() diff --git a/homeassistant/components/synology_dsm/switch.py b/homeassistant/components/synology_dsm/switch.py index c4f1572ceea..91863ff3a26 100644 --- a/homeassistant/components/synology_dsm/switch.py +++ b/homeassistant/components/synology_dsm/switch.py @@ -9,16 +9,14 @@ from typing import Any from synology_dsm.api.surveillance_station import SynoSurveillanceStation from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import SynoApi from .const import DOMAIN -from .coordinator import SynologyDSMSwitchUpdateCoordinator +from .coordinator import SynologyDSMConfigEntry, SynologyDSMSwitchUpdateCoordinator from .entity import SynologyDSMBaseEntity, SynologyDSMEntityDescription -from .models import SynologyDSMData _LOGGER = logging.getLogger(__name__) @@ -41,11 +39,11 @@ SURVEILLANCE_SWITCH: tuple[SynologyDSMSwitchEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: SynologyDSMConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Synology NAS switch.""" - data: SynologyDSMData = hass.data[DOMAIN][entry.unique_id] + data = entry.runtime_data if coordinator := data.coordinator_switches: assert coordinator.version is not None async_add_entities( diff --git a/homeassistant/components/synology_dsm/update.py b/homeassistant/components/synology_dsm/update.py index 71eed2d7f1f..3048a38cb9c 100644 --- a/homeassistant/components/synology_dsm/update.py +++ b/homeassistant/components/synology_dsm/update.py @@ -9,15 +9,12 @@ from synology_dsm.api.core.upgrade import SynoCoreUpgrade from yarl import URL from homeassistant.components.update import UpdateEntity, UpdateEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN -from .coordinator import SynologyDSMCentralUpdateCoordinator +from .coordinator import SynologyDSMCentralUpdateCoordinator, SynologyDSMConfigEntry from .entity import SynologyDSMBaseEntity, SynologyDSMEntityDescription -from .models import SynologyDSMData @dataclass(frozen=True, kw_only=True) @@ -39,11 +36,11 @@ UPDATE_ENTITIES: Final = [ async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: SynologyDSMConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Synology DSM update entities.""" - data: SynologyDSMData = hass.data[DOMAIN][entry.unique_id] + data = entry.runtime_data async_add_entities( SynoDSMUpdateEntity(data.api, data.coordinator_central, description) for description in UPDATE_ENTITIES From 5c642ef62626eb4afab8034b43fd8fc2072e2e8d Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 23 Mar 2025 12:21:07 +0100 Subject: [PATCH 2892/3148] Fix spelling of user-facing strings in `adax` integration (#141190) - capitalize "Bluetooth" and "LED" - sentence-case "Wi-Fi password" --- homeassistant/components/adax/strings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/adax/strings.json b/homeassistant/components/adax/strings.json index 6157b7dfc91..9ba497a9aca 100644 --- a/homeassistant/components/adax/strings.json +++ b/homeassistant/components/adax/strings.json @@ -5,14 +5,14 @@ "data": { "connection_type": "Select connection type" }, - "description": "Select connection type. Local requires heaters with bluetooth" + "description": "Select connection type. Local requires heaters with Bluetooth" }, "local": { "data": { "wifi_ssid": "Wi-Fi SSID", - "wifi_pswd": "Wi-Fi Password" + "wifi_pswd": "Wi-Fi password" }, - "description": "Reset the heater by pressing + and OK until display shows 'Reset'. Then press and hold OK button on the heater until the blue led starts blinking before pressing Submit. Configuring heater might take some minutes." + "description": "Reset the heater by pressing + and OK until display shows 'Reset'. Then press and hold OK button on the heater until the blue LED starts blinking before pressing Submit. Configuring heater might take some minutes." }, "cloud": { "data": { From 77f8ddd948ee048761f26ed834cb348a590da61d Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Sun, 23 Mar 2025 12:32:38 +0100 Subject: [PATCH 2893/3148] Fix climate and humidifier platform for Comelit (#140611) fix climate and humidifier platform for Comelit --- homeassistant/components/comelit/climate.py | 16 +++++++++++++--- homeassistant/components/comelit/humidifier.py | 16 +++++++++++++--- 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/comelit/climate.py b/homeassistant/components/comelit/climate.py index 8064d478c32..3ec79001d55 100644 --- a/homeassistant/components/comelit/climate.py +++ b/homeassistant/components/comelit/climate.py @@ -119,10 +119,10 @@ class ComelitClimateEntity(CoordinatorEntity[ComelitSerialBridge], ClimateEntity # because no serial number or mac is available self._attr_unique_id = f"{config_entry_entry_id}-{device.index}" self._attr_device_info = coordinator.platform_device_info(device, device.type) + self._update_attributes() - @callback - def _handle_coordinator_update(self) -> None: - """Handle updated data from the coordinator.""" + def _update_attributes(self) -> None: + """Update class attributes.""" device = self.coordinator.data[CLIMATE][self._device.index] if not isinstance(device.val, list): raise HomeAssistantError( @@ -158,6 +158,12 @@ class ComelitClimateEntity(CoordinatorEntity[ComelitSerialBridge], ClimateEntity self._attr_target_temperature = values[4] / 10 + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._update_attributes() + super()._handle_coordinator_update() + async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" if ( @@ -171,6 +177,8 @@ class ComelitClimateEntity(CoordinatorEntity[ComelitSerialBridge], ClimateEntity await self.coordinator.api.set_clima_status( self._device.index, ClimaComelitCommand.SET, target_temp ) + self._attr_target_temperature = target_temp + self.async_write_ha_state() async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set hvac mode.""" @@ -182,3 +190,5 @@ class ComelitClimateEntity(CoordinatorEntity[ComelitSerialBridge], ClimateEntity await self.coordinator.api.set_clima_status( self._device.index, MODE_TO_ACTION[hvac_mode] ) + self._attr_hvac_mode = hvac_mode + self.async_write_ha_state() diff --git a/homeassistant/components/comelit/humidifier.py b/homeassistant/components/comelit/humidifier.py index c5edfb1c2de..ad8f49ed5e2 100644 --- a/homeassistant/components/comelit/humidifier.py +++ b/homeassistant/components/comelit/humidifier.py @@ -124,10 +124,10 @@ class ComelitHumidifierEntity(CoordinatorEntity[ComelitSerialBridge], Humidifier self._active_mode = active_mode self._active_action = active_action self._set_command = set_command + self._update_attributes() - @callback - def _handle_coordinator_update(self) -> None: - """Handle updated data from the coordinator.""" + def _update_attributes(self) -> None: + """Update class attributes.""" device = self.coordinator.data[CLIMATE][self._device.index] if not isinstance(device.val, list): raise HomeAssistantError( @@ -154,6 +154,12 @@ class ComelitHumidifierEntity(CoordinatorEntity[ComelitSerialBridge], Humidifier self._attr_mode = MODE_AUTO if _automatic else MODE_NORMAL self._attr_target_humidity = values[4] / 10 + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._update_attributes() + super()._handle_coordinator_update() + async def async_set_humidity(self, humidity: int) -> None: """Set new target humidity.""" if self.mode == HumidifierComelitMode.OFF: @@ -168,12 +174,16 @@ class ComelitHumidifierEntity(CoordinatorEntity[ComelitSerialBridge], Humidifier await self.coordinator.api.set_humidity_status( self._device.index, HumidifierComelitCommand.SET, humidity ) + self._attr_target_humidity = humidity + self.async_write_ha_state() async def async_set_mode(self, mode: str) -> None: """Set humidifier mode.""" await self.coordinator.api.set_humidity_status( self._device.index, MODE_TO_ACTION[mode] ) + self._attr_mode = mode + self.async_write_ha_state() async def async_turn_on(self, **kwargs: Any) -> None: """Turn on.""" From ca10618dc7b0fa9fb6b3e639a7e73bcd1716e3cb Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Sun, 23 Mar 2025 12:50:02 +0100 Subject: [PATCH 2894/3148] Update strings for Comelit (#140925) * Update strings for Comelit * apply review comment * apply review comment * Update homeassistant/components/comelit/strings.json Co-authored-by: Michael <35783820+mib1185@users.noreply.github.com> --------- Co-authored-by: Michael <35783820+mib1185@users.noreply.github.com> --- homeassistant/components/comelit/strings.json | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/comelit/strings.json b/homeassistant/components/comelit/strings.json index 5ff4fa54688..496d62655a9 100644 --- a/homeassistant/components/comelit/strings.json +++ b/homeassistant/components/comelit/strings.json @@ -3,19 +3,25 @@ "flow_title": "{host}", "step": { "reauth_confirm": { - "description": "Please enter the correct PIN for {host}", "data": { "pin": "[%key:common::config_flow::data::pin%]" + }, + "data_description": { + "pin": "The PIN of your Comelit device." } }, "user": { "data": { "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]", - "pin": "[%key:common::config_flow::data::pin%]" + "pin": "[%key:common::config_flow::data::pin%]", + "type": "Device type" }, "data_description": { - "host": "The hostname or IP address of your Comelit device." + "host": "The hostname or IP address of your Comelit device.", + "port": "The port of your Comelit device.", + "pin": "[%key:component::comelit::config::step::reauth_confirm::data_description::pin%]", + "type": "The type of your Comelit device." } } }, From 798ee60ae505c293e18668b110852836c0fa3e63 Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Sun, 23 Mar 2025 14:07:52 +0100 Subject: [PATCH 2895/3148] Make variables action not restricted to local scopes (#141114) Make variables action in scripts not restricted to local scopes --- homeassistant/helpers/script.py | 11 +++---- tests/helpers/test_script.py | 56 ++++++++++++++++++++++++++------- 2 files changed, 49 insertions(+), 18 deletions(-) diff --git a/homeassistant/helpers/script.py b/homeassistant/helpers/script.py index bf7a4a0971c..1242ef3e4d5 100644 --- a/homeassistant/helpers/script.py +++ b/homeassistant/helpers/script.py @@ -966,12 +966,11 @@ class _ScriptRun: ## Variable actions ## async def _async_step_variables(self) -> None: - """Define a local variable.""" - self._step_log("defining local variables") - for key, value in ( - self._action[CONF_VARIABLES].async_simple_render(self._variables).items() - ): - self._variables.define_local(key, value) + """Assign values to variables.""" + self._step_log("assigning variables") + self._variables.update( + self._action[CONF_VARIABLES].async_simple_render(self._variables) + ) ## External actions ## diff --git a/tests/helpers/test_script.py b/tests/helpers/test_script.py index df589a41daa..f8552fcefed 100644 --- a/tests/helpers/test_script.py +++ b/tests/helpers/test_script.py @@ -494,7 +494,7 @@ async def test_calling_service_response_data_in_scopes(hass: HomeAssistant) -> N assert result.variables["my_response"] == expected_var expected_trace = { - "0": [{"variables": {"my_response": expected_var}}], + "0": [{"variables": {"my_response": expected_var, "state": "off"}}], "0/parallel/0/sequence/0": [{"variables": {"state": "off"}}], "0/parallel/0/sequence/1": [ { @@ -1797,7 +1797,7 @@ async def test_wait_in_sequence(hass: HomeAssistant) -> None: assert result.variables["wait"] == expected_var expected_trace = { - "0": [{"variables": {"wait": expected_var}}], + "0": [{"variables": {"wait": expected_var, "state": "off"}}], "0/sequence/0": [{"variables": {"state": "off"}}], "0/sequence/1": [ { @@ -1840,7 +1840,7 @@ async def test_wait_in_parallel(hass: HomeAssistant) -> None: assert "wait" not in result.variables expected_trace = { - "0": [{}], + "0": [{"variables": {"state": "off"}}], "0/parallel/0/sequence/0": [{"variables": {"state": "off"}}], "0/parallel/0/sequence/1": [ { @@ -5277,11 +5277,23 @@ async def test_set_variable( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: """Test setting variables in scripts.""" - alias = "variables step" sequence = cv.SCRIPT_SCHEMA( [ - {"alias": alias, "variables": {"variable": "value"}}, - {"action": "test.script", "data": {"value": "{{ variable }}"}}, + {"alias": "variables", "variables": {"x": 1, "y": 1}}, + { + "alias": "scope", + "sequence": [ + {"variables": {"y": 3, "z": 3}}, + { + "action": "test.script", + "data": {"value": "x={{ x }}, y={{ y }}, z={{ z }}"}, + }, + ], + }, + { + "action": "test.script", + "data": {"value": "x={{ x }}, y={{ y }}, z={{ z }}"}, + }, ] ) script_obj = script.Script(hass, sequence, "test script", "test_domain") @@ -5291,18 +5303,36 @@ async def test_set_variable( await script_obj.async_run(context=Context()) await hass.async_block_till_done() - assert mock_calls[0].data["value"] == "value" - assert f"Executing step {alias}" in caplog.text + assert len(mock_calls) == 2 + assert mock_calls[0].data["value"] == "x=1, y=3, z=3" + assert mock_calls[1].data["value"] == "x=1, y=3, z=3" + + assert "Executing step variables" in caplog.text expected_trace = { - "0": [{"variables": {"variable": "value"}}], - "1": [ + "0": [{"variables": {"x": 1, "y": 1}}], + "1": [{"variables": {"y": 3, "z": 3}}], + "1/sequence/0": [{"variables": {"y": 3, "z": 3}}], + "1/sequence/1": [ { "result": { "params": { "domain": "test", "service": "script", - "service_data": {"value": "value"}, + "service_data": {"value": "x=1, y=3, z=3"}, + "target": {}, + }, + "running_script": False, + }, + } + ], + "2": [ + { + "result": { + "params": { + "domain": "test", + "service": "script", + "service_data": {"value": "x=1, y=3, z=3"}, "target": {}, }, "running_script": False, @@ -5899,7 +5929,9 @@ async def test_stop_action_nested_response_variables( "variables": {"var": var, "output": {"value": "Testing 123"}}, } ], - "1": [{"result": {"choice": choice}}], + "1": [ + {"result": {"choice": choice}, "variables": {"output": {"value": response}}} + ], "1/if": [{"result": {"result": if_result}}], "1/if/condition/0": [{"result": {"result": var == 1, "entities": []}}], f"1/{choice}/0": [{"variables": {"output": {"value": response}}}], From 34504f45a54b90aa4a875e6e368877dc2ce2b42b Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Sun, 23 Mar 2025 14:15:09 +0100 Subject: [PATCH 2896/3148] Patch Z-Wave platforms in climate tests (#141204) --- tests/components/zwave_js/test_climate.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/components/zwave_js/test_climate.py b/tests/components/zwave_js/test_climate.py index 5d711528a28..f312284d897 100644 --- a/tests/components/zwave_js/test_climate.py +++ b/tests/components/zwave_js/test_climate.py @@ -42,6 +42,7 @@ from homeassistant.const import ( ATTR_TEMPERATURE, SERVICE_TURN_OFF, SERVICE_TURN_ON, + Platform, ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError @@ -56,6 +57,12 @@ from .common import ( ) +@pytest.fixture +def platforms() -> list[str]: + """Fixture to specify platforms to test.""" + return [Platform.CLIMATE] + + async def test_thermostat_v2( hass: HomeAssistant, client, From ef2485be3bdee82ebc4bf0d7934453cfaaf8027a Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 23 Mar 2025 14:15:41 +0100 Subject: [PATCH 2897/3148] Fix sentence-casing in part of `airq` sensor names (#141203) --- homeassistant/components/airq/strings.json | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/airq/strings.json b/homeassistant/components/airq/strings.json index 26b944467e6..9c16975a3ab 100644 --- a/homeassistant/components/airq/strings.json +++ b/homeassistant/components/airq/strings.json @@ -91,7 +91,7 @@ "name": "Hydrogen fluoride" }, "health_index": { - "name": "Health Index" + "name": "Health index" }, "absolute_humidity": { "name": "Absolute humidity" @@ -112,10 +112,10 @@ "name": "Oxygen" }, "performance_index": { - "name": "Performance Index" + "name": "Performance index" }, "hydrogen_phosphide": { - "name": "Hydrogen Phosphide" + "name": "Hydrogen phosphide" }, "relative_pressure": { "name": "Relative pressure" @@ -127,22 +127,22 @@ "name": "Refrigerant" }, "silicon_hydride": { - "name": "Silicon Hydride" + "name": "Silicon hydride" }, "noise": { "name": "Noise" }, "maximum_noise": { - "name": "Noise (Maximum)" + "name": "Noise (maximum)" }, "radon": { "name": "Radon" }, "industrial_volatile_organic_compounds": { - "name": "VOCs (Industrial)" + "name": "VOCs (industrial)" }, "virus_index": { - "name": "Virus Index" + "name": "Virus index" } } } From 8874fbe9c7056439dd913ca121ab73d54bd2af74 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 23 Mar 2025 14:16:54 +0100 Subject: [PATCH 2898/3148] Fix sentence-casing of "Station radius" in `airnow` (#141200) --- homeassistant/components/airnow/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/airnow/strings.json b/homeassistant/components/airnow/strings.json index d5fb22106f9..a69f67948cb 100644 --- a/homeassistant/components/airnow/strings.json +++ b/homeassistant/components/airnow/strings.json @@ -7,7 +7,7 @@ "api_key": "[%key:common::config_flow::data::api_key%]", "latitude": "[%key:common::config_flow::data::latitude%]", "longitude": "[%key:common::config_flow::data::longitude%]", - "radius": "Station Radius (miles; optional)" + "radius": "Station radius (miles; optional)" } } }, @@ -25,7 +25,7 @@ "step": { "init": { "data": { - "radius": "Station Radius (miles)" + "radius": "Station radius (miles)" } } } From c7d1e5a28cf6839e72a4cc31753e3b95c9b541ac Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 23 Mar 2025 14:17:32 +0100 Subject: [PATCH 2899/3148] Fix spelling of "Do you want to set up?" in `airgradient` (#141199) --- homeassistant/components/airgradient/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/airgradient/strings.json b/homeassistant/components/airgradient/strings.json index 4cf3a6a34ea..2d9b6be529d 100644 --- a/homeassistant/components/airgradient/strings.json +++ b/homeassistant/components/airgradient/strings.json @@ -11,7 +11,7 @@ } }, "discovery_confirm": { - "description": "Do you want to setup {model}?" + "description": "Do you want to set up {model}?" } }, "abort": { From 588d6ad4cf7d17e19a03d9d6e3d6878aabedb855 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Sun, 23 Mar 2025 14:35:29 +0100 Subject: [PATCH 2900/3148] Patch Z-Wave platforms in cover tests (#141205) --- tests/components/zwave_js/test_cover.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/components/zwave_js/test_cover.py b/tests/components/zwave_js/test_cover.py index b13d4f9787f..13f519725fd 100644 --- a/tests/components/zwave_js/test_cover.py +++ b/tests/components/zwave_js/test_cover.py @@ -2,6 +2,7 @@ import logging +import pytest from zwave_js_server.const import ( CURRENT_STATE_PROPERTY, CURRENT_VALUE_PROPERTY, @@ -35,6 +36,7 @@ from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, STATE_UNKNOWN, + Platform, ) from homeassistant.core import HomeAssistant @@ -50,6 +52,12 @@ FIBARO_FGR_223_SHUTTER_COVER_ENTITY = "cover.fgr_223_test_cover" LOGGER.setLevel(logging.DEBUG) +@pytest.fixture +def platforms() -> list[str]: + """Fixture to specify platforms to test.""" + return [Platform.COVER] + + async def test_window_cover( hass: HomeAssistant, client, chain_actuator_zws12, integration ) -> None: From 4758452e920dd15ab5a08173ea9418f0d2d011bd Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 23 Mar 2025 14:35:59 +0100 Subject: [PATCH 2901/3148] Use correct unit symbol "min" for minutes in `asuswrt` integration (#141206) * Use correct unit symbol "min" for minutes in `asuswrt` integration * Sentence-case all "temperature" sensors --- homeassistant/components/asuswrt/strings.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/asuswrt/strings.json b/homeassistant/components/asuswrt/strings.json index 9d50f50c7e9..cac37c0cfd0 100644 --- a/homeassistant/components/asuswrt/strings.json +++ b/homeassistant/components/asuswrt/strings.json @@ -66,28 +66,28 @@ "name": "Upload" }, "load_avg_1m": { - "name": "Average load (1m)" + "name": "Average load (1 min)" }, "load_avg_5m": { - "name": "Average load (5m)" + "name": "Average load (5 min)" }, "load_avg_15m": { - "name": "Average load (15m)" + "name": "Average load (15 min)" }, "24ghz_temperature": { - "name": "2.4GHz Temperature" + "name": "2.4GHz temperature" }, "5ghz_temperature": { - "name": "5GHz Temperature" + "name": "5GHz temperature" }, "cpu_temperature": { - "name": "CPU Temperature" + "name": "CPU temperature" }, "5ghz_2_temperature": { - "name": "5GHz Temperature (Radio 2)" + "name": "5GHz temperature (Radio 2)" }, "6ghz_temperature": { - "name": "6GHz Temperature" + "name": "6GHz temperature" }, "cpu_usage": { "name": "CPU usage" From 2465d0db7b8f7b3110770862740e1180ab4d8d10 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Sun, 23 Mar 2025 14:52:22 +0100 Subject: [PATCH 2902/3148] Cleanup Vodafone Station strings (#141202) --- homeassistant/components/vodafone_station/strings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/vodafone_station/strings.json b/homeassistant/components/vodafone_station/strings.json index e05e1877798..6e308c35e4f 100644 --- a/homeassistant/components/vodafone_station/strings.json +++ b/homeassistant/components/vodafone_station/strings.json @@ -7,7 +7,7 @@ "password": "[%key:common::config_flow::data::password%]" }, "data_description": { - "password": "Please enter the correct password for host: {host}" + "password": "[%key:component::vodafone_station::config::step::user::data_description::password%]" } }, "user": { @@ -33,10 +33,10 @@ "unknown": "[%key:common::config_flow::error::unknown%]" }, "error": { - "already_logged": "User already logged-in, please try again later.", + "already_logged": "[%key:component::vodafone_station::config::abort::already_logged%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", - "model_not_supported": "The device model is currently unsupported.", + "model_not_supported": "[%key:component::vodafone_station::config::abort::model_not_supported%]", "unknown": "[%key:common::config_flow::error::unknown%]" } }, From 6b724603c8b5db40d66af0c6121d7c2b1e952856 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 23 Mar 2025 15:01:53 +0100 Subject: [PATCH 2903/3148] Remove orphan fuel type settings from Tankerkoening (#141207) remove orphan fule type settings --- homeassistant/components/tankerkoenig/config_flow.py | 6 +----- homeassistant/components/tankerkoenig/const.py | 3 --- homeassistant/components/tankerkoenig/coordinator.py | 3 +-- homeassistant/components/tankerkoenig/strings.json | 1 - tests/components/tankerkoenig/const.py | 3 +-- .../tankerkoenig/snapshots/test_diagnostics.ambr | 3 --- tests/components/tankerkoenig/test_config_flow.py | 8 +------- 7 files changed, 4 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/tankerkoenig/config_flow.py b/homeassistant/components/tankerkoenig/config_flow.py index 8796ae46ab7..b269eaaaf55 100644 --- a/homeassistant/components/tankerkoenig/config_flow.py +++ b/homeassistant/components/tankerkoenig/config_flow.py @@ -39,7 +39,7 @@ from homeassistant.helpers.selector import ( NumberSelectorConfig, ) -from .const import CONF_FUEL_TYPES, CONF_STATIONS, DEFAULT_RADIUS, DOMAIN, FUEL_TYPES +from .const import CONF_STATIONS, DEFAULT_RADIUS, DOMAIN async def async_get_nearby_stations( @@ -175,10 +175,6 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): vol.Required( CONF_API_KEY, default=user_input.get(CONF_API_KEY, "") ): cv.string, - vol.Required( - CONF_FUEL_TYPES, - default=user_input.get(CONF_FUEL_TYPES, list(FUEL_TYPES)), - ): cv.multi_select(FUEL_TYPES), vol.Required( CONF_LOCATION, default=user_input.get( diff --git a/homeassistant/components/tankerkoenig/const.py b/homeassistant/components/tankerkoenig/const.py index c2a1dba9b6a..6761d20f4ce 100644 --- a/homeassistant/components/tankerkoenig/const.py +++ b/homeassistant/components/tankerkoenig/const.py @@ -3,14 +3,11 @@ DOMAIN = "tankerkoenig" NAME = "tankerkoenig" -CONF_FUEL_TYPES = "fuel_types" CONF_STATIONS = "stations" DEFAULT_RADIUS = 2 DEFAULT_SCAN_INTERVAL = 30 -FUEL_TYPES = {"e5": "Super", "e10": "Super E10", "diesel": "Diesel"} - ATTR_BRAND = "brand" ATTR_CITY = "city" ATTR_FUEL_TYPE = "fuel_type" diff --git a/homeassistant/components/tankerkoenig/coordinator.py b/homeassistant/components/tankerkoenig/coordinator.py index 1f73d0577b3..f1e6bc8c865 100644 --- a/homeassistant/components/tankerkoenig/coordinator.py +++ b/homeassistant/components/tankerkoenig/coordinator.py @@ -24,7 +24,7 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import CONF_FUEL_TYPES, CONF_STATIONS, DOMAIN +from .const import CONF_STATIONS, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -54,7 +54,6 @@ class TankerkoenigDataUpdateCoordinator(DataUpdateCoordinator[dict[str, PriceInf self._selected_stations: list[str] = self.config_entry.data[CONF_STATIONS] self.stations: dict[str, Station] = {} - self.fuel_types: list[str] = self.config_entry.data[CONF_FUEL_TYPES] self.show_on_map: bool = self.config_entry.options[CONF_SHOW_ON_MAP] self._tankerkoenig = Tankerkoenig( diff --git a/homeassistant/components/tankerkoenig/strings.json b/homeassistant/components/tankerkoenig/strings.json index 29f4f439dd5..db620b2b11c 100644 --- a/homeassistant/components/tankerkoenig/strings.json +++ b/homeassistant/components/tankerkoenig/strings.json @@ -5,7 +5,6 @@ "data": { "name": "Region name", "api_key": "[%key:common::config_flow::data::api_key%]", - "fuel_types": "Fuel types", "location": "[%key:common::config_flow::data::location%]", "stations": "Additional fuel stations", "radius": "Search radius" diff --git a/tests/components/tankerkoenig/const.py b/tests/components/tankerkoenig/const.py index 2c28753a7f3..9a2ecb3a2be 100644 --- a/tests/components/tankerkoenig/const.py +++ b/tests/components/tankerkoenig/const.py @@ -2,7 +2,7 @@ from aiotankerkoenig import PriceInfo, Station, Status -from homeassistant.components.tankerkoenig.const import CONF_FUEL_TYPES, CONF_STATIONS +from homeassistant.components.tankerkoenig.const import CONF_STATIONS from homeassistant.const import ( CONF_API_KEY, CONF_LATITUDE, @@ -98,7 +98,6 @@ PRICES_MISSING_FUELTYPE = { CONFIG_DATA = { CONF_NAME: "Home", CONF_API_KEY: "269534f6-xxxx-xxxx-xxxx-yyyyzzzzxxxx", - CONF_FUEL_TYPES: ["e5"], CONF_LOCATION: {CONF_LATITUDE: 51.0, CONF_LONGITUDE: 13.0}, CONF_RADIUS: 2.0, CONF_STATIONS: [ diff --git a/tests/components/tankerkoenig/snapshots/test_diagnostics.ambr b/tests/components/tankerkoenig/snapshots/test_diagnostics.ambr index b5b33d7c246..71d9d9c75f8 100644 --- a/tests/components/tankerkoenig/snapshots/test_diagnostics.ambr +++ b/tests/components/tankerkoenig/snapshots/test_diagnostics.ambr @@ -12,9 +12,6 @@ 'entry': dict({ 'data': dict({ 'api_key': '**REDACTED**', - 'fuel_types': list([ - 'e5', - ]), 'location': dict({ 'latitude': '**REDACTED**', 'longitude': '**REDACTED**', diff --git a/tests/components/tankerkoenig/test_config_flow.py b/tests/components/tankerkoenig/test_config_flow.py index bb1e943bbb9..967470c2c16 100644 --- a/tests/components/tankerkoenig/test_config_flow.py +++ b/tests/components/tankerkoenig/test_config_flow.py @@ -4,11 +4,7 @@ from unittest.mock import AsyncMock, patch from aiotankerkoenig.exceptions import TankerkoenigInvalidKeyError -from homeassistant.components.tankerkoenig.const import ( - CONF_FUEL_TYPES, - CONF_STATIONS, - DOMAIN, -) +from homeassistant.components.tankerkoenig.const import CONF_STATIONS, DOMAIN from homeassistant.config_entries import SOURCE_USER from homeassistant.const import ( CONF_API_KEY, @@ -30,7 +26,6 @@ from tests.common import MockConfigEntry MOCK_USER_DATA = { CONF_NAME: "Home", CONF_API_KEY: "269534f6-xxxx-xxxx-xxxx-yyyyzzzzxxxx", - CONF_FUEL_TYPES: ["e5"], CONF_LOCATION: {CONF_LATITUDE: 51.0, CONF_LONGITUDE: 13.0}, CONF_RADIUS: 2.0, } @@ -81,7 +76,6 @@ async def test_user(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.CREATE_ENTRY assert result["data"][CONF_NAME] == "Home" assert result["data"][CONF_API_KEY] == "269534f6-xxxx-xxxx-xxxx-yyyyzzzzxxxx" - assert result["data"][CONF_FUEL_TYPES] == ["e5"] assert result["data"][CONF_LOCATION] == {"latitude": 51.0, "longitude": 13.0} assert result["data"][CONF_RADIUS] == 2.0 assert result["data"][CONF_STATIONS] == [ From ba8ec2258745bc936903aa005ee4cb93cc218d40 Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Sun, 23 Mar 2025 16:20:37 +0200 Subject: [PATCH 2904/3148] Add Switcher missing data descriptions (#141077) --- homeassistant/components/switcher_kis/strings.json | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/switcher_kis/strings.json b/homeassistant/components/switcher_kis/strings.json index e380711303d..c3cf111199f 100644 --- a/homeassistant/components/switcher_kis/strings.json +++ b/homeassistant/components/switcher_kis/strings.json @@ -9,13 +9,21 @@ "data": { "username": "[%key:common::config_flow::data::username%]", "token": "[%key:common::config_flow::data::access_token%]" + }, + "data_description": { + "username": "The email address used to sign in to the Switcher app.", + "token": "The local control token received from Switcher." } }, "reauth_confirm": { - "description": "Found a Switcher device that requires a token\nEnter your username and token\nFor more information see https://www.home-assistant.io/integrations/switcher_kis/#prerequisites", + "description": "[%key:component::switcher_kis::config::step::credentials::description%]", "data": { "username": "[%key:common::config_flow::data::username%]", "token": "[%key:common::config_flow::data::access_token%]" + }, + "data_description": { + "username": "[%key:component::switcher_kis::config::step::credentials::data_description::username%]", + "token": "[%key:component::switcher_kis::config::step::credentials::data_description::token%]" } } }, From 703848766a8da71eb10d8e6a506eaddef31eeff1 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 23 Mar 2025 15:21:48 +0100 Subject: [PATCH 2905/3148] Capitalize "URL" in `feedreader` error message (#141210) --- homeassistant/components/feedreader/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/feedreader/strings.json b/homeassistant/components/feedreader/strings.json index 3132aadbda8..35022e82bb1 100644 --- a/homeassistant/components/feedreader/strings.json +++ b/homeassistant/components/feedreader/strings.json @@ -36,7 +36,7 @@ "issues": { "import_yaml_error_url_error": { "title": "The Feedreader YAML configuration import failed", - "description": "Configuring the Feedreader using YAML is being removed but there was a connection error when trying to import the YAML configuration for `{url}`.\n\nPlease verify that url is reachable and accessible for Home Assistant and restart Home Assistant to try again or remove the Feedreader YAML configuration from your configuration.yaml file and continue to set up the integration manually." + "description": "Configuring the Feedreader using YAML is being removed but there was a connection error when trying to import the YAML configuration for `{url}`.\n\nPlease verify that the URL is reachable and accessible for Home Assistant and restart Home Assistant to try again or remove the Feedreader YAML configuration from your configuration.yaml file and continue to set up the integration manually." } } } From fdaba003ce437dc280e3f34b6bf2dc36cd87fe8a Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Sun, 23 Mar 2025 15:22:18 +0100 Subject: [PATCH 2906/3148] Patch Z-Wave platforms in event tests (#141209) --- tests/components/zwave_js/test_event.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tests/components/zwave_js/test_event.py b/tests/components/zwave_js/test_event.py index 1db02662f4e..84b1ade2632 100644 --- a/tests/components/zwave_js/test_event.py +++ b/tests/components/zwave_js/test_event.py @@ -3,11 +3,12 @@ from datetime import timedelta from freezegun import freeze_time +import pytest from zwave_js_server.event import Event from homeassistant.components.event import ATTR_EVENT_TYPE from homeassistant.components.zwave_js.const import ATTR_VALUE -from homeassistant.const import STATE_UNKNOWN +from homeassistant.const import STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from homeassistant.util import dt as dt_util @@ -15,6 +16,12 @@ BASIC_EVENT_VALUE_ENTITY = "event.honeywell_in_wall_smart_fan_control_event_valu CENTRAL_SCENE_ENTITY = "event.node_51_scene_002" +@pytest.fixture +def platforms() -> list[str]: + """Fixture to specify platforms to test.""" + return [Platform.EVENT] + + async def test_basic( hass: HomeAssistant, client, fan_honeywell_39358, integration ) -> None: From f94b55b6088db851e1acda095d08644d217b9ff6 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 23 Mar 2025 15:22:57 +0100 Subject: [PATCH 2907/3148] Fixes to user-facing strings of `azure_devops` integration (#141208) * Fixes to user-facing strings of `azure_devops` integration - capitalize abbreviations "ID" and "URL" - sentence-case "project" - consistently capitalize "Personal Access Token" as a name * Update test_sensor.ambr --- homeassistant/components/azure_devops/strings.json | 8 ++++---- .../azure_devops/snapshots/test_sensor.ambr | 12 ++++++------ 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/azure_devops/strings.json b/homeassistant/components/azure_devops/strings.json index f5fe5cd06a7..611a8b9a758 100644 --- a/homeassistant/components/azure_devops/strings.json +++ b/homeassistant/components/azure_devops/strings.json @@ -14,7 +14,7 @@ "personal_access_token": "Personal Access Token (PAT)" }, "description": "Set up an Azure DevOps instance to access your project. A Personal Access Token is only required for a private project.", - "title": "Add Azure DevOps Project" + "title": "Add Azure DevOps project" }, "reauth_confirm": { "data": { @@ -32,7 +32,7 @@ "entity": { "sensor": { "build_id": { - "name": "{definition_name} latest build id" + "name": "{definition_name} latest build ID" }, "finish_time": { "name": "{definition_name} latest build finish time" @@ -59,7 +59,7 @@ "name": "{definition_name} latest build start time" }, "url": { - "name": "{definition_name} latest build url" + "name": "{definition_name} latest build URL" }, "work_item_count": { "name": "{item_type} {item_state} work items" @@ -68,7 +68,7 @@ }, "exceptions": { "authentication_failed": { - "message": "Could not authorize with Azure DevOps for {title}. You will need to update your personal access token." + "message": "Could not authorize with Azure DevOps for {title}. You will need to update your Personal Access Token." } } } diff --git a/tests/components/azure_devops/snapshots/test_sensor.ambr b/tests/components/azure_devops/snapshots/test_sensor.ambr index 0b8f35497c6..3fe4d470a63 100644 --- a/tests/components/azure_devops/snapshots/test_sensor.ambr +++ b/tests/components/azure_devops/snapshots/test_sensor.ambr @@ -131,7 +131,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'CI latest build id', + 'original_name': 'CI latest build ID', 'platform': 'azure_devops', 'previous_unique_id': None, 'supported_features': 0, @@ -143,7 +143,7 @@ # name: test_sensors[sensor.testproject_ci_latest_build_id-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'testproject CI latest build id', + 'friendly_name': 'testproject CI latest build ID', }), 'context': , 'entity_id': 'sensor.testproject_ci_latest_build_id', @@ -462,7 +462,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'CI latest build url', + 'original_name': 'CI latest build URL', 'platform': 'azure_devops', 'previous_unique_id': None, 'supported_features': 0, @@ -474,7 +474,7 @@ # name: test_sensors[sensor.testproject_ci_latest_build_url-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'testproject CI latest build url', + 'friendly_name': 'testproject CI latest build URL', }), 'context': , 'entity_id': 'sensor.testproject_ci_latest_build_url', @@ -526,7 +526,7 @@ # name: test_sensors_missing_data[sensor.testproject_ci_latest_build_id-state-missing-data] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'testproject CI latest build id', + 'friendly_name': 'testproject CI latest build ID', }), 'context': , 'entity_id': 'sensor.testproject_ci_latest_build_id', @@ -619,7 +619,7 @@ # name: test_sensors_missing_data[sensor.testproject_ci_latest_build_url-state-missing-data] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'testproject CI latest build url', + 'friendly_name': 'testproject CI latest build URL', }), 'context': , 'entity_id': 'sensor.testproject_ci_latest_build_url', From 8869236e9cc9b7c2d05a6387ca520f4b5f5298be Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 23 Mar 2025 04:26:14 -1000 Subject: [PATCH 2908/3148] Bump google-cloud-pubsub to 2.29.0 (#141178) changelog: https://github.com/googleapis/python-pubsub/compare/v2.28.0...v2.29.0 --- homeassistant/components/google_pubsub/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/google_pubsub/manifest.json b/homeassistant/components/google_pubsub/manifest.json index d3e57c26e39..b96f4e9ebc0 100644 --- a/homeassistant/components/google_pubsub/manifest.json +++ b/homeassistant/components/google_pubsub/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/google_pubsub", "iot_class": "cloud_push", "quality_scale": "legacy", - "requirements": ["google-cloud-pubsub==2.28.0"] + "requirements": ["google-cloud-pubsub==2.29.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 663287929cf..a2f06f812af 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1030,7 +1030,7 @@ goodwe==0.3.6 google-api-python-client==2.71.0 # homeassistant.components.google_pubsub -google-cloud-pubsub==2.28.0 +google-cloud-pubsub==2.29.0 # homeassistant.components.google_cloud google-cloud-speech==2.27.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3f0e1873d9c..32b80165ced 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -880,7 +880,7 @@ goodwe==0.3.6 google-api-python-client==2.71.0 # homeassistant.components.google_pubsub -google-cloud-pubsub==2.28.0 +google-cloud-pubsub==2.29.0 # homeassistant.components.google_cloud google-cloud-speech==2.27.0 From 56f553e352392e8d42a72d9689a73781ef4503a3 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 23 Mar 2025 15:26:44 +0100 Subject: [PATCH 2909/3148] Clarify meaning of "level" in `dynalite.request_channel_level` action (#141184) Without context it's very difficult to come up with a good translation of "level" as there are many different words for this in other languages. This commit adds "brightness" to explain the meaning of "channel level" in `dynalite`. --- homeassistant/components/dynalite/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/dynalite/strings.json b/homeassistant/components/dynalite/strings.json index 468cdebf0b1..4f73f91113b 100644 --- a/homeassistant/components/dynalite/strings.json +++ b/homeassistant/components/dynalite/strings.json @@ -36,7 +36,7 @@ }, "request_channel_level": { "name": "Request channel level", - "description": "Requests Dynalite to report the level of a specific channel.", + "description": "Requests Dynalite to report the brightness level of a specific channel.", "fields": { "host": { "name": "[%key:common::config_flow::data::host%]", @@ -48,7 +48,7 @@ }, "channel": { "name": "Channel", - "description": "Channel to request the level for." + "description": "Channel to request the brightness level for." } } } From 5f3344cd3d1088785107fd12b72b00fff227ff77 Mon Sep 17 00:00:00 2001 From: Simon Lamon <32477463+silamon@users.noreply.github.com> Date: Sun, 23 Mar 2025 15:27:40 +0100 Subject: [PATCH 2910/3148] Bump linkplay to v0.2.0 (#141098) * Bump linkplay to v0.2.0 * Fix invalid reference on items() * Ruff --- homeassistant/components/linkplay/manifest.json | 2 +- .../components/linkplay/media_player.py | 16 ++++++---------- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 9 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/linkplay/manifest.json b/homeassistant/components/linkplay/manifest.json index ec9a8759a30..0fceed1f691 100644 --- a/homeassistant/components/linkplay/manifest.json +++ b/homeassistant/components/linkplay/manifest.json @@ -7,6 +7,6 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["linkplay"], - "requirements": ["python-linkplay==0.1.3"], + "requirements": ["python-linkplay==0.2.0"], "zeroconf": ["_linkplay._tcp.local."] } diff --git a/homeassistant/components/linkplay/media_player.py b/homeassistant/components/linkplay/media_player.py index b27616f1e09..16b0d5f75f1 100644 --- a/homeassistant/components/linkplay/media_player.py +++ b/homeassistant/components/linkplay/media_player.py @@ -86,16 +86,10 @@ REPEAT_MAP: dict[LoopMode, RepeatMode] = { REPEAT_MAP_INV: dict[RepeatMode, LoopMode] = {v: k for k, v in REPEAT_MAP.items()} -EQUALIZER_MAP: dict[EqualizerMode, str] = { - EqualizerMode.NONE: "None", - EqualizerMode.CLASSIC: "Classic", - EqualizerMode.POP: "Pop", - EqualizerMode.JAZZ: "Jazz", - EqualizerMode.VOCAL: "Vocal", +EQUALIZER_MAP_INV: dict[str, EqualizerMode] = { + mode.value: mode for mode in EqualizerMode } -EQUALIZER_MAP_INV: dict[str, EqualizerMode] = {v: k for k, v in EQUALIZER_MAP.items()} - DEFAULT_FEATURES: MediaPlayerEntityFeature = ( MediaPlayerEntityFeature.PLAY | MediaPlayerEntityFeature.PLAY_MEDIA @@ -148,7 +142,6 @@ async def async_setup_entry( class LinkPlayMediaPlayerEntity(LinkPlayBaseEntity, MediaPlayerEntity): """Representation of a LinkPlay media player.""" - _attr_sound_mode_list = list(EQUALIZER_MAP.values()) _attr_device_class = MediaPlayerDeviceClass.RECEIVER _attr_media_content_type = MediaType.MUSIC _attr_name = None @@ -163,6 +156,9 @@ class LinkPlayMediaPlayerEntity(LinkPlayBaseEntity, MediaPlayerEntity): self._attr_source_list = [ SOURCE_MAP[playing_mode] for playing_mode in bridge.device.playmode_support ] + self._attr_sound_mode_list = [ + mode.value for mode in bridge.player.available_equalizer_modes + ] @exception_wrap async def async_update(self) -> None: @@ -348,7 +344,7 @@ class LinkPlayMediaPlayerEntity(LinkPlayBaseEntity, MediaPlayerEntity): self._attr_is_volume_muted = self._bridge.player.muted self._attr_repeat = REPEAT_MAP[self._bridge.player.loop_mode] self._attr_shuffle = self._bridge.player.loop_mode == LoopMode.RANDOM_PLAYBACK - self._attr_sound_mode = EQUALIZER_MAP[self._bridge.player.equalizer_mode] + self._attr_sound_mode = self._bridge.player.equalizer_mode.value self._attr_supported_features = DEFAULT_FEATURES if self._bridge.player.status == PlayingStatus.PLAYING: diff --git a/requirements_all.txt b/requirements_all.txt index a2f06f812af..d30280144a3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2425,7 +2425,7 @@ python-juicenet==1.1.0 python-kasa[speedups]==0.10.2 # homeassistant.components.linkplay -python-linkplay==0.1.3 +python-linkplay==0.2.0 # homeassistant.components.lirc # python-lirc==1.2.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 32b80165ced..5384d917e15 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1961,7 +1961,7 @@ python-juicenet==1.1.0 python-kasa[speedups]==0.10.2 # homeassistant.components.linkplay -python-linkplay==0.1.3 +python-linkplay==0.2.0 # homeassistant.components.matter python-matter-server==7.0.0 From 3df1ebf2fc2e5d73c1ed23aafa3d5d5f3661346b Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 23 Mar 2025 16:25:05 +0100 Subject: [PATCH 2911/3148] Fix typo "to setup" and sentence-casing in `twilio` (#141218) --- homeassistant/components/twilio/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/twilio/strings.json b/homeassistant/components/twilio/strings.json index 871711ff087..f4b7dee707f 100644 --- a/homeassistant/components/twilio/strings.json +++ b/homeassistant/components/twilio/strings.json @@ -12,7 +12,7 @@ "webhook_not_internet_accessible": "[%key:common::config_flow::abort::webhook_not_internet_accessible%]" }, "create_entry": { - "default": "To send events to Home Assistant, you will need to setup [Webhooks with Twilio]({twilio_url}).\n\nFill in the following info:\n\n- URL: `{webhook_url}`\n- Method: POST\n- Content Type: application/x-www-form-urlencoded\n\nSee [the documentation]({docs_url}) on how to configure automations to handle incoming data." + "default": "To send events to Home Assistant, you will need to set up [webhooks with Twilio]({twilio_url}).\n\nFill in the following info:\n\n- URL: `{webhook_url}`\n- Method: POST\n- Content Type: application/x-www-form-urlencoded\n\nSee [the documentation]({docs_url}) on how to configure automations to handle incoming data." } } } From a6ff5391e5eded886863a75c46459e27eb1d919f Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 23 Mar 2025 16:26:28 +0100 Subject: [PATCH 2912/3148] Fix typo "to setup" in `homeassistant_hardware` (#141212) Fix typo "to setup" in multiple integrations --- homeassistant/components/homeassistant_hardware/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/homeassistant_hardware/strings.json b/homeassistant/components/homeassistant_hardware/strings.json index 5456f418c75..6dda01561f1 100644 --- a/homeassistant/components/homeassistant_hardware/strings.json +++ b/homeassistant/components/homeassistant_hardware/strings.json @@ -39,7 +39,7 @@ "description": "The OpenThread Border Router (OTBR) add-on is now starting." }, "otbr_failed": { - "title": "Failed to setup OpenThread Border Router", + "title": "Failed to set up OpenThread Border Router", "description": "The OpenThread Border Router add-on installation was unsuccessful. Ensure no other software is trying to communicate with the {model}, you have access to the Internet and can install other add-ons, and try again. Check the Supervisor logs if the problem persists." }, "confirm_otbr": { From 663a204c044c94535a7e2ea260b69d245a8a0121 Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Sun, 23 Mar 2025 17:01:35 +0100 Subject: [PATCH 2913/3148] Fix Python path for vscode run core task (#141090) Fix Python path for vscode launch core task --- .vscode/tasks.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.vscode/tasks.json b/.vscode/tasks.json index b699ed44b96..09c1d374299 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -4,7 +4,7 @@ { "label": "Run Home Assistant Core", "type": "shell", - "command": "hass -c ./config", + "command": "${command:python.interpreterPath} -m homeassistant -c ./config", "group": "test", "presentation": { "reveal": "always", From f14b76c54b46999a2726f42fa71d63fbebbc0806 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sun, 23 Mar 2025 09:03:06 -0700 Subject: [PATCH 2914/3148] Add Gemini/OpenAI token stats to the conversation trace (#141118) * Add gemini token status to the conversation trace * Add OpenAI Token Stats * Revert input_tokens_details since its not in the openai version yet * Fix ruff lint errors --- .../components/conversation/chat_log.py | 14 ++++++++++---- .../conversation.py | 12 ++++++++++++ .../openai_conversation/conversation.py | 16 +++++++++++++++- .../test_conversation.py | 11 ++++++++++- 4 files changed, 47 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/conversation/chat_log.py b/homeassistant/components/conversation/chat_log.py index 2de785dae7d..cb7b8dd22f7 100644 --- a/homeassistant/components/conversation/chat_log.py +++ b/homeassistant/components/conversation/chat_log.py @@ -8,7 +8,7 @@ from contextlib import contextmanager from contextvars import ContextVar from dataclasses import asdict, dataclass, field, replace import logging -from typing import Literal, TypedDict +from typing import Any, Literal, TypedDict import voluptuous as vol @@ -456,10 +456,16 @@ class ChatLog: LOGGER.debug("Prompt: %s", self.content) LOGGER.debug("Tools: %s", self.llm_api.tools if self.llm_api else None) - trace.async_conversation_trace_append( - trace.ConversationTraceEventType.AGENT_DETAIL, + self.async_trace( { "messages": self.content, "tools": self.llm_api.tools if self.llm_api else None, - }, + } + ) + + def async_trace(self, agent_details: dict[str, Any]) -> None: + """Append agent specific details to the conversation trace.""" + trace.async_conversation_trace_append( + trace.ConversationTraceEventType.AGENT_DETAIL, + agent_details, ) diff --git a/homeassistant/components/google_generative_ai_conversation/conversation.py b/homeassistant/components/google_generative_ai_conversation/conversation.py index 4648f1afb4c..e35346cc745 100644 --- a/homeassistant/components/google_generative_ai_conversation/conversation.py +++ b/homeassistant/components/google_generative_ai_conversation/conversation.py @@ -403,6 +403,18 @@ class GoogleGenerativeAIConversationEntity( error = f"Sorry, I had a problem talking to Google Generative AI: {err}" raise HomeAssistantError(error) from err + if (usage_metadata := chat_response.usage_metadata) is not None: + chat_log.async_trace( + { + "stats": { + "input_tokens": usage_metadata.prompt_token_count, + "cached_input_tokens": usage_metadata.cached_content_token_count + or 0, + "output_tokens": usage_metadata.candidates_token_count, + } + } + ) + response_parts = chat_response.candidates[0].content.parts if not response_parts: raise HomeAssistantError( diff --git a/homeassistant/components/openai_conversation/conversation.py b/homeassistant/components/openai_conversation/conversation.py index 6767734bb00..32ac20b2680 100644 --- a/homeassistant/components/openai_conversation/conversation.py +++ b/homeassistant/components/openai_conversation/conversation.py @@ -9,6 +9,7 @@ from openai._streaming import AsyncStream from openai.types.responses import ( EasyInputMessageParam, FunctionToolParam, + ResponseCompletedEvent, ResponseFunctionCallArgumentsDeltaEvent, ResponseFunctionCallArgumentsDoneEvent, ResponseFunctionToolCall, @@ -111,6 +112,7 @@ def _convert_content_to_param( async def _transform_stream( + chat_log: conversation.ChatLog, result: AsyncStream[ResponseStreamEvent], ) -> AsyncGenerator[conversation.AssistantContentDeltaDict]: """Transform an OpenAI delta stream into HA format.""" @@ -137,6 +139,18 @@ async def _transform_stream( ) ] } + elif ( + isinstance(event, ResponseCompletedEvent) + and (usage := event.response.usage) is not None + ): + chat_log.async_trace( + { + "stats": { + "input_tokens": usage.input_tokens, + "output_tokens": usage.output_tokens, + } + } + ) class OpenAIConversationEntity( @@ -252,7 +266,7 @@ class OpenAIConversationEntity( raise HomeAssistantError("Error talking to OpenAI") from err async for content in chat_log.async_add_delta_content_stream( - user_input.agent_id, _transform_stream(result) + user_input.agent_id, _transform_stream(chat_log, result) ): messages.extend(_convert_content_to_param(content)) diff --git a/tests/components/google_generative_ai_conversation/test_conversation.py b/tests/components/google_generative_ai_conversation/test_conversation.py index 64f71c18bf2..22bc079a21f 100644 --- a/tests/components/google_generative_ai_conversation/test_conversation.py +++ b/tests/components/google_generative_ai_conversation/test_conversation.py @@ -156,8 +156,10 @@ async def test_function_call( trace_events = last_trace.get("events", []) assert [event["event_type"] for event in trace_events] == [ trace.ConversationTraceEventType.ASYNC_PROCESS, - trace.ConversationTraceEventType.AGENT_DETAIL, + trace.ConversationTraceEventType.AGENT_DETAIL, # prompt and tools + trace.ConversationTraceEventType.AGENT_DETAIL, # stats for response trace.ConversationTraceEventType.TOOL_CALL, + trace.ConversationTraceEventType.AGENT_DETAIL, # stats for response ] # AGENT_DETAIL event contains the raw prompt passed to the model detail_event = trace_events[1] @@ -166,6 +168,13 @@ async def test_function_call( p["tool_name"] for p in detail_event["data"]["messages"][2]["tool_calls"] ] == ["test_tool"] + detail_event = trace_events[2] + assert set(detail_event["data"]["stats"].keys()) == { + "input_tokens", + "cached_input_tokens", + "output_tokens", + } + @patch( "homeassistant.components.google_generative_ai_conversation.conversation.llm.AssistAPI._async_get_tools" From c451518959027cfb16e70a5ebb000ac8136af269 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sun, 23 Mar 2025 09:07:42 -0700 Subject: [PATCH 2915/3148] Fix google calendar working location event filtering (#141222) --- homeassistant/components/google/calendar.py | 6 +++--- tests/components/google/test_calendar.py | 13 +++++++++++-- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/google/calendar.py b/homeassistant/components/google/calendar.py index 4f8ffba1d19..4ae8c8cce03 100644 --- a/homeassistant/components/google/calendar.py +++ b/homeassistant/components/google/calendar.py @@ -383,9 +383,9 @@ class GoogleCalendarEntity( for attendee in event.attendees ): return False - - if event.event_type == EventTypeEnum.WORKING_LOCATION: - return self.entity_description.working_location + is_working_location_event = event.event_type == EventTypeEnum.WORKING_LOCATION + if self.entity_description.working_location != is_working_location_event: + return False if self._ignore_availability: return True return event.transparency == OPAQUE diff --git a/tests/components/google/test_calendar.py b/tests/components/google/test_calendar.py index 3d10e753714..274e310fbce 100644 --- a/tests/components/google/test_calendar.py +++ b/tests/components/google/test_calendar.py @@ -1451,6 +1451,13 @@ async def test_working_location_ignored( assert state.attributes.get("message") == expected_event_message +@pytest.mark.parametrize( + ("event_type", "expected_event_message"), + [ + ("workingLocation", "Test All Day Event"), + ("default", None), + ], +) @pytest.mark.parametrize("calendar_is_primary", [True]) async def test_working_location_entity( hass: HomeAssistant, @@ -1458,12 +1465,14 @@ async def test_working_location_entity( entity_registry: er.EntityRegistry, mock_events_list_items: Callable[[list[dict[str, Any]]], None], component_setup: ComponentSetup, + event_type: str, + expected_event_message: str | None, ) -> None: """Test that working location events are registered under a disabled by default entity.""" event = { **TEST_EVENT, **upcoming(), - "eventType": "workingLocation", + "eventType": event_type, } mock_events_list_items([event]) assert await component_setup() @@ -1484,7 +1493,7 @@ async def test_working_location_entity( state = hass.states.get("calendar.working_location") assert state assert state.name == "Working location" - assert state.attributes.get("message") == "Test All Day Event" + assert state.attributes.get("message") == expected_event_message @pytest.mark.parametrize("calendar_is_primary", [False]) From 28ef0a33ad38b901870343e0088c14c9777e43a6 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sun, 23 Mar 2025 09:11:40 -0700 Subject: [PATCH 2916/3148] Update MCP to reconnect to the server on demand (#141215) * Reconnect to the MCP client on deman * Remove debug log * Update log messages --- homeassistant/components/mcp/__init__.py | 1 - homeassistant/components/mcp/coordinator.py | 77 ++++++--------------- 2 files changed, 20 insertions(+), 58 deletions(-) diff --git a/homeassistant/components/mcp/__init__.py b/homeassistant/components/mcp/__init__.py index 4a2b4da990d..41b6a260d9f 100644 --- a/homeassistant/components/mcp/__init__.py +++ b/homeassistant/components/mcp/__init__.py @@ -39,7 +39,6 @@ async def async_setup_entry( entry.async_on_unload(unsub) entry.runtime_data = coordinator - entry.async_on_unload(coordinator.close) return True diff --git a/homeassistant/components/mcp/coordinator.py b/homeassistant/components/mcp/coordinator.py index a5c5ee55dbf..6e66036c548 100644 --- a/homeassistant/components/mcp/coordinator.py +++ b/homeassistant/components/mcp/coordinator.py @@ -40,6 +40,7 @@ async def mcp_client(url: str) -> AsyncGenerator[ClientSession]: await session.initialize() yield session except ExceptionGroup as err: + _LOGGER.debug("Error creating MCP client: %s", err) raise err.exceptions[0] from err @@ -51,13 +52,13 @@ class ModelContextProtocolTool(llm.Tool): name: str, description: str | None, parameters: vol.Schema, - session: ClientSession, + server_url: str, ) -> None: """Initialize the tool.""" self.name = name self.description = description self.parameters = parameters - self.session = session + self.server_url = server_url async def async_call( self, @@ -67,10 +68,16 @@ class ModelContextProtocolTool(llm.Tool): ) -> JsonObjectType: """Call the tool.""" try: - result = await self.session.call_tool( - tool_input.tool_name, tool_input.tool_args - ) + async with asyncio.timeout(TIMEOUT): + async with mcp_client(self.server_url) as session: + result = await session.call_tool( + tool_input.tool_name, tool_input.tool_args + ) + except TimeoutError as error: + _LOGGER.debug("Timeout when calling tool: %s", error) + raise HomeAssistantError(f"Timeout when calling tool: {error}") from error except httpx.HTTPStatusError as error: + _LOGGER.debug("Error when calling tool: %s", error) raise HomeAssistantError(f"Error when calling tool: {error}") from error return result.model_dump(exclude_unset=True, exclude_none=True) @@ -79,8 +86,6 @@ class ModelContextProtocolCoordinator(DataUpdateCoordinator[list[llm.Tool]]): """Define an object to hold MCP data.""" config_entry: ConfigEntry - _session: ClientSession | None = None - _setup_error: Exception | None = None def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: """Initialize ModelContextProtocolCoordinator.""" @@ -91,52 +96,6 @@ class ModelContextProtocolCoordinator(DataUpdateCoordinator[list[llm.Tool]]): config_entry=config_entry, update_interval=UPDATE_INTERVAL, ) - self._stop = asyncio.Event() - - async def _async_setup(self) -> None: - """Set up the client connection.""" - connected = asyncio.Event() - stop = asyncio.Event() - self.config_entry.async_create_background_task( - self.hass, self._connect(connected, stop), "mcp-client" - ) - try: - async with asyncio.timeout(TIMEOUT): - await connected.wait() - self._stop = stop - finally: - if self._setup_error is not None: - raise self._setup_error - - async def _connect(self, connected: asyncio.Event, stop: asyncio.Event) -> None: - """Create a server-sent event MCP client.""" - url = self.config_entry.data[CONF_URL] - try: - async with ( - sse_client(url=url) as streams, - ClientSession(*streams) as session, - ): - await session.initialize() - self._session = session - connected.set() - await stop.wait() - except httpx.HTTPStatusError as err: - self._setup_error = err - _LOGGER.debug("Error connecting to MCP server: %s", err) - raise UpdateFailed(f"Error connecting to MCP server: {err}") from err - except ExceptionGroup as err: - self._setup_error = err.exceptions[0] - _LOGGER.debug("Error connecting to MCP server: %s", err) - raise UpdateFailed( - "Error connecting to MCP server: {err.exceptions[0]}" - ) from err.exceptions[0] - finally: - self._session = None - - async def close(self) -> None: - """Close the client connection.""" - if self._stop is not None: - self._stop.set() async def _async_update_data(self) -> list[llm.Tool]: """Fetch data from API endpoint. @@ -144,11 +103,15 @@ class ModelContextProtocolCoordinator(DataUpdateCoordinator[list[llm.Tool]]): This is the place to pre-process the data to lookup tables so entities can quickly look up their data. """ - if self._session is None: - raise UpdateFailed("No session available") try: - result = await self._session.list_tools() + async with asyncio.timeout(TIMEOUT): + async with mcp_client(self.config_entry.data[CONF_URL]) as session: + result = await session.list_tools() + except TimeoutError as error: + _LOGGER.debug("Timeout when listing tools: %s", error) + raise UpdateFailed(f"Timeout when listing tools: {error}") from error except httpx.HTTPError as err: + _LOGGER.debug("Error communicating with API: %s", err) raise UpdateFailed(f"Error communicating with API: {err}") from err _LOGGER.debug("Received tools: %s", result.tools) @@ -165,7 +128,7 @@ class ModelContextProtocolCoordinator(DataUpdateCoordinator[list[llm.Tool]]): tool.name, tool.description, parameters, - self._session, + self.config_entry.data[CONF_URL], ) ) return tools From d23a724f796ccf14676f66574303397443f2c84d Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 23 Mar 2025 19:28:45 +0100 Subject: [PATCH 2917/3148] Fix typo "to setup" in `reolink` (#141214) --- homeassistant/components/reolink/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index 74823c4bd32..7ad2e1ea217 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -102,7 +102,7 @@ "message": "Error trying to update Reolink firmware: {err}" }, "config_entry_not_ready": { - "message": "Error while trying to setup {host}: {err}" + "message": "Error while trying to set up {host}: {err}" } }, "issues": { From c2057d19c0e007f9b64fcf60331513a868cee889 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 23 Mar 2025 20:05:15 +0100 Subject: [PATCH 2918/3148] Capitalize "ID" and "URL" abbreviations in `trafikverket_camera` (#141238) Make the spelling consistent throughout Home Assistant. --- homeassistant/components/trafikverket_camera/strings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/trafikverket_camera/strings.json b/homeassistant/components/trafikverket_camera/strings.json index b6e2209fc57..8fdc6357156 100644 --- a/homeassistant/components/trafikverket_camera/strings.json +++ b/homeassistant/components/trafikverket_camera/strings.json @@ -18,7 +18,7 @@ "location": "[%key:common::config_flow::data::location%]" }, "data_description": { - "location": "Equal or part of name, description or camera id. Be as specific as possible to avoid getting multiple cameras as result" + "location": "Equal or part of name, description or camera ID. Be as specific as possible to avoid getting multiple cameras as result" } }, "multiple_cameras": { @@ -60,7 +60,7 @@ "name": "[%key:common::config_flow::data::location%]" }, "photo_url": { - "name": "Photo url" + "name": "Photo URL" }, "status": { "name": "Status" @@ -87,7 +87,7 @@ "name": "Photo time" }, "photo_url": { - "name": "Photo url" + "name": "Photo URL" }, "status": { "name": "Status" From 1d36279e7994597cb811f4032cc3f98946bf78e4 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 23 Mar 2025 20:08:25 +0100 Subject: [PATCH 2919/3148] Use correct unit symbol "min" for minutes in `systemmonitor` integration (#141236) * Use correct unit symbol "min" for minutes in `systemmonitor` integration * Update test_sensor.ambr * Remove accidentially added, excessive space character --- .../components/systemmonitor/strings.json | 6 +++--- .../systemmonitor/snapshots/test_sensor.ambr | 18 +++++++++--------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/systemmonitor/strings.json b/homeassistant/components/systemmonitor/strings.json index fb8a318ff45..134fe390357 100644 --- a/homeassistant/components/systemmonitor/strings.json +++ b/homeassistant/components/systemmonitor/strings.json @@ -48,13 +48,13 @@ "name": "Last boot" }, "load_15m": { - "name": "Load (15m)" + "name": "Load (15 min)" }, "load_1m": { - "name": "Load (1m)" + "name": "Load (1 min)" }, "load_5m": { - "name": "Load (5m)" + "name": "Load (5 min)" }, "memory_free": { "name": "Memory free" diff --git a/tests/components/systemmonitor/snapshots/test_sensor.ambr b/tests/components/systemmonitor/snapshots/test_sensor.ambr index 1ee9067a528..8108e4777c8 100644 --- a/tests/components/systemmonitor/snapshots/test_sensor.ambr +++ b/tests/components/systemmonitor/snapshots/test_sensor.ambr @@ -114,34 +114,34 @@ # name: test_sensor[System Monitor Last boot - state] '2024-02-24T15:00:00+00:00' # --- -# name: test_sensor[System Monitor Load (15m) - attributes] +# name: test_sensor[System Monitor Load (15 min) - attributes] ReadOnlyDict({ - 'friendly_name': 'System Monitor Load (15m)', + 'friendly_name': 'System Monitor Load (15 min)', 'icon': 'mdi:cpu-64-bit', 'state_class': , }) # --- -# name: test_sensor[System Monitor Load (15m) - state] +# name: test_sensor[System Monitor Load (15 min) - state] '3' # --- -# name: test_sensor[System Monitor Load (1m) - attributes] +# name: test_sensor[System Monitor Load (1 min) - attributes] ReadOnlyDict({ - 'friendly_name': 'System Monitor Load (1m)', + 'friendly_name': 'System Monitor Load (1 min)', 'icon': 'mdi:cpu-64-bit', 'state_class': , }) # --- -# name: test_sensor[System Monitor Load (1m) - state] +# name: test_sensor[System Monitor Load (1 min) - state] '1' # --- -# name: test_sensor[System Monitor Load (5m) - attributes] +# name: test_sensor[System Monitor Load (5 min) - attributes] ReadOnlyDict({ - 'friendly_name': 'System Monitor Load (5m)', + 'friendly_name': 'System Monitor Load (5 min)', 'icon': 'mdi:cpu-64-bit', 'state_class': , }) # --- -# name: test_sensor[System Monitor Load (5m) - state] +# name: test_sensor[System Monitor Load (5 min) - state] '2' # --- # name: test_sensor[System Monitor Memory free - attributes] From 9677b0d25475ecd32e8652a1c6e2aa24c4b7e5dc Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 23 Mar 2025 20:09:52 +0100 Subject: [PATCH 2920/3148] Capitalize "Recorder" as the component name in Home Assistant (#141226) --- homeassistant/components/recorder/strings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/recorder/strings.json b/homeassistant/components/recorder/strings.json index 43c2ecdc14f..0c8d47548bf 100644 --- a/homeassistant/components/recorder/strings.json +++ b/homeassistant/components/recorder/strings.json @@ -43,15 +43,15 @@ "fields": { "entity_id": { "name": "Entities to remove", - "description": "List of entities for which the data is to be removed from the recorder database." + "description": "List of entities for which the data is to be removed from the Recorder database." }, "domains": { "name": "Domains to remove", - "description": "List of domains for which the data needs to be removed from the recorder database." + "description": "List of domains for which the data needs to be removed from the Recorder database." }, "entity_globs": { "name": "Entity globs to remove", - "description": "List of glob patterns used to select the entities for which the data is to be removed from the recorder database." + "description": "List of glob patterns used to select the entities for which the data is to be removed from the Recorder database." }, "keep_days": { "name": "[%key:component::recorder::services::purge::fields::keep_days::name%]", From ef84fc52aff92f26dc951f56c95ddcc7f52f00fe Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 23 Mar 2025 20:21:18 +0100 Subject: [PATCH 2921/3148] Clarify the meaning of "recorder" in `energy` issue description (#141228) Clarify the meaning of "The recorder" in `energy` issue description "The recorder" has resulted in a bunch of overtranslations that make this alert useless. By using "Home Assistant Recorder" instead this should get fixed. --- homeassistant/components/energy/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/energy/strings.json b/homeassistant/components/energy/strings.json index e9d72247319..5eb2c93161e 100644 --- a/homeassistant/components/energy/strings.json +++ b/homeassistant/components/energy/strings.json @@ -7,7 +7,7 @@ }, "recorder_untracked": { "title": "Entity not tracked", - "description": "The recorder has been configured to exclude these configured entities:" + "description": "Home Assistant Recorder has been configured to exclude these configured entities:" }, "entity_unavailable": { "title": "Entity unavailable", From 1f122ea54dae7a36d6aa83e4e60ccd364cada888 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Sun, 23 Mar 2025 20:23:11 +0100 Subject: [PATCH 2922/3148] Improve error handling and add exception translations for Nettigo Air Monitor integration (#141183) * Add update_error * Add device_communication_error * Add auth_error * Add device_communication_action_error * Coverage --- homeassistant/components/nam/__init__.py | 24 ++++++-- homeassistant/components/nam/button.py | 19 +++++- homeassistant/components/nam/coordinator.py | 6 +- homeassistant/components/nam/strings.json | 14 +++++ tests/components/nam/test_button.py | 67 ++++++++++++++++++++- 5 files changed, 120 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/nam/__init__.py b/homeassistant/components/nam/__init__.py index 6b4ca6ff324..d297443c059 100644 --- a/homeassistant/components/nam/__init__.py +++ b/homeassistant/components/nam/__init__.py @@ -4,7 +4,7 @@ from __future__ import annotations import logging -from aiohttp.client_exceptions import ClientConnectorError, ClientError +from aiohttp.client_exceptions import ClientError from nettigo_air_monitor import ( ApiError, AuthFailedError, @@ -38,15 +38,27 @@ async def async_setup_entry(hass: HomeAssistant, entry: NAMConfigEntry) -> bool: options = ConnectionOptions(host=host, username=username, password=password) try: nam = await NettigoAirMonitor.create(websession, options) - except (ApiError, ClientError, ClientConnectorError, TimeoutError) as err: - raise ConfigEntryNotReady from err + except (ApiError, ClientError) as err: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="device_communication_error", + translation_placeholders={"device": entry.title}, + ) from err try: await nam.async_check_credentials() - except ApiError as err: - raise ConfigEntryNotReady from err + except (ApiError, ClientError) as err: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="device_communication_error", + translation_placeholders={"device": entry.title}, + ) from err except AuthFailedError as err: - raise ConfigEntryAuthFailed from err + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="auth_error", + translation_placeholders={"device": entry.title}, + ) from err coordinator = NAMDataUpdateCoordinator(hass, entry, nam) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/nam/button.py b/homeassistant/components/nam/button.py index 60145e4fe27..791a5fdc27c 100644 --- a/homeassistant/components/nam/button.py +++ b/homeassistant/components/nam/button.py @@ -4,6 +4,9 @@ from __future__ import annotations import logging +from aiohttp.client_exceptions import ClientError +from nettigo_air_monitor import ApiError, AuthFailedError + from homeassistant.components.button import ( ButtonDeviceClass, ButtonEntity, @@ -11,9 +14,11 @@ from homeassistant.components.button import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity +from .const import DOMAIN from .coordinator import NAMConfigEntry, NAMDataUpdateCoordinator PARALLEL_UPDATES = 1 @@ -59,4 +64,16 @@ class NAMButton(CoordinatorEntity[NAMDataUpdateCoordinator], ButtonEntity): async def async_press(self) -> None: """Triggers the restart.""" - await self.coordinator.nam.async_restart() + try: + await self.coordinator.nam.async_restart() + except (ApiError, ClientError) as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="device_communication_action_error", + translation_placeholders={ + "entity": self.entity_id, + "device": self.coordinator.config_entry.title, + }, + ) from err + except AuthFailedError: + self.coordinator.config_entry.async_start_reauth(self.hass) diff --git a/homeassistant/components/nam/coordinator.py b/homeassistant/components/nam/coordinator.py index 3e2c9c24474..8a898dee378 100644 --- a/homeassistant/components/nam/coordinator.py +++ b/homeassistant/components/nam/coordinator.py @@ -64,6 +64,10 @@ class NAMDataUpdateCoordinator(DataUpdateCoordinator[NAMSensors]): # We do not need to catch AuthFailed exception here because sensor data is # always available without authorization. except (ApiError, InvalidSensorDataError, RetryError) as error: - raise UpdateFailed(error) from error + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_error", + translation_placeholders={"device": self.config_entry.title}, + ) from error return data diff --git a/homeassistant/components/nam/strings.json b/homeassistant/components/nam/strings.json index be9fb1fbb07..000dfe74112 100644 --- a/homeassistant/components/nam/strings.json +++ b/homeassistant/components/nam/strings.json @@ -205,5 +205,19 @@ "name": "Last restart" } } + }, + "exceptions": { + "auth_error": { + "message": "Authentication failed for {device}, please update your credentials" + }, + "device_communication_error": { + "message": "An error occurred while communicating with {device}" + }, + "device_communication_action_error": { + "message": "An error occurred while calling action for {entity} for {device}" + }, + "update_error": { + "message": "An error occurred while retrieving data from {device}" + } } } diff --git a/tests/components/nam/test_button.py b/tests/components/nam/test_button.py index 39c37d57f89..b410665911a 100644 --- a/tests/components/nam/test_button.py +++ b/tests/components/nam/test_button.py @@ -2,9 +2,20 @@ from unittest.mock import patch -from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, ButtonDeviceClass +from aiohttp.client_exceptions import ClientError +from nettigo_air_monitor import ApiError, AuthFailedError +import pytest + +from homeassistant.components.button import ( + DOMAIN as BUTTON_DOMAIN, + SERVICE_PRESS, + ButtonDeviceClass, +) +from homeassistant.components.nam import DOMAIN +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.const import ATTR_DEVICE_CLASS, ATTR_ENTITY_ID, STATE_UNKNOWN from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from homeassistant.util import dt as dt_util @@ -38,7 +49,7 @@ async def test_button_press(hass: HomeAssistant) -> None: ): await hass.services.async_call( BUTTON_DOMAIN, - "press", + SERVICE_PRESS, {ATTR_ENTITY_ID: "button.nettigo_air_monitor_restart"}, blocking=True, ) @@ -49,3 +60,55 @@ async def test_button_press(hass: HomeAssistant) -> None: state = hass.states.get("button.nettigo_air_monitor_restart") assert state assert state.state == now.isoformat() + + +@pytest.mark.parametrize(("exc"), [ApiError("API Error"), ClientError]) +async def test_button_press_exc(hass: HomeAssistant, exc: Exception) -> None: + """Test button press when exception occurs.""" + await init_integration(hass) + + with ( + patch( + "homeassistant.components.nam.NettigoAirMonitor.async_restart", + side_effect=exc, + ), + pytest.raises( + HomeAssistantError, + match="An error occurred while calling action for button.nettigo_air_monitor_restart", + ), + ): + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: "button.nettigo_air_monitor_restart"}, + blocking=True, + ) + + +async def test_button_press_auth_error(hass: HomeAssistant) -> None: + """Test button press when auth error occurs.""" + entry = await init_integration(hass) + + with patch( + "homeassistant.components.nam.NettigoAirMonitor.async_restart", + side_effect=AuthFailedError("auth error"), + ): + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: "button.nettigo_air_monitor_restart"}, + blocking=True, + ) + + assert entry.state is ConfigEntryState.LOADED + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + + flow = flows[0] + assert flow.get("step_id") == "reauth_confirm" + assert flow.get("handler") == DOMAIN + + assert "context" in flow + assert flow["context"].get("source") == SOURCE_REAUTH + assert flow["context"].get("entry_id") == entry.entry_id From e2f7133d001c0c7e4f6dc7c55e3f0882de6d02c8 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 23 Mar 2025 20:26:45 +0100 Subject: [PATCH 2923/3148] Fix spelling of "breadcrumbs" in `sentry` integration (#141189) Replace "breadcrums" with "breadcrumps" as this is the spelling that both Sentry and the HA online docs use. Also use "events" instead of "logs" as the log is the whole and the events are its parts. --- homeassistant/components/sentry/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/sentry/strings.json b/homeassistant/components/sentry/strings.json index efcdb631f3c..22f7b355e0e 100644 --- a/homeassistant/components/sentry/strings.json +++ b/homeassistant/components/sentry/strings.json @@ -24,7 +24,7 @@ "event_handled": "Send handled events", "event_third_party_packages": "Send events from third-party packages", "logging_event_level": "The log level Sentry will register an event for", - "logging_level": "The log level Sentry will record logs as breadcrums for", + "logging_level": "The log level Sentry will record events as breadcrumbs for", "tracing": "Enable performance tracing", "tracing_sample_rate": "Tracing sample rate; between 0.0 and 1.0 (1.0 = 100%)" } From 56cb54588e98de956866d5dbe507db2a02edef69 Mon Sep 17 00:00:00 2001 From: tdfountain <174762217+tdfountain@users.noreply.github.com> Date: Sun, 23 Mar 2025 12:35:58 -0700 Subject: [PATCH 2924/3148] Set parallel updates in NUT (#141225) --- homeassistant/components/nut/button.py | 2 ++ homeassistant/components/nut/sensor.py | 3 +++ homeassistant/components/nut/switch.py | 2 ++ 3 files changed, 7 insertions(+) diff --git a/homeassistant/components/nut/button.py b/homeassistant/components/nut/button.py index 436f06b44d7..0708056b2e3 100644 --- a/homeassistant/components/nut/button.py +++ b/homeassistant/components/nut/button.py @@ -17,6 +17,8 @@ from .entity import NUTBaseEntity _LOGGER = logging.getLogger(__name__) +PARALLEL_UPDATES = 0 + async def async_setup_entry( hass: HomeAssistant, diff --git a/homeassistant/components/nut/sensor.py b/homeassistant/components/nut/sensor.py index 80046c6ac22..5ddff5221d2 100644 --- a/homeassistant/components/nut/sensor.py +++ b/homeassistant/components/nut/sensor.py @@ -30,6 +30,9 @@ from . import NutConfigEntry from .const import KEY_STATUS, KEY_STATUS_DISPLAY, STATE_TYPES from .entity import NUTBaseEntity +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + AMBIENT_PRESENT = "ambient.present" AMBIENT_SENSORS = { "ambient.humidity", diff --git a/homeassistant/components/nut/switch.py b/homeassistant/components/nut/switch.py index 3ab8d0ec60a..924a596cc8e 100644 --- a/homeassistant/components/nut/switch.py +++ b/homeassistant/components/nut/switch.py @@ -18,6 +18,8 @@ from .entity import NUTBaseEntity _LOGGER = logging.getLogger(__name__) +PARALLEL_UPDATES = 0 + async def async_setup_entry( hass: HomeAssistant, From 5d16a23d79696e8d0b948074c113b5443988fce3 Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Sun, 23 Mar 2025 21:00:27 +0100 Subject: [PATCH 2925/3148] Bump pydeconz to v120 (#141239) --- homeassistant/components/deconz/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/deconz/manifest.json b/homeassistant/components/deconz/manifest.json index 93ae8e392c8..5664e6abc8a 100644 --- a/homeassistant/components/deconz/manifest.json +++ b/homeassistant/components/deconz/manifest.json @@ -7,7 +7,7 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["pydeconz"], - "requirements": ["pydeconz==118"], + "requirements": ["pydeconz==120"], "ssdp": [ { "manufacturer": "Royal Philips Electronics", diff --git a/requirements_all.txt b/requirements_all.txt index d30280144a3..8d55909955f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1895,7 +1895,7 @@ pydanfossair==0.1.0 pydeako==0.6.0 # homeassistant.components.deconz -pydeconz==118 +pydeconz==120 # homeassistant.components.delijn pydelijn==1.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5384d917e15..3e7864fd4f1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1548,7 +1548,7 @@ pydaikin==2.14.1 pydeako==0.6.0 # homeassistant.components.deconz -pydeconz==118 +pydeconz==120 # homeassistant.components.dexcom pydexcom==0.2.3 From ec5139eb944637ee1a9a071a45592acd77ae9b6e Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 23 Mar 2025 21:12:45 +0100 Subject: [PATCH 2926/3148] Fix typo "to setup" in `slide_local` (#141216) --- homeassistant/components/slide_local/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/slide_local/strings.json b/homeassistant/components/slide_local/strings.json index 67514ff0d50..10efa4bc4f2 100644 --- a/homeassistant/components/slide_local/strings.json +++ b/homeassistant/components/slide_local/strings.json @@ -25,7 +25,7 @@ }, "zeroconf_confirm": { "title": "Confirm setup for Slide", - "description": "Do you want to setup {host}?" + "description": "Do you want to set up {host}?" } }, "abort": { From 3917b460f47c3e508aa842808998bafe78a235e7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 23 Mar 2025 10:48:34 -1000 Subject: [PATCH 2927/3148] Bump dbus-fast to 2.43.0 (#141240) --- 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 27fed6ad647..e4257221374 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -20,7 +20,7 @@ "bluetooth-adapters==0.21.4", "bluetooth-auto-recovery==1.4.5", "bluetooth-data-tools==1.26.1", - "dbus-fast==2.41.1", + "dbus-fast==2.43.0", "habluetooth==3.37.0" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index eef447193c4..6be0021705d 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -30,7 +30,7 @@ certifi>=2021.5.30 ciso8601==2.3.2 cronsim==2.6 cryptography==44.0.1 -dbus-fast==2.41.1 +dbus-fast==2.43.0 fnv-hash-fast==1.4.0 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 diff --git a/requirements_all.txt b/requirements_all.txt index 8d55909955f..fe507ba7ed6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -744,7 +744,7 @@ datadog==0.15.0 datapoint==0.9.9 # homeassistant.components.bluetooth -dbus-fast==2.41.1 +dbus-fast==2.43.0 # homeassistant.components.debugpy debugpy==1.8.13 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3e7864fd4f1..3c7226f13d1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -640,7 +640,7 @@ datadog==0.15.0 datapoint==0.9.9 # homeassistant.components.bluetooth -dbus-fast==2.41.1 +dbus-fast==2.43.0 # homeassistant.components.debugpy debugpy==1.8.13 From 27f529622c545c389bddef88f3383a27ffa25edd Mon Sep 17 00:00:00 2001 From: Patrick ZAJDA Date: Sun, 23 Mar 2025 21:51:13 +0100 Subject: [PATCH 2928/3148] Switchbot: revert name set to none for temperature sensor (#141149) --- homeassistant/components/switchbot/sensor.py | 1 - 1 file changed, 1 deletion(-) diff --git a/homeassistant/components/switchbot/sensor.py b/homeassistant/components/switchbot/sensor.py index 025c40bff9e..9be5ad8be5a 100644 --- a/homeassistant/components/switchbot/sensor.py +++ b/homeassistant/components/switchbot/sensor.py @@ -73,7 +73,6 @@ SENSOR_TYPES: dict[str, SensorEntityDescription] = { ), "temperature": SensorEntityDescription( key="temperature", - name=None, native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.TEMPERATURE, From 2883f0a1e8b24f1647bc244ddf8c19d869f63043 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Sun, 23 Mar 2025 16:12:05 -0500 Subject: [PATCH 2929/3148] Bump intents to 2025.3.23 (#141241) --- homeassistant/components/conversation/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- script/hassfest/docker/Dockerfile | 2 +- tests/components/conversation/snapshots/test_http.ambr | 1 + 6 files changed, 6 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/conversation/manifest.json b/homeassistant/components/conversation/manifest.json index ea950ace323..56d5e28e642 100644 --- a/homeassistant/components/conversation/manifest.json +++ b/homeassistant/components/conversation/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/conversation", "integration_type": "system", "quality_scale": "internal", - "requirements": ["hassil==2.2.3", "home-assistant-intents==2025.3.5"] + "requirements": ["hassil==2.2.3", "home-assistant-intents==2025.3.23"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 6be0021705d..d85bf08338b 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -39,7 +39,7 @@ hass-nabucasa==0.94.0 hassil==2.2.3 home-assistant-bluetooth==1.13.1 home-assistant-frontend==20250306.0 -home-assistant-intents==2025.3.5 +home-assistant-intents==2025.3.23 httpx==0.28.1 ifaddr==0.2.0 Jinja2==3.1.6 diff --git a/requirements_all.txt b/requirements_all.txt index fe507ba7ed6..aae81f816d7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1158,7 +1158,7 @@ holidays==0.68 home-assistant-frontend==20250306.0 # homeassistant.components.conversation -home-assistant-intents==2025.3.5 +home-assistant-intents==2025.3.23 # homeassistant.components.homematicip_cloud homematicip==1.1.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3c7226f13d1..5d62eee9a3e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -984,7 +984,7 @@ holidays==0.68 home-assistant-frontend==20250306.0 # homeassistant.components.conversation -home-assistant-intents==2025.3.5 +home-assistant-intents==2025.3.23 # homeassistant.components.homematicip_cloud homematicip==1.1.7 diff --git a/script/hassfest/docker/Dockerfile b/script/hassfest/docker/Dockerfile index 79716b6fec3..c4f66faafb0 100644 --- a/script/hassfest/docker/Dockerfile +++ b/script/hassfest/docker/Dockerfile @@ -25,7 +25,7 @@ RUN --mount=from=ghcr.io/astral-sh/uv:0.6.8,source=/uv,target=/bin/uv \ -c /usr/src/homeassistant/homeassistant/package_constraints.txt \ -r /usr/src/homeassistant/requirements.txt \ stdlib-list==0.10.0 pipdeptree==2.25.1 tqdm==4.67.1 ruff==0.11.0 \ - PyTurboJPEG==1.7.5 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 hassil==2.2.3 home-assistant-intents==2025.3.5 mutagen==1.47.0 pymicro-vad==1.0.1 pyspeex-noise==1.0.2 + PyTurboJPEG==1.7.5 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 hassil==2.2.3 home-assistant-intents==2025.3.23 mutagen==1.47.0 pymicro-vad==1.0.1 pyspeex-noise==1.0.2 LABEL "name"="hassfest" LABEL "maintainer"="Home Assistant " diff --git a/tests/components/conversation/snapshots/test_http.ambr b/tests/components/conversation/snapshots/test_http.ambr index 849a5b17102..abce735dd8a 100644 --- a/tests/components/conversation/snapshots/test_http.ambr +++ b/tests/components/conversation/snapshots/test_http.ambr @@ -32,6 +32,7 @@ 'it', 'ka', 'ko', + 'kw', 'lb', 'lt', 'lv', From 842356877e820a167e45d0c955e3b1c313c62cfd Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sun, 23 Mar 2025 14:12:44 -0700 Subject: [PATCH 2930/3148] Bump mcp to 1.5.0 (#141219) * Bump mcp to 1.5.0 * Add required server lifespan typing * Remove comment about typing --- homeassistant/components/mcp/manifest.json | 2 +- homeassistant/components/mcp_server/manifest.json | 2 +- homeassistant/components/mcp_server/server.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/mcp/manifest.json b/homeassistant/components/mcp/manifest.json index ee4baf04802..9cd1e2899a6 100644 --- a/homeassistant/components/mcp/manifest.json +++ b/homeassistant/components/mcp/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/mcp", "iot_class": "local_polling", "quality_scale": "silver", - "requirements": ["mcp==1.1.2"] + "requirements": ["mcp==1.5.0"] } diff --git a/homeassistant/components/mcp_server/manifest.json b/homeassistant/components/mcp_server/manifest.json index a3e00d13c4b..b5fb1bdcd87 100644 --- a/homeassistant/components/mcp_server/manifest.json +++ b/homeassistant/components/mcp_server/manifest.json @@ -8,6 +8,6 @@ "integration_type": "service", "iot_class": "local_push", "quality_scale": "silver", - "requirements": ["mcp==1.1.2", "aiohttp_sse==2.2.0", "anyio==4.9.0"], + "requirements": ["mcp==1.5.0", "aiohttp_sse==2.2.0", "anyio==4.9.0"], "single_config_entry": true } diff --git a/homeassistant/components/mcp_server/server.py b/homeassistant/components/mcp_server/server.py index 88b179ae7c2..affa4faecd6 100644 --- a/homeassistant/components/mcp_server/server.py +++ b/homeassistant/components/mcp_server/server.py @@ -52,7 +52,7 @@ async def create_server( if llm_api_id == STATELESS_LLM_API: llm_api_id = llm.LLM_API_ASSIST - server = Server("home-assistant") + server = Server[Any]("home-assistant") async def get_api_instance() -> llm.APIInstance: """Get the LLM API selected.""" diff --git a/requirements_all.txt b/requirements_all.txt index aae81f816d7..54803bb32a2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1382,7 +1382,7 @@ mbddns==0.1.2 # homeassistant.components.mcp # homeassistant.components.mcp_server -mcp==1.1.2 +mcp==1.5.0 # homeassistant.components.minecraft_server mcstatus==11.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5d62eee9a3e..3a4b517f015 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1157,7 +1157,7 @@ mbddns==0.1.2 # homeassistant.components.mcp # homeassistant.components.mcp_server -mcp==1.1.2 +mcp==1.5.0 # homeassistant.components.minecraft_server mcstatus==11.1.1 From 93010ab5c9848200809433b70ce7365e5dcaecc2 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sun, 23 Mar 2025 22:14:06 +0100 Subject: [PATCH 2931/3148] Ensure suggested values are added to section schema in data entry fow (#141227) --- .../components/kitchen_sink/config_flow.py | 45 ++++++++++--------- homeassistant/data_entry_flow.py | 13 ++++++ .../kitchen_sink/test_config_flow.py | 9 ++++ 3 files changed, 45 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/kitchen_sink/config_flow.py b/homeassistant/components/kitchen_sink/config_flow.py index e1ffe334038..1747a0d723c 100644 --- a/homeassistant/components/kitchen_sink/config_flow.py +++ b/homeassistant/components/kitchen_sink/config_flow.py @@ -17,6 +17,7 @@ from homeassistant.config_entries import ( SubentryFlowResult, ) from homeassistant.core import callback +from homeassistant.helpers import config_validation as cv from . import DOMAIN @@ -80,30 +81,30 @@ class OptionsFlowHandler(OptionsFlow): if user_input is not None: return self.async_create_entry(data=self.config_entry.options | user_input) - return self.async_show_form( - step_id="options_1", - data_schema=vol.Schema( - { - vol.Required("section_1"): data_entry_flow.section( - vol.Schema( - { - vol.Optional( - CONF_BOOLEAN, - default=self.config_entry.options.get( - CONF_BOOLEAN, False - ), - ): bool, - vol.Optional( - CONF_INT, - default=self.config_entry.options.get(CONF_INT, 10), - ): int, - } - ), - {"collapsed": False}, + data_schema = vol.Schema( + { + vol.Required("section_1"): data_entry_flow.section( + vol.Schema( + { + vol.Optional( + CONF_BOOLEAN, + default=self.config_entry.options.get( + CONF_BOOLEAN, False + ), + ): bool, + vol.Optional(CONF_INT): cv.positive_int, + } ), - } - ), + {"collapsed": False}, + ), + } ) + self.add_suggested_values_to_schema( + data_schema, + {"section_1": {"int": self.config_entry.options.get(CONF_INT, 10)}}, + ) + + return self.async_show_form(step_id="options_1", data_schema=data_schema) class SubentryFlowHandler(ConfigSubentryFlow): diff --git a/homeassistant/data_entry_flow.py b/homeassistant/data_entry_flow.py index 251e22e7990..7d2ef09ecb8 100644 --- a/homeassistant/data_entry_flow.py +++ b/homeassistant/data_entry_flow.py @@ -657,6 +657,19 @@ class FlowHandler(Generic[_FlowContextT, _FlowResultT, _HandlerT]): ): continue + # Process the section schema options + if ( + suggested_values is not None + and isinstance(val, section) + and key in suggested_values + ): + new_section_key = copy.copy(key) + schema[new_section_key] = val + val.schema = self.add_suggested_values_to_schema( + copy.deepcopy(val.schema), suggested_values[key] + ) + continue + new_key = key if ( suggested_values diff --git a/tests/components/kitchen_sink/test_config_flow.py b/tests/components/kitchen_sink/test_config_flow.py index 1eea1c8036b..88bacc2cb0b 100644 --- a/tests/components/kitchen_sink/test_config_flow.py +++ b/tests/components/kitchen_sink/test_config_flow.py @@ -96,6 +96,15 @@ async def test_options_flow(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.FORM assert result["step_id"] == "options_1" + section_marker, section_schema = list(result["data_schema"].schema.items())[0] + assert section_marker == "section_1" + section_schema_markers = list(section_schema.schema.schema) + assert len(section_schema_markers) == 2 + assert section_schema_markers[0] == "bool" + assert section_schema_markers[0].description is None + assert section_schema_markers[1] == "int" + assert section_schema_markers[1].description == {"suggested_value": 10} + result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={"section_1": {"bool": True, "int": 15}}, From b171439098df6910e4d1f2447238af9e940c72f9 Mon Sep 17 00:00:00 2001 From: Thomas55555 <59625598+Thomas55555@users.noreply.github.com> Date: Sun, 23 Mar 2025 22:19:16 +0100 Subject: [PATCH 2932/3148] Bump aioautomower to 2025.3.2 (#141211) * Bump aioautomower to 2025.3.2 * requirements * adjust test --- homeassistant/components/husqvarna_automower/manifest.json | 2 +- homeassistant/components/husqvarna_automower/number.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/husqvarna_automower/test_number.py | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/husqvarna_automower/manifest.json b/homeassistant/components/husqvarna_automower/manifest.json index 45d4df95a04..7f728148be3 100644 --- a/homeassistant/components/husqvarna_automower/manifest.json +++ b/homeassistant/components/husqvarna_automower/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_push", "loggers": ["aioautomower"], "quality_scale": "silver", - "requirements": ["aioautomower==2025.3.1"] + "requirements": ["aioautomower==2025.3.2"] } diff --git a/homeassistant/components/husqvarna_automower/number.py b/homeassistant/components/husqvarna_automower/number.py index cdcf4b45a2d..9ed00113d4b 100644 --- a/homeassistant/components/husqvarna_automower/number.py +++ b/homeassistant/components/husqvarna_automower/number.py @@ -44,7 +44,7 @@ async def async_set_work_area_cutting_height( ) -> None: """Set cutting height for work area.""" await coordinator.api.commands.workarea_settings( - mower_id, int(cheight), work_area_id + mower_id, work_area_id, cutting_height=int(cheight) ) diff --git a/requirements_all.txt b/requirements_all.txt index 54803bb32a2..75deb539c48 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -201,7 +201,7 @@ aioaseko==1.0.0 aioasuswrt==1.4.0 # homeassistant.components.husqvarna_automower -aioautomower==2025.3.1 +aioautomower==2025.3.2 # homeassistant.components.azure_devops aioazuredevops==2.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3a4b517f015..e04b55e4c08 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -189,7 +189,7 @@ aioaseko==1.0.0 aioasuswrt==1.4.0 # homeassistant.components.husqvarna_automower -aioautomower==2025.3.1 +aioautomower==2025.3.2 # homeassistant.components.azure_devops aioazuredevops==2.2.1 diff --git a/tests/components/husqvarna_automower/test_number.py b/tests/components/husqvarna_automower/test_number.py index 55bf5dda7eb..814846ae1c6 100644 --- a/tests/components/husqvarna_automower/test_number.py +++ b/tests/components/husqvarna_automower/test_number.py @@ -79,7 +79,7 @@ async def test_number_workarea_commands( freezer.tick(timedelta(seconds=EXECUTION_TIME_DELAY)) async_fire_time_changed(hass) await hass.async_block_till_done() - mocked_method.assert_called_once_with(TEST_MOWER_ID, 75, 123456) + mocked_method.assert_called_once_with(TEST_MOWER_ID, 123456, cutting_height=75) state = hass.states.get(entity_id) assert state.state is not None assert state.state == "75" From 693de289a2db4dcad756854f459551521756067c Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 23 Mar 2025 23:03:58 +0100 Subject: [PATCH 2933/3148] Fix descriptions of `virtualkey` and `set_variable_value` actions (#141175) - fix the broken grammar ("presses" vs. "simulate") in the description of the `virtualkey` action by using the wording from the online docs instead - fix the wrong description of the `set_variable_value` action by replacing it with the right one from the online docs --- homeassistant/components/homematic/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/homematic/strings.json b/homeassistant/components/homematic/strings.json index d962a218a4f..78159189db8 100644 --- a/homeassistant/components/homematic/strings.json +++ b/homeassistant/components/homematic/strings.json @@ -2,7 +2,7 @@ "services": { "virtualkey": { "name": "Virtual key", - "description": "Presses a virtual key from CCU/Homegear or simulate keypress.", + "description": "Simulates a keypress (or other valid action) on CCU/Homegear with virtual or device keys.", "fields": { "address": { "name": "Address", @@ -24,7 +24,7 @@ }, "set_variable_value": { "name": "Set variable value", - "description": "Sets the name of a node.", + "description": "Sets the value of a system variable.", "fields": { "entity_id": { "name": "Entity", From 174515d1974dc1693fcf7a33ba07df04a00bfd81 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 23 Mar 2025 23:18:40 +0100 Subject: [PATCH 2934/3148] Use common translation string in SmartThings (#141250) --- homeassistant/components/smartthings/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index 7f6e13ab3ba..e4bc11ed5f6 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -51,7 +51,7 @@ }, "button": { "stop": { - "name": "Stop" + "name": "[%key:common::action::stop%]" } }, "event": { From af96fedc0f6a06f38891d2b74cd18623bf2e0dd2 Mon Sep 17 00:00:00 2001 From: tdfountain <174762217+tdfountain@users.noreply.github.com> Date: Sun, 23 Mar 2025 18:58:41 -0700 Subject: [PATCH 2935/3148] Alphabetize key lists for strings, icons and sensors in NUT (#141254) --- homeassistant/components/nut/icons.json | 10 +- homeassistant/components/nut/sensor.py | 1584 ++++++++++----------- homeassistant/components/nut/strings.json | 80 +- 3 files changed, 834 insertions(+), 840 deletions(-) diff --git a/homeassistant/components/nut/icons.json b/homeassistant/components/nut/icons.json index bfa4703d65e..c98d80ef55d 100644 --- a/homeassistant/components/nut/icons.json +++ b/homeassistant/components/nut/icons.json @@ -1,5 +1,10 @@ { "entity": { + "button": { + "outlet_number_load_cycle": { + "default": "mdi:restart" + } + }, "sensor": { "ambient_humidity_status": { "default": "mdi:information-outline" @@ -152,11 +157,6 @@ "default": "mdi:information-outline" } }, - "button": { - "outlet_number_load_cycle": { - "default": "mdi:restart" - } - }, "switch": { "outlet_number_load_poweronoff": { "default": "mdi:power" diff --git a/homeassistant/components/nut/sensor.py b/homeassistant/components/nut/sensor.py index 5ddff5221d2..5c01314dedf 100644 --- a/homeassistant/components/nut/sensor.py +++ b/homeassistant/components/nut/sensor.py @@ -52,51 +52,751 @@ _LOGGER = logging.getLogger(__name__) SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { - "ups.status.display": SensorEntityDescription( - key="ups.status.display", - translation_key="ups_status_display", + "ambient.humidity": SensorEntityDescription( + key="ambient.humidity", + translation_key="ambient_humidity", + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, ), - "ups.status": SensorEntityDescription( - key="ups.status", - translation_key="ups_status", + "ambient.humidity.status": SensorEntityDescription( + key="ambient.humidity.status", + translation_key="ambient_humidity_status", + device_class=SensorDeviceClass.ENUM, + options=AMBIENT_THRESHOLD_STATUS_OPTIONS, + entity_category=EntityCategory.DIAGNOSTIC, ), - "ups.alarm": SensorEntityDescription( - key="ups.alarm", - translation_key="ups_alarm", + "ambient.temperature": SensorEntityDescription( + key="ambient.temperature", + translation_key="ambient_temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, ), - "ups.temperature": SensorEntityDescription( - key="ups.temperature", - translation_key="ups_temperature", + "ambient.temperature.status": SensorEntityDescription( + key="ambient.temperature.status", + translation_key="ambient_temperature_status", + device_class=SensorDeviceClass.ENUM, + options=AMBIENT_THRESHOLD_STATUS_OPTIONS, + entity_category=EntityCategory.DIAGNOSTIC, + ), + "battery.alarm.threshold": SensorEntityDescription( + key="battery.alarm.threshold", + translation_key="battery_alarm_threshold", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "battery.capacity": SensorEntityDescription( + key="battery.capacity", + translation_key="battery_capacity", + native_unit_of_measurement="Ah", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "battery.charge": SensorEntityDescription( + key="battery.charge", + translation_key="battery_charge", + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.BATTERY, + state_class=SensorStateClass.MEASUREMENT, + ), + "battery.charge.low": SensorEntityDescription( + key="battery.charge.low", + translation_key="battery_charge_low", + native_unit_of_measurement=PERCENTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "battery.charge.restart": SensorEntityDescription( + key="battery.charge.restart", + translation_key="battery_charge_restart", + native_unit_of_measurement=PERCENTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "battery.charge.warning": SensorEntityDescription( + key="battery.charge.warning", + translation_key="battery_charge_warning", + native_unit_of_measurement=PERCENTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "battery.charger.status": SensorEntityDescription( + key="battery.charger.status", + translation_key="battery_charger_status", + ), + "battery.current": SensorEntityDescription( + key="battery.current", + translation_key="battery_current", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "battery.current.total": SensorEntityDescription( + key="battery.current.total", + translation_key="battery_current_total", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "battery.date": SensorEntityDescription( + key="battery.date", + translation_key="battery_date", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "battery.mfr.date": SensorEntityDescription( + key="battery.mfr.date", + translation_key="battery_mfr_date", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "battery.packs": SensorEntityDescription( + key="battery.packs", + translation_key="battery_packs", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "battery.packs.bad": SensorEntityDescription( + key="battery.packs.bad", + translation_key="battery_packs_bad", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "battery.runtime": SensorEntityDescription( + key="battery.runtime", + translation_key="battery_runtime", + native_unit_of_measurement=UnitOfTime.SECONDS, + device_class=SensorDeviceClass.DURATION, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "battery.runtime.low": SensorEntityDescription( + key="battery.runtime.low", + translation_key="battery_runtime_low", + native_unit_of_measurement=UnitOfTime.SECONDS, + device_class=SensorDeviceClass.DURATION, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "battery.runtime.restart": SensorEntityDescription( + key="battery.runtime.restart", + translation_key="battery_runtime_restart", + native_unit_of_measurement=UnitOfTime.SECONDS, + device_class=SensorDeviceClass.DURATION, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "battery.temperature": SensorEntityDescription( + key="battery.temperature", + translation_key="battery_temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), - "ups.load": SensorEntityDescription( - key="ups.load", - translation_key="ups_load", + "battery.type": SensorEntityDescription( + key="battery.type", + translation_key="battery_type", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "battery.voltage": SensorEntityDescription( + key="battery.voltage", + translation_key="battery_voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "battery.voltage.high": SensorEntityDescription( + key="battery.voltage.high", + translation_key="battery_voltage_high", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "battery.voltage.low": SensorEntityDescription( + key="battery.voltage.low", + translation_key="battery_voltage_low", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "battery.voltage.nominal": SensorEntityDescription( + key="battery.voltage.nominal", + translation_key="battery_voltage_nominal", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.bypass.current": SensorEntityDescription( + key="input.bypass.current", + translation_key="input_bypass_current", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.bypass.frequency": SensorEntityDescription( + key="input.bypass.frequency", + translation_key="input_bypass_frequency", + native_unit_of_measurement=UnitOfFrequency.HERTZ, + device_class=SensorDeviceClass.FREQUENCY, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.bypass.L1.current": SensorEntityDescription( + key="input.bypass.L1.current", + translation_key="input_bypass_l1_current", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.bypass.L1-N.voltage": SensorEntityDescription( + key="input.bypass.L1-N.voltage", + translation_key="input_bypass_l1_n_voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.bypass.L1.realpower": SensorEntityDescription( + key="input.bypass.L1.realpower", + translation_key="input_bypass_l1_realpower", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.bypass.L2.current": SensorEntityDescription( + key="input.bypass.L2.current", + translation_key="input_bypass_l2_current", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.bypass.L2-N.voltage": SensorEntityDescription( + key="input.bypass.L2-N.voltage", + translation_key="input_bypass_l2_n_voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.bypass.L2.realpower": SensorEntityDescription( + key="input.bypass.L2.realpower", + translation_key="input_bypass_l2_realpower", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.bypass.L3.current": SensorEntityDescription( + key="input.bypass.L3.current", + translation_key="input_bypass_l3_current", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.bypass.L3-N.voltage": SensorEntityDescription( + key="input.bypass.L3-N.voltage", + translation_key="input_bypass_l3_n_voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.bypass.L3.realpower": SensorEntityDescription( + key="input.bypass.L3.realpower", + translation_key="input_bypass_l3_realpower", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.bypass.phases": SensorEntityDescription( + key="input.bypass.phases", + translation_key="input_bypass_phases", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.bypass.realpower": SensorEntityDescription( + key="input.bypass.realpower", + translation_key="input_bypass_realpower", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.bypass.voltage": SensorEntityDescription( + key="input.bypass.voltage", + translation_key="input_bypass_voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.current": SensorEntityDescription( + key="input.current", + translation_key="input_current", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + "input.current.status": SensorEntityDescription( + key="input.current.status", + translation_key="input_current_status", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.frequency": SensorEntityDescription( + key="input.frequency", + translation_key="input_frequency", + native_unit_of_measurement=UnitOfFrequency.HERTZ, + device_class=SensorDeviceClass.FREQUENCY, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.frequency.nominal": SensorEntityDescription( + key="input.frequency.nominal", + translation_key="input_frequency_nominal", + native_unit_of_measurement=UnitOfFrequency.HERTZ, + device_class=SensorDeviceClass.FREQUENCY, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.frequency.status": SensorEntityDescription( + key="input.frequency.status", + translation_key="input_frequency_status", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.L1.current": SensorEntityDescription( + key="input.L1.current", + translation_key="input_l1_current", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.L1.frequency": SensorEntityDescription( + key="input.L1.frequency", + translation_key="input_l1_frequency", + native_unit_of_measurement=UnitOfFrequency.HERTZ, + device_class=SensorDeviceClass.FREQUENCY, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.L1-N.voltage": SensorEntityDescription( + key="input.L1-N.voltage", + translation_key="input_l1_n_voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.L1.realpower": SensorEntityDescription( + key="input.L1.realpower", + translation_key="input_l1_realpower", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.L2.current": SensorEntityDescription( + key="input.L2.current", + translation_key="input_l2_current", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.L2.frequency": SensorEntityDescription( + key="input.L2.frequency", + translation_key="input_l2_frequency", + native_unit_of_measurement=UnitOfFrequency.HERTZ, + device_class=SensorDeviceClass.FREQUENCY, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.L2-N.voltage": SensorEntityDescription( + key="input.L2-N.voltage", + translation_key="input_l2_n_voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.L2.realpower": SensorEntityDescription( + key="input.L2.realpower", + translation_key="input_l2_realpower", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.L3.current": SensorEntityDescription( + key="input.L3.current", + translation_key="input_l3_current", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.L3.frequency": SensorEntityDescription( + key="input.L3.frequency", + translation_key="input_l3_frequency", + native_unit_of_measurement=UnitOfFrequency.HERTZ, + device_class=SensorDeviceClass.FREQUENCY, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.L3-N.voltage": SensorEntityDescription( + key="input.L3-N.voltage", + translation_key="input_l3_n_voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.L3.realpower": SensorEntityDescription( + key="input.L3.realpower", + translation_key="input_l3_realpower", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.load": SensorEntityDescription( + key="input.load", + translation_key="input_load", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, ), - "ups.load.high": SensorEntityDescription( - key="ups.load.high", - translation_key="ups_load_high", + "input.phases": SensorEntityDescription( + key="input.phases", + translation_key="input_phases", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.power": SensorEntityDescription( + key="input.power", + translation_key="input_power", + device_class=SensorDeviceClass.APPARENT_POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.realpower": SensorEntityDescription( + key="input.realpower", + translation_key="input_realpower", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.sensitivity": SensorEntityDescription( + key="input.sensitivity", + translation_key="input_sensitivity", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.transfer.high": SensorEntityDescription( + key="input.transfer.high", + translation_key="input_transfer_high", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.transfer.low": SensorEntityDescription( + key="input.transfer.low", + translation_key="input_transfer_low", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.transfer.reason": SensorEntityDescription( + key="input.transfer.reason", + translation_key="input_transfer_reason", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.voltage": SensorEntityDescription( + key="input.voltage", + translation_key="input_voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + ), + "input.voltage.nominal": SensorEntityDescription( + key="input.voltage.nominal", + translation_key="input_voltage_nominal", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.voltage.status": SensorEntityDescription( + key="input.voltage.status", + translation_key="input_voltage_status", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "outlet.voltage": SensorEntityDescription( + key="outlet.voltage", + translation_key="outlet_voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + ), + "output.current": SensorEntityDescription( + key="output.current", + translation_key="output_current", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "output.current.nominal": SensorEntityDescription( + key="output.current.nominal", + translation_key="output_current_nominal", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "output.frequency": SensorEntityDescription( + key="output.frequency", + translation_key="output_frequency", + native_unit_of_measurement=UnitOfFrequency.HERTZ, + device_class=SensorDeviceClass.FREQUENCY, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "output.frequency.nominal": SensorEntityDescription( + key="output.frequency.nominal", + translation_key="output_frequency_nominal", + native_unit_of_measurement=UnitOfFrequency.HERTZ, + device_class=SensorDeviceClass.FREQUENCY, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "output.L1.current": SensorEntityDescription( + key="output.L1.current", + translation_key="output_l1_current", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "output.L1-N.voltage": SensorEntityDescription( + key="output.L1-N.voltage", + translation_key="output_l1_n_voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "output.L1.power.percent": SensorEntityDescription( + key="output.L1.power.percent", + translation_key="output_l1_power_percent", native_unit_of_measurement=PERCENTAGE, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), - "ups.id": SensorEntityDescription( - key="ups.id", - translation_key="ups_id", + "output.L1.realpower": SensorEntityDescription( + key="output.L1.realpower", + translation_key="output_l1_realpower", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), - "ups.delay.start": SensorEntityDescription( - key="ups.delay.start", - translation_key="ups_delay_start", - native_unit_of_measurement=UnitOfTime.SECONDS, - device_class=SensorDeviceClass.DURATION, + "output.L2.current": SensorEntityDescription( + key="output.L2.current", + translation_key="output_l2_current", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "output.L2-N.voltage": SensorEntityDescription( + key="output.L2-N.voltage", + translation_key="output_l2_n_voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "output.L2.power.percent": SensorEntityDescription( + key="output.L2.power.percent", + translation_key="output_l2_power_percent", + native_unit_of_measurement=PERCENTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "output.L2.realpower": SensorEntityDescription( + key="output.L2.realpower", + translation_key="output_l2_realpower", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "output.L3.current": SensorEntityDescription( + key="output.L3.current", + translation_key="output_l3_current", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "output.L3-N.voltage": SensorEntityDescription( + key="output.L3-N.voltage", + translation_key="output_l3_n_voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "output.L3.power.percent": SensorEntityDescription( + key="output.L3.power.percent", + translation_key="output_l3_power_percent", + native_unit_of_measurement=PERCENTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "output.L3.realpower": SensorEntityDescription( + key="output.L3.realpower", + translation_key="output_l3_realpower", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "output.phases": SensorEntityDescription( + key="output.phases", + translation_key="output_phases", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "output.power": SensorEntityDescription( + key="output.power", + translation_key="output_power", + native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE, + device_class=SensorDeviceClass.APPARENT_POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "output.power.nominal": SensorEntityDescription( + key="output.power.nominal", + translation_key="output_power_nominal", + native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE, + device_class=SensorDeviceClass.APPARENT_POWER, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "output.realpower": SensorEntityDescription( + key="output.realpower", + translation_key="output_realpower", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "output.realpower.nominal": SensorEntityDescription( + key="output.realpower.nominal", + translation_key="output_realpower_nominal", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "output.voltage": SensorEntityDescription( + key="output.voltage", + translation_key="output_voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + ), + "output.voltage.nominal": SensorEntityDescription( + key="output.voltage.nominal", + translation_key="output_voltage_nominal", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "ups.alarm": SensorEntityDescription( + key="ups.alarm", + translation_key="ups_alarm", + ), + "ups.beeper.status": SensorEntityDescription( + key="ups.beeper.status", + translation_key="ups_beeper_status", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "ups.contacts": SensorEntityDescription( + key="ups.contacts", + translation_key="ups_contacts", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), @@ -116,62 +816,20 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), - "ups.timer.start": SensorEntityDescription( - key="ups.timer.start", - translation_key="ups_timer_start", + "ups.delay.start": SensorEntityDescription( + key="ups.delay.start", + translation_key="ups_delay_start", native_unit_of_measurement=UnitOfTime.SECONDS, device_class=SensorDeviceClass.DURATION, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), - "ups.timer.reboot": SensorEntityDescription( - key="ups.timer.reboot", - translation_key="ups_timer_reboot", - native_unit_of_measurement=UnitOfTime.SECONDS, - device_class=SensorDeviceClass.DURATION, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "ups.timer.shutdown": SensorEntityDescription( - key="ups.timer.shutdown", - translation_key="ups_timer_shutdown", - native_unit_of_measurement=UnitOfTime.SECONDS, - device_class=SensorDeviceClass.DURATION, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "ups.test.interval": SensorEntityDescription( - key="ups.test.interval", - translation_key="ups_test_interval", - native_unit_of_measurement=UnitOfTime.SECONDS, - device_class=SensorDeviceClass.DURATION, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "ups.test.result": SensorEntityDescription( - key="ups.test.result", - translation_key="ups_test_result", - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "ups.test.date": SensorEntityDescription( - key="ups.test.date", - translation_key="ups_test_date", - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), "ups.display.language": SensorEntityDescription( key="ups.display.language", translation_key="ups_display_language", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), - "ups.contacts": SensorEntityDescription( - key="ups.contacts", - translation_key="ups_contacts", - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), "ups.efficiency": SensorEntityDescription( key="ups.efficiency", translation_key="ups_efficiency", @@ -180,6 +838,25 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), + "ups.id": SensorEntityDescription( + key="ups.id", + translation_key="ups_id", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "ups.load": SensorEntityDescription( + key="ups.load", + translation_key="ups_load", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + ), + "ups.load.high": SensorEntityDescription( + key="ups.load.high", + translation_key="ups_load_high", + native_unit_of_measurement=PERCENTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), "ups.power": SensorEntityDescription( key="ups.power", translation_key="ups_power", @@ -214,21 +891,9 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), - "ups.beeper.status": SensorEntityDescription( - key="ups.beeper.status", - translation_key="ups_beeper_status", - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "ups.type": SensorEntityDescription( - key="ups.type", - translation_key="ups_type", - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "ups.watchdog.status": SensorEntityDescription( - key="ups.watchdog.status", - translation_key="ups_watchdog_status", + "ups.shutdown": SensorEntityDescription( + key="ups.shutdown", + translation_key="ups_shutdown", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), @@ -250,744 +915,79 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), - "ups.shutdown": SensorEntityDescription( - key="ups.shutdown", - translation_key="ups_shutdown", - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, + "ups.status": SensorEntityDescription( + key="ups.status", + translation_key="ups_status", ), - "battery.charge": SensorEntityDescription( - key="battery.charge", - translation_key="battery_charge", - native_unit_of_measurement=PERCENTAGE, - device_class=SensorDeviceClass.BATTERY, - state_class=SensorStateClass.MEASUREMENT, + "ups.status.display": SensorEntityDescription( + key="ups.status.display", + translation_key="ups_status_display", ), - "battery.charge.low": SensorEntityDescription( - key="battery.charge.low", - translation_key="battery_charge_low", - native_unit_of_measurement=PERCENTAGE, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "battery.charge.restart": SensorEntityDescription( - key="battery.charge.restart", - translation_key="battery_charge_restart", - native_unit_of_measurement=PERCENTAGE, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "battery.charge.warning": SensorEntityDescription( - key="battery.charge.warning", - translation_key="battery_charge_warning", - native_unit_of_measurement=PERCENTAGE, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "battery.charger.status": SensorEntityDescription( - key="battery.charger.status", - translation_key="battery_charger_status", - ), - "battery.voltage": SensorEntityDescription( - key="battery.voltage", - translation_key="battery_voltage", - native_unit_of_measurement=UnitOfElectricPotential.VOLT, - device_class=SensorDeviceClass.VOLTAGE, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "battery.voltage.nominal": SensorEntityDescription( - key="battery.voltage.nominal", - translation_key="battery_voltage_nominal", - native_unit_of_measurement=UnitOfElectricPotential.VOLT, - device_class=SensorDeviceClass.VOLTAGE, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "battery.voltage.low": SensorEntityDescription( - key="battery.voltage.low", - translation_key="battery_voltage_low", - native_unit_of_measurement=UnitOfElectricPotential.VOLT, - device_class=SensorDeviceClass.VOLTAGE, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "battery.voltage.high": SensorEntityDescription( - key="battery.voltage.high", - translation_key="battery_voltage_high", - native_unit_of_measurement=UnitOfElectricPotential.VOLT, - device_class=SensorDeviceClass.VOLTAGE, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "battery.capacity": SensorEntityDescription( - key="battery.capacity", - translation_key="battery_capacity", - native_unit_of_measurement="Ah", - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "battery.current": SensorEntityDescription( - key="battery.current", - translation_key="battery_current", - native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, - device_class=SensorDeviceClass.CURRENT, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "battery.current.total": SensorEntityDescription( - key="battery.current.total", - translation_key="battery_current_total", - native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, - device_class=SensorDeviceClass.CURRENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "battery.temperature": SensorEntityDescription( - key="battery.temperature", - translation_key="battery_temperature", + "ups.temperature": SensorEntityDescription( + key="ups.temperature", + translation_key="ups_temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), - "battery.runtime": SensorEntityDescription( - key="battery.runtime", - translation_key="battery_runtime", + "ups.test.date": SensorEntityDescription( + key="ups.test.date", + translation_key="ups_test_date", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "ups.test.interval": SensorEntityDescription( + key="ups.test.interval", + translation_key="ups_test_interval", native_unit_of_measurement=UnitOfTime.SECONDS, device_class=SensorDeviceClass.DURATION, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), - "battery.runtime.low": SensorEntityDescription( - key="battery.runtime.low", - translation_key="battery_runtime_low", + "ups.test.result": SensorEntityDescription( + key="ups.test.result", + translation_key="ups_test_result", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "ups.timer.reboot": SensorEntityDescription( + key="ups.timer.reboot", + translation_key="ups_timer_reboot", native_unit_of_measurement=UnitOfTime.SECONDS, device_class=SensorDeviceClass.DURATION, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), - "battery.runtime.restart": SensorEntityDescription( - key="battery.runtime.restart", - translation_key="battery_runtime_restart", + "ups.timer.shutdown": SensorEntityDescription( + key="ups.timer.shutdown", + translation_key="ups_timer_shutdown", native_unit_of_measurement=UnitOfTime.SECONDS, device_class=SensorDeviceClass.DURATION, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), - "battery.alarm.threshold": SensorEntityDescription( - key="battery.alarm.threshold", - translation_key="battery_alarm_threshold", + "ups.timer.start": SensorEntityDescription( + key="ups.timer.start", + translation_key="ups_timer_start", + native_unit_of_measurement=UnitOfTime.SECONDS, + device_class=SensorDeviceClass.DURATION, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), - "battery.date": SensorEntityDescription( - key="battery.date", - translation_key="battery_date", + "ups.type": SensorEntityDescription( + key="ups.type", + translation_key="ups_type", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), - "battery.mfr.date": SensorEntityDescription( - key="battery.mfr.date", - translation_key="battery_mfr_date", + "ups.watchdog.status": SensorEntityDescription( + key="ups.watchdog.status", + translation_key="ups_watchdog_status", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), - "battery.packs": SensorEntityDescription( - key="battery.packs", - translation_key="battery_packs", - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "battery.packs.bad": SensorEntityDescription( - key="battery.packs.bad", - translation_key="battery_packs_bad", - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "battery.type": SensorEntityDescription( - key="battery.type", - translation_key="battery_type", - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "input.sensitivity": SensorEntityDescription( - key="input.sensitivity", - translation_key="input_sensitivity", - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "input.transfer.low": SensorEntityDescription( - key="input.transfer.low", - translation_key="input_transfer_low", - native_unit_of_measurement=UnitOfElectricPotential.VOLT, - device_class=SensorDeviceClass.VOLTAGE, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "input.transfer.high": SensorEntityDescription( - key="input.transfer.high", - translation_key="input_transfer_high", - native_unit_of_measurement=UnitOfElectricPotential.VOLT, - device_class=SensorDeviceClass.VOLTAGE, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "input.transfer.reason": SensorEntityDescription( - key="input.transfer.reason", - translation_key="input_transfer_reason", - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "input.voltage": SensorEntityDescription( - key="input.voltage", - translation_key="input_voltage", - native_unit_of_measurement=UnitOfElectricPotential.VOLT, - device_class=SensorDeviceClass.VOLTAGE, - state_class=SensorStateClass.MEASUREMENT, - ), - "input.voltage.nominal": SensorEntityDescription( - key="input.voltage.nominal", - translation_key="input_voltage_nominal", - native_unit_of_measurement=UnitOfElectricPotential.VOLT, - device_class=SensorDeviceClass.VOLTAGE, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "input.voltage.status": SensorEntityDescription( - key="input.voltage.status", - translation_key="input_voltage_status", - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "input.L1-N.voltage": SensorEntityDescription( - key="input.L1-N.voltage", - translation_key="input_l1_n_voltage", - native_unit_of_measurement=UnitOfElectricPotential.VOLT, - device_class=SensorDeviceClass.VOLTAGE, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "input.L2-N.voltage": SensorEntityDescription( - key="input.L2-N.voltage", - translation_key="input_l2_n_voltage", - native_unit_of_measurement=UnitOfElectricPotential.VOLT, - device_class=SensorDeviceClass.VOLTAGE, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "input.L3-N.voltage": SensorEntityDescription( - key="input.L3-N.voltage", - translation_key="input_l3_n_voltage", - native_unit_of_measurement=UnitOfElectricPotential.VOLT, - device_class=SensorDeviceClass.VOLTAGE, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "input.frequency": SensorEntityDescription( - key="input.frequency", - translation_key="input_frequency", - native_unit_of_measurement=UnitOfFrequency.HERTZ, - device_class=SensorDeviceClass.FREQUENCY, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "input.frequency.nominal": SensorEntityDescription( - key="input.frequency.nominal", - translation_key="input_frequency_nominal", - native_unit_of_measurement=UnitOfFrequency.HERTZ, - device_class=SensorDeviceClass.FREQUENCY, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "input.frequency.status": SensorEntityDescription( - key="input.frequency.status", - translation_key="input_frequency_status", - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "input.L1.frequency": SensorEntityDescription( - key="input.L1.frequency", - translation_key="input_l1_frequency", - native_unit_of_measurement=UnitOfFrequency.HERTZ, - device_class=SensorDeviceClass.FREQUENCY, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "input.L2.frequency": SensorEntityDescription( - key="input.L2.frequency", - translation_key="input_l2_frequency", - native_unit_of_measurement=UnitOfFrequency.HERTZ, - device_class=SensorDeviceClass.FREQUENCY, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "input.L3.frequency": SensorEntityDescription( - key="input.L3.frequency", - translation_key="input_l3_frequency", - native_unit_of_measurement=UnitOfFrequency.HERTZ, - device_class=SensorDeviceClass.FREQUENCY, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "input.bypass.current": SensorEntityDescription( - key="input.bypass.current", - translation_key="input_bypass_current", - native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, - device_class=SensorDeviceClass.CURRENT, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "input.bypass.L1.current": SensorEntityDescription( - key="input.bypass.L1.current", - translation_key="input_bypass_l1_current", - native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, - device_class=SensorDeviceClass.CURRENT, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "input.bypass.L2.current": SensorEntityDescription( - key="input.bypass.L2.current", - translation_key="input_bypass_l2_current", - native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, - device_class=SensorDeviceClass.CURRENT, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "input.bypass.L3.current": SensorEntityDescription( - key="input.bypass.L3.current", - translation_key="input_bypass_l3_current", - native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, - device_class=SensorDeviceClass.CURRENT, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "input.bypass.frequency": SensorEntityDescription( - key="input.bypass.frequency", - translation_key="input_bypass_frequency", - native_unit_of_measurement=UnitOfFrequency.HERTZ, - device_class=SensorDeviceClass.FREQUENCY, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "input.bypass.phases": SensorEntityDescription( - key="input.bypass.phases", - translation_key="input_bypass_phases", - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "input.bypass.realpower": SensorEntityDescription( - key="input.bypass.realpower", - translation_key="input_bypass_realpower", - native_unit_of_measurement=UnitOfPower.WATT, - device_class=SensorDeviceClass.POWER, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "input.bypass.L1.realpower": SensorEntityDescription( - key="input.bypass.L1.realpower", - translation_key="input_bypass_l1_realpower", - native_unit_of_measurement=UnitOfPower.WATT, - device_class=SensorDeviceClass.POWER, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "input.bypass.L2.realpower": SensorEntityDescription( - key="input.bypass.L2.realpower", - translation_key="input_bypass_l2_realpower", - native_unit_of_measurement=UnitOfPower.WATT, - device_class=SensorDeviceClass.POWER, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "input.bypass.L3.realpower": SensorEntityDescription( - key="input.bypass.L3.realpower", - translation_key="input_bypass_l3_realpower", - native_unit_of_measurement=UnitOfPower.WATT, - device_class=SensorDeviceClass.POWER, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "input.bypass.voltage": SensorEntityDescription( - key="input.bypass.voltage", - translation_key="input_bypass_voltage", - native_unit_of_measurement=UnitOfElectricPotential.VOLT, - device_class=SensorDeviceClass.VOLTAGE, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "input.bypass.L1-N.voltage": SensorEntityDescription( - key="input.bypass.L1-N.voltage", - translation_key="input_bypass_l1_n_voltage", - native_unit_of_measurement=UnitOfElectricPotential.VOLT, - device_class=SensorDeviceClass.VOLTAGE, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "input.bypass.L2-N.voltage": SensorEntityDescription( - key="input.bypass.L2-N.voltage", - translation_key="input_bypass_l2_n_voltage", - native_unit_of_measurement=UnitOfElectricPotential.VOLT, - device_class=SensorDeviceClass.VOLTAGE, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "input.bypass.L3-N.voltage": SensorEntityDescription( - key="input.bypass.L3-N.voltage", - translation_key="input_bypass_l3_n_voltage", - native_unit_of_measurement=UnitOfElectricPotential.VOLT, - device_class=SensorDeviceClass.VOLTAGE, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "input.current": SensorEntityDescription( - key="input.current", - translation_key="input_current", - native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, - device_class=SensorDeviceClass.CURRENT, - state_class=SensorStateClass.MEASUREMENT, - entity_registry_enabled_default=False, - ), - "input.current.status": SensorEntityDescription( - key="input.current.status", - translation_key="input_current_status", - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "input.L1.current": SensorEntityDescription( - key="input.L1.current", - translation_key="input_l1_current", - native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, - device_class=SensorDeviceClass.CURRENT, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "input.L2.current": SensorEntityDescription( - key="input.L2.current", - translation_key="input_l2_current", - native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, - device_class=SensorDeviceClass.CURRENT, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "input.L3.current": SensorEntityDescription( - key="input.L3.current", - translation_key="input_l3_current", - native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, - device_class=SensorDeviceClass.CURRENT, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "input.load": SensorEntityDescription( - key="input.load", - translation_key="input_load", - native_unit_of_measurement=PERCENTAGE, - state_class=SensorStateClass.MEASUREMENT, - ), - "input.phases": SensorEntityDescription( - key="input.phases", - translation_key="input_phases", - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "input.power": SensorEntityDescription( - key="input.power", - translation_key="input_power", - device_class=SensorDeviceClass.APPARENT_POWER, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "input.realpower": SensorEntityDescription( - key="input.realpower", - translation_key="input_realpower", - native_unit_of_measurement=UnitOfPower.WATT, - device_class=SensorDeviceClass.POWER, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "input.L1.realpower": SensorEntityDescription( - key="input.L1.realpower", - translation_key="input_l1_realpower", - native_unit_of_measurement=UnitOfPower.WATT, - device_class=SensorDeviceClass.POWER, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "input.L2.realpower": SensorEntityDescription( - key="input.L2.realpower", - translation_key="input_l2_realpower", - native_unit_of_measurement=UnitOfPower.WATT, - device_class=SensorDeviceClass.POWER, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "input.L3.realpower": SensorEntityDescription( - key="input.L3.realpower", - translation_key="input_l3_realpower", - native_unit_of_measurement=UnitOfPower.WATT, - device_class=SensorDeviceClass.POWER, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "outlet.voltage": SensorEntityDescription( - key="outlet.voltage", - translation_key="outlet_voltage", - native_unit_of_measurement=UnitOfElectricPotential.VOLT, - device_class=SensorDeviceClass.VOLTAGE, - state_class=SensorStateClass.MEASUREMENT, - ), - "output.power.nominal": SensorEntityDescription( - key="output.power.nominal", - translation_key="output_power_nominal", - native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE, - device_class=SensorDeviceClass.APPARENT_POWER, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "output.L1.power.percent": SensorEntityDescription( - key="output.L1.power.percent", - translation_key="output_l1_power_percent", - native_unit_of_measurement=PERCENTAGE, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "output.L2.power.percent": SensorEntityDescription( - key="output.L2.power.percent", - translation_key="output_l2_power_percent", - native_unit_of_measurement=PERCENTAGE, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "output.L3.power.percent": SensorEntityDescription( - key="output.L3.power.percent", - translation_key="output_l3_power_percent", - native_unit_of_measurement=PERCENTAGE, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "output.current": SensorEntityDescription( - key="output.current", - translation_key="output_current", - native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, - device_class=SensorDeviceClass.CURRENT, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "output.current.nominal": SensorEntityDescription( - key="output.current.nominal", - translation_key="output_current_nominal", - native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, - device_class=SensorDeviceClass.CURRENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "output.L1.current": SensorEntityDescription( - key="output.L1.current", - translation_key="output_l1_current", - native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, - device_class=SensorDeviceClass.CURRENT, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "output.L2.current": SensorEntityDescription( - key="output.L2.current", - translation_key="output_l2_current", - native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, - device_class=SensorDeviceClass.CURRENT, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "output.L3.current": SensorEntityDescription( - key="output.L3.current", - translation_key="output_l3_current", - native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, - device_class=SensorDeviceClass.CURRENT, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "output.voltage": SensorEntityDescription( - key="output.voltage", - translation_key="output_voltage", - native_unit_of_measurement=UnitOfElectricPotential.VOLT, - device_class=SensorDeviceClass.VOLTAGE, - state_class=SensorStateClass.MEASUREMENT, - ), - "output.voltage.nominal": SensorEntityDescription( - key="output.voltage.nominal", - translation_key="output_voltage_nominal", - native_unit_of_measurement=UnitOfElectricPotential.VOLT, - device_class=SensorDeviceClass.VOLTAGE, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "output.L1-N.voltage": SensorEntityDescription( - key="output.L1-N.voltage", - translation_key="output_l1_n_voltage", - native_unit_of_measurement=UnitOfElectricPotential.VOLT, - device_class=SensorDeviceClass.VOLTAGE, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "output.L2-N.voltage": SensorEntityDescription( - key="output.L2-N.voltage", - translation_key="output_l2_n_voltage", - native_unit_of_measurement=UnitOfElectricPotential.VOLT, - device_class=SensorDeviceClass.VOLTAGE, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "output.L3-N.voltage": SensorEntityDescription( - key="output.L3-N.voltage", - translation_key="output_l3_n_voltage", - native_unit_of_measurement=UnitOfElectricPotential.VOLT, - device_class=SensorDeviceClass.VOLTAGE, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "output.frequency": SensorEntityDescription( - key="output.frequency", - translation_key="output_frequency", - native_unit_of_measurement=UnitOfFrequency.HERTZ, - device_class=SensorDeviceClass.FREQUENCY, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "output.frequency.nominal": SensorEntityDescription( - key="output.frequency.nominal", - translation_key="output_frequency_nominal", - native_unit_of_measurement=UnitOfFrequency.HERTZ, - device_class=SensorDeviceClass.FREQUENCY, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "output.phases": SensorEntityDescription( - key="output.phases", - translation_key="output_phases", - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "output.power": SensorEntityDescription( - key="output.power", - translation_key="output_power", - native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE, - device_class=SensorDeviceClass.APPARENT_POWER, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "output.realpower": SensorEntityDescription( - key="output.realpower", - translation_key="output_realpower", - native_unit_of_measurement=UnitOfPower.WATT, - device_class=SensorDeviceClass.POWER, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "output.realpower.nominal": SensorEntityDescription( - key="output.realpower.nominal", - translation_key="output_realpower_nominal", - native_unit_of_measurement=UnitOfPower.WATT, - device_class=SensorDeviceClass.POWER, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "output.L1.realpower": SensorEntityDescription( - key="output.L1.realpower", - translation_key="output_l1_realpower", - native_unit_of_measurement=UnitOfPower.WATT, - device_class=SensorDeviceClass.POWER, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "output.L2.realpower": SensorEntityDescription( - key="output.L2.realpower", - translation_key="output_l2_realpower", - native_unit_of_measurement=UnitOfPower.WATT, - device_class=SensorDeviceClass.POWER, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "output.L3.realpower": SensorEntityDescription( - key="output.L3.realpower", - translation_key="output_l3_realpower", - native_unit_of_measurement=UnitOfPower.WATT, - device_class=SensorDeviceClass.POWER, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - "ambient.humidity": SensorEntityDescription( - key="ambient.humidity", - translation_key="ambient_humidity", - native_unit_of_measurement=PERCENTAGE, - device_class=SensorDeviceClass.HUMIDITY, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - ), - "ambient.humidity.status": SensorEntityDescription( - key="ambient.humidity.status", - translation_key="ambient_humidity_status", - device_class=SensorDeviceClass.ENUM, - options=AMBIENT_THRESHOLD_STATUS_OPTIONS, - entity_category=EntityCategory.DIAGNOSTIC, - ), - "ambient.temperature": SensorEntityDescription( - key="ambient.temperature", - translation_key="ambient_temperature", - native_unit_of_measurement=UnitOfTemperature.CELSIUS, - device_class=SensorDeviceClass.TEMPERATURE, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - ), - "ambient.temperature.status": SensorEntityDescription( - key="ambient.temperature.status", - translation_key="ambient_temperature_status", - device_class=SensorDeviceClass.ENUM, - options=AMBIENT_THRESHOLD_STATUS_OPTIONS, - entity_category=EntityCategory.DIAGNOSTIC, - ), "watts": SensorEntityDescription( key="watts", translation_key="watts", diff --git a/homeassistant/components/nut/strings.json b/homeassistant/components/nut/strings.json index 3ac5f23a0c1..1a54dffef11 100644 --- a/homeassistant/components/nut/strings.json +++ b/homeassistant/components/nut/strings.json @@ -29,8 +29,8 @@ }, "error": { "cannot_connect": "Connection error: {error}", - "unknown": "[%key:common::config_flow::error::unknown%]", - "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" + "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%]", @@ -78,6 +78,9 @@ } }, "entity": { + "button": { + "outlet_number_load_cycle": { "name": "Power cycle outlet {outlet_name}" } + }, "sensor": { "ambient_humidity": { "name": "Ambient humidity" }, "ambient_humidity_status": { "name": "Ambient humidity status" }, @@ -106,43 +109,40 @@ "battery_voltage_low": { "name": "Low battery voltage" }, "battery_voltage_nominal": { "name": "Nominal battery voltage" }, "input_bypass_current": { "name": "Input bypass current" }, - "input_bypass_l1_current": { "name": "Input bypass L1 current" }, - "input_bypass_l2_current": { "name": "Input bypass L2 current" }, - "input_bypass_l3_current": { "name": "Input bypass L3 current" }, - "input_bypass_voltage": { "name": "Input bypass voltage" }, - "input_bypass_l1_n_voltage": { "name": "Input bypass L1-N voltage" }, - "input_bypass_l2_n_voltage": { "name": "Input bypass L2-N voltage" }, - "input_bypass_l3_n_voltage": { "name": "Input bypass L3-N voltage" }, "input_bypass_frequency": { "name": "Input bypass frequency" }, + "input_bypass_l1_current": { "name": "Input bypass L1 current" }, + "input_bypass_l1_n_voltage": { "name": "Input bypass L1-N voltage" }, + "input_bypass_l1_realpower": { "name": "Input bypass L1 real power" }, + "input_bypass_l2_current": { "name": "Input bypass L2 current" }, + "input_bypass_l2_n_voltage": { "name": "Input bypass L2-N voltage" }, + "input_bypass_l2_realpower": { "name": "Input bypass L2 real power" }, + "input_bypass_l3_current": { "name": "Input bypass L3 current" }, + "input_bypass_l3_n_voltage": { "name": "Input bypass L3-N voltage" }, + "input_bypass_l3_realpower": { "name": "Input bypass L3 real power" }, "input_bypass_phases": { "name": "Input bypass phases" }, "input_bypass_realpower": { "name": "Input bypass real power" }, - "input_bypass_l1_realpower": { - "name": "Input bypass L1 real power" - }, - "input_bypass_l2_realpower": { - "name": "Input bypass L2 real power" - }, - "input_bypass_l3_realpower": { - "name": "Input bypass L3 real power" - }, + "input_bypass_voltage": { "name": "Input bypass voltage" }, "input_current": { "name": "Input current" }, "input_current_status": { "name": "Input current status" }, - "input_l1_current": { "name": "Input L1 current" }, - "input_l2_current": { "name": "Input L2 current" }, - "input_l3_current": { "name": "Input L3 current" }, "input_frequency": { "name": "Input frequency" }, "input_frequency_nominal": { "name": "Input nominal frequency" }, "input_frequency_status": { "name": "Input frequency status" }, + "input_l1_current": { "name": "Input L1 current" }, "input_l1_frequency": { "name": "Input L1 line frequency" }, + "input_l1_n_voltage": { "name": "Input L1 voltage" }, + "input_l1_realpower": { "name": "Input L1 real power" }, + "input_l2_current": { "name": "Input L2 current" }, "input_l2_frequency": { "name": "Input L2 line frequency" }, + "input_l2_n_voltage": { "name": "Input L2 voltage" }, + "input_l2_realpower": { "name": "Input L2 real power" }, + "input_l3_current": { "name": "Input L3 current" }, "input_l3_frequency": { "name": "Input L3 line frequency" }, + "input_l3_n_voltage": { "name": "Input L3 voltage" }, + "input_l3_realpower": { "name": "Input L3 real power" }, + "input_load": { "name": "Input load" }, "input_phases": { "name": "Input phases" }, "input_power": { "name": "Input power" }, "input_realpower": { "name": "Input real power" }, - "input_l1_realpower": { "name": "Input L1 real power" }, - "input_l2_realpower": { "name": "Input L2 real power" }, - "input_l3_realpower": { "name": "Input L3 real power" }, - "input_load": { "name": "Input load" }, "input_sensitivity": { "name": "Input power sensitivity" }, "input_transfer_high": { "name": "High voltage transfer" }, "input_transfer_low": { "name": "Low voltage transfer" }, @@ -150,9 +150,6 @@ "input_voltage": { "name": "Input voltage" }, "input_voltage_nominal": { "name": "Nominal input voltage" }, "input_voltage_status": { "name": "Input voltage status" }, - "input_l1_n_voltage": { "name": "Input L1 voltage" }, - "input_l2_n_voltage": { "name": "Input L2 voltage" }, - "input_l3_n_voltage": { "name": "Input L3 voltage" }, "outlet_number_current": { "name": "Outlet {outlet_name} current" }, "outlet_number_current_status": { "name": "Outlet {outlet_name} current status" @@ -163,27 +160,27 @@ "outlet_voltage": { "name": "Outlet voltage" }, "output_current": { "name": "Output current" }, "output_current_nominal": { "name": "Nominal output current" }, - "output_l1_current": { "name": "Output L1 current" }, - "output_l2_current": { "name": "Output L2 current" }, - "output_l3_current": { "name": "Output L3 current" }, "output_frequency": { "name": "Output frequency" }, "output_frequency_nominal": { "name": "Nominal output frequency" }, + "output_l1_current": { "name": "Output L1 current" }, + "output_l1_n_voltage": { "name": "Output L1-N voltage" }, + "output_l1_power_percent": { "name": "Output L1 power usage" }, + "output_l1_realpower": { "name": "Output L1 real power" }, + "output_l2_current": { "name": "Output L2 current" }, + "output_l2_n_voltage": { "name": "Output L2-N voltage" }, + "output_l2_power_percent": { "name": "Output L2 power usage" }, + "output_l2_realpower": { "name": "Output L2 real power" }, + "output_l3_current": { "name": "Output L3 current" }, + "output_l3_n_voltage": { "name": "Output L3-N voltage" }, + "output_l3_power_percent": { "name": "Output L3 power usage" }, + "output_l3_realpower": { "name": "Output L3 real power" }, "output_phases": { "name": "Output phases" }, "output_power": { "name": "Output apparent power" }, - "output_l2_power_percent": { "name": "Output L2 power usage" }, - "output_l1_power_percent": { "name": "Output L1 power usage" }, - "output_l3_power_percent": { "name": "Output L3 power usage" }, "output_power_nominal": { "name": "Nominal output power" }, "output_realpower": { "name": "Output real power" }, "output_realpower_nominal": { "name": "Nominal output real power" }, - "output_l1_realpower": { "name": "Output L1 real power" }, - "output_l2_realpower": { "name": "Output L2 real power" }, - "output_l3_realpower": { "name": "Output L3 real power" }, "output_voltage": { "name": "Output voltage" }, "output_voltage_nominal": { "name": "Nominal output voltage" }, - "output_l1_n_voltage": { "name": "Output L1-N voltage" }, - "output_l2_n_voltage": { "name": "Output L2-N voltage" }, - "output_l3_n_voltage": { "name": "Output L3-N voltage" }, "ups_alarm": { "name": "Alarms" }, "ups_beeper_status": { "name": "Beeper status" }, "ups_contacts": { "name": "External contacts" }, @@ -218,9 +215,6 @@ "ups_watchdog_status": { "name": "Watchdog status" }, "watts": { "name": "Watts" } }, - "button": { - "outlet_number_load_cycle": { "name": "Power cycle outlet {outlet_name}" } - }, "switch": { "outlet_number_load_poweronoff": { "name": "Power outlet {outlet_name}" } } From 6a7fa3769db6d33bb1f6cd6673ef83fa66866869 Mon Sep 17 00:00:00 2001 From: Ivan Lopez Hernandez Date: Sun, 23 Mar 2025 22:23:52 -0700 Subject: [PATCH 2936/3148] Add Google Search tool in Google Generative AI (#140772) * Added Google Search grounding * Added testing --- .../config_flow.py | 9 +++ .../const.py | 2 + .../conversation.py | 9 +++ .../strings.json | 3 +- .../conftest.py | 20 ++++++ .../snapshots/test_conversation.ambr | 31 +++++++++ .../test_config_flow.py | 3 + .../test_conversation.py | 66 +++++++++++++++++++ 8 files changed, 142 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/google_generative_ai_conversation/config_flow.py b/homeassistant/components/google_generative_ai_conversation/config_flow.py index 00a016143f4..b413f9c9a62 100644 --- a/homeassistant/components/google_generative_ai_conversation/config_flow.py +++ b/homeassistant/components/google_generative_ai_conversation/config_flow.py @@ -44,6 +44,7 @@ from .const import ( CONF_TEMPERATURE, CONF_TOP_K, CONF_TOP_P, + CONF_USE_GOOGLE_SEARCH_TOOL, DOMAIN, RECOMMENDED_CHAT_MODEL, RECOMMENDED_HARM_BLOCK_THRESHOLD, @@ -51,6 +52,7 @@ from .const import ( RECOMMENDED_TEMPERATURE, RECOMMENDED_TOP_K, RECOMMENDED_TOP_P, + RECOMMENDED_USE_GOOGLE_SEARCH_TOOL, TIMEOUT_MILLIS, ) @@ -341,6 +343,13 @@ async def google_generative_ai_config_option_schema( }, default=RECOMMENDED_HARM_BLOCK_THRESHOLD, ): harm_block_thresholds_selector, + vol.Optional( + CONF_USE_GOOGLE_SEARCH_TOOL, + description={ + "suggested_value": options.get(CONF_USE_GOOGLE_SEARCH_TOOL), + }, + default=RECOMMENDED_USE_GOOGLE_SEARCH_TOOL, + ): bool, } ) return schema diff --git a/homeassistant/components/google_generative_ai_conversation/const.py b/homeassistant/components/google_generative_ai_conversation/const.py index 35834f6e7f9..108ffe1891d 100644 --- a/homeassistant/components/google_generative_ai_conversation/const.py +++ b/homeassistant/components/google_generative_ai_conversation/const.py @@ -22,5 +22,7 @@ CONF_HATE_BLOCK_THRESHOLD = "hate_block_threshold" CONF_SEXUAL_BLOCK_THRESHOLD = "sexual_block_threshold" CONF_DANGEROUS_BLOCK_THRESHOLD = "dangerous_block_threshold" RECOMMENDED_HARM_BLOCK_THRESHOLD = "BLOCK_MEDIUM_AND_ABOVE" +CONF_USE_GOOGLE_SEARCH_TOOL = "enable_google_search_tool" +RECOMMENDED_USE_GOOGLE_SEARCH_TOOL = False TIMEOUT_MILLIS = 10000 diff --git a/homeassistant/components/google_generative_ai_conversation/conversation.py b/homeassistant/components/google_generative_ai_conversation/conversation.py index e35346cc745..36e402a62fe 100644 --- a/homeassistant/components/google_generative_ai_conversation/conversation.py +++ b/homeassistant/components/google_generative_ai_conversation/conversation.py @@ -12,6 +12,7 @@ from google.genai.types import ( Content, FunctionDeclaration, GenerateContentConfig, + GoogleSearch, HarmCategory, Part, SafetySetting, @@ -39,6 +40,7 @@ from .const import ( CONF_TEMPERATURE, CONF_TOP_K, CONF_TOP_P, + CONF_USE_GOOGLE_SEARCH_TOOL, DOMAIN, LOGGER, RECOMMENDED_CHAT_MODEL, @@ -296,6 +298,13 @@ class GoogleGenerativeAIConversationEntity( for tool in chat_log.llm_api.tools ] + # Using search grounding allows the model to retrieve information from the web, + # however, it may interfere with how the model decides to use some tools, or entities + # for example weather entity may be disregarded if the model chooses to Google it. + if options.get(CONF_USE_GOOGLE_SEARCH_TOOL) is True: + tools = tools or [] + tools.append(Tool(google_search=GoogleSearch())) + model_name = self.entry.options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL) # Gemini 1.0 doesn't support system_instruction while 1.5 does. # Assume future versions will support it (if not, the request fails with a diff --git a/homeassistant/components/google_generative_ai_conversation/strings.json b/homeassistant/components/google_generative_ai_conversation/strings.json index 7bf1831a34b..b814f89469a 100644 --- a/homeassistant/components/google_generative_ai_conversation/strings.json +++ b/homeassistant/components/google_generative_ai_conversation/strings.json @@ -36,7 +36,8 @@ "harassment_block_threshold": "Negative or harmful comments targeting identity and/or protected attributes", "hate_block_threshold": "Content that is rude, disrespectful, or profane", "sexual_block_threshold": "Contains references to sexual acts or other lewd content", - "dangerous_block_threshold": "Promotes, facilitates, or encourages harmful acts" + "dangerous_block_threshold": "Promotes, facilitates, or encourages harmful acts", + "enable_google_search_tool": "Enable Google Search tool" }, "data_description": { "prompt": "Instruct how the LLM should respond. This can be a template." diff --git a/tests/components/google_generative_ai_conversation/conftest.py b/tests/components/google_generative_ai_conversation/conftest.py index 2bc81b10ce4..6ec147da2ab 100644 --- a/tests/components/google_generative_ai_conversation/conftest.py +++ b/tests/components/google_generative_ai_conversation/conftest.py @@ -4,6 +4,9 @@ from unittest.mock import Mock, patch import pytest +from homeassistant.components.google_generative_ai_conversation.conversation import ( + CONF_USE_GOOGLE_SEARCH_TOOL, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_LLM_HASS_API from homeassistant.core import HomeAssistant @@ -41,6 +44,23 @@ async def mock_config_entry_with_assist( return mock_config_entry +@pytest.fixture +async def mock_config_entry_with_google_search( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> MockConfigEntry: + """Mock a config entry with assist.""" + with patch("google.genai.models.AsyncModels.get"): + hass.config_entries.async_update_entry( + mock_config_entry, + options={ + CONF_LLM_HASS_API: llm.LLM_API_ASSIST, + CONF_USE_GOOGLE_SEARCH_TOOL: True, + }, + ) + await hass.async_block_till_done() + return mock_config_entry + + @pytest.fixture async def mock_init_component( hass: HomeAssistant, mock_config_entry: ConfigEntry diff --git a/tests/components/google_generative_ai_conversation/snapshots/test_conversation.ambr b/tests/components/google_generative_ai_conversation/snapshots/test_conversation.ambr index c840f7da324..2a20ce37a57 100644 --- a/tests/components/google_generative_ai_conversation/snapshots/test_conversation.ambr +++ b/tests/components/google_generative_ai_conversation/snapshots/test_conversation.ambr @@ -61,3 +61,34 @@ ), ]) # --- +# name: test_use_google_search + list([ + tuple( + '', + tuple( + ), + dict({ + 'config': GenerateContentConfig(http_options=None, system_instruction="Current time is 05:00:00. Today's date is 2024-05-24.\nYou are a voice assistant for Home Assistant.\nAnswer questions about the world truthfully.\nAnswer in plain text. Keep it simple and to the point.\nOnly if the user wants to control a device, tell them to expose entities to their voice assistant in Home Assistant.", temperature=1.0, top_p=0.95, top_k=64.0, candidate_count=None, max_output_tokens=150, stop_sequences=None, response_logprobs=None, logprobs=None, presence_penalty=None, frequency_penalty=None, seed=None, response_mime_type=None, response_schema=None, routing_config=None, safety_settings=[SafetySetting(method=None, category=, threshold=), SafetySetting(method=None, category=, threshold=), SafetySetting(method=None, category=, threshold=), SafetySetting(method=None, category=, threshold=)], tools=[Tool(function_declarations=[FunctionDeclaration(response=None, description='Test function', name='test_tool', parameters=Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=None, max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=, description=None, enum=None, format=None, items=None, properties={'param1': Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=None, max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=, description='Test parameters', enum=None, format=None, items=Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=None, max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=, description=None, enum=None, format=None, items=None, properties=None, required=None), properties=None, required=None), 'param2': Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=None, max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=None, description=None, enum=None, format=None, items=None, properties=None, required=None), 'param3': Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=None, max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=, description=None, enum=None, format=None, items=None, properties={'json': Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=None, max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=, description=None, enum=None, format=None, items=None, properties=None, required=None)}, required=[])}, required=[]))], retrieval=None, google_search=None, google_search_retrieval=None, code_execution=None), Tool(function_declarations=None, retrieval=None, google_search=GoogleSearch(), google_search_retrieval=None, code_execution=None)], tool_config=None, labels=None, cached_content=None, response_modalities=None, media_resolution=None, speech_config=None, audio_timestamp=None, automatic_function_calling=AutomaticFunctionCallingConfig(disable=True, maximum_remote_calls=None, ignore_call_history=None), thinking_config=None), + 'history': list([ + ]), + 'model': 'models/gemini-2.0-flash', + }), + ), + tuple( + '().send_message', + tuple( + ), + dict({ + 'message': 'Please call the test function', + }), + ), + tuple( + '().send_message', + tuple( + ), + dict({ + 'message': Content(parts=[Part(video_metadata=None, thought=None, code_execution_result=None, executable_code=None, file_data=None, function_call=None, function_response=FunctionResponse(id=None, name='test_tool', response={'result': 'Test response'}), inline_data=None, text=None)], role=None), + }), + ), + ]) +# --- diff --git a/tests/components/google_generative_ai_conversation/test_config_flow.py b/tests/components/google_generative_ai_conversation/test_config_flow.py index 30c9d6c46e6..f7635c0b45e 100644 --- a/tests/components/google_generative_ai_conversation/test_config_flow.py +++ b/tests/components/google_generative_ai_conversation/test_config_flow.py @@ -21,12 +21,14 @@ from homeassistant.components.google_generative_ai_conversation.const import ( CONF_TEMPERATURE, CONF_TOP_K, CONF_TOP_P, + CONF_USE_GOOGLE_SEARCH_TOOL, DOMAIN, RECOMMENDED_CHAT_MODEL, RECOMMENDED_HARM_BLOCK_THRESHOLD, RECOMMENDED_MAX_TOKENS, RECOMMENDED_TOP_K, RECOMMENDED_TOP_P, + RECOMMENDED_USE_GOOGLE_SEARCH_TOOL, ) from homeassistant.const import CONF_LLM_HASS_API from homeassistant.core import HomeAssistant @@ -143,6 +145,7 @@ async def test_form(hass: HomeAssistant) -> None: CONF_HATE_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD, CONF_SEXUAL_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD, CONF_DANGEROUS_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD, + CONF_USE_GOOGLE_SEARCH_TOOL: RECOMMENDED_USE_GOOGLE_SEARCH_TOOL, }, ), ( diff --git a/tests/components/google_generative_ai_conversation/test_conversation.py b/tests/components/google_generative_ai_conversation/test_conversation.py index 22bc079a21f..82ad4affaf3 100644 --- a/tests/components/google_generative_ai_conversation/test_conversation.py +++ b/tests/components/google_generative_ai_conversation/test_conversation.py @@ -176,6 +176,72 @@ async def test_function_call( } +@patch( + "homeassistant.components.google_generative_ai_conversation.conversation.llm.AssistAPI._async_get_tools" +) +@pytest.mark.usefixtures("mock_init_component") +@pytest.mark.usefixtures("mock_ulid_tools") +async def test_use_google_search( + mock_get_tools, + hass: HomeAssistant, + mock_config_entry_with_google_search: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test function calling.""" + agent_id = "conversation.google_generative_ai_conversation" + context = Context() + + mock_tool = AsyncMock() + mock_tool.name = "test_tool" + mock_tool.description = "Test function" + mock_tool.parameters = vol.Schema( + { + vol.Optional("param1", description="Test parameters"): [ + vol.All(str, vol.Lower) + ], + vol.Optional("param2"): vol.Any(float, int), + vol.Optional("param3"): dict, + } + ) + + mock_get_tools.return_value = [mock_tool] + + with patch("google.genai.chats.AsyncChats.create") as mock_create: + mock_chat = AsyncMock() + mock_create.return_value.send_message = mock_chat + chat_response = Mock(prompt_feedback=None) + mock_chat.return_value = chat_response + mock_part = Mock() + mock_part.text = "" + mock_part.function_call = FunctionCall( + name="test_tool", + args={ + "param1": ["test_value", "param1\\'s value"], + "param2": 2.7, + }, + ) + + def tool_call( + hass: HomeAssistant, tool_input: llm.ToolInput, tool_context: llm.LLMContext + ) -> dict[str, Any]: + mock_part.function_call = None + mock_part.text = "Hi there!" + return {"result": "Test response"} + + mock_tool.async_call.side_effect = tool_call + chat_response.candidates = [Mock(content=Mock(parts=[mock_part]))] + await conversation.async_converse( + hass, + "Please call the test function", + None, + context, + agent_id=agent_id, + device_id="test_device", + ) + + assert [tuple(mock_call) for mock_call in mock_create.mock_calls] == snapshot + + @patch( "homeassistant.components.google_generative_ai_conversation.conversation.llm.AssistAPI._async_get_tools" ) From d3b8dbb76c4532fad2c499984ccd537e374d9577 Mon Sep 17 00:00:00 2001 From: Mirko Liebender Date: Mon, 24 Mar 2025 06:27:35 +0100 Subject: [PATCH 2937/3148] Google gen ai fix for empty chat log messages (#136019) (#140315) * Google gen ai fix for empty chat log messages (#136019) * Google gen ai test for empty chat history fields (#136019) --- .../conversation.py | 9 +++++ .../test_conversation.py | 38 ++++++++++++++++++- 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/google_generative_ai_conversation/conversation.py b/homeassistant/components/google_generative_ai_conversation/conversation.py index 36e402a62fe..cca5f2410bd 100644 --- a/homeassistant/components/google_generative_ai_conversation/conversation.py +++ b/homeassistant/components/google_generative_ai_conversation/conversation.py @@ -4,6 +4,7 @@ from __future__ import annotations import codecs from collections.abc import Callable +from dataclasses import replace from typing import Any, Literal, cast from google.genai.errors import APIError @@ -333,6 +334,14 @@ class GoogleGenerativeAIConversationEntity( tool_results.append(chat_content) continue + if ( + not isinstance(chat_content, conversation.ToolResultContent) + and chat_content.content == "" + ): + # Skipping is not possible since the number of function calls need to match the number of function responses + # and skipping one would mean removing the other and hence this would prevent a proper chat log + chat_content = replace(chat_content, content=" ") + if tool_results: messages.append(_create_google_tool_response_content(tool_results)) tool_results.clear() diff --git a/tests/components/google_generative_ai_conversation/test_conversation.py b/tests/components/google_generative_ai_conversation/test_conversation.py index 82ad4affaf3..bdf1c01fd31 100644 --- a/tests/components/google_generative_ai_conversation/test_conversation.py +++ b/tests/components/google_generative_ai_conversation/test_conversation.py @@ -10,7 +10,7 @@ from syrupy.assertion import SnapshotAssertion import voluptuous as vol from homeassistant.components import conversation -from homeassistant.components.conversation import trace +from homeassistant.components.conversation import UserContent, async_get_chat_log, trace from homeassistant.components.google_generative_ai_conversation.conversation import ( _escape_decode, _format_schema, @@ -18,7 +18,7 @@ from homeassistant.components.google_generative_ai_conversation.conversation imp from homeassistant.const import CONF_LLM_HASS_API from homeassistant.core import Context, HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import intent, llm +from homeassistant.helpers import chat_session, intent, llm from . import CLIENT_ERROR_500 @@ -693,3 +693,37 @@ async def test_escape_decode() -> None: async def test_format_schema(openapi, genai_schema) -> None: """Test _format_schema.""" assert _format_schema(openapi) == genai_schema + + +@pytest.mark.usefixtures("mock_init_component") +async def test_empty_content_in_chat_history( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Tests that in case of an empty entry in the chat history the google API will receive an injected space sign instead.""" + with ( + patch("google.genai.chats.AsyncChats.create") as mock_create, + chat_session.async_get_chat_session(hass) as session, + async_get_chat_log(hass, session) as chat_log, + ): + mock_chat = AsyncMock() + mock_create.return_value.send_message = mock_chat + + # Chat preparation with two inputs, one being an empty string + first_input = "First request" + second_input = "" + chat_log.async_add_user_content(UserContent(first_input)) + chat_log.async_add_user_content(UserContent(second_input)) + + await conversation.async_converse( + hass, + "Second request", + session.conversation_id, + Context(), + agent_id="conversation.google_generative_ai_conversation", + ) + + _, kwargs = mock_create.call_args + actual_history = kwargs.get("history") + + assert actual_history[0].parts[0].text == first_input + assert actual_history[1].parts[0].text == " " From f4d57e37229bf7e9cff127e888827cd20c16137a Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 24 Mar 2025 08:41:19 +0100 Subject: [PATCH 2938/3148] Add cloud onboarding views (#139422) * Add cloud onboarding views * Break import cycle when running hassfest * Add exemption to hassfest for onboarding using cloud * Adjust according to discussion * Fix copy-paste errors * Add tests * Fix stale docstring * Import cloud loally --- homeassistant/components/cloud/http_api.py | 18 ++- homeassistant/components/onboarding/views.py | 110 ++++++++++++++ script/hassfest/dependencies.py | 7 +- tests/components/onboarding/test_views.py | 142 ++++++++++++++++++- 4 files changed, 270 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py index 73952d80f6c..6f18cc424cd 100644 --- a/homeassistant/components/cloud/http_api.py +++ b/homeassistant/components/cloud/http_api.py @@ -245,6 +245,10 @@ class CloudLoginView(HomeAssistantView): name = "api:cloud:login" @require_admin + async def post(self, request: web.Request) -> web.Response: + """Handle login request.""" + return await self._post(request) + @_handle_cloud_errors @RequestDataValidator( vol.Schema( @@ -259,7 +263,7 @@ class CloudLoginView(HomeAssistantView): ) ) ) - async def post(self, request: web.Request, data: dict[str, Any]) -> web.Response: + async def _post(self, request: web.Request, data: dict[str, Any]) -> web.Response: """Handle login request.""" hass = request.app[KEY_HASS] cloud = hass.data[DATA_CLOUD] @@ -316,8 +320,12 @@ class CloudLogoutView(HomeAssistantView): name = "api:cloud:logout" @require_admin - @_handle_cloud_errors async def post(self, request: web.Request) -> web.Response: + """Handle logout request.""" + return await self._post(request) + + @_handle_cloud_errors + async def _post(self, request: web.Request) -> web.Response: """Handle logout request.""" hass = request.app[KEY_HASS] cloud = hass.data[DATA_CLOUD] @@ -400,9 +408,13 @@ class CloudForgotPasswordView(HomeAssistantView): name = "api:cloud:forgot_password" @require_admin + async def post(self, request: web.Request) -> web.Response: + """Handle forgot password request.""" + return await self._post(request) + @_handle_cloud_errors @RequestDataValidator(vol.Schema({vol.Required("email"): str})) - async def post(self, request: web.Request, data: dict[str, Any]) -> web.Response: + async def _post(self, request: web.Request, data: dict[str, Any]) -> web.Response: """Handle forgot password request.""" hass = request.app[KEY_HASS] cloud = hass.data[DATA_CLOUD] diff --git a/homeassistant/components/onboarding/views.py b/homeassistant/components/onboarding/views.py index 5f1d908f7f8..f0638e72d94 100644 --- a/homeassistant/components/onboarding/views.py +++ b/homeassistant/components/onboarding/views.py @@ -60,6 +60,7 @@ async def async_setup( hass.http.register_view(BackupInfoView(data)) hass.http.register_view(RestoreBackupView(data)) hass.http.register_view(UploadBackupView(data)) + setup_cloud_views(hass, data) class OnboardingView(HomeAssistantView): @@ -429,6 +430,115 @@ class UploadBackupView(BackupOnboardingView, backup_http.UploadBackupView): return await self._post(request) +def setup_cloud_views(hass: HomeAssistant, data: OnboardingStoreData) -> None: + """Set up the cloud views.""" + + # The cloud integration is imported locally to avoid cloud being imported by + # bootstrap.py and to avoid circular imports. + + # pylint: disable-next=import-outside-toplevel + from homeassistant.components.cloud import http_api as cloud_http + + # pylint: disable-next=import-outside-toplevel,hass-component-root-import + from homeassistant.components.cloud.const import DATA_CLOUD + + class CloudOnboardingView(HomeAssistantView): + """Cloud onboarding view.""" + + requires_auth = False + + def __init__(self, data: OnboardingStoreData) -> None: + """Initialize the view.""" + self._data = data + + def with_cloud[_ViewT: CloudOnboardingView, **_P]( + func: Callable[ + Concatenate[_ViewT, web.Request, _P], + Coroutine[Any, Any, web.Response], + ], + ) -> Callable[ + Concatenate[_ViewT, web.Request, _P], Coroutine[Any, Any, web.Response] + ]: + """Home Assistant API decorator to check onboarding and cloud.""" + + @wraps(func) + async def _with_cloud( + self: _ViewT, + request: web.Request, + *args: _P.args, + **kwargs: _P.kwargs, + ) -> web.Response: + """Check onboarding status, cloud and call function.""" + if self._data["done"]: + # If at least one onboarding step is done, we don't allow accessing + # the cloud onboarding views. + raise HTTPUnauthorized + + hass = request.app[KEY_HASS] + if DATA_CLOUD not in hass.data: + return self.json( + {"code": "cloud_disabled"}, + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + ) + + return await func(self, request, *args, **kwargs) + + return _with_cloud + + class CloudForgotPasswordView( + CloudOnboardingView, cloud_http.CloudForgotPasswordView + ): + """View to start Forgot Password flow.""" + + url = "/api/onboarding/cloud/forgot_password" + name = "api:onboarding:cloud:forgot_password" + + @with_cloud + async def post(self, request: web.Request) -> web.Response: + """Handle forgot password request.""" + return await super()._post(request) + + class CloudLoginView(CloudOnboardingView, cloud_http.CloudLoginView): + """Login to Home Assistant Cloud.""" + + url = "/api/onboarding/cloud/login" + name = "api:onboarding:cloud:login" + + @with_cloud + async def post(self, request: web.Request) -> web.Response: + """Handle login request.""" + return await super()._post(request) + + class CloudLogoutView(CloudOnboardingView, cloud_http.CloudLogoutView): + """Log out of the Home Assistant cloud.""" + + url = "/api/onboarding/cloud/logout" + name = "api:onboarding:cloud:logout" + + @with_cloud + async def post(self, request: web.Request) -> web.Response: + """Handle logout request.""" + return await super()._post(request) + + class CloudStatusView(CloudOnboardingView): + """Get cloud status view.""" + + url = "/api/onboarding/cloud/status" + name = "api:onboarding:cloud:status" + + @with_cloud + async def get(self, request: web.Request) -> web.Response: + """Return cloud status.""" + hass = request.app[KEY_HASS] + cloud = hass.data[DATA_CLOUD] + return self.json({"logged_in": cloud.is_logged_in}) + + hass.http.register_view(CloudForgotPasswordView(data)) + hass.http.register_view(CloudLoginView(data)) + hass.http.register_view(CloudLogoutView(data)) + hass.http.register_view(CloudStatusView(data)) + + @callback def _async_get_hass_provider(hass: HomeAssistant) -> HassAuthProvider: """Get the Home Assistant auth provider.""" diff --git a/script/hassfest/dependencies.py b/script/hassfest/dependencies.py index b22027500dd..52ea79d32fe 100644 --- a/script/hassfest/dependencies.py +++ b/script/hassfest/dependencies.py @@ -173,10 +173,11 @@ IGNORE_VIOLATIONS = { "logbook", # Temporary needed for migration until 2024.10 ("conversation", "assist_pipeline"), - # The onboarding integration provides a limited backup API used during - # onboarding. The onboarding integration waits for the backup manager - # to be ready before calling any backup functionality. + # The onboarding integration provides limited backup and cloud APIs for use + # during onboarding. The onboarding integration waits for the backup manager + # and cloud to be ready before calling any backup or cloud functionality. ("onboarding", "backup"), + ("onboarding", "cloud"), } diff --git a/tests/components/onboarding/test_views.py b/tests/components/onboarding/test_views.py index d0a6afa50b5..509dece7dd0 100644 --- a/tests/components/onboarding/test_views.py +++ b/tests/components/onboarding/test_views.py @@ -6,12 +6,16 @@ from http import HTTPStatus from io import StringIO import os from typing import Any -from unittest.mock import ANY, AsyncMock, Mock, patch +from unittest.mock import ANY, DEFAULT, AsyncMock, MagicMock, Mock, patch +from hass_nabucasa.auth import CognitoAuth +from hass_nabucasa.const import STATE_CONNECTED +from hass_nabucasa.iot import CloudIoT import pytest from syrupy import SnapshotAssertion from homeassistant.components import backup, onboarding +from homeassistant.components.cloud import DOMAIN as CLOUD_DOMAIN, CloudClient from homeassistant.components.onboarding import const, views from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -1067,3 +1071,139 @@ async def test_onboarding_backup_upload( assert resp.status == 201 assert await resp.json() == {"backup_id": "abc123"} mock_receive.assert_called_once_with(agent_ids=["backup.local"], contents=ANY) + + +@pytest.fixture(name="cloud") +async def cloud_fixture() -> AsyncGenerator[MagicMock]: + """Mock the cloud object. + + See the real hass_nabucasa.Cloud class for how to configure the mock. + """ + with patch( + "homeassistant.components.cloud.Cloud", autospec=True + ) as mock_cloud_class: + mock_cloud = mock_cloud_class.return_value + + mock_cloud.auth = MagicMock(spec=CognitoAuth) + mock_cloud.iot = MagicMock( + spec=CloudIoT, last_disconnect_reason=None, state=STATE_CONNECTED + ) + + def set_up_mock_cloud( + cloud_client: CloudClient, mode: str, **kwargs: Any + ) -> DEFAULT: + """Set up mock cloud with a mock constructor.""" + + # Attributes set in the constructor with parameters. + mock_cloud.client = cloud_client + + return DEFAULT + + mock_cloud_class.side_effect = set_up_mock_cloud + + # Attributes that we mock with default values. + mock_cloud.id_token = None + mock_cloud.is_logged_in = False + + yield mock_cloud + + +@pytest.fixture(name="setup_cloud") +async def setup_cloud_fixture(hass: HomeAssistant, cloud: MagicMock) -> None: + """Fixture that sets up cloud.""" + assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component(hass, CLOUD_DOMAIN, {}) + await hass.async_block_till_done() + + +@pytest.mark.usefixtures("setup_cloud") +async def test_onboarding_cloud_forgot_password( + hass: HomeAssistant, + hass_storage: dict[str, Any], + hass_client: ClientSessionGenerator, + cloud: MagicMock, +) -> None: + """Test cloud forgot password.""" + mock_storage(hass_storage, {"done": []}) + + assert await async_setup_component(hass, "onboarding", {}) + await hass.async_block_till_done() + + client = await hass_client() + + mock_cognito = cloud.auth + + req = await client.post( + "/api/onboarding/cloud/forgot_password", json={"email": "hello@bla.com"} + ) + + assert req.status == HTTPStatus.OK + assert mock_cognito.async_forgot_password.call_count == 1 + + +@pytest.mark.usefixtures("setup_cloud") +async def test_onboarding_cloud_login( + hass: HomeAssistant, + hass_storage: dict[str, Any], + hass_client: ClientSessionGenerator, + cloud: MagicMock, +) -> None: + """Test logging out from cloud.""" + mock_storage(hass_storage, {"done": []}) + + assert await async_setup_component(hass, "onboarding", {}) + await hass.async_block_till_done() + + client = await hass_client() + req = await client.post( + "/api/onboarding/cloud/login", + json={"email": "my_username", "password": "my_password"}, + ) + + assert req.status == HTTPStatus.OK + data = await req.json() + assert data == {"cloud_pipeline": None, "success": True} + assert cloud.login.call_count == 1 + + +@pytest.mark.usefixtures("setup_cloud") +async def test_onboarding_cloud_logout( + hass: HomeAssistant, + hass_storage: dict[str, Any], + hass_client: ClientSessionGenerator, + cloud: MagicMock, +) -> None: + """Test logging out from cloud.""" + mock_storage(hass_storage, {"done": []}) + + assert await async_setup_component(hass, "onboarding", {}) + await hass.async_block_till_done() + + client = await hass_client() + req = await client.post("/api/onboarding/cloud/logout") + + assert req.status == HTTPStatus.OK + data = await req.json() + assert data == {"message": "ok"} + assert cloud.logout.call_count == 1 + + +@pytest.mark.usefixtures("setup_cloud") +async def test_onboarding_cloud_status( + hass: HomeAssistant, + hass_storage: dict[str, Any], + hass_client: ClientSessionGenerator, + cloud: MagicMock, +) -> None: + """Test logging out from cloud.""" + mock_storage(hass_storage, {"done": []}) + + assert await async_setup_component(hass, "onboarding", {}) + await hass.async_block_till_done() + + client = await hass_client() + req = await client.get("/api/onboarding/cloud/status") + + assert req.status == HTTPStatus.OK + data = await req.json() + assert data == {"logged_in": False} From 59190786f9307bf8a7da8bc49976a891b3fa15cf Mon Sep 17 00:00:00 2001 From: tronikos Date: Mon, 24 Mar 2025 00:46:30 -0700 Subject: [PATCH 2939/3148] Bump gassist-text to 0.0.12 (#141244) --- homeassistant/components/google_assistant_sdk/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/google_assistant_sdk/manifest.json b/homeassistant/components/google_assistant_sdk/manifest.json index 85469a464b3..70e93f39f42 100644 --- a/homeassistant/components/google_assistant_sdk/manifest.json +++ b/homeassistant/components/google_assistant_sdk/manifest.json @@ -7,6 +7,6 @@ "documentation": "https://www.home-assistant.io/integrations/google_assistant_sdk", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["gassist-text==0.0.11"], + "requirements": ["gassist-text==0.0.12"], "single_config_entry": true } diff --git a/requirements_all.txt b/requirements_all.txt index 75deb539c48..f14d03b135d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -977,7 +977,7 @@ gTTS==2.5.3 gardena-bluetooth==1.6.0 # homeassistant.components.google_assistant_sdk -gassist-text==0.0.11 +gassist-text==0.0.12 # homeassistant.components.google gcal-sync==7.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e04b55e4c08..42f4f66b0cb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -830,7 +830,7 @@ gTTS==2.5.3 gardena-bluetooth==1.6.0 # homeassistant.components.google_assistant_sdk -gassist-text==0.0.11 +gassist-text==0.0.12 # homeassistant.components.google gcal-sync==7.0.0 From 0514de3e16cecf8c756ea0a7e51bc665f729504d Mon Sep 17 00:00:00 2001 From: Simon Lamon <32477463+silamon@users.noreply.github.com> Date: Mon, 24 Mar 2025 09:13:06 +0100 Subject: [PATCH 2940/3148] Remove manufacturer data for linkplay (#141261) Remove manufacturer data --- homeassistant/components/linkplay/entity.py | 2 +- homeassistant/components/linkplay/utils.py | 71 --------------------- 2 files changed, 1 insertion(+), 72 deletions(-) diff --git a/homeassistant/components/linkplay/entity.py b/homeassistant/components/linkplay/entity.py index 74e067f5eb3..0bfb34af42c 100644 --- a/homeassistant/components/linkplay/entity.py +++ b/homeassistant/components/linkplay/entity.py @@ -4,13 +4,13 @@ from collections.abc import Callable, Coroutine from typing import Any, Concatenate from linkplay.bridge import LinkPlayBridge +from linkplay.manufacturers import MANUFACTURER_GENERIC, get_info_from_project from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr from homeassistant.helpers.entity import Entity from . import DOMAIN, LinkPlayRequestException -from .utils import MANUFACTURER_GENERIC, get_info_from_project def exception_wrap[_LinkPlayEntityT: LinkPlayBaseEntity, **_P, _R]( diff --git a/homeassistant/components/linkplay/utils.py b/homeassistant/components/linkplay/utils.py index 7151ed1537a..63d04a3afc4 100644 --- a/homeassistant/components/linkplay/utils.py +++ b/homeassistant/components/linkplay/utils.py @@ -1,7 +1,5 @@ """Utilities for the LinkPlay component.""" -from typing import Final - from aiohttp import ClientSession from linkplay.utils import async_create_unverified_client_session @@ -10,75 +8,6 @@ from homeassistant.core import Event, HomeAssistant, callback from .const import DATA_SESSION, DOMAIN -MANUFACTURER_ARTSOUND: Final[str] = "ArtSound" -MANUFACTURER_ARYLIC: Final[str] = "Arylic" -MANUFACTURER_IEAST: Final[str] = "iEAST" -MANUFACTURER_WIIM: Final[str] = "WiiM" -MANUFACTURER_GGMM: Final[str] = "GGMM" -MANUFACTURER_MEDION: Final[str] = "Medion" -MANUFACTURER_GENERIC: Final[str] = "Generic" -MODELS_ARTSOUND_SMART_ZONE4: Final[str] = "Smart Zone 4 AMP" -MODELS_ARTSOUND_SMART_HYDE: Final[str] = "Smart Hyde" -MODELS_ARYLIC_S50: Final[str] = "S50+" -MODELS_ARYLIC_S50_PRO: Final[str] = "S50 Pro" -MODELS_ARYLIC_A30: Final[str] = "A30" -MODELS_ARYLIC_A50: Final[str] = "A50" -MODELS_ARYLIC_A50S: Final[str] = "A50+" -MODELS_ARYLIC_UP2STREAM_AMP: Final[str] = "Up2Stream Amp 2.0" -MODELS_ARYLIC_UP2STREAM_AMP_2P1: Final[str] = "Up2Stream Amp 2.1" -MODELS_ARYLIC_UP2STREAM_AMP_V3: Final[str] = "Up2Stream Amp v3" -MODELS_ARYLIC_UP2STREAM_AMP_V4: Final[str] = "Up2Stream Amp v4" -MODELS_ARYLIC_UP2STREAM_PRO: Final[str] = "Up2Stream Pro v1" -MODELS_ARYLIC_UP2STREAM_PRO_V3: Final[str] = "Up2Stream Pro v3" -MODELS_ARYLIC_S10P: Final[str] = "Arylic S10+" -MODELS_ARYLIC_UP2STREAM_PLATE_AMP: Final[str] = "Up2Stream Plate Amp" -MODELS_IEAST_AUDIOCAST_M5: Final[str] = "AudioCast M5" -MODELS_WIIM_AMP: Final[str] = "WiiM Amp" -MODELS_WIIM_MINI: Final[str] = "WiiM Mini" -MODELS_GGMM_GGMM_E2: Final[str] = "GGMM E2" -MODELS_MEDION_MD_43970: Final[str] = "Life P66970 (MD 43970)" -MODELS_GENERIC: Final[str] = "Generic" - -PROJECTID_LOOKUP: Final[dict[str, tuple[str, str]]] = { - "SMART_ZONE4_AMP": (MANUFACTURER_ARTSOUND, MODELS_ARTSOUND_SMART_ZONE4), - "SMART_HYDE": (MANUFACTURER_ARTSOUND, MODELS_ARTSOUND_SMART_HYDE), - "ARYLIC_S50": (MANUFACTURER_ARYLIC, MODELS_ARYLIC_S50), - "RP0016_S50PRO_S": (MANUFACTURER_ARYLIC, MODELS_ARYLIC_S50_PRO), - "RP0011_WB60_S": (MANUFACTURER_ARYLIC, MODELS_ARYLIC_A30), - "X-50": (MANUFACTURER_ARYLIC, MODELS_ARYLIC_A50), - "ARYLIC_A50S": (MANUFACTURER_ARYLIC, MODELS_ARYLIC_A50S), - "RP0011_WB60": (MANUFACTURER_ARYLIC, MODELS_ARYLIC_UP2STREAM_AMP), - "UP2STREAM_AMP_V3": (MANUFACTURER_ARYLIC, MODELS_ARYLIC_UP2STREAM_AMP_V3), - "UP2STREAM_AMP_V4": (MANUFACTURER_ARYLIC, MODELS_ARYLIC_UP2STREAM_AMP_V4), - "UP2STREAM_PRO_V3": (MANUFACTURER_ARYLIC, MODELS_ARYLIC_UP2STREAM_PRO_V3), - "S10P_WIFI": (MANUFACTURER_ARYLIC, MODELS_ARYLIC_S10P), - "ARYLIC_V20": (MANUFACTURER_ARYLIC, MODELS_ARYLIC_UP2STREAM_PLATE_AMP), - "UP2STREAM_MINI_V3": (MANUFACTURER_ARYLIC, MODELS_GENERIC), - "UP2STREAM_AMP_2P1": (MANUFACTURER_ARYLIC, MODELS_ARYLIC_UP2STREAM_AMP_2P1), - "RP0014_A50C_S": (MANUFACTURER_ARYLIC, MODELS_GENERIC), - "ARYLIC_A30": (MANUFACTURER_ARYLIC, MODELS_GENERIC), - "ARYLIC_SUBWOOFER": (MANUFACTURER_ARYLIC, MODELS_GENERIC), - "ARYLIC_S50A": (MANUFACTURER_ARYLIC, MODELS_GENERIC), - "RP0010_D5_S": (MANUFACTURER_ARYLIC, MODELS_GENERIC), - "RP0001": (MANUFACTURER_ARYLIC, MODELS_GENERIC), - "RP0013_WA31S": (MANUFACTURER_ARYLIC, MODELS_GENERIC), - "RP0010_D5": (MANUFACTURER_ARYLIC, MODELS_GENERIC), - "RP0013_WA31S_S": (MANUFACTURER_ARYLIC, MODELS_GENERIC), - "RP0014_A50D_S": (MANUFACTURER_ARYLIC, MODELS_GENERIC), - "ARYLIC_A50TE": (MANUFACTURER_ARYLIC, MODELS_GENERIC), - "ARYLIC_A50N": (MANUFACTURER_ARYLIC, MODELS_GENERIC), - "iEAST-02": (MANUFACTURER_IEAST, MODELS_IEAST_AUDIOCAST_M5), - "WiiM_Amp_4layer": (MANUFACTURER_WIIM, MODELS_WIIM_AMP), - "Muzo_Mini": (MANUFACTURER_WIIM, MODELS_WIIM_MINI), - "GGMM_E2A": (MANUFACTURER_GGMM, MODELS_GGMM_GGMM_E2), - "A16": (MANUFACTURER_MEDION, MODELS_MEDION_MD_43970), -} - - -def get_info_from_project(project: str) -> tuple[str, str]: - """Get manufacturer and model info based on given project.""" - return PROJECTID_LOOKUP.get(project, (MANUFACTURER_GENERIC, MODELS_GENERIC)) - async def async_get_client_session(hass: HomeAssistant) -> ClientSession: """Get a ClientSession that can be used with LinkPlay devices.""" From d65392a374b7edd0f74912f2076e74de1e2d8bd5 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Mon, 24 Mar 2025 01:24:43 -0700 Subject: [PATCH 2941/3148] ConfigSubEntryFlow _get_reconfigure_entry() -> _get_entry() (#141017) * ConfigSubEntryFlow _get_reconfigure_entry() -> _get_entry() * Update MQTT test * Fix test_config_entries * Minimize changes to keep existing tests working * Re-revert and update negative test instead --- .../components/kitchen_sink/config_flow.py | 2 +- homeassistant/components/mqtt/config_flow.py | 2 +- homeassistant/config_entries.py | 18 ++++++------------ tests/components/config/test_config_entries.py | 2 +- tests/test_config_entries.py | 16 ++++++++-------- 5 files changed, 17 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/kitchen_sink/config_flow.py b/homeassistant/components/kitchen_sink/config_flow.py index 1747a0d723c..aa722d27944 100644 --- a/homeassistant/components/kitchen_sink/config_flow.py +++ b/homeassistant/components/kitchen_sink/config_flow.py @@ -147,7 +147,7 @@ class SubentryFlowHandler(ConfigSubentryFlow): if user_input is not None: title = user_input.pop("name") return self.async_update_and_abort( - self._get_reconfigure_entry(), + self._get_entry(), self._get_reconfigure_subentry(), data=user_input, title=title, diff --git a/homeassistant/components/mqtt/config_flow.py b/homeassistant/components/mqtt/config_flow.py index 8dfccbb6b2a..cc98315c218 100644 --- a/homeassistant/components/mqtt/config_flow.py +++ b/homeassistant/components/mqtt/config_flow.py @@ -1176,7 +1176,7 @@ class MQTTSubentryFlowHandler(ConfigSubentryFlow): self, user_input: dict[str, Any] | None = None ) -> SubentryFlowResult: """Save the changes made to the subentry.""" - entry = self._get_reconfigure_entry() + entry = self._get_entry() subentry = self._get_reconfigure_subentry() entity_registry = er.async_get(self.hass) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 9336ead633a..61c85948387 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -3491,18 +3491,14 @@ class ConfigSubentryFlow( return self.async_abort(reason="reconfigure_successful") @property - def _reconfigure_entry_id(self) -> str: - """Return reconfigure entry id.""" - if self.source != SOURCE_RECONFIGURE: - raise ValueError(f"Source is {self.source}, expected {SOURCE_RECONFIGURE}") + def _entry_id(self) -> str: + """Return config entry id.""" return self.handler[0] @callback - def _get_reconfigure_entry(self) -> ConfigEntry: - """Return the reconfigure config entry linked to the current context.""" - return self.hass.config_entries.async_get_known_entry( - self._reconfigure_entry_id - ) + def _get_entry(self) -> ConfigEntry: + """Return the config entry linked to the current context.""" + return self.hass.config_entries.async_get_known_entry(self._entry_id) @property def _reconfigure_subentry_id(self) -> str: @@ -3514,9 +3510,7 @@ class ConfigSubentryFlow( @callback def _get_reconfigure_subentry(self) -> ConfigSubentry: """Return the reconfigure config subentry linked to the current context.""" - entry = self.hass.config_entries.async_get_known_entry( - self._reconfigure_entry_id - ) + entry = self.hass.config_entries.async_get_known_entry(self._entry_id) subentry_id = self._reconfigure_subentry_id if subentry_id not in entry.subentries: raise UnknownSubEntry(subentry_id) diff --git a/tests/components/config/test_config_entries.py b/tests/components/config/test_config_entries.py index 739b79e22bd..ce10a36c42c 100644 --- a/tests/components/config/test_config_entries.py +++ b/tests/components/config/test_config_entries.py @@ -1193,7 +1193,7 @@ async def test_subentry_reconfigure_flow(hass: HomeAssistant, client) -> None: async def async_step_reconfigure(self, user_input=None): if user_input is not None: return self.async_update_and_abort( - self._get_reconfigure_entry(), + self._get_entry(), self._get_reconfigure_subentry(), title="Test Entry", data={"test": "blah"}, diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index 788225365e0..f5296cb2c46 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -6566,7 +6566,7 @@ async def test_update_subentry_and_abort( class SubentryFlowHandler(config_entries.ConfigSubentryFlow): async def async_step_reconfigure(self, user_input=None): return self.async_update_and_abort( - self._get_reconfigure_entry(), + self._get_entry(), self._get_reconfigure_subentry(), **kwargs, ) @@ -8158,10 +8158,10 @@ async def test_get_reconfigure_entry( assert result["reason"] == "Source is user, expected reconfigure: -" -async def test_subentry_get_reconfigure_entry( +async def test_subentry_get_entry( hass: HomeAssistant, manager: config_entries.ConfigEntries ) -> None: - """Test subentry _get_reconfigure_entry and _get_reconfigure_subentry behavior.""" + """Test subentry _get_entry and _get_reconfigure_subentry behavior.""" subentry_id = "mock_subentry_id" entry = MockConfigEntry( data={}, @@ -8198,13 +8198,13 @@ async def test_subentry_get_reconfigure_entry( async def _async_step_confirm(self): """Confirm input.""" try: - entry = self._get_reconfigure_entry() + entry = self._get_entry() except ValueError as err: reason = str(err) else: reason = f"Found entry {entry.title}" try: - entry_id = self._reconfigure_entry_id + entry_id = self._entry_id except ValueError: reason = f"{reason}: -" else: @@ -8233,7 +8233,7 @@ async def test_subentry_get_reconfigure_entry( ) -> dict[str, type[config_entries.ConfigSubentryFlow]]: return {"test": TestFlow.SubentryFlowHandler} - # A reconfigure flow finds the config entry + # A reconfigure flow finds the config entry and subentry with mock_config_flow("test", TestFlow): result = await entry.start_subentry_reconfigure_flow(hass, "test", subentry_id) assert ( @@ -8255,14 +8255,14 @@ async def test_subentry_get_reconfigure_entry( == "Found entry entry_title: mock_entry_id/Subentry not found: 01JRemoved" ) - # A user flow does not have access to the config entry or subentry + # A user flow finds the config entry but not the subentry with mock_config_flow("test", TestFlow): result = await manager.subentries.async_init( (entry.entry_id, "test"), context={"source": config_entries.SOURCE_USER} ) assert ( result["reason"] - == "Source is user, expected reconfigure: -/Source is user, expected reconfigure: -" + == "Found entry entry_title: mock_entry_id/Source is user, expected reconfigure: -" ) From 590c588557774063c4892d33ab61464dd4e982e9 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 24 Mar 2025 09:25:13 +0100 Subject: [PATCH 2942/3148] Fix sentence-casing and change to "1-Wire" in `onewire` strings (#141265) * Fix sentence-casing in a few `onewire` strings * Change "OneWire" to "1-Wire" --- homeassistant/components/onewire/strings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/onewire/strings.json b/homeassistant/components/onewire/strings.json index 46f41503d97..5e7719673b1 100644 --- a/homeassistant/components/onewire/strings.json +++ b/homeassistant/components/onewire/strings.json @@ -140,14 +140,14 @@ "device_selection": "[%key:component::onewire::options::error::device_not_selected%]" }, "description": "Select what configuration steps to process", - "title": "OneWire Device Options" + "title": "1-Wire device options" }, "configure_device": { "data": { - "precision": "Sensor Precision" + "precision": "Sensor precision" }, "description": "Select sensor precision for {sensor_id}", - "title": "OneWire Sensor Precision" + "title": "1-Wire sensor precision" } } } From 12e001cf2b54e8020780ccfff26d4987d2b1cc77 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 24 Mar 2025 09:28:10 +0100 Subject: [PATCH 2943/3148] Add binary sensors for fridge doors in SmartThings (#141252) * Add binary sensors for fridge doors * Add binary sensors for fridge doors * Add binary sensors for fridge doors * Add binary sensors for fridge doors --- .../components/smartthings/binary_sensor.py | 39 ++++++-- .../components/smartthings/strings.json | 6 ++ .../snapshots/test_binary_sensor.ambr | 96 +++++++++++++++++++ 3 files changed, 133 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/smartthings/binary_sensor.py b/homeassistant/components/smartthings/binary_sensor.py index 8479852a6f6..f776aa70c41 100644 --- a/homeassistant/components/smartthings/binary_sensor.py +++ b/homeassistant/components/smartthings/binary_sensor.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass from pysmartthings import Attribute, Capability, Category, SmartThings @@ -35,6 +36,8 @@ class SmartThingsBinarySensorEntityDescription(BinarySensorEntityDescription): is_on_key: str category_device_class: dict[Category | str, BinarySensorDeviceClass] | None = None category: set[Category] | None = None + exists_fn: Callable[[str], bool] | None = None + component_translation_key: dict[str, str] | None = None CAPABILITY_TO_SENSORS: dict[ @@ -58,6 +61,11 @@ CAPABILITY_TO_SENSORS: dict[ Category.DOOR: BinarySensorDeviceClass.DOOR, Category.WINDOW: BinarySensorDeviceClass.WINDOW, }, + exists_fn=lambda key: key in {"freezer", "cooler"}, + component_translation_key={ + "freezer": "freezer_door", + "cooler": "cooler_door", + }, ) }, Capability.FILTER_STATUS: { @@ -164,17 +172,18 @@ async def async_setup_entry( entry_data = entry.runtime_data async_add_entities( SmartThingsBinarySensor( - entry_data.client, - device, - description, - capability, - attribute, + entry_data.client, device, description, capability, attribute, component ) for device in entry_data.devices.values() for capability, attribute_map in CAPABILITY_TO_SENSORS.items() - if capability in device.status[MAIN] for attribute, description in attribute_map.items() - if ( + for component in device.status + if capability in device.status[component] + and ( + component == MAIN + or (description.exists_fn is not None and description.exists_fn(component)) + ) + and ( not description.category or get_main_component_category(device) in description.category ) @@ -193,9 +202,10 @@ class SmartThingsBinarySensor(SmartThingsEntity, BinarySensorEntity): entity_description: SmartThingsBinarySensorEntityDescription, capability: Capability, attribute: Attribute, + component: str, ) -> None: """Init the class.""" - super().__init__(client, device, {capability}) + super().__init__(client, device, {capability}, component=component) self._attribute = attribute self.capability = capability self.entity_description = entity_description @@ -207,6 +217,19 @@ class SmartThingsBinarySensor(SmartThingsEntity, BinarySensorEntity): ): self._attr_device_class = entity_description.category_device_class[category] self._attr_name = None + if ( + entity_description.component_translation_key is not None + and ( + translation_key := entity_description.component_translation_key.get( + component + ) + ) + is not None + ): + self._attr_translation_key = translation_key + self._attr_unique_id = ( + f"{device.device.device_id}_{component}_{capability}_{attribute}" + ) @property def is_on(self) -> bool: diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index e4bc11ed5f6..25872dca82c 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -39,6 +39,12 @@ "filter_status": { "name": "Filter status" }, + "freezer_door": { + "name": "Freezer door" + }, + "cooler_door": { + "name": "Cooler door" + }, "remote_control": { "name": "Remote control" }, diff --git a/tests/components/smartthings/snapshots/test_binary_sensor.ambr b/tests/components/smartthings/snapshots/test_binary_sensor.ambr index 45534085ddf..9bb52a71eee 100644 --- a/tests/components/smartthings/snapshots/test_binary_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_binary_sensor.ambr @@ -569,6 +569,54 @@ 'state': 'on', }) # --- +# name: test_all_entities[da_ref_normal_000001][binary_sensor.refrigerator_cooler_door-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.refrigerator_cooler_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Cooler door', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'cooler_door', + 'unique_id': '7db87911-7dce-1cf2-7119-b953432a2f09_cooler_contactSensor_contact', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ref_normal_000001][binary_sensor.refrigerator_cooler_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Refrigerator Cooler door', + }), + 'context': , + 'entity_id': 'binary_sensor.refrigerator_cooler_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_all_entities[da_ref_normal_000001][binary_sensor.refrigerator_door-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -617,6 +665,54 @@ 'state': 'off', }) # --- +# name: test_all_entities[da_ref_normal_000001][binary_sensor.refrigerator_freezer_door-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.refrigerator_freezer_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Freezer door', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'freezer_door', + 'unique_id': '7db87911-7dce-1cf2-7119-b953432a2f09_freezer_contactSensor_contact', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ref_normal_000001][binary_sensor.refrigerator_freezer_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Refrigerator Freezer door', + }), + 'context': , + 'entity_id': 'binary_sensor.refrigerator_freezer_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_all_entities[da_wm_dw_000001][binary_sensor.dishwasher_child_lock-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From ab9d29bf9d1aa7538249374a61cbffd041f6a553 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 24 Mar 2025 09:29:14 +0100 Subject: [PATCH 2944/3148] Remove reauth persistent notification (#140932) * Remove persistent notification created when starting reauth * Update netatmo tests --- homeassistant/config_entries.py | 35 ------------ tests/components/netatmo/test_init.py | 16 +++--- tests/test_config_entries.py | 77 --------------------------- 3 files changed, 6 insertions(+), 122 deletions(-) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 61c85948387..d3e681ecca1 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -195,8 +195,6 @@ DISCOVERY_SOURCES = { SOURCE_ZEROCONF, } -RECONFIGURE_NOTIFICATION_ID = "config_entry_reconfigure" - EVENT_FLOW_DISCOVERED = "config_entry_discovered" SIGNAL_CONFIG_ENTRY_CHANGED = SignalType["ConfigEntryChange", "ConfigEntry"]( @@ -1714,16 +1712,6 @@ class ConfigEntriesFlowManager( # Create notification. if source in DISCOVERY_SOURCES: await self._discovery_debouncer.async_call() - elif source == SOURCE_REAUTH: - persistent_notification.async_create( - self.hass, - title="Integration requires reconfiguration", - message=( - "At least one of your integrations requires reconfiguration to " - "continue functioning. [Check it out](/config/integrations)." - ), - notification_id=RECONFIGURE_NOTIFICATION_ID, - ) @callback def _async_discovery(self) -> None: @@ -3119,29 +3107,6 @@ class ConfigFlow(ConfigEntryBaseFlow): """Handle a flow initialized by discovery.""" return await self._async_step_discovery_without_unique_id() - @callback - def async_abort( - self, - *, - reason: str, - description_placeholders: Mapping[str, str] | None = None, - ) -> ConfigFlowResult: - """Abort the config flow.""" - # Remove reauth notification if no reauth flows are in progress - if self.source == SOURCE_REAUTH and not any( - ent["flow_id"] != self.flow_id - for ent in self.hass.config_entries.flow.async_progress_by_handler( - self.handler, match_context={"source": SOURCE_REAUTH} - ) - ): - persistent_notification.async_dismiss( - self.hass, RECONFIGURE_NOTIFICATION_ID - ) - - return super().async_abort( - reason=reason, description_placeholders=description_placeholders - ) - async def async_step_bluetooth( self, discovery_info: BluetoothServiceInfoBleak ) -> ConfigFlowResult: diff --git a/tests/components/netatmo/test_init.py b/tests/components/netatmo/test_init.py index 5fdf4f8ea35..c1a687c6fa8 100644 --- a/tests/components/netatmo/test_init.py +++ b/tests/components/netatmo/test_init.py @@ -25,11 +25,7 @@ from .common import ( simulate_webhook, ) -from tests.common import ( - MockConfigEntry, - async_fire_time_changed, - async_get_persistent_notifications, -) +from tests.common import MockConfigEntry, async_fire_time_changed from tests.components.cloud import mock_cloud from tests.typing import WebSocketGenerator @@ -423,9 +419,8 @@ async def test_setup_component_invalid_token_scope(hass: HomeAssistant) -> None: assert config_entry.state is ConfigEntryState.SETUP_ERROR assert hass.config_entries.async_entries(DOMAIN) - notifications = async_get_persistent_notifications(hass) - - assert len(notifications) > 0 + # Test a reauth flow is initiated + assert len(list(config_entry.async_get_active_flows(hass, {"reauth"}))) == 1 for config_entry in hass.config_entries.async_entries("netatmo"): await hass.config_entries.async_remove(config_entry.entry_id) @@ -476,8 +471,9 @@ async def test_setup_component_invalid_token( assert config_entry.state is ConfigEntryState.SETUP_ERROR assert hass.config_entries.async_entries(DOMAIN) - notifications = async_get_persistent_notifications(hass) - assert len(notifications) > 0 + + # Test a reauth flow is initiated + assert len(list(config_entry.async_get_active_flows(hass, {"reauth"}))) == 1 for entry in hass.config_entries.async_entries("netatmo"): await hass.config_entries.async_remove(entry.entry_id) diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index f5296cb2c46..e3b80ecc03f 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -1421,83 +1421,6 @@ async def test_discovery_notification( assert "config_entry_discovery" not in notifications -async def test_reauth_notification(hass: HomeAssistant) -> None: - """Test that we create/dismiss a notification when source is reauth.""" - mock_integration(hass, MockModule("test")) - mock_platform(hass, "test.config_flow", None) - - entry = MockConfigEntry(title="test_title", domain="test") - entry.add_to_hass(hass) - - class TestFlow(config_entries.ConfigFlow): - """Test flow.""" - - VERSION = 5 - - async def async_step_user(self, user_input): - """Test user step.""" - return self.async_show_form(step_id="user_confirm") - - async def async_step_user_confirm(self, user_input): - """Test user confirm step.""" - return self.async_show_form(step_id="user_confirm") - - async def async_step_reauth(self, user_input): - """Test reauth step.""" - return self.async_show_form(step_id="reauth_confirm") - - async def async_step_reauth_confirm(self, user_input): - """Test reauth confirm step.""" - return self.async_abort(reason="test") - - with mock_config_flow("test", TestFlow): - # Start user flow to assert that reconfigure notification doesn't fire - await hass.config_entries.flow.async_init( - "test", context={"source": config_entries.SOURCE_USER} - ) - - await hass.async_block_till_done() - notifications = async_get_persistent_notifications(hass) - assert "config_entry_reconfigure" not in notifications - - # Start first reauth flow to assert that reconfigure notification fires - flow1 = await hass.config_entries.flow.async_init( - "test", - context={ - "source": config_entries.SOURCE_REAUTH, - "entry_id": entry.entry_id, - }, - ) - - await hass.async_block_till_done() - notifications = async_get_persistent_notifications(hass) - assert "config_entry_reconfigure" in notifications - - # Start a second reauth flow so we can finish the first and assert that - # the reconfigure notification persists until the second one is complete - flow2 = await hass.config_entries.flow.async_init( - "test", - context={ - "source": config_entries.SOURCE_REAUTH, - "entry_id": entry.entry_id, - }, - ) - - flow1 = await hass.config_entries.flow.async_configure(flow1["flow_id"], {}) - assert flow1["type"] == data_entry_flow.FlowResultType.ABORT - - await hass.async_block_till_done() - notifications = async_get_persistent_notifications(hass) - assert "config_entry_reconfigure" in notifications - - flow2 = await hass.config_entries.flow.async_configure(flow2["flow_id"], {}) - assert flow2["type"] == data_entry_flow.FlowResultType.ABORT - - await hass.async_block_till_done() - notifications = async_get_persistent_notifications(hass) - assert "config_entry_reconfigure" not in notifications - - async def test_reauth_issue( hass: HomeAssistant, manager: config_entries.ConfigEntries, From b4fd5339c61e1d65795817dbd18e186e2dc4dae3 Mon Sep 17 00:00:00 2001 From: Simon Lamon <32477463+silamon@users.noreply.github.com> Date: Mon, 24 Mar 2025 09:45:09 +0100 Subject: [PATCH 2945/3148] Bump linkplay to v0.2.1 (#141260) --- homeassistant/components/linkplay/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/linkplay/manifest.json b/homeassistant/components/linkplay/manifest.json index 0fceed1f691..0941f2fbe61 100644 --- a/homeassistant/components/linkplay/manifest.json +++ b/homeassistant/components/linkplay/manifest.json @@ -7,6 +7,6 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["linkplay"], - "requirements": ["python-linkplay==0.2.0"], + "requirements": ["python-linkplay==0.2.1"], "zeroconf": ["_linkplay._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index f14d03b135d..f3397e70bec 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2425,7 +2425,7 @@ python-juicenet==1.1.0 python-kasa[speedups]==0.10.2 # homeassistant.components.linkplay -python-linkplay==0.2.0 +python-linkplay==0.2.1 # homeassistant.components.lirc # python-lirc==1.2.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 42f4f66b0cb..92ec683dcdb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1961,7 +1961,7 @@ python-juicenet==1.1.0 python-kasa[speedups]==0.10.2 # homeassistant.components.linkplay -python-linkplay==0.2.0 +python-linkplay==0.2.1 # homeassistant.components.matter python-matter-server==7.0.0 From 0f60fd8c405ecb7979363f86e5a227e87fceac2d Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Mon, 24 Mar 2025 10:36:02 +0100 Subject: [PATCH 2946/3148] Test data entry flow form showing suggested values (#141249) Add test with from showing suggested values to data entry flow tests --- tests/test_data_entry_flow.py | 51 +++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/tests/test_data_entry_flow.py b/tests/test_data_entry_flow.py index 74a55cb4989..a2f4ad6e097 100644 --- a/tests/test_data_entry_flow.py +++ b/tests/test_data_entry_flow.py @@ -133,6 +133,57 @@ async def test_show_form(manager: MockFlowManager) -> None: assert form["errors"] == {"username": "Should be unique."} +async def test_form_shows_with_added_suggested_values(manager: MockFlowManager) -> None: + """Test that we can show a form with suggested values.""" + schema = vol.Schema( + { + vol.Required("username"): str, + vol.Required("password"): str, + vol.Required("section_1"): data_entry_flow.section( + vol.Schema( + { + vol.Optional("full_name"): str, + } + ), + {"collapsed": False}, + ), + } + ) + + @manager.mock_reg_handler("test") + class TestFlow(data_entry_flow.FlowHandler): + async def async_step_init(self, user_input=None): + data_schema = self.add_suggested_values_to_schema( + schema, + { + "username": "doej", + "password": "verySecret1", + "section_1": {"full_name": "John Doe"}, + }, + ) + return self.async_show_form( + step_id="init", + data_schema=data_schema, + ) + + form = await manager.async_init("test") + assert form["type"] == data_entry_flow.FlowResultType.FORM + assert form["data_schema"].schema == schema.schema + markers = list(form["data_schema"].schema) + assert len(markers) == 3 + assert markers[0] == "username" + assert markers[0].description == {"suggested_value": "doej"} + assert markers[1] == "password" + assert markers[1].description == {"suggested_value": "verySecret1"} + assert markers[2] == "section_1" + section_validator = form["data_schema"].schema["section_1"] + assert isinstance(section_validator, data_entry_flow.section) + section_markers = list(section_validator.schema.schema) + assert len(section_markers) == 1 + assert section_markers[0] == "full_name" + assert section_markers[0].description == {"suggested_value": "John Doe"} + + async def test_abort_removes_instance(manager: MockFlowManager) -> None: """Test that abort removes the flow from progress.""" From 4e6eecf11b338a6e68fcb2c62b34d2b49ac351e2 Mon Sep 17 00:00:00 2001 From: tronikos Date: Mon, 24 Mar 2025 03:27:59 -0700 Subject: [PATCH 2947/3148] Retry Google Cloud exceptions (#141266) --- homeassistant/components/google_cloud/stt.py | 2 ++ homeassistant/components/google_cloud/tts.py | 7 ++++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/google_cloud/stt.py b/homeassistant/components/google_cloud/stt.py index 41c5a6710b7..cd5055383ea 100644 --- a/homeassistant/components/google_cloud/stt.py +++ b/homeassistant/components/google_cloud/stt.py @@ -6,6 +6,7 @@ from collections.abc import AsyncGenerator, AsyncIterable import logging from google.api_core.exceptions import GoogleAPIError, Unauthenticated +from google.api_core.retry import AsyncRetry from google.cloud import speech_v1 from homeassistant.components.stt import ( @@ -127,6 +128,7 @@ class GoogleCloudSpeechToTextEntity(SpeechToTextEntity): responses = await self._client.streaming_recognize( requests=request_generator(), timeout=10, + retry=AsyncRetry(initial=0.1, maximum=2.0, multiplier=2.0), ) transcript = "" diff --git a/homeassistant/components/google_cloud/tts.py b/homeassistant/components/google_cloud/tts.py index 1f5f838b593..16519645dee 100644 --- a/homeassistant/components/google_cloud/tts.py +++ b/homeassistant/components/google_cloud/tts.py @@ -7,6 +7,7 @@ from pathlib import Path from typing import Any, cast from google.api_core.exceptions import GoogleAPIError, Unauthenticated +from google.api_core.retry import AsyncRetry from google.cloud import texttospeech import voluptuous as vol @@ -215,7 +216,11 @@ class BaseGoogleCloudProvider: ), ) - response = await self._client.synthesize_speech(request, timeout=10) + response = await self._client.synthesize_speech( + request, + timeout=10, + retry=AsyncRetry(initial=0.1, maximum=2.0, multiplier=2.0), + ) if encoding == texttospeech.AudioEncoding.MP3: extension = "mp3" From f4bc1a35452550e928c1483d25a21e2ad64f5b17 Mon Sep 17 00:00:00 2001 From: tronikos Date: Mon, 24 Mar 2025 04:04:47 -0700 Subject: [PATCH 2948/3148] Bump androidtvremote2 to 0.2.1 (#141259) --- homeassistant/components/androidtv_remote/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/androidtv_remote/manifest.json b/homeassistant/components/androidtv_remote/manifest.json index 1c45e825359..89cc0fc3965 100644 --- a/homeassistant/components/androidtv_remote/manifest.json +++ b/homeassistant/components/androidtv_remote/manifest.json @@ -7,6 +7,6 @@ "integration_type": "device", "iot_class": "local_push", "loggers": ["androidtvremote2"], - "requirements": ["androidtvremote2==0.2.0"], + "requirements": ["androidtvremote2==0.2.1"], "zeroconf": ["_androidtvremote2._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index f3397e70bec..611b56b65e2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -464,7 +464,7 @@ amcrest==1.9.8 androidtv[async]==0.0.75 # homeassistant.components.androidtv_remote -androidtvremote2==0.2.0 +androidtvremote2==0.2.1 # homeassistant.components.anel_pwrctrl anel-pwrctrl-homeassistant==0.0.1.dev2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 92ec683dcdb..744ae62670a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -440,7 +440,7 @@ amberelectric==2.0.12 androidtv[async]==0.0.75 # homeassistant.components.androidtv_remote -androidtvremote2==0.2.0 +androidtvremote2==0.2.1 # homeassistant.components.anova anova-wifi==0.17.0 From 86ff540db90abc2df708bdcb8c9681917954c544 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Mon, 24 Mar 2025 12:19:29 +0100 Subject: [PATCH 2949/3148] Patch Z-Wave platforms in custom event tests (#141268) Patch Z-Wave platforms in custom events tests --- tests/components/zwave_js/test_events.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/components/zwave_js/test_events.py b/tests/components/zwave_js/test_events.py index 0bb6376a02b..8cdaef3e63d 100644 --- a/tests/components/zwave_js/test_events.py +++ b/tests/components/zwave_js/test_events.py @@ -6,11 +6,18 @@ import pytest from zwave_js_server.const import CommandClass from zwave_js_server.event import Event +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from tests.common import async_capture_events +@pytest.fixture +def platforms() -> list[str]: + """Fixture to specify platforms to test.""" + return [] + + async def test_scenes( hass: HomeAssistant, hank_binary_switch, integration, client ) -> None: @@ -244,6 +251,7 @@ async def test_notifications( assert events[2].data["command_class_name"] == "Multilevel Switch" +@pytest.mark.parametrize("platforms", [[Platform.SWITCH]]) async def test_value_updated( hass: HomeAssistant, vision_security_zl7432, integration, client ) -> None: From 75cd32b7426073056f07c49a2f4cd3b51ef22841 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Mon, 24 Mar 2025 12:36:57 +0100 Subject: [PATCH 2950/3148] Fix backup tests typing warnings (#141274) --- tests/components/backup/common.py | 30 +++++++++++------- tests/components/hassio/test_backup.py | 44 +++++++++++++++----------- 2 files changed, 45 insertions(+), 29 deletions(-) diff --git a/tests/components/backup/common.py b/tests/components/backup/common.py index e6e4b2f8a50..3197cbfadeb 100644 --- a/tests/components/backup/common.py +++ b/tests/components/backup/common.py @@ -2,9 +2,9 @@ from __future__ import annotations -from collections.abc import AsyncIterator, Callable, Coroutine, Iterable +from collections.abc import AsyncIterator, Buffer, Callable, Coroutine, Iterable from pathlib import Path -from typing import Any +from typing import Any, cast from unittest.mock import AsyncMock, Mock, patch from homeassistant.components.backup import ( @@ -16,6 +16,7 @@ from homeassistant.components.backup import ( BackupNotFound, Folder, ) +from homeassistant.components.backup.backup import CoreLocalBackupAgent from homeassistant.components.backup.const import DATA_MANAGER from homeassistant.core import HomeAssistant from homeassistant.helpers.backup import async_initialize_backup @@ -69,7 +70,7 @@ def mock_backup_agent(name: str, backups: list[AgentBackup] | None = None) -> Mo async def delete_backup(backup_id: str, **kwargs: Any) -> None: """Mock delete.""" - get_backup(backup_id) + await get_backup(backup_id) async def download_backup(backup_id: str, **kwargs: Any) -> AsyncIterator[bytes]: """Mock download.""" @@ -77,7 +78,7 @@ def mock_backup_agent(name: str, backups: list[AgentBackup] | None = None) -> Mo async def get_backup(backup_id: str, **kwargs: Any) -> AgentBackup: """Get a backup.""" - backup = next((b for b in backups if b.backup_id == backup_id), None) + backup = next((b for b in _backups if b.backup_id == backup_id), None) if backup is None: raise BackupNotFound return backup @@ -89,15 +90,15 @@ def mock_backup_agent(name: str, backups: list[AgentBackup] | None = None) -> Mo **kwargs: Any, ) -> None: """Upload a backup.""" - backups.append(backup) + _backups.append(backup) backup_stream = await open_stream() backup_data = bytearray() async for chunk in backup_stream: backup_data += chunk backups_data[backup.backup_id] = backup_data - backups = backups or [] - backups_data: dict[str, bytes] = {} + _backups = backups or [] + backups_data: dict[str, Buffer] = {} mock_agent = Mock(spec=BackupAgent) mock_agent.domain = TEST_DOMAIN mock_agent.name = name @@ -113,7 +114,7 @@ def mock_backup_agent(name: str, backups: list[AgentBackup] | None = None) -> Mo side_effect=get_backup, spec_set=[BackupAgent.async_get_backup] ) mock_agent.async_list_backups = AsyncMock( - return_value=backups, spec_set=[BackupAgent.async_list_backups] + return_value=_backups, spec_set=[BackupAgent.async_list_backups] ) mock_agent.async_upload_backup = AsyncMock( side_effect=upload_backup, @@ -160,11 +161,18 @@ async def setup_backup_integration( if LOCAL_AGENT_ID not in backups or with_hassio: return remote_agents_dict - agent = hass.data[DATA_MANAGER].backup_agents[LOCAL_AGENT_ID] + local_agent = cast( + CoreLocalBackupAgent, hass.data[DATA_MANAGER].backup_agents[LOCAL_AGENT_ID] + ) for backup in backups[LOCAL_AGENT_ID]: - await agent.async_upload_backup(open_stream=None, backup=backup) - agent._loaded_backups = True + await local_agent.async_upload_backup( + open_stream=AsyncMock( + side_effect=RuntimeError("Local agent does not open stream") + ), + backup=backup, + ) + local_agent._loaded_backups = True return remote_agents_dict diff --git a/tests/components/hassio/test_backup.py b/tests/components/hassio/test_backup.py index e00994b355a..af951fe8aa1 100644 --- a/tests/components/hassio/test_backup.py +++ b/tests/components/hassio/test_backup.py @@ -3,6 +3,7 @@ from collections.abc import ( AsyncGenerator, AsyncIterator, + Buffer, Callable, Coroutine, Generator, @@ -13,7 +14,7 @@ from datetime import datetime from io import StringIO import os from pathlib import PurePath -from typing import Any +from typing import Any, cast from unittest.mock import ANY, AsyncMock, Mock, patch from uuid import UUID @@ -341,7 +342,7 @@ def mock_backup_agent( async def delete_backup(backup_id: str, **kwargs: Any) -> None: """Mock delete.""" - get_backup(backup_id) + await get_backup(backup_id) async def download_backup(backup_id: str, **kwargs: Any) -> AsyncIterator[bytes]: """Mock download.""" @@ -349,7 +350,7 @@ def mock_backup_agent( async def get_backup(backup_id: str, **kwargs: Any) -> AgentBackup: """Get a backup.""" - backup = next((b for b in backups if b.backup_id == backup_id), None) + backup = next((b for b in _backups if b.backup_id == backup_id), None) if backup is None: raise BackupNotFound return backup @@ -361,15 +362,15 @@ def mock_backup_agent( **kwargs: Any, ) -> None: """Upload a backup.""" - backups.append(backup) + _backups.append(backup) backup_stream = await open_stream() backup_data = bytearray() async for chunk in backup_stream: backup_data += chunk backups_data[backup.backup_id] = backup_data - backups = backups or [] - backups_data: dict[str, bytes] = {} + _backups = backups or [] + backups_data: dict[str, Buffer] = {} mock_agent = Mock(spec=BackupAgent) mock_agent.domain = domain mock_agent.name = name @@ -401,7 +402,7 @@ async def _setup_backup_platform( platform: BackupAgentPlatformProtocol, ) -> None: """Set up a mock domain.""" - mock_platform(hass, f"{domain}.backup", platform) + mock_platform(hass, f"{domain}.backup", cast(Mock, platform)) assert await async_setup_component(hass, domain, {}) await hass.async_block_till_done() @@ -423,7 +424,7 @@ async def _setup_backup_platform( name="test", read_only=False, state=supervisor_mounts.MountState.ACTIVE, - user_path="test", + user_path=PurePath("test"), usage=supervisor_mounts.MountUsage.BACKUP, server="test", type=supervisor_mounts.MountType.CIFS, @@ -441,7 +442,7 @@ async def _setup_backup_platform( name="test", read_only=False, state=supervisor_mounts.MountState.ACTIVE, - user_path="test", + user_path=PurePath("test"), usage=supervisor_mounts.MountUsage.MEDIA, server="test", type=supervisor_mounts.MountType.CIFS, @@ -854,7 +855,7 @@ DEFAULT_BACKUP_OPTIONS = supervisor_backups.PartialBackupOptions( "with_automatic_settings": False, }, filename=PurePath("Test_2025-01-30_05.42_12345678.tar"), - folders={"ssl"}, + folders={supervisor_backups.Folder("ssl")}, homeassistant_exclude_database=False, homeassistant=True, location=[LOCATION_LOCAL_STORAGE], @@ -877,7 +878,7 @@ DEFAULT_BACKUP_OPTIONS = supervisor_backups.PartialBackupOptions( ), ( {"include_all_addons": True}, - replace(DEFAULT_BACKUP_OPTIONS, addons="ALL"), + replace(DEFAULT_BACKUP_OPTIONS, addons=supervisor_backups.AddonSet("ALL")), ), ( {"include_database": False}, @@ -885,7 +886,14 @@ DEFAULT_BACKUP_OPTIONS = supervisor_backups.PartialBackupOptions( ), ( {"include_folders": ["media", "share"]}, - replace(DEFAULT_BACKUP_OPTIONS, folders={"media", "share", "ssl"}), + replace( + DEFAULT_BACKUP_OPTIONS, + folders={ + supervisor_backups.Folder("media"), + supervisor_backups.Folder("share"), + supervisor_backups.Folder("ssl"), + }, + ), ), ( { @@ -895,7 +903,7 @@ DEFAULT_BACKUP_OPTIONS = supervisor_backups.PartialBackupOptions( }, replace( DEFAULT_BACKUP_OPTIONS, - folders={"media"}, + folders={supervisor_backups.Folder("media")}, homeassistant=False, homeassistant_exclude_database=True, ), @@ -1251,11 +1259,11 @@ async def test_reader_writer_create_per_agent_encryption( hass_ws_client: WebSocketGenerator, freezer: FrozenDateTimeFactory, supervisor_client: AsyncMock, - commands: dict[str, Any], + commands: list[dict[str, Any]], password: str | None, agent_ids: list[str], password_sent_to_supervisor: str | None, - create_locations: list[str | None], + create_locations: list[str], create_protected: bool, upload_locations: list[str | None], ) -> None: @@ -1270,7 +1278,7 @@ async def test_reader_writer_create_per_agent_encryption( name=f"share{i}", read_only=False, state=supervisor_mounts.MountState.ACTIVE, - user_path=f"share{i}", + user_path=PurePath(f"share{i}"), usage=supervisor_mounts.MountUsage.BACKUP, server=f"share{i}", type=supervisor_mounts.MountType.CIFS, @@ -1996,7 +2004,7 @@ async def test_reader_writer_restore_remote_backup( homeassistant_version="2024.12.0", name="Test", protected=False, - size=0.0, + size=0, ) remote_agent = mock_backup_agent("remote", backups=[test_backup]) await _setup_backup_platform( @@ -2626,7 +2634,7 @@ async def test_config_load_config_info( freezer: FrozenDateTimeFactory, snapshot: SnapshotAssertion, hass_storage: dict[str, Any], - storage_data: dict[str, Any] | None, + storage_data: dict[str, Any], ) -> None: """Test loading stored backup config and reading it via config/info.""" client = await hass_ws_client(hass) From 1ae2cebeb134c6c66592a3d45cd38f9cb2709945 Mon Sep 17 00:00:00 2001 From: karwosts <32912880+karwosts@users.noreply.github.com> Date: Mon, 24 Mar 2025 04:37:55 -0700 Subject: [PATCH 2951/3148] Support for hierarchy of individual energy devices (#132616) * Support for hierarchy of individual energy devices * update DeviceConsumption dict * change name parent to 'included_in' * Break comment --------- Co-authored-by: Martin Hjelmare --- homeassistant/components/energy/data.py | 5 +++++ tests/components/energy/test_websocket_api.py | 8 +++++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/energy/data.py b/homeassistant/components/energy/data.py index ff86177cf41..442aedf23b0 100644 --- a/homeassistant/components/energy/data.py +++ b/homeassistant/components/energy/data.py @@ -139,6 +139,10 @@ class DeviceConsumption(TypedDict): # An optional custom name for display in energy graphs name: str | None + # An optional statistic_id identifying a device + # that includes this device's consumption in its total + included_in_stat: str | None + class EnergyPreferences(TypedDict): """Dictionary holding the energy data.""" @@ -291,6 +295,7 @@ DEVICE_CONSUMPTION_SCHEMA = vol.Schema( { vol.Required("stat_consumption"): str, vol.Optional("name"): str, + vol.Optional("included_in_stat"): str, } ) diff --git a/tests/components/energy/test_websocket_api.py b/tests/components/energy/test_websocket_api.py index 959ec7d1687..e4b0e568a70 100644 --- a/tests/components/energy/test_websocket_api.py +++ b/tests/components/energy/test_websocket_api.py @@ -149,7 +149,13 @@ async def test_save_preferences( "stat_energy_to": "my_battery_charging", }, ], - "device_consumption": [{"stat_consumption": "some_device_usage"}], + "device_consumption": [ + { + "stat_consumption": "some_device_usage", + "name": "My Device", + "included_in_stat": "sensor.some_other_device", + } + ], } await client.send_json({"id": 6, "type": "energy/save_prefs", **new_prefs}) From 265a2ace904f32a5c96df3b7f0e3dbdedfa931c7 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 24 Mar 2025 12:43:53 +0100 Subject: [PATCH 2952/3148] Add Bubble soak switch to SmartThings (#141139) * Add Bubble soak switch to SmartThings * Fix --- .../components/smartthings/icons.json | 6 +++ .../components/smartthings/strings.json | 3 ++ .../components/smartthings/switch.py | 18 +++++++ .../smartthings/snapshots/test_switch.ambr | 47 +++++++++++++++++++ 4 files changed, 74 insertions(+) diff --git a/homeassistant/components/smartthings/icons.json b/homeassistant/components/smartthings/icons.json index 80ac70edc3f..670d23c8c27 100644 --- a/homeassistant/components/smartthings/icons.json +++ b/homeassistant/components/smartthings/icons.json @@ -34,6 +34,12 @@ } }, "switch": { + "bubble_soak": { + "default": "mdi:water-off", + "state": { + "on": "mdi:water" + } + }, "wrinkle_prevent": { "default": "mdi:tumble-dryer", "state": { diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index 25872dca82c..50094b21633 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -458,6 +458,9 @@ } }, "switch": { + "bubble_soak": { + "name": "Bubble Soak" + }, "wrinkle_prevent": { "name": "Wrinkle prevent" } diff --git a/homeassistant/components/smartthings/switch.py b/homeassistant/components/smartthings/switch.py index 6e0dc1ac93d..014b11c5329 100644 --- a/homeassistant/components/smartthings/switch.py +++ b/homeassistant/components/smartthings/switch.py @@ -59,6 +59,13 @@ CAPABILITY_TO_COMMAND_SWITCHES: dict[ command=Command.SET_DRYER_WRINKLE_PREVENT, ) } +CAPABILITY_TO_SWITCHES: dict[Capability | str, SmartThingsSwitchEntityDescription] = { + Capability.SAMSUNG_CE_WASHER_BUBBLE_SOAK: SmartThingsSwitchEntityDescription( + key=Capability.SAMSUNG_CE_WASHER_BUBBLE_SOAK, + translation_key="bubble_soak", + status_attribute=Attribute.STATUS, + ) +} async def async_setup_entry( @@ -86,6 +93,17 @@ async def async_setup_entry( for capability, description in CAPABILITY_TO_COMMAND_SWITCHES.items() if capability in device.status[MAIN] ) + entities.extend( + SmartThingsSwitch( + entry_data.client, + device, + description, + Capability(capability), + ) + for device in entry_data.devices.values() + for capability, description in CAPABILITY_TO_SWITCHES.items() + if capability in device.status[MAIN] + ) async_add_entities(entities) diff --git a/tests/components/smartthings/snapshots/test_switch.ambr b/tests/components/smartthings/snapshots/test_switch.ambr index 40f242e82f5..678c204ab00 100644 --- a/tests/components/smartthings/snapshots/test_switch.ambr +++ b/tests/components/smartthings/snapshots/test_switch.ambr @@ -516,6 +516,53 @@ 'state': 'on', }) # --- +# name: test_all_entities[da_wm_wm_000001_1][switch.washing_machine_bubble_soak-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.washing_machine_bubble_soak', + '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': 'Bubble Soak', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'bubble_soak', + 'unique_id': '63803fae-cbed-f356-a063-2cf148ae3ca7_main_samsungce.washerBubbleSoak', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wm_000001_1][switch.washing_machine_bubble_soak-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Washing Machine Bubble Soak', + }), + 'context': , + 'entity_id': 'switch.washing_machine_bubble_soak', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_all_entities[generic_ef00_v1][switch.thermostat_kuche-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From e96e95c32d0b70f4adf327223c4b5e4fd2a83ee7 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Mon, 24 Mar 2025 12:54:16 +0100 Subject: [PATCH 2953/3148] Add sensor platform to backup integration (#138663) * add sensor platform to backup integration * adjust namings, remove system integration flag * add first simple test * apply review comments * fix test * add sensor tests * adjustements to use backup helper * remove obsolet async_get_manager from init * unsubscribe from events on entry unload * add configuration_url * fix doc string * fix sensor tests * mark async_unsubscribe as callback * set integration_type service * extend sensor test * set integration_type on correct integration :) * fix after online conflict resolution * add sensor update tests * simplify the sensor update tests * avoid io during tests * Add comment --------- Co-authored-by: Martin Hjelmare --- homeassistant/components/backup/__init__.py | 33 +++- .../components/backup/config_flow.py | 21 +++ .../components/backup/coordinator.py | 81 +++++++++ homeassistant/components/backup/entity.py | 36 ++++ homeassistant/components/backup/manager.py | 13 ++ homeassistant/components/backup/manifest.json | 5 +- homeassistant/components/backup/sensor.py | 75 ++++++++ homeassistant/components/backup/strings.json | 19 +++ homeassistant/generated/integrations.json | 7 + homeassistant/helpers/backup.py | 26 ++- .../backup/snapshots/test_sensors.ambr | 160 ++++++++++++++++++ tests/components/backup/test_init.py | 16 ++ tests/components/backup/test_sensors.py | 119 +++++++++++++ 13 files changed, 607 insertions(+), 4 deletions(-) create mode 100644 homeassistant/components/backup/config_flow.py create mode 100644 homeassistant/components/backup/coordinator.py create mode 100644 homeassistant/components/backup/entity.py create mode 100644 homeassistant/components/backup/sensor.py create mode 100644 tests/components/backup/snapshots/test_sensors.ambr create mode 100644 tests/components/backup/test_sensors.py diff --git a/homeassistant/components/backup/__init__.py b/homeassistant/components/backup/__init__.py index d9d1c3cc2fe..124ce8b872c 100644 --- a/homeassistant/components/backup/__init__.py +++ b/homeassistant/components/backup/__init__.py @@ -1,7 +1,9 @@ """The Backup integration.""" +from homeassistant.config_entries import SOURCE_SYSTEM +from homeassistant.const import Platform from homeassistant.core import HomeAssistant, ServiceCall -from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import config_validation as cv, discovery_flow from homeassistant.helpers.backup import DATA_BACKUP from homeassistant.helpers.hassio import is_hassio from homeassistant.helpers.typing import ConfigType @@ -18,10 +20,12 @@ from .agent import ( ) from .config import BackupConfig, CreateBackupParametersDict from .const import DATA_MANAGER, DOMAIN +from .coordinator import BackupConfigEntry, BackupDataUpdateCoordinator from .http import async_register_http_views from .manager import ( BackupManager, BackupManagerError, + BackupPlatformEvent, BackupPlatformProtocol, BackupReaderWriter, BackupReaderWriterError, @@ -52,6 +56,7 @@ __all__ = [ "BackupConfig", "BackupManagerError", "BackupNotFound", + "BackupPlatformEvent", "BackupPlatformProtocol", "BackupReaderWriter", "BackupReaderWriterError", @@ -74,6 +79,8 @@ __all__ = [ "suggested_filename_from_name_date", ] +PLATFORMS = [Platform.SENSOR] + CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) @@ -128,4 +135,28 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async_register_http_views(hass) + discovery_flow.async_create_flow( + hass, DOMAIN, context={"source": SOURCE_SYSTEM}, data={} + ) + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: BackupConfigEntry) -> bool: + """Set up a config entry.""" + backup_manager: BackupManager = hass.data[DATA_MANAGER] + coordinator = BackupDataUpdateCoordinator(hass, entry, backup_manager) + await coordinator.async_config_entry_first_refresh() + + entry.async_on_unload(coordinator.async_unsubscribe) + + entry.runtime_data = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: BackupConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/backup/config_flow.py b/homeassistant/components/backup/config_flow.py new file mode 100644 index 00000000000..ab1f884ea86 --- /dev/null +++ b/homeassistant/components/backup/config_flow.py @@ -0,0 +1,21 @@ +"""Config flow for Home Assistant Backup integration.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult + +from .const import DOMAIN + + +class BackupConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Home Assistant Backup.""" + + VERSION = 1 + + async def async_step_system( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + return self.async_create_entry(title="Backup", data={}) diff --git a/homeassistant/components/backup/coordinator.py b/homeassistant/components/backup/coordinator.py new file mode 100644 index 00000000000..377f23567e0 --- /dev/null +++ b/homeassistant/components/backup/coordinator.py @@ -0,0 +1,81 @@ +"""Coordinator for Home Assistant Backup integration.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from datetime import datetime + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.backup import ( + async_subscribe_events, + async_subscribe_platform_events, +) +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import DOMAIN, LOGGER +from .manager import ( + BackupManager, + BackupManagerState, + BackupPlatformEvent, + ManagerStateEvent, +) + +type BackupConfigEntry = ConfigEntry[BackupDataUpdateCoordinator] + + +@dataclass +class BackupCoordinatorData: + """Class to hold backup data.""" + + backup_manager_state: BackupManagerState + last_successful_automatic_backup: datetime | None + next_scheduled_automatic_backup: datetime | None + + +class BackupDataUpdateCoordinator(DataUpdateCoordinator[BackupCoordinatorData]): + """Class to retrieve backup status.""" + + config_entry: ConfigEntry + + def __init__( + self, + hass: HomeAssistant, + config_entry: ConfigEntry, + backup_manager: BackupManager, + ) -> None: + """Initialize coordinator.""" + super().__init__( + hass, + LOGGER, + config_entry=config_entry, + name=DOMAIN, + update_interval=None, + ) + self.unsubscribe: list[Callable[[], None]] = [ + async_subscribe_events(hass, self._on_event), + async_subscribe_platform_events(hass, self._on_event), + ] + + self.backup_manager = backup_manager + + @callback + def _on_event(self, event: ManagerStateEvent | BackupPlatformEvent) -> None: + """Handle new event.""" + LOGGER.debug("Received backup event: %s", event) + self.config_entry.async_create_task(self.hass, self.async_refresh()) + + async def _async_update_data(self) -> BackupCoordinatorData: + """Update backup manager data.""" + return BackupCoordinatorData( + self.backup_manager.state, + self.backup_manager.config.data.last_completed_automatic_backup, + self.backup_manager.config.data.schedule.next_automatic_backup, + ) + + @callback + def async_unsubscribe(self) -> None: + """Unsubscribe from events.""" + for unsub in self.unsubscribe: + unsub() diff --git a/homeassistant/components/backup/entity.py b/homeassistant/components/backup/entity.py new file mode 100644 index 00000000000..ff7c7889dc5 --- /dev/null +++ b/homeassistant/components/backup/entity.py @@ -0,0 +1,36 @@ +"""Base for backup entities.""" + +from __future__ import annotations + +from homeassistant.const import __version__ as HA_VERSION +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 BackupDataUpdateCoordinator + + +class BackupManagerEntity(CoordinatorEntity[BackupDataUpdateCoordinator]): + """Base entity for backup manager.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: BackupDataUpdateCoordinator, + entity_description: EntityDescription, + ) -> None: + """Initialize base entity.""" + super().__init__(coordinator) + self.entity_description = entity_description + self._attr_unique_id = entity_description.key + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, "backup_manager")}, + manufacturer="Home Assistant", + model="Home Assistant Backup", + sw_version=HA_VERSION, + name="Backup", + entry_type=DeviceEntryType.SERVICE, + configuration_url="homeassistant://config/backup", + ) diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index 6dbe863185c..4bcdf7597b2 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -229,6 +229,13 @@ class RestoreBackupEvent(ManagerStateEvent): state: RestoreBackupState +@dataclass(frozen=True, kw_only=True, slots=True) +class BackupPlatformEvent: + """Backup platform class.""" + + domain: str + + @dataclass(frozen=True, kw_only=True, slots=True) class BlockedEvent(ManagerStateEvent): """Backup manager blocked, Home Assistant is starting.""" @@ -355,6 +362,9 @@ class BackupManager: self._backup_event_subscriptions = hass.data[ DATA_BACKUP ].backup_event_subscriptions + self._backup_platform_event_subscriptions = hass.data[ + DATA_BACKUP + ].backup_platform_event_subscriptions async def async_setup(self) -> None: """Set up the backup manager.""" @@ -465,6 +475,9 @@ class BackupManager: LOGGER.debug("%s platforms loaded in total", len(self.platforms)) LOGGER.debug("%s agents loaded in total", len(self.backup_agents)) LOGGER.debug("%s local agents loaded in total", len(self.local_backup_agents)) + event = BackupPlatformEvent(domain=integration_domain) + for subscription in self._backup_platform_event_subscriptions: + subscription(event) async def async_pre_backup_actions(self) -> None: """Perform pre backup actions.""" diff --git a/homeassistant/components/backup/manifest.json b/homeassistant/components/backup/manifest.json index db0719983b1..3c7b1e5e014 100644 --- a/homeassistant/components/backup/manifest.json +++ b/homeassistant/components/backup/manifest.json @@ -5,8 +5,9 @@ "codeowners": ["@home-assistant/core"], "dependencies": ["http", "websocket_api"], "documentation": "https://www.home-assistant.io/integrations/backup", - "integration_type": "system", + "integration_type": "service", "iot_class": "calculated", "quality_scale": "internal", - "requirements": ["cronsim==2.6", "securetar==2025.2.1"] + "requirements": ["cronsim==2.6", "securetar==2025.2.1"], + "single_config_entry": true } diff --git a/homeassistant/components/backup/sensor.py b/homeassistant/components/backup/sensor.py new file mode 100644 index 00000000000..59e98ae7c2d --- /dev/null +++ b/homeassistant/components/backup/sensor.py @@ -0,0 +1,75 @@ +"""Sensor platform for Home Assistant Backup integration.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from datetime import datetime + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import BackupConfigEntry, BackupCoordinatorData +from .entity import BackupManagerEntity +from .manager import BackupManagerState + + +@dataclass(kw_only=True, frozen=True) +class BackupSensorEntityDescription(SensorEntityDescription): + """Description for Home Assistant Backup sensor entities.""" + + value_fn: Callable[[BackupCoordinatorData], str | datetime | None] + + +BACKUP_MANAGER_DESCRIPTIONS = ( + BackupSensorEntityDescription( + key="backup_manager_state", + translation_key="backup_manager_state", + device_class=SensorDeviceClass.ENUM, + options=[state.value for state in BackupManagerState], + value_fn=lambda data: data.backup_manager_state, + ), + BackupSensorEntityDescription( + key="next_scheduled_automatic_backup", + translation_key="next_scheduled_automatic_backup", + device_class=SensorDeviceClass.TIMESTAMP, + value_fn=lambda data: data.next_scheduled_automatic_backup, + ), + BackupSensorEntityDescription( + key="last_successful_automatic_backup", + translation_key="last_successful_automatic_backup", + device_class=SensorDeviceClass.TIMESTAMP, + value_fn=lambda data: data.last_successful_automatic_backup, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: BackupConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Sensor set up for backup config entry.""" + + coordinator = config_entry.runtime_data + + async_add_entities( + BackupManagerSensor(coordinator, description) + for description in BACKUP_MANAGER_DESCRIPTIONS + ) + + +class BackupManagerSensor(BackupManagerEntity, SensorEntity): + """Sensor to track backup manager state.""" + + entity_description: BackupSensorEntityDescription + + @property + def native_value(self) -> str | datetime | None: + """Return native value of entity.""" + return self.entity_description.value_fn(self.coordinator.data) diff --git a/homeassistant/components/backup/strings.json b/homeassistant/components/backup/strings.json index c3047d3a4ac..487fdd89a7c 100644 --- a/homeassistant/components/backup/strings.json +++ b/homeassistant/components/backup/strings.json @@ -22,5 +22,24 @@ "name": "Create automatic backup", "description": "Creates a new backup with automatic backup settings." } + }, + "entity": { + "sensor": { + "backup_manager_state": { + "name": "Backup Manager State", + "state": { + "idle": "Idle", + "create_backup": "Creating a backup", + "receive_backup": "Receiving a backup", + "restore_backup": "Restoring a backup" + } + }, + "next_scheduled_automatic_backup": { + "name": "Next scheduled automatic backup" + }, + "last_successful_automatic_backup": { + "name": "Last successful automatic backup" + } + } } } diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 55fcb08ba92..64547488e69 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -611,6 +611,13 @@ "config_flow": true, "iot_class": "local_push" }, + "backup": { + "name": "Backup", + "integration_type": "service", + "config_flow": false, + "iot_class": "calculated", + "single_config_entry": true + }, "baf": { "name": "Big Ass Fans", "integration_type": "hub", diff --git a/homeassistant/helpers/backup.py b/homeassistant/helpers/backup.py index 4ab302749a1..b3607f6653c 100644 --- a/homeassistant/helpers/backup.py +++ b/homeassistant/helpers/backup.py @@ -12,7 +12,11 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.util.hass_dict import HassKey if TYPE_CHECKING: - from homeassistant.components.backup import BackupManager, ManagerStateEvent + from homeassistant.components.backup import ( + BackupManager, + BackupPlatformEvent, + ManagerStateEvent, + ) DATA_BACKUP: HassKey[BackupData] = HassKey("backup_data") DATA_MANAGER: HassKey[BackupManager] = HassKey("backup") @@ -25,6 +29,9 @@ class BackupData: backup_event_subscriptions: list[Callable[[ManagerStateEvent], None]] = field( default_factory=list ) + backup_platform_event_subscriptions: list[Callable[[BackupPlatformEvent], None]] = ( + field(default_factory=list) + ) manager_ready: asyncio.Future[None] = field(default_factory=asyncio.Future) @@ -68,3 +75,20 @@ def async_subscribe_events( backup_event_subscriptions.append(on_event) return remove_subscription + + +@callback +def async_subscribe_platform_events( + hass: HomeAssistant, + on_event: Callable[[BackupPlatformEvent], None], +) -> Callable[[], None]: + """Subscribe to backup platform events.""" + backup_platform_event_subscriptions = hass.data[ + DATA_BACKUP + ].backup_platform_event_subscriptions + + def remove_subscription() -> None: + backup_platform_event_subscriptions.remove(on_event) + + backup_platform_event_subscriptions.append(on_event) + return remove_subscription diff --git a/tests/components/backup/snapshots/test_sensors.ambr b/tests/components/backup/snapshots/test_sensors.ambr new file mode 100644 index 00000000000..924038ef81f --- /dev/null +++ b/tests/components/backup/snapshots/test_sensors.ambr @@ -0,0 +1,160 @@ +# serializer version: 1 +# name: test_sensors[sensor.backup_backup_manager_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'idle', + 'create_backup', + 'blocked', + 'receive_backup', + 'restore_backup', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.backup_backup_manager_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Backup Manager State', + 'platform': 'backup', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'backup_manager_state', + 'unique_id': 'backup_manager_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.backup_backup_manager_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Backup Backup Manager State', + 'options': list([ + 'idle', + 'create_backup', + 'blocked', + 'receive_backup', + 'restore_backup', + ]), + }), + 'context': , + 'entity_id': 'sensor.backup_backup_manager_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'idle', + }) +# --- +# name: test_sensors[sensor.backup_last_successful_automatic_backup-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.backup_last_successful_automatic_backup', + '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 successful automatic backup', + 'platform': 'backup', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'last_successful_automatic_backup', + 'unique_id': 'last_successful_automatic_backup', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.backup_last_successful_automatic_backup-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Backup Last successful automatic backup', + }), + 'context': , + 'entity_id': 'sensor.backup_last_successful_automatic_backup', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[sensor.backup_next_scheduled_automatic_backup-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.backup_next_scheduled_automatic_backup', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Next scheduled automatic backup', + 'platform': 'backup', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'next_scheduled_automatic_backup', + 'unique_id': 'next_scheduled_automatic_backup', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.backup_next_scheduled_automatic_backup-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Backup Next scheduled automatic backup', + }), + 'context': , + 'entity_id': 'sensor.backup_next_scheduled_automatic_backup', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/backup/test_init.py b/tests/components/backup/test_init.py index 8a0cc2b97c0..10bd2d8b97a 100644 --- a/tests/components/backup/test_init.py +++ b/tests/components/backup/test_init.py @@ -6,11 +6,13 @@ from unittest.mock import patch import pytest from homeassistant.components.backup.const import DATA_MANAGER, DOMAIN +from homeassistant.config_entries import SOURCE_SYSTEM, ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceNotFound from .common import setup_backup_integration +from tests.common import MockConfigEntry from tests.typing import WebSocketGenerator @@ -141,3 +143,17 @@ async def test_create_automatic_service( ) generate_backup.assert_called_once_with(**expected_kwargs) + + +async def test_setup_entry( + hass: HomeAssistant, +) -> None: + """Test setup backup config entry.""" + await setup_backup_integration(hass, with_hassio=False) + entry = MockConfigEntry(domain=DOMAIN, source=SOURCE_SYSTEM) + entry.add_to_hass(hass) + + with patch("homeassistant.components.backup.PLATFORMS", return_value=[]): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + assert entry.state is ConfigEntryState.LOADED diff --git a/tests/components/backup/test_sensors.py b/tests/components/backup/test_sensors.py new file mode 100644 index 00000000000..bee61887ea5 --- /dev/null +++ b/tests/components/backup/test_sensors.py @@ -0,0 +1,119 @@ +"""Tests for the sensors of the Backup integration.""" + +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +from freezegun.api import FrozenDateTimeFactory +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.backup import store +from homeassistant.components.backup.const import DOMAIN +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .common import setup_backup_integration + +from tests.common import async_fire_time_changed, snapshot_platform +from tests.typing import WebSocketGenerator + + +@pytest.mark.usefixtures("mock_backup_generation") +async def test_sensors( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test setup of backup sensors.""" + with patch("homeassistant.components.backup.PLATFORMS", [Platform.SENSOR]): + await setup_backup_integration(hass, with_hassio=False) + await hass.async_block_till_done(wait_background_tasks=True) + + entry = hass.config_entries.async_entries(DOMAIN)[0] + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) + + # start backup and check sensor states again + client = await hass_ws_client(hass) + await hass.async_block_till_done() + await client.send_json_auto_id( + {"type": "backup/generate", "agent_ids": ["backup.local"]} + ) + + assert await client.receive_json() + state = hass.states.get("sensor.backup_backup_manager_state") + assert state.state == "create_backup" + + await hass.async_block_till_done(wait_background_tasks=True) + state = hass.states.get("sensor.backup_backup_manager_state") + assert state.state == "idle" + + +async def test_sensor_updates( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + freezer: FrozenDateTimeFactory, + hass_storage: dict[str, Any], + create_backup: AsyncMock, +) -> None: + """Test update of backup sensors.""" + # Ensure created backup is already protected, + # to avoid manager creating a new EncryptedBackupStreamer + # instead of using the already mocked stream writer. + created_backup: MagicMock = create_backup.return_value[1].result().backup + created_backup.protected = True + + await hass.config.async_set_time_zone("Europe/Amsterdam") + freezer.move_to("2024-11-12T12:00:00+01:00") + storage_data = { + "backups": [], + "config": { + "agents": {}, + "automatic_backups_configured": True, + "create_backup": { + "agent_ids": ["test.remote"], + "include_addons": [], + "include_all_addons": False, + "include_database": True, + "include_folders": [], + "name": "test-name", + "password": "test-password", + }, + "retention": {"copies": None, "days": None}, + "last_attempted_automatic_backup": "2024-11-11T04:45:00+01:00", + "last_completed_automatic_backup": "2024-11-11T04:45:00+01:00", + "schedule": { + "days": [], + "recurrence": "daily", + "state": "never", + "time": "06:00", + }, + }, + } + hass_storage[DOMAIN] = { + "data": storage_data, + "key": DOMAIN, + "version": store.STORAGE_VERSION, + "minor_version": store.STORAGE_VERSION_MINOR, + } + + with patch("homeassistant.components.backup.PLATFORMS", [Platform.SENSOR]): + await setup_backup_integration( + hass, with_hassio=False, remote_agents=["test.remote"] + ) + await hass.async_block_till_done(wait_background_tasks=True) + + state = hass.states.get("sensor.backup_last_successful_automatic_backup") + assert state.state == "2024-11-11T03:45:00+00:00" + state = hass.states.get("sensor.backup_next_scheduled_automatic_backup") + assert state.state == "2024-11-13T05:00:00+00:00" + + freezer.move_to("2024-11-13T12:00:00+01:00") + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get("sensor.backup_last_successful_automatic_backup") + assert state.state == "2024-11-13T11:00:00+00:00" + state = hass.states.get("sensor.backup_next_scheduled_automatic_backup") + assert state.state == "2024-11-14T05:00:00+00:00" From 83a0ed4250a8990bf50c088f81b5a16dc14f44a3 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Mon, 24 Mar 2025 13:57:08 +0100 Subject: [PATCH 2954/3148] Update Vodafone Station quality scale (#141196) --- .../components/vodafone_station/quality_scale.yaml | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/vodafone_station/quality_scale.yaml b/homeassistant/components/vodafone_station/quality_scale.yaml index fe114b4b324..d8476842b53 100644 --- a/homeassistant/components/vodafone_station/quality_scale.yaml +++ b/homeassistant/components/vodafone_station/quality_scale.yaml @@ -47,20 +47,14 @@ rules: status: exempt comment: device not discoverable docs-data-update: done - docs-examples: - status: todo - comment: add some automation example + docs-examples: done docs-known-limitations: status: exempt comment: no known limitations, yet docs-supported-devices: done docs-supported-functions: done - docs-troubleshooting: - status: todo - comment: add some info for troubleshooting - docs-use-cases: - status: todo - comment: add some use caes + docs-troubleshooting: done + docs-use-cases: done dynamic-devices: done entity-category: done entity-device-class: done From 358f78c7cd74e19952f61c98e20cbc2e148956b4 Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Mon, 24 Mar 2025 14:28:12 +0100 Subject: [PATCH 2955/3148] Tado migrate to OAuth Device Flow (#140761) * Bump PyTado 0.19.0 * Initial setup * Current state * Update to PyTado 0.18.8 * First concept for review * Fix * Fix * Fix * First concept for review * Bump PyTado to 0.18.9 * Remove redundant part * Initial test setup * Authentication exceptions * Fix * Fix * Fix * Update version to 2 * All migration code * Small tuning * Add reauth unique ID check * Add reauth test * 100% on config flow * Making tests working on new device flow * Fix * Fix * Fix * Update homeassistant/components/tado/strings.json * Update homeassistant/components/tado/strings.json --------- Co-authored-by: Joostlek Co-authored-by: Josef Zweck --- homeassistant/components/tado/__init__.py | 53 ++- homeassistant/components/tado/config_flow.py | 237 +++++----- homeassistant/components/tado/const.py | 1 + homeassistant/components/tado/coordinator.py | 17 +- homeassistant/components/tado/manifest.json | 2 +- homeassistant/components/tado/strings.json | 37 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/tado/__init__.py | 2 +- tests/components/tado/conftest.py | 50 ++ .../tado/fixtures/device_authorize.json | 8 + tests/components/tado/test_config_flow.py | 441 +++++++----------- tests/components/tado/test_helper.py | 6 +- tests/components/tado/test_init.py | 30 ++ tests/components/tado/util.py | 12 +- 15 files changed, 470 insertions(+), 430 deletions(-) create mode 100644 tests/components/tado/conftest.py create mode 100644 tests/components/tado/fixtures/device_authorize.json create mode 100644 tests/components/tado/test_init.py diff --git a/homeassistant/components/tado/__init__.py b/homeassistant/components/tado/__init__.py index 4b0203acda3..d1994075f12 100644 --- a/homeassistant/components/tado/__init__.py +++ b/homeassistant/components/tado/__init__.py @@ -10,12 +10,17 @@ from PyTado.interface import Tado from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + ConfigEntryError, + ConfigEntryNotReady, +) from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from .const import ( CONF_FALLBACK, + CONF_REFRESH_TOKEN, CONST_OVERLAY_MANUAL, CONST_OVERLAY_TADO_DEFAULT, CONST_OVERLAY_TADO_MODE, @@ -56,23 +61,34 @@ type TadoConfigEntry = ConfigEntry[TadoData] async def async_setup_entry(hass: HomeAssistant, entry: TadoConfigEntry) -> bool: """Set up Tado from a config entry.""" + if CONF_REFRESH_TOKEN not in entry.data: + raise ConfigEntryAuthFailed _async_import_options_from_data_if_missing(hass, entry) _LOGGER.debug("Setting up Tado connection") + _LOGGER.debug( + "Creating tado instance with refresh token: %s", + entry.data[CONF_REFRESH_TOKEN], + ) + + def create_tado_instance() -> tuple[Tado, str]: + """Create a Tado instance, this time with a previously obtained refresh token.""" + tado = Tado(saved_refresh_token=entry.data[CONF_REFRESH_TOKEN]) + return tado, tado.device_activation_status() + try: - tado = await hass.async_add_executor_job( - Tado, - entry.data[CONF_USERNAME], - entry.data[CONF_PASSWORD], - ) + tado, device_status = await hass.async_add_executor_job(create_tado_instance) except PyTado.exceptions.TadoWrongCredentialsException as err: raise ConfigEntryError(f"Invalid Tado credentials. Error: {err}") from err except PyTado.exceptions.TadoException as err: raise ConfigEntryNotReady(f"Error during Tado setup: {err}") from err - _LOGGER.debug( - "Tado connection established for username: %s", entry.data[CONF_USERNAME] - ) + if device_status != "COMPLETED": + raise ConfigEntryAuthFailed( + f"Device login flow status is {device_status}. Starting re-authentication." + ) + + _LOGGER.debug("Tado connection established") coordinator = TadoDataUpdateCoordinator(hass, entry, tado) await coordinator.async_config_entry_first_refresh() @@ -82,11 +98,23 @@ async def async_setup_entry(hass: HomeAssistant, entry: TadoConfigEntry) -> bool entry.runtime_data = TadoData(coordinator, mobile_coordinator) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - entry.async_on_unload(entry.add_update_listener(update_listener)) return True +async def async_migrate_entry(hass: HomeAssistant, entry: TadoConfigEntry) -> bool: + """Migrate old entry.""" + + if entry.version < 2: + _LOGGER.debug("Migrating Tado entry to version 2. Current data: %s", entry.data) + data = dict(entry.data) + data.pop(CONF_USERNAME, None) + data.pop(CONF_PASSWORD, None) + hass.config_entries.async_update_entry(entry=entry, data=data, version=2) + _LOGGER.debug("Migration to version 2 successful") + return True + + @callback def _async_import_options_from_data_if_missing( hass: HomeAssistant, entry: TadoConfigEntry @@ -106,11 +134,6 @@ def _async_import_options_from_data_if_missing( hass.config_entries.async_update_entry(entry, options=options) -async def update_listener(hass: HomeAssistant, entry: TadoConfigEntry): - """Handle options update.""" - await hass.config_entries.async_reload(entry.entry_id) - - async def async_unload_entry(hass: HomeAssistant, entry: TadoConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/tado/config_flow.py b/homeassistant/components/tado/config_flow.py index f251a292800..64763469885 100644 --- a/homeassistant/components/tado/config_flow.py +++ b/homeassistant/components/tado/config_flow.py @@ -2,22 +2,25 @@ from __future__ import annotations +import asyncio +from collections.abc import Mapping import logging from typing import Any -import PyTado +from PyTado.exceptions import TadoException +from PyTado.http import DeviceActivationStatus from PyTado.interface import Tado -import requests.exceptions import voluptuous as vol +from yarl import URL from homeassistant.config_entries import ( + SOURCE_REAUTH, ConfigEntry, ConfigFlow, ConfigFlowResult, OptionsFlow, ) -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.service_info.zeroconf import ( ATTR_PROPERTIES_ID, @@ -26,137 +29,149 @@ from homeassistant.helpers.service_info.zeroconf import ( from .const import ( CONF_FALLBACK, + CONF_REFRESH_TOKEN, CONST_OVERLAY_TADO_DEFAULT, CONST_OVERLAY_TADO_OPTIONS, DOMAIN, - UNIQUE_ID, ) _LOGGER = logging.getLogger(__name__) -DATA_SCHEMA = vol.Schema( - { - vol.Required(CONF_USERNAME): str, - vol.Required(CONF_PASSWORD): str, - } -) - - -async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]: - """Validate the user input allows us to connect. - - Data has the keys from DATA_SCHEMA with values provided by the user. - """ - - try: - tado = await hass.async_add_executor_job( - Tado, data[CONF_USERNAME], data[CONF_PASSWORD] - ) - tado_me = await hass.async_add_executor_job(tado.get_me) - except KeyError as ex: - raise InvalidAuth from ex - except RuntimeError as ex: - raise CannotConnect from ex - except requests.exceptions.HTTPError as ex: - if ex.response.status_code > 400 and ex.response.status_code < 500: - raise InvalidAuth from ex - raise CannotConnect from ex - - if "homes" not in tado_me or len(tado_me["homes"]) == 0: - raise NoHomes - - home = tado_me["homes"][0] - unique_id = str(home["id"]) - name = home["name"] - - return {"title": name, UNIQUE_ID: unique_id} - class TadoConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Tado.""" - VERSION = 1 + VERSION = 2 + login_task: asyncio.Task | None = None + refresh_token: str | None = None + tado: Tado | None = None + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Handle reauth on credential failure.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Prepare reauth.""" + if user_input is None: + return self.async_show_form(step_id="reauth_confirm") + + return await self.async_step_user() async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Handle the initial step.""" - errors = {} - if user_input is not None: + """Handle users reauth credentials.""" + + if self.tado is None: + _LOGGER.debug("Initiating device activation") try: - validated = await validate_input(self.hass, user_input) - except CannotConnect: - errors["base"] = "cannot_connect" - except InvalidAuth: - errors["base"] = "invalid_auth" - except NoHomes: - errors["base"] = "no_homes" - except Exception: - _LOGGER.exception("Unexpected exception") - errors["base"] = "unknown" + self.tado = await self.hass.async_add_executor_job(Tado) + except TadoException: + _LOGGER.exception("Error while initiating Tado") + return self.async_abort(reason="cannot_connect") + assert self.tado is not None + tado_device_url = self.tado.device_verification_url() + user_code = URL(tado_device_url).query["user_code"] - if "base" not in errors: - await self.async_set_unique_id(validated[UNIQUE_ID]) - self._abort_if_unique_id_configured() - return self.async_create_entry( - title=validated["title"], data=user_input - ) + async def _wait_for_login() -> None: + """Wait for the user to login.""" + assert self.tado is not None + _LOGGER.debug("Waiting for device activation") + try: + await self.hass.async_add_executor_job(self.tado.device_activation) + except Exception as ex: + _LOGGER.exception("Error while waiting for device activation") + raise CannotConnect from ex - return self.async_show_form( - step_id="user", data_schema=DATA_SCHEMA, errors=errors + if ( + self.tado.device_activation_status() + is not DeviceActivationStatus.COMPLETED + ): + raise CannotConnect + + _LOGGER.debug("Checking login task") + if self.login_task is None: + _LOGGER.debug("Creating task for device activation") + self.login_task = self.hass.async_create_task(_wait_for_login()) + + if self.login_task.done(): + _LOGGER.debug("Login task is done, checking results") + if self.login_task.exception(): + return self.async_show_progress_done(next_step_id="timeout") + self.refresh_token = await self.hass.async_add_executor_job( + self.tado.get_refresh_token + ) + return self.async_show_progress_done(next_step_id="finish_login") + + return self.async_show_progress( + step_id="user", + progress_action="wait_for_device", + description_placeholders={ + "url": tado_device_url, + "code": user_code, + }, + progress_task=self.login_task, ) + async def async_step_finish_login( + self, + user_input: dict[str, Any] | None = None, + ) -> ConfigFlowResult: + """Handle the finalization of reauth.""" + _LOGGER.debug("Finalizing reauth") + assert self.tado is not None + tado_me = await self.hass.async_add_executor_job(self.tado.get_me) + + if "homes" not in tado_me or len(tado_me["homes"]) == 0: + return self.async_abort(reason="no_homes") + + home = tado_me["homes"][0] + unique_id = str(home["id"]) + name = home["name"] + + if self.source != SOURCE_REAUTH: + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title=name, + data={CONF_REFRESH_TOKEN: self.refresh_token}, + ) + + self._abort_if_unique_id_mismatch(reason="reauth_account_mismatch") + return self.async_update_reload_and_abort( + self._get_reauth_entry(), + data={CONF_REFRESH_TOKEN: self.refresh_token}, + ) + + async def async_step_timeout( + self, + user_input: dict[str, Any] | None = None, + ) -> ConfigFlowResult: + """Handle issues that need transition await from progress step.""" + if user_input is None: + return self.async_show_form( + step_id="timeout", + ) + del self.login_task + return await self.async_step_user() + async def async_step_homekit( self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle HomeKit discovery.""" self._async_abort_entries_match() properties = { - key.lower(): value for (key, value) in discovery_info.properties.items() + key.lower(): value for key, value in discovery_info.properties.items() } await self.async_set_unique_id(properties[ATTR_PROPERTIES_ID]) self._abort_if_unique_id_configured() return await self.async_step_user() - async def async_step_reconfigure( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Handle a reconfiguration flow initialized by the user.""" - errors: dict[str, str] = {} - reconfigure_entry = self._get_reconfigure_entry() - - if user_input is not None: - user_input[CONF_USERNAME] = reconfigure_entry.data[CONF_USERNAME] - try: - await validate_input(self.hass, user_input) - except CannotConnect: - errors["base"] = "cannot_connect" - except PyTado.exceptions.TadoWrongCredentialsException: - errors["base"] = "invalid_auth" - except NoHomes: - errors["base"] = "no_homes" - except Exception: # pylint: disable=broad-except - _LOGGER.exception("Unexpected exception") - errors["base"] = "unknown" - - if not errors: - return self.async_update_reload_and_abort( - reconfigure_entry, data_updates=user_input - ) - - return self.async_show_form( - step_id="reconfigure", - data_schema=vol.Schema( - { - vol.Required(CONF_PASSWORD): str, - } - ), - errors=errors, - description_placeholders={ - CONF_USERNAME: reconfigure_entry.data[CONF_USERNAME] - }, - ) - @staticmethod @callback def async_get_options_flow( @@ -173,8 +188,10 @@ class OptionsFlowHandler(OptionsFlow): self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle options flow.""" - if user_input is not None: - return self.async_create_entry(data=user_input) + if user_input: + result = self.async_create_entry(data=user_input) + await self.hass.config_entries.async_reload(self.config_entry.entry_id) + return result data_schema = vol.Schema( { @@ -191,11 +208,3 @@ class OptionsFlowHandler(OptionsFlow): class CannotConnect(HomeAssistantError): """Error to indicate we cannot connect.""" - - -class InvalidAuth(HomeAssistantError): - """Error to indicate there is invalid auth.""" - - -class NoHomes(HomeAssistantError): - """Error to indicate the account has no homes.""" diff --git a/homeassistant/components/tado/const.py b/homeassistant/components/tado/const.py index bdc4bff1943..7720ff09110 100644 --- a/homeassistant/components/tado/const.py +++ b/homeassistant/components/tado/const.py @@ -37,6 +37,7 @@ TADO_HVAC_ACTION_TO_HA_HVAC_ACTION = { # Configuration CONF_FALLBACK = "fallback" CONF_HOME_ID = "home_id" +CONF_REFRESH_TOKEN = "refresh_token" DATA = "data" # Weather diff --git a/homeassistant/components/tado/coordinator.py b/homeassistant/components/tado/coordinator.py index 559bc4a16fb..5f3aa1de1e4 100644 --- a/homeassistant/components/tado/coordinator.py +++ b/homeassistant/components/tado/coordinator.py @@ -10,7 +10,6 @@ from PyTado.interface import Tado from requests import RequestException from homeassistant.components.climate import PRESET_AWAY, PRESET_HOME -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -20,6 +19,7 @@ if TYPE_CHECKING: from .const import ( CONF_FALLBACK, + CONF_REFRESH_TOKEN, CONST_OVERLAY_TADO_DEFAULT, DOMAIN, INSIDE_TEMPERATURE_MEASUREMENT, @@ -58,8 +58,7 @@ class TadoDataUpdateCoordinator(DataUpdateCoordinator[dict[str, dict]]): update_interval=SCAN_INTERVAL, ) self._tado = tado - self._username = config_entry.data[CONF_USERNAME] - self._password = config_entry.data[CONF_PASSWORD] + self._refresh_token = config_entry.data[CONF_REFRESH_TOKEN] self._fallback = config_entry.options.get( CONF_FALLBACK, CONST_OVERLAY_TADO_DEFAULT ) @@ -108,6 +107,18 @@ class TadoDataUpdateCoordinator(DataUpdateCoordinator[dict[str, dict]]): self.data["weather"] = home["weather"] self.data["geofence"] = home["geofence"] + refresh_token = await self.hass.async_add_executor_job( + self._tado.get_refresh_token + ) + + if refresh_token != self._refresh_token: + _LOGGER.debug("New refresh token obtained from Tado: %s", refresh_token) + self._refresh_token = refresh_token + self.hass.config_entries.async_update_entry( + self.config_entry, + data={**self.config_entry.data, CONF_REFRESH_TOKEN: refresh_token}, + ) + return self.data async def _async_update_devices(self) -> dict[str, dict]: diff --git a/homeassistant/components/tado/manifest.json b/homeassistant/components/tado/manifest.json index b83e2695137..75ddbacc585 100644 --- a/homeassistant/components/tado/manifest.json +++ b/homeassistant/components/tado/manifest.json @@ -14,5 +14,5 @@ }, "iot_class": "cloud_polling", "loggers": ["PyTado"], - "requirements": ["python-tado==0.18.6"] + "requirements": ["python-tado==0.18.9"] } diff --git a/homeassistant/components/tado/strings.json b/homeassistant/components/tado/strings.json index ff1afc3c03d..c7aef7eb51c 100644 --- a/homeassistant/components/tado/strings.json +++ b/homeassistant/components/tado/strings.json @@ -1,33 +1,24 @@ { "config": { + "progress": { + "wait_for_device": "To authenticate, open the following URL and login at Tado:\n{url}\nIf the code is not automatically copied, paste the following code to authorize the integration:\n\n```{code}```\n\n\nThe login attempt will time out after five minutes." + }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", - "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", + "could_not_authenticate": "Could not authenticate with Tado.", + "no_homes": "There are no homes linked to this Tado account.", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" }, "step": { - "user": { - "data": { - "password": "[%key:common::config_flow::data::password%]", - "username": "[%key:common::config_flow::data::username%]" - }, - "title": "Connect to your Tado account" + "reauth_confirm": { + "title": "Authenticate with Tado", + "description": "You need to reauthenticate with Tado. Press `Submit` to start the authentication process." }, - "reconfigure": { - "title": "Reconfigure your Tado", - "description": "Reconfigure the entry for your account: `{username}`.", - "data": { - "password": "[%key:common::config_flow::data::password%]" - }, - "data_description": { - "password": "Enter the (new) password for Tado." - } + "timeout": { + "description": "The authentication process timed out. Please try again." } - }, - "error": { - "unknown": "[%key:common::config_flow::error::unknown%]", - "no_homes": "There are no homes linked to this Tado account.", - "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" } }, "options": { diff --git a/requirements_all.txt b/requirements_all.txt index 611b56b65e2..d59c11f5709 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2477,7 +2477,7 @@ python-snoo==0.6.4 python-songpal==0.16.2 # homeassistant.components.tado -python-tado==0.18.6 +python-tado==0.18.9 # homeassistant.components.technove python-technove==2.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 744ae62670a..00706fc3c57 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2007,7 +2007,7 @@ python-snoo==0.6.4 python-songpal==0.16.2 # homeassistant.components.tado -python-tado==0.18.6 +python-tado==0.18.9 # homeassistant.components.technove python-technove==2.0.0 diff --git a/tests/components/tado/__init__.py b/tests/components/tado/__init__.py index 11d199f01a1..e6b6257e6ea 100644 --- a/tests/components/tado/__init__.py +++ b/tests/components/tado/__init__.py @@ -1 +1 @@ -"""Tests for the tado integration.""" +"""Tests for the Tado integration.""" diff --git a/tests/components/tado/conftest.py b/tests/components/tado/conftest.py new file mode 100644 index 00000000000..1aa62b218a2 --- /dev/null +++ b/tests/components/tado/conftest.py @@ -0,0 +1,50 @@ +"""Fixtures for Tado tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, MagicMock, patch + +from PyTado.http import DeviceActivationStatus +import pytest + +from homeassistant.components.tado import CONF_REFRESH_TOKEN, DOMAIN + +from tests.common import MockConfigEntry, load_json_object_fixture + + +@pytest.fixture +def mock_tado_api() -> Generator[MagicMock]: + """Mock the Tado API.""" + with ( + patch("homeassistant.components.tado.Tado") as mock_tado, + patch("homeassistant.components.tado.config_flow.Tado", new=mock_tado), + ): + client = mock_tado.return_value + client.device_verification_url.return_value = ( + "https://login.tado.com/oauth2/device?user_code=TEST" + ) + client.device_activation_status.return_value = DeviceActivationStatus.COMPLETED + client.get_me.return_value = load_json_object_fixture("me.json", DOMAIN) + client.get_refresh_token.return_value = "refresh" + yield client + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Mock the setup entry.""" + with patch( + "homeassistant.components.tado.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock a config entry.""" + return MockConfigEntry( + domain=DOMAIN, + data={ + CONF_REFRESH_TOKEN: "refresh", + }, + unique_id="1", + version=2, + ) diff --git a/tests/components/tado/fixtures/device_authorize.json b/tests/components/tado/fixtures/device_authorize.json new file mode 100644 index 00000000000..aacd171fafd --- /dev/null +++ b/tests/components/tado/fixtures/device_authorize.json @@ -0,0 +1,8 @@ +{ + "device_code": "ABCD", + "expires_in": 300, + "interval": 5, + "user_code": "TEST", + "verification_uri": "https://login.tado.com/oauth2/device", + "verification_uri_complete": "https://login.tado.com/oauth2/device?user_code=TEST" +} diff --git a/tests/components/tado/test_config_flow.py b/tests/components/tado/test_config_flow.py index 19acb0aecbd..f7418309d46 100644 --- a/tests/components/tado/test_config_flow.py +++ b/tests/components/tado/test_config_flow.py @@ -1,20 +1,20 @@ """Test the Tado config flow.""" -from http import HTTPStatus from ipaddress import ip_address -from unittest.mock import MagicMock, patch +import threading +from unittest.mock import AsyncMock, MagicMock, patch -import PyTado +from PyTado.http import DeviceActivationStatus import pytest -import requests -from homeassistant import config_entries -from homeassistant.components.tado.config_flow import NoHomes +from homeassistant.components.tado.config_flow import TadoException from homeassistant.components.tado.const import ( CONF_FALLBACK, + CONF_REFRESH_TOKEN, CONST_OVERLAY_TADO_DEFAULT, DOMAIN, ) +from homeassistant.config_entries import SOURCE_HOMEKIT, SOURCE_USER from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -26,92 +26,186 @@ from homeassistant.helpers.service_info.zeroconf import ( from tests.common import MockConfigEntry -def _get_mock_tado_api(get_me=None) -> MagicMock: - mock_tado = MagicMock() - if isinstance(get_me, Exception): - type(mock_tado).get_me = MagicMock(side_effect=get_me) - else: - type(mock_tado).get_me = MagicMock(return_value=get_me) - return mock_tado +async def test_full_flow( + hass: HomeAssistant, + mock_tado_api: MagicMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test the full flow of the config flow.""" + + event = threading.Event() + + def mock_tado_api_device_activation() -> None: + # Simulate the device activation process + event.wait(timeout=5) + + mock_tado_api.device_activation = mock_tado_api_device_activation + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "user" + + event.set() + await hass.async_block_till_done() + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "home name" + assert result["data"] == {CONF_REFRESH_TOKEN: "refresh"} + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_full_flow_reauth( + hass: HomeAssistant, + mock_tado_api: MagicMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test the full flow of the config when reauthticating.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="ABC-123-DEF-456", + data={CONF_REFRESH_TOKEN: "totally_refresh_for_reauth"}, + ) + entry.add_to_hass(hass) + + result = await entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + # The no user input + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + + event = threading.Event() + + def mock_tado_api_device_activation() -> None: + # Simulate the device activation process + event.wait(timeout=5) + + mock_tado_api.device_activation = mock_tado_api_device_activation + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "user" + + event.set() + await hass.async_block_till_done() + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "home name" + assert result["data"] == {CONF_REFRESH_TOKEN: "refresh"} + + +async def test_auth_timeout( + hass: HomeAssistant, + mock_tado_api: MagicMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test the auth timeout.""" + mock_tado_api.device_activation_status.return_value = DeviceActivationStatus.PENDING + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.SHOW_PROGRESS_DONE + assert result["step_id"] == "timeout" + + mock_tado_api.device_activation_status.return_value = ( + DeviceActivationStatus.COMPLETED + ) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "timeout" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "home name" + assert result["data"] == {CONF_REFRESH_TOKEN: "refresh"} + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_no_homes(hass: HomeAssistant, mock_tado_api: MagicMock) -> None: + """Test the full flow of the config flow.""" + mock_tado_api.get_me.return_value["homes"] = [] + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.SHOW_PROGRESS_DONE + assert result["step_id"] == "finish_login" + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_homes" + + +async def test_tado_creation(hass: HomeAssistant) -> None: + """Test we handle Form Exceptions.""" + + with patch( + "homeassistant.components.tado.config_flow.Tado", + side_effect=TadoException("Test exception"), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "cannot_connect" @pytest.mark.parametrize( ("exception", "error"), [ - (KeyError, "invalid_auth"), - (RuntimeError, "cannot_connect"), - (ValueError, "unknown"), + (Exception, "timeout"), + (TadoException, "timeout"), ], ) -async def test_form_exceptions( - hass: HomeAssistant, exception: Exception, error: str +async def test_wait_for_login_exception( + hass: HomeAssistant, + mock_tado_api: MagicMock, + exception: Exception, + error: str, ) -> None: - """Test we handle Form Exceptions.""" + """Test that an exception in wait for login is handled properly.""" + mock_tado_api.device_activation.side_effect = exception + result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, context={"source": SOURCE_USER} ) - - with patch( - "homeassistant.components.tado.config_flow.Tado", - side_effect=exception, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"username": "test-username", "password": "test-password"}, - ) - - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"base": error} - - # Test a retry to recover, upon failure - mock_tado_api = _get_mock_tado_api(get_me={"homes": [{"id": 1, "name": "myhome"}]}) - - with ( - patch( - "homeassistant.components.tado.config_flow.Tado", - return_value=mock_tado_api, - ), - patch( - "homeassistant.components.tado.async_setup_entry", - return_value=True, - ) as mock_setup_entry, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"username": "test-username", "password": "test-password"}, - ) - await hass.async_block_till_done() - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "myhome" - assert result["data"] == { - "username": "test-username", - "password": "test-password", - } - assert len(mock_setup_entry.mock_calls) == 1 + # @joostlek: I think the timeout step is not rightfully named, but heck, it works + assert result["type"] is FlowResultType.SHOW_PROGRESS_DONE + assert result["step_id"] == error -async def test_options_flow(hass: HomeAssistant) -> None: +async def test_options_flow( + hass: HomeAssistant, + mock_tado_api: MagicMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: """Test config flow options.""" - entry = MockConfigEntry(domain=DOMAIN, data={"username": "test-username"}) - entry.add_to_hass(hass) + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {} - - with patch( - "homeassistant.components.tado.async_setup_entry", - return_value=True, - ): - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - result = await hass.config_entries.options.async_init( - entry.entry_id, context={"source": config_entries.SOURCE_USER} - ) + result = await hass.config_entries.options.async_init(mock_config_entry.entry_id) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "init" @@ -119,125 +213,17 @@ async def test_options_flow(hass: HomeAssistant) -> None: result["flow_id"], {CONF_FALLBACK: CONST_OVERLAY_TADO_DEFAULT}, ) - await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY assert result["data"] == {CONF_FALLBACK: CONST_OVERLAY_TADO_DEFAULT} -async def test_create_entry(hass: HomeAssistant) -> None: - """Test we can setup though the user path.""" - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {} - - mock_tado_api = _get_mock_tado_api(get_me={"homes": [{"id": 1, "name": "myhome"}]}) - - with ( - patch( - "homeassistant.components.tado.config_flow.Tado", - return_value=mock_tado_api, - ), - patch( - "homeassistant.components.tado.async_setup_entry", - return_value=True, - ) as mock_setup_entry, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"username": "test-username", "password": "test-password"}, - ) - await hass.async_block_till_done() - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "myhome" - assert result["data"] == { - "username": "test-username", - "password": "test-password", - } - assert len(mock_setup_entry.mock_calls) == 1 - - -async def test_form_invalid_auth(hass: HomeAssistant) -> None: - """Test we handle invalid auth.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - response_mock = MagicMock() - type(response_mock).status_code = HTTPStatus.UNAUTHORIZED - mock_tado_api = _get_mock_tado_api( - get_me=requests.HTTPError(response=response_mock) - ) - - with patch( - "homeassistant.components.tado.config_flow.Tado", - return_value=mock_tado_api, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"username": "test-username", "password": "test-password"}, - ) - - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"base": "invalid_auth"} - - -async def test_form_cannot_connect(hass: HomeAssistant) -> None: - """Test we handle cannot connect error.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - response_mock = MagicMock() - type(response_mock).status_code = HTTPStatus.INTERNAL_SERVER_ERROR - mock_tado_api = _get_mock_tado_api( - get_me=requests.HTTPError(response=response_mock) - ) - - with patch( - "homeassistant.components.tado.config_flow.Tado", - return_value=mock_tado_api, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"username": "test-username", "password": "test-password"}, - ) - - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"base": "cannot_connect"} - - -async def test_no_homes(hass: HomeAssistant) -> None: - """Test we handle no homes error.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - mock_tado_api = _get_mock_tado_api(get_me={"homes": []}) - - with patch( - "homeassistant.components.tado.config_flow.Tado", - return_value=mock_tado_api, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"username": "test-username", "password": "test-password"}, - ) - - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"base": "no_homes"} - - -async def test_form_homekit(hass: HomeAssistant) -> None: +async def test_homekit(hass: HomeAssistant, mock_tado_api: MagicMock) -> None: """Test that we abort from homekit if tado is already setup.""" result = await hass.config_entries.flow.async_init( DOMAIN, - context={"source": config_entries.SOURCE_HOMEKIT}, + context={"source": SOURCE_HOMEKIT}, data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], @@ -248,8 +234,7 @@ async def test_form_homekit(hass: HomeAssistant) -> None: type="mock_type", ), ) - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {} + assert result["type"] is FlowResultType.SHOW_PROGRESS_DONE flow = next( flow for flow in hass.config_entries.flow.async_progress() @@ -264,7 +249,7 @@ async def test_form_homekit(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, - context={"source": config_entries.SOURCE_HOMEKIT}, + context={"source": SOURCE_HOMEKIT}, data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], @@ -276,77 +261,3 @@ async def test_form_homekit(hass: HomeAssistant) -> None: ), ) assert result["type"] is FlowResultType.ABORT - - -@pytest.mark.parametrize( - ("exception", "error"), - [ - (PyTado.exceptions.TadoWrongCredentialsException, "invalid_auth"), - (RuntimeError, "cannot_connect"), - (NoHomes, "no_homes"), - (ValueError, "unknown"), - ], -) -async def test_reconfigure_flow( - hass: HomeAssistant, exception: Exception, error: str -) -> None: - """Test re-configuration flow.""" - entry = MockConfigEntry( - domain=DOMAIN, - data={ - "username": "test-username", - "password": "test-password", - "home_id": 1, - }, - unique_id="unique_id", - ) - entry.add_to_hass(hass) - - result = await entry.start_reconfigure_flow(hass) - - assert result["type"] is FlowResultType.FORM - - with patch( - "homeassistant.components.tado.config_flow.Tado", - side_effect=exception, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_PASSWORD: "test-password", - }, - ) - await hass.async_block_till_done() - - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"base": error} - - mock_tado_api = _get_mock_tado_api(get_me={"homes": [{"id": 1, "name": "myhome"}]}) - with ( - patch( - "homeassistant.components.tado.config_flow.Tado", - return_value=mock_tado_api, - ), - patch( - "homeassistant.components.tado.async_setup_entry", - return_value=True, - ), - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_PASSWORD: "test-password", - }, - ) - await hass.async_block_till_done() - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "reconfigure_successful" - entry = hass.config_entries.async_get_entry(entry.entry_id) - assert entry - assert entry.title == "Mock Title" - assert entry.data == { - "username": "test-username", - "password": "test-password", - "home_id": 1, - } diff --git a/tests/components/tado/test_helper.py b/tests/components/tado/test_helper.py index da959c2124a..7f798e3797c 100644 --- a/tests/components/tado/test_helper.py +++ b/tests/components/tado/test_helper.py @@ -5,7 +5,7 @@ from unittest.mock import MagicMock, patch from PyTado.interface import Tado import pytest -from homeassistant.components.tado import TadoDataUpdateCoordinator +from homeassistant.components.tado import CONF_REFRESH_TOKEN, TadoDataUpdateCoordinator from homeassistant.components.tado.const import ( CONST_OVERLAY_MANUAL, CONST_OVERLAY_TADO_DEFAULT, @@ -28,13 +28,13 @@ def entry(request: pytest.FixtureRequest) -> MockConfigEntry: request.param if hasattr(request, "param") else CONST_OVERLAY_TADO_DEFAULT ) return MockConfigEntry( - version=1, - minor_version=1, + version=2, domain=DOMAIN, title="Tado", data={ CONF_USERNAME: "test-username", CONF_PASSWORD: "test-password", + CONF_REFRESH_TOKEN: "test-refresh", }, options={ "fallback": fallback, diff --git a/tests/components/tado/test_init.py b/tests/components/tado/test_init.py new file mode 100644 index 00000000000..2f2ccacf3c0 --- /dev/null +++ b/tests/components/tado/test_init.py @@ -0,0 +1,30 @@ +"""Test the Tado integration.""" + +from homeassistant.components.tado import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_v1_migration(hass: HomeAssistant) -> None: + """Test migration from v1 to v2 config entry.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_USERNAME: "test", + CONF_PASSWORD: "test", + }, + unique_id="1", + version=1, + ) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + assert entry.version == 2 + assert CONF_USERNAME not in entry.data + assert CONF_PASSWORD not in entry.data + + assert entry.state is ConfigEntryState.SETUP_ERROR + assert len(hass.config_entries.flow.async_progress()) == 1 diff --git a/tests/components/tado/util.py b/tests/components/tado/util.py index 5bf87dbed33..6fd333dff51 100644 --- a/tests/components/tado/util.py +++ b/tests/components/tado/util.py @@ -2,8 +2,7 @@ import requests_mock -from homeassistant.components.tado import DOMAIN -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.components.tado import CONF_REFRESH_TOKEN, DOMAIN from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry, load_fixture @@ -178,9 +177,16 @@ async def async_init_integration( "https://my.tado.com/api/v2/homes/1/zones/1/state", text=load_fixture(zone_1_state_fixture), ) + m.post( + "https://login.tado.com/oauth2/token", + text=load_fixture(token_fixture), + ) entry = MockConfigEntry( domain=DOMAIN, - data={CONF_USERNAME: "mock", CONF_PASSWORD: "mock"}, + version=2, + data={ + CONF_REFRESH_TOKEN: "mock-token", + }, options={"fallback": "NEXT_TIME_BLOCK"}, ) entry.add_to_hass(hass) From e192bfb62e12ad6dec0fcfaad653022700e64163 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Mon, 24 Mar 2025 15:32:57 +0100 Subject: [PATCH 2956/3148] Do not deepcopy section schema when applying suggested values (#141280) Do not deep copy section schema when appying suggested values --- homeassistant/data_entry_flow.py | 2 +- tests/test_data_entry_flow.py | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/homeassistant/data_entry_flow.py b/homeassistant/data_entry_flow.py index 7d2ef09ecb8..f7be891b61b 100644 --- a/homeassistant/data_entry_flow.py +++ b/homeassistant/data_entry_flow.py @@ -666,7 +666,7 @@ class FlowHandler(Generic[_FlowContextT, _FlowResultT, _HandlerT]): new_section_key = copy.copy(key) schema[new_section_key] = val val.schema = self.add_suggested_values_to_schema( - copy.deepcopy(val.schema), suggested_values[key] + val.schema, suggested_values[key] ) continue diff --git a/tests/test_data_entry_flow.py b/tests/test_data_entry_flow.py index a2f4ad6e097..86ba5257001 100644 --- a/tests/test_data_entry_flow.py +++ b/tests/test_data_entry_flow.py @@ -178,6 +178,10 @@ async def test_form_shows_with_added_suggested_values(manager: MockFlowManager) assert markers[2] == "section_1" section_validator = form["data_schema"].schema["section_1"] assert isinstance(section_validator, data_entry_flow.section) + # The section class was not replaced + assert section_validator is schema.schema["section_1"] + # The section schema was not replaced + assert section_validator.schema is schema.schema["section_1"].schema section_markers = list(section_validator.schema.schema) assert len(section_markers) == 1 assert section_markers[0] == "full_name" From b3e3d77d7cd93482ac479d02f9f2c60825b467bc Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 24 Mar 2025 15:38:59 +0100 Subject: [PATCH 2957/3148] Fix spelling of "Power factor" and capitalization in `enphase_envoy` (#141285) * Fix spelling of "Power factor" and capitalization in `enphase_envoy` * Update test_sensor.ambr --- .../components/enphase_envoy/strings.json | 16 +- .../enphase_envoy/snapshots/test_sensor.ambr | 516 +++++++++--------- 2 files changed, 266 insertions(+), 266 deletions(-) diff --git a/homeassistant/components/enphase_envoy/strings.json b/homeassistant/components/enphase_envoy/strings.json index b498c59e0d3..ce3a8593226 100644 --- a/homeassistant/components/enphase_envoy/strings.json +++ b/homeassistant/components/enphase_envoy/strings.json @@ -187,13 +187,13 @@ "name": "Lifetime energy consumption {phase_name}" }, "balanced_net_consumption": { - "name": "balanced net power consumption" + "name": "Balanced net power consumption" }, "lifetime_balanced_net_consumption": { "name": "Lifetime balanced net energy consumption" }, "balanced_net_consumption_phase": { - "name": "balanced net power consumption {phase_name}" + "name": "Balanced net power consumption {phase_name}" }, "lifetime_balanced_net_consumption_phase": { "name": "Lifetime balanced net energy consumption {phase_name}" @@ -217,7 +217,7 @@ "name": "Net consumption CT current" }, "net_ct_powerfactor": { - "name": "Powerfactor net consumption CT" + "name": "Power factor net consumption CT" }, "net_ct_metering_status": { "name": "Metering status net consumption CT" @@ -235,7 +235,7 @@ "name": "Production CT current" }, "production_ct_powerfactor": { - "name": "powerfactor production CT" + "name": "Power factor production CT" }, "production_ct_metering_status": { "name": "Metering status production CT" @@ -262,7 +262,7 @@ "name": "Storage CT current" }, "storage_ct_powerfactor": { - "name": "Powerfactor storage CT" + "name": "Power factor storage CT" }, "storage_ct_metering_status": { "name": "Metering status storage CT" @@ -289,7 +289,7 @@ "name": "Net consumption CT current {phase_name}" }, "net_ct_powerfactor_phase": { - "name": "Powerfactor net consumption CT {phase_name}" + "name": "Power factor net consumption CT {phase_name}" }, "net_ct_metering_status_phase": { "name": "Metering status net consumption CT {phase_name}" @@ -307,7 +307,7 @@ "name": "Production CT current {phase_name}" }, "production_ct_powerfactor_phase": { - "name": "Powerfactor production CT {phase_name}" + "name": "Power factor production CT {phase_name}" }, "production_ct_metering_status_phase": { "name": "Metering status production CT {phase_name}" @@ -334,7 +334,7 @@ "name": "Storage CT current {phase_name}" }, "storage_ct_powerfactor_phase": { - "name": "Powerfactor storage CT {phase_name}" + "name": "Power factor storage CT {phase_name}" }, "storage_ct_metering_status_phase": { "name": "Metering status storage CT {phase_name}" diff --git a/tests/components/enphase_envoy/snapshots/test_sensor.ambr b/tests/components/enphase_envoy/snapshots/test_sensor.ambr index c1e2c9270e2..101caaf1aea 100644 --- a/tests/components/enphase_envoy/snapshots/test_sensor.ambr +++ b/tests/components/enphase_envoy/snapshots/test_sensor.ambr @@ -361,7 +361,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'balanced net power consumption', + 'original_name': 'Balanced net power consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -374,7 +374,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power', - 'friendly_name': 'Envoy 1234 balanced net power consumption', + 'friendly_name': 'Envoy 1234 Balanced net power consumption', 'state_class': , 'unit_of_measurement': , }), @@ -1456,7 +1456,7 @@ 'state': '0.3', }) # --- -# name: test_sensor[envoy_1p_metered][sensor.envoy_1234_powerfactor_net_consumption_ct-entry] +# name: test_sensor[envoy_1p_metered][sensor.envoy_1234_power_factor_net_consumption_ct-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -1471,7 +1471,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.envoy_1234_powerfactor_net_consumption_ct', + 'entity_id': 'sensor.envoy_1234_power_factor_net_consumption_ct', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -1486,7 +1486,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Powerfactor net consumption CT', + 'original_name': 'Power factor net consumption CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -1495,22 +1495,22 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[envoy_1p_metered][sensor.envoy_1234_powerfactor_net_consumption_ct-state] +# name: test_sensor[envoy_1p_metered][sensor.envoy_1234_power_factor_net_consumption_ct-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', - 'friendly_name': 'Envoy 1234 Powerfactor net consumption CT', + 'friendly_name': 'Envoy 1234 Power factor net consumption CT', 'state_class': , }), 'context': , - 'entity_id': 'sensor.envoy_1234_powerfactor_net_consumption_ct', + 'entity_id': 'sensor.envoy_1234_power_factor_net_consumption_ct', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0.21', }) # --- -# name: test_sensor[envoy_1p_metered][sensor.envoy_1234_powerfactor_production_ct-entry] +# name: test_sensor[envoy_1p_metered][sensor.envoy_1234_power_factor_production_ct-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -1525,7 +1525,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct', + 'entity_id': 'sensor.envoy_1234_power_factor_production_ct', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -1540,7 +1540,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'powerfactor production CT', + 'original_name': 'Power factor production CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -1549,15 +1549,15 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[envoy_1p_metered][sensor.envoy_1234_powerfactor_production_ct-state] +# name: test_sensor[envoy_1p_metered][sensor.envoy_1234_power_factor_production_ct-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', - 'friendly_name': 'Envoy 1234 powerfactor production CT', + 'friendly_name': 'Envoy 1234 Power factor production CT', 'state_class': , }), 'context': , - 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct', + 'entity_id': 'sensor.envoy_1234_power_factor_production_ct', 'last_changed': , 'last_reported': , 'last_updated': , @@ -2519,7 +2519,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'balanced net power consumption', + 'original_name': 'Balanced net power consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -2532,7 +2532,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power', - 'friendly_name': 'Envoy 1234 balanced net power consumption', + 'friendly_name': 'Envoy 1234 Balanced net power consumption', 'state_class': , 'unit_of_measurement': , }), @@ -5374,7 +5374,7 @@ 'state': '0.3', }) # --- -# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_powerfactor_net_consumption_ct-entry] +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_power_factor_net_consumption_ct-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -5389,7 +5389,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.envoy_1234_powerfactor_net_consumption_ct', + 'entity_id': 'sensor.envoy_1234_power_factor_net_consumption_ct', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -5404,7 +5404,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Powerfactor net consumption CT', + 'original_name': 'Power factor net consumption CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -5413,22 +5413,22 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_powerfactor_net_consumption_ct-state] +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_power_factor_net_consumption_ct-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', - 'friendly_name': 'Envoy 1234 Powerfactor net consumption CT', + 'friendly_name': 'Envoy 1234 Power factor net consumption CT', 'state_class': , }), 'context': , - 'entity_id': 'sensor.envoy_1234_powerfactor_net_consumption_ct', + 'entity_id': 'sensor.envoy_1234_power_factor_net_consumption_ct', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0.21', }) # --- -# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_powerfactor_net_consumption_ct_l1-entry] +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_power_factor_net_consumption_ct_l1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -5443,7 +5443,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.envoy_1234_powerfactor_net_consumption_ct_l1', + 'entity_id': 'sensor.envoy_1234_power_factor_net_consumption_ct_l1', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -5458,7 +5458,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Powerfactor net consumption CT l1', + 'original_name': 'Power factor net consumption CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -5467,22 +5467,22 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_powerfactor_net_consumption_ct_l1-state] +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_power_factor_net_consumption_ct_l1-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', - 'friendly_name': 'Envoy 1234 Powerfactor net consumption CT l1', + 'friendly_name': 'Envoy 1234 Power factor net consumption CT l1', 'state_class': , }), 'context': , - 'entity_id': 'sensor.envoy_1234_powerfactor_net_consumption_ct_l1', + 'entity_id': 'sensor.envoy_1234_power_factor_net_consumption_ct_l1', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0.22', }) # --- -# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_powerfactor_net_consumption_ct_l2-entry] +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_power_factor_net_consumption_ct_l2-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -5497,7 +5497,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.envoy_1234_powerfactor_net_consumption_ct_l2', + 'entity_id': 'sensor.envoy_1234_power_factor_net_consumption_ct_l2', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -5512,7 +5512,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Powerfactor net consumption CT l2', + 'original_name': 'Power factor net consumption CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -5521,22 +5521,22 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_powerfactor_net_consumption_ct_l2-state] +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_power_factor_net_consumption_ct_l2-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', - 'friendly_name': 'Envoy 1234 Powerfactor net consumption CT l2', + 'friendly_name': 'Envoy 1234 Power factor net consumption CT l2', 'state_class': , }), 'context': , - 'entity_id': 'sensor.envoy_1234_powerfactor_net_consumption_ct_l2', + 'entity_id': 'sensor.envoy_1234_power_factor_net_consumption_ct_l2', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0.23', }) # --- -# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_powerfactor_net_consumption_ct_l3-entry] +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_power_factor_net_consumption_ct_l3-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -5551,7 +5551,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.envoy_1234_powerfactor_net_consumption_ct_l3', + 'entity_id': 'sensor.envoy_1234_power_factor_net_consumption_ct_l3', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -5566,7 +5566,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Powerfactor net consumption CT l3', + 'original_name': 'Power factor net consumption CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -5575,22 +5575,22 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_powerfactor_net_consumption_ct_l3-state] +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_power_factor_net_consumption_ct_l3-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', - 'friendly_name': 'Envoy 1234 Powerfactor net consumption CT l3', + 'friendly_name': 'Envoy 1234 Power factor net consumption CT l3', 'state_class': , }), 'context': , - 'entity_id': 'sensor.envoy_1234_powerfactor_net_consumption_ct_l3', + 'entity_id': 'sensor.envoy_1234_power_factor_net_consumption_ct_l3', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0.24', }) # --- -# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_powerfactor_production_ct-entry] +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_power_factor_production_ct-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -5605,7 +5605,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct', + 'entity_id': 'sensor.envoy_1234_power_factor_production_ct', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -5620,7 +5620,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'powerfactor production CT', + 'original_name': 'Power factor production CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -5629,22 +5629,22 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_powerfactor_production_ct-state] +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_power_factor_production_ct-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', - 'friendly_name': 'Envoy 1234 powerfactor production CT', + 'friendly_name': 'Envoy 1234 Power factor production CT', 'state_class': , }), 'context': , - 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct', + 'entity_id': 'sensor.envoy_1234_power_factor_production_ct', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0.11', }) # --- -# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_powerfactor_production_ct_l1-entry] +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_power_factor_production_ct_l1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -5659,7 +5659,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct_l1', + 'entity_id': 'sensor.envoy_1234_power_factor_production_ct_l1', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -5674,7 +5674,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Powerfactor production CT l1', + 'original_name': 'Power factor production CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -5683,22 +5683,22 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_powerfactor_production_ct_l1-state] +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_power_factor_production_ct_l1-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', - 'friendly_name': 'Envoy 1234 Powerfactor production CT l1', + 'friendly_name': 'Envoy 1234 Power factor production CT l1', 'state_class': , }), 'context': , - 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct_l1', + 'entity_id': 'sensor.envoy_1234_power_factor_production_ct_l1', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0.12', }) # --- -# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_powerfactor_production_ct_l2-entry] +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_power_factor_production_ct_l2-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -5713,7 +5713,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct_l2', + 'entity_id': 'sensor.envoy_1234_power_factor_production_ct_l2', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -5728,7 +5728,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Powerfactor production CT l2', + 'original_name': 'Power factor production CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -5737,22 +5737,22 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_powerfactor_production_ct_l2-state] +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_power_factor_production_ct_l2-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', - 'friendly_name': 'Envoy 1234 Powerfactor production CT l2', + 'friendly_name': 'Envoy 1234 Power factor production CT l2', 'state_class': , }), 'context': , - 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct_l2', + 'entity_id': 'sensor.envoy_1234_power_factor_production_ct_l2', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0.13', }) # --- -# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_powerfactor_production_ct_l3-entry] +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_power_factor_production_ct_l3-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -5767,7 +5767,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct_l3', + 'entity_id': 'sensor.envoy_1234_power_factor_production_ct_l3', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -5782,7 +5782,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Powerfactor production CT l3', + 'original_name': 'Power factor production CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -5791,15 +5791,15 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_powerfactor_production_ct_l3-state] +# name: test_sensor[envoy_acb_batt][sensor.envoy_1234_power_factor_production_ct_l3-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', - 'friendly_name': 'Envoy 1234 Powerfactor production CT l3', + 'friendly_name': 'Envoy 1234 Power factor production CT l3', 'state_class': , }), 'context': , - 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct_l3', + 'entity_id': 'sensor.envoy_1234_power_factor_production_ct_l3', 'last_changed': , 'last_reported': , 'last_updated': , @@ -7026,7 +7026,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'balanced net power consumption', + 'original_name': 'Balanced net power consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -7039,7 +7039,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power', - 'friendly_name': 'Envoy 1234 balanced net power consumption', + 'friendly_name': 'Envoy 1234 Balanced net power consumption', 'state_class': , 'unit_of_measurement': , }), @@ -9881,7 +9881,7 @@ 'state': '0.3', }) # --- -# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_powerfactor_net_consumption_ct-entry] +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_power_factor_net_consumption_ct-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -9896,7 +9896,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.envoy_1234_powerfactor_net_consumption_ct', + 'entity_id': 'sensor.envoy_1234_power_factor_net_consumption_ct', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -9911,7 +9911,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Powerfactor net consumption CT', + 'original_name': 'Power factor net consumption CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -9920,22 +9920,22 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_powerfactor_net_consumption_ct-state] +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_power_factor_net_consumption_ct-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', - 'friendly_name': 'Envoy 1234 Powerfactor net consumption CT', + 'friendly_name': 'Envoy 1234 Power factor net consumption CT', 'state_class': , }), 'context': , - 'entity_id': 'sensor.envoy_1234_powerfactor_net_consumption_ct', + 'entity_id': 'sensor.envoy_1234_power_factor_net_consumption_ct', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0.21', }) # --- -# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_powerfactor_net_consumption_ct_l1-entry] +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_power_factor_net_consumption_ct_l1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -9950,7 +9950,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.envoy_1234_powerfactor_net_consumption_ct_l1', + 'entity_id': 'sensor.envoy_1234_power_factor_net_consumption_ct_l1', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -9965,7 +9965,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Powerfactor net consumption CT l1', + 'original_name': 'Power factor net consumption CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -9974,22 +9974,22 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_powerfactor_net_consumption_ct_l1-state] +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_power_factor_net_consumption_ct_l1-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', - 'friendly_name': 'Envoy 1234 Powerfactor net consumption CT l1', + 'friendly_name': 'Envoy 1234 Power factor net consumption CT l1', 'state_class': , }), 'context': , - 'entity_id': 'sensor.envoy_1234_powerfactor_net_consumption_ct_l1', + 'entity_id': 'sensor.envoy_1234_power_factor_net_consumption_ct_l1', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0.22', }) # --- -# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_powerfactor_net_consumption_ct_l2-entry] +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_power_factor_net_consumption_ct_l2-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -10004,7 +10004,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.envoy_1234_powerfactor_net_consumption_ct_l2', + 'entity_id': 'sensor.envoy_1234_power_factor_net_consumption_ct_l2', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -10019,7 +10019,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Powerfactor net consumption CT l2', + 'original_name': 'Power factor net consumption CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -10028,22 +10028,22 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_powerfactor_net_consumption_ct_l2-state] +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_power_factor_net_consumption_ct_l2-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', - 'friendly_name': 'Envoy 1234 Powerfactor net consumption CT l2', + 'friendly_name': 'Envoy 1234 Power factor net consumption CT l2', 'state_class': , }), 'context': , - 'entity_id': 'sensor.envoy_1234_powerfactor_net_consumption_ct_l2', + 'entity_id': 'sensor.envoy_1234_power_factor_net_consumption_ct_l2', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0.23', }) # --- -# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_powerfactor_net_consumption_ct_l3-entry] +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_power_factor_net_consumption_ct_l3-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -10058,7 +10058,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.envoy_1234_powerfactor_net_consumption_ct_l3', + 'entity_id': 'sensor.envoy_1234_power_factor_net_consumption_ct_l3', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -10073,7 +10073,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Powerfactor net consumption CT l3', + 'original_name': 'Power factor net consumption CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -10082,22 +10082,22 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_powerfactor_net_consumption_ct_l3-state] +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_power_factor_net_consumption_ct_l3-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', - 'friendly_name': 'Envoy 1234 Powerfactor net consumption CT l3', + 'friendly_name': 'Envoy 1234 Power factor net consumption CT l3', 'state_class': , }), 'context': , - 'entity_id': 'sensor.envoy_1234_powerfactor_net_consumption_ct_l3', + 'entity_id': 'sensor.envoy_1234_power_factor_net_consumption_ct_l3', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0.24', }) # --- -# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_powerfactor_production_ct-entry] +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_power_factor_production_ct-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -10112,7 +10112,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct', + 'entity_id': 'sensor.envoy_1234_power_factor_production_ct', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -10127,7 +10127,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'powerfactor production CT', + 'original_name': 'Power factor production CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -10136,22 +10136,22 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_powerfactor_production_ct-state] +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_power_factor_production_ct-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', - 'friendly_name': 'Envoy 1234 powerfactor production CT', + 'friendly_name': 'Envoy 1234 Power factor production CT', 'state_class': , }), 'context': , - 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct', + 'entity_id': 'sensor.envoy_1234_power_factor_production_ct', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0.11', }) # --- -# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_powerfactor_production_ct_l1-entry] +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_power_factor_production_ct_l1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -10166,7 +10166,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct_l1', + 'entity_id': 'sensor.envoy_1234_power_factor_production_ct_l1', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -10181,7 +10181,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Powerfactor production CT l1', + 'original_name': 'Power factor production CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -10190,22 +10190,22 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_powerfactor_production_ct_l1-state] +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_power_factor_production_ct_l1-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', - 'friendly_name': 'Envoy 1234 Powerfactor production CT l1', + 'friendly_name': 'Envoy 1234 Power factor production CT l1', 'state_class': , }), 'context': , - 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct_l1', + 'entity_id': 'sensor.envoy_1234_power_factor_production_ct_l1', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0.12', }) # --- -# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_powerfactor_production_ct_l2-entry] +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_power_factor_production_ct_l2-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -10220,7 +10220,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct_l2', + 'entity_id': 'sensor.envoy_1234_power_factor_production_ct_l2', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -10235,7 +10235,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Powerfactor production CT l2', + 'original_name': 'Power factor production CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -10244,22 +10244,22 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_powerfactor_production_ct_l2-state] +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_power_factor_production_ct_l2-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', - 'friendly_name': 'Envoy 1234 Powerfactor production CT l2', + 'friendly_name': 'Envoy 1234 Power factor production CT l2', 'state_class': , }), 'context': , - 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct_l2', + 'entity_id': 'sensor.envoy_1234_power_factor_production_ct_l2', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0.13', }) # --- -# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_powerfactor_production_ct_l3-entry] +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_power_factor_production_ct_l3-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -10274,7 +10274,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct_l3', + 'entity_id': 'sensor.envoy_1234_power_factor_production_ct_l3', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -10289,7 +10289,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Powerfactor production CT l3', + 'original_name': 'Power factor production CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -10298,15 +10298,15 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_powerfactor_production_ct_l3-state] +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_power_factor_production_ct_l3-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', - 'friendly_name': 'Envoy 1234 Powerfactor production CT l3', + 'friendly_name': 'Envoy 1234 Power factor production CT l3', 'state_class': , }), 'context': , - 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct_l3', + 'entity_id': 'sensor.envoy_1234_power_factor_production_ct_l3', 'last_changed': , 'last_reported': , 'last_updated': , @@ -11630,7 +11630,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'balanced net power consumption', + 'original_name': 'Balanced net power consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -11643,7 +11643,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power', - 'friendly_name': 'Envoy 1234 balanced net power consumption', + 'friendly_name': 'Envoy 1234 Balanced net power consumption', 'state_class': , 'unit_of_measurement': , }), @@ -11688,7 +11688,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'balanced net power consumption l1', + 'original_name': 'Balanced net power consumption l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -11701,7 +11701,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power', - 'friendly_name': 'Envoy 1234 balanced net power consumption l1', + 'friendly_name': 'Envoy 1234 Balanced net power consumption l1', 'state_class': , 'unit_of_measurement': , }), @@ -11746,7 +11746,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'balanced net power consumption l2', + 'original_name': 'Balanced net power consumption l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -11759,7 +11759,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power', - 'friendly_name': 'Envoy 1234 balanced net power consumption l2', + 'friendly_name': 'Envoy 1234 Balanced net power consumption l2', 'state_class': , 'unit_of_measurement': , }), @@ -11804,7 +11804,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'balanced net power consumption l3', + 'original_name': 'Balanced net power consumption l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -11817,7 +11817,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power', - 'friendly_name': 'Envoy 1234 balanced net power consumption l3', + 'friendly_name': 'Envoy 1234 Balanced net power consumption l3', 'state_class': , 'unit_of_measurement': , }), @@ -17547,7 +17547,7 @@ 'state': '0.3', }) # --- -# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_powerfactor_net_consumption_ct-entry] +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_power_factor_net_consumption_ct-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -17562,7 +17562,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.envoy_1234_powerfactor_net_consumption_ct', + 'entity_id': 'sensor.envoy_1234_power_factor_net_consumption_ct', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -17577,7 +17577,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Powerfactor net consumption CT', + 'original_name': 'Power factor net consumption CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -17586,22 +17586,22 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_powerfactor_net_consumption_ct-state] +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_power_factor_net_consumption_ct-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', - 'friendly_name': 'Envoy 1234 Powerfactor net consumption CT', + 'friendly_name': 'Envoy 1234 Power factor net consumption CT', 'state_class': , }), 'context': , - 'entity_id': 'sensor.envoy_1234_powerfactor_net_consumption_ct', + 'entity_id': 'sensor.envoy_1234_power_factor_net_consumption_ct', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0.21', }) # --- -# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_powerfactor_net_consumption_ct_l1-entry] +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_power_factor_net_consumption_ct_l1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -17616,7 +17616,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.envoy_1234_powerfactor_net_consumption_ct_l1', + 'entity_id': 'sensor.envoy_1234_power_factor_net_consumption_ct_l1', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -17631,7 +17631,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Powerfactor net consumption CT l1', + 'original_name': 'Power factor net consumption CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -17640,22 +17640,22 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_powerfactor_net_consumption_ct_l1-state] +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_power_factor_net_consumption_ct_l1-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', - 'friendly_name': 'Envoy 1234 Powerfactor net consumption CT l1', + 'friendly_name': 'Envoy 1234 Power factor net consumption CT l1', 'state_class': , }), 'context': , - 'entity_id': 'sensor.envoy_1234_powerfactor_net_consumption_ct_l1', + 'entity_id': 'sensor.envoy_1234_power_factor_net_consumption_ct_l1', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0.22', }) # --- -# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_powerfactor_net_consumption_ct_l2-entry] +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_power_factor_net_consumption_ct_l2-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -17670,7 +17670,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.envoy_1234_powerfactor_net_consumption_ct_l2', + 'entity_id': 'sensor.envoy_1234_power_factor_net_consumption_ct_l2', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -17685,7 +17685,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Powerfactor net consumption CT l2', + 'original_name': 'Power factor net consumption CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -17694,22 +17694,22 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_powerfactor_net_consumption_ct_l2-state] +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_power_factor_net_consumption_ct_l2-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', - 'friendly_name': 'Envoy 1234 Powerfactor net consumption CT l2', + 'friendly_name': 'Envoy 1234 Power factor net consumption CT l2', 'state_class': , }), 'context': , - 'entity_id': 'sensor.envoy_1234_powerfactor_net_consumption_ct_l2', + 'entity_id': 'sensor.envoy_1234_power_factor_net_consumption_ct_l2', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0.23', }) # --- -# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_powerfactor_net_consumption_ct_l3-entry] +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_power_factor_net_consumption_ct_l3-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -17724,7 +17724,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.envoy_1234_powerfactor_net_consumption_ct_l3', + 'entity_id': 'sensor.envoy_1234_power_factor_net_consumption_ct_l3', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -17739,7 +17739,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Powerfactor net consumption CT l3', + 'original_name': 'Power factor net consumption CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -17748,22 +17748,22 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_powerfactor_net_consumption_ct_l3-state] +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_power_factor_net_consumption_ct_l3-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', - 'friendly_name': 'Envoy 1234 Powerfactor net consumption CT l3', + 'friendly_name': 'Envoy 1234 Power factor net consumption CT l3', 'state_class': , }), 'context': , - 'entity_id': 'sensor.envoy_1234_powerfactor_net_consumption_ct_l3', + 'entity_id': 'sensor.envoy_1234_power_factor_net_consumption_ct_l3', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0.24', }) # --- -# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_powerfactor_production_ct-entry] +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_power_factor_production_ct-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -17778,7 +17778,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct', + 'entity_id': 'sensor.envoy_1234_power_factor_production_ct', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -17793,7 +17793,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'powerfactor production CT', + 'original_name': 'Power factor production CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -17802,22 +17802,22 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_powerfactor_production_ct-state] +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_power_factor_production_ct-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', - 'friendly_name': 'Envoy 1234 powerfactor production CT', + 'friendly_name': 'Envoy 1234 Power factor production CT', 'state_class': , }), 'context': , - 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct', + 'entity_id': 'sensor.envoy_1234_power_factor_production_ct', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0.11', }) # --- -# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_powerfactor_production_ct_l1-entry] +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_power_factor_production_ct_l1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -17832,7 +17832,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct_l1', + 'entity_id': 'sensor.envoy_1234_power_factor_production_ct_l1', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -17847,7 +17847,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Powerfactor production CT l1', + 'original_name': 'Power factor production CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -17856,22 +17856,22 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_powerfactor_production_ct_l1-state] +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_power_factor_production_ct_l1-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', - 'friendly_name': 'Envoy 1234 Powerfactor production CT l1', + 'friendly_name': 'Envoy 1234 Power factor production CT l1', 'state_class': , }), 'context': , - 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct_l1', + 'entity_id': 'sensor.envoy_1234_power_factor_production_ct_l1', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0.12', }) # --- -# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_powerfactor_production_ct_l2-entry] +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_power_factor_production_ct_l2-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -17886,7 +17886,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct_l2', + 'entity_id': 'sensor.envoy_1234_power_factor_production_ct_l2', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -17901,7 +17901,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Powerfactor production CT l2', + 'original_name': 'Power factor production CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -17910,22 +17910,22 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_powerfactor_production_ct_l2-state] +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_power_factor_production_ct_l2-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', - 'friendly_name': 'Envoy 1234 Powerfactor production CT l2', + 'friendly_name': 'Envoy 1234 Power factor production CT l2', 'state_class': , }), 'context': , - 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct_l2', + 'entity_id': 'sensor.envoy_1234_power_factor_production_ct_l2', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0.13', }) # --- -# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_powerfactor_production_ct_l3-entry] +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_power_factor_production_ct_l3-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -17940,7 +17940,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct_l3', + 'entity_id': 'sensor.envoy_1234_power_factor_production_ct_l3', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -17955,7 +17955,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Powerfactor production CT l3', + 'original_name': 'Power factor production CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -17964,22 +17964,22 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_powerfactor_production_ct_l3-state] +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_power_factor_production_ct_l3-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', - 'friendly_name': 'Envoy 1234 Powerfactor production CT l3', + 'friendly_name': 'Envoy 1234 Power factor production CT l3', 'state_class': , }), 'context': , - 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct_l3', + 'entity_id': 'sensor.envoy_1234_power_factor_production_ct_l3', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0.14', }) # --- -# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_powerfactor_storage_ct-entry] +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_power_factor_storage_ct-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -17994,7 +17994,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.envoy_1234_powerfactor_storage_ct', + 'entity_id': 'sensor.envoy_1234_power_factor_storage_ct', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -18009,7 +18009,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Powerfactor storage CT', + 'original_name': 'Power factor storage CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -18018,22 +18018,22 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_powerfactor_storage_ct-state] +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_power_factor_storage_ct-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', - 'friendly_name': 'Envoy 1234 Powerfactor storage CT', + 'friendly_name': 'Envoy 1234 Power factor storage CT', 'state_class': , }), 'context': , - 'entity_id': 'sensor.envoy_1234_powerfactor_storage_ct', + 'entity_id': 'sensor.envoy_1234_power_factor_storage_ct', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0.23', }) # --- -# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_powerfactor_storage_ct_l1-entry] +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_power_factor_storage_ct_l1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -18048,7 +18048,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.envoy_1234_powerfactor_storage_ct_l1', + 'entity_id': 'sensor.envoy_1234_power_factor_storage_ct_l1', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -18063,7 +18063,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Powerfactor storage CT l1', + 'original_name': 'Power factor storage CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -18072,22 +18072,22 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_powerfactor_storage_ct_l1-state] +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_power_factor_storage_ct_l1-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', - 'friendly_name': 'Envoy 1234 Powerfactor storage CT l1', + 'friendly_name': 'Envoy 1234 Power factor storage CT l1', 'state_class': , }), 'context': , - 'entity_id': 'sensor.envoy_1234_powerfactor_storage_ct_l1', + 'entity_id': 'sensor.envoy_1234_power_factor_storage_ct_l1', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0.32', }) # --- -# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_powerfactor_storage_ct_l2-entry] +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_power_factor_storage_ct_l2-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -18102,7 +18102,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.envoy_1234_powerfactor_storage_ct_l2', + 'entity_id': 'sensor.envoy_1234_power_factor_storage_ct_l2', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -18117,7 +18117,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Powerfactor storage CT l2', + 'original_name': 'Power factor storage CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -18126,22 +18126,22 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_powerfactor_storage_ct_l2-state] +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_power_factor_storage_ct_l2-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', - 'friendly_name': 'Envoy 1234 Powerfactor storage CT l2', + 'friendly_name': 'Envoy 1234 Power factor storage CT l2', 'state_class': , }), 'context': , - 'entity_id': 'sensor.envoy_1234_powerfactor_storage_ct_l2', + 'entity_id': 'sensor.envoy_1234_power_factor_storage_ct_l2', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0.23', }) # --- -# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_powerfactor_storage_ct_l3-entry] +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_power_factor_storage_ct_l3-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -18156,7 +18156,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.envoy_1234_powerfactor_storage_ct_l3', + 'entity_id': 'sensor.envoy_1234_power_factor_storage_ct_l3', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -18171,7 +18171,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Powerfactor storage CT l3', + 'original_name': 'Power factor storage CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -18180,15 +18180,15 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_powerfactor_storage_ct_l3-state] +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_power_factor_storage_ct_l3-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', - 'friendly_name': 'Envoy 1234 Powerfactor storage CT l3', + 'friendly_name': 'Envoy 1234 Power factor storage CT l3', 'state_class': , }), 'context': , - 'entity_id': 'sensor.envoy_1234_powerfactor_storage_ct_l3', + 'entity_id': 'sensor.envoy_1234_power_factor_storage_ct_l3', 'last_changed': , 'last_reported': , 'last_updated': , @@ -19586,7 +19586,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'balanced net power consumption', + 'original_name': 'Balanced net power consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -19599,7 +19599,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power', - 'friendly_name': 'Envoy 1234 balanced net power consumption', + 'friendly_name': 'Envoy 1234 Balanced net power consumption', 'state_class': , 'unit_of_measurement': , }), @@ -19644,7 +19644,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'balanced net power consumption l1', + 'original_name': 'Balanced net power consumption l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -19657,7 +19657,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power', - 'friendly_name': 'Envoy 1234 balanced net power consumption l1', + 'friendly_name': 'Envoy 1234 Balanced net power consumption l1', 'state_class': , 'unit_of_measurement': , }), @@ -19702,7 +19702,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'balanced net power consumption l2', + 'original_name': 'Balanced net power consumption l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -19715,7 +19715,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power', - 'friendly_name': 'Envoy 1234 balanced net power consumption l2', + 'friendly_name': 'Envoy 1234 Balanced net power consumption l2', 'state_class': , 'unit_of_measurement': , }), @@ -19760,7 +19760,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'balanced net power consumption l3', + 'original_name': 'Balanced net power consumption l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -19773,7 +19773,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power', - 'friendly_name': 'Envoy 1234 balanced net power consumption l3', + 'friendly_name': 'Envoy 1234 Balanced net power consumption l3', 'state_class': , 'unit_of_measurement': , }), @@ -24065,7 +24065,7 @@ 'state': '0.3', }) # --- -# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_powerfactor_net_consumption_ct-entry] +# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_power_factor_net_consumption_ct-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -24080,7 +24080,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.envoy_1234_powerfactor_net_consumption_ct', + 'entity_id': 'sensor.envoy_1234_power_factor_net_consumption_ct', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -24095,7 +24095,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Powerfactor net consumption CT', + 'original_name': 'Power factor net consumption CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -24104,22 +24104,22 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_powerfactor_net_consumption_ct-state] +# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_power_factor_net_consumption_ct-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', - 'friendly_name': 'Envoy 1234 Powerfactor net consumption CT', + 'friendly_name': 'Envoy 1234 Power factor net consumption CT', 'state_class': , }), 'context': , - 'entity_id': 'sensor.envoy_1234_powerfactor_net_consumption_ct', + 'entity_id': 'sensor.envoy_1234_power_factor_net_consumption_ct', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0.21', }) # --- -# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_powerfactor_net_consumption_ct_l1-entry] +# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_power_factor_net_consumption_ct_l1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -24134,7 +24134,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.envoy_1234_powerfactor_net_consumption_ct_l1', + 'entity_id': 'sensor.envoy_1234_power_factor_net_consumption_ct_l1', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -24149,7 +24149,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Powerfactor net consumption CT l1', + 'original_name': 'Power factor net consumption CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -24158,22 +24158,22 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_powerfactor_net_consumption_ct_l1-state] +# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_power_factor_net_consumption_ct_l1-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', - 'friendly_name': 'Envoy 1234 Powerfactor net consumption CT l1', + 'friendly_name': 'Envoy 1234 Power factor net consumption CT l1', 'state_class': , }), 'context': , - 'entity_id': 'sensor.envoy_1234_powerfactor_net_consumption_ct_l1', + 'entity_id': 'sensor.envoy_1234_power_factor_net_consumption_ct_l1', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0.22', }) # --- -# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_powerfactor_net_consumption_ct_l2-entry] +# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_power_factor_net_consumption_ct_l2-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -24188,7 +24188,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.envoy_1234_powerfactor_net_consumption_ct_l2', + 'entity_id': 'sensor.envoy_1234_power_factor_net_consumption_ct_l2', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -24203,7 +24203,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Powerfactor net consumption CT l2', + 'original_name': 'Power factor net consumption CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -24212,22 +24212,22 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_powerfactor_net_consumption_ct_l2-state] +# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_power_factor_net_consumption_ct_l2-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', - 'friendly_name': 'Envoy 1234 Powerfactor net consumption CT l2', + 'friendly_name': 'Envoy 1234 Power factor net consumption CT l2', 'state_class': , }), 'context': , - 'entity_id': 'sensor.envoy_1234_powerfactor_net_consumption_ct_l2', + 'entity_id': 'sensor.envoy_1234_power_factor_net_consumption_ct_l2', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0.23', }) # --- -# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_powerfactor_net_consumption_ct_l3-entry] +# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_power_factor_net_consumption_ct_l3-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -24242,7 +24242,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.envoy_1234_powerfactor_net_consumption_ct_l3', + 'entity_id': 'sensor.envoy_1234_power_factor_net_consumption_ct_l3', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -24257,7 +24257,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Powerfactor net consumption CT l3', + 'original_name': 'Power factor net consumption CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -24266,22 +24266,22 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_powerfactor_net_consumption_ct_l3-state] +# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_power_factor_net_consumption_ct_l3-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', - 'friendly_name': 'Envoy 1234 Powerfactor net consumption CT l3', + 'friendly_name': 'Envoy 1234 Power factor net consumption CT l3', 'state_class': , }), 'context': , - 'entity_id': 'sensor.envoy_1234_powerfactor_net_consumption_ct_l3', + 'entity_id': 'sensor.envoy_1234_power_factor_net_consumption_ct_l3', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0.24', }) # --- -# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_powerfactor_production_ct-entry] +# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_power_factor_production_ct-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -24296,7 +24296,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct', + 'entity_id': 'sensor.envoy_1234_power_factor_production_ct', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -24311,7 +24311,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'powerfactor production CT', + 'original_name': 'Power factor production CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -24320,22 +24320,22 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_powerfactor_production_ct-state] +# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_power_factor_production_ct-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', - 'friendly_name': 'Envoy 1234 powerfactor production CT', + 'friendly_name': 'Envoy 1234 Power factor production CT', 'state_class': , }), 'context': , - 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct', + 'entity_id': 'sensor.envoy_1234_power_factor_production_ct', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0.11', }) # --- -# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_powerfactor_production_ct_l1-entry] +# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_power_factor_production_ct_l1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -24350,7 +24350,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct_l1', + 'entity_id': 'sensor.envoy_1234_power_factor_production_ct_l1', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -24365,7 +24365,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Powerfactor production CT l1', + 'original_name': 'Power factor production CT l1', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -24374,22 +24374,22 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_powerfactor_production_ct_l1-state] +# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_power_factor_production_ct_l1-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', - 'friendly_name': 'Envoy 1234 Powerfactor production CT l1', + 'friendly_name': 'Envoy 1234 Power factor production CT l1', 'state_class': , }), 'context': , - 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct_l1', + 'entity_id': 'sensor.envoy_1234_power_factor_production_ct_l1', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0.12', }) # --- -# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_powerfactor_production_ct_l2-entry] +# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_power_factor_production_ct_l2-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -24404,7 +24404,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct_l2', + 'entity_id': 'sensor.envoy_1234_power_factor_production_ct_l2', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -24419,7 +24419,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Powerfactor production CT l2', + 'original_name': 'Power factor production CT l2', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -24428,22 +24428,22 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_powerfactor_production_ct_l2-state] +# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_power_factor_production_ct_l2-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', - 'friendly_name': 'Envoy 1234 Powerfactor production CT l2', + 'friendly_name': 'Envoy 1234 Power factor production CT l2', 'state_class': , }), 'context': , - 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct_l2', + 'entity_id': 'sensor.envoy_1234_power_factor_production_ct_l2', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0.13', }) # --- -# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_powerfactor_production_ct_l3-entry] +# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_power_factor_production_ct_l3-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -24458,7 +24458,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct_l3', + 'entity_id': 'sensor.envoy_1234_power_factor_production_ct_l3', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -24473,7 +24473,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Powerfactor production CT l3', + 'original_name': 'Power factor production CT l3', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -24482,15 +24482,15 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_powerfactor_production_ct_l3-state] +# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_power_factor_production_ct_l3-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', - 'friendly_name': 'Envoy 1234 Powerfactor production CT l3', + 'friendly_name': 'Envoy 1234 Power factor production CT l3', 'state_class': , }), 'context': , - 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct_l3', + 'entity_id': 'sensor.envoy_1234_power_factor_production_ct_l3', 'last_changed': , 'last_reported': , 'last_updated': , @@ -25326,7 +25326,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'balanced net power consumption', + 'original_name': 'Balanced net power consumption', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -25339,7 +25339,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power', - 'friendly_name': 'Envoy 1234 balanced net power consumption', + 'friendly_name': 'Envoy 1234 Balanced net power consumption', 'state_class': , 'unit_of_measurement': , }), @@ -25799,7 +25799,7 @@ 'state': 'normal', }) # --- -# name: test_sensor[envoy_tot_cons_metered][sensor.envoy_1234_powerfactor_production_ct-entry] +# name: test_sensor[envoy_tot_cons_metered][sensor.envoy_1234_power_factor_production_ct-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -25814,7 +25814,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct', + 'entity_id': 'sensor.envoy_1234_power_factor_production_ct', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -25829,7 +25829,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'powerfactor production CT', + 'original_name': 'Power factor production CT', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'supported_features': 0, @@ -25838,15 +25838,15 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[envoy_tot_cons_metered][sensor.envoy_1234_powerfactor_production_ct-state] +# name: test_sensor[envoy_tot_cons_metered][sensor.envoy_1234_power_factor_production_ct-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power_factor', - 'friendly_name': 'Envoy 1234 powerfactor production CT', + 'friendly_name': 'Envoy 1234 Power factor production CT', 'state_class': , }), 'context': , - 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct', + 'entity_id': 'sensor.envoy_1234_power_factor_production_ct', 'last_changed': , 'last_reported': , 'last_updated': , From 06382f33e08fffe34ff06d425411768f65144e51 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 24 Mar 2025 15:42:26 +0100 Subject: [PATCH 2958/3148] Add command to SmartThings button unique id (#141281) * Add command to SmartThings button unique id * Add command to SmartThings button unique id --- homeassistant/components/smartthings/button.py | 4 +--- tests/components/smartthings/snapshots/test_button.ambr | 6 +++--- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/smartthings/button.py b/homeassistant/components/smartthings/button.py index ad61880f3b1..fa623a47c47 100644 --- a/homeassistant/components/smartthings/button.py +++ b/homeassistant/components/smartthings/button.py @@ -63,9 +63,7 @@ class SmartThingsButtonEntity(SmartThingsEntity, ButtonEntity): """Initialize the instance.""" super().__init__(client, device, set()) self.entity_description = entity_description - self._attr_unique_id = ( - f"{device.device.device_id}_{MAIN}_{entity_description.key}" - ) + self._attr_unique_id = f"{device.device.device_id}_{MAIN}_{entity_description.key}_{entity_description.command}" async def async_press(self) -> None: """Press the button.""" diff --git a/tests/components/smartthings/snapshots/test_button.ambr b/tests/components/smartthings/snapshots/test_button.ambr index a16ad794929..f1c5d932729 100644 --- a/tests/components/smartthings/snapshots/test_button.ambr +++ b/tests/components/smartthings/snapshots/test_button.ambr @@ -29,7 +29,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'stop', - 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a_main_ovenOperatingState', + 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a_main_ovenOperatingState_stop', 'unit_of_measurement': None, }) # --- @@ -76,7 +76,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'stop', - 'unique_id': '9447959a-0dfa-6b27-d40d-650da525c53f_main_ovenOperatingState', + 'unique_id': '9447959a-0dfa-6b27-d40d-650da525c53f_main_ovenOperatingState_stop', 'unit_of_measurement': None, }) # --- @@ -123,7 +123,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'stop', - 'unique_id': '2c3cbaa0-1899-5ddc-7b58-9d657bd48f18_main_ovenOperatingState', + 'unique_id': '2c3cbaa0-1899-5ddc-7b58-9d657bd48f18_main_ovenOperatingState_stop', 'unit_of_measurement': None, }) # --- From 69a375776aa64694a482194679df0bfe3bf4b11c Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 24 Mar 2025 15:48:18 +0100 Subject: [PATCH 2959/3148] Add wrinkle prevent binary sensor active to SmartThings (#141289) * Add wrinkle prevent binary sensor active to SmartThings * Fix --- .../components/smartthings/binary_sensor.py | 8 ++ .../components/smartthings/icons.json | 6 ++ .../components/smartthings/strings.json | 3 + .../snapshots/test_binary_sensor.ambr | 94 +++++++++++++++++++ 4 files changed, 111 insertions(+) diff --git a/homeassistant/components/smartthings/binary_sensor.py b/homeassistant/components/smartthings/binary_sensor.py index f776aa70c41..6d07a735127 100644 --- a/homeassistant/components/smartthings/binary_sensor.py +++ b/homeassistant/components/smartthings/binary_sensor.py @@ -68,6 +68,14 @@ CAPABILITY_TO_SENSORS: dict[ }, ) }, + Capability.CUSTOM_DRYER_WRINKLE_PREVENT: { + Attribute.OPERATING_STATE: SmartThingsBinarySensorEntityDescription( + key=Attribute.OPERATING_STATE, + translation_key="dryer_wrinkle_prevent_active", + is_on_key="running", + entity_category=EntityCategory.DIAGNOSTIC, + ) + }, Capability.FILTER_STATUS: { Attribute.FILTER_STATUS: SmartThingsBinarySensorEntityDescription( key=Attribute.FILTER_STATUS, diff --git a/homeassistant/components/smartthings/icons.json b/homeassistant/components/smartthings/icons.json index 670d23c8c27..4282b974fb2 100644 --- a/homeassistant/components/smartthings/icons.json +++ b/homeassistant/components/smartthings/icons.json @@ -1,6 +1,12 @@ { "entity": { "binary_sensor": { + "dryer_wrinkle_prevent_active": { + "default": "mdi:tumble-dryer", + "state": { + "on": "mdi:tumble-dryer-alert" + } + }, "remote_control": { "default": "mdi:remote-off", "state": { diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index 50094b21633..4f667121448 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -36,6 +36,9 @@ "door": { "name": "[%key:component::binary_sensor::entity_component::door::name%]" }, + "dryer_wrinkle_prevent_active": { + "name": "Wrinkle prevent active" + }, "filter_status": { "name": "Filter status" }, diff --git a/tests/components/smartthings/snapshots/test_binary_sensor.ambr b/tests/components/smartthings/snapshots/test_binary_sensor.ambr index 9bb52a71eee..62ecfcfff47 100644 --- a/tests/components/smartthings/snapshots/test_binary_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_binary_sensor.ambr @@ -949,6 +949,53 @@ 'state': 'off', }) # --- +# name: test_all_entities[da_wm_wd_000001][binary_sensor.dryer_wrinkle_prevent_active-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.dryer_wrinkle_prevent_active', + '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': 'Wrinkle prevent active', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'dryer_wrinkle_prevent_active', + 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b.operatingState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wd_000001][binary_sensor.dryer_wrinkle_prevent_active-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dryer Wrinkle prevent active', + }), + 'context': , + 'entity_id': 'binary_sensor.dryer_wrinkle_prevent_active', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_all_entities[da_wm_wd_000001_1][binary_sensor.seca_roupa_child_lock-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1091,6 +1138,53 @@ 'state': 'off', }) # --- +# name: test_all_entities[da_wm_wd_000001_1][binary_sensor.seca_roupa_wrinkle_prevent_active-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.seca_roupa_wrinkle_prevent_active', + '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': 'Wrinkle prevent active', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'dryer_wrinkle_prevent_active', + 'unique_id': '3a6c4e05-811d-5041-e956-3d04c424cbcd.operatingState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wd_000001_1][binary_sensor.seca_roupa_wrinkle_prevent_active-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Seca-Roupa Wrinkle prevent active', + }), + 'context': , + 'entity_id': 'binary_sensor.seca_roupa_wrinkle_prevent_active', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_all_entities[da_wm_wm_000001][binary_sensor.washer_child_lock-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 90623bbaffa3ac87aa360eb104a95882d459222f Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 24 Mar 2025 15:49:10 +0100 Subject: [PATCH 2960/3148] Deprecate fridge door sensor in SmartThings (#141275) --- .../components/smartthings/binary_sensor.py | 21 ++++++++++++++----- .../components/smartthings/strings.json | 4 ++++ .../smartthings/test_binary_sensor.py | 13 +++++++++--- 3 files changed, 30 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/smartthings/binary_sensor.py b/homeassistant/components/smartthings/binary_sensor.py index 6d07a735127..24249345080 100644 --- a/homeassistant/components/smartthings/binary_sensor.py +++ b/homeassistant/components/smartthings/binary_sensor.py @@ -5,7 +5,7 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass -from pysmartthings import Attribute, Capability, Category, SmartThings +from pysmartthings import Attribute, Capability, Category, SmartThings, Status from homeassistant.components.automation import automations_with_entity from homeassistant.components.binary_sensor import ( @@ -38,6 +38,9 @@ class SmartThingsBinarySensorEntityDescription(BinarySensorEntityDescription): category: set[Category] | None = None exists_fn: Callable[[str], bool] | None = None component_translation_key: dict[str, str] | None = None + deprecated_fn: Callable[ + [dict[str, dict[Capability | str, dict[Attribute | str, Status]]]], str | None + ] = lambda _: None CAPABILITY_TO_SENSORS: dict[ @@ -66,6 +69,11 @@ CAPABILITY_TO_SENSORS: dict[ "freezer": "freezer_door", "cooler": "cooler_door", }, + deprecated_fn=( + lambda status: "fridge_door" + if "freezer" in status and "cooler" in status + else None + ), ) }, Capability.CUSTOM_DRYER_WRINKLE_PREVENT: { @@ -141,6 +149,7 @@ CAPABILITY_TO_SENSORS: dict[ translation_key="valve", device_class=BinarySensorDeviceClass.OPENING, is_on_key="open", + deprecated_fn=lambda _: "valve", ) }, Capability.WATER_SENSOR: { @@ -250,7 +259,7 @@ class SmartThingsBinarySensor(SmartThingsEntity, BinarySensorEntity): async def async_added_to_hass(self) -> None: """Call when entity is added to hass.""" await super().async_added_to_hass() - if self.capability is not Capability.VALVE: + if (issue := self.entity_description.deprecated_fn(self.device.status)) is None: return automations = automations_with_entity(self.hass, self.entity_id) scripts = scripts_with_entity(self.hass, self.entity_id) @@ -281,11 +290,11 @@ class SmartThingsBinarySensor(SmartThingsEntity, BinarySensorEntity): async_create_issue( self.hass, DOMAIN, - f"deprecated_binary_valve_{self.entity_id}", + f"deprecated_binary_{issue}_{self.entity_id}", breaks_in_ha_version="2025.10.0", is_fixable=False, severity=IssueSeverity.WARNING, - translation_key="deprecated_binary_valve", + translation_key=f"deprecated_binary_{issue}", translation_placeholders={ "entity": self.entity_id, "items": "\n".join(items_list), @@ -295,6 +304,8 @@ class SmartThingsBinarySensor(SmartThingsEntity, BinarySensorEntity): async def async_will_remove_from_hass(self) -> None: """Call when entity will be removed from hass.""" await super().async_will_remove_from_hass() + if (issue := self.entity_description.deprecated_fn(self.device.status)) is None: + return async_delete_issue( - self.hass, DOMAIN, f"deprecated_binary_valve_{self.entity_id}" + self.hass, DOMAIN, f"deprecated_binary_{issue}_{self.entity_id}" ) diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index 4f667121448..d97a51a9b5d 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -473,6 +473,10 @@ "deprecated_binary_valve": { "title": "Deprecated valve binary sensor detected in some automations or scripts", "description": "The valve binary sensor `{entity}` is deprecated and is used in the following automations or scripts:\n{items}\n\nA valve entity with controls is available and should be used going forward; Please use it on the above automations or scripts to fix this issue." + }, + "deprecated_binary_fridge_door": { + "title": "Deprecated refrigerator door binary sensor detected in some automations or scripts", + "description": "The refrigerator door binary sensor `{entity}` is deprecated and is used in the following automations or scripts:\n{items}\n\nSeparate entities for cooler and freezer door are available and should be used going forward; Please use it on the above automations or scripts to fix this issue." } } } diff --git a/tests/components/smartthings/test_binary_sensor.py b/tests/components/smartthings/test_binary_sensor.py index 4d58b5ddd48..517de034613 100644 --- a/tests/components/smartthings/test_binary_sensor.py +++ b/tests/components/smartthings/test_binary_sensor.py @@ -59,16 +59,23 @@ async def test_state_update( @pytest.mark.usefixtures("entity_registry_enabled_by_default") -@pytest.mark.parametrize("device_fixture", ["virtual_valve"]) +@pytest.mark.parametrize( + ("device_fixture", "issue_string", "entity_id"), + [ + ("virtual_valve", "valve", "binary_sensor.volvo_valve"), + ("da_ref_normal_000001", "fridge_door", "binary_sensor.refrigerator_door"), + ], +) async def test_create_issue( hass: HomeAssistant, devices: AsyncMock, mock_config_entry: MockConfigEntry, issue_registry: ir.IssueRegistry, + issue_string: str, + entity_id: str, ) -> None: """Test we create an issue when an automation or script is using a deprecated entity.""" - entity_id = "binary_sensor.volvo_valve" - issue_id = f"deprecated_binary_valve_{entity_id}" + issue_id = f"deprecated_binary_{issue_string}_{entity_id}" assert await async_setup_component( hass, From cb9692f3fb7a6b7afbe1a645dfe3f6d21f3c291d Mon Sep 17 00:00:00 2001 From: Denis Shulyaka Date: Mon, 24 Mar 2025 17:49:34 +0300 Subject: [PATCH 2961/3148] Raise error when max tokens reached for openai_conversation (#140214) * Handle ResponseIncompleteEvent * Updated error text * Fix tests * Update conversation.py * ruff * More tests * Handle ResponseFailed and ResponseError --------- Co-authored-by: Paulus Schoutsen Co-authored-by: Paulus Schoutsen --- .../openai_conversation/conversation.py | 64 ++++++-- .../openai_conversation/test_conversation.py | 155 +++++++++++++++++- 2 files changed, 203 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/openai_conversation/conversation.py b/homeassistant/components/openai_conversation/conversation.py index 32ac20b2680..873406a3999 100644 --- a/homeassistant/components/openai_conversation/conversation.py +++ b/homeassistant/components/openai_conversation/conversation.py @@ -10,10 +10,13 @@ from openai.types.responses import ( EasyInputMessageParam, FunctionToolParam, ResponseCompletedEvent, + ResponseErrorEvent, + ResponseFailedEvent, ResponseFunctionCallArgumentsDeltaEvent, ResponseFunctionCallArgumentsDoneEvent, ResponseFunctionToolCall, ResponseFunctionToolCallParam, + ResponseIncompleteEvent, ResponseInputParam, ResponseOutputItemAddedEvent, ResponseOutputMessage, @@ -139,18 +142,57 @@ async def _transform_stream( ) ] } - elif ( - isinstance(event, ResponseCompletedEvent) - and (usage := event.response.usage) is not None - ): - chat_log.async_trace( - { - "stats": { - "input_tokens": usage.input_tokens, - "output_tokens": usage.output_tokens, + elif isinstance(event, ResponseCompletedEvent): + if event.response.usage is not None: + chat_log.async_trace( + { + "stats": { + "input_tokens": event.response.usage.input_tokens, + "output_tokens": event.response.usage.output_tokens, + } } - } - ) + ) + elif isinstance(event, ResponseIncompleteEvent): + if event.response.usage is not None: + chat_log.async_trace( + { + "stats": { + "input_tokens": event.response.usage.input_tokens, + "output_tokens": event.response.usage.output_tokens, + } + } + ) + + if ( + event.response.incomplete_details + and event.response.incomplete_details.reason + ): + reason: str = event.response.incomplete_details.reason + else: + reason = "unknown reason" + + if reason == "max_output_tokens": + reason = "max output tokens reached" + elif reason == "content_filter": + reason = "content filter triggered" + + raise HomeAssistantError(f"OpenAI response incomplete: {reason}") + elif isinstance(event, ResponseFailedEvent): + if event.response.usage is not None: + chat_log.async_trace( + { + "stats": { + "input_tokens": event.response.usage.input_tokens, + "output_tokens": event.response.usage.output_tokens, + } + } + ) + reason = "unknown reason" + if event.response.error is not None: + reason = event.response.error.message + raise HomeAssistantError(f"OpenAI response failed: {reason}") + elif isinstance(event, ResponseErrorEvent): + raise HomeAssistantError(f"OpenAI response error: {event.message}") class OpenAIConversationEntity( diff --git a/tests/components/openai_conversation/test_conversation.py b/tests/components/openai_conversation/test_conversation.py index bfcacefb044..fb54c423234 100644 --- a/tests/components/openai_conversation/test_conversation.py +++ b/tests/components/openai_conversation/test_conversation.py @@ -12,9 +12,13 @@ from openai.types.responses import ( ResponseContentPartAddedEvent, ResponseContentPartDoneEvent, ResponseCreatedEvent, + ResponseError, + ResponseErrorEvent, + ResponseFailedEvent, ResponseFunctionCallArgumentsDeltaEvent, ResponseFunctionCallArgumentsDoneEvent, ResponseFunctionToolCall, + ResponseIncompleteEvent, ResponseInProgressEvent, ResponseOutputItemAddedEvent, ResponseOutputItemDoneEvent, @@ -26,6 +30,7 @@ from openai.types.responses import ( ResponseTextDeltaEvent, ResponseTextDoneEvent, ) +from openai.types.responses.response import IncompleteDetails import pytest from syrupy.assertion import SnapshotAssertion @@ -83,17 +88,40 @@ def mock_create_stream() -> Generator[AsyncMock]: response=response, type="response.in_progress", ) + response.status = "completed" for value in events: if isinstance(value, ResponseOutputItemDoneEvent): response.output.append(value.item) + elif isinstance(value, IncompleteDetails): + response.status = "incomplete" + response.incomplete_details = value + break + if isinstance(value, ResponseError): + response.status = "failed" + response.error = value + break + yield value - response.status = "completed" - yield ResponseCompletedEvent( - response=response, - type="response.completed", - ) + if isinstance(value, ResponseErrorEvent): + return + + if response.status == "incomplete": + yield ResponseIncompleteEvent( + response=response, + type="response.incomplete", + ) + elif response.status == "failed": + yield ResponseFailedEvent( + response=response, + type="response.failed", + ) + else: + yield ResponseCompletedEvent( + response=response, + type="response.completed", + ) with patch( "openai.resources.responses.AsyncResponses.create", @@ -175,6 +203,123 @@ async def test_error_handling( assert result.response.speech["plain"]["speech"] == message, result.response.speech +@pytest.mark.parametrize( + ("reason", "message"), + [ + ( + "max_output_tokens", + "max output tokens reached", + ), + ( + "content_filter", + "content filter triggered", + ), + ( + None, + "unknown reason", + ), + ], +) +async def test_incomplete_response( + hass: HomeAssistant, + mock_config_entry_with_assist: MockConfigEntry, + mock_init_component, + mock_create_stream: AsyncMock, + mock_chat_log: MockChatLog, # noqa: F811 + reason: str, + message: str, +) -> None: + """Test handling early model stop.""" + # Incomplete details received after some content is generated + mock_create_stream.return_value = [ + ( + # Start message + *create_message_item( + id="msg_A", + text=["Once upon", " a time, ", "there was "], + output_index=0, + ), + # Length limit or content filter + IncompleteDetails(reason=reason), + ) + ] + + result = await conversation.async_converse( + hass, + "Please tell me a big story", + "mock-conversation-id", + Context(), + agent_id="conversation.openai", + ) + + assert result.response.response_type == intent.IntentResponseType.ERROR, result + assert ( + result.response.speech["plain"]["speech"] + == f"OpenAI response incomplete: {message}" + ), result.response.speech + + # Incomplete details received before any content is generated + mock_create_stream.return_value = [ + ( + # Start generating response + *create_reasoning_item(id="rs_A", output_index=0), + # Length limit or content filter + IncompleteDetails(reason=reason), + ) + ] + + result = await conversation.async_converse( + hass, + "please tell me a big story", + "mock-conversation-id", + Context(), + agent_id="conversation.openai", + ) + + assert result.response.response_type == intent.IntentResponseType.ERROR, result + assert ( + result.response.speech["plain"]["speech"] + == f"OpenAI response incomplete: {message}" + ), result.response.speech + + +@pytest.mark.parametrize( + ("error", "message"), + [ + ( + ResponseError(code="rate_limit_exceeded", message="Rate limit exceeded"), + "OpenAI response failed: Rate limit exceeded", + ), + ( + ResponseErrorEvent(type="error", message="Some error"), + "OpenAI response error: Some error", + ), + ], +) +async def test_failed_response( + hass: HomeAssistant, + mock_config_entry_with_assist: MockConfigEntry, + mock_init_component, + mock_create_stream: AsyncMock, + mock_chat_log: MockChatLog, # noqa: F811 + error: ResponseError | ResponseErrorEvent, + message: str, +) -> None: + """Test handling failed and error responses.""" + mock_create_stream.return_value = [(error,)] + + result = await conversation.async_converse( + hass, + "next natural number please", + "mock-conversation-id", + Context(), + agent_id="conversation.openai", + ) + + assert result.response.response_type == intent.IntentResponseType.ERROR, result + assert result.response.speech["plain"]["speech"] == message, result.response.speech + + async def test_conversation_agent( hass: HomeAssistant, mock_config_entry: MockConfigEntry, From e9cf4a209ed8670973b69b149300af3e24513ef1 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 24 Mar 2025 17:01:19 +0100 Subject: [PATCH 2962/3148] Fix typos in `smartthings` binary sensor deprecation messages (#141299) Fix typos in 'smartthings` binary sensor deprecation messages --- homeassistant/components/smartthings/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index d97a51a9b5d..2612b49a3ed 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -472,11 +472,11 @@ "issues": { "deprecated_binary_valve": { "title": "Deprecated valve binary sensor detected in some automations or scripts", - "description": "The valve binary sensor `{entity}` is deprecated and is used in the following automations or scripts:\n{items}\n\nA valve entity with controls is available and should be used going forward; Please use it on the above automations or scripts to fix this issue." + "description": "The valve binary sensor `{entity}` is deprecated and is used in the following automations or scripts:\n{items}\n\nA valve entity with controls is available and should be used going forward. Please use it in the above automations or scripts to fix this issue." }, "deprecated_binary_fridge_door": { "title": "Deprecated refrigerator door binary sensor detected in some automations or scripts", - "description": "The refrigerator door binary sensor `{entity}` is deprecated and is used in the following automations or scripts:\n{items}\n\nSeparate entities for cooler and freezer door are available and should be used going forward; Please use it on the above automations or scripts to fix this issue." + "description": "The refrigerator door binary sensor `{entity}` is deprecated and is used in the following automations or scripts:\n{items}\n\nSeparate entities for cooler and freezer door are available and should be used going forward. Please use them in the above automations or scripts to fix this issue." } } } From c3bab1f3163401e681a2e13d9eeb44c8eb786e0b Mon Sep 17 00:00:00 2001 From: Thomas55555 <59625598+Thomas55555@users.noreply.github.com> Date: Mon, 24 Mar 2025 17:08:39 +0100 Subject: [PATCH 2963/3148] Add downtime and uptime sensors for Husqvarna Automower (#140804) * Add downtime and uptime sensors for Husqvarna Automower * add strings --- .../components/husqvarna_automower/sensor.py | 24 ++++ .../husqvarna_automower/strings.json | 6 + .../husqvarna_automower/fixtures/mower.json | 4 +- .../snapshots/test_diagnostics.ambr | 4 +- .../snapshots/test_sensor.ambr | 116 ++++++++++++++++++ 5 files changed, 150 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/husqvarna_automower/sensor.py b/homeassistant/components/husqvarna_automower/sensor.py index 2e1d4041e5a..75af24ee0ee 100644 --- a/homeassistant/components/husqvarna_automower/sensor.py +++ b/homeassistant/components/husqvarna_automower/sensor.py @@ -295,6 +295,18 @@ MOWER_SENSOR_TYPES: tuple[AutomowerSensorEntityDescription, ...] = ( exists_fn=lambda data: data.statistics.cutting_blade_usage_time is not None, value_fn=attrgetter("statistics.cutting_blade_usage_time"), ), + AutomowerSensorEntityDescription( + key="downtime", + translation_key="downtime", + state_class=SensorStateClass.TOTAL, + device_class=SensorDeviceClass.DURATION, + entity_registry_enabled_default=False, + native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_display_precision=0, + suggested_unit_of_measurement=UnitOfTime.HOURS, + exists_fn=lambda data: data.statistics.downtime is not None, + value_fn=attrgetter("statistics.downtime"), + ), AutomowerSensorEntityDescription( key="total_charging_time", translation_key="total_charging_time", @@ -367,6 +379,18 @@ MOWER_SENSOR_TYPES: tuple[AutomowerSensorEntityDescription, ...] = ( exists_fn=lambda data: data.statistics.total_drive_distance is not None, value_fn=attrgetter("statistics.total_drive_distance"), ), + AutomowerSensorEntityDescription( + key="uptime", + translation_key="uptime", + state_class=SensorStateClass.TOTAL, + device_class=SensorDeviceClass.DURATION, + entity_registry_enabled_default=False, + native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_display_precision=0, + suggested_unit_of_measurement=UnitOfTime.HOURS, + exists_fn=lambda data: data.statistics.uptime is not None, + value_fn=attrgetter("statistics.uptime"), + ), AutomowerSensorEntityDescription( key="next_start_timestamp", translation_key="next_start_timestamp", diff --git a/homeassistant/components/husqvarna_automower/strings.json b/homeassistant/components/husqvarna_automower/strings.json index 9bd0bb06b3e..35ce342867f 100644 --- a/homeassistant/components/husqvarna_automower/strings.json +++ b/homeassistant/components/husqvarna_automower/strings.json @@ -221,6 +221,9 @@ "cutting_blade_usage_time": { "name": "Cutting blade usage time" }, + "downtime": { + "name": "Downtime" + }, "restricted_reason": { "name": "Restricted reason", "state": { @@ -263,6 +266,9 @@ "demo": "Demo" } }, + "uptime": { + "name": "Uptime" + }, "work_area": { "name": "Work area", "state": { diff --git a/tests/components/husqvarna_automower/fixtures/mower.json b/tests/components/husqvarna_automower/fixtures/mower.json index ee368bf6546..06e11ec1252 100644 --- a/tests/components/husqvarna_automower/fixtures/mower.json +++ b/tests/components/husqvarna_automower/fixtures/mower.json @@ -176,7 +176,7 @@ ], "statistics": { "cuttingBladeUsageTime": 123, - "downTime": 123, + "downTime": 3600, "numberOfChargingCycles": 1380, "numberOfCollisions": 11396, "totalChargingTime": 4334400, @@ -184,7 +184,7 @@ "totalDriveDistance": 1780272, "totalRunningTime": 4564800, "totalSearchingTime": 370800, - "upTime": 456 + "upTime": 7200 }, "stayOutZones": { "dirty": false, diff --git a/tests/components/husqvarna_automower/snapshots/test_diagnostics.ambr b/tests/components/husqvarna_automower/snapshots/test_diagnostics.ambr index 9d5004c8f6d..d5546b0d2af 100644 --- a/tests/components/husqvarna_automower/snapshots/test_diagnostics.ambr +++ b/tests/components/husqvarna_automower/snapshots/test_diagnostics.ambr @@ -95,7 +95,7 @@ }), 'statistics': dict({ 'cutting_blade_usage_time': 123, - 'downtime': 123, + 'downtime': 3600, 'number_of_charging_cycles': 1380, 'number_of_collisions': 11396, 'total_charging_time': 4334400, @@ -103,7 +103,7 @@ 'total_drive_distance': 1780272, 'total_running_time': 4564800, 'total_searching_time': 370800, - 'uptime': 456, + 'uptime': 7200, }), 'stay_out_zones': dict({ 'dirty': False, diff --git a/tests/components/husqvarna_automower/snapshots/test_sensor.ambr b/tests/components/husqvarna_automower/snapshots/test_sensor.ambr index 02a64718276..92320de6fdb 100644 --- a/tests/components/husqvarna_automower/snapshots/test_sensor.ambr +++ b/tests/components/husqvarna_automower/snapshots/test_sensor.ambr @@ -106,6 +106,64 @@ 'state': '0.034', }) # --- +# name: test_sensor_snapshot[sensor.test_mower_1_downtime-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_mower_1_downtime', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Downtime', + 'platform': 'husqvarna_automower', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'downtime', + 'unique_id': 'c7233734-b219-4287-a173-08e3643f89f0_downtime', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_snapshot[sensor.test_mower_1_downtime-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Test Mower 1 Downtime', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_mower_1_downtime', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.0', + }) +# --- # name: test_sensor_snapshot[sensor.test_mower_1_error-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1208,6 +1266,64 @@ 'state': '103.000', }) # --- +# name: test_sensor_snapshot[sensor.test_mower_1_uptime-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_mower_1_uptime', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Uptime', + 'platform': 'husqvarna_automower', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'uptime', + 'unique_id': 'c7233734-b219-4287-a173-08e3643f89f0_uptime', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_snapshot[sensor.test_mower_1_uptime-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Test Mower 1 Uptime', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_mower_1_uptime', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.0', + }) +# --- # name: test_sensor_snapshot[sensor.test_mower_1_work_area-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 5f093180ab1620f05a6ec673a1df869fdb35d26b Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Mon, 24 Mar 2025 12:15:02 -0400 Subject: [PATCH 2964/3148] Include hardware integrations in the cached `integrations.json` (#139001) Include hardware integrations in the cached integrations JSON --- homeassistant/generated/integrations.json | 28 +++++++++++++++++++++++ script/hassfest/config_flow.py | 3 +-- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 64547488e69..f70ed1c1283 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -2496,6 +2496,12 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "hardkernel": { + "name": "Hardkernel", + "integration_type": "hardware", + "config_flow": false, + "single_config_entry": true + }, "harman_kardon_avr": { "name": "Harman Kardon AVR", "integration_type": "hub", @@ -2639,6 +2645,23 @@ "integration_type": "virtual", "supported_by": "netatmo" }, + "homeassistant_green": { + "name": "Home Assistant Green", + "integration_type": "hardware", + "config_flow": false, + "single_config_entry": true + }, + "homeassistant_sky_connect": { + "name": "Home Assistant Connect ZBT-1", + "integration_type": "hardware", + "config_flow": true + }, + "homeassistant_yellow": { + "name": "Home Assistant Yellow", + "integration_type": "hardware", + "config_flow": false, + "single_config_entry": true + }, "homee": { "name": "Homee", "integration_type": "hub", @@ -5199,6 +5222,11 @@ "raspberry_pi": { "name": "Raspberry Pi", "integrations": { + "raspberry_pi": { + "integration_type": "hardware", + "config_flow": false, + "name": "Raspberry Pi" + }, "rpi_camera": { "integration_type": "hub", "config_flow": false, diff --git a/script/hassfest/config_flow.py b/script/hassfest/config_flow.py index f842ec61b97..1f8b7d1139b 100644 --- a/script/hassfest/config_flow.py +++ b/script/hassfest/config_flow.py @@ -95,7 +95,6 @@ def _populate_brand_integrations( integration = integrations.get(domain) if not integration or integration.integration_type in ( "entity", - "hardware", "system", ): continue @@ -171,7 +170,7 @@ def _generate_integrations( result["integration"][domain] = metadata else: # integration integration = integrations[domain] - if integration.integration_type in ("entity", "system", "hardware"): + if integration.integration_type in ("entity", "system"): continue if integration.translated_name: From 95cc3e31f511f52092f406d893191efa4a8af881 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Mon, 24 Mar 2025 17:16:29 +0100 Subject: [PATCH 2965/3148] Add exceptions translations for Shelly integration (#141071) * Add exceptions translations * Improve exception strings for update platform * Fix tests * Improve device_communication_error * Remove error placeholder * Improve tests * Fix test_rpc_set_state_errors * Strings improvement * Remove `device` * Remove `entity` * Fix tests --- homeassistant/components/shelly/__init__.py | 36 ++++++++++++--- homeassistant/components/shelly/button.py | 6 +-- homeassistant/components/shelly/climate.py | 8 +++- .../components/shelly/coordinator.py | 44 ++++++++++++++++--- .../components/shelly/device_trigger.py | 10 ++++- homeassistant/components/shelly/entity.py | 26 ++++++++--- homeassistant/components/shelly/number.py | 10 +++-- homeassistant/components/shelly/strings.json | 37 +++++++++++++++- homeassistant/components/shelly/update.py | 30 +++++++++++-- tests/components/shelly/test_button.py | 8 ++-- tests/components/shelly/test_climate.py | 5 ++- .../components/shelly/test_device_trigger.py | 10 ++++- tests/components/shelly/test_number.py | 5 ++- tests/components/shelly/test_switch.py | 22 ++++++++-- tests/components/shelly/test_update.py | 19 +++++--- 15 files changed, 222 insertions(+), 54 deletions(-) diff --git a/homeassistant/components/shelly/__init__.py b/homeassistant/components/shelly/__init__.py index a7ee1c029df..8e6417c5d7c 100644 --- a/homeassistant/components/shelly/__init__.py +++ b/homeassistant/components/shelly/__init__.py @@ -189,13 +189,25 @@ async def _async_setup_block_entry( if not device.firmware_supported: async_create_issue_unsupported_firmware(hass, entry) await device.shutdown() - raise ConfigEntryNotReady + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="firmware_unsupported", + translation_placeholders={"device": entry.title}, + ) except (DeviceConnectionError, MacAddressMismatchError) as err: await device.shutdown() - raise ConfigEntryNotReady(repr(err)) from err + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="device_communication_error", + translation_placeholders={"device": entry.title}, + ) from err except InvalidAuthError as err: await device.shutdown() - raise ConfigEntryAuthFailed(repr(err)) from err + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="auth_error", + translation_placeholders={"device": entry.title}, + ) from err runtime_data.block = ShellyBlockCoordinator(hass, entry, device) runtime_data.block.async_setup() @@ -272,16 +284,28 @@ async def _async_setup_rpc_entry(hass: HomeAssistant, entry: ShellyConfigEntry) if not device.firmware_supported: async_create_issue_unsupported_firmware(hass, entry) await device.shutdown() - raise ConfigEntryNotReady + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="firmware_unsupported", + translation_placeholders={"device": entry.title}, + ) runtime_data.rpc_script_events = await get_rpc_scripts_event_types( device, ignore_scripts=[BLE_SCRIPT_NAME] ) except (DeviceConnectionError, MacAddressMismatchError, RpcCallError) as err: await device.shutdown() - raise ConfigEntryNotReady(repr(err)) from err + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="device_communication_error", + translation_placeholders={"device": entry.title}, + ) from err except InvalidAuthError as err: await device.shutdown() - raise ConfigEntryAuthFailed(repr(err)) from err + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="auth_error", + translation_placeholders={"device": entry.title}, + ) from err runtime_data.rpc = ShellyRpcCoordinator(hass, entry, device) runtime_data.rpc.async_setup() diff --git a/homeassistant/components/shelly/button.py b/homeassistant/components/shelly/button.py index 15bde4fbdff..06dffba5ead 100644 --- a/homeassistant/components/shelly/button.py +++ b/homeassistant/components/shelly/button.py @@ -193,8 +193,7 @@ class ShellyBaseButton( translation_key="device_communication_action_error", translation_placeholders={ "entity": self.entity_id, - "device": self.coordinator.device.name, - "error": repr(err), + "device": self.coordinator.name, }, ) from err except RpcCallError as err: @@ -203,8 +202,7 @@ class ShellyBaseButton( translation_key="rpc_call_action_error", translation_placeholders={ "entity": self.entity_id, - "device": self.coordinator.device.name, - "error": repr(err), + "device": self.coordinator.name, }, ) from err except InvalidAuthError: diff --git a/homeassistant/components/shelly/climate.py b/homeassistant/components/shelly/climate.py index c3612ed3f4f..498f2d3dba9 100644 --- a/homeassistant/components/shelly/climate.py +++ b/homeassistant/components/shelly/climate.py @@ -326,8 +326,12 @@ class BlockSleepingClimate( except DeviceConnectionError as err: self.coordinator.last_update_success = False raise HomeAssistantError( - f"Setting state for entity {self.name} failed, state: {kwargs}, error:" - f" {err!r}" + translation_domain=DOMAIN, + translation_key="device_communication_action_error", + translation_placeholders={ + "entity": self.entity_id, + "device": self.coordinator.name, + }, ) from err except InvalidAuthError: await self.coordinator.async_shutdown_device_and_start_reauth() diff --git a/homeassistant/components/shelly/coordinator.py b/homeassistant/components/shelly/coordinator.py index 85cf430bc5d..076a6621354 100644 --- a/homeassistant/components/shelly/coordinator.py +++ b/homeassistant/components/shelly/coordinator.py @@ -378,14 +378,23 @@ class ShellyBlockCoordinator(ShellyCoordinatorBase[BlockDevice]): if self.sleep_period: # Sleeping device, no point polling it, just mark it unavailable raise UpdateFailed( - f"Sleeping device did not update within {self.sleep_period} seconds interval" + translation_domain=DOMAIN, + translation_key="update_error_sleeping_device", + translation_placeholders={ + "device": self.name, + "period": str(self.sleep_period), + }, ) LOGGER.debug("Polling Shelly Block Device - %s", self.name) try: await self.device.update() except DeviceConnectionError as err: - raise UpdateFailed(repr(err)) from err + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_error", + translation_placeholders={"device": self.name}, + ) from err except InvalidAuthError: await self.async_shutdown_device_and_start_reauth() @@ -470,7 +479,11 @@ class ShellyRestCoordinator(ShellyCoordinatorBase[BlockDevice]): return await self.device.update_shelly() except (DeviceConnectionError, MacAddressMismatchError) as err: - raise UpdateFailed(repr(err)) from err + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_error", + translation_placeholders={"device": self.name}, + ) from err except InvalidAuthError: await self.async_shutdown_device_and_start_reauth() else: @@ -636,7 +649,12 @@ class ShellyRpcCoordinator(ShellyCoordinatorBase[RpcDevice]): if self.sleep_period: # Sleeping device, no point polling it, just mark it unavailable raise UpdateFailed( - f"Sleeping device did not update within {self.sleep_period} seconds interval" + translation_domain=DOMAIN, + translation_key="update_error_sleeping_device", + translation_placeholders={ + "device": self.name, + "period": str(self.sleep_period), + }, ) async with self._connection_lock: @@ -644,7 +662,11 @@ class ShellyRpcCoordinator(ShellyCoordinatorBase[RpcDevice]): return if not await self._async_device_connect_task(): - raise UpdateFailed("Device reconnect error") + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_error_reconnect_error", + translation_placeholders={"device": self.name}, + ) async def _async_disconnected(self, reconnect: bool) -> None: """Handle device disconnected.""" @@ -820,13 +842,21 @@ class ShellyRpcPollingCoordinator(ShellyCoordinatorBase[RpcDevice]): async def _async_update_data(self) -> None: """Fetch data.""" if not self.device.connected: - raise UpdateFailed("Device disconnected") + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_error_device_disconnected", + translation_placeholders={"device": self.name}, + ) LOGGER.debug("Polling Shelly RPC Device - %s", self.name) try: await self.device.poll() except (DeviceConnectionError, RpcCallError) as err: - raise UpdateFailed(f"Device disconnected: {err!r}") from err + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_error", + translation_placeholders={"device": self.name}, + ) from err except InvalidAuthError: await self.async_shutdown_device_and_start_reauth() diff --git a/homeassistant/components/shelly/device_trigger.py b/homeassistant/components/shelly/device_trigger.py index 6e96eb5ed21..740e6aae9b2 100644 --- a/homeassistant/components/shelly/device_trigger.py +++ b/homeassistant/components/shelly/device_trigger.py @@ -105,7 +105,9 @@ async def async_validate_trigger_config( return config raise InvalidDeviceAutomationConfig( - f"Invalid ({CONF_TYPE},{CONF_SUBTYPE}): {trigger}" + translation_domain=DOMAIN, + translation_key="invalid_trigger", + translation_placeholders={"trigger": str(trigger)}, ) @@ -137,7 +139,11 @@ async def async_get_triggers( return triggers - raise InvalidDeviceAutomationConfig(f"Device not found: {device_id}") + raise InvalidDeviceAutomationConfig( + translation_domain=DOMAIN, + translation_key="device_not_found", + translation_placeholders={"device": device_id}, + ) async def async_attach_trigger( diff --git a/homeassistant/components/shelly/entity.py b/homeassistant/components/shelly/entity.py index 58ac34fc5ca..9ed3f47b41a 100644 --- a/homeassistant/components/shelly/entity.py +++ b/homeassistant/components/shelly/entity.py @@ -19,7 +19,7 @@ from homeassistant.helpers.entity_registry import RegistryEntry from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import CONF_SLEEP_PERIOD, LOGGER +from .const import CONF_SLEEP_PERIOD, DOMAIN, LOGGER from .coordinator import ShellyBlockCoordinator, ShellyConfigEntry, ShellyRpcCoordinator from .utils import ( async_remove_shelly_entity, @@ -345,8 +345,12 @@ class ShellyBlockEntity(CoordinatorEntity[ShellyBlockCoordinator]): except DeviceConnectionError as err: self.coordinator.last_update_success = False raise HomeAssistantError( - f"Setting state for entity {self.name} failed, state: {kwargs}, error:" - f" {err!r}" + translation_domain=DOMAIN, + translation_key="device_communication_action_error", + translation_placeholders={ + "entity": self.entity_id, + "device": self.coordinator.name, + }, ) from err except InvalidAuthError: await self.coordinator.async_shutdown_device_and_start_reauth() @@ -406,13 +410,21 @@ class ShellyRpcEntity(CoordinatorEntity[ShellyRpcCoordinator]): except DeviceConnectionError as err: self.coordinator.last_update_success = False raise HomeAssistantError( - f"Call RPC for {self.name} connection error, method: {method}, params:" - f" {params}, error: {err!r}" + translation_domain=DOMAIN, + translation_key="device_communication_action_error", + translation_placeholders={ + "entity": self.entity_id, + "device": self.coordinator.name, + }, ) from err except RpcCallError as err: raise HomeAssistantError( - f"Call RPC for {self.name} request error, method: {method}, params:" - f" {params}, error: {err!r}" + translation_domain=DOMAIN, + translation_key="rpc_call_action_error", + translation_placeholders={ + "entity": self.entity_id, + "device": self.coordinator.name, + }, ) from err except InvalidAuthError: await self.coordinator.async_shutdown_device_and_start_reauth() diff --git a/homeassistant/components/shelly/number.py b/homeassistant/components/shelly/number.py index a8e6de1ca73..c629eb4a57a 100644 --- a/homeassistant/components/shelly/number.py +++ b/homeassistant/components/shelly/number.py @@ -25,7 +25,7 @@ from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, DeviceIn from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_registry import RegistryEntry -from .const import CONF_SLEEP_PERIOD, LOGGER, VIRTUAL_NUMBER_MODE_MAP +from .const import CONF_SLEEP_PERIOD, DOMAIN, LOGGER, VIRTUAL_NUMBER_MODE_MAP from .coordinator import ShellyBlockCoordinator, ShellyConfigEntry, ShellyRpcCoordinator from .entity import ( BlockEntityDescription, @@ -324,8 +324,12 @@ class BlockSleepingNumber(ShellySleepingBlockAttributeEntity, RestoreNumber): except DeviceConnectionError as err: self.coordinator.last_update_success = False raise HomeAssistantError( - f"Setting state for entity {self.name} failed, state: {params}, error:" - f" {err!r}" + translation_domain=DOMAIN, + translation_key="device_communication_action_error", + translation_placeholders={ + "entity": self.entity_id, + "device": self.coordinator.name, + }, ) from err except InvalidAuthError: await self.coordinator.async_shutdown_device_and_start_reauth() diff --git a/homeassistant/components/shelly/strings.json b/homeassistant/components/shelly/strings.json index 22d88928387..8ca16e2a2b5 100644 --- a/homeassistant/components/shelly/strings.json +++ b/homeassistant/components/shelly/strings.json @@ -204,11 +204,44 @@ } }, "exceptions": { + "auth_error": { + "message": "Authentication failed for {device}, please update your credentials" + }, + "device_communication_error": { + "message": "Device communication error occurred for {device}" + }, "device_communication_action_error": { - "message": "Device communication error occurred while calling the entity {entity} action for {device} device: {error}" + "message": "Device communication error occurred while calling action for {entity} of {device}" + }, + "device_not_found": { + "message": "{device} not found while configuring device automation triggers" + }, + "firmware_unsupported": { + "message": "{device} is running an unsupported firmware, please update the firmware" + }, + "invalid_trigger": { + "message": "Invalid device automation trigger (type, subtype): {trigger}" + }, + "ota_update_connection_error": { + "message": "Device communication error occurred while triggering OTA update for {device}" + }, + "ota_update_rpc_error": { + "message": "RPC call error occurred while triggering OTA update for {device}" }, "rpc_call_action_error": { - "message": "RPC call error occurred while calling the entity {entity} action for {device} device: {error}" + "message": "RPC call error occurred while calling action for {entity} of {device}" + }, + "update_error": { + "message": "An error occurred while retrieving data from {device}" + }, + "update_error_device_disconnected": { + "message": "An error occurred while retrieving data from {device} because it is disconnected" + }, + "update_error_reconnect_error": { + "message": "An error occurred while reconnecting to {device}" + }, + "update_error_sleeping_device": { + "message": "Sleeping device did not update within {period} seconds interval" } }, "issues": { diff --git a/homeassistant/components/shelly/update.py b/homeassistant/components/shelly/update.py index b1aa84b2640..12ce6dc70cd 100644 --- a/homeassistant/components/shelly/update.py +++ b/homeassistant/components/shelly/update.py @@ -25,7 +25,14 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity -from .const import CONF_SLEEP_PERIOD, OTA_BEGIN, OTA_ERROR, OTA_PROGRESS, OTA_SUCCESS +from .const import ( + CONF_SLEEP_PERIOD, + DOMAIN, + OTA_BEGIN, + OTA_ERROR, + OTA_PROGRESS, + OTA_SUCCESS, +) from .coordinator import ShellyBlockCoordinator, ShellyConfigEntry, ShellyRpcCoordinator from .entity import ( RestEntityDescription, @@ -198,7 +205,11 @@ class RestUpdateEntity(ShellyRestAttributeEntity, UpdateEntity): try: result = await self.coordinator.device.trigger_ota_update(beta=beta) except DeviceConnectionError as err: - raise HomeAssistantError(f"Error starting OTA update: {err!r}") from err + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="ota_update_connection_error", + translation_placeholders={"device": self.coordinator.name}, + ) from err except InvalidAuthError: await self.coordinator.async_shutdown_device_and_start_reauth() else: @@ -310,9 +321,20 @@ class RpcUpdateEntity(ShellyRpcAttributeEntity, UpdateEntity): try: await self.coordinator.device.trigger_ota_update(beta=beta) except DeviceConnectionError as err: - raise HomeAssistantError(f"OTA update connection error: {err!r}") from err + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="ota_update_connection_error", + translation_placeholders={"device": self.coordinator.name}, + ) from err except RpcCallError as err: - raise HomeAssistantError(f"OTA update request error: {err!r}") from err + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="ota_update_rpc_error", + translation_placeholders={ + "entity": self.entity_id, + "device": self.coordinator.name, + }, + ) from err except InvalidAuthError: await self.coordinator.async_shutdown_device_and_start_reauth() else: diff --git a/tests/components/shelly/test_button.py b/tests/components/shelly/test_button.py index 2a9720ca7ae..edf11b0e163 100644 --- a/tests/components/shelly/test_button.py +++ b/tests/components/shelly/test_button.py @@ -74,11 +74,11 @@ async def test_rpc_button( [ ( DeviceConnectionError, - "Device communication error occurred while calling the entity button.test_name_reboot action for Test name device", + "Device communication error occurred while calling action for button.test_name_reboot of Test name", ), ( RpcCallError(999), - "RPC call error occurred while calling the entity button.test_name_reboot action for Test name device", + "RPC call error occurred while calling action for button.test_name_reboot of Test name", ), ], ) @@ -212,11 +212,11 @@ async def test_rpc_blu_trv_button( [ ( DeviceConnectionError, - "Device communication error occurred while calling the entity button.trv_name_calibrate action for Test name device", + "Device communication error occurred while calling action for button.trv_name_calibrate of Test name", ), ( RpcCallError(999), - "RPC call error occurred while calling the entity button.trv_name_calibrate action for Test name device", + "RPC call error occurred while calling action for button.trv_name_calibrate of Test name", ), ], ) diff --git a/tests/components/shelly/test_climate.py b/tests/components/shelly/test_climate.py index ac9c7967540..c0bb47bfab6 100644 --- a/tests/components/shelly/test_climate.py +++ b/tests/components/shelly/test_climate.py @@ -462,7 +462,10 @@ async def test_block_set_mode_connection_error( mock_block_device.mock_online() await hass.async_block_till_done(wait_background_tasks=True) - with pytest.raises(HomeAssistantError): + with pytest.raises( + HomeAssistantError, + match="Device communication error occurred while calling action for climate.test_name of Test name", + ): await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, diff --git a/tests/components/shelly/test_device_trigger.py b/tests/components/shelly/test_device_trigger.py index 89045208d20..ca9edb19fa7 100644 --- a/tests/components/shelly/test_device_trigger.py +++ b/tests/components/shelly/test_device_trigger.py @@ -168,7 +168,10 @@ async def test_get_triggers_for_invalid_device_id( connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - with pytest.raises(InvalidDeviceAutomationConfig): + with pytest.raises( + InvalidDeviceAutomationConfig, + match="not found while configuring device automation triggers", + ): await async_get_device_automations( hass, DeviceAutomationType.TRIGGER, invalid_device.id ) @@ -384,7 +387,10 @@ async def test_validate_trigger_invalid_triggers( }, ) - assert "Invalid (type,subtype): ('single', 'button3')" in caplog.text + assert ( + "Invalid device automation trigger (type, subtype): ('single', 'button3')" + in caplog.text + ) async def test_rpc_no_runtime_data( diff --git a/tests/components/shelly/test_number.py b/tests/components/shelly/test_number.py index c032a137bfc..ef5766e0091 100644 --- a/tests/components/shelly/test_number.py +++ b/tests/components/shelly/test_number.py @@ -200,7 +200,10 @@ async def test_block_set_value_connection_error( mock_block_device.mock_online() await hass.async_block_till_done(wait_background_tasks=True) - with pytest.raises(HomeAssistantError): + with pytest.raises( + HomeAssistantError, + match="Device communication error occurred while calling action for number.test_name_valve_position of Test name", + ): await hass.services.async_call( NUMBER_DOMAIN, SERVICE_SET_VALUE, diff --git a/tests/components/shelly/test_switch.py b/tests/components/shelly/test_switch.py index 0425f883ad6..fb1c826c67c 100644 --- a/tests/components/shelly/test_switch.py +++ b/tests/components/shelly/test_switch.py @@ -221,7 +221,10 @@ async def test_block_set_state_connection_error( ) await init_integration(hass, 1) - with pytest.raises(HomeAssistantError): + with pytest.raises( + HomeAssistantError, + match="Device communication error occurred while calling action for switch.test_name_channel_1 of Test name", + ): await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_OFF, @@ -360,10 +363,23 @@ async def test_rpc_device_switch_type_lights_mode( assert hass.states.get("switch.test_switch_0") is None -@pytest.mark.parametrize("exc", [DeviceConnectionError, RpcCallError(-1, "error")]) +@pytest.mark.parametrize( + ("exc", "error"), + [ + ( + DeviceConnectionError, + "Device communication error occurred while calling action for switch.test_switch_0 of Test name", + ), + ( + RpcCallError(-1, "error"), + "RPC call error occurred while calling action for switch.test_switch_0 of Test name", + ), + ], +) async def test_rpc_set_state_errors( hass: HomeAssistant, exc: Exception, + error: str, mock_rpc_device: Mock, monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -373,7 +389,7 @@ async def test_rpc_set_state_errors( monkeypatch.setitem(mock_rpc_device.status["sys"], "relay_in_thermostat", False) await init_integration(hass, 2) - with pytest.raises(HomeAssistantError): + with pytest.raises(HomeAssistantError, match=error): await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_OFF, diff --git a/tests/components/shelly/test_update.py b/tests/components/shelly/test_update.py index 9ea66c1acb7..29d72ab4aa8 100644 --- a/tests/components/shelly/test_update.py +++ b/tests/components/shelly/test_update.py @@ -184,14 +184,16 @@ async def test_block_update_connection_error( ) await init_integration(hass, 1) - with pytest.raises(HomeAssistantError) as excinfo: + with pytest.raises( + HomeAssistantError, + match="Device communication error occurred while triggering OTA update for Test name", + ): await hass.services.async_call( UPDATE_DOMAIN, SERVICE_INSTALL, {ATTR_ENTITY_ID: "update.test_name_firmware"}, blocking=True, ) - assert "Error starting OTA update" in str(excinfo.value) @pytest.mark.usefixtures("entity_registry_enabled_by_default") @@ -673,8 +675,14 @@ async def test_rpc_beta_update( @pytest.mark.parametrize( ("exc", "error"), [ - (DeviceConnectionError, "OTA update connection error: DeviceConnectionError()"), - (RpcCallError(-1, "error"), "OTA update request error"), + ( + DeviceConnectionError, + "Device communication error occurred while triggering OTA update for Test name", + ), + ( + RpcCallError(-1, "error"), + "RPC call error occurred while triggering OTA update for Test name", + ), ], ) @pytest.mark.usefixtures("entity_registry_enabled_by_default") @@ -701,14 +709,13 @@ async def test_rpc_update_errors( ) await init_integration(hass, 2) - with pytest.raises(HomeAssistantError) as excinfo: + with pytest.raises(HomeAssistantError, match=error): await hass.services.async_call( UPDATE_DOMAIN, SERVICE_INSTALL, {ATTR_ENTITY_ID: "update.test_name_firmware"}, blocking=True, ) - assert error in str(excinfo.value) @pytest.mark.usefixtures("entity_registry_enabled_by_default") From 1166c9d9275c629c0277aa84c60f98422d388b6e Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Mon, 24 Mar 2025 17:16:59 +0100 Subject: [PATCH 2966/3148] Do not return `router` as `source_type` for Tractive `device_tracker` entity (#141188) * Do not return router as source_type * Add test * Update stale docstring --------- Co-authored-by: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> --- .../components/tractive/device_tracker.py | 4 +-- .../tractive/test_device_tracker.py | 28 +++++++++++++++++++ 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/tractive/device_tracker.py b/homeassistant/components/tractive/device_tracker.py index 73be7216a2f..bd1380ade4c 100644 --- a/homeassistant/components/tractive/device_tracker.py +++ b/homeassistant/components/tractive/device_tracker.py @@ -55,11 +55,9 @@ class TractiveDeviceTracker(TractiveEntity, TrackerEntity): @property def source_type(self) -> SourceType: - """Return the source type, eg gps or router, of the device.""" + """Return the source type of the device.""" if self._source_type == "PHONE": return SourceType.BLUETOOTH - if self._source_type == "KNOWN_WIFI": - return SourceType.ROUTER return SourceType.GPS @property diff --git a/tests/components/tractive/test_device_tracker.py b/tests/components/tractive/test_device_tracker.py index ff78173ef7b..ff9c7ca88ef 100644 --- a/tests/components/tractive/test_device_tracker.py +++ b/tests/components/tractive/test_device_tracker.py @@ -59,3 +59,31 @@ async def test_source_type_phone( hass.states.get("device_tracker.test_pet_tracker").attributes["source_type"] is SourceType.BLUETOOTH ) + + +async def test_source_type_gps( + hass: HomeAssistant, + mock_tractive_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test if the source type is GPS when the location sensor is KNOWN WIFI.""" + await init_integration(hass, mock_config_entry) + + mock_tractive_client.send_position_event( + mock_config_entry, + { + "tracker_id": "device_id_123", + "position": { + "latlong": [22.333, 44.555], + "accuracy": 99, + "sensor_used": "KNOWN_WIFI", + }, + }, + ) + mock_tractive_client.send_hardware_event(mock_config_entry) + await hass.async_block_till_done() + + assert ( + hass.states.get("device_tracker.test_pet_tracker").attributes["source_type"] + is SourceType.GPS + ) From 93561543ff440748f557cc91252fe5bc558cd1e9 Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Mon, 24 Mar 2025 17:21:32 +0100 Subject: [PATCH 2967/3148] Improve code quality of condition validation (#141292) Streamline condition validation --- homeassistant/helpers/config_validation.py | 89 ++++++++++------------ 1 file changed, 42 insertions(+), 47 deletions(-) diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index 4978158c0f6..5c1a7c99565 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -1153,41 +1153,6 @@ def _custom_serializer(schema: Any, *, allow_section: bool) -> Any: return voluptuous_serialize.UNSUPPORTED -def expand_condition_shorthand(value: Any | None) -> Any: - """Expand boolean condition shorthand notations.""" - - if not isinstance(value, dict) or CONF_CONDITIONS in value: - return value - - for key, schema in ( - ("and", AND_CONDITION_SHORTHAND_SCHEMA), - ("or", OR_CONDITION_SHORTHAND_SCHEMA), - ("not", NOT_CONDITION_SHORTHAND_SCHEMA), - ): - try: - schema(value) - return { - CONF_CONDITION: key, - CONF_CONDITIONS: value[key], - **{k: value[k] for k in value if k != key}, - } - except vol.MultipleInvalid: - pass - - if isinstance(value.get(CONF_CONDITION), list): - try: - CONDITION_SHORTHAND_SCHEMA(value) - return { - CONF_CONDITION: "and", - CONF_CONDITIONS: value[CONF_CONDITION], - **{k: value[k] for k in value if k != CONF_CONDITION}, - } - except vol.MultipleInvalid: - pass - - return value - - # Schemas def empty_config_schema(domain: str) -> Callable[[dict], dict]: """Return a config schema which logs if there are configuration parameters.""" @@ -1683,7 +1648,43 @@ DEVICE_CONDITION_BASE_SCHEMA = vol.Schema( DEVICE_CONDITION_SCHEMA = DEVICE_CONDITION_BASE_SCHEMA.extend({}, extra=vol.ALLOW_EXTRA) -dynamic_template_condition_action = vol.All( + +def expand_condition_shorthand(value: Any | None) -> Any: + """Expand boolean condition shorthand notations.""" + + if not isinstance(value, dict) or CONF_CONDITIONS in value: + return value + + for key, schema in ( + ("and", AND_CONDITION_SHORTHAND_SCHEMA), + ("or", OR_CONDITION_SHORTHAND_SCHEMA), + ("not", NOT_CONDITION_SHORTHAND_SCHEMA), + ): + try: + schema(value) + return { + CONF_CONDITION: key, + CONF_CONDITIONS: value[key], + **{k: value[k] for k in value if k != key}, + } + except vol.MultipleInvalid: + pass + + if isinstance(value.get(CONF_CONDITION), list): + try: + CONDITION_SHORTHAND_SCHEMA(value) + return { + CONF_CONDITION: "and", + CONF_CONDITIONS: value[CONF_CONDITION], + **{k: value[k] for k in value if k != CONF_CONDITION}, + } + except vol.MultipleInvalid: + pass + + return value + + +dynamic_template_condition = vol.All( # Wrap a shorthand template condition in a template condition dynamic_template, lambda config: { @@ -1724,7 +1725,7 @@ CONDITION_SCHEMA: vol.Schema = vol.Schema( }, ), ), - dynamic_template_condition_action, + dynamic_template_condition, ) ) @@ -1873,12 +1874,8 @@ _SCRIPT_REPEAT_SCHEMA = vol.Schema( vol.Exclusive(CONF_FOR_EACH, "repeat"): vol.Any( dynamic_template, vol.All(list, template_complex) ), - vol.Exclusive(CONF_WHILE, "repeat"): vol.All( - ensure_list, [CONDITION_SCHEMA] - ), - vol.Exclusive(CONF_UNTIL, "repeat"): vol.All( - ensure_list, [CONDITION_SCHEMA] - ), + vol.Exclusive(CONF_WHILE, "repeat"): CONDITIONS_SCHEMA, + vol.Exclusive(CONF_UNTIL, "repeat"): CONDITIONS_SCHEMA, vol.Required(CONF_SEQUENCE): SCRIPT_SCHEMA, }, has_at_least_one_key(CONF_COUNT, CONF_FOR_EACH, CONF_WHILE, CONF_UNTIL), @@ -1894,9 +1891,7 @@ _SCRIPT_CHOOSE_SCHEMA = vol.Schema( [ { vol.Optional(CONF_ALIAS): string, - vol.Required(CONF_CONDITIONS): vol.All( - ensure_list, [CONDITION_SCHEMA] - ), + vol.Required(CONF_CONDITIONS): CONDITIONS_SCHEMA, vol.Required(CONF_SEQUENCE): SCRIPT_SCHEMA, } ], @@ -1917,7 +1912,7 @@ _SCRIPT_WAIT_FOR_TRIGGER_SCHEMA = vol.Schema( _SCRIPT_IF_SCHEMA = vol.Schema( { **SCRIPT_ACTION_BASE_SCHEMA, - vol.Required(CONF_IF): vol.All(ensure_list, [CONDITION_SCHEMA]), + vol.Required(CONF_IF): CONDITIONS_SCHEMA, vol.Required(CONF_THEN): SCRIPT_SCHEMA, vol.Optional(CONF_ELSE): SCRIPT_SCHEMA, } From 9fdb69c5581b514415ddf0a4c1e6bd3fd8231d6c Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 24 Mar 2025 17:22:52 +0100 Subject: [PATCH 2968/3148] Remove the zengge integration (#141283) --- homeassistant/components/zengge/light.py | 130 ++---------------- homeassistant/components/zengge/manifest.json | 3 +- homeassistant/components/zengge/strings.json | 8 ++ requirements_all.txt | 4 - 4 files changed, 24 insertions(+), 121 deletions(-) create mode 100644 homeassistant/components/zengge/strings.json diff --git a/homeassistant/components/zengge/light.py b/homeassistant/components/zengge/light.py index 2ab46820b56..ccb6733c650 100644 --- a/homeassistant/components/zengge/light.py +++ b/homeassistant/components/zengge/light.py @@ -2,138 +2,38 @@ from __future__ import annotations -import logging -from typing import Any - import voluptuous as vol -from zengge import zengge -from homeassistant.components.light import ( - ATTR_BRIGHTNESS, - ATTR_HS_COLOR, - ATTR_WHITE, - PLATFORM_SCHEMA as LIGHT_PLATFORM_SCHEMA, - ColorMode, - LightEntity, -) +from homeassistant.components.light import PLATFORM_SCHEMA as LIGHT_PLATFORM_SCHEMA from homeassistant.const import CONF_DEVICES, CONF_NAME from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import config_validation as cv, issue_registry as ir from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.util import color as color_util - -_LOGGER = logging.getLogger(__name__) DEVICE_SCHEMA = vol.Schema({vol.Optional(CONF_NAME): cv.string}) +DOMAIN = "zengge" PLATFORM_SCHEMA = LIGHT_PLATFORM_SCHEMA.extend( {vol.Optional(CONF_DEVICES, default={}): {cv.string: DEVICE_SCHEMA}} ) -def setup_platform( +def async_setup_platform( hass: HomeAssistant, config: ConfigType, add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Zengge platform.""" - lights = [] - for address, device_config in config[CONF_DEVICES].items(): - light = ZenggeLight(device_config[CONF_NAME], address) - if light.is_valid: - lights.append(light) - - add_entities(lights, True) - - -class ZenggeLight(LightEntity): - """Representation of a Zengge light.""" - - _attr_supported_color_modes = {ColorMode.HS, ColorMode.WHITE} - - def __init__(self, name: str, address: str) -> None: - """Initialize the light.""" - - self._attr_name = name - self._attr_unique_id = address - self.is_valid = True - self._bulb = zengge(address) - self._white = 0 - self._attr_brightness = 0 - self._attr_hs_color = (0, 0) - self._attr_is_on = False - if self._bulb.connect() is False: - self.is_valid = False - _LOGGER.error("Failed to connect to bulb %s, %s", address, name) - return - - @property - def white_value(self) -> int: - """Return the white property.""" - return self._white - - @property - def color_mode(self) -> ColorMode: - """Return the current color mode.""" - if self._white != 0: - return ColorMode.WHITE - return ColorMode.HS - - def _set_rgb(self, red: int, green: int, blue: int) -> None: - """Set the rgb state.""" - self._bulb.set_rgb(red, green, blue) - - def _set_white(self, white): - """Set the white state.""" - return self._bulb.set_white(white) - - def turn_on(self, **kwargs: Any) -> None: - """Turn the specified light on.""" - self._attr_is_on = True - self._bulb.on() - - hs_color = kwargs.get(ATTR_HS_COLOR) - white = kwargs.get(ATTR_WHITE) - brightness = kwargs.get(ATTR_BRIGHTNESS) - - if white is not None: - # Change the bulb to white - self._attr_brightness = white - self._white = white - self._attr_hs_color = (0, 0) - - if hs_color is not None: - # Change the bulb to hs - self._white = 0 - self._attr_hs_color = hs_color - - if brightness is not None: - self._attr_brightness = brightness - - if self._white != 0: - self._set_white(self.brightness) - else: - assert self.hs_color is not None - assert self.brightness is not None - rgb = color_util.color_hsv_to_RGB( - self.hs_color[0], self.hs_color[1], self.brightness / 255 * 100 - ) - self._set_rgb(*rgb) - - def turn_off(self, **kwargs: Any) -> None: - """Turn the specified light off.""" - self._attr_is_on = False - self._bulb.off() - - def update(self) -> None: - """Synchronise internal state with the actual light state.""" - rgb = self._bulb.get_colour() - hsv = color_util.color_RGB_to_hsv(*rgb) - self._attr_hs_color = hsv[:2] - self._attr_brightness = int((hsv[2] / 100) * 255) - self._white = self._bulb.get_white() - if self._white: - self._attr_brightness = self._white - self._attr_is_on = self._bulb.get_on() + ir.async_create_issue( + hass, + DOMAIN, + DOMAIN, + is_fixable=False, + severity=ir.IssueSeverity.ERROR, + translation_key="integration_removed", + translation_placeholders={ + "led_ble_url": "https://www.home-assistant.io/integrations/led_ble/", + }, + ) diff --git a/homeassistant/components/zengge/manifest.json b/homeassistant/components/zengge/manifest.json index 03d989c5f3b..daa63b4de3d 100644 --- a/homeassistant/components/zengge/manifest.json +++ b/homeassistant/components/zengge/manifest.json @@ -5,6 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/zengge", "iot_class": "local_polling", "loggers": ["zengge"], - "quality_scale": "legacy", - "requirements": ["bluepy==1.3.0", "zengge==0.2"] + "quality_scale": "legacy" } diff --git a/homeassistant/components/zengge/strings.json b/homeassistant/components/zengge/strings.json new file mode 100644 index 00000000000..abc3b2450aa --- /dev/null +++ b/homeassistant/components/zengge/strings.json @@ -0,0 +1,8 @@ +{ + "issues": { + "integration_removed": { + "title": "The Zengge integration has been removed", + "description": "The Zengge integration has been removed from Home Assistant. Support for Zengge lights is provided by the `led_ble` integration.\n\nTo resolve this issue, please remove the (now defunct) `zengge` light configuration from your Home Assistant configuration and [configure the `led_ble` integration]({led_ble_url})." + } + } +} diff --git a/requirements_all.txt b/requirements_all.txt index d59c11f5709..b7974b4affd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -627,7 +627,6 @@ bluecurrent-api==1.2.3 bluemaestro-ble==0.2.3 # homeassistant.components.decora -# homeassistant.components.zengge # bluepy==1.3.0 # homeassistant.components.bluetooth @@ -3143,9 +3142,6 @@ zabbix-utils==2.0.2 # homeassistant.components.zamg zamg==0.3.6 -# homeassistant.components.zengge -zengge==0.2 - # homeassistant.components.zeroconf zeroconf==0.146.0 From 4472dc533d1b5d3d1874ee87d08bad2920fd7fab Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Mon, 24 Mar 2025 11:26:35 -0500 Subject: [PATCH 2969/3148] Don't filter nevermind for fallback (#141294) --- homeassistant/components/assist_pipeline/pipeline.py | 2 +- tests/components/assist_pipeline/test_pipeline.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/assist_pipeline/pipeline.py b/homeassistant/components/assist_pipeline/pipeline.py index 42bb2d4ced8..a205db4e615 100644 --- a/homeassistant/components/assist_pipeline/pipeline.py +++ b/homeassistant/components/assist_pipeline/pipeline.py @@ -125,7 +125,7 @@ SAVE_DELAY = 10 @callback def _async_local_fallback_intent_filter(result: RecognizeResult) -> bool: """Filter out intents that are not local fallback.""" - return result.intent.name in (intent.INTENT_GET_STATE, intent.INTENT_NEVERMIND) + return result.intent.name in (intent.INTENT_GET_STATE) @callback diff --git a/tests/components/assist_pipeline/test_pipeline.py b/tests/components/assist_pipeline/test_pipeline.py index a7f6fbf7553..d67a0fd1726 100644 --- a/tests/components/assist_pipeline/test_pipeline.py +++ b/tests/components/assist_pipeline/test_pipeline.py @@ -684,7 +684,7 @@ def test_fallback_intent_filter() -> None: entities_list=[], ) ) - is True + is False ) assert ( _async_local_fallback_intent_filter( From 8904f174d2b8be696901d5ea4022d4f238dfff70 Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Mon, 24 Mar 2025 17:27:27 +0100 Subject: [PATCH 2970/3148] Remove unused util module in conversation (#141293) --- .../components/conversation/__init__.py | 3 - homeassistant/components/conversation/util.py | 37 ------------ tests/components/conversation/test_util.py | 56 ------------------- 3 files changed, 96 deletions(-) delete mode 100644 homeassistant/components/conversation/util.py delete mode 100644 tests/components/conversation/test_util.py diff --git a/homeassistant/components/conversation/__init__.py b/homeassistant/components/conversation/__init__.py index 14c5244c18b..25aaf6df290 100644 --- a/homeassistant/components/conversation/__init__.py +++ b/homeassistant/components/conversation/__init__.py @@ -4,7 +4,6 @@ from __future__ import annotations from collections.abc import Callable import logging -import re from typing import Literal from hassil.recognize import RecognizeResult @@ -91,8 +90,6 @@ __all__ = [ _LOGGER = logging.getLogger(__name__) -REGEX_TYPE = type(re.compile("")) - SERVICE_PROCESS_SCHEMA = vol.Schema( { vol.Required(ATTR_TEXT): cv.string, diff --git a/homeassistant/components/conversation/util.py b/homeassistant/components/conversation/util.py deleted file mode 100644 index 4326c95cb66..00000000000 --- a/homeassistant/components/conversation/util.py +++ /dev/null @@ -1,37 +0,0 @@ -"""Util for Conversation.""" - -from __future__ import annotations - -import re - - -def create_matcher(utterance: str) -> re.Pattern[str]: - """Create a regex that matches the utterance.""" - # Split utterance into parts that are type: NORMAL, GROUP or OPTIONAL - # Pattern matches (GROUP|OPTIONAL): Change light to [the color] {name} - parts = re.split(r"({\w+}|\[[\w\s]+\] *)", utterance) - # Pattern to extract name from GROUP part. Matches {name} - group_matcher = re.compile(r"{(\w+)}") - # Pattern to extract text from OPTIONAL part. Matches [the color] - optional_matcher = re.compile(r"\[([\w ]+)\] *") - - pattern = ["^"] - for part in parts: - group_match = group_matcher.match(part) - optional_match = optional_matcher.match(part) - - # Normal part - if group_match is None and optional_match is None: - pattern.append(part) - continue - - # Group part - if group_match is not None: - pattern.append(rf"(?P<{group_match.groups()[0]}>[\w ]+?)\s*") - - # Optional part - elif optional_match is not None: - pattern.append(rf"(?:{optional_match.groups()[0]} *)?") - - pattern.append("$") - return re.compile("".join(pattern), re.IGNORECASE) diff --git a/tests/components/conversation/test_util.py b/tests/components/conversation/test_util.py deleted file mode 100644 index 72a334232c1..00000000000 --- a/tests/components/conversation/test_util.py +++ /dev/null @@ -1,56 +0,0 @@ -"""Test the conversation utils.""" - -from homeassistant.components.conversation.util import create_matcher - - -def test_create_matcher() -> None: - """Test the create matcher method.""" - # Basic sentence - pattern = create_matcher("Hello world") - assert pattern.match("Hello world") is not None - - # Match a part - pattern = create_matcher("Hello {name}") - match = pattern.match("hello world") - assert match is not None - assert match.groupdict()["name"] == "world" - no_match = pattern.match("Hello world, how are you?") - assert no_match is None - - # Optional and matching part - pattern = create_matcher("Turn on [the] {name}") - match = pattern.match("turn on the kitchen lights") - assert match is not None - assert match.groupdict()["name"] == "kitchen lights" - match = pattern.match("turn on kitchen lights") - assert match is not None - assert match.groupdict()["name"] == "kitchen lights" - match = pattern.match("turn off kitchen lights") - assert match is None - - # Two different optional parts, 1 matching part - pattern = create_matcher("Turn on [the] [a] {name}") - match = pattern.match("turn on the kitchen lights") - assert match is not None - assert match.groupdict()["name"] == "kitchen lights" - match = pattern.match("turn on kitchen lights") - assert match is not None - assert match.groupdict()["name"] == "kitchen lights" - match = pattern.match("turn on a kitchen light") - assert match is not None - assert match.groupdict()["name"] == "kitchen light" - - # Strip plural - pattern = create_matcher("Turn {name}[s] on") - match = pattern.match("turn kitchen lights on") - assert match is not None - assert match.groupdict()["name"] == "kitchen light" - - # Optional 2 words - pattern = create_matcher("Turn [the great] {name} on") - match = pattern.match("turn the great kitchen lights on") - assert match is not None - assert match.groupdict()["name"] == "kitchen lights" - match = pattern.match("turn kitchen lights on") - assert match is not None - assert match.groupdict()["name"] == "kitchen lights" From 666121822021b13c947b93181bf7baa020d69243 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Mon, 24 Mar 2025 18:03:29 +0100 Subject: [PATCH 2971/3148] Add device reconfigure to Vodafone Station config flow (#141221) * Add device reconfigure to Vodafone Station config flow * remove unreachable code * apply review comment --- .../vodafone_station/config_flow.py | 41 +++++++++++ .../vodafone_station/quality_scale.yaml | 4 +- .../components/vodafone_station/strings.json | 13 ++++ .../vodafone_station/test_config_flow.py | 72 +++++++++++++++++++ 4 files changed, 127 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/vodafone_station/config_flow.py b/homeassistant/components/vodafone_station/config_flow.py index fd0683bdacc..6641f5f5711 100644 --- a/homeassistant/components/vodafone_station/config_flow.py +++ b/homeassistant/components/vodafone_station/config_flow.py @@ -139,6 +139,47 @@ class VodafoneStationConfigFlow(ConfigFlow, domain=DOMAIN): errors=errors, ) + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfiguration of the device.""" + reconfigure_entry = self._get_reconfigure_entry() + if not user_input: + return self.async_show_form( + step_id="reconfigure", data_schema=user_form_schema(user_input) + ) + + updated_host = user_input[CONF_HOST] + + if reconfigure_entry.data[CONF_HOST] != updated_host: + self._async_abort_entries_match({CONF_HOST: updated_host}) + + errors: dict[str, str] = {} + + errors = {} + + try: + await validate_input(self.hass, user_input) + except aiovodafone_exceptions.AlreadyLogged: + errors["base"] = "already_logged" + except aiovodafone_exceptions.CannotConnect: + errors["base"] = "cannot_connect" + except aiovodafone_exceptions.CannotAuthenticate: + errors["base"] = "invalid_auth" + except Exception: # noqa: BLE001 + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + return self.async_update_reload_and_abort( + reconfigure_entry, data_updates={CONF_HOST: updated_host} + ) + + return self.async_show_form( + step_id="reconfigure", + data_schema=user_form_schema(user_input), + errors=errors, + ) + class VodafoneStationOptionsFlowHandler(OptionsFlow): """Handle a option flow.""" diff --git a/homeassistant/components/vodafone_station/quality_scale.yaml b/homeassistant/components/vodafone_station/quality_scale.yaml index d8476842b53..d60020f5e47 100644 --- a/homeassistant/components/vodafone_station/quality_scale.yaml +++ b/homeassistant/components/vodafone_station/quality_scale.yaml @@ -64,9 +64,7 @@ rules: entity-translations: done exception-translations: done icon-translations: done - reconfiguration-flow: - status: todo - comment: handle host change + reconfiguration-flow: done repair-issues: status: exempt comment: no known use cases for repair issues or flows, yet diff --git a/homeassistant/components/vodafone_station/strings.json b/homeassistant/components/vodafone_station/strings.json index 6e308c35e4f..958b774a485 100644 --- a/homeassistant/components/vodafone_station/strings.json +++ b/homeassistant/components/vodafone_station/strings.json @@ -21,12 +21,25 @@ "username": "The username for your Vodafone Station.", "password": "The password for your Vodafone Station." } + }, + "reconfigure": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "[%key:component::vodafone_station::config::step::user::data_description::host%]", + "username": "[%key:component::vodafone_station::config::step::user::data_description::username%]", + "password": "[%key:component::vodafone_station::config::step::user::data_description::password%]" + } } }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", "already_logged": "User already logged-in, please try again later.", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "model_not_supported": "The device model is currently unsupported.", diff --git a/tests/components/vodafone_station/test_config_flow.py b/tests/components/vodafone_station/test_config_flow.py index 68f8247bdf9..0648987eb27 100644 --- a/tests/components/vodafone_station/test_config_flow.py +++ b/tests/components/vodafone_station/test_config_flow.py @@ -228,3 +228,75 @@ async def test_options_flow( assert result["data"] == { CONF_CONSIDER_HOME: 37, } + + +async def test_reconfigure_successful( + hass: HomeAssistant, + mock_vodafone_station_router: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that the host can be reconfigured.""" + mock_config_entry.add_to_hass(hass) + result = await mock_config_entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + # original entry + assert mock_config_entry.data["host"] == "fake_host" + + reconfigure_result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "192.168.100.60", + "password": "fake_password", + "username": "fake_username", + }, + ) + + assert reconfigure_result["type"] is FlowResultType.ABORT + assert reconfigure_result["reason"] == "reconfigure_successful" + + # changed entry + assert mock_config_entry.data["host"] == "192.168.100.60" + + +@pytest.mark.parametrize( + ("side_effect", "error"), + [ + (CannotConnect, "cannot_connect"), + (CannotAuthenticate, "invalid_auth"), + (AlreadyLogged, "already_logged"), + (ConnectionResetError, "unknown"), + ], +) +async def test_reconfigure_fails( + hass: HomeAssistant, + mock_vodafone_station_router: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, + side_effect: Exception, + error: str, +) -> None: + """Test that the host can be reconfigured.""" + mock_config_entry.add_to_hass(hass) + result = await mock_config_entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + mock_vodafone_station_router.login.side_effect = side_effect + + reconfigure_result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "192.168.100.60", + "password": "fake_password", + "username": "fake_username", + }, + ) + + assert reconfigure_result["type"] is FlowResultType.FORM + assert reconfigure_result["step_id"] == "reconfigure" + assert reconfigure_result["errors"] == {"base": error} From 3132cba51f7b270a590fe2badd74fcd41b933ecd Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 24 Mar 2025 18:10:08 +0100 Subject: [PATCH 2972/3148] Improve tests of backup exclusion (#141303) --- tests/components/backup/conftest.py | 37 +++++++++++++++++++++---- tests/components/backup/test_manager.py | 5 ++++ 2 files changed, 37 insertions(+), 5 deletions(-) diff --git a/tests/components/backup/conftest.py b/tests/components/backup/conftest.py index eb38399eb79..8c0e0ef63ac 100644 --- a/tests/components/backup/conftest.py +++ b/tests/components/backup/conftest.py @@ -61,24 +61,48 @@ def path_glob_fixture(hass: HomeAssistant) -> Generator[MagicMock]: CONFIG_DIR = { - "testing_config": [ + "tests/testing_config": [ Path("test.txt"), Path(".DS_Store"), Path(".storage"), + Path("another_subdir"), Path("backups"), Path("tmp_backups"), + Path("tts"), Path("home-assistant_v2.db"), ], - "backups": [ + "/backups": [ Path("backups/backup.tar"), Path("backups/not_backup"), ], - "tmp_backups": [ + "/another_subdir": [ + Path("another_subdir/backups"), + Path("another_subdir/tts"), + ], + "another_subdir/backups": [ + Path("another_subdir/backups/backup.tar"), + Path("another_subdir/backups/not_backup"), + ], + "another_subdir/tts": [ + Path("another_subdir/tts/voice.mp3"), + ], + "/tmp_backups": [ # noqa: S108 Path("tmp_backups/forgotten_backup.tar"), Path("tmp_backups/not_backup"), ], + "/tts": [ + Path("tts/voice.mp3"), + ], +} +CONFIG_DIR_DIRS = { + Path(".storage"), + Path("another_subdir"), + Path("another_subdir/backups"), + Path("another_subdir/tts"), + Path("backups"), + Path("tmp_backups"), + Path("tts"), } -CONFIG_DIR_DIRS = {Path(".storage"), Path("backups"), Path("tmp_backups")} @pytest.fixture(name="create_backup") @@ -105,7 +129,10 @@ def mock_backup_generation_fixture( """Mock backup generator.""" with ( - patch("pathlib.Path.iterdir", lambda x: CONFIG_DIR.get(x.name, [])), + patch( + "pathlib.Path.iterdir", + lambda x: CONFIG_DIR.get(f"{x.parent.name}/{x.name}", []), + ), patch("pathlib.Path.stat", return_value=MagicMock(st_size=123)), patch("pathlib.Path.is_file", lambda x: x not in CONFIG_DIR_DIRS), patch("pathlib.Path.is_dir", lambda x: x in CONFIG_DIR_DIRS), diff --git a/tests/components/backup/test_manager.py b/tests/components/backup/test_manager.py index fef4b84ac61..f518d7c59bc 100644 --- a/tests/components/backup/test_manager.py +++ b/tests/components/backup/test_manager.py @@ -68,10 +68,15 @@ from tests.typing import ClientSessionGenerator, WebSocketGenerator _EXPECTED_FILES = [ "test.txt", ".storage", + "another_subdir", + "another_subdir/backups", + "another_subdir/backups/not_backup", + "another_subdir/tts", "backups", "backups/not_backup", "tmp_backups", "tmp_backups/not_backup", + "tts", ] _EXPECTED_FILES_WITH_DATABASE = { True: [*_EXPECTED_FILES, "home-assistant_v2.db"], From c8f839068cb8114ca29c9cdd74b1a42de71cdcdd Mon Sep 17 00:00:00 2001 From: SLaks Date: Mon, 24 Mar 2025 13:52:16 -0400 Subject: [PATCH 2973/3148] Bump google-genai to 1.7.0 (#140770) Gemini: Upgrade google-genai to support generating images --- .../google_generative_ai_conversation/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../google_generative_ai_conversation/__init__.py | 6 +++--- .../snapshots/test_conversation.ambr | 4 ++-- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/google_generative_ai_conversation/manifest.json b/homeassistant/components/google_generative_ai_conversation/manifest.json index ed215970d7f..25e44964a6d 100644 --- a/homeassistant/components/google_generative_ai_conversation/manifest.json +++ b/homeassistant/components/google_generative_ai_conversation/manifest.json @@ -8,5 +8,5 @@ "documentation": "https://www.home-assistant.io/integrations/google_generative_ai_conversation", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["google-genai==1.1.0"] + "requirements": ["google-genai==1.7.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index b7974b4affd..fe90a81de49 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1038,7 +1038,7 @@ google-cloud-speech==2.27.0 google-cloud-texttospeech==2.17.2 # homeassistant.components.google_generative_ai_conversation -google-genai==1.1.0 +google-genai==1.7.0 # homeassistant.components.nest google-nest-sdm==7.1.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 00706fc3c57..b8848d573c4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -889,7 +889,7 @@ google-cloud-speech==2.27.0 google-cloud-texttospeech==2.17.2 # homeassistant.components.google_generative_ai_conversation -google-genai==1.1.0 +google-genai==1.7.0 # homeassistant.components.nest google-nest-sdm==7.1.4 diff --git a/tests/components/google_generative_ai_conversation/__init__.py b/tests/components/google_generative_ai_conversation/__init__.py index 6e2d37b035b..fbf9ee545db 100644 --- a/tests/components/google_generative_ai_conversation/__init__.py +++ b/tests/components/google_generative_ai_conversation/__init__.py @@ -3,12 +3,12 @@ from unittest.mock import Mock from google.genai.errors import ClientError -import requests +import httpx CLIENT_ERROR_500 = ClientError( 500, Mock( - __class__=requests.Response, + __class__=httpx.Response, json=Mock( return_value={ "message": "Internal Server Error", @@ -20,7 +20,7 @@ CLIENT_ERROR_500 = ClientError( CLIENT_ERROR_API_KEY_INVALID = ClientError( 400, Mock( - __class__=requests.Response, + __class__=httpx.Response, json=Mock( return_value={ "message": "'reason': API_KEY_INVALID", diff --git a/tests/components/google_generative_ai_conversation/snapshots/test_conversation.ambr b/tests/components/google_generative_ai_conversation/snapshots/test_conversation.ambr index 2a20ce37a57..bd4c406f071 100644 --- a/tests/components/google_generative_ai_conversation/snapshots/test_conversation.ambr +++ b/tests/components/google_generative_ai_conversation/snapshots/test_conversation.ambr @@ -6,7 +6,7 @@ tuple( ), dict({ - 'config': GenerateContentConfig(http_options=None, system_instruction="Current time is 05:00:00. Today's date is 2024-05-24.\nYou are a voice assistant for Home Assistant.\nAnswer questions about the world truthfully.\nAnswer in plain text. Keep it simple and to the point.\nOnly if the user wants to control a device, tell them to expose entities to their voice assistant in Home Assistant.", temperature=1.0, top_p=0.95, top_k=64.0, candidate_count=None, max_output_tokens=150, stop_sequences=None, response_logprobs=None, logprobs=None, presence_penalty=None, frequency_penalty=None, seed=None, response_mime_type=None, response_schema=None, routing_config=None, safety_settings=[SafetySetting(method=None, category=, threshold=), SafetySetting(method=None, category=, threshold=), SafetySetting(method=None, category=, threshold=), SafetySetting(method=None, category=, threshold=)], tools=[Tool(function_declarations=[FunctionDeclaration(response=None, description='Test function', name='test_tool', parameters=Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=None, max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=, description=None, enum=None, format=None, items=None, properties={'param1': Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=None, max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=, description='Test parameters', enum=None, format=None, items=Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=None, max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=, description=None, enum=None, format=None, items=None, properties=None, required=None), properties=None, required=None), 'param2': Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=None, max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=None, description=None, enum=None, format=None, items=None, properties=None, required=None), 'param3': Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=None, max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=, description=None, enum=None, format=None, items=None, properties={'json': Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=None, max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=, description=None, enum=None, format=None, items=None, properties=None, required=None)}, required=[])}, required=[]))], retrieval=None, google_search=None, google_search_retrieval=None, code_execution=None)], tool_config=None, labels=None, cached_content=None, response_modalities=None, media_resolution=None, speech_config=None, audio_timestamp=None, automatic_function_calling=AutomaticFunctionCallingConfig(disable=True, maximum_remote_calls=None, ignore_call_history=None), thinking_config=None), + 'config': GenerateContentConfig(http_options=None, system_instruction="Current time is 05:00:00. Today's date is 2024-05-24.\nYou are a voice assistant for Home Assistant.\nAnswer questions about the world truthfully.\nAnswer in plain text. Keep it simple and to the point.\nOnly if the user wants to control a device, tell them to expose entities to their voice assistant in Home Assistant.", temperature=1.0, top_p=0.95, top_k=64.0, candidate_count=None, max_output_tokens=150, stop_sequences=None, response_logprobs=None, logprobs=None, presence_penalty=None, frequency_penalty=None, seed=None, response_mime_type=None, response_schema=None, routing_config=None, safety_settings=[SafetySetting(method=None, category=, threshold=), SafetySetting(method=None, category=, threshold=), SafetySetting(method=None, category=, threshold=), SafetySetting(method=None, category=, threshold=)], tools=[Tool(function_declarations=[FunctionDeclaration(response=None, description='Test function', name='test_tool', parameters=Schema(example=None, pattern=None, default=None, max_length=None, title=None, min_length=None, min_properties=None, max_properties=None, any_of=None, description=None, enum=None, format=None, items=None, max_items=None, maximum=None, min_items=None, minimum=None, nullable=None, properties={'param1': Schema(example=None, pattern=None, default=None, max_length=None, title=None, min_length=None, min_properties=None, max_properties=None, any_of=None, description='Test parameters', enum=None, format=None, items=Schema(example=None, pattern=None, default=None, max_length=None, title=None, min_length=None, min_properties=None, max_properties=None, any_of=None, description=None, enum=None, format=None, items=None, max_items=None, maximum=None, min_items=None, minimum=None, nullable=None, properties=None, property_ordering=None, required=None, type=), max_items=None, maximum=None, min_items=None, minimum=None, nullable=None, properties=None, property_ordering=None, required=None, type=), 'param2': Schema(example=None, pattern=None, default=None, max_length=None, title=None, min_length=None, min_properties=None, max_properties=None, any_of=None, description=None, enum=None, format=None, items=None, max_items=None, maximum=None, min_items=None, minimum=None, nullable=None, properties=None, property_ordering=None, required=None, type=None), 'param3': Schema(example=None, pattern=None, default=None, max_length=None, title=None, min_length=None, min_properties=None, max_properties=None, any_of=None, description=None, enum=None, format=None, items=None, max_items=None, maximum=None, min_items=None, minimum=None, nullable=None, properties={'json': Schema(example=None, pattern=None, default=None, max_length=None, title=None, min_length=None, min_properties=None, max_properties=None, any_of=None, description=None, enum=None, format=None, items=None, max_items=None, maximum=None, min_items=None, minimum=None, nullable=None, properties=None, property_ordering=None, required=None, type=)}, property_ordering=None, required=[], type=)}, property_ordering=None, required=[], type=))], retrieval=None, google_search=None, google_search_retrieval=None, code_execution=None)], tool_config=None, labels=None, cached_content=None, response_modalities=None, media_resolution=None, speech_config=None, audio_timestamp=None, automatic_function_calling=AutomaticFunctionCallingConfig(disable=True, maximum_remote_calls=None, ignore_call_history=None), thinking_config=None), 'history': list([ ]), 'model': 'models/gemini-2.0-flash', @@ -68,7 +68,7 @@ tuple( ), dict({ - 'config': GenerateContentConfig(http_options=None, system_instruction="Current time is 05:00:00. Today's date is 2024-05-24.\nYou are a voice assistant for Home Assistant.\nAnswer questions about the world truthfully.\nAnswer in plain text. Keep it simple and to the point.\nOnly if the user wants to control a device, tell them to expose entities to their voice assistant in Home Assistant.", temperature=1.0, top_p=0.95, top_k=64.0, candidate_count=None, max_output_tokens=150, stop_sequences=None, response_logprobs=None, logprobs=None, presence_penalty=None, frequency_penalty=None, seed=None, response_mime_type=None, response_schema=None, routing_config=None, safety_settings=[SafetySetting(method=None, category=, threshold=), SafetySetting(method=None, category=, threshold=), SafetySetting(method=None, category=, threshold=), SafetySetting(method=None, category=, threshold=)], tools=[Tool(function_declarations=[FunctionDeclaration(response=None, description='Test function', name='test_tool', parameters=Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=None, max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=, description=None, enum=None, format=None, items=None, properties={'param1': Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=None, max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=, description='Test parameters', enum=None, format=None, items=Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=None, max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=, description=None, enum=None, format=None, items=None, properties=None, required=None), properties=None, required=None), 'param2': Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=None, max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=None, description=None, enum=None, format=None, items=None, properties=None, required=None), 'param3': Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=None, max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=, description=None, enum=None, format=None, items=None, properties={'json': Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=None, max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=, description=None, enum=None, format=None, items=None, properties=None, required=None)}, required=[])}, required=[]))], retrieval=None, google_search=None, google_search_retrieval=None, code_execution=None), Tool(function_declarations=None, retrieval=None, google_search=GoogleSearch(), google_search_retrieval=None, code_execution=None)], tool_config=None, labels=None, cached_content=None, response_modalities=None, media_resolution=None, speech_config=None, audio_timestamp=None, automatic_function_calling=AutomaticFunctionCallingConfig(disable=True, maximum_remote_calls=None, ignore_call_history=None), thinking_config=None), + 'config': GenerateContentConfig(http_options=None, system_instruction="Current time is 05:00:00. Today's date is 2024-05-24.\nYou are a voice assistant for Home Assistant.\nAnswer questions about the world truthfully.\nAnswer in plain text. Keep it simple and to the point.\nOnly if the user wants to control a device, tell them to expose entities to their voice assistant in Home Assistant.", temperature=1.0, top_p=0.95, top_k=64.0, candidate_count=None, max_output_tokens=150, stop_sequences=None, response_logprobs=None, logprobs=None, presence_penalty=None, frequency_penalty=None, seed=None, response_mime_type=None, response_schema=None, routing_config=None, safety_settings=[SafetySetting(method=None, category=, threshold=), SafetySetting(method=None, category=, threshold=), SafetySetting(method=None, category=, threshold=), SafetySetting(method=None, category=, threshold=)], tools=[Tool(function_declarations=[FunctionDeclaration(response=None, description='Test function', name='test_tool', parameters=Schema(example=None, pattern=None, default=None, max_length=None, title=None, min_length=None, min_properties=None, max_properties=None, any_of=None, description=None, enum=None, format=None, items=None, max_items=None, maximum=None, min_items=None, minimum=None, nullable=None, properties={'param1': Schema(example=None, pattern=None, default=None, max_length=None, title=None, min_length=None, min_properties=None, max_properties=None, any_of=None, description='Test parameters', enum=None, format=None, items=Schema(example=None, pattern=None, default=None, max_length=None, title=None, min_length=None, min_properties=None, max_properties=None, any_of=None, description=None, enum=None, format=None, items=None, max_items=None, maximum=None, min_items=None, minimum=None, nullable=None, properties=None, property_ordering=None, required=None, type=), max_items=None, maximum=None, min_items=None, minimum=None, nullable=None, properties=None, property_ordering=None, required=None, type=), 'param2': Schema(example=None, pattern=None, default=None, max_length=None, title=None, min_length=None, min_properties=None, max_properties=None, any_of=None, description=None, enum=None, format=None, items=None, max_items=None, maximum=None, min_items=None, minimum=None, nullable=None, properties=None, property_ordering=None, required=None, type=None), 'param3': Schema(example=None, pattern=None, default=None, max_length=None, title=None, min_length=None, min_properties=None, max_properties=None, any_of=None, description=None, enum=None, format=None, items=None, max_items=None, maximum=None, min_items=None, minimum=None, nullable=None, properties={'json': Schema(example=None, pattern=None, default=None, max_length=None, title=None, min_length=None, min_properties=None, max_properties=None, any_of=None, description=None, enum=None, format=None, items=None, max_items=None, maximum=None, min_items=None, minimum=None, nullable=None, properties=None, property_ordering=None, required=None, type=)}, property_ordering=None, required=[], type=)}, property_ordering=None, required=[], type=))], retrieval=None, google_search=None, google_search_retrieval=None, code_execution=None), Tool(function_declarations=None, retrieval=None, google_search=GoogleSearch(), google_search_retrieval=None, code_execution=None)], tool_config=None, labels=None, cached_content=None, response_modalities=None, media_resolution=None, speech_config=None, audio_timestamp=None, automatic_function_calling=AutomaticFunctionCallingConfig(disable=True, maximum_remote_calls=None, ignore_call_history=None), thinking_config=None), 'history': list([ ]), 'model': 'models/gemini-2.0-flash', From 0e6d72dcc8262f5b80eb35d47e8bfbf70f986f03 Mon Sep 17 00:00:00 2001 From: Thomas D <11554546+thomasddn@users.noreply.github.com> Date: Mon, 24 Mar 2025 20:26:02 +0100 Subject: [PATCH 2974/3148] Let device response determine state in Qbus (#141302) Let device response determine state --- homeassistant/components/qbus/light.py | 23 +++++------------------ homeassistant/components/qbus/switch.py | 2 -- 2 files changed, 5 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/qbus/light.py b/homeassistant/components/qbus/light.py index 5ec76f5e807..3d2c763b8e3 100644 --- a/homeassistant/components/qbus/light.py +++ b/homeassistant/components/qbus/light.py @@ -51,7 +51,7 @@ class QbusLight(QbusEntity, LightEntity): super().__init__(mqtt_output) - self._set_state() + self._set_state(0) async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" @@ -74,7 +74,6 @@ class QbusLight(QbusEntity, LightEntity): state.write_percentage(percentage) await self._async_publish_output_state(state) - self._set_state(percentage=percentage, on=on) async def async_turn_off(self, **kwargs: Any) -> None: """Turn the entity off.""" @@ -82,7 +81,6 @@ class QbusLight(QbusEntity, LightEntity): state.write_on_off(on=False) await self._async_publish_output_state(state) - self._set_state(on=False) async def _state_received(self, msg: ReceiveMessage) -> None: output = self._message_factory.parse_output_state( @@ -91,20 +89,9 @@ class QbusLight(QbusEntity, LightEntity): if output is not None: percentage = round(output.read_percentage()) - self._set_state(percentage=percentage) + self._set_state(percentage) self.async_schedule_update_ha_state() - def _set_state( - self, *, percentage: int | None = None, on: bool | None = None - ) -> None: - if percentage is None: - # When turning on without brightness, we don't know the desired - # brightness. It will be set during _state_received(). - if on is True: - self._attr_is_on = True - else: - self._attr_is_on = False - self._attr_brightness = 0 - else: - self._attr_is_on = percentage > 0 - self._attr_brightness = value_to_brightness((1, 100), percentage) + def _set_state(self, percentage: int = 0) -> None: + self._attr_is_on = percentage > 0 + self._attr_brightness = value_to_brightness((1, 100), percentage) diff --git a/homeassistant/components/qbus/switch.py b/homeassistant/components/qbus/switch.py index 002ad43e904..e1feccf4450 100644 --- a/homeassistant/components/qbus/switch.py +++ b/homeassistant/components/qbus/switch.py @@ -57,7 +57,6 @@ class QbusSwitch(QbusEntity, SwitchEntity): state.write_value(True) await self._async_publish_output_state(state) - self._attr_is_on = True async def async_turn_off(self, **kwargs: Any) -> None: """Turn the entity off.""" @@ -65,7 +64,6 @@ class QbusSwitch(QbusEntity, SwitchEntity): state.write_value(False) await self._async_publish_output_state(state) - self._attr_is_on = False async def _state_received(self, msg: ReceiveMessage) -> None: output = self._message_factory.parse_output_state( From 61a76b406489022d762be2156f7a71a5db122309 Mon Sep 17 00:00:00 2001 From: Andrew Scott Date: Mon, 24 Mar 2025 13:33:34 -0700 Subject: [PATCH 2975/3148] Jellyfin: display album primary art instead of artist backdrop (#141246) * Jellyfin: Properly display album primary art instead of artist backdrop when playing music * add test for album art urls, fix existing tests that broke because they have extraneous "album*" fields for non-album items. * fix snapshot test --- .../components/jellyfin/client_wrapper.py | 19 +++++++++++++++---- .../jellyfin/fixtures/get-media-folders.json | 2 -- .../jellyfin/fixtures/sessions.json | 1 + .../fixtures/user-items-parent-id.json | 2 -- .../jellyfin/snapshots/test_diagnostics.ambr | 1 + .../components/jellyfin/test_media_player.py | 5 +++++ 6 files changed, 22 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/jellyfin/client_wrapper.py b/homeassistant/components/jellyfin/client_wrapper.py index ab5d5e7d7f8..91fe0885e4c 100644 --- a/homeassistant/components/jellyfin/client_wrapper.py +++ b/homeassistant/components/jellyfin/client_wrapper.py @@ -97,16 +97,27 @@ def get_artwork_url( client: JellyfinClient, item: dict[str, Any], max_width: int = 600 ) -> str | None: """Find a suitable thumbnail for an item.""" - artwork_id: str = item["Id"] - artwork_type = "Primary" + artwork_id: str | None = None + artwork_type: str | None = None parent_backdrop_id: str | None = item.get("ParentBackdropItemId") - if "Backdrop" in item[ITEM_KEY_IMAGE_TAGS]: + if "AlbumPrimaryImageTag" in item: + # jellyfin_apiclient_python doesn't support passing a specific tag to `.artwork`, + # so we don't use the actual value of AlbumPrimaryImageTag. + # However, its mere presence tells us that the album does have primary artwork, + # and the resulting URL will pull the primary album art even if the tag is not specified. + artwork_type = "Primary" + artwork_id = item["AlbumId"] + elif "Backdrop" in item[ITEM_KEY_IMAGE_TAGS]: artwork_type = "Backdrop" + artwork_id = item["Id"] elif parent_backdrop_id: artwork_type = "Backdrop" artwork_id = parent_backdrop_id - elif "Primary" not in item[ITEM_KEY_IMAGE_TAGS]: + elif "Primary" in item[ITEM_KEY_IMAGE_TAGS]: + artwork_type = "Primary" + artwork_id = item["Id"] + else: return None return str(client.jellyfin.artwork(artwork_id, artwork_type, max_width)) diff --git a/tests/components/jellyfin/fixtures/get-media-folders.json b/tests/components/jellyfin/fixtures/get-media-folders.json index ff87751a9da..f6b5c1e8d78 100644 --- a/tests/components/jellyfin/fixtures/get-media-folders.json +++ b/tests/components/jellyfin/fixtures/get-media-folders.json @@ -302,8 +302,6 @@ "Album": "string", "CollectionType": "tvshows", "DisplayOrder": "string", - "AlbumId": "21af9851-8e39-43a9-9c47-513d3b9e99fc", - "AlbumPrimaryImageTag": "string", "SeriesPrimaryImageTag": "string", "AlbumArtist": "string", "AlbumArtists": [ diff --git a/tests/components/jellyfin/fixtures/sessions.json b/tests/components/jellyfin/fixtures/sessions.json index 00a1f5265db..db2b691dff0 100644 --- a/tests/components/jellyfin/fixtures/sessions.json +++ b/tests/components/jellyfin/fixtures/sessions.json @@ -4346,6 +4346,7 @@ ], "Album": "ALBUM", "AlbumId": "ALBUM-UUID", + "AlbumPrimaryImageTag": "ALBUM-PRIMARY-IMAGE-TAG", "AlbumArtist": "Album Artist", "AlbumArtists": [ { "Name": "Album Artist", "Id": "9a65b2c222ddb34e51f5cae360fad3a1" } diff --git a/tests/components/jellyfin/fixtures/user-items-parent-id.json b/tests/components/jellyfin/fixtures/user-items-parent-id.json index 2e06c30894c..cd0232894bc 100644 --- a/tests/components/jellyfin/fixtures/user-items-parent-id.json +++ b/tests/components/jellyfin/fixtures/user-items-parent-id.json @@ -302,8 +302,6 @@ "Album": "string", "CollectionType": "string", "DisplayOrder": "string", - "AlbumId": "21af9851-8e39-43a9-9c47-513d3b9e99fc", - "AlbumPrimaryImageTag": "string", "SeriesPrimaryImageTag": "string", "AlbumArtist": "string", "AlbumArtists": [ diff --git a/tests/components/jellyfin/snapshots/test_diagnostics.ambr b/tests/components/jellyfin/snapshots/test_diagnostics.ambr index c992628f034..9d73ee6397c 100644 --- a/tests/components/jellyfin/snapshots/test_diagnostics.ambr +++ b/tests/components/jellyfin/snapshots/test_diagnostics.ambr @@ -1707,6 +1707,7 @@ }), ]), 'AlbumId': 'ALBUM-UUID', + 'AlbumPrimaryImageTag': 'ALBUM-PRIMARY-IMAGE-TAG', 'ArtistItems': list([ dict({ 'Id': '1d864900526d9a9513b489f1cc28f8ca', diff --git a/tests/components/jellyfin/test_media_player.py b/tests/components/jellyfin/test_media_player.py index 3263639a32f..c6f015e9bb4 100644 --- a/tests/components/jellyfin/test_media_player.py +++ b/tests/components/jellyfin/test_media_player.py @@ -27,6 +27,7 @@ from homeassistant.components.media_player import ( from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_ENTITY_ID, + ATTR_ENTITY_PICTURE, ATTR_FRIENDLY_NAME, ATTR_ICON, ) @@ -124,6 +125,10 @@ async def test_media_player_music( assert state.attributes.get(ATTR_MEDIA_SERIES_TITLE) is None assert state.attributes.get(ATTR_MEDIA_SEASON) is None assert state.attributes.get(ATTR_MEDIA_EPISODE) is None + assert ( + state.attributes.get(ATTR_ENTITY_PICTURE) + == "http://localhost/Items/ALBUM-UUID/Images/Primary.jpg" + ) entry = entity_registry.async_get(state.entity_id) assert entry From 33198cd70491478932ee6eb91181a2733273f909 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Mon, 24 Mar 2025 16:04:13 -0500 Subject: [PATCH 2976/3148] Add preannounce_media_id to Assist Satellite (#141317) Add preannounce_media_id --- .../components/assist_satellite/__init__.py | 2 + .../components/assist_satellite/entity.py | 37 +++++++++++++++++-- .../components/assist_satellite/services.yaml | 8 ++++ .../components/assist_satellite/strings.json | 8 ++++ .../assist_satellite/test_entity.py | 32 ++++++++++++++++ 5 files changed, 83 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/assist_satellite/__init__.py b/homeassistant/components/assist_satellite/__init__.py index 038ff517264..31afbda1d11 100644 --- a/homeassistant/components/assist_satellite/__init__.py +++ b/homeassistant/components/assist_satellite/__init__.py @@ -56,6 +56,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: { vol.Optional("message"): str, vol.Optional("media_id"): str, + vol.Optional("preannounce_media_id"): str, } ), cv.has_at_least_one_key("message", "media_id"), @@ -70,6 +71,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: { vol.Optional("start_message"): str, vol.Optional("start_media_id"): str, + vol.Optional("preannounce_media_id"): str, vol.Optional("extra_system_prompt"): str, } ), diff --git a/homeassistant/components/assist_satellite/entity.py b/homeassistant/components/assist_satellite/entity.py index 3db38a23889..33b9e904246 100644 --- a/homeassistant/components/assist_satellite/entity.py +++ b/homeassistant/components/assist_satellite/entity.py @@ -101,6 +101,9 @@ class AssistSatelliteAnnouncement: media_id_source: Literal["url", "media_id", "tts"] """Source of the media ID.""" + preannounce_media_id: str | None = None + """Media ID to be played before announcement.""" + class AssistSatelliteEntity(entity.Entity): """Entity encapsulating the state and functionality of an Assist satellite.""" @@ -177,6 +180,7 @@ class AssistSatelliteEntity(entity.Entity): self, message: str | None = None, media_id: str | None = None, + preannounce_media_id: str | None = None, ) -> None: """Play and show an announcement on the satellite. @@ -186,6 +190,8 @@ class AssistSatelliteEntity(entity.Entity): If media_id is provided, it is played directly. It is possible to omit the message and the satellite will not show any text. + If preannounce_media_id is provided, it is played before the announcement. + Calls async_announce with message and media id. """ await self._cancel_running_pipeline() @@ -193,7 +199,9 @@ class AssistSatelliteEntity(entity.Entity): if message is None: message = "" - announcement = await self._resolve_announcement_media_id(message, media_id) + announcement = await self._resolve_announcement_media_id( + message, media_id, preannounce_media_id + ) if self._is_announcing: raise SatelliteBusyError @@ -220,6 +228,7 @@ class AssistSatelliteEntity(entity.Entity): start_message: str | None = None, start_media_id: str | None = None, extra_system_prompt: str | None = None, + preannounce_media_id: str | None = None, ) -> None: """Start a conversation from the satellite. @@ -229,6 +238,8 @@ class AssistSatelliteEntity(entity.Entity): If start_media_id is provided, it is played directly. It is possible to omit the message and the satellite will not show any text. + If preannounce_media_id is provided, it is played before the announcement. + Calls async_start_conversation. """ await self._cancel_running_pipeline() @@ -244,7 +255,7 @@ class AssistSatelliteEntity(entity.Entity): start_message = "" announcement = await self._resolve_announcement_media_id( - start_message, start_media_id + start_message, start_media_id, preannounce_media_id ) if self._is_announcing: @@ -470,7 +481,10 @@ class AssistSatelliteEntity(entity.Entity): return vad.VadSensitivity.to_seconds(vad_sensitivity) async def _resolve_announcement_media_id( - self, message: str, media_id: str | None + self, + message: str, + media_id: str | None, + preannounce_media_id: str | None = None, ) -> AssistSatelliteAnnouncement: """Resolve the media ID.""" media_id_source: Literal["url", "media_id", "tts"] | None = None @@ -478,7 +492,6 @@ class AssistSatelliteEntity(entity.Entity): if media_id: original_media_id = media_id - else: media_id_source = "tts" # Synthesize audio and get URL @@ -530,10 +543,26 @@ class AssistSatelliteEntity(entity.Entity): # Resolve to full URL media_id = async_process_play_media_url(self.hass, media_id) + # Resolve preannounce media id + if preannounce_media_id: + if media_source.is_media_source_id(preannounce_media_id): + preannounce_media = await media_source.async_resolve_media( + self.hass, + preannounce_media_id, + None, + ) + preannounce_media_id = preannounce_media.url + + # Resolve to full URL + preannounce_media_id = async_process_play_media_url( + self.hass, preannounce_media_id + ) + return AssistSatelliteAnnouncement( message=message, media_id=media_id, original_media_id=original_media_id, tts_token=tts_token, media_id_source=media_id_source, + preannounce_media_id=preannounce_media_id, ) diff --git a/homeassistant/components/assist_satellite/services.yaml b/homeassistant/components/assist_satellite/services.yaml index 89a20ada6f3..fd6a4f23ccc 100644 --- a/homeassistant/components/assist_satellite/services.yaml +++ b/homeassistant/components/assist_satellite/services.yaml @@ -14,6 +14,10 @@ announce: required: false selector: text: + preannounce_media_id: + required: false + selector: + text: start_conversation: target: entity: @@ -34,3 +38,7 @@ start_conversation: required: false selector: text: + preannounce_media_id: + required: false + selector: + text: diff --git a/homeassistant/components/assist_satellite/strings.json b/homeassistant/components/assist_satellite/strings.json index fa2dc984ab7..2bb61516bca 100644 --- a/homeassistant/components/assist_satellite/strings.json +++ b/homeassistant/components/assist_satellite/strings.json @@ -23,6 +23,10 @@ "media_id": { "name": "Media ID", "description": "The media ID to announce instead of using text-to-speech." + }, + "preannounce_media_id": { + "name": "Preannounce Media ID", + "description": "The media ID to play before the announcement." } } }, @@ -41,6 +45,10 @@ "extra_system_prompt": { "name": "Extra system prompt", "description": "Provide background information to the AI about the request." + }, + "preannounce_media_id": { + "name": "Preannounce Media ID", + "description": "The media ID to play before the start message or media." } } } diff --git a/tests/components/assist_satellite/test_entity.py b/tests/components/assist_satellite/test_entity.py index 6604fdc3f25..fcc3c5b98b5 100644 --- a/tests/components/assist_satellite/test_entity.py +++ b/tests/components/assist_satellite/test_entity.py @@ -217,6 +217,20 @@ async def test_new_pipeline_cancels_pipeline( media_id_source="url", ), ), + ( + { + "media_id": "http://example.com/bla.mp3", + "preannounce_media_id": "http://example.com/preannounce.mp3", + }, + AssistSatelliteAnnouncement( + message="", + media_id="http://example.com/bla.mp3", + original_media_id="http://example.com/bla.mp3", + tts_token=None, + media_id_source="url", + preannounce_media_id="http://example.com/preannounce.mp3", + ), + ), ], ) async def test_announce( @@ -551,6 +565,24 @@ async def test_vad_sensitivity_entity_not_found( ), ), ), + ( + { + "start_media_id": "http://example.com/given.mp3", + "preannounce_media_id": "http://example.com/preannounce.mp3", + }, + ( + "mock-conversation-id", + None, + AssistSatelliteAnnouncement( + message="", + media_id="http://example.com/given.mp3", + tts_token=None, + original_media_id="http://example.com/given.mp3", + media_id_source="url", + preannounce_media_id="http://example.com/preannounce.mp3", + ), + ), + ), ], ) @pytest.mark.usefixtures("mock_chat_session_conversation_id") From d657809ffedd61d02b8c17a480daca9894ab4ce3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 24 Mar 2025 11:04:54 -1000 Subject: [PATCH 2977/3148] Bump annotatedyaml to 0.4.5 (#141316) --- 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 d85bf08338b..b39edaf64b1 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -10,7 +10,7 @@ aiohttp==3.11.14 aiohttp_cors==0.7.0 aiousbwatcher==1.1.1 aiozoneinfo==0.2.3 -annotatedyaml==0.4.4 +annotatedyaml==0.4.5 astral==2.2 async-interrupt==1.2.2 async-upnp-client==0.43.0 diff --git a/pyproject.toml b/pyproject.toml index 1bd74791a18..0144a3c8ffd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,7 +33,7 @@ dependencies = [ "aiohttp-fast-zlib==0.2.3", "aiohttp-asyncmdnsresolver==0.1.1", "aiozoneinfo==0.2.3", - "annotatedyaml==0.4.4", + "annotatedyaml==0.4.5", "astral==2.2", "async-interrupt==1.2.2", "attrs==25.1.0", diff --git a/requirements.txt b/requirements.txt index 0735e38c89c..e530ea5de08 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,7 +10,7 @@ aiohttp_cors==0.7.0 aiohttp-fast-zlib==0.2.3 aiohttp-asyncmdnsresolver==0.1.1 aiozoneinfo==0.2.3 -annotatedyaml==0.4.4 +annotatedyaml==0.4.5 astral==2.2 async-interrupt==1.2.2 attrs==25.1.0 From b2377d6da35945f34e0ad33fc3481479c3c35b51 Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Tue, 25 Mar 2025 00:28:37 +0100 Subject: [PATCH 2978/3148] Bump pyOverkiz to version 1.16.5 (#141326) Bump pyoverkiz to version 1.16.5 --- homeassistant/components/overkiz/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/overkiz/manifest.json b/homeassistant/components/overkiz/manifest.json index cfaed4ceb8b..937b4ccb937 100644 --- a/homeassistant/components/overkiz/manifest.json +++ b/homeassistant/components/overkiz/manifest.json @@ -13,7 +13,7 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["boto3", "botocore", "pyhumps", "pyoverkiz", "s3transfer"], - "requirements": ["pyoverkiz==1.16.4"], + "requirements": ["pyoverkiz==1.16.5"], "zeroconf": [ { "type": "_kizbox._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index fe90a81de49..9b856492754 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2202,7 +2202,7 @@ pyotgw==2.2.2 pyotp==2.8.0 # homeassistant.components.overkiz -pyoverkiz==1.16.4 +pyoverkiz==1.16.5 # homeassistant.components.onewire pyownet==0.10.0.post1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b8848d573c4..94d60c7b1a9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1796,7 +1796,7 @@ pyotgw==2.2.2 pyotp==2.8.0 # homeassistant.components.overkiz -pyoverkiz==1.16.4 +pyoverkiz==1.16.5 # homeassistant.components.onewire pyownet==0.10.0.post1 From 204b1e1f243dbd3b955a68f522d14fd6e949201f Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Mon, 24 Mar 2025 18:06:45 -0700 Subject: [PATCH 2979/3148] Add a Google Calendar birthdays calendar (#141300) --- homeassistant/components/google/calendar.py | 30 ++++++++++--- homeassistant/components/google/strings.json | 3 ++ tests/components/google/test_calendar.py | 47 ++++++++++++++++++++ 3 files changed, 75 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/google/calendar.py b/homeassistant/components/google/calendar.py index 4ae8c8cce03..a62d2bf1d6b 100644 --- a/homeassistant/components/google/calendar.py +++ b/homeassistant/components/google/calendar.py @@ -89,6 +89,7 @@ OPAQUE = "opaque" RRULE_PREFIX = "RRULE:" SERVICE_CREATE_EVENT = "create_event" +FILTERED_EVENT_TYPES = [EventTypeEnum.BIRTHDAY, EventTypeEnum.WORKING_LOCATION] @dataclasses.dataclass(frozen=True, kw_only=True) @@ -103,7 +104,7 @@ class GoogleCalendarEntityDescription(CalendarEntityDescription): search: str | None local_sync: bool device_id: str - working_location: bool = False + event_type: EventTypeEnum | None = None def _get_entity_descriptions( @@ -173,14 +174,24 @@ def _get_entity_descriptions( local_sync, ) if calendar_item.primary and local_sync: - _LOGGER.debug("work location entity") + # Create a separate calendar for birthdays + entity_descriptions.append( + dataclasses.replace( + entity_description, + key=f"{key}-birthdays", + translation_key="birthdays", + event_type=EventTypeEnum.BIRTHDAY, + name=None, + entity_id=None, + ) + ) # Create an optional disabled by default entity for Work Location entity_descriptions.append( dataclasses.replace( entity_description, key=f"{key}-work-location", translation_key="working_location", - working_location=True, + event_type=EventTypeEnum.WORKING_LOCATION, name=None, entity_id=None, entity_registry_enabled_default=False, @@ -383,8 +394,17 @@ class GoogleCalendarEntity( for attendee in event.attendees ): return False - is_working_location_event = event.event_type == EventTypeEnum.WORKING_LOCATION - if self.entity_description.working_location != is_working_location_event: + # Calendar enttiy may be limited to a specific event type + if ( + self.entity_description.event_type is not None + and self.entity_description.event_type != event.event_type + ): + return False + # Default calendar entity omits the special types but includes all the others + if ( + self.entity_description.event_type is None + and event.event_type in FILTERED_EVENT_TYPES + ): return False if self._ignore_availability: return True diff --git a/homeassistant/components/google/strings.json b/homeassistant/components/google/strings.json index 5ee0cdd9c14..5776fd0480b 100644 --- a/homeassistant/components/google/strings.json +++ b/homeassistant/components/google/strings.json @@ -131,6 +131,9 @@ "calendar": { "working_location": { "name": "Working location" + }, + "birthdays": { + "name": "Birthdays" } } } diff --git a/tests/components/google/test_calendar.py b/tests/components/google/test_calendar.py index 274e310fbce..720c0176850 100644 --- a/tests/components/google/test_calendar.py +++ b/tests/components/google/test_calendar.py @@ -1455,6 +1455,7 @@ async def test_working_location_ignored( ("event_type", "expected_event_message"), [ ("workingLocation", "Test All Day Event"), + ("birthday", None), ("default", None), ], ) @@ -1515,3 +1516,49 @@ async def test_no_working_location_entity( entity_entry = entity_registry.async_get("calendar.working_location") assert not entity_entry + + +@pytest.mark.parametrize( + ("event_type", "expected_event_message"), + [ + ("workingLocation", None), + ("birthday", "Test All Day Event"), + ("default", None), + ], +) +@pytest.mark.parametrize("calendar_is_primary", [True]) +async def test_birthday_entity( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + entity_registry: er.EntityRegistry, + mock_events_list_items: Callable[[list[dict[str, Any]]], None], + component_setup: ComponentSetup, + event_type: str, + expected_event_message: str | None, +) -> None: + """Test that birthday events appear only on the birthdays calendar.""" + event = { + **TEST_EVENT, + **upcoming(), + "eventType": event_type, + } + mock_events_list_items([event]) + assert await component_setup() + + entity_entry = entity_registry.async_get("calendar.birthdays") + assert entity_entry + assert entity_entry.disabled_by is None # Enabled by default + + entity_registry.async_update_entity( + entity_id="calendar.birthdays", disabled_by=None + ) + async_fire_time_changed( + hass, + dt_util.utcnow() + datetime.timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), + ) + await hass.async_block_till_done() + + state = hass.states.get("calendar.birthdays") + assert state + assert state.name == "Birthdays" + assert state.attributes.get("message") == expected_event_message From f864f71028e71f9801899f38a533e2397c283853 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Mon, 24 Mar 2025 18:08:54 -0700 Subject: [PATCH 2980/3148] Remove nest event media files that are no longer referenced (#141295) * Remove nest event media files that are no longer referenced * Fix double glob --- homeassistant/components/nest/media_source.py | 74 +++++++++++++++++ tests/components/nest/conftest.py | 9 ++- tests/components/nest/test_media_source.py | 81 ++++++++++++++++++- 3 files changed, 159 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/nest/media_source.py b/homeassistant/components/nest/media_source.py index 146b6f2479e..a3d2901e911 100644 --- a/homeassistant/components/nest/media_source.py +++ b/homeassistant/components/nest/media_source.py @@ -20,8 +20,10 @@ from __future__ import annotations from collections.abc import Mapping from dataclasses import dataclass +import datetime import logging import os +import pathlib from typing import Any from google_nest_sdm.camera_traits import CameraClipPreviewTrait, CameraEventImageTrait @@ -46,6 +48,7 @@ from homeassistant.components.media_source import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.storage import Store from homeassistant.helpers.template import DATE_STR_FORMAT from homeassistant.util import dt as dt_util @@ -72,6 +75,9 @@ MEDIA_PATH = f"{DOMAIN}/event_media" # Size of small in-memory disk cache to avoid excessive disk reads DISK_READ_LRU_MAX_SIZE = 32 +# Remove orphaned media files that are older than this age +ORPHANED_MEDIA_AGE_CUTOFF = datetime.timedelta(days=7) + async def async_get_media_event_store( hass: HomeAssistant, subscriber: GoogleNestSubscriber @@ -123,6 +129,12 @@ class NestEventMediaStore(EventMediaStore): self._media_path = media_path self._data: dict[str, Any] | None = None self._devices: Mapping[str, str] | None = {} + # Invoke garbage collection for orphaned files one per + async_track_time_interval( + hass, + self.async_remove_orphaned_media, + datetime.timedelta(days=1), + ) async def async_load(self) -> dict | None: """Load data.""" @@ -249,6 +261,68 @@ class NestEventMediaStore(EventMediaStore): devices[device.name] = device_entry.id return devices + async def async_remove_orphaned_media(self, now: datetime.datetime) -> None: + """Remove any media files that are orphaned and not referenced by the active event data. + + The event media store handles garbage collection, but there may be cases where files are + left around or unable to be removed. This is a scheduled event that will also check for + old orphaned files and remove them when the events are not referenced in the active list + of event data. + + Event media files are stored with the format -.suffix. We extract + the list of valid timestamps from the event data and remove any files that are not in that list + or are older than the cutoff time. + """ + _LOGGER.debug("Checking for orphaned media at %s", now) + + def _cleanup(event_timestamps: dict[str, set[int]]) -> None: + time_cutoff = (now - ORPHANED_MEDIA_AGE_CUTOFF).timestamp() + media_path = pathlib.Path(self._media_path) + for device_id, valid_timestamps in event_timestamps.items(): + media_files = list(media_path.glob(f"{device_id}/*")) + _LOGGER.debug("Found %d files (device=%s)", len(media_files), device_id) + for media_file in media_files: + if "-" not in media_file.name: + continue + try: + timestamp = int(media_file.name.split("-")[0]) + except ValueError: + continue + if timestamp in valid_timestamps or timestamp > time_cutoff: + continue + _LOGGER.debug("Removing orphaned media file: %s", media_file) + try: + os.remove(media_file) + except OSError as err: + _LOGGER.error( + "Unable to remove orphaned media file: %s %s", + media_file, + err, + ) + + # Nest device id mapped to home assistant device id + event_timestamps = await self._get_valid_event_timestamps() + await self._hass.async_add_executor_job(_cleanup, event_timestamps) + + async def _get_valid_event_timestamps(self) -> dict[str, set[int]]: + """Return a mapping of home assistant device id to valid timestamps.""" + device_map = await self._get_devices() + event_data = await self.async_load() or {} + valid_device_timestamps = {} + for nest_device_id, device_id in device_map.items(): + if (device_events := event_data.get(nest_device_id, {})) is None: + continue + valid_device_timestamps[device_id] = { + int( + datetime.datetime.fromisoformat( + camera_event["timestamp"] + ).timestamp() + ) + for events in device_events + for camera_event in events["events"].values() + } + return valid_device_timestamps + async def async_get_media_source(hass: HomeAssistant) -> MediaSource: """Set up Nest media source.""" diff --git a/tests/components/nest/conftest.py b/tests/components/nest/conftest.py index 92d90a18a7e..b4b94efce5b 100644 --- a/tests/components/nest/conftest.py +++ b/tests/components/nest/conftest.py @@ -144,13 +144,14 @@ async def auth( return FakeAuth(aioclient_mock, create_device, device_access_project_id) -@pytest.fixture(autouse=True) -def cleanup_media_storage(hass: HomeAssistant) -> Generator[None]: +@pytest.fixture(autouse=True, name="media_path") +def cleanup_media_storage(hass: HomeAssistant) -> Generator[str]: """Test cleanup, remove any media storage persisted during the test.""" tmp_path = str(uuid.uuid4()) with patch("homeassistant.components.nest.media_source.MEDIA_PATH", new=tmp_path): - yield - shutil.rmtree(hass.config.path(tmp_path), ignore_errors=True) + full_path = hass.config.path(tmp_path) + yield full_path + shutil.rmtree(full_path, ignore_errors=True) @pytest.fixture diff --git a/tests/components/nest/test_media_source.py b/tests/components/nest/test_media_source.py index d009e1185da..0b0654fc69c 100644 --- a/tests/components/nest/test_media_source.py +++ b/tests/components/nest/test_media_source.py @@ -8,11 +8,13 @@ from collections.abc import Generator import datetime from http import HTTPStatus import io +import pathlib from typing import Any from unittest.mock import patch import aiohttp import av +from freezegun import freeze_time import numpy as np import pytest @@ -39,7 +41,7 @@ from .common import ( ) from .conftest import FakeAuth -from tests.common import MockUser, async_capture_events +from tests.common import MockUser, async_capture_events, async_fire_time_changed from tests.typing import ClientSessionGenerator DOMAIN = "nest" @@ -1574,3 +1576,80 @@ async def test_event_clip_media_attachment( response = await client.get(content_path) assert response.status == HTTPStatus.OK, f"Response not matched: {response}" await response.read() + + +@pytest.mark.parametrize(("device_traits", "cache_size"), [(BATTERY_CAMERA_TRAITS, 5)]) +async def test_remove_stale_media( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + auth, + mp4, + hass_client: ClientSessionGenerator, + subscriber, + setup_platform, + media_path: str, +) -> None: + """Test media files getting evicted from the cache.""" + await setup_platform() + + device = device_registry.async_get_device(identifiers={(DOMAIN, DEVICE_ID)}) + assert device + assert device.name == DEVICE_NAME + + # Publish a media event + auth.responses = [ + aiohttp.web.Response(body=mp4.getvalue()), + ] + event_timestamp = dt_util.now() + await subscriber.async_receive_event( + create_event_message( + create_battery_event_data(MOTION_EVENT), + timestamp=event_timestamp, + ) + ) + await hass.async_block_till_done() + + # The first subdirectory is the device id. Media for events are stored in the + # device subdirectory. First verify that the media was persisted. We will + # then add additional media files, then invoke the garbage collector, and + # then verify orphaned files are removed. + storage_path = pathlib.Path(media_path) + device_path = storage_path / device.id + media_files = list(device_path.glob("*")) + assert len(media_files) == 1 + event_media = media_files[0] + assert event_media.name.endswith(".mp4") + + event_time1 = datetime.datetime.now(datetime.UTC) - datetime.timedelta(days=8) + extra_media1 = ( + device_path / f"{int(event_time1.timestamp())}-camera_motion-test.mp4" + ) + extra_media1.write_bytes(mp4.getvalue()) + event_time2 = event_time1 + datetime.timedelta(hours=20) + extra_media2 = ( + device_path / f"{int(event_time2.timestamp())}-camera_motion-test.jpg" + ) + extra_media2.write_bytes(mp4.getvalue()) + # This event will not be garbage collected because it is too recent + event_time3 = datetime.datetime.now(datetime.UTC) - datetime.timedelta(days=3) + extra_media3 = ( + device_path / f"{int(event_time3.timestamp())}-camera_motion-test.mp4" + ) + extra_media3.write_bytes(mp4.getvalue()) + + assert len(list(device_path.glob("*"))) == 4 + + # Advance the clock to invoke the garbage collector. This will remove extra + # files that are not valid events that are old enough. + point_in_time = datetime.datetime.now(datetime.UTC) + datetime.timedelta(days=1) + with freeze_time(point_in_time): + async_fire_time_changed(hass, point_in_time) + await hass.async_block_till_done() + await hass.async_block_till_done() + + # Verify that the event media is still present and that the extra files + # are removed. Newer media is not removed. + assert event_media.exists() + assert not extra_media1.exists() + assert not extra_media2.exists() + assert extra_media3.exists() From 598a75379b36eb7c3f543bb6c3780563081b97f9 Mon Sep 17 00:00:00 2001 From: tdfountain <174762217+tdfountain@users.noreply.github.com> Date: Mon, 24 Mar 2025 22:11:13 -0700 Subject: [PATCH 2981/3148] Add sensor native unit of measure in NUT (#141338) --- homeassistant/components/nut/sensor.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/nut/sensor.py b/homeassistant/components/nut/sensor.py index 5c01314dedf..71bfda91335 100644 --- a/homeassistant/components/nut/sensor.py +++ b/homeassistant/components/nut/sensor.py @@ -523,6 +523,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { "input.power": SensorEntityDescription( key="input.power", translation_key="input_power", + native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE, device_class=SensorDeviceClass.APPARENT_POWER, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, From ee3b31c01f09d3842dbbb9652e71e0add3fa747d Mon Sep 17 00:00:00 2001 From: tdfountain <174762217+tdfountain@users.noreply.github.com> Date: Mon, 24 Mar 2025 22:12:26 -0700 Subject: [PATCH 2982/3148] Improve default icons for sensors in NUT (#141255) --- homeassistant/components/nut/icons.json | 71 ++++++++++++++++--------- 1 file changed, 46 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/nut/icons.json b/homeassistant/components/nut/icons.json index c98d80ef55d..a795368005c 100644 --- a/homeassistant/components/nut/icons.json +++ b/homeassistant/components/nut/icons.json @@ -42,11 +42,26 @@ "battery_packs_bad": { "default": "mdi:information-outline" }, + "battery_runtime": { + "default": "mdi:clock-outline" + }, + "battery_runtime_low": { + "default": "mdi:clock-alert-outline" + }, + "battery_runtime_restart": { + "default": "mdi:clock-start" + }, "battery_type": { "default": "mdi:information-outline" }, + "battery_voltage_high": { + "default": "mdi:battery-high" + }, + "battery_voltage_low": { + "default": "mdi:battery-low" + }, "input_bypass_phases": { - "default": "mdi:information-outline" + "default": "mdi:sine-wave" }, "input_current_status": { "default": "mdi:information-outline" @@ -55,13 +70,10 @@ "default": "mdi:information-outline" }, "input_load": { - "default": "mdi:gauge" + "default": "mdi:percent-box-outline" }, "input_phases": { - "default": "mdi:information-outline" - }, - "input_power": { - "default": "mdi:gauge" + "default": "mdi:sine-wave" }, "input_sensitivity": { "default": "mdi:information-outline" @@ -72,35 +84,23 @@ "input_voltage_status": { "default": "mdi:information-outline" }, - "outlet_number_current": { - "default": "mdi:gauge" - }, "outlet_number_current_status": { "default": "mdi:information-outline" }, "outlet_number_desc": { "default": "mdi:information-outline" }, - "outlet_number_power": { - "default": "mdi:gauge" - }, - "outlet_number_realpower": { - "default": "mdi:gauge" - }, - "outlet_voltage": { - "default": "mdi:gauge" - }, "output_l1_power_percent": { - "default": "mdi:gauge" + "default": "mdi:percent-circle-outline" }, "output_l2_power_percent": { - "default": "mdi:gauge" + "default": "mdi:percent-circle-outline" }, "output_l3_power_percent": { - "default": "mdi:gauge" + "default": "mdi:percent-circle-outline" }, "output_phases": { - "default": "mdi:information-outline" + "default": "mdi:sine-wave" }, "ups_alarm": { "default": "mdi:alarm" @@ -111,20 +111,29 @@ "ups_contacts": { "default": "mdi:information-outline" }, + "ups_delay_reboot": { + "default": "mdi:timelapse" + }, + "ups_delay_shutdown": { + "default": "mdi:timelapse" + }, + "ups_delay_start": { + "default": "mdi:timelapse" + }, "ups_display_language": { "default": "mdi:information-outline" }, "ups_efficiency": { - "default": "mdi:gauge" + "default": "mdi:percent-outline" }, "ups_id": { "default": "mdi:information-outline" }, "ups_load": { - "default": "mdi:gauge" + "default": "mdi:percent-box-outline" }, "ups_load_high": { - "default": "mdi:gauge" + "default": "mdi:percent-box-outline" }, "ups_shutdown": { "default": "mdi:information-outline" @@ -147,9 +156,21 @@ "ups_test_date": { "default": "mdi:calendar" }, + "ups_test_interval": { + "default": "mdi:timelapse" + }, "ups_test_result": { "default": "mdi:information-outline" }, + "ups_timer_reboot": { + "default": "mdi:timer-refresh-outline" + }, + "ups_timer_shutdown": { + "default": "mdi:timer-stop-outline" + }, + "ups_timer_start": { + "default": "mdi:timer-play-outline" + }, "ups_type": { "default": "mdi:information-outline" }, From 11877a3b1269943dacda9164684008ad00640fdf Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 25 Mar 2025 08:37:32 +0100 Subject: [PATCH 2983/3148] Bump pysmartthings to 3.0.0 (#141058) * Bump pysmartthings to 2.7.5 * Bump to pysmartthings 3.0.0 --- .../components/smartthings/__init__.py | 23 ++++++++++++------- .../components/smartthings/binary_sensor.py | 4 +--- .../components/smartthings/entity.py | 4 ++-- homeassistant/components/smartthings/event.py | 8 ++++--- .../components/smartthings/manifest.json | 2 +- homeassistant/components/smartthings/valve.py | 4 ++-- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/smartthings/__init__.py | 2 +- tests/components/smartthings/conftest.py | 2 +- tests/components/smartthings/test_init.py | 3 ++- 11 files changed, 32 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py index e5351798219..a8d28e0503f 100644 --- a/homeassistant/components/smartthings/__init__.py +++ b/homeassistant/components/smartthings/__init__.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Callable +import contextlib from dataclasses import dataclass from http import HTTPStatus import logging @@ -12,15 +13,17 @@ from aiohttp import ClientResponseError from pysmartthings import ( Attribute, Capability, + ComponentStatus, Device, DeviceEvent, + Lifecycle, Scene, SmartThings, SmartThingsAuthenticationFailedError, + SmartThingsConnectionError, SmartThingsSinkError, Status, ) -from pysmartthings.models import Lifecycle from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -72,7 +75,7 @@ class FullDevice: """Define an object to hold device data.""" device: Device - status: dict[str, dict[Capability | str, dict[Attribute | str, Status]]] + status: dict[str, ComponentStatus] type SmartThingsConfigEntry = ConfigEntry[SmartThingsData] @@ -124,7 +127,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: SmartThingsConfigEntry) client.refresh_token_function = _refresh_token def _handle_max_connections() -> None: - _LOGGER.debug("We hit the limit of max connections") + _LOGGER.debug( + "We hit the limit of max connections or we could not remove the old one, so retrying" + ) hass.config_entries.async_schedule_reload(entry.entry_id) client.max_connections_reached_callback = _handle_max_connections @@ -147,7 +152,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: SmartThingsConfigEntry) if (old_identifier := entry.data.get(CONF_SUBSCRIPTION_ID)) is not None: _LOGGER.debug("Trying to delete old subscription %s", old_identifier) - await client.delete_subscription(old_identifier) + try: + await client.delete_subscription(old_identifier) + except SmartThingsConnectionError as err: + raise ConfigEntryNotReady("Could not delete old subscription") from err _LOGGER.debug("Trying to create a new subscription") try: @@ -274,7 +282,8 @@ async def async_unload_entry( """Unload a config entry.""" client = entry.runtime_data.client if (subscription_id := entry.data.get(CONF_SUBSCRIPTION_ID)) is not None: - await client.delete_subscription(subscription_id) + with contextlib.suppress(SmartThingsConnectionError): + await client.delete_subscription(subscription_id) return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) @@ -355,9 +364,7 @@ KEEP_CAPABILITY_QUIRK: dict[ } -def process_status( - status: dict[str, dict[Capability | str, dict[Attribute | str, Status]]], -) -> dict[str, dict[Capability | str, dict[Attribute | str, Status]]]: +def process_status(status: dict[str, ComponentStatus]) -> dict[str, ComponentStatus]: """Remove disabled capabilities from status.""" if (main_component := status.get(MAIN)) is None: return status diff --git a/homeassistant/components/smartthings/binary_sensor.py b/homeassistant/components/smartthings/binary_sensor.py index 24249345080..ee68db49929 100644 --- a/homeassistant/components/smartthings/binary_sensor.py +++ b/homeassistant/components/smartthings/binary_sensor.py @@ -174,9 +174,7 @@ def get_main_component_category( device: FullDevice, ) -> Category | str: """Get the main component of a device.""" - main = next( - component for component in device.device.components if component.id == MAIN - ) + main = device.device.components[MAIN] return main.user_category or main.manufacturer_category diff --git a/homeassistant/components/smartthings/entity.py b/homeassistant/components/smartthings/entity.py index 12c07bea983..3314d4b868d 100644 --- a/homeassistant/components/smartthings/entity.py +++ b/homeassistant/components/smartthings/entity.py @@ -8,9 +8,9 @@ from pysmartthings import ( Attribute, Capability, Command, + ComponentStatus, DeviceEvent, SmartThings, - Status, ) from homeassistant.helpers.device_registry import DeviceInfo @@ -38,7 +38,7 @@ class SmartThingsEntity(Entity): self.client = client self.capabilities = capabilities self.component = component - self._internal_state: dict[Capability | str, dict[Attribute | str, Status]] = { + self._internal_state: ComponentStatus = { capability: device.status[component][capability] for capability in capabilities if capability in device.status[component] diff --git a/homeassistant/components/smartthings/event.py b/homeassistant/components/smartthings/event.py index e22a32c7726..8b413f04713 100644 --- a/homeassistant/components/smartthings/event.py +++ b/homeassistant/components/smartthings/event.py @@ -22,10 +22,12 @@ async def async_setup_entry( """Add events for a config entry.""" entry_data = entry.runtime_data async_add_entities( - SmartThingsButtonEvent(entry_data.client, device, component) + SmartThingsButtonEvent( + entry_data.client, device, device.device.components[component] + ) for device in entry_data.devices.values() - for component in device.device.components - if Capability.BUTTON in component.capabilities + for component, capabilities in device.status.items() + if Capability.BUTTON in capabilities ) diff --git a/homeassistant/components/smartthings/manifest.json b/homeassistant/components/smartthings/manifest.json index d7133ce7c6d..49de0c79ce7 100644 --- a/homeassistant/components/smartthings/manifest.json +++ b/homeassistant/components/smartthings/manifest.json @@ -30,5 +30,5 @@ "iot_class": "cloud_push", "loggers": ["pysmartthings"], "quality_scale": "bronze", - "requirements": ["pysmartthings==2.7.4"] + "requirements": ["pysmartthings==3.0.0"] } diff --git a/homeassistant/components/smartthings/valve.py b/homeassistant/components/smartthings/valve.py index 3c401c087ec..4279d528f8b 100644 --- a/homeassistant/components/smartthings/valve.py +++ b/homeassistant/components/smartthings/valve.py @@ -47,8 +47,8 @@ class SmartThingsValve(SmartThingsEntity, ValveEntity): """Init the class.""" super().__init__(client, device, {Capability.VALVE}) self._attr_device_class = DEVICE_CLASS_MAP.get( - device.device.components[0].user_category - or device.device.components[0].manufacturer_category + device.device.components[MAIN].user_category + or device.device.components[MAIN].manufacturer_category ) async def async_open_valve(self) -> None: diff --git a/requirements_all.txt b/requirements_all.txt index 9b856492754..7edd663ba89 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2313,7 +2313,7 @@ pysma==0.7.5 pysmappee==0.2.29 # homeassistant.components.smartthings -pysmartthings==2.7.4 +pysmartthings==3.0.0 # homeassistant.components.smarty pysmarty2==0.10.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 94d60c7b1a9..fafa1008c06 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1883,7 +1883,7 @@ pysma==0.7.5 pysmappee==0.2.29 # homeassistant.components.smartthings -pysmartthings==2.7.4 +pysmartthings==3.0.0 # homeassistant.components.smarty pysmarty2==0.10.2 diff --git a/tests/components/smartthings/__init__.py b/tests/components/smartthings/__init__.py index ad09f1a7acf..fce344b57a7 100644 --- a/tests/components/smartthings/__init__.py +++ b/tests/components/smartthings/__init__.py @@ -3,7 +3,7 @@ from typing import Any from unittest.mock import AsyncMock -from pysmartthings.models import Attribute, Capability, DeviceEvent +from pysmartthings import Attribute, Capability, DeviceEvent from syrupy import SnapshotAssertion from homeassistant.components.smartthings.const import MAIN diff --git a/tests/components/smartthings/conftest.py b/tests/components/smartthings/conftest.py index 761b65adc8a..a19c78dcc00 100644 --- a/tests/components/smartthings/conftest.py +++ b/tests/components/smartthings/conftest.py @@ -4,7 +4,7 @@ from collections.abc import Generator import time from unittest.mock import AsyncMock, patch -from pysmartthings.models import ( +from pysmartthings import ( DeviceResponse, DeviceStatus, LocationResponse, diff --git a/tests/components/smartthings/test_init.py b/tests/components/smartthings/test_init.py index c0d0b8b5840..16458007c29 100644 --- a/tests/components/smartthings/test_init.py +++ b/tests/components/smartthings/test_init.py @@ -8,9 +8,10 @@ from pysmartthings import ( Capability, DeviceResponse, DeviceStatus, + Lifecycle, SmartThingsSinkError, + Subscription, ) -from pysmartthings.models import Lifecycle, Subscription import pytest from syrupy import SnapshotAssertion From 9888385dbed86abe51ea0f8a4acd9c292db4a474 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 25 Mar 2025 08:37:55 +0100 Subject: [PATCH 2984/3148] Bump github/codeql-action from 3.28.12 to 3.28.13 (#141344) Bumps [github/codeql-action](https://github.com/github/codeql-action) from 3.28.12 to 3.28.13. - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/v3.28.12...v3.28.13) --- updated-dependencies: - dependency-name: github/codeql-action dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] 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 f4d4144243c..bd072752d16 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -24,11 +24,11 @@ jobs: uses: actions/checkout@v4.2.2 - name: Initialize CodeQL - uses: github/codeql-action/init@v3.28.12 + uses: github/codeql-action/init@v3.28.13 with: languages: python - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3.28.12 + uses: github/codeql-action/analyze@v3.28.13 with: category: "/language:python" From c7e2acb4bf0ec93742cb8f1463fe3c44a7ff18b8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 25 Mar 2025 08:38:13 +0100 Subject: [PATCH 2985/3148] Bump actions/setup-python from 5.4.0 to 5.5.0 (#141342) Bumps [actions/setup-python](https://github.com/actions/setup-python) from 5.4.0 to 5.5.0. - [Release notes](https://github.com/actions/setup-python/releases) - [Commits](https://github.com/actions/setup-python/compare/v5.4.0...v5.5.0) --- updated-dependencies: - dependency-name: actions/setup-python dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/builder.yml | 6 +++--- .github/workflows/ci.yaml | 32 +++++++++++++++--------------- .github/workflows/translations.yml | 2 +- .github/workflows/wheels.yml | 2 +- 4 files changed, 21 insertions(+), 21 deletions(-) diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index fcf707fef3d..ce89d8c2b10 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -32,7 +32,7 @@ jobs: fetch-depth: 0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v5.4.0 + uses: actions/setup-python@v5.5.0 with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -116,7 +116,7 @@ jobs: - name: Set up Python ${{ env.DEFAULT_PYTHON }} if: needs.init.outputs.channel == 'dev' - uses: actions/setup-python@v5.4.0 + uses: actions/setup-python@v5.5.0 with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -457,7 +457,7 @@ jobs: uses: actions/checkout@v4.2.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v5.4.0 + uses: actions/setup-python@v5.5.0 with: python-version: ${{ env.DEFAULT_PYTHON }} diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 2b1606568b5..c46ec3cda54 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -249,7 +249,7 @@ jobs: uses: actions/checkout@v4.2.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v5.4.0 + uses: actions/setup-python@v5.5.0 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -294,7 +294,7 @@ jobs: - name: Check out code from GitHub uses: actions/checkout@v4.2.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v5.4.0 + uses: actions/setup-python@v5.5.0 id: python with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -334,7 +334,7 @@ jobs: - name: Check out code from GitHub uses: actions/checkout@v4.2.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v5.4.0 + uses: actions/setup-python@v5.5.0 id: python with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -374,7 +374,7 @@ jobs: - name: Check out code from GitHub uses: actions/checkout@v4.2.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v5.4.0 + uses: actions/setup-python@v5.5.0 id: python with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -484,7 +484,7 @@ jobs: uses: actions/checkout@v4.2.2 - name: Set up Python ${{ matrix.python-version }} id: python - uses: actions/setup-python@v5.4.0 + uses: actions/setup-python@v5.5.0 with: python-version: ${{ matrix.python-version }} check-latest: true @@ -587,7 +587,7 @@ jobs: uses: actions/checkout@v4.2.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v5.4.0 + uses: actions/setup-python@v5.5.0 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -620,7 +620,7 @@ jobs: uses: actions/checkout@v4.2.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v5.4.0 + uses: actions/setup-python@v5.5.0 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -677,7 +677,7 @@ jobs: uses: actions/checkout@v4.2.2 - name: Set up Python ${{ matrix.python-version }} id: python - uses: actions/setup-python@v5.4.0 + uses: actions/setup-python@v5.5.0 with: python-version: ${{ matrix.python-version }} check-latest: true @@ -720,7 +720,7 @@ jobs: uses: actions/checkout@v4.2.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v5.4.0 + uses: actions/setup-python@v5.5.0 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -767,7 +767,7 @@ jobs: uses: actions/checkout@v4.2.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v5.4.0 + uses: actions/setup-python@v5.5.0 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -812,7 +812,7 @@ jobs: uses: actions/checkout@v4.2.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v5.4.0 + uses: actions/setup-python@v5.5.0 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -889,7 +889,7 @@ jobs: uses: actions/checkout@v4.2.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v5.4.0 + uses: actions/setup-python@v5.5.0 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -949,7 +949,7 @@ jobs: uses: actions/checkout@v4.2.2 - name: Set up Python ${{ matrix.python-version }} id: python - uses: actions/setup-python@v5.4.0 + uses: actions/setup-python@v5.5.0 with: python-version: ${{ matrix.python-version }} check-latest: true @@ -1074,7 +1074,7 @@ jobs: uses: actions/checkout@v4.2.2 - name: Set up Python ${{ matrix.python-version }} id: python - uses: actions/setup-python@v5.4.0 + uses: actions/setup-python@v5.5.0 with: python-version: ${{ matrix.python-version }} check-latest: true @@ -1208,7 +1208,7 @@ jobs: uses: actions/checkout@v4.2.2 - name: Set up Python ${{ matrix.python-version }} id: python - uses: actions/setup-python@v5.4.0 + uses: actions/setup-python@v5.5.0 with: python-version: ${{ matrix.python-version }} check-latest: true @@ -1359,7 +1359,7 @@ jobs: uses: actions/checkout@v4.2.2 - name: Set up Python ${{ matrix.python-version }} id: python - uses: actions/setup-python@v5.4.0 + uses: actions/setup-python@v5.5.0 with: python-version: ${{ matrix.python-version }} check-latest: true diff --git a/.github/workflows/translations.yml b/.github/workflows/translations.yml index 619d83aef51..0b6abe8fe2c 100644 --- a/.github/workflows/translations.yml +++ b/.github/workflows/translations.yml @@ -22,7 +22,7 @@ jobs: uses: actions/checkout@v4.2.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v5.4.0 + uses: actions/setup-python@v5.5.0 with: python-version: ${{ env.DEFAULT_PYTHON }} diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index cdf0c07cccf..61a2e00fcf4 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -36,7 +36,7 @@ jobs: - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v5.4.0 + uses: actions/setup-python@v5.5.0 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true From 4e266fe56e33bd43a15cce5379a0b1cbe4b0f2eb Mon Sep 17 00:00:00 2001 From: Matrix Date: Tue, 25 Mar 2025 15:39:58 +0800 Subject: [PATCH 2986/3148] Bump YoLink API to 0.4.9 fix fob event (#141343) Fix Fob Event --- homeassistant/components/yolink/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/yolink/manifest.json b/homeassistant/components/yolink/manifest.json index 52ae8281f59..8c297c68670 100644 --- a/homeassistant/components/yolink/manifest.json +++ b/homeassistant/components/yolink/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["auth", "application_credentials"], "documentation": "https://www.home-assistant.io/integrations/yolink", "iot_class": "cloud_push", - "requirements": ["yolink-api==0.4.8"] + "requirements": ["yolink-api==0.4.9"] } diff --git a/requirements_all.txt b/requirements_all.txt index 7edd663ba89..0f8692438c8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3125,7 +3125,7 @@ yeelight==0.7.16 yeelightsunflower==0.0.10 # homeassistant.components.yolink -yolink-api==0.4.8 +yolink-api==0.4.9 # homeassistant.components.youless youless-api==2.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fafa1008c06..ebf02214f0a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2518,7 +2518,7 @@ yalexs==8.10.0 yeelight==0.7.16 # homeassistant.components.yolink -yolink-api==0.4.8 +yolink-api==0.4.9 # homeassistant.components.youless youless-api==2.2.0 From 5fd219fc9eff391c782ca7769ba370d418d64e16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Tue, 25 Mar 2025 07:41:02 +0000 Subject: [PATCH 2987/3148] Add Motionblinds Matter virtual integration (#140812) * Add Motionblinds Matter virtual integration * Change to iot_standards instead of virtual integration --- homeassistant/brands/motionblinds.json | 3 ++- homeassistant/generated/integrations.json | 5 ++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/homeassistant/brands/motionblinds.json b/homeassistant/brands/motionblinds.json index 67013e75966..5a48b573b4d 100644 --- a/homeassistant/brands/motionblinds.json +++ b/homeassistant/brands/motionblinds.json @@ -1,5 +1,6 @@ { "domain": "motionblinds", "name": "Motionblinds", - "integrations": ["motion_blinds", "motionblinds_ble"] + "integrations": ["motion_blinds", "motionblinds_ble"], + "iot_standards": ["matter"] } diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index f70ed1c1283..c43af49f03f 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -4030,7 +4030,10 @@ "iot_class": "assumed_state", "name": "Motionblinds Bluetooth" } - } + }, + "iot_standards": [ + "matter" + ] }, "motioneye": { "name": "motionEye", From b3e054d5a77d6dce6c1ee2c02b8fb00a2ea8d28c Mon Sep 17 00:00:00 2001 From: Ted van den Brink Date: Tue, 25 Mar 2025 09:24:32 +0100 Subject: [PATCH 2988/3148] Fix for whois - quota exceeded and private registry (#141060) * Fix for quota exceeded and private registry * Add tests --- homeassistant/components/whois/config_flow.py | 6 ++ homeassistant/components/whois/strings.json | 4 +- .../whois/snapshots/test_config_flow.ambr | 88 +++++++++++++++++++ tests/components/whois/test_config_flow.py | 4 + 4 files changed, 101 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/whois/config_flow.py b/homeassistant/components/whois/config_flow.py index cb4326d996d..a8306be7632 100644 --- a/homeassistant/components/whois/config_flow.py +++ b/homeassistant/components/whois/config_flow.py @@ -11,6 +11,8 @@ from whois.exceptions import ( UnknownDateFormat, UnknownTld, WhoisCommandFailed, + WhoisPrivateRegistry, + WhoisQuotaExceeded, ) from homeassistant.config_entries import ConfigFlow, ConfigFlowResult @@ -48,6 +50,10 @@ class WhoisFlowHandler(ConfigFlow, domain=DOMAIN): errors["base"] = "unexpected_response" except UnknownDateFormat: errors["base"] = "unknown_date_format" + except WhoisPrivateRegistry: + errors["base"] = "private_registry" + except WhoisQuotaExceeded: + errors["base"] = "quota_exceeded" else: return self.async_create_entry( title=self.imported_name or user_input[CONF_DOMAIN], diff --git a/homeassistant/components/whois/strings.json b/homeassistant/components/whois/strings.json index c28c079784d..3b0f9dfd4d1 100644 --- a/homeassistant/components/whois/strings.json +++ b/homeassistant/components/whois/strings.json @@ -11,7 +11,9 @@ "unexpected_response": "Unexpected response from whois server", "unknown_date_format": "Unknown date format in whois server response", "unknown_tld": "The given TLD is unknown or not available to this integration", - "whois_command_failed": "Whois command failed: could not retrieve whois information" + "whois_command_failed": "Whois command failed: could not retrieve whois information", + "private_registry": "The given domain is registered in a private registry and cannot be monitored", + "quota_exceeded": "Your whois quota has been exceeded for this TLD" }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" diff --git a/tests/components/whois/snapshots/test_config_flow.ambr b/tests/components/whois/snapshots/test_config_flow.ambr index 0d99b0596e3..97d6fde6376 100644 --- a/tests/components/whois/snapshots/test_config_flow.ambr +++ b/tests/components/whois/snapshots/test_config_flow.ambr @@ -175,6 +175,94 @@ 'version': 1, }) # --- +# name: test_full_flow_with_error[WhoisPrivateRegistry-private_registry] + FlowResultSnapshot({ + 'context': dict({ + 'source': 'user', + 'unique_id': 'example.com', + }), + 'data': dict({ + 'domain': 'example.com', + }), + 'description': None, + 'description_placeholders': None, + 'flow_id': , + 'handler': 'whois', + 'minor_version': 1, + 'options': dict({ + }), + 'result': ConfigEntrySnapshot({ + 'data': dict({ + 'domain': 'example.com', + }), + 'disabled_by': None, + 'discovery_keys': dict({ + }), + 'domain': 'whois', + 'entry_id': , + 'minor_version': 1, + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'subentries': list([ + ]), + 'title': 'Example.com', + 'unique_id': 'example.com', + 'version': 1, + }), + 'subentries': tuple( + ), + 'title': 'Example.com', + 'type': , + 'version': 1, + }) +# --- +# name: test_full_flow_with_error[WhoisQuotaExceeded-quota_exceeded] + FlowResultSnapshot({ + 'context': dict({ + 'source': 'user', + 'unique_id': 'example.com', + }), + 'data': dict({ + 'domain': 'example.com', + }), + 'description': None, + 'description_placeholders': None, + 'flow_id': , + 'handler': 'whois', + 'minor_version': 1, + 'options': dict({ + }), + 'result': ConfigEntrySnapshot({ + 'data': dict({ + 'domain': 'example.com', + }), + 'disabled_by': None, + 'discovery_keys': dict({ + }), + 'domain': 'whois', + 'entry_id': , + 'minor_version': 1, + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'subentries': list([ + ]), + 'title': 'Example.com', + 'unique_id': 'example.com', + 'version': 1, + }), + 'subentries': tuple( + ), + 'title': 'Example.com', + 'type': , + 'version': 1, + }) +# --- # name: test_full_user_flow FlowResultSnapshot({ 'context': dict({ diff --git a/tests/components/whois/test_config_flow.py b/tests/components/whois/test_config_flow.py index 35e40c4e809..6ab02887be2 100644 --- a/tests/components/whois/test_config_flow.py +++ b/tests/components/whois/test_config_flow.py @@ -9,6 +9,8 @@ from whois.exceptions import ( UnknownDateFormat, UnknownTld, WhoisCommandFailed, + WhoisPrivateRegistry, + WhoisQuotaExceeded, ) from homeassistant.components.whois.const import DOMAIN @@ -52,6 +54,8 @@ async def test_full_user_flow( (FailedParsingWhoisOutput, "unexpected_response"), (UnknownDateFormat, "unknown_date_format"), (WhoisCommandFailed, "whois_command_failed"), + (WhoisPrivateRegistry, "private_registry"), + (WhoisQuotaExceeded, "quota_exceeded"), ], ) async def test_full_flow_with_error( From 4f6daa227a8e2f91af08b1722e9f8cbbeadd806f Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Tue, 25 Mar 2025 09:34:44 +0100 Subject: [PATCH 2989/3148] Move MQTT light constants to const module (#140945) --- homeassistant/components/mqtt/const.py | 63 ++++++++++++ .../components/mqtt/light/schema_basic.py | 95 +++++++++---------- .../components/mqtt/light/schema_json.py | 31 +++--- .../components/mqtt/light/schema_template.py | 23 +++-- 4 files changed, 134 insertions(+), 78 deletions(-) diff --git a/homeassistant/components/mqtt/const.py b/homeassistant/components/mqtt/const.py index 007b3b7e576..c050a1c32da 100644 --- a/homeassistant/components/mqtt/const.py +++ b/homeassistant/components/mqtt/const.py @@ -56,20 +56,53 @@ CONF_SUPPORTED_FEATURES = "supported_features" CONF_ACTION_TEMPLATE = "action_template" CONF_ACTION_TOPIC = "action_topic" +CONF_BLUE_TEMPLATE = "blue_template" +CONF_BRIGHTNESS_COMMAND_TEMPLATE = "brightness_command_template" +CONF_BRIGHTNESS_COMMAND_TOPIC = "brightness_command_topic" +CONF_BRIGHTNESS_SCALE = "brightness_scale" +CONF_BRIGHTNESS_STATE_TOPIC = "brightness_state_topic" +CONF_BRIGHTNESS_TEMPLATE = "brightness_template" +CONF_BRIGHTNESS_VALUE_TEMPLATE = "brightness_value_template" +CONF_COLOR_MODE = "color_mode" +CONF_COLOR_MODE_STATE_TOPIC = "color_mode_state_topic" +CONF_COLOR_MODE_VALUE_TEMPLATE = "color_mode_value_template" +CONF_COLOR_TEMP_COMMAND_TEMPLATE = "color_temp_command_template" +CONF_COLOR_TEMP_COMMAND_TOPIC = "color_temp_command_topic" CONF_COLOR_TEMP_KELVIN = "color_temp_kelvin" +CONF_COLOR_TEMP_TEMPLATE = "color_temp_template" +CONF_COLOR_TEMP_STATE_TOPIC = "color_temp_state_topic" +CONF_COLOR_TEMP_VALUE_TEMPLATE = "color_temp_value_template" +CONF_COMMAND_OFF_TEMPLATE = "command_off_template" +CONF_COMMAND_ON_TEMPLATE = "command_on_template" CONF_CURRENT_HUMIDITY_TEMPLATE = "current_humidity_template" CONF_CURRENT_HUMIDITY_TOPIC = "current_humidity_topic" CONF_CURRENT_TEMP_TEMPLATE = "current_temperature_template" CONF_CURRENT_TEMP_TOPIC = "current_temperature_topic" CONF_ENABLED_BY_DEFAULT = "enabled_by_default" +CONF_EFFECT_COMMAND_TEMPLATE = "effect_command_template" +CONF_EFFECT_COMMAND_TOPIC = "effect_command_topic" +CONF_EFFECT_LIST = "effect_list" +CONF_EFFECT_STATE_TOPIC = "effect_state_topic" +CONF_EFFECT_TEMPLATE = "effect_template" +CONF_EFFECT_VALUE_TEMPLATE = "effect_value_template" CONF_ENTITY_PICTURE = "entity_picture" +CONF_FLASH_TIME_LONG = "flash_time_long" +CONF_FLASH_TIME_SHORT = "flash_time_short" +CONF_GREEN_TEMPLATE = "green_template" +CONF_HS_COMMAND_TEMPLATE = "hs_command_template" +CONF_HS_COMMAND_TOPIC = "hs_command_topic" +CONF_HS_STATE_TOPIC = "hs_state_topic" +CONF_HS_VALUE_TEMPLATE = "hs_value_template" CONF_MAX_KELVIN = "max_kelvin" +CONF_MAX_MIREDS = "max_mireds" CONF_MIN_KELVIN = "min_kelvin" +CONF_MIN_MIREDS = "min_mireds" CONF_MODE_COMMAND_TEMPLATE = "mode_command_template" CONF_MODE_COMMAND_TOPIC = "mode_command_topic" CONF_MODE_LIST = "modes" CONF_MODE_STATE_TEMPLATE = "mode_state_template" CONF_MODE_STATE_TOPIC = "mode_state_topic" +CONF_ON_COMMAND_TYPE = "on_command_type" CONF_PAYLOAD_CLOSE = "payload_close" CONF_PAYLOAD_OPEN = "payload_open" CONF_PAYLOAD_STOP = "payload_stop" @@ -78,10 +111,24 @@ CONF_POSITION_OPEN = "position_open" CONF_POWER_COMMAND_TOPIC = "power_command_topic" CONF_POWER_COMMAND_TEMPLATE = "power_command_template" CONF_PRECISION = "precision" +CONF_RED_TEMPLATE = "red_template" +CONF_RGB_COMMAND_TEMPLATE = "rgb_command_template" +CONF_RGB_COMMAND_TOPIC = "rgb_command_topic" +CONF_RGB_STATE_TOPIC = "rgb_state_topic" +CONF_RGB_VALUE_TEMPLATE = "rgb_value_template" +CONF_RGBW_COMMAND_TEMPLATE = "rgbw_command_template" +CONF_RGBW_COMMAND_TOPIC = "rgbw_command_topic" +CONF_RGBW_STATE_TOPIC = "rgbw_state_topic" +CONF_RGBW_VALUE_TEMPLATE = "rgbw_value_template" +CONF_RGBWW_COMMAND_TEMPLATE = "rgbww_command_template" +CONF_RGBWW_COMMAND_TOPIC = "rgbww_command_topic" +CONF_RGBWW_STATE_TOPIC = "rgbww_state_topic" +CONF_RGBWW_VALUE_TEMPLATE = "rgbww_value_template" CONF_STATE_CLOSED = "state_closed" CONF_STATE_CLOSING = "state_closing" CONF_STATE_OPEN = "state_open" CONF_STATE_OPENING = "state_opening" +CONF_SUPPORTED_COLOR_MODES = "supported_color_modes" CONF_TEMP_COMMAND_TEMPLATE = "temperature_command_template" CONF_TEMP_COMMAND_TOPIC = "temperature_command_topic" CONF_TEMP_STATE_TEMPLATE = "temperature_state_template" @@ -89,7 +136,14 @@ CONF_TEMP_STATE_TOPIC = "temperature_state_topic" CONF_TEMP_INITIAL = "initial" CONF_TEMP_MAX = "max_temp" CONF_TEMP_MIN = "min_temp" +CONF_XY_COMMAND_TEMPLATE = "xy_command_template" +CONF_XY_COMMAND_TOPIC = "xy_command_topic" +CONF_XY_STATE_TOPIC = "xy_state_topic" +CONF_XY_VALUE_TEMPLATE = "xy_value_template" +CONF_WHITE_COMMAND_TOPIC = "white_command_topic" +CONF_WHITE_SCALE = "white_scale" +# Config flow constants CONF_CERTIFICATE = "certificate" CONF_CLIENT_KEY = "client_key" CONF_CLIENT_CERT = "client_cert" @@ -110,15 +164,23 @@ CONF_CONFIGURATION_URL = "configuration_url" CONF_OBJECT_ID = "object_id" CONF_SUPPORT_URL = "support_url" +DEFAULT_BRIGHTNESS = False +DEFAULT_BRIGHTNESS_SCALE = 255 DEFAULT_PREFIX = "homeassistant" DEFAULT_BIRTH_WILL_TOPIC = DEFAULT_PREFIX + "/status" DEFAULT_DISCOVERY = True +DEFAULT_EFFECT = False DEFAULT_ENCODING = "utf-8" +DEFAULT_FLASH_TIME_LONG = 10 +DEFAULT_FLASH_TIME_SHORT = 2 DEFAULT_OPTIMISTIC = False +DEFAULT_ON_COMMAND_TYPE = "last" DEFAULT_QOS = 0 DEFAULT_PAYLOAD_AVAILABLE = "online" DEFAULT_PAYLOAD_CLOSE = "CLOSE" DEFAULT_PAYLOAD_NOT_AVAILABLE = "offline" +DEFAULT_PAYLOAD_OFF = "OFF" +DEFAULT_PAYLOAD_ON = "ON" DEFAULT_PAYLOAD_OPEN = "OPEN" DEFAULT_PORT = 1883 DEFAULT_RETAIN = False @@ -127,6 +189,7 @@ DEFAULT_WS_PATH = "/" DEFAULT_POSITION_CLOSED = 0 DEFAULT_POSITION_OPEN = 100 DEFAULT_RETAIN = False +DEFAULT_WHITE_SCALE = 255 PROTOCOL_31 = "3.1" PROTOCOL_311 = "3.1.1" diff --git a/homeassistant/components/mqtt/light/schema_basic.py b/homeassistant/components/mqtt/light/schema_basic.py index a2f424b247d..a950aced665 100644 --- a/homeassistant/components/mqtt/light/schema_basic.py +++ b/homeassistant/components/mqtt/light/schema_basic.py @@ -51,12 +51,58 @@ from homeassistant.util import color as color_util from .. import subscription from ..config import MQTT_RW_SCHEMA from ..const import ( + CONF_BRIGHTNESS_COMMAND_TEMPLATE, + CONF_BRIGHTNESS_COMMAND_TOPIC, + CONF_BRIGHTNESS_SCALE, + CONF_BRIGHTNESS_STATE_TOPIC, + CONF_BRIGHTNESS_VALUE_TEMPLATE, + CONF_COLOR_MODE_STATE_TOPIC, + CONF_COLOR_MODE_VALUE_TEMPLATE, + CONF_COLOR_TEMP_COMMAND_TEMPLATE, + CONF_COLOR_TEMP_COMMAND_TOPIC, CONF_COLOR_TEMP_KELVIN, + CONF_COLOR_TEMP_STATE_TOPIC, + CONF_COLOR_TEMP_VALUE_TEMPLATE, CONF_COMMAND_TOPIC, + CONF_EFFECT_COMMAND_TEMPLATE, + CONF_EFFECT_COMMAND_TOPIC, + CONF_EFFECT_LIST, + CONF_EFFECT_STATE_TOPIC, + CONF_EFFECT_VALUE_TEMPLATE, + CONF_HS_COMMAND_TEMPLATE, + CONF_HS_COMMAND_TOPIC, + CONF_HS_STATE_TOPIC, + CONF_HS_VALUE_TEMPLATE, CONF_MAX_KELVIN, + CONF_MAX_MIREDS, CONF_MIN_KELVIN, + CONF_MIN_MIREDS, + CONF_ON_COMMAND_TYPE, + CONF_RGB_COMMAND_TEMPLATE, + CONF_RGB_COMMAND_TOPIC, + CONF_RGB_STATE_TOPIC, + CONF_RGB_VALUE_TEMPLATE, + CONF_RGBW_COMMAND_TEMPLATE, + CONF_RGBW_COMMAND_TOPIC, + CONF_RGBW_STATE_TOPIC, + CONF_RGBW_VALUE_TEMPLATE, + CONF_RGBWW_COMMAND_TEMPLATE, + CONF_RGBWW_COMMAND_TOPIC, + CONF_RGBWW_STATE_TOPIC, + CONF_RGBWW_VALUE_TEMPLATE, CONF_STATE_TOPIC, CONF_STATE_VALUE_TEMPLATE, + CONF_WHITE_COMMAND_TOPIC, + CONF_WHITE_SCALE, + CONF_XY_COMMAND_TEMPLATE, + CONF_XY_COMMAND_TOPIC, + CONF_XY_STATE_TOPIC, + CONF_XY_VALUE_TEMPLATE, + DEFAULT_BRIGHTNESS_SCALE, + DEFAULT_ON_COMMAND_TYPE, + DEFAULT_PAYLOAD_OFF, + DEFAULT_PAYLOAD_ON, + DEFAULT_WHITE_SCALE, PAYLOAD_NONE, ) from ..entity import MqttEntity @@ -74,47 +120,7 @@ from .schema import MQTT_LIGHT_SCHEMA_SCHEMA _LOGGER = logging.getLogger(__name__) -CONF_BRIGHTNESS_COMMAND_TEMPLATE = "brightness_command_template" -CONF_BRIGHTNESS_COMMAND_TOPIC = "brightness_command_topic" -CONF_BRIGHTNESS_SCALE = "brightness_scale" -CONF_BRIGHTNESS_STATE_TOPIC = "brightness_state_topic" -CONF_BRIGHTNESS_VALUE_TEMPLATE = "brightness_value_template" -CONF_COLOR_MODE_STATE_TOPIC = "color_mode_state_topic" -CONF_COLOR_MODE_VALUE_TEMPLATE = "color_mode_value_template" -CONF_COLOR_TEMP_COMMAND_TEMPLATE = "color_temp_command_template" -CONF_COLOR_TEMP_COMMAND_TOPIC = "color_temp_command_topic" -CONF_COLOR_TEMP_STATE_TOPIC = "color_temp_state_topic" -CONF_COLOR_TEMP_VALUE_TEMPLATE = "color_temp_value_template" -CONF_EFFECT_COMMAND_TEMPLATE = "effect_command_template" -CONF_EFFECT_COMMAND_TOPIC = "effect_command_topic" -CONF_EFFECT_LIST = "effect_list" -CONF_EFFECT_STATE_TOPIC = "effect_state_topic" -CONF_EFFECT_VALUE_TEMPLATE = "effect_value_template" -CONF_HS_COMMAND_TEMPLATE = "hs_command_template" -CONF_HS_COMMAND_TOPIC = "hs_command_topic" -CONF_HS_STATE_TOPIC = "hs_state_topic" -CONF_HS_VALUE_TEMPLATE = "hs_value_template" -CONF_MAX_MIREDS = "max_mireds" -CONF_MIN_MIREDS = "min_mireds" -CONF_RGB_COMMAND_TEMPLATE = "rgb_command_template" -CONF_RGB_COMMAND_TOPIC = "rgb_command_topic" -CONF_RGB_STATE_TOPIC = "rgb_state_topic" -CONF_RGB_VALUE_TEMPLATE = "rgb_value_template" -CONF_RGBW_COMMAND_TEMPLATE = "rgbw_command_template" -CONF_RGBW_COMMAND_TOPIC = "rgbw_command_topic" -CONF_RGBW_STATE_TOPIC = "rgbw_state_topic" -CONF_RGBW_VALUE_TEMPLATE = "rgbw_value_template" -CONF_RGBWW_COMMAND_TEMPLATE = "rgbww_command_template" -CONF_RGBWW_COMMAND_TOPIC = "rgbww_command_topic" -CONF_RGBWW_STATE_TOPIC = "rgbww_state_topic" -CONF_RGBWW_VALUE_TEMPLATE = "rgbww_value_template" -CONF_XY_COMMAND_TEMPLATE = "xy_command_template" -CONF_XY_COMMAND_TOPIC = "xy_command_topic" -CONF_XY_STATE_TOPIC = "xy_state_topic" -CONF_XY_VALUE_TEMPLATE = "xy_value_template" -CONF_WHITE_COMMAND_TOPIC = "white_command_topic" -CONF_WHITE_SCALE = "white_scale" -CONF_ON_COMMAND_TYPE = "on_command_type" +DEFAULT_NAME = "MQTT LightEntity" MQTT_LIGHT_ATTRIBUTES_BLOCKED = frozenset( { @@ -137,13 +143,6 @@ MQTT_LIGHT_ATTRIBUTES_BLOCKED = frozenset( } ) -DEFAULT_BRIGHTNESS_SCALE = 255 -DEFAULT_NAME = "MQTT LightEntity" -DEFAULT_PAYLOAD_OFF = "OFF" -DEFAULT_PAYLOAD_ON = "ON" -DEFAULT_WHITE_SCALE = 255 -DEFAULT_ON_COMMAND_TYPE = "last" - VALUES_ON_COMMAND_TYPE = ["first", "last", "brightness"] COMMAND_TEMPLATE_KEYS = [ diff --git a/homeassistant/components/mqtt/light/schema_json.py b/homeassistant/components/mqtt/light/schema_json.py index d18da9e917a..a1f86278cf0 100644 --- a/homeassistant/components/mqtt/light/schema_json.py +++ b/homeassistant/components/mqtt/light/schema_json.py @@ -55,13 +55,26 @@ from homeassistant.util.json import json_loads_object from .. import subscription from ..config import DEFAULT_QOS, DEFAULT_RETAIN, MQTT_RW_SCHEMA from ..const import ( + CONF_COLOR_MODE, CONF_COLOR_TEMP_KELVIN, CONF_COMMAND_TOPIC, + CONF_EFFECT_LIST, + CONF_FLASH_TIME_LONG, + CONF_FLASH_TIME_SHORT, CONF_MAX_KELVIN, + CONF_MAX_MIREDS, CONF_MIN_KELVIN, + CONF_MIN_MIREDS, CONF_QOS, CONF_RETAIN, CONF_STATE_TOPIC, + CONF_SUPPORTED_COLOR_MODES, + DEFAULT_BRIGHTNESS, + DEFAULT_BRIGHTNESS_SCALE, + DEFAULT_EFFECT, + DEFAULT_FLASH_TIME_LONG, + DEFAULT_FLASH_TIME_SHORT, + DEFAULT_WHITE_SCALE, ) from ..entity import MqttEntity from ..models import ReceiveMessage @@ -78,25 +91,7 @@ _LOGGER = logging.getLogger(__name__) DOMAIN = "mqtt_json" -DEFAULT_BRIGHTNESS = False -DEFAULT_EFFECT = False -DEFAULT_FLASH_TIME_LONG = 10 -DEFAULT_FLASH_TIME_SHORT = 2 DEFAULT_NAME = "MQTT JSON Light" -DEFAULT_BRIGHTNESS_SCALE = 255 -DEFAULT_WHITE_SCALE = 255 - -CONF_COLOR_MODE = "color_mode" -CONF_SUPPORTED_COLOR_MODES = "supported_color_modes" - -CONF_EFFECT_LIST = "effect_list" - -CONF_FLASH_TIME_LONG = "flash_time_long" -CONF_FLASH_TIME_SHORT = "flash_time_short" - -CONF_MAX_MIREDS = "max_mireds" -CONF_MIN_MIREDS = "min_mireds" - _PLATFORM_SCHEMA_BASE = ( MQTT_RW_SCHEMA.extend( diff --git a/homeassistant/components/mqtt/light/schema_template.py b/homeassistant/components/mqtt/light/schema_template.py index 901cee6f14c..595f072416b 100644 --- a/homeassistant/components/mqtt/light/schema_template.py +++ b/homeassistant/components/mqtt/light/schema_template.py @@ -40,10 +40,21 @@ from homeassistant.util import color as color_util from .. import subscription from ..config import MQTT_RW_SCHEMA from ..const import ( + CONF_BLUE_TEMPLATE, + CONF_BRIGHTNESS_TEMPLATE, CONF_COLOR_TEMP_KELVIN, + CONF_COLOR_TEMP_TEMPLATE, + CONF_COMMAND_OFF_TEMPLATE, + CONF_COMMAND_ON_TEMPLATE, CONF_COMMAND_TOPIC, + CONF_EFFECT_LIST, + CONF_EFFECT_TEMPLATE, + CONF_GREEN_TEMPLATE, CONF_MAX_KELVIN, + CONF_MAX_MIREDS, CONF_MIN_KELVIN, + CONF_MIN_MIREDS, + CONF_RED_TEMPLATE, CONF_STATE_TOPIC, PAYLOAD_NONE, ) @@ -64,18 +75,6 @@ DOMAIN = "mqtt_template" DEFAULT_NAME = "MQTT Template Light" -CONF_BLUE_TEMPLATE = "blue_template" -CONF_BRIGHTNESS_TEMPLATE = "brightness_template" -CONF_COLOR_TEMP_TEMPLATE = "color_temp_template" -CONF_COMMAND_OFF_TEMPLATE = "command_off_template" -CONF_COMMAND_ON_TEMPLATE = "command_on_template" -CONF_EFFECT_LIST = "effect_list" -CONF_EFFECT_TEMPLATE = "effect_template" -CONF_GREEN_TEMPLATE = "green_template" -CONF_MAX_MIREDS = "max_mireds" -CONF_MIN_MIREDS = "min_mireds" -CONF_RED_TEMPLATE = "red_template" - COMMAND_TEMPLATES = (CONF_COMMAND_ON_TEMPLATE, CONF_COMMAND_OFF_TEMPLATE) VALUE_TEMPLATES = ( CONF_BLUE_TEMPLATE, From 36d32eaabcfe6b3f3145651984b528e5c68de4bd Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 25 Mar 2025 09:52:45 +0100 Subject: [PATCH 2990/3148] Improve backup exclude filters (#141311) * Improve backup exclude filters * Add comment --- homeassistant/components/backup/const.py | 4 ++-- homeassistant/components/backup/manager.py | 4 +++- tests/components/backup/conftest.py | 1 + tests/components/backup/test_manager.py | 2 ++ 4 files changed, 8 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/backup/const.py b/homeassistant/components/backup/const.py index c2070a37b2d..773deaef174 100644 --- a/homeassistant/components/backup/const.py +++ b/homeassistant/components/backup/const.py @@ -16,8 +16,8 @@ DATA_MANAGER: HassKey[BackupManager] = HassKey(DOMAIN) LOGGER = getLogger(__package__) EXCLUDE_FROM_BACKUP = [ - "__pycache__/*", - ".DS_Store", + "**/__pycache__/*", + "**/.DS_Store", ".HA_RESTORE", "*.db-shm", "*.log.*", diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index 4bcdf7597b2..43a7be6db8d 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -1726,7 +1726,9 @@ class CoreBackupReaderWriter(BackupReaderWriter): """Filter to filter excludes.""" for exclude in excludes: - if not path.match(exclude): + # The home assistant core configuration directory is added as "data" + # in the tar file, so we need to prefix that path to the filters. + if not path.full_match(f"data/{exclude}"): continue LOGGER.debug("Ignoring %s because of %s", path, exclude) return True diff --git a/tests/components/backup/conftest.py b/tests/components/backup/conftest.py index 8c0e0ef63ac..d391df44475 100644 --- a/tests/components/backup/conftest.py +++ b/tests/components/backup/conftest.py @@ -76,6 +76,7 @@ CONFIG_DIR = { Path("backups/not_backup"), ], "/another_subdir": [ + Path("another_subdir/.DS_Store"), Path("another_subdir/backups"), Path("another_subdir/tts"), ], diff --git a/tests/components/backup/test_manager.py b/tests/components/backup/test_manager.py index f518d7c59bc..04072dae864 100644 --- a/tests/components/backup/test_manager.py +++ b/tests/components/backup/test_manager.py @@ -70,8 +70,10 @@ _EXPECTED_FILES = [ ".storage", "another_subdir", "another_subdir/backups", + "another_subdir/backups/backup.tar", "another_subdir/backups/not_backup", "another_subdir/tts", + "another_subdir/tts/voice.mp3", "backups", "backups/not_backup", "tmp_backups", From 13f306ddbc2b180f82611bebee665ad903788339 Mon Sep 17 00:00:00 2001 From: pglab-electronics <89299919+pglab-electronics@users.noreply.github.com> Date: Tue, 25 Mar 2025 09:55:11 +0100 Subject: [PATCH 2991/3148] Add cover support to PG LAB integration (#140290) * Add cover support to PG LAB Electronics integration * check shutter none state in is_closing and is_opening * adding a loop instead of test test single cover individually --- homeassistant/components/pglab/cover.py | 107 ++++++++++ homeassistant/components/pglab/discovery.py | 9 + homeassistant/components/pglab/strings.json | 5 + tests/components/pglab/test_cover.py | 210 ++++++++++++++++++++ 4 files changed, 331 insertions(+) create mode 100644 homeassistant/components/pglab/cover.py create mode 100644 tests/components/pglab/test_cover.py diff --git a/homeassistant/components/pglab/cover.py b/homeassistant/components/pglab/cover.py new file mode 100644 index 00000000000..8385fd95ffa --- /dev/null +++ b/homeassistant/components/pglab/cover.py @@ -0,0 +1,107 @@ +"""PG LAB Electronics Cover.""" + +from __future__ import annotations + +from typing import Any + +from pypglab.device import Device as PyPGLabDevice +from pypglab.shutter import Shutter as PyPGLabShutter + +from homeassistant.components.cover import ( + CoverDeviceClass, + CoverEntity, + CoverEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .discovery import PGLabDiscovery +from .entity import PGLabEntity + +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up switches for device.""" + + @callback + def async_discover( + pglab_device: PyPGLabDevice, pglab_shutter: PyPGLabShutter + ) -> None: + """Discover and add a PG LAB Cover.""" + pglab_discovery = config_entry.runtime_data + pglab_cover = PGLabCover(pglab_discovery, pglab_device, pglab_shutter) + async_add_entities([pglab_cover]) + + # Register the callback to create the cover entity when discovered. + pglab_discovery = config_entry.runtime_data + await pglab_discovery.register_platform(hass, Platform.COVER, async_discover) + + +class PGLabCover(PGLabEntity, CoverEntity): + """A PGLab Cover.""" + + _attr_translation_key = "shutter" + + def __init__( + self, + pglab_discovery: PGLabDiscovery, + pglab_device: PyPGLabDevice, + pglab_shutter: PyPGLabShutter, + ) -> None: + """Initialize the Cover class.""" + + super().__init__( + pglab_discovery, + pglab_device, + pglab_shutter, + ) + + self._attr_unique_id = f"{pglab_device.id}_shutter{pglab_shutter.id}" + self._attr_translation_placeholders = {"shutter_id": pglab_shutter.id} + + self._shutter = pglab_shutter + + self._attr_device_class = CoverDeviceClass.SHUTTER + self._attr_supported_features = ( + CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE | CoverEntityFeature.STOP + ) + + async def async_open_cover(self, **kwargs: Any) -> None: + """Open the cover.""" + await self._shutter.open() + + async def async_close_cover(self, **kwargs: Any) -> None: + """Close cover.""" + await self._shutter.close() + + async def async_stop_cover(self, **kwargs: Any) -> None: + """Stop the cover.""" + await self._shutter.stop() + + @property + def is_closed(self) -> bool | None: + """Return if cover is closed.""" + if not self._shutter.state: + return None + return self._shutter.state == PyPGLabShutter.STATE_CLOSED + + @property + def is_closing(self) -> bool | None: + """Return if the cover is closing.""" + if not self._shutter.state: + return None + return self._shutter.state == PyPGLabShutter.STATE_CLOSING + + @property + def is_opening(self) -> bool | None: + """Return if the cover is opening.""" + if not self._shutter.state: + return None + return self._shutter.state == PyPGLabShutter.STATE_OPENING diff --git a/homeassistant/components/pglab/discovery.py b/homeassistant/components/pglab/discovery.py index e34f80a2e2d..c1d8653c17b 100644 --- a/homeassistant/components/pglab/discovery.py +++ b/homeassistant/components/pglab/discovery.py @@ -34,12 +34,14 @@ if TYPE_CHECKING: # Supported platforms. PLATFORMS = [ + Platform.COVER, Platform.SENSOR, Platform.SWITCH, ] # Used to create a new component entity. CREATE_NEW_ENTITY = { + Platform.COVER: "pglab_create_new_entity_cover", Platform.SENSOR: "pglab_create_new_entity_sensor", Platform.SWITCH: "pglab_create_new_entity_switch", } @@ -250,6 +252,13 @@ class PGLabDiscovery: ) self._discovered[pglab_device.id] = discovery_info + # Create all new cover entities. + for s in pglab_device.shutters: + # the HA entity is not yet created, send a message to create it + async_dispatcher_send( + hass, CREATE_NEW_ENTITY[Platform.COVER], pglab_device, s + ) + # Create all new relay entities. for r in pglab_device.relays: # The HA entity is not yet created, send a message to create it. diff --git a/homeassistant/components/pglab/strings.json b/homeassistant/components/pglab/strings.json index 4fad408ad98..c6f80d12f09 100644 --- a/homeassistant/components/pglab/strings.json +++ b/homeassistant/components/pglab/strings.json @@ -15,6 +15,11 @@ } }, "entity": { + "cover": { + "shutter": { + "name": "Shutter {shutter_id}" + } + }, "switch": { "relay": { "name": "Relay {relay_id}" diff --git a/tests/components/pglab/test_cover.py b/tests/components/pglab/test_cover.py new file mode 100644 index 00000000000..ea4c7a7213e --- /dev/null +++ b/tests/components/pglab/test_cover.py @@ -0,0 +1,210 @@ +"""The tests for the PG LAB Electronics cover.""" + +import json + +from homeassistant.components import cover +from homeassistant.components.cover import ( + DOMAIN as COVER_DOMAIN, + SERVICE_CLOSE_COVER, + SERVICE_OPEN_COVER, + SERVICE_STOP_COVER, +) +from homeassistant.const import ( + ATTR_ASSUMED_STATE, + STATE_CLOSED, + STATE_CLOSING, + STATE_OPEN, + STATE_OPENING, + STATE_UNKNOWN, +) +from homeassistant.core import HomeAssistant + +from tests.common import async_fire_mqtt_message +from tests.typing import MqttMockHAClient + +COVER_FEATURES = ( + cover.CoverEntityFeature.OPEN + | cover.CoverEntityFeature.CLOSE + | cover.CoverEntityFeature.STOP +) + + +async def call_service(hass: HomeAssistant, entity_id, service, **kwargs): + """Call a service.""" + await hass.services.async_call( + COVER_DOMAIN, + service, + {"entity_id": entity_id, **kwargs}, + blocking=True, + ) + + +async def test_cover_features( + hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_pglab +) -> None: + """Test cover features.""" + topic = "pglab/discovery/E-Board-DD53AC85/config" + payload = { + "ip": "192.168.1.16", + "mac": "80:34:28:1B:18:5A", + "name": "test", + "hw": "1.0.7", + "fw": "1.0.0", + "type": "E-Board", + "id": "E-Board-DD53AC85", + "manufacturer": "PG LAB Electronics", + "params": {"shutters": 4, "boards": "10000000"}, + } + + async_fire_mqtt_message( + hass, + topic, + json.dumps(payload), + ) + await hass.async_block_till_done() + + assert len(hass.states.async_all("cover")) == 4 + + for i in range(4): + cover = hass.states.get(f"cover.test_shutter_{i}") + assert cover + assert cover.attributes["supported_features"] == COVER_FEATURES + + +async def test_cover_availability( + hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_pglab +) -> None: + """Check if covers are properly created.""" + topic = "pglab/discovery/E-Board-DD53AC85/config" + payload = { + "ip": "192.168.1.16", + "mac": "80:34:28:1B:18:5A", + "name": "test", + "hw": "1.0.7", + "fw": "1.0.0", + "type": "E-Board", + "id": "E-Board-DD53AC85", + "manufacturer": "PG LAB Electronics", + "params": {"shutters": 6, "boards": "11000000"}, + } + + async_fire_mqtt_message( + hass, + topic, + json.dumps(payload), + ) + await hass.async_block_till_done() + + # We are creating 6 covers using two E-RELAY devices connected to E-BOARD. + # Now we are going to check if all covers are created and their state is unknown. + for i in range(5): + cover = hass.states.get(f"cover.test_shutter_{i}") + assert cover.state == STATE_UNKNOWN + assert not cover.attributes.get(ATTR_ASSUMED_STATE) + + # The cover with id 7 should not be created. + cover = hass.states.get("cover.test_shutter_7") + assert not cover + + +async def test_cover_change_state_via_mqtt( + hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_pglab +) -> None: + """Test state update via MQTT.""" + topic = "pglab/discovery/E-Board-DD53AC85/config" + payload = { + "ip": "192.168.1.16", + "mac": "80:34:28:1B:18:5A", + "name": "test", + "hw": "1.0.7", + "fw": "1.0.0", + "type": "E-Board", + "id": "E-Board-DD53AC85", + "manufacturer": "PG LAB Electronics", + "params": {"shutters": 2, "boards": "10000000"}, + } + + async_fire_mqtt_message( + hass, + topic, + json.dumps(payload), + ) + await hass.async_block_till_done() + + # Check initial state is unknown + cover = hass.states.get("cover.test_shutter_0") + assert cover.state == STATE_UNKNOWN + assert not cover.attributes.get(ATTR_ASSUMED_STATE) + + # Simulate the device responds sending mqtt messages and check if the cover state + # change appropriately. + + async_fire_mqtt_message(hass, "pglab/test/shutter/0/state", "OPEN") + await hass.async_block_till_done() + cover = hass.states.get("cover.test_shutter_0") + assert not cover.attributes.get(ATTR_ASSUMED_STATE) + assert cover.state == STATE_OPEN + + async_fire_mqtt_message(hass, "pglab/test/shutter/0/state", "OPENING") + await hass.async_block_till_done() + cover = hass.states.get("cover.test_shutter_0") + assert cover.state == STATE_OPENING + + async_fire_mqtt_message(hass, "pglab/test/shutter/0/state", "CLOSING") + await hass.async_block_till_done() + cover = hass.states.get("cover.test_shutter_0") + assert cover.state == STATE_CLOSING + + async_fire_mqtt_message(hass, "pglab/test/shutter/0/state", "CLOSED") + await hass.async_block_till_done() + cover = hass.states.get("cover.test_shutter_0") + assert cover.state == STATE_CLOSED + + +async def test_cover_mqtt_state_by_calling_service( + hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_pglab +) -> None: + """Calling service to OPEN/CLOSE cover and check mqtt state.""" + topic = "pglab/discovery/E-Board-DD53AC85/config" + payload = { + "ip": "192.168.1.16", + "mac": "80:34:28:1B:18:5A", + "name": "test", + "hw": "1.0.7", + "fw": "1.0.0", + "type": "E-Board", + "id": "E-Board-DD53AC85", + "manufacturer": "PG LAB Electronics", + "params": {"shutters": 2, "boards": "10000000"}, + } + + async_fire_mqtt_message( + hass, + topic, + json.dumps(payload), + ) + await hass.async_block_till_done() + + cover = hass.states.get("cover.test_shutter_0") + assert cover.state == STATE_UNKNOWN + assert not cover.attributes.get(ATTR_ASSUMED_STATE) + + # Call HA covers services and verify that the MQTT messages are sent correctly + + await call_service(hass, "cover.test_shutter_0", SERVICE_OPEN_COVER) + mqtt_mock.async_publish.assert_called_once_with( + "pglab/test/shutter/0/set", "OPEN", 0, False + ) + mqtt_mock.async_publish.reset_mock() + + await call_service(hass, "cover.test_shutter_0", SERVICE_STOP_COVER) + mqtt_mock.async_publish.assert_called_once_with( + "pglab/test/shutter/0/set", "STOP", 0, False + ) + mqtt_mock.async_publish.reset_mock() + + await call_service(hass, "cover.test_shutter_0", SERVICE_CLOSE_COVER) + mqtt_mock.async_publish.assert_called_once_with( + "pglab/test/shutter/0/set", "CLOSE", 0, False + ) + mqtt_mock.async_publish.reset_mock() From d20fc3040959614511ed02e83863c297e7008d5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Tue, 25 Mar 2025 10:11:35 +0100 Subject: [PATCH 2992/3148] Add missing events to Home Connect (#141323) * Add missing events to Home Connect * Unsort * Unsort strings also --- .../components/home_connect/sensor.py | 248 ++++++++++++++++++ .../components/home_connect/strings.json | 241 +++++++++++++++++ 2 files changed, 489 insertions(+) diff --git a/homeassistant/components/home_connect/sensor.py b/homeassistant/components/home_connect/sensor.py index 796af8260fc..632a4260f3c 100644 --- a/homeassistant/components/home_connect/sensor.py +++ b/homeassistant/components/home_connect/sensor.py @@ -218,6 +218,62 @@ EVENT_SENSORS = ( translation_key="freezer_temperature_alarm", appliance_types=("FridgeFreezer", "Freezer"), ), + HomeConnectSensorEntityDescription( + key=EventKey.BSH_COMMON_EVENT_PROGRAM_ABORTED, + device_class=SensorDeviceClass.ENUM, + options=EVENT_OPTIONS, + default_value="off", + translation_key="program_aborted", + appliance_types=("Dishwasher", "CleaningRobot", "CookProcessor"), + ), + HomeConnectSensorEntityDescription( + key=EventKey.BSH_COMMON_EVENT_PROGRAM_FINISHED, + device_class=SensorDeviceClass.ENUM, + options=EVENT_OPTIONS, + default_value="off", + translation_key="program_finished", + appliance_types=( + "Oven", + "Dishwasher", + "Washer", + "Dryer", + "WasherDryer", + "CleaningRobot", + "CookProcessor", + ), + ), + HomeConnectSensorEntityDescription( + key=EventKey.BSH_COMMON_EVENT_ALARM_CLOCK_ELAPSED, + device_class=SensorDeviceClass.ENUM, + options=EVENT_OPTIONS, + default_value="off", + translation_key="alarm_clock_elapsed", + appliance_types=("Oven", "Cooktop"), + ), + HomeConnectSensorEntityDescription( + key=EventKey.COOKING_OVEN_EVENT_PREHEAT_FINISHED, + device_class=SensorDeviceClass.ENUM, + options=EVENT_OPTIONS, + default_value="off", + translation_key="preheat_finished", + appliance_types=("Oven", "Cooktop"), + ), + HomeConnectSensorEntityDescription( + key=EventKey.COOKING_OVEN_EVENT_REGULAR_PREHEAT_FINISHED, + device_class=SensorDeviceClass.ENUM, + options=EVENT_OPTIONS, + default_value="off", + translation_key="regular_preheat_finished", + appliance_types=("Oven",), + ), + HomeConnectSensorEntityDescription( + key=EventKey.LAUNDRY_CARE_DRYER_EVENT_DRYING_PROCESS_FINISHED, + device_class=SensorDeviceClass.ENUM, + options=EVENT_OPTIONS, + default_value="off", + translation_key="drying_process_finished", + appliance_types=("Dryer",), + ), HomeConnectSensorEntityDescription( key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_BEAN_CONTAINER_EMPTY, device_class=SensorDeviceClass.ENUM, @@ -242,6 +298,198 @@ EVENT_SENSORS = ( translation_key="drip_tray_full", appliance_types=("CoffeeMaker",), ), + HomeConnectSensorEntityDescription( + key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_KEEP_MILK_TANK_COOL, + device_class=SensorDeviceClass.ENUM, + options=EVENT_OPTIONS, + default_value="off", + translation_key="keep_milk_tank_cool", + appliance_types=("CoffeeMaker",), + ), + HomeConnectSensorEntityDescription( + key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_DESCALING_IN_20_CUPS, + device_class=SensorDeviceClass.ENUM, + options=EVENT_OPTIONS, + default_value="off", + translation_key="descaling_in_20_cups", + appliance_types=("CoffeeMaker",), + ), + HomeConnectSensorEntityDescription( + key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_DESCALING_IN_15_CUPS, + device_class=SensorDeviceClass.ENUM, + options=EVENT_OPTIONS, + default_value="off", + translation_key="descaling_in_15_cups", + appliance_types=("CoffeeMaker",), + ), + HomeConnectSensorEntityDescription( + key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_DESCALING_IN_10_CUPS, + device_class=SensorDeviceClass.ENUM, + options=EVENT_OPTIONS, + default_value="off", + translation_key="descaling_in_10_cups", + appliance_types=("CoffeeMaker",), + ), + HomeConnectSensorEntityDescription( + key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_DESCALING_IN_5_CUPS, + device_class=SensorDeviceClass.ENUM, + options=EVENT_OPTIONS, + default_value="off", + translation_key="descaling_in_5_cups", + appliance_types=("CoffeeMaker",), + ), + HomeConnectSensorEntityDescription( + key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_DEVICE_SHOULD_BE_DESCALED, + device_class=SensorDeviceClass.ENUM, + options=EVENT_OPTIONS, + default_value="off", + translation_key="device_should_be_descaled", + appliance_types=("CoffeeMaker",), + ), + HomeConnectSensorEntityDescription( + key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_DEVICE_DESCALING_OVERDUE, + device_class=SensorDeviceClass.ENUM, + options=EVENT_OPTIONS, + default_value="off", + translation_key="device_descaling_overdue", + appliance_types=("CoffeeMaker",), + ), + HomeConnectSensorEntityDescription( + key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_DEVICE_DESCALING_BLOCKAGE, + device_class=SensorDeviceClass.ENUM, + options=EVENT_OPTIONS, + default_value="off", + translation_key="device_descaling_blockage", + appliance_types=("CoffeeMaker",), + ), + HomeConnectSensorEntityDescription( + key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_DEVICE_SHOULD_BE_CLEANED, + device_class=SensorDeviceClass.ENUM, + options=EVENT_OPTIONS, + default_value="off", + translation_key="device_should_be_cleaned", + appliance_types=("CoffeeMaker",), + ), + HomeConnectSensorEntityDescription( + key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_DEVICE_CLEANING_OVERDUE, + device_class=SensorDeviceClass.ENUM, + options=EVENT_OPTIONS, + default_value="off", + translation_key="device_cleaning_overdue", + appliance_types=("CoffeeMaker",), + ), + HomeConnectSensorEntityDescription( + key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_CALC_N_CLEAN_IN20CUPS, + device_class=SensorDeviceClass.ENUM, + options=EVENT_OPTIONS, + default_value="off", + translation_key="calc_n_clean_in20cups", + appliance_types=("CoffeeMaker",), + ), + HomeConnectSensorEntityDescription( + key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_CALC_N_CLEAN_IN15CUPS, + device_class=SensorDeviceClass.ENUM, + options=EVENT_OPTIONS, + default_value="off", + translation_key="calc_n_clean_in15cups", + appliance_types=("CoffeeMaker",), + ), + HomeConnectSensorEntityDescription( + key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_CALC_N_CLEAN_IN10CUPS, + device_class=SensorDeviceClass.ENUM, + options=EVENT_OPTIONS, + default_value="off", + translation_key="calc_n_clean_in10cups", + appliance_types=("CoffeeMaker",), + ), + HomeConnectSensorEntityDescription( + key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_CALC_N_CLEAN_IN5CUPS, + device_class=SensorDeviceClass.ENUM, + options=EVENT_OPTIONS, + default_value="off", + translation_key="calc_n_clean_in5cups", + appliance_types=("CoffeeMaker",), + ), + HomeConnectSensorEntityDescription( + key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_DEVICE_SHOULD_BE_CALC_N_CLEANED, + device_class=SensorDeviceClass.ENUM, + options=EVENT_OPTIONS, + default_value="off", + translation_key="device_should_be_calc_n_cleaned", + appliance_types=("CoffeeMaker",), + ), + HomeConnectSensorEntityDescription( + key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_DEVICE_CALC_N_CLEAN_OVERDUE, + device_class=SensorDeviceClass.ENUM, + options=EVENT_OPTIONS, + default_value="off", + translation_key="device_calc_n_clean_overdue", + appliance_types=("CoffeeMaker",), + ), + HomeConnectSensorEntityDescription( + key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_DEVICE_CALC_N_CLEAN_BLOCKAGE, + device_class=SensorDeviceClass.ENUM, + options=EVENT_OPTIONS, + default_value="off", + translation_key="device_calc_n_clean_blockage", + appliance_types=("CoffeeMaker",), + ), + HomeConnectSensorEntityDescription( + key=EventKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_EVENT_EMPTY_DUST_BOX_AND_CLEAN_FILTER, + device_class=SensorDeviceClass.ENUM, + options=EVENT_OPTIONS, + default_value="off", + translation_key="empty_dust_box_and_clean_filter", + appliance_types=("CleaningRobot",), + ), + HomeConnectSensorEntityDescription( + key=EventKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_EVENT_ROBOT_IS_STUCK, + device_class=SensorDeviceClass.ENUM, + options=EVENT_OPTIONS, + default_value="off", + translation_key="robot_is_stuck", + appliance_types=("CleaningRobot",), + ), + HomeConnectSensorEntityDescription( + key=EventKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_EVENT_DOCKING_STATION_NOT_FOUND, + device_class=SensorDeviceClass.ENUM, + options=EVENT_OPTIONS, + default_value="off", + translation_key="docking_station_not_found", + appliance_types=("CleaningRobot",), + ), + HomeConnectSensorEntityDescription( + key=EventKey.LAUNDRY_CARE_WASHER_EVENT_I_DOS_1_FILL_LEVEL_POOR, + device_class=SensorDeviceClass.ENUM, + options=EVENT_OPTIONS, + default_value="off", + translation_key="poor_i_dos_1_fill_level", + appliance_types=("Washer", "WasherDryer"), + ), + HomeConnectSensorEntityDescription( + key=EventKey.LAUNDRY_CARE_WASHER_EVENT_I_DOS_2_FILL_LEVEL_POOR, + device_class=SensorDeviceClass.ENUM, + options=EVENT_OPTIONS, + default_value="off", + translation_key="poor_i_dos_2_fill_level", + appliance_types=("Washer", "WasherDryer"), + ), + HomeConnectSensorEntityDescription( + key=EventKey.COOKING_COMMON_EVENT_HOOD_GREASE_FILTER_MAX_SATURATION_NEARLY_REACHED, + device_class=SensorDeviceClass.ENUM, + options=EVENT_OPTIONS, + default_value="off", + translation_key="grease_filter_max_saturation_nearly_reached", + appliance_types=("Hood",), + ), + HomeConnectSensorEntityDescription( + key=EventKey.COOKING_COMMON_EVENT_HOOD_GREASE_FILTER_MAX_SATURATION_REACHED, + device_class=SensorDeviceClass.ENUM, + options=EVENT_OPTIONS, + default_value="off", + translation_key="grease_filter_max_saturation_reached", + appliance_types=("Hood",), + ), HomeConnectSensorEntityDescription( key=EventKey.DISHCARE_DISHWASHER_EVENT_SALT_NEARLY_EMPTY, device_class=SensorDeviceClass.ENUM, diff --git a/homeassistant/components/home_connect/strings.json b/homeassistant/components/home_connect/strings.json index 00ab29affd8..1d7c1c009b1 100644 --- a/homeassistant/components/home_connect/strings.json +++ b/homeassistant/components/home_connect/strings.json @@ -1548,6 +1548,7 @@ "freezer_door_alarm": { "name": "Freezer door alarm", "state": { + "off": "[%key:common::state::off%]", "confirmed": "[%key:component::home_connect::common::confirmed%]", "present": "[%key:component::home_connect::common::present%]" } @@ -1568,6 +1569,54 @@ "present": "[%key:component::home_connect::common::present%]" } }, + "program_aborted": { + "name": "Program aborted", + "state": { + "off": "[%key:common::state::off%]", + "confirmed": "[%key:component::home_connect::common::confirmed%]", + "present": "[%key:component::home_connect::common::present%]" + } + }, + "program_finished": { + "name": "Program finished", + "state": { + "off": "[%key:common::state::off%]", + "confirmed": "[%key:component::home_connect::common::confirmed%]", + "present": "[%key:component::home_connect::common::present%]" + } + }, + "alarm_clock_elapsed": { + "name": "Alarm clock elapsed", + "state": { + "off": "[%key:common::state::off%]", + "confirmed": "[%key:component::home_connect::common::confirmed%]", + "present": "[%key:component::home_connect::common::present%]" + } + }, + "preheat_finished": { + "name": "Pre-heat finished", + "state": { + "off": "[%key:common::state::off%]", + "confirmed": "[%key:component::home_connect::common::confirmed%]", + "present": "[%key:component::home_connect::common::present%]" + } + }, + "regular_preheat_finished": { + "name": "Regular pre-heat finished", + "state": { + "off": "[%key:common::state::off%]", + "confirmed": "[%key:component::home_connect::common::confirmed%]", + "present": "[%key:component::home_connect::common::present%]" + } + }, + "drying_process_finished": { + "name": "Drying process finished", + "state": { + "off": "[%key:common::state::off%]", + "confirmed": "[%key:component::home_connect::common::confirmed%]", + "present": "[%key:component::home_connect::common::present%]" + } + }, "bean_container_empty": { "name": "Bean container empty", "state": { @@ -1592,6 +1641,198 @@ "present": "[%key:component::home_connect::common::present%]" } }, + "keep_milk_tank_cool": { + "name": "Keep milk tank cool", + "state": { + "off": "[%key:common::state::off%]", + "confirmed": "[%key:component::home_connect::common::confirmed%]", + "present": "[%key:component::home_connect::common::present%]" + } + }, + "descaling_in_20_cups": { + "name": "Descaling in 20 cups", + "state": { + "off": "[%key:common::state::off%]", + "confirmed": "[%key:component::home_connect::common::confirmed%]", + "present": "[%key:component::home_connect::common::present%]" + } + }, + "descaling_in_15_cups": { + "name": "Descaling in 15 cups", + "state": { + "off": "[%key:common::state::off%]", + "confirmed": "[%key:component::home_connect::common::confirmed%]", + "present": "[%key:component::home_connect::common::present%]" + } + }, + "descaling_in_10_cups": { + "name": "Descaling in 10 cups", + "state": { + "off": "[%key:common::state::off%]", + "confirmed": "[%key:component::home_connect::common::confirmed%]", + "present": "[%key:component::home_connect::common::present%]" + } + }, + "descaling_in_5_cups": { + "name": "Descaling in 5 cups", + "state": { + "off": "[%key:common::state::off%]", + "confirmed": "[%key:component::home_connect::common::confirmed%]", + "present": "[%key:component::home_connect::common::present%]" + } + }, + "device_should_be_descaled": { + "name": "Device should be descaled", + "state": { + "off": "[%key:common::state::off%]", + "confirmed": "[%key:component::home_connect::common::confirmed%]", + "present": "[%key:component::home_connect::common::present%]" + } + }, + "device_descaling_overdue": { + "name": "Device descaling overdue", + "state": { + "off": "[%key:common::state::off%]", + "confirmed": "[%key:component::home_connect::common::confirmed%]", + "present": "[%key:component::home_connect::common::present%]" + } + }, + "device_descaling_blockage": { + "name": "Device descaling blockage", + "state": { + "off": "[%key:common::state::off%]", + "confirmed": "[%key:component::home_connect::common::confirmed%]", + "present": "[%key:component::home_connect::common::present%]" + } + }, + "device_should_be_cleaned": { + "name": "Device should be cleaned", + "state": { + "off": "[%key:common::state::off%]", + "confirmed": "[%key:component::home_connect::common::confirmed%]", + "present": "[%key:component::home_connect::common::present%]" + } + }, + "device_cleaning_overdue": { + "name": "Device cleaning overdue", + "state": { + "off": "[%key:common::state::off%]", + "confirmed": "[%key:component::home_connect::common::confirmed%]", + "present": "[%key:component::home_connect::common::present%]" + } + }, + "calc_n_clean_in20cups": { + "name": "Calc'N'Clean in 20 cups", + "state": { + "off": "[%key:common::state::off%]", + "confirmed": "[%key:component::home_connect::common::confirmed%]", + "present": "[%key:component::home_connect::common::present%]" + } + }, + "calc_n_clean_in15cups": { + "name": "Calc'N'Clean in 15 cups", + "state": { + "off": "[%key:common::state::off%]", + "confirmed": "[%key:component::home_connect::common::confirmed%]", + "present": "[%key:component::home_connect::common::present%]" + } + }, + "calc_n_clean_in10cups": { + "name": "Calc'N'Clean in 10 cups", + "state": { + "off": "[%key:common::state::off%]", + "confirmed": "[%key:component::home_connect::common::confirmed%]", + "present": "[%key:component::home_connect::common::present%]" + } + }, + "calc_n_clean_in5cups": { + "name": "Calc'N'Clean in 5 cups", + "state": { + "off": "[%key:common::state::off%]", + "confirmed": "[%key:component::home_connect::common::confirmed%]", + "present": "[%key:component::home_connect::common::present%]" + } + }, + "device_should_be_calc_n_cleaned": { + "name": "Device should be Calc'N'Cleaned", + "state": { + "off": "[%key:common::state::off%]", + "confirmed": "[%key:component::home_connect::common::confirmed%]", + "present": "[%key:component::home_connect::common::present%]" + } + }, + "device_calc_n_clean_overdue": { + "name": "Device Calc'N'Clean overdue", + "state": { + "off": "[%key:common::state::off%]", + "confirmed": "[%key:component::home_connect::common::confirmed%]", + "present": "[%key:component::home_connect::common::present%]" + } + }, + "device_calc_n_clean_blockage": { + "name": "Device Calc'N'Clean blockage", + "state": { + "off": "[%key:common::state::off%]", + "confirmed": "[%key:component::home_connect::common::confirmed%]", + "present": "[%key:component::home_connect::common::present%]" + } + }, + "empty_dust_box_and_clean_filter": { + "name": "Empty dust box and clean filter", + "state": { + "off": "[%key:common::state::off%]", + "confirmed": "[%key:component::home_connect::common::confirmed%]", + "present": "[%key:component::home_connect::common::present%]" + } + }, + "robot_is_stuck": { + "name": "Robot is stuck", + "state": { + "off": "[%key:common::state::off%]", + "confirmed": "[%key:component::home_connect::common::confirmed%]", + "present": "[%key:component::home_connect::common::present%]" + } + }, + "docking_station_not_found": { + "name": "Docking station not found", + "state": { + "off": "[%key:common::state::off%]", + "confirmed": "[%key:component::home_connect::common::confirmed%]", + "present": "[%key:component::home_connect::common::present%]" + } + }, + "poor_i_dos_1_fill_level": { + "name": "Poor i-Dos 1 fill level", + "state": { + "off": "[%key:common::state::off%]", + "confirmed": "[%key:component::home_connect::common::confirmed%]", + "present": "[%key:component::home_connect::common::present%]" + } + }, + "poor_i_dos_2_fill_level": { + "name": "Poor i-Dos 2 fill level", + "state": { + "off": "[%key:common::state::off%]", + "confirmed": "[%key:component::home_connect::common::confirmed%]", + "present": "[%key:component::home_connect::common::present%]" + } + }, + "grease_filter_max_saturation_nearly_reached": { + "name": "Grease filter max saturation nearly reached", + "state": { + "off": "[%key:common::state::off%]", + "confirmed": "[%key:component::home_connect::common::confirmed%]", + "present": "[%key:component::home_connect::common::present%]" + } + }, + "grease_filter_max_saturation_reached": { + "name": "Grease filter max saturation reached", + "state": { + "off": "[%key:common::state::off%]", + "confirmed": "[%key:component::home_connect::common::confirmed%]", + "present": "[%key:component::home_connect::common::present%]" + } + }, "salt_nearly_empty": { "name": "Salt nearly empty", "state": { From 348ebe14021a52a6cd8d36dea84cc30cfec3a930 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Tue, 25 Mar 2025 10:27:21 +0100 Subject: [PATCH 2993/3148] Adds `create_daily` action to Habitica integration (#140684) Add create_daily action --- homeassistant/components/habitica/const.py | 1 + homeassistant/components/habitica/icons.json | 8 + homeassistant/components/habitica/services.py | 9 +- .../components/habitica/services.yaml | 29 ++- .../components/habitica/strings.json | 110 +++++++++-- tests/components/habitica/test_services.py | 184 +++++++++++++++++- 6 files changed, 319 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/habitica/const.py b/homeassistant/components/habitica/const.py index 8b745ff2b99..7a5677cb687 100644 --- a/homeassistant/components/habitica/const.py +++ b/homeassistant/components/habitica/const.py @@ -79,6 +79,7 @@ SERVICE_CREATE_HABIT = "create_habit" SERVICE_UPDATE_TODO = "update_todo" SERVICE_CREATE_TODO = "create_todo" SERVICE_UPDATE_DAILY = "update_daily" +SERVICE_CREATE_DAILY = "create_daily" DEVELOPER_ID = "4c4ca53f-c059-4ffa-966e-9d29dd405daf" X_CLIENT = f"{DEVELOPER_ID} - {APPLICATION_NAME} {__version__}" diff --git a/homeassistant/components/habitica/icons.json b/homeassistant/components/habitica/icons.json index fcb9ec56fa7..aac90814af5 100644 --- a/homeassistant/components/habitica/icons.json +++ b/homeassistant/components/habitica/icons.json @@ -270,6 +270,14 @@ "repeat_weekly_options": "mdi:calendar-refresh", "repeat_monthly_options": "mdi:calendar-refresh" } + }, + "create_daily": { + "service": "mdi:calendar-month", + "sections": { + "developer_options": "mdi:test-tube", + "repeat_weekly_options": "mdi:calendar-refresh", + "repeat_monthly_options": "mdi:calendar-refresh" + } } } } diff --git a/homeassistant/components/habitica/services.py b/homeassistant/components/habitica/services.py index 9fb0b0b7537..bcbd6caa7a7 100644 --- a/homeassistant/components/habitica/services.py +++ b/homeassistant/components/habitica/services.py @@ -84,6 +84,7 @@ from .const import ( SERVICE_API_CALL, SERVICE_CANCEL_QUEST, SERVICE_CAST_SKILL, + SERVICE_CREATE_DAILY, SERVICE_CREATE_HABIT, SERVICE_CREATE_REWARD, SERVICE_CREATE_TODO, @@ -243,6 +244,7 @@ SERVICE_TASK_TYPE_MAP = { SERVICE_UPDATE_TODO: TaskType.TODO, SERVICE_CREATE_TODO: TaskType.TODO, SERVICE_UPDATE_DAILY: TaskType.DAILY, + SERVICE_CREATE_DAILY: TaskType.DAILY, } @@ -913,7 +915,12 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 schema=SERVICE_UPDATE_TASK_SCHEMA, supports_response=SupportsResponse.ONLY, ) - for service in (SERVICE_CREATE_HABIT, SERVICE_CREATE_REWARD, SERVICE_CREATE_TODO): + for service in ( + SERVICE_CREATE_DAILY, + SERVICE_CREATE_HABIT, + SERVICE_CREATE_REWARD, + SERVICE_CREATE_TODO, + ): hass.services.async_register( DOMAIN, service, diff --git a/homeassistant/components/habitica/services.yaml b/homeassistant/components/habitica/services.yaml index 46b3211790e..3fb25e2b4b7 100644 --- a/homeassistant/components/habitica/services.yaml +++ b/homeassistant/components/habitica/services.yaml @@ -347,11 +347,11 @@ update_daily: notes: *notes checklist_options: *checklist_options priority: *priority - start_date: + start_date: &start_date required: false selector: date: - frequency: + frequency: &frequency_daily required: false selector: select: @@ -362,7 +362,7 @@ update_daily: - "yearly" translation_key: "frequency" mode: dropdown - every_x: + every_x: &every_x required: false selector: number: @@ -370,7 +370,7 @@ update_daily: step: 1 unit_of_measurement: "🔃" mode: box - repeat_weekly_options: + repeat_weekly_options: &repeat_weekly_options collapsed: true fields: repeat: @@ -388,7 +388,7 @@ update_daily: mode: list translation_key: repeat multiple: true - repeat_monthly_options: + repeat_monthly_options: &repeat_monthly_options collapsed: true fields: repeat_monthly: @@ -403,7 +403,7 @@ update_daily: reminder_options: collapsed: true fields: - reminder: + reminder: &reminder_daily required: false selector: text: @@ -420,7 +420,7 @@ update_daily: developer_options: collapsed: true fields: - streak: + streak: &streak required: false selector: number: @@ -429,3 +429,18 @@ update_daily: unit_of_measurement: "▶▶" mode: box alias: *alias +create_daily: + fields: + config_entry: *config_entry + name: *name + notes: *notes + add_checklist_item: *add_checklist_item + priority: *priority + start_date: *start_date + frequency: *frequency_daily + every_x: *every_x + repeat_weekly_options: *repeat_weekly_options + repeat_monthly_options: *repeat_monthly_options + reminder: *reminder_daily + tag: *tag + developer_options: *developer_options diff --git a/homeassistant/components/habitica/strings.json b/homeassistant/components/habitica/strings.json index fac0fdf3868..695eb1576fe 100644 --- a/homeassistant/components/habitica/strings.json +++ b/homeassistant/components/habitica/strings.json @@ -52,7 +52,19 @@ "reminder_options_description": "Add, remove or clear reminders of a Habitica task.", "date_name": "Due date", "date_description": "The to-do's due date.", - "repeat_name": "Repeat on" + "repeat_name": "Repeat on", + "start_date_name": "Start date", + "start_date_description": "Defines when the daily task becomes active and specifies the exact weekday or day of the month it repeats on.", + "frequency_daily_name": "Repeat interval", + "frequency_daily_description": "The repetition interval of a daily.", + "every_x_name": "Repeat every X", + "every_x_description": "The number of intervals (days, weeks, months, or years) after which the daily repeats, based on the chosen repetition interval. A value of 0 makes the daily inactive ('Grey Daily').", + "repeat_weekly_description": "The days of the week the daily repeats.", + "repeat_monthly_description": "Whether a monthly recurring task repeats on the same calendar day each month or on the same weekday and week of the month, based on the start date.", + "repeat_weekly_options_name": "Weekly repeat days", + "repeat_weekly_options_description": "Options related to weekly repetition, applicable when the repetition interval is set to weekly.", + "repeat_monthly_options_name": "Monthly repeat day", + "repeat_monthly_options_description": "Options related to monthly repetition, applicable when the repetition interval is set to monthly." }, "config": { "abort": { @@ -1076,24 +1088,24 @@ "description": "[%key:component::habitica::common::priority_description%]" }, "start_date": { - "name": "Start date", - "description": "Defines when the daily task becomes active and specifies the exact weekday or day of the month it repeats on." + "name": "[%key:component::habitica::common::start_date_name%]", + "description": "[%key:component::habitica::common::start_date_description%]" }, "frequency": { - "name": "Repeat interval", - "description": "The repetition interval of a daily." + "name": "[%key:component::habitica::common::frequency_daily_name%]", + "description": "[%key:component::habitica::common::frequency_daily_description%]" }, "every_x": { - "name": "Repeat every X", - "description": "The number of intervals (days, weeks, months, or years) after which the daily repeats, based on the chosen repetition interval. A value of 0 makes the daily inactive ('Grey Daily')." + "name": "[%key:component::habitica::common::every_x_name%]", + "description": "[%key:component::habitica::common::every_x_description%]" }, "repeat": { "name": "[%key:component::habitica::common::repeat_name%]", - "description": "The days of the week the daily repeats." + "description": "[%key:component::habitica::common::repeat_weekly_description%]" }, "repeat_monthly": { "name": "[%key:component::habitica::common::repeat_name%]", - "description": "Whether a monthly recurring task repeats on the same calendar day each month or on the same weekday and week of the month, based on the start date." + "description": "[%key:component::habitica::common::repeat_monthly_description%]" }, "add_checklist_item": { "name": "[%key:component::habitica::common::add_checklist_item_name%]", @@ -1134,12 +1146,12 @@ "description": "[%key:component::habitica::common::checklist_options_description%]" }, "repeat_weekly_options": { - "name": "Weekly repeat days", - "description": "Options related to weekly repetition, applicable when the repetition interval is set to weekly." + "name": "[%key:component::habitica::common::repeat_weekly_options_name%]", + "description": "[%key:component::habitica::common::repeat_weekly_options_description%]" }, "repeat_monthly_options": { - "name": "Monthly repeat day", - "description": "Options related to monthly repetition, applicable when the repetition interval is set to monthly." + "name": "[%key:component::habitica::common::repeat_monthly_options_name%]", + "description": "[%key:component::habitica::common::repeat_monthly_options_description%]" }, "tag_options": { "name": "[%key:component::habitica::common::tag_options_name%]", @@ -1154,6 +1166,78 @@ "description": "[%key:component::habitica::common::reminder_options_description%]" } } + }, + "create_daily": { + "name": "Create a daily", + "description": "Adds a new daily.", + "fields": { + "config_entry": { + "name": "[%key:component::habitica::common::config_entry_name%]", + "description": "[%key:component::habitica::common::config_entry_description%]" + }, + "name": { + "name": "[%key:component::habitica::common::task_name%]", + "description": "[%key:component::habitica::common::name_description%]" + }, + "notes": { + "name": "[%key:component::habitica::common::notes_name%]", + "description": "[%key:component::habitica::common::notes_description%]" + }, + "tag": { + "name": "[%key:component::habitica::common::tag_options_name%]", + "description": "[%key:component::habitica::common::tag_description%]" + }, + "alias": { + "name": "[%key:component::habitica::common::alias_name%]", + "description": "[%key:component::habitica::common::alias_description%]" + }, + "priority": { + "name": "[%key:component::habitica::common::priority_name%]", + "description": "[%key:component::habitica::common::priority_description%]" + }, + "start_date": { + "name": "[%key:component::habitica::common::start_date_name%]", + "description": "[%key:component::habitica::common::start_date_description%]" + }, + "frequency": { + "name": "[%key:component::habitica::common::frequency_daily_name%]", + "description": "[%key:component::habitica::common::frequency_daily_description%]" + }, + "every_x": { + "name": "[%key:component::habitica::common::every_x_name%]", + "description": "[%key:component::habitica::common::every_x_description%]" + }, + "repeat": { + "name": "[%key:component::habitica::common::repeat_name%]", + "description": "[%key:component::habitica::common::repeat_weekly_description%]" + }, + "repeat_monthly": { + "name": "[%key:component::habitica::common::repeat_name%]", + "description": "[%key:component::habitica::common::repeat_monthly_description%]" + }, + "add_checklist_item": { + "name": "[%key:component::habitica::common::checklist_options_name%]", + "description": "[%key:component::habitica::common::add_checklist_item_description%]" + }, + "reminder": { + "name": "[%key:component::habitica::common::reminder_options_name%]", + "description": "[%key:component::habitica::common::reminder_description%]" + } + }, + "sections": { + "repeat_weekly_options": { + "name": "[%key:component::habitica::common::repeat_weekly_options_name%]", + "description": "[%key:component::habitica::common::repeat_weekly_options_description%]" + }, + "repeat_monthly_options": { + "name": "[%key:component::habitica::common::repeat_monthly_options_name%]", + "description": "[%key:component::habitica::common::repeat_monthly_options_description%]" + }, + "developer_options": { + "name": "[%key:component::habitica::common::developer_options_name%]", + "description": "[%key:component::habitica::common::developer_options_description%]" + } + } } }, "selector": { diff --git a/tests/components/habitica/test_services.py b/tests/components/habitica/test_services.py index 258346b9ca7..774593fa0f6 100644 --- a/tests/components/habitica/test_services.py +++ b/tests/components/habitica/test_services.py @@ -60,6 +60,7 @@ from homeassistant.components.habitica.const import ( SERVICE_ACCEPT_QUEST, SERVICE_CANCEL_QUEST, SERVICE_CAST_SKILL, + SERVICE_CREATE_DAILY, SERVICE_CREATE_HABIT, SERVICE_CREATE_REWARD, SERVICE_CREATE_TODO, @@ -1012,7 +1013,12 @@ async def test_update_task_exceptions( ) @pytest.mark.parametrize( "service", - [SERVICE_CREATE_REWARD, SERVICE_CREATE_HABIT, SERVICE_CREATE_TODO], + [ + SERVICE_CREATE_DAILY, + SERVICE_CREATE_HABIT, + SERVICE_CREATE_REWARD, + SERVICE_CREATE_TODO, + ], ) @pytest.mark.usefixtures("habitica") async def test_create_task_exceptions( @@ -1837,6 +1843,182 @@ async def test_update_daily( habitica.update_task.assert_awaited_with(UUID(task_id), call_args) +@pytest.mark.parametrize( + ("service_data", "call_args"), + [ + ( + { + ATTR_NAME: "TITLE", + }, + Task(type=TaskType.DAILY, text="TITLE"), + ), + ( + { + ATTR_NAME: "TITLE", + ATTR_NOTES: "NOTES", + }, + Task(type=TaskType.DAILY, text="TITLE", notes="NOTES"), + ), + ( + { + ATTR_NAME: "TITLE", + ATTR_ADD_CHECKLIST_ITEM: "Checklist-item", + }, + Task( + type=TaskType.DAILY, + text="TITLE", + checklist=[ + Checklist( + id=UUID("12345678-1234-5678-1234-567812345678"), + text="Checklist-item", + completed=False, + ), + ], + ), + ), + ( + { + ATTR_NAME: "TITLE", + ATTR_PRIORITY: "trivial", + }, + Task(type=TaskType.DAILY, text="TITLE", priority=TaskPriority.TRIVIAL), + ), + ( + { + ATTR_NAME: "TITLE", + ATTR_START_DATE: "2025-03-05", + }, + Task(type=TaskType.DAILY, text="TITLE", startDate=datetime(2025, 3, 5)), + ), + ( + { + ATTR_NAME: "TITLE", + ATTR_FREQUENCY: "weekly", + }, + Task(type=TaskType.DAILY, text="TITLE", frequency=Frequency.WEEKLY), + ), + ( + { + ATTR_NAME: "TITLE", + ATTR_INTERVAL: 5, + }, + Task(type=TaskType.DAILY, text="TITLE", everyX=5), + ), + ( + { + ATTR_NAME: "TITLE", + ATTR_FREQUENCY: "weekly", + ATTR_REPEAT: ["m", "t", "w", "th"], + }, + Task( + type=TaskType.DAILY, + text="TITLE", + frequency=Frequency.WEEKLY, + repeat=Repeat(m=True, t=True, w=True, th=True), + ), + ), + ( + { + ATTR_NAME: "TITLE", + ATTR_FREQUENCY: "monthly", + ATTR_REPEAT_MONTHLY: "day_of_month", + }, + Task( + type=TaskType.DAILY, + text="TITLE", + frequency=Frequency.MONTHLY, + daysOfMonth=[25], + weeksOfMonth=[], + ), + ), + ( + { + ATTR_NAME: "TITLE", + ATTR_FREQUENCY: "monthly", + ATTR_REPEAT_MONTHLY: "day_of_week", + }, + Task( + type=TaskType.DAILY, + text="TITLE", + frequency=Frequency.MONTHLY, + daysOfMonth=[], + weeksOfMonth=[3], + repeat=Repeat( + m=False, t=True, w=False, th=False, f=False, s=False, su=False + ), + ), + ), + ( + { + ATTR_NAME: "TITLE", + ATTR_REMINDER: ["10:00"], + }, + Task( + type=TaskType.DAILY, + text="TITLE", + reminders=[ + Reminders( + id=UUID("12345678-1234-5678-1234-567812345678"), + time=datetime(2025, 2, 25, 10, 0, tzinfo=UTC), + startDate=None, + ) + ], + ), + ), + ( + { + ATTR_NAME: "TITLE", + ATTR_REMOVE_REMINDER: ["10:00"], + }, + Task(type=TaskType.DAILY, text="TITLE", reminders=[]), + ), + ( + { + ATTR_NAME: "TITLE", + ATTR_CLEAR_REMINDER: True, + }, + Task(type=TaskType.DAILY, text="TITLE", reminders=[]), + ), + ( + { + ATTR_NAME: "TITLE", + ATTR_STREAK: 10, + }, + Task(type=TaskType.DAILY, text="TITLE", streak=10), + ), + ( + { + ATTR_NAME: "TITLE", + ATTR_ALIAS: "ALIAS", + }, + Task(type=TaskType.DAILY, text="TITLE", alias="ALIAS"), + ), + ], +) +@pytest.mark.usefixtures("mock_uuid4") +@freeze_time("2025-02-25T22:00:00.000Z") +async def test_create_daily( + hass: HomeAssistant, + config_entry: MockConfigEntry, + habitica: AsyncMock, + service_data: dict[str, Any], + call_args: Task, +) -> None: + """Test Habitica create daily action.""" + + await hass.services.async_call( + DOMAIN, + SERVICE_CREATE_DAILY, + service_data={ + ATTR_CONFIG_ENTRY: config_entry.entry_id, + **service_data, + }, + return_response=True, + blocking=True, + ) + habitica.create_task.assert_awaited_with(call_args) + + @pytest.mark.parametrize( "service_data", [ From 615afeb4d5dfafad9d03755cc228bd05bc6dce65 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 25 Mar 2025 10:34:05 +0100 Subject: [PATCH 2994/3148] Log bare exceptions in the config flow (#135584) * Log bare exceptions in the config flow * add more * Fix --- .../components/airthings_ble/config_flow.py | 6 ++++-- homeassistant/components/airtouch5/config_flow.py | 3 ++- homeassistant/components/anova/config_flow.py | 9 +++++++-- homeassistant/components/aquacell/config_flow.py | 2 +- homeassistant/components/chacon_dio/config_flow.py | 2 +- homeassistant/components/deluge/config_flow.py | 6 +++++- homeassistant/components/dexcom/config_flow.py | 6 +++++- homeassistant/components/eheimdigital/config_flow.py | 1 + homeassistant/components/enigma2/config_flow.py | 6 +++++- homeassistant/components/fronius/config_flow.py | 2 +- .../components/frontier_silicon/config_flow.py | 4 ++-- .../components/fujitsu_fglair/config_flow.py | 2 +- homeassistant/components/fyta/config_flow.py | 4 ++-- homeassistant/components/gogogate2/config_flow.py | 6 +++++- homeassistant/components/hko/config_flow.py | 6 +++++- homeassistant/components/homee/config_flow.py | 2 +- homeassistant/components/huawei_lte/config_flow.py | 8 ++++---- .../components/husqvarna_automower/config_flow.py | 3 ++- homeassistant/components/imgw_pib/config_flow.py | 2 +- homeassistant/components/incomfort/config_flow.py | 5 ++++- homeassistant/components/lastfm/config_flow.py | 6 +++++- homeassistant/components/meater/config_flow.py | 6 +++++- .../components/motion_blinds/config_flow.py | 6 +++++- homeassistant/components/mullvad/config_flow.py | 6 +++++- homeassistant/components/mutesync/config_flow.py | 6 +++++- homeassistant/components/nasweb/config_flow.py | 2 +- homeassistant/components/nextdns/config_flow.py | 9 +++++++-- .../components/niko_home_control/config_flow.py | 6 +++++- homeassistant/components/octoprint/config_flow.py | 3 ++- homeassistant/components/progettihwsw/config_flow.py | 6 +++++- homeassistant/components/qnap/config_flow.py | 4 ++-- homeassistant/components/rabbitair/config_flow.py | 4 ++-- homeassistant/components/renault/config_flow.py | 6 +++++- homeassistant/components/skybell/config_flow.py | 6 +++++- homeassistant/components/smarty/config_flow.py | 6 +++++- homeassistant/components/spotify/config_flow.py | 3 ++- homeassistant/components/squeezebox/config_flow.py | 3 ++- .../components/swiss_public_transport/config_flow.py | 2 +- .../components/trafikverket_ferry/config_flow.py | 9 +++++++-- .../components/trafikverket_train/config_flow.py | 4 ++-- .../trafikverket_weatherstation/config_flow.py | 12 +++++++++--- homeassistant/components/triggercmd/config_flow.py | 2 +- homeassistant/components/vallox/config_flow.py | 2 +- homeassistant/components/vilfo/config_flow.py | 4 ++-- homeassistant/components/webdav/config_flow.py | 2 +- 45 files changed, 151 insertions(+), 59 deletions(-) diff --git a/homeassistant/components/airthings_ble/config_flow.py b/homeassistant/components/airthings_ble/config_flow.py index 3e7b659bff1..2d32fa6e7df 100644 --- a/homeassistant/components/airthings_ble/config_flow.py +++ b/homeassistant/components/airthings_ble/config_flow.py @@ -102,7 +102,8 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN): device = await self._get_device_data(discovery_info) except AirthingsDeviceUpdateError: return self.async_abort(reason="cannot_connect") - except Exception: # noqa: BLE001 + except Exception: + _LOGGER.exception("Unknown error occurred") return self.async_abort(reason="unknown") name = get_name(device) @@ -160,7 +161,8 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN): device = await self._get_device_data(discovery_info) except AirthingsDeviceUpdateError: return self.async_abort(reason="cannot_connect") - except Exception: # noqa: BLE001 + except Exception: + _LOGGER.exception("Unknown error occurred") return self.async_abort(reason="unknown") name = get_name(device) self._discovered_devices[address] = Discovery(name, discovery_info, device) diff --git a/homeassistant/components/airtouch5/config_flow.py b/homeassistant/components/airtouch5/config_flow.py index d96aaed96b7..38c85e45fb8 100644 --- a/homeassistant/components/airtouch5/config_flow.py +++ b/homeassistant/components/airtouch5/config_flow.py @@ -32,7 +32,8 @@ class AirTouch5ConfigFlow(ConfigFlow, domain=DOMAIN): client = Airtouch5SimpleClient(user_input[CONF_HOST]) try: await client.test_connection() - except Exception: # noqa: BLE001 + except Exception: + _LOGGER.exception("Unexpected exception") errors = {"base": "cannot_connect"} else: await self.async_set_unique_id(user_input[CONF_HOST]) diff --git a/homeassistant/components/anova/config_flow.py b/homeassistant/components/anova/config_flow.py index bc4723b1dba..f382606baba 100644 --- a/homeassistant/components/anova/config_flow.py +++ b/homeassistant/components/anova/config_flow.py @@ -2,6 +2,8 @@ from __future__ import annotations +import logging + from anova_wifi import AnovaApi, InvalidLogin import voluptuous as vol @@ -11,8 +13,10 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DOMAIN +_LOGGER = logging.getLogger(__name__) -class AnovaConfligFlow(ConfigFlow, domain=DOMAIN): + +class AnovaConfigFlow(ConfigFlow, domain=DOMAIN): """Sets up a config flow for Anova.""" VERSION = 1 @@ -35,7 +39,8 @@ class AnovaConfligFlow(ConfigFlow, domain=DOMAIN): await api.authenticate() except InvalidLogin: errors["base"] = "invalid_auth" - except Exception: # noqa: BLE001 + except Exception: + _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: return self.async_create_entry( diff --git a/homeassistant/components/aquacell/config_flow.py b/homeassistant/components/aquacell/config_flow.py index 1ee89035d93..277cb742486 100644 --- a/homeassistant/components/aquacell/config_flow.py +++ b/homeassistant/components/aquacell/config_flow.py @@ -60,7 +60,7 @@ class AquaCellConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "cannot_connect" except AuthenticationFailed: errors["base"] = "invalid_auth" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: diff --git a/homeassistant/components/chacon_dio/config_flow.py b/homeassistant/components/chacon_dio/config_flow.py index 54604b81153..daaf38e0edc 100644 --- a/homeassistant/components/chacon_dio/config_flow.py +++ b/homeassistant/components/chacon_dio/config_flow.py @@ -44,7 +44,7 @@ class ChaconDioConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "cannot_connect" except DIOChaconInvalidAuthError: errors["base"] = "invalid_auth" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" diff --git a/homeassistant/components/deluge/config_flow.py b/homeassistant/components/deluge/config_flow.py index 19afe26e8f9..78eced64c7c 100644 --- a/homeassistant/components/deluge/config_flow.py +++ b/homeassistant/components/deluge/config_flow.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Mapping +import logging from ssl import SSLError from typing import Any @@ -21,6 +22,8 @@ from .const import ( DOMAIN, ) +_LOGGER = logging.getLogger(__name__) + class DelugeFlowHandler(ConfigFlow, domain=DOMAIN): """Handle a config flow for Deluge.""" @@ -86,7 +89,8 @@ class DelugeFlowHandler(ConfigFlow, domain=DOMAIN): await self.hass.async_add_executor_job(api.connect) except (ConnectionRefusedError, TimeoutError, SSLError): return "cannot_connect" - except Exception as ex: # noqa: BLE001 + except Exception as ex: + _LOGGER.exception("Unexpected error") if type(ex).__name__ == "BadLoginError": return "invalid_auth" return "unknown" diff --git a/homeassistant/components/dexcom/config_flow.py b/homeassistant/components/dexcom/config_flow.py index 90917e0ce2c..ed6dc94e764 100644 --- a/homeassistant/components/dexcom/config_flow.py +++ b/homeassistant/components/dexcom/config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations +import logging from typing import Any from pydexcom import AccountError, Dexcom, SessionError @@ -12,6 +13,8 @@ from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from .const import CONF_SERVER, DOMAIN, SERVER_OUS, SERVER_US +_LOGGER = logging.getLogger(__name__) + DATA_SCHEMA = vol.Schema( { vol.Required(CONF_USERNAME): str, @@ -43,7 +46,8 @@ class DexcomConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "cannot_connect" except AccountError: errors["base"] = "invalid_auth" - except Exception: # noqa: BLE001 + except Exception: + _LOGGER.exception("Unexpected error") errors["base"] = "unknown" if "base" not in errors: diff --git a/homeassistant/components/eheimdigital/config_flow.py b/homeassistant/components/eheimdigital/config_flow.py index c6535608b0c..b0432267c8e 100644 --- a/homeassistant/components/eheimdigital/config_flow.py +++ b/homeassistant/components/eheimdigital/config_flow.py @@ -62,6 +62,7 @@ class EheimDigitalConfigFlow(ConfigFlow, domain=DOMAIN): except (ClientError, TimeoutError): return self.async_abort(reason="cannot_connect") except Exception: # noqa: BLE001 + LOGGER.exception("Unknown exception occurred") return self.async_abort(reason="unknown") await self.async_set_unique_id(hub.main.mac_address) self._abort_if_unique_id_configured(updates={CONF_HOST: host}) diff --git a/homeassistant/components/enigma2/config_flow.py b/homeassistant/components/enigma2/config_flow.py index b0649a8368d..876d55128cf 100644 --- a/homeassistant/components/enigma2/config_flow.py +++ b/homeassistant/components/enigma2/config_flow.py @@ -1,5 +1,6 @@ """Config flow for Enigma2.""" +import logging from typing import Any, cast from aiohttp.client_exceptions import ClientError @@ -63,6 +64,8 @@ CONFIG_SCHEMA = vol.Schema( } ) +_LOGGER = logging.getLogger(__name__) + async def get_options_schema(handler: SchemaCommonFlowHandler) -> vol.Schema: """Get the options schema.""" @@ -130,7 +133,8 @@ class Enigma2ConfigFlowHandler(ConfigFlow, domain=DOMAIN): errors = {"base": "invalid_auth"} except ClientError: errors = {"base": "cannot_connect"} - except Exception: # noqa: BLE001 + except Exception: + _LOGGER.exception("Unexpected exception") errors = {"base": "unknown"} else: unique_id = about["info"]["ifaces"][0]["mac"] or self.unique_id diff --git a/homeassistant/components/fronius/config_flow.py b/homeassistant/components/fronius/config_flow.py index f35c9ce5bc1..b8aa2da81c6 100644 --- a/homeassistant/components/fronius/config_flow.py +++ b/homeassistant/components/fronius/config_flow.py @@ -149,7 +149,7 @@ class FroniusConfigFlow(ConfigFlow, domain=DOMAIN): unique_id, info = await validate_host(self.hass, user_input[CONF_HOST]) except CannotConnect: errors["base"] = "cannot_connect" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: diff --git a/homeassistant/components/frontier_silicon/config_flow.py b/homeassistant/components/frontier_silicon/config_flow.py index f6514da28ff..dc4f6bea989 100644 --- a/homeassistant/components/frontier_silicon/config_flow.py +++ b/homeassistant/components/frontier_silicon/config_flow.py @@ -108,8 +108,8 @@ class FrontierSiliconConfigFlow(ConfigFlow, domain=DOMAIN): self._webfsapi_url = await AFSAPI.get_webfsapi_endpoint(device_url) except FSConnectionError: return self.async_abort(reason="cannot_connect") - except Exception as exception: # noqa: BLE001 - _LOGGER.debug(exception) + except Exception: + _LOGGER.exception("Unexpected exception") return self.async_abort(reason="unknown") # try to login with default pin diff --git a/homeassistant/components/fujitsu_fglair/config_flow.py b/homeassistant/components/fujitsu_fglair/config_flow.py index c4b097ff0de..9369fd7b7cd 100644 --- a/homeassistant/components/fujitsu_fglair/config_flow.py +++ b/homeassistant/components/fujitsu_fglair/config_flow.py @@ -62,7 +62,7 @@ class FGLairConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "cannot_connect" except AylaAuthError: errors["base"] = "invalid_auth" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" diff --git a/homeassistant/components/fyta/config_flow.py b/homeassistant/components/fyta/config_flow.py index 78cb7647785..9c5ab1de405 100644 --- a/homeassistant/components/fyta/config_flow.py +++ b/homeassistant/components/fyta/config_flow.py @@ -65,8 +65,8 @@ class FytaConfigFlow(ConfigFlow, domain=DOMAIN): return {"base": "invalid_auth"} except FytaPasswordError: return {"base": "invalid_auth", CONF_PASSWORD: "password_error"} - except Exception as e: # noqa: BLE001 - _LOGGER.error(e) + except Exception: + _LOGGER.exception("Unexpected exception") return {"base": "unknown"} finally: await fyta.client.close() diff --git a/homeassistant/components/gogogate2/config_flow.py b/homeassistant/components/gogogate2/config_flow.py index 0348d0b428c..cebff656d5d 100644 --- a/homeassistant/components/gogogate2/config_flow.py +++ b/homeassistant/components/gogogate2/config_flow.py @@ -3,6 +3,7 @@ from __future__ import annotations import dataclasses +import logging import re from typing import Any, Self @@ -27,6 +28,8 @@ from homeassistant.helpers.service_info.zeroconf import ( from .common import get_api from .const import DEVICE_TYPE_GOGOGATE2, DEVICE_TYPE_ISMARTGATE, DOMAIN +_LOGGER = logging.getLogger(__name__) + DEVICE_NAMES = { DEVICE_TYPE_GOGOGATE2: "Gogogate2", DEVICE_TYPE_ISMARTGATE: "ismartgate", @@ -115,7 +118,8 @@ class Gogogate2FlowHandler(ConfigFlow, domain=DOMAIN): else: errors["base"] = "cannot_connect" - except Exception: # noqa: BLE001 + except Exception: + _LOGGER.exception("Unexpected exception") errors["base"] = "cannot_connect" if self._ip_address and self._device_type: diff --git a/homeassistant/components/hko/config_flow.py b/homeassistant/components/hko/config_flow.py index 8548bb4767d..1e2a6230455 100644 --- a/homeassistant/components/hko/config_flow.py +++ b/homeassistant/components/hko/config_flow.py @@ -3,6 +3,7 @@ from __future__ import annotations from asyncio import timeout +import logging from typing import Any from hko import HKO, LOCATIONS, HKOError @@ -15,6 +16,8 @@ from homeassistant.helpers.selector import SelectSelector, SelectSelectorConfig from .const import API_RHRREAD, DEFAULT_LOCATION, DOMAIN, KEY_LOCATION +_LOGGER = logging.getLogger(__name__) + def get_loc_name(item): """Return an array of supported locations.""" @@ -54,7 +57,8 @@ class HKOConfigFlow(ConfigFlow, domain=DOMAIN): except HKOError: errors["base"] = "cannot_connect" - except Exception: # noqa: BLE001 + except Exception: + _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: await self.async_set_unique_id( diff --git a/homeassistant/components/homee/config_flow.py b/homeassistant/components/homee/config_flow.py index 61d2a3f25a5..1a3c5011f82 100644 --- a/homeassistant/components/homee/config_flow.py +++ b/homeassistant/components/homee/config_flow.py @@ -52,7 +52,7 @@ class HomeeConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "cannot_connect" except HomeeAuthenticationFailedException: errors["base"] = "invalid_auth" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: diff --git a/homeassistant/components/huawei_lte/config_flow.py b/homeassistant/components/huawei_lte/config_flow.py index 96e160ece7b..4ca9e7531e3 100644 --- a/homeassistant/components/huawei_lte/config_flow.py +++ b/homeassistant/components/huawei_lte/config_flow.py @@ -178,8 +178,8 @@ class ConfigFlowHandler(ConfigFlow, domain=DOMAIN): except Timeout: _LOGGER.warning("Connection timeout", exc_info=True) errors[CONF_URL] = "connection_timeout" - except Exception: # noqa: BLE001 - _LOGGER.warning("Unknown error connecting to device", exc_info=True) + except Exception: + _LOGGER.exception("Unknown error connecting to device") errors[CONF_URL] = "unknown" return conn @@ -188,8 +188,8 @@ class ConfigFlowHandler(ConfigFlow, domain=DOMAIN): try: conn.close() conn.requests_session.close() - except Exception: # noqa: BLE001 - _LOGGER.debug("Disconnect error", exc_info=True) + except Exception: + _LOGGER.exception("Disconnect error") async def async_step_user( self, user_input: dict[str, Any] | None = None diff --git a/homeassistant/components/husqvarna_automower/config_flow.py b/homeassistant/components/husqvarna_automower/config_flow.py index 7efed529453..31ca5eef0cd 100644 --- a/homeassistant/components/husqvarna_automower/config_flow.py +++ b/homeassistant/components/husqvarna_automower/config_flow.py @@ -54,7 +54,8 @@ class HusqvarnaConfigFlowHandler( automower_api = AutomowerSession(AsyncConfigFlowAuth(websession, token), tz) try: status_data = await automower_api.get_status() - except Exception: # noqa: BLE001 + except Exception: + _LOGGER.exception("Unexpected exception") return self.async_abort(reason="unknown") if status_data == {}: return self.async_abort(reason="no_mower_connected") diff --git a/homeassistant/components/imgw_pib/config_flow.py b/homeassistant/components/imgw_pib/config_flow.py index 558528fcbef..805bfa2ccb3 100644 --- a/homeassistant/components/imgw_pib/config_flow.py +++ b/homeassistant/components/imgw_pib/config_flow.py @@ -50,7 +50,7 @@ class ImgwPibFlowHandler(ConfigFlow, domain=DOMAIN): hydrological_data = await imgwpib.get_hydrological_data() except (ClientError, TimeoutError, ApiError): errors["base"] = "cannot_connect" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: diff --git a/homeassistant/components/incomfort/config_flow.py b/homeassistant/components/incomfort/config_flow.py index 875bc25bd2f..027c3ad4691 100644 --- a/homeassistant/components/incomfort/config_flow.py +++ b/homeassistant/components/incomfort/config_flow.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Mapping +import logging from typing import Any from incomfortclient import InvalidGateway, InvalidHeaterList @@ -31,6 +32,7 @@ from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .const import CONF_LEGACY_SETPOINT_STATUS, DOMAIN from .coordinator import InComfortConfigEntry, async_connect_gateway +_LOGGER = logging.getLogger(__name__) TITLE = "Intergas InComfort/Intouch Lan2RF gateway" CONFIG_SCHEMA = vol.Schema( @@ -88,7 +90,8 @@ async def async_try_connect_gateway( return {"base": "no_heaters"} except TimeoutError: return {"base": "timeout_error"} - except Exception: # noqa: BLE001 + except Exception: + _LOGGER.exception("Unexpected exception") return {"base": "unknown"} return None diff --git a/homeassistant/components/lastfm/config_flow.py b/homeassistant/components/lastfm/config_flow.py index 0e1f680dd63..ca40aebd0d4 100644 --- a/homeassistant/components/lastfm/config_flow.py +++ b/homeassistant/components/lastfm/config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations +import logging from typing import Any from pylast import LastFMNetwork, PyLastError, User, WSError @@ -32,6 +33,8 @@ CONFIG_SCHEMA: vol.Schema = vol.Schema( } ) +_LOGGER = logging.getLogger(__name__) + def get_lastfm_user(api_key: str, username: str) -> tuple[User, dict[str, str]]: """Get and validate lastFM User.""" @@ -49,7 +52,8 @@ def get_lastfm_user(api_key: str, username: str) -> tuple[User, dict[str, str]]: errors["base"] = "invalid_auth" else: errors["base"] = "unknown" - except Exception: # noqa: BLE001 + except Exception: + _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" return user, errors diff --git a/homeassistant/components/meater/config_flow.py b/homeassistant/components/meater/config_flow.py index a7ba3ba1498..5c11b10755c 100644 --- a/homeassistant/components/meater/config_flow.py +++ b/homeassistant/components/meater/config_flow.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Mapping +import logging from typing import Any from meater import AuthenticationError, MeaterApi, ServiceUnavailableError @@ -14,6 +15,8 @@ from homeassistant.helpers import aiohttp_client from .const import DOMAIN +_LOGGER = logging.getLogger(__name__) + REAUTH_SCHEMA = vol.Schema({vol.Required(CONF_PASSWORD): str}) USER_SCHEMA = vol.Schema( {vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str} @@ -84,7 +87,8 @@ class MeaterConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "invalid_auth" except ServiceUnavailableError: errors["base"] = "service_unavailable_error" - except Exception: # noqa: BLE001 + except Exception: + _LOGGER.exception("Unexpected exception") errors["base"] = "unknown_auth_error" else: data = {"username": username, "password": password} diff --git a/homeassistant/components/motion_blinds/config_flow.py b/homeassistant/components/motion_blinds/config_flow.py index a7bb34af1e6..954f9e25c21 100644 --- a/homeassistant/components/motion_blinds/config_flow.py +++ b/homeassistant/components/motion_blinds/config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations +import logging from typing import Any from motionblinds import MotionDiscovery, MotionGateway @@ -28,6 +29,8 @@ from .const import ( ) from .gateway import ConnectMotionGateway +_LOGGER = logging.getLogger(__name__) + CONFIG_SCHEMA = vol.Schema( { vol.Optional(CONF_HOST): str, @@ -93,7 +96,8 @@ class MotionBlindsFlowHandler(ConfigFlow, domain=DOMAIN): try: # key not needed for GetDeviceList request await self.hass.async_add_executor_job(gateway.GetDeviceList) - except Exception: # noqa: BLE001 + except Exception: + _LOGGER.exception("Failed to connect to Motion Gateway") return self.async_abort(reason="not_motionblinds") if not gateway.available: diff --git a/homeassistant/components/mullvad/config_flow.py b/homeassistant/components/mullvad/config_flow.py index c16f8879a7b..b179c5605ef 100644 --- a/homeassistant/components/mullvad/config_flow.py +++ b/homeassistant/components/mullvad/config_flow.py @@ -1,5 +1,6 @@ """Config flow for Mullvad VPN integration.""" +import logging from typing import Any from mullvad_api import MullvadAPI, MullvadAPIError @@ -8,6 +9,8 @@ from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from .const import DOMAIN +_LOGGER = logging.getLogger(__name__) + class MullvadConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Mullvad VPN.""" @@ -24,7 +27,8 @@ class MullvadConfigFlow(ConfigFlow, domain=DOMAIN): await self.hass.async_add_executor_job(MullvadAPI) except MullvadAPIError: errors["base"] = "cannot_connect" - except Exception: # noqa: BLE001 + except Exception: + _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: return self.async_create_entry(title="Mullvad VPN", data=user_input) diff --git a/homeassistant/components/mutesync/config_flow.py b/homeassistant/components/mutesync/config_flow.py index ef03df39968..a2aacfc927e 100644 --- a/homeassistant/components/mutesync/config_flow.py +++ b/homeassistant/components/mutesync/config_flow.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +import logging from typing import Any import aiohttp @@ -16,6 +17,8 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DOMAIN +_LOGGER = logging.getLogger(__name__) + STEP_USER_DATA_SCHEMA = vol.Schema({vol.Required("host"): str}) @@ -60,7 +63,8 @@ class MuteSyncConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "cannot_connect" except InvalidAuth: errors["base"] = "invalid_auth" - except Exception: # noqa: BLE001 + except Exception: + _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: return self.async_create_entry( diff --git a/homeassistant/components/nasweb/config_flow.py b/homeassistant/components/nasweb/config_flow.py index 3a9ad3f7d49..298210903dc 100644 --- a/homeassistant/components/nasweb/config_flow.py +++ b/homeassistant/components/nasweb/config_flow.py @@ -103,7 +103,7 @@ class NASwebConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors["base"] = "missing_status" except AbortFlow: raise - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: diff --git a/homeassistant/components/nextdns/config_flow.py b/homeassistant/components/nextdns/config_flow.py index d3327c4c08b..d36064d8fb0 100644 --- a/homeassistant/components/nextdns/config_flow.py +++ b/homeassistant/components/nextdns/config_flow.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Mapping +import logging from typing import Any from aiohttp.client_exceptions import ClientConnectorError @@ -19,6 +20,8 @@ from .const import CONF_PROFILE_ID, DOMAIN AUTH_SCHEMA = vol.Schema({vol.Required(CONF_API_KEY): str}) +_LOGGER = logging.getLogger(__name__) + async def async_init_nextdns(hass: HomeAssistant, api_key: str) -> NextDns: """Check if credentials are valid.""" @@ -51,7 +54,8 @@ class NextDnsFlowHandler(ConfigFlow, domain=DOMAIN): errors["base"] = "invalid_api_key" except (ApiError, ClientConnectorError, RetryError, TimeoutError): errors["base"] = "cannot_connect" - except Exception: # noqa: BLE001 + except Exception: + _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: return await self.async_step_profiles() @@ -111,7 +115,8 @@ class NextDnsFlowHandler(ConfigFlow, domain=DOMAIN): errors["base"] = "invalid_api_key" except (ApiError, ClientConnectorError, RetryError, TimeoutError): errors["base"] = "cannot_connect" - except Exception: # noqa: BLE001 + except Exception: + _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: return self.async_update_reload_and_abort( diff --git a/homeassistant/components/niko_home_control/config_flow.py b/homeassistant/components/niko_home_control/config_flow.py index f37e5e9248a..76e71bc1690 100644 --- a/homeassistant/components/niko_home_control/config_flow.py +++ b/homeassistant/components/niko_home_control/config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations +import logging from typing import Any from nhc.controller import NHCController @@ -12,6 +13,8 @@ from homeassistant.const import CONF_HOST from .const import DOMAIN +_LOGGER = logging.getLogger(__name__) + DATA_SCHEMA = vol.Schema( { vol.Required(CONF_HOST): str, @@ -25,7 +28,8 @@ async def test_connection(host: str) -> str | None: controller = NHCController(host, 8000) try: await controller.connect() - except Exception: # noqa: BLE001 + except Exception: + _LOGGER.exception("Unexpected exception") return "cannot_connect" return None diff --git a/homeassistant/components/octoprint/config_flow.py b/homeassistant/components/octoprint/config_flow.py index 010b45e5a1c..e20eea0a61f 100644 --- a/homeassistant/components/octoprint/config_flow.py +++ b/homeassistant/components/octoprint/config_flow.py @@ -85,7 +85,8 @@ class OctoPrintConfigFlow(ConfigFlow, domain=DOMAIN): raise err from None except CannotConnect: errors["base"] = "cannot_connect" - except Exception: # noqa: BLE001 + except Exception: + _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" if errors: diff --git a/homeassistant/components/progettihwsw/config_flow.py b/homeassistant/components/progettihwsw/config_flow.py index 2e5ea221dca..8818eff2d81 100644 --- a/homeassistant/components/progettihwsw/config_flow.py +++ b/homeassistant/components/progettihwsw/config_flow.py @@ -1,5 +1,6 @@ """Config flow for ProgettiHWSW Automation integration.""" +import logging from typing import TYPE_CHECKING, Any from ProgettiHWSW.ProgettiHWSWAPI import ProgettiHWSWAPI @@ -11,6 +12,8 @@ from homeassistant.exceptions import HomeAssistantError from .const import DOMAIN +_LOGGER = logging.getLogger(__name__) + DATA_SCHEMA = vol.Schema( {vol.Required("host"): str, vol.Required("port", default=80): int} ) @@ -86,7 +89,8 @@ class ProgettiHWSWConfigFlow(ConfigFlow, domain=DOMAIN): info = await validate_input(self.hass, user_input) except CannotConnect: errors["base"] = "cannot_connect" - except Exception: # noqa: BLE001 + except Exception: + _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: user_input.update(info) diff --git a/homeassistant/components/qnap/config_flow.py b/homeassistant/components/qnap/config_flow.py index 75f41a27f69..504883b55e9 100644 --- a/homeassistant/components/qnap/config_flow.py +++ b/homeassistant/components/qnap/config_flow.py @@ -70,8 +70,8 @@ class QnapConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "cannot_connect" except TypeError: errors["base"] = "invalid_auth" - except Exception as error: # noqa: BLE001 - _LOGGER.error(error) + except Exception: + _LOGGER.exception("Unexpected error") errors["base"] = "unknown" else: unique_id = stats["system"]["serial_number"] diff --git a/homeassistant/components/rabbitair/config_flow.py b/homeassistant/components/rabbitair/config_flow.py index f4487a73b58..43959e1e42c 100644 --- a/homeassistant/components/rabbitair/config_flow.py +++ b/homeassistant/components/rabbitair/config_flow.py @@ -74,8 +74,8 @@ class RabbitAirConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "invalid_host" except TimeoutConnect: errors["base"] = "timeout_connect" - except Exception as err: # noqa: BLE001 - _LOGGER.debug("Unexpected exception: %s", err) + except Exception: + _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: user_input[CONF_MAC] = info["mac"] diff --git a/homeassistant/components/renault/config_flow.py b/homeassistant/components/renault/config_flow.py index 70544a5637f..90d2c11613c 100644 --- a/homeassistant/components/renault/config_flow.py +++ b/homeassistant/components/renault/config_flow.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Mapping +import logging from typing import Any import aiohttp @@ -16,6 +17,8 @@ from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from .const import CONF_KAMEREON_ACCOUNT_ID, CONF_LOCALE, DOMAIN from .renault_hub import RenaultHub +_LOGGER = logging.getLogger(__name__) + USER_SCHEMA = vol.Schema( { vol.Required(CONF_LOCALE): vol.In(AVAILABLE_LOCALES.keys()), @@ -54,7 +57,8 @@ class RenaultFlowHandler(ConfigFlow, domain=DOMAIN): ) except (aiohttp.ClientConnectionError, GigyaException): errors["base"] = "cannot_connect" - except Exception: # noqa: BLE001 + except Exception: + _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: if login_success: diff --git a/homeassistant/components/skybell/config_flow.py b/homeassistant/components/skybell/config_flow.py index a32441f4cf8..9893d0dd93a 100644 --- a/homeassistant/components/skybell/config_flow.py +++ b/homeassistant/components/skybell/config_flow.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Mapping +import logging from typing import Any from aioskybell import Skybell, exceptions @@ -14,6 +15,8 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DOMAIN +_LOGGER = logging.getLogger(__name__) + class SkybellFlowHandler(ConfigFlow, domain=DOMAIN): """Handle a config flow for Skybell.""" @@ -95,6 +98,7 @@ class SkybellFlowHandler(ConfigFlow, domain=DOMAIN): return None, "invalid_auth" except exceptions.SkybellException: return None, "cannot_connect" - except Exception: # noqa: BLE001 + except Exception: + _LOGGER.exception("Unexpected exception") return None, "unknown" return skybell.user_id, None diff --git a/homeassistant/components/smarty/config_flow.py b/homeassistant/components/smarty/config_flow.py index 9a55356a990..a7f0bdd4123 100644 --- a/homeassistant/components/smarty/config_flow.py +++ b/homeassistant/components/smarty/config_flow.py @@ -1,5 +1,6 @@ """Config flow for Smarty integration.""" +import logging from typing import Any from pysmarty2 import Smarty @@ -10,6 +11,8 @@ from homeassistant.const import CONF_HOST, CONF_NAME from .const import DOMAIN +_LOGGER = logging.getLogger(__name__) + class SmartyConfigFlow(ConfigFlow, domain=DOMAIN): """Smarty config flow.""" @@ -20,7 +23,8 @@ class SmartyConfigFlow(ConfigFlow, domain=DOMAIN): try: if smarty.update(): return None - except Exception: # noqa: BLE001 + except Exception: + _LOGGER.exception("Unexpected exception") return "unknown" else: return "cannot_connect" diff --git a/homeassistant/components/spotify/config_flow.py b/homeassistant/components/spotify/config_flow.py index d99fa7793df..3478887d64c 100644 --- a/homeassistant/components/spotify/config_flow.py +++ b/homeassistant/components/spotify/config_flow.py @@ -41,7 +41,8 @@ class SpotifyFlowHandler( try: current_user = await spotify.get_current_user() - except Exception: # noqa: BLE001 + except Exception: + self.logger.exception("Error while connecting to Spotify") return self.async_abort(reason="connection_error") name = current_user.display_name diff --git a/homeassistant/components/squeezebox/config_flow.py b/homeassistant/components/squeezebox/config_flow.py index 2853ad14217..31dd5b003b7 100644 --- a/homeassistant/components/squeezebox/config_flow.py +++ b/homeassistant/components/squeezebox/config_flow.py @@ -151,7 +151,8 @@ class SqueezeboxConfigFlow(ConfigFlow, domain=DOMAIN): if server.http_status == HTTPStatus.UNAUTHORIZED: return "invalid_auth" return "cannot_connect" - except Exception: # noqa: BLE001 + except Exception: + _LOGGER.exception("Unknown exception while validating connection") return "unknown" if "uuid" in status: diff --git a/homeassistant/components/swiss_public_transport/config_flow.py b/homeassistant/components/swiss_public_transport/config_flow.py index 4dc6efc2e85..872044097d6 100644 --- a/homeassistant/components/swiss_public_transport/config_flow.py +++ b/homeassistant/components/swiss_public_transport/config_flow.py @@ -190,7 +190,7 @@ class SwissPublicTransportConfigFlow(ConfigFlow, domain=DOMAIN): return "cannot_connect" except OpendataTransportError: return "bad_config" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unknown error") return "unknown" return None diff --git a/homeassistant/components/trafikverket_ferry/config_flow.py b/homeassistant/components/trafikverket_ferry/config_flow.py index 002dc421273..dfa64ed2953 100644 --- a/homeassistant/components/trafikverket_ferry/config_flow.py +++ b/homeassistant/components/trafikverket_ferry/config_flow.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Mapping +import logging from typing import Any from pytrafikverket import TrafikverketFerry @@ -17,6 +18,8 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import CONF_FROM, CONF_TIME, CONF_TO, DOMAIN from .util import create_unique_id +_LOGGER = logging.getLogger(__name__) + DATA_SCHEMA = vol.Schema( { vol.Required(CONF_API_KEY): selector.TextSelector( @@ -81,7 +84,8 @@ class TVFerryConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "invalid_auth" except NoFerryFound: errors["base"] = "invalid_route" - except Exception: # noqa: BLE001 + except Exception: + _LOGGER.exception("Unexpected exception") errors["base"] = "cannot_connect" else: return self.async_update_reload_and_abort( @@ -120,7 +124,8 @@ class TVFerryConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "invalid_auth" except NoFerryFound: errors["base"] = "invalid_route" - except Exception: # noqa: BLE001 + except Exception: + _LOGGER.exception("Unexpected exception") errors["base"] = "cannot_connect" else: if not errors: diff --git a/homeassistant/components/trafikverket_train/config_flow.py b/homeassistant/components/trafikverket_train/config_flow.py index f6a58e464a1..eb0a4a45791 100644 --- a/homeassistant/components/trafikverket_train/config_flow.py +++ b/homeassistant/components/trafikverket_train/config_flow.py @@ -86,8 +86,8 @@ async def validate_station( except UnknownError as error: _LOGGER.error("Unknown error occurred during validation %s", str(error)) errors["base"] = "cannot_connect" - except Exception as error: # noqa: BLE001 - _LOGGER.error("Unknown exception occurred during validation %s", str(error)) + except Exception: + _LOGGER.exception("Unknown exception occurred during validation") errors["base"] = "cannot_connect" return (stations, errors) diff --git a/homeassistant/components/trafikverket_weatherstation/config_flow.py b/homeassistant/components/trafikverket_weatherstation/config_flow.py index f4316b887b3..ee9fe264692 100644 --- a/homeassistant/components/trafikverket_weatherstation/config_flow.py +++ b/homeassistant/components/trafikverket_weatherstation/config_flow.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Mapping +import logging from typing import Any from pytrafikverket.exceptions import ( @@ -25,6 +26,8 @@ from homeassistant.helpers.selector import ( from .const import CONF_STATION, DOMAIN +_LOGGER = logging.getLogger(__name__) + class TVWeatherConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Trafikverket Weatherstation integration.""" @@ -56,7 +59,8 @@ class TVWeatherConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "invalid_station" except MultipleWeatherStationsFound: errors["base"] = "more_stations" - except Exception: # noqa: BLE001 + except Exception: + _LOGGER.exception("Unexpected error") errors["base"] = "cannot_connect" else: return self.async_create_entry( @@ -102,7 +106,8 @@ class TVWeatherConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "invalid_station" except MultipleWeatherStationsFound: errors["base"] = "more_stations" - except Exception: # noqa: BLE001 + except Exception: + _LOGGER.exception("Unexpected exception") errors["base"] = "cannot_connect" else: return self.async_update_reload_and_abort( @@ -132,7 +137,8 @@ class TVWeatherConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "invalid_station" except MultipleWeatherStationsFound: errors["base"] = "more_stations" - except Exception: # noqa: BLE001 + except Exception: + _LOGGER.exception("Unexpected exception") errors["base"] = "cannot_connect" else: return self.async_update_reload_and_abort( diff --git a/homeassistant/components/triggercmd/config_flow.py b/homeassistant/components/triggercmd/config_flow.py index fc02dd0b2fc..48c4eacfd5a 100644 --- a/homeassistant/components/triggercmd/config_flow.py +++ b/homeassistant/components/triggercmd/config_flow.py @@ -57,7 +57,7 @@ class TriggerCMDConfigFlow(ConfigFlow, domain=DOMAIN): errors[CONF_TOKEN] = "invalid_token" except TRIGGERcmdConnectionError: errors["base"] = "cannot_connect" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: diff --git a/homeassistant/components/vallox/config_flow.py b/homeassistant/components/vallox/config_flow.py index 30d1d153d9e..c7e6af8891a 100644 --- a/homeassistant/components/vallox/config_flow.py +++ b/homeassistant/components/vallox/config_flow.py @@ -108,7 +108,7 @@ class ValloxConfigFlow(ConfigFlow, domain=DOMAIN): errors[CONF_HOST] = "invalid_host" except ValloxApiException: errors[CONF_HOST] = "cannot_connect" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors[CONF_HOST] = "unknown" else: diff --git a/homeassistant/components/vilfo/config_flow.py b/homeassistant/components/vilfo/config_flow.py index cdba7f1b8c2..5612591c595 100644 --- a/homeassistant/components/vilfo/config_flow.py +++ b/homeassistant/components/vilfo/config_flow.py @@ -114,8 +114,8 @@ class DomainConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "cannot_connect" except InvalidAuth: errors["base"] = "invalid_auth" - except Exception as err: # noqa: BLE001 - _LOGGER.error("Unexpected exception: %s", err) + except Exception: + _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: await self.async_set_unique_id(info[CONF_ID]) diff --git a/homeassistant/components/webdav/config_flow.py b/homeassistant/components/webdav/config_flow.py index fa1a4fe3ca9..e3e46d2575a 100644 --- a/homeassistant/components/webdav/config_flow.py +++ b/homeassistant/components/webdav/config_flow.py @@ -67,7 +67,7 @@ class WebDavConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "invalid_auth" except MethodNotSupportedError: errors["base"] = "invalid_method" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected error") errors["base"] = "unknown" else: From e7eb173e07cfccc6db79d7e5416ddf6bd5641d1b Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Tue, 25 Mar 2025 10:49:10 +0100 Subject: [PATCH 2995/3148] Add Reolink smart ai number entities (#140417) --- homeassistant/components/reolink/icons.json | 27 ++ homeassistant/components/reolink/number.py | 249 +++++++++++++++++- homeassistant/components/reolink/strings.json | 27 ++ tests/components/reolink/test_number.py | 42 +++ 4 files changed, 341 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/reolink/icons.json b/homeassistant/components/reolink/icons.json index 00045c4cda2..7d1dba099ed 100644 --- a/homeassistant/components/reolink/icons.json +++ b/homeassistant/components/reolink/icons.json @@ -217,6 +217,21 @@ "ai_animal_sensitivity": { "default": "mdi:paw" }, + "crossline_sensitivity": { + "default": "mdi:fence" + }, + "intrusion_sensitivity": { + "default": "mdi:location-enter" + }, + "linger_sensitivity": { + "default": "mdi:account-switch" + }, + "forgotten_item_sensitivity": { + "default": "mdi:package-variant-closed-plus" + }, + "taken_item_sensitivity": { + "default": "mdi:package-variant-closed-minus" + }, "ai_face_delay": { "default": "mdi:face-recognition" }, @@ -235,6 +250,18 @@ "ai_animal_delay": { "default": "mdi:paw" }, + "intrusion_delay": { + "default": "mdi:location-enter" + }, + "linger_delay": { + "default": "mdi:account-switch" + }, + "forgotten_item_delay": { + "default": "mdi:package-variant-closed-plus" + }, + "taken_item_delay": { + "default": "mdi:package-variant-closed-minus" + }, "auto_quick_reply_time": { "default": "mdi:message-reply-text-outline" }, diff --git a/homeassistant/components/reolink/number.py b/homeassistant/components/reolink/number.py index 48382df4cbc..2a6fb740ee0 100644 --- a/homeassistant/components/reolink/number.py +++ b/homeassistant/components/reolink/number.py @@ -9,6 +9,7 @@ from typing import Any from reolink_aio.api import Chime, Host from homeassistant.components.number import ( + NumberDeviceClass, NumberEntity, NumberEntityDescription, NumberMode, @@ -44,6 +45,19 @@ class ReolinkNumberEntityDescription( value: Callable[[Host, int], float | None] +@dataclass(frozen=True, kw_only=True) +class ReolinkSmartAINumberEntityDescription( + NumberEntityDescription, + ReolinkChannelEntityDescription, +): + """A class that describes smart AI number entities.""" + + smart_type: str + method: Callable[[Host, int, int, float], Any] + mode: NumberMode = NumberMode.AUTO + value: Callable[[Host, int, int], float | None] + + @dataclass(frozen=True, kw_only=True) class ReolinkHostNumberEntityDescription( NumberEntityDescription, @@ -125,6 +139,7 @@ NUMBER_ENTITIES = ( cmd_key="GetPtzGuard", translation_key="guard_return_time", entity_category=EntityCategory.CONFIG, + device_class=NumberDeviceClass.DURATION, native_step=1, native_unit_of_measurement=UnitOfTime.SECONDS, native_min_value=10, @@ -248,6 +263,7 @@ NUMBER_ENTITIES = ( cmd_key="GetAiAlarm", translation_key="ai_face_delay", entity_category=EntityCategory.CONFIG, + device_class=NumberDeviceClass.DURATION, entity_registry_enabled_default=False, native_step=1, native_unit_of_measurement=UnitOfTime.SECONDS, @@ -264,6 +280,7 @@ NUMBER_ENTITIES = ( cmd_key="GetAiAlarm", translation_key="ai_person_delay", entity_category=EntityCategory.CONFIG, + device_class=NumberDeviceClass.DURATION, entity_registry_enabled_default=False, native_step=1, native_unit_of_measurement=UnitOfTime.SECONDS, @@ -280,6 +297,7 @@ NUMBER_ENTITIES = ( cmd_key="GetAiAlarm", translation_key="ai_vehicle_delay", entity_category=EntityCategory.CONFIG, + device_class=NumberDeviceClass.DURATION, entity_registry_enabled_default=False, native_step=1, native_unit_of_measurement=UnitOfTime.SECONDS, @@ -296,6 +314,7 @@ NUMBER_ENTITIES = ( cmd_key="GetAiAlarm", translation_key="ai_package_delay", entity_category=EntityCategory.CONFIG, + device_class=NumberDeviceClass.DURATION, entity_registry_enabled_default=False, native_step=1, native_unit_of_measurement=UnitOfTime.SECONDS, @@ -312,6 +331,7 @@ NUMBER_ENTITIES = ( cmd_key="GetAiAlarm", translation_key="ai_pet_delay", entity_category=EntityCategory.CONFIG, + device_class=NumberDeviceClass.DURATION, entity_registry_enabled_default=False, native_step=1, native_unit_of_measurement=UnitOfTime.SECONDS, @@ -330,6 +350,7 @@ NUMBER_ENTITIES = ( cmd_key="GetAiAlarm", translation_key="ai_animal_delay", entity_category=EntityCategory.CONFIG, + device_class=NumberDeviceClass.DURATION, entity_registry_enabled_default=False, native_step=1, native_unit_of_measurement=UnitOfTime.SECONDS, @@ -346,6 +367,7 @@ NUMBER_ENTITIES = ( cmd_key="GetAutoReply", translation_key="auto_quick_reply_time", entity_category=EntityCategory.CONFIG, + device_class=NumberDeviceClass.DURATION, native_step=1, native_unit_of_measurement=UnitOfTime.SECONDS, native_min_value=1, @@ -385,6 +407,7 @@ NUMBER_ENTITIES = ( cmd_key="GetAiCfg", translation_key="auto_track_disappear_time", entity_category=EntityCategory.CONFIG, + device_class=NumberDeviceClass.DURATION, native_step=1, native_unit_of_measurement=UnitOfTime.SECONDS, native_min_value=1, @@ -400,6 +423,7 @@ NUMBER_ENTITIES = ( cmd_key="GetAiCfg", translation_key="auto_track_stop_time", entity_category=EntityCategory.CONFIG, + device_class=NumberDeviceClass.DURATION, native_step=1, native_unit_of_measurement=UnitOfTime.SECONDS, native_min_value=1, @@ -493,6 +517,168 @@ NUMBER_ENTITIES = ( ), ) +SMART_AI_NUMBER_ENTITIES = ( + ReolinkSmartAINumberEntityDescription( + key="crossline_sensitivity", + smart_type="crossline", + cmd_id=527, + translation_key="crossline_sensitivity", + entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, + native_step=1, + native_min_value=0, + native_max_value=100, + supported=lambda api, ch: api.supported(ch, "ai_crossline"), + value=lambda api, ch, loc: ( + api.baichuan.smart_ai_sensitivity(ch, "crossline", loc) + ), + method=lambda api, ch, loc, value: api.baichuan.set_smart_ai( + ch, "crossline", loc, sensitivity=int(value) + ), + ), + ReolinkSmartAINumberEntityDescription( + key="intrusion_sensitivity", + smart_type="intrusion", + cmd_id=529, + translation_key="intrusion_sensitivity", + entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, + native_step=1, + native_min_value=0, + native_max_value=100, + supported=lambda api, ch: api.supported(ch, "ai_intrusion"), + value=lambda api, ch, loc: ( + api.baichuan.smart_ai_sensitivity(ch, "intrusion", loc) + ), + method=lambda api, ch, loc, value: api.baichuan.set_smart_ai( + ch, "intrusion", loc, sensitivity=int(value) + ), + ), + ReolinkSmartAINumberEntityDescription( + key="linger_sensitivity", + smart_type="loitering", + cmd_id=531, + translation_key="linger_sensitivity", + entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, + native_step=1, + native_min_value=0, + native_max_value=100, + supported=lambda api, ch: api.supported(ch, "ai_linger"), + value=lambda api, ch, loc: ( + api.baichuan.smart_ai_sensitivity(ch, "loitering", loc) + ), + method=lambda api, ch, loc, value: api.baichuan.set_smart_ai( + ch, "loitering", loc, sensitivity=int(value) + ), + ), + ReolinkSmartAINumberEntityDescription( + key="forgotten_item_sensitivity", + smart_type="legacy", + cmd_id=549, + translation_key="forgotten_item_sensitivity", + entity_registry_enabled_default=False, + entity_category=EntityCategory.CONFIG, + native_step=1, + native_min_value=0, + native_max_value=100, + supported=lambda api, ch: api.supported(ch, "ai_forgotten_item"), + value=lambda api, ch, loc: ( + api.baichuan.smart_ai_sensitivity(ch, "legacy", loc) + ), + method=lambda api, ch, loc, value: api.baichuan.set_smart_ai( + ch, "legacy", loc, sensitivity=int(value) + ), + ), + ReolinkSmartAINumberEntityDescription( + key="taken_item_sensitivity", + smart_type="loss", + cmd_id=551, + translation_key="taken_item_sensitivity", + entity_registry_enabled_default=False, + entity_category=EntityCategory.CONFIG, + native_step=1, + native_min_value=0, + native_max_value=100, + supported=lambda api, ch: api.supported(ch, "ai_taken_item"), + value=lambda api, ch, loc: api.baichuan.smart_ai_sensitivity(ch, "loss", loc), + method=lambda api, ch, loc, value: api.baichuan.set_smart_ai( + ch, "loss", loc, sensitivity=int(value) + ), + ), + ReolinkSmartAINumberEntityDescription( + key="intrusion_delay", + smart_type="intrusion", + cmd_id=529, + translation_key="intrusion_delay", + entity_registry_enabled_default=False, + entity_category=EntityCategory.CONFIG, + device_class=NumberDeviceClass.DURATION, + native_step=1, + native_unit_of_measurement=UnitOfTime.SECONDS, + native_min_value=0, + native_max_value=10, + supported=lambda api, ch: api.supported(ch, "ai_intrusion"), + value=lambda api, ch, loc: api.baichuan.smart_ai_delay(ch, "intrusion", loc), + method=lambda api, ch, loc, value: api.baichuan.set_smart_ai( + ch, "intrusion", loc, delay=int(value) + ), + ), + ReolinkSmartAINumberEntityDescription( + key="linger_delay", + smart_type="loitering", + cmd_id=531, + translation_key="linger_delay", + entity_registry_enabled_default=False, + entity_category=EntityCategory.CONFIG, + native_step=1, + native_unit_of_measurement=UnitOfTime.SECONDS, + native_min_value=1, + native_max_value=10, + supported=lambda api, ch: api.supported(ch, "ai_linger"), + value=lambda api, ch, loc: api.baichuan.smart_ai_delay(ch, "loitering", loc), + method=lambda api, ch, loc, value: api.baichuan.set_smart_ai( + ch, "loitering", loc, delay=int(value) + ), + ), + ReolinkSmartAINumberEntityDescription( + key="forgotten_item_delay", + smart_type="legacy", + cmd_id=549, + translation_key="forgotten_item_delay", + entity_registry_enabled_default=False, + entity_category=EntityCategory.CONFIG, + device_class=NumberDeviceClass.DURATION, + native_step=1, + native_unit_of_measurement=UnitOfTime.SECONDS, + native_min_value=1, + native_max_value=30, + supported=lambda api, ch: api.supported(ch, "ai_forgotten_item"), + value=lambda api, ch, loc: api.baichuan.smart_ai_delay(ch, "legacy", loc), + method=lambda api, ch, loc, value: api.baichuan.set_smart_ai( + ch, "legacy", loc, delay=int(value) + ), + ), + ReolinkSmartAINumberEntityDescription( + key="taken_item_delay", + smart_type="loss", + cmd_id=551, + translation_key="taken_item_delay", + entity_registry_enabled_default=False, + entity_category=EntityCategory.CONFIG, + device_class=NumberDeviceClass.DURATION, + native_step=1, + native_unit_of_measurement=UnitOfTime.SECONDS, + native_min_value=1, + native_max_value=30, + supported=lambda api, ch: api.supported(ch, "ai_taken_item"), + value=lambda api, ch, loc: api.baichuan.smart_ai_delay(ch, "loss", loc), + method=lambda api, ch, loc, value: api.baichuan.set_smart_ai( + ch, "loss", loc, delay=int(value) + ), + ), +) + HOST_NUMBER_ENTITIES = ( ReolinkHostNumberEntityDescription( key="alarm_volume", @@ -542,22 +728,32 @@ async def async_setup_entry( ) -> None: """Set up a Reolink number entities.""" reolink_data: ReolinkData = config_entry.runtime_data + api = reolink_data.host.api entities: list[NumberEntity] = [ ReolinkNumberEntity(reolink_data, channel, entity_description) for entity_description in NUMBER_ENTITIES - for channel in reolink_data.host.api.channels - if entity_description.supported(reolink_data.host.api, channel) + for channel in api.channels + if entity_description.supported(api, channel) ] + entities.extend( + ReolinkSmartAINumberEntity(reolink_data, channel, location, entity_description) + for entity_description in SMART_AI_NUMBER_ENTITIES + for channel in api.channels + for location in api.baichuan.smart_location_list( + channel, entity_description.smart_type + ) + if entity_description.supported(api, channel) + ) entities.extend( ReolinkHostNumberEntity(reolink_data, entity_description) for entity_description in HOST_NUMBER_ENTITIES - if entity_description.supported(reolink_data.host.api) + if entity_description.supported(api) ) entities.extend( ReolinkChimeNumberEntity(reolink_data, chime, entity_description) for entity_description in CHIME_NUMBER_ENTITIES - for chime in reolink_data.host.api.chime_list + for chime in api.chime_list ) async_add_entities(entities) @@ -599,6 +795,51 @@ class ReolinkNumberEntity(ReolinkChannelCoordinatorEntity, NumberEntity): self.async_write_ha_state() +class ReolinkSmartAINumberEntity(ReolinkChannelCoordinatorEntity, NumberEntity): + """Base smart AI number entity class for Reolink IP cameras.""" + + entity_description: ReolinkSmartAINumberEntityDescription + + def __init__( + self, + reolink_data: ReolinkData, + channel: int, + location: int, + entity_description: ReolinkSmartAINumberEntityDescription, + ) -> None: + """Initialize Reolink number entity.""" + self.entity_description = entity_description + super().__init__(reolink_data, channel) + + unique_index = self._host.api.baichuan.smart_ai_index( + channel, entity_description.smart_type, location + ) + self._attr_unique_id = f"{self._attr_unique_id}_{unique_index}" + + self._location = location + self._attr_mode = entity_description.mode + self._attr_translation_placeholders = { + "zone_name": self._host.api.baichuan.smart_ai_name( + channel, entity_description.smart_type, location + ) + } + + @property + def native_value(self) -> float | None: + """State of the number entity.""" + return self.entity_description.value( + self._host.api, self._channel, self._location + ) + + @raise_translated_error + async def async_set_native_value(self, value: float) -> None: + """Update the current value.""" + await self.entity_description.method( + self._host.api, self._channel, self._location, value + ) + self.async_write_ha_state() + + class ReolinkHostNumberEntity(ReolinkHostCoordinatorEntity, NumberEntity): """Base number entity class for Reolink Host.""" diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index 7ad2e1ea217..72076e7ef88 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -562,6 +562,21 @@ "ai_animal_sensitivity": { "name": "AI animal sensitivity" }, + "crossline_sensitivity": { + "name": "AI crossline {zone_name} sensitivity" + }, + "intrusion_sensitivity": { + "name": "AI intrusion {zone_name} sensitivity" + }, + "linger_sensitivity": { + "name": "AI linger {zone_name} sensitivity" + }, + "forgotten_item_sensitivity": { + "name": "AI item forgotten {zone_name} sensitivity" + }, + "taken_item_sensitivity": { + "name": "AI item taken {zone_name} sensitivity" + }, "ai_face_delay": { "name": "AI face delay" }, @@ -580,6 +595,18 @@ "ai_animal_delay": { "name": "AI animal delay" }, + "intrusion_delay": { + "name": "AI intrusion {zone_name} delay" + }, + "linger_delay": { + "name": "AI linger {zone_name} delay" + }, + "forgotten_item_delay": { + "name": "AI item forgotten {zone_name} delay" + }, + "taken_item_delay": { + "name": "AI item taken {zone_name} delay" + }, "auto_quick_reply_time": { "name": "Auto quick reply time" }, diff --git a/tests/components/reolink/test_number.py b/tests/components/reolink/test_number.py index c6507fa36c1..dd70376d658 100644 --- a/tests/components/reolink/test_number.py +++ b/tests/components/reolink/test_number.py @@ -67,6 +67,48 @@ async def test_number( reolink_connect.set_volume.reset_mock(side_effect=True) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_smart_ai_number( + hass: HomeAssistant, + config_entry: MockConfigEntry, + reolink_connect: MagicMock, +) -> None: + """Test number entity with smart ai sensitivity.""" + reolink_connect.baichuan.smart_ai_sensitivity.return_value = 80 + + with patch("homeassistant.components.reolink.PLATFORMS", [Platform.NUMBER]): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.LOADED + + entity_id = f"{Platform.NUMBER}.{TEST_NVR_NAME}_AI_crossline_zone1_sensitivity" + + assert hass.states.get(entity_id).state == "80" + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: entity_id, ATTR_VALUE: 50}, + blocking=True, + ) + reolink_connect.baichuan.set_smart_ai.assert_called_with( + 0, "crossline", 0, sensitivity=50 + ) + + reolink_connect.baichuan.set_smart_ai.side_effect = InvalidParameterError( + "Test error" + ) + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: entity_id, ATTR_VALUE: 50}, + blocking=True, + ) + + reolink_connect.baichuan.set_smart_ai.reset_mock(side_effect=True) + + async def test_host_number( hass: HomeAssistant, config_entry: MockConfigEntry, From a1a808b84347d20d86709b3b2c7aed526d5f6d5e Mon Sep 17 00:00:00 2001 From: adam-the-hero <132444842+adam-the-hero@users.noreply.github.com> Date: Tue, 25 Mar 2025 10:53:36 +0100 Subject: [PATCH 2996/3148] Add EventEntity for Auto Shut Off events in Watergate integration (#135675) * Add EventEntity for Auto Shut Off events in Watergate integration * Split events into two: volume and duration * Add icons to json. Extract some common translation keys. Simplify tests * Apply suggestions from code review * Fix --------- Co-authored-by: Joost Lekkerkerker --- .../components/watergate/__init__.py | 9 +- homeassistant/components/watergate/const.py | 2 + homeassistant/components/watergate/event.py | 78 ++++++++++++ homeassistant/components/watergate/icons.json | 12 ++ .../components/watergate/quality_scale.yaml | 5 +- .../components/watergate/strings.json | 36 ++++++ .../watergate/snapshots/test_event.ambr | 111 ++++++++++++++++++ tests/components/watergate/test_event.py | 84 +++++++++++++ tests/components/watergate/test_sensor.py | 2 +- 9 files changed, 333 insertions(+), 6 deletions(-) create mode 100644 homeassistant/components/watergate/event.py create mode 100644 homeassistant/components/watergate/icons.json create mode 100644 tests/components/watergate/snapshots/test_event.ambr create mode 100644 tests/components/watergate/test_event.py diff --git a/homeassistant/components/watergate/__init__.py b/homeassistant/components/watergate/__init__.py index c1747af1f11..fd591215d8b 100644 --- a/homeassistant/components/watergate/__init__.py +++ b/homeassistant/components/watergate/__init__.py @@ -18,8 +18,9 @@ from homeassistant.components.webhook import ( ) from homeassistant.const import CONF_IP_ADDRESS, CONF_WEBHOOK_ID, Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers.dispatcher import async_dispatcher_send -from .const import DOMAIN +from .const import AUTO_SHUT_OFF_EVENT_NAME, DOMAIN from .coordinator import WatergateConfigEntry, WatergateDataCoordinator _LOGGER = logging.getLogger(__name__) @@ -28,8 +29,10 @@ WEBHOOK_TELEMETRY_TYPE = "telemetry" WEBHOOK_VALVE_TYPE = "valve" WEBHOOK_WIFI_CHANGED_TYPE = "wifi-changed" WEBHOOK_POWER_SUPPLY_CHANGED_TYPE = "power-supply-changed" +WEBHOOK_AUTO_SHUT_OFF = "auto-shut-off-report" PLATFORMS: list[Platform] = [ + Platform.EVENT, Platform.SENSOR, Platform.VALVE, ] @@ -120,6 +123,10 @@ def get_webhook_handler( coordinator_data.networking.rssi = data.rssi elif body_type == WEBHOOK_POWER_SUPPLY_CHANGED_TYPE: coordinator_data.state.power_supply = data.supply + elif body_type == WEBHOOK_AUTO_SHUT_OFF: + async_dispatcher_send( + hass, AUTO_SHUT_OFF_EVENT_NAME.format(data.type.lower()), data + ) coordinator.async_set_updated_data(coordinator_data) diff --git a/homeassistant/components/watergate/const.py b/homeassistant/components/watergate/const.py index 22a14330af9..c6726d9185f 100644 --- a/homeassistant/components/watergate/const.py +++ b/homeassistant/components/watergate/const.py @@ -3,3 +3,5 @@ DOMAIN = "watergate" MANUFACTURER = "Watergate" + +AUTO_SHUT_OFF_EVENT_NAME = "watergate_{}" diff --git a/homeassistant/components/watergate/event.py b/homeassistant/components/watergate/event.py new file mode 100644 index 00000000000..cf2447df4b3 --- /dev/null +++ b/homeassistant/components/watergate/event.py @@ -0,0 +1,78 @@ +"""Module contains the AutoShutOffEvent class for handling auto shut off events.""" + +from watergate_local_api.models.auto_shut_off_report import AutoShutOffReport + +from homeassistant.components.event import EventEntity, EventEntityDescription +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import WatergateConfigEntry +from .const import AUTO_SHUT_OFF_EVENT_NAME +from .coordinator import WatergateDataCoordinator +from .entity import WatergateEntity + +VOLUME_AUTO_SHUT_OFF = "volume_threshold" +DURATION_AUTO_SHUT_OFF = "duration_threshold" + + +DESCRIPTIONS: list[EventEntityDescription] = [ + EventEntityDescription( + translation_key="auto_shut_off_volume", + key="auto_shut_off_volume", + event_types=[VOLUME_AUTO_SHUT_OFF], + ), + EventEntityDescription( + translation_key="auto_shut_off_duration", + key="auto_shut_off_duration", + event_types=[DURATION_AUTO_SHUT_OFF], + ), +] + +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: WatergateConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Event entities from config entry.""" + + coordinator = config_entry.runtime_data + + async_add_entities( + AutoShutOffEvent(coordinator, description) for description in DESCRIPTIONS + ) + + +class AutoShutOffEvent(WatergateEntity, EventEntity): + """Event for Auto Shut Off.""" + + def __init__( + self, + coordinator: WatergateDataCoordinator, + entity_description: EventEntityDescription, + ) -> None: + """Initialize Auto Shut Off Entity.""" + super().__init__(coordinator, entity_description.key) + self.entity_description = entity_description + + async def async_added_to_hass(self): + """Register the callback for event handling when the entity is added.""" + await super().async_added_to_hass() + self.async_on_remove( + async_dispatcher_connect( + self.hass, + AUTO_SHUT_OFF_EVENT_NAME.format(self.event_types[0]), + self._async_handle_event, + ) + ) + + @callback + def _async_handle_event(self, event: AutoShutOffReport) -> None: + self._trigger_event( + event.type.lower(), + {"volume": event.volume, "duration": event.duration}, + ) + self.async_write_ha_state() diff --git a/homeassistant/components/watergate/icons.json b/homeassistant/components/watergate/icons.json new file mode 100644 index 00000000000..28a0bfbc825 --- /dev/null +++ b/homeassistant/components/watergate/icons.json @@ -0,0 +1,12 @@ +{ + "entity": { + "event": { + "auto_shut_off_volume": { + "default": "mdi:water" + }, + "auto_shut_off_duration": { + "default": "mdi:timelapse" + } + } + } +} diff --git a/homeassistant/components/watergate/quality_scale.yaml b/homeassistant/components/watergate/quality_scale.yaml index b116eff970e..73a39bd5264 100644 --- a/homeassistant/components/watergate/quality_scale.yaml +++ b/homeassistant/components/watergate/quality_scale.yaml @@ -17,10 +17,7 @@ rules: docs-high-level-description: done docs-installation-instructions: done docs-removal-instructions: done - entity-event-setup: - status: exempt - comment: | - Entities of this integration does not explicitly subscribe to events. + entity-event-setup: done entity-unique-id: done has-entity-name: done runtime-data: done diff --git a/homeassistant/components/watergate/strings.json b/homeassistant/components/watergate/strings.json index c312525e420..634e05e7973 100644 --- a/homeassistant/components/watergate/strings.json +++ b/homeassistant/components/watergate/strings.json @@ -19,6 +19,42 @@ } }, "entity": { + "event": { + "auto_shut_off_volume": { + "name": "Volume auto shut-off", + "state_attributes": { + "event_type": { + "state": { + "volume_threshold": "Volume", + "duration_threshold": "Duration" + } + }, + "volume": { + "name": "[%key:component::watergate::entity::event::auto_shut_off_volume::state_attributes::event_type::state::volume_threshold%]" + }, + "duration": { + "name": "[%key:component::watergate::entity::event::auto_shut_off_volume::state_attributes::event_type::state::duration_threshold%]" + } + } + }, + "auto_shut_off_duration": { + "name": "Duration auto shut-off", + "state_attributes": { + "event_type": { + "state": { + "volume_threshold": "[%key:component::watergate::entity::event::auto_shut_off_volume::state_attributes::event_type::state::volume_threshold%]", + "duration_threshold": "[%key:component::watergate::entity::event::auto_shut_off_volume::state_attributes::event_type::state::duration_threshold%]" + } + }, + "volume": { + "name": "[%key:component::watergate::entity::event::auto_shut_off_volume::state_attributes::event_type::state::volume_threshold%]" + }, + "duration": { + "name": "[%key:component::watergate::entity::event::auto_shut_off_volume::state_attributes::event_type::state::duration_threshold%]" + } + } + } + }, "sensor": { "water_meter_volume": { "name": "Water meter volume" diff --git a/tests/components/watergate/snapshots/test_event.ambr b/tests/components/watergate/snapshots/test_event.ambr new file mode 100644 index 00000000000..97f453697ca --- /dev/null +++ b/tests/components/watergate/snapshots/test_event.ambr @@ -0,0 +1,111 @@ +# serializer version: 1 +# name: test_event[event.sonic_duration_auto_shut_off-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'duration_threshold', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.sonic_duration_auto_shut_off', + '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': 'Duration auto shut-off', + 'platform': 'watergate', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'auto_shut_off_duration', + 'unique_id': 'a63182948ce2896a.auto_shut_off_duration', + 'unit_of_measurement': None, + }) +# --- +# name: test_event[event.sonic_duration_auto_shut_off-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'event_type': None, + 'event_types': list([ + 'duration_threshold', + ]), + 'friendly_name': 'Sonic Duration auto shut-off', + }), + 'context': , + 'entity_id': 'event.sonic_duration_auto_shut_off', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_event[event.sonic_volume_auto_shut_off-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'volume_threshold', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.sonic_volume_auto_shut_off', + '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': 'Volume auto shut-off', + 'platform': 'watergate', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'auto_shut_off_volume', + 'unique_id': 'a63182948ce2896a.auto_shut_off_volume', + 'unit_of_measurement': None, + }) +# --- +# name: test_event[event.sonic_volume_auto_shut_off-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'event_type': None, + 'event_types': list([ + 'volume_threshold', + ]), + 'friendly_name': 'Sonic Volume auto shut-off', + }), + 'context': , + 'entity_id': 'event.sonic_volume_auto_shut_off', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/watergate/test_event.py b/tests/components/watergate/test_event.py new file mode 100644 index 00000000000..6997c3f1fdf --- /dev/null +++ b/tests/components/watergate/test_event.py @@ -0,0 +1,84 @@ +"""Tests for the Watergate event entity platform.""" + +from collections.abc import Generator + +from freezegun.api import FrozenDateTimeFactory +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.typing import StateType + +from . import init_integration +from .const import MOCK_WEBHOOK_ID + +from tests.common import AsyncMock, MockConfigEntry, patch, snapshot_platform +from tests.typing import ClientSessionGenerator + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_event( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_entry: MockConfigEntry, + mock_watergate_client: Generator[AsyncMock], + freezer: FrozenDateTimeFactory, + snapshot: SnapshotAssertion, +) -> None: + """Test states of the sensor.""" + freezer.move_to("2021-01-09 12:00:00+00:00") + with patch("homeassistant.components.watergate.PLATFORMS", [Platform.EVENT]): + await init_integration(hass, mock_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_entry.entry_id) + + +@pytest.mark.parametrize( + ("entity_id", "event_type"), + [ + ("sonic_volume_auto_shut_off", "volume_threshold"), + ("sonic_duration_auto_shut_off", "duration_threshold"), + ], +) +async def test_auto_shut_off_webhook( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + mock_entry: MockConfigEntry, + mock_watergate_client: Generator[AsyncMock], + entity_id: str, + event_type: str, +) -> None: + """Test if water flow webhook is handled correctly.""" + await init_integration(hass, mock_entry) + + def assert_state(entity_id: str, expected_state: str): + state = hass.states.get(f"event.{entity_id}") + assert state.state == str(expected_state) + + assert_state(entity_id, "unknown") + + telemetry_change_data = { + "type": "auto-shut-off-report", + "data": { + "type": event_type, + "volume": 1500, + "duration": 30, + "timestamp": 1730148016, + }, + } + client = await hass_client_no_auth() + await client.post(f"/api/webhook/{MOCK_WEBHOOK_ID}", json=telemetry_change_data) + + await hass.async_block_till_done() + + def assert_extra_state( + entity_id: str, attribute: str, expected_attribute: StateType + ): + attributes = hass.states.get(f"event.{entity_id}").attributes + assert attributes.get(attribute) == expected_attribute + + assert_extra_state(entity_id, "event_type", event_type) + assert_extra_state(entity_id, "volume", 1500) + assert_extra_state(entity_id, "duration", 30) diff --git a/tests/components/watergate/test_sensor.py b/tests/components/watergate/test_sensor.py index 78e375857ed..0bf883a1955 100644 --- a/tests/components/watergate/test_sensor.py +++ b/tests/components/watergate/test_sensor.py @@ -1,4 +1,4 @@ -"""Tests for the Watergate valve platform.""" +"""Tests for the Watergate sensor platform.""" from collections.abc import Generator From 376604096049ac2388a1c9d23c578402acbce0b5 Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Tue, 25 Mar 2025 11:34:53 +0100 Subject: [PATCH 2997/3148] Promote after dependencies in bootstrap (#140352) --- homeassistant/bootstrap.py | 28 +++++++++++----------------- tests/test_bootstrap.py | 18 ++++++++++-------- 2 files changed, 21 insertions(+), 25 deletions(-) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 02a3b8c8fcc..962c7871028 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -859,8 +859,14 @@ async def _async_set_up_integrations( integrations, all_integrations = await _async_resolve_domains_and_preload( hass, config ) - all_domains = set(all_integrations) - domains = set(integrations) + # Detect all cycles + integrations_after_dependencies = ( + await loader.resolve_integrations_after_dependencies( + hass, all_integrations.values(), set(all_integrations) + ) + ) + all_domains = set(integrations_after_dependencies) + domains = set(integrations) & all_domains _LOGGER.info( "Domains to be set up: %s | %s", @@ -868,6 +874,8 @@ async def _async_set_up_integrations( all_domains - domains, ) + async_set_domains_to_be_loaded(hass, all_domains) + # Initialize recorder if "recorder" in all_domains: recorder.async_initialize_recorder(hass) @@ -900,24 +908,12 @@ async def _async_set_up_integrations( stage_dep_domains_unfiltered = { dep for domain in stage_domains - for dep in all_integrations[domain].all_dependencies + for dep in integrations_after_dependencies[domain] if dep not in stage_domains } stage_dep_domains = stage_dep_domains_unfiltered - hass.config.components stage_all_domains = stage_domains | stage_dep_domains - stage_all_integrations = { - domain: all_integrations[domain] for domain in stage_all_domains - } - # Detect all cycles - stage_integrations_after_dependencies = ( - await loader.resolve_integrations_after_dependencies( - hass, stage_all_integrations.values(), stage_all_domains - ) - ) - stage_all_domains = set(stage_integrations_after_dependencies) - stage_domains &= stage_all_domains - stage_dep_domains &= stage_all_domains _LOGGER.info( "Setting up stage %s: %s | %s\nDependencies: %s | %s", @@ -928,8 +924,6 @@ async def _async_set_up_integrations( stage_dep_domains_unfiltered - stage_dep_domains, ) - async_set_domains_to_be_loaded(hass, stage_all_domains) - if timeout is None: await _async_setup_multi_components(hass, stage_all_domains, config) continue diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index 1fb87ac5ef6..ca75dc51c56 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -252,8 +252,8 @@ async def test_setup_after_deps_all_present(hass: HomeAssistant) -> None: @pytest.mark.parametrize("load_registries", [False]) -async def test_setup_after_deps_in_stage_1_ignored(hass: HomeAssistant) -> None: - """Test after_dependencies are ignored in stage 1.""" +async def test_setup_after_deps_in_stage_1(hass: HomeAssistant) -> None: + """Test after_dependencies are promoted in stage 1.""" # This test relies on this assert "cloud" in bootstrap.STAGE_1_INTEGRATIONS order = [] @@ -295,7 +295,7 @@ async def test_setup_after_deps_in_stage_1_ignored(hass: HomeAssistant) -> None: assert "normal_integration" in hass.config.components assert "cloud" in hass.config.components - assert order == ["cloud", "an_after_dep", "normal_integration"] + assert order == ["an_after_dep", "normal_integration", "cloud"] @pytest.mark.parametrize("load_registries", [False]) @@ -304,7 +304,7 @@ async def test_setup_after_deps_manifests_are_loaded_even_if_not_setup( ) -> None: """Ensure we preload manifests for after deps even if they are not setup. - Its important that we preload the after dep manifests even if they are not setup + It's important that we preload the after dep manifests even if they are not setup since we will always have to check their requirements since any integration that lists an after dep may import it and we have to ensure requirements are up to date before the after dep can be imported. @@ -371,7 +371,7 @@ async def test_setup_after_deps_manifests_are_loaded_even_if_not_setup( assert "an_after_dep" not in hass.config.components assert "an_after_dep_of_after_dep" not in hass.config.components assert "an_after_dep_of_after_dep_of_after_dep" not in hass.config.components - assert order == ["cloud", "normal_integration"] + assert order == ["normal_integration", "cloud"] assert loader.async_get_loaded_integration(hass, "an_after_dep") is not None assert ( loader.async_get_loaded_integration(hass, "an_after_dep_of_after_dep") @@ -456,9 +456,9 @@ async def test_setup_frontend_before_recorder(hass: HomeAssistant) -> None: assert order == [ "http", + "an_after_dep", "frontend", "recorder", - "an_after_dep", "normal_integration", ] @@ -1577,8 +1577,10 @@ async def test_no_base_platforms_loaded_before_recorder(hass: HomeAssistant) -> assert not isinstance(integrations_or_excs, Exception) integrations[domain] = integration - integrations_all_dependencies = await loader.resolve_integrations_dependencies( - hass, integrations.values() + integrations_all_dependencies = ( + await loader.resolve_integrations_after_dependencies( + hass, integrations.values(), ignore_exceptions=True + ) ) all_integrations = integrations.copy() all_integrations.update( From 32a16ae0f09c7a141f9aa73ebc475d48a1d8d4e4 Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Tue, 25 Mar 2025 12:45:54 +0200 Subject: [PATCH 2998/3148] Make `UnitSystem` a frozen dataclass (#140954) * Make UnitSystem a frozen dataclass * Use super() for attribute setting in UnitSystem class --- homeassistant/util/unit_system.py | 33 +++++++++++++++++++++---------- 1 file changed, 23 insertions(+), 10 deletions(-) diff --git a/homeassistant/util/unit_system.py b/homeassistant/util/unit_system.py index 15993cbae47..055f435503f 100644 --- a/homeassistant/util/unit_system.py +++ b/homeassistant/util/unit_system.py @@ -2,6 +2,7 @@ from __future__ import annotations +from dataclasses import dataclass from numbers import Number from typing import TYPE_CHECKING, Final @@ -82,9 +83,21 @@ def _is_valid_unit(unit: str, unit_type: str) -> bool: return False +@dataclass(frozen=True, kw_only=True) class UnitSystem: """A container for units of measure.""" + _name: str + accumulated_precipitation_unit: UnitOfPrecipitationDepth + area_unit: UnitOfArea + length_unit: UnitOfLength + mass_unit: UnitOfMass + pressure_unit: UnitOfPressure + temperature_unit: UnitOfTemperature + volume_unit: UnitOfVolume + wind_speed_unit: UnitOfSpeed + _conversions: dict[tuple[SensorDeviceClass | str | None, str | None], str] + def __init__( self, name: str, @@ -118,16 +131,16 @@ class UnitSystem: if errors: raise ValueError(errors) - self._name = name - self.accumulated_precipitation_unit = accumulated_precipitation - self.area_unit = area - self.length_unit = length - self.mass_unit = mass - self.pressure_unit = pressure - self.temperature_unit = temperature - self.volume_unit = volume - self.wind_speed_unit = wind_speed - self._conversions = conversions + super().__setattr__("_name", name) + super().__setattr__("accumulated_precipitation_unit", accumulated_precipitation) + super().__setattr__("area_unit", area) + super().__setattr__("length_unit", length) + super().__setattr__("mass_unit", mass) + super().__setattr__("pressure_unit", pressure) + super().__setattr__("temperature_unit", temperature) + super().__setattr__("volume_unit", volume) + super().__setattr__("wind_speed_unit", wind_speed) + super().__setattr__("_conversions", conversions) def temperature(self, temperature: float, from_unit: str) -> float: """Convert the given temperature to this unit system.""" From 17efff940a697025d470b77a8514005895a49794 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 25 Mar 2025 12:49:43 +0100 Subject: [PATCH 2999/3148] Fix missing capitalization of two strings in `mysensors` (#141356) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit … and replace both duplicates with identical references. --- homeassistant/components/mysensors/strings.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/mysensors/strings.json b/homeassistant/components/mysensors/strings.json index 30fe5f46d6b..1636cb076cc 100644 --- a/homeassistant/components/mysensors/strings.json +++ b/homeassistant/components/mysensors/strings.json @@ -21,16 +21,16 @@ "device": "IP address of the gateway", "tcp_port": "[%key:common::config_flow::data::port%]", "version": "MySensors version", - "persistence_file": "persistence file (leave empty to auto-generate)" + "persistence_file": "Persistence file (leave empty to auto-generate)" } }, "gw_serial": { "description": "Serial gateway setup", "data": { "device": "Serial port", - "baud_rate": "baud rate", + "baud_rate": "Baud rate", "version": "[%key:component::mysensors::config::step::gw_tcp::data::version%]", - "persistence_file": "Persistence file (leave empty to auto-generate)" + "persistence_file": "[%key:component::mysensors::config::step::gw_tcp::data::persistence_file%]" } }, "gw_mqtt": { @@ -40,7 +40,7 @@ "topic_in_prefix": "Prefix for input topics (topic_in_prefix)", "topic_out_prefix": "Prefix for output topics (topic_out_prefix)", "version": "[%key:component::mysensors::config::step::gw_tcp::data::version%]", - "persistence_file": "[%key:component::mysensors::config::step::gw_serial::data::persistence_file%]" + "persistence_file": "[%key:component::mysensors::config::step::gw_tcp::data::persistence_file%]" } } }, From 77c210fb87a95d5248cfd8161eec405b0557b8f4 Mon Sep 17 00:00:00 2001 From: Maikel Punie Date: Tue, 25 Mar 2025 13:05:46 +0100 Subject: [PATCH 3000/3148] Velbus add missing translations (#141358) Fix the translation items for Velbus --- homeassistant/components/velbus/strings.json | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/velbus/strings.json b/homeassistant/components/velbus/strings.json index a50395af115..b4b6ae20d13 100644 --- a/homeassistant/components/velbus/strings.json +++ b/homeassistant/components/velbus/strings.json @@ -2,10 +2,11 @@ "config": { "step": { "user": { - "title": "Define the Velbus connection type", - "data": { - "name": "The name for this Velbus connection", - "port": "Connection string" + "title": "Define the Velbus connection", + "description": "How do you want to configure the Velbus hub?", + "menu_options": { + "network": "Via a network connection", + "usbselect": "Via an USB device" } }, "network": { From 0ddf3c794be549d4a3a3a576d065b3bdcffbdf9d Mon Sep 17 00:00:00 2001 From: jukrebs <76174575+MaestroOnICe@users.noreply.github.com> Date: Tue, 25 Mar 2025 13:26:07 +0100 Subject: [PATCH 3001/3148] Add attachment and connection status for IOmeter (#140998) * add binary sensors * fix: suggestion value_fn * add snapshot test and split cases --- homeassistant/components/iometer/__init__.py | 2 +- .../components/iometer/binary_sensor.py | 87 +++++++++++ homeassistant/components/iometer/strings.json | 8 ++ tests/components/iometer/__init__.py | 14 +- tests/components/iometer/conftest.py | 1 + .../iometer/snapshots/test_binary_sensor.ambr | 97 +++++++++++++ .../components/iometer/test_binary_sensor.py | 135 ++++++++++++++++++ tests/components/iometer/test_init.py | 6 +- 8 files changed, 343 insertions(+), 7 deletions(-) create mode 100644 homeassistant/components/iometer/binary_sensor.py create mode 100644 tests/components/iometer/snapshots/test_binary_sensor.ambr create mode 100644 tests/components/iometer/test_binary_sensor.py diff --git a/homeassistant/components/iometer/__init__.py b/homeassistant/components/iometer/__init__.py index bbf046e70e9..feb7ce9b8cf 100644 --- a/homeassistant/components/iometer/__init__.py +++ b/homeassistant/components/iometer/__init__.py @@ -12,7 +12,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from .coordinator import IOmeterConfigEntry, IOMeterCoordinator -PLATFORMS: list[Platform] = [Platform.SENSOR] +PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: IOmeterConfigEntry) -> bool: diff --git a/homeassistant/components/iometer/binary_sensor.py b/homeassistant/components/iometer/binary_sensor.py new file mode 100644 index 00000000000..f443c4ae94a --- /dev/null +++ b/homeassistant/components/iometer/binary_sensor.py @@ -0,0 +1,87 @@ +"""IOmeter binary sensor.""" + +from collections.abc import Callable +from dataclasses import dataclass + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import IOMeterCoordinator, IOmeterData +from .entity import IOmeterEntity + + +@dataclass(frozen=True, kw_only=True) +class IOmeterBinarySensorDescription(BinarySensorEntityDescription): + """Describes Iometer binary sensor entity.""" + + value_fn: Callable[[IOmeterData], bool | None] + + +SENSOR_TYPES: list[IOmeterBinarySensorDescription] = [ + IOmeterBinarySensorDescription( + key="connection_status", + translation_key="connection_status", + device_class=BinarySensorDeviceClass.CONNECTIVITY, + entity_registry_enabled_default=False, + value_fn=lambda data: ( + data.status.device.core.connection_status == "connected" + if data.status.device.core.connection_status is not None + else None + ), + ), + IOmeterBinarySensorDescription( + key="attachment_status", + translation_key="attachment_status", + device_class=BinarySensorDeviceClass.CONNECTIVITY, + entity_registry_enabled_default=False, + value_fn=lambda data: ( + data.status.device.core.attachment_status == "attached" + if data.status.device.core.attachment_status is not None + else None + ), + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Sensors.""" + coordinator: IOMeterCoordinator = config_entry.runtime_data + + async_add_entities( + IOmeterBinarySensor( + coordinator=coordinator, + description=description, + ) + for description in SENSOR_TYPES + ) + + +class IOmeterBinarySensor(IOmeterEntity, BinarySensorEntity): + """Defines a IOmeter binary sensor.""" + + entity_description: IOmeterBinarySensorDescription + + def __init__( + self, + coordinator: IOMeterCoordinator, + description: IOmeterBinarySensorDescription, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = f"{coordinator.identifier}_{description.key}" + + @property + def is_on(self) -> bool | None: + """Return the binary sensor state.""" + return self.entity_description.value_fn(self.coordinator.data) diff --git a/homeassistant/components/iometer/strings.json b/homeassistant/components/iometer/strings.json index 31deb16aa9c..b3878dd1b53 100644 --- a/homeassistant/components/iometer/strings.json +++ b/homeassistant/components/iometer/strings.json @@ -60,6 +60,14 @@ "wifi_rssi": { "name": "Signal strength Wi-Fi" } + }, + "binary_sensor": { + "connection_status": { + "name": "Core/Bridge connection status" + }, + "attachment_status": { + "name": "Core attachment status" + } } } } diff --git a/tests/components/iometer/__init__.py b/tests/components/iometer/__init__.py index 9e48fb982b3..19fe2124f1f 100644 --- a/tests/components/iometer/__init__.py +++ b/tests/components/iometer/__init__.py @@ -1,13 +1,19 @@ """Tests for the IOmeter integration.""" +from unittest.mock import patch + +from homeassistant.const import Platform 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.""" +async def setup_platform( + hass: HomeAssistant, config_entry: MockConfigEntry, platforms: list[Platform] +) -> MockConfigEntry: + """Fixture for setting up the IOmeter platform.""" config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() + with patch("homeassistant.components.iometer.PLATFORMS", platforms): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/iometer/conftest.py b/tests/components/iometer/conftest.py index ee45021952e..f8139c7c64c 100644 --- a/tests/components/iometer/conftest.py +++ b/tests/components/iometer/conftest.py @@ -54,4 +54,5 @@ def mock_config_entry() -> MockConfigEntry: title="IOmeter-1ISK0000000000", data={CONF_HOST: "10.0.0.2"}, unique_id="658c2b34-2017-45f2-a12b-731235f8bb97", + entry_id="01JQ6G5395176MAAWKAAPEZHV6", ) diff --git a/tests/components/iometer/snapshots/test_binary_sensor.ambr b/tests/components/iometer/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000..38aab735a14 --- /dev/null +++ b/tests/components/iometer/snapshots/test_binary_sensor.ambr @@ -0,0 +1,97 @@ +# serializer version: 1 +# name: test_binary_sensors[binary_sensor.iometer_1isk0000000000_core_attachment_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.iometer_1isk0000000000_core_attachment_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Core attachment status', + 'platform': 'iometer', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'attachment_status', + 'unique_id': '01JQ6G5395176MAAWKAAPEZHV6_attachment_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.iometer_1isk0000000000_core_attachment_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'IOmeter-1ISK0000000000 Core attachment status', + }), + 'context': , + 'entity_id': 'binary_sensor.iometer_1isk0000000000_core_attachment_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensors[binary_sensor.iometer_1isk0000000000_core_bridge_connection_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.iometer_1isk0000000000_core_bridge_connection_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Core/Bridge connection status', + 'platform': 'iometer', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'connection_status', + 'unique_id': '01JQ6G5395176MAAWKAAPEZHV6_connection_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.iometer_1isk0000000000_core_bridge_connection_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'IOmeter-1ISK0000000000 Core/Bridge connection status', + }), + 'context': , + 'entity_id': 'binary_sensor.iometer_1isk0000000000_core_bridge_connection_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/iometer/test_binary_sensor.py b/tests/components/iometer/test_binary_sensor.py new file mode 100644 index 00000000000..e007084567e --- /dev/null +++ b/tests/components/iometer/test_binary_sensor.py @@ -0,0 +1,135 @@ +"""Test the IOmeter binary sensors.""" + +from datetime import timedelta +from unittest.mock import AsyncMock + +from freezegun.api import FrozenDateTimeFactory +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNKNOWN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_platform + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_binary_sensors( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_iometer_client: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test binary sensors.""" + await setup_platform(hass, mock_config_entry, [Platform.BINARY_SENSOR]) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_connection_status_sensors( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_iometer_client: AsyncMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test connection status sensor.""" + await setup_platform(hass, mock_config_entry, [Platform.BINARY_SENSOR]) + + assert ( + hass.states.get( + "binary_sensor.iometer_1isk0000000000_core_bridge_connection_status" + ).state + == STATE_ON + ) + + freezer.tick(delta=timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + mock_iometer_client.get_current_status.return_value.device.core.connection_status = "disconnected" + + freezer.tick(delta=timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert ( + hass.states.get( + "binary_sensor.iometer_1isk0000000000_core_bridge_connection_status" + ).state + == STATE_OFF + ) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_attachment_status_sensors( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_iometer_client: AsyncMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test connection status sensor.""" + await setup_platform(hass, mock_config_entry, [Platform.BINARY_SENSOR]) + + assert ( + hass.states.get( + "binary_sensor.iometer_1isk0000000000_core_attachment_status" + ).state + == STATE_ON + ) + + freezer.tick(delta=timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + mock_iometer_client.get_current_status.return_value.device.core.attachment_status = "detached" + + freezer.tick(delta=timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert ( + hass.states.get( + "binary_sensor.iometer_1isk0000000000_core_attachment_status" + ).state + == STATE_OFF + ) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_attachment_status_sensors_unkown( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_iometer_client: AsyncMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test connection status sensor.""" + await setup_platform(hass, mock_config_entry, [Platform.BINARY_SENSOR]) + + assert ( + hass.states.get( + "binary_sensor.iometer_1isk0000000000_core_attachment_status" + ).state + == STATE_ON + ) + + freezer.tick(delta=timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + mock_iometer_client.get_current_status.return_value.device.core.attachment_status = None + + freezer.tick(delta=timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert ( + hass.states.get( + "binary_sensor.iometer_1isk0000000000_core_attachment_status" + ).state + == STATE_UNKNOWN + ) diff --git a/tests/components/iometer/test_init.py b/tests/components/iometer/test_init.py index 22a20b50c60..9d8eadc5079 100644 --- a/tests/components/iometer/test_init.py +++ b/tests/components/iometer/test_init.py @@ -6,10 +6,11 @@ from unittest.mock import AsyncMock from freezegun.api import FrozenDateTimeFactory from homeassistant.components.iometer.const import DOMAIN +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr -from . import setup_integration +from . import setup_platform from tests.common import MockConfigEntry, async_fire_time_changed @@ -22,7 +23,8 @@ async def test_new_firmware_version( freezer: FrozenDateTimeFactory, ) -> None: """Test device registry integration.""" - await setup_integration(hass, mock_config_entry) + # await setup_integration(hass, mock_config_entry) + await setup_platform(hass, mock_config_entry, [Platform.SENSOR]) device_entry = device_registry.async_get_device( identifiers={(DOMAIN, mock_config_entry.unique_id)} ) From f00fb1d9a3b7a0f6143f852dafd7fb1539f1eb53 Mon Sep 17 00:00:00 2001 From: Piotr Machowski <6118709+PiotrMachowski@users.noreply.github.com> Date: Tue, 25 Mar 2025 13:34:19 +0100 Subject: [PATCH 3002/3148] Add media_player support to SmartThings integration (#141296) * Initial soundbar support * Soundbar support * Add SAMSUNG_VD_AUDIO_INPUT_SOURCE capability * Adjust setting input source * Add unit tests for media_player platform * Adjust code after merge * Adjust code after merge * Adjust code style * Adjust code style * Fix * Fix --------- Co-authored-by: Piotr Machowski Co-authored-by: Joostlek --- .../components/smartthings/__init__.py | 1 + .../components/smartthings/media_player.py | 348 ++++++++++++++ tests/components/smartthings/conftest.py | 1 + .../device_status/hw_q80r_soundbar.json | 173 +++++++ .../fixtures/devices/hw_q80r_soundbar.json | 106 +++++ .../smartthings/snapshots/test_init.ambr | 33 ++ .../snapshots/test_media_player.ambr | 233 ++++++++++ .../smartthings/snapshots/test_sensor.ambr | 176 +++++++ .../smartthings/snapshots/test_switch.ambr | 47 ++ .../smartthings/test_media_player.py | 432 ++++++++++++++++++ 10 files changed, 1550 insertions(+) create mode 100644 homeassistant/components/smartthings/media_player.py create mode 100644 tests/components/smartthings/fixtures/device_status/hw_q80r_soundbar.json create mode 100644 tests/components/smartthings/fixtures/devices/hw_q80r_soundbar.json create mode 100644 tests/components/smartthings/snapshots/test_media_player.ambr create mode 100644 tests/components/smartthings/test_media_player.py diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py index a8d28e0503f..e4d50fb3590 100644 --- a/homeassistant/components/smartthings/__init__.py +++ b/homeassistant/components/smartthings/__init__.py @@ -89,6 +89,7 @@ PLATFORMS = [ Platform.FAN, Platform.LIGHT, Platform.LOCK, + Platform.MEDIA_PLAYER, Platform.NUMBER, Platform.SCENE, Platform.SELECT, diff --git a/homeassistant/components/smartthings/media_player.py b/homeassistant/components/smartthings/media_player.py new file mode 100644 index 00000000000..f39a4716ea1 --- /dev/null +++ b/homeassistant/components/smartthings/media_player.py @@ -0,0 +1,348 @@ +"""Support for media players through the SmartThings cloud API.""" + +from __future__ import annotations + +from typing import Any + +from pysmartthings import Attribute, Capability, Category, Command, SmartThings + +from homeassistant.components.media_player import ( + MediaPlayerDeviceClass, + MediaPlayerEntity, + MediaPlayerEntityFeature, + MediaPlayerState, + RepeatMode, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import FullDevice, SmartThingsConfigEntry +from .const import MAIN +from .entity import SmartThingsEntity + +MEDIA_PLAYER_CAPABILITIES = ( + Capability.AUDIO_MUTE, + Capability.AUDIO_TRACK_DATA, + Capability.AUDIO_VOLUME, + Capability.MEDIA_PLAYBACK, +) + +CONTROLLABLE_SOURCES = ["bluetooth", "wifi"] + +DEVICE_CLASS_MAP: dict[Category | str, MediaPlayerDeviceClass] = { + Category.NETWORK_AUDIO: MediaPlayerDeviceClass.SPEAKER, + Category.SPEAKER: MediaPlayerDeviceClass.SPEAKER, + Category.TELEVISION: MediaPlayerDeviceClass.TV, + Category.RECEIVER: MediaPlayerDeviceClass.RECEIVER, +} + +VALUE_TO_STATE = { + "buffering": MediaPlayerState.BUFFERING, + "paused": MediaPlayerState.PAUSED, + "playing": MediaPlayerState.PLAYING, + "stopped": MediaPlayerState.IDLE, + "fast forwarding": MediaPlayerState.BUFFERING, + "rewinding": MediaPlayerState.BUFFERING, +} + +REPEAT_MODE_TO_HA = { + "all": RepeatMode.ALL, + "one": RepeatMode.ONE, + "off": RepeatMode.OFF, +} + +HA_REPEAT_MODE_TO_SMARTTHINGS = {v: k for k, v in REPEAT_MODE_TO_HA.items()} + + +async def async_setup_entry( + hass: HomeAssistant, + entry: SmartThingsConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Add media players for a config entry.""" + entry_data = entry.runtime_data + + async_add_entities( + SmartThingsMediaPlayer(entry_data.client, device) + for device in entry_data.devices.values() + if all( + capability in device.status[MAIN] + for capability in MEDIA_PLAYER_CAPABILITIES + ) + ) + + +class SmartThingsMediaPlayer(SmartThingsEntity, MediaPlayerEntity): + """Define a SmartThings media player.""" + + _attr_name = None + + def __init__(self, client: SmartThings, device: FullDevice) -> None: + """Initialize the media_player class.""" + super().__init__( + client, + device, + { + Capability.AUDIO_MUTE, + Capability.AUDIO_TRACK_DATA, + Capability.AUDIO_VOLUME, + Capability.MEDIA_INPUT_SOURCE, + Capability.MEDIA_PLAYBACK, + Capability.MEDIA_PLAYBACK_REPEAT, + Capability.MEDIA_PLAYBACK_SHUFFLE, + Capability.SAMSUNG_VD_AUDIO_INPUT_SOURCE, + Capability.SWITCH, + }, + ) + self._attr_supported_features = self._determine_features() + self._attr_device_class = DEVICE_CLASS_MAP.get( + device.device.components[MAIN].user_category + or device.device.components[MAIN].manufacturer_category, + ) + + def _determine_features(self) -> MediaPlayerEntityFeature: + flags = MediaPlayerEntityFeature(0) + playback_commands = self.get_attribute_value( + Capability.MEDIA_PLAYBACK, Attribute.SUPPORTED_PLAYBACK_COMMANDS + ) + if "play" in playback_commands: + flags |= MediaPlayerEntityFeature.PLAY + if "pause" in playback_commands: + flags |= MediaPlayerEntityFeature.PAUSE + if "stop" in playback_commands: + flags |= MediaPlayerEntityFeature.STOP + if "rewind" in playback_commands: + flags |= MediaPlayerEntityFeature.PREVIOUS_TRACK + if "fastForward" in playback_commands: + flags |= MediaPlayerEntityFeature.NEXT_TRACK + if self.supports_capability(Capability.AUDIO_VOLUME): + flags |= ( + MediaPlayerEntityFeature.VOLUME_SET + | MediaPlayerEntityFeature.VOLUME_STEP + ) + if self.supports_capability(Capability.AUDIO_MUTE): + flags |= MediaPlayerEntityFeature.VOLUME_MUTE + if self.supports_capability(Capability.SWITCH): + flags |= ( + MediaPlayerEntityFeature.TURN_ON | MediaPlayerEntityFeature.TURN_OFF + ) + if self.supports_capability(Capability.MEDIA_INPUT_SOURCE): + flags |= MediaPlayerEntityFeature.SELECT_SOURCE + if self.supports_capability(Capability.MEDIA_PLAYBACK_SHUFFLE): + flags |= MediaPlayerEntityFeature.SHUFFLE_SET + if self.supports_capability(Capability.MEDIA_PLAYBACK_REPEAT): + flags |= MediaPlayerEntityFeature.REPEAT_SET + return flags + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the media player off.""" + await self.execute_device_command( + Capability.SWITCH, + Command.OFF, + ) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the media player on.""" + await self.execute_device_command( + Capability.SWITCH, + Command.ON, + ) + + async def async_mute_volume(self, mute: bool) -> None: + """Mute volume.""" + await self.execute_device_command( + Capability.AUDIO_MUTE, + Command.SET_MUTE, + argument="muted" if mute else "unmuted", + ) + + async def async_set_volume_level(self, volume: float) -> None: + """Set volume level.""" + await self.execute_device_command( + Capability.AUDIO_VOLUME, + Command.SET_VOLUME, + argument=int(volume * 100), + ) + + async def async_volume_up(self) -> None: + """Increase volume.""" + await self.execute_device_command( + Capability.AUDIO_VOLUME, + Command.VOLUME_UP, + ) + + async def async_volume_down(self) -> None: + """Decrease volume.""" + await self.execute_device_command( + Capability.AUDIO_VOLUME, + Command.VOLUME_DOWN, + ) + + async def async_media_play(self) -> None: + """Play media.""" + await self.execute_device_command( + Capability.MEDIA_PLAYBACK, + Command.PLAY, + ) + + async def async_media_pause(self) -> None: + """Pause media.""" + await self.execute_device_command( + Capability.MEDIA_PLAYBACK, + Command.PAUSE, + ) + + async def async_media_stop(self) -> None: + """Stop media.""" + await self.execute_device_command( + Capability.MEDIA_PLAYBACK, + Command.STOP, + ) + + async def async_media_previous_track(self) -> None: + """Previous track.""" + await self.execute_device_command( + Capability.MEDIA_PLAYBACK, + Command.REWIND, + ) + + async def async_media_next_track(self) -> None: + """Next track.""" + await self.execute_device_command( + Capability.MEDIA_PLAYBACK, + Command.FAST_FORWARD, + ) + + async def async_select_source(self, source: str) -> None: + """Select source.""" + await self.execute_device_command( + Capability.MEDIA_INPUT_SOURCE, + Command.SET_INPUT_SOURCE, + argument=source, + ) + + async def async_set_shuffle(self, shuffle: bool) -> None: + """Set shuffle mode.""" + await self.execute_device_command( + Capability.MEDIA_PLAYBACK_SHUFFLE, + Command.SET_PLAYBACK_SHUFFLE, + argument="enabled" if shuffle else "disabled", + ) + + async def async_set_repeat(self, repeat: RepeatMode) -> None: + """Set repeat mode.""" + await self.execute_device_command( + Capability.MEDIA_PLAYBACK_REPEAT, + Command.SET_PLAYBACK_REPEAT_MODE, + argument=HA_REPEAT_MODE_TO_SMARTTHINGS[repeat], + ) + + @property + def media_title(self) -> str | None: + """Title of current playing media.""" + if ( + track_data := self.get_attribute_value( + Capability.AUDIO_TRACK_DATA, Attribute.AUDIO_TRACK_DATA + ) + ) is None: + return None + return track_data.get("title", None) + + @property + def media_artist(self) -> str | None: + """Artist of current playing media.""" + if ( + track_data := self.get_attribute_value( + Capability.AUDIO_TRACK_DATA, Attribute.AUDIO_TRACK_DATA + ) + ) is None: + return None + return track_data.get("artist") + + @property + def state(self) -> MediaPlayerState | None: + """State of the media player.""" + if self.supports_capability(Capability.SWITCH): + if self.get_attribute_value(Capability.SWITCH, Attribute.SWITCH) == "on": + if ( + self.source is not None + and self.source in CONTROLLABLE_SOURCES + and self.get_attribute_value( + Capability.MEDIA_PLAYBACK, Attribute.PLAYBACK_STATUS + ) + in VALUE_TO_STATE + ): + return VALUE_TO_STATE[ + self.get_attribute_value( + Capability.MEDIA_PLAYBACK, Attribute.PLAYBACK_STATUS + ) + ] + return MediaPlayerState.ON + return MediaPlayerState.OFF + return VALUE_TO_STATE[ + self.get_attribute_value( + Capability.MEDIA_PLAYBACK, Attribute.PLAYBACK_STATUS + ) + ] + + @property + def is_volume_muted(self) -> bool: + """Returns if the volume is muted.""" + return ( + self.get_attribute_value(Capability.AUDIO_MUTE, Attribute.MUTE) == "muted" + ) + + @property + def volume_level(self) -> float: + """Volume level.""" + return self.get_attribute_value(Capability.AUDIO_VOLUME, Attribute.VOLUME) / 100 + + @property + def source(self) -> str | None: + """Input source.""" + if self.supports_capability(Capability.MEDIA_INPUT_SOURCE): + return self.get_attribute_value( + Capability.MEDIA_INPUT_SOURCE, Attribute.INPUT_SOURCE + ) + if self.supports_capability(Capability.SAMSUNG_VD_AUDIO_INPUT_SOURCE): + return self.get_attribute_value( + Capability.SAMSUNG_VD_AUDIO_INPUT_SOURCE, Attribute.INPUT_SOURCE + ) + return None + + @property + def source_list(self) -> list[str] | None: + """List of input sources.""" + if self.supports_capability(Capability.MEDIA_INPUT_SOURCE): + return self.get_attribute_value( + Capability.MEDIA_INPUT_SOURCE, Attribute.SUPPORTED_INPUT_SOURCES + ) + if self.supports_capability(Capability.SAMSUNG_VD_AUDIO_INPUT_SOURCE): + return self.get_attribute_value( + Capability.SAMSUNG_VD_AUDIO_INPUT_SOURCE, + Attribute.SUPPORTED_INPUT_SOURCES, + ) + return None + + @property + def shuffle(self) -> bool | None: + """Returns if shuffle mode is set.""" + if self.supports_capability(Capability.MEDIA_PLAYBACK_SHUFFLE): + return ( + self.get_attribute_value( + Capability.MEDIA_PLAYBACK_SHUFFLE, Attribute.PLAYBACK_SHUFFLE + ) + == "enabled" + ) + return None + + @property + def repeat(self) -> RepeatMode | None: + """Returns if repeat mode is set.""" + if self.supports_capability(Capability.MEDIA_PLAYBACK_REPEAT): + return REPEAT_MODE_TO_HA[ + self.get_attribute_value( + Capability.MEDIA_PLAYBACK_REPEAT, Attribute.PLAYBACK_REPEAT_MODE + ) + ] + return None diff --git a/tests/components/smartthings/conftest.py b/tests/components/smartthings/conftest.py index a19c78dcc00..edcd2f980fa 100644 --- a/tests/components/smartthings/conftest.py +++ b/tests/components/smartthings/conftest.py @@ -140,6 +140,7 @@ def mock_smartthings() -> Generator[AsyncMock]: "tplink_p110", "ikea_kadrilj", "aux_ac", + "hw_q80r_soundbar", ] ) def device_fixture( diff --git a/tests/components/smartthings/fixtures/device_status/hw_q80r_soundbar.json b/tests/components/smartthings/fixtures/device_status/hw_q80r_soundbar.json new file mode 100644 index 00000000000..8cd0d3e35a9 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/hw_q80r_soundbar.json @@ -0,0 +1,173 @@ +{ + "components": { + "main": { + "mediaPlayback": { + "supportedPlaybackCommands": { + "value": ["play", "pause", "stop"], + "timestamp": "2025-03-23T01:10:02.207Z" + }, + "playbackStatus": { + "value": "playing", + "timestamp": "2025-03-23T01:19:44.622Z" + } + }, + "samsungvd.groupInfo": { + "role": { + "value": "none", + "timestamp": "2025-03-23T01:17:10.965Z" + }, + "channel": { + "value": "all", + "timestamp": "2025-03-23T01:17:10.965Z" + }, + "masterName": { + "value": "", + "timestamp": "2025-03-23T01:17:10.965Z" + }, + "status": { + "value": "single", + "timestamp": "2025-03-23T01:17:10.965Z" + } + }, + "audioVolume": { + "volume": { + "value": 1, + "unit": "%", + "timestamp": "2025-03-23T01:17:13.754Z" + } + }, + "ocf": { + "st": { + "value": "NONE", + "timestamp": "2024-12-18T21:07:25.406Z" + }, + "mndt": { + "value": "2018-01-01", + "timestamp": "2024-12-18T21:07:25.406Z" + }, + "mnfv": { + "value": "HW-Q80RWWB-1012.6", + "timestamp": "2024-12-18T21:07:25.406Z" + }, + "mnhw": { + "value": "0-0", + "timestamp": "2024-12-18T21:07:25.406Z" + }, + "di": { + "value": "afcf3b91-48fe-4c3b-ab44-ddff2a0a6577", + "timestamp": "2024-12-18T21:07:25.406Z" + }, + "mnsl": { + "value": "http://www.samsung.com/sec/audio-video/", + "timestamp": "2024-12-18T21:07:25.406Z" + }, + "dmv": { + "value": "res.1.1.0,sh.1.1.0", + "timestamp": "2024-12-18T21:07:25.406Z" + }, + "n": { + "value": "[AV] Samsung Soundbar Q80R", + "timestamp": "2024-12-18T21:07:25.406Z" + }, + "mnmo": { + "value": "Q80R", + "timestamp": "2024-12-18T21:07:25.406Z" + }, + "vid": { + "value": "VD-NetworkAudio-001S", + "timestamp": "2024-12-18T21:07:25.406Z" + }, + "mnmn": { + "value": "Samsung Electronics", + "timestamp": "2024-12-18T21:07:25.406Z" + }, + "mnml": { + "value": "http://www.samsung.com", + "timestamp": "2024-12-18T21:07:25.406Z" + }, + "mnpv": { + "value": "Tizen 4.0", + "timestamp": "2024-12-18T21:07:25.406Z" + }, + "mnos": { + "value": "4.1.10", + "timestamp": "2024-12-18T21:07:25.406Z" + }, + "pi": { + "value": "afcf3b91-48fe-4c3b-ab44-ddff2a0a6577", + "timestamp": "2024-12-18T21:07:25.406Z" + }, + "icv": { + "value": "core.1.1.0", + "timestamp": "2024-12-18T21:07:25.406Z" + } + }, + "mediaInputSource": { + "supportedInputSources": { + "value": ["wifi", "bluetooth", "HDMI1", "HDMI2", "digital"], + "timestamp": "2025-03-23T01:18:01.663Z" + }, + "inputSource": { + "value": "wifi", + "timestamp": "2025-03-23T01:18:01.663Z" + } + }, + "refresh": {}, + "audioNotification": {}, + "audioMute": { + "mute": { + "value": "unmuted", + "timestamp": "2025-03-23T01:17:11.024Z" + } + }, + "execute": { + "data": { + "value": { + "payload": { + "rt": ["x.com.samsung.networkaudio.soundmode"], + "if": ["oic.if.a", "oic.if.baseline"], + "x.com.samsung.networkaudio.soundmode": "standard" + } + }, + "data": { + "href": "/sec/networkaudio/soundmode" + }, + "timestamp": "2023-07-16T23:16:55.582Z" + } + }, + "samsungvd.audioInputSource": { + "supportedInputSources": { + "value": ["wifi", "bluetooth", "HDMI1", "HDMI2", "digital"], + "timestamp": "2025-03-23T01:18:01.663Z" + }, + "inputSource": { + "value": "wificp", + "timestamp": "2025-03-23T01:18:01.663Z" + } + }, + "switch": { + "switch": { + "value": "on", + "timestamp": "2025-03-23T01:19:44.837Z" + } + }, + "audioTrackData": { + "totalTime": { + "value": null, + "timestamp": "2020-07-30T16:09:09.109Z" + }, + "audioTrackData": { + "value": { + "title": "Never Gonna Give You Up", + "artist": "Rick Astley" + }, + "timestamp": "2025-03-23T01:19:15.067Z" + }, + "elapsedTime": { + "value": null, + "timestamp": "2020-07-30T16:09:09.109Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/devices/hw_q80r_soundbar.json b/tests/components/smartthings/fixtures/devices/hw_q80r_soundbar.json new file mode 100644 index 00000000000..5f99cefddcb --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/hw_q80r_soundbar.json @@ -0,0 +1,106 @@ +{ + "items": [ + { + "deviceId": "afcf3b91-0000-1111-2222-ddff2a0a6577", + "name": "[AV] Samsung Soundbar Q80R", + "label": "Soundbar", + "manufacturerName": "Samsung Electronics", + "presentationId": "VD-NetworkAudio-001S", + "deviceManufacturerCode": "Samsung Electronics", + "locationId": "c7f8e400-0000-1111-2222-76463f4eb484", + "ownerId": "bd0d9288-0000-1111-2222-68310a42a709", + "roomId": "be09ff51-0000-1111-2222-e48e2dab37fd", + "deviceTypeName": "Samsung OCF Network Audio Player", + "components": [ + { + "id": "main", + "label": "Soundbar", + "capabilities": [ + { + "id": "ocf", + "version": 1 + }, + { + "id": "execute", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "switch", + "version": 1 + }, + { + "id": "audioVolume", + "version": 1 + }, + { + "id": "audioMute", + "version": 1 + }, + { + "id": "audioTrackData", + "version": 1 + }, + { + "id": "mediaInputSource", + "version": 1 + }, + { + "id": "samsungvd.audioInputSource", + "version": 1 + }, + { + "id": "mediaPlayback", + "version": 1 + }, + { + "id": "audioNotification", + "version": 1 + }, + { + "id": "samsungvd.groupInfo", + "version": 1 + } + ], + "categories": [ + { + "name": "NetworkAudio", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2020-10-19T01:35:08Z", + "profile": { + "id": "c1036d88-000-1111-2222-a361463fd53f" + }, + "ocf": { + "ocfDeviceType": "oic.d.networkaudio", + "name": "[AV] Samsung Soundbar Q80R", + "specVersion": "core.1.1.0", + "verticalDomainSpecVersion": "res.1.1.0,sh.1.1.0", + "manufacturerName": "Samsung Electronics", + "modelNumber": "Q80R", + "platformVersion": "Tizen 4.0", + "platformOS": "4.1.10", + "hwVersion": "0-0", + "firmwareVersion": "HW-Q80RWWB-1012.6", + "vendorId": "VD-NetworkAudio-001S", + "vendorResourceClientServerVersion": "1.2", + "locale": "KO", + "lastSignupTime": "2021-01-16T07:05:02.184545Z", + "transferCandidate": false, + "additionalAuthCodeRequired": false + }, + "type": "OCF", + "restrictionTier": 0, + "allowed": null, + "executionContext": "CLOUD", + "relationships": [] + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/snapshots/test_init.ambr b/tests/components/smartthings/snapshots/test_init.ambr index d6e98553015..507a9a8b3a6 100644 --- a/tests/components/smartthings/snapshots/test_init.ambr +++ b/tests/components/smartthings/snapshots/test_init.ambr @@ -1157,6 +1157,39 @@ 'via_device_id': None, }) # --- +# name: test_devices[hw_q80r_soundbar] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '0-0', + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + 'afcf3b91-0000-1111-2222-ddff2a0a6577', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Samsung Electronics', + 'model': 'Q80R', + 'model_id': None, + 'name': 'Soundbar', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': 'HW-Q80RWWB-1012.6', + 'via_device_id': None, + }) +# --- # name: test_devices[ikea_kadrilj] DeviceRegistryEntrySnapshot({ 'area_id': None, diff --git a/tests/components/smartthings/snapshots/test_media_player.ambr b/tests/components/smartthings/snapshots/test_media_player.ambr new file mode 100644 index 00000000000..193c0c8e296 --- /dev/null +++ b/tests/components/smartthings/snapshots/test_media_player.ambr @@ -0,0 +1,233 @@ +# serializer version: 1 +# name: test_all_entities[hw_q80r_soundbar][media_player.soundbar-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'source_list': list([ + 'wifi', + 'bluetooth', + 'HDMI1', + 'HDMI2', + 'digital', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'media_player', + 'entity_category': None, + 'entity_id': 'media_player.soundbar', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'afcf3b91-0000-1111-2222-ddff2a0a6577', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[hw_q80r_soundbar][media_player.soundbar-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'speaker', + 'friendly_name': 'Soundbar', + 'is_volume_muted': False, + 'media_artist': 'Rick Astley', + 'media_title': 'Never Gonna Give You Up', + 'source': 'wifi', + 'source_list': list([ + 'wifi', + 'bluetooth', + 'HDMI1', + 'HDMI2', + 'digital', + ]), + 'supported_features': , + 'volume_level': 0.01, + }), + 'context': , + 'entity_id': 'media_player.soundbar', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'playing', + }) +# --- +# name: test_all_entities[im_speaker_ai_0001][media_player.galaxy_home_mini-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'media_player', + 'entity_category': None, + 'entity_id': 'media_player.galaxy_home_mini', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'c9276e43-fe3c-88c3-1dcc-2eb79e292b8c', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[im_speaker_ai_0001][media_player.galaxy_home_mini-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'speaker', + 'friendly_name': 'Galaxy Home Mini', + 'is_volume_muted': False, + 'repeat': , + 'shuffle': False, + 'supported_features': , + 'volume_level': 0.52, + }), + 'context': , + 'entity_id': 'media_player.galaxy_home_mini', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'idle', + }) +# --- +# name: test_all_entities[sonos_player][media_player.elliots_rum-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'media_player', + 'entity_category': None, + 'entity_id': 'media_player.elliots_rum', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'c85fced9-c474-4a47-93c2-037cc7829536', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sonos_player][media_player.elliots_rum-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'speaker', + 'friendly_name': 'Elliots Rum', + 'is_volume_muted': False, + 'media_artist': 'David Guetta', + 'media_title': 'Forever Young', + 'supported_features': , + 'volume_level': 0.15, + }), + 'context': , + 'entity_id': 'media_player.elliots_rum', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'playing', + }) +# --- +# name: test_all_entities[vd_network_audio_002s][media_player.soundbar_living-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'media_player', + 'entity_category': None, + 'entity_id': 'media_player.soundbar_living', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '0d94e5db-8501-2355-eb4f-214163702cac', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[vd_network_audio_002s][media_player.soundbar_living-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'speaker', + 'friendly_name': 'Soundbar Living', + 'is_volume_muted': False, + 'media_artist': '', + 'media_title': '', + 'source': 'HDMI1', + 'supported_features': , + 'volume_level': 0.17, + }), + 'context': , + 'entity_id': 'media_player.soundbar_living', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index 8656d12c955..ded9263ebc4 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -7193,6 +7193,182 @@ 'state': '19.0', }) # --- +# name: test_all_entities[hw_q80r_soundbar][sensor.soundbar_media_input_source-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'wifi', + 'bluetooth', + 'hdmi1', + 'hdmi2', + 'digital', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.soundbar_media_input_source', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Media input source', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'media_input_source', + 'unique_id': 'afcf3b91-0000-1111-2222-ddff2a0a6577.inputSource', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[hw_q80r_soundbar][sensor.soundbar_media_input_source-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Soundbar Media input source', + 'options': list([ + 'wifi', + 'bluetooth', + 'hdmi1', + 'hdmi2', + 'digital', + ]), + }), + 'context': , + 'entity_id': 'sensor.soundbar_media_input_source', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'wifi', + }) +# --- +# name: test_all_entities[hw_q80r_soundbar][sensor.soundbar_media_playback_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'paused', + 'playing', + 'stopped', + 'fast_forwarding', + 'rewinding', + 'buffering', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.soundbar_media_playback_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Media playback status', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'media_playback_status', + 'unique_id': 'afcf3b91-0000-1111-2222-ddff2a0a6577.playbackStatus', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[hw_q80r_soundbar][sensor.soundbar_media_playback_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Soundbar Media playback status', + 'options': list([ + 'paused', + 'playing', + 'stopped', + 'fast_forwarding', + 'rewinding', + 'buffering', + ]), + }), + 'context': , + 'entity_id': 'sensor.soundbar_media_playback_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'playing', + }) +# --- +# name: test_all_entities[hw_q80r_soundbar][sensor.soundbar_volume-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.soundbar_volume', + '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': 'Volume', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'audio_volume', + 'unique_id': 'afcf3b91-0000-1111-2222-ddff2a0a6577.volume', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[hw_q80r_soundbar][sensor.soundbar_volume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Soundbar Volume', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.soundbar_volume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1', + }) +# --- # name: test_all_entities[ikea_kadrilj][sensor.kitchen_ikea_kadrilj_window_blind_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/smartthings/snapshots/test_switch.ambr b/tests/components/smartthings/snapshots/test_switch.ambr index 678c204ab00..1a8cb4c0ba7 100644 --- a/tests/components/smartthings/snapshots/test_switch.ambr +++ b/tests/components/smartthings/snapshots/test_switch.ambr @@ -610,6 +610,53 @@ 'state': 'off', }) # --- +# name: test_all_entities[hw_q80r_soundbar][switch.soundbar-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.soundbar', + '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': None, + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'afcf3b91-0000-1111-2222-ddff2a0a6577', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[hw_q80r_soundbar][switch.soundbar-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Soundbar', + }), + 'context': , + 'entity_id': 'switch.soundbar', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_all_entities[sensibo_airconditioner_1][switch.office-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/smartthings/test_media_player.py b/tests/components/smartthings/test_media_player.py new file mode 100644 index 00000000000..b7cecfe8408 --- /dev/null +++ b/tests/components/smartthings/test_media_player.py @@ -0,0 +1,432 @@ +"""Test for the SmartThings media player platform.""" + +from unittest.mock import AsyncMock + +from pysmartthings import Attribute, Capability, Command, Status +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.media_player import ( + ATTR_INPUT_SOURCE, + ATTR_MEDIA_REPEAT, + ATTR_MEDIA_SHUFFLE, + ATTR_MEDIA_VOLUME_LEVEL, + ATTR_MEDIA_VOLUME_MUTED, + DOMAIN as MEDIA_PLAYER_DOMAIN, + SERVICE_SELECT_SOURCE, + RepeatMode, +) +from homeassistant.components.smartthings.const import MAIN +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_MEDIA_NEXT_TRACK, + SERVICE_MEDIA_PAUSE, + SERVICE_MEDIA_PLAY, + SERVICE_MEDIA_PREVIOUS_TRACK, + SERVICE_MEDIA_STOP, + SERVICE_REPEAT_SET, + SERVICE_SHUFFLE_SET, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + SERVICE_VOLUME_DOWN, + SERVICE_VOLUME_MUTE, + SERVICE_VOLUME_SET, + SERVICE_VOLUME_UP, + STATE_OFF, + STATE_PLAYING, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration, snapshot_smartthings_entities, trigger_update + +from tests.common import MockConfigEntry + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + await setup_integration(hass, mock_config_entry) + + snapshot_smartthings_entities( + hass, entity_registry, snapshot, Platform.MEDIA_PLAYER + ) + + +@pytest.mark.parametrize("device_fixture", ["hw_q80r_soundbar"]) +@pytest.mark.parametrize( + ("action", "command"), + [ + (SERVICE_TURN_ON, Command.ON), + (SERVICE_TURN_OFF, Command.OFF), + ], +) +async def test_turn_on_off( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + action: str, + command: Command, +) -> None: + """Test media player turn on and off command.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + action, + {ATTR_ENTITY_ID: "media_player.soundbar"}, + blocking=True, + ) + devices.execute_device_command.assert_called_once_with( + "afcf3b91-0000-1111-2222-ddff2a0a6577", Capability.SWITCH, command, MAIN + ) + + +@pytest.mark.parametrize("device_fixture", ["hw_q80r_soundbar"]) +@pytest.mark.parametrize( + ("muted", "argument"), + [ + (True, "muted"), + (False, "unmuted"), + ], +) +async def test_mute_unmute( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + muted: bool, + argument: str, +) -> None: + """Test media player mute and unmute command.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_VOLUME_MUTE, + {ATTR_ENTITY_ID: "media_player.soundbar", ATTR_MEDIA_VOLUME_MUTED: muted}, + blocking=True, + ) + devices.execute_device_command.assert_called_once_with( + "afcf3b91-0000-1111-2222-ddff2a0a6577", + Capability.AUDIO_MUTE, + Command.SET_MUTE, + MAIN, + argument=argument, + ) + + +@pytest.mark.parametrize("device_fixture", ["hw_q80r_soundbar"]) +async def test_set_volume_level( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test media player set volume level command.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_VOLUME_SET, + {ATTR_ENTITY_ID: "media_player.soundbar", ATTR_MEDIA_VOLUME_LEVEL: 0.31}, + blocking=True, + ) + devices.execute_device_command.assert_called_once_with( + "afcf3b91-0000-1111-2222-ddff2a0a6577", + Capability.AUDIO_VOLUME, + Command.SET_VOLUME, + MAIN, + argument=31, + ) + + +@pytest.mark.parametrize("device_fixture", ["hw_q80r_soundbar"]) +async def test_volume_up( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test media player increase volume level command.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_VOLUME_UP, + {ATTR_ENTITY_ID: "media_player.soundbar"}, + blocking=True, + ) + devices.execute_device_command.assert_called_once_with( + "afcf3b91-0000-1111-2222-ddff2a0a6577", + Capability.AUDIO_VOLUME, + Command.VOLUME_UP, + MAIN, + ) + + +@pytest.mark.parametrize("device_fixture", ["hw_q80r_soundbar"]) +async def test_volume_down( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test media player decrease volume level command.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_VOLUME_DOWN, + {ATTR_ENTITY_ID: "media_player.soundbar"}, + blocking=True, + ) + devices.execute_device_command.assert_called_once_with( + "afcf3b91-0000-1111-2222-ddff2a0a6577", + Capability.AUDIO_VOLUME, + Command.VOLUME_DOWN, + MAIN, + ) + + +@pytest.mark.parametrize("device_fixture", ["hw_q80r_soundbar"]) +async def test_media_play( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test media player play command.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_MEDIA_PLAY, + {ATTR_ENTITY_ID: "media_player.soundbar"}, + blocking=True, + ) + devices.execute_device_command.assert_called_once_with( + "afcf3b91-0000-1111-2222-ddff2a0a6577", + Capability.MEDIA_PLAYBACK, + Command.PLAY, + MAIN, + ) + + +@pytest.mark.parametrize("device_fixture", ["hw_q80r_soundbar"]) +async def test_media_pause( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test media player pause command.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_MEDIA_PAUSE, + {ATTR_ENTITY_ID: "media_player.soundbar"}, + blocking=True, + ) + devices.execute_device_command.assert_called_once_with( + "afcf3b91-0000-1111-2222-ddff2a0a6577", + Capability.MEDIA_PLAYBACK, + Command.PAUSE, + MAIN, + ) + + +@pytest.mark.parametrize("device_fixture", ["hw_q80r_soundbar"]) +async def test_media_stop( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test media player stop command.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_MEDIA_STOP, + {ATTR_ENTITY_ID: "media_player.soundbar"}, + blocking=True, + ) + devices.execute_device_command.assert_called_once_with( + "afcf3b91-0000-1111-2222-ddff2a0a6577", + Capability.MEDIA_PLAYBACK, + Command.STOP, + MAIN, + ) + + +@pytest.mark.parametrize("device_fixture", ["hw_q80r_soundbar"]) +async def test_media_previous_track( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test media player previous track command.""" + devices.get_device_status.return_value[MAIN][Capability.MEDIA_PLAYBACK] = { + Attribute.SUPPORTED_PLAYBACK_COMMANDS: Status(["rewind"]) + } + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_MEDIA_PREVIOUS_TRACK, + {ATTR_ENTITY_ID: "media_player.soundbar"}, + blocking=True, + ) + devices.execute_device_command.assert_called_once_with( + "afcf3b91-0000-1111-2222-ddff2a0a6577", + Capability.MEDIA_PLAYBACK, + Command.REWIND, + MAIN, + ) + + +@pytest.mark.parametrize("device_fixture", ["hw_q80r_soundbar"]) +async def test_media_next_track( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test media player next track command.""" + devices.get_device_status.return_value[MAIN][Capability.MEDIA_PLAYBACK] = { + Attribute.SUPPORTED_PLAYBACK_COMMANDS: Status(["fastForward"]) + } + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_MEDIA_NEXT_TRACK, + {ATTR_ENTITY_ID: "media_player.soundbar"}, + blocking=True, + ) + devices.execute_device_command.assert_called_once_with( + "afcf3b91-0000-1111-2222-ddff2a0a6577", + Capability.MEDIA_PLAYBACK, + Command.FAST_FORWARD, + MAIN, + ) + + +@pytest.mark.parametrize("device_fixture", ["hw_q80r_soundbar"]) +async def test_select_source( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test media player stop command.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_SELECT_SOURCE, + {ATTR_ENTITY_ID: "media_player.soundbar", ATTR_INPUT_SOURCE: "digital"}, + blocking=True, + ) + devices.execute_device_command.assert_called_once_with( + "afcf3b91-0000-1111-2222-ddff2a0a6577", + Capability.MEDIA_INPUT_SOURCE, + Command.SET_INPUT_SOURCE, + MAIN, + "digital", + ) + + +@pytest.mark.parametrize("device_fixture", ["hw_q80r_soundbar"]) +@pytest.mark.parametrize( + ("shuffle", "argument"), + [ + (True, "enabled"), + (False, "disabled"), + ], +) +async def test_media_shuffle_on_off( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + shuffle: bool, + argument: bool, +) -> None: + """Test media player media shuffle command.""" + devices.get_device_status.return_value[MAIN][Capability.MEDIA_PLAYBACK_SHUFFLE] = { + Attribute.PLAYBACK_SHUFFLE: Status(True) + } + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_SHUFFLE_SET, + {ATTR_ENTITY_ID: "media_player.soundbar", ATTR_MEDIA_SHUFFLE: shuffle}, + blocking=True, + ) + devices.execute_device_command.assert_called_once_with( + "afcf3b91-0000-1111-2222-ddff2a0a6577", + Capability.MEDIA_PLAYBACK_SHUFFLE, + Command.SET_PLAYBACK_SHUFFLE, + MAIN, + argument=argument, + ) + + +@pytest.mark.parametrize("device_fixture", ["hw_q80r_soundbar"]) +@pytest.mark.parametrize( + ("repeat", "argument"), + [ + (RepeatMode.OFF, "off"), + (RepeatMode.ONE, "one"), + (RepeatMode.ALL, "all"), + ], +) +async def test_media_repeat_mode( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + repeat: RepeatMode, + argument: bool, +) -> None: + """Test media player repeat mode command.""" + devices.get_device_status.return_value[MAIN][Capability.MEDIA_PLAYBACK_REPEAT] = { + Attribute.REPEAT_MODE: Status("one") + } + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_REPEAT_SET, + {ATTR_ENTITY_ID: "media_player.soundbar", ATTR_MEDIA_REPEAT: repeat}, + blocking=True, + ) + devices.execute_device_command.assert_called_once_with( + "afcf3b91-0000-1111-2222-ddff2a0a6577", + Capability.MEDIA_PLAYBACK_REPEAT, + Command.SET_PLAYBACK_REPEAT_MODE, + MAIN, + argument=argument, + ) + + +@pytest.mark.parametrize("device_fixture", ["hw_q80r_soundbar"]) +async def test_state_update( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test state update.""" + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("media_player.soundbar").state == STATE_PLAYING + + await trigger_update( + hass, + devices, + "afcf3b91-0000-1111-2222-ddff2a0a6577", + Capability.SWITCH, + Attribute.SWITCH, + "off", + ) + + assert hass.states.get("media_player.soundbar").state == STATE_OFF From 3e018f2523f1520a80cb7fd87d2098b230f87d45 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 25 Mar 2025 13:51:11 +0100 Subject: [PATCH 3003/3148] Bump home-assistant/wheels from 2025.02.0 to 2025.03.0 (#141359) --- .github/workflows/wheels.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 61a2e00fcf4..d27a62bab80 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -159,7 +159,7 @@ jobs: sed -i "/uv/d" requirements_diff.txt - name: Build wheels - uses: home-assistant/wheels@2025.02.0 + uses: home-assistant/wheels@2025.03.0 with: abi: ${{ matrix.abi }} tag: musllinux_1_2 @@ -219,7 +219,7 @@ jobs: sed -i "/uv/d" requirements_diff.txt - name: Build wheels - uses: home-assistant/wheels@2025.02.0 + uses: home-assistant/wheels@2025.03.0 with: abi: ${{ matrix.abi }} tag: musllinux_1_2 From 19bc54c1de20dfe11f45059cf1bfa1e5549954da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noah=20Gro=C3=9F?= Date: Tue, 25 Mar 2025 14:12:07 +0100 Subject: [PATCH 3004/3148] Bump python-picnic-api2 from 1.2.2 to 1.2.4 (#141353) --- homeassistant/components/picnic/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/picnic/manifest.json b/homeassistant/components/picnic/manifest.json index 09f28da39a4..251964c15d0 100644 --- a/homeassistant/components/picnic/manifest.json +++ b/homeassistant/components/picnic/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/picnic", "iot_class": "cloud_polling", "loggers": ["python_picnic_api2"], - "requirements": ["python-picnic-api2==1.2.2"] + "requirements": ["python-picnic-api2==1.2.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index 0f8692438c8..55645c87078 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2455,7 +2455,7 @@ python-otbr-api==2.7.0 python-overseerr==0.7.1 # homeassistant.components.picnic -python-picnic-api2==1.2.2 +python-picnic-api2==1.2.4 # homeassistant.components.rabbitair python-rabbitair==0.0.8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ebf02214f0a..ef3ae45c3f7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1989,7 +1989,7 @@ python-otbr-api==2.7.0 python-overseerr==0.7.1 # homeassistant.components.picnic -python-picnic-api2==1.2.2 +python-picnic-api2==1.2.4 # homeassistant.components.rabbitair python-rabbitair==0.0.8 From e49b105724411b4ecf4e2201951d236b781ee48d Mon Sep 17 00:00:00 2001 From: David Badura Date: Tue, 25 Mar 2025 14:22:32 +0100 Subject: [PATCH 3005/3148] Align Matter eve thermo offset max range with eve app (#140579) * align eve thermo offset max range with eve app * fix tests --- homeassistant/components/matter/number.py | 4 ++-- tests/components/matter/snapshots/test_number.ambr | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/matter/number.py b/homeassistant/components/matter/number.py index 44538f46856..2c7a9651c60 100644 --- a/homeassistant/components/matter/number.py +++ b/homeassistant/components/matter/number.py @@ -169,8 +169,8 @@ DISCOVERY_SCHEMAS = [ device_class=NumberDeviceClass.TEMPERATURE, entity_category=EntityCategory.CONFIG, translation_key="temperature_offset", - native_max_value=25, - native_min_value=-25, + native_max_value=50, + native_min_value=-50, native_step=0.5, native_unit_of_measurement=UnitOfTemperature.CELSIUS, measurement_to_ha=lambda x: None if x is None else x / 10, diff --git a/tests/components/matter/snapshots/test_number.ambr b/tests/components/matter/snapshots/test_number.ambr index dc35f6f2a69..d777b9d48d0 100644 --- a/tests/components/matter/snapshots/test_number.ambr +++ b/tests/components/matter/snapshots/test_number.ambr @@ -401,8 +401,8 @@ }), 'area_id': None, 'capabilities': dict({ - 'max': 25, - 'min': -25, + 'max': 50, + 'min': -50, 'mode': , 'step': 0.5, }), @@ -439,8 +439,8 @@ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', 'friendly_name': 'Eve Thermo Temperature offset', - 'max': 25, - 'min': -25, + 'max': 50, + 'min': -50, 'mode': , 'step': 0.5, 'unit_of_measurement': , From 20a2fdb660f0eb9c785b46198cd6866cadb31263 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 25 Mar 2025 14:32:25 +0100 Subject: [PATCH 3006/3148] Create separate httpx client for Discovergy (#141374) --- homeassistant/components/discovergy/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/discovergy/__init__.py b/homeassistant/components/discovergy/__init__.py index 9cf63176de6..0a8b7422f84 100644 --- a/homeassistant/components/discovergy/__init__.py +++ b/homeassistant/components/discovergy/__init__.py @@ -9,7 +9,7 @@ import pydiscovergy.error as discovergyError from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers.httpx_client import get_async_client +from homeassistant.helpers.httpx_client import create_async_httpx_client from .coordinator import DiscovergyConfigEntry, DiscovergyUpdateCoordinator @@ -21,7 +21,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: DiscovergyConfigEntry) - client = Discovergy( email=entry.data[CONF_EMAIL], password=entry.data[CONF_PASSWORD], - httpx_client=get_async_client(hass), + httpx_client=create_async_httpx_client(hass), authentication=BasicAuth(), ) From 3775f154617d885a262dc64ad0e431831da75d86 Mon Sep 17 00:00:00 2001 From: Maikel Punie Date: Tue, 25 Mar 2025 14:37:21 +0100 Subject: [PATCH 3007/3148] Fix Velbus translations (#141372) --- homeassistant/components/velbus/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/velbus/strings.json b/homeassistant/components/velbus/strings.json index b4b6ae20d13..35f94e54470 100644 --- a/homeassistant/components/velbus/strings.json +++ b/homeassistant/components/velbus/strings.json @@ -5,8 +5,8 @@ "title": "Define the Velbus connection", "description": "How do you want to configure the Velbus hub?", "menu_options": { - "network": "Via a network connection", - "usbselect": "Via an USB device" + "network": "Via network connection", + "usbselect": "Via USB device" } }, "network": { From 05ead4d1f528240e889404cc636ff2ff11263461 Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Tue, 25 Mar 2025 16:43:48 +0200 Subject: [PATCH 3008/3148] Initialize Shelly runtime_data in async_setup_entry (#141315) --- homeassistant/components/shelly/__init__.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/shelly/__init__.py b/homeassistant/components/shelly/__init__.py index 8e6417c5d7c..08c161c357e 100644 --- a/homeassistant/components/shelly/__init__.py +++ b/homeassistant/components/shelly/__init__.py @@ -111,6 +111,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ShellyConfigEntry) -> bool: """Set up Shelly from a config entry.""" + entry.runtime_data = ShellyEntryData([]) + # The custom component for Shelly devices uses shelly domain as well as core # integration. If the user removes the custom component but doesn't remove the # config entry, core integration will try to configure that config entry with an @@ -162,7 +164,8 @@ async def _async_setup_block_entry( device_entry = None sleep_period = entry.data.get(CONF_SLEEP_PERIOD) - runtime_data = entry.runtime_data = ShellyEntryData(BLOCK_SLEEPING_PLATFORMS) + runtime_data = entry.runtime_data + runtime_data.platforms = BLOCK_SLEEPING_PLATFORMS # Some old firmware have a wrong sleep period hardcoded value. # Following code block will force the right value for affected devices @@ -273,7 +276,8 @@ async def _async_setup_rpc_entry(hass: HomeAssistant, entry: ShellyConfigEntry) device_entry = None sleep_period = entry.data.get(CONF_SLEEP_PERIOD) - runtime_data = entry.runtime_data = ShellyEntryData(RPC_SLEEPING_PLATFORMS) + runtime_data = entry.runtime_data + runtime_data.platforms = RPC_SLEEPING_PLATFORMS if sleep_period == 0: # Not a sleeping device, finish setup From 8f000f222dff1b95bc98f43d6c0836c618a7a2a3 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Tue, 25 Mar 2025 15:50:40 +0100 Subject: [PATCH 3009/3148] Bump aiocomelit to 0.11.3 (#141375) --- homeassistant/components/comelit/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/comelit/manifest.json b/homeassistant/components/comelit/manifest.json index 8836af4e8dd..3abfc222e7d 100644 --- a/homeassistant/components/comelit/manifest.json +++ b/homeassistant/components/comelit/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["aiocomelit"], - "requirements": ["aiocomelit==0.11.2"] + "requirements": ["aiocomelit==0.11.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index 55645c87078..ebb0ccad0f4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -213,7 +213,7 @@ aiobafi6==0.9.0 aiobotocore==2.13.1 # homeassistant.components.comelit -aiocomelit==0.11.2 +aiocomelit==0.11.3 # homeassistant.components.dhcp aiodhcpwatcher==1.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ef3ae45c3f7..ff6534dc894 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -201,7 +201,7 @@ aiobafi6==0.9.0 aiobotocore==2.13.1 # homeassistant.components.comelit -aiocomelit==0.11.2 +aiocomelit==0.11.3 # homeassistant.components.dhcp aiodhcpwatcher==1.1.1 From 735f877cf1e2d4934b5f85fce20a4fa06dff3a8d Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Tue, 25 Mar 2025 15:57:37 +0100 Subject: [PATCH 3010/3148] Add data description for IMGW-PIB config flow (#141381) * Add data description for IMGW-PIB config flow * Better wording --- homeassistant/components/imgw_pib/strings.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/imgw_pib/strings.json b/homeassistant/components/imgw_pib/strings.json index 89be0661c6f..33cd3cb3917 100644 --- a/homeassistant/components/imgw_pib/strings.json +++ b/homeassistant/components/imgw_pib/strings.json @@ -4,6 +4,9 @@ "user": { "data": { "station_id": "Hydrological station" + }, + "data_description": { + "station_id": "Select a hydrological station from the list." } } }, From 42566b7378704e4d959e55042b22565a62592193 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Tue, 25 Mar 2025 16:03:10 +0100 Subject: [PATCH 3011/3148] Update pytest-asyncio to 0.26.0 (#141365) --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index baf72265c40..de1de795afe 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -18,7 +18,7 @@ pydantic==2.10.6 pylint==3.3.6 pylint-per-file-ignores==1.4.0 pipdeptree==2.25.1 -pytest-asyncio==0.25.3 +pytest-asyncio==0.26.0 pytest-aiohttp==1.1.0 pytest-cov==6.0.0 pytest-freezer==0.4.9 From 83c21570c8b3e034c1f53192ef21c84dce2e123f Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 25 Mar 2025 16:05:35 +0100 Subject: [PATCH 3012/3148] Support TVs in SmartThings (#141366) --- .../components/smartthings/media_player.py | 21 ++++-- .../snapshots/test_media_player.ambr | 65 +++++++++++++++++++ 2 files changed, 79 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/smartthings/media_player.py b/homeassistant/components/smartthings/media_player.py index f39a4716ea1..9a676d2efb6 100644 --- a/homeassistant/components/smartthings/media_player.py +++ b/homeassistant/components/smartthings/media_player.py @@ -22,7 +22,6 @@ from .entity import SmartThingsEntity MEDIA_PLAYER_CAPABILITIES = ( Capability.AUDIO_MUTE, - Capability.AUDIO_TRACK_DATA, Capability.AUDIO_VOLUME, Capability.MEDIA_PLAYBACK, ) @@ -241,10 +240,14 @@ class SmartThingsMediaPlayer(SmartThingsEntity, MediaPlayerEntity): def media_title(self) -> str | None: """Title of current playing media.""" if ( - track_data := self.get_attribute_value( - Capability.AUDIO_TRACK_DATA, Attribute.AUDIO_TRACK_DATA + not self.supports_capability(Capability.AUDIO_TRACK_DATA) + or ( + track_data := self.get_attribute_value( + Capability.AUDIO_TRACK_DATA, Attribute.AUDIO_TRACK_DATA + ) ) - ) is None: + is None + ): return None return track_data.get("title", None) @@ -252,10 +255,14 @@ class SmartThingsMediaPlayer(SmartThingsEntity, MediaPlayerEntity): def media_artist(self) -> str | None: """Artist of current playing media.""" if ( - track_data := self.get_attribute_value( - Capability.AUDIO_TRACK_DATA, Attribute.AUDIO_TRACK_DATA + not self.supports_capability(Capability.AUDIO_TRACK_DATA) + or ( + track_data := self.get_attribute_value( + Capability.AUDIO_TRACK_DATA, Attribute.AUDIO_TRACK_DATA + ) ) - ) is None: + is None + ): return None return track_data.get("artist") diff --git a/tests/components/smartthings/snapshots/test_media_player.ambr b/tests/components/smartthings/snapshots/test_media_player.ambr index 193c0c8e296..b0829b0716e 100644 --- a/tests/components/smartthings/snapshots/test_media_player.ambr +++ b/tests/components/smartthings/snapshots/test_media_player.ambr @@ -231,3 +231,68 @@ 'state': 'on', }) # --- +# name: test_all_entities[vd_stv_2017_k][media_player.tv_samsung_8_series_49-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'source_list': list([ + 'digitalTv', + 'HDMI1', + 'HDMI4', + 'HDMI4', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'media_player', + 'entity_category': None, + 'entity_id': 'media_player.tv_samsung_8_series_49', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '4588d2d9-a8cf-40f4-9a0b-ed5dfbaccda1', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[vd_stv_2017_k][media_player.tv_samsung_8_series_49-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'tv', + 'friendly_name': '[TV] Samsung 8 Series (49)', + 'is_volume_muted': True, + 'source': 'HDMI1', + 'source_list': list([ + 'digitalTv', + 'HDMI1', + 'HDMI4', + 'HDMI4', + ]), + 'supported_features': , + 'volume_level': 0.13, + }), + 'context': , + 'entity_id': 'media_player.tv_samsung_8_series_49', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- From 37aaf149f9f20388596d551160c8816872ab36cf Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Tue, 25 Mar 2025 16:09:51 +0100 Subject: [PATCH 3013/3148] Bump reolink-aio to 0.13.0 (#141379) * Bump reolink-aio to 0.13.0 * Add push cmd_id 588 --- homeassistant/components/reolink/manifest.json | 2 +- homeassistant/components/reolink/switch.py | 1 + requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json index 41cfe1f9ae3..82b9586cccc 100644 --- a/homeassistant/components/reolink/manifest.json +++ b/homeassistant/components/reolink/manifest.json @@ -19,5 +19,5 @@ "iot_class": "local_push", "loggers": ["reolink_aio"], "quality_scale": "platinum", - "requirements": ["reolink-aio==0.12.3"] + "requirements": ["reolink-aio==0.13.0"] } diff --git a/homeassistant/components/reolink/switch.py b/homeassistant/components/reolink/switch.py index 0f106c0f2cc..af87a75eece 100644 --- a/homeassistant/components/reolink/switch.py +++ b/homeassistant/components/reolink/switch.py @@ -162,6 +162,7 @@ SWITCH_ENTITIES = ( ReolinkSwitchEntityDescription( key="manual_record", cmd_key="GetManualRec", + cmd_id=588, translation_key="manual_record", entity_category=EntityCategory.CONFIG, supported=lambda api, ch: api.supported(ch, "manual_record"), diff --git a/requirements_all.txt b/requirements_all.txt index ebb0ccad0f4..4510a3ee932 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2621,7 +2621,7 @@ renault-api==0.2.9 renson-endura-delta==1.7.2 # homeassistant.components.reolink -reolink-aio==0.12.3 +reolink-aio==0.13.0 # homeassistant.components.idteck_prox rfk101py==0.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ff6534dc894..8acdc00bde8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2122,7 +2122,7 @@ renault-api==0.2.9 renson-endura-delta==1.7.2 # homeassistant.components.reolink -reolink-aio==0.12.3 +reolink-aio==0.13.0 # homeassistant.components.rflink rflink==0.0.66 From e72231037ecf30fc2ee4678e0c85fad254eb9470 Mon Sep 17 00:00:00 2001 From: Huyuwei Date: Tue, 25 Mar 2025 23:12:01 +0800 Subject: [PATCH 3014/3148] Bump PySwitchBot to 0.58.0 (#141378) --- homeassistant/components/switchbot/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/manifest.json b/homeassistant/components/switchbot/manifest.json index 85d5bcf6436..d9f6f98d1fd 100644 --- a/homeassistant/components/switchbot/manifest.json +++ b/homeassistant/components/switchbot/manifest.json @@ -39,5 +39,5 @@ "documentation": "https://www.home-assistant.io/integrations/switchbot", "iot_class": "local_push", "loggers": ["switchbot"], - "requirements": ["PySwitchbot==0.57.1"] + "requirements": ["PySwitchbot==0.58.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 4510a3ee932..67090739379 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -84,7 +84,7 @@ PyQRCode==1.2.1 PyRMVtransport==0.3.3 # homeassistant.components.switchbot -PySwitchbot==0.57.1 +PySwitchbot==0.58.0 # homeassistant.components.switchmate PySwitchmate==0.5.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8acdc00bde8..7e703440baa 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -81,7 +81,7 @@ PyQRCode==1.2.1 PyRMVtransport==0.3.3 # homeassistant.components.switchbot -PySwitchbot==0.57.1 +PySwitchbot==0.58.0 # homeassistant.components.syncthru PySyncThru==0.8.0 From a2d9eb2a5b183a6f6e67028d543380bb92c1ed72 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 25 Mar 2025 16:17:57 +0100 Subject: [PATCH 3015/3148] Sentence-case "TOTP secret" in `opower` config flow (#141384) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit … and replace the second occurrence with a reference. --- homeassistant/components/opower/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/opower/strings.json b/homeassistant/components/opower/strings.json index 362e6cd7596..749545743fe 100644 --- a/homeassistant/components/opower/strings.json +++ b/homeassistant/components/opower/strings.json @@ -11,7 +11,7 @@ "mfa": { "description": "The TOTP secret below is not one of the 6 digit time-based numeric codes. It is a string of around 16 characters containing the shared secret that enables your authenticator app to generate the correct time-based code at the appropriate time. See the documentation.", "data": { - "totp_secret": "TOTP Secret" + "totp_secret": "TOTP secret" } }, "reauth_confirm": { @@ -19,7 +19,7 @@ "data": { "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]", - "totp_secret": "TOTP Secret" + "totp_secret": "[%key:component::opower::config::step::mfa::data::totp_secret%]" } } }, From a2f92b1e281b25c67f587b9f7653fc7354788a1a Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Tue, 25 Mar 2025 16:19:06 +0100 Subject: [PATCH 3016/3148] Add battery discrete level sensor to Overkiz (#141328) --- homeassistant/components/overkiz/sensor.py | 9 +++++++++ homeassistant/components/overkiz/strings.json | 4 +++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/overkiz/sensor.py b/homeassistant/components/overkiz/sensor.py index 9214398a37b..cec0d0d2571 100644 --- a/homeassistant/components/overkiz/sensor.py +++ b/homeassistant/components/overkiz/sensor.py @@ -70,6 +70,15 @@ SENSOR_DESCRIPTIONS: list[OverkizSensorDescription] = [ options=["full", "normal", "medium", "low", "verylow"], translation_key="battery", ), + OverkizSensorDescription( + key=OverkizState.CORE_BATTERY_DISCRETE_LEVEL, + name="Battery", + entity_category=EntityCategory.DIAGNOSTIC, + icon="mdi:battery", + device_class=SensorDeviceClass.ENUM, + options=["good", "medium", "low", "critical"], + translation_key="battery", + ), OverkizSensorDescription( key=OverkizState.CORE_RSSI_LEVEL, name="RSSI level", diff --git a/homeassistant/components/overkiz/strings.json b/homeassistant/components/overkiz/strings.json index 0c564a003d6..05b5eac4b21 100644 --- a/homeassistant/components/overkiz/strings.json +++ b/homeassistant/components/overkiz/strings.json @@ -123,7 +123,9 @@ "low": "Low", "normal": "Normal", "medium": "Medium", - "verylow": "Very low" + "verylow": "Very low", + "good": "Good", + "critical": "Critical" } }, "discrete_rssi_level": { From 2cbe8a4a141c805d527a518bc3b69efaa1fd0935 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 25 Mar 2025 17:01:25 +0100 Subject: [PATCH 3017/3148] Add translations to Hue effects (#138990) * Add translations to Hue effects * Add translations to Hue effects * Add more effects * Fix * Trigger build --- homeassistant/components/hue/strings.json | 22 ++++++++++++++++++++++ homeassistant/components/hue/v2/light.py | 16 ++++++++-------- tests/components/hue/test_light_v2.py | 12 ++++++------ 3 files changed, 36 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/hue/strings.json b/homeassistant/components/hue/strings.json index 2f7f2e55561..7860c2a297e 100644 --- a/homeassistant/components/hue/strings.json +++ b/homeassistant/components/hue/strings.json @@ -102,6 +102,28 @@ } } }, + "light": { + "hue_light": { + "state_attributes": { + "effect": { + "state": { + "candle": "Candle", + "sparkle": "Sparkle", + "glisten": "Glisten", + "sunrise": "Sunrise", + "sunset": "Sunset", + "fire": "Fire", + "prism": "Prism", + "opal": "Opal", + "underwater": "Underwater", + "cosmos": "Cosmos", + "sunbeam": "Sunbeam", + "enchant": "Enchant" + } + } + } + } + }, "sensor": { "zigbee_connectivity": { "name": "Zigbee connectivity", diff --git a/homeassistant/components/hue/v2/light.py b/homeassistant/components/hue/v2/light.py index 4b00299bc9d..757b69c7b7b 100644 --- a/homeassistant/components/hue/v2/light.py +++ b/homeassistant/components/hue/v2/light.py @@ -18,6 +18,7 @@ from homeassistant.components.light import ( ATTR_FLASH, ATTR_TRANSITION, ATTR_XY_COLOR, + EFFECT_OFF, FLASH_SHORT, ColorMode, LightEntity, @@ -39,7 +40,6 @@ from .helpers import ( normalize_hue_transition, ) -EFFECT_NONE = "None" FALLBACK_MIN_KELVIN = 6500 FALLBACK_MAX_KELVIN = 2000 FALLBACK_KELVIN = 5800 # halfway @@ -75,7 +75,7 @@ class HueLight(HueBaseEntity, LightEntity): _fixed_color_mode: ColorMode | None = None entity_description = LightEntityDescription( - key="hue_light", has_entity_name=True, name=None + key="hue_light", translation_key="hue_light", has_entity_name=True, name=None ) def __init__( @@ -118,7 +118,7 @@ class HueLight(HueBaseEntity, LightEntity): if x != TimedEffectStatus.NO_EFFECT ] if len(self._attr_effect_list) > 0: - self._attr_effect_list.insert(0, EFFECT_NONE) + self._attr_effect_list.insert(0, EFFECT_OFF) self._attr_supported_features |= LightEntityFeature.EFFECT @property @@ -211,7 +211,7 @@ class HueLight(HueBaseEntity, LightEntity): if timed_effects := self.resource.timed_effects: if timed_effects.status != TimedEffectStatus.NO_EFFECT: return timed_effects.status.value - return EFFECT_NONE + return EFFECT_OFF async def async_turn_on(self, **kwargs: Any) -> None: """Turn the device on.""" @@ -233,12 +233,12 @@ class HueLight(HueBaseEntity, LightEntity): self._color_temp_active = color_temp is not None flash = kwargs.get(ATTR_FLASH) effect = effect_str = kwargs.get(ATTR_EFFECT) - if effect_str in (EFFECT_NONE, EFFECT_NONE.lower()): - # ignore effect if set to "None" and we have no effect active - # the special effect "None" is only used to stop an active effect + if effect_str == EFFECT_OFF: + # ignore effect if set to "off" and we have no effect active + # the special effect "off" is only used to stop an active effect # but sending it while no effect is active can actually result in issues # https://github.com/home-assistant/core/issues/122165 - effect = None if self.effect == EFFECT_NONE else EffectStatus.NO_EFFECT + effect = None if self.effect == EFFECT_OFF else EffectStatus.NO_EFFECT elif effect_str is not None: # work out if we got a regular effect or timed effect effect = EffectStatus(effect_str) diff --git a/tests/components/hue/test_light_v2.py b/tests/components/hue/test_light_v2.py index c831d40d261..3d323d4d31c 100644 --- a/tests/components/hue/test_light_v2.py +++ b/tests/components/hue/test_light_v2.py @@ -42,8 +42,8 @@ async def test_lights( assert light_1.attributes["min_mireds"] == 153 assert light_1.attributes["max_mireds"] == 500 assert light_1.attributes["dynamics"] == "dynamic_palette" - assert light_1.attributes["effect_list"] == ["None", "candle", "fire"] - assert light_1.attributes["effect"] == "None" + assert light_1.attributes["effect_list"] == ["off", "candle", "fire"] + assert light_1.attributes["effect"] == "off" # test light which supports color temperature only light_2 = hass.states.get("light.hue_light_with_color_temperature_only") @@ -57,7 +57,7 @@ async def test_lights( assert light_2.attributes["min_mireds"] == 153 assert light_2.attributes["max_mireds"] == 454 assert light_2.attributes["dynamics"] == "none" - assert light_2.attributes["effect_list"] == ["None", "candle", "sunrise"] + assert light_2.attributes["effect_list"] == ["off", "candle", "sunrise"] # test light which supports color only light_3 = hass.states.get("light.hue_light_with_color_only") @@ -201,7 +201,7 @@ async def test_light_turn_on_service( await hass.services.async_call( "light", "turn_on", - {"entity_id": test_light_id, "effect": "None"}, + {"entity_id": test_light_id, "effect": "off"}, blocking=True, ) assert len(mock_bridge_v2.mock_requests) == 8 @@ -216,14 +216,14 @@ async def test_light_turn_on_service( await hass.async_block_till_done() test_light = hass.states.get(test_light_id) assert test_light is not None - assert test_light.attributes["effect"] == "None" + assert test_light.attributes["effect"] == "off" # test turn on with useless effect # it should send a effect in the request if the device has no effect active await hass.services.async_call( "light", "turn_on", - {"entity_id": test_light_id, "effect": "None"}, + {"entity_id": test_light_id, "effect": "off"}, blocking=True, ) assert len(mock_bridge_v2.mock_requests) == 9 From 0920d7d82d7f2b8b602ba94e18511a3d81575119 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Tue, 25 Mar 2025 17:09:33 +0100 Subject: [PATCH 3018/3148] Set PARALLEL_UPDATES in IMGW-PIB sensor platform (#141386) --- homeassistant/components/imgw_pib/sensor.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/imgw_pib/sensor.py b/homeassistant/components/imgw_pib/sensor.py index 33b82bbb43b..7871006b2ae 100644 --- a/homeassistant/components/imgw_pib/sensor.py +++ b/homeassistant/components/imgw_pib/sensor.py @@ -24,7 +24,8 @@ from .const import DOMAIN from .coordinator import ImgwPibConfigEntry, ImgwPibDataUpdateCoordinator from .entity import ImgwPibEntity -PARALLEL_UPDATES = 1 +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 @dataclass(frozen=True, kw_only=True) From 1772348eef3585317324f4f35f866c51f12ce81b Mon Sep 17 00:00:00 2001 From: Huyuwei Date: Wed, 26 Mar 2025 00:09:51 +0800 Subject: [PATCH 3019/3148] Add illuminance sensor to SwitchBot integration (#141382) * Add illuminance sensor to SwitchBot integration * Add WoHub2 sensor tests --- homeassistant/components/switchbot/sensor.py | 7 +++ tests/components/switchbot/__init__.py | 25 +++++++++ tests/components/switchbot/test_sensor.py | 59 ++++++++++++++++++++ 3 files changed, 91 insertions(+) diff --git a/homeassistant/components/switchbot/sensor.py b/homeassistant/components/switchbot/sensor.py index 9be5ad8be5a..d68c913db15 100644 --- a/homeassistant/components/switchbot/sensor.py +++ b/homeassistant/components/switchbot/sensor.py @@ -11,6 +11,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import ( CONCENTRATION_PARTS_PER_MILLION, + LIGHT_LUX, PERCENTAGE, SIGNAL_STRENGTH_DECIBELS_MILLIWATT, EntityCategory, @@ -71,6 +72,12 @@ SENSOR_TYPES: dict[str, SensorEntityDescription] = { state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.HUMIDITY, ), + "illuminance": SensorEntityDescription( + key="illuminance", + native_unit_of_measurement=LIGHT_LUX, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.ILLUMINANCE, + ), "temperature": SensorEntityDescription( key="temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, diff --git a/tests/components/switchbot/__init__.py b/tests/components/switchbot/__init__.py index 4d6794b962f..d123c93a873 100644 --- a/tests/components/switchbot/__init__.py +++ b/tests/components/switchbot/__init__.py @@ -294,3 +294,28 @@ REMOTE_SERVICE_INFO = BluetoothServiceInfoBleak( connectable=False, tx_power=-127, ) + + +WOHUB2_SERVICE_INFO = BluetoothServiceInfoBleak( + name="WoHub2", + manufacturer_data={ + 2409: b"\xe7\x06\x1dx\x99y\x00\xffg\xe2\xbf]\x84\x04\x9a,\x00", + }, + service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"v\x00"}, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + address="AA:BB:CC:DD:EE:FF", + rssi=-60, + source="local", + advertisement=generate_advertisement_data( + local_name="WoHub2", + manufacturer_data={ + 2409: b"\xe7\x06\x1dx\x99y\x00\xffg\xe2\xbf]\x84\x04\x9a,\x00", + }, + service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"v\x00"}, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + ), + device=generate_ble_device("AA:BB:CC:DD:EE:FF", "WoHub2"), + time=0, + connectable=True, + tx_power=-127, +) diff --git a/tests/components/switchbot/test_sensor.py b/tests/components/switchbot/test_sensor.py index 6a7111a054e..5fd270b3393 100644 --- a/tests/components/switchbot/test_sensor.py +++ b/tests/components/switchbot/test_sensor.py @@ -25,6 +25,7 @@ from . import ( LEAK_SERVICE_INFO, REMOTE_SERVICE_INFO, WOHAND_SERVICE_INFO, + WOHUB2_SERVICE_INFO, WOMETERTHPC_SERVICE_INFO, WORELAY_SWITCH_1PM_SERVICE_INFO, ) @@ -234,3 +235,61 @@ async def test_remote(hass: HomeAssistant) -> None: assert await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_hub2_sensor(hass: HomeAssistant) -> None: + """Test setting up creates the sensor for WoHub2.""" + await async_setup_component(hass, DOMAIN, {}) + inject_bluetooth_service_info(hass, WOHUB2_SERVICE_INFO) + + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_ADDRESS: "AA:BB:CC:DD:EE:FF", + CONF_NAME: "test-name", + CONF_SENSOR_TYPE: "hub2", + }, + unique_id="aabbccddeeff", + ) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.states.async_all("sensor")) == 5 + + temperature_sensor = hass.states.get("sensor.test_name_temperature") + temperature_sensor_attrs = temperature_sensor.attributes + assert temperature_sensor.state == "26.4" + assert temperature_sensor_attrs[ATTR_FRIENDLY_NAME] == "test-name Temperature" + assert temperature_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == "°C" + assert temperature_sensor_attrs[ATTR_STATE_CLASS] == "measurement" + + humidity_sensor = hass.states.get("sensor.test_name_humidity") + humidity_sensor_attrs = humidity_sensor.attributes + assert humidity_sensor.state == "44" + assert humidity_sensor_attrs[ATTR_FRIENDLY_NAME] == "test-name Humidity" + assert humidity_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == "%" + assert humidity_sensor_attrs[ATTR_STATE_CLASS] == "measurement" + + light_level_sensor = hass.states.get("sensor.test_name_light_level") + light_level_sensor_attrs = light_level_sensor.attributes + assert light_level_sensor.state == "4" + assert light_level_sensor_attrs[ATTR_FRIENDLY_NAME] == "test-name Light level" + assert light_level_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == "Level" + + light_level_sensor = hass.states.get("sensor.test_name_illuminance") + light_level_sensor_attrs = light_level_sensor.attributes + assert light_level_sensor.state == "30" + assert light_level_sensor_attrs[ATTR_FRIENDLY_NAME] == "test-name Illuminance" + assert light_level_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == "lx" + + rssi_sensor = hass.states.get("sensor.test_name_bluetooth_signal") + rssi_sensor_attrs = rssi_sensor.attributes + assert rssi_sensor.state == "-60" + assert rssi_sensor_attrs[ATTR_FRIENDLY_NAME] == "test-name Bluetooth signal" + assert rssi_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == "dBm" + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() From ef531cec4144ecba08a55be3771334b8bcd17cce Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Tue, 25 Mar 2025 17:26:13 +0100 Subject: [PATCH 3020/3148] Add data description for Shelly config flow (#141383) --- homeassistant/components/shelly/strings.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/homeassistant/components/shelly/strings.json b/homeassistant/components/shelly/strings.json index 8ca16e2a2b5..b678ab8250f 100644 --- a/homeassistant/components/shelly/strings.json +++ b/homeassistant/components/shelly/strings.json @@ -17,12 +17,20 @@ "data": { "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "username": "Username for the device's web panel.", + "password": "Password for the device's web panel." } }, "reauth_confirm": { "data": { "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "username": "[%key:component::shelly::config::step::credentials::data_description::username%]", + "password": "[%key:component::shelly::config::step::credentials::data_description::password%]" } }, "confirm_discovery": { From db66b4093a4c9e93bce54ddc9f69e32cd55e0e39 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Tue, 25 Mar 2025 18:27:17 +0100 Subject: [PATCH 3021/3148] Bump psutil to 7.0.0 (#141390) --- homeassistant/components/systemmonitor/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/systemmonitor/manifest.json b/homeassistant/components/systemmonitor/manifest.json index bd16464b290..9302746aa17 100644 --- a/homeassistant/components/systemmonitor/manifest.json +++ b/homeassistant/components/systemmonitor/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/systemmonitor", "iot_class": "local_push", "loggers": ["psutil"], - "requirements": ["psutil-home-assistant==0.0.1", "psutil==6.1.1"], + "requirements": ["psutil-home-assistant==0.0.1", "psutil==7.0.0"], "single_config_entry": true } diff --git a/requirements_all.txt b/requirements_all.txt index 67090739379..2e210d225b7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1707,7 +1707,7 @@ proxmoxer==2.0.1 psutil-home-assistant==0.0.1 # homeassistant.components.systemmonitor -psutil==6.1.1 +psutil==7.0.0 # homeassistant.components.pulseaudio_loopback pulsectl==23.5.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7e703440baa..7d7cf2ba88d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1409,7 +1409,7 @@ prometheus-client==0.21.0 psutil-home-assistant==0.0.1 # homeassistant.components.systemmonitor -psutil==6.1.1 +psutil==7.0.0 # homeassistant.components.pushbullet pushbullet.py==0.11.0 From e1eb031022c553834a6f6023dd66ebc19675be1a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 25 Mar 2025 07:44:00 -1000 Subject: [PATCH 3022/3148] Bump orjson to 3.10.16 (#141339) changelog: https://github.com/ijl/orjson/compare/3.10.15...3.10.16 --- 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 b39edaf64b1..7ccb1987551 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -45,7 +45,7 @@ ifaddr==0.2.0 Jinja2==3.1.6 lru-dict==1.3.0 mutagen==1.47.0 -orjson==3.10.15 +orjson==3.10.16 packaging>=23.1 paho-mqtt==2.1.0 Pillow==11.1.0 diff --git a/pyproject.toml b/pyproject.toml index 0144a3c8ffd..1c7cf859829 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -61,7 +61,7 @@ dependencies = [ "Pillow==11.1.0", "propcache==0.3.0", "pyOpenSSL==25.0.0", - "orjson==3.10.15", + "orjson==3.10.16", "packaging>=23.1", "psutil-home-assistant==0.0.1", "python-slugify==8.0.4", diff --git a/requirements.txt b/requirements.txt index e530ea5de08..dfebcd491ee 100644 --- a/requirements.txt +++ b/requirements.txt @@ -33,7 +33,7 @@ cryptography==44.0.1 Pillow==11.1.0 propcache==0.3.0 pyOpenSSL==25.0.0 -orjson==3.10.15 +orjson==3.10.16 packaging>=23.1 psutil-home-assistant==0.0.1 python-slugify==8.0.4 From 73642da7a4f1f6b618a4f8acff7f5cd1e7044444 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 25 Mar 2025 18:45:10 +0100 Subject: [PATCH 3023/3148] Add sensor for brightness intensity to SmartThings (#141368) --- .../components/smartthings/sensor.py | 9 ++ .../components/smartthings/strings.json | 3 + tests/components/smartthings/conftest.py | 1 + .../device_status/vd_sensor_light_2023.json | 95 +++++++++++++++++++ .../devices/vd_sensor_light_2023.json | 81 ++++++++++++++++ .../smartthings/snapshots/test_init.ambr | 33 +++++++ .../smartthings/snapshots/test_sensor.ambr | 51 ++++++++++ .../smartthings/snapshots/test_switch.ambr | 47 +++++++++ 8 files changed, 320 insertions(+) create mode 100644 tests/components/smartthings/fixtures/device_status/vd_sensor_light_2023.json create mode 100644 tests/components/smartthings/fixtures/devices/vd_sensor_light_2023.json diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index ee8550e4f06..0b5cbd3d332 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -675,6 +675,15 @@ CAPABILITY_TO_SENSORS: dict[ ) ] }, + Capability.RELATIVE_BRIGHTNESS: { + Attribute.BRIGHTNESS_INTENSITY: [ + SmartThingsSensorEntityDescription( + key=Attribute.BRIGHTNESS_INTENSITY, + translation_key="brightness_intensity", + state_class=SensorStateClass.MEASUREMENT, + ) + ] + }, Capability.RELATIVE_HUMIDITY_MEASUREMENT: { Attribute.HUMIDITY: [ SmartThingsSensorEntityDescription( diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index 2612b49a3ed..0f049131681 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -345,6 +345,9 @@ "refrigeration_setpoint": { "name": "[%key:component::smartthings::entity::sensor::oven_setpoint::name%]" }, + "brightness_intensity": { + "name": "Brightness intensity" + }, "robot_cleaner_cleaning_mode": { "name": "Cleaning mode", "state": { diff --git a/tests/components/smartthings/conftest.py b/tests/components/smartthings/conftest.py index edcd2f980fa..8a4d830af5a 100644 --- a/tests/components/smartthings/conftest.py +++ b/tests/components/smartthings/conftest.py @@ -106,6 +106,7 @@ def mock_smartthings() -> Generator[AsyncMock]: "centralite", "da_ref_normal_000001", "vd_network_audio_002s", + "vd_sensor_light_2023", "iphone", "da_sac_ehs_000001_sub", "da_wm_dw_000001", diff --git a/tests/components/smartthings/fixtures/device_status/vd_sensor_light_2023.json b/tests/components/smartthings/fixtures/device_status/vd_sensor_light_2023.json new file mode 100644 index 00000000000..cffefa20c4a --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/vd_sensor_light_2023.json @@ -0,0 +1,95 @@ +{ + "components": { + "main": { + "ocf": { + "st": { + "value": "2025-01-14T08:07:36Z", + "timestamp": "2025-01-14T08:07:40.220Z" + }, + "mndt": { + "value": "2023-01-01", + "timestamp": "2025-01-14T08:07:40.220Z" + }, + "mnfv": { + "value": "latest", + "timestamp": "2025-01-14T08:07:40.220Z" + }, + "mnhw": { + "value": "", + "timestamp": "2025-01-14T08:07:40.220Z" + }, + "di": { + "value": "5cc1c096-98b9-460c-8f1c-1045509ec605", + "timestamp": "2025-01-14T08:07:40.220Z" + }, + "mnsl": { + "value": "", + "timestamp": "2025-01-14T08:07:40.220Z" + }, + "dmv": { + "value": "res.1.1.0,sh.1.1.0", + "timestamp": "2025-01-14T08:07:40.220Z" + }, + "n": { + "value": "Light Sensor - 55 The Frame", + "timestamp": "2025-01-14T08:07:40.220Z" + }, + "mnmo": { + "value": "QE55LS03DAUXXN", + "timestamp": "2025-01-14T08:07:40.220Z" + }, + "vid": { + "value": "VD-Sensor.Light-2023", + "timestamp": "2025-01-14T08:07:40.220Z" + }, + "mnmn": { + "value": "Samsung Electronics", + "timestamp": "2025-01-14T08:07:40.220Z" + }, + "mnml": { + "value": "", + "timestamp": "2025-01-14T08:07:40.220Z" + }, + "mnpv": { + "value": "8.0", + "timestamp": "2025-01-14T08:07:40.220Z" + }, + "mnos": { + "value": "Tizen", + "timestamp": "2025-01-14T08:07:40.220Z" + }, + "pi": { + "value": "5cc1c096-98b9-460c-8f1c-1045509ec605", + "timestamp": "2025-01-14T08:07:40.220Z" + }, + "icv": { + "value": "core.1.1.0", + "timestamp": "2025-01-14T08:07:40.220Z" + } + }, + "samsungvd.deviceCategory": { + "category": { + "value": null + } + }, + "relativeBrightness": { + "brightnessIntensity": { + "value": 2, + "unit": "level", + "timestamp": "2025-02-11T19:08:25.539Z" + } + }, + "refresh": {}, + "execute": { + "data": { + "value": null + } + }, + "switch": { + "switch": { + "value": null + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/devices/vd_sensor_light_2023.json b/tests/components/smartthings/fixtures/devices/vd_sensor_light_2023.json new file mode 100644 index 00000000000..ef1dd2e96bc --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/vd_sensor_light_2023.json @@ -0,0 +1,81 @@ +{ + "items": [ + { + "deviceId": "5cc1c096-98b9-460c-8f1c-1045509ec605", + "name": "VD-Sensor.Light-2023", + "label": "Light Sensor - 55\" The Frame", + "manufacturerName": "Samsung Electronics", + "presentationId": "VD-Sensor.Light-2023", + "deviceManufacturerCode": "Samsung Electronics", + "locationId": "df59873c-4e2c-43ba-bcd4-ade4efb0504a", + "ownerId": "71254e90-c144-45b6-aabe-709f78f48376", + "roomId": "8a4fac38-48d1-4a8c-922b-92620442363b", + "deviceTypeName": "x.com.st.d.sensor.light", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "ocf", + "version": 1 + }, + { + "id": "switch", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "execute", + "version": 1 + }, + { + "id": "relativeBrightness", + "version": 1 + }, + { + "id": "samsungvd.deviceCategory", + "version": 1 + } + ], + "categories": [ + { + "name": "LightSensor", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2024-11-15T22:21:27.908Z", + "parentDeviceId": "425ac77a-f7c9-a62d-ff12-cdad144952e3", + "profile": { + "id": "5f1633fb-0c63-34d3-9d04-a314d393d225" + }, + "ocf": { + "ocfDeviceType": "x.com.st.d.sensor.light", + "name": "Light Sensor - 55 The Frame", + "specVersion": "core.1.1.0", + "verticalDomainSpecVersion": "res.1.1.0,sh.1.1.0", + "manufacturerName": "Samsung Electronics", + "modelNumber": "QE55LS03DAUXXN", + "platformVersion": "8.0", + "platformOS": "Tizen", + "hwVersion": "", + "firmwareVersion": "latest", + "vendorId": "VD-Sensor.Light-2023", + "vendorResourceClientServerVersion": "4.0.26", + "lastSignupTime": "2024-11-15T22:21:27.933740026Z", + "transferCandidate": false, + "additionalAuthCodeRequired": false + }, + "type": "OCF", + "restrictionTier": 0, + "allowed": [], + "executionContext": "CLOUD" + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/snapshots/test_init.ambr b/tests/components/smartthings/snapshots/test_init.ambr index 507a9a8b3a6..686b943008d 100644 --- a/tests/components/smartthings/snapshots/test_init.ambr +++ b/tests/components/smartthings/snapshots/test_init.ambr @@ -1487,6 +1487,39 @@ 'via_device_id': None, }) # --- +# name: test_devices[vd_sensor_light_2023] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '5cc1c096-98b9-460c-8f1c-1045509ec605', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Samsung Electronics', + 'model': 'QE55LS03DAUXXN', + 'model_id': None, + 'name': 'Light Sensor - 55" The Frame', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': 'latest', + 'via_device_id': None, + }) +# --- # name: test_devices[vd_stv_2017_k] DeviceRegistryEntrySnapshot({ 'area_id': 'theater', diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index ded9263ebc4..76e86cc832a 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -8347,6 +8347,57 @@ 'state': '17', }) # --- +# name: test_all_entities[vd_sensor_light_2023][sensor.light_sensor_55_the_frame_brightness_intensity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.light_sensor_55_the_frame_brightness_intensity', + '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': 'Brightness intensity', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'brightness_intensity', + 'unique_id': '5cc1c096-98b9-460c-8f1c-1045509ec605.brightnessIntensity', + 'unit_of_measurement': 'level', + }) +# --- +# name: test_all_entities[vd_sensor_light_2023][sensor.light_sensor_55_the_frame_brightness_intensity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Light Sensor - 55" The Frame Brightness intensity', + 'state_class': , + 'unit_of_measurement': 'level', + }), + 'context': , + 'entity_id': 'sensor.light_sensor_55_the_frame_brightness_intensity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2', + }) +# --- # name: test_all_entities[vd_stv_2017_k][sensor.tv_samsung_8_series_49_media_input_source-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/smartthings/snapshots/test_switch.ambr b/tests/components/smartthings/snapshots/test_switch.ambr index 1a8cb4c0ba7..a58176d8ee7 100644 --- a/tests/components/smartthings/snapshots/test_switch.ambr +++ b/tests/components/smartthings/snapshots/test_switch.ambr @@ -845,6 +845,53 @@ 'state': 'on', }) # --- +# name: test_all_entities[vd_sensor_light_2023][switch.light_sensor_55_the_frame-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.light_sensor_55_the_frame', + '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': None, + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '5cc1c096-98b9-460c-8f1c-1045509ec605', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[vd_sensor_light_2023][switch.light_sensor_55_the_frame-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Light Sensor - 55" The Frame', + }), + 'context': , + 'entity_id': 'switch.light_sensor_55_the_frame', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_all_entities[vd_stv_2017_k][switch.tv_samsung_8_series_49-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 44a02ac7a7c6a80148ff00ab07d2c42d13c4dad0 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Tue, 25 Mar 2025 18:52:31 +0100 Subject: [PATCH 3024/3148] Bump holidays to 0.69 (#141391) --- homeassistant/components/holiday/manifest.json | 2 +- homeassistant/components/workday/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/holiday/manifest.json b/homeassistant/components/holiday/manifest.json index ec47b222370..4c73210c36e 100644 --- a/homeassistant/components/holiday/manifest.json +++ b/homeassistant/components/holiday/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/holiday", "iot_class": "local_polling", - "requirements": ["holidays==0.68", "babel==2.15.0"] + "requirements": ["holidays==0.69", "babel==2.15.0"] } diff --git a/homeassistant/components/workday/manifest.json b/homeassistant/components/workday/manifest.json index cc6b0f30002..b08a5ed9fff 100644 --- a/homeassistant/components/workday/manifest.json +++ b/homeassistant/components/workday/manifest.json @@ -7,5 +7,5 @@ "iot_class": "local_polling", "loggers": ["holidays"], "quality_scale": "internal", - "requirements": ["holidays==0.68"] + "requirements": ["holidays==0.69"] } diff --git a/requirements_all.txt b/requirements_all.txt index 2e210d225b7..12fdbd28d57 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1151,7 +1151,7 @@ hole==0.8.0 # homeassistant.components.holiday # homeassistant.components.workday -holidays==0.68 +holidays==0.69 # homeassistant.components.frontend home-assistant-frontend==20250306.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7d7cf2ba88d..348eb8746f1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -978,7 +978,7 @@ hole==0.8.0 # homeassistant.components.holiday # homeassistant.components.workday -holidays==0.68 +holidays==0.69 # homeassistant.components.frontend home-assistant-frontend==20250306.0 From c8745cc33937edd17270077a50983cd91c29db22 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Tue, 25 Mar 2025 19:19:00 +0100 Subject: [PATCH 3025/3148] Add full test coverage for Vodafone Station button platform (#141298) --- .../vodafone_station/test_button.py | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/tests/components/vodafone_station/test_button.py b/tests/components/vodafone_station/test_button.py index d5f377d3f6f..ade5eb78965 100644 --- a/tests/components/vodafone_station/test_button.py +++ b/tests/components/vodafone_station/test_button.py @@ -2,11 +2,20 @@ from unittest.mock import AsyncMock, patch +from aiovodafone.exceptions import ( + AlreadyLogged, + CannotAuthenticate, + CannotConnect, + GenericLoginError, +) +import pytest from syrupy import SnapshotAssertion from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +from homeassistant.components.vodafone_station.const import DOMAIN from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from . import setup_integration @@ -46,3 +55,39 @@ async def test_pressing_button( blocking=True, ) mock_vodafone_station_router.restart_router.assert_called_once() + + +@pytest.mark.parametrize( + ("side_effect", "key", "error"), + [ + (CannotConnect, "cannot_execute_action", "CannotConnect()"), + (AlreadyLogged, "cannot_execute_action", "AlreadyLogged()"), + (GenericLoginError, "cannot_execute_action", "GenericLoginError()"), + (CannotAuthenticate, "cannot_authenticate", "CannotAuthenticate()"), + ], +) +async def test_button_fails( + hass: HomeAssistant, + mock_vodafone_station_router: AsyncMock, + mock_config_entry: MockConfigEntry, + side_effect: Exception, + key: str, + error: str, +) -> None: + """Test button action fails.""" + + await setup_integration(hass, mock_config_entry) + + mock_vodafone_station_router.restart_router.side_effect = side_effect + + with pytest.raises(HomeAssistantError) as exc_info: + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: "button.vodafone_station_m123456789_restart"}, + blocking=True, + ) + + assert exc_info.value.translation_domain == DOMAIN + assert exc_info.value.translation_key == key + assert exc_info.value.translation_placeholders == {"error": error} From 7319637bd57020f9dce5aab2982349aa6eaefe66 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Tue, 25 Mar 2025 13:30:44 -0500 Subject: [PATCH 3026/3148] Set responding state in assist satellite start_conversation (#141388) * Set responding state in async_start_conversation * Check idle state --- homeassistant/components/assist_satellite/entity.py | 3 +++ tests/components/assist_satellite/test_entity.py | 9 +++++++++ 2 files changed, 12 insertions(+) diff --git a/homeassistant/components/assist_satellite/entity.py b/homeassistant/components/assist_satellite/entity.py index 33b9e904246..450e6cadbc9 100644 --- a/homeassistant/components/assist_satellite/entity.py +++ b/homeassistant/components/assist_satellite/entity.py @@ -262,6 +262,8 @@ class AssistSatelliteEntity(entity.Entity): raise SatelliteBusyError self._is_announcing = True + self._set_state(AssistSatelliteState.RESPONDING) + # Provide our start info to the LLM so it understands context of incoming message if extra_system_prompt is not None: self._extra_system_prompt = extra_system_prompt @@ -291,6 +293,7 @@ class AssistSatelliteEntity(entity.Entity): raise finally: self._is_announcing = False + self._set_state(AssistSatelliteState.IDLE) async def async_start_conversation( self, start_announcement: AssistSatelliteAnnouncement diff --git a/tests/components/assist_satellite/test_entity.py b/tests/components/assist_satellite/test_entity.py index fcc3c5b98b5..b9f6da6f96c 100644 --- a/tests/components/assist_satellite/test_entity.py +++ b/tests/components/assist_satellite/test_entity.py @@ -594,6 +594,13 @@ async def test_start_conversation( expected_params: tuple[str, str], ) -> None: """Test starting a conversation on a device.""" + original_start_conversation = entity.async_start_conversation + + async def async_start_conversation(start_announcement): + # Verify state change + assert entity.state == AssistSatelliteState.RESPONDING + await original_start_conversation(start_announcement) + await async_update_pipeline( hass, async_get_pipeline(hass), @@ -620,6 +627,7 @@ async def test_start_conversation( mime_type="audio/mp3", ), ), + patch.object(entity, "async_start_conversation", new=async_start_conversation), ): await hass.services.async_call( "assist_satellite", @@ -628,6 +636,7 @@ async def test_start_conversation( target={"entity_id": "assist_satellite.test_entity"}, blocking=True, ) + assert entity.state == AssistSatelliteState.IDLE assert entity.start_conversations[0] == expected_params From ae18fa2e30e863c164f4a71dd58b09783cb341ab Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Tue, 25 Mar 2025 13:38:52 -0500 Subject: [PATCH 3027/3148] Add start conversation support to ESPHome (#141387) --- .../components/esphome/assist_satellite.py | 27 +- .../esphome/test_assist_satellite.py | 243 +++++++++++++++++- 2 files changed, 265 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/esphome/assist_satellite.py b/homeassistant/components/esphome/assist_satellite.py index fdd16d20d77..4206b545588 100644 --- a/homeassistant/components/esphome/assist_satellite.py +++ b/homeassistant/components/esphome/assist_satellite.py @@ -253,6 +253,11 @@ class EsphomeAssistSatellite( # Will use media player for TTS/announcements self._update_tts_format() + if feature_flags & VoiceAssistantFeature.START_CONVERSATION: + self._attr_supported_features |= ( + assist_satellite.AssistSatelliteEntityFeature.START_CONVERSATION + ) + # Update wake word select when config is updated self.async_on_remove( self.entry_data.async_register_assist_satellite_set_wake_word_callback( @@ -342,6 +347,23 @@ class EsphomeAssistSatellite( Should block until the announcement is done playing. """ + await self._do_announce(announcement, run_pipeline_after=False) + + async def async_start_conversation( + self, start_announcement: assist_satellite.AssistSatelliteAnnouncement + ) -> None: + """Start a conversation from the satellite.""" + await self._do_announce(start_announcement, run_pipeline_after=True) + + async def _do_announce( + self, + announcement: assist_satellite.AssistSatelliteAnnouncement, + run_pipeline_after: bool, + ) -> None: + """Announce media on the satellite. + + Optionally run a voice pipeline after the announcement has finished. + """ _LOGGER.debug( "Waiting for announcement to finished (message=%s, media_id=%s)", announcement.message, @@ -374,7 +396,10 @@ class EsphomeAssistSatellite( media_id = async_process_play_media_url(self.hass, proxy_url) await self.cli.send_voice_assistant_announcement_await_response( - media_id, _ANNOUNCEMENT_TIMEOUT_SEC, announcement.message + media_id, + _ANNOUNCEMENT_TIMEOUT_SEC, + announcement.message, + start_conversation=run_pipeline_after, ) async def handle_pipeline_start( diff --git a/tests/components/esphome/test_assist_satellite.py b/tests/components/esphome/test_assist_satellite.py index 329a7b5179a..081070b23f1 100644 --- a/tests/components/esphome/test_assist_satellite.py +++ b/tests/components/esphome/test_assist_satellite.py @@ -25,7 +25,12 @@ from aioesphomeapi import ( ) import pytest -from homeassistant.components import assist_satellite, conversation, tts +from homeassistant.components import ( + assist_pipeline, + assist_satellite, + conversation, + tts, +) from homeassistant.components.assist_pipeline import PipelineEvent, PipelineEventType from homeassistant.components.assist_satellite import ( AssistSatelliteConfiguration, @@ -1160,7 +1165,7 @@ async def test_announce_supported_features( Awaitable[MockESPHomeDevice], ], ) -> None: - """Test that the announce supported feature is set by flags.""" + """Test that the announce supported feature is not set by default.""" mock_device: MockESPHomeDevice = await mock_esphome_device( mock_client=mock_client, entity_info=[], @@ -1207,11 +1212,12 @@ async def test_announce_message( done = asyncio.Event() async def send_voice_assistant_announcement_await_response( - media_id: str, timeout: float, text: str + media_id: str, timeout: float, text: str, start_conversation: bool ): assert satellite.state == AssistSatelliteState.RESPONDING assert media_id == "http://10.10.10.10:8123/api/tts_proxy/test-token" assert text == "test-text" + assert not start_conversation done.set() @@ -1296,10 +1302,11 @@ async def test_announce_media_id( done = asyncio.Event() async def send_voice_assistant_announcement_await_response( - media_id: str, timeout: float, text: str + media_id: str, timeout: float, text: str, start_conversation: bool ): assert satellite.state == AssistSatelliteState.RESPONDING assert media_id == "https://www.home-assistant.io/proxied.flac" + assert not start_conversation done.set() @@ -1338,6 +1345,234 @@ async def test_announce_media_id( ) +async def test_start_conversation_supported_features( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: Callable[ + [APIClient, list[EntityInfo], list[UserService], list[EntityState]], + Awaitable[MockESPHomeDevice], + ], +) -> None: + """Test that the start conversation supported feature is not set by default.""" + mock_device: MockESPHomeDevice = await mock_esphome_device( + mock_client=mock_client, + entity_info=[], + user_service=[], + states=[], + device_info={ + "voice_assistant_feature_flags": VoiceAssistantFeature.VOICE_ASSISTANT + }, + ) + await hass.async_block_till_done() + + satellite = get_satellite_entity(hass, mock_device.device_info.mac_address) + assert satellite is not None + + assert not ( + satellite.supported_features & AssistSatelliteEntityFeature.START_CONVERSATION + ) + + +async def test_start_conversation_message( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: Callable[ + [APIClient, list[EntityInfo], list[UserService], list[EntityState]], + Awaitable[MockESPHomeDevice], + ], +) -> None: + """Test start conversation with message.""" + mock_device: MockESPHomeDevice = await mock_esphome_device( + mock_client=mock_client, + entity_info=[], + user_service=[], + states=[], + device_info={ + "voice_assistant_feature_flags": VoiceAssistantFeature.VOICE_ASSISTANT + | VoiceAssistantFeature.SPEAKER + | VoiceAssistantFeature.API_AUDIO + | VoiceAssistantFeature.ANNOUNCE + | VoiceAssistantFeature.START_CONVERSATION + }, + ) + await hass.async_block_till_done() + + satellite = get_satellite_entity(hass, mock_device.device_info.mac_address) + assert satellite is not None + + pipeline = assist_pipeline.Pipeline( + conversation_engine="test engine", + conversation_language="en", + language="en", + name="test pipeline", + stt_engine="test stt", + stt_language="en", + tts_engine="test tts", + tts_language="en", + tts_voice=None, + wake_word_entity=None, + wake_word_id=None, + ) + + done = asyncio.Event() + + async def send_voice_assistant_announcement_await_response( + media_id: str, timeout: float, text: str, start_conversation: bool + ): + assert satellite.state == AssistSatelliteState.RESPONDING + assert media_id == "http://10.10.10.10:8123/api/tts_proxy/test-token" + assert text == "test-text" + assert start_conversation + + done.set() + + with ( + patch( + "homeassistant.components.tts.generate_media_source_id", + return_value="media-source://bla", + ), + patch( + "homeassistant.components.tts.async_resolve_engine", + return_value="tts.cloud_tts", + ), + patch( + "homeassistant.components.tts.async_create_stream", + return_value=MockResultStream(hass, "wav", b""), + ), + patch.object( + mock_client, + "send_voice_assistant_announcement_await_response", + new=send_voice_assistant_announcement_await_response, + ), + patch( + "homeassistant.components.assist_satellite.entity.async_get_pipeline", + return_value=pipeline, + ), + ): + async with asyncio.timeout(1): + await hass.services.async_call( + assist_satellite.DOMAIN, + "start_conversation", + {"entity_id": satellite.entity_id, "start_message": "test-text"}, + blocking=True, + ) + await done.wait() + assert satellite.state == AssistSatelliteState.IDLE + + +async def test_start_conversation_media_id( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: Callable[ + [APIClient, list[EntityInfo], list[UserService], list[EntityState]], + Awaitable[MockESPHomeDevice], + ], + device_registry: dr.DeviceRegistry, +) -> None: + """Test start conversation with media id.""" + mock_device: MockESPHomeDevice = await mock_esphome_device( + mock_client=mock_client, + entity_info=[ + MediaPlayerInfo( + object_id="mymedia_player", + key=1, + name="my media_player", + unique_id="my_media_player", + supports_pause=True, + supported_formats=[ + MediaPlayerSupportedFormat( + format="flac", + sample_rate=48000, + num_channels=2, + purpose=MediaPlayerFormatPurpose.ANNOUNCEMENT, + sample_bytes=2, + ), + ], + ) + ], + user_service=[], + states=[], + device_info={ + "voice_assistant_feature_flags": VoiceAssistantFeature.VOICE_ASSISTANT + | VoiceAssistantFeature.SPEAKER + | VoiceAssistantFeature.API_AUDIO + | VoiceAssistantFeature.ANNOUNCE + | VoiceAssistantFeature.START_CONVERSATION + }, + ) + await hass.async_block_till_done() + + dev = device_registry.async_get_device( + connections={(dr.CONNECTION_NETWORK_MAC, mock_device.entry.unique_id)} + ) + + satellite = get_satellite_entity(hass, mock_device.device_info.mac_address) + assert satellite is not None + + pipeline = assist_pipeline.Pipeline( + conversation_engine="test engine", + conversation_language="en", + language="en", + name="test pipeline", + stt_engine="test stt", + stt_language="en", + tts_engine="test tts", + tts_language="en", + tts_voice=None, + wake_word_entity=None, + wake_word_id=None, + ) + + done = asyncio.Event() + + async def send_voice_assistant_announcement_await_response( + media_id: str, timeout: float, text: str, start_conversation: bool + ): + assert satellite.state == AssistSatelliteState.RESPONDING + assert media_id == "https://www.home-assistant.io/proxied.flac" + assert start_conversation + + done.set() + + with ( + patch.object( + mock_client, + "send_voice_assistant_announcement_await_response", + new=send_voice_assistant_announcement_await_response, + ), + patch( + "homeassistant.components.esphome.assist_satellite.async_create_proxy_url", + return_value="https://www.home-assistant.io/proxied.flac", + ) as mock_async_create_proxy_url, + patch( + "homeassistant.components.assist_satellite.entity.async_get_pipeline", + return_value=pipeline, + ), + ): + async with asyncio.timeout(1): + await hass.services.async_call( + assist_satellite.DOMAIN, + "start_conversation", + { + "entity_id": satellite.entity_id, + "start_media_id": "https://www.home-assistant.io/resolved.mp3", + }, + blocking=True, + ) + await done.wait() + assert satellite.state == AssistSatelliteState.IDLE + + mock_async_create_proxy_url.assert_called_once_with( + hass, + dev.id, + "https://www.home-assistant.io/resolved.mp3", + media_format="flac", + rate=48000, + channels=2, + width=2, + ) + + async def test_satellite_unloaded_on_disconnect( hass: HomeAssistant, mock_client: APIClient, From 746f49884c260da10a0b32f0333366cc1225d471 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Tue, 25 Mar 2025 19:39:06 +0100 Subject: [PATCH 3028/3148] Update setuptools for build-system to 77.0.3 (#141394) --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 1c7cf859829..4fdc359d77e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["setuptools==77.0.1"] +requires = ["setuptools==77.0.3"] build-backend = "setuptools.build_meta" [project] From 8b9939c344f82e0cefcd20782d70b197b894fa41 Mon Sep 17 00:00:00 2001 From: tdfountain <174762217+tdfountain@users.noreply.github.com> Date: Tue, 25 Mar 2025 12:04:07 -0700 Subject: [PATCH 3029/3148] Remove invalid watts sensor from NUT (#141401) --- homeassistant/components/nut/sensor.py | 7 ------- homeassistant/components/nut/strings.json | 3 +-- 2 files changed, 1 insertion(+), 9 deletions(-) diff --git a/homeassistant/components/nut/sensor.py b/homeassistant/components/nut/sensor.py index 71bfda91335..5bf7958e39e 100644 --- a/homeassistant/components/nut/sensor.py +++ b/homeassistant/components/nut/sensor.py @@ -989,13 +989,6 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), - "watts": SensorEntityDescription( - key="watts", - translation_key="watts", - native_unit_of_measurement=UnitOfPower.WATT, - device_class=SensorDeviceClass.POWER, - state_class=SensorStateClass.MEASUREMENT, - ), } diff --git a/homeassistant/components/nut/strings.json b/homeassistant/components/nut/strings.json index 1a54dffef11..4d8ffd45475 100644 --- a/homeassistant/components/nut/strings.json +++ b/homeassistant/components/nut/strings.json @@ -212,8 +212,7 @@ "ups_timer_shutdown": { "name": "Load shutdown timer" }, "ups_timer_start": { "name": "Load start timer" }, "ups_type": { "name": "UPS type" }, - "ups_watchdog_status": { "name": "Watchdog status" }, - "watts": { "name": "Watts" } + "ups_watchdog_status": { "name": "Watchdog status" } }, "switch": { "outlet_number_load_poweronoff": { "name": "Power outlet {outlet_name}" } From 10d9e0c684028a949871d236c3c902106b1bdd74 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 25 Mar 2025 20:25:04 +0100 Subject: [PATCH 3030/3148] Fix missing capitalization in two strings of `nobo_hub` (#141404) Fix missing capitalization of two strings in `nobo_hub` --- homeassistant/components/nobo_hub/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/nobo_hub/strings.json b/homeassistant/components/nobo_hub/strings.json index 28be01862e9..1059934e896 100644 --- a/homeassistant/components/nobo_hub/strings.json +++ b/homeassistant/components/nobo_hub/strings.json @@ -44,7 +44,7 @@ "entity": { "select": { "global_override": { - "name": "global override", + "name": "Global override", "state": { "away": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::away%]", "comfort": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::comfort%]", @@ -53,7 +53,7 @@ } }, "week_profile": { - "name": "week profile" + "name": "Week profile" } } } From 5db52cd5dfb2dd9313322e803ac6e9a92d0c76b5 Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Tue, 25 Mar 2025 21:43:46 +0200 Subject: [PATCH 3031/3148] Add data description for Shelly Bluetooth scanner mode (#141409) --- homeassistant/components/shelly/strings.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/shelly/strings.json b/homeassistant/components/shelly/strings.json index b678ab8250f..9eea5e3be9d 100644 --- a/homeassistant/components/shelly/strings.json +++ b/homeassistant/components/shelly/strings.json @@ -95,6 +95,9 @@ "description": "Bluetooth scanning can be active or passive. With active, the Shelly requests data from nearby devices; with passive, the Shelly receives unsolicited data from nearby devices.", "data": { "ble_scanner_mode": "Bluetooth scanner mode" + }, + "data_description": { + "ble_scanner_mode": "The scanner mode to use for Bluetooth scanning." } } } From 4cd4201a318b9464a6bd575d1e9a4093888a1bd2 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 25 Mar 2025 20:49:20 +0100 Subject: [PATCH 3032/3148] Add missing "r" in "Convector air flow" sensor of `ecoforest` (#141410) Add lost "r" in "Convector air flow" sensor of `ecoforest` --- homeassistant/components/ecoforest/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/ecoforest/strings.json b/homeassistant/components/ecoforest/strings.json index 1094e10ada3..1928acbdbd4 100644 --- a/homeassistant/components/ecoforest/strings.json +++ b/homeassistant/components/ecoforest/strings.json @@ -79,7 +79,7 @@ "name": "Extractor" }, "convecto_air_flow": { - "name": "Convecto air flow" + "name": "Convector air flow" } }, "number": { From e853df4fb0b9e4efa93640107e5fe955efd64560 Mon Sep 17 00:00:00 2001 From: elmurato <1382097+elmurato@users.noreply.github.com> Date: Tue, 25 Mar 2025 20:50:03 +0100 Subject: [PATCH 3033/3148] Add Pterodactyl integration (#141197) * Add Pterodactyl integration * Remove translation for unavailable platform sensor, use constant for host * Improve data descriptions * Replace index based handling of data (list) with dict[str, PterodactylData] * Replace CONF_HOST with CONF_URL * Parse URL with YARL * Set proper availability in binary sensor * Remove storage of data within api.py * Fix some review findings * Use better unique ID for binary_sensor * Fix more review findings * Fix remaining review findings * Add wrapper for server and util API, use underscore in unique ID * Reuse result in config flow tests * Patch async_setup_entry in config_flow tests * Move patching of library APIs to the fixture mock_pterodactyl --- CODEOWNERS | 2 + .../components/pterodactyl/__init__.py | 27 +++ homeassistant/components/pterodactyl/api.py | 120 ++++++++++++++ .../components/pterodactyl/binary_sensor.py | 64 ++++++++ .../components/pterodactyl/config_flow.py | 62 +++++++ homeassistant/components/pterodactyl/const.py | 3 + .../components/pterodactyl/coordinator.py | 66 ++++++++ .../components/pterodactyl/entity.py | 47 ++++++ .../components/pterodactyl/manifest.json | 10 ++ .../components/pterodactyl/quality_scale.yaml | 93 +++++++++++ .../components/pterodactyl/strings.json | 30 ++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/pterodactyl/__init__.py | 1 + tests/components/pterodactyl/conftest.py | 155 ++++++++++++++++++ .../pterodactyl/test_config_flow.py | 129 +++++++++++++++ 18 files changed, 822 insertions(+) create mode 100644 homeassistant/components/pterodactyl/__init__.py create mode 100644 homeassistant/components/pterodactyl/api.py create mode 100644 homeassistant/components/pterodactyl/binary_sensor.py create mode 100644 homeassistant/components/pterodactyl/config_flow.py create mode 100644 homeassistant/components/pterodactyl/const.py create mode 100644 homeassistant/components/pterodactyl/coordinator.py create mode 100644 homeassistant/components/pterodactyl/entity.py create mode 100644 homeassistant/components/pterodactyl/manifest.json create mode 100644 homeassistant/components/pterodactyl/quality_scale.yaml create mode 100644 homeassistant/components/pterodactyl/strings.json create mode 100644 tests/components/pterodactyl/__init__.py create mode 100644 tests/components/pterodactyl/conftest.py create mode 100644 tests/components/pterodactyl/test_config_flow.py diff --git a/CODEOWNERS b/CODEOWNERS index 1835e6d0be4..9e33407c7b8 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1183,6 +1183,8 @@ build.json @home-assistant/supervisor /tests/components/prusalink/ @balloob /homeassistant/components/ps4/ @ktnrg45 /tests/components/ps4/ @ktnrg45 +/homeassistant/components/pterodactyl/ @elmurato +/tests/components/pterodactyl/ @elmurato /homeassistant/components/pure_energie/ @klaasnicolaas /tests/components/pure_energie/ @klaasnicolaas /homeassistant/components/purpleair/ @bachya diff --git a/homeassistant/components/pterodactyl/__init__.py b/homeassistant/components/pterodactyl/__init__.py new file mode 100644 index 00000000000..33b3cc7576f --- /dev/null +++ b/homeassistant/components/pterodactyl/__init__.py @@ -0,0 +1,27 @@ +"""The Pterodactyl integration.""" + +from __future__ import annotations + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .coordinator import PterodactylConfigEntry, PterodactylCoordinator + +_PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: PterodactylConfigEntry) -> bool: + """Set up Pterodactyl from a config entry.""" + coordinator = PterodactylCoordinator(hass, entry) + await coordinator.async_config_entry_first_refresh() + entry.runtime_data = coordinator + await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS) + + return True + + +async def async_unload_entry( + hass: HomeAssistant, entry: PterodactylConfigEntry +) -> bool: + """Unload a Pterodactyl config entry.""" + return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS) diff --git a/homeassistant/components/pterodactyl/api.py b/homeassistant/components/pterodactyl/api.py new file mode 100644 index 00000000000..38cb9809652 --- /dev/null +++ b/homeassistant/components/pterodactyl/api.py @@ -0,0 +1,120 @@ +"""API module of the Pterodactyl integration.""" + +from dataclasses import dataclass +import logging + +from pydactyl import PterodactylClient +from pydactyl.exceptions import ( + BadRequestError, + ClientConfigError, + PterodactylApiError, + PydactylError, +) + +from homeassistant.core import HomeAssistant + +_LOGGER = logging.getLogger(__name__) + + +class PterodactylConfigurationError(Exception): + """Raised when the configuration is invalid.""" + + +class PterodactylConnectionError(Exception): + """Raised when no data can be fechted from the server.""" + + +@dataclass +class PterodactylData: + """Data for the Pterodactyl server.""" + + name: str + uuid: str + identifier: str + state: str + memory_utilization: int + cpu_utilization: float + disk_utilization: int + network_rx_utilization: int + network_tx_utilization: int + uptime: int + + +class PterodactylAPI: + """Wrapper for Pterodactyl's API.""" + + pterodactyl: PterodactylClient | None + identifiers: list[str] + + def __init__(self, hass: HomeAssistant, host: str, api_key: str) -> None: + """Initialize the Pterodactyl API.""" + self.hass = hass + self.host = host + self.api_key = api_key + self.pterodactyl = None + self.identifiers = [] + + async def async_init(self): + """Initialize the Pterodactyl API.""" + self.pterodactyl = PterodactylClient(self.host, self.api_key) + + try: + paginated_response = await self.hass.async_add_executor_job( + self.pterodactyl.client.servers.list_servers + ) + except ClientConfigError as error: + raise PterodactylConfigurationError(error) from error + except ( + PydactylError, + BadRequestError, + PterodactylApiError, + ) as error: + raise PterodactylConnectionError(error) from error + else: + game_servers = paginated_response.collect() + for game_server in game_servers: + self.identifiers.append(game_server["attributes"]["identifier"]) + + _LOGGER.debug("Identifiers of Pterodactyl servers: %s", self.identifiers) + + def get_server_data(self, identifier: str) -> tuple[dict, dict]: + """Get all data from the Pterodactyl server.""" + server = self.pterodactyl.client.servers.get_server(identifier) # type: ignore[union-attr] + utilization = self.pterodactyl.client.servers.get_server_utilization( # type: ignore[union-attr] + identifier + ) + + return server, utilization + + async def async_get_data(self) -> dict[str, PterodactylData]: + """Update the data from all Pterodactyl servers.""" + data = {} + + for identifier in self.identifiers: + try: + server, utilization = await self.hass.async_add_executor_job( + self.get_server_data, identifier + ) + except ( + PydactylError, + BadRequestError, + PterodactylApiError, + ) as error: + raise PterodactylConnectionError(error) from error + else: + data[identifier] = PterodactylData( + name=server["name"], + uuid=server["uuid"], + identifier=identifier, + state=utilization["current_state"], + cpu_utilization=utilization["resources"]["cpu_absolute"], + memory_utilization=utilization["resources"]["memory_bytes"], + disk_utilization=utilization["resources"]["disk_bytes"], + network_rx_utilization=utilization["resources"]["network_rx_bytes"], + network_tx_utilization=utilization["resources"]["network_tx_bytes"], + uptime=utilization["resources"]["uptime"], + ) + + _LOGGER.debug("%s", data[identifier]) + + return data diff --git a/homeassistant/components/pterodactyl/binary_sensor.py b/homeassistant/components/pterodactyl/binary_sensor.py new file mode 100644 index 00000000000..e3615c47499 --- /dev/null +++ b/homeassistant/components/pterodactyl/binary_sensor.py @@ -0,0 +1,64 @@ +"""Binary sensor platform of the Pterodactyl integration.""" + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import PterodactylConfigEntry, PterodactylCoordinator +from .entity import PterodactylEntity + +KEY_STATUS = "status" + + +BINARY_SENSOR_DESCRIPTIONS = [ + BinarySensorEntityDescription( + key=KEY_STATUS, + translation_key=KEY_STATUS, + device_class=BinarySensorDeviceClass.RUNNING, + ), +] + +# Coordinator is used to centralize the data updates. +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: PterodactylConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Pterodactyl binary sensor platform.""" + coordinator = config_entry.runtime_data + + async_add_entities( + PterodactylBinarySensorEntity( + coordinator, identifier, description, config_entry + ) + for identifier in coordinator.api.identifiers + for description in BINARY_SENSOR_DESCRIPTIONS + ) + + +class PterodactylBinarySensorEntity(PterodactylEntity, BinarySensorEntity): + """Representation of a Pterodactyl binary sensor base entity.""" + + def __init__( + self, + coordinator: PterodactylCoordinator, + identifier: str, + description: BinarySensorEntityDescription, + config_entry: PterodactylConfigEntry, + ) -> None: + """Initialize binary sensor base entity.""" + super().__init__(coordinator, identifier, config_entry) + self.entity_description = description + self._attr_unique_id = f"{self.game_server_data.uuid}_{description.key}" + + @property + def is_on(self) -> bool: + """Return binary sensor state.""" + return self.game_server_data.state == "running" diff --git a/homeassistant/components/pterodactyl/config_flow.py b/homeassistant/components/pterodactyl/config_flow.py new file mode 100644 index 00000000000..a36069d2bb9 --- /dev/null +++ b/homeassistant/components/pterodactyl/config_flow.py @@ -0,0 +1,62 @@ +"""Config flow for the Pterodactyl integration.""" + +from __future__ import annotations + +import logging +from typing import Any + +import voluptuous as vol +from yarl import URL + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_API_KEY, CONF_URL + +from .api import ( + PterodactylAPI, + PterodactylConfigurationError, + PterodactylConnectionError, +) +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_URL = "http://localhost:8080" + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_URL, default=DEFAULT_URL): str, + vol.Required(CONF_API_KEY): str, + } +) + + +class PterodactylConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Pterodactyl.""" + + 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 is not None: + url = URL(user_input[CONF_URL]).human_repr() + api_key = user_input[CONF_API_KEY] + + self._async_abort_entries_match({CONF_URL: url}) + api = PterodactylAPI(self.hass, url, api_key) + + try: + await api.async_init() + except (PterodactylConfigurationError, PterodactylConnectionError): + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception occurred during config flow") + errors["base"] = "unknown" + else: + return self.async_create_entry(title=url, data=user_input) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) diff --git a/homeassistant/components/pterodactyl/const.py b/homeassistant/components/pterodactyl/const.py new file mode 100644 index 00000000000..8cf4d0c3963 --- /dev/null +++ b/homeassistant/components/pterodactyl/const.py @@ -0,0 +1,3 @@ +"""Constants for the Pterodactyl integration.""" + +DOMAIN = "pterodactyl" diff --git a/homeassistant/components/pterodactyl/coordinator.py b/homeassistant/components/pterodactyl/coordinator.py new file mode 100644 index 00000000000..36456ade630 --- /dev/null +++ b/homeassistant/components/pterodactyl/coordinator.py @@ -0,0 +1,66 @@ +"""Data update coordinator of the Pterodactyl integration.""" + +from __future__ import annotations + +from datetime import timedelta +import logging + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_KEY, CONF_URL +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .api import ( + PterodactylAPI, + PterodactylConfigurationError, + PterodactylConnectionError, + PterodactylData, +) + +SCAN_INTERVAL = timedelta(seconds=60) + +_LOGGER = logging.getLogger(__name__) + +type PterodactylConfigEntry = ConfigEntry[PterodactylCoordinator] + + +class PterodactylCoordinator(DataUpdateCoordinator[dict[str, PterodactylData]]): + """Pterodactyl data update coordinator.""" + + config_entry: PterodactylConfigEntry + api: PterodactylAPI + + def __init__( + self, + hass: HomeAssistant, + config_entry: PterodactylConfigEntry, + ) -> None: + """Initialize coordinator instance.""" + + super().__init__( + hass=hass, + name=config_entry.data[CONF_URL], + config_entry=config_entry, + logger=_LOGGER, + update_interval=SCAN_INTERVAL, + ) + + async def _async_setup(self) -> None: + """Set up the Pterodactyl data coordinator.""" + self.api = PterodactylAPI( + hass=self.hass, + host=self.config_entry.data[CONF_URL], + api_key=self.config_entry.data[CONF_API_KEY], + ) + + try: + await self.api.async_init() + except PterodactylConfigurationError as error: + raise UpdateFailed(error) from error + + async def _async_update_data(self) -> dict[str, PterodactylData]: + """Get updated data from the Pterodactyl server.""" + try: + return await self.api.async_get_data() + except PterodactylConnectionError as error: + raise UpdateFailed(error) from error diff --git a/homeassistant/components/pterodactyl/entity.py b/homeassistant/components/pterodactyl/entity.py new file mode 100644 index 00000000000..49fd65af476 --- /dev/null +++ b/homeassistant/components/pterodactyl/entity.py @@ -0,0 +1,47 @@ +"""Base entity for the Pterodactyl integration.""" + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_URL +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .api import PterodactylData +from .const import DOMAIN +from .coordinator import PterodactylCoordinator + +MANUFACTURER = "Pterodactyl" + + +class PterodactylEntity(CoordinatorEntity[PterodactylCoordinator]): + """Representation of a Pterodactyl base entity.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: PterodactylCoordinator, + identifier: str, + config_entry: ConfigEntry, + ) -> None: + """Initialize base entity.""" + super().__init__(coordinator) + + self.identifier = identifier + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, identifier)}, + manufacturer=MANUFACTURER, + name=self.game_server_data.name, + model=self.game_server_data.name, + model_id=self.game_server_data.uuid, + configuration_url=f"{config_entry.data[CONF_URL]}/server/{identifier}", + ) + + @property + def available(self) -> bool: + """Return binary sensor availability.""" + return super().available and self.identifier in self.coordinator.data + + @property + def game_server_data(self) -> PterodactylData: + """Return game server data.""" + return self.coordinator.data[self.identifier] diff --git a/homeassistant/components/pterodactyl/manifest.json b/homeassistant/components/pterodactyl/manifest.json new file mode 100644 index 00000000000..8ffa21dd186 --- /dev/null +++ b/homeassistant/components/pterodactyl/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "pterodactyl", + "name": "Pterodactyl", + "codeowners": ["@elmurato"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/pterodactyl", + "iot_class": "local_polling", + "quality_scale": "bronze", + "requirements": ["py-dactyl==2.0.4"] +} diff --git a/homeassistant/components/pterodactyl/quality_scale.yaml b/homeassistant/components/pterodactyl/quality_scale.yaml new file mode 100644 index 00000000000..dae3b9fa11a --- /dev/null +++ b/homeassistant/components/pterodactyl/quality_scale.yaml @@ -0,0 +1,93 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: Integration doesn't provide any service actions. + appropriate-polling: done + brands: done + common-modules: done + config-flow: done + config-flow-test-coverage: done + dependency-transparency: done + docs-actions: + status: exempt + comment: Integration doesn't provide any service actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: done + comment: Handled by coordinator. + entity-unique-id: + status: done + comment: Using confid entry ID as the dependency pydactyl doesn't provide a unique information. + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: + status: done + comment: | + Raising ConfigEntryNotReady, if the initialization isn't successful. + unique-config-entry: + status: done + comment: | + As there is no unique information available from the dependency pydactyl, + the server host is used to identify that the same service is already configured. + + # Silver + action-exceptions: + status: exempt + comment: Integration doesn't provide any service actions. + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: Integration doesn't support any configuration parameters. + docs-installation-parameters: todo + entity-unavailable: + status: done + comment: Handled by coordinator. + integration-owner: done + log-when-unavailable: + status: done + comment: Handled by coordinator. + parallel-updates: done + reauthentication-flow: todo + test-coverage: todo + + # Gold + devices: done + diagnostics: todo + discovery: + status: exempt + comment: No discovery possible. + discovery-update-info: + status: exempt + comment: | + No discovery possible. Users can use the (local or public) hostname instead of an IP address, + if static IP addresses cannot be configured. + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: todo + entity-category: done + entity-device-class: done + entity-disabled-by-default: todo + entity-translations: done + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: No repair use-cases for this integration. + stale-devices: todo + + # Platinum + async-dependency: todo + inject-websession: + status: exempt + comment: Integration isn't making any HTTP requests. + strict-typing: todo diff --git a/homeassistant/components/pterodactyl/strings.json b/homeassistant/components/pterodactyl/strings.json new file mode 100644 index 00000000000..a875c72ccd8 --- /dev/null +++ b/homeassistant/components/pterodactyl/strings.json @@ -0,0 +1,30 @@ +{ + "config": { + "step": { + "user": { + "data": { + "url": "[%key:common::config_flow::data::url%]", + "api_key": "[%key:common::config_flow::data::api_key%]" + }, + "data_description": { + "url": "The URL of your Pterodactyl server, including the protocol (http:// or https://) and optionally the port number.", + "api_key": "The account API key for accessing your Pterodactyl server." + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" + } + }, + "entity": { + "binary_sensor": { + "status": { + "name": "Status" + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index a9c4a6b0a93..5a292995f01 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -488,6 +488,7 @@ FLOWS = { "proximity", "prusalink", "ps4", + "pterodactyl", "pure_energie", "purpleair", "pushbullet", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index c43af49f03f..52fb10e1886 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -5021,6 +5021,12 @@ "integration_type": "virtual", "supported_by": "opower" }, + "pterodactyl": { + "name": "Pterodactyl", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_polling" + }, "pulseaudio_loopback": { "name": "PulseAudio Loopback", "integration_type": "hub", diff --git a/requirements_all.txt b/requirements_all.txt index 12fdbd28d57..267281885ad 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1733,6 +1733,9 @@ py-ccm15==0.0.9 # homeassistant.components.cpuspeed py-cpuinfo==9.0.0 +# homeassistant.components.pterodactyl +py-dactyl==2.0.4 + # homeassistant.components.dormakaba_dkey py-dormakaba-dkey==1.0.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 348eb8746f1..45c5353d6f0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1432,6 +1432,9 @@ py-ccm15==0.0.9 # homeassistant.components.cpuspeed py-cpuinfo==9.0.0 +# homeassistant.components.pterodactyl +py-dactyl==2.0.4 + # homeassistant.components.dormakaba_dkey py-dormakaba-dkey==1.0.5 diff --git a/tests/components/pterodactyl/__init__.py b/tests/components/pterodactyl/__init__.py new file mode 100644 index 00000000000..a5b28d67ae3 --- /dev/null +++ b/tests/components/pterodactyl/__init__.py @@ -0,0 +1 @@ +"""Tests for the Pterodactyl integration.""" diff --git a/tests/components/pterodactyl/conftest.py b/tests/components/pterodactyl/conftest.py new file mode 100644 index 00000000000..62326e79207 --- /dev/null +++ b/tests/components/pterodactyl/conftest.py @@ -0,0 +1,155 @@ +"""Common fixtures for the Pterodactyl tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +from pydactyl.responses import PaginatedResponse +import pytest + +from homeassistant.components.pterodactyl.const import DOMAIN +from homeassistant.const import CONF_API_KEY, CONF_URL + +from tests.common import MockConfigEntry + +TEST_URL = "https://192.168.0.1:8080/" +TEST_API_KEY = "TestClientApiKey" +TEST_USER_INPUT = { + CONF_URL: TEST_URL, + CONF_API_KEY: TEST_API_KEY, +} +TEST_SERVER_LIST_DATA = { + "meta": {"pagination": {"total": 2, "count": 2, "per_page": 50, "current_page": 1}}, + "data": [ + { + "object": "server", + "attributes": { + "server_owner": True, + "identifier": "1", + "internal_id": 1, + "uuid": "1-1-1-1-1", + "name": "Test Server 1", + "node": "default_node", + "description": "Description of Test Server 1", + "limits": { + "memory": 2048, + "swap": 1024, + "disk": 10240, + "io": 500, + "cpu": 100, + "threads": None, + "oom_disabled": True, + }, + "invocation": "java -jar test_server1.jar", + "docker_image": "test_docker_image_1", + "egg_features": ["java_version"], + }, + }, + { + "object": "server", + "attributes": { + "server_owner": True, + "identifier": "2", + "internal_id": 2, + "uuid": "2-2-2-2-2", + "name": "Test Server 2", + "node": "default_node", + "description": "Description of Test Server 2", + "limits": { + "memory": 2048, + "swap": 1024, + "disk": 10240, + "io": 500, + "cpu": 100, + "threads": None, + "oom_disabled": True, + }, + "invocation": "java -jar test_server_2.jar", + "docker_image": "test_docker_image2", + "egg_features": ["java_version"], + }, + }, + ], +} +TEST_SERVER = { + "server_owner": True, + "identifier": "1", + "internal_id": 1, + "uuid": "1-1-1-1-1", + "name": "Test Server 1", + "node": "default_node", + "is_node_under_maintenance": False, + "sftp_details": {"ip": "192.168.0.1", "port": 2022}, + "description": "", + "limits": { + "memory": 2048, + "swap": 1024, + "disk": 10240, + "io": 500, + "cpu": 100, + "threads": None, + "oom_disabled": True, + }, + "invocation": "java -jar test.jar", + "docker_image": "test_docker_image", + "egg_features": ["eula", "java_version", "pid_limit"], + "feature_limits": {"databases": 0, "allocations": 0, "backups": 3}, + "status": None, + "is_suspended": False, + "is_installing": False, + "is_transferring": False, + "relationships": {"allocations": {...}, "variables": {...}}, +} +TEST_SERVER_UTILIZATION = { + "current_state": "running", + "is_suspended": False, + "resources": { + "memory_bytes": 1111, + "cpu_absolute": 22, + "disk_bytes": 3333, + "network_rx_bytes": 44, + "network_tx_bytes": 55, + "uptime": 6666, + }, +} + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.pterodactyl.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Create Pterodactyl mock config entry.""" + return MockConfigEntry( + domain=DOMAIN, + unique_id=None, + entry_id="01234567890123456789012345678901", + title=TEST_URL, + data={ + CONF_URL: TEST_URL, + CONF_API_KEY: TEST_API_KEY, + }, + version=1, + ) + + +@pytest.fixture +def mock_pterodactyl(): + """Mock the Pterodactyl API.""" + with patch( + "homeassistant.components.pterodactyl.api.PterodactylClient", autospec=True + ) as mock: + mock.return_value.client.servers.list_servers.return_value = PaginatedResponse( + mock.return_value, "client", TEST_SERVER_LIST_DATA + ) + mock.return_value.client.servers.get_server.return_value = TEST_SERVER + mock.return_value.client.servers.get_server_utilization.return_value = ( + TEST_SERVER_UTILIZATION + ) + + yield mock.return_value diff --git a/tests/components/pterodactyl/test_config_flow.py b/tests/components/pterodactyl/test_config_flow.py new file mode 100644 index 00000000000..14bb2d2f69f --- /dev/null +++ b/tests/components/pterodactyl/test_config_flow.py @@ -0,0 +1,129 @@ +"""Test the Pterodactyl config flow.""" + +from pydactyl import PterodactylClient +from pydactyl.exceptions import ClientConfigError, PterodactylApiError +import pytest + +from homeassistant.components.pterodactyl.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from .conftest import TEST_URL, TEST_USER_INPUT + +from tests.common import MockConfigEntry + + +@pytest.mark.usefixtures("mock_pterodactyl", "mock_setup_entry") +async def test_full_flow(hass: HomeAssistant) -> None: + """Test full flow without errors.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + flow_id=result["flow_id"], + user_input=TEST_USER_INPUT, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == TEST_URL + assert result["data"] == TEST_USER_INPUT + + +@pytest.mark.usefixtures("mock_setup_entry") +@pytest.mark.parametrize( + "exception_type", + [ + ClientConfigError, + PterodactylApiError, + ], +) +async def test_recovery_after_api_error( + hass: HomeAssistant, + exception_type, + mock_pterodactyl: PterodactylClient, +) -> None: + """Test recovery after an API error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + mock_pterodactyl.client.servers.list_servers.side_effect = exception_type + + result = await hass.config_entries.flow.async_configure( + flow_id=result["flow_id"], + user_input=TEST_USER_INPUT, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} + + mock_pterodactyl.reset_mock(side_effect=True) + + result = await hass.config_entries.flow.async_configure( + flow_id=result["flow_id"], user_input=TEST_USER_INPUT + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == TEST_URL + assert result["data"] == TEST_USER_INPUT + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_recovery_after_unknown_error( + hass: HomeAssistant, + mock_pterodactyl: PterodactylClient, +) -> None: + """Test recovery after an API error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + mock_pterodactyl.client.servers.list_servers.side_effect = Exception + + result = await hass.config_entries.flow.async_configure( + flow_id=result["flow_id"], + user_input=TEST_USER_INPUT, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "unknown"} + + mock_pterodactyl.reset_mock(side_effect=True) + + result = await hass.config_entries.flow.async_configure( + flow_id=result["flow_id"], user_input=TEST_USER_INPUT + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == TEST_URL + assert result["data"] == TEST_USER_INPUT + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_service_already_configured( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_pterodactyl: PterodactylClient, +) -> None: + """Test config flow abort if the Pterodactyl server is already configured.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=TEST_USER_INPUT + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" From 646c97a26c7923ae0536d81224ed32abb58e551a Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 25 Mar 2025 21:06:44 +0100 Subject: [PATCH 3034/3148] Fix spelling / grammar in `sensibo` strings (#141130) - capitalize "ID" - remove excessive space and comma - remove excessive "the" --- homeassistant/components/sensibo/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/sensibo/strings.json b/homeassistant/components/sensibo/strings.json index 6aba2be52fc..0fbcda461c8 100644 --- a/homeassistant/components/sensibo/strings.json +++ b/homeassistant/components/sensibo/strings.json @@ -330,7 +330,7 @@ "timer_on_switch": { "name": "Timer", "state_attributes": { - "id": { "name": "Id" }, + "id": { "name": "ID" }, "turn_on": { "name": "Turns on", "state": { @@ -594,7 +594,7 @@ "issues": { "deprecated_entity_horizontalswing": { "title": "The Sensibo {name} entity is deprecated", - "description": "The Sensibo entity `{entity}` is deprecated and will be removed in a future release.\nPlease update your automations and scripts to use the `horizontal_swing` attribute part of the `climate` entity instead.\n, Disable the `{entity}` and reload the config entry or restart Home Assistant to fix this issue." + "description": "The Sensibo entity `{entity}` is deprecated and will be removed in a future release.\nPlease update your automations and scripts to use the `horizontal_swing` attribute part of the `climate` entity instead.\nDisable `{entity}` and reload the config entry or restart Home Assistant to fix this issue." } } } From 013439f7c610981720474eeab640ea63ca007e80 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Wed, 26 Mar 2025 06:09:45 +1000 Subject: [PATCH 3035/3148] Add streaming to Climate platform in Teslemetry (#138689) * Add streaming climate * fixes * Add missing changes * Fix restore * Update homeassistant/components/teslemetry/climate.py Co-authored-by: Joost Lekkerkerker * Use dict * Add fan mode translations * Infer side * Apply suggestions from code review Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/teslemetry/climate.py Co-authored-by: Joost Lekkerkerker --------- Co-authored-by: Joost Lekkerkerker --- .../components/teslemetry/climate.py | 514 +++++++++++++----- .../components/teslemetry/strings.json | 6 + .../teslemetry/snapshots/test_climate.ambr | 117 +++- tests/components/teslemetry/test_climate.py | 114 ++-- tests/components/teslemetry/test_init.py | 21 - 5 files changed, 543 insertions(+), 229 deletions(-) diff --git a/homeassistant/components/teslemetry/climate.py b/homeassistant/components/teslemetry/climate.py index 86811131ab6..3aaf5f0516c 100644 --- a/homeassistant/components/teslemetry/climate.py +++ b/homeassistant/components/teslemetry/climate.py @@ -6,9 +6,11 @@ from itertools import chain from typing import Any, cast from tesla_fleet_api.const import CabinOverheatProtectionTemp, Scope +from tesla_fleet_api.vehicle import VehicleSpecific from homeassistant.components.climate import ( ATTR_HVAC_MODE, + HVAC_MODES, ClimateEntity, ClimateEntityFeature, HVACMode, @@ -22,15 +24,32 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.restore_state import RestoreEntity from . import TeslemetryConfigEntry from .const import DOMAIN, TeslemetryClimateSide -from .entity import TeslemetryVehicleEntity +from .entity import ( + TeslemetryRootEntity, + TeslemetryVehicleEntity, + TeslemetryVehicleStreamEntity, +) from .helpers import handle_vehicle_command from .models import TeslemetryVehicleData DEFAULT_MIN_TEMP = 15 DEFAULT_MAX_TEMP = 28 +COP_TEMPERATURES = { + 30: CabinOverheatProtectionTemp.LOW, + 35: CabinOverheatProtectionTemp.MEDIUM, + 40: CabinOverheatProtectionTemp.HIGH, +} +PRESET_MODES = { + "Off": "off", + "On": "keep", + "Dog": "dog", + "Party": "camp", +} + PARALLEL_UPDATES = 0 @@ -45,13 +64,21 @@ async def async_setup_entry( async_add_entities( chain( ( - TeslemetryClimateEntity( + TeslemetryPollingClimateEntity( + vehicle, TeslemetryClimateSide.DRIVER, entry.runtime_data.scopes + ) + if vehicle.api.pre2021 or vehicle.firmware < "2024.44.25" + else TeslemetryStreamingClimateEntity( vehicle, TeslemetryClimateSide.DRIVER, entry.runtime_data.scopes ) for vehicle in entry.runtime_data.vehicles ), ( - TeslemetryCabinOverheatProtectionEntity( + TeslemetryPollingCabinOverheatProtectionEntity( + vehicle, entry.runtime_data.scopes + ) + if vehicle.api.pre2021 or vehicle.firmware < "2024.44.25" + else TeslemetryStreamingCabinOverheatProtectionEntity( vehicle, entry.runtime_data.scopes ) for vehicle in entry.runtime_data.vehicles @@ -60,66 +87,22 @@ async def async_setup_entry( ) -class TeslemetryClimateEntity(TeslemetryVehicleEntity, ClimateEntity): - """Telemetry vehicle climate entity.""" +class TeslemetryClimateEntity(TeslemetryRootEntity, ClimateEntity): + """Vehicle Climate Control.""" + + api: VehicleSpecific _attr_precision = PRECISION_HALVES - _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_hvac_modes = [HVACMode.HEAT_COOL, HVACMode.OFF] - _attr_supported_features = ( - ClimateEntityFeature.TURN_ON - | ClimateEntityFeature.TURN_OFF - | ClimateEntityFeature.TARGET_TEMPERATURE - | ClimateEntityFeature.PRESET_MODE - ) - _attr_preset_modes = ["off", "keep", "dog", "camp"] - - def __init__( - self, - data: TeslemetryVehicleData, - side: TeslemetryClimateSide, - scopes: Scope, - ) -> None: - """Initialize the climate.""" - self.scoped = Scope.VEHICLE_CMDS in scopes - - if not self.scoped: - self._attr_supported_features = ClimateEntityFeature(0) - self._attr_hvac_modes = [] - - super().__init__( - data, - side, - ) - - def _async_update_attrs(self) -> None: - """Update the attributes of the entity.""" - value = self.get("climate_state_is_climate_on") - if value: - self._attr_hvac_mode = HVACMode.HEAT_COOL - else: - self._attr_hvac_mode = HVACMode.OFF - - # If not scoped, prevent the user from changing the HVAC mode by making it the only option - if self._attr_hvac_mode and not self.scoped: - self._attr_hvac_modes = [self._attr_hvac_mode] - - self._attr_current_temperature = self.get("climate_state_inside_temp") - self._attr_target_temperature = self.get(f"climate_state_{self.key}_setting") - self._attr_preset_mode = self.get("climate_state_climate_keeper_mode") - self._attr_min_temp = cast( - float, self.get("climate_state_min_avail_temp", DEFAULT_MIN_TEMP) - ) - self._attr_max_temp = cast( - float, self.get("climate_state_max_avail_temp", DEFAULT_MAX_TEMP) - ) + _attr_preset_modes = list(PRESET_MODES.values()) + _attr_fan_modes = ["off", "bioweapon"] + _enable_turn_on_off_backwards_compatibility = False async def async_turn_on(self) -> None: """Set the climate state to on.""" - self.raise_for_scope(Scope.VEHICLE_CMDS) - await self.wake_up_if_asleep() + await handle_vehicle_command(self.api.auto_conditioning_start()) self._attr_hvac_mode = HVACMode.HEAT_COOL @@ -127,19 +110,21 @@ class TeslemetryClimateEntity(TeslemetryVehicleEntity, ClimateEntity): async def async_turn_off(self) -> None: """Set the climate state to off.""" - self.raise_for_scope(Scope.VEHICLE_CMDS) - await self.wake_up_if_asleep() + await handle_vehicle_command(self.api.auto_conditioning_stop()) self._attr_hvac_mode = HVACMode.OFF self._attr_preset_mode = self._attr_preset_modes[0] + self._attr_fan_mode = self._attr_fan_modes[0] self.async_write_ha_state() async def async_set_temperature(self, **kwargs: Any) -> None: """Set the climate temperature.""" + if temp := kwargs.get(ATTR_TEMPERATURE): - await self.wake_up_if_asleep() + self.raise_for_scope(Scope.VEHICLE_CMDS) + await handle_vehicle_command( self.api.set_temps( driver_temp=temp, @@ -163,18 +148,210 @@ class TeslemetryClimateEntity(TeslemetryVehicleEntity, ClimateEntity): async def async_set_preset_mode(self, preset_mode: str) -> None: """Set the climate preset mode.""" - await self.wake_up_if_asleep() + self.raise_for_scope(Scope.VEHICLE_CMDS) + await handle_vehicle_command( self.api.set_climate_keeper_mode( climate_keeper_mode=self._attr_preset_modes.index(preset_mode) ) ) self._attr_preset_mode = preset_mode - if preset_mode != self._attr_preset_modes[0]: - # Changing preset mode will also turn on climate + if preset_mode == self._attr_preset_modes[0]: + self._attr_hvac_mode = HVACMode.OFF + else: self._attr_hvac_mode = HVACMode.HEAT_COOL self.async_write_ha_state() + async def async_set_fan_mode(self, fan_mode: str) -> None: + """Set the Bioweapon defense mode.""" + self.raise_for_scope(Scope.VEHICLE_CMDS) + + await handle_vehicle_command( + self.api.set_bioweapon_mode( + on=(fan_mode != "off"), + manual_override=True, + ) + ) + self._attr_fan_mode = fan_mode + if fan_mode == self._attr_fan_modes[1]: + self._attr_hvac_mode = HVACMode.HEAT_COOL + self.async_write_ha_state() + + +class TeslemetryPollingClimateEntity(TeslemetryClimateEntity, TeslemetryVehicleEntity): + """Polling vehicle climate entity.""" + + _attr_supported_features = ( + ClimateEntityFeature.TURN_ON + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.PRESET_MODE + | ClimateEntityFeature.FAN_MODE + ) + + def __init__( + self, + data: TeslemetryVehicleData, + side: TeslemetryClimateSide, + scopes: list[Scope], + ) -> None: + """Initialize the climate.""" + self.scoped = Scope.VEHICLE_CMDS in scopes + if not self.scoped: + self._attr_supported_features = ClimateEntityFeature(0) + + super().__init__(data, side) + + def _async_update_attrs(self) -> None: + """Update the attributes of the entity.""" + value = self.get("climate_state_is_climate_on") + if value is None: + self._attr_hvac_mode = None + if value: + self._attr_hvac_mode = HVACMode.HEAT_COOL + else: + self._attr_hvac_mode = HVACMode.OFF + + self._attr_current_temperature = self.get("climate_state_inside_temp") + self._attr_target_temperature = self.get(f"climate_state_{self.key}_setting") + self._attr_preset_mode = self.get("climate_state_climate_keeper_mode") + if self.get("climate_state_bioweapon_mode"): + self._attr_fan_mode = "bioweapon" + else: + self._attr_fan_mode = "off" + self._attr_min_temp = cast( + float, self.get("climate_state_min_avail_temp", DEFAULT_MIN_TEMP) + ) + self._attr_max_temp = cast( + float, self.get("climate_state_max_avail_temp", DEFAULT_MAX_TEMP) + ) + + +class TeslemetryStreamingClimateEntity( + TeslemetryClimateEntity, TeslemetryVehicleStreamEntity, RestoreEntity +): + """Teslemetry steering wheel climate control.""" + + _attr_supported_features = ( + ClimateEntityFeature.TURN_ON + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.PRESET_MODE + ) + + def __init__( + self, + data: TeslemetryVehicleData, + side: TeslemetryClimateSide, + scopes: list[Scope], + ) -> None: + """Initialize the climate.""" + + # Initialize defaults + self._attr_hvac_mode = None + self._attr_current_temperature = None + self._attr_target_temperature = None + self._attr_fan_mode = None + self._attr_preset_mode = None + + self.scoped = Scope.VEHICLE_CMDS in scopes + if not self.scoped: + self._attr_supported_features = ClimateEntityFeature(0) + self.side = side + super().__init__( + data, + side, + ) + + self._attr_min_temp = cast( + float, + data.coordinator.data.get("climate_state_min_avail_temp", DEFAULT_MIN_TEMP), + ) + self._attr_max_temp = cast( + float, + data.coordinator.data.get("climate_state_max_avail_temp", DEFAULT_MAX_TEMP), + ) + self.rhd: bool = data.coordinator.data.get("vehicle_config_rhd", False) + + async def async_added_to_hass(self) -> None: + """Handle entity which will be added.""" + await super().async_added_to_hass() + if (state := await self.async_get_last_state()) is not None: + self._attr_hvac_mode = ( + HVACMode(state.state) if state.state in HVAC_MODES else None + ) + self._attr_current_temperature = state.attributes.get("current_temperature") + self._attr_target_temperature = state.attributes.get("temperature") + self._attr_preset_mode = state.attributes.get("preset_mode") + + self.async_on_remove( + self.vehicle.stream_vehicle.listen_InsideTemp( + self._async_handle_inside_temp + ) + ) + self.async_on_remove( + self.vehicle.stream_vehicle.listen_HvacACEnabled( + self._async_handle_hvac_ac_enabled + ) + ) + self.async_on_remove( + self.vehicle.stream_vehicle.listen_ClimateKeeperMode( + self._async_handle_climate_keeper_mode + ) + ) + self.async_on_remove( + self.vehicle.stream_vehicle.listen_RightHandDrive(self._async_handle_rhd) + ) + + if self.side == TeslemetryClimateSide.DRIVER: + if self.rhd: + self.async_on_remove( + self.vehicle.stream_vehicle.listen_HvacRightTemperatureRequest( + self._async_handle_hvac_temperature_request + ) + ) + else: + self.async_on_remove( + self.vehicle.stream_vehicle.listen_HvacLeftTemperatureRequest( + self._async_handle_hvac_temperature_request + ) + ) + elif self.side == TeslemetryClimateSide.PASSENGER: + if self.rhd: + self.async_on_remove( + self.vehicle.stream_vehicle.listen_HvacLeftTemperatureRequest( + self._async_handle_hvac_temperature_request + ) + ) + else: + self.async_on_remove( + self.vehicle.stream_vehicle.listen_HvacRightTemperatureRequest( + self._async_handle_hvac_temperature_request + ) + ) + + def _async_handle_inside_temp(self, data: float | None): + self._attr_current_temperature = data + self.async_write_ha_state() + + def _async_handle_hvac_ac_enabled(self, data: bool | None): + self._attr_hvac_mode = ( + None if data is None else HVACMode.HEAT_COOL if data else HVACMode.OFF + ) + self.async_write_ha_state() + + def _async_handle_climate_keeper_mode(self, data: str | None): + self._attr_preset_mode = PRESET_MODES.get(data) if data else None + self.async_write_ha_state() + + def _async_handle_hvac_temperature_request(self, data: float | None): + self._attr_target_temperature = data + self.async_write_ha_state() + + def _async_handle_rhd(self, data: bool | None): + if data is not None: + self.rhd = data + COP_MODES = { "Off": HVACMode.OFF, @@ -182,73 +359,27 @@ COP_MODES = { "FanOnly": HVACMode.FAN_ONLY, } -# String to celsius COP_LEVELS = { "Low": 30, "Medium": 35, "High": 40, } -# Celsius to IntEnum -TEMP_LEVELS = { - 30: CabinOverheatProtectionTemp.LOW, - 35: CabinOverheatProtectionTemp.MEDIUM, - 40: CabinOverheatProtectionTemp.HIGH, -} +class TeslemetryCabinOverheatProtectionEntity(TeslemetryRootEntity, ClimateEntity): + """Vehicle Cabin Overheat Protection.""" -class TeslemetryCabinOverheatProtectionEntity(TeslemetryVehicleEntity, ClimateEntity): - """Telemetry vehicle cabin overheat protection entity.""" + api: VehicleSpecific _attr_precision = PRECISION_WHOLE _attr_target_temperature_step = 5 - _attr_min_temp = COP_LEVELS["Low"] - _attr_max_temp = COP_LEVELS["High"] + _attr_min_temp = 30 + _attr_max_temp = 40 _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_hvac_modes = list(COP_MODES.values()) - _attr_entity_registry_enabled_default = False - def __init__( - self, - data: TeslemetryVehicleData, - scopes: Scope, - ) -> None: - """Initialize the climate.""" - - self.scoped = Scope.VEHICLE_CMDS in scopes - if self.scoped: - self._attr_supported_features = ( - ClimateEntityFeature.TURN_ON | ClimateEntityFeature.TURN_OFF - ) - else: - self._attr_supported_features = ClimateEntityFeature(0) - self._attr_hvac_modes = [] - - super().__init__(data, "climate_state_cabin_overheat_protection") - - # Supported Features from data - if self.scoped and self.get("vehicle_config_cop_user_set_temp_supported"): - self._attr_supported_features |= ClimateEntityFeature.TARGET_TEMPERATURE - - def _async_update_attrs(self) -> None: - """Update the attributes of the entity.""" - - if (state := self.get("climate_state_cabin_overheat_protection")) is None: - self._attr_hvac_mode = None - else: - self._attr_hvac_mode = COP_MODES.get(state) - - # If not scoped, prevent the user from changing the HVAC mode by making it the only option - if self._attr_hvac_mode and not self.scoped: - self._attr_hvac_modes = [self._attr_hvac_mode] - - if (level := self.get("climate_state_cop_activation_temperature")) is None: - self._attr_target_temperature = None - else: - self._attr_target_temperature = COP_LEVELS.get(level) - - self._attr_current_temperature = self.get("climate_state_inside_temp") + _enable_turn_on_off_backwards_compatibility = False async def async_turn_on(self) -> None: """Set the climate state to on.""" @@ -260,26 +391,28 @@ class TeslemetryCabinOverheatProtectionEntity(TeslemetryVehicleEntity, ClimateEn async def async_set_temperature(self, **kwargs: Any) -> None: """Set the climate temperature.""" - self.raise_for_scope(Scope.VEHICLE_CMDS) - if (temp := kwargs.get(ATTR_TEMPERATURE)) is None or ( - cop_mode := TEMP_LEVELS.get(temp) - ) is None: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="invalid_cop_temp", - ) + if temp := kwargs.get(ATTR_TEMPERATURE): + if (cop_mode := COP_TEMPERATURES.get(temp)) is None: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_cop_temp", + ) + self.raise_for_scope(Scope.VEHICLE_CMDS) - await self.wake_up_if_asleep() - await handle_vehicle_command(self.api.set_cop_temp(cop_mode)) - self._attr_target_temperature = temp + await handle_vehicle_command(self.api.set_cop_temp(cop_mode)) + self._attr_target_temperature = temp if mode := kwargs.get(ATTR_HVAC_MODE): - await self._async_set_cop(mode) + # Set HVAC mode will call write_ha_state + await self.async_set_hvac_mode(mode) + else: + self.async_write_ha_state() - self.async_write_ha_state() + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set the climate mode and state.""" + self.raise_for_scope(Scope.VEHICLE_CMDS) - async def _async_set_cop(self, hvac_mode: HVACMode) -> None: if hvac_mode == HVACMode.OFF: await handle_vehicle_command( self.api.set_cabin_overheat_protection(on=False, fan_only=False) @@ -294,10 +427,125 @@ class TeslemetryCabinOverheatProtectionEntity(TeslemetryVehicleEntity, ClimateEn ) self._attr_hvac_mode = hvac_mode - - async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: - """Set the climate mode and state.""" - self.raise_for_scope(Scope.VEHICLE_CMDS) - await self.wake_up_if_asleep() - await self._async_set_cop(hvac_mode) + self.async_write_ha_state() + + +class TeslemetryPollingCabinOverheatProtectionEntity( + TeslemetryVehicleEntity, TeslemetryCabinOverheatProtectionEntity +): + """Vehicle Cabin Overheat Protection.""" + + def __init__( + self, + data: TeslemetryVehicleData, + scopes: list[Scope], + ) -> None: + """Initialize the climate.""" + + super().__init__( + data, + "climate_state_cabin_overheat_protection", + ) + + # Supported Features + self._attr_supported_features = ( + ClimateEntityFeature.TURN_ON | ClimateEntityFeature.TURN_OFF + ) + if self.get("vehicle_config_cop_user_set_temp_supported"): + self._attr_supported_features |= ClimateEntityFeature.TARGET_TEMPERATURE + + # Scopes + self.scoped = Scope.VEHICLE_CMDS in scopes + if not self.scoped: + self._attr_supported_features = ClimateEntityFeature(0) + + def _async_update_attrs(self) -> None: + """Update the attributes of the entity.""" + + if (state := self.get("climate_state_cabin_overheat_protection")) is None: + self._attr_hvac_mode = None + else: + self._attr_hvac_mode = COP_MODES.get(state) + + if (level := self.get("climate_state_cop_activation_temperature")) is None: + self._attr_target_temperature = None + else: + self._attr_target_temperature = COP_LEVELS.get(level) + + self._attr_current_temperature = self.get("climate_state_inside_temp") + + +class TeslemetryStreamingCabinOverheatProtectionEntity( + TeslemetryVehicleStreamEntity, + TeslemetryCabinOverheatProtectionEntity, + RestoreEntity, +): + """Vehicle Cabin Overheat Protection.""" + + def __init__( + self, + data: TeslemetryVehicleData, + scopes: list[Scope], + ) -> None: + """Initialize the climate.""" + + # Initialize defaults + self._attr_hvac_mode = None + self._attr_current_temperature = None + self._attr_target_temperature = None + self._attr_fan_mode = None + self._attr_preset_mode = None + + super().__init__(data, "climate_state_cabin_overheat_protection") + + # Supported Features + self._attr_supported_features = ( + ClimateEntityFeature.TURN_ON | ClimateEntityFeature.TURN_OFF + ) + if data.coordinator.data.get("vehicle_config_cop_user_set_temp_supported"): + self._attr_supported_features |= ClimateEntityFeature.TARGET_TEMPERATURE + + # Scopes + self.scoped = Scope.VEHICLE_CMDS in scopes + if not self.scoped: + self._attr_supported_features = ClimateEntityFeature(0) + + async def async_added_to_hass(self) -> None: + """Handle entity which will be added.""" + await super().async_added_to_hass() + if (state := await self.async_get_last_state()) is not None: + self._attr_hvac_mode = ( + HVACMode(state.state) if state.state in HVAC_MODES else None + ) + self._attr_current_temperature = state.attributes.get("temperature") + self._attr_target_temperature = state.attributes.get("target_temperature") + + self.async_on_remove( + self.vehicle.stream_vehicle.listen_InsideTemp( + self._async_handle_inside_temp + ) + ) + self.async_on_remove( + self.vehicle.stream_vehicle.listen_CabinOverheatProtectionMode( + self._async_handle_protection_mode + ) + ) + self.async_on_remove( + self.vehicle.stream_vehicle.listen_CabinOverheatProtectionTemperatureLimit( + self._async_handle_temperature_limit + ) + ) + + def _async_handle_inside_temp(self, value: float | None): + self._attr_current_temperature = value + self.async_write_ha_state() + + def _async_handle_protection_mode(self, value: str | None): + self._attr_hvac_mode = COP_MODES.get(value) if value is not None else None + self.async_write_ha_state() + + def _async_handle_temperature_limit(self, value: str | None): + self._attr_target_temperature = ( + COP_LEVELS.get(value) if value is not None else None + ) self.async_write_ha_state() diff --git a/homeassistant/components/teslemetry/strings.json b/homeassistant/components/teslemetry/strings.json index c1df7d5aa57..ceb8b3c1af9 100644 --- a/homeassistant/components/teslemetry/strings.json +++ b/homeassistant/components/teslemetry/strings.json @@ -226,6 +226,12 @@ "dog": "Dog mode", "camp": "Camp mode" } + }, + "fan_mode": { + "state": { + "off": "[%key:common::state::off%]", + "bioweapon": "Bioweapon defense" + } } } } diff --git a/tests/components/teslemetry/snapshots/test_climate.ambr b/tests/components/teslemetry/snapshots/test_climate.ambr index 4c265c00cb8..e0e68f23c79 100644 --- a/tests/components/teslemetry/snapshots/test_climate.ambr +++ b/tests/components/teslemetry/snapshots/test_climate.ambr @@ -1,10 +1,4 @@ # serializer version: 1 -# name: test_asleep_or_offline[HomeAssistantError] - 'Timed out trying to wake up vehicle' -# --- -# name: test_asleep_or_offline[InvalidCommand] - 'Failed to wake up vehicle: The data request or command is unknown.' -# --- # name: test_climate[climate.test_cabin_overheat_protection-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -78,6 +72,10 @@ }), 'area_id': None, 'capabilities': dict({ + 'fan_modes': list([ + 'off', + 'bioweapon', + ]), 'hvac_modes': list([ , , @@ -113,7 +111,7 @@ 'original_name': 'Climate', 'platform': 'teslemetry', 'previous_unique_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': , 'unique_id': 'LRW3F7EK4NC700000-driver_temp', 'unit_of_measurement': None, @@ -123,6 +121,11 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'current_temperature': 30.0, + 'fan_mode': 'off', + 'fan_modes': list([ + 'off', + 'bioweapon', + ]), 'friendly_name': 'Test Climate', 'hvac_modes': list([ , @@ -137,7 +140,7 @@ 'dog', 'camp', ]), - 'supported_features': , + 'supported_features': , 'temperature': 22.0, }), 'context': , @@ -220,6 +223,10 @@ }), 'area_id': None, 'capabilities': dict({ + 'fan_modes': list([ + 'off', + 'bioweapon', + ]), 'hvac_modes': list([ , , @@ -255,7 +262,7 @@ 'original_name': 'Climate', 'platform': 'teslemetry', 'previous_unique_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': , 'unique_id': 'LRW3F7EK4NC700000-driver_temp', 'unit_of_measurement': None, @@ -265,6 +272,11 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'current_temperature': 30.0, + 'fan_mode': 'off', + 'fan_modes': list([ + 'off', + 'bioweapon', + ]), 'friendly_name': 'Test Climate', 'hvac_modes': list([ , @@ -279,7 +291,7 @@ 'dog', 'camp', ]), - 'supported_features': , + 'supported_features': , 'temperature': 22.0, }), 'context': , @@ -297,7 +309,9 @@ 'area_id': None, 'capabilities': dict({ 'hvac_modes': list([ + , , + , ]), 'max_temp': 40, 'min_temp': 30, @@ -339,6 +353,7 @@ 'capabilities': dict({ 'hvac_modes': list([ , + , ]), 'max_temp': 28.0, 'min_temp': 15.0, @@ -374,3 +389,85 @@ # name: test_invalid_error[error] 'Command returned exception: The data request or command is unknown.' # --- +# name: test_select_streaming[climate.test_cabin_overheat_protection] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': None, + 'friendly_name': 'Test Cabin overheat protection', + 'hvac_modes': list([ + , + , + , + ]), + 'max_temp': 40, + 'min_temp': 30, + 'supported_features': , + 'target_temp_step': 5, + 'temperature': None, + }), + 'context': , + 'entity_id': 'climate.test_cabin_overheat_protection', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'cool', + }) +# --- +# name: test_select_streaming[climate.test_climate LHD] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 26.0, + 'friendly_name': 'Test Climate', + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 28.0, + 'min_temp': 15.0, + 'preset_mode': None, + 'preset_modes': list([ + 'off', + 'keep', + 'dog', + 'camp', + ]), + 'supported_features': , + 'temperature': 21.0, + }), + 'context': , + 'entity_id': 'climate.test_climate', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat_cool', + }) +# --- +# name: test_select_streaming[climate.test_climate] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 26.0, + 'friendly_name': 'Test Climate', + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 28.0, + 'min_temp': 15.0, + 'preset_mode': None, + 'preset_modes': list([ + 'off', + 'keep', + 'dog', + 'camp', + ]), + 'supported_features': , + 'temperature': 21.0, + }), + 'context': , + 'entity_id': 'climate.test_climate', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat_cool', + }) +# --- diff --git a/tests/components/teslemetry/test_climate.py b/tests/components/teslemetry/test_climate.py index 33f2e134806..948fbffa881 100644 --- a/tests/components/teslemetry/test_climate.py +++ b/tests/components/teslemetry/test_climate.py @@ -2,10 +2,10 @@ from unittest.mock import AsyncMock, patch -from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion from tesla_fleet_api.exceptions import InvalidCommand +from teslemetry_stream import Signal from homeassistant.components.climate import ( ATTR_HVAC_MODE, @@ -24,15 +24,12 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import entity_registry as er -from . import assert_entities, setup_platform +from . import assert_entities, reload_platform, setup_platform from .const import ( COMMAND_ERRORS, COMMAND_IGNORED_REASON, METADATA_NOSCOPE, VEHICLE_DATA_ALT, - VEHICLE_DATA_ASLEEP, - WAKE_UP_ASLEEP, - WAKE_UP_ONLINE, ) @@ -41,6 +38,7 @@ async def test_climate( hass: HomeAssistant, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, + mock_legacy: AsyncMock, ) -> None: """Tests that the climate entity is correct.""" @@ -195,6 +193,7 @@ async def test_climate_alt( snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, mock_vehicle_data: AsyncMock, + mock_legacy: AsyncMock, ) -> None: """Tests that the climate entity is correct.""" @@ -269,71 +268,12 @@ async def test_ignored_error( mock_on.assert_called_once() -@pytest.mark.usefixtures("entity_registry_enabled_by_default") -async def test_asleep_or_offline( - hass: HomeAssistant, - mock_vehicle_data: AsyncMock, - mock_wake_up: AsyncMock, - mock_vehicle: AsyncMock, - freezer: FrozenDateTimeFactory, - snapshot: SnapshotAssertion, -) -> None: - """Tests asleep is handled.""" - - mock_vehicle_data.return_value = VEHICLE_DATA_ASLEEP - await setup_platform(hass, [Platform.CLIMATE]) - entity_id = "climate.test_climate" - - # Run a command but fail trying to wake up the vehicle - mock_wake_up.side_effect = InvalidCommand - with pytest.raises(HomeAssistantError) as error: - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_TURN_ON, - {ATTR_ENTITY_ID: [entity_id]}, - blocking=True, - ) - assert str(error.value) == snapshot(name="InvalidCommand") - mock_wake_up.assert_called_once() - - mock_wake_up.side_effect = None - mock_wake_up.reset_mock() - - # Run a command but timeout trying to wake up the vehicle - mock_wake_up.return_value = WAKE_UP_ASLEEP - mock_vehicle.return_value = WAKE_UP_ASLEEP - with ( - patch("homeassistant.components.teslemetry.helpers.asyncio.sleep"), - pytest.raises(HomeAssistantError) as error, - ): - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_TURN_ON, - {ATTR_ENTITY_ID: [entity_id]}, - blocking=True, - ) - assert str(error.value) == snapshot(name="HomeAssistantError") - mock_wake_up.assert_called_once() - mock_vehicle.assert_called() - - mock_wake_up.reset_mock() - mock_vehicle.reset_mock() - mock_wake_up.return_value = WAKE_UP_ONLINE - mock_vehicle.return_value = WAKE_UP_ONLINE - - # Run a command and wake up the vehicle immediately - await hass.services.async_call( - CLIMATE_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: [entity_id]}, blocking=True - ) - await hass.async_block_till_done() - mock_wake_up.assert_called_once() - - async def test_climate_noscope( hass: HomeAssistant, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, mock_metadata: AsyncMock, + mock_legacy: AsyncMock, ) -> None: """Tests that the climate entity is correct.""" mock_metadata.return_value = METADATA_NOSCOPE @@ -363,3 +303,47 @@ async def test_climate_noscope( {ATTR_ENTITY_ID: [entity_id], ATTR_TEMPERATURE: 20}, blocking=True, ) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_select_streaming( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_vehicle_data: AsyncMock, + mock_add_listener: AsyncMock, +) -> None: + """Tests that the select entities with streaming are correct.""" + + entry = await setup_platform(hass, [Platform.CLIMATE]) + + # Stream update + mock_add_listener.send( + { + "vin": VEHICLE_DATA_ALT["response"]["vin"], + "data": { + Signal.INSIDE_TEMP: 26, + Signal.HVAC_AC_ENABLED: True, + Signal.CLIMATE_KEEPER_MODE: "ClimateKeeperModeOn", + Signal.RIGHT_HAND_DRIVE: True, + Signal.HVAC_LEFT_TEMPERATURE_REQUEST: 22, + Signal.HVAC_RIGHT_TEMPERATURE_REQUEST: 21, + Signal.CABIN_OVERHEAT_PROTECTION_MODE: "CabinOverheatProtectionModeStateOn", + Signal.CABIN_OVERHEAT_PROTECTION_TEMPERATURE_LIMIT: 35, + }, + "createdAt": "2024-10-04T10:45:17.537Z", + } + ) + await hass.async_block_till_done() + + assert hass.states.get("climate.test_climate") == snapshot( + name="climate.test_climate LHD" + ) + + await reload_platform(hass, entry, [Platform.CLIMATE]) + + # Assert the entities restored their values + for entity_id in ( + "climate.test_climate", + "climate.test_cabin_overheat_protection", + ): + assert hass.states.get(entity_id) == snapshot(name=entity_id) diff --git a/tests/components/teslemetry/test_init.py b/tests/components/teslemetry/test_init.py index 5481e6cc034..fcf9c76c939 100644 --- a/tests/components/teslemetry/test_init.py +++ b/tests/components/teslemetry/test_init.py @@ -2,17 +2,14 @@ from unittest.mock import AsyncMock -from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion from tesla_fleet_api.exceptions import ( InvalidToken, SubscriptionRequired, TeslaFleetError, - VehicleOffline, ) -from homeassistant.components.teslemetry.coordinator import VEHICLE_INTERVAL from homeassistant.components.teslemetry.models import TeslemetryData from homeassistant.config_entries import ConfigEntryState from homeassistant.const import STATE_OFF, STATE_ON, Platform @@ -22,8 +19,6 @@ from homeassistant.helpers import device_registry as dr from . import setup_platform from .const import VEHICLE_DATA_ALT -from tests.common import async_fire_time_changed - ERRORS = [ (InvalidToken, ConfigEntryState.SETUP_ERROR), (SubscriptionRequired, ConfigEntryState.SETUP_ERROR), @@ -69,22 +64,6 @@ async def test_devices( assert device == snapshot(name=f"{device.identifiers}") -async def test_vehicle_refresh_offline( - hass: HomeAssistant, mock_vehicle_data: AsyncMock, freezer: FrozenDateTimeFactory -) -> None: - """Test coordinator refresh with an error.""" - entry = await setup_platform(hass, [Platform.CLIMATE]) - assert entry.state is ConfigEntryState.LOADED - mock_vehicle_data.assert_called_once() - mock_vehicle_data.reset_mock() - - mock_vehicle_data.side_effect = VehicleOffline - freezer.tick(VEHICLE_INTERVAL) - async_fire_time_changed(hass) - await hass.async_block_till_done() - mock_vehicle_data.assert_called_once() - - @pytest.mark.parametrize(("side_effect", "state"), ERRORS) async def test_vehicle_refresh_error( hass: HomeAssistant, From c29ca4c50ac42db8b52f75c6365e33a60dd4d198 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 25 Mar 2025 21:24:01 +0100 Subject: [PATCH 3036/3148] Add power binary sensor for microwave in SmartThings (#141415) Add power binary sensor for microwave --- .../components/smartthings/binary_sensor.py | 2 +- .../snapshots/test_binary_sensor.ambr | 48 +++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/smartthings/binary_sensor.py b/homeassistant/components/smartthings/binary_sensor.py index ee68db49929..56cdf803a00 100644 --- a/homeassistant/components/smartthings/binary_sensor.py +++ b/homeassistant/components/smartthings/binary_sensor.py @@ -132,7 +132,7 @@ CAPABILITY_TO_SENSORS: dict[ key=Attribute.SWITCH, device_class=BinarySensorDeviceClass.POWER, is_on_key="on", - category={Category.DRYER, Category.WASHER}, + category={Category.DRYER, Category.MICROWAVE, Category.WASHER}, ) }, Capability.TAMPER_ALERT: { diff --git a/tests/components/smartthings/snapshots/test_binary_sensor.ambr b/tests/components/smartthings/snapshots/test_binary_sensor.ambr index 62ecfcfff47..0a0071ff636 100644 --- a/tests/components/smartthings/snapshots/test_binary_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_binary_sensor.ambr @@ -238,6 +238,54 @@ 'state': 'off', }) # --- +# name: test_all_entities[da_ks_microwave_0101x][binary_sensor.microwave_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.microwave_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a.switch', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_microwave_0101x][binary_sensor.microwave_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Microwave Power', + }), + 'context': , + 'entity_id': 'binary_sensor.microwave_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_all_entities[da_ks_microwave_0101x][binary_sensor.microwave_remote_control-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 8dd179c9e07f02b98a833aace187d651062c44c7 Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Tue, 25 Mar 2025 22:24:44 +0200 Subject: [PATCH 3037/3148] Fix Ecoforest spelling of "convector" air flow sensor (#141414) --- homeassistant/components/ecoforest/sensor.py | 2 +- homeassistant/components/ecoforest/strings.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/ecoforest/sensor.py b/homeassistant/components/ecoforest/sensor.py index c1d4aca6f0c..d0e4c17abe1 100644 --- a/homeassistant/components/ecoforest/sensor.py +++ b/homeassistant/components/ecoforest/sensor.py @@ -132,7 +132,7 @@ SENSOR_TYPES: tuple[EcoforestSensorEntityDescription, ...] = ( ), EcoforestSensorEntityDescription( key="convecto_air_flow", - translation_key="convecto_air_flow", + translation_key="convector_air_flow", native_unit_of_measurement=PERCENTAGE, entity_registry_enabled_default=False, value_fn=lambda data: data.convecto_air_flow, diff --git a/homeassistant/components/ecoforest/strings.json b/homeassistant/components/ecoforest/strings.json index 1928acbdbd4..d0e807b5f2a 100644 --- a/homeassistant/components/ecoforest/strings.json +++ b/homeassistant/components/ecoforest/strings.json @@ -78,7 +78,7 @@ "extractor": { "name": "Extractor" }, - "convecto_air_flow": { + "convector_air_flow": { "name": "Convector air flow" } }, From 3a62095af216171145424819ccf7fcd524974b44 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 25 Mar 2025 21:49:38 +0100 Subject: [PATCH 3038/3148] Add power binary sensor for dishwasher in SmartThings (#141417) Add power binary sensor for dishwasher --- .../components/smartthings/binary_sensor.py | 7 ++- .../snapshots/test_binary_sensor.ambr | 48 +++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/smartthings/binary_sensor.py b/homeassistant/components/smartthings/binary_sensor.py index 56cdf803a00..e42a32abdd2 100644 --- a/homeassistant/components/smartthings/binary_sensor.py +++ b/homeassistant/components/smartthings/binary_sensor.py @@ -132,7 +132,12 @@ CAPABILITY_TO_SENSORS: dict[ key=Attribute.SWITCH, device_class=BinarySensorDeviceClass.POWER, is_on_key="on", - category={Category.DRYER, Category.MICROWAVE, Category.WASHER}, + category={ + Category.DISHWASHER, + Category.DRYER, + Category.MICROWAVE, + Category.WASHER, + }, ) }, Capability.TAMPER_ALERT: { diff --git a/tests/components/smartthings/snapshots/test_binary_sensor.ambr b/tests/components/smartthings/snapshots/test_binary_sensor.ambr index 0a0071ff636..47d9bb9586a 100644 --- a/tests/components/smartthings/snapshots/test_binary_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_binary_sensor.ambr @@ -808,6 +808,54 @@ 'state': 'off', }) # --- +# name: test_all_entities[da_wm_dw_000001][binary_sensor.dishwasher_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.dishwasher_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676.switch', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_dw_000001][binary_sensor.dishwasher_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Dishwasher Power', + }), + 'context': , + 'entity_id': 'binary_sensor.dishwasher_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_all_entities[da_wm_dw_000001][binary_sensor.dishwasher_remote_control-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From cec21b55077d9fbfbe34b1862cce24f68a21d0b5 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 25 Mar 2025 22:03:32 +0100 Subject: [PATCH 3039/3148] Capitalize "Bluetooth" in `motionblinds_ble` user strings (#141419) --- homeassistant/components/motionblinds_ble/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/motionblinds_ble/strings.json b/homeassistant/components/motionblinds_ble/strings.json index d6532f12386..ec1fb080854 100644 --- a/homeassistant/components/motionblinds_ble/strings.json +++ b/homeassistant/components/motionblinds_ble/strings.json @@ -1,8 +1,8 @@ { "config": { "abort": { - "no_bluetooth_adapter": "No bluetooth adapter found", - "no_devices_found": "Could not find any bluetooth devices" + "no_bluetooth_adapter": "No Bluetooth adapter found", + "no_devices_found": "Could not find any Bluetooth devices" }, "error": { "could_not_find_motor": "Could not find a motor with that MAC code", From 56a8c74e872c90e17d787e85ba93c86ddd3746e8 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 25 Mar 2025 22:05:24 +0100 Subject: [PATCH 3040/3148] Capitalize "Bluetooth proxy" in `private_ble_device` integration (#141418) --- homeassistant/components/private_ble_device/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/private_ble_device/strings.json b/homeassistant/components/private_ble_device/strings.json index c35775a4843..845a5d92bae 100644 --- a/homeassistant/components/private_ble_device/strings.json +++ b/homeassistant/components/private_ble_device/strings.json @@ -14,7 +14,7 @@ "irk_not_valid": "The key does not look like a valid IRK." }, "abort": { - "bluetooth_not_available": "At least one Bluetooth adapter or remote bluetooth proxy must be configured to track Private BLE Devices." + "bluetooth_not_available": "At least one Bluetooth adapter or remote Bluetooth proxy must be configured to track Private BLE Devices." } }, "entity": { From f3bcb96b4109f69e5c07183227da06c4f5bb3d21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Tue, 25 Mar 2025 22:06:38 +0100 Subject: [PATCH 3041/3148] Tiny Home Connect tweaks (#141403) --- .../components/home_connect/coordinator.py | 16 ++-- .../components/home_connect/number.py | 2 +- .../components/home_connect/sensor.py | 82 +++++++++---------- .../components/home_connect/strings.json | 80 +++++++++--------- homeassistant/components/home_connect/time.py | 2 +- 5 files changed, 91 insertions(+), 91 deletions(-) diff --git a/homeassistant/components/home_connect/coordinator.py b/homeassistant/components/home_connect/coordinator.py index 495b4efab32..079db6b148e 100644 --- a/homeassistant/components/home_connect/coordinator.py +++ b/homeassistant/components/home_connect/coordinator.py @@ -155,7 +155,7 @@ class HomeConnectCoordinator( f"home_connect-events_listener_task-{self.config_entry.entry_id}", ) - async def _event_listener(self) -> None: # noqa: C901 + async def _event_listener(self) -> None: """Match event with listener for event type.""" retry_time = 10 while True: @@ -279,13 +279,6 @@ class HomeConnectCoordinator( ) break - # Trigger to delete the possible depaired device entities - # from known_entities variable at common.py - for listener, context in self._special_listeners.values(): - assert isinstance(context, tuple) - if EventKey.BSH_COMMON_APPLIANCE_DEPAIRED in context: - listener() - @callback def _call_event_listener(self, event_message: EventMessage) -> None: """Call listener for event.""" @@ -389,6 +382,13 @@ class HomeConnectCoordinator( remove_config_entry_id=self.config_entry.entry_id, ) + # Trigger to delete the possible depaired device entities + # from known_entities variable at common.py + for listener, context in self._special_listeners.values(): + assert isinstance(context, tuple) + if EventKey.BSH_COMMON_APPLIANCE_DEPAIRED in context: + listener() + async def _get_appliance_data( self, appliance: HomeAppliance, diff --git a/homeassistant/components/home_connect/number.py b/homeassistant/components/home_connect/number.py index 99fe6c17296..f525a360fa4 100644 --- a/homeassistant/components/home_connect/number.py +++ b/homeassistant/components/home_connect/number.py @@ -1,4 +1,4 @@ -"""Provides number enties for Home Connect.""" +"""Provides number entities for Home Connect.""" import logging from typing import cast diff --git a/homeassistant/components/home_connect/sensor.py b/homeassistant/components/home_connect/sensor.py index 632a4260f3c..f3c73c8a5ff 100644 --- a/homeassistant/components/home_connect/sensor.py +++ b/homeassistant/components/home_connect/sensor.py @@ -53,7 +53,7 @@ BSH_PROGRAM_SENSORS = ( device_class=SensorDeviceClass.TIMESTAMP, translation_key="program_finish_time", appliance_types=( - "CoffeMaker", + "CoffeeMaker", "CookProcessor", "Dishwasher", "Dryer", @@ -194,30 +194,6 @@ SENSORS = ( ) EVENT_SENSORS = ( - HomeConnectSensorEntityDescription( - key=EventKey.REFRIGERATION_FRIDGE_FREEZER_EVENT_DOOR_ALARM_FREEZER, - device_class=SensorDeviceClass.ENUM, - options=EVENT_OPTIONS, - default_value="off", - translation_key="freezer_door_alarm", - appliance_types=("FridgeFreezer", "Freezer"), - ), - HomeConnectSensorEntityDescription( - key=EventKey.REFRIGERATION_FRIDGE_FREEZER_EVENT_DOOR_ALARM_REFRIGERATOR, - device_class=SensorDeviceClass.ENUM, - options=EVENT_OPTIONS, - default_value="off", - translation_key="refrigerator_door_alarm", - appliance_types=("FridgeFreezer", "Refrigerator"), - ), - HomeConnectSensorEntityDescription( - key=EventKey.REFRIGERATION_FRIDGE_FREEZER_EVENT_TEMPERATURE_ALARM_FREEZER, - device_class=SensorDeviceClass.ENUM, - options=EVENT_OPTIONS, - default_value="off", - translation_key="freezer_temperature_alarm", - appliance_types=("FridgeFreezer", "Freezer"), - ), HomeConnectSensorEntityDescription( key=EventKey.BSH_COMMON_EVENT_PROGRAM_ABORTED, device_class=SensorDeviceClass.ENUM, @@ -274,6 +250,22 @@ EVENT_SENSORS = ( translation_key="drying_process_finished", appliance_types=("Dryer",), ), + HomeConnectSensorEntityDescription( + key=EventKey.DISHCARE_DISHWASHER_EVENT_SALT_NEARLY_EMPTY, + device_class=SensorDeviceClass.ENUM, + options=EVENT_OPTIONS, + default_value="off", + translation_key="salt_nearly_empty", + appliance_types=("Dishwasher",), + ), + HomeConnectSensorEntityDescription( + key=EventKey.DISHCARE_DISHWASHER_EVENT_RINSE_AID_NEARLY_EMPTY, + device_class=SensorDeviceClass.ENUM, + options=EVENT_OPTIONS, + default_value="off", + translation_key="rinse_aid_nearly_empty", + appliance_types=("Dishwasher",), + ), HomeConnectSensorEntityDescription( key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_BEAN_CONTAINER_EMPTY, device_class=SensorDeviceClass.ENUM, @@ -434,6 +426,30 @@ EVENT_SENSORS = ( translation_key="device_calc_n_clean_blockage", appliance_types=("CoffeeMaker",), ), + HomeConnectSensorEntityDescription( + key=EventKey.REFRIGERATION_FRIDGE_FREEZER_EVENT_DOOR_ALARM_FREEZER, + device_class=SensorDeviceClass.ENUM, + options=EVENT_OPTIONS, + default_value="off", + translation_key="freezer_door_alarm", + appliance_types=("FridgeFreezer", "Freezer"), + ), + HomeConnectSensorEntityDescription( + key=EventKey.REFRIGERATION_FRIDGE_FREEZER_EVENT_DOOR_ALARM_REFRIGERATOR, + device_class=SensorDeviceClass.ENUM, + options=EVENT_OPTIONS, + default_value="off", + translation_key="refrigerator_door_alarm", + appliance_types=("FridgeFreezer", "Refrigerator"), + ), + HomeConnectSensorEntityDescription( + key=EventKey.REFRIGERATION_FRIDGE_FREEZER_EVENT_TEMPERATURE_ALARM_FREEZER, + device_class=SensorDeviceClass.ENUM, + options=EVENT_OPTIONS, + default_value="off", + translation_key="freezer_temperature_alarm", + appliance_types=("FridgeFreezer", "Freezer"), + ), HomeConnectSensorEntityDescription( key=EventKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_EVENT_EMPTY_DUST_BOX_AND_CLEAN_FILTER, device_class=SensorDeviceClass.ENUM, @@ -490,22 +506,6 @@ EVENT_SENSORS = ( translation_key="grease_filter_max_saturation_reached", appliance_types=("Hood",), ), - HomeConnectSensorEntityDescription( - key=EventKey.DISHCARE_DISHWASHER_EVENT_SALT_NEARLY_EMPTY, - device_class=SensorDeviceClass.ENUM, - options=EVENT_OPTIONS, - default_value="off", - translation_key="salt_nearly_empty", - appliance_types=("Dishwasher",), - ), - HomeConnectSensorEntityDescription( - key=EventKey.DISHCARE_DISHWASHER_EVENT_RINSE_AID_NEARLY_EMPTY, - device_class=SensorDeviceClass.ENUM, - options=EVENT_OPTIONS, - default_value="off", - translation_key="rinse_aid_nearly_empty", - appliance_types=("Dishwasher",), - ), ) diff --git a/homeassistant/components/home_connect/strings.json b/homeassistant/components/home_connect/strings.json index 1d7c1c009b1..2a7e4c5e718 100644 --- a/homeassistant/components/home_connect/strings.json +++ b/homeassistant/components/home_connect/strings.json @@ -1545,30 +1545,6 @@ "oven_current_cavity_temperature": { "name": "Current oven cavity temperature" }, - "freezer_door_alarm": { - "name": "Freezer door alarm", - "state": { - "off": "[%key:common::state::off%]", - "confirmed": "[%key:component::home_connect::common::confirmed%]", - "present": "[%key:component::home_connect::common::present%]" - } - }, - "refrigerator_door_alarm": { - "name": "Refrigerator door alarm", - "state": { - "off": "[%key:common::state::off%]", - "confirmed": "[%key:component::home_connect::common::confirmed%]", - "present": "[%key:component::home_connect::common::present%]" - } - }, - "freezer_temperature_alarm": { - "name": "Freezer temperature alarm", - "state": { - "off": "[%key:common::state::off%]", - "confirmed": "[%key:component::home_connect::common::confirmed%]", - "present": "[%key:component::home_connect::common::present%]" - } - }, "program_aborted": { "name": "Program aborted", "state": { @@ -1617,6 +1593,22 @@ "present": "[%key:component::home_connect::common::present%]" } }, + "salt_nearly_empty": { + "name": "Salt nearly empty", + "state": { + "off": "[%key:common::state::off%]", + "confirmed": "[%key:component::home_connect::common::confirmed%]", + "present": "[%key:component::home_connect::common::present%]" + } + }, + "rinse_aid_nearly_empty": { + "name": "Rinse aid nearly empty", + "state": { + "off": "[%key:common::state::off%]", + "confirmed": "[%key:component::home_connect::common::confirmed%]", + "present": "[%key:component::home_connect::common::present%]" + } + }, "bean_container_empty": { "name": "Bean container empty", "state": { @@ -1777,6 +1769,30 @@ "present": "[%key:component::home_connect::common::present%]" } }, + "freezer_door_alarm": { + "name": "Freezer door alarm", + "state": { + "off": "[%key:common::state::off%]", + "confirmed": "[%key:component::home_connect::common::confirmed%]", + "present": "[%key:component::home_connect::common::present%]" + } + }, + "refrigerator_door_alarm": { + "name": "Refrigerator door alarm", + "state": { + "off": "[%key:common::state::off%]", + "confirmed": "[%key:component::home_connect::common::confirmed%]", + "present": "[%key:component::home_connect::common::present%]" + } + }, + "freezer_temperature_alarm": { + "name": "Freezer temperature alarm", + "state": { + "off": "[%key:common::state::off%]", + "confirmed": "[%key:component::home_connect::common::confirmed%]", + "present": "[%key:component::home_connect::common::present%]" + } + }, "empty_dust_box_and_clean_filter": { "name": "Empty dust box and clean filter", "state": { @@ -1832,22 +1848,6 @@ "confirmed": "[%key:component::home_connect::common::confirmed%]", "present": "[%key:component::home_connect::common::present%]" } - }, - "salt_nearly_empty": { - "name": "Salt nearly empty", - "state": { - "off": "[%key:common::state::off%]", - "confirmed": "[%key:component::home_connect::common::confirmed%]", - "present": "[%key:component::home_connect::common::present%]" - } - }, - "rinse_aid_nearly_empty": { - "name": "Rinse aid nearly empty", - "state": { - "off": "[%key:common::state::off%]", - "confirmed": "[%key:component::home_connect::common::confirmed%]", - "present": "[%key:component::home_connect::common::present%]" - } } }, "switch": { diff --git a/homeassistant/components/home_connect/time.py b/homeassistant/components/home_connect/time.py index 7cfa0a7d3e4..d0272f77556 100644 --- a/homeassistant/components/home_connect/time.py +++ b/homeassistant/components/home_connect/time.py @@ -1,4 +1,4 @@ -"""Provides time enties for Home Connect.""" +"""Provides time entities for Home Connect.""" from datetime import time from typing import cast From ab709aeb46273715c3c2f193577af5d7344b305b Mon Sep 17 00:00:00 2001 From: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> Date: Tue, 25 Mar 2025 16:55:44 -0500 Subject: [PATCH 3042/3148] Add Get Queue HEOS entity service (#141150) --- homeassistant/components/heos/const.py | 1 + homeassistant/components/heos/icons.json | 3 ++ homeassistant/components/heos/media_player.py | 35 ++++++++++++++----- homeassistant/components/heos/services.yaml | 6 ++++ homeassistant/components/heos/strings.json | 4 +++ tests/components/heos/__init__.py | 1 + tests/components/heos/conftest.py | 26 ++++++++++++++ .../heos/snapshots/test_media_player.ambr | 26 ++++++++++++++ tests/components/heos/test_media_player.py | 26 ++++++++++++++ 9 files changed, 120 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/heos/const.py b/homeassistant/components/heos/const.py index 6d603f7ad30..789fbc12b8e 100644 --- a/homeassistant/components/heos/const.py +++ b/homeassistant/components/heos/const.py @@ -4,6 +4,7 @@ ATTR_PASSWORD = "password" ATTR_USERNAME = "username" DOMAIN = "heos" ENTRY_TITLE = "HEOS System" +SERVICE_GET_QUEUE = "get_queue" SERVICE_GROUP_VOLUME_SET = "group_volume_set" SERVICE_GROUP_VOLUME_DOWN = "group_volume_down" SERVICE_GROUP_VOLUME_UP = "group_volume_up" diff --git a/homeassistant/components/heos/icons.json b/homeassistant/components/heos/icons.json index d7a998b6aec..c957ac1939c 100644 --- a/homeassistant/components/heos/icons.json +++ b/homeassistant/components/heos/icons.json @@ -1,5 +1,8 @@ { "services": { + "get_queue": { + "service": "mdi:playlist-music" + }, "group_volume_set": { "service": "mdi:volume-medium" }, diff --git a/homeassistant/components/heos/media_player.py b/homeassistant/components/heos/media_player.py index 311190ccb74..9cd01051b95 100644 --- a/homeassistant/components/heos/media_player.py +++ b/homeassistant/components/heos/media_player.py @@ -4,6 +4,7 @@ from __future__ import annotations from collections.abc import Awaitable, Callable, Coroutine, Sequence from contextlib import suppress +import dataclasses from datetime import datetime from functools import reduce, wraps import logging @@ -42,7 +43,12 @@ from homeassistant.components.media_player import ( ) from homeassistant.components.media_source import BrowseMediaSource from homeassistant.const import Platform -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import ( + HomeAssistant, + ServiceResponse, + SupportsResponse, + callback, +) from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import ( config_validation as cv, @@ -56,6 +62,7 @@ from homeassistant.util.dt import utcnow from .const import ( DOMAIN as HEOS_DOMAIN, + SERVICE_GET_QUEUE, SERVICE_GROUP_VOLUME_DOWN, SERVICE_GROUP_VOLUME_SET, SERVICE_GROUP_VOLUME_UP, @@ -132,6 +139,12 @@ async def async_setup_entry( """Add media players for a config entry.""" # Register custom entity services platform = entity_platform.async_get_current_platform() + platform.async_register_entity_service( + SERVICE_GET_QUEUE, + None, + "async_get_queue", + supports_response=SupportsResponse.ONLY, + ) platform.async_register_entity_service( SERVICE_GROUP_VOLUME_SET, {vol.Required(ATTR_MEDIA_VOLUME_LEVEL): cv.small_float}, @@ -155,20 +168,20 @@ async def async_setup_entry( add_entities_callback(list(coordinator.heos.players.values())) -type _FuncType[**_P] = Callable[_P, Awaitable[Any]] -type _ReturnFuncType[**_P] = Callable[_P, Coroutine[Any, Any, None]] +type _FuncType[**_P, _R] = Callable[_P, Awaitable[_R]] +type _ReturnFuncType[**_P, _R] = Callable[_P, Coroutine[Any, Any, _R]] -def catch_action_error[**_P]( +def catch_action_error[**_P, _R]( action: str, -) -> Callable[[_FuncType[_P]], _ReturnFuncType[_P]]: +) -> Callable[[_FuncType[_P, _R]], _ReturnFuncType[_P, _R]]: """Return decorator that catches errors and raises HomeAssistantError.""" - def decorator(func: _FuncType[_P]) -> _ReturnFuncType[_P]: + def decorator(func: _FuncType[_P, _R]) -> _ReturnFuncType[_P, _R]: @wraps(func) - async def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> None: + async def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> _R: try: - await func(*args, **kwargs) + return await func(*args, **kwargs) except (HeosError, ValueError) as ex: raise HomeAssistantError( translation_domain=HEOS_DOMAIN, @@ -268,6 +281,12 @@ class HeosMediaPlayer(CoordinatorEntity[HeosCoordinator], MediaPlayerEntity): self.async_on_remove(self._player.add_on_player_event(self._player_update)) await super().async_added_to_hass() + @catch_action_error("get queue") + async def async_get_queue(self) -> ServiceResponse: + """Get the queue for the current player.""" + queue = await self._player.get_queue() + return {"queue": [dataclasses.asdict(item) for item in queue]} + @catch_action_error("clear playlist") async def async_clear_playlist(self) -> None: """Clear players playlist.""" diff --git a/homeassistant/components/heos/services.yaml b/homeassistant/components/heos/services.yaml index 8f3a43421f6..fa79bd03096 100644 --- a/homeassistant/components/heos/services.yaml +++ b/homeassistant/components/heos/services.yaml @@ -1,3 +1,9 @@ +get_queue: + target: + entity: + integration: heos + domain: media_player + group_volume_set: target: entity: diff --git a/homeassistant/components/heos/strings.json b/homeassistant/components/heos/strings.json index 593c437accc..38e3349b7c0 100644 --- a/homeassistant/components/heos/strings.json +++ b/homeassistant/components/heos/strings.json @@ -86,6 +86,10 @@ } } }, + "get_queue": { + "name": "Get queue", + "description": "Retrieves the queue of the media player." + }, "group_volume_down": { "name": "Turn down group volume", "description": "Turns down the group volume." diff --git a/tests/components/heos/__init__.py b/tests/components/heos/__init__.py index cb4313bbd10..34eba8a9c76 100644 --- a/tests/components/heos/__init__.py +++ b/tests/components/heos/__init__.py @@ -37,6 +37,7 @@ class MockHeos(Heos): self.play_preset_station: AsyncMock = AsyncMock() self.play_url: AsyncMock = AsyncMock() self.player_clear_queue: AsyncMock = AsyncMock() + self.player_get_queue: AsyncMock = AsyncMock() self.player_get_quick_selects: AsyncMock = AsyncMock() self.player_play_next: AsyncMock = AsyncMock() self.player_play_previous: AsyncMock = AsyncMock() diff --git a/tests/components/heos/conftest.py b/tests/components/heos/conftest.py index 5d06d1812ea..835e4436398 100644 --- a/tests/components/heos/conftest.py +++ b/tests/components/heos/conftest.py @@ -20,6 +20,7 @@ from pyheos import ( NetworkType, PlayerUpdateResult, PlayState, + QueueItem, RepeatType, const, ) @@ -359,3 +360,28 @@ def change_data_fixture() -> PlayerUpdateResult: def change_data_mapped_ids_fixture() -> PlayerUpdateResult: """Create player change data for testing.""" return PlayerUpdateResult(updated_player_ids={1: 101}) + + +@pytest.fixture(name="queue") +def queue_fixture() -> list[QueueItem]: + """Create a queue fixture.""" + return [ + QueueItem( + queue_id=1, + song="Espresso", + album="Espresso", + artist="Sabrina Carpenter", + image_url="http://resources.wimpmusic.com/images/e4f2d75f/a69e/4b8a/b800/e18546b1ad4c/640x640.jpg", + media_id="356276483", + album_id="356276481", + ), + QueueItem( + queue_id=2, + song="A Bar Song (Tipsy)", + album="A Bar Song (Tipsy)", + artist="Shaboozey", + image_url="http://resources.wimpmusic.com/images/d05b8da3/4fae/45ff/ac1b/7ab7caab3523/640x640.jpg", + media_id="354365598", + album_id="354365596", + ), + ] diff --git a/tests/components/heos/snapshots/test_media_player.ambr b/tests/components/heos/snapshots/test_media_player.ambr index 4cf84363ba0..d366a7f6317 100644 --- a/tests/components/heos/snapshots/test_media_player.ambr +++ b/tests/components/heos/snapshots/test_media_player.ambr @@ -159,6 +159,32 @@ 'title': 'Music Sources', }) # --- +# name: test_get_queue + dict({ + 'media_player.test_player': dict({ + 'queue': list([ + dict({ + 'album': 'Espresso', + 'album_id': '356276481', + 'artist': 'Sabrina Carpenter', + 'image_url': 'http://resources.wimpmusic.com/images/e4f2d75f/a69e/4b8a/b800/e18546b1ad4c/640x640.jpg', + 'media_id': '356276483', + 'queue_id': 1, + 'song': 'Espresso', + }), + dict({ + 'album': 'A Bar Song (Tipsy)', + 'album_id': '354365596', + 'artist': 'Shaboozey', + 'image_url': 'http://resources.wimpmusic.com/images/d05b8da3/4fae/45ff/ac1b/7ab7caab3523/640x640.jpg', + 'media_id': '354365598', + 'queue_id': 2, + 'song': 'A Bar Song (Tipsy)', + }), + ]), + }), + }) +# --- # name: test_state_attributes StateSnapshot({ 'attributes': ReadOnlyDict({ diff --git a/tests/components/heos/test_media_player.py b/tests/components/heos/test_media_player.py index d5bc8cab488..474d606b5b1 100644 --- a/tests/components/heos/test_media_player.py +++ b/tests/components/heos/test_media_player.py @@ -15,6 +15,7 @@ from pyheos import ( MediaType as HeosMediaType, PlayerUpdateResult, PlayState, + QueueItem, RepeatType, SignalHeosEvent, SignalType, @@ -27,6 +28,7 @@ from syrupy.filters import props from homeassistant.components.heos.const import ( DOMAIN, + SERVICE_GET_QUEUE, SERVICE_GROUP_VOLUME_DOWN, SERVICE_GROUP_VOLUME_SET, SERVICE_GROUP_VOLUME_UP, @@ -1696,3 +1698,27 @@ async def test_media_player_group_fails_wrong_integration( blocking=True, ) controller.set_group.assert_not_called() + + +async def test_get_queue( + hass: HomeAssistant, + config_entry: MockConfigEntry, + controller: MockHeos, + queue: list[QueueItem], + snapshot: SnapshotAssertion, +) -> None: + """Test the get queue service.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + controller.player_get_queue.return_value = queue + response = await hass.services.async_call( + DOMAIN, + SERVICE_GET_QUEUE, + { + ATTR_ENTITY_ID: "media_player.test_player", + }, + blocking=True, + return_response=True, + ) + controller.player_get_queue.assert_called_once_with(1, None, None) + assert response == snapshot From 25a36c1588034e3ce0ebe4fa73752a657262cacd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexey=20ALERT=20Rubash=D1=91ff?= Date: Wed, 26 Mar 2025 00:05:14 +0200 Subject: [PATCH 3043/3148] Add AtlanticDomesticHotWaterProductionV2IOComponent to Overkiz (#139524) --- .../overkiz/water_heater/__init__.py | 4 + ...ic_hot_water_production_v2_io_component.py | 332 ++++++++++++++++++ 2 files changed, 336 insertions(+) create mode 100644 homeassistant/components/overkiz/water_heater/atlantic_domestic_hot_water_production_v2_io_component.py diff --git a/homeassistant/components/overkiz/water_heater/__init__.py b/homeassistant/components/overkiz/water_heater/__init__.py index 9895ea84c2c..2960cefe10c 100644 --- a/homeassistant/components/overkiz/water_heater/__init__.py +++ b/homeassistant/components/overkiz/water_heater/__init__.py @@ -13,6 +13,9 @@ from ..entity import OverkizEntity from .atlantic_domestic_hot_water_production_mlb_component import ( AtlanticDomesticHotWaterProductionMBLComponent, ) +from .atlantic_domestic_hot_water_production_v2_io_component import ( + AtlanticDomesticHotWaterProductionV2IOComponent, +) from .atlantic_pass_apc_dhw import AtlanticPassAPCDHW from .domestic_hot_water_production import DomesticHotWaterProduction from .hitachi_dhw import HitachiDHW @@ -52,4 +55,5 @@ WIDGET_TO_WATER_HEATER_ENTITY = { CONTROLLABLE_NAME_TO_WATER_HEATER_ENTITY = { "modbuslink:AtlanticDomesticHotWaterProductionMBLComponent": AtlanticDomesticHotWaterProductionMBLComponent, + "io:AtlanticDomesticHotWaterProductionV2_CV4E_IOComponent": AtlanticDomesticHotWaterProductionV2IOComponent, } diff --git a/homeassistant/components/overkiz/water_heater/atlantic_domestic_hot_water_production_v2_io_component.py b/homeassistant/components/overkiz/water_heater/atlantic_domestic_hot_water_production_v2_io_component.py new file mode 100644 index 00000000000..7e7db07f847 --- /dev/null +++ b/homeassistant/components/overkiz/water_heater/atlantic_domestic_hot_water_production_v2_io_component.py @@ -0,0 +1,332 @@ +"""Support for AtlanticDomesticHotWaterProductionV2IOComponent.""" + +from typing import Any, cast + +from pyoverkiz.enums import OverkizCommand, OverkizCommandParam, OverkizState + +from homeassistant.components.water_heater import ( + STATE_ECO, + STATE_ELECTRIC, + STATE_HEAT_PUMP, + STATE_PERFORMANCE, + WaterHeaterEntity, + WaterHeaterEntityFeature, +) +from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature + +from ..entity import OverkizEntity + +DEFAULT_MIN_TEMP: float = 50.0 +DEFAULT_MAX_TEMP: float = 62.0 +MAX_BOOST_MODE_DURATION: int = 7 + +DHWP_AWAY_MODES = [ + OverkizCommandParam.ABSENCE, + OverkizCommandParam.AWAY, + OverkizCommandParam.FROSTPROTECTION, +] + + +class AtlanticDomesticHotWaterProductionV2IOComponent(OverkizEntity, WaterHeaterEntity): + """Representation of AtlanticDomesticHotWaterProductionV2IOComponent (io).""" + + _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_supported_features = ( + WaterHeaterEntityFeature.TARGET_TEMPERATURE + | WaterHeaterEntityFeature.OPERATION_MODE + | WaterHeaterEntityFeature.AWAY_MODE + | WaterHeaterEntityFeature.ON_OFF + ) + _attr_operation_list = [ + STATE_ECO, + STATE_PERFORMANCE, + STATE_HEAT_PUMP, + STATE_ELECTRIC, + ] + + @property + def min_temp(self) -> float: + """Return the minimum temperature.""" + + min_temp = self.device.states[OverkizState.CORE_MINIMAL_TEMPERATURE_MANUAL_MODE] + if min_temp: + return cast(float, min_temp.value_as_float) + return DEFAULT_MIN_TEMP + + @property + def max_temp(self) -> float: + """Return the maximum temperature.""" + + max_temp = self.device.states[OverkizState.CORE_MAXIMAL_TEMPERATURE_MANUAL_MODE] + if max_temp: + return cast(float, max_temp.value_as_float) + return DEFAULT_MAX_TEMP + + @property + def current_temperature(self) -> float: + """Return the current temperature.""" + + return cast( + float, + self.executor.select_state( + OverkizState.IO_MIDDLE_WATER_TEMPERATURE, + ), + ) + + @property + def target_temperature(self) -> float: + """Return the temperature corresponding to the PRESET.""" + + return cast( + float, + self.executor.select_state(OverkizState.CORE_TARGET_TEMPERATURE), + ) + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new temperature.""" + + temperature = kwargs.get(ATTR_TEMPERATURE) + await self.executor.async_execute_command( + OverkizCommand.SET_TARGET_TEMPERATURE, temperature, refresh_afterwards=False + ) + await self.executor.async_execute_command( + OverkizCommand.REFRESH_TARGET_TEMPERATURE, refresh_afterwards=False + ) + await self.coordinator.async_refresh() + + @property + def is_state_eco(self) -> bool: + """Return true if eco mode is on.""" + + return ( + self.executor.select_state(OverkizState.IO_DHW_MODE) + == OverkizCommandParam.MANUAL_ECO_ACTIVE + ) + + @property + def is_state_performance(self) -> bool: + """Return true if performance mode is on.""" + + return ( + self.executor.select_state(OverkizState.IO_DHW_MODE) + == OverkizCommandParam.AUTO_MODE + ) + + @property + def is_state_heat_pump(self) -> bool: + """Return true if heat pump mode is on.""" + + return ( + self.executor.select_state(OverkizState.IO_DHW_MODE) + == OverkizCommandParam.MANUAL_ECO_INACTIVE + ) + + @property + def is_away_mode_on(self) -> bool: + """Return true if away mode is on.""" + + away_mode_duration = cast( + str, self.executor.select_state(OverkizState.IO_AWAY_MODE_DURATION) + ) + # away_mode_duration can be either a Literal["always"] + if away_mode_duration == OverkizCommandParam.ALWAYS: + return True + + # Or an int of 0 to 7 days. But it still is a string. + if away_mode_duration.isdecimal() and int(away_mode_duration) > 0: + return True + + return False + + @property + def current_operation(self) -> str | None: + """Return current operation.""" + + # The Away Mode leaves the current operation unchanged + if self.is_boost_mode_on: + return STATE_ELECTRIC + + if self.is_state_eco: + return STATE_ECO + + if self.is_state_performance: + return STATE_PERFORMANCE + + if self.is_state_heat_pump: + return STATE_HEAT_PUMP + + return None + + @property + def is_boost_mode_on(self) -> bool: + """Return true if boost mode is on.""" + + return ( + cast( + int, + self.executor.select_state(OverkizState.CORE_BOOST_MODE_DURATION), + ) + > 0 + ) + + async def async_set_operation_mode(self, operation_mode: str) -> None: + """Set new operation mode.""" + + if operation_mode == STATE_ECO: + if self.is_boost_mode_on: + await self.async_turn_boost_mode_off(refresh_afterwards=False) + + if self.is_away_mode_on: + await self.async_turn_away_mode_off(refresh_afterwards=False) + + await self.executor.async_execute_command( + OverkizCommand.SET_DHW_MODE, + OverkizCommandParam.MANUAL_ECO_ACTIVE, + refresh_afterwards=False, + ) + # ECO changes the target temperature so we have to refresh it + await self.executor.async_execute_command( + OverkizCommand.REFRESH_TARGET_TEMPERATURE, refresh_afterwards=False + ) + await self.coordinator.async_refresh() + + elif operation_mode == STATE_PERFORMANCE: + if self.is_boost_mode_on: + await self.async_turn_boost_mode_off(refresh_afterwards=False) + if self.is_away_mode_on: + await self.async_turn_away_mode_off(refresh_afterwards=False) + + await self.executor.async_execute_command( + OverkizCommand.SET_DHW_MODE, + OverkizCommandParam.AUTO_MODE, + refresh_afterwards=False, + ) + + await self.coordinator.async_refresh() + + elif operation_mode == STATE_HEAT_PUMP: + refresh_target_temp = False + if self.is_state_performance: + # Switching from STATE_PERFORMANCE to STATE_HEAT_PUMP + # changes the target temperature and requires a target temperature refresh + refresh_target_temp = True + + if self.is_boost_mode_on: + await self.async_turn_boost_mode_off(refresh_afterwards=False) + if self.is_away_mode_on: + await self.async_turn_away_mode_off(refresh_afterwards=False) + + await self.executor.async_execute_command( + OverkizCommand.SET_DHW_MODE, + OverkizCommandParam.MANUAL_ECO_INACTIVE, + refresh_afterwards=False, + ) + + if refresh_target_temp: + await self.executor.async_execute_command( + OverkizCommand.REFRESH_TARGET_TEMPERATURE, + refresh_afterwards=False, + ) + + await self.coordinator.async_refresh() + + elif operation_mode == STATE_ELECTRIC: + if self.is_away_mode_on: + await self.async_turn_away_mode_off(refresh_afterwards=False) + if not self.is_boost_mode_on: + await self.async_turn_boost_mode_on(refresh_afterwards=False) + await self.coordinator.async_refresh() + + async def async_turn_away_mode_on(self, refresh_afterwards: bool = True) -> None: + """Turn away mode on.""" + + await self.executor.async_execute_command( + OverkizCommand.SET_CURRENT_OPERATING_MODE, + { + OverkizCommandParam.RELAUNCH: OverkizCommandParam.OFF, + OverkizCommandParam.ABSENCE: OverkizCommandParam.ON, + }, + refresh_afterwards=False, + ) + # Toggling the AWAY mode changes away mode duration so we have to refresh it + await self.executor.async_execute_command( + OverkizCommand.REFRESH_AWAY_MODE_DURATION, + refresh_afterwards=False, + ) + if refresh_afterwards: + await self.coordinator.async_refresh() + + async def async_turn_away_mode_off(self, refresh_afterwards: bool = True) -> None: + """Turn away mode off.""" + + await self.executor.async_execute_command( + OverkizCommand.SET_CURRENT_OPERATING_MODE, + { + OverkizCommandParam.RELAUNCH: OverkizCommandParam.OFF, + OverkizCommandParam.ABSENCE: OverkizCommandParam.OFF, + }, + refresh_afterwards=False, + ) + # Toggling the AWAY mode changes away mode duration so we have to refresh it + await self.executor.async_execute_command( + OverkizCommand.REFRESH_AWAY_MODE_DURATION, + refresh_afterwards=False, + ) + if refresh_afterwards: + await self.coordinator.async_refresh() + + async def async_turn_boost_mode_on(self, refresh_afterwards: bool = True) -> None: + """Turn boost mode on.""" + + refresh_target_temp = False + if self.is_state_performance: + # Switching from STATE_PERFORMANCE to BOOST requires a target temperature refresh + refresh_target_temp = True + + await self.executor.async_execute_command( + OverkizCommand.SET_BOOST_MODE_DURATION, + MAX_BOOST_MODE_DURATION, + refresh_afterwards=False, + ) + + await self.executor.async_execute_command( + OverkizCommand.SET_CURRENT_OPERATING_MODE, + { + OverkizCommandParam.RELAUNCH: OverkizCommandParam.ON, + OverkizCommandParam.ABSENCE: OverkizCommandParam.OFF, + }, + refresh_afterwards=False, + ) + + await self.executor.async_execute_command( + OverkizCommand.REFRESH_BOOST_MODE_DURATION, + refresh_afterwards=False, + ) + + if refresh_target_temp: + await self.executor.async_execute_command( + OverkizCommand.REFRESH_TARGET_TEMPERATURE, refresh_afterwards=False + ) + + if refresh_afterwards: + await self.coordinator.async_refresh() + + async def async_turn_boost_mode_off(self, refresh_afterwards: bool = True) -> None: + """Turn boost mode off.""" + + await self.executor.async_execute_command( + OverkizCommand.SET_CURRENT_OPERATING_MODE, + { + OverkizCommandParam.RELAUNCH: OverkizCommandParam.OFF, + OverkizCommandParam.ABSENCE: OverkizCommandParam.OFF, + }, + refresh_afterwards=False, + ) + # Toggling the BOOST mode changes boost mode duration so we have to refresh it + await self.executor.async_execute_command( + OverkizCommand.REFRESH_BOOST_MODE_DURATION, + refresh_afterwards=False, + ) + + if refresh_afterwards: + await self.coordinator.async_refresh() From 07bce8850f48dc4f9634574293cdc980752dbbed Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 25 Mar 2025 23:53:32 +0100 Subject: [PATCH 3044/3148] Capitalize one occurrence of "bluetooth" in `idasen_desk` (#141423) All others are correct in the integration. And (according to Lokalise) in Home Assistant now, too. :-) --- homeassistant/components/idasen_desk/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/idasen_desk/strings.json b/homeassistant/components/idasen_desk/strings.json index 7486973638b..ccac87a75e0 100644 --- a/homeassistant/components/idasen_desk/strings.json +++ b/homeassistant/components/idasen_desk/strings.json @@ -7,7 +7,7 @@ "address": "Device" }, "data_description": { - "address": "The bluetooth device for the desk." + "address": "The Bluetooth device for the desk." } } }, From e78a19ae3e03334ce32ddfcd8a53baafb2d7f52b Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Wed, 26 Mar 2025 00:30:02 +0100 Subject: [PATCH 3045/3148] Reolink translate key (#140821) * Add firmware exception translations * Add test * Much nicer syntax * Check if translation key is present in string.json * fix tests * fix typo --- homeassistant/components/reolink/strings.json | 6 ++++ homeassistant/components/reolink/update.py | 5 ++- homeassistant/components/reolink/util.py | 35 ++++++++++++------- tests/components/reolink/test_update.py | 13 ++++++- tests/components/reolink/test_util.py | 8 +++++ 5 files changed, 53 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index 72076e7ef88..9a6db7b5d67 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -103,6 +103,12 @@ }, "config_entry_not_ready": { "message": "Error while trying to set up {host}: {err}" + }, + "update_already_running": { + "message": "Reolink firmware update already running, wait on completion before starting another" + }, + "firmware_rate_limit": { + "message": "Reolink firmware update server reached hourly rate limit: updating can be tried again in 1 hour" } }, "issues": { diff --git a/homeassistant/components/reolink/update.py b/homeassistant/components/reolink/update.py index 0744d66fb5b..a7c883003b7 100644 --- a/homeassistant/components/reolink/update.py +++ b/homeassistant/components/reolink/update.py @@ -31,7 +31,7 @@ from .entity import ( ReolinkHostCoordinatorEntity, ReolinkHostEntityDescription, ) -from .util import ReolinkConfigEntry, ReolinkData +from .util import ReolinkConfigEntry, ReolinkData, raise_translated_error PARALLEL_UPDATES = 0 RESUME_AFTER_INSTALL = 15 @@ -184,6 +184,7 @@ class ReolinkUpdateBaseEntity( f"## Release notes\n\n{new_firmware.release_notes}" ) + @raise_translated_error async def async_install( self, version: str | None, backup: bool, **kwargs: Any ) -> None: @@ -196,6 +197,8 @@ class ReolinkUpdateBaseEntity( try: await self._host.api.update_firmware(self._channel) except ReolinkError as err: + if err.translation_key: + raise raise HomeAssistantError( translation_domain=DOMAIN, translation_key="firmware_install_error", diff --git a/homeassistant/components/reolink/util.py b/homeassistant/components/reolink/util.py index a5556b66a33..241c370709d 100644 --- a/homeassistant/components/reolink/util.py +++ b/homeassistant/components/reolink/util.py @@ -27,6 +27,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import device_registry as dr from homeassistant.helpers.storage import Store +from homeassistant.helpers.translation import async_get_exception_message from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import DOMAIN @@ -97,6 +98,16 @@ def get_device_uid_and_ch( return (device_uid, ch, is_chime) +def check_translation_key(err: ReolinkError) -> str | None: + """Check if the translation key from the upstream library is present.""" + if not err.translation_key: + return None + if async_get_exception_message(DOMAIN, err.translation_key) == err.translation_key: + # translation key not found in strings.json + return None + return err.translation_key + + # Decorators def raise_translated_error[**P, R]( func: Callable[P, Awaitable[R]], @@ -110,73 +121,73 @@ def raise_translated_error[**P, R]( except InvalidParameterError as err: raise ServiceValidationError( translation_domain=DOMAIN, - translation_key="invalid_parameter", + translation_key=check_translation_key(err) or "invalid_parameter", translation_placeholders={"err": str(err)}, ) from err except ApiError as err: raise HomeAssistantError( translation_domain=DOMAIN, - translation_key="api_error", + translation_key=check_translation_key(err) or "api_error", translation_placeholders={"err": str(err)}, ) from err except InvalidContentTypeError as err: raise HomeAssistantError( translation_domain=DOMAIN, - translation_key="invalid_content_type", + translation_key=check_translation_key(err) or "invalid_content_type", translation_placeholders={"err": str(err)}, ) from err except CredentialsInvalidError as err: raise HomeAssistantError( translation_domain=DOMAIN, - translation_key="invalid_credentials", + translation_key=check_translation_key(err) or "invalid_credentials", translation_placeholders={"err": str(err)}, ) from err except LoginError as err: raise HomeAssistantError( translation_domain=DOMAIN, - translation_key="login_error", + translation_key=check_translation_key(err) or "login_error", translation_placeholders={"err": str(err)}, ) from err except NoDataError as err: raise HomeAssistantError( translation_domain=DOMAIN, - translation_key="no_data", + translation_key=check_translation_key(err) or "no_data", translation_placeholders={"err": str(err)}, ) from err except UnexpectedDataError as err: raise HomeAssistantError( translation_domain=DOMAIN, - translation_key="unexpected_data", + translation_key=check_translation_key(err) or "unexpected_data", translation_placeholders={"err": str(err)}, ) from err except NotSupportedError as err: raise HomeAssistantError( translation_domain=DOMAIN, - translation_key="not_supported", + translation_key=check_translation_key(err) or "not_supported", translation_placeholders={"err": str(err)}, ) from err except SubscriptionError as err: raise HomeAssistantError( translation_domain=DOMAIN, - translation_key="subscription_error", + translation_key=check_translation_key(err) or "subscription_error", translation_placeholders={"err": str(err)}, ) from err except ReolinkConnectionError as err: raise HomeAssistantError( translation_domain=DOMAIN, - translation_key="connection_error", + translation_key=check_translation_key(err) or "connection_error", translation_placeholders={"err": str(err)}, ) from err except ReolinkTimeoutError as err: raise HomeAssistantError( translation_domain=DOMAIN, - translation_key="timeout", + translation_key=check_translation_key(err) or "timeout", translation_placeholders={"err": str(err)}, ) from err except ReolinkError as err: raise HomeAssistantError( translation_domain=DOMAIN, - translation_key="unexpected", + translation_key=check_translation_key(err) or "unexpected", translation_placeholders={"err": str(err)}, ) from err diff --git a/tests/components/reolink/test_update.py b/tests/components/reolink/test_update.py index a6cfe862963..d48362516b8 100644 --- a/tests/components/reolink/test_update.py +++ b/tests/components/reolink/test_update.py @@ -6,7 +6,7 @@ from unittest.mock import MagicMock, patch from freezegun.api import FrozenDateTimeFactory import pytest -from reolink_aio.exceptions import ReolinkError +from reolink_aio.exceptions import ApiError, ReolinkError from reolink_aio.software_version import NewSoftwareVersion from homeassistant.components.reolink.update import POLL_AFTER_INSTALL, POLL_PROGRESS @@ -144,6 +144,17 @@ async def test_update_firm( blocking=True, ) + reolink_connect.update_firmware.side_effect = ApiError( + "Test error", translation_key="firmware_rate_limit" + ) + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + UPDATE_DOMAIN, + SERVICE_INSTALL, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + # test _async_update_future reolink_connect.camera_sw_version.return_value = "v3.3.0.226_23031644" reolink_connect.firmware_update_available.return_value = False diff --git a/tests/components/reolink/test_util.py b/tests/components/reolink/test_util.py index f66f4682b98..73db25eb7dc 100644 --- a/tests/components/reolink/test_util.py +++ b/tests/components/reolink/test_util.py @@ -40,6 +40,14 @@ from tests.common import MockConfigEntry ApiError("Test error"), HomeAssistantError, ), + ( + ApiError("Test error", translation_key="firmware_rate_limit"), + HomeAssistantError, + ), + ( + ApiError("Test error", translation_key="not_in_strings.json"), + HomeAssistantError, + ), ( CredentialsInvalidError("Test error"), HomeAssistantError, From 840613f43dfc627b18693201446f5a430e08fbc6 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Wed, 26 Mar 2025 00:31:01 +0100 Subject: [PATCH 3046/3148] Add mac to Reolink IPC cam device info (#140822) * Add mac to Reolink IPC cams * Add test * check mac none --- homeassistant/components/reolink/entity.py | 5 +++++ tests/components/reolink/conftest.py | 2 ++ 2 files changed, 7 insertions(+) diff --git a/homeassistant/components/reolink/entity.py b/homeassistant/components/reolink/entity.py index 55ce4ce891e..ec598de663d 100644 --- a/homeassistant/components/reolink/entity.py +++ b/homeassistant/components/reolink/entity.py @@ -178,8 +178,13 @@ class ReolinkChannelCoordinatorEntity(ReolinkHostCoordinatorEntity): else: self._dev_id = f"{self._host.unique_id}_ch{dev_ch}" + connections = set() + if mac := self._host.api.baichuan.mac_address(dev_ch): + connections.add((CONNECTION_NETWORK_MAC, mac)) + self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, self._dev_id)}, + connections=connections, via_device=(DOMAIN, self._host.unique_id), name=self._host.api.camera_name(dev_ch), model=self._host.api.camera_model(dev_ch), diff --git a/tests/components/reolink/conftest.py b/tests/components/reolink/conftest.py index f2474d640d8..21acced3d1d 100644 --- a/tests/components/reolink/conftest.py +++ b/tests/components/reolink/conftest.py @@ -35,6 +35,7 @@ TEST_PASSWORD = "password" TEST_PASSWORD2 = "new_password" TEST_MAC = "aa:bb:cc:dd:ee:ff" TEST_MAC2 = "ff:ee:dd:cc:bb:aa" +TEST_MAC_CAM = "11:22:33:44:55:66" DHCP_FORMATTED_MAC = "aabbccddeeff" TEST_UID = "ABC1234567D89EFG" TEST_UID_CAM = "DEF7654321D89GHT" @@ -142,6 +143,7 @@ def reolink_connect_class() -> Generator[MagicMock]: # Disable tcp push by default for tests host_mock.baichuan.port = TEST_BC_PORT host_mock.baichuan.events_active = False + host_mock.baichuan.mac_address.return_value = TEST_MAC_CAM host_mock.baichuan.privacy_mode.return_value = False host_mock.baichuan.day_night_state.return_value = "day" host_mock.baichuan.subscribe_events.side_effect = ReolinkError("Test error") From e2a3bfca9a2a74bdadda8fafd3e7fd0452640c38 Mon Sep 17 00:00:00 2001 From: Tsvi Mostovicz Date: Wed, 26 Mar 2025 01:33:38 +0200 Subject: [PATCH 3047/3148] Jewish calendar migration bugfix (#141425) Fix migration of Jewish calendar --- homeassistant/components/jewish_calendar/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/jewish_calendar/__init__.py b/homeassistant/components/jewish_calendar/__init__.py index 9f7ec6ba976..6b58b9441b0 100644 --- a/homeassistant/components/jewish_calendar/__init__.py +++ b/homeassistant/components/jewish_calendar/__init__.py @@ -113,8 +113,8 @@ async def async_migrate_entry( "first_stars": "tset_hakohavim_tsom", "three_stars": "tset_hakohavim_shabbat", } - new_keys = tuple(key_translations.values()) - if not entity_entry.unique_id.endswith(new_keys): + old_keys = tuple(key_translations.keys()) + if entity_entry.unique_id.endswith(old_keys): old_key = entity_entry.unique_id.split("-")[1] new_unique_id = f"{config_entry.entry_id}-{key_translations[old_key]}" return {"new_unique_id": new_unique_id} From 2208650fdea8410fc0c214786da9239a7ece2de9 Mon Sep 17 00:00:00 2001 From: Thomas D <11554546+thomasddn@users.noreply.github.com> Date: Wed, 26 Mar 2025 01:25:05 +0100 Subject: [PATCH 3048/3148] Add climate platform to qbus (#139327) * Add climate platform * Add unit tests for climate platform * Use setup_integration fixture * Apply new import order * Undo import order * Code review * Throw an exception on invalid preset mode * Let device response determine state * Remove hvac mode OFF * Remove hvac mode OFF * Setup debouncer when being added to hass * Fix typo --- homeassistant/components/qbus/climate.py | 172 +++++++++++++ homeassistant/components/qbus/const.py | 1 + homeassistant/components/qbus/strings.json | 5 + tests/components/qbus/conftest.py | 25 +- .../qbus/fixtures/payload_config.json | 36 ++- tests/components/qbus/test_climate.py | 228 ++++++++++++++++++ tests/components/qbus/test_light.py | 16 +- tests/components/qbus/test_switch.py | 16 +- 8 files changed, 468 insertions(+), 31 deletions(-) create mode 100644 homeassistant/components/qbus/climate.py create mode 100644 tests/components/qbus/test_climate.py diff --git a/homeassistant/components/qbus/climate.py b/homeassistant/components/qbus/climate.py new file mode 100644 index 00000000000..57d97c046b7 --- /dev/null +++ b/homeassistant/components/qbus/climate.py @@ -0,0 +1,172 @@ +"""Support for Qbus thermostat.""" + +import logging +from typing import Any + +from qbusmqttapi.const import KEY_PROPERTIES_REGIME, KEY_PROPERTIES_SET_TEMPERATURE +from qbusmqttapi.discovery import QbusMqttOutput +from qbusmqttapi.state import QbusMqttThermoState, StateType + +from homeassistant.components.climate import ( + ClimateEntity, + ClimateEntityFeature, + HVACAction, + HVACMode, +) +from homeassistant.components.mqtt import ReceiveMessage, client as mqtt +from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers.debounce import Debouncer +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import DOMAIN +from .coordinator import QbusConfigEntry +from .entity import QbusEntity, add_new_outputs + +PARALLEL_UPDATES = 0 + +STATE_REQUEST_DELAY = 2 + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: QbusConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up climate entities.""" + + coordinator = entry.runtime_data + added_outputs: list[QbusMqttOutput] = [] + + def _check_outputs() -> None: + add_new_outputs( + coordinator, + added_outputs, + lambda output: output.type == "thermo", + QbusClimate, + async_add_entities, + ) + + _check_outputs() + entry.async_on_unload(coordinator.async_add_listener(_check_outputs)) + + +class QbusClimate(QbusEntity, ClimateEntity): + """Representation of a Qbus climate entity.""" + + _attr_hvac_modes = [HVACMode.HEAT] + _attr_supported_features = ( + ClimateEntityFeature.PRESET_MODE | ClimateEntityFeature.TARGET_TEMPERATURE + ) + _attr_temperature_unit = UnitOfTemperature.CELSIUS + + def __init__(self, mqtt_output: QbusMqttOutput) -> None: + """Initialize climate entity.""" + + super().__init__(mqtt_output) + + self._attr_hvac_action = HVACAction.IDLE + self._attr_hvac_mode = HVACMode.HEAT + + set_temp: dict[str, Any] = mqtt_output.properties.get( + KEY_PROPERTIES_SET_TEMPERATURE, {} + ) + current_regime: dict[str, Any] = mqtt_output.properties.get( + KEY_PROPERTIES_REGIME, {} + ) + + self._attr_min_temp: float = set_temp.get("min", 0) + self._attr_max_temp: float = set_temp.get("max", 35) + self._attr_target_temperature_step: float = set_temp.get("step", 0.5) + self._attr_preset_modes: list[str] = current_regime.get("enumValues", []) + self._attr_preset_mode: str = ( + self._attr_preset_modes[0] if len(self._attr_preset_modes) > 0 else "" + ) + + self._request_state_debouncer: Debouncer | None = None + + async def async_added_to_hass(self) -> None: + """Run when entity about to be added to hass.""" + self._request_state_debouncer = Debouncer( + self.hass, + _LOGGER, + cooldown=STATE_REQUEST_DELAY, + immediate=False, + function=self._async_request_state, + ) + await super().async_added_to_hass() + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set new target preset mode.""" + + if preset_mode not in self._attr_preset_modes: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_preset", + translation_placeholders={ + "preset": preset_mode, + "options": ", ".join(self._attr_preset_modes), + }, + ) + + state = QbusMqttThermoState(id=self._mqtt_output.id, type=StateType.STATE) + state.write_regime(preset_mode) + + await self._async_publish_output_state(state) + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new target temperature.""" + temperature = kwargs.get(ATTR_TEMPERATURE) + + if temperature is not None and isinstance(temperature, float): + state = QbusMqttThermoState(id=self._mqtt_output.id, type=StateType.STATE) + state.write_set_temperature(temperature) + + await self._async_publish_output_state(state) + + async def _state_received(self, msg: ReceiveMessage) -> None: + state = self._message_factory.parse_output_state( + QbusMqttThermoState, msg.payload + ) + + if state is None: + return + + if preset_mode := state.read_regime(): + self._attr_preset_mode = preset_mode + + if current_temperature := state.read_current_temperature(): + self._attr_current_temperature = current_temperature + + if target_temperature := state.read_set_temperature(): + self._attr_target_temperature = target_temperature + + self._set_hvac_action() + + # When the state type is "event", the payload only contains the changed + # property. Request the state to get the full payload. However, changing + # temperature step by step could cause a flood of state requests, so we're + # holding off a few seconds before requesting the full state. + if state.type == StateType.EVENT: + assert self._request_state_debouncer is not None + await self._request_state_debouncer.async_call() + + self.async_schedule_update_ha_state() + + def _set_hvac_action(self) -> None: + if self.target_temperature is None or self.current_temperature is None: + self._attr_hvac_action = HVACAction.IDLE + return + + self._attr_hvac_action = ( + HVACAction.HEATING + if self.target_temperature > self.current_temperature + else HVACAction.IDLE + ) + + async def _async_request_state(self) -> None: + request = self._message_factory.create_state_request([self._mqtt_output.id]) + await mqtt.async_publish(self.hass, request.topic, request.payload) diff --git a/homeassistant/components/qbus/const.py b/homeassistant/components/qbus/const.py index b9e42f13766..767a41f48cc 100644 --- a/homeassistant/components/qbus/const.py +++ b/homeassistant/components/qbus/const.py @@ -6,6 +6,7 @@ from homeassistant.const import Platform DOMAIN: Final = "qbus" PLATFORMS: list[Platform] = [ + Platform.CLIMATE, Platform.LIGHT, Platform.SWITCH, ] diff --git a/homeassistant/components/qbus/strings.json b/homeassistant/components/qbus/strings.json index e6df18c393c..f308c5b3519 100644 --- a/homeassistant/components/qbus/strings.json +++ b/homeassistant/components/qbus/strings.json @@ -15,5 +15,10 @@ "error": { "no_controller": "No controllers were found" } + }, + "exceptions": { + "invalid_preset": { + "message": "Preset mode \"{preset}\" is not valid. Valid preset modes are: {options}." + } } } diff --git a/tests/components/qbus/conftest.py b/tests/components/qbus/conftest.py index 8268d091bda..f1fd96c321b 100644 --- a/tests/components/qbus/conftest.py +++ b/tests/components/qbus/conftest.py @@ -1,5 +1,7 @@ """Test fixtures for qbus.""" +import json + import pytest from homeassistant.components.qbus.const import CONF_SERIAL_NUMBER, DOMAIN @@ -7,9 +9,13 @@ from homeassistant.const import CONF_ID from homeassistant.core import HomeAssistant from homeassistant.util.json import JsonObjectType -from .const import FIXTURE_PAYLOAD_CONFIG +from .const import FIXTURE_PAYLOAD_CONFIG, TOPIC_CONFIG -from tests.common import MockConfigEntry, load_json_object_fixture +from tests.common import ( + MockConfigEntry, + async_fire_mqtt_message, + load_json_object_fixture, +) @pytest.fixture @@ -31,3 +37,18 @@ def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: def payload_config() -> JsonObjectType: """Return the config topic payload.""" return load_json_object_fixture(FIXTURE_PAYLOAD_CONFIG, DOMAIN) + + +@pytest.fixture +async def setup_integration( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + payload_config: JsonObjectType, +) -> None: + """Set up the integration.""" + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + async_fire_mqtt_message(hass, TOPIC_CONFIG, json.dumps(payload_config)) + await hass.async_block_till_done() diff --git a/tests/components/qbus/fixtures/payload_config.json b/tests/components/qbus/fixtures/payload_config.json index e2c7f463e4e..fc204c975ad 100644 --- a/tests/components/qbus/fixtures/payload_config.json +++ b/tests/components/qbus/fixtures/payload_config.json @@ -46,7 +46,7 @@ { "id": "UL15", "location": "Media room", - "locationId": 0, + "locationId": 1, "name": "MEDIA ROOM", "originalName": "MEDIA ROOM", "refId": "000001/28", @@ -65,6 +65,40 @@ "write": true } } + }, + { + "id": "UL20", + "location": "Living", + "locationId": 0, + "name": "LIVING TH", + "originalName": "LIVING TH", + "refId": "000001/120", + "type": "thermo", + "actions": {}, + "properties": { + "currRegime": { + "enumValues": ["MANUEEL", "VORST", "ECONOMY", "COMFORT", "NACHT"], + "read": true, + "type": "enumString", + "write": true + }, + "currTemp": { + "max": 35, + "min": 0, + "read": true, + "step": 0.5, + "type": "number", + "write": false + }, + "setTemp": { + "max": 35, + "min": 0, + "read": true, + "step": 0.5, + "type": "number", + "write": true + } + } } ] } diff --git a/tests/components/qbus/test_climate.py b/tests/components/qbus/test_climate.py new file mode 100644 index 00000000000..d521e310984 --- /dev/null +++ b/tests/components/qbus/test_climate.py @@ -0,0 +1,228 @@ +"""Test Qbus light entities.""" + +from datetime import timedelta +from unittest.mock import MagicMock, call + +import pytest + +from homeassistant.components.climate import ( + ATTR_CURRENT_TEMPERATURE, + ATTR_HVAC_ACTION, + ATTR_PRESET_MODE, + DOMAIN as CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + SERVICE_SET_TEMPERATURE, + ClimateEntity, + HVACAction, + HVACMode, +) +from homeassistant.components.qbus.climate import STATE_REQUEST_DELAY +from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers.entity_platform import EntityPlatform +from homeassistant.util import dt as dt_util + +from tests.common import async_fire_mqtt_message, async_fire_time_changed +from tests.typing import MqttMockHAClient + +_CURRENT_TEMPERATURE = 21.5 +_SET_TEMPERATURE = 20.5 +_REGIME = "COMFORT" + +_PAYLOAD_CLIMATE_STATE_TEMP = ( + f'{{"id":"UL20","properties":{{"setTemp":{_SET_TEMPERATURE}}},"type":"event"}}' +) +_PAYLOAD_CLIMATE_STATE_TEMP_FULL = f'{{"id":"UL20","properties":{{"currRegime":"MANUEEL","currTemp":{_CURRENT_TEMPERATURE},"setTemp":{_SET_TEMPERATURE}}},"type":"state"}}' + +_PAYLOAD_CLIMATE_STATE_PRESET = ( + f'{{"id":"UL20","properties":{{"currRegime":"{_REGIME}"}},"type":"event"}}' +) +_PAYLOAD_CLIMATE_STATE_PRESET_FULL = f'{{"id":"UL20","properties":{{"currRegime":"{_REGIME}","currTemp":{_CURRENT_TEMPERATURE},"setTemp":22.0}},"type":"state"}}' + +_PAYLOAD_CLIMATE_SET_TEMP = f'{{"id": "UL20", "type": "state", "properties": {{"setTemp": {_SET_TEMPERATURE}}}}}' +_PAYLOAD_CLIMATE_SET_PRESET = ( + '{"id": "UL20", "type": "state", "properties": {"currRegime": "COMFORT"}}' +) + +_TOPIC_CLIMATE_STATE = "cloudapp/QBUSMQTTGW/UL1/UL20/state" +_TOPIC_CLIMATE_SET_STATE = "cloudapp/QBUSMQTTGW/UL1/UL20/setState" +_TOPIC_GET_STATE = "cloudapp/QBUSMQTTGW/getState" + +_CLIMATE_ENTITY_ID = "climate.living_th" + + +async def test_climate( + hass: HomeAssistant, + mqtt_mock: MqttMockHAClient, + setup_integration: None, +) -> None: + """Test climate temperature & preset.""" + + # Set temperature + mqtt_mock.reset_mock() + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: _CLIMATE_ENTITY_ID, + ATTR_TEMPERATURE: _SET_TEMPERATURE, + }, + blocking=True, + ) + + mqtt_mock.async_publish.assert_called_once_with( + _TOPIC_CLIMATE_SET_STATE, _PAYLOAD_CLIMATE_SET_TEMP, 0, False + ) + + # Simulate a partial state response + async_fire_mqtt_message(hass, _TOPIC_CLIMATE_STATE, _PAYLOAD_CLIMATE_STATE_TEMP) + await hass.async_block_till_done() + + # Check state + entity = hass.states.get(_CLIMATE_ENTITY_ID) + assert entity + assert entity.attributes[ATTR_TEMPERATURE] == _SET_TEMPERATURE + assert entity.attributes[ATTR_CURRENT_TEMPERATURE] is None + assert entity.attributes[ATTR_PRESET_MODE] == "MANUEEL" + assert entity.attributes[ATTR_HVAC_ACTION] == HVACAction.IDLE + assert entity.state == HVACMode.HEAT + + # After a delay, a full state request should've been sent + _wait_and_assert_state_request(hass, mqtt_mock) + + # Simulate a full state response + async_fire_mqtt_message( + hass, _TOPIC_CLIMATE_STATE, _PAYLOAD_CLIMATE_STATE_TEMP_FULL + ) + await hass.async_block_till_done() + + # Check state after full state response + entity = hass.states.get(_CLIMATE_ENTITY_ID) + assert entity + assert entity.attributes[ATTR_TEMPERATURE] == _SET_TEMPERATURE + assert entity.attributes[ATTR_CURRENT_TEMPERATURE] == _CURRENT_TEMPERATURE + assert entity.attributes[ATTR_PRESET_MODE] == "MANUEEL" + assert entity.attributes[ATTR_HVAC_ACTION] == HVACAction.IDLE + assert entity.state == HVACMode.HEAT + + # Set preset + mqtt_mock.reset_mock() + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + { + ATTR_ENTITY_ID: _CLIMATE_ENTITY_ID, + ATTR_PRESET_MODE: _REGIME, + }, + blocking=True, + ) + + mqtt_mock.async_publish.assert_called_once_with( + _TOPIC_CLIMATE_SET_STATE, _PAYLOAD_CLIMATE_SET_PRESET, 0, False + ) + + # Simulate a partial state response + async_fire_mqtt_message(hass, _TOPIC_CLIMATE_STATE, _PAYLOAD_CLIMATE_STATE_PRESET) + await hass.async_block_till_done() + + # Check state + entity = hass.states.get(_CLIMATE_ENTITY_ID) + assert entity + assert entity.attributes[ATTR_TEMPERATURE] == _SET_TEMPERATURE + assert entity.attributes[ATTR_CURRENT_TEMPERATURE] == _CURRENT_TEMPERATURE + assert entity.attributes[ATTR_PRESET_MODE] == _REGIME + assert entity.attributes[ATTR_HVAC_ACTION] == HVACAction.IDLE + assert entity.state == HVACMode.HEAT + + # After a delay, a full state request should've been sent + _wait_and_assert_state_request(hass, mqtt_mock) + + # Simulate a full state response + async_fire_mqtt_message( + hass, _TOPIC_CLIMATE_STATE, _PAYLOAD_CLIMATE_STATE_PRESET_FULL + ) + await hass.async_block_till_done() + + # Check state after full state response + entity = hass.states.get(_CLIMATE_ENTITY_ID) + assert entity + assert entity.attributes[ATTR_TEMPERATURE] == 22.0 + assert entity.attributes[ATTR_CURRENT_TEMPERATURE] == _CURRENT_TEMPERATURE + assert entity.attributes[ATTR_PRESET_MODE] == _REGIME + assert entity.attributes[ATTR_HVAC_ACTION] == HVACAction.HEATING + assert entity.state == HVACMode.HEAT + + +async def test_climate_when_invalid_state_received( + hass: HomeAssistant, + mqtt_mock: MqttMockHAClient, + setup_integration: None, +) -> None: + """Test climate when no valid state is received.""" + + platform: EntityPlatform = hass.data["entity_components"][CLIMATE_DOMAIN] + entity: ClimateEntity = next( + ( + entity + for entity in platform.entities + if entity.entity_id == _CLIMATE_ENTITY_ID + ), + None, + ) + + assert entity + entity.async_schedule_update_ha_state = MagicMock() + + # Simulate state response + async_fire_mqtt_message(hass, _TOPIC_CLIMATE_STATE, "") + await hass.async_block_till_done() + + entity.async_schedule_update_ha_state.assert_not_called() + + +async def test_climate_with_fast_subsequent_changes( + hass: HomeAssistant, + mqtt_mock: MqttMockHAClient, + setup_integration: None, +) -> None: + """Test climate with fast subsequent changes.""" + + # Simulate two subsequent partial state responses + async_fire_mqtt_message(hass, _TOPIC_CLIMATE_STATE, _PAYLOAD_CLIMATE_STATE_TEMP) + await hass.async_block_till_done() + async_fire_mqtt_message(hass, _TOPIC_CLIMATE_STATE, _PAYLOAD_CLIMATE_STATE_TEMP) + await hass.async_block_till_done() + + # State request should be requested only once + _wait_and_assert_state_request(hass, mqtt_mock) + + +async def test_climate_with_unknown_preset( + hass: HomeAssistant, + mqtt_mock: MqttMockHAClient, + setup_integration: None, +) -> None: + """Test climate with passing an unknown preset value.""" + + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + { + ATTR_ENTITY_ID: _CLIMATE_ENTITY_ID, + ATTR_PRESET_MODE: "What is cooler than being cool?", + }, + blocking=True, + ) + + +def _wait_and_assert_state_request( + hass: HomeAssistant, mqtt_mock: MqttMockHAClient +) -> None: + mqtt_mock.reset_mock() + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(STATE_REQUEST_DELAY)) + mqtt_mock.async_publish.assert_has_calls( + [call(_TOPIC_GET_STATE, '["UL20"]', 0, False)], + any_order=True, + ) diff --git a/tests/components/qbus/test_light.py b/tests/components/qbus/test_light.py index c64219f1269..2db2c622289 100644 --- a/tests/components/qbus/test_light.py +++ b/tests/components/qbus/test_light.py @@ -1,7 +1,5 @@ """Test Qbus light entities.""" -import json - from homeassistant.components.light import ( ATTR_BRIGHTNESS, DOMAIN as LIGHT_DOMAIN, @@ -10,11 +8,8 @@ from homeassistant.components.light import ( ) from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant -from homeassistant.util.json import JsonObjectType -from .const import TOPIC_CONFIG - -from tests.common import MockConfigEntry, async_fire_mqtt_message +from tests.common import async_fire_mqtt_message from tests.typing import MqttMockHAClient # 186 = 73% (rounded) @@ -44,17 +39,10 @@ _LIGHT_ENTITY_ID = "light.media_room" async def test_light( hass: HomeAssistant, mqtt_mock: MqttMockHAClient, - mock_config_entry: MockConfigEntry, - payload_config: JsonObjectType, + setup_integration: None, ) -> None: """Test turning on and off.""" - assert await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - async_fire_mqtt_message(hass, TOPIC_CONFIG, json.dumps(payload_config)) - await hass.async_block_till_done() - # Switch ON mqtt_mock.reset_mock() await hass.services.async_call( diff --git a/tests/components/qbus/test_switch.py b/tests/components/qbus/test_switch.py index 83bb667e4eb..ddb63e933da 100644 --- a/tests/components/qbus/test_switch.py +++ b/tests/components/qbus/test_switch.py @@ -1,7 +1,5 @@ """Test Qbus switch entities.""" -import json - from homeassistant.components.switch import ( DOMAIN as SWITCH_DOMAIN, SERVICE_TURN_OFF, @@ -9,11 +7,8 @@ from homeassistant.components.switch import ( ) from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant -from homeassistant.util.json import JsonObjectType -from .const import TOPIC_CONFIG - -from tests.common import MockConfigEntry, async_fire_mqtt_message +from tests.common import async_fire_mqtt_message from tests.typing import MqttMockHAClient _PAYLOAD_SWITCH_STATE_ON = '{"id":"UL10","properties":{"value":true},"type":"state"}' @@ -34,17 +29,10 @@ _SWITCH_ENTITY_ID = "switch.living" async def test_switch_turn_on_off( hass: HomeAssistant, mqtt_mock: MqttMockHAClient, - mock_config_entry: MockConfigEntry, - payload_config: JsonObjectType, + setup_integration: None, ) -> None: """Test turning on and off.""" - assert await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - async_fire_mqtt_message(hass, TOPIC_CONFIG, json.dumps(payload_config)) - await hass.async_block_till_done() - # Switch ON mqtt_mock.reset_mock() await hass.services.async_call( From 56cc4044e436dd727308eeed91740201708cd4a4 Mon Sep 17 00:00:00 2001 From: Ivan Lopez Hernandez Date: Tue, 25 Mar 2025 19:59:21 -0700 Subject: [PATCH 3049/3148] Fix a type error when using google-genai==1.7.0 (#141431) * Fix parts * Fix the type being sent to the SDK * Revert changes to __init__ * Test fixes * Bump version back to 1.7 --- .../conversation.py | 24 ++-- .../snapshots/test_conversation.ambr | 12 +- .../test_conversation.py | 116 ++++++++---------- 3 files changed, 77 insertions(+), 75 deletions(-) diff --git a/homeassistant/components/google_generative_ai_conversation/conversation.py b/homeassistant/components/google_generative_ai_conversation/conversation.py index cca5f2410bd..5460f48f20e 100644 --- a/homeassistant/components/google_generative_ai_conversation/conversation.py +++ b/homeassistant/components/google_generative_ai_conversation/conversation.py @@ -171,17 +171,25 @@ def _escape_decode(value: Any) -> Any: return value +def _create_google_tool_response_parts( + parts: list[conversation.ToolResultContent], +) -> list[Part]: + """Create Google tool response parts.""" + return [ + Part.from_function_response( + name=tool_result.tool_name, response=tool_result.tool_result + ) + for tool_result in parts + ] + + def _create_google_tool_response_content( content: list[conversation.ToolResultContent], ) -> Content: """Create a Google tool response content.""" return Content( - parts=[ - Part.from_function_response( - name=tool_result.tool_name, response=tool_result.tool_result - ) - for tool_result in content - ] + role="user", + parts=_create_google_tool_response_parts(content), ) @@ -402,7 +410,7 @@ class GoogleGenerativeAIConversationEntity( chat = self._genai_client.aio.chats.create( model=model_name, history=messages, config=generateContentConfig ) - chat_request: str | Content = user_input.text + chat_request: str | list[Part] = user_input.text # To prevent infinite loops, we limit the number of iterations for _iteration in range(MAX_TOOL_ITERATIONS): try: @@ -456,7 +464,7 @@ class GoogleGenerativeAIConversationEntity( ) ) - chat_request = _create_google_tool_response_content( + chat_request = _create_google_tool_response_parts( [ tool_response async for tool_response in chat_log.async_add_assistant_content( diff --git a/tests/components/google_generative_ai_conversation/snapshots/test_conversation.ambr b/tests/components/google_generative_ai_conversation/snapshots/test_conversation.ambr index bd4c406f071..ec98bdd6529 100644 --- a/tests/components/google_generative_ai_conversation/snapshots/test_conversation.ambr +++ b/tests/components/google_generative_ai_conversation/snapshots/test_conversation.ambr @@ -25,7 +25,9 @@ tuple( ), dict({ - 'message': Content(parts=[Part(video_metadata=None, thought=None, code_execution_result=None, executable_code=None, file_data=None, function_call=None, function_response=FunctionResponse(id=None, name='test_tool', response={'result': 'Test response'}), inline_data=None, text=None)], role=None), + 'message': list([ + Part(video_metadata=None, thought=None, code_execution_result=None, executable_code=None, file_data=None, function_call=None, function_response=FunctionResponse(id=None, name='test_tool', response={'result': 'Test response'}), inline_data=None, text=None), + ]), }), ), ]) @@ -56,7 +58,9 @@ tuple( ), dict({ - 'message': Content(parts=[Part(video_metadata=None, thought=None, code_execution_result=None, executable_code=None, file_data=None, function_call=None, function_response=FunctionResponse(id=None, name='test_tool', response={'result': 'Test response'}), inline_data=None, text=None)], role=None), + 'message': list([ + Part(video_metadata=None, thought=None, code_execution_result=None, executable_code=None, file_data=None, function_call=None, function_response=FunctionResponse(id=None, name='test_tool', response={'result': 'Test response'}), inline_data=None, text=None), + ]), }), ), ]) @@ -87,7 +91,9 @@ tuple( ), dict({ - 'message': Content(parts=[Part(video_metadata=None, thought=None, code_execution_result=None, executable_code=None, file_data=None, function_call=None, function_response=FunctionResponse(id=None, name='test_tool', response={'result': 'Test response'}), inline_data=None, text=None)], role=None), + 'message': list([ + Part(video_metadata=None, thought=None, code_execution_result=None, executable_code=None, file_data=None, function_call=None, function_response=FunctionResponse(id=None, name='test_tool', response={'result': 'Test response'}), inline_data=None, text=None), + ]), }), ), ]) diff --git a/tests/components/google_generative_ai_conversation/test_conversation.py b/tests/components/google_generative_ai_conversation/test_conversation.py index bdf1c01fd31..a2b238b9399 100644 --- a/tests/components/google_generative_ai_conversation/test_conversation.py +++ b/tests/components/google_generative_ai_conversation/test_conversation.py @@ -104,28 +104,24 @@ async def test_function_call( assert result.response.response_type == intent.IntentResponseType.ACTION_DONE assert result.response.as_dict()["speech"]["plain"]["speech"] == "Hi there!" - mock_tool_call = mock_create.mock_calls[2][2]["message"] - assert mock_tool_call.model_dump() == { - "parts": [ - { - "code_execution_result": None, - "executable_code": None, - "file_data": None, - "function_call": None, - "function_response": { - "id": None, - "name": "test_tool", - "response": { - "result": "Test response", - }, - }, - "inline_data": None, - "text": None, - "thought": None, - "video_metadata": None, + mock_tool_response_parts = mock_create.mock_calls[2][2]["message"] + assert len(mock_tool_response_parts) == 1 + assert mock_tool_response_parts[0].model_dump() == { + "code_execution_result": None, + "executable_code": None, + "file_data": None, + "function_call": None, + "function_response": { + "id": None, + "name": "test_tool", + "response": { + "result": "Test response", }, - ], - "role": None, + }, + "inline_data": None, + "text": None, + "thought": None, + "video_metadata": None, } mock_tool.async_call.assert_awaited_once_with( @@ -292,28 +288,24 @@ async def test_function_call_without_parameters( assert result.response.response_type == intent.IntentResponseType.ACTION_DONE assert result.response.as_dict()["speech"]["plain"]["speech"] == "Hi there!" - mock_tool_call = mock_create.mock_calls[2][2]["message"] - assert mock_tool_call.model_dump() == { - "parts": [ - { - "code_execution_result": None, - "executable_code": None, - "file_data": None, - "function_call": None, - "function_response": { - "id": None, - "name": "test_tool", - "response": { - "result": "Test response", - }, - }, - "inline_data": None, - "text": None, - "thought": None, - "video_metadata": None, + mock_tool_response_parts = mock_create.mock_calls[2][2]["message"] + assert len(mock_tool_response_parts) == 1 + assert mock_tool_response_parts[0].model_dump() == { + "code_execution_result": None, + "executable_code": None, + "file_data": None, + "function_call": None, + "function_response": { + "id": None, + "name": "test_tool", + "response": { + "result": "Test response", }, - ], - "role": None, + }, + "inline_data": None, + "text": None, + "thought": None, + "video_metadata": None, } mock_tool.async_call.assert_awaited_once_with( @@ -390,29 +382,25 @@ async def test_function_exception( assert result.response.response_type == intent.IntentResponseType.ACTION_DONE assert result.response.as_dict()["speech"]["plain"]["speech"] == "Hi there!" - mock_tool_call = mock_create.mock_calls[2][2]["message"] - assert mock_tool_call.model_dump() == { - "parts": [ - { - "code_execution_result": None, - "executable_code": None, - "file_data": None, - "function_call": None, - "function_response": { - "id": None, - "name": "test_tool", - "response": { - "error": "HomeAssistantError", - "error_text": "Test tool exception", - }, - }, - "inline_data": None, - "text": None, - "thought": None, - "video_metadata": None, + mock_tool_response_parts = mock_create.mock_calls[2][2]["message"] + assert len(mock_tool_response_parts) == 1 + assert mock_tool_response_parts[0].model_dump() == { + "code_execution_result": None, + "executable_code": None, + "file_data": None, + "function_call": None, + "function_response": { + "id": None, + "name": "test_tool", + "response": { + "error": "HomeAssistantError", + "error_text": "Test tool exception", }, - ], - "role": None, + }, + "inline_data": None, + "text": None, + "thought": None, + "video_metadata": None, } mock_tool.async_call.assert_awaited_once_with( hass, From eb1caeb7709a9f1063beaeb5d9cab01505bebb39 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 26 Mar 2025 07:51:25 +0100 Subject: [PATCH 3050/3148] Add template list functions: intersect, difference, symmetric_difference, union (#141420) --- homeassistant/helpers/template.py | 52 +++++++++ tests/helpers/test_template.py | 178 ++++++++++++++++++++++++++++++ 2 files changed, 230 insertions(+) diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 0d017dda64f..70a94cfaaa9 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -2785,6 +2785,50 @@ def flatten(value: Iterable[Any], levels: int | None = None) -> list[Any]: return flattened +def intersect(value: Iterable[Any], other: Iterable[Any]) -> list[Any]: + """Return the common elements between two lists.""" + if not isinstance(value, Iterable) or isinstance(value, str): + raise TypeError(f"intersect expected a list, got {type(value).__name__}") + if not isinstance(other, Iterable) or isinstance(other, str): + raise TypeError(f"intersect expected a list, got {type(other).__name__}") + + return list(set(value) & set(other)) + + +def difference(value: Iterable[Any], other: Iterable[Any]) -> list[Any]: + """Return elements in first list that are not in second list.""" + if not isinstance(value, Iterable) or isinstance(value, str): + raise TypeError(f"difference expected a list, got {type(value).__name__}") + if not isinstance(other, Iterable) or isinstance(other, str): + raise TypeError(f"difference expected a list, got {type(other).__name__}") + + return list(set(value) - set(other)) + + +def union(value: Iterable[Any], other: Iterable[Any]) -> list[Any]: + """Return all unique elements from both lists combined.""" + if not isinstance(value, Iterable) or isinstance(value, str): + raise TypeError(f"union expected a list, got {type(value).__name__}") + if not isinstance(other, Iterable) or isinstance(other, str): + raise TypeError(f"union expected a list, got {type(other).__name__}") + + return list(set(value) | set(other)) + + +def symmetric_difference(value: Iterable[Any], other: Iterable[Any]) -> list[Any]: + """Return elements that are in either list but not in both.""" + if not isinstance(value, Iterable) or isinstance(value, str): + raise TypeError( + f"symmetric_difference expected a list, got {type(value).__name__}" + ) + if not isinstance(other, Iterable) or isinstance(other, str): + raise TypeError( + f"symmetric_difference expected a list, got {type(other).__name__}" + ) + + return list(set(value) ^ set(other)) + + def combine(*args: Any, recursive: bool = False) -> dict[Any, Any]: """Combine multiple dictionaries into one.""" if not args: @@ -2996,11 +3040,13 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): self.globals["bool"] = forgiving_boolean self.globals["combine"] = combine self.globals["cos"] = cosine + self.globals["difference"] = difference self.globals["e"] = math.e self.globals["flatten"] = flatten self.globals["float"] = forgiving_float self.globals["iif"] = iif self.globals["int"] = forgiving_int + self.globals["intersect"] = intersect self.globals["is_number"] = is_number self.globals["log"] = logarithm self.globals["max"] = min_max_from_filter(self.filters["max"], "max") @@ -3020,11 +3066,13 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): self.globals["sqrt"] = square_root self.globals["statistical_mode"] = statistical_mode self.globals["strptime"] = strptime + self.globals["symmetric_difference"] = symmetric_difference self.globals["tan"] = tangent self.globals["tau"] = math.pi * 2 self.globals["timedelta"] = timedelta self.globals["tuple"] = _to_tuple self.globals["typeof"] = typeof + self.globals["union"] = union self.globals["unpack"] = struct_unpack self.globals["urlencode"] = urlencode self.globals["version"] = version @@ -3049,11 +3097,13 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): self.filters["combine"] = combine self.filters["contains"] = contains self.filters["cos"] = cosine + self.filters["difference"] = difference self.filters["flatten"] = flatten self.filters["float"] = forgiving_float_filter self.filters["from_json"] = from_json self.filters["iif"] = iif self.filters["int"] = forgiving_int_filter + self.filters["intersect"] = intersect self.filters["is_defined"] = fail_when_undefined self.filters["is_number"] = is_number self.filters["log"] = logarithm @@ -3078,12 +3128,14 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): self.filters["slugify"] = slugify self.filters["sqrt"] = square_root self.filters["statistical_mode"] = statistical_mode + self.filters["symmetric_difference"] = symmetric_difference self.filters["tan"] = tangent self.filters["timestamp_custom"] = timestamp_custom self.filters["timestamp_local"] = timestamp_local self.filters["timestamp_utc"] = timestamp_utc self.filters["to_json"] = to_json self.filters["typeof"] = typeof + self.filters["union"] = union self.filters["unpack"] = struct_unpack self.filters["version"] = version diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index e4e73fc52d9..89d1c307fd7 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -6790,6 +6790,184 @@ def test_flatten(hass: HomeAssistant) -> None: template.Template("{{ flatten() }}", hass).async_render() +def test_intersect(hass: HomeAssistant) -> None: + """Test the intersect function and filter.""" + assert list( + template.Template( + "{{ intersect([1, 2, 5, 3, 4, 10], [1, 2, 3, 4, 5, 11, 99]) }}", hass + ).async_render() + ) == unordered([1, 2, 3, 4, 5]) + + assert list( + template.Template( + "{{ [1, 2, 5, 3, 4, 10] | intersect([1, 2, 3, 4, 5, 11, 99]) }}", hass + ).async_render() + ) == unordered([1, 2, 3, 4, 5]) + + assert list( + template.Template( + "{{ intersect(['a', 'b', 'c'], ['b', 'c', 'd']) }}", hass + ).async_render() + ) == unordered(["b", "c"]) + + assert list( + template.Template( + "{{ ['a', 'b', 'c'] | intersect(['b', 'c', 'd']) }}", hass + ).async_render() + ) == unordered(["b", "c"]) + + assert ( + template.Template("{{ intersect([], [1, 2, 3]) }}", hass).async_render() == [] + ) + + assert ( + template.Template("{{ [] | intersect([1, 2, 3]) }}", hass).async_render() == [] + ) + + with pytest.raises(TemplateError, match="intersect expected a list, got str"): + template.Template("{{ 'string' | intersect([1, 2, 3]) }}", hass).async_render() + + with pytest.raises(TemplateError, match="intersect expected a list, got str"): + template.Template("{{ [1, 2, 3] | intersect('string') }}", hass).async_render() + + +def test_difference(hass: HomeAssistant) -> None: + """Test the difference function and filter.""" + assert list( + template.Template( + "{{ difference([1, 2, 5, 3, 4, 10], [1, 2, 3, 4, 5, 11, 99]) }}", hass + ).async_render() + ) == [10] + + assert list( + template.Template( + "{{ [1, 2, 5, 3, 4, 10] | difference([1, 2, 3, 4, 5, 11, 99]) }}", hass + ).async_render() + ) == [10] + + assert list( + template.Template( + "{{ difference(['a', 'b', 'c'], ['b', 'c', 'd']) }}", hass + ).async_render() + ) == ["a"] + + assert list( + template.Template( + "{{ ['a', 'b', 'c'] | difference(['b', 'c', 'd']) }}", hass + ).async_render() + ) == ["a"] + + assert ( + template.Template("{{ difference([], [1, 2, 3]) }}", hass).async_render() == [] + ) + + assert ( + template.Template("{{ [] | difference([1, 2, 3]) }}", hass).async_render() == [] + ) + + with pytest.raises(TemplateError, match="difference expected a list, got str"): + template.Template("{{ 'string' | difference([1, 2, 3]) }}", hass).async_render() + + with pytest.raises(TemplateError, match="difference expected a list, got str"): + template.Template("{{ [1, 2, 3] | difference('string') }}", hass).async_render() + + +def test_union(hass: HomeAssistant) -> None: + """Test the union function and filter.""" + assert list( + template.Template( + "{{ union([1, 2, 5, 3, 4, 10], [1, 2, 3, 4, 5, 11, 99]) }}", hass + ).async_render() + ) == unordered([1, 2, 3, 4, 5, 10, 11, 99]) + + assert list( + template.Template( + "{{ [1, 2, 5, 3, 4, 10] | union([1, 2, 3, 4, 5, 11, 99]) }}", hass + ).async_render() + ) == unordered([1, 2, 3, 4, 5, 10, 11, 99]) + + assert list( + template.Template( + "{{ union(['a', 'b', 'c'], ['b', 'c', 'd']) }}", hass + ).async_render() + ) == unordered(["a", "b", "c", "d"]) + + assert list( + template.Template( + "{{ ['a', 'b', 'c'] | union(['b', 'c', 'd']) }}", hass + ).async_render() + ) == unordered(["a", "b", "c", "d"]) + + assert list( + template.Template("{{ union([], [1, 2, 3]) }}", hass).async_render() + ) == unordered([1, 2, 3]) + + assert list( + template.Template("{{ [] | union([1, 2, 3]) }}", hass).async_render() + ) == unordered([1, 2, 3]) + + with pytest.raises(TemplateError, match="union expected a list, got str"): + template.Template("{{ 'string' | union([1, 2, 3]) }}", hass).async_render() + + with pytest.raises(TemplateError, match="union expected a list, got str"): + template.Template("{{ [1, 2, 3] | union('string') }}", hass).async_render() + + +def test_symmetric_difference(hass: HomeAssistant) -> None: + """Test the symmetric_difference function and filter.""" + assert list( + template.Template( + "{{ symmetric_difference([1, 2, 5, 3, 4, 10], [1, 2, 3, 4, 5, 11, 99]) }}", + hass, + ).async_render() + ) == unordered([10, 11, 99]) + + assert list( + template.Template( + "{{ [1, 2, 5, 3, 4, 10] | symmetric_difference([1, 2, 3, 4, 5, 11, 99]) }}", + hass, + ).async_render() + ) == unordered([10, 11, 99]) + + assert list( + template.Template( + "{{ symmetric_difference(['a', 'b', 'c'], ['b', 'c', 'd']) }}", hass + ).async_render() + ) == unordered(["a", "d"]) + + assert list( + template.Template( + "{{ ['a', 'b', 'c'] | symmetric_difference(['b', 'c', 'd']) }}", hass + ).async_render() + ) == unordered(["a", "d"]) + + assert list( + template.Template( + "{{ symmetric_difference([], [1, 2, 3]) }}", hass + ).async_render() + ) == unordered([1, 2, 3]) + + assert list( + template.Template( + "{{ [] | symmetric_difference([1, 2, 3]) }}", hass + ).async_render() + ) == unordered([1, 2, 3]) + + with pytest.raises( + TemplateError, match="symmetric_difference expected a list, got str" + ): + template.Template( + "{{ 'string' | symmetric_difference([1, 2, 3]) }}", hass + ).async_render() + + with pytest.raises( + TemplateError, match="symmetric_difference expected a list, got str" + ): + template.Template( + "{{ [1, 2, 3] | symmetric_difference('string') }}", hass + ).async_render() + + def test_md5(hass: HomeAssistant) -> None: """Test the md5 function and filter.""" assert ( From e95f2c42825c3b5593b599d348dbee692a872cdc Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 26 Mar 2025 08:28:57 +0100 Subject: [PATCH 3051/3148] Fix log level of cast print informing users to contribute model number (#141438) --- homeassistant/components/cast/helpers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/cast/helpers.py b/homeassistant/components/cast/helpers.py index 7f46100afca..c45bbb4fbbc 100644 --- a/homeassistant/components/cast/helpers.py +++ b/homeassistant/components/cast/helpers.py @@ -81,7 +81,7 @@ class ChromecastInfo: "+label%3A%22integration%3A+cast%22" ) - _LOGGER.debug( + _LOGGER.info( ( "Fetched cast details for unknown model '%s' manufacturer:" " '%s', type: '%s'. Please %s" From d954d04d12bd83caf64748d3bb66232c0327a862 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Wed, 26 Mar 2025 08:34:15 +0100 Subject: [PATCH 3052/3148] Add diagnostics for Home Assistant Backup integration (#141407) add diagnostics platform --- .../components/backup/diagnostics.py | 27 +++++++++++++ .../backup/snapshots/test_diagnostics.ambr | 39 +++++++++++++++++++ tests/components/backup/test_diagnostics.py | 26 +++++++++++++ 3 files changed, 92 insertions(+) create mode 100644 homeassistant/components/backup/diagnostics.py create mode 100644 tests/components/backup/snapshots/test_diagnostics.ambr create mode 100644 tests/components/backup/test_diagnostics.py diff --git a/homeassistant/components/backup/diagnostics.py b/homeassistant/components/backup/diagnostics.py new file mode 100644 index 00000000000..9c3e28bde5b --- /dev/null +++ b/homeassistant/components/backup/diagnostics.py @@ -0,0 +1,27 @@ +"""Diagnostics support for Home Assistant Backup integration.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.const import CONF_PASSWORD +from homeassistant.core import HomeAssistant + +from .coordinator import BackupConfigEntry + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: BackupConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + coordinator = entry.runtime_data + return { + "backup_agents": [ + {"name": agent.name, "agent_id": agent.agent_id} + for agent in coordinator.backup_manager.backup_agents.values() + ], + "backup_config": async_redact_data( + coordinator.backup_manager.config.data.to_dict(), [CONF_PASSWORD] + ), + } diff --git a/tests/components/backup/snapshots/test_diagnostics.ambr b/tests/components/backup/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..cf412970204 --- /dev/null +++ b/tests/components/backup/snapshots/test_diagnostics.ambr @@ -0,0 +1,39 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'backup_agents': list([ + dict({ + 'agent_id': 'backup.local', + 'name': 'local', + }), + ]), + 'backup_config': dict({ + 'agents': dict({ + }), + 'automatic_backups_configured': False, + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'state': 'never', + 'time': None, + }), + }), + }) +# --- diff --git a/tests/components/backup/test_diagnostics.py b/tests/components/backup/test_diagnostics.py new file mode 100644 index 00000000000..a66b4a9a2ea --- /dev/null +++ b/tests/components/backup/test_diagnostics.py @@ -0,0 +1,26 @@ +"""Tests the diagnostics for Home Assistant Backup integration.""" + +from syrupy import SnapshotAssertion + +from homeassistant.components.backup.const import DOMAIN +from homeassistant.core import HomeAssistant + +from .common import setup_backup_integration + +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def test_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics.""" + await setup_backup_integration(hass, with_hassio=False) + await hass.async_block_till_done(wait_background_tasks=True) + + entry = hass.config_entries.async_entries(DOMAIN)[0] + diag_data = await get_diagnostics_for_config_entry(hass, hass_client, entry) + + assert diag_data == snapshot From dd914deb4767d189fabd5ac57aabf01a731e2b7c Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Wed, 26 Mar 2025 03:36:07 -0400 Subject: [PATCH 3053/3148] Bump roborock to silver (#141433) --- homeassistant/components/roborock/manifest.json | 1 + homeassistant/components/roborock/quality_scale.yaml | 4 ++-- script/hassfest/quality_scale.py | 1 - 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/roborock/manifest.json b/homeassistant/components/roborock/manifest.json index 60036edb0bc..531590d5d6e 100644 --- a/homeassistant/components/roborock/manifest.json +++ b/homeassistant/components/roborock/manifest.json @@ -17,6 +17,7 @@ "documentation": "https://www.home-assistant.io/integrations/roborock", "iot_class": "local_polling", "loggers": ["roborock"], + "quality_scale": "silver", "requirements": [ "python-roborock==2.16.1", "vacuum-map-parser-roborock==0.1.2" diff --git a/homeassistant/components/roborock/quality_scale.yaml b/homeassistant/components/roborock/quality_scale.yaml index d064c30ccf6..32ddb145f90 100644 --- a/homeassistant/components/roborock/quality_scale.yaml +++ b/homeassistant/components/roborock/quality_scale.yaml @@ -21,7 +21,7 @@ rules: test-before-setup: done unique-config-entry: done # Silver - action-exceptions: todo + action-exceptions: done config-entry-unloading: done docs-configuration-parameters: done docs-installation-parameters: done @@ -29,7 +29,7 @@ rules: integration-owner: done log-when-unavailable: done parallel-updates: done - reauthentication-flow: todo + reauthentication-flow: done test-coverage: done # Gold devices: done diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index d74011801d5..ea6e657ec50 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -1927,7 +1927,6 @@ INTEGRATIONS_WITHOUT_SCALE = [ "risco", "rituals_perfume_genie", "rmvtransport", - "roborock", "rocketchat", "roku", "romy", From 18dfd3db889be6685959a6b60672b312e6ccd383 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Wed, 26 Mar 2025 08:53:46 +0100 Subject: [PATCH 3054/3148] Simplify Reolink exception handling (#141427) --- homeassistant/components/reolink/util.py | 77 +++++------------------- tests/components/reolink/test_util.py | 34 ++++++----- 2 files changed, 34 insertions(+), 77 deletions(-) diff --git a/homeassistant/components/reolink/util.py b/homeassistant/components/reolink/util.py index 241c370709d..12b4825caeb 100644 --- a/homeassistant/components/reolink/util.py +++ b/homeassistant/components/reolink/util.py @@ -108,6 +108,20 @@ def check_translation_key(err: ReolinkError) -> str | None: return err.translation_key +_EXCEPTION_TO_TRANSLATION_KEY = { + ApiError: "api_error", + InvalidContentTypeError: "invalid_content_type", + CredentialsInvalidError: "invalid_credentials", + LoginError: "login_error", + NoDataError: "no_data", + UnexpectedDataError: "unexpected_data", + NotSupportedError: "not_supported", + SubscriptionError: "subscription_error", + ReolinkConnectionError: "connection_error", + ReolinkTimeoutError: "timeout", +} + + # Decorators def raise_translated_error[**P, R]( func: Callable[P, Awaitable[R]], @@ -124,70 +138,11 @@ def raise_translated_error[**P, R]( translation_key=check_translation_key(err) or "invalid_parameter", translation_placeholders={"err": str(err)}, ) from err - except ApiError as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key=check_translation_key(err) or "api_error", - translation_placeholders={"err": str(err)}, - ) from err - except InvalidContentTypeError as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key=check_translation_key(err) or "invalid_content_type", - translation_placeholders={"err": str(err)}, - ) from err - except CredentialsInvalidError as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key=check_translation_key(err) or "invalid_credentials", - translation_placeholders={"err": str(err)}, - ) from err - except LoginError as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key=check_translation_key(err) or "login_error", - translation_placeholders={"err": str(err)}, - ) from err - except NoDataError as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key=check_translation_key(err) or "no_data", - translation_placeholders={"err": str(err)}, - ) from err - except UnexpectedDataError as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key=check_translation_key(err) or "unexpected_data", - translation_placeholders={"err": str(err)}, - ) from err - except NotSupportedError as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key=check_translation_key(err) or "not_supported", - translation_placeholders={"err": str(err)}, - ) from err - except SubscriptionError as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key=check_translation_key(err) or "subscription_error", - translation_placeholders={"err": str(err)}, - ) from err - except ReolinkConnectionError as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key=check_translation_key(err) or "connection_error", - translation_placeholders={"err": str(err)}, - ) from err - except ReolinkTimeoutError as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key=check_translation_key(err) or "timeout", - translation_placeholders={"err": str(err)}, - ) from err except ReolinkError as err: raise HomeAssistantError( translation_domain=DOMAIN, - translation_key=check_translation_key(err) or "unexpected", + translation_key=check_translation_key(err) + or _EXCEPTION_TO_TRANSLATION_KEY.get(type(err), "unexpected"), translation_placeholders={"err": str(err)}, ) from err diff --git a/tests/components/reolink/test_util.py b/tests/components/reolink/test_util.py index 73db25eb7dc..ef66d471801 100644 --- a/tests/components/reolink/test_util.py +++ b/tests/components/reolink/test_util.py @@ -38,59 +38,59 @@ from tests.common import MockConfigEntry [ ( ApiError("Test error"), - HomeAssistantError, + HomeAssistantError(translation_key="api_error"), ), ( ApiError("Test error", translation_key="firmware_rate_limit"), - HomeAssistantError, + HomeAssistantError(translation_key="firmware_rate_limit"), ), ( ApiError("Test error", translation_key="not_in_strings.json"), - HomeAssistantError, + HomeAssistantError(translation_key="api_error"), ), ( CredentialsInvalidError("Test error"), - HomeAssistantError, + HomeAssistantError(translation_key="invalid_credentials"), ), ( InvalidContentTypeError("Test error"), - HomeAssistantError, + HomeAssistantError(translation_key="invalid_content_type"), ), ( InvalidParameterError("Test error"), - ServiceValidationError, + ServiceValidationError(translation_key="invalid_parameter"), ), ( LoginError("Test error"), - HomeAssistantError, + HomeAssistantError(translation_key="login_error"), ), ( NoDataError("Test error"), - HomeAssistantError, + HomeAssistantError(translation_key="no_data"), ), ( NotSupportedError("Test error"), - HomeAssistantError, + HomeAssistantError(translation_key="not_supported"), ), ( ReolinkConnectionError("Test error"), - HomeAssistantError, + HomeAssistantError(translation_key="connection_error"), ), ( ReolinkError("Test error"), - HomeAssistantError, + HomeAssistantError(translation_key="unexpected"), ), ( ReolinkTimeoutError("Test error"), - HomeAssistantError, + HomeAssistantError(translation_key="timeout"), ), ( SubscriptionError("Test error"), - HomeAssistantError, + HomeAssistantError(translation_key="subscription_error"), ), ( UnexpectedDataError("Test error"), - HomeAssistantError, + HomeAssistantError(translation_key="unexpected_data"), ), ], ) @@ -99,7 +99,7 @@ async def test_try_function( config_entry: MockConfigEntry, reolink_connect: MagicMock, side_effect: ReolinkError, - expected: Exception, + expected: HomeAssistantError, ) -> None: """Test try_function error translations using number entity.""" reolink_connect.volume.return_value = 80 @@ -112,7 +112,7 @@ async def test_try_function( entity_id = f"{Platform.NUMBER}.{TEST_NVR_NAME}_volume" reolink_connect.set_volume.side_effect = side_effect - with pytest.raises(expected): + with pytest.raises(expected.__class__) as err: await hass.services.async_call( NUMBER_DOMAIN, SERVICE_SET_VALUE, @@ -120,4 +120,6 @@ async def test_try_function( blocking=True, ) + assert err.value.translation_key == expected.translation_key + reolink_connect.set_volume.reset_mock(side_effect=True) From 1cb4332a3c9256eb244384e07a7c42c59b869074 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 26 Mar 2025 09:07:30 +0100 Subject: [PATCH 3055/3148] Fix sentence-case and naming of "Security code" in `tradfri` (#141440) --- homeassistant/components/tradfri/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/tradfri/strings.json b/homeassistant/components/tradfri/strings.json index 9ed7e167e71..66c46dd482e 100644 --- a/homeassistant/components/tradfri/strings.json +++ b/homeassistant/components/tradfri/strings.json @@ -6,7 +6,7 @@ "description": "You can find the security code on the back of your gateway.", "data": { "host": "[%key:common::config_flow::data::host%]", - "security_code": "Security Code" + "security_code": "Security code" }, "data_description": { "host": "Hostname or IP address of your Trådfri gateway." @@ -14,7 +14,7 @@ } }, "error": { - "invalid_security_code": "Failed to register with provided key. If this keeps happening, try restarting the gateway.", + "invalid_security_code": "Failed to register with provided code. If this keeps happening, try restarting the gateway.", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "timeout": "Timeout validating the code.", "cannot_authenticate": "Cannot authenticate, is Gateway paired with another server like e.g. Homekit?" From 65c05d66c0eb53363b13698144959f2d0fe4641e Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Wed, 26 Mar 2025 09:43:09 +0100 Subject: [PATCH 3056/3148] Use a constant for sensor statistics issues (#141441) --- homeassistant/components/sensor/recorder.py | 10 +++--- tests/components/sensor/test_recorder.py | 36 ++++++++++++--------- 2 files changed, 26 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/sensor/recorder.py b/homeassistant/components/sensor/recorder.py index 4e8e27e0c79..ae64709ad36 100644 --- a/homeassistant/components/sensor/recorder.py +++ b/homeassistant/components/sensor/recorder.py @@ -78,6 +78,8 @@ WARN_UNSUPPORTED_UNIT: HassKey[set[str]] = HassKey(f"{DOMAIN}_warn_unsupported_u WARN_UNSTABLE_UNIT: HassKey[set[str]] = HassKey(f"{DOMAIN}_warn_unstable_unit") # Link to dev statistics where issues around LTS can be fixed LINK_DEV_STATISTICS = "https://my.home-assistant.io/redirect/developer_statistics" +STATE_CLASS_REMOVED_ISSUE = "state_class_removed" +UNITS_CHANGED_ISSUE = "units_changed" def _get_sensor_states(hass: HomeAssistant) -> list[State]: @@ -697,7 +699,7 @@ def _update_issues( if numeric and state_class is None: # Sensor no longer has a valid state class report_issue( - "state_class_removed", + STATE_CLASS_REMOVED_ISSUE, entity_id, {"statistic_id": entity_id}, ) @@ -708,7 +710,7 @@ def _update_issues( if numeric and not _equivalent_units({state_unit, metadata_unit}): # The unit has changed, and it's not possible to convert report_issue( - "units_changed", + UNITS_CHANGED_ISSUE, entity_id, { "statistic_id": entity_id, @@ -722,7 +724,7 @@ def _update_issues( valid_units = (unit or "" for unit in converter.VALID_UNITS) valid_units_str = ", ".join(sorted(valid_units)) report_issue( - "units_changed", + UNITS_CHANGED_ISSUE, entity_id, { "statistic_id": entity_id, @@ -754,7 +756,7 @@ def update_statistics_issues( issue.domain != DOMAIN or not (issue_data := issue.data) or issue_data.get("issue_type") - not in ("state_class_removed", "units_changed") + not in (STATE_CLASS_REMOVED_ISSUE, UNITS_CHANGED_ISSUE) ): continue issues.add(issue.issue_id) diff --git a/tests/components/sensor/test_recorder.py b/tests/components/sensor/test_recorder.py index 1dd8fb4905a..ce188ecb924 100644 --- a/tests/components/sensor/test_recorder.py +++ b/tests/components/sensor/test_recorder.py @@ -36,6 +36,10 @@ from homeassistant.components.recorder.statistics import ( ) from homeassistant.components.recorder.util import get_instance, session_scope from homeassistant.components.sensor import ATTR_OPTIONS, DOMAIN, SensorDeviceClass +from homeassistant.components.sensor.recorder import ( + STATE_CLASS_REMOVED_ISSUE, + UNITS_CHANGED_ISSUE, +) from homeassistant.const import ATTR_FRIENDLY_NAME, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant, State from homeassistant.helpers import issue_registry as ir @@ -4428,11 +4432,11 @@ async def test_validate_unit_change_convertible( "statistic_id": "sensor.test", "supported_unit": supported_unit, }, - "type": "units_changed", + "type": UNITS_CHANGED_ISSUE, } ], } - await assert_validation_result(hass, client, expected, {"units_changed"}) + await assert_validation_result(hass, client, expected, {UNITS_CHANGED_ISSUE}) # Unavailable state - empty response hass.states.async_set( @@ -4653,11 +4657,11 @@ async def test_validate_statistics_unit_change_no_device_class( "statistic_id": "sensor.test", "supported_unit": supported_unit, }, - "type": "units_changed", + "type": UNITS_CHANGED_ISSUE, } ], } - await assert_validation_result(hass, client, expected, {"units_changed"}) + await assert_validation_result(hass, client, expected, {UNITS_CHANGED_ISSUE}) # Unavailable state - empty response hass.states.async_set( @@ -4769,11 +4773,11 @@ async def test_validate_statistics_state_class_removed( "sensor.test": [ { "data": {"statistic_id": "sensor.test"}, - "type": "state_class_removed", + "type": STATE_CLASS_REMOVED_ISSUE, } ], } - await assert_validation_result(hass, client, expected, {"state_class_removed"}) + await assert_validation_result(hass, client, expected, {STATE_CLASS_REMOVED_ISSUE}) # Unavailable state - empty response hass.states.async_set( @@ -4837,11 +4841,11 @@ async def test_validate_statistics_state_class_removed_issue_cleaned_up( "sensor.test": [ { "data": {"statistic_id": "sensor.test"}, - "type": "state_class_removed", + "type": STATE_CLASS_REMOVED_ISSUE, } ], } - await assert_validation_result(hass, client, expected, {"state_class_removed"}) + await assert_validation_result(hass, client, expected, {STATE_CLASS_REMOVED_ISSUE}) # Remove the statistics - empty response get_instance(hass).async_clear_statistics(["sensor.test"]) @@ -5086,11 +5090,11 @@ async def test_validate_statistics_unit_change_no_conversion( "statistic_id": "sensor.test", "supported_unit": unit1, }, - "type": "units_changed", + "type": UNITS_CHANGED_ISSUE, } ], } - await assert_validation_result(hass, client, expected, {"units_changed"}) + await assert_validation_result(hass, client, expected, {UNITS_CHANGED_ISSUE}) # Unavailable state - empty response hass.states.async_set( @@ -5267,11 +5271,11 @@ async def test_validate_statistics_unit_change_equivalent_units_2( "statistic_id": "sensor.test", "supported_unit": supported_unit, }, - "type": "units_changed", + "type": UNITS_CHANGED_ISSUE, } ], } - await assert_validation_result(hass, client, expected, {"units_changed"}) + await assert_validation_result(hass, client, expected, {UNITS_CHANGED_ISSUE}) # Run statistics one hour later, metadata will not be updated await async_recorder_block_till_done(hass) @@ -5280,7 +5284,7 @@ async def test_validate_statistics_unit_change_equivalent_units_2( await assert_statistic_ids( hass, [{"statistic_id": "sensor.test", "unit_of_measurement": unit1}] ) - await assert_validation_result(hass, client, expected, {"units_changed"}) + await assert_validation_result(hass, client, expected, {UNITS_CHANGED_ISSUE}) async def test_validate_statistics_other_domain( @@ -5369,7 +5373,7 @@ async def test_update_statistics_issues( now = await one_hour_stats(now) expected = { "state_class_removed_sensor.test": { - "issue_type": "state_class_removed", + "issue_type": STATE_CLASS_REMOVED_ISSUE, "statistic_id": "sensor.test", } } @@ -5573,8 +5577,8 @@ async def test_clean_up_repairs( create_issue("test", "test_issue", None) create_issue(DOMAIN, "test_issue_1", None) create_issue(DOMAIN, "test_issue_2", {"issue_type": "another_issue"}) - create_issue(DOMAIN, "test_issue_3", {"issue_type": "state_class_removed"}) - create_issue(DOMAIN, "test_issue_4", {"issue_type": "units_changed"}) + create_issue(DOMAIN, "test_issue_3", {"issue_type": STATE_CLASS_REMOVED_ISSUE}) + create_issue(DOMAIN, "test_issue_4", {"issue_type": UNITS_CHANGED_ISSUE}) # Check the issues assert set(issue_registry.issues) == { From 8bedf973828a444aca34449a845733b108a3ba85 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Wed, 26 Mar 2025 10:05:42 +0100 Subject: [PATCH 3057/3148] Remove helpers and align coding style in Shelly tests (#140080) * Cleanup hass.states method in Shelly tests (part 1) * remove helper functions and align coding style * missed * revert unwanted changes * apply review comment * apply review comment * apply review comment * apply ATTR where missing * apply walrus * add missed walrus * add walrus to entity_registry.async_get * minor tweak * align after merge --- tests/components/shelly/__init__.py | 14 - tests/components/shelly/test_binary_sensor.py | 113 +++--- tests/components/shelly/test_button.py | 10 +- tests/components/shelly/test_climate.py | 134 ++++---- tests/components/shelly/test_config_flow.py | 6 +- tests/components/shelly/test_coordinator.py | 85 +++-- tests/components/shelly/test_cover.py | 65 ++-- tests/components/shelly/test_event.py | 41 +-- tests/components/shelly/test_init.py | 43 ++- tests/components/shelly/test_light.py | 226 ++++++------ tests/components/shelly/test_number.py | 51 +-- tests/components/shelly/test_select.py | 20 +- tests/components/shelly/test_sensor.py | 321 +++++++++--------- tests/components/shelly/test_switch.py | 107 +++--- tests/components/shelly/test_text.py | 20 +- tests/components/shelly/test_update.py | 77 +++-- tests/components/shelly/test_valve.py | 18 +- 17 files changed, 693 insertions(+), 658 deletions(-) diff --git a/tests/components/shelly/__init__.py b/tests/components/shelly/__init__.py index ddece280d8a..ec2d3d2c829 100644 --- a/tests/components/shelly/__init__.py +++ b/tests/components/shelly/__init__.py @@ -143,20 +143,6 @@ def get_entity( ) -def get_entity_state(hass: HomeAssistant, entity_id: str) -> str: - """Return entity state.""" - entity = hass.states.get(entity_id) - assert entity - return entity.state - - -def get_entity_attribute(hass: HomeAssistant, entity_id: str, attribute: str) -> str: - """Return entity attribute.""" - entity = hass.states.get(entity_id) - assert entity - return entity.attributes[attribute] - - def register_device( device_registry: DeviceRegistry, config_entry: ConfigEntry ) -> DeviceEntry: diff --git a/tests/components/shelly/test_binary_sensor.py b/tests/components/shelly/test_binary_sensor.py index 1e7c54320e8..ea3a7d5f3d2 100644 --- a/tests/components/shelly/test_binary_sensor.py +++ b/tests/components/shelly/test_binary_sensor.py @@ -39,15 +39,16 @@ async def test_block_binary_sensor( entity_id = f"{BINARY_SENSOR_DOMAIN}.test_name_channel_1_overpowering" await init_integration(hass, 1) - assert hass.states.get(entity_id).state == STATE_OFF + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_OFF monkeypatch.setattr(mock_block_device.blocks[RELAY_BLOCK_ID], "overpower", 1) mock_block_device.mock_update() - assert hass.states.get(entity_id).state == STATE_ON + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_ON - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-relay_0-overpower" @@ -61,19 +62,18 @@ async def test_block_binary_sensor_extra_state_attr( entity_id = f"{BINARY_SENSOR_DOMAIN}.test_name_gas" await init_integration(hass, 1) - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.state == STATE_ON assert state.attributes.get("detected") == "mild" monkeypatch.setattr(mock_block_device.blocks[SENSOR_BLOCK_ID], "gas", "none") mock_block_device.mock_update() - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.state == STATE_OFF assert state.attributes.get("detected") == "none" - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-sensor_0-gas" @@ -89,15 +89,16 @@ async def test_block_rest_binary_sensor( monkeypatch.setitem(mock_block_device.status, "cloud", {"connected": False}) await init_integration(hass, 1) - assert hass.states.get(entity_id).state == STATE_OFF + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_OFF monkeypatch.setitem(mock_block_device.status["cloud"], "connected", True) await mock_rest_update(hass, freezer) - assert hass.states.get(entity_id).state == STATE_ON + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_ON - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-cloud" @@ -115,20 +116,22 @@ async def test_block_rest_binary_sensor_connected_battery_devices( monkeypatch.setitem(mock_block_device.settings["coiot"], "update_period", 3600) await init_integration(hass, 1, model=MODEL_MOTION) - assert hass.states.get(entity_id).state == STATE_OFF + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_OFF monkeypatch.setitem(mock_block_device.status["cloud"], "connected", True) # Verify no update on fast intervals await mock_rest_update(hass, freezer) - assert hass.states.get(entity_id).state == STATE_OFF + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_OFF # Verify update on slow intervals await mock_rest_update(hass, freezer, seconds=UPDATE_PERIOD_MULTIPLIER * 3600) - assert hass.states.get(entity_id).state == STATE_ON + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_ON - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-cloud" @@ -149,15 +152,16 @@ async def test_block_sleeping_binary_sensor( mock_block_device.mock_online() await hass.async_block_till_done(wait_background_tasks=True) - assert hass.states.get(entity_id).state == STATE_OFF + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_OFF monkeypatch.setattr(mock_block_device.blocks[SENSOR_BLOCK_ID], "motion", 1) mock_block_device.mock_update() - assert hass.states.get(entity_id).state == STATE_ON + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_ON - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-sensor_0-motion" @@ -183,14 +187,16 @@ async def test_block_restored_sleeping_binary_sensor( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert hass.states.get(entity_id).state == STATE_ON + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_ON # Make device online monkeypatch.setattr(mock_block_device, "initialized", True) mock_block_device.mock_online() await hass.async_block_till_done(wait_background_tasks=True) - assert hass.states.get(entity_id).state == STATE_OFF + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_OFF async def test_block_restored_sleeping_binary_sensor_no_last_state( @@ -214,14 +220,16 @@ async def test_block_restored_sleeping_binary_sensor_no_last_state( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert hass.states.get(entity_id).state == STATE_UNKNOWN + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_UNKNOWN # Make device online monkeypatch.setattr(mock_block_device, "initialized", True) mock_block_device.mock_online() await hass.async_block_till_done(wait_background_tasks=True) - assert hass.states.get(entity_id).state == STATE_OFF + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_OFF async def test_rpc_binary_sensor( @@ -234,17 +242,18 @@ async def test_rpc_binary_sensor( entity_id = f"{BINARY_SENSOR_DOMAIN}.test_cover_0_overpowering" await init_integration(hass, 2) - assert hass.states.get(entity_id).state == STATE_OFF + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_OFF mutate_rpc_device_status( monkeypatch, mock_rpc_device, "cover:0", "errors", "overpower" ) mock_rpc_device.mock_update() - assert hass.states.get(entity_id).state == STATE_ON + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_ON - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-cover:0-overpower" @@ -290,20 +299,22 @@ async def test_rpc_sleeping_binary_sensor( mock_rpc_device.mock_online() await hass.async_block_till_done(wait_background_tasks=True) - assert hass.states.get(entity_id).state == STATE_OFF + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_OFF mutate_rpc_device_status(monkeypatch, mock_rpc_device, "cloud", "connected", True) mock_rpc_device.mock_update() - assert hass.states.get(entity_id).state == STATE_ON - - # test external power sensor - state = hass.states.get("binary_sensor.test_name_external_power") - assert state + assert (state := hass.states.get(entity_id)) assert state.state == STATE_ON - entry = entity_registry.async_get("binary_sensor.test_name_external_power") - assert entry + # test external power sensor + assert (state := hass.states.get("binary_sensor.test_name_external_power")) + assert state.state == STATE_ON + + assert ( + entry := entity_registry.async_get("binary_sensor.test_name_external_power") + ) assert entry.unique_id == "123456789ABC-devicepower:0-external_power" @@ -331,14 +342,16 @@ async def test_rpc_restored_sleeping_binary_sensor( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert hass.states.get(entity_id).state == STATE_ON + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_ON # Make device online monkeypatch.setattr(mock_rpc_device, "initialized", True) mock_rpc_device.mock_update() await hass.async_block_till_done() - assert hass.states.get(entity_id).state == STATE_OFF + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_OFF async def test_rpc_restored_sleeping_binary_sensor_no_last_state( @@ -364,7 +377,8 @@ async def test_rpc_restored_sleeping_binary_sensor_no_last_state( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert hass.states.get(entity_id).state == STATE_UNKNOWN + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_UNKNOWN # Make device online monkeypatch.setattr(mock_rpc_device, "initialized", True) @@ -375,7 +389,8 @@ async def test_rpc_restored_sleeping_binary_sensor_no_last_state( mock_rpc_device.mock_update() await hass.async_block_till_done() - assert hass.states.get(entity_id).state == STATE_OFF + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_OFF @pytest.mark.parametrize( @@ -407,17 +422,17 @@ async def test_rpc_device_virtual_binary_sensor( await init_integration(hass, 3) - state = hass.states.get(entity_id) - assert state + assert (state := hass.states.get(entity_id)) assert state.state == STATE_ON - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-boolean:203-boolean" monkeypatch.setitem(mock_rpc_device.status["boolean:203"], "value", False) mock_rpc_device.mock_update() - assert hass.states.get(entity_id).state == STATE_OFF + + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_OFF async def test_rpc_remove_virtual_binary_sensor_when_mode_toggle( @@ -450,8 +465,7 @@ async def test_rpc_remove_virtual_binary_sensor_when_mode_toggle( await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - entry = entity_registry.async_get(entity_id) - assert not entry + assert entity_registry.async_get(entity_id) is None async def test_rpc_remove_virtual_binary_sensor_when_orphaned( @@ -475,8 +489,7 @@ async def test_rpc_remove_virtual_binary_sensor_when_orphaned( await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - entry = entity_registry.async_get(entity_id) - assert not entry + assert entity_registry.async_get(entity_id) is None async def test_blu_trv_binary_sensor_entity( diff --git a/tests/components/shelly/test_button.py b/tests/components/shelly/test_button.py index edf11b0e163..2057076d18b 100644 --- a/tests/components/shelly/test_button.py +++ b/tests/components/shelly/test_button.py @@ -27,10 +27,10 @@ async def test_block_button( entity_id = "button.test_name_reboot" # reboot button - assert hass.states.get(entity_id).state == STATE_UNKNOWN + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_UNKNOWN - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC_reboot" await hass.services.async_call( @@ -54,10 +54,10 @@ async def test_rpc_button( entity_id = "button.test_name_reboot" # reboot button - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state == snapshot(name=f"{entity_id}-state") - entry = entity_registry.async_get(entity_id) + assert (entry := entity_registry.async_get(entity_id)) assert entry == snapshot(name=f"{entity_id}-entry") await hass.services.async_call( diff --git a/tests/components/shelly/test_climate.py b/tests/components/shelly/test_climate.py index c0bb47bfab6..b2135fb38af 100644 --- a/tests/components/shelly/test_climate.py +++ b/tests/components/shelly/test_climate.py @@ -44,13 +44,7 @@ from homeassistant.helpers.device_registry import DeviceRegistry from homeassistant.helpers.entity_registry import EntityRegistry from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM -from . import ( - MOCK_MAC, - get_entity_attribute, - init_integration, - register_device, - register_entity, -) +from . import MOCK_MAC, init_integration, register_device, register_entity from .conftest import MOCK_STATUS_COAP from tests.common import mock_restore_cache, mock_restore_cache_with_extra_data @@ -86,11 +80,9 @@ async def test_climate_hvac_mode( await hass.async_block_till_done(wait_background_tasks=True) # Test initial hvac mode - off - state = hass.states.get(ENTITY_ID) - assert state == snapshot(name=f"{ENTITY_ID}-state") + assert hass.states.get(ENTITY_ID) == snapshot(name=f"{ENTITY_ID}-state") - entry = entity_registry.async_get(ENTITY_ID) - assert entry == snapshot(name=f"{ENTITY_ID}-entry") + assert entity_registry.async_get(ENTITY_ID) == snapshot(name=f"{ENTITY_ID}-entry") # Test set hvac mode heat await hass.services.async_call( @@ -105,7 +97,8 @@ async def test_climate_hvac_mode( monkeypatch.setattr(mock_block_device.blocks[SENSOR_BLOCK_ID], "targetTemp", 20.0) mock_block_device.mock_update() - state = hass.states.get(ENTITY_ID) + + assert (state := hass.states.get(ENTITY_ID)) assert state.state == HVACMode.HEAT # Test set hvac mode off @@ -122,13 +115,13 @@ async def test_climate_hvac_mode( monkeypatch.setattr(mock_block_device.blocks[SENSOR_BLOCK_ID], "targetTemp", 4.0) mock_block_device.mock_update() - state = hass.states.get(ENTITY_ID) + assert (state := hass.states.get(ENTITY_ID)) assert state.state == HVACMode.OFF # Test unavailable on error monkeypatch.setattr(mock_block_device.blocks[DEVICE_BLOCK_ID], "valveError", 1) mock_block_device.mock_update() - state = hass.states.get(ENTITY_ID) + assert (state := hass.states.get(ENTITY_ID)) assert state.state == STATE_UNAVAILABLE @@ -145,7 +138,7 @@ async def test_climate_set_temperature( mock_block_device.mock_online() await hass.async_block_till_done(wait_background_tasks=True) - state = hass.states.get(ENTITY_ID) + assert (state := hass.states.get(ENTITY_ID)) assert state.state == HVACMode.OFF assert state.attributes[ATTR_TEMPERATURE] == 4 @@ -199,7 +192,7 @@ async def test_climate_set_preset_mode( mock_block_device.mock_online() await hass.async_block_till_done(wait_background_tasks=True) - state = hass.states.get(ENTITY_ID) + assert (state := hass.states.get(ENTITY_ID)) assert state.attributes[ATTR_PRESET_MODE] == PRESET_NONE # Test set Profile2 @@ -217,7 +210,7 @@ async def test_climate_set_preset_mode( monkeypatch.setattr(mock_block_device.blocks[DEVICE_BLOCK_ID], "mode", 2) mock_block_device.mock_update() - state = hass.states.get(ENTITY_ID) + assert (state := hass.states.get(ENTITY_ID)) assert state.attributes[ATTR_PRESET_MODE] == "Profile2" # Set preset to none @@ -236,7 +229,7 @@ async def test_climate_set_preset_mode( monkeypatch.setattr(mock_block_device.blocks[DEVICE_BLOCK_ID], "mode", 0) mock_block_device.mock_update() - state = hass.states.get(ENTITY_ID) + assert (state := hass.states.get(ENTITY_ID)) assert state.attributes[ATTR_PRESET_MODE] == PRESET_NONE @@ -271,23 +264,26 @@ async def test_block_restored_climate( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert hass.states.get(entity_id).state == HVACMode.OFF - assert hass.states.get(entity_id).attributes.get("temperature") == 4.0 + assert (state := hass.states.get(entity_id)) + assert state.state == HVACMode.OFF + assert state.attributes.get(ATTR_TEMPERATURE) == 4.0 # Partial update, should not change state mock_block_device.mock_update() await hass.async_block_till_done() - assert hass.states.get(entity_id).state == HVACMode.OFF - assert hass.states.get(entity_id).attributes.get("temperature") == 4.0 + assert (state := hass.states.get(entity_id)) + assert state.state == HVACMode.OFF + assert state.attributes.get(ATTR_TEMPERATURE) == 4.0 # Make device online monkeypatch.setattr(mock_block_device, "initialized", True) mock_block_device.mock_online() await hass.async_block_till_done(wait_background_tasks=True) - assert hass.states.get(entity_id).state == HVACMode.OFF - assert hass.states.get(entity_id).attributes.get("temperature") == 4.0 + assert (state := hass.states.get(entity_id)) + assert state.state == HVACMode.OFF + assert state.attributes.get(ATTR_TEMPERATURE) == 4.0 # Test set hvac mode heat, target temp should be set to last target temp (22) await hass.services.async_call( @@ -302,9 +298,10 @@ async def test_block_restored_climate( monkeypatch.setattr(mock_block_device.blocks[SENSOR_BLOCK_ID], "targetTemp", 22.0) mock_block_device.mock_update() - state = hass.states.get(ENTITY_ID) + + assert (state := hass.states.get(entity_id)) assert state.state == HVACMode.HEAT - assert hass.states.get(entity_id).attributes.get("temperature") == 22.0 + assert state.attributes.get(ATTR_TEMPERATURE) == 22.0 async def test_block_restored_climate_us_customary( @@ -339,17 +336,19 @@ async def test_block_restored_climate_us_customary( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert hass.states.get(entity_id).state == HVACMode.OFF - assert hass.states.get(entity_id).attributes.get("temperature") == 39 - assert hass.states.get(entity_id).attributes.get("current_temperature") == 67 + assert (state := hass.states.get(entity_id)) + assert state.state == HVACMode.OFF + assert state.attributes.get(ATTR_TEMPERATURE) == 39 + assert state.attributes.get(ATTR_CURRENT_TEMPERATURE) == 67 # Partial update, should not change state mock_block_device.mock_update() await hass.async_block_till_done() - assert hass.states.get(entity_id).state == HVACMode.OFF - assert hass.states.get(entity_id).attributes.get("temperature") == 39 - assert hass.states.get(entity_id).attributes.get("current_temperature") == 67 + assert (state := hass.states.get(entity_id)) + assert state.state == HVACMode.OFF + assert state.attributes.get(ATTR_TEMPERATURE) == 39 + assert state.attributes.get(ATTR_CURRENT_TEMPERATURE) == 67 # Make device online monkeypatch.setattr(mock_block_device, "initialized", True) @@ -358,9 +357,10 @@ async def test_block_restored_climate_us_customary( mock_block_device.mock_online() await hass.async_block_till_done(wait_background_tasks=True) - assert hass.states.get(entity_id).state == HVACMode.OFF - assert hass.states.get(entity_id).attributes.get("temperature") == 39 - assert hass.states.get(entity_id).attributes.get("current_temperature") == 65 + assert (state := hass.states.get(entity_id)) + assert state.state == HVACMode.OFF + assert state.attributes.get(ATTR_TEMPERATURE) == 39 + assert state.attributes.get(ATTR_CURRENT_TEMPERATURE) == 65 # Test set hvac mode heat, target temp should be set to last target temp (10.0/50) await hass.services.async_call( @@ -375,9 +375,10 @@ async def test_block_restored_climate_us_customary( monkeypatch.setattr(mock_block_device.blocks[SENSOR_BLOCK_ID], "targetTemp", 10.0) mock_block_device.mock_update() - state = hass.states.get(ENTITY_ID) + + assert (state := hass.states.get(entity_id)) assert state.state == HVACMode.HEAT - assert hass.states.get(entity_id).attributes.get("temperature") == 50 + assert state.attributes.get(ATTR_TEMPERATURE) == 50 async def test_block_restored_climate_unavailable( @@ -405,7 +406,8 @@ async def test_block_restored_climate_unavailable( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert hass.states.get(entity_id).state == HVACMode.OFF + assert (state := hass.states.get(entity_id)) + assert state.state == HVACMode.OFF async def test_block_restored_climate_set_preset_before_online( @@ -433,7 +435,8 @@ async def test_block_restored_climate_set_preset_before_online( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert hass.states.get(entity_id).state == HVACMode.HEAT + assert (state := hass.states.get(entity_id)) + assert state.state == HVACMode.HEAT with pytest.raises(ServiceValidationError): await hass.services.async_call( @@ -615,16 +618,14 @@ async def test_rpc_climate_hvac_mode( await init_integration(hass, 2, model=MODEL_WALL_DISPLAY) - state = hass.states.get(entity_id) - assert state == snapshot(name=f"{entity_id}-state") + assert (state := hass.states.get(entity_id)) == snapshot(name=f"{entity_id}-state") - entry = entity_registry.async_get(entity_id) - assert entry == snapshot(name=f"{entity_id}-entry") + assert entity_registry.async_get(entity_id) == snapshot(name=f"{entity_id}-entry") monkeypatch.setitem(mock_rpc_device.status["thermostat:0"], "output", False) mock_rpc_device.mock_update() - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.IDLE assert state.attributes[ATTR_CURRENT_HUMIDITY] == 44.4 @@ -640,7 +641,7 @@ async def test_rpc_climate_hvac_mode( mock_rpc_device.call_rpc.assert_called_once_with( "Thermostat.SetConfig", {"config": {"id": 0, "enable": False}} ) - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.state == HVACMode.OFF @@ -658,15 +659,14 @@ async def test_rpc_climate_without_humidity( await init_integration(hass, 2, model=MODEL_WALL_DISPLAY) - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.state == HVACMode.HEAT assert state.attributes[ATTR_TEMPERATURE] == 23 assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 12.3 assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.HEATING assert ATTR_CURRENT_HUMIDITY not in state.attributes - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-thermostat:0" @@ -678,7 +678,7 @@ async def test_rpc_climate_set_temperature( await init_integration(hass, 2, model=MODEL_WALL_DISPLAY) - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.attributes[ATTR_TEMPERATURE] == 23 monkeypatch.setitem(mock_rpc_device.status["thermostat:0"], "target_C", 28) @@ -693,7 +693,7 @@ async def test_rpc_climate_set_temperature( mock_rpc_device.call_rpc.assert_called_once_with( "Thermostat.SetConfig", {"config": {"id": 0, "target_C": 28}} ) - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.attributes[ATTR_TEMPERATURE] == 28 @@ -708,7 +708,7 @@ async def test_rpc_climate_hvac_mode_cool( await init_integration(hass, 2, model=MODEL_WALL_DISPLAY) - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.state == HVACMode.COOL assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.COOLING @@ -757,19 +757,16 @@ async def test_wall_display_thermostat_mode_external_actuator( await init_integration(hass, 2, model=MODEL_WALL_DISPLAY) # the switch entity should be created - state = hass.states.get(switch_entity_id) - assert state + assert (state := hass.states.get(switch_entity_id)) assert state.state == STATE_ON assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 1 # the climate entity should be created - state = hass.states.get(climate_entity_id) - assert state + assert (state := hass.states.get(climate_entity_id)) assert state.state == HVACMode.HEAT assert len(hass.states.async_entity_ids(CLIMATE_DOMAIN)) == 1 - entry = entity_registry.async_get(climate_entity_id) - assert entry + assert (entry := entity_registry.async_get(climate_entity_id)) assert entry.unique_id == "123456789ABC-thermostat:0" @@ -787,13 +784,9 @@ async def test_blu_trv_climate_set_temperature( await init_integration(hass, 3, model=MODEL_BLU_GATEWAY_G3) - state = hass.states.get(entity_id) - assert state == snapshot(name=f"{entity_id}-state") + assert (state := hass.states.get(entity_id)) == snapshot(name=f"{entity_id}-state") - entry = entity_registry.async_get(entity_id) - assert entry == snapshot(name=f"{entity_id}-entry") - - assert get_entity_attribute(hass, entity_id, ATTR_TEMPERATURE) == 17.1 + assert entity_registry.async_get(entity_id) == snapshot(name=f"{entity_id}-entry") monkeypatch.setitem( mock_blu_trv.status[f"{BLU_TRV_IDENTIFIER}:200"], "target_C", 28 @@ -816,7 +809,8 @@ async def test_blu_trv_climate_set_temperature( BLU_TRV_TIMEOUT, ) - assert get_entity_attribute(hass, entity_id, ATTR_TEMPERATURE) == 28 + assert (state := hass.states.get(entity_id)) + assert state.attributes[ATTR_TEMPERATURE] == 28 async def test_blu_trv_climate_disabled( @@ -831,14 +825,16 @@ async def test_blu_trv_climate_disabled( await init_integration(hass, 3, model=MODEL_BLU_GATEWAY_G3) - assert get_entity_attribute(hass, entity_id, ATTR_TEMPERATURE) == 17.1 + assert (state := hass.states.get(entity_id)) + assert state.attributes[ATTR_TEMPERATURE] == 17.1 monkeypatch.setitem( mock_blu_trv.config[f"{BLU_TRV_IDENTIFIER}:200"], "enable", False ) mock_blu_trv.mock_update() - assert get_entity_attribute(hass, entity_id, ATTR_TEMPERATURE) is None + assert (state := hass.states.get(entity_id)) + assert state.attributes[ATTR_TEMPERATURE] is None async def test_blu_trv_climate_hvac_action( @@ -853,9 +849,11 @@ async def test_blu_trv_climate_hvac_action( await init_integration(hass, 3, model=MODEL_BLU_GATEWAY_G3) - assert get_entity_attribute(hass, entity_id, ATTR_HVAC_ACTION) == HVACAction.IDLE + assert (state := hass.states.get(entity_id)) + assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.IDLE monkeypatch.setitem(mock_blu_trv.status[f"{BLU_TRV_IDENTIFIER}:200"], "pos", 10) mock_blu_trv.mock_update() - assert get_entity_attribute(hass, entity_id, ATTR_HVAC_ACTION) == HVACAction.HEATING + assert (state := hass.states.get(entity_id)) + assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.HEATING diff --git a/tests/components/shelly/test_config_flow.py b/tests/components/shelly/test_config_flow.py index 0b2d355cfd8..5d8e09d0b56 100644 --- a/tests/components/shelly/test_config_flow.py +++ b/tests/components/shelly/test_config_flow.py @@ -1080,7 +1080,7 @@ async def test_options_flow_ble(hass: HomeAssistant, mock_rpc_device: Mock) -> N await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["data"][CONF_BLE_SCANNER_MODE] == BLEScannerMode.DISABLED + assert result["data"][CONF_BLE_SCANNER_MODE] is BLEScannerMode.DISABLED result = await hass.config_entries.options.async_init(entry.entry_id) assert result["type"] is FlowResultType.FORM @@ -1096,7 +1096,7 @@ async def test_options_flow_ble(hass: HomeAssistant, mock_rpc_device: Mock) -> N await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["data"][CONF_BLE_SCANNER_MODE] == BLEScannerMode.ACTIVE + assert result["data"][CONF_BLE_SCANNER_MODE] is BLEScannerMode.ACTIVE result = await hass.config_entries.options.async_init(entry.entry_id) assert result["type"] is FlowResultType.FORM @@ -1112,7 +1112,7 @@ async def test_options_flow_ble(hass: HomeAssistant, mock_rpc_device: Mock) -> N await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["data"][CONF_BLE_SCANNER_MODE] == BLEScannerMode.PASSIVE + assert result["data"][CONF_BLE_SCANNER_MODE] is BLEScannerMode.PASSIVE await hass.config_entries.async_unload(entry.entry_id) diff --git a/tests/components/shelly/test_coordinator.py b/tests/components/shelly/test_coordinator.py index 55a1d8958cd..27581b4d7c6 100644 --- a/tests/components/shelly/test_coordinator.py +++ b/tests/components/shelly/test_coordinator.py @@ -32,7 +32,6 @@ from homeassistant.helpers import device_registry as dr, issue_registry as ir from . import ( MOCK_MAC, - get_entity_state, init_integration, inject_rpc_device_event, mock_polling_rpc_update, @@ -72,7 +71,7 @@ async def test_block_reload_on_cfg_change( async_fire_time_changed(hass) await hass.async_block_till_done() - assert hass.states.get("switch.test_name_channel_1") is not None + assert hass.states.get("switch.test_name_channel_1") # Generate config change from switch to light monkeypatch.setitem( @@ -82,7 +81,7 @@ async def test_block_reload_on_cfg_change( mock_block_device.mock_update() await hass.async_block_till_done() - assert hass.states.get("switch.test_name_channel_1") is not None + assert hass.states.get("switch.test_name_channel_1") # Wait for debouncer freezer.tick(timedelta(seconds=ENTRY_RELOAD_COOLDOWN)) @@ -114,14 +113,14 @@ async def test_block_no_reload_on_bulb_changes( mock_block_device.mock_update() await hass.async_block_till_done() - assert hass.states.get("switch.test_name_channel_1") is not None + assert hass.states.get("switch.test_name_channel_1") # Wait for debouncer freezer.tick(timedelta(seconds=ENTRY_RELOAD_COOLDOWN)) async_fire_time_changed(hass) await hass.async_block_till_done() - assert hass.states.get("switch.test_name_channel_1") is not None + assert hass.states.get("switch.test_name_channel_1") # Test no reload on effect change monkeypatch.setattr(mock_block_device.blocks[LIGHT_BLOCK_ID], "effect", 1) @@ -129,14 +128,14 @@ async def test_block_no_reload_on_bulb_changes( mock_block_device.mock_update() await hass.async_block_till_done() - assert hass.states.get("switch.test_name_channel_1") is not None + assert hass.states.get("switch.test_name_channel_1") # Wait for debouncer freezer.tick(timedelta(seconds=ENTRY_RELOAD_COOLDOWN)) async_fire_time_changed(hass) await hass.async_block_till_done() - assert hass.states.get("switch.test_name_channel_1") is not None + assert hass.states.get("switch.test_name_channel_1") async def test_block_polling_auth_error( @@ -245,14 +244,16 @@ async def test_block_polling_connection_error( ) await init_integration(hass, 1) - assert get_entity_state(hass, "switch.test_name_channel_1") == STATE_ON + assert (state := hass.states.get("switch.test_name_channel_1")) + assert state.state == STATE_ON # Move time to generate polling freezer.tick(timedelta(seconds=UPDATE_PERIOD_MULTIPLIER * 15)) async_fire_time_changed(hass) await hass.async_block_till_done() - assert get_entity_state(hass, "switch.test_name_channel_1") == STATE_UNAVAILABLE + assert (state := hass.states.get("switch.test_name_channel_1")) + assert state.state == STATE_UNAVAILABLE @pytest.mark.parametrize("exc", [DeviceConnectionError, MacAddressMismatchError]) @@ -270,12 +271,14 @@ async def test_block_rest_update_connection_error( await init_integration(hass, 1) await mock_rest_update(hass, freezer) - assert get_entity_state(hass, entity_id) == STATE_ON + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_ON monkeypatch.setattr(mock_block_device, "update_shelly", AsyncMock(side_effect=exc)) await mock_rest_update(hass, freezer) - assert get_entity_state(hass, entity_id) == STATE_UNAVAILABLE + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_UNAVAILABLE async def test_block_sleeping_device_no_periodic_updates( @@ -297,14 +300,16 @@ async def test_block_sleeping_device_no_periodic_updates( mock_block_device.mock_online() await hass.async_block_till_done(wait_background_tasks=True) - assert get_entity_state(hass, entity_id) == "22.1" + assert (state := hass.states.get(entity_id)) + assert state.state == "22.1" # Move time to generate polling freezer.tick(timedelta(seconds=UPDATE_PERIOD_MULTIPLIER * 3600)) async_fire_time_changed(hass) await hass.async_block_till_done() - assert get_entity_state(hass, entity_id) == STATE_UNAVAILABLE + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_UNAVAILABLE async def test_block_device_push_updates_failure( @@ -416,7 +421,7 @@ async def test_rpc_reload_on_cfg_change( ) await hass.async_block_till_done() - assert hass.states.get("switch.test_switch_0") is not None + assert hass.states.get("switch.test_switch_0") # Wait for debouncer freezer.tick(timedelta(seconds=ENTRY_RELOAD_COOLDOWN)) @@ -596,14 +601,16 @@ async def test_rpc_sleeping_device_no_periodic_updates( mock_rpc_device.mock_online() await hass.async_block_till_done(wait_background_tasks=True) - assert get_entity_state(hass, entity_id) == "22.9" + assert (state := hass.states.get(entity_id)) + assert state.state == "22.9" # Move time to generate polling freezer.tick(timedelta(seconds=UPDATE_PERIOD_MULTIPLIER * 1000)) async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) - assert get_entity_state(hass, entity_id) is STATE_UNAVAILABLE + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_UNAVAILABLE async def test_rpc_sleeping_device_firmware_unsupported( @@ -716,7 +723,8 @@ async def test_rpc_reconnect_error( monkeypatch.setitem(mock_rpc_device.status["sys"], "relay_in_thermostat", False) await init_integration(hass, 2) - assert get_entity_state(hass, "switch.test_switch_0") == STATE_ON + assert (state := hass.states.get("switch.test_switch_0")) + assert state.state == STATE_ON monkeypatch.setattr(mock_rpc_device, "connected", False) monkeypatch.setattr(mock_rpc_device, "initialize", AsyncMock(side_effect=exc)) @@ -726,7 +734,8 @@ async def test_rpc_reconnect_error( async_fire_time_changed(hass) await hass.async_block_till_done() - assert get_entity_state(hass, "switch.test_switch_0") == STATE_UNAVAILABLE + assert (state := hass.states.get("switch.test_switch_0")) + assert state.state == STATE_UNAVAILABLE async def test_rpc_error_running_connected_events( @@ -748,14 +757,17 @@ async def test_rpc_error_running_connected_events( ) assert "Error running connected events for device" in caplog.text - assert get_entity_state(hass, "switch.test_switch_0") == STATE_UNAVAILABLE + + assert (state := hass.states.get("switch.test_switch_0")) + assert state.state == STATE_UNAVAILABLE # Move time to generate reconnect without error freezer.tick(timedelta(seconds=RPC_RECONNECT_INTERVAL)) async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) - assert get_entity_state(hass, "switch.test_switch_0") == STATE_ON + assert (state := hass.states.get("switch.test_switch_0")) + assert state.state == STATE_ON async def test_rpc_polling_connection_error( @@ -776,11 +788,13 @@ async def test_rpc_polling_connection_error( ), ) - assert get_entity_state(hass, entity_id) == "-63" + assert (state := hass.states.get(entity_id)) + assert state.state == "-63" await mock_polling_rpc_update(hass, freezer) - assert get_entity_state(hass, entity_id) == STATE_UNAVAILABLE + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_UNAVAILABLE async def test_rpc_polling_disconnected( @@ -795,11 +809,13 @@ async def test_rpc_polling_disconnected( monkeypatch.setattr(mock_rpc_device, "connected", False) - assert get_entity_state(hass, entity_id) == "-63" + assert (state := hass.states.get(entity_id)) + assert state.state == "-63" await mock_polling_rpc_update(hass, freezer) - assert get_entity_state(hass, entity_id) == STATE_UNAVAILABLE + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_UNAVAILABLE async def test_rpc_update_entry_fw_ver( @@ -903,7 +919,8 @@ async def test_block_sleeping_device_connection_error( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert get_entity_state(hass, entity_id) == STATE_ON + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_ON # Make device online event with connection error monkeypatch.setattr( @@ -917,7 +934,8 @@ async def test_block_sleeping_device_connection_error( await hass.async_block_till_done(wait_background_tasks=True) assert "Error connecting to Shelly device" in caplog.text - assert get_entity_state(hass, entity_id) == STATE_ON + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_ON # Move time to generate sleep period update freezer.tick(timedelta(seconds=sleep_period * UPDATE_PERIOD_MULTIPLIER)) @@ -925,7 +943,8 @@ async def test_block_sleeping_device_connection_error( await hass.async_block_till_done(wait_background_tasks=True) assert "Sleeping device did not update" in caplog.text - assert get_entity_state(hass, entity_id) == STATE_UNAVAILABLE + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_UNAVAILABLE async def test_rpc_sleeping_device_connection_error( @@ -954,7 +973,8 @@ async def test_rpc_sleeping_device_connection_error( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert get_entity_state(hass, entity_id) == STATE_ON + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_ON # Make device online event with connection error monkeypatch.setattr( @@ -968,7 +988,8 @@ async def test_rpc_sleeping_device_connection_error( await hass.async_block_till_done(wait_background_tasks=True) assert "Error connecting to Shelly device" in caplog.text - assert get_entity_state(hass, entity_id) == STATE_ON + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_ON # Move time to generate sleep period update freezer.tick(timedelta(seconds=sleep_period * UPDATE_PERIOD_MULTIPLIER)) @@ -976,7 +997,8 @@ async def test_rpc_sleeping_device_connection_error( await hass.async_block_till_done(wait_background_tasks=True) assert "Sleeping device did not update" in caplog.text - assert get_entity_state(hass, entity_id) == STATE_UNAVAILABLE + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_UNAVAILABLE async def test_rpc_sleeping_device_late_setup( @@ -1001,7 +1023,8 @@ async def test_rpc_sleeping_device_late_setup( monkeypatch.setattr(mock_rpc_device, "connected", True) mock_rpc_device.mock_initialized() await hass.async_block_till_done(wait_background_tasks=True) - assert hass.states.get("sensor.test_name_temperature") is not None + + assert hass.states.get("sensor.test_name_temperature") async def test_rpc_already_connected( diff --git a/tests/components/shelly/test_cover.py b/tests/components/shelly/test_cover.py index 40a364fd435..df3ab4f288d 100644 --- a/tests/components/shelly/test_cover.py +++ b/tests/components/shelly/test_cover.py @@ -47,7 +47,7 @@ async def test_block_device_services( {ATTR_ENTITY_ID: entity_id, ATTR_POSITION: 50}, blocking=True, ) - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.attributes[ATTR_CURRENT_POSITION] == 50 await hass.services.async_call( @@ -56,7 +56,8 @@ async def test_block_device_services( {ATTR_ENTITY_ID: entity_id}, blocking=True, ) - assert hass.states.get(entity_id).state == CoverState.OPENING + assert (state := hass.states.get(entity_id)) + assert state.state == CoverState.OPENING await hass.services.async_call( COVER_DOMAIN, @@ -64,7 +65,8 @@ async def test_block_device_services( {ATTR_ENTITY_ID: entity_id}, blocking=True, ) - assert hass.states.get(entity_id).state == CoverState.CLOSING + assert (state := hass.states.get(entity_id)) + assert state.state == CoverState.CLOSING await hass.services.async_call( COVER_DOMAIN, @@ -72,10 +74,10 @@ async def test_block_device_services( {ATTR_ENTITY_ID: entity_id}, blocking=True, ) - assert hass.states.get(entity_id).state == CoverState.CLOSED + assert (state := hass.states.get(entity_id)) + assert state.state == CoverState.CLOSED - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-roller_0" @@ -86,11 +88,15 @@ async def test_block_device_update( monkeypatch.setattr(mock_block_device.blocks[ROLLER_BLOCK_ID], "rollerPos", 0) await init_integration(hass, 1) - assert hass.states.get("cover.test_name").state == CoverState.CLOSED + state = hass.states.get("cover.test_name") + assert state + assert state.state == CoverState.CLOSED monkeypatch.setattr(mock_block_device.blocks[ROLLER_BLOCK_ID], "rollerPos", 100) mock_block_device.mock_update() - assert hass.states.get("cover.test_name").state == CoverState.OPEN + state = hass.states.get("cover.test_name") + assert state + assert state.state == CoverState.OPEN async def test_block_device_no_roller_blocks( @@ -99,6 +105,7 @@ async def test_block_device_no_roller_blocks( """Test block device without roller blocks.""" monkeypatch.setattr(mock_block_device.blocks[ROLLER_BLOCK_ID], "type", None) await init_integration(hass, 1) + assert hass.states.get("cover.test_name") is None @@ -118,7 +125,7 @@ async def test_rpc_device_services( {ATTR_ENTITY_ID: entity_id, ATTR_POSITION: 50}, blocking=True, ) - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.attributes[ATTR_CURRENT_POSITION] == 50 mutate_rpc_device_status( @@ -131,7 +138,9 @@ async def test_rpc_device_services( blocking=True, ) mock_rpc_device.mock_update() - assert hass.states.get(entity_id).state == CoverState.OPENING + + assert (state := hass.states.get(entity_id)) + assert state.state == CoverState.OPENING mutate_rpc_device_status( monkeypatch, mock_rpc_device, "cover:0", "state", "closing" @@ -143,7 +152,9 @@ async def test_rpc_device_services( blocking=True, ) mock_rpc_device.mock_update() - assert hass.states.get(entity_id).state == CoverState.CLOSING + + assert (state := hass.states.get(entity_id)) + assert state.state == CoverState.CLOSING mutate_rpc_device_status(monkeypatch, mock_rpc_device, "cover:0", "state", "closed") await hass.services.async_call( @@ -153,10 +164,10 @@ async def test_rpc_device_services( blocking=True, ) mock_rpc_device.mock_update() - assert hass.states.get(entity_id).state == CoverState.CLOSED + assert (state := hass.states.get(entity_id)) + assert state.state == CoverState.CLOSED - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-cover:0" @@ -166,6 +177,7 @@ async def test_rpc_device_no_cover_keys( """Test RPC device without cover keys.""" monkeypatch.delitem(mock_rpc_device.status, "cover:0") await init_integration(hass, 2) + assert hass.states.get("cover.test_cover_0") is None @@ -175,11 +187,16 @@ async def test_rpc_device_update( """Test RPC device update.""" mutate_rpc_device_status(monkeypatch, mock_rpc_device, "cover:0", "state", "closed") await init_integration(hass, 2) - assert hass.states.get("cover.test_cover_0").state == CoverState.CLOSED + + state = hass.states.get("cover.test_cover_0") + assert state + assert state.state == CoverState.CLOSED mutate_rpc_device_status(monkeypatch, mock_rpc_device, "cover:0", "state", "open") mock_rpc_device.mock_update() - assert hass.states.get("cover.test_cover_0").state == CoverState.OPEN + state = hass.states.get("cover.test_cover_0") + assert state + assert state.state == CoverState.OPEN async def test_rpc_device_no_position_control( @@ -190,7 +207,10 @@ async def test_rpc_device_no_position_control( monkeypatch, mock_rpc_device, "cover:0", "pos_control", False ) await init_integration(hass, 2) - assert hass.states.get("cover.test_cover_0").state == CoverState.OPEN + + state = hass.states.get("cover.test_cover_0") + assert state + assert state.state == CoverState.OPEN async def test_rpc_cover_tilt( @@ -212,11 +232,10 @@ async def test_rpc_cover_tilt( await init_integration(hass, 3) - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 0 - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-cover:0" await hass.services.async_call( @@ -228,7 +247,7 @@ async def test_rpc_cover_tilt( mutate_rpc_device_status(monkeypatch, mock_rpc_device, "cover:0", "slat_pos", 50) mock_rpc_device.mock_update() - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 50 await hass.services.async_call( @@ -240,7 +259,7 @@ async def test_rpc_cover_tilt( mutate_rpc_device_status(monkeypatch, mock_rpc_device, "cover:0", "slat_pos", 100) mock_rpc_device.mock_update() - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 100 await hass.services.async_call( @@ -258,5 +277,5 @@ async def test_rpc_cover_tilt( mutate_rpc_device_status(monkeypatch, mock_rpc_device, "cover:0", "slat_pos", 10) mock_rpc_device.mock_update() - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 10 diff --git a/tests/components/shelly/test_event.py b/tests/components/shelly/test_event.py index e184c154697..a5367408955 100644 --- a/tests/components/shelly/test_event.py +++ b/tests/components/shelly/test_event.py @@ -33,8 +33,7 @@ async def test_rpc_button( await init_integration(hass, 2) entity_id = "event.test_name_input_0" - state = hass.states.get(entity_id) - assert state + assert (state := hass.states.get(entity_id)) assert state.state == STATE_UNKNOWN assert state.attributes.get(ATTR_EVENT_TYPES) == unordered( ["btn_down", "btn_up", "double_push", "long_push", "single_push", "triple_push"] @@ -42,8 +41,7 @@ async def test_rpc_button( assert state.attributes.get(ATTR_EVENT_TYPE) is None assert state.attributes.get(ATTR_DEVICE_CLASS) == EventDeviceClass.BUTTON - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-input:0" inject_rpc_device_event( @@ -62,7 +60,7 @@ async def test_rpc_button( ) await hass.async_block_till_done() - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.attributes.get(ATTR_EVENT_TYPE) == "single_push" @@ -78,11 +76,9 @@ async def test_rpc_script_1_event( await init_integration(hass, 2) entity_id = "event.test_name_test_script_js" - state = hass.states.get(entity_id) - assert state == snapshot(name=f"{entity_id}-state") + assert hass.states.get(entity_id) == snapshot(name=f"{entity_id}-state") - entry = entity_registry.async_get(entity_id) - assert entry == snapshot(name=f"{entity_id}-entry") + assert entity_registry.async_get(entity_id) == snapshot(name=f"{entity_id}-entry") inject_rpc_device_event( monkeypatch, @@ -101,7 +97,7 @@ async def test_rpc_script_1_event( ) await hass.async_block_till_done() - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.attributes.get(ATTR_EVENT_TYPE) == "script_start" inject_rpc_device_event( @@ -121,7 +117,7 @@ async def test_rpc_script_1_event( ) await hass.async_block_till_done() - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.attributes.get(ATTR_EVENT_TYPE) != "unknown_event" @@ -135,11 +131,9 @@ async def test_rpc_script_2_event( await init_integration(hass, 2) entity_id = "event.test_name_test_script_2_js" - state = hass.states.get(entity_id) - assert state == snapshot(name=f"{entity_id}-state") + assert hass.states.get(entity_id) == snapshot(name=f"{entity_id}-state") - entry = entity_registry.async_get(entity_id) - assert entry == snapshot(name=f"{entity_id}-entry") + assert entity_registry.async_get(entity_id) == snapshot(name=f"{entity_id}-entry") @pytest.mark.usefixtures("entity_registry_enabled_by_default") @@ -152,11 +146,9 @@ async def test_rpc_script_ble_event( await init_integration(hass, 2) entity_id = f"event.test_name_{BLE_SCRIPT_NAME}" - state = hass.states.get(entity_id) - assert state == snapshot(name=f"{entity_id}-state") + assert hass.states.get(entity_id) == snapshot(name=f"{entity_id}-state") - entry = entity_registry.async_get(entity_id) - assert entry == snapshot(name=f"{entity_id}-entry") + assert entity_registry.async_get(entity_id) == snapshot(name=f"{entity_id}-entry") async def test_rpc_event_removal( @@ -186,15 +178,13 @@ async def test_block_event( await init_integration(hass, 1) entity_id = "event.test_name_channel_1" - state = hass.states.get(entity_id) - assert state + assert (state := hass.states.get(entity_id)) assert state.state == STATE_UNKNOWN assert state.attributes.get(ATTR_EVENT_TYPES) == unordered(["single", "long"]) assert state.attributes.get(ATTR_EVENT_TYPE) is None assert state.attributes.get(ATTR_DEVICE_CLASS) == EventDeviceClass.BUTTON - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-relay_0-1" monkeypatch.setattr( @@ -206,7 +196,7 @@ async def test_block_event( mock_block_device.mock_update() await hass.async_block_till_done() - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.attributes.get(ATTR_EVENT_TYPE) == "long" @@ -217,8 +207,7 @@ async def test_block_event_shix3_1( await init_integration(hass, 1, model=MODEL_I3) entity_id = "event.test_name_channel_1" - state = hass.states.get(entity_id) - assert state + assert (state := hass.states.get(entity_id)) assert state.attributes.get(ATTR_EVENT_TYPES) == unordered( ["double", "long", "long_single", "single", "single_long", "triple"] ) diff --git a/tests/components/shelly/test_init.py b/tests/components/shelly/test_init.py index 0cec6383461..129aa812580 100644 --- a/tests/components/shelly/test_init.py +++ b/tests/components/shelly/test_init.py @@ -307,7 +307,8 @@ async def test_sleeping_rpc_device_online_during_setup( assert "will resume when device is online" in caplog.text assert "is online (source: setup)" in caplog.text - assert hass.states.get("sensor.test_name_temperature") is not None + + assert hass.states.get("sensor.test_name_temperature") async def test_sleeping_rpc_device_offline_during_setup( @@ -336,7 +337,7 @@ async def test_sleeping_rpc_device_offline_during_setup( mock_rpc_device.mock_online() await hass.async_block_till_done(wait_background_tasks=True) - assert hass.states.get("sensor.test_name_temperature") is not None + assert hass.states.get("sensor.test_name_temperature") @pytest.mark.parametrize( @@ -360,13 +361,15 @@ async def test_entry_unload( entry = await init_integration(hass, gen) assert entry.state is ConfigEntryState.LOADED - assert hass.states.get(entity_id).state is STATE_ON + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_ON await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() assert entry.state is ConfigEntryState.NOT_LOADED - assert hass.states.get(entity_id).state is STATE_UNAVAILABLE + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_UNAVAILABLE @pytest.mark.parametrize( @@ -384,9 +387,9 @@ async def test_entry_unload_device_not_ready( mock_rpc_device: Mock, ) -> None: """Test entry unload when device is not ready.""" - entry = await init_integration(hass, gen, sleep_period=1000) - + assert (entry := await init_integration(hass, gen, sleep_period=1000)) assert entry.state is ConfigEntryState.LOADED + assert hass.states.get(entity_id) is None await hass.config_entries.async_unload(entry.entry_id) @@ -405,13 +408,15 @@ async def test_entry_unload_not_connected( with patch( "homeassistant.components.shelly.coordinator.async_stop_scanner" ) as mock_stop_scanner: - entry = await init_integration( - hass, 2, options={CONF_BLE_SCANNER_MODE: BLEScannerMode.ACTIVE} + assert ( + entry := await init_integration( + hass, 2, options={CONF_BLE_SCANNER_MODE: BLEScannerMode.ACTIVE} + ) ) - entity_id = "switch.test_switch_0" - assert entry.state is ConfigEntryState.LOADED - assert hass.states.get(entity_id).state is STATE_ON + + assert (state := hass.states.get("switch.test_switch_0")) + assert state.state == STATE_ON assert not mock_stop_scanner.call_count monkeypatch.setattr(mock_rpc_device, "connected", False) @@ -434,13 +439,15 @@ async def test_entry_unload_not_connected_but_we_think_we_are( "homeassistant.components.shelly.coordinator.async_stop_scanner", side_effect=DeviceConnectionError, ) as mock_stop_scanner: - entry = await init_integration( - hass, 2, options={CONF_BLE_SCANNER_MODE: BLEScannerMode.ACTIVE} + assert ( + entry := await init_integration( + hass, 2, options={CONF_BLE_SCANNER_MODE: BLEScannerMode.ACTIVE} + ) ) - entity_id = "switch.test_switch_0" - assert entry.state is ConfigEntryState.LOADED - assert hass.states.get(entity_id).state is STATE_ON + + assert (state := hass.states.get("switch.test_switch_0")) + assert state.state == STATE_ON assert not mock_stop_scanner.call_count monkeypatch.setattr(mock_rpc_device, "connected", False) @@ -473,7 +480,9 @@ async def test_entry_missing_gen(hass: HomeAssistant, mock_block_device: Mock) - entry = await init_integration(hass, None) assert entry.state is ConfigEntryState.LOADED - assert hass.states.get("switch.test_name_channel_1").state is STATE_ON + + assert (state := hass.states.get("switch.test_name_channel_1")) + assert state.state == STATE_ON async def test_entry_missing_port(hass: HomeAssistant) -> None: diff --git a/tests/components/shelly/test_light.py b/tests/components/shelly/test_light.py index 482821aa966..0dab06f53a9 100644 --- a/tests/components/shelly/test_light.py +++ b/tests/components/shelly/test_light.py @@ -65,18 +65,17 @@ async def test_block_device_rgbw_bulb( await init_integration(hass, 1, model=MODEL_BULB) # Test initial - state = hass.states.get(entity_id) - attributes = state.attributes + assert (state := hass.states.get(entity_id)) assert state.state == STATE_ON - assert attributes[ATTR_RGBW_COLOR] == (45, 55, 65, 70) - assert attributes[ATTR_BRIGHTNESS] == 48 - assert attributes[ATTR_SUPPORTED_COLOR_MODES] == [ + assert state.attributes[ATTR_RGBW_COLOR] == (45, 55, 65, 70) + assert state.attributes[ATTR_BRIGHTNESS] == 48 + assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ ColorMode.COLOR_TEMP, ColorMode.RGBW, ] - assert attributes[ATTR_SUPPORTED_FEATURES] == LightEntityFeature.EFFECT - assert len(attributes[ATTR_EFFECT_LIST]) == 7 - assert attributes[ATTR_EFFECT] == "Off" + assert state.attributes[ATTR_SUPPORTED_FEATURES] == LightEntityFeature.EFFECT + assert len(state.attributes[ATTR_EFFECT_LIST]) == 7 + assert state.attributes[ATTR_EFFECT] == "Off" # Turn off mock_block_device.blocks[LIGHT_BLOCK_ID].set_state.reset_mock() @@ -89,7 +88,7 @@ async def test_block_device_rgbw_bulb( mock_block_device.blocks[LIGHT_BLOCK_ID].set_state.assert_called_once_with( turn="off" ) - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.state == STATE_OFF # Turn on, RGBW = [70, 80, 90, 20], brightness = 33, effect = Flash @@ -108,13 +107,12 @@ async def test_block_device_rgbw_bulb( mock_block_device.blocks[LIGHT_BLOCK_ID].set_state.assert_called_once_with( turn="on", gain=13, brightness=13, red=70, green=80, blue=90, white=30, effect=3 ) - state = hass.states.get(entity_id) - attributes = state.attributes + assert (state := hass.states.get(entity_id)) assert state.state == STATE_ON - assert attributes[ATTR_COLOR_MODE] == ColorMode.RGBW - assert attributes[ATTR_RGBW_COLOR] == (70, 80, 90, 30) - assert attributes[ATTR_BRIGHTNESS] == 33 - assert attributes[ATTR_EFFECT] == "Flash" + assert state.attributes[ATTR_COLOR_MODE] == ColorMode.RGBW + assert state.attributes[ATTR_RGBW_COLOR] == (70, 80, 90, 30) + assert state.attributes[ATTR_BRIGHTNESS] == 33 + assert state.attributes[ATTR_EFFECT] == "Flash" # Turn on, COLOR_TEMP_KELVIN = 3500 mock_block_device.blocks[LIGHT_BLOCK_ID].set_state.reset_mock() @@ -127,14 +125,12 @@ async def test_block_device_rgbw_bulb( mock_block_device.blocks[LIGHT_BLOCK_ID].set_state.assert_called_once_with( turn="on", temp=3500, mode="white" ) - state = hass.states.get(entity_id) - attributes = state.attributes + assert (state := hass.states.get(entity_id)) assert state.state == STATE_ON - assert attributes[ATTR_COLOR_MODE] == ColorMode.COLOR_TEMP - assert attributes[ATTR_COLOR_TEMP_KELVIN] == 3500 + assert state.attributes[ATTR_COLOR_MODE] == ColorMode.COLOR_TEMP + assert state.attributes[ATTR_COLOR_TEMP_KELVIN] == 3500 - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-light_0" @@ -154,21 +150,20 @@ async def test_block_device_rgb_bulb( await init_integration(hass, 1, model=MODEL_BULB_RGBW) # Test initial - state = hass.states.get(entity_id) - attributes = state.attributes + assert (state := hass.states.get(entity_id)) assert state.state == STATE_ON - assert attributes[ATTR_RGB_COLOR] == (45, 55, 65) - assert attributes[ATTR_BRIGHTNESS] == 48 - assert attributes[ATTR_SUPPORTED_COLOR_MODES] == [ + assert state.attributes[ATTR_RGB_COLOR] == (45, 55, 65) + assert state.attributes[ATTR_BRIGHTNESS] == 48 + assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ ColorMode.COLOR_TEMP, ColorMode.RGB, ] assert ( - attributes[ATTR_SUPPORTED_FEATURES] + state.attributes[ATTR_SUPPORTED_FEATURES] == LightEntityFeature.EFFECT | LightEntityFeature.TRANSITION ) - assert len(attributes[ATTR_EFFECT_LIST]) == 4 - assert attributes[ATTR_EFFECT] == "Off" + assert len(state.attributes[ATTR_EFFECT_LIST]) == 4 + assert state.attributes[ATTR_EFFECT] == "Off" # Turn off mock_block_device.blocks[LIGHT_BLOCK_ID].set_state.reset_mock() @@ -181,7 +176,7 @@ async def test_block_device_rgb_bulb( mock_block_device.blocks[LIGHT_BLOCK_ID].set_state.assert_called_once_with( turn="off" ) - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.state == STATE_OFF # Turn on, RGB = [70, 80, 90], brightness = 33, effect = Flash @@ -200,13 +195,12 @@ async def test_block_device_rgb_bulb( mock_block_device.blocks[LIGHT_BLOCK_ID].set_state.assert_called_once_with( turn="on", gain=13, brightness=13, red=70, green=80, blue=90, effect=3 ) - state = hass.states.get(entity_id) - attributes = state.attributes + assert (state := hass.states.get(entity_id)) assert state.state == STATE_ON - assert attributes[ATTR_COLOR_MODE] == ColorMode.RGB - assert attributes[ATTR_RGB_COLOR] == (70, 80, 90) - assert attributes[ATTR_BRIGHTNESS] == 33 - assert attributes[ATTR_EFFECT] == "Flash" + assert state.attributes[ATTR_COLOR_MODE] == ColorMode.RGB + assert state.attributes[ATTR_RGB_COLOR] == (70, 80, 90) + assert state.attributes[ATTR_BRIGHTNESS] == 33 + assert state.attributes[ATTR_EFFECT] == "Flash" # Turn on, COLOR_TEMP_KELVIN = 3500 mock_block_device.blocks[LIGHT_BLOCK_ID].set_state.reset_mock() @@ -219,11 +213,10 @@ async def test_block_device_rgb_bulb( mock_block_device.blocks[LIGHT_BLOCK_ID].set_state.assert_called_once_with( turn="on", temp=3500, mode="white" ) - state = hass.states.get(entity_id) - attributes = state.attributes + assert (state := hass.states.get(entity_id)) assert state.state == STATE_ON - assert attributes[ATTR_COLOR_MODE] == ColorMode.COLOR_TEMP - assert attributes[ATTR_COLOR_TEMP_KELVIN] == 3500 + assert state.attributes[ATTR_COLOR_MODE] == ColorMode.COLOR_TEMP + assert state.attributes[ATTR_COLOR_TEMP_KELVIN] == 3500 # Turn on with unsupported effect mock_block_device.blocks[LIGHT_BLOCK_ID].set_state.reset_mock() @@ -236,14 +229,13 @@ async def test_block_device_rgb_bulb( mock_block_device.blocks[LIGHT_BLOCK_ID].set_state.assert_called_once_with( turn="on", mode="color" ) - state = hass.states.get(entity_id) - attributes = state.attributes + + assert (state := hass.states.get(entity_id)) assert state.state == STATE_ON - assert attributes[ATTR_EFFECT] == "Off" + assert state.attributes[ATTR_EFFECT] == "Off" assert "Effect 'Breath' not supported" in caplog.text - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-light_1" @@ -272,12 +264,11 @@ async def test_block_device_white_bulb( await init_integration(hass, 1, model=MODEL_VINTAGE_V2) # Test initial - state = hass.states.get(entity_id) - attributes = state.attributes + assert (state := hass.states.get(entity_id)) assert state.state == STATE_ON - assert attributes[ATTR_BRIGHTNESS] == 128 - assert attributes[ATTR_SUPPORTED_COLOR_MODES] == [ColorMode.BRIGHTNESS] - assert attributes[ATTR_SUPPORTED_FEATURES] == LightEntityFeature.TRANSITION + assert state.attributes[ATTR_BRIGHTNESS] == 128 + assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ColorMode.BRIGHTNESS] + assert state.attributes[ATTR_SUPPORTED_FEATURES] == LightEntityFeature.TRANSITION # Turn off mock_block_device.blocks[LIGHT_BLOCK_ID].set_state.reset_mock() @@ -290,7 +281,7 @@ async def test_block_device_white_bulb( mock_block_device.blocks[LIGHT_BLOCK_ID].set_state.assert_called_once_with( turn="off" ) - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.state == STATE_OFF # Turn on, brightness = 33 @@ -304,13 +295,11 @@ async def test_block_device_white_bulb( mock_block_device.blocks[LIGHT_BLOCK_ID].set_state.assert_called_once_with( turn="on", gain=13, brightness=13 ) - state = hass.states.get(entity_id) - attributes = state.attributes + assert (state := hass.states.get(entity_id)) assert state.state == STATE_ON - assert attributes[ATTR_BRIGHTNESS] == 33 + assert state.attributes[ATTR_BRIGHTNESS] == 33 - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-light_1" @@ -343,9 +332,8 @@ async def test_block_device_support_transition( await init_integration(hass, 1, model=model) # Test initial - state = hass.states.get(entity_id) - attributes = state.attributes - assert attributes[ATTR_SUPPORTED_FEATURES] & LightEntityFeature.TRANSITION + assert (state := hass.states.get(entity_id)) + assert state.attributes[ATTR_SUPPORTED_FEATURES] & LightEntityFeature.TRANSITION # Turn on, TRANSITION = 4 mock_block_device.blocks[LIGHT_BLOCK_ID].set_state.reset_mock() @@ -358,7 +346,7 @@ async def test_block_device_support_transition( mock_block_device.blocks[LIGHT_BLOCK_ID].set_state.assert_called_once_with( turn="on", transition=4000 ) - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.state == STATE_ON # Turn off, TRANSITION = 6, limit to 5000ms @@ -372,11 +360,10 @@ async def test_block_device_support_transition( mock_block_device.blocks[LIGHT_BLOCK_ID].set_state.assert_called_once_with( turn="off", transition=5000 ) - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.state == STATE_OFF - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-light_1" @@ -403,14 +390,14 @@ async def test_block_device_relay_app_type_light( mock_block_device.blocks[RELAY_BLOCK_ID], "description", "relay_1" ) await init_integration(hass, 1) + assert hass.states.get("switch.test_name_channel_1") is None # Test initial - state = hass.states.get(entity_id) - attributes = state.attributes + assert (state := hass.states.get(entity_id)) assert state.state == STATE_ON - assert attributes[ATTR_SUPPORTED_COLOR_MODES] == [ColorMode.ONOFF] - assert attributes[ATTR_SUPPORTED_FEATURES] == 0 + assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ColorMode.ONOFF] + assert state.attributes[ATTR_SUPPORTED_FEATURES] == 0 # Turn off mock_block_device.blocks[RELAY_BLOCK_ID].set_state.reset_mock() @@ -423,7 +410,7 @@ async def test_block_device_relay_app_type_light( mock_block_device.blocks[RELAY_BLOCK_ID].set_state.assert_called_once_with( turn="off" ) - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.state == STATE_OFF # Turn on @@ -437,11 +424,10 @@ async def test_block_device_relay_app_type_light( mock_block_device.blocks[RELAY_BLOCK_ID].set_state.assert_called_once_with( turn="on" ) - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.state == STATE_ON - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-relay_1" @@ -451,6 +437,7 @@ async def test_block_device_no_light_blocks( """Test block device without light blocks.""" monkeypatch.setattr(mock_block_device.blocks[LIGHT_BLOCK_ID], "type", "roller") await init_integration(hass, 1) + assert hass.states.get("light.test_name_channel_1") is None @@ -473,7 +460,9 @@ async def test_rpc_device_switch_type_lights_mode( {ATTR_ENTITY_ID: entity_id}, blocking=True, ) - assert hass.states.get(entity_id).state == STATE_ON + + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_ON mutate_rpc_device_status(monkeypatch, mock_rpc_device, "switch:0", "output", False) await hass.services.async_call( @@ -483,10 +472,11 @@ async def test_rpc_device_switch_type_lights_mode( blocking=True, ) mock_rpc_device.mock_update() - assert hass.states.get(entity_id).state == STATE_OFF - entry = entity_registry.async_get(entity_id) - assert entry + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_OFF + + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-switch:0" @@ -510,7 +500,8 @@ async def test_rpc_light( ) mock_rpc_device.call_rpc.assert_called_once_with("Light.Set", {"id": 0, "on": True}) - state = hass.states.get(entity_id) + + assert (state := hass.states.get(entity_id)) assert state.state == STATE_ON assert state.attributes[ATTR_BRIGHTNESS] == 135 @@ -528,7 +519,8 @@ async def test_rpc_light( mock_rpc_device.call_rpc.assert_called_once_with( "Light.Set", {"id": 0, "on": False} ) - state = hass.states.get(entity_id) + + assert (state := hass.states.get(entity_id)) assert state.state == STATE_OFF # Turn on, brightness = 33 @@ -547,7 +539,8 @@ async def test_rpc_light( mock_rpc_device.call_rpc.assert_called_once_with( "Light.Set", {"id": 0, "on": True, "brightness": 13} ) - state = hass.states.get(entity_id) + + assert (state := hass.states.get(entity_id)) assert state.state == STATE_ON assert state.attributes[ATTR_BRIGHTNESS] == 33 @@ -565,7 +558,8 @@ async def test_rpc_light( mock_rpc_device.call_rpc.assert_called_once_with( "Light.Set", {"id": 0, "on": True, "transition_duration": 10.1} ) - state = hass.states.get(entity_id) + + assert (state := hass.states.get(entity_id)) assert state.state == STATE_ON # Turn off, transition = 0.4, should be limited to 0.5 @@ -584,11 +578,10 @@ async def test_rpc_light( "Light.Set", {"id": 0, "on": False, "transition_duration": 0.5} ) - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.state == STATE_OFF - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-light:0" @@ -606,12 +599,11 @@ async def test_rpc_device_rgb_profile( await init_integration(hass, 2) # Test initial - state = hass.states.get(entity_id) - attributes = state.attributes + assert (state := hass.states.get(entity_id)) assert state.state == STATE_ON - assert attributes[ATTR_RGB_COLOR] == (45, 55, 65) - assert attributes[ATTR_SUPPORTED_COLOR_MODES] == [ColorMode.RGB] - assert attributes[ATTR_SUPPORTED_FEATURES] == LightEntityFeature.TRANSITION + assert state.attributes[ATTR_RGB_COLOR] == (45, 55, 65) + assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ColorMode.RGB] + assert state.attributes[ATTR_SUPPORTED_FEATURES] == LightEntityFeature.TRANSITION # Turn on, RGB = [70, 80, 90] await hass.services.async_call( @@ -628,14 +620,12 @@ async def test_rpc_device_rgb_profile( "RGB.Set", {"id": 0, "on": True, "rgb": [70, 80, 90]} ) - state = hass.states.get(entity_id) - attributes = state.attributes + assert (state := hass.states.get(entity_id)) assert state.state == STATE_ON - assert attributes[ATTR_COLOR_MODE] == ColorMode.RGB - assert attributes[ATTR_RGB_COLOR] == (70, 80, 90) + assert state.attributes[ATTR_COLOR_MODE] == ColorMode.RGB + assert state.attributes[ATTR_RGB_COLOR] == (70, 80, 90) - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-rgb:0" @@ -653,12 +643,11 @@ async def test_rpc_device_rgbw_profile( await init_integration(hass, 2) # Test initial - state = hass.states.get(entity_id) - attributes = state.attributes + assert (state := hass.states.get(entity_id)) assert state.state == STATE_ON - assert attributes[ATTR_RGBW_COLOR] == (21, 22, 23, 120) - assert attributes[ATTR_SUPPORTED_COLOR_MODES] == [ColorMode.RGBW] - assert attributes[ATTR_SUPPORTED_FEATURES] == LightEntityFeature.TRANSITION + assert state.attributes[ATTR_RGBW_COLOR] == (21, 22, 23, 120) + assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ColorMode.RGBW] + assert state.attributes[ATTR_SUPPORTED_FEATURES] == LightEntityFeature.TRANSITION # Turn on, RGBW = [72, 82, 92, 128] await hass.services.async_call( @@ -678,14 +667,12 @@ async def test_rpc_device_rgbw_profile( "RGBW.Set", {"id": 0, "on": True, "rgb": [72, 82, 92], "white": 128} ) - state = hass.states.get(entity_id) - attributes = state.attributes + assert (state := hass.states.get(entity_id)) assert state.state == STATE_ON - assert attributes[ATTR_COLOR_MODE] == ColorMode.RGBW - assert attributes[ATTR_RGBW_COLOR] == (72, 82, 92, 128) + assert state.attributes[ATTR_COLOR_MODE] == ColorMode.RGBW + assert state.attributes[ATTR_RGBW_COLOR] == (72, 82, 92, 128) - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-rgbw:0" @@ -730,9 +717,11 @@ async def test_rpc_rgbw_device_light_mode_remove_others( # verify we have 4 lights for i in range(SHELLY_PLUS_RGBW_CHANNELS): entity_id = f"light.test_light_{i}" - assert hass.states.get(entity_id).state == STATE_ON - entry = entity_registry.async_get(entity_id) - assert entry + + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_ON + + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == f"123456789ABC-light:{i}" # verify RGB & RGBW entities removed @@ -793,9 +782,11 @@ async def test_rpc_rgbw_device_rgb_w_modes_remove_others( # verify we have RGB/w light entity_id = f"light.test_{active_mode}_0" - assert hass.states.get(entity_id).state == STATE_ON - entry = entity_registry.async_get(entity_id) - assert entry + + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_ON + + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == f"123456789ABC-{active_mode}:0" # verify light & RGB/W entities removed @@ -823,8 +814,7 @@ async def test_rpc_cct_light( await init_integration(hass, 2) - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-cct:0" # Turn off @@ -836,7 +826,8 @@ async def test_rpc_cct_light( ) mock_rpc_device.call_rpc.assert_called_once_with("CCT.Set", {"id": 0, "on": False}) - state = hass.states.get(entity_id) + + assert (state := hass.states.get(entity_id)) assert state.state == STATE_OFF # Turn on @@ -851,7 +842,8 @@ async def test_rpc_cct_light( mock_rpc_device.mock_update() mock_rpc_device.call_rpc.assert_called_once_with("CCT.Set", {"id": 0, "on": True}) - state = hass.states.get(entity_id) + + assert (state := hass.states.get(entity_id)) assert state.state == STATE_ON assert state.attributes[ATTR_COLOR_MODE] == ColorMode.COLOR_TEMP assert state.attributes[ATTR_BRIGHTNESS] == 196 # 77% of 255 @@ -874,7 +866,8 @@ async def test_rpc_cct_light( mock_rpc_device.call_rpc.assert_called_once_with( "CCT.Set", {"id": 0, "on": True, "brightness": 88} ) - state = hass.states.get(entity_id) + + assert (state := hass.states.get(entity_id)) assert state.state == STATE_ON assert state.attributes[ATTR_BRIGHTNESS] == 224 # 88% of 255 @@ -894,7 +887,8 @@ async def test_rpc_cct_light( mock_rpc_device.call_rpc.assert_called_once_with( "CCT.Set", {"id": 0, "on": True, "ct": 4444} ) - state = hass.states.get(entity_id) + + assert (state := hass.states.get(entity_id)) assert state.state == STATE_ON assert state.attributes[ATTR_COLOR_TEMP_KELVIN] == 4444 diff --git a/tests/components/shelly/test_number.py b/tests/components/shelly/test_number.py index ef5766e0091..41002917d86 100644 --- a/tests/components/shelly/test_number.py +++ b/tests/components/shelly/test_number.py @@ -54,15 +54,16 @@ async def test_block_number_update( mock_block_device.mock_online() await hass.async_block_till_done(wait_background_tasks=True) - assert hass.states.get(entity_id).state == "50" + assert (state := hass.states.get(entity_id)) + assert state.state == "50" monkeypatch.setattr(mock_block_device.blocks[DEVICE_BLOCK_ID], "valvePos", 30) mock_block_device.mock_update() - assert hass.states.get(entity_id).state == "30" + assert (state := hass.states.get(entity_id)) + assert state.state == "30" - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-device_0-valvePos" @@ -103,14 +104,16 @@ async def test_block_restored_number( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert hass.states.get(entity_id).state == "40" + assert (state := hass.states.get(entity_id)) + assert state.state == "40" # Make device online monkeypatch.setattr(mock_block_device, "initialized", True) mock_block_device.mock_online() await hass.async_block_till_done(wait_background_tasks=True) - assert hass.states.get(entity_id).state == "50" + assert (state := hass.states.get(entity_id)) + assert state.state == "50" async def test_block_restored_number_no_last_state( @@ -141,14 +144,16 @@ async def test_block_restored_number_no_last_state( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert hass.states.get(entity_id).state == STATE_UNKNOWN + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_UNKNOWN # Make device online monkeypatch.setattr(mock_block_device, "initialized", True) mock_block_device.mock_online() await hass.async_block_till_done(wait_background_tasks=True) - assert hass.states.get(entity_id).state == "50" + assert (state := hass.states.get(entity_id)) + assert state.state == "50" async def test_block_number_set_value( @@ -305,8 +310,7 @@ async def test_rpc_device_virtual_number( await init_integration(hass, 3) - state = hass.states.get(entity_id) - assert state + assert (state := hass.states.get(entity_id)) assert state.state == "12.3" assert state.attributes.get(ATTR_MIN) == 0 assert state.attributes.get(ATTR_MAX) == 100 @@ -314,13 +318,13 @@ async def test_rpc_device_virtual_number( assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == expected_unit assert state.attributes.get(ATTR_MODE) is mode - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-number:203-number" monkeypatch.setitem(mock_rpc_device.status["number:203"], "value", 78.9) mock_rpc_device.mock_update() - assert hass.states.get(entity_id).state == "78.9" + assert (state := hass.states.get(entity_id)) + assert state.state == "78.9" monkeypatch.setitem(mock_rpc_device.status["number:203"], "value", 56.7) await hass.services.async_call( @@ -330,7 +334,8 @@ async def test_rpc_device_virtual_number( blocking=True, ) mock_rpc_device.mock_update() - assert hass.states.get(entity_id).state == "56.7" + assert (state := hass.states.get(entity_id)) + assert state.state == "56.7" async def test_rpc_remove_virtual_number_when_mode_label( @@ -368,8 +373,7 @@ async def test_rpc_remove_virtual_number_when_mode_label( await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - entry = entity_registry.async_get(entity_id) - assert not entry + assert entity_registry.async_get(entity_id) is None async def test_rpc_remove_virtual_number_when_orphaned( @@ -393,8 +397,7 @@ async def test_rpc_remove_virtual_number_when_orphaned( await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - entry = entity_registry.async_get(entity_id) - assert not entry + assert entity_registry.async_get(entity_id) is None async def test_blu_trv_number_entity( @@ -430,7 +433,8 @@ async def test_blu_trv_ext_temp_set_value( # After HA start the state should be unknown because there was no previous external # temperature report - assert hass.states.get(entity_id).state is STATE_UNKNOWN + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_UNKNOWN await hass.services.async_call( NUMBER_DOMAIN, @@ -452,7 +456,8 @@ async def test_blu_trv_ext_temp_set_value( BLU_TRV_TIMEOUT, ) - assert hass.states.get(entity_id).state == "22.2" + assert (state := hass.states.get(entity_id)) + assert state.state == "22.2" async def test_blu_trv_valve_pos_set_value( @@ -468,7 +473,8 @@ async def test_blu_trv_valve_pos_set_value( entity_id = f"{NUMBER_DOMAIN}.trv_name_valve_position" - assert hass.states.get(entity_id).state == "0" + assert (state := hass.states.get(entity_id)) + assert state.state == "0" monkeypatch.setitem(mock_blu_trv.status["blutrv:200"], "pos", 20) await hass.services.async_call( @@ -493,4 +499,5 @@ async def test_blu_trv_valve_pos_set_value( # device only accepts int for 'pos' value assert isinstance(mock_blu_trv.call_rpc.call_args[0][1]["params"]["pos"], int) - assert hass.states.get(entity_id).state == "20" + assert (state := hass.states.get(entity_id)) + assert state.state == "20" diff --git a/tests/components/shelly/test_select.py b/tests/components/shelly/test_select.py index 0a6eb2a5843..39e426baa58 100644 --- a/tests/components/shelly/test_select.py +++ b/tests/components/shelly/test_select.py @@ -56,8 +56,7 @@ async def test_rpc_device_virtual_enum( await init_integration(hass, 3) - state = hass.states.get(entity_id) - assert state + assert (state := hass.states.get(entity_id)) assert state.state == expected_state assert state.attributes.get(ATTR_OPTIONS) == [ "Title 1", @@ -65,13 +64,14 @@ async def test_rpc_device_virtual_enum( "option 3", ] - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-enum:203-enum" monkeypatch.setitem(mock_rpc_device.status["enum:203"], "value", "option 2") mock_rpc_device.mock_update() - assert hass.states.get(entity_id).state == "option 2" + + assert (state := hass.states.get(entity_id)) + assert state.state == "option 2" monkeypatch.setitem(mock_rpc_device.status["enum:203"], "value", "option 1") await hass.services.async_call( @@ -83,7 +83,9 @@ async def test_rpc_device_virtual_enum( # 'Title 1' corresponds to 'option 1' assert mock_rpc_device.call_rpc.call_args[0][1] == {"id": 203, "value": "option 1"} mock_rpc_device.mock_update() - assert hass.states.get(entity_id).state == "Title 1" + + assert (state := hass.states.get(entity_id)) + assert state.state == "Title 1" async def test_rpc_remove_virtual_enum_when_mode_label( @@ -122,8 +124,7 @@ async def test_rpc_remove_virtual_enum_when_mode_label( await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - entry = entity_registry.async_get(entity_id) - assert not entry + assert entity_registry.async_get(entity_id) is None async def test_rpc_remove_virtual_enum_when_orphaned( @@ -147,5 +148,4 @@ async def test_rpc_remove_virtual_enum_when_orphaned( await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - entry = entity_registry.async_get(entity_id) - assert not entry + assert entity_registry.async_get(entity_id) is None diff --git a/tests/components/shelly/test_sensor.py b/tests/components/shelly/test_sensor.py index 5c1f03de3e8..7edd38a4b31 100644 --- a/tests/components/shelly/test_sensor.py +++ b/tests/components/shelly/test_sensor.py @@ -40,7 +40,6 @@ from homeassistant.helpers.entity_registry import EntityRegistry from homeassistant.setup import async_setup_component from . import ( - get_entity_state, init_integration, mock_polling_rpc_update, mock_rest_update, @@ -66,15 +65,16 @@ async def test_block_sensor( entity_id = f"{SENSOR_DOMAIN}.test_name_channel_1_power" await init_integration(hass, 1) - assert hass.states.get(entity_id).state == "53.4" + assert (state := hass.states.get(entity_id)) + assert state.state == "53.4" monkeypatch.setattr(mock_block_device.blocks[RELAY_BLOCK_ID], "power", 60.1) mock_block_device.mock_update() - assert hass.states.get(entity_id).state == "60.1" + assert (state := hass.states.get(entity_id)) + assert state.state == "60.1" - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-relay_0-power" @@ -85,14 +85,13 @@ async def test_energy_sensor( entity_id = f"{SENSOR_DOMAIN}.test_name_channel_1_energy" await init_integration(hass, 1) - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) # 1234567.89 Wmin / 60 / 1000 = 20.5761315 kWh assert state.state == "20.5761315" # suggested unit is KWh assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfEnergy.KILO_WATT_HOUR - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-relay_0-energy" @@ -111,13 +110,12 @@ async def test_power_factory_unit_migration( entity_id = f"{SENSOR_DOMAIN}.test_name_power_factor" await init_integration(hass, 1) - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) # Value of 0.98 is converted to 98.0% assert state.state == "98.0" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-emeter_0-powerFactor" @@ -128,12 +126,11 @@ async def test_power_factory_without_unit_migration( entity_id = f"{SENSOR_DOMAIN}.test_name_power_factor" await init_integration(hass, 1) - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.state == "0.98" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is None - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-emeter_0-powerFactor" @@ -147,12 +144,14 @@ async def test_block_rest_sensor( entity_id = register_entity(hass, SENSOR_DOMAIN, "test_name_rssi", "rssi") await init_integration(hass, 1) - assert hass.states.get(entity_id).state == "-64" + assert (state := hass.states.get(entity_id)) + assert state.state == "-64" monkeypatch.setitem(mock_block_device.status["wifi_sta"], "rssi", -71) await mock_rest_update(hass, freezer) - assert hass.states.get(entity_id).state == "-71" + assert (state := hass.states.get(entity_id)) + assert state.state == "-71" async def test_block_sleeping_sensor( @@ -175,15 +174,16 @@ async def test_block_sleeping_sensor( mock_block_device.mock_online() await hass.async_block_till_done(wait_background_tasks=True) - assert hass.states.get(entity_id).state == "22.1" + assert (state := hass.states.get(entity_id)) + assert state.state == "22.1" monkeypatch.setattr(mock_block_device.blocks[SENSOR_BLOCK_ID], "temp", 23.4) mock_block_device.mock_update() - assert hass.states.get(entity_id).state == "23.4" + assert (state := hass.states.get(entity_id)) + assert state.state == "23.4" - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-sensor_0-temp" @@ -211,8 +211,7 @@ async def test_block_restored_sleeping_sensor( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - state = hass.states.get(entity_id) - assert state + assert (state := hass.states.get(entity_id)) assert state.state == "20.4" assert state.attributes[ATTR_STATE_CLASS] == SensorStateClass.MEASUREMENT assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.TEMPERATURE @@ -222,7 +221,8 @@ async def test_block_restored_sleeping_sensor( mock_block_device.mock_online() await hass.async_block_till_done(wait_background_tasks=True) - assert hass.states.get(entity_id).state == "22.1" + assert (state := hass.states.get(entity_id)) + assert state.state == "22.1" async def test_block_restored_sleeping_sensor_no_last_state( @@ -246,14 +246,16 @@ async def test_block_restored_sleeping_sensor_no_last_state( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert hass.states.get(entity_id).state == STATE_UNKNOWN + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_UNKNOWN # Make device online monkeypatch.setattr(mock_block_device, "initialized", True) mock_block_device.mock_online() await hass.async_block_till_done(wait_background_tasks=True) - assert hass.states.get(entity_id).state == "22.1" + assert (state := hass.states.get(entity_id)) + assert state.state == "22.1" async def test_block_sensor_error( @@ -266,15 +268,16 @@ async def test_block_sensor_error( entity_id = f"{SENSOR_DOMAIN}.test_name_battery" await init_integration(hass, 1) - assert hass.states.get(entity_id).state == "98" + assert (state := hass.states.get(entity_id)) + assert state.state == "98" monkeypatch.setattr(mock_block_device.blocks[DEVICE_BLOCK_ID], "battery", -1) mock_block_device.mock_update() - assert hass.states.get(entity_id).state == STATE_UNAVAILABLE + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_UNAVAILABLE - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-device_0-battery" @@ -321,7 +324,8 @@ async def test_block_not_matched_restored_sleeping_sensor( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert hass.states.get(entity_id).state == "20.4" + assert (state := hass.states.get(entity_id)) + assert state.state == "20.4" # Make device online monkeypatch.setattr( @@ -331,7 +335,8 @@ async def test_block_not_matched_restored_sleeping_sensor( mock_block_device.mock_online() await hass.async_block_till_done(wait_background_tasks=True) - assert hass.states.get(entity_id).state == "20.4" + assert (state := hass.states.get(entity_id)) + assert state.state == "20.4" async def test_block_sensor_without_value( @@ -403,7 +408,8 @@ async def test_block_sensor_values( monkeypatch.setattr(mock_block_device.blocks[block_id], attribute, value) mock_block_device.mock_update() - assert hass.states.get(entity_id).state == final_value + assert (state := hass.states.get(entity_id)) + assert state.state == final_value @pytest.mark.parametrize( @@ -430,7 +436,8 @@ async def test_block_shelly_air_lamp_life( ) await init_integration(hass, 1) - assert hass.states.get(entity_id).state == percentage + assert (state := hass.states.get(entity_id)) + assert state.state == percentage async def test_rpc_sensor( @@ -440,17 +447,20 @@ async def test_rpc_sensor( entity_id = f"{SENSOR_DOMAIN}.test_cover_0_power" await init_integration(hass, 2) - assert hass.states.get(entity_id).state == "85.3" + assert (state := hass.states.get(entity_id)) + assert state.state == "85.3" mutate_rpc_device_status(monkeypatch, mock_rpc_device, "cover:0", "apower", "88.2") mock_rpc_device.mock_update() - assert hass.states.get(entity_id).state == "88.2" + assert (state := hass.states.get(entity_id)) + assert state.state == "88.2" mutate_rpc_device_status(monkeypatch, mock_rpc_device, "cover:0", "apower", None) mock_rpc_device.mock_update() - assert hass.states.get(entity_id).state == STATE_UNKNOWN + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_UNKNOWN @pytest.mark.usefixtures("entity_registry_enabled_by_default") @@ -464,7 +474,8 @@ async def test_rpc_rssi_sensor_removal( entry = await init_integration(hass, 2) # WiFi1 enabled, do not remove sensor - assert get_entity_state(hass, entity_id) == "-63" + assert (state := hass.states.get(entity_id)) + assert state.state == "-63" # WiFi1 & WiFi2 disabled - remove sensor monkeypatch.setitem(mock_rpc_device.config["wifi"]["sta"], "enable", False) @@ -476,7 +487,9 @@ async def test_rpc_rssi_sensor_removal( monkeypatch.setitem(mock_rpc_device.config["wifi"]["sta1"], "enable", True) await hass.config_entries.async_reload(entry.entry_id) await hass.async_block_till_done() - assert get_entity_state(hass, entity_id) == "-63" + + assert (state := hass.states.get(entity_id)) + assert state.state == "-63" async def test_rpc_illuminance_sensor( @@ -486,10 +499,10 @@ async def test_rpc_illuminance_sensor( entity_id = f"{SENSOR_DOMAIN}.test_name_illuminance" await init_integration(hass, 2) - assert hass.states.get(entity_id).state == "345" + assert (state := hass.states.get(entity_id)) + assert state.state == "345" - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-illuminance:0-illuminance" @@ -503,17 +516,18 @@ async def test_rpc_sensor_error( entity_id = f"{SENSOR_DOMAIN}.test_name_voltmeter" await init_integration(hass, 2) - assert hass.states.get(entity_id).state == "4.321" + assert (state := hass.states.get(entity_id)) + assert state.state == "4.321" mutate_rpc_device_status( monkeypatch, mock_rpc_device, "voltmeter:100", "voltage", None ) mock_rpc_device.mock_update() - assert hass.states.get(entity_id).state == STATE_UNAVAILABLE + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_UNAVAILABLE - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-voltmeter:100-voltmeter" @@ -528,15 +542,16 @@ async def test_rpc_polling_sensor( entity_id = register_entity(hass, SENSOR_DOMAIN, "test_name_rssi", "wifi-rssi") await init_integration(hass, 2) - assert hass.states.get(entity_id).state == "-63" + assert (state := hass.states.get(entity_id)) + assert state.state == "-63" mutate_rpc_device_status(monkeypatch, mock_rpc_device, "wifi", "rssi", "-70") await mock_polling_rpc_update(hass, freezer) - assert hass.states.get(entity_id).state == "-70" + assert (state := hass.states.get(entity_id)) + assert state.state == "-70" - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-wifi-rssi" @@ -567,12 +582,14 @@ async def test_rpc_sleeping_sensor( mock_rpc_device.mock_online() await hass.async_block_till_done(wait_background_tasks=True) - assert hass.states.get(entity_id).state == "22.9" + assert (state := hass.states.get(entity_id)) + assert state.state == "22.9" mutate_rpc_device_status(monkeypatch, mock_rpc_device, "temperature:0", "tC", 23.4) mock_rpc_device.mock_update() - assert hass.states.get(entity_id).state == "23.4" + assert (state := hass.states.get(entity_id)) + assert state.state == "23.4" async def test_rpc_restored_sleeping_sensor( @@ -600,7 +617,8 @@ async def test_rpc_restored_sleeping_sensor( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert hass.states.get(entity_id).state == "21.0" + assert (state := hass.states.get(entity_id)) + assert state.state == "21.0" # Make device online monkeypatch.setattr(mock_rpc_device, "initialized", True) @@ -611,7 +629,8 @@ async def test_rpc_restored_sleeping_sensor( mock_rpc_device.mock_update() await hass.async_block_till_done() - assert hass.states.get(entity_id).state == "22.9" + assert (state := hass.states.get(entity_id)) + assert state.state == "22.9" async def test_rpc_restored_sleeping_sensor_no_last_state( @@ -637,7 +656,8 @@ async def test_rpc_restored_sleeping_sensor_no_last_state( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert hass.states.get(entity_id).state == STATE_UNKNOWN + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_UNKNOWN # Make device online monkeypatch.setattr(mock_rpc_device, "initialized", True) @@ -648,7 +668,8 @@ async def test_rpc_restored_sleeping_sensor_no_last_state( mock_rpc_device.mock_update() await hass.async_block_till_done() - assert hass.states.get(entity_id).state == "22.9" + assert (state := hass.states.get(entity_id)) + assert state.state == "22.9" @pytest.mark.usefixtures("entity_registry_enabled_by_default") @@ -658,36 +679,32 @@ async def test_rpc_em1_sensors( """Test RPC sensors for EM1 component.""" await init_integration(hass, 2) - state = hass.states.get("sensor.test_name_em0_power") - assert state + assert (state := hass.states.get("sensor.test_name_em0_power")) assert state.state == "85.3" - entry = entity_registry.async_get("sensor.test_name_em0_power") - assert entry + assert (entry := entity_registry.async_get("sensor.test_name_em0_power")) assert entry.unique_id == "123456789ABC-em1:0-power_em1" - state = hass.states.get("sensor.test_name_em1_power") - assert state + assert (state := hass.states.get("sensor.test_name_em1_power")) assert state.state == "123.3" - entry = entity_registry.async_get("sensor.test_name_em1_power") - assert entry + assert (entry := entity_registry.async_get("sensor.test_name_em1_power")) assert entry.unique_id == "123456789ABC-em1:1-power_em1" - state = hass.states.get("sensor.test_name_em0_total_active_energy") - assert state + assert (state := hass.states.get("sensor.test_name_em0_total_active_energy")) assert state.state == "123.4564" - entry = entity_registry.async_get("sensor.test_name_em0_total_active_energy") - assert entry + assert ( + entry := entity_registry.async_get("sensor.test_name_em0_total_active_energy") + ) assert entry.unique_id == "123456789ABC-em1data:0-total_act_energy" - state = hass.states.get("sensor.test_name_em1_total_active_energy") - assert state + assert (state := hass.states.get("sensor.test_name_em1_total_active_energy")) assert state.state == "987.6543" - entry = entity_registry.async_get("sensor.test_name_em1_total_active_energy") - assert entry + assert ( + entry := entity_registry.async_get("sensor.test_name_em1_total_active_energy") + ) assert entry.unique_id == "123456789ABC-em1data:1-total_act_energy" @@ -713,7 +730,7 @@ async def test_rpc_sleeping_update_entity_service( mock_rpc_device.mock_online() await hass.async_block_till_done(wait_background_tasks=True) - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.state == "22.9" await hass.services.async_call( @@ -724,11 +741,10 @@ async def test_rpc_sleeping_update_entity_service( ) # Entity should be available after update_entity service call - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.state == "22.9" - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-temperature:0-temperature_0" assert ( @@ -762,7 +778,8 @@ async def test_block_sleeping_update_entity_service( mock_block_device.mock_online() await hass.async_block_till_done(wait_background_tasks=True) - assert hass.states.get(entity_id).state == "22.1" + assert (state := hass.states.get(entity_id)) + assert state.state == "22.1" await hass.services.async_call( HA_DOMAIN, @@ -772,11 +789,10 @@ async def test_block_sleeping_update_entity_service( ) # Entity should be available after update_entity service call - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.state == "22.1" - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-sensor_0-temp" assert ( @@ -809,20 +825,18 @@ async def test_rpc_analog_input_sensors( await init_integration(hass, 2) entity_id = f"{SENSOR_DOMAIN}.test_name_input_1_analog" - assert hass.states.get(entity_id).state == "89" + assert (state := hass.states.get(entity_id)) + assert state.state == "89" - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-input:1-analoginput" entity_id = f"{SENSOR_DOMAIN}.test_name_input_1_analog_value" - state = hass.states.get(entity_id) - assert state + assert (state := hass.states.get(entity_id)) assert state.state == "8.9" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == expected_unit - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-input:1-analoginput_xpercent" @@ -857,7 +871,8 @@ async def test_rpc_disabled_xpercent( await init_integration(hass, 2) entity_id = f"{SENSOR_DOMAIN}.test_name_input_1_analog" - assert hass.states.get(entity_id).state == "89" + assert (state := hass.states.get(entity_id)) + assert state.state == "89" entity_id = f"{SENSOR_DOMAIN}.test_name_input_1_analog_value" assert hass.states.get(entity_id) is None @@ -887,23 +902,20 @@ async def test_rpc_pulse_counter_sensors( await init_integration(hass, 2) entity_id = f"{SENSOR_DOMAIN}.gas_pulse_counter" - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.state == "56174" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "pulse" assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.TOTAL - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-input:2-pulse_counter" entity_id = f"{SENSOR_DOMAIN}.gas_counter_value" - state = hass.states.get(entity_id) - assert state + assert (state := hass.states.get(entity_id)) assert state.state == "561.74" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == expected_unit - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-input:2-counter_value" @@ -938,7 +950,8 @@ async def test_rpc_disabled_xtotal_counter( await init_integration(hass, 2) entity_id = f"{SENSOR_DOMAIN}.gas_pulse_counter" - assert hass.states.get(entity_id).state == "20635" + assert (state := hass.states.get(entity_id)) + assert state.state == "20635" entity_id = f"{SENSOR_DOMAIN}.gas_counter_value" assert hass.states.get(entity_id) is None @@ -968,23 +981,20 @@ async def test_rpc_pulse_counter_frequency_sensors( await init_integration(hass, 2) entity_id = f"{SENSOR_DOMAIN}.gas_pulse_counter_frequency" - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.state == "208.0" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfFrequency.HERTZ assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-input:2-counter_frequency" entity_id = f"{SENSOR_DOMAIN}.gas_pulse_counter_frequency_value" - state = hass.states.get(entity_id) - assert state + assert (state := hass.states.get(entity_id)) assert state.state == "6.11" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == expected_unit - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-input:2-counter_frequency_value" @@ -1007,11 +1017,9 @@ async def test_rpc_disabled_xfreq( entity_id = f"{SENSOR_DOMAIN}.gas_pulse_counter_frequency_value" - state = hass.states.get(entity_id) - assert not state + assert hass.states.get(entity_id) is None - entry = entity_registry.async_get(entity_id) - assert not entry + assert entity_registry.async_get(entity_id) is None @pytest.mark.parametrize( @@ -1043,17 +1051,16 @@ async def test_rpc_device_virtual_text_sensor( await init_integration(hass, 3) - state = hass.states.get(entity_id) - assert state + assert (state := hass.states.get(entity_id)) assert state.state == "lorem ipsum" - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-text:203-text" monkeypatch.setitem(mock_rpc_device.status["text:203"], "value", "dolor sit amet") mock_rpc_device.mock_update() - assert hass.states.get(entity_id).state == "dolor sit amet" + assert (state := hass.states.get(entity_id)) + assert state.state == "dolor sit amet" async def test_rpc_remove_text_virtual_sensor_when_mode_field( @@ -1086,8 +1093,7 @@ async def test_rpc_remove_text_virtual_sensor_when_mode_field( await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - entry = entity_registry.async_get(entity_id) - assert not entry + assert entity_registry.async_get(entity_id) is None async def test_rpc_remove_text_virtual_sensor_when_orphaned( @@ -1111,8 +1117,7 @@ async def test_rpc_remove_text_virtual_sensor_when_orphaned( await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - entry = entity_registry.async_get(entity_id) - assert not entry + assert entity_registry.async_get(entity_id) is None @pytest.mark.parametrize( @@ -1148,18 +1153,17 @@ async def test_rpc_device_virtual_number_sensor( await init_integration(hass, 3) - state = hass.states.get(entity_id) - assert state + assert (state := hass.states.get(entity_id)) assert state.state == "34.5" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == expected_unit - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-number:203-number" monkeypatch.setitem(mock_rpc_device.status["number:203"], "value", 56.7) mock_rpc_device.mock_update() - assert hass.states.get(entity_id).state == "56.7" + assert (state := hass.states.get(entity_id)) + assert state.state == "56.7" async def test_rpc_remove_number_virtual_sensor_when_mode_field( @@ -1197,8 +1201,7 @@ async def test_rpc_remove_number_virtual_sensor_when_mode_field( await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - entry = entity_registry.async_get(entity_id) - assert not entry + assert entity_registry.async_get(entity_id) is None async def test_rpc_remove_number_virtual_sensor_when_orphaned( @@ -1222,8 +1225,7 @@ async def test_rpc_remove_number_virtual_sensor_when_orphaned( await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - entry = entity_registry.async_get(entity_id) - assert not entry + assert entity_registry.async_get(entity_id) is None @pytest.mark.parametrize( @@ -1263,19 +1265,18 @@ async def test_rpc_device_virtual_enum_sensor( await init_integration(hass, 3) - state = hass.states.get(entity_id) - assert state + assert (state := hass.states.get(entity_id)) assert state.state == expected_state assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.ENUM assert state.attributes.get(ATTR_OPTIONS) == ["Title 1", "two", "three"] - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-enum:203-enum" monkeypatch.setitem(mock_rpc_device.status["enum:203"], "value", "two") mock_rpc_device.mock_update() - assert hass.states.get(entity_id).state == "two" + assert (state := hass.states.get(entity_id)) + assert state.state == "two" async def test_rpc_remove_enum_virtual_sensor_when_mode_dropdown( @@ -1317,8 +1318,7 @@ async def test_rpc_remove_enum_virtual_sensor_when_mode_dropdown( await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - entry = entity_registry.async_get(entity_id) - assert not entry + assert entity_registry.async_get(entity_id) is None async def test_rpc_remove_enum_virtual_sensor_when_orphaned( @@ -1342,8 +1342,7 @@ async def test_rpc_remove_enum_virtual_sensor_when_orphaned( await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - entry = entity_registry.async_get(entity_id) - assert not entry + assert entity_registry.async_get(entity_id) is None @pytest.mark.usefixtures("entity_registry_enabled_by_default") @@ -1374,61 +1373,51 @@ async def test_rpc_rgbw_sensors( entity_id = f"sensor.test_name_{light_type}_light_0_power" - state = hass.states.get(entity_id) - assert state + assert (state := hass.states.get(entity_id)) assert state.state == "12.2" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfPower.WATT - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == f"123456789ABC-{light_type}:0-power_{light_type}" entity_id = f"sensor.test_name_{light_type}_light_0_energy" - state = hass.states.get(entity_id) - assert state + assert (state := hass.states.get(entity_id)) assert state.state == "0.045141" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfEnergy.KILO_WATT_HOUR - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == f"123456789ABC-{light_type}:0-energy_{light_type}" entity_id = f"sensor.test_name_{light_type}_light_0_current" - state = hass.states.get(entity_id) - assert state + assert (state := hass.states.get(entity_id)) assert state.state == "0.23" assert ( state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfElectricCurrent.AMPERE ) - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == f"123456789ABC-{light_type}:0-current_{light_type}" entity_id = f"sensor.test_name_{light_type}_light_0_voltage" - state = hass.states.get(entity_id) - assert state + assert (state := hass.states.get(entity_id)) assert state.state == "12.4" assert ( state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfElectricPotential.VOLT ) - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == f"123456789ABC-{light_type}:0-voltage_{light_type}" entity_id = f"sensor.test_name_{light_type}_light_0_device_temperature" - state = hass.states.get(entity_id) - assert state + assert (state := hass.states.get(entity_id)) assert state.state == "54.3" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfTemperature.CELSIUS - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == f"123456789ABC-{light_type}:0-temperature_{light_type}" @@ -1441,15 +1430,17 @@ async def test_rpc_device_sensor_goes_unavailable_on_disconnect( ) -> None: """Test RPC device with sensor goes unavailable on disconnect.""" await init_integration(hass, 2) - temp_sensor_state = hass.states.get("sensor.test_name_temperature") - assert temp_sensor_state is not None - assert temp_sensor_state.state != STATE_UNAVAILABLE + + assert (state := hass.states.get("sensor.test_name_temperature")) + assert state.state != STATE_UNAVAILABLE + monkeypatch.setattr(mock_rpc_device, "connected", False) monkeypatch.setattr(mock_rpc_device, "initialized", False) mock_rpc_device.mock_disconnected() await hass.async_block_till_done() - temp_sensor_state = hass.states.get("sensor.test_name_temperature") - assert temp_sensor_state.state == STATE_UNAVAILABLE + + assert (state := hass.states.get("sensor.test_name_temperature")) + assert state.state == STATE_UNAVAILABLE freezer.tick(60) async_fire_time_changed(hass) @@ -1460,8 +1451,9 @@ async def test_rpc_device_sensor_goes_unavailable_on_disconnect( monkeypatch.setattr(mock_rpc_device, "initialized", True) mock_rpc_device.mock_initialized() await hass.async_block_till_done() - temp_sensor_state = hass.states.get("sensor.test_name_temperature") - assert temp_sensor_state.state != STATE_UNAVAILABLE + + assert (state := hass.states.get("sensor.test_name_temperature")) + assert state.state != STATE_UNAVAILABLE async def test_rpc_voltmeter_value( @@ -1474,13 +1466,11 @@ async def test_rpc_voltmeter_value( await init_integration(hass, 2) - state = hass.states.get(entity_id) - + assert (state := hass.states.get(entity_id)) assert state.state == "12.34" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "ppm" - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-voltmeter:100-voltmeter_value" @@ -1525,8 +1515,7 @@ async def test_rpc_device_virtual_number_sensor_with_device_class( await init_integration(hass, 3) - state = hass.states.get("sensor.test_name_current_humidity") - assert state + assert (state := hass.states.get("sensor.test_name_current_humidity")) assert state.state == "34" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.HUMIDITY diff --git a/tests/components/shelly/test_switch.py b/tests/components/shelly/test_switch.py index fb1c826c67c..824742d1798 100644 --- a/tests/components/shelly/test_switch.py +++ b/tests/components/shelly/test_switch.py @@ -28,7 +28,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceRegistry from homeassistant.helpers.entity_registry import EntityRegistry -from . import get_entity_state, init_integration, register_device, register_entity +from . import init_integration, register_device, register_entity from tests.common import mock_restore_cache @@ -42,22 +42,25 @@ async def test_block_device_services( ) -> None: """Test block device turn on/off services.""" await init_integration(hass, 1) + entity_id = "switch.test_name_channel_1" await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "switch.test_name_channel_1"}, + {ATTR_ENTITY_ID: entity_id}, blocking=True, ) - assert hass.states.get("switch.test_name_channel_1").state == STATE_ON + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_ON await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: "switch.test_name_channel_1"}, + {ATTR_ENTITY_ID: entity_id}, blocking=True, ) - assert hass.states.get("switch.test_name_channel_1").state == STATE_OFF + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_OFF @pytest.mark.parametrize("model", MOTION_MODELS) @@ -75,7 +78,8 @@ async def test_block_motion_switch( mock_block_device.mock_online() await hass.async_block_till_done(wait_background_tasks=True) - assert get_entity_state(hass, entity_id) == STATE_ON + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_ON # turn off await hass.services.async_call( @@ -88,7 +92,9 @@ async def test_block_motion_switch( mock_block_device.mock_update() mock_block_device.set_shelly_motion_detection.assert_called_once_with(False) - assert get_entity_state(hass, entity_id) == STATE_OFF + + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_OFF # turn on mock_block_device.set_shelly_motion_detection.reset_mock() @@ -102,7 +108,9 @@ async def test_block_motion_switch( mock_block_device.mock_update() mock_block_device.set_shelly_motion_detection.assert_called_once_with(True) - assert get_entity_state(hass, entity_id) == STATE_ON + + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_ON @pytest.mark.parametrize("model", MOTION_MODELS) @@ -132,14 +140,16 @@ async def test_block_restored_motion_switch( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert get_entity_state(hass, entity_id) == STATE_OFF + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_OFF # Make device online monkeypatch.setattr(mock_block_device, "initialized", True) mock_block_device.mock_online() await hass.async_block_till_done(wait_background_tasks=True) - assert get_entity_state(hass, entity_id) == STATE_ON + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_ON @pytest.mark.parametrize("model", MOTION_MODELS) @@ -167,14 +177,16 @@ async def test_block_restored_motion_switch_no_last_state( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert get_entity_state(hass, entity_id) == STATE_UNKNOWN + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_UNKNOWN # Make device online monkeypatch.setattr(mock_block_device, "initialized", True) mock_block_device.mock_online() await hass.async_block_till_done(wait_background_tasks=True) - assert get_entity_state(hass, entity_id) == STATE_ON + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_ON @pytest.mark.parametrize( @@ -205,8 +217,7 @@ async def test_block_device_unique_ids( mock_block_device.mock_online() await hass.async_block_till_done(wait_background_tasks=True) - entry = entity_registry.async_get(entity) - assert entry + assert (entry := entity_registry.async_get(entity)) assert entry.unique_id == unique_id @@ -273,11 +284,15 @@ async def test_block_device_update( """Test block device update.""" monkeypatch.setattr(mock_block_device.blocks[RELAY_BLOCK_ID], "output", False) await init_integration(hass, 1) - assert hass.states.get("switch.test_name_channel_1").state == STATE_OFF + + entity_id = "switch.test_name_channel_1" + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_OFF monkeypatch.setattr(mock_block_device.blocks[RELAY_BLOCK_ID], "output", True) mock_block_device.mock_update() - assert hass.states.get("switch.test_name_channel_1").state == STATE_ON + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_ON async def test_block_device_no_relay_blocks( @@ -317,23 +332,26 @@ async def test_rpc_device_services( monkeypatch.setitem(mock_rpc_device.status["sys"], "relay_in_thermostat", False) await init_integration(hass, 2) + entity_id = "switch.test_switch_0" await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "switch.test_switch_0"}, + {ATTR_ENTITY_ID: entity_id}, blocking=True, ) - assert hass.states.get("switch.test_switch_0").state == STATE_ON + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_ON monkeypatch.setitem(mock_rpc_device.status["switch:0"], "output", False) await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: "switch.test_switch_0"}, + {ATTR_ENTITY_ID: entity_id}, blocking=True, ) mock_rpc_device.mock_update() - assert hass.states.get("switch.test_switch_0").state == STATE_OFF + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_OFF async def test_rpc_device_unique_ids( @@ -347,8 +365,7 @@ async def test_rpc_device_unique_ids( monkeypatch.setitem(mock_rpc_device.status["sys"], "relay_in_thermostat", False) await init_integration(hass, 2) - entry = entity_registry.async_get("switch.test_switch_0") - assert entry + assert (entry := entity_registry.async_get("switch.test_switch_0")) assert entry.unique_id == "123456789ABC-switch:0" @@ -360,6 +377,7 @@ async def test_rpc_device_switch_type_lights_mode( mock_rpc_device.config["sys"]["ui_data"], "consumption_types", ["lights"] ) await init_integration(hass, 2) + assert hass.states.get("switch.test_switch_0") is None @@ -463,7 +481,7 @@ async def test_wall_display_relay_mode( config_entry = await init_integration(hass, 2, model=MODEL_WALL_DISPLAY) - assert hass.states.get(climate_entity_id) is not None + assert (state := hass.states.get(climate_entity_id)) assert len(hass.states.async_entity_ids(CLIMATE_DOMAIN)) == 1 new_status = deepcopy(mock_rpc_device.status) @@ -476,17 +494,16 @@ async def test_wall_display_relay_mode( await hass.async_block_till_done() # the climate entity should be removed + assert hass.states.get(climate_entity_id) is None assert len(hass.states.async_entity_ids(CLIMATE_DOMAIN)) == 0 # the switch entity should be created - state = hass.states.get(switch_entity_id) - assert state + assert (state := hass.states.get(switch_entity_id)) assert state.state == STATE_ON assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 1 - entry = entity_registry.async_get(switch_entity_id) - assert entry + assert (entry := entity_registry.async_get(switch_entity_id)) assert entry.unique_id == "123456789ABC-switch:0" @@ -519,12 +536,10 @@ async def test_rpc_device_virtual_switch( await init_integration(hass, 3) - state = hass.states.get(entity_id) - assert state + assert (state := hass.states.get(entity_id)) assert state.state == STATE_ON - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-boolean:200-boolean" monkeypatch.setitem(mock_rpc_device.status["boolean:200"], "value", False) @@ -535,7 +550,8 @@ async def test_rpc_device_virtual_switch( blocking=True, ) mock_rpc_device.mock_update() - assert hass.states.get(entity_id).state == STATE_OFF + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_OFF monkeypatch.setitem(mock_rpc_device.status["boolean:200"], "value", True) await hass.services.async_call( @@ -545,7 +561,8 @@ async def test_rpc_device_virtual_switch( blocking=True, ) mock_rpc_device.mock_update() - assert hass.states.get(entity_id).state == STATE_ON + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_ON async def test_rpc_device_virtual_binary_sensor( @@ -566,8 +583,7 @@ async def test_rpc_device_virtual_binary_sensor( await init_integration(hass, 3) - state = hass.states.get(entity_id) - assert not state + assert hass.states.get(entity_id) is None async def test_rpc_remove_virtual_switch_when_mode_label( @@ -600,8 +616,7 @@ async def test_rpc_remove_virtual_switch_when_mode_label( await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - entry = entity_registry.async_get(entity_id) - assert not entry + assert entity_registry.async_get(entity_id) is None async def test_rpc_remove_virtual_switch_when_orphaned( @@ -625,8 +640,7 @@ async def test_rpc_remove_virtual_switch_when_orphaned( await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - entry = entity_registry.async_get(entity_id) - assert not entry + assert entity_registry.async_get(entity_id) is None @pytest.mark.usefixtures("entity_registry_enabled_by_default") @@ -656,11 +670,10 @@ async def test_rpc_device_script_switch( await init_integration(hass, 3) - state = hass.states.get(entity_id) - assert state + assert (state := hass.states.get(entity_id)) assert state.state == STATE_ON - entry = entity_registry.async_get(entity_id) - assert entry + + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == f"123456789ABC-{key}-script" monkeypatch.setitem(mock_rpc_device.status[key], "running", False) @@ -671,8 +684,8 @@ async def test_rpc_device_script_switch( blocking=True, ) mock_rpc_device.mock_update() - state = hass.states.get(entity_id) - assert state + + assert (state := hass.states.get(entity_id)) assert state.state == STATE_OFF monkeypatch.setitem(mock_rpc_device.status[key], "running", True) @@ -683,6 +696,6 @@ async def test_rpc_device_script_switch( blocking=True, ) mock_rpc_device.mock_update() - state = hass.states.get(entity_id) - assert state + + assert (state := hass.states.get(entity_id)) assert state.state == STATE_ON diff --git a/tests/components/shelly/test_text.py b/tests/components/shelly/test_text.py index 19acb856f35..a4812cc4160 100644 --- a/tests/components/shelly/test_text.py +++ b/tests/components/shelly/test_text.py @@ -47,17 +47,17 @@ async def test_rpc_device_virtual_text( await init_integration(hass, 3) - state = hass.states.get(entity_id) - assert state + assert (state := hass.states.get(entity_id)) assert state.state == "lorem ipsum" - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-text:203-text" monkeypatch.setitem(mock_rpc_device.status["text:203"], "value", "dolor sit amet") mock_rpc_device.mock_update() - assert hass.states.get(entity_id).state == "dolor sit amet" + + assert (state := hass.states.get(entity_id)) + assert state.state == "dolor sit amet" monkeypatch.setitem(mock_rpc_device.status["text:203"], "value", "sed do eiusmod") await hass.services.async_call( @@ -67,7 +67,9 @@ async def test_rpc_device_virtual_text( blocking=True, ) mock_rpc_device.mock_update() - assert hass.states.get(entity_id).state == "sed do eiusmod" + + assert (state := hass.states.get(entity_id)) + assert state.state == "sed do eiusmod" async def test_rpc_remove_virtual_text_when_mode_label( @@ -100,8 +102,7 @@ async def test_rpc_remove_virtual_text_when_mode_label( await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - entry = entity_registry.async_get(entity_id) - assert not entry + assert entity_registry.async_get(entity_id) is None async def test_rpc_remove_virtual_text_when_orphaned( @@ -125,5 +126,4 @@ async def test_rpc_remove_virtual_text_when_orphaned( await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - entry = entity_registry.async_get(entity_id) - assert not entry + assert entity_registry.async_get(entity_id) is None diff --git a/tests/components/shelly/test_update.py b/tests/components/shelly/test_update.py index 29d72ab4aa8..51016f0cdaa 100644 --- a/tests/components/shelly/test_update.py +++ b/tests/components/shelly/test_update.py @@ -61,14 +61,16 @@ async def test_block_update( monkeypatch.setitem(mock_block_device.status, "cloud", {"connected": False}) await init_integration(hass, 1) - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.state == STATE_ON assert state.attributes[ATTR_INSTALLED_VERSION] == "1.0.0" assert state.attributes[ATTR_LATEST_VERSION] == "2.0.0" assert state.attributes[ATTR_IN_PROGRESS] is False assert state.attributes[ATTR_UPDATE_PERCENTAGE] is None - supported_feat = state.attributes[ATTR_SUPPORTED_FEATURES] - assert supported_feat == UpdateEntityFeature.INSTALL | UpdateEntityFeature.PROGRESS + assert ( + state.attributes[ATTR_SUPPORTED_FEATURES] + == UpdateEntityFeature.INSTALL | UpdateEntityFeature.PROGRESS + ) await hass.services.async_call( UPDATE_DOMAIN, @@ -78,7 +80,7 @@ async def test_block_update( ) assert mock_block_device.trigger_ota_update.call_count == 1 - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.state == STATE_ON assert state.attributes[ATTR_INSTALLED_VERSION] == "1.0.0" assert state.attributes[ATTR_LATEST_VERSION] == "2.0.0" @@ -89,15 +91,14 @@ async def test_block_update( monkeypatch.setitem(mock_block_device.status["update"], "old_version", "2.0.0") await mock_rest_update(hass, freezer) - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.state == STATE_OFF assert state.attributes[ATTR_INSTALLED_VERSION] == "2.0.0" assert state.attributes[ATTR_LATEST_VERSION] == "2.0.0" assert state.attributes[ATTR_IN_PROGRESS] is False assert state.attributes[ATTR_UPDATE_PERCENTAGE] is None - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-fwupdate" @@ -117,7 +118,7 @@ async def test_block_beta_update( monkeypatch.setitem(mock_block_device.status, "cloud", {"connected": False}) await init_integration(hass, 1) - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.state == STATE_OFF assert state.attributes[ATTR_INSTALLED_VERSION] == "1.0.0" assert state.attributes[ATTR_LATEST_VERSION] == "1.0.0" @@ -129,7 +130,7 @@ async def test_block_beta_update( ) await mock_rest_update(hass, freezer) - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.state == STATE_ON assert state.attributes[ATTR_INSTALLED_VERSION] == "1.0.0" assert state.attributes[ATTR_LATEST_VERSION] == "2.0.0-beta" @@ -145,7 +146,7 @@ async def test_block_beta_update( ) assert mock_block_device.trigger_ota_update.call_count == 1 - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.state == STATE_ON assert state.attributes[ATTR_INSTALLED_VERSION] == "1.0.0" assert state.attributes[ATTR_LATEST_VERSION] == "2.0.0-beta" @@ -155,15 +156,14 @@ async def test_block_beta_update( monkeypatch.setitem(mock_block_device.status["update"], "old_version", "2.0.0-beta") await mock_rest_update(hass, freezer) - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.state == STATE_OFF assert state.attributes[ATTR_INSTALLED_VERSION] == "2.0.0-beta" assert state.attributes[ATTR_LATEST_VERSION] == "2.0.0-beta" assert state.attributes[ATTR_IN_PROGRESS] is False assert state.attributes[ATTR_UPDATE_PERCENTAGE] is None - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-fwupdate_beta" @@ -256,11 +256,12 @@ async def test_block_version_compare( monkeypatch.setitem(mock_block_device.status, "cloud", {"connected": False}) await init_integration(hass, 1) - state = hass.states.get(entity_id_latest) + assert (state := hass.states.get(entity_id_latest)) assert state.state == STATE_OFF assert state.attributes[ATTR_INSTALLED_VERSION] == STABLE assert state.attributes[ATTR_LATEST_VERSION] == STABLE - state = hass.states.get(entity_id_beta) + + assert (state := hass.states.get(entity_id_beta)) assert state.state == STATE_ON assert state.attributes[ATTR_INSTALLED_VERSION] == STABLE assert state.attributes[ATTR_LATEST_VERSION] == BETA @@ -270,11 +271,12 @@ async def test_block_version_compare( monkeypatch.setitem(mock_block_device.status["update"], "beta_version", BETA) await mock_rest_update(hass, freezer) - state = hass.states.get(entity_id_latest) + assert (state := hass.states.get(entity_id_latest)) assert state.state == STATE_OFF assert state.attributes[ATTR_INSTALLED_VERSION] == BETA assert state.attributes[ATTR_LATEST_VERSION] == STABLE - state = hass.states.get(entity_id_beta) + + assert (state := hass.states.get(entity_id_beta)) assert state.state == STATE_OFF assert state.attributes[ATTR_INSTALLED_VERSION] == BETA assert state.attributes[ATTR_LATEST_VERSION] == BETA @@ -298,7 +300,7 @@ async def test_rpc_update( ) await init_integration(hass, 2) - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.state == STATE_ON assert state.attributes[ATTR_INSTALLED_VERSION] == "1" assert state.attributes[ATTR_LATEST_VERSION] == "2" @@ -316,7 +318,7 @@ async def test_rpc_update( assert mock_rpc_device.trigger_ota_update.call_count == 1 - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.state == STATE_ON assert state.attributes[ATTR_INSTALLED_VERSION] == "1" assert state.attributes[ATTR_LATEST_VERSION] == "2" @@ -339,7 +341,7 @@ async def test_rpc_update( }, ) - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.attributes[ATTR_IN_PROGRESS] is True assert state.attributes[ATTR_UPDATE_PERCENTAGE] == 0 @@ -359,7 +361,7 @@ async def test_rpc_update( }, ) - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.attributes[ATTR_IN_PROGRESS] is True assert state.attributes[ATTR_UPDATE_PERCENTAGE] == 50 @@ -380,15 +382,14 @@ async def test_rpc_update( monkeypatch.setitem(mock_rpc_device.shelly, "ver", "2") mock_rpc_device.mock_update() - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.state == STATE_OFF assert state.attributes[ATTR_INSTALLED_VERSION] == "2" assert state.attributes[ATTR_LATEST_VERSION] == "2" assert state.attributes[ATTR_IN_PROGRESS] is False assert state.attributes[ATTR_UPDATE_PERCENTAGE] is None - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-sys-fwupdate" @@ -419,7 +420,7 @@ async def test_rpc_sleeping_update( mock_rpc_device.mock_online() await hass.async_block_till_done(wait_background_tasks=True) - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.state == STATE_ON assert state.attributes[ATTR_INSTALLED_VERSION] == "1" assert state.attributes[ATTR_LATEST_VERSION] == "2" @@ -431,7 +432,7 @@ async def test_rpc_sleeping_update( monkeypatch.setitem(mock_rpc_device.shelly, "ver", "2") mock_rpc_device.mock_update() - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.state == STATE_OFF assert state.attributes[ATTR_INSTALLED_VERSION] == "2" assert state.attributes[ATTR_LATEST_VERSION] == "2" @@ -439,8 +440,7 @@ async def test_rpc_sleeping_update( assert state.attributes[ATTR_UPDATE_PERCENTAGE] is None assert state.attributes[ATTR_SUPPORTED_FEATURES] == UpdateEntityFeature(0) - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-sys-fwupdate" @@ -471,7 +471,7 @@ async def test_rpc_restored_sleeping_update( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.state == STATE_ON assert state.attributes[ATTR_INSTALLED_VERSION] == "1" assert state.attributes[ATTR_LATEST_VERSION] == "2" @@ -488,7 +488,7 @@ async def test_rpc_restored_sleeping_update( mock_rpc_device.mock_update() await hass.async_block_till_done() - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.state == STATE_OFF assert state.attributes[ATTR_INSTALLED_VERSION] == "2" assert state.attributes[ATTR_LATEST_VERSION] == "2" @@ -527,7 +527,7 @@ async def test_rpc_restored_sleeping_update_no_last_state( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.state == STATE_UNKNOWN # Make device online @@ -539,7 +539,7 @@ async def test_rpc_restored_sleeping_update_no_last_state( mock_rpc_device.mock_update() await hass.async_block_till_done() - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.state == STATE_ON assert state.attributes[ATTR_INSTALLED_VERSION] == "1" assert state.attributes[ATTR_LATEST_VERSION] == "2" @@ -569,7 +569,7 @@ async def test_rpc_beta_update( ) await init_integration(hass, 2) - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.state == STATE_OFF assert state.attributes[ATTR_INSTALLED_VERSION] == "1" assert state.attributes[ATTR_LATEST_VERSION] == "1" @@ -586,7 +586,7 @@ async def test_rpc_beta_update( ) await mock_rest_update(hass, freezer) - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.state == STATE_ON assert state.attributes[ATTR_INSTALLED_VERSION] == "1" assert state.attributes[ATTR_LATEST_VERSION] == "2b" @@ -616,7 +616,7 @@ async def test_rpc_beta_update( assert mock_rpc_device.trigger_ota_update.call_count == 1 - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.state == STATE_ON assert state.attributes[ATTR_INSTALLED_VERSION] == "1" assert state.attributes[ATTR_LATEST_VERSION] == "2b" @@ -639,7 +639,7 @@ async def test_rpc_beta_update( }, ) - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.attributes[ATTR_IN_PROGRESS] is True assert state.attributes[ATTR_UPDATE_PERCENTAGE] == 40 @@ -660,15 +660,14 @@ async def test_rpc_beta_update( monkeypatch.setitem(mock_rpc_device.shelly, "ver", "2b") await mock_rest_update(hass, freezer) - state = hass.states.get(entity_id) + assert (state := hass.states.get(entity_id)) assert state.state == STATE_OFF assert state.attributes[ATTR_INSTALLED_VERSION] == "2b" assert state.attributes[ATTR_LATEST_VERSION] == "2b" assert state.attributes[ATTR_IN_PROGRESS] is False assert state.attributes[ATTR_UPDATE_PERCENTAGE] is None - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-sys-fwupdate_beta" diff --git a/tests/components/shelly/test_valve.py b/tests/components/shelly/test_valve.py index 9dc8597120a..7bf9e3b5f1a 100644 --- a/tests/components/shelly/test_valve.py +++ b/tests/components/shelly/test_valve.py @@ -25,11 +25,11 @@ async def test_block_device_gas_valve( await init_integration(hass, 1, MODEL_GAS) entity_id = "valve.test_name_valve" - entry = entity_registry.async_get(entity_id) - assert entry + assert (entry := entity_registry.async_get(entity_id)) assert entry.unique_id == "123456789ABC-valve_0-valve" - assert hass.states.get(entity_id).state == ValveState.CLOSED + assert (state := hass.states.get(entity_id)) + assert state.state == ValveState.CLOSED await hass.services.async_call( VALVE_DOMAIN, @@ -38,16 +38,14 @@ async def test_block_device_gas_valve( blocking=True, ) - state = hass.states.get(entity_id) - assert state + assert (state := hass.states.get(entity_id)) assert state.state == ValveState.OPENING monkeypatch.setattr(mock_block_device.blocks[GAS_VALVE_BLOCK_ID], "valve", "opened") mock_block_device.mock_update() await hass.async_block_till_done() - state = hass.states.get(entity_id) - assert state + assert (state := hass.states.get(entity_id)) assert state.state == ValveState.OPEN await hass.services.async_call( @@ -57,14 +55,12 @@ async def test_block_device_gas_valve( blocking=True, ) - state = hass.states.get(entity_id) - assert state + assert (state := hass.states.get(entity_id)) assert state.state == ValveState.CLOSING monkeypatch.setattr(mock_block_device.blocks[GAS_VALVE_BLOCK_ID], "valve", "closed") mock_block_device.mock_update() await hass.async_block_till_done() - state = hass.states.get(entity_id) - assert state + assert (state := hass.states.get(entity_id)) assert state.state == ValveState.CLOSED From f0c774a4bdcceee7fa7a6878357a54435503675b Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 26 Mar 2025 10:16:10 +0100 Subject: [PATCH 3058/3148] Small grammar fixes in `hue` user strings (#141446) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit … including proper sentence-casing --- homeassistant/components/hue/strings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/hue/strings.json b/homeassistant/components/hue/strings.json index 7860c2a297e..6d2e9054c6f 100644 --- a/homeassistant/components/hue/strings.json +++ b/homeassistant/components/hue/strings.json @@ -11,7 +11,7 @@ } }, "manual": { - "title": "Manual configure a Hue bridge", + "title": "Manually configure a Hue bridge", "data": { "host": "[%key:common::config_flow::data::host%]" }, @@ -46,8 +46,8 @@ "button_2": "Second button", "button_3": "Third button", "button_4": "Fourth button", - "double_buttons_1_3": "First and Third buttons", - "double_buttons_2_4": "Second and Fourth buttons", + "double_buttons_1_3": "First and third button", + "double_buttons_2_4": "Second and fourth button", "dim_down": "Dim down", "dim_up": "Dim up", "turn_off": "[%key:common::action::turn_off%]", From b5117eb0717c1db32bbb350055fca3d5a7294bec Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Wed, 26 Mar 2025 10:22:43 +0100 Subject: [PATCH 3059/3148] Proper handling of unavailable Synology DSM nas during backup (#140721) * raise BackupAgentUnreachableError when NAS is unavailable * also raise BackupAgentUnreachableError during upload when nas unavailable * Revert "also raise BackupAgentUnreachableError during upload when nas unavailable" This reverts commit 38877d8540aa3c61c366069dc063bb9b4d866c48. * Revert "raise BackupAgentUnreachableError when NAS is unavailable" This reverts commit 4d8cfae396ea3be3409ed8f4784b9e2448954a04. * check last_update_success of coordinator_central to get backup agents * consider last_update_success before notify backup listeners * add test * use walrus := :) --- .../components/synology_dsm/__init__.py | 12 +++++ .../components/synology_dsm/backup.py | 1 + .../components/synology_dsm/coordinator.py | 1 + tests/components/synology_dsm/test_backup.py | 50 ++++++++++++++++++- 4 files changed, 63 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/synology_dsm/__init__.py b/homeassistant/components/synology_dsm/__init__.py index 70c7e76a53a..d9319beb595 100644 --- a/homeassistant/components/synology_dsm/__init__.py +++ b/homeassistant/components/synology_dsm/__init__.py @@ -123,6 +123,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: SynologyDSMConfigEntry) entry.runtime_data = SynologyDSMData( api=api, coordinator_central=coordinator_central, + coordinator_central_old_update_success=True, coordinator_cameras=coordinator_cameras, coordinator_switches=coordinator_switches, ) @@ -139,6 +140,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: SynologyDSMConfigEntry) entry.async_on_state_change(async_notify_backup_listeners) ) + def async_check_last_update_success() -> None: + if ( + last := coordinator_central.last_update_success + ) is not entry.runtime_data.coordinator_central_old_update_success: + entry.runtime_data.coordinator_central_old_update_success = last + async_notify_backup_listeners() + + entry.runtime_data.coordinator_central.async_add_listener( + async_check_last_update_success + ) + return True diff --git a/homeassistant/components/synology_dsm/backup.py b/homeassistant/components/synology_dsm/backup.py index 11f4287dea2..46e47ebde16 100644 --- a/homeassistant/components/synology_dsm/backup.py +++ b/homeassistant/components/synology_dsm/backup.py @@ -58,6 +58,7 @@ async def async_get_backup_agents( if entry.unique_id is not None and entry.runtime_data.api.file_station and entry.options.get(CONF_BACKUP_PATH) + and entry.runtime_data.coordinator_central.last_update_success ] diff --git a/homeassistant/components/synology_dsm/coordinator.py b/homeassistant/components/synology_dsm/coordinator.py index a35432f0774..dd97dedf65e 100644 --- a/homeassistant/components/synology_dsm/coordinator.py +++ b/homeassistant/components/synology_dsm/coordinator.py @@ -35,6 +35,7 @@ class SynologyDSMData: api: SynoApi coordinator_central: SynologyDSMCentralUpdateCoordinator + coordinator_central_old_update_success: bool coordinator_cameras: SynologyDSMCameraUpdateCoordinator | None coordinator_switches: SynologyDSMSwitchUpdateCoordinator | None diff --git a/tests/components/synology_dsm/test_backup.py b/tests/components/synology_dsm/test_backup.py index 8475a253231..db0062b45bf 100644 --- a/tests/components/synology_dsm/test_backup.py +++ b/tests/components/synology_dsm/test_backup.py @@ -4,9 +4,13 @@ from io import StringIO from typing import Any from unittest.mock import ANY, AsyncMock, MagicMock, Mock, patch +from freezegun.api import FrozenDateTimeFactory import pytest from synology_dsm.api.file_station.models import SynoFileFile, SynoFileSharedFolder -from synology_dsm.exceptions import SynologyDSMAPIErrorException +from synology_dsm.exceptions import ( + SynologyDSMAPIErrorException, + SynologyDSMRequestException, +) from homeassistant.components.backup import ( DOMAIN as BACKUP_DOMAIN, @@ -279,6 +283,50 @@ async def test_agents_on_unload( } +async def test_agents_on_changed_update_success( + hass: HomeAssistant, + setup_dsm_with_filestation: MagicMock, + hass_ws_client: WebSocketGenerator, + freezer: FrozenDateTimeFactory, +) -> None: + """Test backup agent on changed update success of coordintaor.""" + client = await hass_ws_client(hass) + + # config entry is loaded + await client.send_json_auto_id({"type": "backup/agents/info"}) + response = await client.receive_json() + assert response["success"] + assert len(response["result"]["agents"]) == 2 + + # coordinator update was successful + freezer.tick(910) # 15 min interval + 10s + await hass.async_block_till_done(wait_background_tasks=True) + await client.send_json_auto_id({"type": "backup/agents/info"}) + response = await client.receive_json() + assert response["success"] + assert len(response["result"]["agents"]) == 2 + + # coordinator update was un-successful + setup_dsm_with_filestation.update.side_effect = SynologyDSMRequestException( + OSError() + ) + freezer.tick(910) + await hass.async_block_till_done(wait_background_tasks=True) + await client.send_json_auto_id({"type": "backup/agents/info"}) + response = await client.receive_json() + assert response["success"] + assert len(response["result"]["agents"]) == 1 + + # coordinator update was successful again + setup_dsm_with_filestation.update.side_effect = None + freezer.tick(910) + await hass.async_block_till_done(wait_background_tasks=True) + await client.send_json_auto_id({"type": "backup/agents/info"}) + response = await client.receive_json() + assert response["success"] + assert len(response["result"]["agents"]) == 2 + + async def test_agents_list_backups( hass: HomeAssistant, setup_dsm_with_filestation: MagicMock, From 63a86763b16f6a00a1d980850456315a959a3773 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 26 Mar 2025 10:23:20 +0100 Subject: [PATCH 3060/3148] Migrate unique ids in SmartThings (#141308) * Migrate unique ids in SmartThings * Migrate * Migrate * Migrate * Fix * Fix --- .../components/smartthings/__init__.py | 105 +++++- .../components/smartthings/binary_sensor.py | 5 +- .../components/smartthings/config_flow.py | 1 + homeassistant/components/smartthings/const.py | 74 ++++ .../components/smartthings/entity.py | 2 +- .../components/smartthings/number.py | 4 +- .../components/smartthings/select.py | 4 +- .../components/smartthings/sensor.py | 12 +- .../components/smartthings/switch.py | 4 +- tests/components/smartthings/conftest.py | 1 + .../snapshots/test_binary_sensor.ambr | 80 ++--- .../smartthings/snapshots/test_climate.ambr | 20 +- .../smartthings/snapshots/test_cover.ambr | 4 +- .../smartthings/snapshots/test_fan.ambr | 4 +- .../smartthings/snapshots/test_light.ambr | 10 +- .../smartthings/snapshots/test_lock.ambr | 2 +- .../snapshots/test_media_player.ambr | 10 +- .../smartthings/snapshots/test_number.ambr | 4 +- .../smartthings/snapshots/test_select.ambr | 8 +- .../smartthings/snapshots/test_sensor.ambr | 318 +++++++++--------- .../smartthings/snapshots/test_switch.ambr | 40 +-- .../smartthings/snapshots/test_update.ambr | 14 +- .../smartthings/snapshots/test_valve.ambr | 2 +- .../smartthings/test_config_flow.py | 4 +- tests/components/smartthings/test_init.py | 274 ++++++++++++++- 25 files changed, 719 insertions(+), 287 deletions(-) diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py index e4d50fb3590..ab7df490bd3 100644 --- a/homeassistant/components/smartthings/__init__.py +++ b/homeassistant/components/smartthings/__init__.py @@ -40,14 +40,16 @@ from homeassistant.const import ( ) from homeassistant.core import Event, HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.config_entry_oauth2_flow import ( OAuth2Session, async_get_config_entry_implementation, ) +from homeassistant.helpers.entity_registry import RegistryEntry, async_migrate_entries from .const import ( + BINARY_SENSOR_ATTRIBUTES_TO_CAPABILITIES, CONF_INSTALLED_APP_ID, CONF_LOCATION_ID, CONF_SUBSCRIPTION_ID, @@ -55,6 +57,7 @@ from .const import ( EVENT_BUTTON, MAIN, OLD_DATA, + SENSOR_ATTRIBUTES_TO_CAPABILITIES, ) _LOGGER = logging.getLogger(__name__) @@ -297,9 +300,109 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry, version=3, data={OLD_DATA: dict(entry.data)} ) + if entry.minor_version < 2: + + def migrate_entities(entity_entry: RegistryEntry) -> dict[str, Any] | None: + if entity_entry.domain == "binary_sensor": + device_id, attribute = entity_entry.unique_id.split(".") + if ( + capability := BINARY_SENSOR_ATTRIBUTES_TO_CAPABILITIES.get( + attribute + ) + ) is None: + return None + new_unique_id = ( + f"{device_id}_{MAIN}_{capability}_{attribute}_{attribute}" + ) + return { + "new_unique_id": new_unique_id, + } + if entity_entry.domain in {"cover", "climate", "fan", "light", "lock"}: + return {"new_unique_id": f"{entity_entry.unique_id}_{MAIN}"} + if entity_entry.domain == "sensor": + delimiter = "." if " " not in entity_entry.unique_id else " " + if delimiter not in entity_entry.unique_id: + return None + device_id, attribute = entity_entry.unique_id.split( + delimiter, maxsplit=1 + ) + if ( + capability := SENSOR_ATTRIBUTES_TO_CAPABILITIES.get(attribute) + ) is None: + if attribute in { + "energy_meter", + "power_meter", + "deltaEnergy_meter", + "powerEnergy_meter", + "energySaved_meter", + }: + return { + "new_unique_id": f"{device_id}_{MAIN}_{Capability.POWER_CONSUMPTION_REPORT}_{Attribute.POWER_CONSUMPTION}_{attribute}", + } + if attribute in { + "X Coordinate", + "Y Coordinate", + "Z Coordinate", + }: + new_attribute = { + "X Coordinate": "x_coordinate", + "Y Coordinate": "y_coordinate", + "Z Coordinate": "z_coordinate", + }[attribute] + return { + "new_unique_id": f"{device_id}_{MAIN}_{Capability.THREE_AXIS}_{Attribute.THREE_AXIS}_{new_attribute}", + } + if attribute == Attribute.MACHINE_STATE: + capability = determine_machine_type( + hass, entry.entry_id, device_id + ) + if capability is None: + return None + return { + "new_unique_id": f"{device_id}_{MAIN}_{capability}_{attribute}_{attribute}", + } + return None + return { + "new_unique_id": f"{device_id}_{MAIN}_{capability}_{attribute}_{attribute}", + } + + if entity_entry.domain == "switch": + return { + "new_unique_id": f"{entity_entry.unique_id}_{MAIN}_{Capability.SWITCH}_{Attribute.SWITCH}_{Attribute.SWITCH}", + } + + return None + + await async_migrate_entries(hass, entry.entry_id, migrate_entities) + hass.config_entries.async_update_entry( + entry, + minor_version=2, + ) + return True +def determine_machine_type( + hass: HomeAssistant, + entry_id: str, + device_id: str, +) -> Capability | None: + """Determine the machine type for a device.""" + entity_registry = er.async_get(hass) + entries = er.async_entries_for_config_entry(entity_registry, entry_id) + device_entries = [entry for entry in entries if device_id in entry.unique_id] + for entry in device_entries: + if Attribute.DISHWASHER_JOB_STATE in entry.unique_id: + return Capability.DISHWASHER_OPERATING_STATE + if Attribute.WASHER_JOB_STATE in entry.unique_id: + return Capability.WASHER_OPERATING_STATE + if Attribute.DRYER_JOB_STATE in entry.unique_id: + return Capability.DRYER_OPERATING_STATE + if Attribute.OVEN_JOB_STATE in entry.unique_id: + return Capability.OVEN_OPERATING_STATE + return None + + def create_devices( device_registry: dr.DeviceRegistry, devices: dict[str, FullDevice], diff --git a/homeassistant/components/smartthings/binary_sensor.py b/homeassistant/components/smartthings/binary_sensor.py index e42a32abdd2..8e4f5c3878e 100644 --- a/homeassistant/components/smartthings/binary_sensor.py +++ b/homeassistant/components/smartthings/binary_sensor.py @@ -229,7 +229,7 @@ class SmartThingsBinarySensor(SmartThingsEntity, BinarySensorEntity): self._attribute = attribute self.capability = capability self.entity_description = entity_description - self._attr_unique_id = f"{device.device.device_id}.{attribute}" + self._attr_unique_id = f"{device.device.device_id}_{component}_{capability}_{attribute}_{attribute}" if ( entity_description.category_device_class and (category := get_main_component_category(device)) @@ -247,9 +247,6 @@ class SmartThingsBinarySensor(SmartThingsEntity, BinarySensorEntity): is not None ): self._attr_translation_key = translation_key - self._attr_unique_id = ( - f"{device.device.device_id}_{component}_{capability}_{attribute}" - ) @property def is_on(self) -> bool: diff --git a/homeassistant/components/smartthings/config_flow.py b/homeassistant/components/smartthings/config_flow.py index d2654348527..03c8e4bfa66 100644 --- a/homeassistant/components/smartthings/config_flow.py +++ b/homeassistant/components/smartthings/config_flow.py @@ -20,6 +20,7 @@ class SmartThingsConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN): """Handle configuration of SmartThings integrations.""" VERSION = 3 + MINOR_VERSION = 2 DOMAIN = DOMAIN @property diff --git a/homeassistant/components/smartthings/const.py b/homeassistant/components/smartthings/const.py index 2ba59ade4e8..a3ec9a38200 100644 --- a/homeassistant/components/smartthings/const.py +++ b/homeassistant/components/smartthings/const.py @@ -1,5 +1,7 @@ """Constants used by the SmartThings component and platforms.""" +from pysmartthings import Attribute, Capability + DOMAIN = "smartthings" SCOPES = [ @@ -35,3 +37,75 @@ OLD_DATA = "old_data" CONF_SUBSCRIPTION_ID = "subscription_id" EVENT_BUTTON = "smartthings.button" + +BINARY_SENSOR_ATTRIBUTES_TO_CAPABILITIES: dict[str, str] = { + Attribute.ACCELERATION: Capability.ACCELERATION_SENSOR, + Attribute.CONTACT: Capability.CONTACT_SENSOR, + Attribute.FILTER_STATUS: Capability.FILTER_STATUS, + Attribute.MOTION: Capability.MOTION_SENSOR, + Attribute.PRESENCE: Capability.PRESENCE_SENSOR, + Attribute.SOUND: Capability.SOUND_SENSOR, + Attribute.TAMPER: Capability.TAMPER_ALERT, + Attribute.VALVE: Capability.VALVE, + Attribute.WATER: Capability.WATER_SENSOR, +} + +SENSOR_ATTRIBUTES_TO_CAPABILITIES: dict[str, str] = { + Attribute.LIGHTING_MODE: Capability.ACTIVITY_LIGHTING_MODE, + Attribute.AIR_CONDITIONER_MODE: Capability.AIR_CONDITIONER_MODE, + Attribute.AIR_QUALITY: Capability.AIR_QUALITY_SENSOR, + Attribute.ALARM: Capability.ALARM, + Attribute.BATTERY: Capability.BATTERY, + Attribute.BMI_MEASUREMENT: Capability.BODY_MASS_INDEX_MEASUREMENT, + Attribute.BODY_WEIGHT_MEASUREMENT: Capability.BODY_WEIGHT_MEASUREMENT, + Attribute.CARBON_DIOXIDE: Capability.CARBON_DIOXIDE_MEASUREMENT, + Attribute.CARBON_MONOXIDE: Capability.CARBON_MONOXIDE_MEASUREMENT, + Attribute.CARBON_MONOXIDE_LEVEL: Capability.CARBON_MONOXIDE_MEASUREMENT, + Attribute.DISHWASHER_JOB_STATE: Capability.DISHWASHER_OPERATING_STATE, + Attribute.DRYER_MODE: Capability.DRYER_MODE, + Attribute.DRYER_JOB_STATE: Capability.DRYER_OPERATING_STATE, + Attribute.DUST_LEVEL: Capability.DUST_SENSOR, + Attribute.FINE_DUST_LEVEL: Capability.DUST_SENSOR, + Attribute.ENERGY: Capability.ENERGY_METER, + Attribute.EQUIVALENT_CARBON_DIOXIDE_MEASUREMENT: Capability.EQUIVALENT_CARBON_DIOXIDE_MEASUREMENT, + Attribute.FORMALDEHYDE_LEVEL: Capability.FORMALDEHYDE_MEASUREMENT, + Attribute.GAS_METER: Capability.GAS_METER, + Attribute.GAS_METER_CALORIFIC: Capability.GAS_METER, + Attribute.GAS_METER_TIME: Capability.GAS_METER, + Attribute.GAS_METER_VOLUME: Capability.GAS_METER, + Attribute.ILLUMINANCE: Capability.ILLUMINANCE_MEASUREMENT, + Attribute.INFRARED_LEVEL: Capability.INFRARED_LEVEL, + Attribute.INPUT_SOURCE: Capability.MEDIA_INPUT_SOURCE, + Attribute.PLAYBACK_REPEAT_MODE: Capability.MEDIA_PLAYBACK_REPEAT, + Attribute.PLAYBACK_SHUFFLE: Capability.MEDIA_PLAYBACK_SHUFFLE, + Attribute.PLAYBACK_STATUS: Capability.MEDIA_PLAYBACK, + Attribute.ODOR_LEVEL: Capability.ODOR_SENSOR, + Attribute.OVEN_MODE: Capability.OVEN_MODE, + Attribute.OVEN_JOB_STATE: Capability.OVEN_OPERATING_STATE, + Attribute.OVEN_SETPOINT: Capability.OVEN_SETPOINT, + Attribute.POWER: Capability.POWER_METER, + Attribute.POWER_SOURCE: Capability.POWER_SOURCE, + Attribute.REFRIGERATION_SETPOINT: Capability.REFRIGERATION_SETPOINT, + Attribute.HUMIDITY: Capability.RELATIVE_HUMIDITY_MEASUREMENT, + Attribute.ROBOT_CLEANER_CLEANING_MODE: Capability.ROBOT_CLEANER_CLEANING_MODE, + Attribute.ROBOT_CLEANER_MOVEMENT: Capability.ROBOT_CLEANER_MOVEMENT, + Attribute.ROBOT_CLEANER_TURBO_MODE: Capability.ROBOT_CLEANER_TURBO_MODE, + Attribute.LQI: Capability.SIGNAL_STRENGTH, + Attribute.RSSI: Capability.SIGNAL_STRENGTH, + Attribute.SMOKE: Capability.SMOKE_DETECTOR, + Attribute.TEMPERATURE: Capability.TEMPERATURE_MEASUREMENT, + Attribute.COOLING_SETPOINT: Capability.THERMOSTAT_COOLING_SETPOINT, + Attribute.THERMOSTAT_FAN_MODE: Capability.THERMOSTAT_FAN_MODE, + Attribute.HEATING_SETPOINT: Capability.THERMOSTAT_HEATING_SETPOINT, + Attribute.THERMOSTAT_MODE: Capability.THERMOSTAT_MODE, + Attribute.THERMOSTAT_OPERATING_STATE: Capability.THERMOSTAT_OPERATING_STATE, + Attribute.THERMOSTAT_SETPOINT: Capability.THERMOSTAT_SETPOINT, + Attribute.TV_CHANNEL: Capability.TV_CHANNEL, + Attribute.TV_CHANNEL_NAME: Capability.TV_CHANNEL, + Attribute.TVOC_LEVEL: Capability.TVOC_MEASUREMENT, + Attribute.ULTRAVIOLET_INDEX: Capability.ULTRAVIOLET_INDEX, + Attribute.VERY_FINE_DUST_LEVEL: Capability.VERY_FINE_DUST_SENSOR, + Attribute.VOLTAGE: Capability.VOLTAGE_MEASUREMENT, + Attribute.WASHER_MODE: Capability.WASHER_MODE, + Attribute.WASHER_JOB_STATE: Capability.WASHER_OPERATING_STATE, +} diff --git a/homeassistant/components/smartthings/entity.py b/homeassistant/components/smartthings/entity.py index 3314d4b868d..5544297a4c6 100644 --- a/homeassistant/components/smartthings/entity.py +++ b/homeassistant/components/smartthings/entity.py @@ -44,7 +44,7 @@ class SmartThingsEntity(Entity): if capability in device.status[component] } self.device = device - self._attr_unique_id = device.device.device_id + self._attr_unique_id = f"{device.device.device_id}_{component}" self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, device.device.device_id)}, ) diff --git a/homeassistant/components/smartthings/number.py b/homeassistant/components/smartthings/number.py index cbd200e20b6..bb21520e271 100644 --- a/homeassistant/components/smartthings/number.py +++ b/homeassistant/components/smartthings/number.py @@ -36,9 +36,7 @@ class SmartThingsWasherRinseCyclesNumberEntity(SmartThingsEntity, NumberEntity): def __init__(self, client: SmartThings, device: FullDevice) -> None: """Initialize the instance.""" super().__init__(client, device, {Capability.CUSTOM_WASHER_RINSE_CYCLES}) - self._attr_unique_id = ( - f"{device.device.device_id}_{MAIN}_{Capability.CUSTOM_WASHER_RINSE_CYCLES}" - ) + self._attr_unique_id = f"{device.device.device_id}_{MAIN}_{Capability.CUSTOM_WASHER_RINSE_CYCLES}_{Attribute.WASHER_RINSE_CYCLES}_{Attribute.WASHER_RINSE_CYCLES}" @property def options(self) -> list[int]: diff --git a/homeassistant/components/smartthings/select.py b/homeassistant/components/smartthings/select.py index 6011b7947b7..0bb2e7c71db 100644 --- a/homeassistant/components/smartthings/select.py +++ b/homeassistant/components/smartthings/select.py @@ -83,9 +83,7 @@ class SmartThingsSelectEntity(SmartThingsEntity, SelectEntity): capabilities.add(Capability.REMOTE_CONTROL_STATUS) super().__init__(client, device, capabilities) self.entity_description = entity_description - self._attr_unique_id = ( - f"{device.device.device_id}_{MAIN}_{entity_description.key}" - ) + self._attr_unique_id = f"{device.device.device_id}_{MAIN}_{entity_description.key}_{entity_description.status_attribute}_{entity_description.status_attribute}" @property def options(self) -> list[str]: diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index 0b5cbd3d332..87ae1488329 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -128,7 +128,6 @@ class SmartThingsSensorEntityDescription(SensorEntityDescription): value_fn: Callable[[Any], str | float | int | datetime | None] = lambda value: value extra_state_attributes_fn: Callable[[Any], dict[str, Any]] | None = None - unique_id_separator: str = "." capability_ignore_list: list[set[Capability]] | None = None options_attribute: Attribute | None = None exists_fn: Callable[[Status], bool] | None = None @@ -855,21 +854,18 @@ CAPABILITY_TO_SENSORS: dict[ Capability.THREE_AXIS: { Attribute.THREE_AXIS: [ SmartThingsSensorEntityDescription( - key="X Coordinate", + key="x_coordinate", translation_key="x_coordinate", - unique_id_separator=" ", value_fn=lambda value: value[0], ), SmartThingsSensorEntityDescription( - key="Y Coordinate", + key="y_coordinate", translation_key="y_coordinate", - unique_id_separator=" ", value_fn=lambda value: value[1], ), SmartThingsSensorEntityDescription( - key="Z Coordinate", + key="z_coordinate", translation_key="z_coordinate", - unique_id_separator=" ", value_fn=lambda value: value[2], ), ] @@ -1046,7 +1042,7 @@ class SmartThingsSensor(SmartThingsEntity, SensorEntity): if entity_description.use_temperature_unit: capabilities_to_subscribe.add(Capability.TEMPERATURE_MEASUREMENT) super().__init__(client, device, capabilities_to_subscribe) - self._attr_unique_id = f"{device.device.device_id}{entity_description.unique_id_separator}{entity_description.key}" + self._attr_unique_id = f"{device.device.device_id}_{MAIN}_{capability}_{attribute}_{entity_description.key}" self._attribute = attribute self.capability = capability self.entity_description = entity_description diff --git a/homeassistant/components/smartthings/switch.py b/homeassistant/components/smartthings/switch.py index 014b11c5329..a03decd73c0 100644 --- a/homeassistant/components/smartthings/switch.py +++ b/homeassistant/components/smartthings/switch.py @@ -123,9 +123,7 @@ class SmartThingsSwitch(SmartThingsEntity, SwitchEntity): super().__init__(client, device, {capability}) self.entity_description = entity_description self.switch_capability = capability - self._attr_unique_id = device.device.device_id - if capability is not Capability.SWITCH: - self._attr_unique_id = f"{device.device.device_id}_{MAIN}_{capability}" + self._attr_unique_id = f"{device.device.device_id}_{MAIN}_{capability}_{entity_description.status_attribute}_{entity_description.status_attribute}" async def async_turn_off(self, **kwargs: Any) -> None: """Turn the switch off.""" diff --git a/tests/components/smartthings/conftest.py b/tests/components/smartthings/conftest.py index 8a4d830af5a..ad0399a7a6c 100644 --- a/tests/components/smartthings/conftest.py +++ b/tests/components/smartthings/conftest.py @@ -184,6 +184,7 @@ def mock_config_entry(expires_at: int) -> MockConfigEntry: CONF_INSTALLED_APP_ID: "123", }, version=3, + minor_version=2, ) diff --git a/tests/components/smartthings/snapshots/test_binary_sensor.ambr b/tests/components/smartthings/snapshots/test_binary_sensor.ambr index 47d9bb9586a..dcef62cb266 100644 --- a/tests/components/smartthings/snapshots/test_binary_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_binary_sensor.ambr @@ -29,7 +29,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '10e06a70-ee7d-4832-85e9-a0a06a7a05bd.motion', + 'unique_id': '10e06a70-ee7d-4832-85e9-a0a06a7a05bd_main_motionSensor_motion_motion', 'unit_of_measurement': None, }) # --- @@ -77,7 +77,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '10e06a70-ee7d-4832-85e9-a0a06a7a05bd.sound', + 'unique_id': '10e06a70-ee7d-4832-85e9-a0a06a7a05bd_main_soundSensor_sound_sound', 'unit_of_measurement': None, }) # --- @@ -125,7 +125,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '2d9a892b-1c93-45a5-84cb-0e81889498c6.contact', + 'unique_id': '2d9a892b-1c93-45a5-84cb-0e81889498c6_main_contactSensor_contact_contact', 'unit_of_measurement': None, }) # --- @@ -173,7 +173,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'child_lock', - 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a.lockState', + 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a_main_samsungce.kidsLock_lockState_lockState', 'unit_of_measurement': None, }) # --- @@ -220,7 +220,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'door', - 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a.doorState', + 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a_main_samsungce.doorState_doorState_doorState', 'unit_of_measurement': None, }) # --- @@ -268,7 +268,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a.switch', + 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a_main_switch_switch_switch', 'unit_of_measurement': None, }) # --- @@ -316,7 +316,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'remote_control', - 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a.remoteControlEnabled', + 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a_main_remoteControlStatus_remoteControlEnabled_remoteControlEnabled', 'unit_of_measurement': None, }) # --- @@ -363,7 +363,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'child_lock', - 'unique_id': '9447959a-0dfa-6b27-d40d-650da525c53f.lockState', + 'unique_id': '9447959a-0dfa-6b27-d40d-650da525c53f_main_samsungce.kidsLock_lockState_lockState', 'unit_of_measurement': None, }) # --- @@ -410,7 +410,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'door', - 'unique_id': '9447959a-0dfa-6b27-d40d-650da525c53f.doorState', + 'unique_id': '9447959a-0dfa-6b27-d40d-650da525c53f_main_samsungce.doorState_doorState_doorState', 'unit_of_measurement': None, }) # --- @@ -458,7 +458,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'remote_control', - 'unique_id': '9447959a-0dfa-6b27-d40d-650da525c53f.remoteControlEnabled', + 'unique_id': '9447959a-0dfa-6b27-d40d-650da525c53f_main_remoteControlStatus_remoteControlEnabled_remoteControlEnabled', 'unit_of_measurement': None, }) # --- @@ -505,7 +505,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'child_lock', - 'unique_id': '2c3cbaa0-1899-5ddc-7b58-9d657bd48f18.lockState', + 'unique_id': '2c3cbaa0-1899-5ddc-7b58-9d657bd48f18_main_samsungce.kidsLock_lockState_lockState', 'unit_of_measurement': None, }) # --- @@ -552,7 +552,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'door', - 'unique_id': '2c3cbaa0-1899-5ddc-7b58-9d657bd48f18.doorState', + 'unique_id': '2c3cbaa0-1899-5ddc-7b58-9d657bd48f18_main_samsungce.doorState_doorState_doorState', 'unit_of_measurement': None, }) # --- @@ -600,7 +600,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'remote_control', - 'unique_id': '2c3cbaa0-1899-5ddc-7b58-9d657bd48f18.remoteControlEnabled', + 'unique_id': '2c3cbaa0-1899-5ddc-7b58-9d657bd48f18_main_remoteControlStatus_remoteControlEnabled_remoteControlEnabled', 'unit_of_measurement': None, }) # --- @@ -647,7 +647,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'cooler_door', - 'unique_id': '7db87911-7dce-1cf2-7119-b953432a2f09_cooler_contactSensor_contact', + 'unique_id': '7db87911-7dce-1cf2-7119-b953432a2f09_cooler_contactSensor_contact_contact', 'unit_of_measurement': None, }) # --- @@ -695,7 +695,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '7db87911-7dce-1cf2-7119-b953432a2f09.contact', + 'unique_id': '7db87911-7dce-1cf2-7119-b953432a2f09_main_contactSensor_contact_contact', 'unit_of_measurement': None, }) # --- @@ -743,7 +743,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'freezer_door', - 'unique_id': '7db87911-7dce-1cf2-7119-b953432a2f09_freezer_contactSensor_contact', + 'unique_id': '7db87911-7dce-1cf2-7119-b953432a2f09_freezer_contactSensor_contact_contact', 'unit_of_measurement': None, }) # --- @@ -791,7 +791,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'child_lock', - 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676.lockState', + 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676_main_samsungce.kidsLock_lockState_lockState', 'unit_of_measurement': None, }) # --- @@ -838,7 +838,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676.switch', + 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676_main_switch_switch_switch', 'unit_of_measurement': None, }) # --- @@ -886,7 +886,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'remote_control', - 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676.remoteControlEnabled', + 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676_main_remoteControlStatus_remoteControlEnabled_remoteControlEnabled', 'unit_of_measurement': None, }) # --- @@ -933,7 +933,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'child_lock', - 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b.lockState', + 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b_main_samsungce.kidsLock_lockState_lockState', 'unit_of_measurement': None, }) # --- @@ -980,7 +980,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b.switch', + 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b_main_switch_switch_switch', 'unit_of_measurement': None, }) # --- @@ -1028,7 +1028,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'remote_control', - 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b.remoteControlEnabled', + 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b_main_remoteControlStatus_remoteControlEnabled_remoteControlEnabled', 'unit_of_measurement': None, }) # --- @@ -1075,7 +1075,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'dryer_wrinkle_prevent_active', - 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b.operatingState', + 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b_main_custom.dryerWrinklePrevent_operatingState_operatingState', 'unit_of_measurement': None, }) # --- @@ -1122,7 +1122,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'child_lock', - 'unique_id': '3a6c4e05-811d-5041-e956-3d04c424cbcd.lockState', + 'unique_id': '3a6c4e05-811d-5041-e956-3d04c424cbcd_main_samsungce.kidsLock_lockState_lockState', 'unit_of_measurement': None, }) # --- @@ -1169,7 +1169,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '3a6c4e05-811d-5041-e956-3d04c424cbcd.switch', + 'unique_id': '3a6c4e05-811d-5041-e956-3d04c424cbcd_main_switch_switch_switch', 'unit_of_measurement': None, }) # --- @@ -1217,7 +1217,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'remote_control', - 'unique_id': '3a6c4e05-811d-5041-e956-3d04c424cbcd.remoteControlEnabled', + 'unique_id': '3a6c4e05-811d-5041-e956-3d04c424cbcd_main_remoteControlStatus_remoteControlEnabled_remoteControlEnabled', 'unit_of_measurement': None, }) # --- @@ -1264,7 +1264,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'dryer_wrinkle_prevent_active', - 'unique_id': '3a6c4e05-811d-5041-e956-3d04c424cbcd.operatingState', + 'unique_id': '3a6c4e05-811d-5041-e956-3d04c424cbcd_main_custom.dryerWrinklePrevent_operatingState_operatingState', 'unit_of_measurement': None, }) # --- @@ -1311,7 +1311,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'child_lock', - 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47.lockState', + 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47_main_samsungce.kidsLock_lockState_lockState', 'unit_of_measurement': None, }) # --- @@ -1358,7 +1358,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47.switch', + 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47_main_switch_switch_switch', 'unit_of_measurement': None, }) # --- @@ -1406,7 +1406,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'remote_control', - 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47.remoteControlEnabled', + 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47_main_remoteControlStatus_remoteControlEnabled_remoteControlEnabled', 'unit_of_measurement': None, }) # --- @@ -1453,7 +1453,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'child_lock', - 'unique_id': '63803fae-cbed-f356-a063-2cf148ae3ca7.lockState', + 'unique_id': '63803fae-cbed-f356-a063-2cf148ae3ca7_main_samsungce.kidsLock_lockState_lockState', 'unit_of_measurement': None, }) # --- @@ -1500,7 +1500,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '63803fae-cbed-f356-a063-2cf148ae3ca7.switch', + 'unique_id': '63803fae-cbed-f356-a063-2cf148ae3ca7_main_switch_switch_switch', 'unit_of_measurement': None, }) # --- @@ -1548,7 +1548,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'remote_control', - 'unique_id': '63803fae-cbed-f356-a063-2cf148ae3ca7.remoteControlEnabled', + 'unique_id': '63803fae-cbed-f356-a063-2cf148ae3ca7_main_remoteControlStatus_remoteControlEnabled_remoteControlEnabled', 'unit_of_measurement': None, }) # --- @@ -1595,7 +1595,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'd5dc3299-c266-41c7-bd08-f540aea54b89.motion', + 'unique_id': 'd5dc3299-c266-41c7-bd08-f540aea54b89_main_motionSensor_motion_motion', 'unit_of_measurement': None, }) # --- @@ -1643,7 +1643,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'd5dc3299-c266-41c7-bd08-f540aea54b89.presence', + 'unique_id': 'd5dc3299-c266-41c7-bd08-f540aea54b89_main_presenceSensor_presence_presence', 'unit_of_measurement': None, }) # --- @@ -1691,7 +1691,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '184c67cc-69e2-44b6-8f73-55c963068ad9.presence', + 'unique_id': '184c67cc-69e2-44b6-8f73-55c963068ad9_main_presenceSensor_presence_presence', 'unit_of_measurement': None, }) # --- @@ -1739,7 +1739,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '7d246592-93db-4d72-a10d-5a51793ece8c.contact', + 'unique_id': '7d246592-93db-4d72-a10d-5a51793ece8c_main_contactSensor_contact_contact', 'unit_of_measurement': None, }) # --- @@ -1787,7 +1787,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'acceleration', - 'unique_id': '7d246592-93db-4d72-a10d-5a51793ece8c.acceleration', + 'unique_id': '7d246592-93db-4d72-a10d-5a51793ece8c_main_accelerationSensor_acceleration_acceleration', 'unit_of_measurement': None, }) # --- @@ -1835,7 +1835,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'valve', - 'unique_id': '612ab3c2-3bb0-48f7-b2c0-15b169cb2fc3.valve', + 'unique_id': '612ab3c2-3bb0-48f7-b2c0-15b169cb2fc3_main_valve_valve_valve', 'unit_of_measurement': None, }) # --- @@ -1883,7 +1883,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'a2a6018b-2663-4727-9d1d-8f56953b5116.water', + 'unique_id': 'a2a6018b-2663-4727-9d1d-8f56953b5116_main_waterSensor_water_water', 'unit_of_measurement': None, }) # --- diff --git a/tests/components/smartthings/snapshots/test_climate.ambr b/tests/components/smartthings/snapshots/test_climate.ambr index 893093ee2aa..10e9dbd5489 100644 --- a/tests/components/smartthings/snapshots/test_climate.ambr +++ b/tests/components/smartthings/snapshots/test_climate.ambr @@ -36,7 +36,7 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': None, - 'unique_id': 'bf53a150-f8a4-45d1-aac4-86252475d551', + 'unique_id': 'bf53a150-f8a4-45d1-aac4-86252475d551_main', 'unit_of_measurement': None, }) # --- @@ -99,7 +99,7 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': None, - 'unique_id': '286ba274-4093-4bcb-849c-a1a3efe7b1e5', + 'unique_id': '286ba274-4093-4bcb-849c-a1a3efe7b1e5_main', 'unit_of_measurement': None, }) # --- @@ -178,7 +178,7 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': None, - 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d', + 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d_main', 'unit_of_measurement': None, }) # --- @@ -283,7 +283,7 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': None, - 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e', + 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e_main', 'unit_of_measurement': None, }) # --- @@ -383,7 +383,7 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': None, - 'unique_id': 'F8042E25-0E53-0000-0000-000000000000', + 'unique_id': 'F8042E25-0E53-0000-0000-000000000000_main', 'unit_of_measurement': None, }) # --- @@ -461,7 +461,7 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': None, - 'unique_id': '028469cb-6e89-4f14-8d9a-bfbca5e0fbfc', + 'unique_id': '028469cb-6e89-4f14-8d9a-bfbca5e0fbfc_main', 'unit_of_measurement': None, }) # --- @@ -532,7 +532,7 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': None, - 'unique_id': '1888b38f-6246-4f1e-911b-bfcfb66999db', + 'unique_id': '1888b38f-6246-4f1e-911b-bfcfb66999db_main', 'unit_of_measurement': None, }) # --- @@ -595,7 +595,7 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': None, - 'unique_id': '656569c2-7976-4232-a789-34b4d1176c3a', + 'unique_id': '656569c2-7976-4232-a789-34b4d1176c3a_main', 'unit_of_measurement': None, }) # --- @@ -657,7 +657,7 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': None, - 'unique_id': '69a271f6-6537-4982-8cd9-979866872692', + 'unique_id': '69a271f6-6537-4982-8cd9-979866872692_main', 'unit_of_measurement': None, }) # --- @@ -723,7 +723,7 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': None, - 'unique_id': '2894dc93-0f11-49cc-8a81-3a684cebebf6', + 'unique_id': '2894dc93-0f11-49cc-8a81-3a684cebebf6_main', 'unit_of_measurement': None, }) # --- diff --git a/tests/components/smartthings/snapshots/test_cover.ambr b/tests/components/smartthings/snapshots/test_cover.ambr index 6877a8ccc01..4b5cf705665 100644 --- a/tests/components/smartthings/snapshots/test_cover.ambr +++ b/tests/components/smartthings/snapshots/test_cover.ambr @@ -29,7 +29,7 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': None, - 'unique_id': '571af102-15db-4030-b76b-245a691f74a5', + 'unique_id': '571af102-15db-4030-b76b-245a691f74a5_main', 'unit_of_measurement': None, }) # --- @@ -79,7 +79,7 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': None, - 'unique_id': '71afed1c-006d-4e48-b16e-e7f88f9fd638', + 'unique_id': '71afed1c-006d-4e48-b16e-e7f88f9fd638_main', 'unit_of_measurement': None, }) # --- diff --git a/tests/components/smartthings/snapshots/test_fan.ambr b/tests/components/smartthings/snapshots/test_fan.ambr index 40ab7b12267..1196118b3b5 100644 --- a/tests/components/smartthings/snapshots/test_fan.ambr +++ b/tests/components/smartthings/snapshots/test_fan.ambr @@ -37,7 +37,7 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': None, - 'unique_id': 'f1af21a2-d5a1-437c-b10a-b34a87394b71', + 'unique_id': 'f1af21a2-d5a1-437c-b10a-b34a87394b71_main', 'unit_of_measurement': None, }) # --- @@ -97,7 +97,7 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': None, - 'unique_id': '6d95a8b7-4ee3-429a-a13a-00ec9354170c', + 'unique_id': '6d95a8b7-4ee3-429a-a13a-00ec9354170c_main', 'unit_of_measurement': None, }) # --- diff --git a/tests/components/smartthings/snapshots/test_light.ambr b/tests/components/smartthings/snapshots/test_light.ambr index f1f2b92de77..6826a555f6a 100644 --- a/tests/components/smartthings/snapshots/test_light.ambr +++ b/tests/components/smartthings/snapshots/test_light.ambr @@ -37,7 +37,7 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': None, - 'unique_id': '7c16163e-c94e-482f-95f6-139ae0cd9d5e', + 'unique_id': '7c16163e-c94e-482f-95f6-139ae0cd9d5e_main', 'unit_of_measurement': None, }) # --- @@ -103,7 +103,7 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': None, - 'unique_id': 'd0268a69-abfb-4c92-a646-61cec2e510ad', + 'unique_id': 'd0268a69-abfb-4c92-a646-61cec2e510ad_main', 'unit_of_measurement': None, }) # --- @@ -160,7 +160,7 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': None, - 'unique_id': 'aaedaf28-2ae0-4c1d-b57e-87f6a420c298', + 'unique_id': 'aaedaf28-2ae0-4c1d-b57e-87f6a420c298_main', 'unit_of_measurement': None, }) # --- @@ -221,7 +221,7 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': None, - 'unique_id': '440063de-a200-40b5-8a6b-f3399eaa0370', + 'unique_id': '440063de-a200-40b5-8a6b-f3399eaa0370_main', 'unit_of_measurement': None, }) # --- @@ -302,7 +302,7 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': None, - 'unique_id': 'cb958955-b015-498c-9e62-fc0c51abd054', + 'unique_id': 'cb958955-b015-498c-9e62-fc0c51abd054_main', 'unit_of_measurement': None, }) # --- diff --git a/tests/components/smartthings/snapshots/test_lock.ambr b/tests/components/smartthings/snapshots/test_lock.ambr index 2cf9688c3dd..325ce0cc677 100644 --- a/tests/components/smartthings/snapshots/test_lock.ambr +++ b/tests/components/smartthings/snapshots/test_lock.ambr @@ -29,7 +29,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'a9f587c5-5d8b-4273-8907-e7f609af5158', + 'unique_id': 'a9f587c5-5d8b-4273-8907-e7f609af5158_main', 'unit_of_measurement': None, }) # --- diff --git a/tests/components/smartthings/snapshots/test_media_player.ambr b/tests/components/smartthings/snapshots/test_media_player.ambr index b0829b0716e..83f9d19b9fa 100644 --- a/tests/components/smartthings/snapshots/test_media_player.ambr +++ b/tests/components/smartthings/snapshots/test_media_player.ambr @@ -37,7 +37,7 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': None, - 'unique_id': 'afcf3b91-0000-1111-2222-ddff2a0a6577', + 'unique_id': 'afcf3b91-0000-1111-2222-ddff2a0a6577_main', 'unit_of_measurement': None, }) # --- @@ -99,7 +99,7 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': None, - 'unique_id': 'c9276e43-fe3c-88c3-1dcc-2eb79e292b8c', + 'unique_id': 'c9276e43-fe3c-88c3-1dcc-2eb79e292b8c_main', 'unit_of_measurement': None, }) # --- @@ -153,7 +153,7 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': None, - 'unique_id': 'c85fced9-c474-4a47-93c2-037cc7829536', + 'unique_id': 'c85fced9-c474-4a47-93c2-037cc7829536_main', 'unit_of_measurement': None, }) # --- @@ -207,7 +207,7 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': None, - 'unique_id': '0d94e5db-8501-2355-eb4f-214163702cac', + 'unique_id': '0d94e5db-8501-2355-eb4f-214163702cac_main', 'unit_of_measurement': None, }) # --- @@ -268,7 +268,7 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': None, - 'unique_id': '4588d2d9-a8cf-40f4-9a0b-ed5dfbaccda1', + 'unique_id': '4588d2d9-a8cf-40f4-9a0b-ed5dfbaccda1_main', 'unit_of_measurement': None, }) # --- diff --git a/tests/components/smartthings/snapshots/test_number.ambr b/tests/components/smartthings/snapshots/test_number.ambr index 18d0a775c95..a5954a98cf3 100644 --- a/tests/components/smartthings/snapshots/test_number.ambr +++ b/tests/components/smartthings/snapshots/test_number.ambr @@ -34,7 +34,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'washer_rinse_cycles', - 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47_main_custom.washerRinseCycles', + 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47_main_custom.washerRinseCycles_washerRinseCycles_washerRinseCycles', 'unit_of_measurement': 'cycles', }) # --- @@ -91,7 +91,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'washer_rinse_cycles', - 'unique_id': '63803fae-cbed-f356-a063-2cf148ae3ca7_main_custom.washerRinseCycles', + 'unique_id': '63803fae-cbed-f356-a063-2cf148ae3ca7_main_custom.washerRinseCycles_washerRinseCycles_washerRinseCycles', 'unit_of_measurement': 'cycles', }) # --- diff --git a/tests/components/smartthings/snapshots/test_select.ambr b/tests/components/smartthings/snapshots/test_select.ambr index 649e876bb9e..1adb8ed2572 100644 --- a/tests/components/smartthings/snapshots/test_select.ambr +++ b/tests/components/smartthings/snapshots/test_select.ambr @@ -35,7 +35,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'operating_state', - 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b_main_dryerOperatingState', + 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b_main_dryerOperatingState_machineState_machineState', 'unit_of_measurement': None, }) # --- @@ -93,7 +93,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'operating_state', - 'unique_id': '3a6c4e05-811d-5041-e956-3d04c424cbcd_main_dryerOperatingState', + 'unique_id': '3a6c4e05-811d-5041-e956-3d04c424cbcd_main_dryerOperatingState_machineState_machineState', 'unit_of_measurement': None, }) # --- @@ -151,7 +151,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'operating_state', - 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47_main_washerOperatingState', + 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47_main_washerOperatingState_machineState_machineState', 'unit_of_measurement': None, }) # --- @@ -209,7 +209,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'operating_state', - 'unique_id': '63803fae-cbed-f356-a063-2cf148ae3ca7_main_washerOperatingState', + 'unique_id': '63803fae-cbed-f356-a063-2cf148ae3ca7_main_washerOperatingState_machineState_machineState', 'unit_of_measurement': None, }) # --- diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index 76e86cc832a..fbd95649f99 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -31,7 +31,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'f0af21a2-d5a1-437c-b10a-b34a87394b71.energy', + 'unique_id': 'f0af21a2-d5a1-437c-b10a-b34a87394b71_main_energyMeter_energy_energy', 'unit_of_measurement': 'kWh', }) # --- @@ -83,7 +83,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'f0af21a2-d5a1-437c-b10a-b34a87394b71.power', + 'unique_id': 'f0af21a2-d5a1-437c-b10a-b34a87394b71_main_powerMeter_power_power', 'unit_of_measurement': 'W', }) # --- @@ -135,7 +135,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'f0af21a2-d5a1-437c-b10a-b34a87394b71.voltage', + 'unique_id': 'f0af21a2-d5a1-437c-b10a-b34a87394b71_main_voltageMeasurement_voltage_voltage', 'unit_of_measurement': None, }) # --- @@ -186,7 +186,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'bf53a150-f8a4-45d1-aac4-86252475d551.temperature', + 'unique_id': 'bf53a150-f8a4-45d1-aac4-86252475d551_main_temperatureMeasurement_temperature_temperature', 'unit_of_measurement': , }) # --- @@ -238,7 +238,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '68e786a6-7f61-4c3a-9e13-70b803cf782b.energy', + 'unique_id': '68e786a6-7f61-4c3a-9e13-70b803cf782b_main_energyMeter_energy_energy', 'unit_of_measurement': 'kWh', }) # --- @@ -290,7 +290,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '68e786a6-7f61-4c3a-9e13-70b803cf782b.power', + 'unique_id': '68e786a6-7f61-4c3a-9e13-70b803cf782b_main_powerMeter_power_power', 'unit_of_measurement': 'W', }) # --- @@ -340,7 +340,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '286ba274-4093-4bcb-849c-a1a3efe7b1e5.battery', + 'unique_id': '286ba274-4093-4bcb-849c-a1a3efe7b1e5_main_battery_battery_battery', 'unit_of_measurement': '%', }) # --- @@ -391,7 +391,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '286ba274-4093-4bcb-849c-a1a3efe7b1e5.temperature', + 'unique_id': '286ba274-4093-4bcb-849c-a1a3efe7b1e5_main_temperatureMeasurement_temperature_temperature', 'unit_of_measurement': , }) # --- @@ -448,7 +448,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'alarm', - 'unique_id': '10e06a70-ee7d-4832-85e9-a0a06a7a05bd.alarm', + 'unique_id': '10e06a70-ee7d-4832-85e9-a0a06a7a05bd_main_alarm_alarm_alarm', 'unit_of_measurement': None, }) # --- @@ -502,7 +502,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '10e06a70-ee7d-4832-85e9-a0a06a7a05bd.battery', + 'unique_id': '10e06a70-ee7d-4832-85e9-a0a06a7a05bd_main_battery_battery_battery', 'unit_of_measurement': '%', }) # --- @@ -553,7 +553,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'd0268a69-abfb-4c92-a646-61cec2e510ad.power', + 'unique_id': 'd0268a69-abfb-4c92-a646-61cec2e510ad_main_powerMeter_power_power', 'unit_of_measurement': 'W', }) # --- @@ -603,7 +603,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '2d9a892b-1c93-45a5-84cb-0e81889498c6.battery', + 'unique_id': '2d9a892b-1c93-45a5-84cb-0e81889498c6_main_battery_battery_battery', 'unit_of_measurement': '%', }) # --- @@ -654,7 +654,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '2d9a892b-1c93-45a5-84cb-0e81889498c6.temperature', + 'unique_id': '2d9a892b-1c93-45a5-84cb-0e81889498c6_main_temperatureMeasurement_temperature_temperature', 'unit_of_measurement': , }) # --- @@ -706,7 +706,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'air_quality', - 'unique_id': 'a3a970ea-e09c-9c04-161b-94c934e21666.airQuality', + 'unique_id': 'a3a970ea-e09c-9c04-161b-94c934e21666_main_airQualitySensor_airQuality_airQuality', 'unit_of_measurement': 'CAQI', }) # --- @@ -757,7 +757,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'a3a970ea-e09c-9c04-161b-94c934e21666.carbonDioxide', + 'unique_id': 'a3a970ea-e09c-9c04-161b-94c934e21666_main_carbonDioxideMeasurement_carbonDioxide_carbonDioxide', 'unit_of_measurement': 'ppm', }) # --- @@ -809,7 +809,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'a3a970ea-e09c-9c04-161b-94c934e21666.humidity', + 'unique_id': 'a3a970ea-e09c-9c04-161b-94c934e21666_main_relativeHumidityMeasurement_humidity_humidity', 'unit_of_measurement': '%', }) # --- @@ -859,7 +859,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'odor_sensor', - 'unique_id': 'a3a970ea-e09c-9c04-161b-94c934e21666.odorLevel', + 'unique_id': 'a3a970ea-e09c-9c04-161b-94c934e21666_main_odorSensor_odorLevel_odorLevel', 'unit_of_measurement': None, }) # --- @@ -908,7 +908,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'a3a970ea-e09c-9c04-161b-94c934e21666.veryFineDustLevel', + 'unique_id': 'a3a970ea-e09c-9c04-161b-94c934e21666_main_veryFineDustSensor_veryFineDustLevel_veryFineDustLevel', 'unit_of_measurement': 'µg/m³', }) # --- @@ -960,7 +960,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'a3a970ea-e09c-9c04-161b-94c934e21666.dustLevel', + 'unique_id': 'a3a970ea-e09c-9c04-161b-94c934e21666_main_dustSensor_dustLevel_dustLevel', 'unit_of_measurement': 'µg/m³', }) # --- @@ -1012,7 +1012,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'a3a970ea-e09c-9c04-161b-94c934e21666.fineDustLevel', + 'unique_id': 'a3a970ea-e09c-9c04-161b-94c934e21666_main_dustSensor_fineDustLevel_fineDustLevel', 'unit_of_measurement': 'µg/m³', }) # --- @@ -1064,7 +1064,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'a3a970ea-e09c-9c04-161b-94c934e21666.temperature', + 'unique_id': 'a3a970ea-e09c-9c04-161b-94c934e21666_main_temperatureMeasurement_temperature_temperature', 'unit_of_measurement': , }) # --- @@ -1119,7 +1119,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d.energy_meter', + 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d_main_powerConsumptionReport_powerConsumption_energy_meter', 'unit_of_measurement': , }) # --- @@ -1174,7 +1174,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'energy_difference', - 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d.deltaEnergy_meter', + 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d_main_powerConsumptionReport_powerConsumption_deltaEnergy_meter', 'unit_of_measurement': , }) # --- @@ -1229,7 +1229,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'energy_saved', - 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d.energySaved_meter', + 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d_main_powerConsumptionReport_powerConsumption_energySaved_meter', 'unit_of_measurement': , }) # --- @@ -1281,7 +1281,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d.humidity', + 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d_main_relativeHumidityMeasurement_humidity_humidity', 'unit_of_measurement': '%', }) # --- @@ -1336,7 +1336,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d.power_meter', + 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d_main_powerConsumptionReport_powerConsumption_power_meter', 'unit_of_measurement': , }) # --- @@ -1393,7 +1393,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'power_energy', - 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d.powerEnergy_meter', + 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d_main_powerConsumptionReport_powerConsumption_powerEnergy_meter', 'unit_of_measurement': , }) # --- @@ -1445,7 +1445,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d.temperature', + 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d_main_temperatureMeasurement_temperature_temperature', 'unit_of_measurement': , }) # --- @@ -1495,7 +1495,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'audio_volume', - 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d.volume', + 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d_main_audioVolume_volume_volume', 'unit_of_measurement': '%', }) # --- @@ -1548,7 +1548,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e.energy_meter', + 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e_main_powerConsumptionReport_powerConsumption_energy_meter', 'unit_of_measurement': , }) # --- @@ -1603,7 +1603,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'energy_difference', - 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e.deltaEnergy_meter', + 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e_main_powerConsumptionReport_powerConsumption_deltaEnergy_meter', 'unit_of_measurement': , }) # --- @@ -1658,7 +1658,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'energy_saved', - 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e.energySaved_meter', + 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e_main_powerConsumptionReport_powerConsumption_energySaved_meter', 'unit_of_measurement': , }) # --- @@ -1710,7 +1710,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e.humidity', + 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e_main_relativeHumidityMeasurement_humidity_humidity', 'unit_of_measurement': '%', }) # --- @@ -1765,7 +1765,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e.power_meter', + 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e_main_powerConsumptionReport_powerConsumption_power_meter', 'unit_of_measurement': , }) # --- @@ -1822,7 +1822,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'power_energy', - 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e.powerEnergy_meter', + 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e_main_powerConsumptionReport_powerConsumption_powerEnergy_meter', 'unit_of_measurement': , }) # --- @@ -1874,7 +1874,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e.temperature', + 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e_main_temperatureMeasurement_temperature_temperature', 'unit_of_measurement': , }) # --- @@ -1924,7 +1924,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'audio_volume', - 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e.volume', + 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e_main_audioVolume_volume_volume', 'unit_of_measurement': '%', }) # --- @@ -1974,7 +1974,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'air_quality', - 'unique_id': 'F8042E25-0E53-0000-0000-000000000000.airQuality', + 'unique_id': 'F8042E25-0E53-0000-0000-000000000000_main_airQualitySensor_airQuality_airQuality', 'unit_of_measurement': 'CAQI', }) # --- @@ -2025,7 +2025,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'F8042E25-0E53-0000-0000-000000000000.dustLevel', + 'unique_id': 'F8042E25-0E53-0000-0000-000000000000_main_dustSensor_dustLevel_dustLevel', 'unit_of_measurement': 'µg/m³', }) # --- @@ -2077,7 +2077,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'F8042E25-0E53-0000-0000-000000000000.fineDustLevel', + 'unique_id': 'F8042E25-0E53-0000-0000-000000000000_main_dustSensor_fineDustLevel_fineDustLevel', 'unit_of_measurement': 'µg/m³', }) # --- @@ -2129,7 +2129,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'F8042E25-0E53-0000-0000-000000000000.temperature', + 'unique_id': 'F8042E25-0E53-0000-0000-000000000000_main_temperatureMeasurement_temperature_temperature', 'unit_of_measurement': , }) # --- @@ -2179,7 +2179,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'completion_time', - 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a.completionTime', + 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a_main_ovenOperatingState_completionTime_completionTime', 'unit_of_measurement': None, }) # --- @@ -2247,7 +2247,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'oven_job_state', - 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a.ovenJobState', + 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a_main_ovenOperatingState_ovenJobState_ovenJobState', 'unit_of_measurement': None, }) # --- @@ -2320,7 +2320,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'oven_machine_state', - 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a.machineState', + 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a_main_ovenOperatingState_machineState_machineState', 'unit_of_measurement': None, }) # --- @@ -2401,7 +2401,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'oven_mode', - 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a.ovenMode', + 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a_main_ovenMode_ovenMode_ovenMode', 'unit_of_measurement': None, }) # --- @@ -2476,7 +2476,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'oven_setpoint', - 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a.ovenSetpoint', + 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a_main_ovenSetpoint_ovenSetpoint_ovenSetpoint', 'unit_of_measurement': , }) # --- @@ -2527,7 +2527,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a.temperature', + 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a_main_temperatureMeasurement_temperature_temperature', 'unit_of_measurement': , }) # --- @@ -2577,7 +2577,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'completion_time', - 'unique_id': '9447959a-0dfa-6b27-d40d-650da525c53f.completionTime', + 'unique_id': '9447959a-0dfa-6b27-d40d-650da525c53f_main_ovenOperatingState_completionTime_completionTime', 'unit_of_measurement': None, }) # --- @@ -2645,7 +2645,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'oven_job_state', - 'unique_id': '9447959a-0dfa-6b27-d40d-650da525c53f.ovenJobState', + 'unique_id': '9447959a-0dfa-6b27-d40d-650da525c53f_main_ovenOperatingState_ovenJobState_ovenJobState', 'unit_of_measurement': None, }) # --- @@ -2718,7 +2718,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'oven_machine_state', - 'unique_id': '9447959a-0dfa-6b27-d40d-650da525c53f.machineState', + 'unique_id': '9447959a-0dfa-6b27-d40d-650da525c53f_main_ovenOperatingState_machineState_machineState', 'unit_of_measurement': None, }) # --- @@ -2799,7 +2799,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'oven_mode', - 'unique_id': '9447959a-0dfa-6b27-d40d-650da525c53f.ovenMode', + 'unique_id': '9447959a-0dfa-6b27-d40d-650da525c53f_main_ovenMode_ovenMode_ovenMode', 'unit_of_measurement': None, }) # --- @@ -2874,7 +2874,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'oven_setpoint', - 'unique_id': '9447959a-0dfa-6b27-d40d-650da525c53f.ovenSetpoint', + 'unique_id': '9447959a-0dfa-6b27-d40d-650da525c53f_main_ovenSetpoint_ovenSetpoint_ovenSetpoint', 'unit_of_measurement': , }) # --- @@ -2925,7 +2925,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '9447959a-0dfa-6b27-d40d-650da525c53f.temperature', + 'unique_id': '9447959a-0dfa-6b27-d40d-650da525c53f_main_temperatureMeasurement_temperature_temperature', 'unit_of_measurement': , }) # --- @@ -2975,7 +2975,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'completion_time', - 'unique_id': '2c3cbaa0-1899-5ddc-7b58-9d657bd48f18.completionTime', + 'unique_id': '2c3cbaa0-1899-5ddc-7b58-9d657bd48f18_main_ovenOperatingState_completionTime_completionTime', 'unit_of_measurement': None, }) # --- @@ -3043,7 +3043,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'oven_job_state', - 'unique_id': '2c3cbaa0-1899-5ddc-7b58-9d657bd48f18.ovenJobState', + 'unique_id': '2c3cbaa0-1899-5ddc-7b58-9d657bd48f18_main_ovenOperatingState_ovenJobState_ovenJobState', 'unit_of_measurement': None, }) # --- @@ -3116,7 +3116,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'oven_machine_state', - 'unique_id': '2c3cbaa0-1899-5ddc-7b58-9d657bd48f18.machineState', + 'unique_id': '2c3cbaa0-1899-5ddc-7b58-9d657bd48f18_main_ovenOperatingState_machineState_machineState', 'unit_of_measurement': None, }) # --- @@ -3197,7 +3197,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'oven_mode', - 'unique_id': '2c3cbaa0-1899-5ddc-7b58-9d657bd48f18.ovenMode', + 'unique_id': '2c3cbaa0-1899-5ddc-7b58-9d657bd48f18_main_ovenMode_ovenMode_ovenMode', 'unit_of_measurement': None, }) # --- @@ -3272,7 +3272,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'oven_setpoint', - 'unique_id': '2c3cbaa0-1899-5ddc-7b58-9d657bd48f18.ovenSetpoint', + 'unique_id': '2c3cbaa0-1899-5ddc-7b58-9d657bd48f18_main_ovenSetpoint_ovenSetpoint_ovenSetpoint', 'unit_of_measurement': , }) # --- @@ -3323,7 +3323,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '2c3cbaa0-1899-5ddc-7b58-9d657bd48f18.temperature', + 'unique_id': '2c3cbaa0-1899-5ddc-7b58-9d657bd48f18_main_temperatureMeasurement_temperature_temperature', 'unit_of_measurement': , }) # --- @@ -3378,7 +3378,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '7db87911-7dce-1cf2-7119-b953432a2f09.energy_meter', + 'unique_id': '7db87911-7dce-1cf2-7119-b953432a2f09_main_powerConsumptionReport_powerConsumption_energy_meter', 'unit_of_measurement': , }) # --- @@ -3433,7 +3433,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'energy_difference', - 'unique_id': '7db87911-7dce-1cf2-7119-b953432a2f09.deltaEnergy_meter', + 'unique_id': '7db87911-7dce-1cf2-7119-b953432a2f09_main_powerConsumptionReport_powerConsumption_deltaEnergy_meter', 'unit_of_measurement': , }) # --- @@ -3488,7 +3488,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'energy_saved', - 'unique_id': '7db87911-7dce-1cf2-7119-b953432a2f09.energySaved_meter', + 'unique_id': '7db87911-7dce-1cf2-7119-b953432a2f09_main_powerConsumptionReport_powerConsumption_energySaved_meter', 'unit_of_measurement': , }) # --- @@ -3543,7 +3543,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '7db87911-7dce-1cf2-7119-b953432a2f09.power_meter', + 'unique_id': '7db87911-7dce-1cf2-7119-b953432a2f09_main_powerConsumptionReport_powerConsumption_power_meter', 'unit_of_measurement': , }) # --- @@ -3600,7 +3600,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'power_energy', - 'unique_id': '7db87911-7dce-1cf2-7119-b953432a2f09.powerEnergy_meter', + 'unique_id': '7db87911-7dce-1cf2-7119-b953432a2f09_main_powerConsumptionReport_powerConsumption_powerEnergy_meter', 'unit_of_measurement': , }) # --- @@ -3650,7 +3650,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '3442dfc6-17c0-a65f-dae0-4c6e01786f44.battery', + 'unique_id': '3442dfc6-17c0-a65f-dae0-4c6e01786f44_main_battery_battery_battery', 'unit_of_measurement': '%', }) # --- @@ -3708,7 +3708,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'robot_cleaner_cleaning_mode', - 'unique_id': '3442dfc6-17c0-a65f-dae0-4c6e01786f44.robotCleanerCleaningMode', + 'unique_id': '3442dfc6-17c0-a65f-dae0-4c6e01786f44_main_robotCleanerCleaningMode_robotCleanerCleaningMode_robotCleanerCleaningMode', 'unit_of_measurement': None, }) # --- @@ -3777,7 +3777,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'robot_cleaner_movement', - 'unique_id': '3442dfc6-17c0-a65f-dae0-4c6e01786f44.robotCleanerMovement', + 'unique_id': '3442dfc6-17c0-a65f-dae0-4c6e01786f44_main_robotCleanerMovement_robotCleanerMovement_robotCleanerMovement', 'unit_of_measurement': None, }) # --- @@ -3844,7 +3844,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'robot_cleaner_turbo_mode', - 'unique_id': '3442dfc6-17c0-a65f-dae0-4c6e01786f44.robotCleanerTurboMode', + 'unique_id': '3442dfc6-17c0-a65f-dae0-4c6e01786f44_main_robotCleanerTurboMode_robotCleanerTurboMode_robotCleanerTurboMode', 'unit_of_measurement': None, }) # --- @@ -3898,7 +3898,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'thermostat_cooling_setpoint', - 'unique_id': '1f98ebd0-ac48-d802-7f62-000001200100.coolingSetpoint', + 'unique_id': '1f98ebd0-ac48-d802-7f62-000001200100_main_thermostatCoolingSetpoint_coolingSetpoint_coolingSetpoint', 'unit_of_measurement': , }) # --- @@ -3952,7 +3952,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '1f98ebd0-ac48-d802-7f62-000001200100.energy_meter', + 'unique_id': '1f98ebd0-ac48-d802-7f62-000001200100_main_powerConsumptionReport_powerConsumption_energy_meter', 'unit_of_measurement': , }) # --- @@ -4007,7 +4007,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'energy_difference', - 'unique_id': '1f98ebd0-ac48-d802-7f62-000001200100.deltaEnergy_meter', + 'unique_id': '1f98ebd0-ac48-d802-7f62-000001200100_main_powerConsumptionReport_powerConsumption_deltaEnergy_meter', 'unit_of_measurement': , }) # --- @@ -4062,7 +4062,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'energy_saved', - 'unique_id': '1f98ebd0-ac48-d802-7f62-000001200100.energySaved_meter', + 'unique_id': '1f98ebd0-ac48-d802-7f62-000001200100_main_powerConsumptionReport_powerConsumption_energySaved_meter', 'unit_of_measurement': , }) # --- @@ -4117,7 +4117,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '1f98ebd0-ac48-d802-7f62-000001200100.power_meter', + 'unique_id': '1f98ebd0-ac48-d802-7f62-000001200100_main_powerConsumptionReport_powerConsumption_power_meter', 'unit_of_measurement': , }) # --- @@ -4174,7 +4174,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'power_energy', - 'unique_id': '1f98ebd0-ac48-d802-7f62-000001200100.powerEnergy_meter', + 'unique_id': '1f98ebd0-ac48-d802-7f62-000001200100_main_powerConsumptionReport_powerConsumption_powerEnergy_meter', 'unit_of_measurement': , }) # --- @@ -4226,7 +4226,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '1f98ebd0-ac48-d802-7f62-000001200100.temperature', + 'unique_id': '1f98ebd0-ac48-d802-7f62-000001200100_main_temperatureMeasurement_temperature_temperature', 'unit_of_measurement': , }) # --- @@ -4276,7 +4276,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'completion_time', - 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676.completionTime', + 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676_main_dishwasherOperatingState_completionTime_completionTime', 'unit_of_measurement': None, }) # --- @@ -4329,7 +4329,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676.energy_meter', + 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676_main_powerConsumptionReport_powerConsumption_energy_meter', 'unit_of_measurement': , }) # --- @@ -4384,7 +4384,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'energy_difference', - 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676.deltaEnergy_meter', + 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676_main_powerConsumptionReport_powerConsumption_deltaEnergy_meter', 'unit_of_measurement': , }) # --- @@ -4439,7 +4439,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'energy_saved', - 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676.energySaved_meter', + 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676_main_powerConsumptionReport_powerConsumption_energySaved_meter', 'unit_of_measurement': , }) # --- @@ -4502,7 +4502,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'dishwasher_job_state', - 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676.dishwasherJobState', + 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676_main_dishwasherOperatingState_dishwasherJobState_dishwasherJobState', 'unit_of_measurement': None, }) # --- @@ -4568,7 +4568,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'dishwasher_machine_state', - 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676.machineState', + 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676_main_dishwasherOperatingState_machineState_machineState', 'unit_of_measurement': None, }) # --- @@ -4626,7 +4626,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676.power_meter', + 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676_main_powerConsumptionReport_powerConsumption_power_meter', 'unit_of_measurement': , }) # --- @@ -4683,7 +4683,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'power_energy', - 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676.powerEnergy_meter', + 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676_main_powerConsumptionReport_powerConsumption_powerEnergy_meter', 'unit_of_measurement': , }) # --- @@ -4733,7 +4733,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'completion_time', - 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b.completionTime', + 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b_main_dryerOperatingState_completionTime_completionTime', 'unit_of_measurement': None, }) # --- @@ -4786,7 +4786,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b.energy_meter', + 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b_main_powerConsumptionReport_powerConsumption_energy_meter', 'unit_of_measurement': , }) # --- @@ -4841,7 +4841,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'energy_difference', - 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b.deltaEnergy_meter', + 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b_main_powerConsumptionReport_powerConsumption_deltaEnergy_meter', 'unit_of_measurement': , }) # --- @@ -4896,7 +4896,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'energy_saved', - 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b.energySaved_meter', + 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b_main_powerConsumptionReport_powerConsumption_energySaved_meter', 'unit_of_measurement': , }) # --- @@ -4964,7 +4964,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'dryer_job_state', - 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b.dryerJobState', + 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b_main_dryerOperatingState_dryerJobState_dryerJobState', 'unit_of_measurement': None, }) # --- @@ -5035,7 +5035,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'dryer_machine_state', - 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b.machineState', + 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b_main_dryerOperatingState_machineState_machineState', 'unit_of_measurement': None, }) # --- @@ -5093,7 +5093,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b.power_meter', + 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b_main_powerConsumptionReport_powerConsumption_power_meter', 'unit_of_measurement': , }) # --- @@ -5150,7 +5150,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'power_energy', - 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b.powerEnergy_meter', + 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b_main_powerConsumptionReport_powerConsumption_powerEnergy_meter', 'unit_of_measurement': , }) # --- @@ -5200,7 +5200,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'completion_time', - 'unique_id': '3a6c4e05-811d-5041-e956-3d04c424cbcd.completionTime', + 'unique_id': '3a6c4e05-811d-5041-e956-3d04c424cbcd_main_dryerOperatingState_completionTime_completionTime', 'unit_of_measurement': None, }) # --- @@ -5253,7 +5253,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '3a6c4e05-811d-5041-e956-3d04c424cbcd.energy_meter', + 'unique_id': '3a6c4e05-811d-5041-e956-3d04c424cbcd_main_powerConsumptionReport_powerConsumption_energy_meter', 'unit_of_measurement': , }) # --- @@ -5308,7 +5308,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'energy_difference', - 'unique_id': '3a6c4e05-811d-5041-e956-3d04c424cbcd.deltaEnergy_meter', + 'unique_id': '3a6c4e05-811d-5041-e956-3d04c424cbcd_main_powerConsumptionReport_powerConsumption_deltaEnergy_meter', 'unit_of_measurement': , }) # --- @@ -5363,7 +5363,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'energy_saved', - 'unique_id': '3a6c4e05-811d-5041-e956-3d04c424cbcd.energySaved_meter', + 'unique_id': '3a6c4e05-811d-5041-e956-3d04c424cbcd_main_powerConsumptionReport_powerConsumption_energySaved_meter', 'unit_of_measurement': , }) # --- @@ -5431,7 +5431,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'dryer_job_state', - 'unique_id': '3a6c4e05-811d-5041-e956-3d04c424cbcd.dryerJobState', + 'unique_id': '3a6c4e05-811d-5041-e956-3d04c424cbcd_main_dryerOperatingState_dryerJobState_dryerJobState', 'unit_of_measurement': None, }) # --- @@ -5502,7 +5502,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'dryer_machine_state', - 'unique_id': '3a6c4e05-811d-5041-e956-3d04c424cbcd.machineState', + 'unique_id': '3a6c4e05-811d-5041-e956-3d04c424cbcd_main_dryerOperatingState_machineState_machineState', 'unit_of_measurement': None, }) # --- @@ -5560,7 +5560,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '3a6c4e05-811d-5041-e956-3d04c424cbcd.power_meter', + 'unique_id': '3a6c4e05-811d-5041-e956-3d04c424cbcd_main_powerConsumptionReport_powerConsumption_power_meter', 'unit_of_measurement': , }) # --- @@ -5617,7 +5617,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'power_energy', - 'unique_id': '3a6c4e05-811d-5041-e956-3d04c424cbcd.powerEnergy_meter', + 'unique_id': '3a6c4e05-811d-5041-e956-3d04c424cbcd_main_powerConsumptionReport_powerConsumption_powerEnergy_meter', 'unit_of_measurement': , }) # --- @@ -5667,7 +5667,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'completion_time', - 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47.completionTime', + 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47_main_washerOperatingState_completionTime_completionTime', 'unit_of_measurement': None, }) # --- @@ -5720,7 +5720,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47.energy_meter', + 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47_main_powerConsumptionReport_powerConsumption_energy_meter', 'unit_of_measurement': , }) # --- @@ -5775,7 +5775,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'energy_difference', - 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47.deltaEnergy_meter', + 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47_main_powerConsumptionReport_powerConsumption_deltaEnergy_meter', 'unit_of_measurement': , }) # --- @@ -5830,7 +5830,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'energy_saved', - 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47.energySaved_meter', + 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47_main_powerConsumptionReport_powerConsumption_energySaved_meter', 'unit_of_measurement': , }) # --- @@ -5899,7 +5899,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'washer_job_state', - 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47.washerJobState', + 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47_main_washerOperatingState_washerJobState_washerJobState', 'unit_of_measurement': None, }) # --- @@ -5971,7 +5971,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'washer_machine_state', - 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47.machineState', + 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47_main_washerOperatingState_machineState_machineState', 'unit_of_measurement': None, }) # --- @@ -6029,7 +6029,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47.power_meter', + 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47_main_powerConsumptionReport_powerConsumption_power_meter', 'unit_of_measurement': , }) # --- @@ -6086,7 +6086,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'power_energy', - 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47.powerEnergy_meter', + 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47_main_powerConsumptionReport_powerConsumption_powerEnergy_meter', 'unit_of_measurement': , }) # --- @@ -6136,7 +6136,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'completion_time', - 'unique_id': '63803fae-cbed-f356-a063-2cf148ae3ca7.completionTime', + 'unique_id': '63803fae-cbed-f356-a063-2cf148ae3ca7_main_washerOperatingState_completionTime_completionTime', 'unit_of_measurement': None, }) # --- @@ -6189,7 +6189,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '63803fae-cbed-f356-a063-2cf148ae3ca7.energy_meter', + 'unique_id': '63803fae-cbed-f356-a063-2cf148ae3ca7_main_powerConsumptionReport_powerConsumption_energy_meter', 'unit_of_measurement': , }) # --- @@ -6244,7 +6244,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'energy_difference', - 'unique_id': '63803fae-cbed-f356-a063-2cf148ae3ca7.deltaEnergy_meter', + 'unique_id': '63803fae-cbed-f356-a063-2cf148ae3ca7_main_powerConsumptionReport_powerConsumption_deltaEnergy_meter', 'unit_of_measurement': , }) # --- @@ -6299,7 +6299,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'energy_saved', - 'unique_id': '63803fae-cbed-f356-a063-2cf148ae3ca7.energySaved_meter', + 'unique_id': '63803fae-cbed-f356-a063-2cf148ae3ca7_main_powerConsumptionReport_powerConsumption_energySaved_meter', 'unit_of_measurement': , }) # --- @@ -6368,7 +6368,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'washer_job_state', - 'unique_id': '63803fae-cbed-f356-a063-2cf148ae3ca7.washerJobState', + 'unique_id': '63803fae-cbed-f356-a063-2cf148ae3ca7_main_washerOperatingState_washerJobState_washerJobState', 'unit_of_measurement': None, }) # --- @@ -6440,7 +6440,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'washer_machine_state', - 'unique_id': '63803fae-cbed-f356-a063-2cf148ae3ca7.machineState', + 'unique_id': '63803fae-cbed-f356-a063-2cf148ae3ca7_main_washerOperatingState_machineState_machineState', 'unit_of_measurement': None, }) # --- @@ -6498,7 +6498,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '63803fae-cbed-f356-a063-2cf148ae3ca7.power_meter', + 'unique_id': '63803fae-cbed-f356-a063-2cf148ae3ca7_main_powerConsumptionReport_powerConsumption_power_meter', 'unit_of_measurement': , }) # --- @@ -6555,7 +6555,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'power_energy', - 'unique_id': '63803fae-cbed-f356-a063-2cf148ae3ca7.powerEnergy_meter', + 'unique_id': '63803fae-cbed-f356-a063-2cf148ae3ca7_main_powerConsumptionReport_powerConsumption_powerEnergy_meter', 'unit_of_measurement': , }) # --- @@ -6607,7 +6607,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'd5dc3299-c266-41c7-bd08-f540aea54b89.temperature', + 'unique_id': 'd5dc3299-c266-41c7-bd08-f540aea54b89_main_temperatureMeasurement_temperature_temperature', 'unit_of_measurement': , }) # --- @@ -6659,7 +6659,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '028469cb-6e89-4f14-8d9a-bfbca5e0fbfc.humidity', + 'unique_id': '028469cb-6e89-4f14-8d9a-bfbca5e0fbfc_main_relativeHumidityMeasurement_humidity_humidity', 'unit_of_measurement': '%', }) # --- @@ -6711,7 +6711,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '028469cb-6e89-4f14-8d9a-bfbca5e0fbfc.temperature', + 'unique_id': '028469cb-6e89-4f14-8d9a-bfbca5e0fbfc_main_temperatureMeasurement_temperature_temperature', 'unit_of_measurement': , }) # --- @@ -6763,7 +6763,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '1888b38f-6246-4f1e-911b-bfcfb66999db.humidity', + 'unique_id': '1888b38f-6246-4f1e-911b-bfcfb66999db_main_relativeHumidityMeasurement_humidity_humidity', 'unit_of_measurement': '%', }) # --- @@ -6815,7 +6815,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '1888b38f-6246-4f1e-911b-bfcfb66999db.temperature', + 'unique_id': '1888b38f-6246-4f1e-911b-bfcfb66999db_main_temperatureMeasurement_temperature_temperature', 'unit_of_measurement': None, }) # --- @@ -6866,7 +6866,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'link_quality', - 'unique_id': '656569c2-7976-4232-a789-34b4d1176c3a.lqi', + 'unique_id': '656569c2-7976-4232-a789-34b4d1176c3a_main_signalStrength_lqi_lqi', 'unit_of_measurement': None, }) # --- @@ -6916,7 +6916,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '656569c2-7976-4232-a789-34b4d1176c3a.rssi', + 'unique_id': '656569c2-7976-4232-a789-34b4d1176c3a_main_signalStrength_rssi_rssi', 'unit_of_measurement': 'dBm', }) # --- @@ -6968,7 +6968,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '656569c2-7976-4232-a789-34b4d1176c3a.temperature', + 'unique_id': '656569c2-7976-4232-a789-34b4d1176c3a_main_temperatureMeasurement_temperature_temperature', 'unit_of_measurement': , }) # --- @@ -7018,7 +7018,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '5e5b97f3-3094-44e6-abc0-f61283412d6a.battery', + 'unique_id': '5e5b97f3-3094-44e6-abc0-f61283412d6a_main_battery_battery_battery', 'unit_of_measurement': '%', }) # --- @@ -7069,7 +7069,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '69a271f6-6537-4982-8cd9-979866872692.energy', + 'unique_id': '69a271f6-6537-4982-8cd9-979866872692_main_energyMeter_energy_energy', 'unit_of_measurement': 'kWh', }) # --- @@ -7121,7 +7121,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '69a271f6-6537-4982-8cd9-979866872692.power', + 'unique_id': '69a271f6-6537-4982-8cd9-979866872692_main_powerMeter_power_power', 'unit_of_measurement': 'W', }) # --- @@ -7173,7 +7173,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '69a271f6-6537-4982-8cd9-979866872692.temperature', + 'unique_id': '69a271f6-6537-4982-8cd9-979866872692_main_temperatureMeasurement_temperature_temperature', 'unit_of_measurement': , }) # --- @@ -7231,7 +7231,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'media_input_source', - 'unique_id': 'afcf3b91-0000-1111-2222-ddff2a0a6577.inputSource', + 'unique_id': 'afcf3b91-0000-1111-2222-ddff2a0a6577_main_mediaInputSource_inputSource_inputSource', 'unit_of_measurement': None, }) # --- @@ -7295,7 +7295,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'media_playback_status', - 'unique_id': 'afcf3b91-0000-1111-2222-ddff2a0a6577.playbackStatus', + 'unique_id': 'afcf3b91-0000-1111-2222-ddff2a0a6577_main_mediaPlayback_playbackStatus_playbackStatus', 'unit_of_measurement': None, }) # --- @@ -7351,7 +7351,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'audio_volume', - 'unique_id': 'afcf3b91-0000-1111-2222-ddff2a0a6577.volume', + 'unique_id': 'afcf3b91-0000-1111-2222-ddff2a0a6577_main_audioVolume_volume_volume', 'unit_of_measurement': '%', }) # --- @@ -7399,7 +7399,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '71afed1c-006d-4e48-b16e-e7f88f9fd638.battery', + 'unique_id': '71afed1c-006d-4e48-b16e-e7f88f9fd638_main_battery_battery_battery', 'unit_of_measurement': '%', }) # --- @@ -7448,7 +7448,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'media_input_source', - 'unique_id': 'c9276e43-fe3c-88c3-1dcc-2eb79e292b8c.inputSource', + 'unique_id': 'c9276e43-fe3c-88c3-1dcc-2eb79e292b8c_main_mediaInputSource_inputSource_inputSource', 'unit_of_measurement': None, }) # --- @@ -7496,7 +7496,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'media_playback_repeat', - 'unique_id': 'c9276e43-fe3c-88c3-1dcc-2eb79e292b8c.playbackRepeatMode', + 'unique_id': 'c9276e43-fe3c-88c3-1dcc-2eb79e292b8c_main_mediaPlaybackRepeat_playbackRepeatMode_playbackRepeatMode', 'unit_of_measurement': None, }) # --- @@ -7543,7 +7543,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'media_playback_shuffle', - 'unique_id': 'c9276e43-fe3c-88c3-1dcc-2eb79e292b8c.playbackShuffle', + 'unique_id': 'c9276e43-fe3c-88c3-1dcc-2eb79e292b8c_main_mediaPlaybackShuffle_playbackShuffle_playbackShuffle', 'unit_of_measurement': None, }) # --- @@ -7599,7 +7599,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'media_playback_status', - 'unique_id': 'c9276e43-fe3c-88c3-1dcc-2eb79e292b8c.playbackStatus', + 'unique_id': 'c9276e43-fe3c-88c3-1dcc-2eb79e292b8c_main_mediaPlayback_playbackStatus_playbackStatus', 'unit_of_measurement': None, }) # --- @@ -7655,7 +7655,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'audio_volume', - 'unique_id': 'c9276e43-fe3c-88c3-1dcc-2eb79e292b8c.volume', + 'unique_id': 'c9276e43-fe3c-88c3-1dcc-2eb79e292b8c_main_audioVolume_volume_volume', 'unit_of_measurement': '%', }) # --- @@ -7703,7 +7703,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '7d246592-93db-4d72-a10d-5a51793ece8c.battery', + 'unique_id': '7d246592-93db-4d72-a10d-5a51793ece8c_main_battery_battery_battery', 'unit_of_measurement': '%', }) # --- @@ -7754,7 +7754,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '7d246592-93db-4d72-a10d-5a51793ece8c.temperature', + 'unique_id': '7d246592-93db-4d72-a10d-5a51793ece8c_main_temperatureMeasurement_temperature_temperature', 'unit_of_measurement': , }) # --- @@ -7804,7 +7804,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'x_coordinate', - 'unique_id': '7d246592-93db-4d72-a10d-5a51793ece8c X Coordinate', + 'unique_id': '7d246592-93db-4d72-a10d-5a51793ece8c_main_threeAxis_threeAxis_x_coordinate', 'unit_of_measurement': None, }) # --- @@ -7851,7 +7851,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'y_coordinate', - 'unique_id': '7d246592-93db-4d72-a10d-5a51793ece8c Y Coordinate', + 'unique_id': '7d246592-93db-4d72-a10d-5a51793ece8c_main_threeAxis_threeAxis_y_coordinate', 'unit_of_measurement': None, }) # --- @@ -7898,7 +7898,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'z_coordinate', - 'unique_id': '7d246592-93db-4d72-a10d-5a51793ece8c Z Coordinate', + 'unique_id': '7d246592-93db-4d72-a10d-5a51793ece8c_main_threeAxis_threeAxis_z_coordinate', 'unit_of_measurement': None, }) # --- @@ -7945,7 +7945,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'air_conditioner_mode', - 'unique_id': 'bf4b1167-48a3-4af7-9186-0900a678ffa5.airConditionerMode', + 'unique_id': 'bf4b1167-48a3-4af7-9186-0900a678ffa5_main_airConditionerMode_airConditionerMode_airConditionerMode', 'unit_of_measurement': None, }) # --- @@ -7992,7 +7992,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'thermostat_cooling_setpoint', - 'unique_id': 'bf4b1167-48a3-4af7-9186-0900a678ffa5.coolingSetpoint', + 'unique_id': 'bf4b1167-48a3-4af7-9186-0900a678ffa5_main_thermostatCoolingSetpoint_coolingSetpoint_coolingSetpoint', 'unit_of_measurement': , }) # --- @@ -8050,7 +8050,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'media_playback_status', - 'unique_id': 'c85fced9-c474-4a47-93c2-037cc7829536.playbackStatus', + 'unique_id': 'c85fced9-c474-4a47-93c2-037cc7829536_main_mediaPlayback_playbackStatus_playbackStatus', 'unit_of_measurement': None, }) # --- @@ -8106,7 +8106,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'audio_volume', - 'unique_id': 'c85fced9-c474-4a47-93c2-037cc7829536.volume', + 'unique_id': 'c85fced9-c474-4a47-93c2-037cc7829536_main_audioVolume_volume_volume', 'unit_of_measurement': '%', }) # --- @@ -8159,7 +8159,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '6602696a-1e48-49e4-919f-69406f5b5da1.energy_meter', + 'unique_id': '6602696a-1e48-49e4-919f-69406f5b5da1_main_powerConsumptionReport_powerConsumption_energy_meter', 'unit_of_measurement': , }) # --- @@ -8214,7 +8214,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'energy_difference', - 'unique_id': '6602696a-1e48-49e4-919f-69406f5b5da1.deltaEnergy_meter', + 'unique_id': '6602696a-1e48-49e4-919f-69406f5b5da1_main_powerConsumptionReport_powerConsumption_deltaEnergy_meter', 'unit_of_measurement': , }) # --- @@ -8273,7 +8273,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'media_playback_status', - 'unique_id': '0d94e5db-8501-2355-eb4f-214163702cac.playbackStatus', + 'unique_id': '0d94e5db-8501-2355-eb4f-214163702cac_main_mediaPlayback_playbackStatus_playbackStatus', 'unit_of_measurement': None, }) # --- @@ -8329,7 +8329,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'audio_volume', - 'unique_id': '0d94e5db-8501-2355-eb4f-214163702cac.volume', + 'unique_id': '0d94e5db-8501-2355-eb4f-214163702cac_main_audioVolume_volume_volume', 'unit_of_measurement': '%', }) # --- @@ -8379,7 +8379,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'brightness_intensity', - 'unique_id': '5cc1c096-98b9-460c-8f1c-1045509ec605.brightnessIntensity', + 'unique_id': '5cc1c096-98b9-460c-8f1c-1045509ec605_main_relativeBrightness_brightnessIntensity_brightnessIntensity', 'unit_of_measurement': 'level', }) # --- @@ -8435,7 +8435,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'media_input_source', - 'unique_id': '4588d2d9-a8cf-40f4-9a0b-ed5dfbaccda1.inputSource', + 'unique_id': '4588d2d9-a8cf-40f4-9a0b-ed5dfbaccda1_main_mediaInputSource_inputSource_inputSource', 'unit_of_measurement': None, }) # --- @@ -8498,7 +8498,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'media_playback_status', - 'unique_id': '4588d2d9-a8cf-40f4-9a0b-ed5dfbaccda1.playbackStatus', + 'unique_id': '4588d2d9-a8cf-40f4-9a0b-ed5dfbaccda1_main_mediaPlayback_playbackStatus_playbackStatus', 'unit_of_measurement': None, }) # --- @@ -8554,7 +8554,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'tv_channel', - 'unique_id': '4588d2d9-a8cf-40f4-9a0b-ed5dfbaccda1.tvChannel', + 'unique_id': '4588d2d9-a8cf-40f4-9a0b-ed5dfbaccda1_main_tvChannel_tvChannel_tvChannel', 'unit_of_measurement': None, }) # --- @@ -8601,7 +8601,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'tv_channel_name', - 'unique_id': '4588d2d9-a8cf-40f4-9a0b-ed5dfbaccda1.tvChannelName', + 'unique_id': '4588d2d9-a8cf-40f4-9a0b-ed5dfbaccda1_main_tvChannel_tvChannelName_tvChannelName', 'unit_of_measurement': None, }) # --- @@ -8648,7 +8648,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'audio_volume', - 'unique_id': '4588d2d9-a8cf-40f4-9a0b-ed5dfbaccda1.volume', + 'unique_id': '4588d2d9-a8cf-40f4-9a0b-ed5dfbaccda1_main_audioVolume_volume_volume', 'unit_of_measurement': '%', }) # --- @@ -8696,7 +8696,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '2894dc93-0f11-49cc-8a81-3a684cebebf6.battery', + 'unique_id': '2894dc93-0f11-49cc-8a81-3a684cebebf6_main_battery_battery_battery', 'unit_of_measurement': '%', }) # --- @@ -8747,7 +8747,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '2894dc93-0f11-49cc-8a81-3a684cebebf6.temperature', + 'unique_id': '2894dc93-0f11-49cc-8a81-3a684cebebf6_main_temperatureMeasurement_temperature_temperature', 'unit_of_measurement': , }) # --- @@ -8797,7 +8797,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'a2a6018b-2663-4727-9d1d-8f56953b5116.battery', + 'unique_id': 'a2a6018b-2663-4727-9d1d-8f56953b5116_main_battery_battery_battery', 'unit_of_measurement': '%', }) # --- @@ -8846,7 +8846,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'a9f587c5-5d8b-4273-8907-e7f609af5158.battery', + 'unique_id': 'a9f587c5-5d8b-4273-8907-e7f609af5158_main_battery_battery_battery', 'unit_of_measurement': '%', }) # --- diff --git a/tests/components/smartthings/snapshots/test_switch.ambr b/tests/components/smartthings/snapshots/test_switch.ambr index a58176d8ee7..812cb5639ab 100644 --- a/tests/components/smartthings/snapshots/test_switch.ambr +++ b/tests/components/smartthings/snapshots/test_switch.ambr @@ -29,7 +29,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '10e06a70-ee7d-4832-85e9-a0a06a7a05bd', + 'unique_id': '10e06a70-ee7d-4832-85e9-a0a06a7a05bd_main_switch_switch_switch', 'unit_of_measurement': None, }) # --- @@ -76,7 +76,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a', + 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a_main_switch_switch_switch', 'unit_of_measurement': None, }) # --- @@ -123,7 +123,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '3442dfc6-17c0-a65f-dae0-4c6e01786f44', + 'unique_id': '3442dfc6-17c0-a65f-dae0-4c6e01786f44_main_switch_switch_switch', 'unit_of_measurement': None, }) # --- @@ -170,7 +170,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '1f98ebd0-ac48-d802-7f62-000001200100', + 'unique_id': '1f98ebd0-ac48-d802-7f62-000001200100_main_switch_switch_switch', 'unit_of_measurement': None, }) # --- @@ -217,7 +217,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676', + 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676_main_switch_switch_switch', 'unit_of_measurement': None, }) # --- @@ -264,7 +264,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b', + 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b_main_switch_switch_switch', 'unit_of_measurement': None, }) # --- @@ -311,7 +311,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'wrinkle_prevent', - 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b_main_custom.dryerWrinklePrevent', + 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b_main_custom.dryerWrinklePrevent_dryerWrinklePrevent_dryerWrinklePrevent', 'unit_of_measurement': None, }) # --- @@ -358,7 +358,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '3a6c4e05-811d-5041-e956-3d04c424cbcd', + 'unique_id': '3a6c4e05-811d-5041-e956-3d04c424cbcd_main_switch_switch_switch', 'unit_of_measurement': None, }) # --- @@ -405,7 +405,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'wrinkle_prevent', - 'unique_id': '3a6c4e05-811d-5041-e956-3d04c424cbcd_main_custom.dryerWrinklePrevent', + 'unique_id': '3a6c4e05-811d-5041-e956-3d04c424cbcd_main_custom.dryerWrinklePrevent_dryerWrinklePrevent_dryerWrinklePrevent', 'unit_of_measurement': None, }) # --- @@ -452,7 +452,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47', + 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47_main_switch_switch_switch', 'unit_of_measurement': None, }) # --- @@ -499,7 +499,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '63803fae-cbed-f356-a063-2cf148ae3ca7', + 'unique_id': '63803fae-cbed-f356-a063-2cf148ae3ca7_main_switch_switch_switch', 'unit_of_measurement': None, }) # --- @@ -546,7 +546,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'bubble_soak', - 'unique_id': '63803fae-cbed-f356-a063-2cf148ae3ca7_main_samsungce.washerBubbleSoak', + 'unique_id': '63803fae-cbed-f356-a063-2cf148ae3ca7_main_samsungce.washerBubbleSoak_status_status', 'unit_of_measurement': None, }) # --- @@ -593,7 +593,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '656569c2-7976-4232-a789-34b4d1176c3a', + 'unique_id': '656569c2-7976-4232-a789-34b4d1176c3a_main_switch_switch_switch', 'unit_of_measurement': None, }) # --- @@ -640,7 +640,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'afcf3b91-0000-1111-2222-ddff2a0a6577', + 'unique_id': 'afcf3b91-0000-1111-2222-ddff2a0a6577_main_switch_switch_switch', 'unit_of_measurement': None, }) # --- @@ -687,7 +687,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'bf4b1167-48a3-4af7-9186-0900a678ffa5', + 'unique_id': 'bf4b1167-48a3-4af7-9186-0900a678ffa5_main_switch_switch_switch', 'unit_of_measurement': None, }) # --- @@ -734,7 +734,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '550a1c72-65a0-4d55-b97b-75168e055398', + 'unique_id': '550a1c72-65a0-4d55-b97b-75168e055398_main_switch_switch_switch', 'unit_of_measurement': None, }) # --- @@ -781,7 +781,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '6602696a-1e48-49e4-919f-69406f5b5da1', + 'unique_id': '6602696a-1e48-49e4-919f-69406f5b5da1_main_switch_switch_switch', 'unit_of_measurement': None, }) # --- @@ -828,7 +828,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '0d94e5db-8501-2355-eb4f-214163702cac', + 'unique_id': '0d94e5db-8501-2355-eb4f-214163702cac_main_switch_switch_switch', 'unit_of_measurement': None, }) # --- @@ -875,7 +875,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '5cc1c096-98b9-460c-8f1c-1045509ec605', + 'unique_id': '5cc1c096-98b9-460c-8f1c-1045509ec605_main_switch_switch_switch', 'unit_of_measurement': None, }) # --- @@ -922,7 +922,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '4588d2d9-a8cf-40f4-9a0b-ed5dfbaccda1', + 'unique_id': '4588d2d9-a8cf-40f4-9a0b-ed5dfbaccda1_main_switch_switch_switch', 'unit_of_measurement': None, }) # --- diff --git a/tests/components/smartthings/snapshots/test_update.ambr b/tests/components/smartthings/snapshots/test_update.ambr index e74d2d8518c..c27a0b9f5fc 100644 --- a/tests/components/smartthings/snapshots/test_update.ambr +++ b/tests/components/smartthings/snapshots/test_update.ambr @@ -29,7 +29,7 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': None, - 'unique_id': '286ba274-4093-4bcb-849c-a1a3efe7b1e5', + 'unique_id': '286ba274-4093-4bcb-849c-a1a3efe7b1e5_main', 'unit_of_measurement': None, }) # --- @@ -89,7 +89,7 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': None, - 'unique_id': 'd0268a69-abfb-4c92-a646-61cec2e510ad', + 'unique_id': 'd0268a69-abfb-4c92-a646-61cec2e510ad_main', 'unit_of_measurement': None, }) # --- @@ -149,7 +149,7 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': None, - 'unique_id': '2d9a892b-1c93-45a5-84cb-0e81889498c6', + 'unique_id': '2d9a892b-1c93-45a5-84cb-0e81889498c6_main', 'unit_of_measurement': None, }) # --- @@ -209,7 +209,7 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': None, - 'unique_id': '71afed1c-006d-4e48-b16e-e7f88f9fd638', + 'unique_id': '71afed1c-006d-4e48-b16e-e7f88f9fd638_main', 'unit_of_measurement': None, }) # --- @@ -269,7 +269,7 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': None, - 'unique_id': '7d246592-93db-4d72-a10d-5a51793ece8c', + 'unique_id': '7d246592-93db-4d72-a10d-5a51793ece8c_main', 'unit_of_measurement': None, }) # --- @@ -329,7 +329,7 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': None, - 'unique_id': '550a1c72-65a0-4d55-b97b-75168e055398', + 'unique_id': '550a1c72-65a0-4d55-b97b-75168e055398_main', 'unit_of_measurement': None, }) # --- @@ -389,7 +389,7 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': None, - 'unique_id': 'a9f587c5-5d8b-4273-8907-e7f609af5158', + 'unique_id': 'a9f587c5-5d8b-4273-8907-e7f609af5158_main', 'unit_of_measurement': None, }) # --- diff --git a/tests/components/smartthings/snapshots/test_valve.ambr b/tests/components/smartthings/snapshots/test_valve.ambr index bdb61187e3a..f82155c8499 100644 --- a/tests/components/smartthings/snapshots/test_valve.ambr +++ b/tests/components/smartthings/snapshots/test_valve.ambr @@ -29,7 +29,7 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': None, - 'unique_id': '612ab3c2-3bb0-48f7-b2c0-15b169cb2fc3', + 'unique_id': '612ab3c2-3bb0-48f7-b2c0-15b169cb2fc3_main', 'unit_of_measurement': None, }) # --- diff --git a/tests/components/smartthings/test_config_flow.py b/tests/components/smartthings/test_config_flow.py index 4069c201225..d6e8ef03290 100644 --- a/tests/components/smartthings/test_config_flow.py +++ b/tests/components/smartthings/test_config_flow.py @@ -513,7 +513,7 @@ async def test_migration( } assert mock_old_config_entry.unique_id == "397678e5-9995-4a39-9d9f-ae6ba310236c" assert mock_old_config_entry.version == 3 - assert mock_old_config_entry.minor_version == 1 + assert mock_old_config_entry.minor_version == 2 @pytest.mark.usefixtures("current_request_with_host", "use_cloud") @@ -586,7 +586,7 @@ async def test_migration_wrong_location( == "appid123-2be1-4e40-b257-e4ef59083324_397678e5-9995-4a39-9d9f-ae6ba310236c" ) assert mock_old_config_entry.version == 3 - assert mock_old_config_entry.minor_version == 1 + assert mock_old_config_entry.minor_version == 2 @pytest.mark.usefixtures("current_request_with_host") diff --git a/tests/components/smartthings/test_init.py b/tests/components/smartthings/test_init.py index 16458007c29..991f44e4377 100644 --- a/tests/components/smartthings/test_init.py +++ b/tests/components/smartthings/test_init.py @@ -15,13 +15,26 @@ from pysmartthings import ( import pytest from syrupy import SnapshotAssertion -from homeassistant.components.climate import HVACMode +from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN +from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN, HVACMode +from homeassistant.components.cover import DOMAIN as COVER_DOMAIN +from homeassistant.components.fan import DOMAIN as FAN_DOMAIN +from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN +from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.components.smartthings import EVENT_BUTTON -from homeassistant.components.smartthings.const import CONF_SUBSCRIPTION_ID, DOMAIN +from homeassistant.components.smartthings.const import ( + CONF_INSTALLED_APP_ID, + CONF_LOCATION_ID, + CONF_SUBSCRIPTION_ID, + DOMAIN, + SCOPES, +) +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import Event, HomeAssistant -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import device_registry as dr, entity_registry as er from . import setup_integration, trigger_update @@ -354,7 +367,6 @@ async def test_deleted_device_runtime( hass: HomeAssistant, devices: AsyncMock, mock_config_entry: MockConfigEntry, - snapshot: SnapshotAssertion, ) -> None: """Test devices that are deleted in runtime.""" await setup_integration(hass, mock_config_entry) @@ -367,3 +379,257 @@ async def test_deleted_device_runtime( await hass.async_block_till_done() assert hass.states.get("climate.ac_office_granit") is None + + +@pytest.mark.parametrize( + ( + "device_fixture", + "domain", + "old_unique_id", + "suggested_object_id", + "new_unique_id", + ), + [ + ( + "multipurpose_sensor", + BINARY_SENSOR_DOMAIN, + "7d246592-93db-4d72-a10d-5a51793ece8c.contact", + "deck_door", + "7d246592-93db-4d72-a10d-5a51793ece8c_main_contactSensor_contact_contact", + ), + ( + "multipurpose_sensor", + SENSOR_DOMAIN, + "7d246592-93db-4d72-a10d-5a51793ece8c Y Coordinate", + "deck_door_y_coordinate", + "7d246592-93db-4d72-a10d-5a51793ece8c_main_threeAxis_threeAxis_y_coordinate", + ), + ( + "da_ac_rac_000001", + SENSOR_DOMAIN, + "7d246592-93db-4d72-a10d-ca799957065d.energy_meter", + "ac_office_granit_energy", + "7d246592-93db-4d72-a10d-ca799957065d_main_powerConsumptionReport_powerConsumption_energy_meter", + ), + ( + "da_ac_rac_000001", + CLIMATE_DOMAIN, + "7d246592-93db-4d72-a10d-ca799957065d", + "ac_office_granit", + "7d246592-93db-4d72-a10d-ca799957065d_main", + ), + ( + "c2c_shade", + COVER_DOMAIN, + "571af102-15db-4030-b76b-245a691f74a5", + "curtain_1a", + "571af102-15db-4030-b76b-245a691f74a5_main", + ), + ( + "generic_fan_3_speed", + FAN_DOMAIN, + "6d95a8b7-4ee3-429a-a13a-00ec9354170c", + "bedroom_fan", + "6d95a8b7-4ee3-429a-a13a-00ec9354170c_main", + ), + ( + "hue_rgbw_color_bulb", + LIGHT_DOMAIN, + "cb958955-b015-498c-9e62-fc0c51abd054", + "standing_light", + "cb958955-b015-498c-9e62-fc0c51abd054_main", + ), + ( + "yale_push_button_deadbolt_lock", + LOCK_DOMAIN, + "a9f587c5-5d8b-4273-8907-e7f609af5158", + "basement_door_lock", + "a9f587c5-5d8b-4273-8907-e7f609af5158_main", + ), + ( + "smart_plug", + SWITCH_DOMAIN, + "550a1c72-65a0-4d55-b97b-75168e055398", + "arlo_beta_basestation", + "550a1c72-65a0-4d55-b97b-75168e055398_main_switch_switch_switch", + ), + ], +) +async def test_entity_unique_id_migration( + hass: HomeAssistant, + devices: AsyncMock, + expires_at: int, + entity_registry: er.EntityRegistry, + domain: str, + old_unique_id: str, + suggested_object_id: str, + new_unique_id: str, +) -> None: + """Test entity unique ID migration.""" + mock_config_entry = MockConfigEntry( + domain=DOMAIN, + title="My home", + unique_id="397678e5-9995-4a39-9d9f-ae6ba310236c", + data={ + "auth_implementation": DOMAIN, + "token": { + "access_token": "mock-access-token", + "refresh_token": "mock-refresh-token", + "expires_at": expires_at, + "scope": " ".join(SCOPES), + "access_tier": 0, + "installed_app_id": "5aaaa925-2be1-4e40-b257-e4ef59083324", + }, + CONF_LOCATION_ID: "397678e5-9995-4a39-9d9f-ae6ba310236c", + CONF_INSTALLED_APP_ID: "123", + }, + version=3, + minor_version=1, + ) + mock_config_entry.add_to_hass(hass) + entry = entity_registry.async_get_or_create( + domain, + DOMAIN, + old_unique_id, + config_entry=mock_config_entry, + suggested_object_id=suggested_object_id, + ) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + entry = entity_registry.async_get(entry.entity_id) + + assert entry.unique_id == new_unique_id + + +@pytest.mark.parametrize( + ( + "device_fixture", + "domain", + "other_unique_id", + "old_unique_id", + "suggested_object_id", + "new_unique_id", + ), + [ + ( + "da_ks_microwave_0101x", + SENSOR_DOMAIN, + "2bad3237-4886-e699-1b90-4a51a3d55c8a.ovenJobState", + "2bad3237-4886-e699-1b90-4a51a3d55c8a.machineState", + "microwave_machine_state", + "2bad3237-4886-e699-1b90-4a51a3d55c8a_main_ovenOperatingState_machineState_machineState", + ), + ( + "da_ks_microwave_0101x", + SENSOR_DOMAIN, + "2bad3237-4886-e699-1b90-4a51a3d55c8a_main_ovenOperatingState_ovenJobState_ovenJobState", + "2bad3237-4886-e699-1b90-4a51a3d55c8a.machineState", + "microwave_machine_state", + "2bad3237-4886-e699-1b90-4a51a3d55c8a_main_ovenOperatingState_machineState_machineState", + ), + ( + "da_wm_dw_000001", + SENSOR_DOMAIN, + "f36dc7ce-cac0-0667-dc14-a3704eb5e676.dishwasherJobState", + "f36dc7ce-cac0-0667-dc14-a3704eb5e676.machineState", + "microwave_machine_state", + "f36dc7ce-cac0-0667-dc14-a3704eb5e676_main_dishwasherOperatingState_machineState_machineState", + ), + ( + "da_wm_dw_000001", + SENSOR_DOMAIN, + "f36dc7ce-cac0-0667-dc14-a3704eb5e676_main_dishwasherOperatingState_dishwasherJobState_dishwasherJobState", + "f36dc7ce-cac0-0667-dc14-a3704eb5e676.machineState", + "microwave_machine_state", + "f36dc7ce-cac0-0667-dc14-a3704eb5e676_main_dishwasherOperatingState_machineState_machineState", + ), + ( + "da_wm_wd_000001", + SENSOR_DOMAIN, + "02f7256e-8353-5bdd-547f-bd5b1647e01b.dryerJobState", + "02f7256e-8353-5bdd-547f-bd5b1647e01b.machineState", + "dryer_machine_state", + "02f7256e-8353-5bdd-547f-bd5b1647e01b_main_dryerOperatingState_machineState_machineState", + ), + ( + "da_wm_wd_000001", + SENSOR_DOMAIN, + "02f7256e-8353-5bdd-547f-bd5b1647e01b_main_dryerOperatingState_dryerJobState_dryerJobState", + "02f7256e-8353-5bdd-547f-bd5b1647e01b.machineState", + "dryer_machine_state", + "02f7256e-8353-5bdd-547f-bd5b1647e01b_main_dryerOperatingState_machineState_machineState", + ), + ( + "da_wm_wm_000001", + SENSOR_DOMAIN, + "f984b91d-f250-9d42-3436-33f09a422a47.washerJobState", + "f984b91d-f250-9d42-3436-33f09a422a47.machineState", + "washer_machine_state", + "f984b91d-f250-9d42-3436-33f09a422a47_main_washerOperatingState_machineState_machineState", + ), + ( + "da_wm_wm_000001", + SENSOR_DOMAIN, + "f984b91d-f250-9d42-3436-33f09a422a47_main_washerOperatingState_washerJobState_washerJobState", + "f984b91d-f250-9d42-3436-33f09a422a47.machineState", + "washer_machine_state", + "f984b91d-f250-9d42-3436-33f09a422a47_main_washerOperatingState_machineState_machineState", + ), + ], +) +async def test_entity_unique_id_migration_machine_state( + hass: HomeAssistant, + devices: AsyncMock, + expires_at: int, + entity_registry: er.EntityRegistry, + domain: str, + other_unique_id: str, + old_unique_id: str, + suggested_object_id: str, + new_unique_id: str, +) -> None: + """Test entity unique ID migration.""" + mock_config_entry = MockConfigEntry( + domain=DOMAIN, + title="My home", + unique_id="397678e5-9995-4a39-9d9f-ae6ba310236c", + data={ + "auth_implementation": DOMAIN, + "token": { + "access_token": "mock-access-token", + "refresh_token": "mock-refresh-token", + "expires_at": expires_at, + "scope": " ".join(SCOPES), + "access_tier": 0, + "installed_app_id": "5aaaa925-2be1-4e40-b257-e4ef59083324", + }, + CONF_LOCATION_ID: "397678e5-9995-4a39-9d9f-ae6ba310236c", + CONF_INSTALLED_APP_ID: "123", + }, + version=3, + minor_version=1, + ) + mock_config_entry.add_to_hass(hass) + entity_registry.async_get_or_create( + domain, + DOMAIN, + other_unique_id, + config_entry=mock_config_entry, + suggested_object_id="job_state", + ) + entry = entity_registry.async_get_or_create( + domain, + DOMAIN, + old_unique_id, + config_entry=mock_config_entry, + suggested_object_id=suggested_object_id, + ) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + entry = entity_registry.async_get(entry.entity_id) + + assert entry.unique_id == new_unique_id From 2d8420b6567aa063eddc3d17ee677f712ec9af1b Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 26 Mar 2025 10:25:12 +0100 Subject: [PATCH 3061/3148] Fix spelling of "serial number" in `smappee` (#141449) --- homeassistant/components/smappee/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/smappee/strings.json b/homeassistant/components/smappee/strings.json index 2966b5cd753..3037fbc98f6 100644 --- a/homeassistant/components/smappee/strings.json +++ b/homeassistant/components/smappee/strings.json @@ -15,7 +15,7 @@ } }, "zeroconf_confirm": { - "description": "Do you want to add the Smappee device with serialnumber `{serialnumber}` to Home Assistant?", + "description": "Do you want to add the Smappee device with serial number `{serialnumber}` to Home Assistant?", "title": "Discovered Smappee device" }, "pick_implementation": { From 7848c3cd79486a2babc48c3dea681a157327898d Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 26 Mar 2025 10:45:05 +0100 Subject: [PATCH 3062/3148] Fixes to user-facing strings of `cloudflare` integration (#141452) - fix sentence-casing of a few strings - fix grammar of action description --- homeassistant/components/cloudflare/strings.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/cloudflare/strings.json b/homeassistant/components/cloudflare/strings.json index 8c8ec57b074..453135f47a0 100644 --- a/homeassistant/components/cloudflare/strings.json +++ b/homeassistant/components/cloudflare/strings.json @@ -4,19 +4,19 @@ "step": { "user": { "title": "Connect to Cloudflare", - "description": "This integration requires an API Token created with Zone:Zone:Read and Zone:DNS:Edit permissions for all zones in your account.", + "description": "This integration requires an API token created with Zone:Zone:Read and Zone:DNS:Edit permissions for all zones in your account.", "data": { "api_token": "[%key:common::config_flow::data::api_token%]" } }, "zone": { - "title": "Choose the Zone to Update", + "title": "Choose the zone to update", "data": { "zone": "Zone" } }, "records": { - "title": "Choose the Records to Update", + "title": "Choose the records to update", "data": { "records": "Records" } @@ -40,7 +40,7 @@ "services": { "update_records": { "name": "Update records", - "description": "Manually trigger update to Cloudflare records." + "description": "Manually triggers an update of Cloudflare records." } } } From e8158234a9830c52358d992f25b256661980ce4d Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 26 Mar 2025 10:45:55 +0100 Subject: [PATCH 3063/3148] Fix grammar in `spotify` reauthentication error (#141451) --- homeassistant/components/spotify/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/spotify/strings.json b/homeassistant/components/spotify/strings.json index 90e573a1706..66d837c503f 100644 --- a/homeassistant/components/spotify/strings.json +++ b/homeassistant/components/spotify/strings.json @@ -13,7 +13,7 @@ "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", "missing_configuration": "The Spotify integration is not configured. Please follow the documentation.", "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", - "reauth_account_mismatch": "The Spotify account authenticated with, does not match the account needed re-authentication.", + "reauth_account_mismatch": "The Spotify account authenticated with does not match the account that needed re-authentication.", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", From 02f8322ac1ecf852be4407a500335fc5f70c44e7 Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Wed, 26 Mar 2025 10:55:18 +0100 Subject: [PATCH 3064/3148] Bump ZHA to 0.0.54 (#141447) * Bump ZHA to 0.0.54 * Add strings for v2 quirk entities * Adjust cover tests for new ZHA behavior * Improve cover tests further --- homeassistant/components/zha/manifest.json | 2 +- homeassistant/components/zha/strings.json | 33 +++++++++++++++++ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/zha/test_cover.py | 43 +++++++++++++--------- 5 files changed, 61 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 6ed8b253e75..4daa2f2aa40 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -21,7 +21,7 @@ "zha", "universal_silabs_flasher" ], - "requirements": ["zha==0.0.53"], + "requirements": ["zha==0.0.54"], "usb": [ { "vid": "10C4", diff --git a/homeassistant/components/zha/strings.json b/homeassistant/components/zha/strings.json index 23bb9ae051e..a35dd50df54 100644 --- a/homeassistant/components/zha/strings.json +++ b/homeassistant/components/zha/strings.json @@ -610,6 +610,12 @@ }, "flow_switch": { "name": "Flow switch" + }, + "water_leak": { + "name": "Water leak" + }, + "water_supply": { + "name": "Water supply" } }, "button": { @@ -1101,6 +1107,27 @@ }, "shutdown_timer": { "name": "Shutdown timer" + }, + "calibration_vertical_run_time_up": { + "name": "Calibration vertical run time up" + }, + "calibration_vertical_run_time_down": { + "name": "Calibration vertical run time down" + }, + "calibration_rotation_run_time_up": { + "name": "Calibration rotation run time up" + }, + "calibration_rotation_run_time_down": { + "name": "Calibration rotation run time down" + }, + "impulse_mode_duration": { + "name": "Impulse mode duration" + }, + "water_duration": { + "name": "Water duration" + }, + "water_interval": { + "name": "Water interval" } }, "select": { @@ -1319,6 +1346,9 @@ }, "hysteresis_mode": { "name": "Hysteresis mode" + }, + "speed": { + "name": "Speed" } }, "sensor": { @@ -1666,6 +1696,9 @@ }, "last_watering_duration": { "name": "Last watering duration" + }, + "device_status": { + "name": "Device status" } }, "switch": { diff --git a/requirements_all.txt b/requirements_all.txt index 267281885ad..50aeacae6aa 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3152,7 +3152,7 @@ zeroconf==0.146.0 zeversolar==0.3.2 # homeassistant.components.zha -zha==0.0.53 +zha==0.0.54 # homeassistant.components.zhong_hong zhong-hong-hvac==1.0.13 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 45c5353d6f0..8957e12bd2f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2542,7 +2542,7 @@ zeroconf==0.146.0 zeversolar==0.3.2 # homeassistant.components.zha -zha==0.0.53 +zha==0.0.54 # homeassistant.components.zwave_js zwave-js-server-python==0.62.0 diff --git a/tests/components/zha/test_cover.py b/tests/components/zha/test_cover.py index e5d588aa1bf..4bc4d6c97cf 100644 --- a/tests/components/zha/test_cover.py +++ b/tests/components/zha/test_cover.py @@ -81,7 +81,7 @@ async def test_cover(hass: HomeAssistant, setup_zha, zigpy_device_mock) -> None: cluster = zigpy_device.endpoints[1].window_covering cluster.PLUGGED_ATTR_READS = { WCAttrs.current_position_lift_percentage.name: 0, - WCAttrs.current_position_tilt_percentage.name: 42, + WCAttrs.current_position_tilt_percentage.name: 100, WCAttrs.window_covering_type.name: WCT.Tilt_blind_tilt_and_lift, WCAttrs.config_status.name: WCCS(~WCCS.Open_up_commands_reversed), } @@ -115,33 +115,33 @@ async def test_cover(hass: HomeAssistant, setup_zha, zigpy_device_mock) -> None: assert state assert state.state == CoverState.OPEN assert state.attributes[ATTR_CURRENT_POSITION] == 100 - assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 58 + assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 0 - # test that the state has changed from unavailable to off + # test that the state has changed from open to closed await send_attributes_report( hass, cluster, {WCAttrs.current_position_lift_percentage.id: 100} ) assert hass.states.get(entity_id).state == CoverState.CLOSED - # test to see if it opens + # test that it opens await send_attributes_report( hass, cluster, {WCAttrs.current_position_lift_percentage.id: 0} ) assert hass.states.get(entity_id).state == CoverState.OPEN - # test that the state remains after tilting to 100% - await send_attributes_report( - hass, cluster, {WCAttrs.current_position_tilt_percentage.id: 100} - ) - assert hass.states.get(entity_id).state == CoverState.OPEN - - # test to see the state remains after tilting to 0% + # test that the state remains after tilting to 0% (open) await send_attributes_report( hass, cluster, {WCAttrs.current_position_tilt_percentage.id: 0} ) assert hass.states.get(entity_id).state == CoverState.OPEN - # close from UI + # test that the state remains after tilting to 100% (closed) + await send_attributes_report( + hass, cluster, {WCAttrs.current_position_tilt_percentage.id: 100} + ) + assert hass.states.get(entity_id).state == CoverState.OPEN + + # close lift from UI with patch("zigpy.zcl.Cluster.request", return_value=[0x1, zcl_f.Status.SUCCESS]): await hass.services.async_call( COVER_DOMAIN, SERVICE_CLOSE_COVER, {"entity_id": entity_id}, blocking=True @@ -160,6 +160,11 @@ async def test_cover(hass: HomeAssistant, setup_zha, zigpy_device_mock) -> None: assert hass.states.get(entity_id).state == CoverState.CLOSED + # close tilt from UI, needs re-opening first + await send_attributes_report( + hass, cluster, {WCAttrs.current_position_tilt_percentage.id: 0} + ) + assert hass.states.get(entity_id).state == CoverState.OPEN with patch("zigpy.zcl.Cluster.request", return_value=[0x1, zcl_f.Status.SUCCESS]): await hass.services.async_call( COVER_DOMAIN, @@ -185,7 +190,7 @@ async def test_cover(hass: HomeAssistant, setup_zha, zigpy_device_mock) -> None: assert hass.states.get(entity_id).state == CoverState.CLOSED - # open from UI + # open lift from UI with patch("zigpy.zcl.Cluster.request", return_value=[0x0, zcl_f.Status.SUCCESS]): await hass.services.async_call( COVER_DOMAIN, SERVICE_OPEN_COVER, {"entity_id": entity_id}, blocking=True @@ -204,6 +209,7 @@ async def test_cover(hass: HomeAssistant, setup_zha, zigpy_device_mock) -> None: assert hass.states.get(entity_id).state == CoverState.OPEN + # open tilt from UI with patch("zigpy.zcl.Cluster.request", return_value=[0x0, zcl_f.Status.SUCCESS]): await hass.services.async_call( COVER_DOMAIN, @@ -229,7 +235,7 @@ async def test_cover(hass: HomeAssistant, setup_zha, zigpy_device_mock) -> None: assert hass.states.get(entity_id).state == CoverState.OPEN - # set position UI + # set lift position from UI with patch("zigpy.zcl.Cluster.request", return_value=[0x5, zcl_f.Status.SUCCESS]): await hass.services.async_call( COVER_DOMAIN, @@ -261,6 +267,7 @@ async def test_cover(hass: HomeAssistant, setup_zha, zigpy_device_mock) -> None: assert hass.states.get(entity_id).state == CoverState.OPEN + # set tilt position from UI with patch("zigpy.zcl.Cluster.request", return_value=[0x5, zcl_f.Status.SUCCESS]): await hass.services.async_call( COVER_DOMAIN, @@ -281,13 +288,13 @@ async def test_cover(hass: HomeAssistant, setup_zha, zigpy_device_mock) -> None: assert hass.states.get(entity_id).state == CoverState.CLOSING await send_attributes_report( - hass, cluster, {WCAttrs.current_position_lift_percentage.id: 35} + hass, cluster, {WCAttrs.current_position_tilt_percentage.id: 35} ) assert hass.states.get(entity_id).state == CoverState.CLOSING await send_attributes_report( - hass, cluster, {WCAttrs.current_position_lift_percentage.id: 53} + hass, cluster, {WCAttrs.current_position_tilt_percentage.id: 53} ) assert hass.states.get(entity_id).state == CoverState.OPEN @@ -338,7 +345,7 @@ async def test_cover_failures( # load up cover domain cluster = zigpy_device.endpoints[1].window_covering cluster.PLUGGED_ATTR_READS = { - WCAttrs.current_position_tilt_percentage.name: 42, + WCAttrs.current_position_tilt_percentage.name: 100, WCAttrs.window_covering_type.name: WCT.Tilt_blind_tilt_and_lift, } update_attribute_cache(cluster) @@ -355,7 +362,7 @@ async def test_cover_failures( await send_attributes_report(hass, cluster, {0: 0, 8: 100, 1: 1}) assert hass.states.get(entity_id).state == CoverState.CLOSED - # test to see if it opens + # test that it opens await send_attributes_report(hass, cluster, {0: 1, 8: 0, 1: 100}) assert hass.states.get(entity_id).state == CoverState.OPEN From 208e8ae451e971fa77ddd8a106331b767a72f206 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 26 Mar 2025 11:05:31 +0100 Subject: [PATCH 3065/3148] Deprecate SmartThings switch entity (#141360) * Deprecate SmartThings switch entity * Apply suggestions from code review Co-authored-by: Robert Resch * Fix * Revert "Apply suggestions from code review" This reverts commit c6d39d38de1c8b8cc1a95d79a62b6658776375cc. * Revert "Revert "Apply suggestions from code review"" This reverts commit d92411c1560b031eb44679c3f24f3a6835279570. * Fix * Fix --------- Co-authored-by: Robert Resch --- .../components/smartthings/strings.json | 4 + .../components/smartthings/switch.py | 68 ++++++++++++++- tests/components/smartthings/test_switch.py | 83 ++++++++++++++++++- 3 files changed, 151 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index 0f049131681..cbea23f6318 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -480,6 +480,10 @@ "deprecated_binary_fridge_door": { "title": "Deprecated refrigerator door binary sensor detected in some automations or scripts", "description": "The refrigerator door binary sensor `{entity}` is deprecated and is used in the following automations or scripts:\n{items}\n\nSeparate entities for cooler and freezer door are available and should be used going forward. Please use them in the above automations or scripts to fix this issue." + }, + "deprecated_switch_appliance": { + "title": "Deprecated switch detected in some automations or scripts", + "description": "The switch `{entity}` is deprecated because the actions did not work, so it has been replaced with a binary sensor instead.\n\nThe switch was used in the following automations or scripts:\n{items}\n\nPlease use them in the above automations or scripts to fix this issue." } } } diff --git a/homeassistant/components/smartthings/switch.py b/homeassistant/components/smartthings/switch.py index a03decd73c0..6f3db607f91 100644 --- a/homeassistant/components/smartthings/switch.py +++ b/homeassistant/components/smartthings/switch.py @@ -5,14 +5,22 @@ from __future__ import annotations from dataclasses import dataclass from typing import Any -from pysmartthings import Attribute, Capability, Command, SmartThings +from pysmartthings import Attribute, Capability, Category, Command, SmartThings +from homeassistant.components.automation import automations_with_entity +from homeassistant.components.script import scripts_with_entity from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.issue_registry import ( + IssueSeverity, + async_create_issue, + async_delete_issue, +) from . import FullDevice, SmartThingsConfigEntry -from .const import MAIN +from .const import DOMAIN, MAIN from .entity import SmartThingsEntity CAPABILITIES = ( @@ -149,6 +157,62 @@ class SmartThingsSwitch(SmartThingsEntity, SwitchEntity): == "on" ) + async def async_added_to_hass(self) -> None: + """Call when entity is added to hass.""" + await super().async_added_to_hass() + if self.entity_description != SWITCH or self.device.device.components[ + MAIN + ].manufacturer_category not in { + Category.DRYER, + Category.WASHER, + Category.MICROWAVE, + Category.DISHWASHER, + }: + return + automations = automations_with_entity(self.hass, self.entity_id) + scripts = scripts_with_entity(self.hass, self.entity_id) + if not automations and not scripts: + return + + entity_reg: er.EntityRegistry = er.async_get(self.hass) + items_list = [ + f"- [{item.original_name}](/config/{integration}/edit/{item.unique_id})" + for integration, entities in ( + ("automation", automations), + ("script", scripts), + ) + for entity_id in entities + if (item := entity_reg.async_get(entity_id)) + ] + + async_create_issue( + self.hass, + DOMAIN, + f"deprecated_switch_{self.entity_id}", + breaks_in_ha_version="2025.10.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_switch_appliance", + translation_placeholders={ + "entity": self.entity_id, + "items": "\n".join(items_list), + }, + ) + + async def async_will_remove_from_hass(self) -> None: + """Call when entity will be removed from hass.""" + await super().async_will_remove_from_hass() + if self.entity_description != SWITCH or self.device.device.components[ + MAIN + ].manufacturer_category not in { + Category.DRYER, + Category.WASHER, + Category.MICROWAVE, + Category.DISHWASHER, + }: + return + async_delete_issue(self.hass, DOMAIN, f"deprecated_switch_{self.entity_id}") + class SmartThingsCommandSwitch(SmartThingsSwitch): """Define a SmartThings command switch.""" diff --git a/tests/components/smartthings/test_switch.py b/tests/components/smartthings/test_switch.py index 28bac49b0b0..d3908ed10f5 100644 --- a/tests/components/smartthings/test_switch.py +++ b/tests/components/smartthings/test_switch.py @@ -6,7 +6,10 @@ from pysmartthings import Attribute, Capability, Command import pytest from syrupy import SnapshotAssertion -from homeassistant.components.smartthings.const import MAIN +from homeassistant.components import automation, script +from homeassistant.components.automation import automations_with_entity +from homeassistant.components.script import scripts_with_entity +from homeassistant.components.smartthings.const import DOMAIN, MAIN from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.const import ( ATTR_ENTITY_ID, @@ -17,7 +20,8 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import entity_registry as er, issue_registry as ir +from homeassistant.setup import async_setup_component from . import setup_integration, snapshot_smartthings_entities, trigger_update @@ -120,3 +124,78 @@ async def test_state_update( ) assert hass.states.get("switch.2nd_floor_hallway").state == STATE_OFF + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.parametrize( + ("device_fixture", "entity_id"), + [ + ("da_wm_wm_000001", "switch.washer"), + ("da_wm_wd_000001", "switch.dryer"), + ], +) +async def test_create_issue( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + issue_registry: ir.IssueRegistry, + entity_id: str, +) -> None: + """Test we create an issue when an automation or script is using a deprecated entity.""" + issue_id = f"deprecated_switch_{entity_id}" + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "id": "test", + "alias": "test", + "trigger": {"platform": "state", "entity_id": entity_id}, + "action": { + "action": "automation.turn_on", + "target": { + "entity_id": "automation.test", + }, + }, + } + }, + ) + assert await async_setup_component( + hass, + script.DOMAIN, + { + script.DOMAIN: { + "test": { + "sequence": [ + { + "condition": "state", + "entity_id": entity_id, + "state": "on", + }, + ], + } + } + }, + ) + + await setup_integration(hass, mock_config_entry) + + assert automations_with_entity(hass, entity_id)[0] == "automation.test" + assert scripts_with_entity(hass, entity_id)[0] == "script.test" + + assert len(issue_registry.issues) == 1 + issue = issue_registry.async_get_issue(DOMAIN, issue_id) + assert issue is not None + assert issue.translation_key == "deprecated_switch_appliance" + assert issue.translation_placeholders == { + "entity": entity_id, + "items": "- [test](/config/automation/edit/test)\n- [test](/config/script/edit/test)", + } + + await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Assert the issue is no longer present + assert not issue_registry.async_get_issue(DOMAIN, issue_id) + assert len(issue_registry.issues) == 0 From f4fa4056acfdd8f614be8fe47a88614a31ed953c Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Wed, 26 Mar 2025 11:17:54 +0100 Subject: [PATCH 3066/3148] Make BT support detection dynamic for Shelly RPC devices (#137323) --- homeassistant/components/shelly/__init__.py | 8 +++-- .../components/shelly/config_flow.py | 22 ++++++------- .../components/shelly/coordinator.py | 4 ++- homeassistant/components/shelly/strings.json | 4 +++ tests/components/shelly/test_config_flow.py | 33 +++++++++++++++++++ tests/components/shelly/test_coordinator.py | 10 ++++-- 6 files changed, 64 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/shelly/__init__.py b/homeassistant/components/shelly/__init__.py index 08c161c357e..ee28c41f18b 100644 --- a/homeassistant/components/shelly/__init__.py +++ b/homeassistant/components/shelly/__init__.py @@ -293,9 +293,11 @@ async def _async_setup_rpc_entry(hass: HomeAssistant, entry: ShellyConfigEntry) translation_key="firmware_unsupported", translation_placeholders={"device": entry.title}, ) - runtime_data.rpc_script_events = await get_rpc_scripts_event_types( - device, ignore_scripts=[BLE_SCRIPT_NAME] - ) + runtime_data.rpc_supports_scripts = await device.supports_scripts() + if runtime_data.rpc_supports_scripts: + runtime_data.rpc_script_events = await get_rpc_scripts_event_types( + device, ignore_scripts=[BLE_SCRIPT_NAME] + ) except (DeviceConnectionError, MacAddressMismatchError, RpcCallError) as err: await device.shutdown() raise ConfigEntryNotReady( diff --git a/homeassistant/components/shelly/config_flow.py b/homeassistant/components/shelly/config_flow.py index c7c1cd70a53..200a88ea24c 100644 --- a/homeassistant/components/shelly/config_flow.py +++ b/homeassistant/components/shelly/config_flow.py @@ -7,12 +7,7 @@ from typing import Any, Final from aioshelly.block_device import BlockDevice from aioshelly.common import ConnectionOptions, get_info -from aioshelly.const import ( - BLOCK_GENERATIONS, - DEFAULT_HTTP_PORT, - MODEL_WALL_DISPLAY, - RPC_GENERATIONS, -) +from aioshelly.const import BLOCK_GENERATIONS, DEFAULT_HTTP_PORT, RPC_GENERATIONS from aioshelly.exceptions import ( CustomPortNotSupported, DeviceConnectionError, @@ -461,11 +456,9 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN): @callback def async_supports_options_flow(cls, config_entry: ShellyConfigEntry) -> bool: """Return options flow support for this handler.""" - return ( - get_device_entry_gen(config_entry) in RPC_GENERATIONS - and not config_entry.data.get(CONF_SLEEP_PERIOD) - and config_entry.data.get(CONF_MODEL) != MODEL_WALL_DISPLAY - ) + return get_device_entry_gen( + config_entry + ) in RPC_GENERATIONS and not config_entry.data.get(CONF_SLEEP_PERIOD) class OptionsFlowHandler(OptionsFlow): @@ -475,6 +468,13 @@ class OptionsFlowHandler(OptionsFlow): self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle options flow.""" + if ( + supports_scripts := self.config_entry.runtime_data.rpc_supports_scripts + ) is None: + return self.async_abort(reason="cannot_connect") + if not supports_scripts: + return self.async_abort(reason="no_scripts_support") + if user_input is not None: return self.async_create_entry(title="", data=user_input) diff --git a/homeassistant/components/shelly/coordinator.py b/homeassistant/components/shelly/coordinator.py index 076a6621354..4a1ea72f38a 100644 --- a/homeassistant/components/shelly/coordinator.py +++ b/homeassistant/components/shelly/coordinator.py @@ -89,6 +89,7 @@ class ShellyEntryData: rpc: ShellyRpcCoordinator | None = None rpc_poll: ShellyRpcPollingCoordinator | None = None rpc_script_events: dict[int, list[str]] | None = None + rpc_supports_scripts: bool | None = None type ShellyConfigEntry = ConfigEntry[ShellyEntryData] @@ -716,7 +717,8 @@ class ShellyRpcCoordinator(ShellyCoordinatorBase[RpcDevice]): is updated. """ if not self.sleep_period: - await self._async_connect_ble_scanner() + if self.config_entry.runtime_data.rpc_supports_scripts: + await self._async_connect_ble_scanner() else: await self._async_setup_outbound_websocket() diff --git a/homeassistant/components/shelly/strings.json b/homeassistant/components/shelly/strings.json index 9eea5e3be9d..afc3f92a3ce 100644 --- a/homeassistant/components/shelly/strings.json +++ b/homeassistant/components/shelly/strings.json @@ -100,6 +100,10 @@ "ble_scanner_mode": "The scanner mode to use for Bluetooth scanning." } } + }, + "abort": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "no_scripts_support": "Device does not support scripts and cannot be used as a Bluetooth scanner." } }, "selector": { diff --git a/tests/components/shelly/test_config_flow.py b/tests/components/shelly/test_config_flow.py index 5d8e09d0b56..fffffc21cae 100644 --- a/tests/components/shelly/test_config_flow.py +++ b/tests/components/shelly/test_config_flow.py @@ -24,6 +24,7 @@ from homeassistant.components.shelly.const import ( BLEScannerMode, ) from homeassistant.components.shelly.coordinator import ENTRY_RELOAD_COOLDOWN +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( CONF_HOST, CONF_MODEL, @@ -744,6 +745,38 @@ async def test_zeroconf_sleeping_device_error(hass: HomeAssistant) -> None: assert result["reason"] == "cannot_connect" +async def test_options_flow_abort_setup_retry( + hass: HomeAssistant, mock_rpc_device: Mock, monkeypatch: pytest.MonkeyPatch +) -> None: + """Test ble options abort if device is in setup retry.""" + monkeypatch.setattr( + mock_rpc_device, "initialize", AsyncMock(side_effect=DeviceConnectionError) + ) + entry = await init_integration(hass, 2) + + assert entry.state is ConfigEntryState.SETUP_RETRY + + result = await hass.config_entries.options.async_init(entry.entry_id) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + +async def test_options_flow_abort_no_scripts_support( + hass: HomeAssistant, mock_rpc_device: Mock, monkeypatch: pytest.MonkeyPatch +) -> None: + """Test ble options abort if device does not support scripts.""" + monkeypatch.setattr( + mock_rpc_device, "supports_scripts", AsyncMock(return_value=False) + ) + entry = await init_integration(hass, 2) + + result = await hass.config_entries.options.async_init(entry.entry_id) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_scripts_support" + + async def test_zeroconf_already_configured(hass: HomeAssistant) -> None: """Test we get the form.""" diff --git a/tests/components/shelly/test_coordinator.py b/tests/components/shelly/test_coordinator.py index 27581b4d7c6..f89bec8853a 100644 --- a/tests/components/shelly/test_coordinator.py +++ b/tests/components/shelly/test_coordinator.py @@ -853,12 +853,17 @@ async def test_rpc_update_entry_fw_ver( assert device.sw_version == "99.0.0" +@pytest.mark.parametrize(("supports_scripts"), [True, False]) async def test_rpc_runs_connected_events_when_initialized( hass: HomeAssistant, mock_rpc_device: Mock, monkeypatch: pytest.MonkeyPatch, + supports_scripts: bool, ) -> None: """Test RPC runs connected events when initialized.""" + monkeypatch.setattr( + mock_rpc_device, "supports_scripts", AsyncMock(return_value=supports_scripts) + ) monkeypatch.setattr(mock_rpc_device, "initialized", False) await init_integration(hass, 2) @@ -869,8 +874,9 @@ async def test_rpc_runs_connected_events_when_initialized( mock_rpc_device.mock_initialized() await hass.async_block_till_done() - # BLE script list is called during connected events - assert call.script_list() in mock_rpc_device.mock_calls + assert call.supports_scripts() in mock_rpc_device.mock_calls + # BLE script list is called during connected events if device supports scripts + assert bool(call.script_list() in mock_rpc_device.mock_calls) == supports_scripts async def test_rpc_sleeping_device_unload_ignore_ble_scanner( From e10801af80cafc63f783ad6b06babee2390e2278 Mon Sep 17 00:00:00 2001 From: TimL Date: Wed, 26 Mar 2025 21:28:25 +1100 Subject: [PATCH 3067/3148] Bump pysmlight to v0.2.4 (#141450) --- homeassistant/components/smlight/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/smlight/manifest.json b/homeassistant/components/smlight/manifest.json index 3f527d1fcd9..e9025203b8c 100644 --- a/homeassistant/components/smlight/manifest.json +++ b/homeassistant/components/smlight/manifest.json @@ -11,7 +11,7 @@ "documentation": "https://www.home-assistant.io/integrations/smlight", "integration_type": "device", "iot_class": "local_push", - "requirements": ["pysmlight==0.2.3"], + "requirements": ["pysmlight==0.2.4"], "zeroconf": [ { "type": "_slzb-06._tcp.local." diff --git a/requirements_all.txt b/requirements_all.txt index 50aeacae6aa..291ddcce107 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2328,7 +2328,7 @@ pysmhi==1.0.0 pysml==0.0.12 # homeassistant.components.smlight -pysmlight==0.2.3 +pysmlight==0.2.4 # homeassistant.components.snmp pysnmp==6.2.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8957e12bd2f..04b1f0baea1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1898,7 +1898,7 @@ pysmhi==1.0.0 pysml==0.0.12 # homeassistant.components.smlight -pysmlight==0.2.3 +pysmlight==0.2.4 # homeassistant.components.snmp pysnmp==6.2.6 From 043603c9be9f88744baa59a18b103f30ecd516cc Mon Sep 17 00:00:00 2001 From: TimL Date: Wed, 26 Mar 2025 21:34:44 +1100 Subject: [PATCH 3068/3148] Add SMLIGHT sensor entities for second radio (#137403) * Add sensors for second radio * Add test for zigbee2 sensor * Update homeassistant/components/smlight/sensor.py Co-authored-by: Joost Lekkerkerker * drop useless replace * Fix test failure * Fix code coverage in config flow * Update homeassistant/components/smlight/sensor.py Co-authored-by: Joost Lekkerkerker * fix conversion of iterator to list * Remove assert on radios * simplify handling of radios further --------- Co-authored-by: Joost Lekkerkerker Co-authored-by: Franck Nijhof --- .../components/smlight/config_flow.py | 26 +++++----- homeassistant/components/smlight/sensor.py | 51 ++++++++++++++----- tests/components/smlight/test_config_flow.py | 6 +-- tests/components/smlight/test_sensor.py | 40 ++++++++++++++- 4 files changed, 91 insertions(+), 32 deletions(-) diff --git a/homeassistant/components/smlight/config_flow.py b/homeassistant/components/smlight/config_flow.py index fcfc364d983..ce4f8f43233 100644 --- a/homeassistant/components/smlight/config_flow.py +++ b/homeassistant/components/smlight/config_flow.py @@ -51,14 +51,14 @@ class SmlightConfigFlow(ConfigFlow, domain=DOMAIN): self.client = Api2(self._host, session=async_get_clientsession(self.hass)) try: - info = await self.client.get_info() - self._host = str(info.device_ip) - self._device_name = str(info.hostname) - - if info.model not in Devices: - return self.async_abort(reason="unsupported_device") - if not await self._async_check_auth_required(user_input): + info = await self.client.get_info() + self._host = str(info.device_ip) + self._device_name = str(info.hostname) + + if info.model not in Devices: + return self.async_abort(reason="unsupported_device") + return await self._async_complete_entry(user_input) except SmlightConnectionError: errors["base"] = "cannot_connect" @@ -128,13 +128,13 @@ class SmlightConfigFlow(ConfigFlow, domain=DOMAIN): if user_input is not None: try: - info = await self.client.get_info() - - if info.model not in Devices: - return self.async_abort(reason="unsupported_device") - if not await self._async_check_auth_required(user_input): - return await self._async_complete_entry(user_input) + info = await self.client.get_info() + + if info.model not in Devices: + return self.async_abort(reason="unsupported_device") + + return await self._async_complete_entry(user_input) except SmlightConnectionError: return self.async_abort(reason="cannot_connect") diff --git a/homeassistant/components/smlight/sensor.py b/homeassistant/components/smlight/sensor.py index 57a08d177d4..2f57843b5eb 100644 --- a/homeassistant/components/smlight/sensor.py +++ b/homeassistant/components/smlight/sensor.py @@ -37,7 +37,7 @@ class SmSensorEntityDescription(SensorEntityDescription): class SmInfoEntityDescription(SensorEntityDescription): """Class describing SMLIGHT information entities.""" - value_fn: Callable[[Info], StateType] + value_fn: Callable[[Info, int], StateType] INFO: list[SmInfoEntityDescription] = [ @@ -46,24 +46,25 @@ INFO: list[SmInfoEntityDescription] = [ translation_key="device_mode", device_class=SensorDeviceClass.ENUM, options=["eth", "wifi", "usb"], - value_fn=lambda x: x.coord_mode, + value_fn=lambda x, idx: x.coord_mode, ), SmInfoEntityDescription( key="firmware_channel", translation_key="firmware_channel", device_class=SensorDeviceClass.ENUM, options=["dev", "release"], - value_fn=lambda x: x.fw_channel, - ), - SmInfoEntityDescription( - key="zigbee_type", - translation_key="zigbee_type", - device_class=SensorDeviceClass.ENUM, - options=["coordinator", "router", "thread"], - value_fn=lambda x: x.zb_type, + value_fn=lambda x, idx: x.fw_channel, ), ] +RADIO_INFO = SmInfoEntityDescription( + key="zigbee_type", + translation_key="zigbee_type", + device_class=SensorDeviceClass.ENUM, + options=["coordinator", "router", "thread"], + value_fn=lambda x, idx: x.radios[idx].zb_type, +) + SENSORS: list[SmSensorEntityDescription] = [ SmSensorEntityDescription( @@ -102,6 +103,16 @@ SENSORS: list[SmSensorEntityDescription] = [ ), ] +EXTRA_SENSOR = SmSensorEntityDescription( + key="zigbee_temperature_2", + translation_key="zigbee_temperature", + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + value_fn=lambda x: x.zb_temp2, +) + UPTIME: list[SmSensorEntityDescription] = [ SmSensorEntityDescription( key="core_uptime", @@ -127,8 +138,7 @@ async def async_setup_entry( ) -> None: """Set up SMLIGHT sensor based on a config entry.""" coordinator = entry.runtime_data.data - - async_add_entities( + entities: list[SmEntity] = list( chain( (SmInfoSensorEntity(coordinator, description) for description in INFO), (SmSensorEntity(coordinator, description) for description in SENSORS), @@ -136,6 +146,16 @@ async def async_setup_entry( ) ) + entities.extend( + SmInfoSensorEntity(coordinator, RADIO_INFO, idx) + for idx, _ in enumerate(coordinator.data.info.radios) + ) + + if coordinator.data.sensors.zb_temp2 is not None: + entities.append(SmSensorEntity(coordinator, EXTRA_SENSOR)) + + async_add_entities(entities) + class SmSensorEntity(SmEntity, SensorEntity): """Representation of a slzb sensor.""" @@ -172,17 +192,20 @@ class SmInfoSensorEntity(SmEntity, SensorEntity): self, coordinator: SmDataUpdateCoordinator, description: SmInfoEntityDescription, + idx: int = 0, ) -> None: """Initiate slzb sensor.""" super().__init__(coordinator) self.entity_description = description - self._attr_unique_id = f"{coordinator.unique_id}_{description.key}" + self.idx = idx + sensor = f"_{idx}" if idx else "" + self._attr_unique_id = f"{coordinator.unique_id}_{description.key}{sensor}" @property def native_value(self) -> StateType: """Return the sensor value.""" - value = self.entity_description.value_fn(self.coordinator.data.info) + value = self.entity_description.value_fn(self.coordinator.data.info, self.idx) options = self.entity_description.options if isinstance(value, int) and options is not None: diff --git a/tests/components/smlight/test_config_flow.py b/tests/components/smlight/test_config_flow.py index c8933029ce6..4ecfe9366e3 100644 --- a/tests/components/smlight/test_config_flow.py +++ b/tests/components/smlight/test_config_flow.py @@ -193,7 +193,7 @@ async def test_zeroconf_flow_auth( } assert len(mock_setup_entry.mock_calls) == 1 - assert len(mock_smlight_client.get_info.mock_calls) == 3 + assert len(mock_smlight_client.get_info.mock_calls) == 2 async def test_zeroconf_unsupported_abort( @@ -406,7 +406,7 @@ async def test_user_invalid_auth( } assert len(mock_setup_entry.mock_calls) == 1 - assert len(mock_smlight_client.get_info.mock_calls) == 3 + assert len(mock_smlight_client.get_info.mock_calls) == 2 async def test_user_cannot_connect( @@ -443,7 +443,7 @@ async def test_user_cannot_connect( assert result2["title"] == "SLZB-06p7" assert len(mock_setup_entry.mock_calls) == 1 - assert len(mock_smlight_client.get_info.mock_calls) == 3 + assert len(mock_smlight_client.get_info.mock_calls) == 2 async def test_auth_cannot_connect( diff --git a/tests/components/smlight/test_sensor.py b/tests/components/smlight/test_sensor.py index f130d7ccf30..bec73bc514a 100644 --- a/tests/components/smlight/test_sensor.py +++ b/tests/components/smlight/test_sensor.py @@ -2,17 +2,18 @@ from unittest.mock import MagicMock -from pysmlight import Sensors +from pysmlight import Info, Sensors import pytest from syrupy.assertion import SnapshotAssertion +from homeassistant.components.smlight.const import DOMAIN from homeassistant.const import STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from .conftest import setup_integration -from tests.common import MockConfigEntry, snapshot_platform +from tests.common import MockConfigEntry, load_json_object_fixture, snapshot_platform pytestmark = [ pytest.mark.usefixtures( @@ -73,3 +74,38 @@ async def test_zigbee_uptime_disconnected( state = hass.states.get("sensor.mock_title_zigbee_uptime") assert state.state == STATE_UNKNOWN + + +async def test_zigbee2_temp_sensor( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_smlight_client: MagicMock, +) -> None: + """Test for zb_temp2 if device has second radio.""" + mock_smlight_client.get_sensors.return_value = Sensors(zb_temp2=20.45) + await setup_integration(hass, mock_config_entry) + + state = hass.states.get("sensor.mock_title_zigbee_chip_temp_2") + assert state + assert state.state == "20.45" + + +async def test_zigbee_type_sensors( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_smlight_client: MagicMock, +) -> None: + """Test for zigbee type sensor with second radio.""" + mock_smlight_client.get_info.side_effect = None + mock_smlight_client.get_info.return_value = Info.from_dict( + load_json_object_fixture("info-MR1.json", DOMAIN) + ) + await setup_integration(hass, mock_config_entry) + + state = hass.states.get("sensor.mock_title_zigbee_type") + assert state + assert state.state == "coordinator" + + state = hass.states.get("sensor.mock_title_zigbee_type_2") + assert state + assert state.state == "router" From 57d02d7a17ae12d28fddeec87b5310f5538273da Mon Sep 17 00:00:00 2001 From: TimL Date: Wed, 26 Mar 2025 21:45:07 +1100 Subject: [PATCH 3069/3148] Cleanups related to improved typing on radios objects (#141455) * Improved handling of radio objects * Drop get_radio helper * Remove mock of get_radio in tests --- homeassistant/components/smlight/__init__.py | 8 +------ homeassistant/components/smlight/update.py | 4 +--- tests/components/smlight/test_update.py | 25 ++++++++++---------- 3 files changed, 14 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/smlight/__init__.py b/homeassistant/components/smlight/__init__.py index 8f3e675ef6b..b3a6860e5b7 100644 --- a/homeassistant/components/smlight/__init__.py +++ b/homeassistant/components/smlight/__init__.py @@ -2,7 +2,7 @@ from __future__ import annotations -from pysmlight import Api2, Info, Radio +from pysmlight import Api2 from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant @@ -50,9 +50,3 @@ async def async_setup_entry(hass: HomeAssistant, entry: SmConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: SmConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - - -def get_radio(info: Info, idx: int) -> Radio: - """Get the radio object from the info.""" - assert info.radios is not None - return info.radios[idx] diff --git a/homeassistant/components/smlight/update.py b/homeassistant/components/smlight/update.py index 10d142e6221..3143f2f4290 100644 --- a/homeassistant/components/smlight/update.py +++ b/homeassistant/components/smlight/update.py @@ -22,7 +22,6 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import get_radio from .const import LOGGER from .coordinator import SmConfigEntry, SmFirmwareUpdateCoordinator, SmFwData from .entity import SmEntity @@ -56,7 +55,7 @@ CORE_UPDATE_ENTITY = SmUpdateEntityDescription( ZB_UPDATE_ENTITY = SmUpdateEntityDescription( key="zigbee_update", translation_key="zigbee_update", - installed_version=lambda x, idx: get_radio(x, idx).zb_version, + installed_version=lambda x, idx: x.radios[idx].zb_version, latest_version=zigbee_latest_version, ) @@ -75,7 +74,6 @@ async def async_setup_entry( entities = [SmUpdateEntity(coordinator, CORE_UPDATE_ENTITY)] radios = coordinator.data.info.radios - assert radios is not None entities.extend( SmUpdateEntity(coordinator, ZB_UPDATE_ENTITY, idx) diff --git a/tests/components/smlight/test_update.py b/tests/components/smlight/test_update.py index 86d19968910..d120a08d519 100644 --- a/tests/components/smlight/test_update.py +++ b/tests/components/smlight/test_update.py @@ -154,10 +154,9 @@ async def test_update_zigbee2_firmware( mock_smlight_client: MagicMock, ) -> None: """Test update of zigbee2 firmware where available.""" + mock_info = Info.from_dict(load_json_object_fixture("info-MR1.json", DOMAIN)) mock_smlight_client.get_info.side_effect = None - mock_smlight_client.get_info.return_value = Info.from_dict( - load_json_object_fixture("info-MR1.json", DOMAIN) - ) + mock_smlight_client.get_info.return_value = mock_info await setup_integration(hass, mock_config_entry) entity_id = "update.mock_title_zigbee_firmware_2" state = hass.states.get(entity_id) @@ -177,17 +176,17 @@ async def test_update_zigbee2_firmware( event_function = get_mock_event_function(mock_smlight_client, SmEvents.FW_UPD_done) event_function(MOCK_FIRMWARE_DONE) - with patch( - "homeassistant.components.smlight.update.get_radio", return_value=MOCK_RADIO - ): - freezer.tick(timedelta(seconds=5)) - async_fire_time_changed(hass) - await hass.async_block_till_done() - state = hass.states.get(entity_id) - assert state.state == STATE_OFF - assert state.attributes[ATTR_INSTALLED_VERSION] == "20240716" - assert state.attributes[ATTR_LATEST_VERSION] == "20240716" + mock_info.radios[1] = MOCK_RADIO + + freezer.tick(timedelta(seconds=5)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.state == STATE_OFF + assert state.attributes[ATTR_INSTALLED_VERSION] == "20240716" + assert state.attributes[ATTR_LATEST_VERSION] == "20240716" async def test_update_legacy_firmware_v2( From 74ff40e2533ae9fc91eeaf89bdfa891ee23c1693 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 26 Mar 2025 11:46:50 +0100 Subject: [PATCH 3070/3148] Deprecate SmartThings machine state sensors (#141363) * Deprecate SmartThings machine state sensors * Fix --- .../components/smartthings/sensor.py | 62 +++++++++++++- .../components/smartthings/strings.json | 4 + tests/components/smartthings/test_sensor.py | 82 ++++++++++++++++++- 3 files changed, 146 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index 87ae1488329..1b4ccf1c576 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -9,6 +9,8 @@ from typing import Any, cast from pysmartthings import Attribute, Capability, SmartThings, Status +from homeassistant.components.automation import automations_with_entity +from homeassistant.components.script import scripts_with_entity from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, @@ -29,11 +31,17 @@ from homeassistant.const import ( UnitOfVolume, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.issue_registry import ( + IssueSeverity, + async_create_issue, + async_delete_issue, +) from homeassistant.util import dt as dt_util from . import FullDevice, SmartThingsConfigEntry -from .const import MAIN +from .const import DOMAIN, MAIN from .entity import SmartThingsEntity THERMOSTAT_CAPABILITIES = { @@ -1089,3 +1097,55 @@ class SmartThingsSensor(SmartThingsEntity, SensorEntity): return [] return [option.lower() for option in options] return super().options + + async def async_added_to_hass(self) -> None: + """Call when entity is added to hass.""" + await super().async_added_to_hass() + if ( + self.capability + not in {Capability.DRYER_OPERATING_STATE, Capability.WASHER_OPERATING_STATE} + or self._attribute is not Attribute.MACHINE_STATE + ): + return + automations = automations_with_entity(self.hass, self.entity_id) + scripts = scripts_with_entity(self.hass, self.entity_id) + if not automations and not scripts: + return + + entity_reg: er.EntityRegistry = er.async_get(self.hass) + items_list = [ + f"- [{item.original_name}](/config/{integration}/edit/{item.unique_id})" + for integration, entities in ( + ("automation", automations), + ("script", scripts), + ) + for entity_id in entities + if (item := entity_reg.async_get(entity_id)) + ] + + async_create_issue( + self.hass, + DOMAIN, + f"deprecated_machine_state_{self.entity_id}", + breaks_in_ha_version="2025.10.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_machine_state", + translation_placeholders={ + "entity": self.entity_id, + "items": "\n".join(items_list), + }, + ) + + async def async_will_remove_from_hass(self) -> None: + """Call when entity will be removed from hass.""" + await super().async_will_remove_from_hass() + if ( + self.capability + not in {Capability.DRYER_OPERATING_STATE, Capability.WASHER_OPERATING_STATE} + or self._attribute is not Attribute.MACHINE_STATE + ): + return + async_delete_issue( + self.hass, DOMAIN, f"deprecated_machine_state_{self.entity_id}" + ) diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index cbea23f6318..0d9fe38dd0a 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -481,6 +481,10 @@ "title": "Deprecated refrigerator door binary sensor detected in some automations or scripts", "description": "The refrigerator door binary sensor `{entity}` is deprecated and is used in the following automations or scripts:\n{items}\n\nSeparate entities for cooler and freezer door are available and should be used going forward. Please use them in the above automations or scripts to fix this issue." }, + "deprecated_machine_state": { + "title": "Deprecated machine state sensor detected in some automations or scripts", + "description": "The machine state sensor `{entity}` is deprecated and is used in the following automations or scripts:\n{items}\n\nA select entity is now available for the machine state and should be used going forward. Please use them in the above automations or scripts to fix this issue." + }, "deprecated_switch_appliance": { "title": "Deprecated switch detected in some automations or scripts", "description": "The switch `{entity}` is deprecated because the actions did not work, so it has been replaced with a binary sensor instead.\n\nThe switch was used in the following automations or scripts:\n{items}\n\nPlease use them in the above automations or scripts to fix this issue." diff --git a/tests/components/smartthings/test_sensor.py b/tests/components/smartthings/test_sensor.py index c83950de9e9..229644e2473 100644 --- a/tests/components/smartthings/test_sensor.py +++ b/tests/components/smartthings/test_sensor.py @@ -6,9 +6,14 @@ from pysmartthings import Attribute, Capability import pytest from syrupy import SnapshotAssertion +from homeassistant.components import automation, script +from homeassistant.components.automation import automations_with_entity +from homeassistant.components.script import scripts_with_entity +from homeassistant.components.smartthings.const import DOMAIN from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import entity_registry as er, issue_registry as ir +from homeassistant.setup import async_setup_component from . import setup_integration, snapshot_smartthings_entities, trigger_update @@ -49,3 +54,78 @@ async def test_state_update( ) assert hass.states.get("sensor.ac_office_granit_temperature").state == "20" + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.parametrize( + ("device_fixture", "entity_id"), + [ + ("da_wm_wm_000001", "sensor.washer_machine_state"), + ("da_wm_wd_000001", "sensor.dryer_machine_state"), + ], +) +async def test_create_issue( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + issue_registry: ir.IssueRegistry, + entity_id: str, +) -> None: + """Test we create an issue when an automation or script is using a deprecated entity.""" + issue_id = f"deprecated_machine_state_{entity_id}" + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "id": "test", + "alias": "test", + "trigger": {"platform": "state", "entity_id": entity_id}, + "action": { + "action": "automation.turn_on", + "target": { + "entity_id": "automation.test", + }, + }, + } + }, + ) + assert await async_setup_component( + hass, + script.DOMAIN, + { + script.DOMAIN: { + "test": { + "sequence": [ + { + "condition": "state", + "entity_id": entity_id, + "state": "on", + }, + ], + } + } + }, + ) + + await setup_integration(hass, mock_config_entry) + + assert automations_with_entity(hass, entity_id)[0] == "automation.test" + assert scripts_with_entity(hass, entity_id)[0] == "script.test" + + assert len(issue_registry.issues) == 1 + issue = issue_registry.async_get_issue(DOMAIN, issue_id) + assert issue is not None + assert issue.translation_key == "deprecated_machine_state" + assert issue.translation_placeholders == { + "entity": entity_id, + "items": "- [test](/config/automation/edit/test)\n- [test](/config/script/edit/test)", + } + + await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Assert the issue is no longer present + assert not issue_registry.async_get_issue(DOMAIN, issue_id) + assert len(issue_registry.issues) == 0 From ed7c864869b31384b0815b8e9cf748a345c4bae6 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 26 Mar 2025 12:10:44 +0100 Subject: [PATCH 3071/3148] Add switch for icemaker in SmartThings (#141313) * Add switch for icemaker in SmartThings * Fix --- .../components/smartthings/icons.json | 3 ++ .../components/smartthings/strings.json | 3 ++ .../components/smartthings/switch.py | 32 +++++++++++-- .../smartthings/snapshots/test_switch.ambr | 47 +++++++++++++++++++ 4 files changed, 81 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/smartthings/icons.json b/homeassistant/components/smartthings/icons.json index 4282b974fb2..107233665bb 100644 --- a/homeassistant/components/smartthings/icons.json +++ b/homeassistant/components/smartthings/icons.json @@ -51,6 +51,9 @@ "state": { "off": "mdi:tumble-dryer-off" } + }, + "ice_maker": { + "default": "mdi:delete-variant" } } } diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index 0d9fe38dd0a..441a53369b5 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -469,6 +469,9 @@ }, "wrinkle_prevent": { "name": "Wrinkle prevent" + }, + "ice_maker": { + "name": "Ice maker" } } }, diff --git a/homeassistant/components/smartthings/switch.py b/homeassistant/components/smartthings/switch.py index 6f3db607f91..f57577d7c12 100644 --- a/homeassistant/components/smartthings/switch.py +++ b/homeassistant/components/smartthings/switch.py @@ -43,6 +43,7 @@ class SmartThingsSwitchEntityDescription(SwitchEntityDescription): """Describe a SmartThings switch entity.""" status_attribute: Attribute + component_translation_key: dict[str, str] | None = None @dataclass(frozen=True, kw_only=True) @@ -72,7 +73,14 @@ CAPABILITY_TO_SWITCHES: dict[Capability | str, SmartThingsSwitchEntityDescriptio key=Capability.SAMSUNG_CE_WASHER_BUBBLE_SOAK, translation_key="bubble_soak", status_attribute=Attribute.STATUS, - ) + ), + Capability.SWITCH: SmartThingsSwitchEntityDescription( + key=Capability.SWITCH, + status_attribute=Attribute.SWITCH, + component_translation_key={ + "icemaker": "ice_maker", + }, + ), } @@ -107,10 +115,19 @@ async def async_setup_entry( device, description, Capability(capability), + component, ) for device in entry_data.devices.values() for capability, description in CAPABILITY_TO_SWITCHES.items() - if capability in device.status[MAIN] + for component in device.status + if capability in device.status[component] + and ( + (description.component_translation_key is None and component == MAIN) + or ( + description.component_translation_key is not None + and component in description.component_translation_key + ) + ) ) async_add_entities(entities) @@ -126,12 +143,19 @@ class SmartThingsSwitch(SmartThingsEntity, SwitchEntity): device: FullDevice, entity_description: SmartThingsSwitchEntityDescription, capability: Capability, + component: str = MAIN, ) -> None: """Initialize the switch.""" - super().__init__(client, device, {capability}) + super().__init__(client, device, {capability}, component=component) self.entity_description = entity_description self.switch_capability = capability - self._attr_unique_id = f"{device.device.device_id}_{MAIN}_{capability}_{entity_description.status_attribute}_{entity_description.status_attribute}" + self._attr_unique_id = f"{device.device.device_id}_{component}_{capability}_{entity_description.status_attribute}_{entity_description.status_attribute}" + if ( + translation_keys := entity_description.component_translation_key + ) is not None and ( + translation_key := translation_keys.get(component) + ) is not None: + self._attr_translation_key = translation_key async def async_turn_off(self, **kwargs: Any) -> None: """Turn the switch off.""" diff --git a/tests/components/smartthings/snapshots/test_switch.ambr b/tests/components/smartthings/snapshots/test_switch.ambr index 812cb5639ab..d84327f8b70 100644 --- a/tests/components/smartthings/snapshots/test_switch.ambr +++ b/tests/components/smartthings/snapshots/test_switch.ambr @@ -93,6 +93,53 @@ 'state': 'off', }) # --- +# name: test_all_entities[da_ref_normal_000001][switch.refrigerator_ice_maker-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.refrigerator_ice_maker', + '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': 'Ice maker', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'ice_maker', + 'unique_id': '7db87911-7dce-1cf2-7119-b953432a2f09_icemaker_switch_switch_switch', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ref_normal_000001][switch.refrigerator_ice_maker-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Refrigerator Ice maker', + }), + 'context': , + 'entity_id': 'switch.refrigerator_ice_maker', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_all_entities[da_rvc_normal_000001][switch.robot_vacuum-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 53990f8fad5201714f641f0f86ce9acb152b1c0f Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Wed, 26 Mar 2025 12:11:09 +0100 Subject: [PATCH 3072/3148] Do not show the firmware changelog for Shelly Wall Display X2 update entities (#141457) There is no firmware changelog for Wall Display X2 --- homeassistant/components/shelly/const.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/shelly/const.py b/homeassistant/components/shelly/const.py index c94c827b7db..43fb6df18d0 100644 --- a/homeassistant/components/shelly/const.py +++ b/homeassistant/components/shelly/const.py @@ -25,6 +25,7 @@ from aioshelly.const import ( MODEL_VALVE, MODEL_VINTAGE_V2, MODEL_WALL_DISPLAY, + MODEL_WALL_DISPLAY_X2, ) from homeassistant.components.number import NumberMode @@ -245,6 +246,7 @@ GEN2_RELEASE_URL = "https://shelly-api-docs.shelly.cloud/gen2/changelog/" GEN2_BETA_RELEASE_URL = f"{GEN2_RELEASE_URL}#unreleased" DEVICES_WITHOUT_FIRMWARE_CHANGELOG = ( MODEL_WALL_DISPLAY, + MODEL_WALL_DISPLAY_X2, MODEL_MOTION, MODEL_MOTION_2, MODEL_VALVE, From 7bcba2b63964f8933e005cffb44d60b6d73b2abb Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 26 Mar 2025 12:11:49 +0100 Subject: [PATCH 3073/3148] Fix online docs URL in `motionblinds` plus gateway naming (#141453) * Fix online docs URL in `motionblinds` plus gateway naming - add missing "api" to the online docs URL to make it work - fix sentence-casing of "API key" - replace "Motion Gateway" with "Motionblinds gateway" as there is no brand "Motion" and the list of compatible bridges cover a lot more brands * Replace comma with period to improve readability --- homeassistant/components/motion_blinds/strings.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/motion_blinds/strings.json b/homeassistant/components/motion_blinds/strings.json index ddbf928462a..12060cd69f0 100644 --- a/homeassistant/components/motion_blinds/strings.json +++ b/homeassistant/components/motion_blinds/strings.json @@ -3,20 +3,20 @@ "flow_title": "{short_mac} ({ip_address})", "step": { "user": { - "description": "Connect to your Motion Gateway, if the IP address is not set, auto-discovery is used", + "description": "Connect to your Motionblinds gateway. If the IP address is not set, auto-discovery is used", "data": { "host": "[%key:common::config_flow::data::ip%]" } }, "connect": { - "description": "You will need the 16 character API Key, see https://www.home-assistant.io/integrations/motion_blinds/#retrieving-the-key for instructions", + "description": "You will need the 16 character API key, see https://www.home-assistant.io/integrations/motion_blinds/#retrieving-the-api-key for instructions", "data": { "api_key": "[%key:common::config_flow::data::api_key%]" } }, "select": { - "title": "Select the Motion Gateway that you wish to connect", - "description": "Run the setup again if you want to connect additional Motion Gateways", + "title": "Select the Motionblinds gateway that you wish to connect", + "description": "Run the setup again if you want to connect additional Motionblinds gateways", "data": { "select_ip": "[%key:common::config_flow::data::ip%]" } @@ -29,7 +29,7 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", "connection_error": "[%key:common::config_flow::error::cannot_connect%]", - "not_motionblinds": "Discovered device is not a Motion gateway" + "not_motionblinds": "Discovered device is not a Motionblinds gateway" } }, "options": { From d7de8c5f68b6397c44ee7b859cc38e111e642a18 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Wed, 26 Mar 2025 12:21:58 +0100 Subject: [PATCH 3074/3148] Add full test coverage for Comelit coordinator (#141321) * Add full test coverage for Comelit coordinator * add common const * apply review comment --- homeassistant/components/comelit/const.py | 2 + .../components/comelit/coordinator.py | 4 +- tests/components/comelit/test_coordinator.py | 49 +++++++++++++++++++ 3 files changed, 53 insertions(+), 2 deletions(-) create mode 100644 tests/components/comelit/test_coordinator.py diff --git a/homeassistant/components/comelit/const.py b/homeassistant/components/comelit/const.py index 84d8fbd6315..f52f33fd6da 100644 --- a/homeassistant/components/comelit/const.py +++ b/homeassistant/components/comelit/const.py @@ -9,3 +9,5 @@ _LOGGER = logging.getLogger(__package__) DOMAIN = "comelit" DEFAULT_PORT = 80 DEVICE_TYPE_LIST = [BRIDGE, VEDO] + +SCAN_INTERVAL = 5 diff --git a/homeassistant/components/comelit/coordinator.py b/homeassistant/components/comelit/coordinator.py index b3be3a47825..df4965d9945 100644 --- a/homeassistant/components/comelit/coordinator.py +++ b/homeassistant/components/comelit/coordinator.py @@ -22,7 +22,7 @@ from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers import device_registry as dr from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import _LOGGER, DOMAIN +from .const import _LOGGER, DOMAIN, SCAN_INTERVAL type ComelitConfigEntry = ConfigEntry[ComelitBaseCoordinator] @@ -53,7 +53,7 @@ class ComelitBaseCoordinator(DataUpdateCoordinator[T]): logger=_LOGGER, config_entry=entry, name=f"{DOMAIN}-{host}-coordinator", - update_interval=timedelta(seconds=5), + update_interval=timedelta(seconds=SCAN_INTERVAL), ) device_registry = dr.async_get(self.hass) device_registry.async_get_or_create( diff --git a/tests/components/comelit/test_coordinator.py b/tests/components/comelit/test_coordinator.py new file mode 100644 index 00000000000..a8ef82a7e89 --- /dev/null +++ b/tests/components/comelit/test_coordinator.py @@ -0,0 +1,49 @@ +"""Tests for Comelit SimpleHome coordinator.""" + +from unittest.mock import AsyncMock + +from aiocomelit.exceptions import CannotAuthenticate, CannotConnect, CannotRetrieveData +from freezegun.api import FrozenDateTimeFactory +import pytest + +from homeassistant.components.comelit.const import SCAN_INTERVAL +from homeassistant.const import STATE_OFF, STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant + +from . import setup_integration + +from tests.common import MockConfigEntry, async_fire_time_changed + + +@pytest.mark.parametrize( + "side_effect", + [ + CannotConnect, + CannotRetrieveData, + CannotAuthenticate, + ], +) +async def test_coordinator_data_update_fails( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_serial_bridge: AsyncMock, + mock_serial_bridge_config_entry: MockConfigEntry, + side_effect: Exception, +) -> None: + """Test coordinator data update exceptions.""" + + entity_id = "light.light0" + + await setup_integration(hass, mock_serial_bridge_config_entry) + + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_OFF + + mock_serial_bridge.login.side_effect = side_effect + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_UNAVAILABLE From 4a6d2c91da9a310aa70e0af2a8c0d3f7edca63f8 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Wed, 26 Mar 2025 21:28:16 +1000 Subject: [PATCH 3075/3148] Bump tesla-fleet-api to v1.0.16 (#140869) * Add streaming climate * fixes * Add missing changes * Fix restore * Update homeassistant/components/teslemetry/climate.py Co-authored-by: Joost Lekkerkerker * Use dict * Add fan mode translations * Infer side * WIP * fix deps * Migration in progress * Working * tesla-fleet-api==1.0.15 * tesla-fleet-api==1.0.16 --------- Co-authored-by: Joost Lekkerkerker --- .../components/tesla_fleet/__init__.py | 18 ++++------ .../components/tesla_fleet/coordinator.py | 10 +++--- .../components/tesla_fleet/entity.py | 5 +-- .../components/tesla_fleet/manifest.json | 2 +- .../components/tesla_fleet/models.py | 6 ++-- .../components/tesla_fleet/number.py | 6 ++-- .../components/teslemetry/__init__.py | 6 ++-- .../components/teslemetry/climate.py | 6 ++-- .../components/teslemetry/config_flow.py | 2 +- .../components/teslemetry/coordinator.py | 10 +++--- homeassistant/components/teslemetry/entity.py | 12 +++---- .../components/teslemetry/manifest.json | 2 +- .../components/teslemetry/media_player.py | 4 +-- homeassistant/components/teslemetry/models.py | 6 ++-- homeassistant/components/teslemetry/number.py | 6 ++-- homeassistant/components/teslemetry/select.py | 4 +-- homeassistant/components/teslemetry/update.py | 4 +-- homeassistant/components/tessie/__init__.py | 4 +-- .../components/tessie/coordinator.py | 6 ++-- homeassistant/components/tessie/manifest.json | 2 +- homeassistant/components/tessie/models.py | 4 +-- homeassistant/components/tessie/number.py | 4 +-- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/tesla_fleet/conftest.py | 16 ++++----- tests/components/tesla_fleet/test_button.py | 4 +-- tests/components/tesla_fleet/test_climate.py | 6 ++-- tests/components/tesla_fleet/test_cover.py | 12 +++---- tests/components/tesla_fleet/test_lock.py | 6 ++-- .../tesla_fleet/test_media_player.py | 10 +++--- tests/components/tesla_fleet/test_number.py | 8 ++--- tests/components/tesla_fleet/test_select.py | 12 +++---- tests/components/tesla_fleet/test_switch.py | 34 +++++++++---------- tests/components/teslemetry/conftest.py | 24 ++++++------- tests/components/teslemetry/test_button.py | 2 +- tests/components/teslemetry/test_climate.py | 6 ++-- tests/components/teslemetry/test_cover.py | 12 +++---- tests/components/teslemetry/test_lock.py | 6 ++-- .../teslemetry/test_media_player.py | 10 +++--- tests/components/teslemetry/test_number.py | 8 ++--- tests/components/teslemetry/test_select.py | 8 ++--- tests/components/teslemetry/test_sensor.py | 4 +-- tests/components/teslemetry/test_services.py | 16 ++++----- tests/components/teslemetry/test_switch.py | 34 +++++++++---------- tests/components/teslemetry/test_update.py | 2 +- tests/components/tessie/conftest.py | 4 +-- tests/components/tessie/test_number.py | 4 +-- tests/components/tessie/test_select.py | 6 ++-- tests/components/tessie/test_switch.py | 12 +++---- 49 files changed, 196 insertions(+), 203 deletions(-) diff --git a/homeassistant/components/tesla_fleet/__init__.py b/homeassistant/components/tesla_fleet/__init__.py index 27bfb9134ab..2642bd2f7d5 100644 --- a/homeassistant/components/tesla_fleet/__init__.py +++ b/homeassistant/components/tesla_fleet/__init__.py @@ -5,12 +5,7 @@ from typing import Final from aiohttp.client_exceptions import ClientResponseError import jwt -from tesla_fleet_api import ( - EnergySpecific, - TeslaFleetApi, - VehicleSigned, - VehicleSpecific, -) +from tesla_fleet_api import TeslaFleetApi from tesla_fleet_api.const import Scope from tesla_fleet_api.exceptions import ( InvalidRegion, @@ -128,7 +123,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslaFleetConfigEntry) - vehicles: list[TeslaFleetVehicleData] = [] energysites: list[TeslaFleetEnergyData] = [] for product in products: - if "vin" in product and hasattr(tesla, "vehicle"): + if "vin" in product and Scope.VEHICLE_DEVICE_DATA in scopes: # Remove the protobuff 'cached_data' that we do not use to save memory product.pop("cached_data", None) vin = product["vin"] @@ -136,9 +131,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslaFleetConfigEntry) - if signing: if not tesla.private_key: await tesla.get_private_key(hass.config.path("tesla_fleet.key")) - api = VehicleSigned(tesla.vehicle, vin) + api = tesla.vehicles.createSigned(vin) else: - api = VehicleSpecific(tesla.vehicle, vin) + api = tesla.vehicles.createFleet(vin) coordinator = TeslaFleetVehicleDataCoordinator(hass, entry, api, product) await coordinator.async_config_entry_first_refresh() @@ -160,7 +155,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslaFleetConfigEntry) - signing=signing, ) ) - elif "energy_site_id" in product and hasattr(tesla, "energy"): + elif "energy_site_id" in product and Scope.ENERGY_DEVICE_DATA in scopes: site_id = product["energy_site_id"] if not ( product["components"]["battery"] @@ -173,7 +168,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslaFleetConfigEntry) - ) continue - api = EnergySpecific(tesla.energy, site_id) + api = tesla.energySites.create(site_id) live_coordinator = TeslaFleetEnergySiteLiveCoordinator(hass, entry, api) history_coordinator = TeslaFleetEnergySiteHistoryCoordinator( @@ -227,7 +222,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslaFleetConfigEntry) - # Setup Platforms entry.runtime_data = TeslaFleetData(vehicles, energysites, scopes) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - return True diff --git a/homeassistant/components/tesla_fleet/coordinator.py b/homeassistant/components/tesla_fleet/coordinator.py index 6f881d0feba..50a69258a31 100644 --- a/homeassistant/components/tesla_fleet/coordinator.py +++ b/homeassistant/components/tesla_fleet/coordinator.py @@ -7,7 +7,6 @@ from random import randint from time import time from typing import TYPE_CHECKING, Any -from tesla_fleet_api import EnergySpecific, VehicleSpecific from tesla_fleet_api.const import TeslaEnergyPeriod, VehicleDataEndpoint from tesla_fleet_api.exceptions import ( InvalidToken, @@ -17,6 +16,7 @@ from tesla_fleet_api.exceptions import ( TeslaFleetError, VehicleOffline, ) +from tesla_fleet_api.tesla import EnergySite, VehicleFleet from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed @@ -70,7 +70,7 @@ class TeslaFleetVehicleDataCoordinator(DataUpdateCoordinator[dict[str, Any]]): self, hass: HomeAssistant, config_entry: TeslaFleetConfigEntry, - api: VehicleSpecific, + api: VehicleFleet, product: dict, ) -> None: """Initialize TeslaFleet Vehicle Update Coordinator.""" @@ -149,7 +149,7 @@ class TeslaFleetEnergySiteLiveCoordinator(DataUpdateCoordinator[dict[str, Any]]) self, hass: HomeAssistant, config_entry: TeslaFleetConfigEntry, - api: EnergySpecific, + api: EnergySite, ) -> None: """Initialize TeslaFleet Energy Site Live coordinator.""" super().__init__( @@ -202,7 +202,7 @@ class TeslaFleetEnergySiteHistoryCoordinator(DataUpdateCoordinator[dict[str, Any self, hass: HomeAssistant, config_entry: TeslaFleetConfigEntry, - api: EnergySpecific, + api: EnergySite, ) -> None: """Initialize Tesla Fleet Energy Site History coordinator.""" super().__init__( @@ -266,7 +266,7 @@ class TeslaFleetEnergySiteInfoCoordinator(DataUpdateCoordinator[dict[str, Any]]) self, hass: HomeAssistant, config_entry: TeslaFleetConfigEntry, - api: EnergySpecific, + api: EnergySite, product: dict, ) -> None: """Initialize TeslaFleet Energy Info coordinator.""" diff --git a/homeassistant/components/tesla_fleet/entity.py b/homeassistant/components/tesla_fleet/entity.py index 0260acf368e..583e92595d0 100644 --- a/homeassistant/components/tesla_fleet/entity.py +++ b/homeassistant/components/tesla_fleet/entity.py @@ -3,8 +3,9 @@ from abc import abstractmethod from typing import Any -from tesla_fleet_api import EnergySpecific, VehicleSpecific from tesla_fleet_api.const import Scope +from tesla_fleet_api.tesla.energysite import EnergySite +from tesla_fleet_api.tesla.vehicle.fleet import VehicleFleet from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers.device_registry import DeviceInfo @@ -41,7 +42,7 @@ class TeslaFleetEntity( | TeslaFleetEnergySiteLiveCoordinator | TeslaFleetEnergySiteHistoryCoordinator | TeslaFleetEnergySiteInfoCoordinator, - api: VehicleSpecific | EnergySpecific, + api: VehicleFleet | EnergySite, key: str, ) -> None: """Initialize common aspects of a TeslaFleet entity.""" diff --git a/homeassistant/components/tesla_fleet/manifest.json b/homeassistant/components/tesla_fleet/manifest.json index 010197ccbd9..56dc49ad111 100644 --- a/homeassistant/components/tesla_fleet/manifest.json +++ b/homeassistant/components/tesla_fleet/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/tesla_fleet", "iot_class": "cloud_polling", "loggers": ["tesla-fleet-api"], - "requirements": ["tesla-fleet-api==0.9.13"] + "requirements": ["tesla-fleet-api==1.0.16"] } diff --git a/homeassistant/components/tesla_fleet/models.py b/homeassistant/components/tesla_fleet/models.py index 469ebdca914..17a2bf50ed1 100644 --- a/homeassistant/components/tesla_fleet/models.py +++ b/homeassistant/components/tesla_fleet/models.py @@ -5,8 +5,8 @@ from __future__ import annotations import asyncio from dataclasses import dataclass -from tesla_fleet_api import EnergySpecific, VehicleSpecific from tesla_fleet_api.const import Scope +from tesla_fleet_api.tesla import EnergySite, VehicleFleet from homeassistant.helpers.device_registry import DeviceInfo @@ -31,7 +31,7 @@ class TeslaFleetData: class TeslaFleetVehicleData: """Data for a vehicle in the TeslaFleet integration.""" - api: VehicleSpecific + api: VehicleFleet coordinator: TeslaFleetVehicleDataCoordinator vin: str device: DeviceInfo @@ -43,7 +43,7 @@ class TeslaFleetVehicleData: class TeslaFleetEnergyData: """Data for a vehicle in the TeslaFleet integration.""" - api: EnergySpecific + api: EnergySite live_coordinator: TeslaFleetEnergySiteLiveCoordinator history_coordinator: TeslaFleetEnergySiteHistoryCoordinator info_coordinator: TeslaFleetEnergySiteInfoCoordinator diff --git a/homeassistant/components/tesla_fleet/number.py b/homeassistant/components/tesla_fleet/number.py index a1123ab9553..b4f7e42cafd 100644 --- a/homeassistant/components/tesla_fleet/number.py +++ b/homeassistant/components/tesla_fleet/number.py @@ -7,8 +7,8 @@ from dataclasses import dataclass from itertools import chain from typing import Any -from tesla_fleet_api import EnergySpecific, VehicleSpecific from tesla_fleet_api.const import Scope +from tesla_fleet_api.tesla import EnergySite, VehicleFleet from homeassistant.components.number import ( NumberDeviceClass, @@ -33,7 +33,7 @@ PARALLEL_UPDATES = 0 class TeslaFleetNumberVehicleEntityDescription(NumberEntityDescription): """Describes TeslaFleet Number entity.""" - func: Callable[[VehicleSpecific, float], Awaitable[Any]] + func: Callable[[VehicleFleet, float], Awaitable[Any]] native_min_value: float native_max_value: float min_key: str | None = None @@ -74,7 +74,7 @@ VEHICLE_DESCRIPTIONS: tuple[TeslaFleetNumberVehicleEntityDescription, ...] = ( class TeslaFleetNumberBatteryEntityDescription(NumberEntityDescription): """Describes TeslaFleet Number entity.""" - func: Callable[[EnergySpecific, float], Awaitable[Any]] + func: Callable[[EnergySite, float], Awaitable[Any]] requires: str | None = None diff --git a/homeassistant/components/teslemetry/__init__.py b/homeassistant/components/teslemetry/__init__.py index eef974cc5a7..b820d2d1b43 100644 --- a/homeassistant/components/teslemetry/__init__.py +++ b/homeassistant/components/teslemetry/__init__.py @@ -4,7 +4,6 @@ import asyncio from collections.abc import Callable from typing import Final -from tesla_fleet_api import EnergySpecific, Teslemetry, VehicleSpecific from tesla_fleet_api.const import Scope from tesla_fleet_api.exceptions import ( Forbidden, @@ -12,6 +11,7 @@ from tesla_fleet_api.exceptions import ( SubscriptionRequired, TeslaFleetError, ) +from tesla_fleet_api.teslemetry import Teslemetry from teslemetry_stream import TeslemetryStream from homeassistant.config_entries import ConfigEntry @@ -111,7 +111,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) - # Remove the protobuff 'cached_data' that we do not use to save memory product.pop("cached_data", None) vin = product["vin"] - api = VehicleSpecific(teslemetry.vehicle, vin) + api = teslemetry.vehicles.create(vin) coordinator = TeslemetryVehicleDataCoordinator(hass, entry, api, product) device = DeviceInfo( identifiers={(DOMAIN, vin)}, @@ -156,7 +156,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) - ) continue - api = EnergySpecific(teslemetry.energy, site_id) + api = teslemetry.energySites.create(site_id) device = DeviceInfo( identifiers={(DOMAIN, str(site_id))}, manufacturer="Tesla", diff --git a/homeassistant/components/teslemetry/climate.py b/homeassistant/components/teslemetry/climate.py index 3aaf5f0516c..c1c8fcd2f73 100644 --- a/homeassistant/components/teslemetry/climate.py +++ b/homeassistant/components/teslemetry/climate.py @@ -6,7 +6,7 @@ from itertools import chain from typing import Any, cast from tesla_fleet_api.const import CabinOverheatProtectionTemp, Scope -from tesla_fleet_api.vehicle import VehicleSpecific +from tesla_fleet_api.teslemetry import Vehicle from homeassistant.components.climate import ( ATTR_HVAC_MODE, @@ -90,7 +90,7 @@ async def async_setup_entry( class TeslemetryClimateEntity(TeslemetryRootEntity, ClimateEntity): """Vehicle Climate Control.""" - api: VehicleSpecific + api: Vehicle _attr_precision = PRECISION_HALVES _attr_temperature_unit = UnitOfTemperature.CELSIUS @@ -369,7 +369,7 @@ COP_LEVELS = { class TeslemetryCabinOverheatProtectionEntity(TeslemetryRootEntity, ClimateEntity): """Vehicle Cabin Overheat Protection.""" - api: VehicleSpecific + api: Vehicle _attr_precision = PRECISION_WHOLE _attr_target_temperature_step = 5 diff --git a/homeassistant/components/teslemetry/config_flow.py b/homeassistant/components/teslemetry/config_flow.py index d8cf2bd7945..a25a98d6c68 100644 --- a/homeassistant/components/teslemetry/config_flow.py +++ b/homeassistant/components/teslemetry/config_flow.py @@ -6,12 +6,12 @@ from collections.abc import Mapping from typing import Any from aiohttp import ClientConnectionError -from tesla_fleet_api import Teslemetry from tesla_fleet_api.exceptions import ( InvalidToken, SubscriptionRequired, TeslaFleetError, ) +from tesla_fleet_api.teslemetry import Teslemetry import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult diff --git a/homeassistant/components/teslemetry/coordinator.py b/homeassistant/components/teslemetry/coordinator.py index f902fb4cc1b..07549008a6c 100644 --- a/homeassistant/components/teslemetry/coordinator.py +++ b/homeassistant/components/teslemetry/coordinator.py @@ -5,13 +5,13 @@ from __future__ import annotations from datetime import datetime, timedelta from typing import TYPE_CHECKING, Any -from tesla_fleet_api import EnergySpecific, VehicleSpecific from tesla_fleet_api.const import TeslaEnergyPeriod, VehicleDataEndpoint from tesla_fleet_api.exceptions import ( InvalidToken, SubscriptionRequired, TeslaFleetError, ) +from tesla_fleet_api.teslemetry import EnergySite, Vehicle from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed @@ -49,7 +49,7 @@ class TeslemetryVehicleDataCoordinator(DataUpdateCoordinator[dict[str, Any]]): self, hass: HomeAssistant, config_entry: TeslemetryConfigEntry, - api: VehicleSpecific, + api: Vehicle, product: dict, ) -> None: """Initialize Teslemetry Vehicle Update Coordinator.""" @@ -87,7 +87,7 @@ class TeslemetryEnergySiteLiveCoordinator(DataUpdateCoordinator[dict[str, Any]]) self, hass: HomeAssistant, config_entry: TeslemetryConfigEntry, - api: EnergySpecific, + api: EnergySite, data: dict, ) -> None: """Initialize Teslemetry Energy Site Live coordinator.""" @@ -133,7 +133,7 @@ class TeslemetryEnergySiteInfoCoordinator(DataUpdateCoordinator[dict[str, Any]]) self, hass: HomeAssistant, config_entry: TeslemetryConfigEntry, - api: EnergySpecific, + api: EnergySite, product: dict, ) -> None: """Initialize Teslemetry Energy Info coordinator.""" @@ -169,7 +169,7 @@ class TeslemetryEnergyHistoryCoordinator(DataUpdateCoordinator[dict[str, Any]]): self, hass: HomeAssistant, config_entry: TeslemetryConfigEntry, - api: EnergySpecific, + api: EnergySite, ) -> None: """Initialize Teslemetry Energy Info coordinator.""" super().__init__( diff --git a/homeassistant/components/teslemetry/entity.py b/homeassistant/components/teslemetry/entity.py index 82d3db123c3..3d145d24b0c 100644 --- a/homeassistant/components/teslemetry/entity.py +++ b/homeassistant/components/teslemetry/entity.py @@ -4,8 +4,8 @@ from abc import abstractmethod from typing import Any from propcache.api import cached_property -from tesla_fleet_api import EnergySpecific, VehicleSpecific from tesla_fleet_api.const import Scope +from tesla_fleet_api.teslemetry import EnergySite, Vehicle from teslemetry_stream import Signal from homeassistant.exceptions import ServiceValidationError @@ -29,7 +29,7 @@ class TeslemetryRootEntity(Entity): _attr_has_entity_name = True scoped: bool - api: VehicleSpecific | EnergySpecific + api: Vehicle | EnergySite def raise_for_scope(self, scope: Scope): """Raise an error if a scope is not available.""" @@ -105,7 +105,7 @@ class TeslemetryVehicleEntity(TeslemetryEntity): """Parent class for Teslemetry Vehicle entities.""" _last_update: int = 0 - api: VehicleSpecific + api: Vehicle vehicle: TeslemetryVehicleData def __init__( @@ -134,7 +134,7 @@ class TeslemetryVehicleEntity(TeslemetryEntity): class TeslemetryEnergyLiveEntity(TeslemetryEntity): """Parent class for Teslemetry Energy Site Live entities.""" - api: EnergySpecific + api: EnergySite def __init__( self, @@ -155,7 +155,7 @@ class TeslemetryEnergyLiveEntity(TeslemetryEntity): class TeslemetryEnergyInfoEntity(TeslemetryEntity): """Parent class for Teslemetry Energy Site Info Entities.""" - api: EnergySpecific + api: EnergySite def __init__( self, @@ -194,7 +194,7 @@ class TeslemetryWallConnectorEntity(TeslemetryEntity): """Parent class for Teslemetry Wall Connector Entities.""" _attr_has_entity_name = True - api: EnergySpecific + api: EnergySite def __init__( self, diff --git a/homeassistant/components/teslemetry/manifest.json b/homeassistant/components/teslemetry/manifest.json index 3d37ced8cff..cae5a8f3c01 100644 --- a/homeassistant/components/teslemetry/manifest.json +++ b/homeassistant/components/teslemetry/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/teslemetry", "iot_class": "cloud_polling", "loggers": ["tesla-fleet-api"], - "requirements": ["tesla-fleet-api==0.9.13", "teslemetry-stream==0.6.12"] + "requirements": ["tesla-fleet-api==1.0.16", "teslemetry-stream==0.6.12"] } diff --git a/homeassistant/components/teslemetry/media_player.py b/homeassistant/components/teslemetry/media_player.py index 409b409e325..50f15618e66 100644 --- a/homeassistant/components/teslemetry/media_player.py +++ b/homeassistant/components/teslemetry/media_player.py @@ -2,8 +2,8 @@ from __future__ import annotations -from tesla_fleet_api import VehicleSpecific from tesla_fleet_api.const import Scope +from tesla_fleet_api.teslemetry import Vehicle from homeassistant.components.media_player import ( MediaPlayerDeviceClass, @@ -62,7 +62,7 @@ async def async_setup_entry( class TeslemetryMediaEntity(TeslemetryRootEntity, MediaPlayerEntity): """Base vehicle media player class.""" - api: VehicleSpecific + api: Vehicle _attr_device_class = MediaPlayerDeviceClass.SPEAKER _attr_volume_step = VOLUME_STEP diff --git a/homeassistant/components/teslemetry/models.py b/homeassistant/components/teslemetry/models.py index 5b78386c68a..fd6cf12b5b9 100644 --- a/homeassistant/components/teslemetry/models.py +++ b/homeassistant/components/teslemetry/models.py @@ -6,8 +6,8 @@ import asyncio from collections.abc import Callable from dataclasses import dataclass -from tesla_fleet_api import EnergySpecific, VehicleSpecific from tesla_fleet_api.const import Scope +from tesla_fleet_api.teslemetry import EnergySite, Vehicle from teslemetry_stream import TeslemetryStream, TeslemetryStreamVehicle from homeassistant.config_entries import ConfigEntry @@ -34,7 +34,7 @@ class TeslemetryData: class TeslemetryVehicleData: """Data for a vehicle in the Teslemetry integration.""" - api: VehicleSpecific + api: Vehicle config_entry: ConfigEntry coordinator: TeslemetryVehicleDataCoordinator stream: TeslemetryStream @@ -50,7 +50,7 @@ class TeslemetryVehicleData: class TeslemetryEnergyData: """Data for a vehicle in the Teslemetry integration.""" - api: EnergySpecific + api: EnergySite live_coordinator: TeslemetryEnergySiteLiveCoordinator | None info_coordinator: TeslemetryEnergySiteInfoCoordinator history_coordinator: TeslemetryEnergyHistoryCoordinator | None diff --git a/homeassistant/components/teslemetry/number.py b/homeassistant/components/teslemetry/number.py index 10c15a68b09..ff25dec59b8 100644 --- a/homeassistant/components/teslemetry/number.py +++ b/homeassistant/components/teslemetry/number.py @@ -7,8 +7,8 @@ from dataclasses import dataclass from itertools import chain from typing import Any -from tesla_fleet_api import EnergySpecific, VehicleSpecific from tesla_fleet_api.const import Scope +from tesla_fleet_api.teslemetry import EnergySite, Vehicle from teslemetry_stream import TeslemetryStreamVehicle from homeassistant.components.number import ( @@ -46,7 +46,7 @@ PARALLEL_UPDATES = 0 class TeslemetryNumberVehicleEntityDescription(NumberEntityDescription): """Describes Teslemetry Number entity.""" - func: Callable[[VehicleSpecific, int], Awaitable[Any]] + func: Callable[[Vehicle, int], Awaitable[Any]] min_key: str | None = None max_key: str native_min_value: float @@ -99,7 +99,7 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryNumberVehicleEntityDescription, ...] = ( class TeslemetryNumberBatteryEntityDescription(NumberEntityDescription): """Describes Teslemetry Number entity.""" - func: Callable[[EnergySpecific, float], Awaitable[Any]] + func: Callable[[EnergySite, float], Awaitable[Any]] requires: str | None = None scopes: list[Scope] diff --git a/homeassistant/components/teslemetry/select.py b/homeassistant/components/teslemetry/select.py index 0d268e302de..9e13d15edc4 100644 --- a/homeassistant/components/teslemetry/select.py +++ b/homeassistant/components/teslemetry/select.py @@ -7,8 +7,8 @@ from dataclasses import dataclass from itertools import chain from typing import Any -from tesla_fleet_api import VehicleSpecific from tesla_fleet_api.const import EnergyExportMode, EnergyOperationMode, Scope, Seat +from tesla_fleet_api.teslemetry import Vehicle from teslemetry_stream import TeslemetryStreamVehicle from homeassistant.components.select import SelectEntity, SelectEntityDescription @@ -40,7 +40,7 @@ LEVEL = {OFF: 0, LOW: 1, MEDIUM: 2, HIGH: 3} class TeslemetrySelectEntityDescription(SelectEntityDescription): """Seat Heater entity description.""" - select_fn: Callable[[VehicleSpecific, int], Awaitable[Any]] + select_fn: Callable[[Vehicle, int], Awaitable[Any]] supported_fn: Callable[[dict], bool] = lambda _: True streaming_listener: ( Callable[ diff --git a/homeassistant/components/teslemetry/update.py b/homeassistant/components/teslemetry/update.py index 0b0255508e0..b8d40877de4 100644 --- a/homeassistant/components/teslemetry/update.py +++ b/homeassistant/components/teslemetry/update.py @@ -5,7 +5,7 @@ from __future__ import annotations from typing import Any from tesla_fleet_api.const import Scope -from tesla_fleet_api.vehiclespecific import VehicleSpecific +from tesla_fleet_api.teslemetry import Vehicle from homeassistant.components.update import UpdateEntity, UpdateEntityFeature from homeassistant.core import HomeAssistant @@ -48,7 +48,7 @@ async def async_setup_entry( class TeslemetryUpdateEntity(TeslemetryRootEntity, UpdateEntity): """Teslemetry Updates entity.""" - api: VehicleSpecific + api: Vehicle _attr_supported_features = UpdateEntityFeature.PROGRESS async def async_install( diff --git a/homeassistant/components/tessie/__init__.py b/homeassistant/components/tessie/__init__.py index f73ecc7a729..e247931e3ba 100644 --- a/homeassistant/components/tessie/__init__.py +++ b/homeassistant/components/tessie/__init__.py @@ -5,9 +5,9 @@ from http import HTTPStatus import logging from aiohttp import ClientError, ClientResponseError -from tesla_fleet_api import EnergySpecific, Tessie from tesla_fleet_api.const import Scope from tesla_fleet_api.exceptions import TeslaFleetError +from tesla_fleet_api.tessie import Tessie from tessie_api import get_state_of_all_vehicles from homeassistant.config_entries import ConfigEntry @@ -123,7 +123,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: TessieConfigEntry) -> bo ) continue - api = EnergySpecific(tessie.energy, site_id) + api = tessie.energySites.create(site_id) energysites.append( TessieEnergyData( api=api, diff --git a/homeassistant/components/tessie/coordinator.py b/homeassistant/components/tessie/coordinator.py index b06fe6123a5..2382595b058 100644 --- a/homeassistant/components/tessie/coordinator.py +++ b/homeassistant/components/tessie/coordinator.py @@ -8,8 +8,8 @@ import logging from typing import TYPE_CHECKING, Any from aiohttp import ClientResponseError -from tesla_fleet_api import EnergySpecific from tesla_fleet_api.exceptions import InvalidToken, MissingToken, TeslaFleetError +from tesla_fleet_api.tessie import EnergySite from tessie_api import get_state, get_status from homeassistant.core import HomeAssistant @@ -102,7 +102,7 @@ class TessieEnergySiteLiveCoordinator(DataUpdateCoordinator[dict[str, Any]]): config_entry: TessieConfigEntry def __init__( - self, hass: HomeAssistant, config_entry: TessieConfigEntry, api: EnergySpecific + self, hass: HomeAssistant, config_entry: TessieConfigEntry, api: EnergySite ) -> None: """Initialize Tessie Energy Site Live coordinator.""" super().__init__( @@ -138,7 +138,7 @@ class TessieEnergySiteInfoCoordinator(DataUpdateCoordinator[dict[str, Any]]): config_entry: TessieConfigEntry def __init__( - self, hass: HomeAssistant, config_entry: TessieConfigEntry, api: EnergySpecific + self, hass: HomeAssistant, config_entry: TessieConfigEntry, api: EnergySite ) -> None: """Initialize Tessie Energy Info coordinator.""" super().__init__( diff --git a/homeassistant/components/tessie/manifest.json b/homeassistant/components/tessie/manifest.json index 4ddd63552f0..3f96bb226ab 100644 --- a/homeassistant/components/tessie/manifest.json +++ b/homeassistant/components/tessie/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/tessie", "iot_class": "cloud_polling", "loggers": ["tessie", "tesla-fleet-api"], - "requirements": ["tessie-api==0.1.1", "tesla-fleet-api==0.9.13"] + "requirements": ["tessie-api==0.1.1", "tesla-fleet-api==1.0.16"] } diff --git a/homeassistant/components/tessie/models.py b/homeassistant/components/tessie/models.py index ca670b9650b..03652782cfe 100644 --- a/homeassistant/components/tessie/models.py +++ b/homeassistant/components/tessie/models.py @@ -4,7 +4,7 @@ from __future__ import annotations from dataclasses import dataclass -from tesla_fleet_api import EnergySpecific +from tesla_fleet_api.tessie import EnergySite from homeassistant.helpers.device_registry import DeviceInfo @@ -27,7 +27,7 @@ class TessieData: class TessieEnergyData: """Data for a Energy Site in the Tessie integration.""" - api: EnergySpecific + api: EnergySite live_coordinator: TessieEnergySiteLiveCoordinator info_coordinator: TessieEnergySiteInfoCoordinator id: int diff --git a/homeassistant/components/tessie/number.py b/homeassistant/components/tessie/number.py index 1e857345278..77d8037fb14 100644 --- a/homeassistant/components/tessie/number.py +++ b/homeassistant/components/tessie/number.py @@ -7,7 +7,7 @@ from dataclasses import dataclass from itertools import chain from typing import Any -from tesla_fleet_api import EnergySpecific +from tesla_fleet_api.tessie import EnergySite from tessie_api import set_charge_limit, set_charging_amps, set_speed_limit from homeassistant.components.number import ( @@ -90,7 +90,7 @@ VEHICLE_DESCRIPTIONS: tuple[TessieNumberEntityDescription, ...] = ( class TessieNumberBatteryEntityDescription(NumberEntityDescription): """Describes Tessie Number entity.""" - func: Callable[[EnergySpecific, float], Awaitable[Any]] + func: Callable[[EnergySite, float], Awaitable[Any]] requires: str diff --git a/requirements_all.txt b/requirements_all.txt index 291ddcce107..0a312ade915 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2878,7 +2878,7 @@ temperusb==1.6.1 # homeassistant.components.tesla_fleet # homeassistant.components.teslemetry # homeassistant.components.tessie -tesla-fleet-api==0.9.13 +tesla-fleet-api==1.0.16 # homeassistant.components.powerwall tesla-powerwall==0.5.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 04b1f0baea1..9d239a50938 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2316,7 +2316,7 @@ temperusb==1.6.1 # homeassistant.components.tesla_fleet # homeassistant.components.teslemetry # homeassistant.components.tessie -tesla-fleet-api==0.9.13 +tesla-fleet-api==1.0.16 # homeassistant.components.powerwall tesla-powerwall==0.5.2 diff --git a/tests/components/tesla_fleet/conftest.py b/tests/components/tesla_fleet/conftest.py index 06d2b54c936..10b01caca96 100644 --- a/tests/components/tesla_fleet/conftest.py +++ b/tests/components/tesla_fleet/conftest.py @@ -1,4 +1,4 @@ -"""Fixtures for Tessie.""" +"""Fixtures for Tesla Fleet.""" from __future__ import annotations @@ -113,7 +113,7 @@ def mock_products() -> Generator[AsyncMock]: def mock_vehicle_state() -> Generator[AsyncMock]: """Mock Tesla Fleet API Vehicle Specific vehicle method.""" with patch( - "homeassistant.components.tesla_fleet.VehicleSpecific.vehicle", + "tesla_fleet_api.tesla.VehicleFleet.vehicle", return_value=VEHICLE_ONLINE, ) as mock_vehicle: yield mock_vehicle @@ -123,7 +123,7 @@ def mock_vehicle_state() -> Generator[AsyncMock]: def mock_vehicle_data() -> Generator[AsyncMock]: """Mock Tesla Fleet API Vehicle Specific vehicle_data method.""" with patch( - "homeassistant.components.tesla_fleet.VehicleSpecific.vehicle_data", + "tesla_fleet_api.tesla.VehicleFleet.vehicle_data", return_value=VEHICLE_DATA, ) as mock_vehicle_data: yield mock_vehicle_data @@ -133,7 +133,7 @@ def mock_vehicle_data() -> Generator[AsyncMock]: def mock_wake_up() -> Generator[AsyncMock]: """Mock Tesla Fleet API Vehicle Specific wake_up method.""" with patch( - "homeassistant.components.tesla_fleet.VehicleSpecific.wake_up", + "tesla_fleet_api.tesla.VehicleFleet.wake_up", return_value=VEHICLE_ONLINE, ) as mock_wake_up: yield mock_wake_up @@ -143,7 +143,7 @@ def mock_wake_up() -> Generator[AsyncMock]: def mock_live_status() -> Generator[AsyncMock]: """Mock Tesla Fleet API Energy Specific live_status method.""" with patch( - "homeassistant.components.tesla_fleet.EnergySpecific.live_status", + "tesla_fleet_api.tesla.EnergySite.live_status", side_effect=lambda: deepcopy(LIVE_STATUS), ) as mock_live_status: yield mock_live_status @@ -153,7 +153,7 @@ def mock_live_status() -> Generator[AsyncMock]: def mock_site_info() -> Generator[AsyncMock]: """Mock Tesla Fleet API Energy Specific site_info method.""" with patch( - "homeassistant.components.tesla_fleet.EnergySpecific.site_info", + "tesla_fleet_api.tesla.EnergySite.site_info", side_effect=lambda: deepcopy(SITE_INFO), ) as mock_live_status: yield mock_live_status @@ -182,7 +182,7 @@ def mock_request(): def mock_energy_history(): """Mock Teslemetry Energy Specific site_info method.""" with patch( - "homeassistant.components.teslemetry.EnergySpecific.energy_history", + "tesla_fleet_api.tesla.EnergySite.energy_history", return_value=ENERGY_HISTORY, ) as mock_live_status: yield mock_live_status @@ -192,7 +192,7 @@ def mock_energy_history(): def mock_signed_command() -> Generator[AsyncMock]: """Mock Tesla Fleet Api signed_command method.""" with patch( - "homeassistant.components.tesla_fleet.VehicleSigned.signed_command", + "tesla_fleet_api.tesla.VehicleSigned.signed_command", return_value=COMMAND_OK, ) as mock_signed_command: yield mock_signed_command diff --git a/tests/components/tesla_fleet/test_button.py b/tests/components/tesla_fleet/test_button.py index ef1cfd90357..d43f7448379 100644 --- a/tests/components/tesla_fleet/test_button.py +++ b/tests/components/tesla_fleet/test_button.py @@ -56,7 +56,7 @@ async def test_press( await setup_platform(hass, normal_config_entry, [Platform.BUTTON]) with patch( - f"homeassistant.components.tesla_fleet.VehicleSpecific.{func}", + f"tesla_fleet_api.tesla.VehicleFleet.{func}", return_value=COMMAND_OK, ) as command: await hass.services.async_call( @@ -85,7 +85,7 @@ async def test_press_signing_error( with ( patch("homeassistant.components.tesla_fleet.TeslaFleetApi.get_private_key"), patch( - "homeassistant.components.tesla_fleet.VehicleSigned.flash_lights", + "tesla_fleet_api.tesla.VehicleSigned.flash_lights", side_effect=NotOnWhitelistFault, ), pytest.raises(HomeAssistantError) as error, diff --git a/tests/components/tesla_fleet/test_climate.py b/tests/components/tesla_fleet/test_climate.py index b45e5259a5c..fae79c795c2 100644 --- a/tests/components/tesla_fleet/test_climate.py +++ b/tests/components/tesla_fleet/test_climate.py @@ -257,7 +257,7 @@ async def test_invalid_error( with ( patch( - "homeassistant.components.tesla_fleet.VehicleSpecific.auto_conditioning_start", + "tesla_fleet_api.tesla.VehicleFleet.auto_conditioning_start", side_effect=InvalidCommand, ) as mock_on, pytest.raises( @@ -285,7 +285,7 @@ async def test_errors( with ( patch( - "homeassistant.components.tesla_fleet.VehicleSpecific.auto_conditioning_start", + "tesla_fleet_api.tesla.VehicleFleet.auto_conditioning_start", return_value=response, ) as mock_on, pytest.raises(HomeAssistantError), @@ -308,7 +308,7 @@ async def test_ignored_error( await setup_platform(hass, normal_config_entry, [Platform.CLIMATE]) entity_id = "climate.test_climate" with patch( - "homeassistant.components.tesla_fleet.VehicleSpecific.auto_conditioning_start", + "tesla_fleet_api.tesla.VehicleFleet.auto_conditioning_start", return_value=COMMAND_IGNORED_REASON, ) as mock_on: await hass.services.async_call( diff --git a/tests/components/tesla_fleet/test_cover.py b/tests/components/tesla_fleet/test_cover.py index ac5307b2fdd..15d14f34a87 100644 --- a/tests/components/tesla_fleet/test_cover.py +++ b/tests/components/tesla_fleet/test_cover.py @@ -89,7 +89,7 @@ async def test_cover_services( # Vent Windows entity_id = "cover.test_windows" with patch( - "homeassistant.components.teslemetry.VehicleSpecific.window_control", + "tesla_fleet_api.tesla.VehicleFleet.window_control", return_value=COMMAND_OK, ) as call: await hass.services.async_call( @@ -118,7 +118,7 @@ async def test_cover_services( # Charge Port Door entity_id = "cover.test_charge_port_door" with patch( - "homeassistant.components.teslemetry.VehicleSpecific.charge_port_door_open", + "tesla_fleet_api.tesla.VehicleFleet.charge_port_door_open", return_value=COMMAND_OK, ) as call: await hass.services.async_call( @@ -133,7 +133,7 @@ async def test_cover_services( assert state.state == CoverState.OPEN with patch( - "homeassistant.components.teslemetry.VehicleSpecific.charge_port_door_close", + "tesla_fleet_api.tesla.VehicleFleet.charge_port_door_close", return_value=COMMAND_OK, ) as call: await hass.services.async_call( @@ -150,7 +150,7 @@ async def test_cover_services( # Frunk entity_id = "cover.test_frunk" with patch( - "homeassistant.components.teslemetry.VehicleSpecific.actuate_trunk", + "tesla_fleet_api.tesla.VehicleFleet.actuate_trunk", return_value=COMMAND_OK, ) as call: await hass.services.async_call( @@ -167,7 +167,7 @@ async def test_cover_services( # Trunk entity_id = "cover.test_trunk" with patch( - "homeassistant.components.teslemetry.VehicleSpecific.actuate_trunk", + "tesla_fleet_api.tesla.VehicleFleet.actuate_trunk", return_value=COMMAND_OK, ) as call: await hass.services.async_call( @@ -196,7 +196,7 @@ async def test_cover_services( # Sunroof entity_id = "cover.test_sunroof" with patch( - "homeassistant.components.teslemetry.VehicleSpecific.sun_roof_control", + "tesla_fleet_api.tesla.VehicleFleet.sun_roof_control", return_value=COMMAND_OK, ) as call: await hass.services.async_call( diff --git a/tests/components/tesla_fleet/test_lock.py b/tests/components/tesla_fleet/test_lock.py index 00b77aefcaf..ac9a7b49b55 100644 --- a/tests/components/tesla_fleet/test_lock.py +++ b/tests/components/tesla_fleet/test_lock.py @@ -59,7 +59,7 @@ async def test_lock_services( entity_id = "lock.test_lock" with patch( - "homeassistant.components.tesla_fleet.VehicleSpecific.door_lock", + "tesla_fleet_api.tesla.VehicleFleet.door_lock", return_value=COMMAND_OK, ) as call: await hass.services.async_call( @@ -73,7 +73,7 @@ async def test_lock_services( call.assert_called_once() with patch( - "homeassistant.components.tesla_fleet.VehicleSpecific.door_unlock", + "tesla_fleet_api.tesla.VehicleFleet.door_unlock", return_value=COMMAND_OK, ) as call: await hass.services.async_call( @@ -97,7 +97,7 @@ async def test_lock_services( ) with patch( - "homeassistant.components.tesla_fleet.VehicleSpecific.charge_port_door_open", + "tesla_fleet_api.tesla.VehicleFleet.charge_port_door_open", return_value=COMMAND_OK, ) as call: await hass.services.async_call( diff --git a/tests/components/tesla_fleet/test_media_player.py b/tests/components/tesla_fleet/test_media_player.py index 4c833e7499f..b2900d96c80 100644 --- a/tests/components/tesla_fleet/test_media_player.py +++ b/tests/components/tesla_fleet/test_media_player.py @@ -88,7 +88,7 @@ async def test_media_player_services( entity_id = "media_player.test_media_player" with patch( - "homeassistant.components.tesla_fleet.VehicleSpecific.adjust_volume", + "tesla_fleet_api.tesla.VehicleFleet.adjust_volume", return_value=COMMAND_OK, ) as call: await hass.services.async_call( @@ -102,7 +102,7 @@ async def test_media_player_services( call.assert_called_once() with patch( - "homeassistant.components.tesla_fleet.VehicleSpecific.media_toggle_playback", + "tesla_fleet_api.tesla.VehicleFleet.media_toggle_playback", return_value=COMMAND_OK, ) as call: await hass.services.async_call( @@ -117,7 +117,7 @@ async def test_media_player_services( # This test will fail without the previous call to pause playback with patch( - "homeassistant.components.tesla_fleet.VehicleSpecific.media_toggle_playback", + "tesla_fleet_api.tesla.VehicleFleet.media_toggle_playback", return_value=COMMAND_OK, ) as call: await hass.services.async_call( @@ -131,7 +131,7 @@ async def test_media_player_services( call.assert_called_once() with patch( - "homeassistant.components.tesla_fleet.VehicleSpecific.media_next_track", + "tesla_fleet_api.tesla.VehicleFleet.media_next_track", return_value=COMMAND_OK, ) as call: await hass.services.async_call( @@ -144,7 +144,7 @@ async def test_media_player_services( call.assert_called_once() with patch( - "homeassistant.components.tesla_fleet.VehicleSpecific.media_prev_track", + "tesla_fleet_api.tesla.VehicleFleet.media_prev_track", return_value=COMMAND_OK, ) as call: await hass.services.async_call( diff --git a/tests/components/tesla_fleet/test_number.py b/tests/components/tesla_fleet/test_number.py index 8551a99ee29..4ade98852c8 100644 --- a/tests/components/tesla_fleet/test_number.py +++ b/tests/components/tesla_fleet/test_number.py @@ -57,7 +57,7 @@ async def test_number_services( entity_id = "number.test_charge_current" with patch( - "homeassistant.components.tesla_fleet.VehicleSpecific.set_charging_amps", + "tesla_fleet_api.tesla.VehicleFleet.set_charging_amps", return_value=COMMAND_OK, ) as call: await hass.services.async_call( @@ -72,7 +72,7 @@ async def test_number_services( entity_id = "number.test_charge_limit" with patch( - "homeassistant.components.tesla_fleet.VehicleSpecific.set_charge_limit", + "tesla_fleet_api.tesla.VehicleFleet.set_charge_limit", return_value=COMMAND_OK, ) as call: await hass.services.async_call( @@ -87,7 +87,7 @@ async def test_number_services( entity_id = "number.energy_site_backup_reserve" with patch( - "homeassistant.components.tesla_fleet.EnergySpecific.backup", + "tesla_fleet_api.tesla.EnergySite.backup", return_value=COMMAND_OK, ) as call: await hass.services.async_call( @@ -105,7 +105,7 @@ async def test_number_services( entity_id = "number.energy_site_off_grid_reserve" with patch( - "homeassistant.components.tesla_fleet.EnergySpecific.off_grid_vehicle_charging_reserve", + "tesla_fleet_api.tesla.EnergySite.off_grid_vehicle_charging_reserve", return_value=COMMAND_OK, ) as call: await hass.services.async_call( diff --git a/tests/components/tesla_fleet/test_select.py b/tests/components/tesla_fleet/test_select.py index 902b28ddb7a..f06d67041c9 100644 --- a/tests/components/tesla_fleet/test_select.py +++ b/tests/components/tesla_fleet/test_select.py @@ -61,11 +61,11 @@ async def test_select_services( entity_id = "select.test_seat_heater_front_left" with ( patch( - "homeassistant.components.tesla_fleet.VehicleSpecific.remote_seat_heater_request", + "tesla_fleet_api.tesla.VehicleFleet.remote_seat_heater_request", return_value=COMMAND_OK, ) as remote_seat_heater_request, patch( - "homeassistant.components.tesla_fleet.VehicleSpecific.auto_conditioning_start", + "tesla_fleet_api.tesla.VehicleFleet.auto_conditioning_start", return_value=COMMAND_OK, ) as auto_conditioning_start, ): @@ -83,11 +83,11 @@ async def test_select_services( entity_id = "select.test_steering_wheel_heater" with ( patch( - "homeassistant.components.tesla_fleet.VehicleSpecific.remote_steering_wheel_heat_level_request", + "tesla_fleet_api.tesla.VehicleFleet.remote_steering_wheel_heat_level_request", return_value=COMMAND_OK, ) as remote_steering_wheel_heat_level_request, patch( - "homeassistant.components.tesla_fleet.VehicleSpecific.auto_conditioning_start", + "tesla_fleet_api.tesla.VehicleFleet.auto_conditioning_start", return_value=COMMAND_OK, ) as auto_conditioning_start, ): @@ -104,7 +104,7 @@ async def test_select_services( entity_id = "select.energy_site_operation_mode" with patch( - "homeassistant.components.tesla_fleet.EnergySpecific.operation", + "tesla_fleet_api.tesla.EnergySite.operation", return_value=COMMAND_OK, ) as call: await hass.services.async_call( @@ -122,7 +122,7 @@ async def test_select_services( entity_id = "select.energy_site_allow_export" with patch( - "homeassistant.components.tesla_fleet.EnergySpecific.grid_import_export", + "tesla_fleet_api.tesla.EnergySite.grid_import_export", return_value=COMMAND_OK, ) as call: await hass.services.async_call( diff --git a/tests/components/tesla_fleet/test_switch.py b/tests/components/tesla_fleet/test_switch.py index fba4fc05cc4..022c3a0ab18 100644 --- a/tests/components/tesla_fleet/test_switch.py +++ b/tests/components/tesla_fleet/test_switch.py @@ -71,41 +71,41 @@ async def test_switch_offline( @pytest.mark.parametrize( ("name", "on", "off"), [ - ("test_charge", "VehicleSpecific.charge_start", "VehicleSpecific.charge_stop"), + ("test_charge", "VehicleFleet.charge_start", "VehicleFleet.charge_stop"), ( "test_auto_seat_climate_left", - "VehicleSpecific.remote_auto_seat_climate_request", - "VehicleSpecific.remote_auto_seat_climate_request", + "VehicleFleet.remote_auto_seat_climate_request", + "VehicleFleet.remote_auto_seat_climate_request", ), ( "test_auto_seat_climate_right", - "VehicleSpecific.remote_auto_seat_climate_request", - "VehicleSpecific.remote_auto_seat_climate_request", + "VehicleFleet.remote_auto_seat_climate_request", + "VehicleFleet.remote_auto_seat_climate_request", ), ( "test_auto_steering_wheel_heater", - "VehicleSpecific.remote_auto_steering_wheel_heat_climate_request", - "VehicleSpecific.remote_auto_steering_wheel_heat_climate_request", + "VehicleFleet.remote_auto_steering_wheel_heat_climate_request", + "VehicleFleet.remote_auto_steering_wheel_heat_climate_request", ), ( "test_defrost", - "VehicleSpecific.set_preconditioning_max", - "VehicleSpecific.set_preconditioning_max", + "VehicleFleet.set_preconditioning_max", + "VehicleFleet.set_preconditioning_max", ), ( "energy_site_storm_watch", - "EnergySpecific.storm_mode", - "EnergySpecific.storm_mode", + "EnergySite.storm_mode", + "EnergySite.storm_mode", ), ( "energy_site_allow_charging_from_grid", - "EnergySpecific.grid_import_export", - "EnergySpecific.grid_import_export", + "EnergySite.grid_import_export", + "EnergySite.grid_import_export", ), ( "test_sentry_mode", - "VehicleSpecific.set_sentry_mode", - "VehicleSpecific.set_sentry_mode", + "VehicleFleet.set_sentry_mode", + "VehicleFleet.set_sentry_mode", ), ], ) @@ -122,7 +122,7 @@ async def test_switch_services( entity_id = f"switch.{name}" with patch( - f"homeassistant.components.tesla_fleet.{on}", + f"tesla_fleet_api.tesla.{on}", return_value=COMMAND_OK, ) as call: await hass.services.async_call( @@ -136,7 +136,7 @@ async def test_switch_services( call.assert_called_once() with patch( - f"homeassistant.components.tesla_fleet.{off}", + f"tesla_fleet_api.tesla.{off}", return_value=COMMAND_OK, ) as call: await hass.services.async_call( diff --git a/tests/components/teslemetry/conftest.py b/tests/components/teslemetry/conftest.py index e89bab9eff1..0152543e512 100644 --- a/tests/components/teslemetry/conftest.py +++ b/tests/components/teslemetry/conftest.py @@ -25,7 +25,7 @@ from .const import ( def mock_metadata(): """Mock Tesla Fleet Api metadata method.""" with patch( - "homeassistant.components.teslemetry.Teslemetry.metadata", return_value=METADATA + "tesla_fleet_api.teslemetry.Teslemetry.metadata", return_value=METADATA ) as mock_products: yield mock_products @@ -34,7 +34,7 @@ def mock_metadata(): def mock_products(): """Mock Tesla Fleet Api products method.""" with patch( - "homeassistant.components.teslemetry.Teslemetry.products", return_value=PRODUCTS + "tesla_fleet_api.teslemetry.Teslemetry.products", return_value=PRODUCTS ) as mock_products: yield mock_products @@ -43,7 +43,7 @@ def mock_products(): def mock_vehicle_data() -> Generator[AsyncMock]: """Mock Tesla Fleet API Vehicle Specific vehicle_data method.""" with patch( - "homeassistant.components.teslemetry.VehicleSpecific.vehicle_data", + "tesla_fleet_api.teslemetry.Vehicle.vehicle_data", return_value=VEHICLE_DATA, ) as mock_vehicle_data: yield mock_vehicle_data @@ -53,7 +53,7 @@ def mock_vehicle_data() -> Generator[AsyncMock]: def mock_legacy(): """Mock Tesla Fleet Api products method.""" with patch( - "homeassistant.components.teslemetry.VehicleSpecific.pre2021", return_value=True + "tesla_fleet_api.teslemetry.Vehicle.pre2021", return_value=True ) as mock_pre2021: yield mock_pre2021 @@ -62,7 +62,7 @@ def mock_legacy(): def mock_wake_up(): """Mock Tesla Fleet API Vehicle Specific wake_up method.""" with patch( - "homeassistant.components.teslemetry.VehicleSpecific.wake_up", + "tesla_fleet_api.teslemetry.Vehicle.wake_up", return_value=WAKE_UP_ONLINE, ) as mock_wake_up: yield mock_wake_up @@ -72,7 +72,7 @@ def mock_wake_up(): def mock_vehicle() -> Generator[AsyncMock]: """Mock Tesla Fleet API Vehicle Specific vehicle method.""" with patch( - "homeassistant.components.teslemetry.VehicleSpecific.vehicle", + "tesla_fleet_api.teslemetry.Vehicle.vehicle", return_value=WAKE_UP_ONLINE, ) as mock_vehicle: yield mock_vehicle @@ -82,7 +82,7 @@ def mock_vehicle() -> Generator[AsyncMock]: def mock_request(): """Mock Tesla Fleet API Vehicle Specific class.""" with patch( - "homeassistant.components.teslemetry.Teslemetry._request", + "tesla_fleet_api.teslemetry.Teslemetry._request", return_value=COMMAND_OK, ) as mock_request: yield mock_request @@ -92,7 +92,7 @@ def mock_request(): def mock_live_status(): """Mock Teslemetry Energy Specific live_status method.""" with patch( - "homeassistant.components.teslemetry.EnergySpecific.live_status", + "tesla_fleet_api.tesla.energysite.EnergySite.live_status", side_effect=lambda: deepcopy(LIVE_STATUS), ) as mock_live_status: yield mock_live_status @@ -102,7 +102,7 @@ def mock_live_status(): def mock_site_info(): """Mock Teslemetry Energy Specific site_info method.""" with patch( - "homeassistant.components.teslemetry.EnergySpecific.site_info", + "tesla_fleet_api.tesla.energysite.EnergySite.site_info", side_effect=lambda: deepcopy(SITE_INFO), ) as mock_live_status: yield mock_live_status @@ -112,7 +112,7 @@ def mock_site_info(): def mock_energy_history(): """Mock Teslemetry Energy Specific site_info method.""" with patch( - "homeassistant.components.teslemetry.EnergySpecific.energy_history", + "tesla_fleet_api.tesla.energysite.EnergySite.energy_history", return_value=ENERGY_HISTORY, ) as mock_live_status: yield mock_live_status @@ -122,7 +122,7 @@ def mock_energy_history(): def mock_add_listener(): """Mock Teslemetry Stream listen method.""" with patch( - "homeassistant.components.teslemetry.TeslemetryStream.async_add_listener", + "teslemetry_stream.TeslemetryStream.async_add_listener", ) as mock_add_listener: mock_add_listener.listeners = [] @@ -165,7 +165,7 @@ def mock_stream_update_config(): def mock_stream_connected(): """Mock Teslemetry Stream listen method.""" with patch( - "homeassistant.components.teslemetry.TeslemetryStream.connected", + "teslemetry_stream.TeslemetryStream.connected", return_value=True, ) as mock_stream_connected: yield mock_stream_connected diff --git a/tests/components/teslemetry/test_button.py b/tests/components/teslemetry/test_button.py index 75f94342f1e..46db33ce913 100644 --- a/tests/components/teslemetry/test_button.py +++ b/tests/components/teslemetry/test_button.py @@ -42,7 +42,7 @@ async def test_press(hass: HomeAssistant, name: str, func: str) -> None: await setup_platform(hass, [Platform.BUTTON]) with patch( - f"homeassistant.components.teslemetry.VehicleSpecific.{func}", + f"tesla_fleet_api.teslemetry.Vehicle.{func}", return_value=COMMAND_OK, ) as command: await hass.services.async_call( diff --git a/tests/components/teslemetry/test_climate.py b/tests/components/teslemetry/test_climate.py index 948fbffa881..27bed45c51f 100644 --- a/tests/components/teslemetry/test_climate.py +++ b/tests/components/teslemetry/test_climate.py @@ -210,7 +210,7 @@ async def test_invalid_error(hass: HomeAssistant, snapshot: SnapshotAssertion) - with ( patch( - "homeassistant.components.teslemetry.VehicleSpecific.auto_conditioning_start", + "tesla_fleet_api.teslemetry.Vehicle.auto_conditioning_start", side_effect=InvalidCommand, ) as mock_on, pytest.raises(HomeAssistantError) as error, @@ -234,7 +234,7 @@ async def test_errors(hass: HomeAssistant, response: str) -> None: with ( patch( - "homeassistant.components.teslemetry.VehicleSpecific.auto_conditioning_start", + "tesla_fleet_api.teslemetry.Vehicle.auto_conditioning_start", return_value=response, ) as mock_on, pytest.raises(HomeAssistantError), @@ -256,7 +256,7 @@ async def test_ignored_error( await setup_platform(hass, [Platform.CLIMATE]) entity_id = "climate.test_climate" with patch( - "homeassistant.components.teslemetry.VehicleSpecific.auto_conditioning_start", + "tesla_fleet_api.teslemetry.Vehicle.auto_conditioning_start", return_value=COMMAND_IGNORED_REASON, ) as mock_on: await hass.services.async_call( diff --git a/tests/components/teslemetry/test_cover.py b/tests/components/teslemetry/test_cover.py index 14af1e732fe..e3933931c9f 100644 --- a/tests/components/teslemetry/test_cover.py +++ b/tests/components/teslemetry/test_cover.py @@ -75,7 +75,7 @@ async def test_cover_services( # Vent Windows entity_id = "cover.test_windows" with patch( - "homeassistant.components.teslemetry.VehicleSpecific.window_control", + "tesla_fleet_api.teslemetry.Vehicle.window_control", return_value=COMMAND_OK, ) as call: await hass.services.async_call( @@ -104,7 +104,7 @@ async def test_cover_services( # Charge Port Door entity_id = "cover.test_charge_port_door" with patch( - "homeassistant.components.teslemetry.VehicleSpecific.charge_port_door_open", + "tesla_fleet_api.teslemetry.Vehicle.charge_port_door_open", return_value=COMMAND_OK, ) as call: await hass.services.async_call( @@ -119,7 +119,7 @@ async def test_cover_services( assert state.state == CoverState.OPEN with patch( - "homeassistant.components.teslemetry.VehicleSpecific.charge_port_door_close", + "tesla_fleet_api.teslemetry.Vehicle.charge_port_door_close", return_value=COMMAND_OK, ) as call: await hass.services.async_call( @@ -136,7 +136,7 @@ async def test_cover_services( # Frunk entity_id = "cover.test_frunk" with patch( - "homeassistant.components.teslemetry.VehicleSpecific.actuate_trunk", + "tesla_fleet_api.teslemetry.Vehicle.actuate_trunk", return_value=COMMAND_OK, ) as call: await hass.services.async_call( @@ -153,7 +153,7 @@ async def test_cover_services( # Trunk entity_id = "cover.test_trunk" with patch( - "homeassistant.components.teslemetry.VehicleSpecific.actuate_trunk", + "tesla_fleet_api.teslemetry.Vehicle.actuate_trunk", return_value=COMMAND_OK, ) as call: await hass.services.async_call( @@ -182,7 +182,7 @@ async def test_cover_services( # Sunroof entity_id = "cover.test_sunroof" with patch( - "homeassistant.components.teslemetry.VehicleSpecific.sun_roof_control", + "tesla_fleet_api.teslemetry.Vehicle.sun_roof_control", return_value=COMMAND_OK, ) as call: await hass.services.async_call( diff --git a/tests/components/teslemetry/test_lock.py b/tests/components/teslemetry/test_lock.py index 848eee82c39..a74d613859f 100644 --- a/tests/components/teslemetry/test_lock.py +++ b/tests/components/teslemetry/test_lock.py @@ -57,7 +57,7 @@ async def test_lock_services( entity_id = "lock.test_lock" with patch( - "homeassistant.components.teslemetry.VehicleSpecific.door_lock", + "tesla_fleet_api.teslemetry.Vehicle.door_lock", return_value=COMMAND_OK, ) as call: await hass.services.async_call( @@ -71,7 +71,7 @@ async def test_lock_services( call.assert_called_once() with patch( - "homeassistant.components.teslemetry.VehicleSpecific.door_unlock", + "tesla_fleet_api.teslemetry.Vehicle.door_unlock", return_value=COMMAND_OK, ) as call: await hass.services.async_call( @@ -95,7 +95,7 @@ async def test_lock_services( ) with patch( - "homeassistant.components.teslemetry.VehicleSpecific.charge_port_door_open", + "tesla_fleet_api.teslemetry.Vehicle.charge_port_door_open", return_value=COMMAND_OK, ) as call: await hass.services.async_call( diff --git a/tests/components/teslemetry/test_media_player.py b/tests/components/teslemetry/test_media_player.py index de990dbe7bc..ab8f21ceda4 100644 --- a/tests/components/teslemetry/test_media_player.py +++ b/tests/components/teslemetry/test_media_player.py @@ -76,7 +76,7 @@ async def test_media_player_services( entity_id = "media_player.test_media_player" with patch( - "homeassistant.components.teslemetry.VehicleSpecific.adjust_volume", + "tesla_fleet_api.teslemetry.Vehicle.adjust_volume", return_value=COMMAND_OK, ) as call: await hass.services.async_call( @@ -90,7 +90,7 @@ async def test_media_player_services( call.assert_called_once() with patch( - "homeassistant.components.teslemetry.VehicleSpecific.media_toggle_playback", + "tesla_fleet_api.teslemetry.Vehicle.media_toggle_playback", return_value=COMMAND_OK, ) as call: await hass.services.async_call( @@ -105,7 +105,7 @@ async def test_media_player_services( # This test will fail without the previous call to pause playback with patch( - "homeassistant.components.teslemetry.VehicleSpecific.media_toggle_playback", + "tesla_fleet_api.teslemetry.Vehicle.media_toggle_playback", return_value=COMMAND_OK, ) as call: await hass.services.async_call( @@ -119,7 +119,7 @@ async def test_media_player_services( call.assert_called_once() with patch( - "homeassistant.components.teslemetry.VehicleSpecific.media_next_track", + "tesla_fleet_api.teslemetry.Vehicle.media_next_track", return_value=COMMAND_OK, ) as call: await hass.services.async_call( @@ -132,7 +132,7 @@ async def test_media_player_services( call.assert_called_once() with patch( - "homeassistant.components.teslemetry.VehicleSpecific.media_prev_track", + "tesla_fleet_api.teslemetry.Vehicle.media_prev_track", return_value=COMMAND_OK, ) as call: await hass.services.async_call( diff --git a/tests/components/teslemetry/test_number.py b/tests/components/teslemetry/test_number.py index 95eed5a3f1e..2c45631a060 100644 --- a/tests/components/teslemetry/test_number.py +++ b/tests/components/teslemetry/test_number.py @@ -42,7 +42,7 @@ async def test_number_services( entity_id = "number.test_charge_current" with patch( - "homeassistant.components.teslemetry.VehicleSpecific.set_charging_amps", + "tesla_fleet_api.teslemetry.Vehicle.set_charging_amps", return_value=COMMAND_OK, ) as call: await hass.services.async_call( @@ -57,7 +57,7 @@ async def test_number_services( entity_id = "number.test_charge_limit" with patch( - "homeassistant.components.teslemetry.VehicleSpecific.set_charge_limit", + "tesla_fleet_api.teslemetry.Vehicle.set_charge_limit", return_value=COMMAND_OK, ) as call: await hass.services.async_call( @@ -72,7 +72,7 @@ async def test_number_services( entity_id = "number.energy_site_backup_reserve" with patch( - "homeassistant.components.teslemetry.EnergySpecific.backup", + "tesla_fleet_api.teslemetry.EnergySite.backup", return_value=COMMAND_OK, ) as call: await hass.services.async_call( @@ -90,7 +90,7 @@ async def test_number_services( entity_id = "number.energy_site_off_grid_reserve" with patch( - "homeassistant.components.teslemetry.EnergySpecific.off_grid_vehicle_charging_reserve", + "tesla_fleet_api.teslemetry.EnergySite.off_grid_vehicle_charging_reserve", return_value=COMMAND_OK, ) as call: await hass.services.async_call( diff --git a/tests/components/teslemetry/test_select.py b/tests/components/teslemetry/test_select.py index c49e83803cd..b17b52903fa 100644 --- a/tests/components/teslemetry/test_select.py +++ b/tests/components/teslemetry/test_select.py @@ -41,7 +41,7 @@ async def test_select_services(hass: HomeAssistant, mock_vehicle_data) -> None: entity_id = "select.test_seat_heater_front_left" with patch( - "homeassistant.components.teslemetry.VehicleSpecific.remote_seat_heater_request", + "tesla_fleet_api.teslemetry.Vehicle.remote_seat_heater_request", return_value=COMMAND_OK, ) as call: await hass.services.async_call( @@ -56,7 +56,7 @@ async def test_select_services(hass: HomeAssistant, mock_vehicle_data) -> None: entity_id = "select.test_steering_wheel_heater" with patch( - "homeassistant.components.teslemetry.VehicleSpecific.remote_steering_wheel_heat_level_request", + "tesla_fleet_api.teslemetry.Vehicle.remote_steering_wheel_heat_level_request", return_value=COMMAND_OK, ) as call: await hass.services.async_call( @@ -71,7 +71,7 @@ async def test_select_services(hass: HomeAssistant, mock_vehicle_data) -> None: entity_id = "select.energy_site_operation_mode" with patch( - "homeassistant.components.teslemetry.EnergySpecific.operation", + "tesla_fleet_api.teslemetry.EnergySite.operation", return_value=COMMAND_OK, ) as call: await hass.services.async_call( @@ -89,7 +89,7 @@ async def test_select_services(hass: HomeAssistant, mock_vehicle_data) -> None: entity_id = "select.energy_site_allow_export" with patch( - "homeassistant.components.teslemetry.EnergySpecific.grid_import_export", + "tesla_fleet_api.teslemetry.EnergySite.grid_import_export", return_value=COMMAND_OK, ) as call: await hass.services.async_call( diff --git a/tests/components/teslemetry/test_sensor.py b/tests/components/teslemetry/test_sensor.py index c3c2252ab89..213811f6ea0 100644 --- a/tests/components/teslemetry/test_sensor.py +++ b/tests/components/teslemetry/test_sensor.py @@ -31,9 +31,7 @@ async def test_sensors( freezer.move_to("2024-01-01 00:00:00+00:00") # Force the vehicle to use polling - with patch( - "homeassistant.components.teslemetry.VehicleSpecific.pre2021", return_value=True - ): + with patch("tesla_fleet_api.teslemetry.Vehicle.pre2021", return_value=True): entry = await setup_platform(hass, [Platform.SENSOR]) assert_entities(hass, entry.entry_id, entity_registry, snapshot) diff --git a/tests/components/teslemetry/test_services.py b/tests/components/teslemetry/test_services.py index a5b55f5dcc5..bcf5407999f 100644 --- a/tests/components/teslemetry/test_services.py +++ b/tests/components/teslemetry/test_services.py @@ -51,7 +51,7 @@ async def test_services( ).device_id with patch( - "homeassistant.components.teslemetry.VehicleSpecific.navigation_gps_request", + "tesla_fleet_api.teslemetry.Vehicle.navigation_gps_request", return_value=COMMAND_OK, ) as navigation_gps_request: await hass.services.async_call( @@ -66,7 +66,7 @@ async def test_services( navigation_gps_request.assert_called_once() with patch( - "homeassistant.components.teslemetry.VehicleSpecific.set_scheduled_charging", + "tesla_fleet_api.teslemetry.Vehicle.set_scheduled_charging", return_value=COMMAND_OK, ) as set_scheduled_charging: await hass.services.async_call( @@ -93,7 +93,7 @@ async def test_services( ) with patch( - "homeassistant.components.teslemetry.VehicleSpecific.set_scheduled_departure", + "tesla_fleet_api.teslemetry.Vehicle.set_scheduled_departure", return_value=COMMAND_OK, ) as set_scheduled_departure: await hass.services.async_call( @@ -138,7 +138,7 @@ async def test_services( ) with patch( - "homeassistant.components.teslemetry.VehicleSpecific.set_valet_mode", + "tesla_fleet_api.teslemetry.Vehicle.set_valet_mode", return_value=COMMAND_OK, ) as set_valet_mode: await hass.services.async_call( @@ -154,7 +154,7 @@ async def test_services( set_valet_mode.assert_called_once() with patch( - "homeassistant.components.teslemetry.VehicleSpecific.speed_limit_activate", + "tesla_fleet_api.teslemetry.Vehicle.speed_limit_activate", return_value=COMMAND_OK, ) as speed_limit_activate: await hass.services.async_call( @@ -170,7 +170,7 @@ async def test_services( speed_limit_activate.assert_called_once() with patch( - "homeassistant.components.teslemetry.VehicleSpecific.speed_limit_deactivate", + "tesla_fleet_api.teslemetry.Vehicle.speed_limit_deactivate", return_value=COMMAND_OK, ) as speed_limit_deactivate: await hass.services.async_call( @@ -186,7 +186,7 @@ async def test_services( speed_limit_deactivate.assert_called_once() with patch( - "homeassistant.components.teslemetry.EnergySpecific.time_of_use_settings", + "tesla_fleet_api.teslemetry.EnergySite.time_of_use_settings", return_value=COMMAND_OK, ) as set_time_of_use: await hass.services.async_call( @@ -202,7 +202,7 @@ async def test_services( with ( patch( - "homeassistant.components.teslemetry.EnergySpecific.time_of_use_settings", + "tesla_fleet_api.teslemetry.EnergySite.time_of_use_settings", return_value=COMMAND_ERROR, ) as set_time_of_use, pytest.raises(HomeAssistantError), diff --git a/tests/components/teslemetry/test_switch.py b/tests/components/teslemetry/test_switch.py index 17522f0ce2a..6b31a28db59 100644 --- a/tests/components/teslemetry/test_switch.py +++ b/tests/components/teslemetry/test_switch.py @@ -49,41 +49,41 @@ async def test_switch_alt( @pytest.mark.parametrize( ("name", "on", "off"), [ - ("test_charge", "VehicleSpecific.charge_start", "VehicleSpecific.charge_stop"), + ("test_charge", "Vehicle.charge_start", "Vehicle.charge_stop"), ( "test_auto_seat_climate_left", - "VehicleSpecific.remote_auto_seat_climate_request", - "VehicleSpecific.remote_auto_seat_climate_request", + "Vehicle.remote_auto_seat_climate_request", + "Vehicle.remote_auto_seat_climate_request", ), ( "test_auto_seat_climate_right", - "VehicleSpecific.remote_auto_seat_climate_request", - "VehicleSpecific.remote_auto_seat_climate_request", + "Vehicle.remote_auto_seat_climate_request", + "Vehicle.remote_auto_seat_climate_request", ), ( "test_auto_steering_wheel_heater", - "VehicleSpecific.remote_auto_steering_wheel_heat_climate_request", - "VehicleSpecific.remote_auto_steering_wheel_heat_climate_request", + "Vehicle.remote_auto_steering_wheel_heat_climate_request", + "Vehicle.remote_auto_steering_wheel_heat_climate_request", ), ( "test_defrost", - "VehicleSpecific.set_preconditioning_max", - "VehicleSpecific.set_preconditioning_max", + "Vehicle.set_preconditioning_max", + "Vehicle.set_preconditioning_max", ), ( "energy_site_storm_watch", - "EnergySpecific.storm_mode", - "EnergySpecific.storm_mode", + "EnergySite.storm_mode", + "EnergySite.storm_mode", ), ( "energy_site_allow_charging_from_grid", - "EnergySpecific.grid_import_export", - "EnergySpecific.grid_import_export", + "EnergySite.grid_import_export", + "EnergySite.grid_import_export", ), ( "test_sentry_mode", - "VehicleSpecific.set_sentry_mode", - "VehicleSpecific.set_sentry_mode", + "Vehicle.set_sentry_mode", + "Vehicle.set_sentry_mode", ), ], ) @@ -96,7 +96,7 @@ async def test_switch_services( entity_id = f"switch.{name}" with patch( - f"homeassistant.components.teslemetry.{on}", + f"tesla_fleet_api.teslemetry.{on}", return_value=COMMAND_OK, ) as call: await hass.services.async_call( @@ -110,7 +110,7 @@ async def test_switch_services( call.assert_called_once() with patch( - f"homeassistant.components.teslemetry.{off}", + f"tesla_fleet_api.teslemetry.{off}", return_value=COMMAND_OK, ) as call: await hass.services.async_call( diff --git a/tests/components/teslemetry/test_update.py b/tests/components/teslemetry/test_update.py index 0f26b162043..af6c9d847f1 100644 --- a/tests/components/teslemetry/test_update.py +++ b/tests/components/teslemetry/test_update.py @@ -61,7 +61,7 @@ async def test_update_services( entity_id = "update.test_update" with patch( - "homeassistant.components.teslemetry.VehicleSpecific.schedule_software_update", + "tesla_fleet_api.teslemetry.Vehicle.schedule_software_update", return_value=COMMAND_OK, ) as call: await hass.services.async_call( diff --git a/tests/components/tessie/conftest.py b/tests/components/tessie/conftest.py index e0aba73af17..5fb844ff6b4 100644 --- a/tests/components/tessie/conftest.py +++ b/tests/components/tessie/conftest.py @@ -85,7 +85,7 @@ def mock_request(): def mock_live_status(): """Mock Tesla Fleet API EnergySpecific live_status method.""" with patch( - "homeassistant.components.tessie.EnergySpecific.live_status", + "tesla_fleet_api.tessie.EnergySite.live_status", side_effect=lambda: deepcopy(LIVE_STATUS), ) as mock_live_status: yield mock_live_status @@ -95,7 +95,7 @@ def mock_live_status(): def mock_site_info(): """Mock Tesla Fleet API EnergySpecific site_info method.""" with patch( - "homeassistant.components.tessie.EnergySpecific.site_info", + "tesla_fleet_api.tessie.EnergySite.site_info", side_effect=lambda: deepcopy(SITE_INFO), ) as mock_live_status: yield mock_live_status diff --git a/tests/components/tessie/test_number.py b/tests/components/tessie/test_number.py index 0fb13779183..69bbe1c9087 100644 --- a/tests/components/tessie/test_number.py +++ b/tests/components/tessie/test_number.py @@ -67,7 +67,7 @@ async def test_numbers( entity_id = "number.energy_site_backup_reserve" with patch( - "homeassistant.components.teslemetry.EnergySpecific.backup", + "tesla_fleet_api.tessie.EnergySite.backup", return_value=TEST_RESPONSE, ) as call: await hass.services.async_call( @@ -85,7 +85,7 @@ async def test_numbers( entity_id = "number.energy_site_off_grid_reserve" with patch( - "homeassistant.components.teslemetry.EnergySpecific.off_grid_vehicle_charging_reserve", + "tesla_fleet_api.tessie.EnergySite.off_grid_vehicle_charging_reserve", return_value=TEST_RESPONSE, ) as call: await hass.services.async_call( diff --git a/tests/components/tessie/test_select.py b/tests/components/tessie/test_select.py index c78923fbf5b..64380d363fc 100644 --- a/tests/components/tessie/test_select.py +++ b/tests/components/tessie/test_select.py @@ -52,7 +52,7 @@ async def test_select( # Test site operation mode entity_id = "select.energy_site_operation_mode" with patch( - "homeassistant.components.teslemetry.EnergySpecific.operation", + "tesla_fleet_api.tessie.EnergySite.operation", return_value=TEST_RESPONSE, ) as call: await hass.services.async_call( @@ -71,7 +71,7 @@ async def test_select( # Test site export mode entity_id = "select.energy_site_allow_export" with patch( - "homeassistant.components.teslemetry.EnergySpecific.grid_import_export", + "tesla_fleet_api.tessie.EnergySite.grid_import_export", return_value=TEST_RESPONSE, ) as call: await hass.services.async_call( @@ -129,7 +129,7 @@ async def test_errors(hass: HomeAssistant) -> None: # Test changing energy select with unknown error with ( patch( - "homeassistant.components.tessie.EnergySpecific.operation", + "tesla_fleet_api.tessie.EnergySite.operation", side_effect=UnsupportedVehicle, ) as mock_set, pytest.raises(HomeAssistantError) as error, diff --git a/tests/components/tessie/test_switch.py b/tests/components/tessie/test_switch.py index 690ad7d1ab4..f58468edfb7 100644 --- a/tests/components/tessie/test_switch.py +++ b/tests/components/tessie/test_switch.py @@ -61,13 +61,13 @@ async def test_switches( [ ( "energy_site_storm_watch", - "EnergySpecific.storm_mode", - "EnergySpecific.storm_mode", + "storm_mode", + "storm_mode", ), ( "energy_site_allow_charging_from_grid", - "EnergySpecific.grid_import_export", - "EnergySpecific.grid_import_export", + "grid_import_export", + "grid_import_export", ), ], ) @@ -80,7 +80,7 @@ async def test_switch_services( entity_id = f"switch.{name}" with patch( - f"homeassistant.components.teslemetry.{on}", + f"tesla_fleet_api.tessie.EnergySite.{on}", return_value=RESPONSE_OK, ) as call: await hass.services.async_call( @@ -94,7 +94,7 @@ async def test_switch_services( call.assert_called_once() with patch( - f"homeassistant.components.teslemetry.{off}", + f"tesla_fleet_api.tessie.EnergySite.{off}", return_value=RESPONSE_OK, ) as call: await hass.services.async_call( From e3f2f303957f24e5d530ce15bd069cd6e8f235e0 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Wed, 26 Mar 2025 13:15:58 +0100 Subject: [PATCH 3076/3148] Add circular mean statistics and sensor state class MEASUREMENT_ANGLE (#138453) * Add circular mean statistics * fixes * Add has_circular_mean and fix tests * Fix mypy * Rename to MEASUREMENT_ANGLE * Fix kitchen_sink tests * Fix sensor tests * for testing only * Revert ws command change * Apply suggestions * test only * add custom handling for postgres * fix recursion limit * Check if column is already available * Set default false and not nullable for has_circular_mean * Proper fix to be backwards compatible * Fix value is None * Align with schema * Remove has_circular_mean from test schemas as it's not required anymore * fix wrong column type * Use correct variable to reduce stats * Add guard that the uom is matching a valid one from the state class * Add some tests * Fix tests again * Use mean_type in StatisticsMetato difference between different mean type algorithms * Fix leftovers * Fix kitchen_sink tests * Fix postgres * Add circular mean test * Add mean_type_changed stats issue * Align the attributes with unit_changed * Fix mean_type_change stats issue * Add missing sensor recorder tests * Add test_statistic_during_period_circular_mean * Add mean_weight * Add test_statistic_during_period_hole_circular_mean * Use seperate migration step to null has_mean * Typo ARITHMETIC * Implement requested changes * Implement requested changes * Split into #141444 * Add StatisticMeanType.NONE and forbid that mean_type can be None * Fix mean_type * Implement requested changes * Small leftover of latest StatisticMeanType changes --- .../components/duke_energy/coordinator.py | 8 +- homeassistant/components/elvia/importer.py | 8 +- .../components/ista_ecotrend/sensor.py | 3 +- .../components/kitchen_sink/__init__.py | 56 +- .../components/kitchen_sink/sensor.py | 12 +- .../components/opower/coordinator.py | 10 +- homeassistant/components/recorder/const.py | 1 + homeassistant/components/recorder/core.py | 19 +- .../components/recorder/db_schema.py | 9 +- .../components/recorder/migration.py | 46 +- .../components/recorder/models/__init__.py | 2 + .../components/recorder/models/statistics.py | 16 +- .../components/recorder/statistics.py | 209 ++- .../table_managers/statistics_meta.py | 70 +- .../components/recorder/websocket_api.py | 6 +- homeassistant/components/sensor/const.py | 5 +- homeassistant/components/sensor/recorder.py | 159 ++- homeassistant/components/sensor/strings.json | 4 + .../components/tibber/coordinator.py | 8 +- .../kitchen_sink/snapshots/test_init.ambr | 10 + .../kitchen_sink/snapshots/test_sensor.ambr | 28 + tests/components/kitchen_sink/test_init.py | 3 + .../auto_repairs/statistics/test_schema.py | 1 + tests/components/recorder/common.py | 14 +- tests/components/recorder/db_schema_32.py | 2 + .../recorder/test_migration_from_schema_32.py | 13 + tests/components/recorder/test_statistics.py | 261 +++- .../components/recorder/test_websocket_api.py | 571 +++++++- tests/components/sensor/test_recorder.py | 1227 +++++++++++++---- 29 files changed, 2337 insertions(+), 444 deletions(-) diff --git a/homeassistant/components/duke_energy/coordinator.py b/homeassistant/components/duke_energy/coordinator.py index 12a2f5fd6ae..a76168475c0 100644 --- a/homeassistant/components/duke_energy/coordinator.py +++ b/homeassistant/components/duke_energy/coordinator.py @@ -8,7 +8,11 @@ from aiodukeenergy import DukeEnergy from aiohttp import ClientError from homeassistant.components.recorder import get_instance -from homeassistant.components.recorder.models import StatisticData, StatisticMetaData +from homeassistant.components.recorder.models import ( + StatisticData, + StatisticMeanType, + StatisticMetaData, +) from homeassistant.components.recorder.statistics import ( async_add_external_statistics, get_last_statistics, @@ -137,7 +141,7 @@ class DukeEnergyCoordinator(DataUpdateCoordinator[None]): f"Duke Energy {meter['serviceType'].capitalize()} {serial_number}" ) consumption_metadata = StatisticMetaData( - has_mean=False, + mean_type=StatisticMeanType.NONE, has_sum=True, name=f"{name_prefix} Consumption", source=DOMAIN, diff --git a/homeassistant/components/elvia/importer.py b/homeassistant/components/elvia/importer.py index 4e8b7f716ef..caca787237c 100644 --- a/homeassistant/components/elvia/importer.py +++ b/homeassistant/components/elvia/importer.py @@ -7,7 +7,11 @@ from typing import TYPE_CHECKING, cast from elvia import Elvia, error as ElviaError -from homeassistant.components.recorder.models import StatisticData, StatisticMetaData +from homeassistant.components.recorder.models import ( + StatisticData, + StatisticMeanType, + StatisticMetaData, +) from homeassistant.components.recorder.statistics import ( async_add_external_statistics, get_last_statistics, @@ -144,7 +148,7 @@ class ElviaImporter: async_add_external_statistics( hass=self.hass, metadata=StatisticMetaData( - has_mean=False, + mean_type=StatisticMeanType.NONE, has_sum=True, name=f"{self.metering_point_id} Consumption", source=DOMAIN, diff --git a/homeassistant/components/ista_ecotrend/sensor.py b/homeassistant/components/ista_ecotrend/sensor.py index ee54e502c26..0a8ed6e9ddb 100644 --- a/homeassistant/components/ista_ecotrend/sensor.py +++ b/homeassistant/components/ista_ecotrend/sensor.py @@ -8,6 +8,7 @@ import datetime from enum import StrEnum import logging +from homeassistant.components.recorder.models import StatisticMeanType from homeassistant.components.recorder.models.statistics import ( StatisticData, StatisticMetaData, @@ -270,7 +271,7 @@ class IstaSensor(CoordinatorEntity[IstaCoordinator], SensorEntity): ] metadata: StatisticMetaData = { - "has_mean": False, + "mean_type": StatisticMeanType.NONE, "has_sum": True, "name": f"{self.device_entry.name} {self.name}", "source": DOMAIN, diff --git a/homeassistant/components/kitchen_sink/__init__.py b/homeassistant/components/kitchen_sink/__init__.py index de8e521f0e8..2f876ca855d 100644 --- a/homeassistant/components/kitchen_sink/__init__.py +++ b/homeassistant/components/kitchen_sink/__init__.py @@ -12,14 +12,24 @@ from random import random import voluptuous as vol from homeassistant.components.recorder import DOMAIN as RECORDER_DOMAIN, get_instance -from homeassistant.components.recorder.models import StatisticData, StatisticMetaData +from homeassistant.components.recorder.models import ( + StatisticData, + StatisticMeanType, + StatisticMetaData, +) from homeassistant.components.recorder.statistics import ( async_add_external_statistics, async_import_statistics, get_last_statistics, ) from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import Platform, UnitOfEnergy, UnitOfTemperature, UnitOfVolume +from homeassistant.const import ( + DEGREE, + Platform, + UnitOfEnergy, + UnitOfTemperature, + UnitOfVolume, +) from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue @@ -72,6 +82,10 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set the config entry up.""" + if "recorder" in hass.config.components: + # Insert stats for mean_type_changed issue + await _insert_wrong_wind_direction_statistics(hass) + # Set up demo platforms with config entry await hass.config_entries.async_forward_entry_setups( entry, COMPONENTS_WITH_DEMO_PLATFORM @@ -233,7 +247,7 @@ async def _insert_statistics(hass: HomeAssistant) -> None: "name": "Outdoor temperature", "statistic_id": f"{DOMAIN}:temperature_outdoor", "unit_of_measurement": UnitOfTemperature.CELSIUS, - "has_mean": True, + "mean_type": StatisticMeanType.ARITHMETIC, "has_sum": False, } statistics = _generate_mean_statistics(yesterday_midnight, today_midnight, 15, 1) @@ -246,7 +260,7 @@ async def _insert_statistics(hass: HomeAssistant) -> None: "name": "Energy consumption 1", "statistic_id": f"{DOMAIN}:energy_consumption_kwh", "unit_of_measurement": UnitOfEnergy.KILO_WATT_HOUR, - "has_mean": False, + "mean_type": StatisticMeanType.NONE, "has_sum": True, } await _insert_sum_statistics(hass, metadata, yesterday_midnight, today_midnight, 1) @@ -258,7 +272,7 @@ async def _insert_statistics(hass: HomeAssistant) -> None: "name": "Energy consumption 2", "statistic_id": f"{DOMAIN}:energy_consumption_mwh", "unit_of_measurement": UnitOfEnergy.MEGA_WATT_HOUR, - "has_mean": False, + "mean_type": StatisticMeanType.NONE, "has_sum": True, } await _insert_sum_statistics( @@ -272,7 +286,7 @@ async def _insert_statistics(hass: HomeAssistant) -> None: "name": "Gas consumption 1", "statistic_id": f"{DOMAIN}:gas_consumption_m3", "unit_of_measurement": UnitOfVolume.CUBIC_METERS, - "has_mean": False, + "mean_type": StatisticMeanType.NONE, "has_sum": True, } await _insert_sum_statistics( @@ -286,7 +300,7 @@ async def _insert_statistics(hass: HomeAssistant) -> None: "name": "Gas consumption 2", "statistic_id": f"{DOMAIN}:gas_consumption_ft3", "unit_of_measurement": UnitOfVolume.CUBIC_FEET, - "has_mean": False, + "mean_type": StatisticMeanType.NONE, "has_sum": True, } await _insert_sum_statistics(hass, metadata, yesterday_midnight, today_midnight, 15) @@ -298,7 +312,7 @@ async def _insert_statistics(hass: HomeAssistant) -> None: "name": None, "statistic_id": "sensor.statistics_issues_issue_1", "unit_of_measurement": UnitOfVolume.CUBIC_METERS, - "has_mean": True, + "mean_type": StatisticMeanType.ARITHMETIC, "has_sum": False, } statistics = _generate_mean_statistics(yesterday_midnight, today_midnight, 15, 1) @@ -310,7 +324,7 @@ async def _insert_statistics(hass: HomeAssistant) -> None: "name": None, "statistic_id": "sensor.statistics_issues_issue_2", "unit_of_measurement": "cats", - "has_mean": True, + "mean_type": StatisticMeanType.ARITHMETIC, "has_sum": False, } statistics = _generate_mean_statistics(yesterday_midnight, today_midnight, 15, 1) @@ -322,7 +336,7 @@ async def _insert_statistics(hass: HomeAssistant) -> None: "name": None, "statistic_id": "sensor.statistics_issues_issue_3", "unit_of_measurement": UnitOfVolume.CUBIC_METERS, - "has_mean": True, + "mean_type": StatisticMeanType.ARITHMETIC, "has_sum": False, } statistics = _generate_mean_statistics(yesterday_midnight, today_midnight, 15, 1) @@ -334,8 +348,28 @@ async def _insert_statistics(hass: HomeAssistant) -> None: "name": None, "statistic_id": "sensor.statistics_issues_issue_4", "unit_of_measurement": UnitOfVolume.CUBIC_METERS, - "has_mean": True, + "mean_type": StatisticMeanType.ARITHMETIC, "has_sum": False, } statistics = _generate_mean_statistics(yesterday_midnight, today_midnight, 15, 1) async_import_statistics(hass, metadata, statistics) + + +async def _insert_wrong_wind_direction_statistics(hass: HomeAssistant) -> None: + """Insert some fake wind direction statistics.""" + now = dt_util.now() + yesterday = now - datetime.timedelta(days=1) + yesterday_midnight = yesterday.replace(hour=0, minute=0, second=0, microsecond=0) + today_midnight = yesterday_midnight + datetime.timedelta(days=1) + + # Add some statistics required to raise the mean_type_changed issue later + metadata: StatisticMetaData = { + "source": RECORDER_DOMAIN, + "name": None, + "statistic_id": "sensor.statistics_issues_issue_5", + "unit_of_measurement": DEGREE, + "mean_type": StatisticMeanType.ARITHMETIC, + "has_sum": False, + } + statistics = _generate_mean_statistics(yesterday_midnight, today_midnight, 0, 360) + async_import_statistics(hass, metadata, statistics) diff --git a/homeassistant/components/kitchen_sink/sensor.py b/homeassistant/components/kitchen_sink/sensor.py index 19d1b31aeab..04cb833f0df 100644 --- a/homeassistant/components/kitchen_sink/sensor.py +++ b/homeassistant/components/kitchen_sink/sensor.py @@ -8,7 +8,7 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import UnitOfPower +from homeassistant.const import DEGREE, UnitOfPower from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -87,6 +87,16 @@ async def async_setup_entry( state_class=None, unit_of_measurement=UnitOfPower.WATT, ), + DemoSensor( + device_unique_id="statistics_issues", + unique_id="statistics_issue_5", + device_name="Statistics issues", + entity_name="Issue 5", + state=100, + device_class=SensorDeviceClass.WIND_DIRECTION, + state_class=SensorStateClass.MEASUREMENT_ANGLE, + unit_of_measurement=DEGREE, + ), ] ) diff --git a/homeassistant/components/opower/coordinator.py b/homeassistant/components/opower/coordinator.py index aed89ccf46e..e8b6dbf9718 100644 --- a/homeassistant/components/opower/coordinator.py +++ b/homeassistant/components/opower/coordinator.py @@ -16,7 +16,11 @@ from opower import ( from opower.exceptions import ApiException, CannotConnect, InvalidAuth from homeassistant.components.recorder import get_instance -from homeassistant.components.recorder.models import StatisticData, StatisticMetaData +from homeassistant.components.recorder.models import ( + StatisticData, + StatisticMeanType, + StatisticMetaData, +) from homeassistant.components.recorder.statistics import ( async_add_external_statistics, get_last_statistics, @@ -201,7 +205,7 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, Forecast]]): f"{account.meter_type.name.lower()} {account.utility_account_id}" ) cost_metadata = StatisticMetaData( - has_mean=False, + mean_type=StatisticMeanType.NONE, has_sum=True, name=f"{name_prefix} cost", source=DOMAIN, @@ -209,7 +213,7 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, Forecast]]): unit_of_measurement=None, ) consumption_metadata = StatisticMetaData( - has_mean=False, + mean_type=StatisticMeanType.NONE, has_sum=True, name=f"{name_prefix} consumption", source=DOMAIN, diff --git a/homeassistant/components/recorder/const.py b/homeassistant/components/recorder/const.py index 36ff63a0496..4797eecda0f 100644 --- a/homeassistant/components/recorder/const.py +++ b/homeassistant/components/recorder/const.py @@ -54,6 +54,7 @@ CONTEXT_ID_AS_BINARY_SCHEMA_VERSION = 36 EVENT_TYPE_IDS_SCHEMA_VERSION = 37 STATES_META_SCHEMA_VERSION = 38 LAST_REPORTED_SCHEMA_VERSION = 43 +CIRCULAR_MEAN_SCHEMA_VERSION = 49 LEGACY_STATES_EVENT_ID_INDEX_SCHEMA_VERSION = 28 LEGACY_STATES_EVENT_FOREIGN_KEYS_FIXED_SCHEMA_VERSION = 43 diff --git a/homeassistant/components/recorder/core.py b/homeassistant/components/recorder/core.py index 62afa0e7b04..7b8043b9201 100644 --- a/homeassistant/components/recorder/core.py +++ b/homeassistant/components/recorder/core.py @@ -79,7 +79,13 @@ from .db_schema import ( StatisticsShortTerm, ) from .executor import DBInterruptibleThreadPoolExecutor -from .models import DatabaseEngine, StatisticData, StatisticMetaData, UnsupportedDialect +from .models import ( + DatabaseEngine, + StatisticData, + StatisticMeanType, + StatisticMetaData, + UnsupportedDialect, +) from .pool import POOL_SIZE, MutexPool, RecorderPool from .table_managers.event_data import EventDataManager from .table_managers.event_types import EventTypeManager @@ -611,6 +617,17 @@ class Recorder(threading.Thread): table: type[Statistics | StatisticsShortTerm], ) -> None: """Schedule import of statistics.""" + if "mean_type" not in metadata: + # Backwards compatibility for old metadata format + # Can be removed after 2026.4 + metadata["mean_type"] = ( # type: ignore[unreachable] + StatisticMeanType.ARITHMETIC + if metadata.get("has_mean") + else StatisticMeanType.NONE + ) + # Remove deprecated has_mean as it's not needed anymore in core + metadata.pop("has_mean", None) + self.queue_task(ImportStatisticsTask(metadata, stats, table)) @callback diff --git a/homeassistant/components/recorder/db_schema.py b/homeassistant/components/recorder/db_schema.py index bc8fcd1310e..6566cadf64c 100644 --- a/homeassistant/components/recorder/db_schema.py +++ b/homeassistant/components/recorder/db_schema.py @@ -58,6 +58,7 @@ from .const import ALL_DOMAIN_EXCLUDE_ATTRS, SupportedDialect from .models import ( StatisticData, StatisticDataTimestamp, + StatisticMeanType, StatisticMetaData, bytes_to_ulid_or_none, bytes_to_uuid_hex_or_none, @@ -77,7 +78,7 @@ class LegacyBase(DeclarativeBase): """Base class for tables, used for schema migration.""" -SCHEMA_VERSION = 48 +SCHEMA_VERSION = 50 _LOGGER = logging.getLogger(__name__) @@ -719,6 +720,7 @@ class StatisticsBase: start: Mapped[datetime | None] = mapped_column(UNUSED_LEGACY_DATETIME_COLUMN) start_ts: Mapped[float | None] = mapped_column(TIMESTAMP_TYPE, index=True) mean: Mapped[float | None] = mapped_column(DOUBLE_TYPE) + mean_weight: Mapped[float | None] = mapped_column(DOUBLE_TYPE) min: Mapped[float | None] = mapped_column(DOUBLE_TYPE) max: Mapped[float | None] = mapped_column(DOUBLE_TYPE) last_reset: Mapped[datetime | None] = mapped_column(UNUSED_LEGACY_DATETIME_COLUMN) @@ -740,6 +742,7 @@ class StatisticsBase: start=None, start_ts=stats["start"].timestamp(), mean=stats.get("mean"), + mean_weight=stats.get("mean_weight"), min=stats.get("min"), max=stats.get("max"), last_reset=None, @@ -763,6 +766,7 @@ class StatisticsBase: start=None, start_ts=stats["start_ts"], mean=stats.get("mean"), + mean_weight=stats.get("mean_weight"), min=stats.get("min"), max=stats.get("max"), last_reset=None, @@ -848,6 +852,9 @@ class _StatisticsMeta: has_mean: Mapped[bool | None] = mapped_column(Boolean) has_sum: Mapped[bool | None] = mapped_column(Boolean) name: Mapped[str | None] = mapped_column(String(255)) + mean_type: Mapped[StatisticMeanType] = mapped_column( + SmallInteger, nullable=False, default=StatisticMeanType.NONE.value + ) # See StatisticMeanType @staticmethod def from_meta(meta: StatisticMetaData) -> StatisticsMeta: diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index c5eea0f7088..58af15c2aa7 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -81,7 +81,7 @@ from .db_schema import ( StatisticsRuns, StatisticsShortTerm, ) -from .models import process_timestamp +from .models import StatisticMeanType, process_timestamp from .models.time import datetime_to_timestamp_or_none from .queries import ( batch_cleanup_entity_ids, @@ -144,24 +144,32 @@ class _ColumnTypesForDialect: big_int_type: str timestamp_type: str context_bin_type: str + small_int_type: str + double_type: str _MYSQL_COLUMN_TYPES = _ColumnTypesForDialect( big_int_type="INTEGER(20)", timestamp_type=DOUBLE_PRECISION_TYPE_SQL, context_bin_type=f"BLOB({CONTEXT_ID_BIN_MAX_LENGTH})", + small_int_type="SMALLINT", + double_type=DOUBLE_PRECISION_TYPE_SQL, ) _POSTGRESQL_COLUMN_TYPES = _ColumnTypesForDialect( big_int_type="INTEGER", timestamp_type=DOUBLE_PRECISION_TYPE_SQL, context_bin_type="BYTEA", + small_int_type="SMALLINT", + double_type=DOUBLE_PRECISION_TYPE_SQL, ) _SQLITE_COLUMN_TYPES = _ColumnTypesForDialect( big_int_type="INTEGER", timestamp_type="FLOAT", context_bin_type="BLOB", + small_int_type="INTEGER", + double_type="FLOAT", ) _COLUMN_TYPES_FOR_DIALECT: dict[SupportedDialect | None, _ColumnTypesForDialect] = { @@ -1993,6 +2001,42 @@ class _SchemaVersion48Migrator(_SchemaVersionMigrator, target_version=48): _migrate_columns_to_timestamp(self.instance, self.session_maker, self.engine) +class _SchemaVersion49Migrator(_SchemaVersionMigrator, target_version=49): + def _apply_update(self) -> None: + """Version specific update method.""" + _add_columns( + self.session_maker, + "statistics_meta", + [ + f"mean_type {self.column_types.small_int_type} NOT NULL DEFAULT {StatisticMeanType.NONE.value}" + ], + ) + + for table in ("statistics", "statistics_short_term"): + _add_columns( + self.session_maker, + table, + [f"mean_weight {self.column_types.double_type}"], + ) + + with session_scope(session=self.session_maker()) as session: + connection = session.connection() + connection.execute( + text( + "UPDATE statistics_meta SET mean_type=:mean_type WHERE has_mean=true" + ), + {"mean_type": StatisticMeanType.ARITHMETIC.value}, + ) + + +class _SchemaVersion50Migrator(_SchemaVersionMigrator, target_version=50): + def _apply_update(self) -> None: + """Version specific update method.""" + with session_scope(session=self.session_maker()) as session: + connection = session.connection() + connection.execute(text("UPDATE statistics_meta SET has_mean=NULL")) + + def _migrate_statistics_columns_to_timestamp_removing_duplicates( hass: HomeAssistant, instance: Recorder, diff --git a/homeassistant/components/recorder/models/__init__.py b/homeassistant/components/recorder/models/__init__.py index ea7a6c86854..8f76982a900 100644 --- a/homeassistant/components/recorder/models/__init__.py +++ b/homeassistant/components/recorder/models/__init__.py @@ -17,6 +17,7 @@ from .statistics import ( RollingWindowStatisticPeriod, StatisticData, StatisticDataTimestamp, + StatisticMeanType, StatisticMetaData, StatisticPeriod, StatisticResult, @@ -37,6 +38,7 @@ __all__ = [ "RollingWindowStatisticPeriod", "StatisticData", "StatisticDataTimestamp", + "StatisticMeanType", "StatisticMetaData", "StatisticPeriod", "StatisticResult", diff --git a/homeassistant/components/recorder/models/statistics.py b/homeassistant/components/recorder/models/statistics.py index ad4d82067c4..08da12d6b17 100644 --- a/homeassistant/components/recorder/models/statistics.py +++ b/homeassistant/components/recorder/models/statistics.py @@ -3,7 +3,8 @@ from __future__ import annotations from datetime import datetime, timedelta -from typing import Literal, TypedDict +from enum import IntEnum +from typing import Literal, NotRequired, TypedDict class StatisticResult(TypedDict): @@ -36,6 +37,7 @@ class StatisticMixIn(TypedDict, total=False): min: float max: float mean: float + mean_weight: float class StatisticData(StatisticDataBase, StatisticMixIn, total=False): @@ -50,10 +52,20 @@ class StatisticDataTimestamp(StatisticDataTimestampBase, StatisticMixIn, total=F last_reset_ts: float | None +class StatisticMeanType(IntEnum): + """Statistic mean type.""" + + NONE = 0 + ARITHMETIC = 1 + CIRCULAR = 2 + + class StatisticMetaData(TypedDict): """Statistic meta data class.""" - has_mean: bool + # has_mean is deprecated, use mean_type instead. has_mean will be removed in 2026.4 + has_mean: NotRequired[bool] + mean_type: StatisticMeanType has_sum: bool name: str | None source: str diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index e26a69c0db9..2507a66899e 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -9,12 +9,23 @@ from datetime import datetime, timedelta from functools import lru_cache, partial from itertools import chain, groupby import logging +import math from operator import itemgetter import re from time import time as time_time -from typing import TYPE_CHECKING, Any, Literal, TypedDict, cast +from typing import TYPE_CHECKING, Any, Literal, Required, TypedDict, cast -from sqlalchemy import Select, and_, bindparam, func, lambda_stmt, select, text +from sqlalchemy import ( + Label, + Select, + and_, + bindparam, + case, + func, + lambda_stmt, + select, + text, +) from sqlalchemy.engine.row import Row from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.orm.session import Session @@ -29,6 +40,7 @@ from homeassistant.helpers.singleton import singleton from homeassistant.helpers.typing import UNDEFINED, UndefinedType from homeassistant.util import dt as dt_util from homeassistant.util.collection import chunked_or_all +from homeassistant.util.enum import try_parse_enum from homeassistant.util.unit_conversion import ( AreaConverter, BaseUnitConverter, @@ -74,6 +86,7 @@ from .db_schema import ( from .models import ( StatisticData, StatisticDataTimestamp, + StatisticMeanType, StatisticMetaData, StatisticResult, datetime_to_timestamp_or_none, @@ -113,11 +126,54 @@ QUERY_STATISTICS_SHORT_TERM = ( StatisticsShortTerm.sum, ) + +def query_circular_mean(table: type[StatisticsBase]) -> tuple[Label, Label]: + """Return the sqlalchemy function for circular mean and the mean_weight. + + The result must be modulo 360 to normalize the result [0, 360]. + """ + # Postgres doesn't support modulo for double precision and + # the other dbs return the remainder instead of the modulo + # meaning negative values are possible. For these reason + # we need to normalize the result to be in the range [0, 360) + # in Python. + # https://en.wikipedia.org/wiki/Circular_mean + radians = func.radians(table.mean) + weight = func.sqrt( + func.power(func.sum(func.sin(radians) * table.mean_weight), 2) + + func.power(func.sum(func.cos(radians) * table.mean_weight), 2) + ) + return ( + func.degrees( + func.atan2(func.sum(func.sin(radians)), func.sum(func.cos(radians))) + ).label("mean"), + weight.label("mean_weight"), + ) + + QUERY_STATISTICS_SUMMARY_MEAN = ( StatisticsShortTerm.metadata_id, - func.avg(StatisticsShortTerm.mean), func.min(StatisticsShortTerm.min), func.max(StatisticsShortTerm.max), + case( + ( + StatisticsMeta.mean_type == StatisticMeanType.ARITHMETIC, + func.avg(StatisticsShortTerm.mean), + ), + ( + StatisticsMeta.mean_type == StatisticMeanType.CIRCULAR, + query_circular_mean(StatisticsShortTerm)[0], + ), + else_=None, + ), + case( + ( + StatisticsMeta.mean_type == StatisticMeanType.CIRCULAR, + query_circular_mean(StatisticsShortTerm)[1], + ), + else_=None, + ), + StatisticsMeta.mean_type, ) QUERY_STATISTICS_SUMMARY_SUM = ( @@ -180,6 +236,24 @@ def mean(values: list[float]) -> float | None: return sum(values) / len(values) +DEG_TO_RAD = math.pi / 180 +RAD_TO_DEG = 180 / math.pi + + +def weighted_circular_mean(values: Iterable[tuple[float, float]]) -> float: + """Return the weighted circular mean of the values.""" + sin_sum = sum(math.sin(x * DEG_TO_RAD) * weight for x, weight in values) + cos_sum = sum(math.cos(x * DEG_TO_RAD) * weight for x, weight in values) + return (RAD_TO_DEG * math.atan2(sin_sum, cos_sum)) % 360 + + +def circular_mean(values: list[float]) -> float: + """Return the circular mean of the values.""" + sin_sum = sum(math.sin(x * DEG_TO_RAD) for x in values) + cos_sum = sum(math.cos(x * DEG_TO_RAD) for x in values) + return (RAD_TO_DEG * math.atan2(sin_sum, cos_sum)) % 360 + + _LOGGER = logging.getLogger(__name__) @@ -372,11 +446,19 @@ def _compile_hourly_statistics_summary_mean_stmt( start_time_ts: float, end_time_ts: float ) -> StatementLambdaElement: """Generate the summary mean statement for hourly statistics.""" + # Due the fact that we support different mean type (See StatisticMeanType) + # we need to join here with the StatisticsMeta table to get the mean type + # and then use a case statement to compute the mean based on the mean type. + # As we use the StatisticsMeta.mean_type in the select case statement we need + # to group by it as well. return lambda_stmt( lambda: select(*QUERY_STATISTICS_SUMMARY_MEAN) .filter(StatisticsShortTerm.start_ts >= start_time_ts) .filter(StatisticsShortTerm.start_ts < end_time_ts) - .group_by(StatisticsShortTerm.metadata_id) + .join( + StatisticsMeta, and_(StatisticsShortTerm.metadata_id == StatisticsMeta.id) + ) + .group_by(StatisticsShortTerm.metadata_id, StatisticsMeta.mean_type) .order_by(StatisticsShortTerm.metadata_id) ) @@ -418,10 +500,17 @@ def _compile_hourly_statistics(session: Session, start: datetime) -> None: if stats: for stat in stats: - metadata_id, _mean, _min, _max = stat + metadata_id, _min, _max, _mean, _mean_weight, _mean_type = stat + if ( + try_parse_enum(StatisticMeanType, _mean_type) + is StatisticMeanType.CIRCULAR + ): + # Normalize the circular mean to be in the range [0, 360) + _mean = _mean % 360 summary[metadata_id] = { "start_ts": start_time_ts, "mean": _mean, + "mean_weight": _mean_weight, "min": _min, "max": _max, } @@ -827,7 +916,7 @@ def _statistic_by_id_from_metadata( "display_unit_of_measurement": get_display_unit( hass, meta["statistic_id"], meta["unit_of_measurement"] ), - "has_mean": meta["has_mean"], + "mean_type": meta["mean_type"], "has_sum": meta["has_sum"], "name": meta["name"], "source": meta["source"], @@ -846,7 +935,9 @@ def _flatten_list_statistic_ids_metadata_result( { "statistic_id": _id, "display_unit_of_measurement": info["display_unit_of_measurement"], - "has_mean": info["has_mean"], + "has_mean": info["mean_type"] + == StatisticMeanType.ARITHMETIC, # Can be removed with 2026.4 + "mean_type": info["mean_type"], "has_sum": info["has_sum"], "name": info.get("name"), "source": info["source"], @@ -901,7 +992,7 @@ def list_statistic_ids( continue result[key] = { "display_unit_of_measurement": meta["unit_of_measurement"], - "has_mean": meta["has_mean"], + "mean_type": meta["mean_type"], "has_sum": meta["has_sum"], "name": meta["name"], "source": meta["source"], @@ -919,6 +1010,7 @@ def _reduce_statistics( period_start_end: Callable[[float], tuple[float, float]], period: timedelta, types: set[Literal["last_reset", "max", "mean", "min", "state", "sum"]], + metadata: dict[str, tuple[int, StatisticMetaData]], ) -> dict[str, list[StatisticsRow]]: """Reduce hourly statistics to daily or monthly statistics.""" result: dict[str, list[StatisticsRow]] = defaultdict(list) @@ -946,7 +1038,13 @@ def _reduce_statistics( "end": end, } if _want_mean: - row["mean"] = mean(mean_values) if mean_values else None + row["mean"] = None + if mean_values: + match metadata[statistic_id][1]["mean_type"]: + case StatisticMeanType.ARITHMETIC: + row["mean"] = mean(mean_values) + case StatisticMeanType.CIRCULAR: + row["mean"] = circular_mean(mean_values) mean_values.clear() if _want_min: row["min"] = min(min_values) if min_values else None @@ -963,8 +1061,9 @@ def _reduce_statistics( result[statistic_id].append(row) if _want_max and (_max := statistic.get("max")) is not None: max_values.append(_max) - if _want_mean and (_mean := statistic.get("mean")) is not None: - mean_values.append(_mean) + if _want_mean: + if (_mean := statistic.get("mean")) is not None: + mean_values.append(_mean) if _want_min and (_min := statistic.get("min")) is not None: min_values.append(_min) prev_stat = statistic @@ -1011,11 +1110,12 @@ def reduce_day_ts_factory() -> tuple[ def _reduce_statistics_per_day( stats: dict[str, list[StatisticsRow]], types: set[Literal["last_reset", "max", "mean", "min", "state", "sum"]], + metadata: dict[str, tuple[int, StatisticMetaData]], ) -> dict[str, list[StatisticsRow]]: """Reduce hourly statistics to daily statistics.""" _same_day_ts, _day_start_end_ts = reduce_day_ts_factory() return _reduce_statistics( - stats, _same_day_ts, _day_start_end_ts, timedelta(days=1), types + stats, _same_day_ts, _day_start_end_ts, timedelta(days=1), types, metadata ) @@ -1059,11 +1159,12 @@ def reduce_week_ts_factory() -> tuple[ def _reduce_statistics_per_week( stats: dict[str, list[StatisticsRow]], types: set[Literal["last_reset", "max", "mean", "min", "state", "sum"]], + metadata: dict[str, tuple[int, StatisticMetaData]], ) -> dict[str, list[StatisticsRow]]: """Reduce hourly statistics to weekly statistics.""" _same_week_ts, _week_start_end_ts = reduce_week_ts_factory() return _reduce_statistics( - stats, _same_week_ts, _week_start_end_ts, timedelta(days=7), types + stats, _same_week_ts, _week_start_end_ts, timedelta(days=7), types, metadata ) @@ -1112,11 +1213,12 @@ def reduce_month_ts_factory() -> tuple[ def _reduce_statistics_per_month( stats: dict[str, list[StatisticsRow]], types: set[Literal["last_reset", "max", "mean", "min", "state", "sum"]], + metadata: dict[str, tuple[int, StatisticMetaData]], ) -> dict[str, list[StatisticsRow]]: """Reduce hourly statistics to monthly statistics.""" _same_month_ts, _month_start_end_ts = reduce_month_ts_factory() return _reduce_statistics( - stats, _same_month_ts, _month_start_end_ts, timedelta(days=31), types + stats, _same_month_ts, _month_start_end_ts, timedelta(days=31), types, metadata ) @@ -1160,27 +1262,41 @@ def _generate_max_mean_min_statistic_in_sub_period_stmt( return stmt +class _MaxMinMeanStatisticSubPeriod(TypedDict, total=False): + max: float + mean_acc: float + min: float + duration: float + circular_means: Required[list[tuple[float, float]]] + + def _get_max_mean_min_statistic_in_sub_period( session: Session, - result: dict[str, float], + result: _MaxMinMeanStatisticSubPeriod, start_time: datetime | None, end_time: datetime | None, table: type[StatisticsBase], types: set[Literal["max", "mean", "min", "change"]], - metadata_id: int, + metadata: tuple[int, StatisticMetaData], ) -> None: """Return max, mean and min during the period.""" # Calculate max, mean, min + mean_type = metadata[1]["mean_type"] columns = select() if "max" in types: columns = columns.add_columns(func.max(table.max)) if "mean" in types: - columns = columns.add_columns(func.avg(table.mean)) - columns = columns.add_columns(func.count(table.mean)) + match mean_type: + case StatisticMeanType.ARITHMETIC: + columns = columns.add_columns(func.avg(table.mean)) + columns = columns.add_columns(func.count(table.mean)) + case StatisticMeanType.CIRCULAR: + columns = columns.add_columns(*query_circular_mean(table)) if "min" in types: columns = columns.add_columns(func.min(table.min)) + stmt = _generate_max_mean_min_statistic_in_sub_period_stmt( - columns, start_time, end_time, table, metadata_id + columns, start_time, end_time, table, metadata[0] ) stats = cast(Sequence[Row[Any]], execute_stmt_lambda_element(session, stmt)) if not stats: @@ -1188,11 +1304,21 @@ def _get_max_mean_min_statistic_in_sub_period( if "max" in types and (new_max := stats[0].max) is not None: old_max = result.get("max") result["max"] = max(new_max, old_max) if old_max is not None else new_max - if "mean" in types and stats[0].avg is not None: + if "mean" in types: # https://github.com/sqlalchemy/sqlalchemy/issues/9127 - duration = stats[0].count * table.duration.total_seconds() # type: ignore[operator] - result["duration"] = result.get("duration", 0.0) + duration - result["mean_acc"] = result.get("mean_acc", 0.0) + stats[0].avg * duration + match mean_type: + case StatisticMeanType.ARITHMETIC: + duration = stats[0].count * table.duration.total_seconds() # type: ignore[operator] + if stats[0].avg is not None: + result["duration"] = result.get("duration", 0.0) + duration + result["mean_acc"] = ( + result.get("mean_acc", 0.0) + stats[0].avg * duration + ) + case StatisticMeanType.CIRCULAR: + if (new_circular_mean := stats[0].mean) is not None and ( + weight := stats[0].mean_weight + ) is not None: + result["circular_means"].append((new_circular_mean, weight)) if "min" in types and (new_min := stats[0].min) is not None: old_min = result.get("min") result["min"] = min(new_min, old_min) if old_min is not None else new_min @@ -1207,15 +1333,15 @@ def _get_max_mean_min_statistic( tail_start_time: datetime | None, tail_end_time: datetime | None, tail_only: bool, - metadata_id: int, + metadata: tuple[int, StatisticMetaData], types: set[Literal["max", "mean", "min", "change"]], ) -> dict[str, float | None]: """Return max, mean and min during the period. - The mean is a time weighted average, combining hourly and 5-minute statistics if + The mean is time weighted, combining hourly and 5-minute statistics if necessary. """ - max_mean_min: dict[str, float] = {} + max_mean_min = _MaxMinMeanStatisticSubPeriod(circular_means=[]) result: dict[str, float | None] = {} if tail_start_time is not None: @@ -1227,7 +1353,7 @@ def _get_max_mean_min_statistic( tail_end_time, StatisticsShortTerm, types, - metadata_id, + metadata, ) if not tail_only: @@ -1238,7 +1364,7 @@ def _get_max_mean_min_statistic( main_end_time, Statistics, types, - metadata_id, + metadata, ) if head_start_time is not None: @@ -1249,16 +1375,23 @@ def _get_max_mean_min_statistic( head_end_time, StatisticsShortTerm, types, - metadata_id, + metadata, ) if "max" in types: result["max"] = max_mean_min.get("max") if "mean" in types: - if "mean_acc" not in max_mean_min: - result["mean"] = None - else: - result["mean"] = max_mean_min["mean_acc"] / max_mean_min["duration"] + mean_value = None + match metadata[1]["mean_type"]: + case StatisticMeanType.CIRCULAR: + if circular_means := max_mean_min["circular_means"]: + mean_value = weighted_circular_mean(circular_means) + case StatisticMeanType.ARITHMETIC: + if (mean_value := max_mean_min.get("mean_acc")) is not None and ( + duration := max_mean_min.get("duration") + ) is not None: + mean_value = mean_value / duration + result["mean"] = mean_value if "min" in types: result["min"] = max_mean_min.get("min") return result @@ -1559,7 +1692,7 @@ def statistic_during_period( tail_start_time, tail_end_time, tail_only, - metadata_id, + metadata, types, ) @@ -1642,7 +1775,7 @@ def _extract_metadata_and_discard_impossible_columns( has_sum = False for metadata_id, stats_metadata in metadata.values(): metadata_ids.append(metadata_id) - has_mean |= stats_metadata["has_mean"] + has_mean |= stats_metadata["mean_type"] is not StatisticMeanType.NONE has_sum |= stats_metadata["has_sum"] if not has_mean: types.discard("mean") @@ -1798,13 +1931,13 @@ def _statistics_during_period_with_session( ) if period == "day": - result = _reduce_statistics_per_day(result, types) + result = _reduce_statistics_per_day(result, types, metadata) if period == "week": - result = _reduce_statistics_per_week(result, types) + result = _reduce_statistics_per_week(result, types, metadata) if period == "month": - result = _reduce_statistics_per_month(result, types) + result = _reduce_statistics_per_month(result, types, metadata) if "change" in _types: _augment_result_with_change( diff --git a/homeassistant/components/recorder/table_managers/statistics_meta.py b/homeassistant/components/recorder/table_managers/statistics_meta.py index 77fc34518db..634e9565c12 100644 --- a/homeassistant/components/recorder/table_managers/statistics_meta.py +++ b/homeassistant/components/recorder/table_managers/statistics_meta.py @@ -4,16 +4,18 @@ from __future__ import annotations import logging import threading -from typing import TYPE_CHECKING, Final, Literal +from typing import TYPE_CHECKING, Any, Final, Literal from lru import LRU from sqlalchemy import lambda_stmt, select +from sqlalchemy.orm import InstrumentedAttribute from sqlalchemy.orm.session import Session from sqlalchemy.sql.expression import true from sqlalchemy.sql.lambdas import StatementLambdaElement +from ..const import CIRCULAR_MEAN_SCHEMA_VERSION from ..db_schema import StatisticsMeta -from ..models import StatisticMetaData +from ..models import StatisticMeanType, StatisticMetaData from ..util import execute_stmt_lambda_element if TYPE_CHECKING: @@ -28,7 +30,6 @@ QUERY_STATISTIC_META = ( StatisticsMeta.statistic_id, StatisticsMeta.source, StatisticsMeta.unit_of_measurement, - StatisticsMeta.has_mean, StatisticsMeta.has_sum, StatisticsMeta.name, ) @@ -37,24 +38,38 @@ INDEX_ID: Final = 0 INDEX_STATISTIC_ID: Final = 1 INDEX_SOURCE: Final = 2 INDEX_UNIT_OF_MEASUREMENT: Final = 3 -INDEX_HAS_MEAN: Final = 4 -INDEX_HAS_SUM: Final = 5 -INDEX_NAME: Final = 6 +INDEX_HAS_SUM: Final = 4 +INDEX_NAME: Final = 5 +INDEX_MEAN_TYPE: Final = 6 def _generate_get_metadata_stmt( statistic_ids: set[str] | None = None, statistic_type: Literal["mean", "sum"] | None = None, statistic_source: str | None = None, + schema_version: int = 0, ) -> StatementLambdaElement: - """Generate a statement to fetch metadata.""" - stmt = lambda_stmt(lambda: select(*QUERY_STATISTIC_META)) + """Generate a statement to fetch metadata with the passed filters. + + Depending on the schema version, either mean_type (added in version 49) or has_mean column is used. + """ + columns: list[InstrumentedAttribute[Any]] = list(QUERY_STATISTIC_META) + if schema_version >= CIRCULAR_MEAN_SCHEMA_VERSION: + columns.append(StatisticsMeta.mean_type) + else: + columns.append(StatisticsMeta.has_mean) + stmt = lambda_stmt(lambda: select(*columns)) if statistic_ids: stmt += lambda q: q.where(StatisticsMeta.statistic_id.in_(statistic_ids)) if statistic_source is not None: stmt += lambda q: q.where(StatisticsMeta.source == statistic_source) if statistic_type == "mean": - stmt += lambda q: q.where(StatisticsMeta.has_mean == true()) + if schema_version >= CIRCULAR_MEAN_SCHEMA_VERSION: + stmt += lambda q: q.where( + StatisticsMeta.mean_type != StatisticMeanType.NONE + ) + else: + stmt += lambda q: q.where(StatisticsMeta.has_mean == true()) elif statistic_type == "sum": stmt += lambda q: q.where(StatisticsMeta.has_sum == true()) return stmt @@ -100,14 +115,34 @@ class StatisticsMetaManager: for row in execute_stmt_lambda_element( session, _generate_get_metadata_stmt( - statistic_ids, statistic_type, statistic_source + statistic_ids, + statistic_type, + statistic_source, + self.recorder.schema_version, ), orm_rows=False, ): statistic_id = row[INDEX_STATISTIC_ID] row_id = row[INDEX_ID] + if self.recorder.schema_version >= CIRCULAR_MEAN_SCHEMA_VERSION: + try: + mean_type = StatisticMeanType(row[INDEX_MEAN_TYPE]) + except ValueError: + _LOGGER.warning( + "Invalid mean type found for statistic_id: %s, mean_type: %s. Skipping", + statistic_id, + row[INDEX_MEAN_TYPE], + ) + continue + else: + mean_type = ( + StatisticMeanType.ARITHMETIC + if row[INDEX_MEAN_TYPE] + else StatisticMeanType.NONE + ) meta = { - "has_mean": row[INDEX_HAS_MEAN], + "has_mean": mean_type is StatisticMeanType.ARITHMETIC, + "mean_type": mean_type, "has_sum": row[INDEX_HAS_SUM], "name": row[INDEX_NAME], "source": row[INDEX_SOURCE], @@ -157,9 +192,18 @@ class StatisticsMetaManager: This call is not thread-safe and must be called from the recorder thread. """ + if "mean_type" not in new_metadata: + # To maintain backward compatibility after adding 'mean_type' in schema version 49, + # we must still check for its presence. Even though type hints suggest it should always exist, + # custom integrations might omit it, so we need to guard against that. + new_metadata["mean_type"] = ( # type: ignore[unreachable] + StatisticMeanType.ARITHMETIC + if new_metadata["has_mean"] + else StatisticMeanType.NONE + ) metadata_id, old_metadata = old_metadata_dict[statistic_id] if not ( - old_metadata["has_mean"] != new_metadata["has_mean"] + old_metadata["mean_type"] != new_metadata["mean_type"] or old_metadata["has_sum"] != new_metadata["has_sum"] or old_metadata["name"] != new_metadata["name"] or old_metadata["unit_of_measurement"] @@ -170,7 +214,7 @@ class StatisticsMetaManager: self._assert_in_recorder_thread() session.query(StatisticsMeta).filter_by(statistic_id=statistic_id).update( { - StatisticsMeta.has_mean: new_metadata["has_mean"], + StatisticsMeta.mean_type: new_metadata["mean_type"], StatisticsMeta.has_sum: new_metadata["has_sum"], StatisticsMeta.name: new_metadata["name"], StatisticsMeta.unit_of_measurement: new_metadata["unit_of_measurement"], diff --git a/homeassistant/components/recorder/websocket_api.py b/homeassistant/components/recorder/websocket_api.py index d23ecab3dac..f4058943971 100644 --- a/homeassistant/components/recorder/websocket_api.py +++ b/homeassistant/components/recorder/websocket_api.py @@ -37,7 +37,7 @@ from homeassistant.util.unit_conversion import ( VolumeFlowRateConverter, ) -from .models import StatisticPeriod +from .models import StatisticMeanType, StatisticPeriod from .statistics import ( STATISTIC_UNIT_TO_UNIT_CONVERTER, async_add_external_statistics, @@ -532,6 +532,10 @@ def ws_import_statistics( ) -> None: """Import statistics.""" metadata = msg["metadata"] + # The WS command will be changed in a follow up PR + metadata["mean_type"] = ( + StatisticMeanType.ARITHMETIC if metadata["has_mean"] else StatisticMeanType.NONE + ) stats = msg["stats"] if valid_entity_id(metadata["statistic_id"]): diff --git a/homeassistant/components/sensor/const.py b/homeassistant/components/sensor/const.py index e1f7dd13d93..916bd5cbd40 100644 --- a/homeassistant/components/sensor/const.py +++ b/homeassistant/components/sensor/const.py @@ -491,6 +491,9 @@ class SensorStateClass(StrEnum): MEASUREMENT = "measurement" """The state represents a measurement in present time.""" + MEASUREMENT_ANGLE = "measurement_angle" + """The state represents a angle measurement in present time. Currently only degrees are supported.""" + TOTAL = "total" """The state represents a total amount. @@ -693,6 +696,6 @@ DEVICE_CLASS_STATE_CLASSES: dict[SensorDeviceClass, set[SensorStateClass]] = { SensorStateClass.TOTAL, SensorStateClass.TOTAL_INCREASING, }, - SensorDeviceClass.WIND_DIRECTION: set(), + SensorDeviceClass.WIND_DIRECTION: {SensorStateClass.MEASUREMENT_ANGLE}, SensorDeviceClass.WIND_SPEED: {SensorStateClass.MEASUREMENT}, } diff --git a/homeassistant/components/sensor/recorder.py b/homeassistant/components/sensor/recorder.py index ae64709ad36..cb80fa7d2ce 100644 --- a/homeassistant/components/sensor/recorder.py +++ b/homeassistant/components/sensor/recorder.py @@ -5,6 +5,7 @@ from __future__ import annotations from collections import defaultdict from collections.abc import Callable, Iterable from contextlib import suppress +from dataclasses import dataclass import datetime import itertools import logging @@ -21,6 +22,7 @@ from homeassistant.components.recorder import ( ) from homeassistant.components.recorder.models import ( StatisticData, + StatisticMeanType, StatisticMetaData, StatisticResult, ) @@ -52,10 +54,22 @@ from .const import ( _LOGGER = logging.getLogger(__name__) + +@dataclass +class _StatisticsConfig: + types: set[str] + mean_type: StatisticMeanType = StatisticMeanType.NONE + + DEFAULT_STATISTICS = { - SensorStateClass.MEASUREMENT: {"mean", "min", "max"}, - SensorStateClass.TOTAL: {"sum"}, - SensorStateClass.TOTAL_INCREASING: {"sum"}, + SensorStateClass.MEASUREMENT: _StatisticsConfig( + {"mean", "min", "max"}, StatisticMeanType.ARITHMETIC + ), + SensorStateClass.MEASUREMENT_ANGLE: _StatisticsConfig( + {"mean"}, StatisticMeanType.CIRCULAR + ), + SensorStateClass.TOTAL: _StatisticsConfig({"sum"}), + SensorStateClass.TOTAL_INCREASING: _StatisticsConfig({"sum"}), } EQUIVALENT_UNITS = { @@ -76,10 +90,15 @@ WARN_NEGATIVE: HassKey[set[str]] = HassKey(f"{DOMAIN}_warn_total_increasing_nega # Keep track of entities for which a warning about unsupported unit has been logged WARN_UNSUPPORTED_UNIT: HassKey[set[str]] = HassKey(f"{DOMAIN}_warn_unsupported_unit") WARN_UNSTABLE_UNIT: HassKey[set[str]] = HassKey(f"{DOMAIN}_warn_unstable_unit") +# Keep track of entities for which a warning about statistics mean algorithm change has been logged +WARN_STATISTICS_MEAN_CHANGED: HassKey[set[str]] = HassKey( + f"{DOMAIN}_warn_statistics_mean_change" +) # Link to dev statistics where issues around LTS can be fixed LINK_DEV_STATISTICS = "https://my.home-assistant.io/redirect/developer_statistics" STATE_CLASS_REMOVED_ISSUE = "state_class_removed" UNITS_CHANGED_ISSUE = "units_changed" +MEAN_TYPE_CHANGED_ISSUE = "mean_type_changed" def _get_sensor_states(hass: HomeAssistant) -> list[State]: @@ -101,7 +120,7 @@ def _get_sensor_states(hass: HomeAssistant) -> list[State]: ] -def _time_weighted_average( +def _time_weighted_arithmetic_mean( fstates: list[tuple[float, State]], start: datetime.datetime, end: datetime.datetime ) -> float: """Calculate a time weighted average. @@ -139,6 +158,43 @@ def _time_weighted_average( return accumulated / (end - start).total_seconds() +def _time_weighted_circular_mean( + fstates: list[tuple[float, State]], start: datetime.datetime, end: datetime.datetime +) -> float: + """Calculate a time weighted circular mean. + + The circular mean is calculated by weighting the states by duration in seconds between + state changes. + Note: there's no interpolation of values between state changes. + """ + old_fstate: float | None = None + old_start_time: datetime.datetime | None = None + values: list[tuple[float, float]] = [] + + for fstate, state in fstates: + # The recorder will give us the last known state, which may be well + # before the requested start time for the statistics + start_time = max(state.last_updated, start) + if old_start_time is None: + # Adjust start time, if there was no last known state + start = start_time + else: + duration = (start_time - old_start_time).total_seconds() + assert old_fstate is not None + values.append((old_fstate, duration)) + + old_fstate = fstate + old_start_time = start_time + + if old_fstate is not None: + # Add last value weighted by duration until end of the period + assert old_start_time is not None + duration = (end - old_start_time).total_seconds() + values.append((old_fstate, duration)) + + return statistics.weighted_circular_mean(values) + + def _get_units(fstates: list[tuple[float, State]]) -> set[str | None]: """Return a set of all units.""" return {item[1].attributes.get(ATTR_UNIT_OF_MEASUREMENT) for item in fstates} @@ -364,7 +420,7 @@ def reset_detected( return fstate < 0.9 * previous_fstate -def _wanted_statistics(sensor_states: list[State]) -> dict[str, set[str]]: +def _wanted_statistics(sensor_states: list[State]) -> dict[str, _StatisticsConfig]: """Prepare a dict with wanted statistics for entities.""" return { state.entity_id: DEFAULT_STATISTICS[state.attributes[ATTR_STATE_CLASS]] @@ -408,7 +464,9 @@ def compile_statistics( # noqa: C901 wanted_statistics = _wanted_statistics(sensor_states) # Get history between start and end entities_full_history = [ - i.entity_id for i in sensor_states if "sum" in wanted_statistics[i.entity_id] + i.entity_id + for i in sensor_states + if "sum" in wanted_statistics[i.entity_id].types ] history_list: dict[str, list[State]] = {} if entities_full_history: @@ -423,7 +481,7 @@ def compile_statistics( # noqa: C901 entities_significant_history = [ i.entity_id for i in sensor_states - if "sum" not in wanted_statistics[i.entity_id] + if "sum" not in wanted_statistics[i.entity_id].types ] if entities_significant_history: _history_list = history.get_full_significant_states_with_session( @@ -473,7 +531,7 @@ def compile_statistics( # noqa: C901 continue state_class: str = _state.attributes[ATTR_STATE_CLASS] to_process.append((entity_id, statistics_unit, state_class, valid_float_states)) - if "sum" in wanted_statistics[entity_id]: + if "sum" in wanted_statistics[entity_id].types: to_query.add(entity_id) last_stats = statistics.get_latest_short_term_statistics_with_session( @@ -485,6 +543,10 @@ def compile_statistics( # noqa: C901 state_class, valid_float_states, ) in to_process: + mean_type = StatisticMeanType.NONE + if "mean" in wanted_statistics[entity_id].types: + mean_type = wanted_statistics[entity_id].mean_type + # Check metadata if old_metadata := old_metadatas.get(entity_id): if not _equivalent_units( @@ -510,10 +572,34 @@ def compile_statistics( # noqa: C901 ) continue + if ( + mean_type is not StatisticMeanType.NONE + and (old_mean_type := old_metadata[1]["mean_type"]) + is not StatisticMeanType.NONE + and mean_type != old_mean_type + ): + if WARN_STATISTICS_MEAN_CHANGED not in hass.data: + hass.data[WARN_STATISTICS_MEAN_CHANGED] = set() + if entity_id not in hass.data[WARN_STATISTICS_MEAN_CHANGED]: + hass.data[WARN_STATISTICS_MEAN_CHANGED].add(entity_id) + _LOGGER.warning( + ( + "The statistics mean algorithm for %s have changed from %s to %s." + " Generation of long term statistics will be suppressed" + " unless it changes back or go to %s to delete the old" + " statistics" + ), + entity_id, + old_mean_type.name, + mean_type.name, + LINK_DEV_STATISTICS, + ) + continue + # Set meta data meta: StatisticMetaData = { - "has_mean": "mean" in wanted_statistics[entity_id], - "has_sum": "sum" in wanted_statistics[entity_id], + "mean_type": mean_type, + "has_sum": "sum" in wanted_statistics[entity_id].types, "name": None, "source": RECORDER_DOMAIN, "statistic_id": entity_id, @@ -522,19 +608,26 @@ def compile_statistics( # noqa: C901 # Make calculations stat: StatisticData = {"start": start} - if "max" in wanted_statistics[entity_id]: + if "max" in wanted_statistics[entity_id].types: stat["max"] = max( *itertools.islice(zip(*valid_float_states, strict=False), 1) ) - if "min" in wanted_statistics[entity_id]: + if "min" in wanted_statistics[entity_id].types: stat["min"] = min( *itertools.islice(zip(*valid_float_states, strict=False), 1) ) - if "mean" in wanted_statistics[entity_id]: - stat["mean"] = _time_weighted_average(valid_float_states, start, end) + match mean_type: + case StatisticMeanType.ARITHMETIC: + stat["mean"] = _time_weighted_arithmetic_mean( + valid_float_states, start, end + ) + case StatisticMeanType.CIRCULAR: + stat["mean"] = _time_weighted_circular_mean( + valid_float_states, start, end + ) - if "sum" in wanted_statistics[entity_id]: + if "sum" in wanted_statistics[entity_id].types: last_reset = old_last_reset = None new_state = old_state = None _sum = 0.0 @@ -658,18 +751,25 @@ def list_statistic_ids( attributes = state.attributes state_class = attributes[ATTR_STATE_CLASS] provided_statistics = DEFAULT_STATISTICS[state_class] - if statistic_type is not None and statistic_type not in provided_statistics: + if ( + statistic_type is not None + and statistic_type not in provided_statistics.types + ): continue if ( - (has_sum := "sum" in provided_statistics) + (has_sum := "sum" in provided_statistics.types) and ATTR_LAST_RESET not in attributes and state_class == SensorStateClass.MEASUREMENT ): continue + mean_type = StatisticMeanType.NONE + if "mean" in provided_statistics.types: + mean_type = provided_statistics.mean_type + result[entity_id] = { - "has_mean": "mean" in provided_statistics, + "mean_type": mean_type, "has_sum": has_sum, "name": None, "source": RECORDER_DOMAIN, @@ -734,6 +834,23 @@ def _update_issues( }, ) + if ( + (metadata_mean_type := metadata[1]["mean_type"]) is not None + and state_class + and (state_mean_type := DEFAULT_STATISTICS[state_class].mean_type) + != metadata_mean_type + ): + # The mean type has changed and the old statistics are not valid anymore + report_issue( + MEAN_TYPE_CHANGED_ISSUE, + entity_id, + { + "statistic_id": entity_id, + "metadata_mean_type": metadata_mean_type, + "state_mean_type": state_mean_type, + }, + ) + def update_statistics_issues( hass: HomeAssistant, @@ -756,7 +873,11 @@ def update_statistics_issues( issue.domain != DOMAIN or not (issue_data := issue.data) or issue_data.get("issue_type") - not in (STATE_CLASS_REMOVED_ISSUE, UNITS_CHANGED_ISSUE) + not in ( + STATE_CLASS_REMOVED_ISSUE, + UNITS_CHANGED_ISSUE, + MEAN_TYPE_CHANGED_ISSUE, + ) ): continue issues.add(issue.issue_id) diff --git a/homeassistant/components/sensor/strings.json b/homeassistant/components/sensor/strings.json index ae414a178e9..fe6684a9ca4 100644 --- a/homeassistant/components/sensor/strings.json +++ b/homeassistant/components/sensor/strings.json @@ -309,6 +309,10 @@ } }, "issues": { + "mean_type_changed": { + "title": "The mean type of {statistic_id} has changed", + "description": "" + }, "state_class_removed": { "title": "{statistic_id} no longer has a state class", "description": "" diff --git a/homeassistant/components/tibber/coordinator.py b/homeassistant/components/tibber/coordinator.py index 2de9ebd1ec6..e565fdc7dd8 100644 --- a/homeassistant/components/tibber/coordinator.py +++ b/homeassistant/components/tibber/coordinator.py @@ -9,7 +9,11 @@ from typing import cast import tibber from homeassistant.components.recorder import get_instance -from homeassistant.components.recorder.models import StatisticData, StatisticMetaData +from homeassistant.components.recorder.models import ( + StatisticData, + StatisticMeanType, + StatisticMetaData, +) from homeassistant.components.recorder.statistics import ( async_add_external_statistics, get_last_statistics, @@ -159,7 +163,7 @@ class TibberDataCoordinator(DataUpdateCoordinator[None]): ) metadata = StatisticMetaData( - has_mean=False, + mean_type=StatisticMeanType.NONE, has_sum=True, name=f"{home.name} {sensor_type}", source=TIBBER_DOMAIN, diff --git a/tests/components/kitchen_sink/snapshots/test_init.ambr b/tests/components/kitchen_sink/snapshots/test_init.ambr index b91131eb2b0..fe22f19fb7a 100644 --- a/tests/components/kitchen_sink/snapshots/test_init.ambr +++ b/tests/components/kitchen_sink/snapshots/test_init.ambr @@ -48,5 +48,15 @@ 'type': 'no_state', }), ]), + 'sensor.statistics_issues_issue_5': list([ + dict({ + 'data': dict({ + 'metadata_mean_type': 1, + 'state_mean_type': 2, + 'statistic_id': 'sensor.statistics_issues_issue_5', + }), + 'type': 'mean_type_changed', + }), + ]), }) # --- diff --git a/tests/components/kitchen_sink/snapshots/test_sensor.ambr b/tests/components/kitchen_sink/snapshots/test_sensor.ambr index 7b433c40170..6cd9aa2e855 100644 --- a/tests/components/kitchen_sink/snapshots/test_sensor.ambr +++ b/tests/components/kitchen_sink/snapshots/test_sensor.ambr @@ -29,6 +29,20 @@ 'last_updated': , 'state': '1500', }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'wind_direction', + 'friendly_name': 'Statistics issues Issue 5', + 'state_class': , + 'unit_of_measurement': '°', + }), + 'context': , + 'entity_id': 'sensor.statistics_issues_issue_5', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }), StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Statistics issues Issue 1', @@ -99,6 +113,20 @@ 'last_updated': , 'state': '1500', }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'wind_direction', + 'friendly_name': 'Statistics issues Issue 5', + 'state_class': , + 'unit_of_measurement': '°', + }), + 'context': , + 'entity_id': 'sensor.statistics_issues_issue_5', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }), StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Sensor test', diff --git a/tests/components/kitchen_sink/test_init.py b/tests/components/kitchen_sink/test_init.py index 50518f89107..526801aecfa 100644 --- a/tests/components/kitchen_sink/test_init.py +++ b/tests/components/kitchen_sink/test_init.py @@ -11,6 +11,7 @@ import voluptuous as vol from homeassistant.components.kitchen_sink import DOMAIN from homeassistant.components.recorder import get_instance from homeassistant.components.recorder.statistics import ( + StatisticMeanType, async_add_external_statistics, get_last_statistics, list_statistic_ids, @@ -45,6 +46,7 @@ async def test_demo_statistics(hass: HomeAssistant) -> None: assert { "display_unit_of_measurement": "°C", "has_mean": True, + "mean_type": StatisticMeanType.ARITHMETIC, "has_sum": False, "name": "Outdoor temperature", "source": DOMAIN, @@ -55,6 +57,7 @@ async def test_demo_statistics(hass: HomeAssistant) -> None: assert { "display_unit_of_measurement": "kWh", "has_mean": False, + "mean_type": StatisticMeanType.NONE, "has_sum": True, "name": "Energy consumption 1", "source": DOMAIN, diff --git a/tests/components/recorder/auto_repairs/statistics/test_schema.py b/tests/components/recorder/auto_repairs/statistics/test_schema.py index 352a2345052..99d6705e4a4 100644 --- a/tests/components/recorder/auto_repairs/statistics/test_schema.py +++ b/tests/components/recorder/auto_repairs/statistics/test_schema.py @@ -87,6 +87,7 @@ async def test_validate_db_schema_fix_float_issue( "created_ts DOUBLE PRECISION", "start_ts DOUBLE PRECISION", "mean DOUBLE PRECISION", + "mean_weight DOUBLE PRECISION", "min DOUBLE PRECISION", "max DOUBLE PRECISION", "last_reset_ts DOUBLE PRECISION", diff --git a/tests/components/recorder/common.py b/tests/components/recorder/common.py index 28eb097f576..d381c225275 100644 --- a/tests/components/recorder/common.py +++ b/tests/components/recorder/common.py @@ -35,7 +35,8 @@ from homeassistant.components.recorder.db_schema import ( StatesMeta, ) from homeassistant.components.recorder.tasks import RecorderTask, StatisticsTask -from homeassistant.const import UnitOfTemperature +from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass +from homeassistant.const import DEGREE, UnitOfTemperature from homeassistant.core import Event, HomeAssistant, State from homeassistant.helpers import recorder as recorder_helper from homeassistant.util import dt as dt_util @@ -290,6 +291,7 @@ def record_states( sns2 = "sensor.test2" sns3 = "sensor.test3" sns4 = "sensor.test4" + sns5 = "sensor.wind_direction" sns1_attr = { "device_class": "temperature", "state_class": "measurement", @@ -302,6 +304,11 @@ def record_states( } sns3_attr = {"device_class": "temperature"} sns4_attr = {} + sns5_attr = { + "device_class": SensorDeviceClass.WIND_DIRECTION, + "state_class": SensorStateClass.MEASUREMENT_ANGLE, + "unit_of_measurement": DEGREE, + } def set_state(entity_id, state, **kwargs): """Set the state.""" @@ -315,7 +322,7 @@ def record_states( three = two + timedelta(seconds=30 * 5) four = three + timedelta(seconds=14 * 5) - states = {mp: [], sns1: [], sns2: [], sns3: [], sns4: []} + states = {mp: [], sns1: [], sns2: [], sns3: [], sns4: [], sns5: []} with freeze_time(one) as freezer: states[mp].append( set_state(mp, "idle", attributes={"media_title": str(sentinel.mt1)}) @@ -324,6 +331,7 @@ def record_states( states[sns2].append(set_state(sns2, "10", attributes=sns2_attr)) states[sns3].append(set_state(sns3, "10", attributes=sns3_attr)) states[sns4].append(set_state(sns4, "10", attributes=sns4_attr)) + states[sns5].append(set_state(sns5, "10", attributes=sns5_attr)) freezer.move_to(one + timedelta(microseconds=1)) states[mp].append( @@ -335,12 +343,14 @@ def record_states( states[sns2].append(set_state(sns2, "15", attributes=sns2_attr)) states[sns3].append(set_state(sns3, "15", attributes=sns3_attr)) states[sns4].append(set_state(sns4, "15", attributes=sns4_attr)) + states[sns5].append(set_state(sns5, "350", attributes=sns5_attr)) freezer.move_to(three) states[sns1].append(set_state(sns1, "20", attributes=sns1_attr)) states[sns2].append(set_state(sns2, "20", attributes=sns2_attr)) states[sns3].append(set_state(sns3, "20", attributes=sns3_attr)) states[sns4].append(set_state(sns4, "20", attributes=sns4_attr)) + states[sns5].append(set_state(sns5, "5", attributes=sns5_attr)) return zero, four, states diff --git a/tests/components/recorder/db_schema_32.py b/tests/components/recorder/db_schema_32.py index daa7fb6977c..9c19a1c7405 100644 --- a/tests/components/recorder/db_schema_32.py +++ b/tests/components/recorder/db_schema_32.py @@ -583,6 +583,8 @@ class StatisticsBase: last_reset_ts = Column(TIMESTAMP_TYPE) state = Column(DOUBLE_TYPE) sum = Column(DOUBLE_TYPE) + # *** Not originally in v32, only added for tests. Added in v49 + mean_weight = Column(DOUBLE_TYPE) @classmethod def from_stats(cls, metadata_id: int, stats: StatisticData) -> Self: diff --git a/tests/components/recorder/test_migration_from_schema_32.py b/tests/components/recorder/test_migration_from_schema_32.py index 012e227c11a..7fd73aaf735 100644 --- a/tests/components/recorder/test_migration_from_schema_32.py +++ b/tests/components/recorder/test_migration_from_schema_32.py @@ -1538,6 +1538,7 @@ async def test_stats_timestamp_conversion_is_reentrant( "last_reset_ts": one_year_ago.timestamp(), "max": None, "mean": None, + "mean_weight": None, "metadata_id": 1000, "min": None, "start": process_timestamp(one_year_ago).replace(tzinfo=None), @@ -1553,6 +1554,7 @@ async def test_stats_timestamp_conversion_is_reentrant( "last_reset_ts": six_months_ago.timestamp(), "max": None, "mean": None, + "mean_weight": None, "metadata_id": 1000, "min": None, "start": None, @@ -1568,6 +1570,7 @@ async def test_stats_timestamp_conversion_is_reentrant( "last_reset_ts": one_month_ago.timestamp(), "max": None, "mean": None, + "mean_weight": None, "metadata_id": 1000, "min": None, "start": process_timestamp(one_month_ago).replace(tzinfo=None), @@ -1705,6 +1708,7 @@ async def test_stats_timestamp_with_one_by_one( "last_reset_ts": one_year_ago.timestamp(), "max": None, "mean": None, + "mean_weight": None, "metadata_id": 1000, "min": None, "start": None, @@ -1720,6 +1724,7 @@ async def test_stats_timestamp_with_one_by_one( "last_reset_ts": six_months_ago.timestamp(), "max": None, "mean": None, + "mean_weight": None, "metadata_id": 1000, "min": None, "start": None, @@ -1735,6 +1740,7 @@ async def test_stats_timestamp_with_one_by_one( "last_reset_ts": one_month_ago.timestamp(), "max": None, "mean": None, + "mean_weight": None, "metadata_id": 1000, "min": None, "start": None, @@ -1758,6 +1764,7 @@ async def test_stats_timestamp_with_one_by_one( "last_reset_ts": one_year_ago.timestamp(), "max": None, "mean": None, + "mean_weight": None, "metadata_id": 1000, "min": None, "start": None, @@ -1773,6 +1780,7 @@ async def test_stats_timestamp_with_one_by_one( "last_reset_ts": six_months_ago.timestamp(), "max": None, "mean": None, + "mean_weight": None, "metadata_id": 1000, "min": None, "start": None, @@ -1788,6 +1796,7 @@ async def test_stats_timestamp_with_one_by_one( "last_reset_ts": one_month_ago.timestamp(), "max": None, "mean": None, + "mean_weight": None, "metadata_id": 1000, "min": None, "start": None, @@ -1932,6 +1941,7 @@ async def test_stats_timestamp_with_one_by_one_removes_duplicates( "last_reset_ts": one_year_ago.timestamp(), "max": None, "mean": None, + "mean_weight": None, "metadata_id": 1000, "min": None, "start": None, @@ -1947,6 +1957,7 @@ async def test_stats_timestamp_with_one_by_one_removes_duplicates( "last_reset_ts": six_months_ago.timestamp(), "max": None, "mean": None, + "mean_weight": None, "metadata_id": 1000, "min": None, "start": None, @@ -1962,6 +1973,7 @@ async def test_stats_timestamp_with_one_by_one_removes_duplicates( "last_reset_ts": one_month_ago.timestamp(), "max": None, "mean": None, + "mean_weight": None, "metadata_id": 1000, "min": None, "start": None, @@ -1985,6 +1997,7 @@ async def test_stats_timestamp_with_one_by_one_removes_duplicates( "last_reset_ts": six_months_ago.timestamp(), "max": None, "mean": None, + "mean_weight": None, "metadata_id": 1000, "min": None, "start": None, diff --git a/tests/components/recorder/test_statistics.py b/tests/components/recorder/test_statistics.py index ed883c5403e..ed754723426 100644 --- a/tests/components/recorder/test_statistics.py +++ b/tests/components/recorder/test_statistics.py @@ -12,6 +12,7 @@ from homeassistant.components import recorder from homeassistant.components.recorder import Recorder, history, statistics from homeassistant.components.recorder.db_schema import StatisticsShortTerm from homeassistant.components.recorder.models import ( + StatisticMeanType, datetime_to_timestamp_or_none, process_timestamp, ) @@ -123,32 +124,38 @@ async def test_compile_hourly_statistics( stats = get_latest_short_term_statistics_with_session( hass, session, - {"sensor.test1"}, + {"sensor.test1", "sensor.wind_direction"}, {"last_reset", "max", "mean", "min", "state", "sum"}, ) assert stats == {} - for kwargs in ({}, {"statistic_ids": ["sensor.test1"]}): + for kwargs in ({}, {"statistic_ids": ["sensor.test1", "sensor.wind_direction"]}): stats = statistics_during_period(hass, zero, period="5minute", **kwargs) assert stats == {} - stats = get_last_short_term_statistics( - hass, - 0, - "sensor.test1", - True, - {"last_reset", "max", "mean", "min", "state", "sum"}, - ) - assert stats == {} + for sensor in ("sensor.test1", "sensor.wind_direction"): + stats = get_last_short_term_statistics( + hass, + 0, + sensor, + True, + {"last_reset", "max", "mean", "min", "state", "sum"}, + ) + assert stats == {} do_adhoc_statistics(hass, start=zero) do_adhoc_statistics(hass, start=four) await async_wait_recording_done(hass) - metadata = get_metadata(hass, statistic_ids={"sensor.test1", "sensor.test2"}) - assert metadata["sensor.test1"][1]["has_mean"] is True - assert metadata["sensor.test1"][1]["has_sum"] is False - assert metadata["sensor.test2"][1]["has_mean"] is True - assert metadata["sensor.test2"][1]["has_sum"] is False + metadata = get_metadata( + hass, statistic_ids={"sensor.test1", "sensor.test2", "sensor.wind_direction"} + ) + for sensor, mean_type in ( + ("sensor.test1", StatisticMeanType.ARITHMETIC), + ("sensor.test2", StatisticMeanType.ARITHMETIC), + ("sensor.wind_direction", StatisticMeanType.CIRCULAR), + ): + assert metadata[sensor][1]["mean_type"] is mean_type + assert metadata[sensor][1]["has_sum"] is False expected_1 = { "start": process_timestamp(zero).timestamp(), "end": process_timestamp(zero + timedelta(minutes=5)).timestamp(), @@ -168,11 +175,39 @@ async def test_compile_hourly_statistics( expected_stats1 = [expected_1, expected_2] expected_stats2 = [expected_1, expected_2] + expected_stats_wind_direction1 = { + "start": process_timestamp(zero).timestamp(), + "end": process_timestamp(zero + timedelta(minutes=5)).timestamp(), + "mean": pytest.approx(358.6387003873801), + "min": None, + "max": None, + "last_reset": None, + } + expected_stats_wind_direction2 = { + "start": process_timestamp(four).timestamp(), + "end": process_timestamp(four + timedelta(minutes=5)).timestamp(), + "mean": pytest.approx(5), + "min": None, + "max": None, + "last_reset": None, + } + expected_stats_wind_direction = [ + expected_stats_wind_direction1, + expected_stats_wind_direction2, + ] + # Test statistics_during_period stats = statistics_during_period( - hass, zero, period="5minute", statistic_ids={"sensor.test1", "sensor.test2"} + hass, + zero, + period="5minute", + statistic_ids={"sensor.test1", "sensor.test2", "sensor.wind_direction"}, ) - assert stats == {"sensor.test1": expected_stats1, "sensor.test2": expected_stats2} + assert stats == { + "sensor.test1": expected_stats1, + "sensor.test2": expected_stats2, + "sensor.wind_direction": expected_stats_wind_direction, + } # Test statistics_during_period with a far future start and end date future = dt_util.as_utc(dt_util.parse_datetime("2221-11-01 00:00:00")) @@ -181,7 +216,7 @@ async def test_compile_hourly_statistics( future, end_time=future, period="5minute", - statistic_ids={"sensor.test1", "sensor.test2"}, + statistic_ids={"sensor.test1", "sensor.test2", "sensor.wind_direction"}, ) assert stats == {} @@ -191,9 +226,13 @@ async def test_compile_hourly_statistics( zero, end_time=future, period="5minute", - statistic_ids={"sensor.test1", "sensor.test2"}, + statistic_ids={"sensor.test1", "sensor.test2", "sensor.wind_direction"}, ) - assert stats == {"sensor.test1": expected_stats1, "sensor.test2": expected_stats2} + assert stats == { + "sensor.test1": expected_stats1, + "sensor.test2": expected_stats2, + "sensor.wind_direction": expected_stats_wind_direction, + } stats = statistics_during_period( hass, zero, statistic_ids={"sensor.test2"}, period="5minute" @@ -206,32 +245,39 @@ async def test_compile_hourly_statistics( assert stats == {} # Test get_last_short_term_statistics and get_latest_short_term_statistics - stats = get_last_short_term_statistics( - hass, - 0, - "sensor.test1", - True, - {"last_reset", "max", "mean", "min", "state", "sum"}, - ) - assert stats == {} + for sensor, expected in ( + ("sensor.test1", expected_2), + ("sensor.wind_direction", expected_stats_wind_direction2), + ): + stats = get_last_short_term_statistics( + hass, + 0, + sensor, + True, + {"last_reset", "max", "mean", "min", "state", "sum"}, + ) + assert stats == {} - stats = get_last_short_term_statistics( - hass, - 1, - "sensor.test1", - True, - {"last_reset", "max", "mean", "min", "state", "sum"}, - ) - assert stats == {"sensor.test1": [expected_2]} + stats = get_last_short_term_statistics( + hass, + 1, + sensor, + True, + {"last_reset", "max", "mean", "min", "state", "sum"}, + ) + assert stats == {sensor: [expected]} with session_scope(hass=hass, read_only=True) as session: stats = get_latest_short_term_statistics_with_session( hass, session, - {"sensor.test1"}, + {"sensor.test1", "sensor.wind_direction"}, {"last_reset", "max", "mean", "min", "state", "sum"}, ) - assert stats == {"sensor.test1": [expected_2]} + assert stats == { + "sensor.test1": [expected_2], + "sensor.wind_direction": [expected_stats_wind_direction2], + } # Now wipe the latest_short_term_statistics_ids table and test again # to make sure we can rebuild the missing data @@ -241,13 +287,15 @@ async def test_compile_hourly_statistics( stats = get_latest_short_term_statistics_with_session( hass, session, - {"sensor.test1"}, + {"sensor.test1", "sensor.wind_direction"}, {"last_reset", "max", "mean", "min", "state", "sum"}, ) - assert stats == {"sensor.test1": [expected_2]} + assert stats == { + "sensor.test1": [expected_2], + "sensor.wind_direction": [expected_stats_wind_direction2], + } metadata = get_metadata(hass, statistic_ids={"sensor.test1"}) - with session_scope(hass=hass, read_only=True) as session: stats = get_latest_short_term_statistics_with_session( hass, @@ -258,23 +306,44 @@ async def test_compile_hourly_statistics( ) assert stats == {"sensor.test1": [expected_2]} - stats = get_last_short_term_statistics( - hass, - 2, - "sensor.test1", - True, - {"last_reset", "max", "mean", "min", "state", "sum"}, + # Test with multiple metadata ids + metadata = get_metadata( + hass, statistic_ids={"sensor.test1", "sensor.wind_direction"} ) - assert stats == {"sensor.test1": expected_stats1[::-1]} + with session_scope(hass=hass, read_only=True) as session: + stats = get_latest_short_term_statistics_with_session( + hass, + session, + {"sensor.test1", "sensor.wind_direction"}, + {"last_reset", "max", "mean", "min", "state", "sum"}, + metadata=metadata, + ) + assert stats == { + "sensor.test1": [expected_2], + "sensor.wind_direction": [expected_stats_wind_direction2], + } - stats = get_last_short_term_statistics( - hass, - 3, - "sensor.test1", - True, - {"last_reset", "max", "mean", "min", "state", "sum"}, - ) - assert stats == {"sensor.test1": expected_stats1[::-1]} + for sensor, expected in ( + ("sensor.test1", expected_stats1[::-1]), + ("sensor.wind_direction", expected_stats_wind_direction[::-1]), + ): + stats = get_last_short_term_statistics( + hass, + 2, + sensor, + True, + {"last_reset", "max", "mean", "min", "state", "sum"}, + ) + assert stats == {sensor: expected} + + stats = get_last_short_term_statistics( + hass, + 3, + sensor, + True, + {"last_reset", "max", "mean", "min", "state", "sum"}, + ) + assert stats == {sensor: expected} stats = get_last_short_term_statistics( hass, @@ -291,7 +360,7 @@ async def test_compile_hourly_statistics( stats = get_latest_short_term_statistics_with_session( hass, session, - {"sensor.test1"}, + {"sensor.test1", "sensor.wind_direction"}, {"last_reset", "max", "mean", "min", "state", "sum"}, ) assert stats == {} @@ -306,7 +375,7 @@ async def test_compile_hourly_statistics( stats = get_latest_short_term_statistics_with_session( hass, session, - {"sensor.test1"}, + {"sensor.test1", "sensor.wind_direction"}, {"last_reset", "max", "mean", "min", "state", "sum"}, ) assert stats == {} @@ -460,15 +529,35 @@ async def test_rename_entity( expected_stats1 = [expected_1] expected_stats2 = [expected_1] expected_stats99 = [expected_1] + expected_stats_wind_direction = [ + { + "start": process_timestamp(zero).timestamp(), + "end": process_timestamp(zero + timedelta(minutes=5)).timestamp(), + "mean": pytest.approx(358.6387003873801), + "min": None, + "max": None, + "last_reset": None, + "state": None, + "sum": None, + } + ] stats = statistics_during_period(hass, zero, period="5minute") - assert stats == {"sensor.test1": expected_stats1, "sensor.test2": expected_stats2} + assert stats == { + "sensor.test1": expected_stats1, + "sensor.test2": expected_stats2, + "sensor.wind_direction": expected_stats_wind_direction, + } entity_registry.async_update_entity("sensor.test1", new_entity_id="sensor.test99") await async_wait_recording_done(hass) stats = statistics_during_period(hass, zero, period="5minute") - assert stats == {"sensor.test99": expected_stats99, "sensor.test2": expected_stats2} + assert stats == { + "sensor.test99": expected_stats99, + "sensor.test2": expected_stats2, + "sensor.wind_direction": expected_stats_wind_direction, + } async def test_statistics_during_period_set_back_compat( @@ -544,9 +633,25 @@ async def test_rename_entity_collision( } expected_stats1 = [expected_1] expected_stats2 = [expected_1] + expected_stats_wind_direction = [ + { + "start": process_timestamp(zero).timestamp(), + "end": process_timestamp(zero + timedelta(minutes=5)).timestamp(), + "mean": pytest.approx(358.6387003873801), + "min": None, + "max": None, + "last_reset": None, + "state": None, + "sum": None, + } + ] stats = statistics_during_period(hass, zero, period="5minute") - assert stats == {"sensor.test1": expected_stats1, "sensor.test2": expected_stats2} + assert stats == { + "sensor.test1": expected_stats1, + "sensor.test2": expected_stats2, + "sensor.wind_direction": expected_stats_wind_direction, + } # Insert metadata for sensor.test99 metadata_1 = { @@ -567,7 +672,11 @@ async def test_rename_entity_collision( # Statistics failed to migrate due to the collision stats = statistics_during_period(hass, zero, period="5minute") - assert stats == {"sensor.test1": expected_stats1, "sensor.test2": expected_stats2} + assert stats == { + "sensor.test1": expected_stats1, + "sensor.test2": expected_stats2, + "sensor.wind_direction": expected_stats_wind_direction, + } # Verify the safeguard in the states meta manager was hit assert ( @@ -631,9 +740,25 @@ async def test_rename_entity_collision_states_meta_check_disabled( } expected_stats1 = [expected_1] expected_stats2 = [expected_1] + expected_stats_wind_direction = [ + { + "start": process_timestamp(zero).timestamp(), + "end": process_timestamp(zero + timedelta(minutes=5)).timestamp(), + "mean": pytest.approx(358.6387003873801), + "min": None, + "max": None, + "last_reset": None, + "state": None, + "sum": None, + } + ] stats = statistics_during_period(hass, zero, period="5minute") - assert stats == {"sensor.test1": expected_stats1, "sensor.test2": expected_stats2} + assert stats == { + "sensor.test1": expected_stats1, + "sensor.test2": expected_stats2, + "sensor.wind_direction": expected_stats_wind_direction, + } # Insert metadata for sensor.test99 metadata_1 = { @@ -660,7 +785,11 @@ async def test_rename_entity_collision_states_meta_check_disabled( # Statistics failed to migrate due to the collision stats = statistics_during_period(hass, zero, period="5minute") - assert stats == {"sensor.test1": expected_stats1, "sensor.test2": expected_stats2} + assert stats == { + "sensor.test1": expected_stats1, + "sensor.test2": expected_stats2, + "sensor.wind_direction": expected_stats_wind_direction, + } # Verify the filter_unique_constraint_integrity_error safeguard was hit assert "Blocked attempt to insert duplicated statistic rows" in caplog.text @@ -786,6 +915,7 @@ async def test_import_statistics( { "display_unit_of_measurement": "kWh", "has_mean": False, + "mean_type": StatisticMeanType.NONE, "has_sum": True, "statistic_id": statistic_id, "name": "Total imported energy", @@ -800,6 +930,7 @@ async def test_import_statistics( 1, { "has_mean": False, + "mean_type": StatisticMeanType.NONE, "has_sum": True, "name": "Total imported energy", "source": source, @@ -876,6 +1007,7 @@ async def test_import_statistics( { "display_unit_of_measurement": "kWh", "has_mean": False, + "mean_type": StatisticMeanType.NONE, "has_sum": True, "statistic_id": statistic_id, "name": "Total imported energy renamed", @@ -890,6 +1022,7 @@ async def test_import_statistics( 1, { "has_mean": False, + "mean_type": StatisticMeanType.NONE, "has_sum": True, "name": "Total imported energy renamed", "source": source, diff --git a/tests/components/recorder/test_websocket_api.py b/tests/components/recorder/test_websocket_api.py index a4e35bc8753..a4e4fe45db1 100644 --- a/tests/components/recorder/test_websocket_api.py +++ b/tests/components/recorder/test_websocket_api.py @@ -1,11 +1,14 @@ """The tests for sensor recorder platform.""" +from collections.abc import Iterable import datetime from datetime import timedelta +import math from statistics import fmean import sys from unittest.mock import ANY, patch +from _pytest.python_api import ApproxBase from freezegun import freeze_time from freezegun.api import FrozenDateTimeFactory import pytest @@ -13,7 +16,14 @@ import pytest from homeassistant.components import recorder from homeassistant.components.recorder import Recorder from homeassistant.components.recorder.db_schema import Statistics, StatisticsShortTerm +from homeassistant.components.recorder.models import ( + StatisticData, + StatisticMeanType, + StatisticMetaData, +) from homeassistant.components.recorder.statistics import ( + DEG_TO_RAD, + RAD_TO_DEG, async_add_external_statistics, get_last_statistics, get_latest_short_term_statistics_with_session, @@ -24,6 +34,7 @@ from homeassistant.components.recorder.statistics import ( from homeassistant.components.recorder.util import session_scope from homeassistant.components.recorder.websocket_api import UNIT_SCHEMA from homeassistant.components.sensor import UNIT_CONVERTERS +from homeassistant.const import DEGREE from homeassistant.core import HomeAssistant from homeassistant.helpers import recorder as recorder_helper from homeassistant.setup import async_setup_component @@ -247,12 +258,12 @@ async def test_statistics_during_period( @pytest.mark.freeze_time(datetime.datetime(2022, 10, 21, 7, 25, tzinfo=datetime.UTC)) +@pytest.mark.usefixtures("recorder_mock") @pytest.mark.parametrize("offset", [0, 1, 2]) async def test_statistic_during_period( - recorder_mock: Recorder, hass: HomeAssistant, hass_ws_client: WebSocketGenerator, - offset, + offset: int, ) -> None: """Test statistic_during_period.""" now = dt_util.utcnow() @@ -307,7 +318,7 @@ async def test_statistic_during_period( ) imported_metadata = { - "has_mean": False, + "has_mean": True, "has_sum": True, "name": "Total imported energy", "source": "recorder", @@ -655,7 +666,7 @@ async def test_statistic_during_period( hass, session, {"sensor.test"}, - {"last_reset", "max", "mean", "min", "state", "sum"}, + {"last_reset", "state", "sum"}, ) start = imported_stats_5min[-1]["start"].timestamp() end = start + (5 * 60) @@ -672,18 +683,376 @@ async def test_statistic_during_period( } +def _circular_mean(values: Iterable[StatisticData]) -> dict[str, float]: + sin_sum = 0 + cos_sum = 0 + for x in values: + mean = x.get("mean") + assert mean is not None + sin_sum += math.sin(mean * DEG_TO_RAD) + cos_sum += math.cos(mean * DEG_TO_RAD) + + return { + "mean": (RAD_TO_DEG * math.atan2(sin_sum, cos_sum)) % 360, + "mean_weight": math.sqrt(sin_sum**2 + cos_sum**2), + } + + +def _circular_mean_approx(values: Iterable[StatisticData]) -> ApproxBase: + return pytest.approx(_circular_mean(values)["mean"]) + + +@pytest.mark.freeze_time(datetime.datetime(2022, 10, 21, 7, 25, tzinfo=datetime.UTC)) +@pytest.mark.usefixtures("recorder_mock") +@pytest.mark.parametrize("offset", [0, 1, 2]) +async def test_statistic_during_period_circular_mean( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + offset: int, +) -> None: + """Test statistic_during_period.""" + now = dt_util.utcnow() + + await async_recorder_block_till_done(hass) + client = await hass_ws_client() + + zero = now + start = zero.replace(minute=offset * 5, second=0, microsecond=0) + timedelta( + hours=-3 + ) + + imported_stats_5min: list[StatisticData] = [ + { + "start": (start + timedelta(minutes=5 * i)), + "mean": (123.456 * i) % 360, + "mean_weight": 1, + } + for i in range(39) + ] + + imported_stats = [] + slice_end = 12 - offset + imported_stats.append( + { + "start": imported_stats_5min[0]["start"].replace(minute=0), + **_circular_mean(imported_stats_5min[0:slice_end]), + } + ) + for i in range(2): + slice_start = i * 12 + (12 - offset) + slice_end = (i + 1) * 12 + (12 - offset) + assert imported_stats_5min[slice_start]["start"].minute == 0 + imported_stats.append( + { + "start": imported_stats_5min[slice_start]["start"], + **_circular_mean(imported_stats_5min[slice_start:slice_end]), + } + ) + + imported_metadata: StatisticMetaData = { + "mean_type": StatisticMeanType.CIRCULAR, + "has_sum": False, + "name": "Wind direction", + "source": "recorder", + "statistic_id": "sensor.test", + "unit_of_measurement": DEGREE, + } + + recorder.get_instance(hass).async_import_statistics( + imported_metadata, + imported_stats, + Statistics, + ) + recorder.get_instance(hass).async_import_statistics( + imported_metadata, + imported_stats_5min, + StatisticsShortTerm, + ) + await async_wait_recording_done(hass) + + metadata = get_metadata(hass, statistic_ids={"sensor.test"}) + metadata_id = metadata["sensor.test"][0] + run_cache = get_short_term_statistics_run_cache(hass) + # Verify the import of the short term statistics + # also updates the run cache + assert run_cache.get_latest_ids({metadata_id}) is not None + + # No data for this period yet + await client.send_json_auto_id( + { + "type": "recorder/statistic_during_period", + "fixed_period": { + "start_time": now.isoformat(), + "end_time": now.isoformat(), + }, + "statistic_id": "sensor.test", + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == { + "max": None, + "mean": None, + "min": None, + "change": None, + } + + # This should include imported_statistics_5min[:] + await client.send_json_auto_id( + { + "type": "recorder/statistic_during_period", + "statistic_id": "sensor.test", + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == { + "mean": _circular_mean_approx(imported_stats_5min), + "max": None, + "min": None, + "change": None, + } + + # This should also include imported_statistics_5min[:] + start_time = ( + dt_util.parse_datetime("2022-10-21T04:00:00+00:00") + + timedelta(minutes=5 * offset) + ).isoformat() + end_time = ( + dt_util.parse_datetime("2022-10-21T07:15:00+00:00") + + timedelta(minutes=5 * offset) + ).isoformat() + await client.send_json_auto_id( + { + "type": "recorder/statistic_during_period", + "statistic_id": "sensor.test", + "fixed_period": { + "start_time": start_time, + "end_time": end_time, + }, + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == { + "mean": _circular_mean_approx(imported_stats_5min), + "max": None, + "min": None, + "change": None, + } + + # This should also include imported_statistics_5min[:] + start_time = ( + dt_util.parse_datetime("2022-10-21T04:00:00+00:00") + + timedelta(minutes=5 * offset) + ).isoformat() + end_time = ( + dt_util.parse_datetime("2022-10-21T08:20:00+00:00") + + timedelta(minutes=5 * offset) + ).isoformat() + await client.send_json_auto_id( + { + "type": "recorder/statistic_during_period", + "statistic_id": "sensor.test", + "fixed_period": { + "start_time": start_time, + "end_time": end_time, + }, + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == { + "mean": _circular_mean_approx(imported_stats_5min), + "max": None, + "min": None, + "change": None, + } + + # This should include imported_statistics_5min[26:] + start_time = ( + dt_util.parse_datetime("2022-10-21T06:10:00+00:00") + + timedelta(minutes=5 * offset) + ).isoformat() + assert imported_stats_5min[26]["start"].isoformat() == start_time + await client.send_json_auto_id( + { + "type": "recorder/statistic_during_period", + "fixed_period": { + "start_time": start_time, + }, + "statistic_id": "sensor.test", + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == { + "mean": _circular_mean_approx(imported_stats_5min[26:]), + "max": None, + "min": None, + "change": None, + } + + # This should also include imported_statistics_5min[26:] + start_time = ( + dt_util.parse_datetime("2022-10-21T06:09:00+00:00") + + timedelta(minutes=5 * offset) + ).isoformat() + await client.send_json_auto_id( + { + "type": "recorder/statistic_during_period", + "fixed_period": { + "start_time": start_time, + }, + "statistic_id": "sensor.test", + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == { + "mean": _circular_mean_approx(imported_stats_5min[26:]), + "max": None, + "min": None, + "change": None, + } + + # This should include imported_statistics_5min[:26] + end_time = ( + dt_util.parse_datetime("2022-10-21T06:10:00+00:00") + + timedelta(minutes=5 * offset) + ).isoformat() + assert imported_stats_5min[26]["start"].isoformat() == end_time + await client.send_json_auto_id( + { + "type": "recorder/statistic_during_period", + "fixed_period": { + "end_time": end_time, + }, + "statistic_id": "sensor.test", + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == { + "mean": _circular_mean_approx(imported_stats_5min[:26]), + "max": None, + "min": None, + "change": None, + } + + # This should include imported_statistics_5min[26:32] (less than a full hour) + start_time = ( + dt_util.parse_datetime("2022-10-21T06:10:00+00:00") + + timedelta(minutes=5 * offset) + ).isoformat() + assert imported_stats_5min[26]["start"].isoformat() == start_time + end_time = ( + dt_util.parse_datetime("2022-10-21T06:40:00+00:00") + + timedelta(minutes=5 * offset) + ).isoformat() + assert imported_stats_5min[32]["start"].isoformat() == end_time + await client.send_json_auto_id( + { + "type": "recorder/statistic_during_period", + "fixed_period": { + "start_time": start_time, + "end_time": end_time, + }, + "statistic_id": "sensor.test", + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == { + "mean": _circular_mean_approx(imported_stats_5min[26:32]), + "max": None, + "min": None, + "change": None, + } + + # This should include imported_statistics[2:] + imported_statistics_5min[36:] + start_time = "2022-10-21T06:00:00+00:00" + assert imported_stats_5min[24 - offset]["start"].isoformat() == start_time + assert imported_stats[2]["start"].isoformat() == start_time + await client.send_json_auto_id( + { + "type": "recorder/statistic_during_period", + "fixed_period": { + "start_time": start_time, + }, + "statistic_id": "sensor.test", + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == { + "mean": _circular_mean_approx(imported_stats_5min[24 - offset :]), + "max": None, + "min": None, + "change": None, + } + + # This should also include imported_statistics[2:] + imported_statistics_5min[36:] + await client.send_json_auto_id( + { + "type": "recorder/statistic_during_period", + "rolling_window": { + "duration": {"hours": 1, "minutes": 25}, + }, + "statistic_id": "sensor.test", + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == { + "mean": _circular_mean_approx(imported_stats_5min[24 - offset :]), + "max": None, + "min": None, + "change": None, + } + + # This should include imported_statistics[2:3] + await client.send_json_auto_id( + { + "type": "recorder/statistic_during_period", + "rolling_window": { + "duration": {"hours": 1}, + "offset": {"minutes": -25}, + }, + "statistic_id": "sensor.test", + } + ) + response = await client.receive_json() + assert response["success"] + slice_start = 24 - offset + slice_end = 36 - offset + assert response["result"] == { + "mean": _circular_mean_approx(imported_stats_5min[slice_start:slice_end]), + "max": None, + "min": None, + "change": None, + } + + # Test we can get only selected types + await client.send_json_auto_id( + { + "type": "recorder/statistic_during_period", + "statistic_id": "sensor.test", + "types": ["mean"], + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == { + "mean": _circular_mean_approx(imported_stats_5min), + } + + @pytest.mark.freeze_time(datetime.datetime(2022, 10, 21, 7, 25, tzinfo=datetime.UTC)) async def test_statistic_during_period_hole( recorder_mock: Recorder, hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: """Test statistic_during_period when there are holes in the data.""" - stat_id = 1 - - def next_id(): - nonlocal stat_id - stat_id += 1 - return stat_id - now = dt_util.utcnow() await async_recorder_block_till_done(hass) @@ -704,7 +1073,7 @@ async def test_statistic_during_period_hole( ] imported_metadata = { - "has_mean": False, + "has_mean": True, "has_sum": True, "name": "Total imported energy", "source": "recorder", @@ -830,6 +1199,156 @@ async def test_statistic_during_period_hole( } +@pytest.mark.freeze_time(datetime.datetime(2022, 10, 21, 7, 25, tzinfo=datetime.UTC)) +@pytest.mark.usefixtures("recorder_mock") +async def test_statistic_during_period_hole_circular_mean( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test statistic_during_period when there are holes in the data.""" + now = dt_util.utcnow() + + await async_recorder_block_till_done(hass) + client = await hass_ws_client() + + zero = now + start = zero.replace(minute=0, second=0, microsecond=0) + timedelta(hours=-18) + + imported_stats: list[StatisticData] = [ + { + "start": (start + timedelta(hours=3 * i)), + "mean": (123.456 * i) % 360, + "mean_weight": 1, + } + for i in range(6) + ] + + imported_metadata: StatisticMetaData = { + "mean_type": StatisticMeanType.CIRCULAR, + "has_sum": False, + "name": "Wind direction", + "source": "recorder", + "statistic_id": "sensor.test", + "unit_of_measurement": DEGREE, + } + + recorder.get_instance(hass).async_import_statistics( + imported_metadata, + imported_stats, + Statistics, + ) + await async_wait_recording_done(hass) + + # This should include imported_stats[:] + await client.send_json_auto_id( + { + "type": "recorder/statistic_during_period", + "statistic_id": "sensor.test", + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == { + "mean": _circular_mean_approx(imported_stats[:]), + "max": None, + "min": None, + "change": None, + } + + # This should also include imported_stats[:] + start_time = "2022-10-20T13:00:00+00:00" + end_time = "2022-10-21T05:00:00+00:00" + assert imported_stats[0]["start"].isoformat() == start_time + assert imported_stats[-1]["start"].isoformat() < end_time + await client.send_json_auto_id( + { + "type": "recorder/statistic_during_period", + "statistic_id": "sensor.test", + "fixed_period": { + "start_time": start_time, + "end_time": end_time, + }, + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == { + "mean": _circular_mean_approx(imported_stats[:]), + "max": None, + "min": None, + "change": None, + } + + # This should also include imported_stats[:] + start_time = "2022-10-20T13:00:00+00:00" + end_time = "2022-10-21T08:20:00+00:00" + await client.send_json_auto_id( + { + "type": "recorder/statistic_during_period", + "statistic_id": "sensor.test", + "fixed_period": { + "start_time": start_time, + "end_time": end_time, + }, + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == { + "mean": _circular_mean_approx(imported_stats[:]), + "max": None, + "min": None, + "change": None, + } + + # This should include imported_stats[1:4] + start_time = "2022-10-20T16:00:00+00:00" + end_time = "2022-10-20T23:00:00+00:00" + assert imported_stats[1]["start"].isoformat() == start_time + assert imported_stats[3]["start"].isoformat() < end_time + await client.send_json_auto_id( + { + "type": "recorder/statistic_during_period", + "statistic_id": "sensor.test", + "fixed_period": { + "start_time": start_time, + "end_time": end_time, + }, + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == { + "mean": _circular_mean_approx(imported_stats[1:4]), + "max": None, + "min": None, + "change": None, + } + + # This should also include imported_stats[1:4] + start_time = "2022-10-20T15:00:00+00:00" + end_time = "2022-10-21T00:00:00+00:00" + assert imported_stats[1]["start"].isoformat() > start_time + assert imported_stats[3]["start"].isoformat() < end_time + await client.send_json_auto_id( + { + "type": "recorder/statistic_during_period", + "statistic_id": "sensor.test", + "fixed_period": { + "start_time": start_time, + "end_time": end_time, + }, + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == { + "mean": _circular_mean_approx(imported_stats[1:4]), + "max": None, + "min": None, + "change": None, + } + + @pytest.mark.parametrize( "frozen_time", [ @@ -897,7 +1416,7 @@ async def test_statistic_during_period_partial_overlap( statId = "sensor.test_overlapping" imported_metadata = { - "has_mean": False, + "has_mean": True, "has_sum": True, "name": "Total imported energy overlapping", "source": "recorder", @@ -1766,6 +2285,7 @@ async def test_list_statistic_ids( """Test list_statistic_ids.""" now = get_start_time(dt_util.utcnow()) has_mean = attributes["state_class"] == "measurement" + mean_type = StatisticMeanType.ARITHMETIC if has_mean else StatisticMeanType.NONE has_sum = not has_mean hass.config.units = units @@ -1791,6 +2311,7 @@ async def test_list_statistic_ids( "statistic_id": "sensor.test", "display_unit_of_measurement": display_unit, "has_mean": has_mean, + "mean_type": mean_type, "has_sum": has_sum, "name": None, "source": "recorder", @@ -1813,6 +2334,7 @@ async def test_list_statistic_ids( "statistic_id": "sensor.test", "display_unit_of_measurement": display_unit, "has_mean": has_mean, + "mean_type": mean_type, "has_sum": has_sum, "name": None, "source": "recorder", @@ -1838,6 +2360,7 @@ async def test_list_statistic_ids( "statistic_id": "sensor.test", "display_unit_of_measurement": display_unit, "has_mean": has_mean, + "mean_type": mean_type, "has_sum": has_sum, "name": None, "source": "recorder", @@ -1859,6 +2382,7 @@ async def test_list_statistic_ids( "statistic_id": "sensor.test", "display_unit_of_measurement": display_unit, "has_mean": has_mean, + "mean_type": mean_type, "has_sum": has_sum, "name": None, "source": "recorder", @@ -1939,6 +2463,7 @@ async def test_list_statistic_ids_unit_change( """Test list_statistic_ids.""" now = get_start_time(dt_util.utcnow()) has_mean = attributes["state_class"] == "measurement" + mean_type = StatisticMeanType.ARITHMETIC if has_mean else StatisticMeanType.NONE has_sum = not has_mean await async_setup_component(hass, "sensor", {}) @@ -1966,6 +2491,7 @@ async def test_list_statistic_ids_unit_change( "statistic_id": "sensor.test", "display_unit_of_measurement": statistics_unit, "has_mean": has_mean, + "mean_type": mean_type, "has_sum": has_sum, "name": None, "source": "recorder", @@ -1987,6 +2513,7 @@ async def test_list_statistic_ids_unit_change( "statistic_id": "sensor.test", "display_unit_of_measurement": display_unit, "has_mean": has_mean, + "mean_type": mean_type, "has_sum": has_sum, "name": None, "source": "recorder", @@ -2208,6 +2735,7 @@ async def test_update_statistics_metadata( "statistic_id": "sensor.test", "display_unit_of_measurement": "kW", "has_mean": True, + "mean_type": StatisticMeanType.ARITHMETIC, "has_sum": False, "name": None, "source": "recorder", @@ -2235,6 +2763,7 @@ async def test_update_statistics_metadata( "statistic_id": "sensor.test", "display_unit_of_measurement": new_display_unit, "has_mean": True, + "mean_type": StatisticMeanType.ARITHMETIC, "has_sum": False, "name": None, "source": "recorder", @@ -2324,6 +2853,7 @@ async def test_change_statistics_unit( "statistic_id": "sensor.test", "display_unit_of_measurement": "kW", "has_mean": True, + "mean_type": StatisticMeanType.ARITHMETIC, "has_sum": False, "name": None, "source": "recorder", @@ -2375,6 +2905,7 @@ async def test_change_statistics_unit( "statistic_id": "sensor.test", "display_unit_of_measurement": "kW", "has_mean": True, + "mean_type": StatisticMeanType.ARITHMETIC, "has_sum": False, "name": None, "source": "recorder", @@ -2428,6 +2959,7 @@ async def test_change_statistics_unit( "statistic_id": "sensor.test", "display_unit_of_measurement": "kW", "has_mean": True, + "mean_type": StatisticMeanType.ARITHMETIC, "has_sum": False, "name": None, "source": "recorder", @@ -2455,6 +2987,7 @@ async def test_change_statistics_unit_errors( "statistic_id": "sensor.test", "display_unit_of_measurement": "kW", "has_mean": True, + "mean_type": StatisticMeanType.ARITHMETIC, "has_sum": False, "name": None, "source": "recorder", @@ -2774,6 +3307,7 @@ async def test_get_statistics_metadata( """Test get_statistics_metadata.""" now = get_start_time(dt_util.utcnow()) has_mean = attributes["state_class"] == "measurement" + mean_type = StatisticMeanType.ARITHMETIC if has_mean else StatisticMeanType.NONE has_sum = not has_mean hass.config.units = units @@ -2843,6 +3377,7 @@ async def test_get_statistics_metadata( "statistic_id": "test:total_gas", "display_unit_of_measurement": unit, "has_mean": has_mean, + "mean_type": mean_type, "has_sum": has_sum, "name": "Total imported energy", "source": "test", @@ -2874,6 +3409,7 @@ async def test_get_statistics_metadata( "statistic_id": "sensor.test", "display_unit_of_measurement": attributes["unit_of_measurement"], "has_mean": has_mean, + "mean_type": mean_type, "has_sum": has_sum, "name": None, "source": "recorder", @@ -2901,6 +3437,7 @@ async def test_get_statistics_metadata( "statistic_id": "sensor.test", "display_unit_of_measurement": attributes["unit_of_measurement"], "has_mean": has_mean, + "mean_type": mean_type, "has_sum": has_sum, "name": None, "source": "recorder", @@ -2995,6 +3532,7 @@ async def test_import_statistics( { "display_unit_of_measurement": "kWh", "has_mean": False, + "mean_type": StatisticMeanType.NONE, "has_sum": True, "statistic_id": statistic_id, "name": "Total imported energy", @@ -3009,6 +3547,7 @@ async def test_import_statistics( 1, { "has_mean": False, + "mean_type": StatisticMeanType.NONE, "has_sum": True, "name": "Total imported energy", "source": source, @@ -3213,6 +3752,7 @@ async def test_adjust_sum_statistics_energy( { "display_unit_of_measurement": "kWh", "has_mean": False, + "mean_type": StatisticMeanType.NONE, "has_sum": True, "statistic_id": statistic_id, "name": "Total imported energy", @@ -3227,6 +3767,7 @@ async def test_adjust_sum_statistics_energy( 1, { "has_mean": False, + "mean_type": StatisticMeanType.NONE, "has_sum": True, "name": "Total imported energy", "source": source, @@ -3406,6 +3947,7 @@ async def test_adjust_sum_statistics_gas( { "display_unit_of_measurement": "m³", "has_mean": False, + "mean_type": StatisticMeanType.NONE, "has_sum": True, "statistic_id": statistic_id, "name": "Total imported energy", @@ -3420,6 +3962,7 @@ async def test_adjust_sum_statistics_gas( 1, { "has_mean": False, + "mean_type": StatisticMeanType.NONE, "has_sum": True, "name": "Total imported energy", "source": source, @@ -3617,6 +4160,7 @@ async def test_adjust_sum_statistics_errors( { "display_unit_of_measurement": state_unit, "has_mean": False, + "mean_type": StatisticMeanType.NONE, "has_sum": True, "statistic_id": statistic_id, "name": "Total imported energy", @@ -3631,6 +4175,7 @@ async def test_adjust_sum_statistics_errors( 1, { "has_mean": False, + "mean_type": StatisticMeanType.NONE, "has_sum": True, "name": "Total imported energy", "source": source, diff --git a/tests/components/sensor/test_recorder.py b/tests/components/sensor/test_recorder.py index ce188ecb924..962c0a0ef8f 100644 --- a/tests/components/sensor/test_recorder.py +++ b/tests/components/sensor/test_recorder.py @@ -1,7 +1,8 @@ """The tests for sensor recorder platform.""" -from collections.abc import Iterable +from collections.abc import Callable, Iterable from datetime import datetime, timedelta +import logging import math from statistics import mean from typing import Any, Literal @@ -26,21 +27,30 @@ from homeassistant.components.recorder.db_schema import ( ) from homeassistant.components.recorder.models import ( StatisticData, + StatisticMeanType, StatisticMetaData, process_timestamp, ) from homeassistant.components.recorder.statistics import ( + DEG_TO_RAD, + RAD_TO_DEG, async_import_statistics, get_metadata, list_statistic_ids, ) from homeassistant.components.recorder.util import get_instance, session_scope -from homeassistant.components.sensor import ATTR_OPTIONS, DOMAIN, SensorDeviceClass +from homeassistant.components.sensor import ( + ATTR_OPTIONS, + DOMAIN, + SensorDeviceClass, + SensorStateClass, +) from homeassistant.components.sensor.recorder import ( + MEAN_TYPE_CHANGED_ISSUE, STATE_CLASS_REMOVED_ISSUE, UNITS_CHANGED_ISSUE, ) -from homeassistant.const import ATTR_FRIENDLY_NAME, STATE_UNAVAILABLE +from homeassistant.const import ATTR_FRIENDLY_NAME, DEGREE, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant, State from homeassistant.helpers import issue_registry as ir from homeassistant.setup import async_setup_component @@ -102,6 +112,13 @@ KW_SENSOR_ATTRIBUTES = { "state_class": "measurement", "unit_of_measurement": "kW", } +WIND_DIRECTION_ATTRIBUTES = { + "device_class": SensorDeviceClass.WIND_DIRECTION, + "state_class": SensorStateClass.MEASUREMENT_ANGLE, + "unit_of_measurement": DEGREE, +} +WIND_DIRECTION_STATES_SEQ = [350, 0, 15] +TEMP_STATES_SEQ = [-10, 15, 30, 60] @pytest.fixture @@ -285,6 +302,7 @@ async def test_compile_hourly_statistics( "statistic_id": "sensor.test1", "display_unit_of_measurement": display_unit, "has_mean": True, + "mean_type": StatisticMeanType.ARITHMETIC, "has_sum": False, "name": None, "source": "recorder", @@ -310,6 +328,64 @@ async def test_compile_hourly_statistics( assert "Error while processing event StatisticsTask" not in caplog.text +async def test_compile_hourly_statistics_angle( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test compiling hourly statistics for measurement_angle.""" + zero = get_start_time(dt_util.utcnow()) + await async_setup_component(hass, "sensor", {}) + # Wait for the sensor recorder platform to be added + await async_recorder_block_till_done(hass) + with freeze_time(zero) as freezer: + four, states = await async_record_states( + hass, + freezer, + zero, + "sensor.test1", + WIND_DIRECTION_ATTRIBUTES, + seq=WIND_DIRECTION_STATES_SEQ, + ) + await async_wait_recording_done(hass) + hist = history.get_significant_states( + hass, zero, four, hass.states.async_entity_ids() + ) + assert_dict_of_states_equal_without_context_and_last_changed(states, hist) + + do_adhoc_statistics(hass, start=zero) + await async_wait_recording_done(hass) + statistic_ids = await async_list_statistic_ids(hass) + assert statistic_ids == [ + { + "statistic_id": "sensor.test1", + "display_unit_of_measurement": DEGREE, + "has_mean": False, + "mean_type": StatisticMeanType.CIRCULAR, + "has_sum": False, + "name": None, + "source": "recorder", + "statistics_unit_of_measurement": DEGREE, + "unit_class": None, + } + ] + stats = statistics_during_period(hass, zero, period="5minute") + assert stats == { + "sensor.test1": [ + { + "start": process_timestamp(zero).timestamp(), + "end": process_timestamp(zero + timedelta(minutes=5)).timestamp(), + "mean": pytest.approx(0.5802544), + "min": None, + "max": None, + "last_reset": None, + "state": None, + "sum": None, + } + ] + } + assert "Error while processing event StatisticsTask" not in caplog.text + + @pytest.mark.parametrize( ( "device_class", @@ -353,7 +429,7 @@ async def test_compile_hourly_statistics_with_some_same_last_updated( "unit_of_measurement": state_unit, } attributes = dict(attributes) - seq = [-10, 15, 30, 60] + seq = TEMP_STATES_SEQ async def set_state(entity_id, state, **kwargs): """Set the state.""" @@ -399,6 +475,7 @@ async def test_compile_hourly_statistics_with_some_same_last_updated( "statistic_id": "sensor.test1", "display_unit_of_measurement": display_unit, "has_mean": True, + "mean_type": StatisticMeanType.ARITHMETIC, "has_sum": False, "name": None, "source": "recorder", @@ -424,33 +501,167 @@ async def test_compile_hourly_statistics_with_some_same_last_updated( assert "Error while processing event StatisticsTask" not in caplog.text +async def test_compile_hourly_statistics_with_some_same_last_updated_angle( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test compiling hourly statistics with the some of the same last updated value for measurement_angle. + + If the last updated value is the same we will have a zero duration. + """ + zero = get_start_time(dt_util.utcnow()) + await async_setup_component(hass, "sensor", {}) + # Wait for the sensor recorder platform to be added + await async_recorder_block_till_done(hass) + entity_id = "sensor.test1" + seq = [350, 2, 15, 345] + + async def set_state(entity_id, state, **kwargs): + """Set the state.""" + hass.states.async_set(entity_id, state, **kwargs) + await async_wait_recording_done(hass) + return hass.states.get(entity_id) + + one = zero + timedelta(seconds=1 * 5) + two = one + timedelta(seconds=10 * 5) + three = two + timedelta(seconds=40 * 5) + four = three + timedelta(seconds=10 * 5) + + states = {entity_id: []} + with freeze_time(one) as freezer: + states[entity_id].append( + await set_state( + entity_id, str(seq[0]), attributes=WIND_DIRECTION_ATTRIBUTES + ) + ) + + # Record two states at the exact same time + freezer.move_to(two) + states[entity_id].append( + await set_state( + entity_id, str(seq[1]), attributes=WIND_DIRECTION_ATTRIBUTES + ) + ) + states[entity_id].append( + await set_state( + entity_id, str(seq[2]), attributes=WIND_DIRECTION_ATTRIBUTES + ) + ) + + freezer.move_to(three) + states[entity_id].append( + await set_state( + entity_id, str(seq[3]), attributes=WIND_DIRECTION_ATTRIBUTES + ) + ) + + hist = history.get_significant_states( + hass, zero, four, hass.states.async_entity_ids() + ) + assert_dict_of_states_equal_without_context_and_last_changed(states, hist) + + do_adhoc_statistics(hass, start=zero) + await async_wait_recording_done(hass) + statistic_ids = await async_list_statistic_ids(hass) + assert statistic_ids == [ + { + "statistic_id": "sensor.test1", + "display_unit_of_measurement": DEGREE, + "has_mean": False, + "mean_type": StatisticMeanType.CIRCULAR, + "has_sum": False, + "name": None, + "source": "recorder", + "statistics_unit_of_measurement": DEGREE, + "unit_class": None, + } + ] + stats = statistics_during_period(hass, zero, period="5minute") + assert stats == { + "sensor.test1": [ + { + "start": process_timestamp(zero).timestamp(), + "end": process_timestamp(zero + timedelta(minutes=5)).timestamp(), + "mean": pytest.approx(6.274605), + "min": None, + "max": None, + "last_reset": None, + "state": None, + "sum": None, + } + ] + } + assert "Error while processing event StatisticsTask" not in caplog.text + + @pytest.mark.parametrize( ( - "device_class", - "state_unit", + "attributes", "display_unit", "statistics_unit", "unit_class", "mean", "min", "max", + "mean_type", + "seq", ), [ - ("temperature", "°C", "°C", "°C", "temperature", 60, -10, 60), - ("temperature", "°F", "°F", "°F", "temperature", 60, -10, 60), + ( + { + "device_class": "temperature", + "state_class": "measurement", + "unit_of_measurement": "°C", + }, + "°C", + "°C", + "temperature", + 60, + -10, + 60, + StatisticMeanType.ARITHMETIC, + TEMP_STATES_SEQ, + ), + ( + { + "device_class": "temperature", + "state_class": "measurement", + "unit_of_measurement": "°F", + }, + "°F", + "°F", + "temperature", + 60, + -10, + 60, + StatisticMeanType.ARITHMETIC, + TEMP_STATES_SEQ, + ), + ( + WIND_DIRECTION_ATTRIBUTES, + DEGREE, + DEGREE, + None, + 15, + None, + None, + StatisticMeanType.CIRCULAR, + [350, 0, 355, 15], + ), ], ) async def test_compile_hourly_statistics_with_all_same_last_updated( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, - device_class, - state_unit, - display_unit, - statistics_unit, - unit_class, - mean, - min, - max, + attributes: dict[str, Any], + display_unit: str, + statistics_unit: str, + unit_class: str | None, + mean: float | None, + min: float | None, + max: float | None, + mean_type: StatisticMeanType, + seq: list[float], ) -> None: """Test compiling hourly statistics with the all of the same last updated value. @@ -461,13 +672,6 @@ async def test_compile_hourly_statistics_with_all_same_last_updated( # Wait for the sensor recorder platform to be added await async_recorder_block_till_done(hass) entity_id = "sensor.test1" - attributes = { - "device_class": device_class, - "state_class": "measurement", - "unit_of_measurement": state_unit, - } - attributes = dict(attributes) - seq = [-10, 15, 30, 60] async def set_state(entity_id, state, **kwargs): """Set the state.""" @@ -507,7 +711,8 @@ async def test_compile_hourly_statistics_with_all_same_last_updated( { "statistic_id": "sensor.test1", "display_unit_of_measurement": display_unit, - "has_mean": True, + "has_mean": mean_type is StatisticMeanType.ARITHMETIC, + "mean_type": mean_type, "has_sum": False, "name": None, "source": "recorder", @@ -535,31 +740,72 @@ async def test_compile_hourly_statistics_with_all_same_last_updated( @pytest.mark.parametrize( ( - "device_class", - "state_unit", + "attributes", "display_unit", "statistics_unit", "unit_class", "mean", "min", "max", + "mean_type", + "seq", ), [ - ("temperature", "°C", "°C", "°C", "temperature", 60, -10, 60), - ("temperature", "°F", "°F", "°F", "temperature", 60, -10, 60), + ( + { + "device_class": "temperature", + "state_class": "measurement", + "unit_of_measurement": "°C", + }, + "°C", + "°C", + "temperature", + 60, + -10, + 60, + StatisticMeanType.ARITHMETIC, + TEMP_STATES_SEQ, + ), + ( + { + "device_class": "temperature", + "state_class": "measurement", + "unit_of_measurement": "°F", + }, + "°F", + "°F", + "temperature", + 60, + -10, + 60, + StatisticMeanType.ARITHMETIC, + TEMP_STATES_SEQ, + ), + ( + WIND_DIRECTION_ATTRIBUTES, + DEGREE, + DEGREE, + None, + 15, + None, + None, + StatisticMeanType.CIRCULAR, + [350, 0, 355, 15], + ), ], ) async def test_compile_hourly_statistics_only_state_is_at_end_of_period( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, - device_class, - state_unit, - display_unit, - statistics_unit, - unit_class, - mean, - min, - max, + attributes: dict[str, Any], + display_unit: str, + statistics_unit: str, + unit_class: str | None, + mean: float | None, + min: float | None, + max: float | None, + mean_type: StatisticMeanType, + seq: list[float], ) -> None: """Test compiling hourly statistics when the only states are at end of period.""" zero = get_start_time(dt_util.utcnow()) @@ -567,13 +813,6 @@ async def test_compile_hourly_statistics_only_state_is_at_end_of_period( # Wait for the sensor recorder platform to be added await async_recorder_block_till_done(hass) entity_id = "sensor.test1" - attributes = { - "device_class": device_class, - "state_class": "measurement", - "unit_of_measurement": state_unit, - } - attributes = dict(attributes) - seq = [-10, 15, 30, 60] async def set_state(entity_id, state, **kwargs): """Set the state.""" @@ -615,7 +854,8 @@ async def test_compile_hourly_statistics_only_state_is_at_end_of_period( { "statistic_id": "sensor.test1", "display_unit_of_measurement": display_unit, - "has_mean": True, + "has_mean": mean_type is StatisticMeanType.ARITHMETIC, + "mean_type": mean_type, "has_sum": False, "name": None, "source": "recorder", @@ -699,6 +939,7 @@ async def test_compile_hourly_statistics_purged_state_changes( "statistic_id": "sensor.test1", "display_unit_of_measurement": display_unit, "has_mean": True, + "mean_type": StatisticMeanType.ARITHMETIC, "has_sum": False, "name": None, "source": "recorder", @@ -785,6 +1026,7 @@ async def test_compile_hourly_statistics_ignore_future_state( "statistic_id": "sensor.test1", "display_unit_of_measurement": display_unit, "has_mean": True, + "mean_type": StatisticMeanType.ARITHMETIC, "has_sum": False, "name": None, "source": "recorder", @@ -877,6 +1119,7 @@ async def test_compile_hourly_statistics_wrong_unit( "statistic_id": "sensor.test1", "display_unit_of_measurement": "°C", "has_mean": True, + "mean_type": StatisticMeanType.ARITHMETIC, "has_sum": False, "name": None, "source": "recorder", @@ -886,6 +1129,7 @@ async def test_compile_hourly_statistics_wrong_unit( { "display_unit_of_measurement": "invalid", "has_mean": True, + "mean_type": StatisticMeanType.ARITHMETIC, "has_sum": False, "name": None, "source": "recorder", @@ -896,6 +1140,7 @@ async def test_compile_hourly_statistics_wrong_unit( { "display_unit_of_measurement": None, "has_mean": True, + "mean_type": StatisticMeanType.ARITHMETIC, "has_sum": False, "name": None, "source": "recorder", @@ -907,6 +1152,7 @@ async def test_compile_hourly_statistics_wrong_unit( "statistic_id": "sensor.test6", "display_unit_of_measurement": "°C", "has_mean": True, + "mean_type": StatisticMeanType.ARITHMETIC, "has_sum": False, "name": None, "source": "recorder", @@ -917,6 +1163,7 @@ async def test_compile_hourly_statistics_wrong_unit( "statistic_id": "sensor.test7", "display_unit_of_measurement": "°C", "has_mean": True, + "mean_type": StatisticMeanType.ARITHMETIC, "has_sum": False, "name": None, "source": "recorder", @@ -1088,6 +1335,7 @@ async def test_compile_hourly_sum_statistics_amount( "statistic_id": "sensor.test1", "display_unit_of_measurement": statistics_unit, "has_mean": False, + "mean_type": StatisticMeanType.NONE, "has_sum": True, "name": None, "source": "recorder", @@ -1292,6 +1540,7 @@ async def test_compile_hourly_sum_statistics_amount_reset_every_state_change( "statistic_id": "sensor.test1", "display_unit_of_measurement": display_unit, "has_mean": False, + "mean_type": StatisticMeanType.NONE, "has_sum": True, "name": None, "source": "recorder", @@ -1401,6 +1650,7 @@ async def test_compile_hourly_sum_statistics_amount_invalid_last_reset( "statistic_id": "sensor.test1", "display_unit_of_measurement": display_unit, "has_mean": False, + "mean_type": StatisticMeanType.NONE, "has_sum": True, "name": None, "source": "recorder", @@ -1497,6 +1747,7 @@ async def test_compile_hourly_sum_statistics_nan_inf_state( "statistic_id": "sensor.test1", "display_unit_of_measurement": display_unit, "has_mean": False, + "mean_type": StatisticMeanType.NONE, "has_sum": True, "name": None, "source": "recorder", @@ -1640,6 +1891,7 @@ async def test_compile_hourly_sum_statistics_negative_state( assert { "display_unit_of_measurement": display_unit, "has_mean": False, + "mean_type": StatisticMeanType.NONE, "has_sum": True, "name": None, "source": "recorder", @@ -1741,6 +1993,7 @@ async def test_compile_hourly_sum_statistics_total_no_reset( "statistic_id": "sensor.test1", "display_unit_of_measurement": display_unit, "has_mean": False, + "mean_type": StatisticMeanType.NONE, "has_sum": True, "name": None, "source": "recorder", @@ -1854,6 +2107,7 @@ async def test_compile_hourly_sum_statistics_total_increasing( "statistic_id": "sensor.test1", "display_unit_of_measurement": display_unit, "has_mean": False, + "mean_type": StatisticMeanType.NONE, "has_sum": True, "name": None, "source": "recorder", @@ -1980,6 +2234,7 @@ async def test_compile_hourly_sum_statistics_total_increasing_small_dip( "statistic_id": "sensor.test1", "display_unit_of_measurement": display_unit, "has_mean": False, + "mean_type": StatisticMeanType.NONE, "has_sum": True, "name": None, "source": "recorder", @@ -2084,6 +2339,7 @@ async def test_compile_hourly_energy_statistics_unsupported( "statistic_id": "sensor.test1", "display_unit_of_measurement": "kWh", "has_mean": False, + "mean_type": StatisticMeanType.NONE, "has_sum": True, "name": None, "source": "recorder", @@ -2186,6 +2442,7 @@ async def test_compile_hourly_energy_statistics_multiple( "statistic_id": "sensor.test1", "display_unit_of_measurement": "kWh", "has_mean": False, + "mean_type": StatisticMeanType.NONE, "has_sum": True, "name": None, "source": "recorder", @@ -2196,6 +2453,7 @@ async def test_compile_hourly_energy_statistics_multiple( "statistic_id": "sensor.test2", "display_unit_of_measurement": "kWh", "has_mean": False, + "mean_type": StatisticMeanType.NONE, "has_sum": True, "name": None, "source": "recorder", @@ -2206,6 +2464,7 @@ async def test_compile_hourly_energy_statistics_multiple( "statistic_id": "sensor.test3", "display_unit_of_measurement": "Wh", "has_mean": False, + "mean_type": StatisticMeanType.NONE, "has_sum": True, "name": None, "source": "recorder", @@ -2388,8 +2647,64 @@ async def test_compile_hourly_statistics_unchanged( assert "Error while processing event StatisticsTask" not in caplog.text +async def test_compile_hourly_statistics_unchanged_angle( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test compiling hourly statistics, with no changes during the hour for measurement_angle.""" + zero = get_start_time(dt_util.utcnow()) + await async_setup_component(hass, "sensor", {}) + # Wait for the sensor recorder platform to be added + await async_recorder_block_till_done(hass) + with freeze_time(zero) as freezer: + four, states = await async_record_states( + hass, + freezer, + zero, + "sensor.test1", + WIND_DIRECTION_ATTRIBUTES, + seq=WIND_DIRECTION_STATES_SEQ, + ) + await async_wait_recording_done(hass) + hist = history.get_significant_states( + hass, zero, four, hass.states.async_entity_ids() + ) + assert_dict_of_states_equal_without_context_and_last_changed(states, hist) + + do_adhoc_statistics(hass, start=four) + await async_wait_recording_done(hass) + stats = statistics_during_period(hass, four, period="5minute") + assert stats == { + "sensor.test1": [ + { + "start": process_timestamp(four).timestamp(), + "end": process_timestamp(four + timedelta(minutes=5)).timestamp(), + "mean": pytest.approx(15), + "min": None, + "max": None, + "last_reset": None, + "state": None, + "sum": None, + } + ] + } + assert "Error while processing event StatisticsTask" not in caplog.text + + +@pytest.mark.parametrize( + ("attributes", "expected_mean", "expected_min", "expected_max"), + [ + (TEMPERATURE_SENSOR_ATTRIBUTES, 21.1864406779661, 10.0, 25.0), + (WIND_DIRECTION_ATTRIBUTES, 21.202479155239875, None, None), + ], +) async def test_compile_hourly_statistics_partially_unavailable( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + attributes: dict, + expected_mean: float, + expected_min: float | None, + expected_max: float | None, ) -> None: """Test compiling hourly statistics, with the sensor being partially unavailable.""" zero = get_start_time(dt_util.utcnow()) @@ -2397,7 +2712,7 @@ async def test_compile_hourly_statistics_partially_unavailable( # Wait for the sensor recorder platform to be added await async_recorder_block_till_done(hass) four, states = await async_record_states_partially_unavailable( - hass, zero, "sensor.test1", TEMPERATURE_SENSOR_ATTRIBUTES + hass, zero, "sensor.test1", attributes ) await async_wait_recording_done(hass) hist = history.get_significant_states( @@ -2413,9 +2728,9 @@ async def test_compile_hourly_statistics_partially_unavailable( { "start": process_timestamp(zero).timestamp(), "end": process_timestamp(zero + timedelta(minutes=5)).timestamp(), - "mean": pytest.approx(21.1864406779661), - "min": pytest.approx(10.0), - "max": pytest.approx(25.0), + "mean": pytest.approx(expected_mean), + "min": pytest.approx(expected_min), + "max": pytest.approx(expected_max), "last_reset": None, "state": None, "sum": None, @@ -2506,6 +2821,58 @@ async def test_compile_hourly_statistics_unavailable( assert "Error while processing event StatisticsTask" not in caplog.text +async def test_compile_hourly_statistics_unavailable_angle( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test compiling hourly statistics, with one sensor being unavailable for measurement_angle. + + sensor.test1 is unavailable and should not have statistics generated + sensor.test2 should have statistics generated + """ + zero = get_start_time(dt_util.utcnow()) + await async_setup_component(hass, "sensor", {}) + # Wait for the sensor recorder platform to be added + await async_recorder_block_till_done(hass) + four, states = await async_record_states_partially_unavailable( + hass, zero, "sensor.test1", WIND_DIRECTION_ATTRIBUTES + ) + with freeze_time(zero) as freezer: + _, _states = await async_record_states( + hass, + freezer, + zero, + "sensor.test2", + WIND_DIRECTION_ATTRIBUTES, + seq=WIND_DIRECTION_STATES_SEQ, + ) + await async_wait_recording_done(hass) + states = {**states, **_states} + hist = history.get_significant_states( + hass, zero, four, hass.states.async_entity_ids() + ) + assert_dict_of_states_equal_without_context_and_last_changed(states, hist) + + do_adhoc_statistics(hass, start=four) + await async_wait_recording_done(hass) + stats = statistics_during_period(hass, four, period="5minute") + assert stats == { + "sensor.test2": [ + { + "start": process_timestamp(four).timestamp(), + "end": process_timestamp(four + timedelta(minutes=5)).timestamp(), + "mean": pytest.approx(15), + "min": None, + "max": None, + "last_reset": None, + "state": None, + "sum": None, + } + ] + } + assert "Error while processing event StatisticsTask" not in caplog.text + + async def test_compile_hourly_statistics_fails( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: @@ -2534,59 +2901,267 @@ async def test_compile_hourly_statistics_fails( "statistic_type", ), [ - ("measurement", "area", "m²", "m²", "m²", "area", "mean"), - ("measurement", "area", "mi²", "mi²", "mi²", "area", "mean"), + ("measurement", "area", "m²", "m²", "m²", "area", StatisticMeanType.ARITHMETIC), + ( + "measurement", + "area", + "mi²", + "mi²", + "mi²", + "area", + StatisticMeanType.ARITHMETIC, + ), ("total", "area", "m²", "m²", "m²", "area", "sum"), ("total", "area", "mi²", "mi²", "mi²", "area", "sum"), - ("measurement", "battery", "%", "%", "%", "unitless", "mean"), - ("measurement", "battery", None, None, None, "unitless", "mean"), - ("measurement", "distance", "m", "m", "m", "distance", "mean"), - ("measurement", "distance", "mi", "mi", "mi", "distance", "mean"), + ( + "measurement", + "battery", + "%", + "%", + "%", + "unitless", + StatisticMeanType.ARITHMETIC, + ), + ( + "measurement", + "battery", + None, + None, + None, + "unitless", + StatisticMeanType.ARITHMETIC, + ), + ( + "measurement", + "distance", + "m", + "m", + "m", + "distance", + StatisticMeanType.ARITHMETIC, + ), + ( + "measurement", + "distance", + "mi", + "mi", + "mi", + "distance", + StatisticMeanType.ARITHMETIC, + ), ("total", "distance", "m", "m", "m", "distance", "sum"), ("total", "distance", "mi", "mi", "mi", "distance", "sum"), ("total", "energy", "Wh", "Wh", "Wh", "energy", "sum"), ("total", "energy", "kWh", "kWh", "kWh", "energy", "sum"), - ("measurement", "energy", "Wh", "Wh", "Wh", "energy", "mean"), - ("measurement", "energy", "kWh", "kWh", "kWh", "energy", "mean"), - ("measurement", "humidity", "%", "%", "%", "unitless", "mean"), - ("measurement", "humidity", None, None, None, "unitless", "mean"), + ( + "measurement", + "energy", + "Wh", + "Wh", + "Wh", + "energy", + StatisticMeanType.ARITHMETIC, + ), + ( + "measurement", + "energy", + "kWh", + "kWh", + "kWh", + "energy", + StatisticMeanType.ARITHMETIC, + ), + ( + "measurement", + "humidity", + "%", + "%", + "%", + "unitless", + StatisticMeanType.ARITHMETIC, + ), + ( + "measurement", + "humidity", + None, + None, + None, + "unitless", + StatisticMeanType.ARITHMETIC, + ), ("total", "monetary", "USD", "USD", "USD", None, "sum"), ("total", "monetary", "None", "None", "None", None, "sum"), ("total", "gas", "m³", "m³", "m³", "volume", "sum"), ("total", "gas", "ft³", "ft³", "ft³", "volume", "sum"), - ("measurement", "monetary", "USD", "USD", "USD", None, "mean"), - ("measurement", "monetary", "None", "None", "None", None, "mean"), - ("measurement", "gas", "m³", "m³", "m³", "volume", "mean"), - ("measurement", "gas", "ft³", "ft³", "ft³", "volume", "mean"), - ("measurement", "pressure", "Pa", "Pa", "Pa", "pressure", "mean"), - ("measurement", "pressure", "hPa", "hPa", "hPa", "pressure", "mean"), - ("measurement", "pressure", "mbar", "mbar", "mbar", "pressure", "mean"), - ("measurement", "pressure", "inHg", "inHg", "inHg", "pressure", "mean"), - ("measurement", "pressure", "psi", "psi", "psi", "pressure", "mean"), - ("measurement", "speed", "m/s", "m/s", "m/s", "speed", "mean"), - ("measurement", "speed", "mph", "mph", "mph", "speed", "mean"), - ("measurement", "temperature", "°C", "°C", "°C", "temperature", "mean"), - ("measurement", "temperature", "°F", "°F", "°F", "temperature", "mean"), - ("measurement", "volume", "m³", "m³", "m³", "volume", "mean"), - ("measurement", "volume", "ft³", "ft³", "ft³", "volume", "mean"), + ( + "measurement", + "monetary", + "USD", + "USD", + "USD", + None, + StatisticMeanType.ARITHMETIC, + ), + ( + "measurement", + "monetary", + "None", + "None", + "None", + None, + StatisticMeanType.ARITHMETIC, + ), + ( + "measurement", + "gas", + "m³", + "m³", + "m³", + "volume", + StatisticMeanType.ARITHMETIC, + ), + ( + "measurement", + "gas", + "ft³", + "ft³", + "ft³", + "volume", + StatisticMeanType.ARITHMETIC, + ), + ( + "measurement", + "pressure", + "Pa", + "Pa", + "Pa", + "pressure", + StatisticMeanType.ARITHMETIC, + ), + ( + "measurement", + "pressure", + "hPa", + "hPa", + "hPa", + "pressure", + StatisticMeanType.ARITHMETIC, + ), + ( + "measurement", + "pressure", + "mbar", + "mbar", + "mbar", + "pressure", + StatisticMeanType.ARITHMETIC, + ), + ( + "measurement", + "pressure", + "inHg", + "inHg", + "inHg", + "pressure", + StatisticMeanType.ARITHMETIC, + ), + ( + "measurement", + "pressure", + "psi", + "psi", + "psi", + "pressure", + StatisticMeanType.ARITHMETIC, + ), + ( + "measurement", + "speed", + "m/s", + "m/s", + "m/s", + "speed", + StatisticMeanType.ARITHMETIC, + ), + ( + "measurement", + "speed", + "mph", + "mph", + "mph", + "speed", + StatisticMeanType.ARITHMETIC, + ), + ( + "measurement", + "temperature", + "°C", + "°C", + "°C", + "temperature", + StatisticMeanType.ARITHMETIC, + ), + ( + "measurement", + "temperature", + "°F", + "°F", + "°F", + "temperature", + StatisticMeanType.ARITHMETIC, + ), + ( + "measurement", + "volume", + "m³", + "m³", + "m³", + "volume", + StatisticMeanType.ARITHMETIC, + ), + ( + "measurement", + "volume", + "ft³", + "ft³", + "ft³", + "volume", + StatisticMeanType.ARITHMETIC, + ), ("total", "volume", "m³", "m³", "m³", "volume", "sum"), ("total", "volume", "ft³", "ft³", "ft³", "volume", "sum"), - ("measurement", "weight", "g", "g", "g", "mass", "mean"), - ("measurement", "weight", "oz", "oz", "oz", "mass", "mean"), + ("measurement", "weight", "g", "g", "g", "mass", StatisticMeanType.ARITHMETIC), + ( + "measurement", + "weight", + "oz", + "oz", + "oz", + "mass", + StatisticMeanType.ARITHMETIC, + ), ("total", "weight", "g", "g", "g", "mass", "sum"), ("total", "weight", "oz", "oz", "oz", "mass", "sum"), + ( + SensorStateClass.MEASUREMENT_ANGLE, + SensorDeviceClass.WIND_DIRECTION, + DEGREE, + DEGREE, + DEGREE, + None, + StatisticMeanType.CIRCULAR, + ), ], ) async def test_list_statistic_ids( hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, - state_class, - device_class, - state_unit, - display_unit, - statistics_unit, - unit_class, - statistic_type, + state_class: str | SensorStateClass, + device_class: str | SensorDeviceClass, + state_unit: str, + display_unit: str, + statistics_unit: str, + unit_class: str | None, + statistic_type: str | StatisticMeanType, ) -> None: """Test listing future statistic ids.""" await async_setup_component(hass, "sensor", {}) @@ -2600,11 +3175,20 @@ async def test_list_statistic_ids( } hass.states.async_set("sensor.test1", 0, attributes=attributes) statistic_ids = await async_list_statistic_ids(hass) + mean_type = ( + statistic_type + if isinstance(statistic_type, StatisticMeanType) + else StatisticMeanType.NONE + ) + statistic_type = ( + statistic_type if not isinstance(statistic_type, StatisticMeanType) else "mean" + ) assert statistic_ids == [ { "statistic_id": "sensor.test1", "display_unit_of_measurement": display_unit, - "has_mean": statistic_type == "mean", + "has_mean": mean_type is StatisticMeanType.ARITHMETIC, + "mean_type": mean_type, "has_sum": statistic_type == "sum", "name": None, "source": "recorder", @@ -2612,6 +3196,7 @@ async def test_list_statistic_ids( "unit_class": unit_class, }, ] + for stat_type in ("mean", "sum", "dogs"): statistic_ids = await async_list_statistic_ids(hass, statistic_type=stat_type) if statistic_type == stat_type: @@ -2619,7 +3204,8 @@ async def test_list_statistic_ids( { "statistic_id": "sensor.test1", "display_unit_of_measurement": display_unit, - "has_mean": statistic_type == "mean", + "has_mean": mean_type is StatisticMeanType.ARITHMETIC, + "mean_type": mean_type, "has_sum": statistic_type == "sum", "name": None, "source": "recorder", @@ -2727,6 +3313,7 @@ async def test_compile_hourly_statistics_changing_units_1( "statistic_id": "sensor.test1", "display_unit_of_measurement": state_unit, "has_mean": True, + "mean_type": StatisticMeanType.ARITHMETIC, "has_sum": False, "name": None, "source": "recorder", @@ -2762,6 +3349,7 @@ async def test_compile_hourly_statistics_changing_units_1( "statistic_id": "sensor.test1", "display_unit_of_measurement": state_unit, "has_mean": True, + "mean_type": StatisticMeanType.ARITHMETIC, "has_sum": False, "name": None, "source": "recorder", @@ -2853,6 +3441,7 @@ async def test_compile_hourly_statistics_changing_units_2( "statistic_id": "sensor.test1", "display_unit_of_measurement": "cats", "has_mean": True, + "mean_type": StatisticMeanType.ARITHMETIC, "has_sum": False, "name": None, "source": "recorder", @@ -2935,6 +3524,7 @@ async def test_compile_hourly_statistics_changing_units_3( "statistic_id": "sensor.test1", "display_unit_of_measurement": display_unit, "has_mean": True, + "mean_type": StatisticMeanType.ARITHMETIC, "has_sum": False, "name": None, "source": "recorder", @@ -2970,6 +3560,7 @@ async def test_compile_hourly_statistics_changing_units_3( "statistic_id": "sensor.test1", "display_unit_of_measurement": display_unit, "has_mean": True, + "mean_type": StatisticMeanType.ARITHMETIC, "has_sum": False, "name": None, "source": "recorder", @@ -3052,6 +3643,7 @@ async def test_compile_hourly_statistics_convert_units_1( "statistic_id": "sensor.test1", "display_unit_of_measurement": state_unit_1, "has_mean": True, + "mean_type": StatisticMeanType.ARITHMETIC, "has_sum": False, "name": None, "source": "recorder", @@ -3099,6 +3691,7 @@ async def test_compile_hourly_statistics_convert_units_1( "statistic_id": "sensor.test1", "display_unit_of_measurement": state_unit_2, "has_mean": True, + "mean_type": StatisticMeanType.ARITHMETIC, "has_sum": False, "name": None, "source": "recorder", @@ -3215,6 +3808,7 @@ async def test_compile_hourly_statistics_equivalent_units_1( "statistic_id": "sensor.test1", "display_unit_of_measurement": state_unit, "has_mean": True, + "mean_type": StatisticMeanType.ARITHMETIC, "has_sum": False, "name": None, "source": "recorder", @@ -3246,6 +3840,7 @@ async def test_compile_hourly_statistics_equivalent_units_1( "statistic_id": "sensor.test1", "display_unit_of_measurement": state_unit2, "has_mean": True, + "mean_type": StatisticMeanType.ARITHMETIC, "has_sum": False, "name": None, "source": "recorder", @@ -3337,6 +3932,7 @@ async def test_compile_hourly_statistics_equivalent_units_2( "statistic_id": "sensor.test1", "display_unit_of_measurement": state_unit, "has_mean": True, + "mean_type": StatisticMeanType.ARITHMETIC, "has_sum": False, "name": None, "source": "recorder", @@ -3421,6 +4017,7 @@ async def test_compile_hourly_statistics_changing_device_class_1( "statistic_id": "sensor.test1", "display_unit_of_measurement": state_unit, "has_mean": True, + "mean_type": StatisticMeanType.ARITHMETIC, "has_sum": False, "name": None, "source": "recorder", @@ -3470,6 +4067,7 @@ async def test_compile_hourly_statistics_changing_device_class_1( "statistic_id": "sensor.test1", "display_unit_of_measurement": state_unit, "has_mean": True, + "mean_type": StatisticMeanType.ARITHMETIC, "has_sum": False, "name": None, "source": "recorder", @@ -3529,6 +4127,7 @@ async def test_compile_hourly_statistics_changing_device_class_1( "statistic_id": "sensor.test1", "display_unit_of_measurement": state_unit, "has_mean": True, + "mean_type": StatisticMeanType.ARITHMETIC, "has_sum": False, "name": None, "source": "recorder", @@ -3633,6 +4232,7 @@ async def test_compile_hourly_statistics_changing_device_class_2( "statistic_id": "sensor.test1", "display_unit_of_measurement": display_unit, "has_mean": True, + "mean_type": StatisticMeanType.ARITHMETIC, "has_sum": False, "name": None, "source": "recorder", @@ -3682,6 +4282,7 @@ async def test_compile_hourly_statistics_changing_device_class_2( "statistic_id": "sensor.test1", "display_unit_of_measurement": display_unit, "has_mean": True, + "mean_type": StatisticMeanType.ARITHMETIC, "has_sum": False, "name": None, "source": "recorder", @@ -3721,15 +4322,13 @@ async def test_compile_hourly_statistics_changing_device_class_2( ( "device_class", "state_unit", - "display_unit", - "statistics_unit", "unit_class", "mean", "min", "max", ), [ - (None, None, None, None, "unitless", 13.050847, -10, 30), + (None, None, "unitless", 13.050847, -10, 30), ], ) async def test_compile_hourly_statistics_changing_state_class( @@ -3737,8 +4336,6 @@ async def test_compile_hourly_statistics_changing_state_class( caplog: pytest.LogCaptureFixture, device_class, state_unit, - display_unit, - statistics_unit, unit_class, mean, min, @@ -3774,6 +4371,7 @@ async def test_compile_hourly_statistics_changing_state_class( "statistic_id": "sensor.test1", "display_unit_of_measurement": None, "has_mean": True, + "mean_type": StatisticMeanType.ARITHMETIC, "has_sum": False, "name": None, "source": "recorder", @@ -3787,6 +4385,7 @@ async def test_compile_hourly_statistics_changing_state_class( 1, { "has_mean": True, + "mean_type": StatisticMeanType.ARITHMETIC, "has_sum": False, "name": None, "source": "recorder", @@ -3816,6 +4415,7 @@ async def test_compile_hourly_statistics_changing_state_class( "statistic_id": "sensor.test1", "display_unit_of_measurement": None, "has_mean": False, + "mean_type": StatisticMeanType.NONE, "has_sum": True, "name": None, "source": "recorder", @@ -3829,6 +4429,7 @@ async def test_compile_hourly_statistics_changing_state_class( 1, { "has_mean": False, + "mean_type": StatisticMeanType.NONE, "has_sum": True, "name": None, "source": "recorder", @@ -3894,10 +4495,11 @@ async def test_compile_statistics_hourly_daily_monthly_summary( "unit_of_measurement": "EUR", } + durations = [50, 200, 45] + def _weighted_average(seq, i, last_state): total = 0 duration = 0 - durations = [50, 200, 45] if i > 0: total += last_state * 5 duration += 5 @@ -3906,6 +4508,24 @@ async def test_compile_statistics_hourly_daily_monthly_summary( duration += dur return total / duration + def _time_weighted_circular_mean(values: list[tuple[float, int]]): + sin_sum = 0 + cos_sum = 0 + for x, dur in values: + sin_sum += math.sin(x * DEG_TO_RAD) * dur + cos_sum += math.cos(x * DEG_TO_RAD) * dur + + return (RAD_TO_DEG * math.atan2(sin_sum, cos_sum)) % 360 + + def _circular_mean(values: list[float]) -> float: + sin_sum = 0 + cos_sum = 0 + for x in values: + sin_sum += math.sin(x * DEG_TO_RAD) + cos_sum += math.cos(x * DEG_TO_RAD) + + return (RAD_TO_DEG * math.atan2(sin_sum, cos_sum)) % 360 + def _min(seq, last_state): if last_state is None: return min(seq) @@ -3927,17 +4547,24 @@ async def test_compile_statistics_hourly_daily_monthly_summary( "sensor.test2": [], "sensor.test3": [], "sensor.test4": [], + "sensor.test5": [], } expected_minima = {"sensor.test1": [], "sensor.test2": [], "sensor.test3": []} expected_maxima = {"sensor.test1": [], "sensor.test2": [], "sensor.test3": []} - expected_averages = {"sensor.test1": [], "sensor.test2": [], "sensor.test3": []} + expected_means = { + "sensor.test1": [], + "sensor.test2": [], + "sensor.test3": [], + "sensor.test5": [], + } expected_states = {"sensor.test4": []} expected_sums = {"sensor.test4": []} - last_states = { + last_states: dict[str, float | None] = { "sensor.test1": None, "sensor.test2": None, "sensor.test3": None, "sensor.test4": None, + "sensor.test5": None, } start = zero for i in range(24): @@ -3950,7 +4577,7 @@ async def test_compile_statistics_hourly_daily_monthly_summary( last_state = last_states["sensor.test1"] expected_minima["sensor.test1"].append(_min(seq, last_state)) expected_maxima["sensor.test1"].append(_max(seq, last_state)) - expected_averages["sensor.test1"].append(_weighted_average(seq, i, last_state)) + expected_means["sensor.test1"].append(_weighted_average(seq, i, last_state)) last_states["sensor.test1"] = seq[-1] # test2 values change: min/max at the last state seq = [-10 * (i + 1), 15 * (i + 1), 30 * (i + 1)] @@ -3961,7 +4588,7 @@ async def test_compile_statistics_hourly_daily_monthly_summary( last_state = last_states["sensor.test2"] expected_minima["sensor.test2"].append(_min(seq, last_state)) expected_maxima["sensor.test2"].append(_max(seq, last_state)) - expected_averages["sensor.test2"].append(_weighted_average(seq, i, last_state)) + expected_means["sensor.test2"].append(_weighted_average(seq, i, last_state)) last_states["sensor.test2"] = seq[-1] # test3 values change: min/max at the first state seq = [-10 * (23 - i + 1), 15 * (23 - i + 1), 30 * (23 - i + 1)] @@ -3972,7 +4599,7 @@ async def test_compile_statistics_hourly_daily_monthly_summary( last_state = last_states["sensor.test3"] expected_minima["sensor.test3"].append(_min(seq, last_state)) expected_maxima["sensor.test3"].append(_max(seq, last_state)) - expected_averages["sensor.test3"].append(_weighted_average(seq, i, last_state)) + expected_means["sensor.test3"].append(_weighted_average(seq, i, last_state)) last_states["sensor.test3"] = seq[-1] # test4 values grow seq = [i, i + 0.5, i + 0.75] @@ -3995,6 +4622,18 @@ async def test_compile_statistics_hourly_daily_monthly_summary( ) last_states["sensor.test4"] = seq[-1] + # test5 circular mean + seq = [350 - i, 0 + (i / 2.0), 15 + i] + four, _states = await async_record_states( + hass, freezer, start, "sensor.test5", WIND_DIRECTION_ATTRIBUTES, seq + ) + states["sensor.test5"] += _states["sensor.test5"] + values = [(seq, durations[j]) for j, seq in enumerate(seq)] + if (state := last_states["sensor.test5"]) is not None: + values.append((state, 5)) + expected_means["sensor.test5"].append(_time_weighted_circular_mean(values)) + last_states["sensor.test5"] = seq[-1] + start += timedelta(minutes=5) await async_wait_recording_done(hass) hist = history.get_significant_states( @@ -4020,6 +4659,7 @@ async def test_compile_statistics_hourly_daily_monthly_summary( "statistic_id": "sensor.test1", "display_unit_of_measurement": "%", "has_mean": True, + "mean_type": StatisticMeanType.ARITHMETIC, "has_sum": False, "name": None, "source": "recorder", @@ -4030,6 +4670,7 @@ async def test_compile_statistics_hourly_daily_monthly_summary( "statistic_id": "sensor.test2", "display_unit_of_measurement": "%", "has_mean": True, + "mean_type": StatisticMeanType.ARITHMETIC, "has_sum": False, "name": None, "source": "recorder", @@ -4040,6 +4681,7 @@ async def test_compile_statistics_hourly_daily_monthly_summary( "statistic_id": "sensor.test3", "display_unit_of_measurement": "%", "has_mean": True, + "mean_type": StatisticMeanType.ARITHMETIC, "has_sum": False, "name": None, "source": "recorder", @@ -4050,12 +4692,24 @@ async def test_compile_statistics_hourly_daily_monthly_summary( "statistic_id": "sensor.test4", "display_unit_of_measurement": "EUR", "has_mean": False, + "mean_type": StatisticMeanType.NONE, "has_sum": True, "name": None, "source": "recorder", "statistics_unit_of_measurement": "EUR", "unit_class": None, }, + { + "statistic_id": "sensor.test5", + "display_unit_of_measurement": DEGREE, + "has_mean": False, + "mean_type": StatisticMeanType.CIRCULAR, + "has_sum": False, + "name": None, + "source": "recorder", + "statistics_unit_of_measurement": DEGREE, + "unit_class": None, + }, ] # Adjust the inserted statistics @@ -4074,6 +4728,7 @@ async def test_compile_statistics_hourly_daily_monthly_summary( "sensor.test2": [], "sensor.test3": [], "sensor.test4": [], + "sensor.test5": [], } start = zero end = zero + timedelta(minutes=5) @@ -4083,11 +4738,10 @@ async def test_compile_statistics_hourly_daily_monthly_summary( "sensor.test2", "sensor.test3", "sensor.test4", + "sensor.test5", ): expected_average = ( - expected_averages[entity_id][i] - if entity_id in expected_averages - else None + expected_means[entity_id][i] if entity_id in expected_means else None ) expected_minimum = ( expected_minima[entity_id][i] if entity_id in expected_minima else None @@ -4117,176 +4771,78 @@ async def test_compile_statistics_hourly_daily_monthly_summary( end += timedelta(minutes=5) assert stats == expected_stats - stats = statistics_during_period(hass, zero, period="hour") - expected_stats = { - "sensor.test1": [], - "sensor.test2": [], - "sensor.test3": [], - "sensor.test4": [], - } - start = zero - end = zero + timedelta(hours=1) - for i in range(2): - for entity_id in ( - "sensor.test1", - "sensor.test2", - "sensor.test3", - "sensor.test4", - ): - expected_average = ( - mean(expected_averages[entity_id][i * 12 : (i + 1) * 12]) - if entity_id in expected_averages - else None - ) - expected_minimum = ( - min(expected_minima[entity_id][i * 12 : (i + 1) * 12]) - if entity_id in expected_minima - else None - ) - expected_maximum = ( - max(expected_maxima[entity_id][i * 12 : (i + 1) * 12]) - if entity_id in expected_maxima - else None - ) - expected_state = ( - expected_states[entity_id][(i + 1) * 12 - 1] - if entity_id in expected_states - else None - ) - expected_sum = ( - expected_sums[entity_id][(i + 1) * 12 - 1] - if entity_id in expected_sums - else None - ) - expected_stats[entity_id].append( - { - "start": process_timestamp(start).timestamp(), - "end": process_timestamp(end).timestamp(), - "mean": pytest.approx(expected_average), - "min": pytest.approx(expected_minimum), - "max": pytest.approx(expected_maximum), - "last_reset": None, - "state": expected_state, - "sum": expected_sum, - } - ) - start += timedelta(hours=1) - end += timedelta(hours=1) - assert stats == expected_stats + def verify_stats( + period: Literal["5minute", "day", "hour", "week", "month"], + start: datetime, + next_datetime: Callable[[datetime], datetime], + ) -> None: + stats = statistics_during_period(hass, zero, period=period) + expected_stats = { + "sensor.test1": [], + "sensor.test2": [], + "sensor.test3": [], + "sensor.test4": [], + "sensor.test5": [], + } + end = next_datetime(start) + for i in range(2): + for entity_id, mean_fn in ( + ("sensor.test1", mean), + ("sensor.test2", mean), + ("sensor.test3", mean), + ("sensor.test4", mean), + ("sensor.test5", _circular_mean), + ): + expected_average = ( + mean_fn(expected_means[entity_id][i * 12 : (i + 1) * 12]) + if entity_id in expected_means + else None + ) + expected_minimum = ( + min(expected_minima[entity_id][i * 12 : (i + 1) * 12]) + if entity_id in expected_minima + else None + ) + expected_maximum = ( + max(expected_maxima[entity_id][i * 12 : (i + 1) * 12]) + if entity_id in expected_maxima + else None + ) + expected_state = ( + expected_states[entity_id][(i + 1) * 12 - 1] + if entity_id in expected_states + else None + ) + expected_sum = ( + expected_sums[entity_id][(i + 1) * 12 - 1] + if entity_id in expected_sums + else None + ) + expected_stats[entity_id].append( + { + "start": process_timestamp(start).timestamp(), + "end": process_timestamp(end).timestamp(), + "mean": pytest.approx(expected_average), + "min": pytest.approx(expected_minimum), + "max": pytest.approx(expected_maximum), + "last_reset": None, + "state": expected_state, + "sum": expected_sum, + } + ) + start = next_datetime(start) + end = next_datetime(end) + assert stats == expected_stats + + verify_stats("hour", zero, lambda v: v + timedelta(hours=1)) - stats = statistics_during_period(hass, zero, period="day") - expected_stats = { - "sensor.test1": [], - "sensor.test2": [], - "sensor.test3": [], - "sensor.test4": [], - } start = dt_util.parse_datetime("2021-08-31T06:00:00+00:00") - end = start + timedelta(days=1) - for i in range(2): - for entity_id in ( - "sensor.test1", - "sensor.test2", - "sensor.test3", - "sensor.test4", - ): - expected_average = ( - mean(expected_averages[entity_id][i * 12 : (i + 1) * 12]) - if entity_id in expected_averages - else None - ) - expected_minimum = ( - min(expected_minima[entity_id][i * 12 : (i + 1) * 12]) - if entity_id in expected_minima - else None - ) - expected_maximum = ( - max(expected_maxima[entity_id][i * 12 : (i + 1) * 12]) - if entity_id in expected_maxima - else None - ) - expected_state = ( - expected_states[entity_id][(i + 1) * 12 - 1] - if entity_id in expected_states - else None - ) - expected_sum = ( - expected_sums[entity_id][(i + 1) * 12 - 1] - if entity_id in expected_sums - else None - ) - expected_stats[entity_id].append( - { - "start": process_timestamp(start).timestamp(), - "end": process_timestamp(end).timestamp(), - "mean": pytest.approx(expected_average), - "min": pytest.approx(expected_minimum), - "max": pytest.approx(expected_maximum), - "last_reset": None, - "state": expected_state, - "sum": expected_sum, - } - ) - start += timedelta(days=1) - end += timedelta(days=1) - assert stats == expected_stats + assert start + verify_stats("day", start, lambda v: v + timedelta(days=1)) - stats = statistics_during_period(hass, zero, period="month") - expected_stats = { - "sensor.test1": [], - "sensor.test2": [], - "sensor.test3": [], - "sensor.test4": [], - } start = dt_util.parse_datetime("2021-08-01T06:00:00+00:00") - end = dt_util.parse_datetime("2021-09-01T06:00:00+00:00") - for i in range(2): - for entity_id in ( - "sensor.test1", - "sensor.test2", - "sensor.test3", - "sensor.test4", - ): - expected_average = ( - mean(expected_averages[entity_id][i * 12 : (i + 1) * 12]) - if entity_id in expected_averages - else None - ) - expected_minimum = ( - min(expected_minima[entity_id][i * 12 : (i + 1) * 12]) - if entity_id in expected_minima - else None - ) - expected_maximum = ( - max(expected_maxima[entity_id][i * 12 : (i + 1) * 12]) - if entity_id in expected_maxima - else None - ) - expected_state = ( - expected_states[entity_id][(i + 1) * 12 - 1] - if entity_id in expected_states - else None - ) - expected_sum = ( - expected_sums[entity_id][(i + 1) * 12 - 1] - if entity_id in expected_sums - else None - ) - expected_stats[entity_id].append( - { - "start": process_timestamp(start).timestamp(), - "end": process_timestamp(end).timestamp(), - "mean": pytest.approx(expected_average), - "min": pytest.approx(expected_minimum), - "max": pytest.approx(expected_maximum), - "last_reset": None, - "state": expected_state, - "sum": expected_sum, - } - ) - start = (start + timedelta(days=31)).replace(day=1) - end = (end + timedelta(days=31)).replace(day=1) - assert stats == expected_stats + assert start + verify_stats("month", start, lambda v: (v + timedelta(days=31)).replace(day=1)) assert "Error while processing event StatisticsTask" not in caplog.text @@ -5579,6 +6135,7 @@ async def test_clean_up_repairs( create_issue(DOMAIN, "test_issue_2", {"issue_type": "another_issue"}) create_issue(DOMAIN, "test_issue_3", {"issue_type": STATE_CLASS_REMOVED_ISSUE}) create_issue(DOMAIN, "test_issue_4", {"issue_type": UNITS_CHANGED_ISSUE}) + create_issue(DOMAIN, "test_issue_5", {"issue_type": MEAN_TYPE_CHANGED_ISSUE}) # Check the issues assert set(issue_registry.issues) == { @@ -5587,6 +6144,7 @@ async def test_clean_up_repairs( ("sensor", "test_issue_2"), ("sensor", "test_issue_3"), ("sensor", "test_issue_4"), + ("sensor", "test_issue_5"), } # Request update of issues @@ -5600,3 +6158,140 @@ async def test_clean_up_repairs( ("sensor", "test_issue_1"), ("sensor", "test_issue_2"), } + + +async def test_validate_statistics_mean_type_changed( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test validate_statistics. + + This tests a validation issue is created when a the mean type is changed. + """ + now = get_start_time(dt_util.utcnow()) + + await async_setup_component(hass, "sensor", {}) + await async_recorder_block_till_done(hass) + client = await hass_ws_client() + + # No statistics, no state - empty response + await assert_validation_result(hass, client, {}, {}) + + # No statistics, original unit - empty response + hass.states.async_set( + "sensor.wind_direction", + 10, + attributes=WIND_DIRECTION_ATTRIBUTES, + timestamp=now.timestamp(), + ) + await assert_validation_result(hass, client, {}, {}) + + # Run statistics + await async_recorder_block_till_done(hass) + do_adhoc_statistics(hass, start=now) + await async_recorder_block_till_done(hass) + statistic_ids = await async_list_statistic_ids(hass) + assert statistic_ids == [ + { + "statistic_id": "sensor.wind_direction", + "display_unit_of_measurement": DEGREE, + "has_mean": False, + "mean_type": StatisticMeanType.CIRCULAR, + "has_sum": False, + "name": None, + "source": "recorder", + "statistics_unit_of_measurement": DEGREE, + "unit_class": None, + } + ] + + expected_log_entry = ( + "homeassistant.components.sensor.recorder", + logging.WARNING, + ( + "The statistics mean algorithm for sensor.wind_direction have changed from" + " CIRCULAR to ARITHMETIC. Generation of long term statistics will be " + "suppressed unless it changes back or go to " + "https://my.home-assistant.io/redirect/developer_statistics " + "to delete the old statistics" + ), + ) + # Valid stats, no log entry + assert expected_log_entry not in caplog.record_tuples + + # State class changed + hass.states.async_set( + "sensor.wind_direction", + 5, + attributes={ + **WIND_DIRECTION_ATTRIBUTES, + "state_class": SensorStateClass.MEASUREMENT, + }, + timestamp=now.timestamp(), + ) + expected = { + "sensor.wind_direction": [ + { + "data": { + "statistic_id": "sensor.wind_direction", + "metadata_mean_type": StatisticMeanType.CIRCULAR, + "state_mean_type": StatisticMeanType.ARITHMETIC, + }, + "type": MEAN_TYPE_CHANGED_ISSUE, + } + ], + } + await assert_validation_result(hass, client, expected, {MEAN_TYPE_CHANGED_ISSUE}) + + # Run statistics one hour later, metadata will not be updated + await async_recorder_block_till_done(hass) + do_adhoc_statistics(hass, start=now + timedelta(hours=1)) + await async_recorder_block_till_done(hass) + statistic_ids = await async_list_statistic_ids(hass) + assert statistic_ids == [ + { + "statistic_id": "sensor.wind_direction", + "display_unit_of_measurement": DEGREE, + "has_mean": False, + "mean_type": StatisticMeanType.CIRCULAR, + "has_sum": False, + "name": None, + "source": "recorder", + "statistics_unit_of_measurement": DEGREE, + "unit_class": None, + } + ] + await assert_validation_result(hass, client, expected, {MEAN_TYPE_CHANGED_ISSUE}) + assert expected_log_entry in caplog.record_tuples + + # State class changed back + hass.states.async_set( + "sensor.wind_direction", + 350, + attributes=WIND_DIRECTION_ATTRIBUTES, + timestamp=now.timestamp(), + ) + await assert_validation_result(hass, client, {}, {}) + + # Run statistics + await async_recorder_block_till_done(hass) + do_adhoc_statistics(hass, start=now) + await async_recorder_block_till_done(hass) + statistic_ids = await async_list_statistic_ids(hass) + assert statistic_ids == [ + { + "statistic_id": "sensor.wind_direction", + "display_unit_of_measurement": DEGREE, + "has_mean": False, + "mean_type": StatisticMeanType.CIRCULAR, + "has_sum": False, + "name": None, + "source": "recorder", + "statistics_unit_of_measurement": DEGREE, + "unit_class": None, + } + ] + + # Issue should be resolved + await assert_validation_result(hass, client, {}, {}) From 06f6c86ba5081c25201f0984afeffa93acd00145 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Wed, 26 Mar 2025 05:19:48 -0700 Subject: [PATCH 3077/3148] Simplify roborock map storage test fixture (#141430) --- tests/components/roborock/conftest.py | 40 +++++++------------ tests/components/roborock/test_config_flow.py | 1 - tests/components/roborock/test_init.py | 20 +++++----- 3 files changed, 25 insertions(+), 36 deletions(-) diff --git a/tests/components/roborock/conftest.py b/tests/components/roborock/conftest.py index 758b002f534..1ec2b00263f 100644 --- a/tests/components/roborock/conftest.py +++ b/tests/components/roborock/conftest.py @@ -3,10 +3,9 @@ from collections.abc import Generator from copy import deepcopy import pathlib -import shutil +import tempfile from typing import Any from unittest.mock import Mock, patch -import uuid import pytest from roborock import RoborockCategory, RoomMapping @@ -19,7 +18,6 @@ from homeassistant.components.roborock.const import ( CONF_USER_DATA, DOMAIN, ) -from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_USERNAME, Platform from homeassistant.core import HomeAssistant @@ -218,7 +216,6 @@ async def setup_entry( hass: HomeAssistant, bypass_api_fixture, mock_roborock_entry: MockConfigEntry, - cleanup_map_storage: pathlib.Path, platforms: list[Platform], ) -> Generator[MockConfigEntry]: """Set up the Roborock platform.""" @@ -228,27 +225,18 @@ async def setup_entry( yield mock_roborock_entry -@pytest.fixture(autouse=True) -async def cleanup_map_storage(cleanup_map_storage_manual) -> Generator[pathlib.Path]: - """Test cleanup, remove any map storage persisted during the test.""" - return cleanup_map_storage_manual - - -@pytest.fixture -async def cleanup_map_storage_manual( - hass: HomeAssistant, mock_roborock_entry: MockConfigEntry +@pytest.fixture(autouse=True, name="storage_path") +async def storage_path_fixture( + hass: HomeAssistant, ) -> Generator[pathlib.Path]: """Test cleanup, remove any map storage persisted during the test.""" - tmp_path = str(uuid.uuid4()) - with patch( - "homeassistant.components.roborock.roborock_storage.STORAGE_PATH", new=tmp_path - ): - storage_path = ( - pathlib.Path(hass.config.path(tmp_path)) / mock_roborock_entry.entry_id - ) - yield storage_path - # We need to first unload the config entry because unloading it will - # persist any unsaved maps to storage. - if mock_roborock_entry.state is ConfigEntryState.LOADED: - await hass.config_entries.async_unload(mock_roborock_entry.entry_id) - shutil.rmtree(str(storage_path), ignore_errors=True) + with tempfile.TemporaryDirectory() as tmp_path: + + def get_storage_path(_: HomeAssistant, entry_id: str) -> pathlib.Path: + return pathlib.Path(tmp_path) / entry_id + + with patch( + "homeassistant.components.roborock.roborock_storage._storage_path_prefix", + new=get_storage_path, + ): + yield pathlib.Path(tmp_path) diff --git a/tests/components/roborock/test_config_flow.py b/tests/components/roborock/test_config_flow.py index abd19660fba..441974dc15d 100644 --- a/tests/components/roborock/test_config_flow.py +++ b/tests/components/roborock/test_config_flow.py @@ -331,7 +331,6 @@ async def test_discovery_already_setup( hass: HomeAssistant, bypass_api_fixture, mock_roborock_entry: MockConfigEntry, - cleanup_map_storage_manual, ) -> None: """Handle aborting if the device is already setup.""" await hass.config_entries.async_setup(mock_roborock_entry.entry_id) diff --git a/tests/components/roborock/test_init.py b/tests/components/roborock/test_init.py index 3d288b6479b..983e3d083f4 100644 --- a/tests/components/roborock/test_init.py +++ b/tests/components/roborock/test_init.py @@ -174,7 +174,7 @@ async def test_remove_from_hass( bypass_api_fixture, setup_entry: MockConfigEntry, hass_client: ClientSessionGenerator, - cleanup_map_storage: pathlib.Path, + storage_path: pathlib.Path, ) -> None: """Test that removing from hass removes any existing images.""" @@ -184,17 +184,18 @@ async def test_remove_from_hass( resp = await client.get("/api/image_proxy/image.roborock_s7_maxv_upstairs") assert resp.status == HTTPStatus.OK - assert not cleanup_map_storage.exists() + config_entry_storage = storage_path / setup_entry.entry_id + assert not config_entry_storage.exists() # Flush to disk await hass.config_entries.async_unload(setup_entry.entry_id) - assert cleanup_map_storage.exists() - paths = list(cleanup_map_storage.walk()) + assert config_entry_storage.exists() + paths = list(config_entry_storage.walk()) assert len(paths) == 4 # Two map image and two directories await hass.config_entries.async_remove(setup_entry.entry_id) # After removal, directories should be empty. - assert not cleanup_map_storage.exists() + assert not config_entry_storage.exists() @pytest.mark.parametrize("platforms", [[Platform.IMAGE]]) @@ -202,7 +203,7 @@ async def test_oserror_remove_image( hass: HomeAssistant, bypass_api_fixture, setup_entry: MockConfigEntry, - cleanup_map_storage: pathlib.Path, + storage_path: pathlib.Path, hass_client: ClientSessionGenerator, caplog: pytest.LogCaptureFixture, ) -> None: @@ -215,11 +216,12 @@ async def test_oserror_remove_image( assert resp.status == HTTPStatus.OK # Image content is saved when unloading - assert not cleanup_map_storage.exists() + config_entry_storage = storage_path / setup_entry.entry_id + assert not config_entry_storage.exists() await hass.config_entries.async_unload(setup_entry.entry_id) - assert cleanup_map_storage.exists() - paths = list(cleanup_map_storage.walk()) + assert config_entry_storage.exists() + paths = list(config_entry_storage.walk()) assert len(paths) == 4 # Two map image and two directories with patch( From 82db1ffd12183f2ad47cf47bbb7c64dec3260b74 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 26 Mar 2025 13:28:46 +0100 Subject: [PATCH 3078/3148] Update typing-extensions to 4.13.0 (#141465) --- 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 7ccb1987551..6ed4f9ce387 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -65,7 +65,7 @@ securetar==2025.2.1 SQLAlchemy==2.0.39 standard-aifc==3.13.0 standard-telnetlib==3.13.0 -typing-extensions>=4.12.2,<5.0 +typing-extensions>=4.13.0,<5.0 ulid-transform==1.4.0 urllib3>=1.26.5,<2 uv==0.6.8 diff --git a/pyproject.toml b/pyproject.toml index 4fdc359d77e..750c70b135a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -71,7 +71,7 @@ dependencies = [ "SQLAlchemy==2.0.39", "standard-aifc==3.13.0", "standard-telnetlib==3.13.0", - "typing-extensions>=4.12.2,<5.0", + "typing-extensions>=4.13.0,<5.0", "ulid-transform==1.4.0", # Constrain urllib3 to ensure we deal with CVE-2020-26137 and CVE-2021-33503 # Temporary setting an upper bound, to prevent compat issues with urllib3>=2 diff --git a/requirements.txt b/requirements.txt index dfebcd491ee..00a5a6fa621 100644 --- a/requirements.txt +++ b/requirements.txt @@ -43,7 +43,7 @@ securetar==2025.2.1 SQLAlchemy==2.0.39 standard-aifc==3.13.0 standard-telnetlib==3.13.0 -typing-extensions>=4.12.2,<5.0 +typing-extensions>=4.13.0,<5.0 ulid-transform==1.4.0 urllib3>=1.26.5,<2 uv==0.6.8 From 3f68e327f3d2f2386ee8228560948d77906a1e56 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Wed, 26 Mar 2025 13:30:57 +0100 Subject: [PATCH 3079/3148] Bump uv to 0.6.10 (#141464) --- Dockerfile | 2 +- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- script/hassfest/docker/Dockerfile | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Dockerfile b/Dockerfile index 2efb9d59a44..0a74e0a3aac 100644 --- a/Dockerfile +++ b/Dockerfile @@ -31,7 +31,7 @@ RUN \ && go2rtc --version # Install uv -RUN pip3 install uv==0.6.8 +RUN pip3 install uv==0.6.10 WORKDIR /usr/src diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 6ed4f9ce387..ac47f900f15 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -68,7 +68,7 @@ standard-telnetlib==3.13.0 typing-extensions>=4.13.0,<5.0 ulid-transform==1.4.0 urllib3>=1.26.5,<2 -uv==0.6.8 +uv==0.6.10 voluptuous-openapi==0.0.6 voluptuous-serialize==2.6.0 voluptuous==0.15.2 diff --git a/pyproject.toml b/pyproject.toml index 750c70b135a..a85b3d99c67 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -77,7 +77,7 @@ dependencies = [ # Temporary setting an upper bound, to prevent compat issues with urllib3>=2 # https://github.com/home-assistant/core/issues/97248 "urllib3>=1.26.5,<2", - "uv==0.6.8", + "uv==0.6.10", "voluptuous==0.15.2", "voluptuous-serialize==2.6.0", "voluptuous-openapi==0.0.6", diff --git a/requirements.txt b/requirements.txt index 00a5a6fa621..378240607cf 100644 --- a/requirements.txt +++ b/requirements.txt @@ -46,7 +46,7 @@ standard-telnetlib==3.13.0 typing-extensions>=4.13.0,<5.0 ulid-transform==1.4.0 urllib3>=1.26.5,<2 -uv==0.6.8 +uv==0.6.10 voluptuous==0.15.2 voluptuous-serialize==2.6.0 voluptuous-openapi==0.0.6 diff --git a/script/hassfest/docker/Dockerfile b/script/hassfest/docker/Dockerfile index c4f66faafb0..6101a90d4c0 100644 --- a/script/hassfest/docker/Dockerfile +++ b/script/hassfest/docker/Dockerfile @@ -14,7 +14,7 @@ WORKDIR "/github/workspace" COPY . /usr/src/homeassistant # Uv is only needed during build -RUN --mount=from=ghcr.io/astral-sh/uv:0.6.8,source=/uv,target=/bin/uv \ +RUN --mount=from=ghcr.io/astral-sh/uv:0.6.10,source=/uv,target=/bin/uv \ # Uv creates a lock file in /tmp --mount=type=tmpfs,target=/tmp \ # Required for PyTurboJPEG From 77bf977d63ec49510e6326b8d69b60fc81429399 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Wed, 26 Mar 2025 13:34:24 +0100 Subject: [PATCH 3080/3148] Add sensor as entity platform on MQTT subentries (#139899) * Add sensor as entity platform on MQTT subentries * Fix typo * Improve device class data description * Tweak * Rework reconfig calculation * Filter out last_reset_value_template if state class is not total * Collapse expire after as advanced setting * Update suggested_display_precision translation strings * Make options and last_reset_template conditional, use sections for advanced settings * Ensure options are removed properly * Improve sensor options label, ensure UOM is set when device class has units * Use helper to apply suggested values from component config * Rename to `Add option` * Fix schema builder not hiding empty sections and removing fields excluded from reconfig * Do not hide advanced settings if values are available or are defaults * Improve spelling and Learn more links * Improve unit of measurement validation * Fix UOM selector and translation strings * Address comments from code review * Remove stale comment * Rename selector constant, split validator * Simplify config validator * Return tuple with config and errors for config validation --- homeassistant/components/mqtt/config_flow.py | 455 +++++++++++++++++-- homeassistant/components/mqtt/const.py | 3 + homeassistant/components/mqtt/entity.py | 7 +- homeassistant/components/mqtt/sensor.py | 14 +- homeassistant/components/mqtt/strings.json | 125 ++++- homeassistant/components/mqtt/util.py | 6 + tests/components/mqtt/common.py | 74 ++- tests/components/mqtt/test_config_flow.py | 406 +++++++++++++++-- 8 files changed, 993 insertions(+), 97 deletions(-) diff --git a/homeassistant/components/mqtt/config_flow.py b/homeassistant/components/mqtt/config_flow.py index cc98315c218..acdc225a59a 100644 --- a/homeassistant/components/mqtt/config_flow.py +++ b/homeassistant/components/mqtt/config_flow.py @@ -27,6 +27,12 @@ import voluptuous as vol from homeassistant.components.file_upload import process_uploaded_file from homeassistant.components.hassio import AddonError, AddonManager, AddonState +from homeassistant.components.sensor import ( + CONF_STATE_CLASS, + DEVICE_CLASS_UNITS, + SensorDeviceClass, + SensorStateClass, +) from homeassistant.config_entries import ( SOURCE_RECONFIGURE, ConfigEntry, @@ -45,6 +51,7 @@ from homeassistant.const import ( ATTR_SW_VERSION, CONF_CLIENT_ID, CONF_DEVICE, + CONF_DEVICE_CLASS, CONF_DISCOVERY, CONF_HOST, CONF_NAME, @@ -53,10 +60,12 @@ from homeassistant.const import ( CONF_PLATFORM, CONF_PORT, CONF_PROTOCOL, + CONF_UNIT_OF_MEASUREMENT, CONF_USERNAME, + CONF_VALUE_TEMPLATE, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.data_entry_flow import AbortFlow +from homeassistant.data_entry_flow import AbortFlow, SectionConfig, section from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.hassio import is_hassio from homeassistant.helpers.json import json_dumps @@ -99,11 +108,16 @@ from .const import ( CONF_COMMAND_TOPIC, CONF_DISCOVERY_PREFIX, CONF_ENTITY_PICTURE, + CONF_EXPIRE_AFTER, CONF_KEEPALIVE, + CONF_LAST_RESET_VALUE_TEMPLATE, + CONF_OPTIONS, CONF_PAYLOAD_AVAILABLE, CONF_PAYLOAD_NOT_AVAILABLE, CONF_QOS, CONF_RETAIN, + CONF_STATE_TOPIC, + CONF_SUGGESTED_DISPLAY_PRECISION, CONF_TLS_INSECURE, CONF_TRANSPORT, CONF_WILL_MESSAGE, @@ -133,6 +147,7 @@ from .models import MqttAvailabilityData, MqttDeviceData, MqttSubentryData from .util import ( async_create_certificate_temp_files, get_file_path, + learn_more_url, valid_birth_will, valid_publish_topic, valid_qos_schema, @@ -217,7 +232,7 @@ KEY_UPLOAD_SELECTOR = FileSelector( ) # Subentry selectors -SUBENTRY_PLATFORMS = [Platform.NOTIFY] +SUBENTRY_PLATFORMS = [Platform.NOTIFY, Platform.SENSOR] SUBENTRY_PLATFORM_SELECTOR = SelectSelector( SelectSelectorConfig( options=[platform.value for platform in SUBENTRY_PLATFORMS], @@ -225,7 +240,6 @@ SUBENTRY_PLATFORM_SELECTOR = SelectSelector( translation_key=CONF_PLATFORM, ) ) - TEMPLATE_SELECTOR = TemplateSelector(TemplateSelectorConfig()) SUBENTRY_AVAILABILITY_SCHEMA = vol.Schema( @@ -241,17 +255,109 @@ SUBENTRY_AVAILABILITY_SCHEMA = vol.Schema( } ) +# Sensor specific selectors +SENSOR_DEVICE_CLASS_SELECTOR = SelectSelector( + SelectSelectorConfig( + options=[device_class.value for device_class in SensorDeviceClass], + mode=SelectSelectorMode.DROPDOWN, + translation_key="device_class_sensor", + sort=True, + ) +) +SENSOR_STATE_CLASS_SELECTOR = SelectSelector( + SelectSelectorConfig( + options=[device_class.value for device_class in SensorStateClass], + mode=SelectSelectorMode.DROPDOWN, + translation_key=CONF_STATE_CLASS, + ) +) +OPTIONS_SELECTOR = SelectSelector( + SelectSelectorConfig( + options=[], + custom_value=True, + multiple=True, + ) +) +SUGGESTED_DISPLAY_PRECISION_SELECTOR = NumberSelector( + NumberSelectorConfig(mode=NumberSelectorMode.BOX, min=0, max=9) +) +EXPIRE_AFTER_SELECTOR = NumberSelector( + NumberSelectorConfig(mode=NumberSelectorMode.BOX, min=0) +) + + +@callback +def validate_sensor_platform_config( + config: dict[str, Any], +) -> dict[str, str]: + """Validate the sensor options, state and device class config.""" + errors: dict[str, str] = {} + # Only allow `options` to be set for `enum` sensors + # to limit the possible sensor values + if config.get(CONF_OPTIONS) is not None: + if config.get(CONF_STATE_CLASS) or config.get(CONF_UNIT_OF_MEASUREMENT): + errors[CONF_OPTIONS] = "options_not_allowed_with_state_class_or_uom" + + if (device_class := config.get(CONF_DEVICE_CLASS)) != SensorDeviceClass.ENUM: + errors[CONF_DEVICE_CLASS] = "options_device_class_enum" + + if ( + (device_class := config.get(CONF_DEVICE_CLASS)) == SensorDeviceClass.ENUM + and errors is not None + and CONF_OPTIONS not in config + ): + errors[CONF_OPTIONS] = "options_with_enum_device_class" + + if ( + device_class in DEVICE_CLASS_UNITS + and (unit_of_measurement := config.get(CONF_UNIT_OF_MEASUREMENT)) is None + and errors is not None + ): + # Do not allow an empty unit of measurement in a subentry data flow + errors[CONF_UNIT_OF_MEASUREMENT] = "uom_required_for_device_class" + return errors + + if ( + device_class is not None + and device_class in DEVICE_CLASS_UNITS + and unit_of_measurement not in DEVICE_CLASS_UNITS[device_class] + ): + errors[CONF_UNIT_OF_MEASUREMENT] = "invalid_uom" + + return errors + @dataclass(frozen=True) class PlatformField: """Stores a platform config field schema, required flag and validator.""" - selector: Selector + selector: Selector[Any] | Callable[..., Selector[Any]] required: bool validator: Callable[..., Any] error: str | None = None default: str | int | vol.Undefined = vol.UNDEFINED exclude_from_reconfig: bool = False + conditions: tuple[dict[str, Any], ...] | None = None + custom_filtering: bool = False + section: str | None = None + + +@callback +def unit_of_measurement_selector(user_data: dict[str, Any | None]) -> Selector: + """Return a context based unit of measurement selector.""" + if ( + user_data is None + or (device_class := user_data.get(CONF_DEVICE_CLASS)) is None + or device_class not in DEVICE_CLASS_UNITS + ): + return TEXT_SELECTOR + return SelectSelector( + SelectSelectorConfig( + options=[str(uom) for uom in DEVICE_CLASS_UNITS[device_class]], + sort=True, + custom_value=True, + ) + ) COMMON_ENTITY_FIELDS = { @@ -264,7 +370,29 @@ COMMON_ENTITY_FIELDS = { COMMON_MQTT_FIELDS = { CONF_QOS: PlatformField(QOS_SELECTOR, False, valid_qos_schema, default=0), - CONF_RETAIN: PlatformField(BOOLEAN_SELECTOR, False, bool), +} + +PLATFORM_ENTITY_FIELDS = { + Platform.NOTIFY.value: {}, + Platform.SENSOR.value: { + CONF_DEVICE_CLASS: PlatformField(SENSOR_DEVICE_CLASS_SELECTOR, False, str), + CONF_STATE_CLASS: PlatformField(SENSOR_STATE_CLASS_SELECTOR, False, str), + CONF_UNIT_OF_MEASUREMENT: PlatformField( + unit_of_measurement_selector, False, str, custom_filtering=True + ), + CONF_SUGGESTED_DISPLAY_PRECISION: PlatformField( + SUGGESTED_DISPLAY_PRECISION_SELECTOR, + False, + cv.positive_int, + section="advanced_settings", + ), + CONF_OPTIONS: PlatformField( + OPTIONS_SELECTOR, + False, + cv.ensure_list, + conditions=({"device_class": "enum"},), + ), + }, } PLATFORM_MQTT_FIELDS = { Platform.NOTIFY.value: { @@ -274,7 +402,33 @@ PLATFORM_MQTT_FIELDS = { CONF_COMMAND_TEMPLATE: PlatformField( TEMPLATE_SELECTOR, False, cv.template, "invalid_template" ), + CONF_RETAIN: PlatformField(BOOLEAN_SELECTOR, False, bool), }, + Platform.SENSOR.value: { + CONF_STATE_TOPIC: PlatformField( + TEXT_SELECTOR, True, valid_subscribe_topic, "invalid_subscribe_topic" + ), + CONF_VALUE_TEMPLATE: PlatformField( + TEMPLATE_SELECTOR, False, cv.template, "invalid_template" + ), + CONF_LAST_RESET_VALUE_TEMPLATE: PlatformField( + TEMPLATE_SELECTOR, + False, + cv.template, + "invalid_template", + conditions=({CONF_STATE_CLASS: "total"},), + ), + CONF_EXPIRE_AFTER: PlatformField( + EXPIRE_AFTER_SELECTOR, False, cv.positive_int, section="advanced_settings" + ), + }, +} +ENTITY_CONFIG_VALIDATOR: dict[ + str, + Callable[[dict[str, Any]], dict[str, str]] | None, +] = { + Platform.NOTIFY.value: None, + Platform.SENSOR.value: validate_sensor_platform_config, } MQTT_DEVICE_SCHEMA = vol.Schema( @@ -337,38 +491,140 @@ def validate_field( errors[field] = error +@callback +def _check_conditions( + platform_field: PlatformField, component_data: dict[str, Any] | None = None +) -> bool: + """Only include field if one of conditions match, or no conditions are set.""" + if platform_field.conditions is None or component_data is None: + return True + return any( + all(component_data.get(key) == value for key, value in condition.items()) + for condition in platform_field.conditions + ) + + +@callback +def calculate_merged_config( + merged_user_input: dict[str, Any], + data_schema_fields: dict[str, PlatformField], + component_data: dict[str, Any], +) -> dict[str, Any]: + """Calculate merged config.""" + base_schema_fields = { + key + for key, platform_field in data_schema_fields.items() + if _check_conditions(platform_field, component_data) + } - set(merged_user_input) + return { + key: value + for key, value in component_data.items() + if key not in base_schema_fields + } | merged_user_input + + @callback def validate_user_input( user_input: dict[str, Any], data_schema_fields: dict[str, PlatformField], - errors: dict[str, str], -) -> None: + component_data: dict[str, Any] | None, + config_validator: Callable[[dict[str, Any]], dict[str, str]] | None = None, +) -> tuple[dict[str, Any], dict[str, str]]: """Validate user input.""" - for field, value in user_input.items(): + errors: dict[str, str] = {} + # Merge sections + merged_user_input: dict[str, Any] = {} + for key, value in user_input.items(): + if isinstance(value, dict): + merged_user_input.update(value) + else: + merged_user_input[key] = value + + for field, value in merged_user_input.items(): validator = data_schema_fields[field].validator try: validator(value) except (ValueError, vol.Invalid): errors[field] = data_schema_fields[field].error or "invalid_input" + if config_validator is not None: + if TYPE_CHECKING: + assert component_data is not None + + errors |= config_validator( + calculate_merged_config( + merged_user_input, data_schema_fields, component_data + ), + ) + + return merged_user_input, errors + @callback def data_schema_from_fields( data_schema_fields: dict[str, PlatformField], reconfig: bool, + component_data: dict[str, Any] | None = None, + user_input: dict[str, Any] | None = None, ) -> vol.Schema: - """Generate data schema from platform fields.""" - return vol.Schema( - { + """Generate custom data schema from platform fields.""" + component_data_with_user_input = deepcopy(component_data) + if component_data_with_user_input is not None and user_input is not None: + component_data_with_user_input |= user_input + sections: dict[str | None, None] = { + field_details.section: None for field_details in data_schema_fields.values() + } + data_schema: dict[Any, Any] = {} + all_data_element_options: set[Any] = set() + no_reconfig_options: set[Any] = set() + for schema_section in sections: + data_schema_element = { vol.Required(field_name, default=field_details.default) if field_details.required else vol.Optional( field_name, default=field_details.default - ): field_details.selector + ): field_details.selector(component_data_with_user_input) # type: ignore[operator] + if field_details.custom_filtering + else field_details.selector for field_name, field_details in data_schema_fields.items() - if not field_details.exclude_from_reconfig or not reconfig + if field_details.section == schema_section + and (not field_details.exclude_from_reconfig or not reconfig) + and _check_conditions(field_details, component_data_with_user_input) } - ) + data_element_options = set(data_schema_element) + all_data_element_options |= data_element_options + no_reconfig_options |= { + field_name + for field_name, field_details in data_schema_fields.items() + if field_details.section == schema_section + and field_details.exclude_from_reconfig + } + if schema_section is None: + data_schema.update(data_schema_element) + continue + collapsed = ( + not any( + (default := data_schema_fields[str(option)].default) is vol.UNDEFINED + or component_data_with_user_input[str(option)] != default + for option in data_element_options + if option in component_data_with_user_input + ) + if component_data_with_user_input is not None + else True + ) + data_schema[vol.Optional(schema_section)] = section( + vol.Schema(data_schema_element), SectionConfig({"collapsed": collapsed}) + ) + + # Reset all fields from the component_data not in the schema + if component_data: + filtered_fields = ( + set(data_schema_fields) - all_data_element_options - no_reconfig_options + ) + for field in filtered_fields: + if field in component_data: + del component_data[field] + return vol.Schema(data_schema) class FlowHandler(ConfigFlow, domain=DOMAIN): @@ -893,20 +1149,56 @@ class MQTTSubentryFlowHandler(ConfigSubentryFlow): @callback def update_component_fields( - self, data_schema: vol.Schema, user_input: dict[str, Any] + self, + data_schema_fields: dict[str, PlatformField], + merged_user_input: dict[str, Any], ) -> None: """Update the componment fields.""" if TYPE_CHECKING: assert self._component_id is not None component_data = self._subentry_data["components"][self._component_id] - # Remove the fields from the component data if they are not in the user input - for field in [ - form_field - for form_field in data_schema.schema - if form_field in component_data and form_field not in user_input - ]: + # Remove the fields from the component data + # if they are not in the schema and not in the user input + config = calculate_merged_config( + merged_user_input, data_schema_fields, component_data + ) + for field in ( + field + for field, platform_field in data_schema_fields.items() + if field in (set(component_data) - set(config)) + and not platform_field.exclude_from_reconfig + ): component_data.pop(field) - component_data.update(user_input) + component_data.update(merged_user_input) + + @callback + def generate_names(self) -> tuple[str, str]: + """Generate the device and full entity name.""" + if TYPE_CHECKING: + assert self._component_id is not None + device_name = self._subentry_data[CONF_DEVICE][CONF_NAME] + if entity_name := self._subentry_data["components"][self._component_id].get( + CONF_NAME + ): + full_entity_name: str = f"{device_name} {entity_name}" + else: + full_entity_name = device_name + return device_name, full_entity_name + + @callback + def get_suggested_values_from_component( + self, data_schema: vol.Schema + ) -> dict[str, Any]: + """Get suggestions from component data based on the data schema.""" + if TYPE_CHECKING: + assert self._component_id is not None + component_data = self._subentry_data["components"][self._component_id] + return { + field_key: self.get_suggested_values_from_component(value.schema) + if isinstance(value, section) + else component_data.get(field_key) + for field_key, value in data_schema.schema.items() + } async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -956,25 +1248,28 @@ class MQTTSubentryFlowHandler(ConfigSubentryFlow): data_schema_fields = COMMON_ENTITY_FIELDS entity_name_label: str = "" platform_label: str = "" + component_data: dict[str, Any] | None = None if reconfig := (self._component_id is not None): - name: str | None = self._subentry_data["components"][ - self._component_id - ].get(CONF_NAME) + component_data = self._subentry_data["components"][self._component_id] + name: str | None = component_data.get(CONF_NAME) platform_label = f"{self._subentry_data['components'][self._component_id][CONF_PLATFORM]} " entity_name_label = f" ({name})" if name is not None else "" data_schema = data_schema_from_fields(data_schema_fields, reconfig=reconfig) if user_input is not None: - validate_user_input(user_input, data_schema_fields, errors) + merged_user_input, errors = validate_user_input( + user_input, data_schema_fields, component_data + ) if not errors: if self._component_id is None: self._component_id = uuid4().hex self._subentry_data["components"].setdefault(self._component_id, {}) - self.update_component_fields(data_schema, user_input) - return await self.async_step_mqtt_platform_config() + self.update_component_fields(data_schema_fields, merged_user_input) + return await self.async_step_entity_platform_config() data_schema = self.add_suggested_values_to_schema(data_schema, user_input) elif self.source == SOURCE_RECONFIGURE and self._component_id is not None: data_schema = self.add_suggested_values_to_schema( - data_schema, self._subentry_data["components"][self._component_id] + data_schema, + self.get_suggested_values_from_component(data_schema), ) device_name = self._subentry_data[CONF_DEVICE][CONF_NAME] return self.async_show_form( @@ -994,9 +1289,11 @@ class MQTTSubentryFlowHandler(ConfigSubentryFlow): device_name = self._subentry_data[CONF_DEVICE][CONF_NAME] entities = [ SelectOptionDict( - value=key, label=f"{device_name} {component.get(CONF_NAME, '-')}" + value=key, + label=f"{device_name} {component_data.get(CONF_NAME, '-')}" + f" ({component_data[CONF_PLATFORM]})", ) - for key, component in self._subentry_data["components"].items() + for key, component_data in self._subentry_data["components"].items() ] data_schema = vol.Schema( { @@ -1034,6 +1331,61 @@ class MQTTSubentryFlowHandler(ConfigSubentryFlow): return await self.async_step_summary_menu() return self._show_update_or_delete_form("delete_entity") + async def async_step_entity_platform_config( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Configure platform entity details.""" + if TYPE_CHECKING: + assert self._component_id is not None + component_data = self._subentry_data["components"][self._component_id] + platform = component_data[CONF_PLATFORM] + data_schema_fields = PLATFORM_ENTITY_FIELDS[platform] + errors: dict[str, str] = {} + + data_schema = data_schema_from_fields( + data_schema_fields, + reconfig=bool( + {field for field in data_schema_fields if field in component_data} + ), + component_data=component_data, + user_input=user_input, + ) + if not data_schema.schema: + return await self.async_step_mqtt_platform_config() + if user_input is not None: + # Test entity fields against the validator + merged_user_input, errors = validate_user_input( + user_input, + data_schema_fields, + component_data, + ENTITY_CONFIG_VALIDATOR[platform], + ) + if not errors: + self.update_component_fields(data_schema_fields, merged_user_input) + return await self.async_step_mqtt_platform_config() + + data_schema = self.add_suggested_values_to_schema(data_schema, user_input) + else: + data_schema = self.add_suggested_values_to_schema( + data_schema, + self.get_suggested_values_from_component(data_schema), + ) + + device_name, full_entity_name = self.generate_names() + return self.async_show_form( + step_id="entity_platform_config", + data_schema=data_schema, + description_placeholders={ + "mqtt_device": device_name, + CONF_PLATFORM: platform, + "entity": full_entity_name, + "url": learn_more_url(platform), + } + | (user_input or {}), + errors=errors, + last_step=False, + ) + async def async_step_mqtt_platform_config( self, user_input: dict[str, Any] | None = None ) -> SubentryFlowResult: @@ -1041,16 +1393,26 @@ class MQTTSubentryFlowHandler(ConfigSubentryFlow): errors: dict[str, str] = {} if TYPE_CHECKING: assert self._component_id is not None - platform = self._subentry_data["components"][self._component_id][CONF_PLATFORM] + component_data = self._subentry_data["components"][self._component_id] + platform = component_data[CONF_PLATFORM] data_schema_fields = PLATFORM_MQTT_FIELDS[platform] | COMMON_MQTT_FIELDS data_schema = data_schema_from_fields( - data_schema_fields, reconfig=self._component_id is not None + data_schema_fields, + reconfig=bool( + {field for field in data_schema_fields if field in component_data} + ), + component_data=component_data, ) if user_input is not None: # Test entity fields against the validator - validate_user_input(user_input, data_schema_fields, errors) + merged_user_input, errors = validate_user_input( + user_input, + data_schema_fields, + component_data, + ENTITY_CONFIG_VALIDATOR[platform], + ) if not errors: - self.update_component_fields(data_schema, user_input) + self.update_component_fields(data_schema_fields, merged_user_input) self._component_id = None if self.source == SOURCE_RECONFIGURE: return await self.async_step_summary_menu() @@ -1059,16 +1421,10 @@ class MQTTSubentryFlowHandler(ConfigSubentryFlow): data_schema = self.add_suggested_values_to_schema(data_schema, user_input) else: data_schema = self.add_suggested_values_to_schema( - data_schema, self._subentry_data["components"][self._component_id] + data_schema, + self.get_suggested_values_from_component(data_schema), ) - device_name = self._subentry_data[CONF_DEVICE][CONF_NAME] - entity_name: str | None - if entity_name := self._subentry_data["components"][self._component_id].get( - CONF_NAME - ): - full_entity_name: str = f"{device_name} {entity_name}" - else: - full_entity_name = device_name + device_name, full_entity_name = self.generate_names() return self.async_show_form( step_id="mqtt_platform_config", data_schema=data_schema, @@ -1076,6 +1432,7 @@ class MQTTSubentryFlowHandler(ConfigSubentryFlow): "mqtt_device": device_name, CONF_PLATFORM: platform, "entity": full_entity_name, + "url": learn_more_url(platform), }, errors=errors, last_step=False, @@ -1087,12 +1444,12 @@ class MQTTSubentryFlowHandler(ConfigSubentryFlow): ) -> SubentryFlowResult: """Create a subentry for a new MQTT device.""" device_name = self._subentry_data[CONF_DEVICE][CONF_NAME] - component: dict[str, Any] = next( + component_data: dict[str, Any] = next( iter(self._subentry_data["components"].values()) ) - platform = component[CONF_PLATFORM] + platform = component_data[CONF_PLATFORM] entity_name: str | None - if entity_name := component.get(CONF_NAME): + if entity_name := component_data.get(CONF_NAME): full_entity_name: str = f"{device_name} {entity_name}" else: full_entity_name = device_name @@ -1151,8 +1508,8 @@ class MQTTSubentryFlowHandler(ConfigSubentryFlow): self._component_id = None mqtt_device = self._subentry_data[CONF_DEVICE][CONF_NAME] mqtt_items = ", ".join( - f"{mqtt_device} {component.get(CONF_NAME, '-')}" - for component in self._subentry_data["components"].values() + f"{mqtt_device} {component_data.get(CONF_NAME, '-')} ({component_data[CONF_PLATFORM]})" + for component_data in self._subentry_data["components"].values() ) menu_options = [ "entity", diff --git a/homeassistant/components/mqtt/const.py b/homeassistant/components/mqtt/const.py index c050a1c32da..b2fcd492435 100644 --- a/homeassistant/components/mqtt/const.py +++ b/homeassistant/components/mqtt/const.py @@ -86,6 +86,7 @@ CONF_EFFECT_STATE_TOPIC = "effect_state_topic" CONF_EFFECT_TEMPLATE = "effect_template" CONF_EFFECT_VALUE_TEMPLATE = "effect_value_template" CONF_ENTITY_PICTURE = "entity_picture" +CONF_EXPIRE_AFTER = "expire_after" CONF_FLASH_TIME_LONG = "flash_time_long" CONF_FLASH_TIME_SHORT = "flash_time_short" CONF_GREEN_TEMPLATE = "green_template" @@ -93,6 +94,7 @@ CONF_HS_COMMAND_TEMPLATE = "hs_command_template" CONF_HS_COMMAND_TOPIC = "hs_command_topic" CONF_HS_STATE_TOPIC = "hs_state_topic" CONF_HS_VALUE_TEMPLATE = "hs_value_template" +CONF_LAST_RESET_VALUE_TEMPLATE = "last_reset_value_template" CONF_MAX_KELVIN = "max_kelvin" CONF_MAX_MIREDS = "max_mireds" CONF_MIN_KELVIN = "min_kelvin" @@ -128,6 +130,7 @@ CONF_STATE_CLOSED = "state_closed" CONF_STATE_CLOSING = "state_closing" CONF_STATE_OPEN = "state_open" CONF_STATE_OPENING = "state_opening" +CONF_SUGGESTED_DISPLAY_PRECISION = "suggested_display_precision" CONF_SUPPORTED_COLOR_MODES = "supported_color_modes" CONF_TEMP_COMMAND_TEMPLATE = "temperature_command_template" CONF_TEMP_COMMAND_TOPIC = "temperature_command_topic" diff --git a/homeassistant/components/mqtt/entity.py b/homeassistant/components/mqtt/entity.py index 0b4f65fab47..5fdcbea2e70 100644 --- a/homeassistant/components/mqtt/entity.py +++ b/homeassistant/components/mqtt/entity.py @@ -123,7 +123,7 @@ from .subscription import ( async_subscribe_topics_internal, async_unsubscribe_topics, ) -from .util import mqtt_config_entry_enabled +from .util import learn_more_url, mqtt_config_entry_enabled _LOGGER = logging.getLogger(__name__) @@ -346,9 +346,6 @@ def async_setup_entity_entry_helper( line = getattr(yaml_config, "__line__", "?") issue_id = hex(hash(frozenset(yaml_config))) yaml_config_str = yaml_dump(yaml_config) - learn_more_url = ( - f"https://www.home-assistant.io/integrations/{domain}.mqtt/" - ) async_create_issue( hass, DOMAIN, @@ -356,7 +353,7 @@ def async_setup_entity_entry_helper( issue_domain=domain, is_fixable=False, severity=IssueSeverity.ERROR, - learn_more_url=learn_more_url, + learn_more_url=learn_more_url(domain), translation_placeholders={ "domain": domain, "config_file": config_file, diff --git a/homeassistant/components/mqtt/sensor.py b/homeassistant/components/mqtt/sensor.py index 4d67b0d56e6..b27ef68368a 100644 --- a/homeassistant/components/mqtt/sensor.py +++ b/homeassistant/components/mqtt/sensor.py @@ -41,7 +41,15 @@ from homeassistant.util import dt as dt_util from . import subscription from .config import MQTT_RO_SCHEMA -from .const import CONF_OPTIONS, CONF_STATE_TOPIC, DOMAIN, PAYLOAD_NONE +from .const import ( + CONF_EXPIRE_AFTER, + CONF_LAST_RESET_VALUE_TEMPLATE, + CONF_OPTIONS, + CONF_STATE_TOPIC, + CONF_SUGGESTED_DISPLAY_PRECISION, + DOMAIN, + PAYLOAD_NONE, +) from .entity import MqttAvailabilityMixin, MqttEntity, async_setup_entity_entry_helper from .models import MqttValueTemplate, PayloadSentinel, ReceiveMessage from .schemas import MQTT_ENTITY_COMMON_SCHEMA @@ -51,10 +59,6 @@ _LOGGER = logging.getLogger(__name__) PARALLEL_UPDATES = 0 -CONF_EXPIRE_AFTER = "expire_after" -CONF_LAST_RESET_VALUE_TEMPLATE = "last_reset_value_template" -CONF_SUGGESTED_DISPLAY_PRECISION = "suggested_display_precision" - MQTT_SENSOR_ATTRIBUTES_BLOCKED = frozenset( { sensor.ATTR_LAST_RESET, diff --git a/homeassistant/components/mqtt/strings.json b/homeassistant/components/mqtt/strings.json index f0112097f4e..9aa1522915f 100644 --- a/homeassistant/components/mqtt/strings.json +++ b/homeassistant/components/mqtt/strings.json @@ -198,20 +198,66 @@ "component": "Select the entity you want to update." } }, + "entity_platform_config": { + "title": "Configure MQTT device \"{mqtt_device}\"", + "description": "Please configure specific details for {platform} entity \"{entity}\":", + "data": { + "device_class": "Device class", + "state_class": "State class", + "unit_of_measurement": "Unit of measurement", + "options": "Add option" + }, + "data_description": { + "device_class": "The device class of the {platform} entity. [Learn more.]({url}#device_class)", + "state_class": "The [state_class](https://developers.home-assistant.io/docs/core/entity/sensor/#available-state-classes) of the sensor. [Learn more.]({url}#state_class)", + "unit_of_measurement": "Defines the unit of measurement of the sensor, if any.", + "options": "Options for allowed sensor state values. The sensor’s device_class must be set to Enumeration. The options option cannot be used together with State Class or Unit of measurement." + }, + "sections": { + "advanced_settings": { + "name": "Advanced options", + "data": { + "suggested_display_precision": "Suggested display precision" + }, + "data_description": { + "suggested_display_precision": "The number of decimals which should be used in the {platform} entity state after rounding. [Learn more.]({url}#suggested_display_precision)" + } + } + } + }, "mqtt_platform_config": { "title": "Configure MQTT device \"{mqtt_device}\"", "description": "Please configure MQTT specific details for {platform} entity \"{entity}\":", "data": { "command_topic": "Command topic", "command_template": "Command template", + "state_topic": "State topic", + "value_template": "Value template", + "last_reset_value_template": "Last reset value template", + "force_update": "Force update", "retain": "Retain", "qos": "QoS" }, "data_description": { - "command_topic": "The publishing topic that will be used to control the {platform} entity.", + "command_topic": "The publishing topic that will be used to control the {platform} entity. [Learn more.]({url}#command_topic)", "command_template": "A [template](https://www.home-assistant.io/docs/configuration/templating/#using-command-templates-with-mqtt) to render the payload to be published at the command topic.", + "state_topic": "The MQTT topic subscribed to receive {platform} state values. [Learn more.]({url}#state_topic)", + "value_template": "Defines a [template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract the {platform} entity value.", + "last_reset_value_template": "Defines a [template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract the last reset. When Last reset template is set, the State class option must be Total. [Learn more.]({url}#last_reset_value_template)", + "force_update": "Sends update events even if the value hasn’t changed. Useful if you want to have meaningful value graphs in history. [Learn more.]({url}#force_update)", "retain": "Select if values published by the {platform} entity should be retained at the MQTT broker.", "qos": "The QoS value {platform} entity should use." + }, + "sections": { + "advanced_settings": { + "name": "Advanced settings", + "data": { + "expire_after": "Expire after" + }, + "data_description": { + "expire_after": "If set, it defines the number of seconds after the sensor’s state expires, if it’s not updated. After expiry, the sensor’s state becomes unavailable. If not set, the sensor's state never expires. [Learn more.]({url}#expire_after)" + } + } } } }, @@ -225,7 +271,12 @@ "invalid_input": "Invalid value", "invalid_subscribe_topic": "Invalid subscribe topic", "invalid_template": "Invalid template", - "invalid_url": "Invalid URL" + "invalid_uom": "The unit of measurement \"{unit_of_measurement}\" is not supported by the selected device class, please either remove the device class, select a device class which supports \"{unit_of_measurement}\", or pick a supported unit of measurement from the list", + "invalid_url": "Invalid URL", + "options_not_allowed_with_state_class_or_uom": "The 'Options' setting is not allowed when state class or unit of measurement are used", + "options_device_class_enum": "The 'Options' setting must be used with the Enumeration device class'. If you continue, the existing options will be reset", + "options_with_enum_device_class": "Configure options for the enumeration sensor", + "uom_required_for_device_class": "The selected device device class requires a unit" } } }, @@ -342,9 +393,70 @@ } }, "selector": { + "device_class_sensor": { + "options": { + "apparent_power": "[%key:component::sensor::entity_component::apparent_power::name%]", + "area": "[%key:component::sensor::entity_component::area::name%]", + "aqi": "[%key:component::sensor::entity_component::aqi::name%]", + "atmospheric_pressure": "[%key:component::sensor::entity_component::atmospheric_pressure::name%]", + "battery": "[%key:component::sensor::entity_component::battery::name%]", + "blood_glucose_concentration": "[%key:component::sensor::entity_component::blood_glucose_concentration::name%]", + "carbon_dioxide": "[%key:component::sensor::entity_component::carbon_dioxide::name%]", + "carbon_monoxide": "[%key:component::sensor::entity_component::carbon_monoxide::name%]", + "conductivity": "[%key:component::sensor::entity_component::conductivity::name%]", + "current": "[%key:component::sensor::entity_component::current::name%]", + "data_rate": "[%key:component::sensor::entity_component::data_rate::name%]", + "data_size": "[%key:component::sensor::entity_component::data_size::name%]", + "date": "[%key:component::sensor::entity_component::date::name%]", + "distance": "[%key:component::sensor::entity_component::distance::name%]", + "duration": "[%key:component::sensor::entity_component::duration::name%]", + "energy": "[%key:component::sensor::entity_component::energy::name%]", + "energy_distance": "[%key:component::sensor::entity_component::energy_distance::name%]", + "energy_storage": "[%key:component::sensor::entity_component::energy_storage::name%]", + "enum": "Enumeration", + "frequency": "[%key:component::sensor::entity_component::frequency::name%]", + "gas": "[%key:component::sensor::entity_component::gas::name%]", + "humidity": "[%key:component::sensor::entity_component::humidity::name%]", + "illuminance": "[%key:component::sensor::entity_component::illuminance::name%]", + "irradiance": "[%key:component::sensor::entity_component::irradiance::name%]", + "moisture": "[%key:component::sensor::entity_component::moisture::name%]", + "monetary": "[%key:component::sensor::entity_component::monetary::name%]", + "nitrogen_dioxide": "[%key:component::sensor::entity_component::nitrogen_dioxide::name%]", + "nitrogen_monoxide": "[%key:component::sensor::entity_component::nitrogen_monoxide::name%]", + "nitrous_oxide": "[%key:component::sensor::entity_component::nitrous_oxide::name%]", + "ozone": "[%key:component::sensor::entity_component::ozone::name%]", + "ph": "[%key:component::sensor::entity_component::ph::name%]", + "pm1": "[%key:component::sensor::entity_component::pm1::name%]", + "pm10": "[%key:component::sensor::entity_component::pm10::name%]", + "pm25": "[%key:component::sensor::entity_component::pm25::name%]", + "power": "[%key:component::sensor::entity_component::power::name%]", + "power_factor": "[%key:component::sensor::entity_component::power_factor::name%]", + "precipitation": "[%key:component::sensor::entity_component::precipitation::name%]", + "precipitation_intensity": "[%key:component::sensor::entity_component::precipitation_intensity::name%]", + "pressure": "[%key:component::sensor::entity_component::pressure::name%]", + "reactive_power": "[%key:component::sensor::entity_component::reactive_power::name%]", + "signal_strength": "[%key:component::sensor::entity_component::signal_strength::name%]", + "sound_pressure": "[%key:component::sensor::entity_component::sound_pressure::name%]", + "speed": "[%key:component::sensor::entity_component::speed::name%]", + "sulphur_dioxide": "[%key:component::sensor::entity_component::sulphur_dioxide::name%]", + "temperature": "[%key:component::sensor::entity_component::temperature::name%]", + "timestamp": "[%key:component::sensor::entity_component::timestamp::name%]", + "volatile_organic_compounds": "[%key:component::sensor::entity_component::volatile_organic_compounds::name%]", + "volatile_organic_compounds_parts": "[%key:component::sensor::entity_component::volatile_organic_compounds::name%]", + "voltage": "[%key:component::sensor::entity_component::voltage::name%]", + "volume": "[%key:component::sensor::entity_component::volume::name%]", + "volume_flow_rate": "[%key:component::sensor::entity_component::volume_flow_rate::name%]", + "volume_storage": "[%key:component::sensor::entity_component::volume_storage::name%]", + "water": "[%key:component::sensor::entity_component::water::name%]", + "weight": "[%key:component::sensor::entity_component::weight::name%]", + "wind_direction": "[%key:component::sensor::entity_component::wind_direction::name%]", + "wind_speed": "[%key:component::sensor::entity_component::wind_speed::name%]" + } + }, "platform": { "options": { - "notify": "Notify" + "notify": "Notify", + "sensor": "Sensor" } }, "set_ca_cert": { @@ -353,6 +465,13 @@ "auto": "Auto", "custom": "Custom" } + }, + "state_class": { + "options": { + "measurement": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::measurement%]", + "total": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::total%]", + "total_increasing": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::total_increasing%]" + } } }, "services": { diff --git a/homeassistant/components/mqtt/util.py b/homeassistant/components/mqtt/util.py index 27bdb4f2a35..e3996c80a8a 100644 --- a/homeassistant/components/mqtt/util.py +++ b/homeassistant/components/mqtt/util.py @@ -411,3 +411,9 @@ def migrate_certificate_file_to_content(file_name_or_auto: str) -> str | None: return certificate_file.read() except OSError: return None + + +@callback +def learn_more_url(platform: str) -> str: + """Return the URL for the platform specific MQTT documentation.""" + return f"https://www.home-assistant.io/integrations/{platform}.mqtt/" diff --git a/tests/components/mqtt/common.py b/tests/components/mqtt/common.py index f000c4e0b9b..aad71fbc26e 100644 --- a/tests/components/mqtt/common.py +++ b/tests/components/mqtt/common.py @@ -72,7 +72,7 @@ MOCK_SUBENTRY_NOTIFY_COMPONENT1 = { "name": "Milkman alert", "qos": 0, "command_topic": "test-topic", - "command_template": "{{ value_json.value }}", + "command_template": "{{ value }}", "entity_picture": "https://example.com/363a7ecad6be4a19b939a016ea93e994", "retain": False, }, @@ -91,12 +91,47 @@ MOCK_SUBENTRY_NOTIFY_COMPONENT_NO_NAME = { "platform": "notify", "qos": 0, "command_topic": "test-topic", - "command_template": "{{ value_json.value }}", + "command_template": "{{ value }}", "entity_picture": "https://example.com/5269352dd9534c908d22812ea5d714cd", "retain": False, }, } +MOCK_SUBENTRY_SENSOR_COMPONENT = { + "e9261f6feed443e7b7d5f3fbe2a47412": { + "platform": "sensor", + "name": "Energy", + "device_class": "enum", + "qos": 1, + "state_topic": "test-topic", + "options": ["low", "medium", "high"], + "expire_after": 30, + "value_template": "{{ value_json.value }}", + "entity_picture": "https://example.com/e9261f6feed443e7b7d5f3fbe2a47412", + }, +} +MOCK_SUBENTRY_SENSOR_COMPONENT_STATE_CLASS = { + "a0f85790a95d4889924602effff06b6e": { + "platform": "sensor", + "name": "Energy", + "state_class": "measurement", + "state_topic": "test-topic", + "entity_picture": "https://example.com/a0f85790a95d4889924602effff06b6e", + "qos": 0, + }, +} +MOCK_SUBENTRY_SENSOR_COMPONENT_LAST_RESET = { + "e9261f6feed443e7b7d5f3fbe2a47412": { + "platform": "sensor", + "name": "Energy", + "state_class": "total", + "last_reset_value_template": "{{ value_json.value }}", + "state_topic": "test-topic", + "entity_picture": "https://example.com/e9261f6feed443e7b7d5f3fbe2a47412", + "qos": 0, + }, +} + # Bogus light component just for code coverage # Note that light cannot be setup through the UI yet # The test is for code coverage @@ -151,7 +186,7 @@ MOCK_NOTIFY_SUBENTRY_DATA_SINGLE = { }, "components": MOCK_SUBENTRY_NOTIFY_COMPONENT1, } -MOCK_SUBENTRY_DATA_NOTIFY_NO_NAME = { +MOCK_NOTIFY_SUBENTRY_DATA_NO_NAME = { "device": { "name": "Milk notifier", "sw_version": "1.0", @@ -162,6 +197,39 @@ MOCK_SUBENTRY_DATA_NOTIFY_NO_NAME = { }, "components": MOCK_SUBENTRY_NOTIFY_COMPONENT_NO_NAME, } +MOCK_SENSOR_SUBENTRY_DATA_SINGLE = { + "device": { + "name": "Test sensor", + "sw_version": "1.0", + "hw_version": "2.1 rev a", + "model": "Model XL", + "model_id": "mn002", + "configuration_url": "https://example.com", + }, + "components": MOCK_SUBENTRY_SENSOR_COMPONENT, +} +MOCK_SENSOR_SUBENTRY_DATA_SINGLE_STATE_CLASS = { + "device": { + "name": "Test sensor", + "sw_version": "1.0", + "hw_version": "2.1 rev a", + "model": "Model XL", + "model_id": "mn002", + "configuration_url": "https://example.com", + }, + "components": MOCK_SUBENTRY_SENSOR_COMPONENT_STATE_CLASS, +} +MOCK_SENSOR_SUBENTRY_DATA_SINGLE_LAST_RESET_TEMPLATE = { + "device": { + "name": "Test sensor", + "sw_version": "1.0", + "hw_version": "2.1 rev a", + "model": "Model XL", + "model_id": "mn002", + "configuration_url": "https://example.com", + }, + "components": MOCK_SUBENTRY_SENSOR_COMPONENT_LAST_RESET, +} MOCK_SUBENTRY_DATA_BAD_COMPONENT_SCHEMA = { "device": { diff --git a/tests/components/mqtt/test_config_flow.py b/tests/components/mqtt/test_config_flow.py index 354cb33ba39..266be761a91 100644 --- a/tests/components/mqtt/test_config_flow.py +++ b/tests/components/mqtt/test_config_flow.py @@ -18,6 +18,7 @@ from homeassistant import config_entries from homeassistant.components import mqtt from homeassistant.components.hassio import AddonError from homeassistant.components.mqtt.config_flow import PWD_NOT_CHANGED +from homeassistant.components.mqtt.util import learn_more_url from homeassistant.config_entries import ConfigSubentry, ConfigSubentryData from homeassistant.const import ( CONF_CLIENT_ID, @@ -33,8 +34,11 @@ from homeassistant.helpers.service_info.hassio import HassioServiceInfo from .common import ( MOCK_NOTIFY_SUBENTRY_DATA_MULTI, + MOCK_NOTIFY_SUBENTRY_DATA_NO_NAME, MOCK_NOTIFY_SUBENTRY_DATA_SINGLE, - MOCK_SUBENTRY_DATA_NOTIFY_NO_NAME, + MOCK_SENSOR_SUBENTRY_DATA_SINGLE, + MOCK_SENSOR_SUBENTRY_DATA_SINGLE_LAST_RESET_TEMPLATE, + MOCK_SENSOR_SUBENTRY_DATA_SINGLE_STATE_CLASS, ) from tests.common import MockConfigEntry, MockMqttReasonCode @@ -2613,49 +2617,139 @@ async def test_migrate_of_incompatible_config_entry( ( "config_subentries_data", "mock_entity_user_input", + "mock_entity_details_user_input", + "mock_entity_details_failed_user_input", "mock_mqtt_user_input", "mock_failed_mqtt_user_input", - "mock_failed_mqtt_user_input_errors", "entity_name", ), [ ( MOCK_NOTIFY_SUBENTRY_DATA_SINGLE, {"name": "Milkman alert"}, + None, + None, { "command_topic": "test-topic", - "command_template": "{{ value_json.value }}", + "command_template": "{{ value }}", "qos": 0, "retain": False, }, - {"command_topic": "test-topic#invalid"}, - {"command_topic": "invalid_publish_topic"}, + ( + ( + {"command_topic": "test-topic#invalid"}, + {"command_topic": "invalid_publish_topic"}, + ), + ), "Milk notifier Milkman alert", ), ( - MOCK_SUBENTRY_DATA_NOTIFY_NO_NAME, + MOCK_NOTIFY_SUBENTRY_DATA_NO_NAME, {}, + None, + None, { "command_topic": "test-topic", - "command_template": "{{ value_json.value }}", + "command_template": "{{ value }}", "qos": 0, "retain": False, }, - {"command_topic": "test-topic#invalid"}, - {"command_topic": "invalid_publish_topic"}, + ( + ( + {"command_topic": "test-topic#invalid"}, + {"command_topic": "invalid_publish_topic"}, + ), + ), "Milk notifier", ), + ( + MOCK_SENSOR_SUBENTRY_DATA_SINGLE, + {"name": "Energy"}, + {"device_class": "enum", "options": ["low", "medium", "high"]}, + ( + ( + { + "device_class": "energy", + "unit_of_measurement": "ppm", + }, + {"unit_of_measurement": "invalid_uom"}, + ), + # Trigger options to be shown on the form + ( + {"device_class": "enum"}, + {"options": "options_with_enum_device_class"}, + ), + # Test options are only allowed with device_class enum + ( + { + "device_class": "energy", + "options": ["less", "more"], + }, + { + "device_class": "options_device_class_enum", + "unit_of_measurement": "uom_required_for_device_class", + }, + ), + # Include options again to allow flow with valid data + ( + {"device_class": "enum"}, + {"options": "options_with_enum_device_class"}, + ), + ( + { + "device_class": "enum", + "state_class": "measurement", + "options": ["less", "more"], + }, + {"options": "options_not_allowed_with_state_class_or_uom"}, + ), + ), + { + "state_topic": "test-topic", + "value_template": "{{ value_json.value }}", + "advanced_settings": {"expire_after": 30}, + "qos": 1, + }, + ( + ( + {"state_topic": "test-topic#invalid"}, + {"state_topic": "invalid_subscribe_topic"}, + ), + ), + "Test sensor Energy", + ), + ( + MOCK_SENSOR_SUBENTRY_DATA_SINGLE_STATE_CLASS, + {"name": "Energy"}, + { + "state_class": "measurement", + }, + (), + { + "state_topic": "test-topic", + }, + (), + "Test sensor Energy", + ), + ], + ids=[ + "notify_with_entity_name", + "notify_no_entity_name", + "sensor_options", + "sensor_total", ], - ids=["notify_with_entity_name", "notify_no_entity_name"], ) async def test_subentry_configflow( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator, config_subentries_data: dict[str, Any], mock_entity_user_input: dict[str, Any], + mock_entity_details_user_input: dict[str, Any], + mock_entity_details_failed_user_input: tuple[ + tuple[dict[str, Any], dict[str, str]], + ], mock_mqtt_user_input: dict[str, Any], - mock_failed_mqtt_user_input: dict[str, Any], - mock_failed_mqtt_user_input_errors: dict[str, Any], + mock_failed_mqtt_user_input: tuple[tuple[dict[str, Any], dict[str, str]],], entity_name: str, ) -> None: """Test the subentry ConfigFlow.""" @@ -2723,23 +2817,55 @@ async def test_subentry_configflow( | mock_entity_user_input, ) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "mqtt_platform_config" assert result["errors"] == {} assert result["description_placeholders"] == { - "mqtt_device": "Milk notifier", - "platform": "notify", + "mqtt_device": device_name, + "platform": component["platform"], "entity": entity_name, + "url": learn_more_url(component["platform"]), } - # Process entity platform config flow + # Process extra step if the platform supports it + if mock_entity_details_user_input is not None: + # Extra entity details flow step + assert result["step_id"] == "entity_platform_config" - # Test an invalid mqtt user_input case - result = await hass.config_entries.subentries.async_configure( - result["flow_id"], - user_input=mock_failed_mqtt_user_input, - ) - assert result["type"] is FlowResultType.FORM - assert result["errors"] == mock_failed_mqtt_user_input_errors + # First test validators if set of test + for failed_user_input, failed_errors in mock_entity_details_failed_user_input: + # Test an invalid entity details user input case + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input=failed_user_input, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == failed_errors + + # Now try again with valid data + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input=mock_entity_details_user_input, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + assert result["description_placeholders"] == { + "mqtt_device": device_name, + "platform": component["platform"], + "entity": entity_name, + "url": learn_more_url(component["platform"]), + } + else: + # No details form step + assert result["step_id"] == "mqtt_platform_config" + + # Process mqtt platform config flow + # Test an invalid mqtt user input case + for failed_user_input, failed_errors in mock_failed_mqtt_user_input: + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input=failed_user_input, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == failed_errors # Try again with a valid configuration result = await hass.config_entries.subentries.async_configure( @@ -2799,8 +2925,12 @@ async def test_subentry_reconfigure_remove_entity( assert len(components) == 2 object_list = list(components) component_list = list(components.values()) - entity_name_0 = f"{device.name} {component_list[0]['name']}" - entity_name_1 = f"{device.name} {component_list[1]['name']}" + entity_name_0 = ( + f"{device.name} {component_list[0]['name']} ({component_list[0]['platform']})" + ) + entity_name_1 = ( + f"{device.name} {component_list[1]['name']} ({component_list[1]['platform']})" + ) for key, component in components.items(): unique_entity_id = f"{subentry_id}_{key}" @@ -2920,8 +3050,12 @@ async def test_subentry_reconfigure_edit_entity_multi_entitites( assert len(components) == 2 object_list = list(components) component_list = list(components.values()) - entity_name_0 = f"{device.name} {component_list[0]['name']}" - entity_name_1 = f"{device.name} {component_list[1]['name']}" + entity_name_0 = ( + f"{device.name} {component_list[0]['name']} ({component_list[0]['platform']})" + ) + entity_name_1 = ( + f"{device.name} {component_list[1]['name']} ({component_list[1]['platform']})" + ) for key in components: unique_entity_id = f"{subentry_id}_{key}" @@ -3000,7 +3134,13 @@ async def test_subentry_reconfigure_edit_entity_multi_entitites( @pytest.mark.parametrize( - ("mqtt_config_subentries_data", "user_input_mqtt"), + ( + "mqtt_config_subentries_data", + "user_input_platform_config_validation", + "user_input_platform_config", + "user_input_mqtt", + "removed_options", + ), [ ( ( @@ -3010,21 +3150,66 @@ async def test_subentry_reconfigure_edit_entity_multi_entitites( title="Mock subentry", ), ), + (), + None, { "command_topic": "test-topic1-updated", - "command_template": "{{ value_json.value }}", + "command_template": "{{ value }}", "retain": True, }, - ) + {"entity_picture"}, + ), + ( + ( + ConfigSubentryData( + data=MOCK_SENSOR_SUBENTRY_DATA_SINGLE, + subentry_type="device", + title="Mock subentry", + ), + ), + ( + ( + { + "device_class": "battery", + "options": [], + "state_class": "measurement", + "unit_of_measurement": "invalid", + }, + # Allow to accept options are being removed + { + "device_class": "options_device_class_enum", + "options": "options_not_allowed_with_state_class_or_uom", + "unit_of_measurement": "invalid_uom", + }, + ), + ), + { + "device_class": "battery", + "state_class": "measurement", + "unit_of_measurement": "%", + "advanced_settings": {"suggested_display_precision": 1}, + }, + { + "state_topic": "test-topic1-updated", + "value_template": "{{ value_json.value }}", + }, + {"options", "expire_after", "entity_picture"}, + ), ], - ids=["notify"], + ids=["notify", "sensor"], ) async def test_subentry_reconfigure_edit_entity_single_entity( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, + user_input_platform_config_validation: tuple[ + tuple[dict[str, Any], dict[str, str] | None], ... + ] + | None, + user_input_platform_config: dict[str, Any] | None, user_input_mqtt: dict[str, Any], + removed_options: tuple[str, ...], ) -> None: """Test the subentry ConfigFlow reconfigure with single entity.""" await mqtt_mock_entry() @@ -3081,7 +3266,28 @@ async def test_subentry_reconfigure_edit_entity_single_entity( user_input={}, ) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "mqtt_platform_config" + + if user_input_platform_config is None: + # Skip entity flow step + assert result["step_id"] == "mqtt_platform_config" + else: + # Additional entity flow step + assert result["step_id"] == "entity_platform_config" + for entity_validation_config, errors in user_input_platform_config_validation: + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input=entity_validation_config, + ) + assert result["step_id"] == "entity_platform_config" + assert result.get("errors") == errors + assert result["type"] is FlowResultType.FORM + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input=user_input_platform_config, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "mqtt_platform_config" # submit the new platform specific entity data, result = await hass.config_entries.subentries.async_configure( @@ -3110,6 +3316,142 @@ async def test_subentry_reconfigure_edit_entity_single_entity( for key, value in user_input_mqtt.items(): assert new_components[component_id][key] == value + assert set(component) - set(new_components[component_id]) == removed_options + + +@pytest.mark.parametrize( + ( + "mqtt_config_subentries_data", + "user_input_entity_details", + "user_input_mqtt", + "filtered_out_fields", + ), + [ + ( + ( + ConfigSubentryData( + data=MOCK_SENSOR_SUBENTRY_DATA_SINGLE_LAST_RESET_TEMPLATE, + subentry_type="device", + title="Mock subentry", + ), + ), + { + "state_class": "measurement", + }, + { + "state_topic": "test-topic", + }, + ("last_reset_value_template",), + ), + ], + ids=["sensor_last_reset_template"], +) +async def test_subentry_reconfigure_edit_entity_reset_fields( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + user_input_entity_details: dict[str, Any], + user_input_mqtt: dict[str, Any], + filtered_out_fields: tuple[str, ...], +) -> None: + """Test the subentry ConfigFlow reconfigure resets filtered out fields.""" + await mqtt_mock_entry() + config_entry: MockConfigEntry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] + subentry_id: str + subentry: ConfigSubentry + subentry_id, subentry = next(iter(config_entry.subentries.items())) + result = await config_entry.start_subentry_reconfigure_flow( + hass, "device", subentry_id + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "summary_menu" + + # assert we have a device for the subentry + device = device_registry.async_get_device(identifiers={(mqtt.DOMAIN, subentry_id)}) + assert device is not None + + # assert we have an entity for the subentry component + components = deepcopy(dict(subentry.data))["components"] + assert len(components) == 1 + + component_id, component = next(iter(components.items())) + for field in filtered_out_fields: + assert field in component + + unique_entity_id = f"{subentry_id}_{component_id}" + entity_id = entity_registry.async_get_entity_id( + domain=component["platform"], platform=mqtt.DOMAIN, unique_id=unique_entity_id + ) + assert entity_id is not None + entity_entry = entity_registry.async_get(entity_id) + assert entity_entry is not None + assert entity_entry.config_subentry_id == subentry_id + + # assert menu options, we do not have the option to delete an entity + # we have no option to save and finish yet + assert result["menu_options"] == [ + "entity", + "update_entity", + "device", + "availability", + ] + + # assert we can update the entity, there is no select step + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {"next_step_id": "update_entity"}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "entity" + + # submit the new common entity data, reset entity_picture + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "entity_platform_config" + + # submit the new entity platform config + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input=user_input_entity_details, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "mqtt_platform_config" + + # submit the new platform specific mqtt data, + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input=user_input_mqtt, + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "summary_menu" + + # finish reconfigure flow + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {"next_step_id": "save_changes"}, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + # Check we still have out components + new_components = deepcopy(dict(subentry.data))["components"] + assert len(new_components) == 1 + + # Check our update was successful + assert "entity_picture" not in new_components[component_id] + + # Check the second component was updated + for key, value in user_input_mqtt.items(): + assert new_components[component_id][key] == value + + # Check field are filtered out correctly + for field in filtered_out_fields: + assert field not in new_components[component_id] + @pytest.mark.parametrize( ("mqtt_config_subentries_data", "user_input_entity", "user_input_mqtt"), From 054b3bb26c0296c5220cbf4be84181c804ce8eb9 Mon Sep 17 00:00:00 2001 From: Tsvi Mostovicz Date: Wed, 26 Mar 2025 14:38:58 +0200 Subject: [PATCH 3081/3148] Add service for counting the omer (#141008) * Add service for counting the omer * Add description and strings. Expect string from user * Fix constraints on nusach and language + Make independent of config_entry * Provide config schema * Fix services.yaml and strings.json to match updated service.py * Use LanguageSelector and some constants * Action description -> third-person singular * Use built-in language selector in yaml * Fix schema * Show the hebrew date in the correct language in the response * Revert "Show the hebrew date in the correct language in the response" This reverts commit 59442d16c531e4bd54028dea3fb9ae6a7312af7b. Requires a bugfix in the original library * Don't return the hebrew date as it doesn't return correctly --- .../components/jewish_calendar/__init__.py | 13 +++- .../components/jewish_calendar/const.py | 5 ++ .../components/jewish_calendar/icons.json | 7 +++ .../components/jewish_calendar/service.py | 63 +++++++++++++++++++ .../components/jewish_calendar/services.yaml | 28 +++++++++ .../components/jewish_calendar/strings.json | 30 +++++++++ .../jewish_calendar/test_service.py | 55 ++++++++++++++++ 7 files changed, 200 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/jewish_calendar/icons.json create mode 100644 homeassistant/components/jewish_calendar/service.py create mode 100644 homeassistant/components/jewish_calendar/services.yaml create mode 100644 tests/components/jewish_calendar/test_service.py diff --git a/homeassistant/components/jewish_calendar/__init__.py b/homeassistant/components/jewish_calendar/__init__.py index 6b58b9441b0..47d60d74938 100644 --- a/homeassistant/components/jewish_calendar/__init__.py +++ b/homeassistant/components/jewish_calendar/__init__.py @@ -16,7 +16,8 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import config_validation as cv, entity_registry as er +from homeassistant.helpers.typing import ConfigType from .const import ( CONF_CANDLE_LIGHT_MINUTES, @@ -26,11 +27,21 @@ from .const import ( DEFAULT_DIASPORA, DEFAULT_HAVDALAH_OFFSET_MINUTES, DEFAULT_LANGUAGE, + DOMAIN, ) from .entity import JewishCalendarConfigEntry, JewishCalendarData +from .service import async_setup_services _LOGGER = logging.getLogger(__name__) PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR] +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the Jewish Calendar service.""" + async_setup_services(hass) + + return True async def async_setup_entry( diff --git a/homeassistant/components/jewish_calendar/const.py b/homeassistant/components/jewish_calendar/const.py index 4af76a8927b..0d5455fcd86 100644 --- a/homeassistant/components/jewish_calendar/const.py +++ b/homeassistant/components/jewish_calendar/const.py @@ -2,6 +2,9 @@ DOMAIN = "jewish_calendar" +ATTR_DATE = "date" +ATTR_NUSACH = "nusach" + CONF_DIASPORA = "diaspora" CONF_CANDLE_LIGHT_MINUTES = "candle_lighting_minutes_before_sunset" CONF_HAVDALAH_OFFSET_MINUTES = "havdalah_minutes_after_sunset" @@ -11,3 +14,5 @@ DEFAULT_CANDLE_LIGHT = 18 DEFAULT_DIASPORA = False DEFAULT_HAVDALAH_OFFSET_MINUTES = 0 DEFAULT_LANGUAGE = "english" + +SERVICE_COUNT_OMER = "count_omer" diff --git a/homeassistant/components/jewish_calendar/icons.json b/homeassistant/components/jewish_calendar/icons.json new file mode 100644 index 00000000000..24b922df7a2 --- /dev/null +++ b/homeassistant/components/jewish_calendar/icons.json @@ -0,0 +1,7 @@ +{ + "services": { + "count_omer": { + "service": "mdi:counter" + } + } +} diff --git a/homeassistant/components/jewish_calendar/service.py b/homeassistant/components/jewish_calendar/service.py new file mode 100644 index 00000000000..7c3c7a21f1c --- /dev/null +++ b/homeassistant/components/jewish_calendar/service.py @@ -0,0 +1,63 @@ +"""Services for Jewish Calendar.""" + +import datetime +from typing import cast + +from hdate import HebrewDate +from hdate.omer import Nusach, Omer +from hdate.translator import Language +import voluptuous as vol + +from homeassistant.const import CONF_LANGUAGE +from homeassistant.core import ( + HomeAssistant, + ServiceCall, + ServiceResponse, + SupportsResponse, +) +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.selector import LanguageSelector, LanguageSelectorConfig + +from .const import ATTR_DATE, ATTR_NUSACH, DOMAIN, SERVICE_COUNT_OMER + +SUPPORTED_LANGUAGES = {"en": "english", "fr": "french", "he": "hebrew"} +OMER_SCHEMA = vol.Schema( + { + vol.Required(ATTR_DATE, default=datetime.date.today): cv.date, + vol.Required(ATTR_NUSACH, default="sfarad"): vol.In( + [nusach.name.lower() for nusach in Nusach] + ), + vol.Required(CONF_LANGUAGE, default="he"): LanguageSelector( + LanguageSelectorConfig(languages=list(SUPPORTED_LANGUAGES.keys())) + ), + } +) + + +def async_setup_services(hass: HomeAssistant) -> None: + """Set up the Jewish Calendar services.""" + + async def get_omer_count(call: ServiceCall) -> ServiceResponse: + """Return the Omer blessing for a given date.""" + hebrew_date = HebrewDate.from_gdate(call.data["date"]) + nusach = Nusach[call.data["nusach"].upper()] + + # Currently Omer only supports Hebrew, English, and French and requires + # the full language name + language = cast(Language, SUPPORTED_LANGUAGES[call.data[CONF_LANGUAGE]]) + + omer = Omer(date=hebrew_date, nusach=nusach, language=language) + return { + "message": str(omer.count_str()), + "weeks": omer.week, + "days": omer.day, + "total_days": omer.total_days, + } + + hass.services.async_register( + DOMAIN, + SERVICE_COUNT_OMER, + get_omer_count, + schema=OMER_SCHEMA, + supports_response=SupportsResponse.ONLY, + ) diff --git a/homeassistant/components/jewish_calendar/services.yaml b/homeassistant/components/jewish_calendar/services.yaml new file mode 100644 index 00000000000..b0fa2cfef6c --- /dev/null +++ b/homeassistant/components/jewish_calendar/services.yaml @@ -0,0 +1,28 @@ +count_omer: + fields: + date: + required: true + example: "2025-04-14" + selector: + date: + nusach: + example: "sfarad" + default: "sfarad" + selector: + select: + translation_key: "nusach" + options: + - "sfarad" + - "ashkenaz" + - "adot_mizrah" + - "italian" + language: + required: true + default: "he" + example: "he" + selector: + language: + languages: + - "en" + - "he" + - "fr" diff --git a/homeassistant/components/jewish_calendar/strings.json b/homeassistant/components/jewish_calendar/strings.json index 1b7b86c0056..41e666b1e5d 100644 --- a/homeassistant/components/jewish_calendar/strings.json +++ b/homeassistant/components/jewish_calendar/strings.json @@ -45,5 +45,35 @@ } } } + }, + "selector": { + "nusach": { + "options": { + "sfarad": "Sfarad", + "ashkenaz": "Ashkenaz", + "adot_mizrah": "Adot Mizrah", + "italian": "Italian" + } + } + }, + "services": { + "count_omer": { + "name": "Count the Omer", + "description": "Returns the phrase for counting the Omer on a given date.", + "fields": { + "date": { + "name": "Date", + "description": "Date to count the Omer for." + }, + "nusach": { + "name": "Nusach", + "description": "Nusach to count the Omer in." + }, + "language": { + "name": "Language", + "description": "Language to count the Omer in." + } + } + } } } diff --git a/tests/components/jewish_calendar/test_service.py b/tests/components/jewish_calendar/test_service.py new file mode 100644 index 00000000000..9eb80e5e7f0 --- /dev/null +++ b/tests/components/jewish_calendar/test_service.py @@ -0,0 +1,55 @@ +"""Test jewish calendar service.""" + +import datetime as dt + +from hdate.translator import Language +import pytest + +from homeassistant.components.jewish_calendar.const import DOMAIN +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +@pytest.mark.parametrize( + ("test_date", "nusach", "language", "expected"), + [ + pytest.param(dt.date(2025, 3, 20), "sfarad", "he", "", id="no_blessing"), + pytest.param( + dt.date(2025, 5, 20), + "ashkenaz", + "he", + "היום שבעה ושלושים יום שהם חמישה שבועות ושני ימים בעומר", + id="ahskenaz-hebrew", + ), + pytest.param( + dt.date(2025, 5, 20), + "sfarad", + "en", + "Today is the thirty-seventh day, which are five weeks and two days of the Omer", + id="sefarad-english", + ), + ], +) +async def test_get_omer_blessing( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + test_date: dt.date, + nusach: str, + language: Language, + expected: str, +) -> None: + """Test get omer blessing.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.services.async_call( + DOMAIN, + "count_omer", + {"date": test_date, "nusach": nusach, "language": language}, + blocking=True, + return_response=True, + ) + + assert result["message"] == expected From 21d5885ded2c1d1c8afc45dfe0a39b85d6accbc3 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 26 Mar 2025 13:39:36 +0100 Subject: [PATCH 3082/3148] Add select entity for dishwasher operating state in SmartThings (#141468) * Add select entity for dishwasher operating state in SmartThings * Add select entity for dishwasher operating state in SmartThings --- .../components/smartthings/select.py | 9 +++ .../components/smartthings/sensor.py | 12 +++- .../smartthings/snapshots/test_select.ambr | 58 +++++++++++++++++++ 3 files changed, 77 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/smartthings/select.py b/homeassistant/components/smartthings/select.py index 0bb2e7c71db..f0a483b1329 100644 --- a/homeassistant/components/smartthings/select.py +++ b/homeassistant/components/smartthings/select.py @@ -28,6 +28,15 @@ class SmartThingsSelectDescription(SelectEntityDescription): CAPABILITIES_TO_SELECT: dict[Capability | str, SmartThingsSelectDescription] = { + Capability.DISHWASHER_OPERATING_STATE: SmartThingsSelectDescription( + key=Capability.DISHWASHER_OPERATING_STATE, + name=None, + translation_key="operating_state", + requires_remote_control_status=True, + options_attribute=Attribute.SUPPORTED_MACHINE_STATES, + status_attribute=Attribute.MACHINE_STATE, + command=Command.SET_MACHINE_STATE, + ), Capability.DRYER_OPERATING_STATE: SmartThingsSelectDescription( key=Capability.DRYER_OPERATING_STATE, name=None, diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index 1b4ccf1c576..6d2ce6417da 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -1103,7 +1103,11 @@ class SmartThingsSensor(SmartThingsEntity, SensorEntity): await super().async_added_to_hass() if ( self.capability - not in {Capability.DRYER_OPERATING_STATE, Capability.WASHER_OPERATING_STATE} + not in { + Capability.DISHWASHER_OPERATING_STATE, + Capability.DRYER_OPERATING_STATE, + Capability.WASHER_OPERATING_STATE, + } or self._attribute is not Attribute.MACHINE_STATE ): return @@ -1142,7 +1146,11 @@ class SmartThingsSensor(SmartThingsEntity, SensorEntity): await super().async_will_remove_from_hass() if ( self.capability - not in {Capability.DRYER_OPERATING_STATE, Capability.WASHER_OPERATING_STATE} + not in { + Capability.DISHWASHER_OPERATING_STATE, + Capability.DRYER_OPERATING_STATE, + Capability.WASHER_OPERATING_STATE, + } or self._attribute is not Attribute.MACHINE_STATE ): return diff --git a/tests/components/smartthings/snapshots/test_select.ambr b/tests/components/smartthings/snapshots/test_select.ambr index 1adb8ed2572..867eb96c048 100644 --- a/tests/components/smartthings/snapshots/test_select.ambr +++ b/tests/components/smartthings/snapshots/test_select.ambr @@ -1,4 +1,62 @@ # serializer version: 1 +# name: test_all_entities[da_wm_dw_000001][select.dishwasher-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'stop', + 'run', + 'pause', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.dishwasher', + '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': None, + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'operating_state', + 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676_main_dishwasherOperatingState_machineState_machineState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_dw_000001][select.dishwasher-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dishwasher', + 'options': list([ + 'stop', + 'run', + 'pause', + ]), + }), + 'context': , + 'entity_id': 'select.dishwasher', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'stop', + }) +# --- # name: test_all_entities[da_wm_wd_000001][select.dryer-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From aa493ff97dca511365231fca5f964706f3ee0fb2 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Wed, 26 Mar 2025 13:48:08 +0100 Subject: [PATCH 3083/3148] Correct device class and state class for wind direction sensors (#141393) * Fix state class on wind direction sensors * Update snapshots --- homeassistant/components/netatmo/sensor.py | 12 +- .../components/openweathermap/sensor.py | 3 +- homeassistant/components/rflink/sensor.py | 3 +- homeassistant/components/rfxtrx/sensor.py | 3 +- .../trafikverket_weatherstation/sensor.py | 3 +- homeassistant/components/zamg/sensor.py | 3 +- .../netatmo/snapshots/test_sensor.ambr | 362 +++++++++--------- 7 files changed, 203 insertions(+), 186 deletions(-) diff --git a/homeassistant/components/netatmo/sensor.py b/homeassistant/components/netatmo/sensor.py index 5f8084d542c..56b8233912f 100644 --- a/homeassistant/components/netatmo/sensor.py +++ b/homeassistant/components/netatmo/sensor.py @@ -213,7 +213,8 @@ SENSOR_TYPES: tuple[NetatmoSensorEntityDescription, ...] = ( netatmo_name="wind_angle", entity_registry_enabled_default=False, native_unit_of_measurement=DEGREE, - state_class=SensorStateClass.MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT_ANGLE, + device_class=SensorDeviceClass.WIND_DIRECTION, ), NetatmoSensorEntityDescription( key="windstrength", @@ -235,7 +236,8 @@ SENSOR_TYPES: tuple[NetatmoSensorEntityDescription, ...] = ( netatmo_name="gust_angle", entity_registry_enabled_default=False, native_unit_of_measurement=DEGREE, - state_class=SensorStateClass.MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT_ANGLE, + device_class=SensorDeviceClass.WIND_DIRECTION, ), NetatmoSensorEntityDescription( key="guststrength", @@ -345,7 +347,8 @@ PUBLIC_WEATHER_STATION_TYPES: tuple[ key="windangle_value", entity_registry_enabled_default=False, native_unit_of_measurement=DEGREE, - state_class=SensorStateClass.MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT_ANGLE, + device_class=SensorDeviceClass.WIND_DIRECTION, value_fn=lambda area: area.get_latest_wind_angles(), ), NetatmoPublicWeatherSensorEntityDescription( @@ -360,7 +363,8 @@ PUBLIC_WEATHER_STATION_TYPES: tuple[ translation_key="gust_angle", entity_registry_enabled_default=False, native_unit_of_measurement=DEGREE, - state_class=SensorStateClass.MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT_ANGLE, + device_class=SensorDeviceClass.WIND_DIRECTION, value_fn=lambda area: area.get_latest_gust_angles(), ), NetatmoPublicWeatherSensorEntityDescription( diff --git a/homeassistant/components/openweathermap/sensor.py b/homeassistant/components/openweathermap/sensor.py index 0afab69b638..a595652d90b 100644 --- a/homeassistant/components/openweathermap/sensor.py +++ b/homeassistant/components/openweathermap/sensor.py @@ -89,7 +89,8 @@ WEATHER_SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( key=ATTR_API_WIND_BEARING, name="Wind bearing", native_unit_of_measurement=DEGREE, - state_class=SensorStateClass.MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT_ANGLE, + device_class=SensorDeviceClass.WIND_DIRECTION, ), SensorEntityDescription( key=ATTR_API_HUMIDITY, diff --git a/homeassistant/components/rflink/sensor.py b/homeassistant/components/rflink/sensor.py index 027c39da70f..97d0b811509 100644 --- a/homeassistant/components/rflink/sensor.py +++ b/homeassistant/components/rflink/sensor.py @@ -236,7 +236,8 @@ SENSOR_TYPES = ( key="winddirection", name="Wind direction", icon="mdi:compass", - state_class=SensorStateClass.MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT_ANGLE, + device_class=SensorDeviceClass.WIND_DIRECTION, native_unit_of_measurement=DEGREE, ), SensorEntityDescription( diff --git a/homeassistant/components/rfxtrx/sensor.py b/homeassistant/components/rfxtrx/sensor.py index 4b256279445..6669b1367df 100644 --- a/homeassistant/components/rfxtrx/sensor.py +++ b/homeassistant/components/rfxtrx/sensor.py @@ -161,7 +161,8 @@ SENSOR_TYPES = ( RfxtrxSensorEntityDescription( key="Wind direction", translation_key="wind_direction", - state_class=SensorStateClass.MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT_ANGLE, + device_class=SensorDeviceClass.WIND_DIRECTION, native_unit_of_measurement=DEGREE, ), RfxtrxSensorEntityDescription( diff --git a/homeassistant/components/trafikverket_weatherstation/sensor.py b/homeassistant/components/trafikverket_weatherstation/sensor.py index cb923037a24..bbc6764e3ef 100644 --- a/homeassistant/components/trafikverket_weatherstation/sensor.py +++ b/homeassistant/components/trafikverket_weatherstation/sensor.py @@ -89,7 +89,8 @@ SENSOR_TYPES: tuple[TrafikverketSensorEntityDescription, ...] = ( translation_key="wind_direction", value_fn=lambda data: data.winddirection, native_unit_of_measurement=DEGREE, - state_class=SensorStateClass.MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT_ANGLE, + device_class=SensorDeviceClass.WIND_DIRECTION, ), TrafikverketSensorEntityDescription( key="wind_speed", diff --git a/homeassistant/components/zamg/sensor.py b/homeassistant/components/zamg/sensor.py index 5846092e555..fdb9d51185c 100644 --- a/homeassistant/components/zamg/sensor.py +++ b/homeassistant/components/zamg/sensor.py @@ -82,7 +82,8 @@ SENSOR_TYPES: tuple[ZamgSensorEntityDescription, ...] = ( key="wind_bearing", name="Wind Bearing", native_unit_of_measurement=DEGREE, - state_class=SensorStateClass.MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT_ANGLE, + device_class=SensorDeviceClass.WIND_DIRECTION, para_name="DD", ), ZamgSensorEntityDescription( diff --git a/tests/components/netatmo/snapshots/test_sensor.ambr b/tests/components/netatmo/snapshots/test_sensor.ambr index b149e80fa5b..00285f565a6 100644 --- a/tests/components/netatmo/snapshots/test_sensor.ambr +++ b/tests/components/netatmo/snapshots/test_sensor.ambr @@ -1501,7 +1501,7 @@ }), 'area_id': None, 'capabilities': dict({ - 'state_class': , + 'state_class': , }), 'config_entry_id': , 'config_subentry_id': , @@ -1520,7 +1520,7 @@ 'name': None, 'options': dict({ }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Gust angle', 'platform': 'netatmo', @@ -1535,10 +1535,11 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by Netatmo', + 'device_class': 'wind_direction', 'friendly_name': 'Home avg Gust angle', 'latitude': 32.17901225, 'longitude': -117.17901225, - 'state_class': , + 'state_class': , 'unit_of_measurement': '°', }), 'context': , @@ -1659,60 +1660,6 @@ 'state': '63.2', }) # --- -# name: test_entity[sensor.home_avg_none-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.home_avg_none', - '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': None, - 'platform': 'netatmo', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'Home-avg-windangle_value', - 'unit_of_measurement': '°', - }) -# --- -# name: test_entity[sensor.home_avg_none-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by Netatmo', - 'friendly_name': 'Home avg None', - 'latitude': 32.17901225, - 'longitude': -117.17901225, - 'state_class': , - 'unit_of_measurement': '°', - }), - 'context': , - 'entity_id': 'sensor.home_avg_none', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '17.0', - }) -# --- # name: test_entity[sensor.home_avg_precipitation-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1939,6 +1886,61 @@ 'state': '22.7', }) # --- +# name: test_entity[sensor.home_avg_wind_direction-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_avg_wind_direction', + '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 direction', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'Home-avg-windangle_value', + 'unit_of_measurement': '°', + }) +# --- +# name: test_entity[sensor.home_avg_wind_direction-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'wind_direction', + 'friendly_name': 'Home avg Wind direction', + 'latitude': 32.17901225, + 'longitude': -117.17901225, + 'state_class': , + 'unit_of_measurement': '°', + }), + 'context': , + 'entity_id': 'sensor.home_avg_wind_direction', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '17.0', + }) +# --- # name: test_entity[sensor.home_avg_wind_speed-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2061,7 +2063,7 @@ }), 'area_id': None, 'capabilities': dict({ - 'state_class': , + 'state_class': , }), 'config_entry_id': , 'config_subentry_id': , @@ -2080,7 +2082,7 @@ 'name': None, 'options': dict({ }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Gust angle', 'platform': 'netatmo', @@ -2095,10 +2097,11 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by Netatmo', + 'device_class': 'wind_direction', 'friendly_name': 'Home max Gust angle', 'latitude': 32.17901225, 'longitude': -117.17901225, - 'state_class': , + 'state_class': , 'unit_of_measurement': '°', }), 'context': , @@ -2219,60 +2222,6 @@ 'state': '76', }) # --- -# name: test_entity[sensor.home_max_none-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.home_max_none', - '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': None, - 'platform': 'netatmo', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'Home-max-windangle_value', - 'unit_of_measurement': '°', - }) -# --- -# name: test_entity[sensor.home_max_none-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by Netatmo', - 'friendly_name': 'Home max None', - 'latitude': 32.17901225, - 'longitude': -117.17901225, - 'state_class': , - 'unit_of_measurement': '°', - }), - 'context': , - 'entity_id': 'sensor.home_max_none', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '17', - }) -# --- # name: test_entity[sensor.home_max_precipitation-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2499,6 +2448,61 @@ 'state': '27.4', }) # --- +# name: test_entity[sensor.home_max_wind_direction-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_max_wind_direction', + '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 direction', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'Home-max-windangle_value', + 'unit_of_measurement': '°', + }) +# --- +# name: test_entity[sensor.home_max_wind_direction-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'wind_direction', + 'friendly_name': 'Home max Wind direction', + 'latitude': 32.17901225, + 'longitude': -117.17901225, + 'state_class': , + 'unit_of_measurement': '°', + }), + 'context': , + 'entity_id': 'sensor.home_max_wind_direction', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '17', + }) +# --- # name: test_entity[sensor.home_max_wind_speed-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2621,7 +2625,7 @@ }), 'area_id': None, 'capabilities': dict({ - 'state_class': , + 'state_class': , }), 'config_entry_id': , 'config_subentry_id': , @@ -2640,7 +2644,7 @@ 'name': None, 'options': dict({ }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Gust angle', 'platform': 'netatmo', @@ -2655,10 +2659,11 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by Netatmo', + 'device_class': 'wind_direction', 'friendly_name': 'Home min Gust angle', 'latitude': 32.17901225, 'longitude': -117.17901225, - 'state_class': , + 'state_class': , 'unit_of_measurement': '°', }), 'context': , @@ -2779,60 +2784,6 @@ 'state': '56', }) # --- -# name: test_entity[sensor.home_min_none-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.home_min_none', - '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': None, - 'platform': 'netatmo', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'Home-min-windangle_value', - 'unit_of_measurement': '°', - }) -# --- -# name: test_entity[sensor.home_min_none-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by Netatmo', - 'friendly_name': 'Home min None', - 'latitude': 32.17901225, - 'longitude': -117.17901225, - 'state_class': , - 'unit_of_measurement': '°', - }), - 'context': , - 'entity_id': 'sensor.home_min_none', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '17', - }) -# --- # name: test_entity[sensor.home_min_precipitation-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -3059,6 +3010,61 @@ 'state': '19.8', }) # --- +# name: test_entity[sensor.home_min_wind_direction-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_min_wind_direction', + '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 direction', + 'platform': 'netatmo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'Home-min-windangle_value', + 'unit_of_measurement': '°', + }) +# --- +# name: test_entity[sensor.home_min_wind_direction-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Netatmo', + 'device_class': 'wind_direction', + 'friendly_name': 'Home min Wind direction', + 'latitude': 32.17901225, + 'longitude': -117.17901225, + 'state_class': , + 'unit_of_measurement': '°', + }), + 'context': , + 'entity_id': 'sensor.home_min_wind_direction', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '17', + }) +# --- # name: test_entity[sensor.home_min_wind_speed-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -6253,7 +6259,7 @@ }), 'area_id': None, 'capabilities': dict({ - 'state_class': , + 'state_class': , }), 'config_entry_id': , 'config_subentry_id': , @@ -6272,7 +6278,7 @@ 'name': None, 'options': dict({ }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Gust angle', 'platform': 'netatmo', @@ -6287,8 +6293,9 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by Netatmo', + 'device_class': 'wind_direction', 'friendly_name': 'Villa Garden Gust angle', - 'state_class': , + 'state_class': , 'unit_of_measurement': '°', }), 'context': , @@ -6524,7 +6531,7 @@ }), 'area_id': None, 'capabilities': dict({ - 'state_class': , + 'state_class': , }), 'config_entry_id': , 'config_subentry_id': , @@ -6543,7 +6550,7 @@ 'name': None, 'options': dict({ }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Wind angle', 'platform': 'netatmo', @@ -6558,8 +6565,9 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by Netatmo', + 'device_class': 'wind_direction', 'friendly_name': 'Villa Garden Wind angle', - 'state_class': , + 'state_class': , 'unit_of_measurement': '°', }), 'context': , From f842640249ae065430592b266575f49896216d80 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Wed, 26 Mar 2025 13:52:00 +0100 Subject: [PATCH 3084/3148] Add check that sensor state classes are used only with valid unit of measurements (#141444) --- homeassistant/components/sensor/__init__.py | 13 ++++++++ homeassistant/components/sensor/const.py | 5 ++++ tests/components/sensor/test_init.py | 33 +++++++++++++++++++++ 3 files changed, 51 insertions(+) diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index e3ee566a855..e06ee85cd03 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -44,6 +44,7 @@ from .const import ( # noqa: F401 DEVICE_CLASSES_SCHEMA, DOMAIN, NON_NUMERIC_DEVICE_CLASSES, + STATE_CLASS_UNITS, STATE_CLASSES, STATE_CLASSES_SCHEMA, UNIT_CONVERTERS, @@ -713,6 +714,18 @@ class SensorEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): report_issue, ) + # Validate unit of measurement used for sensors with a state class + if ( + state_class + and (units := STATE_CLASS_UNITS.get(state_class)) is not None + and native_unit_of_measurement not in units + ): + raise ValueError( + f"Sensor {self.entity_id} ({type(self)}) is using native unit of " + f"measurement '{native_unit_of_measurement}' which is not a valid unit " + f"for the state class ('{state_class}') it is using; expected one of {units};" + ) + return value def _display_precision_or_none(self) -> int | None: diff --git a/homeassistant/components/sensor/const.py b/homeassistant/components/sensor/const.py index 916bd5cbd40..63af8e5bf52 100644 --- a/homeassistant/components/sensor/const.py +++ b/homeassistant/components/sensor/const.py @@ -699,3 +699,8 @@ DEVICE_CLASS_STATE_CLASSES: dict[SensorDeviceClass, set[SensorStateClass]] = { SensorDeviceClass.WIND_DIRECTION: {SensorStateClass.MEASUREMENT_ANGLE}, SensorDeviceClass.WIND_SPEED: {SensorStateClass.MEASUREMENT}, } + + +STATE_CLASS_UNITS: dict[SensorStateClass | str, set[type[StrEnum] | str | None]] = { + SensorStateClass.MEASUREMENT_ANGLE: {DEGREE}, +} diff --git a/tests/components/sensor/test_init.py b/tests/components/sensor/test_init.py index b162200f95e..9666e29579b 100644 --- a/tests/components/sensor/test_init.py +++ b/tests/components/sensor/test_init.py @@ -24,6 +24,7 @@ from homeassistant.components.sensor import ( async_rounded_state, async_update_suggested_units, ) +from homeassistant.components.sensor.const import STATE_CLASS_UNITS from homeassistant.config_entries import ConfigEntry, ConfigFlow from homeassistant.const import ( ATTR_UNIT_OF_MEASUREMENT, @@ -2005,6 +2006,7 @@ async def test_non_numeric_device_class_with_unit_of_measurement( SensorDeviceClass.VOLUME, SensorDeviceClass.WATER, SensorDeviceClass.WEIGHT, + SensorDeviceClass.WIND_DIRECTION, SensorDeviceClass.WIND_SPEED, ], ) @@ -2035,6 +2037,37 @@ async def test_device_classes_with_invalid_unit_of_measurement( ) in caplog.text +@pytest.mark.parametrize( + "state_class", + [SensorStateClass.MEASUREMENT_ANGLE], +) +async def test_state_classes_with_invalid_unit_of_measurement( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + state_class: SensorStateClass, +) -> None: + """Test error when unit of measurement is not valid for used state class.""" + entity0 = MockSensor( + name="Test", + native_value="1.0", + state_class=state_class, + native_unit_of_measurement="INVALID!", + ) + setup_test_component_platform(hass, sensor.DOMAIN, [entity0]) + units = { + str(unit) if unit else "no unit of measurement" + for unit in STATE_CLASS_UNITS.get(state_class, set()) + } + assert await async_setup_component(hass, "sensor", {"sensor": {"platform": "test"}}) + await hass.async_block_till_done() + + assert ( + f"Sensor sensor.test ({entity0.__class__}) is using native unit of " + "measurement 'INVALID!' which is not a valid unit " + f"for the state class ('{state_class}') it is using; expected one of {units};" + ) in caplog.text + + @pytest.mark.parametrize( ("device_class", "state_class", "unit"), [ From dba4c197c8d1d3ea0cdb034f82fab1c5825db2bd Mon Sep 17 00:00:00 2001 From: Sanjay Govind Date: Thu, 27 Mar 2025 01:56:44 +1300 Subject: [PATCH 3085/3148] Add bosch_alarm integration (#138497) * Add bosch_alarm integration * Remove other platforms for now * update some strings not being consistant * fix sentence-casing for strings * remove options flow and versioning * clean up config flow * Add OSI license + tagged releases + ci to bosch-alarm-mode2 * Apply suggestions from code review Co-authored-by: Josef Zweck * apply changes from review * apply changes from review * remove options flow * work on fixtures * work on fixtures * fix errors and complete flow * use fixtures for alarm config * Update homeassistant/components/bosch_alarm/manifest.json Co-authored-by: Josef Zweck * fix missing type * mock setup entry * remove use of patch in config flow test * Use coordinator for managing panel data * Use coordinator for managing panel data * Coordinator cleanup * remove unnecessary observers * update listeners when error state changes * Update homeassistant/components/bosch_alarm/coordinator.py Co-authored-by: Josef Zweck * Update homeassistant/components/bosch_alarm/quality_scale.yaml Co-authored-by: Josef Zweck * Update homeassistant/components/bosch_alarm/config_flow.py Co-authored-by: Josef Zweck * rename config flow * Update homeassistant/components/bosch_alarm/quality_scale.yaml Co-authored-by: Josef Zweck * add missing types * fix quality_scale.yaml * enable strict typing * enable strict typing * Add test for alarm control panel * add more tests * add more tests * Update homeassistant/components/bosch_alarm/coordinator.py Co-authored-by: Josef Zweck * Update homeassistant/components/bosch_alarm/coordinator.py Co-authored-by: Josef Zweck * Update homeassistant/components/bosch_alarm/alarm_control_panel.py Co-authored-by: Josef Zweck * Update homeassistant/components/bosch_alarm/alarm_control_panel.py Co-authored-by: Josef Zweck * Update homeassistant/components/bosch_alarm/alarm_control_panel.py Co-authored-by: Josef Zweck * Add snapshot test * add snapshot test * add snapshot test * update quality scale * update quality scale * update quality scale * update quality scale * Apply suggestions from code review Co-authored-by: Joost Lekkerkerker * apply changes from code review * apply changes from code review * apply changes from code review * Apply suggestions from code review Co-authored-by: Joost Lekkerkerker * apply changes from code review * apply changes from code review * Fix alarm control panel device name * Fix * Fix * Fix * Fix --------- Co-authored-by: Josef Zweck Co-authored-by: Joost Lekkerkerker --- .strict-typing | 1 + CODEOWNERS | 2 + .../components/bosch_alarm/__init__.py | 62 +++++ .../bosch_alarm/alarm_control_panel.py | 109 +++++++++ .../components/bosch_alarm/config_flow.py | 165 ++++++++++++++ homeassistant/components/bosch_alarm/const.py | 6 + .../components/bosch_alarm/manifest.json | 11 + .../components/bosch_alarm/quality_scale.yaml | 84 +++++++ .../components/bosch_alarm/strings.json | 36 +++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + mypy.ini | 10 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/bosch_alarm/__init__.py | 22 ++ tests/components/bosch_alarm/conftest.py | 131 +++++++++++ .../snapshots/test_alarm_control_panel.ambr | 154 +++++++++++++ .../bosch_alarm/test_alarm_control_panel.py | 145 ++++++++++++ .../bosch_alarm/test_config_flow.py | 212 ++++++++++++++++++ tests/components/bosch_alarm/test_init.py | 33 +++ 20 files changed, 1196 insertions(+) create mode 100644 homeassistant/components/bosch_alarm/__init__.py create mode 100644 homeassistant/components/bosch_alarm/alarm_control_panel.py create mode 100644 homeassistant/components/bosch_alarm/config_flow.py create mode 100644 homeassistant/components/bosch_alarm/const.py create mode 100644 homeassistant/components/bosch_alarm/manifest.json create mode 100644 homeassistant/components/bosch_alarm/quality_scale.yaml create mode 100644 homeassistant/components/bosch_alarm/strings.json create mode 100644 tests/components/bosch_alarm/__init__.py create mode 100644 tests/components/bosch_alarm/conftest.py create mode 100644 tests/components/bosch_alarm/snapshots/test_alarm_control_panel.ambr create mode 100644 tests/components/bosch_alarm/test_alarm_control_panel.py create mode 100644 tests/components/bosch_alarm/test_config_flow.py create mode 100644 tests/components/bosch_alarm/test_init.py diff --git a/.strict-typing b/.strict-typing index 0e00c2e9e07..e0c4e569f4b 100644 --- a/.strict-typing +++ b/.strict-typing @@ -119,6 +119,7 @@ homeassistant.components.bluetooth_adapters.* homeassistant.components.bluetooth_tracker.* homeassistant.components.bmw_connected_drive.* homeassistant.components.bond.* +homeassistant.components.bosch_alarm.* homeassistant.components.braviatv.* homeassistant.components.bring.* homeassistant.components.brother.* diff --git a/CODEOWNERS b/CODEOWNERS index 9e33407c7b8..9a8d8b2fc64 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -216,6 +216,8 @@ build.json @home-assistant/supervisor /tests/components/bmw_connected_drive/ @gerard33 @rikroe /homeassistant/components/bond/ @bdraco @prystupa @joshs85 @marciogranzotto /tests/components/bond/ @bdraco @prystupa @joshs85 @marciogranzotto +/homeassistant/components/bosch_alarm/ @mag1024 @sanjay900 +/tests/components/bosch_alarm/ @mag1024 @sanjay900 /homeassistant/components/bosch_shc/ @tschamm /tests/components/bosch_shc/ @tschamm /homeassistant/components/braviatv/ @bieniu @Drafteed diff --git a/homeassistant/components/bosch_alarm/__init__.py b/homeassistant/components/bosch_alarm/__init__.py new file mode 100644 index 00000000000..bc7fee46f60 --- /dev/null +++ b/homeassistant/components/bosch_alarm/__init__.py @@ -0,0 +1,62 @@ +"""The Bosch Alarm integration.""" + +from __future__ import annotations + +from ssl import SSLError + +from bosch_alarm_mode2 import Panel + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr + +from .const import CONF_INSTALLER_CODE, CONF_USER_CODE, DOMAIN + +PLATFORMS: list[Platform] = [Platform.ALARM_CONTROL_PANEL] + +type BoschAlarmConfigEntry = ConfigEntry[Panel] + + +async def async_setup_entry(hass: HomeAssistant, entry: BoschAlarmConfigEntry) -> bool: + """Set up Bosch Alarm from a config entry.""" + + panel = Panel( + host=entry.data[CONF_HOST], + port=entry.data[CONF_PORT], + automation_code=entry.data.get(CONF_PASSWORD), + installer_or_user_code=entry.data.get( + CONF_INSTALLER_CODE, entry.data.get(CONF_USER_CODE) + ), + ) + try: + await panel.connect() + except (PermissionError, ValueError) as err: + await panel.disconnect() + raise ConfigEntryNotReady from err + except (TimeoutError, OSError, ConnectionRefusedError, SSLError) as err: + await panel.disconnect() + raise ConfigEntryNotReady("Connection failed") from err + + entry.runtime_data = panel + + device_registry = dr.async_get(hass) + + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, entry.unique_id or entry.entry_id)}, + name=f"Bosch {panel.model}", + manufacturer="Bosch Security Systems", + model=panel.model, + sw_version=panel.firmware_version, + ) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: BoschAlarmConfigEntry) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + await entry.runtime_data.disconnect() + return unload_ok diff --git a/homeassistant/components/bosch_alarm/alarm_control_panel.py b/homeassistant/components/bosch_alarm/alarm_control_panel.py new file mode 100644 index 00000000000..a1d8a7b90f4 --- /dev/null +++ b/homeassistant/components/bosch_alarm/alarm_control_panel.py @@ -0,0 +1,109 @@ +"""Support for Bosch Alarm Panel.""" + +from __future__ import annotations + +from bosch_alarm_mode2 import Panel + +from homeassistant.components.alarm_control_panel import ( + AlarmControlPanelEntity, + AlarmControlPanelEntityFeature, + AlarmControlPanelState, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import BoschAlarmConfigEntry +from .const import DOMAIN + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: BoschAlarmConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up control panels for each area.""" + panel = config_entry.runtime_data + + async_add_entities( + AreaAlarmControlPanel( + panel, + area_id, + config_entry.unique_id or config_entry.entry_id, + ) + for area_id in panel.areas + ) + + +class AreaAlarmControlPanel(AlarmControlPanelEntity): + """An alarm control panel entity for a bosch alarm panel.""" + + _attr_has_entity_name = True + _attr_supported_features = ( + AlarmControlPanelEntityFeature.ARM_HOME + | AlarmControlPanelEntityFeature.ARM_AWAY + ) + _attr_code_arm_required = False + _attr_name = None + + def __init__(self, panel: Panel, area_id: int, unique_id: str) -> None: + """Initialise a Bosch Alarm control panel entity.""" + self.panel = panel + self._area = panel.areas[area_id] + self._area_id = area_id + self._attr_unique_id = f"{unique_id}_area_{area_id}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self._attr_unique_id)}, + name=self._area.name, + manufacturer="Bosch Security Systems", + via_device=( + DOMAIN, + unique_id, + ), + ) + + @property + def alarm_state(self) -> AlarmControlPanelState | None: + """Return the state of the alarm.""" + if self._area.is_triggered(): + return AlarmControlPanelState.TRIGGERED + if self._area.is_disarmed(): + return AlarmControlPanelState.DISARMED + if self._area.is_arming(): + return AlarmControlPanelState.ARMING + if self._area.is_pending(): + return AlarmControlPanelState.PENDING + if self._area.is_part_armed(): + return AlarmControlPanelState.ARMED_HOME + if self._area.is_all_armed(): + return AlarmControlPanelState.ARMED_AWAY + return None + + async def async_alarm_disarm(self, code: str | None = None) -> None: + """Disarm this panel.""" + await self.panel.area_disarm(self._area_id) + + async def async_alarm_arm_home(self, code: str | None = None) -> None: + """Send arm home command.""" + await self.panel.area_arm_part(self._area_id) + + async def async_alarm_arm_away(self, code: str | None = None) -> None: + """Send arm away command.""" + await self.panel.area_arm_all(self._area_id) + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return self.panel.connection_status() + + async def async_added_to_hass(self) -> None: + """Run when entity attached to hass.""" + await super().async_added_to_hass() + self._area.status_observer.attach(self.schedule_update_ha_state) + self.panel.connection_status_observer.attach(self.schedule_update_ha_state) + + async def async_will_remove_from_hass(self) -> None: + """Run when entity removed from hass.""" + await super().async_will_remove_from_hass() + self._area.status_observer.detach(self.schedule_update_ha_state) + self.panel.connection_status_observer.detach(self.schedule_update_ha_state) diff --git a/homeassistant/components/bosch_alarm/config_flow.py b/homeassistant/components/bosch_alarm/config_flow.py new file mode 100644 index 00000000000..e48f2a11944 --- /dev/null +++ b/homeassistant/components/bosch_alarm/config_flow.py @@ -0,0 +1,165 @@ +"""Config flow for Bosch Alarm integration.""" + +from __future__ import annotations + +import asyncio +import logging +import ssl +from typing import Any + +from bosch_alarm_mode2 import Panel +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import ( + CONF_CODE, + CONF_HOST, + CONF_MODEL, + CONF_PASSWORD, + CONF_PORT, +) +import homeassistant.helpers.config_validation as cv + +from .const import CONF_INSTALLER_CODE, CONF_USER_CODE, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Required(CONF_PORT, default=7700): cv.positive_int, + } +) + +STEP_AUTH_DATA_SCHEMA_SOLUTION = vol.Schema( + { + vol.Required(CONF_USER_CODE): str, + } +) + +STEP_AUTH_DATA_SCHEMA_AMAX = vol.Schema( + { + vol.Required(CONF_INSTALLER_CODE): str, + vol.Required(CONF_PASSWORD): str, + } +) + +STEP_AUTH_DATA_SCHEMA_BG = vol.Schema( + { + vol.Required(CONF_PASSWORD): str, + } +) + +STEP_INIT_DATA_SCHEMA = vol.Schema({vol.Optional(CONF_CODE): str}) + + +async def try_connect( + data: dict[str, Any], load_selector: int = 0 +) -> tuple[str, int | None]: + """Validate the user input allows us to connect. + + Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. + """ + panel = Panel( + host=data[CONF_HOST], + port=data[CONF_PORT], + automation_code=data.get(CONF_PASSWORD), + installer_or_user_code=data.get(CONF_INSTALLER_CODE, data.get(CONF_USER_CODE)), + ) + + try: + await panel.connect(load_selector) + finally: + await panel.disconnect() + + return (panel.model, panel.serial_number) + + +class BoschAlarmConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Bosch Alarm.""" + + def __init__(self) -> None: + """Init config flow.""" + + self._data: dict[str, Any] = {} + + 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: + try: + # Use load_selector = 0 to fetch the panel model without authentication. + (model, serial) = await try_connect(user_input, 0) + except ( + OSError, + ConnectionRefusedError, + ssl.SSLError, + asyncio.exceptions.TimeoutError, + ) as e: + _LOGGER.error("Connection Error: %s", e) + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + self._data = user_input + self._data[CONF_MODEL] = model + return await self.async_step_auth() + return self.async_show_form( + step_id="user", + data_schema=self.add_suggested_values_to_schema( + STEP_USER_DATA_SCHEMA, user_input + ), + errors=errors, + ) + + async def async_step_auth( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the auth step.""" + errors: dict[str, str] = {} + + # Each model variant requires a different authentication flow + if "Solution" in self._data[CONF_MODEL]: + schema = STEP_AUTH_DATA_SCHEMA_SOLUTION + elif "AMAX" in self._data[CONF_MODEL]: + schema = STEP_AUTH_DATA_SCHEMA_AMAX + else: + schema = STEP_AUTH_DATA_SCHEMA_BG + + if user_input is not None: + self._data.update(user_input) + try: + (model, serial_number) = await try_connect( + self._data, Panel.LOAD_EXTENDED_INFO + ) + except (PermissionError, ValueError) as e: + errors["base"] = "invalid_auth" + _LOGGER.error("Authentication Error: %s", e) + except ( + OSError, + ConnectionRefusedError, + ssl.SSLError, + TimeoutError, + ) as e: + _LOGGER.error("Connection Error: %s", e) + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + if serial_number: + await self.async_set_unique_id(str(serial_number)) + self._abort_if_unique_id_configured() + else: + self._async_abort_entries_match({CONF_HOST: self._data[CONF_HOST]}) + return self.async_create_entry(title=f"Bosch {model}", data=self._data) + + return self.async_show_form( + step_id="auth", + data_schema=self.add_suggested_values_to_schema(schema, user_input), + errors=errors, + ) diff --git a/homeassistant/components/bosch_alarm/const.py b/homeassistant/components/bosch_alarm/const.py new file mode 100644 index 00000000000..7205831391c --- /dev/null +++ b/homeassistant/components/bosch_alarm/const.py @@ -0,0 +1,6 @@ +"""Constants for the Bosch Alarm integration.""" + +DOMAIN = "bosch_alarm" +HISTORY_ATTR = "history" +CONF_INSTALLER_CODE = "installer_code" +CONF_USER_CODE = "user_code" diff --git a/homeassistant/components/bosch_alarm/manifest.json b/homeassistant/components/bosch_alarm/manifest.json new file mode 100644 index 00000000000..a54ace71782 --- /dev/null +++ b/homeassistant/components/bosch_alarm/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "bosch_alarm", + "name": "Bosch Alarm", + "codeowners": ["@mag1024", "@sanjay900"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/bosch_alarm", + "integration_type": "device", + "iot_class": "local_push", + "quality_scale": "bronze", + "requirements": ["bosch-alarm-mode2==0.4.3"] +} diff --git a/homeassistant/components/bosch_alarm/quality_scale.yaml b/homeassistant/components/bosch_alarm/quality_scale.yaml new file mode 100644 index 00000000000..467760fb863 --- /dev/null +++ b/homeassistant/components/bosch_alarm/quality_scale.yaml @@ -0,0 +1,84 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + No custom actions defined + appropriate-polling: + status: exempt + comment: | + No polling + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: | + No custom actions are defined. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: done + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: | + No custom actions are defined. + config-entry-unloading: done + docs-configuration-parameters: todo + docs-installation-parameters: todo + entity-unavailable: todo + integration-owner: done + log-when-unavailable: todo + parallel-updates: todo + reauthentication-flow: todo + test-coverage: done + + # Gold + devices: done + diagnostics: todo + discovery-update-info: todo + discovery: todo + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: + status: exempt + comment: | + Device type integration + entity-category: todo + entity-device-class: todo + entity-disabled-by-default: todo + entity-translations: todo + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: | + No repairs + stale-devices: + status: exempt + comment: | + Device type integration + + # Platinum + async-dependency: done + inject-websession: + status: exempt + comment: | + Integration does not make any HTTP requests. + strict-typing: done diff --git a/homeassistant/components/bosch_alarm/strings.json b/homeassistant/components/bosch_alarm/strings.json new file mode 100644 index 00000000000..f4846021b55 --- /dev/null +++ b/homeassistant/components/bosch_alarm/strings.json @@ -0,0 +1,36 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "port": "[%key:common::config_flow::data::port%]" + }, + "data_description": { + "host": "The hostname or IP address of your Bosch alarm panel", + "port": "The port used to connect to your Bosch alarm panel. This is usually 7700" + } + }, + "auth": { + "data": { + "password": "[%key:common::config_flow::data::password%]", + "installer_code": "Installer code", + "user_code": "User code" + }, + "data_description": { + "password": "The Mode 2 automation code from your panel", + "installer_code": "The installer code from your panel", + "user_code": "The user code from your panel" + } + } + }, + "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%]" + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 5a292995f01..d192b8fcd13 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -91,6 +91,7 @@ FLOWS = { "bluetooth", "bmw_connected_drive", "bond", + "bosch_alarm", "bosch_shc", "braviatv", "bring", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 52fb10e1886..58f7f7fab20 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -759,6 +759,12 @@ "config_flow": true, "iot_class": "local_push" }, + "bosch_alarm": { + "name": "Bosch Alarm", + "integration_type": "device", + "config_flow": true, + "iot_class": "local_push" + }, "bosch_shc": { "name": "Bosch SHC", "integration_type": "hub", diff --git a/mypy.ini b/mypy.ini index 852678677bb..9831a183ec4 100644 --- a/mypy.ini +++ b/mypy.ini @@ -945,6 +945,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.bosch_alarm.*] +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.braviatv.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 0a312ade915..50557f638cb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -644,6 +644,9 @@ bluetooth-data-tools==1.26.1 # homeassistant.components.bond bond-async==0.2.1 +# homeassistant.components.bosch_alarm +bosch-alarm-mode2==0.4.3 + # homeassistant.components.bosch_shc boschshcpy==0.2.91 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9d239a50938..6b5c71037f3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -569,6 +569,9 @@ bluetooth-data-tools==1.26.1 # homeassistant.components.bond bond-async==0.2.1 +# homeassistant.components.bosch_alarm +bosch-alarm-mode2==0.4.3 + # homeassistant.components.bosch_shc boschshcpy==0.2.91 diff --git a/tests/components/bosch_alarm/__init__.py b/tests/components/bosch_alarm/__init__.py new file mode 100644 index 00000000000..2b2d94cf1e5 --- /dev/null +++ b/tests/components/bosch_alarm/__init__.py @@ -0,0 +1,22 @@ +"""Tests for the Bosch Alarm component.""" + +from unittest.mock import AsyncMock + +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) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + +async def call_observable(hass: HomeAssistant, observable: AsyncMock) -> None: + """Call the observable with the given event.""" + for callback in observable.attach.call_args_list: + callback[0][0]() + await hass.async_block_till_done() diff --git a/tests/components/bosch_alarm/conftest.py b/tests/components/bosch_alarm/conftest.py new file mode 100644 index 00000000000..45ec0072a37 --- /dev/null +++ b/tests/components/bosch_alarm/conftest.py @@ -0,0 +1,131 @@ +"""Define fixtures for Bosch Alarm tests.""" + +from collections.abc import Generator +from typing import Any +from unittest.mock import AsyncMock, patch + +from bosch_alarm_mode2.panel import Area +from bosch_alarm_mode2.utils import Observable +import pytest + +from homeassistant.components.bosch_alarm.const import ( + CONF_INSTALLER_CODE, + CONF_USER_CODE, + DOMAIN, +) +from homeassistant.const import CONF_HOST, CONF_MODEL, CONF_PASSWORD, CONF_PORT + +from tests.common import MockConfigEntry + + +@pytest.fixture( + params=[ + "solution_3000", + "amax_3000", + "b5512", + ] +) +def model(request: pytest.FixtureRequest) -> Generator[str]: + """Return every device.""" + return request.param + + +@pytest.fixture +def extra_config_entry_data( + model: str, model_name: str, config_flow_data: dict[str, Any] +) -> dict[str, Any]: + """Return extra config entry data.""" + return {CONF_MODEL: model_name} | config_flow_data + + +@pytest.fixture +def config_flow_data(model: str) -> dict[str, Any]: + """Return extra config entry data.""" + if model == "solution_3000": + return {CONF_USER_CODE: "1234"} + if model == "amax_3000": + return {CONF_INSTALLER_CODE: "1234", CONF_PASSWORD: "1234567890"} + if model == "b5512": + return {CONF_PASSWORD: "1234567890"} + pytest.fail("Invalid model") + + +@pytest.fixture +def model_name(model: str) -> str | None: + """Return extra config entry data.""" + return { + "solution_3000": "Solution 3000", + "amax_3000": "AMAX 3000", + "b5512": "B5512 (US1B)", + }.get(model) + + +@pytest.fixture +def serial_number(model: str) -> str | None: + """Return extra config entry data.""" + if model == "solution_3000": + return "1234567890" + return None + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.bosch_alarm.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def area() -> Generator[Area]: + """Define a mocked area.""" + mock = AsyncMock(spec=Area) + mock.name = "Area1" + mock.status_observer = AsyncMock(spec=Observable) + mock.is_triggered.return_value = False + mock.is_disarmed.return_value = True + mock.is_arming.return_value = False + mock.is_pending.return_value = False + mock.is_part_armed.return_value = False + mock.is_all_armed.return_value = False + return mock + + +@pytest.fixture +def mock_panel( + area: AsyncMock, model_name: str, serial_number: str | None +) -> Generator[AsyncMock]: + """Define a fixture to set up Bosch Alarm.""" + with ( + patch( + "homeassistant.components.bosch_alarm.Panel", autospec=True + ) as mock_panel, + patch("homeassistant.components.bosch_alarm.config_flow.Panel", new=mock_panel), + ): + client = mock_panel.return_value + client.areas = {1: area} + client.model = model_name + client.firmware_version = "1.0.0" + client.serial_number = serial_number + client.connection_status_observer = AsyncMock(spec=Observable) + yield client + + +@pytest.fixture +def mock_config_entry( + extra_config_entry_data: dict[str, Any], serial_number: str | None +) -> MockConfigEntry: + """Mock config entry for bosch alarm.""" + return MockConfigEntry( + domain=DOMAIN, + unique_id=serial_number, + entry_id="01JQ917ACKQ33HHM7YCFXYZX51", + data={ + CONF_HOST: "0.0.0.0", + CONF_PORT: 7700, + CONF_MODEL: "bosch_alarm_test_data.model", + } + | extra_config_entry_data, + ) diff --git a/tests/components/bosch_alarm/snapshots/test_alarm_control_panel.ambr b/tests/components/bosch_alarm/snapshots/test_alarm_control_panel.ambr new file mode 100644 index 00000000000..76568cef56c --- /dev/null +++ b/tests/components/bosch_alarm/snapshots/test_alarm_control_panel.ambr @@ -0,0 +1,154 @@ +# serializer version: 1 +# name: test_alarm_control_panel[amax_3000][alarm_control_panel.area1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'alarm_control_panel', + 'entity_category': None, + 'entity_id': 'alarm_control_panel.area1', + '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': None, + 'platform': 'bosch_alarm', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '01JQ917ACKQ33HHM7YCFXYZX51_area_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_alarm_control_panel[amax_3000][alarm_control_panel.area1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'changed_by': None, + 'code_arm_required': False, + 'code_format': None, + 'friendly_name': 'Area1', + 'supported_features': , + }), + 'context': , + 'entity_id': 'alarm_control_panel.area1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'disarmed', + }) +# --- +# name: test_alarm_control_panel[b5512][alarm_control_panel.area1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'alarm_control_panel', + 'entity_category': None, + 'entity_id': 'alarm_control_panel.area1', + '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': None, + 'platform': 'bosch_alarm', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '01JQ917ACKQ33HHM7YCFXYZX51_area_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_alarm_control_panel[b5512][alarm_control_panel.area1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'changed_by': None, + 'code_arm_required': False, + 'code_format': None, + 'friendly_name': 'Area1', + 'supported_features': , + }), + 'context': , + 'entity_id': 'alarm_control_panel.area1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'disarmed', + }) +# --- +# name: test_alarm_control_panel[solution_3000][alarm_control_panel.area1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'alarm_control_panel', + 'entity_category': None, + 'entity_id': 'alarm_control_panel.area1', + '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': None, + 'platform': 'bosch_alarm', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '1234567890_area_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_alarm_control_panel[solution_3000][alarm_control_panel.area1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'changed_by': None, + 'code_arm_required': False, + 'code_format': None, + 'friendly_name': 'Area1', + 'supported_features': , + }), + 'context': , + 'entity_id': 'alarm_control_panel.area1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'disarmed', + }) +# --- diff --git a/tests/components/bosch_alarm/test_alarm_control_panel.py b/tests/components/bosch_alarm/test_alarm_control_panel.py new file mode 100644 index 00000000000..31d2f928ec5 --- /dev/null +++ b/tests/components/bosch_alarm/test_alarm_control_panel.py @@ -0,0 +1,145 @@ +"""Tests for Bosch Alarm component.""" + +from collections.abc import AsyncGenerator +from unittest.mock import AsyncMock, patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.alarm_control_panel import ( + DOMAIN as ALARM_CONTROL_PANEL_DOMAIN, + AlarmControlPanelState, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_ALARM_ARM_AWAY, + SERVICE_ALARM_ARM_HOME, + SERVICE_ALARM_DISARM, + STATE_UNAVAILABLE, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import call_observable, setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.fixture(autouse=True) +async def platforms() -> AsyncGenerator[None]: + """Return the platforms to be loaded for this test.""" + with patch( + "homeassistant.components.bosch_alarm.PLATFORMS", [Platform.ALARM_CONTROL_PANEL] + ): + yield + + +async def test_update_alarm_device( + hass: HomeAssistant, + mock_panel: AsyncMock, + area: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that alarm panel state changes after arming the panel.""" + await setup_integration(hass, mock_config_entry) + entity_id = "alarm_control_panel.area1" + assert hass.states.get(entity_id).state == AlarmControlPanelState.DISARMED + + area.is_arming.return_value = True + area.is_disarmed.return_value = False + + await hass.services.async_call( + ALARM_CONTROL_PANEL_DOMAIN, + SERVICE_ALARM_ARM_AWAY, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + await call_observable(hass, area.status_observer) + + assert hass.states.get(entity_id).state == AlarmControlPanelState.ARMING + + area.is_arming.return_value = False + area.is_all_armed.return_value = True + + await call_observable(hass, area.status_observer) + + assert hass.states.get(entity_id).state == AlarmControlPanelState.ARMED_AWAY + + await hass.services.async_call( + ALARM_CONTROL_PANEL_DOMAIN, + SERVICE_ALARM_DISARM, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + area.is_all_armed.return_value = False + area.is_disarmed.return_value = True + + await call_observable(hass, area.status_observer) + + assert hass.states.get(entity_id).state == AlarmControlPanelState.DISARMED + await hass.services.async_call( + ALARM_CONTROL_PANEL_DOMAIN, + SERVICE_ALARM_ARM_HOME, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + area.is_disarmed.return_value = False + area.is_arming.return_value = True + + await call_observable(hass, area.status_observer) + + assert hass.states.get(entity_id).state == AlarmControlPanelState.ARMING + + area.is_arming.return_value = False + area.is_part_armed.return_value = True + + await call_observable(hass, area.status_observer) + + assert hass.states.get(entity_id).state == AlarmControlPanelState.ARMED_HOME + await hass.services.async_call( + ALARM_CONTROL_PANEL_DOMAIN, + SERVICE_ALARM_DISARM, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + area.is_part_armed.return_value = False + area.is_disarmed.return_value = True + + await call_observable(hass, area.status_observer) + assert hass.states.get(entity_id).state == AlarmControlPanelState.DISARMED + + +async def test_alarm_control_panel( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_panel: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the alarm_control_panel state.""" + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_alarm_control_panel_availability( + hass: HomeAssistant, + mock_panel: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the alarm_control_panel availability.""" + await setup_integration(hass, mock_config_entry) + + assert ( + hass.states.get("alarm_control_panel.area1").state + == AlarmControlPanelState.DISARMED + ) + + mock_panel.connection_status.return_value = False + + await call_observable(hass, mock_panel.connection_status_observer) + + assert hass.states.get("alarm_control_panel.area1").state == STATE_UNAVAILABLE diff --git a/tests/components/bosch_alarm/test_config_flow.py b/tests/components/bosch_alarm/test_config_flow.py new file mode 100644 index 00000000000..066b3008821 --- /dev/null +++ b/tests/components/bosch_alarm/test_config_flow.py @@ -0,0 +1,212 @@ +"""Tests for the bosch_alarm config flow.""" + +import asyncio +from typing import Any +from unittest.mock import AsyncMock + +import pytest + +from homeassistant import config_entries +from homeassistant.components.bosch_alarm.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_HOST, CONF_MODEL, CONF_PORT +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +async def test_form_user( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_panel: AsyncMock, + model_name: str, + serial_number: str, + config_flow_data: dict[str, Any], +) -> None: + """Test the config flow for bosch_alarm.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "1.1.1.1", CONF_PORT: 7700}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "auth" + assert result["errors"] == {} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + config_flow_data, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == f"Bosch {model_name}" + assert ( + result["data"] + == { + CONF_HOST: "1.1.1.1", + CONF_PORT: 7700, + CONF_MODEL: model_name, + } + | config_flow_data + ) + assert result["result"].unique_id == serial_number + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("exception", "message"), + [ + (asyncio.TimeoutError, "cannot_connect"), + (Exception, "unknown"), + ], +) +async def test_form_exceptions( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_panel: AsyncMock, + config_flow_data: dict[str, Any], + exception: Exception, + message: str, +) -> None: + """Test we handle exceptions correctly.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + mock_panel.connect.side_effect = exception + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "1.1.1.1", CONF_PORT: 7700}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": message} + + mock_panel.connect.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "1.1.1.1", CONF_PORT: 7700}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "auth" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + config_flow_data, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + + +@pytest.mark.parametrize( + ("exception", "message"), + [ + (PermissionError, "invalid_auth"), + (asyncio.TimeoutError, "cannot_connect"), + (Exception, "unknown"), + ], +) +async def test_form_exceptions_user( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_panel: AsyncMock, + config_flow_data: dict[str, Any], + exception: Exception, + message: str, +) -> None: + """Test we handle exceptions correctly.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "1.1.1.1", CONF_PORT: 7700}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "auth" + assert result["errors"] == {} + mock_panel.connect.side_effect = exception + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], config_flow_data + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "auth" + assert result["errors"] == {"base": message} + + mock_panel.connect.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], config_flow_data + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + + +@pytest.mark.parametrize("model", ["solution_3000", "amax_3000"]) +async def test_entry_already_configured_host( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_panel: AsyncMock, + config_flow_data: dict[str, Any], +) -> None: + """Test if configuring an entity twice results in an error.""" + 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"], {CONF_HOST: "0.0.0.0"} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "auth" + assert result["errors"] == {} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], config_flow_data + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.parametrize("model", ["b5512"]) +async def test_entry_already_configured_serial( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_panel: AsyncMock, + config_flow_data: dict[str, Any], +) -> None: + """Test if configuring an entity twice results in an error.""" + 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"], {CONF_HOST: "0.0.0.0"} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "auth" + assert result["errors"] == {} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], config_flow_data + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/bosch_alarm/test_init.py b/tests/components/bosch_alarm/test_init.py new file mode 100644 index 00000000000..0497a91eadf --- /dev/null +++ b/tests/components/bosch_alarm/test_init.py @@ -0,0 +1,33 @@ +"""Tests for bosch alarm integration init.""" + +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from . import setup_integration + +from tests.common import MockConfigEntry + + +@pytest.fixture(autouse=True) +def disable_platform_only(): + """Disable platforms to speed up tests.""" + with patch("homeassistant.components.bosch_alarm.PLATFORMS", []): + yield + + +@pytest.mark.parametrize("model", ["solution_3000"]) +@pytest.mark.parametrize("exception", [PermissionError(), TimeoutError()]) +async def test_incorrect_auth( + hass: HomeAssistant, + mock_panel: AsyncMock, + mock_config_entry: MockConfigEntry, + exception: Exception, +) -> None: + """Test errors with incorrect auth.""" + mock_panel.connect.side_effect = exception + await setup_integration(hass, mock_config_entry) + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY From 3aaf8599851eb38e5ef9f6a23e3be5d74267aa58 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Wed, 26 Mar 2025 13:58:23 +0100 Subject: [PATCH 3086/3148] Add state class MEASUREMENT_ANGLE to wind direction sensor (#141392) * Add state class MEASUREMENT_ANGLE to wind direction sensor * Update snapshots * Add some more --- .../components/ambient_network/sensor.py | 1 + .../components/ambient_station/sensor.py | 1 + homeassistant/components/arwn/sensor.py | 9 ++++++++- homeassistant/components/buienradar/sensor.py | 1 + homeassistant/components/ecowitt/sensor.py | 1 + .../components/environment_canada/sensor.py | 1 + homeassistant/components/homematic/sensor.py | 1 + homeassistant/components/lacrosse_view/sensor.py | 1 + homeassistant/components/meteoclimatic/sensor.py | 1 + homeassistant/components/mysensors/sensor.py | 1 + homeassistant/components/nws/sensor.py | 1 + homeassistant/components/weatherflow/sensor.py | 1 + .../ambient_network/snapshots/test_sensor.ambr | 15 ++++++++++++--- 13 files changed, 31 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/ambient_network/sensor.py b/homeassistant/components/ambient_network/sensor.py index 9ec6db6ff45..b96da9863a1 100644 --- a/homeassistant/components/ambient_network/sensor.py +++ b/homeassistant/components/ambient_network/sensor.py @@ -240,6 +240,7 @@ SENSOR_DESCRIPTIONS = ( suggested_display_precision=0, entity_registry_enabled_default=False, device_class=SensorDeviceClass.WIND_DIRECTION, + state_class=SensorStateClass.MEASUREMENT_ANGLE, ), SensorEntityDescription( key=TYPE_WINDGUSTMPH, diff --git a/homeassistant/components/ambient_station/sensor.py b/homeassistant/components/ambient_station/sensor.py index 730b798bd15..1b4334774d4 100644 --- a/homeassistant/components/ambient_station/sensor.py +++ b/homeassistant/components/ambient_station/sensor.py @@ -609,6 +609,7 @@ SENSOR_DESCRIPTIONS = ( translation_key="wind_direction", native_unit_of_measurement=DEGREE, device_class=SensorDeviceClass.WIND_DIRECTION, + state_class=SensorStateClass.MEASUREMENT_ANGLE, ), SensorEntityDescription( key=TYPE_WINDDIR_AVG10M, diff --git a/homeassistant/components/arwn/sensor.py b/homeassistant/components/arwn/sensor.py index a31156bbba6..4cc4feed2d4 100644 --- a/homeassistant/components/arwn/sensor.py +++ b/homeassistant/components/arwn/sensor.py @@ -6,7 +6,11 @@ import logging from typing import Any from homeassistant.components import mqtt -from homeassistant.components.sensor import SensorDeviceClass, SensorEntity +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorStateClass, +) from homeassistant.const import DEGREE, UnitOfPrecipitationDepth, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -98,6 +102,7 @@ def discover_sensors(topic: str, payload: dict[str, Any]) -> list[ArwnSensor] | DEGREE, "mdi:compass", device_class=SensorDeviceClass.WIND_DIRECTION, + state_class=SensorStateClass.MEASUREMENT_ANGLE, ), ] return None @@ -178,6 +183,7 @@ class ArwnSensor(SensorEntity): units: str, icon: str | None = None, device_class: SensorDeviceClass | None = None, + state_class: SensorStateClass | None = None, ) -> None: """Initialize the sensor.""" self.entity_id = _slug(name) @@ -188,6 +194,7 @@ class ArwnSensor(SensorEntity): self._attr_native_unit_of_measurement = units self._attr_icon = icon self._attr_device_class = device_class + self._attr_state_class = state_class def set_event(self, event: dict[str, Any]) -> None: """Update the sensor with the most recent event.""" diff --git a/homeassistant/components/buienradar/sensor.py b/homeassistant/components/buienradar/sensor.py index a4d39ea07cc..586543de129 100644 --- a/homeassistant/components/buienradar/sensor.py +++ b/homeassistant/components/buienradar/sensor.py @@ -170,6 +170,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( native_unit_of_measurement=DEGREE, icon="mdi:compass-outline", device_class=SensorDeviceClass.WIND_DIRECTION, + state_class=SensorStateClass.MEASUREMENT_ANGLE, ), SensorEntityDescription( key="pressure", diff --git a/homeassistant/components/ecowitt/sensor.py b/homeassistant/components/ecowitt/sensor.py index 6968acdfa4f..7d37aa40b86 100644 --- a/homeassistant/components/ecowitt/sensor.py +++ b/homeassistant/components/ecowitt/sensor.py @@ -68,6 +68,7 @@ ECOWITT_SENSORS_MAPPING: Final = { key="DEGREE", native_unit_of_measurement=DEGREE, device_class=SensorDeviceClass.WIND_DIRECTION, + state_class=SensorStateClass.MEASUREMENT_ANGLE, ), EcoWittSensorTypes.WATT_METERS_SQUARED: SensorEntityDescription( key="WATT_METERS_SQUARED", diff --git a/homeassistant/components/environment_canada/sensor.py b/homeassistant/components/environment_canada/sensor.py index 3a789289c74..1685888d2bc 100644 --- a/homeassistant/components/environment_canada/sensor.py +++ b/homeassistant/components/environment_canada/sensor.py @@ -168,6 +168,7 @@ SENSOR_TYPES: tuple[ECSensorEntityDescription, ...] = ( native_unit_of_measurement=DEGREE, value_fn=lambda data: data.conditions.get("wind_bearing", {}).get("value"), device_class=SensorDeviceClass.WIND_DIRECTION, + state_class=SensorStateClass.MEASUREMENT_ANGLE, ), ECSensorEntityDescription( key="wind_chill", diff --git a/homeassistant/components/homematic/sensor.py b/homeassistant/components/homematic/sensor.py index 24172e196c1..bdd446d7091 100644 --- a/homeassistant/components/homematic/sensor.py +++ b/homeassistant/components/homematic/sensor.py @@ -178,6 +178,7 @@ SENSOR_DESCRIPTIONS: dict[str, SensorEntityDescription] = { key="WIND_DIRECTION", native_unit_of_measurement=DEGREE, device_class=SensorDeviceClass.WIND_DIRECTION, + state_class=SensorStateClass.MEASUREMENT_ANGLE, ), "WIND_DIRECTION_RANGE": SensorEntityDescription( key="WIND_DIRECTION_RANGE", diff --git a/homeassistant/components/lacrosse_view/sensor.py b/homeassistant/components/lacrosse_view/sensor.py index 667fcbb8dcc..dde8dfd54a2 100644 --- a/homeassistant/components/lacrosse_view/sensor.py +++ b/homeassistant/components/lacrosse_view/sensor.py @@ -106,6 +106,7 @@ SENSOR_DESCRIPTIONS = { native_unit_of_measurement=DEGREE, suggested_display_precision=2, device_class=SensorDeviceClass.WIND_DIRECTION, + state_class=SensorStateClass.MEASUREMENT_ANGLE, ), "WetDry": LaCrosseSensorEntityDescription( key="WetDry", diff --git a/homeassistant/components/meteoclimatic/sensor.py b/homeassistant/components/meteoclimatic/sensor.py index 169da7a0a18..6e508bd63d8 100644 --- a/homeassistant/components/meteoclimatic/sensor.py +++ b/homeassistant/components/meteoclimatic/sensor.py @@ -102,6 +102,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( native_unit_of_measurement=DEGREE, icon="mdi:weather-windy", device_class=SensorDeviceClass.WIND_DIRECTION, + state_class=SensorStateClass.MEASUREMENT_ANGLE, ), SensorEntityDescription( key="rain", diff --git a/homeassistant/components/mysensors/sensor.py b/homeassistant/components/mysensors/sensor.py index 3a7101e6b39..3793bed8af2 100644 --- a/homeassistant/components/mysensors/sensor.py +++ b/homeassistant/components/mysensors/sensor.py @@ -102,6 +102,7 @@ SENSORS: dict[str, SensorEntityDescription] = { native_unit_of_measurement=DEGREE, icon="mdi:compass", device_class=SensorDeviceClass.WIND_DIRECTION, + state_class=SensorStateClass.MEASUREMENT_ANGLE, ), "V_WEIGHT": SensorEntityDescription( key="V_WEIGHT", diff --git a/homeassistant/components/nws/sensor.py b/homeassistant/components/nws/sensor.py index 4cfb3b85e0f..8a7631d8381 100644 --- a/homeassistant/components/nws/sensor.py +++ b/homeassistant/components/nws/sensor.py @@ -115,6 +115,7 @@ SENSOR_TYPES: tuple[NWSSensorEntityDescription, ...] = ( native_unit_of_measurement=DEGREE, unit_convert=DEGREE, device_class=SensorDeviceClass.WIND_DIRECTION, + state_class=SensorStateClass.MEASUREMENT_ANGLE, ), NWSSensorEntityDescription( key="barometricPressure", diff --git a/homeassistant/components/weatherflow/sensor.py b/homeassistant/components/weatherflow/sensor.py index 8eee472fe5c..10c04b3283b 100644 --- a/homeassistant/components/weatherflow/sensor.py +++ b/homeassistant/components/weatherflow/sensor.py @@ -268,6 +268,7 @@ SENSORS: tuple[WeatherFlowSensorEntityDescription, ...] = ( key="wind_direction", translation_key="wind_direction", device_class=SensorDeviceClass.WIND_DIRECTION, + state_class=SensorStateClass.MEASUREMENT_ANGLE, native_unit_of_measurement=DEGREE, event_subscriptions=[EVENT_RAPID_WIND, EVENT_OBSERVATION], raw_data_conv_fn=lambda raw_data: raw_data.magnitude, diff --git a/tests/components/ambient_network/snapshots/test_sensor.ambr b/tests/components/ambient_network/snapshots/test_sensor.ambr index 8637471cc60..ddf05c99b88 100644 --- a/tests/components/ambient_network/snapshots/test_sensor.ambr +++ b/tests/components/ambient_network/snapshots/test_sensor.ambr @@ -815,7 +815,9 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'state_class': , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -854,6 +856,7 @@ 'device_class': 'wind_direction', 'friendly_name': 'Station A Wind direction', 'last_measured': HAFakeDatetime(2023, 11, 8, 12, 12, 0, 914000, tzinfo=zoneinfo.ZoneInfo(key='US/Pacific')), + 'state_class': , 'unit_of_measurement': '°', }), 'context': , @@ -1800,7 +1803,9 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'state_class': , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -1839,6 +1844,7 @@ 'device_class': 'wind_direction', 'friendly_name': 'Station C Wind direction', 'last_measured': HAFakeDatetime(2024, 6, 6, 8, 28, 3, tzinfo=zoneinfo.ZoneInfo(key='US/Pacific')), + 'state_class': , 'unit_of_measurement': '°', }), 'context': , @@ -2722,7 +2728,9 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'state_class': , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -2760,6 +2768,7 @@ 'attribution': 'Data provided by ambientnetwork.net', 'device_class': 'wind_direction', 'friendly_name': 'Station D Wind direction', + 'state_class': , 'unit_of_measurement': '°', }), 'context': , From 3eda5333b0c3e7f1ceec611e2695c711d82a1947 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Wed, 26 Mar 2025 08:06:51 -0500 Subject: [PATCH 3087/3148] Add info websocket command to wyoming integration (#139982) * Add info websocket command to wyoming integration * Add snapshot * Add config schema * Remove snapshots because of changing config entry ids --- homeassistant/components/wyoming/__init__.py | 14 ++++- .../components/wyoming/websocket_api.py | 42 ++++++++++++++ tests/components/wyoming/conftest.py | 16 ++++- tests/components/wyoming/test_websocket.py | 58 +++++++++++++++++++ 4 files changed, 126 insertions(+), 4 deletions(-) create mode 100644 homeassistant/components/wyoming/websocket_api.py create mode 100644 tests/components/wyoming/test_websocket.py diff --git a/homeassistant/components/wyoming/__init__.py b/homeassistant/components/wyoming/__init__.py index d639933ece6..4e76287d8e7 100644 --- a/homeassistant/components/wyoming/__init__.py +++ b/homeassistant/components/wyoming/__init__.py @@ -8,15 +8,19 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import config_validation as cv, device_registry as dr +from homeassistant.helpers.typing import ConfigType from .const import ATTR_SPEAKER, DOMAIN from .data import WyomingService from .devices import SatelliteDevice from .models import DomainDataItem +from .websocket_api import async_register_websocket_api _LOGGER = logging.getLogger(__name__) +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + SATELLITE_PLATFORMS = [ Platform.ASSIST_SATELLITE, Platform.BINARY_SENSOR, @@ -28,11 +32,19 @@ SATELLITE_PLATFORMS = [ __all__ = [ "ATTR_SPEAKER", "DOMAIN", + "async_setup", "async_setup_entry", "async_unload_entry", ] +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the Wyoming integration.""" + async_register_websocket_api(hass) + + return True + + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Load Wyoming.""" service = await WyomingService.create(entry.data["host"], entry.data["port"]) diff --git a/homeassistant/components/wyoming/websocket_api.py b/homeassistant/components/wyoming/websocket_api.py new file mode 100644 index 00000000000..613238c302a --- /dev/null +++ b/homeassistant/components/wyoming/websocket_api.py @@ -0,0 +1,42 @@ +"""Wyoming Websocket API.""" + +import logging +from typing import Any + +import voluptuous as vol + +from homeassistant.components import websocket_api +from homeassistant.core import HomeAssistant, callback + +from .const import DOMAIN +from .models import DomainDataItem + +_LOGGER = logging.getLogger(__name__) + + +@callback +def async_register_websocket_api(hass: HomeAssistant) -> None: + """Register the websocket API.""" + websocket_api.async_register_command(hass, websocket_info) + + +@callback +@websocket_api.require_admin +@websocket_api.websocket_command({vol.Required("type"): "wyoming/info"}) +def websocket_info( + hass: HomeAssistant, + connection: websocket_api.connection.ActiveConnection, + msg: dict[str, Any], +) -> None: + """List service information for Wyoming all config entries.""" + entry_items: dict[str, DomainDataItem] = hass.data.get(DOMAIN, {}) + + connection.send_result( + msg["id"], + { + "info": { + entry_id: item.service.info.to_dict() + for entry_id, item in entry_items.items() + } + }, + ) diff --git a/tests/components/wyoming/conftest.py b/tests/components/wyoming/conftest.py index 018fff33821..125edc547c6 100644 --- a/tests/components/wyoming/conftest.py +++ b/tests/components/wyoming/conftest.py @@ -121,7 +121,9 @@ def handle_config_entry(hass: HomeAssistant) -> ConfigEntry: @pytest.fixture -async def init_wyoming_stt(hass: HomeAssistant, stt_config_entry: ConfigEntry): +async def init_wyoming_stt( + hass: HomeAssistant, stt_config_entry: ConfigEntry +) -> ConfigEntry: """Initialize Wyoming STT.""" with patch( "homeassistant.components.wyoming.data.load_wyoming_info", @@ -129,9 +131,13 @@ async def init_wyoming_stt(hass: HomeAssistant, stt_config_entry: ConfigEntry): ): await hass.config_entries.async_setup(stt_config_entry.entry_id) + return stt_config_entry + @pytest.fixture -async def init_wyoming_tts(hass: HomeAssistant, tts_config_entry: ConfigEntry): +async def init_wyoming_tts( + hass: HomeAssistant, tts_config_entry: ConfigEntry +) -> ConfigEntry: """Initialize Wyoming TTS.""" with patch( "homeassistant.components.wyoming.data.load_wyoming_info", @@ -139,11 +145,13 @@ async def init_wyoming_tts(hass: HomeAssistant, tts_config_entry: ConfigEntry): ): await hass.config_entries.async_setup(tts_config_entry.entry_id) + return tts_config_entry + @pytest.fixture async def init_wyoming_wake_word( hass: HomeAssistant, wake_word_config_entry: ConfigEntry -): +) -> ConfigEntry: """Initialize Wyoming Wake Word.""" with patch( "homeassistant.components.wyoming.data.load_wyoming_info", @@ -151,6 +159,8 @@ async def init_wyoming_wake_word( ): await hass.config_entries.async_setup(wake_word_config_entry.entry_id) + return wake_word_config_entry + @pytest.fixture async def init_wyoming_intent( diff --git a/tests/components/wyoming/test_websocket.py b/tests/components/wyoming/test_websocket.py new file mode 100644 index 00000000000..18b43321354 --- /dev/null +++ b/tests/components/wyoming/test_websocket.py @@ -0,0 +1,58 @@ +"""Websocket tests for Wyoming integration.""" + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from tests.typing import WebSocketGenerator + + +async def test_info( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + init_components, + init_wyoming_stt: ConfigEntry, + init_wyoming_tts: ConfigEntry, + init_wyoming_wake_word: ConfigEntry, + init_wyoming_intent: ConfigEntry, + init_wyoming_handle: ConfigEntry, +) -> None: + """Test info websocket command.""" + client = await hass_ws_client(hass) + + await client.send_json_auto_id({"type": "wyoming/info"}) + + # result + msg = await client.receive_json() + assert msg["success"] + + info = msg.get("result", {}).get("info", {}) + + # stt (speech-to-text) = asr (automated speech recognition) + assert init_wyoming_stt.entry_id in info + asr_info = info[init_wyoming_stt.entry_id].get("asr", []) + assert len(asr_info) == 1 + assert asr_info[0].get("name") == "Test ASR" + + # tts (text-to-speech) + assert init_wyoming_tts.entry_id in info + tts_info = info[init_wyoming_tts.entry_id].get("tts", []) + assert len(tts_info) == 1 + assert tts_info[0].get("name") == "Test TTS" + + # wake word detection + assert init_wyoming_wake_word.entry_id in info + wake_info = info[init_wyoming_wake_word.entry_id].get("wake", []) + assert len(wake_info) == 1 + assert wake_info[0].get("name") == "Test Wake Word" + + # intent recognition + assert init_wyoming_intent.entry_id in info + intent_info = info[init_wyoming_intent.entry_id].get("intent", []) + assert len(intent_info) == 1 + assert intent_info[0].get("name") == "Test Intent" + + # intent handling + assert init_wyoming_handle.entry_id in info + handle_info = info[init_wyoming_handle.entry_id].get("handle", []) + assert len(handle_info) == 1 + assert handle_info[0].get("name") == "Test Handle" From 8db91623ec4233d1a63b6fef9ad16b64403615de Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Wed, 26 Mar 2025 08:07:15 -0500 Subject: [PATCH 3088/3148] Add language scores websocket command (#140480) * Add language scores websocket command * Don't store language scores in snapshot * Add language/country args for preferred lang * Bump intents to 2025.3.24 for dash lang code --- homeassistant/components/conversation/http.py | 33 +++++++++++ .../components/conversation/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- script/hassfest/docker/Dockerfile | 2 +- tests/components/conversation/test_http.py | 57 +++++++++++++++++++ 7 files changed, 95 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/conversation/http.py b/homeassistant/components/conversation/http.py index 4d8526a4fd4..efcdcb8d69b 100644 --- a/homeassistant/components/conversation/http.py +++ b/homeassistant/components/conversation/http.py @@ -3,11 +3,13 @@ from __future__ import annotations from collections.abc import Iterable +from dataclasses import asdict from typing import Any from aiohttp import web from hassil.recognize import MISSING_ENTITY, RecognizeResult from hassil.string_matcher import UnmatchedRangeEntity, UnmatchedTextEntity +from home_assistant_intents import get_language_scores import voluptuous as vol from homeassistant.components import http, websocket_api @@ -38,6 +40,7 @@ def async_setup(hass: HomeAssistant) -> None: websocket_api.async_register_command(hass, websocket_list_agents) websocket_api.async_register_command(hass, websocket_list_sentences) websocket_api.async_register_command(hass, websocket_hass_agent_debug) + websocket_api.async_register_command(hass, websocket_hass_agent_language_scores) @websocket_api.websocket_command( @@ -336,6 +339,36 @@ def _get_unmatched_slots( return unmatched_slots +@websocket_api.websocket_command( + { + vol.Required("type"): "conversation/agent/homeassistant/language_scores", + vol.Optional("language"): str, + vol.Optional("country"): str, + } +) +@websocket_api.async_response +async def websocket_hass_agent_language_scores( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Get support scores per language.""" + language = msg.get("language", hass.config.language) + country = msg.get("country", hass.config.country) + + scores = await hass.async_add_executor_job(get_language_scores) + matching_langs = language_util.matches(language, scores.keys(), country=country) + preferred_lang = matching_langs[0] if matching_langs else language + result = { + "languages": { + lang_key: asdict(lang_scores) for lang_key, lang_scores in scores.items() + }, + "preferred_language": preferred_lang, + } + + connection.send_result(msg["id"], result) + + class ConversationProcessView(http.HomeAssistantView): """View to process text.""" diff --git a/homeassistant/components/conversation/manifest.json b/homeassistant/components/conversation/manifest.json index 56d5e28e642..acaa2ef0967 100644 --- a/homeassistant/components/conversation/manifest.json +++ b/homeassistant/components/conversation/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/conversation", "integration_type": "system", "quality_scale": "internal", - "requirements": ["hassil==2.2.3", "home-assistant-intents==2025.3.23"] + "requirements": ["hassil==2.2.3", "home-assistant-intents==2025.3.24"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index ac47f900f15..d340183bc94 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -39,7 +39,7 @@ hass-nabucasa==0.94.0 hassil==2.2.3 home-assistant-bluetooth==1.13.1 home-assistant-frontend==20250306.0 -home-assistant-intents==2025.3.23 +home-assistant-intents==2025.3.24 httpx==0.28.1 ifaddr==0.2.0 Jinja2==3.1.6 diff --git a/requirements_all.txt b/requirements_all.txt index 50557f638cb..c74bab50d51 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1160,7 +1160,7 @@ holidays==0.69 home-assistant-frontend==20250306.0 # homeassistant.components.conversation -home-assistant-intents==2025.3.23 +home-assistant-intents==2025.3.24 # homeassistant.components.homematicip_cloud homematicip==1.1.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6b5c71037f3..59aca552c40 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -987,7 +987,7 @@ holidays==0.69 home-assistant-frontend==20250306.0 # homeassistant.components.conversation -home-assistant-intents==2025.3.23 +home-assistant-intents==2025.3.24 # homeassistant.components.homematicip_cloud homematicip==1.1.7 diff --git a/script/hassfest/docker/Dockerfile b/script/hassfest/docker/Dockerfile index 6101a90d4c0..21e97ac097b 100644 --- a/script/hassfest/docker/Dockerfile +++ b/script/hassfest/docker/Dockerfile @@ -25,7 +25,7 @@ RUN --mount=from=ghcr.io/astral-sh/uv:0.6.10,source=/uv,target=/bin/uv \ -c /usr/src/homeassistant/homeassistant/package_constraints.txt \ -r /usr/src/homeassistant/requirements.txt \ stdlib-list==0.10.0 pipdeptree==2.25.1 tqdm==4.67.1 ruff==0.11.0 \ - PyTurboJPEG==1.7.5 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 hassil==2.2.3 home-assistant-intents==2025.3.23 mutagen==1.47.0 pymicro-vad==1.0.1 pyspeex-noise==1.0.2 + PyTurboJPEG==1.7.5 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 hassil==2.2.3 home-assistant-intents==2025.3.24 mutagen==1.47.0 pymicro-vad==1.0.1 pyspeex-noise==1.0.2 LABEL "name"="hassfest" LABEL "maintainer"="Home Assistant " diff --git a/tests/components/conversation/test_http.py b/tests/components/conversation/test_http.py index 6d69ec3c739..77fa97ad845 100644 --- a/tests/components/conversation/test_http.py +++ b/tests/components/conversation/test_http.py @@ -536,3 +536,60 @@ async def test_ws_hass_agent_debug_sentence_trigger( # Trigger should not have been executed assert len(calls) == 0 + + +async def test_ws_hass_language_scores( + hass: HomeAssistant, init_components, hass_ws_client: WebSocketGenerator +) -> None: + """Test getting language support scores.""" + client = await hass_ws_client(hass) + + await client.send_json_auto_id( + {"type": "conversation/agent/homeassistant/language_scores"} + ) + + msg = await client.receive_json() + assert msg["success"] + + # Sanity check + result = msg["result"] + assert result["languages"]["en-US"] == { + "cloud": 3, + "focused_local": 2, + "full_local": 3, + } + + +async def test_ws_hass_language_scores_with_filter( + hass: HomeAssistant, init_components, hass_ws_client: WebSocketGenerator +) -> None: + """Test getting language support scores with language/country filter.""" + client = await hass_ws_client(hass) + + # Language filter + await client.send_json_auto_id( + {"type": "conversation/agent/homeassistant/language_scores", "language": "de"} + ) + + msg = await client.receive_json() + assert msg["success"] + + # German should be preferred + result = msg["result"] + assert result["preferred_language"] == "de-DE" + + # Language/country filter + await client.send_json_auto_id( + { + "type": "conversation/agent/homeassistant/language_scores", + "language": "en", + "country": "GB", + } + ) + + msg = await client.receive_json() + assert msg["success"] + + # GB English should be preferred + result = msg["result"] + assert result["preferred_language"] == "en-GB" From c9742854901045cdd884e95b04ab2c675b3ce17f Mon Sep 17 00:00:00 2001 From: Denis Shulyaka Date: Wed, 26 Mar 2025 16:36:05 +0300 Subject: [PATCH 3089/3148] Add Web search to OpenAI Conversation integration (#141426) * Add Web search to OpenAI Conversation integration * Limit search for gpt-4o models * Add more tests --- .../openai_conversation/config_flow.py | 116 +++++++++++++++- .../components/openai_conversation/const.py | 10 ++ .../openai_conversation/conversation.py | 29 ++++ .../openai_conversation/strings.json | 20 ++- .../openai_conversation/test_config_flow.py | 128 +++++++++++++++++- .../openai_conversation/test_conversation.py | 108 ++++++++++++++- 6 files changed, 397 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/openai_conversation/config_flow.py b/homeassistant/components/openai_conversation/config_flow.py index c631884ea0b..7304eb52da3 100644 --- a/homeassistant/components/openai_conversation/config_flow.py +++ b/homeassistant/components/openai_conversation/config_flow.py @@ -2,22 +2,31 @@ from __future__ import annotations +import json import logging from types import MappingProxyType from typing import Any import openai import voluptuous as vol +from voluptuous_openapi import convert +from homeassistant.components.zone import ENTITY_ID_HOME from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, OptionsFlow, ) -from homeassistant.const import CONF_API_KEY, CONF_LLM_HASS_API +from homeassistant.const import ( + ATTR_LATITUDE, + ATTR_LONGITUDE, + CONF_API_KEY, + CONF_LLM_HASS_API, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import llm +from homeassistant.helpers.httpx_client import get_async_client from homeassistant.helpers.selector import ( NumberSelector, NumberSelectorConfig, @@ -37,12 +46,22 @@ from .const import ( CONF_RECOMMENDED, CONF_TEMPERATURE, CONF_TOP_P, + CONF_WEB_SEARCH, + CONF_WEB_SEARCH_CITY, + CONF_WEB_SEARCH_CONTEXT_SIZE, + CONF_WEB_SEARCH_COUNTRY, + CONF_WEB_SEARCH_REGION, + CONF_WEB_SEARCH_TIMEZONE, + CONF_WEB_SEARCH_USER_LOCATION, DOMAIN, RECOMMENDED_CHAT_MODEL, RECOMMENDED_MAX_TOKENS, RECOMMENDED_REASONING_EFFORT, RECOMMENDED_TEMPERATURE, RECOMMENDED_TOP_P, + RECOMMENDED_WEB_SEARCH, + RECOMMENDED_WEB_SEARCH_CONTEXT_SIZE, + RECOMMENDED_WEB_SEARCH_USER_LOCATION, UNSUPPORTED_MODELS, ) @@ -66,7 +85,9 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None: Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. """ - client = openai.AsyncOpenAI(api_key=data[CONF_API_KEY]) + client = openai.AsyncOpenAI( + api_key=data[CONF_API_KEY], http_client=get_async_client(hass) + ) await hass.async_add_executor_job(client.with_options(timeout=10.0).models.list) @@ -137,7 +158,16 @@ class OpenAIOptionsFlow(OptionsFlow): if user_input.get(CONF_CHAT_MODEL) in UNSUPPORTED_MODELS: errors[CONF_CHAT_MODEL] = "model_not_supported" - else: + + if user_input.get(CONF_WEB_SEARCH): + if not user_input.get( + CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL + ).startswith("gpt-4o"): + errors[CONF_WEB_SEARCH] = "web_search_not_supported" + elif user_input.get(CONF_WEB_SEARCH_USER_LOCATION): + user_input.update(await self.get_location_data()) + + if not errors: return self.async_create_entry(title="", data=user_input) else: # Re-render the options again, now with the recommended options shown/hidden @@ -156,6 +186,59 @@ class OpenAIOptionsFlow(OptionsFlow): errors=errors, ) + async def get_location_data(self) -> dict[str, str]: + """Get approximate location data of the user.""" + location_data: dict[str, str] = {} + zone_home = self.hass.states.get(ENTITY_ID_HOME) + if zone_home is not None: + client = openai.AsyncOpenAI( + api_key=self.config_entry.data[CONF_API_KEY], + http_client=get_async_client(self.hass), + ) + location_schema = vol.Schema( + { + vol.Optional( + CONF_WEB_SEARCH_CITY, + description="Free text input for the city, e.g. `San Francisco`", + ): str, + vol.Optional( + CONF_WEB_SEARCH_REGION, + description="Free text input for the region, e.g. `California`", + ): str, + } + ) + response = await client.responses.create( + model=RECOMMENDED_CHAT_MODEL, + input=[ + { + "role": "system", + "content": "Where are the following coordinates located: " + f"({zone_home.attributes[ATTR_LATITUDE]}," + f" {zone_home.attributes[ATTR_LONGITUDE]})?", + } + ], + text={ + "format": { + "type": "json_schema", + "name": "approximate_location", + "description": "Approximate location data of the user " + "for refined web search results", + "schema": convert(location_schema), + "strict": False, + } + }, + store=False, + ) + location_data = location_schema(json.loads(response.output_text) or {}) + + if self.hass.config.country: + location_data[CONF_WEB_SEARCH_COUNTRY] = self.hass.config.country + location_data[CONF_WEB_SEARCH_TIMEZONE] = self.hass.config.time_zone + + _LOGGER.debug("Location data: %s", location_data) + + return location_data + def openai_config_option_schema( hass: HomeAssistant, @@ -227,10 +310,35 @@ def openai_config_option_schema( ): SelectSelector( SelectSelectorConfig( options=["low", "medium", "high"], - translation_key="reasoning_effort", + translation_key=CONF_REASONING_EFFORT, mode=SelectSelectorMode.DROPDOWN, ) ), + vol.Optional( + CONF_WEB_SEARCH, + description={"suggested_value": options.get(CONF_WEB_SEARCH)}, + default=RECOMMENDED_WEB_SEARCH, + ): bool, + vol.Optional( + CONF_WEB_SEARCH_CONTEXT_SIZE, + description={ + "suggested_value": options.get(CONF_WEB_SEARCH_CONTEXT_SIZE) + }, + default=RECOMMENDED_WEB_SEARCH_CONTEXT_SIZE, + ): SelectSelector( + SelectSelectorConfig( + options=["low", "medium", "high"], + translation_key=CONF_WEB_SEARCH_CONTEXT_SIZE, + mode=SelectSelectorMode.DROPDOWN, + ) + ), + vol.Optional( + CONF_WEB_SEARCH_USER_LOCATION, + description={ + "suggested_value": options.get(CONF_WEB_SEARCH_USER_LOCATION) + }, + default=RECOMMENDED_WEB_SEARCH_USER_LOCATION, + ): bool, } ) return schema diff --git a/homeassistant/components/openai_conversation/const.py b/homeassistant/components/openai_conversation/const.py index c9987cb81b9..41abc504219 100644 --- a/homeassistant/components/openai_conversation/const.py +++ b/homeassistant/components/openai_conversation/const.py @@ -14,11 +14,21 @@ CONF_REASONING_EFFORT = "reasoning_effort" CONF_RECOMMENDED = "recommended" CONF_TEMPERATURE = "temperature" CONF_TOP_P = "top_p" +CONF_WEB_SEARCH = "web_search" +CONF_WEB_SEARCH_USER_LOCATION = "user_location" +CONF_WEB_SEARCH_CONTEXT_SIZE = "search_context_size" +CONF_WEB_SEARCH_CITY = "city" +CONF_WEB_SEARCH_REGION = "region" +CONF_WEB_SEARCH_COUNTRY = "country" +CONF_WEB_SEARCH_TIMEZONE = "timezone" RECOMMENDED_CHAT_MODEL = "gpt-4o-mini" RECOMMENDED_MAX_TOKENS = 150 RECOMMENDED_REASONING_EFFORT = "low" RECOMMENDED_TEMPERATURE = 1.0 RECOMMENDED_TOP_P = 1.0 +RECOMMENDED_WEB_SEARCH = False +RECOMMENDED_WEB_SEARCH_CONTEXT_SIZE = "medium" +RECOMMENDED_WEB_SEARCH_USER_LOCATION = False UNSUPPORTED_MODELS: list[str] = [ "o1-mini", diff --git a/homeassistant/components/openai_conversation/conversation.py b/homeassistant/components/openai_conversation/conversation.py index 873406a3999..026e18f3ce1 100644 --- a/homeassistant/components/openai_conversation/conversation.py +++ b/homeassistant/components/openai_conversation/conversation.py @@ -23,8 +23,10 @@ from openai.types.responses import ( ResponseStreamEvent, ResponseTextDeltaEvent, ToolParam, + WebSearchToolParam, ) from openai.types.responses.response_input_param import FunctionCallOutput +from openai.types.responses.web_search_tool_param import UserLocation from voluptuous_openapi import convert from homeassistant.components import assist_pipeline, conversation @@ -43,6 +45,13 @@ from .const import ( CONF_REASONING_EFFORT, CONF_TEMPERATURE, CONF_TOP_P, + CONF_WEB_SEARCH, + CONF_WEB_SEARCH_CITY, + CONF_WEB_SEARCH_CONTEXT_SIZE, + CONF_WEB_SEARCH_COUNTRY, + CONF_WEB_SEARCH_REGION, + CONF_WEB_SEARCH_TIMEZONE, + CONF_WEB_SEARCH_USER_LOCATION, DOMAIN, LOGGER, RECOMMENDED_CHAT_MODEL, @@ -50,6 +59,7 @@ from .const import ( RECOMMENDED_REASONING_EFFORT, RECOMMENDED_TEMPERATURE, RECOMMENDED_TOP_P, + RECOMMENDED_WEB_SEARCH_CONTEXT_SIZE, ) # Max number of back and forth with the LLM to generate a response @@ -265,6 +275,25 @@ class OpenAIConversationEntity( for tool in chat_log.llm_api.tools ] + if options.get(CONF_WEB_SEARCH): + web_search = WebSearchToolParam( + type="web_search_preview", + search_context_size=options.get( + CONF_WEB_SEARCH_CONTEXT_SIZE, RECOMMENDED_WEB_SEARCH_CONTEXT_SIZE + ), + ) + if options.get(CONF_WEB_SEARCH_USER_LOCATION): + web_search["user_location"] = UserLocation( + type="approximate", + city=options.get(CONF_WEB_SEARCH_CITY, ""), + region=options.get(CONF_WEB_SEARCH_REGION, ""), + country=options.get(CONF_WEB_SEARCH_COUNTRY, ""), + timezone=options.get(CONF_WEB_SEARCH_TIMEZONE, ""), + ) + if tools is None: + tools = [] + tools.append(web_search) + model = options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL) messages = [ m diff --git a/homeassistant/components/openai_conversation/strings.json b/homeassistant/components/openai_conversation/strings.json index c9d7ee112bd..a373ec448d7 100644 --- a/homeassistant/components/openai_conversation/strings.json +++ b/homeassistant/components/openai_conversation/strings.json @@ -24,16 +24,23 @@ "top_p": "Top P", "llm_hass_api": "[%key:common::config_flow::data::llm_hass_api%]", "recommended": "Recommended model settings", - "reasoning_effort": "Reasoning effort" + "reasoning_effort": "Reasoning effort", + "web_search": "Enable web search", + "search_context_size": "Search context size", + "user_location": "Include home location" }, "data_description": { "prompt": "Instruct how the LLM should respond. This can be a template.", - "reasoning_effort": "How many reasoning tokens the model should generate before creating a response to the prompt (for certain reasoning models)" + "reasoning_effort": "How many reasoning tokens the model should generate before creating a response to the prompt (for certain reasoning models)", + "web_search": "Allow the model to search the web for the latest information before generating a response", + "search_context_size": "High level guidance for the amount of context window space to use for the search", + "user_location": "Refine search results based on geography" } } }, "error": { - "model_not_supported": "This model is not supported, please select a different model" + "model_not_supported": "This model is not supported, please select a different model", + "web_search_not_supported": "Web search is only supported for gpt-4o and gpt-4o-mini models" } }, "selector": { @@ -43,6 +50,13 @@ "medium": "Medium", "high": "High" } + }, + "search_context_size": { + "options": { + "low": "Low", + "medium": "Medium", + "high": "High" + } } }, "services": { diff --git a/tests/components/openai_conversation/test_config_flow.py b/tests/components/openai_conversation/test_config_flow.py index 90a08471f39..17a5aad6478 100644 --- a/tests/components/openai_conversation/test_config_flow.py +++ b/tests/components/openai_conversation/test_config_flow.py @@ -1,9 +1,10 @@ """Test the OpenAI Conversation config flow.""" -from unittest.mock import patch +from unittest.mock import AsyncMock, patch -from httpx import Response +import httpx from openai import APIConnectionError, AuthenticationError, BadRequestError +from openai.types.responses import Response, ResponseOutputMessage, ResponseOutputText import pytest from homeassistant import config_entries @@ -16,6 +17,13 @@ from homeassistant.components.openai_conversation.const import ( CONF_RECOMMENDED, CONF_TEMPERATURE, CONF_TOP_P, + CONF_WEB_SEARCH, + CONF_WEB_SEARCH_CITY, + CONF_WEB_SEARCH_CONTEXT_SIZE, + CONF_WEB_SEARCH_COUNTRY, + CONF_WEB_SEARCH_REGION, + CONF_WEB_SEARCH_TIMEZONE, + CONF_WEB_SEARCH_USER_LOCATION, DOMAIN, RECOMMENDED_CHAT_MODEL, RECOMMENDED_MAX_TOKENS, @@ -117,13 +125,17 @@ async def test_options_unsupported_model( (APIConnectionError(request=None), "cannot_connect"), ( AuthenticationError( - response=Response(status_code=None, request=""), body=None, message=None + response=httpx.Response(status_code=None, request=""), + body=None, + message=None, ), "invalid_auth", ), ( BadRequestError( - response=Response(status_code=None, request=""), body=None, message=None + response=httpx.Response(status_code=None, request=""), + body=None, + message=None, ), "unknown", ), @@ -172,6 +184,9 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non CONF_TOP_P: RECOMMENDED_TOP_P, CONF_MAX_TOKENS: RECOMMENDED_MAX_TOKENS, CONF_REASONING_EFFORT: RECOMMENDED_REASONING_EFFORT, + CONF_WEB_SEARCH: False, + CONF_WEB_SEARCH_CONTEXT_SIZE: "medium", + CONF_WEB_SEARCH_USER_LOCATION: False, }, ), ( @@ -183,6 +198,9 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non CONF_TOP_P: RECOMMENDED_TOP_P, CONF_MAX_TOKENS: RECOMMENDED_MAX_TOKENS, CONF_REASONING_EFFORT: RECOMMENDED_REASONING_EFFORT, + CONF_WEB_SEARCH: False, + CONF_WEB_SEARCH_CONTEXT_SIZE: "medium", + CONF_WEB_SEARCH_USER_LOCATION: False, }, { CONF_RECOMMENDED: True, @@ -225,3 +243,105 @@ async def test_options_switching( await hass.async_block_till_done() assert options["type"] is FlowResultType.CREATE_ENTRY assert options["data"] == expected_options + + +async def test_options_web_search_user_location( + hass: HomeAssistant, mock_config_entry, mock_init_component +) -> None: + """Test fetching user location.""" + options_flow = await hass.config_entries.options.async_init( + mock_config_entry.entry_id + ) + hass.config.country = "US" + hass.config.time_zone = "America/Los_Angeles" + hass.states.async_set( + "zone.home", "0", {"latitude": 37.7749, "longitude": -122.4194} + ) + with patch( + "openai.resources.responses.AsyncResponses.create", + new_callable=AsyncMock, + ) as mock_create: + mock_create.return_value = Response( + object="response", + id="resp_A", + created_at=1700000000, + model="gpt-4o-mini", + parallel_tool_calls=True, + tool_choice="auto", + tools=[], + output=[ + ResponseOutputMessage( + type="message", + id="msg_A", + content=[ + ResponseOutputText( + type="output_text", + text='{"city": "San Francisco", "region": "California"}', + annotations=[], + ) + ], + role="assistant", + status="completed", + ) + ], + ) + + options = await hass.config_entries.options.async_configure( + options_flow["flow_id"], + { + CONF_RECOMMENDED: False, + CONF_PROMPT: "Speak like a pirate", + CONF_TEMPERATURE: 1.0, + CONF_CHAT_MODEL: RECOMMENDED_CHAT_MODEL, + CONF_TOP_P: RECOMMENDED_TOP_P, + CONF_MAX_TOKENS: RECOMMENDED_MAX_TOKENS, + CONF_REASONING_EFFORT: RECOMMENDED_REASONING_EFFORT, + CONF_WEB_SEARCH: True, + CONF_WEB_SEARCH_CONTEXT_SIZE: "medium", + CONF_WEB_SEARCH_USER_LOCATION: True, + }, + ) + await hass.async_block_till_done() + assert ( + mock_create.call_args.kwargs["input"][0]["content"] == "Where are the following" + " coordinates located: (37.7749, -122.4194)?" + ) + assert options["type"] is FlowResultType.CREATE_ENTRY + assert options["data"] == { + CONF_RECOMMENDED: False, + CONF_PROMPT: "Speak like a pirate", + CONF_TEMPERATURE: 1.0, + CONF_CHAT_MODEL: RECOMMENDED_CHAT_MODEL, + CONF_TOP_P: RECOMMENDED_TOP_P, + CONF_MAX_TOKENS: RECOMMENDED_MAX_TOKENS, + CONF_REASONING_EFFORT: RECOMMENDED_REASONING_EFFORT, + CONF_WEB_SEARCH: True, + CONF_WEB_SEARCH_CONTEXT_SIZE: "medium", + CONF_WEB_SEARCH_USER_LOCATION: True, + CONF_WEB_SEARCH_CITY: "San Francisco", + CONF_WEB_SEARCH_REGION: "California", + CONF_WEB_SEARCH_COUNTRY: "US", + CONF_WEB_SEARCH_TIMEZONE: "America/Los_Angeles", + } + + +async def test_options_web_search_unsupported_model( + hass: HomeAssistant, mock_config_entry, mock_init_component +) -> None: + """Test the options form giving error about web search not being available.""" + options_flow = await hass.config_entries.options.async_init( + mock_config_entry.entry_id + ) + result = await hass.config_entries.options.async_configure( + options_flow["flow_id"], + { + CONF_RECOMMENDED: False, + CONF_PROMPT: "Speak like a pirate", + CONF_CHAT_MODEL: "o1-pro", + CONF_LLM_HASS_API: "assist", + CONF_WEB_SEARCH: True, + }, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"web_search": "web_search_not_supported"} diff --git a/tests/components/openai_conversation/test_conversation.py b/tests/components/openai_conversation/test_conversation.py index fb54c423234..d6f09e0f30e 100644 --- a/tests/components/openai_conversation/test_conversation.py +++ b/tests/components/openai_conversation/test_conversation.py @@ -18,6 +18,7 @@ from openai.types.responses import ( ResponseFunctionCallArgumentsDeltaEvent, ResponseFunctionCallArgumentsDoneEvent, ResponseFunctionToolCall, + ResponseFunctionWebSearch, ResponseIncompleteEvent, ResponseInProgressEvent, ResponseOutputItemAddedEvent, @@ -29,6 +30,9 @@ from openai.types.responses import ( ResponseTextConfig, ResponseTextDeltaEvent, ResponseTextDoneEvent, + ResponseWebSearchCallCompletedEvent, + ResponseWebSearchCallInProgressEvent, + ResponseWebSearchCallSearchingEvent, ) from openai.types.responses.response import IncompleteDetails import pytest @@ -36,6 +40,15 @@ from syrupy.assertion import SnapshotAssertion from homeassistant.components import conversation from homeassistant.components.homeassistant.exposed_entities import async_expose_entity +from homeassistant.components.openai_conversation.const import ( + CONF_WEB_SEARCH, + CONF_WEB_SEARCH_CITY, + CONF_WEB_SEARCH_CONTEXT_SIZE, + CONF_WEB_SEARCH_COUNTRY, + CONF_WEB_SEARCH_REGION, + CONF_WEB_SEARCH_TIMEZONE, + CONF_WEB_SEARCH_USER_LOCATION, +) from homeassistant.const import CONF_LLM_HASS_API from homeassistant.core import Context, HomeAssistant from homeassistant.helpers import intent @@ -225,7 +238,6 @@ async def test_incomplete_response( mock_config_entry_with_assist: MockConfigEntry, mock_init_component, mock_create_stream: AsyncMock, - mock_chat_log: MockChatLog, # noqa: F811 reason: str, message: str, ) -> None: @@ -301,7 +313,6 @@ async def test_failed_response( mock_config_entry_with_assist: MockConfigEntry, mock_init_component, mock_create_stream: AsyncMock, - mock_chat_log: MockChatLog, # noqa: F811 error: ResponseError | ResponseErrorEvent, message: str, ) -> None: @@ -491,6 +502,41 @@ def create_reasoning_item(id: str, output_index: int) -> list[ResponseStreamEven ] +def create_web_search_item(id: str, output_index: int) -> list[ResponseStreamEvent]: + """Create a web search call item.""" + return [ + ResponseOutputItemAddedEvent( + item=ResponseFunctionWebSearch( + id=id, status="in_progress", type="web_search_call" + ), + output_index=output_index, + type="response.output_item.added", + ), + ResponseWebSearchCallInProgressEvent( + item_id=id, + output_index=output_index, + type="response.web_search_call.in_progress", + ), + ResponseWebSearchCallSearchingEvent( + item_id=id, + output_index=output_index, + type="response.web_search_call.searching", + ), + ResponseWebSearchCallCompletedEvent( + item_id=id, + output_index=output_index, + type="response.web_search_call.completed", + ), + ResponseOutputItemDoneEvent( + item=ResponseFunctionWebSearch( + id=id, status="completed", type="web_search_call" + ), + output_index=output_index, + type="response.output_item.done", + ), + ] + + async def test_function_call( hass: HomeAssistant, mock_config_entry_with_assist: MockConfigEntry, @@ -581,7 +627,6 @@ async def test_function_call_invalid( mock_config_entry_with_assist: MockConfigEntry, mock_init_component, mock_create_stream: AsyncMock, - mock_chat_log: MockChatLog, # noqa: F811 description: str, messages: tuple[ResponseStreamEvent], ) -> None: @@ -633,3 +678,60 @@ async def test_assist_api_tools_conversion( tools = mock_create_stream.mock_calls[0][2]["tools"] assert tools + + +async def test_web_search( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_init_component, + mock_create_stream, + mock_chat_log: MockChatLog, # noqa: F811 +) -> None: + """Test web_search_tool.""" + hass.config_entries.async_update_entry( + mock_config_entry, + options={ + **mock_config_entry.options, + CONF_WEB_SEARCH: True, + CONF_WEB_SEARCH_CONTEXT_SIZE: "low", + CONF_WEB_SEARCH_USER_LOCATION: True, + CONF_WEB_SEARCH_CITY: "San Francisco", + CONF_WEB_SEARCH_COUNTRY: "US", + CONF_WEB_SEARCH_REGION: "California", + CONF_WEB_SEARCH_TIMEZONE: "America/Los_Angeles", + }, + ) + await hass.config_entries.async_reload(mock_config_entry.entry_id) + + message = "Home Assistant now supports ChatGPT Search in Assist" + mock_create_stream.return_value = [ + # Initial conversation + ( + *create_web_search_item(id="ws_A", output_index=0), + *create_message_item(id="msg_A", text=message, output_index=1), + ) + ] + + result = await conversation.async_converse( + hass, + "What's on the latest news?", + mock_chat_log.conversation_id, + Context(), + agent_id="conversation.openai", + ) + + assert mock_create_stream.mock_calls[0][2]["tools"] == [ + { + "type": "web_search_preview", + "search_context_size": "low", + "user_location": { + "type": "approximate", + "city": "San Francisco", + "region": "California", + "country": "US", + "timezone": "America/Los_Angeles", + }, + } + ] + assert result.response.response_type == intent.IntentResponseType.ACTION_DONE + assert result.response.speech["plain"]["speech"] == message, result.response.speech From b5910dd7d602de446ac607cb8b7c54cc27e2e2de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Wed, 26 Mar 2025 14:46:07 +0100 Subject: [PATCH 3090/3148] Move Home Connect alarm clock entity from time platform to number platform (#141400) * Move alarm clock entity from time platform to number platform * Deprecate alarm clock time entity * Don't update unique id * Fix tests * Fixable issues * improvement * Make the issues persistent --- .../components/home_connect/number.py | 5 + .../components/home_connect/strings.json | 25 +++ homeassistant/components/home_connect/time.py | 79 ++++++++ tests/components/home_connect/test_time.py | 186 +++++++++++++++++- 4 files changed, 294 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/home_connect/number.py b/homeassistant/components/home_connect/number.py index f525a360fa4..1bb793f4015 100644 --- a/homeassistant/components/home_connect/number.py +++ b/homeassistant/components/home_connect/number.py @@ -26,6 +26,11 @@ _LOGGER = logging.getLogger(__name__) PARALLEL_UPDATES = 1 NUMBERS = ( + NumberEntityDescription( + key=SettingKey.BSH_COMMON_ALARM_CLOCK, + device_class=NumberDeviceClass.DURATION, + translation_key="alarm_clock", + ), NumberEntityDescription( key=SettingKey.REFRIGERATION_FRIDGE_FREEZER_SETPOINT_TEMPERATURE_REFRIGERATOR, device_class=NumberDeviceClass.TEMPERATURE, diff --git a/homeassistant/components/home_connect/strings.json b/homeassistant/components/home_connect/strings.json index 2a7e4c5e718..44a6eb17cea 100644 --- a/homeassistant/components/home_connect/strings.json +++ b/homeassistant/components/home_connect/strings.json @@ -110,6 +110,28 @@ } }, "issues": { + "deprecated_time_alarm_clock_in_automations_scripts": { + "title": "Deprecated alarm clock entity detected in some automations or scripts", + "fix_flow": { + "step": { + "confirm": { + "title": "[%key:component::home_connect::issues::deprecated_time_alarm_clock_in_automations_scripts::title%]", + "description": "The alarm clock entity `{entity_id}`, which is deprecated because it's being moved to the `number` platform, is used in the following automations or scripts:\n{items}\n\nPlease, fix this issue by updating your automations or scripts to use the new `number` entity." + } + } + } + }, + "deprecated_time_alarm_clock": { + "title": "Deprecated alarm clock entity", + "fix_flow": { + "step": { + "confirm": { + "title": "[%key:component::home_connect::issues::deprecated_time_alarm_clock::title%]", + "description": "The alarm clock entity `{entity_id}` is deprecated because it's being moved to the `number` platform.\n\nPlease use the new `number` entity." + } + } + } + }, "deprecated_binary_common_door_sensor": { "title": "Deprecated binary door sensor detected in some automations or scripts", "description": "The binary door sensor `{entity}`, which is deprecated, is used in the following automations or scripts:\n{items}\n\nA sensor entity with additional possible states is available and should be used going forward; Please use it on the above automations or scripts to fix this issue." @@ -868,6 +890,9 @@ } }, "number": { + "alarm_clock": { + "name": "Alarm clock" + }, "refrigerator_setpoint_temperature": { "name": "Refrigerator temperature" }, diff --git a/homeassistant/components/home_connect/time.py b/homeassistant/components/home_connect/time.py index d0272f77556..adf26d2d973 100644 --- a/homeassistant/components/home_connect/time.py +++ b/homeassistant/components/home_connect/time.py @@ -6,10 +6,18 @@ from typing import cast from aiohomeconnect.model import SettingKey from aiohomeconnect.model.error import HomeConnectError +from homeassistant.components.automation import automations_with_entity +from homeassistant.components.script import scripts_with_entity from homeassistant.components.time import TimeEntity, TimeEntityDescription from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.issue_registry import ( + IssueSeverity, + async_create_issue, + async_delete_issue, +) from .common import setup_home_connect_entry from .const import DOMAIN @@ -23,6 +31,7 @@ TIME_ENTITIES = ( TimeEntityDescription( key=SettingKey.BSH_COMMON_ALARM_CLOCK, translation_key="alarm_clock", + entity_registry_enabled_default=False, ), ) @@ -67,8 +76,78 @@ def time_to_seconds(t: time) -> int: class HomeConnectTimeEntity(HomeConnectEntity, TimeEntity): """Time setting class for Home Connect.""" + async def async_added_to_hass(self) -> None: + """Call when entity is added to hass.""" + await super().async_added_to_hass() + if self.bsh_key == SettingKey.BSH_COMMON_ALARM_CLOCK: + automations = automations_with_entity(self.hass, self.entity_id) + scripts = scripts_with_entity(self.hass, self.entity_id) + items = automations + scripts + if not items: + return + + entity_reg: er.EntityRegistry = er.async_get(self.hass) + entity_automations = [ + automation_entity + for automation_id in automations + if (automation_entity := entity_reg.async_get(automation_id)) + ] + entity_scripts = [ + script_entity + for script_id in scripts + if (script_entity := entity_reg.async_get(script_id)) + ] + + items_list = [ + f"- [{item.original_name}](/config/automation/edit/{item.unique_id})" + for item in entity_automations + ] + [ + f"- [{item.original_name}](/config/script/edit/{item.unique_id})" + for item in entity_scripts + ] + + async_create_issue( + self.hass, + DOMAIN, + f"deprecated_time_alarm_clock_in_automations_scripts_{self.entity_id}", + breaks_in_ha_version="2025.10.0", + is_fixable=True, + is_persistent=True, + severity=IssueSeverity.WARNING, + translation_key="deprecated_time_alarm_clock", + translation_placeholders={ + "entity_id": self.entity_id, + "items": "\n".join(items_list), + }, + ) + + async def async_will_remove_from_hass(self) -> None: + """Call when entity will be removed from hass.""" + if self.bsh_key == SettingKey.BSH_COMMON_ALARM_CLOCK: + async_delete_issue( + self.hass, + DOMAIN, + f"deprecated_time_alarm_clock_in_automations_scripts_{self.entity_id}", + ) + async_delete_issue( + self.hass, DOMAIN, f"deprecated_time_alarm_clock_{self.entity_id}" + ) + async def async_set_value(self, value: time) -> None: """Set the native value of the entity.""" + async_create_issue( + self.hass, + DOMAIN, + f"deprecated_time_alarm_clock_{self.entity_id}", + breaks_in_ha_version="2025.10.0", + is_fixable=True, + is_persistent=True, + severity=IssueSeverity.WARNING, + translation_key="deprecated_time_alarm_clock", + translation_placeholders={ + "entity_id": self.entity_id, + }, + ) try: await self.coordinator.client.set_setting( self.appliance.info.ha_id, diff --git a/tests/components/home_connect/test_time.py b/tests/components/home_connect/test_time.py index 6be23460cac..e52e62a8927 100644 --- a/tests/components/home_connect/test_time.py +++ b/tests/components/home_connect/test_time.py @@ -2,6 +2,7 @@ from collections.abc import Awaitable, Callable from datetime import time +from http import HTTPStatus from unittest.mock import AsyncMock, MagicMock from aiohomeconnect.model import ( @@ -16,15 +17,26 @@ from aiohomeconnect.model import ( from aiohomeconnect.model.error import HomeConnectApiError, HomeConnectError import pytest +from homeassistant.components.automation import ( + DOMAIN as AUTOMATION_DOMAIN, + automations_with_entity, +) from homeassistant.components.home_connect.const import DOMAIN +from homeassistant.components.script import DOMAIN as SCRIPT_DOMAIN, scripts_with_entity from homeassistant.components.time import DOMAIN as TIME_DOMAIN, SERVICE_SET_VALUE from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ATTR_ENTITY_ID, ATTR_TIME, STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers import ( + device_registry as dr, + entity_registry as er, + issue_registry as ir, +) +from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry +from tests.typing import ClientSessionGenerator @pytest.fixture @@ -45,6 +57,7 @@ async def test_time( assert config_entry.state is ConfigEntryState.LOADED +@pytest.mark.usefixtures("entity_registry_enabled_by_default") @pytest.mark.parametrize("appliance", ["Oven"], indirect=True) async def test_paired_depaired_devices_flow( appliance: HomeAppliance, @@ -99,6 +112,7 @@ async def test_paired_depaired_devices_flow( assert entity_registry.async_get(entity_entry.entity_id) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") @pytest.mark.parametrize("appliance", ["Oven"], indirect=True) async def test_connected_devices( appliance: HomeAppliance, @@ -151,6 +165,7 @@ async def test_connected_devices( assert len(new_entity_entries) > len(entity_entries) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") @pytest.mark.parametrize("appliance", ["Oven"], indirect=True) async def test_time_entity_availability( hass: HomeAssistant, @@ -204,6 +219,7 @@ async def test_time_entity_availability( assert state.state != STATE_UNAVAILABLE +@pytest.mark.usefixtures("entity_registry_enabled_by_default") @pytest.mark.parametrize("appliance", ["Oven"], indirect=True) @pytest.mark.parametrize( ("entity_id", "setting_key"), @@ -248,6 +264,7 @@ async def test_time_entity_functionality( assert hass.states.is_state(entity_id, str(time(second=value))) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") @pytest.mark.parametrize( ("entity_id", "setting_key", "mock_attr"), [ @@ -299,3 +316,170 @@ async def test_time_entity_error( blocking=True, ) assert getattr(client_with_exception, mock_attr).call_count == 2 + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.parametrize("appliance", ["Oven"], indirect=True) +async def test_create_issue( + hass: HomeAssistant, + appliance: HomeAppliance, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + issue_registry: ir.IssueRegistry, +) -> None: + """Test we create an issue when an automation or script is using a deprecated entity.""" + entity_id = f"{TIME_DOMAIN}.oven_alarm_clock" + automation_script_issue_id = ( + f"deprecated_time_alarm_clock_in_automations_scripts_{entity_id}" + ) + action_handler_issue_id = f"deprecated_time_alarm_clock_{entity_id}" + + assert await async_setup_component( + hass, + AUTOMATION_DOMAIN, + { + AUTOMATION_DOMAIN: { + "alias": "test", + "trigger": {"platform": "state", "entity_id": entity_id}, + "action": { + "action": "automation.turn_on", + "target": { + "entity_id": "automation.test", + }, + }, + } + }, + ) + assert await async_setup_component( + hass, + SCRIPT_DOMAIN, + { + SCRIPT_DOMAIN: { + "test": { + "sequence": [ + { + "action": "switch.turn_on", + "entity_id": entity_id, + }, + ], + } + } + }, + ) + + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + await hass.services.async_call( + TIME_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: entity_id, + ATTR_TIME: time(minute=1), + }, + blocking=True, + ) + + assert automations_with_entity(hass, entity_id)[0] == "automation.test" + assert scripts_with_entity(hass, entity_id)[0] == "script.test" + + assert len(issue_registry.issues) == 2 + assert issue_registry.async_get_issue(DOMAIN, automation_script_issue_id) + assert issue_registry.async_get_issue(DOMAIN, action_handler_issue_id) + + await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() + + # Assert the issue is no longer present + assert not issue_registry.async_get_issue(DOMAIN, automation_script_issue_id) + assert not issue_registry.async_get_issue(DOMAIN, action_handler_issue_id) + assert len(issue_registry.issues) == 0 + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.parametrize("appliance", ["Oven"], indirect=True) +async def test_issue_fix( + hass: HomeAssistant, + appliance: HomeAppliance, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + issue_registry: ir.IssueRegistry, + hass_client: ClientSessionGenerator, +) -> None: + """Test we create an issue when an automation or script is using a deprecated entity.""" + entity_id = f"{TIME_DOMAIN}.oven_alarm_clock" + automation_script_issue_id = ( + f"deprecated_time_alarm_clock_in_automations_scripts_{entity_id}" + ) + action_handler_issue_id = f"deprecated_time_alarm_clock_{entity_id}" + + assert await async_setup_component( + hass, + AUTOMATION_DOMAIN, + { + AUTOMATION_DOMAIN: { + "alias": "test", + "trigger": {"platform": "state", "entity_id": entity_id}, + "action": { + "action": "automation.turn_on", + "target": { + "entity_id": "automation.test", + }, + }, + } + }, + ) + assert await async_setup_component( + hass, + SCRIPT_DOMAIN, + { + SCRIPT_DOMAIN: { + "test": { + "sequence": [ + { + "action": "switch.turn_on", + "entity_id": entity_id, + }, + ], + } + } + }, + ) + + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + await hass.services.async_call( + TIME_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: entity_id, + ATTR_TIME: time(minute=1), + }, + blocking=True, + ) + + assert len(issue_registry.issues) == 2 + assert issue_registry.async_get_issue(DOMAIN, automation_script_issue_id) + assert issue_registry.async_get_issue(DOMAIN, action_handler_issue_id) + + for issue in issue_registry.issues.copy().values(): + _client = await hass_client() + resp = await _client.post( + "/api/repairs/issues/fix", + json={"handler": DOMAIN, "issue_id": issue.issue_id}, + ) + assert resp.status == HTTPStatus.OK + flow_id = (await resp.json())["flow_id"] + resp = await _client.post(f"/api/repairs/issues/fix/{flow_id}") + + # Assert the issue is no longer present + assert not issue_registry.async_get_issue(DOMAIN, automation_script_issue_id) + assert not issue_registry.async_get_issue(DOMAIN, action_handler_issue_id) + assert len(issue_registry.issues) == 0 From 63d4efda2e4cf68a0725f76967df2445a6624bdb Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 26 Mar 2025 15:06:13 +0100 Subject: [PATCH 3091/3148] Deprecate switch entity for airdresser (#141470) * Deprecate switch entity for airdresser * Deprecate switch entity for airdresser --- .../components/smartthings/binary_sensor.py | 1 + .../components/smartthings/switch.py | 2 + tests/components/smartthings/conftest.py | 1 + .../device_status/da_wm_sc_000001.json | 929 ++++++++++++++++++ .../fixtures/devices/da_wm_sc_000001.json | 172 ++++ .../snapshots/test_binary_sensor.ambr | 142 +++ .../smartthings/snapshots/test_init.ambr | 33 + .../smartthings/snapshots/test_select.ambr | 58 ++ .../smartthings/snapshots/test_sensor.ambr | 467 +++++++++ .../smartthings/snapshots/test_switch.ambr | 47 + 10 files changed, 1852 insertions(+) create mode 100644 tests/components/smartthings/fixtures/device_status/da_wm_sc_000001.json create mode 100644 tests/components/smartthings/fixtures/devices/da_wm_sc_000001.json diff --git a/homeassistant/components/smartthings/binary_sensor.py b/homeassistant/components/smartthings/binary_sensor.py index 8e4f5c3878e..3508d174370 100644 --- a/homeassistant/components/smartthings/binary_sensor.py +++ b/homeassistant/components/smartthings/binary_sensor.py @@ -133,6 +133,7 @@ CAPABILITY_TO_SENSORS: dict[ device_class=BinarySensorDeviceClass.POWER, is_on_key="on", category={ + Category.CLOTHING_CARE_MACHINE, Category.DISHWASHER, Category.DRYER, Category.MICROWAVE, diff --git a/homeassistant/components/smartthings/switch.py b/homeassistant/components/smartthings/switch.py index f57577d7c12..968d1e51b6a 100644 --- a/homeassistant/components/smartthings/switch.py +++ b/homeassistant/components/smartthings/switch.py @@ -187,6 +187,7 @@ class SmartThingsSwitch(SmartThingsEntity, SwitchEntity): if self.entity_description != SWITCH or self.device.device.components[ MAIN ].manufacturer_category not in { + Category.CLOTHING_CARE_MACHINE, Category.DRYER, Category.WASHER, Category.MICROWAVE, @@ -229,6 +230,7 @@ class SmartThingsSwitch(SmartThingsEntity, SwitchEntity): if self.entity_description != SWITCH or self.device.device.components[ MAIN ].manufacturer_category not in { + Category.CLOTHING_CARE_MACHINE, Category.DRYER, Category.WASHER, Category.MICROWAVE, diff --git a/tests/components/smartthings/conftest.py b/tests/components/smartthings/conftest.py index ad0399a7a6c..dfc4bd28227 100644 --- a/tests/components/smartthings/conftest.py +++ b/tests/components/smartthings/conftest.py @@ -114,6 +114,7 @@ def mock_smartthings() -> Generator[AsyncMock]: "da_wm_wd_000001_1", "da_wm_wm_000001", "da_wm_wm_000001_1", + "da_wm_sc_000001", "da_rvc_normal_000001", "da_ks_microwave_0101x", "da_ks_range_0101x", diff --git a/tests/components/smartthings/fixtures/device_status/da_wm_sc_000001.json b/tests/components/smartthings/fixtures/device_status/da_wm_sc_000001.json new file mode 100644 index 00000000000..d52b5186db3 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/da_wm_sc_000001.json @@ -0,0 +1,929 @@ +{ + "components": { + "main": { + "samsungce.welcomeMessage": { + "welcomeMessage": { + "value": null + } + }, + "samsungce.deviceIdentification": { + "micomAssayCode": { + "value": "20299141", + "timestamp": "2025-02-11T08:21:17.534Z" + }, + "modelName": { + "value": null + }, + "serialNumber": { + "value": null + }, + "serialNumberExtra": { + "value": null + }, + "modelClassificationCode": { + "value": "3801010200151107020100FF00000000", + "timestamp": "2025-02-11T08:21:17.534Z" + }, + "description": { + "value": "DA_DF_TP2_20_COMMON_DF8500A/DC92-02995A_0010", + "timestamp": "2025-02-11T08:21:17.534Z" + }, + "releaseYear": { + "value": null + }, + "binaryId": { + "value": "DA_DF_TP2_20_COMMON", + "timestamp": "2025-02-11T08:21:17.534Z" + } + }, + "samsungce.steamClosetCycle": { + "supportedCycles": { + "value": [ + { + "cycle": "22", + "supportedOptions": { + "keepFresh": { + "raw": "660F", + "default": "on", + "options": ["on", "off"] + }, + "sanitize": { + "raw": "6106", + "default": "off", + "options": ["off", "on"] + } + } + }, + { + "cycle": "23", + "supportedOptions": { + "keepFresh": { + "raw": "660F", + "default": "on", + "options": ["on", "off"] + }, + "sanitize": { + "raw": "6102", + "default": "off", + "options": ["off"] + } + } + }, + { + "cycle": "32", + "supportedOptions": { + "keepFresh": { + "raw": "660F", + "default": "on", + "options": ["on", "off"] + }, + "sanitize": { + "raw": "6102", + "default": "off", + "options": ["off"] + } + } + }, + { + "cycle": "09", + "supportedOptions": { + "keepFresh": { + "raw": "660F", + "default": "on", + "options": ["on", "off"] + }, + "sanitize": { + "raw": "6102", + "default": "off", + "options": ["off"] + } + } + }, + { + "cycle": "12", + "supportedOptions": { + "keepFresh": { + "raw": "660F", + "default": "on", + "options": ["on", "off"] + }, + "sanitize": { + "raw": "6102", + "default": "off", + "options": ["off"] + } + } + }, + { + "cycle": "0C", + "supportedOptions": { + "keepFresh": { + "raw": "660F", + "default": "on", + "options": ["on", "off"] + }, + "sanitize": { + "raw": "6102", + "default": "off", + "options": ["off"] + } + } + }, + { + "cycle": "31", + "supportedOptions": { + "keepFresh": { + "raw": "660F", + "default": "on", + "options": ["on", "off"] + }, + "sanitize": { + "raw": "6102", + "default": "off", + "options": ["off"] + } + } + }, + { + "cycle": "0B", + "supportedOptions": { + "keepFresh": { + "raw": "660F", + "default": "on", + "options": ["on", "off"] + }, + "sanitize": { + "raw": "6102", + "default": "off", + "options": ["off"] + } + } + }, + { + "cycle": "10", + "supportedOptions": { + "keepFresh": { + "raw": "660F", + "default": "on", + "options": ["on", "off"] + }, + "sanitize": { + "raw": "6102", + "default": "off", + "options": ["off"] + } + } + }, + { + "cycle": "0A", + "supportedOptions": { + "keepFresh": { + "raw": "660F", + "default": "on", + "options": ["on", "off"] + }, + "sanitize": { + "raw": "6106", + "default": "off", + "options": ["off", "on"] + } + } + }, + { + "cycle": "14", + "supportedOptions": { + "keepFresh": { + "raw": "660F", + "default": "on", + "options": ["on", "off"] + }, + "sanitize": { + "raw": "6102", + "default": "off", + "options": ["off"] + } + } + }, + { + "cycle": "13", + "supportedOptions": { + "keepFresh": { + "raw": "660F", + "default": "on", + "options": ["on", "off"] + }, + "sanitize": { + "raw": "6106", + "default": "off", + "options": ["off", "on"] + } + } + }, + { + "cycle": "16", + "supportedOptions": { + "keepFresh": { + "raw": "660F", + "default": "on", + "options": ["on", "off"] + }, + "sanitize": { + "raw": "6102", + "default": "off", + "options": ["off"] + } + } + }, + { + "cycle": "24", + "supportedOptions": { + "keepFresh": { + "raw": "660F", + "default": "on", + "options": ["on", "off"] + }, + "sanitize": { + "raw": "6206", + "default": "on", + "options": ["off", "on"] + } + } + }, + { + "cycle": "25", + "supportedOptions": { + "keepFresh": { + "raw": "660F", + "default": "on", + "options": ["on", "off"] + }, + "sanitize": { + "raw": "6206", + "default": "on", + "options": ["off", "on"] + } + } + }, + { + "cycle": "2F", + "supportedOptions": { + "keepFresh": { + "raw": "660F", + "default": "on", + "options": ["on", "off"] + }, + "sanitize": { + "raw": "6206", + "default": "on", + "options": ["off", "on"] + } + } + }, + { + "cycle": "20", + "supportedOptions": { + "keepFresh": { + "raw": "660F", + "default": "on", + "options": ["on", "off"] + }, + "sanitize": { + "raw": "6204", + "default": "on", + "options": ["on"] + } + } + }, + { + "cycle": "0F", + "supportedOptions": { + "keepFresh": { + "raw": "66F0", + "default": "off", + "options": ["on", "off"] + }, + "sanitize": { + "raw": "6204", + "default": "on", + "options": ["on"] + } + } + }, + { + "cycle": "27", + "supportedOptions": { + "keepFresh": { + "raw": "660F", + "default": "on", + "options": ["on", "off"] + }, + "sanitize": { + "raw": "6102", + "default": "off", + "options": ["off"] + } + } + }, + { + "cycle": "30", + "supportedOptions": { + "keepFresh": { + "raw": "660F", + "default": "on", + "options": ["on", "off"] + }, + "sanitize": { + "raw": "6102", + "default": "off", + "options": ["off"] + } + } + }, + { + "cycle": "15", + "supportedOptions": { + "keepFresh": { + "raw": "660F", + "default": "on", + "options": ["on", "off"] + }, + "sanitize": { + "raw": "6102", + "default": "off", + "options": ["off"] + } + } + }, + { + "cycle": "1A", + "supportedOptions": { + "keepFresh": { + "raw": "660F", + "default": "on", + "options": ["on", "off"] + }, + "sanitize": { + "raw": "6102", + "default": "off", + "options": ["off"] + } + } + }, + { + "cycle": "1B", + "supportedOptions": { + "keepFresh": { + "raw": "660F", + "default": "on", + "options": ["on", "off"] + }, + "sanitize": { + "raw": "6102", + "default": "off", + "options": ["off"] + } + } + }, + { + "cycle": "1C", + "supportedOptions": { + "keepFresh": { + "raw": "660F", + "default": "on", + "options": ["on", "off"] + }, + "sanitize": { + "raw": "6102", + "default": "off", + "options": ["off"] + } + } + }, + { + "cycle": "2D", + "supportedOptions": { + "keepFresh": { + "raw": "660F", + "default": "on", + "options": ["on", "off"] + }, + "sanitize": { + "raw": "6102", + "default": "off", + "options": ["off"] + } + } + }, + { + "cycle": "07", + "supportedOptions": { + "keepFresh": { + "raw": "66F0", + "default": "off", + "options": ["on", "off"] + }, + "sanitize": { + "raw": "6102", + "default": "off", + "options": ["off"] + } + } + }, + { + "cycle": "08", + "supportedOptions": { + "keepFresh": { + "raw": "66F0", + "default": "off", + "options": ["on", "off"] + }, + "sanitize": { + "raw": "6102", + "default": "off", + "options": ["off"] + } + } + } + ], + "timestamp": "2025-02-11T08:21:17.534Z" + }, + "steamClosetCycle": { + "value": "Table_00_Course_22", + "timestamp": "2025-02-11T08:21:17.534Z" + }, + "referenceTable": { + "value": { + "id": "Table_00" + }, + "timestamp": "2025-02-11T08:21:17.534Z" + } + }, + "switch": { + "switch": { + "value": "off", + "timestamp": "2025-02-11T08:21:17.534Z" + } + }, + "samsungce.quickControl": { + "version": { + "value": null + } + }, + "ocf": { + "st": { + "value": null + }, + "mndt": { + "value": null + }, + "mnfv": { + "value": "DA_DF_TP2_20_COMMON_30230807", + "timestamp": "2025-01-14T01:42:53.834Z" + }, + "mnhw": { + "value": "MediaTek", + "timestamp": "2025-01-14T01:42:53.834Z" + }, + "di": { + "value": "b93211bf-9d96-bd21-3b2f-964fcc87f5cc", + "timestamp": "2025-01-14T01:42:53.834Z" + }, + "mnsl": { + "value": "http://www.samsung.com", + "timestamp": "2025-01-14T01:42:53.834Z" + }, + "dmv": { + "value": "res.1.1.0,sh.1.1.0", + "timestamp": "2025-01-14T01:42:53.834Z" + }, + "n": { + "value": "[airdresser] Samsung", + "timestamp": "2025-01-14T01:42:53.834Z" + }, + "mnmo": { + "value": "DA_DF_TP2_20_COMMON|20299141|3801010200151107020100FF00000000", + "timestamp": "2025-01-14T01:42:53.834Z" + }, + "vid": { + "value": "DA-WM-SC-000001", + "timestamp": "2025-01-14T01:42:53.834Z" + }, + "mnmn": { + "value": "Samsung Electronics", + "timestamp": "2025-01-14T01:42:53.834Z" + }, + "mnml": { + "value": "http://www.samsung.com", + "timestamp": "2025-01-14T01:42:53.834Z" + }, + "mnpv": { + "value": "DAWIT 2.0", + "timestamp": "2025-01-14T01:42:53.834Z" + }, + "mnos": { + "value": "TizenRT 2.0 + IPv6", + "timestamp": "2025-01-14T01:42:53.834Z" + }, + "pi": { + "value": "b93211bf-9d96-bd21-3b2f-964fcc87f5cc", + "timestamp": "2025-01-14T01:42:53.834Z" + }, + "icv": { + "value": "core.1.1.0", + "timestamp": "2025-01-14T01:42:53.834Z" + } + }, + "samsungce.steamClosetCyclePreset": { + "maxNumberOfPresets": { + "value": 10, + "timestamp": "2025-02-11T08:21:17.534Z" + }, + "presets": { + "value": { + "F1": {}, + "F2": {}, + "F3": {}, + "F4": {}, + "F5": {}, + "F6": {}, + "F7": {}, + "F8": {}, + "F9": {}, + "FA": {} + }, + "timestamp": "2025-02-11T08:21:17.534Z" + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": [ + "custom.steamClosetWrinklePrevent", + "custom.veryFineDustFilter", + "demandResponseLoadControl", + "sec.wifiConfiguration", + "samsungce.quickControl", + "samsungce.deviceInfoPrivate" + ], + "timestamp": "2025-02-11T08:21:17.534Z" + } + }, + "samsungce.driverVersion": { + "versionNumber": { + "value": 24110101, + "timestamp": "2024-12-02T07:55:47.237Z" + } + }, + "sec.diagnosticsInformation": { + "logType": { + "value": ["errCode", "dump"], + "timestamp": "2025-02-11T08:21:17.534Z" + }, + "endpoint": { + "value": "SSM", + "timestamp": "2025-02-11T08:21:17.534Z" + }, + "minVersion": { + "value": "1.0", + "timestamp": "2025-02-11T08:21:17.534Z" + }, + "signinPermission": { + "value": null + }, + "setupId": { + "value": "A00", + "timestamp": "2025-02-11T08:21:17.534Z" + }, + "protocolType": { + "value": "wifi_https", + "timestamp": "2025-02-11T08:21:17.534Z" + }, + "tsId": { + "value": null + }, + "mnId": { + "value": "0AJT", + "timestamp": "2025-02-11T08:21:17.534Z" + }, + "dumpType": { + "value": "file", + "timestamp": "2025-02-11T08:21:17.534Z" + } + }, + "samsungce.steamClosetKeepFreshMode": { + "operatingState": { + "value": "ready", + "timestamp": "2025-02-11T08:21:17.534Z" + }, + "status": { + "value": "off", + "timestamp": "2025-02-11T08:21:17.534Z" + } + }, + "samsungce.kidsLock": { + "lockState": { + "value": "unlocked", + "timestamp": "2025-02-11T08:21:17.534Z" + } + }, + "demandResponseLoadControl": { + "drlcStatus": { + "value": null + } + }, + "powerConsumptionReport": { + "powerConsumption": { + "value": { + "energy": 207500, + "deltaEnergy": 0, + "power": 0, + "powerEnergy": 0.0, + "persistedEnergy": 0, + "energySaved": 0, + "start": "2025-02-10T22:51:59Z", + "end": "2025-02-11T08:21:17Z" + }, + "timestamp": "2025-02-11T08:21:17.534Z" + } + }, + "dryerOperatingState": { + "completionTime": { + "value": "2025-02-11T09:00:17Z", + "timestamp": "2025-02-11T08:21:17.534Z" + }, + "machineState": { + "value": "stop", + "timestamp": "2025-02-11T08:21:17.534Z" + }, + "supportedMachineStates": { + "value": ["stop", "run", "pause"], + "timestamp": "2025-02-11T08:21:17.534Z" + }, + "dryerJobState": { + "value": "none", + "timestamp": "2025-02-11T08:21:17.534Z" + } + }, + "refresh": {}, + "samsungce.steamClosetSanitizeMode": { + "status": { + "value": "off", + "timestamp": "2025-02-11T08:21:17.534Z" + } + }, + "custom.jobBeginningStatus": { + "jobBeginningStatus": { + "value": null + } + }, + "execute": { + "data": { + "value": { + "payload": { + "rt": ["x.com.samsung.da.information"], + "if": ["oic.if.baseline", "oic.if.a"], + "x.com.samsung.da.modelNum": "DA_DF_TP2_20_COMMON|20299141|3801010200151107020100FF00000000", + "x.com.samsung.da.description": "DA_DF_TP2_20_COMMON_DF8500A/DC92-02995A_0010", + "x.com.samsung.da.serialNum": "1EG158TW400002M", + "x.com.samsung.da.otnDUID": "MTCHUODP5V4FA", + "x.com.samsung.da.diagProtocolType": "WIFI_HTTPS", + "x.com.samsung.da.diagLogType": ["errCode", "dump"], + "x.com.samsung.da.diagDumpType": "file", + "x.com.samsung.da.diagEndPoint": "SSM", + "x.com.samsung.da.diagMnid": "0AJT", + "x.com.samsung.da.diagSetupid": "A00", + "x.com.samsung.da.diagMinVersion": "1.0", + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "DA_DF_TP2_20_COMMON|20299141|3801010200151107020100FF00000000", + "x.com.samsung.da.type": "Software", + "x.com.samsung.da.number": "02673A230807(F821)", + "x.com.samsung.da.newVersionAvailable": "0" + }, + { + "x.com.samsung.da.id": "1", + "x.com.samsung.da.description": "Firmware_1_DB_20299141210618090FFFFF202995412203111604FFFF(015E2029914120299541_30000000)(FileDown:0)(Type:0)", + "x.com.samsung.da.type": "Firmware", + "x.com.samsung.da.number": "21061809,22031116", + "x.com.samsung.da.newVersionAvailable": "0" + }, + { + "x.com.samsung.da.id": "2", + "x.com.samsung.da.description": "Firmware_2_DB_2023564319111852041FFFFFFFFFFFFFFFFFFFFFFFFE(015E20235643FFFFFFFF_30000000)(FileDown:0)(Type:0)", + "x.com.samsung.da.type": "Firmware", + "x.com.samsung.da.number": "19111852,FFFFFFFF" + } + ] + } + }, + "data": { + "href": "/information/vs/0" + }, + "timestamp": "2024-03-06T11:24:05.312Z" + } + }, + "samsungce.steamClosetDelayEnd": { + "remainingTime": { + "value": 0, + "unit": "min", + "timestamp": "2025-02-11T08:21:17.534Z" + } + }, + "samsungce.steamClosetAutoCycleLink": { + "steamClosetAutoCycleLink": { + "value": "on", + "timestamp": "2025-02-11T08:21:17.534Z" + } + }, + "sec.wifiConfiguration": { + "autoReconnection": { + "value": null + }, + "minVersion": { + "value": null + }, + "supportedWiFiFreq": { + "value": null + }, + "supportedAuthType": { + "value": null + }, + "protocolType": { + "value": null + } + }, + "custom.steamClosetWrinklePrevent": { + "steamClosetWrinklePrevent": { + "value": "off", + "timestamp": "2025-02-11T08:21:17.534Z" + } + }, + "remoteControlStatus": { + "remoteControlEnabled": { + "value": "false", + "timestamp": "2025-02-11T08:21:17.534Z" + } + }, + "custom.supportedOptions": { + "course": { + "value": null + }, + "referenceTable": { + "value": { + "id": "Table_00" + }, + "timestamp": "2025-02-11T08:21:17.534Z" + }, + "supportedCourses": { + "value": [ + "22", + "23", + "32", + "09", + "12", + "0C", + "31", + "0B", + "10", + "0A", + "14", + "13", + "16", + "24", + "25", + "2F", + "20", + "0F", + "27", + "30", + "15", + "1A", + "1B", + "1C", + "2D", + "07", + "08" + ], + "timestamp": "2025-02-11T08:21:17.534Z" + } + }, + "custom.steamClosetOperatingState": { + "supportedSteamClosetJobState": { + "value": ["none", "steaming", "airwashing", "drying", "finish"], + "timestamp": "2025-02-09T22:16:19.221Z" + }, + "completionTime": { + "value": "2025-02-11T09:00:17Z", + "timestamp": "2025-02-11T08:21:17.534Z" + }, + "steamClosetMachineState": { + "value": "stop", + "timestamp": "2025-02-11T08:21:17.534Z" + }, + "supportedSteamClosetMachineState": { + "value": ["stop", "run", "pause"], + "timestamp": "2023-06-23T16:00:41.238Z" + }, + "steamClosetJobState": { + "value": "none", + "timestamp": "2025-02-11T08:21:17.534Z" + }, + "progress": { + "value": 1, + "unit": "%", + "timestamp": "2025-02-10T22:53:25.928Z" + }, + "remainingTimeStr": { + "value": "00:39", + "timestamp": "2025-02-10T22:53:25.928Z" + }, + "steamClosetDelayEndTime": { + "value": null + }, + "remainingTime": { + "value": 39, + "unit": "min", + "timestamp": "2025-02-10T22:53:25.928Z" + } + }, + "custom.energyType": { + "energyType": { + "value": "2.0", + "timestamp": "2024-03-06T11:24:06.106Z" + }, + "energySavingSupport": { + "value": false, + "timestamp": "2024-03-06T11:24:06.106Z" + }, + "drMaxDuration": { + "value": null + }, + "energySavingLevel": { + "value": null + }, + "energySavingInfo": { + "value": null + }, + "supportedEnergySavingLevels": { + "value": null + }, + "energySavingOperation": { + "value": null + }, + "notificationTemplateID": { + "value": null + }, + "energySavingOperationSupport": { + "value": null + } + }, + "samsungce.softwareUpdate": { + "targetModule": { + "value": {}, + "timestamp": "2025-02-09T17:33:28.019Z" + }, + "otnDUID": { + "value": "MTCHUODP5V4FA", + "timestamp": "2025-02-11T08:21:17.534Z" + }, + "lastUpdatedDate": { + "value": null + }, + "availableModules": { + "value": [], + "timestamp": "2023-06-23T16:00:41.636Z" + }, + "newVersionAvailable": { + "value": false, + "timestamp": "2025-02-09T17:33:28.019Z" + }, + "operatingState": { + "value": null + }, + "progress": { + "value": null + } + }, + "custom.veryFineDustFilter": { + "veryFineDustFilterStatus": { + "value": null + }, + "veryFineDustFilterResetType": { + "value": null + }, + "veryFineDustFilterUsage": { + "value": null + }, + "veryFineDustFilterLastResetDate": { + "value": null + }, + "veryFineDustFilterUsageStep": { + "value": null + }, + "veryFineDustFilterCapacity": { + "value": null + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/devices/da_wm_sc_000001.json b/tests/components/smartthings/fixtures/devices/da_wm_sc_000001.json new file mode 100644 index 00000000000..8b501cba9b7 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/da_wm_sc_000001.json @@ -0,0 +1,172 @@ +{ + "items": [ + { + "deviceId": "b93211bf-9d96-bd21-3b2f-964fcc87f5cc", + "name": "[airdresser] Samsung", + "label": "AirDresser", + "manufacturerName": "Samsung Electronics", + "presentationId": "DA-WM-SC-000001", + "deviceManufacturerCode": "Samsung Electronics", + "locationId": "df59873c-4e2c-43ba-bcd4-ade4efb0504a", + "ownerId": "71254e90-c144-45b6-aabe-709f78f48376", + "roomId": "4c9052ba-4430-4cb1-a788-f1e4449c43c9", + "deviceTypeName": "Samsung OCF Steam Closet", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "ocf", + "version": 1 + }, + { + "id": "execute", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "switch", + "version": 1 + }, + { + "id": "remoteControlStatus", + "version": 1 + }, + { + "id": "dryerOperatingState", + "version": 1 + }, + { + "id": "demandResponseLoadControl", + "version": 1 + }, + { + "id": "powerConsumptionReport", + "version": 1 + }, + { + "id": "custom.steamClosetOperatingState", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + }, + { + "id": "custom.energyType", + "version": 1 + }, + { + "id": "custom.steamClosetWrinklePrevent", + "version": 1 + }, + { + "id": "custom.jobBeginningStatus", + "version": 1 + }, + { + "id": "custom.supportedOptions", + "version": 1 + }, + { + "id": "custom.veryFineDustFilter", + "version": 1 + }, + { + "id": "samsungce.deviceIdentification", + "version": 1 + }, + { + "id": "samsungce.driverVersion", + "version": 1 + }, + { + "id": "samsungce.softwareUpdate", + "version": 1 + }, + { + "id": "samsungce.steamClosetDelayEnd", + "version": 1 + }, + { + "id": "samsungce.steamClosetKeepFreshMode", + "version": 1 + }, + { + "id": "samsungce.steamClosetSanitizeMode", + "version": 1 + }, + { + "id": "samsungce.steamClosetAutoCycleLink", + "version": 1 + }, + { + "id": "samsungce.steamClosetCycle", + "version": 1 + }, + { + "id": "samsungce.steamClosetCyclePreset", + "version": 1 + }, + { + "id": "samsungce.kidsLock", + "version": 1 + }, + { + "id": "samsungce.welcomeMessage", + "version": 1 + }, + { + "id": "samsungce.quickControl", + "version": 1 + }, + { + "id": "sec.diagnosticsInformation", + "version": 1 + }, + { + "id": "sec.wifiConfiguration", + "version": 1 + } + ], + "categories": [ + { + "name": "ClothingCareMachine", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2023-06-23T16:00:40.545Z", + "profile": { + "id": "a3623498-4747-3761-bac1-ba13f437d8ea" + }, + "ocf": { + "ocfDeviceType": "x.com.st.d.steamcloset", + "name": "[airdresser] Samsung", + "specVersion": "core.1.1.0", + "verticalDomainSpecVersion": "res.1.1.0,sh.1.1.0", + "manufacturerName": "Samsung Electronics", + "modelNumber": "DA_DF_TP2_20_COMMON|20299141|3801010200151107020100FF00000000", + "platformVersion": "DAWIT 2.0", + "platformOS": "TizenRT 2.0 + IPv6", + "hwVersion": "MediaTek", + "firmwareVersion": "DA_DF_TP2_20_COMMON_30230807", + "vendorId": "DA-WM-SC-000001", + "vendorResourceClientServerVersion": "MediaTek Release 2.211214.1", + "lastSignupTime": "2023-06-23T16:00:36.793123Z", + "transferCandidate": false, + "additionalAuthCodeRequired": false + }, + "type": "OCF", + "restrictionTier": 0, + "allowed": [], + "executionContext": "CLOUD" + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/snapshots/test_binary_sensor.ambr b/tests/components/smartthings/snapshots/test_binary_sensor.ambr index dcef62cb266..1d4222292a0 100644 --- a/tests/components/smartthings/snapshots/test_binary_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_binary_sensor.ambr @@ -903,6 +903,148 @@ 'state': 'off', }) # --- +# name: test_all_entities[da_wm_sc_000001][binary_sensor.airdresser_child_lock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.airdresser_child_lock', + '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': 'Child lock', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': 'b93211bf-9d96-bd21-3b2f-964fcc87f5cc_main_samsungce.kidsLock_lockState_lockState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_sc_000001][binary_sensor.airdresser_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'AirDresser Child lock', + }), + 'context': , + 'entity_id': 'binary_sensor.airdresser_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[da_wm_sc_000001][binary_sensor.airdresser_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.airdresser_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'b93211bf-9d96-bd21-3b2f-964fcc87f5cc_main_switch_switch_switch', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_sc_000001][binary_sensor.airdresser_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'AirDresser Power', + }), + 'context': , + 'entity_id': 'binary_sensor.airdresser_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[da_wm_sc_000001][binary_sensor.airdresser_remote_control-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.airdresser_remote_control', + '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': 'Remote control', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'remote_control', + 'unique_id': 'b93211bf-9d96-bd21-3b2f-964fcc87f5cc_main_remoteControlStatus_remoteControlEnabled_remoteControlEnabled', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_sc_000001][binary_sensor.airdresser_remote_control-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'AirDresser Remote control', + }), + 'context': , + 'entity_id': 'binary_sensor.airdresser_remote_control', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_all_entities[da_wm_wd_000001][binary_sensor.dryer_child_lock-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/smartthings/snapshots/test_init.ambr b/tests/components/smartthings/snapshots/test_init.ambr index 686b943008d..206584d1068 100644 --- a/tests/components/smartthings/snapshots/test_init.ambr +++ b/tests/components/smartthings/snapshots/test_init.ambr @@ -662,6 +662,39 @@ 'via_device_id': None, }) # --- +# name: test_devices[da_wm_sc_000001] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': 'MediaTek', + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + 'b93211bf-9d96-bd21-3b2f-964fcc87f5cc', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Samsung Electronics', + 'model': 'DA_DF_TP2_20_COMMON', + 'model_id': None, + 'name': 'AirDresser', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': 'DA_DF_TP2_20_COMMON_30230807', + 'via_device_id': None, + }) +# --- # name: test_devices[da_wm_wd_000001] DeviceRegistryEntrySnapshot({ 'area_id': 'theater', diff --git a/tests/components/smartthings/snapshots/test_select.ambr b/tests/components/smartthings/snapshots/test_select.ambr index 867eb96c048..06185e09547 100644 --- a/tests/components/smartthings/snapshots/test_select.ambr +++ b/tests/components/smartthings/snapshots/test_select.ambr @@ -57,6 +57,64 @@ 'state': 'stop', }) # --- +# name: test_all_entities[da_wm_sc_000001][select.airdresser-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'stop', + 'run', + 'pause', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.airdresser', + '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': None, + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'operating_state', + 'unique_id': 'b93211bf-9d96-bd21-3b2f-964fcc87f5cc_main_dryerOperatingState_machineState_machineState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_sc_000001][select.airdresser-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'AirDresser', + 'options': list([ + 'stop', + 'run', + 'pause', + ]), + }), + 'context': , + 'entity_id': 'select.airdresser', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'stop', + }) +# --- # name: test_all_entities[da_wm_wd_000001][select.dryer-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index fbd95649f99..416a3d15947 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -4703,6 +4703,473 @@ 'state': '0.0', }) # --- +# name: test_all_entities[da_wm_sc_000001][sensor.airdresser_completion_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.airdresser_completion_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Completion time', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'completion_time', + 'unique_id': 'b93211bf-9d96-bd21-3b2f-964fcc87f5cc_main_dryerOperatingState_completionTime_completionTime', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_sc_000001][sensor.airdresser_completion_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'AirDresser Completion time', + }), + 'context': , + 'entity_id': 'sensor.airdresser_completion_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2025-02-11T09:00:17+00:00', + }) +# --- +# name: test_all_entities[da_wm_sc_000001][sensor.airdresser_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.airdresser_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'b93211bf-9d96-bd21-3b2f-964fcc87f5cc_main_powerConsumptionReport_powerConsumption_energy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_wm_sc_000001][sensor.airdresser_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'AirDresser Energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.airdresser_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '207.5', + }) +# --- +# name: test_all_entities[da_wm_sc_000001][sensor.airdresser_energy_difference-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.airdresser_energy_difference', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy difference', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_difference', + 'unique_id': 'b93211bf-9d96-bd21-3b2f-964fcc87f5cc_main_powerConsumptionReport_powerConsumption_deltaEnergy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_wm_sc_000001][sensor.airdresser_energy_difference-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'AirDresser Energy difference', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.airdresser_energy_difference', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_wm_sc_000001][sensor.airdresser_energy_saved-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.airdresser_energy_saved', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy saved', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_saved', + 'unique_id': 'b93211bf-9d96-bd21-3b2f-964fcc87f5cc_main_powerConsumptionReport_powerConsumption_energySaved_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_wm_sc_000001][sensor.airdresser_energy_saved-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'AirDresser Energy saved', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.airdresser_energy_saved', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_wm_sc_000001][sensor.airdresser_job_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'cooling', + 'delay_wash', + 'drying', + 'finished', + 'none', + 'refreshing', + 'weight_sensing', + 'wrinkle_prevent', + 'dehumidifying', + 'ai_drying', + 'sanitizing', + 'internal_care', + 'freeze_protection', + 'continuous_dehumidifying', + 'thawing_frozen_inside', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.airdresser_job_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Job state', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'dryer_job_state', + 'unique_id': 'b93211bf-9d96-bd21-3b2f-964fcc87f5cc_main_dryerOperatingState_dryerJobState_dryerJobState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_sc_000001][sensor.airdresser_job_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'AirDresser Job state', + 'options': list([ + 'cooling', + 'delay_wash', + 'drying', + 'finished', + 'none', + 'refreshing', + 'weight_sensing', + 'wrinkle_prevent', + 'dehumidifying', + 'ai_drying', + 'sanitizing', + 'internal_care', + 'freeze_protection', + 'continuous_dehumidifying', + 'thawing_frozen_inside', + ]), + }), + 'context': , + 'entity_id': 'sensor.airdresser_job_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'none', + }) +# --- +# name: test_all_entities[da_wm_sc_000001][sensor.airdresser_machine_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'pause', + 'run', + 'stop', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.airdresser_machine_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Machine state', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'dryer_machine_state', + 'unique_id': 'b93211bf-9d96-bd21-3b2f-964fcc87f5cc_main_dryerOperatingState_machineState_machineState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_sc_000001][sensor.airdresser_machine_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'AirDresser Machine state', + 'options': list([ + 'pause', + 'run', + 'stop', + ]), + }), + 'context': , + 'entity_id': 'sensor.airdresser_machine_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'stop', + }) +# --- +# name: test_all_entities[da_wm_sc_000001][sensor.airdresser_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.airdresser_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'b93211bf-9d96-bd21-3b2f-964fcc87f5cc_main_powerConsumptionReport_powerConsumption_power_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_wm_sc_000001][sensor.airdresser_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'AirDresser Power', + 'power_consumption_end': '2025-02-11T08:21:17Z', + 'power_consumption_start': '2025-02-10T22:51:59Z', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.airdresser_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_all_entities[da_wm_sc_000001][sensor.airdresser_power_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.airdresser_power_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power energy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_energy', + 'unique_id': 'b93211bf-9d96-bd21-3b2f-964fcc87f5cc_main_powerConsumptionReport_powerConsumption_powerEnergy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_wm_sc_000001][sensor.airdresser_power_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'AirDresser Power energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.airdresser_power_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- # name: test_all_entities[da_wm_wd_000001][sensor.dryer_completion_time-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/smartthings/snapshots/test_switch.ambr b/tests/components/smartthings/snapshots/test_switch.ambr index d84327f8b70..44d0388b72e 100644 --- a/tests/components/smartthings/snapshots/test_switch.ambr +++ b/tests/components/smartthings/snapshots/test_switch.ambr @@ -281,6 +281,53 @@ 'state': 'off', }) # --- +# name: test_all_entities[da_wm_sc_000001][switch.airdresser-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.airdresser', + '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': None, + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'b93211bf-9d96-bd21-3b2f-964fcc87f5cc_main_switch_switch_switch', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_sc_000001][switch.airdresser-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'AirDresser', + }), + 'context': , + 'entity_id': 'switch.airdresser', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_all_entities[da_wm_wd_000001][switch.dryer-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 0de3549e6ea6ad81f958f7492cd4aa2aa577da9b Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Wed, 26 Mar 2025 15:20:08 +0100 Subject: [PATCH 3092/3148] Move QoS setting to shared device properties in MQTT device subentries configuration (#141369) * Move QoS setting to shared device properties in MQTT device subentries configuration * Use kwargs for validate_user_input helper --- homeassistant/components/mqtt/config_flow.py | 84 ++++++++++++-------- homeassistant/components/mqtt/entity.py | 2 + homeassistant/components/mqtt/models.py | 7 ++ homeassistant/components/mqtt/strings.json | 17 +++- tests/components/mqtt/common.py | 9 +-- tests/components/mqtt/test_config_flow.py | 21 ++--- tests/components/mqtt/test_mixins.py | 44 +++++++++- 7 files changed, 124 insertions(+), 60 deletions(-) diff --git a/homeassistant/components/mqtt/config_flow.py b/homeassistant/components/mqtt/config_flow.py index acdc225a59a..0352c5b5f58 100644 --- a/homeassistant/components/mqtt/config_flow.py +++ b/homeassistant/components/mqtt/config_flow.py @@ -134,6 +134,7 @@ from .const import ( DEFAULT_PORT, DEFAULT_PREFIX, DEFAULT_PROTOCOL, + DEFAULT_QOS, DEFAULT_TRANSPORT, DEFAULT_WILL, DEFAULT_WS_PATH, @@ -368,10 +369,6 @@ COMMON_ENTITY_FIELDS = { CONF_ENTITY_PICTURE: PlatformField(TEXT_SELECTOR, False, cv.url, "invalid_url"), } -COMMON_MQTT_FIELDS = { - CONF_QOS: PlatformField(QOS_SELECTOR, False, valid_qos_schema, default=0), -} - PLATFORM_ENTITY_FIELDS = { Platform.NOTIFY.value: {}, Platform.SENSOR.value: { @@ -431,16 +428,17 @@ ENTITY_CONFIG_VALIDATOR: dict[ Platform.SENSOR.value: validate_sensor_platform_config, } -MQTT_DEVICE_SCHEMA = vol.Schema( - { - vol.Required(ATTR_NAME): TEXT_SELECTOR, - vol.Optional(ATTR_SW_VERSION): TEXT_SELECTOR, - vol.Optional(ATTR_HW_VERSION): TEXT_SELECTOR, - vol.Optional(ATTR_MODEL): TEXT_SELECTOR, - vol.Optional(ATTR_MODEL_ID): TEXT_SELECTOR, - vol.Optional(ATTR_CONFIGURATION_URL): TEXT_SELECTOR, - } -) +MQTT_DEVICE_PLATFORM_FIELDS = { + ATTR_NAME: PlatformField(TEXT_SELECTOR, False, str), + ATTR_SW_VERSION: PlatformField(TEXT_SELECTOR, False, str), + ATTR_HW_VERSION: PlatformField(TEXT_SELECTOR, False, str), + ATTR_MODEL: PlatformField(TEXT_SELECTOR, False, str), + ATTR_MODEL_ID: PlatformField(TEXT_SELECTOR, False, str), + ATTR_CONFIGURATION_URL: PlatformField(TEXT_SELECTOR, False, cv.url, "invalid_url"), + CONF_QOS: PlatformField( + QOS_SELECTOR, False, int, default=DEFAULT_QOS, section="mqtt_settings" + ), +} REAUTH_SCHEMA = vol.Schema( { @@ -527,7 +525,8 @@ def calculate_merged_config( def validate_user_input( user_input: dict[str, Any], data_schema_fields: dict[str, PlatformField], - component_data: dict[str, Any] | None, + *, + component_data: dict[str, Any] | None = None, config_validator: Callable[[dict[str, Any]], dict[str, str]] | None = None, ) -> tuple[dict[str, Any], dict[str, str]]: """Validate user input.""" @@ -566,11 +565,21 @@ def data_schema_from_fields( reconfig: bool, component_data: dict[str, Any] | None = None, user_input: dict[str, Any] | None = None, + device_data: MqttDeviceData | None = None, ) -> vol.Schema: - """Generate custom data schema from platform fields.""" - component_data_with_user_input = deepcopy(component_data) + """Generate custom data schema from platform fields or device data.""" + if device_data is not None: + component_data_with_user_input: dict[str, Any] | None = dict(device_data) + if TYPE_CHECKING: + assert component_data_with_user_input is not None + component_data_with_user_input.update( + component_data_with_user_input.pop("mqtt_settings", {}) + ) + else: + component_data_with_user_input = deepcopy(component_data) if component_data_with_user_input is not None and user_input is not None: component_data_with_user_input |= user_input + sections: dict[str | None, None] = { field_details.section: None for field_details in data_schema_fields.values() } @@ -1221,17 +1230,26 @@ class MQTTSubentryFlowHandler(ConfigSubentryFlow): self, user_input: dict[str, Any] | None = None ) -> SubentryFlowResult: """Add a new MQTT device.""" - errors: dict[str, str] = {} - validate_field("configuration_url", cv.url, user_input, errors, "invalid_url") - if not errors and user_input is not None: - self._subentry_data[CONF_DEVICE] = cast(MqttDeviceData, user_input) - if self.source == SOURCE_RECONFIGURE: - return await self.async_step_summary_menu() - return await self.async_step_entity() - + errors: dict[str, Any] = {} + device_data = self._subentry_data[CONF_DEVICE] + data_schema = data_schema_from_fields( + MQTT_DEVICE_PLATFORM_FIELDS, + device_data=device_data, + reconfig=True, + ) + if user_input is not None: + merged_user_input, errors = validate_user_input( + user_input, MQTT_DEVICE_PLATFORM_FIELDS + ) + if not errors: + self._subentry_data[CONF_DEVICE] = cast( + MqttDeviceData, merged_user_input + ) + if self.source == SOURCE_RECONFIGURE: + return await self.async_step_summary_menu() + return await self.async_step_entity() data_schema = self.add_suggested_values_to_schema( - MQTT_DEVICE_SCHEMA, - self._subentry_data[CONF_DEVICE] if user_input is None else user_input, + data_schema, device_data if user_input is None else user_input ) return self.async_show_form( step_id=CONF_DEVICE, @@ -1257,7 +1275,7 @@ class MQTTSubentryFlowHandler(ConfigSubentryFlow): data_schema = data_schema_from_fields(data_schema_fields, reconfig=reconfig) if user_input is not None: merged_user_input, errors = validate_user_input( - user_input, data_schema_fields, component_data + user_input, data_schema_fields, component_data=component_data ) if not errors: if self._component_id is None: @@ -1357,8 +1375,8 @@ class MQTTSubentryFlowHandler(ConfigSubentryFlow): merged_user_input, errors = validate_user_input( user_input, data_schema_fields, - component_data, - ENTITY_CONFIG_VALIDATOR[platform], + component_data=component_data, + config_validator=ENTITY_CONFIG_VALIDATOR[platform], ) if not errors: self.update_component_fields(data_schema_fields, merged_user_input) @@ -1395,7 +1413,7 @@ class MQTTSubentryFlowHandler(ConfigSubentryFlow): assert self._component_id is not None component_data = self._subentry_data["components"][self._component_id] platform = component_data[CONF_PLATFORM] - data_schema_fields = PLATFORM_MQTT_FIELDS[platform] | COMMON_MQTT_FIELDS + data_schema_fields = PLATFORM_MQTT_FIELDS[platform] data_schema = data_schema_from_fields( data_schema_fields, reconfig=bool( @@ -1408,8 +1426,8 @@ class MQTTSubentryFlowHandler(ConfigSubentryFlow): merged_user_input, errors = validate_user_input( user_input, data_schema_fields, - component_data, - ENTITY_CONFIG_VALIDATOR[platform], + component_data=component_data, + config_validator=ENTITY_CONFIG_VALIDATOR[platform], ) if not errors: self.update_component_fields(data_schema_fields, merged_user_input) diff --git a/homeassistant/components/mqtt/entity.py b/homeassistant/components/mqtt/entity.py index 5fdcbea2e70..8446f9041c9 100644 --- a/homeassistant/components/mqtt/entity.py +++ b/homeassistant/components/mqtt/entity.py @@ -300,6 +300,7 @@ def async_setup_entity_entry_helper( availability_config = subentry_data.get("availability", {}) subentry_entities: list[Entity] = [] device_config = subentry_data["device"].copy() + device_mqtt_options = device_config.pop("mqtt_settings", {}) device_config["identifiers"] = config_subentry_id for component_id, component_data in subentry_data["components"].items(): if component_data["platform"] != domain: @@ -311,6 +312,7 @@ def async_setup_entity_entry_helper( component_config[CONF_DEVICE] = device_config component_config.pop("platform") component_config.update(availability_config) + component_config.update(device_mqtt_options) try: config = platform_schema_modern(component_config) diff --git a/homeassistant/components/mqtt/models.py b/homeassistant/components/mqtt/models.py index bcfe94bbd58..8a42797b0f2 100644 --- a/homeassistant/components/mqtt/models.py +++ b/homeassistant/components/mqtt/models.py @@ -420,6 +420,12 @@ class MqttComponentConfig: discovery_payload: MQTTDiscoveryPayload +class DeviceMqttOptions(TypedDict, total=False): + """Hold the shared MQTT specific options for an MQTT device.""" + + qos: int + + class MqttDeviceData(TypedDict, total=False): """Hold the data for an MQTT device.""" @@ -430,6 +436,7 @@ class MqttDeviceData(TypedDict, total=False): hw_version: str model: str model_id: str + mqtt_settings: DeviceMqttOptions class MqttAvailabilityData(TypedDict, total=False): diff --git a/homeassistant/components/mqtt/strings.json b/homeassistant/components/mqtt/strings.json index 9aa1522915f..e44a6c0d44a 100644 --- a/homeassistant/components/mqtt/strings.json +++ b/homeassistant/components/mqtt/strings.json @@ -150,6 +150,17 @@ "hw_version": "The hardware version of the device. E.g. 'v1.0 rev a'.", "model": "E.g. 'Cleanmaster Pro'.", "model_id": "E.g. '123NK2PRO'." + }, + "sections": { + "mqtt_settings": { + "name": "MQTT Settings", + "data": { + "qos": "QoS" + }, + "data_description": { + "qos": "The QoS value the device's entities should use." + } + } } }, "summary_menu": { @@ -235,8 +246,7 @@ "value_template": "Value template", "last_reset_value_template": "Last reset value template", "force_update": "Force update", - "retain": "Retain", - "qos": "QoS" + "retain": "Retain" }, "data_description": { "command_topic": "The publishing topic that will be used to control the {platform} entity. [Learn more.]({url}#command_topic)", @@ -245,8 +255,7 @@ "value_template": "Defines a [template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract the {platform} entity value.", "last_reset_value_template": "Defines a [template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract the last reset. When Last reset template is set, the State class option must be Total. [Learn more.]({url}#last_reset_value_template)", "force_update": "Sends update events even if the value hasn’t changed. Useful if you want to have meaningful value graphs in history. [Learn more.]({url}#force_update)", - "retain": "Select if values published by the {platform} entity should be retained at the MQTT broker.", - "qos": "The QoS value {platform} entity should use." + "retain": "Select if values published by the {platform} entity should be retained at the MQTT broker." }, "sections": { "advanced_settings": { diff --git a/tests/components/mqtt/common.py b/tests/components/mqtt/common.py index aad71fbc26e..372d1354e85 100644 --- a/tests/components/mqtt/common.py +++ b/tests/components/mqtt/common.py @@ -70,7 +70,6 @@ MOCK_SUBENTRY_NOTIFY_COMPONENT1 = { "363a7ecad6be4a19b939a016ea93e994": { "platform": "notify", "name": "Milkman alert", - "qos": 0, "command_topic": "test-topic", "command_template": "{{ value }}", "entity_picture": "https://example.com/363a7ecad6be4a19b939a016ea93e994", @@ -81,7 +80,6 @@ MOCK_SUBENTRY_NOTIFY_COMPONENT2 = { "6494827dac294fa0827c54b02459d309": { "platform": "notify", "name": "The second notifier", - "qos": 0, "command_topic": "test-topic2", "entity_picture": "https://example.com/6494827dac294fa0827c54b02459d309", }, @@ -89,7 +87,6 @@ MOCK_SUBENTRY_NOTIFY_COMPONENT2 = { MOCK_SUBENTRY_NOTIFY_COMPONENT_NO_NAME = { "5269352dd9534c908d22812ea5d714cd": { "platform": "notify", - "qos": 0, "command_topic": "test-topic", "command_template": "{{ value }}", "entity_picture": "https://example.com/5269352dd9534c908d22812ea5d714cd", @@ -102,7 +99,6 @@ MOCK_SUBENTRY_SENSOR_COMPONENT = { "platform": "sensor", "name": "Energy", "device_class": "enum", - "qos": 1, "state_topic": "test-topic", "options": ["low", "medium", "high"], "expire_after": 30, @@ -117,7 +113,6 @@ MOCK_SUBENTRY_SENSOR_COMPONENT_STATE_CLASS = { "state_class": "measurement", "state_topic": "test-topic", "entity_picture": "https://example.com/a0f85790a95d4889924602effff06b6e", - "qos": 0, }, } MOCK_SUBENTRY_SENSOR_COMPONENT_LAST_RESET = { @@ -128,7 +123,6 @@ MOCK_SUBENTRY_SENSOR_COMPONENT_LAST_RESET = { "last_reset_value_template": "{{ value_json.value }}", "state_topic": "test-topic", "entity_picture": "https://example.com/e9261f6feed443e7b7d5f3fbe2a47412", - "qos": 0, }, } @@ -139,7 +133,6 @@ MOCK_SUBENTRY_LIGHT_COMPONENT = { "8131babc5e8d4f44b82e0761d39091a2": { "platform": "light", "name": "Test light", - "qos": 1, "command_topic": "test-topic4", "schema": "basic", "entity_picture": "https://example.com/8131babc5e8d4f44b82e0761d39091a2", @@ -149,7 +142,6 @@ MOCK_SUBENTRY_NOTIFY_BAD_SCHEMA = { "b10b531e15244425a74bb0abb1e9d2c6": { "platform": "notify", "name": "Test", - "qos": 1, "command_topic": "bad#topic", }, } @@ -183,6 +175,7 @@ MOCK_NOTIFY_SUBENTRY_DATA_SINGLE = { "model": "Model XL", "model_id": "mn002", "configuration_url": "https://example.com", + "mqtt_settings": {"qos": 1}, }, "components": MOCK_SUBENTRY_NOTIFY_COMPONENT1, } diff --git a/tests/components/mqtt/test_config_flow.py b/tests/components/mqtt/test_config_flow.py index 266be761a91..a20fa4aeec6 100644 --- a/tests/components/mqtt/test_config_flow.py +++ b/tests/components/mqtt/test_config_flow.py @@ -2616,6 +2616,7 @@ async def test_migrate_of_incompatible_config_entry( @pytest.mark.parametrize( ( "config_subentries_data", + "mock_device_user_input", "mock_entity_user_input", "mock_entity_details_user_input", "mock_entity_details_failed_user_input", @@ -2626,13 +2627,13 @@ async def test_migrate_of_incompatible_config_entry( [ ( MOCK_NOTIFY_SUBENTRY_DATA_SINGLE, + {"name": "Milk notifier", "mqtt_settings": {"qos": 1}}, {"name": "Milkman alert"}, None, None, { "command_topic": "test-topic", "command_template": "{{ value }}", - "qos": 0, "retain": False, }, ( @@ -2645,13 +2646,13 @@ async def test_migrate_of_incompatible_config_entry( ), ( MOCK_NOTIFY_SUBENTRY_DATA_NO_NAME, + {"name": "Milk notifier", "mqtt_settings": {"qos": 0}}, {}, None, None, { "command_topic": "test-topic", "command_template": "{{ value }}", - "qos": 0, "retain": False, }, ( @@ -2664,6 +2665,7 @@ async def test_migrate_of_incompatible_config_entry( ), ( MOCK_SENSOR_SUBENTRY_DATA_SINGLE, + {"name": "Test sensor", "mqtt_settings": {"qos": 0}}, {"name": "Energy"}, {"device_class": "enum", "options": ["low", "medium", "high"]}, ( @@ -2708,7 +2710,6 @@ async def test_migrate_of_incompatible_config_entry( "state_topic": "test-topic", "value_template": "{{ value_json.value }}", "advanced_settings": {"expire_after": 30}, - "qos": 1, }, ( ( @@ -2720,6 +2721,7 @@ async def test_migrate_of_incompatible_config_entry( ), ( MOCK_SENSOR_SUBENTRY_DATA_SINGLE_STATE_CLASS, + {"name": "Test sensor", "mqtt_settings": {"qos": 0}}, {"name": "Energy"}, { "state_class": "measurement", @@ -2743,6 +2745,7 @@ async def test_subentry_configflow( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator, config_subentries_data: dict[str, Any], + mock_device_user_input: dict[str, Any], mock_entity_user_input: dict[str, Any], mock_entity_details_user_input: dict[str, Any], mock_entity_details_failed_user_input: tuple[ @@ -2753,7 +2756,7 @@ async def test_subentry_configflow( entity_name: str, ) -> None: """Test the subentry ConfigFlow.""" - device_name = config_subentries_data["device"]["name"] + device_name = mock_device_user_input["name"] component = next(iter(config_subentries_data["components"].values())) await mqtt_mock_entry() @@ -2780,14 +2783,7 @@ async def test_subentry_configflow( result = await hass.config_entries.subentries.async_configure( result["flow_id"], - user_input={ - "name": device_name, - "sw_version": "1.0", - "hw_version": "2.1 rev a", - "model": "Model XL", - "model_id": "mn002", - "configuration_url": "https://example.com", - }, + user_input=mock_device_user_input, ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "entity" @@ -3471,7 +3467,6 @@ async def test_subentry_reconfigure_edit_entity_reset_fields( }, { "command_topic": "test-topic2", - "qos": 0, }, ) ], diff --git a/tests/components/mqtt/test_mixins.py b/tests/components/mqtt/test_mixins.py index 2049dec0437..fa30283962b 100644 --- a/tests/components/mqtt/test_mixins.py +++ b/tests/components/mqtt/test_mixins.py @@ -1,7 +1,7 @@ """The tests for shared code of the MQTT platform.""" from typing import Any -from unittest.mock import patch +from unittest.mock import call, patch import pytest @@ -21,7 +21,11 @@ from homeassistant.helpers import ( ) from homeassistant.util import slugify -from .common import MOCK_SUBENTRY_DATA_BAD_COMPONENT_SCHEMA, MOCK_SUBENTRY_DATA_SET_MIX +from .common import ( + MOCK_NOTIFY_SUBENTRY_DATA_SINGLE, + MOCK_SUBENTRY_DATA_BAD_COMPONENT_SCHEMA, + MOCK_SUBENTRY_DATA_SET_MIX, +) from tests.common import MockConfigEntry, async_capture_events, async_fire_mqtt_message from tests.typing import MqttMockHAClientGenerator @@ -547,3 +551,39 @@ async def test_loading_subentry_with_bad_component_schema( "Schema violation occurred when trying to set up entity from subentry" in caplog.text ) + + +@pytest.mark.parametrize( + "mqtt_config_subentries_data", + [ + ( + ConfigSubentryData( + data=MOCK_NOTIFY_SUBENTRY_DATA_SINGLE, + subentry_type="device", + title="Mock subentry", + ), + ) + ], +) +async def test_qos_on_mqt_device_from_subentry( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + mqtt_config_subentries_data: tuple[dict[str, Any]], + device_registry: dr.DeviceRegistry, +) -> None: + """Test QoS is set correctly on entities from MQTT device.""" + mqtt_mock = await mqtt_mock_entry() + entry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] + subentry_id = next(iter(entry.subentries)) + # Each subentry has one device + device = device_registry.async_get_device({("mqtt", subentry_id)}) + assert device is not None + assert hass.states.get("notify.milk_notifier_milkman_alert") is not None + await hass.services.async_call( + "notify", + "send_message", + {"entity_id": "notify.milk_notifier_milkman_alert", "message": "Test message"}, + ) + await hass.async_block_till_done() + assert len(mqtt_mock.async_publish.mock_calls) == 1 + mqtt_mock.async_publish.mock_calls[0] = call("test-topic", "Test message", 1, False) From 1622638f1075f341e341e1b632f481494a7b93d4 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 26 Mar 2025 15:21:38 +0100 Subject: [PATCH 3093/3148] Update mypy-dev to 1.16.0a7 (#141472) --- .../components/alexa/capabilities.py | 2 +- homeassistant/components/everlights/light.py | 12 ++++++----- homeassistant/components/fints/sensor.py | 4 ++-- .../components/home_connect/light.py | 6 ++++-- homeassistant/components/led_ble/light.py | 4 ++-- homeassistant/components/light/__init__.py | 2 +- homeassistant/components/melcloud/climate.py | 4 ++-- homeassistant/components/philips_js/light.py | 4 ++-- homeassistant/components/switchbot/light.py | 6 ++++-- .../components/tradfri/config_flow.py | 4 ++-- homeassistant/components/zwave_js/light.py | 21 ++++++++++++++++--- requirements_test.txt | 2 +- 12 files changed, 46 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/alexa/capabilities.py b/homeassistant/components/alexa/capabilities.py index e70055c20b1..897037987a7 100644 --- a/homeassistant/components/alexa/capabilities.py +++ b/homeassistant/components/alexa/capabilities.py @@ -1438,7 +1438,7 @@ class AlexaModeController(AlexaCapability): # Fan preset_mode if self.instance == f"{fan.DOMAIN}.{fan.ATTR_PRESET_MODE}": mode = self.entity.attributes.get(fan.ATTR_PRESET_MODE, None) - if mode in self.entity.attributes.get(fan.ATTR_PRESET_MODES, None): + if mode in self.entity.attributes.get(fan.ATTR_PRESET_MODES, ()): return f"{fan.ATTR_PRESET_MODE}.{mode}" # Humidifier mode diff --git a/homeassistant/components/everlights/light.py b/homeassistant/components/everlights/light.py index ae159d77240..c153f01e83c 100644 --- a/homeassistant/components/everlights/light.py +++ b/homeassistant/components/everlights/light.py @@ -4,7 +4,7 @@ from __future__ import annotations from datetime import timedelta import logging -from typing import Any +from typing import Any, cast import pyeverlights import voluptuous as vol @@ -84,7 +84,7 @@ class EverLightsLight(LightEntity): api: pyeverlights.EverLights, channel: int, status: dict[str, Any], - effects, + effects: list[str], ) -> None: """Initialize the light.""" self._api = api @@ -106,8 +106,10 @@ class EverLightsLight(LightEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Turn the light on.""" - hs_color = kwargs.get(ATTR_HS_COLOR, self._attr_hs_color) - brightness = kwargs.get(ATTR_BRIGHTNESS, self._attr_brightness) + hs_color = cast( + tuple[float, float], kwargs.get(ATTR_HS_COLOR, self._attr_hs_color) + ) + brightness = cast(int, kwargs.get(ATTR_BRIGHTNESS, self._attr_brightness)) effect = kwargs.get(ATTR_EFFECT) if effect is not None: @@ -116,7 +118,7 @@ class EverLightsLight(LightEntity): rgb = color_int_to_rgb(colors[0]) hsv = color_util.color_RGB_to_hsv(*rgb) hs_color = hsv[:2] - brightness = hsv[2] / 100 * 255 + brightness = round(hsv[2] / 100 * 255) else: rgb = color_util.color_hsv_to_RGB( diff --git a/homeassistant/components/fints/sensor.py b/homeassistant/components/fints/sensor.py index 318325dbb09..f5188d5bf21 100644 --- a/homeassistant/components/fints/sensor.py +++ b/homeassistant/components/fints/sensor.py @@ -5,7 +5,7 @@ from __future__ import annotations from collections import namedtuple from datetime import timedelta import logging -from typing import Any +from typing import Any, cast from fints.client import FinTS3PinTanClient from fints.models import SEPAAccount @@ -73,7 +73,7 @@ def setup_platform( credentials = BankCredentials( config[CONF_BIN], config[CONF_USERNAME], config[CONF_PIN], config[CONF_URL] ) - fints_name = config.get(CONF_NAME, config[CONF_BIN]) + fints_name = cast(str, config.get(CONF_NAME, config[CONF_BIN])) account_config = { acc[CONF_ACCOUNT]: acc[CONF_NAME] for acc in config[CONF_ACCOUNTS] diff --git a/homeassistant/components/home_connect/light.py b/homeassistant/components/home_connect/light.py index 707620f099a..de55a60bd43 100644 --- a/homeassistant/components/home_connect/light.py +++ b/homeassistant/components/home_connect/light.py @@ -207,11 +207,13 @@ class HomeConnectLight(HomeConnectEntity, LightEntity): brightness = round( color_util.brightness_to_value( self._brightness_scale, - kwargs.get(ATTR_BRIGHTNESS, self._attr_brightness), + cast(int, kwargs.get(ATTR_BRIGHTNESS, self._attr_brightness)), ) ) - hs_color = kwargs.get(ATTR_HS_COLOR, self._attr_hs_color) + hs_color = cast( + tuple[float, float], kwargs.get(ATTR_HS_COLOR, self._attr_hs_color) + ) rgb = color_util.color_hsv_to_RGB(hs_color[0], hs_color[1], brightness) hex_val = color_util.color_rgb_to_hex(*rgb) diff --git a/homeassistant/components/led_ble/light.py b/homeassistant/components/led_ble/light.py index 14f2f228e13..2facda734d5 100644 --- a/homeassistant/components/led_ble/light.py +++ b/homeassistant/components/led_ble/light.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import Any +from typing import Any, cast from led_ble import LEDBLE @@ -83,7 +83,7 @@ class LEDBLEEntity(CoordinatorEntity[DataUpdateCoordinator[None]], LightEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Instruct the light to turn on.""" - brightness = kwargs.get(ATTR_BRIGHTNESS, self.brightness) + brightness = cast(int, kwargs.get(ATTR_BRIGHTNESS, self.brightness)) if effect := kwargs.get(ATTR_EFFECT): await self._async_set_effect(effect, brightness) return diff --git a/homeassistant/components/light/__init__.py b/homeassistant/components/light/__init__.py index 637ba45c7d9..7b548533058 100644 --- a/homeassistant/components/light/__init__.py +++ b/homeassistant/components/light/__init__.py @@ -465,7 +465,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: ): params.pop(_DEPRECATED_ATTR_COLOR_TEMP.value) color_temp = params.pop(ATTR_COLOR_TEMP_KELVIN) - brightness = params.get(ATTR_BRIGHTNESS, light.brightness) + brightness = cast(int, params.get(ATTR_BRIGHTNESS, light.brightness)) params[ATTR_RGBWW_COLOR] = color_util.color_temperature_to_rgbww( color_temp, brightness, diff --git a/homeassistant/components/melcloud/climate.py b/homeassistant/components/melcloud/climate.py index 9c2ee60b12c..682a28ea080 100644 --- a/homeassistant/components/melcloud/climate.py +++ b/homeassistant/components/melcloud/climate.py @@ -3,7 +3,7 @@ from __future__ import annotations from datetime import timedelta -from typing import Any +from typing import Any, cast from pymelcloud import DEVICE_TYPE_ATA, DEVICE_TYPE_ATW, AtaDevice, AtwDevice import pymelcloud.ata_device as ata @@ -236,7 +236,7 @@ class AtaDeviceClimate(MelCloudClimate): set_dict: dict[str, Any] = {} if ATTR_HVAC_MODE in kwargs: self._apply_set_hvac_mode( - kwargs.get(ATTR_HVAC_MODE, self.hvac_mode), set_dict + cast(HVACMode, kwargs.get(ATTR_HVAC_MODE, self.hvac_mode)), set_dict ) if ATTR_TEMPERATURE in kwargs: diff --git a/homeassistant/components/philips_js/light.py b/homeassistant/components/philips_js/light.py index bf15292335e..87e3323a30c 100644 --- a/homeassistant/components/philips_js/light.py +++ b/homeassistant/components/philips_js/light.py @@ -3,7 +3,7 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Any +from typing import Any, cast from haphilipsjs import PhilipsTV from haphilipsjs.typing import AmbilightCurrentConfiguration @@ -328,7 +328,7 @@ class PhilipsTVLightEntity(PhilipsJsEntity, LightEntity): """Turn the bulb on.""" brightness = kwargs.get(ATTR_BRIGHTNESS, self.brightness) hs_color = kwargs.get(ATTR_HS_COLOR, self.hs_color) - attr_effect = kwargs.get(ATTR_EFFECT, self.effect) + attr_effect = cast(str, kwargs.get(ATTR_EFFECT, self.effect)) if not self._tv.on: raise HomeAssistantError("TV is not available") diff --git a/homeassistant/components/switchbot/light.py b/homeassistant/components/switchbot/light.py index 0a2c342ecf0..4b9a7e1b988 100644 --- a/homeassistant/components/switchbot/light.py +++ b/homeassistant/components/switchbot/light.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import Any +from typing import Any, cast from switchbot import ColorMode as SwitchBotColorMode, SwitchbotBaseLight @@ -68,7 +68,9 @@ class SwitchbotLightEntity(SwitchbotEntity, LightEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Instruct the light to turn on.""" - brightness = round(kwargs.get(ATTR_BRIGHTNESS, self.brightness) / 255 * 100) + brightness = round( + cast(int, kwargs.get(ATTR_BRIGHTNESS, self.brightness)) / 255 * 100 + ) if ( self.supported_color_modes diff --git a/homeassistant/components/tradfri/config_flow.py b/homeassistant/components/tradfri/config_flow.py index 9f5b39a9657..f4adb1cc09e 100644 --- a/homeassistant/components/tradfri/config_flow.py +++ b/homeassistant/components/tradfri/config_flow.py @@ -3,7 +3,7 @@ from __future__ import annotations import asyncio -from typing import Any +from typing import Any, cast from uuid import uuid4 from pytradfri import Gateway, RequestError @@ -54,7 +54,7 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): errors: dict[str, str] = {} if user_input is not None: - host = user_input.get(CONF_HOST, self._host) + host = cast(str, user_input.get(CONF_HOST, self._host)) try: auth = await authenticate( self.hass, host, user_input[KEY_SECURITY_CODE] diff --git a/homeassistant/components/zwave_js/light.py b/homeassistant/components/zwave_js/light.py index a610bbcb91e..f60e129cc77 100644 --- a/homeassistant/components/zwave_js/light.py +++ b/homeassistant/components/zwave_js/light.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import Any, cast +from typing import TYPE_CHECKING, Any, cast from zwave_js_server.client import Client as ZwaveClient from zwave_js_server.const import ( @@ -483,7 +483,7 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity): red = multi_color.get(COLOR_SWITCH_COMBINED_RED, red_val.value) green = multi_color.get(COLOR_SWITCH_COMBINED_GREEN, green_val.value) blue = multi_color.get(COLOR_SWITCH_COMBINED_BLUE, blue_val.value) - if None not in (red, green, blue): + if red is not None and green is not None and blue is not None: # convert to HS self._hs_color = color_util.color_RGB_to_hs(red, green, blue) # Light supports color, set color mode to hs @@ -496,7 +496,8 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity): # Calculate color temps based on whites if cold_white or warm_white: self._color_temp = color_util.color_temperature_mired_to_kelvin( - MAX_MIREDS - ((cold_white / 255) * (MAX_MIREDS - MIN_MIREDS)) + MAX_MIREDS + - ((cast(int, cold_white) / 255) * (MAX_MIREDS - MIN_MIREDS)) ) # White channels turned on, set color mode to color_temp self._color_mode = ColorMode.COLOR_TEMP @@ -505,6 +506,13 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity): # only one white channel (warm white) = rgbw support elif red_val and green_val and blue_val and ww_val: white = multi_color.get(COLOR_SWITCH_COMBINED_WARM_WHITE, ww_val.value) + if TYPE_CHECKING: + assert ( + red is not None + and green is not None + and blue is not None + and white is not None + ) self._rgbw_color = (red, green, blue, white) # Light supports rgbw, set color mode to rgbw self._color_mode = ColorMode.RGBW @@ -512,6 +520,13 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity): elif cw_val: self._supports_rgbw = True white = multi_color.get(COLOR_SWITCH_COMBINED_COLD_WHITE, cw_val.value) + if TYPE_CHECKING: + assert ( + red is not None + and green is not None + and blue is not None + and white is not None + ) self._rgbw_color = (red, green, blue, white) # Light supports rgbw, set color mode to rgbw self._color_mode = ColorMode.RGBW diff --git a/requirements_test.txt b/requirements_test.txt index de1de795afe..c7bb9b11b87 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -12,7 +12,7 @@ coverage==7.6.12 freezegun==1.5.1 license-expression==30.4.1 mock-open==1.4.0 -mypy-dev==1.16.0a5 +mypy-dev==1.16.0a7 pre-commit==4.0.0 pydantic==2.10.6 pylint==3.3.6 From 7a4ca6dcdcbca09412f5a83e3deec3f2915140da Mon Sep 17 00:00:00 2001 From: Markus Adrario Date: Wed, 26 Mar 2025 15:46:21 +0100 Subject: [PATCH 3094/3148] Add Homee lock platform (#140893) * Add homee lock platform * finish tests * add locking & unlocking * add PARALLEL_UPDATES * fix review comments * fix test review comment. * fix another review comment --- homeassistant/components/homee/__init__.py | 1 + homeassistant/components/homee/lock.py | 73 ++++++++++ tests/components/homee/fixtures/lock.json | 52 ++++++++ .../components/homee/snapshots/test_lock.ambr | 50 +++++++ tests/components/homee/test_lock.py | 125 ++++++++++++++++++ 5 files changed, 301 insertions(+) create mode 100644 homeassistant/components/homee/lock.py create mode 100644 tests/components/homee/fixtures/lock.json create mode 100644 tests/components/homee/snapshots/test_lock.ambr create mode 100644 tests/components/homee/test_lock.py diff --git a/homeassistant/components/homee/__init__.py b/homeassistant/components/homee/__init__.py index 6158a699302..9fd88ee40aa 100644 --- a/homeassistant/components/homee/__init__.py +++ b/homeassistant/components/homee/__init__.py @@ -19,6 +19,7 @@ PLATFORMS = [ Platform.BUTTON, Platform.COVER, Platform.LIGHT, + Platform.LOCK, Platform.NUMBER, Platform.SELECT, Platform.SENSOR, diff --git a/homeassistant/components/homee/lock.py b/homeassistant/components/homee/lock.py new file mode 100644 index 00000000000..4cfc34e11fe --- /dev/null +++ b/homeassistant/components/homee/lock.py @@ -0,0 +1,73 @@ +"""The Homee lock platform.""" + +from typing import Any + +from pyHomee.const import AttributeChangedBy, AttributeType + +from homeassistant.components.lock import LockEntity +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import HomeeConfigEntry +from .entity import HomeeEntity +from .helpers import get_name_for_enum + +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: HomeeConfigEntry, + async_add_devices: AddConfigEntryEntitiesCallback, +) -> None: + """Add the Homee platform for the lock component.""" + + async_add_devices( + HomeeLock(attribute, config_entry) + for node in config_entry.runtime_data.nodes + for attribute in node.attributes + if (attribute.type == AttributeType.LOCK_STATE and attribute.editable) + ) + + +class HomeeLock(HomeeEntity, LockEntity): + """Representation of a Homee lock.""" + + _attr_name = None + + @property + def is_locked(self) -> bool: + """Return if lock is locked.""" + return self._attribute.current_value == 1.0 + + @property + def is_locking(self) -> bool: + """Return if lock is locking.""" + return self._attribute.target_value > self._attribute.current_value + + @property + def is_unlocking(self) -> bool: + """Return if lock is unlocking.""" + return self._attribute.target_value < self._attribute.current_value + + @property + def changed_by(self) -> str: + """Return by whom or what the lock was last changed.""" + changed_id = str(self._attribute.changed_by_id) + changed_by_name = get_name_for_enum( + AttributeChangedBy, self._attribute.changed_by + ) + if self._attribute.changed_by == AttributeChangedBy.USER: + changed_id = self._entry.runtime_data.get_user_by_id( + self._attribute.changed_by_id + ).username + + return f"{changed_by_name}-{changed_id}" + + async def async_lock(self, **kwargs: Any) -> None: + """Lock specified lock. A code to lock the lock with may be specified.""" + await self.async_set_homee_value(1) + + async def async_unlock(self, **kwargs: Any) -> None: + """Unlock specified lock. A code to unlock the lock with may be specified.""" + await self.async_set_homee_value(0) diff --git a/tests/components/homee/fixtures/lock.json b/tests/components/homee/fixtures/lock.json new file mode 100644 index 00000000000..79fd53e0311 --- /dev/null +++ b/tests/components/homee/fixtures/lock.json @@ -0,0 +1,52 @@ +{ + "id": 1, + "name": "Test Lock", + "profile": 2007, + "image": "default", + "favorite": 0, + "order": 31, + "protocol": 1, + "routing": 0, + "state": 1, + "state_changed": 1711799526, + "added": 1645036891, + "history": 1, + "cube_type": 1, + "note": "", + "services": 3, + "phonetic_name": "", + "owner": 2, + "security": 0, + "attributes": [ + { + "id": 1, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 1, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 1.0, + "unit": "", + "step_value": 1.0, + "editable": 1, + "type": 232, + "state": 1, + "last_changed": 1711897362, + "changed_by": 4, + "changed_by_id": 5, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["toggle"], + "history": { + "day": 35, + "week": 5, + "month": 1, + "stepped": true + } + } + } + ] +} diff --git a/tests/components/homee/snapshots/test_lock.ambr b/tests/components/homee/snapshots/test_lock.ambr new file mode 100644 index 00000000000..d055039cca4 --- /dev/null +++ b/tests/components/homee/snapshots/test_lock.ambr @@ -0,0 +1,50 @@ +# serializer version: 1 +# name: test_lock_snapshot[lock.test_lock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'lock', + 'entity_category': None, + 'entity_id': 'lock.test_lock', + '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': None, + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00055511EECC-1-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_lock_snapshot[lock.test_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'changed_by': 'unknown-5', + 'friendly_name': 'Test Lock', + 'supported_features': , + }), + 'context': , + 'entity_id': 'lock.test_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unlocked', + }) +# --- diff --git a/tests/components/homee/test_lock.py b/tests/components/homee/test_lock.py new file mode 100644 index 00000000000..3e6ff3f8ec6 --- /dev/null +++ b/tests/components/homee/test_lock.py @@ -0,0 +1,125 @@ +"""Test Homee locks.""" + +from unittest.mock import MagicMock, patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.lock import ( + DOMAIN as LOCK_DOMAIN, + SERVICE_LOCK, + SERVICE_UNLOCK, + LockState, +) +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import build_mock_node, setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +async def setup_lock( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_homee: MagicMock +) -> None: + """Setups the integration lock tests.""" + mock_homee.nodes = [build_mock_node("lock.json")] + mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] + await setup_integration(hass, mock_config_entry) + + +@pytest.mark.parametrize( + ("service", "target_value"), + [ + (SERVICE_LOCK, 1), + (SERVICE_UNLOCK, 0), + ], +) +async def test_lock_services( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_homee: MagicMock, + service: str, + target_value: int, +) -> None: + """Test lock services.""" + await setup_lock(hass, mock_config_entry, mock_homee) + + await hass.services.async_call( + LOCK_DOMAIN, + service, + {ATTR_ENTITY_ID: "lock.test_lock"}, + ) + mock_homee.set_value.assert_called_once_with(1, 1, target_value) + + +@pytest.mark.parametrize( + ("target_value", "current_value", "expected"), + [ + (1.0, 1.0, LockState.LOCKED), + (0.0, 0.0, LockState.UNLOCKED), + (1.0, 0.0, LockState.LOCKING), + (0.0, 1.0, LockState.UNLOCKING), + ], +) +async def test_lock_state( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_homee: MagicMock, + target_value: float, + current_value: float, + expected: LockState, +) -> None: + """Test lock state.""" + mock_homee.nodes = [build_mock_node("lock.json")] + mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] + attribute = mock_homee.nodes[0].attributes[0] + attribute.target_value = target_value + attribute.current_value = current_value + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("lock.test_lock").state == expected + + +@pytest.mark.parametrize( + ("attr_changed_by", "changed_by_id", "expected"), + [ + (1, 0, "itself-0"), + (2, 1, "user-testuser"), + (3, 54, "homeegram-54"), + (6, 0, "ai-0"), + ], +) +async def test_lock_changed_by( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_homee: MagicMock, + attr_changed_by: int, + changed_by_id: int, + expected: str, +) -> None: + """Test lock changed by entries.""" + mock_homee.nodes = [build_mock_node("lock.json")] + mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] + mock_homee.get_user_by_id.return_value = MagicMock(username="testuser") + attribute = mock_homee.nodes[0].attributes[0] + attribute.changed_by = attr_changed_by + attribute.changed_by_id = changed_by_id + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("lock.test_lock").attributes["changed_by"] == expected + + +async def test_lock_snapshot( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_homee: MagicMock, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test the lock snapshots.""" + with patch("homeassistant.components.homee.PLATFORMS", [Platform.LOCK]): + await setup_lock(hass, mock_config_entry, mock_homee) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) From 9d63a4981259710838218d743f67e1205f607243 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 26 Mar 2025 16:27:43 +0100 Subject: [PATCH 3095/3148] Update frontend to 20250326.0 (#141481) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index b210fdb6661..b78323488ae 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20250306.0"] + "requirements": ["home-assistant-frontend==20250326.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index d340183bc94..d1e91fd8604 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -38,7 +38,7 @@ habluetooth==3.37.0 hass-nabucasa==0.94.0 hassil==2.2.3 home-assistant-bluetooth==1.13.1 -home-assistant-frontend==20250306.0 +home-assistant-frontend==20250326.0 home-assistant-intents==2025.3.24 httpx==0.28.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index c74bab50d51..2b08f1ec5f9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1157,7 +1157,7 @@ hole==0.8.0 holidays==0.69 # homeassistant.components.frontend -home-assistant-frontend==20250306.0 +home-assistant-frontend==20250326.0 # homeassistant.components.conversation home-assistant-intents==2025.3.24 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 59aca552c40..1ada93fb4e5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -984,7 +984,7 @@ hole==0.8.0 holidays==0.69 # homeassistant.components.frontend -home-assistant-frontend==20250306.0 +home-assistant-frontend==20250326.0 # homeassistant.components.conversation home-assistant-intents==2025.3.24 From 3a1e1684ea4e2f87472df5156d0f39bdf88f913a Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 26 Mar 2025 16:29:02 +0100 Subject: [PATCH 3096/3148] Add power binary sensor for Cooktop in SmartThings (#141482) --- .../components/smartthings/binary_sensor.py | 1 + .../components/smartthings/switch.py | 2 + tests/components/smartthings/conftest.py | 1 + .../device_status/da_ks_cooktop_31001.json | 508 ++++++++++++++++++ .../fixtures/devices/da_ks_cooktop_31001.json | 277 ++++++++++ .../snapshots/test_binary_sensor.ambr | 48 ++ .../smartthings/snapshots/test_init.ambr | 33 ++ .../smartthings/snapshots/test_switch.ambr | 47 ++ 8 files changed, 917 insertions(+) create mode 100644 tests/components/smartthings/fixtures/device_status/da_ks_cooktop_31001.json create mode 100644 tests/components/smartthings/fixtures/devices/da_ks_cooktop_31001.json diff --git a/homeassistant/components/smartthings/binary_sensor.py b/homeassistant/components/smartthings/binary_sensor.py index 3508d174370..bd09f1725d3 100644 --- a/homeassistant/components/smartthings/binary_sensor.py +++ b/homeassistant/components/smartthings/binary_sensor.py @@ -134,6 +134,7 @@ CAPABILITY_TO_SENSORS: dict[ is_on_key="on", category={ Category.CLOTHING_CARE_MACHINE, + Category.COOKTOP, Category.DISHWASHER, Category.DRYER, Category.MICROWAVE, diff --git a/homeassistant/components/smartthings/switch.py b/homeassistant/components/smartthings/switch.py index 968d1e51b6a..dab944bb663 100644 --- a/homeassistant/components/smartthings/switch.py +++ b/homeassistant/components/smartthings/switch.py @@ -188,6 +188,7 @@ class SmartThingsSwitch(SmartThingsEntity, SwitchEntity): MAIN ].manufacturer_category not in { Category.CLOTHING_CARE_MACHINE, + Category.COOKTOP, Category.DRYER, Category.WASHER, Category.MICROWAVE, @@ -231,6 +232,7 @@ class SmartThingsSwitch(SmartThingsEntity, SwitchEntity): MAIN ].manufacturer_category not in { Category.CLOTHING_CARE_MACHINE, + Category.COOKTOP, Category.DRYER, Category.WASHER, Category.MICROWAVE, diff --git a/tests/components/smartthings/conftest.py b/tests/components/smartthings/conftest.py index dfc4bd28227..ef6b6f29011 100644 --- a/tests/components/smartthings/conftest.py +++ b/tests/components/smartthings/conftest.py @@ -117,6 +117,7 @@ def mock_smartthings() -> Generator[AsyncMock]: "da_wm_sc_000001", "da_rvc_normal_000001", "da_ks_microwave_0101x", + "da_ks_cooktop_31001", "da_ks_range_0101x", "da_ks_oven_01061", "hue_color_temperature_bulb", diff --git a/tests/components/smartthings/fixtures/device_status/da_ks_cooktop_31001.json b/tests/components/smartthings/fixtures/device_status/da_ks_cooktop_31001.json new file mode 100644 index 00000000000..5ca8f56fbbf --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/da_ks_cooktop_31001.json @@ -0,0 +1,508 @@ +{ + "components": { + "burner-02": { + "samsungce.surfaceResidualHeat": { + "surfaceResidualHeat": { + "value": "normal", + "timestamp": "2025-03-25T18:18:28.550Z" + } + }, + "samsungce.cooktopHeatingPower": { + "manualLevel": { + "value": 0, + "timestamp": "2025-03-26T05:57:23.203Z" + }, + "heatingMode": { + "value": "manual", + "timestamp": "2025-03-25T18:18:28.550Z" + }, + "manualLevelMin": { + "value": 0, + "timestamp": "2025-03-25T18:18:28.550Z" + }, + "supportedHeatingModes": { + "value": ["manual", "boost", "keepWarm"], + "timestamp": "2025-03-25T18:18:28.550Z" + }, + "manualLevelMax": { + "value": 15, + "timestamp": "2025-03-25T18:18:28.550Z" + } + }, + "samsungce.countDownTimer": { + "startValue": { + "value": 0, + "unit": "min", + "timestamp": "2025-03-25T18:18:28.550Z" + }, + "currentValue": { + "value": 0, + "unit": "min", + "timestamp": "2025-03-25T18:18:28.550Z" + }, + "status": { + "value": "idle", + "timestamp": "2025-03-25T18:18:28.550Z" + } + } + }, + "burner-01": { + "samsungce.surfaceResidualHeat": { + "surfaceResidualHeat": { + "value": "normal", + "timestamp": "2025-03-25T18:18:28.518Z" + } + }, + "samsungce.cooktopHeatingPower": { + "manualLevel": { + "value": 0, + "timestamp": "2025-03-26T05:57:23.203Z" + }, + "heatingMode": { + "value": "manual", + "timestamp": "2025-03-25T18:18:28.518Z" + }, + "manualLevelMin": { + "value": 0, + "timestamp": "2025-03-25T18:18:28.518Z" + }, + "supportedHeatingModes": { + "value": ["manual", "boost", "keepWarm"], + "timestamp": "2025-03-25T18:18:28.518Z" + }, + "manualLevelMax": { + "value": 15, + "timestamp": "2025-03-25T18:18:28.518Z" + } + }, + "samsungce.countDownTimer": { + "startValue": { + "value": 0, + "unit": "min", + "timestamp": "2025-03-25T18:18:28.518Z" + }, + "currentValue": { + "value": 0, + "unit": "min", + "timestamp": "2025-03-25T18:18:28.518Z" + }, + "status": { + "value": "idle", + "timestamp": "2025-03-25T18:18:28.518Z" + } + } + }, + "main": { + "custom.disabledComponents": { + "disabledComponents": { + "value": ["burner-6"], + "timestamp": "2025-03-25T18:18:28.464Z" + } + }, + "custom.userNotification": { + "message": { + "value": null + } + }, + "samsungce.remoteManagementData": { + "reportRawData": { + "value": "AgUBASCgAwAACaEDAAAM4AQAAAAA4QHwAw==", + "timestamp": "2025-03-26T07:27:58.282Z" + }, + "version": { + "value": "CT-31.0001", + "timestamp": "2025-03-25T18:18:28.476Z" + } + }, + "samsungce.deviceIdentification": { + "micomAssayCode": { + "value": "5828", + "timestamp": "2025-03-25T18:18:28.476Z" + }, + "modelName": { + "value": "NZ64B5046GK", + "timestamp": "2025-03-25T18:18:28.476Z" + }, + "serialNumber": { + "value": "B8C878DX900290H", + "timestamp": "2025-03-25T18:18:28.476Z" + }, + "serialNumberExtra": { + "value": "N/A", + "timestamp": "2025-03-25T18:18:28.476Z" + }, + "modelClassificationCode": { + "value": "50000204001611000E00000000000000", + "timestamp": "2025-03-25T18:18:28.476Z" + }, + "description": { + "value": "N/A", + "timestamp": "2025-03-25T18:18:28.476Z" + }, + "releaseYear": { + "value": null + }, + "binaryId": { + "value": "TP2X_DA-KS-COOKTOP-31001", + "timestamp": "2025-03-25T18:18:28.476Z" + } + }, + "switch": { + "switch": { + "value": "off", + "timestamp": "2025-03-26T07:27:58.478Z" + } + }, + "samsungce.errorAndAlarmState": { + "events": { + "value": [], + "timestamp": "2025-03-25T18:18:28.476Z" + } + }, + "samsungce.cooktopFlexZone": { + "flexZones": { + "value": [], + "timestamp": "2025-03-26T05:57:23.671Z" + } + }, + "samsungce.softwareVersion": { + "versions": { + "value": [ + { + "id": "Wifi", + "swType": "Wifi-Application", + "versionNumber": "80001A220811", + "description": "Aug 11 2022 08:38:36, Wifi:ws029_030, STDK : 1.7.4)" + }, + { + "id": "Micom", + "swType": "Micom Software", + "versionNumber": "240617", + "description": "Description for this micom version" + } + ], + "timestamp": "2025-03-25T18:18:28.482Z" + } + }, + "healthCheck": { + "checkInterval": { + "value": null + }, + "healthStatus": { + "value": null + }, + "DeviceWatch-Enroll": { + "value": null + }, + "DeviceWatch-DeviceStatus": { + "value": null + } + }, + "custom.cooktopOperatingState": { + "supportedCooktopOperatingState": { + "value": ["ready", "run", "paused"], + "timestamp": "2025-03-26T07:26:39.690Z" + }, + "cooktopOperatingState": { + "value": "ready", + "timestamp": "2025-03-26T07:27:58.652Z" + } + }, + "samsungce.kitchenDeviceIdentification": { + "regionCode": { + "value": "EU", + "timestamp": "2025-03-25T18:18:28.501Z" + }, + "modelCode": { + "value": "OZ8500B/EU2", + "timestamp": "2025-03-25T18:18:28.501Z" + }, + "fuel": { + "value": null + }, + "type": { + "value": "cooktop", + "timestamp": "2025-03-25T18:18:28.501Z" + }, + "representativeComponent": { + "value": null + } + }, + "samsungce.softwareUpdate": { + "targetModule": { + "value": null + }, + "otnDUID": { + "value": "JHCB2ZD4E2KRY", + "timestamp": "2025-03-25T18:18:28.482Z" + }, + "lastUpdatedDate": { + "value": null + }, + "availableModules": { + "value": [], + "timestamp": "2025-03-25T18:18:28.501Z" + }, + "newVersionAvailable": { + "value": false, + "timestamp": "2025-03-25T18:18:28.501Z" + }, + "operatingState": { + "value": null + }, + "progress": { + "value": null + } + }, + "samsungce.kidsLockControl": { + "lockState": { + "value": "unlocked", + "timestamp": "2025-03-25T18:18:28.476Z" + } + }, + "audioMute": { + "mute": { + "value": "unmuted", + "timestamp": "2025-03-25T18:18:28.464Z" + } + } + }, + "burner-06": { + "samsungce.surfaceResidualHeat": { + "surfaceResidualHeat": { + "value": "normal", + "timestamp": "2025-03-25T18:18:28.591Z" + } + }, + "samsungce.cooktopHeatingPower": { + "manualLevel": { + "value": 0, + "timestamp": "2025-03-25T18:18:28.591Z" + }, + "heatingMode": { + "value": "manual", + "timestamp": "2025-03-25T18:18:28.591Z" + }, + "manualLevelMin": { + "value": 0, + "timestamp": "2025-03-25T18:18:28.591Z" + }, + "supportedHeatingModes": { + "value": ["manual", "boost", "keepWarm"], + "timestamp": "2025-03-25T18:18:28.591Z" + }, + "manualLevelMax": { + "value": 15, + "timestamp": "2025-03-25T18:18:28.591Z" + } + }, + "samsungce.countDownTimer": { + "startValue": { + "value": 0, + "unit": "min", + "timestamp": "2025-03-25T18:18:28.591Z" + }, + "currentValue": { + "value": 0, + "unit": "min", + "timestamp": "2025-03-25T18:18:28.591Z" + }, + "status": { + "value": null + } + } + }, + "hood": { + "samsungce.connectionState": { + "connectionState": { + "value": "disconnected", + "timestamp": "2025-03-25T18:18:28.650Z" + } + }, + "samsungce.hoodFanSpeed": { + "settableMaxFanSpeed": { + "value": 5, + "timestamp": "2025-03-25T18:18:28.650Z" + }, + "hoodFanSpeed": { + "value": 0, + "timestamp": "2025-03-25T18:18:28.650Z" + }, + "supportedHoodFanSpeed": { + "value": [1, 2, 3, 4, 5], + "timestamp": "2025-03-25T18:18:28.650Z" + }, + "settableMinFanSpeed": { + "value": 0, + "timestamp": "2025-03-25T18:18:28.650Z" + } + }, + "samsungce.countDownTimer": { + "startValue": { + "value": 0, + "unit": "min", + "timestamp": "2025-03-25T18:18:28.650Z" + }, + "currentValue": { + "value": 0, + "unit": "min", + "timestamp": "2025-03-25T18:18:28.646Z" + }, + "status": { + "value": null + } + }, + "switch": { + "switch": { + "value": null + } + }, + "samsungce.lamp": { + "brightnessLevel": { + "value": null + }, + "supportedBrightnessLevel": { + "value": ["off", "mid"], + "timestamp": "2025-03-25T18:18:28.650Z" + } + } + }, + "burner-05": { + "samsungce.surfaceResidualHeat": { + "surfaceResidualHeat": { + "value": "normal", + "timestamp": "2025-03-25T18:18:28.586Z" + } + }, + "samsungce.cooktopHeatingPower": { + "manualLevel": { + "value": 0, + "timestamp": "2025-03-25T18:18:28.586Z" + }, + "heatingMode": { + "value": "manual", + "timestamp": "2025-03-25T18:18:28.586Z" + }, + "manualLevelMin": { + "value": 0, + "timestamp": "2025-03-25T18:18:28.586Z" + }, + "supportedHeatingModes": { + "value": ["manual", "boost", "keepWarm"], + "timestamp": "2025-03-25T18:18:28.586Z" + }, + "manualLevelMax": { + "value": 15, + "timestamp": "2025-03-25T18:18:28.586Z" + } + }, + "samsungce.countDownTimer": { + "startValue": { + "value": 0, + "unit": "min", + "timestamp": "2025-03-25T18:18:28.586Z" + }, + "currentValue": { + "value": 0, + "unit": "min", + "timestamp": "2025-03-25T18:18:28.591Z" + }, + "status": { + "value": null + } + } + }, + "burner-04": { + "samsungce.surfaceResidualHeat": { + "surfaceResidualHeat": { + "value": "normal", + "timestamp": "2025-03-25T18:18:28.578Z" + } + }, + "samsungce.cooktopHeatingPower": { + "manualLevel": { + "value": 0, + "timestamp": "2025-03-25T18:49:25.153Z" + }, + "heatingMode": { + "value": "manual", + "timestamp": "2025-03-25T18:18:28.578Z" + }, + "manualLevelMin": { + "value": 0, + "timestamp": "2025-03-25T18:18:28.578Z" + }, + "supportedHeatingModes": { + "value": ["manual", "boost", "keepWarm"], + "timestamp": "2025-03-25T18:18:28.578Z" + }, + "manualLevelMax": { + "value": 15, + "timestamp": "2025-03-25T18:18:28.578Z" + } + }, + "samsungce.countDownTimer": { + "startValue": { + "value": 0, + "unit": "min", + "timestamp": "2025-03-25T18:18:28.586Z" + }, + "currentValue": { + "value": 0, + "unit": "min", + "timestamp": "2025-03-25T18:18:28.586Z" + }, + "status": { + "value": "idle", + "timestamp": "2025-03-25T18:18:28.578Z" + } + } + }, + "burner-03": { + "samsungce.surfaceResidualHeat": { + "surfaceResidualHeat": { + "value": "normal", + "timestamp": "2025-03-25T18:18:28.550Z" + } + }, + "samsungce.cooktopHeatingPower": { + "manualLevel": { + "value": 0, + "timestamp": "2025-03-26T07:27:58.652Z" + }, + "heatingMode": { + "value": "manual", + "timestamp": "2025-03-25T18:18:28.550Z" + }, + "manualLevelMin": { + "value": 0, + "timestamp": "2025-03-25T18:18:28.550Z" + }, + "supportedHeatingModes": { + "value": ["manual", "boost", "keepWarm"], + "timestamp": "2025-03-25T18:18:28.550Z" + }, + "manualLevelMax": { + "value": 15, + "timestamp": "2025-03-25T18:18:28.550Z" + } + }, + "samsungce.countDownTimer": { + "startValue": { + "value": 0, + "unit": "min", + "timestamp": "2025-03-25T18:18:28.550Z" + }, + "currentValue": { + "value": 0, + "unit": "min", + "timestamp": "2025-03-25T18:18:28.550Z" + }, + "status": { + "value": "idle", + "timestamp": "2025-03-25T18:18:28.550Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/devices/da_ks_cooktop_31001.json b/tests/components/smartthings/fixtures/devices/da_ks_cooktop_31001.json new file mode 100644 index 00000000000..433e45dae7a --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/da_ks_cooktop_31001.json @@ -0,0 +1,277 @@ +{ + "items": [ + { + "deviceId": "808dbd84-f357-47e2-a0cd-3b66fa22d584", + "name": "Builtin Cooktop", + "label": "Induction Hob", + "manufacturerName": "0A4H", + "presentationId": "DA-KS-COOKTOP-31001", + "deviceManufacturerCode": "0A4H", + "locationId": "7d27161a-0ef6-4294-91a0-80054ea5bc59", + "ownerId": "d52fb883-0f76-f4d9-0f6a-7ec2c0987b11", + "roomId": "afe14ff1-d444-420d-a766-4dd52f3e1c71", + "deviceTypeId": "Cooktop", + "deviceTypeName": "Samsung Cooktop", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "healthCheck", + "version": 1 + }, + { + "id": "switch", + "version": 1 + }, + { + "id": "audioMute", + "version": 1 + }, + { + "id": "custom.disabledComponents", + "version": 1 + }, + { + "id": "custom.userNotification", + "version": 1 + }, + { + "id": "custom.cooktopOperatingState", + "version": 1 + }, + { + "id": "samsungce.deviceIdentification", + "version": 1 + }, + { + "id": "samsungce.kitchenDeviceIdentification", + "version": 1 + }, + { + "id": "samsungce.softwareVersion", + "version": 1 + }, + { + "id": "samsungce.softwareUpdate", + "version": 1 + }, + { + "id": "samsungce.errorAndAlarmState", + "version": 1 + }, + { + "id": "samsungce.remoteManagementData", + "version": 1 + }, + { + "id": "samsungce.kidsLockControl", + "version": 1 + }, + { + "id": "samsungce.cooktopFlexZone", + "version": 1 + } + ], + "categories": [ + { + "name": "Cooktop", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "burner-01", + "label": "burner-01", + "capabilities": [ + { + "id": "samsungce.surfaceResidualHeat", + "version": 1 + }, + { + "id": "samsungce.cooktopHeatingPower", + "version": 1 + }, + { + "id": "samsungce.countDownTimer", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "burner-02", + "label": "burner-02", + "capabilities": [ + { + "id": "samsungce.surfaceResidualHeat", + "version": 1 + }, + { + "id": "samsungce.cooktopHeatingPower", + "version": 1 + }, + { + "id": "samsungce.countDownTimer", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "burner-03", + "label": "burner-03", + "capabilities": [ + { + "id": "samsungce.surfaceResidualHeat", + "version": 1 + }, + { + "id": "samsungce.cooktopHeatingPower", + "version": 1 + }, + { + "id": "samsungce.countDownTimer", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "burner-04", + "label": "burner-04", + "capabilities": [ + { + "id": "samsungce.surfaceResidualHeat", + "version": 1 + }, + { + "id": "samsungce.cooktopHeatingPower", + "version": 1 + }, + { + "id": "samsungce.countDownTimer", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "burner-05", + "label": "burner-05", + "capabilities": [ + { + "id": "samsungce.surfaceResidualHeat", + "version": 1 + }, + { + "id": "samsungce.cooktopHeatingPower", + "version": 1 + }, + { + "id": "samsungce.countDownTimer", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "burner-06", + "label": "burner-06", + "capabilities": [ + { + "id": "samsungce.surfaceResidualHeat", + "version": 1 + }, + { + "id": "samsungce.cooktopHeatingPower", + "version": 1 + }, + { + "id": "samsungce.countDownTimer", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "hood", + "label": "hood", + "capabilities": [ + { + "id": "switch", + "version": 1 + }, + { + "id": "samsungce.connectionState", + "version": 1 + }, + { + "id": "samsungce.hoodFanSpeed", + "version": 1 + }, + { + "id": "samsungce.lamp", + "version": 1 + }, + { + "id": "samsungce.countDownTimer", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2025-03-25T18:18:23.576Z", + "profile": { + "id": "a99bbcb8-51c9-468d-b9d5-0ce6dca09d5a" + }, + "mqtt": { + "executingLocally": false, + "transferCandidate": false + }, + "type": "MQTT", + "restrictionTier": 0, + "allowed": null, + "executionContext": "CLOUD", + "relationships": [] + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/snapshots/test_binary_sensor.ambr b/tests/components/smartthings/snapshots/test_binary_sensor.ambr index 1d4222292a0..d6a5ac6a4e7 100644 --- a/tests/components/smartthings/snapshots/test_binary_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_binary_sensor.ambr @@ -143,6 +143,54 @@ 'state': 'off', }) # --- +# name: test_all_entities[da_ks_cooktop_31001][binary_sensor.induction_hob_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.induction_hob_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '808dbd84-f357-47e2-a0cd-3b66fa22d584_main_switch_switch_switch', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_cooktop_31001][binary_sensor.induction_hob_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Induction Hob Power', + }), + 'context': , + 'entity_id': 'binary_sensor.induction_hob_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_all_entities[da_ks_microwave_0101x][binary_sensor.microwave_child_lock-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/smartthings/snapshots/test_init.ambr b/tests/components/smartthings/snapshots/test_init.ambr index 206584d1068..6a402182b82 100644 --- a/tests/components/smartthings/snapshots/test_init.ambr +++ b/tests/components/smartthings/snapshots/test_init.ambr @@ -431,6 +431,39 @@ 'via_device_id': None, }) # --- +# name: test_devices[da_ks_cooktop_31001] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '808dbd84-f357-47e2-a0cd-3b66fa22d584', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': None, + 'model': None, + 'model_id': None, + 'name': 'Induction Hob', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- # name: test_devices[da_ks_microwave_0101x] DeviceRegistryEntrySnapshot({ 'area_id': 'theater', diff --git a/tests/components/smartthings/snapshots/test_switch.ambr b/tests/components/smartthings/snapshots/test_switch.ambr index 44d0388b72e..8c95d2f20fc 100644 --- a/tests/components/smartthings/snapshots/test_switch.ambr +++ b/tests/components/smartthings/snapshots/test_switch.ambr @@ -46,6 +46,53 @@ 'state': 'on', }) # --- +# name: test_all_entities[da_ks_cooktop_31001][switch.induction_hob-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.induction_hob', + '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': None, + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '808dbd84-f357-47e2-a0cd-3b66fa22d584_main_switch_switch_switch', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_cooktop_31001][switch.induction_hob-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Induction Hob', + }), + 'context': , + 'entity_id': 'switch.induction_hob', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_all_entities[da_ks_microwave_0101x][switch.microwave-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 6e5648629491ee1e126298804502d120862d9bc8 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 26 Mar 2025 16:30:37 +0100 Subject: [PATCH 3097/3148] Bump pychromecast to 14.0.7 (#141479) --- homeassistant/components/cast/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/cast/manifest.json b/homeassistant/components/cast/manifest.json index feb613f4765..6c8b0536e2f 100644 --- a/homeassistant/components/cast/manifest.json +++ b/homeassistant/components/cast/manifest.json @@ -14,7 +14,7 @@ "documentation": "https://www.home-assistant.io/integrations/cast", "iot_class": "local_polling", "loggers": ["casttube", "pychromecast"], - "requirements": ["PyChromecast==14.0.6"], + "requirements": ["PyChromecast==14.0.7"], "single_config_entry": true, "zeroconf": ["_googlecast._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 2b08f1ec5f9..ab0d9254af0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -45,7 +45,7 @@ ProgettiHWSW==0.1.3 # PyBluez==0.22 # homeassistant.components.cast -PyChromecast==14.0.6 +PyChromecast==14.0.7 # homeassistant.components.flick_electric PyFlick==1.1.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1ada93fb4e5..cf4dd2d127b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -42,7 +42,7 @@ PlexAPI==4.15.16 ProgettiHWSW==0.1.3 # homeassistant.components.cast -PyChromecast==14.0.6 +PyChromecast==14.0.7 # homeassistant.components.flick_electric PyFlick==1.1.3 From 57f65c205e82ea88096880d1c818c1a2cbb38428 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 26 Mar 2025 16:31:28 +0100 Subject: [PATCH 3098/3148] Use SPDX identifier for container license (#141477) --- build.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.yaml b/build.yaml index cd54e410493..87dad1bf5ef 100644 --- a/build.yaml +++ b/build.yaml @@ -19,4 +19,4 @@ labels: org.opencontainers.image.authors: The Home Assistant Authors org.opencontainers.image.url: https://www.home-assistant.io/ org.opencontainers.image.documentation: https://www.home-assistant.io/docs/ - org.opencontainers.image.licenses: Apache License 2.0 + org.opencontainers.image.licenses: Apache-2.0 From febc455bc590d9e62f5d36ca7d327fd685309232 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Wed, 26 Mar 2025 16:46:44 +0100 Subject: [PATCH 3099/3148] Add switch as entity platform on MQTT subentries (#140658) --- homeassistant/components/mqtt/config_flow.py | 33 +++++++++++++++++++- homeassistant/components/mqtt/strings.json | 17 ++++++++-- tests/components/mqtt/common.py | 28 +++++++++++++++-- tests/components/mqtt/test_config_flow.py | 30 ++++++++++++++++++ 4 files changed, 102 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/mqtt/config_flow.py b/homeassistant/components/mqtt/config_flow.py index 0352c5b5f58..471b6d048a7 100644 --- a/homeassistant/components/mqtt/config_flow.py +++ b/homeassistant/components/mqtt/config_flow.py @@ -33,6 +33,7 @@ from homeassistant.components.sensor import ( SensorDeviceClass, SensorStateClass, ) +from homeassistant.components.switch import SwitchDeviceClass from homeassistant.config_entries import ( SOURCE_RECONFIGURE, ConfigEntry, @@ -55,6 +56,7 @@ from homeassistant.const import ( CONF_DISCOVERY, CONF_HOST, CONF_NAME, + CONF_OPTIMISTIC, CONF_PASSWORD, CONF_PAYLOAD, CONF_PLATFORM, @@ -233,7 +235,7 @@ KEY_UPLOAD_SELECTOR = FileSelector( ) # Subentry selectors -SUBENTRY_PLATFORMS = [Platform.NOTIFY, Platform.SENSOR] +SUBENTRY_PLATFORMS = [Platform.NOTIFY, Platform.SENSOR, Platform.SWITCH] SUBENTRY_PLATFORM_SELECTOR = SelectSelector( SelectSelectorConfig( options=[platform.value for platform in SUBENTRY_PLATFORMS], @@ -286,6 +288,15 @@ EXPIRE_AFTER_SELECTOR = NumberSelector( NumberSelectorConfig(mode=NumberSelectorMode.BOX, min=0) ) +# Switch specific selectors +SWITCH_DEVICE_CLASS_SELECTOR = SelectSelector( + SelectSelectorConfig( + options=[device_class.value for device_class in SwitchDeviceClass], + mode=SelectSelectorMode.DROPDOWN, + translation_key="device_class_switch", + ) +) + @callback def validate_sensor_platform_config( @@ -390,6 +401,9 @@ PLATFORM_ENTITY_FIELDS = { conditions=({"device_class": "enum"},), ), }, + Platform.SWITCH.value: { + CONF_DEVICE_CLASS: PlatformField(SWITCH_DEVICE_CLASS_SELECTOR, False, str), + }, } PLATFORM_MQTT_FIELDS = { Platform.NOTIFY.value: { @@ -419,6 +433,22 @@ PLATFORM_MQTT_FIELDS = { EXPIRE_AFTER_SELECTOR, False, cv.positive_int, section="advanced_settings" ), }, + Platform.SWITCH.value: { + CONF_COMMAND_TOPIC: PlatformField( + TEXT_SELECTOR, True, valid_publish_topic, "invalid_publish_topic" + ), + CONF_COMMAND_TEMPLATE: PlatformField( + TEMPLATE_SELECTOR, False, cv.template, "invalid_template" + ), + CONF_STATE_TOPIC: PlatformField( + TEXT_SELECTOR, False, valid_subscribe_topic, "invalid_subscribe_topic" + ), + CONF_VALUE_TEMPLATE: PlatformField( + TEMPLATE_SELECTOR, False, cv.template, "invalid_template" + ), + CONF_RETAIN: PlatformField(BOOLEAN_SELECTOR, False, bool), + CONF_OPTIMISTIC: PlatformField(BOOLEAN_SELECTOR, False, bool), + }, } ENTITY_CONFIG_VALIDATOR: dict[ str, @@ -426,6 +456,7 @@ ENTITY_CONFIG_VALIDATOR: dict[ ] = { Platform.NOTIFY.value: None, Platform.SENSOR.value: validate_sensor_platform_config, + Platform.SWITCH.value: None, } MQTT_DEVICE_PLATFORM_FIELDS = { diff --git a/homeassistant/components/mqtt/strings.json b/homeassistant/components/mqtt/strings.json index e44a6c0d44a..052af8fd72a 100644 --- a/homeassistant/components/mqtt/strings.json +++ b/homeassistant/components/mqtt/strings.json @@ -246,7 +246,9 @@ "value_template": "Value template", "last_reset_value_template": "Last reset value template", "force_update": "Force update", - "retain": "Retain" + "optimistic": "Optimistic", + "retain": "Retain", + "qos": "QoS" }, "data_description": { "command_topic": "The publishing topic that will be used to control the {platform} entity. [Learn more.]({url}#command_topic)", @@ -255,7 +257,9 @@ "value_template": "Defines a [template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract the {platform} entity value.", "last_reset_value_template": "Defines a [template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract the last reset. When Last reset template is set, the State class option must be Total. [Learn more.]({url}#last_reset_value_template)", "force_update": "Sends update events even if the value hasn’t changed. Useful if you want to have meaningful value graphs in history. [Learn more.]({url}#force_update)", - "retain": "Select if values published by the {platform} entity should be retained at the MQTT broker." + "optimistic": "Flag that defines if the {platform} entity works in optimistic mode. [Learn more.]({url}#optimistic)", + "retain": "Select if values published by the {platform} entity should be retained at the MQTT broker.", + "qos": "The QoS value {platform} entity should use." }, "sections": { "advanced_settings": { @@ -462,10 +466,17 @@ "wind_speed": "[%key:component::sensor::entity_component::wind_speed::name%]" } }, + "device_class_switch": { + "options": { + "outlet": "[%key:component::switch::entity_component::outlet::name%]", + "switch": "[%key:component::switch::title%]" + } + }, "platform": { "options": { "notify": "Notify", - "sensor": "Sensor" + "sensor": "Sensor", + "switch": "Switch" } }, "set_ca_cert": { diff --git a/tests/components/mqtt/common.py b/tests/components/mqtt/common.py index 372d1354e85..e4a368f0d71 100644 --- a/tests/components/mqtt/common.py +++ b/tests/components/mqtt/common.py @@ -125,6 +125,19 @@ MOCK_SUBENTRY_SENSOR_COMPONENT_LAST_RESET = { "entity_picture": "https://example.com/e9261f6feed443e7b7d5f3fbe2a47412", }, } +MOCK_SUBENTRY_SWITCH_COMPONENT = { + "3faf1318016c46c5aea26707eeb6f12e": { + "platform": "switch", + "name": "Outlet", + "device_class": "outlet", + "command_topic": "test-topic", + "state_topic": "test-topic", + "command_template": "{{ value }}", + "value_template": "{{ value_json.value }}", + "entity_picture": "https://example.com/3faf1318016c46c5aea26707eeb6f12e", + "optimistic": True, + }, +} # Bogus light component just for code coverage # Note that light cannot be setup through the UI yet @@ -223,7 +236,17 @@ MOCK_SENSOR_SUBENTRY_DATA_SINGLE_LAST_RESET_TEMPLATE = { }, "components": MOCK_SUBENTRY_SENSOR_COMPONENT_LAST_RESET, } - +MOCK_SWITCH_SUBENTRY_DATA_SINGLE_STATE_CLASS = { + "device": { + "name": "Test switch", + "sw_version": "1.0", + "hw_version": "2.1 rev a", + "model": "Model XL", + "model_id": "mn002", + "configuration_url": "https://example.com", + }, + "components": MOCK_SUBENTRY_SWITCH_COMPONENT, +} MOCK_SUBENTRY_DATA_BAD_COMPONENT_SCHEMA = { "device": { "name": "Milk notifier", @@ -246,7 +269,8 @@ MOCK_SUBENTRY_DATA_SET_MIX = { }, "components": MOCK_SUBENTRY_NOTIFY_COMPONENT1 | MOCK_SUBENTRY_NOTIFY_COMPONENT2 - | MOCK_SUBENTRY_LIGHT_COMPONENT, + | MOCK_SUBENTRY_LIGHT_COMPONENT + | MOCK_SUBENTRY_SWITCH_COMPONENT, } | MOCK_SUBENTRY_AVAILABILITY_DATA _SENTINEL = object() diff --git a/tests/components/mqtt/test_config_flow.py b/tests/components/mqtt/test_config_flow.py index a20fa4aeec6..2635263ae8e 100644 --- a/tests/components/mqtt/test_config_flow.py +++ b/tests/components/mqtt/test_config_flow.py @@ -39,6 +39,7 @@ from .common import ( MOCK_SENSOR_SUBENTRY_DATA_SINGLE, MOCK_SENSOR_SUBENTRY_DATA_SINGLE_LAST_RESET_TEMPLATE, MOCK_SENSOR_SUBENTRY_DATA_SINGLE_STATE_CLASS, + MOCK_SWITCH_SUBENTRY_DATA_SINGLE_STATE_CLASS, ) from tests.common import MockConfigEntry, MockMqttReasonCode @@ -2733,12 +2734,41 @@ async def test_migrate_of_incompatible_config_entry( (), "Test sensor Energy", ), + ( + MOCK_SWITCH_SUBENTRY_DATA_SINGLE_STATE_CLASS, + {"name": "Test switch", "mqtt_settings": {"qos": 0}}, + {"name": "Outlet"}, + {"device_class": "outlet"}, + (), + { + "command_topic": "test-topic", + "command_template": "{{ value }}", + "state_topic": "test-topic", + "value_template": "{{ value_json.value }}", + "optimistic": True, + }, + ( + ( + {"command_topic": "test-topic#invalid"}, + {"command_topic": "invalid_publish_topic"}, + ), + ( + { + "command_topic": "test-topic", + "state_topic": "test-topic#invalid", + }, + {"state_topic": "invalid_subscribe_topic"}, + ), + ), + "Test switch Outlet", + ), ], ids=[ "notify_with_entity_name", "notify_no_entity_name", "sensor_options", "sensor_total", + "switch", ], ) async def test_subentry_configflow( From 220aaf93c6b0d201bb4baa59d96ff9d9c8a66279 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Wed, 26 Mar 2025 11:31:05 -0500 Subject: [PATCH 3100/3148] Add preannounce media id support for ESPHome (#141474) * Working on preannounce media id support for ESPHome * Fix test * Update tests --- .../components/esphome/assist_satellite.py | 27 ++- .../esphome/test_assist_satellite.py | 213 +++++++++++++++++- 2 files changed, 223 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/esphome/assist_satellite.py b/homeassistant/components/esphome/assist_satellite.py index 4206b545588..a129a7723dd 100644 --- a/homeassistant/components/esphome/assist_satellite.py +++ b/homeassistant/components/esphome/assist_satellite.py @@ -370,8 +370,10 @@ class EsphomeAssistSatellite( announcement.media_id, ) media_id = announcement.media_id - if announcement.media_id_source != "tts": - # Route non-TTS media through the proxy + is_media_tts = announcement.media_id_source == "tts" + preannounce_media_id = announcement.preannounce_media_id + if (not is_media_tts) or preannounce_media_id: + # Route media through the proxy format_to_use: MediaPlayerSupportedFormat | None = None for supported_format in chain( *self.entry_data.media_player_formats.values() @@ -384,22 +386,33 @@ class EsphomeAssistSatellite( assert (self.registry_entry is not None) and ( self.registry_entry.device_id is not None ) - proxy_url = async_create_proxy_url( - self.hass, - self.registry_entry.device_id, - media_id, + + make_proxy_url = partial( + async_create_proxy_url, + hass=self.hass, + device_id=self.registry_entry.device_id, media_format=format_to_use.format, rate=format_to_use.sample_rate or None, channels=format_to_use.num_channels or None, width=format_to_use.sample_bytes or None, ) - media_id = async_process_play_media_url(self.hass, proxy_url) + + if not is_media_tts: + media_id = async_process_play_media_url( + self.hass, make_proxy_url(media_url=media_id) + ) + + if preannounce_media_id: + preannounce_media_id = async_process_play_media_url( + self.hass, make_proxy_url(media_url=preannounce_media_id) + ) await self.cli.send_voice_assistant_announcement_await_response( media_id, _ANNOUNCEMENT_TIMEOUT_SEC, announcement.message, start_conversation=run_pipeline_after, + preannounce_media_id=preannounce_media_id or "", ) async def handle_pipeline_start( diff --git a/tests/components/esphome/test_assist_satellite.py b/tests/components/esphome/test_assist_satellite.py index 081070b23f1..7fc46e87503 100644 --- a/tests/components/esphome/test_assist_satellite.py +++ b/tests/components/esphome/test_assist_satellite.py @@ -1212,12 +1212,17 @@ async def test_announce_message( done = asyncio.Event() async def send_voice_assistant_announcement_await_response( - media_id: str, timeout: float, text: str, start_conversation: bool + media_id: str, + timeout: float, + text: str, + start_conversation: bool, + preannounce_media_id: str | None = None, ): assert satellite.state == AssistSatelliteState.RESPONDING assert media_id == "http://10.10.10.10:8123/api/tts_proxy/test-token" assert text == "test-text" assert not start_conversation + assert not preannounce_media_id done.set() @@ -1302,11 +1307,16 @@ async def test_announce_media_id( done = asyncio.Event() async def send_voice_assistant_announcement_await_response( - media_id: str, timeout: float, text: str, start_conversation: bool + media_id: str, + timeout: float, + text: str, + start_conversation: bool, + preannounce_media_id: str | None = None, ): assert satellite.state == AssistSatelliteState.RESPONDING assert media_id == "https://www.home-assistant.io/proxied.flac" assert not start_conversation + assert not preannounce_media_id done.set() @@ -1335,9 +1345,9 @@ async def test_announce_media_id( assert satellite.state == AssistSatelliteState.IDLE mock_async_create_proxy_url.assert_called_once_with( - hass, - dev.id, - "https://www.home-assistant.io/resolved.mp3", + hass=hass, + device_id=dev.id, + media_url="https://www.home-assistant.io/resolved.mp3", media_format="flac", rate=48000, channels=2, @@ -1345,6 +1355,83 @@ async def test_announce_media_id( ) +async def test_announce_message_with_preannounce( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: Callable[ + [APIClient, list[EntityInfo], list[UserService], list[EntityState]], + Awaitable[MockESPHomeDevice], + ], +) -> None: + """Test announcement with message and preannounce media id.""" + mock_device: MockESPHomeDevice = await mock_esphome_device( + mock_client=mock_client, + entity_info=[], + user_service=[], + states=[], + device_info={ + "voice_assistant_feature_flags": VoiceAssistantFeature.VOICE_ASSISTANT + | VoiceAssistantFeature.SPEAKER + | VoiceAssistantFeature.API_AUDIO + | VoiceAssistantFeature.ANNOUNCE + }, + ) + await hass.async_block_till_done() + + satellite = get_satellite_entity(hass, mock_device.device_info.mac_address) + assert satellite is not None + + done = asyncio.Event() + + async def send_voice_assistant_announcement_await_response( + media_id: str, + timeout: float, + text: str, + start_conversation: bool, + preannounce_media_id: str | None = None, + ): + assert satellite.state == AssistSatelliteState.RESPONDING + assert media_id == "http://10.10.10.10:8123/api/tts_proxy/test-token" + assert text == "test-text" + assert not start_conversation + assert preannounce_media_id == "test-preannounce" + + done.set() + + with ( + patch( + "homeassistant.components.tts.generate_media_source_id", + return_value="media-source://bla", + ), + patch( + "homeassistant.components.tts.async_resolve_engine", + return_value="tts.cloud_tts", + ), + patch( + "homeassistant.components.tts.async_create_stream", + return_value=MockResultStream(hass, "wav", b""), + ), + patch.object( + mock_client, + "send_voice_assistant_announcement_await_response", + new=send_voice_assistant_announcement_await_response, + ), + ): + async with asyncio.timeout(1): + await hass.services.async_call( + assist_satellite.DOMAIN, + "announce", + { + "entity_id": satellite.entity_id, + "message": "test-text", + "preannounce_media_id": "test-preannounce", + }, + blocking=True, + ) + await done.wait() + assert satellite.state == AssistSatelliteState.IDLE + + async def test_start_conversation_supported_features( hass: HomeAssistant, mock_client: APIClient, @@ -1417,12 +1504,17 @@ async def test_start_conversation_message( done = asyncio.Event() async def send_voice_assistant_announcement_await_response( - media_id: str, timeout: float, text: str, start_conversation: bool + media_id: str, + timeout: float, + text: str, + start_conversation: bool, + preannounce_media_id: str, ): assert satellite.state == AssistSatelliteState.RESPONDING assert media_id == "http://10.10.10.10:8123/api/tts_proxy/test-token" assert text == "test-text" assert start_conversation + assert not preannounce_media_id done.set() @@ -1526,11 +1618,16 @@ async def test_start_conversation_media_id( done = asyncio.Event() async def send_voice_assistant_announcement_await_response( - media_id: str, timeout: float, text: str, start_conversation: bool + media_id: str, + timeout: float, + text: str, + start_conversation: bool, + preannounce_media_id: str, ): assert satellite.state == AssistSatelliteState.RESPONDING assert media_id == "https://www.home-assistant.io/proxied.flac" assert start_conversation + assert not preannounce_media_id done.set() @@ -1563,9 +1660,9 @@ async def test_start_conversation_media_id( assert satellite.state == AssistSatelliteState.IDLE mock_async_create_proxy_url.assert_called_once_with( - hass, - dev.id, - "https://www.home-assistant.io/resolved.mp3", + hass=hass, + device_id=dev.id, + media_url="https://www.home-assistant.io/resolved.mp3", media_format="flac", rate=48000, channels=2, @@ -1573,6 +1670,102 @@ async def test_start_conversation_media_id( ) +async def test_start_conversation_message_with_preannounce( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: Callable[ + [APIClient, list[EntityInfo], list[UserService], list[EntityState]], + Awaitable[MockESPHomeDevice], + ], +) -> None: + """Test start conversation with message and preannounce media id.""" + mock_device: MockESPHomeDevice = await mock_esphome_device( + mock_client=mock_client, + entity_info=[], + user_service=[], + states=[], + device_info={ + "voice_assistant_feature_flags": VoiceAssistantFeature.VOICE_ASSISTANT + | VoiceAssistantFeature.SPEAKER + | VoiceAssistantFeature.API_AUDIO + | VoiceAssistantFeature.ANNOUNCE + | VoiceAssistantFeature.START_CONVERSATION + }, + ) + await hass.async_block_till_done() + + satellite = get_satellite_entity(hass, mock_device.device_info.mac_address) + assert satellite is not None + + pipeline = assist_pipeline.Pipeline( + conversation_engine="test engine", + conversation_language="en", + language="en", + name="test pipeline", + stt_engine="test stt", + stt_language="en", + tts_engine="test tts", + tts_language="en", + tts_voice=None, + wake_word_entity=None, + wake_word_id=None, + ) + + done = asyncio.Event() + + async def send_voice_assistant_announcement_await_response( + media_id: str, + timeout: float, + text: str, + start_conversation: bool, + preannounce_media_id: str, + ): + assert satellite.state == AssistSatelliteState.RESPONDING + assert media_id == "http://10.10.10.10:8123/api/tts_proxy/test-token" + assert text == "test-text" + assert start_conversation + assert preannounce_media_id == "test-preannounce" + + done.set() + + with ( + patch( + "homeassistant.components.tts.generate_media_source_id", + return_value="media-source://bla", + ), + patch( + "homeassistant.components.tts.async_resolve_engine", + return_value="tts.cloud_tts", + ), + patch( + "homeassistant.components.tts.async_create_stream", + return_value=MockResultStream(hass, "wav", b""), + ), + patch.object( + mock_client, + "send_voice_assistant_announcement_await_response", + new=send_voice_assistant_announcement_await_response, + ), + patch( + "homeassistant.components.assist_satellite.entity.async_get_pipeline", + return_value=pipeline, + ), + ): + async with asyncio.timeout(1): + await hass.services.async_call( + assist_satellite.DOMAIN, + "start_conversation", + { + "entity_id": satellite.entity_id, + "start_message": "test-text", + "preannounce_media_id": "test-preannounce", + }, + blocking=True, + ) + await done.wait() + assert satellite.state == AssistSatelliteState.IDLE + + async def test_satellite_unloaded_on_disconnect( hass: HomeAssistant, mock_client: APIClient, From 3bcf1c942cf282245f0748dc3a0527993c390fce Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Wed, 26 Mar 2025 17:40:22 +0100 Subject: [PATCH 3101/3148] Cleanup missed QoS translation string for MQTT subentries (#141485) --- homeassistant/components/mqtt/strings.json | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/mqtt/strings.json b/homeassistant/components/mqtt/strings.json index 052af8fd72a..60339347f2a 100644 --- a/homeassistant/components/mqtt/strings.json +++ b/homeassistant/components/mqtt/strings.json @@ -158,7 +158,7 @@ "qos": "QoS" }, "data_description": { - "qos": "The QoS value the device's entities should use." + "qos": "The Quality of Service value the device's entities should use." } } } @@ -247,8 +247,7 @@ "last_reset_value_template": "Last reset value template", "force_update": "Force update", "optimistic": "Optimistic", - "retain": "Retain", - "qos": "QoS" + "retain": "Retain" }, "data_description": { "command_topic": "The publishing topic that will be used to control the {platform} entity. [Learn more.]({url}#command_topic)", @@ -258,8 +257,7 @@ "last_reset_value_template": "Defines a [template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract the last reset. When Last reset template is set, the State class option must be Total. [Learn more.]({url}#last_reset_value_template)", "force_update": "Sends update events even if the value hasn’t changed. Useful if you want to have meaningful value graphs in history. [Learn more.]({url}#force_update)", "optimistic": "Flag that defines if the {platform} entity works in optimistic mode. [Learn more.]({url}#optimistic)", - "retain": "Select if values published by the {platform} entity should be retained at the MQTT broker.", - "qos": "The QoS value {platform} entity should use." + "retain": "Select if values published by the {platform} entity should be retained at the MQTT broker." }, "sections": { "advanced_settings": { From 69c8f4fbb6e063d6a71e0ea498c37d2bb0623c65 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 26 Mar 2025 17:48:03 +0100 Subject: [PATCH 3102/3148] Add button to reset the water filter in SmartThings (#141493) * Add button to reset the water filter in SmartThings * Add button to reset the water filter in SmartThings --- .../components/smartthings/button.py | 5 ++ .../components/smartthings/icons.json | 3 ++ .../components/smartthings/strings.json | 3 ++ .../smartthings/snapshots/test_button.ambr | 47 +++++++++++++++++++ 4 files changed, 58 insertions(+) diff --git a/homeassistant/components/smartthings/button.py b/homeassistant/components/smartthings/button.py index fa623a47c47..00fbaa0e2c4 100644 --- a/homeassistant/components/smartthings/button.py +++ b/homeassistant/components/smartthings/button.py @@ -29,6 +29,11 @@ CAPABILITIES_TO_BUTTONS: dict[Capability | str, SmartThingsButtonDescription] = translation_key="stop", command=Command.STOP, ), + Capability.CUSTOM_WATER_FILTER: SmartThingsButtonDescription( + key=Capability.CUSTOM_WATER_FILTER, + translation_key="reset_water_filter", + command=Command.RESET_WATER_FILTER, + ), } diff --git a/homeassistant/components/smartthings/icons.json b/homeassistant/components/smartthings/icons.json index 107233665bb..214a9953a5a 100644 --- a/homeassistant/components/smartthings/icons.json +++ b/homeassistant/components/smartthings/icons.json @@ -21,6 +21,9 @@ } }, "button": { + "reset_water_filter": { + "default": "mdi:reload" + }, "stop": { "default": "mdi:stop" } diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index 441a53369b5..dfba018b8d9 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -59,6 +59,9 @@ } }, "button": { + "reset_water_filter": { + "name": "Reset water filter" + }, "stop": { "name": "[%key:common::action::stop%]" } diff --git a/tests/components/smartthings/snapshots/test_button.ambr b/tests/components/smartthings/snapshots/test_button.ambr index f1c5d932729..2c9dbd008af 100644 --- a/tests/components/smartthings/snapshots/test_button.ambr +++ b/tests/components/smartthings/snapshots/test_button.ambr @@ -140,3 +140,50 @@ 'state': 'unknown', }) # --- +# name: test_all_entities[da_ref_normal_000001][button.refrigerator_reset_water_filter-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.refrigerator_reset_water_filter', + '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': 'Reset water filter', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'reset_water_filter', + 'unique_id': '7db87911-7dce-1cf2-7119-b953432a2f09_main_custom.waterFilter_resetWaterFilter', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ref_normal_000001][button.refrigerator_reset_water_filter-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Refrigerator Reset water filter', + }), + 'context': , + 'entity_id': 'button.refrigerator_reset_water_filter', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- From eb3cb0e0c7835ca10cdbb225d85f5e22d512e290 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 26 Mar 2025 17:49:29 +0100 Subject: [PATCH 3103/3148] Bump yt-dlp to 2025.03.26 (#141484) --- homeassistant/components/media_extractor/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/media_extractor/manifest.json b/homeassistant/components/media_extractor/manifest.json index 575c0fa878d..e049a827c75 100644 --- a/homeassistant/components/media_extractor/manifest.json +++ b/homeassistant/components/media_extractor/manifest.json @@ -8,6 +8,6 @@ "iot_class": "calculated", "loggers": ["yt_dlp"], "quality_scale": "internal", - "requirements": ["yt-dlp[default]==2025.02.19"], + "requirements": ["yt-dlp[default]==2025.03.26"], "single_config_entry": true } diff --git a/requirements_all.txt b/requirements_all.txt index ab0d9254af0..90c1f3f9b11 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3140,7 +3140,7 @@ youless-api==2.2.0 youtubeaio==1.1.5 # homeassistant.components.media_extractor -yt-dlp[default]==2025.02.19 +yt-dlp[default]==2025.03.26 # homeassistant.components.zabbix zabbix-utils==2.0.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index cf4dd2d127b..2958c627833 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2533,7 +2533,7 @@ youless-api==2.2.0 youtubeaio==1.1.5 # homeassistant.components.media_extractor -yt-dlp[default]==2025.02.19 +yt-dlp[default]==2025.03.26 # homeassistant.components.zamg zamg==0.3.6 From 222d89a84cfeaced2722f86bd7b8d9f6a7a9869b Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 26 Mar 2025 17:56:45 +0100 Subject: [PATCH 3104/3148] Update meteofrance-api to 1.4.0 (#141490) --- homeassistant/components/meteo_france/__init__.py | 2 +- homeassistant/components/meteo_france/manifest.json | 2 +- homeassistant/components/meteo_france/sensor.py | 4 ++-- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/meteo_france/conftest.py | 4 ++-- ..._phenomenoms.json => raw_warning_current_phenomenons.json} | 0 7 files changed, 8 insertions(+), 8 deletions(-) rename tests/components/meteo_france/fixtures/{raw_warning_current_phenomenoms.json => raw_warning_current_phenomenons.json} (100%) diff --git a/homeassistant/components/meteo_france/__init__.py b/homeassistant/components/meteo_france/__init__.py index 5c4ada6b5f1..5f1d5269538 100644 --- a/homeassistant/components/meteo_france/__init__.py +++ b/homeassistant/components/meteo_france/__init__.py @@ -57,7 +57,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Fetch data from API endpoint.""" assert isinstance(department, str) return await hass.async_add_executor_job( - client.get_warning_current_phenomenoms, department, 0, True + client.get_warning_current_phenomenons, department, 0, True ) coordinator_forecast = DataUpdateCoordinator( diff --git a/homeassistant/components/meteo_france/manifest.json b/homeassistant/components/meteo_france/manifest.json index 567788ec479..d82d0c3f91b 100644 --- a/homeassistant/components/meteo_france/manifest.json +++ b/homeassistant/components/meteo_france/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/meteo_france", "iot_class": "cloud_polling", "loggers": ["meteofrance_api"], - "requirements": ["meteofrance-api==1.3.0"] + "requirements": ["meteofrance-api==1.4.0"] } diff --git a/homeassistant/components/meteo_france/sensor.py b/homeassistant/components/meteo_france/sensor.py index c29cc1ceda9..7333f7b0c19 100644 --- a/homeassistant/components/meteo_france/sensor.py +++ b/homeassistant/components/meteo_france/sensor.py @@ -7,7 +7,7 @@ from typing import Any from meteofrance_api.helpers import ( get_warning_text_status_from_indice_color, - readeable_phenomenoms_dict, + readable_phenomenons_dict, ) from meteofrance_api.model.forecast import Forecast from meteofrance_api.model.rain import Rain @@ -336,7 +336,7 @@ class MeteoFranceAlertSensor(MeteoFranceSensor[CurrentPhenomenons]): def extra_state_attributes(self): """Return the state attributes.""" return { - **readeable_phenomenoms_dict(self.coordinator.data.phenomenons_max_colors), + **readable_phenomenons_dict(self.coordinator.data.phenomenons_max_colors), } diff --git a/requirements_all.txt b/requirements_all.txt index 90c1f3f9b11..e6170c29e4b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1405,7 +1405,7 @@ messagebird==1.2.0 meteoalertapi==0.3.1 # homeassistant.components.meteo_france -meteofrance-api==1.3.0 +meteofrance-api==1.4.0 # homeassistant.components.mfi mficlient==0.5.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2958c627833..a90bd3bce9b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1175,7 +1175,7 @@ medcom-ble==0.1.1 melnor-bluetooth==0.0.25 # homeassistant.components.meteo_france -meteofrance-api==1.3.0 +meteofrance-api==1.4.0 # homeassistant.components.mfi mficlient==0.5.0 diff --git a/tests/components/meteo_france/conftest.py b/tests/components/meteo_france/conftest.py index eb28ec0a838..82b220e331e 100644 --- a/tests/components/meteo_france/conftest.py +++ b/tests/components/meteo_france/conftest.py @@ -24,8 +24,8 @@ def patch_requests(): mock_data.get_rain.return_value = Rain( load_json_object_fixture("raw_rain.json", DOMAIN) ) - mock_data.get_warning_current_phenomenoms.return_value = CurrentPhenomenons( - load_json_object_fixture("raw_warning_current_phenomenoms.json", DOMAIN) + mock_data.get_warning_current_phenomenons.return_value = CurrentPhenomenons( + load_json_object_fixture("raw_warning_current_phenomenons.json", DOMAIN) ) yield mock_data diff --git a/tests/components/meteo_france/fixtures/raw_warning_current_phenomenoms.json b/tests/components/meteo_france/fixtures/raw_warning_current_phenomenons.json similarity index 100% rename from tests/components/meteo_france/fixtures/raw_warning_current_phenomenoms.json rename to tests/components/meteo_france/fixtures/raw_warning_current_phenomenons.json From 4f3b36c2e1fd6e8f2b634ad5ed375197ac29c577 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Wed, 26 Mar 2025 17:57:15 +0100 Subject: [PATCH 3105/3148] Update aioairzone-cloud to v0.6.11 (#141488) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Álvaro Fernández Rojas --- homeassistant/components/airzone_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/airzone_cloud/manifest.json b/homeassistant/components/airzone_cloud/manifest.json index 0e21e57ec52..3b6f94df57c 100644 --- a/homeassistant/components/airzone_cloud/manifest.json +++ b/homeassistant/components/airzone_cloud/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/airzone_cloud", "iot_class": "cloud_push", "loggers": ["aioairzone_cloud"], - "requirements": ["aioairzone-cloud==0.6.10"] + "requirements": ["aioairzone-cloud==0.6.11"] } diff --git a/requirements_all.txt b/requirements_all.txt index e6170c29e4b..ac1a8251e88 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -179,7 +179,7 @@ aioacaia==0.1.14 aioairq==0.4.4 # homeassistant.components.airzone_cloud -aioairzone-cloud==0.6.10 +aioairzone-cloud==0.6.11 # homeassistant.components.airzone aioairzone==0.9.9 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a90bd3bce9b..1c7a54edf6a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -167,7 +167,7 @@ aioacaia==0.1.14 aioairq==0.4.4 # homeassistant.components.airzone_cloud -aioairzone-cloud==0.6.10 +aioairzone-cloud==0.6.11 # homeassistant.components.airzone aioairzone==0.9.9 From c8ab5bc7960d456585c372c5a718b5baa53688a4 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Wed, 26 Mar 2025 17:57:27 +0100 Subject: [PATCH 3106/3148] Bump IMGW-PIB library to 1.0.10 (#141491) --- homeassistant/components/imgw_pib/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/imgw_pib/manifest.json b/homeassistant/components/imgw_pib/manifest.json index 0ecc1b4b7d0..3d8b34055fd 100644 --- a/homeassistant/components/imgw_pib/manifest.json +++ b/homeassistant/components/imgw_pib/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/imgw_pib", "iot_class": "cloud_polling", - "requirements": ["imgw_pib==1.0.9"] + "requirements": ["imgw_pib==1.0.10"] } diff --git a/requirements_all.txt b/requirements_all.txt index ac1a8251e88..08bf975f23e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1220,7 +1220,7 @@ igloohome-api==0.1.0 ihcsdk==2.8.5 # homeassistant.components.imgw_pib -imgw_pib==1.0.9 +imgw_pib==1.0.10 # homeassistant.components.incomfort incomfort-client==0.6.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1c7a54edf6a..9cadd834d53 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1032,7 +1032,7 @@ ifaddr==0.2.0 igloohome-api==0.1.0 # homeassistant.components.imgw_pib -imgw_pib==1.0.9 +imgw_pib==1.0.10 # homeassistant.components.incomfort incomfort-client==0.6.7 From fe99c39e251f251e528a5fb411cf278515dfebd6 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 26 Mar 2025 18:21:49 +0100 Subject: [PATCH 3107/3148] Deprecate media player sensors for SmartThings (#141469) * Deprecate media player sensors for SmartThings * Deprecate media player sensors --- .../components/smartthings/sensor.py | 48 +++++++++++-------- .../components/smartthings/strings.json | 4 ++ tests/components/smartthings/test_sensor.py | 24 ++++++++-- 3 files changed, 51 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index 6d2ce6417da..f93b27337e1 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -7,7 +7,7 @@ from dataclasses import dataclass from datetime import datetime from typing import Any, cast -from pysmartthings import Attribute, Capability, SmartThings, Status +from pysmartthings import Attribute, Capability, ComponentStatus, SmartThings, Status from homeassistant.components.automation import automations_with_entity from homeassistant.components.script import scripts_with_entity @@ -140,6 +140,7 @@ class SmartThingsSensorEntityDescription(SensorEntityDescription): options_attribute: Attribute | None = None exists_fn: Callable[[Status], bool] | None = None use_temperature_unit: bool = False + deprecated: Callable[[ComponentStatus], str | None] | None = None CAPABILITY_TO_SENSORS: dict[ @@ -196,6 +197,17 @@ CAPABILITY_TO_SENSORS: dict[ key=Attribute.VOLUME, translation_key="audio_volume", native_unit_of_measurement=PERCENTAGE, + deprecated=( + lambda status: "media_player" + if all( + capability in status + for capability in ( + Capability.AUDIO_MUTE, + Capability.MEDIA_PLAYBACK, + ) + ) + else None + ), ) ] }, @@ -319,6 +331,7 @@ CAPABILITY_TO_SENSORS: dict[ translation_key="dryer_machine_state", options=WASHER_OPTIONS, device_class=SensorDeviceClass.ENUM, + deprecated=lambda _: "machine_state", ) ], Attribute.DRYER_JOB_STATE: [ @@ -470,6 +483,7 @@ CAPABILITY_TO_SENSORS: dict[ device_class=SensorDeviceClass.ENUM, options_attribute=Attribute.SUPPORTED_INPUT_SOURCES, value_fn=lambda value: value.lower() if value else None, + deprecated=lambda _: "media_player", ) ] }, @@ -478,6 +492,7 @@ CAPABILITY_TO_SENSORS: dict[ SmartThingsSensorEntityDescription( key=Attribute.PLAYBACK_REPEAT_MODE, translation_key="media_playback_repeat", + deprecated=lambda _: "media_player", ) ] }, @@ -486,6 +501,7 @@ CAPABILITY_TO_SENSORS: dict[ SmartThingsSensorEntityDescription( key=Attribute.PLAYBACK_SHUFFLE, translation_key="media_playback_shuffle", + deprecated=lambda _: "media_player", ) ] }, @@ -504,6 +520,7 @@ CAPABILITY_TO_SENSORS: dict[ ], device_class=SensorDeviceClass.ENUM, value_fn=lambda value: MEDIA_PLAYBACK_STATE_MAP.get(value, value), + deprecated=lambda _: "media_player", ) ] }, @@ -949,6 +966,7 @@ CAPABILITY_TO_SENSORS: dict[ translation_key="washer_machine_state", options=WASHER_OPTIONS, device_class=SensorDeviceClass.ENUM, + deprecated=lambda _: "machine_state", ) ], Attribute.WASHER_JOB_STATE: [ @@ -1102,13 +1120,9 @@ class SmartThingsSensor(SmartThingsEntity, SensorEntity): """Call when entity is added to hass.""" await super().async_added_to_hass() if ( - self.capability - not in { - Capability.DISHWASHER_OPERATING_STATE, - Capability.DRYER_OPERATING_STATE, - Capability.WASHER_OPERATING_STATE, - } - or self._attribute is not Attribute.MACHINE_STATE + not self.entity_description.deprecated + or (reason := self.entity_description.deprecated(self.device.status[MAIN])) + is None ): return automations = automations_with_entity(self.hass, self.entity_id) @@ -1130,11 +1144,11 @@ class SmartThingsSensor(SmartThingsEntity, SensorEntity): async_create_issue( self.hass, DOMAIN, - f"deprecated_machine_state_{self.entity_id}", + f"deprecated_{reason}_{self.entity_id}", breaks_in_ha_version="2025.10.0", is_fixable=False, severity=IssueSeverity.WARNING, - translation_key="deprecated_machine_state", + translation_key=f"deprecated_{reason}", translation_placeholders={ "entity": self.entity_id, "items": "\n".join(items_list), @@ -1145,15 +1159,9 @@ class SmartThingsSensor(SmartThingsEntity, SensorEntity): """Call when entity will be removed from hass.""" await super().async_will_remove_from_hass() if ( - self.capability - not in { - Capability.DISHWASHER_OPERATING_STATE, - Capability.DRYER_OPERATING_STATE, - Capability.WASHER_OPERATING_STATE, - } - or self._attribute is not Attribute.MACHINE_STATE + not self.entity_description.deprecated + or (reason := self.entity_description.deprecated(self.device.status[MAIN])) + is None ): return - async_delete_issue( - self.hass, DOMAIN, f"deprecated_machine_state_{self.entity_id}" - ) + async_delete_issue(self.hass, DOMAIN, f"deprecated_{reason}_{self.entity_id}") diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index dfba018b8d9..7e812845839 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -494,6 +494,10 @@ "deprecated_switch_appliance": { "title": "Deprecated switch detected in some automations or scripts", "description": "The switch `{entity}` is deprecated because the actions did not work, so it has been replaced with a binary sensor instead.\n\nThe switch was used in the following automations or scripts:\n{items}\n\nPlease use them in the above automations or scripts to fix this issue." + }, + "deprecated_media_player": { + "title": "Deprecated sensor detected in some automations or scripts", + "description": "The sensor `{entity}` is deprecated because it has been replaced with a media player entity.\n\nThe sensor was used in the following automations or scripts:\n{items}\n\nPlease use the media player entity in the above automations or scripts to fix this issue." } } } diff --git a/tests/components/smartthings/test_sensor.py b/tests/components/smartthings/test_sensor.py index 229644e2473..cf49d02b910 100644 --- a/tests/components/smartthings/test_sensor.py +++ b/tests/components/smartthings/test_sensor.py @@ -58,10 +58,23 @@ async def test_state_update( @pytest.mark.usefixtures("entity_registry_enabled_by_default") @pytest.mark.parametrize( - ("device_fixture", "entity_id"), + ("device_fixture", "entity_id", "translation_key"), [ - ("da_wm_wm_000001", "sensor.washer_machine_state"), - ("da_wm_wd_000001", "sensor.dryer_machine_state"), + ("da_wm_wm_000001", "sensor.washer_machine_state", "machine_state"), + ("da_wm_wd_000001", "sensor.dryer_machine_state", "machine_state"), + ("hw_q80r_soundbar", "sensor.soundbar_volume", "media_player"), + ("hw_q80r_soundbar", "sensor.soundbar_media_playback_status", "media_player"), + ("hw_q80r_soundbar", "sensor.soundbar_media_input_source", "media_player"), + ( + "im_speaker_ai_0001", + "sensor.galaxy_home_mini_media_playback_shuffle", + "media_player", + ), + ( + "im_speaker_ai_0001", + "sensor.galaxy_home_mini_media_playback_repeat", + "media_player", + ), ], ) async def test_create_issue( @@ -70,9 +83,10 @@ async def test_create_issue( mock_config_entry: MockConfigEntry, issue_registry: ir.IssueRegistry, entity_id: str, + translation_key: str, ) -> None: """Test we create an issue when an automation or script is using a deprecated entity.""" - issue_id = f"deprecated_machine_state_{entity_id}" + issue_id = f"deprecated_{translation_key}_{entity_id}" assert await async_setup_component( hass, @@ -117,7 +131,7 @@ async def test_create_issue( assert len(issue_registry.issues) == 1 issue = issue_registry.async_get_issue(DOMAIN, issue_id) assert issue is not None - assert issue.translation_key == "deprecated_machine_state" + assert issue.translation_key == f"deprecated_{translation_key}" assert issue.translation_placeholders == { "entity": entity_id, "items": "- [test](/config/automation/edit/test)\n- [test](/config/script/edit/test)", From 2e3853dd7d1ba07c1b9cf538f6b519b19b7e6b47 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 26 Mar 2025 18:40:11 +0100 Subject: [PATCH 3108/3148] Deprecate SmartThings media player switch (#141467) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Deprecate SmartThings media player switch * Fix * Fix * Update homeassistant/components/smartthings/strings.json Co-authored-by: Abílio Costa * Fix --------- Co-authored-by: Abílio Costa --- .../components/smartthings/strings.json | 12 +++-- .../components/smartthings/switch.py | 47 ++++++++++--------- tests/components/smartthings/test_switch.py | 10 ++-- 3 files changed, 40 insertions(+), 29 deletions(-) diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index 7e812845839..e4cf03178fd 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -481,7 +481,7 @@ "issues": { "deprecated_binary_valve": { "title": "Deprecated valve binary sensor detected in some automations or scripts", - "description": "The valve binary sensor `{entity}` is deprecated and is used in the following automations or scripts:\n{items}\n\nA valve entity with controls is available and should be used going forward. Please use it in the above automations or scripts to fix this issue." + "description": "The valve binary sensor `{entity}` is deprecated and is used in the following automations or scripts:\n{items}\n\nA valve entity with controls is available and should be used going forward. Please use the new valve entity in the above automations or scripts to fix this issue." }, "deprecated_binary_fridge_door": { "title": "Deprecated refrigerator door binary sensor detected in some automations or scripts", @@ -489,15 +489,19 @@ }, "deprecated_machine_state": { "title": "Deprecated machine state sensor detected in some automations or scripts", - "description": "The machine state sensor `{entity}` is deprecated and is used in the following automations or scripts:\n{items}\n\nA select entity is now available for the machine state and should be used going forward. Please use them in the above automations or scripts to fix this issue." + "description": "The machine state sensor `{entity}` is deprecated and is used in the following automations or scripts:\n{items}\n\nA select entity is now available for the machine state and should be used going forward. Please use the new select entity in the above automations or scripts to fix this issue." }, "deprecated_switch_appliance": { "title": "Deprecated switch detected in some automations or scripts", - "description": "The switch `{entity}` is deprecated because the actions did not work, so it has been replaced with a binary sensor instead.\n\nThe switch was used in the following automations or scripts:\n{items}\n\nPlease use them in the above automations or scripts to fix this issue." + "description": "The switch `{entity}` is deprecated because the actions did not work, so it has been replaced with a binary sensor instead.\n\nThe switch was used in the following automations or scripts:\n{items}\n\nPlease use the new binary sensor in the above automations or scripts to fix this issue." + }, + "deprecated_switch_media_player": { + "title": "[%key:component::smartthings::issues::deprecated_switch_appliance::title%]", + "description": "The switch `{entity}` is deprecated and a media player entity has been added to replace it.\n\nThe switch was used in the following automations or scripts:\n{items}\n\nPlease use the new media player entity in the above automations or scripts to fix this issue." }, "deprecated_media_player": { "title": "Deprecated sensor detected in some automations or scripts", - "description": "The sensor `{entity}` is deprecated because it has been replaced with a media player entity.\n\nThe sensor was used in the following automations or scripts:\n{items}\n\nPlease use the media player entity in the above automations or scripts to fix this issue." + "description": "The sensor `{entity}` is deprecated because it has been replaced with a media player entity.\n\nThe sensor was used in the following automations or scripts:\n{items}\n\nPlease use the new media player entity in the above automations or scripts to fix this issue." } } } diff --git a/homeassistant/components/smartthings/switch.py b/homeassistant/components/smartthings/switch.py index dab944bb663..e5b74de3241 100644 --- a/homeassistant/components/smartthings/switch.py +++ b/homeassistant/components/smartthings/switch.py @@ -136,6 +136,7 @@ class SmartThingsSwitch(SmartThingsEntity, SwitchEntity): """Define a SmartThings switch.""" entity_description: SmartThingsSwitchEntityDescription + created_issue: bool = False def __init__( self, @@ -184,16 +185,26 @@ class SmartThingsSwitch(SmartThingsEntity, SwitchEntity): async def async_added_to_hass(self) -> None: """Call when entity is added to hass.""" await super().async_added_to_hass() - if self.entity_description != SWITCH or self.device.device.components[ - MAIN - ].manufacturer_category not in { - Category.CLOTHING_CARE_MACHINE, - Category.COOKTOP, - Category.DRYER, - Category.WASHER, - Category.MICROWAVE, - Category.DISHWASHER, - }: + media_player = all( + capability in self.device.status[MAIN] + for capability in ( + Capability.AUDIO_MUTE, + Capability.AUDIO_VOLUME, + Capability.MEDIA_PLAYBACK, + ) + ) + if ( + self.entity_description != SWITCH + and self.device.device.components[MAIN].manufacturer_category + not in { + Category.CLOTHING_CARE_MACHINE, + Category.COOKTOP, + Category.DRYER, + Category.WASHER, + Category.MICROWAVE, + Category.DISHWASHER, + } + ) or (self.entity_description != SWITCH and not media_player): return automations = automations_with_entity(self.hass, self.entity_id) scripts = scripts_with_entity(self.hass, self.entity_id) @@ -211,6 +222,9 @@ class SmartThingsSwitch(SmartThingsEntity, SwitchEntity): if (item := entity_reg.async_get(entity_id)) ] + identifier = "media_player" if media_player else "appliance" + + self.created_issue = True async_create_issue( self.hass, DOMAIN, @@ -218,7 +232,7 @@ class SmartThingsSwitch(SmartThingsEntity, SwitchEntity): breaks_in_ha_version="2025.10.0", is_fixable=False, severity=IssueSeverity.WARNING, - translation_key="deprecated_switch_appliance", + translation_key=f"deprecated_switch_{identifier}", translation_placeholders={ "entity": self.entity_id, "items": "\n".join(items_list), @@ -228,16 +242,7 @@ class SmartThingsSwitch(SmartThingsEntity, SwitchEntity): async def async_will_remove_from_hass(self) -> None: """Call when entity will be removed from hass.""" await super().async_will_remove_from_hass() - if self.entity_description != SWITCH or self.device.device.components[ - MAIN - ].manufacturer_category not in { - Category.CLOTHING_CARE_MACHINE, - Category.COOKTOP, - Category.DRYER, - Category.WASHER, - Category.MICROWAVE, - Category.DISHWASHER, - }: + if not self.created_issue: return async_delete_issue(self.hass, DOMAIN, f"deprecated_switch_{self.entity_id}") diff --git a/tests/components/smartthings/test_switch.py b/tests/components/smartthings/test_switch.py index d3908ed10f5..2e360ff68e3 100644 --- a/tests/components/smartthings/test_switch.py +++ b/tests/components/smartthings/test_switch.py @@ -128,10 +128,11 @@ async def test_state_update( @pytest.mark.usefixtures("entity_registry_enabled_by_default") @pytest.mark.parametrize( - ("device_fixture", "entity_id"), + ("device_fixture", "entity_id", "translation_key"), [ - ("da_wm_wm_000001", "switch.washer"), - ("da_wm_wd_000001", "switch.dryer"), + ("da_wm_wm_000001", "switch.washer", "deprecated_switch_appliance"), + ("da_wm_wd_000001", "switch.dryer", "deprecated_switch_appliance"), + ("hw_q80r_soundbar", "switch.soundbar", "deprecated_switch_media_player"), ], ) async def test_create_issue( @@ -140,6 +141,7 @@ async def test_create_issue( mock_config_entry: MockConfigEntry, issue_registry: ir.IssueRegistry, entity_id: str, + translation_key: str, ) -> None: """Test we create an issue when an automation or script is using a deprecated entity.""" issue_id = f"deprecated_switch_{entity_id}" @@ -187,7 +189,7 @@ async def test_create_issue( assert len(issue_registry.issues) == 1 issue = issue_registry.async_get_issue(DOMAIN, issue_id) assert issue is not None - assert issue.translation_key == "deprecated_switch_appliance" + assert issue.translation_key == translation_key assert issue.translation_placeholders == { "entity": entity_id, "items": "- [test](/config/automation/edit/test)\n- [test](/config/script/edit/test)", From 22d1b8e1cd0b30b19dbc4024055e75e364429cd4 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Wed, 26 Mar 2025 19:36:04 +0100 Subject: [PATCH 3109/3148] Bump deebot-client to 12.4.0 (#141501) --- homeassistant/components/ecovacs/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ecovacs/manifest.json b/homeassistant/components/ecovacs/manifest.json index 6d3dc5c9be6..acb5b620719 100644 --- a/homeassistant/components/ecovacs/manifest.json +++ b/homeassistant/components/ecovacs/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/ecovacs", "iot_class": "cloud_push", "loggers": ["sleekxmppfs", "sucks", "deebot_client"], - "requirements": ["py-sucks==0.9.10", "deebot-client==12.3.1"] + "requirements": ["py-sucks==0.9.10", "deebot-client==12.4.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 08bf975f23e..d7db5450a5f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -758,7 +758,7 @@ debugpy==1.8.13 # decora==0.6 # homeassistant.components.ecovacs -deebot-client==12.3.1 +deebot-client==12.4.0 # homeassistant.components.ihc # homeassistant.components.namecheapdns diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9cadd834d53..229c1a76559 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -649,7 +649,7 @@ dbus-fast==2.43.0 debugpy==1.8.13 # homeassistant.components.ecovacs -deebot-client==12.3.1 +deebot-client==12.4.0 # homeassistant.components.ihc # homeassistant.components.namecheapdns From 930b4a2c817d7bc8b06ab131aa6b7cf7d3005bba Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 26 Mar 2025 20:18:52 +0100 Subject: [PATCH 3110/3148] Capitalize "Ethernet" in `roku` sensor name (#141509) * Capitalize "Ethernet" in `roku` sensor name * Update test_binary_sensor.py --- homeassistant/components/roku/strings.json | 2 +- tests/components/roku/test_binary_sensor.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/roku/strings.json b/homeassistant/components/roku/strings.json index 04348bc3bfb..62f1f8b1736 100644 --- a/homeassistant/components/roku/strings.json +++ b/homeassistant/components/roku/strings.json @@ -47,7 +47,7 @@ "name": "Supports AirPlay" }, "supports_ethernet": { - "name": "Supports ethernet" + "name": "Supports Ethernet" }, "supports_find_remote": { "name": "Supports find remote" diff --git a/tests/components/roku/test_binary_sensor.py b/tests/components/roku/test_binary_sensor.py index ad27a857101..c3aec4f0968 100644 --- a/tests/components/roku/test_binary_sensor.py +++ b/tests/components/roku/test_binary_sensor.py @@ -50,7 +50,7 @@ async def test_roku_binary_sensors( assert entry.unique_id == f"{UPNP_SERIAL}_supports_ethernet" assert entry.entity_category == EntityCategory.DIAGNOSTIC assert state.state == STATE_ON - assert state.attributes.get(ATTR_FRIENDLY_NAME) == "My Roku 3 Supports ethernet" + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "My Roku 3 Supports Ethernet" assert ATTR_DEVICE_CLASS not in state.attributes state = hass.states.get("binary_sensor.my_roku_3_supports_find_remote") @@ -125,7 +125,7 @@ async def test_rokutv_binary_sensors( assert entry.entity_category == EntityCategory.DIAGNOSTIC assert state.state == STATE_ON assert ( - state.attributes.get(ATTR_FRIENDLY_NAME) == '58" Onn Roku TV Supports ethernet' + state.attributes.get(ATTR_FRIENDLY_NAME) == '58" Onn Roku TV Supports Ethernet' ) assert ATTR_DEVICE_CLASS not in state.attributes From eb901bcf3a8bc73fa944fc29ff2c8c38ff022b4e Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 26 Mar 2025 20:30:03 +0100 Subject: [PATCH 3111/3148] Bump version to 2025.5.0dev0 (#141507) --- .github/workflows/ci.yaml | 2 +- homeassistant/const.py | 2 +- pyproject.toml | 1437 ++++++++++++++++++------------------- 3 files changed, 716 insertions(+), 725 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index c46ec3cda54..a843133f1a5 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -40,7 +40,7 @@ env: CACHE_VERSION: 12 UV_CACHE_VERSION: 1 MYPY_CACHE_VERSION: 9 - HA_SHORT_VERSION: "2025.4" + HA_SHORT_VERSION: "2025.5" DEFAULT_PYTHON: "3.13" ALL_PYTHON_VERSIONS: "['3.13']" # 10.3 is the oldest supported version diff --git a/homeassistant/const.py b/homeassistant/const.py index b9695c350a7..a6f39db8532 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -24,7 +24,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2025 -MINOR_VERSION: Final = 4 +MINOR_VERSION: Final = 5 PATCH_VERSION: Final = "0.dev0" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" diff --git a/pyproject.toml b/pyproject.toml index a85b3d99c67..0a56de0f6f7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,96 +3,96 @@ requires = ["setuptools==77.0.3"] build-backend = "setuptools.build_meta" [project] -name = "homeassistant" -version = "2025.4.0.dev0" -license = "Apache-2.0" +name = "homeassistant" +version = "2025.5.0.dev0" +license = "Apache-2.0" license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"] description = "Open-source home automation platform running on Python 3." -readme = "README.rst" -authors = [ - {name = "The Home Assistant Authors", email = "hello@home-assistant.io"} +readme = "README.rst" +authors = [ + { name = "The Home Assistant Authors", email = "hello@home-assistant.io" }, ] -keywords = ["home", "automation"] +keywords = ["home", "automation"] classifiers = [ - "Development Status :: 5 - Production/Stable", - "Intended Audience :: End Users/Desktop", - "Intended Audience :: Developers", - "Operating System :: OS Independent", - "Programming Language :: Python :: 3.13", - "Topic :: Home Automation", + "Development Status :: 5 - Production/Stable", + "Intended Audience :: End Users/Desktop", + "Intended Audience :: Developers", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3.13", + "Topic :: Home Automation", ] requires-python = ">=3.13.0" -dependencies = [ - "aiodns==3.2.0", - # Integrations may depend on hassio integration without listing it to - # change behavior based on presence of supervisor. Deprecated with #127228 - # Lib can be removed with 2025.11 - "aiohasupervisor==0.3.0", - "aiohttp==3.11.14", - "aiohttp_cors==0.7.0", - "aiohttp-fast-zlib==0.2.3", - "aiohttp-asyncmdnsresolver==0.1.1", - "aiozoneinfo==0.2.3", - "annotatedyaml==0.4.5", - "astral==2.2", - "async-interrupt==1.2.2", - "attrs==25.1.0", - "atomicwrites-homeassistant==1.4.1", - "audioop-lts==0.2.1", - "awesomeversion==24.6.0", - "bcrypt==4.2.0", - "certifi>=2021.5.30", - "ciso8601==2.3.2", - "cronsim==2.6", - "fnv-hash-fast==1.4.0", - # hass-nabucasa is imported by helpers which don't depend on the cloud - # integration - "hass-nabucasa==0.94.0", - # When bumping httpx, please check the version pins of - # httpcore, anyio, and h11 in gen_requirements_all - "httpx==0.28.1", - "home-assistant-bluetooth==1.13.1", - "ifaddr==0.2.0", - "Jinja2==3.1.6", - "lru-dict==1.3.0", - "PyJWT==2.10.1", - # PyJWT has loose dependency. We want the latest one. - "cryptography==44.0.1", - "Pillow==11.1.0", - "propcache==0.3.0", - "pyOpenSSL==25.0.0", - "orjson==3.10.16", - "packaging>=23.1", - "psutil-home-assistant==0.0.1", - "python-slugify==8.0.4", - "PyYAML==6.0.2", - "requests==2.32.3", - "securetar==2025.2.1", - "SQLAlchemy==2.0.39", - "standard-aifc==3.13.0", - "standard-telnetlib==3.13.0", - "typing-extensions>=4.13.0,<5.0", - "ulid-transform==1.4.0", - # Constrain urllib3 to ensure we deal with CVE-2020-26137 and CVE-2021-33503 - # Temporary setting an upper bound, to prevent compat issues with urllib3>=2 - # https://github.com/home-assistant/core/issues/97248 - "urllib3>=1.26.5,<2", - "uv==0.6.10", - "voluptuous==0.15.2", - "voluptuous-serialize==2.6.0", - "voluptuous-openapi==0.0.6", - "yarl==1.18.3", - "webrtc-models==0.3.0", - "zeroconf==0.146.0" +dependencies = [ + "aiodns==3.2.0", + # Integrations may depend on hassio integration without listing it to + # change behavior based on presence of supervisor. Deprecated with #127228 + # Lib can be removed with 2025.11 + "aiohasupervisor==0.3.0", + "aiohttp==3.11.14", + "aiohttp_cors==0.7.0", + "aiohttp-fast-zlib==0.2.3", + "aiohttp-asyncmdnsresolver==0.1.1", + "aiozoneinfo==0.2.3", + "annotatedyaml==0.4.5", + "astral==2.2", + "async-interrupt==1.2.2", + "attrs==25.1.0", + "atomicwrites-homeassistant==1.4.1", + "audioop-lts==0.2.1", + "awesomeversion==24.6.0", + "bcrypt==4.2.0", + "certifi>=2021.5.30", + "ciso8601==2.3.2", + "cronsim==2.6", + "fnv-hash-fast==1.4.0", + # hass-nabucasa is imported by helpers which don't depend on the cloud + # integration + "hass-nabucasa==0.94.0", + # When bumping httpx, please check the version pins of + # httpcore, anyio, and h11 in gen_requirements_all + "httpx==0.28.1", + "home-assistant-bluetooth==1.13.1", + "ifaddr==0.2.0", + "Jinja2==3.1.6", + "lru-dict==1.3.0", + "PyJWT==2.10.1", + # PyJWT has loose dependency. We want the latest one. + "cryptography==44.0.1", + "Pillow==11.1.0", + "propcache==0.3.0", + "pyOpenSSL==25.0.0", + "orjson==3.10.16", + "packaging>=23.1", + "psutil-home-assistant==0.0.1", + "python-slugify==8.0.4", + "PyYAML==6.0.2", + "requests==2.32.3", + "securetar==2025.2.1", + "SQLAlchemy==2.0.39", + "standard-aifc==3.13.0", + "standard-telnetlib==3.13.0", + "typing-extensions>=4.13.0,<5.0", + "ulid-transform==1.4.0", + # Constrain urllib3 to ensure we deal with CVE-2020-26137 and CVE-2021-33503 + # Temporary setting an upper bound, to prevent compat issues with urllib3>=2 + # https://github.com/home-assistant/core/issues/97248 + "urllib3>=1.26.5,<2", + "uv==0.6.10", + "voluptuous==0.15.2", + "voluptuous-serialize==2.6.0", + "voluptuous-openapi==0.0.6", + "yarl==1.18.3", + "webrtc-models==0.3.0", + "zeroconf==0.146.0", ] [project.urls] -"Homepage" = "https://www.home-assistant.io/" +"Homepage" = "https://www.home-assistant.io/" "Source Code" = "https://github.com/home-assistant/core" "Bug Reports" = "https://github.com/home-assistant/core/issues" -"Docs: Dev" = "https://developers.home-assistant.io/" -"Discord" = "https://www.home-assistant.io/join-chat/" -"Forum" = "https://community.home-assistant.io/" +"Docs: Dev" = "https://developers.home-assistant.io/" +"Discord" = "https://www.home-assistant.io/join-chat/" +"Forum" = "https://community.home-assistant.io/" [project.scripts] hass = "homeassistant.__main__:main" @@ -119,30 +119,28 @@ init-hook = """\ ) \ """ load-plugins = [ - "pylint.extensions.code_style", - "pylint.extensions.typing", - "hass_decorator", - "hass_enforce_class_module", - "hass_enforce_sorted_platforms", - "hass_enforce_super_call", - "hass_enforce_type_hints", - "hass_inheritance", - "hass_imports", - "hass_logger", - "pylint_per_file_ignores", + "pylint.extensions.code_style", + "pylint.extensions.typing", + "hass_decorator", + "hass_enforce_class_module", + "hass_enforce_sorted_platforms", + "hass_enforce_super_call", + "hass_enforce_type_hints", + "hass_inheritance", + "hass_imports", + "hass_logger", + "pylint_per_file_ignores", ] persistent = false extension-pkg-allow-list = [ - "av.audio.stream", - "av.logging", - "av.stream", - "ciso8601", - "orjson", - "cv2", -] -fail-on = [ - "I", + "av.audio.stream", + "av.logging", + "av.stream", + "ciso8601", + "orjson", + "cv2", ] +fail-on = ["I"] [tool.pylint.BASIC] class-const-naming-style = "any" @@ -167,257 +165,257 @@ class-const-naming-style = "any" # consider-using-namedtuple-or-dataclass - too opinionated # consider-using-assignment-expr - decision to use := better left to devs disable = [ - "format", - "abstract-method", - "cyclic-import", - "duplicate-code", - "inconsistent-return-statements", - "locally-disabled", - "not-context-manager", - "too-few-public-methods", - "too-many-ancestors", - "too-many-arguments", - "too-many-instance-attributes", - "too-many-lines", - "too-many-locals", - "too-many-public-methods", - "too-many-boolean-expressions", - "too-many-positional-arguments", - "wrong-import-order", - "consider-using-namedtuple-or-dataclass", - "consider-using-assignment-expr", - "possibly-used-before-assignment", + "format", + "abstract-method", + "cyclic-import", + "duplicate-code", + "inconsistent-return-statements", + "locally-disabled", + "not-context-manager", + "too-few-public-methods", + "too-many-ancestors", + "too-many-arguments", + "too-many-instance-attributes", + "too-many-lines", + "too-many-locals", + "too-many-public-methods", + "too-many-boolean-expressions", + "too-many-positional-arguments", + "wrong-import-order", + "consider-using-namedtuple-or-dataclass", + "consider-using-assignment-expr", + "possibly-used-before-assignment", - # Handled by ruff - # Ref: - "await-outside-async", # PLE1142 - "bad-str-strip-call", # PLE1310 - "bad-string-format-type", # PLE1307 - "bidirectional-unicode", # PLE2502 - "continue-in-finally", # PLE0116 - "duplicate-bases", # PLE0241 - "misplaced-bare-raise", # PLE0704 - "format-needs-mapping", # F502 - "function-redefined", # F811 - # Needed because ruff does not understand type of __all__ generated by a function - # "invalid-all-format", # PLE0605 - "invalid-all-object", # PLE0604 - "invalid-character-backspace", # PLE2510 - "invalid-character-esc", # PLE2513 - "invalid-character-nul", # PLE2514 - "invalid-character-sub", # PLE2512 - "invalid-character-zero-width-space", # PLE2515 - "logging-too-few-args", # PLE1206 - "logging-too-many-args", # PLE1205 - "missing-format-string-key", # F524 - "mixed-format-string", # F506 - "no-method-argument", # N805 - "no-self-argument", # N805 - "nonexistent-operator", # B002 - "nonlocal-without-binding", # PLE0117 - "not-in-loop", # F701, F702 - "notimplemented-raised", # F901 - "return-in-init", # PLE0101 - "return-outside-function", # F706 - "syntax-error", # E999 - "too-few-format-args", # F524 - "too-many-format-args", # F522 - "too-many-star-expressions", # F622 - "truncated-format-string", # F501 - "undefined-all-variable", # F822 - "undefined-variable", # F821 - "used-prior-global-declaration", # PLE0118 - "yield-inside-async-function", # PLE1700 - "yield-outside-function", # F704 - "anomalous-backslash-in-string", # W605 - "assert-on-string-literal", # PLW0129 - "assert-on-tuple", # F631 - "bad-format-string", # W1302, F - "bad-format-string-key", # W1300, F - "bare-except", # E722 - "binary-op-exception", # PLW0711 - "cell-var-from-loop", # B023 - # "dangerous-default-value", # B006, ruff catches new occurrences, needs more work - "duplicate-except", # B014 - "duplicate-key", # F601 - "duplicate-string-formatting-argument", # F - "duplicate-value", # F - "eval-used", # S307 - "exec-used", # S102 - "expression-not-assigned", # B018 - "f-string-without-interpolation", # F541 - "forgotten-debug-statement", # T100 - "format-string-without-interpolation", # F - # "global-statement", # PLW0603, ruff catches new occurrences, needs more work - "global-variable-not-assigned", # PLW0602 - "implicit-str-concat", # ISC001 - "import-self", # PLW0406 - "inconsistent-quotes", # Q000 - "invalid-envvar-default", # PLW1508 - "keyword-arg-before-vararg", # B026 - "logging-format-interpolation", # G - "logging-fstring-interpolation", # G - "logging-not-lazy", # G - "misplaced-future", # F404 - "named-expr-without-context", # PLW0131 - "nested-min-max", # PLW3301 - "pointless-statement", # B018 - "raise-missing-from", # B904 - "redefined-builtin", # A001 - "try-except-raise", # TRY302 - "unused-argument", # ARG001, we don't use it - "unused-format-string-argument", #F507 - "unused-format-string-key", # F504 - "unused-import", # F401 - "unused-variable", # F841 - "useless-else-on-loop", # PLW0120 - "wildcard-import", # F403 - "bad-classmethod-argument", # N804 - "consider-iterating-dictionary", # SIM118 - "empty-docstring", # D419 - "invalid-name", # N815 - "line-too-long", # E501, disabled globally - "missing-class-docstring", # D101 - "missing-final-newline", # W292 - "missing-function-docstring", # D103 - "missing-module-docstring", # D100 - "multiple-imports", #E401 - "singleton-comparison", # E711, E712 - "subprocess-run-check", # PLW1510 - "superfluous-parens", # UP034 - "ungrouped-imports", # I001 - "unidiomatic-typecheck", # E721 - "unnecessary-direct-lambda-call", # PLC3002 - "unnecessary-lambda-assignment", # PLC3001 - "unnecessary-pass", # PIE790 - "unneeded-not", # SIM208 - "useless-import-alias", # PLC0414 - "wrong-import-order", # I001 - "wrong-import-position", # E402 - "comparison-of-constants", # PLR0133 - "comparison-with-itself", # PLR0124 - "consider-alternative-union-syntax", # UP007 - "consider-merging-isinstance", # PLR1701 - "consider-using-alias", # UP006 - "consider-using-dict-comprehension", # C402 - "consider-using-generator", # C417 - "consider-using-get", # SIM401 - "consider-using-set-comprehension", # C401 - "consider-using-sys-exit", # PLR1722 - "consider-using-ternary", # SIM108 - "literal-comparison", # F632 - "property-with-parameters", # PLR0206 - "super-with-arguments", # UP008 - "too-many-branches", # PLR0912 - "too-many-return-statements", # PLR0911 - "too-many-statements", # PLR0915 - "trailing-comma-tuple", # COM818 - "unnecessary-comprehension", # C416 - "use-a-generator", # C417 - "use-dict-literal", # C406 - "use-list-literal", # C405 - "useless-object-inheritance", # UP004 - "useless-return", # PLR1711 - "no-else-break", # RET508 - "no-else-continue", # RET507 - "no-else-raise", # RET506 - "no-else-return", # RET505 - "broad-except", # BLE001 - "protected-access", # SLF001 - "broad-exception-raised", # TRY002 - "consider-using-f-string", # PLC0209 - # "no-self-use", # PLR6301 # Optional plugin, not enabled + # Handled by ruff + # Ref: + "await-outside-async", # PLE1142 + "bad-str-strip-call", # PLE1310 + "bad-string-format-type", # PLE1307 + "bidirectional-unicode", # PLE2502 + "continue-in-finally", # PLE0116 + "duplicate-bases", # PLE0241 + "misplaced-bare-raise", # PLE0704 + "format-needs-mapping", # F502 + "function-redefined", # F811 + # Needed because ruff does not understand type of __all__ generated by a function + # "invalid-all-format", # PLE0605 + "invalid-all-object", # PLE0604 + "invalid-character-backspace", # PLE2510 + "invalid-character-esc", # PLE2513 + "invalid-character-nul", # PLE2514 + "invalid-character-sub", # PLE2512 + "invalid-character-zero-width-space", # PLE2515 + "logging-too-few-args", # PLE1206 + "logging-too-many-args", # PLE1205 + "missing-format-string-key", # F524 + "mixed-format-string", # F506 + "no-method-argument", # N805 + "no-self-argument", # N805 + "nonexistent-operator", # B002 + "nonlocal-without-binding", # PLE0117 + "not-in-loop", # F701, F702 + "notimplemented-raised", # F901 + "return-in-init", # PLE0101 + "return-outside-function", # F706 + "syntax-error", # E999 + "too-few-format-args", # F524 + "too-many-format-args", # F522 + "too-many-star-expressions", # F622 + "truncated-format-string", # F501 + "undefined-all-variable", # F822 + "undefined-variable", # F821 + "used-prior-global-declaration", # PLE0118 + "yield-inside-async-function", # PLE1700 + "yield-outside-function", # F704 + "anomalous-backslash-in-string", # W605 + "assert-on-string-literal", # PLW0129 + "assert-on-tuple", # F631 + "bad-format-string", # W1302, F + "bad-format-string-key", # W1300, F + "bare-except", # E722 + "binary-op-exception", # PLW0711 + "cell-var-from-loop", # B023 + # "dangerous-default-value", # B006, ruff catches new occurrences, needs more work + "duplicate-except", # B014 + "duplicate-key", # F601 + "duplicate-string-formatting-argument", # F + "duplicate-value", # F + "eval-used", # S307 + "exec-used", # S102 + "expression-not-assigned", # B018 + "f-string-without-interpolation", # F541 + "forgotten-debug-statement", # T100 + "format-string-without-interpolation", # F + # "global-statement", # PLW0603, ruff catches new occurrences, needs more work + "global-variable-not-assigned", # PLW0602 + "implicit-str-concat", # ISC001 + "import-self", # PLW0406 + "inconsistent-quotes", # Q000 + "invalid-envvar-default", # PLW1508 + "keyword-arg-before-vararg", # B026 + "logging-format-interpolation", # G + "logging-fstring-interpolation", # G + "logging-not-lazy", # G + "misplaced-future", # F404 + "named-expr-without-context", # PLW0131 + "nested-min-max", # PLW3301 + "pointless-statement", # B018 + "raise-missing-from", # B904 + "redefined-builtin", # A001 + "try-except-raise", # TRY302 + "unused-argument", # ARG001, we don't use it + "unused-format-string-argument", #F507 + "unused-format-string-key", # F504 + "unused-import", # F401 + "unused-variable", # F841 + "useless-else-on-loop", # PLW0120 + "wildcard-import", # F403 + "bad-classmethod-argument", # N804 + "consider-iterating-dictionary", # SIM118 + "empty-docstring", # D419 + "invalid-name", # N815 + "line-too-long", # E501, disabled globally + "missing-class-docstring", # D101 + "missing-final-newline", # W292 + "missing-function-docstring", # D103 + "missing-module-docstring", # D100 + "multiple-imports", #E401 + "singleton-comparison", # E711, E712 + "subprocess-run-check", # PLW1510 + "superfluous-parens", # UP034 + "ungrouped-imports", # I001 + "unidiomatic-typecheck", # E721 + "unnecessary-direct-lambda-call", # PLC3002 + "unnecessary-lambda-assignment", # PLC3001 + "unnecessary-pass", # PIE790 + "unneeded-not", # SIM208 + "useless-import-alias", # PLC0414 + "wrong-import-order", # I001 + "wrong-import-position", # E402 + "comparison-of-constants", # PLR0133 + "comparison-with-itself", # PLR0124 + "consider-alternative-union-syntax", # UP007 + "consider-merging-isinstance", # PLR1701 + "consider-using-alias", # UP006 + "consider-using-dict-comprehension", # C402 + "consider-using-generator", # C417 + "consider-using-get", # SIM401 + "consider-using-set-comprehension", # C401 + "consider-using-sys-exit", # PLR1722 + "consider-using-ternary", # SIM108 + "literal-comparison", # F632 + "property-with-parameters", # PLR0206 + "super-with-arguments", # UP008 + "too-many-branches", # PLR0912 + "too-many-return-statements", # PLR0911 + "too-many-statements", # PLR0915 + "trailing-comma-tuple", # COM818 + "unnecessary-comprehension", # C416 + "use-a-generator", # C417 + "use-dict-literal", # C406 + "use-list-literal", # C405 + "useless-object-inheritance", # UP004 + "useless-return", # PLR1711 + "no-else-break", # RET508 + "no-else-continue", # RET507 + "no-else-raise", # RET506 + "no-else-return", # RET505 + "broad-except", # BLE001 + "protected-access", # SLF001 + "broad-exception-raised", # TRY002 + "consider-using-f-string", # PLC0209 + # "no-self-use", # PLR6301 # Optional plugin, not enabled - # Handled by mypy - # Ref: - "abstract-class-instantiated", - "arguments-differ", - "assigning-non-slot", - "assignment-from-no-return", - "assignment-from-none", - "bad-exception-cause", - "bad-format-character", - "bad-reversed-sequence", - "bad-super-call", - "bad-thread-instantiation", - "catching-non-exception", - "comparison-with-callable", - "deprecated-class", - "dict-iter-missing-items", - "format-combined-specification", - "global-variable-undefined", - "import-error", - "inconsistent-mro", - "inherit-non-class", - "init-is-generator", - "invalid-class-object", - "invalid-enum-extension", - "invalid-envvar-value", - "invalid-format-returned", - "invalid-hash-returned", - "invalid-metaclass", - "invalid-overridden-method", - "invalid-repr-returned", - "invalid-sequence-index", - "invalid-slice-index", - "invalid-slots-object", - "invalid-slots", - "invalid-star-assignment-target", - "invalid-str-returned", - "invalid-unary-operand-type", - "invalid-unicode-codec", - "isinstance-second-argument-not-valid-type", - "method-hidden", - "misplaced-format-function", - "missing-format-argument-key", - "missing-format-attribute", - "missing-kwoa", - "no-member", - "no-value-for-parameter", - "non-iterator-returned", - "non-str-assignment-to-dunder-name", - "nonlocal-and-global", - "not-a-mapping", - "not-an-iterable", - "not-async-context-manager", - "not-callable", - "not-context-manager", - "overridden-final-method", - "raising-bad-type", - "raising-non-exception", - "redundant-keyword-arg", - "relative-beyond-top-level", - "self-cls-assignment", - "signature-differs", - "star-needs-assignment-target", - "subclassed-final-class", - "super-without-brackets", - "too-many-function-args", - "typevar-double-variance", - "typevar-name-mismatch", - "unbalanced-dict-unpacking", - "unbalanced-tuple-unpacking", - "unexpected-keyword-arg", - "unhashable-member", - "unpacking-non-sequence", - "unsubscriptable-object", - "unsupported-assignment-operation", - "unsupported-binary-operation", - "unsupported-delete-operation", - "unsupported-membership-test", - "used-before-assignment", - "using-final-decorator-in-unsupported-version", - "wrong-exception-operation", + # Handled by mypy + # Ref: + "abstract-class-instantiated", + "arguments-differ", + "assigning-non-slot", + "assignment-from-no-return", + "assignment-from-none", + "bad-exception-cause", + "bad-format-character", + "bad-reversed-sequence", + "bad-super-call", + "bad-thread-instantiation", + "catching-non-exception", + "comparison-with-callable", + "deprecated-class", + "dict-iter-missing-items", + "format-combined-specification", + "global-variable-undefined", + "import-error", + "inconsistent-mro", + "inherit-non-class", + "init-is-generator", + "invalid-class-object", + "invalid-enum-extension", + "invalid-envvar-value", + "invalid-format-returned", + "invalid-hash-returned", + "invalid-metaclass", + "invalid-overridden-method", + "invalid-repr-returned", + "invalid-sequence-index", + "invalid-slice-index", + "invalid-slots-object", + "invalid-slots", + "invalid-star-assignment-target", + "invalid-str-returned", + "invalid-unary-operand-type", + "invalid-unicode-codec", + "isinstance-second-argument-not-valid-type", + "method-hidden", + "misplaced-format-function", + "missing-format-argument-key", + "missing-format-attribute", + "missing-kwoa", + "no-member", + "no-value-for-parameter", + "non-iterator-returned", + "non-str-assignment-to-dunder-name", + "nonlocal-and-global", + "not-a-mapping", + "not-an-iterable", + "not-async-context-manager", + "not-callable", + "not-context-manager", + "overridden-final-method", + "raising-bad-type", + "raising-non-exception", + "redundant-keyword-arg", + "relative-beyond-top-level", + "self-cls-assignment", + "signature-differs", + "star-needs-assignment-target", + "subclassed-final-class", + "super-without-brackets", + "too-many-function-args", + "typevar-double-variance", + "typevar-name-mismatch", + "unbalanced-dict-unpacking", + "unbalanced-tuple-unpacking", + "unexpected-keyword-arg", + "unhashable-member", + "unpacking-non-sequence", + "unsubscriptable-object", + "unsupported-assignment-operation", + "unsupported-binary-operation", + "unsupported-delete-operation", + "unsupported-membership-test", + "used-before-assignment", + "using-final-decorator-in-unsupported-version", + "wrong-exception-operation", ] enable = [ - #"useless-suppression", # temporarily every now and then to clean them up - "use-symbolic-message-instead", + #"useless-suppression", # temporarily every now and then to clean them up + "use-symbolic-message-instead", ] per-file-ignores = [ - # redefined-outer-name: Tests reference fixtures in the test function - # use-implicit-booleaness-not-comparison: Tests need to validate that a list - # or a dict is returned - "/tests/:redefined-outer-name,use-implicit-booleaness-not-comparison", + # redefined-outer-name: Tests reference fixtures in the test function + # use-implicit-booleaness-not-comparison: Tests need to validate that a list + # or a dict is returned + "/tests/:redefined-outer-name,use-implicit-booleaness-not-comparison", ] [tool.pylint.REPORTS] @@ -425,7 +423,7 @@ score = false [tool.pylint.TYPECHECK] ignored-classes = [ - "_CountingAttr", # for attrs + "_CountingAttr", # for attrs ] mixin-class-rgx = ".*[Mm]ix[Ii]n" @@ -434,9 +432,9 @@ expected-line-ending-format = "LF" [tool.pylint.EXCEPTIONS] overgeneral-exceptions = [ - "builtins.BaseException", - "builtins.Exception", - # "homeassistant.exceptions.HomeAssistantError", # too many issues + "builtins.BaseException", + "builtins.Exception", + # "homeassistant.exceptions.HomeAssistantError", # too many issues ] [tool.pylint.TYPING] @@ -446,241 +444,236 @@ runtime-typing = false max-line-length-suggestions = 72 [tool.pytest.ini_options] -testpaths = [ - "tests", -] -norecursedirs = [ - ".git", - "testing_config", -] +testpaths = ["tests"] +norecursedirs = [".git", "testing_config"] log_format = "%(asctime)s.%(msecs)03d %(levelname)-8s %(threadName)s %(name)s:%(filename)s:%(lineno)s %(message)s" log_date_format = "%Y-%m-%d %H:%M:%S" asyncio_mode = "auto" asyncio_default_fixture_loop_scope = "function" filterwarnings = [ - "error::sqlalchemy.exc.SAWarning", + "error::sqlalchemy.exc.SAWarning", - # -- HomeAssistant - aiohttp - # Overwrite web.Application to pass a custom default argument to _make_request - "ignore:Inheritance class HomeAssistantApplication from web.Application is discouraged:DeprecationWarning", - # Hass wraps `ClientSession.close` to emit a warning if the session is closed accidentally - "ignore:Setting custom ClientSession.close attribute is discouraged:DeprecationWarning:homeassistant.helpers.aiohttp_client", - # Modify app state for testing - "ignore:Changing state of started or joined application is deprecated:DeprecationWarning:tests.components.http.test_ban", + # -- HomeAssistant - aiohttp + # Overwrite web.Application to pass a custom default argument to _make_request + "ignore:Inheritance class HomeAssistantApplication from web.Application is discouraged:DeprecationWarning", + # Hass wraps `ClientSession.close` to emit a warning if the session is closed accidentally + "ignore:Setting custom ClientSession.close attribute is discouraged:DeprecationWarning:homeassistant.helpers.aiohttp_client", + # Modify app state for testing + "ignore:Changing state of started or joined application is deprecated:DeprecationWarning:tests.components.http.test_ban", - # -- Tests - # Ignore custom pytest marks - "ignore:Unknown pytest.mark.disable_autouse_fixture:pytest.PytestUnknownMarkWarning:tests.components.met", - "ignore:Unknown pytest.mark.dataset:pytest.PytestUnknownMarkWarning:tests.components.screenlogic", - # https://github.com/rokam/sunweg/blob/3.1.0/sunweg/plant.py#L96 - v3.1.0 - 2024-10-02 - "ignore:The '(kwh_per_kwp|performance_rate)' property is deprecated and will return 0:DeprecationWarning:tests.components.sunweg.test_init", + # -- Tests + # Ignore custom pytest marks + "ignore:Unknown pytest.mark.disable_autouse_fixture:pytest.PytestUnknownMarkWarning:tests.components.met", + "ignore:Unknown pytest.mark.dataset:pytest.PytestUnknownMarkWarning:tests.components.screenlogic", + # https://github.com/rokam/sunweg/blob/3.1.0/sunweg/plant.py#L96 - v3.1.0 - 2024-10-02 + "ignore:The '(kwh_per_kwp|performance_rate)' property is deprecated and will return 0:DeprecationWarning:tests.components.sunweg.test_init", - # -- design choice 3rd party - # https://github.com/gwww/elkm1/blob/2.2.10/elkm1_lib/util.py#L8-L19 - "ignore:ssl.TLSVersion.TLSv1 is deprecated:DeprecationWarning:elkm1_lib.util", - # https://github.com/allenporter/ical/pull/215 - # https://github.com/allenporter/ical/blob/8.2.0/ical/util.py#L21-L23 - "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:ical.util", - # https://github.com/bachya/regenmaschine/blob/2024.03.0/regenmaschine/client.py#L52 - "ignore:ssl.TLSVersion.SSLv3 is deprecated:DeprecationWarning:regenmaschine.client", + # -- design choice 3rd party + # https://github.com/gwww/elkm1/blob/2.2.10/elkm1_lib/util.py#L8-L19 + "ignore:ssl.TLSVersion.TLSv1 is deprecated:DeprecationWarning:elkm1_lib.util", + # https://github.com/allenporter/ical/pull/215 + # https://github.com/allenporter/ical/blob/8.2.0/ical/util.py#L21-L23 + "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:ical.util", + # https://github.com/bachya/regenmaschine/blob/2024.03.0/regenmaschine/client.py#L52 + "ignore:ssl.TLSVersion.SSLv3 is deprecated:DeprecationWarning:regenmaschine.client", - # -- Setuptools DeprecationWarnings - # https://github.com/googleapis/google-cloud-python/issues/11184 - # https://github.com/zopefoundation/meta/issues/194 - # https://github.com/Azure/azure-sdk-for-python - "ignore:Deprecated call to `pkg_resources.declare_namespace\\(('azure'|'google.*'|'pywinusb'|'repoze'|'xbox'|'zope')\\)`:DeprecationWarning:pkg_resources", + # -- Setuptools DeprecationWarnings + # https://github.com/googleapis/google-cloud-python/issues/11184 + # https://github.com/zopefoundation/meta/issues/194 + # https://github.com/Azure/azure-sdk-for-python + "ignore:Deprecated call to `pkg_resources.declare_namespace\\(('azure'|'google.*'|'pywinusb'|'repoze'|'xbox'|'zope')\\)`:DeprecationWarning:pkg_resources", - # -- tracked upstream / open PRs - # - pyOpenSSL v24.2.1 - # https://github.com/certbot/certbot/issues/9828 - v2.11.0 - # https://github.com/certbot/certbot/issues/9992 - "ignore:X509Extension support in pyOpenSSL is deprecated. You should use the APIs in cryptography:DeprecationWarning:acme.crypto_util", - "ignore:CSR support in pyOpenSSL is deprecated. You should use the APIs in cryptography:DeprecationWarning:acme.crypto_util", - "ignore:CSR support in pyOpenSSL is deprecated. You should use the APIs in cryptography:DeprecationWarning:josepy.util", - # - other - # https://github.com/foxel/python_ndms2_client/issues/6 - v0.1.3 - # https://github.com/foxel/python_ndms2_client/pull/8 - "ignore:'telnetlib' is deprecated and slated for removal in Python 3.13:DeprecationWarning:ndms2_client.connection", + # -- tracked upstream / open PRs + # - pyOpenSSL v24.2.1 + # https://github.com/certbot/certbot/issues/9828 - v2.11.0 + # https://github.com/certbot/certbot/issues/9992 + "ignore:X509Extension support in pyOpenSSL is deprecated. You should use the APIs in cryptography:DeprecationWarning:acme.crypto_util", + "ignore:CSR support in pyOpenSSL is deprecated. You should use the APIs in cryptography:DeprecationWarning:acme.crypto_util", + "ignore:CSR support in pyOpenSSL is deprecated. You should use the APIs in cryptography:DeprecationWarning:josepy.util", + # - other + # https://github.com/foxel/python_ndms2_client/issues/6 - v0.1.3 + # https://github.com/foxel/python_ndms2_client/pull/8 + "ignore:'telnetlib' is deprecated and slated for removal in Python 3.13:DeprecationWarning:ndms2_client.connection", - # -- fixed, waiting for release / update - # https://github.com/bachya/aiopurpleair/pull/200 - >=2023.10.0 - "ignore:datetime.*utcfromtimestamp\\(\\) is deprecated and scheduled for removal:DeprecationWarning:aiopurpleair.helpers.validators", - # https://bugs.launchpad.net/beautifulsoup/+bug/2076897 - >4.12.3 - "ignore:The 'strip_cdata' option of HTMLParser\\(\\) has never done anything and will eventually be removed:DeprecationWarning:bs4.builder._lxml", - # https://github.com/DataDog/datadogpy/pull/290 - >=0.23.0 - "ignore:invalid escape sequence:SyntaxWarning:.*datadog.dogstatsd.base", - # https://github.com/DataDog/datadogpy/pull/566/files - >=0.37.0 - "ignore:pkg_resources is deprecated as an API:DeprecationWarning:datadog.util.compat", - # https://github.com/fwestenberg/devialet/pull/6 - >1.4.5 - "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:devialet.devialet_api", - # https://github.com/httplib2/httplib2/pull/226 - >=0.21.0 - "ignore:ssl.PROTOCOL_TLS is deprecated:DeprecationWarning:httplib2", - # https://github.com/influxdata/influxdb-client-python/issues/603 >=1.45.0 - # https://github.com/influxdata/influxdb-client-python/pull/652 - "ignore:datetime.*utcfromtimestamp\\(\\) is deprecated and scheduled for removal:DeprecationWarning:influxdb_client.client.write.point", - # https://github.com/majuss/lupupy/pull/15 - >0.3.2 - "ignore:\"is not\" with 'str' literal. Did you mean \"!=\"?:SyntaxWarning:.*lupupy.devices.alarm", - # https://github.com/nextcord/nextcord/pull/1095 - >2.6.1 - "ignore:pkg_resources is deprecated as an API:DeprecationWarning:nextcord.health_check", - # https://github.com/eclipse/paho.mqtt.python/issues/653 - >=2.0.0 - # https://github.com/eclipse/paho.mqtt.python/pull/665 - "ignore:ssl.PROTOCOL_TLS is deprecated:DeprecationWarning:paho.mqtt.client", - # https://github.com/vacanza/python-holidays/discussions/1800 - >1.0.0 - "ignore::DeprecationWarning:holidays", - # https://github.com/rytilahti/python-miio/pull/1809 - >=0.6.0.dev0 - "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:miio.protocol", - "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:miio.miioprotocol", - # https://github.com/okunishinishi/python-stringcase/commit/6a5c5bbd3fe5337862abc7fd0853a0f36e18b2e1 - >1.2.0 - "ignore:invalid escape sequence:SyntaxWarning:.*stringcase", + # -- fixed, waiting for release / update + # https://github.com/bachya/aiopurpleair/pull/200 - >=2023.10.0 + "ignore:datetime.*utcfromtimestamp\\(\\) is deprecated and scheduled for removal:DeprecationWarning:aiopurpleair.helpers.validators", + # https://bugs.launchpad.net/beautifulsoup/+bug/2076897 - >4.12.3 + "ignore:The 'strip_cdata' option of HTMLParser\\(\\) has never done anything and will eventually be removed:DeprecationWarning:bs4.builder._lxml", + # https://github.com/DataDog/datadogpy/pull/290 - >=0.23.0 + "ignore:invalid escape sequence:SyntaxWarning:.*datadog.dogstatsd.base", + # https://github.com/DataDog/datadogpy/pull/566/files - >=0.37.0 + "ignore:pkg_resources is deprecated as an API:DeprecationWarning:datadog.util.compat", + # https://github.com/fwestenberg/devialet/pull/6 - >1.4.5 + "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:devialet.devialet_api", + # https://github.com/httplib2/httplib2/pull/226 - >=0.21.0 + "ignore:ssl.PROTOCOL_TLS is deprecated:DeprecationWarning:httplib2", + # https://github.com/influxdata/influxdb-client-python/issues/603 >=1.45.0 + # https://github.com/influxdata/influxdb-client-python/pull/652 + "ignore:datetime.*utcfromtimestamp\\(\\) is deprecated and scheduled for removal:DeprecationWarning:influxdb_client.client.write.point", + # https://github.com/majuss/lupupy/pull/15 - >0.3.2 + "ignore:\"is not\" with 'str' literal. Did you mean \"!=\"?:SyntaxWarning:.*lupupy.devices.alarm", + # https://github.com/nextcord/nextcord/pull/1095 - >2.6.1 + "ignore:pkg_resources is deprecated as an API:DeprecationWarning:nextcord.health_check", + # https://github.com/eclipse/paho.mqtt.python/issues/653 - >=2.0.0 + # https://github.com/eclipse/paho.mqtt.python/pull/665 + "ignore:ssl.PROTOCOL_TLS is deprecated:DeprecationWarning:paho.mqtt.client", + # https://github.com/vacanza/python-holidays/discussions/1800 - >1.0.0 + "ignore::DeprecationWarning:holidays", + # https://github.com/rytilahti/python-miio/pull/1809 - >=0.6.0.dev0 + "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:miio.protocol", + "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:miio.miioprotocol", + # https://github.com/okunishinishi/python-stringcase/commit/6a5c5bbd3fe5337862abc7fd0853a0f36e18b2e1 - >1.2.0 + "ignore:invalid escape sequence:SyntaxWarning:.*stringcase", - # -- fixed for Python 3.13 - # https://github.com/rhasspy/wyoming/commit/e34af30d455b6f2bb9e5cfb25fad8d276914bc54 - >=1.4.2 - "ignore:'audioop' is deprecated and slated for removal in Python 3.13:DeprecationWarning:wyoming.audio", + # -- fixed for Python 3.13 + # https://github.com/rhasspy/wyoming/commit/e34af30d455b6f2bb9e5cfb25fad8d276914bc54 - >=1.4.2 + "ignore:'audioop' is deprecated and slated for removal in Python 3.13:DeprecationWarning:wyoming.audio", - # -- other - # Locale changes might take some time to resolve upstream - # https://github.com/Squachen/micloud/blob/v_0.6/micloud/micloud.py#L35 - v0.6 - 2022-12-08 - "ignore:'locale.getdefaultlocale' is deprecated and slated for removal in Python 3.15:DeprecationWarning:micloud.micloud", - # https://github.com/MatsNl/pyatag/issues/11 - v0.3.7.1 - 2023-10-09 - "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:pyatag.gateway", - # https://github.com/lidatong/dataclasses-json/issues/328 - # https://github.com/lidatong/dataclasses-json/pull/351 - "ignore:The 'default' argument to fields is deprecated. Use 'dump_default' instead:DeprecationWarning:dataclasses_json.mm", - # https://pypi.org/project/emulated-roku/ - v0.3.0 - 2023-12-19 - # https://github.com/martonperei/emulated_roku - "ignore:loop argument is deprecated:DeprecationWarning:emulated_roku", - # https://github.com/w1ll1am23/pyeconet/blob/v0.1.23/src/pyeconet/api.py#L38 - v0.1.23 - 2024-10-08 - "ignore:ssl.PROTOCOL_TLS is deprecated:DeprecationWarning:pyeconet.api", - # https://github.com/thecynic/pylutron - v0.2.16 - 2024-10-22 - "ignore:setDaemon\\(\\) is deprecated, set the daemon attribute instead:DeprecationWarning:pylutron", - # https://github.com/pschmitt/pynuki/blob/1.6.3/pynuki/utils.py#L21 - v1.6.3 - 2024-02-24 - "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:pynuki.utils", - # https://github.com/lextudio/pysnmp/blob/v7.1.10/pysnmp/smi/compiler.py#L23-L31 - v7.1.10 - 2024-11-04 - "ignore:smiV1Relaxed is deprecated. Please use smi_v1_relaxed instead:DeprecationWarning:pysnmp.smi.compiler", - "ignore:getReadersFromUrls is deprecated. Please use get_readers_from_urls instead:DeprecationWarning:pysmi.reader.url", # wrong stacklevel - # https://github.com/briis/pyweatherflowudp/blob/v1.4.5/pyweatherflowudp/const.py#L20 - v1.4.5 - 2023-10-10 - "ignore:This function will be removed in future versions of pint:DeprecationWarning:pyweatherflowudp.const", - # Wrong stacklevel - # https://bugs.launchpad.net/beautifulsoup/+bug/2034451 fixed in >4.12.3 - "ignore:It looks like you're parsing an XML document using an HTML parser:UserWarning:html.parser", - # New in aiohttp - v3.9.0 - "ignore:It is recommended to use web.AppKey instances for keys:UserWarning:(homeassistant|tests|aiohttp_cors)", - # - SyntaxWarnings - # https://pypi.org/project/aprslib/ - v0.7.2 - 2022-07-10 - "ignore:invalid escape sequence:SyntaxWarning:.*aprslib.parsing.common", - "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:aprslib.parsing.common", - # https://pypi.org/project/panasonic-viera/ - v0.4.2 - 2024-04-24 - # https://github.com/florianholzapfel/panasonic-viera/blob/0.4.2/panasonic_viera/__init__.py#L789 - "ignore:invalid escape sequence:SyntaxWarning:.*panasonic_viera", - # https://pypi.org/project/pyblackbird/ - v0.6 - 2023-03-15 - # https://github.com/koolsb/pyblackbird/pull/9 -> closed - "ignore:invalid escape sequence:SyntaxWarning:.*pyblackbird", - # https://pypi.org/project/pyws66i/ - v1.1 - 2022-04-05 - "ignore:invalid escape sequence:SyntaxWarning:.*pyws66i", - # https://pypi.org/project/sanix/ - v1.0.6 - 2024-05-01 - # https://github.com/tomaszsluszniak/sanix_py/blob/v1.0.6/sanix/__init__.py#L42 - "ignore:invalid escape sequence:SyntaxWarning:.*sanix", - # https://pypi.org/project/sleekxmppfs/ - v1.4.1 - 2022-08-18 - "ignore:invalid escape sequence:SyntaxWarning:.*sleekxmppfs.thirdparty.mini_dateutil", # codespell:ignore thirdparty - # - pkg_resources - # https://pypi.org/project/aiomusiccast/ - v0.14.8 - 2023-03-20 - "ignore:pkg_resources is deprecated as an API:DeprecationWarning:aiomusiccast", - # https://pypi.org/project/habitipy/ - v0.3.3 - 2024-10-28 - "ignore:pkg_resources is deprecated as an API:DeprecationWarning:habitipy.api", - # https://github.com/eavanvalkenburg/pysiaalarm/blob/v3.1.1/src/pysiaalarm/data/data.py#L7 - v3.1.1 - 2023-04-17 - "ignore:pkg_resources is deprecated as an API:DeprecationWarning:pysiaalarm.data.data", - # https://pypi.org/project/pybotvac/ - v0.0.25 - 2024-04-11 - "ignore:pkg_resources is deprecated as an API:DeprecationWarning:pybotvac.version", - # https://github.com/home-assistant-ecosystem/python-mystrom/blob/2.2.0/pymystrom/__init__.py#L10 - v2.2.0 - 2023-05-21 - "ignore:pkg_resources is deprecated as an API:DeprecationWarning:pymystrom", + # -- other + # Locale changes might take some time to resolve upstream + # https://github.com/Squachen/micloud/blob/v_0.6/micloud/micloud.py#L35 - v0.6 - 2022-12-08 + "ignore:'locale.getdefaultlocale' is deprecated and slated for removal in Python 3.15:DeprecationWarning:micloud.micloud", + # https://github.com/MatsNl/pyatag/issues/11 - v0.3.7.1 - 2023-10-09 + "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:pyatag.gateway", + # https://github.com/lidatong/dataclasses-json/issues/328 + # https://github.com/lidatong/dataclasses-json/pull/351 + "ignore:The 'default' argument to fields is deprecated. Use 'dump_default' instead:DeprecationWarning:dataclasses_json.mm", + # https://pypi.org/project/emulated-roku/ - v0.3.0 - 2023-12-19 + # https://github.com/martonperei/emulated_roku + "ignore:loop argument is deprecated:DeprecationWarning:emulated_roku", + # https://github.com/w1ll1am23/pyeconet/blob/v0.1.23/src/pyeconet/api.py#L38 - v0.1.23 - 2024-10-08 + "ignore:ssl.PROTOCOL_TLS is deprecated:DeprecationWarning:pyeconet.api", + # https://github.com/thecynic/pylutron - v0.2.16 - 2024-10-22 + "ignore:setDaemon\\(\\) is deprecated, set the daemon attribute instead:DeprecationWarning:pylutron", + # https://github.com/pschmitt/pynuki/blob/1.6.3/pynuki/utils.py#L21 - v1.6.3 - 2024-02-24 + "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:pynuki.utils", + # https://github.com/lextudio/pysnmp/blob/v7.1.10/pysnmp/smi/compiler.py#L23-L31 - v7.1.10 - 2024-11-04 + "ignore:smiV1Relaxed is deprecated. Please use smi_v1_relaxed instead:DeprecationWarning:pysnmp.smi.compiler", + "ignore:getReadersFromUrls is deprecated. Please use get_readers_from_urls instead:DeprecationWarning:pysmi.reader.url", # wrong stacklevel + # https://github.com/briis/pyweatherflowudp/blob/v1.4.5/pyweatherflowudp/const.py#L20 - v1.4.5 - 2023-10-10 + "ignore:This function will be removed in future versions of pint:DeprecationWarning:pyweatherflowudp.const", + # Wrong stacklevel + # https://bugs.launchpad.net/beautifulsoup/+bug/2034451 fixed in >4.12.3 + "ignore:It looks like you're parsing an XML document using an HTML parser:UserWarning:html.parser", + # New in aiohttp - v3.9.0 + "ignore:It is recommended to use web.AppKey instances for keys:UserWarning:(homeassistant|tests|aiohttp_cors)", + # - SyntaxWarnings + # https://pypi.org/project/aprslib/ - v0.7.2 - 2022-07-10 + "ignore:invalid escape sequence:SyntaxWarning:.*aprslib.parsing.common", + "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:aprslib.parsing.common", + # https://pypi.org/project/panasonic-viera/ - v0.4.2 - 2024-04-24 + # https://github.com/florianholzapfel/panasonic-viera/blob/0.4.2/panasonic_viera/__init__.py#L789 + "ignore:invalid escape sequence:SyntaxWarning:.*panasonic_viera", + # https://pypi.org/project/pyblackbird/ - v0.6 - 2023-03-15 + # https://github.com/koolsb/pyblackbird/pull/9 -> closed + "ignore:invalid escape sequence:SyntaxWarning:.*pyblackbird", + # https://pypi.org/project/pyws66i/ - v1.1 - 2022-04-05 + "ignore:invalid escape sequence:SyntaxWarning:.*pyws66i", + # https://pypi.org/project/sanix/ - v1.0.6 - 2024-05-01 + # https://github.com/tomaszsluszniak/sanix_py/blob/v1.0.6/sanix/__init__.py#L42 + "ignore:invalid escape sequence:SyntaxWarning:.*sanix", + # https://pypi.org/project/sleekxmppfs/ - v1.4.1 - 2022-08-18 + "ignore:invalid escape sequence:SyntaxWarning:.*sleekxmppfs.thirdparty.mini_dateutil", # codespell:ignore thirdparty + # - pkg_resources + # https://pypi.org/project/aiomusiccast/ - v0.14.8 - 2023-03-20 + "ignore:pkg_resources is deprecated as an API:DeprecationWarning:aiomusiccast", + # https://pypi.org/project/habitipy/ - v0.3.3 - 2024-10-28 + "ignore:pkg_resources is deprecated as an API:DeprecationWarning:habitipy.api", + # https://github.com/eavanvalkenburg/pysiaalarm/blob/v3.1.1/src/pysiaalarm/data/data.py#L7 - v3.1.1 - 2023-04-17 + "ignore:pkg_resources is deprecated as an API:DeprecationWarning:pysiaalarm.data.data", + # https://pypi.org/project/pybotvac/ - v0.0.25 - 2024-04-11 + "ignore:pkg_resources is deprecated as an API:DeprecationWarning:pybotvac.version", + # https://github.com/home-assistant-ecosystem/python-mystrom/blob/2.2.0/pymystrom/__init__.py#L10 - v2.2.0 - 2023-05-21 + "ignore:pkg_resources is deprecated as an API:DeprecationWarning:pymystrom", - # -- Python 3.13 - # HomeAssistant - "ignore:'audioop' is deprecated and slated for removal in Python 3.13:DeprecationWarning:homeassistant.components.assist_pipeline.websocket_api", - "ignore:'telnetlib' is deprecated and slated for removal in Python 3.13:DeprecationWarning:homeassistant.components.hddtemp.sensor", - # https://pypi.org/project/nextcord/ - v2.6.0 - 2023-09-23 - # https://github.com/nextcord/nextcord/issues/1174 - # https://github.com/nextcord/nextcord/blob/v2.6.1/nextcord/player.py#L5 - "ignore:'audioop' is deprecated and slated for removal in Python 3.13:DeprecationWarning:nextcord.player", - # https://pypi.org/project/SpeechRecognition/ - v3.11.0 - 2024-05-05 - # https://github.com/Uberi/speech_recognition/blob/3.11.0/speech_recognition/__init__.py#L7 - "ignore:'aifc' is deprecated and slated for removal in Python 3.13:DeprecationWarning:speech_recognition", - # https://pypi.org/project/voip-utils/ - v0.2.0 - 2024-09-06 - # https://github.com/home-assistant-libs/voip-utils/blob/0.2.0/voip_utils/rtp_audio.py#L3 - "ignore:'audioop' is deprecated and slated for removal in Python 3.13:DeprecationWarning:voip_utils.rtp_audio", + # -- Python 3.13 + # HomeAssistant + "ignore:'audioop' is deprecated and slated for removal in Python 3.13:DeprecationWarning:homeassistant.components.assist_pipeline.websocket_api", + "ignore:'telnetlib' is deprecated and slated for removal in Python 3.13:DeprecationWarning:homeassistant.components.hddtemp.sensor", + # https://pypi.org/project/nextcord/ - v2.6.0 - 2023-09-23 + # https://github.com/nextcord/nextcord/issues/1174 + # https://github.com/nextcord/nextcord/blob/v2.6.1/nextcord/player.py#L5 + "ignore:'audioop' is deprecated and slated for removal in Python 3.13:DeprecationWarning:nextcord.player", + # https://pypi.org/project/SpeechRecognition/ - v3.11.0 - 2024-05-05 + # https://github.com/Uberi/speech_recognition/blob/3.11.0/speech_recognition/__init__.py#L7 + "ignore:'aifc' is deprecated and slated for removal in Python 3.13:DeprecationWarning:speech_recognition", + # https://pypi.org/project/voip-utils/ - v0.2.0 - 2024-09-06 + # https://github.com/home-assistant-libs/voip-utils/blob/0.2.0/voip_utils/rtp_audio.py#L3 + "ignore:'audioop' is deprecated and slated for removal in Python 3.13:DeprecationWarning:voip_utils.rtp_audio", - # -- Python 3.13 - unmaintained projects, last release about 2+ years - # https://pypi.org/project/pydub/ - v0.25.1 - 2021-03-10 - "ignore:'audioop' is deprecated and slated for removal in Python 3.13:DeprecationWarning:pydub.utils", - # https://github.com/heathbar/plum-lightpad-python/issues/7 - v0.0.11 - 2018-10-16 - "ignore:'telnetlib' is deprecated and slated for removal in Python 3.13:DeprecationWarning:plumlightpad.lightpad", - # https://pypi.org/project/pyws66i/ - v1.1 - 2022-04-05 - # https://github.com/ssaenger/pyws66i/blob/v1.1/pyws66i/__init__.py#L2 - "ignore:'telnetlib' is deprecated and slated for removal in Python 3.13:DeprecationWarning:pyws66i", + # -- Python 3.13 - unmaintained projects, last release about 2+ years + # https://pypi.org/project/pydub/ - v0.25.1 - 2021-03-10 + "ignore:'audioop' is deprecated and slated for removal in Python 3.13:DeprecationWarning:pydub.utils", + # https://github.com/heathbar/plum-lightpad-python/issues/7 - v0.0.11 - 2018-10-16 + "ignore:'telnetlib' is deprecated and slated for removal in Python 3.13:DeprecationWarning:plumlightpad.lightpad", + # https://pypi.org/project/pyws66i/ - v1.1 - 2022-04-05 + # https://github.com/ssaenger/pyws66i/blob/v1.1/pyws66i/__init__.py#L2 + "ignore:'telnetlib' is deprecated and slated for removal in Python 3.13:DeprecationWarning:pyws66i", - # -- New in Python 3.13 - # https://github.com/kurtmckee/feedparser/pull/389 - >6.0.11 - # https://github.com/kurtmckee/feedparser/issues/481 - "ignore:'count' is passed as positional argument:DeprecationWarning:feedparser.html", - # https://github.com/youknowone/python-deadlib - Backports for aifc, telnetlib - "ignore:aifc was removed in Python 3.13.*'standard-aifc':DeprecationWarning:speech_recognition", - "ignore:telnetlib was removed in Python 3.13.*'standard-telnetlib':DeprecationWarning:homeassistant.components.hddtemp.sensor", - "ignore:telnetlib was removed in Python 3.13.*'standard-telnetlib':DeprecationWarning:ndms2_client.connection", - "ignore:telnetlib was removed in Python 3.13.*'standard-telnetlib':DeprecationWarning:plumlightpad.lightpad", - "ignore:telnetlib was removed in Python 3.13.*'standard-telnetlib':DeprecationWarning:pyws66i", + # -- New in Python 3.13 + # https://github.com/kurtmckee/feedparser/pull/389 - >6.0.11 + # https://github.com/kurtmckee/feedparser/issues/481 + "ignore:'count' is passed as positional argument:DeprecationWarning:feedparser.html", + # https://github.com/youknowone/python-deadlib - Backports for aifc, telnetlib + "ignore:aifc was removed in Python 3.13.*'standard-aifc':DeprecationWarning:speech_recognition", + "ignore:telnetlib was removed in Python 3.13.*'standard-telnetlib':DeprecationWarning:homeassistant.components.hddtemp.sensor", + "ignore:telnetlib was removed in Python 3.13.*'standard-telnetlib':DeprecationWarning:ndms2_client.connection", + "ignore:telnetlib was removed in Python 3.13.*'standard-telnetlib':DeprecationWarning:plumlightpad.lightpad", + "ignore:telnetlib was removed in Python 3.13.*'standard-telnetlib':DeprecationWarning:pyws66i", - # -- unmaintained projects, last release about 2+ years - # https://pypi.org/project/agent-py/ - v0.0.23 - 2020-06-04 - "ignore:with timeout\\(\\) is deprecated:DeprecationWarning:agent.a", - # https://pypi.org/project/aiomodernforms/ - v0.1.8 - 2021-06-27 - "ignore:with timeout\\(\\) is deprecated:DeprecationWarning:aiomodernforms.modernforms", - # https://pypi.org/project/alarmdecoder/ - v1.13.11 - 2021-06-01 - "ignore:invalid escape sequence:SyntaxWarning:.*alarmdecoder", - # https://pypi.org/project/directv/ - v0.4.0 - 2020-09-12 - "ignore:with timeout\\(\\) is deprecated:DeprecationWarning:directv.directv", - "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:directv.models", - # https://pypi.org/project/foobot_async/ - v1.0.1 - 2024-08-16 - "ignore:with timeout\\(\\) is deprecated:DeprecationWarning:foobot_async", - # https://pypi.org/project/httpsig/ - v1.3.0 - 2018-11-28 - "ignore:pkg_resources is deprecated as an API:DeprecationWarning:httpsig", - # https://pypi.org/project/influxdb/ - v5.3.2 - 2024-04-18 (archived) - "ignore:datetime.*utcfromtimestamp\\(\\) is deprecated and scheduled for removal:DeprecationWarning:influxdb.line_protocol", - # https://pypi.org/project/lark-parser/ - v0.12.0 - 2021-08-30 -> moved to `lark` - # https://pypi.org/project/commentjson/ - v0.9.0 - 2020-10-05 - # https://github.com/vaidik/commentjson/issues/51 - # https://github.com/vaidik/commentjson/pull/52 - # Fixed upstream, commentjson depends on old version and seems to be unmaintained - "ignore:module '(sre_parse|sre_constants)' is deprecate:DeprecationWarning:lark.utils", - # https://pypi.org/project/lomond/ - v0.3.3 - 2018-09-21 - "ignore:ssl.PROTOCOL_TLS is deprecated:DeprecationWarning:lomond.session", - # https://pypi.org/project/oauth2client/ - v4.1.3 - 2018-09-07 (archived) - "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:oauth2client.client", - # https://pypi.org/project/opuslib/ - v3.0.1 - 2018-01-16 - "ignore:\"is not\" with 'int' literal. Did you mean \"!=\"?:SyntaxWarning:.*opuslib.api.decoder", - # https://pypi.org/project/passlib/ - v1.7.4 - 2020-10-08 - "ignore:'crypt' is deprecated and slated for removal in Python 3.13:DeprecationWarning:passlib.utils", - # https://pypi.org/project/pilight/ - v0.1.1 - 2016-10-19 - "ignore:pkg_resources is deprecated as an API:DeprecationWarning:pilight", - # https://pypi.org/project/plumlightpad/ - v0.0.11 - 2018-10-16 - "ignore:invalid escape sequence:SyntaxWarning:.*plumlightpad.plumdiscovery", - "ignore:\"is\" with 'int' literal. Did you mean \"==\"?:SyntaxWarning:.*plumlightpad.(lightpad|logicalload)", - # https://pypi.org/project/pure-python-adb/ - v0.3.0.dev0 - 2020-08-05 - "ignore:invalid escape sequence:SyntaxWarning:.*ppadb", - # https://pypi.org/project/pydub/ - v0.25.1 - 2021-03-10 - "ignore:invalid escape sequence:SyntaxWarning:.*pydub.utils", - # https://pypi.org/project/pyiss/ - v1.0.1 - 2016-12-19 - "ignore:\"is\" with 'int' literal. Did you mean \"==\"?:SyntaxWarning:.*pyiss", - # https://pypi.org/project/PyMetEireann/ - v2021.8.0 - 2021-08-16 - "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:meteireann", - # https://pypi.org/project/PyPasser/ - v0.0.5 - 2021-10-21 - "ignore:invalid escape sequence:SyntaxWarning:.*pypasser.utils", - # https://pypi.org/project/pyqwikswitch/ - v0.94 - 2019-08-19 - "ignore:client.loop property is deprecated:DeprecationWarning:pyqwikswitch.async_", - "ignore:with timeout\\(\\) is deprecated:DeprecationWarning:pyqwikswitch.async_", - # https://pypi.org/project/Rx/ - v3.2.0 - 2021-04-25 - "ignore:datetime.*utcfromtimestamp\\(\\) is deprecated and scheduled for removal:DeprecationWarning:rx.internal.constants", - # https://pypi.org/project/rxv/ - v0.7.0 - 2021-10-10 - "ignore:defusedxml.cElementTree is deprecated, import from defusedxml.ElementTree instead:DeprecationWarning:rxv.ssdp", + # -- unmaintained projects, last release about 2+ years + # https://pypi.org/project/agent-py/ - v0.0.23 - 2020-06-04 + "ignore:with timeout\\(\\) is deprecated:DeprecationWarning:agent.a", + # https://pypi.org/project/aiomodernforms/ - v0.1.8 - 2021-06-27 + "ignore:with timeout\\(\\) is deprecated:DeprecationWarning:aiomodernforms.modernforms", + # https://pypi.org/project/alarmdecoder/ - v1.13.11 - 2021-06-01 + "ignore:invalid escape sequence:SyntaxWarning:.*alarmdecoder", + # https://pypi.org/project/directv/ - v0.4.0 - 2020-09-12 + "ignore:with timeout\\(\\) is deprecated:DeprecationWarning:directv.directv", + "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:directv.models", + # https://pypi.org/project/foobot_async/ - v1.0.1 - 2024-08-16 + "ignore:with timeout\\(\\) is deprecated:DeprecationWarning:foobot_async", + # https://pypi.org/project/httpsig/ - v1.3.0 - 2018-11-28 + "ignore:pkg_resources is deprecated as an API:DeprecationWarning:httpsig", + # https://pypi.org/project/influxdb/ - v5.3.2 - 2024-04-18 (archived) + "ignore:datetime.*utcfromtimestamp\\(\\) is deprecated and scheduled for removal:DeprecationWarning:influxdb.line_protocol", + # https://pypi.org/project/lark-parser/ - v0.12.0 - 2021-08-30 -> moved to `lark` + # https://pypi.org/project/commentjson/ - v0.9.0 - 2020-10-05 + # https://github.com/vaidik/commentjson/issues/51 + # https://github.com/vaidik/commentjson/pull/52 + # Fixed upstream, commentjson depends on old version and seems to be unmaintained + "ignore:module '(sre_parse|sre_constants)' is deprecate:DeprecationWarning:lark.utils", + # https://pypi.org/project/lomond/ - v0.3.3 - 2018-09-21 + "ignore:ssl.PROTOCOL_TLS is deprecated:DeprecationWarning:lomond.session", + # https://pypi.org/project/oauth2client/ - v4.1.3 - 2018-09-07 (archived) + "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:oauth2client.client", + # https://pypi.org/project/opuslib/ - v3.0.1 - 2018-01-16 + "ignore:\"is not\" with 'int' literal. Did you mean \"!=\"?:SyntaxWarning:.*opuslib.api.decoder", + # https://pypi.org/project/passlib/ - v1.7.4 - 2020-10-08 + "ignore:'crypt' is deprecated and slated for removal in Python 3.13:DeprecationWarning:passlib.utils", + # https://pypi.org/project/pilight/ - v0.1.1 - 2016-10-19 + "ignore:pkg_resources is deprecated as an API:DeprecationWarning:pilight", + # https://pypi.org/project/plumlightpad/ - v0.0.11 - 2018-10-16 + "ignore:invalid escape sequence:SyntaxWarning:.*plumlightpad.plumdiscovery", + "ignore:\"is\" with 'int' literal. Did you mean \"==\"?:SyntaxWarning:.*plumlightpad.(lightpad|logicalload)", + # https://pypi.org/project/pure-python-adb/ - v0.3.0.dev0 - 2020-08-05 + "ignore:invalid escape sequence:SyntaxWarning:.*ppadb", + # https://pypi.org/project/pydub/ - v0.25.1 - 2021-03-10 + "ignore:invalid escape sequence:SyntaxWarning:.*pydub.utils", + # https://pypi.org/project/pyiss/ - v1.0.1 - 2016-12-19 + "ignore:\"is\" with 'int' literal. Did you mean \"==\"?:SyntaxWarning:.*pyiss", + # https://pypi.org/project/PyMetEireann/ - v2021.8.0 - 2021-08-16 + "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:meteireann", + # https://pypi.org/project/PyPasser/ - v0.0.5 - 2021-10-21 + "ignore:invalid escape sequence:SyntaxWarning:.*pypasser.utils", + # https://pypi.org/project/pyqwikswitch/ - v0.94 - 2019-08-19 + "ignore:client.loop property is deprecated:DeprecationWarning:pyqwikswitch.async_", + "ignore:with timeout\\(\\) is deprecated:DeprecationWarning:pyqwikswitch.async_", + # https://pypi.org/project/Rx/ - v3.2.0 - 2021-04-25 + "ignore:datetime.*utcfromtimestamp\\(\\) is deprecated and scheduled for removal:DeprecationWarning:rx.internal.constants", + # https://pypi.org/project/rxv/ - v0.7.0 - 2021-10-10 + "ignore:defusedxml.cElementTree is deprecated, import from defusedxml.ElementTree instead:DeprecationWarning:rxv.ssdp", ] [tool.coverage.run] @@ -688,16 +681,16 @@ source = ["homeassistant"] [tool.coverage.report] exclude_lines = [ - # Have to re-enable the standard pragma - "pragma: no cover", - # Don't complain about missing debug-only code: - "def __repr__", - # Don't complain if tests don't hit defensive assertion code: - "raise AssertionError", - "raise NotImplementedError", - # TYPE_CHECKING and @overload blocks are never executed during pytest run - "if TYPE_CHECKING:", - "@overload", + # Have to re-enable the standard pragma + "pragma: no cover", + # Don't complain about missing debug-only code: + "def __repr__", + # Don't complain if tests don't hit defensive assertion code: + "raise AssertionError", + "raise NotImplementedError", + # TYPE_CHECKING and @overload blocks are never executed during pytest run + "if TYPE_CHECKING:", + "@overload", ] [tool.ruff] @@ -705,158 +698,158 @@ required-version = ">=0.11.0" [tool.ruff.lint] select = [ - "A001", # Variable {name} is shadowing a Python builtin - "ASYNC", # flake8-async - "B002", # Python does not support the unary prefix increment - "B005", # Using .strip() with multi-character strings is misleading - "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} - "B024", # `{name}` is an abstract base class, but it has no abstract methods or properties - "B026", # Star-arg unpacking after a keyword argument is strongly discouraged - "B032", # Possible unintentional type annotation (using :). Did you mean to assign (using =)? - "B035", # Dictionary comprehension uses static key - "B904", # Use raise from to specify exception cause - "B905", # zip() without an explicit strict= parameter - "BLE", - "C", # complexity - "COM818", # Trailing comma on bare tuple prohibited - "D", # docstrings - "DTZ003", # Use datetime.now(tz=) instead of datetime.utcnow() - "DTZ004", # Use datetime.fromtimestamp(ts, tz=) instead of datetime.utcfromtimestamp(ts) - "E", # pycodestyle - "F", # pyflakes/autoflake - "F541", # f-string without any placeholders - "FLY", # flynt - "FURB", # refurb - "G", # flake8-logging-format - "I", # isort - "INP", # flake8-no-pep420 - "ISC", # flake8-implicit-str-concat - "ICN001", # import concentions; {name} should be imported as {asname} - "LOG", # flake8-logging - "N804", # First argument of a class method should be named cls - "N805", # First argument of a method should be named self - "N815", # Variable {name} in class scope should not be mixedCase - "PERF", # Perflint - "PGH", # pygrep-hooks - "PIE", # flake8-pie - "PL", # pylint - "PT", # flake8-pytest-style - "PTH", # flake8-pathlib - "PYI", # flake8-pyi - "RET", # flake8-return - "RSE", # flake8-raise - "RUF005", # Consider iterable unpacking instead of concatenation - "RUF006", # Store a reference to the return value of asyncio.create_task - "RUF007", # Prefer itertools.pairwise() over zip() when iterating over successive pairs - "RUF008", # Do not use mutable default values for dataclass attributes - "RUF010", # Use explicit conversion flag - "RUF013", # PEP 484 prohibits implicit Optional - "RUF016", # Slice in indexed access to type {value_type} uses type {index_type} instead of an integer - "RUF017", # Avoid quadratic list summation - "RUF018", # Avoid assignment expressions in assert statements - "RUF019", # Unnecessary key check before dictionary access - "RUF020", # {never_like} | T is equivalent to T - "RUF021", # Parenthesize a and b expressions when chaining and and or together, to make the precedence clear - "RUF022", # Sort __all__ - "RUF023", # Sort __slots__ - "RUF024", # Do not pass mutable objects as values to dict.fromkeys - "RUF026", # default_factory is a positional-only argument to defaultdict - "RUF030", # print() call in assert statement is likely unintentional - "RUF032", # Decimal() called with float literal argument - "RUF033", # __post_init__ method with argument defaults - "RUF034", # Useless if-else condition - "RUF100", # Unused `noqa` directive - "RUF101", # noqa directives that use redirected rule codes - "RUF200", # Failed to parse pyproject.toml: {message} - "S102", # Use of exec detected - "S103", # bad-file-permissions - "S108", # hardcoded-temp-file - "S306", # suspicious-mktemp-usage - "S307", # suspicious-eval-usage - "S313", # suspicious-xmlc-element-tree-usage - "S314", # suspicious-xml-element-tree-usage - "S315", # suspicious-xml-expat-reader-usage - "S316", # suspicious-xml-expat-builder-usage - "S317", # suspicious-xml-sax-usage - "S318", # suspicious-xml-mini-dom-usage - "S319", # suspicious-xml-pull-dom-usage - "S601", # paramiko-call - "S602", # subprocess-popen-with-shell-equals-true - "S604", # call-with-shell-equals-true - "S608", # hardcoded-sql-expression - "S609", # unix-command-wildcard-injection - "SIM", # flake8-simplify - "SLF", # flake8-self - "SLOT", # flake8-slots - "T100", # Trace found: {name} used - "T20", # flake8-print - "TC", # flake8-type-checking - "TID", # Tidy imports - "TRY", # tryceratops - "UP", # pyupgrade - "UP031", # Use format specifiers instead of percent format - "UP032", # Use f-string instead of `format` call - "W", # pycodestyle + "A001", # Variable {name} is shadowing a Python builtin + "ASYNC", # flake8-async + "B002", # Python does not support the unary prefix increment + "B005", # Using .strip() with multi-character strings is misleading + "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} + "B024", # `{name}` is an abstract base class, but it has no abstract methods or properties + "B026", # Star-arg unpacking after a keyword argument is strongly discouraged + "B032", # Possible unintentional type annotation (using :). Did you mean to assign (using =)? + "B035", # Dictionary comprehension uses static key + "B904", # Use raise from to specify exception cause + "B905", # zip() without an explicit strict= parameter + "BLE", + "C", # complexity + "COM818", # Trailing comma on bare tuple prohibited + "D", # docstrings + "DTZ003", # Use datetime.now(tz=) instead of datetime.utcnow() + "DTZ004", # Use datetime.fromtimestamp(ts, tz=) instead of datetime.utcfromtimestamp(ts) + "E", # pycodestyle + "F", # pyflakes/autoflake + "F541", # f-string without any placeholders + "FLY", # flynt + "FURB", # refurb + "G", # flake8-logging-format + "I", # isort + "INP", # flake8-no-pep420 + "ISC", # flake8-implicit-str-concat + "ICN001", # import concentions; {name} should be imported as {asname} + "LOG", # flake8-logging + "N804", # First argument of a class method should be named cls + "N805", # First argument of a method should be named self + "N815", # Variable {name} in class scope should not be mixedCase + "PERF", # Perflint + "PGH", # pygrep-hooks + "PIE", # flake8-pie + "PL", # pylint + "PT", # flake8-pytest-style + "PTH", # flake8-pathlib + "PYI", # flake8-pyi + "RET", # flake8-return + "RSE", # flake8-raise + "RUF005", # Consider iterable unpacking instead of concatenation + "RUF006", # Store a reference to the return value of asyncio.create_task + "RUF007", # Prefer itertools.pairwise() over zip() when iterating over successive pairs + "RUF008", # Do not use mutable default values for dataclass attributes + "RUF010", # Use explicit conversion flag + "RUF013", # PEP 484 prohibits implicit Optional + "RUF016", # Slice in indexed access to type {value_type} uses type {index_type} instead of an integer + "RUF017", # Avoid quadratic list summation + "RUF018", # Avoid assignment expressions in assert statements + "RUF019", # Unnecessary key check before dictionary access + "RUF020", # {never_like} | T is equivalent to T + "RUF021", # Parenthesize a and b expressions when chaining and and or together, to make the precedence clear + "RUF022", # Sort __all__ + "RUF023", # Sort __slots__ + "RUF024", # Do not pass mutable objects as values to dict.fromkeys + "RUF026", # default_factory is a positional-only argument to defaultdict + "RUF030", # print() call in assert statement is likely unintentional + "RUF032", # Decimal() called with float literal argument + "RUF033", # __post_init__ method with argument defaults + "RUF034", # Useless if-else condition + "RUF100", # Unused `noqa` directive + "RUF101", # noqa directives that use redirected rule codes + "RUF200", # Failed to parse pyproject.toml: {message} + "S102", # Use of exec detected + "S103", # bad-file-permissions + "S108", # hardcoded-temp-file + "S306", # suspicious-mktemp-usage + "S307", # suspicious-eval-usage + "S313", # suspicious-xmlc-element-tree-usage + "S314", # suspicious-xml-element-tree-usage + "S315", # suspicious-xml-expat-reader-usage + "S316", # suspicious-xml-expat-builder-usage + "S317", # suspicious-xml-sax-usage + "S318", # suspicious-xml-mini-dom-usage + "S319", # suspicious-xml-pull-dom-usage + "S601", # paramiko-call + "S602", # subprocess-popen-with-shell-equals-true + "S604", # call-with-shell-equals-true + "S608", # hardcoded-sql-expression + "S609", # unix-command-wildcard-injection + "SIM", # flake8-simplify + "SLF", # flake8-self + "SLOT", # flake8-slots + "T100", # Trace found: {name} used + "T20", # flake8-print + "TC", # flake8-type-checking + "TID", # Tidy imports + "TRY", # tryceratops + "UP", # pyupgrade + "UP031", # Use format specifiers instead of percent format + "UP032", # Use f-string instead of `format` call + "W", # pycodestyle ] ignore = [ - "ASYNC109", # Async function definition with a `timeout` parameter Use `asyncio.timeout` instead - "ASYNC110", # Use `asyncio.Event` instead of awaiting `asyncio.sleep` in a `while` loop - "D202", # No blank lines allowed after function docstring - "D203", # 1 blank line required before class docstring - "D213", # Multi-line docstring summary should start at the second line - "D406", # Section name should end with a newline - "D407", # Section name underlining - "E501", # line too long + "ASYNC109", # Async function definition with a `timeout` parameter Use `asyncio.timeout` instead + "ASYNC110", # Use `asyncio.Event` instead of awaiting `asyncio.sleep` in a `while` loop + "D202", # No blank lines allowed after function docstring + "D203", # 1 blank line required before class docstring + "D213", # Multi-line docstring summary should start at the second line + "D406", # Section name should end with a newline + "D407", # Section name underlining + "E501", # line too long - "PLC1901", # {existing} can be simplified to {replacement} as an empty string is falsey; too many false positives - "PLR0911", # Too many return statements ({returns} > {max_returns}) - "PLR0912", # Too many branches ({branches} > {max_branches}) - "PLR0913", # Too many arguments to function call ({c_args} > {max_args}) - "PLR0915", # Too many statements ({statements} > {max_statements}) - "PLR2004", # Magic value used in comparison, consider replacing {value} with a constant variable - "PLW2901", # Outer {outer_kind} variable {name} overwritten by inner {inner_kind} target - "PT011", # pytest.raises({exception}) is too broad, set the `match` parameter or use a more specific exception - "PT018", # Assertion should be broken down into multiple parts - "RUF001", # String contains ambiguous unicode character. - "RUF002", # Docstring contains ambiguous unicode character. - "RUF003", # Comment contains ambiguous unicode character. - "RUF015", # Prefer next(...) over single element slice - "SIM102", # Use a single if statement instead of nested if statements - "SIM103", # Return the condition {condition} directly - "SIM108", # Use ternary operator {contents} instead of if-else-block - "SIM115", # Use context handler for opening files + "PLC1901", # {existing} can be simplified to {replacement} as an empty string is falsey; too many false positives + "PLR0911", # Too many return statements ({returns} > {max_returns}) + "PLR0912", # Too many branches ({branches} > {max_branches}) + "PLR0913", # Too many arguments to function call ({c_args} > {max_args}) + "PLR0915", # Too many statements ({statements} > {max_statements}) + "PLR2004", # Magic value used in comparison, consider replacing {value} with a constant variable + "PLW2901", # Outer {outer_kind} variable {name} overwritten by inner {inner_kind} target + "PT011", # pytest.raises({exception}) is too broad, set the `match` parameter or use a more specific exception + "PT018", # Assertion should be broken down into multiple parts + "RUF001", # String contains ambiguous unicode character. + "RUF002", # Docstring contains ambiguous unicode character. + "RUF003", # Comment contains ambiguous unicode character. + "RUF015", # Prefer next(...) over single element slice + "SIM102", # Use a single if statement instead of nested if statements + "SIM103", # Return the condition {condition} directly + "SIM108", # Use ternary operator {contents} instead of if-else-block + "SIM115", # Use context handler for opening files - # Moving imports into type-checking blocks can mess with pytest.patch() - "TC001", # Move application import {} into a type-checking block - "TC002", # Move third-party import {} into a type-checking block - "TC003", # Move standard library import {} into a type-checking block - # Quotes for typing.cast generally not necessary, only for performance critical paths - "TC006", # Add quotes to type expression in typing.cast() + # Moving imports into type-checking blocks can mess with pytest.patch() + "TC001", # Move application import {} into a type-checking block + "TC002", # Move third-party import {} into a type-checking block + "TC003", # Move standard library import {} into a type-checking block + # Quotes for typing.cast generally not necessary, only for performance critical paths + "TC006", # Add quotes to type expression in typing.cast() - "TRY003", # Avoid specifying long messages outside the exception class - "TRY400", # Use `logging.exception` instead of `logging.error` - # Ignored due to performance: https://github.com/charliermarsh/ruff/issues/2923 - "UP038", # Use `X | Y` in `isinstance` call instead of `(X, Y)` + "TRY003", # Avoid specifying long messages outside the exception class + "TRY400", # Use `logging.exception` instead of `logging.error` + # Ignored due to performance: https://github.com/charliermarsh/ruff/issues/2923 + "UP038", # Use `X | Y` in `isinstance` call instead of `(X, Y)` - # May conflict with the formatter, https://docs.astral.sh/ruff/formatter/#conflicting-lint-rules - "W191", - "E111", - "E114", - "E117", - "D206", - "D300", - "Q", - "COM812", - "COM819", + # May conflict with the formatter, https://docs.astral.sh/ruff/formatter/#conflicting-lint-rules + "W191", + "E111", + "E114", + "E117", + "D206", + "D300", + "Q", + "COM812", + "COM819", - # Disabled because ruff does not understand type of __all__ generated by a function - "PLE0605", + # Disabled because ruff does not understand type of __all__ generated by a function + "PLE0605", ] [tool.ruff.lint.flake8-import-conventions.extend-aliases] @@ -932,9 +925,7 @@ mark-parentheses = false [tool.ruff.lint.isort] force-sort-within-sections = true -known-first-party = [ - "homeassistant", -] +known-first-party = ["homeassistant"] combine-as-imports = true split-on-trailing-comma = false From 46ee3d2b26e7236644c652468c9cd8ab26683218 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 26 Mar 2025 20:52:39 +0100 Subject: [PATCH 3112/3148] Sort SmartThings devices to be created by parent device id (#141515) --- homeassistant/components/smartthings/__init__.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py index ab7df490bd3..20325e7d3e5 100644 --- a/homeassistant/components/smartthings/__init__.py +++ b/homeassistant/components/smartthings/__init__.py @@ -410,7 +410,9 @@ def create_devices( rooms: dict[str, str], ) -> None: """Create devices in the device registry.""" - for device in devices.values(): + for device in sorted( + devices.values(), key=lambda d: d.device.parent_device_id or "" + ): kwargs: dict[str, Any] = {} if device.device.hub is not None: kwargs = { From 002ca9611d8c6cd961127c1a9b1c71cdccbe8354 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Wed, 26 Mar 2025 21:40:02 +0100 Subject: [PATCH 3113/3148] Add test for invalid mean type in StatisticsMeta (#141475) --- .../table_managers/test_statistics_meta.py | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) diff --git a/tests/components/recorder/table_managers/test_statistics_meta.py b/tests/components/recorder/table_managers/test_statistics_meta.py index 66edb84c3ef..1af60b71ed5 100644 --- a/tests/components/recorder/table_managers/test_statistics_meta.py +++ b/tests/components/recorder/table_managers/test_statistics_meta.py @@ -2,10 +2,19 @@ from __future__ import annotations +import logging +import threading + import pytest from homeassistant.components import recorder +from homeassistant.components.recorder.db_schema import StatisticsMeta +from homeassistant.components.recorder.models import ( + StatisticMeanType, + StatisticMetaData, +) from homeassistant.components.recorder.util import session_scope +from homeassistant.const import DEGREE from homeassistant.core import HomeAssistant from tests.typing import RecorderInstanceGenerator @@ -55,3 +64,78 @@ async def test_unsafe_calls_to_statistics_meta_manager( session, statistic_ids=["light.kitchen"], ) + + +async def test_invalid_mean_types( + async_setup_recorder_instance: RecorderInstanceGenerator, + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test passing invalid mean types will be skipped and logged.""" + instance = await async_setup_recorder_instance( + hass, {recorder.CONF_COMMIT_INTERVAL: 0} + ) + instance.recorder_and_worker_thread_ids.add(threading.get_ident()) + + valid_metadata: dict[str, tuple[int, StatisticMetaData]] = { + "sensor.energy": ( + 1, + { + "mean_type": StatisticMeanType.NONE, + "has_mean": False, + "has_sum": True, + "name": "Total imported energy", + "source": "recorder", + "statistic_id": "sensor.energy", + "unit_of_measurement": "kWh", + }, + ), + "sensor.wind_direction": ( + 2, + { + "mean_type": StatisticMeanType.CIRCULAR, + "has_mean": False, + "has_sum": False, + "name": "Wind direction", + "source": "recorder", + "statistic_id": "sensor.wind_direction", + "unit_of_measurement": DEGREE, + }, + ), + "sensor.wind_speed": ( + 3, + { + "mean_type": StatisticMeanType.ARITHMETIC, + "has_mean": True, + "has_sum": False, + "name": "Wind speed", + "source": "recorder", + "statistic_id": "sensor.wind_speed", + "unit_of_measurement": "km/h", + }, + ), + } + manager = instance.statistics_meta_manager + with instance.get_session() as session: + for _, metadata in valid_metadata.values(): + session.add(StatisticsMeta.from_meta(metadata)) + + # Add invalid mean type + session.add( + StatisticsMeta( + statistic_id="sensor.invalid", + source="recorder", + has_sum=False, + name="Invalid", + mean_type=12345, + ) + ) + session.commit() + + # Check that the invalid mean type was skipped + assert manager.get_many(session) == valid_metadata + assert ( + "homeassistant.components.recorder.table_managers.statistics_meta", + logging.WARNING, + "Invalid mean type found for statistic_id: sensor.invalid, mean_type: 12345. Skipping", + ) in caplog.record_tuples From 6bfd39f0942cd013b64ed01d1c50ad5ef9d73c91 Mon Sep 17 00:00:00 2001 From: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> Date: Wed, 26 Mar 2025 15:47:10 -0500 Subject: [PATCH 3114/3148] Add play queue item to HEOS (#141480) Add ability to play specific queue item --- homeassistant/components/heos/media_player.py | 9 ++++ tests/components/heos/__init__.py | 1 + tests/components/heos/test_media_player.py | 45 +++++++++++++++++++ 3 files changed, 55 insertions(+) diff --git a/homeassistant/components/heos/media_player.py b/homeassistant/components/heos/media_player.py index 9cd01051b95..81d997ba44f 100644 --- a/homeassistant/components/heos/media_player.py +++ b/homeassistant/components/heos/media_player.py @@ -387,6 +387,15 @@ class HeosMediaPlayer(CoordinatorEntity[HeosCoordinator], MediaPlayerEntity): await self._player.play_preset_station(index) return + if media_type == "queue": + # media_id must be an int + try: + queue_id = int(media_id) + except ValueError: + raise ValueError(f"Invalid queue id '{media_id}'") from None + await self._player.play_queue(queue_id) + return + raise ValueError(f"Unsupported media type '{media_type}'") @catch_action_error("select source") diff --git a/tests/components/heos/__init__.py b/tests/components/heos/__init__.py index 34eba8a9c76..1fb67bd114f 100644 --- a/tests/components/heos/__init__.py +++ b/tests/components/heos/__init__.py @@ -41,6 +41,7 @@ class MockHeos(Heos): self.player_get_quick_selects: AsyncMock = AsyncMock() self.player_play_next: AsyncMock = AsyncMock() self.player_play_previous: AsyncMock = AsyncMock() + self.player_play_queue: AsyncMock = AsyncMock() self.player_play_quick_select: AsyncMock = AsyncMock() self.player_set_mute: AsyncMock = AsyncMock() self.player_set_play_mode: AsyncMock = AsyncMock() diff --git a/tests/components/heos/test_media_player.py b/tests/components/heos/test_media_player.py index 474d606b5b1..5bc4f2bae30 100644 --- a/tests/components/heos/test_media_player.py +++ b/tests/components/heos/test_media_player.py @@ -1321,6 +1321,51 @@ async def test_play_media_music_source_url( controller.play_url.assert_called_once() +async def test_play_media_queue( + hass: HomeAssistant, + config_entry: MockConfigEntry, + controller: MockHeos, +) -> None: + """Test the play media service with type queue.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: "media_player.test_player", + ATTR_MEDIA_CONTENT_TYPE: "queue", + ATTR_MEDIA_CONTENT_ID: "2", + }, + blocking=True, + ) + controller.player_play_queue.assert_called_once_with(1, 2) + + +async def test_play_media_queue_invalid( + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos +) -> None: + """Test the play media service with an invalid queue id.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + with pytest.raises( + HomeAssistantError, + match=re.escape("Unable to play media: Invalid queue id 'Invalid'"), + ): + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: "media_player.test_player", + ATTR_MEDIA_CONTENT_TYPE: "queue", + ATTR_MEDIA_CONTENT_ID: "Invalid", + }, + blocking=True, + ) + assert controller.player_play_queue.call_count == 0 + + async def test_browse_media_root( hass: HomeAssistant, config_entry: MockConfigEntry, From 3a207e2571df7ff31af14e0d8b795ede314542b8 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Wed, 26 Mar 2025 22:03:24 +0100 Subject: [PATCH 3115/3148] Show box for Smartthings rise number entity (#141526) --- homeassistant/components/smartthings/number.py | 3 ++- tests/components/smartthings/snapshots/test_number.ambr | 8 ++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/smartthings/number.py b/homeassistant/components/smartthings/number.py index bb21520e271..2f2ac7903f2 100644 --- a/homeassistant/components/smartthings/number.py +++ b/homeassistant/components/smartthings/number.py @@ -4,7 +4,7 @@ from __future__ import annotations from pysmartthings import Attribute, Capability, Command, SmartThings -from homeassistant.components.number import NumberEntity +from homeassistant.components.number import NumberEntity, NumberMode from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -32,6 +32,7 @@ class SmartThingsWasherRinseCyclesNumberEntity(SmartThingsEntity, NumberEntity): _attr_translation_key = "washer_rinse_cycles" _attr_native_step = 1.0 + _attr_mode = NumberMode.BOX def __init__(self, client: SmartThings, device: FullDevice) -> None: """Initialize the instance.""" diff --git a/tests/components/smartthings/snapshots/test_number.ambr b/tests/components/smartthings/snapshots/test_number.ambr index a5954a98cf3..66aade5b958 100644 --- a/tests/components/smartthings/snapshots/test_number.ambr +++ b/tests/components/smartthings/snapshots/test_number.ambr @@ -7,7 +7,7 @@ 'capabilities': dict({ 'max': 5, 'min': 0, - 'mode': , + 'mode': , 'step': 1.0, }), 'config_entry_id': , @@ -44,7 +44,7 @@ 'friendly_name': 'Washer Rinse cycles', 'max': 5, 'min': 0, - 'mode': , + 'mode': , 'step': 1.0, 'unit_of_measurement': 'cycles', }), @@ -64,7 +64,7 @@ 'capabilities': dict({ 'max': 5, 'min': 0, - 'mode': , + 'mode': , 'step': 1.0, }), 'config_entry_id': , @@ -101,7 +101,7 @@ 'friendly_name': 'Washing Machine Rinse cycles', 'max': 5, 'min': 0, - 'mode': , + 'mode': , 'step': 1.0, 'unit_of_measurement': 'cycles', }), From c3f8b7e2003eef86b3805f4e921a6cebce31551b Mon Sep 17 00:00:00 2001 From: Thomas55555 <59625598+Thomas55555@users.noreply.github.com> Date: Wed, 26 Mar 2025 22:16:26 +0100 Subject: [PATCH 3116/3148] Fix work area sensor for Husqvarna Automower (#141527) * Fix work area sensor for Husqvarna Automower * simplify --- .../components/husqvarna_automower/sensor.py | 10 +++++++--- tests/components/husqvarna_automower/test_sensor.py | 12 ++++++++++++ 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/husqvarna_automower/sensor.py b/homeassistant/components/husqvarna_automower/sensor.py index 75af24ee0ee..d7a83c82185 100644 --- a/homeassistant/components/husqvarna_automower/sensor.py +++ b/homeassistant/components/husqvarna_automower/sensor.py @@ -227,12 +227,16 @@ def _get_work_area_names(data: MowerAttributes) -> list[str]: @callback def _get_current_work_area_name(data: MowerAttributes) -> str: """Return the name of the current work area.""" - if data.mower.work_area_id is None: - return STATE_NO_WORK_AREA_ACTIVE if TYPE_CHECKING: # Sensor does not get created if values are None assert data.work_areas is not None - return data.work_areas[data.mower.work_area_id].name + if ( + data.mower.work_area_id is not None + and data.mower.work_area_id in data.work_areas + ): + return data.work_areas[data.mower.work_area_id].name + + return STATE_NO_WORK_AREA_ACTIVE @callback diff --git a/tests/components/husqvarna_automower/test_sensor.py b/tests/components/husqvarna_automower/test_sensor.py index 08ed5251344..85d20178e73 100644 --- a/tests/components/husqvarna_automower/test_sensor.py +++ b/tests/components/husqvarna_automower/test_sensor.py @@ -110,6 +110,18 @@ async def test_work_area_sensor( state = hass.states.get("sensor.test_mower_1_work_area") assert state.state == "my_lawn" + # Test EPOS mower, which returns work_area_id = 0, when no + # work area is active and has no default work_area_id=0 + values[TEST_MOWER_ID].mower.work_area_id = 0 + del values[TEST_MOWER_ID].work_areas[0] + del values[TEST_MOWER_ID].work_area_dict[0] + mock_automower_client.get_status.return_value = values + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + state = hass.states.get("sensor.test_mower_1_work_area") + assert state.state == "no_work_area_active" + @pytest.mark.usefixtures("entity_registry_enabled_by_default") @pytest.mark.parametrize( From 42ae572948237ed973bd9e67dcc847aa47ea1514 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Wed, 26 Mar 2025 22:56:57 +0100 Subject: [PATCH 3117/3148] Fix MQTT options flow QoS selector can not serialize (#141528) --- homeassistant/components/mqtt/config_flow.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/mqtt/config_flow.py b/homeassistant/components/mqtt/config_flow.py index 471b6d048a7..5f0984e9b9f 100644 --- a/homeassistant/components/mqtt/config_flow.py +++ b/homeassistant/components/mqtt/config_flow.py @@ -153,7 +153,6 @@ from .util import ( learn_more_url, valid_birth_will, valid_publish_topic, - valid_qos_schema, valid_subscribe_topic, valid_subscribe_topic_template, ) @@ -182,7 +181,6 @@ PASSWORD_SELECTOR = TextSelector(TextSelectorConfig(type=TextSelectorType.PASSWO QOS_SELECTOR = NumberSelector( NumberSelectorConfig(mode=NumberSelectorMode.BOX, min=0, max=2) ) -QOS_DATA_SCHEMA = vol.All(QOS_SELECTOR, valid_qos_schema) KEEPALIVE_SELECTOR = vol.All( NumberSelector( NumberSelectorConfig( @@ -1145,7 +1143,7 @@ class MQTTOptionsFlowHandler(OptionsFlow): "birth_payload", description={"suggested_value": birth[CONF_PAYLOAD]} ) ] = TEXT_SELECTOR - fields[vol.Optional("birth_qos", default=birth[ATTR_QOS])] = QOS_DATA_SCHEMA + fields[vol.Optional("birth_qos", default=birth[ATTR_QOS])] = QOS_SELECTOR fields[vol.Optional("birth_retain", default=birth[ATTR_RETAIN])] = ( BOOLEAN_SELECTOR ) @@ -1168,7 +1166,7 @@ class MQTTOptionsFlowHandler(OptionsFlow): "will_payload", description={"suggested_value": will[CONF_PAYLOAD]} ) ] = TEXT_SELECTOR - fields[vol.Optional("will_qos", default=will[ATTR_QOS])] = QOS_DATA_SCHEMA + fields[vol.Optional("will_qos", default=will[ATTR_QOS])] = QOS_SELECTOR fields[vol.Optional("will_retain", default=will[ATTR_RETAIN])] = ( BOOLEAN_SELECTOR ) From 543c6929e6174ada8ba451af7fd258ee9342371b Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Wed, 26 Mar 2025 23:34:53 +0100 Subject: [PATCH 3118/3148] Fix refresh state for Comelit alarm (#141370) --- .../components/comelit/alarm_control_panel.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/homeassistant/components/comelit/alarm_control_panel.py b/homeassistant/components/comelit/alarm_control_panel.py index 5ecc9a63599..1ad26905dd1 100644 --- a/homeassistant/components/comelit/alarm_control_panel.py +++ b/homeassistant/components/comelit/alarm_control_panel.py @@ -41,6 +41,7 @@ ALARM_ACTIONS: dict[str, str] = { ALARM_AREA_ARMED_STATUS: dict[str, int] = { + DISABLE: 0, HOME_P1: 1, HOME_P2: 2, NIGHT: 3, @@ -128,20 +129,38 @@ class ComelitAlarmEntity(CoordinatorEntity[ComelitVedoSystem], AlarmControlPanel AlarmAreaState.TRIGGERED: AlarmControlPanelState.TRIGGERED, }.get(self._area.human_status) + async def _async_update_state(self, area_state: AlarmAreaState, armed: int) -> None: + """Update state after action.""" + self._area.human_status = area_state + self._area.armed = armed + await self.async_update_ha_state() + async def async_alarm_disarm(self, code: str | None = None) -> None: """Send disarm command.""" if code != str(self._api.device_pin): return await self._api.set_zone_status(self._area.index, ALARM_ACTIONS[DISABLE]) + await self._async_update_state( + AlarmAreaState.DISARMED, ALARM_AREA_ARMED_STATUS[DISABLE] + ) async def async_alarm_arm_away(self, code: str | None = None) -> None: """Send arm away command.""" await self._api.set_zone_status(self._area.index, ALARM_ACTIONS[AWAY]) + await self._async_update_state( + AlarmAreaState.ARMED, ALARM_AREA_ARMED_STATUS[AWAY] + ) async def async_alarm_arm_home(self, code: str | None = None) -> None: """Send arm home command.""" await self._api.set_zone_status(self._area.index, ALARM_ACTIONS[HOME]) + await self._async_update_state( + AlarmAreaState.ARMED, ALARM_AREA_ARMED_STATUS[HOME_P1] + ) async def async_alarm_arm_night(self, code: str | None = None) -> None: """Send arm night command.""" await self._api.set_zone_status(self._area.index, ALARM_ACTIONS[NIGHT]) + await self._async_update_state( + AlarmAreaState.ARMED, ALARM_AREA_ARMED_STATUS[NIGHT] + ) From 377548e3a1d4632c29277f956e316282b9dd88e1 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Wed, 26 Mar 2025 23:35:28 +0100 Subject: [PATCH 3119/3148] Fix QoS schema issue in MQTT subentries (#141531) --- homeassistant/components/mqtt/config_flow.py | 8 ++------ tests/components/mqtt/test_config_flow.py | 4 ++++ 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/mqtt/config_flow.py b/homeassistant/components/mqtt/config_flow.py index 5f0984e9b9f..7fe01e9a890 100644 --- a/homeassistant/components/mqtt/config_flow.py +++ b/homeassistant/components/mqtt/config_flow.py @@ -1267,13 +1267,9 @@ class MQTTSubentryFlowHandler(ConfigSubentryFlow): reconfig=True, ) if user_input is not None: - merged_user_input, errors = validate_user_input( - user_input, MQTT_DEVICE_PLATFORM_FIELDS - ) + _, errors = validate_user_input(user_input, MQTT_DEVICE_PLATFORM_FIELDS) if not errors: - self._subentry_data[CONF_DEVICE] = cast( - MqttDeviceData, merged_user_input - ) + self._subentry_data[CONF_DEVICE] = cast(MqttDeviceData, user_input) if self.source == SOURCE_RECONFIGURE: return await self.async_step_summary_menu() return await self.async_step_entity() diff --git a/tests/components/mqtt/test_config_flow.py b/tests/components/mqtt/test_config_flow.py index 2635263ae8e..c94d692b374 100644 --- a/tests/components/mqtt/test_config_flow.py +++ b/tests/components/mqtt/test_config_flow.py @@ -2908,6 +2908,10 @@ async def test_subentry_configflow( iter(config_subentries_data["components"].values()) ) + subentry_device_data = next(iter(config_entry.subentries.values())).data["device"] + for option, value in mock_device_user_input.items(): + assert subentry_device_data[option] == value + await hass.async_block_till_done() From 89bf426163a53a7611eec9afa297dd5b084e2a7b Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 27 Mar 2025 00:24:14 +0100 Subject: [PATCH 3120/3148] Fix wrong friendly name for `storage_power` in `solaredge` (#141269) * Fix wrong friendly name for `storage_power` in `solaredge` "Stored power" is a contradiction in itself. You can only store energy. * Two additional spelling fixes * Sentence-case "site" --- homeassistant/components/solaredge/strings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/solaredge/strings.json b/homeassistant/components/solaredge/strings.json index 2b626987546..105a9282a6d 100644 --- a/homeassistant/components/solaredge/strings.json +++ b/homeassistant/components/solaredge/strings.json @@ -5,7 +5,7 @@ "title": "Define the API parameters for this installation", "data": { "name": "The name of this installation", - "site_id": "The SolarEdge site-id", + "site_id": "The SolarEdge site ID", "api_key": "[%key:common::config_flow::data::api_key%]" } } @@ -14,7 +14,7 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "invalid_api_key": "[%key:common::config_flow::error::invalid_api_key%]", "site_not_active": "The site is not active", - "could_not_connect": "Could not connect to the solaredge API" + "could_not_connect": "Could not connect to the SolarEdge API" }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" @@ -65,7 +65,7 @@ "name": "Grid power" }, "storage_power": { - "name": "Stored power" + "name": "Storage power" }, "purchased_energy": { "name": "Imported energy" From 50d050e63ef2ec1cd455a273f3389e2658f38c8e Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Thu, 27 Mar 2025 01:33:01 +0100 Subject: [PATCH 3121/3148] Update pyserial-asyncio-fast to 0.15 (#141537) --- homeassistant/components/serial/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/serial/manifest.json b/homeassistant/components/serial/manifest.json index cfe9196f596..557166d8cb2 100644 --- a/homeassistant/components/serial/manifest.json +++ b/homeassistant/components/serial/manifest.json @@ -4,5 +4,5 @@ "codeowners": ["@fabaff"], "documentation": "https://www.home-assistant.io/integrations/serial", "iot_class": "local_polling", - "requirements": ["pyserial-asyncio-fast==0.14"] + "requirements": ["pyserial-asyncio-fast==0.15"] } diff --git a/requirements_all.txt b/requirements_all.txt index d7db5450a5f..7c7ecb7ebc0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2289,7 +2289,7 @@ pyschlage==2024.11.0 pysensibo==1.1.0 # homeassistant.components.serial -pyserial-asyncio-fast==0.14 +pyserial-asyncio-fast==0.15 # homeassistant.components.acer_projector # homeassistant.components.crownstone From d51070c99bda9eae6e8a51c097df7132b0bf42ac Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Thu, 27 Mar 2025 01:38:34 +0100 Subject: [PATCH 3122/3148] Update boto3 to 1.37.1 and aiobotocore to 2.21.1 (#141499) --- homeassistant/components/amazon_polly/manifest.json | 2 +- homeassistant/components/aws/manifest.json | 2 +- homeassistant/components/route53/manifest.json | 2 +- requirements_all.txt | 6 +++--- requirements_test_all.txt | 4 ++-- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/amazon_polly/manifest.json b/homeassistant/components/amazon_polly/manifest.json index e7fbf8edc74..f684292d9a2 100644 --- a/homeassistant/components/amazon_polly/manifest.json +++ b/homeassistant/components/amazon_polly/manifest.json @@ -6,5 +6,5 @@ "iot_class": "cloud_push", "loggers": ["boto3", "botocore", "s3transfer"], "quality_scale": "legacy", - "requirements": ["boto3==1.34.131"] + "requirements": ["boto3==1.37.1"] } diff --git a/homeassistant/components/aws/manifest.json b/homeassistant/components/aws/manifest.json index 12149e4388a..92ae37c857b 100644 --- a/homeassistant/components/aws/manifest.json +++ b/homeassistant/components/aws/manifest.json @@ -6,5 +6,5 @@ "iot_class": "cloud_push", "loggers": ["aiobotocore", "botocore"], "quality_scale": "legacy", - "requirements": ["aiobotocore==2.13.1", "botocore==1.34.131"] + "requirements": ["aiobotocore==2.21.1", "botocore==1.37.1"] } diff --git a/homeassistant/components/route53/manifest.json b/homeassistant/components/route53/manifest.json index 978c916e3ee..8c21b856b80 100644 --- a/homeassistant/components/route53/manifest.json +++ b/homeassistant/components/route53/manifest.json @@ -6,5 +6,5 @@ "iot_class": "cloud_push", "loggers": ["boto3", "botocore", "s3transfer"], "quality_scale": "legacy", - "requirements": ["boto3==1.34.131"] + "requirements": ["boto3==1.37.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 7c7ecb7ebc0..68d02cf5cea 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -210,7 +210,7 @@ aioazuredevops==2.2.1 aiobafi6==0.9.0 # homeassistant.components.aws -aiobotocore==2.13.1 +aiobotocore==2.21.1 # homeassistant.components.comelit aiocomelit==0.11.3 @@ -652,10 +652,10 @@ boschshcpy==0.2.91 # homeassistant.components.amazon_polly # homeassistant.components.route53 -boto3==1.34.131 +boto3==1.37.1 # homeassistant.components.aws -botocore==1.34.131 +botocore==1.37.1 # homeassistant.components.bring bring-api==1.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 229c1a76559..1c1f4bfdb4d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -198,7 +198,7 @@ aioazuredevops==2.2.1 aiobafi6==0.9.0 # homeassistant.components.aws -aiobotocore==2.13.1 +aiobotocore==2.21.1 # homeassistant.components.comelit aiocomelit==0.11.3 @@ -576,7 +576,7 @@ bosch-alarm-mode2==0.4.3 boschshcpy==0.2.91 # homeassistant.components.aws -botocore==1.34.131 +botocore==1.37.1 # homeassistant.components.bring bring-api==1.1.0 From 66c03713b7eb8509fe324328380364f1da882a48 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Thu, 27 Mar 2025 10:55:34 +1000 Subject: [PATCH 3123/3148] Fix Auto Seat Heater in Tesla Fleet (#141539) Fix Auto Seat Heater --- homeassistant/components/tesla_fleet/switch.py | 10 ++++++---- homeassistant/components/teslemetry/switch.py | 18 +++++++++++++----- 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/tesla_fleet/switch.py b/homeassistant/components/tesla_fleet/switch.py index 614af8772cc..4c64acfafa6 100644 --- a/homeassistant/components/tesla_fleet/switch.py +++ b/homeassistant/components/tesla_fleet/switch.py @@ -7,7 +7,7 @@ from dataclasses import dataclass from itertools import chain from typing import Any -from tesla_fleet_api.const import Scope, Seat +from tesla_fleet_api.const import AutoSeat, Scope, Seat from homeassistant.components.switch import ( SwitchDeviceClass, @@ -46,7 +46,9 @@ VEHICLE_DESCRIPTIONS: tuple[TeslaFleetSwitchEntityDescription, ...] = ( ), TeslaFleetSwitchEntityDescription( key="climate_state_auto_seat_climate_left", - on_func=lambda api: api.remote_auto_seat_climate_request(Seat.FRONT_LEFT, True), + on_func=lambda api: api.remote_auto_seat_climate_request( + AutoSeat.FRONT_LEFT, True + ), off_func=lambda api: api.remote_auto_seat_climate_request( Seat.FRONT_LEFT, False ), @@ -55,10 +57,10 @@ VEHICLE_DESCRIPTIONS: tuple[TeslaFleetSwitchEntityDescription, ...] = ( TeslaFleetSwitchEntityDescription( key="climate_state_auto_seat_climate_right", on_func=lambda api: api.remote_auto_seat_climate_request( - Seat.FRONT_RIGHT, True + AutoSeat.FRONT_RIGHT, True ), off_func=lambda api: api.remote_auto_seat_climate_request( - Seat.FRONT_RIGHT, False + AutoSeat.FRONT_RIGHT, False ), scopes=[Scope.VEHICLE_CMDS], ), diff --git a/homeassistant/components/teslemetry/switch.py b/homeassistant/components/teslemetry/switch.py index 516a6f9852f..645a8398820 100644 --- a/homeassistant/components/teslemetry/switch.py +++ b/homeassistant/components/teslemetry/switch.py @@ -7,7 +7,7 @@ from dataclasses import dataclass from itertools import chain from typing import Any -from tesla_fleet_api.const import Scope +from tesla_fleet_api.const import AutoSeat, Scope from teslemetry_stream import TeslemetryStreamVehicle from homeassistant.components.switch import ( @@ -62,15 +62,23 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetrySwitchEntityDescription, ...] = ( TeslemetrySwitchEntityDescription( key="climate_state_auto_seat_climate_left", streaming_listener=lambda x, y: x.listen_AutoSeatClimateLeft(y), - on_func=lambda api: api.remote_auto_seat_climate_request(1, True), - off_func=lambda api: api.remote_auto_seat_climate_request(1, False), + on_func=lambda api: api.remote_auto_seat_climate_request( + AutoSeat.FRONT_LEFT, True + ), + off_func=lambda api: api.remote_auto_seat_climate_request( + AutoSeat.FRONT_LEFT, False + ), scopes=[Scope.VEHICLE_CMDS], ), TeslemetrySwitchEntityDescription( key="climate_state_auto_seat_climate_right", streaming_listener=lambda x, y: x.listen_AutoSeatClimateRight(y), - on_func=lambda api: api.remote_auto_seat_climate_request(2, True), - off_func=lambda api: api.remote_auto_seat_climate_request(2, False), + on_func=lambda api: api.remote_auto_seat_climate_request( + AutoSeat.FRONT_RIGHT, True + ), + off_func=lambda api: api.remote_auto_seat_climate_request( + AutoSeat.FRONT_RIGHT, False + ), scopes=[Scope.VEHICLE_CMDS], ), TeslemetrySwitchEntityDescription( From 5eb1d0a28e8c65479da6f86a2efbae4797e28e2f Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Wed, 26 Mar 2025 22:45:28 -0500 Subject: [PATCH 3124/3148] Add default preannounce sound to Assist satellites (#141522) * Add default preannounce sound * Allow None to disable sound * Register static path instead of HTTP view * Fix path --------- Co-authored-by: Paulus Schoutsen --- .../components/assist_satellite/__init__.py | 17 +++++- .../components/assist_satellite/const.py | 3 + .../components/assist_satellite/entity.py | 10 +-- .../assist_satellite/preannounce.mp3 | Bin 0 -> 17265 bytes .../components/media_player/browse_media.py | 6 +- .../assist_satellite/test_entity.py | 57 +++++++++++++++++- .../esphome/test_assist_satellite.py | 14 ++++- 7 files changed, 95 insertions(+), 12 deletions(-) create mode 100644 homeassistant/components/assist_satellite/preannounce.mp3 diff --git a/homeassistant/components/assist_satellite/__init__.py b/homeassistant/components/assist_satellite/__init__.py index 31afbda1d11..bc2157b10b2 100644 --- a/homeassistant/components/assist_satellite/__init__.py +++ b/homeassistant/components/assist_satellite/__init__.py @@ -1,9 +1,11 @@ """Base class for assist satellite entities.""" import logging +from pathlib import Path import voluptuous as vol +from homeassistant.components.http import StaticPathConfig from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv @@ -15,6 +17,8 @@ from .const import ( CONNECTION_TEST_DATA, DATA_COMPONENT, DOMAIN, + PREANNOUNCE_FILENAME, + PREANNOUNCE_URL, AssistSatelliteEntityFeature, ) from .entity import ( @@ -56,7 +60,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: { vol.Optional("message"): str, vol.Optional("media_id"): str, - vol.Optional("preannounce_media_id"): str, + vol.Optional("preannounce_media_id"): vol.Any(str, None), } ), cv.has_at_least_one_key("message", "media_id"), @@ -71,7 +75,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: { vol.Optional("start_message"): str, vol.Optional("start_media_id"): str, - vol.Optional("preannounce_media_id"): str, + vol.Optional("preannounce_media_id"): vol.Any(str, None), vol.Optional("extra_system_prompt"): str, } ), @@ -84,6 +88,15 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async_register_websocket_api(hass) hass.http.register_view(ConnectionTestView()) + # Default preannounce sound + await hass.http.async_register_static_paths( + [ + StaticPathConfig( + PREANNOUNCE_URL, str(Path(__file__).parent / PREANNOUNCE_FILENAME) + ) + ] + ) + return True diff --git a/homeassistant/components/assist_satellite/const.py b/homeassistant/components/assist_satellite/const.py index f7ac7e524b4..7fca88f3b12 100644 --- a/homeassistant/components/assist_satellite/const.py +++ b/homeassistant/components/assist_satellite/const.py @@ -20,6 +20,9 @@ CONNECTION_TEST_DATA: HassKey[dict[str, asyncio.Event]] = HassKey( f"{DOMAIN}_connection_tests" ) +PREANNOUNCE_FILENAME = "preannounce.mp3" +PREANNOUNCE_URL = f"/api/assist_satellite/static/{PREANNOUNCE_FILENAME}" + class AssistSatelliteEntityFeature(IntFlag): """Supported features of Assist satellite entity.""" diff --git a/homeassistant/components/assist_satellite/entity.py b/homeassistant/components/assist_satellite/entity.py index 450e6cadbc9..7b4c1b92d8c 100644 --- a/homeassistant/components/assist_satellite/entity.py +++ b/homeassistant/components/assist_satellite/entity.py @@ -28,7 +28,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import chat_session, entity from homeassistant.helpers.entity import EntityDescription -from .const import AssistSatelliteEntityFeature +from .const import PREANNOUNCE_URL, AssistSatelliteEntityFeature from .errors import AssistSatelliteError, SatelliteBusyError _LOGGER = logging.getLogger(__name__) @@ -180,7 +180,7 @@ class AssistSatelliteEntity(entity.Entity): self, message: str | None = None, media_id: str | None = None, - preannounce_media_id: str | None = None, + preannounce_media_id: str | None = PREANNOUNCE_URL, ) -> None: """Play and show an announcement on the satellite. @@ -190,7 +190,8 @@ class AssistSatelliteEntity(entity.Entity): If media_id is provided, it is played directly. It is possible to omit the message and the satellite will not show any text. - If preannounce_media_id is provided, it is played before the announcement. + If preannounce_media_id is provided, it overrides the default sound. + If preannounce_media_id is None, no sound is played. Calls async_announce with message and media id. """ @@ -228,7 +229,7 @@ class AssistSatelliteEntity(entity.Entity): start_message: str | None = None, start_media_id: str | None = None, extra_system_prompt: str | None = None, - preannounce_media_id: str | None = None, + preannounce_media_id: str | None = PREANNOUNCE_URL, ) -> None: """Start a conversation from the satellite. @@ -239,6 +240,7 @@ class AssistSatelliteEntity(entity.Entity): to omit the message and the satellite will not show any text. If preannounce_media_id is provided, it is played before the announcement. + If preannounce_media_id is None, no sound is played. Calls async_start_conversation. """ diff --git a/homeassistant/components/assist_satellite/preannounce.mp3 b/homeassistant/components/assist_satellite/preannounce.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..6e2fa0aba3e22e797c76a5866ea4e9aa6073e860 GIT binary patch literal 17265 zcmd74bx>SQ816Z^yK4w;!6gBLyL)g8?jbmV0RjYfhv2Tk0|bUZAXtFl4k5TZVSqVz z=Brz~wOhBg?mt_*O--GtVW#_c-t(NdpMIx7MP3jE1aC+V`g(c_@F%?Rhq{c00)vQ< zjg$9#246d`_f8(}41zrTe0&V-j^5s$V!XVr)^2t@@9lZq?YueUv@|r}A0mT4?A#qd z+Fl-S?cTq)vt_UeV9@h0(pOi|g#QW+{t)035riN9KW)HOge?dsANf%m#Do+FLPkZ< zFp2{Nj48=|Al!|-Jy;R|6W<079F#(Bvk0C=UAgd`_Yr_#0wDpA$Ge}YV*Y#g;lg>q zxn~gM-$xWF@Snc`067PN=z#Aal1JQunNN@J?mtC=#Mgj4;Of~fobvQ-T_%%NR#1W?z*>w#1hNF*c*s zltucLqlt|ev~3!U>sBatUPT1luCRZJWG)zG27wr)H$h0pJ-h&LQVwL`?Cw`SeE8%2 zF+O`T8UQYiW4UEy5(sf+q=3f)HXOx$659(@x3&7fprrk0W|Ir9snvG}S@bBkv06>~j_8FzX7LIx)yn`v} zS08Cr2MRokR1qC6ew|PzQN&dpNESfewBJQTyavGdtb5rakti7w-<<{{>$sN=?Fjri z^m9n|$ME;Z@SIhxB1A?l)iG zK1#|iPMTwXB&-{lA9ab;byJ+vcrY2pL` z_7(Re;OzI9eJCg~_XOLLpf4~?<2q*lFOh?0WD23te%T=)5RSBMYF(=S$+Q=P<=j3Z z{^Z#2z%EIH2<@e>o8+% znhPsHPD2$Hf(!47O}-nj820RxpeRK9=+ z_8ZC#TLA$236B4Nw)*m^B~-^uFwC9{Y}{r524qwOxMW;&mmI_jA*=~L-fm#P2-QlO zpxWTNX4Orip+6aVke4Ql;HSjF0~_Xs_`Zoll}& zemb1X4R5}qSOpafJd{unb)R}aN9)f5tZ&8UrqjVWWzGqlhJMk}aJ3{OfnKq%ZAoS3 zK6eUetA=e25r1|ZG_YL~A9fS@7IY{ibUDOA&|7M0mlpgs zoAFQwss{^21RMqtXq5Mu(}F(*j^t!L8=`7>J&m+euh&~;pg!9R!W6^!garZ%v!+#M$0RYlq48acI0eCQ3km>3H>V*-vHK6?# z`8niCj^Ga586Rx*k4XIf6FQB-Di{FH>5I&`s^=R+^rrJJ^NGxVVg2G-2uJxUgEWNT ze}+>|5X>uN!%M6RT{mMV+QsK)2t6eXqk#uHV0<;sBtUv}o3Ik|`{JqWXDqw&2ae(A znnYnp9>71A$4|gcUmUOcKO-VZ6l!wJZzalVUsl{&kU>@lb(P#dj=T3|>E?!idU4F| z|9poe{crQ%%h00V?~r6b4DgfxX9P!g;VR|Y04lDH2tf6E!9NfFQo*Cr|4etzD5mC< zuPd=DHnPR?>TRbzal+7dkS1!%!uhzfv%1S&IdvU^p9#w?*e8kG!IT&q`aqwJv+Fop zT+Oct0L$7KuWCGiJt=r}ri@25oSV|qo-)p;jU z>}KO5BjTUs9<+#++6%>TS%bYYD)-{Q=XT(I^U&nz;vX zEdquq9KkP!`778VNr-TU&?R=-!jYyitpip(|Elpf_(`d`ajkdb5d^|V5$}A>ak!iK zR*;=iU`ai0{WHsR)5*#Rz+%16fG1~-)PP+BR7Hf8 z6T*sck!du1Ra4W;lLl%T5=)@J=u(BHFI5*zNPN2R4_^3L#BrdX_!s~Jr0oDKX}J|V z_rzzS1q@XMOkRX<0A-WF##1OVQXk7@8*=|D?fV2Wi#Hz;{N|YVU~4%46+Hg^7Sc>M z`AZgtz6qua|9W}eSmtQm#C?Znd-ye71A`IAr%ZNfvv$b6w^tWRodj@;iIc&p6dfIt zo3wHbLV@7fY*!nIcQ151d?;x@$m{+sk}aPKwD@%^J7SC9HwMD4P`_aEFF}9_@xwjZ z`+7jp4$&)c7;eM~-0+Er_`$D6JcyEDFnE6MMQx7?9%qGMr{A-;x)1=y zLwV5sBI`*X!S9b{9c-7x?VLue3!9{Oq^XzhTaQ=g@=Zvi2vA>mgYzBCt|jlgLzT={}2;H2YaSr3KA}o27TXX34Fy=*iTg50s*d!D}8qJ@&Q1%NDlyV zbK48vt%Nv=@qht#c%mD_V3_%?*RNN?)Hmg=iv0<3b6`d%B7yMH9%p|Gzd(sqnG$n7 zyv{BOcAXf7^V`;9u>|=gakB*wVc+i2za-n8t`_d*FG)X+6PQ0f1c00D@Nwuh1a1`K zqkv;6?A5Q|fvs5UL*>#R@&n^PH_bqQc;)x_)wddO%N|2&4Sj8}D$s3-LOEp0v!~^I zrY#>n{g)uC^=Er5r=jp3;jV`tRUClENdd6Nl1^c`!*_^|$;l}UTP66|s5H($r##no zu*&_3Ad)hsxcQnYPFgTNpqS!#1w0xm2N%c9QAgWcAWzqS{$Uui9IhWByd7d{*bKdq z$t46nz@r7N2Oz->g+~Z5TMRiPe1JPsWGur<-7d@=4lY3*u}ISWYL=#M#*fuL`iB~` zX;o|J3K-oB=8!E~U9Cpow$Zza@dm6`Cu9}u0aVBbBjzN`7;xxP(vXkN(t**o)ABoz z)IR1^6$gE&^!_iJrv<%f+Wp*hSlJr*zR&3m7=Q#)d(79&mH|)|Fq~tyamUKQ1E3nW zyv8FB2Y?xwT!enMFmJ$4N$k#2L|(A25J#5XGr%o4ulpuj{}%WrFZtah>QCU#(A})s z-(HT)1)YYWnSkEi>Nc9SrR`HX9=4K|ZrCXJ2IGT-YBT(vZm|d?@@DpUov1190;0JI%KUgMlyQxtM z-nNIBeG0jSj{_X{e)_bl;sz*-6~Ft{nJx+iW?H-r4@^Uzwirc_w>%DKDx9z$C$i#fPw2Wria zo`-%b2DY8BpN0X>NbZ?PWT_L9n(h#~Ev8M;BG3&!+8ufBxXYh-2-!xji-=rjuIsx~ zr<4r7O30DH3QRq;#_<7wJ#-=Te~i-v*5c)!(IP*DHHSEE7dVQ)0N2N-sgS+Us@MEj zeb@pYvKbgU0-lIkPgmSGPNXxCP%;{n({9{Pd2a7iXsd#_)0C-S^U1}yM8Azt19QX^ zu|0h=w7eqBM*g8aTL_%YotPfpnpQg>>CG4fF2K_W>_lhEQCf*4spF2HQUgA~y>sSC z`?E*%SrDW4vv!NtB;}N1*&j?{2SBrK=8UysYk~%y=6WuEEPFnn584J712>}Zc?ZtF zhV=_w(miW!OUC^U@M(PXj3@@Neo>e*0R-dYCf^g-%1( zkFaC@a*EeaP30(LH$^tHu;DSbfXfH>?9x6SvZy89R-%#K20VE@>05$NIvQ`_gm)%) z-}aTQT-jX!<1k|%tl_l|d$b}D`yk_i_k3KZaYv9ymzXHn`*z<^WTJP}=V{u!t>))1 zv`lnNR-}W_q9RYD;jjCmqHf3aO}p55Z1WkWU6`l~tlZoZZj2 z?M+yyRu$yVIQ;bj4|W2k*U}7FH%pgS!IUv+JWP zF?SE4MuUh&;&&x~F)wF(*0MT`oaYqgNvKQxk-9Z)=Ah%i{DD~lTHW_-E;(o-BgCxt z;R0?-fj2h@{&U=S9|d{!Uu~ytK{G9kb_;?$;6vPS8N5*;XALd#q$Fn6wLA^C?Q`vm z?lSQ)YhUqQ4s!*edAu+B(Ojih6aQf@q#!7{rsZ3fKuo%9F`rypa=$n_fK%M z>gIe<&lN2G;OqzQ_SQBh=vt-*X+XpS=9z+}c-+2=9s#1LV(qG(*FTnu{@sw}$De*- zz7s8oTz&68>C#XhZqFcCa&E?aQ=N9r!YZYZeh*T zOE|x@11A71bSrpJ_OzLbV92?06#C7*Gb4z`Bd^sns%H6bhY&+rg zx>k=<(CNG5o}pO}DVxa~?oFjWKgycDJm6Zmy+=ewdCi+XL$k|V{W$j-e0~dgBK#Sa z84Q7r)}Yyz=F^7?XFu;BU#?(lki5deny_G_GU-byQrjM;)8G;bM{R#8Bb|L+&iDy{og5+fkFiI>_~HD{WZ2MD3qCs=ZxIK?6#(XMPD?I%QoK=P zLz{%+7>^sz9m$aUFZNBpx- zg@I4n*yLvbP>oZuU#{o8wy;qhtOkbrr$C^La1we*?z`O?x&^|DTMqoa<|16^o<(~C z+eXisS zb1q8=Yo<8&R#;G)D%RxHB%LjotHOseX)Q>V7m~6XV!q@(JQO5xWap8L&5dh+m=Z2R zpZY>6Cf_mIQ1$9<<_Fo5aGQ^)M(t@jws;D9Xd&TQz?L(TPA~xgyJRH=WD82zS;+l%JAh%BzjlH52i2cztk2l2Lh;>e zIPoIY{U|jOg(xfXLxHX(a0mZ^w};06^mB&m6UM#4Eo@JG3C*=&U(T&gFNuy0XL!{- zrSPPd^J$Y@BHI0U5+54o1ELVWPi-TooAP#ANTGBH?=k;F_;K!b4y*}r(|Qt={eTu0 z>a@9^fORE^93TbEowR^^uvHFqpDitAtw4?%PuC}n`|_nTQ{Dj5sYuQ+*<=tUMlO@` za0k7mS1peZSkwK)F`vQk!}Z-u^`B#}>NQh3J>L%`gUB79;eAC8zW`w{82^dHLOX!M z?qpiY+7R(S#D>RzoljVkj2h|{Lg%1Nf6(E#FkAL83!FU(uguTQUKLM;|G2d`eDKT$ zks^~B@~CjPFQa||waSm%u^rFr_S)||gK>P{tm^jacW&t)1eVY$z{(@OxZ1C=0noel z_Ij~edl{VB$@(RsX_L(TTj1>0r}1Q#k=3#qwN4uc#b1}7@O;*g{q5qmoR7Xh;)##E zyshAo--4}M+gg_7U)?!e<27w2RNSjn5E8>pHHpovK$-lA_#uM-47V7}I?5m%m3rA5 zYk|2{;M`+s(9kwx;#6VAXUDJsYuKoOm;!--@ywP#8W-v4uKS_fE($F+m2fa*^JB>w zK|E>xsG{hRS{FPaY&HyqZpWZ;c(+|~RqPwzv)J#sMlo^7rFIpY>=aoZkI0-<1dRgU z32EISz(Zl-$>PtMspTt+4OnI+vWjv zKiLC#YG5GTs^wHW4+fj_ z_-<=pi}k6n1auz_J4FbCXB0e6QBhc~lqO}L6HA>15?@J%W!)H27_UZv6ezX|%8-`kTJL(gXb%z%|6< ztYW>*v||PUR!XnHRg$#8JrW;egzF0ir8VvdNp@3flp+H0h~wq-SZ7{r`Ty22L9-Z(ZEae7IKxoYxFxyr(;E0s+{QRUb-Z zVZkx$E1WAxz73iQVHqkRh(A=RgiPz5h|b8*TGuNpmxw2c|r|T5c=WWa$ z2QSRqYG;76Iw2iAhlQuN7lhU?7QlDkPb}z&(#jOy8h@0>r!*~+ktuR*W5V>6JxVY( zMx9A4#B$;V(4~(YgTN2fLx(O?HeK0r=V)fMF&A`>dA02kvkmk&Vr-(r!9` zRyDGI8CK|T>4_`pHUiTsn2QMhA-I2L45SoxCJF2+vA~?|Ix6N#+~9RPKHT?t;T~ob zfPxecI%btNViiFBPA?jtr35^2YS#Lh^u~Ido42__U~A4NN{#;xn1hxZbBLuD9KiLE z?bJn-_%8ueDV+;WS$#QTO$WHuP*o3EpDO;l^snDFd6Y66fhy+cdr1cXkemawywwNH zkK8EXca^2(`LGu33>e`guGsD+wFWINh)jSfSS!`!g~TJ#_^@(bQzz#%QP{{#awGT$ zadLuL1DSm!iA;KfduhqSD`72RA$?sb3X(0)pCG^u_(9j399mZqmuWN+aOe= zMP_#`X2nIaUo6235r>LyfBoWARq}5dJ1pugO%eRRuwpDZR~UpeDTw=xdnsM4#A%YH z(Vm6GkV}H|3CE!OW%M}Fnl;`s*(4k zGA)SpZCPk5kK$1sUTc9ZyiYVPoFiM#Y`F97_^-^QQwS5Bbyzw=vrW&(_CyNhTWHB( zKHa^MM|zeXA< z`Rqg1!LUAg!(fy~=nv1D1^~Wz*9&ZGHm}PQ+n-u;k#R3ya7(@eEcM(L%D9LA0+0n= z`hqdcIZRJ!Ox4bpPlio~%8ktbrqUH&s~FEpXV5<9&7tG&Y&(RtJOEdn=Jf*O;c=`N zkNrCoRa4=7lBaU zjpZS1^c8R~j+^IJ4rIChdR?JvkT{~dNSl~ z_d0iJf_E0fd|J${M(p5H1oJs3G!HMswQQpZJP_1l+s zq@{FakgF^Sm{1{s#PRi%AS>Dg2&wNC@+=k-+K}`x=+m>QTIhNi*?Ogl+sqr^dei^> z=P`CfEj_b@sV2!KJpT~RhRgwmZD8P5%yi+jd)FuUbKvpC{FhfOD^L40YLU?0nJB=N zxnU@v{33EtQp9de%<<>N(CeFN;6eoXq!R$T+UHM!qq*v~HhWtD=zu`#_-VcMs$X;j zMAhDXjYMb7WuRbJ${^t$N{N-2+zy7+kfslZO$%HZ=Z}qFa`$qUv#mKiwpX+gs(*D< z_|6gv!Q8nD-*$e}$7{$-u(wqe(2*>t#V>VB7Aq z-zD}2dOy8hujN-q)_^pVg1kvhMCA3derY(^RGhp~ZCw_-N~>Y2eqGbTy*}(WpEvu5`K5!& z%KNl7H40txu@?*Lc1cf8M8Yf`j;LAMNPH~)eR+Pr4+l}`Q>kT~?c&jQ%|HEN(f~KY z%DxvBQC>u>FT)Y{@(!;jKwM-79}dKNkwGTw8Wb_XMuD-`Sn*UlVt! zajC}4F37wybQ4}*8ILGw?pJ%!?Izv6EG2(d!c{g+cR9If>}EY7v4U=^W0A|>(gd$p zBr-|?58c(7dX1)TokQNujKOCb9P%3_$3s3ItyM17zsG(bZUGi5>8@^8ZUDY(9D;uh zJHeWtmr_WQc)2&zg3>^-CP}ljf;PU9+(-OpqSu5F(h4^oIPkUQ&|R>EkKr=0PN!QP z1`DW~L~W*t{=&&pQlm|{O3`lTnYyN%%B#CKz#--^3Kh%`c{EpH9fn^WHa!=898 zHq7VJ`{q#WJqXr!NG*%XWhsAST}7m@B?umYfAnoYUsu&t@MWafsf^B>Iy-Z&wT=P! ze^j`t#0Uos<3|OvzE(rmx3pu>h{PPfWMFa_ef_Wgu#fYhmYzf6gCT`PP z&oh|#fU&WQQsOr*8btgru&=8b10_r}DYtq|q( zuAx=%)uuMQ2(hC5qLtuc);Nb)FRre|gk?Z7qAUns-)1%pIwLZ=5BV>rp|_v(h}AI^CWR-#y9 zv&AG>YQY9yI-TC4e6KVpuWOLkQ>TUw#Vr%G9Xa{*EjUg|@iJ9in~<&!5-T z%64jUFx_l6Hp0A7i3i~JD4{d&h)N;MX!ODr;w5G0Ji!7 zf5LfKVTG!R6%|Tw-JYQ!SI(LQF)y%*jo2K-B}C@<1NnPIWH%;k)oPdxxR|8 zZ?kcn`RR|GM5H9Qp1UZib3m@2sJJ+OV%~avT=r(o#Ifo?nza?*^v_!vv_uR%Hl@y^ z6%$FGQqPRo|MLG8)_yGmhlG$br9i*4g-~SZpooVxcmF~?E{aC8Yk%quvAIR8n@Ot5 zi}t>qWaM!)MM_YtJrq(Os*;!}R2<2Zo!1kMU92G7)g>n&sowY~fw~?CK*Y^L=-f)$2>}qr!+ZpZ{Y6Wkv4m4C?d)=UtM<8zxvvHQwFsc4&N?cx(P_eME-AP5~ z9*ZN1jSpwvgZ}!kva0~xJv>0{xpB!PH@z1N8>R7YoNkNlrrDgBkxCKqKgA)fW(bh* z<{=g6kAmwbJljlP!6Jv7dZA(Oui^67j^*LCZg-QH-A$8x)xL1g(g$uw7Lh*u4874m zsxfNkH+217B!oK55n;iGM$&x?9;4Al8yYsF!fM+pnUMY`>E+(rc*AxFhMM;1%E~DE zviKH;5g7V7hPo?`vTs#7(;e(T-!hpUo@||mjW#db9{;i#mL|ljZYoP4esQxbli-q{ z{qD>+kITHu{R!q~^J9Rmjl>_na!=9&!GDHxUP zGGoG`A~p2*!tp7(Zrkre{Xqj%&r@2N;IDN_?syCdAd7>?{F~R$F6Zi6N3pQ^PPlu) zu-5Dog8u=#s+yif!drqOXWHF@HZs($!MXNd`E1LV=69XU&3(2BtyFWE(qi{JwXXic zuiR8R?v6?zvck#Bx_Dq~g#EkN zq{p@9GxGJPjPAE{)3itvc*T8P7jwB_`U$?b1J<|}+w!@1Ue{>KLlw_6yVLa&(#CH& z($=pmfibV|m{^l}y!WFS+l%C?7|k2jEgX!BMR{}DMRTTob1CZR-_rg^W%Q7lqEW2^81q{0f(AG!N}G|H748yYe$5hx51 zl+GwGD1Q;Hy8L~E?$V%Xn7pUq^SL`aOO9!^WF8}Bc{H#pSi?A3S(&&-x!3oJ--e#r zqB-(`ZtdE2E8V4g+)-nVchlDO7QugqXH-i~DiNef;NKr>L8)jD+=h;A6b-9gPPi=E zP)a5XPp@1iGej!nq0xp@M#-k5M`USy$;F!T|DE_Pr{$*Fn*`SgvzHj@mXJsNqY0-n z!{7d25z@6{DV||NjDh#FEHSylLO&)1X4b#|S$M7JF80$WNr=DgIVyG^D`xViw}Z}R zb~NH9j>Gox`rS`=k`Be$hJU#sd#Ph%Atq<@R)h(l^S9F|TBa4FPi_V&!==rTC;UB& z!r78{2>yF))M|!c32#X%Cs~D5z5{vSG3w#Qai55z@3R0awjxQZs%3$pmF=NnijK@g z*DIQEVO;})F>A5$pQD{uOFlCyB$0%lLBi!bUFefuw+vJXlJ5CIXeRIC6E~R4GJgq= z$>zItF=ib&y9chywEb(jfIQV8Hr4*Oi59IOShgHAeQ}n`q}j+tBJlmrdyr(J*^wFV z`8e(hA1bT2UC6yoEvz-Hh);MRW_dPXLtzgsB3vlhhagx6!GDS+T}toG=dDRWr7OL! zyqh24YC`dz z5@36;)S7<;NkP9|h~)W*%p>-1*dAd2t7aVK6LKbyk+m_SoXvj|+CPG)EM!_&hA*%} z{TUWzK!G1_$5Xd0oMz=lbWG}YrK;2N0;RLLyY&ZkHogb1@UJ-};YrYECxx#oqza1a z7`utYB4{NCZR5gUn6hZ&UyGTphACk;y7SG{(Xq>vnY0(Z3pS~tsx1gBSLz>3VtSkO`fkoPCF1BQ6MWYFv;dZ# zQ`|=I1K4J@^geL?P*WL7AM)+7Z#J}{DWGci|D2wXidARljLeah4wtF~Gd^i+(9Vfh7Kx)ahGxu}Yd92u-1&$&qXuwcKKmx#^tV)~%c5 zC67v%+yh5os?!Ye1&3meu7RyGdQ(J~QhQB)7WYSaCWRx~-2s(SkrWqizvd$Yzw(9Z z%yMBj7xAUJqxZYj49IR1xa5370+ARgX+llOA=S&($*~Mi)gkkCm*5A>Z=aC4jVTq)yl>*ut+_M3%;oztk#4>s5D z1issV+ehE5iX9EA;94tHarK4Dqs60~y8z}m`oC%R_98{v%&N*FRLzhPIyT}x%l)W* z(75|d;bWoS$|2l761eMfA^0!wzSL5aNz8H*7fTwqQflE&u5SP{^;)pd?ZlsTc3=M{ zxH~mSPQGR;{{3%oIwmE6cDo9b2f6XXMp1nH=XWL{2ZEMQGV!SIirm+k%Co=el$#WD zFtn`tL&H6a-P}49pM68ue|Vu0GbxZ*V_Wl+Nk#NXvEw-r-48S}YxO8Yi~h)~b{GGW z!d|WMVBaA{1~%iI*}NOq+!d|0m7_K#IU}S$oN6mUP{6$72m5r?O?a zbt$`sjrq3SJ`^3ZZc@*y#j_nPFK25QYR`sqm@42i93%M8v6PG9`k$ev;#ihG6dJTG z4C0-n&U|`7#%}7TCt`4+noj=ZZ)6kFpCh-2Z*nlv_LB^x(Anj8v>nXBA+1q5+BaSBwqCfj5*pyH>czcZ zjgCQIn;gF@$r^CIHK-`_;OFJYa2@|nvpZG03<0#~Q3;RFZqwtnpF$-B|1pj_g5O(}P)95ZHz@TtDMq>v^Oj7X;PZ-<+Q{XM@7o#K@U7w$^MLP{ngGN zy(C|gk6lyxou??jab#Wl`_AfBX7EFilx>r5dT)p1aA@z?7fDA+jXn=fFQE$;p;^dt z5Kt2k&@8xc1z$Bnf72Z_jg)fQT{sZYlRL+LGzfbai5x16I!CGFWxGS!6X2}pbfF?b z`j()OeJKl7@a$K5_i-C$m8wb0hEI3}4iR#s#ms;4$L6c1C6sv2Luo82f5>MlUvqck zJwtKS(E6+@8S-G>gx)ksbk=EP!_;asb;(J_Q(}#bQkr_|dh|5!KT;LF=HQ&O7Lc3R zR5tfn^jlhOqeuOpbB#hIXPMHFdGXc+wf%!PTCiBXyI#NnmN18Im&|}M#$Q0L>Z;~- zv~syMY!MK+f=WH7VyfVw%0gy%8uwZla3E!;<#7C+<37P#cmk}oStJ6cu=?AH%71qT zyLHXU3{Dk2Yoz=+o z{&{(B>H;*p7dTwFa}7pX%WwSg?r$H;TWR%T>eTp!6h)_Ja~kym&}r4&-fjf{Kg=)1 z^!|Lh&NN8vGN~dMq1OT*N5MzyVL8_-ww2QEHGAL}jur8yqPX8yZ$lkdjr(Yw%r3&IXuqc7Skwrc1mJC@q_Kj^1^zYq zv;du)>!*0ZZ@u4mleyFqTeR3x#I#3riEgNiFqb+gHy9zXCR&t){UEXGPl|<%+LdJ= z+7f{kk0;gMSRn-eDekvoxPKP%CwjRR1^3VRu1WWBRyCdNZuCWYVG2 zwXfI)N&ReN>0SbNyeFf6d08pfb%K5$j4tc*#502Y=43KM7tl;x3j>@yPmPyo%7r7@wyFNN4)Y!eK?P}&$9@0^~$;u8)ofkA>h~nfoE^;K+4J@{A z-g$Vv`VM1nPWXjgWU51xwh3flVVEV>OsJi)pW_Vn9OOt3OkD*h>0-S2j~{Ei5bmGd zG^um?r7zW$LQnN~@P7kM_w^^*RghEQOk*7uM(LbSe}Fv5YX9^eJ5}qRj6eUoH!`ZN z5ROJ<=}1GZfL~;)_~GA|S%9=6N-TWioH1Z0XL;JFP}I@N5n0W$U?$|su(~?UNo}0|F^3=eROuqq@}l` z*CkfhM<5}neEr$VmMfAXSr4pbiYpok!M}+`Sd7q*CMB^X-2cz!S9#pxDZS*5OaiO% zIPmlc_7tjxoH}#YqDap#WK&l%h3IMEo}@&xaB0SKtE12+nEI+?)!_Qxb8r%^5o^JS zkQ{>u3td1Iv5b?~u3j~zL3O&oc55&MXu7eHB@PQ5uQrLE^H0z*3*j)D))Qi+von-R zEWdu}yw6#AVqJL4Tlp1x&uR4nyaKZFx-X-at`}|P- zkz$@bHAksk7tgH^FQ)yrvx8V)k@ z%zS&IB1HL8CVP3zH! zz`e2G_;eju4}-Wj{5ixFZBm#&cAn@ekKxTH)cgB-z@BvS0Ci-vj+NiztEJ#dfs%H$ zs&XdLte7{fPY%^Wp$@B0Yg(IawKRd$wE5DfzEBr6Rb32cgy}S)3uY61Hk>kG)?|>>WUgxpj z35Z{Tv9SFs{izZSqINQd~Q5sDe(FU-|YFYWFCuzF>JmO3>=%dP3H{Y z=O_;mdX2njU#?5Vbf0q?!B%&zdqHx1PRqIct>#x@oAr-uS9`vf&p1o%EH+5LIf4j9 zid)Q0zdh7sv-c#Z@veuvD%$UaEN#F`@Rh9S`&iO*3QFOb{+|uo`Q%e^Us-Fe2_Lh{ zcG?MGiFi9R38csBX^=jpnARp}%NOX{vb46)C*h0xjYzJEv#QR{HS7j2YX2p$&n!pq zpW#RsGLA}kni2aC#ai$ws#+9X;x^&^BX<-ACyJWn|LEy_H)LGPB-BNe1Cztj{p%ht60s!AxU-!Z z!tJWe;jy_od*rF!ny|dbE&fe|zR+ajnj&grL={zGjtIeji(OSpYb(L$OrskveJQ69 z`bi<<2&mNnW{Xoj-nWQyL2n%IW*!~}vP++3kbRY8e=YG`L2dg-X77aJ3s-T8IkHqr ziN6vjihSm=*TXM8PA=Fn*DtK@H$k-|j2a~ew6<1l1c5ZCw?S#8Pl(kYZ2Lt9tP2Z^ z%W6X;Jw|cxQvo06pJ2!7uJ8$-#;$F60EzG;#J879I3xWQ({yN7g zE2MoVq3uj8BWaKh_s`I!>ejE-CcES{ynG_cnxXLpSfzm>EpoARF$cI!C^9HT48xTM zU5Tql2I`A=M5n?;3onOJe?}IdvNN*Nw8jl8%ofG5;eC?Rl^H6n`9A42pUGHu`R1FB z5|0SSer56NYURLwTg%zS8`LOjMWT|SMn{vxh972+HH++SqUshjn3m*?H@{-8%DPPM z9U7`2FlLY-GH>gc!s1TRC~6t0(gxsO6BHaD{6Fh&Uke#V5dN7Tp8u6+^MMD07jtcI zrOX1fTK_&Aw$I{%lto+EFywwwP5$cQYfjmmT2$U69TJGt+hlv0OUDMYXtc@)Y0)<7 zPP){Sx3Hyp=L}nUZdY%me89FYZ$A0$SvadBt!=22; z;(EJqJdvpZFI6nC(bFSRDS2~&qtJh2GHZsZ0Jmp+t^QCX}dGE0mxkjc0x6ldm;;5v>= zjwxx7ou|G{vGtegj>w{;iG;lr+?YtR{yTA6Y%JMcgh-8CTj~usB=i-(2=<5w0xZn#^9e z`olYgM!ntrU+f^(IMH$ohmgtAPRuD|RRsS74kkiBqde5sUMlH)^XI`oT`r!we2**b zK0h>~iyt&$(>pH{4wU5?KuF9LVQm_WV_q%XjNi&p1kqLP3+U&{ISOf`_wt_AbMMH~ z5lFV0lcnx9?K{1EcZ?mXjb6dwl2uR?PTab7Ju=C%44u0>x_Mj>J)m@Am`haT*wBz;BSHJgXoT5$Q5!S@Vfqc57(~?S z*>S&xty$m8^J=(UnTgctA${~&d`9DDq0@NumfJmD!ui;KC+feyUpT{FErhS%-89LX z#5B?=m8{N6p&1GxC+i;v+FQ>Y+ic~rtKomGph;5n-o}u_LC^MmXQmpNsDAC+wiI7F_9#~ z+mp$~kaYISDBh!;@l9qj34@=BG1jK4Da6sz8b(->l>@ySds!zT8%6SOo(i8VKbxp< z{y%d5FVO#YUP9<+6kWTRl#`i}mhiVC11_cf*Z-Xl{{Q4X`Z7wO|5w}p{r_M5Uk3yJ E2Z%CyM*si- literal 0 HcmV?d00001 diff --git a/homeassistant/components/media_player/browse_media.py b/homeassistant/components/media_player/browse_media.py index c917164a2ee..d234050c1b2 100644 --- a/homeassistant/components/media_player/browse_media.py +++ b/homeassistant/components/media_player/browse_media.py @@ -23,7 +23,11 @@ from homeassistant.helpers.network import ( from .const import CONTENT_AUTH_EXPIRY_TIME, MediaClass, MediaType # Paths that we don't need to sign -PATHS_WITHOUT_AUTH = ("/api/tts_proxy/", "/api/esphome/ffmpeg_proxy/") +PATHS_WITHOUT_AUTH = ( + "/api/tts_proxy/", + "/api/esphome/ffmpeg_proxy/", + "/api/assist_satellite/static/", +) @callback diff --git a/tests/components/assist_satellite/test_entity.py b/tests/components/assist_satellite/test_entity.py index b9f6da6f96c..2b1cc78943f 100644 --- a/tests/components/assist_satellite/test_entity.py +++ b/tests/components/assist_satellite/test_entity.py @@ -22,6 +22,7 @@ from homeassistant.components.assist_satellite import ( AssistSatelliteAnnouncement, SatelliteBusyError, ) +from homeassistant.components.assist_satellite.const import PREANNOUNCE_URL from homeassistant.components.assist_satellite.entity import AssistSatelliteState from homeassistant.components.media_source import PlayMedia from homeassistant.config_entries import ConfigEntry @@ -185,7 +186,7 @@ async def test_new_pipeline_cancels_pipeline( ("service_data", "expected_params"), [ ( - {"message": "Hello"}, + {"message": "Hello", "preannounce_media_id": None}, AssistSatelliteAnnouncement( message="Hello", media_id="http://10.10.10.10:8123/api/tts_proxy/test-token", @@ -198,6 +199,7 @@ async def test_new_pipeline_cancels_pipeline( { "message": "Hello", "media_id": "media-source://given", + "preannounce_media_id": None, }, AssistSatelliteAnnouncement( message="Hello", @@ -208,7 +210,7 @@ async def test_new_pipeline_cancels_pipeline( ), ), ( - {"media_id": "http://example.com/bla.mp3"}, + {"media_id": "http://example.com/bla.mp3", "preannounce_media_id": None}, AssistSatelliteAnnouncement( message="", media_id="http://example.com/bla.mp3", @@ -368,6 +370,24 @@ async def test_announce_cancels_pipeline( mock_async_announce.assert_called_once() +async def test_announce_default_preannounce( + hass: HomeAssistant, init_components: ConfigEntry, entity: MockAssistSatellite +) -> None: + """Test announcing on a device with the default preannouncement sound.""" + + async def async_announce(announcement): + assert announcement.preannounce_media_id.endswith(PREANNOUNCE_URL) + + with patch.object(entity, "async_announce", new=async_announce): + await hass.services.async_call( + "assist_satellite", + "announce", + {"media_id": "test-media-id"}, + target={"entity_id": "assist_satellite.test_entity"}, + blocking=True, + ) + + async def test_context_refresh( hass: HomeAssistant, init_components: ConfigEntry, entity: MockAssistSatellite ) -> None: @@ -521,6 +541,7 @@ async def test_vad_sensitivity_entity_not_found( { "start_message": "Hello", "extra_system_prompt": "Better system prompt", + "preannounce_media_id": None, }, ( "mock-conversation-id", @@ -538,6 +559,7 @@ async def test_vad_sensitivity_entity_not_found( { "start_message": "Hello", "start_media_id": "media-source://given", + "preannounce_media_id": None, }, ( "mock-conversation-id", @@ -552,7 +574,10 @@ async def test_vad_sensitivity_entity_not_found( ), ), ( - {"start_media_id": "http://example.com/given.mp3"}, + { + "start_media_id": "http://example.com/given.mp3", + "preannounce_media_id": None, + }, ( "mock-conversation-id", None, @@ -657,6 +682,32 @@ async def test_start_conversation_reject_builtin_agent( ) +async def test_start_conversation_default_preannounce( + hass: HomeAssistant, init_components: ConfigEntry, entity: MockAssistSatellite +) -> None: + """Test starting a conversation on a device with the default preannouncement sound.""" + + async def async_start_conversation(start_announcement): + assert PREANNOUNCE_URL in start_announcement.preannounce_media_id + + await async_update_pipeline( + hass, + async_get_pipeline(hass), + conversation_engine="conversation.some_llm", + ) + + with ( + patch.object(entity, "async_start_conversation", new=async_start_conversation), + ): + await hass.services.async_call( + "assist_satellite", + "start_conversation", + {"start_media_id": "test-media-id"}, + target={"entity_id": "assist_satellite.test_entity"}, + blocking=True, + ) + + async def test_wake_word_start_keeps_responding( hass: HomeAssistant, init_components: ConfigEntry, entity: MockAssistSatellite ) -> None: diff --git a/tests/components/esphome/test_assist_satellite.py b/tests/components/esphome/test_assist_satellite.py index 7fc46e87503..5f433a6c0ed 100644 --- a/tests/components/esphome/test_assist_satellite.py +++ b/tests/components/esphome/test_assist_satellite.py @@ -1249,7 +1249,11 @@ async def test_announce_message( await hass.services.async_call( assist_satellite.DOMAIN, "announce", - {"entity_id": satellite.entity_id, "message": "test-text"}, + { + "entity_id": satellite.entity_id, + "message": "test-text", + "preannounce_media_id": None, + }, blocking=True, ) await done.wait() @@ -1338,6 +1342,7 @@ async def test_announce_media_id( { "entity_id": satellite.entity_id, "media_id": "https://www.home-assistant.io/resolved.mp3", + "preannounce_media_id": None, }, blocking=True, ) @@ -1545,7 +1550,11 @@ async def test_start_conversation_message( await hass.services.async_call( assist_satellite.DOMAIN, "start_conversation", - {"entity_id": satellite.entity_id, "start_message": "test-text"}, + { + "entity_id": satellite.entity_id, + "start_message": "test-text", + "preannounce_media_id": None, + }, blocking=True, ) await done.wait() @@ -1653,6 +1662,7 @@ async def test_start_conversation_media_id( { "entity_id": satellite.entity_id, "start_media_id": "https://www.home-assistant.io/resolved.mp3", + "preannounce_media_id": None, }, blocking=True, ) From 4f318c0be38ee0847a927b90e7f03e80c74e8fad Mon Sep 17 00:00:00 2001 From: Ivan Lopez Hernandez Date: Wed, 26 Mar 2025 22:05:22 -0700 Subject: [PATCH 3125/3148] Initialize google.genai.Client in the executor (#141432) * Intialize the client on an executor thread * Fix MyPy error * MyPy error * Exception error * Fix ruff * Update __init__.py --------- Co-authored-by: tronikos --- .../google_generative_ai_conversation/__init__.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/google_generative_ai_conversation/__init__.py b/homeassistant/components/google_generative_ai_conversation/__init__.py index c32d7b5ddea..88a51446cda 100644 --- a/homeassistant/components/google_generative_ai_conversation/__init__.py +++ b/homeassistant/components/google_generative_ai_conversation/__init__.py @@ -5,7 +5,7 @@ from __future__ import annotations import mimetypes from pathlib import Path -from google import genai # type: ignore[attr-defined] +from google.genai import Client from google.genai.errors import APIError, ClientError from requests.exceptions import Timeout import voluptuous as vol @@ -43,7 +43,7 @@ CONF_FILENAMES = "filenames" CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) PLATFORMS = (Platform.CONVERSATION,) -type GoogleGenerativeAIConfigEntry = ConfigEntry[genai.Client] +type GoogleGenerativeAIConfigEntry = ConfigEntry[Client] async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: @@ -139,7 +139,11 @@ async def async_setup_entry( """Set up Google Generative AI Conversation from a config entry.""" try: - client = genai.Client(api_key=entry.data[CONF_API_KEY]) + + def _init_client() -> Client: + return Client(api_key=entry.data[CONF_API_KEY]) + + client = await hass.async_add_executor_job(_init_client) await client.aio.models.get( model=entry.options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL), config={"http_options": {"timeout": TIMEOUT_MILLIS}}, From 0f9fd78656a6835641fad8e984e20ef941327010 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 26 Mar 2025 20:32:59 -1000 Subject: [PATCH 3126/3148] Bump pyserial-asyncio-fast to 0.16 (#141540) changelog: https://github.com/home-assistant-libs/pyserial-asyncio-fast/compare/0.15...0.16 --- homeassistant/components/serial/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/serial/manifest.json b/homeassistant/components/serial/manifest.json index 557166d8cb2..2a5d3c78737 100644 --- a/homeassistant/components/serial/manifest.json +++ b/homeassistant/components/serial/manifest.json @@ -4,5 +4,5 @@ "codeowners": ["@fabaff"], "documentation": "https://www.home-assistant.io/integrations/serial", "iot_class": "local_polling", - "requirements": ["pyserial-asyncio-fast==0.15"] + "requirements": ["pyserial-asyncio-fast==0.16"] } diff --git a/requirements_all.txt b/requirements_all.txt index 68d02cf5cea..98b2c54f702 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2289,7 +2289,7 @@ pyschlage==2024.11.0 pysensibo==1.1.0 # homeassistant.components.serial -pyserial-asyncio-fast==0.15 +pyserial-asyncio-fast==0.16 # homeassistant.components.acer_projector # homeassistant.components.crownstone From 13fc8718060ea6acd92d9f5ed70521f3088b25dd Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 27 Mar 2025 07:46:08 +0100 Subject: [PATCH 3127/3148] Use kwargs only for MQTT subentry PlatformField helper (#141498) --- homeassistant/components/mqtt/config_flow.py | 136 ++++++++++++++----- 1 file changed, 99 insertions(+), 37 deletions(-) diff --git a/homeassistant/components/mqtt/config_flow.py b/homeassistant/components/mqtt/config_flow.py index 7fe01e9a890..83592c4c23d 100644 --- a/homeassistant/components/mqtt/config_flow.py +++ b/homeassistant/components/mqtt/config_flow.py @@ -337,7 +337,7 @@ def validate_sensor_platform_config( return errors -@dataclass(frozen=True) +@dataclass(frozen=True, kw_only=True) class PlatformField: """Stores a platform config field schema, required flag and validator.""" @@ -372,80 +372,132 @@ def unit_of_measurement_selector(user_data: dict[str, Any | None]) -> Selector: COMMON_ENTITY_FIELDS = { CONF_PLATFORM: PlatformField( - SUBENTRY_PLATFORM_SELECTOR, True, str, exclude_from_reconfig=True + selector=SUBENTRY_PLATFORM_SELECTOR, + required=True, + validator=str, + exclude_from_reconfig=True, + ), + CONF_NAME: PlatformField( + selector=TEXT_SELECTOR, + required=False, + validator=str, + exclude_from_reconfig=True, + ), + CONF_ENTITY_PICTURE: PlatformField( + selector=TEXT_SELECTOR, required=False, validator=cv.url, error="invalid_url" ), - CONF_NAME: PlatformField(TEXT_SELECTOR, False, str, exclude_from_reconfig=True), - CONF_ENTITY_PICTURE: PlatformField(TEXT_SELECTOR, False, cv.url, "invalid_url"), } PLATFORM_ENTITY_FIELDS = { Platform.NOTIFY.value: {}, Platform.SENSOR.value: { - CONF_DEVICE_CLASS: PlatformField(SENSOR_DEVICE_CLASS_SELECTOR, False, str), - CONF_STATE_CLASS: PlatformField(SENSOR_STATE_CLASS_SELECTOR, False, str), + CONF_DEVICE_CLASS: PlatformField( + selector=SENSOR_DEVICE_CLASS_SELECTOR, required=False, validator=str + ), + CONF_STATE_CLASS: PlatformField( + selector=SENSOR_STATE_CLASS_SELECTOR, required=False, validator=str + ), CONF_UNIT_OF_MEASUREMENT: PlatformField( - unit_of_measurement_selector, False, str, custom_filtering=True + selector=unit_of_measurement_selector, + required=False, + validator=str, + custom_filtering=True, ), CONF_SUGGESTED_DISPLAY_PRECISION: PlatformField( - SUGGESTED_DISPLAY_PRECISION_SELECTOR, - False, - cv.positive_int, + selector=SUGGESTED_DISPLAY_PRECISION_SELECTOR, + required=False, + validator=cv.positive_int, section="advanced_settings", ), CONF_OPTIONS: PlatformField( - OPTIONS_SELECTOR, - False, - cv.ensure_list, + selector=OPTIONS_SELECTOR, + required=False, + validator=cv.ensure_list, conditions=({"device_class": "enum"},), ), }, Platform.SWITCH.value: { - CONF_DEVICE_CLASS: PlatformField(SWITCH_DEVICE_CLASS_SELECTOR, False, str), + CONF_DEVICE_CLASS: PlatformField( + selector=SWITCH_DEVICE_CLASS_SELECTOR, required=False, validator=str + ), }, } PLATFORM_MQTT_FIELDS = { Platform.NOTIFY.value: { CONF_COMMAND_TOPIC: PlatformField( - TEXT_SELECTOR, True, valid_publish_topic, "invalid_publish_topic" + selector=TEXT_SELECTOR, + required=True, + validator=valid_publish_topic, + error="invalid_publish_topic", ), CONF_COMMAND_TEMPLATE: PlatformField( - TEMPLATE_SELECTOR, False, cv.template, "invalid_template" + selector=TEMPLATE_SELECTOR, + required=False, + validator=cv.template, + error="invalid_template", + ), + CONF_RETAIN: PlatformField( + selector=BOOLEAN_SELECTOR, required=False, validator=bool ), - CONF_RETAIN: PlatformField(BOOLEAN_SELECTOR, False, bool), }, Platform.SENSOR.value: { CONF_STATE_TOPIC: PlatformField( - TEXT_SELECTOR, True, valid_subscribe_topic, "invalid_subscribe_topic" + selector=TEXT_SELECTOR, + required=True, + validator=valid_subscribe_topic, + error="invalid_subscribe_topic", ), CONF_VALUE_TEMPLATE: PlatformField( - TEMPLATE_SELECTOR, False, cv.template, "invalid_template" + selector=TEMPLATE_SELECTOR, + required=False, + validator=cv.template, + error="invalid_template", ), CONF_LAST_RESET_VALUE_TEMPLATE: PlatformField( - TEMPLATE_SELECTOR, - False, - cv.template, - "invalid_template", + selector=TEMPLATE_SELECTOR, + required=False, + validator=cv.template, + error="invalid_template", conditions=({CONF_STATE_CLASS: "total"},), ), CONF_EXPIRE_AFTER: PlatformField( - EXPIRE_AFTER_SELECTOR, False, cv.positive_int, section="advanced_settings" + selector=EXPIRE_AFTER_SELECTOR, + required=False, + validator=cv.positive_int, + section="advanced_settings", ), }, Platform.SWITCH.value: { CONF_COMMAND_TOPIC: PlatformField( - TEXT_SELECTOR, True, valid_publish_topic, "invalid_publish_topic" + selector=TEXT_SELECTOR, + required=True, + validator=valid_publish_topic, + error="invalid_publish_topic", ), CONF_COMMAND_TEMPLATE: PlatformField( - TEMPLATE_SELECTOR, False, cv.template, "invalid_template" + selector=TEMPLATE_SELECTOR, + required=False, + validator=cv.template, + error="invalid_template", ), CONF_STATE_TOPIC: PlatformField( - TEXT_SELECTOR, False, valid_subscribe_topic, "invalid_subscribe_topic" + selector=TEXT_SELECTOR, + required=False, + validator=valid_subscribe_topic, + error="invalid_subscribe_topic", ), CONF_VALUE_TEMPLATE: PlatformField( - TEMPLATE_SELECTOR, False, cv.template, "invalid_template" + selector=TEMPLATE_SELECTOR, + required=False, + validator=cv.template, + error="invalid_template", + ), + CONF_RETAIN: PlatformField( + selector=BOOLEAN_SELECTOR, required=False, validator=bool + ), + CONF_OPTIMISTIC: PlatformField( + selector=BOOLEAN_SELECTOR, required=False, validator=bool ), - CONF_RETAIN: PlatformField(BOOLEAN_SELECTOR, False, bool), - CONF_OPTIMISTIC: PlatformField(BOOLEAN_SELECTOR, False, bool), }, } ENTITY_CONFIG_VALIDATOR: dict[ @@ -458,14 +510,24 @@ ENTITY_CONFIG_VALIDATOR: dict[ } MQTT_DEVICE_PLATFORM_FIELDS = { - ATTR_NAME: PlatformField(TEXT_SELECTOR, False, str), - ATTR_SW_VERSION: PlatformField(TEXT_SELECTOR, False, str), - ATTR_HW_VERSION: PlatformField(TEXT_SELECTOR, False, str), - ATTR_MODEL: PlatformField(TEXT_SELECTOR, False, str), - ATTR_MODEL_ID: PlatformField(TEXT_SELECTOR, False, str), - ATTR_CONFIGURATION_URL: PlatformField(TEXT_SELECTOR, False, cv.url, "invalid_url"), + ATTR_NAME: PlatformField(selector=TEXT_SELECTOR, required=False, validator=str), + ATTR_SW_VERSION: PlatformField( + selector=TEXT_SELECTOR, required=False, validator=str + ), + ATTR_HW_VERSION: PlatformField( + selector=TEXT_SELECTOR, required=False, validator=str + ), + ATTR_MODEL: PlatformField(selector=TEXT_SELECTOR, required=False, validator=str), + ATTR_MODEL_ID: PlatformField(selector=TEXT_SELECTOR, required=False, validator=str), + ATTR_CONFIGURATION_URL: PlatformField( + selector=TEXT_SELECTOR, required=False, validator=cv.url, error="invalid_url" + ), CONF_QOS: PlatformField( - QOS_SELECTOR, False, int, default=DEFAULT_QOS, section="mqtt_settings" + selector=QOS_SELECTOR, + required=False, + validator=int, + default=DEFAULT_QOS, + section="mqtt_settings", ), } From 5546f1d73d691287e79e3b8308cc4551b2269485 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Thu, 27 Mar 2025 07:46:58 +0100 Subject: [PATCH 3128/3148] Support for upcoming pyLoad-ng release in pyLoad integration (#141297) Fix extra key `proxy` in pyLoad --- homeassistant/components/pyload/coordinator.py | 1 + tests/components/pyload/snapshots/test_diagnostics.ambr | 1 + 2 files changed, 2 insertions(+) diff --git a/homeassistant/components/pyload/coordinator.py b/homeassistant/components/pyload/coordinator.py index c57dfa7720d..7bb2b870520 100644 --- a/homeassistant/components/pyload/coordinator.py +++ b/homeassistant/components/pyload/coordinator.py @@ -31,6 +31,7 @@ class PyLoadData: download: bool reconnect: bool captcha: bool | None = None + proxy: bool | None = None free_space: int diff --git a/tests/components/pyload/snapshots/test_diagnostics.ambr b/tests/components/pyload/snapshots/test_diagnostics.ambr index 81a5d750bc0..d773804bf73 100644 --- a/tests/components/pyload/snapshots/test_diagnostics.ambr +++ b/tests/components/pyload/snapshots/test_diagnostics.ambr @@ -13,6 +13,7 @@ 'download': True, 'free_space': 99999999999, 'pause': False, + 'proxy': None, 'queue': 6, 'reconnect': False, 'speed': 5405963.0, From dfb088e5247ff47fedfc7eb3fbf94cae5452ea56 Mon Sep 17 00:00:00 2001 From: Simon Lamon <32477463+silamon@users.noreply.github.com> Date: Thu, 27 Mar 2025 08:51:12 +0100 Subject: [PATCH 3129/3148] Bump linkplay to v0.2.2 (#141542) Bump linkplay --- homeassistant/components/linkplay/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/linkplay/manifest.json b/homeassistant/components/linkplay/manifest.json index 0941f2fbe61..02acd0f04f4 100644 --- a/homeassistant/components/linkplay/manifest.json +++ b/homeassistant/components/linkplay/manifest.json @@ -7,6 +7,6 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["linkplay"], - "requirements": ["python-linkplay==0.2.1"], + "requirements": ["python-linkplay==0.2.2"], "zeroconf": ["_linkplay._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 98b2c54f702..2d175156f98 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2430,7 +2430,7 @@ python-juicenet==1.1.0 python-kasa[speedups]==0.10.2 # homeassistant.components.linkplay -python-linkplay==0.2.1 +python-linkplay==0.2.2 # homeassistant.components.lirc # python-lirc==1.2.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1c1f4bfdb4d..b65ffc3be10 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1967,7 +1967,7 @@ python-juicenet==1.1.0 python-kasa[speedups]==0.10.2 # homeassistant.components.linkplay -python-linkplay==0.2.1 +python-linkplay==0.2.2 # homeassistant.components.matter python-matter-server==7.0.0 From 284b3f444d7a660c984c6999415c4e0e9dec0a8d Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Thu, 27 Mar 2025 09:53:47 +0100 Subject: [PATCH 3130/3148] Remove leftover cloudflare persistent notification dismiss (#141548) --- homeassistant/components/cloudflare/config_flow.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/homeassistant/components/cloudflare/config_flow.py b/homeassistant/components/cloudflare/config_flow.py index c3845a447e4..1fad38c5afc 100644 --- a/homeassistant/components/cloudflare/config_flow.py +++ b/homeassistant/components/cloudflare/config_flow.py @@ -9,7 +9,6 @@ from typing import Any import pycfdns import voluptuous as vol -from homeassistant.components import persistent_notification from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_API_TOKEN, CONF_ZONE from homeassistant.core import HomeAssistant @@ -118,8 +117,6 @@ class CloudflareConfigFlow(ConfigFlow, domain=DOMAIN): self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle a flow initiated by the user.""" - persistent_notification.async_dismiss(self.hass, "cloudflare_setup") - errors: dict[str, str] = {} if user_input is not None: From 373cca98575de477bb07fe7a3ce17e96681bfabb Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 27 Mar 2025 10:03:07 +0100 Subject: [PATCH 3131/3148] Remove unused mypy ignore from google_generative_ai_conversation (#141549) --- .../components/google_generative_ai_conversation/config_flow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/google_generative_ai_conversation/config_flow.py b/homeassistant/components/google_generative_ai_conversation/config_flow.py index b413f9c9a62..b7753c21bf9 100644 --- a/homeassistant/components/google_generative_ai_conversation/config_flow.py +++ b/homeassistant/components/google_generative_ai_conversation/config_flow.py @@ -7,7 +7,7 @@ import logging from types import MappingProxyType from typing import Any -from google import genai # type: ignore[attr-defined] +from google import genai from google.genai.errors import APIError, ClientError from requests.exceptions import Timeout import voluptuous as vol From d9d74107febcbe910114e0923c177dc248bf1ba5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Thu, 27 Mar 2025 10:18:30 +0100 Subject: [PATCH 3132/3148] Improve some Home Connect deprecations (#141508) --- .../components/home_connect/binary_sensor.py | 4 +- .../components/home_connect/strings.json | 40 +++++- .../components/home_connect/switch.py | 35 ++++- .../home_connect/test_binary_sensor.py | 81 +++++++++++- tests/components/home_connect/test_switch.py | 124 +++++++++++++++++- tests/components/home_connect/test_time.py | 8 +- 6 files changed, 271 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/home_connect/binary_sensor.py b/homeassistant/components/home_connect/binary_sensor.py index b7b7e50047e..a28b4ff2b49 100644 --- a/homeassistant/components/home_connect/binary_sensor.py +++ b/homeassistant/components/home_connect/binary_sensor.py @@ -244,6 +244,7 @@ class HomeConnectDoorBinarySensor(HomeConnectBinarySensor): BSH_DOOR_STATE_LOCKED: False, BSH_DOOR_STATE_OPEN: True, }, + entity_registry_enabled_default=False, ), ) self._attr_unique_id = f"{appliance.info.ha_id}-Door" @@ -283,7 +284,8 @@ class HomeConnectDoorBinarySensor(HomeConnectBinarySensor): DOMAIN, f"deprecated_binary_common_door_sensor_{self.entity_id}", breaks_in_ha_version="2025.5.0", - is_fixable=False, + is_fixable=True, + is_persistent=True, severity=IssueSeverity.WARNING, translation_key="deprecated_binary_common_door_sensor", translation_placeholders={ diff --git a/homeassistant/components/home_connect/strings.json b/homeassistant/components/home_connect/strings.json index 44a6eb17cea..5072a4d49a7 100644 --- a/homeassistant/components/home_connect/strings.json +++ b/homeassistant/components/home_connect/strings.json @@ -134,15 +134,47 @@ }, "deprecated_binary_common_door_sensor": { "title": "Deprecated binary door sensor detected in some automations or scripts", - "description": "The binary door sensor `{entity}`, which is deprecated, is used in the following automations or scripts:\n{items}\n\nA sensor entity with additional possible states is available and should be used going forward; Please use it on the above automations or scripts to fix this issue." + "fix_flow": { + "step": { + "confirm": { + "title": "[%key:component::home_connect::issues::deprecated_binary_common_door_sensor::title%]", + "description": "The binary door sensor `{entity}`, which is deprecated, is used in the following automations or scripts:\n{items}\n\nA sensor entity with additional possible states is available and should be used going forward; Please use it on the above automations or scripts to fix this issue." + } + } + } }, "deprecated_command_actions": { "title": "The command related actions are deprecated in favor of the new buttons", - "description": "The `pause_program` and `resume_program` actions have been deprecated in favor of new button entities, if the command is available for your appliance. Please update your automations, scripts and panels that use this action to use the button entities instead, and press on submit to fix the issue." + "fix_flow": { + "step": { + "confirm": { + "title": "[%key:component::home_connect::issues::deprecated_command_actions::title%]", + "description": "The `pause_program` and `resume_program` actions have been deprecated in favor of new button entities, if the command is available for your appliance. Please update your automations, scripts and panels that use this action to use the button entities instead, and press on submit to fix the issue." + } + } + } + }, + "deprecated_program_switch_in_automations_scripts": { + "title": "Deprecated program switch detected in some automations or scripts", + "fix_flow": { + "step": { + "confirm": { + "title": "[%key:component::home_connect::issues::deprecated_program_switch_in_automations_scripts::title%]", + "description": "Program switches are deprecated and {entity_id} is used in the following automations or scripts:\n{items}\n\nYou can use the active program select entity to run the program without any additional options and get the current running program on the above automations or scripts to fix this issue." + } + } + } }, "deprecated_program_switch": { - "title": "Deprecated program switch detected in some automations or scripts", - "description": "Program switches are deprecated and {entity_id} is used in the following automations or scripts:\n{items}\n\nYou can use the active program select entity to run the program without any additional options and get the current running program on the above automations or scripts to fix this issue." + "title": "Deprecated program switch entities", + "fix_flow": { + "step": { + "confirm": { + "title": "[%key:component::home_connect::issues::deprecated_program_switch::title%]", + "description": "The switch entity `{entity_id}` and all the other program switches are deprecated.\n\nPlease use the active program select entity instead." + } + } + } }, "deprecated_set_program_and_option_actions": { "title": "The executed action is deprecated", diff --git a/homeassistant/components/home_connect/switch.py b/homeassistant/components/home_connect/switch.py index 33e30f184b7..05f0ed2ddc3 100644 --- a/homeassistant/components/home_connect/switch.py +++ b/homeassistant/components/home_connect/switch.py @@ -266,7 +266,10 @@ class HomeConnectProgramSwitch(HomeConnectEntity, SwitchEntity): super().__init__( coordinator, appliance, - SwitchEntityDescription(key=EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM), + SwitchEntityDescription( + key=EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM, + entity_registry_enabled_default=False, + ), ) self._attr_name = f"{appliance.info.name} {desc}" self._attr_unique_id = f"{appliance.info.ha_id}-{desc}" @@ -304,11 +307,12 @@ class HomeConnectProgramSwitch(HomeConnectEntity, SwitchEntity): async_create_issue( self.hass, DOMAIN, - f"deprecated_program_switch_{self.entity_id}", + f"deprecated_program_switch_in_automations_scripts_{self.entity_id}", breaks_in_ha_version="2025.6.0", - is_fixable=False, + is_fixable=True, + is_persistent=True, severity=IssueSeverity.WARNING, - translation_key="deprecated_program_switch", + translation_key="deprecated_program_switch_in_automations_scripts", translation_placeholders={ "entity_id": self.entity_id, "items": "\n".join(items_list), @@ -317,12 +321,34 @@ class HomeConnectProgramSwitch(HomeConnectEntity, SwitchEntity): async def async_will_remove_from_hass(self) -> None: """Call when entity will be removed from hass.""" + async_delete_issue( + self.hass, + DOMAIN, + f"deprecated_program_switch_in_automations_scripts_{self.entity_id}", + ) async_delete_issue( self.hass, DOMAIN, f"deprecated_program_switch_{self.entity_id}" ) + def create_action_handler_issue(self) -> None: + """Create deprecation issue.""" + async_create_issue( + self.hass, + DOMAIN, + f"deprecated_program_switch_{self.entity_id}", + breaks_in_ha_version="2025.6.0", + is_fixable=True, + is_persistent=True, + severity=IssueSeverity.WARNING, + translation_key="deprecated_program_switch", + translation_placeholders={ + "entity_id": self.entity_id, + }, + ) + async def async_turn_on(self, **kwargs: Any) -> None: """Start the program.""" + self.create_action_handler_issue() try: await self.coordinator.client.start_program( self.appliance.info.ha_id, program_key=self.program.key @@ -339,6 +365,7 @@ class HomeConnectProgramSwitch(HomeConnectEntity, SwitchEntity): async def async_turn_off(self, **kwargs: Any) -> None: """Stop the program.""" + self.create_action_handler_issue() try: await self.coordinator.client.stop_program(self.appliance.info.ha_id) except HomeConnectError as err: diff --git a/tests/components/home_connect/test_binary_sensor.py b/tests/components/home_connect/test_binary_sensor.py index 31c15ec00cf..ce879a38de5 100644 --- a/tests/components/home_connect/test_binary_sensor.py +++ b/tests/components/home_connect/test_binary_sensor.py @@ -1,6 +1,7 @@ """Tests for home_connect binary_sensor entities.""" from collections.abc import Awaitable, Callable +from http import HTTPStatus from unittest.mock import AsyncMock, MagicMock from aiohomeconnect.model import ( @@ -39,6 +40,7 @@ import homeassistant.helpers.issue_registry as ir from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry +from tests.typing import ClientSessionGenerator @pytest.fixture @@ -165,6 +167,7 @@ async def test_connected_devices( assert len(new_entity_entries) > len(entity_entries) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") @pytest.mark.parametrize("appliance", ["Washer"], indirect=True) async def test_binary_sensors_entity_availability( hass: HomeAssistant, @@ -219,6 +222,7 @@ async def test_binary_sensors_entity_availability( assert state.state != STATE_UNAVAILABLE +@pytest.mark.usefixtures("entity_registry_enabled_by_default") @pytest.mark.parametrize("appliance", ["Washer"], indirect=True) @pytest.mark.parametrize( ("value", "expected"), @@ -402,7 +406,7 @@ async def test_connected_sensor_functionality( @pytest.mark.usefixtures("entity_registry_enabled_by_default") -async def test_create_issue( +async def test_create_door_binary_sensor_deprecation_issue( hass: HomeAssistant, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], @@ -410,7 +414,7 @@ async def test_create_issue( client: MagicMock, issue_registry: ir.IssueRegistry, ) -> None: - """Test we create an issue when an automation or script is using a deprecated entity.""" + """Test that we create an issue when an automation or script is using a door binary sensor entity.""" entity_id = "binary_sensor.washer_door" issue_id = f"deprecated_binary_common_door_sensor_{entity_id}" @@ -464,3 +468,76 @@ async def test_create_issue( # Assert the issue is no longer present assert not issue_registry.async_get_issue(DOMAIN, issue_id) assert len(issue_registry.issues) == 0 + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_door_binary_sensor_deprecation_issue_fix( + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + issue_registry: ir.IssueRegistry, + hass_client: ClientSessionGenerator, +) -> None: + """Test that we create an issue when an automation or script is using a door binary sensor entity.""" + entity_id = "binary_sensor.washer_door" + issue_id = f"deprecated_binary_common_door_sensor_{entity_id}" + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "alias": "test", + "trigger": {"platform": "state", "entity_id": entity_id}, + "action": { + "action": "automation.turn_on", + "target": { + "entity_id": "automation.test", + }, + }, + } + }, + ) + assert await async_setup_component( + hass, + script.DOMAIN, + { + script.DOMAIN: { + "test": { + "sequence": [ + { + "condition": "state", + "entity_id": entity_id, + "state": "on", + }, + ], + } + } + }, + ) + + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + assert automations_with_entity(hass, entity_id)[0] == "automation.test" + assert scripts_with_entity(hass, entity_id)[0] == "script.test" + + assert len(issue_registry.issues) == 1 + issue = issue_registry.async_get_issue(DOMAIN, issue_id) + assert issue + + _client = await hass_client() + resp = await _client.post( + "/api/repairs/issues/fix", + json={"handler": DOMAIN, "issue_id": issue.issue_id}, + ) + assert resp.status == HTTPStatus.OK + flow_id = (await resp.json())["flow_id"] + resp = await _client.post(f"/api/repairs/issues/fix/{flow_id}") + + # Assert the issue is no longer present + assert not issue_registry.async_get_issue(DOMAIN, issue_id) + assert len(issue_registry.issues) == 0 diff --git a/tests/components/home_connect/test_switch.py b/tests/components/home_connect/test_switch.py index 2903c8ac718..01f9cad5d2e 100644 --- a/tests/components/home_connect/test_switch.py +++ b/tests/components/home_connect/test_switch.py @@ -1,6 +1,7 @@ """Tests for home_connect sensor entities.""" from collections.abc import Awaitable, Callable +from http import HTTPStatus from typing import Any from unittest.mock import AsyncMock, MagicMock @@ -59,6 +60,7 @@ from homeassistant.helpers import ( from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry +from tests.typing import ClientSessionGenerator @pytest.fixture @@ -209,6 +211,7 @@ async def test_connected_devices( assert len(new_entity_entries) > len(entity_entries) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") @pytest.mark.parametrize("appliance", ["Dishwasher"], indirect=True) async def test_switch_entity_availability( hass: HomeAssistant, @@ -320,6 +323,7 @@ async def test_switch_functionality( assert hass.states.is_state(entity_id, state) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") @pytest.mark.parametrize( ("entity_id", "program_key", "initial_state", "appliance"), [ @@ -397,6 +401,7 @@ async def test_program_switch_functionality( client.stop_program.assert_awaited_once_with(appliance.ha_id) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") @pytest.mark.parametrize( ( "entity_id", @@ -801,18 +806,24 @@ async def test_power_switch_service_validation_errors( @pytest.mark.usefixtures("entity_registry_enabled_by_default") -async def test_create_issue( +@pytest.mark.parametrize( + "service", + [SERVICE_TURN_ON, SERVICE_TURN_OFF], +) +async def test_create_program_switch_deprecation_issue( hass: HomeAssistant, appliance: HomeAppliance, + service: str, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, client: MagicMock, issue_registry: ir.IssueRegistry, ) -> None: - """Test we create an issue when an automation or script is using a deprecated entity.""" + """Test that we create an issue when an automation or script is using a program switch entity or the entity is used by the user.""" entity_id = "switch.washer_program_mix" - issue_id = f"deprecated_program_switch_{entity_id}" + automation_script_issue_id = f"deprecated_program_switch_{entity_id}" + action_handler_issue_id = f"deprecated_program_switch_{entity_id}" assert await async_setup_component( hass, @@ -851,17 +862,118 @@ async def test_create_issue( assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED + await hass.services.async_call( + SWITCH_DOMAIN, + service, + { + ATTR_ENTITY_ID: entity_id, + }, + blocking=True, + ) + assert automations_with_entity(hass, entity_id)[0] == "automation.test" assert scripts_with_entity(hass, entity_id)[0] == "script.test" - assert len(issue_registry.issues) == 1 - assert issue_registry.async_get_issue(DOMAIN, issue_id) + assert len(issue_registry.issues) == 2 + assert issue_registry.async_get_issue(DOMAIN, automation_script_issue_id) + assert issue_registry.async_get_issue(DOMAIN, action_handler_issue_id) await hass.config_entries.async_unload(config_entry.entry_id) await hass.async_block_till_done() # Assert the issue is no longer present - assert not issue_registry.async_get_issue(DOMAIN, issue_id) + assert not issue_registry.async_get_issue(DOMAIN, automation_script_issue_id) + assert not issue_registry.async_get_issue(DOMAIN, action_handler_issue_id) + assert len(issue_registry.issues) == 0 + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.parametrize( + "service", + [SERVICE_TURN_ON, SERVICE_TURN_OFF], +) +async def test_program_switch_deprecation_issue_fix( + hass: HomeAssistant, + appliance: HomeAppliance, + service: str, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + issue_registry: ir.IssueRegistry, + hass_client: ClientSessionGenerator, +) -> None: + """Test we can fix the issues created when a program switch entity is in an automation or in a script or when is used.""" + entity_id = "switch.washer_program_mix" + automation_script_issue_id = f"deprecated_program_switch_{entity_id}" + action_handler_issue_id = f"deprecated_program_switch_{entity_id}" + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "alias": "test", + "trigger": {"platform": "state", "entity_id": entity_id}, + "action": { + "action": "automation.turn_on", + "target": { + "entity_id": "automation.test", + }, + }, + } + }, + ) + assert await async_setup_component( + hass, + script.DOMAIN, + { + script.DOMAIN: { + "test": { + "sequence": [ + { + "action": "switch.turn_on", + "entity_id": entity_id, + }, + ], + } + } + }, + ) + + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + await hass.services.async_call( + SWITCH_DOMAIN, + service, + { + ATTR_ENTITY_ID: entity_id, + }, + blocking=True, + ) + + assert automations_with_entity(hass, entity_id)[0] == "automation.test" + assert scripts_with_entity(hass, entity_id)[0] == "script.test" + + assert len(issue_registry.issues) == 2 + assert issue_registry.async_get_issue(DOMAIN, automation_script_issue_id) + assert issue_registry.async_get_issue(DOMAIN, action_handler_issue_id) + + for issue in issue_registry.issues.copy().values(): + _client = await hass_client() + resp = await _client.post( + "/api/repairs/issues/fix", + json={"handler": DOMAIN, "issue_id": issue.issue_id}, + ) + assert resp.status == HTTPStatus.OK + flow_id = (await resp.json())["flow_id"] + resp = await _client.post(f"/api/repairs/issues/fix/{flow_id}") + + # Assert the issue is no longer present + assert not issue_registry.async_get_issue(DOMAIN, automation_script_issue_id) + assert not issue_registry.async_get_issue(DOMAIN, action_handler_issue_id) assert len(issue_registry.issues) == 0 diff --git a/tests/components/home_connect/test_time.py b/tests/components/home_connect/test_time.py index e52e62a8927..8c23a09053a 100644 --- a/tests/components/home_connect/test_time.py +++ b/tests/components/home_connect/test_time.py @@ -320,7 +320,7 @@ async def test_time_entity_error( @pytest.mark.usefixtures("entity_registry_enabled_by_default") @pytest.mark.parametrize("appliance", ["Oven"], indirect=True) -async def test_create_issue( +async def test_create_alarm_clock_deprecation_issue( hass: HomeAssistant, appliance: HomeAppliance, config_entry: MockConfigEntry, @@ -329,7 +329,7 @@ async def test_create_issue( client: MagicMock, issue_registry: ir.IssueRegistry, ) -> None: - """Test we create an issue when an automation or script is using a deprecated entity.""" + """Test that we create an issue when an automation or script is using a alarm clock time entity or the entity is used by the user.""" entity_id = f"{TIME_DOMAIN}.oven_alarm_clock" automation_script_issue_id = ( f"deprecated_time_alarm_clock_in_automations_scripts_{entity_id}" @@ -401,7 +401,7 @@ async def test_create_issue( @pytest.mark.usefixtures("entity_registry_enabled_by_default") @pytest.mark.parametrize("appliance", ["Oven"], indirect=True) -async def test_issue_fix( +async def test_alarm_clock_deprecation_issue_fix( hass: HomeAssistant, appliance: HomeAppliance, config_entry: MockConfigEntry, @@ -411,7 +411,7 @@ async def test_issue_fix( issue_registry: ir.IssueRegistry, hass_client: ClientSessionGenerator, ) -> None: - """Test we create an issue when an automation or script is using a deprecated entity.""" + """Test we can fix the issues created when a alarm clock time entity is in an automation or in a script or when is used.""" entity_id = f"{TIME_DOMAIN}.oven_alarm_clock" automation_script_issue_id = ( f"deprecated_time_alarm_clock_in_automations_scripts_{entity_id}" From 43a5c7ddc85b6f3f15d50d7f0ebc343f60704e9d Mon Sep 17 00:00:00 2001 From: Thomas55555 <59625598+Thomas55555@users.noreply.github.com> Date: Thu, 27 Mar 2025 10:22:25 +0100 Subject: [PATCH 3133/3148] Handle webcal prefix in remote calendar (#141541) Handel webcal prefix in remote calendar --- .../components/remote_calendar/config_flow.py | 4 +++ .../remote_calendar/test_config_flow.py | 29 +++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/homeassistant/components/remote_calendar/config_flow.py b/homeassistant/components/remote_calendar/config_flow.py index 03d0e7ea96a..1ceeb7a3937 100644 --- a/homeassistant/components/remote_calendar/config_flow.py +++ b/homeassistant/components/remote_calendar/config_flow.py @@ -42,6 +42,10 @@ class RemoteCalendarConfigFlow(ConfigFlow, domain=DOMAIN): self._async_abort_entries_match( {CONF_CALENDAR_NAME: user_input[CONF_CALENDAR_NAME]} ) + if user_input[CONF_URL].startswith("webcal://"): + user_input[CONF_URL] = user_input[CONF_URL].replace( + "webcal://", "https://", 1 + ) self._async_abort_entries_match({CONF_URL: user_input[CONF_URL]}) client = get_async_client(self.hass) try: diff --git a/tests/components/remote_calendar/test_config_flow.py b/tests/components/remote_calendar/test_config_flow.py index 626bc2c6e03..9eb9cb40134 100644 --- a/tests/components/remote_calendar/test_config_flow.py +++ b/tests/components/remote_calendar/test_config_flow.py @@ -45,6 +45,35 @@ async def test_form_import_ics(hass: HomeAssistant, ics_content: str) -> None: } +@respx.mock +async def test_form_import_webcal(hass: HomeAssistant, ics_content: str) -> None: + """Test we get the import form.""" + respx.get(CALENDER_URL).mock( + return_value=Response( + status_code=200, + text=ics_content, + ) + ) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_CALENDAR_NAME: CALENDAR_NAME, + CONF_URL: "webcal://some.calendar.com/calendar.ics", + }, + ) + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["title"] == CALENDAR_NAME + assert result2["data"] == { + CONF_CALENDAR_NAME: CALENDAR_NAME, + CONF_URL: CALENDER_URL, + } + + @pytest.mark.parametrize( ("side_effect"), [ From 5747c6b1a87c8a7b4ed5c49bbd3b97aa5a7ea864 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 27 Mar 2025 10:59:19 +0100 Subject: [PATCH 3134/3148] Fix sentence-casing in `konnected` strings, replace "override" with "custom" (#141553) Fix sentence-casing in `konnected`strings, replace "Override" with "Custom" Make string consistent with HA standards. As "Override" can be misunderstood as the verb, replace it with "Custom". --- .../components/konnected/strings.json | 34 +++++++++---------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/konnected/strings.json b/homeassistant/components/konnected/strings.json index e1a6863a199..df92e014f12 100644 --- a/homeassistant/components/konnected/strings.json +++ b/homeassistant/components/konnected/strings.json @@ -2,19 +2,19 @@ "config": { "step": { "import_confirm": { - "title": "Import Konnected Device", - "description": "A Konnected Alarm Panel with ID {id} has been discovered in configuration.yaml. This flow will allow you to import it into a config entry." + "title": "Import Konnected device", + "description": "A Konnected alarm panel with ID {id} has been discovered in configuration.yaml. This flow will allow you to import it into a config entry." }, "user": { - "description": "Please enter the host information for your Konnected Panel.", + "description": "Please enter the host information for your Konnected panel.", "data": { "host": "[%key:common::config_flow::data::ip%]", "port": "[%key:common::config_flow::data::port%]" } }, "confirm": { - "title": "Konnected Device Ready", - "description": "Model: {model}\nID: {id}\nHost: {host}\nPort: {port}\n\nYou can configure the IO and panel behavior in the Konnected Alarm Panel settings." + "title": "Konnected device ready", + "description": "Model: {model}\nID: {id}\nHost: {host}\nPort: {port}\n\nYou can configure the IO and panel behavior in the Konnected alarm panel settings." } }, "error": { @@ -45,8 +45,8 @@ } }, "options_io_ext": { - "title": "Configure Extended I/O", - "description": "Select the configuration of the remaining I/O below. You'll be able to configure detailed options in the next steps.", + "title": "Configure extended I/O", + "description": "Select the configuration of the remaining I/O below. You'll be able to configure detailed options in the next steps.", "data": { "8": "Zone 8", "9": "Zone 9", @@ -59,25 +59,25 @@ } }, "options_binary": { - "title": "Configure Binary Sensor", + "title": "Configure binary sensor", "description": "{zone} options", "data": { - "type": "Binary Sensor Type", + "type": "Binary sensor type", "name": "[%key:common::config_flow::data::name%]", "inverse": "Invert the open/close state" } }, "options_digital": { - "title": "Configure Digital Sensor", + "title": "Configure digital sensor", "description": "[%key:component::konnected::options::step::options_binary::description%]", "data": { - "type": "Sensor Type", + "type": "Sensor type", "name": "[%key:common::config_flow::data::name%]", - "poll_interval": "Poll Interval (minutes)" + "poll_interval": "Poll interval (minutes)" } }, "options_switch": { - "title": "Configure Switchable Output", + "title": "Configure switchable output", "description": "{zone} options: state {state}", "data": { "name": "[%key:common::config_flow::data::name%]", @@ -89,18 +89,18 @@ } }, "options_misc": { - "title": "Configure Misc", + "title": "Configure misc", "description": "Please select the desired behavior for your panel", "data": { "discovery": "Respond to discovery requests on your network", "blink": "Blink panel LED on when sending state change", - "override_api_host": "Override default Home Assistant API host panel URL", - "api_host": "Override API host URL" + "override_api_host": "Override default Home Assistant API host URL", + "api_host": "Custom API host URL" } } }, "error": { - "bad_host": "Invalid Override API host URL" + "bad_host": "Invalid custom API host URL" }, "abort": { "not_konn_panel": "[%key:component::konnected::config::abort::not_konn_panel%]" From 3646884d791af729dcf9002e075ba3957bd60c84 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 27 Mar 2025 11:29:53 +0100 Subject: [PATCH 3135/3148] Replace "controller_id" with friendly name in `homeworks` error message (#141550) --- homeassistant/components/homeworks/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/homeworks/strings.json b/homeassistant/components/homeworks/strings.json index 1a144615e89..3ec4945957b 100644 --- a/homeassistant/components/homeworks/strings.json +++ b/homeassistant/components/homeworks/strings.json @@ -57,7 +57,7 @@ }, "exceptions": { "invalid_controller_id": { - "message": "Invalid controller_id \"{controller_id}\", expected one of \"{controller_ids}\"" + "message": "Invalid controller ID \"{controller_id}\", expected one of \"{controller_ids}\"" } }, "options": { From e8aa3e6d34572852658900eccac4b33975b89f8b Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 27 Mar 2025 12:05:45 +0100 Subject: [PATCH 3136/3148] Add icons to hue effects (#141559) --- homeassistant/components/hue/icons.json | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/homeassistant/components/hue/icons.json b/homeassistant/components/hue/icons.json index 31464308b0a..646c420f1fe 100644 --- a/homeassistant/components/hue/icons.json +++ b/homeassistant/components/hue/icons.json @@ -1,4 +1,28 @@ { + "entity": { + "light": { + "hue_light": { + "state_attributes": { + "effect": { + "state": { + "candle": "mdi:candle", + "sparkle": "mdi:shimmer", + "glisten": "mdi:creation", + "sunrise": "mdi:weather-sunset-up", + "sunset": "mdi:weather-sunset", + "fire": "mdi:fire", + "prism": "mdi:triangle-outline", + "opal": "mdi:diamond-stone", + "underwater": "mdi:waves", + "cosmos": "mdi:star-shooting", + "sunbeam": "mdi:spotlight-beam", + "enchant": "mdi:magic-staff" + } + } + } + } + } + }, "services": { "hue_activate_scene": { "service": "mdi:palette" From e9e95f45d8eee8725867bd950199e1eaad9fac4a Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Thu, 27 Mar 2025 15:29:11 +0100 Subject: [PATCH 3137/3148] Handle cloud subscription expired for backup upload (#141564) Handle cloud backup subscription expired for upload --- homeassistant/components/cloud/backup.py | 14 ++- tests/components/cloud/test_backup.py | 118 ++++++++++++++++++++++- 2 files changed, 128 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/cloud/backup.py b/homeassistant/components/cloud/backup.py index b83c4725663..f4426eabeed 100644 --- a/homeassistant/components/cloud/backup.py +++ b/homeassistant/components/cloud/backup.py @@ -4,13 +4,14 @@ from __future__ import annotations import asyncio from collections.abc import AsyncIterator, Callable, Coroutine, Mapping +from http import HTTPStatus import logging import random from typing import Any -from aiohttp import ClientError +from aiohttp import ClientError, ClientResponseError from hass_nabucasa import Cloud, CloudError -from hass_nabucasa.api import CloudApiNonRetryableError +from hass_nabucasa.api import CloudApiError, CloudApiNonRetryableError from hass_nabucasa.cloud_api import ( FilesHandlerListEntry, async_files_delete_file, @@ -120,6 +121,8 @@ class CloudBackupAgent(BackupAgent): """ if not backup.protected: raise BackupAgentError("Cloud backups must be protected") + if self._cloud.subscription_expired: + raise BackupAgentError("Cloud subscription has expired") size = backup.size try: @@ -152,6 +155,13 @@ class CloudBackupAgent(BackupAgent): ) from err raise BackupAgentError(f"Failed to upload backup {err}") from err except CloudError as err: + if ( + isinstance(err, CloudApiError) + and isinstance(err.orig_exc, ClientResponseError) + and err.orig_exc.status == HTTPStatus.FORBIDDEN + and self._cloud.subscription_expired + ): + raise BackupAgentError("Cloud subscription has expired") from err if tries == _RETRY_LIMIT: raise BackupAgentError(f"Failed to upload backup {err}") from err tries += 1 diff --git a/tests/components/cloud/test_backup.py b/tests/components/cloud/test_backup.py index dd6252c4d62..8399e69ab09 100644 --- a/tests/components/cloud/test_backup.py +++ b/tests/components/cloud/test_backup.py @@ -5,9 +5,9 @@ from io import StringIO from typing import Any from unittest.mock import ANY, Mock, PropertyMock, patch -from aiohttp import ClientError +from aiohttp import ClientError, ClientResponseError from hass_nabucasa import CloudError -from hass_nabucasa.api import CloudApiNonRetryableError +from hass_nabucasa.api import CloudApiError, CloudApiNonRetryableError from hass_nabucasa.files import FilesError, StorageType import pytest @@ -547,6 +547,120 @@ async def test_agents_upload_not_protected( assert stored_backup["failed_agent_ids"] == ["cloud.cloud"] +@pytest.mark.usefixtures("cloud_logged_in", "mock_list_files") +async def test_agents_upload_not_subscribed( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + hass_storage: dict[str, Any], + cloud: Mock, +) -> None: + """Test upload backup when cloud user is not subscribed.""" + cloud.subscription_expired = True + client = await hass_client() + backup_data = "test" + backup_id = "test-backup" + test_backup = AgentBackup( + addons=[AddonInfo(name="Test", slug="test", version="1.0.0")], + backup_id=backup_id, + database_included=True, + date="1970-01-01T00:00:00.000Z", + extra_metadata={}, + folders=[Folder.MEDIA, Folder.SHARE], + homeassistant_included=True, + homeassistant_version="2024.12.0", + name="Test", + protected=True, + size=len(backup_data), + ) + + with ( + patch( + "homeassistant.components.backup.manager.BackupManager.async_get_backup", + ) as fetch_backup, + patch( + "homeassistant.components.backup.manager.read_backup", + return_value=test_backup, + ), + patch("pathlib.Path.open") as mocked_open, + ): + mocked_open.return_value.read = Mock(side_effect=[backup_data.encode(), b""]) + fetch_backup.return_value = test_backup + resp = await client.post( + "/api/backup/upload?agent_id=cloud.cloud", + data={"file": StringIO(backup_data)}, + ) + await hass.async_block_till_done() + + assert resp.status == 201 + assert cloud.files.upload.call_count == 0 + store_backups = hass_storage[BACKUP_DOMAIN]["data"]["backups"] + assert len(store_backups) == 1 + stored_backup = store_backups[0] + assert stored_backup["backup_id"] == backup_id + assert stored_backup["failed_agent_ids"] == ["cloud.cloud"] + + +@pytest.mark.usefixtures("cloud_logged_in", "mock_list_files") +async def test_agents_upload_not_subscribed_midway( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + hass_storage: dict[str, Any], + cloud: Mock, +) -> None: + """Test upload backup when cloud subscription expires during the call.""" + client = await hass_client() + backup_data = "test" + backup_id = "test-backup" + test_backup = AgentBackup( + addons=[AddonInfo(name="Test", slug="test", version="1.0.0")], + backup_id=backup_id, + database_included=True, + date="1970-01-01T00:00:00.000Z", + extra_metadata={}, + folders=[Folder.MEDIA, Folder.SHARE], + homeassistant_included=True, + homeassistant_version="2024.12.0", + name="Test", + protected=True, + size=len(backup_data), + ) + + async def mock_upload(*args: Any, **kwargs: Any) -> None: + """Mock file upload.""" + cloud.subscription_expired = True + raise CloudApiError( + "Boom!", orig_exc=ClientResponseError(Mock(), Mock(), status=403) + ) + + cloud.files.upload.side_effect = mock_upload + + with ( + patch( + "homeassistant.components.backup.manager.BackupManager.async_get_backup", + ) as fetch_backup, + patch( + "homeassistant.components.backup.manager.read_backup", + return_value=test_backup, + ), + patch("pathlib.Path.open") as mocked_open, + ): + mocked_open.return_value.read = Mock(side_effect=[backup_data.encode(), b""]) + fetch_backup.return_value = test_backup + resp = await client.post( + "/api/backup/upload?agent_id=cloud.cloud", + data={"file": StringIO(backup_data)}, + ) + await hass.async_block_till_done() + + assert resp.status == 201 + assert cloud.files.upload.call_count == 1 + store_backups = hass_storage[BACKUP_DOMAIN]["data"]["backups"] + assert len(store_backups) == 1 + stored_backup = store_backups[0] + assert stored_backup["backup_id"] == backup_id + assert stored_backup["failed_agent_ids"] == ["cloud.cloud"] + + @pytest.mark.usefixtures("cloud_logged_in", "mock_list_files") async def test_agents_upload_wrong_size( hass: HomeAssistant, From c30f17f592a2cdeb0438927a6f8a294c4982355c Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Thu, 27 Mar 2025 16:01:54 +0100 Subject: [PATCH 3138/3148] Tado fix HomeKit flow (#141525) * Initial commit * Fix * Fix --------- Co-authored-by: Joostlek --- homeassistant/components/tado/config_flow.py | 21 ++++++++++---------- homeassistant/components/tado/strings.json | 4 ++++ tests/components/tado/test_config_flow.py | 21 +++++++++++++------- 3 files changed, 29 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/tado/config_flow.py b/homeassistant/components/tado/config_flow.py index 64763469885..48c3d30cb2b 100644 --- a/homeassistant/components/tado/config_flow.py +++ b/homeassistant/components/tado/config_flow.py @@ -22,10 +22,7 @@ from homeassistant.config_entries import ( ) from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.service_info.zeroconf import ( - ATTR_PROPERTIES_ID, - ZeroconfServiceInfo, -) +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import ( CONF_FALLBACK, @@ -164,12 +161,16 @@ class TadoConfigFlow(ConfigFlow, domain=DOMAIN): self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle HomeKit discovery.""" - self._async_abort_entries_match() - properties = { - key.lower(): value for key, value in discovery_info.properties.items() - } - await self.async_set_unique_id(properties[ATTR_PROPERTIES_ID]) - self._abort_if_unique_id_configured() + await self._async_handle_discovery_without_unique_id() + return await self.async_step_homekit_confirm() + + async def async_step_homekit_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Prepare for Homekit.""" + if user_input is None: + return self.async_show_form(step_id="homekit_confirm") + return await self.async_step_user() @staticmethod diff --git a/homeassistant/components/tado/strings.json b/homeassistant/components/tado/strings.json index c7aef7eb51c..53de3969998 100644 --- a/homeassistant/components/tado/strings.json +++ b/homeassistant/components/tado/strings.json @@ -16,6 +16,10 @@ "title": "Authenticate with Tado", "description": "You need to reauthenticate with Tado. Press `Submit` to start the authentication process." }, + "homekit": { + "title": "Authenticate with Tado", + "description": "Your device has been discovered and needs to authenticate with Tado. Press `Submit` to start the authentication process." + }, "timeout": { "description": "The authentication process timed out. Please try again." } diff --git a/tests/components/tado/test_config_flow.py b/tests/components/tado/test_config_flow.py index f7418309d46..2fd8e6a0468 100644 --- a/tests/components/tado/test_config_flow.py +++ b/tests/components/tado/test_config_flow.py @@ -234,13 +234,19 @@ async def test_homekit(hass: HomeAssistant, mock_tado_api: MagicMock) -> None: type="mock_type", ), ) - assert result["type"] is FlowResultType.SHOW_PROGRESS_DONE - flow = next( - flow - for flow in hass.config_entries.flow.async_progress() - if flow["flow_id"] == result["flow_id"] - ) - assert flow["context"]["unique_id"] == "AA:BB:CC:DD:EE:FF" + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "homekit_confirm" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["result"].unique_id == "1" + + +async def test_homekit_already_setup( + hass: HomeAssistant, mock_tado_api: MagicMock +) -> None: + """Test that we abort from homekit if tado is already setup.""" entry = MockConfigEntry( domain=DOMAIN, data={CONF_USERNAME: "mock", CONF_PASSWORD: "mock"} @@ -261,3 +267,4 @@ async def test_homekit(hass: HomeAssistant, mock_tado_api: MagicMock) -> None: ), ) assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" From dea00fac3f5b0b17bfeac325c4abd62f1cab119b Mon Sep 17 00:00:00 2001 From: Andrii Mitnovych <10116550+formatBCE@users.noreply.github.com> Date: Thu, 27 Mar 2025 08:02:47 -0700 Subject: [PATCH 3139/3148] Get area and floor by alias (#126150) * Add possibility to get area by alias * Add ability to get floor by alias * Moved alias lookup to separate function, adjusted templates. * Changed registry to return all areas/floors with given alias * Use normalize_name from normalized_name_base_registry --- homeassistant/helpers/area_registry.py | 20 +++++++++++ homeassistant/helpers/floor_registry.py | 46 +++++++++++++++++++++++-- homeassistant/helpers/template.py | 16 ++++++--- tests/helpers/test_area_registry.py | 23 +++++++++++++ tests/helpers/test_floor_registry.py | 23 ++++++++++++- 5 files changed, 120 insertions(+), 8 deletions(-) diff --git a/homeassistant/helpers/area_registry.py b/homeassistant/helpers/area_registry.py index 5601ce4032d..ba02ed51f6b 100644 --- a/homeassistant/helpers/area_registry.py +++ b/homeassistant/helpers/area_registry.py @@ -20,6 +20,7 @@ from .json import json_bytes, json_fragment from .normalized_name_base_registry import ( NormalizedNameBaseRegistryEntry, NormalizedNameBaseRegistryItems, + normalize_name, ) from .registry import BaseRegistry, RegistryIndexType from .singleton import singleton @@ -169,6 +170,7 @@ class AreaRegistryItems(NormalizedNameBaseRegistryItems[AreaEntry]): super().__init__() self._labels_index: RegistryIndexType = defaultdict(dict) self._floors_index: RegistryIndexType = defaultdict(dict) + self._aliases_index: RegistryIndexType = defaultdict(dict) def _index_entry(self, key: str, entry: AreaEntry) -> None: """Index an entry.""" @@ -177,6 +179,9 @@ class AreaRegistryItems(NormalizedNameBaseRegistryItems[AreaEntry]): self._floors_index[entry.floor_id][key] = True for label in entry.labels: self._labels_index[label][key] = True + for alias in entry.aliases: + normalized_alias = normalize_name(alias) + self._aliases_index[normalized_alias][key] = True def _unindex_entry( self, key: str, replacement_entry: AreaEntry | None = None @@ -184,6 +189,10 @@ class AreaRegistryItems(NormalizedNameBaseRegistryItems[AreaEntry]): # always call base class before other indices super()._unindex_entry(key, replacement_entry) entry = self.data[key] + if aliases := entry.aliases: + for alias in aliases: + normalized_alias = normalize_name(alias) + self._unindex_entry_value(key, normalized_alias, self._aliases_index) if labels := entry.labels: for label in labels: self._unindex_entry_value(key, label, self._labels_index) @@ -200,6 +209,12 @@ class AreaRegistryItems(NormalizedNameBaseRegistryItems[AreaEntry]): data = self.data return [data[key] for key in self._floors_index.get(floor, ())] + def get_areas_for_alias(self, alias: str) -> list[AreaEntry]: + """Get areas for alias.""" + data = self.data + normalized_alias = normalize_name(alias) + return [data[key] for key in self._aliases_index.get(normalized_alias, ())] + class AreaRegistry(BaseRegistry[AreasRegistryStoreData]): """Class to hold a registry of areas.""" @@ -232,6 +247,11 @@ class AreaRegistry(BaseRegistry[AreasRegistryStoreData]): """Get area by name.""" return self.areas.get_by_name(name) + @callback + def async_get_areas_by_alias(self, alias: str) -> list[AreaEntry]: + """Get areas by alias.""" + return self.areas.get_areas_for_alias(alias) + @callback def async_list_areas(self) -> Iterable[AreaEntry]: """Get all areas.""" diff --git a/homeassistant/helpers/floor_registry.py b/homeassistant/helpers/floor_registry.py index fcfca8e3212..186ad2b31f7 100644 --- a/homeassistant/helpers/floor_registry.py +++ b/homeassistant/helpers/floor_registry.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections import defaultdict from collections.abc import Iterable import dataclasses from dataclasses import dataclass @@ -16,8 +17,9 @@ from homeassistant.util.hass_dict import HassKey from .normalized_name_base_registry import ( NormalizedNameBaseRegistryEntry, NormalizedNameBaseRegistryItems, + normalize_name, ) -from .registry import BaseRegistry +from .registry import BaseRegistry, RegistryIndexType from .singleton import singleton from .storage import Store from .typing import UNDEFINED, UndefinedType @@ -92,10 +94,43 @@ class FloorRegistryStore(Store[FloorRegistryStoreData]): return old_data # type: ignore[return-value] +class FloorRegistryItems(NormalizedNameBaseRegistryItems[FloorEntry]): + """Class to hold floor registry items.""" + + def __init__(self) -> None: + """Initialize the floor registry items.""" + super().__init__() + self._aliases_index: RegistryIndexType = defaultdict(dict) + + def _index_entry(self, key: str, entry: FloorEntry) -> None: + """Index an entry.""" + super()._index_entry(key, entry) + for alias in entry.aliases: + normalized_alias = normalize_name(alias) + self._aliases_index[normalized_alias][key] = True + + def _unindex_entry( + self, key: str, replacement_entry: FloorEntry | None = None + ) -> None: + # always call base class before other indices + super()._unindex_entry(key, replacement_entry) + entry = self.data[key] + if aliases := entry.aliases: + for alias in aliases: + normalized_alias = normalize_name(alias) + self._unindex_entry_value(key, normalized_alias, self._aliases_index) + + def get_floors_for_alias(self, alias: str) -> list[FloorEntry]: + """Get floors for alias.""" + data = self.data + normalized_alias = normalize_name(alias) + return [data[key] for key in self._aliases_index.get(normalized_alias, ())] + + class FloorRegistry(BaseRegistry[FloorRegistryStoreData]): """Class to hold a registry of floors.""" - floors: NormalizedNameBaseRegistryItems[FloorEntry] + floors: FloorRegistryItems _floor_data: dict[str, FloorEntry] def __init__(self, hass: HomeAssistant) -> None: @@ -123,6 +158,11 @@ class FloorRegistry(BaseRegistry[FloorRegistryStoreData]): """Get floor by name.""" return self.floors.get_by_name(name) + @callback + def async_get_floors_by_alias(self, alias: str) -> list[FloorEntry]: + """Get floors by alias.""" + return self.floors.get_floors_for_alias(alias) + @callback def async_list_floors(self) -> Iterable[FloorEntry]: """Get all floors.""" @@ -226,7 +266,7 @@ class FloorRegistry(BaseRegistry[FloorRegistryStoreData]): async def async_load(self) -> None: """Load the floor registry.""" data = await self._store.async_load() - floors = NormalizedNameBaseRegistryItems[FloorEntry]() + floors = FloorRegistryItems() if data is not None: for floor in data["floors"]: diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 70a94cfaaa9..9468eb6bf49 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -1478,10 +1478,14 @@ def floors(hass: HomeAssistant) -> Iterable[str | None]: def floor_id(hass: HomeAssistant, lookup_value: Any) -> str | None: - """Get the floor ID from a floor name.""" + """Get the floor ID from a floor or area name, alias, device id, or entity id.""" floor_registry = fr.async_get(hass) - if floor := floor_registry.async_get_floor_by_name(str(lookup_value)): + lookup_str = str(lookup_value) + if floor := floor_registry.async_get_floor_by_name(lookup_str): return floor.floor_id + floors_list = floor_registry.async_get_floors_by_alias(lookup_str) + if floors_list: + return floors_list[0].floor_id if aid := area_id(hass, lookup_value): area_reg = area_registry.async_get(hass) @@ -1541,10 +1545,14 @@ def areas(hass: HomeAssistant) -> Iterable[str | None]: def area_id(hass: HomeAssistant, lookup_value: str) -> str | None: - """Get the area ID from an area name, device id, or entity id.""" + """Get the area ID from an area name, alias, device id, or entity id.""" area_reg = area_registry.async_get(hass) - if area := area_reg.async_get_area_by_name(str(lookup_value)): + lookup_str = str(lookup_value) + if area := area_reg.async_get_area_by_name(lookup_str): return area.id + areas_list = area_reg.async_get_areas_by_alias(lookup_str) + if areas_list: + return areas_list[0].id ent_reg = entity_registry.async_get(hass) dev_reg = device_registry.async_get(hass) diff --git a/tests/helpers/test_area_registry.py b/tests/helpers/test_area_registry.py index c69f039027e..3496c41ecf4 100644 --- a/tests/helpers/test_area_registry.py +++ b/tests/helpers/test_area_registry.py @@ -494,6 +494,29 @@ async def test_async_get_area_by_name(area_registry: ar.AreaRegistry) -> None: assert area_registry.async_get_area_by_name("M o c k 1").normalized_name == "mock1" +async def test_async_get_areas_by_alias( + area_registry: ar.AreaRegistry, +) -> None: + """Make sure we can get the areas by alias.""" + area1 = area_registry.async_create("Mock1", aliases=("alias_1", "alias_2")) + area2 = area_registry.async_create("Mock2", aliases=("alias_1", "alias_3")) + + assert len(area_registry.areas) == 2 + + alias1_list = area_registry.async_get_areas_by_alias("A l i a s_1") + alias2_list = area_registry.async_get_areas_by_alias("A l i a s_2") + alias3_list = area_registry.async_get_areas_by_alias("A l i a s_3") + + assert len(alias1_list) == 2 + assert len(alias2_list) == 1 + assert len(alias3_list) == 1 + + assert area1 in alias1_list + assert area1 in alias2_list + assert area2 in alias1_list + assert area2 in alias3_list + + async def test_async_get_area_by_name_not_found(area_registry: ar.AreaRegistry) -> None: """Make sure we return None for non-existent areas.""" area_registry.async_create("Mock1") diff --git a/tests/helpers/test_floor_registry.py b/tests/helpers/test_floor_registry.py index 6a672399522..5ebd63ae302 100644 --- a/tests/helpers/test_floor_registry.py +++ b/tests/helpers/test_floor_registry.py @@ -327,7 +327,7 @@ async def test_loading_floors_from_storage( assert len(registry.floors) == 1 -async def test_getting_floor(floor_registry: fr.FloorRegistry) -> None: +async def test_getting_floor_by_name(floor_registry: fr.FloorRegistry) -> None: """Make sure we can get the floors by name.""" floor = floor_registry.async_create("First floor") floor2 = floor_registry.async_get_floor_by_name("first floor") @@ -341,6 +341,27 @@ async def test_getting_floor(floor_registry: fr.FloorRegistry) -> None: assert get_floor == floor +async def test_async_get_floors_by_alias( + floor_registry: fr.FloorRegistry, +) -> None: + """Make sure we can get the floors by alias.""" + floor1 = floor_registry.async_create("First floor", aliases=("alias_1", "alias_2")) + floor2 = floor_registry.async_create("Second floor", aliases=("alias_1", "alias_3")) + + alias1_list = floor_registry.async_get_floors_by_alias("A l i a s_1") + alias2_list = floor_registry.async_get_floors_by_alias("A l i a s_2") + alias3_list = floor_registry.async_get_floors_by_alias("A l i a s_3") + + assert len(alias1_list) == 2 + assert len(alias2_list) == 1 + assert len(alias3_list) == 1 + + assert floor1 in alias1_list + assert floor1 in alias2_list + assert floor2 in alias1_list + assert floor2 in alias3_list + + async def test_async_get_floor_by_name_not_found( floor_registry: fr.FloorRegistry, ) -> None: From f0fd5a639a69b83aa6b01a5238537248aedac5fa Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Thu, 27 Mar 2025 11:17:56 -0400 Subject: [PATCH 3140/3148] Better handle Roborock discovery (#141575) --- homeassistant/components/roborock/config_flow.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/roborock/config_flow.py b/homeassistant/components/roborock/config_flow.py index 1a359faca10..886bebea9b6 100644 --- a/homeassistant/components/roborock/config_flow.py +++ b/homeassistant/components/roborock/config_flow.py @@ -143,6 +143,7 @@ class RoborockFlowHandler(ConfigFlow, domain=DOMAIN): self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle a flow started by a dhcp discovery.""" + await self._async_handle_discovery_without_unique_id() device_registry = dr.async_get(self.hass) device = device_registry.async_get_device( connections={ From 62be82fd3cc853da9097668c44d3674288cc9555 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 27 Mar 2025 16:36:45 +0100 Subject: [PATCH 3141/3148] Also migrate completion time entities in SmartThings (#141572) --- .../components/smartthings/__init__.py | 5 +- tests/components/smartthings/test_init.py | 68 ++++++++++++++++++- 2 files changed, 70 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py index 20325e7d3e5..4f7b8c2ddb9 100644 --- a/homeassistant/components/smartthings/__init__.py +++ b/homeassistant/components/smartthings/__init__.py @@ -352,7 +352,10 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return { "new_unique_id": f"{device_id}_{MAIN}_{Capability.THREE_AXIS}_{Attribute.THREE_AXIS}_{new_attribute}", } - if attribute == Attribute.MACHINE_STATE: + if attribute in { + Attribute.MACHINE_STATE, + Attribute.COMPLETION_TIME, + }: capability = determine_machine_type( hass, entry.entry_id, device_id ) diff --git a/tests/components/smartthings/test_init.py b/tests/components/smartthings/test_init.py index 991f44e4377..1d4b124c60d 100644 --- a/tests/components/smartthings/test_init.py +++ b/tests/components/smartthings/test_init.py @@ -529,12 +529,28 @@ async def test_entity_unique_id_migration( "microwave_machine_state", "2bad3237-4886-e699-1b90-4a51a3d55c8a_main_ovenOperatingState_machineState_machineState", ), + ( + "da_ks_microwave_0101x", + SENSOR_DOMAIN, + "2bad3237-4886-e699-1b90-4a51a3d55c8a.ovenJobState", + "2bad3237-4886-e699-1b90-4a51a3d55c8a.completionTime", + "microwave_completion_time", + "2bad3237-4886-e699-1b90-4a51a3d55c8a_main_ovenOperatingState_completionTime_completionTime", + ), + ( + "da_ks_microwave_0101x", + SENSOR_DOMAIN, + "2bad3237-4886-e699-1b90-4a51a3d55c8a_main_ovenOperatingState_ovenJobState_ovenJobState", + "2bad3237-4886-e699-1b90-4a51a3d55c8a.completionTime", + "microwave_completion_time", + "2bad3237-4886-e699-1b90-4a51a3d55c8a_main_ovenOperatingState_completionTime_completionTime", + ), ( "da_wm_dw_000001", SENSOR_DOMAIN, "f36dc7ce-cac0-0667-dc14-a3704eb5e676.dishwasherJobState", "f36dc7ce-cac0-0667-dc14-a3704eb5e676.machineState", - "microwave_machine_state", + "dishwasher_machine_state", "f36dc7ce-cac0-0667-dc14-a3704eb5e676_main_dishwasherOperatingState_machineState_machineState", ), ( @@ -542,9 +558,25 @@ async def test_entity_unique_id_migration( SENSOR_DOMAIN, "f36dc7ce-cac0-0667-dc14-a3704eb5e676_main_dishwasherOperatingState_dishwasherJobState_dishwasherJobState", "f36dc7ce-cac0-0667-dc14-a3704eb5e676.machineState", - "microwave_machine_state", + "dishwasher_machine_state", "f36dc7ce-cac0-0667-dc14-a3704eb5e676_main_dishwasherOperatingState_machineState_machineState", ), + ( + "da_wm_dw_000001", + SENSOR_DOMAIN, + "f36dc7ce-cac0-0667-dc14-a3704eb5e676.dishwasherJobState", + "f36dc7ce-cac0-0667-dc14-a3704eb5e676.completionTime", + "dishwasher_completion_time", + "f36dc7ce-cac0-0667-dc14-a3704eb5e676_main_dishwasherOperatingState_completionTime_completionTime", + ), + ( + "da_wm_dw_000001", + SENSOR_DOMAIN, + "f36dc7ce-cac0-0667-dc14-a3704eb5e676_main_dishwasherOperatingState_dishwasherJobState_dishwasherJobState", + "f36dc7ce-cac0-0667-dc14-a3704eb5e676.completionTime", + "dishwasher_completion_time", + "f36dc7ce-cac0-0667-dc14-a3704eb5e676_main_dishwasherOperatingState_completionTime_completionTime", + ), ( "da_wm_wd_000001", SENSOR_DOMAIN, @@ -561,6 +593,22 @@ async def test_entity_unique_id_migration( "dryer_machine_state", "02f7256e-8353-5bdd-547f-bd5b1647e01b_main_dryerOperatingState_machineState_machineState", ), + ( + "da_wm_wd_000001", + SENSOR_DOMAIN, + "02f7256e-8353-5bdd-547f-bd5b1647e01b.dryerJobState", + "02f7256e-8353-5bdd-547f-bd5b1647e01b.completionTime", + "dryer_completion_time", + "02f7256e-8353-5bdd-547f-bd5b1647e01b_main_dryerOperatingState_completionTime_completionTime", + ), + ( + "da_wm_wd_000001", + SENSOR_DOMAIN, + "02f7256e-8353-5bdd-547f-bd5b1647e01b_main_dryerOperatingState_dryerJobState_dryerJobState", + "02f7256e-8353-5bdd-547f-bd5b1647e01b.completionTime", + "dryer_completion_time", + "02f7256e-8353-5bdd-547f-bd5b1647e01b_main_dryerOperatingState_completionTime_completionTime", + ), ( "da_wm_wm_000001", SENSOR_DOMAIN, @@ -577,6 +625,22 @@ async def test_entity_unique_id_migration( "washer_machine_state", "f984b91d-f250-9d42-3436-33f09a422a47_main_washerOperatingState_machineState_machineState", ), + ( + "da_wm_wm_000001", + SENSOR_DOMAIN, + "f984b91d-f250-9d42-3436-33f09a422a47.washerJobState", + "f984b91d-f250-9d42-3436-33f09a422a47.completionTime", + "washer_completion_time", + "f984b91d-f250-9d42-3436-33f09a422a47_main_washerOperatingState_completionTime_completionTime", + ), + ( + "da_wm_wm_000001", + SENSOR_DOMAIN, + "f984b91d-f250-9d42-3436-33f09a422a47_main_washerOperatingState_washerJobState_washerJobState", + "f984b91d-f250-9d42-3436-33f09a422a47.completionTime", + "washer_completion_time", + "f984b91d-f250-9d42-3436-33f09a422a47_main_washerOperatingState_completionTime_completionTime", + ), ], ) async def test_entity_unique_id_migration_machine_state( From abbabc11d2669fc5fb11e2f842575953e6bbc561 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Thu, 27 Mar 2025 17:51:52 +0100 Subject: [PATCH 3142/3148] Update frontend to 20250327.0 (#141585) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index b78323488ae..ee4db01a6f3 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20250326.0"] + "requirements": ["home-assistant-frontend==20250327.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index d1e91fd8604..bff67afd0d4 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -38,7 +38,7 @@ habluetooth==3.37.0 hass-nabucasa==0.94.0 hassil==2.2.3 home-assistant-bluetooth==1.13.1 -home-assistant-frontend==20250326.0 +home-assistant-frontend==20250327.0 home-assistant-intents==2025.3.24 httpx==0.28.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 2d175156f98..ada1250e411 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1157,7 +1157,7 @@ hole==0.8.0 holidays==0.69 # homeassistant.components.frontend -home-assistant-frontend==20250326.0 +home-assistant-frontend==20250327.0 # homeassistant.components.conversation home-assistant-intents==2025.3.24 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b65ffc3be10..7cee75b2114 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -984,7 +984,7 @@ hole==0.8.0 holidays==0.69 # homeassistant.components.frontend -home-assistant-frontend==20250326.0 +home-assistant-frontend==20250327.0 # homeassistant.components.conversation home-assistant-intents==2025.3.24 From de1e06c39bce99f55ea36175e29cc1d76bc35836 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 27 Mar 2025 17:57:58 +0100 Subject: [PATCH 3143/3148] Revert "Promote after dependencies in bootstrap" (#141584) Revert "Promote after dependencies in bootstrap (#140352)" This reverts commit 376604096049ac2388a1c9d23c578402acbce0b5. --- homeassistant/bootstrap.py | 28 +++++++++++++++++----------- tests/test_bootstrap.py | 18 ++++++++---------- 2 files changed, 25 insertions(+), 21 deletions(-) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 962c7871028..02a3b8c8fcc 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -859,14 +859,8 @@ async def _async_set_up_integrations( integrations, all_integrations = await _async_resolve_domains_and_preload( hass, config ) - # Detect all cycles - integrations_after_dependencies = ( - await loader.resolve_integrations_after_dependencies( - hass, all_integrations.values(), set(all_integrations) - ) - ) - all_domains = set(integrations_after_dependencies) - domains = set(integrations) & all_domains + all_domains = set(all_integrations) + domains = set(integrations) _LOGGER.info( "Domains to be set up: %s | %s", @@ -874,8 +868,6 @@ async def _async_set_up_integrations( all_domains - domains, ) - async_set_domains_to_be_loaded(hass, all_domains) - # Initialize recorder if "recorder" in all_domains: recorder.async_initialize_recorder(hass) @@ -908,12 +900,24 @@ async def _async_set_up_integrations( stage_dep_domains_unfiltered = { dep for domain in stage_domains - for dep in integrations_after_dependencies[domain] + for dep in all_integrations[domain].all_dependencies if dep not in stage_domains } stage_dep_domains = stage_dep_domains_unfiltered - hass.config.components stage_all_domains = stage_domains | stage_dep_domains + stage_all_integrations = { + domain: all_integrations[domain] for domain in stage_all_domains + } + # Detect all cycles + stage_integrations_after_dependencies = ( + await loader.resolve_integrations_after_dependencies( + hass, stage_all_integrations.values(), stage_all_domains + ) + ) + stage_all_domains = set(stage_integrations_after_dependencies) + stage_domains &= stage_all_domains + stage_dep_domains &= stage_all_domains _LOGGER.info( "Setting up stage %s: %s | %s\nDependencies: %s | %s", @@ -924,6 +928,8 @@ async def _async_set_up_integrations( stage_dep_domains_unfiltered - stage_dep_domains, ) + async_set_domains_to_be_loaded(hass, stage_all_domains) + if timeout is None: await _async_setup_multi_components(hass, stage_all_domains, config) continue diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index ca75dc51c56..1fb87ac5ef6 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -252,8 +252,8 @@ async def test_setup_after_deps_all_present(hass: HomeAssistant) -> None: @pytest.mark.parametrize("load_registries", [False]) -async def test_setup_after_deps_in_stage_1(hass: HomeAssistant) -> None: - """Test after_dependencies are promoted in stage 1.""" +async def test_setup_after_deps_in_stage_1_ignored(hass: HomeAssistant) -> None: + """Test after_dependencies are ignored in stage 1.""" # This test relies on this assert "cloud" in bootstrap.STAGE_1_INTEGRATIONS order = [] @@ -295,7 +295,7 @@ async def test_setup_after_deps_in_stage_1(hass: HomeAssistant) -> None: assert "normal_integration" in hass.config.components assert "cloud" in hass.config.components - assert order == ["an_after_dep", "normal_integration", "cloud"] + assert order == ["cloud", "an_after_dep", "normal_integration"] @pytest.mark.parametrize("load_registries", [False]) @@ -304,7 +304,7 @@ async def test_setup_after_deps_manifests_are_loaded_even_if_not_setup( ) -> None: """Ensure we preload manifests for after deps even if they are not setup. - It's important that we preload the after dep manifests even if they are not setup + Its important that we preload the after dep manifests even if they are not setup since we will always have to check their requirements since any integration that lists an after dep may import it and we have to ensure requirements are up to date before the after dep can be imported. @@ -371,7 +371,7 @@ async def test_setup_after_deps_manifests_are_loaded_even_if_not_setup( assert "an_after_dep" not in hass.config.components assert "an_after_dep_of_after_dep" not in hass.config.components assert "an_after_dep_of_after_dep_of_after_dep" not in hass.config.components - assert order == ["normal_integration", "cloud"] + assert order == ["cloud", "normal_integration"] assert loader.async_get_loaded_integration(hass, "an_after_dep") is not None assert ( loader.async_get_loaded_integration(hass, "an_after_dep_of_after_dep") @@ -456,9 +456,9 @@ async def test_setup_frontend_before_recorder(hass: HomeAssistant) -> None: assert order == [ "http", - "an_after_dep", "frontend", "recorder", + "an_after_dep", "normal_integration", ] @@ -1577,10 +1577,8 @@ async def test_no_base_platforms_loaded_before_recorder(hass: HomeAssistant) -> assert not isinstance(integrations_or_excs, Exception) integrations[domain] = integration - integrations_all_dependencies = ( - await loader.resolve_integrations_after_dependencies( - hass, integrations.values(), ignore_exceptions=True - ) + integrations_all_dependencies = await loader.resolve_integrations_dependencies( + hass, integrations.values() ) all_integrations = integrations.copy() all_integrations.update( From 9f5d94046df2f35ddb82bb1bfd4701eb40e11fb3 Mon Sep 17 00:00:00 2001 From: Simon Lamon <32477463+silamon@users.noreply.github.com> Date: Thu, 27 Mar 2025 18:39:33 +0100 Subject: [PATCH 3144/3148] Fix typing error in NMBS (#141589) Fix typing error --- homeassistant/components/nmbs/sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/nmbs/sensor.py b/homeassistant/components/nmbs/sensor.py index 822b0236dd0..3552ac3c26d 100644 --- a/homeassistant/components/nmbs/sensor.py +++ b/homeassistant/components/nmbs/sensor.py @@ -360,7 +360,7 @@ class NMBSSensor(SensorEntity): attrs[ATTR_LONGITUDE] = self.station_coordinates[1] if self.is_via_connection and not self._excl_vias: - via = self._attrs.vias.via[0] + via = self._attrs.vias[0] attrs["via"] = via.station attrs["via_arrival_platform"] = via.arrival.platform From 1ad12d5945b9606fff2ea9c001f3b2185ad25323 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Thu, 27 Mar 2025 18:44:33 +0100 Subject: [PATCH 3145/3148] Bump aiowebdav2 to 0.4.3 (#141586) --- homeassistant/components/webdav/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/webdav/manifest.json b/homeassistant/components/webdav/manifest.json index 30028cb28c9..65940eccaf1 100644 --- a/homeassistant/components/webdav/manifest.json +++ b/homeassistant/components/webdav/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["aiowebdav2"], "quality_scale": "bronze", - "requirements": ["aiowebdav2==0.4.2"] + "requirements": ["aiowebdav2==0.4.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index ada1250e411..d8f5d1f3e42 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -422,7 +422,7 @@ aiowaqi==3.1.0 aiowatttime==0.1.1 # homeassistant.components.webdav -aiowebdav2==0.4.2 +aiowebdav2==0.4.3 # homeassistant.components.webostv aiowebostv==0.7.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7cee75b2114..a0da1ac8b3a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -404,7 +404,7 @@ aiowaqi==3.1.0 aiowatttime==0.1.1 # homeassistant.components.webdav -aiowebdav2==0.4.2 +aiowebdav2==0.4.3 # homeassistant.components.webostv aiowebostv==0.7.3 From 51db140aed3cacb79d084ceca7c62223acf651ab Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Thu, 27 Mar 2025 20:30:16 +0100 Subject: [PATCH 3146/3148] Clean up Z-Wave config flow (#141595) --- .../components/zwave_js/config_flow.py | 20 +------------------ 1 file changed, 1 insertion(+), 19 deletions(-) diff --git a/homeassistant/components/zwave_js/config_flow.py b/homeassistant/components/zwave_js/config_flow.py index aed0dd839be..d95f3208e17 100644 --- a/homeassistant/components/zwave_js/config_flow.py +++ b/homeassistant/components/zwave_js/config_flow.py @@ -21,19 +21,16 @@ from homeassistant.components.hassio import ( ) from homeassistant.config_entries import ( SOURCE_USB, - ConfigEntriesFlowManager, ConfigEntry, ConfigEntryBaseFlow, ConfigEntryState, ConfigFlow, - ConfigFlowContext, ConfigFlowResult, OptionsFlow, - OptionsFlowManager, ) from homeassistant.const import CONF_NAME, CONF_URL from homeassistant.core import HomeAssistant, callback -from homeassistant.data_entry_flow import AbortFlow, FlowManager +from homeassistant.data_entry_flow import AbortFlow from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.hassio import is_hassio @@ -191,11 +188,6 @@ class BaseZwaveJSFlow(ConfigEntryBaseFlow, ABC): self.start_task: asyncio.Task | None = None self.version_info: VersionInfo | None = None - @property - @abstractmethod - def flow_manager(self) -> FlowManager[ConfigFlowContext, ConfigFlowResult]: - """Return the flow manager of the flow.""" - async def async_step_install_addon( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -355,11 +347,6 @@ class ZWaveJSConfigFlow(BaseZwaveJSFlow, ConfigFlow, domain=DOMAIN): self.use_addon = False self._usb_discovery = False - @property - def flow_manager(self) -> ConfigEntriesFlowManager: - """Return the correct flow manager.""" - return self.hass.config_entries.flow - @staticmethod @callback def async_get_options_flow( @@ -729,11 +716,6 @@ class OptionsFlowHandler(BaseZwaveJSFlow, OptionsFlow): self.original_addon_config: dict[str, Any] | None = None self.revert_reason: str | None = None - @property - def flow_manager(self) -> OptionsFlowManager: - """Return the correct flow manager.""" - return self.hass.config_entries.options - @callback def _async_update_entry(self, data: dict[str, Any]) -> None: """Update the config entry with new data.""" From 52f7bdeb5dbf6dba80ab7980bf44358770643fcb Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Thu, 27 Mar 2025 20:40:39 +0100 Subject: [PATCH 3147/3148] Patch Z-Wave platforms in fan tests (#141591) --- tests/components/zwave_js/test_fan.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/components/zwave_js/test_fan.py b/tests/components/zwave_js/test_fan.py index 2551fc7b34a..25ab6a87200 100644 --- a/tests/components/zwave_js/test_fan.py +++ b/tests/components/zwave_js/test_fan.py @@ -29,12 +29,19 @@ from homeassistant.const import ( STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN, + Platform, ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er +@pytest.fixture +def platforms() -> list[str]: + """Fixture to specify platforms to test.""" + return [Platform.FAN] + + async def test_generic_fan( hass: HomeAssistant, client, fan_generic, integration ) -> None: From 631f817f11f18a43bce226851ef35acfb0106625 Mon Sep 17 00:00:00 2001 From: Stephan Traub Date: Thu, 27 Mar 2025 20:51:42 +0100 Subject: [PATCH 3148/3148] Wiz - update dependency to support new light features and bugfixes (#141529) * Bump pywizlight and fix deprecation issue * Removed workaround for color_mode; update pywizlight --- homeassistant/components/wiz/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/wiz/manifest.json b/homeassistant/components/wiz/manifest.json index 7b1ecdcdb6b..947e7f0b638 100644 --- a/homeassistant/components/wiz/manifest.json +++ b/homeassistant/components/wiz/manifest.json @@ -26,5 +26,5 @@ ], "documentation": "https://www.home-assistant.io/integrations/wiz", "iot_class": "local_push", - "requirements": ["pywizlight==0.5.14"] + "requirements": ["pywizlight==0.6.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index d8f5d1f3e42..7b72e788a0c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2564,7 +2564,7 @@ pywemo==1.4.0 pywilight==0.0.74 # homeassistant.components.wiz -pywizlight==0.5.14 +pywizlight==0.6.2 # homeassistant.components.wmspro pywmspro==0.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a0da1ac8b3a..24c14f1ca89 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2080,7 +2080,7 @@ pywemo==1.4.0 pywilight==0.0.74 # homeassistant.components.wiz -pywizlight==0.5.14 +pywizlight==0.6.2 # homeassistant.components.wmspro pywmspro==0.2.1